Skip to content

fix(inbox): claim pending messages atomically to stop duplicate delivery - #709

Open
AmirF194 wants to merge 18 commits into
awslabs:mainfrom
AmirF194:fix/406-inbox-atomic-claim
Open

AmirF194 wants to merge 18 commits into
awslabs:mainfrom
AmirF194:fix/406-inbox-atomic-claim

Conversation

@AmirF194

Copy link
Copy Markdown

Fixes #406.

deliver_pending() read a terminal's PENDING inbox rows with get_pending_messages(), then marked them DELIVERED right before send_input(). That is a read-then-write gap, not an atomic claim: the status-event path, the immediate-delivery path, and the retry sweep can all call deliver_pending() for the same terminal, so two of them can read the same PENDING row before either one marks it, and both deliver it.

claim_pending_messages() (in database.py) replaces the read with a single UPDATE inbox SET status = 'delivered' WHERE id IN (SELECT ... WHERE status = 'pending' ORDER BY created_at LIMIT n) RETURNING .... That statement both picks and marks the rows atomically, so only one caller's UPDATE matches each row; every other caller finds it already DELIVERED and gets nothing back for it. deliver_pending() now calls this instead of the old read-plus-loop, and the existing PENDING/FAILED reset paths on a send failure are untouched.

I confirmed every entry point into inbox delivery goes through deliver_pending() (the status-event consumer, poll_opencode_pending_messages(), and both call sites in api/main.py), so the atomic claim covers the whole surface described in the issue, not just one path into it.

Verification

  • Added test_claim_pending_messages_only_one_caller_wins and test_claim_pending_messages_concurrent_threads_never_double_claim (two real OS threads racing the same rows over an on-disk SQLite file), both fail against the old get_pending_messages() read-based approach and pass against claim_pending_messages().
  • Updated the existing deliver_pending() unit tests (test_inbox_service.py, test_plugin_event_emission.py) that asserted the old DELIVERED-then-maybe-reset sequence.
  • Ran the full suite in Docker on Python 3.10 and 3.12 (the CI matrix's extremes): no new failures against a same-container baseline on main. I did not verify the three TestSessionOwnershipIntegration cases or the test_manifest_freeze/test_git_baseline/test_path_validation cases either way, since they fail identically with and without this diff in a plain python:3.1x-slim container (missing tmux, running as root, and so on), unrelated to this change.
  • black, isort clean on the changed files; mypy reports no new issues.

Copilot AI lite review requested due to automatic review settings August 30, 2026 07:42

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@codecov-commenter

codecov-commenter commented Aug 31, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 96.06742% with 7 lines in your changes missing coverage. Please review.
⚠️ Please upload report for BASE (main@aacd835). Learn more about missing BASE report.

Files with missing lines Patch % Lines
src/cli_agent_orchestrator/clients/database.py 81.25% 3 Missing ⚠️
...c/cli_agent_orchestrator/services/inbox_service.py 98.24% 2 Missing ⚠️
.../cli_agent_orchestrator/services/status_monitor.py 95.12% 2 Missing ⚠️
Additional details and impacted files
@@           Coverage Diff           @@
##             main     #709   +/-   ##
=======================================
  Coverage        ?   91.91%           
=======================================
  Files           ?      207           
  Lines           ?    29422           
  Branches        ?        0           
=======================================
  Hits            ?    27043           
  Misses          ?     2379           
  Partials        ?        0           
Flag Coverage Δ
unittests 91.91% <96.06%> (?)

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@haofeif haofeif left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed exact head 75bc2fbe833a122574fef96c670ea8c001e3e752. The atomic UPDATE prevents two callers from claiming the same row, but it does not serialize delivery to one terminal. A deterministic two-thread reproduction with two queued rows reached two overlapping sends (paste:first, paste:second, then the two submits), so this still violates the one-message-per-IDLE-cycle contract and can merge or reorder tasks. Changes requested for the inline P1.

# Claim atomically (#164, #406): a concurrent deliver_pending call for this
# terminal can reach this point before this one commits, so only an atomic
# UPDATE, not a prior read, decides who delivers each message.
messages = claim_pending_messages(terminal_id, limit=limit)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Serialize the whole per-terminal delivery sequence\n\nThe atomic claim only arbitrates individual rows. If two callers both pass the preceding pending/status checks while the terminal is IDLE and at least two rows are queued, caller A claims the oldest row and caller B claims the next one; both then call send_input() concurrently. The real tmux sink performs separate paste, delay, and Enter operations with no delivery lock, so these calls can interleave. I reproduced paste:first, paste:second, enter:second, enter:first, with both rows left DELIVERED. That can concatenate/reorder tasks and consumes two messages in one IDLE cycle despite this service’s contract. Please hold a per-terminal lock across the pending read, status check, claim, send, and failure reset (or otherwise serialize that complete sequence), rather than only making the row claim atomic.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch, thanks. Pushed a fix: deliver_pending now holds a per-terminal lock across the whole read-check-claim-send-reset sequence, so two callers claiming different rows for the same terminal can no longer both call send_input concurrently. Other terminals are unaffected, each gets its own lock. Added a real two-thread regression test that fails without the lock and passes with it. Also rebased onto main to pick up the session-ownership PR that landed since this was opened.

deliver_pending() read the PENDING rows for a terminal, then marked them
DELIVERED right before send_input(). That read-then-write gap is not atomic:
the status-event path, the immediate-delivery path, and the retry sweep can
all reach the same receiver_id concurrently, so two callers can both read a
row while it is still PENDING and both deliver it.

claim_pending_messages() replaces the read with a single
UPDATE ... WHERE status = 'pending' ... RETURNING that both selects and
marks DELIVERED in one statement. Only one caller's UPDATE matches each row;
the other finds it already DELIVERED and gets nothing back for it. The
PENDING/FAILED reset paths on a send failure are unchanged.

Fixes awslabs#406
Copilot AI review requested due to automatic review settings August 31, 2026 02:03
@AmirF194
AmirF194 force-pushed the fix/406-inbox-atomic-claim branch from 75bc2fb to 7f13798 Compare August 31, 2026 02:03

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@haofeif haofeif left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new per-terminal lock fixes the byte-level overlap from the prior review, but a queued contender still sends the next message in the same cached-IDLE cycle. One blocking delivery issue therefore remains.

provider, "accepts_input_while_processing", False
)
if not eager_eligible:
with _terminal_delivery_lock(terminal_id):

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Coalesce concurrent delivery attempts instead of queuing them

