🤖 fix: gate bash monitor wake delivery on the settled shown-frontier#3691
Conversation
|
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: eb9ddc5e31
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
|
@codex review Addressed the P2 ( Root cause: Fix: added a contiguity guard in |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: f1ceabfcfa
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
|
@codex review Addressed the P2 ( Process IDs are derived from Fix: the emitting process's |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 55ab568275
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
|
@codex review Addressed the P2 ( The match-merge branch previously kept the pending record's old lines but overwrote its Fix: a record is drain-suppressible only while its lines belong to one instance. The merge now carries the offset and instance token forward only when both sides share the same |
|
Codex Review: Didn't find any major issues. Chef's kiss. Reviewed commit: ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
If Codex has suggestions, it will comment; otherwise it will react with 👍. Codex can also answer questions or update the PR. Try commenting "@codex address that feedback". |
A background bash monitor could wake the agent about output (e.g. "ALL DONE exit=0") that a concurrent task_await had already returned inline. PR #3663 added the right offset primitives but enforced the "don't re-report shown output" invariant only once, at emit time (emitMonitorMatch). The monitor exit-flush and the task_await read are independent async loops, so on process exit the flush can win the race against the read that advances shownThroughOffset, emit the wake, and persist it -- then the drain delivers it later with no re-check. Move the authoritative check to delivery. The match's matchedThroughOffset now travels through the payload into the persisted wake record; drainBashMonitorWakes re-checks it against the settled shown-frontier (getSettledShownThroughOffset, which awaits any in-flight unfiltered read) and supersedes any record already shown. The emit-time check is demoted to a cheap fast-path early-out. Legacy records without the offset and managers without the query fail open (deliver). --- _Generated with `mux` • Model: `anthropic:claude-opus-4-8` • Thinking: `xhigh` • Cost: `$5.81`_ <!-- mux-attribution: model=anthropic:claude-opus-4-8 thinking=xhigh costs=5.81 -->
Codex flagged that the drain-time suppression could discard the only wake for output a filter dropped: outputBytesRead is a shared cursor, so a filtered getOutput advances it past a matched complete line without advancing shownThroughOffset. A subsequent unfiltered read with no new output then computed the frontier from that already-advanced cursor, jumping the gap and marking the filtered-out line as shown. Add a contiguity guard: only advance shownThroughOffset when this read's processable region begins at or before the current frontier. A gap left by a prior filtered read pins the frontier low, which is safe -- it can only over-wake, never suppress a genuinely-unshown line.
39f21f1 to
0f60389
Compare
|
@codex review Rebased onto latest |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 0f603894b6
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
The delivery gate needs two things per pending wake: the matched byte offset to compare against the settled shown-frontier, and the identity of the process instance that produced it (process IDs are display-name-derived and reclaimed across restarts, so a relaunched process must not suppress a prior instance's undelivered output). Rather than persist a separate processStartTime token and reconcile it in a cross-generation merge branch, bind the check to the record's existing createdAt: the originating instance necessarily started before its first match created the record, so any live process whose startTime is after createdAt reused the ID and the gate fails open. This drops the processStartTime field (payload/record/schema) and the entire cross-generation merge special-casing; the merge just takes the max offset. matchedThroughOffset stays on the record (a live read can't replace it: it must stay consistent with the record's snapshotted lines, or a merge race re-reports already-shown output). It is the only field this gate adds; the schema is relaxed from .strict() to .strip() so an older parser drops it rather than rejecting the whole record, and this build tolerates future additive fields. The residual downgrade cost is one in-flight nightly wake mid-drain, self-recovering on re-upgrade (stable v0.27.0 has no wake store).
0f60389 to
f1ccfbc
Compare
|
Addressed the downgrade finding (thread The delivery gate needed two things per wake: the matched offset, and the identity of the process instance that produced it (IDs are reclaimed across restarts). The earlier revision persisted a That leaves |
|
@codex review Reworked per the downgrade finding: removed the |
|
Codex Review: Didn't find any major issues. Hooray! Reviewed commit: ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
If Codex has suggestions, it will comment; otherwise it will react with 👍. Codex can also answer questions or update the PR. Try commenting "@codex address that feedback". |
Summary
A background bash monitor could wake the agent about output it had already been shown. In the reported case a monitor armed with
/ALL DONE|FAIL|error/fired a synthetic "monitor matched" wake forALL DONE exit=0after a concurrenttask_awaithad already returned that exact line in its completed report. This moves the "don't re-report shown output" invariant from emit time to delivery time, where it can be enforced against a settled view of what the agent has seen.Background
PR #3663 introduced the correct primitives — two absolute file offsets,
proc.shownThroughOffset(end of the last complete line an unfiltered read delivered to the agent) andmonitor.matchedThroughOffset(end of the last matched line) — but it compared them exactly once, insideemitMonitorMatch. The monitor tail loop andtask_await's read run as independent async loops. On process exit the monitor flushes immediately (bypassing the cooldown), so that exit-flush can win the race against the read that advancesshownThroughOffset: the comparison sees a stale frontier, emits the wake, and persists it.drainBashMonitorWakesthen delivers it later — possibly seconds afterward, at idle — with no re-check. This is exactly the common terminal case: a process prints its finalDONE/FAILline and exits while atask_awaitis reading it.Implementation
"A wake must not re-report shown output" is a property of delivery, not of matching, so the authoritative check now lives at the single point where a wake becomes a transcript message.
matchedThroughOffsettravels throughMonitorMatchPayloadinto the persistedBashMonitorWakeRecord(optional on the record/schema so older on-disk records keep parsing).BackgroundProcessManager.getSettledShownThroughOffset(processId, originNotAfterMs?)reports the shown-frontier after any in-flight unfiltered read settles.getOutputregisters a settled-promise for unfiltered reads only — filtered reads never advance the frontier and a filtered long-poll can hold the lock for its full timeout, so gating on them would stall delivery for nothing.drainBashMonitorWakesre-checks each match record against that settled frontier and supersedes any record already shown, dropping it from the batch (and returning early if the batch empties). The emit-time check is retained as a cheap fast-path early-out.Delivery-time correctness edge cases
Two ways the settled frontier could otherwise mislead the gate, both handled here:
outputBytesReadis advanced by filtered reads too, which drop non-matching complete lines without showing them. A filtered read that consumed a matched line, followed by an unfiltered read with no new output, previously letshownThroughOffsetjump the gap and falsely mark the dropped line as shown.getOutputnow advances the frontier only when the read's processable region is contiguous with it; a gap pins the frontier low, which can only over-wake, never suppress genuinely-unshown output.display_nameand reclaimed when a relaunched process lands on an empty manager, so a pending wake for a dead instance could otherwise be superseded by an unrelated newer process that shares the ID and has been read past the old match. Rather than persist a separate instance token, the gate binds the check to the record's owncreatedAt: the originating instance necessarily started before its first match created the record, sogetSettledShownThroughOffsetreturnsundefined(fail open) whenever the live process'sstartTimeis aftercreatedAt— i.e. it reused the ID. A cross-generation merge therefore needs no special handling: it simply takes the max offset, and thecreatedAtbinding makes the whole record deliver against a newer instance, so a dead instance's undelivered lines are never dropped.Fail-open is preserved everywhere: legacy records without
matchedThroughOffsetand managers that don't implement the query still deliver, matching prior behavior.Downgrade safety
matchedThroughOffsetis the only field this change adds to the persistedBashMonitorWakeRecord. To keep the record forward/backward tolerant, its Zod schema is relaxed from.strict()to.strip(), so this build never rejects a record written by a newer build that added a field. Downgrading to a build whose parser predatesmatchedThroughOffsetdrops an in-flight pending wake as malformed, but the file is not deleted, so re-upgrading recovers it; the loss is bounded to nightly builds mid-drain (stablev0.27.0ships no wake store at all).Risks
Low-to-moderate; scoped to background bash monitor wake delivery. The gate only ever suppresses a wake when the settled shown-frontier is at or past the match and the live process is the same instance that produced it (per the
createdAtbinding), so the worst-case failure mode is a missed wake rather than a spurious one — and that only triggers when the agent has provably already seen the output. All other paths (filtered-read gaps, reused IDs, legacy records, missing query) fail open.Generated with
mux• Model:anthropic:claude-opus-4-8• Thinking:xhigh• Cost:$26.26