From 83930d65952e92b11ba4a48acd7a771faf8a27f8 Mon Sep 17 00:00:00 2001 From: kunchenguid Date: Fri, 18 Sep 2026 15:35:06 -0700 Subject: [PATCH 01/11] feat: park main under the away posture on Pi While the away-posture record exists on a Pi primary, the supervision branch takes every actionable wake, no processing turn opens on main, captain rows accumulate for the return brief, and main's standing authority relocates to the branch through the existing guarded scripts. - lib/fm-branch-dispatch.ts: read the record at every routing decision; while it exists claim check, decision-owned, and heartbeat rows too, keeping the two broken-queue vetoes; expose checkSeqs so a claimed check row lifts task scoping. - fm-primary-pi-watch.ts: offer every actionable row under the record; a declined wake and every watcher-failure alarm still reach main. - fm-branch-supervision.ts: drop the legacy .afk decline; append a fixed POSTURE: AWAY tail carrying the record's read-back verbatim per wake; open no processing request while the record exists, re-checked immediately before a request would open and at every run boundary; present the accumulated rows at the first run boundary after archive. - fm-lease-lib.sh: fm_lease_forbid_branch passes the branch for opted-in actions only while fm-afk-contract.sh validate succeeds on a confirmed live record; PR merge, fresh spawn, and decision answer opt in, local landing never does. - fm-send.sh: a --resolve-key naming an open needs-decision or captain-held task is a decision answer and meets the partition; blocked: keys stay steering. - fm-spawn.sh: enforce the record's spend cap for a fresh ordinary spawn by either actor; relaunches and secondmates exempt. - fm-branch-prompt.sh: fixed Postures section and the verbatim ask-user-authority policy; the prefix stays byte-stable. - fm-afk-return.sh: count what the away session handled from the store. - docs, afk skill, AGENTS.md stub: main parked on Pi, green merge gate absolute while away. - tests: watcher and branch extension suites, fleet-record, merge, and decision-answer suites cover the relocation, the vetoes, the tail, the parked processing turn, the cancellation, the re-presentation, and the spend cap; dated live-guard evidence recorded. --- .agents/skills/afk/SKILL.md | 7 +- .pi/extensions/fm-branch-supervision.ts | 84 +++++++++-- .pi/extensions/fm-primary-pi-watch.ts | 19 ++- .pi/extensions/lib/fm-branch-dispatch.ts | 74 ++++++++-- AGENTS.md | 4 +- bin/fm-afk-return.sh | 6 +- bin/fm-branch-prompt.sh | 28 +++- bin/fm-lease-lib.sh | 49 ++++++- bin/fm-merge-local.sh | 7 +- bin/fm-pr-merge.sh | 14 +- bin/fm-send.sh | 23 +++ bin/fm-spawn.sh | 41 +++++- docs/architecture.md | 2 +- docs/configuration.md | 5 +- docs/pi-supervision-branch.md | 47 ++++-- docs/supervision-protocols/pi.md | 7 +- docs/verification/runtime-backends.md | 26 ++++ tests/fm-branch-supervision.test.sh | 87 +++++++++++ tests/fm-pi-branch-extension.test.sh | 162 ++++++++++++++++++++- tests/fm-pi-watch-extension.test.sh | 177 +++++++++++++++++++++++ tests/fm-pr-merge.test.sh | 74 ++++++++++ tests/fm-send-resolve-key.test.sh | 64 ++++++++ 22 files changed, 935 insertions(+), 72 deletions(-) diff --git a/.agents/skills/afk/SKILL.md b/.agents/skills/afk/SKILL.md index 0046d63e020..4504164ce77 100644 --- a/.agents/skills/afk/SKILL.md +++ b/.agents/skills/afk/SKILL.md @@ -2,7 +2,7 @@ name: afk description: >- Enter the away posture when the captain invokes /afk, says they are going afk, `state/.afk-contract` or `state/.afk` exists, an incoming message starts with `FM_INJECT_MARK`, or any `state/.subsuper-*` marker is involved. - It reads the captain's away words back as a mandate, writes the durable away-posture record after their go, announces hold-for-return only at entry, keeps the one supervision session running in the away posture (no daemon on Pi; the daemon still delivers batched digests on the other harnesses for now), and on the first unmarked message renders the return brief from durable records before ordinary work resumes. + It reads the captain's away words back as a mandate, writes the durable away-posture record after their go, announces hold-for-return only at entry, keeps the one supervision session running in the away posture (on Pi the supervision branch takes every wake with main parked; the daemon still delivers batched digests on the other harnesses for now), and on the first unmarked message renders the return brief from durable records before ordinary work resumes. user-invocable: true metadata: internal: true @@ -41,6 +41,8 @@ Hold-for-return is the default and the only reach profile this release records: 4. **Per harness, after the record exists:** - **Pi and pi-signed**: stop here. The away daemon is no longer launched on Pi; the ordinary supervision session (`docs/pi-supervision-branch.md`) keeps running with the record present, and `bin/fm-afk-launch.sh start` refuses on these harnesses. + With the record present main is parked: the supervision branch takes every wake, captain outcomes accumulate for the return brief, and main's standing authority relocates to the branch through the guarded scripts (`docs/pi-supervision-branch.md` "Postures"); main still wakes only for a broken branch or a watcher failure. + `/quiet` needs nothing extra on Pi: the attended branch already keeps routine wakes out of this conversation, so quiet-while-present is the attended posture's own shape there. - **Harness WITH a native in-pane tracked-background tool** (claude's background bash, grok's background tool): run `bin/fm-afk-launch.sh start-native`, then run `FM_AFK_STATE_PREPARED=1 bin/fm-afk-start.sh` through that native tool. This is a deliberate no-separate-terminal exception because the harness-hosted job creates no terminal or layout mutation, and a shell launcher cannot invoke a harness-native background tool. If the native launch fails, run `bin/fm-afk-launch.sh stop` to roll back the prepared lifecycle. @@ -58,6 +60,8 @@ Hold-for-return is the default and the only reach profile this release records: Declared external waits keep their condition-aware, hours-long recheck cadence (`bin/fm-watch.sh`, `bin/fm-classify-lib.sh`). - Recorded clauses are not executed by this release. Forbidden, destructive, irreversible, and security-sensitive actions are never pre-authorizable regardless of clause text, no recorded clause is authority by itself, and merge authority plus ask-user findings keep exactly the rules they have when attended (`AGENTS.md` section 7 and `ask-user-authority`); anything that needs the captain holds for their return. +- On Pi, main is parked and the supervision branch handles every wake under main's standing authority plus the record's merge grants, through the same guarded scripts main would use: a granted or `yolo` task merges only green at its live head, already-queued work whose blockers cleared dispatches within the spend cap, and only a finding `ask-user-authority` lets firstmate decide is answered. + Anything else holds for the return, local-only landing always waits for the captain, and main still wakes only for a broken branch or a watcher failure (`docs/pi-supervision-branch.md` "Postures"). - The session-start digest reports the posture under its AFK subsection, so a restart re-enters the posture from the record, not from memory. ## How to exit: the return @@ -88,6 +92,7 @@ While the away-posture record exists, a merge proceeds only when that task's rec A merge grant never releases a captain hold, and it expires when the away record is archived. `--allow-red` remains attended-only and is refused while the record exists. A merge under away authority must be synchronous; `fm-pr-merge.sh` refuses auto-merge and any GitHub queue state that cannot prove an immediate merge while the record exists. +The same gates bind whichever actor performs the action: on Pi the parked main's standing authority relocates to the supervision branch, which meets exactly these rules, and the spend cap recorded at entry is enforced by `fm-spawn.sh` for both actors while the record exists. A mandate clause is the captain's explicit instruction given before leaving, recorded with its named object and condition; a clause is never inferred, never applied by analogy, and expires at return. Forbidden, destructive, irreversible, and security-sensitive actions are never pre-authorizable regardless of clause text, and no recorded clause is authority by itself. This release records clauses and does not execute them. diff --git a/.pi/extensions/fm-branch-supervision.ts b/.pi/extensions/fm-branch-supervision.ts index 682f0a087ab..8b0c58c9ab3 100644 --- a/.pi/extensions/fm-branch-supervision.ts +++ b/.pi/extensions/fm-branch-supervision.ts @@ -20,8 +20,21 @@ // file lives in .pi/extensions, so no // other harness ever loads it. Supervision is default-on for every task once // this Pi session owns the fleet lock: no captain grant file is required. -// Away mode (or a broken branch between its bounded recovery probes) keeps -// today's wake-to-main behavior untouched regardless. +// A broken branch between its bounded recovery probes keeps today's +// wake-to-main behavior. +// +// Postures (docs/pi-supervision-branch.md "Postures"): the away-posture +// record state/.afk-contract (owner: bin/fm-afk-contract.sh) is read as a +// file at the tail of every wake and at every captain-outcome presentation, +// never inferred from chat and never placed in the byte-stable prompt prefix. +// While it exists the branch takes every row the dispatcher offers, the +// record's read-back is appended to the wake message so the branch knows the +// posture and the recorded facts at execution time, captain-verdict outcomes +// accumulate unprocessed in the store instead of opening the processing turn +// on the parked main, and the guarded scripts pass the branch actor under +// main's standing authority (bin/fm-lease-lib.sh). The first unmarked captain +// message archives the record; the next run boundary then presents the +// accumulated captain rows exactly as after any other gap. // // Prefix stability (the cache contract, owner: bin/fm-branch-prompt.sh // header): the branch's system prompt is the generator's byte-stable output, @@ -97,6 +110,7 @@ import { } from "./lib/fm-calm-visibility.ts"; import { activateEligibleRowsOwner, + afkPostureRecordPresent, deactivateEligibleRowsOwner, FM_BRANCH_DISPATCH_EVENT, releaseEligibleRowsSnapshot, @@ -123,11 +137,11 @@ const fmHome = process.env.FM_HOME || process.env.FM_ROOT_OVERRIDE || root; const fmRoot = process.env.FM_ROOT_OVERRIDE || root; const state = process.env.FM_STATE_OVERRIDE || `${fmHome}/state`; const config = process.env.FM_CONFIG_OVERRIDE || `${fmHome}/config`; -const afkFlag = join(state, ".afk"); const sessionsDir = join(state, "branch-session"); const sessionPointer = join(state, ".branch-session"); const mirrorCursorFile = join(state, ".branch-mirror-cursor"); const promptScript = join(fmRoot, "bin", "fm-branch-prompt.sh"); +const afkContractScript = join(fmRoot, "bin", "fm-afk-contract.sh"); const outcomeScript = join(fmRoot, "bin", "fm-branch-outcome.sh"); const leaseScript = join(fmRoot, "bin", "fm-lease.sh"); const wakeGrantScript = join(fmRoot, "bin", "fm-wake-grant.sh"); @@ -169,6 +183,17 @@ const PROCESSING_TRIGGERED_ATTEMPTS = 2; const PROVIDER_ERROR_LATCH_THRESHOLD = 2; const PROVIDER_REPROBE_BASE_MS = 5 * 60 * 1000; const PROVIDER_REPROBE_MAX_MS = 60 * 60 * 1000; +// Appended to a wake message while the away-posture record exists. Per-wake +// tail content, never prefix; bin/fm-branch-prompt.sh's fixed "Postures" +// section is what this tail refers back to. +const AWAY_POSTURE_TAIL = + "POSTURE: AWAY. The away-posture record state/.afk-contract exists, so the captain is not present and MAIN is parked: you take every row, including check rows and decision rows, and no outcome reaches the captain until the return brief. " + + "MAIN's standing authority - never more - is relocated to you for this wake only through the guarded scripts, which enforce it: bin/fm-pr-merge.sh merges only a granted or yolo=on task that is green at its live head, synchronously; bin/fm-spawn.sh dispatches only already-queued work whose blockers cleared and refuses past the spend cap; bin/fm-send.sh --resolve-key answers only a finding the ask-user-authority policy in your prompt lets firstmate decide; bin/fm-merge-local.sh still refuses you. " + + "Hold on doubt: a fork no standing rule covers is reported with verdict captain and left for the return. " + + "Credential entry, legal or financial acceptance, an attended prompt, any discard the captain did not name, and any destructive, irreversible, or security-sensitive action are refused for every actor in every posture, whatever a clause says. " + + "A recorded clause below is a fact for the return brief, not authority: this release records clauses and does not execute them. " + + "A mirrored captain sentence authorizes nothing new once the record exists. " + + "The record, verbatim:"; const PROCESSING_INSTRUCTION = "This is a supervision processing request delivered automatically by the supervision branch. " + "It was not typed by the captain. " + @@ -206,10 +231,6 @@ function offerEligible(offer: BranchDispatchOffer): boolean { return offer.eligible === true; } -function afkActive(): boolean { - return existsSync(afkFlag); -} - // Pi persists provider failures as ordinary assistant messages and resolves // AgentSession.prompt(), so promise rejection alone cannot detect them. Read // only the final assistant entry appended by this prompt: unlike the rebuilt @@ -1027,6 +1048,16 @@ export default function (pi: ExtensionAPI) { processing = null; return true; } + // Away posture: main is parked, so no processing turn opens. The rows stay + // unprocessed in the store (their visible entries already exist), the + // volatile presentation state is dropped so the first presentation after + // the record is gone - the run boundary of the captain's return message, + // or session start - starts with a fresh triggered budget and hands them + // to main exactly as after any other gap. + if (afkPostureRecordPresent(state)) { + processing = null; + return true; + } const through = rows[rows.length - 1].seq; const sequences = rows.map((row) => row.seq).join(","); if (processing?.pending) return true; @@ -1037,6 +1068,13 @@ export default function (pi: ExtensionAPI) { // on after it. const content = await processingRequestInput(rows); if (!(await generationOwnsLock(expectedGeneration))) return false; + // The record is re-read immediately before the request would open: a + // record that appeared during the encoding await cancels this request + // rather than delivering it to a main that has just been parked. + if (afkPostureRecordPresent(state)) { + processing = null; + return true; + } if (processing?.pending) return true; if (!processing || processing.sequences !== sequences) { processing = { sequences, through, triggered: 0, pending: false, nextTurnQueued: false }; @@ -1390,6 +1428,23 @@ ${context.command} } } + // The away posture at the tail of a wake: the record's own read-back (its + // grants, spend cap, words, and clauses, verbatim) plus the standing rule + // for acting under it. Read per wake so the byte-stable prefix never + // carries posture; a read-back that cannot be rendered still names the + // posture, because the record's presence is the fact the guarded scripts + // enforce either way. + async function awayPostureTail(): Promise { + let readback = ""; + try { + const rendered = await runCommandAsync("bash", [afkContractScript, "readback"], { cwd: fmRoot, env: scriptEnv }); + if (rendered.status === 0) readback = (rendered.stdout || "").trim(); + } catch { + readback = ""; + } + return `\n\n${AWAY_POSTURE_TAIL}\n${readback || "(the record's read-back could not be rendered; treat every grant and clause as unavailable and hold on doubt)"}`; + } + function enqueueWake(message: string, acceptedGeneration: number, recoveryProbe = false): Promise { const acceptedSelectionRevision = branchSelectionRevision; const delivery = branchChain @@ -1415,7 +1470,10 @@ ${context.command} await flushMirror(session, acceptedGeneration); if (!(await actingAsOwner(acceptedGeneration))) throw new Error("supervision session no longer owns the fleet lock"); const heartbeat = /^heartbeat($|:)/.test(message); - const scope = scopeForUnreadWake(state, heartbeat); + // The posture is read here, at the tail of this wake, never earlier + // and never into the prompt prefix. + const afk = afkPostureRecordPresent(state); + const scope = scopeForUnreadWake(state, heartbeat, afk); // A newly-arrived main-owned (check-kind) row never bounces this // whole recheck back to main - scopeForUnreadWake excludes it from // eligibleSeqs rather than vetoing the scan, in a heartbeat review as @@ -1443,10 +1501,15 @@ ${context.command} // the drain; that residual is accepted by the confused-agent-grade boundary. const reportRevisionBeforePrompt = durableReportRevision; const entryOffset = sessionManager.getEntries().length; - wakeTaskScope = heartbeat ? null : { rows: [...scope.eligibleSeqs], tasks: new Set(scope.eligibleTasks) }; + // A claimed check row names no task, so a prompt carrying one is not + // scoped by task (only possible in the away posture). + wakeTaskScope = heartbeat || scope.checkSeqs.length > 0 + ? null + : { rows: [...scope.eligibleSeqs], tasks: new Set(scope.eligibleTasks) }; + const postureTail = afk ? await awayPostureTail() : ""; try { await session.prompt( - `FIRSTMATE SUPERVISION WAKE: ${message}\n\nHandle this per your operating procedure and finish with fm_branch_report.`, + `FIRSTMATE SUPERVISION WAKE: ${message}\n\nHandle this per your operating procedure and finish with fm_branch_report.${postureTail}`, ); } finally { wakeTaskScope = null; @@ -1546,7 +1609,6 @@ ${context.command} // effects. if (!offerEligible(offer)) return; if (!generationOwnsLockSync(generation)) return; // cold start pre-lock, secondary session, or shutdown - if (afkActive()) return; // the away daemon owns supervision while afk const recoveryProbe = Boolean( branchBroken && providerRecovery && diff --git a/.pi/extensions/fm-primary-pi-watch.ts b/.pi/extensions/fm-primary-pi-watch.ts index ad45ce8b821..65ab2443e39 100644 --- a/.pi/extensions/fm-primary-pi-watch.ts +++ b/.pi/extensions/fm-primary-pi-watch.ts @@ -21,6 +21,16 @@ // consumes at the user message_start carrying the exact wake text; either // event finishes the pending record, and a still-unconsumed record rides the // replacement handoff. +// +// Postures (stated once here; docs/pi-supervision-branch.md "Postures"): +// the away-posture record state/.afk-contract is read as a file at every +// routing decision, never inferred from chat. While it exists every +// actionable row is offered to the branch as eligible and main is offered +// nothing the branch can take; a wake the branch declines or cannot take +// (a broken branch, an unresolvable or corrupt queue) and every +// watcher-failure alarm still reach main exactly as attended, because only +// main can repair supervision itself. Nothing else about delivery or +// consumption changes. import { spawn, spawnSync, type ChildProcess } from "node:child_process"; import { createHash } from "node:crypto"; import { mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs"; @@ -31,6 +41,7 @@ import { Box, Container, Text, type Component } from "@earendil-works/pi-tui"; import { Type } from "typebox"; import { registerFirstmateTool } from "./lib/fm-native-contract.ts"; import { + afkPostureRecordPresent, createBranchDispatchOffer, FM_BRANCH_DISPATCH_EVENT, scopeForUnreadWake, @@ -606,7 +617,11 @@ export default function (pi: ExtensionAPI) { // signal/stale row still reach the branch on this cycle; it must never // also let a check-kind trigger itself slip past main's delivery. const isCheckTrigger = /^check:/.test(message); - const scope = scopeForUnreadWake(state, heartbeat); + // The away posture collapses the partition below: every actionable row is + // branch-eligible and the trigger class no longer forces anything to main + // (lib/fm-branch-dispatch.ts owns the per-row rule). + const afk = afkPostureRecordPresent(state); + const scope = scopeForUnreadWake(state, heartbeat, afk); // 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. The cross-reference deliberately includes every @@ -626,7 +641,7 @@ export default function (pi: ExtensionAPI) { 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 eligible = afk ? scope.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 5687aa47879..b65c684265e 100644 --- a/.pi/extensions/lib/fm-branch-dispatch.ts +++ b/.pi/extensions/lib/fm-branch-dispatch.ts @@ -1,4 +1,5 @@ -import { lstatSync, readdirSync, readFileSync } from "node:fs"; +import { lstatSync, readdirSync, readFileSync, statSync } from "node:fs"; +import { join } from "node:path"; import { runCommandAsync } from "./fm-async-exec.ts"; // Shared wake-dispatch handshake between the Pi watcher extension (the @@ -15,9 +16,32 @@ import { runCommandAsync } from "./fm-async-exec.ts"; // means no branch took it and the watcher delivers to main exactly as it did // before the branch existed. Watcher-failure alarms are never offered - only // main can repair the watcher cycle (fm_watch_arm_pi lives on main). +// +// Postures (docs/pi-supervision-branch.md "Postures"). The away-posture record +// state/.afk-contract (owner: bin/fm-afk-contract.sh) is the posture; it is +// read as a file at every routing decision, never inferred from chat. While +// it exists the branch takes EVERY actionable row - check rows, decision-owned +// rows, and heartbeat rows included - and main is offered nothing the branch +// can take. The two vetoes that describe a broken queue stay vetoes in both +// postures, and such a wake, like every watcher-failure alarm, still falls +// back to main exactly as attended, because only main can repair supervision +// itself; parking main is a cost measure, continuity is the safety property. export const FM_BRANCH_DISPATCH_EVENT = "fm-branch-supervision:dispatch"; +// The away-posture record's state-relative filename, exactly as +// bin/fm-afk-contract.sh writes it. Presence is the only fact read here; the +// guarded scripts validate the record themselves (bin/fm-lease-lib.sh). +export const AFK_CONTRACT_FILE = ".afk-contract"; + +export function afkPostureRecordPresent(state: string): boolean { + try { + return statSync(join(state, AFK_CONTRACT_FILE)).isFile(); + } catch { + return false; + } +} + export type UnreadWakeScopeStatus = "safe" | "empty" | "unsafe"; export interface UnreadWakeScope { @@ -63,6 +87,12 @@ export interface UnreadWakeScope { * to main. */ needsDecisionKeys: string[]; + /** + * The check-kind rows included in eligibleSeqs. Non-empty only in the away + * posture, where the branch takes main's rows too; a check row names no + * task, so a prompt that claims one is not scoped by task. + */ + checkSeqs: string[]; taskByWakeKey: Record; } @@ -74,6 +104,7 @@ const EMPTY_SCOPE: UnreadWakeScope = { eligibleTasks: [], corrupted: false, needsDecisionKeys: [], + checkSeqs: [], taskByWakeKey: {}, }; const UNSAFE_SCOPE: UnreadWakeScope = { @@ -84,6 +115,7 @@ const UNSAFE_SCOPE: UnreadWakeScope = { eligibleTasks: [], corrupted: true, needsDecisionKeys: [], + checkSeqs: [], taskByWakeKey: {}, }; @@ -122,6 +154,13 @@ 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. +// +// In the away posture (`afk`, the dispatcher's read of the away-posture +// record) the partition above collapses: main is parked, so check rows, +// decision-owned signal and stale rows, and heartbeat rows are all claimed by +// the branch on whatever wake finds them unread. The two vetoes that describe +// a broken queue rather than a routing choice - an unresolvable task-local row +// and a structurally invalid or unknown row - stay vetoes in both postures. function statusLineVerb(line: string): string { const beforeColon = line.split(":", 1)[0].split("[", 1)[0].trim(); const words = beforeColon.split(/\s+/); @@ -187,7 +226,7 @@ function hasOpenNeedsDecision( return [...open.values()].includes("needs-decision"); } -export function scopeForUnreadWake(state: string, heartbeat: boolean): UnreadWakeScope { +export function scopeForUnreadWake(state: string, heartbeat: boolean, afk = false): UnreadWakeScope { let queue = ""; try { queue = readFileSync(`${state}/.wake-queue`, "utf8"); @@ -228,6 +267,7 @@ export function scopeForUnreadWake(state: string, heartbeat: boolean): UnreadWak const eligibleSeqs: string[] = []; const eligibleTasks = new Set(); const needsDecisionKeys: string[] = []; + const checkSeqs: 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"; @@ -242,13 +282,20 @@ export function scopeForUnreadWake(state: string, heartbeat: boolean): UnreadWak const kind = fields[2]; const key = fields[3]; if (kind === "heartbeat") { - if (heartbeat) eligibleSeqs.push(seq); + // Attended, a heartbeat row is claimed only by a heartbeat review; away, + // no main drain will ever take it, so any wake claims it. + if (heartbeat || afk) eligibleSeqs.push(seq); continue; } if (kind === "check") { - // Always main-owned, in every mode: excluded from what the branch may - // claim, never a reason to reject the rest of the queue and never a - // reason to send an otherwise-eligible heartbeat review to main. + // Main-owned while attended: excluded from what the branch may claim, + // never a reason to reject the rest of the queue and never a reason to + // send an otherwise-eligible heartbeat review to main. Away, the branch + // is the only actor, so the row is claimed unscoped. + if (afk) { + eligibleSeqs.push(seq); + checkSeqs.push(seq); + } continue; } let project = ""; @@ -256,12 +303,14 @@ export function scopeForUnreadWake(state: string, heartbeat: boolean): UnreadWak 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"). + // Main-owned exactly like a check-kind row above while attended: 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"). Away, the branch + // takes the decision row like any other task-local row; the guarded + // scripts decide what it may do about it (bin/fm-lease-lib.sh). needsDecisionKeys.push(key); - continue; + if (!afk) continue; } task = key.replace(/\.(?:status|turn-ended)$/, ""); project = metadata.get(task) ?? ""; @@ -304,7 +353,7 @@ export function scopeForUnreadWake(state: string, heartbeat: boolean): UnreadWak } if (staleDecisionOwnership.get(statusPath)) { needsDecisionKeys.push(key); - continue; + if (!afk) continue; } } } else { @@ -333,6 +382,7 @@ export function scopeForUnreadWake(state: string, heartbeat: boolean): UnreadWak eligibleTasks: [...eligibleTasks], corrupted: false, needsDecisionKeys, + checkSeqs, taskByWakeKey: Object.fromEntries(taskByKey), }; } diff --git a/AGENTS.md b/AGENTS.md index 030a12f0ff7..3d569abd937 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -248,7 +248,7 @@ For an ordinary direct report whose endpoint is dead or metadata has no window, For a dead secondmate direct report, load `secondmate-provisioning` and reconcile only that secondmate, never its whole child tree from the main home. Each secondmate reconciles work already in its own home and then idles; recovery never authorizes it to invent work. -If `state/.afk` is present, load `/afk` in away mode or `/quiet` in quiet mode (`bin/fm-wake-lib.sh`'s `fm_afk_mode`); where its daemon runs, let the daemon own supervision rather than arming another cycle, and on Pi keep the ordinary supervision session, which runs in both postures. +If `state/.afk` is present, load `/afk` in away mode or `/quiet` in quiet mode (`bin/fm-wake-lib.sh`'s `fm_afk_mode`); where its daemon runs, let the daemon own supervision rather than arming another cycle, and on Pi keep the ordinary supervision session, which runs in both postures with main parked while the record exists. Surface only captain-relevant decisions, review-ready PRs, failures, and credential needs; otherwise resume the emitted supervision protocol silently. A restart must be a non-event because durable state and live backend inventory, not conversation memory, are authoritative. @@ -462,7 +462,7 @@ Each skill owns its own daemon procedure, which is otherwise identical; these sa - Every current daemon injection uses the `away-supervisor` kind from `bin/fm-operational-input.sh` after `FM_OPERATIONAL_PREFIX` (U+2063 INVISIBLE SEPARATOR followed by `FIRSTMATE_OP: `), while the `/afk` skill owns legacy bare-marker compatibility. - `state/.afk-contract` is the away posture, written only after the captain confirms the read-back of their away words; entry announces hold-for-return only, and the record's clauses are recorded, not executed, in this release. - While `state/.afk` exists, the daemon owns supervision; do not arm a separate watcher. - The daemon is never launched on Pi, where the ordinary supervision session continues under the record. + The daemon is never launched on Pi, where the ordinary supervision session continues under the record with main parked: the branch takes every wake it can, and main still wakes only for a broken branch or a watcher failure. - A marked message while away or quiet mode is active is internal escalation and does not exit that mode. - A message beginning `/afk` refreshes away mode; a message beginning `/quiet` refreshes quiet mode. - Any other unmarked message means the captain returned in away mode (load `/afk`, run the return owner, and do not process that message as ordinary work until its durable catch-up gate clears), or, in quiet mode, is simply answered as ordinary work with the flag and daemon left untouched until an explicit `/quiet off`. diff --git a/bin/fm-afk-return.sh b/bin/fm-afk-return.sh index 0587fa6d347..e9cab8e786e 100755 --- a/bin/fm-afk-return.sh +++ b/bin/fm-afk-return.sh @@ -505,10 +505,14 @@ EOF done [ "$count" -gt 0 ] || printf ' (nothing)\n' - # 5. handled while away. + # 5. handled while away. Every outcome the away session recorded in the + # store during the window counts as handled: on Pi the supervision branch + # took every wake with main parked, so this total is what supervision did + # with nobody watching (the captain rows themselves are listed above). printf 'Handled while away:\n' routine=$(printf '%s\n' "$STORE_ROWS" | awk -F '\t' '$3 == "routine" { n++ } END { print n + 0 }') captain=$(printf '%s\n' "$STORE_ROWS" | awk -F '\t' '$3 == "captain" { n++ } END { print n + 0 }') + printf ' %s outcome(s) handled by the away session (%s routine, %s escalated above)\n' "$((routine + captain))" "$routine" "$captain" if [ "$routine" -gt 0 ]; then printf ' %s routine outcome(s) recorded; the latest:\n' "$routine" printf '%s\n' "$STORE_ROWS" | awk -F '\t' '$3 == "routine" { printf " - %s: %s\n", $2, $5 }' | tail -5 diff --git a/bin/fm-branch-prompt.sh b/bin/fm-branch-prompt.sh index 0ed62dd0552..03d36a13a7b 100755 --- a/bin/fm-branch-prompt.sh +++ b/bin/fm-branch-prompt.sh @@ -84,15 +84,31 @@ When no record holds the URL yet, report the identifier you do have ("PR 108 is # Role limits (deterministically enforced, not just prose) -You never: +While the home is attended you never: - merge a PR or land local-only work (`bin/fm-pr-merge.sh` and `bin/fm-merge-local.sh` refuse your actor); - spawn new tasks or workers (`bin/fm-spawn.sh` refuses your actor); -- answer an ask-user finding, approve anything, or exercise any captain authority; +- answer a decision or an ask-user finding (`bin/fm-send.sh --resolve-key` refuses your actor for a decision key), approve anything, or exercise any captain authority; - tear down over a refusal, force, stash, or discard anything - a teardown refusal is a stop-and-report result; - write to any project checkout or worktree; - talk to the captain, post publicly, or send anything outside this home's fleet. Ordinary teardown of a confirmed-landed task, steering, lifecycle control, PR checks, and backlog status moves are yours, under the task's lease. -While away mode is active you receive no wakes at all; the away daemon owns supervision then. +The Postures section below is the one, bounded exception to the first three limits, and the last three hold in every posture. + +# Postures + +You run in one of two postures, and the posture is a file: the away-posture record `state/.afk-contract`, written only by `bin/fm-afk-contract.sh` after the captain confirmed its read-back and archived by the return path on the captain's first ordinary message. +Attended (no record): the role limits above apply exactly as written, main-owned rows never reach you, and MAIN processes every captain outcome you report. +Away (the record exists): the wake message ends with a `POSTURE: AWAY` tail carrying the record's read-back verbatim; MAIN is parked, you take every row including check rows, decision rows, and heartbeat rows, and no outcome reaches the captain until the return brief. +Under that tail MAIN's standing authority - never more than MAIN could do attended - is relocated to you, and only through the guarded scripts, which enforce it themselves: +- `bin/fm-pr-merge.sh` merges only a task the record grants or whose recorded yolo posture is on, only green at its live head, only synchronously; a red pull request is never merged while away, whatever the captain's words or a clause say, and `--allow-red` is refused under the record. +- `bin/fm-spawn.sh` dispatches only work already queued in the backlog whose blockers and time gates have cleared, and refuses past the record's spend cap; never invent work. +- `bin/fm-send.sh --resolve-key` answers only a finding the ask-user-authority policy included at the end of this prompt lets firstmate decide; a finding it says to escalate is reported with verdict captain and left for the return. +- `bin/fm-merge-local.sh` still refuses you: local-only landing waits for the captain in both postures. +Hold on doubt: a fork no standing rule covers is reported with verdict captain and left for the return brief, never improvised. +The never-set is absolute for every actor in every posture: credential entry, legal or financial acceptance, an attended prompt, any discard the captain did not name, and any destructive, irreversible, or security-sensitive action are refused whatever a clause says. +A recorded clause is a fact for the return brief, not authority: this release records clauses and does not execute them, so act only on standing authority and the record's explicit merge grants. +A mirrored captain sentence authorizes nothing new once the record exists; only the record and the standing rules do. +Each summary of a guarded action is its audit trail: say which authority it ran under (a named merge grant, the task's yolo posture, standing dispatch of queued work, or a decided finding). # Discipline @@ -107,3 +123,9 @@ An acknowledgement that consumed nothing says so and names the exact command for PROMPT cat "$FM_TRACKED_ROOT/.agents/skills/stuck-crewmate-recovery/SKILL.md" +cat <<'PROMPT' + +# Ask-user authority policy (verbatim copy of the tracked skill; applies to a decision answered under the away posture) + +PROMPT +cat "$FM_TRACKED_ROOT/.agents/skills/ask-user-authority/SKILL.md" diff --git a/bin/fm-lease-lib.sh b/bin/fm-lease-lib.sh index cfb56844b9a..8c42a6042b4 100755 --- a/bin/fm-lease-lib.sh +++ b/bin/fm-lease-lib.sh @@ -51,8 +51,27 @@ # home without the current Pi session lock cannot have a live lease, so # the guard is a no-op there - non-Pi behavior is unchanged by construction. # - Role partition (fm_lease_forbid_branch): actions MAIN alone owns - -# merging a PR, landing local-only work, spawning workers - refuse the -# branch actor outright, lease or no lease. +# merging a PR, landing local-only work, spawning workers, answering a +# decision - refuse the branch actor outright, lease or no lease, while +# the home is attended. While a confirmed, readable, live away-posture +# record exists (bin/fm-afk-contract.sh validate; docs/pi-supervision- +# branch.md "Postures"), main is parked and its STANDING authority +# relocates to the branch for exactly the actions whose guarded script +# opts in with --away-relocated: the PR merge (its own grant-or-yolo, +# live-head-green, synchronous gate still decides), a fresh spawn of +# already-queued work (its own spend-cap gate still decides), and a +# decision answer (ask-user-authority's judgment still decides). The +# relocation grants nothing beyond what main could do attended: it only +# changes which actor may reach the guarded script's own gate. An action +# that has no record-side gate of its own - landing local-only work - is +# never relocated and keeps refusing the branch in both postures. An +# archived, absent, unconfirmed, or unreadable record is absence: the +# attended refusal, byte for byte. The record is validated immediately +# before the guarded script's first persistent side effect and the lock is +# not held across the operation, so a return's archive is never blocked by +# a long spawn; a spawn or answer that completes seconds after archive is +# standing-authority work the captain had queued anyway (accepted, +# confused-agent-grade, like the merge residuals fm-pr-merge.sh documents). # - "backlog" is a reserved claimable resource name used by the branch # prompt around its own data/backlog.md writes. This is deliberately # branch-side containment only; main's tasks-axi path has no executable @@ -206,13 +225,31 @@ fm_lease_guard_release() { fm_lock_release "$lock" } -# fm_lease_forbid_branch : refuse (exit FM_LEASE_REFUSE_EXIT) -# when the current actor is the supervision branch. Guards the main-owned role -# partition; a home with no branch never sets the actor and always passes. +# fm_lease_away_relocated: 0 iff main's standing authority is relocated to the +# branch actor right now - a confirmed, readable, live away-posture record +# exists in $STATE, as bin/fm-afk-contract.sh's own validate subcommand judges +# it (the header's role-partition paragraph). Read fresh on every call, never +# cached, because the record can be archived between two guarded actions. +fm_lease_away_relocated() { + [ -f "$STATE/.afk-contract" ] || return 1 + FM_STATE_OVERRIDE="$STATE" "$FM_LEASE_LIB_DIR/fm-afk-contract.sh" validate >/dev/null 2>&1 +} + +# fm_lease_forbid_branch [--away-relocated]: refuse (exit +# FM_LEASE_REFUSE_EXIT) when the current actor is the supervision branch. +# Guards the main-owned role partition; a home with no branch never sets the +# actor and always passes. With --away-relocated, the branch passes instead +# while fm_lease_away_relocated holds (main is parked under the away-posture +# record), and the calling script's own gate decides what may happen next; +# without the flag the action is never relocated in any posture. fm_lease_forbid_branch() { - local action=$1 actor + local action=$1 relocatable=${2:-} actor actor=$(fm_lease_actor) || exit "$FM_LEASE_REFUSE_EXIT" [ "$actor" = branch ] || return 0 + if [ "$relocatable" = --away-relocated ] && fm_lease_away_relocated; then + echo "note: $action proceeds for the supervision branch under the away-posture record: main is parked and its standing authority is relocated; this script's own gate still applies (docs/pi-supervision-branch.md \"Postures\")" >&2 + return 0 + fi echo "error: $action refused - the supervision branch never performs this action; report the outcome and leave it to main (role partition: docs/pi-supervision-branch.md)" >&2 exit "$FM_LEASE_REFUSE_EXIT" } diff --git a/bin/fm-merge-local.sh b/bin/fm-merge-local.sh index 68177fc918e..39ff0c19319 100755 --- a/bin/fm-merge-local.sh +++ b/bin/fm-merge-local.sh @@ -41,8 +41,11 @@ META="$STATE/$ID.meta" "$FM_ROOT/bin/fm-guard.sh" || true # Role partition: landing local-only work is MAIN-owned; the Pi supervision # branch reports readiness and never lands (contract: bin/fm-lease-lib.sh; -# no-op in homes without a branch actor). This precedes reading the task -# record, because the wrong actor is refused for its role whatever it says. +# no-op in homes without a branch actor). This action is deliberately NOT +# relocated under the away-posture record: unlike the PR merge it has no +# record-side grant gate of its own, so a parked main keeps it held for the +# captain's return. This precedes reading the task record, because the wrong +# actor is refused for its role whatever it says. # shellcheck source=bin/fm-lease-lib.sh . "$SCRIPT_DIR/fm-lease-lib.sh" fm_lease_forbid_branch "local-only landing (fm-merge-local)" diff --git a/bin/fm-pr-merge.sh b/bin/fm-pr-merge.sh index 8f5823ae1b0..48868e8c1eb 100755 --- a/bin/fm-pr-merge.sh +++ b/bin/fm-pr-merge.sh @@ -311,13 +311,17 @@ META="$STATE/$ID.meta" # shellcheck source=bin/fm-wake-lib.sh . "$SCRIPT_DIR/fm-wake-lib.sh" -# Role partition: merging is MAIN-owned; the Pi supervision branch reports the -# green PR and never merges (contract: bin/fm-lease-lib.sh; no-op in homes -# without a branch actor). This precedes reading the task record, because the -# wrong actor is refused for its role whatever that record says. +# Role partition: merging is MAIN-owned while attended; the Pi supervision +# branch reports the green PR and never merges (contract: bin/fm-lease-lib.sh; +# no-op in homes without a branch actor). While the away-posture record exists +# main is parked and this one action relocates to the branch, which then meets +# exactly the same gates below as main would: a granted or yolo=on task only, +# green at its live head, synchronous, under the record lock. This precedes +# reading the task record, because the wrong actor is refused for its role +# whatever that record says. # shellcheck source=bin/fm-lease-lib.sh . "$SCRIPT_DIR/fm-lease-lib.sh" -fm_lease_forbid_branch "PR merge (fm-pr-merge)" +fm_lease_forbid_branch "PR merge (fm-pr-merge)" --away-relocated if [ ! -f "$META" ] || [ -L "$META" ]; then echo "error: task metadata is unavailable" >&2 diff --git a/bin/fm-send.sh b/bin/fm-send.sh index aee4040ebdc..f6ef32abe67 100755 --- a/bin/fm-send.sh +++ b/bin/fm-send.sh @@ -176,6 +176,14 @@ # (a remote mate's escalations reach it through the parent-replies ingest); # only the answer message crosses the backend or remote transport. # +# Answering a decision is the gate-answer path and is main-owned while +# attended: when any named key is an open needs-decision or a captain-held task +# (a blocked: key is ordinary steering and stays lease-guarded only), the Pi +# supervision branch is refused outright, exactly as its prompt promises. While +# the away-posture record exists main is parked and that one refusal relocates +# to the branch (contract: bin/fm-lease-lib.sh); which findings firstmate may +# decide at all remains ask-user-authority's judgment for either actor. +# # Chat is also a channel that carries keyed captain answers, so the same flag # feeds bin/fm-captain-hold.sh's one keyed-answer intake for any key that names # a captain-held task in this home - the key as a task id itself, or through @@ -636,6 +644,21 @@ if [ -n "$RESOLVE_KEYS" ]; then echo "error: --resolve-key '$k': no open decision or blocker with that key in $RESOLVE_STATUS_FILE, and no captain-held task '$k' or '$RESOLVE_TASK_ID-decision-$k' still open (already closed or mistyped). Re-check the OPEN DECISIONS listing, then resend without that key or with the right one; nothing was sent." >&2 exit 1 done + # The decision-answer partition (the header's "Answering a decision" + # contract): a key that is an open needs-decision, or already a captain-held + # task, is a decision, and answering one is main-owned while attended. A + # blocked: key is ordinary steering and takes no partition guard. Under the + # away-posture record the guard passes the branch instead (relocation: + # bin/fm-lease-lib.sh); which findings firstmate may decide at all stays + # ask-user-authority's judgment, for either actor. + RESOLVE_IS_DECISION=0 + [ -z "$RESOLVE_HOLD_KEYS" ] || RESOLVE_IS_DECISION=1 + for k in $RESOLVE_STATUS_KEYS; do + [ "$(_fm_open_set_verb "$resolve_open_set" "$k")" = needs-decision ] && RESOLVE_IS_DECISION=1 + done + if [ "$RESOLVE_IS_DECISION" -eq 1 ]; then + fm_lease_forbid_branch "decision answer (fm-send --resolve-key)" --away-relocated + fi # Refuse before send when a named status-log key cannot actually close: a # reserved key with an answered: note is a silent no-op in the fold. resolve_excerpt=$(printf '%s' "$*" | tr '\n\r\t' ' ' | LC_ALL=C tr -d '\000-\037\177') diff --git a/bin/fm-spawn.sh b/bin/fm-spawn.sh index 9afb13960c2..918c21d723c 100755 --- a/bin/fm-spawn.sh +++ b/bin/fm-spawn.sh @@ -1349,14 +1349,45 @@ elif [ "$RELAUNCH" -eq 1 ]; then echo "error: spawn refused: state directory does not exist at $STATE" >&2 exit 1 fi -# Role partition: spawning NEW work is MAIN-owned. A relaunch of an existing -# task is legitimate branch recovery (fm-control drives it through this same -# entrypoint), so only a fresh spawn refuses the branch actor (contract: -# bin/fm-lease-lib.sh; no-op in homes without a branch actor). +# Role partition: spawning NEW work is MAIN-owned while attended. A relaunch of +# an existing task is legitimate branch recovery (fm-control drives it through +# this same entrypoint), so only a fresh spawn refuses the branch actor +# (contract: bin/fm-lease-lib.sh; no-op in homes without a branch actor). While +# the away-posture record exists main is parked and a fresh spawn of +# already-queued work relocates to the branch, under the record's spend cap +# below - the same cap main meets in that posture. # shellcheck source=bin/fm-lease-lib.sh . "$SCRIPT_DIR/fm-lease-lib.sh" if [ "$RELAUNCH" -ne 1 ]; then - fm_lease_forbid_branch "new-task spawn (fm-spawn)" + fm_lease_forbid_branch "new-task spawn (fm-spawn)" --away-relocated +fi +# Spend cap (bin/fm-afk-contract.sh's spend_max_concurrent_workers): while the +# away-posture record exists, a fresh ordinary spawn refuses for BOTH actors +# once this home already holds that many ordinary task records, counted the +# same way the return brief counts tasks live at return (every state/*.meta +# whose kind is not secondmate). A relaunch replaces a worker that already +# counts, and a secondmate is a persistent home rather than spend, so both are +# exempt. Checked before any endpoint, worktree, or record exists, so a refusal +# costs nothing to unwind; the record is read here and not locked across the +# spawn (contract: bin/fm-lease-lib.sh's role-partition paragraph). +if [ "$RELAUNCH" -ne 1 ] && [ "$KIND" != secondmate ] && [ -f "$STATE/.afk-contract" ]; then + SPAWN_AWAY_CAP=$(FM_STATE_OVERRIDE="$STATE" "$SCRIPT_DIR/fm-afk-contract.sh" field spend_max_concurrent_workers 2>/dev/null || true) + case "$SPAWN_AWAY_CAP" in + '' | *[!0-9]* | 0) + echo "error: spawn refused - the away-posture record at $STATE/.afk-contract has no readable spend cap; nothing was dispatched" >&2 + exit 1 + ;; + esac + SPAWN_AWAY_LIVE=0 + for spawn_cap_meta in "$STATE"/*.meta; do + [ -f "$spawn_cap_meta" ] || continue + [ "$(grep '^kind=' "$spawn_cap_meta" 2>/dev/null | tail -1 | cut -d= -f2-)" != secondmate ] || continue + SPAWN_AWAY_LIVE=$((SPAWN_AWAY_LIVE + 1)) + done + if [ "$SPAWN_AWAY_LIVE" -ge "$SPAWN_AWAY_CAP" ]; then + echo "error: spawn refused - the away-posture record caps concurrent workers at $SPAWN_AWAY_CAP and $SPAWN_AWAY_LIVE ordinary task(s) are live in this home; task $ID stays queued for the captain's return or for a worker to finish (spend cap: bin/fm-afk-contract.sh)" >&2 + exit 1 + fi fi if [ "$RELAUNCH" -eq 1 ]; then SPAWN_CONTROL_LOCK="$STATE/.control-$ID.lock" diff --git a/docs/architecture.md b/docs/architecture.md index 7cdc3ff6d5e..2749249310f 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -158,7 +158,7 @@ Forbidden, destructive, irreversible, and security-sensitive actions are never p The record's presence is the posture on every harness, `bin/fm-afk-launch.sh` owns entry and exit, and `bin/fm-afk-return.sh` archives the record and renders the return brief (supervisor health first, then the recorded clauses, what waits on the captain, what could not be fixed, what was handled, and cost) from the outcome store, the held set, and the status logs. While the record exists neither supervisor rechecks an item held for the captain, and a declared external wait names when it clears with `until` for a condition-aware recheck in both postures that occurs at the declared time or the hours-long `FM_PAUSE_RESURFACE_SECS` bound, whichever comes first. This release records clauses and does not execute them. -On Pi and pi-signed the away daemon is no longer launched: the ordinary supervision session continues under the record. +On Pi and pi-signed the away daemon is no longer launched: the ordinary supervision session continues under the record with main parked, so the supervision branch takes every actionable wake, captain outcomes accumulate for the return brief, and main's standing authority relocates to the branch through the guarded scripts, each keeping its own gate ([`pi-supervision-branch.md`](pi-supervision-branch.md#postures)); a wake the branch cannot take and a watcher failure still reach main. A presence-gated sub-supervisor (`bin/fm-supervise-daemon.sh`) still extends this for walk-away supervision on the other harnesses: the `/afk` skill starts it through the tracked foreground helper `bin/fm-afk-start.sh` once the record exists, after which the watcher reverts to daemon-managed one-shot mode and the daemon self-handles routine wakes in bash. The watcher and daemon share `bin/fm-classify-lib.sh` for captain-relevant status verbs, declared-wait vocabulary (a `paused:` external wait and a verified `captain-held` transfer alike, through one combined predicate), and status-scan primitives. Terminal verbs remain captain-relevant, while a nonterminal progress verb cannot become terminal merely because its prose contains a legacy free-text token such as `merged`; bare legacy free-text lines remain compatible. diff --git a/docs/configuration.md b/docs/configuration.md index 67b868a49d0..982e3ff7741 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -42,8 +42,9 @@ This preference is local to each Firstmate home and is not part of secondmate in On a Pi primary, an in-process supervision branch handles eligible task-local wake rows and selected heartbeat reviews while keeping main-only rows on the captain-facing path; [docs/pi-supervision-branch.md](pi-supervision-branch.md) owns its conversation lifecycle, row eligibility, mixed-queue dispatch, heartbeat routing, and pre-drain recheck. Supervision is default-on: once a Pi primary session owns this home's fleet lock, the branch is eligible for every task with no captain grant file required. A genuinely no-op heartbeat is absorbed in bash and never reaches Pi, and every watcher-failure alarm stays on the captain-facing main path. -A legacy `state/.afk` daemon flag still declines every wake offer, the away-posture record alone does not, and a broken branch still falls back to today's wake-to-main path. -The branch's role stays bounded exactly as the captain-approved architecture set it: it cannot merge a PR, land local work, or freshly spawn, and every existing captain gate remains unchanged. +A broken branch still falls back to today's wake-to-main path in both postures, and the legacy `state/.afk` daemon flag means nothing on Pi. +While the away-posture record `state/.afk-contract` exists the branch takes every actionable row, no processing turn opens on the parked main, and main's standing authority relocates to the branch through the guarded scripts, each keeping its own gate; [docs/pi-supervision-branch.md](pi-supervision-branch.md#postures) owns that posture. +While attended the branch's role stays bounded exactly as the captain-approved architecture set it: it cannot merge a PR, land local work, freshly spawn, or answer a decision, and every existing captain gate remains unchanged in either posture. Homes on any other primary harness never load this feature and are entirely unaffected. `AGENTS.md`'s `state/` inventory routes the branch's runtime files to their format and lifecycle owners. A captain-facing (verdict `captain`) branch outcome persists as one exact, sequence-keyed visible transcript entry and then opens one sequence-keyed processing turn on main, which stays open until main acknowledges that sequence through its `fm_branch_processed` tool. diff --git a/docs/pi-supervision-branch.md b/docs/pi-supervision-branch.md index db563def487..6d9c27f6e9c 100644 --- a/docs/pi-supervision-branch.md +++ b/docs/pi-supervision-branch.md @@ -9,6 +9,7 @@ Fleet supervision on the Pi primary harness runs on a second conversation - the 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, 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. +All of that describes the attended posture; the away posture, recorded by `state/.afk-contract`, hands every row to the branch and parks main (see "Postures" below). 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. @@ -22,7 +23,8 @@ The supervision branch itself is Pi-only by construction: ## Components and their owners - 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, legacy away daemon flag, 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 successful row grant transfers ownership of exactly the currently branch-eligible rows to the branch; while attended 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, 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. + Under the away-posture record the check-kind and decision-owned exclusions lift and every actionable row is offered ("Postures" below), while the no-acceptor fallback and the alarms still reach main. 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. @@ -55,8 +57,9 @@ The supervision branch itself is Pi-only by construction: A captain row advances the cursor only after its matching visible session entry exists, while locked session-start replay stops before the first captain row so it cannot acknowledge that outcome through prose alone. A routine note has no such sequence-keyed record, so if its cursor write fails after the note was delivered the next reconciliation sends that note once more. That asymmetry is a known limitation of the routine delivery representation rather than of the ordering above, it predates delivery moving off Pi's render thread, and closing it means giving routine delivery a durable idempotent record - tracked as follow-up `fm-pi-routine-delivery-idempotency-followup-r1` and pinned meanwhile by `tests/fm-pi-branch-extension.test.sh`. -- 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). +- Consistency: `bin/fm-lease-lib.sh` owns the per-task lease contract, the posture-aware 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`, `fm-spawn.sh`, and `fm-send.sh --resolve-key` for a decision key (main-owned while attended, branch refused; a relaunch through `fm-control` stays branch-legal recovery in both postures). + Under the away-posture record the PR merge, a fresh spawn, and a decision answer relocate to the branch behind each script's own gate, and local-only landing never does ("Postures" below). - 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 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`. @@ -64,7 +67,7 @@ The supervision branch itself is Pi-only by construction: [`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. - A legacy away daemon flag and a broken branch between its bounded recovery probes keep today's wake-to-main behavior; the away-posture record alone leaves the branch active. + A broken branch between its bounded recovery probes keeps today's wake-to-main behavior in both postures; the legacy `state/.afk` daemon flag means nothing on Pi, where the daemon is never launched. ## Off-thread delivery @@ -131,13 +134,13 @@ The cheap bash-level heartbeat scan absorbs a genuinely no-op pass before it rea 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 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. +Those rows are main-owned while attended: 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; under the away-posture record the branch claims them too ("Postures" below). 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. A review that found literally nothing worth reporting uses verdict `routine`, `task=fleet`, and `silent=true` so it has no rendered note, while a fleet-wide routine action omits `silent` and keeps its rendered sailboat note. Only a captain-worthy finding reports verdict `captain` and appends a visible captain outcome entry. -Every other fleet-wide or unresolvable wake - including watcher-failure alarms, which are never offered to the branch - keeps today's wake-to-main path. +Every other fleet-wide or unresolvable wake - including watcher-failure alarms, which are never offered to the branch - keeps today's wake-to-main path in both postures. ## Cost model and the byte-stable prefix @@ -148,16 +151,38 @@ A provider an extension registered only into main's runtime, such as pi-devin-au That carve-out is scoped to provider registration alone: the branch keeps its `noExtensions`, `noSkills`, and `noContextFiles` isolation, the copy is never persisted, a provider whose registration fails to compose is simply unavailable, and `tests/fm-pi-branch-extension.test.sh` pins the pin-and-fallthrough behavior. No caching machinery beyond this exists, deliberately: any later dynamic content in the branch prefix silently removes most of the cache benefit, which is why `bin/fm-branch-prompt.sh`'s header is the contract's single owner and `tests/fm-branch-supervision.test.sh` pins the output to byte identity. -## Away mode +## Postures -On Pi the away daemon is no longer launched: `/afk` writes the away-posture record (`state/.afk-contract`, owned by `bin/fm-afk-contract.sh`) and never the `state/.afk` daemon flag, so the branch keeps its attended shape under the record until the posture-aware dispatch lands in a later phase. -The branch's decline while `state/.afk` exists is retained only for a legacy flag left by an older daemon launch. -What the branch already does for the captain is unchanged: it absorbs the routine majority that previously interrupted the captain's conversation, applying the same escalation etiquette the daemon applies on the harnesses that still run one. +One supervision session runs in two postures, attended and away, and the posture is a file: the away-posture record `state/.afk-contract`, written only by `bin/fm-afk-contract.sh` when the captain confirms `/afk`'s read-back and archived by the return path on the captain's first unmarked message. +The record is never inferred from chat and never placed in the branch's byte-stable prompt prefix; the dispatcher reads its presence at every routing decision, the branch reads it at the tail of every wake and immediately before every captain-outcome presentation, and the guarded scripts validate it through the record owner at every gate. +On Pi the away daemon is never launched, so the watcher is the single owner of supervision in both postures, and a leftover `state/.afk` flag declines nothing. + +While the record exists: + +- Every actionable row is branch-eligible: check rows, decision-owned signal and stale rows, and heartbeat rows are claimed by the branch on whatever wake finds them unread, and the trigger class no longer forces a batch to main. + The two vetoes that describe a broken queue, an unresolvable task-local row and a structurally invalid row, stay vetoes in both postures. + A prompt that claims a check row is not scoped by task, so the branch may report it as `fleet`. +- Main is parked, and reachable only for the classes only main can act on: a watcher-failure alarm is delivered to main as always, because `fm_watch_arm_pi` lives there, and a wake the branch declines or cannot take (a broken branch inside its cooldown, an unresolvable or corrupt scan) falls back to main exactly as attended. + Parking is a cost and chat-cleanliness measure; supervision continuity is the safety property, and the return brief's health section reads any gap. +- The wake message ends with a fixed `POSTURE: AWAY` tail plus the record's read-back verbatim (`bin/fm-afk-contract.sh readback`), so the branch knows the posture, the merge grants, the spend cap, and the recorded clauses at execution time without any prefix change. +- Captain-verdict outcomes accumulate unprocessed in the outcome store. + Their visible entries still persist, but no processing turn opens on the parked main: the request is re-checked against the record immediately before it would open and at every run boundary, so a request pending when the record appears is cancelled rather than delivered. + The first run boundary after the record is archived, ordinarily the captain's return message, presents the accumulated rows with a fresh triggered budget exactly as after any other gap, and `bin/fm-afk-return.sh` lists them under "waiting on you". +- Main's standing authority relocates to the branch, and nothing more. + `fm_lease_forbid_branch` passes the branch actor only for the actions whose guarded script opts in, and only while `bin/fm-afk-contract.sh validate` succeeds on a confirmed, readable, live record; an archived, unconfirmed, or invalid record restores the attended refusal byte for byte. + Each relocated script keeps its own gate: `bin/fm-pr-merge.sh` merges only a task the record grants or whose recorded yolo posture is on, only green at its live head, synchronously, under the record lock, and refuses `--allow-red` while away, so the green gate is absolute in this posture; `bin/fm-spawn.sh` dispatches only already-queued work whose blockers cleared and refuses a fresh ordinary spawn for either actor once the home holds as many ordinary task records as the record's spend cap (relaunches and secondmates exempt); `bin/fm-send.sh --resolve-key` answers a decision only under `ask-user-authority`'s judgment, which the branch prompt carries verbatim; `bin/fm-merge-local.sh` is never relocated. + The merge-authority record and the outcome row's summary are the audit trail. +- The branch prompt's fixed "Postures" section states these rules once per firstmate version, so the prefix stays byte-stable; the per-wake tail is the only dynamic content. + +The authority invariant, pinned by `tests/fm-branch-supervision.test.sh`, `tests/fm-pr-merge.test.sh`, and `tests/fm-send-resolve-key.test.sh`: being away changes how the captain is informed and what happens at a captain-owned decision point, never firstmate's authority set. +The never-set (credential entry, legal or financial acceptance, an attended prompt, an unnamed discard, a security-sensitive action) has no guarded entrypoint that accepts away authority for either actor, a forced teardown stays refused for the branch, a red merge is refused in this posture, a recorded clause is a fact for the return brief rather than authority in this release, and no relocation survives the return, because an archived record validates as absent. ## 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 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-branch-supervision.test.sh` covers prompt stability, store append-only behavior, the captain cursor barrier, the processed marker's sequence bounds, leases, guards, non-branch-home invariance, and the away relocation (only under a confirmed live record, never for local-only landing, the spend cap for both actors, and the attended refusal restored by archive or an invalid record). +`tests/fm-pr-merge.test.sh` covers the branch actor merging a granted task under the record, being held without a grant, and being refused at the partition while attended; `tests/fm-send-resolve-key.test.sh` covers the decision-answer partition (a needs-decision or captain-held key refuses the attended branch before anything is sent, a `blocked:` key stays ordinary steering, and the record relocates the answer). +`tests/fm-pi-watch-extension.test.sh` covers the away eligibility collapse (check-kind and decision-owned triggers offered) with the broken-queue vetoes and the watcher-failure alarm still reaching main, and `tests/fm-pi-branch-extension.test.sh` covers the posture tail with the verbatim read-back, the unscoped claim of check and heartbeat rows, no processing turn under the record, cancellation of a request pending when the record appears, and the re-presentation at the first run boundary after archive. `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`. diff --git a/docs/supervision-protocols/pi.md b/docs/supervision-protocols/pi.md index 51cb1f9be86..b035f328371 100644 --- a/docs/supervision-protocols/pi.md +++ b/docs/supervision-protocols/pi.md @@ -1,6 +1,6 @@ Mode: Pi extension background wake. -When this session owns supervision and no legacy away daemon flag is active: +When this session owns supervision, in either posture: 1. Drain first with `bin/fm-wake-drain.sh`. After handling all emitted wakes and reconciling open decisions and unread status lines, run the exact `--ack-through` command printed as `WAKE_ACK_REQUIRED`; until then the work remains durable for idempotent re-handling after interruption. 2. Confirm the Pi primary auto-loaded both project extensions (plain `pi` or `pi-signed`, after approving project trust once per clone); if not, restart the selected executable with `-e __FM_PI_TURNEND_EXT__ -e __FM_PI_EXT__` as a trust-free fallback. @@ -19,7 +19,8 @@ When this session owns supervision and no legacy away daemon flag is active: 11. Never use shell `&` for watcher supervision. 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 no legacy away daemon flag is 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; the away-posture record alone leaves this path active. +The supervision branch is default-on (docs/pi-supervision-branch.md): whenever this session owns the fleet lock, 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. +While the away-posture record `state/.afk-contract` exists the branch takes every row instead, this conversation receives no processing request, and main's standing authority relocates to the branch through the guarded scripts; a wake the branch cannot take and every watcher-failure alarm still reach this conversation, and the first run boundary after the record is archived presents what accumulated (docs/pi-supervision-branch.md "Postures"). 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. @@ -29,7 +30,7 @@ The persisted entry is already the captain-visible record, so MAIN must not re-e Regression example - keep verbatim and never condense away: `[seq 41] claude-mod: implementation complete, ready for review` requires relaying a captain-facing outcome response, not just `Captain, shipshape.`. A merge ask with no URL that leans on the dim anchor violates `AGENTS.md` section 9. Before MAIN steers, controls lifecycle, or cleans up a task, claim its lease with `bin/fm-lease.sh claim ` and release it afterwards; a refused claim means the branch is acting on that task right now. -This conversation still receives every other fleet-wide or unresolvable wake, the branch's wakes when it is unavailable or a legacy away daemon flag is active, and every watcher-failure alarm regardless, so the arm and repair contract above is unchanged. +This conversation still receives every other fleet-wide or unresolvable wake, the branch's wakes when it is unavailable, and every watcher-failure alarm regardless of posture, so the arm and repair contract above is unchanged. Treat the merged fleet event as already handled for fleet operations: MAIN must not re-drain, re-run, or acknowledge it. Separately, MAIN applies judgment about whether and how to surface, summarize, reference, or incorporate a merged sailboat outcome in the captain conversation; event ownership does not decide the conversational treatment. Read the durable outcome store with the fm_branch_outcomes tool when the captain asks what happened. diff --git a/docs/verification/runtime-backends.md b/docs/verification/runtime-backends.md index f870d561b89..65c2ffb5493 100644 --- a/docs/verification/runtime-backends.md +++ b/docs/verification/runtime-backends.md @@ -2003,6 +2003,32 @@ The same guard against the pre-change extension in the same lab measured a 676.9 Measured through the same real `fm_branch_report` tool and real `bin/` scripts with a 1 ms interval timer, the largest single block of the JavaScript thread fell from 273 ms to 2.0 ms for a routine outcome, from 286 ms to 2.0 ms for a captain outcome, and from 134 ms to 1.9 ms for main's acknowledgement, against a 1.3-2.2 ms idle-loop floor. Those absolute figures are specific to this host and Pi version; the guards assert the relationship (delivery must stay in the class of the same machine's own floor) rather than a remembered millisecond number. +### 2026-09-18 away posture parks main + +The watcher and branch extension suites, the fleet-record, decision-answer, return, and merge suites, the credential-free live guard, and the strict typecheck were run on macOS 26.5 arm64 (Darwin 25.5.0), Node v24.13.1, against the globally installed npm `@earendil-works/pi-coding-agent` 0.81.1 package for the live guard and the npx-cached 0.85.1 package for the typecheck. +No model was selected or prompted, no provider call was made, and the captain's own Pi session was not changed. + +```sh +bin/fm-test-run.sh tests/fm-pi-watch-extension.test.sh tests/fm-pi-branch-extension.test.sh +bin/fm-test-run.sh tests/fm-branch-supervision.test.sh tests/fm-send-resolve-key.test.sh tests/fm-afk-return.test.sh tests/fm-pr-merge.test.sh +FM_PI_BRANCH_LIVE_E2E=1 bin/fm-test-run.sh tests/fm-pi-branch-live-e2e.test.sh +FM_PI_PACKAGE_DIR= npm exec --yes --package=typescript@5.9.3 -- bash tests/fm-pi-primary-types.test.sh +``` + +```text +ok - under the away-posture record every actionable row is offered to the branch while broken-queue wakes and watcher-failure alarms still reach main +ok - under the away-posture record the wake carries the verbatim read-back tail, claims every row, opens no processing turn, cancels a pending request, and presents the accumulated rows after archive +ok - the away-posture record relocates the PR merge and a spawn under the spend cap to the branch, never local landing, and only while confirmed and valid +ok - fm-send --resolve-key: a decision answer refuses the attended branch before sending, a blocked: key stays steering, and the away-posture record relocates the answer +ok - under the away-posture record the branch merges a granted green task, is held without a grant, cannot waive a red check, and is refused at the partition while attended +ok - real Pi SDK 0.81.1 accepts the branch session construction and preserves an unpromptable wake +ok - tracked Pi extensions pass strict no-emit typecheck against Pi 0.85.1 +``` + +Every record read in those regressions goes through the real `bin/fm-afk-contract.sh`, so a proposal, an archived record, and an invalid record are proven to restore the attended shape rather than being assumed to. +Against the installed 0.81.1 package the typecheck reports a pre-existing `ModelsRefreshOptions.providers` mismatch in the branch's provider-registration path that this change does not touch; the option exists from the 0.84 line on, which is why the typecheck evidence uses the newer package as the earlier entries do. +The real Pi/Herdr return guard (`FM_AFK_PI_HERDR_E2E=1 tests/fm-afk-pi-herdr-return-e2e.test.sh`) remains the owner of the live return-brief proof; it loads no supervision extension into its synthetic primary and does not yet exercise the parked-main scenario, which is a follow-up for a Herdr-lab-guarded task. + ## Native Codex through Pi Verified on 2026-09-08 with Pi 0.85.1 and the installed `pi-codex-native` 0.2.1 adapter. diff --git a/tests/fm-branch-supervision.test.sh b/tests/fm-branch-supervision.test.sh index 5771cb8a2bc..c68d9995a52 100644 --- a/tests/fm-branch-supervision.test.sh +++ b/tests/fm-branch-supervision.test.sh @@ -837,6 +837,92 @@ test_branch_cannot_force_teardown_or_directly_relaunch() { pass "the branch cannot force a teardown or bypass fm-control for a relaunch" } +# --- away posture: main parked, standing authority relocated ----------------- + +# The relocation is exactly bin/fm-lease-lib.sh's role-partition paragraph: +# the branch passes the main-only partition for the PR merge and a fresh spawn +# ONLY while a confirmed, live away-posture record exists; local-only landing +# is never relocated; the record's spend cap binds a fresh ordinary spawn for +# either actor; and an unconfirmed, archived, or invalid record is absence, +# restoring the attended refusal byte for byte. +test_away_record_relocates_main_owned_actions_to_the_branch() { + local home root out status refusal + home="$TMP_ROOT/away-home" + root="$TMP_ROOT/away-root" + mkdir -p "$home/state" "$root" + git init -q -b main "$root" + git -C "$root" commit -q --allow-empty -m init + ln -s "$ROOT/bin" "$root/bin" + refusal="error: PR merge (fm-pr-merge) refused - the supervision branch never performs this action; report the outcome and leave it to main (role partition: docs/pi-supervision-branch.md)" + + # Attended: the refusal wording every caller already pins. + out=$(FM_HOME="$home" FM_SUPERVISION_ACTOR=branch "$ROOT/bin/fm-pr-merge.sh" task-x https://github.com/o/r/pull/1 2>&1) + status=$? + [ "$status" -eq 6 ] || fail "attended branch fm-pr-merge exited $status, not 6: $out" + assert_contains "$out" "$refusal" "attended refusal lost its wording" + + # A proposal alone is not the posture: only a CONFIRMED record relocates. + FM_HOME="$home" "$ROOT/bin/fm-afk-contract.sh" propose --spend 2 >/dev/null || fail "away propose failed" + out=$(FM_HOME="$home" FM_SUPERVISION_ACTOR=branch "$ROOT/bin/fm-pr-merge.sh" task-x https://github.com/o/r/pull/1 2>&1) + status=$? + [ "$status" -eq 6 ] || fail "an unconfirmed proposal relocated the merge (exit $status): $out" + FM_HOME="$home" "$ROOT/bin/fm-afk-contract.sh" confirm >/dev/null || fail "away confirm failed" + + # Under the record the partition passes and the merge script reaches its + # OWN gate (no task record here), never the partition refusal. + out=$(FM_HOME="$home" FM_SUPERVISION_ACTOR=branch "$ROOT/bin/fm-pr-merge.sh" task-x https://github.com/o/r/pull/1 2>&1) + status=$? + [ "$status" -ne 6 ] || fail "branch fm-pr-merge still hit the partition under the record: $out" + assert_contains "$out" "main is parked" "the relocation did not announce itself" + assert_contains "$out" "task metadata is unavailable" "the merge did not reach its own gate under the record" + + # Local-only landing is never relocated: it has no record-side gate. + out=$(FM_HOME="$home" FM_SUPERVISION_ACTOR=branch "$ROOT/bin/fm-merge-local.sh" task-x 2>&1) + status=$? + [ "$status" -eq 6 ] || fail "branch fm-merge-local was relocated under the record (exit $status): $out" + assert_contains "$out" "local-only landing (fm-merge-local) refused" "merge-local refusal lost its wording under the record" + + # A fresh spawn passes the partition and meets the spend cap: one ordinary + # task record against a cap of 2 proceeds to ordinary validation, a + # secondmate record never counts, and a second ordinary record refuses. + fm_write_meta "$home/state/task-a.meta" "window=fm-task-a" "kind=ship" + fm_write_meta "$home/state/mate-1.meta" "window=remote:mate-1" "kind=secondmate" + out=$(FM_HOME="$home" FM_ROOT_OVERRIDE="$root" FM_SUPERVISION_ACTOR=branch \ + "$ROOT/bin/fm-spawn.sh" task-new --mode no-mistakes --yolo off 2>&1) + status=$? + [ "$status" -ne 6 ] || fail "branch fm-spawn still hit the partition under the record: $out" + assert_contains "$out" "main is parked" "the spawn relocation did not announce itself" + assert_not_contains "$out" "caps concurrent workers" "one ordinary task under a cap of 2 was refused" + fm_write_meta "$home/state/task-b.meta" "window=fm-task-b" "kind=ship" + out=$(FM_HOME="$home" FM_ROOT_OVERRIDE="$root" FM_SUPERVISION_ACTOR=branch \ + "$ROOT/bin/fm-spawn.sh" task-new --mode no-mistakes --yolo off 2>&1) + status=$? + [ "$status" -eq 1 ] || fail "spend-cap refusal exited $status, not 1: $out" + assert_contains "$out" "caps concurrent workers at 2 and 2 ordinary task(s) are live" "spend-cap refusal lost its count" + # The cap binds main too: the posture, not the actor, is what caps spend. + out=$(FM_HOME="$home" FM_ROOT_OVERRIDE="$root" "$ROOT/bin/fm-spawn.sh" task-new --mode no-mistakes --yolo off 2>&1) + status=$? + [ "$status" -eq 1 ] || fail "main spawn past the cap exited $status, not 1: $out" + assert_contains "$out" "caps concurrent workers" "main was not held to the spend cap" + + # Archive is absence: the attended refusal returns, byte for byte. + FM_HOME="$home" "$ROOT/bin/fm-afk-contract.sh" archive >/dev/null || fail "away archive failed" + out=$(FM_HOME="$home" FM_SUPERVISION_ACTOR=branch "$ROOT/bin/fm-pr-merge.sh" task-x https://github.com/o/r/pull/1 2>&1) + status=$? + [ "$status" -eq 6 ] || fail "an archived record still relocated the merge (exit $status): $out" + assert_contains "$out" "$refusal" "the attended refusal changed after archive" + assert_not_contains "$out" "main is parked" "an archived record still announced a relocation" + out=$(FM_HOME="$home" "$ROOT/bin/fm-spawn.sh" task-new --mode no-mistakes --yolo off 2>&1) + assert_not_contains "$out" "caps concurrent workers" "the spend cap outlived the record" + # A record that no longer validates is absence too. + printf 'version: 99\n' > "$home/state/.afk-contract" + out=$(FM_HOME="$home" FM_SUPERVISION_ACTOR=branch "$ROOT/bin/fm-pr-merge.sh" task-x https://github.com/o/r/pull/1 2>&1) + status=$? + [ "$status" -eq 6 ] || fail "an invalid record relocated the merge (exit $status): $out" + assert_contains "$out" "$refusal" "the attended refusal changed under an invalid record" + pass "the away-posture record relocates the PR merge and a spawn under the spend cap to the branch, never local landing, and only while confirmed and valid" +} + test_branch_prompt_is_byte_stable_and_above_cache_floor test_outcome_store_is_append_only_with_cursor_reads test_outcome_startup_replay_preserves_silence @@ -857,3 +943,4 @@ test_guard_holds_exclusivity_through_mutation test_claim_refuses_the_other_actors_name_loudly test_release_actor_drops_only_that_actors_leases test_branch_cannot_force_teardown_or_directly_relaunch +test_away_record_relocates_main_owned_actions_to_the_branch diff --git a/tests/fm-pi-branch-extension.test.sh b/tests/fm-pi-branch-extension.test.sh index 812a55eade3..554e7d46f5d 100644 --- a/tests/fm-pi-branch-extension.test.sh +++ b/tests/fm-pi-branch-extension.test.sh @@ -1587,17 +1587,19 @@ if (dispatch("check: unresolved fleet event", []).accepted) { throw new Error("branch accepted an unscoped, non-heartbeat fleet wake"); } -// Away mode still owns supervision regardless of default-on eligibility. +// The legacy away daemon flag means nothing on Pi, where the daemon is never +// launched: the branch keeps accepting (docs/pi-supervision-branch.md +// "Postures"; the away-posture record itself is covered by +// test_away_record_parks_main_and_presents_after_archive). writeFileSync(`${home}/state/.afk`, ""); -if (dispatch("signal: while afk").accepted) throw new Error("branch accepted a wake during away mode"); +if (!dispatch("signal: legacy flag present").accepted) throw new Error("branch declined a wake over the legacy daemon flag"); rmSync(`${home}/state/.afk`); -if (!dispatch("signal: gates cleared").accepted) throw new Error("branch refused a wake with gates cleared"); await settle(() => (globalThis.__fmPrompts ?? []).length === 3, "branch wake prompts"); process.exit(0); EOF status=$? out=$(cat "$TMP_ROOT/node-output") - expect_code 0 "$status" "default-on eligibility, heartbeat routing, and afk gating must bind: $out" + expect_code 0 "$status" "default-on eligibility, heartbeat routing, and legacy-flag indifference must bind: $out" PLUGIN="$repo/.pi/extensions/fm-branch-supervision.ts" FM_HOME="$TMP_ROOT/gating-home-2" FM_ROOT_OVERRIDE="$broken" \ DRIVER_PRELUDE="$DRIVER_PRELUDE" node --input-type=module > "$TMP_ROOT/node-output" 2>&1 <<'EOF' @@ -1625,7 +1627,156 @@ EOF status=$? out=$(cat "$TMP_ROOT/node-output") expect_code 0 "$status" "broken-branch settlement must return delivery ownership to the watcher: $out" - pass "branch default-on eligibility (task-scoped, heartbeat, afk) binds and a broken branch rejects to watcher fallback" + pass "branch default-on eligibility (task-scoped, heartbeat, legacy flag ignored) binds and a broken branch rejects to watcher fallback" +} + +# The away posture on the branch side (docs/pi-supervision-branch.md +# "Postures"): with the record present the wake carries the POSTURE: AWAY tail +# ending in the record's read-back verbatim while the branch session and its +# prefix are untouched; check and heartbeat rows are claimed and lift task +# scoping; a captain outcome persists its visible entry but opens NO processing +# turn on the parked main, at report time, at every run boundary, and at +# session start; a request already pending when the record appears is +# cancelled rather than re-presented; and the first run boundary after the +# record is archived presents the accumulated rows with a fresh triggered +# budget. Every record read goes through the real bin/fm-afk-contract.sh. +test_away_record_parks_main_and_presents_after_archive() { + local repo home out status + repo="$TMP_ROOT/away-root" + home="$TMP_ROOT/away-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 = { fire, dispatch, settle, sentToMain, mainEntries, outcomeScript, defaultSessionCtx, home, realRoot, bus, approvedProject }; })()`); +const { fire, dispatch, settle, sentToMain, mainEntries, outcomeScript, defaultSessionCtx, home, realRoot, bus, approvedProject } = globalThis.__t; +import { spawnSync } from "node:child_process"; +import { readFileSync, writeFileSync } from "node:fs"; + +const contract = (args) => { + const result = spawnSync("bash", [`${realRoot}/bin/fm-afk-contract.sh`, ...args], { + encoding: "utf8", + env: { ...process.env, FM_HOME: home, FM_STATE_OVERRIDE: `${home}/state` }, + }); + if (result.status !== 0) throw new Error(`fm-afk-contract.sh ${args.join(" ")} failed: ${result.stderr}`); + return (result.stdout || "").trim(); +}; +const requests = () => sentToMain.filter((sent) => sent.message.customType === "fm-branch-process"); +const unprocessedSeqs = () => outcomeScript(["unprocessed"]).split("\n").filter(Boolean).map((line) => JSON.parse(line).seq); +const runOf = async (fn) => { await fire("agent_start", {}); await fn?.(); await fire("agent_end", {}); await fire("agent_settled", {}); }; + +await fire("session_start", {}, defaultSessionCtx); + +// 1. Attended: no tail, and the branch session is built from the generator. +let finishPrompt; +globalThis.__fmOnBranchPrompt = () => new Promise((resolve) => { finishPrompt = resolve; }); +const attendedOffer = dispatch("signal: attended wake"); +if (!attendedOffer.accepted) throw new Error("the attended wake was refused"); +await settle(() => (globalThis.__fmPrompts ?? []).length === 1, "attended branch prompt"); +const session = globalThis.__fmSessions[0]; +const report = session.options.customTools.find((tool) => tool.name === "fm_branch_report"); +if (globalThis.__fmPrompts[0].includes("POSTURE: AWAY")) throw new Error("an attended wake carried the away tail"); +// The prefix is the generator's output handed to the branch's resource +// loader; the per-wake tail must never appear there. +const systemPrompt = (globalThis.__fmLoaders ?? []).at(-1)?.options?.systemPrompt; +if (typeof systemPrompt !== "string" || !systemPrompt.startsWith("You are the SUPERVISION BRANCH")) { + throw new Error("the branch session was not built from the byte-stable generator"); +} +if (systemPrompt.includes("POSTURE: AWAY.")) throw new Error("the per-wake tail leaked into the prefix"); +if (!systemPrompt.includes("# Postures") || !systemPrompt.includes("# Ask-user authority policy")) { + throw new Error("the prefix lost its fixed Postures section or the ask-user-authority policy"); +} +await report.execute("r1", { task: "branch-driver", verdict: "routine", summary: "worker healthy" }, undefined, undefined, {}); +finishPrompt(); +await attendedOffer.settlement; +globalThis.__fmOnBranchPrompt = undefined; + +// 2. A captain outcome reported while attended opens its processing request; +// the record appearing before the next run boundary cancels the volatile +// request instead of re-presenting it to a main that has just been parked. +const first = await report.execute("c1", { task: "task-d", verdict: "captain", summary: "PR https://example.com/pr/1 is ready for review" }, undefined, undefined, {}); +if (first.isError) throw new Error(`attended captain report failed: ${JSON.stringify(first)}`); +const seq1 = JSON.parse(outcomeScript(["list", "--recent", "1"])).seq; +if (requests().length !== 1) throw new Error(`the attended captain outcome opened ${requests().length} requests, not 1`); +contract(["propose", "--grant", "task-d"]); +contract(["confirm"]); +await runOf(() => mainEntries.push({ type: "message", message: { role: "assistant", content: [] } })); +if (requests().length !== 1) throw new Error("a request pending when the record appeared was re-presented to the parked main"); +if (JSON.stringify(unprocessedSeqs()) !== JSON.stringify([seq1])) throw new Error(`the record moved the processed marker: ${unprocessedSeqs()}`); + +// 3. Under the record: the tail ends with the read-back verbatim, the branch +// session is the same one (no rebuild, so the prefix is untouched), the +// check and heartbeat rows are claimed, and a claimed check row lifts task +// scoping so the branch may report fleet. +writeFileSync( + `${home}/state/.wake-queue`, + "1\t1\tsignal\tbranch-driver.status\tsignal: away wake\n2\t2\tcheck\tmain-only\tcheck: task-d.check.sh: PR merged\n3\t3\theartbeat\theartbeat\theartbeat\n", +); +globalThis.__fmOnBranchPrompt = () => new Promise((resolve) => { finishPrompt = resolve; }); +const awayOffer = { + message: "signal: away wake", + projects: [approvedProject], + heartbeat: false, + eligible: true, + accepted: false, + settlement: Promise.resolve(), + accept(settlement = Promise.resolve()) { + awayOffer.accepted = true; + awayOffer.settlement = settlement; + }, +}; +bus.emit("fm-branch-supervision:dispatch", awayOffer); +if (!awayOffer.accepted) throw new Error("the away wake was refused"); +await settle(() => (globalThis.__fmPrompts ?? []).length === 2, "away branch prompt"); +if (globalThis.__fmSessions.length !== 1) throw new Error("the away posture rebuilt the branch session"); +const awayPrompt = globalThis.__fmPrompts[1]; +const head = "FIRSTMATE SUPERVISION WAKE: signal: away wake\n\nHandle this per your operating procedure and finish with fm_branch_report.\n\nPOSTURE: AWAY. "; +if (!awayPrompt.startsWith(head)) throw new Error(`the away wake lost its shape or its tail: ${awayPrompt}`); +const readback = contract(["readback"]); +if (!readback.includes("merge when green (task ids): task-d")) throw new Error(`the read-back lost the grant: ${readback}`); +if (!awayPrompt.endsWith(`The record, verbatim:\n${readback}`)) throw new Error(`the tail does not end with the record's read-back verbatim: ${awayPrompt}`); +const snapshot = readFileSync(`${home}/state/.branch-eligible-rows`, "utf8").trim().split("\n").join(","); +if (snapshot !== "1,2,3") throw new Error(`the away wake claimed rows ${snapshot}, not every row`); +const fleet = await report.execute("c2", { task: "fleet", verdict: "captain", summary: "merged task-d's PR under its grant" }, undefined, undefined, {}); +if (fleet.isError) throw new Error(`a fleet report under a claimed check row was refused: ${JSON.stringify(fleet)}`); +finishPrompt(); +await awayOffer.settlement; +globalThis.__fmOnBranchPrompt = undefined; +const seq2 = JSON.parse(outcomeScript(["list", "--recent", "1"])).seq; + +// 4. No processing turn under the record: not at report time, not at a run +// boundary, not at session start. The visible entry still persists. +if (requests().length !== 1) throw new Error("a captain outcome under the record opened a processing turn on the parked main"); +if (!mainEntries.some((entry) => entry.customType === "fm-branch-visible-outcome" && entry.data.seq === seq2)) { + throw new Error("the captain row's visible entry was not persisted under the record"); +} +if (JSON.stringify(unprocessedSeqs()) !== JSON.stringify([seq1, seq2])) throw new Error(`the rows did not accumulate unprocessed: ${unprocessedSeqs()}`); +await runOf(); +if (requests().length !== 1) throw new Error("a run boundary under the record opened a processing turn"); +await fire("session_shutdown", {}); +await fire("session_start", {}, defaultSessionCtx); +if (requests().length !== 1) throw new Error("session start under the record opened a processing turn"); +if (JSON.stringify(unprocessedSeqs()) !== JSON.stringify([seq1, seq2])) throw new Error("the record moved the processed marker across a session start"); + +// 5. The return archives the record; the first run boundary presents the +// accumulated set as one request with a fresh triggered budget. +contract(["archive"]); +await runOf(); +if (requests().length !== 2) throw new Error(`the run boundary after archive presented ${requests().length - 1} requests, not 1`); +const presented = requests()[1]; +if (presented.options.triggerTurn !== true || presented.options.deliverAs !== "followUp") { + throw new Error(`the post-archive presentation did not open its own turn: ${JSON.stringify(presented.options)}`); +} +for (const needle of [`[seq ${seq1}] task-d:`, `[seq ${seq2}] fleet:`, `through=${seq2}`]) { + if (!presented.message.content.includes(needle)) throw new Error(`the post-archive request lost ${needle}: ${presented.message.content}`); +} +process.exit(0); +EOF + status=$? + out=$(cat "$TMP_ROOT/node-output") + expect_code 0 "$status" "the away posture must park main and present after archive: $out" + pass "under the away-posture record the wake carries the verbatim read-back tail, claims every row, opens no processing turn, cancels a pending request, and presents the accumulated rows after archive" } test_branch_predrain_recheck_keeps_a_heartbeat_a_co_present_check_arrives_under() { @@ -4936,6 +5087,7 @@ test_captain_outcome_processing_turn_is_sequence_keyed_and_re_presented test_branch_dispatch_classifies_main_only_rows_and_writes_the_eligible_snapshot test_branch_cache_key_is_per_home_stable test_branch_default_on_heartbeat_afk_and_fallback +test_away_record_parks_main_and_presents_after_archive 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 diff --git a/tests/fm-pi-watch-extension.test.sh b/tests/fm-pi-watch-extension.test.sh index 494ff7c2b01..2604163d7ad 100755 --- a/tests/fm-pi-watch-extension.test.sh +++ b/tests/fm-pi-watch-extension.test.sh @@ -1328,6 +1328,182 @@ EOF pass "watcher-failure repair stays with main even with a live, accepting branch listener" } +# Under the away-posture record the dispatcher offers every actionable row to +# the branch - a check-kind trigger and a needs-decision signal included, the +# two classes attended routing forces to main - while the two broken-queue +# vetoes (an unresolvable task-local row, a structurally invalid row) and every +# watcher-failure alarm still reach main exactly as attended +# (docs/pi-supervision-branch.md "Postures"). +test_pi_away_record_collapses_eligibility_and_keeps_vetoes_on_main() { + local repo home plugin log stop out status label expect reason queue + repo="$TMP_ROOT/pi-away-root" + home="$TMP_ROOT/pi-away-home" + 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" + FM_HOME="$home" "$ROOT/bin/fm-afk-contract.sh" propose >/dev/null || fail "away propose failed" + FM_HOME="$home" "$ROOT/bin/fm-afk-contract.sh" confirm >/dev/null || fail "away confirm failed" + [ -f "$home/state/.afk-contract" ] || fail "the away-posture record was not written" + 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 '%s\n' "${FM_TEST_REASON:?}" + 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" + while IFS='|' read -r label expect reason queue; do + [ -n "$label" ] || continue + log="$TMP_ROOT/pi-away-$label.log" + stop="$TMP_ROOT/pi-away-$label.stop" + out=$(PLUGIN="$plugin" FM_HOME="$home" FM_ROOT_OVERRIDE="$repo" FM_ARM_LOG="$log" FM_STOP_FILE="$stop" \ + FM_TEST_REASON="$reason" FM_TEST_QUEUE="$queue" FM_TEST_EXPECT="$expect" 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`); +writeFileSync( + `${process.env.FM_HOME}/state/.wake-queue`, + process.env.FM_TEST_QUEUE.replace(/\\t/g, "\t").replace(/\\n/g, "\n"), +); +const mod = await import(pathToFileURL(process.env.PLUGIN).href); +mod.default(pi); +await tool.execute("tool-call-away", {}, undefined, undefined, {}); +for (let i = 0; i < 250 && offers.length === 0 && !prompt; i += 1) { + await new Promise((resolve) => setTimeout(resolve, 10)); +} +// Give a wrongly-routed main follow-up time to show up before asserting its absence. +for (let i = 0; i < 25 && !prompt; i += 1) { + await new Promise((resolve) => setTimeout(resolve, 10)); +} +if (process.env.FM_TEST_EXPECT === "branch") { + if (offers.length !== 1 || offers[0].eligible !== true) { + throw new Error(`under the away-posture record this wake was not offered to the branch: ${JSON.stringify(offers)}`); + } + if (prompt) throw new Error(`a branch-eligible wake still woke the parked main: ${prompt}`); +} else { + if (offers.length !== 1 || offers[0].eligible !== false) { + throw new Error(`a broken-queue wake was offered to the branch under the record: ${JSON.stringify(offers)}`); + } + if (!prompt.includes(`FIRSTMATE WATCHER WAKE: ${process.env.FM_TEST_REASON}`)) { + throw new Error(`a wake the branch cannot take did not fall back to main: ${prompt}`); + } +} +writeFileSync(process.env.FM_STOP_FILE, "stop\n"); +process.exit(0); +EOF + ) + status=$? + expect_code 0 "$status" "away routing for the $label case must bind: $out" + [ -z "$out" ] || fail "Pi away routing test ($label) printed output: $out" + done <<'CASES' +check-trigger|branch|check: task-a.check.sh: PR merged|1\t1\tsignal\ttask-a.status\tsignal: task-a.status\n2\t2\tcheck\tmain-only\tcheck: task-a.check.sh: PR merged\n +check-only|branch|check: x-mention 1234567890|1\t1\tcheck\tmain-only\tcheck: x-mention 1234567890\n +needs-decision|branch|signal: task-a.status|1\t1\tsignal\ttask-a.status\tneeds-decision: [key=scope] skip or re-implement\n +unresolvable|main|signal: task-zz.status|1\t1\tsignal\ttask-zz.status\tsignal: task-zz.status\n +corrupt|main|signal: task-a.status|not a queue row\n +CASES + + # Only main can repair supervision itself: a watcher-failure alarm still + # reaches main with the record present and a live, accepting branch listener. + repo="$TMP_ROOT/pi-away-alarm-root" + mkdir -p "$repo/bin" + 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 +printf 'watcher: healthy pid=1 (beacon 0s)\n' +SH + chmod +x "$repo/bin/fm-watch-arm.sh" + out=$(PLUGIN="$plugin" FM_HOME="$home" FM_ROOT_OVERRIDE="$repo" FM_WATCH_REARM_RETRY_BASE_MS=5 FM_WATCH_REARM_RETRY_MAX_MS=10 FM_WATCH_REARM_RETRY_LIMIT=2 node --input-type=module 2>&1 <<'EOF' +import { writeFileSync } from "node:fs"; +import { pathToFileURL } from "node:url"; + +const offers = []; +let prompt = ""; +let handler = null; +const handlers = new Map(); +const bus = { + on(channel, h) { + handlers.set(channel, [...(handlers.get(channel) ?? []), h]); + return () => {}; + }, + emit(channel, data) { + for (const h of handlers.get(channel) ?? []) h(data); + }, +}; +bus.on("fm-branch-supervision:dispatch", (offer) => { + offers.push({ message: offer.message }); + offer.accept(); +}); +const pi = { + on() {}, + events: bus, + registerCommand(name, options) { + if (name === "fm-watch-arm-pi") handler = options.handler; + }, + registerTool() {}, + sendUserMessage: async (message) => { + prompt = message; + }, +}; +writeFileSync(`${process.env.FM_HOME}/state/.lock`, `${process.pid}\n`); +const mod = await import(pathToFileURL(process.env.PLUGIN).href); +mod.default(pi); +await handler("", { ui: { notify() {} } }); +for (let i = 0; i < 250 && !prompt; i += 1) { + await new Promise((resolve) => setTimeout(resolve, 20)); +} +if (!prompt.includes("external healthy watcher")) { + throw new Error(`a watcher failure under the away-posture record did not reach main: ${prompt}`); +} +if (offers.length !== 0) { + throw new Error(`a watcher failure was offered to the branch under the record: ${JSON.stringify(offers)}`); +} +EOF + ) + status=$? + expect_code 0 "$status" "a watcher-failure alarm must still reach main under the record: $out" + [ -z "$out" ] || fail "Pi away alarm test printed output: $out" + pass "under the away-posture record every actionable row is offered to the branch while broken-queue wakes and watcher-failure alarms still reach main" +} + test_pi_handling_delivery_failure_is_typed_once() { local repo home plugin log stop out status repo="$TMP_ROOT/pi-handling-fail-root" @@ -3989,6 +4165,7 @@ 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_away_record_collapses_eligibility_and_keeps_vetoes_on_main test_pi_handling_delivery_failure_is_typed_once test_pi_hung_successor_falls_back_to_typed_wake test_pi_unretired_successor_falls_back_without_retry diff --git a/tests/fm-pr-merge.test.sh b/tests/fm-pr-merge.test.sh index ef48c11488c..f36007d699b 100755 --- a/tests/fm-pr-merge.test.sh +++ b/tests/fm-pr-merge.test.sh @@ -2768,6 +2768,79 @@ test_away_grant_and_yolo_and_hold_for_return() { pass "away merges require yolo or a grant, and --attended-override does not skip that" } +# While the away-posture record exists main is parked, so the supervision +# branch actor may reach the merge gate - and meets exactly the gate main +# would: a granted task merges green at its live head under away-grant +# authority, an ungranted one is held for the return, and without the record +# the branch is refused at the role partition before any forge call +# (docs/pi-supervision-branch.md "Postures"). +test_away_branch_actor_merges_only_with_a_grant() { + local case_dir rc url head + head=dadadadadadadadadadadadadadadadadadadada + url=https://github.com/example/repo/pull/93 + + case_dir=$(make_case away-branch-attended) + mkdir -p "$case_dir/wt" + add_gh_mocks "$case_dir" "$head" + set +e + FM_SUPERVISION_ACTOR=branch run_pr_merge "$case_dir" task-x1 "$url" \ + > "$case_dir/stdout" 2> "$case_dir/stderr" + rc=$? + set -e + expect_code 6 "$rc" "away-branch-attended: an attended branch must be refused at the partition" + assert_grep 'the supervision branch never performs this action' "$case_dir/stderr" \ + "away-branch-attended: refusal lost the partition wording" + [ ! -e "$case_dir/gh.log" ] || assert_no_grep 'pr ' "$case_dir/gh.log" \ + "away-branch-attended: gh ran for an attended branch merge" + + case_dir=$(make_case away-branch-held) + mkdir -p "$case_dir/wt" + add_gh_mocks "$case_dir" "$head" + write_away_record "$case_dir" + set +e + FM_SUPERVISION_ACTOR=branch run_pr_merge "$case_dir" task-x1 "$url" \ + > "$case_dir/stdout" 2> "$case_dir/stderr" + rc=$? + set -e + expect_code 1 "$rc" "away-branch-held: an ungranted task must be held for the return" + assert_grep 'main is parked' "$case_dir/stderr" \ + "away-branch-held: the relocation note was not printed" + assert_grep 'task task-x1 is held for the captain return' "$case_dir/stderr" \ + "away-branch-held: refusal did not name hold-for-return" + assert_no_grep 'pr merge' "$case_dir/gh.log" \ + "away-branch-held: gh pr merge ran for an ungranted branch merge" + + case_dir=$(make_case away-branch-grant) + mkdir -p "$case_dir/wt" "$case_dir/home" + add_gh_mocks "$case_dir" "$head" + write_away_record "$case_dir" --grant task-x1 + FM_SUPERVISION_ACTOR=branch FM_TEST_HOME="$case_dir/home" run_pr_merge "$case_dir" task-x1 "$url" \ + > "$case_dir/stdout" 2> "$case_dir/stderr" \ + || fail "away-branch-grant: a granted green merge must succeed for the branch: $(cat "$case_dir/stderr")" + assert_logged_gh_merge "$case_dir" 93 example/repo --squash + assert_grep "merge landed: task-x1 $url away-grant" "$case_dir/state/.wake-queue" \ + "away-branch-grant: the durable outcome did not tag away-grant" + + # The green gate is absolute in this posture for the branch as for main. + case_dir=$(make_case away-branch-red) + mkdir -p "$case_dir/wt" "$case_dir/home" + add_gh_mocks "$case_dir" "$head" + write_github_rollup_json "$case_dir" "$head" \ + '{"__typename":"CheckRun","name":"lint","status":"COMPLETED","conclusion":"FAILURE","startedAt":"2026-09-01T00:00:00Z"}' + write_away_record "$case_dir" --grant task-x1 + set +e + FM_SUPERVISION_ACTOR=branch FM_TEST_HOME="$case_dir/home" run_pr_merge "$case_dir" task-x1 "$url" --allow-red lint \ + > "$case_dir/stdout" 2> "$case_dir/stderr" + rc=$? + set -e + expect_code 2 "$rc" "away-branch-red: --allow-red must stay attended-only for the branch" + assert_grep 'allow-red is attended-only' "$case_dir/stderr" \ + "away-branch-red: refusal did not name the attended-only waiver" + assert_no_grep 'pr merge' "$case_dir/gh.log" \ + "away-branch-red: gh pr merge ran for a red branch merge while away" + pass "under the away-posture record the branch merges a granted green task, is held without a grant, cannot waive a red check, and is refused at the partition while attended" +} + test_away_posture_refuses_asynchronous_merge_paths() { local case_dir rc url head merge_line head=abababababababababababababababababababab @@ -3099,6 +3172,7 @@ test_allow_red_still_waives_only_the_current_failure test_allow_red_is_refused_while_away test_allow_red_requires_one_separate_name test_away_grant_and_yolo_and_hold_for_return +test_away_branch_actor_merges_only_with_a_grant test_away_posture_refuses_asynchronous_merge_paths test_away_plan_gated_403_does_not_block_the_merge test_away_grant_does_not_bypass_red_or_identity diff --git a/tests/fm-send-resolve-key.test.sh b/tests/fm-send-resolve-key.test.sh index 62a8f05d2d5..67ea1080bbc 100755 --- a/tests/fm-send-resolve-key.test.sh +++ b/tests/fm-send-resolve-key.test.sh @@ -722,6 +722,69 @@ test_remote_reserved_pending_reply_key_closes_locally() { pass "fm-send --resolve-key: a remote secondmate reserved-key close is the same local ledger append" } +# The decision-answer partition (bin/fm-send.sh header "Answering a decision"): +# a --resolve-key naming an open needs-decision or a captain-held task is a +# decision answer, main-owned while attended and refused for the supervision +# branch before anything is sent; a blocked: key is ordinary steering for +# either actor; and while the away-posture record exists the same branch +# answer is sent and closes the key, because main is parked. Main itself never +# meets the partition. +test_decision_answer_partition_relocates_under_the_record() { + local dir fb log home rc out + dir="$TMP_ROOT/partition"; mkdir -p "$dir" + fb=$(make_stubs "$dir"); log="$dir/send.log" + home=$(setup_home partition) + fm_write_meta "$home/state/t1.meta" "window=sess:fm-t1" "kind=ship" + printf 'needs-decision [key=api-shape]: pick REST or RPC\n' > "$home/state/t1.status" + printf 'blocked [key=token]: firstmate can refresh the token\n' >> "$home/state/t1.status" + + # Attended branch: the decision is refused at the partition, nothing sent. + : > "$log" + out=$(env PATH="$fb:$PATH" FM_ROOT_OVERRIDE="$home" FM_HOME="$home" FM_SEND_LOG="$log" FM_SEND_SETTLE=0 \ + FM_SUPERVISION_ACTOR=branch "$SEND" t1 --resolve-key api-shape "go with REST" 2>&1); rc=$? + expect_code 6 "$rc" "an attended branch answering a decision must be refused at the partition" + assert_contains "$out" "decision answer (fm-send --resolve-key) refused" "the partition refusal lost its action label" + [ ! -e "$home/state/t1.inbox" ] || fail "a refused decision answer still reached the worker's inbox" + [ ! -s "$log" ] || fail "a refused decision answer still rang the doorbell" + out=$(drain_out "$home") + printf '%s' "$out" | grep -F '[key=api-shape]' >/dev/null \ + || fail "the refused answer closed the decision anyway: $out" + + # Attended branch: a blocked: key is steering, sent and closed under the + # ordinary lease guard alone. + FM_SUPERVISION_ACTOR=branch run_send "$fb" "$home" "$log" t1 --resolve-key token "refreshed the token; resume"; rc=$? + expect_code 0 "$rc" "an attended branch resolving a blocker is ordinary steering" + grep -qF 'resolved [key=token]: answered: refreshed the token; resume' "$home/state/t1.status" \ + || fail "the branch's blocker answer did not close the key:"$'\n'"$(cat "$home/state/t1.status")" + grep -qF "refreshed the token; resume" "$home/state/t1.inbox/001.msg" \ + || fail "the branch's blocker answer did not reach the worker's inbox" + + # Under the record: the same decision answer is sent and closes the key. + FM_HOME="$home" "$ROOT/bin/fm-afk-contract.sh" propose >/dev/null || fail "away propose failed" + FM_HOME="$home" "$ROOT/bin/fm-afk-contract.sh" confirm >/dev/null || fail "away confirm failed" + out=$(env PATH="$fb:$PATH" FM_ROOT_OVERRIDE="$home" FM_HOME="$home" FM_SEND_LOG="$log" FM_SEND_SETTLE=0 \ + FM_SUPERVISION_ACTOR=branch "$SEND" t1 --resolve-key api-shape "go with REST" 2>&1); rc=$? + expect_code 0 "$rc" "under the away-posture record the branch's decision answer must be sent: $out" + assert_contains "$out" "main is parked" "the relocation did not announce itself" + grep -qF 'resolved [key=api-shape]: answered: go with REST' "$home/state/t1.status" \ + || fail "the relocated answer did not close the decision:"$'\n'"$(cat "$home/state/t1.status")" + grep -qF "go with REST" "$home/state/t1.inbox/002.msg" \ + || fail "the relocated answer did not reach the worker's inbox" + out=$(drain_out "$home") + if printf '%s' "$out" | grep -F '[key=api-shape]' >/dev/null; then + fail "the relocated answer left the decision open: $out" + fi + + # Main never meets the partition, attended or not. + FM_HOME="$home" "$ROOT/bin/fm-afk-contract.sh" archive >/dev/null || fail "away archive failed" + printf 'needs-decision [key=db]: postgres or sqlite\n' >> "$home/state/t1.status" + run_send "$fb" "$home" "$log" t1 --resolve-key db "postgres"; rc=$? + expect_code 0 "$rc" "main answering a decision attended is unaffected by the partition" + grep -qF 'resolved [key=db]: answered: postgres' "$home/state/t1.status" \ + || fail "main's attended decision answer did not close the key" + pass "fm-send --resolve-key: a decision answer refuses the attended branch before sending, a blocked: key stays steering, and the away-posture record relocates the answer" +} + test_answer_send_closes_open_decision test_answer_close_is_self_announced test_colon_first_key_position_is_answerable @@ -742,3 +805,4 @@ test_unclosable_reserved_key_refuses_before_send test_long_decision_key_refuses_before_send test_failed_close_recovery_command_is_shell_safe test_remote_reserved_pending_reply_key_closes_locally +test_decision_answer_partition_relocates_under_the_record From 3e62b499b5ac33ee63da6c28a8a710deda960e74 Mon Sep 17 00:00:00 2001 From: kunchenguid Date: Fri, 18 Sep 2026 15:58:43 -0700 Subject: [PATCH 02/11] no-mistakes(review): Refuse branch merge after preflight archive race --- bin/fm-pr-merge.sh | 1 + tests/fm-pr-merge.test.sh | 39 ++++++++++++++++++++++++++++++++++++++- 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/bin/fm-pr-merge.sh b/bin/fm-pr-merge.sh index 48868e8c1eb..7c3e5fc072f 100755 --- a/bin/fm-pr-merge.sh +++ b/bin/fm-pr-merge.sh @@ -941,6 +941,7 @@ require_current_away_authority() { return 2 fi fi + fm_lease_forbid_branch "PR merge (fm-pr-merge)" --away-relocated require_away_merge_grant || return 1 if [ "$FM_PR_AWAY_POSTURE" = true ] && [ "${#ALLOW_RED[@]}" -gt 0 ]; then echo "error: --allow-red is attended-only; while the away-posture record exists the green check is absolute" >&2 diff --git a/tests/fm-pr-merge.test.sh b/tests/fm-pr-merge.test.sh index f36007d699b..a22690455ce 100755 --- a/tests/fm-pr-merge.test.sh +++ b/tests/fm-pr-merge.test.sh @@ -145,7 +145,11 @@ case "${1:-} ${2:-}" in *statusCheckRollup*) cat "$FM_TEST_GH_VIEW_JSON" if [ -f "${FM_TEST_AWAY_RECORD_AFTER_VIEW:-}" ]; then - cp "$FM_TEST_AWAY_RECORD_AFTER_VIEW" "$FM_STATE_OVERRIDE/.afk-contract" + if [ -s "${FM_TEST_AWAY_RECORD_AFTER_VIEW}" ]; then + cp "$FM_TEST_AWAY_RECORD_AFTER_VIEW" "$FM_STATE_OVERRIDE/.afk-contract" + else + rm -f "$FM_STATE_OVERRIDE/.afk-contract" + fi fi exit 0 ;; @@ -2841,6 +2845,38 @@ test_away_branch_actor_merges_only_with_a_grant() { pass "under the away-posture record the branch merges a granted green task, is held without a grant, cannot waive a red check, and is refused at the partition while attended" } +# The race this closes: a granted branch merge passes the opening partition +# because the live record exists, then the captain returns and archives that +# record during the slow forge preflight. The locked authority recheck must +# treat that archive as absence and refuse the branch before gh pr merge. +# An empty away-record-after-view file is the mock's archive-during-view hook. +test_away_branch_refuses_when_record_archived_during_preflight() { + local case_dir rc url head + head=a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7 + url=https://github.com/example/repo/pull/127 + + case_dir=$(make_case away-branch-archived-during-preflight) + mkdir -p "$case_dir/wt" "$case_dir/home" + add_gh_mocks "$case_dir" "$head" + write_away_record "$case_dir" --grant task-x1 + : > "$case_dir/away-record-after-view" + set +e + FM_SUPERVISION_ACTOR=branch FM_TEST_HOME="$case_dir/home" run_pr_merge "$case_dir" task-x1 "$url" \ + > "$case_dir/stdout" 2> "$case_dir/stderr" + rc=$? + set -e + expect_code 6 "$rc" "away-branch-archived-during-preflight: an archived record must refuse the branch under the lock" + assert_grep 'main is parked' "$case_dir/stderr" \ + "away-branch-archived-during-preflight: the opening partition never saw the live record" + assert_grep 'the supervision branch never performs this action' "$case_dir/stderr" \ + "away-branch-archived-during-preflight: refusal lost the partition wording" + assert_grep 'pr view' "$case_dir/gh.log" \ + "away-branch-archived-during-preflight: the forge preflight never ran" + assert_no_grep 'pr merge' "$case_dir/gh.log" \ + "away-branch-archived-during-preflight: gh pr merge ran after the record was archived" + pass "a branch merge refuses under the lock when the away record is archived during preflight" +} + test_away_posture_refuses_asynchronous_merge_paths() { local case_dir rc url head merge_line head=abababababababababababababababababababab @@ -3173,6 +3209,7 @@ test_allow_red_is_refused_while_away test_allow_red_requires_one_separate_name test_away_grant_and_yolo_and_hold_for_return test_away_branch_actor_merges_only_with_a_grant +test_away_branch_refuses_when_record_archived_during_preflight test_away_posture_refuses_asynchronous_merge_paths test_away_plan_gated_403_does_not_block_the_merge test_away_grant_does_not_bypass_red_or_identity From b1ba25bf455f68e3abfc125d2c0394ce65e7007b Mon Sep 17 00:00:00 2001 From: kunchenguid Date: Fri, 18 Sep 2026 16:18:11 -0700 Subject: [PATCH 03/11] no-mistakes(review): Fix away wake, spawn, and processing races --- .pi/extensions/fm-branch-supervision.ts | 34 ++++++- .pi/extensions/lib/fm-branch-dispatch.ts | 15 ++- bin/fm-spawn.sh | 53 ++++++---- tests/fm-branch-supervision.test.sh | 81 ++++++++++++++- tests/fm-pi-branch-extension.test.sh | 122 ++++++++++++++++++++++- 5 files changed, 275 insertions(+), 30 deletions(-) diff --git a/.pi/extensions/fm-branch-supervision.ts b/.pi/extensions/fm-branch-supervision.ts index 8b0c58c9ab3..c48e65dbc78 100644 --- a/.pi/extensions/fm-branch-supervision.ts +++ b/.pi/extensions/fm-branch-supervision.ts @@ -126,6 +126,7 @@ import { type BranchPickerItem, } from "./lib/fm-branch-model-picker.ts"; import { + classifyFirstmateCurrentOperationalText, classifyFirstmateOperationalText, encodeFirstmateOperationalInputWith, } from "./lib/fm-operational-input.ts"; @@ -652,6 +653,7 @@ export default function (pi: ExtensionAPI) { // session generation. type ProcessingState = { sequences: string; through: number; triggered: number; pending: boolean; nextTurnQueued: boolean }; let processing: ProcessingState | null = null; + let queuedProcessingDelivery = false; let processedInitializedGeneration = -1; // One revision for BOTH selections: a model or effort change invalidates an // in-flight branch build exactly the same way. @@ -1086,6 +1088,7 @@ export default function (pi: ExtensionAPI) { if (processing.triggered < PROCESSING_TRIGGERED_ATTEMPTS) { processing.triggered += 1; processing.pending = true; + queuedProcessingDelivery = true; pi.sendMessage(message, { triggerTurn: true, deliverAs: "followUp" }); } else if (!processing.nextTurnQueued) { processing.nextTurnQueued = true; @@ -1445,7 +1448,7 @@ ${context.command} return `\n\n${AWAY_POSTURE_TAIL}\n${readback || "(the record's read-back could not be rendered; treat every grant and clause as unavailable and hold on doubt)"}`; } - function enqueueWake(message: string, acceptedGeneration: number, recoveryProbe = false): Promise { + function enqueueWake(message: string, acceptedGeneration: number, recoveryProbe = false, acceptedAway = false): Promise { const acceptedSelectionRevision = branchSelectionRevision; const delivery = branchChain .then(async () => { @@ -1485,7 +1488,12 @@ ${context.command} // scopeForUnreadWake itself marks corrupted (the queue or its // metadata could not be read safely, or an unresolvable task-local // row) still falls back to main. - if (scope.status === "empty" || (!scope.corrupted && scope.eligibleSeqs.length === 0)) return; + if (scope.status === "empty" || (!scope.corrupted && scope.eligibleSeqs.length === 0)) { + if (acceptedAway) { + throw new Error("accepted away-only wake is no longer branch-eligible"); + } + return; + } if (scope.corrupted) { throw new Error("the unread wake queue could not be read safely"); } @@ -1503,7 +1511,7 @@ ${context.command} const entryOffset = sessionManager.getEntries().length; // A claimed check row names no task, so a prompt carrying one is not // scoped by task (only possible in the away posture). - wakeTaskScope = heartbeat || scope.checkSeqs.length > 0 + wakeTaskScope = heartbeat || scope.checkSeqs.length > 0 || scope.heartbeatSeqs.length > 0 ? null : { rows: [...scope.eligibleSeqs], tasks: new Set(scope.eligibleTasks) }; const postureTail = afk ? await awayPostureTail() : ""; @@ -1618,7 +1626,7 @@ ${context.command} if (branchBroken && !recoveryProbe) return; // main owns every wake inside the cooldown window if (!collectCurrentMainDialog()) return; if (recoveryProbe && providerRecovery) providerRecovery.probeInFlight = true; - offer.accept(enqueueWake(offer.message, generation, recoveryProbe)); + offer.accept(enqueueWake(offer.message, generation, recoveryProbe, afkPostureRecordPresent(state))); }); // Pi awaits every extension event handler, so an awaited ownership read @@ -1638,6 +1646,14 @@ ${context.command} // Stage it verbatim and remember the future persisted index for turn_end's // duplicate suppression. Operational extension injections are not dialog. const prompt = event.prompt.trim(); + if ( + classifyFirstmateCurrentOperationalText(prompt)?.trim() === "branch-outcome" && + afkPostureRecordPresent(state) + ) { + processing = null; + ctx?.abort?.(); + return; + } if (!prompt || isOperationalUserText(prompt)) return; const file = currentMainSession.getSessionFile() ?? ""; const index = mirrorCollection.collectAnchor?.index ?? currentMainSession.getEntries().length; @@ -1645,11 +1661,18 @@ ${context.command} mirrorCollection.stagedCaptain = { file, index, text: prompt }; }); - pi.on?.("agent_start", () => { + pi.on?.("agent_start", (_event, ctx) => { mainStreaming = true; // Pi delivers a queued nextTurn copy with the prompt that starts this run, // so a fresh copy may be queued again once this run settles unacknowledged. if (processing) processing.nextTurnQueued = false; + if (queuedProcessingDelivery && afkPostureRecordPresent(state)) { + queuedProcessingDelivery = false; + processing = null; + ctx?.abort?.(); + return; + } + queuedProcessingDelivery = false; }); pi.on?.("agent_end", () => { mainStreaming = false; @@ -1662,6 +1685,7 @@ ${context.command} // reply that only paraphrased it - and is presented again. pi.on?.("agent_settled", async () => { mainStreaming = false; + queuedProcessingDelivery = false; if (processing) processing.pending = false; const settledGeneration = generation; await enqueueDelivery(async () => { diff --git a/.pi/extensions/lib/fm-branch-dispatch.ts b/.pi/extensions/lib/fm-branch-dispatch.ts index b65c684265e..69a098a71f4 100644 --- a/.pi/extensions/lib/fm-branch-dispatch.ts +++ b/.pi/extensions/lib/fm-branch-dispatch.ts @@ -93,6 +93,12 @@ export interface UnreadWakeScope { * task, so a prompt that claims one is not scoped by task. */ checkSeqs: string[]; + /** + * The heartbeat rows included in eligibleSeqs. A heartbeat names no task, + * so a prompt that claims one is not scoped by task, including when a + * non-heartbeat wake claims it in the away posture. + */ + heartbeatSeqs: string[]; taskByWakeKey: Record; } @@ -105,6 +111,7 @@ const EMPTY_SCOPE: UnreadWakeScope = { corrupted: false, needsDecisionKeys: [], checkSeqs: [], + heartbeatSeqs: [], taskByWakeKey: {}, }; const UNSAFE_SCOPE: UnreadWakeScope = { @@ -116,6 +123,7 @@ const UNSAFE_SCOPE: UnreadWakeScope = { corrupted: true, needsDecisionKeys: [], checkSeqs: [], + heartbeatSeqs: [], taskByWakeKey: {}, }; @@ -268,6 +276,7 @@ export function scopeForUnreadWake(state: string, heartbeat: boolean, afk = fals const eligibleTasks = new Set(); const needsDecisionKeys: string[] = []; const checkSeqs: string[] = []; + const heartbeatSeqs: 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"; @@ -284,7 +293,10 @@ export function scopeForUnreadWake(state: string, heartbeat: boolean, afk = fals if (kind === "heartbeat") { // Attended, a heartbeat row is claimed only by a heartbeat review; away, // no main drain will ever take it, so any wake claims it. - if (heartbeat || afk) eligibleSeqs.push(seq); + if (heartbeat || afk) { + eligibleSeqs.push(seq); + heartbeatSeqs.push(seq); + } continue; } if (kind === "check") { @@ -383,6 +395,7 @@ export function scopeForUnreadWake(state: string, heartbeat: boolean, afk = fals corrupted: false, needsDecisionKeys, checkSeqs, + heartbeatSeqs, taskByWakeKey: Object.fromEntries(taskByKey), }; } diff --git a/bin/fm-spawn.sh b/bin/fm-spawn.sh index 918c21d723c..ef3ceb88a42 100755 --- a/bin/fm-spawn.sh +++ b/bin/fm-spawn.sh @@ -1361,33 +1361,47 @@ fi if [ "$RELAUNCH" -ne 1 ]; then fm_lease_forbid_branch "new-task spawn (fm-spawn)" --away-relocated fi -# Spend cap (bin/fm-afk-contract.sh's spend_max_concurrent_workers): while the -# away-posture record exists, a fresh ordinary spawn refuses for BOTH actors -# once this home already holds that many ordinary task records, counted the -# same way the return brief counts tasks live at return (every state/*.meta -# whose kind is not secondmate). A relaunch replaces a worker that already -# counts, and a secondmate is a persistent home rather than spend, so both are -# exempt. Checked before any endpoint, worktree, or record exists, so a refusal -# costs nothing to unwind; the record is read here and not locked across the -# spawn (contract: bin/fm-lease-lib.sh's role-partition paragraph). -if [ "$RELAUNCH" -ne 1 ] && [ "$KIND" != secondmate ] && [ -f "$STATE/.afk-contract" ]; then - SPAWN_AWAY_CAP=$(FM_STATE_OVERRIDE="$STATE" "$SCRIPT_DIR/fm-afk-contract.sh" field spend_max_concurrent_workers 2>/dev/null || true) - case "$SPAWN_AWAY_CAP" in +spawn_refuse_if_away_spend_cap() { + local cap live meta + [ "$RELAUNCH" -ne 1 ] || return 0 + [ "$KIND" != secondmate ] || return 0 + [ -f "$STATE/.afk-contract" ] || return 0 + cap=$(FM_STATE_OVERRIDE="$STATE" "$SCRIPT_DIR/fm-afk-contract.sh" field spend_max_concurrent_workers 2>/dev/null || true) + case "$cap" in '' | *[!0-9]* | 0) echo "error: spawn refused - the away-posture record at $STATE/.afk-contract has no readable spend cap; nothing was dispatched" >&2 exit 1 ;; esac - SPAWN_AWAY_LIVE=0 - for spawn_cap_meta in "$STATE"/*.meta; do - [ -f "$spawn_cap_meta" ] || continue - [ "$(grep '^kind=' "$spawn_cap_meta" 2>/dev/null | tail -1 | cut -d= -f2-)" != secondmate ] || continue - SPAWN_AWAY_LIVE=$((SPAWN_AWAY_LIVE + 1)) + live=0 + for meta in "$STATE"/*.meta; do + [ -f "$meta" ] || continue + [ "$(grep '^kind=' "$meta" 2>/dev/null | tail -1 | cut -d= -f2-)" != secondmate ] || continue + live=$((live + 1)) done - if [ "$SPAWN_AWAY_LIVE" -ge "$SPAWN_AWAY_CAP" ]; then - echo "error: spawn refused - the away-posture record caps concurrent workers at $SPAWN_AWAY_CAP and $SPAWN_AWAY_LIVE ordinary task(s) are live in this home; task $ID stays queued for the captain's return or for a worker to finish (spend cap: bin/fm-afk-contract.sh)" >&2 + if [ "$live" -ge "$cap" ]; then + echo "error: spawn refused - the away-posture record caps concurrent workers at $cap and $live ordinary task(s) are live in this home; task $ID stays queued for the captain's return or for a worker to finish (spend cap: bin/fm-afk-contract.sh)" >&2 exit 1 fi +} +# Spend cap (bin/fm-afk-contract.sh's spend_max_concurrent_workers): while the +# away-posture record exists, a fresh ordinary spawn refuses for BOTH actors +# once this home already holds that many ordinary task records, counted the +# same way the return brief counts tasks live at return (every state/*.meta +# whose kind is not secondmate). A relaunch replaces a worker that already +# counts, and a secondmate is a persistent home rather than spend, so both are +# exempt. Checked before any endpoint, worktree, or record exists, so a refusal +# costs nothing to unwind; rechecked after the task-set lock so two fresh +# spawns cannot both publish from a stale count. +spawn_refuse_if_away_spend_cap +if [ "$RELAUNCH" -ne 1 ] && [ "$KIND" != secondmate ]; then + spawn_actor=$(fm_lease_actor) || exit "$FM_LEASE_REFUSE_EXIT" + if [ "$spawn_actor" = branch ] && fm_lease_away_relocated; then + if ! fm_backlog_row_probe "$DATA" "$ID" || ! fm_backlog_row_dispatchable "$FM_BACKLOG_ROW_STATE"; then + echo "error: spawn refused - the supervision branch under the away-posture record may dispatch only already-queued unblocked work; task $ID has no dispatchable backlog item in this home" >&2 + exit 1 + fi + fi fi if [ "$RELAUNCH" -eq 1 ]; then SPAWN_CONTROL_LOCK="$STATE/.control-$ID.lock" @@ -1440,6 +1454,7 @@ if [ "$RELAUNCH" -eq 0 ]; then exit 1 fi SPAWN_TASK_SET_LOCK_HELD=1 + spawn_refuse_if_away_spend_cap fi if [ "$KIND" = secondmate ]; then if spawn_remote_secondmate "$ID"; then diff --git a/tests/fm-branch-supervision.test.sh b/tests/fm-branch-supervision.test.sh index c68d9995a52..bdb3dfcf28a 100644 --- a/tests/fm-branch-supervision.test.sh +++ b/tests/fm-branch-supervision.test.sh @@ -883,8 +883,9 @@ test_away_record_relocates_main_owned_actions_to_the_branch() { assert_contains "$out" "local-only landing (fm-merge-local) refused" "merge-local refusal lost its wording under the record" # A fresh spawn passes the partition and meets the spend cap: one ordinary - # task record against a cap of 2 proceeds to ordinary validation, a - # secondmate record never counts, and a second ordinary record refuses. + # task record against a cap of 2, then a second ordinary record refuses. + # An arbitrary id is not already-queued work, so the branch is refused at + # that gate rather than proceeding to ordinary validation. fm_write_meta "$home/state/task-a.meta" "window=fm-task-a" "kind=ship" fm_write_meta "$home/state/mate-1.meta" "window=remote:mate-1" "kind=secondmate" out=$(FM_HOME="$home" FM_ROOT_OVERRIDE="$root" FM_SUPERVISION_ACTOR=branch \ @@ -892,6 +893,7 @@ test_away_record_relocates_main_owned_actions_to_the_branch() { status=$? [ "$status" -ne 6 ] || fail "branch fm-spawn still hit the partition under the record: $out" assert_contains "$out" "main is parked" "the spawn relocation did not announce itself" + assert_contains "$out" "already-queued unblocked work" "an arbitrary branch spawn was not held to queued work" assert_not_contains "$out" "caps concurrent workers" "one ordinary task under a cap of 2 was refused" fm_write_meta "$home/state/task-b.meta" "window=fm-task-b" "kind=ship" out=$(FM_HOME="$home" FM_ROOT_OVERRIDE="$root" FM_SUPERVISION_ACTOR=branch \ @@ -923,6 +925,79 @@ test_away_record_relocates_main_owned_actions_to_the_branch() { pass "the away-posture record relocates the PR merge and a spawn under the spend cap to the branch, never local landing, and only while confirmed and valid" } +test_away_branch_spawn_requires_queued_dispatchable_work() { + local home root out status + home="$TMP_ROOT/away-queued-home" + root="$TMP_ROOT/away-queued-root" + mkdir -p "$home/state" "$home/data" "$home/config" "$root" + git init -q -b main "$root" + git -C "$root" commit -q --allow-empty -m init + ln -s "$ROOT/bin" "$root/bin" + cp "$ROOT/.tasks.toml" "$home/.tasks.toml" + printf 'manual\n' > "$home/config/backlog-backend" + cat > "$home/data/backlog.md" <<'EOF' +## Queued +- [ ] task-queued - already queued work + +## Done +EOF + FM_HOME="$home" "$ROOT/bin/fm-afk-contract.sh" propose --spend 2 >/dev/null || fail "away propose failed" + FM_HOME="$home" "$ROOT/bin/fm-afk-contract.sh" confirm >/dev/null || fail "away confirm failed" + + out=$(FM_HOME="$home" FM_ROOT_OVERRIDE="$root" FM_SUPERVISION_ACTOR=branch \ + "$ROOT/bin/fm-spawn.sh" task-arbitrary --mode no-mistakes --yolo off 2>&1) + status=$? + [ "$status" -eq 1 ] || fail "an arbitrary branch spawn exited $status, not 1: $out" + assert_contains "$out" "already-queued unblocked work" "an arbitrary id was dispatched under the record" + + out=$(FM_HOME="$home" FM_ROOT_OVERRIDE="$root" FM_SUPERVISION_ACTOR=branch \ + "$ROOT/bin/fm-spawn.sh" task-queued --mode no-mistakes --yolo off 2>&1) + status=$? + assert_not_contains "$out" "already-queued unblocked work" "a queued item was refused as if it were arbitrary: $out" + [ "$status" -ne 6 ] || fail "a queued branch spawn hit the partition: $out" + assert_contains "$out" "main is parked" "the queued spawn lost its relocation note" + + out=$(FM_HOME="$home" FM_ROOT_OVERRIDE="$root" \ + "$ROOT/bin/fm-spawn.sh" task-arbitrary --mode no-mistakes --yolo off 2>&1) + assert_not_contains "$out" "already-queued unblocked work" "main's attended spawn was held to the branch queued-work gate" + pass "relocated branch spawn admits only already-queued dispatchable work, including on a manual-backend home" +} + +test_away_spend_cap_is_rechecked_under_the_task_set_lock() { + local home root out_a out_b status_a status_b metas + home="$TMP_ROOT/away-cap-lock-home" + root="$TMP_ROOT/away-cap-lock-root" + mkdir -p "$home/state" "$home/data" "$home/config" "$root" + git init -q -b main "$root" + git -C "$root" commit -q --allow-empty -m init + ln -s "$ROOT/bin" "$root/bin" + cp "$ROOT/.tasks.toml" "$home/.tasks.toml" + cat > "$home/data/backlog.md" <<'EOF' +## Queued +- [ ] task-q1 - first queued spawn +- [ ] task-q2 - second queued spawn + +## Done +EOF + FM_HOME="$home" "$ROOT/bin/fm-afk-contract.sh" propose --spend 1 >/dev/null || fail "away propose failed" + FM_HOME="$home" "$ROOT/bin/fm-afk-contract.sh" confirm >/dev/null || fail "away confirm failed" + + FM_HOME="$home" FM_ROOT_OVERRIDE="$root" \ + "$ROOT/bin/fm-spawn.sh" task-q1 --mode no-mistakes --yolo off \ + > "$home/q1.out" 2>&1 & + FM_HOME="$home" FM_ROOT_OVERRIDE="$root" \ + "$ROOT/bin/fm-spawn.sh" task-q2 --mode no-mistakes --yolo off \ + > "$home/q2.out" 2>&1 & + wait || true + out_a=$(cat "$home/q1.out" 2>/dev/null || true) + out_b=$(cat "$home/q2.out" 2>/dev/null || true) + metas=0 + [ -f "$home/state/task-q1.meta" ] && metas=$((metas + 1)) + [ -f "$home/state/task-q2.meta" ] && metas=$((metas + 1)) + [ "$metas" -le 1 ] || fail "two concurrent spawns both published under spend cap 1: q1=$out_a q2=$out_b" + pass "the away spend cap is rechecked under the task-set lock so concurrent spawns cannot both publish" +} + test_branch_prompt_is_byte_stable_and_above_cache_floor test_outcome_store_is_append_only_with_cursor_reads test_outcome_startup_replay_preserves_silence @@ -944,3 +1019,5 @@ test_claim_refuses_the_other_actors_name_loudly test_release_actor_drops_only_that_actors_leases test_branch_cannot_force_teardown_or_directly_relaunch test_away_record_relocates_main_owned_actions_to_the_branch +test_away_branch_spawn_requires_queued_dispatchable_work +test_away_spend_cap_is_rechecked_under_the_task_set_lock diff --git a/tests/fm-pi-branch-extension.test.sh b/tests/fm-pi-branch-extension.test.sh index 554e7d46f5d..bca424d21db 100644 --- a/tests/fm-pi-branch-extension.test.sh +++ b/tests/fm-pi-branch-extension.test.sh @@ -1693,15 +1693,28 @@ await attendedOffer.settlement; globalThis.__fmOnBranchPrompt = undefined; // 2. A captain outcome reported while attended opens its processing request; -// the record appearing before the next run boundary cancels the volatile -// request instead of re-presenting it to a main that has just been parked. +// the record appearing before that request is consumed suppresses the first +// queued delivery rather than letting it open a parked-main turn. const first = await report.execute("c1", { task: "task-d", verdict: "captain", summary: "PR https://example.com/pr/1 is ready for review" }, undefined, undefined, {}); if (first.isError) throw new Error(`attended captain report failed: ${JSON.stringify(first)}`); const seq1 = JSON.parse(outcomeScript(["list", "--recent", "1"])).seq; if (requests().length !== 1) throw new Error(`the attended captain outcome opened ${requests().length} requests, not 1`); +const pending = requests()[0]; +if (pending.message.customType !== "fm-branch-process") { + throw new Error(`the first queued request was not a processing delivery: ${JSON.stringify(pending.message)}`); +} +if (!pending.message.content.includes(`[seq ${seq1}]`)) { + throw new Error(`the first queued request lost seq ${seq1}: ${pending.message.content}`); +} contract(["propose", "--grant", "task-d"]); contract(["confirm"]); -await runOf(() => mainEntries.push({ type: "message", message: { role: "assistant", content: [] } })); +let aborted = false; +const abortCtx = { ...defaultSessionCtx, abort() { aborted = true; } }; +await fire("before_agent_start", { prompt: pending.message.content }, abortCtx); +await fire("agent_start", {}, abortCtx); +if (!aborted) throw new Error("the first queued processing request was not suppressed under the record"); +await fire("agent_end", {}); +await fire("agent_settled", {}); if (requests().length !== 1) throw new Error("a request pending when the record appeared was re-presented to the parked main"); if (JSON.stringify(unprocessedSeqs()) !== JSON.stringify([seq1])) throw new Error(`the record moved the processed marker: ${unprocessedSeqs()}`); @@ -1779,6 +1792,107 @@ EOF pass "under the away-posture record the wake carries the verbatim read-back tail, claims every row, opens no processing turn, cancels a pending request, and presents the accumulated rows after archive" } +test_away_only_wake_rejects_when_record_is_archived_before_drain() { + local repo home out status + repo="$TMP_ROOT/away-only-recheck-root" + home="$TMP_ROOT/away-only-recheck-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 = { fire, home, realRoot, bus, makeOffer, mainUserMessages }; })()`); +const { fire, home, realRoot, bus, makeOffer, mainUserMessages } = globalThis.__t; +import { spawnSync } from "node:child_process"; +import { writeFileSync } from "node:fs"; + +const contract = (args) => { + const result = spawnSync("bash", [`${realRoot}/bin/fm-afk-contract.sh`, ...args], { + encoding: "utf8", + env: { ...process.env, FM_HOME: home, FM_STATE_OVERRIDE: `${home}/state` }, + }); + if (result.status !== 0) throw new Error(`fm-afk-contract.sh ${args.join(" ")} failed: ${result.stderr}`); + return (result.stdout || "").trim(); +}; + +await fire("session_start", {}); +contract(["propose"]); +contract(["confirm"]); +writeFileSync(`${home}/state/.wake-queue`, "1\t1\tcheck\tmain-only\tcheck: task-d.check.sh: PR merged\n"); +const offer = makeOffer("check: task-d.check.sh: PR merged", [], false, true); +bus.emit("fm-branch-supervision:dispatch", offer); +if (!offer.accepted) throw new Error("the away check-only wake was refused at accept"); +contract(["archive"]); +const failure = await offer.settlement.then(() => null, (error) => error); +if (!(failure instanceof Error) || !failure.message.includes("no longer branch-eligible")) { + throw new Error(`an accepted away-only wake quiet-no-op'd after archive: ${String(failure)}`); +} +if ((globalThis.__fmPrompts ?? []).length !== 0) { + throw new Error(`the archived away-only wake still prompted the branch: ${JSON.stringify(globalThis.__fmPrompts)}`); +} +if (mainUserMessages.length !== 0) { + throw new Error("the rejected settlement leaked a main user message from the branch"); +} +process.exit(0); +EOF + status=$? + out=$(cat "$TMP_ROOT/node-output") + expect_code 0 "$status" "an accepted away-only wake must reject after archive: $out" + pass "an accepted away-only wake rejects settlement when the record is archived before drain" +} + +test_away_claimed_heartbeat_on_a_task_wake_lifts_task_scoping() { + local repo home out status + repo="$TMP_ROOT/away-heartbeat-scope-root" + home="$TMP_ROOT/away-heartbeat-scope-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 = { fire, settle, home, realRoot, bus, makeOffer, approvedProject, defaultSessionCtx }; })()`); +const { fire, settle, home, realRoot, bus, makeOffer, approvedProject, defaultSessionCtx } = globalThis.__t; +import { spawnSync } from "node:child_process"; +import { readFileSync, writeFileSync } from "node:fs"; + +const contract = (args) => { + const result = spawnSync("bash", [`${realRoot}/bin/fm-afk-contract.sh`, ...args], { + encoding: "utf8", + env: { ...process.env, FM_HOME: home, FM_STATE_OVERRIDE: `${home}/state` }, + }); + if (result.status !== 0) throw new Error(`fm-afk-contract.sh ${args.join(" ")} failed: ${result.stderr}`); + return (result.stdout || "").trim(); +}; + +await fire("session_start", {}, defaultSessionCtx); +contract(["propose"]); +contract(["confirm"]); +writeFileSync( + `${home}/state/.wake-queue`, + "1\t1\tsignal\tbranch-driver.status\tsignal: branch-driver.status\n2\t2\theartbeat\theartbeat\theartbeat\n", +); +let finishPrompt; +globalThis.__fmOnBranchPrompt = () => new Promise((resolve) => { finishPrompt = resolve; }); +const offer = makeOffer("signal: branch-driver.status", [approvedProject], false, true); +bus.emit("fm-branch-supervision:dispatch", offer); +if (!offer.accepted) throw new Error("the mixed away wake was refused"); +await settle(() => (globalThis.__fmPrompts ?? []).length === 1, "mixed away branch prompt"); +const snapshot = readFileSync(`${home}/state/.branch-eligible-rows`, "utf8").trim().split("\n").join(","); +if (snapshot !== "1,2") throw new Error(`the mixed away wake claimed rows ${snapshot}, not signal+heartbeat`); +const session = globalThis.__fmSessions[0]; +const report = session.options.customTools.find((tool) => tool.name === "fm_branch_report"); +const fleet = await report.execute("fleet", { task: "fleet", verdict: "routine", summary: "fleet heartbeat under a task wake" }, undefined, undefined, {}); +if (fleet.isError) throw new Error(`a claimed heartbeat on a task wake still scoped the report: ${JSON.stringify(fleet)}`); +finishPrompt(); +await offer.settlement; +process.exit(0); +EOF + status=$? + out=$(cat "$TMP_ROOT/node-output") + expect_code 0 "$status" "a claimed heartbeat on a non-heartbeat wake must lift task scoping: $out" + pass "a claimed heartbeat row on a non-heartbeat away wake lifts task scoping for the fleet report" +} + test_branch_predrain_recheck_keeps_a_heartbeat_a_co_present_check_arrives_under() { local repo home out status repo="$TMP_ROOT/predrain-recheck-root" @@ -5088,6 +5202,8 @@ test_branch_dispatch_classifies_main_only_rows_and_writes_the_eligible_snapshot test_branch_cache_key_is_per_home_stable test_branch_default_on_heartbeat_afk_and_fallback test_away_record_parks_main_and_presents_after_archive +test_away_only_wake_rejects_when_record_is_archived_before_drain +test_away_claimed_heartbeat_on_a_task_wake_lifts_task_scoping 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 From 9c4e63801aa0e939f4066b824c17c8f3b25e6d63 Mon Sep 17 00:00:00 2001 From: kunchenguid Date: Fri, 18 Sep 2026 16:31:34 -0700 Subject: [PATCH 04/11] no-mistakes(review): Suppress parked processing; narrow away-only rejection --- .pi/extensions/fm-branch-supervision.ts | 61 +++++++++++++--------- bin/fm-spawn.sh | 3 ++ tests/fm-branch-supervision.test.sh | 46 ++++++++++------- tests/fm-pi-branch-extension.test.sh | 67 +++++++++++++++++++++---- 4 files changed, 126 insertions(+), 51 deletions(-) diff --git a/.pi/extensions/fm-branch-supervision.ts b/.pi/extensions/fm-branch-supervision.ts index c48e65dbc78..06a8581d75f 100644 --- a/.pi/extensions/fm-branch-supervision.ts +++ b/.pi/extensions/fm-branch-supervision.ts @@ -126,7 +126,6 @@ import { type BranchPickerItem, } from "./lib/fm-branch-model-picker.ts"; import { - classifyFirstmateCurrentOperationalText, classifyFirstmateOperationalText, encodeFirstmateOperationalInputWith, } from "./lib/fm-operational-input.ts"; @@ -232,6 +231,31 @@ function offerEligible(offer: BranchDispatchOffer): boolean { return offer.eligible === true; } +function isProcessingCustomMessage(message: { role?: string; customType?: string }): boolean { + return message.role === "custom" && message.customType === PROCESSING_MESSAGE_TYPE; +} + +function wakeAcceptedOnlyBecauseAway(message: string): boolean { + if (!afkPostureRecordPresent(state)) return false; + const heartbeat = /^heartbeat($|:)/.test(message); + if (/^check:/.test(message)) return true; + const attended = scopeForUnreadWake(state, heartbeat, false); + const triggerKeys = /^signal:/.test(message) + ? message + .slice("signal:".length) + .split(/\s+/) + .filter(Boolean) + .map((path) => path.split("/").pop() ?? path) + : /^stale:/.test(message) + ? [message.slice("stale:".length).trim().split(/\s+/, 1)[0]].filter(Boolean) + : []; + const taskIdentity = (key: string): string => + attended.taskByWakeKey[key] ?? attended.taskByWakeKey[key.replace(/^fm-/, "")] ?? key; + const needsDecisionTasks = new Set(attended.needsDecisionKeys.map(taskIdentity)); + if (triggerKeys.some((key) => needsDecisionTasks.has(taskIdentity(key)))) return true; + return !attended.eligible; +} + // Pi persists provider failures as ordinary assistant messages and resolves // AgentSession.prompt(), so promise rejection alone cannot detect them. Read // only the final assistant entry appended by this prompt: unlike the rebuilt @@ -653,7 +677,6 @@ export default function (pi: ExtensionAPI) { // session generation. type ProcessingState = { sequences: string; through: number; triggered: number; pending: boolean; nextTurnQueued: boolean }; let processing: ProcessingState | null = null; - let queuedProcessingDelivery = false; let processedInitializedGeneration = -1; // One revision for BOTH selections: a model or effort change invalidates an // in-flight branch build exactly the same way. @@ -1088,7 +1111,6 @@ export default function (pi: ExtensionAPI) { if (processing.triggered < PROCESSING_TRIGGERED_ATTEMPTS) { processing.triggered += 1; processing.pending = true; - queuedProcessingDelivery = true; pi.sendMessage(message, { triggerTurn: true, deliverAs: "followUp" }); } else if (!processing.nextTurnQueued) { processing.nextTurnQueued = true; @@ -1448,7 +1470,7 @@ ${context.command} return `\n\n${AWAY_POSTURE_TAIL}\n${readback || "(the record's read-back could not be rendered; treat every grant and clause as unavailable and hold on doubt)"}`; } - function enqueueWake(message: string, acceptedGeneration: number, recoveryProbe = false, acceptedAway = false): Promise { + function enqueueWake(message: string, acceptedGeneration: number, recoveryProbe = false, acceptedAwayOnly = false): Promise { const acceptedSelectionRevision = branchSelectionRevision; const delivery = branchChain .then(async () => { @@ -1489,7 +1511,7 @@ ${context.command} // metadata could not be read safely, or an unresolvable task-local // row) still falls back to main. if (scope.status === "empty" || (!scope.corrupted && scope.eligibleSeqs.length === 0)) { - if (acceptedAway) { + if (acceptedAwayOnly) { throw new Error("accepted away-only wake is no longer branch-eligible"); } return; @@ -1626,7 +1648,7 @@ ${context.command} if (branchBroken && !recoveryProbe) return; // main owns every wake inside the cooldown window if (!collectCurrentMainDialog()) return; if (recoveryProbe && providerRecovery) providerRecovery.probeInFlight = true; - offer.accept(enqueueWake(offer.message, generation, recoveryProbe, afkPostureRecordPresent(state))); + offer.accept(enqueueWake(offer.message, generation, recoveryProbe, wakeAcceptedOnlyBecauseAway(offer.message))); }); // Pi awaits every extension event handler, so an awaited ownership read @@ -1646,14 +1668,6 @@ ${context.command} // Stage it verbatim and remember the future persisted index for turn_end's // duplicate suppression. Operational extension injections are not dialog. const prompt = event.prompt.trim(); - if ( - classifyFirstmateCurrentOperationalText(prompt)?.trim() === "branch-outcome" && - afkPostureRecordPresent(state) - ) { - processing = null; - ctx?.abort?.(); - return; - } if (!prompt || isOperationalUserText(prompt)) return; const file = currentMainSession.getSessionFile() ?? ""; const index = mirrorCollection.collectAnchor?.index ?? currentMainSession.getEntries().length; @@ -1661,18 +1675,20 @@ ${context.command} mirrorCollection.stagedCaptain = { file, index, text: prompt }; }); - pi.on?.("agent_start", (_event, ctx) => { + pi.on?.("agent_start", () => { mainStreaming = true; // Pi delivers a queued nextTurn copy with the prompt that starts this run, // so a fresh copy may be queued again once this run settles unacknowledged. if (processing) processing.nextTurnQueued = false; - if (queuedProcessingDelivery && afkPostureRecordPresent(state)) { - queuedProcessingDelivery = false; - processing = null; - ctx?.abort?.(); - return; - } - queuedProcessingDelivery = false; + }); + pi.on?.("context", (event, ctx) => { + if (!afkPostureRecordPresent(state)) return; + const messages = event.messages ?? []; + const kept = messages.filter((message) => !isProcessingCustomMessage(message)); + if (kept.length === messages.length) return; + processing = null; + if (!kept.some((message) => message.role === "user")) ctx?.abort?.(); + return { messages: kept }; }); pi.on?.("agent_end", () => { mainStreaming = false; @@ -1685,7 +1701,6 @@ ${context.command} // reply that only paraphrased it - and is presented again. pi.on?.("agent_settled", async () => { mainStreaming = false; - queuedProcessingDelivery = false; if (processing) processing.pending = false; const settledGeneration = generation; await enqueueDelivery(async () => { diff --git a/bin/fm-spawn.sh b/bin/fm-spawn.sh index ef3ceb88a42..e5a5c940bc9 100755 --- a/bin/fm-spawn.sh +++ b/bin/fm-spawn.sh @@ -1394,6 +1394,9 @@ spawn_refuse_if_away_spend_cap() { # costs nothing to unwind; rechecked after the task-set lock so two fresh # spawns cannot both publish from a stale count. spawn_refuse_if_away_spend_cap +if [ -x "${FM_TEST_SPAWN_AFTER_EARLY_CAP:-}" ]; then + "$FM_TEST_SPAWN_AFTER_EARLY_CAP" +fi if [ "$RELAUNCH" -ne 1 ] && [ "$KIND" != secondmate ]; then spawn_actor=$(fm_lease_actor) || exit "$FM_LEASE_REFUSE_EXIT" if [ "$spawn_actor" = branch ] && fm_lease_away_relocated; then diff --git a/tests/fm-branch-supervision.test.sh b/tests/fm-branch-supervision.test.sh index bdb3dfcf28a..74307393d56 100644 --- a/tests/fm-branch-supervision.test.sh +++ b/tests/fm-branch-supervision.test.sh @@ -964,37 +964,47 @@ EOF } test_away_spend_cap_is_rechecked_under_the_task_set_lock() { - local home root out_a out_b status_a status_b metas + local home root out hook i home="$TMP_ROOT/away-cap-lock-home" root="$TMP_ROOT/away-cap-lock-root" mkdir -p "$home/state" "$home/data" "$home/config" "$root" git init -q -b main "$root" git -C "$root" commit -q --allow-empty -m init ln -s "$ROOT/bin" "$root/bin" - cp "$ROOT/.tasks.toml" "$home/.tasks.toml" - cat > "$home/data/backlog.md" <<'EOF' -## Queued -- [ ] task-q1 - first queued spawn -- [ ] task-q2 - second queued spawn - -## Done -EOF FM_HOME="$home" "$ROOT/bin/fm-afk-contract.sh" propose --spend 1 >/dev/null || fail "away propose failed" FM_HOME="$home" "$ROOT/bin/fm-afk-contract.sh" confirm >/dev/null || fail "away confirm failed" + hook="$home/after-early-cap" + cat > "$hook" < "$home/early-cap-passed" +i=0 +while [ ! -f "$home/competitor-published" ]; do + i=\$((i + 1)) + [ "\$i" -lt 200 ] || exit 1 + sleep 0.05 +done +HOOK + chmod +x "$hook" + + FM_TEST_SPAWN_AFTER_EARLY_CAP="$hook" \ FM_HOME="$home" FM_ROOT_OVERRIDE="$root" \ "$ROOT/bin/fm-spawn.sh" task-q1 --mode no-mistakes --yolo off \ > "$home/q1.out" 2>&1 & - FM_HOME="$home" FM_ROOT_OVERRIDE="$root" \ - "$ROOT/bin/fm-spawn.sh" task-q2 --mode no-mistakes --yolo off \ - > "$home/q2.out" 2>&1 & + i=0 + while [ ! -f "$home/early-cap-passed" ]; do + i=$((i + 1)) + [ "$i" -lt 200 ] || fail "spawn never reached the early spend-cap check: $(cat "$home/q1.out" 2>/dev/null || true)" + sleep 0.05 + done + fm_write_meta "$home/state/task-live.meta" "window=fm-task-live" "kind=ship" + : > "$home/competitor-published" wait || true - out_a=$(cat "$home/q1.out" 2>/dev/null || true) - out_b=$(cat "$home/q2.out" 2>/dev/null || true) - metas=0 - [ -f "$home/state/task-q1.meta" ] && metas=$((metas + 1)) - [ -f "$home/state/task-q2.meta" ] && metas=$((metas + 1)) - [ "$metas" -le 1 ] || fail "two concurrent spawns both published under spend cap 1: q1=$out_a q2=$out_b" + out=$(cat "$home/q1.out" 2>/dev/null || true) + assert_contains "$out" "caps concurrent workers at 1 and 1 ordinary task(s) are live" \ + "the paused spawn did not recheck the cap after the competitor published: $out" + [ ! -f "$home/state/task-q1.meta" ] || fail "the stale-count spawn published after a competitor landed" pass "the away spend cap is rechecked under the task-set lock so concurrent spawns cannot both publish" } diff --git a/tests/fm-pi-branch-extension.test.sh b/tests/fm-pi-branch-extension.test.sh index bca424d21db..b650850bb8b 100644 --- a/tests/fm-pi-branch-extension.test.sh +++ b/tests/fm-pi-branch-extension.test.sh @@ -600,7 +600,9 @@ const pi = { async function fire(event, payload, ctx) { const eventCtx = ctx; if (eventCtx?.sessionManager) activeMainSession = eventCtx.sessionManager; - for (const handler of piHandlers.get(event) ?? []) await handler(payload, eventCtx); + let result; + for (const handler of piHandlers.get(event) ?? []) result = await handler(payload, eventCtx); + return result; } function makeOffer(message, projects = [approvedProject], heartbeat = false, eligible = projects.length > 0 || heartbeat) { const offer = { @@ -1692,9 +1694,11 @@ finishPrompt(); await attendedOffer.settlement; globalThis.__fmOnBranchPrompt = undefined; -// 2. A captain outcome reported while attended opens its processing request; -// the record appearing before that request is consumed suppresses the first -// queued delivery rather than letting it open a parked-main turn. +// 2. A captain outcome reported while main is already streaming queues a +// followUp that joins this run. The record appearing before that follow-up +// is consumed must strip the typed processing message at the context +// boundary for followUp, nextTurn, and a dedicated processing turn. +await fire("agent_start", {}, defaultSessionCtx); const first = await report.execute("c1", { task: "task-d", verdict: "captain", summary: "PR https://example.com/pr/1 is ready for review" }, undefined, undefined, {}); if (first.isError) throw new Error(`attended captain report failed: ${JSON.stringify(first)}`); const seq1 = JSON.parse(outcomeScript(["list", "--recent", "1"])).seq; @@ -1703,16 +1707,41 @@ const pending = requests()[0]; if (pending.message.customType !== "fm-branch-process") { throw new Error(`the first queued request was not a processing delivery: ${JSON.stringify(pending.message)}`); } +if (pending.options.triggerTurn !== true || pending.options.deliverAs !== "followUp") { + throw new Error(`the first queued request was not a streaming followUp: ${JSON.stringify(pending.options)}`); +} if (!pending.message.content.includes(`[seq ${seq1}]`)) { throw new Error(`the first queued request lost seq ${seq1}: ${pending.message.content}`); } contract(["propose", "--grant", "task-d"]); contract(["confirm"]); +const processingMsg = { role: "custom", customType: pending.message.customType, content: pending.message.content, display: false }; let aborted = false; const abortCtx = { ...defaultSessionCtx, abort() { aborted = true; } }; -await fire("before_agent_start", { prompt: pending.message.content }, abortCtx); -await fire("agent_start", {}, abortCtx); -if (!aborted) throw new Error("the first queued processing request was not suppressed under the record"); +const followUpResult = await fire("context", { + messages: [{ role: "user", content: "captain still in this turn" }, processingMsg], +}, abortCtx); +if (aborted) throw new Error("stripping a followUp processing message aborted the streaming captain turn"); +if (followUpResult?.messages?.some((message) => message.customType === "fm-branch-process")) { + throw new Error(`followUp processing was not stripped: ${JSON.stringify(followUpResult)}`); +} +if (!followUpResult?.messages?.some((message) => message.role === "user")) { + throw new Error("followUp suppression dropped the captain turn"); +} +aborted = false; +const nextTurnResult = await fire("context", { + messages: [{ role: "user", content: "watcher: FAILED - repair the cycle" }, processingMsg], +}, abortCtx); +if (aborted) throw new Error("stripping a nextTurn processing message aborted the watcher-failure turn"); +if (nextTurnResult?.messages?.some((message) => message.customType === "fm-branch-process")) { + throw new Error(`nextTurn processing was not stripped: ${JSON.stringify(nextTurnResult)}`); +} +aborted = false; +const idleResult = await fire("context", { messages: [processingMsg] }, abortCtx); +if (!aborted) throw new Error("a dedicated processing turn was not suppressed under the record"); +if (idleResult?.messages?.some((message) => message.customType === "fm-branch-process")) { + throw new Error(`dedicated processing was not stripped: ${JSON.stringify(idleResult)}`); +} await fire("agent_end", {}); await fire("agent_settled", {}); if (requests().length !== 1) throw new Error("a request pending when the record appeared was re-presented to the parked main"); @@ -1801,8 +1830,8 @@ test_away_only_wake_rejects_when_record_is_archived_before_drain() { 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 = { fire, home, realRoot, bus, makeOffer, mainUserMessages }; })()`); -const { fire, home, realRoot, bus, makeOffer, mainUserMessages } = globalThis.__t; +await eval(`(async () => { ${prelude}; globalThis.__t = { fire, home, realRoot, bus, makeOffer, mainUserMessages, approvedProject }; })()`); +const { fire, home, realRoot, bus, makeOffer, mainUserMessages, approvedProject } = globalThis.__t; import { spawnSync } from "node:child_process"; import { writeFileSync } from "node:fs"; @@ -1833,12 +1862,30 @@ if ((globalThis.__fmPrompts ?? []).length !== 0) { if (mainUserMessages.length !== 0) { throw new Error("the rejected settlement leaked a main user message from the branch"); } + +contract(["propose"]); +contract(["confirm"]); +writeFileSync(`${home}/state/.wake-queue`, "1\t1\tsignal\tbranch-driver.status\tsignal: branch-driver.status\n"); +const taskLocal = makeOffer("signal: branch-driver.status", [approvedProject], false, true); +bus.emit("fm-branch-supervision:dispatch", taskLocal); +if (!taskLocal.accepted) throw new Error("the attended-eligible away wake was refused at accept"); +writeFileSync(`${home}/state/.wake-queue`, ""); +const quiet = await taskLocal.settlement.then(() => null, (error) => error); +if (quiet instanceof Error) { + throw new Error(`an attended-eligible wake threw after it was drained: ${quiet.message}`); +} +if ((globalThis.__fmPrompts ?? []).length !== 0) { + throw new Error(`a drained task-local wake prompted the branch: ${JSON.stringify(globalThis.__fmPrompts)}`); +} +if (mainUserMessages.length !== 0) { + throw new Error("a drained task-local wake opened a redundant main turn"); +} process.exit(0); EOF status=$? out=$(cat "$TMP_ROOT/node-output") expect_code 0 "$status" "an accepted away-only wake must reject after archive: $out" - pass "an accepted away-only wake rejects settlement when the record is archived before drain" + pass "an accepted away-only wake rejects after archive, while a drained task-local wake stays a quiet no-op" } test_away_claimed_heartbeat_on_a_task_wake_lifts_task_scoping() { From a96180196b99969292d40ebe1422f82cf11f10bb Mon Sep 17 00:00:00 2001 From: kunchenguid Date: Fri, 18 Sep 2026 17:13:28 -0700 Subject: [PATCH 05/11] no-mistakes(review): Abort dedicated processing; gate branch spawn once --- .pi/extensions/fm-branch-supervision.ts | 18 +++++++++++++++++- bin/fm-spawn.sh | 22 ++++++++++++++-------- tests/fm-branch-supervision.test.sh | 20 ++++++++++++++++++++ tests/fm-pi-branch-extension.test.sh | 23 +++++++++++++++++++---- 4 files changed, 70 insertions(+), 13 deletions(-) diff --git a/.pi/extensions/fm-branch-supervision.ts b/.pi/extensions/fm-branch-supervision.ts index 06a8581d75f..de6d22972d7 100644 --- a/.pi/extensions/fm-branch-supervision.ts +++ b/.pi/extensions/fm-branch-supervision.ts @@ -1497,6 +1497,10 @@ ${context.command} const heartbeat = /^heartbeat($|:)/.test(message); // The posture is read here, at the tail of this wake, never earlier // and never into the prompt prefix. + // Accepted confused-agent-grade residual (bin/fm-lease-lib.sh role- + // partition paragraph): the record is validated then may be archived + // mid-operation; every relocated action revalidates at its own gate; + // rows are store-first and the durable queue keeps them. const afk = afkPostureRecordPresent(state); const scope = scopeForUnreadWake(state, heartbeat, afk); // A newly-arrived main-owned (check-kind) row never bounces this @@ -1536,6 +1540,9 @@ ${context.command} wakeTaskScope = heartbeat || scope.checkSeqs.length > 0 || scope.heartbeatSeqs.length > 0 ? null : { rows: [...scope.eligibleSeqs], tasks: new Set(scope.eligibleTasks) }; + // Same residual: archive during snapshot publish or read-back still + // lets this prompt proceed; the guarded scripts revalidate, and the + // durable queue keeps every row (bin/fm-lease-lib.sh role-partition). const postureTail = afk ? await awayPostureTail() : ""; try { await session.prompt( @@ -1687,7 +1694,16 @@ ${context.command} const kept = messages.filter((message) => !isProcessingCustomMessage(message)); if (kept.length === messages.length) return; processing = null; - if (!kept.some((message) => message.role === "user")) ctx?.abort?.(); + let openedByUser = false; + for (let i = kept.length - 1; i >= 0; i -= 1) { + const role = kept[i].role; + if (role === "assistant") break; + if (role === "user") { + openedByUser = true; + break; + } + } + if (!openedByUser) ctx?.abort?.(); return { messages: kept }; }); pi.on?.("agent_end", () => { diff --git a/bin/fm-spawn.sh b/bin/fm-spawn.sh index e5a5c940bc9..3393e51e730 100755 --- a/bin/fm-spawn.sh +++ b/bin/fm-spawn.sh @@ -1397,15 +1397,20 @@ spawn_refuse_if_away_spend_cap if [ -x "${FM_TEST_SPAWN_AFTER_EARLY_CAP:-}" ]; then "$FM_TEST_SPAWN_AFTER_EARLY_CAP" fi -if [ "$RELAUNCH" -ne 1 ] && [ "$KIND" != secondmate ]; then - spawn_actor=$(fm_lease_actor) || exit "$FM_LEASE_REFUSE_EXIT" - if [ "$spawn_actor" = branch ] && fm_lease_away_relocated; then - if ! fm_backlog_row_probe "$DATA" "$ID" || ! fm_backlog_row_dispatchable "$FM_BACKLOG_ROW_STATE"; then - echo "error: spawn refused - the supervision branch under the away-posture record may dispatch only already-queued unblocked work; task $ID has no dispatchable backlog item in this home" >&2 - exit 1 - fi +spawn_require_relocated_queued_work() { + local actor + [ "$RELAUNCH" -ne 1 ] || return 0 + actor=$(fm_lease_actor) || exit "$FM_LEASE_REFUSE_EXIT" + [ "$actor" = branch ] || return 0 + if [ "$KIND" = secondmate ]; then + fm_lease_forbid_branch "new-task spawn (fm-spawn)" fi -fi + fm_lease_forbid_branch "new-task spawn (fm-spawn)" --away-relocated + if ! fm_backlog_row_probe "$DATA" "$ID" || ! fm_backlog_row_dispatchable "$FM_BACKLOG_ROW_STATE"; then + echo "error: spawn refused - the supervision branch under the away-posture record may dispatch only already-queued unblocked work; task $ID has no dispatchable backlog item in this home" >&2 + exit 1 + fi +} if [ "$RELAUNCH" -eq 1 ]; then SPAWN_CONTROL_LOCK="$STATE/.control-$ID.lock" control_owner=$(cat "$SPAWN_CONTROL_LOCK/pid" 2>/dev/null || true) @@ -1458,6 +1463,7 @@ if [ "$RELAUNCH" -eq 0 ]; then fi SPAWN_TASK_SET_LOCK_HELD=1 spawn_refuse_if_away_spend_cap + spawn_require_relocated_queued_work fi if [ "$KIND" = secondmate ]; then if spawn_remote_secondmate "$ID"; then diff --git a/tests/fm-branch-supervision.test.sh b/tests/fm-branch-supervision.test.sh index 74307393d56..f24dbb10dfc 100644 --- a/tests/fm-branch-supervision.test.sh +++ b/tests/fm-branch-supervision.test.sh @@ -957,6 +957,26 @@ EOF [ "$status" -ne 6 ] || fail "a queued branch spawn hit the partition: $out" assert_contains "$out" "main is parked" "the queued spawn lost its relocation note" + out=$(FM_HOME="$home" FM_ROOT_OVERRIDE="$root" FM_SUPERVISION_ACTOR=branch \ + "$ROOT/bin/fm-spawn.sh" mate-new --secondmate 2>&1) + status=$? + [ "$status" -eq 6 ] || fail "a branch secondmate spawn exited $status, not 6: $out" + assert_contains "$out" "the supervision branch never performs this action" "a branch secondmate spawn was not refused at the partition" + + hook="$home/archive-after-early" + cat > "$hook" </dev/null +HOOK + chmod +x "$hook" + out=$(FM_TEST_SPAWN_AFTER_EARLY_CAP="$hook" FM_HOME="$home" FM_ROOT_OVERRIDE="$root" \ + FM_SUPERVISION_ACTOR=branch "$ROOT/bin/fm-spawn.sh" task-queued --mode no-mistakes --yolo off 2>&1) + status=$? + [ "$status" -eq 6 ] || fail "an archived-after-early-guard spawn exited $status, not 6: $out" + assert_contains "$out" "the supervision branch never performs this action" \ + "archiving between the early guard and the gate did not restore the attended refusal" + out=$(FM_HOME="$home" FM_ROOT_OVERRIDE="$root" \ "$ROOT/bin/fm-spawn.sh" task-arbitrary --mode no-mistakes --yolo off 2>&1) assert_not_contains "$out" "already-queued unblocked work" "main's attended spawn was held to the branch queued-work gate" diff --git a/tests/fm-pi-branch-extension.test.sh b/tests/fm-pi-branch-extension.test.sh index b650850bb8b..bee9358ca9a 100644 --- a/tests/fm-pi-branch-extension.test.sh +++ b/tests/fm-pi-branch-extension.test.sh @@ -1736,11 +1736,26 @@ if (aborted) throw new Error("stripping a nextTurn processing message aborted th if (nextTurnResult?.messages?.some((message) => message.customType === "fm-branch-process")) { throw new Error(`nextTurn processing was not stripped: ${JSON.stringify(nextTurnResult)}`); } +const history = [ + { role: "user", content: "earlier captain request" }, + { role: "assistant", content: "earlier firstmate reply" }, +]; +aborted = false; +const openedByRequest = await fire("context", { messages: [...history, processingMsg] }, abortCtx); +if (!aborted) throw new Error("a dedicated processing turn with history was not aborted under the record"); +if (openedByRequest?.messages?.some((message) => message.customType === "fm-branch-process")) { + throw new Error(`dedicated processing with history was not stripped: ${JSON.stringify(openedByRequest)}`); +} aborted = false; -const idleResult = await fire("context", { messages: [processingMsg] }, abortCtx); -if (!aborted) throw new Error("a dedicated processing turn was not suppressed under the record"); -if (idleResult?.messages?.some((message) => message.customType === "fm-branch-process")) { - throw new Error(`dedicated processing was not stripped: ${JSON.stringify(idleResult)}`); +const openedByCaptain = await fire("context", { + messages: [...history, { role: "user", content: "current captain prompt" }, processingMsg], +}, abortCtx); +if (aborted) throw new Error("stripping processing aborted a captain-opened turn that had history"); +if (openedByCaptain?.messages?.some((message) => message.customType === "fm-branch-process")) { + throw new Error(`captain-opened processing was not stripped: ${JSON.stringify(openedByCaptain)}`); +} +if (!openedByCaptain?.messages?.some((message) => message.content === "current captain prompt")) { + throw new Error("captain-opened suppression dropped the current prompt"); } await fire("agent_end", {}); await fire("agent_settled", {}); From f257ebee79649f9d6d986a0ccadef6de3bded4d4 Mon Sep 17 00:00:00 2001 From: kunchenguid Date: Fri, 18 Sep 2026 17:25:12 -0700 Subject: [PATCH 06/11] no-mistakes(review): Stamp away-only on the dispatch offer --- .pi/extensions/fm-branch-supervision.ts | 23 +---------------------- .pi/extensions/fm-primary-pi-watch.ts | 8 ++++++-- .pi/extensions/lib/fm-branch-dispatch.ts | 4 ++++ tests/fm-pi-branch-extension.test.sh | 9 +++++---- 4 files changed, 16 insertions(+), 28 deletions(-) diff --git a/.pi/extensions/fm-branch-supervision.ts b/.pi/extensions/fm-branch-supervision.ts index de6d22972d7..323e727188d 100644 --- a/.pi/extensions/fm-branch-supervision.ts +++ b/.pi/extensions/fm-branch-supervision.ts @@ -235,27 +235,6 @@ function isProcessingCustomMessage(message: { role?: string; customType?: string return message.role === "custom" && message.customType === PROCESSING_MESSAGE_TYPE; } -function wakeAcceptedOnlyBecauseAway(message: string): boolean { - if (!afkPostureRecordPresent(state)) return false; - const heartbeat = /^heartbeat($|:)/.test(message); - if (/^check:/.test(message)) return true; - const attended = scopeForUnreadWake(state, heartbeat, false); - const triggerKeys = /^signal:/.test(message) - ? message - .slice("signal:".length) - .split(/\s+/) - .filter(Boolean) - .map((path) => path.split("/").pop() ?? path) - : /^stale:/.test(message) - ? [message.slice("stale:".length).trim().split(/\s+/, 1)[0]].filter(Boolean) - : []; - const taskIdentity = (key: string): string => - attended.taskByWakeKey[key] ?? attended.taskByWakeKey[key.replace(/^fm-/, "")] ?? key; - const needsDecisionTasks = new Set(attended.needsDecisionKeys.map(taskIdentity)); - if (triggerKeys.some((key) => needsDecisionTasks.has(taskIdentity(key)))) return true; - return !attended.eligible; -} - // Pi persists provider failures as ordinary assistant messages and resolves // AgentSession.prompt(), so promise rejection alone cannot detect them. Read // only the final assistant entry appended by this prompt: unlike the rebuilt @@ -1655,7 +1634,7 @@ ${context.command} if (branchBroken && !recoveryProbe) return; // main owns every wake inside the cooldown window if (!collectCurrentMainDialog()) return; if (recoveryProbe && providerRecovery) providerRecovery.probeInFlight = true; - offer.accept(enqueueWake(offer.message, generation, recoveryProbe, wakeAcceptedOnlyBecauseAway(offer.message))); + offer.accept(enqueueWake(offer.message, generation, recoveryProbe, offer.awayOnly === true)); }); // Pi awaits every extension event handler, so an awaited ownership read diff --git a/.pi/extensions/fm-primary-pi-watch.ts b/.pi/extensions/fm-primary-pi-watch.ts index 65ab2443e39..58e4841adbd 100644 --- a/.pi/extensions/fm-primary-pi-watch.ts +++ b/.pi/extensions/fm-primary-pi-watch.ts @@ -641,8 +641,12 @@ export default function (pi: ExtensionAPI) { 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 = afk ? scope.eligible : !isCheckTrigger && !isNeedsDecisionTrigger && scope.eligible; - const offer = createBranchDispatchOffer(message, scope.projects, heartbeat, eligible); + const attendedEligible = !isCheckTrigger && !isNeedsDecisionTrigger && ( + afk ? scopeForUnreadWake(state, heartbeat, false).eligible : scope.eligible + ); + const eligible = afk ? scope.eligible : attendedEligible; + const awayOnly = Boolean(eligible && !attendedEligible); + const offer = createBranchDispatchOffer(message, scope.projects, heartbeat, eligible, awayOnly); 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 69a098a71f4..f843926f3ff 100644 --- a/.pi/extensions/lib/fm-branch-dispatch.ts +++ b/.pi/extensions/lib/fm-branch-dispatch.ts @@ -484,6 +484,8 @@ export interface BranchDispatchOffer { heartbeat: boolean; /** True only when at least one currently unread row is safe for branch handling. */ eligible: boolean; + /** True when routing-time eligibility existed only because of the away collapse. */ + awayOnly: boolean; /** Set by accept(); read by the watcher after emit returns. */ accepted: boolean; settlement: Promise; @@ -495,12 +497,14 @@ export function createBranchDispatchOffer( projects: readonly string[] = [], heartbeat = false, eligible = false, + awayOnly = false, ): BranchDispatchOffer { const offer: BranchDispatchOffer = { message, projects: [...projects], heartbeat, eligible, + awayOnly, accepted: false, settlement: Promise.resolve(), accept(settlement = Promise.resolve()) { diff --git a/tests/fm-pi-branch-extension.test.sh b/tests/fm-pi-branch-extension.test.sh index bee9358ca9a..0b85063acdb 100644 --- a/tests/fm-pi-branch-extension.test.sh +++ b/tests/fm-pi-branch-extension.test.sh @@ -604,12 +604,13 @@ async function fire(event, payload, ctx) { for (const handler of piHandlers.get(event) ?? []) result = await handler(payload, eventCtx); return result; } -function makeOffer(message, projects = [approvedProject], heartbeat = false, eligible = projects.length > 0 || heartbeat) { +function makeOffer(message, projects = [approvedProject], heartbeat = false, eligible = projects.length > 0 || heartbeat, awayOnly = false) { const offer = { message, projects, heartbeat, eligible, + awayOnly, accepted: false, settlement: Promise.resolve(), accept(settlement = Promise.resolve()) { @@ -1863,13 +1864,13 @@ await fire("session_start", {}); contract(["propose"]); contract(["confirm"]); writeFileSync(`${home}/state/.wake-queue`, "1\t1\tcheck\tmain-only\tcheck: task-d.check.sh: PR merged\n"); -const offer = makeOffer("check: task-d.check.sh: PR merged", [], false, true); +contract(["archive"]); +const offer = makeOffer("check: task-d.check.sh: PR merged", [], false, true, true); bus.emit("fm-branch-supervision:dispatch", offer); if (!offer.accepted) throw new Error("the away check-only wake was refused at accept"); -contract(["archive"]); const failure = await offer.settlement.then(() => null, (error) => error); if (!(failure instanceof Error) || !failure.message.includes("no longer branch-eligible")) { - throw new Error(`an accepted away-only wake quiet-no-op'd after archive: ${String(failure)}`); + throw new Error(`an away-only wake archived before accept quiet-no-op'd: ${String(failure)}`); } if ((globalThis.__fmPrompts ?? []).length !== 0) { throw new Error(`the archived away-only wake still prompted the branch: ${JSON.stringify(globalThis.__fmPrompts)}`); From f0ac6a52c66e8d22470b60b287154794dd232df6 Mon Sep 17 00:00:00 2001 From: kunchenguid Date: Fri, 18 Sep 2026 17:28:47 -0700 Subject: [PATCH 07/11] no-mistakes(review): Treat invalid away records as spend-cap absence --- bin/fm-spawn.sh | 9 ++------- tests/fm-branch-supervision.test.sh | 3 +++ 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/bin/fm-spawn.sh b/bin/fm-spawn.sh index 3393e51e730..ebf0b1b3488 100755 --- a/bin/fm-spawn.sh +++ b/bin/fm-spawn.sh @@ -1366,13 +1366,8 @@ spawn_refuse_if_away_spend_cap() { [ "$RELAUNCH" -ne 1 ] || return 0 [ "$KIND" != secondmate ] || return 0 [ -f "$STATE/.afk-contract" ] || return 0 - cap=$(FM_STATE_OVERRIDE="$STATE" "$SCRIPT_DIR/fm-afk-contract.sh" field spend_max_concurrent_workers 2>/dev/null || true) - case "$cap" in - '' | *[!0-9]* | 0) - echo "error: spawn refused - the away-posture record at $STATE/.afk-contract has no readable spend cap; nothing was dispatched" >&2 - exit 1 - ;; - esac + FM_STATE_OVERRIDE="$STATE" "$SCRIPT_DIR/fm-afk-contract.sh" validate >/dev/null 2>&1 || return 0 + cap=$(FM_STATE_OVERRIDE="$STATE" "$SCRIPT_DIR/fm-afk-contract.sh" field spend_max_concurrent_workers) live=0 for meta in "$STATE"/*.meta; do [ -f "$meta" ] || continue diff --git a/tests/fm-branch-supervision.test.sh b/tests/fm-branch-supervision.test.sh index f24dbb10dfc..4239b2cd86e 100644 --- a/tests/fm-branch-supervision.test.sh +++ b/tests/fm-branch-supervision.test.sh @@ -922,6 +922,9 @@ test_away_record_relocates_main_owned_actions_to_the_branch() { status=$? [ "$status" -eq 6 ] || fail "an invalid record relocated the merge (exit $status): $out" assert_contains "$out" "$refusal" "the attended refusal changed under an invalid record" + out=$(FM_HOME="$home" FM_ROOT_OVERRIDE="$root" "$ROOT/bin/fm-spawn.sh" task-new --mode no-mistakes --yolo off 2>&1) + assert_not_contains "$out" "caps concurrent workers" "an invalid record refused a main spawn via the spend cap" + assert_not_contains "$out" "no readable spend cap" "an invalid record refused a main spawn for an unreadable cap" pass "the away-posture record relocates the PR merge and a spawn under the spend cap to the branch, never local landing, and only while confirmed and valid" } From b5ce046224e5ff1d1e8de281db11a149263d8edc Mon Sep 17 00:00:00 2001 From: kunchenguid Date: Fri, 18 Sep 2026 17:46:20 -0700 Subject: [PATCH 08/11] no-mistakes(review): Drop spawn test hook; abort processing-opened runs --- .pi/extensions/fm-branch-supervision.ts | 18 +++--- bin/fm-spawn.sh | 3 - tests/fm-branch-supervision.test.sh | 79 +++++++++++++++++-------- tests/fm-pi-branch-extension.test.sh | 34 ++++++----- 4 files changed, 80 insertions(+), 54 deletions(-) diff --git a/.pi/extensions/fm-branch-supervision.ts b/.pi/extensions/fm-branch-supervision.ts index 323e727188d..8372e2a3511 100644 --- a/.pi/extensions/fm-branch-supervision.ts +++ b/.pi/extensions/fm-branch-supervision.ts @@ -656,6 +656,8 @@ export default function (pi: ExtensionAPI) { // session generation. type ProcessingState = { sequences: string; through: number; triggered: number; pending: boolean; nextTurnQueued: boolean }; let processing: ProcessingState | null = null; + let queuedTriggeringProcessing = false; + let processingOpenedThisRun = false; let processedInitializedGeneration = -1; // One revision for BOTH selections: a model or effort change invalidates an // in-flight branch build exactly the same way. @@ -1090,6 +1092,7 @@ export default function (pi: ExtensionAPI) { if (processing.triggered < PROCESSING_TRIGGERED_ATTEMPTS) { processing.triggered += 1; processing.pending = true; + queuedTriggeringProcessing = true; pi.sendMessage(message, { triggerTurn: true, deliverAs: "followUp" }); } else if (!processing.nextTurnQueued) { processing.nextTurnQueued = true; @@ -1666,6 +1669,8 @@ ${context.command} // Pi delivers a queued nextTurn copy with the prompt that starts this run, // so a fresh copy may be queued again once this run settles unacknowledged. if (processing) processing.nextTurnQueued = false; + processingOpenedThisRun = queuedTriggeringProcessing; + queuedTriggeringProcessing = false; }); pi.on?.("context", (event, ctx) => { if (!afkPostureRecordPresent(state)) return; @@ -1673,16 +1678,7 @@ ${context.command} const kept = messages.filter((message) => !isProcessingCustomMessage(message)); if (kept.length === messages.length) return; processing = null; - let openedByUser = false; - for (let i = kept.length - 1; i >= 0; i -= 1) { - const role = kept[i].role; - if (role === "assistant") break; - if (role === "user") { - openedByUser = true; - break; - } - } - if (!openedByUser) ctx?.abort?.(); + if (processingOpenedThisRun) ctx?.abort?.(); return { messages: kept }; }); pi.on?.("agent_end", () => { @@ -1696,6 +1692,8 @@ ${context.command} // reply that only paraphrased it - and is presented again. pi.on?.("agent_settled", async () => { mainStreaming = false; + queuedTriggeringProcessing = false; + processingOpenedThisRun = false; if (processing) processing.pending = false; const settledGeneration = generation; await enqueueDelivery(async () => { diff --git a/bin/fm-spawn.sh b/bin/fm-spawn.sh index ebf0b1b3488..8842ed47d3d 100755 --- a/bin/fm-spawn.sh +++ b/bin/fm-spawn.sh @@ -1389,9 +1389,6 @@ spawn_refuse_if_away_spend_cap() { # costs nothing to unwind; rechecked after the task-set lock so two fresh # spawns cannot both publish from a stale count. spawn_refuse_if_away_spend_cap -if [ -x "${FM_TEST_SPAWN_AFTER_EARLY_CAP:-}" ]; then - "$FM_TEST_SPAWN_AFTER_EARLY_CAP" -fi spawn_require_relocated_queued_work() { local actor [ "$RELAUNCH" -ne 1 ] || return 0 diff --git a/tests/fm-branch-supervision.test.sh b/tests/fm-branch-supervision.test.sh index 4239b2cd86e..dde0ce9eb8d 100644 --- a/tests/fm-branch-supervision.test.sh +++ b/tests/fm-branch-supervision.test.sh @@ -966,15 +966,31 @@ EOF [ "$status" -eq 6 ] || fail "a branch secondmate spawn exited $status, not 6: $out" assert_contains "$out" "the supervision branch never performs this action" "a branch secondmate spawn was not refused at the partition" - hook="$home/archive-after-early" - cat > "$hook" < "$root/bin/fm-afk-contract.sh" </dev/null -HOOK - chmod +x "$hook" - out=$(FM_TEST_SPAWN_AFTER_EARLY_CAP="$hook" FM_HOME="$home" FM_ROOT_OVERRIDE="$root" \ - FM_SUPERVISION_ACTOR=branch "$ROOT/bin/fm-spawn.sh" task-queued --mode no-mistakes --yolo off 2>&1) +REAL="$ROOT/bin/fm-afk-contract.sh" +COUNT="$home/contract-validate-count" +if [ "\${1:-}" = validate ]; then + n=0 + [ -f "\$COUNT" ] && n=\$(cat "\$COUNT") + n=\$((n + 1)) + printf '%s\n' "\$n" > "\$COUNT" + if [ "\$n" -eq 2 ]; then + "\$REAL" archive >/dev/null + fi +fi +exec "\$REAL" "\$@" +WRAPPER + chmod +x "$root/bin/fm-afk-contract.sh" + out=$(FM_HOME="$home" FM_ROOT_OVERRIDE="$root" FM_SUPERVISION_ACTOR=branch \ + "$root/bin/fm-spawn.sh" task-queued --mode no-mistakes --yolo off 2>&1) status=$? [ "$status" -eq 6 ] || fail "an archived-after-early-guard spawn exited $status, not 6: $out" assert_contains "$out" "the supervision branch never performs this action" \ @@ -987,33 +1003,44 @@ HOOK } test_away_spend_cap_is_rechecked_under_the_task_set_lock() { - local home root out hook i + local home root out i home="$TMP_ROOT/away-cap-lock-home" root="$TMP_ROOT/away-cap-lock-root" - mkdir -p "$home/state" "$home/data" "$home/config" "$root" + mkdir -p "$home/state" "$home/data" "$home/config" "$root/bin" git init -q -b main "$root" git -C "$root" commit -q --allow-empty -m init - ln -s "$ROOT/bin" "$root/bin" + for f in "$ROOT/bin"/*; do + ln -s "$f" "$root/bin/${f##*/}" + done + rm -f "$root/bin/fm-afk-contract.sh" + cat > "$root/bin/fm-afk-contract.sh" < "\$COUNT" + if [ "\$n" -eq 1 ]; then + : > "$home/early-cap-passed" + i=0 + while [ ! -f "$home/competitor-published" ]; do + i=\$((i + 1)) + [ "\$i" -lt 200 ] || exit 1 + sleep 0.05 + done + fi +fi +exec "\$REAL" "\$@" +WRAPPER + chmod +x "$root/bin/fm-afk-contract.sh" FM_HOME="$home" "$ROOT/bin/fm-afk-contract.sh" propose --spend 1 >/dev/null || fail "away propose failed" FM_HOME="$home" "$ROOT/bin/fm-afk-contract.sh" confirm >/dev/null || fail "away confirm failed" - hook="$home/after-early-cap" - cat > "$hook" < "$home/early-cap-passed" -i=0 -while [ ! -f "$home/competitor-published" ]; do - i=\$((i + 1)) - [ "\$i" -lt 200 ] || exit 1 - sleep 0.05 -done -HOOK - chmod +x "$hook" - - FM_TEST_SPAWN_AFTER_EARLY_CAP="$hook" \ FM_HOME="$home" FM_ROOT_OVERRIDE="$root" \ - "$ROOT/bin/fm-spawn.sh" task-q1 --mode no-mistakes --yolo off \ + "$root/bin/fm-spawn.sh" task-q1 --mode no-mistakes --yolo off \ > "$home/q1.out" 2>&1 & i=0 while [ ! -f "$home/early-cap-passed" ]; do diff --git a/tests/fm-pi-branch-extension.test.sh b/tests/fm-pi-branch-extension.test.sh index 0b85063acdb..839f21e2cfd 100644 --- a/tests/fm-pi-branch-extension.test.sh +++ b/tests/fm-pi-branch-extension.test.sh @@ -1719,15 +1719,20 @@ contract(["confirm"]); const processingMsg = { role: "custom", customType: pending.message.customType, content: pending.message.content, display: false }; let aborted = false; const abortCtx = { ...defaultSessionCtx, abort() { aborted = true; } }; -const followUpResult = await fire("context", { - messages: [{ role: "user", content: "captain still in this turn" }, processingMsg], +const streamingResult = await fire("context", { + messages: [ + { role: "user", content: "captain still in this turn" }, + { role: "assistant", content: [{ type: "toolCall", id: "t1" }] }, + { role: "toolResult", toolCallId: "t1", content: "tool finished" }, + processingMsg, + ], }, abortCtx); -if (aborted) throw new Error("stripping a followUp processing message aborted the streaming captain turn"); -if (followUpResult?.messages?.some((message) => message.customType === "fm-branch-process")) { - throw new Error(`followUp processing was not stripped: ${JSON.stringify(followUpResult)}`); +if (aborted) throw new Error("stripping processing aborted a captain-opened streaming turn after a tool call"); +if (streamingResult?.messages?.some((message) => message.customType === "fm-branch-process")) { + throw new Error(`streaming processing was not stripped: ${JSON.stringify(streamingResult)}`); } -if (!followUpResult?.messages?.some((message) => message.role === "user")) { - throw new Error("followUp suppression dropped the captain turn"); +if (!streamingResult?.messages?.some((message) => message.role === "user")) { + throw new Error("streaming suppression dropped the captain turn"); } aborted = false; const nextTurnResult = await fire("context", { @@ -1742,12 +1747,6 @@ const history = [ { role: "assistant", content: "earlier firstmate reply" }, ]; aborted = false; -const openedByRequest = await fire("context", { messages: [...history, processingMsg] }, abortCtx); -if (!aborted) throw new Error("a dedicated processing turn with history was not aborted under the record"); -if (openedByRequest?.messages?.some((message) => message.customType === "fm-branch-process")) { - throw new Error(`dedicated processing with history was not stripped: ${JSON.stringify(openedByRequest)}`); -} -aborted = false; const openedByCaptain = await fire("context", { messages: [...history, { role: "user", content: "current captain prompt" }, processingMsg], }, abortCtx); @@ -1755,8 +1754,13 @@ if (aborted) throw new Error("stripping processing aborted a captain-opened turn if (openedByCaptain?.messages?.some((message) => message.customType === "fm-branch-process")) { throw new Error(`captain-opened processing was not stripped: ${JSON.stringify(openedByCaptain)}`); } -if (!openedByCaptain?.messages?.some((message) => message.content === "current captain prompt")) { - throw new Error("captain-opened suppression dropped the current prompt"); +await fire("agent_end", {}); +await fire("agent_start", {}, defaultSessionCtx); +aborted = false; +const openedByRequest = await fire("context", { messages: [...history, processingMsg] }, abortCtx); +if (!aborted) throw new Error("a dedicated processing turn with history was not aborted under the record"); +if (openedByRequest?.messages?.some((message) => message.customType === "fm-branch-process")) { + throw new Error(`dedicated processing with history was not stripped: ${JSON.stringify(openedByRequest)}`); } await fire("agent_end", {}); await fire("agent_settled", {}); From dc9cd974e630fbf5472e6ccb1308645a761f80b5 Mon Sep 17 00:00:00 2001 From: kunchenguid Date: Fri, 18 Sep 2026 17:55:43 -0700 Subject: [PATCH 09/11] no-mistakes(review): Bind abort to opening prompt; cap-read absence --- .pi/extensions/fm-branch-supervision.ts | 19 ++++++++--------- bin/fm-spawn.sh | 5 ++++- tests/fm-branch-supervision.test.sh | 27 +++++++++++++++++++++++++ tests/fm-pi-branch-extension.test.sh | 12 ++++++++++- 4 files changed, 52 insertions(+), 11 deletions(-) diff --git a/.pi/extensions/fm-branch-supervision.ts b/.pi/extensions/fm-branch-supervision.ts index 8372e2a3511..a56d064ca6f 100644 --- a/.pi/extensions/fm-branch-supervision.ts +++ b/.pi/extensions/fm-branch-supervision.ts @@ -656,7 +656,7 @@ export default function (pi: ExtensionAPI) { // session generation. type ProcessingState = { sequences: string; through: number; triggered: number; pending: boolean; nextTurnQueued: boolean }; let processing: ProcessingState | null = null; - let queuedTriggeringProcessing = false; + let queuedProcessingContent: string | null = null; let processingOpenedThisRun = false; let processedInitializedGeneration = -1; // One revision for BOTH selections: a model or effort change invalidates an @@ -1092,7 +1092,7 @@ export default function (pi: ExtensionAPI) { if (processing.triggered < PROCESSING_TRIGGERED_ATTEMPTS) { processing.triggered += 1; processing.pending = true; - queuedTriggeringProcessing = true; + queuedProcessingContent = content; pi.sendMessage(message, { triggerTurn: true, deliverAs: "followUp" }); } else if (!processing.nextTurnQueued) { processing.nextTurnQueued = true; @@ -1656,12 +1656,15 @@ ${context.command} // getEntries() here loses the captain request that the next wake may answer. // Stage it verbatim and remember the future persisted index for turn_end's // duplicate suppression. Operational extension injections are not dialog. - const prompt = event.prompt.trim(); - if (!prompt || isOperationalUserText(prompt)) return; + const prompt = event.prompt; + processingOpenedThisRun = queuedProcessingContent !== null && prompt === queuedProcessingContent; + if (processingOpenedThisRun) queuedProcessingContent = null; + const trimmed = prompt.trim(); + if (!trimmed || isOperationalUserText(trimmed)) return; const file = currentMainSession.getSessionFile() ?? ""; const index = mirrorCollection.collectAnchor?.index ?? currentMainSession.getEntries().length; - pendingMirror.push({ tag: "captain", text: prompt }); - mirrorCollection.stagedCaptain = { file, index, text: prompt }; + pendingMirror.push({ tag: "captain", text: trimmed }); + mirrorCollection.stagedCaptain = { file, index, text: trimmed }; }); pi.on?.("agent_start", () => { @@ -1669,8 +1672,6 @@ ${context.command} // Pi delivers a queued nextTurn copy with the prompt that starts this run, // so a fresh copy may be queued again once this run settles unacknowledged. if (processing) processing.nextTurnQueued = false; - processingOpenedThisRun = queuedTriggeringProcessing; - queuedTriggeringProcessing = false; }); pi.on?.("context", (event, ctx) => { if (!afkPostureRecordPresent(state)) return; @@ -1692,7 +1693,7 @@ ${context.command} // reply that only paraphrased it - and is presented again. pi.on?.("agent_settled", async () => { mainStreaming = false; - queuedTriggeringProcessing = false; + queuedProcessingContent = null; processingOpenedThisRun = false; if (processing) processing.pending = false; const settledGeneration = generation; diff --git a/bin/fm-spawn.sh b/bin/fm-spawn.sh index 8842ed47d3d..ec06c07c772 100755 --- a/bin/fm-spawn.sh +++ b/bin/fm-spawn.sh @@ -1367,7 +1367,10 @@ spawn_refuse_if_away_spend_cap() { [ "$KIND" != secondmate ] || return 0 [ -f "$STATE/.afk-contract" ] || return 0 FM_STATE_OVERRIDE="$STATE" "$SCRIPT_DIR/fm-afk-contract.sh" validate >/dev/null 2>&1 || return 0 - cap=$(FM_STATE_OVERRIDE="$STATE" "$SCRIPT_DIR/fm-afk-contract.sh" field spend_max_concurrent_workers) + cap=$(FM_STATE_OVERRIDE="$STATE" "$SCRIPT_DIR/fm-afk-contract.sh" field spend_max_concurrent_workers 2>/dev/null || true) + case "$cap" in + '' | *[!0-9]* | 0) return 0 ;; + esac live=0 for meta in "$STATE"/*.meta; do [ -f "$meta" ] || continue diff --git a/tests/fm-branch-supervision.test.sh b/tests/fm-branch-supervision.test.sh index dde0ce9eb8d..b828308db86 100644 --- a/tests/fm-branch-supervision.test.sh +++ b/tests/fm-branch-supervision.test.sh @@ -907,6 +907,33 @@ test_away_record_relocates_main_owned_actions_to_the_branch() { [ "$status" -eq 1 ] || fail "main spawn past the cap exited $status, not 1: $out" assert_contains "$out" "caps concurrent workers" "main was not held to the spend cap" + rm -f "$root/bin" + mkdir -p "$root/bin" + for f in "$ROOT/bin"/*; do + ln -s "$f" "$root/bin/${f##*/}" + done + rm -f "$root/bin/fm-afk-contract.sh" + cat > "$root/bin/fm-afk-contract.sh" < "\$COUNT" +if [ "\$n" -eq 2 ]; then + "\$REAL" archive >/dev/null +fi +exec "\$REAL" "\$@" +WRAPPER + chmod +x "$root/bin/fm-afk-contract.sh" + out=$(FM_HOME="$home" FM_ROOT_OVERRIDE="$root" "$root/bin/fm-spawn.sh" task-new --mode no-mistakes --yolo off 2>&1) || true + assert_not_contains "$out" "caps concurrent workers" "a field-read after archive refused a main spawn via the spend cap" + assert_not_contains "$out" "no readable spend cap" "a field-read after archive killed the spawn instead of restoring attended behavior" + FM_HOME="$home" "$ROOT/bin/fm-afk-contract.sh" propose --spend 2 >/dev/null || fail "away re-propose failed" + FM_HOME="$home" "$ROOT/bin/fm-afk-contract.sh" confirm >/dev/null || fail "away re-confirm failed" + # Archive is absence: the attended refusal returns, byte for byte. FM_HOME="$home" "$ROOT/bin/fm-afk-contract.sh" archive >/dev/null || fail "away archive failed" out=$(FM_HOME="$home" FM_SUPERVISION_ACTOR=branch "$ROOT/bin/fm-pr-merge.sh" task-x https://github.com/o/r/pull/1 2>&1) diff --git a/tests/fm-pi-branch-extension.test.sh b/tests/fm-pi-branch-extension.test.sh index 839f21e2cfd..06ccf8eb93c 100644 --- a/tests/fm-pi-branch-extension.test.sh +++ b/tests/fm-pi-branch-extension.test.sh @@ -1754,9 +1754,19 @@ if (aborted) throw new Error("stripping processing aborted a captain-opened turn if (openedByCaptain?.messages?.some((message) => message.customType === "fm-branch-process")) { throw new Error(`captain-opened processing was not stripped: ${JSON.stringify(openedByCaptain)}`); } +aborted = false; +await fire("before_agent_start", { prompt: "captain typed this now" }, abortCtx); +const stolen = await fire("context", { + messages: [{ role: "user", content: "captain typed this now" }, processingMsg], +}, abortCtx); +if (aborted) throw new Error("a captain prompt that opened the run was aborted after a queued processing request joined it"); +if (stolen?.messages?.some((message) => message.customType === "fm-branch-process")) { + throw new Error(`joined processing was not stripped from the captain-opened run: ${JSON.stringify(stolen)}`); +} await fire("agent_end", {}); -await fire("agent_start", {}, defaultSessionCtx); aborted = false; +await fire("before_agent_start", { prompt: pending.message.content }, abortCtx); +await fire("agent_start", {}, defaultSessionCtx); const openedByRequest = await fire("context", { messages: [...history, processingMsg] }, abortCtx); if (!aborted) throw new Error("a dedicated processing turn with history was not aborted under the record"); if (openedByRequest?.messages?.some((message) => message.customType === "fm-branch-process")) { From c2f5edf81f3db68a6be5f17e0b8dadde35eb03af Mon Sep 17 00:00:00 2001 From: kunchenguid Date: Fri, 18 Sep 2026 18:03:08 -0700 Subject: [PATCH 10/11] no-mistakes(review): Limit away branch spawn to queued work only --- bin/fm-spawn.sh | 10 ++++++++-- tests/fm-branch-supervision.test.sh | 9 +++++++++ 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/bin/fm-spawn.sh b/bin/fm-spawn.sh index ec06c07c772..f3989aeaa9a 100755 --- a/bin/fm-spawn.sh +++ b/bin/fm-spawn.sh @@ -1401,7 +1401,7 @@ spawn_require_relocated_queued_work() { fm_lease_forbid_branch "new-task spawn (fm-spawn)" fi fm_lease_forbid_branch "new-task spawn (fm-spawn)" --away-relocated - if ! fm_backlog_row_probe "$DATA" "$ID" || ! fm_backlog_row_dispatchable "$FM_BACKLOG_ROW_STATE"; then + if ! fm_backlog_row_probe "$DATA" "$ID" || [ "$FM_BACKLOG_ROW_STATE" != "queued no no" ]; then echo "error: spawn refused - the supervision branch under the away-posture record may dispatch only already-queued unblocked work; task $ID has no dispatchable backlog item in this home" >&2 exit 1 fi @@ -3003,7 +3003,13 @@ if fm_backlog_transition_applies "$CONFIG" "$DATA" "$KIND"; then echo "error: task $ID's backlog item could not be read before dispatch ($FM_BACKLOG_ROW_ERROR)" >&2 exit 1 fi - if ! fm_backlog_row_dispatchable "$BACKLOG_ROW_STATE"; then + spawn_preflight_actor=$(fm_lease_actor) || exit "$FM_LEASE_REFUSE_EXIT" + if [ "$spawn_preflight_actor" = branch ] && fm_lease_away_relocated; then + if [ "$BACKLOG_ROW_STATE" != "queued no no" ]; then + echo "error: spawn refused - the supervision branch under the away-posture record may dispatch only already-queued unblocked work; task $ID has no dispatchable backlog item in this home" >&2 + exit 1 + fi + elif ! fm_backlog_row_dispatchable "$BACKLOG_ROW_STATE"; then echo "error: this home's backlog item $ID is not dispatchable in state $BACKLOG_ROW_STATE; refusing before creating its endpoint or local copy" >&2 exit 1 fi diff --git a/tests/fm-branch-supervision.test.sh b/tests/fm-branch-supervision.test.sh index b828308db86..6dd79583271 100644 --- a/tests/fm-branch-supervision.test.sh +++ b/tests/fm-branch-supervision.test.sh @@ -966,6 +966,9 @@ test_away_branch_spawn_requires_queued_dispatchable_work() { cp "$ROOT/.tasks.toml" "$home/.tasks.toml" printf 'manual\n' > "$home/config/backlog-backend" cat > "$home/data/backlog.md" <<'EOF' +## In flight +- [ ] task-inflight - orphaned in-flight work + ## Queued - [ ] task-queued - already queued work @@ -987,6 +990,12 @@ EOF [ "$status" -ne 6 ] || fail "a queued branch spawn hit the partition: $out" assert_contains "$out" "main is parked" "the queued spawn lost its relocation note" + out=$(FM_HOME="$home" FM_ROOT_OVERRIDE="$root" FM_SUPERVISION_ACTOR=branch \ + "$ROOT/bin/fm-spawn.sh" task-inflight --mode no-mistakes --yolo off 2>&1) + status=$? + [ "$status" -eq 1 ] || fail "an in-flight branch spawn exited $status, not 1: $out" + assert_contains "$out" "already-queued unblocked work" "an in-flight row was dispatched by the away branch" + out=$(FM_HOME="$home" FM_ROOT_OVERRIDE="$root" FM_SUPERVISION_ACTOR=branch \ "$ROOT/bin/fm-spawn.sh" mate-new --secondmate 2>&1) status=$? From e4a43d3756eb2b3152b57f30edfe631a33c54e46 Mon Sep 17 00:00:00 2001 From: kunchenguid Date: Fri, 18 Sep 2026 18:24:18 -0700 Subject: [PATCH 11/11] no-mistakes(document): Correct AFK posture documentation --- .agents/skills/afk/SKILL.md | 8 ++++---- AGENTS.md | 2 +- bin/fm-afk-return.sh | 6 +++--- bin/fm-branch-prompt.sh | 3 +-- docs/configuration.md | 2 +- docs/pi-supervision-branch.md | 4 ++-- docs/supervision-protocols/pi.md | 2 +- docs/verification/runtime-backends.md | 6 +++++- 8 files changed, 18 insertions(+), 15 deletions(-) diff --git a/.agents/skills/afk/SKILL.md b/.agents/skills/afk/SKILL.md index 4504164ce77..a80d3611476 100644 --- a/.agents/skills/afk/SKILL.md +++ b/.agents/skills/afk/SKILL.md @@ -2,7 +2,7 @@ name: afk description: >- Enter the away posture when the captain invokes /afk, says they are going afk, `state/.afk-contract` or `state/.afk` exists, an incoming message starts with `FM_INJECT_MARK`, or any `state/.subsuper-*` marker is involved. - It reads the captain's away words back as a mandate, writes the durable away-posture record after their go, announces hold-for-return only at entry, keeps the one supervision session running in the away posture (on Pi the supervision branch takes every wake with main parked; the daemon still delivers batched digests on the other harnesses for now), and on the first unmarked message renders the return brief from durable records before ordinary work resumes. + It reads the captain's away words back as a mandate, writes the durable away-posture record after their go, announces hold-for-return only at entry, keeps the one supervision session running in the away posture (on Pi the supervision branch takes every safe actionable wake with main parked; the daemon still delivers batched digests on the other harnesses for now), and on the first unmarked message renders the return brief from durable records before ordinary work resumes. user-invocable: true metadata: internal: true @@ -41,7 +41,7 @@ Hold-for-return is the default and the only reach profile this release records: 4. **Per harness, after the record exists:** - **Pi and pi-signed**: stop here. The away daemon is no longer launched on Pi; the ordinary supervision session (`docs/pi-supervision-branch.md`) keeps running with the record present, and `bin/fm-afk-launch.sh start` refuses on these harnesses. - With the record present main is parked: the supervision branch takes every wake, captain outcomes accumulate for the return brief, and main's standing authority relocates to the branch through the guarded scripts (`docs/pi-supervision-branch.md` "Postures"); main still wakes only for a broken branch or a watcher failure. + With the record present main is parked: the supervision branch takes every safe actionable wake, captain outcomes accumulate for the return brief, and main's standing authority relocates to the branch through the guarded scripts (`docs/pi-supervision-branch.md` "Postures"); only a wake the branch declines (including a broken branch or unsafe scan) or a watcher failure wakes main. `/quiet` needs nothing extra on Pi: the attended branch already keeps routine wakes out of this conversation, so quiet-while-present is the attended posture's own shape there. - **Harness WITH a native in-pane tracked-background tool** (claude's background bash, grok's background tool): run `bin/fm-afk-launch.sh start-native`, then run `FM_AFK_STATE_PREPARED=1 bin/fm-afk-start.sh` through that native tool. This is a deliberate no-separate-terminal exception because the harness-hosted job creates no terminal or layout mutation, and a shell launcher cannot invoke a harness-native background tool. @@ -60,8 +60,8 @@ Hold-for-return is the default and the only reach profile this release records: Declared external waits keep their condition-aware, hours-long recheck cadence (`bin/fm-watch.sh`, `bin/fm-classify-lib.sh`). - Recorded clauses are not executed by this release. Forbidden, destructive, irreversible, and security-sensitive actions are never pre-authorizable regardless of clause text, no recorded clause is authority by itself, and merge authority plus ask-user findings keep exactly the rules they have when attended (`AGENTS.md` section 7 and `ask-user-authority`); anything that needs the captain holds for their return. -- On Pi, main is parked and the supervision branch handles every wake under main's standing authority plus the record's merge grants, through the same guarded scripts main would use: a granted or `yolo` task merges only green at its live head, already-queued work whose blockers cleared dispatches within the spend cap, and only a finding `ask-user-authority` lets firstmate decide is answered. - Anything else holds for the return, local-only landing always waits for the captain, and main still wakes only for a broken branch or a watcher failure (`docs/pi-supervision-branch.md` "Postures"). +- On Pi, main is parked and the supervision branch handles every safe actionable wake under main's standing authority plus the record's merge grants, through the same guarded scripts main would use: a granted or `yolo` task merges only green at its live head, already-queued work whose blockers cleared dispatches within the spend cap, and only a finding `ask-user-authority` lets firstmate decide is answered. + Anything else holds for the return, local-only landing always waits for the captain, and only a wake the branch declines (including a broken branch or unsafe scan) or a watcher failure wakes main (`docs/pi-supervision-branch.md` "Postures"). - The session-start digest reports the posture under its AFK subsection, so a restart re-enters the posture from the record, not from memory. ## How to exit: the return diff --git a/AGENTS.md b/AGENTS.md index 3d569abd937..c3634216137 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -462,7 +462,7 @@ Each skill owns its own daemon procedure, which is otherwise identical; these sa - Every current daemon injection uses the `away-supervisor` kind from `bin/fm-operational-input.sh` after `FM_OPERATIONAL_PREFIX` (U+2063 INVISIBLE SEPARATOR followed by `FIRSTMATE_OP: `), while the `/afk` skill owns legacy bare-marker compatibility. - `state/.afk-contract` is the away posture, written only after the captain confirms the read-back of their away words; entry announces hold-for-return only, and the record's clauses are recorded, not executed, in this release. - While `state/.afk` exists, the daemon owns supervision; do not arm a separate watcher. - The daemon is never launched on Pi, where the ordinary supervision session continues under the record with main parked: the branch takes every wake it can, and main still wakes only for a broken branch or a watcher failure. + The daemon is never launched on Pi, where the ordinary supervision session continues under the record with main parked: the branch takes every safe actionable wake it can, and only a declined wake (including a broken branch or unsafe scan) or a watcher failure wakes main. - A marked message while away or quiet mode is active is internal escalation and does not exit that mode. - A message beginning `/afk` refreshes away mode; a message beginning `/quiet` refreshes quiet mode. - Any other unmarked message means the captain returned in away mode (load `/afk`, run the return owner, and do not process that message as ordinary work until its durable catch-up gate clears), or, in quiet mode, is simply answered as ordinary work with the flag and daemon left untouched until an explicit `/quiet off`. diff --git a/bin/fm-afk-return.sh b/bin/fm-afk-return.sh index e9cab8e786e..3b38defc916 100755 --- a/bin/fm-afk-return.sh +++ b/bin/fm-afk-return.sh @@ -506,9 +506,9 @@ EOF [ "$count" -gt 0 ] || printf ' (nothing)\n' # 5. handled while away. Every outcome the away session recorded in the - # store during the window counts as handled: on Pi the supervision branch - # took every wake with main parked, so this total is what supervision did - # with nobody watching (the captain rows themselves are listed above). + # store during the window counts as handled. On Pi the supervision branch + # took every safe actionable wake it could while main was parked; wakes it + # declined still fell back to main. The captain rows are listed above. printf 'Handled while away:\n' routine=$(printf '%s\n' "$STORE_ROWS" | awk -F '\t' '$3 == "routine" { n++ } END { print n + 0 }') captain=$(printf '%s\n' "$STORE_ROWS" | awk -F '\t' '$3 == "captain" { n++ } END { print n + 0 }') diff --git a/bin/fm-branch-prompt.sh b/bin/fm-branch-prompt.sh index 03d36a13a7b..4bc5d883e4b 100755 --- a/bin/fm-branch-prompt.sh +++ b/bin/fm-branch-prompt.sh @@ -98,7 +98,7 @@ The Postures section below is the one, bounded exception to the first three limi You run in one of two postures, and the posture is a file: the away-posture record `state/.afk-contract`, written only by `bin/fm-afk-contract.sh` after the captain confirmed its read-back and archived by the return path on the captain's first ordinary message. Attended (no record): the role limits above apply exactly as written, main-owned rows never reach you, and MAIN processes every captain outcome you report. -Away (the record exists): the wake message ends with a `POSTURE: AWAY` tail carrying the record's read-back verbatim; MAIN is parked, you take every row including check rows, decision rows, and heartbeat rows, and no outcome reaches the captain until the return brief. +Away (the record exists): the wake message ends with a `POSTURE: AWAY` tail carrying the record's read-back verbatim; MAIN is parked, you take every row including check rows, decision rows, and heartbeat rows, and captain outcomes remain unprocessed for the return brief even though their visible transcript entries persist. Under that tail MAIN's standing authority - never more than MAIN could do attended - is relocated to you, and only through the guarded scripts, which enforce it themselves: - `bin/fm-pr-merge.sh` merges only a task the record grants or whose recorded yolo posture is on, only green at its live head, only synchronously; a red pull request is never merged while away, whatever the captain's words or a clause say, and `--allow-red` is refused under the record. - `bin/fm-spawn.sh` dispatches only work already queued in the backlog whose blockers and time gates have cleared, and refuses past the record's spend cap; never invent work. @@ -108,7 +108,6 @@ Hold on doubt: a fork no standing rule covers is reported with verdict captain a The never-set is absolute for every actor in every posture: credential entry, legal or financial acceptance, an attended prompt, any discard the captain did not name, and any destructive, irreversible, or security-sensitive action are refused whatever a clause says. A recorded clause is a fact for the return brief, not authority: this release records clauses and does not execute them, so act only on standing authority and the record's explicit merge grants. A mirrored captain sentence authorizes nothing new once the record exists; only the record and the standing rules do. -Each summary of a guarded action is its audit trail: say which authority it ran under (a named merge grant, the task's yolo posture, standing dispatch of queued work, or a decided finding). # Discipline diff --git a/docs/configuration.md b/docs/configuration.md index 982e3ff7741..cdc36b1c92a 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -47,7 +47,7 @@ While the away-posture record `state/.afk-contract` exists the branch takes ever While attended the branch's role stays bounded exactly as the captain-approved architecture set it: it cannot merge a PR, land local work, freshly spawn, or answer a decision, and every existing captain gate remains unchanged in either posture. Homes on any other primary harness never load this feature and are entirely unaffected. `AGENTS.md`'s `state/` inventory routes the branch's runtime files to their format and lifecycle owners. -A captain-facing (verdict `captain`) branch outcome persists as one exact, sequence-keyed visible transcript entry and then opens one sequence-keyed processing turn on main, which stays open until main acknowledges that sequence through its `fm_branch_processed` tool. +While attended, a captain-facing (verdict `captain`) branch outcome persists as one exact, sequence-keyed visible transcript entry and then opens one sequence-keyed processing turn on main, which stays open until main acknowledges that sequence through its `fm_branch_processed` tool; while away, the entry persists but processing waits until the record is archived. The branch prompt's "Verdict: routine or captain" section owns the distinction between captain-facing, unsolicited routine, and unchanged-review outcomes. The generated [Pi supervision protocol](supervision-protocols/pi.md) owns main's event ownership, acknowledgement duty, and conversational treatment for merged outcomes, while the persisted entry itself owns captain visibility. 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 still appends a rendered, sailboat-prefixed note. diff --git a/docs/pi-supervision-branch.md b/docs/pi-supervision-branch.md index 6d9c27f6e9c..97b354a56dc 100644 --- a/docs/pi-supervision-branch.md +++ b/docs/pi-supervision-branch.md @@ -10,7 +10,7 @@ Supervision is default-on: once a Pi primary session owns this home's fleet lock 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. All of that describes the attended posture; the away posture, recorded by `state/.afk-contract`, hands every row to the branch and parks main (see "Postures" below). -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. +While attended, 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; while away, the entries persist but processing waits until the record is archived. 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. The supervision branch itself is Pi-only by construction: @@ -180,7 +180,7 @@ The never-set (credential entry, legal or financial acceptance, an attended prom ## 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 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, non-branch-home invariance, and the away relocation (only under a confirmed live record, never for local-only landing, the spend cap for both actors, and the attended refusal restored by archive or an invalid record). +`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, non-branch-home invariance, and the away relocation (only under a confirmed live record, never for local-only landing, queued-only branch dispatch rather than orphaned in-flight recovery, the spend cap for both actors and its lock-held recheck, and the attended guarded-action behavior restored by archive or an invalid record). `tests/fm-pr-merge.test.sh` covers the branch actor merging a granted task under the record, being held without a grant, and being refused at the partition while attended; `tests/fm-send-resolve-key.test.sh` covers the decision-answer partition (a needs-decision or captain-held key refuses the attended branch before anything is sent, a `blocked:` key stays ordinary steering, and the record relocates the answer). `tests/fm-pi-watch-extension.test.sh` covers the away eligibility collapse (check-kind and decision-owned triggers offered) with the broken-queue vetoes and the watcher-failure alarm still reaching main, and `tests/fm-pi-branch-extension.test.sh` covers the posture tail with the verbatim read-back, the unscoped claim of check and heartbeat rows, no processing turn under the record, cancellation of a request pending when the record appears, and the re-presentation at the first run boundary after archive. `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. diff --git a/docs/supervision-protocols/pi.md b/docs/supervision-protocols/pi.md index b035f328371..f9142b7f755 100644 --- a/docs/supervision-protocols/pi.md +++ b/docs/supervision-protocols/pi.md @@ -23,7 +23,7 @@ The supervision branch is default-on (docs/pi-supervision-branch.md): whenever t While the away-posture record `state/.afk-contract` exists the branch takes every row instead, this conversation receives no processing request, and main's standing authority relocates to the branch through the guarded scripts; a wake the branch cannot take and every watcher-failure alarm still reach this conversation, and the first run boundary after the record is archived presents what accumulated (docs/pi-supervision-branch.md "Postures"). 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. +A captain-facing outcome instead appears as one exact, sequence-keyed visible transcript entry, and while attended then arrives in this conversation as one hidden supervision processing request listing each `[seq N] task: summary` it covers; outcomes recorded while away wait for that request until the record is archived. 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. Only that call closes the outcome; an unrelated, empty, or paraphrased answer leaves it open, and the current unprocessed sequence set is presented again at the next run boundary and at session start until it is acknowledged. The persisted entry is already the captain-visible record, so MAIN must not re-emit it verbatim merely because it appeared; this prevents repetition but does not replace any captain-facing outcome response required by `AGENTS.md` section 9. diff --git a/docs/verification/runtime-backends.md b/docs/verification/runtime-backends.md index 65c2ffb5493..2ec6d91f5b7 100644 --- a/docs/verification/runtime-backends.md +++ b/docs/verification/runtime-backends.md @@ -2018,14 +2018,18 @@ FM_PI_PACKAGE_DIR= npm exec --yes --package=typescript@5.9.3 ```text ok - under the away-posture record every actionable row is offered to the branch while broken-queue wakes and watcher-failure alarms still reach main ok - under the away-posture record the wake carries the verbatim read-back tail, claims every row, opens no processing turn, cancels a pending request, and presents the accumulated rows after archive +ok - an accepted away-only wake rejects after archive, while a drained task-local wake stays a quiet no-op +ok - a claimed heartbeat row on a non-heartbeat away wake lifts task scoping for the fleet report ok - the away-posture record relocates the PR merge and a spawn under the spend cap to the branch, never local landing, and only while confirmed and valid +ok - relocated branch spawn admits only already-queued dispatchable work, including on a manual-backend home +ok - the away spend cap is rechecked under the task-set lock so concurrent spawns cannot both publish ok - fm-send --resolve-key: a decision answer refuses the attended branch before sending, a blocked: key stays steering, and the away-posture record relocates the answer ok - under the away-posture record the branch merges a granted green task, is held without a grant, cannot waive a red check, and is refused at the partition while attended ok - real Pi SDK 0.81.1 accepts the branch session construction and preserves an unpromptable wake ok - tracked Pi extensions pass strict no-emit typecheck against Pi 0.85.1 ``` -Every record read in those regressions goes through the real `bin/fm-afk-contract.sh`, so a proposal, an archived record, and an invalid record are proven to restore the attended shape rather than being assumed to. +Every record read in those regressions ultimately goes through the real `bin/fm-afk-contract.sh`, with fixture wrappers used only to archive at deterministic call boundaries; a proposal, an archived record, and an invalid record are proven to restore attended guarded-action behavior rather than being assumed to. Against the installed 0.81.1 package the typecheck reports a pre-existing `ModelsRefreshOptions.providers` mismatch in the branch's provider-registration path that this change does not touch; the option exists from the 0.84 line on, which is why the typecheck evidence uses the newer package as the earlier entries do. The real Pi/Herdr return guard (`FM_AFK_PI_HERDR_E2E=1 tests/fm-afk-pi-herdr-return-e2e.test.sh`) remains the owner of the live return-brief proof; it loads no supervision extension into its synthetic primary and does not yet exercise the parked-main scenario, which is a follow-up for a Herdr-lab-guarded task.