This blocking lock prevents the two send_input() calls from overlapping, but every contender still runs the full delivery after it acquires the lock. send_input() returns immediately after submitting Enter; notify_input_sent() only arms a future status detection, while assume_processing_on_dispatch defaults to false and no provider overrides it. A caller that waited behind the first therefore sees the same cached IDLE, claims the next row, and pastes it before the first task has begun processing. The added regression test codifies this by requiring len(intervals) == 2. I reproduced two IDLE checks and paste:first, enter:first, paste:second, enter:second from two simultaneous callers. This removes byte interleaving but still consumes two messages in one IDLE cycle, so the second task can be buffered or interpreted during the first turn while both rows remain DELIVERED. Coalesce/return when delivery is already active (or track a dispatch generation/busy state under the lock), and leave the second row pending until a later ready event.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right, and I confirmed it by reading notify_input_sent/get_status directly. Without assume_processing_on_dispatch, notify_input_sent only arms the next PROCESSING detection, it does not flip the cached status itself, and status_monitor.get_status() keeps returning the pre-send IDLE/COMPLETED until real terminal output runs through StatusMonitor. So the lock only decided who goes first, both contenders still passed the same stale ready check.

Pushed a follow-up commit that adds a per-terminal busy marker alongside the lock. It is set right after a successful send_input and checked at the top of deliver_pending before touching the DB or status_monitor, so a second call for the same terminal coalesces and leaves its row PENDING instead of dispatching into the same cycle. The marker is cleared the moment InboxService.run() sees the next status event for that terminal (that only fires once the real pipeline has processed a genuine transition, so it cannot be fooled by the same stale value). It also expires after a short window on its own, so a terminal that stops producing any status event after the dispatch cannot get wedged shut against the orphaned-message reconcile sweep.

Extended the regression test: two simultaneous callers against a constantly-IDLE mocked status now assert exactly one send_input call and one row left PENDING, plus a follow-up test showing that row delivers once a later status event clears the marker, and a TestRun case confirming a PROCESSING event clears the marker even though it does not itself trigger delivery. Ran the targeted suite (test/services/test_inbox_service.py test/clients/test_database.py test/services/test_plugin_event_emission.py) in a clean container, 126/126 pass, and confirmed the new tests fail against the pre-fix code (they error on the missing busy-marker attribute, and reverting just the source changes reproduces the original two-send behavior). black and isort are clean on the diff.

The per-terminal lock added for awslabs#709 stops two callers from interleaving
their tmux paste/delay/Enter sequences, but a queued caller still ran the
full delivery the instant it got the lock. status_monitor.get_status()
keeps returning the cached IDLE/COMPLETED from before the first send:
notify_input_sent() only arms the next PROCESSING detection, it does not
flip the cached status itself, and the real detection needs actual
terminal output to run. A second caller that only checks status sees the
same stale ready value and dispatches its own message into a terminal
that has not started on the first one yet.

Add a per-terminal busy marker alongside the lock. It is set right after
a successful send_input and checked at the top of deliver_pending before
touching the DB or status_monitor, so a concurrent or immediately
following call coalesces and leaves its row PENDING for a later ready
event instead of consuming it into the same cycle. The marker is cleared
the instant InboxService.run() observes the next status event for that
terminal (any value: a published event proves the real pipeline ran a
genuine transition), and it also expires on its own after a short window
so a terminal that stops producing status events after the dispatch
cannot get wedged shut against the orphaned-message reconcile sweep.

Extends the concurrent-delivery regression test to assert exactly one
send_input call and one row left PENDING, adds a test proving that row
delivers once a later status event clears the marker, and a TestRun case
confirming a PROCESSING event clears the marker even though it does not
itself trigger delivery.
Copilot AI review requested due to automatic review settings August 31, 2026 05:05

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@haofeif haofeif left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed at exact head 49caa558d367349e99cd33783e3133d3add8b091. The busy marker fixes the simultaneous-contender reproduction, but it is not causally tied to a post-dispatch status transition: an older queued status event or the fixed timeout can remove it while the first turn is still active. The one-message-per-ready-cycle P1 therefore remains.

# now stale. Clear the busy marker before deciding whether to
# deliver, so a real ready event right behind a dispatch is never
# starved by its own dispatch (#709).
_clear_dispatch_active(terminal_id)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Only clear this guard for a post-dispatch transition

