From b4d02bb5c3dc95e1b8a18fcd5494ec0119cf8d16 Mon Sep 17 00:00:00 2001 From: kunchenguid Date: Thu, 3 Sep 2026 14:44:02 -0700 Subject: [PATCH 1/3] fix(bin): stop the supervision branch's stale-ack and ghost-report loops Clean-slate implementation of the four authorized recommendations from the supervision-ghost-retrigger analysis (items 1, 2, 3, and 7), in their minimal form, superseding PR #3604: - fm_branch_report refuses a task the wake being handled never named. The extension fixes the reportable task set from the eligible rows before each prompt (signal and stale rows resolve to their tasks, a heartbeat allows any task with a live record, fleet is always allowed), so a report typed from memory about a task whose records teardown already removed is never stored or delivered. - An acknowledgement that consumes nothing says "nothing was acknowledged through N" and prints the exact --ack-through / --recovery-generation command for the current presented wake, instead of "re-run the drain", which re-fed the same stale acknowledgement in a loop. - bin/fm-guard.sh no longer tells the branch actor to drain queued wakes while it is handling them; it names the granted rows instead. - Teardown removes state/..branch-outcome-index for ordinary tasks and descendants; the index rebuild and the append-side index write both skip a task with neither a live record nor a status log, so the branch's report of a teardown it just performed is stored without recreating the index. No new locking, no spawn-generation binding, and no retired-task refusal: the branch can still report the outcome of a task it just tore down, and the teardown test now proves that path end to end. --- .pi/extensions/fm-branch-supervision.ts | 40 +++++- .pi/extensions/lib/fm-branch-dispatch.ts | 63 +++++++++- AGENTS.md | 2 +- bin/fm-branch-outcome.sh | 22 +++- bin/fm-branch-prompt.sh | 2 + bin/fm-guard.sh | 33 ++++- bin/fm-teardown.sh | 5 +- bin/fm-wake-drain.sh | 45 ++++++- docs/pi-supervision-branch.md | 2 + docs/watcher-continuity.md | 1 + tests/fm-guard-stale-banner.test.sh | 49 ++++++++ tests/fm-pi-branch-extension.test.sh | 125 +++++++++++++++++-- tests/fm-teardown.test.sh | 18 +++ tests/fm-wake-drain-outcome-backstop.test.sh | 22 ++++ tests/fm-wake-queue.test.sh | 106 ++++++++++++++++ 15 files changed, 508 insertions(+), 27 deletions(-) diff --git a/.pi/extensions/fm-branch-supervision.ts b/.pi/extensions/fm-branch-supervision.ts index 8d566b490a6..8dda46003c3 100644 --- a/.pi/extensions/fm-branch-supervision.ts +++ b/.pi/extensions/fm-branch-supervision.ts @@ -512,6 +512,16 @@ export default function (pi: ExtensionAPI) { // so a prompt can prove that it created a durable outcome after claiming its // wake rows without relying on provider text or incidental session shape. let durableReportRevision = 0; + // The task set the wake being handled right now may be reported on, fixed + // deterministically from the eligible rows before the prompt opens and + // cleared when it settles: a signal or stale wake names exactly the tasks + // its rows resolve to, a heartbeat review may name any task with a live + // record at that moment, and `fleet` is always allowed. fm_branch_report + // refuses every other task id, so a report typed from memory about a task + // the wake never named (or one whose record is already gone) is never + // stored or delivered. Null only outside a wake prompt, where no model turn + // can reach the tool. + let wakeTaskScope: { heartbeat: boolean; rows: string[]; tasks: Set } | null = null; let mainStreaming = false; let shuttingDown = false; // Bumps at every session replacement so a stale chain continuation from the @@ -921,6 +931,17 @@ export default function (pi: ExtensionAPI) { return presentUnprocessedOutcomes(expectedGeneration); } + function wakeScopeRefusal(task: string): string { + if (!wakeTaskScope || task === "fleet" || wakeTaskScope.tasks.has(task)) return ""; + const allowed = [...wakeTaskScope.tasks].sort(); + const named = allowed.length === 0 ? "no task" : allowed.join(", "); + if (wakeTaskScope.heartbeat) { + return `report refused: ${task} has no live task record, so this fleet review cannot report it; live tasks: ${named} (use fleet for a fleet-wide outcome)`; + } + const rows = wakeTaskScope.rows.join(", "); + return `report refused: the wake being handled (row ${rows}) names ${named}, not ${task}; report only that task or fleet, never a task from memory`; + } + function createReportTool(toolGeneration: number): ToolDefinition { return { name: "fm_branch_report", @@ -956,6 +977,10 @@ export default function (pi: ExtensionAPI) { }; } const verdict = verdictRaw as Verdict; + const scopeRefusal = wakeScopeRefusal(task); + if (scopeRefusal) { + return { content: [{ type: "text", text: scopeRefusal }], details: undefined, isError: true }; + } const appendArgs = ["append", "--task", task, "--verdict", verdict, "--summary", summary, "--silent", String(silent)]; if (wake) appendArgs.push("--wake", wake); if (!actingAsOwner(toolGeneration)) { @@ -1215,9 +1240,18 @@ ${context.command} // the drain; that residual is accepted by the confused-agent-grade boundary. const reportRevisionBeforePrompt = durableReportRevision; const entryOffset = sessionManager.getEntries().length; - await session.prompt( - `FIRSTMATE SUPERVISION WAKE: ${message}\n\nHandle this per your operating procedure and finish with fm_branch_report.`, - ); + wakeTaskScope = { + heartbeat, + rows: [...scope.eligibleSeqs], + tasks: new Set(heartbeat ? scope.liveTasks : scope.eligibleTasks), + }; + try { + await session.prompt( + `FIRSTMATE SUPERVISION WAKE: ${message}\n\nHandle this per your operating procedure and finish with fm_branch_report.`, + ); + } finally { + wakeTaskScope = null; + } const providerError = settledPromptProviderError(sessionManager, entryOffset); if (providerError) { const detail = `supervision branch provider failed after construction: ${providerError}`; diff --git a/.pi/extensions/lib/fm-branch-dispatch.ts b/.pi/extensions/lib/fm-branch-dispatch.ts index c507be1e8e0..26dc12b934a 100644 --- a/.pi/extensions/lib/fm-branch-dispatch.ts +++ b/.pi/extensions/lib/fm-branch-dispatch.ts @@ -33,6 +33,20 @@ export interface UnreadWakeScope { * `eligible` is false. */ eligibleSeqs: string[]; + /** + * The exact task ids the eligible signal/stale rows name (a signal row by + * its status-log key, a stale row through the task metadata recording that + * endpoint). The branch may report only these tasks, or `fleet`, while it + * handles the wake; a task it merely remembers is refused (docs/ + * pi-supervision-branch.md "Autonomy"). Empty for a heartbeat. + */ + eligibleTasks: string[]; + /** + * Every task with a live state/.meta at scan time. A heartbeat review + * may report any of them; a task with no live record cannot receive an + * outcome. + */ + liveTasks: string[]; /** * True only when this scan itself is untrustworthy: the queue or its * metadata could not be read, a line fails the structural tab-field check, @@ -47,8 +61,24 @@ export interface UnreadWakeScope { corrupted: boolean; } -const EMPTY_SCOPE: UnreadWakeScope = { status: "empty", eligible: false, projects: [], eligibleSeqs: [], corrupted: false }; -const UNSAFE_SCOPE: UnreadWakeScope = { status: "unsafe", eligible: false, projects: [], eligibleSeqs: [], corrupted: true }; +const EMPTY_SCOPE: UnreadWakeScope = { + status: "empty", + eligible: false, + projects: [], + eligibleSeqs: [], + eligibleTasks: [], + liveTasks: [], + corrupted: false, +}; +const UNSAFE_SCOPE: UnreadWakeScope = { + status: "unsafe", + eligible: false, + projects: [], + eligibleSeqs: [], + eligibleTasks: [], + liveTasks: [], + corrupted: true, +}; // scopeForUnreadWake is the single owner of branch-eligibility classification // (docs/pi-supervision-branch.md "Autonomy"; docs/watcher-continuity.md @@ -92,6 +122,10 @@ export function scopeForUnreadWake(state: string, heartbeat: boolean): UnreadWak const projects = new Set(); const metadata = new Map(); + // The task id behind each key a signal or stale row may carry: the task id + // itself, or the endpoint its metadata records. + const taskByKey = new Map(); + const liveTasks: string[] = []; try { for (const name of readdirSync(state)) { if (!name.endsWith(".meta")) continue; @@ -99,9 +133,14 @@ export function scopeForUnreadWake(state: string, heartbeat: boolean): UnreadWak const fields = readFileSync(`${state}/${name}`, "utf8").split(/\r?\n/); const project = fields.find((line) => line.startsWith("project="))?.slice(8) ?? ""; const window = fields.find((line) => line.startsWith("window="))?.slice(7) ?? ""; + liveTasks.push(task); if (project) { metadata.set(task, project); - if (window) metadata.set(window, project); + taskByKey.set(task, task); + if (window) { + metadata.set(window, project); + taskByKey.set(window, task); + } } } } catch { @@ -109,6 +148,7 @@ export function scopeForUnreadWake(state: string, heartbeat: boolean): UnreadWak } const eligibleSeqs: string[] = []; + const eligibleTasks = new Set(); for (const line of rows) { const fields = line.split("\t"); if (fields.length < 5 || !/^[0-9]+$/.test(fields[1])) return UNSAFE_SCOPE; @@ -126,18 +166,21 @@ export function scopeForUnreadWake(state: string, heartbeat: boolean): UnreadWak continue; } let project = ""; + let task = ""; if (kind === "signal") { - const task = key.replace(/\.(?:status|turn-ended)$/, ""); + task = key.replace(/\.(?:status|turn-ended)$/, ""); project = metadata.get(task) ?? ""; } else if (kind === "stale") { + task = taskByKey.get(key) ?? taskByKey.get(key.replace(/^fm-/, "")) ?? ""; project = metadata.get(key) ?? metadata.get(key.replace(/^fm-/, "")) ?? ""; } else { // A kind fm_wake_append never emits: structural corruption, not an // ordinary main-only row. return UNSAFE_SCOPE; } - if (!project) return UNSAFE_SCOPE; + if (!project || !task) return UNSAFE_SCOPE; projects.add(project); + eligibleTasks.add(task); eligibleSeqs.push(seq); } const eligible = eligibleSeqs.length > 0; @@ -148,7 +191,15 @@ export function scopeForUnreadWake(state: string, heartbeat: boolean): UnreadWak // empty eligible set, so reading eligibility off the claim set rather than // off the heartbeat flag changes no pre-existing outcome and keeps a // heartbeat from being offered with nothing to hand over.) - return { status: eligible ? "safe" : "unsafe", eligible, projects: [...projects], eligibleSeqs, corrupted: false }; + return { + status: eligible ? "safe" : "unsafe", + eligible, + projects: [...projects], + eligibleSeqs, + eligibleTasks: [...eligibleTasks], + liveTasks, + corrupted: false, + }; } // The exact state-relative filename bin/fm-wake-drain.sh reads for a diff --git a/AGENTS.md b/AGENTS.md index 149894ad849..646c607ac7f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -109,7 +109,7 @@ state/ runtime records and signals; gitignored .pr-poll-registration private transactional provenance record binding the task, canonical metadata identity, sidecar, and static poll publication .pr-poll-retirement private identity-bound crash-recovery receipt for one exact validated merged result; removed after its poll artifacts retire .pr-poll-merge-notified canonical PR identity of the last merge outcome delivered for this task; bin/fm-pr-lib.sh owns the marker format and identity mechanics, while bin/fm-merge-outcome-lib.sh owns locked publication, duplicate suppression, and replacement - branch-outcomes.jsonl .branch-outcomes-cursor .branch-outcomes-processed ..branch-outcome-index .branch-outcome-index-ready Pi supervision-branch durable outcome store, its read cursor, main's processed marker, bounded latest per-task status-coverage caches, and their recovery marker; bin/fm-branch-outcome.sh owns the formats + branch-outcomes.jsonl .branch-outcomes-cursor .branch-outcomes-processed ..branch-outcome-index .branch-outcome-index-ready Pi supervision-branch durable outcome store, its read cursor, main's processed marker, bounded latest per-task status-coverage caches (each retired with its task by teardown), and their recovery marker; bin/fm-branch-outcome.sh owns the formats branch-session/ .branch-session .branch-mirror-cursor the branch's per-main-session conversations, the pointer to the current one, and the dialog-mirror cursor; extension-owned (docs/pi-supervision-branch.md) .branch-eligible-rows .branch-eligible-owner .main-eligible-rows per-actor wake-row claims and branch-owner evidence; docs/watcher-continuity.md owns the acknowledgement contract .lease- per-task supervision lease naming which actor (main or branch) may change that task; bin/fm-lease-lib.sh owns the contract the guarded scripts enforce diff --git a/bin/fm-branch-outcome.sh b/bin/fm-branch-outcome.sh index 3038cedfbd5..100cc5b2401 100755 --- a/bin/fm-branch-outcome.sh +++ b/bin/fm-branch-outcome.sh @@ -44,6 +44,11 @@ # before append and published only after the cache update; processed-init # rebuilds every cache before publishing it, so interruption or upgrade # fails closed without making each drain scan lifetime history. +# bin/fm-teardown.sh removes a retired task's cache with its other records, +# and both append and the rebuild skip the cache for a task that has +# neither a live meta nor a status log (the outcome itself is still +# stored), so the branch's report of a teardown it just performed leaves +# no index behind. # Main-actor drain calls processed-init under the outcome lock when that # ready marker is absent or invalid, on every harness; only a genuine store # fault keeps the lost-wake backstop skipped. @@ -264,6 +269,11 @@ rebuild_outcome_indexes() { ' "$STORE") || return 1 while IFS=$(printf '\t') read -r task seq epoch endpoint ident; do [ -n "$task" ] || continue + # A retired task (no live record, no status log) has no reader for its + # cache: the drain backstop compares an index against that task's status + # log, which is gone with the task. Rebuilding it would only resurrect the + # footprint teardown just removed. + [ -e "$STATE/$task.meta" ] || [ -e "$STATE/$task.status" ] || continue if [ -z "$endpoint" ] || [ -z "$ident" ]; then f="$STATE/$task.status" endpoint=0 @@ -460,7 +470,17 @@ case "$CMD" in "$SEQ" "$(date +%s)" "$(json_escape "$TASK")" "$(json_escape "$WAKE")" \ "$VERDICT" "$(json_escape "$SUMMARY")" "$SILENT" "$CAPTURED_STATUS_ENDPOINT" \ "$(json_escape "$CAPTURED_STATUS_IDENT")" >> "$STORE" - if ! write_outcome_index "$TASK" "$SEQ" || ! publish_outcome_index_ready "$SEQ"; then + # A task with neither a live meta nor a status log is retired: the branch + # reports the teardown it just performed, and writing the index here would + # recreate the footprint teardown removed. The outcome itself is still + # stored and delivered; only the reader-less cache is skipped. + if { [ -e "$STATE/$TASK.meta" ] || [ -e "$STATE/$TASK.status" ]; } \ + && ! write_outcome_index "$TASK" "$SEQ"; then + fm_lock_release "$LOCK" + echo "error: outcome was stored but its bounded task index could not be updated" >&2 + exit 1 + fi + if ! publish_outcome_index_ready "$SEQ"; then fm_lock_release "$LOCK" echo "error: outcome was stored but its bounded task index could not be updated" >&2 exit 1 diff --git a/bin/fm-branch-prompt.sh b/bin/fm-branch-prompt.sh index d3befd19795..4673503641a 100755 --- a/bin/fm-branch-prompt.sh +++ b/bin/fm-branch-prompt.sh @@ -100,6 +100,8 @@ Stay terse: your context is a cost. Do not re-read files the drain just printed. Never use shell background operators for supervision; the watcher and extension own continuity. Never call fm_branch_report speculatively - only after the event is actually handled or a refusal/lease conflict genuinely ended your handling. +The tool refuses a task the wake being handled did not name (a heartbeat names every task with a live record); a refusal means you reached for a task from memory, so report the wake's own task or fleet, never retry with another id. +An acknowledgement that consumed nothing says so and names the exact command for the current wake; run that printed command, do not drain again. # Recovery playbook (verbatim copy of the tracked skill) diff --git a/bin/fm-guard.sh b/bin/fm-guard.sh index 21d6da3ed81..ec799788a33 100755 --- a/bin/fm-guard.sh +++ b/bin/fm-guard.sh @@ -27,7 +27,11 @@ # bounded). Independent alarms (queued wakes, worktree tangle) are never # suppressed by that dedup. Normal wake handling (watcher briefly down between a # wake and the next supervision resume) stays inside the grace window and stays -# silent. Always exits 0: the guard warns, it never blocks. +# silent. The queued-wakes warning is replaced, for the supervision branch +# actor (FM_SUPERVISION_ACTOR=branch), by a note naming the rows its grant +# covers, because that actor runs guarded commands while handling exactly those +# rows and can drain nothing else. Always exits 0: the guard warns, it never +# blocks. set -u SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" @@ -52,6 +56,22 @@ STALE_BANNER_MARKER="$STATE/.guard-watcher-stale-banner" . "$SCRIPT_DIR/fm-tangle-lib.sh" # shellcheck source=bin/fm-supervision-lib.sh . "$SCRIPT_DIR/fm-supervision-lib.sh" +# shellcheck source=bin/fm-lease-lib.sh +. "$SCRIPT_DIR/fm-lease-lib.sh" + +# The current actor (fm_lease_actor is the one owner of that identity); a +# malformed value is a wiring bug elsewhere, so the guard just warns as main. +GUARD_ACTOR=$(fm_lease_actor 2>/dev/null) || GUARD_ACTOR=main + +# The sequence numbers currently granted to the branch, comma-joined, from the +# same snapshot bin/fm-wake-drain.sh consumes; empty when there is no valid +# grant. +fm_guard_branch_granted_rows() { # + local rows="$1/.branch-eligible-rows" + [ -f "$rows" ] && [ ! -L "$rows" ] && [ -s "$rows" ] || return 1 + awk 'BEGIN { ok=1 } !/^[0-9]+$/ || seen[$0]++ { ok=0 } END { exit !ok }' "$rows" || return 1 + awk 'NR > 1 { printf ", " } { printf "%s", $0 } END { printf "\n" }' "$rows" +} # Deterministic episode key from the qualitative down-state (the failing # condition), NOT the beacon mtime: under the auto-arm model a healthy @@ -232,9 +252,20 @@ fi # Queued wakes are an independent hazard; warn whenever they are pending, even if # a watcher is alive. Kept after the banner so the no-watcher alarm reads first. # Dedup of the watcher-down banner never suppresses this warning. +# The supervision branch is the exception: it runs guarded commands (fm-peek, +# fm-crew-state) in the middle of handling the very rows that are queued, and +# "drain them before anything else" mid-handling reads as "an earlier wake is +# still pending", which is what made it re-run a previous acknowledgement in a +# loop. The branch can act on nothing outside its grant anyway, so for that +# actor the guard names the granted rows it is handling and says nothing about +# any other queued row. if "$queue_pending"; then if [ "$READ_ONLY" -eq 1 ]; then echo "WARNING: queued wakes pending - left untouched because this session lacks verified fleet-lock ownership." >&2 + elif [ "$GUARD_ACTOR" = branch ]; then + branch_rows=$(fm_guard_branch_granted_rows "$STATE") || branch_rows= + [ -z "$branch_rows" ] \ + || echo "NOTE: you are handling wake row $branch_rows - finish with fm_branch_report, then run the exact WAKE_ACK_REQUIRED command your drain printed." >&2 else echo "WARNING: queued wakes pending - drain them with bin/fm-wake-drain.sh before anything else." >&2 fi diff --git a/bin/fm-teardown.sh b/bin/fm-teardown.sh index e200bc80b0f..c2e48dae6a0 100755 --- a/bin/fm-teardown.sh +++ b/bin/fm-teardown.sh @@ -2570,7 +2570,8 @@ cleanup_firstmate_home_children() { "$sub_state/$child_id.pi-ext.ts" \ "$sub_state/$child_id.grok-turnend-token" "$sub_state/$child_id.kimi-turnend-token" \ "$sub_state/$child_id.muse-session" "$sub_state/$child_id.muse-session-current" \ - "$sub_state/$child_id.cursor-session" "$sub_state/$child_id.reconcile-nudged" + "$sub_state/$child_id.cursor-session" "$sub_state/$child_id.reconcile-nudged" \ + "$sub_state/.$child_id.branch-outcome-index" done } @@ -2919,7 +2920,7 @@ rm -f "$STATE/$ID.turn-ended" \ "$STATE/$ID.muse-session-current" "$STATE/$ID.cursor-session" \ "$STATE/$ID.control-relaunch" "$STATE/$ID.control-relaunch.meta-prior" \ "$STATE/$ID.control-relaunch.brief-prior" "$STATE/$ID.control-relaunch.note" \ - "$STATE/$ID.reconcile-nudged" + "$STATE/$ID.reconcile-nudged" "$STATE/.$ID.branch-outcome-index" # The steering inbox (bin/fm-task-inbox-lib.sh) is runtime state for the # retired endpoint; teardown only runs after landing is confirmed, so any # leftover unhandled steer here is moot rather than unlanded work. diff --git a/bin/fm-wake-drain.sh b/bin/fm-wake-drain.sh index 88fc8edb5d8..73bbd30d8c6 100755 --- a/bin/fm-wake-drain.sh +++ b/bin/fm-wake-drain.sh @@ -33,6 +33,8 @@ RECOVERY_ACK_REQUIRED=false RECOVERY_ACK_MOVED=false ACK_THROUGH= ACK_GENERATION= +ACK_REMOVED=0 +PRESENTED_MAX=0 ACK_FINGERPRINTS= ACK_NOTICE_FINGERPRINTS= PRESENTATION_LOCK_TIMEOUT=${FM_STATUS_PRESENTATION_LOCK_TIMEOUT:-10} @@ -144,6 +146,19 @@ require_branch_eligible_rows() { } } +# 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 +# presentation is never named as "the current wake" the caller may acknowledge +# unseen. 0 when nothing is on record. +presented_max_row() { # + if rows_file_valid "$1" 2>/dev/null; then + awk '$1 ~ /^[0-9]+$/ && $1 > max { max=$1 } END { print max + 0 }' "$1" + else + printf '0\n' + fi +} + case "${1:-}" in '') ;; --ack-through) @@ -594,6 +609,11 @@ reclaim_stale_branch_grant_locked || exit 1 [ "$ACTOR" != branch ] || require_branch_eligible_rows || exit 1 if [ -n "$ACK_THROUGH" ]; then + if [ "$ACTOR" = branch ]; then + PRESENTED_MAX=$(presented_max_row "$ELIGIBLE_ROWS_FILE") || exit 1 + else + PRESENTED_MAX=$(presented_max_row "$MAIN_ROWS_FILE") || exit 1 + fi if [ "$ACTOR" = main ]; then # Preserve main's original whole-cutoff acknowledgement contract: rows may # arrive after presentation but before the printed ack runs, and a direct @@ -649,6 +669,7 @@ if [ -n "$ACK_THROUGH" ]; then exit 1 } fi + ACK_REMOVED=$(( $(awk 'END { print NR }' "$FM_WAKE_QUEUE") - $(awk 'END { print NR }' "$DRAIN_TMP") )) if [ ! -s "$DRAIN_TMP" ]; then fm_recovery_marker_ack "$RECOVERY_MARKER" "$ACK_GENERATION" RECOVERY_ACK_STATUS=$? @@ -679,9 +700,27 @@ if [ -n "$ACK_THROUGH" ]; then fi fm_lock_release "$FM_WAKE_QUEUE_LOCK" DRAIN_LOCK_HELD=false - if [ "$RECOVERY_ACK_MOVED" = true ]; then - printf 'wake drain: acknowledged wakes through %s, but a newer recovery episode is pending; re-run bin/fm-wake-drain.sh and use the new WAKE_ACK_REQUIRED command\n' \ - "$ACK_THROUGH" >&2 + if [ "$ACK_REMOVED" -eq 0 ] && [ "$PRESENTED_MAX" -gt "$ACK_THROUGH" ]; then + # Nothing at or below the cutoff was this actor's to consume, while a + # presented row above it is still waiting: the caller acknowledged an + # earlier wake, not the one it is handling. Say so, and name the exact + # command for the current wake, so the remedy is never "drain again" (which + # re-presents the same row and invites the same stale acknowledgement). + # The generation is the marker's current one; only a retired marker cannot + # be named because the next drain opens a fresh generation for it. + case "$RECOVERY_MARKER_TOKEN" in + pending:*|announced:*) + printf 'wake drain: nothing was acknowledged through %s (none of your presented wake rows is at or below it); the current wake is row %s: run bin/fm-wake-drain.sh --ack-through %s --recovery-generation %s after handling it\n' \ + "$ACK_THROUGH" "$PRESENTED_MAX" "$PRESENTED_MAX" "${RECOVERY_MARKER_TOKEN##*:}" >&2 + ;; + *) + printf 'wake drain: nothing was acknowledged through %s (none of your presented wake rows is at or below it); the current wake is row %s: re-run bin/fm-wake-drain.sh and use the WAKE_ACK_REQUIRED command it prints\n' \ + "$ACK_THROUGH" "$PRESENTED_MAX" >&2 + ;; + esac + elif [ "$RECOVERY_ACK_MOVED" = true ]; then + printf 'wake drain: acknowledged wakes through %s (%s row(s) consumed), but a newer recovery episode is pending; re-run bin/fm-wake-drain.sh and use the new WAKE_ACK_REQUIRED command\n' \ + "$ACK_THROUGH" "$ACK_REMOVED" >&2 fi exit 0 fi diff --git a/docs/pi-supervision-branch.md b/docs/pi-supervision-branch.md index 71786837b2c..d6cec011075 100644 --- a/docs/pi-supervision-branch.md +++ b/docs/pi-supervision-branch.md @@ -34,6 +34,8 @@ The supervision branch itself is Pi-only by construction: It checks the current extension generation and `state/.lock` ownership before each guarded branch side effect so replacement or lock loss cannot let an old continuation mutate the new session. Every accepted path that cannot reach a working branch rejects its settlement to the watcher, which retains delivery ownership and routes the wake to main as a follow-up that counts as delivered once Pi accepts it; a broken branch declines later offers so they take that path directly. After wake rows are claimed, a branch prompt counts as handled only when `fm_branch_report` appends a durable outcome before that prompt settles; a settled provider error or a settled prompt with no report releases the grant and rejects delivery ownership back to the watcher. + While a prompt is open, `fm_branch_report` accepts only `fleet` and the tasks that prompt's claimed rows resolve to (a signal row by its status-log key, a stale row through the task record naming that endpoint), or for a heartbeat review any task with a live record at prompt time; a report for any other task id is refused before the store is touched, so a task remembered from an earlier wake, or one whose records teardown already removed, cannot become a delivered outcome. + The branch's guarded commands never tell it to drain queued rows mid-handling: for that actor `bin/fm-guard.sh` names the granted rows it is handling instead, and an acknowledgement that consumed nothing reports that plainly with the exact command for the current wake (`docs/watcher-continuity.md` "Per-actor acknowledgement"). Two consecutive settled provider errors latch the branch broken and surface a one-line health note only on that initial trip. Main keeps every wake during a five-minute cooldown, after which one wake may probe the branch while concurrent wakes still stay on main; each probe that settles with another provider error doubles the next cooldown up to one hour. A prompt from the current branch generation and model or effort selection that appends a durable `fm_branch_report` and then settles without a provider error clears both the latch and provider-error streak and surfaces a one-line recovery note; a provider error settled after that report wins instead, re-latches the branch, and extends the cooldown. diff --git a/docs/watcher-continuity.md b/docs/watcher-continuity.md index 537a236d842..c4b746dac52 100644 --- a/docs/watcher-continuity.md +++ b/docs/watcher-continuity.md @@ -73,6 +73,7 @@ A main drain validates that owner evidence under the queue lock and reclaims the A main drain claims every currently unclaimed row and excludes an active branch grant from both presentation and acknowledgement. Its `--ack-through ` deletes only claimed main rows at or below the cutoff, while a branch acknowledgement deletes only claimed branch rows at or below its cutoff. Every settled branch prompt releases any residual grant, so an omitted or failed acknowledgement leaves the durable row available to a later main drain; a successful acknowledgement has already removed it. +An acknowledgement whose cutoff removes none of the actor's rows while a presented row above the cutoff still waits is reported as having acknowledged nothing, together with the exact `--ack-through` and `--recovery-generation` command for that presented row; the presented set is read before any re-claim, so a row that arrived after presentation is never named for unseen acknowledgement. If a branch offer loses the claim race to main, it rejects its settlement so the watcher retains the actionable close until Pi accepts its main follow-up. [`pi-supervision-branch.md`](pi-supervision-branch.md#components-and-their-owners) owns branch eligibility, mixed-queue dispatch, the pre-drain recheck, and heartbeat's all-or-nothing rule. A check-kind row is main-owned in every mode, including a heartbeat review, so it is never part of a branch claim and never defers one; main is woken for it on that check's own triggering close. diff --git a/tests/fm-guard-stale-banner.test.sh b/tests/fm-guard-stale-banner.test.sh index 4171301f6c6..c9748e4f3da 100755 --- a/tests/fm-guard-stale-banner.test.sh +++ b/tests/fm-guard-stale-banner.test.sh @@ -86,6 +86,18 @@ run_guard_case_extension() { "$ROOT/bin/fm-guard.sh" 2>&1 } +# The same extension-model call from the supervision branch actor +# (FM_SUPERVISION_ACTOR=branch, as the Pi branch extension injects it). +run_guard_case_extension_as_branch() { + local dir=$1 + FM_ROOT_OVERRIDE="$(case_root "$dir")" \ + FM_HOME="$(case_home "$dir")" \ + FM_GUARD_GRACE=999 \ + FM_SUPERVISION_MODEL=extension \ + FM_SUPERVISION_ACTOR=branch \ + "$ROOT/bin/fm-guard.sh" 2>&1 +} + # Stand up the durable evidence a live Pi session leaves behind: both primary # extensions present under the case root, and a marker per extension recording # that extension's current build plus the session pid in state/.lock. @@ -613,6 +625,42 @@ test_extension_handoff_keeps_queued_wake_warning() { # The tolerance is scoped to the extension model alone. Every persistent-watcher # primary (codex, opencode, grok, kimi, tmux, unknown) must keep alarming on the # same state, even when Pi extension markers happen to be present on disk. +# The supervision branch runs guarded commands (fm-peek, fm-crew-state) while +# handling the very rows that are queued. For that actor the drain warning is +# not advice, it is the misreading that re-ran a previous acknowledgement in a +# loop, so the guard names the granted rows instead and says nothing about any +# other queued row. +test_branch_actor_sees_its_granted_rows_instead_of_the_drain_warning() { + local dir home out pid + dir=$(make_guard_case branch-actor-queued-wake) + home=$(case_home "$dir") + sleep 60 & + pid=$! + record_pi_extension_session "$dir" "$pid" || fail "could not record the Pi extension session" + touch "$home/state/.last-watcher-beat" + printf '%s\n' \ + "1700000000 7 stale firstmate:fm-task stale: firstmate:fm-task (idle 378s, possible wedge)" \ + "1700000001 8 check merge-poll check: merge-poll: merged" > "$home/state/.wake-queue" + printf '7\n' > "$home/state/.branch-eligible-rows" + out=$(run_guard_case_extension_as_branch "$dir") + assert_contains "$out" "you are handling wake row 7" \ + "the branch actor must be told which granted row it is handling" + assert_not_contains "$out" "queued wakes pending" \ + "the branch actor must not be told to drain the rows it is already handling" + rm -f "$home/state/.branch-eligible-rows" + out=$(run_guard_case_extension_as_branch "$dir") + assert_not_contains "$out" "queued wakes pending" \ + "a branch actor with no grant can drain nothing, so the warning must stay silent" + assert_not_contains "$out" "you are handling" \ + "a branch actor with no grant is handling nothing" + out=$(run_guard_case_extension "$dir") + kill "$pid" 2>/dev/null || true + wait "$pid" 2>/dev/null || true + assert_contains "$out" "queued wakes pending" \ + "main must still be warned about the queued rows" + pass "fm-guard: the branch actor is told which granted row it is handling, never to drain first" +} + test_persistent_model_ignores_pi_extension_evidence() { local dir home out pid dir=$(make_guard_case persistent-ignores-pi-evidence) @@ -690,6 +738,7 @@ test_extension_without_ownership_evidence_stays_alarm test_extension_ownership_needs_every_signal test_extension_stale_beacon_alarms_despite_live_session test_extension_handoff_keeps_queued_wake_warning +test_branch_actor_sees_its_granted_rows_instead_of_the_drain_warning test_persistent_model_ignores_pi_extension_evidence test_extension_live_watcher_is_healthy_without_ownership_evidence test_autoarm_fresh_beacon_without_watcher_is_healthy diff --git a/tests/fm-pi-branch-extension.test.sh b/tests/fm-pi-branch-extension.test.sh index c3fd163a582..ec064792fba 100644 --- a/tests/fm-pi-branch-extension.test.sh +++ b/tests/fm-pi-branch-extension.test.sh @@ -669,9 +669,12 @@ console.log(`CACHE_KEY=${rewriteA.prompt_cache_key}`); // captain-relevant persists a visible entry with no model turn. Store rows are // written before delivery and marked read only after it. const report = session.options.customTools.find((tool) => tool.name === "fm_branch_report"); -const r1 = await report.execute("call-1", { task: "task-9", verdict: "routine", summary: "worker healthy, no action needed", wake: "signal: working" }, undefined, undefined, {}); +const r1 = await report.execute("call-1", { task: "branch-driver", verdict: "routine", summary: "worker healthy, no action needed", wake: "signal: working" }, undefined, undefined, {}); if (r1.isError) throw new Error(`routine report failed: ${JSON.stringify(r1)}`); finishWakePrompt(); +// Reports below are made outside any wake prompt (as a real Pi turn cannot): +// wait for the wake to settle so its task scope has been cleared. +await offer.settlement; globalThis.__fmOnBranchPrompt = undefined; if (sentToMain.length !== 1) throw new Error("routine report did not merge exactly one note"); if (sentToMain[0].message.customType !== "fm-branch-merge") throw new Error("merge note has the wrong custom type"); @@ -947,7 +950,7 @@ globalThis.__fmOnBranchPrompt = async ({ session }) => { const result = await report.execute( `resource-result-${fleetOperations.length}`, { - task: "task-resource", + task: "branch-driver", verdict: directlyRequested ? "captain" : "routine", summary: "healthy resource report: CPU 12%, memory 41%", wake: "signal: healthy resource result", @@ -1002,7 +1005,7 @@ if (sentToMain.length !== 1 || sentToMain[0].options.triggerTurn) { throw new Error(`unsolicited healthy result opened a main turn: ${JSON.stringify(sentToMain)}`); } const sailboat = sentToMain[0]; -if (sailboat.message.display !== true || !sailboat.message.content.startsWith("⛵ task-resource:")) { +if (sailboat.message.display !== true || !sailboat.message.content.startsWith("⛵ branch-driver:")) { throw new Error(`unsolicited healthy result was not a rendered sailboat note: ${JSON.stringify(sailboat)}`); } @@ -1064,7 +1067,7 @@ if (processingRequests.length !== 2 || processingRequests[1].options.triggerTurn throw new Error(`the widened captain sequence set did not open one keyed turn at the run boundary: ${JSON.stringify(processingRequests)}`); } for (let seq = 2; seq <= 5; seq += 1) { - if (!processingRequests[1].message.content.includes(`[seq ${seq}] task-resource: healthy resource report: CPU 12%, memory 41%`)) { + if (!processingRequests[1].message.content.includes(`[seq ${seq}] branch-driver: healthy resource report: CPU 12%, memory 41%`)) { throw new Error(`the widened processing request lost seq ${seq}: ${processingRequests[1].message.content}`); } } @@ -1216,12 +1219,16 @@ if (readFileSync(`${home}/state/.branch-outcomes-processed`, "utf8").trim() !== // open through its report, as the real AgentSession does for tool execution. let finishRoutinePrompt; globalThis.__fmOnBranchPrompt = () => new Promise((resolve) => { finishRoutinePrompt = resolve; }); -if (!dispatch("signal: routine wake").accepted) throw new Error("branch refused the routine wake"); +const routineOffer = dispatch("signal: routine wake"); +if (!routineOffer.accepted) throw new Error("branch refused the routine wake"); await settle(() => (globalThis.__fmPrompts ?? []).length === 1, "routine branch prompt"); const session = globalThis.__fmSessions[0]; const report = session.options.customTools.find((tool) => tool.name === "fm_branch_report"); -await report.execute("routine", { task: "task-r", verdict: "routine", summary: "worker healthy" }, undefined, undefined, {}); +await report.execute("routine", { task: "branch-driver", verdict: "routine", summary: "worker healthy" }, undefined, undefined, {}); finishRoutinePrompt(); +// The reports below are made outside any wake prompt: wait for the wake to +// settle so its task scope has been cleared. +await routineOffer.settlement; globalThis.__fmOnBranchPrompt = undefined; const routineSeq = JSON.parse(outcomeScript(["list", "--recent", "1"])).seq; runOf(); @@ -1301,17 +1308,21 @@ const stale = await report.execute("captain-stale", { task: "task-e", verdict: " if (!stale.isError) throw new Error("a replaced branch session's report tool was accepted"); let finishReplacementPrompt; globalThis.__fmOnBranchPrompt = () => new Promise((resolve) => { finishReplacementPrompt = resolve; }); -if (!dispatch("signal: after replacement").accepted) throw new Error("branch refused a wake after the replacement"); +const replacementOffer = dispatch("signal: after replacement"); +if (!replacementOffer.accepted) throw new Error("branch refused a wake after the replacement"); await settle(() => (globalThis.__fmSessions ?? []).length === 2, "replacement branch session"); const report2 = globalThis.__fmSessions[1].options.customTools.find((tool) => tool.name === "fm_branch_report"); const beforePair = requests().length; -const second = await report2.execute("captain-2", { task: "task-e", verdict: "captain", summary: "PR https://example.com/pr/e is ready for review" }, undefined, undefined, {}); +const second = await report2.execute("captain-2", { task: "branch-driver", verdict: "captain", summary: "PR https://example.com/pr/e is ready for review" }, undefined, undefined, {}); if (second.isError) throw new Error(`second captain report failed: ${JSON.stringify(second)}`); finishReplacementPrompt(); +// The next report is made outside the wake prompt: wait for the wake to +// settle so its task scope has been cleared. +await replacementOffer.settlement; globalThis.__fmOnBranchPrompt = undefined; const seqE = seq + 1; const seqF = seq + 2; -if (requests().length !== beforePair + 1 || !requests().at(-1).message.content.includes(`[seq ${seqE}] task-e:`)) { +if (requests().length !== beforePair + 1 || !requests().at(-1).message.content.includes(`[seq ${seqE}] branch-driver:`)) { throw new Error("the first newer captain outcome did not open its processing request"); } const third = await report2.execute("captain-3", { task: "task-f", verdict: "captain", summary: "worker blocked on a missing credential" }, undefined, undefined, {}); @@ -1327,7 +1338,7 @@ if (JSON.stringify(unprocessedSeqs()) !== JSON.stringify([seqE, seqF])) { runOf(); if (requests().length !== beforePair + 2) throw new Error("the widened sequence was not presented at the run boundary"); const latest = requests().at(-1).message.content; -if (!latest.includes(`[seq ${seqE}] task-e:`) || !latest.includes(`[seq ${seqF}] task-f:`) || !latest.includes(`through=${seqF}`)) { +if (!latest.includes(`[seq ${seqE}] branch-driver:`) || !latest.includes(`[seq ${seqF}] task-f:`) || !latest.includes(`through=${seqF}`)) { throw new Error(`the widened request did not cover every unprocessed sequence with the highest key: ${latest}`); } const beforePairRepeat = requests().length; @@ -1602,6 +1613,99 @@ EOF pass "a heartbeat review survives a check row arriving before its drain" } +# The report tool refuses a task the wake being handled never named: the +# refused-ack loop's ghost reports were typed from memory about a task whose +# records teardown had already removed, while the prompt was a stale row for +# another pane. A signal or stale wake may report only the task its rows +# resolve to (or fleet); a heartbeat review may report any task with a live +# record. The wake's own task and fleet still go through, and nothing refused +# ever reaches the durable store. +test_branch_report_refuses_a_task_the_wake_did_not_name() { + local repo home out status + repo="$TMP_ROOT/ghost-report-root" + home="$TMP_ROOT/ghost-report-home" + mkdir -p "$home/state" "$home/config" + install_pi_branch_extension_fixture "$repo" + PLUGIN="$repo/.pi/extensions/fm-branch-supervision.ts" FM_HOME="$home" FM_ROOT_OVERRIDE="$ROOT" \ + DRIVER_PRELUDE="$DRIVER_PRELUDE" node --input-type=module > "$TMP_ROOT/node-output" 2>&1 <<'EOF' +const prelude = process.env.DRIVER_PRELUDE; +await eval(`(async () => { ${prelude}; globalThis.__t = { dispatch, fire, home, settle, approvedProject, defaultSessionCtx }; })()`); +const { dispatch, fire, home, settle, approvedProject, defaultSessionCtx } = globalThis.__t; +import { existsSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname } from "node:path"; +import { pathToFileURL } from "node:url"; + +// A second live task the wake does not name, plus the memory of a task whose +// records are already gone. +writeFileSync(`${home}/state/other-task.meta`, `project=${approvedProject}\nwindow=default:wX:p1\n`); +fire("session_start", {}, defaultSessionCtx); + +let finish; +globalThis.__fmOnBranchPrompt = () => new Promise((resolve) => { finish = resolve; }); +if (!dispatch("signal: task-local wake").accepted) throw new Error("branch refused the task-local wake"); +await settle(() => (globalThis.__fmPrompts ?? []).length === 1, "task-local branch prompt"); +const session = globalThis.__fmSessions[globalThis.__fmSessions.length - 1]; +const report = session.options.customTools.find((tool) => tool.name === "fm_branch_report"); +const ghost = await report.execute("ghost", { task: "other-task", verdict: "captain", summary: "PR ready to merge" }, undefined, undefined, {}); +if (!ghost.isError || !ghost.content[0].text.includes("names branch-driver, not other-task")) { + throw new Error(`a report for a live task the wake never named was not refused: ${JSON.stringify(ghost)}`); +} +const gone = await report.execute("gone", { task: "retired-task", verdict: "captain", summary: "PR ready to merge" }, undefined, undefined, {}); +if (!gone.isError) throw new Error(`a report for a task with no record was not refused: ${JSON.stringify(gone)}`); +const fleet = await report.execute("fleet", { task: "fleet", verdict: "routine", summary: "fleet-wide note" }, undefined, undefined, {}); +if (fleet.isError) throw new Error(`a fleet report was refused during a task-local wake: ${JSON.stringify(fleet)}`); +const named = await report.execute("named", { task: "branch-driver", verdict: "routine", summary: "worker healthy" }, undefined, undefined, {}); +if (named.isError) throw new Error(`the wake's own task was refused: ${JSON.stringify(named)}`); +finish(); +await settle(() => !existsSync(`${home}/state/.branch-eligible-rows`), "task-local grant release"); + +// A heartbeat review may report any task with a live record, never a task +// with none. +globalThis.__fmOnBranchPrompt = () => new Promise((resolve) => { finish = resolve; }); +if (!dispatch("heartbeat", [], true, true).accepted) throw new Error("branch refused the heartbeat"); +await settle(() => (globalThis.__fmPrompts ?? []).length === 2, "heartbeat branch prompt"); +const heartbeatSession = globalThis.__fmSessions[globalThis.__fmSessions.length - 1]; +const heartbeatReport = heartbeatSession.options.customTools.find((tool) => tool.name === "fm_branch_report"); +const live = await heartbeatReport.execute("live", { task: "other-task", verdict: "routine", summary: "worker healthy" }, undefined, undefined, {}); +if (live.isError) throw new Error(`a heartbeat report for a live task was refused: ${JSON.stringify(live)}`); +const goneInReview = await heartbeatReport.execute("gone-in-review", { task: "retired-task", verdict: "captain", summary: "PR ready to merge" }, undefined, undefined, {}); +if (!goneInReview.isError || !goneInReview.content[0].text.includes("no live task record")) { + throw new Error(`a heartbeat report for a task with no record was not refused: ${JSON.stringify(goneInReview)}`); +} +finish(); + +const stored = readFileSync(`${home}/state/branch-outcomes.jsonl`, "utf8").trim().split("\n").map((line) => JSON.parse(line).task); +if (JSON.stringify(stored) !== JSON.stringify(["fleet", "branch-driver", "other-task"])) { + throw new Error(`refused reports reached the durable store: ${JSON.stringify(stored)}`); +} + +// The classification owner names the tasks behind each eligible row: a +// signal row by its status-log key, a stale row by the endpoint a task's +// metadata records, with every live record listed for a heartbeat review. +const lib = await import(pathToFileURL(`${dirname(process.env.PLUGIN)}/lib/fm-branch-dispatch.ts`).href); +writeFileSync(`${home}/state/.wake-queue`, [ + "1\t1\tsignal\tbranch-driver.status\tsignal: done", + "2\t2\tstale\tdefault:wX:p1\tstale: default:wX:p1 (idle 378s)", + "3\t3\tcheck\tmerge-poll\tcheck: merged", +].join("\n") + "\n"); +const scope = lib.scopeForUnreadWake(`${home}/state`, false); +if (JSON.stringify([...scope.eligibleTasks].sort()) !== JSON.stringify(["branch-driver", "other-task"])) { + throw new Error(`eligible rows resolved to the wrong tasks: ${JSON.stringify(scope)}`); +} +if (JSON.stringify([...scope.liveTasks].sort()) !== JSON.stringify(["branch-driver", "other-task"])) { + throw new Error(`live tasks were not listed from the task records: ${JSON.stringify(scope)}`); +} +if (JSON.stringify(scope.eligibleSeqs) !== JSON.stringify(["1", "2"])) { + throw new Error(`the main-owned check row leaked into the branch claim: ${JSON.stringify(scope)}`); +} +process.exit(0); +EOF + status=$? + out=$(cat "$TMP_ROOT/node-output") + expect_code 0 "$status" "the report tool must refuse tasks the wake never named: $out" + pass "fm_branch_report refuses a task the wake did not name and a task with no live record" +} + # The non-heartbeat half of the same recheck: a check-kind row that arrives # after a signal/stale offer is accepted must stay main-owned WITHOUT bouncing # the branch's own eligible row back to main @@ -3889,6 +3993,7 @@ test_branch_dispatch_classifies_main_only_rows_and_writes_the_eligible_snapshot test_branch_cache_key_is_per_home_stable test_branch_default_on_heartbeat_afk_and_fallback test_branch_predrain_recheck_keeps_a_heartbeat_a_co_present_check_arrives_under +test_branch_report_refuses_a_task_the_wake_did_not_name test_branch_predrain_recheck_excludes_new_main_owned_row_without_deferring_eligible_work test_settled_branch_prompt_releases_unacknowledged_grant test_post_construction_provider_error_falls_back_latches_and_recovers_on_cooldown diff --git a/tests/fm-teardown.test.sh b/tests/fm-teardown.test.sh index 1a626698d57..940a2055db8 100755 --- a/tests/fm-teardown.test.sh +++ b/tests/fm-teardown.test.sh @@ -569,6 +569,9 @@ test_local_only_fork_remote_allows() { write_meta "$case_dir" local-only ship wt_commit "$case_dir" "fix the thing" add_fork_with_pushed_branch "$case_dir" + # The supervision branch's bounded per-task outcome cache is a footprint of + # the retired task, not a record anything reads after it is gone. + printf 'fm-branch-outcome-index-v1\t5\t0\t-\n' > "$case_dir/state/.task-x1.branch-outcome-index" set +e run_teardown "$case_dir" > "$case_dir/stdout" 2> "$case_dir/stderr" @@ -577,6 +580,21 @@ test_local_only_fork_remote_allows() { expect_code 0 "$rc" "fork-allow: teardown should succeed when HEAD is on a fork remote" ! grep -q REFUSED "$case_dir/stderr" || fail "fork-allow: teardown printed a REFUSED line" + [ ! -e "$case_dir/state/.task-x1.branch-outcome-index" ] \ + || fail "fork-allow: teardown left the task's branch outcome index behind" + # The supervision branch reports the teardown it just performed AFTER the + # task's records are gone (bin/fm-branch-prompt.sh); that report must be + # stored, must publish its ready sequence, and must not recreate the index. + post_seq=$(FM_STATE_OVERRIDE="$case_dir/state" "$ROOT/bin/fm-branch-outcome.sh" append \ + --task task-x1 --verdict captain --summary 'PR merged and cleaned up') \ + || fail "fork-allow: post-teardown branch report was refused" + [ "$post_seq" = 1 ] || fail "fork-allow: post-teardown branch report got seq $post_seq, expected 1" + grep -q '"task":"task-x1"' "$case_dir/state/branch-outcomes.jsonl" \ + || fail "fork-allow: post-teardown branch report was not stored" + [ ! -e "$case_dir/state/.task-x1.branch-outcome-index" ] \ + || fail "fork-allow: post-teardown branch report recreated the retired task index" + [ "$(cat "$case_dir/state/.branch-outcome-index-ready")" = 1 ] \ + || fail "fork-allow: post-teardown branch report did not publish its ready sequence" jq -e --arg id task-x1 ' .schema == "fm-secondmate-home-summary.v1" and all(.endpoints[]; .id != $id) diff --git a/tests/fm-wake-drain-outcome-backstop.test.sh b/tests/fm-wake-drain-outcome-backstop.test.sh index 9a2a94b0c6a..e14a3a8a4cd 100755 --- a/tests/fm-wake-drain-outcome-backstop.test.sh +++ b/tests/fm-wake-drain-outcome-backstop.test.sh @@ -328,6 +328,27 @@ test_missing_index_self_heals_on_first_drain() { pass "a missing outcome index self-heals on the first drain and suppresses its handled status" } +# The rebuild recreates the caches from lifetime history; a task teardown +# already removed (no record, no status log) must not get its footprint back. +test_index_rebuild_skips_a_retired_task() { + local dir state + dir=$(make_case index-rebuild-retired) + state="$dir/state" + printf 'working: still going\n' > "$state/live.status" + printf '%s\n' \ + '{"seq":1,"epoch":1700000000,"task":"retired","wake":"","verdict":"captain","summary":"merged long ago","silent":false,"statusEndpoint":0,"statusIdent":"-"}' \ + '{"seq":2,"epoch":1700000001,"task":"live","wake":"","verdict":"routine","summary":"healthy","silent":false,"statusEndpoint":0,"statusIdent":"-"}' \ + > "$state/branch-outcomes.jsonl" + printf '2\n' > "$state/.branch-outcomes-cursor" + FM_STATE_OVERRIDE="$state" "$OUTCOMES" processed-init > "$dir/init.out" 2>&1 \ + || fail "processed-init failed: $(cat "$dir/init.out")" + [ -f "$state/.branch-outcome-index-ready" ] || fail "rebuild did not publish the ready marker" + [ -f "$state/.live.branch-outcome-index" ] || fail "rebuild skipped a task that still has a status log" + [ ! -e "$state/.retired.branch-outcome-index" ] \ + || fail "rebuild resurrected the outcome index of a task with no record and no status log" + pass "outcome index rebuild leaves a retired task without an index" +} + test_uncovered_event_surfaces_on_first_drain_without_index() { local dir state out body dir=$(make_case index-selfheal-uncovered) @@ -516,6 +537,7 @@ test_output_failure_does_not_commit_the_backstop_receipt test_receipt_commit_failure_repeats_the_already_presented_backstop test_rejected_decision_line_surfaces_once_through_backstop test_missing_index_self_heals_on_first_drain +test_index_rebuild_skips_a_retired_task test_uncovered_event_surfaces_on_first_drain_without_index test_malformed_outcome_store_fails_closed_without_pi_advice test_held_lock_mode_rejects_an_unlocked_caller diff --git a/tests/fm-wake-queue.test.sh b/tests/fm-wake-queue.test.sh index c1f86a59c0d..a9ca08be5f4 100755 --- a/tests/fm-wake-queue.test.sh +++ b/tests/fm-wake-queue.test.sh @@ -951,6 +951,110 @@ test_stale_recovery_generation_cannot_touch_a_newer_episode() { pass "wake drain: a stale acknowledgement cannot retire or consume a newer recovery episode" } +# An acknowledgement for an EARLIER wake while the current one is still +# presented consumes nothing. That must be said plainly, with the exact command +# for the current wake, because "re-run the drain" re-presents the same row and +# invites the same stale acknowledgement again (the refused-ack loop). +stale_ack_remedy() { # -> "\t" + local seq generation + seq=$(sed -n 's/^wake drain: nothing was acknowledged through [0-9][0-9]*.*run bin\/fm-wake-drain.sh --ack-through \([0-9][0-9]*\) --recovery-generation [A-Za-z0-9._-][A-Za-z0-9._-]* after handling it$/\1/p' "$1") + generation=$(sed -n 's/^wake drain: nothing was acknowledged through [0-9][0-9]*.*run bin\/fm-wake-drain.sh --ack-through [0-9][0-9]* --recovery-generation \([A-Za-z0-9._-][A-Za-z0-9._-]*\) after handling it$/\1/p' "$1") + [ -n "$seq" ] && [ -n "$generation" ] || return 1 + printf '%s\t%s\n' "$seq" "$generation" +} + +test_stale_ack_that_consumes_nothing_names_the_current_wake() { + local dir state first_seq first_gen second_seq second_gen remedy rc + dir=$(make_case stale-ack-current-wake) + state="$dir/state" + + append_wake "$state" check first 'check: first wake' || fail "first append failed" + FM_STATE_OVERRIDE="$state" "$DRAIN" > "$dir/first.out" 2> "$dir/first.err" || fail "first drain failed" + first_seq=$(sed -n 's/^WAKE_ACK_REQUIRED:.*--ack-through \([0-9][0-9]*\) --recovery-generation .*/\1/p' "$dir/first.err") + first_gen=$(sed -n 's/^WAKE_ACK_REQUIRED:.*--recovery-generation \([A-Za-z0-9._-][A-Za-z0-9._-]*\)$/\1/p' "$dir/first.err") + [ -n "$first_seq" ] && [ -n "$first_gen" ] || fail "first drain printed no acknowledgement command" + FM_STATE_OVERRIDE="$state" "$DRAIN" --ack-through "$first_seq" --recovery-generation "$first_gen" \ + || fail "first acknowledgement failed" + + append_wake "$state" check second 'check: second wake' || fail "second append failed" + FM_STATE_OVERRIDE="$state" "$DRAIN" > "$dir/second.out" 2> "$dir/second.err" || fail "second drain failed" + second_seq=$(sed -n 's/^WAKE_ACK_REQUIRED:.*--ack-through \([0-9][0-9]*\) --recovery-generation .*/\1/p' "$dir/second.err") + second_gen=$(sed -n 's/^WAKE_ACK_REQUIRED:.*--recovery-generation \([A-Za-z0-9._-][A-Za-z0-9._-]*\)$/\1/p' "$dir/second.err") + [ "$second_seq" -gt "$first_seq" ] || fail "second drain did not present a newer row" + + # The stale acknowledgement: the previous wake's command, re-run from memory. + rc=0 + FM_STATE_OVERRIDE="$state" "$DRAIN" --ack-through "$first_seq" --recovery-generation "$first_gen" \ + > "$dir/stale.out" 2> "$dir/stale.err" || rc=$? + [ "$rc" -eq 0 ] || fail "a stale acknowledgement failed instead of degrading safely: $(cat "$dir/stale.err")" + grep -F "nothing was acknowledged through $first_seq" "$dir/stale.err" >/dev/null \ + || fail "a no-op acknowledgement was not reported as acknowledging nothing: $(cat "$dir/stale.err")" + grep -F "the current wake is row $second_seq" "$dir/stale.err" >/dev/null \ + || fail "a no-op acknowledgement did not name the current wake: $(cat "$dir/stale.err")" + ! grep -F 're-run' "$dir/stale.err" >/dev/null \ + || fail "a no-op acknowledgement told the caller to drain again instead of naming the exact command: $(cat "$dir/stale.err")" + remedy=$(stale_ack_remedy "$dir/stale.err") \ + || fail "a no-op acknowledgement did not print the exact current command: $(cat "$dir/stale.err")" + [ "${remedy%%$'\t'*}" = "$second_seq" ] && [ "${remedy##*$'\t'}" = "$second_gen" ] \ + || fail "the printed remedy differs from the drain's own WAKE_ACK_REQUIRED command: $remedy vs $second_seq/$second_gen" + grep "$(printf '\tcheck\tsecond\t')" "$state/.wake-queue" >/dev/null \ + || fail "a stale acknowledgement consumed the current wake" + + # Following the printed command, verbatim, closes the wake and the episode. + FM_STATE_OVERRIDE="$state" "$DRAIN" --ack-through "${remedy%%$'\t'*}" --recovery-generation "${remedy##*$'\t'}" \ + 2> "$dir/remedy.err" || fail "the printed remedy failed: $(cat "$dir/remedy.err")" + [ ! -s "$state/.wake-queue" ] || fail "the printed remedy left the current wake queued" + ! grep -F 'nothing was acknowledged' "$dir/remedy.err" >/dev/null \ + || fail "a real acknowledgement was reported as acknowledging nothing: $(cat "$dir/remedy.err")" + case "$(cat "$state/.watcher-down")" in + acked:*) ;; + *) fail "the printed remedy did not retire the recovery episode" ;; + esac + pass "wake drain: an acknowledgement that consumes nothing says so and names the exact command for the current wake" +} + +test_branch_stale_ack_that_consumes_nothing_names_its_granted_wake() { + local dir state first_seq first_gen second_seq second_gen remedy + dir=$(make_case branch-stale-ack-current-wake) + state="$dir/state" + append_wake "$state" signal "task-a.status" "signal: task-a first" || fail "first signal append failed" + FM_STATE_OVERRIDE="$state" "$GRANT" activate "$$" branch-stale || fail "branch owner activation failed" + FM_STATE_OVERRIDE="$state" "$GRANT" publish branch-stale 1 || fail "first grant publication failed" + FM_STATE_OVERRIDE="$state" FM_SUPERVISION_ACTOR=branch "$DRAIN" > "$dir/first.out" 2> "$dir/first.err" \ + || fail "first branch drain failed: $(cat "$dir/first.err")" + first_seq=$(sed -n 's/^WAKE_ACK_REQUIRED:.*--ack-through \([0-9][0-9]*\) --recovery-generation .*/\1/p' "$dir/first.err") + first_gen=$(sed -n 's/^WAKE_ACK_REQUIRED:.*--recovery-generation \([A-Za-z0-9._-][A-Za-z0-9._-]*\)$/\1/p' "$dir/first.err") + FM_STATE_OVERRIDE="$state" FM_SUPERVISION_ACTOR=branch "$DRAIN" --ack-through "$first_seq" --recovery-generation "$first_gen" \ + || fail "first branch acknowledgement failed" + FM_STATE_OVERRIDE="$state" "$GRANT" release branch-stale || fail "first grant release failed" + + # The next prompt: a stale escalation for another pane, granted on its own. + append_wake "$state" stale "fm-window-b" "stale: fm-window-b (idle 378s, possible wedge)" || fail "stale append failed" + FM_STATE_OVERRIDE="$state" "$GRANT" publish branch-stale 2 || fail "second grant publication failed" + FM_STATE_OVERRIDE="$state" FM_SUPERVISION_ACTOR=branch "$DRAIN" > "$dir/second.out" 2> "$dir/second.err" \ + || fail "second branch drain failed: $(cat "$dir/second.err")" + second_seq=$(sed -n 's/^WAKE_ACK_REQUIRED:.*--ack-through \([0-9][0-9]*\) --recovery-generation .*/\1/p' "$dir/second.err") + second_gen=$(sed -n 's/^WAKE_ACK_REQUIRED:.*--recovery-generation \([A-Za-z0-9._-][A-Za-z0-9._-]*\)$/\1/p' "$dir/second.err") + [ "$second_seq" -eq 2 ] || fail "second branch drain did not present the granted stale row: $(cat "$dir/second.out")" + + # The refused-ack loop's first step: the PREVIOUS wake's command. + FM_STATE_OVERRIDE="$state" FM_SUPERVISION_ACTOR=branch "$DRAIN" --ack-through "$first_seq" --recovery-generation "$first_gen" \ + > "$dir/stale.out" 2> "$dir/stale.err" || fail "a stale branch acknowledgement failed instead of degrading safely: $(cat "$dir/stale.err")" + grep -F "nothing was acknowledged through $first_seq" "$dir/stale.err" >/dev/null \ + || fail "the branch's no-op acknowledgement was not reported as acknowledging nothing: $(cat "$dir/stale.err")" + remedy=$(stale_ack_remedy "$dir/stale.err") \ + || fail "the branch's no-op acknowledgement did not print the exact current command: $(cat "$dir/stale.err")" + [ "${remedy%%$'\t'*}" = "$second_seq" ] && [ "${remedy##*$'\t'}" = "$second_gen" ] \ + || fail "the branch remedy differs from its drain's WAKE_ACK_REQUIRED command: $remedy vs $second_seq/$second_gen" + grep "$(printf '\tstale\tfm-window-b\t')" "$state/.wake-queue" >/dev/null \ + || fail "a stale branch acknowledgement consumed the granted wake" + FM_STATE_OVERRIDE="$state" FM_SUPERVISION_ACTOR=branch "$DRAIN" --ack-through "${remedy%%$'\t'*}" --recovery-generation "${remedy##*$'\t'}" \ + || fail "the branch's printed remedy failed" + [ ! -s "$state/.wake-queue" ] || fail "the branch's printed remedy left its wake queued" + FM_STATE_OVERRIDE="$state" "$GRANT" deactivate "$$" branch-stale || fail "branch owner deactivation failed" + pass "wake drain: a branch acknowledgement that consumes nothing names the exact command for its granted wake" +} + test_recovery_ack_failure_is_reported() { local dir state fakebin real_mv rc generation dir=$(make_case recovery-ack-failure) @@ -1467,5 +1571,7 @@ test_branch_actor_without_eligible_snapshot_refuses test_wake_publish_requires_atomic_recovery_evidence test_legacy_generationless_wake_is_adopted test_stale_recovery_generation_cannot_touch_a_newer_episode +test_stale_ack_that_consumes_nothing_names_the_current_wake +test_branch_stale_ack_that_consumes_nothing_names_its_granted_wake test_recovery_ack_failure_is_reported test_interruption_before_and_after_raw_commit From 56761e255b2989018abba12425f586ba25b77348 Mon Sep 17 00:00:00 2001 From: kunchenguid Date: Thu, 3 Sep 2026 15:43:30 -0700 Subject: [PATCH 2/3] fix(bin): narrow the branch report scope and guard silence to the minimal form Apply the four review decisions on the clean-slate branch: - A signal or stale prompt may report only the tasks its own rows resolve to; fleet is refused there too. A heartbeat review is not scoped by task at all, so the extension no longer tracks live task records and refuses nothing by task id during a fleet review. - The outcome-index rebuild no longer skips retired tasks; the append-side skip alone keeps a torn-down task's index from being recreated. - bin/fm-guard.sh keeps the queued-wakes warning silent for the branch actor instead of printing a replacement note. --- .pi/extensions/fm-branch-supervision.ts | 32 +++++++------------- .pi/extensions/lib/fm-branch-dispatch.ts | 18 +++-------- bin/fm-branch-outcome.sh | 12 ++------ bin/fm-branch-prompt.sh | 2 +- bin/fm-guard.sh | 28 ++++------------- docs/pi-supervision-branch.md | 4 +-- tests/fm-guard-stale-banner.test.sh | 15 ++++----- tests/fm-pi-branch-extension.test.sh | 25 ++++++++------- tests/fm-wake-drain-outcome-backstop.test.sh | 22 -------------- 9 files changed, 45 insertions(+), 113 deletions(-) diff --git a/.pi/extensions/fm-branch-supervision.ts b/.pi/extensions/fm-branch-supervision.ts index 8dda46003c3..5da28991977 100644 --- a/.pi/extensions/fm-branch-supervision.ts +++ b/.pi/extensions/fm-branch-supervision.ts @@ -513,15 +513,13 @@ export default function (pi: ExtensionAPI) { // wake rows without relying on provider text or incidental session shape. let durableReportRevision = 0; // The task set the wake being handled right now may be reported on, fixed - // deterministically from the eligible rows before the prompt opens and - // cleared when it settles: a signal or stale wake names exactly the tasks - // its rows resolve to, a heartbeat review may name any task with a live - // record at that moment, and `fleet` is always allowed. fm_branch_report - // refuses every other task id, so a report typed from memory about a task - // the wake never named (or one whose record is already gone) is never - // stored or delivered. Null only outside a wake prompt, where no model turn - // can reach the tool. - let wakeTaskScope: { heartbeat: boolean; rows: string[]; tasks: Set } | null = null; + // deterministically from the eligible rows before a signal or stale prompt + // opens and cleared when it settles: exactly the tasks those rows resolve + // to. fm_branch_report refuses every other task id during such a prompt, + // `fleet` included, so a report typed from memory about a task the wake + // never named is never stored or delivered. Null outside a wake prompt and + // during a heartbeat review, which is not scoped by task. + let wakeTaskScope: { rows: string[]; tasks: Set } | null = null; let mainStreaming = false; let shuttingDown = false; // Bumps at every session replacement so a stale chain continuation from the @@ -932,14 +930,10 @@ export default function (pi: ExtensionAPI) { } function wakeScopeRefusal(task: string): string { - if (!wakeTaskScope || task === "fleet" || wakeTaskScope.tasks.has(task)) return ""; - const allowed = [...wakeTaskScope.tasks].sort(); - const named = allowed.length === 0 ? "no task" : allowed.join(", "); - if (wakeTaskScope.heartbeat) { - return `report refused: ${task} has no live task record, so this fleet review cannot report it; live tasks: ${named} (use fleet for a fleet-wide outcome)`; - } + if (!wakeTaskScope || wakeTaskScope.tasks.has(task)) return ""; + const named = [...wakeTaskScope.tasks].sort().join(", "); const rows = wakeTaskScope.rows.join(", "); - return `report refused: the wake being handled (row ${rows}) names ${named}, not ${task}; report only that task or fleet, never a task from memory`; + return `report refused: the wake being handled (row ${rows}) names ${named}, not ${task}; report only that task, never fleet or a task from memory`; } function createReportTool(toolGeneration: number): ToolDefinition { @@ -1240,11 +1234,7 @@ ${context.command} // the drain; that residual is accepted by the confused-agent-grade boundary. const reportRevisionBeforePrompt = durableReportRevision; const entryOffset = sessionManager.getEntries().length; - wakeTaskScope = { - heartbeat, - rows: [...scope.eligibleSeqs], - tasks: new Set(heartbeat ? scope.liveTasks : scope.eligibleTasks), - }; + wakeTaskScope = heartbeat ? null : { rows: [...scope.eligibleSeqs], tasks: new Set(scope.eligibleTasks) }; try { await session.prompt( `FIRSTMATE SUPERVISION WAKE: ${message}\n\nHandle this per your operating procedure and finish with fm_branch_report.`, diff --git a/.pi/extensions/lib/fm-branch-dispatch.ts b/.pi/extensions/lib/fm-branch-dispatch.ts index 26dc12b934a..8e2a92874a4 100644 --- a/.pi/extensions/lib/fm-branch-dispatch.ts +++ b/.pi/extensions/lib/fm-branch-dispatch.ts @@ -36,17 +36,12 @@ export interface UnreadWakeScope { /** * The exact task ids the eligible signal/stale rows name (a signal row by * its status-log key, a stale row through the task metadata recording that - * endpoint). The branch may report only these tasks, or `fleet`, while it - * handles the wake; a task it merely remembers is refused (docs/ - * pi-supervision-branch.md "Autonomy"). Empty for a heartbeat. + * endpoint). The branch may report only these tasks while it handles the + * wake; `fleet` or a task it merely remembers is refused (docs/ + * pi-supervision-branch.md "Autonomy"). Empty for a heartbeat, which is + * not scoped by task. */ eligibleTasks: string[]; - /** - * Every task with a live state/.meta at scan time. A heartbeat review - * may report any of them; a task with no live record cannot receive an - * outcome. - */ - liveTasks: string[]; /** * True only when this scan itself is untrustworthy: the queue or its * metadata could not be read, a line fails the structural tab-field check, @@ -67,7 +62,6 @@ const EMPTY_SCOPE: UnreadWakeScope = { projects: [], eligibleSeqs: [], eligibleTasks: [], - liveTasks: [], corrupted: false, }; const UNSAFE_SCOPE: UnreadWakeScope = { @@ -76,7 +70,6 @@ const UNSAFE_SCOPE: UnreadWakeScope = { projects: [], eligibleSeqs: [], eligibleTasks: [], - liveTasks: [], corrupted: true, }; @@ -125,7 +118,6 @@ export function scopeForUnreadWake(state: string, heartbeat: boolean): UnreadWak // The task id behind each key a signal or stale row may carry: the task id // itself, or the endpoint its metadata records. const taskByKey = new Map(); - const liveTasks: string[] = []; try { for (const name of readdirSync(state)) { if (!name.endsWith(".meta")) continue; @@ -133,7 +125,6 @@ export function scopeForUnreadWake(state: string, heartbeat: boolean): UnreadWak const fields = readFileSync(`${state}/${name}`, "utf8").split(/\r?\n/); const project = fields.find((line) => line.startsWith("project="))?.slice(8) ?? ""; const window = fields.find((line) => line.startsWith("window="))?.slice(7) ?? ""; - liveTasks.push(task); if (project) { metadata.set(task, project); taskByKey.set(task, task); @@ -197,7 +188,6 @@ export function scopeForUnreadWake(state: string, heartbeat: boolean): UnreadWak projects: [...projects], eligibleSeqs, eligibleTasks: [...eligibleTasks], - liveTasks, corrupted: false, }; } diff --git a/bin/fm-branch-outcome.sh b/bin/fm-branch-outcome.sh index 100cc5b2401..491be2a7c6e 100755 --- a/bin/fm-branch-outcome.sh +++ b/bin/fm-branch-outcome.sh @@ -45,10 +45,9 @@ # rebuilds every cache before publishing it, so interruption or upgrade # fails closed without making each drain scan lifetime history. # bin/fm-teardown.sh removes a retired task's cache with its other records, -# and both append and the rebuild skip the cache for a task that has -# neither a live meta nor a status log (the outcome itself is still -# stored), so the branch's report of a teardown it just performed leaves -# no index behind. +# and append skips the cache for a task that has neither a live meta nor a +# status log (the outcome itself is still stored), so the branch's report +# of a teardown it just performed leaves no index behind. # Main-actor drain calls processed-init under the outcome lock when that # ready marker is absent or invalid, on every harness; only a genuine store # fault keeps the lost-wake backstop skipped. @@ -269,11 +268,6 @@ rebuild_outcome_indexes() { ' "$STORE") || return 1 while IFS=$(printf '\t') read -r task seq epoch endpoint ident; do [ -n "$task" ] || continue - # A retired task (no live record, no status log) has no reader for its - # cache: the drain backstop compares an index against that task's status - # log, which is gone with the task. Rebuilding it would only resurrect the - # footprint teardown just removed. - [ -e "$STATE/$task.meta" ] || [ -e "$STATE/$task.status" ] || continue if [ -z "$endpoint" ] || [ -z "$ident" ]; then f="$STATE/$task.status" endpoint=0 diff --git a/bin/fm-branch-prompt.sh b/bin/fm-branch-prompt.sh index 4673503641a..c426f7ec8d2 100755 --- a/bin/fm-branch-prompt.sh +++ b/bin/fm-branch-prompt.sh @@ -100,7 +100,7 @@ Stay terse: your context is a cost. Do not re-read files the drain just printed. Never use shell background operators for supervision; the watcher and extension own continuity. Never call fm_branch_report speculatively - only after the event is actually handled or a refusal/lease conflict genuinely ended your handling. -The tool refuses a task the wake being handled did not name (a heartbeat names every task with a live record); a refusal means you reached for a task from memory, so report the wake's own task or fleet, never retry with another id. +The tool refuses a task the wake being handled did not name, fleet included (a heartbeat review is not scoped by task); a refusal means you reached for a task from memory, so report the wake's own task, never retry with another id. An acknowledgement that consumed nothing says so and names the exact command for the current wake; run that printed command, do not drain again. # Recovery playbook (verbatim copy of the tracked skill) diff --git a/bin/fm-guard.sh b/bin/fm-guard.sh index ec799788a33..0b2a34a824e 100755 --- a/bin/fm-guard.sh +++ b/bin/fm-guard.sh @@ -27,11 +27,10 @@ # bounded). Independent alarms (queued wakes, worktree tangle) are never # suppressed by that dedup. Normal wake handling (watcher briefly down between a # wake and the next supervision resume) stays inside the grace window and stays -# silent. The queued-wakes warning is replaced, for the supervision branch -# actor (FM_SUPERVISION_ACTOR=branch), by a note naming the rows its grant -# covers, because that actor runs guarded commands while handling exactly those -# rows and can drain nothing else. Always exits 0: the guard warns, it never -# blocks. +# silent. The queued-wakes warning stays silent for the supervision branch +# actor (FM_SUPERVISION_ACTOR=branch), because that actor runs guarded commands +# while handling exactly the queued rows its grant covers and can drain nothing +# else. Always exits 0: the guard warns, it never blocks. set -u SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" @@ -63,16 +62,6 @@ STALE_BANNER_MARKER="$STATE/.guard-watcher-stale-banner" # malformed value is a wiring bug elsewhere, so the guard just warns as main. GUARD_ACTOR=$(fm_lease_actor 2>/dev/null) || GUARD_ACTOR=main -# The sequence numbers currently granted to the branch, comma-joined, from the -# same snapshot bin/fm-wake-drain.sh consumes; empty when there is no valid -# grant. -fm_guard_branch_granted_rows() { # - local rows="$1/.branch-eligible-rows" - [ -f "$rows" ] && [ ! -L "$rows" ] && [ -s "$rows" ] || return 1 - awk 'BEGIN { ok=1 } !/^[0-9]+$/ || seen[$0]++ { ok=0 } END { exit !ok }' "$rows" || return 1 - awk 'NR > 1 { printf ", " } { printf "%s", $0 } END { printf "\n" }' "$rows" -} - # Deterministic episode key from the qualitative down-state (the failing # condition), NOT the beacon mtime: under the auto-arm model a healthy # between-turns watcher advances that mtime every poll, which made the "same @@ -257,16 +246,11 @@ fi # "drain them before anything else" mid-handling reads as "an earlier wake is # still pending", which is what made it re-run a previous acknowledgement in a # loop. The branch can act on nothing outside its grant anyway, so for that -# actor the guard names the granted rows it is handling and says nothing about -# any other queued row. +# actor the guard stays silent about queued rows. if "$queue_pending"; then if [ "$READ_ONLY" -eq 1 ]; then echo "WARNING: queued wakes pending - left untouched because this session lacks verified fleet-lock ownership." >&2 - elif [ "$GUARD_ACTOR" = branch ]; then - branch_rows=$(fm_guard_branch_granted_rows "$STATE") || branch_rows= - [ -z "$branch_rows" ] \ - || echo "NOTE: you are handling wake row $branch_rows - finish with fm_branch_report, then run the exact WAKE_ACK_REQUIRED command your drain printed." >&2 - else + elif [ "$GUARD_ACTOR" != branch ]; then echo "WARNING: queued wakes pending - drain them with bin/fm-wake-drain.sh before anything else." >&2 fi fi diff --git a/docs/pi-supervision-branch.md b/docs/pi-supervision-branch.md index d6cec011075..919cbe3513c 100644 --- a/docs/pi-supervision-branch.md +++ b/docs/pi-supervision-branch.md @@ -34,8 +34,8 @@ The supervision branch itself is Pi-only by construction: It checks the current extension generation and `state/.lock` ownership before each guarded branch side effect so replacement or lock loss cannot let an old continuation mutate the new session. Every accepted path that cannot reach a working branch rejects its settlement to the watcher, which retains delivery ownership and routes the wake to main as a follow-up that counts as delivered once Pi accepts it; a broken branch declines later offers so they take that path directly. After wake rows are claimed, a branch prompt counts as handled only when `fm_branch_report` appends a durable outcome before that prompt settles; a settled provider error or a settled prompt with no report releases the grant and rejects delivery ownership back to the watcher. - While a prompt is open, `fm_branch_report` accepts only `fleet` and the tasks that prompt's claimed rows resolve to (a signal row by its status-log key, a stale row through the task record naming that endpoint), or for a heartbeat review any task with a live record at prompt time; a report for any other task id is refused before the store is touched, so a task remembered from an earlier wake, or one whose records teardown already removed, cannot become a delivered outcome. - The branch's guarded commands never tell it to drain queued rows mid-handling: for that actor `bin/fm-guard.sh` names the granted rows it is handling instead, and an acknowledgement that consumed nothing reports that plainly with the exact command for the current wake (`docs/watcher-continuity.md` "Per-actor acknowledgement"). + While a signal or stale prompt is open, `fm_branch_report` accepts only the tasks that prompt's claimed rows resolve to (a signal row by its status-log key, a stale row through the task record naming that endpoint); a report for any other task id, `fleet` included, is refused before the store is touched, so a task remembered from an earlier wake cannot become a delivered outcome, while a heartbeat review is not scoped by task. + The branch's guarded commands never tell it to drain queued rows mid-handling: for that actor `bin/fm-guard.sh` keeps the queued-wakes warning silent, and an acknowledgement that consumed nothing reports that plainly with the exact command for the current wake (`docs/watcher-continuity.md` "Per-actor acknowledgement"). Two consecutive settled provider errors latch the branch broken and surface a one-line health note only on that initial trip. Main keeps every wake during a five-minute cooldown, after which one wake may probe the branch while concurrent wakes still stay on main; each probe that settles with another provider error doubles the next cooldown up to one hour. A prompt from the current branch generation and model or effort selection that appends a durable `fm_branch_report` and then settles without a provider error clears both the latch and provider-error streak and surfaces a one-line recovery note; a provider error settled after that report wins instead, re-latches the branch, and extends the cooldown. diff --git a/tests/fm-guard-stale-banner.test.sh b/tests/fm-guard-stale-banner.test.sh index c9748e4f3da..10c901e9b7c 100755 --- a/tests/fm-guard-stale-banner.test.sh +++ b/tests/fm-guard-stale-banner.test.sh @@ -628,9 +628,8 @@ test_extension_handoff_keeps_queued_wake_warning() { # The supervision branch runs guarded commands (fm-peek, fm-crew-state) while # handling the very rows that are queued. For that actor the drain warning is # not advice, it is the misreading that re-ran a previous acknowledgement in a -# loop, so the guard names the granted rows instead and says nothing about any -# other queued row. -test_branch_actor_sees_its_granted_rows_instead_of_the_drain_warning() { +# loop, so the guard stays silent about queued rows for that actor. +test_branch_actor_is_not_told_to_drain_queued_wakes() { local dir home out pid dir=$(make_guard_case branch-actor-queued-wake) home=$(case_home "$dir") @@ -643,22 +642,20 @@ test_branch_actor_sees_its_granted_rows_instead_of_the_drain_warning() { "1700000001 8 check merge-poll check: merge-poll: merged" > "$home/state/.wake-queue" printf '7\n' > "$home/state/.branch-eligible-rows" out=$(run_guard_case_extension_as_branch "$dir") - assert_contains "$out" "you are handling wake row 7" \ - "the branch actor must be told which granted row it is handling" assert_not_contains "$out" "queued wakes pending" \ "the branch actor must not be told to drain the rows it is already handling" + assert_not_contains "$out" "wake row" \ + "the branch actor gets no replacement note about queued rows" rm -f "$home/state/.branch-eligible-rows" out=$(run_guard_case_extension_as_branch "$dir") assert_not_contains "$out" "queued wakes pending" \ "a branch actor with no grant can drain nothing, so the warning must stay silent" - assert_not_contains "$out" "you are handling" \ - "a branch actor with no grant is handling nothing" out=$(run_guard_case_extension "$dir") kill "$pid" 2>/dev/null || true wait "$pid" 2>/dev/null || true assert_contains "$out" "queued wakes pending" \ "main must still be warned about the queued rows" - pass "fm-guard: the branch actor is told which granted row it is handling, never to drain first" + pass "fm-guard: the branch actor is never told to drain queued wakes while main still is" } test_persistent_model_ignores_pi_extension_evidence() { @@ -738,7 +735,7 @@ test_extension_without_ownership_evidence_stays_alarm test_extension_ownership_needs_every_signal test_extension_stale_beacon_alarms_despite_live_session test_extension_handoff_keeps_queued_wake_warning -test_branch_actor_sees_its_granted_rows_instead_of_the_drain_warning +test_branch_actor_is_not_told_to_drain_queued_wakes test_persistent_model_ignores_pi_extension_evidence test_extension_live_watcher_is_healthy_without_ownership_evidence test_autoarm_fresh_beacon_without_watcher_is_healthy diff --git a/tests/fm-pi-branch-extension.test.sh b/tests/fm-pi-branch-extension.test.sh index ec064792fba..93d1429f5ba 100644 --- a/tests/fm-pi-branch-extension.test.sh +++ b/tests/fm-pi-branch-extension.test.sh @@ -1653,14 +1653,16 @@ if (!ghost.isError || !ghost.content[0].text.includes("names branch-driver, not const gone = await report.execute("gone", { task: "retired-task", verdict: "captain", summary: "PR ready to merge" }, undefined, undefined, {}); if (!gone.isError) throw new Error(`a report for a task with no record was not refused: ${JSON.stringify(gone)}`); const fleet = await report.execute("fleet", { task: "fleet", verdict: "routine", summary: "fleet-wide note" }, undefined, undefined, {}); -if (fleet.isError) throw new Error(`a fleet report was refused during a task-local wake: ${JSON.stringify(fleet)}`); +if (!fleet.isError || !fleet.content[0].text.includes("never fleet")) { + throw new Error(`a fleet-wide report was not refused during a task-local wake: ${JSON.stringify(fleet)}`); +} const named = await report.execute("named", { task: "branch-driver", verdict: "routine", summary: "worker healthy" }, undefined, undefined, {}); if (named.isError) throw new Error(`the wake's own task was refused: ${JSON.stringify(named)}`); finish(); await settle(() => !existsSync(`${home}/state/.branch-eligible-rows`), "task-local grant release"); -// A heartbeat review may report any task with a live record, never a task -// with none. +// A heartbeat review is not scoped by task: it may report any task id, a +// task whose records are already gone, and fleet. globalThis.__fmOnBranchPrompt = () => new Promise((resolve) => { finish = resolve; }); if (!dispatch("heartbeat", [], true, true).accepted) throw new Error("branch refused the heartbeat"); await settle(() => (globalThis.__fmPrompts ?? []).length === 2, "heartbeat branch prompt"); @@ -1668,20 +1670,20 @@ const heartbeatSession = globalThis.__fmSessions[globalThis.__fmSessions.length const heartbeatReport = heartbeatSession.options.customTools.find((tool) => tool.name === "fm_branch_report"); const live = await heartbeatReport.execute("live", { task: "other-task", verdict: "routine", summary: "worker healthy" }, undefined, undefined, {}); if (live.isError) throw new Error(`a heartbeat report for a live task was refused: ${JSON.stringify(live)}`); -const goneInReview = await heartbeatReport.execute("gone-in-review", { task: "retired-task", verdict: "captain", summary: "PR ready to merge" }, undefined, undefined, {}); -if (!goneInReview.isError || !goneInReview.content[0].text.includes("no live task record")) { - throw new Error(`a heartbeat report for a task with no record was not refused: ${JSON.stringify(goneInReview)}`); -} +const goneInReview = await heartbeatReport.execute("gone-in-review", { task: "retired-task", verdict: "captain", summary: "PR merged and cleaned up" }, undefined, undefined, {}); +if (goneInReview.isError) throw new Error(`a heartbeat report for a task with no record was refused: ${JSON.stringify(goneInReview)}`); +const fleetInReview = await heartbeatReport.execute("fleet-in-review", { task: "fleet", verdict: "routine", summary: "fleet-wide note" }, undefined, undefined, {}); +if (fleetInReview.isError) throw new Error(`a fleet-wide report was refused during a heartbeat review: ${JSON.stringify(fleetInReview)}`); finish(); const stored = readFileSync(`${home}/state/branch-outcomes.jsonl`, "utf8").trim().split("\n").map((line) => JSON.parse(line).task); -if (JSON.stringify(stored) !== JSON.stringify(["fleet", "branch-driver", "other-task"])) { +if (JSON.stringify(stored) !== JSON.stringify(["branch-driver", "other-task", "retired-task", "fleet"])) { throw new Error(`refused reports reached the durable store: ${JSON.stringify(stored)}`); } // The classification owner names the tasks behind each eligible row: a // signal row by its status-log key, a stale row by the endpoint a task's -// metadata records, with every live record listed for a heartbeat review. +// metadata records. const lib = await import(pathToFileURL(`${dirname(process.env.PLUGIN)}/lib/fm-branch-dispatch.ts`).href); writeFileSync(`${home}/state/.wake-queue`, [ "1\t1\tsignal\tbranch-driver.status\tsignal: done", @@ -1692,9 +1694,6 @@ const scope = lib.scopeForUnreadWake(`${home}/state`, false); if (JSON.stringify([...scope.eligibleTasks].sort()) !== JSON.stringify(["branch-driver", "other-task"])) { throw new Error(`eligible rows resolved to the wrong tasks: ${JSON.stringify(scope)}`); } -if (JSON.stringify([...scope.liveTasks].sort()) !== JSON.stringify(["branch-driver", "other-task"])) { - throw new Error(`live tasks were not listed from the task records: ${JSON.stringify(scope)}`); -} if (JSON.stringify(scope.eligibleSeqs) !== JSON.stringify(["1", "2"])) { throw new Error(`the main-owned check row leaked into the branch claim: ${JSON.stringify(scope)}`); } @@ -1703,7 +1702,7 @@ EOF status=$? out=$(cat "$TMP_ROOT/node-output") expect_code 0 "$status" "the report tool must refuse tasks the wake never named: $out" - pass "fm_branch_report refuses a task the wake did not name and a task with no live record" + pass "fm_branch_report refuses a task the wake did not name, fleet included, while a heartbeat is unscoped" } # The non-heartbeat half of the same recheck: a check-kind row that arrives diff --git a/tests/fm-wake-drain-outcome-backstop.test.sh b/tests/fm-wake-drain-outcome-backstop.test.sh index e14a3a8a4cd..9a2a94b0c6a 100755 --- a/tests/fm-wake-drain-outcome-backstop.test.sh +++ b/tests/fm-wake-drain-outcome-backstop.test.sh @@ -328,27 +328,6 @@ test_missing_index_self_heals_on_first_drain() { pass "a missing outcome index self-heals on the first drain and suppresses its handled status" } -# The rebuild recreates the caches from lifetime history; a task teardown -# already removed (no record, no status log) must not get its footprint back. -test_index_rebuild_skips_a_retired_task() { - local dir state - dir=$(make_case index-rebuild-retired) - state="$dir/state" - printf 'working: still going\n' > "$state/live.status" - printf '%s\n' \ - '{"seq":1,"epoch":1700000000,"task":"retired","wake":"","verdict":"captain","summary":"merged long ago","silent":false,"statusEndpoint":0,"statusIdent":"-"}' \ - '{"seq":2,"epoch":1700000001,"task":"live","wake":"","verdict":"routine","summary":"healthy","silent":false,"statusEndpoint":0,"statusIdent":"-"}' \ - > "$state/branch-outcomes.jsonl" - printf '2\n' > "$state/.branch-outcomes-cursor" - FM_STATE_OVERRIDE="$state" "$OUTCOMES" processed-init > "$dir/init.out" 2>&1 \ - || fail "processed-init failed: $(cat "$dir/init.out")" - [ -f "$state/.branch-outcome-index-ready" ] || fail "rebuild did not publish the ready marker" - [ -f "$state/.live.branch-outcome-index" ] || fail "rebuild skipped a task that still has a status log" - [ ! -e "$state/.retired.branch-outcome-index" ] \ - || fail "rebuild resurrected the outcome index of a task with no record and no status log" - pass "outcome index rebuild leaves a retired task without an index" -} - test_uncovered_event_surfaces_on_first_drain_without_index() { local dir state out body dir=$(make_case index-selfheal-uncovered) @@ -537,7 +516,6 @@ test_output_failure_does_not_commit_the_backstop_receipt test_receipt_commit_failure_repeats_the_already_presented_backstop test_rejected_decision_line_surfaces_once_through_backstop test_missing_index_self_heals_on_first_drain -test_index_rebuild_skips_a_retired_task test_uncovered_event_surfaces_on_first_drain_without_index test_malformed_outcome_store_fails_closed_without_pi_advice test_held_lock_mode_rejects_an_unlocked_caller From cb1b41883d6073b83e11c02a275efc984f82b367 Mon Sep 17 00:00:00 2001 From: kunchenguid Date: Thu, 3 Sep 2026 16:30:33 -0700 Subject: [PATCH 3/3] no-mistakes(document): Align supervision docs with scoped wake handling --- .pi/extensions/lib/fm-branch-dispatch.ts | 4 ++-- AGENTS.md | 2 +- docs/architecture.md | 5 +++-- docs/pi-supervision-branch.md | 3 ++- docs/scripts.md | 2 +- docs/watcher-continuity.md | 2 +- tests/fm-pi-branch-extension.test.sh | 8 ++++---- 7 files changed, 14 insertions(+), 12 deletions(-) diff --git a/.pi/extensions/lib/fm-branch-dispatch.ts b/.pi/extensions/lib/fm-branch-dispatch.ts index 8e2a92874a4..f56adba9030 100644 --- a/.pi/extensions/lib/fm-branch-dispatch.ts +++ b/.pi/extensions/lib/fm-branch-dispatch.ts @@ -38,8 +38,8 @@ export interface UnreadWakeScope { * its status-log key, a stale row through the task metadata recording that * endpoint). The branch may report only these tasks while it handles the * wake; `fleet` or a task it merely remembers is refused (docs/ - * pi-supervision-branch.md "Autonomy"). Empty for a heartbeat, which is - * not scoped by task. + * pi-supervision-branch.md "Components and their owners"). Empty for a + * heartbeat, which is not scoped by task. */ eligibleTasks: string[]; /** diff --git a/AGENTS.md b/AGENTS.md index 646c607ac7f..149894ad849 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -109,7 +109,7 @@ state/ runtime records and signals; gitignored .pr-poll-registration private transactional provenance record binding the task, canonical metadata identity, sidecar, and static poll publication .pr-poll-retirement private identity-bound crash-recovery receipt for one exact validated merged result; removed after its poll artifacts retire .pr-poll-merge-notified canonical PR identity of the last merge outcome delivered for this task; bin/fm-pr-lib.sh owns the marker format and identity mechanics, while bin/fm-merge-outcome-lib.sh owns locked publication, duplicate suppression, and replacement - branch-outcomes.jsonl .branch-outcomes-cursor .branch-outcomes-processed ..branch-outcome-index .branch-outcome-index-ready Pi supervision-branch durable outcome store, its read cursor, main's processed marker, bounded latest per-task status-coverage caches (each retired with its task by teardown), and their recovery marker; bin/fm-branch-outcome.sh owns the formats + branch-outcomes.jsonl .branch-outcomes-cursor .branch-outcomes-processed ..branch-outcome-index .branch-outcome-index-ready Pi supervision-branch durable outcome store, its read cursor, main's processed marker, bounded latest per-task status-coverage caches, and their recovery marker; bin/fm-branch-outcome.sh owns the formats branch-session/ .branch-session .branch-mirror-cursor the branch's per-main-session conversations, the pointer to the current one, and the dialog-mirror cursor; extension-owned (docs/pi-supervision-branch.md) .branch-eligible-rows .branch-eligible-owner .main-eligible-rows per-actor wake-row claims and branch-owner evidence; docs/watcher-continuity.md owns the acknowledgement contract .lease- per-task supervision lease naming which actor (main or branch) may change that task; bin/fm-lease-lib.sh owns the contract the guarded scripts enforce diff --git a/docs/architecture.md b/docs/architecture.md index aa7284dc02b..c6cd277f64b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -112,8 +112,9 @@ It suppresses failed-looking closes when the same identity-matched watcher is he Cursor's `bin/fm-turnend-guard-cursor.sh` hook is the same between-turns shape in one synchronous step: it parks the awaited `stop` hook on the arm wrapper and translates an actionable close into one `followup_message`, with a generation baton that makes an older park still running after the next `stop` claim stand down instead of leaking a stale duplicate wake. The existing turn-end guard remains the final backstop for every harness-engine protocol, with pi-signed sharing Pi's protocol, the `--claude` mode cooperating with the auto-arm claim, and Cursor's `--cursor` mode rendering a block as one bounded follow-up because its `stop` step cannot be blocked. Its `--restart` mode signals only the watcher recorded in the current home's `state/.watch.lock`, so restarting one home cannot kill sibling secondmate watchers. -A pull-based guard (`bin/fm-guard.sh`) warns through supervision tool output if the primary checkout is tangled, if work, process-event sources, or Relay polling has an unhealthy model-aware supervision verdict, or if queued wakes are waiting to be drained. -The drain script calls that guard after presenting the queue; records remain durable, and may keep the queued-wakes warning visible, until the exact generation-bound acknowledgement printed by the drain succeeds after handling. +A pull-based guard (`bin/fm-guard.sh`) warns through supervision tool output if the primary checkout is tangled or if work, process-event sources, or Relay polling has an unhealthy model-aware supervision verdict; on main it also warns when queued wakes are waiting to be drained. +The drain script calls that guard after presenting the queue; records remain durable until the exact generation-bound acknowledgement printed by the drain succeeds after handling, and main may keep the queued-wakes warning visible until then. +The Pi supervision branch's deliberate queued-wake warning exception is owned by [`pi-supervision-branch.md`](pi-supervision-branch.md#components-and-their-owners). It leads with a prominent bordered tangle banner, while `bin/fm-guard.sh` owns the watcher-down banner and reminder policy so repeated guarded commands stay noisy without reprinting the full banner in the same episode. On every verified primary harness, tracked hook integration gives the primary session a push-based backstop: when work, a process-event source, or Relay polling needs supervision and no supervision owner provably holds this home with a fresh beacon, blocking-capable Stop hooks block and nonblocking turn-end integrations force one bounded follow-up. The guard covers the main primary and genuinely marked secondmate homes, exempts child crewmate/scout worktrees, is loop-safe per harness, and is documented in [turnend-guard.md](turnend-guard.md). diff --git a/docs/pi-supervision-branch.md b/docs/pi-supervision-branch.md index 919cbe3513c..efd0659d861 100644 --- a/docs/pi-supervision-branch.md +++ b/docs/pi-supervision-branch.md @@ -129,9 +129,10 @@ What is new is only the attended path: outside away mode, the branch absorbs the ## Verification -Portable regressions: `tests/fm-pi-branch-extension.test.sh` covers dispatch, the new branch conversation at every main session start with continuation inside one session, the mirror re-anchor that pairs with it, requested-versus-unsolicited delivery, exact visible entry content, no unkeyed model turn, the sequence-keyed processing request and its acknowledgement, re-presentation after an empty reply and after an unrelated prior answer, the triggered-then-next-turn pacing, session-start re-presentation, routine outcomes staying turn-free, the processed-marker migration, idle and busy main state, incident-shaped compaction and unrelated-assistant context, cold-start post-lock recovery, crash-before-cursor reload recovery, repeated-reload idempotency, mirroring, post-construction provider-error and no-report fallback, the consecutive-error latch, cooldown probe, exponential backoff, report-plus-settlement recovery, report-before-error re-latch, cache key, and model and effort selection. +Portable regressions: `tests/fm-pi-branch-extension.test.sh` covers dispatch, signal and stale report scoping with unscoped heartbeat reports, the new branch conversation at every main session start with continuation inside one session, the mirror re-anchor that pairs with it, requested-versus-unsolicited delivery, exact visible entry content, no unkeyed model turn, the sequence-keyed processing request and its acknowledgement, re-presentation after an empty reply and after an unrelated prior answer, the triggered-then-next-turn pacing, session-start re-presentation, routine outcomes staying turn-free, the processed-marker migration, idle and busy main state, incident-shaped compaction and unrelated-assistant context, cold-start post-lock recovery, crash-before-cursor reload recovery, repeated-reload idempotency, mirroring, post-construction provider-error and no-report fallback, the consecutive-error latch, cooldown probe, exponential backoff, report-plus-settlement recovery, report-before-error re-latch, cache key, and model and effort selection. `tests/fm-branch-supervision.test.sh` covers prompt stability, store append-only behavior, the captain cursor barrier, the processed marker's sequence bounds, leases, guards, and non-branch-home invariance. `tests/fm-wake-drain-outcome-backstop.test.sh` covers keyless resurfacing, causal suppression, same-second ordering, one-shot presentation, first-drain index self-healing under the outcome lock, store-fault fail-closed behavior, bounded history cost and output, and the oversized-line limit. +`tests/fm-teardown.test.sh` covers removal of the retired task's outcome index and the append-side rule that a post-teardown report does not recreate it. The branch-offer, heartbeat-offer, heartbeat-not-ridden-by-a-check, and main-only-check-class tests remain in `tests/fm-pi-watch-extension.test.sh`, the recovery test remains in `tests/fm-session-start.test.sh`, and the per-actor consume regression remains in `tests/fm-wake-queue.test.sh`. Live guard: `FM_PI_BRANCH_LIVE_E2E=1 tests/fm-pi-branch-live-e2e.test.sh` exercises the real installed Pi SDK's immediate active-transcript appendEntry rendering, persistence, custom-entry model exclusion, branch-session surfaces, and watcher-owned fallback after rejected branch settlement. Record dated current results in [docs/verification/runtime-backends.md](verification/runtime-backends.md). diff --git a/docs/scripts.md b/docs/scripts.md index 9b33fced128..a1d66fb7b95 100644 --- a/docs/scripts.md +++ b/docs/scripts.md @@ -41,7 +41,7 @@ The shared no-mistakes gate refusal for fleet lifecycle entrypoints is summarize | `fm-test-run.sh` | Behavior-test runner: selection, portable lanes, bounded concurrency, budgets, coverage guard, timing/JSON | | `fm-test-isolation-proof.sh` | Concurrent isolation harness and portable candidate set owner | | `fm-ensure-agents-md.sh` | Ensure a project's real `AGENTS.md`, its `CLAUDE.md` `@AGENTS.md` pointer, and the canonical self-governance section | -| `fm-guard.sh` | Warn on primary-checkout tangles, pending queued wakes, and unhealthy supervision | +| `fm-guard.sh` | Warn on primary-checkout tangles, main-session pending wakes, and unhealthy supervision | | `fm-primary-scope-lib.sh` | Shared marker-or-plain-checkout primary-home predicate for tracked hooks | | `fm-session-lock-lib.sh` | Shared session-lock harness identity (ancestry walk and holder liveness) for fm-lock.sh and the Claude Stop auto-arm | | `fm-claude-stop-autoarm.sh` | Claude Stop `asyncRewake` hook owning tokenless watcher continuity with single-flight exit-2 rewake (docs/watcher-continuity.md) | diff --git a/docs/watcher-continuity.md b/docs/watcher-continuity.md index c4b746dac52..eb551b390fe 100644 --- a/docs/watcher-continuity.md +++ b/docs/watcher-continuity.md @@ -80,7 +80,7 @@ A check-kind row is main-owned in every mode, including a heartbeat review, so i `fm-wake-drain.sh` never reclassifies a row itself: it filters the queue to the current actor's opaque claim before same-key deduplication, then presents and acknowledges only that actor-local view. A missing or empty branch snapshot is refused loudly rather than read as "nothing eligible", because reaching the drain without the non-empty handoff promised by the extension is a wiring bug. Because branch claims contain no check-kind rows, a branch acknowledgement skips check-specific receipt scans. -`tests/fm-wake-queue.test.sh`'s mixed-queue actor and presentation-deadline tests drive the real scripts: branch acknowledgement cannot swallow a main row, a concurrent main turn cannot present or acknowledge an active branch grant, live-holder presentation contention stays bounded and retriable, and acknowledgement locking remains blocking. +`tests/fm-wake-queue.test.sh`'s mixed-queue actor, stale-acknowledgement remedy, and presentation-deadline tests drive the real scripts: branch acknowledgement cannot swallow a main row, a concurrent main turn cannot present or acknowledge an active branch grant, a no-op stale acknowledgement names the current presented wake's exact command, live-holder presentation contention stays bounded and retriable, and acknowledgement locking remains blocking. `tests/fm-pi-branch-extension.test.sh` pins extension-side classification, claim publication and release, and the pre-drain recheck. ## Arm-layer cycle contract diff --git a/tests/fm-pi-branch-extension.test.sh b/tests/fm-pi-branch-extension.test.sh index 93d1429f5ba..b8a79834362 100644 --- a/tests/fm-pi-branch-extension.test.sh +++ b/tests/fm-pi-branch-extension.test.sh @@ -1616,10 +1616,10 @@ EOF # The report tool refuses a task the wake being handled never named: the # refused-ack loop's ghost reports were typed from memory about a task whose # records teardown had already removed, while the prompt was a stale row for -# another pane. A signal or stale wake may report only the task its rows -# resolve to (or fleet); a heartbeat review may report any task with a live -# record. The wake's own task and fleet still go through, and nothing refused -# ever reaches the durable store. +# another pane. A signal or stale wake may report only the tasks its rows +# resolve to, with fleet refused too; a heartbeat review is unscoped and +# refuses nothing by task id. The wake's own task still goes through, and +# nothing refused ever reaches the durable store. test_branch_report_refuses_a_task_the_wake_did_not_name() { local repo home out status repo="$TMP_ROOT/ghost-report-root"