diff --git a/.agents/skills/afk/SKILL.md b/.agents/skills/afk/SKILL.md index 0046d63e020..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 (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 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,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 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. 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 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 @@ -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/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8436d065119..64ddfaeb4ae 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,12 +22,16 @@ concurrency: group: ci-${{ github.workflow }}-${{ github.event_name }}-${{ github.event.pull_request.number || github.run_id }} cancel-in-progress: ${{ github.event_name == 'pull_request' }} +# Timeout policy: docs/fm-test-portable-shards.md "Timeouts" owns the three +# tiers and their rationale; tests/fm-ci-workflow.test.sh guards this workflow. +# Each job comment identifies the tier implemented by its executable value. + jobs: lint: name: Lint ${{ matrix.partition }} runs-on: ubuntu-latest - # Keep the hang tripwire separate from the measured performance target. - timeout-minutes: 25 + # Normal tier (see the timeout policy above). + timeout-minutes: 30 strategy: fail-fast: false matrix: @@ -68,7 +72,7 @@ jobs: test-coverage: name: Test coverage guard runs-on: ubuntu-latest - # Hang tripwire: the coverage guard is a seconds-long local computation. + # Fast tier: the coverage guard is a seconds-long local computation. timeout-minutes: 5 steps: - uses: actions/checkout@v6 @@ -80,14 +84,8 @@ jobs: tests-portable-parallel-1: name: Behavior portable parallel 1 runs-on: ubuntu-latest - # This cap is intended as a hang tripwire, but the previous lane 1 reached - # it; the former "~1 min of serial sum" estimate no longer applies. - # Compare it with the derived hints from fm-test-run.sh --check-coverage - # and completed job timings, allowing for setup and runner-speed spread. - # A packed hint sum is not a measured job wall time or proof of headroom. - # Evidence and refresh procedure: docs/fm-test-portable-shards.md. - # Changes to this cap or the lane count require a separate scope decision. - timeout-minutes: 10 + # Normal tier (see the timeout policy above). + timeout-minutes: 30 steps: - uses: actions/checkout@v6 with: @@ -130,8 +128,8 @@ jobs: tests-portable-parallel-2: name: Behavior portable parallel 2 runs-on: ubuntu-latest - # Same timeout rationale as portable parallel shard 1 above. - timeout-minutes: 10 + # Normal tier (see the timeout policy above). + timeout-minutes: 30 steps: - uses: actions/checkout@v6 with: @@ -174,9 +172,7 @@ jobs: tests-portable-serial: name: Behavior portable serial ${{ matrix.shard }} runs-on: ubuntu-latest - # Refreshed weights put the longest modeled shard near 12 minutes across - # nine runners. Preserve the existing hang tripwire until complete Linux - # measurements establish the new healthy envelope; a model is not a timer. + # Normal tier (see the timeout policy above). timeout-minutes: 30 strategy: # Every shard reports so one failure never hides another shard's result. @@ -248,10 +244,9 @@ jobs: tests-herdr: name: Behavior tests (Herdr) runs-on: ubuntu-latest - # Healthy runs finish around 7 minutes. This job cap is a last-resort hang - # tripwire, not the expected end of the lane. The family-run step owns the - # tighter bound so a wedged suite fails fast with always() cleanup and - # timing artifacts still uploaded (docs/fm-test-portable-shards.md). + # Heavy tier (see the timeout policy above): the last-resort job backstop. + # The family-run step below owns the hang tripwire, so a wedged suite fails + # there with the always() cleanup and timing upload still running. timeout-minutes: 75 steps: - uses: actions/checkout@v6 @@ -332,8 +327,9 @@ jobs: mkdir -p "$RUNNER_TEMP/fm-herdr" bin/fm-herdr-ci-cleanup.sh snapshot "$RUNNER_TEMP/fm-herdr/sessions-before.json" - name: Run real-Herdr family (serial, required) - # Comfortably above the ~7 min healthy wall and far below the 75 min - # job backstop. A hang must fail this step so cleanup still runs. + id: run-real-herdr-family + # Heavy tier step tripwire: above the healthy 7-10 minute wall and far + # below the job backstop, so a hang fails this step and cleanup runs. timeout-minutes: 20 run: | set -eu @@ -344,6 +340,7 @@ jobs: --fail-on-gate-skip 'herdr not found' \ --json "$RUNNER_TEMP/fm-test/fm-test-timing-herdr.json" - name: Cleanup job-owned Herdr lab sessions + id: cleanup-herdr-lab-sessions if: always() run: | set -eu @@ -368,7 +365,7 @@ jobs: tests-timing-aggregate: name: Behavior timing aggregate runs-on: ubuntu-latest - # Hang tripwire: aggregation is seconds of work over lane artifacts. + # Fast tier: aggregation is seconds of work over lane artifacts. timeout-minutes: 5 needs: - tests-portable-parallel-1 @@ -408,7 +405,8 @@ jobs: macos-stock-bash: name: Stock macOS Bash snapshot compatibility runs-on: macos-latest - timeout-minutes: 10 + # Normal tier (see the timeout policy above). + timeout-minutes: 30 steps: - uses: actions/checkout@v6 - name: Run snapshot consumers with stock Bash @@ -480,7 +478,7 @@ jobs: invariants: name: Repo invariants runs-on: ubuntu-latest - # Hang tripwire: the invariant checks are seconds-long file comparisons. + # Fast tier: the invariant checks are seconds-long file comparisons. timeout-minutes: 5 steps: - uses: actions/checkout@v6 diff --git a/.pi/extensions/fm-branch-supervision.ts b/.pi/extensions/fm-branch-supervision.ts index 682f0a087ab..a56d064ca6f 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,8 +231,8 @@ function offerEligible(offer: BranchDispatchOffer): boolean { return offer.eligible === true; } -function afkActive(): boolean { - return existsSync(afkFlag); +function isProcessingCustomMessage(message: { role?: string; customType?: string }): boolean { + return message.role === "custom" && message.customType === PROCESSING_MESSAGE_TYPE; } // Pi persists provider failures as ordinary assistant messages and resolves @@ -631,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 queuedProcessingContent: string | null = null; + 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. @@ -1027,6 +1054,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 +1074,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 }; @@ -1048,6 +1092,7 @@ export default function (pi: ExtensionAPI) { if (processing.triggered < PROCESSING_TRIGGERED_ATTEMPTS) { processing.triggered += 1; processing.pending = true; + queuedProcessingContent = content; pi.sendMessage(message, { triggerTurn: true, deliverAs: "followUp" }); } else if (!processing.nextTurnQueued) { processing.nextTurnQueued = true; @@ -1390,7 +1435,24 @@ ${context.command} } } - function enqueueWake(message: string, acceptedGeneration: number, recoveryProbe = false): Promise { + // 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, acceptedAwayOnly = false): Promise { const acceptedSelectionRevision = branchSelectionRevision; const delivery = branchChain .then(async () => { @@ -1415,7 +1477,14 @@ ${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. + // 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 // whole recheck back to main - scopeForUnreadWake excludes it from // eligibleSeqs rather than vetoing the scan, in a heartbeat review as @@ -1427,7 +1496,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 (acceptedAwayOnly) { + 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"); } @@ -1443,10 +1517,18 @@ ${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 || 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( - `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 +1628,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 && @@ -1556,7 +1637,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, offer.awayOnly === true)); }); // Pi awaits every extension event handler, so an awaited ownership read @@ -1575,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", () => { @@ -1589,6 +1673,15 @@ ${context.command} // so a fresh copy may be queued again once this run settles unacknowledged. if (processing) processing.nextTurnQueued = 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 (processingOpenedThisRun) ctx?.abort?.(); + return { messages: kept }; + }); pi.on?.("agent_end", () => { mainStreaming = false; }); @@ -1600,6 +1693,8 @@ ${context.command} // reply that only paraphrased it - and is presented again. pi.on?.("agent_settled", async () => { mainStreaming = false; + queuedProcessingContent = null; + processingOpenedThisRun = false; if (processing) processing.pending = false; const settledGeneration = generation; await enqueueDelivery(async () => { diff --git a/.pi/extensions/fm-primary-pi-watch.ts b/.pi/extensions/fm-primary-pi-watch.ts index ad45ce8b821..58e4841adbd 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,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 = !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 5687aa47879..f843926f3ff 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,18 @@ 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[]; + /** + * 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; } @@ -74,6 +110,8 @@ const EMPTY_SCOPE: UnreadWakeScope = { eligibleTasks: [], corrupted: false, needsDecisionKeys: [], + checkSeqs: [], + heartbeatSeqs: [], taskByWakeKey: {}, }; const UNSAFE_SCOPE: UnreadWakeScope = { @@ -84,6 +122,8 @@ const UNSAFE_SCOPE: UnreadWakeScope = { eligibleTasks: [], corrupted: true, needsDecisionKeys: [], + checkSeqs: [], + heartbeatSeqs: [], taskByWakeKey: {}, }; @@ -122,6 +162,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 +234,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 +275,8 @@ export function scopeForUnreadWake(state: string, heartbeat: boolean): UnreadWak const eligibleSeqs: string[] = []; 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"; @@ -242,13 +291,23 @@ 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); + heartbeatSeqs.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 +315,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 +365,7 @@ export function scopeForUnreadWake(state: string, heartbeat: boolean): UnreadWak } if (staleDecisionOwnership.get(statusPath)) { needsDecisionKeys.push(key); - continue; + if (!afk) continue; } } } else { @@ -333,6 +394,8 @@ export function scopeForUnreadWake(state: string, heartbeat: boolean): UnreadWak eligibleTasks: [...eligibleTasks], corrupted: false, needsDecisionKeys, + checkSeqs, + heartbeatSeqs, taskByWakeKey: Object.fromEntries(taskByKey), }; } @@ -421,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; @@ -432,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/AGENTS.md b/AGENTS.md index 4fda69ec921..6a0c70fc636 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -149,6 +149,7 @@ state/ runtime records and signals; gitignored .afk-contract the away-posture record: the captain's verbatim away words, expected return, reach profile, spend cap, and structured mandate clauses; written only by bin/fm-afk-contract.sh after the captain confirms the read-back, archived under afk-contracts/ at return; its presence IS the away posture in every harness; its sibling .afk-contract.lock serializes actions authorized by the live record (contract: bin/fm-afk-contract.sh) afk-contracts/ archived away-posture records: one final record per away window keyed by entry time, plus any superseded mandates from that window .afk durable away/quiet-mode daemon flag on the harnesses that still launch the daemon (never on Pi); present = sub-supervisor may inject escalations, first line `away` (default, set by /afk, cleared on user return) or `quiet` (set by /quiet, cleared only on explicit /quiet off) per the single owner fm_afk_mode() in bin/fm-wake-lib.sh + .lock-session trusted Claude session-lock sidecar; written only by bin/fm-lock.sh; never touch .watch.lock .wake-queue.lock watcher singleton and queue serialization locks .claude-autoarm.lock .claude-autoarm-epoch .claude-autoarm-failure-notified .claude-autoarm-failure-alarmed .turnend-claude-blocks .turnend-claude-blocks.lock Claude Stop auto-arm single-flight, epoch, failure-episode, attended-alarm, guard-budget, and budget-lock records; never touch .cursor-park-owner .cursor-park-owner.lock .turnend-cursor-blocks Cursor stop-hook owner record, publication and commit lock, and bounded repair-nag budget; never touch @@ -250,7 +251,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. @@ -464,7 +465,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 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 0587fa6d347..3b38defc916 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 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 }') + 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..4bc5d883e4b 100755 --- a/bin/fm-branch-prompt.sh +++ b/bin/fm-branch-prompt.sh @@ -84,15 +84,30 @@ 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 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. +- `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. # Discipline @@ -107,3 +122,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-claude-stop-autoarm.sh b/bin/fm-claude-stop-autoarm.sh index df1100ba988..bf09b78431a 100755 --- a/bin/fm-claude-stop-autoarm.sh +++ b/bin/fm-claude-stop-autoarm.sh @@ -10,7 +10,11 @@ # - Scope: only a genuine primary checkout (plain checkout or validly marked # secondmate home) with AGENTS.md, bin/, and the effective state dir - the # exact fm-turnend-guard.sh scope. Child crew/scout worktrees stay inert. -# - Identity: only when THIS session's harness ancestor holds state/.lock. +# - Identity: only when THIS session holds state/.lock, as +# bin/fm-session-lock-lib.sh decides it: the recorded pid is a harness +# ancestor, or a live lock was recorded under this same trusted Claude +# session id (which is what keeps a background session arming after its +# transient helper chain is recycled). # When an existing numeric owner fails the shared harness-liveness predicate, # the hook delegates guarded recovery to bin/fm-lock.sh and then re-verifies # ownership. A live owner, missing lock, malformed lock, or unresolved 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-lock.sh b/bin/fm-lock.sh index 52d7c8aee4b..94e26db9620 100755 --- a/bin/fm-lock.sh +++ b/bin/fm-lock.sh @@ -1,8 +1,25 @@ #!/usr/bin/env bash # Acquire or inspect the per-home firstmate session lock. -# Writes the harness (agent) process PID found by walking the shell's ancestry, -# which lives as long as the firstmate session - unlike the transient subshell -# PID of any one tool call, which is dead moments after it is written. +# +# Line 1 of state/.lock is the owning session's anchor pid, resolved by +# fm_session_lock_anchor_pid in bin/fm-session-lock-lib.sh: the harness (agent) +# process found by walking the shell's ancestry, which lives as long as the +# firstmate session - unlike the transient subshell PID of any one tool call, +# which is dead moments after it is written. For a Claude session that proves a +# trusted session id the anchor is CLAUDE_PID, the model-loop process, so a +# shared transient daemon or a front-end that outlives the session never keeps +# a dead session's lock alive. Line 1 keeps its whole-line pid format because +# every other reader takes the first line as the pid. +# +# The trusted id itself is recorded beside the lock in state/.lock-session, a +# sidecar written only here and only under the claim lock: refreshed on every +# confirmed-own acquisition, including the early already-mine exit that waits +# for the claim lock, removed when the acquiring session proves no trusted id, +# and left byte-identical when it already names that id. A same-session +# confirmation never rewrites line 1 while the recorded pid is alive, because +# bin/fm-startup-network.sh compares that pid across its deferred sweeps; a dead +# recorded pid is reclaimed and rewritten to this session's anchor. +# # Usage: fm-lock.sh acquire; exit 1 unless ownership is verified # fm-lock.sh status print holder and liveness; always exits 0 set -u @@ -12,14 +29,15 @@ FM_ROOT="${FM_ROOT_OVERRIDE:-$(cd "$SCRIPT_DIR/.." && pwd)}" FM_HOME="${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}}" STATE="${FM_STATE_OVERRIDE:-$FM_HOME/state}" LOCK="$STATE/.lock" +LOCK_SESSION="$STATE/.lock-session" mkdir -p "$STATE" 2>/dev/null || { echo "error: cannot create session-lock state directory $STATE; operate read-only until resolved" >&2 exit 1 } -# Harness identity (FM_HARNESS_RE, ancestry walk, holder liveness) is owned by -# the shared session-lock lib so the Claude Stop auto-arm applies the exact -# same identity contract. +# Harness identity (FM_HARNESS_RE, ancestry walk, holder liveness, trusted +# session id, anchor pid) is owned by the shared session-lock lib so the Claude +# Stop auto-arm applies the exact same identity contract. # shellcheck source=bin/fm-session-lock-lib.sh . "$SCRIPT_DIR/fm-session-lock-lib.sh" @@ -33,7 +51,7 @@ if [ "${1:-}" = "status" ]; then exit 0 fi -me=$(fm_harness_ancestry_pid) || { echo "error: cannot locate harness process in ancestry" >&2; exit 1; } +me=$(fm_session_lock_anchor_pid) || { echo "error: cannot locate harness process in ancestry" >&2; exit 1; } probe=$(mktemp "$STATE/.lock-write.XXXXXX" 2>/dev/null) || { echo "error: cannot write session lock; operate read-only until resolved" >&2 exit 1 @@ -46,24 +64,135 @@ rm -f "$probe" 2>/dev/null || { . "$SCRIPT_DIR/fm-wake-lib.sh" CLAIM_LOCK="$STATE/.lock.acquire" CLAIM_LOCK_HELD=0 +# PHASE 0: committed/none. 1: sidecar mutated, line 1 not written. 2: line 1 written, not verified. +# KIND 0: no backup. 1: restore $LOCK_SESSION_PREV. 2: sidecar was absent. +LOCK_SESSION_PHASE=0 +LOCK_SESSION_KIND=0 +LOCK_SESSION_PREV="$STATE/.lock-session.prev" +LOCK_LINE_PRE= release_claim_lock() { if [ "$CLAIM_LOCK_HELD" -eq 1 ]; then fm_lock_release "$CLAIM_LOCK" CLAIM_LOCK_HELD=0 fi } -trap release_claim_lock EXIT +restore_uncommitted_lock_session() { + case "$LOCK_SESSION_PHASE" in + 1) + case "$LOCK_SESSION_KIND" in + 1) mv -f "$LOCK_SESSION_PREV" "$LOCK_SESSION" 2>/dev/null || true ;; + 2) rm -f "$LOCK_SESSION" "$LOCK_SESSION_PREV" 2>/dev/null || true ;; + esac + ;; + 2) rm -f "$LOCK_SESSION" "$LOCK_SESSION_PREV" 2>/dev/null || true ;; + esac + LOCK_SESSION_PHASE=0 + LOCK_SESSION_KIND=0 +} +commit_lock_session() { + LOCK_SESSION_PHASE=0 + LOCK_SESSION_KIND=0 + rm -f "$LOCK_SESSION_PREV" 2>/dev/null || true +} +on_lock_exit() { + restore_uncommitted_lock_session + [ -n "$LOCK_LINE_PRE" ] && rm -f "$LOCK_LINE_PRE" + release_claim_lock +} +trap on_lock_exit EXIT trap 'exit 1' HUP INT TERM +remember_lock_session() { + [ "$LOCK_SESSION_PHASE" -eq 0 ] || return 0 + if [ -e "$LOCK_SESSION" ] || [ -L "$LOCK_SESSION" ]; then + rm -f "$LOCK_SESSION_PREV" 2>/dev/null || true + cp -P "$LOCK_SESSION" "$LOCK_SESSION_PREV" 2>/dev/null || return 1 + LOCK_SESSION_KIND=1 + else + LOCK_SESSION_KIND=2 + fi + LOCK_SESSION_PHASE=1 +} + +# Record the trusted session id beside the lock, or remove a sidecar that no +# trusted id backs. Called only while the claim lock is held. A sidecar already +# naming this id is left untouched, so a same-session confirmation keeps it +# byte-identical. +publish_lock_session() { + local trusted recorded tmp + if trusted=$(fm_session_lock_trusted_session_id); then + if recorded=$(fm_session_lock_recorded_session_id "$STATE") && [ "$recorded" = "$trusted" ]; then + return 0 + fi + remember_lock_session || return 1 + tmp=$(mktemp "$STATE/.lock-session.XXXXXX" 2>/dev/null) || return 1 + if ! { printf '%s\n' "$trusted" > "$tmp" && mv -f "$tmp" "$LOCK_SESSION"; } 2>/dev/null; then + rm -f "$tmp" 2>/dev/null + return 1 + fi + return 0 + fi + if [ -e "$LOCK_SESSION" ] || [ -L "$LOCK_SESSION" ]; then + remember_lock_session || return 1 + rm -f "$LOCK_SESSION" 2>/dev/null || return 1 + fi + return 0 +} + +publish_lock_session_or_die() { + publish_lock_session && return 0 + echo "error: cannot record the session identity beside the lock; operate read-only until resolved" >&2 + exit 1 +} + +# This session already holds the lock, recorded as pid $1. Line 1 stays exactly +# as recorded while that pid is alive; only the sidecar is refreshed, under the +# claim lock, so a /clear re-key inside the same process replaces the old id. +# A same-session confirmation waits for the claim lock so the sidecar refresh +# completes. After the wait, the lock is re-read and the sidecar is refreshed +# only when this session still owns it; otherwise the claim lock is released +# and the caller continues with the ordinary live-owner or reclaim path. The +# prior-session-sweep-is-finishing refusal is a takeover rule and does not +# apply here. +confirm_own_lock() { # + local recorded waited=0 + if [ "$CLAIM_LOCK_HELD" -ne 1 ]; then + fm_lock_acquire_wait "$CLAIM_LOCK" + CLAIM_LOCK_HELD=1 + waited=1 + fi + recorded=$(cat "$LOCK" 2>/dev/null || true) + if [ "$recorded" = "$me" ] || fm_session_lock_owned_by_self "$STATE"; then + publish_lock_session_or_die + commit_lock_session + release_claim_lock + echo "lock acquired: harness pid $recorded" + exit 0 + fi + if [ "$waited" -eq 1 ]; then + release_claim_lock + fi + return 1 +} + +refuse_live_owner() { # + local recorded + if recorded=$(fm_session_lock_recorded_session_id "$STATE"); then + echo "error: another live firstmate session holds the lock (pid $1, session $recorded); operate read-only until resolved" >&2 + else + echo "error: another live firstmate session holds the lock (pid $1); operate read-only until resolved" >&2 + fi + exit 1 +} + if [ -f "$LOCK" ] && [ ! -L "$LOCK" ]; then old=$(cat "$LOCK" 2>/dev/null || true) - if [ "$old" = "$me" ]; then - echo "lock acquired: harness pid $me" - exit 0 + if [ "$old" = "$me" ] || fm_session_lock_owned_by_self "$STATE"; then + confirm_own_lock "$old" + old=$(cat "$LOCK" 2>/dev/null || true) fi if fm_harness_pid_alive "$old"; then - echo "error: another live firstmate session holds the lock (pid $old); operate read-only until resolved" >&2 - exit 1 + refuse_live_owner "$old" fi fi @@ -87,11 +216,45 @@ if [ -e "$LOCK" ] || [ -L "$LOCK" ]; then exit 1 } if [ "$old" != "$me" ] && fm_harness_pid_alive "$old"; then - echo "error: another live firstmate session holds the lock (pid $old); operate read-only until resolved" >&2 + fm_session_lock_owned_by_self "$STATE" && confirm_own_lock "$old" + old=$(cat "$LOCK" 2>/dev/null || true) + if [ "$old" != "$me" ] && fm_harness_pid_alive "$old"; then + refuse_live_owner "$old" + fi + fi +fi +# The sidecar goes first: a fresh pid beside a previous session's id would let +# that session's resume own this lock. If the sidecar changes before line 1 is +# written, a failure restores the previous sidecar. If line 1 is written but +# not yet verified, a failure removes the sidecar and leaves the lock +# ancestry-only. After line 1 verifies as this session's anchor, a later +# signal leaves the published pair in place. +publish_lock_session_or_die +if [ -f "$LOCK" ]; then + LOCK_LINE_PRE=$(mktemp "$STATE/.lock.pre.XXXXXX") || { + echo "error: cannot write session lock; operate read-only until resolved" >&2 + exit 1 + } + if ! cp "$LOCK" "$LOCK_LINE_PRE" 2>/dev/null; then + echo "error: cannot write session lock; operate read-only until resolved" >&2 exit 1 fi fi +LOCK_SESSION_PHASE=2 if ! { printf '%s\n' "$me" > "$LOCK"; } 2>/dev/null; then + lock_unchanged=0 + if [ -n "$LOCK_LINE_PRE" ] && cmp -s "$LOCK_LINE_PRE" "$LOCK"; then + lock_unchanged=1 + elif [ -z "$LOCK_LINE_PRE" ] && [ ! -e "$LOCK" ] && [ ! -L "$LOCK" ]; then + lock_unchanged=1 + fi + if [ "$lock_unchanged" -eq 1 ]; then + if [ "$LOCK_SESSION_KIND" -ne 0 ]; then + LOCK_SESSION_PHASE=1 + else + LOCK_SESSION_PHASE=0 + fi + fi echo "error: cannot write session lock; operate read-only until resolved" >&2 exit 1 fi @@ -103,5 +266,6 @@ if [ ! -f "$LOCK" ] || [ -L "$LOCK" ] || [ "$written" != "$me" ]; then echo "error: session lock ownership verification failed; operate read-only until resolved" >&2 exit 1 fi +commit_lock_session release_claim_lock echo "lock acquired: harness pid $me" 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..7c3e5fc072f 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 @@ -937,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/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-session-lock-lib.sh b/bin/fm-session-lock-lib.sh index 7dec38a73a0..a2e3a4c0fef 100644 --- a/bin/fm-session-lock-lib.sh +++ b/bin/fm-session-lock-lib.sh @@ -2,10 +2,15 @@ # Shared session-lock harness identity. # # ONE owner of the "which verified-harness process holds this home's session -# lock, and does the current process descend from that same harness?" decision. -# bin/fm-lock.sh uses it to acquire and inspect state/.lock; -# bin/fm-claude-stop-autoarm.sh uses it to prove a Stop hook fires inside the -# lock-owning primary session before it may arm or rewake. +# lock, and does the current process run inside that same session?" decision. +# bin/fm-lock.sh uses it to acquire and inspect state/.lock and its +# state/.lock-session sidecar; bin/fm-claude-stop-autoarm.sh uses it to prove a +# Stop hook fires inside the lock-owning primary session before it may arm or +# rewake. Two signals decide ownership, either one sufficient: the recorded pid +# is a member of this process's contiguous harness ancestry, or the trusted +# Claude session id below matches the id recorded beside a live lock. Neither +# signal ever fails open: no id, no sidecar, an untrusted id, or a different +# recorded id leaves the ancestry verdict exactly as it was. # This file is sourced by scripts and has no side effects on source. # Cursor process identity is NOT expressible as a command-name pattern and is @@ -132,19 +137,24 @@ fm_harness_ancestry_pids() { [ "$printed" -eq 1 ] } -# Print the one pid that identifies this session when the session lock is being -# WRITTEN: the outermost pid of the contiguous run. That is the pid that lives as -# long as the session - a Claude worker several levels in is reaped when its hook -# returns, and a lock naming it would look stale moments later while the session -# is still running. Every non-Claude harness reports a single pid, so this is its -# innermost match unchanged. +# Print the outermost pid of this session's contiguous harness run for callers +# that need that ancestry identity. This is not necessarily the pid written to +# the session lock: fm_session_lock_anchor_pid owns that choice and uses a +# trusted Claude session's model-loop pid instead. Every non-Claude harness +# reports a single pid, so this remains its innermost match unchanged. fm_harness_ancestry_pid() { - local pids pid outermost='' + local pids pids=$(fm_harness_ancestry_pids) || return 1 + _fm_harness_outermost_pid "$pids" +} + +# Print the last (outermost) pid of ancestry list $1, or return 1 when empty. +_fm_harness_outermost_pid() { # + local pid outermost='' while IFS= read -r pid; do [ -n "$pid" ] && outermost=$pid done <] + local id=${CLAUDE_CODE_SESSION_ID:-} claude_pid=${CLAUDE_PID:-} pids=${1:-} pid comm args + [ -n "$id" ] || return 1 + case "$id" in *$'\n'*|*$'\r'*) return 1 ;; esac + case "$claude_pid" in ''|*[!0-9]*) return 1 ;; esac + if [ -z "$pids" ]; then + pids=$(fm_harness_ancestry_pids) || return 1 + fi + while IFS= read -r pid; do + [ "$pid" = "$claude_pid" ] || continue + comm=$(ps -o comm= -p "$pid" 2>/dev/null) || return 1 + args=$(ps -o args= -p "$pid" 2>/dev/null) + fm_harness_process_matches "$comm" "$args" || return 1 + [ "$FM_HARNESS_IS_CLAUDE" -eq 1 ] || return 1 + printf '%s\n' "$id" + return 0 + done < + local state=$1 recorded + [ -f "$state/.lock-session" ] && [ ! -L "$state/.lock-session" ] || return 1 + recorded=$(head -n 1 "$state/.lock-session" 2>/dev/null) || return 1 + [ -n "$recorded" ] || return 1 + case "$recorded" in *$'\n'*|*$'\r'*) return 1 ;; esac + printf '%s\n' "$recorded" +} + +# True when the lock in state dir $1 was recorded by this same Claude session: +# the trusted id equals the id recorded beside the lock. No trusted id, no +# sidecar, or a different recorded id is false. +fm_session_lock_same_session() { # [] + local state=$1 trusted recorded + trusted=$(fm_session_lock_trusted_session_id "${2:-}") || return 1 + recorded=$(fm_session_lock_recorded_session_id "$state") || return 1 + [ "$recorded" = "$trusted" ] +} + +# Print the pid bin/fm-lock.sh records on lock line 1 for this session. For a +# Claude session with a trusted id that is CLAUDE_PID, the model-loop process: +# never the shared transient daemon and never a front-end that outlives the +# session, so "recorded pid dead" keeps meaning "session gone" instead of +# wedging a home behind a live daemon whose session died. A replaced background +# helper leaves a dead pid that its own session's next hook reclaims, because +# the sidecar still names that session. Every other session records the +# outermost pid of its contiguous run, exactly as before. +fm_session_lock_anchor_pid() { + local pids + pids=$(fm_harness_ancestry_pids) || return 1 + if fm_session_lock_trusted_session_id "$pids" >/dev/null; then + printf '%s\n' "$CLAUDE_PID" + return 0 + fi + _fm_harness_outermost_pid "$pids" +} + +# True when state dir $1 holds a session lock that this process's session owns: +# the recorded pid is ANY harness ancestor of the current process, or the lock +# was recorded by this same trusted Claude session and its recorded pid is still +# a live harness. Membership is the honest ancestry test, because the lock owner +# sits at an unknown depth in a contiguous Claude run - it is the outermost pid +# when the hook fires inside the session's own nested worker chain, and an inner +# pid when a harness-named daemon parents the session. The same-session path +# requires the recorded pid alive so that a dead one is reclaimed through +# bin/fm-lock.sh's ordinary stale-owner path, which refreshes line 1, rather than +# silently owned with a dead anchor. A missing lock, a malformed lock, a lock +# held by a harness outside this ancestry under another (or no) session id, or +# an ancestry that cannot be resolved all fail closed. fm_session_lock_owned_by_self() { local state=$1 lock_pid pids pid lock_pid=$(cat "$state/.lock" 2>/dev/null || true) @@ -179,13 +282,15 @@ fm_session_lock_owned_by_self() { done <&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 +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 + 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 2>/dev/null || true) + case "$cap" in + '' | *[!0-9]* | 0) return 0 ;; + esac + 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 [ "$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 +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 + fm_lease_forbid_branch "new-task spawn (fm-spawn)" --away-relocated + 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 +} if [ "$RELAUNCH" -eq 1 ]; then SPAWN_CONTROL_LOCK="$STATE/.control-$ID.lock" control_owner=$(cat "$SPAWN_CONTROL_LOCK/pid" 2>/dev/null || true) @@ -1411,6 +1459,8 @@ if [ "$RELAUNCH" -eq 0 ]; then exit 1 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 @@ -2955,7 +3005,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/bin/fm-startup-network.sh b/bin/fm-startup-network.sh index 380138ae25f..cc9e70451d6 100755 --- a/bin/fm-startup-network.sh +++ b/bin/fm-startup-network.sh @@ -301,9 +301,9 @@ EOF # # The question is deliberately "does the lock still name the session that asked # for this work?", not "is that session still alive". The hazard being closed is -# a SECOND session sweeping concurrently, and taking the lock is exactly what -# rewrites this value - bin/fm-lock.sh overwrites a dead holder's pid with its -# own. An unchanged value therefore proves no one else owns the sweeps, which is +# a SECOND session sweeping concurrently. A different session can take the lock +# only after the recorded holder is dead, when bin/fm-lock.sh rewrites that pid +# with its own anchor. An unchanged value therefore proves no one else owns the sweeps, which is # the whole guarantee. Requiring liveness instead would refuse to finish work # nobody else has claimed, and the sweeps are idempotent, so finishing it is # strictly better than abandoning it. A missing, unreadable, or replaced lock all diff --git a/bin/fm-turnend-guard.sh b/bin/fm-turnend-guard.sh index 6ad3592d6f3..7854f93b6dd 100755 --- a/bin/fm-turnend-guard.sh +++ b/bin/fm-turnend-guard.sh @@ -66,9 +66,10 @@ # auto-arm (bin/fm-claude-stop-autoarm.sh), which fires on the same Stop event: # 1. a live identity-matched watcher with a fresh beacon - or, in away mode, a # live identity-matched daemon with a fresh beacon - allows immediately; -# 2. an unhealthy session with a verified live session-lock owner outside its -# harness ancestry exits with a read-only diagnostic instead of blocking a -# session that cannot repair supervision without stealing ownership; +# 2. an unhealthy session with a verified live session-lock owner it does not +# own under the shared ancestry-or-trusted-id verdict exits with a read-only +# diagnostic instead of blocking a session that cannot repair supervision +# without stealing ownership; # 3. otherwise wait briefly (FM_CLAUDE_AUTOARM_SYNC_WAIT_MS, default 800ms) # for the auto-arm to claim this home (a live OPEN generation claim in the # state/.claude-autoarm-epoch ledger - fm_autoarm_claim_open - or a legacy @@ -253,8 +254,9 @@ block_stop() { exit 2 } -# A live session outside this process's harness ancestry owns the home lock. -# This session is read-only and cannot arm or repair supervision without +# Another verified live session owns the home lock under the shared +# ancestry-or-trusted-id verdict. This session is read-only and cannot arm or +# repair supervision without # stealing ownership, so blocking its Stop would create an impossible loop. # Report the ownership conflict as a diagnostic and let this turn end safely; # the owning session remains responsible for restoring the watcher. diff --git a/docs/architecture.md b/docs/architecture.md index b06b5ca526f..57d784e48f0 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 853514110ef..4f3d509ed8b 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -42,11 +42,12 @@ 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. +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/fm-test-portable-shards.md b/docs/fm-test-portable-shards.md index cd0ddb161fa..d33c5676a8c 100644 --- a/docs/fm-test-portable-shards.md +++ b/docs/fm-test-portable-shards.md @@ -33,7 +33,7 @@ The two parallel lanes use longest-processing-time assignment over those hints. [`bin/fm-test-run.sh`](../bin/fm-test-run.sh) holds the duration values in `portable_parallel_weight_hints` and the ordered memberships and lane-specific prerequisite constraints beside `list_portable_parallel_1` and `list_portable_parallel_2`. Read the derived packing estimates with that runner's `--check-coverage`; its header and `--help` own the output fields and the selection-specific `--list-scheduled` weight rules. The largest individual hint sets a lower bound on the estimated duration of any split, regardless of how evenly the remaining work is assigned. -The CI cap and its rationale are owned by [`.github/workflows/ci.yml`](../.github/workflows/ci.yml). +The CI cap follows the three-tier timeout policy in [Timeouts](#timeouts) below. [`tests/fm-test-run.test.sh`](../tests/fm-test-run.test.sh), in `test_portable_parallel_lanes_stay_duration_balanced`, requires every parallel member to have a hint and the lane sums to differ by no more than five percent of the larger sum. Its scheduling regressions also check stored parallel lane order and preserve serial-weight scheduling for other selections. @@ -72,7 +72,7 @@ Refresh the hints whenever the serial lane gains scripts, rather than waiting fo Nine serial runners pack the refreshed measurements into a longest modeled script sum of 697969 ms (11m38s), with other shards near 10m36s. The longest script, `tests/fm-watch-triage.test.sh`, legitimately occupies one whole shard and is the indivisible floor for this layout. This is a packing estimate, not measured new-workflow execution or an end-to-end latency guarantee. -Existing job timeouts remain hang tripwires; they are not the desired healthy duration. +Job timeouts remain hang tripwires under the policy in [Timeouts](#timeouts) below; they are not the desired healthy duration. `tests/fm-ci-workflow.test.sh` compares the parsed CI matrix to the executable runner lanes, and the runner rejects parallel `--jobs` on a serial lane even when that shard has only one member. Refresh the CI-derived hints by downloading the per-shard timing artifacts from several green CI runs and replacing the `portable_serial_weight_hints` table in `bin/fm-test-run.sh` with the slowest measured `duration_ms` per `path`: @@ -124,11 +124,16 @@ The workflow retains per-PR supersession without cancelling main pushes or chang ## Timeouts -| Lane | Bound | Rationale | -|---|---|---| -| portable parallel 1/2 | See [CI workflow](../.github/workflows/ci.yml) | The workflow owns the parallel cap rationale and its evidence limits. | -| portable serial shards | See [CI workflow](../.github/workflows/ci.yml) | Packing estimates are not healthy execution bounds; the existing cap remains a hang tripwire. | -| Herdr | family-run step `timeout-minutes: 20`; job `timeout-minutes: 75` backstop | Healthy runs finished around 7 minutes before this lane gained `fm-backend-herdr-focus-flash-e2e`, which measures about 2 minutes against a real lab locally, so the step bound is still the hang tripwire (cleanup and timing artifacts still upload) while the job cap stays a last-resort backstop. Refresh this figure from the lane's uploaded timing artifact. | +CI job timeouts follow one three-tier policy, so the workflow reads as a policy rather than as a collection of per-job numbers. +Every tier is a hang tripwire with headroom above the healthy duration, never a packing estimate or a runtime target. +A lane that reaches its tier bound is wedged, not slow, so change the policy here rather than treating the bound as a way to fit a slower lane. -Timeouts are intended as hang tripwires; a passing coverage guard does not establish a healthy job duration. -`.github/workflows/ci.yml` owns the exact numbers. +| Tier | Jobs | Bound | Rationale | +|---|---|---|---| +| Fast | coverage guard, repo invariants, timing aggregate | 5 minutes | Seconds-long local work, so the tripwire only catches a hung runner. | +| Normal | lint partitions, portable parallel shards, portable serial shards, macOS stock Bash | 30 minutes, one value shared by every job in the tier | One shared hang tripwire keeps every ordinary test and lint lane on the same policy instead of allowing per-lane packing estimates or one-off caps to set the bound. | +| Heavy | Herdr | family-run step 20 minutes under a 75-minute job-level last-resort backstop | Healthy runs finish in about 7-10 minutes, so the step tripwire fails a wedged suite while the `always()` cleanup and timing upload still run, and the job cap only catches a hang outside that step. | + +[`.github/workflows/ci.yml`](../.github/workflows/ci.yml) holds the executable values and names each job's tier beside its `timeout-minutes`. +[`tests/fm-ci-workflow.test.sh`](../tests/fm-ci-workflow.test.sh) holds the policy against the parsed workflow: every job belongs to exactly one tier, the workflow carries exactly three distinct job-level values, the fast tier stays within 5-10 minutes, the normal jobs share one 30-minute budget, and the Herdr family-run step is the 20-minute tripwire below its job backstop with an `always()` teardown after it. +A passing coverage guard does not establish a healthy job duration; refresh the healthy figures above from the lanes' uploaded timing artifacts. diff --git a/docs/pi-supervision-branch.md b/docs/pi-supervision-branch.md index db563def487..97b354a56dc 100644 --- a/docs/pi-supervision-branch.md +++ b/docs/pi-supervision-branch.md @@ -9,7 +9,8 @@ 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. -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. +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). +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: @@ -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, 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. `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/scripts.md b/docs/scripts.md index 5b8ceb56d3e..ef45d68dfa2 100644 --- a/docs/scripts.md +++ b/docs/scripts.md @@ -44,7 +44,7 @@ The shared no-mistakes gate refusal for fleet lifecycle entrypoints is summarize | `fm-ensure-agents-md.sh` | Ensure a project's real `AGENTS.md`, its `CLAUDE.md` `@AGENTS.md` pointer, and self-governance guidance (explicit project mark documented in the helper's header and help) | | `fm-guard.sh` | Warn on primary-checkout tangles, main-session pending wakes, and unhealthy supervision | | `fm-primary-scope-lib.sh` | Shared marker-or-plain-checkout primary-home predicate for tracked hooks | -| `fm-session-lock-lib.sh` | Shared session-lock harness identity (ancestry walk and holder liveness) for fm-lock.sh and the Claude Stop auto-arm | +| `fm-session-lock-lib.sh` | Shared session-lock ownership from harness ancestry or a trusted Claude session id for fm-lock.sh and the Claude Stop auto-arm | | `fm-claude-stop-autoarm.sh` | Claude Stop `asyncRewake` hook owning tokenless watcher continuity with single-flight exit-2 rewake (docs/watcher-continuity.md) | | `fm-turnend-guard.sh` | Shared primary turn-end guard predicate so no turn ends blind (docs/turnend-guard.md) | | `fm-turnend-guard-grok.sh` | Grok Stop-hook adapter for the primary turn-end guard | diff --git a/docs/sessionstart-nudge.md b/docs/sessionstart-nudge.md index 17d44c93c44..11018891ec1 100644 --- a/docs/sessionstart-nudge.md +++ b/docs/sessionstart-nudge.md @@ -33,8 +33,9 @@ Compaction is covered where a tracked adapter delivers that source because a com Current harness ownership of the lock and its matching `state/.session-start-complete` record together are the idempotency interlock for the whole scheme. The full digest clears that completion record after acquiring the lock and republishes the lock owner's pid only after every stage completes, so `clear` or `compact` cannot skip startup sweeps after a truncated run. -`bin/fm-lock.sh` already treats a lock this session's own harness holds as its own, so a proven `clear` or `compact` re-emit re-verifies ownership and proceeds, while a lock another live session took meanwhile still produces the ordinary read-only digest. -On a run-tier harness the nudge cannot also fire: `resume`, `reload`, and `fork` are the only sources routed to it, and on those its own ancestry check stays silent whenever this process already holds the lock. +`bin/fm-lock.sh` treats a lock owned through either the shared ancestry verdict or a trusted same-session Claude id as this session's own, so a proven `clear` or `compact` re-emit re-verifies ownership and proceeds, while a lock another live session took meanwhile still produces the ordinary read-only digest. +On a run-tier harness only `resume`, `reload`, and `fork` are routed to the nudge wrapper, whose separate ancestry-only check normally stays silent when this process already holds the lock. +After a background Claude helper-chain recycle breaks that ancestry, the wrapper may emit a redundant nudge even though the shared same-session verdict still owns the lock; the requested session start remains idempotent. `bin/fm-session-start.sh --reemit` owns which work a re-emit skips, its true-start AGENTS.md baseline, and its supported stale-instruction refresh pairs; its header is the single owner of those mechanics. @@ -59,7 +60,7 @@ The Guard Predicates section of [`turnend-guard.md`](turnend-guard.md#guard-pred The nudge payload starts with U+2063 and the stable `FIRSTMATE_OP: ` label, carries the current `session-start` protocol kind, and retains exactly ``Run `bin/fm-session-start.sh` now, exactly once, before executing any other instructions.`` as its body. The Ahoy skill owns the rule that this marked operational input is never a captain-authored session boundary, including its narrow legacy compatibility cases, and its own step 0 helm check is the fallback that protects a nudge-tier harness whose first command is a skill. -Before printing, the nudge wrapper reads `state/.lock` and walks at most eight parents from its own pid in its own separate, hard-coded loop, independent of `bin/fm-lock.sh`'s ancestry walk (`fm_harness_ancestry_pid()` in `bin/fm-session-lock-lib.sh`, which now walks up to sixteen parents and can extend past a claude-named match to a still-more-ancestral one) and of Pi's `lockOwnership()`. +Before printing, the nudge wrapper reads `state/.lock` and walks at most eight parents from its own pid in its own separate, hard-coded loop, independent of the shared sixteen-hop ancestry walk in `bin/fm-session-lock-lib.sh` that `bin/fm-lock.sh` uses for anchor selection and ownership, and independent of Pi's `lockOwnership()`. If the lock names a live pid in that ancestry, session start already ran in this harness session and the wrapper stays silent. Every ordinary transport path in both wrappers exits 0, including malformed state and adapter errors, because a Claude SessionStart exit 2 blocks session initialization. The run wrapper's internal `--pi-prerequisite` mode uses silent exit 3 only for an intentional gate or scope stand-down, letting Pi distinguish ineligibility from an eligible empty native result without changing any harness hook's exit contract. diff --git a/docs/supervision-protocols/pi.md b/docs/supervision-protocols/pi.md index 51cb1f9be86..f9142b7f755 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,17 +19,18 @@ 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. +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. 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/turnend-guard.md b/docs/turnend-guard.md index a7426b31c2c..c932eacf6ac 100644 --- a/docs/turnend-guard.md +++ b/docs/turnend-guard.md @@ -34,9 +34,11 @@ Every mode treats `state/x-watch.check.sh` as supervision need, so Relay polling A custom check registered with `bin/fm-check-register.sh` counts the same way, so an operator's home-level poll keeps running after the last task is torn down. Otherwise it calls `fm_watcher_healthy [grace-seconds] [home]` from `bin/fm-wake-lib.sh`, the same PID-strict identity-matched lock and fresh-beacon check used by `bin/fm-watch-arm.sh`: a stale beacon blocks even when a watcher pid is live, and a fresh leftover beacon blocks when the lock is missing, dead, or identity-mismatched. The turn-end guard needs that strict check because it fires at the turn boundary, where the auto-arm is bringing a fresh watcher up for the upcoming idle period, and it cooperates with that arm rather than trusting a beacon left by the cycle that just ended. -When an active home instead has a live session lock held by a verified harness outside the current session's contiguous ancestry, the Claude guard emits a read-only ownership diagnostic and allows the turn to end safely. +When an active home instead has a live session lock held by a verified harness that the current session does not own, the Claude guard emits a read-only ownership diagnostic and allows the turn to end safely. +Ownership is the shared `fm_session_lock_owned_by_self` verdict in `bin/fm-session-lock-lib.sh`: the recorded pid is a member of the current session's contiguous harness ancestry, or the trusted Claude session id recorded beside the lock in `state/.lock-session` matches this hook's own environment while the recorded pid is still a live harness. +That second signal keeps a background Claude session owning its own lock after the transient helper chain between its hooks and its recorded owner is recycled; the library's header owns the trust gate (`CLAUDE_PID` must be a Claude-shaped member of the current run) and `bin/fm-lock.sh` owns the sidecar and the line-1 anchor it records for such a session. That Claude session cannot arm or repair the home without stealing the live owner's lock, so blocking it would create an unbounded loop; the lock-owning session remains responsible for restoring supervision. -Malformed, absent, dead, or ancestry-uncertain lock records do not satisfy this Claude-specific exception and retain the ordinary guard behavior. +Malformed, absent, dead, or ancestry-uncertain lock records do not satisfy this Claude-specific exception and retain the ordinary guard behavior, and a missing or mismatched sidecar or an untrusted id adds nothing to the verdict, so a live owner outside the ancestry still takes this exit exactly as before. `bin/fm-guard.sh`, the pull warning, instead uses the model-aware `fm_watcher_supervision_verdict` from the same library, because it fires mid-turn when the auto-arm model runs no watcher at all. Under the Claude Stop auto-arm model a beacon fresh within grace is healthy even with no live watcher process. A stale beacon is still healthy while `fm_autoarm_midturn_healthy` in `bin/fm-wake-lib.sh` proves a Claude rewake explains the mid-turn gap: the rewake is bound to the current recovery generation and live session-lock owner, and no later watcher beacon or exhausted-failure marker supersedes it, because that session's turn-end will re-arm. diff --git a/docs/verification/runtime-backends.md b/docs/verification/runtime-backends.md index f870d561b89..2ec6d91f5b7 100644 --- a/docs/verification/runtime-backends.md +++ b/docs/verification/runtime-backends.md @@ -2003,6 +2003,36 @@ 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 - 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 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. + ## 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/docs/verification/supervision.md b/docs/verification/supervision.md index cfddd97c29a..6e5198fa2ab 100644 --- a/docs/verification/supervision.md +++ b/docs/verification/supervision.md @@ -323,9 +323,34 @@ That inertness result is scoped to the builds it exercised: it did not establish The secondmate-home scope and manual-repair wake path were measured with Claude Code 2.1.207 on 2026-07-12, when a native background completion re-invoked the idle model with no human input. The current Stop-owned main/secondmate inclusion and child-worktree exclusion are covered deterministically by `tests/fm-claude-stop-autoarm.test.sh`. -Session-lock ownership in `bin/fm-session-lock-lib.sh` is decided against a session's whole contiguous harness ancestry rather than one chosen pid, so the Stop auto-arm reaches its lock owner wherever that owner sits: the outermost pid of Claude Code's multi-level `bg-spare` hook worker chain, or an inner pid when a harness-named daemon parents the session. +Session-lock ownership in `bin/fm-session-lock-lib.sh` is decided against a session's whole contiguous harness ancestry rather than one chosen pid, so the Stop auto-arm reaches its lock owner wherever that owner sits: a pid of Claude Code's multi-level `bg-spare` hook worker chain, or an inner pid when a harness-named daemon parents the session. +A background Claude session whose transient helper chain is recycled loses that contiguity while its recorded owner stays alive, so the library also accepts a trusted same-session id: `CLAUDE_CODE_SESSION_ID` counts only when `CLAUDE_PID` is a Claude-shaped member of the current run, it must equal the id `bin/fm-lock.sh` recorded in `state/.lock-session`, and the recorded pid must still be a live harness, while every weaker combination (no id, no sidecar, an untrusted id, a different id, a dead recorded pid) leaves the ancestry verdict unchanged. +For such a session `bin/fm-lock.sh` records `CLAUDE_PID` on lock line 1 instead of the outermost chain pid, so a shared daemon or front-end that outlives the session never keeps a dead session's lock alive, and a same-session confirmation never rewrites a live line 1. Harness identity is read from the executable path and `argv[0]` as well as the command basename, because Claude Code's native installer names the per-session executable by its version (`.../share/claude/versions/2.1.220`): `ps -o comm=` reports that path on macOS and the bare version string on Linux, and neither basename names a harness. `tests/fm-session-lock-ancestry.test.sh` pins both platforms' reporting semantics behind a deterministic process table and runs the real Stop auto-arm in version-named, daemon-parented, and combined real process trees. +The same suite drives the ancestry and session-id signals apart in that table, asserting the divergence itself so no case is vacuous, and runs a real orphaned front-end, daemon, pty-host, and bg-spare tree whose daemon is ended mid-run: the same id keeps arming through the real `bin/fm-lock.sh`, `bin/fm-claude-stop-autoarm.sh`, and `bin/fm-turnend-guard.sh --claude` with lock line 1 and the sidecar untouched, a different id, an untrusted id, and no id each keep the live-owner refusal naming the recorded id, and the dead front-end is reclaimed onto the spare's pid rather than the outermost pty-host. +`tests/fm-turnend-foreign-owner-repro.py` keeps the genuinely foreign live owner as the negative control and adds the same-id positive control. +Both ran on 2026-09-18 on macOS with bash 3.2.57 as the fake harness interpreter: + +```sh +tests/fm-session-lock-ancestry.test.sh +tests/fm-turnend-foreign-owner-arm-fix.test.sh +``` + +Observed output, bounded to the lines the new coverage adds: + +```text +ok - session-lock: a trusted same-session id keeps owning a recycled background chain, and nothing weaker does +ok - session-lock: a trusted id anchors the lock on the model-loop process, anything else on the outermost pid +ok - session-lock e2e: a background session keeps its lock and its supervision across a recycled helper chain +same-session acquisition rc=0 stdout='lock acquired: harness pid 41994\nlock_rc=0\n' stderr='' +other-session acquisition rc=0 stdout='lock_rc=1\n' stderr='error: another live firstmate session holds the lock (pid 41994, session synthetic-same); operate read-only until resolved\n' +FIXED same-session id owns the lock; a different id is still foreign +COMPLETE +``` + +No live unattended Claude background session ran on the verifying machine: that topology is documented by the real process listings in issues #3902, #2314, #3398, and #4066, and the coverage above is the structural predicate plus those executable fixtures, not a live pass. +[`sessionstart-nudge.md`](../sessionstart-nudge.md#shared-wrapper-and-safety) owns the nudge wrapper's separate ancestry check and its redundant-nudge behavior after helper-chain recycling. `tests/fm-watch-arm.test.sh` runs real watcher and arm cycles against durable on-disk state to verify that a delivered reason survives until post-handling acknowledgement and stops replaying after acknowledgement, while an unrelated queue append cannot make a watcher cycle that delivered nothing look successful. The same suite ingests a keyed remote-secondmate parent reply through the real adapter, establishes the incremental OPEN DECISIONS cursor, interrupts supervision, and proves re-arm replays every unacknowledged queue row plus the still-open decision through the ordinary drain path. It also covers decision-only recovery, interrupted handling, handling-window generation reuse, non-fatal moved-generation acknowledgement with sequence-bounded consumption, and a persistent successor remaining live after recovery is acknowledged. diff --git a/docs/watcher-continuity.md b/docs/watcher-continuity.md index 009777636c9..9f79edf94cd 100644 --- a/docs/watcher-continuity.md +++ b/docs/watcher-continuity.md @@ -14,8 +14,9 @@ omp's replacement follows the same generation-owner contract in `.omp/extensions Cursor's `.cursor/hooks.json` `stop` hook (`bin/fm-turnend-guard-cursor.sh`) owns routine tokenless re-arm for a Cursor primary by parking that awaited hook on `bin/fm-watch-arm.sh` and returning an actionable close as one follow-up; [`turnend-guard.md`](turnend-guard.md#harness-integrations) owns its Pi-host stand-down, loop bounds, and supersession baton. Claude's `.claude/settings.json` Stop `asyncRewake` hook (`bin/fm-claude-stop-autoarm.sh`) owns routine tokenless re-arm. The hook fires on every Stop, and an eligible primary with supervision need admits one home-scoped owner that foregrounds `bin/fm-watch-arm.sh` inside the hook-owned process tree. -A numeric session-lock owner that fails the shared `fm_harness_pid_alive` predicate is reclaimed through `bin/fm-lock.sh` before auto-arm state changes, while a live owner, absent lock, or malformed lock keeps the competing hook inert. -[`turnend-guard.md`](turnend-guard.md#guard-predicates) owns the Claude guard's behavior when that live owner is outside the current session's harness ancestry. +A numeric session-lock owner that fails the shared `fm_harness_pid_alive` predicate is reclaimed through `bin/fm-lock.sh` before auto-arm state changes, while a live owner the session does not own, an absent lock, or a malformed lock keeps the competing hook inert. +Whether the session owns that lock is the shared `fm_session_lock_owned_by_self` verdict in `bin/fm-session-lock-lib.sh`, which accepts a recorded pid inside the current harness ancestry or a live lock recorded under this same trusted Claude session id, so a background session keeps arming after its transient helper chain is recycled. +[`turnend-guard.md`](turnend-guard.md#guard-predicates) owns the Claude guard's behavior when that live owner is genuinely another session. The stale-owner claim occurs only after the existing AFK and supervision-need gates pass. After each non-actionable arm close, the hook rechecks the identity-matched watcher lock and fresh beacon before retrying a bounded number of times. A cycle-end failure is benign when that live-watcher predicate is true, and the hook suppresses the arm output and continues silently. diff --git a/tests/fm-branch-supervision.test.sh b/tests/fm-branch-supervision.test.sh index 5771cb8a2bc..6dd79583271 100644 --- a/tests/fm-branch-supervision.test.sh +++ b/tests/fm-branch-supervision.test.sh @@ -837,6 +837,263 @@ 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, 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 \ + "$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_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 \ + "$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" + + 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) + 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" + 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" +} + +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' +## In flight +- [ ] task-inflight - orphaned in-flight work + +## 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" 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=$? + [ "$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" + + 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 +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" \ + "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" + 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 i + home="$TMP_ROOT/away-cap-lock-home" + root="$TMP_ROOT/away-cap-lock-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 + 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" + + FM_HOME="$home" FM_ROOT_OVERRIDE="$root" \ + "$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 + 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=$(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" +} + 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 +1114,6 @@ 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 +test_away_branch_spawn_requires_queued_dispatchable_work +test_away_spend_cap_is_rechecked_under_the_task_set_lock diff --git a/tests/fm-ci-workflow.test.sh b/tests/fm-ci-workflow.test.sh index 78795eebe4b..1fdcbd67821 100755 --- a/tests/fm-ci-workflow.test.sh +++ b/tests/fm-ci-workflow.test.sh @@ -5,7 +5,9 @@ # concurrency deduplication, so every superseded PR head kept its full job # fan-out, and four jobs carried no timeout at all. These tests hold both # safeguards: PR runs supersede within one PR while main pushes are never -# cancelled, and every CI job carries a finite hang tripwire. +# cancelled, and every CI job carries a finite hang tripwire drawn from the +# three-tier timeout policy that docs/fm-test-portable-shards.md "Timeouts" +# owns (fast, normal, heavy), so no job drifts back to a one-off number. # # The workflow is parsed as YAML and its concurrency expressions are resolved # against simulated pull_request and push contexts, so the assertions describe @@ -68,6 +70,35 @@ puts YAML.load_file(ARGV[0]).fetch("jobs").fetch(ARGV[1]).fetch("timeout-minutes ' "$CI_WORKFLOW" "$1" } +# Tier membership is the executable inventory of the timeout policy: a new job +# must join a tier, and a job-level value outside these tiers is exactly the +# one-off number the policy removed. +FAST_TIER_JOBS='test-coverage invariants tests-timing-aggregate' +NORMAL_TIER_JOBS='lint tests-portable-parallel-1 tests-portable-parallel-2 tests-portable-serial macos-stock-bash' +HEAVY_TIER_JOBS='tests-herdr' + +# Print the one timeout every listed job shares; fail on any disagreement. +tier_timeout() { # ... + local tier=$1 job first actual + shift + first= + for job in "$@"; do + actual=$(job_timeout "$job") || fail "could not read the $job timeout" + case "$actual" in ''|*[!0-9]*) fail "$job ($tier tier) has no integer timeout, got $actual" ;; esac + if [ -z "$first" ]; then + first=$actual + elif [ "$actual" != "$first" ]; then + fail "$tier tier jobs must share one timeout, got $first and $actual ($job)" + fi + done + printf '%s\n' "$first" +} + +# Print every job id in the workflow, one per line. +workflow_jobs() { + ruby -ryaml -e 'puts YAML.load_file(ARGV[0]).fetch("jobs").keys' "$CI_WORKFLOW" +} + group_of() { printf '%s\n' "$1" | cut -f1; } cancel_of() { printf '%s\n' "$1" | cut -f2; } @@ -115,40 +146,77 @@ end pass "every ci.yml job carries a finite timeout" } -# The four jobs the incident found unbounded, at the report's recommended caps. -test_previously_unbounded_jobs_keep_their_caps() { - local job expected actual - while read -r job expected; do - [ -n "$job" ] || continue - actual=$(job_timeout "$job") || fail "could not read the $job timeout" - [ "$actual" = "$expected" ] \ - || fail "$job timeout must stay $expected minutes, got $actual" - done <<'CAPS' -lint 25 -test-coverage 5 -tests-timing-aggregate 5 -invariants 5 -CAPS - pass "the incident's unbounded jobs keep their recommended caps" +# Every job sits in exactly one tier, and the workflow carries exactly three +# distinct job-level timeouts: one per tier, no one-off numbers. +test_every_job_belongs_to_exactly_one_timeout_tier() { + local expected actual distinct + # shellcheck disable=SC2086 + expected=$(printf '%s\n' $FAST_TIER_JOBS $NORMAL_TIER_JOBS $HEAVY_TIER_JOBS | LC_ALL=C sort) + [ "$(printf '%s\n' "$expected" | LC_ALL=C sort -u)" = "$expected" ] \ + || fail "a job is listed in more than one timeout tier:"$'\n'"$expected" + actual=$(workflow_jobs | LC_ALL=C sort) || fail "could not list ci.yml jobs" + [ "$actual" = "$expected" ] \ + || fail "ci.yml jobs and the timeout tiers disagree; every job must join one tier"$'\n'"workflow: $(printf '%s' "$actual" | tr '\n' ' ')"$'\n'"tiers: $(printf '%s' "$expected" | tr '\n' ' ')" + distinct=$(for job in $expected; do job_timeout "$job"; done | LC_ALL=C sort -u | wc -l | tr -d ' ') + [ "$distinct" = 3 ] \ + || fail "ci.yml must carry exactly three distinct job timeouts (fast, normal, heavy), got $distinct" + pass "every ci.yml job belongs to one of the three timeout tiers" } -# Cancellation makes an undersized cap costlier: a falsely tripped job now also -# discards a run nobody replaced. These bounds were measured, not guessed. -test_measured_lanes_keep_their_existing_bounds() { - local job expected actual - while read -r job expected; do - [ -n "$job" ] || continue - actual=$(job_timeout "$job") || fail "could not read the $job timeout" - [ "$actual" = "$expected" ] \ - || fail "$job timeout must stay $expected minutes, got $actual" - done <<'CAPS' -tests-portable-parallel-1 10 -tests-portable-parallel-2 10 -tests-portable-serial 30 -tests-herdr 75 -macos-stock-bash 10 -CAPS - pass "the already-measured lane bounds are unchanged" +# Fast tier: seconds-long checks share one short tripwire in the 5-10 minute band. +test_fast_tier_shares_one_short_tripwire() { + local fast + # shellcheck disable=SC2086 + fast=$(tier_timeout fast $FAST_TIER_JOBS) || exit 1 + [ "$fast" -ge 5 ] && [ "$fast" -le 10 ] \ + || fail "fast tier must be a 5-10 minute hang tripwire, got $fast" + pass "fast tier jobs share one $fast minute tripwire" +} + +# Normal tier: every test or lint lane shares ONE fixed 30-minute budget, +# above the fast tier. That budget is a hang tripwire, not a packing estimate. +test_normal_tier_shares_one_budget() { + local fast normal + # shellcheck disable=SC2086 + fast=$(tier_timeout fast $FAST_TIER_JOBS) || exit 1 + # shellcheck disable=SC2086 + normal=$(tier_timeout normal $NORMAL_TIER_JOBS) || exit 1 + [ "$normal" -gt "$fast" ] \ + || fail "normal tier ($normal) must exceed the fast tier ($fast)" + [ "$normal" = 30 ] \ + || fail "normal tier must be the single 30-minute shared budget, got $normal" + pass "normal tier jobs share one $normal minute budget" +} + +# Heavy tier: Herdr alone carries a job-level last-resort backstop above the +# normal tier, while its family-run step owns a tighter tripwire so the +# always() cleanup and timing upload still run after a hang. +test_heavy_tier_keeps_a_step_tripwire_under_a_job_backstop() { + local normal heavy step + # shellcheck disable=SC2086 + normal=$(tier_timeout normal $NORMAL_TIER_JOBS) || exit 1 + # shellcheck disable=SC2086 + heavy=$(tier_timeout heavy $HEAVY_TIER_JOBS) || exit 1 + [ "$heavy" -gt "$normal" ] \ + || fail "heavy tier backstop ($heavy) must exceed the normal tier ($normal)" + [ "$heavy" -ge 60 ] && [ "$heavy" -le 75 ] \ + || fail "heavy tier backstop must stay a 60-75 minute last resort, got $heavy" + step=$(ruby -ryaml -e ' +steps = YAML.load_file(ARGV[0]).fetch("jobs").fetch(ARGV[1]).fetch("steps") +index = steps.index { |s| s["id"] == "run-real-herdr-family" } +raise "no run-real-herdr-family step" unless index +teardown = steps.index { |s| s["id"] == "cleanup-herdr-lab-sessions" } +raise "no cleanup-herdr-lab-sessions step" unless teardown +raise "teardown must follow the family-run step" unless teardown > index +raise "teardown must run under always()" unless steps[teardown]["if"].to_s.strip == "always()" +puts steps[index].fetch("timeout-minutes", "none") +' "$CI_WORKFLOW" tests-herdr) || fail "could not read the Herdr family-run step" + case "$step" in ''|*[!0-9]*) fail "the Herdr family-run step needs its own timeout-minutes, got $step" ;; esac + [ "$step" = 20 ] \ + || fail "the Herdr family-run step must be the 20-minute tripwire, got $step" + [ "$step" -lt "$heavy" ] \ + || fail "the Herdr step tripwire ($step) must stay below the job backstop ($heavy)" + pass "Herdr keeps a $step minute step tripwire under a $heavy minute job backstop" } test_ci_matrices_match_executable_partitions() { @@ -186,5 +254,7 @@ test_pr_pushes_supersede_within_one_pr test_separate_prs_do_not_cancel_each_other test_main_pushes_are_never_cancelled test_every_job_has_a_finite_timeout -test_previously_unbounded_jobs_keep_their_caps -test_measured_lanes_keep_their_existing_bounds +test_every_job_belongs_to_exactly_one_timeout_tier +test_fast_tier_shares_one_short_tripwire +test_normal_tier_shares_one_budget +test_heavy_tier_keeps_a_step_tripwire_under_a_job_backstop diff --git a/tests/fm-pi-branch-extension.test.sh b/tests/fm-pi-branch-extension.test.sh index 812a55eade3..06ccf8eb93c 100644 --- a/tests/fm-pi-branch-extension.test.sh +++ b/tests/fm-pi-branch-extension.test.sh @@ -600,14 +600,17 @@ 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) { +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()) { @@ -1587,17 +1590,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 +1630,344 @@ 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 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; +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.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; } }; +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 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 (!streamingResult?.messages?.some((message) => message.role === "user")) { + throw new Error("streaming 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)}`); +} +const history = [ + { role: "user", content: "earlier captain request" }, + { role: "assistant", content: "earlier firstmate reply" }, +]; +aborted = false; +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)}`); +} +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", {}); +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")) { + throw new Error(`dedicated processing with history was not stripped: ${JSON.stringify(openedByRequest)}`); +} +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()}`); + +// 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_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, approvedProject }; })()`); +const { fire, home, realRoot, bus, makeOffer, mainUserMessages, approvedProject } = 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"); +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"); +const failure = await offer.settlement.then(() => null, (error) => error); +if (!(failure instanceof Error) || !failure.message.includes("no longer branch-eligible")) { + 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)}`); +} +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 after archive, while a drained task-local wake stays a quiet no-op" +} + +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() { @@ -4936,6 +5278,9 @@ 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_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 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..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 ;; @@ -2768,6 +2772,111 @@ 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" +} + +# 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 @@ -3099,6 +3208,8 @@ 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_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 diff --git a/tests/fm-send-resolve-key.test.sh b/tests/fm-send-resolve-key.test.sh index 149f8ae8ee9..9a83f9f6983 100755 --- a/tests/fm-send-resolve-key.test.sh +++ b/tests/fm-send-resolve-key.test.sh @@ -791,6 +791,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 @@ -811,4 +874,5 @@ 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 test_secondmate_helper_keyed_report_then_resolve_key diff --git a/tests/fm-session-lock-ancestry.test.sh b/tests/fm-session-lock-ancestry.test.sh index dbf1e683f77..381acd6ae85 100755 --- a/tests/fm-session-lock-ancestry.test.sh +++ b/tests/fm-session-lock-ancestry.test.sh @@ -35,12 +35,19 @@ NAMED_CLAUDE="$FAKEBIN/claude" # --- unit layer: identity behind a deterministic process table --------------- # Run one library expression with shadowing ps. kill is stubbed so -# liveness questions are decided by the process table alone. +# liveness questions are decided by the process table alone (FM_TEST_KILL_RC=1 +# makes every pid dead). The suite itself may run inside a Claude session whose +# CLAUDE_CODE_SESSION_ID and CLAUDE_PID would leak into the expression, so both +# are scrubbed and only FM_TEST_SESSION_ID and FM_TEST_CLAUDE_PID reach it. lib_eval() { # local fakebin=$1 expr=$2 - PATH="$fakebin:$PATH" bash -c " + local -a session_env=() + [ -z "${FM_TEST_SESSION_ID:-}" ] || session_env+=("CLAUDE_CODE_SESSION_ID=$FM_TEST_SESSION_ID") + [ -z "${FM_TEST_CLAUDE_PID:-}" ] || session_env+=("CLAUDE_PID=$FM_TEST_CLAUDE_PID") + env -u CLAUDE_CODE_SESSION_ID -u CLAUDE_PID ${session_env[@]+"${session_env[@]}"} \ + PATH="$fakebin:$PATH" bash -c " . \"\$0\" - kill() { return 0; } + kill() { return \${FM_TEST_KILL_RC:-0}; } $expr " "$LIB" } @@ -266,6 +273,160 @@ SH pass "session-lock: a live version-named session holding the lock is not mistaken for a stale owner" } +# A background Claude session's process table. The hook fires inside +# `claude bg-spare` (710), whose parent is `claude bg-pty-host` (720). With the +# transient daemon gone the pty-host is reparented to launchd, so the contiguous +# claude-named run from the hook ends at 720 and the live front-end 700 that +# holds the lock is no longer an ancestor at all. FM_TEST_DAEMON_PRESENT=1 puts +# the daemon (730) back between 720 and 700: the healthy topology. +write_background_session_ps() { # + cat > "$1/ps" <<'SH' +#!/usr/bin/env bash +set -u +field= pid= +while [ "$#" -gt 0 ]; do + case "$1" in + -o) field=$2; shift 2 ;; + -p) pid=$2; shift 2 ;; + *) shift ;; + esac +done +case "$pid:$field:${FM_TEST_DAEMON_PRESENT:-0}" in + 700:comm=:*) printf '%s\n' claude ;; + 700:args=:*) printf '%s\n' 'claude --resume' ;; + 700:ppid=:*) printf '%s\n' 1 ;; + 730:comm=:*) printf '%s\n' claude ;; + 730:args=:*) printf '%s\n' 'claude daemon run --origin transient' ;; + 730:ppid=:*) printf '%s\n' 700 ;; + 720:comm=:*) printf '%s\n' 'claude bg-pty-host' ;; + 720:args=:*) printf '%s\n' 'claude bg-pty-host /tmp/pty.sock 120 40 -- claude --bg-spare' ;; + 720:ppid=:1) printf '%s\n' 730 ;; + 720:ppid=:*) printf '%s\n' 1 ;; + 710:comm=:*) printf '%s\n' 'claude bg-spare' ;; + 710:args=:*) printf '%s\n' 'claude bg-spare /tmp/claim.sock' ;; + 710:ppid=:*) printf '%s\n' 720 ;; + *:comm=:*) printf '%s\n' bash ;; + *:args=:*) printf '%s\n' 'bash /repo/bin/fm-claude-stop-autoarm.sh' ;; + *:ppid=:*) printf '%s\n' 710 ;; +esac +SH + chmod +x "$1/ps" +} + +owned() { # + lib_eval "$1" "fm_session_lock_owned_by_self '$2'" +} + +foreign_owner() { # -> prints the foreign pid + lib_eval "$1" "fm_session_lock_foreign_owner_live '$2' && printf '%s' \"\$FM_SESSION_LOCK_FOREIGN_OWNER_PID\"" +} + +test_same_session_id_owns_a_recycled_background_chain() { + local dir fakebin state got + dir="$TMP_ROOT/background-session" + fakebin=$(fm_fakebin "$dir") + state="$dir/state" + mkdir -p "$state" + write_background_session_ps "$fakebin" + printf '700\n' > "$state/.lock" + printf 'S1\n' > "$state/.lock-session" + + # The divergence itself, so none of the verdicts below can be vacuous: with + # the daemon gone the front-end is not an ancestor, with it back it is. + if lib_eval "$fakebin" 'fm_harness_ancestry_pids' | grep -qx 700; then + fail "the recycled chain still reached the front-end, so the id cases would prove nothing" + fi + FM_TEST_DAEMON_PRESENT=1 lib_eval "$fakebin" 'fm_harness_ancestry_pids' | grep -qx 700 \ + || fail "the healthy chain did not reach the front-end" + + # 1. The session's own id from its model-loop process: owned, not foreign. + FM_TEST_SESSION_ID=S1 FM_TEST_CLAUDE_PID=710 owned "$fakebin" "$state" \ + || fail "the same session's trusted id did not own the lock after the helper chain was recycled" + if FM_TEST_SESSION_ID=S1 FM_TEST_CLAUDE_PID=710 foreign_owner "$fakebin" "$state" >/dev/null; then + fail "the session's own live front-end was reported as a foreign owner despite the matching id" + fi + # 2. A different id: the existing refusal, naming the live owner. + if FM_TEST_SESSION_ID=S2 FM_TEST_CLAUDE_PID=710 owned "$fakebin" "$state"; then + fail "a different session id claimed a live owner's lock" + fi + got=$(FM_TEST_SESSION_ID=S2 FM_TEST_CLAUDE_PID=710 foreign_owner "$fakebin" "$state") \ + || fail "a different session id did not see the live owner as foreign" + [ "$got" = 700 ] || fail "the foreign owner pid was '$got', expected 700" + # 3. The trust gate: the right id carried by a CLAUDE_PID outside the run. + if FM_TEST_SESSION_ID=S1 FM_TEST_CLAUDE_PID=700 owned "$fakebin" "$state"; then + fail "an id whose CLAUDE_PID is outside the current Claude run was trusted" + fi + FM_TEST_SESSION_ID=S1 FM_TEST_CLAUDE_PID=700 foreign_owner "$fakebin" "$state" >/dev/null \ + || fail "an untrusted id suppressed the foreign-owner verdict" + printf 'S1:x\n' > "$state/.lock-session" + FM_TEST_SESSION_ID='S1:x' FM_TEST_CLAUDE_PID=710 owned "$fakebin" "$state" \ + || fail "a trusted id containing a colon did not own the lock" + if FM_TEST_SESSION_ID='S1:x' FM_TEST_CLAUDE_PID=710 foreign_owner "$fakebin" "$state" >/dev/null; then + fail "a matching id containing a colon was reported as a foreign owner" + fi + printf 'S1\r' > "$state/.lock-session" + if FM_TEST_SESSION_ID=S1 FM_TEST_CLAUDE_PID=710 owned "$fakebin" "$state"; then + fail "a recorded id containing a carriage return was treated as a session id" + fi + FM_TEST_SESSION_ID=S1 FM_TEST_CLAUDE_PID=710 foreign_owner "$fakebin" "$state" >/dev/null \ + || fail "a carriage-return sidecar suppressed the foreign-owner verdict" + printf 'S1\n' > "$state/.lock-session" + # 4. No id at all: the legacy ancestry verdict, unchanged. + if owned "$fakebin" "$state"; then + fail "with no session id the recycled chain claimed the lock" + fi + foreign_owner "$fakebin" "$state" >/dev/null \ + || fail "with no session id the live owner was not reported as foreign" + # 5. The healthy chain owns by ancestry whatever the environment says. + FM_TEST_DAEMON_PRESENT=1 FM_TEST_SESSION_ID=S2 FM_TEST_CLAUDE_PID=710 owned "$fakebin" "$state" \ + || fail "ancestry membership lost to a different session id" + FM_TEST_DAEMON_PRESENT=1 owned "$fakebin" "$state" \ + || fail "ancestry membership lost with no session id" + if FM_TEST_DAEMON_PRESENT=1 FM_TEST_SESSION_ID=S2 FM_TEST_CLAUDE_PID=710 foreign_owner "$fakebin" "$state" >/dev/null; then + fail "an ancestor was reported as a foreign owner" + fi + # 6. Never fail open: no sidecar, a symlinked sidecar, and a dead recorded pid + # are all ancestry-only, so the dead one is left for the ordinary reclaim. + rm -f "$state/.lock-session" + if FM_TEST_SESSION_ID=S1 FM_TEST_CLAUDE_PID=710 owned "$fakebin" "$state"; then + fail "a lock with no recorded session id was owned through the environment id" + fi + printf 'S1\n' > "$dir/elsewhere" + ln -s "$dir/elsewhere" "$state/.lock-session" + if FM_TEST_SESSION_ID=S1 FM_TEST_CLAUDE_PID=710 owned "$fakebin" "$state"; then + fail "a symlinked sidecar was trusted" + fi + rm -f "$state/.lock-session" + printf 'S1\n' > "$state/.lock-session" + if FM_TEST_KILL_RC=1 FM_TEST_SESSION_ID=S1 FM_TEST_CLAUDE_PID=710 owned "$fakebin" "$state"; then + fail "a same-session lock whose recorded pid is dead was owned instead of left for reclaim" + fi + pass "session-lock: a trusted same-session id keeps owning a recycled background chain, and nothing weaker does" +} + +test_anchor_pid_is_the_model_loop_process_only_for_a_trusted_id() { + local dir fakebin got + dir="$TMP_ROOT/background-anchor" + fakebin=$(fm_fakebin "$dir") + write_background_session_ps "$fakebin" + + got=$(FM_TEST_SESSION_ID=S1 FM_TEST_CLAUDE_PID=710 lib_eval "$fakebin" 'fm_session_lock_anchor_pid') \ + || fail "no anchor pid was resolved for a trusted id" + [ "$got" = 710 ] || fail "a trusted id anchored '$got', expected the model-loop process 710" + got=$(lib_eval "$fakebin" 'fm_session_lock_anchor_pid') || fail "no anchor pid was resolved without an id" + [ "$got" = 720 ] || fail "without an id the anchor was '$got', expected the outermost pid 720" + got=$(FM_TEST_SESSION_ID=S1 FM_TEST_CLAUDE_PID=700 lib_eval "$fakebin" 'fm_session_lock_anchor_pid') \ + || fail "no anchor pid was resolved for an untrusted id" + [ "$got" = 720 ] || fail "an untrusted id anchored '$got', expected the outermost pid 720" + got=$(FM_TEST_DAEMON_PRESENT=1 lib_eval "$fakebin" 'fm_session_lock_anchor_pid') \ + || fail "no anchor pid was resolved for the healthy chain" + [ "$got" = 700 ] || fail "the healthy chain without an id anchored '$got', expected the outermost pid 700" + got=$(FM_TEST_DAEMON_PRESENT=1 FM_TEST_SESSION_ID=S1 FM_TEST_CLAUDE_PID=710 lib_eval "$fakebin" 'fm_session_lock_anchor_pid') \ + || fail "no anchor pid was resolved for the healthy chain with a trusted id" + [ "$got" = 710 ] || fail "the healthy chain with a trusted id anchored '$got', expected 710 rather than the front-end" + pass "session-lock: a trusted id anchors the lock on the model-loop process, anything else on the outermost pid" +} + # --- end-to-end layer: the real Stop auto-arm in real process trees ---------- install_autoarm_scripts() { @@ -339,10 +500,12 @@ SH run_fixture_tree() { # [] local dir=$1 session_bin=$2 daemon_bin=${3:-} i if [ -n "$daemon_bin" ]; then - FM_HOME="$dir" FM_SESSION_BIN="$session_bin" FM_FIXTURE_ORPHAN_HERE=0 \ + env -u CLAUDE_CODE_SESSION_ID -u CLAUDE_PID \ + FM_HOME="$dir" FM_SESSION_BIN="$session_bin" FM_FIXTURE_ORPHAN_HERE=0 \ bash -c '"$0" "$1" &' "$daemon_bin" "$dir/daemon.sh" else - FM_HOME="$dir" FM_FIXTURE_ORPHAN_HERE=1 \ + env -u CLAUDE_CODE_SESSION_ID -u CLAUDE_PID \ + FM_HOME="$dir" FM_FIXTURE_ORPHAN_HERE=1 \ bash -c '"$0" "$1" &' "$session_bin" "$dir/session.sh" fi i=0 @@ -404,11 +567,543 @@ test_e2e_daemon_parented_version_named_session_keeps_its_lock() { pass "session-lock e2e: a version-named session under a harness-named daemon keeps its own lock" } +# --- end-to-end layer: a background session whose helper chain is recycled --- +# +# The topology the four issue reports (#3902, #2314, #3398, #4066) recorded with +# real process listings: a front-end that acquired the lock, a transient daemon +# under it, the pty-host the daemon spawned, and the bg-spare inside the pty-host +# that runs the model loop and therefore fires every hook. Every fixture process +# is the fake claude, so the ancestry walk sees a contiguous claude-named run +# exactly as in production, and the tree is orphaned before use. The daemon is +# then ended while the front-end stays alive - the recycling that breaks the run +# above the pty-host - and the spare fires the real Stop auto-arm, the real +# turn-end guard, and the real lock script once per phase under a chosen hook +# environment, recording every verdict for the assertions below. + +BG_FIXTURE_PIDS=() +reap_background_fixture() { + local pid + for pid in ${BG_FIXTURE_PIDS[@]+"${BG_FIXTURE_PIDS[@]}"}; do + kill -TERM "$pid" 2>/dev/null || true + done +} +trap 'reap_background_fixture; fm_test_cleanup' EXIT + +make_background_session_home() { # + local dir=$1 + mkdir -p "$dir/state" + git init -q "$dir" + git -C "$dir" commit -q --allow-empty -m init + : > "$dir/AGENTS.md" + : > "$dir/state/task.meta" + # The whole bin, because the real turn-end guard composes far more of it than + # the auto-arm alone; only the arm is replaced by the recording stub above. + cp -R "$ROOT/bin" "$dir/bin" + install_autoarm_scripts "$dir" + # Every fixture script ends in an explicit exit so bash can never tail-exec the + # script under test in place of the fake claude, which would collapse the + # chain the assertions depend on. + cat > "$dir/frontend.sh" <<'SH' +#!/usr/bin/env bash +i=0 +while [ "$i" -lt 200 ] && [ "$(ps -o ppid= -p $$ 2>/dev/null | tr -d ' ')" != 1 ]; do + sleep 0.05 + i=$((i + 1)) +done +printf '%s\n' "$$" > "$FM_HOME/state/frontend-pid" +CLAUDE_CODE_SESSION_ID=S1 CLAUDE_PID=$$ "$FM_HOME/bin/fm-lock.sh" > "$FM_HOME/state/frontend-lock.out" 2>&1 +printf '%s\n' "$?" > "$FM_HOME/state/frontend-lock.rc" +"$FM_FIXTURE_CLAUDE" "$FM_HOME/daemon.sh" & +disown +while [ ! -e "$FM_HOME/state/stop-frontend" ]; do sleep 0.05; done +exit 0 +SH + cat > "$dir/daemon.sh" <<'SH' +#!/usr/bin/env bash +printf '%s\n' "$$" > "$FM_HOME/state/daemon-pid" +exec -a 'claude bg-pty-host' "$FM_FIXTURE_CLAUDE" "$FM_HOME/ptyhost.sh" & +while :; do sleep 0.1; done +exit 0 +SH + cat > "$dir/ptyhost.sh" <<'SH' +#!/usr/bin/env bash +printf '%s\n' "$$" > "$FM_HOME/state/ptyhost-pid" +exec -a 'claude bg-spare' "$FM_FIXTURE_CLAUDE" "$FM_HOME/spare.sh" & +while [ ! -e "$FM_HOME/state/stop-spare" ]; do sleep 0.1; done +exit 0 +SH + cat > "$dir/spare.sh" <<'SH' +#!/usr/bin/env bash +printf '%s\n' "$$" > "$FM_HOME/state/spare-pid" +n=1 +while [ ! -e "$FM_HOME/state/stop-spare" ]; do + req="$FM_HOME/state/fire-$n" + if [ -f "$req" ]; then + out="$FM_HOME/state/phase-$n" + mkdir -p "$out" + unset CLAUDE_CODE_SESSION_ID CLAUDE_PID + # shellcheck disable=SC1090 + . "$req" + ( . "$FM_HOME/bin/fm-session-lock-lib.sh" && fm_harness_ancestry_pids ) > "$out/ancestry" 2>/dev/null + printf '%s\n' '{"session_id":"fixture","stop_hook_active":true}' \ + | "$FM_HOME/bin/fm-claude-stop-autoarm.sh" > "$out/hook.out" 2>&1 + printf '%s\n' "$?" > "$out/hook.rc" + printf '%s\n' '{"session_id":"fixture","stop_hook_active":true}' \ + | "$FM_HOME/bin/fm-turnend-guard.sh" --claude > "$out/guard.out" 2>&1 + printf '%s\n' "$?" > "$out/guard.rc" + "$FM_HOME/bin/fm-lock.sh" > "$out/lock.out" 2>&1 + printf '%s\n' "$?" > "$out/lock.rc" + cp "$FM_HOME/state/.lock" "$out/lock-after" + [ ! -e "$FM_HOME/state/.lock-session" ] || cp "$FM_HOME/state/.lock-session" "$out/session-after" + : > "$out/done" + n=$((n + 1)) + fi + sleep 0.05 +done +exit 0 +SH + chmod +x "$dir/frontend.sh" "$dir/daemon.sh" "$dir/ptyhost.sh" "$dir/spare.sh" +} + +wait_for_file() { # + local i=0 + while [ "$i" -lt 400 ] && [ ! -s "$1" ]; do + sleep 0.05 + i=$((i + 1)) + done + [ -s "$1" ] || fail "background-session fixture never produced $2" +} + +fire_phase() { # + local dir=$1 n=$2 + printf '%s\n' "$3" > "$dir/state/fire-$n.tmp" + mv "$dir/state/fire-$n.tmp" "$dir/state/fire-$n" + wait_for_file "$dir/state/phase-$n/hook.rc" "phase $n" + local i=0 + while [ "$i" -lt 400 ] && [ ! -e "$dir/state/phase-$n/done" ]; do + sleep 0.05 + i=$((i + 1)) + done + [ -e "$dir/state/phase-$n/done" ] || fail "background-session fixture never finished phase $n" +} + +phase_value() { # + tr -d '[:space:]' < "$1/state/phase-$2/$3" +} + +arm_count() { # + [ -e "$1/state/arm-ran" ] || { printf '0'; return; } + wc -l < "$1/state/arm-ran" | tr -d ' ' +} + +# The recycled chain must still be treated as the owner: arm, no diagnostic, +# lock accepted, line 1 untouched while the recorded pid lives, sidecar bytes +# untouched. +expect_phase_owned() { #