diff --git a/.agents/skills/harness-adapters/SKILL.md b/.agents/skills/harness-adapters/SKILL.md index 01f5f841a60..de751fdbabc 100644 --- a/.agents/skills/harness-adapters/SKILL.md +++ b/.agents/skills/harness-adapters/SKILL.md @@ -337,7 +337,8 @@ Before killing and respawning a live OMP agent for a wedged PROVIDER STREAM, fol **Primary-session integration fact (verified 2026-07-31, OMP 17.1.8).** Plain OMP started from the Firstmate root discovers `.omp/extensions/fm-primary-omp.ts` natively, including in a fresh checkout before canonical `state/` exists; `omp -e .omp/extensions/fm-primary-omp.ts` remains the explicit recovery fallback. The adapter publishes the OMP marker shape owned by [configuration](../../../docs/configuration.md#harness-support), delivers the session-start instruction on native `session_start` and `session_switch` events, and owns watcher generations through `fm_watch_arm_omp` plus `/new` and `/resume` continuity. -It routes watcher follow-ups through OMP's `sendUserMessage(content)` with no explicit `deliverAs`, so OMP starts a turn when idle and steers while streaming instead of queueing the notification; it runs the shared turn-end predicate through native `session_stop`, and applies the shared watcher-arm, persistent-directory, and delegation-shaped tool safety checks before tool execution. +It routes watcher wakes through OMP's `sendMessage` as a hidden custom next-turn message with `triggerTurn`, which starts a handling turn from idle and, when the current turn is still unwinding, schedules the continuation that consumes every wake queued during it; [watcher continuity](../../../docs/watcher-continuity.md#actionable-wake-ordering) owns the durable-row acknowledgement and session-event re-notification contract. +It runs the shared turn-end predicate through native `session_stop`, and applies the shared watcher-arm, persistent-directory, and delegation-shaped tool safety checks before tool execution. `bin/fm-session-start.sh` rejects a missing, stale, foreign-PID, or version-mismatched loaded marker and prints both native-discovery and explicit `-e` recovery commands. The authoritative operating procedure is `docs/supervision-protocols/omp.md`. diff --git a/.omp/extensions/fm-primary-omp.ts b/.omp/extensions/fm-primary-omp.ts index 1533d70cc42..6c6cdf90286 100644 --- a/.omp/extensions/fm-primary-omp.ts +++ b/.omp/extensions/fm-primary-omp.ts @@ -2,7 +2,7 @@ // OMP-native session, stop, tool-call, and shutdown events stay in this adapter. import { spawn, spawnSync } from "node:child_process"; import { randomUUID } from "node:crypto"; -import { mkdirSync, renameSync, unlinkSync, writeFileSync } from "node:fs"; +import { mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs"; import { dirname, isAbsolute, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import type { @@ -135,6 +135,18 @@ function runSessionstartNudge(forceForNativeSwitch = false): string { return result.stdout.trim(); } +function durableWakeQueueHasRows(): boolean { + let queue = ""; + try { + queue = readFileSync(`${state}/.wake-queue`, "utf8"); + } catch { + return false; + } + return queue.split("\n").some((line) => + /^(?:[^\t]*)\t[0-9]+\t(?:signal|stale|check|heartbeat)\t[^\t]*\t[^\t]*$/.test(line), + ); +} + function runChecker(script: string, flag: "--command" | "--tool", value: string): Promise { return new Promise((resolveResult) => { const child = spawn(`${fmRoot}/bin/${script}`, [flag, value], { @@ -203,6 +215,37 @@ export default function (omp: ExtensionAPI) { const taskInboxDoorbell = installTaskInboxDoorbell(omp); let pendingStartupNudge = ""; + // Hidden next-turn delivery with triggerTurn. OMP schedules an internal + // continuation bound to the current prompt generation, so a wake that lands + // while a turn is still unwinding still starts its handling turn instead of + // stranding an idle session, and every notification queued during that turn + // is consumed by the one continuation. The message never enters the editable + // pending-message UI, so the captain's draft is untouched. + const sendWakeNotification = (content: string): void => { + omp.sendMessage( + { + customType: "firstmate-watcher-wake", + content, + display: false, + attribution: "agent", + details: { kind: "watcher", runtime: "omp" }, + }, + { deliverAs: "nextTurn", triggerTurn: true }, + ); + }; + + // Durable rows are the whole persistence: only fm-wake-drain acknowledgement + // removes them, so an interruption leaves the next session event able to + // re-notify. At most one notification is sent per session event, and the + // core remains the sole speaker while it owns an undelivered close. + const notifyQueuedWake = (coreOwnsDelivery: boolean): void => { + if (coreOwnsDelivery || !durableWakeQueueHasRows()) return; + sendWakeNotification(encodeOperationalInput( + "watcher", + "Durable watcher wakes are queued. Run `bin/fm-wake-drain.sh` first to present and acknowledge them.", + )); + }; + // Supervision-branch dispatch handshake (docs/omp-supervision-branch.md). // Build one offer per ordinary actionable wake and emit it on the shared // event bus; a live, enabled branch extension calls accept() synchronously @@ -234,19 +277,7 @@ export default function (omp: ExtensionAPI) { armReadyTimeoutEnv: "FM_OMP_ARM_READY_TIMEOUT_MS", repairToolName: "fm_watch_arm_omp", encodeOperationalInput, - sendFollowUp: async (content) => { - // Deliver a custom steer so OMP wakes idle sessions without touching the editable draft. - omp.sendMessage( - { - customType: "firstmate-watcher-wake", - content, - display: false, - attribution: "agent", - details: { kind: "watcher", runtime: "omp" }, - }, - { deliverAs: "steer", triggerTurn: true }, - ); - }, + sendFollowUp: async (content) => sendWakeNotification(content), offerWakeToBranch, }); @@ -281,9 +312,13 @@ export default function (omp: ExtensionAPI) { omp.on("session_start", (_event, ctx) => { taskInboxDoorbell.activate(); + // Read before sessionStart: activating the watch consumes the core's own + // handoff, so asking afterwards could not tell who owns the redelivery. + const coreOwnsDelivery = watch.hasPendingActionableHandoff(); watch.sessionStart(); publishSecondmateSession(ctx); deliverSessionstartNudge(); + notifyQueuedWake(coreOwnsDelivery); }); omp.on("turn_start", () => { @@ -294,7 +329,9 @@ export default function (omp: ExtensionAPI) { await watch.sessionShutdown(true); publishSecondmateSession(ctx); deliverSessionstartNudge(event.reason === "new" || event.reason === "resume"); + const coreOwnsDelivery = watch.hasPendingActionableHandoff(); watch.sessionStart(); + notifyQueuedWake(coreOwnsDelivery); }); omp.on("before_agent_start", (event): BeforeAgentStartEventResult | undefined => { diff --git a/bin/fm-primary-watch-core.ts b/bin/fm-primary-watch-core.ts index 052f14e7c1f..e5f7f8ad069 100644 --- a/bin/fm-primary-watch-core.ts +++ b/bin/fm-primary-watch-core.ts @@ -17,6 +17,7 @@ import { spawn, spawnSync, type ChildProcess } from "node:child_process"; import { createHash, randomUUID } from "node:crypto"; import { closeSync, + existsSync, mkdirSync, openSync, readFileSync, @@ -110,6 +111,7 @@ export type PrimaryWatchCore = { arm: () => ArmResult; armAndWait: () => Promise; acknowledgeWake: (content: string) => void; + hasPendingActionableHandoff: () => boolean; markLoaded: () => void; sessionShutdown: (replacement?: boolean) => Promise; sessionStart: () => void; @@ -1130,6 +1132,18 @@ export function createPrimaryWatchCore(options: PrimaryWatchCoreOptions): Primar return result; } + // True while the core still owns an actionable wake it has not delivered. + function hasPendingActionableHandoff(): boolean { + if (replacementCoordinator.pending.length > 0) return true; + if (generation.pendingActionables.some((pending) => !pending.delivered)) return true; + try { + return validateReplacementHandoff(JSON.parse(readFileSync(actionableHandoff, "utf8"))) + .some((pending) => !pending.delivered); + } catch { + return false; + } + } + function acknowledgeWake(content: string): void { for (const [token, acknowledgement] of generation.wakeAcknowledgements) { if (acknowledgement.content !== content) continue; @@ -1164,6 +1178,7 @@ export function createPrimaryWatchCore(options: PrimaryWatchCoreOptions): Primar arm: () => activateOwnedWatch(generation), armAndWait, acknowledgeWake, + hasPendingActionableHandoff, markLoaded, sessionShutdown, sessionStart, diff --git a/bin/fm-wake-drain.sh b/bin/fm-wake-drain.sh index eed0ef2693b..8933b8a9d8d 100755 --- a/bin/fm-wake-drain.sh +++ b/bin/fm-wake-drain.sh @@ -158,8 +158,8 @@ decide_scoped_locked() { } # The highest sequence this actor has already been presented: the branch's -# grant is exactly its current prompt's rows, and main's claim file is what its -# last drain printed. Read BEFORE an ack re-claims, so a row that arrived since +# grant is exactly its current prompt's rows, and main's eligible-row snapshot +# is what its last drain printed. Read BEFORE an ack re-claims, so a row that arrived since # presentation is never named as "the current wake" the caller may acknowledge # unseen. 0 when nothing is on record. presented_max_row() { # diff --git a/docs/verification/runtime-backends.md b/docs/verification/runtime-backends.md index 346add656da..d3cc338231a 100644 --- a/docs/verification/runtime-backends.md +++ b/docs/verification/runtime-backends.md @@ -527,7 +527,7 @@ FM_TEST_SUMMARY_FAMILY family=watcher-wake-lock count=2 duration_ms=97033 failed Every listed script ran at that head with no gate skip. The isolated Herdr role matrix emitted no queued-wake warning. The final run retained the fresh-beacon, pending-notification, queue-drain, and bounded-delivery assertions in the Herdr fixture. -The focused OMP adapter contract now delivers watcher wakes as a custom steer with `triggerTurn`, preserving the editable draft while retaining idle wake and streaming delivery. +At this 2026-08-01 head, the focused OMP adapter contract delivered watcher wakes as a custom steer with `triggerTurn`, preserving the editable draft while retaining idle wake and streaming delivery. The tmux role fixtures emitted their expected task-copy worktree and missing-fixture-watcher notices. The Herdr exit fixture refused an unlocked presentation close after proving normal process exit, then completed its named guarded teardown. @@ -618,7 +618,7 @@ ok - a captain-worthy wake opens exactly one follow-up turn on MAIN (real SDK) ok - OMP supervision branch live guard passed against @oh-my-pi/pi-coding-agent 17.3.4 ``` -The guard proves a broken branch (an unresolvable model pin) falls the wake back to main through the primary adapter's watcher-wake steer with triggerTurn, not sendUserMessage, leaving the wake queue durable. +The guard proves a broken branch (an unresolvable model pin) falls the wake back to main through the primary adapter's custom steer with triggerTurn, not sendUserMessage, leaving the wake queue durable. It proves a resident second AgentSession is created and remains re-promptable on a later wake without its turn output reaching main or replacing main's terminal resume breadcrumb, and that a routine verdict opens no new main turn while a captain verdict opens exactly one follow-up turn. The captain sub-check is skipped, not passed, on a run where the model judges the captain-worthy fixture routine. diff --git a/docs/verification/supervision.md b/docs/verification/supervision.md index 2909a50ced0..d656901486c 100644 --- a/docs/verification/supervision.md +++ b/docs/verification/supervision.md @@ -127,6 +127,24 @@ ok - OMP omp/18.1.5 primary E2E proved watcher delivery with an intact editable Before that change the same guard failed on OMP 18.1.5 at the first `/new`: the restored watcher's first wake started an agent-initiated turn, and OMP emits `before_agent_start` neither for that turn nor for a captain prompt queued into it, so an instruction staged for `before_agent_start` never reached the replacement session. +The OMP 18.1.5 idle-wake guard ran on 2026-09-06 after the adapter moved its watcher wake from a custom steer to a hidden `nextTurn` message with `triggerTurn`. +Delivery alone was already covered; this run adds the guarantee that the idle session runs the handling turn for that wake itself. + +```sh +omp --version +FM_OMP_PRIMARY_LIVE_E2E=1 tests/fm-omp-primary-live-e2e.test.sh +``` + +```text +omp/18.1.5 +ok - OMP omp/18.1.5 primary E2E proved fresh no-state and ordinary native discovery, exact ownership, once-only startup, guarded watcher startup, repeated /new continuity, shutdown, resume, and away-mode delivery +ok - OMP omp/18.1.5 primary E2E proved an idle session runs the watcher wake turn itself with an intact editable draft +``` + +The session was idle with an unsent draft when the watcher fired and nobody typed anything: it started the handling turn on its own and reached a terminal assistant record with the exact draft unchanged. +The same command refreshes this result. +A live leg that fires the wake mid-turn was attempted and left out: driving a reliably long real turn from the composer was not dependable enough for a guard, so the mid-turn continuation rests on OMP's own `sendCustomMessage` contract - `nextTurn` with `triggerTurn` schedules a post-prompt continuation bound to that prompt generation - plus the deterministic delivery-mode assertions in `tests/fm-omp-primary.test.sh`. + Standalone OMP executable compatibility is recorded in [runtime backend verification](runtime-backends.md#omp-lifecycle). Current deterministic and live entry points: diff --git a/docs/watcher-continuity.md b/docs/watcher-continuity.md index 55a9bdfae81..092710c0d89 100644 --- a/docs/watcher-continuity.md +++ b/docs/watcher-continuity.md @@ -26,7 +26,11 @@ While supervision is still needed and away mode remains inactive, an actionable ## Actionable wake ordering After an actionable Pi, OMP, or OpenCode child close, the adapter starts and verifies one singleton successor before it delivers the original wake. -OMP delivers that follow-up through its host API as a hidden custom `steer` with `triggerTurn`, which starts an idle handling turn without touching an editable TUI draft. +OMP delivers that follow-up through its host API as a hidden custom `nextTurn` message with `triggerTurn`, which starts an idle handling turn without touching an editable TUI draft. +That mode is what makes an idle session reachable from a turn boundary: a wake that lands while the current turn is still unwinding schedules a continuation bound to that prompt generation instead of relying on a steer the finishing turn may never poll, and every wake queued during that turn is consumed by the one continuation. +The durable wake rows are the whole persistence: only `bin/fm-wake-drain.sh` acknowledgement removes them, so an interruption before acknowledgement leaves the rows queued for the next session event to re-notify. +On `session_start` and `session_switch`, OMP sends at most one hidden next-turn notification when structurally valid durable rows remain and the core has no undelivered actionable handoff. +The core remains the sole speaker while it owns an undelivered close, and OMP's hidden next-turn transport coalesces notifications queued during one turn into a single continuation. A delivered follow-up is acknowledged when the runtime starts a turn whose prompt is that wake; a wake the runtime queues into an already-running turn never starts one, so the core waits at most `FM_WATCH_WAKE_CONSUME_TIMEOUT_MS` for that acknowledgement, then treats the wake as consumed and continues the successor chain instead of parking every later actionable close behind it. It confirms the handling handoff against that successor before scheduling the follow-up, retries once against the current generation and successor, and treats a failed confirmation as a restoration failure: it classifies the error, retires a successor that is no longer alive, and surfaces exactly one typed message. A failed confirmation is never swallowed. @@ -102,8 +106,10 @@ The same suite covers Pi same-process replacement for `/new`, `/resume`, `/fork` `tests/fm-omp-primary.test.sh` drives OMP `/new`, `/resume`, `/fork`, and reload through real watcher child processes and the adapter's public `session_switch` event, proving automatic re-arm plus the same in-flight actionable handoff without using `session_shutdown` as a replacement signal. Both suites also prove the bounded acknowledgement wait: two consecutive actionable closes whose deliveries never receive `before_agent_start` still start a third arm and deliver exactly one wake per close. It also covers a fresh factory bind without a prior shutdown: the superseded binding retires its arm child, cannot arm or reclaim ownership through retained session callbacks, the live binding keeps exactly one arm child, and repeated binds never accumulate more than one process-exit fallback. -`tests/fm-omp-primary.test.sh` covers OMP's binding of the same core to its native session, watcher, and shutdown surfaces, pins the input-preserving custom-steer delivery contract, pins the recovery handling handshake OMP performs before delivering that steer, and pins the single typed wake a refused handshake produces. -The opt-in `tests/fm-omp-primary-live-e2e.test.sh` proves a real OMP watcher wake reaches the session while its exact pending draft remains intact. +`tests/fm-omp-primary.test.sh` covers OMP's binding of the same core to its native session, watcher, and shutdown surfaces, pins the input-preserving hidden next-turn delivery contract, pins the recovery handling handshake OMP performs before delivering that notification, and pins the single typed wake a refused handshake produces. +It also covers durable-row re-notification on session events, acknowledgement gating, queue-empty suppression, and the silent handover while the core still owns an undelivered close. +The opt-in `tests/fm-omp-primary-live-e2e.test.sh` proves a real OMP watcher wake reaches the session while its exact pending draft remains intact, and that an idle OMP primary runs a handling turn for that wake with no manual intervention. +`tests/fm-wake-queue.test.sh` covers durable-row acknowledgement and interrupted handling replay. `tests/fm-watch-arm.test.sh` covers durable queue replay, real remote parent-replies ingestion into the authoritative status log, decision-only OPEN DECISIONS recovery, interrupted handling replay, generation-bound acknowledgement, a persistent live successor after recovery, a watcher close inside the handling window that must leave the printed acknowledgement valid, and the self-healing moved-generation acknowledgement that consumes its handled rows and names its remedy. `tests/fm-watch-recovery-loop.test.sh` covers the once-per-generation announcement bound with the real Pi extension against a refused handling handshake, and a handling successor that must surface a real crew event instead of going blind. Pi and OMP share `bin/fm-primary-watch-core.ts` here, so that Pi-driven coverage exercises the same confirmation, retry, and typed-failure code OMP runs. diff --git a/tests/fm-omp-branch-live-e2e.test.sh b/tests/fm-omp-branch-live-e2e.test.sh index 99d28e0d83f..cf24a37763b 100755 --- a/tests/fm-omp-branch-live-e2e.test.sh +++ b/tests/fm-omp-branch-live-e2e.test.sh @@ -101,17 +101,17 @@ const sessionFile = () => { try { return readFileSync(`${HOME}/state/.branch-ses if (MODE === "degrade") { // The broken branch falls the wake back to main through the primary adapter's - // exact main-wake mechanism: a firstmate-watcher-wake steer with triggerTurn, - // captured in the sendMessage stream (not sendUserMessage). + // exact main-wake mechanism: a hidden firstmate-watcher-wake nextTurn message + // with triggerTurn, captured in the sendMessage stream (not sendUserMessage). const fallback = sent.filter((s) => s.m.customType === "firstmate-watcher-wake"); - if (fallback.length < 1) fail("a broken branch did not fall the wake back to main via a watcher-wake steer"); - if (fallback.some((s) => s.o.triggerTurn !== true || s.o.deliverAs !== "steer")) { - fail("fallback did not use the steer+triggerTurn main-wake mechanism"); + if (fallback.length < 1) fail("a broken branch did not fall the wake back to main via a watcher-wake notification"); + if (fallback.some((s) => s.o.triggerTurn !== true || s.o.deliverAs !== "nextTurn")) { + fail("fallback did not use the hidden nextTurn+triggerTurn main-wake mechanism"); } if (sent.some((s) => s.m.customType === "fm-branch-merge")) fail("a broken branch merged into main instead of falling back"); - if (userMsgs.length !== 0) fail("fallback used sendUserMessage instead of the watcher-wake steer"); + if (userMsgs.length !== 0) fail("fallback used sendUserMessage instead of the watcher-wake notification"); if (!(readFileSync(`${HOME}/state/.wake-queue`, "utf8").trim().length > 0)) fail("the wake queue was lost on degrade"); - console.log("DRIVER_OK degrade: broken branch fell back to main via a watcher-wake steer with the wake queue intact"); + console.log("DRIVER_OK degrade: broken branch fell back to main via a hidden watcher-wake nextTurn with the wake queue intact"); process.exit(0); } diff --git a/tests/fm-omp-primary-live-e2e.test.sh b/tests/fm-omp-primary-live-e2e.test.sh index 817813a2289..b1ca2733841 100755 --- a/tests/fm-omp-primary-live-e2e.test.sh +++ b/tests/fm-omp-primary-live-e2e.test.sh @@ -106,6 +106,34 @@ wait_idle() { return 1 } +# True once the session actually ran a turn FOR the watcher wake: the hidden +# watcher-wake entry carrying is followed by a terminal assistant +# record. Delivery alone is not the guarantee - an idle session that accepts the +# notification and never turns is exactly the failure this guard exists to +# catch, and a persistent second mate is this same adapter running as the +# primary of its own home. +session_handled_watcher_wake_after() { # + local file=$1 offset=$2 marker=$3 + tail -c "+$((offset + 1))" "$file" | node -e ' + const marker = process.argv[1]; + let input = ""; + process.stdin.setEncoding("utf8"); + process.stdin.on("data", chunk => { input += chunk; }); + process.stdin.on("end", () => { + let seen = false; + for (const line of input.trimEnd().split("\n")) { + if (!line) continue; + if (line.includes("\"firstmate-watcher-wake\"") && line.includes(marker)) seen = true; + if (!seen) continue; + let entry; + try { entry = JSON.parse(line); } catch { continue; } + if (entry.message?.role === "assistant" && entry.message.stopReason === "stop") process.exit(0); + } + process.exit(1); + }); + ' "$marker" +} + session_has_terminal_assistant_after() { local file=$1 offset=$2 marker=$3 tail -c "+$((offset + 1))" "$file" | node -e ' @@ -374,7 +402,18 @@ printf 'ok - OMP %s primary E2E proved fresh no-state and ordinary native discov "$OMP_VERSION" draft="human-draft-survives-omp-watcher-wake" PATH="$WRAPPER_BIN:$PATH" tmux send-keys -t "$TARGET" -l "$draft" -[ "$(composer_state)" = pending ] && [ "$(composer_text)" = "$draft" ] \ +# The TUI renders typed text asynchronously, so wait for the exact draft rather +# than sampling once: the assertion is that this exact draft is what the wake +# must leave alone, not how fast the terminal repaints it. +draft_rendered=0 +for _ in $(seq 1 120); do + if [ "$(composer_state)" = pending ] && [ "$(composer_text)" = "$draft" ]; then + draft_rendered=1 + break + fi + sleep 0.25 +done +[ "$draft_rendered" -eq 1 ] \ || { capture >&2; fail "OMP $OMP_VERSION did not render the exact editable draft before the watcher wake"; } wake_status="$HOME_DIR/state/omp-wake-preserve-$$.status" wake_offset=$(wc -c < "$session_file" | tr -d '[:space:]') @@ -394,6 +433,20 @@ done [ "$(composer_state)" = pending ] && [ "$(composer_text)" = "$draft" ] \ || { capture >&2; fail "OMP $OMP_VERSION watcher wake changed the exact editable draft"; } -printf 'ok - OMP %s primary E2E proved watcher delivery with an intact editable draft\n' "$OMP_VERSION" +# The session was idle with a pending draft when the watcher fired, and nobody +# typed anything: hidden next-turn delivery with triggerTurn has to start the +# handling turn by itself and carry it to a terminal assistant record. +wake_handled=0 +for _ in $(seq 1 480); do + if session_handled_watcher_wake_after "$session_file" "$wake_offset" "$wake_status"; then + wake_handled=1 + break + fi + sleep 0.25 +done +[ "$wake_handled" -eq 1 ] \ + || { capture >&2; fail "OMP $OMP_VERSION left an idle session holding the watcher wake without running a handling turn"; } + +printf 'ok - OMP %s primary E2E proved an idle session runs the watcher wake turn itself with an intact editable draft\n' "$OMP_VERSION" PATH="$WRAPPER_BIN:$PATH" tmux send-keys -t "$TARGET" Escape submit_omp /exit || fail "OMP $OMP_VERSION did not accept cleanup after draft preservation" diff --git a/tests/fm-omp-primary.test.sh b/tests/fm-omp-primary.test.sh index 12765900e52..27ea133abd0 100755 --- a/tests/fm-omp-primary.test.sh +++ b/tests/fm-omp-primary.test.sh @@ -377,7 +377,10 @@ extension.default(api); JS ) rc=$? - set -e + # Restore the suite's own mode. Leaving errexit on here would make every later + # test's command substitution abort the whole script silently instead of + # reporting its own "not ok" line. + set +e [ "$rc" -ne 0 ] || fail "OMP primary marker accepted a whitespace-bearing entrypoint" assert_contains "$out" 'OMP primary identity paths containing whitespace are unsupported' \ "OMP primary whitespace refusal was not actionable" @@ -640,10 +643,10 @@ if (watcherMessages.length !== 1 || !watcherMessages[0].message.content.includes } if ( watcherMessages[0].message.customType !== "firstmate-watcher-wake" || - watcherMessages[0].options?.deliverAs !== "steer" || + watcherMessages[0].options?.deliverAs !== "nextTurn" || watcherMessages[0].options?.triggerTurn !== true ) { - throw new Error(`OMP watcher notification did not preserve the editable draft delivery mode: ${JSON.stringify(watcherMessages[0])}`); + throw new Error(`OMP watcher notification did not use hidden next-turn delivery: ${JSON.stringify(watcherMessages[0])}`); } if (!existsSync(`${process.env.FM_STATE_OVERRIDE}/watch-successor-ready`)) { throw new Error("OMP actionable notification arrived before successor readiness"); @@ -782,7 +785,7 @@ JS # The shared core delivers the recovery handshake for every runtime bound to it, # so OMP must confirm a handling delivery exactly like Pi and OpenCode do: start # and verify the successor, run fm-watch-arm.sh --handling-delivered for the -# generation the successor reported, and only then deliver the wake steer. +# generation the successor reported, and only then deliver the wake notification. # Upstream covers Pi and OpenCode; this pins the fork's OMP binding of the same # contract so a future adapter change cannot silently drop it. test_native_omp_confirms_recovery_handling_delivery() { @@ -863,8 +866,8 @@ const rows = armRows(); const arms = rows.filter((row) => row.startsWith("arm=")); if (arms.length !== 2) throw new Error(`expected one successor arm, got ${arms.length}: ${rows.join(" | ")}`); if (steers !== 1) throw new Error(`expected exactly one wake steer, got ${steers}`); -if (deliveryOptions?.deliverAs !== "steer" || deliveryOptions?.triggerTurn !== true) { - throw new Error(`wake was not delivered as a turn-triggering steer: ${JSON.stringify(deliveryOptions)}`); +if (deliveryOptions?.deliverAs !== "nextTurn" || deliveryOptions?.triggerTurn !== true) { + throw new Error(`wake was not delivered as a turn-triggering hidden next-turn message: ${JSON.stringify(deliveryOptions)}`); } if (rowsAtDelivery !== 2) throw new Error(`wake delivery began before successor establishment (${rowsAtDelivery} arm rows)`); const confirmations = rows.filter((row) => row.startsWith("confirmed ")); @@ -883,8 +886,8 @@ JS ) || status=$? printf 'stop\n' > "$TMP_ROOT/native-handling-delivery.stop" 2>/dev/null || true expect_code 0 "$status" "OMP recovery handling delivery" - assert_contains "$out" omp-handling-delivery-ok "OMP did not confirm its recovery handling delivery after the wake steer" - pass "OMP confirms the recovery handling handshake after delivering its wake steer" + assert_contains "$out" omp-handling-delivery-ok "OMP did not confirm its recovery handling delivery after the wake notification" + pass "OMP confirms the recovery handling handshake after delivering its hidden next-turn wake" } # A refused handling handshake must be classified and surfaced exactly once @@ -1187,7 +1190,10 @@ const api = { registerTool() {}, // The runtime queues every wake as a steer into a running turn: delivery // resolves, no turn starts, and before_agent_start is never invoked for it. - sendMessage(message) { steers.push(String(message?.content ?? "")); }, + sendMessage(message) { + const content = String(message?.content ?? ""); + if (content.includes("omp unacknowledged wake")) steers.push(content); + }, }; const state = process.env.FM_STATE_OVERRIDE; const bound = Number(process.env.FM_WATCH_WAKE_CONSUME_TIMEOUT_MS); @@ -1234,6 +1240,218 @@ JS pass "OMP unacknowledged wake delivery keeps the successor chain and delivers once per close" } +make_omp_queue_fixture() { # + local fixture=$TMP_ROOT/$1 + mkdir -p "$fixture/.omp/extensions/lib" "$fixture/bin" "$fixture/state" "$fixture/config" + : > "$fixture/AGENTS.md" + git init -q -b main "$fixture" + cp "$ROOT/.omp/extensions/fm-primary-omp.ts" "$fixture/.omp/extensions/fm-primary-omp.ts" + cp "$ROOT/.omp/extensions/lib/fm-branch-dispatch.ts" "$fixture/.omp/extensions/lib/fm-branch-dispatch.ts" + cp "$ROOT/.omp/extensions/lib/fm-async-exec.ts" "$fixture/.omp/extensions/lib/fm-async-exec.ts" + cp "$ROOT/.omp/extensions/lib/fm-task-inbox-doorbell.ts" "$fixture/.omp/extensions/lib/fm-task-inbox-doorbell.ts" + cp "$ROOT/bin/fm-primary-watch-core.ts" "$fixture/bin/fm-primary-watch-core.ts" + cp "$ROOT/bin/fm-pi-compatible-runtimes" "$fixture/bin/fm-pi-compatible-runtimes" + cp "$ROOT/bin/fm-wake-lib.sh" "$fixture/bin/fm-wake-lib.sh" + cat > "$fixture/bin/fm-gate-refuse-lib.sh" <<'SH' +fm_is_gate_agent() { return 1; } +SH + cat > "$fixture/bin/fm-primary-scope-lib.sh" <<'SH' +fm_primary_scope_matches() { return 0; } +SH + cat > "$fixture/bin/fm-operational-input.sh" <<'SH' +#!/usr/bin/env bash +printf 'encoded:%s:%s' "$2" "$(cat)" +SH + cat > "$fixture/bin/fm-sessionstart-nudge.sh" <<'SH' +#!/usr/bin/env bash +exit 0 +SH + cat > "$fixture/bin/fm-turnend-guard.sh" <<'SH' +#!/usr/bin/env bash +exit 0 +SH + for script in fm-subagent-pretool-check.sh fm-cd-pretool-check.sh fm-arm-pretool-check.sh; do + cat > "$fixture/bin/$script" <<'SH' +#!/usr/bin/env bash +exit 0 +SH + done + chmod +x "$fixture/bin/"*.sh + printf '%s\n' "$fixture" +} + +write_queue_watcher() { # + cat > "$1/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 sleep 0.02; done +SH + chmod +x "$1/bin/fm-watch-arm.sh" +} + +test_native_omp_durable_queue_session_notifications() { + local fixture out status=0 + fixture=$(make_omp_queue_fixture native-queue-session) + write_queue_watcher "$fixture" + FM_STATE_OVERRIDE="$fixture/state" bash -c \ + '. "$1/bin/fm-wake-lib.sh"; fm_wake_append signal task-a.status "signal: task-a"' _ "$fixture" \ + || fail "the OMP queue fixture could not seed a durable wake row" + 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 { existsSync, readFileSync, writeFileSync } from "node:fs"; +import { pathToFileURL } from "node:url"; +const state = process.env.FM_STATE_OVERRIDE; +const wakes = []; +const handlers = new Map(); +const api = { + zod: { object: () => ({}) }, + on(name, handler) { handlers.set(name, handler); }, + registerCommand() {}, + registerTool() {}, + sendMessage(message, options) { + if (message?.customType === "firstmate-watcher-wake") wakes.push({ message, options }); + }, +}; +const count = () => existsSync(`${state}/watch-count`) ? Number(readFileSync(`${state}/watch-count`, "utf8")) : 0; +async function waitFor(pred, label) { + for (let i = 0; i < 500; i += 1) { if (pred()) return; await new Promise((r) => setTimeout(r, 10)); } + throw new Error(`timeout waiting for ${label}`); +} +writeFileSync(`${state}/.lock`, `${process.pid}\n`); +process.argv[1] = process.env.EXTENSION; +const module = await import(`${pathToFileURL(process.env.EXTENSION).href}?queue-session=${Date.now()}`); +module.default(api); +const context = { sessionManager: { getSessionFile: () => undefined, getSessionId: () => "sess-one" } }; +await handlers.get("session_start")({ type: "session_start" }, context); +await waitFor(() => count() === 1, "initial arm"); +await waitFor(() => wakes.length === 1, "session-start durable wake"); +if (wakes[0].options?.deliverAs !== "nextTurn" || wakes[0].options?.triggerTurn !== true) throw new Error("session-start wake used the wrong delivery mode"); +await handlers.get("session_switch")({ type: "session_switch", reason: "new" }, context); +await waitFor(() => wakes.length === 2, "session-switch durable wake"); +if (wakes.length !== 2) throw new Error(`expected two session-event notifications, got ${wakes.length}`); +writeFileSync(`${state}/watch-stop`, "stop\n"); +await handlers.get("session_shutdown")({ type: "session_shutdown" }, {}); +console.log("omp-durable-queue-session-notifications-ok"); +JS + ) || status=$? + printf 'stop\n' > "$fixture/state/watch-stop" 2>/dev/null || true + expect_code 0 "$status" "OMP durable queue session notifications" + assert_contains "$out" omp-durable-queue-session-notifications-ok "session events did not re-notify queued durable wakes: $out" + pass "OMP re-notifies durable wakes once on session start and switch" +} + +test_native_omp_empty_queue_suppresses_session_notifications() { + local fixture out status=0 + fixture=$(make_omp_queue_fixture native-queue-empty) + write_queue_watcher "$fixture" + 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 { writeFileSync } from "node:fs"; +import { pathToFileURL } from "node:url"; +const state = process.env.FM_STATE_OVERRIDE; +let wakes = 0; +const handlers = new Map(); +const api = { zod: { object: () => ({}) }, on(name, handler) { handlers.set(name, handler); }, registerCommand() {}, registerTool() {}, sendMessage(message) { if (message?.customType === "firstmate-watcher-wake") wakes += 1; } }; +writeFileSync(`${state}/.lock`, `${process.pid}\n`); +process.argv[1] = process.env.EXTENSION; +const module = await import(`${pathToFileURL(process.env.EXTENSION).href}?queue-empty=${Date.now()}`); +module.default(api); +const context = { sessionManager: { getSessionFile: () => undefined, getSessionId: () => "sess-one" } }; +await handlers.get("session_start")({ type: "session_start" }, context); +await handlers.get("session_switch")({ type: "session_switch", reason: "new" }, context); +await new Promise((r) => setTimeout(r, 100)); +if (wakes !== 0) throw new Error(`empty queue produced ${wakes} notifications`); +writeFileSync(`${state}/watch-stop`, "stop\n"); +await handlers.get("session_shutdown")({ type: "session_shutdown" }, {}); +console.log("omp-empty-queue-session-notifications-ok"); +JS + ) || status=$? + printf 'stop\n' > "$fixture/state/watch-stop" 2>/dev/null || true + expect_code 0 "$status" "OMP empty queue session notifications" + assert_contains "$out" omp-empty-queue-session-notifications-ok "empty durable queue produced a session notification: $out" + pass "OMP suppresses session notifications when the durable queue is empty" +} + +test_native_omp_core_handoff_suppresses_queue_notification() { + local fixture out status=0 + fixture=$(make_omp_queue_fixture native-queue-core-handoff) + write_queue_watcher "$fixture" + mkdir -p "$fixture/state/extensions/omp-primary-watch" + printf '{"version":2,"pending":[{"version":1,"token":"1-2-3","message":"signal: core owned undelivered close","predecessorArmPid":""}]}\n' \ + > "$fixture/state/extensions/omp-primary-watch/session-replacement-actionable.json" + 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 { writeFileSync } from "node:fs"; +import { pathToFileURL } from "node:url"; +const state = process.env.FM_STATE_OVERRIDE; +const wakes = []; +const handlers = new Map(); +const api = { zod: { object: () => ({}) }, on(name, handler) { handlers.set(name, handler); }, registerCommand() {}, registerTool() {}, sendMessage(message, options) { if (message?.customType === "firstmate-watcher-wake") wakes.push({ message, options }); } }; +writeFileSync(`${state}/.lock`, `${process.pid}\n`); +process.argv[1] = process.env.EXTENSION; +const module = await import(`${pathToFileURL(process.env.EXTENSION).href}?queue-core=${Date.now()}`); +module.default(api); +await handlers.get("session_start")({ type: "session_start" }, { sessionManager: { getSessionFile: () => undefined, getSessionId: () => "sess-one" } }); +for (let i = 0; i < 300 && wakes.length === 0; i += 1) await new Promise((r) => setTimeout(r, 10)); +if (wakes.length !== 1 || !wakes[0].message.content.includes("core owned undelivered close")) throw new Error(`core handoff delivery was not exclusive: ${JSON.stringify(wakes)}`); +if (wakes[0].options?.deliverAs !== "nextTurn" || wakes[0].options?.triggerTurn !== true) throw new Error("core handoff used the wrong delivery mode"); +writeFileSync(`${state}/watch-stop`, "stop\n"); +await handlers.get("session_shutdown")({ type: "session_shutdown" }, {}); +console.log("omp-core-handoff-queue-notification-ok"); +JS + ) || status=$? + printf 'stop\n' > "$fixture/state/watch-stop" 2>/dev/null || true + expect_code 0 "$status" "OMP core handoff queue notification" + assert_contains "$out" omp-core-handoff-queue-notification-ok "core handoff was duplicated by queue notification: $out" + pass "OMP core handoff suppresses duplicate durable queue notification" +} + +test_native_omp_delivered_handoff_does_not_suppress_queue_notification() { + local fixture out status=0 + fixture=$(make_omp_queue_fixture native-queue-delivered-handoff) + write_queue_watcher "$fixture" + mkdir -p "$fixture/state/extensions/omp-primary-watch" + printf '{"version":2,"pending":[{"version":1,"token":"1-2-3","message":"signal: already delivered","predecessorArmPid":"","delivered":true}]}\n' \ + > "$fixture/state/extensions/omp-primary-watch/session-replacement-actionable.json" + FM_STATE_OVERRIDE="$fixture/state" bash -c \ + '. "$1/bin/fm-wake-lib.sh"; fm_wake_append signal task-a.status "signal: task-a"' _ "$fixture" \ + || fail "the delivered-handoff fixture could not seed a durable wake row" + 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 { writeFileSync } from "node:fs"; +import { pathToFileURL } from "node:url"; +const state = process.env.FM_STATE_OVERRIDE; +const wakes = []; +const handlers = new Map(); +const api = { zod: { object: () => ({}) }, on(name, handler) { handlers.set(name, handler); }, registerCommand() {}, registerTool() {}, sendMessage(message, options) { if (message?.customType === "firstmate-watcher-wake") wakes.push({ message, options }); } }; +writeFileSync(`${state}/.lock`, `${process.pid}\n`); +process.argv[1] = process.env.EXTENSION; +const module = await import(`${pathToFileURL(process.env.EXTENSION).href}?queue-delivered-handoff=${Date.now()}`); +module.default(api); +await handlers.get("session_start")({ type: "session_start" }, { sessionManager: { getSessionFile: () => undefined, getSessionId: () => "sess-one" } }); +for (let i = 0; i < 300 && wakes.length === 0; i += 1) await new Promise((r) => setTimeout(r, 10)); +if (wakes.length !== 1) throw new Error(`delivered handoff suppressed or duplicated the queue wake: ${wakes.length}`); +if (wakes[0].options?.deliverAs !== "nextTurn" || wakes[0].options?.triggerTurn !== true) throw new Error("queue wake used the wrong delivery mode"); +writeFileSync(`${state}/watch-stop`, "stop\n"); +await handlers.get("session_shutdown")({ type: "session_shutdown" }, {}); +console.log("omp-delivered-handoff-queue-notification-ok"); +JS + ) || status=$? + printf 'stop\n' > "$fixture/state/watch-stop" 2>/dev/null || true + expect_code 0 "$status" "OMP delivered handoff queue notification" + assert_contains "$out" omp-delivered-handoff-queue-notification-ok "delivered handoff suppressed durable queue notification: $out" + pass "OMP ignores already-delivered handoffs when notifying queued wakes" +} + test_resolve_path_uses_node_when_readlink_f_is_unavailable test_exact_bun_omp_primary_identity test_standalone_omp_primary_identity @@ -1247,3 +1465,7 @@ test_native_omp_confirms_recovery_handling_delivery test_native_omp_refused_handling_delivery_is_typed_once test_native_omp_session_switch_carries_inflight_actionable_close test_native_omp_unacknowledged_wake_keeps_successor_chain +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_delivered_handoff_does_not_suppress_queue_notification diff --git a/tests/fm-wake-queue.test.sh b/tests/fm-wake-queue.test.sh index 2fc58219df8..f2716c5000f 100755 --- a/tests/fm-wake-queue.test.sh +++ b/tests/fm-wake-queue.test.sh @@ -13,7 +13,6 @@ set -u WATCH="$ROOT/bin/fm-watch.sh" DRAIN="$ROOT/bin/fm-wake-drain.sh" - TMP_ROOT=$(fm_test_tmproot fm-wake-tests) # Wait briefly for to become non-empty.