diff --git a/.omp/extensions/fm-primary-omp.ts b/.omp/extensions/fm-primary-omp.ts index 1f48159a202..1533d70cc42 100644 --- a/.omp/extensions/fm-primary-omp.ts +++ b/.omp/extensions/fm-primary-omp.ts @@ -252,7 +252,31 @@ export default function (omp: ExtensionAPI) { const deliverSessionstartNudge = (forceForNativeSwitch = false): void => { watch.markLoaded(); - pendingStartupNudge = runSessionstartNudge(forceForNativeSwitch); + const nudge = runSessionstartNudge(forceForNativeSwitch); + if (!forceForNativeSwitch) { + pendingStartupNudge = nudge; + return; + } + // A native /new or /resume switch replaces the conversation while this + // process still owns the lock, so the watcher re-arms immediately and its + // first wake can start an agent-initiated turn. OMP never emits + // before_agent_start for such a turn, nor for a captain prompt queued into + // it, so a nudge staged for before_agent_start would stay parked past the + // new session's first turn. Append the instruction to the replacement + // session's context right now instead, ahead of any wake, and leave nothing + // staged so the next before_agent_start cannot deliver it a second time. + pendingStartupNudge = ""; + if (!nudge) return; + omp.sendMessage( + { + customType: "firstmate-sessionstart-nudge", + content: nudge, + display: false, + attribution: "agent", + details: { kind: "session-start", runtime: "omp" }, + }, + { deliverAs: "nextTurn" }, + ); }; omp.on("session_start", (_event, ctx) => { diff --git a/docs/sessionstart-nudge.md b/docs/sessionstart-nudge.md index 105fa3fbaa9..479ec33cff1 100644 --- a/docs/sessionstart-nudge.md +++ b/docs/sessionstart-nudge.md @@ -24,7 +24,7 @@ Every path exits 0, including malformed state and adapter errors, because a Clau | Codex | `.codex/hooks.json` anchors to the hook process working directory, verifies a Firstmate-shaped hook-bearing root, and executes the wrapper. | Native stdout context injection is supported. | | OpenCode | `.opencode/plugins/fm-primary-sessionstart-nudge.js` listens for `session.created`, runs once per session id, and calls `client.session.promptAsync` only when the wrapper prints a nudge. | Interactive TUI delivery is supported; headless `opencode run` is intentionally fail-open because the process can exit before the queued turn. | | Pi / pi-signed | `.pi/extensions/fm-primary-turnend-guard.ts` handles `session_start` reasons `startup`, `new`, and `resume`, then injects the wrapper output with `pi.sendMessage`. | The custom message reaches model context without racing an initial positional prompt. | -| OMP | `.omp/extensions/fm-primary-omp.ts` runs the wrapper on native `session_start` and `session_switch`, then injects the result as one hidden `before_agent_start` message. A native `/new` or `/resume` switch re-injects the same typed instruction directly instead of through the wrapper, which stays silent while this session still holds the lock. | The custom message reaches model context without racing the launch prompt, and native project discovery loads the adapter without a trust prompt. | +| OMP | `.omp/extensions/fm-primary-omp.ts` runs the wrapper on native `session_start` and injects the result as one hidden `before_agent_start` message bound to the launch prompt. A native `/new` or `/resume` switch instead appends the same typed instruction to the replacement session's context at once through `sendMessage` without starting a turn: the wrapper stays silent while this session still holds the lock, and the restored watcher's first wake can start an agent-initiated turn that never reaches `before_agent_start`. `/fork` still invokes the wrapper silently under the held lock, and OMP reloads likewise keep that wrapper invocation silent while following the native `resume` switch transport. | The custom message reaches model context without racing the launch prompt or the restored watcher's first wake, and native project discovery loads the adapter without a trust prompt. | | Grok | `.grok/hooks/fm-primary-sessionstart-nudge.json` registers a project `SessionStart` hook and invokes the wrapper through inline-defaulted `${GROK_WORKSPACE_ROOT:-}`. | The project hook runs when the checkout is trusted, but Grok currently discards hook stdout from model context, so this path is intentionally fail-open. | The OpenCode nudge runs only on `session.created`. @@ -38,7 +38,8 @@ That alternative expands trust and writes outside this repository, so Firstmate `tests/fm-sessionstart-nudge.test.sh` proves wrapper silence for both gate signals, an unmarked linked worktree, a missing state directory, and an already-owned lock. It proves exact U+2063 `FIRSTMATE_OP:`-prefixed, `session-start`-typed one-line output for a plain primary and a marked linked secondmate primary. `tests/fm-pi-primary-live-e2e.test.sh` and `tests/fm-opencode-primary-live-e2e.test.sh` exercise native startup paths with first-message and later-message Ahoy regressions. -`tests/fm-omp-primary-live-e2e.test.sh` covers OMP's native discovery and once-only startup delivery, including a `/new` switch and an exact-session resume launch. +`tests/fm-omp-primary.test.sh` proves each in-process `/new` and `/resume` switch appends exactly one instruction without staging a second copy, including a second `/new` with no `before_agent_start` in between, while `/fork` stays silent under the held lock. +`tests/fm-omp-primary-live-e2e.test.sh` covers OMP's native discovery and once-only startup delivery, including two consecutive `/new` switches whose instruction precedes each new session's first assistant turn, and an exact-session resume launch. `tests/fm-turnend-guard.test.sh`, `tests/fm-pi-watch-extension.test.sh`, and `tests/fm-daemon.test.sh` cover marked guard, monitoring, and away-mode delivery. [`verification/supervision.md`](verification/supervision.md#native-session-start-delivery) records the active version-scoped transport evidence. diff --git a/docs/supervision-protocols/omp.md b/docs/supervision-protocols/omp.md index 64be49f6df0..c78e01ce413 100644 --- a/docs/supervision-protocols/omp.md +++ b/docs/supervision-protocols/omp.md @@ -12,7 +12,7 @@ When this session owns supervision and away mode is not active: 6. The extension starts `bin/fm-watch-arm.sh --restart`, keeps the child attached to the live OMP process, and owns every later successor launch. The tool and the fallback command return only after that child reports readiness, so a `watcher: FAILED` readiness timeout is a real failure to handle under step 11 rather than a slow success. 7. OMP `/new`, `/resume`, `/fork`, and session reload emit `session_switch`, replace the prior extension generation, and restore the watcher without a foreground watcher command. - `/new` and `/resume` also inject the session-start instruction exactly once for the new conversation. + `/new` and `/resume` also append the session-start instruction exactly once to the new conversation, ahead of the restored watcher's first wake. 8. After an actionable child close, the shared watcher core rechecks session-lock ownership and verifies one successor before it delivers the follow-up notification; a replacement generation receives an actionable close whose prior delivery was not yet consumed. 9. Ordinary work, turn completion, and ordinary notification handling must not call `fm_watch_arm_omp` again because continuity is extension-owned. 10. An unexpected child close enters bounded exponential retry, and an exhausted retry or lost session lock is surfaced as a watcher failure. diff --git a/docs/verification/supervision.md b/docs/verification/supervision.md index 2d9dc531e63..fa64afae168 100644 --- a/docs/verification/supervision.md +++ b/docs/verification/supervision.md @@ -114,6 +114,18 @@ ok - OMP omp/17.2.10 primary E2E proved watcher delivery with an intact editable ``` The live guard observed the watcher wake in the OMP session and found the exact draft unchanged after delivery. +The OMP 18.1.5 repeated-`/new` delivery guard ran on 2026-09-05 after the adapter began appending the replacement instruction through `sendMessage` at switch time; the guard now drives two consecutive `/new` switches and requires each new session's instruction to precede its first assistant record. + +```sh +FM_OMP_PRIMARY_LIVE_E2E=1 bin/fm-test-run.sh tests/fm-omp-primary-live-e2e.test.sh +``` + +```text +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 watcher delivery with an intact editable draft +``` + +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. Standalone OMP executable compatibility is recorded in [runtime backend verification](runtime-backends.md#omp-lifecycle). @@ -354,8 +366,8 @@ FM_OMP_PRIMARY_LIVE_E2E=1 bin/fm-test-run.sh tests/fm-omp-primary-live-e2e.test. not ok - OMP /new did not inject exactly one startup instruction for the new session ``` -The identical pre-port failure establishes a pre-existing OMP 18.1.5 session-start-nudge compatibility finding rather than a watcher-continuity regression. -The provider-free adapter regression remains the current proof that its `/new` and `/resume` replacement handlers each stage one nudge while re-arming, and the live guard remains intentionally red until that separate compatibility finding is resolved. +The identical pre-port failure established a pre-existing OMP 18.1.5 session-start-nudge compatibility finding rather than a watcher-continuity regression. +That finding was resolved on 2026-09-05; the [native session-start delivery](#native-session-start-delivery) section records the passing repeated-`/new` guard, and `tests/fm-omp-primary.test.sh` keeps the provider-free proof that each `/new` and `/resume` replacement handler appends exactly one instruction while re-arming, including a second `/new` with no `before_agent_start` in between. The remaining primary harnesses are not applicable because they do not bind the shared Pi-compatible extension generation lifecycle. The once-per-generation recovery bound and immediate handling-successor poll were verified on 2026-08-21 at revision `549dd1e0ff05f96607c5e7457b4d8e3d7396bd16` with the tracked Pi extension, real watcher processes, and an isolated home. diff --git a/tests/fm-omp-primary-live-e2e.test.sh b/tests/fm-omp-primary-live-e2e.test.sh index 21d703e3cf8..ca337bc45a4 100755 --- a/tests/fm-omp-primary-live-e2e.test.sh +++ b/tests/fm-omp-primary-live-e2e.test.sh @@ -174,6 +174,28 @@ wait_pid_change() { return 1 } +# The replacement session's instruction must already be in context when its +# first turn starts, whether the restored watcher's first wake or a captain +# prompt starts that turn. +session_nudge_precedes_first_assistant() { # + node -e ' + const lines = require("node:fs").readFileSync(process.argv[1], "utf8").trimEnd().split("\n"); + let nudge = -1; + let assistant = -1; + lines.forEach((line, index) => { + let entry; + try { entry = JSON.parse(line); } catch { return; } + if (nudge < 0 && entry.type === "custom_message" && entry.customType === "firstmate-sessionstart-nudge") nudge = index; + if (assistant < 0 && entry.message?.role === "assistant") assistant = index; + }); + process.exit(nudge >= 0 && (assistant < 0 || nudge < assistant) ? 0 : 1); + ' "$1" +} + +newest_session_file() { + find "$SESSION_DIR" -maxdepth 1 -type f -name '*.jsonl' -print | sort | tail -n 1 +} + submit_omp() { local text=$1 bun bin bun=$(sed -n '3p' "$MARKER") @@ -269,6 +291,29 @@ done [ "${sessions_after:-0}" -gt "$sessions_before" ] || fail "OMP /new did not create a native session" [ "$(grep -Rhc '"customType":"firstmate-sessionstart-nudge"' "$SESSION_DIR"/*.jsonl | awk '{s+=$1} END{print s+0}')" -eq 2 ] \ || fail "OMP /new did not inject exactly one startup instruction for the new session" +session_nudge_precedes_first_assistant "$(newest_session_file)" \ + || fail "OMP /new startup instruction did not precede the new session's first assistant turn" + +# A second /new must deliver its own instruction even though the first +# replacement's turn may have been started by the restored watcher rather than +# by a captain prompt. +sessions_before=$sessions_after +submit_omp /new || fail "OMP second /new command was not submitted" +third_watch_pid=$(wait_pid_change "$WATCH_LOCK" "$second_watch_pid") \ + || fail "OMP second /new did not replace and restore the extension-owned watcher generation" +submit_omp 'Reply exactly OMP_SECOND_NEW_READY.' || fail "second new OMP session did not accept a prompt" +wait_text OMP_SECOND_NEW_READY || fail "second new OMP session did not complete its first turn" +for _ in $(seq 1 240); do + sessions_after=$(find "$SESSION_DIR" -maxdepth 1 -type f -name '*.jsonl' | wc -l | tr -d ' ') + [ "$sessions_after" -gt "$sessions_before" ] && break + sleep 0.25 +done +[ "${sessions_after:-0}" -gt "$sessions_before" ] || fail "OMP second /new did not create a native session" +[ "$(grep -Rhc '"customType":"firstmate-sessionstart-nudge"' "$SESSION_DIR"/*.jsonl | awk '{s+=$1} END{print s+0}')" -eq 3 ] \ + || fail "OMP second /new did not inject exactly one startup instruction for its new session" +session_nudge_precedes_first_assistant "$(newest_session_file)" \ + || fail "OMP second /new startup instruction did not precede its session's first assistant turn" +second_watch_pid=$third_watch_pid submit_omp /exit || fail "OMP primary /exit was not submitted" for _ in $(seq 1 240); do @@ -296,7 +341,7 @@ second_omp_pid=$(sed -n '2p' "$MARKER") [ "$second_omp_pid" = "$(cat "$LOCK")" ] || fail "OMP resume did not bind the lock to the new process" resume_watch_pid=$(wait_pid_change "$WATCH_LOCK" "$second_watch_pid") \ || fail "OMP resume did not restore a live watcher generation" -[ "$(grep -Rhc '"customType":"firstmate-sessionstart-nudge"' "$SESSION_DIR"/*.jsonl | awk '{s+=$1} END{print s+0}')" -eq 3 ] \ +[ "$(grep -Rhc '"customType":"firstmate-sessionstart-nudge"' "$SESSION_DIR"/*.jsonl | awk '{s+=$1} END{print s+0}')" -eq 4 ] \ || fail "OMP process resume did not add exactly one fresh startup instruction" kill -0 "$resume_watch_pid" 2>/dev/null || fail "OMP resume watcher is not live" @@ -322,7 +367,7 @@ grep -R -F 'OMP_AWAY_DELIVERY' "$SESSION_DIR"/*.jsonl >/dev/null 2>&1 \ grep -R -F 'away-supervisor' "$SESSION_DIR"/*.jsonl >/dev/null 2>&1 \ || fail "OMP away-mode notification lost its operational-input kind" wait_idle || fail "OMP $OMP_VERSION did not reach an idle boundary after away-mode delivery" -printf 'ok - OMP %s primary E2E proved fresh no-state and ordinary native discovery, exact ownership, once-only startup, guarded watcher startup, /new continuity, shutdown, resume, and away-mode delivery\n' \ +printf 'ok - OMP %s 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\n' \ "$OMP_VERSION" draft="human-draft-survives-omp-watcher-wake" PATH="$WRAPPER_BIN:$PATH" tmux send-keys -t "$TARGET" -l "$draft" diff --git a/tests/fm-omp-primary.test.sh b/tests/fm-omp-primary.test.sh index 5dafa540cf7..fdc7e26a995 100755 --- a/tests/fm-omp-primary.test.sh +++ b/tests/fm-omp-primary.test.sh @@ -536,24 +536,49 @@ if (await handlers.get("before_agent_start")({ type: "before_agent_start" }, {}) throw new Error("startup nudge repeated within one OMP session"); } writeFileSync(`${process.env.FM_STATE_OVERRIDE}/.lock`, `${process.pid}\n`); +// A native switch re-arms the watcher at once, and OMP starts its first wake as +// an agent-initiated turn that never emits before_agent_start. The replacement +// nudge therefore has to land in the replacement session context at switch time, +// through sendMessage, and nothing may stay staged for a later before_agent_start. +const switchNudges = () => watcherMessages.filter((entry) => entry.message.customType === "firstmate-sessionstart-nudge"); +async function expectSwitchNudge(label, expectedTotal) { + const nudges = switchNudges(); + if (nudges.length !== expectedTotal) { + throw new Error(`${label} did not append exactly one startup instruction (${nudges.length} total): ${JSON.stringify(nudges)}`); + } + const latest = nudges[nudges.length - 1]; + if ( + latest.message.content !== "encoded:session-start:Run `bin/fm-session-start.sh` now, exactly once, before executing any other instructions." || + latest.message.attribution !== "agent" || + latest.message.display !== false || + latest.options?.deliverAs !== "nextTurn" || + latest.options?.triggerTurn !== undefined + ) { + throw new Error(`${label} startup instruction was not appended as hidden agent context without a turn: ${JSON.stringify(latest)}`); + } + for (const attempt of [1, 2]) { + if (await handlers.get("before_agent_start")({ type: "before_agent_start" }, {}) !== undefined) { + throw new Error(`${label} staged its startup instruction for before_agent_start too (attempt ${attempt})`); + } + } +} await handlers.get("session_switch")({ type: "session_switch", reason: "new" }, extensionContext); await waitForWatchCount(1, "in-process OMP /new automatic watcher arm"); -const newStartup = await handlers.get("before_agent_start")({ type: "before_agent_start" }, {}); -if (newStartup?.message?.customType !== "firstmate-sessionstart-nudge" || newStartup.message.attribution !== "agent") { - throw new Error(`in-process OMP /new lost its once-only startup instruction: ${JSON.stringify(newStartup)}`); -} -if (await handlers.get("before_agent_start")({ type: "before_agent_start" }, {}) !== undefined) { - throw new Error("in-process OMP /new repeated its startup instruction"); -} +await expectSwitchNudge("in-process OMP /new", 1); +// Regression: a second /new with no before_agent_start in between must still +// deliver its own nudge because the replacement turn was agent-initiated. +await handlers.get("session_switch")({ type: "session_switch", reason: "new" }, extensionContext); +await waitForWatchCount(2, "in-process OMP second /new automatic watcher arm"); +await expectSwitchNudge("in-process OMP second /new", 2); await handlers.get("session_switch")({ type: "session_switch", reason: "resume" }, extensionContext); -await waitForWatchCount(2, "in-process OMP /resume automatic watcher arm"); -const resumeStartup = await handlers.get("before_agent_start")({ type: "before_agent_start" }, {}); -if (resumeStartup?.message?.customType !== "firstmate-sessionstart-nudge" || resumeStartup.message.attribution !== "agent") { - throw new Error(`in-process OMP /resume lost its once-only startup instruction: ${JSON.stringify(resumeStartup)}`); -} -if (await handlers.get("before_agent_start")({ type: "before_agent_start" }, {}) !== undefined) { - throw new Error("in-process OMP /resume repeated its startup instruction"); +await waitForWatchCount(3, "in-process OMP /resume automatic watcher arm"); +await expectSwitchNudge("in-process OMP /resume", 3); +await handlers.get("session_switch")({ type: "session_switch", reason: "fork" }, extensionContext); +await waitForWatchCount(4, "in-process OMP /fork automatic watcher arm"); +if (switchNudges().length !== 3 || await handlers.get("before_agent_start")({ type: "before_agent_start" }, {}) !== undefined) { + throw new Error("in-process OMP /fork delivered a startup instruction while this session still holds the lock"); } +watcherMessages.length = 0; const signal = new AbortController().signal; const stop = await handlers.get("session_stop")({