From 2522affad3bbc624c41f1ca36c8453dddd91ee0f Mon Sep 17 00:00:00 2001 From: dnth Date: Mon, 21 Sep 2026 16:13:57 +0800 Subject: [PATCH 1/7] fix(omp): bound main-fallback in-flight wake marker at turn boundaries An idle-main wake injection drives agent.prompt() directly, which emits no before_agent_start and arrives as a custom-role message, so the existing consumeWake evidence (before_agent_start or a user message_start matching the wake text) never fired. mainFallbackWakeInFlight stayed set forever and every later main-bound wake was suppressed behind it. Treat the first turn_start after an accepted send as consumption, acknowledge the wake's own custom-role message_start (customType firstmate-watcher-wake), and bound the marker at turn_end: clear it when every send-time queue row has been drained or it has outlived a full turn boundary, so the rows-outlived successor still fires. The episode mirror persisted at state/extensions/omp-primary-watch/main-fallback-episode.state records the marker plus send/consume/boundary timestamps, and fm-guard.sh warns when an in-flight wake is older than a turn. --- .omp/extensions/fm-primary-omp.ts | 19 +- bin/fm-guard.sh | 21 ++- bin/fm-primary-watch-core.ts | 304 ++++++++++++++++++++++-------- docs/omp-supervision-branch.md | 7 +- tests/fm-omp-primary.test.sh | 220 ++++++++++++++++++++- 5 files changed, 487 insertions(+), 84 deletions(-) diff --git a/.omp/extensions/fm-primary-omp.ts b/.omp/extensions/fm-primary-omp.ts index 19d714264aa..1e4d7d5726b 100644 --- a/.omp/extensions/fm-primary-omp.ts +++ b/.omp/extensions/fm-primary-omp.ts @@ -367,6 +367,9 @@ export default function (omp: ExtensionAPI) { omp.on("turn_start", () => { taskInboxDoorbell.notifyTurnStart(); publishTaskTurnStarted(); + // An idle-main wake injection opens an agent-initiated turn that emits no + // before_agent_start; this turn boundary is its consumption evidence. + watch.turnStart(); // A turn can begin while no arm child is live - after an actionable close // whose successor restore failed, an exhausted continuity retry, or a lock // reclaimed without a follow-up arm - and only the turn-end guard would @@ -410,8 +413,20 @@ export default function (omp: ExtensionAPI) { }); omp.on("message_start", (event) => { - if (event.message.role !== "user") return; - watch.acknowledgeWake(userMessageText(event.message.content)); + const message = event.message; + if (message.role === "user") { + watch.acknowledgeWake(userMessageText(message.content)); + return; + } + // An idle-main injection is delivered as this custom message inside an + // agent-initiated turn that emits no before_agent_start, so its own + // message_start is the consumption evidence when the text matches a wake. + if ( + message.role === "custom" && + (message as { customType?: unknown }).customType === "firstmate-watcher-wake" + ) { + watch.acknowledgeWake(userMessageText(message.content)); + } }); omp.on("session_stop", async (event): Promise => { diff --git a/bin/fm-guard.sh b/bin/fm-guard.sh index 8196361c871..f9688e76023 100755 --- a/bin/fm-guard.sh +++ b/bin/fm-guard.sh @@ -22,8 +22,8 @@ # the beacon mtime, which a healthy between-turns watcher advances every poll); # later guarded commands in the same episode print a one-line reminder instead. # Episode state lives only under state/.guard-watcher-stale-banner (volatile, -# bounded). Independent alarms (queued wakes, worktree tangle) are never -# suppressed by that dedup. Normal wake handling (watcher briefly down between a +# bounded). Independent alarms (queued wakes, worktree tangle, a stale +# in-flight OMP fallback wake) are never suppressed by that dedup. Normal wake handling (watcher briefly down between a # wake and the next supervision resume) stays inside the grace window and stays # silent. The queued-wakes warning counts only the rows the calling actor can # itself present or retire (fm_wake_actor_pending_count), so it is never an @@ -162,6 +162,23 @@ if [ -n "$tangle_branch" ]; then } >&2 fi +# A still in-flight OMP main-fallback wake that has outlived a full turn +# boundary means the runtime never surfaced its consumption, so every later +# main-bound wake is being suppressed. The extension mirrors that marker to +# state/extensions/omp-primary-watch/main-fallback-episode.state; warn whenever +# a turn end is recorded after the send with the marker still set. +OMP_EPISODE_STATE="$STATE/extensions/omp-primary-watch/main-fallback-episode.state" +if [ -f "$OMP_EPISODE_STATE" ]; then + omp_inflight=$(sed -n 's/^in_flight=//p' "$OMP_EPISODE_STATE" | head -1) + omp_sent_ms=$(sed -n 's/^in_flight_sent_at_ms=//p' "$OMP_EPISODE_STATE" | head -1) + omp_turn_end_ms=$(sed -n 's/^last_turn_end_at_ms=//p' "$OMP_EPISODE_STATE" | head -1) + if [ "$omp_inflight" = 1 ] \ + && [[ "$omp_sent_ms" =~ ^[0-9]+$ ]] && [ "$omp_sent_ms" -gt 0 ] \ + && [[ "$omp_turn_end_ms" =~ ^[0-9]+$ ]] && [ "$omp_turn_end_ms" -gt "$omp_sent_ms" ]; then + echo "WARNING: an OMP main-fallback wake stayed in-flight across a full turn boundary without consumption - later main-bound wakes are suppressed; a session replacement clears the marker." >&2 + fi +fi + # Compute supervision need and watcher-beacon freshness via the shared # grace-based predicate (bin/fm-supervision-lib.sh). Act when work, an event # source, or an X-mode relay poll needs supervision. diff --git a/bin/fm-primary-watch-core.ts b/bin/fm-primary-watch-core.ts index 28c0ddd361c..9a7b6ae4cd7 100644 --- a/bin/fm-primary-watch-core.ts +++ b/bin/fm-primary-watch-core.ts @@ -15,14 +15,18 @@ // // Delivery versus consumption (stated once here): // A main follow-up is delivered once the runtime accepts it (sendFollowUp -// resolves). The successor pipeline never waits for the model to read it: a -// follow-up queued while main is streaming joins the running run without ever -// raising before_agent_start, so waiting on that event stalls every later close. -// Consumption is tracked only so a replacement can replay a follow-up the -// runtime had not consumed. An idle main consumes at before_agent_start; a -// streaming main consumes at the user message_start carrying the exact wake -// text; either event finishes the pending record, and a still-unconsumed record -// rides the replacement handoff. +// resolves). Consumption is tracked so a replacement can replay a follow-up +// the runtime had not consumed and so a coalescing episode can be retired. +// Consumption evidence is whichever surface the runtime emits: a streaming +// main consumes at before_agent_start or the user message_start carrying the +// exact wake text, while an idle-main injection opens an agent-initiated turn +// that never emits before_agent_start - there the first turn_start after the +// accepted send, or the wake's own custom-role message_start, is the evidence. +// Any of these finishes the pending record. An accepted wake whose in-flight +// marker outlives a full turn boundary, or whose send-time queue rows were +// drained, is also cleared as consumed at turn_end, so a missed consumption +// signal can never suppress every later wake. A still-unconsumed record rides +// the replacement handoff. // // The active generation and the process-exit fallback are process-wide, not // per-core-instance. @@ -125,6 +129,17 @@ type SessionGeneration = { pendingTurnEnd: boolean; mainFallbackWakeInFlight: string | null; mainFallbackBaselineRows: Set | null; + // Wedge evidence for an in-flight fallback wake whose consumption events the + // runtime may never emit (the idle-main injection path raises neither + // before_agent_start nor a user-role message_start): the queue rows pending + // when it was sent, the count of turn boundaries it has outlived, and the + // timestamps persisted for off-process diagnosis. + mainFallbackInFlightRows: Set | null; + mainFallbackInFlightTurnEnds: number; + mainFallbackWakeSentAtMs: number | null; + mainFallbackWakeConsumedAtMs: number | null; + lastTurnStartAtMs: number | null; + lastTurnEndAtMs: number | null; }; export type ArmResult = { @@ -166,6 +181,9 @@ export type PrimaryWatchCore = { markLoaded: () => void; sessionShutdown: (replacement?: boolean) => Promise; sessionStart: () => void; + // A main turn opened: positive consumption evidence for an accepted wake on + // runtimes where the injection path emits no before_agent_start. + turnStart: () => void; turnEnd: () => void; }; @@ -280,6 +298,12 @@ function createGeneration(): SessionGeneration { pendingTurnEnd: false, mainFallbackWakeInFlight: null, mainFallbackBaselineRows: null, + mainFallbackInFlightRows: null, + mainFallbackInFlightTurnEnds: 0, + mainFallbackWakeSentAtMs: null, + mainFallbackWakeConsumedAtMs: null, + lastTurnStartAtMs: null, + lastTurnEndAtMs: null, }; } @@ -337,6 +361,10 @@ export function createPrimaryWatchCore(options: PrimaryWatchCoreOptions): Primar const armScript = `${fmRoot}/bin/fm-watch-arm.sh`; const handoffDir = `${state}/extensions/${runtime}-primary-watch`; const actionableHandoff = `${handoffDir}/session-replacement-actionable.json`; + // Durable, diagnostic-only mirror of the main-fallback episode so a wedge is + // readable from disk (and fm-guard.sh can warn on an in-flight wake that has + // outlived a full turn boundary) instead of living only in this process. + const episodeStateFile = `${handoffDir}/main-fallback-episode.state`; let nextHandoffId = 0; let replacementHandoff: PendingActionableClose[] | null = null; let replacementCoordinator = replacementCoordinators.get(actionableHandoff); @@ -492,6 +520,53 @@ export function createPrimaryWatchCore(options: PrimaryWatchCoreOptions): Primar } } + // Best-effort atomic write of the episode mirror. Failures are swallowed: + // this file is diagnosis, never delivery authority. Nothing is written until + // an episode exists or a prior file needs its terminal state refreshed. + let episodeStateWrites = 0; + function persistEpisodeState(owner: SessionGeneration): void { + if (!coalesceMainFallbackWakes) return; + if ( + !owner.mainFallbackEpisode && + !owner.mainFallbackWakeInFlight && + !existsSync(episodeStateFile) + ) { + return; + } + const temporary = `${episodeStateFile}.tmp-${process.pid}-${++episodeStateWrites}`; + try { + mkdirSync(handoffDir, { recursive: true }); + const inFlightToken = owner.mainFallbackWakeInFlight + ? createHash("sha256").update(owner.mainFallbackWakeInFlight).digest("hex").slice(0, 16) + : ""; + writeFileSync( + temporary, + [ + "version=1", + `generation=${owner.id}`, + `episode=${owner.mainFallbackEpisode ? 1 : 0}`, + `in_flight=${owner.mainFallbackWakeInFlight ? 1 : 0}`, + `in_flight_token=${inFlightToken}`, + `in_flight_sent_at_ms=${owner.mainFallbackWakeSentAtMs ?? 0}`, + `in_flight_turn_ends=${owner.mainFallbackInFlightTurnEnds}`, + `last_consume_at_ms=${owner.mainFallbackWakeConsumedAtMs ?? 0}`, + `last_turn_start_at_ms=${owner.lastTurnStartAtMs ?? 0}`, + `last_turn_end_at_ms=${owner.lastTurnEndAtMs ?? 0}`, + `updated_at_ms=${Date.now()}`, + "", + ].join("\n"), + { mode: 0o600 }, + ); + renameSync(temporary, episodeStateFile); + } catch { + try { + unlinkSync(temporary); + } catch { + // The temp file may not have been created. + } + } + } + function lockOwnership(): LockOwnership { let lockPid = ""; try { @@ -628,10 +703,10 @@ export function createPrimaryWatchCore(options: PrimaryWatchCoreOptions): Primar } // Deliver a wake to main. The runtime accepting the follow-up is the - // delivery; consumption is a separate event observed by the adapter at - // before_agent_start for an idle main and at the user message_start for a - // streaming main. Until consumption the pending record is kept in - // unconsumedWakes so a session replacement can replay it. + // delivery; consumption is a separate event observed by the adapter (see the + // delivery-versus-consumption contract at the top of this file). Until + // consumption the pending record is kept in unconsumedWakes so a session + // replacement can replay it. async function sendWake( owner: SessionGeneration, message: string, @@ -644,16 +719,27 @@ export function createPrimaryWatchCore(options: PrimaryWatchCoreOptions): Primar `FIRSTMATE WATCHER WAKE: ${message}\n\nRun bin/fm-wake-drain.sh first and handle the queued wake. Watcher continuity is extension-owned.`, ); if (pending) owner.unconsumedWakes.set(pending.token, { content, pending }); - if (trackMainFallback) owner.mainFallbackWakeInFlight = content; + if (trackMainFallback) { + owner.mainFallbackWakeInFlight = content; + owner.mainFallbackInFlightTurnEnds = 0; + owner.mainFallbackWakeSentAtMs = Date.now(); + // Rows pending at send time are the drain-evidence baseline: if a later + // boundary finds every one retired, the wake was handled even though no + // consumption event ever surfaced. + owner.mainFallbackInFlightRows = owner.mainFallbackBaselineRows ?? mainOwnedWakeSnapshot(); + } try { await sendFollowUp(content); } catch (error) { if (pending) owner.unconsumedWakes.delete(pending.token); if (trackMainFallback && owner.mainFallbackWakeInFlight === content) { owner.mainFallbackWakeInFlight = null; + owner.mainFallbackInFlightRows = null; + owner.mainFallbackWakeSentAtMs = null; } throw error; } + if (trackMainFallback) persistEpisodeState(owner); // Accepted by the runtime. A generation replaced while the runtime was // accepting the follow-up may have lost the message with the old session, // so report it undelivered and let the replacement replay the still-pending @@ -661,8 +747,9 @@ export function createPrimaryWatchCore(options: PrimaryWatchCoreOptions): Primar return generationIsLive(owner); } - // The runtime consumed a main follow-up: idle main at before_agent_start, - // streaming main at the user message_start carrying the exact wake text. + // The runtime consumed a main follow-up - or bookkeeping determined a + // still-in-flight wake was handled/wedged past its bound (turnEnd). Either + // way the pending record finishes and the in-flight marker clears. function consumeWake(owner: SessionGeneration, text: string): void { for (const [token, wake] of owner.unconsumedWakes) { if (wake.content !== text) continue; @@ -674,13 +761,14 @@ export function createPrimaryWatchCore(options: PrimaryWatchCoreOptions): Primar surfaceCleanupFailure(owner, error); schedulePendingCleanup(owner); } - if (owner.mainFallbackWakeInFlight === text) { - owner.mainFallbackWakeInFlight = null; - } - return; + break; } if (owner.mainFallbackWakeInFlight === text) { owner.mainFallbackWakeInFlight = null; + owner.mainFallbackInFlightRows = null; + owner.mainFallbackInFlightTurnEnds = 0; + owner.mainFallbackWakeConsumedAtMs = Date.now(); + persistEpisodeState(owner); } } @@ -1415,60 +1503,111 @@ export function createPrimaryWatchCore(options: PrimaryWatchCoreOptions): Primar return new Set((result.stdout || "").split(/\r?\n/).filter(Boolean)); } + // A main turn opened. An idle-main injection drives agent.prompt() directly, + // which emits no before_agent_start and arrives as a custom-role message, so + // the first turn boundary after an accepted send is itself the consumption + // evidence: the runtime only starts a turn for a message it has accepted into + // the prompt. A turn that was already streaming when its wake was queued + // emitted its turn_start before the send, so this can only clear a marker + // whose own turn (or a later one) has actually begun. + function turnStart(): void { + const owner = generation; + owner.lastTurnStartAtMs = Date.now(); + if (!coalesceMainFallbackWakes || !generationIsLive(owner)) return; + if (owner.mainFallbackWakeInFlight) consumeWake(owner, owner.mainFallbackWakeInFlight); + persistEpisodeState(owner); + } + // Main turn boundary: the only safe point to evaluate a suppressed burst. // While an episode is open, unread main-owned rows mean the acknowledged // drain left work behind - grant exactly one successor injection. A fully // drained queue retires the episode and finishes every close it covered, so // no redundant notification follows an acknowledgement that consumed them. + // The in-flight marker is bounded here too: if every row pending at send + // time has been drained, or the marker has outlived a full turn boundary + // (two consecutive turn ends without consumption), the wake is treated as + // consumed instead of suppressing every later main-bound wake forever. function turnEnd(): void { const owner = generation; - if (!coalesceMainFallbackWakes || !generationIsLive(owner) || !owner.mainFallbackEpisode) return; - if (owner.restoring) { - // A delivery run is mid-flight: a close in its restore phase has not - // reached the suppression point yet, so an empty queue here could retire - // the episode under it and let that close inject late. Re-evaluate the - // boundary when the run settles. - owner.pendingTurnEnd = true; - return; - } - const rowCount = mainOwnedWakeRows(); - if (rowCount > 0) { - const retry = owner.pendingActionables.find( - (pending) => !pending.delivered && !owner.unconsumedWakes.has(pending.token) && - !owner.episodeCoalesced.has(pending.token), - ); - if (retry && !owner.mainFallbackWakeInFlight) { - owner.mainFallbackSuccessor = true; - void processPendingActionables(owner); + if (!coalesceMainFallbackWakes || !generationIsLive(owner)) return; + owner.lastTurnEndAtMs = Date.now(); + try { + if (!owner.mainFallbackEpisode) return; + if (owner.restoring) { + // A delivery run is mid-flight: a close in its restore phase has not + // reached the suppression point yet, so an empty queue here could + // retire the episode under it and let that close inject late. + // Re-evaluate the boundary when the run settles. + owner.pendingTurnEnd = true; return; } - if (!owner.mainFallbackBaselineRows) { - const recoveredRows = mainOwnedWakeSnapshot(); - if (!recoveredRows) return; - owner.mainFallbackBaselineRows = recoveredRows; - return; + if (owner.mainFallbackWakeInFlight) { + owner.mainFallbackInFlightTurnEnds += 1; + } else { + owner.mainFallbackInFlightTurnEnds = 0; } - const currentRows = mainOwnedWakeSnapshot(); - if (!currentRows) return; - if ([...owner.mainFallbackBaselineRows].some((row) => currentRows.has(row))) return; - // The successor is the newest close whose notification was never - // accepted: oldest-first would re-present rows the drain already - // acknowledged. Coalesced marks are cleared only for that record so the - // run loop delivers exactly it and re-suppresses the rest. - const next = owner.pendingActionables - .filter((pending) => !pending.delivered && !owner.unconsumedWakes.has(pending.token)) - .pop(); - if (next && !owner.mainFallbackWakeInFlight && !owner.mainFallbackSuccessorGranted) { - owner.mainFallbackSuccessor = true; - owner.mainFallbackSuccessorGranted = true; - if (currentRows) owner.mainFallbackBaselineRows = currentRows; - owner.episodeCoalesced.delete(next.token); - void processPendingActionables(owner); - } else if ( - owner.pendingActionables.every((pending) => pending.delivered) && - !owner.mainFallbackWakeInFlight && - !owner.mainFallbackSuccessorGranted - ) { + const rowCount = mainOwnedWakeRows(); + if (rowCount > 0) { + let clearedStaleInFlight = false; + if (owner.mainFallbackWakeInFlight) { + const sendRows = owner.mainFallbackInFlightRows; + const sendRowsDrained = sendRows !== null && + ((): boolean => { + const currentRows = mainOwnedWakeSnapshot(); + return currentRows !== null && + ![...sendRows].some((row) => currentRows.has(row)); + })(); + // Two boundaries survived means the marker outlived one full turn + // interval without the runtime surfacing any consumption event. + if (sendRowsDrained || owner.mainFallbackInFlightTurnEnds >= 2) { + consumeWake(owner, owner.mainFallbackWakeInFlight); + clearedStaleInFlight = true; + } + } + const retry = owner.pendingActionables.find( + (pending) => !pending.delivered && !owner.unconsumedWakes.has(pending.token) && + !owner.episodeCoalesced.has(pending.token), + ); + if (retry && !owner.mainFallbackWakeInFlight) { + owner.mainFallbackSuccessor = true; + void processPendingActionables(owner); + return; + } + if (!owner.mainFallbackBaselineRows) { + const recoveredRows = mainOwnedWakeSnapshot(); + if (!recoveredRows) return; + owner.mainFallbackBaselineRows = recoveredRows; + return; + } + const currentRows = mainOwnedWakeSnapshot(); + if (!currentRows) return; + if ( + !clearedStaleInFlight && + [...owner.mainFallbackBaselineRows].some((row) => currentRows.has(row)) + ) { + return; + } + // The successor is the newest close whose notification was never + // accepted: oldest-first would re-present rows the drain already + // acknowledged. Coalesced marks are cleared only for that record so + // the run loop delivers exactly it and re-suppresses the rest. A + // just-cleared stale marker skips the baseline early return above: + // its notification was never read, so the rows it covered still earn + // one successor injection. + const next = owner.pendingActionables + .filter((pending) => !pending.delivered && !owner.unconsumedWakes.has(pending.token)) + .pop(); + if (next && !owner.mainFallbackWakeInFlight && !owner.mainFallbackSuccessorGranted) { + owner.mainFallbackSuccessor = true; + owner.mainFallbackSuccessorGranted = true; + if (currentRows) owner.mainFallbackBaselineRows = currentRows; + owner.episodeCoalesced.delete(next.token); + void processPendingActionables(owner); + } else if ( + owner.pendingActionables.every((pending) => pending.delivered) && + !owner.mainFallbackWakeInFlight && + !owner.mainFallbackSuccessorGranted + ) { // Rows outlived every close record (e.g. appended after the last // actionable close): a synthetic wake re-presents them. The episode // stays open so the next boundary re-evaluates. @@ -1485,22 +1624,30 @@ export function createPrimaryWatchCore(options: PrimaryWatchCoreOptions): Primar // that in-flight notification already covers the unread rows. return; } - if (owner.mainFallbackWakeInFlight) return; - owner.mainFallbackEpisode = false; - owner.mainFallbackSuccessor = false; - owner.mainFallbackSuccessorGranted = false; - owner.mainFallbackBaselineRows = null; - for (const pending of owner.pendingActionables.filter( - (item) => !item.delivered && owner.episodeCoalesced.has(item.token), - )) { - pending.delivered = true; - owner.episodeCoalesced.delete(pending.token); - try { - finishPendingActionable(owner, pending); - } catch (error) { - surfaceCleanupFailure(owner, error); - schedulePendingCleanup(owner); + // An empty queue under a still-set in-flight marker means the wake's own + // turn ran and drained everything while its consumption events never + // surfaced; keeping the marker would wedge every later wake behind it. + if (owner.mainFallbackWakeInFlight) consumeWake(owner, owner.mainFallbackWakeInFlight); + owner.mainFallbackEpisode = false; + owner.mainFallbackSuccessor = false; + owner.mainFallbackSuccessorGranted = false; + owner.mainFallbackBaselineRows = null; + owner.mainFallbackInFlightRows = null; + owner.mainFallbackInFlightTurnEnds = 0; + for (const pending of owner.pendingActionables.filter( + (item) => !item.delivered && owner.episodeCoalesced.has(item.token), + )) { + pending.delivered = true; + owner.episodeCoalesced.delete(pending.token); + try { + finishPendingActionable(owner, pending); + } catch (error) { + surfaceCleanupFailure(owner, error); + schedulePendingCleanup(owner); + } } + } finally { + persistEpisodeState(owner); } } @@ -1534,6 +1681,7 @@ export function createPrimaryWatchCore(options: PrimaryWatchCoreOptions): Primar markLoaded, sessionShutdown, sessionStart, + turnStart, turnEnd, }; } diff --git a/docs/omp-supervision-branch.md b/docs/omp-supervision-branch.md index 95d9b6efab1..9e195621deb 100644 --- a/docs/omp-supervision-branch.md +++ b/docs/omp-supervision-branch.md @@ -64,7 +64,12 @@ Main claims every unread row not currently granted to the branch, then drains an The branch drains and acknowledges only the exact row set the extension granted to it, published to `state/.branch-eligible-rows` under the queue lock immediately before every branch prompt. `.omp/extensions/lib/fm-branch-dispatch.ts`'s `scopeForUnreadWake` is the single owner of which rows are branch-eligible; the drain never reclassifies a row itself, it only consumes that already-computed snapshot. A row whose sequence number is not in the branch's snapshot is left completely untouched by a branch-actor drain or acknowledgement, no matter its sequence number, so the branch can never swallow a main-owned row still waiting for main. -A main fall-back follow-up is delivered once OMP accepts it (`sendMessage` resolves); consumption is a separate event that finishes the pending record. An idle main consumes at `before_agent_start`; a streaming main consumes at the user `message_start` carrying the exact wake text; either event marks the row delivered. A follow-up accepted but not yet consumed is kept in the generation's unconsumed set, so a session replacement can replay only the wakes the runtime had not yet started to read. +A main fall-back follow-up is delivered once OMP accepts it (`sendMessage` resolves); consumption is a separate event that finishes the pending record. +A streaming main consumes at `before_agent_start` or the user `message_start` carrying the exact wake text. +An idle-main injection opens an agent-initiated turn through `agent.prompt()` directly, which emits no `before_agent_start`; there the first `turn_start` after the accepted send is the consumption evidence, and the wake's own `message_start` (role `custom`, `customType` `firstmate-watcher-wake`) acknowledges it too. +Any of these marks the row delivered, and a marker that outlives a full turn boundary without any of them - or whose send-time queue rows were drained - is cleared as consumed at `turn_end`, so a missed consumption signal cannot suppress every later wake. +A follow-up accepted but not yet consumed is kept in the generation's unconsumed set, so a session replacement can replay only the wakes the runtime had not yet started to read. +The episode mirror persisted at `state/extensions/omp-primary-watch/main-fallback-episode.state` records the in-flight marker and the send, consume, and turn-boundary timestamps so a wedge is diagnosable from disk; `bin/fm-guard.sh` warns when that file shows an in-flight wake older than a turn. An acknowledgement that consumes none of the actor's presented rows reports that fact and names the exact current `--ack-through` and `--recovery-generation` command, so retrying an earlier wake cannot re-fire a stale loop. Because branch exclusion makes granted rows invisible to main, `bin/fm-guard.sh`'s queued-wake warning counts only the rows the calling actor can itself present or retire (`fm_wake_actor_pending_count` in `bin/fm-wake-lib.sh`, the single owner of the per-actor count alongside the grant row-list and owner-record reads the drain and `bin/fm-wake-grant.sh` share), so an actor is never sent to a drain that provably has nothing for it. A row a live grant reserves is therefore never counted as drainable for main; rather than going silent about a visibly non-empty queue, the guard prints a distinct advisory naming the live supervision branch as the holder and saying not to drain those rows from here. diff --git a/tests/fm-omp-primary.test.sh b/tests/fm-omp-primary.test.sh index 72f71083aed..4a8c51e001f 100755 --- a/tests/fm-omp-primary.test.sh +++ b/tests/fm-omp-primary.test.sh @@ -1275,13 +1275,22 @@ const api = { on(name, handler) { handlers.set(name, handler); }, registerCommand() {}, registerTool() {}, + // The runtime accepts each wake for an idle main: the injection opens an + // agent-initiated turn, which emits turn_start and the custom message's own + // message_start - never before_agent_start (see the switch-nudge note at the + // native contract test). Withheld consumption models the runtime dropping + // every one of those signals. sendMessage(message) { steers.push(String(message?.content ?? "")); if (withholdConsumption) { withholdConsumption = false; return; } - handlers.get("before_agent_start")?.({ type: "before_agent_start", prompt: message.content }, {}); + handlers.get("turn_start")?.({ type: "turn_start" }, context); + handlers.get("message_start")?.({ + type: "message_start", + message: { role: "custom", customType: message.customType, content: message.content }, + }); }, }; const count = () => existsSync(`${process.env.FM_STATE_OVERRIDE}/watch-count`) @@ -1771,6 +1780,166 @@ JS pass "OMP coalesces fallback wakes into one in-flight notification per handling episode" } +# An idle-main injection opens an agent-initiated turn that emits no +# before_agent_start, so consumption must come from turn_start or the wake's +# own custom-role message_start. Regression for the wedge where one such send +# left mainFallbackWakeInFlight set forever and suppressed every later wake. +test_native_omp_idle_main_wake_consumption_and_stale_bound() { + local fixture out status=0 + fixture=$(make_omp_queue_fixture native-idle-wake-consume) + cat > "$fixture/bin/fm-watch-arm.sh" <<'SH' +#!/usr/bin/env bash +state=${FM_STATE_OVERRIDE:?} +count=$(cat "$state/watch-count" 2>/dev/null || printf 0) +count=$((count + 1)) +printf '%s\n' "$count" > "$state/watch-count" +printf 'watcher: started pid=%s (beacon fresh)\n' "$$" +trap 'exit 0' TERM INT +while [ ! -e "$state/watch-stop" ]; do + if [ -e "$state/wake-now-$count" ]; then + printf 'signal: idle-close-%s\n' "$count" + exit 0 + fi + sleep 0.02 +done +SH + chmod +x "$fixture/bin/fm-watch-arm.sh" + out=$(EXTENSION="$fixture/.omp/extensions/fm-primary-omp.ts" FM_HOME="$fixture" \ + FM_ROOT_OVERRIDE="$fixture" FM_STATE_OVERRIDE="$fixture/state" FM_CONFIG_OVERRIDE="$fixture/config" \ + node --input-type=module 2>&1 <<'JS' +import { appendFileSync, existsSync, readFileSync, writeFileSync } from "node:fs"; +import { pathToFileURL } from "node:url"; +const state = process.env.FM_STATE_OVERRIDE; +const episodeFile = `${state}/extensions/omp-primary-watch/main-fallback-episode.state`; +const handlers = new Map(); +const wakes = []; +// suppressConsume models the runtime surface that reports the wedge: the +// injection is accepted but no turn_start, message_start, or +// before_agent_start ever names it. +let suppressConsume = false; +const context = { sessionManager: { getSessionFile: () => undefined, getSessionId: () => "sess-one" } }; +const api = { + zod: { object: () => ({}) }, + on(name, handler) { handlers.set(name, handler); }, + registerCommand() {}, + registerTool() {}, + sendMessage(message) { + if (message?.customType !== "firstmate-watcher-wake") return; + wakes.push(String(message.content ?? "")); + if (suppressConsume) return; + // An idle main starts an agent-initiated turn: turn_start and the injected + // message's own custom-role message_start fire, before_agent_start never + // does. + handlers.get("turn_start")?.({ type: "turn_start" }, context); + handlers.get("message_start")?.({ + type: "message_start", + message: { role: "custom", customType: "firstmate-watcher-wake", content: message.content }, + }); + }, +}; +const queue = `${state}/.wake-queue`; +const seqFile = `${state}/.wake-queue.seq`; +let seq = 0; +const rows = new Map(); +const appendRow = (kind, key) => { + seq += 1; + const line = `0\t${seq}\t${kind}\t${key}\t${kind}: ${key}`; + rows.set(seq, line); + writeFileSync(seqFile, `${seq}\n`); + appendFileSync(queue, `${line}\n`); +}; +const drainAll = () => { + rows.clear(); + writeFileSync(queue, ""); +}; +const count = () => existsSync(`${state}/watch-count`) ? Number(readFileSync(`${state}/watch-count`, "utf8").trim()) : 0; +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +async function waitFor(pred, label) { + for (let i = 0; i < 500; i += 1) { if (pred()) return; await sleep(10); } + throw new Error(`timeout waiting for ${label}`); +} +const episodeState = () => existsSync(episodeFile) ? readFileSync(episodeFile, "utf8") : ""; +const episodeField = (name) => (episodeState().match(new RegExp(`^${name}=(.*)$`, "m")) || [])[1] ?? ""; + +writeFileSync(`${state}/.lock`, `${process.pid}\n`); +process.argv[1] = process.env.EXTENSION; +const module = await import(`${pathToFileURL(process.env.EXTENSION).href}?idle-consume=${Date.now()}`); +module.default(api); +await handlers.get("session_start")({ type: "session_start" }, context); +await waitFor(() => count() === 1, "initial arm"); + +// Leg 1: an idle-main wake is consumed by its own turn boundary signals alone +// (no before_agent_start), and the episode mirror lands on disk. +appendRow("signal", "crew-a.turn-ended"); +writeFileSync(`${state}/wake-now-1`, "go\n"); +await waitFor(() => wakes.length === 1 && count() === 2, "idle-main fallback wake and successor"); +if (!wakes[0].includes("signal: idle-close-1")) throw new Error(`first idle wake mismatched its close: ${wakes[0]}`); +await waitFor(() => episodeState() !== "", "persisted episode state"); +if (episodeField("in_flight") !== "0") throw new Error(`idle wake was not consumed by turn_start: ${episodeState()}`); +if (Number(episodeField("in_flight_sent_at_ms")) <= 0 || Number(episodeField("last_consume_at_ms")) <= 0) { + throw new Error(`episode state lost its send/consume timestamps: ${episodeState()}`); +} +drainAll(); +await handlers.get("turn_end")({ type: "turn_end" }, context); +await sleep(100); + +// Leg 2: wedge - the next idle send is accepted but every consumption signal +// is dropped. The first boundary counts it; the second must clear it and let +// the rows-outlived successor fire. +suppressConsume = true; +appendRow("signal", "crew-b.turn-ended"); +writeFileSync(`${state}/wake-now-2`, "go\n"); +await waitFor(() => wakes.length === 2 && count() === 3, "wedged idle-main fallback wake"); +await sleep(50); +if (episodeField("in_flight") !== "1") throw new Error(`wedged wake was not persisted in-flight: ${episodeState()}`); +await handlers.get("turn_end")({ type: "turn_end" }, context); +await sleep(150); +if (wakes.length !== 2) throw new Error(`a successor fired before the stale bound: ${wakes.length}`); +if (episodeField("in_flight_turn_ends") !== "1") throw new Error(`boundary count was not persisted: ${episodeState()}`); +suppressConsume = false; +await handlers.get("turn_end")({ type: "turn_end" }, context); +await waitFor(() => wakes.length === 3, "stale in-flight bound did not release one successor"); +if (!wakes[2].includes("wakes remain queued")) throw new Error(`stale-bound successor carried the wrong wake: ${wakes[2]}`); +drainAll(); +await handlers.get("turn_end")({ type: "turn_end" }, context); +await sleep(150); +if (episodeField("in_flight") !== "0") throw new Error(`cleared marker still persisted in-flight: ${episodeState()}`); + +// Leg 3: after the wedge clears, an ordinary close must inject again instead +// of staying suppressed behind the stale marker. +appendRow("signal", "crew-c.turn-ended"); +writeFileSync(`${state}/wake-now-3`, "go\n"); +await waitFor(() => wakes.length === 4 && count() === 4, "post-wedge close injection"); +if (!wakes[3].includes("signal: idle-close-3")) throw new Error(`post-wedge wake mismatched its close: ${wakes[3]}`); +drainAll(); +await handlers.get("turn_end")({ type: "turn_end" }, context); +await sleep(150); + +// Leg 4: a wedged marker over a fully drained queue clears at a single +// boundary and the next close injects immediately. +suppressConsume = true; +appendRow("signal", "crew-d.turn-ended"); +writeFileSync(`${state}/wake-now-4`, "go\n"); +await waitFor(() => wakes.length === 5 && count() === 5, "second wedged idle-main wake"); +drainAll(); +await handlers.get("turn_end")({ type: "turn_end" }, context); +await sleep(150); +appendRow("signal", "crew-e.turn-ended"); +writeFileSync(`${state}/wake-now-5`, "go\n"); +await waitFor(() => wakes.length === 6, "close after a drained-queue wedge was still suppressed"); +if (!wakes[5].includes("signal: idle-close-5")) throw new Error(`post-drain wake mismatched its close: ${wakes[5]}`); + +writeFileSync(`${state}/watch-stop`, "stop\n"); +await handlers.get("session_shutdown")({ type: "session_shutdown" }, context); +console.log("omp-idle-wake-consume-ok"); +JS + ) || status=$? + printf 'stop\n' > "$fixture/state/watch-stop" 2>/dev/null || true + expect_code 0 "$status" "OMP idle-main wake consumption" + assert_contains "$out" omp-idle-wake-consume-ok "OMP idle-main wake stayed suppressed or unbounded: $out" + pass "OMP idle-main wakes consume at turn_start and a stale in-flight marker is bounded" +} + test_native_omp_delivered_handoff_does_not_suppress_queue_notification() { local fixture out status=0 fixture=$(make_omp_queue_fixture native-queue-delivered-handoff) @@ -1809,6 +1978,53 @@ JS pass "OMP ignores already-delivered handoffs when notifying queued wakes" } +# fm-guard.sh reads the persisted episode mirror and must warn exactly when an +# in-flight wake has outlived a recorded turn boundary. +test_fm_guard_warns_on_stale_omp_inflight_wake() { + local fixture state out + fixture="$TMP_ROOT/guard-stale-inflight" + state="$fixture/state" + mkdir -p "$fixture/bin" "$state/extensions/omp-primary-watch" "$fixture/config" + for f in "$ROOT"/bin/*; do ln -s "$f" "$fixture/bin/$(basename "$f")"; done + : > "$fixture/AGENTS.md" + git init -q -b main "$fixture" + fm_write_meta "$state/fixturecrew.meta" \ + "window=fmtest-nonexistent:fakecrew" \ + "harness=omp" \ + "kind=ship" + local episode_state="$state/extensions/omp-primary-watch/main-fallback-episode.state" + cat > "$episode_state" <<'EOF' +version=1 +generation=1 +episode=1 +in_flight=1 +in_flight_token=0123456789abcdef +in_flight_sent_at_ms=1000 +in_flight_turn_ends=1 +last_consume_at_ms=0 +last_turn_start_at_ms=1500 +last_turn_end_at_ms=2000 +updated_at_ms=2000 +EOF + out=$(FM_HOME="$fixture" FM_ROOT_OVERRIDE="$fixture" FM_STATE_OVERRIDE="$state" \ + FM_CONFIG_OVERRIDE="$fixture/config" "$fixture/bin/fm-guard.sh" 2>&1) + assert_contains "$out" "main-fallback wake stayed in-flight" \ + "fm-guard did not warn on an in-flight wake older than a turn" + # A marker with no turn boundary after its send is still within its bound. + sed -i 's/^in_flight_sent_at_ms=1000/in_flight_sent_at_ms=3000/' "$episode_state" + out=$(FM_HOME="$fixture" FM_ROOT_OVERRIDE="$fixture" FM_STATE_OVERRIDE="$state" \ + FM_CONFIG_OVERRIDE="$fixture/config" "$fixture/bin/fm-guard.sh" 2>&1) + assert_not_contains "$out" "stayed in-flight" \ + "fm-guard warned on an in-flight wake younger than a turn" + # A cleared marker never warns. + sed -i 's/^in_flight=1/in_flight=0/' "$episode_state" + out=$(FM_HOME="$fixture" FM_ROOT_OVERRIDE="$fixture" FM_STATE_OVERRIDE="$state" \ + FM_CONFIG_OVERRIDE="$fixture/config" "$fixture/bin/fm-guard.sh" 2>&1) + assert_not_contains "$out" "stayed in-flight" \ + "fm-guard warned on a cleared in-flight marker" + pass "fm-guard warns only when an OMP in-flight wake outlives a turn boundary" +} + test_resolve_path_uses_node_when_readlink_f_is_unavailable test_exact_bun_omp_primary_identity test_standalone_omp_primary_identity @@ -1827,4 +2043,6 @@ test_native_omp_durable_queue_session_notifications test_native_omp_empty_queue_suppresses_session_notifications test_native_omp_core_handoff_suppresses_queue_notification test_native_omp_main_fallback_coalesces_burst +test_native_omp_idle_main_wake_consumption_and_stale_bound test_native_omp_delivered_handoff_does_not_suppress_queue_notification +test_fm_guard_warns_on_stale_omp_inflight_wake From a82e39379dce75967ad1e9d0cd2cf4099397eb9f Mon Sep 17 00:00:00 2001 From: dnth Date: Mon, 21 Sep 2026 16:19:43 +0800 Subject: [PATCH 2/7] no-mistakes(review): Bound stale episode persistence across generation replacement --- bin/fm-primary-watch-core.ts | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/bin/fm-primary-watch-core.ts b/bin/fm-primary-watch-core.ts index 9a7b6ae4cd7..6f782a48de4 100644 --- a/bin/fm-primary-watch-core.ts +++ b/bin/fm-primary-watch-core.ts @@ -619,6 +619,17 @@ export function createPrimaryWatchCore(options: PrimaryWatchCoreOptions): Primar } persistenceFailed = true; } finally { + if (coalesceMainFallbackWakes) { + owner.mainFallbackWakeInFlight = null; + owner.mainFallbackInFlightRows = null; + owner.mainFallbackInFlightTurnEnds = 0; + owner.mainFallbackWakeSentAtMs = null; + owner.mainFallbackEpisode = false; + owner.mainFallbackSuccessor = false; + owner.mainFallbackSuccessorGranted = false; + owner.mainFallbackBaselineRows = null; + persistEpisodeState(owner); + } const child = stopGeneration(owner); await waitForGenerationChildClose(child); } @@ -736,10 +747,12 @@ export function createPrimaryWatchCore(options: PrimaryWatchCoreOptions): Primar owner.mainFallbackWakeInFlight = null; owner.mainFallbackInFlightRows = null; owner.mainFallbackWakeSentAtMs = null; + owner.mainFallbackInFlightTurnEnds = 0; + if (generationIsLive(owner)) persistEpisodeState(owner); } throw error; } - if (trackMainFallback) persistEpisodeState(owner); + if (trackMainFallback && generationIsLive(owner)) persistEpisodeState(owner); // Accepted by the runtime. A generation replaced while the runtime was // accepting the follow-up may have lost the message with the old session, // so report it undelivered and let the replacement replay the still-pending From 740e73514d3b033925295ab62aad25a894db13c1 Mon Sep 17 00:00:00 2001 From: dnth Date: Mon, 21 Sep 2026 17:17:59 +0800 Subject: [PATCH 3/7] no-mistakes(document): Aligned OMP consumption documentation --- docs/omp-supervision-branch.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/omp-supervision-branch.md b/docs/omp-supervision-branch.md index 9e195621deb..658a9b905a3 100644 --- a/docs/omp-supervision-branch.md +++ b/docs/omp-supervision-branch.md @@ -128,7 +128,7 @@ An outcome covering the event records it (state `recorded`); an uncovered one is Neither presenting the event nor a routine verdict retires it, so a completion reported as `routine` still opens a main turn: the report tool's merge takes the captain delivery shape whenever its covered span holds an undelivered obligation. The grant publishes each granted task's status identity and event endpoint beside the eligible rows, so an outcome's covered span is bound to what the branch owned at grant time and can never stamp a fresh EOF over events it never saw. A settled wake whose prompt left a granted completion `pending` - no covering outcome, no receipt - rejects its settlement, so a mixed grant can never settle only part of its completions. -The branch re-sends any still-undelivered obligation once per generation after a settled wake, batched into one turn; main's consumption events (`before_agent_start`, a matching user `message_start`) retire the in-flight marker so a consumed send is not re-sent, while the durable receipt is what actually discharges the obligation. +The branch re-sends any still-undelivered obligation once per generation after a settled wake, batched into one turn; main's consumption events (`before_agent_start`, the first `turn_start` after an idle-main send, or a matching `message_start` with the wake's custom role and type) retire the in-flight marker so a consumed send is not re-sent, while the durable receipt is what actually discharges the obligation. The main-side backstop is the other consumer: `bin/fm-wake-drain.sh`'s STATUS OUTCOME BACKSTOP section re-surfaces every undelivered obligation on each drain until main records the printed `bin/fm-branch-outcome.sh deliver` receipt, and the per-task manifest field it feeds back is the delivered frontier that bounds the next scan, never a presentation marker. Delivery is at-least-once by design: a lost send is replayed by the next wake or the backstop, and a duplicated send is deduplicated by the receipt, so the contract never claims exactly-once captain visibility. From 6be2c5c5c416c10b7f2e8fdafab83004d55b6abb Mon Sep 17 00:00:00 2001 From: dnth Date: Mon, 21 Sep 2026 17:45:05 +0800 Subject: [PATCH 4/7] no-mistakes(ci): Fixed the macOS Bash 3.2 snapshot parse failure by replacing the new quoted heredoc fixture state with portable printf output. Verified with bash -n and the full tests/fm-omp-primary.test.sh suite --- tests/fm-omp-primary.test.sh | 25 ++++++++++++------------- 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/tests/fm-omp-primary.test.sh b/tests/fm-omp-primary.test.sh index 4a8c51e001f..fddced21a73 100755 --- a/tests/fm-omp-primary.test.sh +++ b/tests/fm-omp-primary.test.sh @@ -1993,19 +1993,18 @@ test_fm_guard_warns_on_stale_omp_inflight_wake() { "harness=omp" \ "kind=ship" local episode_state="$state/extensions/omp-primary-watch/main-fallback-episode.state" - cat > "$episode_state" <<'EOF' -version=1 -generation=1 -episode=1 -in_flight=1 -in_flight_token=0123456789abcdef -in_flight_sent_at_ms=1000 -in_flight_turn_ends=1 -last_consume_at_ms=0 -last_turn_start_at_ms=1500 -last_turn_end_at_ms=2000 -updated_at_ms=2000 -EOF + printf '%s\n' \ + 'version=1' \ + 'generation=1' \ + 'episode=1' \ + 'in_flight=1' \ + 'in_flight_token=0123456789abcdef' \ + 'in_flight_sent_at_ms=1000' \ + 'in_flight_turn_ends=1' \ + 'last_consume_at_ms=0' \ + 'last_turn_start_at_ms=1500' \ + 'last_turn_end_at_ms=2000' \ + 'updated_at_ms=2000' > "$episode_state" out=$(FM_HOME="$fixture" FM_ROOT_OVERRIDE="$fixture" FM_STATE_OVERRIDE="$state" \ FM_CONFIG_OVERRIDE="$fixture/config" "$fixture/bin/fm-guard.sh" 2>&1) assert_contains "$out" "main-fallback wake stayed in-flight" \ From 9b145cf36edc578b3f741b3c42b286e975a21a69 Mon Sep 17 00:00:00 2001 From: dnth Date: Mon, 21 Sep 2026 18:08:44 +0800 Subject: [PATCH 5/7] no-mistakes(ci): Fixed the macOS Bash compatibility issue in tests/fm-omp-primary.test.sh by replacing non-portable in-place sed edits with a portable awk-and-mv field updater. Verified Bash parsing, the full OMP primary test suite, and git diff checks successfully --- tests/fm-omp-primary.test.sh | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/tests/fm-omp-primary.test.sh b/tests/fm-omp-primary.test.sh index fddced21a73..5ab8c92d19b 100755 --- a/tests/fm-omp-primary.test.sh +++ b/tests/fm-omp-primary.test.sh @@ -2005,18 +2005,24 @@ test_fm_guard_warns_on_stale_omp_inflight_wake() { 'last_turn_start_at_ms=1500' \ 'last_turn_end_at_ms=2000' \ 'updated_at_ms=2000' > "$episode_state" + replace_episode_field() { + local field=$1 value=$2 tmp="${episode_state}.tmp" + awk -v field="$field" -v value="$value" \ + 'index($0, field "=") == 1 { print field "=" value; next } { print }' \ + "$episode_state" > "$tmp" && mv "$tmp" "$episode_state" + } out=$(FM_HOME="$fixture" FM_ROOT_OVERRIDE="$fixture" FM_STATE_OVERRIDE="$state" \ FM_CONFIG_OVERRIDE="$fixture/config" "$fixture/bin/fm-guard.sh" 2>&1) assert_contains "$out" "main-fallback wake stayed in-flight" \ "fm-guard did not warn on an in-flight wake older than a turn" # A marker with no turn boundary after its send is still within its bound. - sed -i 's/^in_flight_sent_at_ms=1000/in_flight_sent_at_ms=3000/' "$episode_state" + replace_episode_field in_flight_sent_at_ms 3000 out=$(FM_HOME="$fixture" FM_ROOT_OVERRIDE="$fixture" FM_STATE_OVERRIDE="$state" \ FM_CONFIG_OVERRIDE="$fixture/config" "$fixture/bin/fm-guard.sh" 2>&1) assert_not_contains "$out" "stayed in-flight" \ "fm-guard warned on an in-flight wake younger than a turn" # A cleared marker never warns. - sed -i 's/^in_flight=1/in_flight=0/' "$episode_state" + replace_episode_field in_flight 0 out=$(FM_HOME="$fixture" FM_ROOT_OVERRIDE="$fixture" FM_STATE_OVERRIDE="$state" \ FM_CONFIG_OVERRIDE="$fixture/config" "$fixture/bin/fm-guard.sh" 2>&1) assert_not_contains "$out" "stayed in-flight" \ From 2a60b645e9db45fd50df01bd7fb8b80e34a51b9f Mon Sep 17 00:00:00 2001 From: dnth Date: Mon, 21 Sep 2026 18:32:50 +0800 Subject: [PATCH 6/7] no-mistakes(ci): Fixed the stock macOS Bash 3.2 parse failure by removing the problematic single-quoted awk program in tests/fm-omp-primary.test.sh and using an equivalent portable double-quoted program. Verified the full shell parse sweep with /bin/bash -n and git diff --check --- tests/fm-omp-primary.test.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/fm-omp-primary.test.sh b/tests/fm-omp-primary.test.sh index 5ab8c92d19b..5543fb6541d 100755 --- a/tests/fm-omp-primary.test.sh +++ b/tests/fm-omp-primary.test.sh @@ -2008,7 +2008,7 @@ test_fm_guard_warns_on_stale_omp_inflight_wake() { replace_episode_field() { local field=$1 value=$2 tmp="${episode_state}.tmp" awk -v field="$field" -v value="$value" \ - 'index($0, field "=") == 1 { print field "=" value; next } { print }' \ + "index(\$0, field \"=\") == 1 { print field \"=\" value; next } { print }" \ "$episode_state" > "$tmp" && mv "$tmp" "$episode_state" } out=$(FM_HOME="$fixture" FM_ROOT_OVERRIDE="$fixture" FM_STATE_OVERRIDE="$state" \ From 2dee14f573879978bfff20aba4b1edbc717c3036 Mon Sep 17 00:00:00 2001 From: dnth Date: Mon, 21 Sep 2026 18:54:58 +0800 Subject: [PATCH 7/7] no-mistakes(ci): Removed the two apostrophes in JavaScript heredoc comments that desynchronized stock Bash 3.2 parsing. Verified with `bash -n`, `git diff --check`, and the full `tests/fm-omp-primary.test.sh` suite --- tests/fm-omp-primary.test.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/fm-omp-primary.test.sh b/tests/fm-omp-primary.test.sh index 5543fb6541d..2dae0bb15f8 100755 --- a/tests/fm-omp-primary.test.sh +++ b/tests/fm-omp-primary.test.sh @@ -1276,7 +1276,7 @@ const api = { registerCommand() {}, registerTool() {}, // The runtime accepts each wake for an idle main: the injection opens an - // agent-initiated turn, which emits turn_start and the custom message's own + // agent-initiated turn, which emits turn_start and the custom message_start // message_start - never before_agent_start (see the switch-nudge note at the // native contract test). Withheld consumption models the runtime dropping // every one of those signals. @@ -1828,7 +1828,7 @@ const api = { wakes.push(String(message.content ?? "")); if (suppressConsume) return; // An idle main starts an agent-initiated turn: turn_start and the injected - // message's own custom-role message_start fire, before_agent_start never + // custom-role message_start of that message fire, before_agent_start never // does. handlers.get("turn_start")?.({ type: "turn_start" }, context); handlers.get("message_start")?.({