From 54b179f11cae1e83597d366b7cc7b34a059b2caa Mon Sep 17 00:00:00 2001 From: kunchenguid Date: Fri, 4 Sep 2026 18:15:30 -0700 Subject: [PATCH 01/17] fix(pi): route needs-decision wakes and mixed batches wholly to main Skip the supervision branch for every needs-decision status append, the same way a check-kind wake already skips it. A coalesced signal/stale trigger batch containing any needs-decision row is delivered wholly to main, not split between the branch and a later main wake - the whole batch, including any co-present routine rows for a different task, travels together. Heartbeat and unread-status scans stay independent. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_013LB2CeerSMCZN4oNsVfLeE --- .pi/extensions/fm-primary-pi-watch.ts | 15 +++- .pi/extensions/lib/fm-branch-dispatch.ts | 30 +++++++ bin/fm-classify-lib.sh | 28 +++++-- bin/fm-watch.sh | 34 ++++++-- docs/pi-supervision-branch.md | 14 +++- docs/supervision-protocols/pi.md | 1 + tests/fm-pi-branch-extension.test.sh | 99 ++++++++++++++++++++++++ tests/fm-watch-triage.test.sh | 61 +++++++++++++++ 8 files changed, 267 insertions(+), 15 deletions(-) diff --git a/.pi/extensions/fm-primary-pi-watch.ts b/.pi/extensions/fm-primary-pi-watch.ts index 31d08615f6d..60344a8d46f 100644 --- a/.pi/extensions/fm-primary-pi-watch.ts +++ b/.pi/extensions/fm-primary-pi-watch.ts @@ -606,7 +606,20 @@ export default function (pi: ExtensionAPI) { // also let a check-kind trigger itself slip past main's delivery. const isCheckTrigger = /^check:/.test(message); const scope = scopeForUnreadWake(state, heartbeat); - const eligible = !isCheckTrigger && scope.eligible; + // A signal close containing a needs-decision status file gets the identical + // main-only treatment as a check-kind trigger, without extending that + // classification to heartbeat scans (docs/pi-supervision-branch.md + // "Autonomy"). The message keeps the ordinary "signal:" shape, so + // compare this cycle's status-file basenames with the needs-decision keys. + const signalKeys = /^signal:/.test(message) + ? message + .slice("signal:".length) + .split(/\s+/) + .filter(Boolean) + .map((path) => path.split("/").pop() ?? path) + : []; + const isNeedsDecisionTrigger = signalKeys.some((key) => scope.needsDecisionKeys.includes(key)); + const eligible = !isCheckTrigger && !isNeedsDecisionTrigger && scope.eligible; const offer = createBranchDispatchOffer(message, scope.projects, heartbeat, eligible); pi.events?.emit?.(FM_BRANCH_DISPATCH_EVENT, offer); return offer.accepted ? offer.settlement : null; diff --git a/.pi/extensions/lib/fm-branch-dispatch.ts b/.pi/extensions/lib/fm-branch-dispatch.ts index dbbf58bb905..8b5d41b29be 100644 --- a/.pi/extensions/lib/fm-branch-dispatch.ts +++ b/.pi/extensions/lib/fm-branch-dispatch.ts @@ -54,6 +54,15 @@ export interface UnreadWakeScope { * either mode. */ corrupted: boolean; + /** + * The exact "key" field (status-file basename) of every signal row this scan + * excluded because its payload is "needs-decision:"-prefixed + * (bin/fm-watch.sh's signal_files_actionable). fm-primary-pi-watch.ts's + * offerWakeToBranch cross-references this against the current trigger's own + * file list so a needs-decision trigger is forced to main exactly like a + * check-kind trigger, without the wake message text itself ever changing. + */ + needsDecisionKeys: string[]; } const EMPTY_SCOPE: UnreadWakeScope = { @@ -63,6 +72,7 @@ const EMPTY_SCOPE: UnreadWakeScope = { eligibleSeqs: [], eligibleTasks: [], corrupted: false, + needsDecisionKeys: [], }; const UNSAFE_SCOPE: UnreadWakeScope = { status: "unsafe", @@ -71,6 +81,7 @@ const UNSAFE_SCOPE: UnreadWakeScope = { eligibleSeqs: [], eligibleTasks: [], corrupted: true, + needsDecisionKeys: [], }; // scopeForUnreadWake is the single owner of branch-eligibility classification @@ -86,6 +97,14 @@ const UNSAFE_SCOPE: UnreadWakeScope = { // (fm-primary-pi-watch.ts forces every check-kind TRIGGER to main), so nothing // starves by being left behind. // +// A signal-kind row whose payload is "needs-decision:"-prefixed - a task-local +// needs-decision status append surfaced by bin/fm-watch.sh's +// signal_files_actionable - gets the identical treatment: excluded from +// eligibleSeqs, never a scan veto, and forced to main on its own triggering +// close (fm-primary-pi-watch.ts's offerWakeToBranch). This classification is +// intentionally limited to marked signal rows; stale and heartbeat rows keep +// their existing eligibility rules. +// // That applies to a heartbeat review too, and it is the whole point: a // heartbeat used to be deferred to main merely because some unrelated check // row happened to be sitting unread, which put a routine fleet review in the @@ -140,6 +159,7 @@ export function scopeForUnreadWake(state: string, heartbeat: boolean): UnreadWak const eligibleSeqs: string[] = []; const eligibleTasks = new Set(); + const needsDecisionKeys: string[] = []; for (const line of rows) { const fields = line.split("\t"); if (fields.length < 5 || !/^[0-9]+$/.test(fields[1])) return UNSAFE_SCOPE; @@ -159,6 +179,15 @@ export function scopeForUnreadWake(state: string, heartbeat: boolean): UnreadWak let project = ""; let task = ""; if (kind === "signal") { + const payload = fields[4] ?? ""; + if (/^needs-decision:/.test(payload)) { + // Main-owned exactly like a check-kind row above: a needs-decision + // status append surfaced through the actionable signal path is + // excluded from what the branch may claim without vetoing the scan + // (docs/pi-supervision-branch.md "Autonomy"). + needsDecisionKeys.push(key); + continue; + } task = key.replace(/\.(?:status|turn-ended)$/, ""); project = metadata.get(task) ?? ""; } else if (kind === "stale") { @@ -189,6 +218,7 @@ export function scopeForUnreadWake(state: string, heartbeat: boolean): UnreadWak eligibleSeqs, eligibleTasks: [...eligibleTasks], corrupted: false, + needsDecisionKeys, }; } diff --git a/bin/fm-classify-lib.sh b/bin/fm-classify-lib.sh index 506f398cce9..0d4e755f549 100755 --- a/bin/fm-classify-lib.sh +++ b/bin/fm-classify-lib.sh @@ -1615,9 +1615,9 @@ _fm_status_open_decision_origins() { # printf '%s' "$origins" } -status_span_first_actionable_record() { # - local f=$1 start=${2:-0} size ident cur_ident scratch chunk_file full_file prefix_file - local line verb key origins='' folded=0 rc=1 failed=0 prefix_lines=0 line_number=0 live_line='' events='' _line _key +status_span_first_actionable_record() { # [record-var] [needs-decision-var] + local f=$1 start=${2:-0} output_var=${3-} needs_var=${4-} size ident cur_ident scratch chunk_file full_file prefix_file result + local line verb key origins='' folded=0 rc=1 failed=0 prefix_lines=0 line_number=0 live_line='' events='' _line _key _fm_span_needs_decision=0 [ -e "$f" ] || { [ -L "$f" ] && return 2; return 1; } [ -f "$f" ] && [ -r "$f" ] && [ ! -L "$f" ] || return 2 ident=$(_fm_open_decisions_file_ident "$f") || return 2 @@ -1626,7 +1626,16 @@ status_span_first_actionable_record() { # case "$size" in ''|*[!0-9]*) return 2 ;; esac case "$start" in ''|*[!0-9]*) start=0 ;; esac [ "$start" -le "$size" ] || start=0 - [ "$start" -lt "$size" ] || { printf '%s\t%s' "$size" "$ident"; return 1; } + if [ "$start" -ge "$size" ]; then + result="${size}"$'\t'"${ident}" + if [ -n "$output_var" ]; then + printf -v "$output_var" '%s' "$result" + [ -z "$needs_var" ] || printf -v "$needs_var" '%s' 0 + else + printf '%s' "$result" + fi + return 1 + fi scratch=$(_fm_status_span_scratch "$f") || return 2 chunk_file="${scratch}.span"; full_file="${scratch}.full"; prefix_file="${scratch}.prefix" _fm_status_read_span "$f" "$start" "$((size - start))" > "$chunk_file" 2>/dev/null \ @@ -1645,12 +1654,14 @@ status_span_first_actionable_record() { # key=$(_fm_decision_key "$line") || { [ -n "$events" ] && events="${events} ; " events="${events}${line}" + [ "$verb" = needs-decision ] && _fm_span_needs_decision=1 rc=0 continue } _fm_decision_key_transition_allowed "$key" "$(status_line_note "$line")" || { [ -n "$events" ] && events="${events} ; " events="${events}reconciliation-required: ${line}" + [ "$verb" = needs-decision ] && _fm_span_needs_decision=1 rc=0 continue } @@ -1674,6 +1685,7 @@ EOF [ -n "$live_line" ] && [ "$((prefix_lines + line_number))" -eq "$live_line" ] || continue [ -n "$events" ] && events="${events} ; " events="${events}${line}" + [ "$verb" = needs-decision ] && _fm_span_needs_decision=1 rc=0 ;; *) @@ -1685,7 +1697,13 @@ EOF done < "$chunk_file" rm -f "$chunk_file" "$full_file" "$prefix_file" [ "$failed" -eq 0 ] || return 2 - if [ "$rc" -eq 0 ]; then printf '%s\t%s\t%s' "$size" "$ident" "$events"; else printf '%s\t%s' "$size" "$ident"; fi + if [ "$rc" -eq 0 ]; then result="${size}"$'\t'"${ident}"$'\t'"${events}"; else result="${size}"$'\t'"${ident}"; fi + if [ -n "$output_var" ]; then + printf -v "$output_var" '%s' "$result" + [ -z "$needs_var" ] || printf -v "$needs_var" '%s' "$_fm_span_needs_decision" + else + printf '%s' "$result" + fi return "$rc" } diff --git a/bin/fm-watch.sh b/bin/fm-watch.sh index 23042e9c4b7..5c4fd5bcc49 100755 --- a/bin/fm-watch.sh +++ b/bin/fm-watch.sh @@ -1214,15 +1214,22 @@ run_check_capture() { # no verb) are skipped. A 1 here is NOT "benign" on its own: a no-verb signal # still needs the authoritative working proof or the eligible opt-in bare # turn-end pane-churn proof before it is benign. +# Also populates FM_SIGNAL_NEEDS_DECISION_FILES (space-separated status-file +# paths) with exactly the files whose newly classified span carries a +# needs-decision event, so the caller can route those - and only those - signal +# rows as main-only (docs/pi-supervision-branch.md). Stale and heartbeat rows +# retain their existing eligibility rules. signal_files_actionable() { # ... - local f task record rest endpoint ident rc found=1 + local f task record rest endpoint ident needs_decision rc found=1 FM_SIGNAL_SURFACE_ENDPOINTS='' + FM_SIGNAL_NEEDS_DECISION_FILES='' for f in "$@"; do case "$f" in *.status) ;; *) continue ;; esac [ -e "$f" ] || [ -L "$f" ] || continue task=$(basename "$f"); task="${task%.status}" - record=$(status_span_first_actionable_record "$f" \ - "$(fm_wake_signal_seen_size "$STATE" "$f")") + record=''; needs_decision=0 + status_span_first_actionable_record "$f" \ + "$(fm_wake_signal_seen_size "$STATE" "$f")" record needs_decision rc=$? [ "$rc" -eq 1 ] && [ -z "$record" ] && continue if [ "$rc" -eq 2 ]; then @@ -1235,7 +1242,12 @@ signal_files_actionable() { # ... fi endpoint=${record%%$'\t'*}; rest=${record#*$'\t'}; ident=${rest%%$'\t'*} FM_SIGNAL_SURFACE_ENDPOINTS="${FM_SIGNAL_SURFACE_ENDPOINTS}${f}"$'\t'"${endpoint}"$'\t'"${ident}"$'\n' - [ "$rc" -eq 0 ] && found=0 + if [ "$rc" -eq 0 ]; then + found=0 + if [ "$needs_decision" -eq 1 ]; then + FM_SIGNAL_NEEDS_DECISION_FILES="${FM_SIGNAL_NEEDS_DECISION_FILES} ${f}" + fi + fi done return "$found" } @@ -1737,15 +1749,27 @@ EOF # ordering evaluates them ONLY for a non-afk signal with no captain-relevant # status span, and the capture only once the authoritative verdict comes up short. FM_SIGNAL_SURFACE_ENDPOINTS='' + FM_SIGNAL_NEEDS_DECISION_FILES='' # shellcheck disable=SC2086 # $files is a space-separated status-path list (ids carry no spaces) signal_files_actionable $files signal_actionable=$? + # A needs-decision file's queued row payload is marked "needs-decision:" + # instead of the ordinary "signal:" below (other files in the same batch + # keep the ordinary payload). The wake reason line itself, and every + # harness-arm consumer that pattern-matches it, stays byte-identical - + # only the per-row payload changes, which is what + # docs/pi-supervision-branch.md's Pi-only branch dispatcher reads to keep a + # needs-decision row off the supervision branch (fm-branch-dispatch.ts, + # fm-primary-pi-watch.ts). Every other harness and script keeps seeing the + # exact same "signal:$files" wake it always has. # shellcheck disable=SC2086 # same space-separated status-path list if afk_present || [ "$signal_actionable" -eq 0 ] \ || { ! signal_crew_provably_working $files && ! signal_turnend_panes_churned $files; }; then while IFS=$(printf '\t') read -r sf sig f; do [ -n "$sf" ] || continue - fm_wake_append signal "$(basename "$f")" "$reason" || exit 1 + file_reason="$reason" + case " $FM_SIGNAL_NEEDS_DECISION_FILES " in *" $f "*) file_reason="needs-decision:$files" ;; esac + fm_wake_append signal "$(basename "$f")" "$file_reason" || exit 1 done <`, so other harness-arm scripts see their existing shape. + This main-only classification is specific to marked signal rows; stale and heartbeat rows retain their existing eligibility rules. A fleet-wide heartbeat keeps its own all-or-nothing rule (see "Heartbeat routing" below): it takes every branch-ownable unread row or none of them. A co-present main-owned check row no longer defers that review to main, because it is not fleet context the branch is missing and main is woken for it on its own triggering close. - The branch itself: `.pi/extensions/fm-branch-supervision.ts` creates the branch session, serializes wakes, mirrors dialog, and merges outcomes. @@ -51,9 +56,9 @@ The supervision branch itself is Pi-only by construction: - Consistency: `bin/fm-lease-lib.sh` owns the per-task lease contract, the main-only role partition, and the deliberate CONFUSED-AGENT-GRADE threat model these guards target (captain-decided; adversarial-grade separation is out of scope and tracked as follow-up design work); `bin/fm-lease.sh` is the command surface. The guards are wired into `fm-send.sh`, `fm-control.sh`, and `fm-teardown.sh` (overlap, lease-checked, with claim serialization retained through the mutation) and `fm-pr-merge.sh`, `fm-merge-local.sh`, and `fm-spawn.sh` (main-owned, branch refused; a relaunch through `fm-control` stays branch-legal recovery). - Autonomy: supervision is default-on for every task once a Pi primary session owns the fleet lock (docs/configuration.md "Pi supervision branch"); no captain grant file is required. - A fleet-wide heartbeat is separately eligible only when every non-check row in the unread queue is a heartbeat row or a resolvable task-local row (see "Heartbeat routing" below); every other fleet-wide or unresolvable wake, and every watcher-failure alarm, stays on main. + A fleet-wide heartbeat is separately eligible only when every row other than a check or marked needs-decision signal is a heartbeat row or a resolvable task-local row (see "Heartbeat routing" below); every other fleet-wide or unresolvable wake, and every watcher-failure alarm, stays on main. The branch recomputes eligibility immediately before prompting the branch to drain and publishes the exact eligible row set to `state/.branch-eligible-rows` through `writeEligibleRowsSnapshot`. - A newly-arrived main-owned row observed at that recheck no longer defers the whole queue to main: it is excluded from the eligible set, so whatever else is currently eligible still reaches the branch, and the main-owned row stays queued for main's own later drain. + A newly-arrived main-owned row observed at that recheck no longer defers the whole queue to main: it is excluded from the eligible set, so whatever else is currently eligible still reaches the branch, and the main-owned row stays queued for main's own drain. [`watcher-continuity.md`](watcher-continuity.md#per-actor-acknowledgement) owns the consume-side guarantee that neither actor can present or acknowledge the other's claim. Heartbeat keeps its own all-or-nothing recheck over the rows it can claim: it takes every branch-ownable unread row or none of them, and an unresolvable task-local row still defers the whole review to main. A producer can still append a row in the instant between that final check and drain startup; this accepted residual follows the confused-agent-grade boundary above rather than claiming adversarial queue isolation. @@ -146,12 +151,13 @@ 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, 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. +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, model and effort selection, and (in `test_branch_dispatch_classifies_main_only_rows_and_writes_the_eligible_snapshot`) a needs-decision signal row's exclusion from `eligibleSeqs`, its presence in `needsDecisionKeys`, that it never vetoes an unrelated eligible row, and that a needs-decision-only queue reads as ordinary main-only absence. `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`. +The branch-offer, heartbeat-offer, heartbeat-not-ridden-by-a-check, main-only-check-class, needs-decision-signal-stays-on-main, and mixed-signal-routing tests remain in `tests/fm-pi-watch-extension.test.sh` (the last two exercise `offerWakeToBranch`'s file-list cross-reference end to end), 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`. It also covers the off-thread delivery contract behaviorally: that a delivery leaves the event loop running rather than blocking it, that interleaved reports stay ordered and exactly once, that a session replaced mid-delivery neither loses nor duplicates an outcome, and that a failing store script surfaces without losing or doubling one. +`tests/fm-watch-triage.test.sh` covers `bin/fm-watch.sh`'s side of the contract end to end: a needs-decision signal row's queued payload is marked `needs-decision:`, a needs-decision whose key transition was rejected by the reserved-key vocabulary (fm-classify-lib.sh's "reconciliation-required: " wrapper) is still marked, and a routine captain-relevant signal's payload stays unmarked. Live guards: `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. `FM_PI_BRANCH_RESPONSIVENESS_E2E=1 tests/fm-pi-branch-responsiveness-live-e2e.test.sh` answers the question only a real TUI can: it types into an isolated Pi pane while outcomes are delivered and fails if keystroke echo leaves the class of the same machine's extension-free floor. Record dated current results in [docs/verification/runtime-backends.md](verification/runtime-backends.md). diff --git a/docs/supervision-protocols/pi.md b/docs/supervision-protocols/pi.md index b01f5c514c9..23245e86378 100644 --- a/docs/supervision-protocols/pi.md +++ b/docs/supervision-protocols/pi.md @@ -20,6 +20,7 @@ When this session owns supervision and away mode is not active: The arm mechanism above is extension-owned, not a model tool call, but a manual recovery probe that backgrounds, pipes, or bundles the arm is denied automatically by the PreToolUse seatbelt (`bin/fm-arm-pretool-check.sh`, wired into the turn-end guard extension at `__FM_PI_TURNEND_EXT__`). The supervision branch is default-on (docs/pi-supervision-branch.md): whenever this session owns the fleet lock and away mode is not active, the watcher extension hands eligible task-local rows from ordinary actionable wakes, plus selected fleet-wide heartbeat reviews, to the in-process supervision branch while main-only rows remain queued for this conversation. +A needs-decision signal makes its coalesced signal/stale trigger batch main-owned in whole. Fleet-wide heartbeat scans remain independent and are never pulled to main merely because a needs-decision row is queued. A no-change heartbeat outcome explicitly reported with `task=fleet` and `silent=true` is delivered silently with no rendered note, while every other routine outcome returns as an appended, rendered note that leads with ⛵ then the dim outcome text. A captain-facing outcome instead appears as one exact, sequence-keyed visible transcript entry, and then arrives in this conversation as one hidden supervision processing request listing each `[seq N] task: summary` it covers. That request is the one turn in which MAIN processes the outcome: give the captain a visible response where one is due, answer or escalate a decision, act on a blocker or failure, or record that no further action is needed, then call the `fm_branch_processed` tool with the highest sequence the request listed, exactly once. diff --git a/tests/fm-pi-branch-extension.test.sh b/tests/fm-pi-branch-extension.test.sh index 24ce89d5200..fa4e7f39ed3 100644 --- a/tests/fm-pi-branch-extension.test.sh +++ b/tests/fm-pi-branch-extension.test.sh @@ -1798,6 +1798,71 @@ EOF pass "pre-drain eligibility re-check excludes a newly main-owned row without deferring eligible work" } +# A needs-decision signal wakes main independently, but it must not veto an +# already accepted routine delivery at the branch's pre-drain recheck. The +# grant serializes the actors: branch owns only the routine row, while the +# decision row remains main-owned. If the prompted branch then fails, rejecting +# the settlement releases the grant so watcher fallback can replay both rows. +test_branch_predrain_needs_decision_keeps_routine_row_branch_eligible() { + local repo home out status + repo="$TMP_ROOT/predrain-needs-decision-root" + home="$TMP_ROOT/predrain-needs-decision-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 = { bus, fire, home, makeOffer, realRoot }; })()`); +const { bus, fire, home, makeOffer, realRoot } = globalThis.__t; +import { spawnSync } from "node:child_process"; +import { existsSync, readFileSync, writeFileSync } from "node:fs"; + +fire("session_start", {}); +writeFileSync( + `${home}/state/.wake-queue`, + "1\t1\tsignal\tbranch-driver.status\tsignal: routine progress\n" + + "2\t2\tsignal\tdecision-task.status\tneeds-decision: decision-task.status\n", +); +let releasePrompt; +globalThis.__fmPromptGate = new Promise((resolve) => { releasePrompt = resolve; }); +const offer = makeOffer("signal: branch-driver.status"); +bus.emit("fm-branch-supervision:dispatch", offer); +if (!offer.accepted) throw new Error("branch refused the routine offer before its mixed-queue recheck"); +for (let i = 0; i < 250 && !globalThis.__fmPromptStarted; i += 1) { + await new Promise((resolve) => setTimeout(resolve, 10)); +} +if (!globalThis.__fmPromptStarted) { + throw new Error("a co-present needs-decision row vetoed the accepted routine branch prompt"); +} +const snapshot = readFileSync(`${home}/state/.branch-eligible-rows`, "utf8").trim().split("\n"); +if (!snapshot.includes("1") || snapshot.includes("2")) { + throw new Error(`mixed queue granted the wrong rows to branch: ${snapshot}`); +} +releasePrompt(); +const failure = await offer.settlement.then(() => null, (error) => error); +if (!(failure instanceof Error) || !failure.message.includes("produced no durable outcome")) { + throw new Error(`accepted wake settled without delivery instead of rejecting to fallback: ${String(failure)}`); +} +if (existsSync(`${home}/state/.branch-eligible-rows`)) { + throw new Error("failed branch prompt retained its routine-row grant"); +} +const drain = spawnSync("bash", [`${realRoot}/bin/fm-wake-drain.sh`], { + encoding: "utf8", + env: { ...process.env, FM_HOME: home, FM_STATE_OVERRIDE: `${home}/state`, FM_ROOT_OVERRIDE: realRoot }, +}); +if (drain.status !== 0) throw new Error(`main fallback drain failed: ${drain.stderr}`); +if (!drain.stdout.includes("\t1\tsignal\tbranch-driver.status\t") || + !drain.stdout.includes("\t2\tsignal\tdecision-task.status\t")) { + throw new Error(`fallback did not receive the released mixed queue: ${drain.stdout}`); +} +process.exit(0); +EOF + status=$? + out=$(cat "$TMP_ROOT/node-output") + expect_code 0 "$status" "a mixed needs-decision recheck must keep routine branch delivery live: $out" + pass "a co-present needs-decision row neither vetoes nor falsely settles routine branch delivery" +} + test_settled_branch_prompt_releases_unacknowledged_grant() { local repo home out status repo="$TMP_ROOT/settled-grant-root" @@ -3673,6 +3738,39 @@ for (const row of mainOnlyRows) { if (scope.corrupted) throw new Error(`an ordinary main-only row must not read as corrupted: ${row}`); } +// A needs-decision signal row is a main-only class too, marked by payload +// rather than kind (docs/pi-supervision-branch.md "Autonomy"): it is excluded +// from eligibleSeqs, named in needsDecisionKeys, and never vetoes an unrelated +// eligible row sharing the queue. +writeFileSync( + `${state}/.wake-queue`, + [ + "1\t1\tsignal\ttask-a.status\tneeds-decision: task-a.status", + "1\t2\tstale\tfm-window\tstale: fm-window", + ].join("\n"), +); +const needsDecisionMixed = scopeForUnreadWake(state, false); +if (!needsDecisionMixed.eligible) { + throw new Error(`a needs-decision row must not veto an unrelated eligible row: ${JSON.stringify(needsDecisionMixed)}`); +} +if (needsDecisionMixed.eligibleSeqs.join(",") !== "2") { + throw new Error(`a needs-decision row must be excluded from eligibleSeqs: ${JSON.stringify(needsDecisionMixed)}`); +} +if (needsDecisionMixed.needsDecisionKeys.join(",") !== "task-a.status") { + throw new Error(`needsDecisionKeys must name the excluded row: ${JSON.stringify(needsDecisionMixed)}`); +} +if (needsDecisionMixed.corrupted) { + throw new Error(`a needs-decision row must not read as corrupted: ${JSON.stringify(needsDecisionMixed)}`); +} + +// A queue holding only a needs-decision row is ordinary main-only absence, +// exactly like a queue holding only a check row. +writeFileSync(`${state}/.wake-queue`, "1\t1\tsignal\ttask-a.status\tneeds-decision: task-a.status"); +const needsDecisionOnly = scopeForUnreadWake(state, false); +if (needsDecisionOnly.eligible || needsDecisionOnly.eligibleSeqs.length !== 0 || needsDecisionOnly.corrupted) { + throw new Error(`a needs-decision-only queue must be ordinary main-only absence: ${JSON.stringify(needsDecisionOnly)}`); +} + writeFileSync( `${state}/.wake-queue`, [ @@ -4521,6 +4619,7 @@ 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_branch_predrain_needs_decision_keeps_routine_row_branch_eligible test_settled_branch_prompt_releases_unacknowledged_grant test_post_construction_provider_error_falls_back_latches_and_recovers_on_cooldown test_selection_change_does_not_corrupt_inflight_provider_state diff --git a/tests/fm-watch-triage.test.sh b/tests/fm-watch-triage.test.sh index 2b8d2933c4b..15d2dc9c7cb 100755 --- a/tests/fm-watch-triage.test.sh +++ b/tests/fm-watch-triage.test.sh @@ -1522,6 +1522,64 @@ test_actionable_signal_surfaced() { pass "captain-relevant signal is surfaced (queue + exit) and marked surfaced" } +# A needs-decision status append surfaced through this actionable signal path +# must skip the Pi supervision branch and reach main directly +# (docs/pi-supervision-branch.md "Autonomy"). The row still +# queues as an ordinary signal-kind wake - fm-branch-dispatch.ts's +# scopeForUnreadWake tells it apart from a routine signal by this payload +# marker, not by kind. +test_needs_decision_signal_payload_marked_for_branch_exclusion() { + local dir state fakebin out status_file pid + dir=$(make_case needs-decision-payload); state="$dir/state"; fakebin="$dir/fakebin" + out="$dir/watch.out" + status_file="$state/task.status" + printf 'working: setup\nneeds-decision: pick A or B\n' > "$status_file" + watch_bg "$state" "$fakebin" "$out" + pid=$! + wait_for_exit "$pid" 100 || fail "watcher did not exit for an actionable needs-decision signal" + grep -F "$(printf 'signal\ttask.status\tneeds-decision:')" "$state/.wake-queue" >/dev/null \ + || fail "a needs-decision signal row was not payload-marked for branch exclusion: $(cat "$state/.wake-queue")" + pass "a needs-decision signal row's queued payload is marked needs-decision: for branch exclusion" +} + +# A needs-decision whose key transition was rejected by the reserved-key +# vocabulary is reported as a "reconciliation-required: " wrapped event +# (fm-classify-lib.sh's status_span_first_actionable_record), but it is still a +# needs-decision signal that this path routes directly to main - the payload +# marker must not be fooled by that wrapper. +test_needs_decision_reconciliation_required_still_marked() { + local dir state fakebin out status_file pid + dir=$(make_case needs-decision-reconciliation); state="$dir/state"; fakebin="$dir/fakebin" + out="$dir/watch.out" + status_file="$state/task.status" + printf 'needs-decision [key=pending-reply-x]: unrelated request\nworking: awaiting reconciliation\n' \ + > "$status_file" + watch_bg "$state" "$fakebin" "$out" + pid=$! + wait_for_exit "$pid" 100 || fail "watcher did not exit for a rejected-reserved-key needs-decision" + grep -F "$(printf 'signal\ttask.status\tneeds-decision:')" "$state/.wake-queue" >/dev/null \ + || fail "a reconciliation-required needs-decision row was not payload-marked for branch exclusion: $(cat "$state/.wake-queue")" + pass "a reconciliation-required needs-decision row's queued payload is still marked needs-decision:" +} + +# A routine (non-needs-decision) captain-relevant event must keep its ordinary +# payload: only a genuine needs-decision gets the exclusion marker. +test_routine_signal_payload_not_marked_needs_decision() { + local dir state fakebin out status_file pid + dir=$(make_case routine-signal-payload); state="$dir/state"; fakebin="$dir/fakebin" + out="$dir/watch.out" + status_file="$state/task.status" + printf 'working: setup\ndone: migration complete ; needs-decision: documented in follow-up\n' > "$status_file" + watch_bg "$state" "$fakebin" "$out" + pid=$! + wait_for_exit "$pid" 100 || fail "watcher did not exit for an actionable done signal" + grep -F "$(printf 'signal\ttask.status\tneeds-decision:')" "$state/.wake-queue" >/dev/null \ + && fail "a routine done signal was incorrectly payload-marked needs-decision: $(cat "$state/.wake-queue")" + grep -F "$(printf 'signal\ttask.status\tsignal:')" "$state/.wake-queue" >/dev/null \ + || fail "a routine signal lost its ordinary payload: $(cat "$state/.wake-queue")" + pass "a routine event containing a needs-decision phrase keeps its ordinary payload, unmarked" +} + # The reported bug, end to end through a real watcher: a crew reports something # the captain must act on and then keeps appending routine progress, which is # ordinary while the watcher lingers its signal grace window to coalesce a status @@ -4032,6 +4090,9 @@ test_working_note_not_working_surfaced test_secondmate_status_note_surfaced_despite_busy_agent test_self_announced_close_does_not_rewake_but_next_note_does test_actionable_signal_surfaced +test_needs_decision_signal_payload_marked_for_branch_exclusion +test_needs_decision_reconciliation_required_still_marked +test_routine_signal_payload_not_marked_needs_decision test_actionable_signal_survives_a_later_routine_append test_release_completion_survives_a_later_routine_append test_routine_appends_after_a_classified_event_stay_absorbed From 883db7e59de78006a60ae12a42782866b92fa8df Mon Sep 17 00:00:00 2001 From: kunchenguid Date: Fri, 4 Sep 2026 18:15:30 -0700 Subject: [PATCH 02/17] test(pi): cover distinct-file mixed batches and heartbeat independence Add a regression using two distinct files (not the same status file twice) in one coalesced trigger so a some-vs-every regression on the file-list cross-reference cannot hide behind a degenerate same-key case, and a heartbeat/needs-decision co-presence test proving a needs-decision row neither vetoes nor rides along with an otherwise eligible heartbeat scan. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_013LB2CeerSMCZN4oNsVfLeE --- tests/fm-pi-watch-extension.test.sh | 380 ++++++++++++++++++++++++++++ 1 file changed, 380 insertions(+) diff --git a/tests/fm-pi-watch-extension.test.sh b/tests/fm-pi-watch-extension.test.sh index 520e122d57b..a39d9f56a62 100755 --- a/tests/fm-pi-watch-extension.test.sh +++ b/tests/fm-pi-watch-extension.test.sh @@ -810,6 +810,382 @@ CLASSES pass "every main-only check class still reaches main, never the supervision branch" } +# A needs-decision status append surfaced through an actionable signal must +# reach main directly, exactly like a check-kind trigger, never taking the +# supervision-branch hop first +# (docs/pi-supervision-branch.md "Autonomy"). Unlike a check row it shares the +# ordinary "signal:" kind and wake-message shape, so the dispatcher tells it +# apart by payload (fm-branch-dispatch.ts's needsDecisionKeys) instead of by +# message prefix - this exercises that cross-reference end to end. An unrelated +# eligible stale row sits in the same queue to prove it is only the +# needs-decision TRIGGER itself that stays on main, not the whole scan. +test_pi_needs_decision_signal_stays_on_main() { + local repo home plugin log stop out status + repo="$TMP_ROOT/pi-needs-decision-root" + home="$TMP_ROOT/pi-needs-decision-home" + log="$TMP_ROOT/pi-needs-decision.log" + stop="$TMP_ROOT/pi-needs-decision.stop" + mkdir -p "$repo/bin" "$home/state" "$home/config" "$home/projects/approved" + install_pi_watch_extension_fixture "$repo" + plugin="$repo/.pi/extensions/fm-primary-pi-watch.ts" + printf 'project=%s/projects/approved\nwindow=fm-window\n' "$home" > "$home/state/task-a.meta" + cat > "$repo/bin/fm-watch-arm.sh" <<'SH' +#!/usr/bin/env bash +if [ "${1:-}" = --handling-delivered ]; then exit 0; fi +printf 'arm=%s\n' "$$" >> "${FM_ARM_LOG:?}" +count=$(grep -c '^arm=' "$FM_ARM_LOG") +if [ "$count" -eq 1 ]; then + printf 'watcher: started pid=%s (beacon fresh)\n' "$$" + printf 'signal: task-a.status\n' + exit 0 +fi +printf 'watcher: started pid=%s (beacon fresh) recovery-generation=fixture-generation\n' "$$" +trap 'exit 0' TERM INT +while [ ! -e "$FM_STOP_FILE" ]; do sleep 0.02; done +SH + chmod +x "$repo/bin/fm-watch-arm.sh" + out=$(PLUGIN="$plugin" FM_HOME="$home" FM_ROOT_OVERRIDE="$repo" FM_ARM_LOG="$log" FM_STOP_FILE="$stop" \ + node --input-type=module 2>&1 <<'EOF' +import { writeFileSync } from "node:fs"; +import { pathToFileURL } from "node:url"; + +const offers = []; +let prompt = ""; +let tool = null; +const handlers = new Map(); +const bus = { + on(channel, handler) { + handlers.set(channel, [...(handlers.get(channel) ?? []), handler]); + return () => {}; + }, + emit(channel, data) { + for (const handler of handlers.get(channel) ?? []) handler(data); + }, +}; +bus.on("fm-branch-supervision:dispatch", (offer) => { + offers.push({ message: offer.message, eligible: offer.eligible }); + if (offer.eligible) offer.accept(); +}); +const pi = { + on() {}, + events: bus, + registerCommand() {}, + registerTool(candidate) { + if (candidate.name === "fm_watch_arm_pi") tool = candidate; + }, + sendUserMessage: async (message) => { + prompt = message; + }, +}; +writeFileSync(`${process.env.FM_HOME}/state/.lock`, `${process.pid}\n`); +// the task-a.status row (the needs-decision trigger) sits alongside an +// unrelated eligible stale row for the same project. +writeFileSync( + `${process.env.FM_HOME}/state/.wake-queue`, + "1\t1\tstale\tfm-window\tstale: fm-window\n2\t2\tsignal\ttask-a.status\tneeds-decision: task-a.status\n", +); +const mod = await import(pathToFileURL(process.env.PLUGIN).href); +mod.default(pi); +await tool.execute("tool-call-needs-decision", {}, undefined, undefined, {}); +for (let i = 0; i < 250 && !prompt; i += 1) { + await new Promise((resolve) => setTimeout(resolve, 10)); +} +if (offers.length !== 1 || offers[0].eligible !== false) { + throw new Error(`a needs-decision trigger was offered to the branch: ${JSON.stringify(offers)}`); +} +if (!prompt.includes("FIRSTMATE WATCHER WAKE: signal: task-a.status")) { + throw new Error(`a needs-decision trigger did not reach main: ${prompt}`); +} +writeFileSync(process.env.FM_STOP_FILE, "stop\n"); +process.exit(0); +EOF + ) + status=$? + expect_code 0 "$status" "a needs-decision signal trigger must stay on main: $out" + [ -z "$out" ] || fail "Pi needs-decision test printed output: $out" + pass "a needs-decision signal trigger reaches main even with an unrelated eligible row" +} + +# A routine row can remain unread when the same status file raises a decision. +# The complete triggering batch goes directly to main without waiting for a +# supervision turn. +test_pi_same_key_mixed_signal_routes_whole_batch_to_main() { + local repo home plugin log stop out status + repo="$TMP_ROOT/pi-mixed-signal-root" + home="$TMP_ROOT/pi-mixed-signal-home" + log="$TMP_ROOT/pi-mixed-signal.log" + stop="$TMP_ROOT/pi-mixed-signal.stop" + mkdir -p "$repo/bin" "$home/state" "$home/config" "$home/projects/approved" + install_pi_watch_extension_fixture "$repo" + plugin="$repo/.pi/extensions/fm-primary-pi-watch.ts" + printf 'project=%s/projects/approved\nwindow=fm-a\n' "$home" > "$home/state/task-a.meta" + cat > "$repo/bin/fm-watch-arm.sh" <<'SH' +#!/usr/bin/env bash +if [ "${1:-}" = --handling-delivered ]; then exit 0; fi +printf 'arm=%s\n' "$$" >> "${FM_ARM_LOG:?}" +count=$(grep -c '^arm=' "$FM_ARM_LOG") +if [ "$count" -eq 1 ]; then + printf 'watcher: started pid=%s (beacon fresh)\n' "$$" + printf 'signal: task-a.status\n' + exit 0 +fi +printf 'watcher: started pid=%s (beacon fresh) recovery-generation=fixture-generation\n' "$$" +trap 'exit 0' TERM INT +while [ ! -e "$FM_STOP_FILE" ]; do sleep 0.02; done +SH + chmod +x "$repo/bin/fm-watch-arm.sh" + out=$(PLUGIN="$plugin" FM_HOME="$home" FM_ROOT_OVERRIDE="$repo" FM_ARM_LOG="$log" FM_STOP_FILE="$stop" \ + node --input-type=module 2>&1 <<'EOF' +import { writeFileSync } from "node:fs"; +import { pathToFileURL } from "node:url"; + +const offers = []; +let prompt = ""; +let tool = null; +const handlers = new Map(); +const bus = { + on(channel, handler) { + handlers.set(channel, [...(handlers.get(channel) ?? []), handler]); + return () => {}; + }, + emit(channel, data) { + for (const handler of handlers.get(channel) ?? []) handler(data); + }, +}; +bus.on("fm-branch-supervision:dispatch", (offer) => { + offers.push({ message: offer.message, eligible: offer.eligible, projects: [...offer.projects] }); + if (offer.eligible) offer.accept(); +}); +const pi = { + on() {}, + events: bus, + registerCommand() {}, + registerTool(candidate) { + if (candidate.name === "fm_watch_arm_pi") tool = candidate; + }, + sendUserMessage: async (message) => { + prompt = message; + }, +}; +writeFileSync(`${process.env.FM_HOME}/state/.lock`, `${process.pid}\n`); +writeFileSync( + `${process.env.FM_HOME}/state/.wake-queue`, + "1\t1\tsignal\ttask-a.status\tsignal: task-a.status\n" + + "2\t2\tsignal\ttask-a.status\tneeds-decision: task-a.status\n", +); +const mod = await import(pathToFileURL(process.env.PLUGIN).href); +mod.default(pi); +await tool.execute("tool-call-mixed-signal", {}, undefined, undefined, {}); +for (let i = 0; i < 250 && offers.length === 0; i += 1) { + await new Promise((resolve) => setTimeout(resolve, 10)); +} +for (let i = 0; i < 250 && !prompt; i += 1) { + await new Promise((resolve) => setTimeout(resolve, 10)); +} +if (offers.length !== 1 || offers[0].eligible !== false) { + throw new Error(`a mixed needs-decision batch was offered to the branch: ${JSON.stringify(offers)}`); +} +if (!prompt.includes("FIRSTMATE WATCHER WAKE: signal: task-a.status")) { + throw new Error(`the mixed needs-decision batch did not wake main: ${prompt}`); +} +writeFileSync(process.env.FM_STOP_FILE, "stop\n"); +process.exit(0); +EOF + ) + status=$? + expect_code 0 "$status" "a mixed needs-decision batch must route wholly to main: $out" + [ -z "$out" ] || fail "Pi mixed-signal test printed output: $out" + pass "a mixed needs-decision batch routes wholly to main" +} + +# The captain's accepted rule names ONE coalesced trigger batch, not only a +# single status file with two rows: when a watcher poll bundles a routine +# signal for one task and a needs-decision signal for a DIFFERENT task into +# one "signal: " close, the whole batch - both files - must reach main +# together, never split so the routine file goes to the branch while only the +# decision file goes to main. A `some`-vs-`every` regression on the file-list +# cross-reference would pass the same-key test above (both entries share one +# key) but must fail here, where the two files are genuinely distinct. +test_pi_distinct_files_mixed_batch_routes_whole_batch_to_main() { + local repo home plugin log stop out status + repo="$TMP_ROOT/pi-distinct-mixed-batch-root" + home="$TMP_ROOT/pi-distinct-mixed-batch-home" + log="$TMP_ROOT/pi-distinct-mixed-batch.log" + stop="$TMP_ROOT/pi-distinct-mixed-batch.stop" + mkdir -p "$repo/bin" "$home/state" "$home/config" "$home/projects/approved" + install_pi_watch_extension_fixture "$repo" + plugin="$repo/.pi/extensions/fm-primary-pi-watch.ts" + printf 'project=%s/projects/approved\nwindow=fm-a\n' "$home" > "$home/state/task-a.meta" + printf 'project=%s/projects/approved\nwindow=fm-b\n' "$home" > "$home/state/task-b.meta" + cat > "$repo/bin/fm-watch-arm.sh" <<'SH' +#!/usr/bin/env bash +if [ "${1:-}" = --handling-delivered ]; then exit 0; fi +printf 'arm=%s\n' "$$" >> "${FM_ARM_LOG:?}" +count=$(grep -c '^arm=' "$FM_ARM_LOG") +if [ "$count" -eq 1 ]; then + printf 'watcher: started pid=%s (beacon fresh)\n' "$$" + printf 'signal: task-a.status task-b.status\n' + exit 0 +fi +printf 'watcher: started pid=%s (beacon fresh) recovery-generation=fixture-generation\n' "$$" +trap 'exit 0' TERM INT +while [ ! -e "$FM_STOP_FILE" ]; do sleep 0.02; done +SH + chmod +x "$repo/bin/fm-watch-arm.sh" + out=$(PLUGIN="$plugin" FM_HOME="$home" FM_ROOT_OVERRIDE="$repo" FM_ARM_LOG="$log" FM_STOP_FILE="$stop" \ + node --input-type=module 2>&1 <<'EOF' +import { writeFileSync } from "node:fs"; +import { pathToFileURL } from "node:url"; + +const offers = []; +let prompt = ""; +let tool = null; +const handlers = new Map(); +const bus = { + on(channel, handler) { + handlers.set(channel, [...(handlers.get(channel) ?? []), handler]); + return () => {}; + }, + emit(channel, data) { + for (const handler of handlers.get(channel) ?? []) handler(data); + }, +}; +bus.on("fm-branch-supervision:dispatch", (offer) => { + offers.push({ message: offer.message, eligible: offer.eligible, projects: [...offer.projects] }); + if (offer.eligible) offer.accept(); +}); +const pi = { + on() {}, + events: bus, + registerCommand() {}, + registerTool(candidate) { + if (candidate.name === "fm_watch_arm_pi") tool = candidate; + }, + sendUserMessage: async (message) => { + prompt = message; + }, +}; +writeFileSync(`${process.env.FM_HOME}/state/.lock`, `${process.pid}\n`); +writeFileSync( + `${process.env.FM_HOME}/state/.wake-queue`, + "1\t1\tsignal\ttask-a.status\tneeds-decision: task-a.status\n" + + "2\t2\tsignal\ttask-b.status\tsignal: task-b.status\n", +); +const mod = await import(pathToFileURL(process.env.PLUGIN).href); +mod.default(pi); +await tool.execute("tool-call-distinct-mixed-batch", {}, undefined, undefined, {}); +for (let i = 0; i < 250 && offers.length === 0; i += 1) { + await new Promise((resolve) => setTimeout(resolve, 10)); +} +for (let i = 0; i < 250 && !prompt; i += 1) { + await new Promise((resolve) => setTimeout(resolve, 10)); +} +if (offers.length !== 1 || offers[0].eligible !== false) { + throw new Error(`a distinct-file mixed batch was split, offering the routine file to the branch: ${JSON.stringify(offers)}`); +} +if (!prompt.includes("FIRSTMATE WATCHER WAKE: signal: task-a.status task-b.status")) { + throw new Error(`the distinct-file mixed batch did not reach main as one whole close: ${prompt}`); +} +writeFileSync(process.env.FM_STOP_FILE, "stop\n"); +process.exit(0); +EOF + ) + status=$? + expect_code 0 "$status" "a distinct-file mixed batch must route wholly to main, not split: $out" + [ -z "$out" ] || fail "Pi distinct-file mixed-batch test printed output: $out" + pass "a mixed batch of two distinct files - one routine, one needs-decision - routes wholly to main" +} + +# Independent heartbeat handling: a needs-decision row sitting elsewhere in the +# unread queue is excluded and non-vetoing exactly like a check-kind row, so it +# must neither block nor ride along with a co-present, otherwise-eligible +# heartbeat scan (docs/pi-supervision-branch.md "Heartbeat routing"). +test_pi_heartbeat_is_not_ridden_into_main_by_a_co_present_needs_decision() { + local repo home plugin log stop out status + repo="$TMP_ROOT/pi-heartbeat-needs-decision-root" + home="$TMP_ROOT/pi-heartbeat-needs-decision-home" + log="$TMP_ROOT/pi-heartbeat-needs-decision.log" + stop="$TMP_ROOT/pi-heartbeat-needs-decision.stop" + mkdir -p "$repo/bin" "$home/state" "$home/config" + install_pi_watch_extension_fixture "$repo" + plugin="$repo/.pi/extensions/fm-primary-pi-watch.ts" + cat > "$repo/bin/fm-watch-arm.sh" <<'SH' +#!/usr/bin/env bash +if [ "${1:-}" = --handling-delivered ]; then exit 0; fi +printf 'arm=%s\n' "$$" >> "${FM_ARM_LOG:?}" +count=$(grep -c '^arm=' "$FM_ARM_LOG") +if [ "$count" -eq 1 ]; then + printf 'watcher: started pid=%s (beacon fresh)\n' "$$" + printf 'heartbeat\n' + exit 0 +fi +printf 'watcher: started pid=%s (beacon fresh) recovery-generation=fixture-generation\n' "$$" +trap 'exit 0' TERM INT +while [ ! -e "$FM_STOP_FILE" ]; do sleep 0.02; done +SH + chmod +x "$repo/bin/fm-watch-arm.sh" + out=$(PLUGIN="$plugin" FM_HOME="$home" FM_ROOT_OVERRIDE="$repo" FM_ARM_LOG="$log" FM_STOP_FILE="$stop" node --input-type=module 2>&1 <<'EOF' +import { writeFileSync } from "node:fs"; +import { pathToFileURL } from "node:url"; + +const offers = []; +let prompt = ""; +let tool = null; +const handlers = new Map(); +const bus = { + on(channel, handler) { + handlers.set(channel, [...(handlers.get(channel) ?? []), handler]); + return () => {}; + }, + emit(channel, data) { + for (const handler of handlers.get(channel) ?? []) handler(data); + }, +}; +bus.on("fm-branch-supervision:dispatch", (offer) => { + offers.push({ message: offer.message, heartbeat: offer.heartbeat, eligible: offer.eligible }); + if (offer.eligible) offer.accept(); +}); +const pi = { + on() {}, + events: bus, + registerCommand() {}, + registerTool(candidate) { + if (candidate.name === "fm_watch_arm_pi") tool = candidate; + }, + sendUserMessage: async (message) => { + prompt = message; + }, +}; +writeFileSync(`${process.env.FM_HOME}/state/.lock`, `${process.pid}\n`); +writeFileSync( + `${process.env.FM_HOME}/state/.wake-queue`, + "1\t1\theartbeat\theartbeat\theartbeat\n2\t2\tsignal\tdecision-task.status\tneeds-decision: decision-task.status\n", +); +const mod = await import(pathToFileURL(process.env.PLUGIN).href); +mod.default(pi); +await tool.execute("tool-call-heartbeat-needs-decision", {}, undefined, undefined, {}); +for (let i = 0; i < 250 && offers.length === 0; i += 1) { + await new Promise((resolve) => setTimeout(resolve, 10)); +} +for (let i = 0; i < 25 && !prompt; i += 1) { + await new Promise((resolve) => setTimeout(resolve, 10)); +} +if (offers.length !== 1 || offers[0].heartbeat !== true || offers[0].eligible !== true) { + throw new Error(`a co-present needs-decision row made the heartbeat offer ineligible: ${JSON.stringify(offers)}`); +} +if (prompt) { + throw new Error(`a co-present needs-decision row rode the heartbeat into main: ${prompt}`); +} +writeFileSync(process.env.FM_STOP_FILE, "stop\n"); +process.exit(0); +EOF + ) + status=$? + expect_code 0 "$status" "a heartbeat must not ride a co-present needs-decision row into main: $out" + [ -z "$out" ] || fail "Pi heartbeat/needs-decision test printed output: $out" + pass "a co-present needs-decision row neither vetoes nor rides a heartbeat into main" +} + test_pi_heartbeat_restoration_failure_stays_on_main() { local repo home plugin log out status repo="$TMP_ROOT/pi-heartbeat-restoration-failure-root" @@ -3612,6 +3988,10 @@ test_pi_branch_offer_owns_actionable_wake test_pi_branch_offer_flags_heartbeat test_pi_heartbeat_is_not_ridden_into_main_by_a_co_present_check test_pi_main_only_check_classes_stay_on_main +test_pi_needs_decision_signal_stays_on_main +test_pi_same_key_mixed_signal_routes_whole_batch_to_main +test_pi_distinct_files_mixed_batch_routes_whole_batch_to_main +test_pi_heartbeat_is_not_ridden_into_main_by_a_co_present_needs_decision test_pi_heartbeat_restoration_failure_stays_on_main test_pi_watcher_failure_never_offered_to_branch test_pi_handling_delivery_failure_is_typed_once From 9ae58ee45033e08b1aeef69a2143d100c8f1802a Mon Sep 17 00:00:00 2001 From: kunchenguid Date: Fri, 4 Sep 2026 18:57:43 -0700 Subject: [PATCH 03/17] no-mistakes(document): Clarify needs-decision and heartbeat routing --- docs/pi-supervision-branch.md | 10 +++++----- docs/supervision-protocols/pi.md | 3 ++- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/docs/pi-supervision-branch.md b/docs/pi-supervision-branch.md index a365ea33b94..d7ae6a83edb 100644 --- a/docs/pi-supervision-branch.md +++ b/docs/pi-supervision-branch.md @@ -58,7 +58,7 @@ The supervision branch itself is Pi-only by construction: - Autonomy: supervision is default-on for every task once a Pi primary session owns the fleet lock (docs/configuration.md "Pi supervision branch"); no captain grant file is required. A fleet-wide heartbeat is separately eligible only when every row other than a check or marked needs-decision signal is a heartbeat row or a resolvable task-local row (see "Heartbeat routing" below); every other fleet-wide or unresolvable wake, and every watcher-failure alarm, stays on main. The branch recomputes eligibility immediately before prompting the branch to drain and publishes the exact eligible row set to `state/.branch-eligible-rows` through `writeEligibleRowsSnapshot`. - A newly-arrived main-owned row observed at that recheck no longer defers the whole queue to main: it is excluded from the eligible set, so whatever else is currently eligible still reaches the branch, and the main-owned row stays queued for main's own drain. + After an independently eligible wake has already been offered, a newly-arrived main-owned row observed at that pre-drain recheck does not revoke the offer: it is excluded from the eligible set, so whatever else is currently eligible still reaches the branch, and the main-owned row stays queued for main's own drain. [`watcher-continuity.md`](watcher-continuity.md#per-actor-acknowledgement) owns the consume-side guarantee that neither actor can present or acknowledge the other's claim. Heartbeat keeps its own all-or-nothing recheck over the rows it can claim: it takes every branch-ownable unread row or none of them, and an unresolvable task-local row still defers the whole review to main. A producer can still append a row in the instant between that final check and drain startup; this accepted residual follows the confused-agent-grade boundary above rather than claiming adversarial queue isolation. @@ -126,10 +126,10 @@ Main can read the durable outcome store on demand through its `fm_branch_outcome ## Heartbeat routing The cheap bash-level heartbeat scan absorbs a genuinely no-op pass before it reaches Pi, unchanged from before. -Only a scan already flagged as possibly captain-relevant emits the bare `heartbeat` wake; `.pi/extensions/fm-primary-pi-watch.ts` flags that offer `heartbeat: true`, and the branch accepts it without a project only when every non-check row observed in the unread-queue eligibility check is either heartbeat-kind or a resolvable task-local signal or stale event. +Only a scan already flagged as possibly captain-relevant emits the bare `heartbeat` wake; `.pi/extensions/fm-primary-pi-watch.ts` flags that offer `heartbeat: true`, and the branch accepts it without a project only when every branch-ownable row observed in the unread-queue eligibility check is either heartbeat-kind or a resolvable task-local signal or stale event. -A heartbeat is never vetoed or ridden into main by a co-present check row. -A check row is permanently main-owned in every mode: it is excluded from what the branch may claim and left queued for main, which is woken for it on that check's own watcher cycle, so nothing starves by being left behind. +A heartbeat is never vetoed or ridden into main by a co-present check row or marked needs-decision signal row. +Those rows are permanently main-owned in every mode: they are excluded from what the branch may claim and left queued for main, which is woken for each on its own watcher cycle, so nothing starves by being left behind. Deferring the fleet review to main merely because some unrelated merge poll or Relay mention happened to be sitting unread put a routine review in the captain's chat for a reason that had nothing to do with the fleet, and that coupling is gone. What all-or-nothing still guarantees is unchanged: the branch takes every branch-ownable unread row or none of them, and an unresolvable task-local row, an unknown row kind, or an unreadable queue still defers the whole review to main. The branch runs its normal operating procedure for the wake (`bin/fm-branch-prompt.sh` "Handling a wake") and performs the deeper fleet review that main previously performed. @@ -155,7 +155,7 @@ Portable regressions: `tests/fm-pi-branch-extension.test.sh` covers dispatch, si `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, main-only-check-class, needs-decision-signal-stays-on-main, and mixed-signal-routing tests remain in `tests/fm-pi-watch-extension.test.sh` (the last two exercise `offerWakeToBranch`'s file-list cross-reference end to end), 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`. +The branch-offer, heartbeat-offer, heartbeat-not-ridden-by-main-only-rows, main-only-check-class, needs-decision-signal-stays-on-main, and mixed-signal-routing tests remain in `tests/fm-pi-watch-extension.test.sh` (the last two exercise `offerWakeToBranch`'s file-list cross-reference end to end), 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`. It also covers the off-thread delivery contract behaviorally: that a delivery leaves the event loop running rather than blocking it, that interleaved reports stay ordered and exactly once, that a session replaced mid-delivery neither loses nor duplicates an outcome, and that a failing store script surfaces without losing or doubling one. `tests/fm-watch-triage.test.sh` covers `bin/fm-watch.sh`'s side of the contract end to end: a needs-decision signal row's queued payload is marked `needs-decision:`, a needs-decision whose key transition was rejected by the reserved-key vocabulary (fm-classify-lib.sh's "reconciliation-required: " wrapper) is still marked, and a routine captain-relevant signal's payload stays unmarked. Live guards: `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. diff --git a/docs/supervision-protocols/pi.md b/docs/supervision-protocols/pi.md index 23245e86378..3c0c3b8b2ec 100644 --- a/docs/supervision-protocols/pi.md +++ b/docs/supervision-protocols/pi.md @@ -20,7 +20,8 @@ When this session owns supervision and away mode is not active: The arm mechanism above is extension-owned, not a model tool call, but a manual recovery probe that backgrounds, pipes, or bundles the arm is denied automatically by the PreToolUse seatbelt (`bin/fm-arm-pretool-check.sh`, wired into the turn-end guard extension at `__FM_PI_TURNEND_EXT__`). The supervision branch is default-on (docs/pi-supervision-branch.md): whenever this session owns the fleet lock and away mode is not active, the watcher extension hands eligible task-local rows from ordinary actionable wakes, plus selected fleet-wide heartbeat reviews, to the in-process supervision branch while main-only rows remain queued for this conversation. -A needs-decision signal makes its coalesced signal/stale trigger batch main-owned in whole. Fleet-wide heartbeat scans remain independent and are never pulled to main merely because a needs-decision row is queued. +A needs-decision signal makes its coalesced signal/stale trigger batch main-owned in whole. +Fleet-wide heartbeat scans remain independent and are never pulled to main merely because a needs-decision row is queued. A no-change heartbeat outcome explicitly reported with `task=fleet` and `silent=true` is delivered silently with no rendered note, while every other routine outcome returns as an appended, rendered note that leads with ⛵ then the dim outcome text. A captain-facing outcome instead appears as one exact, sequence-keyed visible transcript entry, and then arrives in this conversation as one hidden supervision processing request listing each `[seq N] task: summary` it covers. That request is the one turn in which MAIN processes the outcome: give the captain a visible response where one is due, answer or escalate a decision, act on a blocker or failure, or record that no further action is needed, then call the `fm_branch_processed` tool with the highest sequence the request listed, exactly once. From c42f1c1094cfb9704e980559711b4581ac1582c6 Mon Sep 17 00:00:00 2001 From: kunchenguid Date: Fri, 4 Sep 2026 19:29:38 -0700 Subject: [PATCH 04/17] no-mistakes(ci): Fixed captain-held stale reminders so they bypass supervision and wake main, while unrelated unread rows and heartbeats remain independent. Added routing regressions and updated documentation. Pi watcher tests, strict TypeScript checks, lint, and diff checks pass. The no-mistakes attestation failure was pipeline-state related, not a source defect --- .pi/extensions/fm-primary-pi-watch.ts | 17 ++++----- .pi/extensions/lib/fm-branch-dispatch.ts | 40 +++++++++++++-------- docs/pi-supervision-branch.md | 16 ++++----- tests/fm-pi-branch-extension.test.sh | 19 ++++++++++ tests/fm-pi-watch-extension.test.sh | 46 +++++++++++------------- 5 files changed, 82 insertions(+), 56 deletions(-) diff --git a/.pi/extensions/fm-primary-pi-watch.ts b/.pi/extensions/fm-primary-pi-watch.ts index 60344a8d46f..f53e760543c 100644 --- a/.pi/extensions/fm-primary-pi-watch.ts +++ b/.pi/extensions/fm-primary-pi-watch.ts @@ -606,19 +606,20 @@ export default function (pi: ExtensionAPI) { // also let a check-kind trigger itself slip past main's delivery. const isCheckTrigger = /^check:/.test(message); const scope = scopeForUnreadWake(state, heartbeat); - // A signal close containing a needs-decision status file gets the identical - // main-only treatment as a check-kind trigger, without extending that - // classification to heartbeat scans (docs/pi-supervision-branch.md - // "Autonomy"). The message keeps the ordinary "signal:" shape, so - // compare this cycle's status-file basenames with the needs-decision keys. - const signalKeys = /^signal:/.test(message) + // A signal close containing a needs-decision status file, or a stale close + // for a captain-held task, gets the identical main-only treatment as a + // check-kind trigger. Cross-reference only this trigger's keys so independent + // unread scans and heartbeat handling remain unchanged. + const triggerKeys = /^signal:/.test(message) ? message .slice("signal:".length) .split(/\s+/) .filter(Boolean) .map((path) => path.split("/").pop() ?? path) - : []; - const isNeedsDecisionTrigger = signalKeys.some((key) => scope.needsDecisionKeys.includes(key)); + : /^stale:/.test(message) + ? [message.slice("stale:".length).trim().split(/\s+/, 1)[0]].filter(Boolean) + : []; + const isNeedsDecisionTrigger = triggerKeys.some((key) => scope.needsDecisionKeys.includes(key)); const eligible = !isCheckTrigger && !isNeedsDecisionTrigger && scope.eligible; const offer = createBranchDispatchOffer(message, scope.projects, heartbeat, eligible); pi.events?.emit?.(FM_BRANCH_DISPATCH_EVENT, offer); diff --git a/.pi/extensions/lib/fm-branch-dispatch.ts b/.pi/extensions/lib/fm-branch-dispatch.ts index 8b5d41b29be..f530fee1e36 100644 --- a/.pi/extensions/lib/fm-branch-dispatch.ts +++ b/.pi/extensions/lib/fm-branch-dispatch.ts @@ -1,4 +1,4 @@ -import { readdirSync, readFileSync } from "node:fs"; +import { existsSync, readdirSync, readFileSync } from "node:fs"; import { runCommandAsync } from "./fm-async-exec.ts"; // Shared wake-dispatch handshake between the Pi watcher extension (the @@ -55,12 +55,11 @@ export interface UnreadWakeScope { */ corrupted: boolean; /** - * The exact "key" field (status-file basename) of every signal row this scan - * excluded because its payload is "needs-decision:"-prefixed - * (bin/fm-watch.sh's signal_files_actionable). fm-primary-pi-watch.ts's - * offerWakeToBranch cross-references this against the current trigger's own - * file list so a needs-decision trigger is forced to main exactly like a - * check-kind trigger, without the wake message text itself ever changing. + * The exact "key" field of every decision-owned signal or stale row this + * scan excluded. Signal rows are marked by bin/fm-watch.sh; stale rows are + * decision-owned when their task's current declaration is captain-held. + * fm-primary-pi-watch.ts cross-references these keys against the current + * trigger so its entire coalesced batch is forced to main. */ needsDecisionKeys: string[]; } @@ -97,13 +96,11 @@ const UNSAFE_SCOPE: UnreadWakeScope = { // (fm-primary-pi-watch.ts forces every check-kind TRIGGER to main), so nothing // starves by being left behind. // -// A signal-kind row whose payload is "needs-decision:"-prefixed - a task-local -// needs-decision status append surfaced by bin/fm-watch.sh's -// signal_files_actionable - gets the identical treatment: excluded from -// eligibleSeqs, never a scan veto, and forced to main on its own triggering -// close (fm-primary-pi-watch.ts's offerWakeToBranch). This classification is -// intentionally limited to marked signal rows; stale and heartbeat rows keep -// their existing eligibility rules. +// A signal row whose payload is "needs-decision:"-prefixed, or a stale row +// for a task whose current declaration is captain-held, gets the identical +// treatment: excluded from eligibleSeqs, never a scan veto, and forced to main +// on its own triggering close (fm-primary-pi-watch.ts's offerWakeToBranch). +// Heartbeat handling remains independent. // // That applies to a heartbeat review too, and it is the whole point: a // heartbeat used to be deferred to main merely because some unrelated check @@ -193,6 +190,21 @@ export function scopeForUnreadWake(state: string, heartbeat: boolean): UnreadWak } else if (kind === "stale") { task = taskByKey.get(key) ?? taskByKey.get(key.replace(/^fm-/, "")) ?? ""; project = metadata.get(key) ?? metadata.get(key.replace(/^fm-/, "")) ?? ""; + if (task) { + const statusPath = `${state}/${task}.status`; + if (existsSync(statusPath)) { + let statusLines: string[]; + try { + statusLines = readFileSync(statusPath, "utf8").split(/\r?\n/).filter(Boolean); + } catch { + return UNSAFE_SCOPE; + } + if (/^captain-held(?:\s|\[|:)/.test(statusLines.at(-1) ?? "")) { + needsDecisionKeys.push(key); + continue; + } + } + } } else { // A kind fm_wake_append never emits: structural corruption, not an // ordinary main-only row. diff --git a/docs/pi-supervision-branch.md b/docs/pi-supervision-branch.md index d7ae6a83edb..4dbae331862 100644 --- a/docs/pi-supervision-branch.md +++ b/docs/pi-supervision-branch.md @@ -24,10 +24,10 @@ The supervision branch itself is Pi-only by construction: - Wake dispatch: `.pi/extensions/fm-primary-pi-watch.ts` stays the dispatcher; `.pi/extensions/lib/fm-branch-dispatch.ts` owns the offer handshake and row eligibility, while [`watcher-continuity.md`](watcher-continuity.md#per-actor-acknowledgement) owns the per-actor consume contract. A successful row grant transfers ownership of exactly the currently branch-eligible rows to the branch; a check-kind triggering close (merge-confirmation polls, Relay mentions, credential/auth failures, and every other legitimately main-only class) is never offered even when other rows are eligible, no acceptor (extension absent, away mode, branch broken) keeps today's wake-to-main path for that close, and watcher-failure alarms always go to main because only main can repair the watcher cycle. A `needs-decision:` event surfaced by `bin/fm-watch.sh`'s actionable signal path gets the identical treatment even though it keeps the ordinary `signal` kind. - `signal_files_actionable` marks that signal row's queued payload `needs-decision:` instead of `signal:`, and `scopeForUnreadWake` excludes the marked row from what the branch may claim. - The dispatcher cross-references the trigger's file list against the excluded rows: any signal close containing a decision file goes wholly to main, including a batch that also contains routine signal or stale rows. - The wake message itself remains `signal:`, so other harness-arm scripts see their existing shape. - This main-only classification is specific to marked signal rows; stale and heartbeat rows retain their existing eligibility rules. + `signal_files_actionable` marks that signal row's queued payload `needs-decision:` instead of `signal:`, and `scopeForUnreadWake` excludes the marked row from what the branch may claim. It also excludes a stale row when the mapped task's current declaration is `captain-held`, so the bounded reminder returns to main to answer or release the held decision. + The dispatcher cross-references the trigger's keys against the excluded rows: any signal/stale close containing a decision row goes wholly to main, including a batch that also contains routine rows. + The wake message itself retains its existing shape, so other harness-arm scripts remain unchanged. + Heartbeat handling remains independent. A fleet-wide heartbeat keeps its own all-or-nothing rule (see "Heartbeat routing" below): it takes every branch-ownable unread row or none of them. A co-present main-owned check row no longer defers that review to main, because it is not fleet context the branch is missing and main is woken for it on its own triggering close. - The branch itself: `.pi/extensions/fm-branch-supervision.ts` creates the branch session, serializes wakes, mirrors dialog, and merges outcomes. @@ -56,7 +56,7 @@ The supervision branch itself is Pi-only by construction: - Consistency: `bin/fm-lease-lib.sh` owns the per-task lease contract, the main-only role partition, and the deliberate CONFUSED-AGENT-GRADE threat model these guards target (captain-decided; adversarial-grade separation is out of scope and tracked as follow-up design work); `bin/fm-lease.sh` is the command surface. The guards are wired into `fm-send.sh`, `fm-control.sh`, and `fm-teardown.sh` (overlap, lease-checked, with claim serialization retained through the mutation) and `fm-pr-merge.sh`, `fm-merge-local.sh`, and `fm-spawn.sh` (main-owned, branch refused; a relaunch through `fm-control` stays branch-legal recovery). - Autonomy: supervision is default-on for every task once a Pi primary session owns the fleet lock (docs/configuration.md "Pi supervision branch"); no captain grant file is required. - A fleet-wide heartbeat is separately eligible only when every row other than a check or marked needs-decision signal is a heartbeat row or a resolvable task-local row (see "Heartbeat routing" below); every other fleet-wide or unresolvable wake, and every watcher-failure alarm, stays on main. + A fleet-wide heartbeat is separately eligible only when every row other than a check or decision-owned signal/stale row is a heartbeat row or a resolvable task-local row (see "Heartbeat routing" below); every other fleet-wide or unresolvable wake, and every watcher-failure alarm, stays on main. The branch recomputes eligibility immediately before prompting the branch to drain and publishes the exact eligible row set to `state/.branch-eligible-rows` through `writeEligibleRowsSnapshot`. After an independently eligible wake has already been offered, a newly-arrived main-owned row observed at that pre-drain recheck does not revoke the offer: it is excluded from the eligible set, so whatever else is currently eligible still reaches the branch, and the main-owned row stays queued for main's own drain. [`watcher-continuity.md`](watcher-continuity.md#per-actor-acknowledgement) owns the consume-side guarantee that neither actor can present or acknowledge the other's claim. @@ -128,7 +128,7 @@ Main can read the durable outcome store on demand through its `fm_branch_outcome The cheap bash-level heartbeat scan absorbs a genuinely no-op pass before it reaches Pi, unchanged from before. Only a scan already flagged as possibly captain-relevant emits the bare `heartbeat` wake; `.pi/extensions/fm-primary-pi-watch.ts` flags that offer `heartbeat: true`, and the branch accepts it without a project only when every branch-ownable row observed in the unread-queue eligibility check is either heartbeat-kind or a resolvable task-local signal or stale event. -A heartbeat is never vetoed or ridden into main by a co-present check row or marked needs-decision signal row. +A heartbeat is never vetoed or ridden into main by a co-present check row or decision-owned signal/stale row. Those rows are permanently main-owned in every mode: they are excluded from what the branch may claim and left queued for main, which is woken for each on its own watcher cycle, so nothing starves by being left behind. Deferring the fleet review to main merely because some unrelated merge poll or Relay mention happened to be sitting unread put a routine review in the captain's chat for a reason that had nothing to do with the fleet, and that coupling is gone. What all-or-nothing still guarantees is unchanged: the branch takes every branch-ownable unread row or none of them, and an unresolvable task-local row, an unknown row kind, or an unreadable queue still defers the whole review to main. @@ -151,11 +151,11 @@ 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, 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, model and effort selection, and (in `test_branch_dispatch_classifies_main_only_rows_and_writes_the_eligible_snapshot`) a needs-decision signal row's exclusion from `eligibleSeqs`, its presence in `needsDecisionKeys`, that it never vetoes an unrelated eligible row, and that a needs-decision-only queue reads as ordinary main-only absence. +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, model and effort selection, and (in `test_branch_dispatch_classifies_main_only_rows_and_writes_the_eligible_snapshot`) decision-owned signal and captain-held stale rows' exclusion from `eligibleSeqs`, their presence in `needsDecisionKeys`, non-vetoing behavior for unrelated eligible rows, and decision-only queues reading as ordinary main-only absence. `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-main-only-rows, main-only-check-class, needs-decision-signal-stays-on-main, and mixed-signal-routing tests remain in `tests/fm-pi-watch-extension.test.sh` (the last two exercise `offerWakeToBranch`'s file-list cross-reference end to end), 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`. +The branch-offer, heartbeat-offer, heartbeat-not-ridden-by-main-only-rows, main-only-check-class, captain-held-stale-stays-on-main, and mixed-signal-routing tests remain in `tests/fm-pi-watch-extension.test.sh` (the last two routing classes exercise `offerWakeToBranch`'s trigger-key cross-reference end to end), 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`. It also covers the off-thread delivery contract behaviorally: that a delivery leaves the event loop running rather than blocking it, that interleaved reports stay ordered and exactly once, that a session replaced mid-delivery neither loses nor duplicates an outcome, and that a failing store script surfaces without losing or doubling one. `tests/fm-watch-triage.test.sh` covers `bin/fm-watch.sh`'s side of the contract end to end: a needs-decision signal row's queued payload is marked `needs-decision:`, a needs-decision whose key transition was rejected by the reserved-key vocabulary (fm-classify-lib.sh's "reconciliation-required: " wrapper) is still marked, and a routine captain-relevant signal's payload stays unmarked. Live guards: `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. diff --git a/tests/fm-pi-branch-extension.test.sh b/tests/fm-pi-branch-extension.test.sh index fa4e7f39ed3..a4845d174dc 100644 --- a/tests/fm-pi-branch-extension.test.sh +++ b/tests/fm-pi-branch-extension.test.sh @@ -3771,6 +3771,25 @@ if (needsDecisionOnly.eligible || needsDecisionOnly.eligibleSeqs.length !== 0 || throw new Error(`a needs-decision-only queue must be ordinary main-only absence: ${JSON.stringify(needsDecisionOnly)}`); } +// A captain-held task's bounded stale recheck is itself a decision wake. It is +// excluded while an unrelated routine row remains independently branch-owned. +writeFileSync(`${state}/task-a.status`, "captain-held [key=route]: awaiting the captain\n"); +writeFileSync( + `${state}/.wake-queue`, + [ + "1\t1\tstale\tfm-window\tstale: fm-window (awaiting the captain)", + "1\t2\tsignal\ttask-a.status\tsignal: routine follow-up", + ].join("\n"), +); +const captainHeldMixed = scopeForUnreadWake(state, false); +if (!captainHeldMixed.eligible || captainHeldMixed.eligibleSeqs.join(",") !== "2") { + throw new Error(`a captain-held stale row was offered to the branch: ${JSON.stringify(captainHeldMixed)}`); +} +if (captainHeldMixed.needsDecisionKeys.join(",") !== "fm-window") { + throw new Error(`the captain-held stale key was not marked main-owned: ${JSON.stringify(captainHeldMixed)}`); +} +writeFileSync(`${state}/task-a.status`, "working: routine work\n"); + writeFileSync( `${state}/.wake-queue`, [ diff --git a/tests/fm-pi-watch-extension.test.sh b/tests/fm-pi-watch-extension.test.sh index a39d9f56a62..7b216b03e5d 100755 --- a/tests/fm-pi-watch-extension.test.sh +++ b/tests/fm-pi-watch-extension.test.sh @@ -810,25 +810,20 @@ CLASSES pass "every main-only check class still reaches main, never the supervision branch" } -# A needs-decision status append surfaced through an actionable signal must -# reach main directly, exactly like a check-kind trigger, never taking the -# supervision-branch hop first -# (docs/pi-supervision-branch.md "Autonomy"). Unlike a check row it shares the -# ordinary "signal:" kind and wake-message shape, so the dispatcher tells it -# apart by payload (fm-branch-dispatch.ts's needsDecisionKeys) instead of by -# message prefix - this exercises that cross-reference end to end. An unrelated -# eligible stale row sits in the same queue to prove it is only the -# needs-decision TRIGGER itself that stays on main, not the whole scan. -test_pi_needs_decision_signal_stays_on_main() { +# A captain-held task's bounded stale reminder is a decision trigger and must +# wake main directly. A co-present routine signal remains independently +# branch-ownable but cannot take this stale close away from main. +test_pi_captain_held_stale_stays_on_main() { local repo home plugin log stop out status - repo="$TMP_ROOT/pi-needs-decision-root" - home="$TMP_ROOT/pi-needs-decision-home" - log="$TMP_ROOT/pi-needs-decision.log" - stop="$TMP_ROOT/pi-needs-decision.stop" + repo="$TMP_ROOT/pi-captain-held-root" + home="$TMP_ROOT/pi-captain-held-home" + log="$TMP_ROOT/pi-captain-held.log" + stop="$TMP_ROOT/pi-captain-held.stop" mkdir -p "$repo/bin" "$home/state" "$home/config" "$home/projects/approved" install_pi_watch_extension_fixture "$repo" plugin="$repo/.pi/extensions/fm-primary-pi-watch.ts" printf 'project=%s/projects/approved\nwindow=fm-window\n' "$home" > "$home/state/task-a.meta" + printf 'captain-held [key=route]: awaiting the captain\n' > "$home/state/task-a.status" cat > "$repo/bin/fm-watch-arm.sh" <<'SH' #!/usr/bin/env bash if [ "${1:-}" = --handling-delivered ]; then exit 0; fi @@ -836,7 +831,7 @@ printf 'arm=%s\n' "$$" >> "${FM_ARM_LOG:?}" count=$(grep -c '^arm=' "$FM_ARM_LOG") if [ "$count" -eq 1 ]; then printf 'watcher: started pid=%s (beacon fresh)\n' "$$" - printf 'signal: task-a.status\n' + printf 'stale: fm-window (captain-held, awaiting the captain)\n' exit 0 fi printf 'watcher: started pid=%s (beacon fresh) recovery-generation=fixture-generation\n' "$$" @@ -878,32 +873,31 @@ const pi = { }, }; writeFileSync(`${process.env.FM_HOME}/state/.lock`, `${process.pid}\n`); -// the task-a.status row (the needs-decision trigger) sits alongside an -// unrelated eligible stale row for the same project. writeFileSync( `${process.env.FM_HOME}/state/.wake-queue`, - "1\t1\tstale\tfm-window\tstale: fm-window\n2\t2\tsignal\ttask-a.status\tneeds-decision: task-a.status\n", + "1\t1\tstale\tfm-window\tstale: fm-window (captain-held)\n" + + "1\t2\tsignal\ttask-a.status\tsignal: routine follow-up\n", ); const mod = await import(pathToFileURL(process.env.PLUGIN).href); mod.default(pi); -await tool.execute("tool-call-needs-decision", {}, undefined, undefined, {}); +await tool.execute("tool-call-captain-held", {}, undefined, undefined, {}); for (let i = 0; i < 250 && !prompt; i += 1) { await new Promise((resolve) => setTimeout(resolve, 10)); } if (offers.length !== 1 || offers[0].eligible !== false) { - throw new Error(`a needs-decision trigger was offered to the branch: ${JSON.stringify(offers)}`); + throw new Error(`a captain-held stale trigger was offered to the branch: ${JSON.stringify(offers)}`); } -if (!prompt.includes("FIRSTMATE WATCHER WAKE: signal: task-a.status")) { - throw new Error(`a needs-decision trigger did not reach main: ${prompt}`); +if (!prompt.includes("FIRSTMATE WATCHER WAKE: stale: fm-window")) { + throw new Error(`a captain-held stale trigger did not reach main: ${prompt}`); } writeFileSync(process.env.FM_STOP_FILE, "stop\n"); process.exit(0); EOF ) status=$? - expect_code 0 "$status" "a needs-decision signal trigger must stay on main: $out" - [ -z "$out" ] || fail "Pi needs-decision test printed output: $out" - pass "a needs-decision signal trigger reaches main even with an unrelated eligible row" + expect_code 0 "$status" "a captain-held stale trigger must stay on main: $out" + [ -z "$out" ] || fail "Pi captain-held test printed output: $out" + pass "a captain-held stale trigger reaches main even with an unrelated eligible row" } # A routine row can remain unread when the same status file raises a decision. @@ -3988,7 +3982,7 @@ test_pi_branch_offer_owns_actionable_wake test_pi_branch_offer_flags_heartbeat test_pi_heartbeat_is_not_ridden_into_main_by_a_co_present_check test_pi_main_only_check_classes_stay_on_main -test_pi_needs_decision_signal_stays_on_main +test_pi_captain_held_stale_stays_on_main test_pi_same_key_mixed_signal_routes_whole_batch_to_main test_pi_distinct_files_mixed_batch_routes_whole_batch_to_main test_pi_heartbeat_is_not_ridden_into_main_by_a_co_present_needs_decision From e2d00c03863ad62f3b2995fe95f01a3c107275a4 Mon Sep 17 00:00:00 2001 From: kunchenguid Date: Fri, 4 Sep 2026 20:20:04 -0700 Subject: [PATCH 05/17] no-mistakes(ci): Fixed CI lint by narrowly suppressing false-positive SC2031 diagnostics where background PIDs are captured immediately in the same shell. Verified with `CI=true bin/fm-lint.sh` and `git diff --check`. The no-mistakes attestation failure is pipeline-state-related (`test` was skipped), not a source defect --- tests/fm-afk-launch.test.sh | 21 +++++++++++++++++---- tests/fm-secondmate-reconcile.test.sh | 2 ++ tests/fm-secondmate-safety.test.sh | 3 +++ 3 files changed, 22 insertions(+), 4 deletions(-) diff --git a/tests/fm-afk-launch.test.sh b/tests/fm-afk-launch.test.sh index 6d0c7bd9d1a..5aa541bc7d7 100755 --- a/tests/fm-afk-launch.test.sh +++ b/tests/fm-afk-launch.test.sh @@ -162,6 +162,7 @@ unit_stop_ordering() { trap "if [ -f \"$1/state/.afk\" ]; then echo present > \"$2\"; else echo absent > \"$2\"; fi; exit 0" TERM while :; do sleep 0.2; done ' _ "$st" "$marker" & + # shellcheck disable=SC2031 # The background PID is captured immediately in this shell. daemon_pid=$! lock="$st/state/.supervise-daemon.lock" mkdir -p "$lock" @@ -196,6 +197,7 @@ unit_stop_rejects_reused_pid() { mkdir -p "$st/state" date '+%s' > "$st/state/.afk" sleep 600 & + # shellcheck disable=SC2031 # The background PID is captured immediately in this shell. sleeper_pid=$! lock="$st/state/.supervise-daemon.lock" mkdir -p "$lock" @@ -240,9 +242,13 @@ unit_concurrent_start_serialized() { TRACK_TMUX_SESSIONS="$TRACK_TMUX_SESSIONS $cap_session" cap_pane=$(tmux display-message -p -t "$cap_session" '#{pane_id}') FM_HOME="$st" FM_STATE_OVERRIDE="$st/state" FM_SUPERVISOR_TARGET="$cap_pane" \ - FM_SUPERVISOR_BACKEND=tmux FM_AFK_LAUNCH_ENTRY="$SLEEPER" "$LAUNCH" start >/dev/null 2>&1 & first=$! + FM_SUPERVISOR_BACKEND=tmux FM_AFK_LAUNCH_ENTRY="$SLEEPER" "$LAUNCH" start >/dev/null 2>&1 & + # shellcheck disable=SC2031 # The background PID is captured immediately in this shell. + first=$! FM_HOME="$st" FM_STATE_OVERRIDE="$st/state" FM_SUPERVISOR_TARGET="$cap_pane" \ - FM_SUPERVISOR_BACKEND=tmux FM_AFK_LAUNCH_ENTRY="$SLEEPER" "$LAUNCH" start >/dev/null 2>&1 & second=$! + FM_SUPERVISOR_BACKEND=tmux FM_AFK_LAUNCH_ENTRY="$SLEEPER" "$LAUNCH" start >/dev/null 2>&1 & + # shellcheck disable=SC2031 # The background PID is captured immediately in this shell. + second=$! wait "$first"; wait "$second" rec=$(cut -f2 "$st/state/.afk-daemon-terminal" 2>/dev/null || true) count=$(tmux list-sessions -F '#{session_name}' 2>/dev/null | awk -v expected="$rec" '$0 == expected {n++} END{print n+0}') @@ -273,6 +279,7 @@ unit_lock_initialization_grace() { rm -rf "$st/state/.afk-launch.lock" fi ) & + # shellcheck disable=SC2031 # The background PID is captured immediately in this shell. initializer=$! # shellcheck disable=SC2031 # The initializer communicates through this shared file path. if FM_HOME="$st" FM_STATE_OVERRIDE="$st/state" bash -c ' @@ -298,6 +305,7 @@ unit_signal_exits_with_lock_cleanup() { fm_afk_launch_main start : > "$2" ' _ "$LAUNCH" "$marker" & + # shellcheck disable=SC2031 # The background PID is captured immediately in this shell. child=$! # Signal only once the lifecycle actually holds its lock. Killing before the # lock exists tests nothing, and on a loaded machine it used to race: the @@ -651,7 +659,9 @@ unit_stop_validates_before_signal() { mkdir -p "$st/state" : > "$st/state/.afk" printf 'tmux\tonly-two-fields\n' > "$st/state/.afk-daemon-terminal" - sleep 30 & sleeper_pid=$! + sleep 30 & + # shellcheck disable=SC2031 # The background PID is captured immediately in this shell. + sleeper_pid=$! mkdir -p "$st/state/.supervise-daemon.lock" printf '%s' "$sleeper_pid" > "$st/state/.supervise-daemon.lock/pid" ( . "$ROOT/bin/fm-wake-lib.sh"; fm_pid_identity "$sleeper_pid" > "$st/state/.supervise-daemon.lock/pid-identity" ) @@ -706,6 +716,7 @@ unit_stop_confirms_daemon_exit() { : > "$st/state/.afk" printf 'none\t-\tnative\n' > "$st/state/.afk-daemon-terminal" bash -c 'trap "" TERM; while :; do sleep 1; done' & + # shellcheck disable=SC2031 # The background PID is captured immediately in this shell. daemon_pid=$! printf '%s' "$daemon_pid" > "$st/state/.supervise-daemon.lock/pid" ( . "$ROOT/bin/fm-wake-lib.sh"; fm_pid_identity "$daemon_pid" > "$st/state/.supervise-daemon.lock/pid-identity" ) @@ -737,7 +748,9 @@ unit_refresh_validates_record() { st=$(mktemp -d "${TMPDIR:-/tmp}/fm-afk-refresh-record.XXXXXX") mkdir -p "$st/state/.supervise-daemon.lock" printf 'tmux\tonly-two-fields\n' > "$st/state/.afk-daemon-terminal" - sleep 30 & daemon_pid=$! + sleep 30 & + # shellcheck disable=SC2031 # The background PID is captured immediately in this shell. + daemon_pid=$! printf '%s' "$daemon_pid" > "$st/state/.supervise-daemon.lock/pid" ( . "$ROOT/bin/fm-wake-lib.sh"; fm_pid_identity "$daemon_pid" > "$st/state/.supervise-daemon.lock/pid-identity" ) if FM_HOME="$st" FM_STATE_OVERRIDE="$st/state" FM_SUPERVISOR_TARGET=unused \ diff --git a/tests/fm-secondmate-reconcile.test.sh b/tests/fm-secondmate-reconcile.test.sh index 7cc148b2dff..000686500c4 100755 --- a/tests/fm-secondmate-reconcile.test.sh +++ b/tests/fm-secondmate-reconcile.test.sh @@ -563,6 +563,7 @@ META fm_lock_release "$home/state/.control-mate.lock" : > "$lifecycle_done" ) & + # shellcheck disable=SC2031 # The background PID is captured immediately in this shell. lifecycle_pid=$! sleep 0.1 @@ -663,6 +664,7 @@ SH rc=0 FM_RECONCILE_RACE_SIGNAL="$signal" FM_RECONCILE_RACE_RELEASE="$release" \ run_remote_notify "$home" "$fakebin" "$snap" > "$home/notify.out" 2>&1 & + # shellcheck disable=SC2031 # The background PID is captured immediately in this shell. notify_pid=$! while [ ! -f "$signal" ]; do kill -0 "$notify_pid" 2>/dev/null || fail "reconcile exited before entering fm-send" diff --git a/tests/fm-secondmate-safety.test.sh b/tests/fm-secondmate-safety.test.sh index 9b97b21e568..7121ee537e5 100755 --- a/tests/fm-secondmate-safety.test.sh +++ b/tests/fm-secondmate-safety.test.sh @@ -2350,6 +2350,7 @@ hold_task_set_lock() { # -> echoes " " fm_lock_try_acquire "$lock" || exit 1 sleep 30 ) >/dev/null 2>&1 & + # shellcheck disable=SC2031 # The background PID is captured immediately in this shell. holder=$! while [ ! -e "$lock" ] && [ "$i" -lt 100 ]; do sleep 0.1 @@ -2464,6 +2465,7 @@ SH XDG_STATE_HOME="$TMP_ROOT/taskset-state-absent-xdg" \ FM_TASK_SET_TEST_READY="$ready" FM_TASK_SET_TEST_RELEASE="$release" \ "$ROOT/bin/fm-teardown.sh" domain --force >/dev/null 2>"$err" & + # shellcheck disable=SC2031 # The background PID is captured immediately in this shell. pid=$! while [ ! -e "$ready" ] && kill -0 "$pid" 2>/dev/null && [ "$i" -lt 200 ]; do sleep 0.05 @@ -2737,6 +2739,7 @@ EOF out="$TMP_ROOT/watch-fake/watch.out" PATH="$fakebin:$PATH" FM_HOME="$home" FM_FAKE_TMUX_WINDOW="$window" FM_FAKE_TMUX_LOG="$TMP_ROOT/watch-fake/tmux.log" FM_FAKE_TMUX_CAPTURE="$TMP_ROOT/watch-fake/pane.txt" \ FM_POLL=1 FM_SIGNAL_GRACE=1 FM_CHECK_INTERVAL=999999 FM_HEARTBEAT=999999 "$ROOT/bin/fm-watch.sh" > "$out" & + # shellcheck disable=SC2031 # The background PID is captured immediately in this shell. pid=$! if ! wait_live "$pid" 25; then wait "$pid" || true From 5ab462870e30a779e1f7e3a273008648a5dc39f2 Mon Sep 17 00:00:00 2001 From: kunchenguid Date: Sat, 5 Sep 2026 00:00:47 -0700 Subject: [PATCH 06/17] no-mistakes(review): Route stale open decisions directly to main --- .pi/extensions/lib/fm-branch-dispatch.ts | 57 ++++++++++++++++++++---- tests/fm-pi-branch-extension.test.sh | 14 ++++++ tests/fm-pi-watch-extension.test.sh | 35 ++++++++------- 3 files changed, 81 insertions(+), 25 deletions(-) diff --git a/.pi/extensions/lib/fm-branch-dispatch.ts b/.pi/extensions/lib/fm-branch-dispatch.ts index f530fee1e36..26d11b47c89 100644 --- a/.pi/extensions/lib/fm-branch-dispatch.ts +++ b/.pi/extensions/lib/fm-branch-dispatch.ts @@ -57,9 +57,10 @@ export interface UnreadWakeScope { /** * The exact "key" field of every decision-owned signal or stale row this * scan excluded. Signal rows are marked by bin/fm-watch.sh; stale rows are - * decision-owned when their task's current declaration is captain-held. - * fm-primary-pi-watch.ts cross-references these keys against the current - * trigger so its entire coalesced batch is forced to main. + * decision-owned when their task has an open needs-decision or its current + * declaration is captain-held. fm-primary-pi-watch.ts cross-references these + * keys against the current trigger so its entire coalesced batch is forced + * to main. */ needsDecisionKeys: string[]; } @@ -97,10 +98,10 @@ const UNSAFE_SCOPE: UnreadWakeScope = { // starves by being left behind. // // A signal row whose payload is "needs-decision:"-prefixed, or a stale row -// for a task whose current declaration is captain-held, gets the identical -// treatment: excluded from eligibleSeqs, never a scan veto, and forced to main -// on its own triggering close (fm-primary-pi-watch.ts's offerWakeToBranch). -// Heartbeat handling remains independent. +// for a task with an open needs-decision or a current captain-held declaration, +// gets the identical treatment: excluded from eligibleSeqs, never a scan veto, +// and forced to main on its own triggering close (fm-primary-pi-watch.ts's +// offerWakeToBranch). Heartbeat handling remains independent. // // That applies to a heartbeat review too, and it is the whole point: a // heartbeat used to be deferred to main merely because some unrelated check @@ -118,6 +119,46 @@ const UNSAFE_SCOPE: UnreadWakeScope = { // this repo's fm_wake_append could never have produced (an unknown kind, or a // line that fails the structural tab-field check) also still vetoes the whole // scan - that is queue corruption, not an everyday mixed queue. +function statusLineVerb(line: string): string { + const beforeColon = line.split(":", 1)[0].split("[", 1)[0].trim(); + const words = beforeColon.split(/\s+/); + if (!words.some((word) => word.startsWith("corr="))) return beforeColon; + return words.filter((word, index) => index === 0 || !/^corr=[0-9a-f]{16}$/i.test(word)).join(" "); +} + +function decisionKey(line: string): string | null { + const colon = line.indexOf(":"); + const beforeColon = colon < 0 ? line : line.slice(0, colon); + const beforeMatch = beforeColon.match(/\[key=([^\]]*)\]/); + const noteMatch = beforeMatch || colon < 0 ? null : line.slice(colon + 1).trimStart().match(/^\[key=([^\]]*)\]/); + const key = (beforeMatch ?? noteMatch)?.[1] ?? "default"; + return /^[A-Za-z0-9._-]+$/.test(key) ? key : null; +} + +function statusLineNote(line: string): string { + const colon = line.indexOf(":"); + if (colon < 0) return line; + const note = line.slice(colon + 1).trimStart(); + if (/\[key=[^\]]*\]/.test(line.slice(0, colon))) return note; + const match = note.match(/^\[key=([A-Za-z0-9._-]+)\]/); + return match ? note.slice(match[0].length).trimStart() : note; +} + +function hasOpenNeedsDecision(lines: readonly string[]): boolean { + const open = new Map(); + for (const line of lines) { + const verb = statusLineVerb(line); + if (!["needs-decision", "blocked", "resolved", "captain-held"].includes(verb)) continue; + const key = decisionKey(line); + if (!key) continue; + const note = statusLineNote(line); + if (key.startsWith("pending-reply-") && !/^pending-reply-.*:/.test(note)) continue; + if (verb === "needs-decision" || verb === "blocked") open.set(key, verb); + else open.delete(key); + } + return [...open.values()].includes("needs-decision"); +} + export function scopeForUnreadWake(state: string, heartbeat: boolean): UnreadWakeScope { let queue = ""; try { @@ -199,7 +240,7 @@ export function scopeForUnreadWake(state: string, heartbeat: boolean): UnreadWak } catch { return UNSAFE_SCOPE; } - if (/^captain-held(?:\s|\[|:)/.test(statusLines.at(-1) ?? "")) { + if (hasOpenNeedsDecision(statusLines) || /^captain-held(?:\s|\[|:)/.test(statusLines.at(-1) ?? "")) { needsDecisionKeys.push(key); continue; } diff --git a/tests/fm-pi-branch-extension.test.sh b/tests/fm-pi-branch-extension.test.sh index a4845d174dc..ee22235a552 100644 --- a/tests/fm-pi-branch-extension.test.sh +++ b/tests/fm-pi-branch-extension.test.sh @@ -3788,6 +3788,20 @@ if (!captainHeldMixed.eligible || captainHeldMixed.eligibleSeqs.join(",") !== "2 if (captainHeldMixed.needsDecisionKeys.join(",") !== "fm-window") { throw new Error(`the captain-held stale key was not marked main-owned: ${JSON.stringify(captainHeldMixed)}`); } + +// A later unrelated status does not mask a still-open durable decision. The +// stale row remains main-owned while the routine signal stays branch-owned. +writeFileSync( + `${state}/task-a.status`, + "needs-decision [key=cleanup]: choose destructive cleanup\nworking: routine follow-up\n", +); +const openDecisionMixed = scopeForUnreadWake(state, false); +if (!openDecisionMixed.eligible || openDecisionMixed.eligibleSeqs.join(",") !== "2") { + throw new Error(`an open-decision stale row was offered to the branch: ${JSON.stringify(openDecisionMixed)}`); +} +if (openDecisionMixed.needsDecisionKeys.join(",") !== "fm-window") { + throw new Error(`the open-decision stale key was not marked main-owned: ${JSON.stringify(openDecisionMixed)}`); +} writeFileSync(`${state}/task-a.status`, "working: routine work\n"); writeFileSync( diff --git a/tests/fm-pi-watch-extension.test.sh b/tests/fm-pi-watch-extension.test.sh index 7b216b03e5d..00ea93c474f 100755 --- a/tests/fm-pi-watch-extension.test.sh +++ b/tests/fm-pi-watch-extension.test.sh @@ -810,20 +810,21 @@ CLASSES pass "every main-only check class still reaches main, never the supervision branch" } -# A captain-held task's bounded stale reminder is a decision trigger and must -# wake main directly. A co-present routine signal remains independently -# branch-ownable but cannot take this stale close away from main. -test_pi_captain_held_stale_stays_on_main() { +# A task's stale reminder remains a decision trigger while an earlier durable +# needs-decision is open, even when a later unrelated status follows it. A +# co-present routine signal remains independently branch-ownable but cannot +# take this stale close away from main. +test_pi_open_decision_stale_stays_on_main() { local repo home plugin log stop out status - repo="$TMP_ROOT/pi-captain-held-root" - home="$TMP_ROOT/pi-captain-held-home" - log="$TMP_ROOT/pi-captain-held.log" - stop="$TMP_ROOT/pi-captain-held.stop" + repo="$TMP_ROOT/pi-open-decision-stale-root" + home="$TMP_ROOT/pi-open-decision-stale-home" + log="$TMP_ROOT/pi-open-decision-stale.log" + stop="$TMP_ROOT/pi-open-decision-stale.stop" mkdir -p "$repo/bin" "$home/state" "$home/config" "$home/projects/approved" install_pi_watch_extension_fixture "$repo" plugin="$repo/.pi/extensions/fm-primary-pi-watch.ts" printf 'project=%s/projects/approved\nwindow=fm-window\n' "$home" > "$home/state/task-a.meta" - printf 'captain-held [key=route]: awaiting the captain\n' > "$home/state/task-a.status" + printf 'needs-decision [key=cleanup]: choose destructive cleanup\nworking: routine follow-up\n' > "$home/state/task-a.status" cat > "$repo/bin/fm-watch-arm.sh" <<'SH' #!/usr/bin/env bash if [ "${1:-}" = --handling-delivered ]; then exit 0; fi @@ -831,7 +832,7 @@ printf 'arm=%s\n' "$$" >> "${FM_ARM_LOG:?}" count=$(grep -c '^arm=' "$FM_ARM_LOG") if [ "$count" -eq 1 ]; then printf 'watcher: started pid=%s (beacon fresh)\n' "$$" - printf 'stale: fm-window (captain-held, awaiting the captain)\n' + printf 'stale: fm-window (routine follow-up)\n' exit 0 fi printf 'watcher: started pid=%s (beacon fresh) recovery-generation=fixture-generation\n' "$$" @@ -880,24 +881,24 @@ writeFileSync( ); const mod = await import(pathToFileURL(process.env.PLUGIN).href); mod.default(pi); -await tool.execute("tool-call-captain-held", {}, undefined, undefined, {}); +await tool.execute("tool-call-open-decision-stale", {}, undefined, undefined, {}); for (let i = 0; i < 250 && !prompt; i += 1) { await new Promise((resolve) => setTimeout(resolve, 10)); } if (offers.length !== 1 || offers[0].eligible !== false) { - throw new Error(`a captain-held stale trigger was offered to the branch: ${JSON.stringify(offers)}`); + throw new Error(`an open-decision stale trigger was offered to the branch: ${JSON.stringify(offers)}`); } if (!prompt.includes("FIRSTMATE WATCHER WAKE: stale: fm-window")) { - throw new Error(`a captain-held stale trigger did not reach main: ${prompt}`); + throw new Error(`an open-decision stale trigger did not reach main: ${prompt}`); } writeFileSync(process.env.FM_STOP_FILE, "stop\n"); process.exit(0); EOF ) status=$? - expect_code 0 "$status" "a captain-held stale trigger must stay on main: $out" - [ -z "$out" ] || fail "Pi captain-held test printed output: $out" - pass "a captain-held stale trigger reaches main even with an unrelated eligible row" + expect_code 0 "$status" "an open-decision stale trigger must stay on main: $out" + [ -z "$out" ] || fail "Pi open-decision stale test printed output: $out" + pass "an open-decision stale trigger reaches main even with an unrelated eligible row" } # A routine row can remain unread when the same status file raises a decision. @@ -3982,7 +3983,7 @@ test_pi_branch_offer_owns_actionable_wake test_pi_branch_offer_flags_heartbeat test_pi_heartbeat_is_not_ridden_into_main_by_a_co_present_check test_pi_main_only_check_classes_stay_on_main -test_pi_captain_held_stale_stays_on_main +test_pi_open_decision_stale_stays_on_main test_pi_same_key_mixed_signal_routes_whole_batch_to_main test_pi_distinct_files_mixed_batch_routes_whole_batch_to_main test_pi_heartbeat_is_not_ridden_into_main_by_a_co_present_needs_decision From a0b8985aa0388bad6b99e86631d048715dc8645a Mon Sep 17 00:00:00 2001 From: kunchenguid Date: Sat, 5 Sep 2026 00:08:19 -0700 Subject: [PATCH 07/17] no-mistakes(review): Honor configured verbs in stale decision routing --- .pi/extensions/lib/fm-branch-dispatch.ts | 9 ++++++--- tests/fm-pi-branch-extension.test.sh | 21 +++++++++++++++++++++ 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/.pi/extensions/lib/fm-branch-dispatch.ts b/.pi/extensions/lib/fm-branch-dispatch.ts index 26d11b47c89..7bce9af91da 100644 --- a/.pi/extensions/lib/fm-branch-dispatch.ts +++ b/.pi/extensions/lib/fm-branch-dispatch.ts @@ -144,11 +144,11 @@ function statusLineNote(line: string): string { return match ? note.slice(match[0].length).trimStart() : note; } -function hasOpenNeedsDecision(lines: readonly string[]): boolean { +function hasOpenNeedsDecision(lines: readonly string[], resolveVerb: string, heldVerb: string): boolean { const open = new Map(); for (const line of lines) { const verb = statusLineVerb(line); - if (!["needs-decision", "blocked", "resolved", "captain-held"].includes(verb)) continue; + if (!["needs-decision", "blocked", resolveVerb, heldVerb].includes(verb)) continue; const key = decisionKey(line); if (!key) continue; const note = statusLineNote(line); @@ -240,7 +240,10 @@ export function scopeForUnreadWake(state: string, heartbeat: boolean): UnreadWak } catch { return UNSAFE_SCOPE; } - if (hasOpenNeedsDecision(statusLines) || /^captain-held(?:\s|\[|:)/.test(statusLines.at(-1) ?? "")) { + const resolveVerb = process.env.FM_CLASSIFY_RESOLVE_VERB || "resolved"; + const heldVerb = process.env.FM_CLASSIFY_CAPTAIN_HELD_VERB || "captain-held"; + if (hasOpenNeedsDecision(statusLines, resolveVerb, heldVerb) || + statusLineVerb(statusLines.at(-1) ?? "") === heldVerb) { needsDecisionKeys.push(key); continue; } diff --git a/tests/fm-pi-branch-extension.test.sh b/tests/fm-pi-branch-extension.test.sh index ee22235a552..68b70dd5890 100644 --- a/tests/fm-pi-branch-extension.test.sh +++ b/tests/fm-pi-branch-extension.test.sh @@ -3802,6 +3802,27 @@ if (!openDecisionMixed.eligible || openDecisionMixed.eligibleSeqs.join(",") !== if (openDecisionMixed.needsDecisionKeys.join(",") !== "fm-window") { throw new Error(`the open-decision stale key was not marked main-owned: ${JSON.stringify(openDecisionMixed)}`); } + +process.env.FM_CLASSIFY_RESOLVE_VERB = "answered"; +writeFileSync( + `${state}/task-a.status`, + "needs-decision [key=cleanup]: choose destructive cleanup\nanswered [key=cleanup]: remove generated files\n", +); +const customResolved = scopeForUnreadWake(state, false); +if (!customResolved.eligible || customResolved.eligibleSeqs.slice().sort().join(",") !== "1,2" || + customResolved.needsDecisionKeys.length !== 0) { + throw new Error(`a custom resolution verb left the stale decision open: ${JSON.stringify(customResolved)}`); +} + +process.env.FM_CLASSIFY_CAPTAIN_HELD_VERB = "awaiting-captain"; +writeFileSync(`${state}/task-a.status`, "awaiting-captain [key=cleanup]: awaiting the captain\n"); +const customHeld = scopeForUnreadWake(state, false); +if (!customHeld.eligible || customHeld.eligibleSeqs.join(",") !== "2" || + customHeld.needsDecisionKeys.join(",") !== "fm-window") { + throw new Error(`a custom captain-held verb was offered to the branch: ${JSON.stringify(customHeld)}`); +} +delete process.env.FM_CLASSIFY_RESOLVE_VERB; +delete process.env.FM_CLASSIFY_CAPTAIN_HELD_VERB; writeFileSync(`${state}/task-a.status`, "working: routine work\n"); writeFileSync( From b6def7f1cac7096c76548a56160e28c41271caf7 Mon Sep 17 00:00:00 2001 From: kunchenguid Date: Sat, 5 Sep 2026 00:36:25 -0700 Subject: [PATCH 08/17] no-mistakes(review): Route second-mate escalations and configured decisions to main --- .pi/extensions/lib/fm-branch-dispatch.ts | 15 ++++++++-- bin/fm-classify-lib.sh | 13 ++++++++- tests/fm-pi-branch-extension.test.sh | 12 ++++++++ tests/fm-pi-watch-extension.test.sh | 27 ++++++++++-------- tests/fm-watch-triage.test.sh | 35 ++++++++++++++++++++++++ 5 files changed, 86 insertions(+), 16 deletions(-) diff --git a/.pi/extensions/lib/fm-branch-dispatch.ts b/.pi/extensions/lib/fm-branch-dispatch.ts index 7bce9af91da..7a24c2831d1 100644 --- a/.pi/extensions/lib/fm-branch-dispatch.ts +++ b/.pi/extensions/lib/fm-branch-dispatch.ts @@ -144,7 +144,12 @@ function statusLineNote(line: string): string { return match ? note.slice(match[0].length).trimStart() : note; } -function hasOpenNeedsDecision(lines: readonly string[], resolveVerb: string, heldVerb: string): boolean { +function hasOpenNeedsDecision( + lines: readonly string[], + resolveVerb: string, + heldVerb: string, + reservedPrefixes: readonly string[], +): boolean { const open = new Map(); for (const line of lines) { const verb = statusLineVerb(line); @@ -152,7 +157,8 @@ function hasOpenNeedsDecision(lines: readonly string[], resolveVerb: string, hel const key = decisionKey(line); if (!key) continue; const note = statusLineNote(line); - if (key.startsWith("pending-reply-") && !/^pending-reply-.*:/.test(note)) continue; + const reservedPrefix = reservedPrefixes.find((prefix) => key.startsWith(prefix)); + if (reservedPrefix && !(note.startsWith(reservedPrefix) && note.slice(reservedPrefix.length).includes(":"))) continue; if (verb === "needs-decision" || verb === "blocked") open.set(key, verb); else open.delete(key); } @@ -242,7 +248,10 @@ export function scopeForUnreadWake(state: string, heartbeat: boolean): UnreadWak } const resolveVerb = process.env.FM_CLASSIFY_RESOLVE_VERB || "resolved"; const heldVerb = process.env.FM_CLASSIFY_CAPTAIN_HELD_VERB || "captain-held"; - if (hasOpenNeedsDecision(statusLines, resolveVerb, heldVerb) || + const reservedPrefixes = (process.env.FM_CLASSIFY_RESERVED_KEY_PREFIXES || "pending-reply-") + .split(/\s+/) + .filter(Boolean); + if (hasOpenNeedsDecision(statusLines, resolveVerb, heldVerb, reservedPrefixes) || statusLineVerb(statusLines.at(-1) ?? "") === heldVerb) { needsDecisionKeys.push(key); continue; diff --git a/bin/fm-classify-lib.sh b/bin/fm-classify-lib.sh index 0d4e755f549..9e204de33ca 100755 --- a/bin/fm-classify-lib.sh +++ b/bin/fm-classify-lib.sh @@ -418,6 +418,14 @@ _fm_decision_key_transition_allowed() { # return 0 } +_fm_is_pending_reply_escalation() { # + case "$1" in pending-reply-*) ;; *) return 1 ;; esac + case "$2" in + pending-reply-missed:*|pending-reply-delivery-unknown:*|pending-reply-recovery-delivery-failed:*|pending-reply-recovery-delivery-unknown:*) return 0 ;; + *) return 1 ;; + esac +} + _fm_decision_fold_line() { # local open=$1 line=$2 resolve=$3 held=$4 verb key note # Blank-line guard. A `case` glob answers "does this line hold any non-space @@ -1685,7 +1693,10 @@ EOF [ -n "$live_line" ] && [ "$((prefix_lines + line_number))" -eq "$live_line" ] || continue [ -n "$events" ] && events="${events} ; " events="${events}${line}" - [ "$verb" = needs-decision ] && _fm_span_needs_decision=1 + if [ "$verb" = needs-decision ] || { [ "$verb" = blocked ] && + _fm_is_pending_reply_escalation "$key" "$(status_line_note "$line")"; }; then + _fm_span_needs_decision=1 + fi rc=0 ;; *) diff --git a/tests/fm-pi-branch-extension.test.sh b/tests/fm-pi-branch-extension.test.sh index 68b70dd5890..41eb12f1025 100644 --- a/tests/fm-pi-branch-extension.test.sh +++ b/tests/fm-pi-branch-extension.test.sh @@ -3823,6 +3823,18 @@ if (!customHeld.eligible || customHeld.eligibleSeqs.join(",") !== "2" || } delete process.env.FM_CLASSIFY_RESOLVE_VERB; delete process.env.FM_CLASSIFY_CAPTAIN_HELD_VERB; + +process.env.FM_CLASSIFY_RESERVED_KEY_PREFIXES = "secret-"; +writeFileSync( + `${state}/task-a.status`, + "needs-decision [key=pending-reply-x]: choose destructive cleanup\nworking: routine follow-up\n", +); +const customReservedPrefixes = scopeForUnreadWake(state, false); +if (!customReservedPrefixes.eligible || customReservedPrefixes.eligibleSeqs.join(",") !== "2" || + customReservedPrefixes.needsDecisionKeys.join(",") !== "fm-window") { + throw new Error(`configured reserved prefixes lost an open stale decision: ${JSON.stringify(customReservedPrefixes)}`); +} +delete process.env.FM_CLASSIFY_RESERVED_KEY_PREFIXES; writeFileSync(`${state}/task-a.status`, "working: routine work\n"); writeFileSync( diff --git a/tests/fm-pi-watch-extension.test.sh b/tests/fm-pi-watch-extension.test.sh index 00ea93c474f..2bf5b2a1287 100755 --- a/tests/fm-pi-watch-extension.test.sh +++ b/tests/fm-pi-watch-extension.test.sh @@ -824,7 +824,7 @@ test_pi_open_decision_stale_stays_on_main() { install_pi_watch_extension_fixture "$repo" plugin="$repo/.pi/extensions/fm-primary-pi-watch.ts" printf 'project=%s/projects/approved\nwindow=fm-window\n' "$home" > "$home/state/task-a.meta" - printf 'needs-decision [key=cleanup]: choose destructive cleanup\nworking: routine follow-up\n' > "$home/state/task-a.status" + printf 'needs-decision [key=pending-reply-x]: choose destructive cleanup\nworking: routine follow-up\n' > "$home/state/task-a.status" cat > "$repo/bin/fm-watch-arm.sh" <<'SH' #!/usr/bin/env bash if [ "${1:-}" = --handling-delivered ]; then exit 0; fi @@ -873,10 +873,11 @@ const pi = { prompt = message; }, }; +process.env.FM_CLASSIFY_RESERVED_KEY_PREFIXES = "secret-"; writeFileSync(`${process.env.FM_HOME}/state/.lock`, `${process.pid}\n`); writeFileSync( `${process.env.FM_HOME}/state/.wake-queue`, - "1\t1\tstale\tfm-window\tstale: fm-window (captain-held)\n" + + "1\t1\tstale\tfm-window\tstale: fm-window (routine follow-up)\n" + "1\t2\tsignal\ttask-a.status\tsignal: routine follow-up\n", ); const mod = await import(pathToFileURL(process.env.PLUGIN).href); @@ -901,10 +902,10 @@ EOF pass "an open-decision stale trigger reaches main even with an unrelated eligible row" } -# A routine row can remain unread when the same status file raises a decision. -# The complete triggering batch goes directly to main without waiting for a -# supervision turn. -test_pi_same_key_mixed_signal_routes_whole_batch_to_main() { +# A routine row can remain unread when the same status file raises a second-mate +# pending-reply escalation. The complete triggering batch goes directly to main +# without waiting for a supervision turn. +test_pi_pending_reply_mixed_signal_routes_whole_batch_to_main() { local repo home plugin log stop out status repo="$TMP_ROOT/pi-mixed-signal-root" home="$TMP_ROOT/pi-mixed-signal-home" @@ -914,6 +915,8 @@ test_pi_same_key_mixed_signal_routes_whole_batch_to_main() { install_pi_watch_extension_fixture "$repo" plugin="$repo/.pi/extensions/fm-primary-pi-watch.ts" printf 'project=%s/projects/approved\nwindow=fm-a\n' "$home" > "$home/state/task-a.meta" + printf 'blocked [key=pending-reply-0123456789abcdef]: pending-reply-missed: task=task-a pending-reply-id=0123456789abcdef request=finish report\n' \ + > "$home/state/task-a.status" cat > "$repo/bin/fm-watch-arm.sh" <<'SH' #!/usr/bin/env bash if [ "${1:-}" = --handling-delivered ]; then exit 0; fi @@ -978,19 +981,19 @@ for (let i = 0; i < 250 && !prompt; i += 1) { await new Promise((resolve) => setTimeout(resolve, 10)); } if (offers.length !== 1 || offers[0].eligible !== false) { - throw new Error(`a mixed needs-decision batch was offered to the branch: ${JSON.stringify(offers)}`); + throw new Error(`a mixed pending-reply escalation batch was offered to the branch: ${JSON.stringify(offers)}`); } if (!prompt.includes("FIRSTMATE WATCHER WAKE: signal: task-a.status")) { - throw new Error(`the mixed needs-decision batch did not wake main: ${prompt}`); + throw new Error(`the mixed pending-reply escalation batch did not wake main: ${prompt}`); } writeFileSync(process.env.FM_STOP_FILE, "stop\n"); process.exit(0); EOF ) status=$? - expect_code 0 "$status" "a mixed needs-decision batch must route wholly to main: $out" - [ -z "$out" ] || fail "Pi mixed-signal test printed output: $out" - pass "a mixed needs-decision batch routes wholly to main" + expect_code 0 "$status" "a mixed pending-reply escalation batch must route wholly to main: $out" + [ -z "$out" ] || fail "Pi pending-reply mixed-signal test printed output: $out" + pass "a mixed pending-reply escalation batch routes wholly to main" } # The captain's accepted rule names ONE coalesced trigger batch, not only a @@ -3984,7 +3987,7 @@ test_pi_branch_offer_flags_heartbeat test_pi_heartbeat_is_not_ridden_into_main_by_a_co_present_check test_pi_main_only_check_classes_stay_on_main test_pi_open_decision_stale_stays_on_main -test_pi_same_key_mixed_signal_routes_whole_batch_to_main +test_pi_pending_reply_mixed_signal_routes_whole_batch_to_main test_pi_distinct_files_mixed_batch_routes_whole_batch_to_main test_pi_heartbeat_is_not_ridden_into_main_by_a_co_present_needs_decision test_pi_heartbeat_restoration_failure_stays_on_main diff --git a/tests/fm-watch-triage.test.sh b/tests/fm-watch-triage.test.sh index 15d2dc9c7cb..0081d047c60 100755 --- a/tests/fm-watch-triage.test.sh +++ b/tests/fm-watch-triage.test.sh @@ -1562,6 +1562,39 @@ test_needs_decision_reconciliation_required_still_marked() { pass "a reconciliation-required needs-decision row's queued payload is still marked needs-decision:" } +test_pending_reply_escalation_signal_payload_marked_for_branch_exclusion() { + local dir state fakebin out status_file pid corr + dir=$(make_case pending-reply-escalation-payload); state="$dir/state"; fakebin="$dir/fakebin" + out="$dir/watch.out" + status_file="$state/task.status" + corr=0123456789abcdef + printf 'blocked [key=pending-reply-%s]: pending-reply-missed: task=task pending-reply-id=%s request=finish report\n' \ + "$corr" "$corr" > "$status_file" + watch_bg "$state" "$fakebin" "$out" + pid=$! + wait_for_exit "$pid" 100 || fail "watcher did not exit for a pending-reply escalation" + grep -F "$(printf 'signal\ttask.status\tneeds-decision:')" "$state/.wake-queue" >/dev/null \ + || fail "a pending-reply escalation was not payload-marked for branch exclusion: $(cat "$state/.wake-queue")" + pass "a pending-reply second-mate escalation is marked for main-only routing" +} + +test_ordinary_blocked_signal_payload_remains_branch_eligible() { + local dir state fakebin out status_file pid + dir=$(make_case ordinary-blocked-payload); state="$dir/state"; fakebin="$dir/fakebin" + out="$dir/watch.out" + status_file="$state/task.status" + printf 'blocked [key=dependency]: waiting for an upstream release\n' > "$status_file" + watch_bg "$state" "$fakebin" "$out" + pid=$! + wait_for_exit "$pid" 100 || fail "watcher did not exit for an ordinary blocked event" + grep -F "$(printf 'signal\ttask.status\tsignal:')" "$state/.wake-queue" >/dev/null \ + || fail "an ordinary blocked event lost branch-eligible routing: $(cat "$state/.wake-queue")" + if grep -F "$(printf 'signal\ttask.status\tneeds-decision:')" "$state/.wake-queue" >/dev/null; then + fail "an ordinary blocked event was marked as a second-mate escalation" + fi + pass "an ordinary blocked event remains branch-eligible" +} + # A routine (non-needs-decision) captain-relevant event must keep its ordinary # payload: only a genuine needs-decision gets the exclusion marker. test_routine_signal_payload_not_marked_needs_decision() { @@ -4092,6 +4125,8 @@ test_self_announced_close_does_not_rewake_but_next_note_does test_actionable_signal_surfaced test_needs_decision_signal_payload_marked_for_branch_exclusion test_needs_decision_reconciliation_required_still_marked +test_pending_reply_escalation_signal_payload_marked_for_branch_exclusion +test_ordinary_blocked_signal_payload_remains_branch_eligible test_routine_signal_payload_not_marked_needs_decision test_actionable_signal_survives_a_later_routine_append test_release_completion_survives_a_later_routine_append From bcc94dc0631d76278a7f4b79d3928fa6a7849cc9 Mon Sep 17 00:00:00 2001 From: kunchenguid Date: Sat, 5 Sep 2026 00:45:17 -0700 Subject: [PATCH 09/17] no-mistakes(review): Ignore trailing whitespace after captain holds --- .pi/extensions/lib/fm-branch-dispatch.ts | 2 +- tests/fm-pi-branch-extension.test.sh | 2 +- tests/fm-pi-watch-extension.test.sh | 34 +++++++++++------------- 3 files changed, 18 insertions(+), 20 deletions(-) diff --git a/.pi/extensions/lib/fm-branch-dispatch.ts b/.pi/extensions/lib/fm-branch-dispatch.ts index 7a24c2831d1..c723960fb4d 100644 --- a/.pi/extensions/lib/fm-branch-dispatch.ts +++ b/.pi/extensions/lib/fm-branch-dispatch.ts @@ -242,7 +242,7 @@ export function scopeForUnreadWake(state: string, heartbeat: boolean): UnreadWak if (existsSync(statusPath)) { let statusLines: string[]; try { - statusLines = readFileSync(statusPath, "utf8").split(/\r?\n/).filter(Boolean); + statusLines = readFileSync(statusPath, "utf8").split(/\r?\n/).filter((line) => /\S/.test(line)); } catch { return UNSAFE_SCOPE; } diff --git a/tests/fm-pi-branch-extension.test.sh b/tests/fm-pi-branch-extension.test.sh index 41eb12f1025..c285e47022d 100644 --- a/tests/fm-pi-branch-extension.test.sh +++ b/tests/fm-pi-branch-extension.test.sh @@ -3773,7 +3773,7 @@ if (needsDecisionOnly.eligible || needsDecisionOnly.eligibleSeqs.length !== 0 || // A captain-held task's bounded stale recheck is itself a decision wake. It is // excluded while an unrelated routine row remains independently branch-owned. -writeFileSync(`${state}/task-a.status`, "captain-held [key=route]: awaiting the captain\n"); +writeFileSync(`${state}/task-a.status`, "captain-held [key=route]: awaiting the captain\n \t \n"); writeFileSync( `${state}/.wake-queue`, [ diff --git a/tests/fm-pi-watch-extension.test.sh b/tests/fm-pi-watch-extension.test.sh index 2bf5b2a1287..ea9ceebe9ed 100755 --- a/tests/fm-pi-watch-extension.test.sh +++ b/tests/fm-pi-watch-extension.test.sh @@ -810,21 +810,20 @@ CLASSES pass "every main-only check class still reaches main, never the supervision branch" } -# A task's stale reminder remains a decision trigger while an earlier durable -# needs-decision is open, even when a later unrelated status follows it. A -# co-present routine signal remains independently branch-ownable but cannot -# take this stale close away from main. -test_pi_open_decision_stale_stays_on_main() { +# A captain-held stale reminder remains a decision trigger when whitespace-only +# lines follow the hold. A co-present routine signal remains independently +# branch-ownable but cannot take this stale close away from main. +test_pi_captain_held_trailing_whitespace_stale_stays_on_main() { local repo home plugin log stop out status - repo="$TMP_ROOT/pi-open-decision-stale-root" - home="$TMP_ROOT/pi-open-decision-stale-home" - log="$TMP_ROOT/pi-open-decision-stale.log" - stop="$TMP_ROOT/pi-open-decision-stale.stop" + repo="$TMP_ROOT/pi-captain-held-whitespace-root" + home="$TMP_ROOT/pi-captain-held-whitespace-home" + log="$TMP_ROOT/pi-captain-held-whitespace.log" + stop="$TMP_ROOT/pi-captain-held-whitespace.stop" mkdir -p "$repo/bin" "$home/state" "$home/config" "$home/projects/approved" install_pi_watch_extension_fixture "$repo" plugin="$repo/.pi/extensions/fm-primary-pi-watch.ts" printf 'project=%s/projects/approved\nwindow=fm-window\n' "$home" > "$home/state/task-a.meta" - printf 'needs-decision [key=pending-reply-x]: choose destructive cleanup\nworking: routine follow-up\n' > "$home/state/task-a.status" + printf 'captain-held [key=route]: awaiting the captain\n \t \n' > "$home/state/task-a.status" cat > "$repo/bin/fm-watch-arm.sh" <<'SH' #!/usr/bin/env bash if [ "${1:-}" = --handling-delivered ]; then exit 0; fi @@ -873,7 +872,6 @@ const pi = { prompt = message; }, }; -process.env.FM_CLASSIFY_RESERVED_KEY_PREFIXES = "secret-"; writeFileSync(`${process.env.FM_HOME}/state/.lock`, `${process.pid}\n`); writeFileSync( `${process.env.FM_HOME}/state/.wake-queue`, @@ -882,24 +880,24 @@ writeFileSync( ); const mod = await import(pathToFileURL(process.env.PLUGIN).href); mod.default(pi); -await tool.execute("tool-call-open-decision-stale", {}, undefined, undefined, {}); +await tool.execute("tool-call-captain-held-whitespace-stale", {}, undefined, undefined, {}); for (let i = 0; i < 250 && !prompt; i += 1) { await new Promise((resolve) => setTimeout(resolve, 10)); } if (offers.length !== 1 || offers[0].eligible !== false) { - throw new Error(`an open-decision stale trigger was offered to the branch: ${JSON.stringify(offers)}`); + throw new Error(`a captain-held stale trigger with trailing whitespace was offered to the branch: ${JSON.stringify(offers)}`); } if (!prompt.includes("FIRSTMATE WATCHER WAKE: stale: fm-window")) { - throw new Error(`an open-decision stale trigger did not reach main: ${prompt}`); + throw new Error(`a captain-held stale trigger with trailing whitespace did not reach main: ${prompt}`); } writeFileSync(process.env.FM_STOP_FILE, "stop\n"); process.exit(0); EOF ) status=$? - expect_code 0 "$status" "an open-decision stale trigger must stay on main: $out" - [ -z "$out" ] || fail "Pi open-decision stale test printed output: $out" - pass "an open-decision stale trigger reaches main even with an unrelated eligible row" + expect_code 0 "$status" "a captain-held stale trigger with trailing whitespace must stay on main: $out" + [ -z "$out" ] || fail "Pi captain-held whitespace stale test printed output: $out" + pass "a captain-held stale trigger ignores trailing whitespace and reaches main" } # A routine row can remain unread when the same status file raises a second-mate @@ -3986,7 +3984,7 @@ test_pi_branch_offer_owns_actionable_wake test_pi_branch_offer_flags_heartbeat test_pi_heartbeat_is_not_ridden_into_main_by_a_co_present_check test_pi_main_only_check_classes_stay_on_main -test_pi_open_decision_stale_stays_on_main +test_pi_captain_held_trailing_whitespace_stale_stays_on_main test_pi_pending_reply_mixed_signal_routes_whole_batch_to_main test_pi_distinct_files_mixed_batch_routes_whole_batch_to_main test_pi_heartbeat_is_not_ridden_into_main_by_a_co_present_needs_decision From 6fe1083ddd3e77d90db7db8a1b56ec9d196d9535 Mon Sep 17 00:00:00 2001 From: kunchenguid Date: Sat, 5 Sep 2026 00:51:04 -0700 Subject: [PATCH 10/17] no-mistakes(review): Cache stale decision classification per status file --- .pi/extensions/lib/fm-branch-dispatch.ts | 38 +++++++++++++---------- tests/fm-pi-branch-extension.test.sh | 39 +++++++++++++++++++++++- 2 files changed, 60 insertions(+), 17 deletions(-) diff --git a/.pi/extensions/lib/fm-branch-dispatch.ts b/.pi/extensions/lib/fm-branch-dispatch.ts index c723960fb4d..d2028448135 100644 --- a/.pi/extensions/lib/fm-branch-dispatch.ts +++ b/.pi/extensions/lib/fm-branch-dispatch.ts @@ -204,6 +204,12 @@ export function scopeForUnreadWake(state: string, heartbeat: boolean): UnreadWak const eligibleSeqs: string[] = []; const eligibleTasks = new Set(); const needsDecisionKeys: string[] = []; + const staleDecisionOwnership = new Map(); + const resolveVerb = process.env.FM_CLASSIFY_RESOLVE_VERB || "resolved"; + const heldVerb = process.env.FM_CLASSIFY_CAPTAIN_HELD_VERB || "captain-held"; + const reservedPrefixes = (process.env.FM_CLASSIFY_RESERVED_KEY_PREFIXES || "pending-reply-") + .split(/\s+/) + .filter(Boolean); for (const line of rows) { const fields = line.split("\t"); if (fields.length < 5 || !/^[0-9]+$/.test(fields[1])) return UNSAFE_SCOPE; @@ -239,23 +245,23 @@ export function scopeForUnreadWake(state: string, heartbeat: boolean): UnreadWak project = metadata.get(key) ?? metadata.get(key.replace(/^fm-/, "")) ?? ""; if (task) { const statusPath = `${state}/${task}.status`; - if (existsSync(statusPath)) { - let statusLines: string[]; - try { - statusLines = readFileSync(statusPath, "utf8").split(/\r?\n/).filter((line) => /\S/.test(line)); - } catch { - return UNSAFE_SCOPE; - } - const resolveVerb = process.env.FM_CLASSIFY_RESOLVE_VERB || "resolved"; - const heldVerb = process.env.FM_CLASSIFY_CAPTAIN_HELD_VERB || "captain-held"; - const reservedPrefixes = (process.env.FM_CLASSIFY_RESERVED_KEY_PREFIXES || "pending-reply-") - .split(/\s+/) - .filter(Boolean); - if (hasOpenNeedsDecision(statusLines, resolveVerb, heldVerb, reservedPrefixes) || - statusLineVerb(statusLines.at(-1) ?? "") === heldVerb) { - needsDecisionKeys.push(key); - continue; + if (!staleDecisionOwnership.has(statusPath)) { + let decisionOwned = false; + if (existsSync(statusPath)) { + let statusLines: string[]; + try { + statusLines = readFileSync(statusPath, "utf8").split(/\r?\n/).filter((line) => /\S/.test(line)); + } catch { + return UNSAFE_SCOPE; + } + decisionOwned = hasOpenNeedsDecision(statusLines, resolveVerb, heldVerb, reservedPrefixes) || + statusLineVerb(statusLines.at(-1) ?? "") === heldVerb; } + staleDecisionOwnership.set(statusPath, decisionOwned); + } + if (staleDecisionOwnership.get(statusPath)) { + needsDecisionKeys.push(key); + continue; } } } else { diff --git a/tests/fm-pi-branch-extension.test.sh b/tests/fm-pi-branch-extension.test.sh index c285e47022d..d6c8f653c78 100644 --- a/tests/fm-pi-branch-extension.test.sh +++ b/tests/fm-pi-branch-extension.test.sh @@ -3714,7 +3714,17 @@ test_branch_dispatch_classifies_main_only_rows_and_writes_the_eligible_snapshot( LIB="$repo/.pi/extensions/lib/fm-branch-dispatch.ts" FM_HOME="$home" GRANT="$ROOT/bin/fm-wake-grant.sh" \ node --input-type=module > "$TMP_ROOT/node-output" 2>&1 <<'EOF' import { pathToFileURL } from "node:url"; -import { readFileSync, writeFileSync } from "node:fs"; +import { syncBuiltinESMExports } from "node:module"; +import fs, { readFileSync, writeFileSync } from "node:fs"; + +const originalReadFileSync = fs.readFileSync; +let countedStatusPath = ""; +let countedStatusReads = 0; +fs.readFileSync = function(path, ...args) { + if (String(path) === countedStatusPath) countedStatusReads += 1; + return originalReadFileSync.call(this, path, ...args); +}; +syncBuiltinESMExports(); const { activateEligibleRowsOwner, scopeForUnreadWake, writeEligibleRowsSnapshot, releaseEligibleRowsSnapshot, BRANCH_ELIGIBLE_ROWS_FILE } = await import(pathToFileURL(process.env.LIB).href); @@ -3789,6 +3799,33 @@ if (captainHeldMixed.needsDecisionKeys.join(",") !== "fm-window") { throw new Error(`the captain-held stale key was not marked main-owned: ${JSON.stringify(captainHeldMixed)}`); } +writeFileSync( + `${state}/.wake-queue`, + [ + "1\t1\tstale\tfm-window\tstale: fm-window (first reminder)", + "1\t2\tstale\tfm-window\tstale: fm-window (second reminder)", + "1\t3\tsignal\ttask-a.status\tsignal: routine follow-up", + ].join("\n"), +); +countedStatusPath = `${state}/task-a.status`; +countedStatusReads = 0; +const repeatedCaptainHeld = scopeForUnreadWake(state, false); +if (countedStatusReads !== 1) { + throw new Error(`one status was read ${countedStatusReads} times for repeated stale rows`); +} +if (!repeatedCaptainHeld.eligible || repeatedCaptainHeld.eligibleSeqs.join(",") !== "3" || + repeatedCaptainHeld.needsDecisionKeys.join(",") !== "fm-window,fm-window") { + throw new Error(`repeated stale reminders changed classification: ${JSON.stringify(repeatedCaptainHeld)}`); +} +countedStatusPath = ""; +writeFileSync( + `${state}/.wake-queue`, + [ + "1\t1\tstale\tfm-window\tstale: fm-window (awaiting the captain)", + "1\t2\tsignal\ttask-a.status\tsignal: routine follow-up", + ].join("\n"), +); + // A later unrelated status does not mask a still-open durable decision. The // stale row remains main-owned while the routine signal stays branch-owned. writeFileSync( From 51a61e2f61d51bcbc3e117aff070e3d987aa3d9e Mon Sep 17 00:00:00 2001 From: kunchenguid Date: Sat, 5 Sep 2026 01:05:21 -0700 Subject: [PATCH 11/17] no-mistakes(review): Document unread decision precedence for later task wakes --- .pi/extensions/fm-primary-pi-watch.ts | 6 ++++-- docs/pi-supervision-branch.md | 2 +- tests/fm-pi-branch-extension.test.sh | 9 +++++---- tests/fm-pi-watch-extension.test.sh | 25 ++++++++++++------------- 4 files changed, 22 insertions(+), 20 deletions(-) diff --git a/.pi/extensions/fm-primary-pi-watch.ts b/.pi/extensions/fm-primary-pi-watch.ts index f53e760543c..4a6d4089cff 100644 --- a/.pi/extensions/fm-primary-pi-watch.ts +++ b/.pi/extensions/fm-primary-pi-watch.ts @@ -608,8 +608,10 @@ export default function (pi: ExtensionAPI) { const scope = scopeForUnreadWake(state, heartbeat); // A signal close containing a needs-decision status file, or a stale close // for a captain-held task, gets the identical main-only treatment as a - // check-kind trigger. Cross-reference only this trigger's keys so independent - // unread scans and heartbeat handling remain unchanged. + // check-kind trigger. The cross-reference deliberately includes every + // unread decision row: until that row is read, a later signal or stale + // trigger for the same task stays on main. Other tasks and heartbeat + // handling remain independent. const triggerKeys = /^signal:/.test(message) ? message .slice("signal:".length) diff --git a/docs/pi-supervision-branch.md b/docs/pi-supervision-branch.md index 4dbae331862..966c5d2c4be 100644 --- a/docs/pi-supervision-branch.md +++ b/docs/pi-supervision-branch.md @@ -25,7 +25,7 @@ The supervision branch itself is Pi-only by construction: A successful row grant transfers ownership of exactly the currently branch-eligible rows to the branch; a check-kind triggering close (merge-confirmation polls, Relay mentions, credential/auth failures, and every other legitimately main-only class) is never offered even when other rows are eligible, no acceptor (extension absent, away mode, branch broken) keeps today's wake-to-main path for that close, and watcher-failure alarms always go to main because only main can repair the watcher cycle. A `needs-decision:` event surfaced by `bin/fm-watch.sh`'s actionable signal path gets the identical treatment even though it keeps the ordinary `signal` kind. `signal_files_actionable` marks that signal row's queued payload `needs-decision:` instead of `signal:`, and `scopeForUnreadWake` excludes the marked row from what the branch may claim. It also excludes a stale row when the mapped task's current declaration is `captain-held`, so the bounded reminder returns to main to answer or release the held decision. - The dispatcher cross-references the trigger's keys against the excluded rows: any signal/stale close containing a decision row goes wholly to main, including a batch that also contains routine rows. + The dispatcher cross-references the trigger's keys against every currently unread excluded decision row: any signal/stale close containing a decision row goes wholly to main, including a batch that also contains routine rows, and an unread decision for one task keeps every later signal or stale close for that same task on main until the decision row is read. Other tasks remain independently eligible. The wake message itself retains its existing shape, so other harness-arm scripts remain unchanged. Heartbeat handling remains independent. A fleet-wide heartbeat keeps its own all-or-nothing rule (see "Heartbeat routing" below): it takes every branch-ownable unread row or none of them. diff --git a/tests/fm-pi-branch-extension.test.sh b/tests/fm-pi-branch-extension.test.sh index d6c8f653c78..34583db04a4 100644 --- a/tests/fm-pi-branch-extension.test.sh +++ b/tests/fm-pi-branch-extension.test.sh @@ -3750,18 +3750,19 @@ for (const row of mainOnlyRows) { // A needs-decision signal row is a main-only class too, marked by payload // rather than kind (docs/pi-supervision-branch.md "Autonomy"): it is excluded -// from eligibleSeqs, named in needsDecisionKeys, and never vetoes an unrelated -// eligible row sharing the queue. +// from eligibleSeqs and named in needsDecisionKeys. A later routine row for the +// same task remains individually claimable, while trigger-key precedence keeps +// its complete wake on main until the decision row is read. writeFileSync( `${state}/.wake-queue`, [ "1\t1\tsignal\ttask-a.status\tneeds-decision: task-a.status", - "1\t2\tstale\tfm-window\tstale: fm-window", + "1\t2\tsignal\ttask-a.status\tsignal: later routine update", ].join("\n"), ); const needsDecisionMixed = scopeForUnreadWake(state, false); if (!needsDecisionMixed.eligible) { - throw new Error(`a needs-decision row must not veto an unrelated eligible row: ${JSON.stringify(needsDecisionMixed)}`); + throw new Error(`an unread needs-decision row must not erase a later routine row: ${JSON.stringify(needsDecisionMixed)}`); } if (needsDecisionMixed.eligibleSeqs.join(",") !== "2") { throw new Error(`a needs-decision row must be excluded from eligibleSeqs: ${JSON.stringify(needsDecisionMixed)}`); diff --git a/tests/fm-pi-watch-extension.test.sh b/tests/fm-pi-watch-extension.test.sh index ea9ceebe9ed..9127a12cc6d 100755 --- a/tests/fm-pi-watch-extension.test.sh +++ b/tests/fm-pi-watch-extension.test.sh @@ -900,10 +900,9 @@ EOF pass "a captain-held stale trigger ignores trailing whitespace and reaches main" } -# A routine row can remain unread when the same status file raises a second-mate -# pending-reply escalation. The complete triggering batch goes directly to main -# without waiting for a supervision turn. -test_pi_pending_reply_mixed_signal_routes_whole_batch_to_main() { +# An unread second-mate pending-reply escalation keeps a later routine signal +# for the same task on main until the decision row is read. +test_pi_unread_pending_reply_forces_later_routine_signal_to_main() { local repo home plugin log stop out status repo="$TMP_ROOT/pi-mixed-signal-root" home="$TMP_ROOT/pi-mixed-signal-home" @@ -966,12 +965,12 @@ const pi = { writeFileSync(`${process.env.FM_HOME}/state/.lock`, `${process.pid}\n`); writeFileSync( `${process.env.FM_HOME}/state/.wake-queue`, - "1\t1\tsignal\ttask-a.status\tsignal: task-a.status\n" + - "2\t2\tsignal\ttask-a.status\tneeds-decision: task-a.status\n", + "1\t1\tsignal\ttask-a.status\tneeds-decision: task-a.status\n" + + "2\t2\tsignal\ttask-a.status\tsignal: task-a.status\n", ); const mod = await import(pathToFileURL(process.env.PLUGIN).href); mod.default(pi); -await tool.execute("tool-call-mixed-signal", {}, undefined, undefined, {}); +await tool.execute("tool-call-later-routine-signal", {}, undefined, undefined, {}); for (let i = 0; i < 250 && offers.length === 0; i += 1) { await new Promise((resolve) => setTimeout(resolve, 10)); } @@ -979,19 +978,19 @@ for (let i = 0; i < 250 && !prompt; i += 1) { await new Promise((resolve) => setTimeout(resolve, 10)); } if (offers.length !== 1 || offers[0].eligible !== false) { - throw new Error(`a mixed pending-reply escalation batch was offered to the branch: ${JSON.stringify(offers)}`); + throw new Error(`a later routine signal bypassed its unread decision: ${JSON.stringify(offers)}`); } if (!prompt.includes("FIRSTMATE WATCHER WAKE: signal: task-a.status")) { - throw new Error(`the mixed pending-reply escalation batch did not wake main: ${prompt}`); + throw new Error(`a later routine signal with an unread decision did not wake main: ${prompt}`); } writeFileSync(process.env.FM_STOP_FILE, "stop\n"); process.exit(0); EOF ) status=$? - expect_code 0 "$status" "a mixed pending-reply escalation batch must route wholly to main: $out" - [ -z "$out" ] || fail "Pi pending-reply mixed-signal test printed output: $out" - pass "a mixed pending-reply escalation batch routes wholly to main" + expect_code 0 "$status" "an unread pending-reply escalation must keep a later routine signal on main: $out" + [ -z "$out" ] || fail "Pi unread pending-reply precedence test printed output: $out" + pass "an unread pending-reply escalation keeps later routine signals on main" } # The captain's accepted rule names ONE coalesced trigger batch, not only a @@ -3985,7 +3984,7 @@ test_pi_branch_offer_flags_heartbeat test_pi_heartbeat_is_not_ridden_into_main_by_a_co_present_check test_pi_main_only_check_classes_stay_on_main test_pi_captain_held_trailing_whitespace_stale_stays_on_main -test_pi_pending_reply_mixed_signal_routes_whole_batch_to_main +test_pi_unread_pending_reply_forces_later_routine_signal_to_main test_pi_distinct_files_mixed_batch_routes_whole_batch_to_main test_pi_heartbeat_is_not_ridden_into_main_by_a_co_present_needs_decision test_pi_heartbeat_restoration_failure_stays_on_main From b3938174511493ecdb1504bfdcf73ca3ca4636dc Mon Sep 17 00:00:00 2001 From: kunchenguid Date: Sat, 5 Sep 2026 01:10:33 -0700 Subject: [PATCH 12/17] no-mistakes(review): Cache unchanged stale decisions across scope scans --- .pi/extensions/lib/fm-branch-dispatch.ts | 55 ++++++++++++++++++++---- tests/fm-pi-branch-extension.test.sh | 11 +++++ 2 files changed, 57 insertions(+), 9 deletions(-) diff --git a/.pi/extensions/lib/fm-branch-dispatch.ts b/.pi/extensions/lib/fm-branch-dispatch.ts index d2028448135..eae49d7df30 100644 --- a/.pi/extensions/lib/fm-branch-dispatch.ts +++ b/.pi/extensions/lib/fm-branch-dispatch.ts @@ -1,4 +1,4 @@ -import { existsSync, readdirSync, readFileSync } from "node:fs"; +import { readdirSync, readFileSync, statSync } from "node:fs"; import { runCommandAsync } from "./fm-async-exec.ts"; // Shared wake-dispatch handshake between the Pi watcher extension (the @@ -144,6 +144,24 @@ function statusLineNote(line: string): string { return match ? note.slice(match[0].length).trimStart() : note; } +interface StaleDecisionCacheEntry { + version: string; + config: string; + decisionOwned: boolean; +} + +const staleDecisionCache = new Map(); + +function statusFileVersion(path: string): string | null { + try { + const stat = statSync(path); + return `${stat.dev}:${stat.ino}:${stat.size}:${stat.mtimeMs}:${stat.ctimeMs}`; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return null; + throw error; + } +} + function hasOpenNeedsDecision( lines: readonly string[], resolveVerb: string, @@ -210,6 +228,7 @@ export function scopeForUnreadWake(state: string, heartbeat: boolean): UnreadWak const reservedPrefixes = (process.env.FM_CLASSIFY_RESERVED_KEY_PREFIXES || "pending-reply-") .split(/\s+/) .filter(Boolean); + const decisionConfig = `${resolveVerb}\0${heldVerb}\0${reservedPrefixes.join("\0")}`; for (const line of rows) { const fields = line.split("\t"); if (fields.length < 5 || !/^[0-9]+$/.test(fields[1])) return UNSAFE_SCOPE; @@ -246,16 +265,34 @@ export function scopeForUnreadWake(state: string, heartbeat: boolean): UnreadWak if (task) { const statusPath = `${state}/${task}.status`; if (!staleDecisionOwnership.has(statusPath)) { + let version: string | null; + try { + version = statusFileVersion(statusPath); + } catch { + return UNSAFE_SCOPE; + } let decisionOwned = false; - if (existsSync(statusPath)) { - let statusLines: string[]; - try { - statusLines = readFileSync(statusPath, "utf8").split(/\r?\n/).filter((line) => /\S/.test(line)); - } catch { - return UNSAFE_SCOPE; + if (version) { + const cached = staleDecisionCache.get(statusPath); + if (cached?.version === version && cached.config === decisionConfig) { + decisionOwned = cached.decisionOwned; + } else { + let statusLines: string[]; + try { + statusLines = readFileSync(statusPath, "utf8").split(/\r?\n/).filter((line) => /\S/.test(line)); + if (statusFileVersion(statusPath) !== version) return UNSAFE_SCOPE; + } catch { + return UNSAFE_SCOPE; + } + decisionOwned = hasOpenNeedsDecision(statusLines, resolveVerb, heldVerb, reservedPrefixes) || + statusLineVerb(statusLines.at(-1) ?? "") === heldVerb; + staleDecisionCache.set(statusPath, { version, config: decisionConfig, decisionOwned }); + if (staleDecisionCache.size > 512) { + staleDecisionCache.delete(staleDecisionCache.keys().next().value!); + } } - decisionOwned = hasOpenNeedsDecision(statusLines, resolveVerb, heldVerb, reservedPrefixes) || - statusLineVerb(statusLines.at(-1) ?? "") === heldVerb; + } else { + staleDecisionCache.delete(statusPath); } staleDecisionOwnership.set(statusPath, decisionOwned); } diff --git a/tests/fm-pi-branch-extension.test.sh b/tests/fm-pi-branch-extension.test.sh index 34583db04a4..df652db1064 100644 --- a/tests/fm-pi-branch-extension.test.sh +++ b/tests/fm-pi-branch-extension.test.sh @@ -3800,6 +3800,7 @@ if (captainHeldMixed.needsDecisionKeys.join(",") !== "fm-window") { throw new Error(`the captain-held stale key was not marked main-owned: ${JSON.stringify(captainHeldMixed)}`); } +writeFileSync(`${state}/task-a.status`, "captain-held [key=route]: awaiting a second captain reminder\n \n"); writeFileSync( `${state}/.wake-queue`, [ @@ -3818,6 +3819,16 @@ if (!repeatedCaptainHeld.eligible || repeatedCaptainHeld.eligibleSeqs.join(",") repeatedCaptainHeld.needsDecisionKeys.join(",") !== "fm-window,fm-window") { throw new Error(`repeated stale reminders changed classification: ${JSON.stringify(repeatedCaptainHeld)}`); } +const repeatedCaptainHeldNextScan = scopeForUnreadWake(state, false); +if (countedStatusReads !== 1 || repeatedCaptainHeldNextScan.needsDecisionKeys.join(",") !== "fm-window,fm-window") { + throw new Error(`an unchanged status was not reused across scans: reads=${countedStatusReads} scope=${JSON.stringify(repeatedCaptainHeldNextScan)}`); +} +writeFileSync(`${state}/task-a.status`, "captain-held [key=route]: awaiting the captain\nworking: resumed after answer\n"); +const changedCaptainHeld = scopeForUnreadWake(state, false); +if (countedStatusReads !== 2 || changedCaptainHeld.eligibleSeqs.join(",") !== "1,2,3" || + changedCaptainHeld.needsDecisionKeys.length !== 0) { + throw new Error(`a changed status did not invalidate its cached decision: reads=${countedStatusReads} scope=${JSON.stringify(changedCaptainHeld)}`); +} countedStatusPath = ""; writeFileSync( `${state}/.wake-queue`, From 93821997c2c9edc6f5b16d2a665335d78e07ff4b Mon Sep 17 00:00:00 2001 From: kunchenguid Date: Sat, 5 Sep 2026 01:20:00 -0700 Subject: [PATCH 13/17] no-mistakes(review): Resolve decision aliases and reject symlinked statuses --- .pi/extensions/fm-primary-pi-watch.ts | 5 ++++- .pi/extensions/lib/fm-branch-dispatch.ts | 11 ++++++++-- docs/pi-supervision-branch.md | 2 +- tests/fm-pi-branch-extension.test.sh | 25 +++++++++++++++++------ tests/fm-pi-watch-extension.test.sh | 26 ++++++++++++------------ 5 files changed, 46 insertions(+), 23 deletions(-) diff --git a/.pi/extensions/fm-primary-pi-watch.ts b/.pi/extensions/fm-primary-pi-watch.ts index 4a6d4089cff..024ab5173a4 100644 --- a/.pi/extensions/fm-primary-pi-watch.ts +++ b/.pi/extensions/fm-primary-pi-watch.ts @@ -621,7 +621,10 @@ export default function (pi: ExtensionAPI) { : /^stale:/.test(message) ? [message.slice("stale:".length).trim().split(/\s+/, 1)[0]].filter(Boolean) : []; - const isNeedsDecisionTrigger = triggerKeys.some((key) => scope.needsDecisionKeys.includes(key)); + const taskIdentity = (key: string): string => + scope.taskByWakeKey[key] ?? scope.taskByWakeKey[key.replace(/^fm-/, "")] ?? key; + const needsDecisionTasks = new Set(scope.needsDecisionKeys.map(taskIdentity)); + const isNeedsDecisionTrigger = triggerKeys.some((key) => needsDecisionTasks.has(taskIdentity(key))); const eligible = !isCheckTrigger && !isNeedsDecisionTrigger && scope.eligible; const offer = createBranchDispatchOffer(message, scope.projects, heartbeat, eligible); pi.events?.emit?.(FM_BRANCH_DISPATCH_EVENT, offer); diff --git a/.pi/extensions/lib/fm-branch-dispatch.ts b/.pi/extensions/lib/fm-branch-dispatch.ts index eae49d7df30..5687aa47879 100644 --- a/.pi/extensions/lib/fm-branch-dispatch.ts +++ b/.pi/extensions/lib/fm-branch-dispatch.ts @@ -1,4 +1,4 @@ -import { readdirSync, readFileSync, statSync } from "node:fs"; +import { lstatSync, readdirSync, readFileSync } from "node:fs"; import { runCommandAsync } from "./fm-async-exec.ts"; // Shared wake-dispatch handshake between the Pi watcher extension (the @@ -63,6 +63,7 @@ export interface UnreadWakeScope { * to main. */ needsDecisionKeys: string[]; + taskByWakeKey: Record; } const EMPTY_SCOPE: UnreadWakeScope = { @@ -73,6 +74,7 @@ const EMPTY_SCOPE: UnreadWakeScope = { eligibleTasks: [], corrupted: false, needsDecisionKeys: [], + taskByWakeKey: {}, }; const UNSAFE_SCOPE: UnreadWakeScope = { status: "unsafe", @@ -82,6 +84,7 @@ const UNSAFE_SCOPE: UnreadWakeScope = { eligibleTasks: [], corrupted: true, needsDecisionKeys: [], + taskByWakeKey: {}, }; // scopeForUnreadWake is the single owner of branch-eligibility classification @@ -154,7 +157,8 @@ const staleDecisionCache = new Map(); function statusFileVersion(path: string): string | null { try { - const stat = statSync(path); + const stat = lstatSync(path); + if (stat.isSymbolicLink()) throw new Error("status path is a symbolic link"); return `${stat.dev}:${stat.ino}:${stat.size}:${stat.mtimeMs}:${stat.ctimeMs}`; } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return null; @@ -209,6 +213,8 @@ export function scopeForUnreadWake(state: string, heartbeat: boolean): UnreadWak if (project) { metadata.set(task, project); taskByKey.set(task, task); + taskByKey.set(`${task}.status`, task); + taskByKey.set(`${task}.turn-ended`, task); if (window) { metadata.set(window, project); taskByKey.set(window, task); @@ -327,6 +333,7 @@ export function scopeForUnreadWake(state: string, heartbeat: boolean): UnreadWak eligibleTasks: [...eligibleTasks], corrupted: false, needsDecisionKeys, + taskByWakeKey: Object.fromEntries(taskByKey), }; } diff --git a/docs/pi-supervision-branch.md b/docs/pi-supervision-branch.md index 966c5d2c4be..54c178a2e52 100644 --- a/docs/pi-supervision-branch.md +++ b/docs/pi-supervision-branch.md @@ -25,7 +25,7 @@ The supervision branch itself is Pi-only by construction: A successful row grant transfers ownership of exactly the currently branch-eligible rows to the branch; a check-kind triggering close (merge-confirmation polls, Relay mentions, credential/auth failures, and every other legitimately main-only class) is never offered even when other rows are eligible, no acceptor (extension absent, away mode, branch broken) keeps today's wake-to-main path for that close, and watcher-failure alarms always go to main because only main can repair the watcher cycle. A `needs-decision:` event surfaced by `bin/fm-watch.sh`'s actionable signal path gets the identical treatment even though it keeps the ordinary `signal` kind. `signal_files_actionable` marks that signal row's queued payload `needs-decision:` instead of `signal:`, and `scopeForUnreadWake` excludes the marked row from what the branch may claim. It also excludes a stale row when the mapped task's current declaration is `captain-held`, so the bounded reminder returns to main to answer or release the held decision. - The dispatcher cross-references the trigger's keys against every currently unread excluded decision row: any signal/stale close containing a decision row goes wholly to main, including a batch that also contains routine rows, and an unread decision for one task keeps every later signal or stale close for that same task on main until the decision row is read. Other tasks remain independently eligible. + The dispatcher resolves trigger keys and every currently unread excluded decision row to task identity before cross-referencing them: any signal/stale close containing a decision row goes wholly to main, including a batch that also contains routine rows, and an unread decision for one task keeps every later signal or stale close for that same task on main until the decision row is read, regardless of whether the rows use its status-file key or window alias. Other tasks remain independently eligible. The wake message itself retains its existing shape, so other harness-arm scripts remain unchanged. Heartbeat handling remains independent. A fleet-wide heartbeat keeps its own all-or-nothing rule (see "Heartbeat routing" below): it takes every branch-ownable unread row or none of them. diff --git a/tests/fm-pi-branch-extension.test.sh b/tests/fm-pi-branch-extension.test.sh index df652db1064..d74c0704c70 100644 --- a/tests/fm-pi-branch-extension.test.sh +++ b/tests/fm-pi-branch-extension.test.sh @@ -3715,7 +3715,7 @@ test_branch_dispatch_classifies_main_only_rows_and_writes_the_eligible_snapshot( node --input-type=module > "$TMP_ROOT/node-output" 2>&1 <<'EOF' import { pathToFileURL } from "node:url"; import { syncBuiltinESMExports } from "node:module"; -import fs, { readFileSync, writeFileSync } from "node:fs"; +import fs, { readFileSync, symlinkSync, unlinkSync, writeFileSync } from "node:fs"; const originalReadFileSync = fs.readFileSync; let countedStatusPath = ""; @@ -3750,19 +3750,19 @@ for (const row of mainOnlyRows) { // A needs-decision signal row is a main-only class too, marked by payload // rather than kind (docs/pi-supervision-branch.md "Autonomy"): it is excluded -// from eligibleSeqs and named in needsDecisionKeys. A later routine row for the -// same task remains individually claimable, while trigger-key precedence keeps -// its complete wake on main until the decision row is read. +// from eligibleSeqs and named in needsDecisionKeys. A later stale row under the +// task's window alias remains individually claimable, while task-identity +// precedence keeps its complete wake on main until the decision row is read. writeFileSync( `${state}/.wake-queue`, [ "1\t1\tsignal\ttask-a.status\tneeds-decision: task-a.status", - "1\t2\tsignal\ttask-a.status\tsignal: later routine update", + "1\t2\tstale\tfm-window\tstale: later routine reminder", ].join("\n"), ); const needsDecisionMixed = scopeForUnreadWake(state, false); if (!needsDecisionMixed.eligible) { - throw new Error(`an unread needs-decision row must not erase a later routine row: ${JSON.stringify(needsDecisionMixed)}`); + throw new Error(`an unread needs-decision row must not erase a later stale row: ${JSON.stringify(needsDecisionMixed)}`); } if (needsDecisionMixed.eligibleSeqs.join(",") !== "2") { throw new Error(`a needs-decision row must be excluded from eligibleSeqs: ${JSON.stringify(needsDecisionMixed)}`); @@ -3770,6 +3770,10 @@ if (needsDecisionMixed.eligibleSeqs.join(",") !== "2") { if (needsDecisionMixed.needsDecisionKeys.join(",") !== "task-a.status") { throw new Error(`needsDecisionKeys must name the excluded row: ${JSON.stringify(needsDecisionMixed)}`); } +if (needsDecisionMixed.taskByWakeKey["task-a.status"] !== "task-a" || + needsDecisionMixed.taskByWakeKey["fm-window"] !== "task-a") { + throw new Error(`status and stale aliases did not resolve to one task: ${JSON.stringify(needsDecisionMixed)}`); +} if (needsDecisionMixed.corrupted) { throw new Error(`a needs-decision row must not read as corrupted: ${JSON.stringify(needsDecisionMixed)}`); } @@ -3884,6 +3888,15 @@ if (!customReservedPrefixes.eligible || customReservedPrefixes.eligibleSeqs.join throw new Error(`configured reserved prefixes lost an open stale decision: ${JSON.stringify(customReservedPrefixes)}`); } delete process.env.FM_CLASSIFY_RESERVED_KEY_PREFIXES; + +writeFileSync(`${state}/symlink-target.status`, "needs-decision: external choice\n"); +unlinkSync(`${state}/task-a.status`); +symlinkSync(`${state}/symlink-target.status`, `${state}/task-a.status`); +const symlinkedStatus = scopeForUnreadWake(state, false); +if (!symlinkedStatus.corrupted || symlinkedStatus.eligible || symlinkedStatus.needsDecisionKeys.length !== 0) { + throw new Error(`a symlinked status file influenced stale routing: ${JSON.stringify(symlinkedStatus)}`); +} +unlinkSync(`${state}/task-a.status`); writeFileSync(`${state}/task-a.status`, "working: routine work\n"); writeFileSync( diff --git a/tests/fm-pi-watch-extension.test.sh b/tests/fm-pi-watch-extension.test.sh index 9127a12cc6d..ddad7a233ee 100755 --- a/tests/fm-pi-watch-extension.test.sh +++ b/tests/fm-pi-watch-extension.test.sh @@ -900,9 +900,9 @@ EOF pass "a captain-held stale trigger ignores trailing whitespace and reaches main" } -# An unread second-mate pending-reply escalation keeps a later routine signal -# for the same task on main until the decision row is read. -test_pi_unread_pending_reply_forces_later_routine_signal_to_main() { +# An unread second-mate pending-reply escalation keeps a later stale reminder +# under the same task's window alias on main until the decision row is read. +test_pi_unread_pending_reply_forces_later_stale_alias_to_main() { local repo home plugin log stop out status repo="$TMP_ROOT/pi-mixed-signal-root" home="$TMP_ROOT/pi-mixed-signal-home" @@ -921,7 +921,7 @@ printf 'arm=%s\n' "$$" >> "${FM_ARM_LOG:?}" count=$(grep -c '^arm=' "$FM_ARM_LOG") if [ "$count" -eq 1 ]; then printf 'watcher: started pid=%s (beacon fresh)\n' "$$" - printf 'signal: task-a.status\n' + printf 'stale: fm-a (routine reminder)\n' exit 0 fi printf 'watcher: started pid=%s (beacon fresh) recovery-generation=fixture-generation\n' "$$" @@ -966,11 +966,11 @@ writeFileSync(`${process.env.FM_HOME}/state/.lock`, `${process.pid}\n`); writeFileSync( `${process.env.FM_HOME}/state/.wake-queue`, "1\t1\tsignal\ttask-a.status\tneeds-decision: task-a.status\n" + - "2\t2\tsignal\ttask-a.status\tsignal: task-a.status\n", + "2\t2\tstale\tfm-a\tstale: fm-a (routine reminder)\n", ); const mod = await import(pathToFileURL(process.env.PLUGIN).href); mod.default(pi); -await tool.execute("tool-call-later-routine-signal", {}, undefined, undefined, {}); +await tool.execute("tool-call-later-stale-alias", {}, undefined, undefined, {}); for (let i = 0; i < 250 && offers.length === 0; i += 1) { await new Promise((resolve) => setTimeout(resolve, 10)); } @@ -978,19 +978,19 @@ for (let i = 0; i < 250 && !prompt; i += 1) { await new Promise((resolve) => setTimeout(resolve, 10)); } if (offers.length !== 1 || offers[0].eligible !== false) { - throw new Error(`a later routine signal bypassed its unread decision: ${JSON.stringify(offers)}`); + throw new Error(`a later stale alias bypassed its unread decision: ${JSON.stringify(offers)}`); } -if (!prompt.includes("FIRSTMATE WATCHER WAKE: signal: task-a.status")) { - throw new Error(`a later routine signal with an unread decision did not wake main: ${prompt}`); +if (!prompt.includes("FIRSTMATE WATCHER WAKE: stale: fm-a")) { + throw new Error(`a later stale alias with an unread decision did not wake main: ${prompt}`); } writeFileSync(process.env.FM_STOP_FILE, "stop\n"); process.exit(0); EOF ) status=$? - expect_code 0 "$status" "an unread pending-reply escalation must keep a later routine signal on main: $out" - [ -z "$out" ] || fail "Pi unread pending-reply precedence test printed output: $out" - pass "an unread pending-reply escalation keeps later routine signals on main" + expect_code 0 "$status" "an unread pending-reply escalation must keep a later stale alias on main: $out" + [ -z "$out" ] || fail "Pi unread pending-reply alias test printed output: $out" + pass "an unread pending-reply escalation keeps later stale aliases on main" } # The captain's accepted rule names ONE coalesced trigger batch, not only a @@ -3984,7 +3984,7 @@ test_pi_branch_offer_flags_heartbeat test_pi_heartbeat_is_not_ridden_into_main_by_a_co_present_check test_pi_main_only_check_classes_stay_on_main test_pi_captain_held_trailing_whitespace_stale_stays_on_main -test_pi_unread_pending_reply_forces_later_routine_signal_to_main +test_pi_unread_pending_reply_forces_later_stale_alias_to_main test_pi_distinct_files_mixed_batch_routes_whole_batch_to_main test_pi_heartbeat_is_not_ridden_into_main_by_a_co_present_needs_decision test_pi_heartbeat_restoration_failure_stays_on_main From 30eacf0e6e96206892d2f8fc034c5c1a0cd14419 Mon Sep 17 00:00:00 2001 From: kunchenguid Date: Sat, 5 Sep 2026 01:25:21 -0700 Subject: [PATCH 14/17] no-mistakes(review): Route surfaced captain-held signals directly to main --- bin/fm-classify-lib.sh | 4 ++++ bin/fm-watch.sh | 6 +++--- tests/fm-pi-watch-extension.test.sh | 27 +++++++++++++-------------- tests/fm-watch-triage.test.sh | 18 ++++++++++++++++++ 4 files changed, 38 insertions(+), 17 deletions(-) diff --git a/bin/fm-classify-lib.sh b/bin/fm-classify-lib.sh index 9e204de33ca..7b905aabbee 100755 --- a/bin/fm-classify-lib.sh +++ b/bin/fm-classify-lib.sh @@ -1655,6 +1655,10 @@ status_span_first_actionable_record() { # [record- while IFS= read -r line || [ -n "$line" ]; do line_number=$((line_number + 1)) case "$line" in *[![:space:]]*) ;; *) continue ;; esac + if status_is_captain_held "$line"; then + _fm_span_needs_decision=1 + continue + fi status_is_captain_relevant "$line" || continue verb=$(status_line_verb "$line") case "$verb" in diff --git a/bin/fm-watch.sh b/bin/fm-watch.sh index 5c4fd5bcc49..2bef162af14 100755 --- a/bin/fm-watch.sh +++ b/bin/fm-watch.sh @@ -1242,11 +1242,11 @@ signal_files_actionable() { # ... fi endpoint=${record%%$'\t'*}; rest=${record#*$'\t'}; ident=${rest%%$'\t'*} FM_SIGNAL_SURFACE_ENDPOINTS="${FM_SIGNAL_SURFACE_ENDPOINTS}${f}"$'\t'"${endpoint}"$'\t'"${ident}"$'\n' + if [ "$needs_decision" -eq 1 ]; then + FM_SIGNAL_NEEDS_DECISION_FILES="${FM_SIGNAL_NEEDS_DECISION_FILES} ${f}" + fi if [ "$rc" -eq 0 ]; then found=0 - if [ "$needs_decision" -eq 1 ]; then - FM_SIGNAL_NEEDS_DECISION_FILES="${FM_SIGNAL_NEEDS_DECISION_FILES} ${f}" - fi fi done return "$found" diff --git a/tests/fm-pi-watch-extension.test.sh b/tests/fm-pi-watch-extension.test.sh index ddad7a233ee..dbbbb1318c3 100755 --- a/tests/fm-pi-watch-extension.test.sh +++ b/tests/fm-pi-watch-extension.test.sh @@ -810,10 +810,9 @@ CLASSES pass "every main-only check class still reaches main, never the supervision branch" } -# A captain-held stale reminder remains a decision trigger when whitespace-only -# lines follow the hold. A co-present routine signal remains independently -# branch-ownable but cannot take this stale close away from main. -test_pi_captain_held_trailing_whitespace_stale_stays_on_main() { +# A surfaced captain-held signal uses the existing decision-owned payload, so a +# co-present routine row cannot take the signal close away from main. +test_pi_captain_held_signal_stays_on_main() { local repo home plugin log stop out status repo="$TMP_ROOT/pi-captain-held-whitespace-root" home="$TMP_ROOT/pi-captain-held-whitespace-home" @@ -831,7 +830,7 @@ printf 'arm=%s\n' "$$" >> "${FM_ARM_LOG:?}" count=$(grep -c '^arm=' "$FM_ARM_LOG") if [ "$count" -eq 1 ]; then printf 'watcher: started pid=%s (beacon fresh)\n' "$$" - printf 'stale: fm-window (routine follow-up)\n' + printf 'signal: task-a.status\n' exit 0 fi printf 'watcher: started pid=%s (beacon fresh) recovery-generation=fixture-generation\n' "$$" @@ -875,29 +874,29 @@ const pi = { writeFileSync(`${process.env.FM_HOME}/state/.lock`, `${process.pid}\n`); writeFileSync( `${process.env.FM_HOME}/state/.wake-queue`, - "1\t1\tstale\tfm-window\tstale: fm-window (routine follow-up)\n" + + "1\t1\tsignal\ttask-a.status\tneeds-decision: task-a.status\n" + "1\t2\tsignal\ttask-a.status\tsignal: routine follow-up\n", ); const mod = await import(pathToFileURL(process.env.PLUGIN).href); mod.default(pi); -await tool.execute("tool-call-captain-held-whitespace-stale", {}, undefined, undefined, {}); +await tool.execute("tool-call-captain-held-signal", {}, undefined, undefined, {}); for (let i = 0; i < 250 && !prompt; i += 1) { await new Promise((resolve) => setTimeout(resolve, 10)); } if (offers.length !== 1 || offers[0].eligible !== false) { - throw new Error(`a captain-held stale trigger with trailing whitespace was offered to the branch: ${JSON.stringify(offers)}`); + throw new Error(`a captain-held signal trigger was offered to the branch: ${JSON.stringify(offers)}`); } -if (!prompt.includes("FIRSTMATE WATCHER WAKE: stale: fm-window")) { - throw new Error(`a captain-held stale trigger with trailing whitespace did not reach main: ${prompt}`); +if (!prompt.includes("FIRSTMATE WATCHER WAKE: signal: task-a.status")) { + throw new Error(`a captain-held signal trigger did not reach main: ${prompt}`); } writeFileSync(process.env.FM_STOP_FILE, "stop\n"); process.exit(0); EOF ) status=$? - expect_code 0 "$status" "a captain-held stale trigger with trailing whitespace must stay on main: $out" - [ -z "$out" ] || fail "Pi captain-held whitespace stale test printed output: $out" - pass "a captain-held stale trigger ignores trailing whitespace and reaches main" + expect_code 0 "$status" "a captain-held signal trigger must stay on main: $out" + [ -z "$out" ] || fail "Pi captain-held signal test printed output: $out" + pass "a captain-held signal trigger reaches main with routine rows present" } # An unread second-mate pending-reply escalation keeps a later stale reminder @@ -3983,7 +3982,7 @@ test_pi_branch_offer_owns_actionable_wake test_pi_branch_offer_flags_heartbeat test_pi_heartbeat_is_not_ridden_into_main_by_a_co_present_check test_pi_main_only_check_classes_stay_on_main -test_pi_captain_held_trailing_whitespace_stale_stays_on_main +test_pi_captain_held_signal_stays_on_main test_pi_unread_pending_reply_forces_later_stale_alias_to_main test_pi_distinct_files_mixed_batch_routes_whole_batch_to_main test_pi_heartbeat_is_not_ridden_into_main_by_a_co_present_needs_decision diff --git a/tests/fm-watch-triage.test.sh b/tests/fm-watch-triage.test.sh index 0081d047c60..ffeb8c04cc8 100755 --- a/tests/fm-watch-triage.test.sh +++ b/tests/fm-watch-triage.test.sh @@ -1562,6 +1562,23 @@ test_needs_decision_reconciliation_required_still_marked() { pass "a reconciliation-required needs-decision row's queued payload is still marked needs-decision:" } +test_captain_held_signal_payload_marked_for_branch_exclusion() { + local dir state fakebin out status_file pid + dir=$(make_case captain-held-signal-payload); state="$dir/state"; fakebin="$dir/fakebin" + out="$dir/watch.out" + status_file="$state/task.status" + printf 'captain-held [key=route]: awaiting the captain\n' > "$status_file" + export FM_FAKE_CREW_STATE='state: unknown · source: none · finished worker' + watch_bg "$state" "$fakebin" "$out" + pid=$! + wait_for_exit "$pid" 100 || fail "watcher did not exit for a captain-held no-verb signal" + grep -F "signal: $status_file" "$out" >/dev/null \ + || fail "a captain-held no-verb signal changed its wake reason: $(cat "$out")" + grep -F "$(printf 'signal\ttask.status\tneeds-decision:')" "$state/.wake-queue" >/dev/null \ + || fail "a captain-held signal was not payload-marked for branch exclusion: $(cat "$state/.wake-queue")" + pass "a captain-held no-verb signal is marked for main-only routing" +} + test_pending_reply_escalation_signal_payload_marked_for_branch_exclusion() { local dir state fakebin out status_file pid corr dir=$(make_case pending-reply-escalation-payload); state="$dir/state"; fakebin="$dir/fakebin" @@ -4125,6 +4142,7 @@ test_self_announced_close_does_not_rewake_but_next_note_does test_actionable_signal_surfaced test_needs_decision_signal_payload_marked_for_branch_exclusion test_needs_decision_reconciliation_required_still_marked +test_captain_held_signal_payload_marked_for_branch_exclusion test_pending_reply_escalation_signal_payload_marked_for_branch_exclusion test_ordinary_blocked_signal_payload_remains_branch_eligible test_routine_signal_payload_not_marked_needs_decision From 7df2931fdd2291858978060230afcd3f758830c4 Mon Sep 17 00:00:00 2001 From: kunchenguid Date: Sat, 5 Sep 2026 02:03:50 -0700 Subject: [PATCH 15/17] no-mistakes(document): Document decision-owned main routing --- bin/fm-classify-lib.sh | 10 +++++++--- bin/fm-watch.sh | 19 ++++++++++--------- docs/pi-supervision-branch.md | 14 ++++++++------ docs/supervision-protocols/pi.md | 3 +-- 4 files changed, 26 insertions(+), 20 deletions(-) diff --git a/bin/fm-classify-lib.sh b/bin/fm-classify-lib.sh index 7b905aabbee..cca7435d050 100755 --- a/bin/fm-classify-lib.sh +++ b/bin/fm-classify-lib.sh @@ -1563,12 +1563,16 @@ window_to_task() { # Capture the bytes of an append-only status log at or after under # one size-and-identity snapshot. -# The record form prints `\t\t` and returns 0 when +# The record form produces `\t\t` and returns 0 when # the span has actionable events, joining every such event in source order with # ` ; ` so callers report the complete captured span before committing it. +# With optional , it assigns that record instead of printing it; with +# optional , it also assigns 1 when the span newly surfaces a +# needs-decision, captain-held declaration, or pending-reply escalation, otherwise +# 0. This side-band classification never changes the event text. # It returns 1 after a successful classification with no actionable event; an -# existing log still prints its committable endpoint and identity, while an absent -# log is the ordinary empty case and prints no record. +# existing log still produces its committable endpoint and identity, while an absent +# log is the ordinary empty case and produces no record. # It returns 2 with no committable endpoint when an existing status object cannot # be classified. # The simpler wrapper prints only the event field, and the predicate discards the diff --git a/bin/fm-watch.sh b/bin/fm-watch.sh index 2bef162af14..71178199838 100755 --- a/bin/fm-watch.sh +++ b/bin/fm-watch.sh @@ -1211,14 +1211,15 @@ run_check_capture() { # hiding the `needs-decision`, `blocked`, `failed`, or `done` event that arrived # just before it: the .seen-* marker advances either way, so an event absorbed # here is never re-read. Non-.status arguments (.turn-ended markers, which carry -# no verb) are skipped. A 1 here is NOT "benign" on its own: a no-verb signal -# still needs the authoritative working proof or the eligible opt-in bare -# turn-end pane-churn proof before it is benign. +# no verb) are skipped. A 1 here is NOT "benign" on its own: a no-verb signal, +# including a newly declared captain hold, still needs the authoritative working +# proof or the eligible opt-in bare turn-end pane-churn proof before it is benign. # Also populates FM_SIGNAL_NEEDS_DECISION_FILES (space-separated status-file -# paths) with exactly the files whose newly classified span carries a -# needs-decision event, so the caller can route those - and only those - signal -# rows as main-only (docs/pi-supervision-branch.md). Stale and heartbeat rows -# retain their existing eligibility rules. +# paths) with exactly the files whose newly classified span carries one of the +# decision-owned classes defined by the status-span contract, so the caller can +# route those - and only those - signal rows as main-only +# (docs/pi-supervision-branch.md). Stale and heartbeat rows retain their existing +# eligibility rules. signal_files_actionable() { # ... local f task record rest endpoint ident needs_decision rc found=1 FM_SIGNAL_SURFACE_ENDPOINTS='' @@ -1753,13 +1754,13 @@ EOF # shellcheck disable=SC2086 # $files is a space-separated status-path list (ids carry no spaces) signal_files_actionable $files signal_actionable=$? - # A needs-decision file's queued row payload is marked "needs-decision:" + # A decision-owned file's queued row payload is marked "needs-decision:" # instead of the ordinary "signal:" below (other files in the same batch # keep the ordinary payload). The wake reason line itself, and every # harness-arm consumer that pattern-matches it, stays byte-identical - # only the per-row payload changes, which is what # docs/pi-supervision-branch.md's Pi-only branch dispatcher reads to keep a - # needs-decision row off the supervision branch (fm-branch-dispatch.ts, + # decision-owned row off the supervision branch (fm-branch-dispatch.ts, # fm-primary-pi-watch.ts). Every other harness and script keeps seeing the # exact same "signal:$files" wake it always has. # shellcheck disable=SC2086 # same space-separated status-path list diff --git a/docs/pi-supervision-branch.md b/docs/pi-supervision-branch.md index 54c178a2e52..144d0c76f5d 100644 --- a/docs/pi-supervision-branch.md +++ b/docs/pi-supervision-branch.md @@ -7,7 +7,7 @@ This document stays the owner and the contract. Fleet supervision on the Pi primary harness runs on a second conversation - the supervision branch - inside the same `pi` process as the captain's chat. Supervision is default-on: once a Pi primary session owns this home's fleet lock, the branch handles eligible task-local rows from ordinary actionable wakes plus heartbeat scans that the cheap bash-level scan flags as possibly captain-relevant, then merges each outcome back into the captain conversation's transcript. -Ordinary main-only rows remain on main even when eligible task-local rows share their queue. +Ordinary main-only rows remain on main even when eligible task-local rows share their queue, except that a decision-owned signal or stale trigger keeps its entire coalesced trigger batch on main. An unresolvable row makes the scan unsafe and returns the whole wake to main, and every watcher-failure alarm also stays on main. Captain-relevant branch outcomes persist as exact, sequence-keyed visible transcript entries and then open one sequence-keyed processing turn on main, which stays open until main acknowledges that sequence. The design source is the captain-approved forked-supervision architecture board, a captain-private fleet record (a self-contained HTML explainer with the measured cache and judgment evidence); this document records the shape it landed as, and the delivering PR cites the board artifact itself. @@ -23,9 +23,11 @@ The supervision branch itself is Pi-only by construction: - Wake dispatch: `.pi/extensions/fm-primary-pi-watch.ts` stays the dispatcher; `.pi/extensions/lib/fm-branch-dispatch.ts` owns the offer handshake and row eligibility, while [`watcher-continuity.md`](watcher-continuity.md#per-actor-acknowledgement) owns the per-actor consume contract. A successful row grant transfers ownership of exactly the currently branch-eligible rows to the branch; a check-kind triggering close (merge-confirmation polls, Relay mentions, credential/auth failures, and every other legitimately main-only class) is never offered even when other rows are eligible, no acceptor (extension absent, away mode, branch broken) keeps today's wake-to-main path for that close, and watcher-failure alarms always go to main because only main can repair the watcher cycle. - A `needs-decision:` event surfaced by `bin/fm-watch.sh`'s actionable signal path gets the identical treatment even though it keeps the ordinary `signal` kind. - `signal_files_actionable` marks that signal row's queued payload `needs-decision:` instead of `signal:`, and `scopeForUnreadWake` excludes the marked row from what the branch may claim. It also excludes a stale row when the mapped task's current declaration is `captain-held`, so the bounded reminder returns to main to answer or release the held decision. - The dispatcher resolves trigger keys and every currently unread excluded decision row to task identity before cross-referencing them: any signal/stale close containing a decision row goes wholly to main, including a batch that also contains routine rows, and an unread decision for one task keeps every later signal or stale close for that same task on main until the decision row is read, regardless of whether the rows use its status-file key or window alias. Other tasks remain independently eligible. + A decision-owned event surfaced by `bin/fm-watch.sh`'s signal path gets the identical treatment even though it keeps the ordinary `signal` kind. + `signal_files_actionable` marks the queued payload `needs-decision:` for a newly surfaced `needs-decision`, a `captain-held` declaration surfaced through the no-verb fallback, or a pending-reply second-mate escalation; `scopeForUnreadWake` excludes every marked row from what the branch may claim. + For a stale row, `scopeForUnreadWake` folds the mapped task's status log and excludes the row when any `needs-decision` remains open or the current meaningful declaration is `captain-held`; an unreadable or symlinked status log fails the scope closed rather than influencing routing. + The dispatcher resolves trigger keys and every currently unread excluded decision row to task identity before cross-referencing them: any signal or stale trigger containing a decision-owned task goes wholly to main, including a batch that also contains routine rows, and an unread decision for one task keeps every later signal or stale trigger for that same task on main until the decision row is read, regardless of whether the rows use its status-file key or window alias. + Other tasks remain independently eligible. The wake message itself retains its existing shape, so other harness-arm scripts remain unchanged. Heartbeat handling remains independent. A fleet-wide heartbeat keeps its own all-or-nothing rule (see "Heartbeat routing" below): it takes every branch-ownable unread row or none of them. @@ -151,13 +153,13 @@ 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, 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, model and effort selection, and (in `test_branch_dispatch_classifies_main_only_rows_and_writes_the_eligible_snapshot`) decision-owned signal and captain-held stale rows' exclusion from `eligibleSeqs`, their presence in `needsDecisionKeys`, non-vetoing behavior for unrelated eligible rows, and decision-only queues reading as ordinary main-only absence. +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, model and effort selection, and (in `test_branch_dispatch_classifies_main_only_rows_and_writes_the_eligible_snapshot`) decision-owned signal and stale rows' exclusion from `eligibleSeqs`, their presence in `needsDecisionKeys`, task alias resolution, reserved-key configuration, status-log race and symlink refusal, non-vetoing behavior for unrelated eligible rows, and decision-only queues reading as ordinary main-only absence. `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-main-only-rows, main-only-check-class, captain-held-stale-stays-on-main, and mixed-signal-routing tests remain in `tests/fm-pi-watch-extension.test.sh` (the last two routing classes exercise `offerWakeToBranch`'s trigger-key cross-reference end to end), 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`. It also covers the off-thread delivery contract behaviorally: that a delivery leaves the event loop running rather than blocking it, that interleaved reports stay ordered and exactly once, that a session replaced mid-delivery neither loses nor duplicates an outcome, and that a failing store script surfaces without losing or doubling one. -`tests/fm-watch-triage.test.sh` covers `bin/fm-watch.sh`'s side of the contract end to end: a needs-decision signal row's queued payload is marked `needs-decision:`, a needs-decision whose key transition was rejected by the reserved-key vocabulary (fm-classify-lib.sh's "reconciliation-required: " wrapper) is still marked, and a routine captain-relevant signal's payload stays unmarked. +`tests/fm-watch-triage.test.sh` covers `bin/fm-watch.sh`'s side of the contract end to end: needs-decision, no-verb captain-held, and pending-reply second-mate escalation signal rows are marked `needs-decision:`, a needs-decision whose key transition was rejected by the reserved-key vocabulary (`fm-classify-lib.sh`'s `reconciliation-required:` wrapper) is still marked, and ordinary blocked or captain-relevant signals stay unmarked. Live guards: `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. `FM_PI_BRANCH_RESPONSIVENESS_E2E=1 tests/fm-pi-branch-responsiveness-live-e2e.test.sh` answers the question only a real TUI can: it types into an isolated Pi pane while outcomes are delivered and fails if keystroke echo leaves the class of the same machine's extension-free floor. Record dated current results in [docs/verification/runtime-backends.md](verification/runtime-backends.md). diff --git a/docs/supervision-protocols/pi.md b/docs/supervision-protocols/pi.md index 3c0c3b8b2ec..1f9a1ea6d44 100644 --- a/docs/supervision-protocols/pi.md +++ b/docs/supervision-protocols/pi.md @@ -20,8 +20,7 @@ When this session owns supervision and away mode is not active: The arm mechanism above is extension-owned, not a model tool call, but a manual recovery probe that backgrounds, pipes, or bundles the arm is denied automatically by the PreToolUse seatbelt (`bin/fm-arm-pretool-check.sh`, wired into the turn-end guard extension at `__FM_PI_TURNEND_EXT__`). The supervision branch is default-on (docs/pi-supervision-branch.md): whenever this session owns the fleet lock and away mode is not active, the watcher extension hands eligible task-local rows from ordinary actionable wakes, plus selected fleet-wide heartbeat reviews, to the in-process supervision branch while main-only rows remain queued for this conversation. -A needs-decision signal makes its coalesced signal/stale trigger batch main-owned in whole. -Fleet-wide heartbeat scans remain independent and are never pulled to main merely because a needs-decision row is queued. +Decision-owned signal and stale routing, including whole-batch precedence and the independent heartbeat exception, is owned by [docs/pi-supervision-branch.md](../pi-supervision-branch.md#components-and-their-owners). A no-change heartbeat outcome explicitly reported with `task=fleet` and `silent=true` is delivered silently with no rendered note, while every other routine outcome returns as an appended, rendered note that leads with ⛵ then the dim outcome text. A captain-facing outcome instead appears as one exact, sequence-keyed visible transcript entry, and then arrives in this conversation as one hidden supervision processing request listing each `[seq N] task: summary` it covers. That request is the one turn in which MAIN processes the outcome: give the captain a visible response where one is due, answer or escalate a decision, act on a blocker or failure, or record that no further action is needed, then call the `fm_branch_processed` tool with the highest sequence the request listed, exactly once. From f3304d0b8031a0fbafe5ae2035c18616f467a8a7 Mon Sep 17 00:00:00 2001 From: kunchenguid Date: Sat, 5 Sep 2026 02:34:15 -0700 Subject: [PATCH 16/17] no-mistakes(ci): Fixed captain-held spans to remain actionable while crew working evidence is positive, ensuring the watcher delivers their main-only marker. Updated the executable regression test to cover this case. Verified with the full fm-watch-triage suite, bash syntax checks, and git diff checks. Shellcheck reported only pre-existing test harness warnings (SC1091/SC2034) --- bin/fm-classify-lib.sh | 1 + tests/fm-watch-triage.test.sh | 11 +++++++---- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/bin/fm-classify-lib.sh b/bin/fm-classify-lib.sh index cca7435d050..69e8dfb5cbf 100755 --- a/bin/fm-classify-lib.sh +++ b/bin/fm-classify-lib.sh @@ -1661,6 +1661,7 @@ status_span_first_actionable_record() { # [record- case "$line" in *[![:space:]]*) ;; *) continue ;; esac if status_is_captain_held "$line"; then _fm_span_needs_decision=1 + rc=0 continue fi status_is_captain_relevant "$line" || continue diff --git a/tests/fm-watch-triage.test.sh b/tests/fm-watch-triage.test.sh index ffeb8c04cc8..c82234b6c68 100755 --- a/tests/fm-watch-triage.test.sh +++ b/tests/fm-watch-triage.test.sh @@ -1562,21 +1562,24 @@ test_needs_decision_reconciliation_required_still_marked() { pass "a reconciliation-required needs-decision row's queued payload is still marked needs-decision:" } +# A captain-held declaration is itself actionable. Positive evidence that the +# crew is still working must not absorb the signal before its main-only marker +# can be delivered. test_captain_held_signal_payload_marked_for_branch_exclusion() { local dir state fakebin out status_file pid dir=$(make_case captain-held-signal-payload); state="$dir/state"; fakebin="$dir/fakebin" out="$dir/watch.out" status_file="$state/task.status" printf 'captain-held [key=route]: awaiting the captain\n' > "$status_file" - export FM_FAKE_CREW_STATE='state: unknown · source: none · finished worker' + export FM_FAKE_CREW_STATE='state: working · source: run-step · still wrapping up' watch_bg "$state" "$fakebin" "$out" pid=$! - wait_for_exit "$pid" 100 || fail "watcher did not exit for a captain-held no-verb signal" + wait_for_exit "$pid" 100 || fail "watcher absorbed a captain-held signal while the crew was still working" grep -F "signal: $status_file" "$out" >/dev/null \ - || fail "a captain-held no-verb signal changed its wake reason: $(cat "$out")" + || fail "a captain-held signal changed its wake reason: $(cat "$out")" grep -F "$(printf 'signal\ttask.status\tneeds-decision:')" "$state/.wake-queue" >/dev/null \ || fail "a captain-held signal was not payload-marked for branch exclusion: $(cat "$state/.wake-queue")" - pass "a captain-held no-verb signal is marked for main-only routing" + pass "a captain-held signal stays actionable while the crew is still working" } test_pending_reply_escalation_signal_payload_marked_for_branch_exclusion() { From e24a937f9294f6ed246183aafd956109b88dd56c Mon Sep 17 00:00:00 2001 From: kunchenguid Date: Sat, 5 Sep 2026 03:01:43 -0700 Subject: [PATCH 17/17] no-mistakes(ci): Fixed the CI regression: captain-held transfers now retain their established non-actionable stale classification while the signal-routing side-band still surfaces them main-only. Verified with tests/fm-daemon.test.sh, tests/fm-watch-triage.test.sh, bash syntax checks, and git diff --check --- bin/fm-classify-lib.sh | 4 +++- bin/fm-watch.sh | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/bin/fm-classify-lib.sh b/bin/fm-classify-lib.sh index 69e8dfb5cbf..8bd4fe746ad 100755 --- a/bin/fm-classify-lib.sh +++ b/bin/fm-classify-lib.sh @@ -1660,8 +1660,10 @@ status_span_first_actionable_record() { # [record- line_number=$((line_number + 1)) case "$line" in *[![:space:]]*) ;; *) continue ;; esac if status_is_captain_held "$line"; then + # A transfer closes the status-log decision and remains non-actionable to + # stale classification. The side-band marker lets signal routing surface + # the captain-owned hold without changing that established stale verdict. _fm_span_needs_decision=1 - rc=0 continue fi status_is_captain_relevant "$line" || continue diff --git a/bin/fm-watch.sh b/bin/fm-watch.sh index 71178199838..d1b3bc2805c 100755 --- a/bin/fm-watch.sh +++ b/bin/fm-watch.sh @@ -1246,7 +1246,7 @@ signal_files_actionable() { # ... if [ "$needs_decision" -eq 1 ]; then FM_SIGNAL_NEEDS_DECISION_FILES="${FM_SIGNAL_NEEDS_DECISION_FILES} ${f}" fi - if [ "$rc" -eq 0 ]; then + if [ "$rc" -eq 0 ] || [ "$needs_decision" -eq 1 ]; then found=0 fi done