diff --git a/.agents/skills/harness-adapters/SKILL.md b/.agents/skills/harness-adapters/SKILL.md index d20a7dfaa6..988466d9ca 100644 --- a/.agents/skills/harness-adapters/SKILL.md +++ b/.agents/skills/harness-adapters/SKILL.md @@ -102,7 +102,7 @@ Before inspecting or changing session-open behavior, read `docs/sessionstart-nud At session start, `bin/fm-session-start.sh` prints exactly one watcher supervision block for the detected primary harness. Do not substitute another harness's wait shape when resuming supervision. -Claude's Stop `asyncRewake` hook (`bin/fm-claude-stop-autoarm.sh`) owns tokenless re-arm around `bin/fm-watch-arm.sh`, and Grok uses tracked background-notify cycles around `bin/fm-watch-arm.sh`. +Claude's Stop `asyncRewake` hook (`bin/fm-claude-stop-autoarm.sh`) owns tokenless re-arm around `bin/fm-watch-arm.sh`, and Grok uses one tracked `bin/fm-watch-grok-longrun.sh` task that keeps routine declared-wait rechecks inside the task. Codex uses bounded foreground checkpoints through `bin/fm-watch-checkpoint.sh` because Codex cannot reason while a foreground tool call is running. OpenCode uses `.opencode/plugins/fm-primary-watch-arm.js`, which coordinates with the turn-end guard plugin and wakes the TUI with `client.session.promptAsync`. Pi and pi-signed use the tracked `.pi/extensions/fm-primary-turnend-guard.ts` plus the tracked `.pi/extensions/fm-primary-pi-watch.ts`, both project-local extensions the Pi engine auto-discovers once trusted. @@ -357,7 +357,7 @@ Grok 0.2.112 exposes native same-process Stop continuation in its running payloa The exact adaptive and malformed-input contract is owned by `docs/turnend-guard.md`. The tracked Claude hook entries whose event Grok already covers through its own `.grok/hooks/` registration skip themselves under `GROK_AGENT` or `GROK_HOOK_EVENT`, because Grok also loads Claude-compatible project settings and otherwise creates a second blocking path; the exact marker set and why `GROK_SESSION_ID` is excluded are owned by `docs/turnend-guard.md` "Harness integrations". Project-local Grok hooks require folder trust, verified with launch-time `--trust`; if the primary firstmate checkout is not trusted for Grok hooks, this primary guard fails open and `fm-guard.sh` remains the next-command alarm. -Grok's primary watcher protocol remains background-notify around `bin/fm-watch-arm.sh`; native Stop continuation does not provide Pi-like extension ownership. +Grok's primary watcher protocol uses one tracked `bin/fm-watch-grok-longrun.sh` task that completes only for an actionable watcher result or failure; native Stop continuation does not provide Pi-like extension ownership. ## cursor (VERIFIED CREWMATE/SCOUT 2026-08-11 on tmux and 2026-08-12 on Herdr, and SECONDMATE/PRIMARY 2026-08-13, Cursor Agent CLI 2026.08.11-e8db854) diff --git a/README.md b/README.md index ea321a8f8c..0878311a35 100644 --- a/README.md +++ b/README.md @@ -68,7 +68,7 @@ Backend-specific setup is linked in [Documentation](#documentation). ### Recommended harnesses **Claude Code, Grok, and Pi are equal co-primary recommendations** for running the primary firstmate session, with `pi-signed` supported as Pi's distinct signed-wrapper identity. -Claude Code uses a tracked Stop hook for tokenless watcher re-arm and rewake, Grok uses background-notify wake cycles, and Pi uses its tracked primary watcher extension. +Claude Code uses a tracked Stop hook for tokenless watcher re-arm and rewake, Grok uses a long-lived tracked watcher task that completes only for actionable work, and Pi uses its tracked primary watcher extension. All three have verified turn-end guard paths when launched with their documented setup. Pick whichever one matches your subscription and workflow. diff --git a/bin/fm-arm-command-policy.mjs b/bin/fm-arm-command-policy.mjs index 846965fa9a..eb6c79b6bd 100755 --- a/bin/fm-arm-command-policy.mjs +++ b/bin/fm-arm-command-policy.mjs @@ -1,5 +1,5 @@ #!/usr/bin/env node -// Semantic policy for watcher arm and checkpoint shell commands. +// Semantic policy for watcher arm, checkpoint, and Grok long-runner shell commands. // // This parser is deliberately narrow. // It recognizes executed command positions without evaluating, expanding, @@ -44,7 +44,7 @@ function parseArguments(argv) { } function rawMentionsProtected(command) { - return /(?:^|[/\s'"`(])fm-watch(?:-(?:arm|checkpoint))?\.sh\b/.test(normalizeLineContinuations(command)); + return /(?:^|[/\s'"`(])fm-watch(?:-(?:arm|checkpoint|grok-longrun))?\.sh\b/.test(normalizeLineContinuations(command)); } function rawMentionsBroadKill(command) { @@ -598,6 +598,7 @@ export function commandPosition(tokens) { const PROTECTED_SCRIPTS = [ { relative: "bin/fm-watch-arm.sh", kind: "arm" }, { relative: "bin/fm-watch-checkpoint.sh", kind: "checkpoint" }, + { relative: "bin/fm-watch-grok-longrun.sh", kind: "grok-longrun" }, { relative: "bin/fm-watch.sh", kind: "watch" }, ]; diff --git a/bin/fm-arm-pretool-check.sh b/bin/fm-arm-pretool-check.sh index 0fa78d1b01..3bae3ce0b0 100755 --- a/bin/fm-arm-pretool-check.sh +++ b/bin/fm-arm-pretool-check.sh @@ -1,8 +1,8 @@ #!/usr/bin/env bash -# Stable PreToolUse transport for the watcher-arm command policy. +# Stable PreToolUse transport for the watcher command policy. # -# A firstmate primary must arm the watcher or run a Codex checkpoint as a -# standalone verified harness call. +# A firstmate primary must arm the watcher, run a Codex checkpoint, or run the +# Grok long-runner as a standalone verified harness call. # bin/fm-arm-command-policy.mjs is the sole owner of shell classification, # protected execution identity, the blessed setup tree, and deny reason codes. # This wrapper only acquires the harness payload, discovers the active roots, diff --git a/bin/fm-supervision-instructions.sh b/bin/fm-supervision-instructions.sh index a503bd9d35..1a2d3f3d31 100755 --- a/bin/fm-supervision-instructions.sh +++ b/bin/fm-supervision-instructions.sh @@ -147,7 +147,7 @@ repair_line() { printf '%s%s\n' "$prefix" 'repair missing watcher supervision by letting the OpenCode TUI plugin arm after idle; use bin/fm-watch-arm.sh only as a manual recovery probe if the plugin reports failure.' ;; grok) - printf '%s%s\n' "$prefix" 'repair missing watcher supervision with bin/fm-watch-arm.sh as its own Grok tracked background task, never shell &.' + printf '%s%s\n' "$prefix" 'repair missing watcher supervision with bin/fm-watch-grok-longrun.sh as its own Grok tracked background task, never shell &.' ;; cursor) printf '%s%s\n' "$prefix" 'watcher supervision is owned by the stop-hook park; inspect the hook registration and watcher startup path before ending the turn.' @@ -173,7 +173,7 @@ ordinary_wake_line() { printf '%s\n' '- Ordinary wake: the OpenCode TUI plugin already owns watcher continuity; do not arm manually.' ;; grok) - printf '%s\n' '- Ordinary wake: re-arm exactly one bin/fm-watch-arm.sh Grok tracked background task as directed below.' + printf '%s\n' '- Ordinary wake: re-arm exactly one bin/fm-watch-grok-longrun.sh Grok tracked background task as directed below.' ;; cursor) printf '%s\n' '- Ordinary wake: the stop-hook park (bin/fm-turnend-guard-cursor.sh) already owns watcher continuity; drain and handle the wake, and do not arm another cycle yourself.' diff --git a/bin/fm-test-run.sh b/bin/fm-test-run.sh index 1e90445ceb..ab209f10ce 100755 --- a/bin/fm-test-run.sh +++ b/bin/fm-test-run.sh @@ -156,7 +156,7 @@ family_for_basename() { fm-supervision-events.test.sh|fm-turnend-guard.test.sh|fm-wake-daemon-lifecycle-e2e.test.sh|\ fm-wake-drain-unread-status.test.sh|\ fm-tool-update-check.test.sh|\ - fm-wake-queue.test.sh|fm-watch-arm.test.sh|fm-watch-checkpoint.test.sh|fm-watch-recovery-loop.test.sh|\ + fm-wake-queue.test.sh|fm-watch-arm.test.sh|fm-watch-checkpoint.test.sh|fm-grok-watch-longrun.test.sh|fm-watch-recovery-loop.test.sh|\ fm-watch-triage.test.sh|fm-task-inbox.test.sh|\ fm-watcher-lock.test.sh|fm-inactive-reconcile.test.sh) printf '%s\n' watcher-wake-lock @@ -422,6 +422,7 @@ tests/fm-gate-refuse.test.sh 4071 tests/fm-gitignore-config.test.sh 63 tests/fm-gotmp.test.sh 762 tests/fm-grok-continuity-live-e2e.test.sh 19 +tests/fm-grok-watch-longrun.test.sh 3000 tests/fm-grok-stop-live-e2e.test.sh 21 tests/fm-guard-stale-banner.test.sh 11280 tests/fm-harness-liveness-drift-live-e2e.test.sh 19 diff --git a/bin/fm-watch-arm.sh b/bin/fm-watch-arm.sh index d134f51940..5f06c33148 100755 --- a/bin/fm-watch-arm.sh +++ b/bin/fm-watch-arm.sh @@ -6,7 +6,8 @@ # daemon owns triage and the watcher exits on every wake for the daemon to # classify. Reliability depends on arming through a mechanism that SURVIVES the # call and NOTIFIES on exit, so firstmate must run this script as the harness's -# own tracked background task (e.g. run_in_background), or - for a Claude +# own tracked background task (e.g. run_in_background), through Grok's tracked +# bin/fm-watch-grok-longrun.sh wrapper, or - for a Claude # primary - inside the Stop asyncRewake hook's foreground process tree # (bin/fm-claude-stop-autoarm.sh), where the harness owns the process group and # the hook's exit-2 rewake is the notification. Run it as its own standalone diff --git a/bin/fm-watch-grok-longrun.sh b/bin/fm-watch-grok-longrun.sh new file mode 100755 index 0000000000..059c102772 --- /dev/null +++ b/bin/fm-watch-grok-longrun.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash +# Keep Grok's one tracked supervision task open across routine declared-wait +# rechecks. The task completes only for an actionable watcher result or a +# failure, so Grok does not inject billed task_completed prompts for quiet +# cycles. +set -u + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ARM="$SCRIPT_DIR/fm-watch-arm.sh" +STATE="${FM_STATE_OVERRIDE:-${FM_HOME:-${FM_ROOT_OVERRIDE:-$(cd "$SCRIPT_DIR/.." && pwd)}}/state}" +mkdir -p "$STATE" +out= + +cleanup() { + [ -n "$out" ] && rm -f "$out" 2>/dev/null || true +} +trap cleanup EXIT +trap 'exit 129' HUP +trap 'exit 143' TERM +trap 'exit 130' INT + +routine_declared_wait() { + local out=$1 count reason + count=$(grep -Ec '^(signal:|stale:|check:|heartbeat($|:))' "$out" 2>/dev/null || true) + [ "$count" -eq 1 ] || return 1 + reason=$(grep -E '^(signal:|stale:|check:|heartbeat($|:))' "$out" 2>/dev/null | head -1 || true) + case "$reason" in + stale:*'declared pause, rechecked on a long cadence not a wedge; confirm the wait still holds)') return 0 ;; + stale:*'verified hold transfer, rechecked on a long cadence not a wedge; answer the held decision or release the hold)') return 0 ;; + *) return 1 ;; + esac +} + +while :; do + out=$(mktemp "$STATE/.grok-watch-longrun.XXXXXX") || { + echo "watcher: FAILED - Grok long-runner could not allocate cycle output" + exit 1 + } + status=0 + "$ARM" >"$out" 2>&1 || status=$? + if [ "$status" -ne 0 ]; then + cat "$out" + rm -f "$out" + out= + exit "$status" + fi + if routine_declared_wait "$out"; then + rm -f "$out" + out= + continue + fi + cat "$out" + rm -f "$out" + out= + exit 0 +done diff --git a/bin/fm-watch.sh b/bin/fm-watch.sh index f5a714b4c7..c010052078 100755 --- a/bin/fm-watch.sh +++ b/bin/fm-watch.sh @@ -1110,6 +1110,13 @@ resurface_after_downtime() { fi [ "$FM_RECOVERY_MARKER_ACTION" = recover ] || return 0 fi + # A recovery marker can exist with no durable wake and no open decision. + # Closing that empty cycle makes Grok inject a billed completion prompt even + # though there is nothing to handle. Keep this watcher live through the quiet + # episode after the recovery state is safely consumed. + [ -s "$FM_WAKE_QUEUE" ] && wake "check: rearm-resurface" + open=$(scan_open_decisions "$STATE") || wake "check: rearm-resurface" + [ -n "$open" ] || return 0 wake "check: rearm-resurface" } diff --git a/docs/architecture.md b/docs/architecture.md index cd0318c097..e0e906ce95 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -80,7 +80,7 @@ The default path remains local-only; live GitHub enrichment exists only behind t Optional Relay integrates with the watcher only after explicit opt-in; [configuration.md](configuration.md#relay-env) owns its generated-artifact and dispatch mechanics. At session start, `bin/fm-session-start.sh` emits exactly one primary-harness supervision block rendered by `bin/fm-supervision-instructions.sh` from `docs/supervision-protocols/`. -That block owns the live wait shape for the running primary harness: Claude's Stop `asyncRewake` hook owns tokenless re-arm cycles, Cursor's stop hook parks on the watcher, Grok uses background-notify cycles, Codex uses bounded foreground checkpoints, Pi and pi-signed use the same two tracked primary extensions, and OpenCode uses its TUI plugin. +That block owns the live wait shape for the running primary harness: Claude's Stop `asyncRewake` hook owns tokenless re-arm cycles, Cursor's stop hook parks on the watcher, Grok keeps one tracked task open across routine declared-wait rechecks, Codex uses bounded foreground checkpoints, Pi and pi-signed use the same two tracked primary extensions, and OpenCode uses its TUI plugin. `bin/fm-watch-arm.sh` remains the verified arm wrapper for protocols that call it; it forks the watcher as a tracked child, verifies it is genuinely alive with a fresh liveness beacon, and prints an honest `started`, `attached`, or nonzero `FAILED` status. [`watcher-continuity.md`](watcher-continuity.md#arm-layer-cycle-contract) owns the arm layer's successor, terminal-delivery, re-arm recovery, and typed clean-close failure contract. The arm layer records one bounded lifecycle row per observed cycle in `state/.watch-cycle-exits.log`; `state/.watch-triage.log` remains exclusively the absorbed-wake debug log. diff --git a/docs/arm-pretool-check.md b/docs/arm-pretool-check.md index d4c27b7c98..8cf286e740 100644 --- a/docs/arm-pretool-check.md +++ b/docs/arm-pretool-check.md @@ -63,6 +63,7 @@ A command word in executed position is a protected execution when its normalized ```text bin/fm-watch-arm.sh (arm; blessed entry point) bin/fm-watch-checkpoint.sh (checkpoint; blessed entry point) +bin/fm-watch-grok-longrun.sh (Grok long-runner; blessed entry point) bin/fm-watch.sh (watch; protected but never blessed) ``` @@ -90,7 +91,7 @@ An actual protected command with a heredoc still has a redirection and is denied ## Blessed syntax tree An allowed watcher program is one linear outer command list with zero or more approved setup nodes followed by exactly one direct protected node. -`bin/fm-watch-arm.sh` and `bin/fm-watch-checkpoint.sh` are the only blessed final nodes, including their expanded-path forms; a `bin/fm-watch.sh` final node is never blessed and denies with `watcher-direct`. +`bin/fm-watch-arm.sh`, `bin/fm-watch-checkpoint.sh`, and `bin/fm-watch-grok-longrun.sh` are the only blessed final nodes, including their expanded-path forms; a `bin/fm-watch.sh` final node is never blessed and denies with `watcher-direct`. Approved setup nodes are: diff --git a/docs/configuration.md b/docs/configuration.md index a861b40678..dc9b8ab490 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -243,7 +243,7 @@ Pi-family launches adapt the regular-TUI safeguard to the installed CLI's capabi Enabled primary-session turn-end guard integrations are tracked as repo-level hook files and documented in [`docs/turnend-guard.md`](turnend-guard.md). Kimi remains outside the primary turn-end guard integrations; [`docs/turnend-guard.md`](turnend-guard.md#compatibility-limits) owns its separate captain-approved crew wake hook. Primary-session watcher wake protocols are rendered at session start by [`bin/fm-supervision-instructions.sh`](../bin/fm-supervision-instructions.sh) from [`docs/supervision-protocols/`](supervision-protocols/). -Claude's Stop `asyncRewake` hook owns tokenless re-arm cycles, Cursor's stop hook parks on the watcher, Grok uses background-notify cycles, Codex uses bounded foreground checkpoints, Pi and pi-signed use the same two tracked primary extensions, and OpenCode uses its TUI plugin. +Claude's Stop `asyncRewake` hook owns tokenless re-arm cycles, Cursor's stop hook parks on the watcher, Grok keeps one tracked task open across routine declared-wait rechecks, Codex uses bounded foreground checkpoints, Pi and pi-signed use the same two tracked primary extensions, and OpenCode uses its TUI plugin. `config/crew-harness` is a local, gitignored file containing one adapter name for crewmate and scout launches. When pi-signed is selected, Firstmate preserves `FM_PI_HARNESS=pi-signed` and refuses the launch if the selected executable is unavailable rather than falling back to pi; [`fm-spawn.sh --help`](../bin/fm-spawn.sh) owns executable resolution and launch mechanics. Plain Pi launches set `FM_PI_HARNESS=pi`, so a signed primary's environment cannot relabel a plain Pi worker. diff --git a/docs/scripts.md b/docs/scripts.md index 5408ce683d..dc1857fa36 100644 --- a/docs/scripts.md +++ b/docs/scripts.md @@ -72,6 +72,7 @@ The shared no-mistakes gate refusal for fleet lifecycle entrypoints is summarize | `fm-procevent-when.sh` | Fire a trust-bound deterministic action at most once when its registered condition holds, then wake with the outcome | | `fm-gate-refuse-lib.sh` | Shared no-mistakes gate-context refusal for fleet lifecycle entrypoints | | `fm-watch-arm.sh` | Verified home-scoped watcher arm wrapper with loud cycle endings and bounded lifecycle ledger | +| `fm-watch-grok-longrun.sh` | Grok tracked-task wrapper that absorbs routine declared-wait cycle closes and returns actionable results | | `fm-watch-checkpoint.sh` | Run one bounded foreground watcher checkpoint for Codex-style supervision | | `fm-watch.sh` | Singleton-safe watcher: absorb benign wakes, detect stalled local-secondmate wake queues, and exit on actionable ones | | `fm-inactive-reconcile.sh` | Reconcile long-inactive direct crewmate terminal outcomes without forge access | diff --git a/docs/supervision-protocols/grok.md b/docs/supervision-protocols/grok.md index f27ae302e1..8f557ce5cf 100644 --- a/docs/supervision-protocols/grok.md +++ b/docs/supervision-protocols/grok.md @@ -1,39 +1,39 @@ -Mode: Grok background-notify supervision. +Mode: Grok long-lived background-notify supervision. When this session owns supervision and away mode is not active: + 1. Drain first with `bin/fm-wake-drain.sh`. - After handling all emitted wakes and reconciling open decisions and unread status lines, run the exact `--ack-through` command printed as `WAKE_ACK_REQUIRED`; until then the work remains durable for idempotent re-handling after interruption. + After handling all emitted wakes and reconciling open decisions and unread status lines, run the exact `--ack-through` command printed as `WAKE_ACK_REQUIRED`. + Until then the work remains durable for idempotent re-handling after interruption. 2. Source `__FM_X_MODE_ENV__` first when Relay is active. 3. First cycle: arm with Grok's tracked background tool, as its own call: `run_terminal_command` with `background: true` on: - `[ -f __FM_X_MODE_ENV_SH__ ] && . __FM_X_MODE_ENV_SH__; exec bin/fm-watch-arm.sh` - -4. Trust only the arm's one-line status. -5. `watcher: started ...` or `watcher: attached ...` means a live cycle exists. - On attach, the background task follows verified identity-matched successors instead of exiting when the first cycle ends. -6. Failure or missing cycle only: `watcher: FAILED ...` means supervision is down; fix and re-arm. -7. After a successful start or attach status, end the turn. - The background arm remains the live wait until it returns an actionable wake or failure. -8. Waiting is silent. -9. Never use shell `&` for firstmate supervision. -10. Never bundle the arm onto another command. - A shell `&`, a truncating pipe, or bundling is denied automatically by the PreToolUse seatbelt (`bin/fm-arm-pretool-check.sh`) whenever this project's Grok hooks are trusted. - -Grok injects a synthetic user message with `synthetic_reason: task_completed` when the background arm completes. -When you see a background-task-completed system reminder for the arm: + `[ -f __FM_X_MODE_ENV_SH__ ] && . __FM_X_MODE_ENV_SH__; exec bin/fm-watch-grok-longrun.sh` + +4. The long-runner owns `bin/fm-watch-arm.sh` in the foreground. + It keeps the same Grok tracked task open across routine declared-pause and captain-hold rechecks. + Those internal cycle closes never become Grok `task_completed` prompts. +5. The long-runner returns only for a real actionable watcher result or failure. + Grok's native tracked-task completion then wakes the model immediately. +6. Waiting is silent. +7. Never use shell `&` for firstmate supervision. +8. Never bundle the long-runner onto another command. + A shell `&`, a truncating pipe, or bundling is denied automatically by the PreToolUse seatbelt (`bin/fm-arm-pretool-check.sh`) whenever this project's Grok hooks are trusted. + +When Grok injects a background-task-completed reminder for the long-runner: + 1. Run `bin/fm-wake-drain.sh` first. -2. Optionally fetch arm output with `get_command_or_subagent_output()` for the reason line. -3. Handle `signal`, `stale`, `check`, or `heartbeat` using the harness-neutral contract in `AGENTS.md`. -4. Ordinary wake: re-arm the next cycle with the same background `bin/fm-watch-arm.sh` call if work remains in flight or Relay still needs polling. -5. Do not invent a wake from an attach-status line alone. +2. Optionally fetch the task output with `get_command_or_subagent_output()` for the reason line. +3. Handle `signal`, non-routine `stale`, `check`, or `heartbeat` using the harness-neutral contract in `AGENTS.md`. +4. Re-arm one new tracked `bin/fm-watch-grok-longrun.sh` task if work remains in flight or Relay still needs polling. +5. Do not invent a wake from a `watcher: started ...` or `watcher: attached ...` line alone. Drain the queue and act only on real wake records, the drain's `OPEN DECISIONS` and `UNREAD STATUS` entries, or a real watcher reason line. - Re-arm attaches to an existing healthy cycle when one is already present and follows its verified successor chain. - See [`watcher-continuity.md`](../watcher-continuity.md) for the arm-layer successor and clean-close failure contract. +6. See [`watcher-continuity.md`](../watcher-continuity.md) for the arm-layer singleton, liveness, recovery, and clean-close failure contract. -The primary project Stop hook runs `bin/fm-turnend-guard-grok.sh` as a backstop, not the normal wake path. +The primary project Stop hook runs `bin/fm-turnend-guard-grok.sh` as a strict backstop, not the normal wake path. [`turnend-guard.md`](../turnend-guard.md) owns its running-payload capability selection between native same-process blocking and the pre-native bounded resume fallback. -After any forced continuation, arm the watcher with the background protocol above. +After any forced continuation, arm the long-lived tracked watcher through the first-cycle procedure above. Interactive TUI primary sessions are the supported supervision host. Headless `grok -p` may wait for background process exit but does not reliably surface full auto-wake model output; do not run the primary firstmate as a one-shot headless process. diff --git a/docs/verification/supervision.md b/docs/verification/supervision.md index 8ae889f30f..7ab9be284e 100644 --- a/docs/verification/supervision.md +++ b/docs/verification/supervision.md @@ -421,7 +421,7 @@ Claude Code 2.1.219 codex-cli 0.144.4 OpenCode 1.17.18 Pi 0.80.10 -grok 0.2.103 (89c3d36fb6f1) [stable] +grok 1.0.5 (5115b46bc909) ``` | Harness | Exact opt-in command | Observed guarantee | @@ -430,7 +430,9 @@ grok 0.2.103 (89c3d36fb6f1) [stable] | Codex | `FM_CODEX_LIVE_E2E=1 tests/fm-codex-continuity-live-e2e.test.sh` | The one-second foreground checkpoint returned without switching to the arm wrapper. | | OpenCode | `FM_OPENCODE_LIVE_E2E=1 tests/fm-opencode-primary-live-e2e.test.sh` | A verified successor existed before prompt handling, with no model re-arm or turn-end fallback. | | Pi | `FM_PI_LIVE_E2E=1 tests/fm-pi-primary-live-e2e.test.sh` | One initial tool call led to extension-owned successors and clean child retirement on exit. | -| Grok | `FM_GROK_LIVE_E2E=1 tests/fm-grok-continuity-live-e2e.test.sh` | Native task completion surfaced the actionable close and the cycle ledger recorded `reason=actionable-signal`. | +| Grok | `FM_GROK_LIVE_E2E=1 tests/fm-grok-continuity-live-e2e.test.sh` | One tracked long-runner stayed open through quiet recovery with no primary turn or internal arm completion, then one actionable signal produced exactly one native task completion and a handled durable wake. | + +The Grok row was reverified on 2026-08-24 against Grok 1.0.5 after the tracked long-runner replaced close-per-routine-cycle supervision. Pi 0.81.1 repeated the continuity and clean-exit lifecycle on 2026-07-23 after the Calm presentation changes. diff --git a/docs/watcher-continuity.md b/docs/watcher-continuity.md index cf458e2640..3122af7498 100644 --- a/docs/watcher-continuity.md +++ b/docs/watcher-continuity.md @@ -40,7 +40,9 @@ No PreToolUse hook denies fleet commands based on watcher status. A genuine auto-arm failure describes the automatic mechanism as broken and never directs a routine manual background arm. Terminal arm-output classification (`started`, `attached`, or `FAILED`) remains defense in depth for the manual recovery path. Codex retains its bounded foreground checkpoint protocol. -Grok retains its tracked background-task notification protocol. +Grok retains its native tracked background-task notification protocol, but `bin/fm-watch-grok-longrun.sh` keeps the tracked task open across routine declared-wait rechecks. +The long-runner executes one verified `bin/fm-watch-arm.sh` cycle at a time, so the watcher singleton lock and liveness beacon remain authoritative. +It returns only when the arm reports a non-routine actionable result or failure, which preserves immediate native wake delivery and the strict turn-end guard for real work. No adapter starts a replacement with shell `&`. The turn-end guard remains the final backstop rather than the normal continuity mechanism and cooperates with the auto-arm in its `--claude` mode. @@ -83,6 +85,7 @@ Only the watcher process touches `state/.last-watcher-beat`; no helper process c The same suite covers ordinary same-process session replacement for `/new`, `/resume`, and `/fork`, same-instance shutdown-plus-start, stale prior-generation callbacks, repeated transitions with exactly one live cycle, disappearance of the shutting-down refusal after a valid replacement activates, and terminal quit still refusing late rearm. `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. +`tests/fm-grok-watch-longrun.test.sh` reproduces five routine declared-wait watcher closes inside one tracked-task process, proves none escapes as completion output, and then proves one actionable close surfaces exactly once. `tests/fm-watcher-lock.test.sh` covers verified-successor attach, recovery publication before stale-lock removal, the typed self-eviction failure, bounded and successor-linked lifecycle rows, and a SIGSTOP counterfactual that distinguishes a live PID from a stale beacon before classifying termination. `tests/fm-subagent-pretool-check.test.sh` proves Claude retains only the non-status Bash seatbelts. `tests/fm-claude-stop-autoarm.test.sh` covers the auto-arm's scope, stale and live session owners, unchanged AFK and need boundaries, single-flight, bounded failure retries, benign live-watcher cycle ends, one-notice failure episodes, and exit-2 translation. @@ -92,9 +95,9 @@ It also covers abandoned single-flight claims: a claim the ledger shows already ## Active limits and verification -The goal is continuity without a Pi or OpenCode model-memory re-arm step. +The goal is continuity without a Pi or OpenCode model-memory re-arm step and without Grok model turns for routine declared-wait cycles. No zero-latency guarantee is claimed because lock verification, watcher startup, and bounded retry delays remain deliberate safety work. OpenCode support targets persistent TUI sessions rather than headless `opencode run`. -Claude depends on the Stop `asyncRewake` rewake, Cursor depends on its awaited stop-hook park, Grok retains native background-completion notifications, and Codex retains bounded foreground checkpoints. +Claude depends on the Stop `asyncRewake` rewake, Cursor depends on its awaited stop-hook park, Grok retains native background-completion notifications only for actionable long-runner exits, and Codex retains bounded foreground checkpoints. [`verification/supervision.md`](verification/supervision.md#watcher-continuity) records the current five-harness live evidence, the 2026-07-24 Stop-owned Claude auto-arm results, and exact opt-in commands. diff --git a/tests/fm-arm-pretool-check.test.sh b/tests/fm-arm-pretool-check.test.sh index 267efd286d..6d052b3c98 100755 --- a/tests/fm-arm-pretool-check.test.sh +++ b/tests/fm-arm-pretool-check.test.sh @@ -43,6 +43,7 @@ matrix_case A14 allow "[ -f 'config/x-mode.env' ] && source 'config/x-mode.env'; matrix_case A15 allow "cd $ROOT && exec bin/fm-watch-arm.sh" matrix_case A16 allow "export FM_HOME=$ROOT && bin/fm-watch-checkpoint.sh --seconds 180" matrix_case A17 allow $'source "config/x-mode.env"\nbin/fm-watch-checkpoint.sh --seconds 180' +matrix_case A18 allow 'exec bin/fm-watch-grok-longrun.sh' matrix_case R01 allow "pgrep -fl '/bin/fm-watch.sh' || true" matrix_case R02 allow "ps aux | rg '/bin/fm-watch.sh'" @@ -122,6 +123,8 @@ matrix_case D55 deny 'while true; do pkill -f fm-watch; done' matrix_case D56 deny 'for x in 1; do pkill -f fm-watch; done' matrix_case D57 deny 'case x in x) pkill -f fm-watch ;; esac' matrix_case D58 deny 'until false; do kill $(pgrep -f fm-watch); done' +matrix_case D59 deny 'bin/fm-watch-grok-longrun.sh &' +matrix_case D60 deny 'bin/fm-watch-grok-longrun.sh; echo after' matrix_case E01 allow "bin/fm-watch-checkpoint.sh --seconds '180;still-one-arg'" matrix_case E02 allow "bin/fm-watch-checkpoint.sh --label 'fm-watch-arm.sh; literal argument'" diff --git a/tests/fm-grok-continuity-live-e2e.test.sh b/tests/fm-grok-continuity-live-e2e.test.sh index 2caf9ab74f..29f497fc59 100755 --- a/tests/fm-grok-continuity-live-e2e.test.sh +++ b/tests/fm-grok-continuity-live-e2e.test.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash -# Opt-in credentialed Grok regression proving the shared arm wrapper still works -# through Grok's tracked background-task notification path. +# Opt-in credentialed Grok regression proving one tracked long-runner spends no +# primary turn for quiet recovery and completes once for an actionable wake. set -u if [ "${FM_GROK_LIVE_E2E:-0}" != 1 ]; then @@ -16,6 +16,7 @@ fail() { } command -v grok >/dev/null 2>&1 || fail "grok not found" +command -v jq >/dev/null 2>&1 || fail "jq not found" command -v tmux >/dev/null 2>&1 || fail "tmux not found" TMUX=$(command -v tmux) @@ -27,7 +28,7 @@ HOME_DIR="$LAB/fmhome" GROK_VERSION=$(grok --version) capture() { - "$TMUX" -L "$SOCKET" capture-pane -p -t "$SESSION" -S -900 2>/dev/null || true + "$TMUX" -L "$SOCKET" capture-pane -p -t "$SESSION" -S -1200 2>/dev/null || true } wait_for_text() { @@ -41,6 +42,40 @@ wait_for_text() { return 1 } +chat_history() { + find "$HOME/.grok/sessions" -type f \ + -path "*${LAB##*/}%2Fproject/*/chat_history.jsonl" -print 2>/dev/null \ + | head -1 +} + +assistant_turn_count() { + local chat=$1 + jq -r 'select(.type == "assistant" and .model_id != null) | 1' "$chat" 2>/dev/null \ + | wc -l | tr -d ' ' +} + +completion_count() { + local chat=$1 command=$2 + jq -r --arg command "$command" ' + select(.type == "user" and .synthetic_reason == "task_completed") + | .content[]? + | select(.type == "text" and (.text | contains($command))) + | 1 + ' "$chat" 2>/dev/null | wc -l | tr -d ' ' +} + +wait_for_assistant_text() { + local chat=$1 expected=$2 attempts=${3:-240} i=0 + while [ "$i" -lt "$attempts" ]; do + jq -e --arg expected "$expected" \ + 'select(.type == "assistant" and .content == $expected)' "$chat" >/dev/null 2>&1 \ + && return 0 + sleep 0.5 + i=$((i + 1)) + done + return 1 +} + lab_pid_is_safe() { local pid=$1 command command=$(ps -p "$pid" -o command= 2>/dev/null || true) @@ -51,62 +86,101 @@ lab_pid_is_safe() { } cleanup() { - local watcher_pid arm_pid + local watcher_pid arm_pid longrun_pid watcher_pid=$(cat "$HOME_DIR/state/.watch.lock/pid" 2>/dev/null || true) arm_pid=$(ps -p "$watcher_pid" -o ppid= 2>/dev/null | tr -d ' ' || true) + longrun_pid=$(ps -p "$arm_pid" -o ppid= 2>/dev/null | tr -d ' ' || true) "$TMUX" -L "$SOCKET" kill-server 2>/dev/null || true - sleep 0.1 - if [ -n "$watcher_pid" ] && lab_pid_is_safe "$watcher_pid"; then - kill -TERM "$watcher_pid" 2>/dev/null || true - fi - if [ -n "$arm_pid" ] && lab_pid_is_safe "$arm_pid"; then - kill -TERM "$arm_pid" 2>/dev/null || true - fi + sleep 0.2 + for pid in "$watcher_pid" "$arm_pid" "$longrun_pid"; do + if [ -n "$pid" ] && lab_pid_is_safe "$pid"; then + kill -TERM "$pid" 2>/dev/null || true + kill -CONT "$pid" 2>/dev/null || true + fi + done rm -rf "$LAB" } trap cleanup EXIT mkdir -p "$LAB" git clone -q "$ROOT" "$PROJECT" -cp "$ROOT/bin/fm-watch-arm.sh" "$PROJECT/bin/fm-watch-arm.sh" +for file in fm-watch-arm.sh fm-watch.sh fm-watch-grok-longrun.sh; do + cp "$ROOT/bin/$file" "$PROJECT/bin/$file" + chmod +x "$PROJECT/bin/$file" +done mkdir -p "$HOME_DIR/state" "$HOME_DIR/config" printf 'project=fixture\n' > "$HOME_DIR/state/grok-e2e.meta" +FM_STATE_OVERRIDE="$HOME_DIR/state" bash -c \ + '. "$1"; fm_recovery_transition "$2" publish downtime' _ \ + "$PROJECT/bin/fm-wake-lib.sh" "$HOME_DIR/state/.watcher-down" \ + || fail "could not publish the isolated quiet recovery episode" "$TMUX" -L "$SOCKET" new-session -d -s "$SESSION" -c "$PROJECT" \ - "env FM_HOME='$HOME_DIR' FM_ROOT_OVERRIDE='$PROJECT' FM_POLL=1 FM_SIGNAL_GRACE=0 FM_HEARTBEAT=600 bash -lc 'printf \"%s\\n\" \"\$\$\" > \"\$FM_HOME/state/.lock\"; grok --trust --always-approve --reasoning-effort low; rc=\$?; printf \"GROK_EXIT=%s\\n\" \"\$rc\"; sleep 300'" + "env FM_HOME='$HOME_DIR' FM_ROOT_OVERRIDE='$PROJECT' FM_POLL=1 FM_SIGNAL_GRACE=0 FM_HEARTBEAT=600 FM_CHECK_INTERVAL=999999 bash -lc 'printf \"%s\\n\" \"\$\$\" > \"\$FM_HOME/state/.lock\"; exec grok --trust --always-approve --reasoning-effort low'" wait_for_text "Grok Build" 180 || fail "Grok did not reach its ready composer" +wait_for_text "❯" 180 || fail "Grok did not render its ready composer input" sleep 1 # shellcheck disable=SC2016 # Backticks are literal prompt markup. -PROMPT='Use run_terminal_command with background=true to run exactly `bin/fm-watch-arm.sh`. Never use a shell ampersand. Once it reports started, respond briefly.' +PROMPT='Use run_terminal_command with background=true to run exactly `bin/fm-watch-grok-longrun.sh`. Never use a shell ampersand. Once the tracked task is running, respond exactly ARM_READY. When its task-completed reminder arrives, run `bin/fm-wake-drain.sh`, handle the one wake, run the exact WAKE_ACK_REQUIRED acknowledgement, and respond exactly ACTIONABLE_HANDLED. Do not re-arm during this test.' "$TMUX" -L "$SOCKET" send-keys -t "$SESSION" -l "$PROMPT" "$TMUX" -L "$SOCKET" send-keys -t "$SESSION" Enter i=0 -initial_watcher= -while [ "$i" -lt 240 ]; do - initial_watcher=$(cat "$HOME_DIR/state/.watch.lock/pid" 2>/dev/null || true) - [ -n "$initial_watcher" ] && kill -0 "$initial_watcher" 2>/dev/null && break +chat= +while [ "$i" -lt 120 ]; do + chat=$(chat_history) + [ -n "$chat" ] && [ -f "$chat" ] && break sleep 0.5 i=$((i + 1)) done -if [ -z "$initial_watcher" ] || ! kill -0 "$initial_watcher" 2>/dev/null; then - fail "Grok did not start the tracked background watcher" -fi +[ -n "$chat" ] && [ -f "$chat" ] || fail "Grok chat history was not created for the live cell" +wait_for_text "ARM_READY" 240 || fail "Grok did not start the tracked long-runner" +wait_for_assistant_text "$chat" ARM_READY 120 \ + || fail "Grok readiness response was not durably recorded" -printf 'done: grok live e2e watcher fire\n' > "$HOME_DIR/state/grok-e2e.status" i=0 -while [ "$i" -lt 240 ]; do - grep -Eq 'reason=actionable-signal' "$HOME_DIR/state/.watch-cycle-exits.log" 2>/dev/null && break +watcher_pid= +while [ "$i" -lt 120 ]; do + watcher_pid=$(cat "$HOME_DIR/state/.watch.lock/pid" 2>/dev/null || true) + [ -n "$watcher_pid" ] && kill -0 "$watcher_pid" 2>/dev/null && break sleep 0.5 i=$((i + 1)) done -grep -Eq 'reason=actionable-signal' "$HOME_DIR/state/.watch-cycle-exits.log" 2>/dev/null \ - || fail "Grok action cycle was not classified in the lifecycle ledger" -wait_for_text "Task completed in" 120 || fail "Grok did not surface its native background-task completion notification" -pane=$(capture) -if printf '%s\n' "$pane" | grep -Fq 'bin/fm-watch-arm.sh &'; then - fail "Grok used a shell ampersand instead of its tracked background task" +if [ -z "$watcher_pid" ] || ! kill -0 "$watcher_pid" 2>/dev/null; then + fail "tracked long-runner did not establish a live watcher" +fi +arm_pid=$(ps -p "$watcher_pid" -o ppid= 2>/dev/null | tr -d ' ' || true) +longrun_pid=$(ps -p "$arm_pid" -o ppid= 2>/dev/null | tr -d ' ' || true) +if [ -z "$longrun_pid" ] || ! kill -0 "$longrun_pid" 2>/dev/null; then + fail "tracked long-runner process was not live" fi -printf 'ok - %s live E2E preserved tracked background completion and shared ledger classification\n' "$GROK_VERSION" +baseline_turns=$(assistant_turn_count "$chat") +baseline_longrun_completions=$(completion_count "$chat" 'fm-watch-grok-longrun.sh') +baseline_arm_completions=$(completion_count "$chat" 'fm-watch-arm.sh') +[ "$baseline_longrun_completions" -eq 0 ] \ + || fail "quiet recovery completed the tracked long-runner before its ready response" +sleep 4 +[ "$(assistant_turn_count "$chat")" -eq "$baseline_turns" ] \ + || fail "quiet recovery spent a primary turn" +[ "$(completion_count "$chat" 'fm-watch-grok-longrun.sh')" -eq "$baseline_longrun_completions" ] \ + || fail "quiet recovery completed the tracked long-runner" +[ "$(completion_count "$chat" 'fm-watch-arm.sh')" -eq "$baseline_arm_completions" ] \ + || fail "an internal watcher cycle leaked as its own tracked completion" +kill -0 "$longrun_pid" 2>/dev/null || fail "quiet recovery closed the tracked long-runner" + +printf 'done: grok live long-runner actionable fire\n' > "$HOME_DIR/state/grok-e2e.status" +wait_for_assistant_text "$chat" ACTIONABLE_HANDLED 600 \ + || fail "Grok actionable response was not durably recorded" +[ "$(completion_count "$chat" 'fm-watch-grok-longrun.sh')" -eq $((baseline_longrun_completions + 1)) ] \ + || fail "actionable wake did not produce exactly one long-runner completion prompt" +[ "$(completion_count "$chat" 'fm-watch-arm.sh')" -eq "$baseline_arm_completions" ] \ + || fail "actionable path leaked an internal arm completion prompt" +grep -Eq 'reason=actionable-signal' "$HOME_DIR/state/.watch-cycle-exits.log" 2>/dev/null \ + || fail "actionable cycle was not classified in the lifecycle ledger" +[ ! -s "$HOME_DIR/state/.wake-queue" ] || fail "Grok did not acknowledge the one durable signal row" +! capture | grep -Fq 'bin/fm-watch-grok-longrun.sh &' \ + || fail "Grok used a shell ampersand instead of its tracked background task" + +printf 'ok - %s kept quiet recovery inside one tracked task and woke once for actionable work\n' "$GROK_VERSION" diff --git a/tests/fm-grok-watch-longrun.test.sh b/tests/fm-grok-watch-longrun.test.sh new file mode 100755 index 0000000000..40e24563b0 --- /dev/null +++ b/tests/fm-grok-watch-longrun.test.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +set -u + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +LONGRUN="$ROOT/bin/fm-watch-grok-longrun.sh" +LAB=$(mktemp -d "${TMPDIR:-/tmp}/fm-watch-grok-longrun.XXXXXX") +trap 'rm -rf "$LAB"' EXIT + +fail() { + printf 'not ok - %s\n' "$1" >&2 + exit 1 +} + +mkdir -p "$LAB/repo/bin" "$LAB/home/state" +cp "$LONGRUN" "$LAB/repo/bin/fm-watch-grok-longrun.sh" +cat > "$LAB/repo/bin/fm-watch-arm.sh" <<'SH' +#!/usr/bin/env bash +set -u +STATE="${FM_STATE_OVERRIDE:-$FM_HOME/state}" +count_file="$STATE/cycle-count" +count=$(cat "$count_file" 2>/dev/null || printf '0') +count=$((count + 1)) +printf '%s\n' "$count" > "$count_file" +printf 'watcher: started pid=%s (beacon fresh)\n' "$$" +if [ "$count" -le 5 ]; then + printf 'stale: fixture:w1:p%s (paused 20000s, awaiting external - declared pause, rechecked on a long cadence not a wedge; confirm the wait still holds)\n' "$count" + exit 0 +fi +printf 'signal: %s/actionable.status\n' "$STATE" +SH +chmod +x "$LAB/repo/bin/fm-watch-arm.sh" "$LAB/repo/bin/fm-watch-grok-longrun.sh" + +FM_HOME="$LAB/home" FM_STATE_OVERRIDE="$LAB/home/state" \ + "$LAB/repo/bin/fm-watch-grok-longrun.sh" > "$LAB/output" 2>&1 & +pid=$! +i=0 +while [ "$i" -lt 100 ]; do + [ "$(cat "$LAB/home/state/cycle-count" 2>/dev/null || true)" = 6 ] && break + sleep 0.02 + i=$((i + 1)) +done +wait "$pid" || fail "long-runner failed while handing over the actionable cycle" +[ "$(cat "$LAB/home/state/cycle-count")" = 6 ] \ + || fail "fixture did not reproduce five routine closes before the actionable cycle" +[ "$(grep -c '^signal:' "$LAB/output")" -eq 1 ] \ + || fail "actionable wake did not surface exactly once" +! grep -q 'declared pause' "$LAB/output" \ + || fail "a routine declared-wait cycle escaped the long-runner" +[ "$(grep -c '^watcher: started' "$LAB/output")" -eq 1 ] \ + || fail "quiet cycle diagnostics leaked into tracked-task completion output" + +printf 'ok - five routine watcher closes stayed inside one tracked task and one actionable wake surfaced\n' diff --git a/tests/fm-supervision-instructions.test.sh b/tests/fm-supervision-instructions.test.sh index 377e95d152..80ef387477 100755 --- a/tests/fm-supervision-instructions.test.sh +++ b/tests/fm-supervision-instructions.test.sh @@ -103,10 +103,10 @@ test_cross_harness_ordinary_continuation_and_repair_matrix() { ordinary=$(printf '%s\n' "$out" | grep -F -- '- Ordinary wake:') assert_contains "$ordinary" "re-arm" "grok ordinary-wake line does not tell the model to re-arm" assert_contains "$ordinary" "Grok tracked background task" "grok ordinary-wake line lost tracked background ownership" - assert_contains "$ordinary" "bin/fm-watch-arm.sh" "grok ordinary-wake line lost the background arm command" + assert_contains "$ordinary" "bin/fm-watch-grok-longrun.sh" "grok ordinary-wake line lost the long-runner command" out=$("$RENDER" --harness grok --repair-line) assert_contains "$out" "Grok tracked background task" "grok recovery line lost its tracked background repair" - assert_contains "$out" "bin/fm-watch-arm.sh" "grok recovery line lost the arm command" + assert_contains "$out" "bin/fm-watch-grok-longrun.sh" "grok recovery line lost the long-runner command" out=$("$RENDER" --harness codex) ordinary=$(printf '%s\n' "$out" | grep -F -- '- Ordinary wake:') @@ -136,18 +136,18 @@ test_pi_signed_preserves_identity_with_pi_supervision_protocol() { pass "pi-signed keeps its identity while sharing Pi's supervision protocol" } -test_grok_is_background_notify() { +test_grok_is_long_lived_background_notify() { local out out=$("$RENDER" --harness grok) - assert_contains "$out" "Mode: Grok background-notify supervision." "grok snippet missing background-notify mode" + assert_contains "$out" "Mode: Grok long-lived background-notify supervision." "grok snippet missing long-lived background-notify mode" assert_contains "$out" "background: true" "grok snippet missing tracked background tool instruction" - assert_contains "$out" "synthetic_reason: task_completed" "grok snippet missing auto-wake synthetic prompt detail" - assert_contains "$out" "bin/fm-watch-arm.sh" "grok snippet missing watcher arm" + assert_contains "$out" "task_completed" "grok snippet missing actionable native wake detail" + assert_contains "$out" "bin/fm-watch-grok-longrun.sh" "grok snippet missing watcher long-runner" assert_not_contains "$out" "__FM_X_MODE_ENV" "renderer leaked an x-mode path placeholder" assert_not_contains "$out" "foreground checkpoint" "grok snippet must not be Codex-style foreground checkpoint" out=$("$RENDER" --harness grok --repair-line) assert_contains "$out" "Grok tracked background task" "grok repair line is not background-notify shaped" - pass "grok supervision is Claude-shaped background notify with passive Stop-hook backstop" + pass "grok supervision keeps routine closes inside one tracked background task" } test_grok_command_sources_effective_config() { @@ -156,7 +156,7 @@ test_grok_command_sources_effective_config() { config="$TMP_ROOT/grok-config" mkdir -p "$home/state" "$config" out=$(FM_HOME="$home" FM_CONFIG_OVERRIDE="$config" "$RENDER" --harness grok --x-mode 1) - assert_contains "$out" "[ -f '$config/x-mode.env' ] && . '$config/x-mode.env'; exec bin/fm-watch-arm.sh" "grok arm command did not use the effective x-mode config path" + assert_contains "$out" "[ -f '$config/x-mode.env' ] && . '$config/x-mode.env'; exec bin/fm-watch-grok-longrun.sh" "grok long-runner command did not use the effective x-mode config path" pass "grok rendered command sources the effective x-mode config" } @@ -182,6 +182,6 @@ test_conditional_stanzas test_repair_lines test_cross_harness_ordinary_continuation_and_repair_matrix test_pi_signed_preserves_identity_with_pi_supervision_protocol -test_grok_is_background_notify +test_grok_is_long_lived_background_notify test_grok_command_sources_effective_config test_pi_snippet_uses_effective_extension_path diff --git a/tests/fm-watch-arm.test.sh b/tests/fm-watch-arm.test.sh index 2a3a5173c4..c1cfee9139 100755 --- a/tests/fm-watch-arm.test.sh +++ b/tests/fm-watch-arm.test.sh @@ -393,10 +393,14 @@ test_marker_publish_failure_retains_recovery_evidence() { rmdir "$state/.watcher-down" armout="$dir/recovery-arm.out" start_rearm_arm "$home" "$state" "$fakebin" "$armout" - wait_for_exit "$ARM_PID" 80 || fail "stale-lock recovery did not surface downtime" - grep -F 'check: rearm-resurface' "$armout" >/dev/null \ - || fail "stale-lock recovery did not emit the recovery wake: $(cat "$armout")" - pass "watch-arm: marker publication failure retains stale-lock recovery evidence" + sleep 0.25 + is_live_non_zombie "$ARM_PID" \ + || fail "stale-lock recovery closed despite having no durable work to surface" + ! grep -F 'check: rearm-resurface' "$armout" >/dev/null \ + || fail "empty stale-lock recovery emitted a no-work completion: $(cat "$armout")" + printf 'done: fixture cleanup\n' > "$state/cleanup.status" + wait_for_exit "$ARM_PID" 80 || fail "marker-failure recovery cleanup wake did not surface" + pass "watch-arm: marker publication failure retains recovery evidence without a no-work close" } test_delivery_gap_wake_is_recovered_once() { @@ -541,20 +545,17 @@ test_malformed_marker_is_quarantined_once() { printf 'foreign state\n' > "$state/.watcher-down/payload" start_rearm_arm "$home" "$state" "$fakebin" "$dir/recovery-arm.out" - wait_for_exit "$ARM_PID" 80 || fail "malformed marker did not produce a bounded recovery wake" - grep -F 'check: rearm-resurface' "$dir/recovery-arm.out" >/dev/null \ - || fail "malformed marker did not emit the recovery wake" + sleep 0.25 + is_live_non_zombie "$ARM_PID" || fail "malformed marker closed a no-work recovery cycle" + ! grep -F 'check: rearm-resurface' "$dir/recovery-arm.out" >/dev/null \ + || fail "malformed marker emitted a no-work completion" invalid_count=$(find "$state" -maxdepth 1 -type d -name '.watcher-down.invalid.*' | wc -l | tr -d '[:space:]') [ "$invalid_count" -eq 1 ] || fail "malformed marker was not quarantined exactly once" - - FM_HOME="$home" FM_STATE_OVERRIDE="$state" "$DRAIN" > "$dir/recovery-drain.out" \ - || fail "malformed-marker recovery drain failed" - ack_wakes "$state" || fail "malformed-marker handling acknowledgement failed" - start_rearm_arm "$home" "$state" "$fakebin" "$dir/stable-successor.out" - is_live_non_zombie "$ARM_PID" || fail "malformed marker caused a persistent recovery loop" + printf 'done: malformed marker fixture cleanup\n' > "$state/cleanup.status" + wait_for_exit "$ARM_PID" 80 || fail "malformed-marker cleanup wake did not surface" kill "$ARM_PID" 2>/dev/null || true wait "$ARM_PID" 2>/dev/null || true - pass "watch-arm: malformed recovery state is quarantined without a successor loop" + pass "watch-arm: malformed recovery state is quarantined without a no-work completion" } test_recovery_consumption_serializes_queue_publication() { @@ -603,13 +604,17 @@ test_restart_preserves_recovery_across_reused_pid_lock() { ln -s "$owner" "$state/.watch.lock" start_rearm_arm "$home" "$state" "$fakebin" "$armout" - wait_for_exit "$ARM_PID" 80 || fail "restart did not surface recovery after clearing a reused-pid lock" - grep -F 'check: rearm-resurface' "$armout" >/dev/null \ - || fail "restart cleared reused-pid lock evidence without a recovery wake: $(cat "$armout")" + sleep 0.25 + is_live_non_zombie "$ARM_PID" \ + || fail "restart closed after clearing a reused-pid lock with no durable work" + ! grep -F 'check: rearm-resurface' "$armout" >/dev/null \ + || fail "restart emitted a no-work completion after clearing reused-pid evidence: $(cat "$armout")" is_live_non_zombie "$unrelated" || fail "restart signaled the unrelated process whose pid was reused" + printf 'done: reused-pid fixture cleanup\n' > "$state/cleanup.status" + wait_for_exit "$ARM_PID" 80 || fail "reused-pid recovery cleanup wake did not surface" kill "$unrelated" 2>/dev/null || true wait "$unrelated" 2>/dev/null || true - pass "watch-arm: restart publishes recovery before clearing a reused-pid watcher lock" + pass "watch-arm: restart preserves recovery evidence without a no-work close after a reused-pid watcher lock" } test_markerless_legacy_queue_is_recovered_on_arm() {