A status event in this queue is not necessarily newer than the dispatch that set _dispatch_active: the immediate API, OpenCode poller, and reconcile paths call deliver_pending() outside this consumer. If an IDLE event is already queued, one of those paths can send the first row and set the marker before run() consumes the older event; this line then clears the marker and the cached IDLE lets the event path claim and send the second row in the same cycle. I reproduced that exact ordering by queueing IDLE before the first deliver_pending() call, delaying run(), and observed first, second. Independently, the five-second expiry permits the same two sends with no status event at all when startup/output latency exceeds the window. Tie the guard to a status generation/sequence captured at dispatch (or move the source-of-truth status to busy atomically); neither an unsequenced event nor elapsed wall time proves the first turn has advanced.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch, and confirmed by reading the ordering directly: the busy marker had no way to tell a status event that predates the dispatch from one that follows it.

Pushed a fix that gives StatusMonitor a monotonic per-terminal transition counter (get_status_generation), published alongside each status event. deliver_pending now snapshots that counter when it marks the terminal busy, and run() only clears the marker when the incoming event's generation is strictly newer than the one recorded at dispatch time. A stale, already-queued event can no longer clear a marker set after it.

Added two focused regression tests: one that reproduces your exact ordering (a generation-1 event queued before a dispatch that also observes generation 1, marker must survive) and one confirming a genuinely later event (generation 2) still clears it, so the fix cannot regress into never clearing. Full targeted suite 159/159 green.

…transition

The busy marker set by deliver_pending was cleared by run() on ANY status
event for the terminal, including one already sitting in the queue before
the dispatch happened (the immediate API, OpenCode poller and reconcile
paths all call deliver_pending() outside this consumer). A stale queued
event could clear the marker before the real post-dispatch transition
arrives, reopening the window it exists to close.

status_monitor now exposes a monotonic per-terminal transition generation
(get_status_generation), published alongside each status event. The busy
marker records the generation at dispatch time, and run() only clears it
when the event's own generation is strictly newer.
Copilot AI review requested due to automatic review settings August 31, 2026 16:14

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@haofeif haofeif left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed at exact head 2d44ecdc2aabef516b6b4d6961f3959efeb972e2. The status-generation check fixes the pre-dispatch queued-event race from the previous review. The independent elapsed-time bypass remains: the guard still expires without evidence that the first turn advanced, so one blocking delivery issue remains.

if entry is None:
return False
dispatched_at, _generation_at_dispatch = entry
if time.monotonic() - dispatched_at < INBOX_DISPATCH_COALESCE_WINDOW_S:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Keep the guard until readiness is re-established

The generation check now prevents an older queued event from clearing the marker, but this branch still removes it solely because five seconds elapsed. There is no contract that a provider must emit output or a PROCESSING transition inside that window; after a slow/silent start, the cached status can still be the same pre-send IDLE. The next immediate delivery (and especially the five-second OpenCode poller) then claims and sends a second row during the first turn. I reproduced this on the new head with no status event: after advancing past the configured window, two deliver_pending() calls produced first, second against the unchanged IDLE/generation. A timeout may trigger recovery/revalidation, but elapsed time alone must not authorize another send; retain the guard until a newer transition proves the cycle advanced, or atomically move the source-of-truth status out of ready at dispatch.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right, and I reproduced it the same way: patching the window small and sleeping past it with the mocked status and generation held constant, deliver_pending sent twice.

Pushed a fix that removes the elapsed-time expiry entirely. The busy marker now only ever clears through the existing generation check, so a terminal that never produces another status event holds its remaining PENDING messages instead of risking a second send; the message that set the marker was already delivered. INBOX_DISPATCH_COALESCE_WINDOW_S is gone along with the time-based path.

Replaced the test that asserted the old expiry with one asserting the marker survives elapsed time, and added a regression test driving two deliver_pending calls with a real sleep past the old window and no status event, confirming only one send. Full targeted suite 129/129 pass; also confirmed the same scenario against the pre-fix code in a clean container produces two sends.

The busy marker set by deliver_pending expired on its own after
INBOX_DISPATCH_COALESCE_WINDOW_S (5s), even with no status event and an
unchanged generation. A provider is not contractually bound to emit output
or a PROCESSING transition inside that window, so a slow or silent start
left the cached status at the pre-send IDLE the whole time, and the next
caller (in particular the five-second OpenCode poller) dispatched a second
message into the same unconfirmed cycle.

_dispatch_active now stores only the generation recorded at dispatch and
is cleared exclusively by _clear_dispatch_active on a genuinely newer
transition; _is_dispatch_active no longer tracks or checks elapsed time.
INBOX_DISPATCH_COALESCE_WINDOW_S is removed along with it. A terminal whose
provider truly never produces another status event holds its remaining
PENDING messages rather than risk another interleaved send; the first
message that set the marker was already delivered.

Replaced the test asserting the old time-based expiry with one asserting
the marker survives elapsed time, and added a regression test driving two
deliver_pending calls with no status event across a real sleep past the
old window, confirming only one send happens. Verified against the
pre-fix code in a clean container that the same scenario (constant
patched small, real sleep past it) produces two sends. Full targeted
suite (test/services/test_inbox_service.py test/clients/test_database.py
test/services/test_plugin_event_emission.py) 129/129 pass; black/isort
clean.

Signed-off-by: Amir Fathi <amirfathi.me@gmail.com>
Copilot AI review requested due to automatic review settings September 1, 2026 03:22

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 6 out of 6 changed files in this pull request and generated 3 comments.

Comment on lines +1719 to +1723
def claim_pending_messages(receiver_id: str, limit: int = 1) -> List[InboxMessage]:
"""Atomically move up to `limit` PENDING messages for receiver_id to DELIVERED.

Picks the oldest PENDING rows and flips them to DELIVERED in a single
``UPDATE ... WHERE status = 'pending' ... RETURNING``, so concurrent callers
Comment on lines 143 to +146
event = await queue.get()
status_value = event["data"]["status"]
event_generation = event["data"]["generation"]
terminal_id = terminal_id_from_topic(event["topic"])
Comment on lines +631 to +635
def get_status_generation(self, terminal_id: str) -> int:
"""Current value of the per-terminal transition counter.

Lets a caller record "no genuine transition has happened yet" (by
snapshotting this value) and later tell a status event that predates
claim_pending_messages used UPDATE ... WHERE status = 'pending' ... RETURNING,
which needs SQLite 3.35+ and is explicitly avoided elsewhere in this codebase
for exactly that reason (workflow_journal.py's _connect docstring, TD-2).
Replaced with a BEGIN IMMEDIATE transaction: the write lock is taken before
the SELECT runs, so no other writer's claim can interleave between the pick
and the flip, without depending on RETURNING to report which rows changed.

InboxService.run() also indexed event["data"]["generation"] directly, but
ApprovalBridge publishes to the same terminal.*.status topic without a
generation field, so that event shape is already live in this codebase, not
hypothetical. Defaults to 0 instead of raising; a missing generation just
can't confirm a dispatch marker, it doesn't drop the status event.

StatusMonitor.clear_terminal()/reset_buffer() popped every other per-terminal
dict but left _status_generations behind, leaking one entry per deleted
terminal and letting a stale count carry into a relaunched CLI mode on the
same terminal_id.

Signed-off-by: Amir Fathi <amirfathi.me@gmail.com>
…c-claim

Signed-off-by: Amir Fathi <amirfathi.me@gmail.com>

# Conflicts:
#	src/cli_agent_orchestrator/services/inbox_service.py
#	src/cli_agent_orchestrator/services/status_monitor.py
Copilot AI review requested due to automatic review settings September 1, 2026 04:41
@AmirF194

AmirF194 commented Sep 1, 2026

Copy link
Copy Markdown
Author

Copilot flagged three real gaps, all fixed here (head a90a05b):

  1. claim_pending_messages used UPDATE ... RETURNING, which needs SQLite 3.35+
    and this codebase explicitly avoids RETURNING elsewhere for that reason
    (workflow_journal.py's _connect docstring). Replaced it with a BEGIN IMMEDIATE
    transaction: the write lock is taken before the SELECT runs, so no other
    writer's claim can interleave, without needing RETURNING to report which rows
    changed. Verified with concurrent threads racing the claim in a clean
    container: each gets a distinct row, no duplicates.

  2. InboxService.run() indexed event["data"]["generation"] directly, but
    ApprovalBridge already publishes to the same terminal.*.status topic without
    one (see its own tests), so that shape is live, not hypothetical. Now
    defaults to 0 instead of raising.

  3. StatusMonitor.clear_terminal()/reset_buffer() popped every other
    per-terminal dict but left _status_generations behind. Now popped in both.

Also rebased onto main to pick up #712, which touched the same files and
added its own simpler per-terminal lock for a very similar race. Kept this
PR's atomic claim and generation-based coalescing (the parts your last four
rounds of review shaped) and dropped the now-redundant lock from #712, since
this design already covers what it did.

Full targeted suite (test/services/test_inbox_service.py
test/clients/test_database.py test/services/test_plugin_event_emission.py
test/services/test_status_monitor.py) 182/182 pass; full suite matches main's
own 21 pre-existing environmental failures (no git binary, root-owned HOME)
with nothing new. black/isort clean.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 6 out of 6 changed files in this pull request and generated 3 comments.

Comment on lines +119 to +121
with _delivery_registry_guard:
lock, count = _delivery_locks.get(terminal_id, (threading.Lock(), 0))
_delivery_locks[terminal_id] = (lock, count + 1)

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed, fixed at b14d9a5: only construct the Lock when the terminal key is absent instead of on every call.

Comment on lines +145 to +150
# Other publishers share this topic (e.g. ApprovalBridge) and don't
# carry a generation, so default to 0 rather than raise: that never
# clears a real dispatch marker early, it just leaves this one
# event unable to confirm one (see _clear_dispatch_active).
event_generation = event["data"].get("generation", 0)
terminal_id = terminal_id_from_topic(event["topic"])

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed, fixed at b14d9a5. ApprovalBridge only subscribes to this topic; StatusMonitor is the sole publisher and always includes a generation. Reworded the comment to say that.

Comment on lines +1731 to +1734
with SessionLocal() as db:
raw_conn = db.connection().connection.dbapi_connection
raw_conn.execute("BEGIN IMMEDIATE")
try:

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tested this directly: db.connection() marks the SQLAlchemy Core transaction as begun logically, but with the pysqlite dialect it does not issue a real BEGIN to sqlite3 until the first statement actually executes (raw_conn.in_transaction reads False right after db.connection()). The manual BEGIN IMMEDIATE right after is therefore the first real transaction start, and the existing concurrent-claim tests (test_claim_pending_messages_concurrent_threads_never_double_claim) already exercise this path with real threads and pass. Not changing this; happy to revisit if you have a repro where it raises.

_terminal_delivery_lock's default-arg .get() constructed a fresh
threading.Lock() on every call regardless of whether one already
existed for the terminal. Only create one when the key is absent.

The generation-default comment named ApprovalBridge as a publisher
without a generation field; it only subscribes to this topic and
never publishes to it. StatusMonitor is the sole production publisher
and always includes a generation.
Copilot AI review requested due to automatic review settings September 1, 2026 05:50
@AmirF194

AmirF194 commented Sep 2, 2026

Copy link
Copy Markdown
Author

Fixed at 59490cd: clear_terminal and reset_buffer now run a teardown hook that drops inbox_service's dispatch-active marker, so a reset inside the unconfirmed window no longer strands the terminal. The same hook reaps the marker on terminal deletion, closing the leak you flagged alongside it.

Left the eager-delivery throttling question, the doc update, and the remaining test-hygiene nits for a follow-up. None of them are correctness issues on their own, and I'd rather keep this round scoped to the blocking one.

183 baseline tests plus 4 new ones (clear_terminal, reset_buffer, the leak, and the reset-mid-dispatch end to end case) pass in a clean container. The new tests fail against the prior head, confirming the reproduction.

@haofeif haofeif left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed exact head 59490cdd. The new status check fixes the previously reported direct-completion case, and the reset hook fixes generation-counter teardown. One adjacent check-then-mark race still strands the inbox: a post-dispatch transition can be published and consumed after the final generation/status snapshot but before _dispatch_active is installed. I reproduced that ordering with two real pending rows; only the first send occurred and the second remained permanently coalesced. The focused existing set passes (51 tests), but it does not schedule this boundary.

Comment thread src/cli_agent_orchestrator/services/inbox_service.py Outdated
haofeif re-reviewed exact head 59490cd and found one more check-then-mark
window: the marker was armed from a post-dispatch generation read, so a
completion event landing between that read and _mark_dispatch_active could
be consumed by InboxService.run() while no marker existed yet, then this
call would install one for a generation the event had already confirmed.
Nothing left to arrive would ever clear it, coalescing every later message
to that terminal.

Arm the marker before send_input is called instead, using the pre-dispatch
generation. No status event caused by this dispatch can be published before
the marker exists, so the event-driven clear always has something to work
against. This also drops the post-dispatch generation comparison entirely:
the real transition, whenever it lands, clears the marker on its own. A
failed or unresolved send now aborts the marker explicitly in both except
branches, since nothing will confirm a dispatch that never reached the
terminal.

Reproduced the exact race from a fresh clone at 59490cd and added a
regression test for it plus a companion showing the old ordering strands
the marker. Docker-verified (python:3.12-slim): 189 passed across
test/services/test_inbox_service.py, test/clients/test_database.py,
test/services/test_plugin_event_emission.py and
test/services/test_status_monitor.py; black --check clean on both changed
files.

Signed-off-by: Amir Fathi <amirfathi.me@gmail.com>
Copilot AI review requested due to automatic review settings September 2, 2026 16:20

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@AmirF194

AmirF194 commented Sep 2, 2026

Copy link
Copy Markdown
Author

Fixed at 027cd13: the marker now arms before send_input is called (with the pre-dispatch generation), not after. That closes the window you found, since no status event caused by this dispatch can be published before the marker exists, so InboxService.run() always has something to clear against. Dropped the post-dispatch generation comparison entirely since it is no longer needed, and added an explicit abort on a failed or unresolved send so a dispatch that never reached the terminal cannot leave a marker behind either.

Added a regression test that reproduces your exact ordering (event consumed while still inside send_input) plus one that replays the old ordering directly to show it strands the marker.

@haofeif haofeif left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed exact head 027cd13b. Pre-arming fixes the reported post-send check-then-mark race, but two independently reproduced marker-ownership gaps remain: a pre-dispatch status event can clear the marker before input reaches the terminal (P1), and failure of a later sender group can remove protection for an earlier successful send (P2). The focused inbox suite passes (40 tests) but does not cover either ordering.

Comment thread src/cli_agent_orchestrator/services/inbox_service.py Outdated
Comment thread src/cli_agent_orchestrator/services/inbox_service.py Outdated
@AmirF194

AmirF194 commented Sep 3, 2026

Copy link
Copy Markdown
Author

Both are real, and the shared cause is the same one that produced the last few rounds: _dispatch_active is a single slot per terminal_id, and neither gap has a fix that fits inside a single slot.

P1 needs two phases, not one snapshot: an early "busy" marker (armed before prep, so _is_dispatch_active blocks concurrent callers immediately) that carries no confirmable generation yet, and a separate "confirmed" generation written only after send_input actually crosses into the backend write. Anything that arrives while still in the first phase gets ignored for clearing, not compared against.

P2 needs the slot to hold more than one outstanding attempt: within one deliver_pending call, groups in a multi-message batch dispatch sequentially without waiting for each other's confirmation, so two of them can be genuinely unconfirmed at once for the same terminal. A single overwritable slot can't represent that regardless of token-gating the abort; the abort in a failing group needs to know it only owns its own attempt, which means the registry has to key on attempt, not just terminal_id.

I don't want to ship a ninth version of this dict shape as a quick patch. This is a redesign (a per-terminal set of outstanding attempts, each with its own confirm state), and I'd rather do it as its own pass and validate it the way the last several rounds were, with concurrent tests pinning each ordering, than patch around the current struct again.

…ake abort attempt-specific

haofeif reviewed the exact head 027cd13 and found two more races.

First (P1): the marker's confirming generation was read BEFORE send_input
was even called. An event about output that predates this dispatch, queued
while send_input is still preparing metadata/status/provider work, could
already exceed that pre-dispatch snapshot and clear the marker before the
input actually crossed the backend dispatch boundary. A second caller then
found the terminal not busy and sent into it while the first message was
still being typed.

Second (P2): with num_messages > 1, sequential sender groups under one
terminal lock shared a single marker slot. If group 1's send succeeded and
group 2's then failed, group 2's abort unconditionally cleared the slot,
wiping the protection still owed to group 1's successful-but-unconfirmed
dispatch.

Both come from the same underlying gap: one shared (terminal_id -> generation)
slot can't distinguish "not yet confirmable" from "confirmed", or one
attempt from another. Replaced it with a list of (token, boundary) entries
per terminal. Arm now records no boundary at all; only once send_input
returns does a confirm set the boundary to the post-dispatch generation, so
an event that landed before that point can never satisfy it. Abort and
confirm both take the token _arm_dispatch_active returned, so a failed
sender group only ever touches its own entry.

Reproduced both races directly against the prior single-slot code (a mock
send_input clearing mid-call, and a two-sender-group batch where the second
group's send raises) and added regression tests for each, plus a companion
confirming a real post-send transition still clears the marker normally.
_mark_dispatch_active keeps its existing single-step behavior for the
callers (mainly tests) that already know the confirming generation up
front.

Docker-verified (python:3.12-slim): 42 passed in
test/services/test_inbox_service.py; black and isort clean on both changed
files.

Signed-off-by: Amir Fathi <amirfathi.me@gmail.com>
…c-claim

# Conflicts:
#	src/cli_agent_orchestrator/clients/database.py
#	test/clients/test_database.py
Copilot AI review requested due to automatic review settings September 8, 2026 20:08

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@AmirF194

AmirF194 commented Sep 8, 2026

Copy link
Copy Markdown
Author

Both fixed at e155e2b (rebased onto main at 6108d39). Replaced the single generation slot with a list of (token, boundary) attempts. Boundary stays None from arm until send_input returns, so an event consumed during dispatch preparation can no longer confirm a send that hasn't happened yet. Abort and confirm both take the token from arm, so a failed sender group only ever touches its own entry, never a sibling group's still-unconfirmed one.

Reproduced both races against the prior code and added a regression test for each, plus one confirming a real post-send transition still clears normally. 205 passed (inbox + database + related suites) in a clean container, black and isort clean on the whole tree.

@haofeif haofeif left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed exact head 6108d39066e2fa56dc1cf1da924e41a295d5396f against base ce18db239553407c01a817aba52129fc54657818, including the follow-up to the previously reviewed 027cd13b1aaae06bbb9b15f2740edc2cf93e6959.

Both latest findings are fixed: pre-dispatch status activity no longer removes the newly armed protection, and a later sender-group failure no longer removes ownership belonging to an earlier successful send. Permanent and one-shot transient failure controls preserve the earlier attempt.

P1 - Dispatch confirmation loses genuine post-write completion

At inbox_service.py:394-396, confirmation samples the generation only after send_input() returns. That is later than the actual backend write: terminal_service.send_input() still updates last-active metadata after send_keys() (2346-2356), before returning. A genuine completion can arrive in that interval.

If the event is consumed before confirmation, its attempt still has a None boundary and the clearing path retains it without remembering the event. If consumed afterward, confirmation has recorded the already-completed generation, so the event fails the strictly-newer comparison. There is a second manifestation of the same handshake defect when completion is consumed between the generation snapshot and _confirm_dispatch_active() storing it.

Deterministic reproduction with real SQLite claims, terminal_service.send_input, StatusMonitor, EventBus, and the real inbox consumer leaves two rows as DELIVERED, PENDING, with the terminal COMPLETED and its marker still active. Explicit delivery retries and orphan reconciliation do not release the later row; it remains suppressed until unrelated status activity or reset. This also reproduces with a genuine PROCESSING-to-COMPLETED sequence during the post-write interval, not just direct completion. Completion after confirmation works normally, and the no-transition control remains conservatively guarded. Synthetic processing on dispatch was disabled.

Preserve the early busy guard and attempt-specific ownership, but tie the handshake to the actual dispatch boundary and atomically retain/reconcile genuine post-write transitions observed during confirmation. Simply discarding preparation-time events until the entire send_input() call returns loses legitimate completions. Cover both sides of the write boundary and the snapshot-to-confirmation gap.

This is one consolidated P1 regression introduced by the follow-up, continuing the existing direct-completion finding, not two new independent blockers. The previous inbox implementation delivers both rows in the post-write comparison cases; that differential isolates the previous inbox module with current dependencies, rather than claiming a full previous-checkout baseline.

All 104 focused cases pass, but they do not catch this ordering. The corrected diagnostic harness covers 17 scenarios. Historical feedback was reviewed; no additional supported blocker was established. No current-head check-run/status results were available, so this is not a claim of passing GitHub CI.

…ration

A genuine completion can land and be consumed by InboxService.run()
while send_input is still executing (it keeps working after
send_keys, e.g. updating last-active metadata), i.e. while the
marker's boundary is still None and the event cannot confirm it.
Reading a fresh post-call generation and arming it as the boundary
then needs something strictly newer than an event that already
happened and will not repeat, stranding the marker forever.

Compare the post-call generation against the one read at arm time
instead: if it already moved, resolve the attempt immediately rather
than arming an unreachable boundary.

Closes the eleventh round of awslabs#709 (haofeif).

Signed-off-by: Amir Fathi <amirfathi.me@gmail.com>
Copilot AI review requested due to automatic review settings September 9, 2026 05:52

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@haofeif haofeif left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed 8f17ec5, base ce18db2, against the previously reviewed 6108d39.

Changes requested: one P1, a regression of the existing pre-dispatch finding—not a new independent issue.

[P1] Do not use preparation-time activity to confirm the dispatch

At inbox_service.py:196-198, current_generation > pre_dispatch_generation also includes transitions that happen BEFORE the backend write. With cached IDLE at generation 1, let the real monitor observe COMPLETED at generation 2 during get_terminal_metadata() or inject_memory_context(), and let InboxService.run() consume it while the attempt still has a None boundary. The first backend send then succeeds, with no subsequent activity. Confirmation nevertheless removes its token because 2 > 1; the queued consumer or next drain can send the second row using that same stale COMPLETED status.

The same diagnostic against the complete previous and current checkouts gives:

Ordering 6108d390 8f17ec53
Completion before the first backend write, during metadata lookup or memory preparation One write; DELIVERED, PENDING Two writes; DELIVERED, DELIVERED — regression
Genuine completion after the backend write but before send_input() returns, with early or late event consumption Next row stranded Next row delivered — fixed
Genuine completion in the old snapshot-to-confirmation window Next row stranded Next row delivered — fixed

The existing pre-dispatch regression calls _clear_dispatch_active(..., 1) but leaves the mocked monitor generation at 0. It therefore does not exercise the counter advance that makes the new confirmation branch remove the marker. Cover this with the real generation update and a second inbox row, alongside the genuine post-write case.

Neither entry to nor return from send_input() is the actual dispatch boundary: it contains both preparation and post-write bookkeeping. Keep preparation-time events from confirming the attempt while retaining genuine post-write events for atomic confirmation. Moving a blind snapshot back to either outer boundary trades one of these races for the other.

This reopens the existing pre-dispatch P1. The previous direct-completion finding is fixed in the reproduced orderings. Permanent and one-shot transient later sender-group failures still preserve the earlier successful attempt; no-transition coalescing, subsequent completion, and reset recovery also remain intact.

Evidence: 105 focused repository tests passed; the ten-scenario diagnostic ran against both complete heads using real SQLite claims, StatusMonitor, EventBus, InboxService.run(), and terminal_service.send_input(), with external I/O and timing controlled and synthetic PROCESSING disabled. GitHub CI, Secret Scan, and cargo-deny currently report action_required, not successful checks.

…ion, not the pre-call one

The eleventh-round fix read the boundary generation before send_input was
even called, then compared it against a post-call read to detect a
transition landing during the call. That window includes send_input's own
prep (get_terminal_metadata, inject_memory_context), which runs before the
actual backend write. A transition observed during prep predates this
dispatch and is not evidence of a response to it, so the comparison could
mistake leftover prep-window activity for confirmation and drop the marker
before the terminal had actually started on the message.

Have status_monitor.notify_input_sent snapshot the generation itself, right
where send_input calls it: after prep, immediately before the write.
InboxService now confirms against that snapshot instead of one taken at
arm time. A transition during prep is already folded into it and cannot
register as newer; only a transition at or after the write can.

Closes the twelfth round of awslabs#709 (haofeif).

Signed-off-by: Amir Fathi <amirfathi.me@gmail.com>
Copilot AI review requested due to automatic review settings September 9, 2026 09:45

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@haofeif haofeif left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed 0d36424 against base ce18db2, comparing the complete previous 8f17ec5 checkout.

Changes requested: the same existing pre-dispatch P1 is only partially addressed. No new independent finding.

The metadata-lookup and memory-injection cases are now fixed. However, notify_input_sent() is still before, not atomic with, the actual backend write. After the snapshot at status_monitor.py:611, the real send_input path still clears the rolling buffer and marks provider input. The backend also does work before application delivery: TmuxClient.send_keys loads a buffer before pasting it.

Let the real monitor observe COMPLETED at generation N+1 after notify_input_sent() captured N, but while load-buffer is running, before any paste-buffer or Enter call. The inbox consumer consumes that event while the attempt boundary is None. The first paste and submission then finish with no subsequent status transition. Confirmation still removes the token because N+1 > N, allowing the queued delivery to claim and send a second row against stale COMPLETED.

Transition ordering 8f17ec53 0d364240
During metadata lookup or memory injection, before notify Two pastes; both rows DELIVERED One paste; second row PENDING — fixed
After notify but before rolling-buffer clearing, during provider marking, or in load-buffer before paste Two pastes; both rows DELIVERED Same incorrect early release — P1 remains
Genuine completion after Enter while the backend call is still running, or during post-write bookkeeping Next row delivered Still correct

This uses real SQLite claims, StatusMonitor, EventBus, InboxService.run(), terminal_service.send_input(), TmuxBackend, and TmuxClient.send_keys(); only external I/O and scheduling were controlled. All three remaining pre-write placements observed zero paste and zero Enter calls at the status transition. Synthetic PROCESSING was explicitly disabled. No real tmux session was created.

The new fake send_input test reduces the send path to preparation -> notify -> success, so it omits precisely this remaining pre-paste interval. The fix needs to coordinate the dispatch boundary with status observation, rather than moving a blind snapshot to another earlier call. Preserve genuine post-write completion as well as pre-write protection; moving the snapshot after the whole send would reintroduce the previously fixed loss of completion.

Keeping the existing pre-dispatch P1 open. The prior completion-loss/snapshot-gap fixes, no-transition conservatism, reset recovery, and permanent/one-shot transient sender-group ownership controls remain intact. This is a standalone review against the stated base, not a dependency on another unmerged PR.

…c-claim

# Conflicts:
#	src/cli_agent_orchestrator/services/status_monitor.py
…he pane write

notify_input_sent's snapshot runs after send_input's prep but before
clear_rolling_buffer, provider.mark_input_received, and the backend's own
pre-write steps, so a transition landing in that window was still treated as
prep-window noise instead of a real response.

Threads a pre_write_hook through send_keys down to TmuxClient (fires between
load-buffer and paste-buffer) and HerdrBackend (fires immediately before
send-text); terminal_service.send_input passes
status_monitor.mark_pre_write as the hook, overwriting the earlier snapshot
at the last point before the pane is actually touched.

Closes the thirteenth round of awslabs#709 (haofeif).

Signed-off-by: Amir Fathi <amirfathi.me@gmail.com>
Copilot AI review requested due to automatic review settings September 11, 2026 15:13

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@AmirF194

Copy link
Copy Markdown
Author

Found a further gap in the same window while re-reading the twelfth-round fix: notify_input_sent's snapshot lands after send_input's prep but before clear_rolling_buffer, provider.mark_input_received, and the backend's own pre-write steps, so a transition in that stretch still read as prep noise instead of a real response. Threaded a pre_write_hook through send_keys (TmuxClient fires it between load-buffer and paste-buffer, HerdrBackend immediately before send-text) so status_monitor can re-snapshot right before the pane is actually touched. Pushed at 06b1442.

@haofeif haofeif left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed 06b1442 against the exact ancestral main base aacd835, including comparison with the complete previously reviewed 0d36424 tree.

The previous pre-dispatch P1 is fixed and accepted. The transport hook now folds completion during buffer clearing, provider marking, and tmux load-buffer into the final pre-write snapshot. Those placements retain the first attempt and leave the second row PENDING, rather than prematurely delivering both. Genuine post-submit completion still releases the attempt with early or late event consumption, including completion while the backend call is finishing and during last-active bookkeeping. The metadata/memory, stale-ready-event, reset, and sender-group ownership repairs remain intact. I am resolving the existing pre-dispatch thread rather than carrying it forward.

One newly identified P2 remains: the shared guard is not connected to Herdr's native status producer. This is an integration gap introduced by the PR's guard, not a regression in the latest pre-write hook. The actual HerdrInboxService event loop calls deliver_pending on native ready events without advancing or publishing the generation that the guard requires. Native get_status() also returns the provider's result without recording that transition.

The composed native path uses the real socket-event JSON decoder, Codex provider/native-status resolver, Herdr backend, SQLite inbox, status monitor, and delivery service. After native working followed by done or idle, and after honoring the provider's documented completion flush wait, get_status() is COMPLETED but the head still has generation 0, one paste, and DELIVERED/PENDING rows. Repeated ready events, immediate delivery, and reconciliation cannot release it. The identical current-broadcast and supported legacy event cases deliver both rows against the exact base. CLI subprocess responses and time were controlled; this is not live authenticated provider acceptance.

The inline finding includes the correction direction. P2 reflects loss of automatic inbox progression on the optional Herdr backend; the queued messages are retained, and the tmux preparation-window repair is accepted.

with self._delivery_lock(terminal_id):
self._deliver_pending_locked(terminal_id, num_messages, registry)
with _terminal_delivery_lock(terminal_id):
if _is_dispatch_active(terminal_id):

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Connect Herdr's native progress to the dispatch guard

This gate stays closed after the first successful Herdr delivery. api/main.py:1308-1314 wires this service into HerdrInboxService, whose native-event loop calls _deliver() on idle/done (herdr_inbox_service.py:536-540) but never advances StatusMonitor's generation or publishes its status topic. The native branch of StatusMonitor.get_status() merely returns provider.get_status(). Consequently the first send leaves an active boundary of 0, and later native working/ready events cannot clear it; this early return also blocks the immediate and reconciliation paths.

With the real Herdr event decoder and Codex native-status path, after the documented 10-second flush wait the provider reports COMPLETED, yet the head retains DELIVERED/PENDING rows, one paste/two Enters, and generation 0. Current pane.updated working-to-done and working-to-idle sequences both behave this way. The identical sequences deliver both rows on base aacd835a.

Have genuine native busy/ready transitions participate in the shared sequenced confirmation mechanism before the delivery callback, retaining per-terminal serialization, stale-event rejection, and no-progress coalescing. Add coverage through HerdrInboxService._event_loop() and the real inbox service: manually injecting _apply_detection() events into a transport-only case hides this missing producer integration. Simply expiring or bypassing the guard would reintroduce the earlier stale-ready problem.

@AmirF194

Copy link
Copy Markdown
Author

Since your last review, I found and fixed two more gaps in the same dispatch-slot logic while re-reading the code (e155e2b, then a pre_write_hook fix). It's been quiet for about two weeks; flagging in case another look is due.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug] Duplicate inbox delivery: concurrent deliver_pending picks the same PENDING message twice (pick→mark window unprotected)

5 participants