diff --git a/.agents/skills/afk/SKILL.md b/.agents/skills/afk/SKILL.md index d089ed9dc65..98b4d684782 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 records the captain's away words verbatim as the whole mandate, reads them back in plain sentences, 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 acts on the words by its own judgment and 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. + It writes the durable away-posture record with the captain's away words verbatim as the whole mandate in the same turn as /afk, before any other work and without waiting for a further go, reads the words back in plain sentences after entry, announces hold-for-return only at entry, keeps the one supervision session running in the away posture (on Pi the supervision branch acts on the words by its own judgment and 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 @@ -13,26 +13,21 @@ metadata: Away mode is a POSTURE of the one supervision session, not a second architecture. Being away changes exactly two things: how the captain is informed, and what happens at a captain-owned decision point (hold for return, or the answer the captain's away words already gave). It never changes the authority set. -The posture is a file, `state/.afk-contract`, written only by `bin/fm-afk-contract.sh` after the captain confirms a read-back; nothing infers the posture from chat. +The posture is a file, `state/.afk-contract`, written only by `bin/fm-afk-contract.sh` in the same turn as `/afk`; nothing infers the posture from chat. +Typing `/afk` is itself the go: the captain may not look at the screen again, so entry never waits for a further human response, and no read-back gates it or asks for a go. Hold-for-return is the default and the only reach profile this release records: there is no phone channel, and the entry announcement says so aloud every time. ## Entering: `/afk [words]` -1. **Record the captain's words, verbatim.** +1. **Write the record first, in this same turn.** + Before any other work, run `bin/fm-afk-launch.sh enter --words-file [--expected-return ] [--spend ]` (or `--words `). + It writes `state/.afk-contract` at once, with no separate confirmation step, then prints the entry announcement and the record's read-back. The words are the whole mandate: `bin/fm-afk-contract.sh` records them exactly as given, with no clause fields, verbs, ids, or merge-grant list, and by the captain's mandate no parser, tokenizer, classifier, or grammar reads them anywhere. Read `bin/fm-afk-contract.sh --help` for the flags rather than memorizing them. - Plain `/afk` with no words is a valid entry with no mandate. -2. **Propose and read back.** - Run `bin/fm-afk-launch.sh propose --words-file [--expected-return ] [--spend ]` (or `--words `); it writes the proposal and prints the record's read-back. - Then relay your own plain-sentence restatement of the words to the captain in `AGENTS.md` section 9 language - what you read them as asking for, sentence by sentence, never a numbered field list - beside the expected return, the spend cap, and the one-sentence reach announcement, so the captain can catch a misreading before saying go. - Say plainly which sentence, if any, you could not act on while away (a red merge, a discard, anything on the never-set, local-only landing), so the captain can restate it or accept that it waits for their return. -3. **Confirm on the captain's go.** - Run `bin/fm-afk-launch.sh confirm`; it promotes the proposal into the record and prints the entry announcement. - Relay that announcement verbatim in spirit: hold-for-return only, no phone channel, your instructions are recorded and the away session will carry them out where it can, anything it is unsure of, or that needs you, waits for your return, and destructive, irreversible, and security-sensitive actions are never pre-authorizable whatever the words say. - With no words, run `propose` and `confirm` back to back; the announcement says no instructions were recorded. - Re-invoking `/afk` while already away with no new words is a refresh and leaves the standing record untouched; new words replace the mandate after the same read-back, preserve the original session entry, and archive the superseded words for the return brief. -4. **Per harness, after the record exists:** - - **Pi and pi-signed**: stop here. + Plain `/afk` with no words is a valid entry with no mandate; the announcement says no instructions were recorded. + Re-invoking `/afk` while already away with no new words is a refresh and leaves the standing record untouched; new words replace the mandate at once, preserve the original session entry, and archive the superseded words for the return brief. +2. **Per harness, after the record exists:** + - **Pi and pi-signed**: nothing to launch; go on to the announcement. 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. @@ -42,9 +37,14 @@ Hold-for-return is the default and the only reach profile this release records: Do not wrap it in `nohup ... &` (Codex/herdr can reap fire-and-forget shell children after a tool call returns). - **Every other harness** (codex, opencode, omp, kimi, cursor): run `bin/fm-afk-launch.sh start`. It is the single owner of the daemon terminal: it creates a NON-VISIBLE tracked terminal for the current backend and passes the captain pane in as `FM_SUPERVISOR_TARGET` so the daemon injects into the captain, not its own new pane (docs/herdr-backend.md "Away-mode supervisor support"). - Both daemon paths require the already-confirmed record and share `bin/fm-afk-start.sh` as the daemon entry. + Both daemon paths require the record `enter` wrote and share `bin/fm-afk-start.sh` as the daemon entry. The daemon is **presence-gated**: it injects escalations only while `state/.afk` exists, and stays quiet otherwise. -5. **Do not separately arm `fm-watch.sh` where the daemon runs.** The daemon manages the watcher as its child; the singleton lock no-ops a stray arm harmlessly. +3. **Announce, then read back after entry.** + Relay the announcement in spirit: hold-for-return only, no phone channel, your instructions are recorded and the away session will carry them out where it can, anything it is unsure of, or that needs you, waits for your return, and destructive, irreversible, and security-sensitive actions are never pre-authorizable whatever the words say. + Then give your own plain-sentence restatement of the words in `AGENTS.md` section 9 language - what you read them as asking for, sentence by sentence, never a numbered field list - beside the expected return, the spend cap, and the one-sentence reach announcement. + Say plainly which sentence, if any, you could not act on while away (a red merge, a discard, anything on the never-set, local-only landing); it waits for their return. + This read-back is informational: the record already stands, so never ask for a go or wait for a reply; a captain who wants a different reading sends `/afk` again with new words. +4. **Do not separately arm `fm-watch.sh` where the daemon runs.** The daemon manages the watcher as its child; the singleton lock no-ops a stray arm harmlessly. On Pi nothing changes about arming: the supervision session's own cycle continues. ## While away @@ -65,12 +65,13 @@ No `/back` is needed. The first genuine message is the return signal: - A message **without** the current operational prefix or a legacy bare marker, and **not** starting with `/afk` -> the captain is back. Run `bin/fm-afk-return.sh` before acting on the message that brought the captain back. That script owns the correct-ordered daemon shutdown where a daemon ran, the archive of the posture record, durable wake presentation and post-handling acknowledgement, escalation and wedge evidence, the return brief, and the return-catch-up gate. - Relay the return brief in section 9 language and in its own order: supervisor health across the away window first (any gap leads), then the captain's instructions verbatim with the away session's account of every action it took under them, then what is waiting on the captain, then what was tried and failed or could not be fixed, then what was handled, then cost. + Relay every section of the return brief in its emitted order and in section 9 language; `bin/fm-afk-return.sh` owns that order. The gate keeps every open `blocked:` event until that blocker's own resolution is proven: remediate each immediately through the normal lifecycle, or explicitly reclassify it with a durable reason and close its decision key with `resolved [key=...]`, then run `bin/fm-afk-return.sh check`. Captain-verdict outcomes are listed under "waiting on you", but do not exempt open blockers: per-blocker provenance is deferred with no owner, and the gate fails safe by keeping every open blocker. Once the record is archived, resume full per-wake responsiveness through the emitted primary-harness supervision protocol while blocker handling proceeds, so the gate never creates a blind wait. A Bearings request may be answered while the gate is open, and the digest surfaces the catch-up state as a Charted Next `(return-catchup)` warning row naming what still holds it. Acting on the fleet - dispatching, steering, merging, or any other ordinary captain work - still waits until the check exits successfully. + Once it does, close every task the brief lists under "Landed, cleanup due" through ordinary teardown (`bin/fm-teardown.sh `, never forced; a refusal is a stop-and-investigate result) and tell the captain those workers are closed in outcome language. - A message **with** the current operational prefix (`FM_OPERATIONAL_PREFIX`, U+2063 INVISIBLE SEPARATOR followed by `FIRSTMATE_OP: `), or a legacy bare `FM_INJECT_MARK` daemon escalation -> stay away and process it. - Re-invoking `/afk` while already away -> stay away (refresh); this does **not** trigger an exit. diff --git a/.agents/skills/harness-adapters/references/harness/claude.md b/.agents/skills/harness-adapters/references/harness/claude.md index 1dfc448d077..1bea4444148 100644 --- a/.agents/skills/harness-adapters/references/harness/claude.md +++ b/.agents/skills/harness-adapters/references/harness/claude.md @@ -64,7 +64,7 @@ A `--secondmate` launch omits the statement because a secondmate operates under ## Primary integration -Primary behavior was verified 2026-07-04 on 2.1.201, preserved 2026-07-08 on 2.1.204, and Stop auto-arm revalidated 2026-07-24 on 2.1.219. +[`../../../../../docs/verification/supervision.md`](../../../../../docs/verification/supervision.md#turn-end-guard) records the current primary and Stop auto-arm live evidence. This differs from the worker hook, which only touches a task marker through `.claude/settings.local.json`. Primary `.claude/settings.json` registers `../../../bin/fm-turnend-guard.sh --claude` and `../../../bin/fm-claude-stop-autoarm.sh` with `asyncRewake: true` and `timeout: 28800`. diff --git a/.agents/skills/process-event-sources/SKILL.md b/.agents/skills/process-event-sources/SKILL.md index 1f9ea4caf1f..8b765f01c8a 100644 --- a/.agents/skills/process-event-sources/SKILL.md +++ b/.agents/skills/process-event-sources/SKILL.md @@ -27,6 +27,7 @@ Firstmate registers a source, keeps working, and is woken when that process comp ## Arming a source Use the adapter, not the generic runner, for a real source. +Before either Lavish arm form below, open the artifact with `lavish-axi` so its saved session can route the listener; the [operating contract](../../../docs/configuration.md#process-to-event-sources-stateprocevent) owns the prerequisite and refusal boundary. For a Lavish review artifact firstmate owns: ```sh diff --git a/.agents/skills/quota-array-dispatch/SKILL.md b/.agents/skills/quota-array-dispatch/SKILL.md index 4b988f1baab..6ec4a52cfbc 100644 --- a/.agents/skills/quota-array-dispatch/SKILL.md +++ b/.agents/skills/quota-array-dispatch/SKILL.md @@ -17,14 +17,14 @@ This skill is the single owner of the completion-aware profile-array selection p `harness-adapters` owns harness verification, model/provider discovery, and effort fallback. `quota-axi` remains data-only: it publishes `spendPriority` as a comparable scalar and never recommends, selects, ranks, or infers a route. Do not add a daemon, opaque composite score, routing wrapper, hard-coded model-specific policy, or producer-side route recommendation. -Deterministic shell owns only schema, configuration, and version validation plus concrete spawn safeguards; every model-to-provider, provider-to-credential, and quota-applicability relation is yours to establish transparently and to show your evidence for. +The [worker helper](../../../bin/fm-quota-choose.sh) and [typed resolver](../../../docs/configuration.md#typed-dispatch-resolution-env-typesafe_api_key) own their deterministic mapping boundaries. ## Worker-side quota helper The canonical shell helper for a worker that has already performed its model-selection reasoning and now needs to pick the first viable candidate is `bin/fm-quota-choose.sh`. Pass it the intake's already-captured default TOON or permitted JSON fallback through stdin or `--snapshot`; it never takes another quota snapshot, so it selects from the same quota state as the intake. Pass each candidate as `harness:model`, with earlier candidates preferred. -The helper maps each harness to its primary provider family and applies the provider-wide scopes plus the exact model or product scopes for the model. +The helper's header owns its provider mapping and quota selection mechanics. An `exhausted_now` runway vetoes the candidate. The helper selects a candidate only when its applicable quota has a known `effectivePercentRemaining` greater than zero. This is an optional narrow helper with a known limitation: it maps each harness to one primary provider family only, so a candidate whose established provider differs from that primary family is checked against the wrong quota row. @@ -33,7 +33,8 @@ Authoritative multi-provider routing - including provider discovery from the har Use it only when the brief already fixed the candidate order and every candidate's provider is the harness's primary family. It does not replace the reasoning-class, runway-feasibility, or authentication gates above. Firstmate can optionally arm `bin/fm-procevent-quota.sh` for a recurring mid-task check that wakes when the tracked provider drops below its configured threshold or its runway becomes `exhausted_now`. -The opt-in `bin/fm-dispatch-resolve.sh` (`docs/configuration.md` "Typed dispatch resolution") applies the same eligibility gates and `spendPriority` argmax in code after a typed rule match; it never removes this skill's authority, and its `ambiguous`, `escalate`, and `error` outcomes return here. +The opt-in [typed resolver](../../../docs/configuration.md#typed-dispatch-resolution-env-typesafe_api_key) has its own documented gates. +It never removes this skill's authority, and its `ambiguous`, `escalate`, and `error` outcomes return here. ## Read the default TOON @@ -62,15 +63,15 @@ It cannot override a hard-gate failure, and it is never hidden inside a new comp ### 1. Eligibility -Deterministic shell must never map a model to a provider, a provider to a credential store, or a name prefix to a family. -You establish those relations yourself, in the open, from the candidate's own authoritative catalog (`harness-adapters` owns the per-harness discovery surface) plus the one intake snapshot. +Outside those documented mappings, deterministic shell must not infer a provider family or credential store from a harness, model, or source name. +You establish the remaining relations yourself, in the open, from the candidate's own authoritative catalog (`harness-adapters` owns the per-harness discovery surface) plus the one intake snapshot. Confirm the catalog lists the candidate's model and record the provider family it reports. A model the catalog does not list is concrete contradictory evidence: block that candidate and quote the catalog result. Apply quota at the granularity the vendor actually supplies. -A provider-level or `all_models`/`all_products` scope bounds every model you established in that family, including one with no window of its own. +A provider-level or `all_models`/`all_products` scope bounds every model you established in that family within the candidate's matched account, including one with no window of its own. A named-model or named-product scope is an additional bound for that model alone. -Match the candidate to its `quota[]` row by that established provider and scope; a stale, auth-required, or unmeasurable scope is named in `attention[]` instead of a fabricated number. +Match the candidate to its `quota[]` row by that established provider, its `accountKey` when the snapshot is schema 6 (a Pi lane's auth provider id such as `openai-codex-work`, or `codex-home` for native Codex including Pi's `codex-native/` adapter, then the `default` row, else unmeasured; never a row picked by position, never rows summed across accounts), and scope; a stale, auth-required, or unmeasurable scope is named in `attention[]` instead of a fabricated number. A candidate authenticates through its own tuple's surface; another harness's CLI can never gate it, and `harness=pi` with `model=xai/grok-*` is Pi using xAI rather than the standalone Grok CLI. `quota-axi auth --json` lists each provider's credential sources independently, so read the one source the candidate actually uses rather than collapsing a provider to a single status. diff --git a/.agents/skills/stuck-crewmate-recovery/SKILL.md b/.agents/skills/stuck-crewmate-recovery/SKILL.md index 3d7ac5e1d66..ffef22777f0 100644 --- a/.agents/skills/stuck-crewmate-recovery/SKILL.md +++ b/.agents/skills/stuck-crewmate-recovery/SKILL.md @@ -13,6 +13,7 @@ metadata: # stuck-crewmate-recovery Use this playbook when the session-start digest reports an ordinary direct report's endpoint dead or its metadata has no window, or when a direct report is stale, looping, repeatedly confused, asking a question its brief already answers, unresponsive, or when a steer failed to land. +A stale or dead-endpoint report for a worker whose pull request has already landed is not a recovery case: the work is finished, so close the task through ordinary teardown (`AGENTS.md` section 7 for firstmate, the landed-work rule in `bin/fm-branch-prompt.sh` for the supervision branch) instead of this playbook, never with `--force`. Follow the crew-hosted Lavish board contract in [`docs/configuration.md`](../../../docs/configuration.md#crew-hosted-lavish-review-boards) when recovering a worker that hosts a board. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e918fa9b44d..7c1684a399c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -440,7 +440,7 @@ jobs: [ "$parse_fail" -eq 0 ] || { echo "::error::stock macOS Bash 3.2 parse sweep failed"; exit 1; } command -v npm >/dev/null || { echo "::error::npm is required to install tasks-axi"; exit 1; } - npm install -g tasks-axi@0.2.5 >/dev/null + npm install -g tasks-axi@0.2.6 >/dev/null PATH="$(npm prefix -g)/bin:$PATH" export PATH command -v tasks-axi >/dev/null || { echo "::error::tasks-axi is required for the stock Bash regressions"; exit 1; } diff --git a/.github/workflows/no-mistakes-required.yml b/.github/workflows/no-mistakes-required.yml index 41bbac1f564..be7b8b744c3 100644 --- a/.github/workflows/no-mistakes-required.yml +++ b/.github/workflows/no-mistakes-required.yml @@ -9,6 +9,7 @@ on: permissions: contents: read + pull-requests: read # GitHub concurrency groups retain at most one pending run, replacing older # pending runs even when cancel-in-progress is false. Give body-bearing events @@ -27,4 +28,6 @@ jobs: github.event.pull_request.user.login != 'dependabot[bot]' steps: - name: Verify no-mistakes signature and pipeline attestation - uses: kunchenguid/no-mistakes/.github/actions/require-no-mistakes@32d396ac0f29135daf7fcb9964aba9d5f4e796d6 # post-v1.57.1, untagged (action added in #819) + uses: kunchenguid/no-mistakes/.github/actions/require-no-mistakes@f6441c96c352a18b9cadcaef6b6c7017e9ac3970 # v1.80.1 + with: + exempt-authors: kunchenguid diff --git a/.omp/extensions/fm-primary-omp-watch.ts b/.omp/extensions/fm-primary-omp-watch.ts index 93749f09dcd..6d908d258d7 100644 --- a/.omp/extensions/fm-primary-omp-watch.ts +++ b/.omp/extensions/fm-primary-omp-watch.ts @@ -1,7 +1,7 @@ // Firstmate primary watcher bridge for omp (Oh My Pi). // // A port of .pi/extensions/fm-primary-pi-watch.ts for the omp fork. The arm, -// successor, retry, and replacement-handoff logic is the Pi contract verbatim; +// successor, retry, and replacement-handoff logic follows the Pi contract; // the omp-specific differences are stated once here: // - omp auto-discovers this file from /.omp/extensions with no trust // gate, so an omp primary or secondmate started inside its home loads it @@ -14,6 +14,10 @@ // session_start, in this process or a later one, replays it. Replaying a // wake main has already drained is harmless (the queue is durable and the // drain is idempotent); losing one across /new is not. +// - Replacement shutdown retires the established predecessor arm before the +// successor arms; unlike Pi, it is not retained until a distinct active +// successor generation commits its own arm, so omp keeps the plain +// teardown-and-rearm replacement shape. // - The Pi supervision branch is out of scope for omp: every actionable wake // is delivered to main, so no branch offer is made and no calm presentation // hooks exist. diff --git a/.pi/extensions/fm-primary-pi-watch.ts b/.pi/extensions/fm-primary-pi-watch.ts index 58e4841adbd..23b450d39b0 100644 --- a/.pi/extensions/fm-primary-pi-watch.ts +++ b/.pi/extensions/fm-primary-pi-watch.ts @@ -4,8 +4,10 @@ // Pi emits session_shutdown for ordinary same-process replacements (/new, /resume, // /fork, reload) as well as terminal quit. This extension binds one generation per // session activation. Only the active live generation may start, stop, rearm, or -// clear the arm child. An owning replacement session_start (or fresh factory bind) -// arms its new generation without a model turn. A replacement handoff carries +// clear the arm child. Replacement shutdown publishes a generation-bound handoff +// phase but retains its established child until the next owning session_start (or +// fresh factory bind) publishes a distinct active generation and commits the +// tracked replacement arm without a model turn. A replacement handoff carries // actionable closes that were still pending delivery; its durable state lives at // state/extensions/pi-primary-watch/session-replacement-actionable.json. // Terminal quit leaves the final generation stopped so late callbacks cannot rearm. @@ -162,7 +164,6 @@ const armRetireTimeoutMs = positiveInteger("FM_WATCH_ARM_RETIRE_TIMEOUT_MS", 100 const repairOnlyHint = "call fm_watch_arm_pi again only after a later notification says the cycle is missing, failed, or unhealthy"; const shuttingDownMessage = "watcher: not armed - Pi session is shutting down"; -let nextGenerationId = 0; let nextHandoffId = 0; let activeGeneration: SessionGeneration | null = null; let replacementHandoff: PendingActionableClose[] | null = null; @@ -175,6 +176,7 @@ type ReplacementCoordinator = { receiver: ReplacementActionableReceiver | null; pending: PendingActionableClose[]; nextTokenId: number; + nextGenerationId: number; deliveries: Map; }; type ReplacementCoordinatorGlobal = typeof globalThis & { @@ -189,6 +191,7 @@ function replacementCoordinatorFor(handoff: string): ReplacementCoordinator { receiver: null, pending: [], nextTokenId: 0, + nextGenerationId: 0, deliveries: new Map(), }; replacementCoordinators.set(handoff, created); @@ -197,6 +200,7 @@ function replacementCoordinatorFor(handoff: string): ReplacementCoordinator { const replacementCoordinator = replacementCoordinatorFor(actionableHandoff); const armReadiness = new WeakMap>(); const armClose = new WeakMap>(); +const retiringGenerations = new Set(); // Children the extension itself asked to exit; their close is not a failure // of the successor and never earns a deferred retry. const armRetired = new WeakSet(); @@ -241,10 +245,39 @@ function lockOwnership(): LockOwnership { return pidAlive(lockPid) ? "other" : "missing"; } -function markLoaded(): void { +function publishGenerationOwner(generation: SessionGeneration, phase: "active" | "handoff"): void { if (lockOwnership() === "other") return; mkdirSync(state, { recursive: true }); - writeFileSync(marker, `${extensionVersion}\n${process.pid}\n`); + const temporary = `${marker}.tmp-${process.pid}-${generation.id}`; + writeFileSync( + temporary, + `${extensionVersion}\n${process.pid}\ngeneration=${generation.id} phase=${phase}\n`, + { mode: 0o600 }, + ); + renameSync(temporary, marker); +} + +function retireGenerationOwner(generation: SessionGeneration, replacement: boolean): void { + let lines: string[]; + try { + lines = readFileSync(marker, "utf8").trimEnd().split(/\r?\n/); + } catch { + return; + } + if ( + lines[0] !== extensionVersion || + lines[1] !== String(process.pid) || + lines[2] !== `generation=${generation.id} phase=active` + ) return; + if (replacement) { + publishGenerationOwner(generation, "handoff"); + return; + } + try { + unlinkSync(marker); + } catch (error) { + if (nodeErrorCode(error) !== "ENOENT") throw error; + } } function actionableLine(output: string): string { @@ -421,7 +454,7 @@ function classifyClose(stdout: string, stderr: string, code: number | null, sign function createGeneration(): SessionGeneration { return { - id: ++nextGenerationId, + id: ++replacementCoordinator.nextGenerationId, stopping: false, replacement: false, child: null, @@ -445,15 +478,21 @@ function generationIsLive(generation: SessionGeneration): boolean { return activeGeneration === generation && !generation.stopping; } -function stopGeneration(generation: SessionGeneration): ChildProcess | null { +function relinquishGeneration(generation: SessionGeneration): void { generation.stopping = true; if (generation.retryTimer) clearTimeout(generation.retryTimer); if (generation.cleanupTimer) clearTimeout(generation.cleanupTimer); generation.retryTimer = null; generation.cleanupTimer = null; + if (generation.child) retiringGenerations.add(generation); +} + +function stopGeneration(generation: SessionGeneration): ChildProcess | null { + relinquishGeneration(generation); const child = generation.child; if (child) child.kill("SIGTERM"); generation.child = null; + retiringGenerations.delete(generation); return child; } @@ -472,12 +511,25 @@ async function waitForGenerationChildClose(armChild: ChildProcess | null): Promi async function stopSessionGeneration(generation: SessionGeneration, replacement: boolean): Promise { generation.replacement = replacement; - let persistedTokens = ""; + retireGenerationOwner(generation, replacement); + if (!replacement) { + const child = stopGeneration(generation); + await waitForGenerationChildClose(child); + return; + } + + // A same-process replacement has not proved its successor yet. Keep this + // generation's established arm child alive while transferring delivery and + // retry responsibility. The replacement's --restart arm retires it only + // after the new generation has committed its own tracked child. + relinquishGeneration(generation); + const observed = generation.child ? armPendingActionable.get(generation.child) : undefined; + if (observed && !generation.pendingActionables.some((item) => item.token === observed.token)) { + generation.pendingActionables.push(observed); + } + if (generation.pendingActionables.length === 0) return; try { - if (replacement && generation.pendingActionables.length > 0) { - persistReplacementHandoff(generation.pendingActionables); - persistedTokens = generation.pendingActionables.map((pending) => pending.token).join("\n"); - } + persistReplacementHandoff(generation.pendingActionables); } catch (error) { const detail = error instanceof Error ? error.message : String(error); for (const pending of generation.pendingActionables) { @@ -487,18 +539,11 @@ async function stopSessionGeneration(generation: SessionGeneration, replacement: message: `${pending.message}\n\nwatcher: FAILED - Pi extension could not persist a replacement-session actionable wake\n${detail}`, }); } - throw error; - } finally { - const child = stopGeneration(generation); - await waitForGenerationChildClose(child); - } - const currentTokens = generation.pendingActionables.map((pending) => pending.token).join("\n"); - if (replacement && currentTokens && currentTokens !== persistedTokens) { - persistReplacementHandoff(generation.pendingActionables); } } const cleanupOnProcessExit = () => { + for (const generation of retiringGenerations) stopGeneration(generation); if (activeGeneration) stopGeneration(activeGeneration); }; process.once("exit", cleanupOnProcessExit); @@ -960,7 +1005,7 @@ export default function (pi: ExtensionAPI) { message: "watcher: not armed - no live session holds the lock; run bin/fm-session-start.sh to reclaim it, then call fm_watch_arm_pi to re-arm", }; } - markLoaded(); + publishGenerationOwner(owner, "active"); if (owner.child) { return { ok: true, @@ -1020,11 +1065,11 @@ export default function (pi: ExtensionAPI) { if (reason && !armPendingActionable.has(armChild)) { const pending = createPendingActionable(reason, String(armChild.pid ?? "")); armPendingActionable.set(armChild, pending); - enqueuePendingActionable(owner, pending); } }; const releaseChild = (): void => { if (owner.child === armChild) owner.child = null; + if (!owner.child) retiringGenerations.delete(owner); }; armChild.stdout.on("data", (chunk: Buffer) => { stdout += chunk.toString(); @@ -1120,7 +1165,6 @@ export default function (pi: ExtensionAPI) { pi.on?.("session_start", async () => { if (generation.stopping) generation = createGeneration(); activateGeneration(generation); - markLoaded(); if (lockOwnership() !== "owned") return; activateOwnedWatch(generation); }); @@ -1182,5 +1226,9 @@ export default function (pi: ExtensionAPI) { }, }); - markLoaded(); + // Pi loads project extensions before the first model turn can run the locked + // session-start command. Publish this generation while the lock is absent so + // that command can distinguish a loaded extension from a missing one; a + // foreign live lock still suppresses publication. + publishGenerationOwner(generation, "active"); } diff --git a/AGENTS.md b/AGENTS.md index 6f8e05f9ffe..4f0df70a82c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -84,7 +84,7 @@ config/startup-memory-budget primary-authoritative per-home startup-memory b config/stow-pass-horizon optional presence flag opting this home in to /stow's default-off pass-count decay horizon; LOCAL, gitignored, and not inherited; see docs/configuration.md "Stow pass horizon" config/herdr-presentation-spaces optional "off" opt-out from, or "on" opt-in to, Herdr's default-on disposable single-task visual projection, which is unconfigured-default-on only at or above a Herdr version floor; LOCAL, gitignored; inherited by secondmate homes; see docs/herdr-backend.md "Presentation spaces" config/trace-context optional presence flag enabling default-off native W3C trace-context propagation to spawned agents; LOCAL, gitignored; inherited by secondmate homes; see docs/configuration.md "Trace context propagation" and docs/trace-context.md -config/lavish-axi-host optional one-line per-machine Lavish server address; LOCAL, gitignored, inherited by secondmate homes, and exported into every worker launch; the adapter reads it before each board call; see docs/configuration.md "Lavish server address" +config/lavish-axi-host optional one-line per-machine Lavish server address; LOCAL, gitignored, inherited by secondmate homes, and exported into every worker launch; see docs/configuration.md "Lavish server address" for opening versus polling config/brief-include.md optional standing worker instructions appended verbatim as the last section of every ship and scout scaffold; LOCAL, gitignored, and not inherited; keep its text out of `## Firstmate spec`; see docs/configuration.md "Home brief include" config/turnend-churn-absorb optional presence flag opting this home into the default-off absorb of bare turn-end wakes on pane churn; LOCAL, gitignored, and not inherited; see docs/configuration.md "Turn-end pane-churn absorb" config/wedge-defer-parked-gate optional presence flag opting this home into the default-off deferral of a wedge escalation for a lane parked at a validation gate awaiting the supervisor's own still-open decision; LOCAL, gitignored, and not inherited; see docs/configuration.md "Parked-gate wait deferral" @@ -149,8 +149,9 @@ state/ runtime records and signals; gitignored .wake-queue durable queued wakes retained until post-handling acknowledgement: epochseqkindkeypayload .watcher-down private generation-bound recovery state coupling watcher downtime, durable wake presentation, and post-handling acknowledgement; never touch ..open-decisions-cursor per-task byte cursor and folded open-decision set bounding the OPEN DECISIONS scan's cost to new status-log appends; written only by fm-classify-lib.sh's status_open_decisions_incremental, removed by teardown, safe to delete (forces one full re-fold) + ..home-appends per-task ledger of byte ranges this home itself appended as bookkeeping closes, so a wake scan can tell its own growth from a foreign write instead of waking on it; presentation is unaffected, so both the signal annotation and UNREAD STATUS still print those lines; written only by fm-classify-lib.sh's status_home_appends_record; its sibling ..home-appends.lock serializes that ledger's read-merge-write; both removed by teardown, safe to delete .status-presentation-cursor .status-presentation-lock fleet-wide per-task status identity plus independent annotation and outcome-backstop byte offsets, with a serialization lock preventing already-presented lines from replaying while preserving delayed signal annotations; owned by fm-classify-lib.sh, with each task's row retired by teardown - .afk-contract the away-posture record: the captain's verbatim away words, expected return, reach profile, and spend cap; 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-contract the away-posture record: the captain's verbatim away words, expected return, reach profile, and spend cap; written only by bin/fm-afk-contract.sh in the same turn as /afk, 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 @@ -228,7 +229,7 @@ When dispatch profiles exist, consult them at every crewmate or scout intake and Routing precedence is an explicit per-task captain override, then the best-fit configured rule, then the configured default, then the static crewmate harness. Firstmate alone resolves a matched profile array: begin with `quota-axi`'s default TOON at that intake, using the skill's narrow TOON-then-`--json` fallback only for genuine ambiguity, evaluate every configured candidate against that current output, and choose with inspectable `spendPriority` as the one quota-perspective ranker after the skill's eligibility, reasoning-class, and runway-feasibility gates. Account for every candidate with the catalog evidence, provider relationship, applicable quota and authentication facts, remaining uncertainty, fit and reasoning class, and the spendPriority and runway evidence used in selection; never omit a candidate, guess, fall back silently, or call the result quota-informed without them. -Establish model support and provider family from that harness's own authoritative catalog, then read `quota-axi` at the granularity the vendor actually supplies: provider-level or all-model evidence applies to every model established in that family, and a named-model window bounds only that model. +Establish model support and provider family from that harness's own authoritative catalog, then apply the [account and scope matching rules in `quota-array-dispatch`](.agents/skills/quota-array-dispatch/SKILL.md#1-eligibility). Missing model-level quota, a missing authentication source, unmeasurable headroom, or unmodeled authentication is disclosed uncertainty that keeps a candidate eligible, never a credential or login escalation. Only concrete contradictory evidence blocks a candidate, such as an authoritative catalog proving the model unsupported or proof that the credential selected for that surface is unusable; never infer a credential store, provider family, or quota mapping from a harness, model, or source name, and never launch another harness's CLI to judge a candidate. Preserve malformed profile configuration as an actionable error rather than selecting around it. @@ -390,16 +391,21 @@ Send the same worker one exact decision naming the decision key, step, action, a Require the matching `resolved` event, forbid `--yes`, and require the worker to process every synchronous return until completion or a genuinely new escalation. Resume fleet supervision immediately after the decision lands. -Judge validation by the currently attributed run step through `bin/fm-crew-state.sh`, not by shell liveness or the last status event. -Running, fixing, or CI states remain working; parked approval or fix-review states require the worker to follow the active gate help; passed or checks-passed is done; failed or cancelled is failed exactly as `bin/fm-crew-state.sh` prints it - only that state line reclassifies an orphaned ci monitor after green checks as held-for-merge done, or a run record the `daemon status` probe leaves unverified as unknown, never the raw run record. +Judge validation by the resolved state line from [`bin/fm-crew-state.sh`](bin/fm-crew-state.sh), whose header owns outcome mappings and CI-monitor/daemon exceptions, never by shell liveness, the last status event, or a raw run record. +Workers parked at approval or fix-review must follow the active gate help. A worker hand-editing, committing, aborting, or restarting during an active validation run duplicates pipeline ownership outside the supersession sequence above; steer it back to the gate response flow. The worker reports the PR when CI first becomes green rather than waiting for merge monitoring to finish. ### PR ready, landing, and teardown -For PR-based ship tasks, the ready signal depends on mode: `no-mistakes` reports `done [at=]: PR checks green` after CI is green, while `direct-PR` reports `done [at=]: PR ` after opening the PR. -Run `bin/fm-pr-check.sh ` with the URL copied from that ready signal - it records `pr=` and the forge's `pr_head=` when available in the task's meta and arms the watcher's merge poll. -Tell the captain the PR's full `https://...` URL copied from the worker's ready line or the task's `pr=` metadata, a concise outcome summary, and the no-mistakes risk level when applicable. +For PR-based ship tasks, the ready signal depends on mode: `no-mistakes` reports `done [at=]: PR checks green` after CI is green, while `direct-PR` reports `done [at=]: PR ` after opening the PR, each only for a non-draft PR; a lane that deliberately holds a draft declares a wait instead, and `bin/fm-pr-check.sh` refuses to arm merge monitoring on a draft. +Run `bin/fm-pr-check.sh ` with the URL copied from that ready signal or the resolved checks-green `fm-crew-state.sh` line - it records `pr=` and the forge's `pr_head=` when available in the task's meta and arms the watcher's merge poll. +`bin/fm-dod-lib.sh` owns the named-head gate on that ready signal: a ship `done:` whose named head exists only in the worker's disposable copy is not ready (`bin/fm-crew-state.sh` reports blocked, `bin/fm-pr-check.sh` refuses to register, and a secondmate does not publish that done upstream). +That blocked reading is the gate working, not a stuck worker, so steer the worker on the commit the refusal names rather than waiting. +A direct-PR worker pushes that commit to its PR branch, and a local-only worker commits it on its `fm/` branch. +A no-mistakes worker re-validates it with /no-mistakes so the pipeline stays the one publisher; it never pushes from its copy. +In no-mistakes mode the earlier `done [at=]: {summary}` is the pipeline handoff and is not gated. +Tell the captain the PR's full `https://...` URL copied from the worker's ready line, the resolved checks-green crew-state line, or the task's `pr=` metadata, a concise outcome summary, and the no-mistakes risk level when applicable. A captain instruction to merge is explicit authority; `yolo` is the only standing routine merge authority. For any custom `state/.check.sh` you write yourself, keep it an ordinary single-link mode-`0700` file, print one line only when firstmate should wake, print nothing otherwise, finish before `FM_CHECK_TIMEOUT`, then bind its current bytes with `bin/fm-check-register.sh ` before the watcher may execute it. Retire a custom check only through `bin/fm-check-unregister.sh ` (or `bin/fm-teardown.sh` for a spawned task); never hand-compose an `rm` with `$STATE`/`$ID`. @@ -470,7 +476,7 @@ Invoke the `/quiet` skill instead when the captain says `/quiet` or asks for qui Each skill owns its own daemon procedure, which is otherwise identical; these safety facts remain inline for both: - 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 away session acts on those words by its own judgment through the guarded scripts under standing authority, holding for the return on doubt. +- `state/.afk-contract` is the away posture, written in the same turn as `/afk` before any other work, because `/afk` is itself the go: no read-back gates entry or waits for a go; entry announces hold-for-return only, and the away session acts on those words by its own judgment through the guarded scripts under standing authority, holding for the return on doubt. - While `state/.afk` exists, the daemon owns supervision; do not arm a separate watcher. The daemon is never launched on Pi, where the ordinary supervision session continues under the record with main parked: the branch takes every 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. diff --git a/bin/fm-afk-contract.sh b/bin/fm-afk-contract.sh index cfb2bc425f6..ba349b8b233 100755 --- a/bin/fm-afk-contract.sh +++ b/bin/fm-afk-contract.sh @@ -12,9 +12,15 @@ # only reach profile this release records: there is no phone channel, and the # entry announcement says so every time. # +# ENTRY IS THE GO. `/afk` itself is the captain's go: `enter` writes the record +# in the same turn, before any other work, and never waits for a further human +# response, because the captain who typed /afk may not look at the screen again. +# The read-back is printed after the record exists; it is informational, never a +# gate, and never asks for a go. +# # THE RECORD IS THE WORDS. The captain's away words are the whole mandate: they -# are recorded verbatim, read back as plain sentences by firstmate before the -# captain says go, and acted on by the supervision session's own judgment at the +# are recorded verbatim, read back as plain sentences by firstmate after entry, +# and acted on by the supervision session's own judgment at the # moment an event makes them relevant, through the guarded scripts and under the # standing authority it already has (bin/fm-branch-prompt.sh "Postures" owns the # execution rules). NO PARSER, TOKENIZER, CLASSIFIER, OR GRAMMAR READS THE WORDS @@ -35,8 +41,8 @@ # reach_channels: none # reach_announced: # spend_max_concurrent_workers: -# confirmed: -# confirmed_epoch: +# confirmed: when this mandate was recorded; /afk itself +# confirmed_epoch: is the go, so no later human step stamps it # words: | or |- the captain's words, verbatim, never edited, # one record line per input line (or `words: -` # ... when /afk carried no words); `|` retains a @@ -49,31 +55,32 @@ # scalar fields and words are read exactly as above, and its clauses:, refused:, # and merge_grants: sections are ignored, so an upgrade never breaks a live away # window. Only version 2 is ever written. -# A proposal (state/.afk-contract.proposed) has the same shape without the -# confirmed fields; confirmation stamps the first entry time. Archived final -# records live under state/afk-contracts/ as .afk-contract, and -# replaced mandates use -superseded-.afk-contract. +# The retired two-step entry staged a proposal at state/.afk-contract.proposed; +# no proposal is written any more, and `enter` removes one an older version left +# behind. Archived final records live under state/afk-contracts/ as +# .afk-contract, and replaced mandates use +# -superseded-.afk-contract. # A replacement carries the original session entry forward. Durable # archive-chain identity and same-second session identity are deferred, with no # owner: no incident motivates them. # # Usage: -# fm-afk-contract.sh propose [--words-file | --words ] +# fm-afk-contract.sh enter [--words-file | --words ] # [--expected-return ] [--spend ] -# Write the proposal, then print the read-back. Exit 0 on success and 2 on a -# usage error. --words-file keeps the file's bytes verbatim, trailing -# newlines included. -# fm-afk-contract.sh confirm -# Promote the proposal into the record with the confirmed timestamp and -# print the entry announcement. A proposal is required when no confirmed -# record exists; an existing record with no proposal is a no-op refresh. -# A replacement is staged before the prior record is archived and replaced. -# fm-afk-contract.sh readback [--proposal] +# Write the record now, with no separate confirmation step, then print the +# entry announcement and the read-back. Exit 0 on success and 2 on a usage +# error. --words-file keeps the file's bytes verbatim, trailing newlines +# included. With no words while a record stands, this is a refresh that +# leaves the standing record untouched; new words replace the mandate, +# carry the original session entry forward, and archive the superseded +# record. A replacement is staged before the prior record is archived and +# replaced. `propose` and `confirm` were retired with the wait-for-go gate. +# fm-afk-contract.sh readback # The record's content for the captain and for the away session: the words # verbatim plus the entry time, expected return, spend cap, and reach line. -# fm-afk-contract.sh field [--proposal] -# fm-afk-contract.sh words [--proposal | --path ] -# fm-afk-contract.sh validate [--proposal | --path ] exit 0 when the record is readable and, for a record, confirmed +# fm-afk-contract.sh field [--path ] +# fm-afk-contract.sh words [--path ] +# fm-afk-contract.sh validate [--path ] exit 0 when the record is readable and complete # fm-afk-contract.sh archive move the record aside; print its path # fm-afk-contract.sh archived print that archived record's path # @@ -83,7 +90,7 @@ # and afterwards hands a merge to the forge. A publication, replacement, or # archive landing between that read and the forge handoff would land a merge on # authority that no longer holds, so the two subsystems share one lock instead of -# each locking its own records: the record-mutating subcommands (confirm, +# each locking its own records: the record-mutating subcommands (enter, # archive) hold it across their mutation, and a reader that acts on the record # holds it across both its read and that action (fm_afk_contract_lock_hold / # fm_afk_contract_lock_release). The read-only subcommands never take it, so a @@ -96,7 +103,7 @@ # # Sourceable: with the BASH_SOURCE guard, other scripts get the path, presence, # and lock helpers (fm_afk_contract_path, fm_afk_contract_present, -# fm_afk_contract_proposal_path, fm_afk_contract_archive_dir, +# fm_afk_contract_archive_dir, # fm_afk_contract_lock_hold, fm_afk_contract_lock_release) without running main. set -u @@ -122,7 +129,9 @@ fm_afk_contract_path() { # [state-dir] printf '%s/.afk-contract' "${1:-$FM_AFK_CONTRACT_STATE}" } -fm_afk_contract_proposal_path() { # [state-dir] +# Where the retired two-step entry staged its proposal; kept only so `enter` can +# remove one an older version left behind. +fm_afk_contract_legacy_proposal_path() { # [state-dir] printf '%s/.afk-contract.proposed' "${1:-$FM_AFK_CONTRACT_STATE}" } @@ -198,10 +207,10 @@ fm_afk_contract_validate_iso() { # fm_utc_iso_to_epoch "$1" >/dev/null 2>&1 } -# Render a record body on stdout (everything except the confirmed fields). +# Render a whole record on stdout. # Inputs: WORDS (verbatim), EXPECTED_RETURN, SPEND. -fm_afk_contract_render_body() { # - local entered=$1 entered_epoch=$2 +fm_afk_contract_render_record() { # + local entered=$1 entered_epoch=$2 confirmed=$3 confirmed_epoch=$4 printf 'version: %s\n' "$FM_AFK_CONTRACT_VERSION" printf 'entered: %s\n' "$entered" printf 'entered_epoch: %s\n' "$entered_epoch" @@ -209,6 +218,8 @@ fm_afk_contract_render_body() { # printf 'reach_channels: none\n' printf 'reach_announced: %s\n' "$FM_AFK_CONTRACT_REACH_ANNOUNCED" printf 'spend_max_concurrent_workers: %s\n' "${SPEND:-$FM_AFK_CONTRACT_SPEND_DEFAULT}" + printf 'confirmed: %s\n' "$confirmed" + printf 'confirmed_epoch: %s\n' "$confirmed_epoch" if [ -n "$WORDS" ]; then local words_body=$WORDS words_indicator='|-' case "$words_body" in @@ -282,8 +293,8 @@ fm_afk_contract_read_words() { # # A record is valid when its version is one this script reads and the required # scalar fields and words block are present. Refuses rather than guessing at a # foreign schema. A version 1 record's clause and grant sections are ignored. -fm_afk_contract_validate() { # - local path=$1 require_confirmed=$2 version entered entered_epoch expected reach announced spend words_header confirmed +fm_afk_contract_validate() { # + local path=$1 version entered entered_epoch expected reach announced spend words_header confirmed [ -f "$path" ] || return 1 version=$(fm_afk_contract_read_field "$path" version) case " $FM_AFK_CONTRACT_READABLE_VERSIONS " in @@ -307,22 +318,21 @@ fm_afk_contract_validate() { # words_header=$(sed -n '/^words: /{p;q;}' "$path") case "$words_header" in 'words: -'|'words: |'|'words: |-') ;; *) fm_afk_contract_log "record $path has no valid words field"; return 1 ;; esac fm_afk_contract_read_words "$path" >/dev/null || return 1 - if [ "$require_confirmed" -eq 1 ]; then - confirmed=$(fm_afk_contract_read_field "$path" confirmed) - fm_afk_contract_validate_iso "$confirmed" || { fm_afk_contract_log "record $path has no valid confirmed time"; return 1; } - case "$(fm_afk_contract_read_field "$path" confirmed_epoch)" in - ''|*[!0-9]*) fm_afk_contract_log "record $path was never confirmed"; return 1 ;; - esac - fi + confirmed=$(fm_afk_contract_read_field "$path" confirmed) + fm_afk_contract_validate_iso "$confirmed" || { fm_afk_contract_log "record $path has no valid confirmed time"; return 1; } + case "$(fm_afk_contract_read_field "$path" confirmed_epoch)" in + ''|*[!0-9]*) fm_afk_contract_log "record $path has no confirmed_epoch"; return 1 ;; + esac } # --- rendering -------------------------------------------------------------- # The read-back is the record's content and nothing else: the words verbatim # beside the entry time, expected return, spend cap, and reach line. Firstmate's -# plain-sentence restatement is spoken in chat, and the execution rules live in -# bin/fm-branch-prompt.sh, so this render stays a faithful mirror of the record -# for the captain at entry and for the away session on every wake. +# plain-sentence restatement is spoken in chat after entry, and the execution +# rules live in bin/fm-branch-prompt.sh, so this render stays a faithful mirror +# of the record for the captain at entry and for the away session on every wake. +# It never asks for a go: the record already stands when it is printed. fm_afk_contract_render_readback() { # local path=$1 title=$2 words expected spend expected=$(fm_afk_contract_read_field "$path" expected_return) @@ -353,7 +363,7 @@ fm_afk_contract_render_announcement() { # <path> else mandate_text='No away instructions were recorded; the away session acts on standing authority only, and anything that needs you waits for your return.' fi - printf 'Away posture confirmed at %s: hold-for-return only. %s %s Destructive, irreversible, and security-sensitive actions are never pre-authorizable, whatever the words say. Expected return: %s. Spend cap: %s concurrent workers.\n' \ + printf 'Away posture recorded at %s: hold-for-return only. %s %s Destructive, irreversible, and security-sensitive actions are never pre-authorizable, whatever the words say. Expected return: %s. Spend cap: %s concurrent workers.\n' \ "$(fm_afk_contract_read_field "$path" confirmed)" \ "$(fm_afk_contract_read_field "$path" reach_announced)" \ "$mandate_text" \ @@ -365,7 +375,7 @@ fm_afk_contract_render_announcement() { # <path> fm_afk_contract_parse_inputs() { # <args...>; sets WORDS, EXPECTED_RETURN, SPEND local words_file='' - WORDS=; EXPECTED_RETURN=-; SPEND=$FM_AFK_CONTRACT_SPEND_DEFAULT + WORDS=; EXPECTED_RETURN=-; SPEND=$FM_AFK_CONTRACT_SPEND_DEFAULT; FM_AFK_CONTRACT_SCALARS_GIVEN=0 while [ "$#" -gt 0 ]; do case "$1" in --words-file) @@ -383,11 +393,13 @@ fm_afk_contract_parse_inputs() { # <args...>; sets WORDS, EXPECTED_RETURN, SPEN return 2 fi EXPECTED_RETURN=$2 + FM_AFK_CONTRACT_SCALARS_GIVEN=1 shift 2 ;; --spend) [ "$#" -gt 1 ] || { fm_afk_contract_log '--spend requires a positive integer'; return 2; } case "$2" in ''|*[!0-9]*|0) fm_afk_contract_log "--spend must be a positive integer, got '$2'"; return 2 ;; esac SPEND=$2 + FM_AFK_CONTRACT_SCALARS_GIVEN=1 shift 2 ;; --action|--object|--when|--stop|--grant|--grant=*) fm_afk_contract_log "$1 was retired: the captain's away words are the whole mandate, so pass them with --words or --words-file and nothing else" @@ -407,20 +419,6 @@ fm_afk_contract_parse_inputs() { # <args...>; sets WORDS, EXPECTED_RETURN, SPEN return 0 } -fm_afk_contract_cmd_propose() { - local entered entered_epoch proposal - fm_afk_contract_parse_inputs "$@" || return 2 - entered=$(fm_afk_contract_now_iso) - entered_epoch=$(date +%s) - proposal=$(fm_afk_contract_proposal_path) - fm_afk_contract_render_body "$entered" "$entered_epoch" | fm_afk_contract_write_atomic "$proposal" || { - fm_afk_contract_log "failed to write the proposal at $proposal" - return 1 - } - fm_afk_contract_render_readback "$proposal" 'Away posture read-back (proposed, not yet confirmed):' || return 1 - printf 'Say go to confirm; restate your instructions first if this reading is not what you meant.\n' -} - fm_afk_contract_archive_target() { # <record> [superseded-stamp] local record=$1 stamp=${2:-} dir entered_epoch target dir=$(fm_afk_contract_archive_dir) @@ -436,44 +434,40 @@ fm_afk_contract_archive_target() { # <record> [superseded-stamp] printf '%s\n' "$target" } -fm_afk_contract_cmd_confirm() { - local record proposal body confirmed confirmed_epoch archived archived_tmp staged session_entered session_entered_epoch +# /afk is the go: write the record in this same call, with no proposal and no +# later confirmation step. Inputs were parsed before the lock (WORDS, +# EXPECTED_RETURN, SPEND, FM_AFK_CONTRACT_SCALARS_GIVEN). +fm_afk_contract_cmd_enter() { + local record legacy now now_epoch session_entered session_entered_epoch staged archived archived_tmp record=$(fm_afk_contract_path) - proposal=$(fm_afk_contract_proposal_path) - confirmed=$(fm_afk_contract_now_iso) - confirmed_epoch=$(date +%s) - if [ -f "$proposal" ]; then - fm_afk_contract_validate "$proposal" 0 || return 1 - body=$(cat "$proposal") - elif [ -f "$record" ]; then - fm_afk_contract_validate "$record" 1 || return 1 - fm_afk_contract_log "away posture already recorded at $(fm_afk_contract_read_field "$record" entered); nothing to confirm" + legacy=$(fm_afk_contract_legacy_proposal_path) + if [ -f "$record" ] && [ -z "$WORDS" ]; then + fm_afk_contract_validate "$record" || return 1 + fm_afk_contract_log "away posture already recorded at $(fm_afk_contract_read_field "$record" entered); a refresh leaves it untouched" + if [ "$FM_AFK_CONTRACT_SCALARS_GIVEN" -eq 1 ]; then + fm_afk_contract_log "the expected return and spend cap given with this refresh were not applied; enter new words to replace the mandate" + fi + rm -f "$legacy" fm_afk_contract_render_announcement "$record" || return 1 - return 0 - else - fm_afk_contract_log "no away-posture proposal exists; run propose before confirm" - return 1 + fm_afk_contract_render_readback "$record" 'Away posture (recorded):' + return fi - session_entered=$confirmed - session_entered_epoch=$confirmed_epoch + now=$(fm_afk_contract_now_iso) + now_epoch=$(date +%s) + session_entered=$now + session_entered_epoch=$now_epoch if [ -f "$record" ]; then + fm_afk_contract_validate "$record" || return 1 session_entered=$(fm_afk_contract_read_field "$record" entered) session_entered_epoch=$(fm_afk_contract_read_field "$record" entered_epoch) fi - staged=$(mktemp "$(dirname "$record")/.afk-contract.confirming.XXXXXX") || return 1 - { - printf '%s\n' "$body" | awk -v entered="$session_entered" -v epoch="$session_entered_epoch" ' - /^entered: / { print "entered: " entered; next } - /^entered_epoch: / { print "entered_epoch: " epoch; next } - /^words: / { exit } - { print } - ' - printf 'confirmed: %s\nconfirmed_epoch: %s\n' "$confirmed" "$confirmed_epoch" - printf '%s\n' "$body" | awk 'p{print} /^words: /{p=1; print}' - } > "$staged" || { rm -f "$staged"; return 1; } - fm_afk_contract_validate "$staged" 1 || { rm -f "$staged"; return 1; } + mkdir -p "$(dirname "$record")" || return 1 + staged=$(mktemp "$(dirname "$record")/.afk-contract.entering.XXXXXX") || return 1 + fm_afk_contract_render_record "$session_entered" "$session_entered_epoch" "$now" "$now_epoch" > "$staged" \ + || { rm -f "$staged"; return 1; } + fm_afk_contract_validate "$staged" || { rm -f "$staged"; return 1; } if [ -f "$record" ]; then - archived=$(fm_afk_contract_archive_target "$record" "$confirmed_epoch") || { rm -f "$staged"; return 1; } + archived=$(fm_afk_contract_archive_target "$record" "$now_epoch") || { rm -f "$staged"; return 1; } # Copy into a temporary name first and rename atomically, so a failed copy # never leaves a partial archive at a glob-visible name. archived_tmp=$(mktemp "$(dirname "$archived")/.afk-contract.archiving.XXXXXX") || { rm -f "$staged"; return 1; } @@ -490,16 +484,17 @@ fm_afk_contract_cmd_confirm() { if [ -n "${archived:-}" ]; then fm_afk_contract_log "replaced the earlier away posture; its record is archived at $archived" fi - rm -f "$proposal" + rm -f "$legacy" fm_afk_contract_render_announcement "$record" || return 1 + fm_afk_contract_render_readback "$record" 'Away posture (recorded):' } fm_afk_contract_cmd_archive() { local record target record=$(fm_afk_contract_path) [ -f "$record" ] || return 0 - if ! fm_afk_contract_validate "$record" 1; then - fm_afk_contract_log "confirmed away-posture record at $record is invalid; refusing to archive" + if ! fm_afk_contract_validate "$record"; then + fm_afk_contract_log "away-posture record at $record is invalid; refusing to archive" return 1 fi target=$(fm_afk_contract_archive_target "$record") || return 1 @@ -507,12 +502,14 @@ fm_afk_contract_cmd_archive() { printf '%s\n' "$target" } -fm_afk_contract_select_path() { # <args...> -> prints the record path chosen by --proposal/--path +fm_afk_contract_select_path() { # <args...> -> prints the record path chosen by --path local path path=$(fm_afk_contract_path) while [ "$#" -gt 0 ]; do case "$1" in - --proposal) path=$(fm_afk_contract_proposal_path); shift ;; + --proposal) + fm_afk_contract_log "--proposal was retired with the wait-for-go gate: /afk writes the record directly, so read the record itself" + return 2 ;; --path) [ "$#" -gt 1 ] || return 2; path=$2; shift 2 ;; *) return 2 ;; esac @@ -538,18 +535,17 @@ fm_afk_contract_main() { [ -n "$cmd" ] || { fm_afk_contract_usage >&2; return 2; } shift case "$cmd" in - propose) fm_afk_contract_cmd_propose "$@" ;; - confirm) - [ "$#" -eq 0 ] || { fm_afk_contract_usage >&2; return 2; } - fm_afk_contract_locked_cmd fm_afk_contract_cmd_confirm ;; + enter) + fm_afk_contract_parse_inputs "$@" || return 2 + fm_afk_contract_locked_cmd fm_afk_contract_cmd_enter ;; + propose|confirm) + fm_afk_contract_log "'$cmd' was retired with the wait-for-go gate: /afk is itself the go, so run 'enter' to write the record in the same turn" + return 2 ;; readback) - path=$(fm_afk_contract_select_path "$@") || { fm_afk_contract_usage >&2; return 2; } + [ "$#" -eq 0 ] || { fm_afk_contract_select_path "$@" >/dev/null; fm_afk_contract_usage >&2; return 2; } + path=$(fm_afk_contract_path) [ -f "$path" ] || { fm_afk_contract_log "no record at $path"; return 1; } - if [ "$path" = "$(fm_afk_contract_proposal_path)" ]; then - fm_afk_contract_render_readback "$path" 'Away posture read-back (proposed, not yet confirmed):' || return 1 - else - fm_afk_contract_render_readback "$path" 'Away posture (confirmed):' || return 1 - fi ;; + fm_afk_contract_render_readback "$path" 'Away posture (recorded):' || return 1 ;; field) [ "$#" -ge 1 ] || { fm_afk_contract_usage >&2; return 2; } local name=$1; shift @@ -560,11 +556,7 @@ fm_afk_contract_main() { fm_afk_contract_read_words "$path" ;; validate) path=$(fm_afk_contract_select_path "$@") || { fm_afk_contract_usage >&2; return 2; } - if [ "$path" = "$(fm_afk_contract_proposal_path)" ]; then - fm_afk_contract_validate "$path" 0 - else - fm_afk_contract_validate "$path" 1 - fi ;; + fm_afk_contract_validate "$path" ;; clauses|flags|refused|grants) fm_afk_contract_log "'$cmd' was retired with the clause and merge-grant apparatus: the record is the captain's words (read them with 'words' or 'readback')" return 2 ;; diff --git a/bin/fm-afk-launch.sh b/bin/fm-afk-launch.sh index ee8a6e6693f..75d0ea8cb2b 100755 --- a/bin/fm-afk-launch.sh +++ b/bin/fm-afk-launch.sh @@ -1,23 +1,24 @@ #!/usr/bin/env bash # fm-afk-launch.sh - the single owner of away-mode ENTRY and EXIT: the -# read-back-and-confirm entry that writes the away-posture record through +# same-turn entry that writes the away-posture record through # bin/fm-afk-contract.sh, and the away-mode daemon TERMINAL lifecycle where a # daemon still runs: launch it in a NON-VISIBLE tracked terminal per backend, # record its exact id, tear it down by that exact id, and reconcile a leaked one # after a crash. # -# ENTRY (the posture record). `/afk [words]` is two steps so the captain hears -# the mandate back before it binds: `propose` records the captain's away words -# verbatim into a proposal and prints the read-back (bin/fm-afk-contract.sh owns -# the record schema; the words are the whole mandate and no script parses them); -# `confirm` promotes it into state/.afk-contract and prints the entry -# announcement (hold-for-return only: no phone channel exists). The record is -# the posture in every harness. +# ENTRY (the posture record). `/afk [words]` is itself the captain's go, because +# the captain who typed it may not look at the screen again: `enter` records the +# away words verbatim straight into state/.afk-contract in the same turn, with no +# separate confirmation step, then prints the entry announcement (hold-for-return +# only: no phone channel exists) and the read-back, which is informational and +# never waits for a go (bin/fm-afk-contract.sh owns the record schema; the words +# are the whole mandate and no script parses them). The record is the posture in +# every harness. # On Pi and pi-signed the entry ENDS there: the away daemon is no longer launched # on Pi, the ordinary supervision session keeps running in both postures, and # `start` refuses on those harnesses. Every other harness still runs the daemon -# for now, so `start` and `start-native` require the confirmed record before they -# launch the daemon. +# for now, so `start` and `start-native` require the record `enter` wrote before +# they launch the daemon. # `stop` (the return, driven by bin/fm-afk-return.sh) shuts the daemon down, # clears state/.afk last, and archives the record under state/afk-contracts/. # @@ -38,12 +39,13 @@ # FM_SUPERVISOR_TARGET/FM_SUPERVISOR_BACKEND explicitly. # # Usage: -# fm-afk-launch.sh propose [--words-file <path> | --words <text>] -# [--expected-return <UTC ISO 8601>] [--spend <n>] -# Record the captain's away words verbatim into a -# proposal and print the read-back. -# fm-afk-launch.sh confirm Promote the required proposal and print the entry -# announcement. On Pi this is the whole entry. +# fm-afk-launch.sh enter [--words-file <path> | --words <text>] +# [--expected-return <UTC ISO 8601>] [--spend <n>] +# Write the away-posture record now, with no +# separate confirmation, then print the entry +# announcement and the read-back. With no words +# while away it is a refresh; new words replace +# the mandate. On Pi this is the whole entry. # fm-afk-launch.sh start Capture the captain pane, then (unless the daemon # is already running) launch the daemon in a fresh # non-visible terminal for the detected backend and @@ -55,8 +57,10 @@ # background job and record that no terminal exists. # fm-afk-launch.sh stop Correct-ordered exit: SIGTERM the daemon so its # cleanup flushes WHILE state/.afk is still present, -# wait for it, close the recorded terminal by exact -# id, clear state/.afk, then archive the record last. +# wait for it, close a recorded non-native terminal +# by exact id, clear state/.afk, then archive the +# record last. A Pi or native entry that never +# launched a daemon reports that none was running. # fm-afk-launch.sh reconcile Close a recorded-but-dead daemon terminal by exact # id and drop the record (recovery after a crash). # @@ -192,7 +196,7 @@ fm_afk_launch_daemon_allowed() { harness=$(fm_afk_launch_primary_harness) case "$harness" in pi|pi-signed) - fm_afk_launch_log "the away daemon is no longer launched on $harness; the away-posture record is the posture there (run bin/fm-afk-launch.sh confirm and stop)" + fm_afk_launch_log "the away daemon is no longer launched on $harness; the away-posture record is the posture there (run bin/fm-afk-launch.sh enter and stop)" return 1 ;; esac return 0 @@ -210,23 +214,18 @@ fm_afk_launch_record_require() { local record record=$(fm_afk_contract_path "$FM_AFK_LAUNCH_STATE") if ! fm_afk_contract_present "$FM_AFK_LAUNCH_STATE"; then - fm_afk_launch_log "a confirmed away-posture record is required; run propose and confirm before starting the daemon" + fm_afk_launch_log "an away-posture record is required; run enter before starting the daemon" return 1 fi - fm_afk_contract_validate "$record" 1 || { - fm_afk_launch_log "the away-posture record is not confirmed; run confirm before starting the daemon" + fm_afk_contract_validate "$record" || { + fm_afk_launch_log "the away-posture record is unreadable; run enter before starting the daemon" return 1 } } -fm_afk_launch_propose() { +fm_afk_launch_enter() { fm_afk_launch_catchup_pending && return 1 - "$FM_AFK_CONTRACT_CMD" propose "$@" -} - -fm_afk_launch_confirm() { - fm_afk_launch_catchup_pending && return 1 - "$FM_AFK_CONTRACT_CMD" confirm + "$FM_AFK_CONTRACT_CMD" enter "$@" } # The command run inside the created terminal. Real launch runs the shared @@ -655,7 +654,7 @@ fm_afk_launch_start_native() { } fm_afk_launch_stop() { - local pid pid_identity current_identity result=0 read_result archived + local pid pid_identity current_identity result=0 read_result archived closed_daemon_terminal=0 fm_afk_launch_record_read read_result=$? if [ "$read_result" -eq 2 ]; then @@ -691,9 +690,15 @@ fm_afk_launch_stop() { return 1 fi fi - # (2) Close the daemon's own terminal by exact id. + # (2) Close the daemon's own terminal by exact id. A native/none record or + # an absent record means no terminal existed for this entry (Pi never + # launches one). if [ "$read_result" -eq 0 ]; then + if [ "$FM_AFK_REC_BACKEND" != none ]; then + closed_daemon_terminal=1 + fi fm_afk_launch_close_recorded || result=1 + [ "$result" -eq 0 ] || closed_daemon_terminal=0 fi # (3) Clear the away-mode flag, then (4) archive the posture record LAST so the # posture ends only once every daemon-side artifact is down. @@ -710,7 +715,11 @@ fm_afk_launch_stop() { fi fi if [ "$result" -eq 0 ]; then - fm_afk_launch_log "away mode stopped; daemon terminal torn down, .afk cleared, and the posture record archived" + if [ "$closed_daemon_terminal" -eq 1 ]; then + fm_afk_launch_log "away mode stopped; daemon terminal torn down, .afk cleared, and the posture record archived" + else + fm_afk_launch_log "away mode stopped; no daemon terminal was running, .afk cleared, and the posture record archived" + fi else fm_afk_launch_log "away mode stopped; terminal teardown or the record archive remains recorded for retry" fi @@ -729,8 +738,10 @@ fm_afk_launch_main() { trap 'exit 143' TERM fm_afk_launch_lock_acquire || return 1 case "${1:-start}" in - propose) shift; fm_afk_launch_propose "$@" ;; - confirm) fm_afk_launch_confirm ;; + enter) shift; fm_afk_launch_enter "$@" ;; + propose|confirm) + fm_afk_launch_log "'$1' was retired with the wait-for-go gate: /afk is itself the go, so run 'enter' to write the record in the same turn" + (exit 2) ;; start) fm_afk_launch_start ;; start-native) fm_afk_launch_start_native ;; stop) fm_afk_launch_stop ;; diff --git a/bin/fm-afk-return.sh b/bin/fm-afk-return.sh index 05953b725df..08dc5f86b7d 100755 --- a/bin/fm-afk-return.sh +++ b/bin/fm-afk-return.sh @@ -20,9 +20,13 @@ # every action it took under them (each outcome-store row from the window whose # summary opens with the "per your away instructions:" marker the branch prompt # in bin/fm-branch-prompt.sh requires), then what is waiting on the captain, -# then what was tried and failed or could not be fixed, then what the away -# session handled, then cost. The health snapshot is taken BEFORE the daemon -# shutdown so the shutdown itself cannot read as a gap. +# then what was tried and failed or could not be fixed, then landed work whose +# task record is still live (the recorded PR carries the +# merge-notification marker bin/fm-pr-lib.sh owns, read from durable records +# only, never the forge - finished work that owes an ordinary teardown, which +# is fleet work and so waits for the gate rather than holding it), then what +# the away session handled, then cost. The health snapshot is taken BEFORE the +# daemon shutdown so the shutdown itself cannot read as a gap. # # THE GATE. `blocked:` is the crewmate protocol's firstmate-actionable verb. A # live task's open blocked event must be remediated and closed with @@ -405,9 +409,26 @@ render_words_account() { # the away session's account of what it did under the fi } +# Live task records whose recorded PR the merge outcome path already marked +# merged: the notification marker bin/fm-pr-lib.sh owns, written by +# bin/fm-merge-outcome-lib.sh for a merge this home performed or observed. +# That is landed work nobody closed. Durable records only, never the forge. +scan_landed_awaiting_cleanup() { # -> <task>\t<url> rows + local meta task + for meta in "$STATE"/*.meta; do + [ -f "$meta" ] || continue + task=$(basename "$meta"); task=${task%.meta} + fm_pr_metadata_identity_parse "$meta" || continue + fm_pr_poll_merge_already_notified "$STATE" "$task" \ + "$FM_PR_META_PROVIDER" "$FM_PR_META_HOST" "$FM_PR_META_PATH" "$FM_PR_META_NUMBER" \ + || continue + printf '%s\t%s\n' "$task" "$FM_PR_META_URL" + done +} + render_return_brief() { # <evidence-file> <blockers-file> <since-epoch> local evidence=$1 blockers=$2 since=$3 now record superseded superseded_at archive_dir stamp - local tag task key summary count routine captain live held_err last verb rows status + local tag task key summary count routine captain live held_err last verb rows status url now=$(date +%s) printf '=== Return brief' if [ -n "$since" ]; then @@ -502,7 +523,21 @@ EOF done [ "$count" -gt 0 ] || printf ' (nothing)\n' - # 5. handled while away. Every outcome the away session recorded in the + # 5. landed, cleanup due: finished work whose task record is still live. + # Listing it keeps a landed task that remains live past the return from being + # overlooked. The cleanup itself is ordinary fleet work and waits for the gate. + printf 'Landed, cleanup due:\n' + count=0 + while IFS="$(printf '\t')" read -r task url; do + [ -n "$task" ] || continue + count=$((count + 1)) + printf ' - %s: %s is merged and the worker is still up; close it with bin/fm-teardown.sh %s once catch-up clears\n' "$task" "$url" "$task" + done <<EOF +$(scan_landed_awaiting_cleanup) +EOF + [ "$count" -gt 0 ] || printf ' (nothing)\n' + + # 6. 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. @@ -517,7 +552,7 @@ EOF printf ' (no routine outcomes recorded in the store for this window)\n' fi - # 6. cost. + # 7. cost. live=0 for meta in "$STATE"/*.meta; do [ -f "$meta" ] && live=$((live + 1)); done printf 'Cost: %s supervision outcome(s) recorded (%s routine, %s captain); %s task(s) live at return.\n' \ @@ -553,7 +588,7 @@ return_reconcile() { remove_evidence lifecycle "away-posture record unreadable: $retained_live; catch-up stays gated" "$evidence" || lifecycle_ok=0 append_evidence lifecycle "away-posture record missing: $retained_live; catch-up stays gated" "$evidence" lifecycle_ok=0 - elif ! fm_afk_contract_validate "$retained_live" 1; then + elif ! fm_afk_contract_validate "$retained_live"; then remove_evidence lifecycle "away-posture record missing: $retained_live; catch-up stays gated" "$evidence" || lifecycle_ok=0 append_evidence lifecycle "away-posture record unreadable: $retained_live; catch-up stays gated" "$evidence" lifecycle_ok=0 @@ -599,7 +634,7 @@ EOF append_evidence wake "$drained" "$evidence" if fm_afk_contract_present "$STATE"; then - if ! fm_afk_contract_validate "$(fm_afk_contract_path "$STATE")" 1; then + if ! fm_afk_contract_validate "$(fm_afk_contract_path "$STATE")"; then append_evidence lifecycle "away-posture record unreadable: $(fm_afk_contract_path "$STATE"); catch-up stays gated" "$evidence" lifecycle_ok=0 else @@ -610,7 +645,7 @@ EOF if [ -z "$archived_contract" ]; then append_evidence lifecycle "archived away-posture record missing for entered_epoch $contract_since; catch-up stays gated" "$evidence" lifecycle_ok=0 - elif ! fm_afk_contract_validate "$archived_contract" 1; then + elif ! fm_afk_contract_validate "$archived_contract"; then append_evidence lifecycle "archived away-posture record unreadable for entered_epoch $contract_since; catch-up stays gated" "$evidence" lifecycle_ok=0 else @@ -625,7 +660,7 @@ EOF remove_evidence lifecycle "superseded away-posture record unreadable: $retained_record; catch-up stays gated" "$evidence" || lifecycle_ok=0 append_evidence lifecycle "superseded away-posture record missing: $retained_record; catch-up stays gated" "$evidence" lifecycle_ok=0 - elif ! fm_afk_contract_validate "$retained_record" 1; then + elif ! fm_afk_contract_validate "$retained_record"; then remove_evidence lifecycle "superseded away-posture record missing: $retained_record; catch-up stays gated" "$evidence" || lifecycle_ok=0 append_evidence lifecycle "superseded away-posture record unreadable: $retained_record; catch-up stays gated" "$evidence" lifecycle_ok=0 @@ -640,7 +675,7 @@ EOF for superseded_record in "$(fm_afk_contract_archive_dir "$STATE")/$contract_since-superseded-"*.afk-contract; do [ -f "$superseded_record" ] || continue - if ! fm_afk_contract_validate "$superseded_record" 1; then + if ! fm_afk_contract_validate "$superseded_record"; then append_superseded_record "$superseded_record" "$evidence" append_evidence lifecycle "superseded away-posture record unreadable: $superseded_record; catch-up stays gated" "$evidence" lifecycle_ok=0 @@ -728,6 +763,8 @@ main() { . "$SCRIPT_DIR/fm-tasks-axi-lib.sh" # shellcheck source=bin/fm-backlog-transition-lib.sh . "$SCRIPT_DIR/fm-backlog-transition-lib.sh" + # shellcheck source=bin/fm-pr-lib.sh + . "$SCRIPT_DIR/fm-pr-lib.sh" mkdir -p "$STATE" || return 1 fm_lock_acquire_wait "$LOCK" diff --git a/bin/fm-bootstrap.sh b/bin/fm-bootstrap.sh index 6caff8366b7..d65ae5eaac1 100755 --- a/bin/fm-bootstrap.sh +++ b/bin/fm-bootstrap.sh @@ -938,7 +938,7 @@ NO_MISTAKES_MIN=1.46.0 # tasks-axi feature probes are an independent defense-in-depth concern, not part # of its floor. GH_AXI_MIN=0.1.29 -LAVISH_AXI_MIN=0.1.46 +LAVISH_AXI_MIN=0.1.77 treehouse_supports_lease() { treehouse get --help 2>&1 | grep -Eq '(^|[^[:alnum:]_-])--lease([^[:alnum:]_-]|$)' diff --git a/bin/fm-branch-prompt.sh b/bin/fm-branch-prompt.sh index 7808e24c662..360cef39646 100755 --- a/bin/fm-branch-prompt.sh +++ b/bin/fm-branch-prompt.sh @@ -47,7 +47,7 @@ Handle it start to finish in one turn sequence: 2. For each task you are about to mutate, claim its lease first: `bin/fm-lease.sh claim <task>`. Claim the reserved `backlog` lease around backlog writes (`bin/fm-lease.sh claim backlog`, then `bin/fm-tasks-axi.sh ...`, then release). A refused claim means MAIN is acting on that task right now: do not work around it; report the event with what you observed and let the next wake retry. -3. Handle with real tools: `bin/fm-crew-state.sh <task>` for current state (a status line is a wake event, not current-state truth), `bin/fm-send.sh` for a short steer, `bin/fm-control.sh <task> interrupt|exit|relaunch` for lifecycle, `bin/fm-pr-check.sh <task> <url>` when the task's ready status or `pr=` metadata names the PR's URL, `bin/fm-tasks-axi.sh` for backlog moves. +3. Handle with real tools: `bin/fm-crew-state.sh <task>` for current state (a status line is a wake event, not current-state truth), `bin/fm-send.sh` for a short steer, `bin/fm-control.sh <task> interrupt|exit|relaunch` for lifecycle, `bin/fm-pr-check.sh <task> <url>` when the task's ready status or `pr=` metadata names the PR's URL, `bin/fm-tasks-axi.sh` for backlog moves, and `bin/fm-teardown.sh <task>` for the ordinary cleanup of a task whose PR has landed. 4. Report: call the fm_branch_report tool exactly once per handled event, with the task id, the verdict, and a one-or-two-sentence summary; set silent true only for a fleet-wide heartbeat review that found literally nothing worth reporting. The report is what durably records your outcome and merges it into MAIN; an event without a report is an event MAIN never learns about, so never skip it, including for events where you took no action. 5. Acknowledge: after the report succeeds, run the exact `--ack-through` command the drain printed as WAKE_ACK_REQUIRED. @@ -61,6 +61,11 @@ Never report verdict captain merely to say the fleet is quiet; a no-op heartbeat For a stale, looping, confused, or unresponsive worker, follow the recovery playbook included at the end of this prompt. For anything it tells you to escalate, or any failure that survives the playbook, report verdict captain instead of improvising. +A worker whose pull request has landed is finished, not stuck, and closing it is your job in both postures. +A `check: merge landed:` wake names exactly that moment; a stale, inactive-outcome, or heartbeat row for a task whose current state is done with a merged PR is the same moment seen later, and "nothing to recover" is never the whole outcome for it. +Claim the task's lease and run `bin/fm-teardown.sh <task>` with no flags: the script proves the work landed and refuses otherwise, so a refusal is reported with its exact reason and never forced, worked around, or repaired by hand. +Report the cleanup in that event's outcome with the PR's URL. + # Verdict: routine or captain Report verdict captain for the finished result of work the captain requested, even when that result is healthy. @@ -96,7 +101,7 @@ The Postures section below is the one, bounded exception to the first three limi # 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. +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` in the same turn as the captain's `/afk` 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. The record is the captain's away words, recorded verbatim: the explicit instruction the captain gave before leaving, and the whole mandate. diff --git a/bin/fm-busy-lib.sh b/bin/fm-busy-lib.sh index d8f7a0ee111..9644152a7f6 100755 --- a/bin/fm-busy-lib.sh +++ b/bin/fm-busy-lib.sh @@ -44,21 +44,51 @@ # Classifier-only sources (never written into a record): # endpoint-gone, herdr-native, grok-regex, rovo-regex, agy-regex, muse-session-log, # cursor-transcript, missing, malformed, gen-mismatch, source-mismatch, -# kimi-unverified, codex-unverified, capture-failed, no-target +# kimi-unverified, codex-unverified, capture-failed, no-target, launch-prompt # # Classification (fm_busy_classify): busy | idle | unknown | dead, always # with the producing source as the second token. Precedence: # 1. dead endpoint (fm_busy_classify_live only) -> dead endpoint-gone # 2. standalone Kimi before verification -> unknown kimi-unverified -# 3. a valid, gen-matching, source-trusted record -> its state and source +# 3. a valid, gen-matching, source-trusted record -> its state and source, +# UNLESS the record is still the untouched seed fm-spawn wrote at arm +# time (state=busy source=fm-spawn - no adapter hook has posted since +# launch) AND the caller supplied a captured tail that matches that +# harness's own recognized interactive-prompt signature (a trust +# dialog, sign-in screen, or first-run menu - fm_busy_launch_prompt_parked +# owns the per-harness table). That combination classifies unknown +# launch-prompt instead: the launch never actually started the brief, so +# it must not read as proof of an active turn. A record that has +# advanced past fm-spawn (any real hook event) is NEVER reclassified +# this way, however its rendered tail looks, so a genuinely working turn +# keeps its ordinary busy verdict and the general BUSY_TURN_MAX_SECS +# bound is unchanged. # 4. no record at all: herdr's native busy verdict is trusted as busy # (generation state is sufficient for busy, not for idle), then the # muse session-log and cursor transcript pull sources, then the # Grok/Rovo/AGY temporary regex fallbacks classify a grok, rovo, or agy # task from its rendered tail, then unknown missing # 5. malformed, stale, or untrusted records -> unknown, never a fallback -# Grok, Rovo, and AGY are the ONLY rendered-text classifications that survive the -# redesign, because none of their structured lifecycles was credited-live-verified +# +# fm_busy_launch_prompt_parked (the launch-prompt classifier-only source): a +# launch whose busy record never advanced past the fm-spawn seed is +# indistinguishable, from the record alone, between "still reading its +# brief" and "parked on an interactive prompt the harness never gets past +# without a human" - a Claude/Gemini/Pi workspace-trust dialog, a sign-in or +# auth-method picker, or a first-run setup menu. Left alone this reads as +# ordinary busy for the full BUSY_TURN_MAX_SECS (one hour) before the +# separate wedge-suspect bound even looks at it. The signature table matches +# each harness's own verified rendered dialog text (see +# .agents/skills/harness-adapters/references/harness/*.md and +# docs/verification/*.md for the evidence), scoped to the exact harness that +# renders it so one adapter's ordinary output can never match another's +# dialog. This is a best-effort backstop, not prevention: it never suppresses +# a real busy verdict once any hook has posted, and it defers to whatever +# harness-specific trust pre-registration already exists (fm-claude-trust.sh, +# GEMINI_CLI_TRUST_WORKSPACE) to stop the dialog from appearing at all. +# Apart from the launch-prompt backstop above, Grok, Rovo, and AGY are the ONLY +# rendered-text busy fallbacks that survive the redesign, because none of their +# structured lifecycles was credited-live-verified # in the approved audit (Rovo's clean ACP stopReason lives outside the TUI # path firstmate drives, see references/harness/rovo.md; agy 1.2.0 exposes no # hook surface at all, see references/harness/agy.md); each is scoped to @@ -867,12 +897,122 @@ fm_busy_agy_tail_busy() { | grep -qiE 'esc[[:space:]]+to[[:space:]]+cancel' } +# --- launch-prompt signatures (fm_busy_launch_prompt_parked) ---------------- +# +# Each function consumes a captured pane tail on stdin (the caller's whole +# tail40, NOT reduced to the last 12 non-blank lines the way the Grok/Rovo/AGY +# busy footers above are): a bordered dialog box renders many short lines of +# pure border/padding (`│ ... │`) that are NOT whitespace-only, so a 12-line +# non-blank reduction was verified live to push the box's own heading text +# (e.g. Gemini's "How would you like to authenticate for this project?") +# outside the window entirely, silently defeating the match. Matching the +# full capture avoids that trap; a signature is still best-effort exactly like +# the footer fallbacks - a screen taller than the capture can still scroll a +# signature out, so absence never proves the pane is NOT parked, only that +# this check cannot confirm it. + +# fm_busy_claude_launch_prompt_tail: Claude's workspace-trust dialog +# ("Quick safety check: Is this a project you created or one you trust?", +# re-verified live on Claude Code 2.1.278, docs/verification/runtime-backends.md +# "Launch-prompt backstop signatures") and its separate external-CLAUDE.md- +# imports dialog ("Allow external CLAUDE.md file imports?", verified by +# disassembly, .agents/skills/harness-adapters/references/harness/claude.md +# "Hook trust" sibling section). fm-claude-trust.sh pre-registers both before +# launch; this is the backstop for when that registration did not take effect. +# Each dialog's own question text is paired with one of its own rendered +# option/footer lines, both required together: the question text alone is +# plausible self-referential prose a firstmate-repo worker could easily render +# on its own (fm-claude-trust.sh's header literally quotes both questions), +# but the option/footer pairing only ever renders inside the real dialog. +fm_busy_claude_launch_prompt_tail() { + local buf + buf=$(cat) + if printf '%s' "$buf" | grep -qiE "${FM_BUSY_CLAUDE_TRUST_PROMPT_REGEX:-Quick safety check: Is this a project you created or one you trust\\?}" \ + && printf '%s' "$buf" | grep -qiE 'No, exit|Enter to confirm'; then + return 0 + fi + printf '%s' "$buf" | grep -qiE "${FM_BUSY_CLAUDE_IMPORTS_PROMPT_REGEX:-Allow external CLAUDE\\.md file imports\\?}" \ + && printf '%s' "$buf" | grep -qiE 'No, disable external imports|Yes, allow external imports' +} + +# fm_busy_pi_launch_prompt_tail: Pi's project-trust dialog. Live-verified on +# pi 0.86.1 (2026-09-22) in a fresh untrusted worktree carrying a project-local +# .pi/extensions/ file (the shape a real ship/scout spawn always launches +# into): the rendered heading is "Trust project folder?" and its declining +# option is literally "Do not trust". An initial guess sourced only from the +# installed binary's UI strings ("Project trust", the internal panel-title +# component name, not this dialog's own rendered heading) was proven wrong by +# that live run and never matched the real screen - which is exactly why this +# class of check must be proven end to end rather than read off strings or a +# name. Matching BOTH the heading and "Do not trust" keeps this from firing on +# a worker's own prose that happens to use the common word "trust" alone. +# Covers omp too: it shares Pi's engine and the same project-trust gate. +fm_busy_pi_launch_prompt_tail() { + local buf + buf=$(cat) + printf '%s' "$buf" | grep -qiE "${FM_BUSY_PI_LAUNCH_PROMPT_REGEX:-Trust project folder\\?}" \ + && printf '%s' "$buf" | grep -qiE 'Do not trust' +} + +# fm_busy_gemini_launch_prompt_tail: Gemini's workspace-trust dialog ("Do you +# trust the files in this folder?"), its first-run auth-method picker ("How +# would you like to authenticate for this project?"), and the credential +# entry it falls through to with no resolvable key ("Enter Gemini API Key"). +# GEMINI_CLI_TRUST_WORKSPACE=true (fm-spawn.sh's launch template) already +# suppresses the first; the other two have no pre-registration and are the +# primary target of this backstop. The trust dialog and the auth-method picker +# were live-verified on gemini 0.60.0 in a credential-less scratch environment +# (docs/verification/runtime-backends.md "Launch-prompt backstop signatures"), +# and each question is paired with one of its own rendered option lines, +# required together, for the same reason as Claude's pairing above: the +# question text alone is plausible prose this very file's own comments could +# render. The auth-method picker's live capture is also what proved the +# full-capture match necessary: its heading renders more than 12 non-blank- +# looking lines above the bordered box's bottom border. The API-key entry +# screen is carried over from .agents/skills/harness-adapters/references/ +# harness/gemini.md "Trust, and why the two documented options are not +# equivalent" rather than this guard's own live capture, and stays a single +# marker: it is reached only after actively selecting that auth method, so +# self-referential prose is a materially smaller risk there. +fm_busy_gemini_launch_prompt_tail() { + local buf + buf=$(cat) + if printf '%s' "$buf" | grep -qiE "${FM_BUSY_GEMINI_TRUST_PROMPT_REGEX:-Do you trust the files in this folder\\?}" \ + && printf '%s' "$buf" | grep -qiE "Trust folder|Don't trust"; then + return 0 + fi + if printf '%s' "$buf" | grep -qiE "${FM_BUSY_GEMINI_AUTH_PROMPT_REGEX:-How would you like to authenticate for this project\\?}" \ + && printf '%s' "$buf" | grep -qiE 'Use Gemini API Key|No authentication method selected'; then + return 0 + fi + printf '%s' "$buf" | grep -qiE "${FM_BUSY_GEMINI_APIKEY_PROMPT_REGEX:-Enter Gemini API Key}" +} + +# fm_busy_launch_prompt_parked: dispatch to the signature above for <harness>, +# or fail when this harness has none. Consumes the tail on stdin. Scoped to +# exactly the harnesses fm-spawn.sh arms with the fm-spawn busy source +# (claude*, opencode*, pi, pi-signed, omp, gemini) since only those can ever +# read a pinned "busy fm-spawn" record; codex and standalone Kimi already +# classify unknown before a record is ever consulted, and opencode ships no +# trust dialog at all. +fm_busy_launch_prompt_parked() { # <harness> + case "${1:-}" in + claude*) fm_busy_claude_launch_prompt_tail ;; + pi | pi-signed | omp) fm_busy_pi_launch_prompt_tail ;; + gemini) fm_busy_gemini_launch_prompt_tail ;; + *) return 1 ;; + esac +} + # fm_busy_classify: semantic classification for a task whose endpoint the # caller has already established as present. Prints "<verdict> <source>": # busy|idle|unknown plus the producing source (see header). Never probes -# process state. <tail40> is optional pre-captured plain output used only by -# the grok, rovo, and agy arms; when absent each captures through -# fm_backend_capture if available, else reports unknown capture-failed. +# process state. <tail40> is optional pre-captured plain output: the grok, +# rovo, and agy arms capture it themselves through fm_backend_capture when it +# is absent (or report unknown capture-failed if that is unavailable too), +# while the launch-prompt backstop below has no capture fallback of its own - +# without a supplied tail40 it is skipped entirely and a record still pinned +# at the fm-spawn seed keeps reading busy fm-spawn, unchanged. fm_busy_classify() { # <backend> <target> <harness> <id> <state-dir> [tail40] local backend=$1 target=$2 harness=$3 id=$4 state=$5 tail40=${6-} local out rc r_state r_source native log @@ -914,7 +1054,12 @@ fm_busy_classify() { # <backend> <target> <harness> <id> <state-dir> [tail40] out=${out#* } r_source=${out%% *} if fm_busy_source_trusted "$harness" "$r_source"; then - printf '%s %s' "$r_state" "$r_source" + if [ "$r_state" = busy ] && [ "$r_source" = fm-spawn ] && [ -n "$tail40" ] \ + && printf '%s' "$tail40" | fm_busy_launch_prompt_parked "$harness"; then + printf 'unknown launch-prompt' + else + printf '%s %s' "$r_state" "$r_source" + fi else printf 'unknown source-mismatch' fi @@ -1039,7 +1184,8 @@ fm_busy_classify_live() { # <backend> <target> <harness> <id> <state-dir> [expe # fm_busy_classify_meta: classify a task from its recorded metadata, so every # consumer resolves backend, target, and harness the same way instead of # re-deriving them. Requires fm-backend.sh to be sourced. <tail40> is -# optional pre-captured plain output reused by the Grok arm. +# optional pre-captured plain output reused by the contract's rendered-text +# checks: the Grok/Rovo/AGY busy fallbacks and the launch-prompt backstop. fm_busy_classify_meta() { # <meta-file> <id> <state-dir> [tail40] local meta=$1 id=$2 state=$3 tail40=${4-} backend target harness [ -f "$meta" ] || { printf 'unknown missing'; return 0; } diff --git a/bin/fm-classify-lib.sh b/bin/fm-classify-lib.sh index cc56ed3e06a..4e993574ead 100755 --- a/bin/fm-classify-lib.sh +++ b/bin/fm-classify-lib.sh @@ -27,7 +27,7 @@ # A missing, malformed, identity-mismatched, or past-end classified position reads # from byte 0, preferring a bounded duplicate over a lost event. # -# There are three documented exceptions. The absorb classification +# There are four documented exceptions. The absorb classification # (crew_absorb_class and its working/paused wrappers) is NOT a pure status-file # read: it reuses bin/fm-crew-state.sh, which may make a bounded no-mistakes call, # to decide whether a crew that just stopped its turn or went stale is working, @@ -37,9 +37,12 @@ # open-decisions fold" below) also writes: it persists a per-status-file byte # cursor and folded open-set as a side effect, so a per-drain fleet-wide scan # stays bounded by new appends instead of re-reading each task's whole lifetime -# log every time. crew_worktree_written_since reads the task's meta file and walks -# a bounded slice of its worktree instead of a status file, so callers run it only -# at the moment they would otherwise escalate. +# log every time. status_home_appends_record writes the per-task home-owned +# append ledger (see "home-owned status-append ledger" below) so the wake scan +# can treat this home's own bookkeeping bytes as already owned. +# crew_worktree_written_since reads the task's meta file and walks a bounded slice +# of its worktree instead of a status file, so callers run it only at the moment +# they would otherwise escalate. # Directory of this library, used to locate the sibling fm-crew-state.sh reader. # Resolved at source time from BASH_SOURCE so it works whether sourced by a @@ -1502,13 +1505,15 @@ status_presentation_marker_commit() { status_retire_presentation_task() { # <state> <task-id> local state=$1 task=$2 lock manifest tmp data row_task ident offset backstop extra rc=0 found=0 - local signal_marker heartbeat_marker daemon_marker + local signal_marker heartbeat_marker daemon_marker home_appends home_appends_lock lock="$state/.status-presentation-lock" manifest="$state/.status-presentation-cursor" tmp="$manifest.tmp.$$" signal_marker=$(status_signal_seen_marker_path "$state" "$task") heartbeat_marker=$(status_heartbeat_seen_marker_path "$state" "$task") daemon_marker=$(status_daemon_seen_marker_path "$state" "$task") + home_appends="$state/.$task.home-appends" + home_appends_lock="$home_appends.lock" # A remote-home teardown can legitimately retire an endpoint ID that has no # status log in that home. Do not contend with that home's unrelated status @@ -1518,6 +1523,8 @@ status_retire_presentation_task() { # <state> <task-id> if [ ! -e "$state/$task.status" ] && [ ! -L "$state/$task.status" ] \ && [ ! -e "$state/.$task.open-decisions-cursor" ] \ && [ ! -L "$state/.$task.open-decisions-cursor" ] \ + && [ ! -e "$home_appends" ] && [ ! -L "$home_appends" ] \ + && [ ! -e "$home_appends_lock" ] && [ ! -L "$home_appends_lock" ] \ && [ ! -e "$signal_marker" ] && [ ! -L "$signal_marker" ] \ && [ ! -e "$heartbeat_marker" ] && [ ! -L "$heartbeat_marker" ] \ && [ ! -e "$daemon_marker" ] && [ ! -L "$daemon_marker" ]; then @@ -1567,7 +1574,8 @@ EOF fi if [ "$rc" -eq 0 ]; then rm -f -- "$state/$task.status" "$state/.$task.open-decisions-cursor" \ - "$signal_marker" "$heartbeat_marker" "$daemon_marker" || rc=1 + "$home_appends" "$signal_marker" "$heartbeat_marker" "$daemon_marker" || rc=1 + fm_lock_remove_path "$home_appends_lock" 2>/dev/null || true fi fm_lock_release "$lock" || rc=1 return "$rc" @@ -1916,6 +1924,134 @@ window_to_task() { t="${w##*:}"; t="${t#fm-}"; printf '%s' "$t" } +# --- home-owned status-append ledger ---------------------------------------- +# +# This home's bookkeeping closes (fm_wake_status_append_self_announced) record +# the exact byte range they appended so the wake scan can tell this home's own +# growth from a foreign write. That is the multi-answer path: two distinct +# --resolve-key closes must not each force a captain-facing wake solely because +# each one appended a status line, while a worker-authored line that is not in +# this ledger still signals. +# fm_wake_signal_seen_current (bin/fm-wake-lib.sh) is the ONLY consumer. The +# ledger decides whether growth wakes this home and nothing else: it never +# removes a line from presentation, so the drain's signal annotation and its +# UNREAD STATUS section both still print these bytes. +# The ledger does not use lag verbs to hide a worker `resolved` line; only +# bytes this home itself recorded as owned are ever treated as owned. +# +# Path: state/.<task>.home-appends +# Format: +# v1 +# ident=<file-ident> +# <start><TAB><end> +# Ranges are half-open [start, end), written in the order they were appended. +# The only writer is fm_wake_status_append_self_announced, which records the +# pre- and post-append size of an append-only log it just grew, so each new +# start is at or after the last recorded end; a new range that begins exactly +# where the last one ended extends that line instead of adding another. +# status_home_appends_covers depends on that ascending order: it walks the +# ledger once and ignores any range starting past the point it has reached, so +# a ledger written out of order would refuse to prove coverage and fail toward +# waking, never toward silence. +# An identity mismatch (file rotated) discards the ledger. Teardown deletes it. +# Not a pure status-file read: status_home_appends_record writes this sidecar. +# That read-merge-write serializes through bin/fm-wake-lib.sh's fm_lock_* +# helpers, exactly as status_retire_presentation_task above does, so a caller +# that touches this ledger must have sourced that library first. + +status_home_appends_path() { # <status-file> + local f=$1 dir base + dir=$(dirname "$f") + base=$(basename "$f") + printf '%s/.%s.home-appends' "$dir" "${base%.status}" +} + +status_home_appends_ranges() { # <status-file> -> start<TAB>end lines + local f=$1 path ident data first rest line start end extra + path=$(status_home_appends_path "$f") + [ -f "$path" ] && [ -r "$path" ] && [ ! -L "$path" ] || return 0 + ident=$(_fm_open_decisions_file_ident "$f") || return 0 + data=$(LC_ALL=C command cat "$path" 2>/dev/null) || return 0 + first=${data%%$'\n'*} + [ "$first" = v1 ] || return 0 + rest=${data#*$'\n'} + [ "$rest" != "$data" ] || return 0 + line=${rest%%$'\n'*} + case "$line" in ident=*) ;; *) return 0 ;; esac + [ "${line#ident=}" = "$ident" ] || return 0 + case "$rest" in + *$'\n'*) rest=${rest#*$'\n'} ;; + *) return 0 ;; + esac + while IFS=$(printf '\t') read -r start end extra || [ -n "$start" ]; do + [ -n "$start" ] || continue + [ -z "$extra" ] || continue + case "$start:$end" in *[!0-9:]*) continue ;; esac + [ "$end" -gt "$start" ] || continue + printf '%s\t%s\n' "$start" "$end" || return 1 + done <<EOF +$rest +EOF +} + +status_home_appends_covers() { # <status-file> <start> <end> + local start=$2 end=$3 range_start range_end + case "$start:$end" in *[!0-9:]*) return 1 ;; esac + [ "$end" -ge "$start" ] || return 1 + while IFS=$(printf '\t') read -r range_start range_end; do + [ -n "$range_start" ] || continue + case "$range_start:$range_end" in *[!0-9:]*) continue ;; esac + [ "$range_start" -le "$start" ] || continue + if [ "$range_end" -gt "$start" ]; then + start=$range_end + fi + if [ "$start" -ge "$end" ]; then + return 0 + fi + done <<EOF +$(status_home_appends_ranges "$1") +EOF + [ "$start" -ge "$end" ] +} + +status_home_appends_record() { # <status-file> <start> <end> + local f=$1 start=$2 end=$3 path lock rc=0 + case "$start:$end" in *[!0-9:]*) return 1 ;; esac + [ "$end" -gt "$start" ] || return 1 + path=$(status_home_appends_path "$f") + lock="$path.lock" + fm_lock_acquire_wait "$lock" || return 1 + _fm_status_home_appends_merge_locked "$f" "$path" "$start" "$end" || rc=1 + fm_lock_release "$lock" || rc=1 + return "$rc" +} + +_fm_status_home_appends_merge_locked() { # <status-file> <ledger-path> <start> <end> + local f=$1 path=$2 start=$3 end=$4 ident tmp line last='' body='' coalesced=0 + local LC_ALL=C + ident=$(_fm_open_decisions_file_ident "$f") || return 1 + while IFS= read -r line; do + [ -n "$line" ] || continue + if [ -n "$last" ]; then body="${body}${last}"$'\n'; fi + last=$line + done <<EOF +$(status_home_appends_ranges "$f") +EOF + if [ -n "$last" ]; then + if [ "${last#*$'\t'}" = "$start" ]; then + last="${last%%$'\t'*}"$'\t'"$end" + coalesced=1 + fi + body="${body}${last}"$'\n' + fi + if [ "$coalesced" -eq 0 ]; then + body="${body}${start}"$'\t'"${end}"$'\n' + fi + tmp="$path.tmp.$$" + printf 'v1\nident=%s\n%s' "$ident" "$body" > "$tmp" || { rm -f "$tmp"; return 1; } + mv -f "$tmp" "$path" || { rm -f "$tmp"; return 1; } +} + # Capture the bytes of an append-only status log at or after <start-offset> under # one size-and-identity snapshot. # The record form produces `<endpoint>\t<identity>\t<events>` and returns 0 when diff --git a/bin/fm-control.sh b/bin/fm-control.sh index 5a9bffcec2e..c3ff38f21f0 100755 --- a/bin/fm-control.sh +++ b/bin/fm-control.sh @@ -829,10 +829,10 @@ safe_checkpoint() { marker=$(cat "$WT/.fm-secondmate-home" 2>/dev/null || true) [ "$marker" = "$ID" ] \ || die "task $ID's home $WT is not marked as its own seeded secondmate home (marker: ${marker:-none}); refusing to relaunch" - [ -d "$WT/state" ] \ + # Do not walk state/ with find(1): watcher scratch files can vanish + # mid-scan and make find fail even when every child *.meta is readable. + [ -d "$WT/state" ] && [ -r "$WT/state" ] && [ -x "$WT/state" ] \ || die "secondmate $ID's home has no readable state directory, so its child work cannot be accounted for; refusing to relaunch" - find "$WT/state" -mindepth 1 -maxdepth 1 -print >/dev/null 2>&1 \ - || die "secondmate $ID's child records cannot be traversed; refusing to relaunch" children=0 for child_meta in "$WT/state"/*.meta; do if [ ! -e "$child_meta" ] && [ ! -L "$child_meta" ]; then diff --git a/bin/fm-crew-state.sh b/bin/fm-crew-state.sh index 86239e8b95b..8a75968c41b 100755 --- a/bin/fm-crew-state.sh +++ b/bin/fm-crew-state.sh @@ -10,6 +10,8 @@ # current state from a tail of the log: it reads the authoritative source (a # no-mistakes run-step attributed under bin/fm-nm-run-lib.sh's contract, else # the pane busy-signature) and reconciles the possibly-stale log against it. +# A ship `done:` is current-state done only when bin/fm-dod-lib.sh accepts the +# named head as reachable outside the worker's disposable copy; otherwise blocked. # # The determinism lives entirely here - run-step / pane / log reads, fixed # mapping logic, and terminal passed-run PR detail from bounded evidence only, @@ -94,10 +96,13 @@ # The run-step is AUTHORITATIVE: running/fixing -> working, ci -> working # (the id-addressed detail read carries step words the overview does not), # awaiting_approval/fix_review -> parked (with gate findings), terminal -# passed/checks-passed -> done, failed/cancelled -> failed. EXCEPT: while +# passed/checks-passed/passed-with-override -> done, failed/cancelled -> +# failed. passed-with-override is a passing outcome carrying an +# explicitly approved Test or CI exception (no-mistakes' own vocabulary), +# read identically to a clean passed. EXCEPT: while # the active step is ci, `axi status` alone cannot tell "still waiting on # checks" from "checks green, waiting on merge" (see nm_ci_checks_state) - -# a ci-step log-tail check overrides working -> done once checks read +# a check of the full ci-step log overrides working -> done once checks read # green, so a green PR is never silently read as still-validating. And a # terminal FAILED run whose only failure is the ci monitor step, after # every substantive step completed and the ci log's last marker reads @@ -165,6 +170,8 @@ STATE="${FM_STATE_OVERRIDE:-$FM_HOME/state}" . "$SCRIPT_DIR/fm-pr-lib.sh" # shellcheck source=bin/fm-timeout-lib.sh . "$SCRIPT_DIR/fm-timeout-lib.sh" +# shellcheck source=bin/fm-dod-lib.sh +. "$SCRIPT_DIR/fm-dod-lib.sh" ID=${1:-} [ -n "$ID" ] || { echo "usage: fm-crew-state.sh <id>" >&2; exit 2; } @@ -221,6 +228,17 @@ fi # a crew with no active run and an idle pane that declared a known external wait # reports `paused` distinctly, so a supervisor reading this sees a declared pause # and its reason rather than a wedge-suspect idle. +# A ship `done:` is not current-state done while bin/fm-dod-lib.sh refuses the +# named-head reachability gate: that claim is blocked so a disposable copy is +# not treated as finished-and-safe. +emit_ship_status_done() { # [extra-detail] + local extra=${1:-} reason + if reason=$(fm_dod_accept_ship_done "$KIND" "$(meta_value mode)" "$WT" "$(meta_value project)" "$LOG_LINE" "$STATE" "$ID" "$META"); then + emit "done" status-log "$(status_line_note "$LOG_LINE")${extra:+${SEP}$extra}" + fi + emit blocked status-log "$reason" +} + map_log_state() { # <line> if status_is_paused "$1"; then echo paused @@ -299,12 +317,15 @@ pane_readable() { # <target> # isolated rendered-tail fallback; a herdr crew's native `busy` is accepted # when no record exists, but its native `idle` is NOT, because agent.get # reports generation state (idle while a crew blocks on its own long-running -# foreground tool call) rather than turn state. +# foreground tool call) rather than turn state. The tail is captured +# unconditionally (not just for Grok) so this authoritative read also sees +# fm_busy_lib's launch-prompt backstop: without it, a launch parked on a +# recognized interactive prompt would report `working` here while the +# watcher's own poll (which always captures a tail) already classifies it +# unknown - the exact split issue #1792 describes for a different cause. crew_busy_verdict() { # <target> - local tail40='' - case "$HARNESS" in - grok*) tail40=$(fm_backend_capture "$TASK_BACKEND" "$1" 40 "$EXPECTED_LABEL" 2>/dev/null) || tail40='' ;; - esac + local tail40 + tail40=$(fm_backend_capture "$TASK_BACKEND" "$1" 40 "$EXPECTED_LABEL" 2>/dev/null) || tail40='' fm_busy_classify "$TASK_BACKEND" "$1" "$HARNESS" "$ID" "$STATE" "$tail40" } @@ -571,10 +592,7 @@ EOF } log_reports_ci_ready() { [ "$LOG_VERB" = "done" ] || return 1 - case "$(status_line_note "$LOG_LINE")" in - *PR*"checks green"*|*"checks green"*PR*) return 0 ;; - *) return 1 ;; - esac + fm_dod_note_reports_ci_ready "$(status_line_note "$LOG_LINE")" } # 0 when a status-log line reports positive daemon socket failure rather than a @@ -778,22 +796,28 @@ nm_effective_ci_step_status() { # monitoring until merged or closed" or "no CI checks reported - still # monitoring until merged or closed" (verified against 360+ real run logs under # ~/.no-mistakes/logs/*/ci.log on the installed v1.32.2 binary, including the -# actual PR #252 run). Reads the ci step's log tail via `axi logs` and scans it -# for the MOST RECENT recognized marker (the log is append-only/chronological, +# actual PR #252 run). Reads the ci step's log via `axi logs --full` and scans +# it for the MOST RECENT recognized marker (the log is append-only/chronological, # so the last match is current): green with nothing red after it means CI is # green right now, still only waiting on merge/close. +# "base branch advanced (..), re-arming CI monitor timeout" is deliberately NOT +# a marker: the monitor logs a checks state only when that state changes, and a +# base advance re-arms only its idle timeout without clearing readiness, so the +# green marker before it is still current (no-mistakes' own ci-log parser +# ignores the line the same way, v1.32.2 through v1.79.0). Reading it as +# not-ready held a green PR at working for as long as main kept advancing. nm_ci_checks_state() { - local run_id log_tail marker + local run_id ci_log marker run_id=$(strip_quotes "$(nm_field id)") [ -n "$run_id" ] || { printf 'unknown'; return; } - log_tail=$(nm_run axi logs --step ci --run "$run_id") || true - [ -n "$log_tail" ] || { printf 'unknown'; return; } - marker=$(printf '%s\n' "$log_tail" \ - | grep -E 'CI checks passed|no CI checks reported - still monitoring|no CI checks reported yet|checks failed|issues detected|CI checks running|base branch advanced.*re-arming CI monitor timeout' \ + ci_log=$(nm_run axi logs --step ci --run "$run_id" --full) || true + [ -n "$ci_log" ] || { printf 'unknown'; return; } + marker=$(printf '%s\n' "$ci_log" \ + | grep -E 'CI checks passed|no CI checks reported - still monitoring|no CI checks reported yet|checks failed|issues detected|CI checks running' \ | tail -1) case "$marker" in *"checks passed"*|*"no CI checks reported - still monitoring"*) printf 'green' ;; - *"no CI checks reported yet"*|*"checks failed"*|*"issues detected"*|*"CI checks running"*|*"base branch advanced"*"re-arming CI monitor timeout"*) printf 'not-ready' ;; + *"no CI checks reported yet"*|*"checks failed"*|*"issues detected"*|*"CI checks running"*) printf 'not-ready' ;; *) printf 'unknown' ;; esac } @@ -1009,7 +1033,7 @@ if [ "$HAVE_RUN" = 1 ]; then if [ -n "$outcome" ]; then case "$outcome" in - passed) RUN_STATE="done"; RUN_DETAIL=$(passed_pr_detail) ;; + passed|passed-with-override) RUN_STATE="done"; RUN_DETAIL=$(passed_pr_detail) ;; checks-passed) RUN_STATE="done"; RUN_DETAIL="checks green: PR ready for review" ;; failed) if nm_reclassify_failed_run_as_held_green; then :; else @@ -1057,6 +1081,10 @@ if [ "$HAVE_RUN" = 1 ]; then if [ "$CI_LOG_STATE" = green ]; then RUN_STATE="done" RUN_DETAIL="checks green: PR ready for review (still monitoring for merge/close)" + # The run's own PR URL makes this reading actionable even when + # the worker never reported it and no pr= was recorded. + ci_pr_url=$(strip_quotes "$(nm_field pr)") + [ -z "$ci_pr_url" ] || RUN_DETAIL="$RUN_DETAIL: $ci_pr_url" fi ;; fixing) @@ -1069,7 +1097,7 @@ if [ "$HAVE_RUN" = 1 ]; then if [ "$RUN_STATE" = working ] && log_reports_ci_ready; then if [ "$RUN_SOURCE" = coarse ]; then - emit "done" status-log "$(status_line_note "$LOG_LINE")${SEP}run still monitoring PR" + emit_ship_status_done "run still monitoring PR" fi [ -n "$CI_STEP_STATUS" ] || CI_STEP_STATUS=$(nm_effective_ci_step_status) if [ "$RUN_STATUS" = fixing ]; then @@ -1080,7 +1108,7 @@ if [ "$HAVE_RUN" = 1 ]; then CI_LOG_STATE=not-ready fi if [ "$CI_LOG_STATE" != not-ready ]; then - emit "done" status-log "$(status_line_note "$LOG_LINE")${SEP}run still monitoring PR" + emit_ship_status_done "run still monitoring PR" fi fi @@ -1219,6 +1247,9 @@ fi # the verb->state mapping (including the configurable paused verb), so reusing its # `unknown` verdict as the "not a state" test needs no second verb list here. if [ -n "$LOG_VERB" ]; then + if [ "$LOG_VERB" = "done" ]; then + emit_ship_status_done + fi LOG_STATE=$(map_log_state "$LOG_LINE") if [ "$LOG_STATE" != unknown ]; then emit "$LOG_STATE" status-log "$(status_line_note "$LOG_LINE")" diff --git a/bin/fm-dispatch-resolve.sh b/bin/fm-dispatch-resolve.sh index 66e712aaddc..31b82245e5c 100755 --- a/bin/fm-dispatch-resolve.sh +++ b/bin/fm-dispatch-resolve.sh @@ -20,8 +20,12 @@ # fixed generic none option. Jev returns the matched rule, a probability per # option, and a confidence. Everything after that is jq: the confidence # floor, the rule's declared `approval` and `floor`, each profile's declared -# `provider` and `floor`, the quota rows from ONE quota-axi --json snapshot, -# and the spendPriority argmax over the eligible candidates. The model never +# `provider` and `floor`, the quota rows from ONE quota-axi --json snapshot +# (schema 5 or 6; each candidate binds to one row through quota_row in +# bin/fm-quota-axi-lib.sh, so a Pi lane such as openai-codex-work/... +# reads its own account's row and an expanded provider with no row for the +# candidate is unmeasured, never blocked), and the spendPriority argmax over +# the eligible candidates. The model never # sees quota, catalogs, approvals, `why`, or `use`. With no rules, it returns # a non-clear result so firstmate keeps using the existing intake. # docs/configuration.md "Crew dispatch profiles" owns the declared fields and @@ -279,25 +283,26 @@ fm_quota_json_valid < "$QUOTA" || emit_error "quota-axi --json returned an inval # ---- resolution: declared gates + quota evidence + argmax, all in jq ------------ RESULT=$(jq -n --arg floor "$CONFIDENCE_FLOOR" --argjson lat "$LAT_MS" --arg none_criterion "$DEFAULT_WHEN" --argjson pmap "$PMAP" \ - --slurpfile resp "$RESP_FILE" --slurpfile rules "$RULES" --slurpfile quota "$QUOTA" ' + --slurpfile resp "$RESP_FILE" --slurpfile rules "$RULES" --slurpfile quota "$QUOTA" "$FM_QUOTA_ROW_JQ"' ($resp[0]) as $r | ($rules[0]) as $cfg | ($quota[0]) as $q | ($r.answers.rule) as $a | def profiles($v): if ($v | type) == "array" then $v elif ($v | type) == "object" then [$v] else [] end; - def prov($p): ([$q.providers[] | select(.provider == $p)] | first) // null; - def rows($p): (prov($p) | .quotaSemantics.effectiveAvailability // []); + def prov($p; $lane): quota_row($q; $p; $lane); + def rows($p; $lane): (prov($p; $lane) | .quotaSemantics.effectiveAvailability // []); def bare($m): ($m | split("/") | last); def provider_of($c): ($c.provider // $pmap[$c.harness] // null); - def measured($p): - (prov($p) != null and (["known", "partial"] | index(prov($p).quotaSemantics.status)) != null); - def applicable($p; $m): + def lane_of($c): quota_lane($c.harness; $c.model); + def measured($p; $lane): + (prov($p; $lane) != null and (["known", "partial"] | index(prov($p; $lane).quotaSemantics.status)) != null); + def applicable($p; $lane; $m): (bare($m)) as $bare | - [rows($p)[] | select( + [rows($p; $lane)[] | select( .scope == "all_models" or .scope == "all_products" or ($m != "" and (.scope == ("model:" + $bare) or .scope == ("product:" + $bare))) )]; - def floor_state($f; $p): + def floor_state($f; $p; $lane): if $f == null then "none" - elif prov($p) == null or (measured($p) | not) then "unknown" - else [rows($p)[] | select(.scope == $f.scope)] as $matches + elif prov($p; $lane) == null or (measured($p; $lane) | not) then "unknown" + else [rows($p; $lane)[] | select(.scope == $f.scope)] as $matches | if ($matches | length) == 0 or any($matches[]; .status != "known") then "unknown" elif any($matches[]; .effectivePercentRemaining < $f.min_percent) then "below" else "ok" @@ -306,13 +311,17 @@ RESULT=$(jq -n --arg floor "$CONFIDENCE_FLOOR" --argjson lat "$LAT_MS" --arg non def evidence($rows): $rows | map({scope, status, pct: (.effectivePercentRemaining // null), runway: (.runway.status // null), spendPriority: (.selection.spendPriority // null)}); def evaluate($c): - (provider_of($c)) as $p | + (provider_of($c)) as $p | (lane_of($c)) as $lane | if $p == null then {profile: $c, eligible: false, reason: "no provider family for harness \($c.harness); declare provider on the profile"} - elif prov($p) == null then {profile: $c, provider: $p, eligible: true, unranked: true, reason: "provider \($p) not in the quota snapshot"} + elif prov($p; $lane) == null then + {profile: $c, provider: $p, eligible: true, unranked: true, + reason: (if any($q.providers[]; .provider == $p) + then "provider \($p) has no quota row for account \(if $lane == "" then "default" else $lane end)" + else "provider \($p) not in the quota snapshot" end)} else - (applicable($p; ($c.model // ""))) as $rows | + (applicable($p; $lane; ($c.model // ""))) as $rows | (evidence($rows)) as $bounds | - (floor_state($c.floor; $p)) as $profile_floor_state | + (floor_state($c.floor; $p; $lane)) as $profile_floor_state | if any($rows[]; (.runway.status // "") == "exhausted_now") then ($rows | map(select((.runway.status // "") == "exhausted_now")) | first) as $bad | {profile: $c, provider: $p, bounds: $bounds, scope: $bad.scope, pct: ($bad.effectivePercentRemaining // null), runway: $bad.runway.status, eligible: false, reason: "runway exhausted_now at \($bad.scope)"} @@ -320,18 +329,18 @@ RESULT=$(jq -n --arg floor "$CONFIDENCE_FLOOR" --argjson lat "$LAT_MS" --arg non ($rows | map(select(.status == "known" and (.effectivePercentRemaining | type) == "number" and .effectivePercentRemaining <= 0)) | first) as $bad | {profile: $c, provider: $p, bounds: $bounds, scope: $bad.scope, pct: $bad.effectivePercentRemaining, runway: $bad.runway.status, eligible: false, reason: "0% remaining at \($bad.scope)"} elif $profile_floor_state == "below" then - ([rows($p)[] | select( + ([rows($p; $lane)[] | select( .scope == $c.floor.scope and .effectivePercentRemaining < $c.floor.min_percent )] | first) as $floor_row | {profile: $c, provider: $p, bounds: $bounds, scope: ($floor_row.scope // $c.floor.scope), pct: ($floor_row.effectivePercentRemaining // null), runway: ($floor_row.runway.status // null), eligible: false, reason: "profile floor \($c.floor.scope) below \($c.floor.min_percent)%"} - elif (measured($p) | not) then + elif (measured($p; $lane) | not) then ($rows | first) as $row | - {profile: $c, provider: $p, bounds: $bounds, scope: ($row.scope // null), pct: ($row.effectivePercentRemaining // null), runway: ($row.runway.status // null), eligible: true, unranked: true, unknown: true, reason: "provider \($p) unmeasured (\(prov($p).quotaSemantics.status))"} + {profile: $c, provider: $p, bounds: $bounds, scope: ($row.scope // null), pct: ($row.effectivePercentRemaining // null), runway: ($row.runway.status // null), eligible: true, unranked: true, unknown: true, reason: "provider \($p) unmeasured (\(prov($p; $lane).quotaSemantics.status))"} elif ($rows | length) == 0 then {profile: $c, provider: $p, bounds: $bounds, eligible: true, unranked: true, unknown: true, reason: "no applicable quota row for provider \($p)"} elif $profile_floor_state == "unknown" then - ([rows($p)[] | select(.scope == $c.floor.scope)] | first) as $floor_row | + ([rows($p; $lane)[] | select(.scope == $c.floor.scope)] | first) as $floor_row | {profile: $c, provider: $p, bounds: $bounds, scope: $c.floor.scope, pct: ($floor_row.effectivePercentRemaining // null), runway: ($floor_row.runway.status // null), eligible: true, unranked: true, unknown: true, reason: "profile floor \($c.floor.scope) is unverifiable: not rankable"} elif any($rows[]; .status != "known") then ($rows | map(select(.status != "known")) | first) as $bad | @@ -352,7 +361,7 @@ RESULT=$(jq -n --arg floor "$CONFIDENCE_FLOOR" --argjson lat "$LAT_MS" --arg non (if $choice == "default" then null elif $rule_number != null and $rule_number <= (($cfg.rules // []) | length) then $cfg.rules[$rule_number - 1] else null end) as $rule | - (if $rule == null then "none" else floor_state($rule.floor; $rule.floor.provider) end) as $rule_floor_state | + (if $rule == null then "none" else floor_state($rule.floor; $rule.floor.provider; "") end) as $rule_floor_state | (if $choice != "default" and $rule == null then [] elif $rule == null then profiles($cfg.default // null) else profiles($rule.use) diff --git a/bin/fm-dod-lib.sh b/bin/fm-dod-lib.sh index db70a186f88..990e6a11bc8 100755 --- a/bin/fm-dod-lib.sh +++ b/bin/fm-dod-lib.sh @@ -1,15 +1,32 @@ #!/usr/bin/env bash -# Single owner of a ship task's mode-specific "Definition of done" block. +# Single owner of a ship task's mode-specific "Definition of done" block and of +# the named-head reachability gate that accepts a ship `done:` claim. # Sourced by bin/fm-brief.sh, which renders it into a generated ship brief, and by # bin/fm-promote.sh, which renders it into the ship instructions a promoted scout # receives. Both paths must hand the worker the same contract: a promoted # no-mistakes worker that never received the ask-user escalation rule or the # `--yes` ban is the exact delivery hole this single owner exists to close. +# Callers of the gate are bin/fm-crew-state.sh (current-state done), +# bin/fm-pr-check.sh (PR registration), and bin/fm-inactive-reconcile.sh +# (secondmate ledger-first publish of a child done). A ship `done:` is not +# accepted while the named head exists only in the worker's disposable copy. +# The check tests that head, not whether some branch moved. In no-mistakes +# mode the pre-validation `done: {summary}` is the pipeline handoff and is +# not gated; only the later CI-ready `done: PR <url> checks green` is. The +# named head is the worker copy's HEAD, except that a done naming the task's +# recorded pr= passes when the forge holds that head: a forge-reported +# pr_head= in no-mistakes mode, or a recorded merge +# (state/<id>.pr-poll-merge-notified). Teardown's landed-work test remains the +# complete discard gate. # fm_dod_block <no-mistakes|direct-PR|local-only> <task-id> prints the block on # stdout with no trailing blank line. The caller validates the mode; an unknown # mode is refused rather than silently rendered as the pipeline contract. # The block opens with the fixed machine-readable "Delivery contract: mode=<mode>" # line that bin/fm-spawn.sh checks a ship brief against. +# The two PR-based blocks require a non-draft pull request before the done +# report, read back from the forge; a lane that deliberately holds a draft +# declares a paused wait instead. bin/fm-pr-check.sh refuses to arm merge +# monitoring on a draft through the same reading bin/fm-pr-merge.sh uses. # This file is the one owner of the no-mistakes `--intent` contract: only the # brief's `## Captain's intent` subsection plus later captain words, never # `## Firstmate spec` and never the worker's own tradeoffs. @@ -39,6 +56,11 @@ # fm_ship_rule_one owns the mode-specific first ship safety rule shared by an # ordinary ship brief and the durable contract written during scout promotion. +# shellcheck source=bin/fm-pr-lib.sh +. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/fm-pr-lib.sh" +# shellcheck source=bin/fm-classify-lib.sh +. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/fm-classify-lib.sh" + fm_brief_worker_role() { # <state-dir> <task-id> local state=$1 task_id=$2 cat <<'EOF' @@ -249,7 +271,12 @@ fm_dod_block() { # <mode> <task-id> Delivery contract: mode=direct-PR This task ships **direct-PR**: you raise the PR yourself, without the no-mistakes pipeline. The task is complete only when committed on your branch. -When it is implemented and committed, push your branch and open a PR with \`gh-axi\`, then append \`done [at=<epoch>]: PR {url}\` to the status file and stop. +When it is implemented and committed, push your branch and open a PR with \`gh-axi\` that is ready for review, not a draft. +Before you report done, read the PR back from the forge and confirm it is not a draft (\`gh pr view <url> --json isDraft\` must print false); if it is a draft, mark it ready with \`gh-axi pr ready\`. +A draft cannot be merged, so a done report on one leaves the merge unasked. +Then append \`done [at=<epoch>]: PR {url}\` to the status file and stop. +That \`done:\` is accepted only when this copy's HEAD - your latest commit - is pushed to your PR branch; the check tests that commit, not merely that a branch moved. +If you deliberately keep the PR a draft, append \`paused [at=<epoch>]: {why the draft is held}\` instead of done. Do NOT run /no-mistakes. The configured merge authority decides whether to merge the PR; firstmate relays the outcome. EOF ;; @@ -259,6 +286,7 @@ EOF Delivery contract: mode=local-only This task ships **local-only**: no remote, no PR, no pipeline. The task is complete only when committed on your branch \`fm/$id\`. Do NOT push, do NOT open a PR, do NOT merge. +A \`done:\` is accepted when the named head is on this project's shared local branch, not only on a detached copy; the check tests that head, not merely that a branch moved. Keep your branch a clean fast-forward onto the current default branch - if \`main\` has advanced, rebase onto it so the eventual merge stays a fast-forward. When it is implemented and committed, append \`done [at=<epoch>]: ready in branch fm/$id\` to the status file and stop. The configured merge authority approves the ready branch, then firstmate merges it into local \`main\` through the guarded fast-forward path. @@ -271,6 +299,7 @@ Delivery contract: mode=no-mistakes The task is complete only when committed on your branch. When you believe it is complete, append \`done [at=<epoch>]: {summary}\` to the status file and stop. Firstmate will then instruct you to run /no-mistakes to validate and ship a PR. +That first \`done:\` is the handoff that starts the pipeline, which owns the push; it is not a request to push from this copy. You drive no-mistakes by responding to its gates, not by implementing fixes. Follow the guidance no-mistakes itself provides for the mechanics: it loads when you invoke /no-mistakes, and \`no-mistakes axi run --help\` plus the \`help\` lines in each \`axi\` response are authoritative and version-matched to the installed binary. @@ -285,8 +314,10 @@ This replaces the no-mistakes skill's advice to enrich \`--intent\` with decisio Do not hand-edit, commit, or fix findings yourself while a run is active - the pipeline applies every fix. One drive call blocks until the next gate or outcome, which routinely outlives what your harness lets a single command run: Claude Code kills a command at ten minutes maximum, while one fix round is capped around thirty minutes and up to three rounds chain. -So background the drive call and poll \`no-mistakes axi status\` from a separate call instead of sitting in one blocking hold your harness will kill. -Where a harness's own command limit is not established, assume it bounds commands and use that same background-and-poll shape. +So background the drive call instead of sitting in one blocking hold your harness will kill, and read its return when it finishes. +Where a harness's own command limit is not established, assume it bounds commands and use that same backgrounded shape. +Only a drive call's return reports the green PR: \`no-mistakes axi status\` shows progress but never reports \`checks-passed\` while the ci step is still monitoring the PR for merge, so never wait on a status poll for the next gate or outcome. +Whenever a drive call returns without a gate or an outcome - its own wait elapsed, or it was killed or timed out - reattach at once by re-running \`no-mistakes axi run\` without flags, backgrounded the same way; once checks are green it returns \`checks-passed\` immediately, and if it refuses because no run is active, read the finished outcome from \`no-mistakes axi status\`. A killed or timed-out call is never evidence the daemon died: the daemon accepts your response immediately and runs the round in the background, so the call was only ever waiting for a read while the run kept working. Reattach and keep going rather than reporting the pipeline blocked; rule 7 owns the checks that decide when a pipeline block is real. @@ -297,7 +328,11 @@ Two firstmate-specific rules layer on top of that guidance: - NEVER pass \`--yes\` (or \`-y\`) to \`no-mistakes axi run\` or \`no-mistakes axi respond\`. It is banned fleet-wide. It auto-resolves every gate including ask-user findings with no escalation, and answering your own ask-user finding is a hard rule violation. -After /no-mistakes reports CI green (the CI-ready return point - do not wait for it to keep monitoring in the background until merge), append \`done [at=<epoch>]: PR {url} checks green\` and stop. You are finished. +After /no-mistakes reports CI green (the CI-ready return point - do not wait for it to keep monitoring in the background until merge), read the PR back from the forge and confirm it is not a draft (\`gh pr view <url> --json isDraft\` must print false); if it is a draft, mark it ready with \`gh-axi pr ready\`. +A draft cannot be merged, so a done report on one leaves the merge unasked. +Then append \`done [at=<epoch>]: PR {url} checks green\` and stop. You are finished. +That CI-ready \`done:\` is accepted only when this copy's HEAD - your latest commit - is one the /no-mistakes run pushed, so commit nothing after the run; the check tests that commit, not merely that a branch moved. +If you deliberately keep the PR a draft, append \`paused [at=<epoch>]: {why the draft is held}\` instead of done. EOF ;; *) @@ -305,3 +340,128 @@ EOF return 1 ;; esac } + +# 0 when <sha> is contained in a ref under <namespace> in <repo>. +# --contains tests that exact commit, so a branch that moved to a different +# tip does not count. +fm_dod_ref_contains() { # <repo> <ref-namespace> <sha> + local repo=$1 ns=$2 sha=$3 hit + [ -n "$repo" ] && [ -d "$repo" ] || return 1 + [ -n "$sha" ] || return 1 + hit=$(git -C "$repo" for-each-ref --format='%(refname)' --contains="$sha" --count=1 "$ns" 2>/dev/null) || return 1 + [ -n "$hit" ] +} + +# 0 when a done: note reports the no-mistakes CI-ready PR (`PR <url> checks +# green`, with any surrounding text). bin/fm-crew-state.sh takes its CI-ready +# path on this same test, so every CI-ready line it acts on is gated. +fm_dod_note_reports_ci_ready() { # <note> + case "$1" in + *PR*"checks green"*|*"checks green"*PR*) return 0 ;; + esac + return 1 +} + +# 0 when this ship done: is one the named-head gate must accept or refuse. +# no-mistakes pre-validation done: is the pipeline handoff and is not gated. +# Empty mode is treated as no-mistakes, the unregistered-project default. +fm_dod_should_gate_ship_done() { # <kind> <mode> <line> + local note + [ "$1" = ship ] || return 1 + [ "$(status_line_verb "$3")" = "done" ] || return 1 + note=$(status_line_note "$3") + case "$2" in + direct-PR|local-only) return 0 ;; + no-mistakes|'') fm_dod_note_reports_ci_ready "$note" ;; + *) return 1 ;; + esac +} + +# The PR/MR URL from a `done: PR <url>...` note, or empty. +fm_dod_pr_url_from_done_note() { # <note> + local note=$1 url + case "$note" in + PR\ https://*|PR\ http://*) ;; + *) return 1 ;; + esac + url=${note#PR } + url=${url%% *} + printf '%s\n' "$url" +} + +# The last recorded <key>= value in <meta>, or empty. +fm_dod_meta_value() { # <meta> <key> + grep "^$2=" "$1" 2>/dev/null | tail -1 | cut -d= -f2- +} + +# 0 when the forge's head for a PR is the head the done names. In no-mistakes +# mode the pipeline pushes it, possibly with commits the worker clone never +# fetched. A direct-PR worker pushes from its own copy, so its named head stays +# that copy's HEAD and a later unpushed commit is refused. +fm_dod_forge_head_is_named_head() { # <mode> + case "$1" in + no-mistakes|'') return 0 ;; + esac + return 1 +} + +# 0 when <url> is the task's recorded pr= and the forge holds its head: +# bin/fm-pr-check.sh recorded the forge's pr_head= for it in no-mistakes mode, +# or the merge poll recorded it merged (<state>/<id>.pr-poll-merge-notified, +# bin/fm-pr-lib.sh). That head is stored outside the worker copy even when +# this clone never fetched it or fleet sync pruned its branch after a squash +# merge. +fm_dod_recorded_pr_on_forge() { # <state> <id> <meta> <mode> <url> + local state=$1 id=$2 meta=$3 mode=$4 url=$5 + [ -n "$meta" ] && [ -f "$meta" ] || return 1 + [ "$(fm_dod_meta_value "$meta" pr)" = "$url" ] || return 1 + if fm_dod_forge_head_is_named_head "$mode" && [ -n "$(fm_dod_meta_value "$meta" pr_head)" ]; then + return 0 + fi + ( fm_pr_url_parse "$url" \ + && fm_pr_poll_merge_already_notified "$state" "$id" \ + "$FM_PR_PROVIDER" "$FM_PR_HOST" "$FM_PR_PATH" "$FM_PR_NUMBER" ) +} + +# 0 when <sha> is reachable from a ref that survives the disposable worktree: +# any remote-tracking ref, or - for local-only - heads in the project clone. +fm_dod_named_head_reachable_outside_worktree() { # <worktree> <project> <mode> <sha> + local wt=$1 project=$2 mode=$3 sha=$4 + fm_dod_ref_contains "$wt" refs/remotes "$sha" && return 0 + fm_dod_ref_contains "$project" refs/remotes "$sha" && return 0 + [ "$mode" = local-only ] && fm_dod_ref_contains "$project" refs/heads "$sha" +} + +# 0 when <line> is not a ship done: to gate, when it names the task's recorded +# PR whose head the forge holds, or when its named head - the worker copy's +# HEAD - is reachable outside that disposable copy. There is no free-text SHA +# scan: a SHA that happens to appear in the note is not the named head. 1 when +# the claim is refused; stdout then holds a one-line reason and no other +# output. <state> <id> <meta> supply pr=, +# pr_head=, and the merge-notified marker; <meta> may be a captured copy +# (bin/fm-fleet-snapshot.sh), so the marker is read from <state>. +fm_dod_accept_ship_done() { # <kind> <mode> <worktree> <project> <line> [<state> <id> <meta>] + local kind=$1 mode=$2 wt=$3 project=$4 line=$5 state=${6:-} id=${7:-} meta=${8:-} url sha + fm_dod_should_gate_ship_done "$kind" "$mode" "$line" || return 0 + if url=$(fm_dod_pr_url_from_done_note "$(status_line_note "$line")") \ + && fm_dod_recorded_pr_on_forge "$state" "$id" "$meta" "$mode" "$url"; then + return 0 + fi + if [ -z "$wt" ] || [ ! -d "$wt" ]; then + printf '%s\n' "named head cannot be verified: worktree missing" + return 1 + fi + if ! git -C "$wt" rev-parse --git-dir >/dev/null 2>&1; then + printf '%s\n' "named head cannot be verified: worktree is not a git copy" + return 1 + fi + sha=$(git -C "$wt" rev-parse --verify HEAD 2>/dev/null) || { + printf '%s\n' "named head could not be resolved" + return 1 + } + if fm_dod_named_head_reachable_outside_worktree "$wt" "$project" "$mode" "$sha"; then + return 0 + fi + printf '%s\n' "named head $sha is unreachable outside the worker copy" + return 1 +} diff --git a/bin/fm-inactive-reconcile.sh b/bin/fm-inactive-reconcile.sh index dc2308821d5..a8be24e261b 100755 --- a/bin/fm-inactive-reconcile.sh +++ b/bin/fm-inactive-reconcile.sh @@ -15,8 +15,14 @@ # bin/fm-parent-channel-lib.sh from this unstamped payload: # <state> [key=child-outcome-<child>-<state>-<fp8>]: child <child> <state>: <note> [pr=<url>] [mode=<mode>] [yolo=<posture>] [report=data/<child>/report.md] # carrying the child's recorded PR, delivery mode, merge posture, and scout -# report pointer, without consulting fm-crew-state.sh and without waiting for -# the inactive cadence. A line still being appended (no trailing newline yet) +# report pointer, without consulting fm-crew-state.sh. A ship `done:` is +# published only when bin/fm-dod-lib.sh accepts the named head, so an +# unpushed copy is not reported upstream as ready. The cadence path uses +# fm-crew-state.sh, which applies the same gate: a no-mistakes +# pre-validation `done: {summary}` still reads done (the pipeline handoff), +# while a CI-ready or direct-PR/local-only done whose head lives only in the +# disposable copy reads blocked and is not a terminal inactive outcome. +# A line still being appended (no trailing newline yet) # is left for the next poll. This is what keeps a mate's PR-ready, finding, # and failure outcomes from depending on the mate model appending them # (docs/secondmate-parent-channel.md). A main home has no parent channel and @@ -96,6 +102,8 @@ CREW_STATE_BIN="${FM_INACTIVE_CREW_STATE_BIN:-$SCRIPT_DIR/fm-crew-state.sh}" . "$SCRIPT_DIR/fm-parent-channel-lib.sh" # shellcheck source=bin/fm-timeout-lib.sh . "$SCRIPT_DIR/fm-timeout-lib.sh" +# shellcheck source=bin/fm-dod-lib.sh +. "$SCRIPT_DIR/fm-dod-lib.sh" FM_INACTIVE_RECONCILE_SECS=${FM_INACTIVE_RECONCILE_SECS:-900} case "$FM_INACTIVE_RECONCILE_SECS" in @@ -410,6 +418,13 @@ report_child_ledger_locked() { # <id> <meta> pr=$(pr_for_task "$meta" "$last") incarnation=$(meta_incarnation "$meta") fingerprint=$(sha256_text "$incarnation|$id|$state|ledger|$last") + if [ "$state" = "done" ] && [ ! -f "$(record_path "$fingerprint" reported)" ] \ + && [ ! -f "$(record_path "$fingerprint" pending)" ] \ + && ! fm_dod_accept_ship_done "$(meta_field "$meta" kind)" "$(meta_field "$meta" mode)" \ + "$(meta_field "$meta" worktree)" "$(meta_field "$meta" project)" "$last" \ + "$STATE" "$id" "$meta" >/dev/null; then + return 0 + fi outcome_key="child-outcome-$id-$state-${fingerprint:0:8}" ensure_record "$fingerprint" "$id" "$incarnation" "$state" "$outcome_key" direct upstream "$pr" || return 1 [ -n "$RECORD_PENDING" ] || return 0 @@ -443,9 +458,10 @@ report_child_ledger_locked() { # <id> <meta> return 1 } -# Every direct child's ledger, under its meta lock. Cheap file reads only, so -# it runs on every poll in a secondmate home; a delivery failure is already -# queued as a notice and never fails the scan. +# Every direct child's ledger, under its meta lock. File reads, plus a local +# git reachability check for a ship done: with no delivery record yet, so it +# runs on every poll in a secondmate home; a delivery failure is already queued as a +# notice and never fails the scan. ledger_pass() { local meta id lock for meta in "$STATE"/*.meta; do diff --git a/bin/fm-nm-run-lib.sh b/bin/fm-nm-run-lib.sh index 31bfec25f33..dbde8077313 100644 --- a/bin/fm-nm-run-lib.sh +++ b/bin/fm-nm-run-lib.sh @@ -127,13 +127,16 @@ fm_nm_run_status_class() { # <status_word> # toolchain. A capped overview requires an optional Python 3 sqlite3 reader # for a read-only same-branch query of NM_HOME/state.sqlite (default: # ~/.no-mistakes/state.sqlite; relative NM_HOME resolves from the worktree). -# The real CLI overview never carries a `repo: ` identity line (observed -# 2026-09-20: a truncated overview with zero rows for this task's branch has -# only `count:`/`runs[...]:`), so repo identity is looked up by the task -# worktree path itself, which is exactly what `no-mistakes` records as a -# repo's `working_path`; the recorded spelling is matched exactly, so a task -# worktree that is not absolute, or whose spelling differs from the recorded -# one, reads as unreadable rather than guessed among candidates. +# Repo identity is the overview's own top-level `repo:` line, which every axi +# release emits: it is the `working_path` the CLI itself resolved for the +# queried worktree. That is NOT the task worktree path in general - a linked +# git worktree resolves to its main clone's registered path (observed +# 2026-09-22 on v1.79.0: every task copy of a firstmate home reports +# `repo: <home clone>`, and looking the repo up by the task worktree path +# matched no row, so every capped read reported the inventory unreadable). +# The recorded spelling is matched exactly, so an overview without exactly one +# absolute `repo:` line, or with one the inventory does not record, reads as +# unreadable rather than guessed among candidates. # The reader subprocess is bounded by $4 seconds (default 10), so a contended # database can never outlast the caller's per-read budget. # If that reader or inventory is unavailable, report unknown with available @@ -231,7 +234,7 @@ fm_nm_select_run() { # <branch> <axi-overview> <worktree> [timeout_secs] incomplete\|*) available_ids=${selection#*|} ;; *) printf '%s\n' "$selection"; return ;; esac - if ! inventory=$(fm_nm_bounded "$3" "$timeout_secs" python3 - "$1" "$3" "$available_ids" 2>/dev/null <<'PY' + if ! inventory=$(fm_nm_bounded "$3" "$timeout_secs" python3 - "$1" "$2" "$3" "$available_ids" 2>/dev/null <<'PY' import json import os import re @@ -240,17 +243,21 @@ import sys from contextlib import closing from pathlib import Path -branch, worktree, available_ids = sys.argv[1:] +branch, overview, worktree, available_ids = sys.argv[1:] ids = available_ids.split(", ") if available_ids else [] try: - if not os.path.isabs(worktree): + repos = [line[6:].strip() for line in overview.splitlines() if line.startswith("repo: ")] + if len(repos) != 1: + raise ValueError + repo_path = json.loads(repos[0]) if repos[0].startswith('"') else repos[0] + if not isinstance(repo_path, str) or not os.path.isabs(repo_path): raise ValueError root = Path(os.environ.get("NM_HOME") or Path.home() / ".no-mistakes") if not root.is_absolute(): root = Path(worktree) / root with closing(sqlite3.connect((root / "state.sqlite").as_uri() + "?mode=ro", uri=True, timeout=30)) as db: db.execute("BEGIN") - repo = db.execute("SELECT id FROM repos WHERE working_path = ?", (worktree,)).fetchall() + repo = db.execute("SELECT id FROM repos WHERE working_path = ?", (repo_path,)).fetchall() if len(repo) != 1: raise ValueError rows = db.execute( @@ -362,7 +369,7 @@ fm_nm_run_is_parked() { # <toon-output> # daemon-down probe for exactly that reason. # All four accepted words reach here on BOTH surfaces. The overview table # fm_nm_select_run validates carries a narrower column -# (pending|running|completed|failed|cancelled, :196), but that column is not +# (pending|running|completed|failed|cancelled, its unknown_status check), but that column is not # what this predicate reads: the selected-run route re-reads the run by id and # passes that DETAIL object, whose own vocabulary check admits `fixing` and `ci` # as live, and the legacy bare-status route passes the same detail shape. diff --git a/bin/fm-pr-check.sh b/bin/fm-pr-check.sh index c355233fd12..768c15ec218 100755 --- a/bin/fm-pr-check.sh +++ b/bin/fm-pr-check.sh @@ -1,10 +1,21 @@ #!/usr/bin/env bash # Record a PR-ready task: store one validated canonical pr=<url> and the forge's # exact pr_head=<sha> when available, then atomically arm a static merge poll. +# Refuses when bin/fm-dod-lib.sh will not accept the named head as reachable +# outside the worker's disposable copy; in no-mistakes mode a forge-reported +# head is that named head and is already stored on the forge. # The watcher check source is byte-for-byte bin/fm-pr-poll.sh; task and PR data # live only in a private sidecar and are never interpolated into shell source. # A GitHub pull request URL and a GitLab merge request URL are both accepted, # including a merge request on a self-hosted GitLab instance. +# A GitHub pull request the forge reports as a draft is refused, naming the draft +# state and recording and arming nothing: a draft cannot be merged, so a poll armed on it +# would wait for an event that cannot occur while nobody is asked to act. +# Mark the pull request ready for review, then arm again; a lane that keeps a +# draft on purpose declares a wait instead of reporting done. An unreadable +# draft state does not refuse, matching how the head read below is optional. +# bin/fm-pr-merge.sh records through this script with FM_PR_CHECK_MERGE=1 and +# skips this refusal, because its own merge-time draft refusal is authoritative. # Usage: fm-pr-check.sh <task-id> <pr-url> set -eu @@ -19,6 +30,8 @@ STATE="${FM_STATE_OVERRIDE:-$FM_HOME/state}" . "$SCRIPT_DIR/fm-wake-lib.sh" # shellcheck source=bin/fm-parent-channel-lib.sh . "$SCRIPT_DIR/fm-parent-channel-lib.sh" +# shellcheck source=bin/fm-dod-lib.sh +. "$SCRIPT_DIR/fm-dod-lib.sh" if [ "$#" -ne 2 ]; then echo "error: invalid PR check request" >&2 @@ -60,6 +73,16 @@ if [ "$PROVIDER" = gitlab ] && ! command -v glab >/dev/null 2>&1; then exit 1 fi +# The draft state is read before anything is recorded or armed. Only a positive +# draft reading refuses, because an unreadable one must not block arming. +if [ "$PROVIDER" = github ] && [ "${FM_PR_CHECK_MERGE:-}" != 1 ] && command -v gh >/dev/null 2>&1 && command -v jq >/dev/null 2>&1; then + DRAFT_JSON=$(gh pr view "$URL" --json isDraft 2>/dev/null || true) + if [ "$(fm_pr_json_draft_state "$DRAFT_JSON")" = true ]; then + echo "error: $URL is a draft pull request; a draft cannot be merged, so merge monitoring would wait for an event that cannot occur - mark it ready for review and arm again, or declare a wait instead of done if the draft is deliberate" >&2 + exit 1 + fi +fi + "$FM_ROOT/bin/fm-guard.sh" || true # pr_head is recorded only when the forge's CLI can supply it. gh exposes the @@ -80,6 +103,19 @@ if [ "$PROVIDER" = github ] && [ -n "$WT" ] && [ -d "$WT" ] && command -v gh >/d fi fi +KIND=$(grep '^kind=' "$META" | tail -1 | cut -d= -f2- || true) +MODE=$(grep '^mode=' "$META" | tail -1 | cut -d= -f2- || true) +PROJECT=$(grep '^project=' "$META" | tail -1 | cut -d= -f2- || true) +case "$MODE" in + no-mistakes|'') DONE_LINE="done: PR $URL checks green" ;; + *) DONE_LINE="done: PR $URL" ;; +esac +if { [ -z "$PR_HEAD" ] || ! fm_dod_forge_head_is_named_head "$MODE"; } \ + && ! GATE_REASON=$(fm_dod_accept_ship_done "${KIND:-ship}" "$MODE" "$WT" "$PROJECT" "$DONE_LINE" "$STATE" "$ID" "$META"); then + echo "error: $GATE_REASON" >&2 + exit 1 +fi + META_TMP= META_LOCK= META_LOCK_HELD=0 diff --git a/bin/fm-pr-lib.sh b/bin/fm-pr-lib.sh index 4b97a2f4394..20385f4fb3d 100755 --- a/bin/fm-pr-lib.sh +++ b/bin/fm-pr-lib.sh @@ -217,6 +217,17 @@ fm_pr_head_valid() { [[ "$head" =~ ^[0-9a-f]{40}$|^[0-9a-f]{64}$ ]] } +# The one reading of a GitHub pull request's draft state. Prints "true" or +# "false" for a boolean isDraft and nothing for anything else, so a caller can +# tell a positive draft from an unreadable payload. bin/fm-pr-merge.sh refuses +# a merge unless this prints "false"; bin/fm-pr-check.sh refuses to arm a merge +# poll only when it prints "true". +fm_pr_json_draft_state() { # <pull-request-json> + printf '%s' "${1-}" | jq -r ' + if type == "object" and (.isDraft | type) == "boolean" then (.isDraft | tostring) else "" end + ' 2>/dev/null || true +} + fm_pr_file_mode() { if [ "$(uname)" = Darwin ]; then /usr/bin/stat -f %Lp "$1" 2>/dev/null diff --git a/bin/fm-pr-merge.sh b/bin/fm-pr-merge.sh index da827f810f9..051b6a31323 100755 --- a/bin/fm-pr-merge.sh +++ b/bin/fm-pr-merge.sh @@ -584,7 +584,6 @@ github_verify_mergeable() { if ! fields=$(printf '%s' "$json" | jq -r ' if type == "object" then "state=" + ((.state // "") | tostring), - "draft=" + (if (.isDraft | type) == "boolean" then (.isDraft | tostring) else "" end), "mergeable=" + ((.mergeable // "") | tostring), "merge_state=" + ((.mergeStateStatus // "") | tostring), "head=" + ((.headRefOid // "") | tostring), @@ -599,7 +598,6 @@ github_verify_mergeable() { total=$((total + 1)) case "$line" in state=*) state=${line#state=} ;; - draft=*) draft=${line#draft=} ;; mergeable=*) mergeable=${line#mergeable=} ;; merge_state=*) merge_state=${line#merge_state=} ;; head=*) live_head=${line#head=} ;; @@ -610,11 +608,12 @@ github_verify_mergeable() { done <<FIELDS $fields FIELDS - if [ "$named" -ne 6 ] || [ "$total" -ne 6 ] || [ -z "$base" ]; then + if [ "$named" -ne 5 ] || [ "$total" -ne 5 ] || [ -z "$base" ]; then echo "error: could not read the GitHub pull request state before merging" >&2 return 1 fi + draft=$(fm_pr_json_draft_state "$json") if ! fm_pr_head_valid "$live_head"; then echo "error: could not read the GitHub pull request head commit before merging" >&2 return 1 @@ -864,7 +863,7 @@ METHODS } record_pr_metadata() { - if ! "$SCRIPT_DIR/fm-pr-check.sh" "$ID" "$URL"; then + if ! FM_PR_CHECK_MERGE=1 "$SCRIPT_DIR/fm-pr-check.sh" "$ID" "$URL"; then return 1 fi grep -qxF "pr=$URL" "$META" || { diff --git a/bin/fm-procevent-lavish.sh b/bin/fm-procevent-lavish.sh index 31d72d8fcd3..a99cb80aaf5 100755 --- a/bin/fm-procevent-lavish.sh +++ b/bin/fm-procevent-lavish.sh @@ -79,8 +79,12 @@ # browser_disconnected. A waiting result from this no-timeout poll means a # second poller was present; it is not a normal idle round. browser_disconnected # means the session remains open and is handled as a silent reconnect wait. -# The poll reads config/lavish-axi-host from FM_HOME before every lavish-axi -# invocation so firstmate and workers reach the same server. +# Before each poll attempt, resolve the artifact's saved URL from Lavish's own +# session store (LAVISH_AXI_STATE_DIR/state.json, default ~/.lavish-axi/state.json) +# and use its host and port. Opening the board writes that URL; polling does not. +# This is a routing lookup before the blocking call, not presence polling or a +# second route record. Ambient/configured addresses must not retarget a reply. +# An unreadable or missing session stops before the staged reply is consumed. # # `answers` is this adapter's half of the generic keyed-answer contract in # bin/fm-procevent.sh. It reports what the captain actually chose, as @@ -142,47 +146,38 @@ FM_HOME="${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}}" die() { printf 'error: %s\n' "$1" >&2; exit 1; } usage() { sed -n '2,/^set -u$/p' "${BASH_SOURCE[0]}" | sed '$d; s/^# \{0,1\}//'; exit 2; } -apply_configured_lavish_host() { - local original_present=$1 original_host=$2 host_file host rc - host_file="${FM_HOME%/}/config/lavish-axi-host" - host=$(perl -MFcntl=:mode -e ' +apply_session_host() { # <artifact> + local endpoint + endpoint=$(perl -MJSON::PP -MCwd=realpath -MEncode=decode,FB_CROAK -e ' use strict; use warnings; - my ($path) = @ARGV; - if (!lstat $path) { - exit 10 if $!{ENOENT}; - exit 11; - } - open my $file, "<", $path or exit 11; - my @stat = stat $file; - exit 11 unless @stat && S_ISREG($stat[2]); - while (1) { - my $count = read $file, my $chunk, 65536; - exit 12 unless defined $count; - last if $count == 0; - print $chunk or exit 12; - } - ' "$host_file") - rc=$? - case "$rc" in - 0) ;; - 10) - if [ "$original_present" = 1 ]; then - export LAVISH_AXI_HOST=$original_host - else - unset LAVISH_AXI_HOST - fi - return 0 - ;; - 11) die "config/lavish-axi-host must be a readable regular file" ;; - *) die "cannot read config/lavish-axi-host" ;; - esac - case "$host" in - ''|*[[:space:][:cntrl:]]*) - die "config/lavish-axi-host must contain one non-empty address without whitespace" - ;; - esac - export LAVISH_AXI_HOST=$host + my ($path, $artifact) = @ARGV; + my $real = realpath($artifact) // die "cannot resolve board artifact\n"; + $real = decode("UTF-8", $real, FB_CROAK); + open my $file, "<", $path or die "cannot read Lavish session store\n"; + -f $file or die "Lavish session store is not a regular file\n"; + local $/; + my $state = eval { decode_json(<$file>) }; + !$@ or die "invalid Lavish session store\n"; + ref($state) eq "HASH" && ref($state->{sessions}) eq "HASH" + or die "invalid Lavish session store\n"; + my @sessions = grep { + ref($_) eq "HASH" && defined($_->{file}) && $_->{file} eq $real + } values %{$state->{sessions}}; + @sessions == 1 or die "board must have one saved Lavish session\n"; + my $url = $sessions[0]->{url} // ""; + $url =~ m{\Ahttp://(\[[0-9a-fA-F:]+\]|[A-Za-z0-9._-]+):([0-9]+)/session/[0-9a-f]{16}(?:\?[^\s#]*)?\z} + or die "invalid saved Lavish session URL\n"; + my ($host, $port) = ($1, $2); + $host =~ s/^\[|\]$//g; + $host ne "0.0.0.0" && $host ne "::" && $port >= 1 && $port <= 65535 + or die "invalid saved Lavish server address\n"; + print "$host\n$port\n"; + ' "${LAVISH_AXI_STATE_DIR:-$HOME/.lavish-axi}/state.json" "$1") \ + || die "cannot resolve the board server from its Lavish session: $1" + LAVISH_AXI_HOST=${endpoint%$'\n'*} + LAVISH_AXI_PORT=${endpoint##*$'\n'} + export LAVISH_AXI_HOST LAVISH_AXI_PORT } # Canonical identity is physical, not the path string: Lavish itself keys a @@ -348,13 +343,9 @@ poll_iteration_floor_wait() { cmd_poll() { local artifact=${1-} delay attempt=0 response cleanup_command rc filter_rc iteration_started - local pipeline_status original_host_present=0 original_host='' reply_file='' + local pipeline_status reply_file='' local reply_text='' reply_pending=0 [ -n "$artifact" ] || usage - if [ "${LAVISH_AXI_HOST+x}" = x ]; then - original_host_present=1 - original_host=$LAVISH_AXI_HOST - fi if [ "$#" -eq 3 ] && [ "${2-}" = --agent-reply-file ]; then reply_file=$3 elif [ "$#" -ne 1 ]; then @@ -377,9 +368,9 @@ cmd_poll() { done while :; do iteration_started=$(poll_iteration_started) || die "cannot start the poll rate governor" - apply_configured_lavish_host "$original_host_present" "$original_host" [ -f "$artifact" ] && [ ! -L "$artifact" ] && [ -r "$artifact" ] \ || die "artifact is no longer a readable file: $artifact" + apply_session_host "$artifact" # Posting a round's reply is BEST EFFORT and deliberately carries no delivery # machinery. The staged file is the only record that a reply is owed, so it is # consumed HERE - after every non-posting step that could abort this poll has diff --git a/bin/fm-procevent-quota.sh b/bin/fm-procevent-quota.sh index a1d87a0d8b9..16ce34da2c4 100755 --- a/bin/fm-procevent-quota.sh +++ b/bin/fm-procevent-quota.sh @@ -27,6 +27,11 @@ # The canonical source id is `quota` for the aggregate tracked provider. # A provider named with --provider sets the tracked provider and the source id # becomes `quota-<provider>`. +# +# Snapshots may be quota-axi schema 5 or 6 (bin/fm-quota-axi-lib.sh owns the +# validator). Both watches read every matching account row independently, +# without combining quotas. A --provider watch restricts those rows to the +# requested provider; details preserve each row's accountKey when present. set -u SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" @@ -122,19 +127,10 @@ condition_status() { elif any($known[]; .effectivePercentRemaining < ($threshold | tonumber)) then "low" else "healthy" end; - if (.providers | type) != "array" then "error" - elif $provider == "" then - if (.providers | length) == 0 then "healthy" - elif ([.providers[]?.quotaSemantics.effectiveAvailability[]?] | length) == 0 then "healthy" - else classify([.providers[]?.quotaSemantics.effectiveAvailability[]?]) - end - else - ([.providers[]? | select(.provider == $provider)] | first) as $p | - if ($p // null) == null then "error" - elif ($p.quotaSemantics.effectiveAvailability | length) == 0 and - ($p.quotaSemantics.status == "unknown" or $p.quotaSemantics.status == "partial") then "healthy" - else classify($p.quotaSemantics.effectiveAvailability // []) - end + .providers |= map(select($provider == "" or .provider == $provider)) | + if (.providers | length) == 0 and $provider != "" then "error" + elif ([.providers[]?.quotaSemantics.effectiveAvailability[]?] | length) == 0 then "healthy" + else classify([.providers[]?.quotaSemantics.effectiveAvailability[]?]) end ' 2>/dev/null || printf 'error\n' } @@ -151,23 +147,18 @@ details() { elif ($known | length) > 0 then ($known | min_by(.effectivePercentRemaining)) else null end; - if $provider == "" then + [.providers[]? | select($provider == "" or .provider == $provider) | + {provider} + + (if has("accountKey") then {accountKey} else {} end) + + {best: best_detail(.quotaSemantics.effectiveAvailability // [])} + ] as $summary | + if $provider == "" or ($summary | length) > 1 then { - provider: "aggregate", - summary: [ - (.providers[]? | - { provider: .provider, - best: best_detail(.quotaSemantics.effectiveAvailability // []) - } - ) - ] + provider: (if $provider == "" then "aggregate" else $provider end), + summary: $summary } else - (.providers[]? | select(.provider == $provider)) as $p | - { - provider: $provider, - best: best_detail($p.quotaSemantics.effectiveAvailability // []) - } + $summary[0] // {provider: $provider, best: null} end ' 2>/dev/null } diff --git a/bin/fm-quota-axi-lib.sh b/bin/fm-quota-axi-lib.sh index 7a2df68a440..7162d89c82a 100644 --- a/bin/fm-quota-axi-lib.sh +++ b/bin/fm-quota-axi-lib.sh @@ -1,5 +1,6 @@ # shellcheck shell=bash -# Shared quota-axi compatibility floor for the bootstrap diagnostic. +# Shared quota-axi compatibility floor for the bootstrap diagnostic, the +# --json snapshot validator, and the provider-row join dispatch consumers use. # Usage: . bin/fm-quota-axi-lib.sh # # FM_QUOTA_AXI_MIN follows the axi-family floor policy owned beside the floor @@ -8,10 +9,41 @@ # This file is the single owner of that version number. bin/fm-bootstrap.sh # turns a failing check into the operator-facing MISSING diagnostic, which is # what keeps an older build from reaching a dispatch intake at all. +# +# Snapshot schemas: fm_quota_json_valid accepts quota-axi schema 5 (one row per +# provider, no accountKey) and schema 6 (every row carries accountKey, unique on +# provider + accountKey; quota-axi emits it once any provider expands to more +# than one account). Schema 5 keeps its exact pre-schema-6 rules so an older +# quota-axi keeps working unchanged. FM_QUOTA_ROW_JQ is the one join used to +# bind a candidate to its row under either schema. -FM_QUOTA_AXI_MIN=0.1.29 +FM_QUOTA_AXI_MIN=0.1.51 FM_QUOTA_PROVIDER_ID_RE='^[a-z0-9]+(-[a-z0-9]+)*\z' +# The eligibility section of .agents/skills/quota-array-dispatch/SKILL.md +# owns the account-matching contract these jq definitions implement. +# Prepend them to a consumer's program: +# quota_lane($harness; $model) the candidate's account key, or "" when none +# is identified by the contract. +# quota_row($snapshot; $provider; $lane) +# the one provider row the candidate binds to, +# or null; schema 5 ignores $lane. +# shellcheck disable=SC2016,SC2034 # jq program text, not shell expansion; read by the sourcing consumers +FM_QUOTA_ROW_JQ=' + def quota_lane($harness; $model): + if $harness == "codex" then "codex-home" + elif ($harness == "pi" or $harness == "pi-signed") and (($model // "") | contains("/")) + then ($model | split("/") | first | if . == "codex-native" then "codex-home" else . end) + else "" end; + def quota_row($snapshot; $provider; $lane): + ([$snapshot.providers[]? | select(.provider == $provider)]) as $rows | + if $snapshot.schemaVersion == 6 then + (([$rows[] | select(.accountKey == $lane)] | first) // + ([$rows[] | select(.accountKey == "default")] | first) // null) + else ($rows | first) // null + end; +' + fm_quota_axi_compatible() { local timeout=${1:-} output parts major minor patch extra local min_major min_minor min_patch min_extra @@ -47,9 +79,18 @@ fm_quota_json_valid() { length == 1 and (.[0] | type) == "object" and (.[0] | - .schemaVersion == 5 and (.providers | type) == "array" and - (([.providers[].provider] | length) == ([.providers[].provider] | unique | length)) and + (if .schemaVersion == 5 then + (([.providers[].provider] | length) == ([.providers[].provider] | unique | length)) + elif .schemaVersion == 6 then + all(.providers[]; + (.accountKey | type) == "string" and + (.accountKey | length) > 0 and + ((.accountKey | test("\\s")) | not)) and + (([.providers[] | [.provider, .accountKey]] | length) == + ([.providers[] | [.provider, .accountKey]] | unique | length)) + else false + end) and all(.providers[]; (.provider | type) == "string" and (.provider | test($provider_re)) and diff --git a/bin/fm-quota-choose.sh b/bin/fm-quota-choose.sh index 4bfe89247bf..8a5a24117ca 100755 --- a/bin/fm-quota-choose.sh +++ b/bin/fm-quota-choose.sh @@ -5,10 +5,12 @@ # fm-quota-choose.sh [--snapshot <path>] [--candidate <harness:model>]... # # Reads one already-captured quota-axi default TOON or JSON snapshot from the -# provided file, or from stdin when --snapshot is omitted. For each --candidate -# in order, it maps <harness> to its primary provider family, then applies the -# provider-wide scopes and exact model or product scopes for <model>. A candidate -# is eligible only when no applicable runway is `exhausted_now` and its known +# provided file, or from stdin when --snapshot is omitted. +# bin/fm-quota-axi-lib.sh owns schema compatibility and the shared row join. +# For each --candidate in order, it maps <harness> to its primary provider +# family, then applies the matched row's provider-wide scopes and exact model +# or product scopes for <model>. A candidate is eligible only when no +# applicable runway is `exhausted_now` and its known # effective percent remaining is greater than zero. The first eligible # candidate is printed as "<harness> <model>" and the script exits 0. # If no candidate is quota-eligible, it prints "none" and exits 1. @@ -115,7 +117,7 @@ if printf '%s\n' "$QUOTA_SNAPSHOT" | jq -e 'type == "object"' >/dev/null 2>&1; t QUOTA_JSON=$QUOTA_SNAPSHOT schema=$(printf '%s\n' "$QUOTA_JSON" | jq -r '.schemaVersion // empty' 2>/dev/null) || schema= case "$schema" in - 5) ;; + 5|6) ;; '') die "quota-axi json missing schemaVersion" ;; *) die "unsupported quota-axi schema version: $schema" ;; esac @@ -161,12 +163,20 @@ else ((decoded_row | length) == $field_count) and all(decoded_row[]; length > 0) ); + # Schema 6 TOON adds accountKey right after provider in every block; $k is + # that column offset (0 or 1) and keyed_row folds it into the record. + def key_col($k): if $k == 1 then "accountKey," else "" end; + def keyed_row($k): if $k == 1 then {provider: .[0], accountKey: .[1]} else {provider: .[0]} end; + def account_of: if has("accountKey") then {accountKey} else {} end; + def schema_of($k): if $k == 1 then 6 else 5 end; def valid_attention_entries: type == "array" and all(.[]; type == "object" and (.provider | type) == "string" and (.provider | test("^[a-z0-9]+(-[a-z0-9]+)*$")) and + ((has("accountKey") | not) or + ((.accountKey | type) == "string" and (.accountKey | length) > 0 and ((.accountKey | test("\\s")) | not))) and (.scope | type) == "string" and (.scope | length) > 0 and ((.scope | test("^\\s|\\s$")) | not) and @@ -184,26 +194,31 @@ else end; def unknown_providers($entries): $entries | - group_by(.provider) | - map({ - provider: .[0].provider, + group_by([.provider, .accountKey]) | + map((.[0] | {provider} + account_of) + { quotaSemantics: { status: "unknown", effectiveAvailability: [.[] | attention_availability] } }); - def exhaustion_count: + def unknown_snapshot($entries): + {schemaVersion: (if any($entries[]; has("accountKey")) then 6 else 5 end), providers: unknown_providers($entries)}; + def exhaustion_count($k): if . == "exhaustion[0]:" or . == "exhaustion: []" then 0 else - capture("^exhaustion\\[(?<count>[1-9][0-9]*)\\]\\{provider,scope,usableRunwaySeconds,projectedExhaustedAt,limitingWindowId\\}:$").count | + capture("^exhaustion\\[(?<count>[1-9][0-9]*)\\]\\{provider," + key_col($k) + "scope,usableRunwaySeconds,projectedExhaustedAt,limitingWindowId\\}:$").count | tonumber end; - def attention_count: + def attention_count($k): if . == "attention[0]:" or . == "attention: []" then 0 else - capture("^attention\\[(?<count>[1-9][0-9]*)\\]\\{provider,scope,kind,detail,remedy\\}:$").count | + capture("^attention\\[(?<count>[1-9][0-9]*)\\]\\{provider," + key_col($k) + "scope,kind,detail,remedy\\}:$").count | tonumber end; + def attention_entries($k): + map(decoded_row | keyed_row($k) + { + scope: .[1 + $k], kind: .[2 + $k], detail: .[3 + $k], remedy: .[4 + $k] + }); (split("\n") | map(select(length > 0))) as $lines | ($lines | map(. == "quota[0]:" or . == "quota: []") | index(true)) as $zero_index | if $zero_index != null then @@ -215,17 +230,16 @@ else if ($tail[1] == "attention[0]:" or $tail[1] == "attention: []") and ($tail[2:] | valid_help_tail) then {schemaVersion: 5, providers: []} - elif ($tail[1] | test("^attention\\[[1-9][0-9]*\\]\\{provider,scope,kind,detail,remedy\\}:$")) then - ($tail[1] | attention_count) as $attention_count | + elif ($tail[1] | test("^attention\\[[1-9][0-9]*\\]\\{provider,(accountKey,)?scope,kind,detail,remedy\\}:$")) then + (if ($tail[1] | contains("{provider,accountKey,")) then 1 else 0 end) as $k | + ($tail[1] | attention_count($k)) as $attention_count | ($tail[2:(2 + $attention_count)]) as $attention_rows | if ($attention_rows | length) == $attention_count and - ($attention_rows | valid_rows(5)) and + ($attention_rows | valid_rows(5 + $k)) and ($tail[(2 + $attention_count):] | valid_help_tail) then - ($attention_rows | map(decoded_row | { - provider: .[0], scope: .[1], kind: .[2], detail: .[3], remedy: .[4] - })) as $entries | + ($attention_rows | attention_entries($k)) as $entries | if ($entries | valid_attention_entries) then - {schemaVersion: 5, providers: unknown_providers($entries)} + unknown_snapshot($entries) else error("invalid zero-row attention identities") end else error("invalid zero-row attention section") @@ -234,7 +248,7 @@ else ($tail[1] | sub("^attention: "; "") | fromjson) as $entries | if ($entries | valid_attention_entries) and ($tail[2:] | valid_help_tail) then - {schemaVersion: 5, providers: unknown_providers($entries)} + unknown_snapshot($entries) else error("invalid zero-row attention array") end else error("invalid zero-row attention section") @@ -244,55 +258,51 @@ else else error("invalid zero-row quota header") end else - ($lines | map(test("^quota\\[[1-9][0-9]*\\]\\{provider,scope,effectivePercentRemaining,spendPriority,runway,confidence,limitedBy,resetsAt\\}:$")) | index(true)) as $quota_index | + ($lines | map(test("^quota\\[[1-9][0-9]*\\]\\{provider,(accountKey,)?scope,effectivePercentRemaining,spendPriority,runway,confidence,limitedBy,resetsAt\\}:$")) | index(true)) as $quota_index | if $quota_index == null then error("missing quota section") else + (if ($lines[$quota_index] | contains("{provider,accountKey,")) then 1 else 0 end) as $k | ($lines[:$quota_index]) as $head | ($lines[$quota_index] | capture("^quota\\[(?<count>[1-9][0-9]*)\\]").count | tonumber) as $quota_count | ($lines[($quota_index + 1):($quota_index + 1 + $quota_count)]) as $quota_lines | ($quota_index + 1 + $quota_count) as $exhaustion_index | - ($lines[$exhaustion_index] | exhaustion_count) as $exhaustion_count | + ($lines[$exhaustion_index] | exhaustion_count($k)) as $exhaustion_count | ($lines[($exhaustion_index + 1):($exhaustion_index + 1 + $exhaustion_count)]) as $exhaustion_rows | ($exhaustion_index + 1 + $exhaustion_count) as $attention_index | - ($lines[$attention_index] | attention_count) as $attention_count | + ($lines[$attention_index] | attention_count($k)) as $attention_count | ($lines[($attention_index + 1):($attention_index + 1 + $attention_count)]) as $attention_rows | ($lines[($attention_index + 1 + $attention_count):]) as $tail | if (($head | valid_preamble) | not) or ($quota_lines | length) != $quota_count or - (($quota_lines | valid_rows(8)) | not) or + (($quota_lines | valid_rows(8 + $k)) | not) or ($exhaustion_rows | length) != $exhaustion_count or - (($exhaustion_rows | valid_rows(5)) | not) or + (($exhaustion_rows | valid_rows(5 + $k)) | not) or ($attention_rows | length) != $attention_count or - (($attention_rows | valid_rows(5)) | not) or + (($attention_rows | valid_rows(5 + $k)) | not) or (($tail | valid_help_tail) | not) then error("invalid quota-axi TOON envelope") else ($quota_lines | map(decoded_row)) as $rows | - ($attention_rows | map(decoded_row | { - provider: .[0], scope: .[1], kind: .[2], detail: .[3], remedy: .[4] - })) as $attention_entries | + ($attention_rows | attention_entries($k)) as $attention_entries | if (($attention_entries | valid_attention_entries) | not) then error("invalid attention identities") - elif any($rows[]; length != 8) then error("invalid quota rows") + elif any($rows[]; length != 8 + $k) then error("invalid quota rows") else { - schemaVersion: 5, + schemaVersion: schema_of($k), providers: (($rows | - map({ - provider: .[0], + map(keyed_row($k) + { availability: { - scope: .[1], + scope: .[1 + $k], status: "known", - effectivePercentRemaining: (.[2] | tonumber), - runway: {status: .[4]} + effectivePercentRemaining: (.[2 + $k] | tonumber), + runway: {status: .[4 + $k]} } })) + - ($attention_entries | map(. as $entry | { - provider: $entry.provider, + ($attention_entries | map(. as $entry | ($entry | {provider} + account_of) + { availability: ([$entry | attention_availability] | first // null) })) | - group_by(.provider) | - map({ - provider: .[0].provider, + group_by([.provider, .accountKey]) | + map((.[0] | {provider} + account_of) + { quotaSemantics: { status: (if any(.[]; .availability.status == "known") then "known" else "unknown" end), effectiveAvailability: [.[].availability | select(. != null)] @@ -317,14 +327,16 @@ provider_for_harness() { fm_quota_provider_for_harness "$@" } -# effective_for_provider_model <provider> <model> +# effective_for_provider_model <provider> <model> <lane> # Print the most constraining applicable quota evidence for the provider/model -# tuple, including provider-wide and exact model or product scopes. +# tuple, including provider-wide and exact model or product scopes. The row is +# bound through quota_row from bin/fm-quota-axi-lib.sh, so <lane> matters only +# on a schema 6 snapshot. effective_for_provider_model() { - local provider=$1 model=${2:-default} - printf '%s\n' "$QUOTA_JSON" | jq -c --arg provider "$provider" --arg model "$model" ' + local provider=$1 model=${2:-default} lane=${3:-} + printf '%s\n' "$QUOTA_JSON" | jq -c --arg provider "$provider" --arg model "$model" --arg lane "$lane" "$FM_QUOTA_ROW_JQ"' ($model | sub("^model:"; "")) as $model_token | - ([.providers[]? | select(.provider == $provider)] | first) as $p | + quota_row(.; $provider; $lane) as $p | if ($p // null) == null then {status: "unknown"} else ($p.quotaSemantics.effectiveAvailability // []) | map(select(.scope as $scope | @@ -366,7 +378,8 @@ for c in "${CANDIDATES[@]}"; do provider=$(provider_for_harness "$harness" "$model") scope_model=$model [ "$harness" != omp ] || scope_model=${model#*/} - effective=$(effective_for_provider_model "$provider" "$scope_model") + lane=$(jq -rn --arg h "$harness" --arg m "$model" "$FM_QUOTA_ROW_JQ"'quota_lane($h; $m)') + effective=$(effective_for_provider_model "$provider" "$scope_model" "$lane") if [ -z "$effective" ] || [ "$effective" = "null" ]; then continue fi diff --git a/bin/fm-send.sh b/bin/fm-send.sh index e672b963823..af09392a4d0 100755 --- a/bin/fm-send.sh +++ b/bin/fm-send.sh @@ -692,11 +692,12 @@ fi # command; the decision then stays open and re-surfaces, never silently lost. # All of one answer's closes are this home's own bookkeeping, written by the # very turn that answered the decisions, so they go through ONE guarded -# self-announced append (bin/fm-wake-lib.sh) and do not wake this same session -# again, including when this home already folded those bytes through OPEN -# DECISIONS without a matching watcher seen marker; any concurrent foreign -# status bytes, or a worker line the fold read but never listed, leave the -# watcher's wake path untouched. +# self-announced append (bin/fm-wake-lib.sh). That records the appended byte +# range so separate --resolve-key answers do not each wake this same session, +# including when this home already folded those bytes through OPEN DECISIONS +# without a matching watcher seen marker; any concurrent foreign status bytes, +# or a worker line the fold read but never listed, leave the watcher's wake +# path untouched. fm_send_close_resolved_keys() { # <answer-text> local note=$1 k close_note append_rc still manual_close_cmd close_lines=() i=0 note=$(printf '%s' "$note" | tr '\n\r\t' ' ' | LC_ALL=C tr -d '\000-\037\177') diff --git a/bin/fm-session-start.sh b/bin/fm-session-start.sh index af435c45b9a..37b4909161f 100755 --- a/bin/fm-session-start.sh +++ b/bin/fm-session-start.sh @@ -764,7 +764,7 @@ if [ "$PRIMARY_HARNESS" = pi ] || [ "$PRIMARY_HARNESS" = pi-signed ]; then [ "$PRIMARY_HARNESS" != pi ] || PI_RESTART_COMMAND='plain pi' PI_WATCH_VERSION=$(fm_pi_extension_version "$PI_EXT" || printf '') PI_TURNEND_VERSION=$(fm_pi_extension_version "$PI_TURNEND_EXT" || printf '') - if ! fm_pi_extension_loaded "$PI_WATCH_MARKER" "$PI_WATCH_VERSION" "$PI_LOCK" \ + if ! fm_pi_extension_loaded "$PI_WATCH_MARKER" "$PI_WATCH_VERSION" "$PI_LOCK" active \ || ! fm_pi_extension_loaded "$PI_TURNEND_MARKER" "$PI_TURNEND_VERSION" "$PI_LOCK"; then printf 'PI_WATCH_EXTENSION: not loaded - approve Pi project trust once per clone, then restart %s so %s and %s auto-load for turn-end guard and background wake coverage; use -e %s -e %s only if project hooks are not trusted\n' "$PI_RESTART_COMMAND" "$PI_TURNEND_EXT" "$PI_EXT" "$PI_TURNEND_EXT" "$PI_EXT" fi diff --git a/bin/fm-task-inbox-lib.sh b/bin/fm-task-inbox-lib.sh index 852dbd22ab3..27c3aeda623 100644 --- a/bin/fm-task-inbox-lib.sh +++ b/bin/fm-task-inbox-lib.sh @@ -46,7 +46,8 @@ # # Re-ring ladder (fm_task_inbox_due_action): an unhandled message older than # FM_TASK_INBOX_GRACE_SECS is due one delivery attempt per grace period; an -# attempt may ring or be skipped to protect proven pending composer text. After +# attempt may ring or be skipped to protect another draft in a proven pending +# composer; an unsubmitted copy of this doorbell is retried. After # FM_TASK_INBOX_RING_MAX attempts without an acknowledgement it escalates. The # caller owns the busy and recovery-grade endpoint checks: a busy pane waits, # while a positively dead or missing endpoint skips delivery and the ladder and @@ -273,16 +274,20 @@ fm_task_inbox_doorbell_line() { # <record-path> # composer pre-check, then the backend's submit machinery with a minimal retry # budget, verdict discarded. # Returns 0 rang, 1 skipped because the composer PROVENLY holds pending text -# (the watcher re-rings later), 2 the backend send failed, 3 skipped because -# the endpoint is positively dead or missing (nothing typed; recovery owns the -# record). No return value is delivery proof; the acknowledgement move is the -# only delivery signal. -# The skip is deliberately narrow: only an exact `pending` verdict defers, +# other than our own doorbell (the watcher re-rings later), 2 the backend send +# failed, 3 skipped because the endpoint is positively dead or missing (nothing +# typed; recovery owns the record). No return value is delivery proof; the +# acknowledgement move is the only delivery signal. +# The skip is deliberately narrow: only an exact `pending` verdict can defer, # because there our Enter could submit someone's real half-typed content. # `pending-unproven` and `unknown` still ring - the worst outcome is a garbled # CONSTANT line the worker recovers semantically, while skipping on ambiguous # verdicts would starve a harness whose idle screen the classifier cannot # positively identify (that classifier is advisory here by design). +# A pending composer holding exactly our own doorbell line is a previous ring +# whose Enter never landed, so on an agent not reported busy it is submitted +# rather than skipped; skipping it would block every later ring. On both paths +# a lost first Enter gets one confirmed retry. fm_task_inbox_ring() { # <backend> <target> <record-path> [expected-label] local backend=$1 target=$2 rec=$3 label=${4:-} line cstate verdict case "$(fm_backend_agent_state "$backend" "$target" 2>/dev/null || true)" in @@ -293,13 +298,22 @@ fm_task_inbox_ring() { # <backend> <target> <record-path> [expected-label] fi cstate=$(fm_backend_composer_state "$backend" "$target" "$label" 2>/dev/null) || cstate=unknown case "$cstate" in - pending) return 1 ;; + pending) + fm_task_inbox_composer_holds "$backend" "$target" "$line" "$label" \ + && [ "$(fm_backend_busy_state "$backend" "$target" 2>/dev/null)" != busy ] \ + || return 1 + fm_backend_send_key "$backend" "$target" Enter "$label" >/dev/null 2>&1 || return 2 + sleep 0.3 + fm_task_inbox_composer_holds "$backend" "$target" "$line" "$label" || return 0 + fm_backend_send_key "$backend" "$target" Enter "$label" >/dev/null 2>&1 || return 2 + return 0 + ;; esac # Accepted residual race: terminal input and Enter are separate delivery # steps, so an agent exiting after the liveness check could leave a bare # shell only a suffix; the `: ` prefix protects complete lines only. Do not # add process-bound atomic delivery here unless an incident reopens this. - if ! verdict=$(fm_backend_send_text_submit "$backend" "$target" "$line" 1 0.4 0.3 "$label" 2>/dev/null); then + if ! verdict=$(fm_backend_send_text_submit "$backend" "$target" "$line" 2 0.4 0.3 "$label" 2>/dev/null); then return 2 fi # The verdict is read only to report a failed keystroke; every other value @@ -308,6 +322,15 @@ fm_task_inbox_ring() { # <backend> <target> <record-path> [expected-label] return 0 } +# Whether the composer's content, ignoring line wrapping, is exactly <line>. +fm_task_inbox_composer_holds() { # <backend> <target> <line> [expected-label] + local cap held + fm_backend_source "$1" || return 1 + cap=$(fm_backend_capture "$1" "$2" "$FM_COMPOSER_CAPTURE_LINES" "${4:-}" 2>/dev/null) || return 1 + held=$(fm_composer_extract_selected_content styled=0 "$cap") || return 1 + [ -n "$held" ] && [ "$(printf '%s' "$held" | tr -d '[:space:]')" = "$(printf '%s' "$3" | tr -d '[:space:]')" ] +} + fm_task_inbox_is_fire_and_forget() { # <record-path> local rec=$1 if [ ! -f "$rec" ]; then diff --git a/bin/fm-tasks-axi-lib.sh b/bin/fm-tasks-axi-lib.sh index 96f2c41f611..6bce7dc4228 100644 --- a/bin/fm-tasks-axi-lib.sh +++ b/bin/fm-tasks-axi-lib.sh @@ -42,7 +42,7 @@ # Both layers are bounded by process lifetime, so a tasks-axi install or upgrade # is picked up by the next process rather than being cached to disk. -FM_TASKS_AXI_MIN=0.2.4 +FM_TASKS_AXI_MIN=0.2.6 FM_TASKS_AXI_COMPATIBLE_MEMO=${FM_TASKS_AXI_COMPATIBLE:-} unset FM_TASKS_AXI_COMPATIBLE diff --git a/bin/fm-teardown.sh b/bin/fm-teardown.sh index 2b4f7f291eb..b4ed373516e 100755 --- a/bin/fm-teardown.sh +++ b/bin/fm-teardown.sh @@ -173,8 +173,24 @@ # recorded and named in the teardown line; the flag never relaxes the # unlanded-work refusal, which --force alone can authorize. A legacy- stamp # an abandoned attempt left behind never counts as a published incarnation: -# the record still reads as a legacy record, so the endpoint gate runs again -# and the retry still needs --legacy-record. +# the record still reads as a legacy record, so a recorded endpoint runs the +# endpoint gate again and the retry still needs --legacy-record. The safe +# windowless exception below retries its retained stamp without the flag. +# A tmux record with no window names no live endpoint, so there is nothing +# for that classifier to inspect and nothing to kill. Combined with a +# missing spawn_gen, that leftover would otherwise deadlock: automatic +# teardown refuses for want of spawn_gen, and --legacy-record then refuses +# for want of a window. When backlog incarnation validation applies, such a +# leftover (no window, no spawn_gen or only a retained legacy stamp, no +# backend other than tmux, no Orca terminal= or other backend's <backend>_* +# endpoint identity, and every other identity field passing the shared +# endpoint validator as if it named the task's own window) is accepted as a +# missing-endpoint legacy record with or without --legacy-record; the shared +# endpoint validator is skipped so it cannot be read as the current window, +# kill is skipped, and a still-present worktree still faces the ordinary +# landed-work checks. Every other windowless record, including one with a +# spawn_gen, a non-tmux backend, or an ambiguous field, still faces the +# validator and refuses. # # Transient / stale worktree git lock recovery (teardown-lock-race): a crew process # killed mid-git-operation can leave a .git/worktrees/<wt>/index.lock (or, for a @@ -442,6 +458,32 @@ TEARDOWN_LEGACY_RETAINED_STAMP= TEARDOWN_LEGACY_PRESTAMP_SIZE=0 TEARDOWN_BACKLOG_APPLIES=0 TEARDOWN_BACKLOG_SKIP_REASON= +TEARDOWN_WINDOWLESS=0 +TEARDOWN_WINDOWLESS_SHAPE=0 +TEARDOWN_WINDOW_COUNT=$(LC_ALL=C grep -c '^window=' "$META" 2>/dev/null || true) +TEARDOWN_BACKEND_COUNT=$(LC_ALL=C grep -c '^backend=' "$META" 2>/dev/null || true) +case "$TEARDOWN_WINDOW_COUNT:$(fm_meta_get "$META" window)" in + 0:|1:) + case "$TEARDOWN_BACKEND_COUNT:$(fm_meta_get "$META" backend)" in + 0:|1:tmux) + TEARDOWN_FOREIGN_ENDPOINT_KEYS='^terminal=' + for TEARDOWN_FOREIGN_BACKEND in $FM_BACKEND_KNOWN; do + [ "$TEARDOWN_FOREIGN_BACKEND" = tmux ] \ + || TEARDOWN_FOREIGN_ENDPOINT_KEYS="$TEARDOWN_FOREIGN_ENDPOINT_KEYS|^${TEARDOWN_FOREIGN_BACKEND}_" + done + if ! LC_ALL=C grep -Eq "$TEARDOWN_FOREIGN_ENDPOINT_KEYS" "$META" 2>/dev/null; then + TEARDOWN_SHAPE_META=$(umask 077; mktemp "${TMPDIR:-/tmp}/fm-teardown-shape.XXXXXX") || exit 1 + { LC_ALL=C grep -v '^window=' "$META" || true; printf 'window=leftover:fm-%s\n' "$ID"; } \ + > "$TEARDOWN_SHAPE_META" + if fm_backend_validate_task_endpoint "$TEARDOWN_SHAPE_META" "$ID" 2>/dev/null; then + TEARDOWN_WINDOWLESS_SHAPE=1 + fi + rm -f "$TEARDOWN_SHAPE_META" + fi + ;; + esac + ;; +esac if [ "$TEARDOWN_CLEANUP_RECOVERY" != orca ]; then if fm_backlog_transition_applies "$CONFIG" "$DATA" "$TEARDOWN_META_KIND"; then TEARDOWN_BACKLOG_APPLIES=1 @@ -457,7 +499,15 @@ fi if [ "$TEARDOWN_BACKLOG_APPLIES" = 1 ]; then if ! fm_backlog_meta_spawn_gen "$META" "$STATE"; then TEARDOWN_LEGACY_GEN_COUNT=$(LC_ALL=C awk -F= '$1 == "spawn_gen" { count++ } END { print count + 0 }' "$META" 2>/dev/null || printf '0\n') - if [ "$TEARDOWN_LEGACY_GEN_COUNT" = 0 ] && [ "$LEGACY_RECORD_GIVEN" = 1 ]; then + if [ "$TEARDOWN_LEGACY_GEN_COUNT" = 0 ] && [ "$TEARDOWN_WINDOWLESS_SHAPE" = 1 ]; then + # A tmux record with no window names no live endpoint, so there is no + # incarnation for spawn_gen to identify and nothing for --legacy-record + # to classify. Accept it as a missing-endpoint leftover, with or without + # the flag; a still-present worktree still faces the ordinary landed-work + # checks below. + TEARDOWN_WINDOWLESS=1 + TEARDOWN_LEGACY_PENDING=1 + elif [ "$TEARDOWN_LEGACY_GEN_COUNT" = 0 ] && [ "$LEGACY_RECORD_GIVEN" = 1 ]; then # A record that predates the incarnation field: acceptance is gated later, # once the recorded endpoint is known, so its state can be confirmed dead # or agent-less before any cleanup decision is made. @@ -479,7 +529,9 @@ if [ "$TEARDOWN_BACKLOG_APPLIES" = 1 ]; then # legacy record it was, and is treated as one: the dead-or-agent-less # endpoint gate runs again on the retry instead of being skipped by # the abandoned attempt's own stamp. - if [ "$LEGACY_RECORD_GIVEN" != 1 ]; then + if [ "$TEARDOWN_WINDOWLESS_SHAPE" = 1 ]; then + TEARDOWN_WINDOWLESS=1 + elif [ "$LEGACY_RECORD_GIVEN" != 1 ]; then echo "error: task $ID's record carries the legacy incarnation stamp $FM_BACKLOG_META_SPAWN_GEN left by an abandoned --legacy-record teardown, not an incarnation published by a spawn; refusing automatic teardown - relaunch the task to publish an unambiguous incarnation, then retry teardown, or pass --legacy-record once its recorded endpoint is confirmed dead or agent-less" >&2 exit 1 fi @@ -968,13 +1020,21 @@ fi # This is the first cleanup authorization check. It is metadata-only and must # complete before fm-guard, a backend command, file removal, branch deletion, # worktree return, registry change, or process termination can run. -fm_backend_validate_task_endpoint "$META" "$ID" || exit 1 -BACKEND=$FM_BACKEND_VALIDATED_BACKEND -T=$FM_BACKEND_VALIDATED_TARGET +# A windowless record names no endpoint: the shared validator would refuse it +# (and must keep refusing it for control/kill callers), so teardown skips the +# validator rather than probing or closing an ambient current window. WT=$(fm_meta_get "$META" worktree) PROJ=$(fm_meta_get "$META" project) T_ORCA= -[ "$BACKEND" != orca ] || T_ORCA=$T +if [ "$TEARDOWN_WINDOWLESS" = 1 ]; then + BACKEND=tmux + T= +else + fm_backend_validate_task_endpoint "$META" "$ID" || exit 1 + BACKEND=$FM_BACKEND_VALIDATED_BACKEND + T=$FM_BACKEND_VALIDATED_TARGET + [ "$BACKEND" != orca ] || T_ORCA=$T +fi if [ "${FM_TEARDOWN_GUARD_DONE:-0}" != 1 ]; then "$FM_ROOT/bin/fm-guard.sh" || true fi @@ -1011,24 +1071,30 @@ fi MODE=$(grep '^mode=' "$META" | cut -d= -f2- || true) [ -n "$MODE" ] || MODE=no-mistakes -# A record accepted as a legacy incarnation (no spawn_gen, --legacy-record -# given) may be torn down only when its recorded endpoint is confidently gone -# or agent-less; only the recovery-grade classifier's dead and missing license +# A record accepted as a legacy incarnation (no spawn_gen, and either +# --legacy-record given or the record is windowless) may be torn down only +# when its recorded endpoint is confidently gone or agent-less. Windowless +# leftovers name no endpoint and are treated as missing. For a recorded +# window, only the recovery-grade classifier's dead and missing license # that, and every ambiguous, unreadable, or unverified endpoint state refuses # while the record is still intact. Acceptance resolves the incarnation token # here; the record itself is stamped only once every landed-work refusal has # passed, immediately before the close marker binds to it, so any refusal # leaves the record byte-identical. if [ "$TEARDOWN_LEGACY_PENDING" = 1 ]; then - TEARDOWN_LEGACY_ENDPOINT=$(fm_backend_agent_state "$BACKEND" "$T") - case "$TEARDOWN_LEGACY_ENDPOINT" in - dead|missing) ;; - *) - echo "REFUSED: task $ID's record predates spawn_gen and its recorded endpoint reads '$TEARDOWN_LEGACY_ENDPOINT', not confidently dead or agent-less; --legacy-record teardown is refused while an agent may still be bound to it. Nothing was changed." >&2 - echo "Reconcile the endpoint first (bin/fm-crew-state.sh $ID), or relaunch the task to publish an unambiguous incarnation, then retry teardown." >&2 - exit 1 - ;; - esac + if [ "$TEARDOWN_WINDOWLESS" = 1 ]; then + TEARDOWN_LEGACY_ENDPOINT=missing + else + TEARDOWN_LEGACY_ENDPOINT=$(fm_backend_agent_state "$BACKEND" "$T") + case "$TEARDOWN_LEGACY_ENDPOINT" in + dead|missing) ;; + *) + echo "REFUSED: task $ID's record predates spawn_gen and its recorded endpoint reads '$TEARDOWN_LEGACY_ENDPOINT', not confidently dead or agent-less; --legacy-record teardown is refused while an agent may still be bound to it. Nothing was changed." >&2 + echo "Reconcile the endpoint first (bin/fm-crew-state.sh $ID), or relaunch the task to publish an unambiguous incarnation, then retry teardown." >&2 + exit 1 + ;; + esac + fi if [ -n "$TEARDOWN_LEGACY_RETAINED_STAMP" ]; then TEARDOWN_META_SPAWN_GEN=$TEARDOWN_LEGACY_RETAINED_STAMP else @@ -1854,7 +1920,7 @@ task_status_is_terminal_run() { # <axi-status-output> <run-id> [ "$run_id" = "$expected_id" ] || return 1 outcome=$(fm_nm_strip_quotes "$(fm_nm_field "$out" outcome)") case "$outcome" in - cancelled|failed|passed|checks-passed) return 0 ;; + cancelled|failed|passed|checks-passed|passed-with-override) return 0 ;; esac return 1 } @@ -3508,7 +3574,7 @@ elif [ "$BACKEND" = herdr ]; then else echo "warning: herdr session presentation lock path is unavailable; skipping the pane close rather than closing unlocked" >&2 fi -elif [ "$BACKEND" != orca ]; then +elif [ "$BACKEND" != orca ] && [ "$TEARDOWN_WINDOWLESS" != 1 ]; then fm_backend_kill "$BACKEND" "$T" "$(meta_value "$META" zellij_tab_id)" "fm-$ID" \ || endpoint_close_refusal "$ID" "$BACKEND" "$T" 1 || exit 1 fi @@ -3655,10 +3721,10 @@ if [ -d "$STATE" ]; then "$SCRIPT_DIR/fm-home-summary-refresh.sh" --best-effort || true fi if [ "$TEARDOWN_LEGACY_ACCEPTED" = 1 ]; then - echo "teardown $ID complete (window $T, worktree $WT, legacy record accepted without spawn_gen: endpoint $TEARDOWN_LEGACY_ENDPOINT, incarnation $TEARDOWN_META_SPAWN_GEN)" + echo "teardown $ID complete (window ${T:-none}, worktree $WT, legacy record accepted without spawn_gen: endpoint $TEARDOWN_LEGACY_ENDPOINT, incarnation $TEARDOWN_META_SPAWN_GEN)" elif teardown_owns_worktree; then - echo "teardown $ID complete (window $T, worktree $WT)" + echo "teardown $ID complete (window ${T:-none}, worktree $WT)" else - echo "teardown $ID complete (window $T; pool slot $WT left to task $TEARDOWN_SLOT_REASSIGNED_TO${TEARDOWN_SLOT_REASSIGNED_HOME:+ (home $TEARDOWN_SLOT_REASSIGNED_HOME)}, which it was reassigned to)" + echo "teardown $ID complete (window ${T:-none}; pool slot $WT left to task $TEARDOWN_SLOT_REASSIGNED_TO${TEARDOWN_SLOT_REASSIGNED_HOME:+ (home $TEARDOWN_SLOT_REASSIGNED_HOME)}, which it was reassigned to)" fi backlog_refresh_reminder diff --git a/bin/fm-test-run.sh b/bin/fm-test-run.sh index d1ab921e01c..e9cb6f6b385 100755 --- a/bin/fm-test-run.sh +++ b/bin/fm-test-run.sh @@ -276,7 +276,7 @@ family_for_basename() { case "$1" in fm-arm-pretool-check.test.sh|fm-ask-user-authority.test.sh|\ fm-bearings-board.test.sh|\ - fm-brief.test.sh|fm-vendor-auth-probe.test.sh|\ + fm-brief.test.sh|fm-dod-lib.test.sh|fm-vendor-auth-probe.test.sh|\ fm-calm-pi-extension.test.sh|fm-cd-pretool-check.test.sh|\ fm-classify-decision-key.test.sh|\ fm-composer-ghost.test.sh|fm-composer-lib.test.sh|\ @@ -352,6 +352,7 @@ family_for_basename() { fm-jev-compaction-live-e2e.test.sh|\ fm-harness-liveness-drift-live-e2e.test.sh|\ fm-muse-signals-live-e2e.test.sh|fm-rovo-signals-live-e2e.test.sh|fm-agy-signals-live-e2e.test.sh|\ + fm-launch-prompt-signals-live-e2e.test.sh|\ fm-herdr-version-floor-live-e2e.test.sh|\ fm-herdr-pi-stale-registration-live-e2e.test.sh|\ fm-opencode-primary-live-e2e.test.sh|fm-pi-branch-live-e2e.test.sh|\ @@ -720,6 +721,7 @@ tests/fm-cursor-primary.test.sh 52269 tests/fm-daemon.test.sh 27262 tests/fm-dispatch-resolve.test.sh 4397 tests/fm-documentation-audiences.test.sh 847 +tests/fm-dod-lib.test.sh 4000 tests/fm-extension-binding.test.sh 9053 tests/fm-fleet-snapshot-view.test.sh 17465 tests/fm-fleet-sync.test.sh 35983 diff --git a/bin/fm-wake-lib.sh b/bin/fm-wake-lib.sh index d3be247d29a..228e0c6596c 100755 --- a/bin/fm-wake-lib.sh +++ b/bin/fm-wake-lib.sh @@ -238,17 +238,32 @@ fm_pi_extension_version() { fi } -# fm_pi_extension_loaded <marker> <expected-version> <session-lock> +# fm_pi_extension_loaded <marker> <expected-version> <session-lock> [active] # True when <marker> records <expected-version> and names the session process in # <session-lock>, i.e. the session holding this home loaded exactly this build. +# The Pi watcher marker additionally carries its generation phase. Requiring +# `active` rejects the handoff marker a retiring generation leaves behind, so a +# running Pi process whose replacement did not load the watcher extension can +# never vouch for an unheld watcher lock with stale load evidence. fm_pi_extension_loaded() { - local marker=$1 expected_version=$2 lock=$3 marker_version marker_pid lock_pid + local marker=$1 expected_version=$2 lock=$3 required_phase=${4:-} marker_version marker_pid lock_pid owner [ -f "$marker" ] && [ -f "$lock" ] && [ -n "$expected_version" ] || return 1 marker_version=$(sed -n '1p' "$marker") marker_pid=$(sed -n '2p' "$marker") lock_pid=$(sed -n '1p' "$lock") [ -n "$marker_pid" ] || return 1 - [ "$marker_version" = "$expected_version" ] && [ "$marker_pid" = "$lock_pid" ] + [ "$marker_version" = "$expected_version" ] && [ "$marker_pid" = "$lock_pid" ] || return 1 + [ -z "$required_phase" ] && return 0 + owner=$(sed -n '3p' "$marker") + case "$owner" in + generation=*\ phase="$required_phase") + owner=${owner#generation=} + owner=${owner%% *} + case "$owner" in ''|0|*[!0-9]*) return 1 ;; esac + return 0 + ;; + *) return 1 ;; + esac } # fm_pi_extension_owns_supervision <state> <root> @@ -260,7 +275,7 @@ fm_pi_extension_loaded() { # missing it has no benign hand-off to tolerate. fm_pi_extension_owns_supervision() { fm_extension_pair_owns_supervision "$1" "$2/.pi/extensions" \ - "fm-primary-pi-watch.ts:.pi-watch-extension-loaded" \ + "fm-primary-pi-watch.ts:.pi-watch-extension-loaded:active" \ "fm-primary-turnend-guard.ts:.pi-turnend-extension-loaded" } @@ -284,15 +299,18 @@ fm_extension_owns_supervision() { fm_pi_extension_owns_supervision "$1" "$2" || fm_omp_extension_owns_supervision "$1" "$2" } -fm_extension_pair_owns_supervision() { # <state> <extension-dir> <source:marker>... - local state=$1 dir=$2 lock session_pid pair source marker version +fm_extension_pair_owns_supervision() { # <state> <extension-dir> <source:marker[:phase]>... + local state=$1 dir=$2 lock session_pid pair source rest marker phase version shift 2 lock="$state/.lock" for pair in "$@"; do source=${pair%%:*} - marker=${pair#*:} + rest=${pair#*:} + marker=${rest%%:*} + phase= + [ "$marker" = "$rest" ] || phase=${rest#*:} version=$(fm_pi_extension_version "$dir/$source") || return 1 - fm_pi_extension_loaded "$state/$marker" "$version" "$lock" || return 1 + fm_pi_extension_loaded "$state/$marker" "$version" "$lock" "$phase" || return 1 done session_pid=$(sed -n '1p' "$lock" 2>/dev/null) fm_pid_alive "$session_pid" @@ -1886,6 +1904,22 @@ fm_wake_secondmate_progress_marker_write() { # <task> <observed-at> <oldest-row- fi } +fm_wake_secondmate_ring_marker_write() { # <task> <row-key> + local task=$1 row_key=$2 marker tmp + case "$task" in ''|*[!A-Za-z0-9._-]*) return 1 ;; esac + case "$row_key" in ''|*[!0-9-]*) return 1 ;; esac + marker="$STATE/.secondmate-wake-ring-$task" + if [ -e "$marker" ] || [ -L "$marker" ]; then + [ -f "$marker" ] && [ ! -L "$marker" ] || return 1 + fi + tmp=$(mktemp "$STATE/.secondmate-wake-ring.XXXXXX") || return 1 + if ! printf '%s\n' "$row_key" > "$tmp" || ! chmod 0600 "$tmp" \ + || ! _fm_atomic_replace "$tmp" "$marker"; then + rm -f -- "$tmp" + return 1 + fi +} + fm_wake_secondmate_stall_marker_write() { # <task> <row-key> local task=$1 row_key=$2 marker tmp case "$task" in ''|*[!A-Za-z0-9._-]*) return 1 ;; esac @@ -2123,7 +2157,10 @@ fm_wake_signal_seen_size() { # <state> <file> # that fact. # A missing marker or unreadable signature is not a match, so uncertainty reads # as an unreported state. -fm_wake_signal_seen_current() { # <state> <file> +# This predicate never consults the owned-append ledger, which is what makes it +# the safe gate for a captain-facing surface: a line must never be withheld from +# presentation merely because this home is the writer that appended it. +fm_wake_signal_reported_current() { # <state> <file> local sig marker sig=$(fm_wake_signal_sig "$2") || return 1 [ -n "$sig" ] || return 1 @@ -2137,6 +2174,28 @@ fm_wake_signal_seen_current() { # <state> <file> esac } +# 0 when the state was already reported, or when the file is a readable regular +# file that grew past the watcher's classified offset and every grown byte is in +# this home's owned-append ledger. Owned-only growth past the classified offset +# is this home's own bookkeeping and is not a new signal, so separate +# --resolve-key answers do not each force a wake. Any other signature change +# without owned growth is not a match, so uncertainty still reads as unreported. +# This is the wake-scan predicate and answers only "should this wake the home?". +# Presentation asks the different question and uses +# fm_wake_signal_reported_current. +fm_wake_signal_seen_current() { # <state> <file> + local classified size + fm_wake_signal_reported_current "$1" "$2" && return 0 + case "$2" in *.status) ;; *) return 1 ;; esac + _fm_wake_require_classify || return 1 + classified=$(fm_wake_signal_seen_size "$1" "$2") + size=$(_fm_status_file_size "$2") || return 1 + size=${size//[[:space:]]/} + case "$classified:$size" in *[!0-9:]*) return 1 ;; esac + [ "$classified" -lt "$size" ] && [ -f "$2" ] && [ -r "$2" ] && [ ! -L "$2" ] || return 1 + status_home_appends_covers "$2" "$classified" "$size" +} + fm_wake_status_reported_commit() { # <state> <status-file> <reported-signature> _fm_wake_require_classify || return 1 status_presentation_marker_report "$(fm_wake_signal_seen_path "$1" "$2")" "$3" @@ -2162,9 +2221,10 @@ fm_wake_status_mark_current() { # <state> <status-file> # in the very turn or tick that writes them (answerer-closes resolved lines, a # pending-reply escalation close, captain-held transfers). Such a close must # not wake the session that wrote it, so this appends one command's lines -# together and then advances the watcher's seen marker across the appended -# bytes and no byte this home has not already read. The advance is -# provenance-gated and fails toward waking: +# together, records the exact appended byte range in the home-owned append +# ledger (bin/fm-classify-lib.sh), and then advances the watcher's seen marker +# across the appended bytes and no byte this home has not already read. The +# advance is provenance-gated and fails toward waking: # - the marker advances only when this home already read every pre-append # byte, the post-append size equals that size plus exactly the appended # bytes (no foreign write interleaved), AND the watcher's own span @@ -2181,10 +2241,13 @@ fm_wake_status_mark_current() { # <state> <status-file> # side-band; # - on ANY other condition - a missing file, pending foreign bytes, an # interleaved writer, an unreadable size or identity - the lines are still -# appended but the marker is left alone, so the watcher surfaces the file -# normally. -# A later, different line from any other writer grows the size past the marker -# and wakes as before: task identity alone can never suppress new content. +# appended and the owned range is still recorded when growth is proven, but +# the marker is left alone, so the watcher surfaces the file normally. +# Later signal scans treat owned ranges as already owned even when the watcher +# has not caught up, so separate --resolve-key answers do not each force a +# captain-facing wake. A later, different line from any other writer grows the +# size past the owned ranges and wakes as before: task identity alone can never +# suppress new content. # Each line is stamped with its emission time on the way in (status_stamp_line, # bin/fm-classify-lib.sh), so the appended bytes are the stamped ones, not the # caller's: a caller that caps a line first must reserve status_stamp_width, @@ -2193,7 +2256,8 @@ fm_wake_status_mark_current() { # <state> <status-file> # Returns 0 appended and self-announced, 1 appended but left for the watcher # (the safe direction), 2 the append itself failed. fm_wake_status_append_self_announced() { # <state> <status-file> <line>... - local state=$1 file=$2 line appended=0 pre_size='' pre_ident='' post_size post_ident classified folded lag span_rc=0 + local state=$1 file=$2 line appended=0 pre_size='' pre_ident='' post_size post_ident + local classified folded lag span_rc=0 local LC_ALL=C stamped=() shift 2 _fm_wake_require_classify || return 1 @@ -2205,12 +2269,14 @@ fm_wake_status_append_self_announced() { # <state> <status-file> <line>... pre_ident=$(_fm_open_decisions_file_ident "$file") || pre_ident='' fi printf '%s\n' "${stamped[@]}" >> "$file" || return 2 + case "$pre_size" in ''|*[!0-9]*) return 1 ;; esac post_size=$(_fm_status_file_size "$file") || return 1 post_ident=$(_fm_open_decisions_file_ident "$file") || return 1 - case "$pre_size$post_size" in ''|*[!0-9]*) return 1 ;; esac + case "$post_size" in ''|*[!0-9]*) return 1 ;; esac [ -n "$pre_ident" ] && [ "$post_ident" = "$pre_ident" ] || return 1 for line in "${stamped[@]}"; do appended=$((appended + ${#line} + 1)); done [ "$post_size" -eq $((pre_size + appended)) ] || return 1 + status_home_appends_record "$file" "$pre_size" "$post_size" || return 1 classified=$(fm_wake_signal_seen_size "$state" "$file") if [ "$classified" != "$pre_size" ]; then folded=$(status_open_decisions_cursor_offset "$file") || folded=0 @@ -2385,7 +2451,7 @@ fm_wake_print_annotations() { # <deduped-raw-rows> [<presentation-snapshot>] # existing historical caveat. A direct status row is annotated for every # still-unread line since the last drain presentation; already-presented # bytes are not replayed. - if [ "$mode" = historical ] && fm_wake_signal_seen_current "$STATE" "$path"; then + if [ "$mode" = historical ] && fm_wake_signal_reported_current "$STATE" "$path"; then continue fi offset=$(fm_wake_status_cursor_offset "$path") || return 1 diff --git a/bin/fm-watch.sh b/bin/fm-watch.sh index 3bb27ba93d1..0597d0a6d76 100755 --- a/bin/fm-watch.sh +++ b/bin/fm-watch.sh @@ -122,9 +122,16 @@ # while the mate was not in an active turn (a busy mate # is exempt only until the queue has been frozen for # BUSY_TURN_MAX_SECS); declared external-wait pause -# rows do not feed this escalation, observation is -# read-only, and one parent notification covers each -# no-progress episode +# rows do not feed this escalation; a mate whose +# semantic busy class is exactly idle, whose agent is +# alive, and whose composer is not pending is rung +# once so its own home can drain, and the parent +# notification is withheld until that same row stays +# frozen for another stall interval; unknown or +# ring-unsafe panes keep the parent alarm; empty +# inbox and a fresh child beacon are not idle proof; +# the foreign queue itself stays read-only, and one +# parent notification covers each no-progress episode # For normal supervision, resume the session-start primary-harness protocol # after each printed reason. Direct duplicate invocations of this script still # no-op through the watcher singleton lock. @@ -345,8 +352,10 @@ hash_pane() { # verdict returns 0: idle, unknown, and dead all return 1, so a converted # adapter whose semantic state is missing, malformed, stale, or unverified is # treated as not-provably-working and surfaces rather than being absorbed. -# <tail40> is the same bounded capture already read for hashing and is -# consumed only by the Grok-scoped fallback inside the contract. +# <tail40> is the same bounded capture already read for hashing and is passed +# into the contract's harness-scoped rendered-text checks: the Grok/Rovo/AGY +# busy fallbacks and the launch-prompt backstop that keeps a launch pinned at +# its fm-spawn seed from reading as provably working. window_is_busy() { # <window> <tail40> local w=$1 tail40=$2 task meta verdict task=$(window_to_task "$w" "$STATE") @@ -768,6 +777,60 @@ secondmate_in_active_turn() { # <window> <idle> window_is_busy "$w" "$tail40" } +# First token of the semantic busy classification for <window>: busy, idle, +# unknown, or dead. Capture failure and a missing window are unknown, never +# idle. Empty inbox and a fresh watcher beacon are not consulted. +secondmate_busy_class() { # <window> + local w=$1 task meta tail40 verdict + task=$(window_to_task "$w" "$STATE") + meta="$STATE/$task.meta" + if [ -z "$w" ] || [ -z "$task" ] || [ ! -f "$meta" ]; then + printf 'unknown' + return 0 + fi + tail40=$(fm_backend_capture "$(window_backend "$w")" "$w" 40 "$(window_label "$w")" 2>/dev/null) || { + printf 'unknown' + return 0 + } + verdict=$(fm_busy_classify_meta "$meta" "$task" "$STATE" "$tail40") + printf '%s' "${verdict%% *}" +} + +# 0 iff a child ring is authorized: exact idle, a live agent, and a composer +# that is not proven pending. Busy, unknown, dead, missing, and pending +# composer all refuse, so a Kimi or Claude pane without an exact idle +# verdict is never typed into. +secondmate_idle_ring_safe() { # <window> + local w=$1 backend agent_state cstate + [ -n "$w" ] || return 1 + [ "$(secondmate_busy_class "$w")" = idle ] || return 1 + backend=$(window_backend "$w") + agent_state=$(fm_backend_agent_state "$backend" "$w" 2>/dev/null || true) + [ "$agent_state" = alive ] || return 1 + cstate=$(fm_backend_composer_state "$backend" "$w" "$(window_label "$w")" 2>/dev/null) || cstate=unknown + [ "$cstate" != pending ] || return 1 + return 0 +} + +# Write one fire-and-forget drain steer and ring the child's doorbell. The +# steer carries the same from-firstmate fire-and-forget carrier fm-send uses +# for a secondmate (marker, then delivery=<16-hex-id>, then the text), so the +# mate reads it as a parent request that expects no reply, never as captain +# intervention. The worker's ordinary wake-handling turn drains its own home's +# wake queue; this parent never rewrites that foreign queue. 0 iff the ring +# call returned 0. +secondmate_ring_to_drain() { # <task> <window> + local task=$1 w=$2 rec backend delivery_id + backend=$(window_backend "$w") + delivery_id=$(LC_ALL=C od -An -v -tx1 -N 8 /dev/urandom 2>/dev/null | tr -d ' \n') || return 1 + case "$delivery_id" in ''|*[!0-9a-f]*) return 1 ;; esac + [ "${#delivery_id}" -eq 16 ] || return 1 + rec=$(fm_task_inbox_write "$STATE" "$task" \ + "${FM_FROMFIRST_MARK}delivery=${delivery_id} Drain pending rows in this home's wake queue, then resume idle supervision." \ + fire-and-forget) || return 1 + fm_task_inbox_ring "$backend" "$w" "$rec" "$(window_label "$w")" +} + # Surface one durable parent check when the foreign queue's drain position has # not moved for the bounded interval. The progress marker records that position # as the same epoch-sequence row identity the stall receipts use, so the timer @@ -780,12 +843,17 @@ secondmate_in_active_turn() { # <window> <idle> # a later genuine freeze remains visible. A mate demonstrably inside an active # turn defers its escalation, but only while this same interval is under # BUSY_TURN_MAX_SECS, so a turn that never ends cannot hide a frozen queue. +# A mate whose busy class is exactly idle, whose agent is alive, and whose +# composer is not pending is rung once so its own home can drain, and the +# parent notification is withheld until that same row stays frozen for another +# stall interval. Unknown, busy-over-bound, and ring-unsafe panes keep the +# parent alarm. Empty inbox and a fresh child beacon are not idle proof. # Receipts close the append-before-marker crash window without changing the # foreign queue. secondmate_wake_stall_tick() { local now=$(( $(date +%s) )) threshold=$SECONDMATE_WAKE_STALL_SECS - local meta task kind remote_host home queue row epoch seq row_key marker progress_marker progress observed_at observed_key - local receipt receipt_dir notify_key queued idle reason episode_alerted + local meta task kind remote_host home queue row epoch seq row_key marker progress_marker ring_marker progress observed_at observed_key + local receipt receipt_dir notify_key queued idle reason episode_alerted already_rung w # Endpoint metadata admits this queue-loop check; secondmate-liveness owns registered mates whose endpoint is missing or dead. for meta in "$STATE"/*.meta; do [ -e "$meta" ] || continue @@ -804,9 +872,10 @@ secondmate_wake_stall_tick() { row=$(secondmate_oldest_queue_row "$queue") marker="$STATE/.secondmate-wake-stall-$task" progress_marker="$STATE/.secondmate-wake-progress-$task" + ring_marker="$STATE/.secondmate-wake-ring-$task" receipt_dir="$STATE/.secondmate-wake-stall-receipts/$task" if [ -z "$row" ]; then - rm -f "$marker" "$progress_marker" + rm -f "$marker" "$progress_marker" "$ring_marker" if [ -e "$receipt_dir" ] || [ -L "$receipt_dir" ]; then [ -d "$receipt_dir" ] && [ ! -L "$receipt_dir" ] || return 1 rm -rf -- "$receipt_dir" || return 1 @@ -838,12 +907,26 @@ EOF || [ "$now" -lt "$observed_at" ] || [ "$row_key" != "$observed_key" ]; then fm_wake_secondmate_progress_marker_write "$task" "$now" "$row_key" || return 1 [ "$episode_alerted" -eq 0 ] || rm -f "$marker" || return 1 + rm -f "$ring_marker" || return 1 continue fi [ "$episode_alerted" -eq 0 ] || continue idle=$((now - observed_at)) [ "$idle" -ge "$threshold" ] || continue - ! secondmate_in_active_turn "$(fm_backend_target_of_meta "$meta")" "$idle" || continue + w=$(fm_backend_target_of_meta "$meta") + ! secondmate_in_active_turn "$w" "$idle" || continue + already_rung=0 + if [ -e "$ring_marker" ] || [ -L "$ring_marker" ]; then + [ -f "$ring_marker" ] && [ ! -L "$ring_marker" ] || return 1 + [ "$(cat "$ring_marker" 2>/dev/null || true)" = "$row_key" ] && already_rung=1 + fi + if [ "$already_rung" -eq 0 ] && secondmate_idle_ring_safe "$w"; then + if secondmate_ring_to_drain "$task" "$w"; then + fm_wake_secondmate_ring_marker_write "$task" "$row_key" || return 1 + fm_wake_secondmate_progress_marker_write "$task" "$now" "$row_key" || return 1 + continue + fi + fi receipt="$receipt_dir/$row_key" if [ "$(cat "$receipt" 2>/dev/null || true)" = "$row_key" ]; then fm_wake_secondmate_stall_marker_write "$task" "$row_key" || return 1 @@ -1708,6 +1791,10 @@ age_of() { # seconds since file mtime; "due immediately" if missing # -nt comparison. # Status signatures include observable file and readability state, while turn-end # markers retain their size-and-mtime signature. +# A status file is asked the wider wake question instead, so it also stays quiet +# when the only bytes it grew past the classified offset are this home's own +# bookkeeping appends; fm_wake_signal_seen_current (bin/fm-wake-lib.sh) owns that +# rule and every other signature change still reads as unreported. # Pure read: prints one "<seen-file>\t<sig>\t<file>" line per changed file. # The caller records reported state only after surfacing or intentional absorption, # and commits a status classification position only after a successful span read. @@ -1850,6 +1937,22 @@ fm_active_check_stop() { FM_ACTIVE_CHECK_PGID= } +# Stop-signal dispositions, installed with the EXIT trap below. HUP and TERM +# keep bash's native fatal-signal handling, which runs watcher_cleanup through +# the EXIT trap and then exits on every supported bash (bash 3.2 also needs the +# SIGCHLD ticker started beside WATCHER_PID to act on a signal that arrives +# during a blocked command-substitution read). A trap body such as +# 'exit 1' is not reliable for them: bash 5.2 runs a pending trap inside the +# parse of the next command substitution, the body then fails to parse ("trap: +# line 2: unexpected EOF while looking for matching `)'", or nothing at all), +# and the signal is consumed, so a stop request could leave this watcher +# polling forever while its stopper waits (fixed upstream in bash 5.3). INT +# keeps its trap because bash ignores a direct SIGINT while a child runs. +watcher_stop_signals() { + trap - HUP TERM + trap 'exit 1' INT +} + run_check_capture() { local pgid fm_check_output_cleanup @@ -1857,20 +1960,23 @@ run_check_capture() { FM_CHECK_OUTPUT=$(mktemp "$STATE/.fm-check-output.XXXXXX") || return 1 chmod 0600 "$FM_CHECK_OUTPUT" || { fm_check_output_cleanup; return 1; } FM_CHECK_SIGNAL_PENDING= + # Defer stop signals only until the check's process group is recorded for + # watcher_cleanup. Keep command substitutions out of this window: bash 5.2 + # can drop a trap that is pending when one is parsed (watcher_stop_signals). trap 'FM_CHECK_SIGNAL_PENDING=1' HUP INT TERM set -m ( FM_CHECK_OWNED_GROUP=1 run_check_process "$@" ) > "$FM_CHECK_OUTPUT" 2>/dev/null & FM_ACTIVE_CHECK_PID=$! FM_ACTIVE_CHECK_PGID=$FM_ACTIVE_CHECK_PID set +m + watcher_stop_signals + [ -z "$FM_CHECK_SIGNAL_PENDING" ] || exit 1 pgid=$(ps -o pgid= -p "$FM_ACTIVE_CHECK_PID" 2>/dev/null | tr -d '[:space:]') - trap 'exit 1' HUP INT TERM if [ -n "$pgid" ] && [ "$pgid" != "$FM_ACTIVE_CHECK_PGID" ]; then fm_active_check_stop || true fm_check_output_cleanup return 1 fi - [ -z "$FM_CHECK_SIGNAL_PENDING" ] || exit 1 wait "$FM_ACTIVE_CHECK_PID" 2>/dev/null || true FM_ACTIVE_CHECK_PID= fm_active_check_stop || return 1 @@ -2207,6 +2313,7 @@ watcher_cleanup() { fm_active_check_stop || cleanup_status=1 fm_check_output_cleanup fm_custom_check_snapshot_cleanup + [ -z "${WATCHER_SIGNAL_TICKER_PID:-}" ] || kill "$WATCHER_SIGNAL_TICKER_PID" 2>/dev/null || true if [ "$owns_lock" -eq 1 ] \ && ! fm_recovery_transition "$WATCHER_DOWNTIME_MARKER" "$transition" "$WATCH_LOCK" downtime; then echo "watcher: recovery state could not be persisted; retaining stale lock evidence" >&2 @@ -2215,11 +2322,20 @@ watcher_cleanup() { return "$cleanup_status" } trap watcher_cleanup EXIT -trap 'exit 1' HUP INT TERM +watcher_stop_signals # This watcher's own pid, as recorded in the lock by fm_lock_claim (which writes # ${BASHPID:-$$} from this same main shell). Read directly, never via a command # substitution, so it matches the stored holder pid for the self-eviction check. WATCHER_PID=${BASHPID:-$$} +# Bash 3.2 (stock macOS) does not act on a fatal HUP or TERM that arrives while +# this shell is blocked reading a command substitution (a hung pane capture); +# it retries the read and exits only when the read ends or a SIGCHLD arrives. +# A ticker child sends SIGCHLD every second so one stop signal still ends the +# watcher. It stops once this watcher is gone; watcher_cleanup also stops it. +if [ "${BASH_VERSINFO[0]:-0}" -lt 4 ]; then + ( while kill -CHLD "$WATCHER_PID" 2>/dev/null; do sleep 1; done ) </dev/null >/dev/null 2>&1 & + WATCHER_SIGNAL_TICKER_PID=$! +fi printf '%s\n' "$FM_HOME" > "$WATCH_LOCK/fm-home" || true printf '%s\n' "$WATCH_PATH" > "$WATCH_LOCK/watcher-path" || true # shellcheck disable=SC2034 # Consumed by wake() in the separately linted transition owner. diff --git a/docs/architecture.md b/docs/architecture.md index 5ee3073baba..b071c45cae6 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -64,9 +64,10 @@ That handoff is keyed on the declaration itself (the status log's signature) rat Those actionable wakes are written to a durable local queue (`state/.wake-queue`) only after generation-bound recovery evidence is published, so an interrupted watcher or handling turn can be recovered without losing the queue record. Agent endpoint liveness and queue-consumption liveness are separate: on each poll, the primary watcher reads the oldest valid actionable row from every endpoint-recorded local secondmate home's durable wake queue without locking, consuming, or rewriting that foreign queue. A queue that is draining is not stalled, so the primary times the interval since that oldest actionable row last changed rather than the age of the row itself, and rows that declare themselves a bounded external wait (`awaiting external - declared pause`) are not actionable evidence at all. -Once that no-progress interval reaches `FM_SECONDMATE_WAKE_STALL_SECS` and the mate is not provably inside an active turn (an exact busy verdict, honored only while that same no-progress interval is under `FM_BUSY_TURN_MAX_SECS`, because a mate's turns end in its own home and leave no completed-turn evidence in the primary's), the primary appends one keyed `check` wake naming the mate, row sequence, and observed idle interval; parent receipts and queued-key deduplication suppress repeats across watcher and handling crashes, one notification covers a whole no-progress episode, and any move of that position - drain progress, or the fresh rows of a queue reprovisioned under the same task id, at whatever sequence it restarts - ends that episode and starts a fresh observation interval, while empty, advancing, and declared-wait queues remain silent. +Once that no-progress interval reaches `FM_SECONDMATE_WAKE_STALL_SECS` and the mate is not provably inside an active turn (an exact busy verdict, honored only while that same no-progress interval is under `FM_BUSY_TURN_MAX_SECS`, because a mate's turns end in its own home and leave no completed-turn evidence in the primary's), a mate whose semantic busy class is exactly idle, whose agent is alive, and whose composer is not pending is rung once so its own home can drain, and the parent notification is withheld until that same row stays frozen for another stall interval; unknown, busy-over-bound, and ring-unsafe panes keep the parent alarm, and empty inbox or a fresh child beacon is not idle proof. +The primary then appends one keyed `check` wake naming the mate, row sequence, and observed idle interval; parent receipts and queued-key deduplication suppress repeats across watcher and handling crashes, one notification covers a whole no-progress episode, and any move of that position - drain progress, or the fresh rows of a queue reprovisioned under the same task id, at whatever sequence it restarts - ends that episode and starts a fresh observation interval, while empty, advancing, and declared-wait queues remain silent. Endpointless registered mates remain outside this scan because startup secondmate-liveness owns dead or missing endpoint recovery, and remote homes retain their host-local supervision boundary. -`tests/fm-wake-queue.test.sh` pins the no-progress notification, drain-progress reset, declared-pause exclusion, active-turn deferral, idempotence, quiet-queue, and byte-for-byte foreign-row preservation guarantees. +`tests/fm-wake-queue.test.sh` pins the no-progress notification, drain-progress reset, declared-pause exclusion, active-turn deferral, proven-idle child-first ring, busy and unknown parent-alarm paths, genuine stall after a ring, idempotence, quiet-queue, and byte-for-byte foreign-row preservation guarantees. When a canonical validated PR poll returns exactly `merged`, the watcher routes it through the shared merge-outcome emitter before retiring the poll. [`bin/fm-merge-outcome-lib.sh`](../bin/fm-merge-outcome-lib.sh)'s header owns role routing, PR-specific wake identity, marker-locked normal deduplication, and the at-least-once ordering that prefers a rare duplicate over silence. After successful outcome publication, the watcher immediately delivers the emitter's local actionable poll row and publishes a private retirement receipt bound to the poll's registration, bytes, file identities, metadata, provider, URL, and task ID. @@ -107,16 +108,18 @@ A queued signal annotation prints every status line still unread at that cursor, A third bounded section, RECORD DIVERGENCE, prints on the same drains for the opposite failure: the status fold went quiet on a key that the durable captain-held task still shows as open, so the status side reads as complete while the two records contradict each other; `bin/fm-captain-hold.sh diverged` decides what counts and closes nothing, and `docs/captain-hold-lifecycle.md` owns the mechanism. A failed read, output, or concurrent-replacement check prevents the snapshot cursor from advancing across uncertain bytes, and teardown retires a task's manifest row before that task ID can be reused. The explicit resolution is written by the actor that answers, not the busy worker: `fm-send`'s `--resolve-key` appends the closing `resolved` line to this home's own copy of the ledger at answer time, which covers crewmates, local secondmates, and remote secondmates identically because a remote mate's escalations reach that local copy through the parent-replies ingest and only the answer message itself crosses the transport. -This home's answerer close, pending-reply escalation close, and captain-held transfer use the provenance-guarded append owned by `bin/fm-wake-lib.sh`, so they advance the watcher marker past their own bytes only when every earlier byte was already classified by the watcher or listed as an open decision; any other earlier line, and any interleaved foreign write, fails toward an ordinary wake. +This home's answerer close, pending-reply escalation close, and captain-held transfer use the provenance-guarded append owned by `bin/fm-wake-lib.sh`, which records the exact byte range it appended so a later wake scan can tell this home's own growth from a foreign write instead of waking on it. +The watcher marker advances past those bytes only when every earlier byte was already classified by the watcher or listed as an open decision by the OPEN DECISIONS fold; any other earlier line, including a worker line the fold read but never listed, and any interleaved foreign write, fails toward an ordinary wake. A turn-ended-only queue row omits its historical status annotation when that status file exactly matches the same seen marker. Any direct or remaining historical annotation prints every status line unread at the presentation cursor instead of replaying only the latest line. +The owned-append ledger only decides whether growth wakes this home; it never removes a line from presentation, so both that annotation and the UNREAD STATUS section still print this home's own bookkeeping closes. `bin/fm-crew-state.sh <id>` is the cheap current-state read for an actionable heartbeat review: it attributes an active or terminal no-mistakes run under the shared run-attribution contract, then keeps that run-step authoritative even if the pane has closed, except that a `blocked:` event reporting a refused or missing daemon socket outranks a potentially stale active run record only while that socket-down declaration is itself the log's latest recognized event, since any later event, including another `blocked:` one, means the crew moved on. For other daemon, timeout, or unreachability claims, a running or fixing run with recent pipeline-reported activity supersedes the event and names reattachment as the recovery instead of surfacing a false block. [`bin/fm-nm-run-lib.sh`](../bin/fm-nm-run-lib.sh) owns branch, head, and pipeline-custody attribution, plus complete same-branch run selection, optional inventory lookup, and ambiguity reporting. A run executing on the crew's own branch is current regardless of head, because the pipeline rebases that branch and commits its fix rounds in its own checkout, so reading an older run that still matches the local head would report a working crew as failed; every other run, parked or terminal, still binds on head equality or ancestry, or on the pipeline's own custody attribution while it owns the branch, and that head-free live bind is withdrawn once an explicit `daemon status` probe answers that the daemon is down. [`tests/fm-crew-state.test.sh`](../tests/fm-crew-state.test.sh) covers run selection; its [capture provenance and live-evidence limits](../tests/captures/no-mistakes-v1.70.1/README.md) distinguish recorded inputs from composed scenarios. -During no-mistakes' `ci` monitor phase, it also reads the ci step log tail because `axi status` reports both "still waiting on checks" and "checks green, waiting on merge" as `ci,running`. -The most recent recognized ci log marker wins, so checks-green monitoring reports done while a later re-arm, failed-check, or issue marker returns the crew to working. +During no-mistakes' `ci` monitor phase, it also reads the full ci step log because `axi status` reports both "still waiting on checks" and "checks green, waiting on merge" as `ci,running`. +The most recent recognized ci log marker wins, so checks-green monitoring reports done while a later failed-check, checks-running, or issue marker returns the crew to working; a base-branch timeout re-arm is not a marker because it leaves readiness unchanged. `bin/fm-crew-state.sh` owns the evidence guard that recognizes ended CI monitors after green checks, including cancelled runs and skipped rebase steps; a passed run alone never proves a forge merge. In the coarse runs-ledger fallback, which has no steps table and no ci log, a terminal failed record whose daemon an explicit `daemon status` probe proves down reports unknown as unverified instead: an instrument failure must never read as work failure. The same instrument rule covers the ledger-anchored continuation of a selected run whose head this copy cannot resolve: once the probe answers down, that still-executing record reports unknown as unverified, while a run parked at a gate keeps its gate and findings because an open decision stays open when the instrument dies, and a `needs-decision` or `blocked` event the crew observed first hand stays open with the unverified record named as the reason rather than superseded by it. @@ -158,6 +161,7 @@ That block owns the live wait shape for the running primary harness: Claude's St [`watcher-continuity.md`](watcher-continuity.md#arm-layer-cycle-contract) owns the arm layer's successor, terminal-delivery, re-arm recovery, and typed clean-close failure contract. The arm layer records one bounded lifecycle row per observed cycle in `state/.watch-cycle-exits.log`; `state/.watch-triage.log` remains exclusively the absorbed-wake debug log. Pi, omp, and OpenCode verify session-lock ownership and launch one singleton successor from their child-close handlers before delivering an actionable wake prompt, with bounded exponential retry for failed restoration. +Pi additionally retains an established predecessor across ordinary same-process session shutdown until the replacement generation commits its tracked arm, and its active-versus-handoff generation marker prevents an absent replacement extension from satisfying the fresh-beacon handoff tolerance. Claude's `bin/fm-claude-stop-autoarm.sh` hook fires on every Stop and, when the home is eligible and still needs supervision, claims one home-scoped cycle, foregrounds the arm wrapper, and translates actionable closes into exit-2 rewakes. It suppresses failed-looking closes when the same identity-matched watcher is healthy, retries genuine failures within a bound, and coordinates exhausted failure episodes with the Claude turn-end guard as documented in [`turnend-guard.md`](turnend-guard.md). [`watcher-continuity.md`](watcher-continuity.md) owns Claude's residual active-turn coverage and watcher-status command-gating boundary. @@ -171,11 +175,11 @@ It leads with a prominent bordered tangle banner, while `bin/fm-guard.sh` owns t On every verified primary harness, tracked hook integration gives the primary session a push-based backstop: when work, a process-event source, a registered custom check, or Relay polling needs supervision and no supervision owner provably holds this home with a fresh beacon, blocking-capable Stop hooks block and nonblocking turn-end integrations force one bounded follow-up. The guard covers the main primary and genuinely marked secondmate homes, exempts child crewmate/scout worktrees, is loop-safe per harness, and is documented in [turnend-guard.md](turnend-guard.md). -Away mode is a posture of the one supervision session, recorded in `state/.afk-contract` by `bin/fm-afk-contract.sh` after the captain confirms a plain-sentence read-back of their away words, and announced at entry as hold-for-return only because no phone channel exists. +Away mode is a posture of the one supervision session, recorded in `state/.afk-contract` by `bin/fm-afk-contract.sh` in the same turn as `/afk` with no wait for a further go, read back in plain sentences only after entry, and announced at entry as hold-for-return only because no phone channel exists. The captain's away words are the whole mandate: the record owner's header is the single owner of the record schema, the words are recorded verbatim, and by the captain's mandate no parser, tokenizer, classifier, or grammar reads them anywhere. The supervision session reads the words at the tail of every wake and acts on them by its own judgment at the moment an event makes them relevant, only through the guarded scripts under standing authority, never by analogy, holding for the return on doubt; `bin/fm-branch-prompt.sh` "Postures" owns those execution rules. What stays mechanical is exactly what a script can check without reading words: a merge green at its live head under the record lock, synchronous merges only, the spend cap, and the never-set; destructive, irreversible, and security-sensitive actions are never pre-authorizable whatever the words say. -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 captain's words verbatim with the session's account of every action taken under them, 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. +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 owns the return brief's ordered sections, including landed live task records that still owe cleanup, rendered from durable state. 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. 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. @@ -218,7 +222,11 @@ Every classification returns a verdict of busy, idle, unknown, or dead together Each converted adapter reports its own turn lifecycle through a machine-readable contract the vendor already exposes, rather than through rendered footer text: Pi and pi-signed through the Firstmate-owned extension's `agent_start` and `agent_settled` confirmed by `ctx.isIdle()`, omp through its extension's `agent_start` and `agent_end` without `willContinue`, OpenCode through its plugin's semantic `session.status`, Claude through owned `UserPromptSubmit`, `Stop`, `StopFailure`, and `SessionEnd` hooks, Muse through its session log, and Cursor through its conversation transcript. Kimi behind Pi inherits Pi's lifecycle. -Codex and standalone Kimi classify unknown behind explicit probes until a semantic source is live-verified for them, and Grok, Rovo, and AGY each keep one clearly isolated rendered-tail fallback that can only ever classify their own task. +Codex and standalone Kimi classify unknown behind explicit probes until a semantic source is live-verified for them, and Grok, Rovo, and AGY each keep one clearly isolated rendered-tail busy fallback that can only ever classify their own task. +The one case where the contract reads rendered text for a converted adapter is the launch-prompt backstop (`fm_busy_launch_prompt_parked` in `bin/fm-busy-lib.sh`): when a record is still the untouched `fm-spawn` seed and the caller supplied a captured pane matching that harness's own recognized interactive launch prompt - a workspace-trust dialog, sign-in screen, or first-run menu - `fm_busy_classify` reports `unknown launch-prompt` instead of `busy fm-spawn`. +That keeps a launch that never began its brief from holding the busy-age exemption for the whole `FM_BUSY_TURN_MAX_SECS` bound and surfaces it through the ordinary not-provably-working path instead. +A record any real hook event has advanced is never reclassified this way however its pane looks, no captured tail means the record's own state stands, and the general busy bound is unchanged. +The per-harness signature table lives in `bin/fm-busy-lib.sh`'s header, and [runtime backend verification](verification/runtime-backends.md#launch-prompt-backstop-signatures) owns the live evidence. Missing, malformed, stale, untrusted, or unverified semantic state is unknown, never idle, and unknown is never promoted to busy either. Ordinary task-state consumers act only on an exact busy verdict, so an unreadable worker surfaces for a closer look instead of being absorbed as still-working or written off as finished. @@ -346,6 +354,8 @@ Each task's mode and `yolo` merge posture are firstmate's decision at intake. The mode is passed explicitly to `bin/fm-brief.sh`, and both values are passed explicitly to `bin/fm-spawn.sh` and `bin/fm-promote.sh`; each command refuses to guess the values it consumes. A ship brief records its mode as a fixed machine-readable line and the spawn refuses to launch on a different one, so the worker's instructions and the recorded task delivery cannot diverge. `bin/fm-dod-lib.sh` is the one owner of that mode's definition of done, rendered into a generated ship brief, the ship instructions a promoted scout receives, and that scout's own `brief.md` so a later relaunch reads the same contract, so a promoted worker cannot be handed a weaker contract than a briefed one. +It also owns the named-head reachability gate that refuses a ship `done:` while that head exists only in the worker's disposable copy, testing the named head rather than whether some branch moved. +`bin/fm-crew-state.sh`, `bin/fm-pr-check.sh`, and the secondmate ledger-first publisher call that same gate before treating a ship `done:` as ready. It is also the one owner of the no-mistakes `--intent` contract those workers follow. `data/projects.md` records each project's standing posture and optional `+yolo` merge flag as the captain's default and as context for that decision, including the conditional `no-mistakes-prod-only` policy; a ship spawn that drops below the registered rigor prints a deviation notice and continues. `bin/fm-project-mode.sh` remains the one registry parser for the mechanical consumers that have no task in hand: fleet sync's `local-only` skip and home seeding's refusal and no-mistakes initialization. diff --git a/docs/captain-hold-lifecycle.md b/docs/captain-hold-lifecycle.md index bd2f08018fc..b6023c47736 100644 --- a/docs/captain-hold-lifecycle.md +++ b/docs/captain-hold-lifecycle.md @@ -37,7 +37,7 @@ The policy prefers holding the very work item a question gates, so the backlog r `bin/fm-teardown.sh` therefore asks the read-only `open` subcommand before its automatic close: exit 0 means the row is still an open captain call (not Done, `hold_kind: captain`), 1 means it is not, and 2 means the answer could not be established, which teardown treats as a refusal before any destructive step rather than as permission to close. On 0 only the close changes: after cleanup and still under the task's own lock, teardown records one `Deliverable of the finished work: ...` line at the end of the task body, copies a supported pull request or canonical `data/<id>/report.md` into the row's structured artifact fields, and runs `tasks-axi reopen`, so the row returns to Queued with its hold intact and remains on the appropriate Captain's Call or Charted Next decision surface instead of reading as work still under way. The pending-close record teardown already stages before destructive cleanup carries that intent as a `mode=retain` line, so an interrupted cleanup replays the retention at the next session start through the same record, validator, and lock as an ordinary close and never closes the row; if the captain answers before replay, `answer` validates that record and copies any supported retained pull request or report into the row before closing it, after which replay retires the record. -Two retained-delivery gaps remain bounded by tasks-axi 0.2.5 and are recorded for separate upstream work rather than representing defects introduced by this branch. +Two retained-delivery gaps remain bounded by tasks-axi 0.2.6 and are recorded for separate upstream work rather than representing defects introduced by this branch. A retained local-only delivery cannot reach the row because `--note` exists on `tasks-axi done` but not on `tasks-axi update`, while the durable pending-close record carrying that note is retired when retention completes. A relocated retained report cannot reach the row because tasks-axi accepts only `data/<id>/report.md`: `done` reports `Task report link must be a data/<id>/report.md path`, and `update` reports `--report must be a data/<id>/report.md path`. When an interrupted retention leaves such a relocated report in the validated pending-close record, `answer` skips only that known-unsupported row artifact and closes normally, so the delivery remains absent from Recently Landed instead of wedging the captain's answer. diff --git a/docs/configuration.md b/docs/configuration.md index 7e3fb25edb2..a03055201b1 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -382,9 +382,10 @@ The [Claude adapter reference](../.agents/skills/harness-adapters/references/har ## Lavish server address (config/lavish-axi-host) The optional local, gitignored `config/lavish-axi-host` contains one non-empty address without whitespace for the per-machine Lavish server. -`fm-spawn.sh` exports that address into every new worker and relaunch, the process-event adapter reads it before each `lavish-axi` invocation, and the file is inherited into secondmate homes through the primary-authoritative configuration contract. +`fm-spawn.sh` exports that address into every new worker and relaunch for opening boards, and the file is inherited into secondmate homes through the primary-authoritative configuration contract. +Once a board exists, the process-event adapter derives the polling address from that board's own saved Lavish session instead; its header owns the lookup contract. When the file is absent, worker launches do not add a board address and retain the existing ambient-environment behavior. -Malformed or unreadable values refuse the launch before the worker starts, while the adapter refuses the same malformed value before polling. +Malformed or unreadable values refuse the launch before the worker starts. The address selects the existing shared server; it does not authorize starting or stopping the server, and the Lavish startup crash remains a vendor-tool concern. ## Home brief include (config/brief-include.md) @@ -486,14 +487,16 @@ Rule `approval` and `floor`, and profile `provider` and `floor` are optional dec The resolver supplies the fixed neutral Choice option `No listed rule applies to this task.` for work that matches no listed rule. `approval` accepts only `"captain"` and means a task the rule matches is never dispatched from the tool's answer alone. A rule `floor` names the quota-axi `provider` and `scope` whose `effectivePercentRemaining` must be at least `min_percent` for the rule's profiles to apply. -A known percentage below it makes the tool resolve among `default` instead; an absent or unknown row or unmeasured provider makes the floor unverifiable and escalates without authorizing default routing. +A provider-only rule floor on an expanded provider binds to its `default` account row. +An absent or unknown row or unmeasured provider makes the floor unverifiable and escalates without authorizing default routing. +A known percentage below the floor makes the tool resolve among `default` profiles instead. A profile `provider` optionally names the quota-axi provider family whose rows apply to that profile; when present, profile and rule-floor provider IDs must match the strict whole-string pattern `^[a-z0-9]+(-[a-z0-9]+)*\z`. Bootstrap validates resolver-only `approval`, `floor`, and present `provider` values only while typed resolution is active; without the key those inert fields and the pre-existing verified-harness baseline preserve bootstrap behavior. Typed resolution additively recognizes `gemini` because AGENTS.md section 4 verifies it for crewmate and scout dispatch. The opted-in resolver has authoritative single-provider mappings for `claude`, `codex`, `grok`, `kimi`, `cursor`, `agy`, and `muse`; every other verified harness must declare `provider` explicitly, including multi-provider `pi`, `pi-signed`, `omp`, and `opencode` and unmapped `gemini` and `rovo`. Its single-provider table is separate from the frozen legacy mapping used by `fm-quota-choose.sh`, so additions cannot alter no-key routing. The resolver returns an actionable configuration error before any request when such a profile omits it. -A profile `floor` contains only `scope` and `min_percent`, always uses that profile's provider, and makes that one candidate ineligible below `min_percent` on the named scope. +A profile `floor` contains only `scope` and `min_percent`, always uses that profile's provider and matched account, and makes that one candidate ineligible below `min_percent` on the named scope. An absent or unknown named row also makes the candidate unrankable and is reported as an unverifiable floor, not as a known shortfall. `ultra` is native-only: the model-aware validation contract and launch mapping are owned by `bin/fm-harness.sh validate-native-effort` and `bin/fm-spawn.sh` respectively. Codex `max` is valid when the profile selects `gpt-5.6-luna`, whose installed catalog entry supports that reasoning level. @@ -534,6 +537,8 @@ Secondmate spawns, relaunches, and batch pairs reject `--resolve`. When on and at least one rule exists, the tool sends the project name and the whole brief as state and asks one Choice question whose options are every rule's `when` plus the fixed neutral option for no matching rule; the model never sees quota, catalogs, `why`, `use`, or approvals. An absent rules file, a default-only file, or `rules: []` returns the non-clear reason `no rules to match` without a model or quota request, leaving firstmate's existing routing in control; an existing but unreadable or malformed rules file, including a broken symlink, remains an actionable exit 2 configuration error. Everything after the answer runs in code: the confidence floor, the matched rule's `approval` and `floor`, each candidate's `provider` and `floor`, every applicable account-wide and model/product row from one `quota-axi --json` snapshot, and the numeric `spendPriority` argmax over candidates using each candidate's limiting row. +The [shared quota library](../bin/fm-quota-axi-lib.sh) accepts schema 5 and schema 6 and implements the [account-matching contract](../.agents/skills/quota-array-dispatch/SKILL.md#1-eligibility). +An expanded provider with no matching account row leaves the candidate eligible but unranked. Known applicable rows from a provider with partial quota semantics remain rankable; rows whose own status is not known remain unrankable. Any applicable `exhausted_now` row or known zero bound makes that candidate ineligible, and a known profile-floor shortfall does the same before unrelated quota uncertainty is considered. Missing or nonnumeric `spendPriority` evidence is never ranked, and every candidate is printed beside its evidence or the reason it was not rankable, including on ambiguous and approval-gated outcomes that emit no profile. @@ -822,6 +827,7 @@ Work routed elsewhere reports a typed terminal result with `bin/fm-public-follow When that work lives in a REMOTE secondmate home, delivery clears its bound legacy link after validating the public receipt, while retirement clears the link before closing the loop, and both clears run over that route's SSH transport. Readable remote state that proves no link exists succeeds without a write, while a present link is cleared only when its Relay request identity matches the registration and the state is writable; an identity mismatch, unreadable or unsafe state, an unavailable write or lock, an older remote copy, or a host that never confirms the clear leaves the loop retained for reconciliation. A terminal event's id is derived from its identity tuple, so a duplicate report, a retry, or a replay after restart resolves to the same event and changes nothing. +When bound work ends failed or parked, its typed failed result remains deliverable even when the promised final expected a merged pull request, so the owed reply carries the honest failure instead of remaining stranded. Work bound to a REMOTE secondmate home reports across a machine boundary, where no local path reaches the owning home. `bin/fm-public-followup.sh brief` therefore prints that worker the route's own code root and home with `--stage-in`, so the typed result is staged in `outbox/` in the home where the work actually runs rather than written to a path that only exists on the owning machine. @@ -839,7 +845,7 @@ Unreconciled terminal results ride the existing 30-second relay poll rather than The session-start digest separately prints a "Public commitments" subsection from disk when, and only when, this home is relay-active and still holds an open public loop (a reply still owed, or a delivered loop with nothing owed), so compaction and restart are non-events. `bin/fm-teardown.sh` refuses to clean up a task while this home still owes a public reply for exactly that work, unless `--force` carries explicit discard approval. `FM_PF_RETRY_BACKOFF_SECS` (default 900) sets the next-attempt time recorded with a retryable delivery error. -See [verification/public-followup.md](verification/public-followup.md) for the current maintainer evidence behind restart recovery, retained-loop disposition, and the relay-disabled zero-overhead guarantee. +See [verification/public-followup.md](verification/public-followup.md) for the current maintainer evidence behind restart recovery, failed terminal outcomes, retained-loop disposition, and the relay-disabled zero-overhead guarantee. ## Trusted external process-event adapters (config/extensions.d) @@ -913,6 +919,7 @@ Never run the registered blocking source command directly in a conversational tu A long-polling external process is registered as a *source* through its adapter, whose header and `--help` own the commands and flags. `bin/fm-procevent.sh` owns the generic contract; built-in adapters retain their tracked `bin/fm-procevent-<adapter>.sh` commands, while an explicitly bound external adapter routes through the trusted host contract above. `bin/fm-procevent-lavish.sh` is the first built-in adapter and wraps only the currently published `lavish-axi poll` interface. +Before arming any Lavish source, open its artifact with `lavish-axi` so the saved session identifies the board's server; each poll attempt derives its host and port from that session and refuses missing or invalid session evidence before consuming a staged worker reply. That adapter, and only that adapter, retries the one exact transient response a cut-short listener returns while its marks remain available (`error: Lavish Editor poll response was interrupted` with `code: SERVER_ERROR`), up to 12 times with poll starts at least 5 seconds apart, so an internal retry never reaches the runner as a captured result. This start-to-start governor is a no-op after a normally blocking poll but caps an immediately returning poll under the shipped defaults independently of the owner lease and registration launch pacing. Real feedback, ended and missing sessions, any other `SERVER_ERROR`, and that same interruption still standing once the bound is spent are all captured and announced normally; `FM_LAVISH_POLL_RETRY_DELAY` is a bounded 1 to 60 second test override for the interval only, and the runner itself stays adapter-agnostic. @@ -921,7 +928,7 @@ An already-armed Lavish source keeps its registered listener command until it is ### Crew-hosted Lavish review boards A live task that hosts a Lavish board owns its listener, so firstmate must never arm that board. -The worker arms it with `bin/fm-procevent-lavish.sh arm <artifact.html> --for <task-id>` and never runs `lavish-axi poll` itself. +After opening the artifact as required above, the worker arms it with `bin/fm-procevent-lavish.sh arm <artifact.html> --for <task-id>` and never runs `lavish-axi poll` itself. The arm is refused unless that task id has valid, identity-matching endpoint metadata, because a board whose owner has no endpoint would collect feedback nobody can be told about. The registration persists as one task-owned source record, while each captured nonterminal round remains open until the worker re-arms and the existing handled marker acknowledges that round. Re-arm is that acknowledgement and nothing else: the board is armed once while no record exists, and a further arm by the same owner is refused unless an unacknowledged nonterminal round is waiting, so a generation already carrying a reply is never replaced before its listener posts it. @@ -1218,7 +1225,7 @@ FM_CLASSIFY_PAUSED_VERB=paused # leading status verb for a declared external FM_STALE_ESCALATE_SECS=240 # idle seconds before a provably-working stale pane escalates, unless that pane's own worker declared a wait that has not elapsed, or, where config/wedge-defer-parked-gate arms it, that pane's crew is parked at a validation gate awaiting the supervisor's decision on it that the crew raised under that run's key and nobody has answered yet, either of which takes the FM_PAUSE_RESURFACE_SECS recheck below instead; stale panes whose crew is not provably working surface immediately unless admitted directly to the declared-wait cadence, while a live idle declared wait still surfaces once before that cadence bounds repeats; at that same escalation moment a recovery-grade agent-state probe (docs/architecture.md owns that dead-record contract) reports a pane whose endpoint is proven `dead` or `missing` once and stops re-escalating it while it stays that way FM_BUSY_TURN_MAX_SECS=3600 # maximum age without a completed turn or explicit native-harness progress (bin/fm-watch.sh owns marker selection), before the same wedge escalation used for a provably-working non-busy stale takes over; inspection-only, never an automatic interrupt or restart; a declared external wait, an attended verified captain-held transfer, or - where config/wedge-defer-parked-gate arms it - a validation gate of the crew's own awaiting the supervisor's still-unanswered decision takes the FM_PAUSE_RESURFACE_SECS recheck below instead FM_PAUSE_RESURFACE_SECS=14400 # four hours between bounded rechecks of a declared external wait or verified captain-held transfer, and between repeated new-hash stale alarms for an ordinary crew task with an open backlog captain call; a structured until time can make an external-wait recheck occur sooner but cannot extend this bound; this includes a live idle pane after its first inconclusive stale wake, a provably-working pane whose own unelapsed declared wait or, where config/wedge-defer-parked-gate arms it, unanswered supervisor-owed validation gate defers its FM_STALE_ESCALATE_SECS escalation, and a live busy pane past FM_BUSY_TURN_MAX_SECS, while the away-mode daemon uses the same setting and ages its window against the crew's own latest status line rather than pane busy state; a captain-held transfer is never rechecked while the away-posture record exists, while an armed validation gate awaiting the supervisor's decision keeps this recheck in either posture -FM_SECONDMATE_WAKE_STALL_SECS=180 # minimum interval with no change of the oldest actionable foreign wake-queue row (it advances as the mate drains, and a queue reprovisioned under the same task id starts a fresh interval at whatever sequence it restarts) before an endpoint-recorded local secondmate produces one durable parent wake-loop-stall notification for that no-progress episode; a mate that is provably inside an active turn (an exact busy verdict) does not escalate until that same no-progress interval reaches FM_BUSY_TURN_MAX_SECS above, declared external-wait pause rows are excluded, and zero or invalid values use 180 +FM_SECONDMATE_WAKE_STALL_SECS=180 # minimum interval with no change of the oldest actionable foreign wake-queue row (it advances as the mate drains, and a queue reprovisioned under the same task id starts a fresh interval at whatever sequence it restarts) before an endpoint-recorded local secondmate produces one durable parent wake-loop-stall notification for that no-progress episode; a mate that is provably inside an active turn (an exact busy verdict) does not escalate until that same no-progress interval reaches FM_BUSY_TURN_MAX_SECS above; a mate whose busy class is exactly idle, whose agent is alive, and whose composer is not pending is rung once so its own home can drain, and the parent notification is withheld until that same row stays frozen for another stall interval; unknown or ring-unsafe panes keep the parent alarm; declared external-wait pause rows are excluded, and zero or invalid values use 180 FM_WEDGE_DEMAND_INSPECT_COUNT=3 # consecutive provably-working stale escalations on the same unchanged pane before demand-deep-inspection is added FM_WORKTREE_WRITE_PRUNE='.git node_modules .venv venv __pycache__ .mypy_cache .pytest_cache .ruff_cache .tox target dist build .next .cache vendor' # directory names the wedge detector's task-worktree write probe skips; the default keeps .git out so a supervisor's own read-only git command can never look like crew progress; set it to the empty string to prune nothing, which widens the probe to the whole depth-bounded tree rather than disabling it FM_WORKTREE_WRITE_MAXDEPTH=6 # depth that same probe walks below the recorded worktree; it runs only at the moment a wedge escalation would otherwise fire, never on every poll; no probe knob applies to a secondmate, whose recorded worktree is a provisioned home the probe skips entirely diff --git a/docs/pi-supervision-branch.md b/docs/pi-supervision-branch.md index b45ab7ea493..a0d3caffd2b 100644 --- a/docs/pi-supervision-branch.md +++ b/docs/pi-supervision-branch.md @@ -153,7 +153,7 @@ No caching machinery beyond this exists, deliberately: any later dynamic content ## Postures -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. +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` in the same turn as `/afk` 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. @@ -169,7 +169,7 @@ While the record exists: 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. + `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 complete, readable, live record; an archived, incomplete, or invalid record restores the attended refusal byte for byte. The captain's away words are the whole mandate: the branch reads them at the tail, decides by its own judgment whether the event in front of it is the moment they name, acts on them only through the guarded scripts, never by analogy, and holds with verdict captain on doubt; `bin/fm-branch-prompt.sh` "Postures" owns those execution rules and requires every action taken under the words to open its outcome summary with "per your away instructions:". Each relocated script keeps its own gate, enforcing exactly what a script can check without reading words: `bin/fm-pr-merge.sh` merges any pull request 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 and which pull request the words meant is the branch's reading; `bin/fm-spawn.sh` dispatches only queued work whose blockers cleared - already queued, or filed by the branch because the words explicitly call for it - 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 the words pre-answer, or one `ask-user-authority`'s judgment (carried verbatim in the branch prompt) lets firstmate decide; `bin/fm-merge-local.sh` is never relocated. The merge-authority record and the outcome row's summary are the audit trail, and the return brief renders the words verbatim beside that account. @@ -177,11 +177,13 @@ While the record exists: 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 whatever the words say, and no relocation survives the return, because an archived record validates as absent and the words die with it. +The ordinary cleanup of a task whose pull request has landed needs no relocation because it is the branch's own job in both postures: `bin/fm-branch-prompt.sh` names the `check: merge landed:` wake, and any later stale or inactive-outcome row on that task, as the moment to attempt `bin/fm-teardown.sh` without `--force` and report any refusal instead of concluding there is "nothing to recover". ## Verification Portable regressions: `tests/fm-pi-branch-extension.test.sh` covers dispatch, signal and stale report scoping with unscoped heartbeat reports, the new branch conversation at every main session start with continuation inside one session, the mirror re-anchor that pairs with it, requested-versus-unsolicited delivery, exact visible entry content, no unkeyed model turn, the sequence-keyed processing request and its acknowledgement, re-presentation after an empty reply and after an unrelated prior answer, the triggered-then-next-turn pacing, session-start re-presentation, routine outcomes staying turn-free, the processed-marker migration, idle and busy main state, incident-shaped compaction and unrelated-assistant context, cold-start post-lock recovery, crash-before-cursor reload recovery, repeated-reload idempotency, mirroring, post-construction provider-error and no-report fallback, the consecutive-error latch, cooldown probe, exponential backoff, report-plus-settlement recovery, report-before-error re-latch, cache key, model and effort selection, and (in `test_branch_dispatch_classifies_main_only_rows_and_writes_the_eligible_snapshot`) decision-owned signal and stale rows' exclusion from `eligibleSeqs`, their presence in `needsDecisionKeys`, task alias resolution, reserved-key configuration, status-log race and symlink refusal, non-vetoing behavior for unrelated eligible rows, and decision-only queues reading as ordinary main-only absence. -`tests/fm-branch-supervision.test.sh` covers prompt stability, store append-only behavior, the captain cursor barrier, the processed marker's sequence bounds, leases, guards, non-branch-home invariance, and the away relocation (only under a confirmed live record, never for local-only landing, 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-branch-supervision.test.sh` covers prompt stability, including the landed-work cleanup instruction, 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 valid 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-afk-return.test.sh` covers the ordered cleanup-due section, its durable merge-marker requirement, and exclusion of a done task without durable merge evidence. `tests/fm-pr-merge.test.sh` covers the branch actor merging a green task under the record, being refused on a red check or `--allow-red` under it, and being refused at the partition while attended; `tests/fm-send-resolve-key.test.sh` covers the decision-answer partition (a needs-decision or captain-held key refuses the attended branch before anything is sent, a `blocked:` key stays ordinary steering, and the record relocates the answer). `tests/fm-pi-watch-extension.test.sh` covers the away eligibility collapse (check-kind and decision-owned triggers offered) with the broken-queue vetoes and the watcher-failure alarm still reaching main, and `tests/fm-pi-branch-extension.test.sh` covers the posture tail with the verbatim read-back, the unscoped claim of check and heartbeat rows, no processing turn under the record, cancellation of a request pending when the record appears, and the re-presentation at the first run boundary after archive. `tests/fm-wake-drain-outcome-backstop.test.sh` covers keyless resurfacing, causal suppression, same-second ordering, one-shot presentation, first-drain index self-healing under the outcome lock, store-fault fail-closed behavior, bounded history cost and output, and the oversized-line limit. diff --git a/docs/scripts.md b/docs/scripts.md index 1ff6f206419..00e95210ec6 100644 --- a/docs/scripts.md +++ b/docs/scripts.md @@ -33,7 +33,7 @@ The shared no-mistakes gate refusal for fleet lifecycle entrypoints is summarize | `fm-captain-hold.sh` | Hold tasks for the captain, record the captain's answers, gate investigation completion, and report record divergence between the status log and the backlog | | `fm-decision-hold.sh` | One-release compatibility shim mapping the retired decision commands onto fm-captain-hold.sh | | `fm-brief.sh` | Scaffold ship (explicit `--mode`), scout, secondmate-charter, and Herdr-lab briefs, with Captain's intent and Firstmate spec subsections on ship/scout | -| [`fm-dod-lib.sh`](../bin/fm-dod-lib.sh) | Own ship/scout worker role scope, ship definitions of done, and the no-mistakes `--intent` contract | +| [`fm-dod-lib.sh`](../bin/fm-dod-lib.sh) | Own ship/scout worker role scope, ship definitions of done, the named-head reachability gate on ship `done:` acceptance, and the no-mistakes `--intent` contract | | `fm-herdr-lab.sh` | Provision and guardedly operate an isolated, never-default Herdr lab session | | `fm-herdr-lab-viewer.py` | The pty engine behind `fm-herdr-lab.sh viewer`: one real foreground Herdr client on a non-zero window grid | | `fm-install-herdr.sh` | Install CI's exact-version Herdr pin with official asset URL, SHA-256, and protocol checks | @@ -89,7 +89,7 @@ The shared no-mistakes gate refusal for fleet lifecycle entrypoints is summarize | `fm-inactive-reconcile.sh` | Reconcile long-inactive direct crewmate terminal outcomes without forge access | | `fm-afk-contract.sh` | Own the away-posture record: schema, the captain's away words verbatim, read-back, entry announcement, archive, and cross-subsystem authority lock | | `fm-afk-start.sh` | Run the common sourceable away-mode daemon entry in the foreground | -| `fm-afk-launch.sh` | Own away-mode entry (read-back, confirm, record), exit, rollback, and any backend terminal lifecycle | +| `fm-afk-launch.sh` | Own away-mode entry (same-turn record write, then read-back), exit, rollback, and any backend terminal lifecycle | | `fm-afk-return.sh` | Own deterministic return shutdown, the return brief, catch-up evidence, and the firstmate-actionable blocker gate | | `fm-supervisor-target-lib.sh` | Resolve the shared supervisor target and backend for the daemon and launcher | | `fm-supervise-daemon.sh` | Presence-gated away-mode sub-supervisor: self-handle routine wakes, guard injection by the detected primary harness, escalate batched digests, alert on failed delivery | @@ -111,7 +111,7 @@ The shared no-mistakes gate refusal for fleet lifecycle entrypoints is summarize | `fm-wake-drain.sh` | Present and acknowledge the current actor's claimed wake rows alongside status, outcome-backstop, decision, divergence, recovery, and supervision checks | | `fm-wake-grant.sh` | Serialize Pi supervision-branch wake-row claim activation, publication, release, and deactivation | | `fm-wake-lib.sh` | Shared durable wake queue, recovery generations, portable locks, and watcher identity/health helpers | -| `fm-classify-lib.sh` | Shared wake classification, durable keyed-decision folds and scans, unread status selection, and bounded latest-event snapshots | +| `fm-classify-lib.sh` | Shared wake classification, durable keyed-decision folds and scans, unread status selection, home-owned status-append ranges, and bounded latest-event snapshots | | `fm-send.sh` | Steer a task via a durable inbox record plus doorbell, or send a supported key or typed harness invocation through the recorded backend | | `fm-branch-prompt.sh` | Emit the Pi supervision branch's byte-stable system prompt ([pi-supervision-branch.md](pi-supervision-branch.md)) | | `fm-branch-outcome.sh` | Own the supervision branch's append-only outcome store, cursors, bounded status-coverage indexes, and session-start replay | @@ -130,7 +130,7 @@ The shared no-mistakes gate refusal for fleet lifecycle entrypoints is summarize | `fm-pr-lib.sh` | Own canonical task and PR validation plus private atomic PR-poll publication, merge-notification identity, and retirement | | `fm-pr-poll.sh` | Provide the byte-static watcher program for validated PR/MR-poll sidecars | | `fm-contributions.sh` | Observe owned publications, retain exact-head judgments, measure required actors, and wake on maintainer signals | -| `fm-pr-check.sh` | Record validated `pr=` and `pr_head=` values, then atomically arm a static merge poll | +| `fm-pr-check.sh` | Record validated `pr=` and `pr_head=` values, then atomically arm a static merge poll; refuses a GitHub draft | | `fm-pr-merge.sh` | Record PR metadata, merge a task's canonical full GitHub or GitLab URL, then refuse an outcome it cannot prove landed or queued | | `fm-pr-state.sh` | Read-only: print one line per GitHub pull-request blocker it can see, reporting on checks that have reported rather than verdicting merge-readiness | | `fm-pr-reviewers.sh` | Read-only: suggest reviewers from GitHub's own author mapping of recent commits on a pull request's changed files, never requesting one | diff --git a/docs/secondmate-parent-channel.md b/docs/secondmate-parent-channel.md index e99e037e668..b5fe46a8686 100644 --- a/docs/secondmate-parent-channel.md +++ b/docs/secondmate-parent-channel.md @@ -32,7 +32,7 @@ Every captain-facing outcome that leaves durable evidence in the mate home is pu | Answer to a marked request | a correlated line guarded by the pending-reply record | `bin/fm-secondmate-report.sh`, which resolves the parent channel from the mate home; the pending-reply guard repairs a line stranded in the local mate's same-basename status file before recovery or escalation | | An outcome that exists only in the mate's reasoning | none | the charter and the `AGENTS.md` carve-outs only | -The ledger delivery reads files only: it calls no harness, no forge, and no current-state reader, so it is identical for every harness and runtime backend. +The ledger delivery reads files, plus a local git reachability check on a ship `done:` with no delivery record yet (`bin/fm-dod-lib.sh`): it calls no harness, no forge, and no current-state reader, so it is identical for every harness and runtime backend. Each delivery is keyed with the first eight hexadecimal characters of its receipt fingerprint and uses the shared append contract above, and the ledger path reuses the inactive scan's per-fingerprint receipts, so a replayed poll or restart cannot deliver an event twice while a genuinely new terminal event is delivered again. A duplicate line is harmless and a missed one is not, so the mate may still append its own judgement about a delivered outcome, and the parent reads the script's line as the fact and the mate's line as commentary. For marked replies, the report helper accepts no caller-selected destination and uses the channel resolver for both local and remote homes; its script header owns the exact invocation contract. @@ -49,7 +49,7 @@ A missed-reply escalation includes the complete first sighting path and line num ## Regression coverage -`tests/fm-inactive-reconcile.test.sh` covers the ledger delivery against real ledgers with no harness: immediate done and failed delivery with note, PR, mode, posture, and report pointer, once-only delivery across polls, a line still being appended, the remote route, the yield of the inactive path to a terminal ledger, and the real watcher poll driving it. +`tests/fm-inactive-reconcile.test.sh` covers the ledger delivery against real ledgers with no harness: immediate done and failed delivery with note, PR, mode, posture, and report pointer, once-only delivery across polls, a ship `done:` withheld while its named head exists only in the worker copy, a pending one still delivered after teardown removes that copy, a line still being appended, the remote route, the yield of the inactive path to a terminal ledger, and the real watcher poll driving it. `tests/fm-captain-hold-lifecycle.test.sh` covers a mate home publishing a hold, its answer, and a distinct occurrence on re-hold, and a main home publishing nothing. `tests/fm-pr-merge.test.sh` covers the PR-ready line at registration and the merge outcome's upward report. `tests/fm-teardown.test.sh` covers teardown delivering a child's final line and refusing when the channel cannot be written. diff --git a/docs/tmux-backend.md b/docs/tmux-backend.md index da4ddb523ee..bd917644e4d 100644 --- a/docs/tmux-backend.md +++ b/docs/tmux-backend.md @@ -80,7 +80,7 @@ A bare shell prompt is `unknown`, so away-mode escalation is never injected into Busy state is not read from rendered text on this backend. A task's busy, idle, unknown, or dead verdict comes from the semantic busy-state contract owned by `bin/fm-busy-lib.sh`; [architecture](architecture.md#busy-state-is-semantic-per-adapter) owns its boundaries. -The one remaining rendered-tail reader is Grok's isolated fallback inside that contract, which can only classify a Grok task. +The isolated rendered-tail busy fallbacks that remain are harness-scoped, so one adapter's output can never classify another's task. The submit acknowledgement and away-mode supervisor-pane busy guard below still consult rendered output, but only to decide whether input can be delivered, never to decide recorded task state. The supervisor guard selects only the detected primary harness's signature rather than a global union of vendor patterns. diff --git a/docs/turnend-guard.md b/docs/turnend-guard.md index c932eacf6ac..f4715f1db0d 100644 --- a/docs/turnend-guard.md +++ b/docs/turnend-guard.md @@ -46,7 +46,7 @@ Without that proof a stale or absent beacon is a genuine lapse and alarms. Under the extension model (Pi, pi-signed, and omp) a live identity-matched watcher is the ordinary healthy state, but a genuinely unheld lock with a beacon fresh within grace is also healthy while a live Pi or omp session provably owns continuity, because `.pi/extensions/fm-primary-pi-watch.ts` and `.omp/extensions/fm-primary-omp-watch.ts` tear the watcher down on every actionable wake and spawn the replacement themselves. A lock is genuinely unheld only when the lock directory or its symlinked owner directory is absent, or when the existing lock records no pid at all. Any lock with a recorded pid remains down when its pid, home, watcher path, or process identity fails the strict watcher health check. -That ownership proof is `fm_extension_owns_supervision` in `bin/fm-wake-lib.sh`, which accepts either the Pi pair (`fm_pi_extension_owns_supervision`) or the omp pair (`fm_omp_extension_owns_supervision`): both primary extensions of one family must be recorded in their state markers at their current on-disk builds by the process named in `state/.lock`, and that process must still be alive; omp never inherits the Pi tolerance because its proof is keyed on its own two files and markers. +That ownership proof is `fm_extension_owns_supervision` in `bin/fm-wake-lib.sh`, which accepts either the Pi pair (`fm_pi_extension_owns_supervision`) or the omp pair (`fm_omp_extension_owns_supervision`): both primary extensions of one family must be recorded in their state markers at their current on-disk builds by the process named in `state/.lock`, and that process must still be alive; Pi's watcher marker must additionally name an active generation rather than a retiring handoff, while omp never inherits the Pi tolerance because its proof is keyed on its own two files and markers. Requiring the turn-end guard extension as well as the watch extension is deliberate, because a home without that structural backstop has no benign hand-off to tolerate. Without that proof an unheld lock alarms exactly as it did before, so an unloaded, version-drifted, or exited Pi or omp session is loud immediately, and a cycle the extension never restores is loud once the beacon passes grace. Under every persistent-watcher harness a live identity-matched watcher with a fresh beacon is still required, so the pull guard keeps the same strict semantics there. @@ -203,4 +203,4 @@ It also covers true-reason banner wording and reason-keyed episode dedup survivi `tests/fm-supervision-instructions.test.sh` covers recovery-line ownership and pi-signed's identity-preserving reuse of Pi's protocol. `FM_PI_LIVE_E2E=1 tests/fm-pi-primary-live-e2e.test.sh` is the opt-in isolated Pi path. `tests/fm-omp-harness.test.sh` covers the omp extension pair over a fake omp API (forced continuation on exit 2, the `stop_hook_active` bound, the seatbelt block, the ownership proof), and `FM_OMP_LIVE_E2E=1 tests/fm-omp-primary-live-e2e.test.sh` is the opt-in isolated omp path. -[`verification/supervision.md`](verification/supervision.md#turn-end-guard) records the active cross-harness empirical evidence, including the 2026-07-24 Claude `asyncRewake` revalidation. +[`verification/supervision.md`](verification/supervision.md#turn-end-guard) records the active cross-harness empirical evidence, including the current Claude `asyncRewake` revalidation. diff --git a/docs/verification/dispatch-auth.md b/docs/verification/dispatch-auth.md index 57772f113f7..fa75e1c2bf6 100644 --- a/docs/verification/dispatch-auth.md +++ b/docs/verification/dispatch-auth.md @@ -7,13 +7,13 @@ It records only facts that must be re-established when a producer or vendor vers Task chronology, incident transcripts, and credential metadata stay in private reports or PR evidence. Firstmate resolves a candidate's provider family, credential surface, and applicable quota by reading the evidence below and reasoning in the open. -No script maps a model to a provider, a provider to a credential store, or a name prefix to a family, so the facts here are what that reasoning rests on. +The [worker helper](../../bin/fm-quota-choose.sh) and [typed resolver](../configuration.md#typed-dispatch-resolution-env-typesafe_api_key) document their deterministic mapping boundaries; the [eligibility procedure](../../.agents/skills/quota-array-dispatch/SKILL.md#1-eligibility) owns the remaining catalog and credential judgments. Credential paths below are shown with the home directory replaced by `<home>`. ## Quota granularity the judgment depends on Verified 2026-07-30 against quota-axi 0.1.16 for the provider and model-scope relationships below. -That release's captured default output included `quotaSemantics.description`; the current default TOON and JSON fallback field placement are verified against 0.1.29 in the next section. +That release's captured default output included `quotaSemantics.description`; the schema-5 default TOON and JSON fallback field placement are verified against 0.1.29 in the next section. Current dispatch reads the TOON scope and `limitedBy` fields; the JSON fallback's corresponding `scope` and `boundedBy` fields preserve the same provider/model applicability without relying on the `--full`-only description. ```json @@ -31,9 +31,9 @@ Current dispatch reads the TOON scope and `limitedBy` fields; the JSON fallback' } ``` -Three properties follow and are load-bearing for dispatch: +The [eligibility procedure](../../.agents/skills/quota-array-dispatch/SKILL.md#1-eligibility) owns account and scope applicability; this capture illustrates those scope bounds: -- An `all_models` (or `all_products`) scope is real evidence for every model in that provider family, including a model with no window of its own. +- The captured Codex account reports an `all_models` bound of 64% even for models without their own window. - A `model:`-scoped entry is an additional bound for that one model. `model:codex_bengalfox` is the GPT-5.3-Codex-Spark window and bounds nothing else. - A named-model window can be tighter than the account bound, so it must not be read across models. In the same snapshot Claude reported `all_models` with `effectivePercentRemaining` 10 while `model:fable` reported 4, limited by the `model:fable` window itself. A non-Fable Claude model reads 10, not 4. @@ -109,7 +109,7 @@ This live snapshot was all `through_reset`, so finite-runway fields were omitted There is no `projectionBasis` field; its absence means `cycle_average`. `runway` and `selection` are nested under each effective-availability scope, so the same provider/model applicability rules govern headroom, runway, and `spendPriority`. Projection confidence is not present on every known runway, so selection must preserve that absence as uncertainty rather than fabricate it. -The older-schema fallback contract is owned by `quota-array-dispatch`; this evidence does not reinterpret an absent runway, pace, or selection field. +The schema compatibility and account-matching contract is owned by [`quota-array-dispatch`](../../.agents/skills/quota-array-dispatch/SKILL.md#1-eligibility); this schema-5 evidence does not reinterpret an absent runway, pace, or selection field. ## Provider-family counterfactual that this producer schema supports @@ -125,7 +125,7 @@ openai-codex gpt-5.6-terra 272K 128K yes yes ``` The Pi catalog is authoritative for Pi model support and reports the provider family in its own column. -For `harness=pi`, `model=openai-codex/gpt-5.6-terra` the catalog establishes the model is supported and belongs to the `openai-codex` family, and the Codex `all_models` scope above supplies fresh, known 64 effective remaining for every model in that family. +In this capture, the catalog lists `openai-codex/gpt-5.6-terra`, and the Codex row above reports 64% remaining at `all_models`. No Terra-specific window exists in the snapshot, and `quota-axi auth --json` lists no `pi:openai-codex` source. Both absences are missing model-level and source-level detail, not contradictory evidence, so this candidate is dispatchable with the model-level uncertainty disclosed. @@ -165,7 +165,9 @@ Verified 2026-07-30 against quota-axi 0.1.16. Observed source statuses are `available`, `expired` (with an `error` slug), and `missing`. - A provider can carry a healthy source beside a missing or expired one, so a provider must not be collapsed to a single status. Claude's `oauth-file` is missing while its keychain source is available, and Kimi's standalone CLI credential is expired while its Pi source is available. -- A `pi:`-prefixed source exists only where Pi holds its own credential for that family (`pi:xai`, `pi:kimi-coding`). Pi's `openai-codex` family has none, because it authenticates through the Codex store that the `codex` provider already lists. A missing `pi:` source is therefore never evidence against a Pi candidate. +- In this captured setup, only `pi:xai` and `pi:kimi-coding` have `pi:`-prefixed sources. + The Pi `openai-codex` candidate used the Codex store listed above; this observation does not establish the credential source for another account or setup. + The [eligibility procedure](../../.agents/skills/quota-array-dispatch/SKILL.md#1-eligibility) owns how missing authentication evidence affects dispatch. Neither this per-source shape nor `state.authStatus` exists before quota-axi 0.1.16. `bin/fm-bootstrap.sh` enforces the current compatibility floor through `bin/fm-quota-axi-lib.sh`. @@ -201,4 +203,5 @@ It asserts that the script accepts no harness, model, or provider input, never c `tests/fm-bootstrap.test.sh` owns the quota-axi version-floor diagnostic. `tests/fm-quota-array-dispatch-live-e2e.test.sh` drives the public Pi skill-loading interface against one fake schema-5 snapshot per case, served as quota-axi's default TOON. It covers TOON-first `spendPriority` ranking among candidates that pass eligibility, reasoning-class, and runway-feasibility gates, explicit accounting for unmeasurable runway, the strongest-reasoning constraint, and the runway feasibility floor over a higher `spendPriority`. +`tests/fm-dispatch-resolve.test.sh`, `tests/fm-quota-choose.test.sh`, and `tests/fm-procevent-quota.test.sh` cover schema-6 account-row binding, account separation, and schema-5 compatibility through the public script interfaces. The skill's primary path is that default TOON; `--json` is the documented defensive fallback, and this section records the producer `--json` shape that fallback consumes. diff --git a/docs/verification/dispatch-resolve.md b/docs/verification/dispatch-resolve.md index f47fcc61421..9f85536d8c4 100644 --- a/docs/verification/dispatch-resolve.md +++ b/docs/verification/dispatch-resolve.md @@ -62,7 +62,7 @@ It proves absent, default-only, and empty-rules files return `no rules to match` It proves the documented starter configuration resolves its Pi default through the declared Claude provider, a `.env` key turns the tool on, and the environment wins over it. It proves the key is absent from child environments, never appears on `curl` argv, and arrives only as the bearer header on the descriptor. It proves the request uses the fixed endpoint and model, carries only the project, brief, and rule Choice with one option per rule plus the fixed neutral none option, and never carries `why`, `use`, or quota. -It proves the clear, fixed-floor ambiguous with candidate evidence, escalate (approval with candidate evidence, unverifiable rule floor, tie, nothing rankable), known rule-floor fall-through, known and unverifiable profile-floor evidence, explicit-provider and provider-ID enforcement, authoritative Agy and explicit-provider Gemini routing, partial providers, eligible unranked candidates and their clear-result note, concrete quota vetoes and profile-floor shortfalls taking precedence over uncertainty, account-wide quota veto, limiting-bound ranking, missing-curl and quota-axi failures, HTTP 429 and 500, transport failure, malformed usage, zero-mass or malformed probabilities or confidence, malformed or duplicate profile, invalid selector, removed-option rejection, and out-of-range rule ID paths behave as the contract states, with configuration errors exiting 2 before any network call. +It proves the clear, fixed-floor ambiguous with candidate evidence, escalate (approval with candidate evidence, unverifiable rule floor, tie, nothing rankable), known rule-floor fall-through, known and unverifiable profile-floor evidence, explicit-provider and provider-ID enforcement, authoritative Agy and explicit-provider Gemini routing, partial providers, eligible unranked candidates and their clear-result note, concrete quota vetoes and profile-floor shortfalls taking precedence over uncertainty, account-wide quota veto, limiting-bound ranking, schema-6 account-row binding with schema-5 compatibility, missing-curl and quota-axi failures, HTTP 429 and 500, transport failure, malformed usage, zero-mass or malformed probabilities or confidence, malformed or duplicate profile, invalid selector, removed-option rejection, and out-of-range rule ID paths behave as the contract states, with configuration errors exiting 2 before any network call. `tests/fm-bootstrap.test.sh` proves bootstrap ignores resolver-only fields without the typed key, validates each malformed shape when the environment or home `.env` activates typed resolution, and prevents an environment-provided key from reaching child processes. ```console diff --git a/docs/verification/process-event-sources.md b/docs/verification/process-event-sources.md index 392d1f7ab0c..8abe4a71a06 100644 --- a/docs/verification/process-event-sources.md +++ b/docs/verification/process-event-sources.md @@ -35,7 +35,8 @@ code: VALIDATION_ERROR # exit 2 Exit 2 with `VALIDATION_ERROR` is positive proof the subcommand does not exist, because the word is parsed as a filename. Note that `lavish-axi <anything> --help` exits 0 for any argument, including a nonsense subcommand, so a `--help` exit code can never be used as a capability probe. -The adapter depends on none of this: it uses only the published poll shape above. +The adapter requires none of those extra commands or endpoints: delivery uses the published poll shape above. +Its separate routing lookup reads the board's saved Lavish session; the adapter header owns that contract. ## Why an ended Lavish review is terminal @@ -103,7 +104,7 @@ Exercised by `tests/fm-procevent.test.sh` against a fake blocking source whose c | adapter-owned silence verdict | an ordinary firstmate-owned Lavish source driven against a stand-in poll that returns an empty ended session captures its result, records it durably handled, appends no wake, and stays silent through a later `reconcile` that would otherwise republish it, while still retiring its ended source; the same real path with a `Send & End` response carrying the captain's choice still publishes its `check` wake and is left unacknowledged for the handler | | worker-owned Lavish rounds | one three-round fixture arms a board for an identity-matched task endpoint, delivers nonterminal and terminal captures directly to that task's steering inbox without a firstmate `check` wake, acknowledges each nonterminal round through a successful re-arm, redelivers an inbox note filed before acknowledgement, refuses a second armer and every early retirement, and concludes the terminal round through `handled` without another poll; focused fixtures also pin failed re-arm rollback, generation-specific reply staging, one reply post across transient poll retries, unreachable-owner refusal, interrupted conclusion recovery, and repeat acknowledgement isolation | | Lavish handled-status classification | an executable fixture table pins exact `feedback`, `ended`, `waiting`, and `browser_disconnected` mappings, including `browser_disconnected` to `disconnected`; the same suite proves that status is nonterminal and receives a zero-answer silence verdict | -| configured Lavish host convergence | the adapter reads `config/lavish-axi-host` before a poll, restores its original set or unset ambient value when the file disappears before a retry, and refuses an uninspectable path before calling `lavish-axi`; spawn coverage proves a configured address enters the worker launch while an absent file leaves the destination environment unchanged | +| session-derived Lavish routing | the three-round worker fixture starts its first listener under conflicting ambient host/port values and configuration, then recovers later listeners while that conflicting configuration remains, and proves every reply/poll uses the board's saved session endpoint; direct polls cover Unicode artifact paths, hostnames, IPv6, session endpoint changes, quiet retries, and refusal before reply consumption when session evidence is absent or invalid; spawn coverage still proves the configured opening address enters the worker launch | | silence fails closed | the adapter's published `silent` command suppresses only an `ended` session with no queued content block or a `browser_disconnected` response, and announces a real answer, freeform prose, any recognized content block regardless of its declared count, a malformed top-level content header, a `waiting` or `missing` session, a server error, an unreadable result, and indented payload text imitating an empty content block; the `remote-reply` and `when` adapters, which implement no `silent` command, announce every result | | terminal retirement preserves the result | the retired source's captured output, its announced event, its handled acknowledgement, and later explicit `retire` all still behave normally | | registration-generation retirement | an old terminal runner preserves a concurrently replaced registration and releases ownership so the replacement runs independently; injected registration-removal failure retains a terminal claim, performs no second poll, and completes idempotently once removal recovers; a live owner retiring its own terminal source mid-capture tolerates only its transient reservation-removal failure and still removes the registration under exact ownership | @@ -226,6 +227,7 @@ Without this launcher, reconcile would silently fail to start a runner on macOS The generic runner and external-adapter path remain domain-neutral and create no endpoint, task metadata, or backlog item, so they affect supported primary harnesses and runtime backends only through the existing `check` and status-signal wake paths they already consume. The built-in task-owned Lavish exception validates existing task endpoint metadata and uses the existing steering-inbox backend doorbell to deliver a capture directly to that worker; it creates no new endpoint or backend protocol. +Session-derived routing happens only inside the shared Lavish poll adapter, so it changes no harness or session-provider launch, registration, steering, or lifecycle interface. Built-in adapters extend the runner through `bin/fm-procevent-<adapter>.sh`; the `when` adapter also uses the runner library's locked registration publisher so its private trust state and source registration are serialized under one source boundary. Explicit external adapters instead use the single-capability contract in [`docs/extension-bindings.md`](../extension-bindings.md), with no filename discovery or package-supplied argv. An adapter's `terminal` command is optional and defaults to keeping the source armed. diff --git a/docs/verification/public-followup.md b/docs/verification/public-followup.md index 64ee1efd903..a63f56b7634 100644 --- a/docs/verification/public-followup.md +++ b/docs/verification/public-followup.md @@ -2,7 +2,7 @@ Audience: maintainer verification. -This record supports six active guarantees for promised public replies made through the myfirstmate relay: +This record supports seven active guarantees for promised public replies made through the myfirstmate relay: 1. A promised final reply survives compaction and restart, reconciles from disk alone, and lands in the original thread exactly once. 2. A home that never opted into the relay pays nothing for any of it. @@ -10,6 +10,7 @@ This record supports six active guarantees for promised public replies made thro 4. A first registration with no registry lock already held succeeds under stock macOS Bash 3.2 with `set -u`. 5. A public loop whose work lives in a REMOTE secondmate home retires when readable remote state proves no link exists, or after readable and writable remote state clears the matching bound legacy Relay link; unreadable state, a non-writable matching link, an identity mismatch, a metadata lock it cannot acquire within its bound, or unconfirmed completion retains the loop instead of hanging, and `--force` still covers only the unresolved obligation. 6. Work bound to a REMOTE secondmate home can report its typed terminal result: the instructions name paths that exist on the worker's own machine, the owning home collects results for open registrations over that route, an unreachable route fails loudly, an empty reachable route is a healthy no-op, and a non-open registration is skipped without contact. +7. Work that ends failed or parked remains deliverable when its promised final expected a merged pull request, so the original thread receives the honest failed outcome exactly once instead of retaining an undeliverable promise. [`docs/configuration.md`](../configuration.md#promised-public-replies-statepublic-followup) owns the operator-facing contract, [`docs/architecture.md`](../architecture.md#optional-relay) owns the mechanism boundary, and `tasks-axi public-followup --help` owns the typed obligation schema. Task chronology and delivery evidence stay outside this record. @@ -20,6 +21,7 @@ Recorded 2026-09-01 on Darwin 25.5.0 (arm64) with GNU bash 5.3.9, tasks-axi 0.2. The stock macOS compatibility lane additionally runs the focused first-registration regression with `/bin/bash` 3.2.57 and a real `tasks-axi` installation. The relay is a fakebin `curl` in every case, so no public post is ever made; `tasks-axi` and `jq` are the real tools, because stubbing the obligation state machine would verify nothing. The remote-route cases fake only the SSH binary at the `FM_SSH_BIN` process seam and then run the real tracked `fm-remote-entrypoint.sh` against a local checkout standing in for the remote one, so the work that has to reach the remote home actually runs there; no host and no network are involved. +The failed-result regression was refreshed separately on 2026-09-22 in the same environment with tasks-axi 0.2.6. ## Restart end-to-end and regressions @@ -106,6 +108,19 @@ ok - staging requires the matching secondmate firstmate home The restart case is the end-to-end proof of guarantee 1. It reproduces the stranded state first (work bound, no reconciled terminal result, delivery refused with "still waiting on its bound work" and zero posts), then has a secondmate-shaped child report a typed `pr-merged` result, deletes the drained inbox payload, reconciles from disk, and asserts exactly one `connector/followup` call carrying the original `request_id`, a validated `posted` receipt, and a Done obligation. +The focused tasks-axi 0.2.6 regression is the proof of guarantee 7: + +```sh +FM_TEST_ONLY=test_failed_work_on_pr_merged_promise_delivers_honest_outcome bash tests/fm-public-followup.test.sh +``` + +``` +ok - failed work on a pr-merged promise delivers its honest outcome exactly once +``` + +It binds a `pr-merged` promised final to work that reports `outcome=failed`, reconciles that accepted relation to `ready`, posts the recorded failure text once to the original request, and verifies that the obligation closes. +Parked work uses the same typed failed terminal outcome, so it follows the same state-machine path. + The dropped-baton case is the end-to-end proof of guarantee 3. It delivers a `report-ready` promised-final, asserts the registration is retained and `pending` prints `open-loop`, then shows that an unbound follow-on ship is not teardown-refused (the one-variable control still refuses the moment a commitment is registered for that work). `rechain` then binds a fresh `pr-merged` obligation onto the same request/thread, and a second follow-up carries the shipped text. diff --git a/docs/verification/runtime-backends.md b/docs/verification/runtime-backends.md index def40f57113..d3fa9c5655f 100644 --- a/docs/verification/runtime-backends.md +++ b/docs/verification/runtime-backends.md @@ -508,6 +508,92 @@ The lab home was deleted and the test entry was removed from the store and verif That automated spawn case runs against a fake claude, so it asserts the store entry and the launch command and nothing more; the live arms above are what establish that the entry actually suppresses the dialog. The composer-classification record below observes the same gate from the other side, where an untrusted worktree left Claude, Grok, and Muse unverified because the guard reads a first-launch trust dialog as an unreadable composer. +## Launch-prompt backstop signatures + +`bin/fm-busy-lib.sh`'s launch-prompt backstop (`fm_busy_launch_prompt_parked`) reclassifies a launch whose busy record is still pinned at the fm-spawn seed as `unknown launch-prompt`, rather than `busy fm-spawn`, when the captured pane matches that harness's own recognized trust, sign-in, or first-run dialog. +Each signature below was live-verified against the real installed binary through `tests/fm-launch-prompt-signals-live-e2e.test.sh` (`FM_LAUNCH_PROMPT_SIGNALS_LIVE=1`), which is what refreshes this record after an upgrade. + +An initial Pi signature sourced only from the installed binary's own UI strings ("Project trust", the internal panel-title component, never the dialog's own rendered heading) was wrong and never matched the real screen. +This guard's first live run caught that before it shipped, which is the evidence for why this class of check must be driven end to end rather than read off strings or a component name. + +Verified 2026-09-22 on Claude Code 2.1.278, pi 0.86.1, and gemini 0.60.0. + +```sh +FM_LAUNCH_PROMPT_SIGNALS_LIVE=1 bash tests/fm-launch-prompt-signals-live-e2e.test.sh +``` + +``` +# live claude version: 2.1.278 (Claude Code) +ok - claude: a real launch parked on its own rendered trust dialog surfaces through the watcher gate +# live pi version: 0.86.1 +ok - pi, pi-signed, omp: a real Pi-engine launch parked on its own rendered trust dialog surfaces through the watcher gate +# live gemini version: 0.60.0 +ok - gemini: a real launch parked on its own rendered auth or trust dialog surfaces through the watcher gate +# checked 3 launch-prompt signature(s) against real installed binaries +``` + +Claude, launched `--dangerously-skip-permissions` into a brand-new worktree under the operator's own already-onboarded config (the shape a real crewmate spawn produces): + +``` + Accessing workspace: + + /tmp/fm-launch-prompt-claude.XXXXXX/wt + + Quick safety check: Is this a project you created or one you trust? (Like your own code, a well-known open source project, or work from your team). If not, take a moment to review what's in this + folder first. + + Claude Code'll be able to read, edit, and execute files here. + + Security guide + + ❯ No, exit + Yes, I trust this folder + + Enter to confirm · Esc to cancel +``` + +Pi, launched into a fresh worktree carrying a project-local `.pi/extensions/` file (the trust-requiring resource that actually gates the dialog) under an isolated `HOME`: + +``` + Trust project folder? + /tmp/fm-launch-prompt-pi.XXXXXX/wt + + This allows pi to load .pi settings and resources, install missing project packages, and execute project extensions. + + → Trust + Trust parent folder (/tmp/fm-launch-prompt-pi.XXXXXX) + Trust (this session only) + Do not trust + Do not trust (this session only) + + ↑↓ navigate enter select escape/ctrl+c cancel +``` + +Gemini, launched `GEMINI_CLI_TRUST_WORKSPACE=true gemini -y` with no `GEMINI_API_KEY` and no prior OAuth credential: + +``` + ? Get started + + How would you like to authenticate for this project? + + ● 1. Sign in with Google + 2. Use Gemini API Key + 3. Vertex AI + + No authentication method selected. + + (Use Enter to select) + + Terms of Services and Privacy Notice for Gemini CLI + + https://geminicli.com/docs/resources/tos-privacy/ +``` + +The real pane renders this inside a bordered box, omitted here for readability; that border is exactly what proves the point below. + +That capture demonstrated why each signature function matches the FULL captured tail rather than the Grok/Rovo/AGY busy-footer convention of the last 12 non-blank lines: a bordered dialog box renders many short lines of pure border and padding (`│ ... │`) that are NOT whitespace-only, so the 12-line reduction pushed this exact heading text out of the window and silently defeated the match on the first attempt. +None of these three runs ever answered its dialog (Escape only, never Enter), so no credential store was written to and no model tokens were spent. + ## Codex hook trust Verified 2026-09-16 on codex-cli 0.151.0, macOS arm64, in a fresh linked worktree of this repository. @@ -712,7 +798,8 @@ ok - muse (Muse Code 0.2.1 (0.2.1-R1215.1)): the doorbell reached a real worker, ``` All six installed harnesses honored the doorbell contract with real model turns: each listed the inbox named by the doorbell, read its record, executed the instruction inside it, and acknowledged with the atomic `mv`. -Two findings from the run shaped the shipped behavior: an OpenCode vendor update modal swallowed the first doorbell and the single re-ring recovered it, which is exactly the watcher ladder's job; and grok 1.0.5's idle composer never classifies `empty` (a classifier drift owned by the [Composer classification matrix](#composer-classification-matrix) guard, whose refresh for grok 1.0.5 is still owed), which is why the ring's advisory pre-check skips only on an exact proven `pending` verdict - a doorbell into an ambiguous composer is a recoverable constant line, while skipping on ambiguity would starve steering for any harness the classifier cannot positively identify. +Two findings from the run shaped the shipped behavior: an OpenCode vendor update modal swallowed the first doorbell and the single re-ring recovered it, which is exactly the watcher ladder's job; and grok 1.0.5's idle composer never classifies `empty` (a classifier drift owned by the [Composer classification matrix](#composer-classification-matrix) guard, whose refresh for grok 1.0.5 is still owed), which motivated the ring's advisory pre-check not to skip on ambiguity - a doorbell into an ambiguous composer is a recoverable constant line, while skipping on ambiguity would starve steering for any harness the classifier cannot positively identify. +The current pending-composer ring contract is owned by `bin/fm-task-inbox-lib.sh`. Kimi was not installed on the verification machine; its receive path is the same one-line-plus-shell contract, and the portable ladder and enqueue regressions in `tests/fm-task-inbox.test.sh` and `tests/fm-send-inbox.test.sh` cover every harness-independent half. This guard is the refresh command after any harness upgrade; it spends a small number of real tokens per installed harness, reports an absent harness explicitly, and refuses a run that verified nothing. @@ -1568,7 +1655,8 @@ ok - real Pi/Herdr: nothing injects into the captain pane under the away posture evidence: herdr=herdr 0.9.0 pi=0.82.0 target=fm-lab-fm-afk-pi-return-37189-7133:w1:p1 archived-records=2 ``` -Observed guarantees: `fm-afk-launch.sh start` refused on the Pi primary and `confirm` recorded the posture with no daemon pid, flag, or terminal; a pending real Pi draft was left untouched with nothing submitted into the captain pane; the unmarked return request was recognized as the return, rendered the brief health first, and opened the catch-up gate on the live blocker; resolving the blocker cleared the gate, and a clean re-entry and return left exactly one archived record per away window. +Observed guarantees: `fm-afk-launch.sh start` refused on the Pi primary and the posture was recorded with no daemon pid, flag, or terminal; a pending real Pi draft was left untouched with nothing submitted into the captain pane; the unmarked return request was recognized as the return, rendered the brief health first, and opened the catch-up gate on the live blocker; resolving the blocker cleared the gate, and a clean re-entry and return left exactly one archived record per away window. +The current guard uses one `enter` call for each entry, so no separate confirmation sits between `/afk` and the durable record. The current catch-up reporting boundary is pinned by `tests/fm-afk-return.test.sh` and the same live entry point: Bearings continues through a pending return catch-up, projects its posture as an action-free warning outside Captain's Call, and drops that warning after the gate clears, while an active away window still refuses. The fixture captures submitted input through Pi's `input` extension hook, so the lab agent directory needs no provider credentials. The daemon injection transport into a live composer keeps its coverage in `tests/fm-afk-inject-herdr-e2e.test.sh` for the harnesses that still run the daemon, and the dedicated Herdr daemon workspace topology is covered by `tests/fm-afk-launch.test.sh` and preserves the captain tab's pane count. @@ -2081,7 +2169,7 @@ ok - real Pi SDK 0.81.1 accepts the branch session construction and preserves an 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. +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; an absent record, 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. @@ -2097,11 +2185,12 @@ bin/fm-test-run.sh tests/fm-afk-contract.test.sh tests/fm-afk-launch.test.sh tes ```text ok - the read-back renders the words verbatim beside the expected return, spend cap, and reach line -ok - propose then confirm writes a version 2 record, announces hold-for-return only, and every read subcommand reflects it +ok - one enter call writes a version 2 record, announces hold-for-return only, reads it back without asking for a go, and every read subcommand reflects it +ok - the retired propose, confirm, and --proposal inputs are refused by name and write nothing ok - retired clause fields, --grant, and the clause and grant subcommands are refused by name ok - a version 1 record validates, reads its words and scalars with the clause and grant sections ignored, refreshes untouched, and archives ok - new words over a live version 1 record archive it and write version 2 with the same session start -ok - propose: the retired --grant flag is refused by name +ok - enter: the retired --grant flag is refused by name and leaves the standing record alone ok - the return brief renders health, the words with the session account, waiting, could-not-fix, handled, and cost from durable records, and the gate shrinks to what the away session could not fix ok - while the away-posture record exists any green merge lands under away authority, yolo or not, and attended merges stay untagged ok - under the away-posture record the branch merges a green task, is refused on a red check with or without --allow-red, and is refused at the partition while attended @@ -2113,7 +2202,7 @@ ok - branch prompt is byte-stable across homes, cwd, timezone, and time, above t 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 ``` -The runner reported exit 0 with 337 passing lines across the eight scripts; the merge suite (about 227 s) and the security suite dominate the wall time. +The merge suite and the security suite dominate the wall time. ## Native Codex through Pi diff --git a/docs/verification/supervision.md b/docs/verification/supervision.md index e1d233b3d97..c4b968883a9 100644 --- a/docs/verification/supervision.md +++ b/docs/verification/supervision.md @@ -240,11 +240,11 @@ tests/fm-crew-state.test.sh ## Turn-end guard -The blocking and bounded-follow-up mechanisms were validated across seven harnesses on 2026-07-08 through 2026-09-05, with Claude's replacement Stop-owned path revalidated on 2026-07-24, Cursor's stop-hook park validated on 2026-08-13, and omp's blocking `session_stop` hook validated on 2026-09-05. +The blocking and bounded-follow-up mechanisms were validated across seven harnesses on 2026-07-08 through 2026-09-21, with Claude's replacement Stop-owned path revalidated on 2026-09-21, Cursor's stop-hook park validated on 2026-08-13, and omp's blocking `session_stop` hook validated on 2026-09-05. | Harness | Version verified | Mechanism | Observed result | | --- | --- | --- | --- | -| Claude | 2.1.219 | Cooperative blocking `Stop` guard plus `asyncRewake` auto-arm | A fresh unsupervised session ran session start first, reclaimed a stale dead-owner lock, completed two tokenless rewake cycles with no model arm command or guard continuation, and left a competing live owner unchanged. | +| Claude | 2.1.278 | Cooperative blocking `Stop` guard plus `asyncRewake` auto-arm | A fresh unsupervised session received the full session-start digest through the tracked `SessionStart` hook, reclaimed a stale dead-owner lock, completed two tokenless rewake cycles with no model arm command or guard continuation, and left a competing live owner unchanged. | | Codex | 0.142.1 | Blocking `Stop` hook | Hook process root stayed anchored to the trusted checkout and one continuation ran. | | OpenCode | 1.17.6 | Passive `session.idle` callback | Throwing could not block, while `promptAsync` scheduled one TUI follow-up; headless remained fail-open. | | Pi | 0.80.5 | Passive `agent_settled` callback | Exactly one guard follow-up ran for an unhealthy cycle, with no recursion across tool turns. | @@ -355,7 +355,8 @@ No live unattended Claude background session ran on the verifying machine: that 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. -The Claude product live path ran with Claude Code 2.1.219 on 2026-07-24: +The Claude product live path ran with Claude Code 2.1.278 on 2026-09-21. +The same guard also passed once under Claude Code 2.1.236 and 2.1.219 during this verification. ```sh claude --version @@ -365,8 +366,8 @@ FM_CLAUDE_LIVE_E2E=1 tests/fm-claude-stop-autoarm-live-e2e.test.sh Observed output: ```text -2.1.219 (Claude Code) -ok - Claude 2.1.219 (Claude Code) live E2E reclaimed a stale session lock through session start, completed two tokenless Stop-owned rewake cycles, and preserved the competing-live-owner boundary +2.1.278 (Claude Code) +ok - Claude 2.1.278 (Claude Code) live E2E reclaimed a stale session lock through session start, completed two tokenless Stop-owned rewake cycles, and preserved the competing-live-owner boundary ``` Current entry points: @@ -472,11 +473,11 @@ fm-claude-stop-autoarm: ok ## Watcher continuity -The cross-harness evidence combines the 2026-07-17 live pass with Claude's replacement Stop-owned path revalidated on 2026-07-24, all against isolated project and home state. +The cross-harness evidence combines the 2026-07-17 live pass with Claude's replacement Stop-owned path revalidated on 2026-09-21, all against isolated project and home state. No credential material was copied into a fixture. ```text -Claude Code 2.1.219 +Claude Code 2.1.278 codex-cli 0.144.4 OpenCode 1.17.18 Pi 0.80.10 @@ -485,15 +486,29 @@ grok 0.2.103 (89c3d36fb6f1) [stable] | Harness | Exact opt-in command | Observed guarantee | | --- | --- | --- | -| Claude | `FM_CLAUDE_LIVE_E2E=1 tests/fm-claude-stop-autoarm-live-e2e.test.sh` | Session start reclaimed a stale owner before two Stop-owned cycles, and a competing live owner prevented arm, rewake, epoch write, or lock replacement. | +| Claude | `FM_CLAUDE_LIVE_E2E=1 tests/fm-claude-stop-autoarm-live-e2e.test.sh` | The tracked `SessionStart` hook reclaimed a stale owner before two Stop-owned cycles, and a competing live owner prevented arm, rewake, epoch write, or lock replacement. | | Codex | `FM_CODEX_LIVE_E2E=1 tests/fm-codex-continuity-live-e2e.test.sh` | The one-second foreground checkpoint returned without switching to the arm wrapper. | | OpenCode | `FM_OPENCODE_LIVE_E2E=1 tests/fm-opencode-primary-live-e2e.test.sh` | A verified successor existed before prompt handling, with no model re-arm or turn-end fallback. | -| Pi | `FM_PI_LIVE_E2E=1 tests/fm-pi-primary-live-e2e.test.sh` | One initial tool call led to extension-owned successors and clean child retirement on exit. | +| Pi | `FM_PI_LIVE_E2E=1 FM_PI_LIVE_WATCH_ONLY=1 tests/fm-pi-primary-live-e2e.test.sh` | Three consecutive actionable closes each produced a ledger-linked successor, and an intentional stopped-chain failure still raised the outage alarm. | | omp | `FM_OMP_LIVE_E2E=1 tests/fm-omp-primary-live-e2e.test.sh` | One initial `fm_watch_arm_omp` invocation (the openai-codex model reaches extension tools through omp's `xd://` virtual-file bridge, a `write` to `xd://fm_watch_arm_omp`, counted as the same invocation) started a live watcher; an actionable close spawned a ledger-linked successor and woke main exactly once; the lab is reaped by path, and omp 18.1.11 did not exit within 30s of its rpc stdin closing, recorded as a note. omp 18.1.11, 2026-09-05. | | Grok | `FM_GROK_LIVE_E2E=1 tests/fm-grok-continuity-live-e2e.test.sh` | Native task completion surfaced the actionable close and the cycle ledger recorded `reason=actionable-signal`. | Pi 0.81.1 repeated the continuity and clean-exit lifecycle on 2026-07-23 after the Calm presentation changes. +Pi 0.86.1 repeated the isolated watcher-only live check on 2026-09-22: + +```sh +FM_PI_LIVE_E2E=1 FM_PI_LIVE_WATCH_ONLY=1 tests/fm-pi-primary-live-e2e.test.sh +``` + +Observed output: + +```text +ok - Pi 0.86.1 live E2E covered repeated successor handoffs and a genuine stopped-chain alarm +``` + +The test observed three consecutive actionable notifications, each with a ledger-linked successor before model handling, then replaced the isolated lab's arm command with an intentional failure, stopped that lab's live arm chain, and confirmed the guard still emitted `WATCHER DOWN - SUPERVISION IS OFF` after the bounded grace period. + Pi same-process session-transition ownership was verified on 2026-09-01 against the tracked extension with provider-free public lifecycle events, retained and fresh extension-module rebinds, and real arm children: ```sh @@ -508,6 +523,8 @@ Stale prior-generation tool callbacks could not mutate the active child, repeate The strict no-emit check used the installed Pi SDK declarations to hold the lifecycle event contract. Plain Pi and pi-signed share the same tracked `.pi/extensions/fm-primary-pi-watch.ts` path, so both inherit the generation owner; other primary harnesses are not applicable because they do not use this Pi extension lifecycle. +On 2026-09-22 the deterministic transition suite additionally proved that replacement shutdown leaves the established predecessor running under a `handoff` generation marker until a distinct `active` successor generation commits, an actionable reason observed before process close cannot reuse its predecessor as the successor, and a handoff marker from an absent replacement extension cannot suppress session-start or turn-end outage diagnostics. + On 2026-09-02 the same suite, the strict typecheck, and the credential-free real-SDK guard were rerun against `@earendil-works/pi-coding-agent` 0.84.4 after the extension stopped waiting for `before_agent_start` before settling a main delivery; [`runtime-backends.md`](runtime-backends.md#2026-09-02-streaming-time-watcher-delivery) owns the exact commands and output. Observed guarantee: a wake delivered while main was streaming was followed by a verified successor and by delivery of the next actionable close, a replacement replayed only the follow-up Pi had not consumed, an exhausted restoration delivered its typed failure without launching an arm past the retry bound, and a verified successor that failed while a branch settlement still held its wake took the ordinary bounded retry once that delivery settled. diff --git a/docs/watcher-continuity.md b/docs/watcher-continuity.md index 9f79edf94cd..d24fb137ad1 100644 --- a/docs/watcher-continuity.md +++ b/docs/watcher-continuity.md @@ -8,9 +8,11 @@ Must-work continuity now lives above that process boundary instead of depending Pi's `.pi/extensions/fm-primary-pi-watch.ts`, omp's `.omp/extensions/fm-primary-omp-watch.ts`, and OpenCode's `.opencode/plugins/fm-primary-watch-arm.js` own continuous re-arm after an actionable child close. Each adapter starts the next arm before delivering the wake prompt, checks current session-lock ownership at launch, preserves one child or scheduled retry at a time, and applies bounded exponential retry after an unexpected or failed close. A failed follow-up never cancels continuity restoration. -Pi same-process session replacement follows the generation-owner contract in `.pi/extensions/fm-primary-pi-watch.ts`: an owning `session_start` arms the replacement generation without waiting for a model turn, and a state-scoped replacement handoff carries every actionable close whose delivery overlapped `session_shutdown`, including a main follow-up Pi accepted but had not yet consumed, branch handling, and a retiring child that reports after the bounded shutdown wait. +Pi same-process session replacement follows the generation-owner contract in `.pi/extensions/fm-primary-pi-watch.ts`: `session_shutdown` changes the current generation's durable extension marker from `active` to `handoff` but keeps its established arm child alive, then the owning `session_start` publishes a distinct active generation and commits its tracked replacement arm before that arm retires the predecessor. +A state-scoped replacement handoff carries every actionable close whose delivery overlapped `session_shutdown`, including a main follow-up Pi accepted but had not yet consumed, branch handling, and a retiring child that reports after the successor claim. +A handoff marker never satisfies the extension-ownership tolerance, so a running Pi process whose replacement did not load this extension is reported as missing rather than borrowing stale load evidence from its predecessor. A main follow-up counts as delivered once Pi accepts it, never once the model reads it, because a follow-up queued while main is streaming joins the running run without a `before_agent_start`; the extension header owns how consumption is observed and why it only decides what a replacement replays. -omp's replacement follows the same generation-owner contract in `.omp/extensions/fm-primary-omp-watch.ts`, whose header owns the one difference: omp reports no shutdown reason, so every shutdown with a pending actionable close persists the handoff for the next owning `session_start` to replay. +omp's replacement follows its own generation-owner contract in `.omp/extensions/fm-primary-omp-watch.ts`, whose header owns its differences from Pi: it retires the predecessor arm at replacement shutdown instead of retaining it across the handoff, and it reports no shutdown reason, so every shutdown with a pending actionable close persists the handoff for the next owning `session_start` to replay. 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. @@ -26,7 +28,8 @@ While supervision is still needed and away mode remains inactive, an actionable ## Actionable wake ordering -After an actionable Pi, omp, or OpenCode child close, the adapter starts and verifies one singleton successor before it delivers the original wake. +After an actionable Pi, omp, or OpenCode child close, the adapter waits for the predecessor process to close, then starts and verifies one singleton successor before it delivers the original wake. +A complete Pi reason line observed while the predecessor is still finishing durable cleanup is retained for replacement handoff but never treats that already-ready predecessor as its own successor. It confirms the handling handoff against that successor before scheduling the follow-up, retries once against the current generation and successor, and treats a failed confirmation as a restoration failure: it classifies the error, retires a successor that is no longer alive, and surfaces exactly one typed message. A failed confirmation is never swallowed. It waits at most one readiness timeout per attempt, then sends TERM and waits a bounded retirement confirmation before the next lock-verified exponential retry. @@ -111,18 +114,21 @@ The file is size-capped through `FM_WATCH_CYCLE_LOG_MAX_BYTES` and `FM_WATCH_CYC The default 300-second grace is unchanged. Only the watcher process touches `state/.last-watcher-beat`; no helper process can make a wedged watcher appear healthy. +The watcher uses bash's native fatal handling for HUP and TERM, including during a blocked poll, so both run its EXIT cleanup; `watcher_stop_signals` in `bin/fm-watch.sh` owns the signal-handling rationale. ## Regression coverage `tests/fm-pi-watch-extension.test.sh` checks Pi's first-cycle-or-explicit-repair tool metadata and ownership-based redundant-call no-ops, then simulates actionable and empty child closes against the actual Pi and OpenCode close handlers, blocks prompt delivery to prove the successor launches first, verifies single-flight behavior, changes the session lock before close to prove ownership is rechecked, and hangs each successor arm to prove bounded fallback delivery includes the typed restoration failure. -The same suite covers ordinary same-process session replacement for `/new`, `/resume`, `/fork`, and reload, same-instance shutdown-plus-start, automatic re-arm before any model turn, a fresh extension-module rebind carrying all in-flight actionable closes exactly once, stale prior-generation callbacks, repeated transitions with exactly one live cycle, disappearance of the shutting-down refusal after a valid replacement activates, and terminal quit still refusing late rearm. -`tests/fm-watch-arm.test.sh` covers durable queue replay, real remote parent-replies ingestion into the authoritative status log, decision-only OPEN DECISIONS recovery, interrupted handling replay, generation-bound acknowledgement, a persistent live successor after recovery, a watcher close inside the handling window that must leave the printed acknowledgement valid, and the self-healing moved-generation acknowledgement that consumes its handled rows and names its remedy. +The same suite covers ordinary same-process session replacement for `/new`, `/resume`, `/fork`, and reload, same-instance shutdown-plus-start, the predecessor remaining live under a handoff generation until its replacement commits, bounded retry after that replacement kills the predecessor but fails before readiness, automatic re-arm before any model turn, a fresh extension-module rebind carrying all in-flight actionable closes exactly once, stale prior-generation callbacks, repeated transitions with exactly one live cycle, disappearance of the shutting-down refusal after a valid replacement activates, and terminal quit still refusing late rearm. +The guard and session-start suites prove that active generation evidence tolerates a fresh-beacon handoff while a legacy or handoff-phase watcher marker from an absent replacement extension still raises the outage diagnostic. +`tests/fm-watch-arm.test.sh` covers durable queue replay, real remote parent-replies ingestion into the authoritative status log, decision-only OPEN DECISIONS recovery, interrupted handling replay, generation-bound acknowledgement, a persistent live successor after recovery, a watcher close inside the handling window that must leave the printed acknowledgement valid, a re-arm whose recovery cycle is slowed after confirmation and must still surface rather than read as a watcher that stayed live, and the self-healing moved-generation acknowledgement that consumes its handled rows and names its remedy. `tests/fm-watch-recovery-loop.test.sh` covers the once-per-generation announcement bound with the real Pi extension against a refused handling handshake, and a handling successor that must surface a real crew event instead of going blind. +`tests/fm-watch-triage.test.sh` proves TERM stops a watcher blocked inside a poll's pane capture and still releases its lock and records an acknowledgeable stop. `tests/fm-watcher-lock.test.sh` covers verified-successor attach, recovery publication before stale-lock removal, the typed self-eviction failure, bounded and successor-linked lifecycle rows, and a SIGSTOP counterfactual that distinguishes a live PID from a stale beacon before classifying termination. `tests/fm-subagent-pretool-check.test.sh` proves Claude retains only the non-status Bash seatbelts. `tests/fm-claude-stop-autoarm.test.sh` covers the auto-arm's scope, stale and live session owners, unchanged AFK and need boundaries, single-flight, bounded failure retries, benign live-watcher cycle ends, one-notice failure episodes, exit-2 translation, and host-timeout HUP/TERM/INT translation into the same durable failure handoff. It also covers generation-claim single-flight, stuck-claim supersession, superseded-owner silence, notice-marker refusal and retry, ownership-atomic episode reset, and the legacy upgrade shim; [`turnend-guard.md`](turnend-guard.md) owns those behavior contracts. -`FM_CLAUDE_LIVE_E2E=1 tests/fm-claude-stop-autoarm-live-e2e.test.sh` starts with the reproduced stale-lock state, runs session start first, completes two tokenless cycles, and checks the competing-live-owner negative control. +`FM_CLAUDE_LIVE_E2E=1 tests/fm-claude-stop-autoarm-live-e2e.test.sh` starts with the reproduced stale-lock state, receives session start through the tracked SessionStart hook, completes two tokenless cycles, and checks the competing-live-owner negative control. `tests/fm-turnend-guard.test.sh` covers the cooperative `--claude` guard, including monotonic failed-epoch progression, the integrated bounded fail-open, post-alarm continuation suppression, and positive recovery reset; [`turnend-guard.md`](turnend-guard.md#regression-coverage) lists that suite's full generation and legacy claim coverage. ## Active limits and verification @@ -132,4 +138,4 @@ No zero-latency guarantee is claimed because lock verification, watcher startup, OpenCode support targets persistent TUI sessions rather than headless `opencode run`. Claude depends on the Stop `asyncRewake` rewake, Cursor depends on its awaited stop-hook park, Grok retains native background-completion notifications, and Codex retains bounded foreground checkpoints. -[`verification/supervision.md`](verification/supervision.md#watcher-continuity) records the current five-harness live evidence, the 2026-07-24 Stop-owned Claude auto-arm results, and exact opt-in commands. +[`verification/supervision.md`](verification/supervision.md#watcher-continuity) records the current cross-harness live evidence, the dated Stop-owned Claude auto-arm results, and exact opt-in commands. diff --git a/tests/captures/no-mistakes-v1.70.1/README.md b/tests/captures/no-mistakes-v1.70.1/README.md index 75cc474d955..b8ade9d8178 100644 --- a/tests/captures/no-mistakes-v1.70.1/README.md +++ b/tests/captures/no-mistakes-v1.70.1/README.md @@ -28,6 +28,7 @@ No branch in that repository had two recorded live runs at capture time. Only the copy's repository `working_path` was relocated to the permitted worktree; no pipeline was initialized or controlled. The copy omitted step data and had no daemon, so the unrelated active-run detail from that output is intentionally excluded. The retained section demonstrates the actual ten-row cap, row order, quoting, and field layout. +The excluded header also carried the overview's top-level `repo:` line, the resolved `working_path` that the capped-inventory reader uses as repository identity, so this section's lack of that line says nothing about the real output. Original stdout, source projections, and SHA-256 digests were retained in the test-phase evidence directory under `real-anchors/`. ## Replay transformations and limits diff --git a/tests/fm-afk-contract.test.sh b/tests/fm-afk-contract.test.sh index 6598b37862f..b4da2f24e23 100755 --- a/tests/fm-afk-contract.test.sh +++ b/tests/fm-afk-contract.test.sh @@ -2,7 +2,8 @@ # tests/fm-afk-contract.test.sh - the away-posture record owner # (bin/fm-afk-contract.sh): the captain's away words recorded verbatim as the # whole mandate, the read-back rendering, the entry announcement (hold-for- -# return only), the propose/confirm lifecycle, the refresh and replace rules, +# return only), the one-step same-turn entry with no wait for a go, the +# retired two-step entry refusing by name, the refresh and replace rules, # the archive at return, the version 2 record with version 1 still readable, # the retired clause and merge-grant apparatus refusing by name, and the read # subcommands every consumer uses instead of parsing the file. @@ -67,9 +68,9 @@ test_readback_renders_words_verbatim_with_the_record_scalars() { home=$(make_home readback) words="$home/words.txt" printf 'drive the windows fix to green and merge it,\n cut a prerelease; then re-run "nm-ci-windows"\n\tif the install deadlocks abort the competing pipeline\nmerge task y even if nm-ci-windows looks red enough, honestly\n' > "$words" - out=$(contract "$home" propose --words-file "$words" --expected-return 2026-09-08T08:00Z --spend 3 2>&1) \ - || fail "proposal with words failed: $out" - assert_contains "$out" 'Away posture read-back (proposed, not yet confirmed):' 'read-back title' + out=$(contract "$home" enter --words-file "$words" --expected-return 2026-09-08T08:00Z --spend 3 2>&1) \ + || fail "entry with words failed: $out" + assert_contains "$out" 'Away posture (recorded):' 'read-back title' assert_contains "$out" 'expected return: 2026-09-08T08:00Z' 'expected return rendered' assert_contains "$out" 'spend cap: 3 concurrent workers' 'spend cap rendered' assert_contains "$out" 'reach: hold-for-return only. No phone channel is configured; anything that needs you waits for your return.' 'reach rendered' @@ -78,11 +79,12 @@ test_readback_renders_words_verbatim_with_the_record_scalars() { assert_contains "$out" ' cut a prerelease; then re-run "nm-ci-windows"' 'words line 2 keeps its own indentation and quotes' assert_contains "$out" "$(printf ' \tif the install deadlocks')" 'words line 3 keeps its tab' assert_contains "$out" ' merge task y even if nm-ci-windows looks red enough, honestly' 'wording is recorded, never judged' - assert_contains "$out" 'Say go to confirm' 'confirmation prompt' + assert_not_contains "$out" 'Say go' 'the read-back must never ask for a go' + assert_not_contains "$out" 'not yet confirmed' 'the read-back must never describe a pending entry' assert_not_contains "$out" 'clause' 'the read-back must carry no clause apparatus' assert_not_contains "$out" 'task ids' 'the read-back must carry no merge-grant list' # The verbatim words survive the record byte for byte, trailing newline included. - [ "$(contract "$home" words --proposal; printf x)" = "$(cat "$words"; printf x)" ] || fail "the proposal did not keep the words verbatim" + [ "$(contract "$home" words; printf x)" = "$(cat "$words"; printf x)" ] || fail "the record did not keep the words verbatim" pass "the read-back renders the words verbatim beside the expected return, spend cap, and reach line" } @@ -95,136 +97,189 @@ test_words_preserve_final_newline_shape() { printf 'merge when green' > "$without" printf 'merge when green\n' > "$with" printf 'first line\n\n' > "$trailing" - contract "$home" propose --words-file "$without" >/dev/null || fail "proposal without a final newline failed" - [ "$(contract "$home" words --proposal; printf x)" = "$(cat "$without"; printf x)" ] \ + contract "$home" enter --words-file "$without" >/dev/null 2>&1 || fail "entry without a final newline failed" + [ "$(contract "$home" words; printf x)" = "$(cat "$without"; printf x)" ] \ || fail "words without a final newline did not round-trip byte-exact" - contract "$home" propose --words-file "$with" >/dev/null || fail "proposal with a final newline failed" - [ "$(contract "$home" words --proposal; printf x)" = "$(cat "$with"; printf x)" ] \ + contract "$home" enter --words-file "$with" >/dev/null 2>&1 || fail "entry with a final newline failed" + [ "$(contract "$home" words; printf x)" = "$(cat "$with"; printf x)" ] \ || fail "words with a final newline did not round-trip byte-exact" - out=$(contract "$home" propose --words-file "$trailing"; printf x) || fail "proposal with trailing blank lines failed" + out=$(contract "$home" enter --words-file "$trailing" 2>/dev/null; printf x) || fail "entry with trailing blank lines failed" out=${out%x} - assert_contains "$out" $' first line\n \nSay go to confirm' \ - "read-back dropped a trailing blank line from the captain's words" - [ "$(contract "$home" words --proposal; printf x)" = "$(cat "$trailing"; printf x)" ] \ + [ "${out%$' first line\n \n'}" != "$out" ] \ + || fail "read-back dropped a trailing blank line from the captain's words: $out" + [ "$(contract "$home" words; printf x)" = "$(cat "$trailing"; printf x)" ] \ || fail "trailing blank lines did not round-trip byte-exact" pass "words preserve their final newline shape in storage and read-back" } -test_propose_confirm_writes_a_v2_record_and_announces_hold_for_return() { - local home out record proposed_epoch +# /afk is itself the go: one `enter` call writes the record, with no proposal +# staged and no later confirmation, then announces and reads it back. +test_enter_writes_a_v2_record_in_one_step_and_announces_hold_for_return() { + local home out record before after home=$(make_home lifecycle) - contract "$home" propose --words 'merge it when green' >/dev/null || fail "propose failed" - [ -f "$home/state/.afk-contract.proposed" ] || fail "propose did not write the proposal" - proposed_epoch=$(contract "$home" field entered_epoch --proposal) - [ ! -f "$home/state/.afk-contract" ] || fail "a proposal alone must not count as the posture" - sleep 1 - out=$(contract "$home" confirm 2>&1) || fail "confirm failed: $out" + before=$(date +%s) + out=$(contract "$home" enter --words 'merge it when green' 2>&1) || fail "enter failed: $out" + after=$(date +%s) record="$home/state/.afk-contract" - [ -f "$record" ] || fail "confirm did not write the record" - [ ! -f "$home/state/.afk-contract.proposed" ] || fail "confirm left the proposal behind" - assert_contains "$out" 'Away posture confirmed at ' 'announcement opens with the confirmation time' + [ -f "$record" ] || fail "enter did not write the record" + [ ! -e "$home/state/.afk-contract.proposed" ] || fail "enter staged a proposal instead of writing the record" + assert_contains "$out" 'Away posture recorded at ' 'announcement opens with the recorded time' assert_contains "$out" 'hold-for-return only. No phone channel is configured; anything that needs you waits for your return.' 'announcement says hold-for-return only, aloud' assert_contains "$out" 'Your away instructions are recorded verbatim; the away session will carry them out where it can, and anything it is unsure of, or that needs you, waits for your return.' 'announcement says the words will be carried out' assert_contains "$out" 'Destructive, irreversible, and security-sensitive actions are never pre-authorizable, whatever the words say.' 'announcement states the never-set' assert_contains "$out" 'Expected return: not given. Spend cap: 4 concurrent workers.' 'announcement carries the defaults' + assert_contains "$out" 'Away posture (recorded):' 'the read-back follows the entry' + assert_contains "$out" ' merge it when green' 'the read-back carries the words' + assert_not_contains "$out" 'Say go' 'entry must never ask for a go' + assert_not_contains "$out" 'confirm' 'entry must never ask for a confirmation' assert_not_contains "$out" 'not executed' 'the announcement must not call the words inert' assert_not_contains "$out" 'clause' 'the announcement must carry no clause apparatus' [ "$(contract "$home" field version)" = 2 ] || fail "record version is not 2: $(contract "$home" field version)" [ "$(contract "$home" field reach_channels)" = none ] || fail "reach channels are not none" case "$(contract "$home" field confirmed_epoch)" in ''|*[!0-9]*) fail "confirmed_epoch is not numeric" ;; esac case "$(contract "$home" field entered_epoch)" in ''|*[!0-9]*) fail "entered_epoch is not numeric" ;; esac - [ "$(contract "$home" field entered_epoch)" -gt "$proposed_epoch" ] || fail "entry time was not stamped at confirmation" + [ "$(contract "$home" field entered_epoch)" -ge "$before" ] && [ "$(contract "$home" field entered_epoch)" -le "$after" ] \ + || fail "entry time was not stamped by the enter call itself" + [ "$(contract "$home" field confirmed_epoch)" = "$(contract "$home" field entered_epoch)" ] \ + || fail "a fresh entry stamped two different times" [ "$(contract "$home" words)" = 'merge it when green' ] || fail "words did not round-trip" [ -z "$(contract "$home" field merge_grants)" ] || fail "a version 2 record carries a merge_grants field" [ -z "$(contract "$home" field clauses)" ] || fail "a version 2 record carries a clauses section" - contract "$home" validate || fail "the confirmed record does not validate" - out=$(contract "$home" readback) || fail "readback of the confirmed record failed" - assert_contains "$out" 'Away posture (confirmed):' 'confirmed read-back title' - assert_contains "$out" ' merge it when green' 'confirmed read-back carries the words' - pass "propose then confirm writes a version 2 record, announces hold-for-return only, and every read subcommand reflects it" + contract "$home" validate || fail "the record does not validate" + out=$(contract "$home" readback) || fail "readback of the record failed" + assert_contains "$out" 'Away posture (recorded):' 'read-back title' + assert_contains "$out" ' merge it when green' 'read-back carries the words' + pass "one enter call writes a version 2 record, announces hold-for-return only, reads it back without asking for a go, and every read subcommand reflects it" } -test_confirm_requires_readback_and_refresh_is_a_no_op() { - local home out first rc +# The wait-for-go gate is gone: the retired two-step subcommands and the +# proposal read flag are refused by name and write nothing, so no caller can +# stage a mandate that waits on a further human response before it binds. +test_retired_two_step_entry_is_refused_by_name() { + local home cmd out rc + home=$(make_home retired-two-step) + for cmd in propose confirm; do + set +e + out=$(contract "$home" "$cmd" --words 'merge it when green' 2>&1) + rc=$? + set -e + [ "$rc" -eq 2 ] || fail "$cmd should be a usage error (rc=$rc): $out" + assert_contains "$out" "'$cmd' was retired with the wait-for-go gate" "$cmd refusal did not name the retirement" + assert_contains "$out" "run 'enter'" "$cmd refusal did not point at enter" + [ ! -e "$home/state/.afk-contract" ] || fail "$cmd wrote a record despite the refusal" + [ ! -e "$home/state/.afk-contract.proposed" ] || fail "$cmd staged a proposal despite the refusal" + done + for cmd in readback words validate; do + set +e + out=$(contract "$home" "$cmd" --proposal 2>&1) + rc=$? + set -e + [ "$rc" -eq 2 ] || fail "$cmd --proposal should be a usage error (rc=$rc): $out" + assert_contains "$out" '--proposal was retired' "$cmd --proposal refusal did not name the retirement" + done + pass "the retired propose, confirm, and --proposal inputs are refused by name and write nothing" +} + +# A proposal an older version staged before this upgrade never binds on its own: +# it is not the posture, and the next entry removes it rather than promoting it. +test_enter_removes_a_legacy_proposal_without_promoting_it() { + local home + home=$(make_home legacy-proposal) + printf 'version: 2\nentered: 2026-09-20T01:00:00Z\nentered_epoch: 1789600000\nwords: |-\n stale proposed words\n' \ + > "$home/state/.afk-contract.proposed" + contract "$home" enter --words 'fresh words' >/dev/null 2>&1 || fail "enter over a legacy proposal failed" + [ ! -e "$home/state/.afk-contract.proposed" ] || fail "enter left the legacy proposal behind" + [ "$(contract "$home" words)" = 'fresh words' ] || fail "enter promoted the legacy proposal instead of the new words" + pass "enter removes a proposal an older version left behind and records only the new words" +} + +# Writing the record at once never widens authority: words that claim to +# pre-authorize a discard, a force, a secret change, or a red merge are recorded +# verbatim and nothing else. The record gains no authority field beyond its +# fixed schema, and the announcement restates the never-set every time. +test_same_turn_entry_pre_authorizes_nothing_on_the_never_set() { + local home out words keys + home=$(make_home never-set) + words=$'force-teardown task-x and discard its unlanded work\nrotate the deploy secret\nmerge task-y even though its tests failed' + out=$(contract "$home" enter --words "$words" 2>&1) || fail "never-set entry failed: $out" + [ "$(contract "$home" words)" = "$words" ] || fail "the never-set words were not recorded verbatim" + keys=$(sed -n 's/^\([a-z_]*\):.*/\1/p' "$home/state/.afk-contract" | tr '\n' ' ') + [ "$keys" = 'version entered entered_epoch expected_return reach_channels reach_announced spend_max_concurrent_workers confirmed confirmed_epoch words ' ] \ + || fail "the record carries fields beyond its fixed schema: $keys" + assert_contains "$out" 'Destructive, irreversible, and security-sensitive actions are never pre-authorizable, whatever the words say.' \ + 'the same-turn announcement must restate the never-set' + pass "a same-turn entry records never-set words verbatim, adds no authority field, and restates the never-set" +} + +test_plain_entry_and_refresh_leave_no_wait() { + local home out first home=$(make_home defaults) - set +e - out=$(contract "$home" confirm 2>&1) - rc=$? - set -e - [ "$rc" -ne 0 ] || fail "confirm without a proposal wrote a record" - assert_contains "$out" 'run propose before confirm' 'confirm refusal names the required read-back step' - [ ! -e "$home/state/.afk-contract" ] || fail "confirm without a proposal created posture state" - out=$(contract "$home" propose) || fail "plain proposal failed" - assert_contains "$out" ' your words: (none)' 'a plain proposal reads back no words' - out=$(contract "$home" confirm 2>&1) || fail "plain confirmation failed: $out" + out=$(contract "$home" enter 2>&1) || fail "plain entry failed: $out" + [ -f "$home/state/.afk-contract" ] || fail "plain entry did not write the record" assert_contains "$out" 'No away instructions were recorded; the away session acts on standing authority only, and anything that needs you waits for your return.' 'plain announcement' assert_contains "$out" 'hold-for-return only.' 'plain announcement says hold-for-return' + assert_contains "$out" ' your words: (none)' 'a plain entry reads back no words' first=$(cat "$home/state/.afk-contract") sleep 1 - out=$(contract "$home" confirm 2>&1) || fail "refresh confirm failed: $out" + out=$(contract "$home" enter --spend 9 2>&1) || fail "refresh failed: $out" assert_contains "$out" 'already recorded at' 'refresh names the standing record' + assert_contains "$out" 'were not applied' 'refresh says its scalars were not applied' + assert_contains "$out" 'hold-for-return only.' 'refresh repeats the announcement' [ "$(cat "$home/state/.afk-contract")" = "$first" ] || fail "a refresh rewrote the standing record" - pass "confirmation requires a read-back, and refresh leaves the standing record untouched" + pass "a plain entry records no mandate in one step, and a refresh leaves the standing record untouched" } -test_confirming_a_new_proposal_archives_the_standing_record() { +test_new_words_archive_the_standing_record() { local home first_epoch archived home=$(make_home replace) - contract "$home" propose --words 'first words' >/dev/null 2>&1 || fail "first propose failed" - contract "$home" confirm >/dev/null 2>&1 || fail "first confirm failed" + contract "$home" enter --words 'first words' >/dev/null 2>&1 || fail "first entry failed" first_epoch=$(contract "$home" field entered_epoch) sleep 1 - contract "$home" propose --words 'replacement words' >/dev/null 2>&1 || fail "second propose failed" - contract "$home" confirm >/dev/null 2>&1 || fail "second confirm failed" + contract "$home" enter --words 'replacement words' >/dev/null 2>&1 || fail "replacement entry failed" archived=$(find "$home/state/afk-contracts" -name "$first_epoch-superseded-*.afk-contract" -print -quit) [ -f "$archived" ] || fail "the superseded record was not archived" [ "$(contract "$home" words --path "$archived")" = 'first words' ] || fail "the archived record lost the superseded words" [ "$(contract "$home" field entered_epoch)" = "$first_epoch" ] || fail "replacement changed the away session start" + [ "$(contract "$home" field confirmed_epoch)" -gt "$first_epoch" ] || fail "replacement did not stamp its own record time" [ "$(contract "$home" words)" = 'replacement words' ] || fail "the new record does not carry the new words" - pass "a replacement archives the old words and keeps the session start" + pass "new words archive the old words and keep the session start" } test_failed_replacement_keeps_the_standing_record() { local home before out rc home=$(make_home replace-failure) - contract "$home" propose --words 'original posture' >/dev/null || fail "first propose failed" - contract "$home" confirm >/dev/null || fail "first confirm failed" + contract "$home" enter --words 'original posture' >/dev/null 2>&1 || fail "first entry failed" before=$(cat "$home/state/.afk-contract") - contract "$home" propose --words 'replacement posture' >/dev/null || fail "replacement propose failed" printf 'not a directory\n' > "$home/state/afk-contracts" set +e - out=$(contract "$home" confirm 2>&1) + out=$(contract "$home" enter --words 'replacement posture' 2>&1) rc=$? set -e [ "$rc" -ne 0 ] || fail "replacement succeeded without an archive destination" [ "$(cat "$home/state/.afk-contract")" = "$before" ] || fail "failed replacement removed or changed the standing posture" - [ -f "$home/state/.afk-contract.proposed" ] || fail "failed replacement discarded the pending proposal" pass "a failed replacement keeps the standing posture live" } test_failed_final_replacement_rolls_back_the_superseded_archive() { local home before out rc home=$(make_home replace-final-move-failure) - contract "$home" propose --words 'original posture' >/dev/null || fail "first propose failed" - contract "$home" confirm >/dev/null || fail "first confirm failed" + contract "$home" enter --words 'original posture' >/dev/null 2>&1 || fail "first entry failed" before=$(cat "$home/state/.afk-contract") - contract "$home" propose --words 'replacement posture' >/dev/null || fail "replacement propose failed" mkdir -p "$home/fakebin" cat > "$home/fakebin/mv" <<'SH' #!/usr/bin/env bash case "${1:-}:${2:-}" in - *.afk-contract.confirming.*:*/.afk-contract) exit 1 ;; + *.afk-contract.entering.*:*/.afk-contract) exit 1 ;; esac exec /bin/mv "$@" SH chmod +x "$home/fakebin/mv" set +e - out=$(PATH="$home/fakebin:$PATH" contract "$home" confirm 2>&1) + out=$(PATH="$home/fakebin:$PATH" contract "$home" enter --words 'replacement posture' 2>&1) rc=$? set -e [ "$rc" -ne 0 ] || fail "replacement succeeded after its final publication failed" [ "$(cat "$home/state/.afk-contract")" = "$before" ] || fail "failed final publication changed the standing posture" - [ -f "$home/state/.afk-contract.proposed" ] || fail "failed final publication discarded the pending proposal" [ -z "$(find "$home/state/afk-contracts" -name '*-superseded-*.afk-contract' -print -quit)" ] \ || fail "failed final publication left a duplicate superseded mandate" pass "a failed final replacement publication rolls back its superseded archive" @@ -234,8 +289,7 @@ test_validation_rejects_damaged_words_blocks() { local mode home record out rc for mode in unindented empty; do home=$(make_home "damaged-words-$mode") - contract "$home" propose --words 'captain words' >/dev/null || fail "$mode words proposal failed" - contract "$home" confirm >/dev/null || fail "$mode words confirmation failed" + contract "$home" enter --words 'captain words' >/dev/null 2>&1 || fail "$mode words entry failed" record="$home/state/.afk-contract" if [ "$mode" = unindented ]; then sed 's/^ captain words$/captain words/' "$record" > "$home/damaged" @@ -268,9 +322,8 @@ test_a_damaged_words_line_never_truncates_the_mandate() { local home record out rc home=$(make_home truncated-v2) - contract "$home" propose --words $'merge A when green\nhold B until I return' >/dev/null \ - || fail "the multi-line v2 proposal failed" - contract "$home" confirm >/dev/null || fail "the multi-line v2 confirmation failed" + contract "$home" enter --words $'merge A when green\nhold B until I return' >/dev/null 2>&1 \ + || fail "the multi-line v2 entry failed" record="$home/state/.afk-contract" [ "$(contract "$home" words)" = $'merge A when green\nhold B until I return' ] \ || fail "the intact v2 record lost a words line" @@ -322,8 +375,7 @@ assert_words_read_refuses_the_damage() { # <home> <record> <label> test_archive_moves_the_record_aside_and_is_idempotent() { local home epoch path home=$(make_home archive) - contract "$home" propose --words 'archived words' >/dev/null 2>&1 || fail "propose failed" - contract "$home" confirm >/dev/null 2>&1 || fail "confirm failed" + contract "$home" enter --words 'archived words' >/dev/null 2>&1 || fail "entry failed" epoch=$(contract "$home" field entered_epoch) path=$(contract "$home" archive) || fail "archive failed" [ "$path" = "$home/state/afk-contracts/$epoch.afk-contract" ] || fail "archive path is not keyed by entered_epoch: $path" @@ -342,22 +394,22 @@ test_inputs_are_validated() { local home out rc home=$(make_home inputs) set +e - out=$(contract "$home" propose --expected-return 'tomorrow morning' 2>&1) + out=$(contract "$home" enter --expected-return 'tomorrow morning' 2>&1) rc=$? set -e [ "$rc" -eq 2 ] || fail "a non-ISO expected return should be a usage error (rc=$rc): $out" assert_contains "$out" '--expected-return must be UTC ISO 8601' 'expected-return refusal wording' set +e - out=$(contract "$home" propose --spend 0 2>&1) + out=$(contract "$home" enter --spend 0 2>&1) rc=$? set -e [ "$rc" -eq 2 ] || fail "a zero spend cap should be a usage error (rc=$rc): $out" set +e - out=$(contract "$home" propose --words-file "$home/absent.txt" 2>&1) + out=$(contract "$home" enter --words-file "$home/absent.txt" 2>&1) rc=$? set -e [ "$rc" -eq 2 ] || fail "a missing words file should be a usage error (rc=$rc): $out" - [ ! -f "$home/state/.afk-contract.proposed" ] || fail "an invalid proposal was written" + [ ! -f "$home/state/.afk-contract" ] || fail "an invalid entry wrote a record" set +e out=$(contract "$home" validate 2>&1) rc=$? @@ -374,28 +426,27 @@ test_inputs_are_validated() { } # The clause fields and the merge-grant list are retired with the words model. -# A stale caller that still passes them is told so by name, and no proposal is +# A stale caller that still passes them is told so by name, and no record is # written from a refused command line. test_retired_clause_and_grant_inputs_are_usage_errors_by_name() { local home flag out rc home=$(make_home retired-inputs) for flag in --action --object --when --stop --grant; do set +e - out=$(contract "$home" propose --words 'merge it when green' "$flag" merge 2>&1) + out=$(contract "$home" enter --words 'merge it when green' "$flag" merge 2>&1) rc=$? set -e [ "$rc" -eq 2 ] || fail "$flag should be a usage error (rc=$rc): $out" assert_contains "$out" "$flag was retired" "$flag refusal did not name the retirement" assert_contains "$out" "away words are the whole mandate" "$flag refusal did not point at the words" - [ ! -f "$home/state/.afk-contract.proposed" ] || fail "$flag wrote a proposal despite the refusal" + [ ! -f "$home/state/.afk-contract" ] || fail "$flag wrote a record despite the refusal" done set +e - out=$(contract "$home" propose --grant=task-x1 2>&1) + out=$(contract "$home" enter --grant=task-x1 2>&1) rc=$? set -e [ "$rc" -eq 2 ] || fail "--grant= should be a usage error (rc=$rc): $out" - contract "$home" propose --words 'merge it when green' >/dev/null || fail "a words-only proposal failed" - contract "$home" confirm >/dev/null || fail "confirm failed" + contract "$home" enter --words 'merge it when green' >/dev/null 2>&1 || fail "a words-only entry failed" for cmd in clauses flags refused grants; do set +e out=$(contract "$home" "$cmd" 2>&1) @@ -421,14 +472,14 @@ test_version_1_record_still_validates_reads_and_archives() { [ "$(contract "$home" words; printf x)" = 'merge the windows fix when greenx' ] \ || fail "words did not read the v1 words block bounded by its clauses section: $(contract "$home" words)" out=$(contract "$home" readback) || fail "readback of a version 1 record failed" - assert_contains "$out" 'Away posture (confirmed):' 'v1 read-back title' + assert_contains "$out" 'Away posture (recorded):' 'v1 read-back title' assert_contains "$out" 'spend cap: 3 concurrent workers' 'v1 read-back spend cap' assert_contains "$out" 'expected return: 2026-09-20T09:00:00Z' 'v1 read-back expected return' assert_contains "$out" ' merge the windows fix when green' 'v1 read-back words' assert_not_contains "$out" 'task x1 PR' 'the ignored v1 clauses leaked into the read-back' assert_not_contains "$out" 'task-x1' 'the ignored v1 merge grants leaked into the read-back' assert_not_contains "$out" 'refused' 'the ignored v1 refused section leaked into the read-back' - out=$(contract "$home" confirm 2>&1) || fail "refresh of a version 1 record failed: $out" + out=$(contract "$home" enter 2>&1) || fail "refresh of a version 1 record failed: $out" assert_contains "$out" 'already recorded at 2026-09-20T01:00:00Z' 'refresh did not keep the v1 record' [ "$(contract "$home" field version)" = 1 ] || fail "a refresh rewrote the version 1 record" path=$(contract "$home" archive) || fail "archive of a version 1 record failed" @@ -441,8 +492,7 @@ test_version_1_record_is_replaced_by_a_version_2_record() { local home archived home=$(make_home v1-replace) write_v1_record "$home" 'first words, version 1' - contract "$home" propose --words 'new words after the upgrade' >/dev/null || fail "replacement propose over a v1 record failed" - contract "$home" confirm >/dev/null 2>&1 || fail "replacement confirm over a v1 record failed" + contract "$home" enter --words 'new words after the upgrade' >/dev/null 2>&1 || fail "replacement entry over a v1 record failed" [ "$(contract "$home" field version)" = 2 ] || fail "the replacement did not write a version 2 record" [ "$(contract "$home" field entered_epoch)" = 1789600000 ] || fail "the replacement changed the v1 session start" [ "$(contract "$home" words)" = 'new words after the upgrade' ] || fail "the replacement lost the new words" @@ -455,14 +505,13 @@ test_version_1_record_is_replaced_by_a_version_2_record() { # The record-mutating commands share one lock with the subsystems that read this # record's authority and then act on it (bin/fm-pr-merge.sh reads the record -# and merges). While a reader holds that lock, confirm and archive must refuse +# and merges). While a reader holds that lock, enter and archive must refuse # and change nothing, so no publication, replacement, or archive can land inside # the window between that read and the action it authorized. test_record_changes_refuse_while_a_reader_holds_the_lock() { local home lock holder_pid i rc out before home=$(make_home lock-contended) - contract "$home" propose --words 'standing words' >/dev/null || fail "lock-contended: proposal failed" - contract "$home" confirm >/dev/null || fail "lock-contended: confirm failed" + contract "$home" enter --words 'standing words' >/dev/null 2>&1 || fail "lock-contended: entry failed" before=$(cat "$home/state/.afk-contract") lock="$home/state/.afk-contract.lock" @@ -491,32 +540,34 @@ test_record_changes_refuse_while_a_reader_holds_the_lock() { [ -f "$home/state/.afk-contract" ] \ || { kill "$holder_pid" 2>/dev/null || true; fail "lock-contended: the refused archive still moved the record"; } - contract "$home" propose --words 'replacement words' >/dev/null || fail "lock-contended: replacement proposal failed" set +e - out=$(FM_TEST_AFK_CONTRACT_LOCK_TIMEOUT=1 contract "$home" confirm 2>&1) + out=$(FM_TEST_AFK_CONTRACT_LOCK_TIMEOUT=1 contract "$home" enter --words 'replacement words' 2>&1) rc=$? set -e - [ "$rc" -ne 0 ] || { kill "$holder_pid" 2>/dev/null || true; fail "lock-contended: confirm replaced the record while it was locked"; } - assert_contains "$out" 'locked by live process' "lock-contended: the confirm refusal did not name the live holder" + [ "$rc" -ne 0 ] || { kill "$holder_pid" 2>/dev/null || true; fail "lock-contended: enter replaced the record while it was locked"; } + assert_contains "$out" 'locked by live process' "lock-contended: the enter refusal did not name the live holder" [ "$(cat "$home/state/.afk-contract")" = "$before" ] \ - || { kill "$holder_pid" 2>/dev/null || true; fail "lock-contended: the refused confirm changed the standing record"; } + || { kill "$holder_pid" 2>/dev/null || true; fail "lock-contended: the refused enter changed the standing record"; } [ "$(contract "$home" words)" = 'standing words' ] \ || { kill "$holder_pid" 2>/dev/null || true; fail "lock-contended: a read subcommand did not see the unchanged words"; } : > "$home/release" wait "$holder_pid" || fail "lock-contended: the fixture holder did not release cleanly" - contract "$home" confirm >/dev/null 2>&1 || fail "lock-contended: confirm failed once the lock cleared" + contract "$home" enter --words 'replacement words' >/dev/null 2>&1 || fail "lock-contended: enter failed once the lock cleared" [ "$(contract "$home" words)" = 'replacement words' ] \ || fail "lock-contended: the released replacement did not take effect" contract "$home" archive >/dev/null || fail "lock-contended: archive failed once the lock cleared" - pass "confirm and archive refuse while the record is locked, and proceed once it clears" + pass "enter and archive refuse while the record is locked, and proceed once it clears" } test_readback_renders_words_verbatim_with_the_record_scalars test_words_preserve_final_newline_shape -test_propose_confirm_writes_a_v2_record_and_announces_hold_for_return -test_confirm_requires_readback_and_refresh_is_a_no_op -test_confirming_a_new_proposal_archives_the_standing_record +test_enter_writes_a_v2_record_in_one_step_and_announces_hold_for_return +test_retired_two_step_entry_is_refused_by_name +test_enter_removes_a_legacy_proposal_without_promoting_it +test_same_turn_entry_pre_authorizes_nothing_on_the_never_set +test_plain_entry_and_refresh_leave_no_wait +test_new_words_archive_the_standing_record test_failed_replacement_keeps_the_standing_record test_failed_final_replacement_rolls_back_the_superseded_archive test_validation_rejects_damaged_words_blocks diff --git a/tests/fm-afk-launch.test.sh b/tests/fm-afk-launch.test.sh index 577393e2cd4..f3034c6e17e 100755 --- a/tests/fm-afk-launch.test.sh +++ b/tests/fm-afk-launch.test.sh @@ -45,57 +45,75 @@ GLOBAL_CLEANUP() { } trap GLOBAL_CLEANUP EXIT -confirm_posture() { # <home> - FM_HOME="$1" FM_STATE_OVERRIDE="$1/state" "$CONTRACT" propose >/dev/null 2>&1 \ - && FM_HOME="$1" FM_STATE_OVERRIDE="$1/state" "$CONTRACT" confirm >/dev/null 2>&1 +enter_posture() { # <home> + FM_HOME="$1" FM_STATE_OVERRIDE="$1/state" "$CONTRACT" enter >/dev/null 2>&1 } # --------------------------------------------------------------------------- -# UNIT 0: the away-posture record is the entry. `propose` reads the mandate -# back, `confirm` records it and announces hold-for-return; on Pi the entry -# ends there, and every daemon path requires that confirmed record. +# UNIT 0: /afk is itself the go. `enter` writes the away-posture record in the +# same call, with no separate confirmation, and prints the announcement and the +# read-back after the record exists; on Pi the entry ends there, and every +# daemon path requires that record. # --------------------------------------------------------------------------- -unit_propose_confirm_records_the_posture_without_a_daemon() { +unit_enter_records_the_posture_in_one_step_without_a_daemon() { local st out rc - st=$(mktemp -d "${TMPDIR:-/tmp}/fm-afk-propose.XXXXXX") + st=$(mktemp -d "${TMPDIR:-/tmp}/fm-afk-enter.XXXXXX") mkdir -p "$st/state" - out=$(FM_HOME="$st" FM_STATE_OVERRIDE="$st/state" "$LAUNCH" propose \ + out=$(FM_HOME="$st" FM_STATE_OVERRIDE="$st/state" "$LAUNCH" enter \ --words 'merge the windows fix when green' --expected-return 2026-09-08T08:00Z --spend 2 2>&1) rc=$? - if [ "$rc" -eq 0 ] && [ -f "$st/state/.afk-contract.proposed" ] \ + if [ "$rc" -eq 0 ] && [ -f "$st/state/.afk-contract" ] && [ ! -e "$st/state/.afk-contract.proposed" ] \ + && [ "$(FM_HOME="$st" FM_STATE_OVERRIDE="$st/state" "$CONTRACT" words)" = 'merge the windows fix when green' ] \ + && [ "$(FM_HOME="$st" FM_STATE_OVERRIDE="$st/state" "$CONTRACT" field expected_return)" = 2026-09-08T08:00Z ] \ + && [ "$(FM_HOME="$st" FM_STATE_OVERRIDE="$st/state" "$CONTRACT" field spend_max_concurrent_workers)" = 2 ] \ + && [ ! -e "$st/state/.afk" ] && [ ! -e "$st/state/.afk-daemon-terminal" ] \ + && printf '%s' "$out" | grep -F 'hold-for-return only. No phone channel is configured; anything that needs you waits for your return.' >/dev/null \ && printf '%s' "$out" | grep -F ' merge the windows fix when green' >/dev/null \ - && printf '%s' "$out" | grep -F 'expected return: 2026-09-08T08:00Z' >/dev/null \ - && printf '%s' "$out" | grep -F 'spend cap: 2 concurrent workers' >/dev/null \ - && [ ! -e "$st/state/.afk-contract" ]; then - pass "propose: the read-back carries the words verbatim with the expected return and spend cap, and writes only a proposal" - else - fail "propose: read-back or proposal wrong (rc=$rc): $out" - fi - out=$(FM_HOME="$st" FM_STATE_OVERRIDE="$st/state" "$LAUNCH" propose --words 'merge it' --grant fix-windows 2>&1) - rc=$? - if [ "$rc" -eq 2 ] && printf '%s' "$out" | grep -F -- '--grant was retired' >/dev/null; then - pass "propose: the retired --grant flag is refused by name" + && ! printf '%s' "$out" | grep -iE 'say go|to confirm|not yet confirmed' >/dev/null; then + pass "enter: one call writes the record with the words, expected return, and spend cap, reads it back without asking for a go, and launches no daemon" else - fail "propose: --grant was not refused by name (rc=$rc): $out" + fail "enter: record, read-back, or daemon state wrong (rc=$rc): $out" fi - out=$(FM_HOME="$st" FM_STATE_OVERRIDE="$st/state" "$LAUNCH" confirm 2>&1) + out=$(FM_HOME="$st" FM_STATE_OVERRIDE="$st/state" "$LAUNCH" enter --words 'merge it' --grant fix-windows 2>&1) rc=$? - if [ "$rc" -eq 0 ] && [ -f "$st/state/.afk-contract" ] && [ ! -e "$st/state/.afk-contract.proposed" ] \ - && [ ! -e "$st/state/.afk" ] && [ ! -e "$st/state/.afk-daemon-terminal" ] \ - && printf '%s' "$out" | grep -F 'hold-for-return only. No phone channel is configured; anything that needs you waits for your return.' >/dev/null; then - pass "confirm: records the posture, announces hold-for-return only, and launches no daemon" + if [ "$rc" -eq 2 ] && printf '%s' "$out" | grep -F -- '--grant was retired' >/dev/null \ + && [ "$(FM_HOME="$st" FM_STATE_OVERRIDE="$st/state" "$CONTRACT" words)" = 'merge the windows fix when green' ]; then + pass "enter: the retired --grant flag is refused by name and leaves the standing record alone" else - fail "confirm: record, announcement, or daemon state wrong (rc=$rc): $out" + fail "enter: --grant was not refused by name (rc=$rc): $out" fi printf 'schema\tfm-afk-return.v1\nphase\tblocked\n' > "$st/state/.afk-return-catchup" - if FM_HOME="$st" FM_STATE_OVERRIDE="$st/state" "$LAUNCH" propose --words 'merge task a PR when green' >/dev/null 2>&1; then - fail "propose: accepted a new mandate while the prior return catch-up was pending" + if FM_HOME="$st" FM_STATE_OVERRIDE="$st/state" "$LAUNCH" enter --words 'merge task a PR when green' >/dev/null 2>&1; then + fail "enter: accepted a new mandate while the prior return catch-up was pending" + elif [ "$(FM_HOME="$st" FM_STATE_OVERRIDE="$st/state" "$CONTRACT" words)" = 'merge the windows fix when green' ]; then + pass "enter: refuses while the prior return catch-up is pending" else - pass "propose: refuses while the prior return catch-up is pending" + fail "enter: a refused entry changed the standing record" fi rm -rf "$st" } +# No launch path waits for a separate go: the retired two-step subcommands are +# refused by name and write nothing, so no caller can stage a mandate that then +# waits on a human response before it binds. +unit_retired_two_step_entry_is_refused() { + local st cmd out rc + st=$(mktemp -d "${TMPDIR:-/tmp}/fm-afk-retired.XXXXXX") + mkdir -p "$st/state" + for cmd in propose confirm; do + out=$(FM_HOME="$st" FM_STATE_OVERRIDE="$st/state" "$LAUNCH" "$cmd" --words 'merge it when green' 2>&1) + rc=$? + if [ "$rc" -eq 2 ] && printf '%s' "$out" | grep -F "'$cmd' was retired" >/dev/null \ + && [ ! -e "$st/state/.afk-contract" ] && [ ! -e "$st/state/.afk-contract.proposed" ] \ + && [ ! -d "$st/state/.afk-launch.lock" ]; then + pass "$cmd: the retired wait-for-go step is refused by name, writes nothing, and releases the launcher lock" + else + fail "$cmd: the retired step was not refused cleanly (rc=$rc): $out" + fi + done + rm -rf "$st" +} + unit_pi_never_launches_the_daemon() { local st harness out rc for harness in pi pi-signed; do @@ -123,41 +141,61 @@ unit_pi_never_launches_the_daemon() { done } -unit_daemon_entry_requires_confirmation() { +unit_pi_enter_stop_does_not_claim_a_daemon_terminal() { + local st out rc + st=$(mktemp -d "${TMPDIR:-/tmp}/fm-afk-pi-stop.XXXXXX") + mkdir -p "$st/state" + enter_posture "$st" || fail "pi stop: could not enter fixture posture" + [ ! -e "$st/state/.afk" ] || fail "pi stop: fixture error: enter wrote the away flag" + [ ! -e "$st/state/.afk-daemon-terminal" ] || fail "pi stop: fixture error: enter recorded a daemon terminal" + [ ! -e "$st/state/.supervise-daemon.log" ] || fail "pi stop: fixture error: a daemon log already existed" + out=$(FM_HOME="$st" FM_STATE_OVERRIDE="$st/state" "$LAUNCH" stop 2>&1) + rc=$? + if [ "$rc" -eq 0 ] \ + && printf '%s' "$out" | grep -F 'no daemon terminal was running' >/dev/null \ + && ! printf '%s' "$out" | grep -F 'daemon terminal torn down' >/dev/null \ + && [ ! -e "$st/state/.afk-contract" ]; then + pass "pi enter stop: reports that no daemon terminal was running" + else + fail "pi enter stop: claimed a daemon teardown or failed (rc=$rc): $out" + fi + rm -rf "$st" +} + +unit_daemon_entry_requires_the_record() { local st out rc st=$(mktemp -d "${TMPDIR:-/tmp}/fm-afk-entry-record.XXXXXX") mkdir -p "$st/state" - FM_HOME="$st" FM_STATE_OVERRIDE="$st/state" "$CONTRACT" propose --words 'merge task a PR when green' >/dev/null 2>&1 out=$(FM_HOME="$st" FM_STATE_OVERRIDE="$st/state" "$LAUNCH" start-native 2>&1) rc=$? - if [ "$rc" -ne 0 ] && [ -f "$st/state/.afk-contract.proposed" ] && [ ! -e "$st/state/.afk-contract" ] \ - && [ ! -e "$st/state/.afk" ] && printf '%s' "$out" | grep -F 'a confirmed away-posture record is required' >/dev/null; then - pass "daemon entry: a pending proposal cannot bypass captain confirmation" + if [ "$rc" -ne 0 ] && [ ! -e "$st/state/.afk-contract" ] && [ ! -e "$st/state/.afk" ] \ + && printf '%s' "$out" | grep -F 'an away-posture record is required; run enter' >/dev/null; then + pass "daemon entry: no daemon lifecycle starts without the away-posture record" else - fail "daemon entry: pending proposal was promoted or refusal was unclear (rc=$rc): $out" + fail "daemon entry: started without a record or the refusal was unclear (rc=$rc): $out" fi - FM_HOME="$st" FM_STATE_OVERRIDE="$st/state" "$CONTRACT" confirm >/dev/null 2>&1 - if FM_HOME="$st" FM_STATE_OVERRIDE="$st/state" "$LAUNCH" start-native >/dev/null 2>&1 \ + if FM_HOME="$st" FM_STATE_OVERRIDE="$st/state" "$LAUNCH" enter --words 'merge task a PR when green' >/dev/null 2>&1 \ + && FM_HOME="$st" FM_STATE_OVERRIDE="$st/state" "$LAUNCH" start-native >/dev/null 2>&1 \ && [ -e "$st/state/.afk" ]; then - pass "daemon entry: an explicitly confirmed record permits lifecycle preparation" + pass "daemon entry: enter then start-native run back to back with no confirmation between them" else - fail "daemon entry: rejected an explicitly confirmed record" + fail "daemon entry: the record enter wrote did not permit lifecycle preparation" fi FM_HOME="$st" FM_STATE_OVERRIDE="$st/state" "$LAUNCH" stop >/dev/null 2>&1 rm -rf "$st" } -unit_failed_daemon_launch_preserves_confirmed_record() { +unit_failed_daemon_launch_preserves_the_record() { local st st=$(mktemp -d "${TMPDIR:-/tmp}/fm-afk-failed-record.XXXXXX") mkdir -p "$st/state" - confirm_posture "$st" || fail "failed start: could not confirm fixture posture" + enter_posture "$st" || fail "failed start: could not enter fixture posture" if ! FM_HOME="$st" FM_STATE_OVERRIDE="$st/state" FM_SUPERVISOR_TARGET=unused \ FM_SUPERVISOR_BACKEND=unsupported "$LAUNCH" start >/dev/null 2>&1 \ && [ -f "$st/state/.afk-contract" ] && [ ! -e "$st/state/afk-contracts" ]; then - pass "failed start: preserves the pre-confirmed posture record" + pass "failed start: preserves the posture record enter wrote" else - fail "failed start: changed the pre-confirmed posture record" + fail "failed start: changed the posture record enter wrote" fi rm -rf "$st" } @@ -166,7 +204,7 @@ unit_stop_archives_the_record_last() { local st epoch st=$(mktemp -d "${TMPDIR:-/tmp}/fm-afk-stop-archive.XXXXXX") mkdir -p "$st/state" - confirm_posture "$st" || fail "stop archive: could not confirm fixture posture" + enter_posture "$st" || fail "stop archive: could not enter fixture posture" FM_HOME="$st" FM_STATE_OVERRIDE="$st/state" "$LAUNCH" start-native >/dev/null 2>&1 || fail "stop archive: native entry failed" epoch=$(FM_HOME="$st" FM_STATE_OVERRIDE="$st/state" "$CONTRACT" field entered_epoch) if FM_HOME="$st" FM_STATE_OVERRIDE="$st/state" "$LAUNCH" stop >/dev/null 2>&1 \ @@ -467,7 +505,7 @@ unit_failed_start_rolls_back_state() { mkdir -p "$st/state" printf 'pending\n' > "$st/state/.subsuper-escalations" printf 'wedged\n' > "$st/state/.subsuper-inject-wedged" - confirm_posture "$st" || fail "failed start: could not confirm fixture posture" + enter_posture "$st" || fail "failed start: could not enter fixture posture" if FM_HOME="$st" FM_STATE_OVERRIDE="$st/state" FM_SUPERVISOR_TARGET=unused \ FM_SUPERVISOR_BACKEND=unsupported "$LAUNCH" start >/dev/null 2>&1; then fail "failed start: unsupported backend unexpectedly succeeded" @@ -489,7 +527,7 @@ unit_concurrent_start_serialized() { tmux new-session -d -s "$cap_session" 2>/dev/null || { fail "concurrent start: captain session creation failed"; rm -rf "$st"; return 0; } TRACK_TMUX_SESSIONS="$TRACK_TMUX_SESSIONS $cap_session" cap_pane=$(tmux display-message -p -t "$cap_session" '#{pane_id}') - confirm_posture "$st" || fail "concurrent start: could not confirm fixture posture" + enter_posture "$st" || fail "concurrent start: could not enter fixture posture" FM_HOME="$st" FM_STATE_OVERRIDE="$st/state" FM_SUPERVISOR_TARGET="$cap_pane" \ FM_SUPERVISOR_BACKEND=tmux FM_AFK_LAUNCH_ENTRY="$SLEEPER" "$LAUNCH" start >/dev/null 2>&1 & # shellcheck disable=SC2031 # The background PID is captured immediately in this shell. @@ -742,11 +780,11 @@ unit_tmux_absence_distinguishes_probe_failure() { } unit_native_lifecycle() { - local st + local st out st=$(mktemp -d "${TMPDIR:-/tmp}/fm-afk-native.XXXXXX") mkdir -p "$st/state" : > "$st/state/.subsuper-escalations" - confirm_posture "$st" || fail "native lifecycle: could not confirm fixture posture" + enter_posture "$st" || fail "native lifecycle: could not enter fixture posture" if FM_HOME="$st" FM_STATE_OVERRIDE="$st/state" "$LAUNCH" start-native >/dev/null 2>&1 \ && [ "$(cut -f1 "$st/state/.afk-daemon-terminal")" = none ] \ && [ -e "$st/state/.afk" ] \ @@ -755,11 +793,13 @@ unit_native_lifecycle() { else fail "native lifecycle: state preparation or no-terminal record failed" fi - FM_HOME="$st" FM_STATE_OVERRIDE="$st/state" "$LAUNCH" stop >/dev/null 2>&1 - if [ ! -e "$st/state/.afk" ] && [ ! -e "$st/state/.afk-daemon-terminal" ]; then + out=$(FM_HOME="$st" FM_STATE_OVERRIDE="$st/state" "$LAUNCH" stop 2>&1) + if [ ! -e "$st/state/.afk" ] && [ ! -e "$st/state/.afk-daemon-terminal" ] \ + && printf '%s' "$out" | grep -F 'no daemon terminal was running' >/dev/null \ + && ! printf '%s' "$out" | grep -F 'daemon terminal torn down' >/dev/null; then pass "native lifecycle: uniform stop clears state without closing a terminal" else - fail "native lifecycle: uniform stop retained state" + fail "native lifecycle: uniform stop retained state or claimed a teardown: $out" fi rm -rf "$st" } @@ -1125,7 +1165,7 @@ e2e_herdr() { cap_pane=$(printf '%s' "$out" | jq -r '.result.root_pane.pane_id // empty') if [ -z "$cap_ws" ] || [ -z "$cap_pane" ]; then E2E_HERDR_CLEANUP; fail "herdr e2e: could not create captain workspace"; return 0; fi target="$SESSION:$cap_pane" - confirm_posture "$home_tmp" || fail "herdr e2e: could not confirm fixture posture" + enter_posture "$home_tmp" || fail "herdr e2e: could not enter fixture posture" before=$(fm_backend_herdr_cli "$SESSION" pane list --workspace "$cap_ws" 2>/dev/null | jq --arg t "$cap_tab" '[.result.panes[]?|select(.tab_id==$t)]|length') ws_before=$(fm_backend_herdr_cli "$SESSION" workspace list 2>/dev/null | jq '[.result.workspaces[]?]|length') @@ -1167,7 +1207,7 @@ e2e_tmux() { tmux new-session -d -s "$cap_session" 2>/dev/null || { fail "tmux e2e: could not create captain session"; rm -rf "$home_tmp"; return 0; } TRACK_TMUX_SESSIONS="$TRACK_TMUX_SESSIONS $cap_session" cap_pane=$(tmux display-message -p -t "$cap_session" '#{pane_id}') - confirm_posture "$home_tmp" || fail "tmux e2e: could not confirm fixture posture" + enter_posture "$home_tmp" || fail "tmux e2e: could not enter fixture posture" before=$(tmux list-panes -t "$cap_session" | wc -l | tr -d ' ') FM_HOME="$home_tmp" FM_STATE_OVERRIDE="$home_tmp/state" \ @@ -1193,10 +1233,12 @@ e2e_tmux() { } unit_clear_stale -unit_propose_confirm_records_the_posture_without_a_daemon +unit_enter_records_the_posture_in_one_step_without_a_daemon +unit_retired_two_step_entry_is_refused unit_pi_never_launches_the_daemon -unit_daemon_entry_requires_confirmation -unit_failed_daemon_launch_preserves_confirmed_record +unit_pi_enter_stop_does_not_claim_a_daemon_terminal +unit_daemon_entry_requires_the_record +unit_failed_daemon_launch_preserves_the_record unit_stop_archives_the_record_last unit_relative_paths_are_absolute_before_daemon_launch unit_fresh_vs_refresh diff --git a/tests/fm-afk-pi-herdr-return-e2e.test.sh b/tests/fm-afk-pi-herdr-return-e2e.test.sh index 0b94a29d9b1..2f97b6b1668 100755 --- a/tests/fm-afk-pi-herdr-return-e2e.test.sh +++ b/tests/fm-afk-pi-herdr-return-e2e.test.sh @@ -181,14 +181,12 @@ START_RC=$? set -e [ "$START_RC" -ne 0 ] || fail "the away daemon launched on a Pi primary" assert_contains "$START_OUT" 'the away daemon is no longer launched on pi' "the Pi refusal did not name its reason" -PATH="$FAKEBIN:$ORIGINAL_PATH" HERDR_SESSION="$SESSION" FM_HOME="$HOME_DIR" FM_STATE_OVERRIDE="$STATE" \ - PI_CODING_AGENT=true "$ROOT/bin/fm-afk-launch.sh" propose >/dev/null || fail "the away posture read-back failed on Pi" -CONFIRM_OUT=$(PATH="$FAKEBIN:$ORIGINAL_PATH" HERDR_SESSION="$SESSION" FM_HOME="$HOME_DIR" FM_STATE_OVERRIDE="$STATE" \ - PI_CODING_AGENT=true "$ROOT/bin/fm-afk-launch.sh" confirm 2>&1) || fail "the away posture could not be recorded on Pi: $CONFIRM_OUT" -assert_contains "$CONFIRM_OUT" 'hold-for-return only' "the entry announcement did not say hold-for-return" -[ -f "$STATE/.afk-contract" ] || fail "confirm did not write the away-posture record" -[ ! -e "$STATE/.afk" ] || fail "confirm wrote the daemon flag on Pi" -[ ! -e "$STATE/.afk-daemon-terminal" ] || fail "confirm recorded a daemon terminal on Pi" +ENTER_OUT=$(PATH="$FAKEBIN:$ORIGINAL_PATH" HERDR_SESSION="$SESSION" FM_HOME="$HOME_DIR" FM_STATE_OVERRIDE="$STATE" \ + PI_CODING_AGENT=true "$ROOT/bin/fm-afk-launch.sh" enter 2>&1) || fail "the away posture could not be recorded on Pi: $ENTER_OUT" +assert_contains "$ENTER_OUT" 'hold-for-return only' "the entry announcement did not say hold-for-return" +[ -f "$STATE/.afk-contract" ] || fail "enter did not write the away-posture record" +[ ! -e "$STATE/.afk" ] || fail "enter wrote the daemon flag on Pi" +[ ! -e "$STATE/.afk-daemon-terminal" ] || fail "enter recorded a daemon terminal on Pi" sleep 2 [ ! -s "$STATE/.supervise-daemon.pid" ] || fail "an away daemon started on Pi" pass "real Pi primary: the away posture is recorded with no daemon launched" @@ -274,9 +272,7 @@ PATH="$FAKEBIN:$ORIGINAL_PATH" HERDR_SESSION="$SESSION" FM_ROOT_OVERRIDE="$PROJE # A clean re-entry records a fresh posture, and an immediate return is # idempotently clear because the keyed blocker is resolved. PATH="$FAKEBIN:$ORIGINAL_PATH" HERDR_SESSION="$SESSION" FM_HOME="$HOME_DIR" FM_STATE_OVERRIDE="$STATE" \ - PI_CODING_AGENT=true "$ROOT/bin/fm-afk-launch.sh" propose >/dev/null || fail "clean away re-entry read-back failed" -PATH="$FAKEBIN:$ORIGINAL_PATH" HERDR_SESSION="$SESSION" FM_HOME="$HOME_DIR" FM_STATE_OVERRIDE="$STATE" \ - PI_CODING_AGENT=true "$ROOT/bin/fm-afk-launch.sh" confirm >/dev/null || fail "clean away re-entry failed" + PI_CODING_AGENT=true "$ROOT/bin/fm-afk-launch.sh" enter >/dev/null || fail "clean away re-entry failed" PATH="$FAKEBIN:$ORIGINAL_PATH" HERDR_SESSION="$SESSION" FM_ROOT_OVERRIDE="$PROJECT" FM_HOME="$HOME_DIR" FM_STATE_OVERRIDE="$STATE" \ PI_CODING_AGENT=true "$ROOT/bin/fm-afk-return.sh" begin >/dev/null \ || fail "clean away re-entry/return was not idempotent" diff --git a/tests/fm-afk-return.test.sh b/tests/fm-afk-return.test.sh index 6434cb021e6..0ce90aba151 100755 --- a/tests/fm-afk-return.test.sh +++ b/tests/fm-afk-return.test.sh @@ -34,6 +34,8 @@ install_runner() { # <case-dir> cp "$ROOT/bin/fm-branch-outcome.sh" "$dir/bin/" cp "$ROOT/bin/fm-tasks-axi-lib.sh" "$dir/bin/" cp "$ROOT/bin/fm-backlog-transition-lib.sh" "$dir/bin/" + # The merge-notification marker reader behind the brief's landed section. + cp "$ROOT/bin/fm-pr-lib.sh" "$dir/bin/" cp "$ROOT/.tasks.toml" "$dir/home/.tasks.toml" printf '## In flight\n\n## Queued\n\n## Done\n' > "$dir/home/data/backlog.md" # The fake stop mirrors the real one's ordering: the away flag goes, then the @@ -377,9 +379,7 @@ test_return_brief_composes_from_record_store_and_held_set() { (cd "$dir/home" && tasks-axi add fix-windows 'Fix the windows lane' --file data/backlog.md >/dev/null \ && tasks-axi hold fix-windows --reason 'awaiting the captain on the merge' --kind captain --file data/backlog.md >/dev/null) \ || fail "could not seed the held backlog" - contract_in "$dir" propose --words $'merge the windows fix when green, then cut a prerelease\nif the install deadlocks abort the competing run' >/dev/null 2>&1 \ - || fail "could not propose the away-posture record" - contract_in "$dir" confirm >/dev/null 2>&1 || fail "could not confirm the away-posture record" + contract_in "$dir" enter --words $'merge the windows fix when green, then cut a prerelease\nif the install deadlocks abort the competing run' >/dev/null 2>&1 || fail "could not confirm the away-posture record" # Two live blockers, one on a task with a captain-verdict outcome and one on a # task with a routine outcome. A third task failed outright. printf 'window=synthetic:fm-fix-windows\nbackend=tmux\nkind=ship\n' > "$dir/home/state/fix-windows.meta" @@ -465,18 +465,51 @@ test_return_brief_composes_from_record_store_and_held_set() { pass "the return brief renders health, the words with the session account, waiting, could-not-fix, handled, and cost from durable records, and the gate shrinks to what the away session could not fix" } +test_return_brief_lists_landed_work_awaiting_cleanup() { + local dir out landed_line failed_line handled_line + dir="$TMP_ROOT/brief-landed" + install_runner "$dir" + contract_in "$dir" enter --words 'merge the exemption changes when green' >/dev/null 2>&1 || fail "could not confirm the away-posture record" + # The 2026-09-22 away window: exemption workers whose pull requests had + # merged were left sitting, and the return brief never listed them. Two done + # workers with recorded PRs: the merge outcome path marked the first merged + # through its own marker writer, while nothing durable proves the second + # landed, so the brief must list exactly the first. + printf 'window=synthetic:fm-landed\nbackend=tmux\nkind=ship\npr=https://github.com/example/landed/pull/7\n' > "$dir/home/state/landed.meta" + printf 'done [at=1]: PR https://github.com/example/landed/pull/7\n' > "$dir/home/state/landed.status" + printf 'window=synthetic:fm-open\nbackend=tmux\nkind=ship\npr=https://github.com/example/open/pull/8\n' > "$dir/home/state/open.meta" + printf 'done [at=1]: PR https://github.com/example/open/pull/8\n' > "$dir/home/state/open.status" + ( + # shellcheck source=bin/fm-pr-lib.sh + . "$ROOT/bin/fm-pr-lib.sh" + fm_pr_poll_merge_mark_notified "$dir/home/state" landed github github.com example/landed 7 + ) || fail "could not record the landed PR's merge notification through its owner" + touch "$dir/home/state/.last-watcher-beat" + : > "$dir/home/state/.fake-drain" + + out=$(run_return "$dir" begin) || fail "a return with only landed work should clear: $out" + landed_line=$(line_of "$out" 'Landed, cleanup due:') + failed_line=$(line_of "$out" 'Tried and failed, or could not be fixed:') + handled_line=$(line_of "$out" 'Handled while away:') + [ -n "$landed_line" ] && [ -n "$failed_line" ] && [ -n "$handled_line" ] || fail "the brief is missing a section: $out" + [ "$failed_line" -lt "$landed_line" ] && [ "$landed_line" -lt "$handled_line" ] \ + || fail "landed work is out of order (failed $failed_line, landed $landed_line, handled $handled_line)" + assert_contains "$out" ' - landed: https://github.com/example/landed/pull/7 is merged and the worker is still up; close it with bin/fm-teardown.sh landed once catch-up clears' "the landed worker was not listed for cleanup" + assert_not_contains "$out" ' - open:' "a done worker with no durable merge evidence was listed as landed" + assert_contains "$out" 'catch-up clear' "landed work must not hold the gate" + pass "the return brief lists landed work whose worker is still up, from the durable merge marker only, without gating on it" +} + test_return_brief_keeps_refresh_history() { local dir out first_epoch dir="$TMP_ROOT/brief-refresh" install_runner "$dir" - contract_in "$dir" propose --words 'first mandate: merge task first PR when green' >/dev/null 2>&1 || fail "could not propose the first mandate" - contract_in "$dir" confirm >/dev/null 2>&1 || fail "could not confirm the first mandate" + contract_in "$dir" enter --words 'first mandate: merge task first PR when green' >/dev/null 2>&1 || fail "could not confirm the first mandate" first_epoch=$(contract_in "$dir" field entered_epoch) outcome_in "$dir" append --task first --verdict routine \ --summary 'completed before the mandate refresh' --wake 'signal: first.status' >/dev/null \ || fail "could not seed the pre-refresh outcome" - contract_in "$dir" propose --words $'replacement mandate\n\n' >/dev/null 2>&1 || fail "could not propose the replacement mandate" - contract_in "$dir" confirm >/dev/null 2>&1 || fail "could not confirm the replacement mandate" + contract_in "$dir" enter --words $'replacement mandate\n\n' >/dev/null 2>&1 || fail "could not confirm the replacement mandate" [ "$(contract_in "$dir" field entered_epoch)" = "$first_epoch" ] || fail "refresh changed the away-window boundary" touch "$dir/home/state/.last-watcher-beat" : > "$dir/home/state/.fake-drain" @@ -520,8 +553,7 @@ test_missing_epoch_record_stays_required_after_disappearing() { gate="$dir/home/state/.afk-return-catchup" record="$dir/home/state/.afk-contract" backup="$dir/valid-record.backup" - contract_in "$dir" propose --words 'captain words survive' >/dev/null || fail "could not propose the posture record" - contract_in "$dir" confirm >/dev/null || fail "could not confirm the posture record" + contract_in "$dir" enter --words 'captain words survive' >/dev/null 2>&1 || fail "could not confirm the posture record" epoch=$(contract_in "$dir" field entered_epoch) entered=$(contract_in "$dir" field entered) cp "$record" "$backup" @@ -652,12 +684,56 @@ test_unreadable_status_file_keeps_catchup_gated() { pass "an unreadable status stays private and gates until a successful reread" } +test_statusless_leftover_record_keeps_catchup_gated_until_cleanup() { + local dir out rc gate + dir="$TMP_ROOT/statusless-leftover" + install_runner "$dir" + gate="$dir/home/state/.afk-return-catchup" + # A long-merged leftover: no window, no spawn_gen, no status file. The + # catch-up gate must keep refusing while that record exists, matching the + # proven path where writing a readable status file lets return proceed. + printf 'kind=ship\npr=https://github.com/example/repo/pull/1\n' \ + > "$dir/home/state/leftover.meta" + touch "$dir/home/state/.last-watcher-beat" + : > "$dir/home/state/.fake-drain" + set +e + out=$(run_return "$dir" begin) + rc=$? + set -e + [ "$rc" -eq 3 ] || fail "a leftover without a status file should keep catch-up gated (rc=$rc): $out" + [ -f "$gate" ] || fail "a leftover without a status file did not retain the return gate" + assert_contains "$out" "status file unreadable: $dir/home/state/leftover.status; catch-up stays gated" \ + "the gate did not name the missing leftover status" + assert_contains "$out" 'catch-up must finish before the captain request' \ + "the visible return block did not name the catch-up gate" + + : > "$dir/home/state/leftover.status" + out=$(run_return "$dir" check) || fail "catch-up did not clear after the leftover gained a readable status: $out" + assert_contains "$out" 'catch-up clear' "the readable leftover status did not clear catch-up" + [ ! -e "$gate" ] || fail "the readable leftover status left the return gate behind" + pass "a status-file-less leftover record gates return; a readable status on that same record is the proven path that passes" +} + +test_statusful_leftover_record_lets_catchup_clear() { + local dir out + dir="$TMP_ROOT/statusful-leftover" + install_runner "$dir" + printf 'kind=ship\npr=https://github.com/example/repo/pull/1\n' \ + > "$dir/home/state/leftover.meta" + : > "$dir/home/state/leftover.status" + touch "$dir/home/state/.last-watcher-beat" + : > "$dir/home/state/.fake-drain" + out=$(run_return "$dir" begin) || fail "a leftover with a readable status gated return: $out" + assert_contains "$out" 'catch-up clear' "a leftover with a readable status did not let ordinary work proceed" + [ ! -e "$dir/home/state/.afk-return-catchup" ] || fail "a leftover with a readable status left the return gate behind" + pass "a leftover record with a readable status file lets return catch-up clear" +} + test_return_guard_refuses_while_the_record_exists() { local dir out rc dir="$TMP_ROOT/guard-record" install_runner "$dir" - contract_in "$dir" propose >/dev/null 2>&1 || fail "could not propose the away-posture record" - contract_in "$dir" confirm >/dev/null 2>&1 || fail "could not write the away-posture record" + contract_in "$dir" enter >/dev/null 2>&1 || fail "could not write the away-posture record" set +e out=$(FM_HOME="$dir/home" FM_STATE_OVERRIDE="$dir/home/state" "$dir/bin/fm-afk-return.sh" guard 2>&1) rc=$? @@ -672,8 +748,7 @@ test_return_brief_health_leads_with_a_gap() { local dir out gap_line clean_line dir="$TMP_ROOT/brief-gap" install_runner "$dir" - contract_in "$dir" propose >/dev/null 2>&1 || fail "could not propose the away-posture record" - contract_in "$dir" confirm >/dev/null 2>&1 || fail "could not write the away-posture record" + contract_in "$dir" enter >/dev/null 2>&1 || fail "could not write the away-posture record" : > "$dir/home/state/.watcher-down" # A beacon older than the grace, on either date flavor. touch "$dir/home/state/.last-watcher-beat" @@ -694,8 +769,7 @@ test_return_brief_does_not_report_an_acked_watcher_down_marker_as_a_gap() { local dir out dir="$TMP_ROOT/brief-acked-marker" install_runner "$dir" - contract_in "$dir" propose >/dev/null 2>&1 || fail "could not propose the away-posture record" - contract_in "$dir" confirm >/dev/null 2>&1 || fail "could not write the away-posture record" + contract_in "$dir" enter >/dev/null 2>&1 || fail "could not write the away-posture record" # An episode that was detected and fully handled during the away window # leaves the marker behind in an acked state (fm-wake-lib.sh # _fm_recovery_marker_ack); that is not an open gap. @@ -727,11 +801,9 @@ test_unreadable_superseded_archive_keeps_return_gated() { local dir out rc epoch archive backup dir="$TMP_ROOT/superseded-unreadable" install_runner "$dir" - contract_in "$dir" propose --words 'first mandate' >/dev/null 2>&1 || fail "could not propose the first mandate" - contract_in "$dir" confirm >/dev/null 2>&1 || fail "could not confirm the first mandate" + contract_in "$dir" enter --words 'first mandate' >/dev/null 2>&1 || fail "could not confirm the first mandate" epoch=$(contract_in "$dir" field entered_epoch) - contract_in "$dir" propose --words 'replacement mandate' >/dev/null 2>&1 || fail "could not propose the replacement mandate" - contract_in "$dir" confirm >/dev/null 2>&1 || fail "could not confirm the replacement mandate" + contract_in "$dir" enter --words 'replacement mandate' >/dev/null 2>&1 || fail "could not confirm the replacement mandate" archive="" for archive in "$dir/home/state/afk-contracts/$epoch-superseded-"*.afk-contract; do break; done [ -f "$archive" ] || fail "no superseded archive was written" @@ -764,8 +836,7 @@ test_missing_final_archive_keeps_retained_contract_gated() { local dir out rc epoch archive backup dir="$TMP_ROOT/final-archive-missing" install_runner "$dir" - contract_in "$dir" propose --words 'durable mandate' >/dev/null 2>&1 || fail "could not propose the mandate" - contract_in "$dir" confirm >/dev/null 2>&1 || fail "could not confirm the mandate" + contract_in "$dir" enter --words 'durable mandate' >/dev/null 2>&1 || fail "could not confirm the mandate" epoch=$(contract_in "$dir" field entered_epoch) seed_live_blocker "$dir" tmux repair-final touch "$dir/home/state/.last-watcher-beat" @@ -804,12 +875,15 @@ test_check_retries_recorded_terminal_teardown test_unreadable_superseded_archive_keeps_return_gated test_missing_final_archive_keeps_retained_contract_gated test_return_brief_composes_from_record_store_and_held_set +test_return_brief_lists_landed_work_awaiting_cleanup test_return_brief_keeps_refresh_history test_malformed_posture_record_keeps_catchup_gated test_missing_epoch_record_stays_required_after_disappearing test_unreadable_outcome_store_keeps_catchup_gated test_failed_held_listing_keeps_catchup_gated test_unreadable_status_file_keeps_catchup_gated +test_statusless_leftover_record_keeps_catchup_gated_until_cleanup +test_statusful_leftover_record_lets_catchup_clear test_return_guard_refuses_while_the_record_exists test_return_brief_health_leads_with_a_gap test_return_brief_does_not_report_an_acked_watcher_down_marker_as_a_gap diff --git a/tests/fm-backlog-atomicity.test.sh b/tests/fm-backlog-atomicity.test.sh index 2290c5848bf..7cf8aa93ee8 100755 --- a/tests/fm-backlog-atomicity.test.sh +++ b/tests/fm-backlog-atomicity.test.sh @@ -126,7 +126,7 @@ configure_env_backend_tasks_axi() { # <case-dir> cat > "$case_dir/fakebin/tasks-axi" <<SH #!/usr/bin/env bash case "\${1:-}" in - --version) printf '0.2.5\n' ;; + --version) printf '0.2.6\n' ;; update) printf '%s\n' '--archive-body' ;; mv) printf '%s\n' '[<id>...]' ;; show) @@ -180,7 +180,7 @@ make_beads_tasks_axi_stub() { # <case-dir> <id> printf '%s\n' "\$*" >> "$case_dir/tasks-axi-calls" case "\${1:-}" in --version) - printf '%s\n' '0.2.5' + printf '%s\n' '0.2.6' ;; update) [ "\${2:-}" = --help ] || exit 1 @@ -845,7 +845,7 @@ test_completion_omits_the_file_for_a_beads_done() { #!/usr/bin/env bash printf '%s\n' "\$*" >> "$case_dir/tasks-axi-calls" case "\${1:-}" in - --version) printf '%s\n' '0.2.5' ;; + --version) printf '%s\n' '0.2.6' ;; update) [ "\${2:-}" = --help ] || exit 1 printf '%s\n' '--archive-body' diff --git a/tests/fm-backlog-read-bound.test.sh b/tests/fm-backlog-read-bound.test.sh index 726ca052455..811b1fbe1c6 100755 --- a/tests/fm-backlog-read-bound.test.sh +++ b/tests/fm-backlog-read-bound.test.sh @@ -39,7 +39,7 @@ make_hanging_tasks_axi() { # <fakebin> #!/usr/bin/env bash set -u case "${1:-}" in - --version) printf '%s\n' '0.2.5'; exit 0 ;; + --version) printf '%s\n' '0.2.6'; exit 0 ;; update) [ "${2:-}" = --help ] || exit 0 printf '%s\n' 'usage: tasks-axi update <id> [flags]' ' --body-file <path>' ' --archive-body' @@ -285,7 +285,7 @@ cat > "$MIG_FAKEBIN/tasks-axi" <<'SH' #!/usr/bin/env bash set -u case "${1:-}" in - --version) printf '%s\n' '0.2.5'; exit 0 ;; + --version) printf '%s\n' '0.2.6'; exit 0 ;; show) [ -z "${2:-}" ] && { printf 'code: NOT_FOUND\n' >&2; exit 1; } # Only the prefixed migrated candidates wedge; the exact and legacy ids @@ -391,7 +391,7 @@ exit 1 SH chmod +x "$E2E_FAKEBIN/ps" fm_fake_exit0 "$E2E_FAKEBIN" tmux node chrome-devtools-axi gh treehouse -fm_fake_version_tool "$E2E_FAKEBIN" lavish-axi FM_FAKE_LAVISH_AXI_VERSION 0.1.46 +fm_fake_version_tool "$E2E_FAKEBIN" lavish-axi FM_FAKE_LAVISH_AXI_VERSION 0.1.77 fm_fake_version_tool "$E2E_FAKEBIN" gh-axi FM_FAKE_GH_AXI_VERSION 0.1.29 fm_fake_version_tool "$E2E_FAKEBIN" no-mistakes FM_FAKE_NO_MISTAKES_VERSION \ 'no-mistakes version v1.46.0 (fake) 2026-06-27T00:02:18Z' diff --git a/tests/fm-bearings-board-lavish-live-e2e.test.sh b/tests/fm-bearings-board-lavish-live-e2e.test.sh index a413e27c3a0..44b707f6fbf 100755 --- a/tests/fm-bearings-board-lavish-live-e2e.test.sh +++ b/tests/fm-bearings-board-lavish-live-e2e.test.sh @@ -35,6 +35,7 @@ note() { printf '# %s\n' "$1"; } LAB='' cleanup() { + fm_test_reap_procevent_homes [ -z "$LAB" ] || { [ ! -f "$LAB/.lavish/bearings-board.html" ] \ || lavish-axi end "$LAB/.lavish/bearings-board.html" >/dev/null 2>&1 || true @@ -50,6 +51,7 @@ note "lavish-axi ${VERSION:-version-unknown}" LAB=$(mktemp -d "${TMPDIR:-/tmp}/fm-bearings-lavish-live.XXXXXX") || fail "cannot create the guard lab" LAB=$(cd -P -- "$LAB" && pwd -P) mkdir -p "$LAB/state" "$LAB/data" +fm_test_track_procevent_home "$LAB" "$LAB/procevent-claims" cat > "$LAB/payload.json" <<'JSON' { diff --git a/tests/fm-bearings-board-render.test.sh b/tests/fm-bearings-board-render.test.sh index d32d0e9dd79..21601260dcb 100755 --- a/tests/fm-bearings-board-render.test.sh +++ b/tests/fm-bearings-board-render.test.sh @@ -33,7 +33,7 @@ make_home() { # <name> cat > "$fakebin/lavish-axi" <<'SH' #!/usr/bin/env bash case "${1-}" in - --version) printf '0.1.61\n' ;; + --version) printf '0.1.77\n' ;; '') printf 'sessions[1]{file,status,url,pending_prompts}:\n' [ ! -s "$FM_HOME/lavish-open" ] \ diff --git a/tests/fm-bearings-board.test.sh b/tests/fm-bearings-board.test.sh index b5254d42bfa..5c37ed1a83a 100644 --- a/tests/fm-bearings-board.test.sh +++ b/tests/fm-bearings-board.test.sh @@ -35,7 +35,7 @@ state=${LAVISH_FAKE_STATE:?} emit() { # <canonical-file> <status> printf 'session:\n' printf ' file: %s\n' "$1" - printf ' url: "http://127.0.0.1:4387/session/deadbeef"\n' + printf ' url: "http://127.0.0.1:4387/session/0123456789abcdef"\n' printf ' status: %s\n' "$2" } case "${1-}" in @@ -69,7 +69,7 @@ case "${1-}" in if [ -s "$state/open" ]; then while IFS= read -r listed; do [ -n "$listed" ] || continue - printf ' %s,open,"http://127.0.0.1:4387/session/deadbeef",0\n' "$listed" + printf ' %s,open,"http://127.0.0.1:4387/session/0123456789abcdef",0\n' "$listed" done < "$state/open" fi exit 0 @@ -91,6 +91,9 @@ if [ -e "$state/refuse-reopen" ]; then fi rm -f -- "$state/user-ended" printf '%s\n' "$real" > "$state/open" +jq -n --arg file "$real" \ + '{sessions:{"0123456789abcdef":{file:$file,url:"http://127.0.0.1:4387/session/0123456789abcdef"}}}' \ + > "$state/state.json" emit "$real" opened exit 0 SH @@ -106,7 +109,7 @@ run_board() { # <home> <args...> PATH="$home/fakebin:$PATH" FM_HOME="$home" \ FM_STATE_OVERRIDE="$home/state" FM_DATA_OVERRIDE="$home/data" \ FM_PROCEVENT_CLAIM_ROOT="$home/procevent-claims" \ - LAVISH_FAKE_STATE="$home/lavish-state" \ + LAVISH_FAKE_STATE="$home/lavish-state" LAVISH_AXI_STATE_DIR="$home/lavish-state" \ "$BOARD" "$@" } @@ -116,6 +119,7 @@ run_procevent() { # <home> <command args...> PATH="$home/fakebin:$PATH" FM_HOME="$home" \ FM_STATE_OVERRIDE="$home/state" FM_DATA_OVERRIDE="$home/data" \ FM_PROCEVENT_CLAIM_ROOT="$home/procevent-claims" \ + LAVISH_AXI_STATE_DIR="$home/lavish-state" \ "$ROOT/bin/fm-procevent.sh" "$@" } @@ -400,6 +404,10 @@ fi if [ "${1:-}" != poll ]; then real=$(cd "$(dirname "$1")" && pwd -P)/$(basename "$1") printf '%s\n' "$real" > "$FM_HOME/order-open" + mkdir -p "$LAVISH_AXI_STATE_DIR" + jq -n --arg file "$real" \ + '{sessions:{"0123456789abcdef":{file:$file,url:"http://127.0.0.1:14387/session/0123456789abcdef"}}}' \ + > "$LAVISH_AXI_STATE_DIR/state.json" printf 'session:\n status: opened\n' exit 0 fi @@ -419,6 +427,7 @@ SH FM_BEARINGS_BOARD_TEMPLATE="$ROOT/.agents/skills/bearings/assets/board-template.html" \ REAL_LAVISH_ADAPTER="$ROOT/bin/fm-procevent-lavish.sh" \ REAL_PROCEVENT="$ROOT/bin/fm-procevent.sh" ORDER_PROOF_HOLD="$hold" \ + LAVISH_AXI_STATE_DIR="$home/lavish-state" \ "$runtime/bin/fm-bearings-board.sh" build "$data" >/dev/null \ || fail "the order-proof board build failed" diff --git a/tests/fm-bootstrap.test.sh b/tests/fm-bootstrap.test.sh index d8cc824f0dd..0b144e1886f 100755 --- a/tests/fm-bootstrap.test.sh +++ b/tests/fm-bootstrap.test.sh @@ -45,7 +45,7 @@ make_fake_toolchain() { local dir=$1 fakebin fakebin=$(fm_fakebin "$dir") fm_fake_exit0 "$fakebin" tmux node chrome-devtools-axi - fm_fake_version_tool "$fakebin" lavish-axi FM_FAKE_LAVISH_AXI_VERSION 0.1.46 + fm_fake_version_tool "$fakebin" lavish-axi FM_FAKE_LAVISH_AXI_VERSION 0.1.77 cat > "$fakebin/gh-axi" <<'SH' #!/usr/bin/env bash if [ "${1:-}" = --version ]; then @@ -85,7 +85,7 @@ fi exit 0 SH chmod +x "$fakebin/no-mistakes" - add_tasks_axi "$fakebin" "0.2.4" + add_tasks_axi "$fakebin" "0.2.6" add_quota_axi "$fakebin" printf '%s\n' "$fakebin" } @@ -95,7 +95,7 @@ add_quota_axi() { cat > "$fakebin/quota-axi" <<'SH' #!/usr/bin/env bash if [ "${1:-}" = --version ]; then - printf '%s\n' "${FM_FAKE_QUOTA_AXI_VERSION:-0.1.29}" + printf '%s\n' "${FM_FAKE_QUOTA_AXI_VERSION:-0.1.51}" exit 0 fi exit 0 @@ -304,16 +304,16 @@ test_bootstrap_reporting() { ;; esac done <<'ROWS' -treehouse --lease support is accepted silently^1^0.2.4^1^manual^empty^^ -treehouse without --lease reports an upgrade, gh auth is fine^0^0.2.4^1^-^grep^MISSING: treehouse (install: curl -fsSL https://kunchenguid.github.io/treehouse/install.sh | sh)^NEEDS_GH_AUTH -compatible tasks-axi is silent by default^1^0.2.4^1^-^empty^^ +treehouse --lease support is accepted silently^1^0.2.6^1^manual^empty^^ +treehouse without --lease reports an upgrade, gh auth is fine^0^0.2.6^1^-^grep^MISSING: treehouse (install: curl -fsSL https://kunchenguid.github.io/treehouse/install.sh | sh)^NEEDS_GH_AUTH +compatible tasks-axi is silent by default^1^0.2.6^1^-^empty^^ missing tasks-axi is required by default^1^-^1^-^exact^MISSING: tasks-axi (install: npm install -g tasks-axi)^ incompatible tasks-axi is required by default^1^0.1.0^1^-^exact^MISSING: tasks-axi (install: npm install -g tasks-axi)^ -tasks-axi without archive-body is required by default^1^0.2.4:noarchive^1^-^exact^MISSING: tasks-axi (install: npm install -g tasks-axi)^ -tasks-axi without multi-id mv is required by default^1^0.2.4:nomulti^1^-^exact^MISSING: tasks-axi (install: npm install -g tasks-axi)^ -missing quota-axi is required by default^1^0.2.4^0^manual^exact^MISSING: quota-axi (install: npm install -g quota-axi)^ +tasks-axi without archive-body is required by default^1^0.2.6:noarchive^1^-^exact^MISSING: tasks-axi (install: npm install -g tasks-axi)^ +tasks-axi without multi-id mv is required by default^1^0.2.6:nomulti^1^-^exact^MISSING: tasks-axi (install: npm install -g tasks-axi)^ +missing quota-axi is required by default^1^0.2.6^0^manual^exact^MISSING: quota-axi (install: npm install -g quota-axi)^ manual backlog backend still requires missing tasks-axi^1^-^1^manual^exact^MISSING: tasks-axi (install: npm install -g tasks-axi)^ -manual backlog backend suppresses tasks-axi availability^1^0.2.4^1^manual^empty^^ +manual backlog backend suppresses tasks-axi availability^1^0.2.6^1^manual^empty^^ ROWS pass "bootstrap reports treehouse lease + tasks-axi/quota-axi bootstrap contracts" } @@ -381,7 +381,7 @@ ROWS test_lavish_axi_min_version() { local label version mode case_dir fakebin out unavailable n - unavailable='PRESENTATION_UNAVAILABLE: lavish-axi (requires >=0.1.46; install: npm install -g lavish-axi && lavish-axi setup hooks) - nonvisual work may proceed with plain-text decisions and reports; install or upgrade before using Lavish' + unavailable='PRESENTATION_UNAVAILABLE: lavish-axi (requires >=0.1.77; install: npm install -g lavish-axi && lavish-axi setup hooks) - nonvisual work may proceed with plain-text decisions and reports; install or upgrade before using Lavish' n=0 while IFS='^' read -r label version mode; do [ -n "$label" ] || continue @@ -403,11 +403,11 @@ test_lavish_axi_min_version() { esac done <<'ROWS' absent lavish-axi permits text fallback^absent^unavailable -minimum lavish-axi version is accepted^0.1.46^empty -newer lavish-axi patch is accepted^0.1.47^empty +minimum lavish-axi version is accepted^0.1.77^empty +newer lavish-axi patch is accepted^0.1.78^empty newer lavish-axi minor is accepted^0.2.0^empty newer lavish-axi major is accepted^1.0.0^empty -the patch just below the floor permits text fallback^0.1.45^unavailable +the patch just below the floor permits text fallback^0.1.76^unavailable much older lavish-axi minor permits text fallback^0.0.9^unavailable unparseable lavish-axi version permits text fallback^lavish-axi development build^unavailable ROWS @@ -449,15 +449,15 @@ test_tasks_axi_min_version() { [ "$out" = "$missing" ] || fail "$label: expected '$missing', got: $out" ;; esac done <<'ROWS' -minimum tasks-axi version is accepted^0.2.4^empty -newer tasks-axi patch is accepted^0.2.5^empty +minimum tasks-axi version is accepted^0.2.6^empty +newer tasks-axi patch is accepted^0.2.7^empty newer tasks-axi minor is accepted^0.3.0^empty newer tasks-axi major is accepted^1.0.0^empty older tasks-axi with features reports an upgrade^0.1.1^missing -the patch just below the floor reports an upgrade^0.2.3^missing +the patch just below the floor reports an upgrade^0.2.5^missing unparseable tasks-axi version reports an upgrade^tasks-axi development build^missing -tasks-axi at floor without archive-body reports an upgrade^0.2.4:noarchive^missing -tasks-axi at floor without multi-id reports an upgrade^0.2.4:nomulti^missing +tasks-axi at floor without archive-body reports an upgrade^0.2.6:noarchive^missing +tasks-axi at floor without multi-id reports an upgrade^0.2.6:nomulti^missing ROWS pass "bootstrap enforces tasks-axi minimum version" } @@ -484,11 +484,11 @@ test_quota_axi_min_version() { [ "$out" = "$missing" ] || fail "$label: expected '$missing', got: $out" ;; esac done <<'ROWS' -minimum quota-axi version is accepted^0.1.29^empty -newer quota-axi patch is accepted^0.1.30^empty +minimum quota-axi version is accepted^0.1.51^empty +newer quota-axi patch is accepted^0.1.52^empty newer quota-axi minor is accepted^0.2.0^empty newer quota-axi major is accepted^1.0.0^empty -the patch just below the floor reports an upgrade^0.1.28^missing +the patch just below the floor reports an upgrade^0.1.50^missing much older quota-axi minor reports an upgrade^0.0.9^missing unparseable quota-axi version reports an upgrade^quota-axi development build^missing ROWS diff --git a/tests/fm-branch-supervision.test.sh b/tests/fm-branch-supervision.test.sh index 7c70a92e846..7a4cedd370c 100644 --- a/tests/fm-branch-supervision.test.sh +++ b/tests/fm-branch-supervision.test.sh @@ -57,6 +57,14 @@ test_branch_prompt_is_byte_stable_and_above_cache_floor() { *"# PR identity: copy or abstain"*"copied verbatim from the task's \`done [at=<epoch>]: PR <url>\` status line or its \`pr=\` metadata field"*"Never assemble an owner, repository, host, or number"*"report the identifier you do have"*) ;; *) fail "branch prompt lost the copy-or-abstain PR identity rule" ;; esac + # The 2026-09-22 away window: every landed exemption worker was left sitting + # because the prompt granted landed-task cleanup without ever naming the + # moment or the command, so the stale wake ended in the recovery playbook's + # "nothing to recover". + case "$out_a" in + *"A worker whose pull request has landed is finished, not stuck"*"\`check: merge landed:\` wake names exactly that moment"*"\`bin/fm-teardown.sh <task>\` with no flags"*"never forced, worked around, or repaired by hand"*) ;; + *) fail "branch prompt lost the landed-work cleanup rule" ;; + esac pass "branch prompt is byte-stable across homes, cwd, timezone, and time, above the cache floor" } @@ -861,12 +869,8 @@ test_away_record_relocates_main_owned_actions_to_the_branch() { [ "$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" + # /afk is the go: the one entry call writes the record that relocates. + FM_HOME="$home" "$ROOT/bin/fm-afk-contract.sh" enter --spend 2 >/dev/null || fail "away entry failed" # Under the record the partition passes and the merge script reaches its # OWN gate (no task record here), never the partition refusal. @@ -931,8 +935,7 @@ WRAPPER 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" + FM_HOME="$home" "$ROOT/bin/fm-afk-contract.sh" enter --spend 2 >/dev/null || fail "away re-entry 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" @@ -974,8 +977,7 @@ test_away_branch_spawn_requires_queued_dispatchable_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" + FM_HOME="$home" "$ROOT/bin/fm-afk-contract.sh" enter --spend 2 >/dev/null || fail "away entry 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) @@ -1072,8 +1074,7 @@ 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" "$ROOT/bin/fm-afk-contract.sh" enter --spend 1 >/dev/null || fail "away entry failed" FM_HOME="$home" FM_ROOT_OVERRIDE="$root" \ "$root/bin/fm-spawn.sh" task-q1 --mode no-mistakes --yolo off \ diff --git a/tests/fm-brief.test.sh b/tests/fm-brief.test.sh index df3901a1c89..bb5c1304326 100755 --- a/tests/fm-brief.test.sh +++ b/tests/fm-brief.test.sh @@ -327,6 +327,34 @@ test_faster_paths_use_configured_authority_without_stacked_review() { pass "fm-brief.sh: faster paths use configured authority without stacked review" } +# A PR-based ship must not report done on a draft, which cannot be merged; a +# lane that deliberately holds a draft declares a wait instead. local-only opens +# no PR, so it must not carry the requirement. +test_pr_based_dod_requires_non_draft() { + local home mode id brief + home="$TMP_ROOT/draft-dod-home" + mkdir -p "$home/data" + for mode in no-mistakes direct-PR local-only; do + id="brief-draft-$mode" + FM_HOME="$home" "$ROOT/bin/fm-brief.sh" "$id" some-proj --mode "$mode" >/dev/null 2>&1 + brief="$home/data/$id/brief.md" + assert_present "$brief" "$mode: brief was not scaffolded" + if [ "$mode" = local-only ]; then + assert_no_grep "isDraft" "$brief" "$mode: a branch-only delivery must not require a non-draft PR" + continue + fi + # shellcheck disable=SC2016 # single quotes are deliberate: the backticks must stay literal + assert_grep 'confirm it is not a draft (`gh pr view <url> --json isDraft` must print false)' "$brief" \ + "$mode: done must require reading the PR back from the forge as non-draft" + # shellcheck disable=SC2016 # single quotes are deliberate: the backticks must stay literal + assert_grep 'mark it ready with `gh-axi pr ready`' "$brief" \ + "$mode: a draft must be marked ready before done" + assert_grep "If you deliberately keep the PR a draft, append \`paused" "$brief" \ + "$mode: a deliberate draft must declare a wait instead of done" + done + pass "fm-brief.sh: PR-based done requires a non-draft PR; a deliberate draft declares a wait" +} + # Pin the specific line the bug lived on: the no-mistakes DOD's no-mistakes # reference must render as plain prose with no dangling apostrophe artifact. test_no_mistakes_dod_wording() { @@ -380,6 +408,34 @@ test_no_mistakes_dod_wording() { pass "fm-brief.sh: no-mistakes DOD keeps its apostrophe prose and bans --yes outright" } +# The green-PR report must not depend on a status poll: `axi status` never +# reports `checks-passed` while the ci step monitors the PR for merge, so a +# worker told to wait on it for the next gate or outcome never learned its PR +# went green (2026-09-22, PR #5317). The rendered DOD must make the drive +# call's own return the green signal and reattach after a bounded return. +test_no_mistakes_dod_green_detection() { + local home id brief + home="$TMP_ROOT/green-detection-home" + mkdir -p "$home/data" + id="brief-green-b1" + FM_HOME="$home" "$ROOT/bin/fm-brief.sh" "$id" some-proj --mode no-mistakes >/dev/null 2>&1 + brief="$home/data/$id/brief.md" + assert_present "$brief" "brief was not scaffolded" + assert_grep "Only a drive call's return reports the green PR" "$brief" \ + "no-mistakes DOD must make the drive call's return the green signal" + assert_grep "never reports \`checks-passed\` while the ci step is still monitoring the PR for merge" "$brief" \ + "no-mistakes DOD must say axi status cannot show a green PR in merge monitoring" + assert_grep "never wait on a status poll for the next gate or outcome" "$brief" \ + "no-mistakes DOD must forbid waiting on a status poll" + assert_grep "reattach at once by re-running \`no-mistakes axi run\` without flags" "$brief" \ + "no-mistakes DOD must reattach the drive call after a bounded return" + assert_grep "once checks are green it returns \`checks-passed\` immediately" "$brief" \ + "no-mistakes DOD must say a reattach reports an already-green PR" + assert_no_grep "poll \`no-mistakes axi status\` from a separate call" "$brief" \ + "no-mistakes DOD still makes a status poll the wait for the next gate or outcome" + pass "fm-brief.sh: no-mistakes DOD detects a green PR from the drive call, not a status poll" +} + test_ask_user_escalation_format() { local home id brief mode other_id other_brief home="$TMP_ROOT/ask-user-home" @@ -920,9 +976,9 @@ test_scout_lavish_line_follows_presentation_floor() { assert_no_grep "$hosting" "$brief" "$label: scout brief offered a below-floor Lavish" fi done <<'ROWS' -lavish-axi at the floor^0.1.46^hosting +lavish-axi at the floor^0.1.77^hosting lavish-axi above the floor^0.2.0^hosting -lavish-axi just below the floor^0.1.45^text +lavish-axi just below the floor^0.1.76^text absent lavish-axi^absent^text ROWS pass "fm-brief.sh: scout Lavish hosting follows the bootstrap lavish-axi floor" @@ -1048,6 +1104,8 @@ test_ship_mode_is_explicit_not_registry test_delivery_flags_are_refused_where_they_do_not_apply test_faster_paths_use_configured_authority_without_stacked_review test_no_mistakes_dod_wording +test_no_mistakes_dod_green_detection +test_pr_based_dod_requires_non_draft test_ask_user_escalation_format test_ship_project_memory_wording test_herdr_lab_contract_is_explicit_and_complete diff --git a/tests/fm-busy-state.test.sh b/tests/fm-busy-state.test.sh index 7dfef208589..77da1bb0b39 100755 --- a/tests/fm-busy-state.test.sh +++ b/tests/fm-busy-state.test.sh @@ -289,6 +289,141 @@ Ctrl+c:cancel' pass "converted adapters never classify busy from rendered footer text" } +# --- launch-prompt backstop (a launch pinned at fm-spawn, parked on a +# recognized interactive prompt, must classify unknown rather than busy) ------ + +test_launch_prompt_claude_trust_dialog() { + local state out + state=$(new_state_dir launch-prompt-claude) + "$EV" arm "$state" t1 >/dev/null + out=$(fm_busy_classify tmux w1 claude t1 "$state" 'Accessing workspace: /tmp/wt-a +Quick safety check: Is this a project you created or one you trust? +Claude Code'"'"'ll be able to read, edit, and execute files here. +> No, exit + Yes, I trust this folder +Enter to confirm . Esc to cancel') + [ "$out" = "unknown launch-prompt" ] \ + || fail "a launch pinned at fm-spawn parked on Claude's trust dialog must classify unknown launch-prompt, got '$out'" + out=$(fm_busy_classify tmux w1 claude t1 "$state" 'Allow external CLAUDE.md file imports? +This project'"'"'s CLAUDE.md imports files outside the current working directory. +> No, disable external imports + Yes, allow external imports') + [ "$out" = "unknown launch-prompt" ] \ + || fail "a launch pinned at fm-spawn parked on Claude's external-imports dialog must classify unknown launch-prompt, got '$out'" + pass "a Claude launch parked on its trust or external-imports dialog classifies unknown launch-prompt" +} + +test_launch_prompt_pi_trust_dialog() { + local state out h + for h in pi pi-signed omp; do + state=$(new_state_dir "launch-prompt-$h") + "$EV" arm "$state" t1 >/dev/null + out=$(fm_busy_classify tmux w1 "$h" t1 "$state" ' Trust project folder? + /tmp/fm-pi-trust-check/wt + + This allows pi to load .pi settings and resources, install missing project packages, and execute project extensions. + + > Trust + Trust parent folder (/tmp/fm-pi-trust-check) + Trust (this session only) + Do not trust + Do not trust (this session only) + + up/down navigate enter select escape/ctrl+c cancel') + [ "$out" = "unknown launch-prompt" ] \ + || fail "a $h launch pinned at fm-spawn parked on the project-trust dialog must classify unknown launch-prompt, got '$out'" + done + pass "a Pi-family launch (pi, pi-signed, omp) parked on the project-trust dialog classifies unknown launch-prompt" +} + +test_launch_prompt_pi_requires_both_markers() { + local state out + state=$(new_state_dir launch-prompt-pi-partial) + "$EV" arm "$state" t1 >/dev/null + # "trust" alone, with neither the dialog heading nor its decline option, must + # not be read as the dialog - it is an ordinary word a worker's own output + # could easily contain. + out=$(fm_busy_classify tmux w1 pi t1 "$state" 'I trust this approach and will proceed.') + [ "$out" = "busy fm-spawn" ] \ + || fail "ordinary prose containing 'trust' must not classify as a parked launch, got '$out'" + pass "the Pi signature requires both the dialog heading and its decline option, not the bare word trust" +} + +test_launch_prompt_gemini_dialogs() { + local state out + state=$(new_state_dir launch-prompt-gemini-trust) + "$EV" arm "$state" t1 >/dev/null + out=$(fm_busy_classify tmux w1 gemini t1 "$state" 'Do you trust the files in this folder? +● 1. Trust folder (worktree) + 2. Trust parent folder (project) + 3. Don'"'"'t trust') + [ "$out" = "unknown launch-prompt" ] \ + || fail "a Gemini launch parked on the workspace-trust dialog must classify unknown launch-prompt, got '$out'" + + state=$(new_state_dir launch-prompt-gemini-auth) + "$EV" arm "$state" t1 >/dev/null + out=$(fm_busy_classify tmux w1 gemini t1 "$state" 'How would you like to authenticate for this project? +● 2. Use Gemini API Key') + [ "$out" = "unknown launch-prompt" ] \ + || fail "a Gemini launch parked on the auth-method picker must classify unknown launch-prompt, got '$out'" + + state=$(new_state_dir launch-prompt-gemini-apikey) + "$EV" arm "$state" t1 >/dev/null + out=$(fm_busy_classify tmux w1 gemini t1 "$state" 'Enter Gemini API Key +> ') + [ "$out" = "unknown launch-prompt" ] \ + || fail "a Gemini launch parked on the API-key entry dialog must classify unknown launch-prompt, got '$out'" + pass "a Gemini launch parked on its trust, auth-picker, or API-key dialog classifies unknown launch-prompt" +} + +test_launch_prompt_never_shortens_a_working_launch() { + local state out + state=$(new_state_dir launch-prompt-working) + "$EV" arm "$state" t1 >/dev/null + # A genuinely working launch (Claude's ordinary busy footer, rendered before + # its own hook has posted a single event yet) must keep the normal busy + # bound rather than being shortened by this backstop. + out=$(fm_busy_classify tmux w1 claude t1 "$state" '• Working (6s • esc to interrupt)') + [ "$out" = "busy fm-spawn" ] \ + || fail "a genuinely busy launch must not be reclassified, got '$out'" + pass "the launch-prompt backstop never reclassifies a genuinely working launch" +} + +test_launch_prompt_scoped_to_armed_harnesses() { + local state out + # opencode ships no trust dialog (fm-busy-lib.sh header), so it has no + # signature at all: even Claude's own dialog text must not reclassify it. + state=$(new_state_dir launch-prompt-opencode) + "$EV" arm "$state" t1 >/dev/null + out=$(fm_busy_classify tmux w1 opencode t1 "$state" \ + 'Quick safety check: Is this a project you created or one you trust?') + [ "$out" = "busy fm-spawn" ] \ + || fail "opencode has no launch-prompt signature and must stay busy fm-spawn, got '$out'" + pass "the launch-prompt backstop is scoped to harnesses with a verified signature" +} + +test_launch_prompt_never_reclassifies_an_advanced_record() { + local state gen out + state=$(new_state_dir launch-prompt-advanced) + gen=$("$EV" arm "$state" t1) + "$EV" apply "$state" t1 busy --gen "$gen" --source claude-hook --event user-prompt-submit + out=$(fm_busy_classify tmux w1 claude t1 "$state" \ + 'Quick safety check: Is this a project you created or one you trust?') + [ "$out" = "busy claude-hook" ] \ + || fail "a record that has advanced past fm-spawn must never be reclassified by pane text, got '$out'" + pass "the launch-prompt backstop only ever touches the untouched fm-spawn seed" +} + +test_launch_prompt_requires_a_captured_tail() { + local state out + state=$(new_state_dir launch-prompt-no-tail) + "$EV" arm "$state" t1 >/dev/null + out=$(fm_busy_classify tmux w1 claude t1 "$state") + [ "$out" = "busy fm-spawn" ] \ + || fail "with no captured tail the record's own state must stand, got '$out'" + pass "the launch-prompt backstop never runs without a captured tail" +} + test_grok_regex_isolated() { local state out state=$(new_state_dir grok-arm) @@ -474,6 +609,14 @@ test_malformed_record_unknown test_record_without_sidecar_unknown test_source_mismatch_cross_adapter test_converted_adapters_ignore_footer_text +test_launch_prompt_claude_trust_dialog +test_launch_prompt_pi_trust_dialog +test_launch_prompt_pi_requires_both_markers +test_launch_prompt_gemini_dialogs +test_launch_prompt_never_shortens_a_working_launch +test_launch_prompt_scoped_to_armed_harnesses +test_launch_prompt_never_reclassifies_an_advanced_record +test_launch_prompt_requires_a_captured_tail test_grok_regex_isolated test_codex_unverified_gate test_kimi_unverified_gate diff --git a/tests/fm-captain-hold-lifecycle.test.sh b/tests/fm-captain-hold-lifecycle.test.sh index a87dbaf9de1..86a40b667a8 100755 --- a/tests/fm-captain-hold-lifecycle.test.sh +++ b/tests/fm-captain-hold-lifecycle.test.sh @@ -334,7 +334,7 @@ write_known_rows_stub() { # <fakebin> <row-id...> cat > "$fb/tasks-axi" <<'SH' #!/usr/bin/env bash case "${1:-}" in - --version) printf '%s\n' '0.2.5' ;; + --version) printf '%s\n' '0.2.6' ;; update) [ "${2:-}" = --help ] || exit 1 printf '%s\n' '--archive-body' @@ -530,7 +530,7 @@ EOF #!/usr/bin/env bash printf '%s\n' "$*" >> "@LOG@" case "${1:-}" in - --version) printf '%s\n' '0.2.5' ;; + --version) printf '%s\n' '0.2.6' ;; update) if [ "${2:-}" = --help ]; then printf '%s\n' '--archive-body' diff --git a/tests/fm-claude-stop-autoarm-live-e2e.test.sh b/tests/fm-claude-stop-autoarm-live-e2e.test.sh index ae14d9f3af5..0cfaf2f563f 100755 --- a/tests/fm-claude-stop-autoarm-live-e2e.test.sh +++ b/tests/fm-claude-stop-autoarm-live-e2e.test.sh @@ -3,10 +3,11 @@ # (bin/fm-claude-stop-autoarm.sh + bin/fm-turnend-guard.sh --claude). # Proves, against the real installed Claude Code and the real tracked hook # registration: a fresh session with in-flight work, no watcher, and a stale -# session lock can run fm-session-start.sh first; session start reclaims the -# dead owner; at least two tokenless auto-arm and rewake cycles then complete -# with zero model-issued arm commands; and the cooperative guard consumes no -# forced continuation while the hook's launch is healthy. +# session lock receives the full session-start digest through the tracked +# SessionStart hook; session start reclaims the dead owner; at least two +# tokenless auto-arm and rewake cycles then complete with zero model-issued arm +# commands; and the cooperative guard consumes no forced continuation while the +# hook's launch is healthy. # The project and FM_HOME are isolated; Claude keeps using its existing managed # authentication. No live fleet home, worktree, or session is touched. # shellcheck disable=SC2016 # the model, not this test shell, reads the prompt text @@ -42,7 +43,7 @@ mkdir -p "$LAB" git clone -q "$ROOT" "$PROJECT" cp -R "$ROOT/bin/." "$PROJECT/bin/" cp "$ROOT/.claude/settings.json" "$PROJECT/.claude/settings.json" -# The lab keeps the real tracked .claude/settings.json SessionStart nudge, +# The lab keeps the real tracked .claude/settings.json SessionStart run hook, # Stop guard, and asyncRewake auto-arm registration. # The only local hook records model-issued Bash calls without acquiring the # session lock or otherwise changing lifecycle behavior. @@ -87,6 +88,8 @@ if [ "$N" -ge 3 ]; then printf 'watcher: attached pid=%s (beacon 2s)\n' "$$" exit 0 fi +printf 'pending:downtime:fixture-generation-%s\n' "$N" > "$FM_HOME/state/.watcher-down" +touch "$FM_HOME/state/.last-watcher-beat" printf 'watcher: started pid=%s (beacon fresh)\n' "$$" printf 'stale: fixture-rapid-%s\n' "$N" exit 0 @@ -105,7 +108,7 @@ printf 'stale: fixture-rapid drained\n' SH chmod +x "$PROJECT/bin/fm-watch-arm.sh" "$PROJECT/bin/fm-wake-drain.sh" -PROMPT='Run exactly `bin/fm-session-start.sh` with Bash as your first tool call. After reading its complete digest, reply with exactly CYCLE0 and stop. Whenever a Stop hook feedback message wakes you, run exactly `bin/fm-wake-drain.sh` once with Bash, then reply with exactly ACK and stop. Never run bin/fm-watch-arm.sh or any other arm command, and never use any other tool.' +PROMPT='After reading the complete session-start digest, reply with exactly CYCLE0 and stop. Whenever a Stop hook feedback message wakes you, run exactly `bin/fm-wake-drain.sh` once with Bash, then reply with exactly ACK and stop. Never run bin/fm-watch-arm.sh or any other arm command, and never use any other tool.' ( cd "$PROJECT" || exit 1 @@ -118,12 +121,32 @@ ARM_RUNS=$(wc -l < "$HOME_DIR/state/arm-ran" 2>/dev/null | tr -d ' ') [ "$ARM_RUNS" = 2 ] || fail "expected exactly 2 hook-owned arm cycles, got $ARM_RUNS: $(cat "$HOME_DIR/state/arm-ran" 2>/dev/null)" DRAIN_RUNS=$(wc -l < "$HOME_DIR/state/drain-ran" 2>/dev/null | tr -d ' ') [ "$DRAIN_RUNS" = 3 ] || fail "expected one session-start drain plus two model wake drains, got $DRAIN_RUNS drains" -REWAKES=$(grep -c 'Stop hook feedback' "$TRANSCRIPT" 2>/dev/null || true) +REWAKES=$(jq -r ' + select(.type == "user") + | .message.content[]? + | select(.type == "text") + | .text +' "$TRANSCRIPT" 2>/dev/null | awk '/^Stop hook feedback:/{count++} END{print count+0}') [ "$REWAKES" -ge 2 ] || fail "expected at least 2 exit-2 rewake deliveries, got $REWAKES" grep -q 'stale: fixture-rapid-1' "$TRANSCRIPT" || fail "first rapid rewake reason missing from the transcript" grep -q 'stale: fixture-rapid-2' "$TRANSCRIPT" || fail "second rapid rewake reason missing from the transcript" -[ "$(sed -n '1p' "$HOME_DIR/state/tool-calls.log" 2>/dev/null)" = 'bin/fm-session-start.sh' ] \ - || fail "fresh Claude session did not run session start first: $(cat "$HOME_DIR/state/tool-calls.log" 2>/dev/null)" +[ -s "$HOME_DIR/state/tool-calls.log" ] \ + || fail "Claude emitted no logged Bash tool calls" +! grep -q 'fm-session-start.sh' "$HOME_DIR/state/tool-calls.log" \ + || fail "model issued a redundant session-start command: $(cat "$HOME_DIR/state/tool-calls.log")" +DIGEST_EVENTS=$(jq -c --arg heading "SESSION START - $HOME_DIR" ' + select(.type == "system" and .subtype == "hook_response" and .hook_event == "SessionStart") + | select(.stdout | contains($heading)) +' "$TRANSCRIPT" 2>/dev/null) +[ "$(printf '%s' "$DIGEST_EVENTS" | jq -s 'length')" = 1 ] \ + || fail "expected exactly one SessionStart hook_response carrying the session-start digest" +DIGEST=$(printf '%s' "$DIGEST_EVENTS" | jq -r '.stdout') +printf '%s' "$DIGEST" | grep -q '^lock acquired: harness pid [0-9][0-9]*$' \ + || fail "SessionStart hook digest lacks the stale-lock reclaim" +! printf '%s' "$DIGEST" | grep -q '^● STARTUP TRUNCATED - ' \ + || fail "SessionStart hook digest was truncated" +printf '%s' "$DIGEST" | grep -q '^The digest above is complete for this session start\.' \ + || fail "SessionStart hook digest lacks its completion marker" [ "$(cat "$HOME_DIR/state/.lock" 2>/dev/null)" != 9999999 ] \ || fail "session start did not reclaim the stale dead-owner lock" if [ -f "$HOME_DIR/state/tool-calls.log" ]; then diff --git a/tests/fm-contributions.test.sh b/tests/fm-contributions.test.sh index 2f5b604fedd..e9bee1b06cb 100755 --- a/tests/fm-contributions.test.sh +++ b/tests/fm-contributions.test.sh @@ -339,10 +339,8 @@ test_away_yolo_is_fleet_work() { with_home "$home" "$ROOT/bin/fm-pr-check.sh" delivery https://github.com/o/r/pull/8 >/dev/null \ || fail 'could not register away delivery' printf 'yolo=on\n' >> "$home/state/delivery.meta" - with_home "$home" "$ROOT/bin/fm-afk-contract.sh" propose --words 'merge the delivery PR when green' >/dev/null \ - || fail 'could not propose away posture' - with_home "$home" "$ROOT/bin/fm-afk-contract.sh" confirm >/dev/null \ - || fail 'could not confirm away posture' + with_home "$home" "$ROOT/bin/fm-afk-contract.sh" enter --words 'merge the delivery PR when green' >/dev/null \ + || fail 'could not enter away posture' mutate_record "$home" delivery '.records[0].observation.can_merge=true' with_home "$home" "$ROOT/bin/fm-fleet-snapshot.sh" --contribution-input > "$home/input.json" \ || fail 'could not collect contribution input for away posture' @@ -364,10 +362,8 @@ test_away_yolo_cross_home_is_fleet_work() { with_home "$child" "$ROOT/bin/fm-pr-check.sh" delivery https://github.com/o/r/pull/8 >/dev/null \ || fail 'could not register child away delivery' printf 'yolo=on\n' >> "$child/state/delivery.meta" - with_home "$child" "$ROOT/bin/fm-afk-contract.sh" propose --words 'merge the delivery PR when green' >/dev/null \ - || fail 'could not propose child away posture' - with_home "$child" "$ROOT/bin/fm-afk-contract.sh" confirm >/dev/null \ - || fail 'could not confirm child away posture' + with_home "$child" "$ROOT/bin/fm-afk-contract.sh" enter --words 'merge the delivery PR when green' >/dev/null \ + || fail 'could not enter child away posture' mutate_record "$child" delivery '.records[0].observation.can_merge=true' FM_SNAPSHOT_NOW="$NOW" with_home "$child" "$ROOT/bin/fm-fleet-snapshot.sh" --secondmate-home-summary > "$child/state/home-summary.json" \ || fail 'could not collect child contribution summary' @@ -558,7 +554,8 @@ printf '%s\n' "$*" >> "$FORGE/calls" fault=$(cat "$FORGE/fault" 2>/dev/null || true) case "$fault" in latency) sleep "${FORGE_LATENCY:-2}" ;; esac case "$fault:$*" in - reserve:'api repos/o/r/'*) + # Advance once before the parallel read wave; its readers share this clock. + reserve:'api repos/o/r/issues/9') printf '%s\n' "$(( $(cat "$FORGE/clock") + 6 ))" > "$FORGE/clock" ;; exhaust:'api repos/o/r/issues/8/comments?'*) printf '%s\n' "$(( $(cat "$FORGE/clock") + 100 ))" > "$FORGE/clock" ;; diff --git a/tests/fm-control-relaunch.test.sh b/tests/fm-control-relaunch.test.sh index 6918a18e9ec..fbccda9ee90 100755 --- a/tests/fm-control-relaunch.test.sh +++ b/tests/fm-control-relaunch.test.sh @@ -1504,18 +1504,73 @@ test_secondmate_checkpoint_refuses_unreadable_child_state() { expect_code 1 "$rc" "a non-readable child record should refuse" assert_contains "$out" "not a readable regular file" "the refusal should name the unreadable child record" [ "$(cat "$dir/fake/command")" = claude ] || fail "child record failure must not stop the secondmate" + pass "fm-control relaunch: unreadable child records fail checkpoint" + if [ "$(id -u)" = 0 ]; then + pass "fm-control relaunch: unlistable state check skipped as root (mode 000 does not restrict root)" + return 0 + fi rmdir "$dir/smhome/state/bad.meta" - cat > "$dir/fakebin/find" <<'SH' + printf 'window=x:c1\n' > "$dir/smhome/state/c1.meta" + chmod 000 "$dir/smhome/state" + out=$(run_control "$dir" sm5 relaunch); rc=$? + chmod 755 "$dir/smhome/state" + expect_code 1 "$rc" "an unlistable state directory should refuse" + assert_contains "$out" "no readable state directory" \ + "the refusal should name the unlistable home state directory" + [ "$(cat "$dir/fake/command")" = claude ] || fail "unlistable child state must not stop the secondmate" + pass "fm-control relaunch: unlistable state fails checkpoint" +} + +test_secondmate_checkpoint_ignores_a_vanished_scratch_find_walk() { + local dir home out rc real_find + dir=$(new_case smfindrace sm6) + home="$dir/home" + mkdir -p "$home/config" + printf 'claude\n' > "$home/config/secondmate-harness" + fm_git_worktree "$dir/proj" "$dir/smhome" sm-branch + mkdir -p "$dir/smhome/state" "$dir/smhome/data" "$dir/smhome/bin" + printf 'sm6\n' > "$dir/smhome/.fm-secondmate-home" + printf '# charter\n' > "$dir/smhome/data/charter.md" + printf '# agents\n' > "$dir/smhome/AGENTS.md" + printf 'window=x:fm-c1\n' > "$dir/smhome/state/c1.meta" + printf 'window=x:fm-c2\n' > "$dir/smhome/state/c2.meta" + : > "$dir/smhome/state/.hash-0" + : > "$dir/smhome/state/.count-0" + : > "$dir/smhome/state/.last-0" + { + echo "window=fmses:fm-sm6" + echo "endpoint_task_id=sm6" + echo "worktree=$dir/smhome" + echo "project=$dir/smhome" + echo "harness=claude" + echo "kind=secondmate" + echo "mode=secondmate" + echo "yolo=off" + echo "model=default" + echo "effort=default" + echo "home=$dir/smhome" + echo "projects=" + } > "$home/state/sm6.meta" + printf '%s\n' "fm-sm6" > "$dir/fake/windows" + printf '%s' "$dir/smhome" > "$dir/fake/cwd" + real_find=$(command -v find) + cat > "$dir/fakebin/find" <<SH #!/usr/bin/env bash -exit 1 +for arg in "\$@"; do + if [ "\$arg" = "$dir/smhome/state" ]; then + echo "find: \$arg/.hash-0: No such file or directory" >&2 + exit 1 + fi +done +exec "$real_find" "\$@" SH chmod +x "$dir/fakebin/find" - out=$(run_control "$dir" sm5 relaunch); rc=$? - expect_code 1 "$rc" "failed child-state traversal should refuse" - assert_contains "$out" "child records cannot be traversed" \ - "the refusal should preserve a find traversal failure" - [ "$(cat "$dir/fake/command")" = claude ] || fail "child traversal failure must not stop the secondmate" - pass "fm-control relaunch: unreadable and untraversable child state fails checkpoint" + out=$(run_control "$dir" sm6 relaunch); rc=$? + expect_code 0 "$rc" "a vanished watcher scratch file must not refuse relaunch"$'\n'"$out" + assert_contains "$out" "relaunched sm6" "readable child metas must still allow the replacement launch" + [ "$(journal_field "$dir" sm6 children)" = 2 ] \ + || fail "readable child metas must still be counted, got '$(journal_field "$dir" sm6 children)'" + pass "fm-control relaunch: a vanished watcher scratch file does not fail the child-record checkpoint" } test_concurrent_relaunch_is_refused() { @@ -2282,6 +2337,7 @@ test_journal_records_the_checkpoint_it_proved test_secondmate_relaunch_checkpoints_child_work_and_spares_the_charter test_secondmate_relaunch_refuses_an_unmarked_home test_secondmate_checkpoint_refuses_unreadable_child_state +test_secondmate_checkpoint_ignores_a_vanished_scratch_find_walk test_concurrent_relaunch_is_refused test_direct_spawn_relaunch_participates_in_the_lifecycle_lock test_promotion_participates_in_the_lifecycle_lock_before_metadata_resolution diff --git a/tests/fm-crew-state.test.sh b/tests/fm-crew-state.test.sh index 8ec1ecc19a1..745f32c8309 100755 --- a/tests/fm-crew-state.test.sh +++ b/tests/fm-crew-state.test.sh @@ -54,12 +54,16 @@ fm_git_identity fmtest fmtest@example.invalid # A real git repo checked out on <branch>, so the helper's branch attribution # (git symbolic-ref) resolves like it would for a live crew worktree. +# Stamp origin/main at the current HEAD so a later ship done: is not refused +# solely for being a fixture with no remote-tracking refs; tests that need an +# unpreserved named head point those refs at a different commit. make_repo_on_branch() { # <dir> <branch> local dir=$1 branch=$2 mkdir -p "$dir" git -C "$dir" init -q git -C "$dir" commit -q --allow-empty -m init git -C "$dir" checkout -q -b "$branch" + git -C "$dir" update-ref refs/remotes/origin/main "$(git -C "$dir" rev-parse HEAD)" # Real worktree HEAD for run head-binding (fixtures read FM_FAKE_RUN_HEAD). FM_FAKE_RUN_HEAD=$(git -C "$dir" rev-parse HEAD) export FM_FAKE_RUN_HEAD @@ -97,7 +101,19 @@ case "${1:-}" in exit "${FM_FAKE_AXI_STATUS_ERROR:-0}" fi ;; logs) - printf '%s\n' "${FM_FAKE_CI_LOGS:-}" ;; + shift + # The real CLI prints only the last 40 log lines ("lines: 40 of N + # total (tail)", verified against v1.79.0) unless --full asks for the + # whole log, so a marker older than that is invisible to a plain read. + full=0 + for arg in "$@"; do + [ "$arg" = --full ] && full=1 + done + if [ "$full" = 1 ]; then + printf '%s\n' "${FM_FAKE_CI_LOGS:-}" + else + printf '%s\n' "${FM_FAKE_CI_LOGS:-}" | tail -40 + fi ;; esac ;; runs) @@ -566,6 +582,20 @@ outcome: passed EOF } +run_passed_with_override() { # <branch> + cat <<EOF +run: + id: "01RUN" + branch: $1 + status: completed + head: "${FM_FAKE_RUN_HEAD:-abc1234}" + pr: "https://github.com/o/r/pull/1" + findings: none +outcome: passed-with-override +ci_override_reason: "live checks not all passed: Lint (fail)" +EOF +} + run_passed_with_pr() { # <branch> <pr-url> cat <<EOF run: @@ -1098,7 +1128,7 @@ test_ci_ready_done_log_beats_monitoring_run() { # Regression for the PR #252 incident: the crew's own status log never got a # "done: ... checks green" line (log_reports_ci_ready above does not apply), -# but the ci step's log tail shows CI is actually green and only waiting on +# but the ci step's log shows CI is actually green and only waiting on # merge/close. fm-crew-state must surface this as done, not "validating # (running)", so a green PR is never silently absorbed as still-in-progress. test_ci_monitoring_checks_green_surfaces_done() { @@ -1152,7 +1182,11 @@ test_ci_monitoring_no_checks_terminal_surfaces_done() { pass "terminal no-checks ci-monitor marker surfaces done" } -test_ci_monitoring_green_then_rearm_stays_working() { +# The monitor logs a checks state only when it changes, and a base-branch +# advance re-arms only its idle timeout, so a green PR on a busy base ends its +# ci log with re-arm lines (the 2026-09-22 PR #5317 shape: green, then main +# advanced while it waited for merge). The green marker before them is current. +test_ci_monitoring_green_then_rearm_stays_green() { reset_fakes local d; d=$(new_case ci-green-then-rearm) make_repo_on_branch "$d/wt" fm/feat-cirearm @@ -1162,13 +1196,43 @@ test_ci_monitoring_green_then_rearm_stays_working() { FM_FAKE_CI_LOGS=$(cat <<'EOF' all CI checks passed - still monitoring until merged or closed base branch advanced (aaaaaaa..bbbbbbb), re-arming CI monitor timeout +base branch advanced (bbbbbbb..ccccccc), re-arming CI monitor timeout EOF ) local out; out=$(run_crew_state "$d" feat-cirearm) - assert_contains "$out" "state: working" "base-advance rearm marker -> working" - assert_not_contains "$out" "state: done" "base-advance rearm marker must not read as done" - assert_not_contains "$out" "checks green" "base-advance rearm marker must not read as checks green" - pass "base-advance rearm after green stays working" + assert_contains "$out" "state: done" "a base-advance re-arm after green keeps the PR green" + assert_contains "$out" "source: run-step" "re-armed green monitoring stays run-step sourced" + assert_contains "$out" "checks green: PR ready for review" "re-armed green monitoring reads held for merge" + assert_contains "$out" "https://github.com/o/r/pull/2" "the held-for-merge reading names the run's PR" + assert_not_contains "$out" "state: working" "a re-arm line must not read as checks not ready" + pass "base-advance re-arm after green stays checks green" +} + +# The same green-then-re-arm shape, but monitored long enough that the base +# advanced past the CLI's 40-line log tail: `axi logs` without --full would +# answer with re-arm lines only, hiding the green marker entirely, and the +# green PR would read as still working for as long as main kept moving. +test_ci_monitoring_green_before_log_tail_stays_green() { + reset_fakes + local d; d=$(new_case ci-green-beyond-tail) + make_repo_on_branch "$d/wt" fm/feat-citail + make_fakebin "$d" >/dev/null + fm_write_meta "$d/state/feat-citail.meta" "window=fm:fm-feat-citail" "worktree=$d/wt" "kind=ship" + FM_FAKE_AXI_STATUS="$(run_ci_monitoring fm/feat-citail)" + FM_FAKE_CI_LOGS=$({ + printf 'monitoring CI for PR #2 (timeout: 4h0m0s)...\n' + printf 'all CI checks passed - still monitoring until merged or closed\n' + for i in $(seq 1 60); do + printf 'base branch advanced (%07d..%07d), re-arming CI monitor timeout\n' "$i" "$((i + 1))" + done + }) + local out; out=$(run_crew_state "$d" feat-citail) + assert_contains "$out" "state: done" "a green marker older than the log tail still reads green" + assert_contains "$out" "source: run-step" "the full-log green reading stays run-step sourced" + assert_contains "$out" "checks green: PR ready for review" "the full-log reading is held for merge" + assert_contains "$out" "https://github.com/o/r/pull/2" "the full-log reading names the run's PR" + assert_not_contains "$out" "state: working" "a truncated ci log must not hide a green PR" + pass "a green marker before the ci log tail still surfaces done" } test_ci_monitoring_no_checks_yet_stays_working() { @@ -1206,7 +1270,7 @@ test_ci_monitoring_still_waiting_stays_working() { } # A later merge-conflict auto-fix round after an earlier green reading must -# not be masked: the MOST RECENT marker in the log tail wins. +# not be masked: the MOST RECENT marker in the ci log wins. test_ci_monitoring_green_then_new_issue_stays_working() { reset_fakes local d; d=$(new_case ci-green-then-issue) @@ -1312,6 +1376,22 @@ test_terminal_passed() { pass "terminal passed run is authoritative" } +test_terminal_passed_with_override() { + reset_fakes + local d; d=$(new_case passed-with-override) + make_repo_on_branch "$d/wt" fm/feat-override + make_fakebin "$d" >/dev/null + fm_write_meta "$d/state/feat-override.meta" "window=fm:fm-feat-override" "worktree=$d/wt" "kind=ship" + FM_FAKE_AXI_STATUS="$(run_passed_with_override fm/feat-override)" + local out; out=$(run_crew_state "$d" feat-override) + assert_contains "$out" "state: done" "passed-with-override run -> done, not unknown" + assert_contains "$out" "source: run-step" "passed-with-override -> run-step source" + assert_contains "$out" "run passed: PR merged" "passed-with-override run reports merged only after the PR record says merged" + assert_not_contains "$out" "state: unknown" "passed-with-override must not fall through to unknown" + assert_not_contains "$out" "outcome: passed-with-override" "passed-with-override must not surface as a raw unmapped outcome detail" + pass "terminal passed-with-override run reads done like a clean pass" +} + test_terminal_passed_uses_matching_retirement_receipt_without_forge() { reset_fakes local d url read_log out @@ -1875,6 +1955,110 @@ EOF pass "another branch's run is ignored, falls back" } +# A ship done: whose named head lives only in the disposable copy is not +# current-state done (issue 4768). The worker's claim stays a blocked +# preservation failure rather than finished-and-safe. +test_unpushed_ship_done_is_blocked() { + reset_fakes + local d sha out + d=$(new_case unpushed-done) + make_repo_on_branch "$d/wt" fm/unpushed + git -C "$d/wt" commit -q --allow-empty -m 'fix only in the worktree' + sha=$(git -C "$d/wt" rev-parse HEAD) + make_fakebin "$d" >/dev/null + fm_write_meta "$d/state/unpushed.meta" \ + "window=fm:fm-unpushed" "worktree=$d/wt" "project=$d/wt" \ + "kind=ship" "mode=no-mistakes" "harness=claude" + printf 'done: PR https://example.test/o/r/pull/9 checks green\n' \ + > "$d/state/unpushed.status" + FM_FAKE_AXI_STATUS="" + FM_FAKE_RUNS_LIST="" + FM_FAKE_BUSY=0 + arm_idle_record "$d/state" unpushed + out=$(run_crew_state "$d" unpushed) + assert_contains "$out" "state: blocked" "unpushed ship done: must not read as done" + assert_contains "$out" "source: status-log" "preservation refusal stays status-log sourced" + assert_contains "$out" "named head $sha is unreachable outside the worker copy" \ + "refusal must name the unpushed head" + assert_not_contains "$out" "state: done" "unpushed ship done: must not remain done" + pass "unpushed ship done: is current-state blocked" +} + +# Fleet snapshot hands crew-state a captured meta copy outside state/. The +# poll's merge marker stays in the live state dir, so a squash-merged PR whose +# branch fleet sync pruned still reads done there. +test_merged_pr_reads_done_under_captured_meta() { + reset_fakes + local d out + d=$(new_case merged-captured) + make_repo_on_branch "$d/wt" fm/merged + git -C "$d/wt" commit -q --allow-empty -m 'squash-merged fix, branch pruned' + make_fakebin "$d" >/dev/null + fm_write_meta "$d/state/merged.meta" \ + "window=fm:fm-merged" "worktree=$d/wt" "project=$d/wt" \ + "kind=ship" "mode=direct-PR" "harness=claude" "pr=https://github.com/o/r/pull/7" + printf '%s\n' fm-pr-poll-merge-notified-v1 github github.com o/r 7 \ + > "$d/state/merged.pr-poll-merge-notified" + chmod 600 "$d/state/merged.pr-poll-merge-notified" + printf 'done: PR https://github.com/o/r/pull/7\n' > "$d/state/merged.status" + mkdir -p "$d/captured" + cp "$d/state/merged.meta" "$d/captured/merged.meta" + FM_FAKE_AXI_STATUS="" + FM_FAKE_RUNS_LIST="" + FM_FAKE_BUSY=0 + arm_idle_record "$d/state" merged + out=$(FM_CREW_STATE_META_OVERRIDE="$d/captured/merged.meta" run_crew_state "$d" merged) + assert_contains "$out" "state: done" "recorded merged PR must read done under a captured meta" + assert_not_contains "$out" "state: blocked" "merge marker must be read from the live state dir" + pass "recorded merged PR reads done under the fleet snapshot's captured meta" +} + +test_no_mistakes_prevalidation_done_stays_done() { + reset_fakes + local d out + d=$(new_case preval-done) + make_repo_on_branch "$d/wt" fm/preval + git -C "$d/wt" commit -q --allow-empty -m 'fix only in the worktree' + make_fakebin "$d" >/dev/null + fm_write_meta "$d/state/preval.meta" \ + "window=fm:fm-preval" "worktree=$d/wt" "project=$d/wt" \ + "kind=ship" "mode=no-mistakes" "harness=claude" + printf 'done: implementation complete\n' > "$d/state/preval.status" + FM_FAKE_AXI_STATUS="" + FM_FAKE_RUNS_LIST="" + FM_FAKE_BUSY=0 + arm_idle_record "$d/state" preval + out=$(run_crew_state "$d" preval) + assert_contains "$out" "state: done" "no-mistakes pre-validation done: remains done" + assert_not_contains "$out" "state: blocked" "pre-validation done: must not be the named-head gate" + pass "no-mistakes pre-validation done: stays current-state done" +} + +test_moved_remote_branch_without_named_head_is_blocked() { + reset_fakes + local d main_sha fix_sha out + d=$(new_case moved-branch) + make_repo_on_branch "$d/wt" fm/moved + main_sha=$(git -C "$d/wt" rev-parse refs/remotes/origin/main) + git -C "$d/wt" commit -q --allow-empty -m 'the actual fix' + fix_sha=$(git -C "$d/wt" rev-parse HEAD) + git -C "$d/wt" update-ref refs/remotes/origin/fm/moved "$main_sha" + make_fakebin "$d" >/dev/null + fm_write_meta "$d/state/moved.meta" \ + "window=fm:fm-moved" "worktree=$d/wt" "project=$d/wt" \ + "kind=ship" "mode=direct-PR" "harness=claude" + printf 'done: PR https://example.test/o/r/pull/8\n' > "$d/state/moved.status" + FM_FAKE_AXI_STATUS="" + FM_FAKE_RUNS_LIST="" + FM_FAKE_BUSY=0 + arm_idle_record "$d/state" moved + out=$(run_crew_state "$d" moved) + assert_contains "$out" "state: blocked" "a moved remote branch must not count as preserved" + assert_contains "$out" "named head $fix_sha is unreachable outside the worker copy" \ + "refusal must name the missing fix, not the moved branch" + pass "moved remote branch without the named head is current-state blocked" +} + # (f) no run for this crew + a busy pane -> working via pane test_no_run_busy_pane() { reset_fakes @@ -1897,6 +2081,40 @@ test_no_run_busy_pane() { pass "no run + a busy semantic record reads working, attributed to its source" } +# A launch pinned at the fm-spawn seed (no hook has posted yet) whose pane +# renders a recognized interactive prompt must read unknown, never working - +# this is the load-bearing link the launch-prompt backstop depends on: +# fm-watch.sh's pause_state_class absorbs a stale pane as "provably working" +# whenever THIS script reports `state: working · source: pane`, so if this +# authoritative read still said working, the watcher would silently swallow +# the wake even though bin/fm-busy-lib.sh's own classifier had already flipped +# to unknown launch-prompt. crew_busy_verdict must therefore capture a real +# tail for every harness, not only grok, so the backstop's own tail-based +# check ever runs here at all. +test_no_run_launch_prompt_parked_is_not_working() { + reset_fakes + local d; d=$(new_case launch-prompt) + make_repo_on_branch "$d/wt" fm/feat-lp + make_fakebin "$d" >/dev/null + fm_write_meta "$d/state/feat-lp.meta" "window=fm:fm-feat-lp" "worktree=$d/wt" "kind=ship" "harness=claude" + FM_FAKE_AXI_STATUS="" + FM_FAKE_RUNS_LIST="" + FM_FAKE_BUSY=1 + FM_FAKE_BUSY_TEXT='Quick safety check: Is this a project you created or one you trust? ... +> No, exit + Yes, I trust this folder +Enter to confirm . Esc to cancel' + export FM_FAKE_BUSY_TEXT + # arm only, never apply: the launch turn has never advanced past the seed + # fm-spawn.sh writes at spawn time. + "$ROOT/bin/fm-busy-event.sh" arm "$d/state" feat-lp >/dev/null + local out; out=$(run_crew_state "$d" feat-lp) + assert_not_contains "$out" "state: working" "a launch parked on its trust dialog must never read working" + assert_contains "$out" "state: unknown" "a parked launch reads unknown, not busy or idle" + assert_contains "$out" "launch-prompt" "the unknown verdict names the launch-prompt backstop as its source" + pass "a launch parked on a recognized interactive prompt never reads working, closing the absorb path a stale watcher poll depends on" +} + # A converted adapter must NOT read working from rendered footer text: the # redesign removed that dependency, so a pane painting "esc to interrupt" with # no semantic record is unknown, never working and never silently idle. @@ -2257,7 +2475,7 @@ test_single_owner_terminal_declaration_supersedes_stale_decision() { reset_fakes local d kind opener terminal out key expected d=$(new_case terminal-stale-decision) - mkdir -p "$d/wt" + make_repo_on_branch "$d/wt" fm/task make_fakebin "$d" >/dev/null arm_idle_record "$d/state" task for kind in scout ship; do @@ -3288,14 +3506,12 @@ test_capped_overview_without_branch_rows_reports_both_ids() { pass 'same-branch identity survives both runs falling outside the overview' } -# Real `no-mistakes axi` overview truncation carries no `repo: ` identity -# line at all (tests/captures/no-mistakes-v1.70.1/overview.toon, captured -# 2026-09-20): only `count:`/`runs[...]:`. A branch with zero rows anywhere -# in a capped overview must still read as truthfully absent from that real -# shape, not as an unreadable table. -test_capped_overview_without_repo_line_and_no_runs_reports_absent() { +# A branch with zero rows anywhere in a capped overview must read as +# truthfully absent, not as an unreadable table: the rebuilt zero-row +# inventory re-parses as `runs[0]`. +test_capped_overview_with_no_branch_runs_reports_absent() { reset_fakes - local d; d=$TMP_ROOT/capped-no-repo-line-no-runs + local d; d=$TMP_ROOT/capped-no-branch-runs mkdir -p "$d/state" make_repo_on_branch "$d/wt" fm/orphan-branch make_fakebin "$d" >/dev/null @@ -3304,6 +3520,7 @@ test_capped_overview_without_repo_line_and_no_runs_reports_absent() { mkdir -p "$NM_HOME" local head; head=$(git -C "$d/wt" rev-parse --short=8 HEAD) FM_FAKE_AXI_HOME=$(python3 - "$NM_HOME/state.sqlite" "$d/wt" "$head" <<'PY' +import json import sqlite3 import sys @@ -3318,7 +3535,7 @@ with sqlite3.connect(database) as db: db.executemany("INSERT INTO runs VALUES (?, ?, ?, ?, ?, ?)", [("01OTHER%02d" % i, "repo", "fm/other-%d" % i, "running", head, i) for i in range(11)]) -# Genuine captured shape: no `repo: ` line, ever. +print("repo: " + json.dumps(worktree)) print("count: 10 of 11 total") print("runs[10]{id,branch,status,head,pr}:") for i in range(10): @@ -3331,14 +3548,14 @@ PY "$ROOT/bin/fm-busy-event.sh" apply "$d/state" orphan busy --gen "$gen" \ --source claude-hook --event user-prompt-submit local out; out=$(run_crew_state "$d" orphan) - assert_not_contains "$out" "state: unknown" 'a zero-row branch in a repo-line-free capped overview is absent, not unreadable' - assert_not_contains "$out" "unreadable" 'the missing repo: line must not read as an unreadable table' + assert_not_contains "$out" "state: unknown" 'a zero-row branch in a capped overview is absent, not unreadable' + assert_not_contains "$out" "unreadable" 'a zero-row branch must not read as an unreadable table' assert_contains "$out" "state: working" 'absence of a run falls through to the pane/busy verdict' assert_contains "$out" "source: pane" 'the working verdict still comes from the pane source' - pass 'a capped overview with no repo: line and zero same-branch rows reports absent, not unreadable' + pass 'a capped overview with zero same-branch rows reports absent, not unreadable' } -# The same real capped shape, but reached through the code path that actually +# The same capped shape, but reached through the code path that actually # consumes the same-branch selection: fm-crew-state only consults the overview # once `axi status` answers with a run, so a branch of its own with no run at # all is only reported while SOME run exists elsewhere. Pre-fix this read @@ -3355,6 +3572,7 @@ test_no_branch_run_beside_a_live_run_elsewhere_reads_absent() { mkdir -p "$NM_HOME" local head; head=$(git -C "$d/wt" rev-parse HEAD) FM_FAKE_AXI_HOME=$(python3 - "$NM_HOME/state.sqlite" "$d/wt" "$head" <<'PY' +import json import sqlite3 import sys @@ -3369,7 +3587,7 @@ with sqlite3.connect(database) as db: db.executemany("INSERT INTO runs VALUES (?, ?, ?, ?, ?, ?)", [("01OTHER%02d" % i, "repo", "fm/other-%d" % i, "running", head, i) for i in range(11)]) -# Genuine captured shape: no `repo: ` line, ever. +print("repo: " + json.dumps(worktree)) print("count: 10 of 11 total") print("runs[10]{id,branch,status,head,pr}:") for i in range(10): @@ -3414,18 +3632,96 @@ SH pass 'the capped inventory reader is bounded by the crew read budget' } -# Repo identity is looked up by the exact recorded `working_path`; a worktree -# spelled differently from the registered row is not guessed at, and reads as -# an unreadable inventory that still names every candidate run id. -test_capped_inventory_requires_exact_worktree_path() { +# Repo identity is the overview's own `repo:` line matched exactly against the +# recorded `working_path`; a spelling the inventory does not record is not +# guessed at, and reads as an unreadable inventory that still names every +# candidate run id. +test_capped_inventory_requires_exact_repo_path() { make_capped_runs_case capped-noncanonical running pending hidden local d=$TMP_ROOT/capped-noncanonical out - fm_write_meta "$d/state/competing.meta" "window=fm:fm-competing" "worktree=$d/wt/./" "kind=ship" + FM_FAKE_AXI_HOME=$(printf '%s\n' "$FM_FAKE_AXI_HOME" | sed "s|^repo: .*|repo: \"$d/wt/./\"|") out=$(run_crew_state "$d" competing) - assert_contains "$out" 'state: unknown' 'an unmatched worktree spelling cannot establish a verdict' + assert_contains "$out" 'state: unknown' 'an unmatched repo spelling cannot establish a verdict' assert_contains "$out" 'unreadable' 'an unmatched repo lookup reports the inventory unreadable' + assert_contains "$out" '01NEW' 'an unmatched repo lookup still names the candidate run' assert_not_contains "$out" 'absent' 'an unmatched repo lookup never reads as a branch without runs' - pass 'a worktree spelling the inventory does not record reads unreadable' + pass 'a repo spelling the inventory does not record reads unreadable' +} + +# The 2026-09-22 PR #5317 shape on no-mistakes v1.79.0. A task copy is a linked +# git worktree of its home clone, and the CLI registers the repository once, by +# the clone's path, which the overview reports as `repo:`. Past ten runs the +# overview is capped, so selection goes through the inventory reader, which must +# key on that `repo:` line: keyed on the task worktree path it matched no row and +# every read reported the inventory unreadable. The run is in ci merge +# monitoring with every check green, and main advanced while it waited for the +# merge, so its ci log ends in re-arm lines. It must read as a green PR held for +# the merge decision, naming the PR, rather than unknown or still validating. +test_linked_worktree_green_merge_monitoring_reads_held_for_merge() { + reset_fakes + local d out overview + d=$(new_case linked-worktree-green) + mkdir -p "$d/clone" + git -C "$d/clone" init -q + git -C "$d/clone" commit -q --allow-empty -m init + git -C "$d/clone" worktree add -q -b fm/feat-green "$d/wt" + FM_FAKE_RUN_HEAD=$(git -C "$d/wt" rev-parse HEAD) + export FM_FAKE_RUN_HEAD + make_fakebin "$d" >/dev/null + fm_write_meta "$d/state/feat-green.meta" "window=fm:fm-feat-green" "worktree=$d/wt" "kind=ship" + NM_HOME="$d/nm" + mkdir -p "$NM_HOME" + overview=$(python3 - "$NM_HOME/state.sqlite" "$d/clone" "$FM_FAKE_RUN_HEAD" <<'PY' +import json +import sqlite3 +import sys + +database, clone, head = sys.argv[1:] +pr = "https://github.com/o/r/pull/2" +with sqlite3.connect(database) as db: + db.executescript(""" + CREATE TABLE repos (id TEXT PRIMARY KEY, working_path TEXT NOT NULL UNIQUE); + CREATE TABLE runs (id TEXT PRIMARY KEY, repo_id TEXT NOT NULL, branch TEXT NOT NULL, + status TEXT NOT NULL, head_sha TEXT NOT NULL, created_at INTEGER NOT NULL); + """) + db.execute("INSERT INTO repos VALUES ('repo', ?)", (clone,)) + db.execute("INSERT INTO runs VALUES ('01GREEN', 'repo', 'fm/feat-green', 'running', ?, 100)", (head,)) + db.executemany("INSERT INTO runs VALUES (?, ?, ?, ?, ?, ?)", + [("01DONE%02d" % i, "repo", "fm/done-%d" % i, "completed", head, i) + for i in range(11)]) +print("repo: " + json.dumps(clone)) +print("current_branch: fm/feat-green") +print("daemon: running") +print("count: 10 of 12 total") +print("runs[10]{id,branch,status,head,pr}:") +print(' "01GREEN",fm/feat-green,running,%s,"%s"' % (head[:8], pr)) +for i in reversed(range(2, 11)): + print(' "01DONE%02d",fm/done-%d,completed,%s,""' % (i, i, head[:8])) +PY +) || fail 'could not create the linked-worktree run inventory fixture' + # Guard the divergence this case exists for, so it cannot go vacuous. + [ "$(git -C "$d/wt" rev-parse --show-toplevel)" != "$(git -C "$d/clone" rev-parse --show-toplevel)" ] \ + || fail 'the fixture task copy must not be the registered clone' + assert_contains "$overview" 'count: 10 of 12 total' 'the fixture overview must be capped' + FM_FAKE_AXI_HOME=$overview + FM_FAKE_AXI_STATUS="$(run_ci_monitoring fm/feat-green | sed 's/01RUN/01GREEN/')" + FM_FAKE_AXI_STATUS_RUN=$FM_FAKE_AXI_STATUS + FM_FAKE_CI_LOGS=$(cat <<'EOF' +monitoring CI for PR #2 (timeout: 4h0m0s)... +CI checks running, waiting for results... +all CI checks passed - still monitoring until merged or closed +base branch advanced (f9f74a1d91cc..6f0f139962ea), re-arming CI monitor timeout +base branch advanced (6f0f139962ea..c5131a33a1b2), re-arming CI monitor timeout +EOF +) + out=$(run_crew_state "$d" feat-green) + assert_not_contains "$out" 'unreadable' 'a linked worktree reads its run through the repo line' + assert_not_contains "$out" 'state: unknown' 'a green PR in merge monitoring is never unknown' + assert_contains "$out" 'state: done' 'a green PR in merge monitoring reads done' + assert_contains "$out" 'source: run-step' 'the green reading comes from the selected run' + assert_contains "$out" 'checks green: PR ready for review' 'the reading is held for the merge decision' + assert_contains "$out" 'https://github.com/o/r/pull/2' 'the reading names the PR to ask about' + pass 'a linked worktree green PR in merge monitoring reads held for merge' } test_capped_replacement_keeps_gate_and_inventory_unchanged() { @@ -3448,7 +3744,7 @@ test_capped_replacement_keeps_gate_and_inventory_unchanged() { test_capped_inventory_failures_report_unknown() { local mode rc=0 overview - for mode in missing corrupt schema repo count; do + for mode in missing corrupt schema repo count norepo; do ( make_capped_runs_case "capped-unreadable-$mode" running running d=$TMP_ROOT/capped-unreadable-$mode @@ -3468,6 +3764,7 @@ with sqlite3.connect(sys.argv[1]) as db: PY ;; count) overview=$(printf '%s\n' "$overview" | sed '/^count:/d') ;; + norepo) overview=$(printf '%s\n' "$overview" | sed '/^repo:/d') ;; esac out=$(FM_FAKE_AXI_HOME="$overview" run_crew_state "$d" competing) assert_contains "$out" 'state: unknown' "$mode cannot fall back to a confident verdict from capped rows" @@ -4840,7 +5137,8 @@ test_ci_ready_done_log_beats_monitoring_run test_ci_monitoring_checks_green_surfaces_done test_top_level_ci_checks_green_surfaces_done test_ci_monitoring_no_checks_terminal_surfaces_done -test_ci_monitoring_green_then_rearm_stays_working +test_ci_monitoring_green_then_rearm_stays_green +test_ci_monitoring_green_before_log_tail_stays_green test_ci_monitoring_no_checks_yet_stays_working test_ci_monitoring_still_waiting_stays_working test_ci_monitoring_green_then_new_issue_stays_working @@ -4849,6 +5147,7 @@ test_ci_fixing_after_green_stays_working test_top_level_fixing_ci_running_after_green_stays_working test_top_level_fixing_done_log_stays_working test_terminal_passed +test_terminal_passed_with_override test_terminal_passed_uses_matching_retirement_receipt_without_forge test_terminal_passed_no_forge_switch_skips_read_but_keeps_receipt test_terminal_passed_with_open_pr_does_not_claim_merged @@ -4874,7 +5173,12 @@ test_unknown_status_row_keeps_newest_first_precedence test_terminal_run_without_live_sibling_is_unchanged test_coarse_run_does_not_probe_other_branch_ci_log_for_ready_status test_other_branch_run_ignored +test_unpushed_ship_done_is_blocked +test_merged_pr_reads_done_under_captured_meta +test_no_mistakes_prevalidation_done_stays_done +test_moved_remote_branch_without_named_head_is_blocked test_no_run_busy_pane +test_no_run_launch_prompt_parked_is_not_working test_no_run_footer_text_alone_is_not_working test_no_run_grok_uses_isolated_fallback test_no_run_herdr_unknown_uses_backend_capture @@ -4924,10 +5228,11 @@ test_no_run_herdr_stale_registration_over_shell_reads_agent_gone test_no_run_herdr_stale_working_record_is_never_busy test_capped_competing_live_runs_report_both_ids test_capped_overview_without_branch_rows_reports_both_ids -test_capped_overview_without_repo_line_and_no_runs_reports_absent +test_capped_overview_with_no_branch_runs_reports_absent test_no_branch_run_beside_a_live_run_elsewhere_reads_absent test_capped_inventory_reader_is_time_bounded -test_capped_inventory_requires_exact_worktree_path +test_capped_inventory_requires_exact_repo_path +test_linked_worktree_green_merge_monitoring_reads_held_for_merge test_capped_replacement_keeps_gate_and_inventory_unchanged test_capped_inventory_failures_report_unknown test_complete_inventory_ignores_unrelated_semantics diff --git a/tests/fm-dispatch-resolve.test.sh b/tests/fm-dispatch-resolve.test.sh index 411134ece50..32f39359c44 100755 --- a/tests/fm-dispatch-resolve.test.sh +++ b/tests/fm-dispatch-resolve.test.sh @@ -516,6 +516,133 @@ assert_contains "$out" ' reason: no rankable eligible candidate' "no-candidate assert_contains "$out" '-> not eligible: runway exhausted_now' "exhausted candidates keep their reason" pass "no rankable candidate: the tool escalates instead of guessing" +# --- schema 6: rows keyed by provider + accountKey bind per account ---------------- +# quota-axi emits schema 6 once a provider expands to several accounts; every +# row then carries accountKey and one provider id may appear on several rows. +# Native Codex and Pi lanes bind to their own account rows, with no row +# chosen by position or summed across accounts. +LANE_RULES="$TMP_ROOT/lane-rules.json" +SCHEMA6="$TMP_ROOT/schema6.json" +SCHEMA5_PAIR="$TMP_ROOT/schema5-pair.json" +cat > "$LANE_RULES" <<'JSON' +{ + "rules": [ + { + "when": "Codex work.", + "use": [ + { "harness": "pi", "model": "openai-codex-work/gpt-5.6-terra", "provider": "codex" }, + { "harness": "pi", "model": "openai-codex/gpt-5.6-sol", "provider": "codex" }, + { "harness": "codex", "model": "gpt-5.6-sol" } + ] + } + ] +} +JSON +cat > "$SCHEMA6" <<'JSON' +{ + "generatedAt": "2030-01-01T00:00:00Z", + "schemaVersion": 6, + "providers": [ + { "provider": "claude", "accountKey": "default", "quotaSemantics": { "status": "unknown", "effectiveAvailability": [] } }, + { "provider": "codex", "accountKey": "openai-codex", "quotaSemantics": { "status": "known", "effectiveAvailability": [ + { "scope": "all_models", "status": "known", "effectivePercentRemaining": 0, "runway": { "status": "exhausted_now" }, "selection": { "spendPriority": -1.4788 } } ] } }, + { "provider": "codex", "accountKey": "openai-codex-work", "quotaSemantics": { "status": "known", "effectiveAvailability": [ + { "scope": "all_models", "status": "known", "effectivePercentRemaining": 11, "runway": { "status": "projected_exhaustion" }, "selection": { "spendPriority": -5.6819 } } ] } }, + { "provider": "cursor", "accountKey": "default", "quotaSemantics": { "status": "known", "effectiveAvailability": [ + { "scope": "all_models", "status": "known", "effectivePercentRemaining": 24, "runway": { "status": "projected_exhaustion" }, "selection": { "spendPriority": 0.3917 } } ] } } + ] +} +JSON +cat > "$RESPONSE" <<'JSON' +{ "model": "jev-1.13.0", + "answers": { "rule": { "type": "choice", "choice": "rule_1", "confidence": 0.9, + "probabilities": { "rule_1": 0.97, "default": 0.03 } } }, + "usage": { "input_tokens": 812, "output_tokens": 60 } } +JSON +cp "$LANE_RULES" "$RULES" +reset_log +TYPESAFE_API_KEY=$KEY QUOTA_AXI_FIXTURE="$SCHEMA6" run code out err "$BRIEF" +expect_code 0 "$code" "schema 6 snapshot exits 0" +assert_contains "$out" ' status: clear' "schema 6 snapshot resolves" +assert_contains "$out" 'candidate: pi:openai-codex-work/gpt-5.6-terra provider=codex scope=all_models remaining=11% spendPriority=-5.6819 runway=projected_exhaustion -> eligible' "a Pi lane binds to its own account row" +assert_contains "$out" 'candidate: pi:openai-codex/gpt-5.6-sol provider=codex scope=all_models remaining=0% spendPriority=- runway=exhausted_now -> not eligible: runway exhausted_now at all_models' "the sibling lane reads its own exhausted row" +assert_contains "$out" 'candidate: codex:gpt-5.6-sol provider=codex -> eligible, unranked: provider codex has no quota row for account codex-home: disclosed uncertainty' "native Codex never infers an account from a Pi lane" +assert_contains "$out" " profile: --harness 'pi' --model 'openai-codex-work/gpt-5.6-terra'" "the lane with headroom is chosen" +assert_equals '--json' "$(cat "$LOG/quota-axi.calls")" "schema 6 needs one quota-axi --json read" + +SCHEMA6_NATIVE="$TMP_ROOT/schema6-native.json" +jq ' + .providers |= map(if .provider == "codex" then + .quotaSemantics.effectiveAvailability |= map(.effectivePercentRemaining = 0 | .runway.status = "exhausted_now") + else . end) | + (.providers[] | select(.accountKey == "openai-codex-work")) as $account | + .providers += [($account | .accountKey = "default"), + ($account | .accountKey = "codex-home" | + .quotaSemantics.effectiveAvailability |= map( + .effectivePercentRemaining = 80 | .runway.status = "through_reset" | .selection.spendPriority = 0.8))] +' "$SCHEMA6" > "$SCHEMA6_NATIVE" +reset_log +TYPESAFE_API_KEY=$KEY QUOTA_AXI_FIXTURE="$SCHEMA6_NATIVE" run code out err "$BRIEF" +expect_code 0 "$code" "native Codex schema 6 snapshot exits 0" +assert_contains "$out" ' status: clear' "native Codex headroom resolves despite exhausted Pi and default rows" +assert_contains "$out" 'candidate: codex:gpt-5.6-sol provider=codex scope=all_models remaining=80% spendPriority=0.8 runway=through_reset -> eligible' "native Codex reads codex-home" +assert_contains "$out" " profile: --harness 'codex' --model 'gpt-5.6-sol'" "native Codex headroom is chosen" + +jq '.providers |= reverse' "$SCHEMA6_NATIVE" > "$TMP_ROOT/schema6-reversed.json" +reset_log +TYPESAFE_API_KEY=$KEY QUOTA_AXI_FIXTURE="$TMP_ROOT/schema6-reversed.json" run code out err "$BRIEF" +assert_contains "$out" " profile: --harness 'codex' --model 'gpt-5.6-sol'" "native Codex selection ignores row order" + +jq '.providers |= map(select(.provider != "codex" or .accountKey != "default") | + if .accountKey == "codex-home" then .accountKey = "default" else . end)' "$SCHEMA6_NATIVE" > "$TMP_ROOT/schema6-default.json" +reset_log +TYPESAFE_API_KEY=$KEY QUOTA_AXI_FIXTURE="$TMP_ROOT/schema6-default.json" run code out err "$BRIEF" +assert_contains "$out" " profile: --harness 'codex' --model 'gpt-5.6-sol'" "native Codex falls back to the default row when codex-home is absent" +pass "native Codex binds to codex-home before default, independently of Pi accounts and row order" + +jq '.schemaVersion = 5 | .providers |= map(select(.accountKey != "openai-codex")) | del(.providers[].accountKey)' "$SCHEMA6" > "$SCHEMA5_PAIR" +reset_log +TYPESAFE_API_KEY=$KEY QUOTA_AXI_FIXTURE="$SCHEMA5_PAIR" run code out err "$BRIEF" +assert_contains "$out" ' status: escalate' "schema 5 keeps joining by provider alone" +assert_contains "$out" ' reason: genuine spendPriority tie' "every codex profile reads the one schema 5 codex row" +assert_contains "$out" 'candidate: codex:gpt-5.6-sol provider=codex scope=all_models remaining=11% spendPriority=-5.6819 runway=projected_exhaustion -> eligible' "a schema 5 row never needs accountKey" + +SCHEMA6_PI_NATIVE="$TMP_ROOT/schema6-pi-native.json" +jq '.providers |= map(select(.provider != "codex" or .accountKey != "default"))' "$SCHEMA6_NATIVE" > "$SCHEMA6_PI_NATIVE" +for harness in pi pi-signed; do + jq --arg harness "$harness" '.rules[0].use |= map(if .harness == "codex" then + {harness: $harness, model: "codex-native/gpt-6-astra", provider: "codex", effort: "ultra"} + else . end)' "$LANE_RULES" > "$RULES" + reset_log + TYPESAFE_API_KEY=$KEY QUOTA_AXI_FIXTURE="$SCHEMA6_PI_NATIVE" run code out err "$BRIEF" + expect_code 0 "$code" "$harness native adapter schema 6 exits 0" + assert_contains "$out" ' status: clear' "$harness native adapter resolves with codex-home and no default row" + assert_contains "$out" "candidate: $harness:codex-native/gpt-6-astra provider=codex scope=all_models remaining=80% spendPriority=0.8 runway=through_reset -> eligible" "$harness native adapter reads codex-home" + assert_contains "$out" " profile: --harness '$harness' --model 'codex-native/gpt-6-astra' --effort 'ultra'" "$harness native adapter is chosen over exhausted Pi accounts" + + reset_log + TYPESAFE_API_KEY=$KEY QUOTA_AXI_FIXTURE="$TMP_ROOT/schema6-default.json" run code out err "$BRIEF" + assert_contains "$out" " profile: --harness '$harness' --model 'codex-native/gpt-6-astra' --effort 'ultra'" "$harness native adapter falls back to default" + + reset_log + TYPESAFE_API_KEY=$KEY QUOTA_AXI_FIXTURE="$SCHEMA6" run code out err "$BRIEF" + assert_contains "$out" "candidate: $harness:codex-native/gpt-6-astra provider=codex -> eligible, unranked: provider codex has no quota row for account codex-home: disclosed uncertainty" "$harness native adapter never borrows a Pi account" + + reset_log + TYPESAFE_API_KEY=$KEY QUOTA_AXI_FIXTURE="$SCHEMA5_PAIR" run code out err "$BRIEF" + assert_contains "$out" "candidate: $harness:codex-native/gpt-6-astra provider=codex scope=all_models remaining=11% spendPriority=-5.6819 runway=projected_exhaustion -> eligible" "$harness native adapter still joins schema 5 by provider alone" +done +cp "$LANE_RULES" "$RULES" +pass "Pi native adapters bind to codex-home with existing fallbacks and schema 5 compatibility" + +jq 'del(.providers[1].accountKey)' "$SCHEMA6" > "$TMP_ROOT/schema6-keyless.json" +reset_log +TYPESAFE_API_KEY=$KEY QUOTA_AXI_FIXTURE="$TMP_ROOT/schema6-keyless.json" run code out err "$BRIEF" +assert_contains "$out" ' status: error' "a schema 6 row without accountKey is an error outcome" +assert_contains "$out" ' reason: quota-axi --json returned an invalid snapshot' "keyless schema 6 row is named as an invalid snapshot" +cp "$BASE_RULES" "$RULES" +pass "schema 6: each candidate binds to its account row; schema 5 is unchanged" + # --- quota-axi is read exactly once -------------------------------------------- reset_log write_response "$RESPONSE" rule_4 0.9 diff --git a/tests/fm-dod-lib.test.sh b/tests/fm-dod-lib.test.sh new file mode 100644 index 00000000000..91424c47e69 --- /dev/null +++ b/tests/fm-dod-lib.test.sh @@ -0,0 +1,323 @@ +#!/usr/bin/env bash +# Behavior tests for bin/fm-dod-lib.sh's named-head reachability gate on ship +# done: acceptance (issue 4768). The gate must test the commit the worker names, +# not merely that some remote-tracking branch exists or moved. +set -u + +# shellcheck source=tests/lib.sh +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" +# shellcheck source=bin/fm-dod-lib.sh +. "$ROOT/bin/fm-dod-lib.sh" + +TMP_ROOT=$(fm_test_tmproot fm-dod-lib) +fm_git_identity fmtest fmtest@example.invalid + +accept_done() { # <kind> <mode> <worktree> <project> <line> [<state> <id> <meta>] + fm_dod_accept_ship_done "$@" +} + +write_merge_marker() { # <state> <id> <provider> <host> <path> <number> + printf '%s\n' fm-pr-poll-merge-notified-v1 "$3" "$4" "$5" "$6" > "$1/$2.pr-poll-merge-notified" + chmod 600 "$1/$2.pr-poll-merge-notified" +} + +test_scout_done_is_not_gated() { + local repo wt + repo="$TMP_ROOT/scout-repo" + wt="$TMP_ROOT/scout-wt" + fm_git_worktree "$repo" "$wt" fm/scout + git -C "$wt" commit -q --allow-empty -m 'only in the disposable copy' + accept_done scout no-mistakes "$wt" "$repo" 'done: report written' \ + || fail "scout done: must not require named-head reachability outside the copy" + pass "scout done: is not gated" +} + +test_unpushed_ship_done_is_refused() { + local repo wt sha reason rc + repo="$TMP_ROOT/unpushed-repo" + wt="$TMP_ROOT/unpushed-wt" + fm_git_worktree "$repo" "$wt" fm/unpushed + git -C "$wt" commit -q --allow-empty -m 'fix only in the worktree' + sha=$(git -C "$wt" rev-parse HEAD) + reason=$(accept_done ship no-mistakes "$wt" "$repo" "done: PR https://example.test/o/r/pull/1 checks green") + rc=$? + [ "$rc" -eq 1 ] || fail "unpushed ship done: was accepted (exit $rc)" + case "$reason" in + *"named head $sha is unreachable outside the worker copy") ;; + *) fail "unpushed refusal did not name the commit: $reason" ;; + esac + pass "unpushed ship done: is refused" +} + +test_remote_containing_named_head_is_accepted() { + local repo wt sha + repo="$TMP_ROOT/pushed-repo" + wt="$TMP_ROOT/pushed-wt" + fm_git_worktree "$repo" "$wt" fm/pushed + git -C "$wt" commit -q --allow-empty -m 'fix on the branch' + sha=$(git -C "$wt" rev-parse HEAD) + git -C "$wt" update-ref refs/remotes/origin/fm/pushed "$sha" + accept_done ship no-mistakes "$wt" "$repo" "done: PR https://example.test/o/r/pull/2 checks green" \ + || fail "named head on a remote-tracking ref was refused" + pass "named head on a remote-tracking ref is accepted" +} + +test_moved_branch_without_named_head_is_refused() { + local repo wt main_sha fix_sha reason rc + repo="$TMP_ROOT/moved-repo" + wt="$TMP_ROOT/moved-wt" + fm_git_worktree "$repo" "$wt" fm/moved + main_sha=$(git -C "$repo" rev-parse main) + git -C "$wt" commit -q --allow-empty -m 'the actual fix' + fix_sha=$(git -C "$wt" rev-parse HEAD) + # The fork branch exists and moved, but only to a merge of the default + # branch: reachability of that branch is not reachability of the named head. + git -C "$wt" update-ref refs/remotes/origin/fm/moved "$main_sha" + reason=$(accept_done ship no-mistakes "$wt" "$repo" "done: PR https://example.test/o/r/pull/3 checks green") + rc=$? + [ "$rc" -eq 1 ] || fail "moved remote branch without the named head was accepted" + case "$reason" in + *"named head $fix_sha is unreachable outside the worker copy") ;; + *) fail "moved-branch refusal did not name the fix commit: $reason" ;; + esac + pass "a moved remote branch that lacks the named head is refused" +} + +test_no_mistakes_prevalidation_done_is_not_gated() { + local repo wt + repo="$TMP_ROOT/preval-repo" + wt="$TMP_ROOT/preval-wt" + fm_git_worktree "$repo" "$wt" fm/preval + git -C "$wt" commit -q --allow-empty -m 'only in the disposable copy' + accept_done ship no-mistakes "$wt" "$repo" 'done: implementation complete' \ + || fail "no-mistakes pre-validation done: must not require named-head reachability" + pass "no-mistakes pre-validation done: is not gated" +} + +test_local_only_linked_branch_is_accepted() { + local repo wt + repo="$TMP_ROOT/local-repo" + wt="$TMP_ROOT/local-wt" + fm_git_worktree "$repo" "$wt" fm/local + git -C "$wt" commit -q --allow-empty -m 'local-only work' + accept_done ship local-only "$wt" "$repo" "done: ready in branch fm/local" \ + || fail "local-only named branch in a linked worktree was refused" + pass "local-only linked named branch is reachable from the project clone" +} + +test_local_only_detached_head_is_refused() { + local repo wt sha rc + repo="$TMP_ROOT/detach-repo" + wt="$TMP_ROOT/detach-wt" + fm_git_worktree "$repo" "$wt" fm/detach + git -C "$wt" commit -q --allow-empty -m 'detached only' + sha=$(git -C "$wt" rev-parse HEAD) + git -C "$wt" checkout -q --detach HEAD + git -C "$wt" branch -q -D fm/detach + accept_done ship local-only "$wt" "$repo" "done: ready in branch fm/detach" >/dev/null \ + && fail "detached local-only head whose branch was deleted was accepted" + rc=0 + accept_done ship local-only "$wt" "$repo" "done: implementation complete" >/dev/null || rc=$? + [ "$rc" -eq 1 ] || fail "detached local-only HEAD was accepted as done" + pass "local-only detached HEAD only in the disposable copy is refused" +} + +test_standalone_local_only_needs_project_ref() { + local repo wt sha + repo="$TMP_ROOT/stand-project" + wt="$TMP_ROOT/stand-copy" + fm_git_init_commit "$repo" + git clone --quiet "$repo" "$wt" + git -C "$wt" checkout -q -b fm/stand + git -C "$wt" commit -q --allow-empty -m 'only in the standalone copy' + sha=$(git -C "$wt" rev-parse HEAD) + accept_done ship local-only "$wt" "$repo" "done: ready in branch fm/stand" >/dev/null \ + && fail "standalone local-only copy was accepted without the named head in the project clone" + git -C "$repo" fetch -q "$wt" "fm/stand:fm/stand" + [ "$(git -C "$repo" rev-parse fm/stand)" = "$sha" ] \ + || fail "project clone did not gain the named head" + accept_done ship local-only "$wt" "$repo" "done: ready in branch fm/stand" \ + || fail "standalone local-only named head present in the project clone was refused" + pass "standalone local-only done: requires the named head in the project clone" +} + +test_free_text_sha_is_not_the_named_head() { + local repo wt old new reason rc + repo="$TMP_ROOT/hex-repo" + wt="$TMP_ROOT/hex-wt" + fm_git_worktree "$repo" "$wt" fm/hex + old=$(git -C "$wt" rev-parse HEAD) + git -C "$wt" update-ref refs/remotes/origin/main "$old" + git -C "$wt" commit -q --allow-empty -m 'actual fix' + new=$(git -C "$wt" rev-parse HEAD) + reason=$(accept_done ship direct-PR "$wt" "$repo" "done: reverted $old and fixed the retry") + rc=$? + [ "$rc" -eq 1 ] || fail "free-text SHA on origin/main made an unpushed HEAD accept" + case "$reason" in + *"named head $new is unreachable outside the worker copy") ;; + *) fail "free-text SHA scan still selected the old commit: $reason" ;; + esac + pass "a 40-hex token in the note is not the named head" +} + +test_recorded_merged_pr_is_landed_after_prune() { + local repo wt meta state + repo="$TMP_ROOT/merged-repo" + wt="$TMP_ROOT/merged-wt" + state="$TMP_ROOT/merged-state" + mkdir -p "$state" + fm_git_worktree "$repo" "$wt" fm/merged + git -C "$wt" commit -q --allow-empty -m 'fix, squash-merged and branch pruned' + meta="$state/merged.meta" + printf 'kind=ship\nmode=direct-PR\nworktree=%s\nproject=%s\npr=https://github.com/o/r/pull/7\n' \ + "$wt" "$repo" > "$meta" + write_merge_marker "$state" merged github github.com o/r 7 + accept_done ship direct-PR "$wt" "$repo" "done: PR https://github.com/o/r/pull/7" "$state" merged "$meta" \ + || fail "recorded merged PR was refused after its remote-tracking ref was pruned" + pass "a recorded merged PR satisfies the gate after prune" +} + +test_merge_marker_binds_to_the_named_pr() { + local repo wt meta state reason rc sha + repo="$TMP_ROOT/bind-repo" + wt="$TMP_ROOT/bind-wt" + state="$TMP_ROOT/bind-state" + mkdir -p "$state" + fm_git_worktree "$repo" "$wt" fm/bind + git -C "$wt" commit -q --allow-empty -m 'second PR head, never pushed' + sha=$(git -C "$wt" rev-parse HEAD) + meta="$state/bind.meta" + printf 'kind=ship\nmode=direct-PR\nworktree=%s\nproject=%s\npr=https://github.com/o/r/pull/7\n' \ + "$wt" "$repo" > "$meta" + write_merge_marker "$state" bind github github.com o/r 7 + reason=$(accept_done ship direct-PR "$wt" "$repo" "done: PR https://github.com/o/r/pull/9" "$state" bind "$meta") + rc=$? + [ "$rc" -eq 1 ] || fail "merge of recorded PR 7 accepted an unpushed done naming PR 9" + case "$reason" in + *"named head $sha is unreachable outside the worker copy") ;; + *) fail "PR 9 refusal did not name the unpushed head: $reason" ;; + esac + write_merge_marker "$state" bind github github.com other/r 7 + accept_done ship direct-PR "$wt" "$repo" "done: PR https://github.com/o/r/pull/7" "$state" bind "$meta" >/dev/null \ + && fail "merge marker for another repository's PR 7 was accepted" + pass "the merged-PR short-circuit applies only to the recorded PR the done line names" +} + +test_forge_recorded_head_is_accepted_without_local_object() { + local repo wt meta state forge_head + repo="$TMP_ROOT/forge-repo" + wt="$TMP_ROOT/forge-wt" + state="$TMP_ROOT/forge-state" + mkdir -p "$state" + fm_git_worktree "$repo" "$wt" fm/forge + git -C "$wt" commit -q --allow-empty -m 'worker head, not pushed from this copy' + # The pipeline's own commit: on the forge and in the gate repo, never + # fetched into the worker clone. + forge_head=0123456789abcdef0123456789abcdef01234567 + meta="$state/forge.meta" + printf 'kind=ship\nmode=no-mistakes\nworktree=%s\nproject=%s\npr=https://github.com/o/r/pull/5\npr_head=%s\n' \ + "$wt" "$repo" "$forge_head" > "$meta" + accept_done ship no-mistakes "$wt" "$repo" "done: PR https://github.com/o/r/pull/5 checks green" \ + "$state" forge "$meta" \ + || fail "forge-recorded pr_head the worker clone never fetched was refused" + accept_done ship no-mistakes "$wt" "$repo" "done: PR https://github.com/o/r/pull/6 checks green" \ + "$state" forge "$meta" >/dev/null \ + && fail "pr_head recorded for PR 5 was accepted for a done naming PR 6" + pass "a forge-recorded head for the named PR is accepted without a local object" +} + +# A direct-PR worker pushes from its own copy: a commit made after the PR's +# recorded head, never pushed, is the named head and is refused. +test_direct_pr_recorded_head_does_not_cover_unpushed_commit() { + local repo wt meta state pushed later reason rc + repo="$TMP_ROOT/postopen-repo" + wt="$TMP_ROOT/postopen-wt" + state="$TMP_ROOT/postopen-state" + mkdir -p "$state" + fm_git_worktree "$repo" "$wt" fm/postopen + git -C "$wt" commit -q --allow-empty -m 'pushed when the PR opened' + pushed=$(git -C "$wt" rev-parse HEAD) + git -C "$wt" update-ref refs/remotes/origin/fm/postopen "$pushed" + git -C "$wt" commit -q --allow-empty -m 'the fix, only in the worktree' + later=$(git -C "$wt" rev-parse HEAD) + meta="$state/postopen.meta" + printf 'kind=ship\nmode=direct-PR\nworktree=%s\nproject=%s\npr=https://github.com/o/r/pull/5\npr_head=%s\n' \ + "$wt" "$repo" "$pushed" > "$meta" + reason=$(accept_done ship direct-PR "$wt" "$repo" "done: PR https://github.com/o/r/pull/5" "$state" postopen "$meta") + rc=$? + [ "$rc" -eq 1 ] || fail "direct-PR recorded pr_head accepted an unpushed later commit" + case "$reason" in + *"named head $later is unreachable outside the worker copy") ;; + *) fail "direct-PR refusal did not name the unpushed commit: $reason" ;; + esac + pass "a direct-PR recorded head does not cover a later unpushed commit" +} + +test_ci_ready_variants_are_gated() { + local repo wt line rc + repo="$TMP_ROOT/variant-repo" + wt="$TMP_ROOT/variant-wt" + fm_git_worktree "$repo" "$wt" fm/variant + git -C "$wt" commit -q --allow-empty -m 'only in the disposable copy' + for line in \ + 'done: PR https://github.com/o/r/pull/5 checks green, risk low' \ + 'done: PR https://github.com/o/r/pull/5 - checks green' \ + 'done: PR https://github.com/o/r/pull/5 checks green.' \ + 'done: PR https://github.com/o/r/pull/5 (checks green)'; do + rc=0 + accept_done ship no-mistakes "$wt" "$repo" "$line" >/dev/null || rc=$? + [ "$rc" -eq 1 ] || fail "no-mistakes CI-ready variant skipped the gate: $line" + done + pass "no-mistakes CI-ready done: with extra text is gated" +} + +test_keyed_and_spaced_done_lines_are_gated() { + local repo wt line mode rc + repo="$TMP_ROOT/keyed-repo" + wt="$TMP_ROOT/keyed-wt" + fm_git_worktree "$repo" "$wt" fm/keyed + git -C "$wt" commit -q --allow-empty -m 'only in the disposable copy' + for line in \ + 'no-mistakes|done [key=fix]: PR https://github.com/o/r/pull/5 checks green' \ + 'no-mistakes|done : PR https://github.com/o/r/pull/5 checks green' \ + 'direct-PR|done [key=fix]: PR https://github.com/o/r/pull/5' \ + 'direct-PR|done: [key=fix] PR https://github.com/o/r/pull/5'; do + mode=${line%%|*} + rc=0 + accept_done ship "$mode" "$wt" "$repo" "${line#*|}" >/dev/null || rc=$? + [ "$rc" -eq 1 ] || fail "$mode done line skipped the gate: ${line#*|}" + done + pass "keyed and spaced ship done: lines are gated" +} + +test_non_done_lines_are_not_gated() { + local repo wt + repo="$TMP_ROOT/nongate-repo" + wt="$TMP_ROOT/nongate-wt" + fm_git_worktree "$repo" "$wt" fm/nongate + git -C "$wt" commit -q --allow-empty -m 'unpushed' + accept_done ship no-mistakes "$wt" "$repo" 'working: still implementing' \ + || fail "working: line was gated" + accept_done ship no-mistakes "$wt" "$repo" 'blocked: waiting on a credential' \ + || fail "blocked: line was gated" + pass "non-done lines are not gated" +} + +test_scout_done_is_not_gated +test_unpushed_ship_done_is_refused +test_no_mistakes_prevalidation_done_is_not_gated +test_remote_containing_named_head_is_accepted +test_moved_branch_without_named_head_is_refused +test_free_text_sha_is_not_the_named_head +test_recorded_merged_pr_is_landed_after_prune +test_merge_marker_binds_to_the_named_pr +test_forge_recorded_head_is_accepted_without_local_object +test_direct_pr_recorded_head_does_not_cover_unpushed_commit +test_ci_ready_variants_are_gated +test_keyed_and_spaced_done_lines_are_gated +test_local_only_linked_branch_is_accepted +test_local_only_detached_head_is_refused +test_standalone_local_only_needs_project_ref +test_non_done_lines_are_not_gated + +echo "all fm-dod-lib tests passed" diff --git a/tests/fm-gotmp.test.sh b/tests/fm-gotmp.test.sh index 2555e85f1b5..d3337fbc57c 100755 --- a/tests/fm-gotmp.test.sh +++ b/tests/fm-gotmp.test.sh @@ -109,7 +109,7 @@ SH # fused backlog close is skipped and the follow-up echo takes the plain-message # path; there is no tasks-axi and no backlog in this fixture. cat > "$fake/bin/fm-tasks-axi-lib.sh" <<'SH' -FM_TASKS_AXI_MIN=0.2.4 +FM_TASKS_AXI_MIN=0.2.6 fm_tasks_axi_backend() { printf 'markdown\n'; } fm_tasks_axi_backend_available() { return 1; } fm_tasks_axi_compatible() { return 1; } @@ -202,7 +202,7 @@ exit 0 SH chmod +x "$fake/bin/fm-fleet-sync.sh" cat > "$fake/bin/fm-tasks-axi-lib.sh" <<'SH' -FM_TASKS_AXI_MIN=0.2.4 +FM_TASKS_AXI_MIN=0.2.6 fm_tasks_axi_backend() { printf 'markdown\n'; } fm_tasks_axi_backend_available() { return 1; } fm_tasks_axi_compatible() { return 1; } diff --git a/tests/fm-guard-stale-banner.test.sh b/tests/fm-guard-stale-banner.test.sh index 5f06c15f739..e5bfb9e8762 100755 --- a/tests/fm-guard-stale-banner.test.sh +++ b/tests/fm-guard-stale-banner.test.sh @@ -143,7 +143,11 @@ record_pi_extension_session() { version=$(FM_STATE_OVERRIDE="$home/state" bash -c '. "$1"; fm_pi_extension_version "$2"' \ _ "$ROOT/bin/fm-wake-lib.sh" "$root/.pi/extensions/$source") || return 1 fi - printf '%s\n%s\n' "$version" "$session_pid" > "$home/state/$marker" + if [ "${pair##*:}" = watch ]; then + printf '%s\n%s\ngeneration=1 phase=active\n' "$version" "$session_pid" > "$home/state/$marker" + else + printf '%s\n%s\n' "$version" "$session_pid" > "$home/state/$marker" + fi done [ -n "$session_pid" ] && printf '%s\n' "$session_pid" > "$home/state/.lock" return 0 @@ -713,7 +717,9 @@ test_extension_ownership_needs_every_signal() { "missing-watch-marker:live:watch:" \ "missing-turnend-marker:live:turnend:" \ "drifted-watch-build:live::watch" \ - "drifted-turnend-build:live::turnend"; do + "drifted-turnend-build:live::turnend" \ + "handoff-watch-generation:live::" \ + "legacy-watch-marker:live::"; do case_name=${spec%%:*} dir=$(make_guard_case "extension-$case_name") home=$(case_home "$dir") @@ -727,6 +733,20 @@ test_extension_ownership_needs_every_signal() { "$(printf '%s' "$spec" | cut -d: -f3)" \ "$(printf '%s' "$spec" | cut -d: -f4)" \ || fail "could not record the Pi extension session for $case_name" + case "$case_name" in + handoff-watch-generation) + head -n 2 "$home/state/.pi-watch-extension-loaded" \ + > "$home/state/.pi-watch-extension-loaded.tmp" + printf 'generation=1 phase=handoff\n' \ + >> "$home/state/.pi-watch-extension-loaded.tmp" + mv "$home/state/.pi-watch-extension-loaded.tmp" "$home/state/.pi-watch-extension-loaded" + ;; + legacy-watch-marker) + head -n 2 "$home/state/.pi-watch-extension-loaded" \ + > "$home/state/.pi-watch-extension-loaded.tmp" + mv "$home/state/.pi-watch-extension-loaded.tmp" "$home/state/.pi-watch-extension-loaded" + ;; + esac touch "$home/state/.last-watcher-beat" out=$(run_guard_case_extension "$dir") kill "$pid" 2>/dev/null || true diff --git a/tests/fm-inactive-reconcile.test.sh b/tests/fm-inactive-reconcile.test.sh index 720a75c7082..cc046380331 100755 --- a/tests/fm-inactive-reconcile.test.sh +++ b/tests/fm-inactive-reconcile.test.sh @@ -9,6 +9,7 @@ RECON="$ROOT/bin/fm-inactive-reconcile.sh" DRAIN="$ROOT/bin/fm-wake-drain.sh" WATCH="$ROOT/bin/fm-watch.sh" TMP_ROOT=$(fm_test_tmproot fm-inactive-reconcile) +fm_git_identity fmtest fmtest@example.invalid set_mtime() { # <epoch> <path> local epoch=$1 path=$2 stamp @@ -80,11 +81,17 @@ EOF } write_child() { # <home> <id> <status> [spawn-gen] - local home=$1 id=$2 status=$3 spawn_gen=${4:-s${BASHPID:-$$}.$RANDOM} + local home=$1 id=$2 status=$3 spawn_gen=${4:-s${BASHPID:-$$}.$RANDOM} sha + mkdir -p "$home/projects/$id" + git -C "$home/projects/$id" init -q + git -C "$home/projects/$id" commit -q --allow-empty -m init + sha=$(git -C "$home/projects/$id" rev-parse HEAD) + git -C "$home/projects/$id" update-ref refs/remotes/origin/main "$sha" fm_write_meta "$home/state/$id.meta" \ - "window=firstmate:fm-$id" "worktree=$home/projects/$id" "project=alpha" \ + "window=firstmate:fm-$id" "worktree=$home/projects/$id" "project=$home/projects/$id" \ 'harness=codex' 'kind=ship' 'mode=no-mistakes' 'yolo=off' \ - "spawn_gen=$spawn_gen" 'pr=https://example.test/owner/repo/pull/1' + "spawn_gen=$spawn_gen" 'pr=https://example.test/owner/repo/pull/1' \ + "pr_head=$sha" printf '%s\n' "$status" > "$home/state/$id.status" : > "$home/state/$id.turn-ended" age "$home/state/$id.meta" "$home/state/$id.status" "$home/state/$id.turn-ended" @@ -164,6 +171,42 @@ test_main_direct_terminal_presentation_receipt() { pass "main direct terminal presentation has a durable receipt" } +# An unpushed CI-ready ship done: is not a parent-facing ready signal. The +# ledger pass reads the child's line before any PR is recorded for it, so the +# gate tests the worker copy's HEAD. +test_unpushed_ci_ready_done_is_not_published() { + make_world unpushed-ready; bind_secondmate local + write_child "$MATE" child 'done: PR https://example.test/owner/repo/pull/1 checks green, risk low' + git -C "$MATE/projects/child" commit -q --allow-empty -m 'only in the copy' + grep -v '^pr=\|^pr_head=' "$MATE/state/child.meta" > "$MATE/state/child.meta.tmp" + mv "$MATE/state/child.meta.tmp" "$MATE/state/child.meta" + FM_FAKE_CREW_STATE='unknown' run_reconcile "$MATE" + [ ! -s "$MAIN/state/mate.status" ] || fail "unpushed CI-ready done: was published upstream" + [ "$(outcome_count "$MATE" reported)" = 0 ] || fail "unpushed CI-ready done: left a delivery receipt" + pass "unpushed CI-ready ship done: is not published upstream" +} + +# The ledger pass runs on every poll, so a ship done: already delivered does +# not pay for the git reachability check again. +test_delivered_ledger_done_skips_git_gate() { + local real_git + make_world gate-once; bind_secondmate local + write_child "$MATE" child 'done: PR https://example.test/owner/repo/pull/2 checks green' + real_git=$(command -v git) + printf '#!/usr/bin/env bash\nprintf "%%s\\n" "$*" >> %q\nexec %q "$@"\n' \ + "$WORLD/git.log" "$real_git" > "$WORLD/fakebin/git" + chmod +x "$WORLD/fakebin/git" + FM_FAKE_CREW_STATE='unknown' run_reconcile "$MATE" + [ "$(outcome_count "$MATE" reported)" = 1 ] || fail "pushed CI-ready done: was not delivered" + [ -s "$WORLD/git.log" ] || fail "first delivery did not test the named head" + : > "$WORLD/git.log" + FM_FAKE_CREW_STATE='unknown' run_reconcile "$MATE" + [ ! -s "$WORLD/git.log" ] || fail "a poll after delivery re-ran the git gate: $(cat "$WORLD/git.log")" + [ "$(grep -c 'child-outcome-child-done' "$MAIN/state/mate.status")" = 1 ] \ + || fail "the delivered done: was published again" + pass "a delivered ship done: skips the git gate on later polls" +} + # A secondmate delivers a child's terminal ledger line to the parent on the # very next poll, from the ledger alone: no current-state read, no inactive # cadence, and no line appended by the mate model. The delivery carries the @@ -468,6 +511,26 @@ test_secondmate_remote_route_ledger_delivery() { pass "the remote route carries a child's ledger line once" } +# A ship done: the gate accepted stays owed while its parent write is pending. +# Teardown removes the worktree before `report`, so the retry delivers that +# line instead of re-testing a copy that no longer exists. +test_pending_ledger_done_is_delivered_after_worktree_removal() { + local key + make_world pending-retry; bind_secondmate local + write_child "$MATE" child 'done: PR https://example.test/owner/repo/pull/2 checks green' + cp "$MATE/.fm-secondmate-parent" "$WORLD/parent-binding" + printf 'schema=fm-secondmate-parent.v1\nroute=invalid\n' > "$MATE/.fm-secondmate-parent" + FM_FAKE_CREW_STATE='unknown' run_reconcile "$MATE" + [ "$(outcome_count "$MATE" pending)" = 1 ] || fail "failed parent write did not leave a pending delivery" + rm -rf "$MATE/projects/child" + cp "$WORLD/parent-binding" "$MATE/.fm-secondmate-parent" + run_report "$MATE" child || fail "report refused the pending delivery" + key=$(reported_outcome_key "$MATE" child 'done') || fail "pending delivery was dropped instead of reported" + sed -E 's/ \[at=[0-9]+\]//' "$MAIN/state/mate.status" | grep -Fq "done [key=$key]: child child done: PR https://example.test/owner/repo/pull/2 checks green" \ + || fail "report did not deliver the pending done after the worktree was removed" + pass "a pending ship done: is delivered by report after teardown removed the worktree" +} + # `report <child>` is the teardown-side delivery: it delivers or says nothing # is owed with 0, and returns non-zero only when the channel cannot be written. test_report_subcommand_delivers_and_refuses() { @@ -902,6 +965,8 @@ SH } test_main_direct_terminal_presentation_receipt +test_unpushed_ci_ready_done_is_not_published +test_delivered_ledger_done_skips_git_gate test_local_secondmate_delivers_terminal_ledger_line test_secondmate_multiline_terminal_outcome_is_delivered_once test_secondmate_unterminated_prose_reports_run_outcome @@ -915,6 +980,7 @@ test_long_terminal_lines_have_distinct_receipts test_secondmate_partial_ledger_line_waits_for_newline test_secondmate_remote_route_ledger_delivery test_report_subcommand_delivers_and_refuses +test_pending_ledger_done_is_delivered_after_worktree_removal test_report_avoids_scan_meta_lock_inversion test_local_secondmate_rejects_relative_parent_home test_invalid_secondmate_marker_blocks_routing diff --git a/tests/fm-launch-prompt-signals-live-e2e.test.sh b/tests/fm-launch-prompt-signals-live-e2e.test.sh new file mode 100644 index 00000000000..65009c14212 --- /dev/null +++ b/tests/fm-launch-prompt-signals-live-e2e.test.sh @@ -0,0 +1,205 @@ +#!/usr/bin/env bash +# Live guard for bin/fm-busy-lib.sh's launch-prompt backstop (live-harness-optin +# family). Per .agents/skills/firstmate-coding-guidelines "Harness-dependent +# checks", a classifier built on vendor-rendered dialog text must be proven +# against the REAL installed harness, because a stub can only confirm the +# assumption already written into the stub - and this guard exists because that +# assumption was wrong once already: an initial Pi signature, sourced only from +# the installed binary's own UI strings ("Project trust", an internal panel +# title never rendered as the dialog's own heading), silently never matched the +# real screen ("Trust project folder?") until this guard's first live run +# caught it. +# +# For each of claude, pi (covering pi-signed and omp, which share Pi's engine +# and trust gate), and gemini that is actually installed, this drives the REAL +# binary in an isolated tmux server into its genuine interactive launch prompt +# (a fresh untrusted worktree carrying a project-local trust-requiring +# resource for claude and pi, a fresh credential-less environment for gemini), +# captures the pane with the exact production shape (bin/fm-backend.sh's +# fm_backend_tmux_capture: `tmux capture-pane -p -S -40`), arms a scratch +# busy-state record exactly as fm-spawn.sh does at launch, and requires +# fm_busy_classify to report `unknown launch-prompt` instead of the record's +# seeded `busy fm-spawn`. No prompt is ever submitted and no dialog is ever +# answered (Escape only, never Enter), so no model tokens are spent and no +# operator credential store is written to. An absent harness binary is +# reported explicitly and skipped rather than silently passing over it; a run +# that checked nothing fails. +# +# Precondition: this machine's default `claude` config must already be past +# first-run onboarding (a subscription or API key already selected, and a +# theme already chosen) - the guard targets a brand-new SCRATCH WORKTREE under +# the operator's own already-onboarded config, exactly the shape a real +# crewmate spawn produces, never a fresh CLAUDE_CONFIG_DIR. An unonboarded +# machine reports that precondition explicitly rather than failing the +# signature. +# +# Run explicitly with FM_LAUNCH_PROMPT_SIGNALS_LIVE=1. Refresh +# docs/verification/runtime-backends.md ("Launch-prompt backstop signatures") +# from this guard's output after any of claude/pi/gemini upgrades. +set -u + +# shellcheck source=tests/lib.sh +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +REAL_TMUX=$(command -v tmux 2>/dev/null || true) +SOCKET="fm-launch-prompt-$$" +CHECKED=0 +LABS=() + +note() { printf '# %s\n' "$1"; } +pass() { printf 'ok - %s\n' "$1"; } + +cleanup_all() { + [ -z "${REAL_TMUX:-}" ] || "$REAL_TMUX" -L "$SOCKET" kill-server >/dev/null 2>&1 || true + local lab + for lab in "${LABS[@]:-}"; do + [ -z "$lab" ] || rm -rf -- "$lab" + done +} +trap cleanup_all EXIT + +fail() { printf 'not ok - %s\n' "$1" >&2; exit 1; } + +fm_live_gate opt-in FM_LAUNCH_PROMPT_SIGNALS_LIVE tmux + +# shellcheck source=bin/fm-busy-lib.sh +. "$ROOT/bin/fm-busy-lib.sh" +EV="$ROOT/bin/fm-busy-event.sh" + +# watcher_gate_not_busy: exercise the watcher's production absorb predicate on +# the same real pane capture. The custom tmux socket is intentionally not the +# watcher's default socket, so this checks the pure semantic gate with the +# recorded target while the harness itself remains a real live pane. +watcher_gate_not_busy() { # <lab> <state> <target> <harness> <tail> + local lab=$1 state=$2 target=$3 harness=$4 tail=$5 + mkdir -p "$lab/config" + printf 'window=%s\nbackend=tmux\nharness=%s\n' "$target" "$harness" > "$state/t1.meta" + FM_ROOT_OVERRIDE="$ROOT" + FM_HOME="$lab" + FM_STATE_OVERRIDE="$state" + FM_CONFIG_OVERRIDE="$lab/config" + export FM_ROOT_OVERRIDE FM_HOME FM_STATE_OVERRIDE FM_CONFIG_OVERRIDE + # shellcheck source=bin/fm-watch.sh + . "$ROOT/bin/fm-watch.sh" + if window_is_busy "$target" "$tail"; then + fail "$harness: the watcher still treats the real parked prompt as busy" + fi +} + +# check_harness: launch <harness> (checked with fm_busy_classify, which may +# differ from the tmux <session> name when several harnesses share one real +# binary) via <cmd...> into a fresh worktree carrying <extra-file> +# (path,content - empty means none), wait up to 15s for <expect-regex> to +# render, capture the pane the production way, arm a scratch busy-state +# record, and require the launch-prompt backstop to classify it unknown +# launch-prompt. Never answers the dialog: Escape only, never Enter. +# +# Writes the captured tail to <tail-out> rather than returning it on stdout: +# a caller that needs the tail (the Pi case, which reuses it for pi-signed and +# omp) must NOT wrap this whole function in a command substitution just to +# capture that output, because `fail` calls `exit`, and `exit` inside a +# `$(...)` subshell only ends that subshell - a real failure would be silently +# swallowed there instead of failing the guard. +check_harness() { # <harness> <session> <extra-path> <extra-content> <expect-regex> <tail-out> <cmd...> + local harness=$1 session=$2 extra_path=$3 extra_content=$4 expect=$5 tail_out=$6 + local target="$session:w" lab state tail out + shift 6 + lab=$(mktemp -d "${TMPDIR:-/tmp}/fm-launch-prompt-$harness.XXXXXX") || fail "$harness: could not create the isolated lab" + LABS+=("$lab") + mkdir -p "$lab/wt" + git -C "$lab/wt" init -q || fail "$harness: could not initialize the isolated worktree" + if [ -n "$extra_path" ]; then + mkdir -p "$lab/wt/$(dirname "$extra_path")" + printf '%s' "$extra_content" > "$lab/wt/$extra_path" + fi + + "$REAL_TMUX" -L "$SOCKET" new-session -d -s "$session" -n w -c "$lab/wt" -- "$@" \ + || fail "$harness: could not launch the real binary" + + tail='' + for _ in $(seq 1 75); do + tail=$("$REAL_TMUX" -L "$SOCKET" capture-pane -p -t "$target" -S -40 2>/dev/null) || true + printf '%s' "$tail" | grep -qiE "$expect" && break + sleep 0.2 + done + if ! printf '%s' "$tail" | grep -qiE "$expect"; then + "$REAL_TMUX" -L "$SOCKET" kill-session -t "$session" >/dev/null 2>&1 || true + fail "$harness: the real launch never rendered its expected prompt ('$expect') within 15s - captured tail: +$tail" + fi + + state="$lab/state" + mkdir -p "$state" + "$EV" arm "$state" t1 >/dev/null || fail "$harness: could not arm the scratch busy-state record" + out=$(fm_busy_classify tmux w1 "$harness" t1 "$state" "$tail") + [ "$out" = "unknown launch-prompt" ] \ + || fail "$harness: real launch parked on its prompt classified '$out', expected 'unknown launch-prompt'" + watcher_gate_not_busy "$lab" "$state" "$target" "$harness" "$tail" + + "$REAL_TMUX" -L "$SOCKET" send-keys -t "$target" Escape >/dev/null 2>&1 || true + "$REAL_TMUX" -L "$SOCKET" kill-session -t "$session" >/dev/null 2>&1 || true + CHECKED=$((CHECKED + 1)) + [ -z "$tail_out" ] || printf '%s' "$tail" > "$tail_out" +} + +CLAUDE_BIN=$(command -v claude 2>/dev/null || true) +if [ -x "${CLAUDE_BIN:-}" ]; then + VERSION_OUT=$("$CLAUDE_BIN" --version 2>&1) || fail "claude --version failed: $VERSION_OUT" + note "live claude version: $VERSION_OUT" + check_harness claude fm-lp-claude-$$ '' '' \ + 'Is this a project you created or one you trust' '' \ + "$CLAUDE_BIN" --dangerously-skip-permissions hello + pass "claude: a real launch parked on its own rendered trust dialog surfaces through the watcher gate" +else + note "claude not installed - launch-prompt signature not checked" +fi + +PI_BIN=$(command -v pi 2>/dev/null || true) +if [ -x "${PI_BIN:-}" ]; then + VERSION_OUT=$("$PI_BIN" --version 2>&1) || fail "pi --version failed: $VERSION_OUT" + note "live pi version: $VERSION_OUT" + # A fresh, isolated HOME is required so pi's own trust store has no prior + # decision for this scratch worktree; a project-local .pi/extensions/ file + # is what actually gates a fresh worktree behind the dialog (pi only asks + # when the directory holds a trust-requiring resource), exactly the shape + # fm-spawn.sh's own pi launch always carries. + PI_HOME_LAB=$(mktemp -d "${TMPDIR:-/tmp}/fm-launch-prompt-pi-home.XXXXXX") || fail "pi: could not create the isolated HOME" + LABS+=("$PI_HOME_LAB") + PI_TAIL_FILE=$(mktemp "${TMPDIR:-/tmp}/fm-launch-prompt-pi-tail.XXXXXX") || fail "pi: could not create the tail capture file" + LABS+=("$PI_TAIL_FILE") + check_harness pi fm-lp-pi-$$ '.pi/extensions/dummy.ts' 'export default {};' \ + 'Trust project folder' "$PI_TAIL_FILE" \ + env HOME="$PI_HOME_LAB" "$PI_BIN" hello + # pi-signed and omp share Pi's engine and the same project-trust gate + # (fm_busy_launch_prompt_parked), so the one real capture also proves them, + # each against its own freshly armed fm-spawn seed record. + for h in pi-signed omp; do + hstate=$(mktemp -d "${TMPDIR:-/tmp}/fm-launch-prompt-$h.XXXXXX") || fail "$h: could not create the isolated state dir" + LABS+=("$hstate") + "$EV" arm "$hstate" t1 >/dev/null || fail "$h: could not arm the scratch busy-state record" + out=$(fm_busy_classify tmux w1 "$h" t1 "$hstate" "$(cat "$PI_TAIL_FILE")") + [ "$out" = "unknown launch-prompt" ] \ + || fail "$h: the same real Pi trust-dialog capture classified '$out', expected 'unknown launch-prompt'" + done + pass "pi, pi-signed, omp: a real Pi-engine launch parked on its own rendered trust dialog surfaces through the watcher gate" +else + note "pi not installed - launch-prompt signature not checked" +fi + +GEMINI_BIN=$(command -v gemini 2>/dev/null || true) +if [ -x "${GEMINI_BIN:-}" ]; then + VERSION_OUT=$("$GEMINI_BIN" --version 2>&1) || fail "gemini --version failed: $VERSION_OUT" + note "live gemini version: $VERSION_OUT" + check_harness gemini fm-lp-gemini-$$ '' '' \ + 'How would you like to authenticate for this project|Do you trust the files in this folder|Enter Gemini API Key' '' \ + env GEMINI_CLI_TRUST_WORKSPACE=true GEMINI_API_KEY= "$GEMINI_BIN" -y hello + pass "gemini: a real launch parked on its own rendered auth or trust dialog surfaces through the watcher gate" +else + note "gemini not installed - launch-prompt signature not checked" +fi + +[ "$CHECKED" -gt 0 ] || fail "no installed harness could be checked; this run verified nothing" +note "checked $CHECKED launch-prompt signature(s) against real installed binaries" +cleanup_all +trap - EXIT diff --git a/tests/fm-on.test.sh b/tests/fm-on.test.sh index 3b54274bcf6..32493452439 100755 --- a/tests/fm-on.test.sh +++ b/tests/fm-on.test.sh @@ -62,7 +62,7 @@ cat > "$REMOTE_ROOT/bin/tasks-axi" <<SH #!/usr/bin/env bash printf '%s\n' "\${FM_REMOTE_JOB_ACTIVE:-absent}" >> "$TOOL_PROBE_LOG" case "\${1:-}:\${2:-}" in - --version:*) printf '0.2.4\n' ;; + --version:*) printf '0.2.6\n' ;; update:--help) printf '%s\n' --archive-body ;; mv:--help) printf '%s\n' 'usage: tasks-axi mv <id> [<id>...]' ;; esac @@ -354,7 +354,7 @@ printf '#!/usr/bin/env bash\nprintf "{\\\"server\\\":{\\\"running\\\":false}}\\n cat > "$DOCTOR_BIN/tasks-axi" <<'SH' #!/usr/bin/env bash case "${1:-}:${2:-}" in - --version:*) printf '0.2.4\n' ;; + --version:*) printf '0.2.6\n' ;; update:--help) printf '%s\n' --archive-body ;; mv:--help) printf '%s\n' 'usage: tasks-axi mv <id> [<id>...]' ;; esac diff --git a/tests/fm-pi-branch-extension.test.sh b/tests/fm-pi-branch-extension.test.sh index 7b60f33ef12..87b511f6567 100644 --- a/tests/fm-pi-branch-extension.test.sh +++ b/tests/fm-pi-branch-extension.test.sh @@ -1714,8 +1714,7 @@ if (pending.options.triggerTurn !== true || pending.options.deliverAs !== "follo if (!pending.message.content.includes(`[seq ${seq1}]`)) { throw new Error(`the first queued request lost seq ${seq1}: ${pending.message.content}`); } -contract(["propose", "--words", "merge task-d when green, then cut the prerelease\n\n"]); -contract(["confirm"]); +contract(["enter", "--words", "merge task-d when green, then cut the prerelease\n\n"]); const processingMsg = { role: "custom", customType: pending.message.customType, content: pending.message.content, display: false }; let aborted = false; const abortCtx = { ...defaultSessionCtx, abort() { aborted = true; } }; @@ -1877,8 +1876,7 @@ const contract = (args) => { }; await fire("session_start", {}); -contract(["propose"]); -contract(["confirm"]); +contract(["enter"]); 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); @@ -1895,8 +1893,7 @@ if (mainUserMessages.length !== 0) { throw new Error("the rejected settlement leaked a main user message from the branch"); } -contract(["propose"]); -contract(["confirm"]); +contract(["enter"]); 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); @@ -1944,8 +1941,7 @@ const contract = (args) => { }; await fire("session_start", {}, defaultSessionCtx); -contract(["propose"]); -contract(["confirm"]); +contract(["enter"]); writeFileSync( `${home}/state/.wake-queue`, "1\t1\tsignal\tbranch-driver.status\tsignal: branch-driver.status\n2\t2\theartbeat\theartbeat\theartbeat\n", diff --git a/tests/fm-pi-primary-live-e2e.test.sh b/tests/fm-pi-primary-live-e2e.test.sh index 2eddb2ef335..d95e2ca082e 100755 --- a/tests/fm-pi-primary-live-e2e.test.sh +++ b/tests/fm-pi-primary-live-e2e.test.sh @@ -25,6 +25,8 @@ PROJECT="$LAB/project" AHOY_PROJECT="$LAB/ahoy-project" HOME_DIR="$LAB/fmhome" PI_VERSION=$(pi --version) +WATCH_ONLY=${FM_PI_LIVE_WATCH_ONLY:-0} +case "$WATCH_ONLY" in 0|1) ;; *) fail "FM_PI_LIVE_WATCH_ONLY must be 0 or 1" ;; esac # shellcheck source=/dev/null . "$ROOT/bin/fm-operational-input.sh" # shellcheck disable=SC2016 # Backticks are literal prompt markup. @@ -243,8 +245,10 @@ run_native_ahoy_regressions() { mkdir -p "$LAB" git clone -q "$ROOT" "$PROJECT" -run_ahoy_transcript_regressions -run_native_ahoy_regressions +if [ "$WATCH_ONLY" -eq 0 ]; then + run_ahoy_transcript_regressions + run_native_ahoy_regressions +fi mkdir -p "$PROJECT/.pi/extensions/lib" cp "$ROOT/.pi/extensions/fm-calm.ts" "$PROJECT/.pi/extensions/fm-calm.ts" cp "$ROOT/.pi/extensions/fm-primary-pi-watch.ts" "$PROJECT/.pi/extensions/fm-primary-pi-watch.ts" @@ -279,46 +283,67 @@ done wait_for_text "(openai-codex)" 120 || fail "Pi did not reach its ready composer" sleep 1 -send_prompt "/calm" -sleep 0.2 -send_prompt "Reply exactly CALM_LIVE_WORKING_VISIBLE" +if [ "$WATCH_ONLY" -eq 0 ]; then + send_prompt "/calm" + sleep 0.2 + send_prompt "Reply exactly CALM_LIVE_WORKING_VISIBLE" + i=0 + while [ "$i" -lt 240 ]; do + pane=$(capture) + if printf '%s\n' "$pane" | grep -Fq '╲▁▁▁╱'; then + break + fi + sleep 0.05 + i=$((i + 1)) + done + printf '%s\n' "$pane" | grep -Fq '╲▁▁▁╱' \ + || fail "Calm did not show the working ship on the credentialed provider path" + printf '%s\n' "$pane" | grep -Fq "Working..." \ + && fail "Calm left Pi's stock working row visible on the credentialed provider path" + wait_for_exact_line "CALM_LIVE_WORKING_VISIBLE" 120 \ + || fail "Pi did not settle the Calm working-ship provider probe" + pane=$(capture) + printf '%s\n' "$pane" | grep -Fq '╲▁▁▁╱' \ + && fail "Calm left the working ship on screen after the run settled" + printf '%s\n' "$pane" | grep -Fq "calm transcript" \ + && fail "Calm added a persistent Calm status row on the credentialed provider path" + send_prompt "/calm" + sleep 0.2 +fi + +: > "$HOME_DIR/state/pi-e2e.meta" +send_prompt "Start supervision with fm_watch_arm_pi and never use bash to arm supervision. Three watcher notifications will name LIVE_WAKE_1 through LIVE_WAKE_3. After each one, run bin/fm-wake-drain.sh, handle and acknowledge it, then reply exactly HANDLED_1, HANDLED_2, or HANDLED_3 to match that notification." i=0 -while [ "$i" -lt 240 ]; do +while [ "$i" -lt 120 ]; do pane=$(capture) - if printf '%s\n' "$pane" | grep -Fq '╲▁▁▁╱'; then + if printf '%s\n' "$pane" | grep -Eq 'watcher: started Pi extension arm child|Pi extension already owns an arm child'; then break fi - sleep 0.05 + sleep 0.5 i=$((i + 1)) done -printf '%s\n' "$pane" | grep -Fq '╲▁▁▁╱' \ - || fail "Calm did not show the working ship on the credentialed provider path" -printf '%s\n' "$pane" | grep -Fq "Working..." \ - && fail "Calm left Pi's stock working row visible on the credentialed provider path" -wait_for_exact_line "CALM_LIVE_WORKING_VISIBLE" 120 \ - || fail "Pi did not settle the Calm working-ship provider probe" -pane=$(capture) -printf '%s\n' "$pane" | grep -Fq '╲▁▁▁╱' \ - && fail "Calm left the working ship on screen after the run settled" -printf '%s\n' "$pane" | grep -Fq "calm transcript" \ - && fail "Calm added a persistent Calm status row on the credentialed provider path" -send_prompt "/calm" -sleep 0.2 +printf '%s\n' "$pane" | grep -Eq 'watcher: started Pi extension arm child|Pi extension already owns an arm child' \ + || fail "Pi did not render the initial watcher ownership result" -: > "$HOME_DIR/state/pi-e2e.meta" -send_prompt "Start supervision with fm_watch_arm_pi and never use bash to arm supervision. After the watcher wake arrives, run bin/fm-wake-drain.sh and reply exactly HANDLED." -wait_for_text "watcher: started Pi extension arm child 1" || fail "Pi did not render the initial watcher tool result" - -printf 'done: pi live e2e watcher fire\n' > "$HOME_DIR/state/pi-e2e.status" -i=0 -while [ "$i" -lt 240 ]; do - grep -Eq 'reason=actionable-signal.*successor=started:[0-9]+' "$HOME_DIR/state/.watch-cycle-exits.log" 2>/dev/null && break - sleep 0.5 - i=$((i + 1)) +wake_number=1 +while [ "$wake_number" -le 3 ]; do + printf 'done: pi live e2e LIVE_WAKE_%s\n' "$wake_number" >> "$HOME_DIR/state/pi-e2e.status" + i=0 + cycle_count=0 + while [ "$i" -lt 240 ]; do + if [ -f "$HOME_DIR/state/.watch-cycle-exits.log" ]; then + cycle_count=$(grep -Ec 'reason=actionable-signal.*successor=started:[0-9]+' "$HOME_DIR/state/.watch-cycle-exits.log" 2>/dev/null || true) + fi + [ "$cycle_count" -ge "$wake_number" ] && break + sleep 0.5 + i=$((i + 1)) + done + [ "$cycle_count" -ge "$wake_number" ] \ + || fail "Pi extension did not ledger-link successor $wake_number after its actionable close" + wait_for_exact_line "HANDLED_$wake_number" 120 \ + || fail "Pi did not drain and settle notification $wake_number after its extension-owned successor started" + wake_number=$((wake_number + 1)) done -grep -Eq 'reason=actionable-signal.*successor=started:[0-9]+' "$HOME_DIR/state/.watch-cycle-exits.log" 2>/dev/null \ - || fail "Pi extension did not start and ledger-link a successor after the actionable close" -wait_for_exact_line "HANDLED" 120 || fail "Pi did not drain and settle after its extension-owned successor started" pane=$(capture) guard_count=$(printf '%s\n' "$pane" | grep -Fc "TURN WOULD END BLIND - supervision is off." || true) @@ -335,6 +360,20 @@ pid_file=$(find "$HOME_DIR/state" -maxdepth 3 -type f -name pid | head -1) watcher_pid=$(sed -n '1p' "$pid_file") arm_pid=$(ps -p "$watcher_pid" -o ppid= | tr -d ' ') [ -n "$arm_pid" ] || fail "re-armed watcher parent was not live" +lab_pid_is_safe "$watcher_pid" || fail "refusing to stop watcher outside the isolated live-Pi lab" +lab_pid_is_safe "$arm_pid" || fail "refusing to stop arm outside the isolated live-Pi lab" +printf '%s\n' '#!/usr/bin/env bash' \ + 'echo "watcher: FAILED - intentional isolated live-E2E stop"' \ + 'exit 1' > "$PROJECT/bin/fm-watch-arm.sh" +chmod +x "$PROJECT/bin/fm-watch-arm.sh" +kill -TERM "$arm_pid" 2>/dev/null || fail "could not intentionally stop the isolated arm chain" +wait_pid_dead "$watcher_pid" || fail "intentionally stopped watcher stayed alive" +wait_pid_dead "$arm_pid" || fail "intentionally stopped arm stayed alive" +sleep 2 +alarm=$(FM_HOME="$HOME_DIR" FM_ROOT_OVERRIDE="$PROJECT" FM_GUARD_GRACE=1 \ + FM_SUPERVISION_MODEL=extension "$PROJECT/bin/fm-guard.sh" 2>&1) +printf '%s\n' "$alarm" | grep -Fq 'WATCHER DOWN - SUPERVISION IS OFF' \ + || fail "an intentionally stopped live Pi chain did not raise the genuine outage alarm: $alarm" "$TMUX" -L "$SOCKET" send-keys -t "$SESSION" -l '/quit' sleep 1 @@ -343,4 +382,8 @@ wait_for_text "PI_EXIT=0" 60 || fail "Pi did not exit cleanly" wait_pid_dead "$watcher_pid" || fail "watcher child survived clean Pi exit" wait_pid_dead "$arm_pid" || fail "arm child survived clean Pi exit" -printf 'ok - Pi %s live E2E covered the Calm working ship, Ahoy first/later messages, legacy transcripts, near misses, and watcher continuity\n' "$PI_VERSION" +if [ "$WATCH_ONLY" -eq 1 ]; then + printf 'ok - Pi %s live E2E covered repeated successor handoffs and a genuine stopped-chain alarm\n' "$PI_VERSION" +else + printf 'ok - Pi %s live E2E covered repeated successor handoffs and a genuine stopped-chain alarm, plus Calm and Ahoy regressions\n' "$PI_VERSION" +fi diff --git a/tests/fm-pi-watch-extension.test.sh b/tests/fm-pi-watch-extension.test.sh index 2604163d7ad..e984875b098 100755 --- a/tests/fm-pi-watch-extension.test.sh +++ b/tests/fm-pi-watch-extension.test.sh @@ -425,6 +425,90 @@ EOF pass "Pi actionable close starts one successor before wake delivery settles" } +# The arm child can publish its complete actionable line before its process +# closes while the watcher finishes durable cleanup. The still-open predecessor +# must never be mistaken for the successor merely because its readiness promise +# already settled. +test_pi_actionable_output_waits_for_predecessor_close() { + local repo home plugin log stop out status + repo="$TMP_ROOT/pi-actionable-before-close-root" + home="$TMP_ROOT/pi-actionable-before-close-home" + log="$TMP_ROOT/pi-actionable-before-close.log" + stop="$TMP_ROOT/pi-actionable-before-close.stop" + mkdir -p "$repo/bin" "$home/state" "$home/config" + install_pi_watch_extension_fixture "$repo" + plugin="$repo/.pi/extensions/fm-primary-pi-watch.ts" + cat > "$repo/bin/fm-watch-arm.sh" <<'SH' +#!/usr/bin/env bash +if [ "${1:-}" = --handling-delivered ]; then + printf 'handling-confirmed\n' >> "${FM_ARM_LOG:?}" + exit 0 +fi +printf 'arm=%s\n' "$$" >> "${FM_ARM_LOG:?}" +count=$(grep -c '^arm=' "$FM_ARM_LOG") +printf 'watcher: started pid=%s (beacon fresh) recovery-generation=before-close-%s\n' "$$" "$count" +if [ "$count" -eq 1 ]; then + printf 'actionable-emitted\n' >> "$FM_ARM_LOG" + printf 'signal: actionable output before predecessor close\n' + sleep 0.5 + printf 'predecessor-closed\n' >> "$FM_ARM_LOG" + exit 0 +fi +trap 'exit 0' TERM INT +while [ ! -e "$FM_STOP_FILE" ]; do sleep 0.02; done +SH + chmod +x "$repo/bin/fm-watch-arm.sh" + out=$(PLUGIN="$plugin" FM_HOME="$home" FM_ROOT_OVERRIDE="$repo" FM_ARM_LOG="$log" FM_STOP_FILE="$stop" node --input-type=module 2>&1 <<'EOF' +import { existsSync, readFileSync, writeFileSync } from "node:fs"; +import { pathToFileURL } from "node:url"; + +const failNow = (message) => { + console.error(message); + process.exit(1); +}; +let tool = null; +const prompts = []; +const pi = { + on() {}, + registerCommand() {}, + registerTool(candidate) { + if (candidate.name === "fm_watch_arm_pi") tool = candidate; + }, + sendUserMessage: async (message) => { + prompts.push(message); + writeFileSync(process.env.FM_ARM_LOG, "delivery\n", { flag: "a" }); + }, + events: { on() {}, emit() {} }, +}; + +writeFileSync(`${process.env.FM_HOME}/state/.lock`, `${process.pid}\n`); +const mod = await import(pathToFileURL(process.env.PLUGIN).href); +mod.default(pi); +await tool.execute("initial-arm", {}, undefined, undefined, {}); +await new Promise((resolve) => setTimeout(resolve, 1200)); +const rows = existsSync(process.env.FM_ARM_LOG) + ? readFileSync(process.env.FM_ARM_LOG, "utf8").trim().split("\n") + : []; +const armIndexes = rows.map((row, index) => row.startsWith("arm=") ? index : -1).filter((index) => index >= 0); +const closeIndex = rows.indexOf("predecessor-closed"); +const deliveryIndex = rows.indexOf("delivery"); +if (armIndexes.length !== 2) failNow(`expected a successor after the predecessor close: ${rows.join(" | ")}`); +if (closeIndex < 0 || deliveryIndex < 0) failNow(`missing close or delivery evidence: ${rows.join(" | ")}`); +if (armIndexes[1] < closeIndex) failNow(`successor started before predecessor close: ${rows.join(" | ")}`); +if (deliveryIndex < armIndexes[1]) failNow(`wake was delivered before successor startup: ${rows.join(" | ")}`); +if (prompts.length !== 1 || !prompts[0].includes("signal: actionable output before predecessor close")) { + failNow(`wrong actionable wake: ${prompts.join(" | ")}`); +} +writeFileSync(process.env.FM_STOP_FILE, "stop\n"); +process.exit(0); +EOF + ) + status=$? + expect_code 0 "$status" "Pi actionable output must wait for predecessor close before successor restoration" + [ -z "$out" ] || fail "Pi actionable-before-close test printed output: $out" + pass "Pi actionable output waits for predecessor close before successor restoration" +} + test_pi_branch_offer_owns_actionable_wake() { local repo home plugin log stop out status repo="$TMP_ROOT/pi-branch-offer-root" @@ -1342,8 +1426,7 @@ test_pi_away_record_collapses_eligibility_and_keeps_vetoes_on_main() { install_pi_watch_extension_fixture "$repo" plugin="$repo/.pi/extensions/fm-primary-pi-watch.ts" printf 'project=%s/projects/approved\nwindow=fm-window\n' "$home" > "$home/state/task-a.meta" - 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" + FM_HOME="$home" "$ROOT/bin/fm-afk-contract.sh" enter >/dev/null || fail "away entry 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 @@ -2068,18 +2151,31 @@ EOF } test_pi_session_transition_generation_owner() { - local repo home plugin child_pid_file child_marker_file marker_root arm_log out status + local repo home plugin child_pid_file child_marker_file marker_root arm_log fail_once out status repo="$TMP_ROOT/pi-session-transition-root" home="$TMP_ROOT/pi-session-transition-home" child_pid_file="$TMP_ROOT/pi-session-transition-child.pid" child_marker_file="$TMP_ROOT/pi-session-transition-child.marker" marker_root="$TMP_ROOT/pi-session-transition-markers" arm_log="$TMP_ROOT/pi-session-transition-arm.log" + fail_once="$TMP_ROOT/pi-session-transition-fail-once" mkdir -p "$repo/bin" "$home/state" "$home/config" "$marker_root" 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 +# Model the real --restart arm taking over from the still-live replacement +# predecessor only after this successor process has been committed. +previous=$(cat "${FM_CHILD_PID_FILE:?}" 2>/dev/null || true) +if [ -n "$previous" ] && kill -0 "$previous" 2>/dev/null; then + kill -TERM "$previous" 2>/dev/null || true +fi +if [ -f "${FM_FAIL_ONCE:?}" ]; then + rm -f "$FM_FAIL_ONCE" + printf 'failed-replacement-attempt\n' >> "${FM_ARM_LOG:?}" + printf 'watcher: FAILED - simulated replacement launch failure\n' >&2 + exit 7 +fi # The marker identifies this exact process lifetime after its PID is recycled. marker=$(mktemp "${FM_MARKER_ROOT:?}/arm.XXXXXX") || exit 1 cleanup() { rm -f "$marker"; } @@ -2092,7 +2188,7 @@ printf 'arm pid=%s marker=%s\n' "$$" "$marker" >> "${FM_ARM_LOG:?}" while :; do sleep 0.2; done SH chmod +x "$repo/bin/fm-watch-arm.sh" - out=$(PLUGIN="$plugin" FM_HOME="$home" FM_ROOT_OVERRIDE="$repo" FM_CHILD_PID_FILE="$child_pid_file" FM_CHILD_MARKER_FILE="$child_marker_file" FM_MARKER_ROOT="$marker_root" FM_ARM_LOG="$arm_log" 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' + out=$(PLUGIN="$plugin" FM_HOME="$home" FM_ROOT_OVERRIDE="$repo" FM_CHILD_PID_FILE="$child_pid_file" FM_CHILD_MARKER_FILE="$child_marker_file" FM_MARKER_ROOT="$marker_root" FM_ARM_LOG="$arm_log" FM_FAIL_ONCE="$fail_once" 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 { existsSync, readFileSync, writeFileSync } from "node:fs"; import { pathToFileURL } from "node:url"; @@ -2141,6 +2237,15 @@ function currentArm() { } } +function extensionOwner() { + const path = `${process.env.FM_HOME}/state/.pi-watch-extension-loaded`; + try { + return readFileSync(path, "utf8").trim().split("\n")[2] ?? ""; + } catch { + return ""; + } +} + function liveArmPids() { if (!existsSync(process.env.FM_ARM_LOG)) return []; return readFileSync(process.env.FM_ARM_LOG, "utf8") @@ -2155,11 +2260,14 @@ function liveArmPids() { .map((arm) => arm.pid); } -writeFileSync(`${process.env.FM_HOME}/state/.lock`, `${process.pid}\n`); const mod = await import(pathToFileURL(process.env.PLUGIN).href); const startup = makePi(); mod.default(startup.pi); +if (!/^generation=[1-9][0-9]* phase=active$/.test(extensionOwner())) { + throw new Error(`extension bind did not publish its pre-lock active generation: ${extensionOwner()}`); +} +writeFileSync(`${process.env.FM_HOME}/state/.lock`, `${process.pid}\n`); await startup.handlers.get("session_start")?.({ type: "session_start", reason: "startup" }, {}); await waitFor(() => { const arm = currentArm(); @@ -2167,13 +2275,22 @@ await waitFor(() => { }, "startup child"); const { pid: startupChild, marker: startupMarker } = currentArm(); if (!pidAlive(startupChild)) throw new Error("startup child was not alive"); +if (!/^generation=[1-9][0-9]* phase=active$/.test(extensionOwner())) { + throw new Error(`startup did not publish an active generation owner: ${extensionOwner()}`); +} const staleTool = startup.getTool(); async function replaceSession(previous, reason) { const previousArm = currentArm(); + const previousOwner = extensionOwner(); + const previousGeneration = /^generation=([1-9][0-9]*) phase=active$/.exec(previousOwner)?.[1]; + if (!previousGeneration) throw new Error(`${reason} predecessor had no active generation owner: ${previousOwner}`); await previous.handlers.get("session_shutdown")?.({ type: "session_shutdown", reason }, {}); - if (previousArm.marker) { - await waitFor(() => !existsSync(previousArm.marker), `${reason} previous child exit`); + if (!previousArm.marker || !existsSync(previousArm.marker) || !pidAlive(previousArm.pid)) { + throw new Error(`${reason} shutdown retired the old generation before a successor owned recovery`); + } + if (extensionOwner() !== `generation=${previousGeneration} phase=handoff`) { + throw new Error(`${reason} shutdown did not publish its generation handoff: ${extensionOwner()}`); } const next = makePi(); mod.default(next.pi); @@ -2186,6 +2303,12 @@ async function replaceSession(previous, reason) { const arm = currentArm(); return arm.pid && arm.marker && arm.marker !== previousArm.marker && existsSync(arm.marker) && pidAlive(arm.pid) && liveArmPids().includes(arm.pid); }, `${reason} replacement child and arm record`); + await waitFor(() => !existsSync(previousArm.marker), `${reason} previous child exit after successor claim`); + const nextOwner = extensionOwner(); + const nextGeneration = /^generation=([1-9][0-9]*) phase=active$/.exec(nextOwner)?.[1]; + if (!nextGeneration || nextGeneration === previousGeneration) { + throw new Error(`${reason} successor did not claim a distinct active generation: ${nextOwner}`); + } const live = liveArmPids(); if (live.length !== 1) { throw new Error(`${reason} expected exactly one live arm child, got ${live.join(",") || "(none)"}`); @@ -2202,15 +2325,35 @@ current = await replaceSession(current, "resume"); current = await replaceSession(current, "fork"); current = await replaceSession(current, "reload"); +// A replacement that kills the predecessor but fails before watcher readiness +// remains generation-owned and reaches its bounded automatic retry. +writeFileSync(process.env.FM_FAIL_ONCE, "fail once\n"); +current = await replaceSession(current, "resume"); +if (!readFileSync(process.env.FM_ARM_LOG, "utf8").includes("failed-replacement-attempt")) { + throw new Error("replacement launch failure did not exercise automatic retry"); +} + // Same bound instance: ordinary shutdown then session_start without a fresh factory. const sameInstanceArm = currentArm(); +const sameInstanceGeneration = /^generation=([1-9][0-9]*) phase=active$/.exec(extensionOwner())?.[1]; await current.handlers.get("session_shutdown")?.({ type: "session_shutdown", reason: "new" }, {}); +if (!sameInstanceArm.marker || !existsSync(sameInstanceArm.marker) || !pidAlive(sameInstanceArm.pid)) { + throw new Error("same-instance shutdown retired the old generation before its replacement started"); +} +if (!sameInstanceGeneration || extensionOwner() !== `generation=${sameInstanceGeneration} phase=handoff`) { + throw new Error(`same-instance shutdown did not publish its handoff generation: ${extensionOwner()}`); +} await current.handlers.get("session_start")?.({ type: "session_start", reason: "new" }, {}); await waitFor(() => { const arm = currentArm(); return arm.pid && arm.marker && arm.marker !== sameInstanceArm.marker && existsSync(arm.marker) && pidAlive(arm.pid) && liveArmPids().includes(arm.pid); }, "same-instance replacement child and arm record"); await waitFor(() => !existsSync(sameInstanceArm.marker), "same-instance previous child exit"); +const sameInstanceOwner = extensionOwner(); +const sameInstanceSuccessor = /^generation=([1-9][0-9]*) phase=active$/.exec(sameInstanceOwner)?.[1]; +if (!sameInstanceSuccessor || sameInstanceSuccessor === sameInstanceGeneration) { + throw new Error(`same-instance successor did not claim a distinct active generation: ${sameInstanceOwner}`); +} const sameInstanceResult = await current.getTool().execute("same-instance-redundant", {}, undefined, undefined, {}); if (!sameInstanceResult.details?.ok || !String(sameInstanceResult.details.message).includes("unchanged")) { throw new Error(`same-instance replacement lost automatic arm ownership: ${JSON.stringify(sameInstanceResult.details)}`); @@ -2247,6 +2390,7 @@ for (const reason of ["resume", "fork", "new", "resume"]) { const finalArm = currentArm(); await current.handlers.get("session_shutdown")?.({ type: "session_shutdown", reason: "quit" }, {}); await waitFor(() => !existsSync(finalArm.marker), "terminal shutdown child exit"); +if (extensionOwner()) throw new Error(`terminal shutdown left an extension owner: ${extensionOwner()}`); const quitArm = await current.getTool().execute("after-quit", {}, undefined, undefined, {}); if (quitArm.details?.ok !== false || quitArm.details.message !== "watcher: not armed - Pi session is shutting down") { throw new Error(`terminal quit must keep the shutting-down refusal: ${JSON.stringify(quitArm.details)}`); @@ -2787,6 +2931,9 @@ count=0 [ ! -f "$FM_ARM_COUNT" ] || count=$(cat "$FM_ARM_COUNT") count=$((count + 1)) printf '%s\n' "$count" > "$FM_ARM_COUNT" +previous=$(cat "$FM_ARM_COUNT.pid" 2>/dev/null || true) +printf '%s\n' "$$" > "$FM_ARM_COUNT.pid" +[ -z "$previous" ] || kill -TERM "$previous" 2>/dev/null || true late_close() { sleep 0.15 printf 'signal: late retiring actionable outcome\n' @@ -2892,6 +3039,9 @@ count=0 [ ! -f "$FM_ARM_COUNT" ] || count=$(cat "$FM_ARM_COUNT") count=$((count + 1)) printf '%s\n' "$count" > "$FM_ARM_COUNT" +previous=$(cat "$FM_ARM_COUNT.pid" 2>/dev/null || true) +printf '%s\n' "$$" > "$FM_ARM_COUNT.pid" +[ -z "$previous" ] || kill -TERM "$previous" 2>/dev/null || true late_close() { sleep 0.08 printf 'signal: module-%s late actionable outcome\n' "$count" @@ -2946,6 +3096,18 @@ for (let moduleIndex = 1; moduleIndex <= 2; moduleIndex += 1) { ); await instance.handlers.get("session_shutdown")?.({ type: "session_shutdown", reason: "new" }, {}); } +// Replacement shutdown deliberately retains the established module-2 arm until +// a successor commits. Start that successor so both retiring modules publish +// their late actionable closes under distinct process-wide tokens. +const collectorMod = await import(`${pathToFileURL(process.env.PLUGIN).href}?token-module=collector`); +const collector = makePi(); +collectorMod.default(collector.pi); +const collectorArm = await collector.getTool().execute("arm-collector", {}, undefined, undefined, {}); +if (!collectorArm.details?.ok) throw new Error(`collector arm failed: ${JSON.stringify(collectorArm.details)}`); +await waitFor( + () => existsSync(process.env.FM_ARM_COUNT) && Number(readFileSync(process.env.FM_ARM_COUNT, "utf8").trim()) >= 3, + "collector arm", +); const handoffPath = `${process.env.FM_HOME}/state/extensions/pi-primary-watch/session-replacement-actionable.json`; await waitFor(() => existsSync(handoffPath), "replacement handoff"); await waitFor(() => JSON.parse(readFileSync(handoffPath, "utf8")).pending.length === 2, "two distinct handoff outcomes"); @@ -2958,6 +3120,7 @@ for (const moduleIndex of [1, 2]) { throw new Error(`module ${moduleIndex} outcome was dropped: ${JSON.stringify(handoff)}`); } } +process.exit(0); EOF ) status=$? @@ -2966,7 +3129,7 @@ EOF pass "Pi replacement handoff tokens stay unique across fresh modules" } -test_pi_replacement_persistence_failure_stops_arm_child() { +test_pi_replacement_persistence_failure_keeps_predecessor_until_successor() { local repo home plugin count marker out status repo="$TMP_ROOT/pi-replacement-persistence-failure-root" home="$TMP_ROOT/pi-replacement-persistence-failure-home" @@ -2986,6 +3149,11 @@ if [ "$count" -eq 1 ]; then printf 'signal: persistence failure actionable outcome\n' exit 0 fi +previous=$(cat "$FM_CHILD_MARKER" 2>/dev/null || true) +if [ -n "$previous" ] && kill -0 "$previous" 2>/dev/null; then + kill -TERM "$previous" 2>/dev/null || true + while kill -0 "$previous" 2>/dev/null; do sleep 0.01; done +fi cleanup() { rm -f "$FM_CHILD_MARKER"; } trap cleanup EXIT trap 'exit 0' TERM INT @@ -3032,14 +3200,10 @@ const armed = await tool.execute("initial-arm", {}, undefined, undefined, {}); if (!armed.details?.ok) throw new Error(`initial arm failed: ${JSON.stringify(armed.details)}`); await waitFor(() => deliveryStarted && existsSync(process.env.FM_CHILD_MARKER), "blocked delivery and successor child"); writeFileSync(`${process.env.FM_HOME}/state/extensions`, "block handoff directory\n"); -let shutdownError = null; -try { - await handlers.get("session_shutdown")?.({ type: "session_shutdown", reason: "new" }, {}); -} catch (error) { - shutdownError = error; +await handlers.get("session_shutdown")?.({ type: "session_shutdown", reason: "new" }, {}); +if (!existsSync(process.env.FM_CHILD_MARKER)) { + throw new Error("replacement shutdown retired the established predecessor after handoff persistence failed"); } -if (!shutdownError) throw new Error("replacement shutdown hid the handoff persistence failure"); -await waitFor(() => !existsSync(process.env.FM_CHILD_MARKER), "successor cleanup after persistence failure"); const { unlinkSync } = await import("node:fs"); unlinkSync(`${process.env.FM_HOME}/state/extensions`); const replacementMod = await import(`${pathToFileURL(process.env.PLUGIN).href}?replacement=persistence-failure`); @@ -3057,9 +3221,9 @@ process.exit(0); EOF ) status=$? - expect_code 0 "$status" "Pi replacement shutdown must stop its arm after handoff persistence fails" - [ -z "$out" ] || fail "Pi replacement persistence-failure cleanup test printed output: $out" - pass "Pi replacement persistence failure still stops its arm child" + expect_code 0 "$status" "Pi replacement persistence failure must keep its predecessor until a successor commits" + [ -z "$out" ] || fail "Pi replacement persistence-failure continuity test printed output: $out" + pass "Pi replacement persistence failure keeps its predecessor until a successor commits" } test_pi_process_exit_cleanup_listener_lifecycle() { @@ -4155,6 +4319,7 @@ test_pi_tool_returns_agent_tool_result test_pi_redundant_tool_call_is_owned_noop test_pi_scheduled_retry_call_is_owned_noop test_pi_actionable_close_starts_single_successor_before_delivery +test_pi_actionable_output_waits_for_predecessor_close test_pi_branch_offer_owns_actionable_wake test_pi_branch_offer_flags_heartbeat test_pi_heartbeat_is_not_ridden_into_main_by_a_co_present_check @@ -4181,7 +4346,7 @@ test_pi_streaming_time_delivery_keeps_the_successor_chain test_pi_successor_failure_during_delivery_is_retried_after_delivery test_pi_late_retiring_actionable_reaches_replacement test_pi_replacement_tokens_are_process_unique -test_pi_replacement_persistence_failure_stops_arm_child +test_pi_replacement_persistence_failure_keeps_predecessor_until_successor test_pi_process_exit_cleanup_listener_lifecycle test_pi_process_exit_cleanup_stops_arm_child test_opencode_plugin_package_boundary_is_explicit_esm diff --git a/tests/fm-pr-check-security.test.sh b/tests/fm-pr-check-security.test.sh index c403ea3cae8..70bec6e23b3 100755 --- a/tests/fm-pr-check-security.test.sh +++ b/tests/fm-pr-check-security.test.sh @@ -17,6 +17,7 @@ WATCH="$ROOT/bin/fm-watch.sh" TEARDOWN="$ROOT/bin/fm-teardown.sh" REGISTER="$ROOT/bin/fm-check-register.sh" TMP_ROOT=$(fm_test_tmproot fm-pr-check-security) +fm_git_identity fmtest fmtest@example.invalid BASE_PATH=${FM_TEST_BASE_PATH:-/usr/bin:/bin:/usr/sbin:/sbin} REAL_CP=$(command -v cp) REAL_MV=$(command -v mv) @@ -127,6 +128,9 @@ make_case() { fakebin="$dir/fakebin" fake_root="$dir/root" mkdir -p "$dir/home/state" "$dir/home/data" "$dir/home/config" "$dir/wt" "$fakebin" "$fake_root/bin" + git -C "$dir/wt" init -q + git -C "$dir/wt" commit -q --allow-empty -m init + git -C "$dir/wt" update-ref refs/remotes/origin/main "$(git -C "$dir/wt" rev-parse HEAD)" cat > "$fake_root/bin/fm-guard.sh" <<'SH' #!/usr/bin/env bash printf 'guard\n' >> "$FM_TEST_GUARD_LOG" @@ -150,6 +154,10 @@ case "${1:-} ${2:-}" in printf '%s\n' "{\"state\":\"OPEN\",\"isDraft\":false,\"mergeable\":\"MERGEABLE\",\"mergeStateStatus\":\"CLEAN\",\"headRefOid\":\"${FM_TEST_GH_HEAD:-0123456789abcdef0123456789abcdef01234567}\",\"baseRefName\":\"main\",\"statusCheckRollup\":[{\"__typename\":\"CheckRun\",\"name\":\"ci\",\"status\":\"COMPLETED\",\"conclusion\":\"SUCCESS\"}]}" exit 0 ;; + *" --json isDraft "*) + printf '%s\n' "{\"isDraft\":${FM_TEST_GH_DRAFT:-false}}" + exit 0 + ;; *headRefOid,reviewDecision*) printf '%s\n' "{\"headRefOid\":\"${FM_TEST_GH_HEAD:-0123456789abcdef0123456789abcdef01234567}\",\"reviewDecision\":\"APPROVED\"}" exit 0 @@ -228,10 +236,12 @@ write_task_meta() { # Extra "field=value" arguments are written before pr=, because # fm_pr_metadata_identity_parse rejects an unrecognised line after it. write_poll_meta() { - local state=$1 id=$2 url=$3 + local state=$1 id=$2 url=$3 case_dir + case_dir=$(cd "$state/../.." && pwd) shift 3 fm_write_meta "$state/$id.meta" \ "window=fm-$id" \ + "worktree=$case_dir/wt" \ "$@" \ "pr=$url" } @@ -518,6 +528,79 @@ test_invalid_entrypoints_have_zero_side_effects() { pass "PR and teardown entrypoints reject invalid arguments before every side effect" } +# A draft cannot be merged, so arming a merge poll on one would wait for an event +# that cannot occur. Only a positive draft reading refuses, and it refuses before +# anything is recorded or armed; a ready or unreadable one arms as before. +test_draft_pull_request_is_not_armed() { + local dir rc + dir=$(make_case draft-refused) + write_task_meta "$dir" + cp "$dir/home/state/task-a.meta" "$dir/meta.before" + set +e + FM_TEST_GH_DRAFT=true run_check_entry "$dir" task-a https://github.com/o/r/pull/9 \ + > "$dir/stdout" 2> "$dir/stderr"; rc=$? + set -e + [ "$rc" -ne 0 ] || fail "arming accepted a draft pull request" + grep -qi 'draft' "$dir/stderr" || fail "the refusal did not name the draft state" + grep -qF 'https://github.com/o/r/pull/9' "$dir/stderr" || fail "the refusal did not name the pull request" + cmp -s "$dir/meta.before" "$dir/home/state/task-a.meta" || fail "a refused draft changed the task metadata" + [ ! -e "$dir/home/state/task-a.check.sh" ] || fail "a refused draft armed a poll" + [ ! -e "$dir/home/state/task-a.pr-poll" ] || fail "a refused draft wrote a poll sidecar" + [ ! -s "$dir/guard.log" ] || fail "a refused draft reached the guard" + + dir=$(make_case draft-cleared) + write_task_meta "$dir" + FM_TEST_GH_DRAFT=false run_check_entry "$dir" task-a https://github.com/o/r/pull/9 \ + > "$dir/stdout" 2> "$dir/stderr" || fail "arming refused a pull request that is not a draft" + grep -qxF 'pr=https://github.com/o/r/pull/9' "$dir/home/state/task-a.meta" \ + || fail "a non-draft pull request was not recorded" + [ -f "$dir/home/state/task-a.check.sh" ] || fail "a non-draft pull request was not armed" + + dir=$(make_case draft-unreadable) + write_task_meta "$dir" + FM_TEST_GH_DRAFT=null run_check_entry "$dir" task-a https://github.com/o/r/pull/9 \ + > "$dir/stdout" 2> "$dir/stderr" || fail "an unreadable draft state blocked arming" + [ -f "$dir/home/state/task-a.check.sh" ] || fail "an unreadable draft state was not armed" + pass "arming refuses a draft pull request, naming it, and arms a ready or unreadable one" +} + +# With no forge-reported head (gh cannot supply one), the named head is the +# worker copy's HEAD, and a HEAD that exists only there is refused. +test_unpushed_named_head_refuses_registration() { + local dir sha + dir=$(make_case unpushed-named-head) + write_task_meta "$dir" + git -C "$dir/wt" commit -q --allow-empty -m 'only in the copy' + sha=$(git -C "$dir/wt" rev-parse HEAD) + FM_TEST_GH_HEAD=unavailable run_check_entry "$dir" task-a https://github.com/o/r/pull/4 \ + > "$dir/stdout" 2> "$dir/stderr" && fail "unpushed PR head was registered" + grep -Fq "named head $sha is unreachable outside the worker copy" "$dir/stderr" \ + || fail "refusal did not name the unreachable head: $(cat "$dir/stderr")" + ! grep -q '^pr=' "$dir/home/state/task-a.meta" || fail "unpushed PR head still recorded pr=" + [ ! -e "$dir/home/state/task-a.check.sh" ] || fail "unpushed PR head still armed a poll" + pass "fm-pr-check refuses to register a PR whose named head is only in the worker copy" +} + +# A direct-PR worker pushes from its own copy: the forge still reports the +# head pushed when the PR opened, but a later fix committed only in the copy +# is the named head, so registration is refused. +test_direct_pr_unpushed_commit_refuses_registration() { + local dir pushed later + dir=$(make_case direct-pr-unpushed) + fm_write_meta "$dir/home/state/task-a.meta" \ + "window=firstmate:fm-task-a" "endpoint_task_id=task-a" "worktree=$dir/wt" \ + "project=$dir/project" "kind=ship" "mode=direct-PR" + pushed=$(git -C "$dir/wt" rev-parse HEAD) + git -C "$dir/wt" commit -q --allow-empty -m 'fix only in the copy' + later=$(git -C "$dir/wt" rev-parse HEAD) + FM_TEST_GH_HEAD=$pushed run_check_entry "$dir" task-a https://github.com/o/r/pull/4 \ + > "$dir/stdout" 2> "$dir/stderr" && fail "direct-PR head with an unpushed later commit was registered" + grep -Fq "named head $later is unreachable outside the worker copy" "$dir/stderr" \ + || fail "direct-PR refusal did not name the unpushed commit: $(cat "$dir/stderr")" + [ ! -e "$dir/home/state/task-a.check.sh" ] || fail "direct-PR unpushed commit still armed a poll" + pass "fm-pr-check refuses a direct-PR registration while a later commit is only in the copy" +} + test_valid_recording_and_merge_derivation() { local dir expected sidecar count rc dir=$(make_case valid-recording) @@ -611,7 +694,7 @@ SH fm_write_meta "$dir/home/state/$id.meta" \ "window=firstmate:fm-$id" \ "endpoint_task_id=$id" \ - "worktree=$dir/missing-worktree" \ + "worktree=$dir/wt" \ "project=$dir/project" \ 'kind=ship' \ 'mode=local-only' @@ -642,6 +725,7 @@ SH || fail "path-safe legacy task ID could not use the PR merge flow" fm_pr_poll_artifacts_valid "$dir/home/state" "$id" "$POLL" \ || fail "path-safe legacy task ID did not publish an authenticated poll" + rm -rf "$dir/wt" FM_HOME="$dir/home" FM_ROOT_OVERRIDE="$ROOT" PATH="$dir/fakebin:$BASE_PATH" \ "$TEARDOWN" "$id" --force > "$dir/teardown.out" 2> "$dir/teardown.err" \ || fail "legacy path-safe task ID could not be torn down" @@ -654,7 +738,7 @@ run_watcher_bounded() { local home=$1 fakebin=$2 check_interval=${FM_TEST_CHECK_INTERVAL:-0} watch_root=${FM_TEST_WATCH_ROOT:-$ROOT} local check_timeout=${FM_TEST_CHECK_TIMEOUT:-1} shift 2 - perl -e 'my $pid=fork; die unless defined $pid; if (!$pid) { exec @ARGV } local $SIG{ALRM}=sub { kill "TERM", $pid; waitpid $pid, 0; exit 124 }; alarm 10; waitpid $pid, 0; alarm 0; exit($? >> 8)' \ + perl -e 'my $pid=fork; die unless defined $pid; if (!$pid) { exec @ARGV } local $SIG{ALRM}=sub { kill "TERM", $pid; waitpid $pid, 0; exit 124 }; alarm 60; waitpid $pid, 0; alarm 0; exit($? >> 8)' \ env FM_HOME="$home" FM_ROOT_OVERRIDE="$watch_root" FM_CHECK_INTERVAL="$check_interval" FM_CHECK_TIMEOUT="$check_timeout" \ FM_POLL=0.02 FM_HEARTBEAT=999999 FM_SIGNAL_GRACE=0 PATH="$fakebin:$BASE_PATH" "$WATCH" "$@" } @@ -2166,15 +2250,12 @@ test_gitlab_merged_poll_retires() { # --- poll-path merge authority ---------------------------------------------- -write_away_record() { # <dir> [<fm-afk-contract.sh propose args>...] +write_away_record() { # <dir> [<fm-afk-contract.sh enter args>...] local dir=$1 shift FM_HOME="$dir/home" FM_STATE_OVERRIDE="$dir/home/state" \ - "$ROOT/bin/fm-afk-contract.sh" propose "$@" >/dev/null \ - || fail "could not propose an away-posture record" - FM_HOME="$dir/home" FM_STATE_OVERRIDE="$dir/home/state" \ - "$ROOT/bin/fm-afk-contract.sh" confirm >/dev/null \ - || fail "could not confirm an away-posture record" + "$ROOT/bin/fm-afk-contract.sh" enter "$@" >/dev/null \ + || fail "could not enter an away-posture record" } archive_away_record() { # <dir> @@ -2774,6 +2855,9 @@ test_retirement_refuses_replacement_and_nonterminal_results test_retirement_queue_failure_and_receipt_tampering test_gitlab_merged_poll_retires test_invalid_entrypoints_have_zero_side_effects +test_draft_pull_request_is_not_armed +test_unpushed_named_head_refuses_registration +test_direct_pr_unpushed_commit_refuses_registration test_valid_recording_and_merge_derivation test_rejected_metacharacter_bytes_are_inert test_static_poll_contract diff --git a/tests/fm-pr-merge.test.sh b/tests/fm-pr-merge.test.sh index cfa9d4f83af..c4c0549f05c 100755 --- a/tests/fm-pr-merge.test.sh +++ b/tests/fm-pr-merge.test.sh @@ -36,6 +36,8 @@ make_case() { case_dir="$TMP_ROOT/$name" fakebin="$case_dir/fakebin" mkdir -p "$case_dir/state" "$case_dir/home/data" "$case_dir/home/config" "$fakebin" + fm_git_init_commit "$case_dir/wt" + git -C "$case_dir/wt" update-ref refs/remotes/origin/main "$(git -C "$case_dir/wt" rev-parse HEAD)" cp "$ROOT/.tasks.toml" "$case_dir/home/.tasks.toml" printf '%s\n' '## In flight' '' '## Queued' '' '## Done' \ > "$case_dir/home/data/backlog.md" @@ -52,9 +54,9 @@ make_case() { 'base=main' > "$case_dir/github-outcome" : > "$case_dir/github-rules" : > "$case_dir/gh.log" - # No worktree/project on disk; fm-pr-check.sh tolerates a worktree it cannot - # stat and simply skips the pr_head lookup via `gh` in that case, so give it - # one that resolves for cases that want pr_head recorded. + # The worktree is a git copy whose HEAD is on a remote-tracking ref, as a + # pushed ship task's is, so fm-pr-check.sh's named-head gate accepts it when + # the forge supplies no head (GitLab). No project clone exists on disk. printf '%s\n' "$case_dir" } @@ -157,6 +159,10 @@ case "${1:-} ${2:-}" in cat "$FM_TEST_GH_HEAD" exit 0 ;; + *isDraft*) + cat "$FM_TEST_GH_VIEW_JSON" + exit 0 + ;; esac ;; "pr merge") @@ -424,9 +430,7 @@ write_away_record() { local case_dir=$1 shift FM_HOME="$case_dir/home" FM_STATE_OVERRIDE="$case_dir/state" \ - "$ROOT/bin/fm-afk-contract.sh" propose "$@" >/dev/null - FM_HOME="$case_dir/home" FM_STATE_OVERRIDE="$case_dir/state" \ - "$ROOT/bin/fm-afk-contract.sh" confirm >/dev/null + "$ROOT/bin/fm-afk-contract.sh" enter "$@" >/dev/null } test_verified_merge_records_pr_and_head() { @@ -2404,6 +2408,40 @@ test_github_red_checks_refuse_and_allow_red_waives_named() { pass "fm-pr-merge refuses red GitHub checks and waives only a named --allow-red check" } +# A draft cannot be merged, and neither can a pull request whose draft state the +# forge did not report as a boolean; both refuse before any merge call. +test_github_draft_or_unreadable_draft_state_refuses() { + local case_dir rc head label filter + head=dddddddddddddddddddddddddddddddddddddddd + for label in draft unreadable; do + case_dir=$(make_case "github-$label") + mkdir -p "$case_dir/wt" + add_gh_mocks "$case_dir" "$head" + case "$label" in + draft) filter='.isDraft = true' ;; + *) filter='del(.isDraft)' ;; + esac + jq -c "$filter" "$case_dir/github-view.json" > "$case_dir/github-view.tmp" + mv "$case_dir/github-view.tmp" "$case_dir/github-view.json" + + set +e + run_pr_merge "$case_dir" task-x1 https://github.com/example/repo/pull/82 \ + > "$case_dir/stdout" 2> "$case_dir/stderr" + rc=$? + set -e + expect_code 1 "$rc" "github-$label: a pull request not read as non-draft must refuse" + assert_grep "the pull request is a draft" "$case_dir/stderr" \ + "github-$label: the draft state was not named" + assert_no_grep 'pr merge' "$case_dir/gh.log" \ + "github-$label: gh pr merge ran without a non-draft reading" + assert_no_grep 'declare a wait instead of done' "$case_dir/stderr" \ + "github-$label: the arm-time draft refusal preempted the merge refusal" + grep -qxF 'pr=https://github.com/example/repo/pull/82' "$case_dir/state/task-x1.meta" \ + || fail "github-$label: pr= was not recorded before the merge refusal" + done + pass "fm-pr-merge refuses a draft pull request and one with no boolean draft state" +} + # When the base branch advances, GitHub cancels a pull request's in-flight run # and re-triggers it, leaving the cancelled run in the rollup beside the passing # re-run while reporting the pull request itself CLEAN. The merge must follow the @@ -3069,8 +3107,7 @@ SH add_gh_mocks "$case_dir" 2c2c2c2c2c2c2c2c2c2c2c2c2c2c2c2c2c2c2c2c write_away_record "$case_dir" --words 'merge task-x1 when green' mutate=$(away_change_script "$case_dir" replace-at-merge <<'SH' -"$CONTRACT" propose --words 'hold everything for my return' -"$CONTRACT" confirm +"$CONTRACT" enter --words 'hold everything for my return' SH ) export FM_TEST_AWAY_MUTATE_AT_MERGE="$mutate" @@ -3196,6 +3233,7 @@ test_untraversable_user_backend_config_directory_refuses_the_merge test_absent_user_backend_config_directory_and_backlog_still_merge test_backend_override_bypasses_unreadable_user_config test_github_red_checks_refuse_and_allow_red_waives_named +test_github_draft_or_unreadable_draft_state_refuses test_superseded_failed_check_run_no_longer_refuses test_check_runs_never_supersede_status_contexts test_current_failed_check_run_still_refuses diff --git a/tests/fm-procevent-quota.test.sh b/tests/fm-procevent-quota.test.sh index 850e10ba648..863e21a38a4 100755 --- a/tests/fm-procevent-quota.test.sh +++ b/tests/fm-procevent-quota.test.sh @@ -16,7 +16,7 @@ mkdir -p "$FAKEBIN" cat > "$FAKEBIN/quota-axi" <<'SH' #!/usr/bin/env bash if [ "${1:-}" = "--version" ]; then - printf 'quota-axi 0.1.29\n' + printf 'quota-axi 0.1.51\n' exit 0 fi case "${QUOTA_AXI_MALFORMED:-}" in @@ -56,7 +56,26 @@ case "${QUOTA_AXI_MALFORMED:-}" in printf '{"schemaVersion":5,"providers":[{"provider":" codex","quotaSemantics":{"status":"known","effectiveAvailability":[{"scope":"all_models","status":"known","effectivePercentRemaining":0,"runway":{"status":"exhausted_now"}}]}}]}\n' exit 0 ;; + schema6-keyless) + printf '{"schemaVersion":6,"providers":[{"provider":"codex","accountKey":"openai-codex","quotaSemantics":{"status":"unknown","effectiveAvailability":[]}},{"provider":"codex","quotaSemantics":{"status":"unknown","effectiveAvailability":[]}}]}\n' + exit 0 + ;; + schema6-duplicate) + printf '{"schemaVersion":6,"providers":[{"provider":"codex","accountKey":"openai-codex","quotaSemantics":{"status":"unknown","effectiveAvailability":[]}},{"provider":"codex","accountKey":"openai-codex","quotaSemantics":{"status":"unknown","effectiveAvailability":[]}}]}\n' + exit 0 + ;; esac +# Schema 6: an expanded provider (codex, two Pi lanes) puts one provider id on +# two rows keyed by accountKey; the schema 5 pair is the same state from an +# older quota-axi that only knows one codex account. +if [ "${QUOTA_AXI_SCHEMA6:-0}" = 1 ]; then + printf '{"schemaVersion":6,"providers":[{"provider":"codex","accountKey":"openai-codex","quotaSemantics":{"status":"known","effectiveAvailability":[{"scope":"all_models","status":"known","effectivePercentRemaining":3,"runway":{"status":"projected_exhaustion"}}]}},{"provider":"codex","accountKey":"openai-codex-work","quotaSemantics":{"status":"known","effectiveAvailability":[{"scope":"all_models","status":"known","effectivePercentRemaining":0,"runway":{"status":"exhausted_now"}}]}},{"provider":"cursor","accountKey":"default","quotaSemantics":{"status":"known","effectiveAvailability":[{"scope":"all_models","status":"known","effectivePercentRemaining":5,"runway":{"status":"through_reset"}}]}}]}\n' + exit 0 +fi +if [ "${QUOTA_AXI_SCHEMA5_PAIR:-0}" = 1 ]; then + printf '{"schemaVersion":5,"providers":[{"provider":"codex","quotaSemantics":{"status":"known","effectiveAvailability":[{"scope":"all_models","status":"known","effectivePercentRemaining":3,"runway":{"status":"projected_exhaustion"}}]}},{"provider":"cursor","quotaSemantics":{"status":"known","effectiveAvailability":[{"scope":"all_models","status":"known","effectivePercentRemaining":5,"runway":{"status":"through_reset"}}]}}]}\n' + exit 0 +fi if [ "${QUOTA_AXI_EXHAUSTED_DETAIL:-0}" = 1 ]; then printf '{"schemaVersion":5,"providers":[{"provider":"codex","quotaSemantics":{"status":"known","effectiveAvailability":[{"scope":"all_models","status":"known","effectivePercentRemaining":10,"runway":{"status":"exhausted_now"}},{"scope":"model:foo","status":"known","effectivePercentRemaining":5,"runway":{"status":"through_reset"}}]}}]}\n' exit 0 @@ -210,6 +229,49 @@ for malformed in schema duplicate types range runway availability known-empty se done ok "poll rejects malformed schema-five snapshots" +for malformed in schema6-keyless schema6-duplicate; do + out=$(QUOTA_AXI_MALFORMED="$malformed" QUOTA_AXI_COUNT="$COUNT" PATH="$FAKEBIN:$PATH" "$BIN/fm-procevent-quota.sh" poll --interval 1 --threshold 10 --provider codex --timeout 1) + printf '%s\n' "$out" | grep -qx 'status: error' || fail "$malformed snapshot did not report an error" + printf '%s\n' "$out" | grep -qx 'condition_polls: 1' || fail "$malformed snapshot did not stop immediately" +done +ok "poll rejects schema-six snapshots missing or repeating an account key" + +out=$(QUOTA_AXI_SCHEMA6=1 QUOTA_AXI_COUNT="$COUNT" PATH="$FAKEBIN:$PATH" "$BIN/fm-procevent-quota.sh" poll --interval 1 --threshold 10 --provider '' --timeout 1) +printf '%s\n' "$out" | grep -qx 'status: exhausted' || fail "schema 6 aggregate watch did not report the exhausted account" +printf '%s\n' "$out" | grep -qx 'condition_polls: 1' || fail "schema 6 aggregate watch did not fire on the first poll" +detail=$(printf '%s\n' "$out" | sed -n 's/^detail: //p') +printf '%s\n' "$detail" | jq -e ' + [.summary[] | select(.provider == "codex") | .accountKey] == ["openai-codex", "openai-codex-work"] and + ([.summary[] | select(.accountKey == "openai-codex-work") | .best.runway.status] == ["exhausted_now"]) and + ([.summary[] | select(.accountKey == "openai-codex") | .best.effectivePercentRemaining] == [3]) +' >/dev/null || fail "schema 6 aggregate detail did not keep each account separate: $detail" +ok "aggregate watch reads every schema 6 account row without combining them" + +out=$(QUOTA_AXI_SCHEMA6=1 QUOTA_AXI_COUNT="$COUNT" PATH="$FAKEBIN:$PATH" "$BIN/fm-procevent-quota.sh" poll --interval 1 --threshold 10 --provider cursor --timeout 1) +printf '%s\n' "$out" | grep -qx 'status: low' || fail "schema 6 provider watch included another provider's exhausted account" +detail=$(printf '%s\n' "$out" | sed -n 's/^detail: //p') +printf '%s\n' "$detail" | jq -e '.provider == "cursor" and .accountKey == "default" and .best.effectivePercentRemaining == 5' >/dev/null \ + || fail "schema 6 provider detail did not name the default account: $detail" +out=$(QUOTA_AXI_SCHEMA6=1 QUOTA_AXI_COUNT="$COUNT" PATH="$FAKEBIN:$PATH" "$BIN/fm-procevent-quota.sh" poll --interval 1 --threshold 10 --provider codex --timeout 1) +printf '%s\n' "$out" | grep -qx 'status: exhausted' || fail "expanded provider watch did not report the exhausted account" +printf '%s\n' "$out" | grep -qx 'condition_polls: 1' || fail "expanded provider watch did not stop immediately" +detail=$(printf '%s\n' "$out" | sed -n 's/^detail: //p') +printf '%s\n' "$detail" | jq -e ' + .provider == "codex" and + (.summary | length) == 2 and + all(.summary[]; .provider == "codex") and + ([.summary[] | select(.accountKey == "openai-codex") | .best.effectivePercentRemaining] == [3]) and + ([.summary[] | select(.accountKey == "openai-codex-work") | .best.runway.status] == ["exhausted_now"]) +' >/dev/null || fail "provider watch did not preserve independent account evidence: $detail" +ok "provider watch classifies every matching account and preserves accountKey in details" + +out=$(QUOTA_AXI_SCHEMA5_PAIR=1 QUOTA_AXI_COUNT="$COUNT" PATH="$FAKEBIN:$PATH" "$BIN/fm-procevent-quota.sh" poll --interval 1 --threshold 10 --provider codex --timeout 1) +printf '%s\n' "$out" | grep -qx 'status: low' || fail "schema 5 provider watch did not bind the keyless codex row" +detail=$(printf '%s\n' "$out" | sed -n 's/^detail: //p') +printf '%s\n' "$detail" | jq -e '.provider == "codex" and (has("accountKey") | not) and .best.effectivePercentRemaining == 3' >/dev/null \ + || fail "schema 5 provider detail changed shape: $detail" +ok "the same path still binds a schema 5 row by provider alone" + rm -f "$COUNT" out=$(QUOTA_AXI_UNKNOWN_FIRST=1 QUOTA_AXI_COUNT="$COUNT" PATH="$FAKEBIN:$PATH" "$BIN/fm-procevent-quota.sh" poll --interval 0.01 --threshold 10 --provider codex --timeout 1) printf '%s\n' "$out" | grep -qx 'status: exhausted' || fail "unknown quota did not continue to exhaustion" diff --git a/tests/fm-procevent.test.sh b/tests/fm-procevent.test.sh index 06b2fd45d01..3b8d3c6f7ed 100755 --- a/tests/fm-procevent.test.sh +++ b/tests/fm-procevent.test.sh @@ -19,6 +19,26 @@ set -u ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) TMP_ROOT=$(fm_test_tmproot fm-procevent-tests) export FM_PROCEVENT_CLAIM_ROOT="$TMP_ROOT/claims" +export LAVISH_AXI_STATE_DIR="$TMP_ROOT/lavish-state" +mkdir -p "$LAVISH_AXI_STATE_DIR" + +# Lavish owns this persisted session contract. The fake CLI below only handles +# poll delivery; each opened-board fixture supplies the same routing evidence +# a real `lavish-axi <artifact>` writes, without starting a server. +lavish_session() { # <artifact> [session-url] + perl -MJSON::PP -MCwd=realpath -MDigest::SHA=sha256_hex -MEncode=decode -e ' + my ($path, $artifact, $url) = @ARGV; + my $real = realpath($artifact) // die "missing fixture artifact"; + my $key = substr(sha256_hex($real), 0, 16); + my $state = { sessions => {} }; + if (-f $path) { open my $in, "<", $path or die $!; local $/; $state = decode_json(<$in>); } + $state->{sessions}{$key} = { + key => $key, file => decode("UTF-8", $real), status => "open", url => $url, + }; + open my $out, ">", $path or die $!; + print $out encode_json($state); + ' "$LAVISH_AXI_STATE_DIR/state.json" "$1" "${2:-http://127.0.0.1:14387/session/0123456789abcdef}" +} BLOCKER="$TMP_ROOT/blocker.sh" cat > "$BLOCKER" <<'SH' @@ -642,6 +662,7 @@ SH chmod +x "$LAVISH_BIN/lavish-axi" REVIEW_ART="$TMP_ROOT/review.html" printf '<h1>review</h1>\n' > "$REVIEW_ART" +lavish_session "$REVIEW_ART" lavish_id=$("$ROOT/bin/fm-procevent-lavish.sh" source-id "$REVIEW_ART") fm_test_track_procevent_home "$HLT" PATH="$LAVISH_BIN:$PATH" FM_HOME="$HLT" "$ROOT/bin/fm-procevent-lavish.sh" arm "$REVIEW_ART" >/dev/null @@ -682,6 +703,7 @@ SH chmod +x "$EMPTY_BIN/lavish-axi" QUIET_ART="$TMP_ROOT/quiet-board.html" printf '<h1>quiet</h1>\n' > "$QUIET_ART" +lavish_session "$QUIET_ART" quiet_id=$("$ROOT/bin/fm-procevent-lavish.sh" source-id "$QUIET_ART") fm_test_track_procevent_home "$HEMPTY" PATH="$EMPTY_BIN:$PATH" FM_HOME="$HEMPTY" \ @@ -729,6 +751,7 @@ set -eu n=$(cat "$MULTI_ROOT/count" 2>/dev/null || echo 0) n=$((n + 1)) printf '%s\n' "$n" > "$MULTI_ROOT/count" +printf '%s:%s\n' "${LAVISH_AXI_HOST-unset}" "${LAVISH_AXI_PORT-unset}" >> "$MULTI_ROOT/routes" for arg in "$@"; do case "$arg" in --agent-reply) ;; @@ -757,11 +780,14 @@ printf 'reply two\n' > "$MULTI_ROOT/reply2" printf 'reply three\n' > "$MULTI_ROOT/reply3" MULTI_ART="$MULTI_ROOT/board.html" printf '<h1>multi-round</h1>\n' > "$MULTI_ART" +lavish_session "$MULTI_ART" multi_id=$("$ROOT/bin/fm-procevent-lavish.sh" source-id "$MULTI_ART") fm_test_track_procevent_home "$HMULTI" new_task_endpoint "$HMULTI" worker-1 new_task_endpoint "$HMULTI" worker-2 -PATH="$MULTI_BIN:$PATH" FM_HOME="$HMULTI" \ +mkdir -p "$HMULTI/config" +printf 'wrong-server.example\n' > "$HMULTI/config/lavish-axi-host" +PATH="$MULTI_BIN:$PATH" LAVISH_AXI_HOST=arming.example LAVISH_AXI_PORT=24387 FM_HOME="$HMULTI" \ "$ROOT/bin/fm-procevent-lavish.sh" arm "$MULTI_ART" --for worker-1 \ --agent-reply-file "$MULTI_ROOT/reply1" >/dev/null if PATH="$MULTI_BIN:$PATH" FM_HOME="$HMULTI" \ @@ -773,7 +799,7 @@ assert_contains "$(cat "$MULTI_ROOT/firstmate-arm.err")" "owned by task worker-1 list_out=$(FM_HOME="$HMULTI" "$ROOT/bin/fm-procevent.sh" list) assert_contains "$list_out" "task:worker-1/dead" \ "the source list did not expose the worker-owned board state" -PATH="$MULTI_BIN:$PATH" FM_HOME="$HMULTI" \ +PATH="$MULTI_BIN:$PATH" LAVISH_AXI_HOST=recovery.example LAVISH_AXI_PORT=34387 FM_HOME="$HMULTI" \ pe "$HMULTI" start "$multi_id" > "$MULTI_ROOT/run1" 2>&1 & MULTI_RUN=$! for _ in $(seq 1 100); do [ "$(cat "$MULTI_ROOT/count" 2>/dev/null || true)" = 1 ] && break; sleep 0.02; done @@ -850,6 +876,10 @@ assert_contains "$(cat "$HMULTI/state/worker-1.inbox/003.msg" 2>/dev/null || tru || fail "worker replies were not posted once per round" assert_contains "$(cat "$MULTI_ROOT/replies")" "poll1 reply: reply one" \ "the reply staged with the arm was not the one the board received" +printf '%s\n' '127.0.0.1:14387' '127.0.0.1:14387' '127.0.0.1:14387' > "$MULTI_ROOT/expected-routes" +cmp -s "$MULTI_ROOT/expected-routes" "$MULTI_ROOT/routes" \ + || fail "worker replies/polls did not use the opened session server across start and reconcile" +pass "worker board replies and recovered listeners derive their server from the board session" # The terminal round keeps the board with worker-1 until worker-1 acknowledges # it, so the one source record stays the only ownership evidence there is: while @@ -919,6 +949,7 @@ SH chmod +x "$ORPHAN_BIN/lavish-axi" ORPHAN_ART="$TMP_ROOT/orphan-board.html" printf '<h1>orphan</h1>\n' > "$ORPHAN_ART" +lavish_session "$ORPHAN_ART" orphan_id=$("$ROOT/bin/fm-procevent-lavish.sh" source-id "$ORPHAN_ART") fm_test_track_procevent_home "$HORPHAN" new_task_endpoint "$HORPHAN" worker-4 @@ -949,6 +980,7 @@ SH chmod +x "$ADOPT_BIN/lavish-axi" ADOPT_ART="$TMP_ROOT/adopt-board.html" printf '<h1>adopt</h1>\n' > "$ADOPT_ART" +lavish_session "$ADOPT_ART" adopt_id=$("$ROOT/bin/fm-procevent-lavish.sh" source-id "$ADOPT_ART") fm_test_track_procevent_home "$HADOPT" new_task_endpoint "$HADOPT" worker-5 @@ -981,6 +1013,7 @@ pass "an orphaned capture is not acknowledged by a worker it never reached" HNOMETA="$TMP_ROOT/hnometa"; new_home "$HNOMETA" NOMETA_ART="$TMP_ROOT/nometa-board.html" printf '<h1>no endpoint</h1>\n' > "$NOMETA_ART" +lavish_session "$NOMETA_ART" nometa_id=$("$ROOT/bin/fm-procevent-lavish.sh" source-id "$NOMETA_ART") fm_test_track_procevent_home "$HNOMETA" if PATH="$ADOPT_BIN:$PATH" FM_HOME="$HNOMETA" \ @@ -1007,6 +1040,7 @@ pass "a worker-owned board is only armed for an owner its feedback can reach" HREDELIVER="$TMP_ROOT/hredeliver"; new_home "$HREDELIVER" REDELIVER_ART="$TMP_ROOT/redeliver-board.html" printf '<h1>redeliver</h1>\n' > "$REDELIVER_ART" +lavish_session "$REDELIVER_ART" redeliver_id=$("$ROOT/bin/fm-procevent-lavish.sh" source-id "$REDELIVER_ART") fm_test_track_procevent_home "$HREDELIVER" new_task_endpoint "$HREDELIVER" worker-6 @@ -1037,6 +1071,7 @@ SH chmod +x "$CONC_BIN/lavish-axi" CONC_ART="$TMP_ROOT/conclude-board.html" printf '<h1>conclude</h1>\n' > "$CONC_ART" +lavish_session "$CONC_ART" conc_id=$("$ROOT/bin/fm-procevent-lavish.sh" source-id "$CONC_ART") fm_test_track_procevent_home "$HCONC" new_task_endpoint "$HCONC" worker-7 @@ -1090,6 +1125,7 @@ SH chmod +x "$INTR_BIN/lavish-axi" INTR_ART="$TMP_ROOT/interrupted-board.html" printf '<h1>interrupted</h1>\n' > "$INTR_ART" +lavish_session "$INTR_ART" intr_id=$("$ROOT/bin/fm-procevent-lavish.sh" source-id "$INTR_ART") fm_test_track_procevent_home "$HINTR" new_task_endpoint "$HINTR" worker-12 @@ -1132,6 +1168,7 @@ SH chmod +x "$ROLL_BIN/lavish-axi" ROLL_ART="$TMP_ROOT/rollback-board.html" printf '<h1>rollback</h1>\n' > "$ROLL_ART" +lavish_session "$ROLL_ART" roll_id=$("$ROOT/bin/fm-procevent-lavish.sh" source-id "$ROLL_ART") fm_test_track_procevent_home "$HROLL" new_task_endpoint "$HROLL" worker-8 @@ -1181,6 +1218,7 @@ SH chmod +x "$REARM_BIN/lavish-axi" REARM_ART="$TMP_ROOT/rearm-board.html" printf '<h1>rearm</h1>\n' > "$REARM_ART" +lavish_session "$REARM_ART" rearm_id=$("$ROOT/bin/fm-procevent-lavish.sh" source-id "$REARM_ART") fm_test_track_procevent_home "$HREARM" new_task_endpoint "$HREARM" worker-11 @@ -1234,6 +1272,7 @@ SH chmod +x "$ANSWER_BIN/lavish-axi" ANSWER_ART="$TMP_ROOT/answered-board.html" printf '<h1>answered</h1>\n' > "$ANSWER_ART" +lavish_session "$ANSWER_ART" answer_id=$("$ROOT/bin/fm-procevent-lavish.sh" source-id "$ANSWER_ART") fm_test_track_procevent_home "$HANSWER" PATH="$ANSWER_BIN:$PATH" FM_HOME="$HANSWER" \ @@ -1302,6 +1341,7 @@ export LAVISH_COUNT LAVISH_SCRIPT DEFAULT_RATE_ART="$TMP_ROOT/default-rate-board.html" printf '<h1>default rate</h1>\n' > "$DEFAULT_RATE_ART" +lavish_session "$DEFAULT_RATE_ART" DEFAULT_RATE_COUNT="$TMP_ROOT/default-rate-count" PATH="$LAVISH_SCRIPTED_BIN:$PATH" LAVISH_COUNT="$DEFAULT_RATE_COUNT" LAVISH_SCRIPT=interrupt \ FM_LAVISH_POLL_RETRY_DELAY='' \ @@ -1326,6 +1366,7 @@ export FM_LAVISH_POLL_RETRY_DELAY=1 HRETRY="$TMP_ROOT/hretry"; new_home "$HRETRY" RETRY_ART="$TMP_ROOT/retry-board.html" printf '<h1>retry</h1>\n' > "$RETRY_ART" +lavish_session "$RETRY_ART" retry_id=$("$ROOT/bin/fm-procevent-lavish.sh" source-id "$RETRY_ART") fm_test_track_procevent_home "$HRETRY" LAVISH_COUNT="$TMP_ROOT/retry-count"; LAVISH_SCRIPT="interrupt interrupt feedback" @@ -1353,6 +1394,7 @@ pass "a transient Lavish poll interruption is retried quietly and never announce HREPLY="$TMP_ROOT/hreply"; new_home "$HREPLY" REPLY_ART="$TMP_ROOT/reply-retry-board.html" printf '<h1>reply retry</h1>\n' > "$REPLY_ART" +lavish_session "$REPLY_ART" reply_id=$("$ROOT/bin/fm-procevent-lavish.sh" source-id "$REPLY_ART") fm_test_track_procevent_home "$HREPLY" new_task_endpoint "$HREPLY" worker-9 @@ -1418,6 +1460,7 @@ GONE_ART="$TMP_ROOT/artifact-gone-board.html" GONE_REPLY="$TMP_ROOT/artifact-gone-reply" GONE_COUNT="$TMP_ROOT/artifact-gone-count" printf '<h1>gone</h1>\n' > "$GONE_ART" +lavish_session "$GONE_ART" printf 'owed to the next listener\n' > "$GONE_REPLY" rm -f "$GONE_ART" gone_status=0 @@ -1437,6 +1480,7 @@ pass "a listener whose artifact vanished leaves the staged reply for the next on HEXH="$TMP_ROOT/hexh"; new_home "$HEXH" EXH_ART="$TMP_ROOT/exhaust-board.html" printf '<h1>exhaust</h1>\n' > "$EXH_ART" +lavish_session "$EXH_ART" exh_id=$("$ROOT/bin/fm-procevent-lavish.sh" source-id "$EXH_ART") fm_test_track_procevent_home "$HEXH" LAVISH_COUNT="$TMP_ROOT/exhaust-count"; LAVISH_SCRIPT="interrupt" @@ -1460,6 +1504,7 @@ pass "an interruption that outlives the bounded retries is captured and announce HOTHER="$TMP_ROOT/hother"; new_home "$HOTHER" OTHER_ART="$TMP_ROOT/other-board.html" printf '<h1>other</h1>\n' > "$OTHER_ART" +lavish_session "$OTHER_ART" other_id=$("$ROOT/bin/fm-procevent-lavish.sh" source-id "$OTHER_ART") fm_test_track_procevent_home "$HOTHER" LAVISH_COUNT="$TMP_ROOT/other-count"; LAVISH_SCRIPT="other-server-error" @@ -1480,6 +1525,7 @@ unset FM_LAVISH_POLL_RETRY_DELAY HNEAR="$TMP_ROOT/hnear"; new_home "$HNEAR" NEAR_ART="$TMP_ROOT/near-board.html" printf '<h1>near</h1>\n' > "$NEAR_ART" +lavish_session "$NEAR_ART" near_id=$("$ROOT/bin/fm-procevent-lavish.sh" source-id "$NEAR_ART") fm_test_track_procevent_home "$HNEAR" LAVISH_COUNT="$TMP_ROOT/near-count"; LAVISH_SCRIPT="near-interrupt feedback" @@ -1499,6 +1545,7 @@ pass "only the literal two-line interruption enters the quiet retry policy" HINVALID="$TMP_ROOT/hinvalid"; new_home "$HINVALID" INVALID_ART="$TMP_ROOT/invalid-delay-board.html" printf '<h1>invalid delay</h1>\n' > "$INVALID_ART" +lavish_session "$INVALID_ART" invalid_id=$("$ROOT/bin/fm-procevent-lavish.sh" source-id "$INVALID_ART") for invalid_delay in 0 61 invalid; do invalid_status=0 @@ -1532,6 +1579,7 @@ LAVISH_STREAM_READY="$TMP_ROOT/stream-ready" LAVISH_STREAM_RELEASE="$TMP_ROOT/stream-release" mkdir -p "$STREAM_TMPDIR" printf '<h1>stream</h1>\n' > "$STREAM_ART" +lavish_session "$STREAM_ART" stream_id=$("$ROOT/bin/fm-procevent-lavish.sh" source-id "$STREAM_ART") fm_test_track_procevent_home "$HSTREAM" LAVISH_COUNT="$TMP_ROOT/stream-count"; LAVISH_SCRIPT="stream" @@ -2743,6 +2791,7 @@ pass "invalid output bounds fail closed" # --- the Lavish adapter uses the published poll shape ----------------------- ART="$TMP_ROOT/artifact.html" printf '<h1>fixture</h1>\n' > "$ART" +lavish_session "$ART" sid=$(FM_HOME="$TMP_ROOT/hg" "$ROOT/bin/fm-procevent-lavish.sh" source-id "$ART") case "$sid" in lavish-*) : ;; *) fail "adapter source id has an unexpected shape: $sid" ;; esac sid2=$(FM_HOME="$TMP_ROOT/hg" "$ROOT/bin/fm-procevent-lavish.sh" source-id "$ART") @@ -2796,69 +2845,72 @@ pass "the adapter classifies published poll output safely" HOST_HOME="$TMP_ROOT/host-config" mkdir -p "$HOST_HOME/config" printf '%s\n' '100.99.161.42' > "$HOST_HOME/config/lavish-axi-host" -HOST_ART="$TMP_ROOT/host-config-board.html" -printf '<h1>host config</h1>\n' > "$HOST_ART" -HOST_SEEN="$TMP_ROOT/host-config-seen" -HOST_BIN=$(fm_fakebin "$TMP_ROOT/host-config-bin") +HOST_ART="$TMP_ROOT/board, '评审'.html" +printf '<h1>session routing</h1>\n' > "$HOST_ART" +HOST_SEEN="$TMP_ROOT/session-route-seen" +HOST_BIN=$(fm_fakebin "$TMP_ROOT/session-route-bin") cat > "$HOST_BIN/lavish-axi" <<'SH' #!/usr/bin/env bash -if [ -n "${HOST_RETRY_SEEN-}" ]; then - if [ "${LAVISH_AXI_HOST+x}" = x ]; then - printf 'set:%s\n' "$LAVISH_AXI_HOST" >> "$HOST_RETRY_SEEN" - else - printf 'unset\n' >> "$HOST_RETRY_SEEN" - fi - if [ "$(wc -l < "$HOST_RETRY_SEEN" | tr -d ' ')" = 1 ]; then - rm -f "$HOST_CONFIG_FILE" - printf 'error: Lavish Editor poll response was interrupted\ncode: SERVER_ERROR\n' - else - printf 'session:\n file: /host-config.html\n status: ended\n ended_by: user\n' - fi +[ "${1-}" = poll ] || exit 2 +printf '%s:%s\n' "${LAVISH_AXI_HOST-unset}" "${LAVISH_AXI_PORT-unset}" >> "$HOST_SEEN" +if [ -n "${HOST_RETRY-}" ] && [ "$(wc -l < "$HOST_SEEN" | tr -d ' ')" = 1 ]; then + rm -f "$HOST_CONFIG_FILE" + printf 'error: Lavish Editor poll response was interrupted\ncode: SERVER_ERROR\n' else - printf '%s\n' "${LAVISH_AXI_HOST-}" > "$HOST_SEEN" - printf 'session:\n file: /host-config.html\n status: ended\n ended_by: user\n' + printf 'session:\n status: ended\n ended_by: user\n' fi SH chmod +x "$HOST_BIN/lavish-axi" -PATH="$HOST_BIN:$PATH" HOST_SEEN="$HOST_SEEN" LAVISH_AXI_HOST=wrong.example FM_HOME="$HOST_HOME" \ - "$ROOT/bin/fm-procevent-lavish.sh" poll "$HOST_ART" >/dev/null -assert_grep '100.99.161.42' "$HOST_SEEN" \ - "the adapter poll did not read config/lavish-axi-host before invoking lavish-axi" -pass "Lavish poll uses the configured per-machine board address" - -HOST_RETRY_SEEN="$TMP_ROOT/host-config-retry-seen" -HOST_RETRY_EXPECTED="$TMP_ROOT/host-config-retry-expected" -printf '%s\n%s\n' 'set:100.99.161.42' 'set:ambient.example' > "$HOST_RETRY_EXPECTED" -PATH="$HOST_BIN:$PATH" HOST_RETRY_SEEN="$HOST_RETRY_SEEN" \ - HOST_CONFIG_FILE="$HOST_HOME/config/lavish-axi-host" LAVISH_AXI_HOST=ambient.example \ - FM_LAVISH_POLL_RETRY_DELAY=1 FM_HOME="$HOST_HOME" \ - "$ROOT/bin/fm-procevent-lavish.sh" poll "$HOST_ART" >/dev/null -cmp -s "$HOST_RETRY_EXPECTED" "$HOST_RETRY_SEEN" \ - || fail "Lavish poll did not restore its original host after configuration removal" +# Re-reading the session makes its saved endpoint authoritative without a +# Firstmate route record, even when the same artifact is subsequently reopened. +for endpoint in '127.0.0.1:14387' 'board.example:24387' '[::1]:34387'; do + lavish_session "$HOST_ART" "http://$endpoint/session/0123456789abcdef" + : > "$HOST_SEEN" + PATH="$HOST_BIN:$PATH" HOST_SEEN="$HOST_SEEN" LAVISH_AXI_HOST=wrong.example \ + LAVISH_AXI_PORT=44387 FM_HOME="$HOST_HOME" \ + "$ROOT/bin/fm-procevent-lavish.sh" poll "$HOST_ART" >/dev/null + expected=${endpoint//\[/}; expected=${expected//\]/} + [ "$(cat "$HOST_SEEN")" = "$expected" ] \ + || fail "poll did not derive the endpoint from the Unicode-path board session" +done +pass "poll derives host and port from the artifact session, not ambient or configured routing" -HOST_RETRY_UNSET_SEEN="$TMP_ROOT/host-config-retry-unset-seen" -printf '%s\n' '100.99.161.42' > "$HOST_HOME/config/lavish-axi-host" -printf '%s\n%s\n' 'set:100.99.161.42' 'unset' > "$HOST_RETRY_EXPECTED" -env -u LAVISH_AXI_HOST PATH="$HOST_BIN:$PATH" HOST_RETRY_SEEN="$HOST_RETRY_UNSET_SEEN" \ - HOST_CONFIG_FILE="$HOST_HOME/config/lavish-axi-host" FM_LAVISH_POLL_RETRY_DELAY=1 \ - FM_HOME="$HOST_HOME" "$ROOT/bin/fm-procevent-lavish.sh" poll "$HOST_ART" >/dev/null -cmp -s "$HOST_RETRY_EXPECTED" "$HOST_RETRY_UNSET_SEEN" \ - || fail "Lavish poll did not restore its originally unset host after configuration removal" -pass "Lavish poll restores its original host when configuration disappears" - -HOST_BLOCKED_HOME="$TMP_ROOT/host-config-blocked" -mkdir -p "$HOST_BLOCKED_HOME" -printf '%s\n' 'not a directory' > "$HOST_BLOCKED_HOME/config" +lavish_session "$HOST_ART" : > "$HOST_SEEN" -host_blocked_status=0 -host_blocked_out=$(PATH="$HOST_BIN:$PATH" HOST_SEEN="$HOST_SEEN" LAVISH_AXI_HOST=wrong.example \ - FM_HOME="$HOST_BLOCKED_HOME" "$ROOT/bin/fm-procevent-lavish.sh" poll "$HOST_ART" 2>&1) \ - || host_blocked_status=$? -[ "$host_blocked_status" -ne 0 ] || fail "an uninspectable Lavish host configuration was treated as absent" -assert_contains "$host_blocked_out" "must be a readable regular file" \ - "an uninspectable Lavish host configuration fails closed" -[ ! -s "$HOST_SEEN" ] || fail "lavish-axi was called after host configuration inspection failed" -pass "Lavish poll fails closed when host configuration cannot be inspected" +PATH="$HOST_BIN:$PATH" HOST_SEEN="$HOST_SEEN" HOST_RETRY=1 \ + HOST_CONFIG_FILE="$HOST_HOME/config/lavish-axi-host" LAVISH_AXI_HOST=ambient.example \ + LAVISH_AXI_PORT=44387 FM_LAVISH_POLL_RETRY_DELAY=1 FM_HOME="$HOST_HOME" \ + "$ROOT/bin/fm-procevent-lavish.sh" poll "$HOST_ART" >/dev/null +printf '%s\n%s\n' '127.0.0.1:14387' '127.0.0.1:14387' > "$HOST_HOME/expected" +cmp -s "$HOST_HOME/expected" "$HOST_SEEN" \ + || fail "a retry switched away from the session server after config removal" +pass "quiet retries use the board session regardless of configuration changes" + +# Route lookup is read-only and precedes reply consumption. Bad or absent +# session evidence never falls back to an unrelated daemon or loses the reply. +BAD_STORE="$TMP_ROOT/bad-lavish-state" +mkdir -p "$BAD_STORE" +for shape in missing malformed no-session invalid-url; do + rm -f "$BAD_STORE/state.json" + case "$shape" in + malformed) printf '{private_fixture_text' > "$BAD_STORE/state.json" ;; + no-session) printf '{"sessions":{}}\n' > "$BAD_STORE/state.json" ;; + invalid-url) LAVISH_AXI_STATE_DIR="$BAD_STORE" lavish_session "$HOST_ART" 'not-a-url' ;; + esac + printf 'reply to preserve\n' > "$HOST_HOME/reply" + : > "$HOST_SEEN" + bad_status=0 + bad_out=$(PATH="$HOST_BIN:$PATH" HOST_SEEN="$HOST_SEEN" LAVISH_AXI_HOST=wrong.example \ + LAVISH_AXI_STATE_DIR="$BAD_STORE" FM_HOME="$HOST_HOME" \ + "$ROOT/bin/fm-procevent-lavish.sh" poll "$HOST_ART" \ + --agent-reply-file "$HOST_HOME/reply" 2>&1) || bad_status=$? + [ "$bad_status" -ne 0 ] || fail "$shape session evidence was accepted" + [ ! -s "$HOST_SEEN" ] || fail "$shape session evidence reached the CLI" + [ "$(cat "$HOST_HOME/reply")" = 'reply to preserve' ] \ + || fail "$shape session evidence consumed the staged reply" + assert_not_contains "$bad_out" private_fixture_text "JSON errors must not print session content" +done +pass "missing or unreadable session routing preserves replies and never guesses another server" # The adapter, not the runner, decides which results end a Lavish source. A # final feedback delivery still classifies as feedback for the handler while @@ -3249,20 +3301,24 @@ HFLOOR="$TMP_ROOT/launch-floor"; new_home "$HFLOOR" fm_test_track_procevent_home "$HFLOOR" pe_register "$HFLOOR" lavish floor-src -- \ "$STORM_SOURCE" "$TMP_ROOT/launch-times" "$HFLOOR" "$ROOT" -FM_PROCEVENT_OWNER_LEASE_SECONDS=4 FM_PROCEVENT_OWNER_CHECK_SECONDS=1 \ +# Three real launches can outlive a four-second lease on a loaded host. Give +# this fixture a bounded observation window, then retire it as soon as sampled +# rather than leaving its orphan loop running alongside the remaining tests. +FM_PROCEVENT_OWNER_LEASE_SECONDS=30 FM_PROCEVENT_OWNER_CHECK_SECONDS=1 \ FM_PROCEVENT_LAUNCH_FLOOR_SECONDS=1 pe "$HFLOOR" reconcile >/dev/null -floor_deadline=$((SECONDS + 12)) +floor_deadline=$((SECONDS + 30)) while :; do floor_count=0 [ ! -f "$TMP_ROOT/launch-times" ] \ || floor_count=$(wc -l < "$TMP_ROOT/launch-times" | tr -d ' ') [ "$floor_count" -ge 3 ] && break [ "$SECONDS" -lt "$floor_deadline" ] \ - || fail "the orphan-storm fixture did not relaunch its source command" + || fail "the orphan-storm fixture launched only $floor_count times within its observation window" sleep 0.1 done launch_count=$(wc -l < "$TMP_ROOT/launch-times" | tr -d ' ') launch_span=$(perl -e '@t=<>; printf "%.3f", $t[-1] - $t[0]' "$TMP_ROOT/launch-times") +pe "$HFLOOR" retire floor-src >/dev/null perl -e 'exit($ARGV[0] >= ($ARGV[1] - 1) * 0.8 ? 0 : 1)' "$launch_span" "$launch_count" \ || fail "an orphaned source launched $launch_count times in only ${launch_span}s" [ "$launch_count" -le 6 ] \ diff --git a/tests/fm-public-followup.test.sh b/tests/fm-public-followup.test.sh index c5551d99a0b..a2d36d208f3 100755 --- a/tests/fm-public-followup.test.sh +++ b/tests/fm-public-followup.test.sh @@ -428,6 +428,39 @@ test_restart_e2e_delivers_exactly_once() { pass "restart end-to-end: typed result reconciles from disk and delivers one reply to the original thread" } +# A promised-final expecting pr-merged whose bound work ends failed (the only +# typed outcome a failed or parked lane can report) must still become +# deliverable, so the owed public reply carries the honest outcome instead of +# stranding at pending-work with no delivery path. Needs tasks-axi 0.2.6. +test_failed_work_on_pr_merged_promise_delivers_honest_outcome() { + local home log out posts + home=$(make_home failed-deliver) + log="$home/curl.log"; : > "$log" + seed_commitment "$home" pf-failed req-failed discord main work-failed + "$EMIT" --home "$home" --obligation pf-failed --relation rel-code --source-home main \ + --work-id work-failed --generation 1 --outcome failed --deliverable error_code=quota-exhausted \ + --outcome-text 'This one did not pan out: the worker ran out of quota before it could open a fix.' \ + >/dev/null || fail "the failed terminal result could not be reported" + + out=$(FAKE_CURL_LOG="$log" run_pf "$home" consume) || fail "reconciliation failed: $out" + assert_contains "$out" "ready pf-failed req-failed discord" \ + "a failed outcome on a pr-merged promise must become delivery-ready" + [ "$(delivery_state "$home" pf-failed)" = ready ] \ + || fail "the failed outcome must move the commitment to ready, got '$(delivery_state "$home" pf-failed)'" + + out=$(FAKE_CURL_LOG="$log" run_pf "$home" deliver pf-failed) || fail "delivery failed: $out" + assert_contains "$out" "delivered pf-failed request=req-failed platform=discord" \ + "delivery must report the original request binding" + posts=$(followup_posts "$log") + [ "$posts" -eq 1 ] || fail "expected exactly one public reply, got $posts" + assert_grep '"request_id":"req-failed"' "$log" "the reply must target the original request" + assert_grep 'the worker ran out of quota before it could open a fix' "$log" \ + "the reply must carry the accepted failed outcome text verbatim" + [ "$(task_state "$home" pf-failed)" = 'done' ] \ + || fail "the commitment must be Done after the posted receipt" + pass "failed work on a pr-merged promise delivers its honest outcome exactly once" +} + # --- 2. idempotency ------------------------------------------------------------ test_duplicate_event_and_replay_are_noops() { @@ -3147,6 +3180,7 @@ fi test_ambient_tasks_axi_env_never_reaches_a_real_backlog test_outcome_text_is_bounded_without_corrupting_characters test_restart_e2e_delivers_exactly_once +test_failed_work_on_pr_merged_promise_delivers_honest_outcome test_duplicate_event_and_replay_are_noops test_invalid_events_are_refused_and_quarantined test_relay_failure_holds_without_false_completion diff --git a/tests/fm-quota-choose.test.sh b/tests/fm-quota-choose.test.sh index 095e292365f..58ff190e137 100755 --- a/tests/fm-quota-choose.test.sh +++ b/tests/fm-quota-choose.test.sh @@ -44,6 +44,11 @@ MALFORMED_COUNTED_TOON="$LAB/malformed-counted-quota.toon" UNKNOWN_EXHAUSTED_TOON="$LAB/unknown-exhausted-quota.toon" TRAILING_EMPTY_TOON="$LAB/trailing-empty-quota.toon" QUOTED_TOON="$LAB/quoted-quota.toon" +SCHEMA6="$LAB/schema6.json" +SCHEMA5_PAIR="$LAB/schema5-pair.json" +SCHEMA6_KEYLESS="$LAB/schema6-keyless.json" +SCHEMA6_DUPLICATE="$LAB/schema6-duplicate.json" +SCHEMA6_TOON="$LAB/schema6-quota.toon" FAKEBIN="$LAB/fakebin" CALLS="$LAB/calls" @@ -147,7 +152,7 @@ cat > "$FAKEBIN/quota-axi" <<'SH' #!/usr/bin/env bash printf 'called\n' >> "${QUOTA_AXI_CALLS:?}" if [ "${1:-}" = "--version" ]; then - echo "quota-axi 0.1.29" + echo "quota-axi 0.1.51" exit 0 fi cat "${QUOTA_AXI_FIXTURE:?}" @@ -641,6 +646,89 @@ fi [ "$err" = "error: invalid quota-axi provider data" ] || fail "invalid availability status returned: $err" ok "invalid availability status fails closed" +# Schema 6: quota-axi keys every row by provider + accountKey once a provider +# expands to several accounts. Shaped like a real expanded snapshot: two codex +# rows with different keys and percentages plus default-keyed providers. +cat > "$SCHEMA6" <<'JSON' +{ + "generatedAt": "2030-01-01T00:00:00Z", + "schemaVersion": 6, + "providers": [ + { "provider": "claude", "accountKey": "default", "quotaSemantics": { "status": "unknown", "effectiveAvailability": [] } }, + { "provider": "codex", "accountKey": "openai-codex", "quotaSemantics": { "status": "known", "effectiveAvailability": [ + { "scope": "all_models", "status": "known", "effectivePercentRemaining": 3, "runway": { "status": "projected_exhaustion" } } ] } }, + { "provider": "codex", "accountKey": "openai-codex-work", "quotaSemantics": { "status": "known", "effectiveAvailability": [ + { "scope": "all_models", "status": "known", "effectivePercentRemaining": 11, "runway": { "status": "projected_exhaustion" } } ] } }, + { "provider": "cursor", "accountKey": "default", "quotaSemantics": { "status": "known", "effectiveAvailability": [ + { "scope": "all_models", "status": "known", "effectivePercentRemaining": 24, "runway": { "status": "projected_exhaustion" } } ] } } + ] +} +JSON +out=$(call_choose --snapshot "$SCHEMA6" --candidate codex:default --candidate cursor:default) +[ "$out" = "cursor default" ] || fail "schema 6 snapshot returned: $out" +ok "native Codex never infers an account from a Pi lane" + +SCHEMA6_NATIVE="$LAB/schema6-native.json" +jq ' + .providers |= map(if .provider == "codex" then + .quotaSemantics.effectiveAvailability |= map(.effectivePercentRemaining = 0 | .runway.status = "exhausted_now") + else . end) | + (.providers[] | select(.accountKey == "openai-codex-work")) as $account | + .providers += [($account | .accountKey = "default"), + ($account | .accountKey = "codex-home" | + .quotaSemantics.effectiveAvailability |= map(.effectivePercentRemaining = 80 | .runway.status = "through_reset"))] +' "$SCHEMA6" > "$SCHEMA6_NATIVE" +for model in default gpt-5.6-sol; do + out=$(call_choose --snapshot "$SCHEMA6_NATIVE" --candidate "codex:$model" --candidate cursor:default) + [ "$out" = "codex $model" ] || fail "native Codex did not select codex-home for $model: $out" +done +jq '.providers |= reverse' "$SCHEMA6_NATIVE" > "$LAB/schema6-reversed.json" +out=$(call_choose --snapshot "$LAB/schema6-reversed.json" --candidate codex:default --candidate cursor:default) +[ "$out" = "codex default" ] || fail "native Codex selection depended on row order: $out" + +jq '.providers |= map(select(.provider != "codex" or .accountKey != "default") | + if .accountKey == "codex-home" then .accountKey = "default" else . end)' "$SCHEMA6_NATIVE" > "$LAB/schema6-default.json" +out=$(call_choose --snapshot "$LAB/schema6-default.json" --candidate codex:default --candidate cursor:default) +[ "$out" = "codex default" ] || fail "native Codex did not fall back to the default row: $out" +ok "native Codex binds to codex-home before default, independently of model and row order" + +jq '.schemaVersion = 5 | .providers |= unique_by(.provider) | del(.providers[].accountKey)' "$SCHEMA6" > "$SCHEMA5_PAIR" +out=$(call_choose --snapshot "$SCHEMA5_PAIR" --candidate codex:default --candidate cursor:default) +[ "$out" = "codex default" ] || fail "schema 5 pair snapshot returned: $out" +ok "the same path still selects from a schema 5 snapshot by provider alone" + +jq 'del(.providers[1].accountKey)' "$SCHEMA6" > "$SCHEMA6_KEYLESS" +if err=$(call_choose --snapshot "$SCHEMA6_KEYLESS" --candidate cursor:default 2>&1); then + fail "schema 6 row without accountKey unexpectedly dispatched" +fi +[ "$err" = "error: invalid quota-axi provider data" ] || fail "keyless schema 6 row returned: $err" +jq '.providers[2].accountKey = "openai-codex"' "$SCHEMA6" > "$SCHEMA6_DUPLICATE" +if err=$(call_choose --snapshot "$SCHEMA6_DUPLICATE" --candidate cursor:default 2>&1); then + fail "duplicate provider + accountKey unexpectedly dispatched" +fi +[ "$err" = "error: invalid quota-axi provider data" ] || fail "duplicate schema 6 key returned: $err" +ok "schema 6 requires accountKey on every row and uniqueness on provider + accountKey" + +cat > "$SCHEMA6_TOON" <<'TOON' +bin: ~/.local/bin/quota-axi +description: Report local agent-provider quota windows for routing-aware agents +generatedAt: "2030-01-01T00:00:00Z" +quota[3]{provider,accountKey,scope,effectivePercentRemaining,spendPriority,runway,confidence,limitedBy,resetsAt}: + codex,openai-codex,all_models,3,-1.4788,projected_exhaustion,established,weekly,"2030-01-03T00:00:00Z" + codex,openai-codex-work,all_models,11,-5.6818,projected_exhaustion,established,weekly,"2030-01-07T00:00:00Z" + cursor,default,all_models,24,0.3917,projected_exhaustion,established,auto_usage,"2030-01-12T00:00:00Z" +exhaustion[2]{provider,accountKey,scope,usableRunwaySeconds,projectedExhaustedAt,limitingWindowId}: + codex,openai-codex,all_models,11644,"2030-01-01T03:00:00Z",weekly + codex,openai-codex-work,all_models,11447,"2030-01-01T03:00:00Z",weekly +attention[1]{provider,accountKey,scope,kind,detail,remedy}: + claude,default,all,auth_required,keychain_prompt_required · reason keychain_access_required,quota-axi --allow-keychain-prompt +help[1]: + Run `quota-axi --full` for windows, pace, reserve, and account evidence +TOON +out=$(call_choose --snapshot "$SCHEMA6_TOON" --candidate claude:default --candidate codex:default --candidate cursor:default) +[ "$out" = "cursor default" ] || fail "schema 6 TOON snapshot returned: $out" +ok "schema 6 TOON with the accountKey column is accepted" + [ "$(wc -l < "$CALLS" | tr -d '[:space:]')" = 1 ] || fail "helper took an additional quota snapshot" ok "helper reuses the captured quota snapshot" diff --git a/tests/fm-remote-doctor.test.sh b/tests/fm-remote-doctor.test.sh index b9a2168bd16..b134a815e7b 100755 --- a/tests/fm-remote-doctor.test.sh +++ b/tests/fm-remote-doctor.test.sh @@ -273,7 +273,7 @@ SH cat > "$CASE_BIN/tasks-axi" <<'SH' #!/usr/bin/env bash case "${1:-}:${2:-}" in - --version:*) printf '0.2.4\n' ;; + --version:*) printf '0.2.6\n' ;; update:--help) printf '%s\n' --archive-body ;; mv:--help) printf '%s\n' 'usage: tasks-axi mv <id> [<id>...]' ;; esac diff --git a/tests/fm-secondmate-harness.test.sh b/tests/fm-secondmate-harness.test.sh index 4b1b89b3e67..498e7595ba7 100755 --- a/tests/fm-secondmate-harness.test.sh +++ b/tests/fm-secondmate-harness.test.sh @@ -1072,7 +1072,7 @@ make_fake_toolchain() { fakebin="$dir/fakebin" mkdir -p "$fakebin" fm_fake_exit0 "$fakebin" node chrome-devtools-axi - fm_fake_version_tool "$fakebin" lavish-axi FM_FAKE_LAVISH_AXI_VERSION 0.1.46 + fm_fake_version_tool "$fakebin" lavish-axi FM_FAKE_LAVISH_AXI_VERSION 0.1.77 cat > "$fakebin/gh-axi" <<'SH' #!/usr/bin/env bash if [ "${1:-}" = --version ]; then @@ -1135,7 +1135,7 @@ SH cat > "$fakebin/tasks-axi" <<'SH' #!/usr/bin/env bash case "${1:-} ${2:-}" in - "--version ") printf '%s\n' '0.2.4' ;; + "--version ") printf '%s\n' '0.2.6' ;; "update --help") printf '%s\n' 'usage: tasks-axi update <id> [flags]' ' --archive-body' ;; "mv --help") printf '%s\n' 'usage: tasks-axi mv <id> [<id>...] --to <path-or-dir>' ;; esac @@ -1145,7 +1145,7 @@ SH cat > "$fakebin/quota-axi" <<'SH' #!/usr/bin/env bash if [ "${1:-}" = --version ]; then - printf '%s\n' '0.1.29' + printf '%s\n' '0.1.51' exit 0 fi exit 0 diff --git a/tests/fm-secondmate-liveness.test.sh b/tests/fm-secondmate-liveness.test.sh index 13fe87e025b..2cd574daa09 100755 --- a/tests/fm-secondmate-liveness.test.sh +++ b/tests/fm-secondmate-liveness.test.sh @@ -162,10 +162,12 @@ SH test_herdr_agent_state_preserves_husk_classifier() { local pane_state expected out + # Pin the session server as running so an installed herdr on the host + # cannot turn the unknown row into a stopped-server `missing`. for row in 'dead missing' 'no-agent dead' 'live alive' 'unknown unreadable'; do pane_state=${row%% *} expected=${row#* } - out=$(FM_TEST_PANE_STATE="$pane_state" bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_pane_agent_state() { printf "%s" "$FM_TEST_PANE_STATE"; }; fm_backend_herdr_agent_state "sess:p1"' "$ROOT") + out=$(FM_TEST_PANE_STATE="$pane_state" bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_pane_agent_state() { printf "%s" "$FM_TEST_PANE_STATE"; }; fm_backend_herdr_server_running_state() { printf running; }; fm_backend_herdr_agent_state "sess:p1"' "$ROOT") [ "$out" = "$expected" ] || fail "Herdr pane state $pane_state should map to $expected, got '$out'" done @@ -207,7 +209,7 @@ make_toolchain() { local dir=$1 fakebin fakebin=$(fm_fakebin "$dir") fm_fake_exit0 "$fakebin" node chrome-devtools-axi pi-signed - fm_fake_version_tool "$fakebin" lavish-axi FM_FAKE_LAVISH_AXI_VERSION 0.1.46 + fm_fake_version_tool "$fakebin" lavish-axi FM_FAKE_LAVISH_AXI_VERSION 0.1.77 cat > "$fakebin/gh-axi" <<'SH' #!/usr/bin/env bash if [ "${1:-}" = --version ]; then @@ -242,7 +244,7 @@ SH cat > "$fakebin/tasks-axi" <<'SH' #!/usr/bin/env bash case "${1:-} ${2:-}" in - "--version ") printf '%s\n' '0.2.4' ;; + "--version ") printf '%s\n' '0.2.6' ;; "update --help") printf '%s\n' 'usage: tasks-axi update <id> [flags]' ' --archive-body' ;; "mv --help") printf '%s\n' 'usage: tasks-axi mv <id> [<id>...] --to <path-or-dir>' ;; esac @@ -252,7 +254,7 @@ SH cat > "$fakebin/quota-axi" <<'SH' #!/usr/bin/env bash if [ "${1:-}" = --version ]; then - printf '%s\n' '0.1.29' + printf '%s\n' '0.1.51' exit 0 fi exit 0 diff --git a/tests/fm-secondmate-safety.test.sh b/tests/fm-secondmate-safety.test.sh index 7a69e15fe86..e83d7299ce8 100755 --- a/tests/fm-secondmate-safety.test.sh +++ b/tests/fm-secondmate-safety.test.sh @@ -73,8 +73,16 @@ test_fm_home_parameterization() { brief="$home_one/data/task-c/brief.md" grep -F ">> '$home_one/state/task-c.status'" "$brief" >/dev/null || fail "secondmate brief did not shell-quote FM_HOME state path" - printf 'project=x\n' > "$home_one/state/task-a.meta" - FM_HOME="$home_one" FM_GUARD_GRACE=999999 "$ROOT/bin/fm-pr-check.sh" task-a https://github.com/example/repo/pull/1 >/dev/null 2>/dev/null \ + # A pushed ship worktree, and a gh that supplies no forge head, so the PR + # check stays offline and its named-head gate reads the worktree's HEAD. + fm_git_init_commit "$home_one/wt" + git -C "$home_one/wt" update-ref refs/remotes/origin/main "$(git -C "$home_one/wt" rev-parse HEAD)" + mkdir -p "$home_one/fakebin" + printf '#!/usr/bin/env bash\nexit 1\n' > "$home_one/fakebin/gh" + chmod +x "$home_one/fakebin/gh" + printf 'project=x\nworktree=%s\n' "$home_one/wt" > "$home_one/state/task-a.meta" + PATH="$home_one/fakebin:$PATH" FM_HOME="$home_one" FM_GUARD_GRACE=999999 \ + "$ROOT/bin/fm-pr-check.sh" task-a https://github.com/example/repo/pull/1 >/dev/null 2>/dev/null \ || fail "fm-pr-check failed under FM_HOME" [ -f "$home_one/state/task-a.check.sh" ] || fail "pr check was not written under FM_HOME/state" [ ! -e "$home_two/state/task-a.check.sh" ] || fail "pr check leaked into another home" diff --git a/tests/fm-secondmate-sync.test.sh b/tests/fm-secondmate-sync.test.sh index 1e5d2290f32..68ca9d80015 100755 --- a/tests/fm-secondmate-sync.test.sh +++ b/tests/fm-secondmate-sync.test.sh @@ -322,7 +322,7 @@ make_fake_toolchain() { fakebin="$dir/fakebin" mkdir -p "$fakebin" fm_fake_exit0 "$fakebin" node chrome-devtools-axi - fm_fake_version_tool "$fakebin" lavish-axi FM_FAKE_LAVISH_AXI_VERSION 0.1.46 + fm_fake_version_tool "$fakebin" lavish-axi FM_FAKE_LAVISH_AXI_VERSION 0.1.77 cat > "$fakebin/gh-axi" <<'SH' #!/usr/bin/env bash if [ "${1:-}" = --version ]; then @@ -379,7 +379,7 @@ SH cat > "$fakebin/tasks-axi" <<'SH' #!/usr/bin/env bash case "${1:-} ${2:-}" in - "--version ") printf '%s\n' '0.2.4' ;; + "--version ") printf '%s\n' '0.2.6' ;; "update --help") printf '%s\n' 'usage: tasks-axi update <id> [flags]' ' --archive-body' ;; "mv --help") printf '%s\n' 'usage: tasks-axi mv <id> [<id>...] --to <path-or-dir>' ;; esac @@ -389,7 +389,7 @@ SH cat > "$fakebin/quota-axi" <<'SH' #!/usr/bin/env bash if [ "${1:-}" = --version ]; then - printf '%s\n' 'quota-axi 0.1.29 (fake)' + printf '%s\n' 'quota-axi 0.1.51 (fake)' fi exit 0 SH diff --git a/tests/fm-send-resolve-key.test.sh b/tests/fm-send-resolve-key.test.sh index 3141367c1c7..dbedbe732fb 100755 --- a/tests/fm-send-resolve-key.test.sh +++ b/tests/fm-send-resolve-key.test.sh @@ -187,6 +187,63 @@ test_answer_close_is_self_announced() { pass "fm-send --resolve-key: the close never re-wakes its own home, later lines still do" } +# Two distinct --resolve-key answers must each stay quiet even when the seen +# marker does NOT cover them. An in-flight watcher classification that lands +# after the first answer regresses the classified offset behind that answer's +# bytes, so the marker no longer vouches for them; only the home-appends ledger +# does. Without the ledger the second scan re-wakes this home over its own +# close. A later worker line on the same task still wakes. +test_separate_resolve_key_answers_do_not_rewake() { + local dir fb log home rc status pre_answer ident + dir="$TMP_ROOT/separate-answers"; mkdir -p "$dir" + fb=$(make_stubs "$dir"); log="$dir/send.log" + home=$(setup_home separate-answers) + status="$home/state/t7.status" + fm_write_meta "$home/state/t7.meta" "window=sess:fm-t7" "kind=ship" + { + printf 'needs-decision [key=budget]: approve spend?\n' + printf 'needs-decision [key=vendor]: pick a vendor\n' + } > "$status" + FM_STATE_OVERRIDE="$home/state" bash -c ' + . "$1"; fm_wake_status_mark_current "$2" "$3" + ' _ "$ROOT/bin/fm-wake-lib.sh" "$home/state" "$status" \ + || fail "could not prime the announced baseline" + pre_answer=$(wc -c < "$status" | tr -d '[:space:]') + + run_send "$fb" "$home" "$log" t7 --resolve-key budget "approved"; rc=$? + expect_code 0 "$rc" "the first answer should succeed" + + # A watcher classification captured before the answer commits afterwards and + # rewinds the classified offset behind the answer's bytes. + ident=$(FM_STATE_OVERRIDE="$home/state" bash -c ' + . "$1"; _fm_open_decisions_file_ident "$2" + ' _ "$ROOT/bin/fm-classify-lib.sh" "$status") \ + || fail "could not read the status identity" + FM_STATE_OVERRIDE="$home/state" bash -c ' + . "$1"; fm_wake_status_seen_commit "$2" "$3" "$4" "$5" + ' _ "$ROOT/bin/fm-wake-lib.sh" "$home/state" "$status" "$pre_answer" "$ident" \ + || fail "could not replay the stale watcher classification" + FM_STATE_OVERRIDE="$home/state" bash -c ' + . "$1"; fm_wake_signal_seen_current "$2" "$3" + ' _ "$ROOT/bin/fm-wake-lib.sh" "$home/state" "$status" \ + || fail "the first --resolve-key answer was left to re-wake this home" + + run_send "$fb" "$home" "$log" t7 --resolve-key vendor "acme"; rc=$? + expect_code 0 "$rc" "the second answer should succeed" + FM_STATE_OVERRIDE="$home/state" bash -c ' + . "$1"; fm_wake_signal_seen_current "$2" "$3" + ' _ "$ROOT/bin/fm-wake-lib.sh" "$home/state" "$status" \ + || fail "the second --resolve-key answer was left to re-wake this home" + + printf 'blocked: need staging credentials\n' >> "$status" + if FM_STATE_OVERRIDE="$home/state" bash -c ' + . "$1"; fm_wake_signal_seen_current "$2" "$3" + ' _ "$ROOT/bin/fm-wake-lib.sh" "$home/state" "$status"; then + fail "a later worker line after two answers was swallowed" + fi + pass "fm-send --resolve-key: separate answers do not each re-wake; later lines still do" +} + # The reported failure behind issue #2109: a worker that put the colon first # (needs-decision: [key=X] ...) had its key silently folded to "default", so # the answer's --resolve-key X refused with "no open decision or blocker with @@ -824,8 +881,7 @@ test_decision_answer_partition_relocates_under_the_record() { || 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" + FM_HOME="$home" "$ROOT/bin/fm-afk-contract.sh" enter >/dev/null || fail "away entry 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" @@ -851,6 +907,7 @@ test_decision_answer_partition_relocates_under_the_record() { test_answer_send_closes_open_decision test_answer_close_is_self_announced +test_separate_resolve_key_answers_do_not_rewake test_colon_first_key_position_is_answerable test_answer_starts_work_never_orphans test_routine_steer_never_closes diff --git a/tests/fm-session-start.test.sh b/tests/fm-session-start.test.sh index b14639b3dde..3beaad78ad1 100755 --- a/tests/fm-session-start.test.sh +++ b/tests/fm-session-start.test.sh @@ -72,7 +72,7 @@ new_world() { make_fake_toolchain() { local fakebin=$1 fm_fake_exit0 "$fakebin" tmux node chrome-devtools-axi - fm_fake_version_tool "$fakebin" lavish-axi FM_FAKE_LAVISH_AXI_VERSION 0.1.46 + fm_fake_version_tool "$fakebin" lavish-axi FM_FAKE_LAVISH_AXI_VERSION 0.1.77 cat > "$fakebin/gh-axi" <<'SH' #!/usr/bin/env bash if [ "${1:-}" = --version ]; then @@ -137,7 +137,7 @@ list_help() { } case "${1:-}" in --version|-v|-V) - printf '%s\n' '0.2.4' + printf '%s\n' '0.2.6' exit 0 ;; update) @@ -687,7 +687,7 @@ install_pi_watch_extension_fixture() { write_pi_watch_loaded_marker() { local home=$1 root=$2 pid=$3 version version=$(hash_file_for_test "$root/.pi/extensions/fm-primary-pi-watch.ts") - printf '%s\n%s\n' "$version" "$pid" > "$home/state/.pi-watch-extension-loaded" + printf '%s\n%s\ngeneration=1 phase=active\n' "$version" "$pid" > "$home/state/.pi-watch-extension-loaded" } write_pi_turnend_loaded_marker() { @@ -2548,6 +2548,35 @@ EOF pass "session start rejects stale Pi loaded markers" } +test_pi_diagnostic_rejects_handoff_generation_marker() { + local rec root home fakebin out marker holder_pid + rec=$(new_world pi-handoff-generation-marker) + IFS='|' read -r root home fakebin <<EOF +$rec +EOF + make_fake_toolchain "$fakebin" + + sleep 300 & + holder_pid=$! + make_fake_ps_pi_holder "$fakebin" "$holder_pid" + install_pi_turnend_extension_fixture "$root" + install_pi_watch_extension_fixture "$root" + write_pi_loaded_markers "$home" "$root" "$holder_pid" + marker="$home/state/.pi-watch-extension-loaded" + head -n 2 "$marker" > "$marker.tmp" + printf 'generation=1 phase=handoff\n' >> "$marker.tmp" + mv "$marker.tmp" "$marker" + + out=$(FM_FAKE_HARNESS=pi run_session_start "$home" "$root" "$fakebin:$BASE_PATH") + kill "$holder_pid" 2>/dev/null || true + wait "$holder_pid" 2>/dev/null || true + + assert_contains "$out" "PI_WATCH_EXTENSION: not loaded" \ + "pi diagnostic trusted a handoff marker left by an absent replacement extension" + + pass "session start rejects a Pi watcher generation left in handoff" +} + test_pi_diagnostic_accepts_prelock_loaded_marker() { local rec root home fakebin out holder_pid rec=$(new_world pi-prelock-loaded-marker) @@ -2709,6 +2738,7 @@ test_next_step_afk_legacy_empty_flag_defaults_away test_supervision_block_exactly_one_and_pi_diagnostic test_pi_signed_primary_uses_pi_extensions_without_identity_normalization test_pi_diagnostic_rejects_stale_loaded_marker +test_pi_diagnostic_rejects_handoff_generation_marker test_pi_diagnostic_accepts_prelock_loaded_marker test_omp_supervision_block_and_diagnostic test_omp_diagnostic_accepts_prelock_loaded_marker diff --git a/tests/fm-shared-captain-inheritance.test.sh b/tests/fm-shared-captain-inheritance.test.sh index efd61dd804f..559957c4808 100755 --- a/tests/fm-shared-captain-inheritance.test.sh +++ b/tests/fm-shared-captain-inheritance.test.sh @@ -220,7 +220,7 @@ SH add_bootstrap_compatible_tools() { local fakebin=$1 fm_fake_exit0 "$fakebin" node chrome-devtools-axi gh treehouse - fm_fake_version_tool "$fakebin" lavish-axi FM_FAKE_LAVISH_AXI_VERSION 0.1.46 + fm_fake_version_tool "$fakebin" lavish-axi FM_FAKE_LAVISH_AXI_VERSION 0.1.77 cat > "$fakebin/gh-axi" <<'SH' #!/usr/bin/env bash if [ "${1:-}" = --version ]; then @@ -240,7 +240,7 @@ SH cat > "$fakebin/tasks-axi" <<'SH' #!/usr/bin/env bash case "${1:-} ${2:-}" in - "--version ") printf '%s\n' '0.2.4' ;; + "--version ") printf '%s\n' '0.2.6' ;; "update --help") printf '%s\n' 'usage: tasks-axi update <id> [flags]' ' --archive-body' ;; "mv --help") printf '%s\n' 'usage: tasks-axi mv <id> [<id>...] --to <path-or-dir>' ;; esac @@ -249,7 +249,7 @@ SH cat > "$fakebin/quota-axi" <<'SH' #!/usr/bin/env bash if [ "${1:-}" = --version ]; then - printf '%s\n' '0.1.29' + printf '%s\n' '0.1.51' exit 0 fi exit 0 diff --git a/tests/fm-startup-memory-budget.test.sh b/tests/fm-startup-memory-budget.test.sh index fe5a5439f62..a0f854b659e 100755 --- a/tests/fm-startup-memory-budget.test.sh +++ b/tests/fm-startup-memory-budget.test.sh @@ -16,7 +16,7 @@ make_fake_toolchain() { local dir=$1 fakebin fakebin=$(fm_fakebin "$dir") fm_fake_exit0 "$fakebin" node chrome-devtools-axi - fm_fake_version_tool "$fakebin" lavish-axi FM_FAKE_LAVISH_AXI_VERSION 0.1.46 + fm_fake_version_tool "$fakebin" lavish-axi FM_FAKE_LAVISH_AXI_VERSION 0.1.77 cat > "$fakebin/gh-axi" <<'SH' #!/usr/bin/env bash if [ "${1:-}" = --version ]; then @@ -27,7 +27,7 @@ SH cat > "$fakebin/quota-axi" <<'SH' #!/usr/bin/env bash if [ "${1:-}" = --version ]; then - printf '%s\n' 'quota-axi 0.1.29 (fake)' + printf '%s\n' 'quota-axi 0.1.51 (fake)' fi exit 0 SH @@ -50,7 +50,7 @@ SH cat > "$fakebin/tasks-axi" <<'SH' #!/usr/bin/env bash case "${1:-}:${2:-}" in - --version:*) printf '%s\n' '0.2.4' ;; + --version:*) printf '%s\n' '0.2.6' ;; update:--help) printf '%s\n' '--archive-body' ;; mv:--help) printf '%s\n' 'usage: tasks-axi mv <id> [<id>...]' ;; esac diff --git a/tests/fm-task-inbox.test.sh b/tests/fm-task-inbox.test.sh index 9ed62c5e009..eda6f37170c 100644 --- a/tests/fm-task-inbox.test.sh +++ b/tests/fm-task-inbox.test.sh @@ -284,6 +284,107 @@ test_ring_skips_dead_agent() { pass "inbox: the ring skips dead or missing endpoints and still rings live or unclassifiable endpoints" } +# A fake tmux whose pane is a Claude-style composer that keeps its content in +# FM_FAKE_COMPOSER: literal input appends to it, capture renders it wrapped +# between rules, and Enter submits it (logged as SUBMIT) unless +# FM_FAKE_DROP_ENTERS still holds a count of Enters to swallow. +make_composer_stub() { # <dir> + mkdir -p "$1/fakebin" + cat > "$1/fakebin/tmux" <<'SH' +#!/usr/bin/env bash +set -u +case "${1:-}" in + send-keys) + shift + literal=0 + while [ $# -gt 0 ]; do + case "$1" in + -t) shift 2 ;; + -l) literal=1; shift ;; + *) break ;; + esac + done + if [ "$literal" = 1 ]; then + printf '%s' "$1" >> "$FM_FAKE_COMPOSER" + elif [ "${1:-}" = Enter ]; then + drops=$(cat "$FM_FAKE_DROP_ENTERS" 2>/dev/null || echo 0) + if [ "$drops" -gt 0 ]; then + echo $((drops - 1)) > "$FM_FAKE_DROP_ENTERS" + elif [ -s "$FM_FAKE_COMPOSER" ]; then + printf 'SUBMIT: %s\n' "$(cat "$FM_FAKE_COMPOSER")" >> "$FM_SEND_LOG" + : > "$FM_FAKE_COMPOSER" + fi + fi + exit 0 ;; + display-message) + case "$*" in *cursor_y*) printf '2\n'; exit 0 ;; esac + printf 'fakepane\n'; exit 0 ;; + capture-pane) + rule=$(printf '─%.0s' $(seq 64)) + printf '● done\n%s\n' "$rule" + if [ -s "$FM_FAKE_COMPOSER" ]; then + fold -w 60 "$FM_FAKE_COMPOSER" | awk 'NR == 1 { print "❯ " $0; next } { print " " $0 }' + else + printf '❯ \n' + fi + printf '%s\n ? for shortcuts\n' "$rule" + exit 0 ;; + list-windows) printf 'fm-t1\n'; exit 0 ;; +esac +exit 0 +SH + chmod +x "$1/fakebin/tmux" +} + +# The stuck-doorbell deadlock: a doorbell whose Enter never landed sits in the +# composer, and a ring that skipped every pending composer blocked all later +# rings. Our own exact doorbell is submitted instead; any other pending text +# still skips untouched; and a lost Enter after typing gets one retry. +test_ring_submits_its_own_stuck_doorbell() { + local dir state rec doorbell log composer drops rc other + dir="$TMP_ROOT/ring-stuck" + state="$dir/state" + mkdir -p "$state" + make_composer_stub "$dir" + rec=$(inbox_lib "$state" fm_task_inbox_write "$state" t1 "please continue") + doorbell=$(inbox_lib "$state" fm_task_inbox_doorbell_line "$rec") + log="$dir/send.log"; composer="$dir/composer"; drops="$dir/drops" + ring() { + PATH="$dir/fakebin:$PATH" FM_SEND_LOG="$log" FM_FAKE_COMPOSER="$composer" \ + FM_FAKE_DROP_ENTERS="$drops" inbox_lib "$state" fm_task_inbox_ring tmux sess:fm-t1 "$rec" fm-t1 + } + + : > "$log"; printf '%s' "$doorbell" > "$composer" + rc=0; ring || rc=$? + [ "$rc" = 0 ] || fail "a composer holding our own stuck doorbell should be submitted, got rc $rc" + [ "$(cat "$log")" = "SUBMIT: $doorbell" ] \ + || fail "the stuck doorbell should be submitted exactly once, not retyped:"$'\n'"$(cat "$log")" + [ ! -s "$composer" ] || fail "the stuck doorbell was left in the composer" + + : > "$log"; printf '%s' "$doorbell" > "$composer"; echo 1 > "$drops" + rc=0; ring || rc=$? + [ "$rc" = 0 ] || fail "a stuck doorbell whose first Enter is lost should still report rung, got rc $rc" + [ "$(cat "$log")" = "SUBMIT: $doorbell" ] \ + || fail "the retry Enter should submit the stuck doorbell once, not retype it:"$'\n'"$(cat "$log")" + [ ! -s "$composer" ] || fail "a lost Enter left the stuck doorbell unsubmitted" + + for other in 'a half-typed draft' "$doorbell and a draft"; do + : > "$log"; printf '%s' "$other" > "$composer" + rc=0; ring || rc=$? + [ "$rc" = 1 ] || fail "other pending text should skip the ring, got rc $rc for: $other" + [ ! -s "$log" ] || fail "other pending text was submitted:"$'\n'"$(cat "$log")" + [ "$(cat "$composer")" = "$other" ] || fail "other pending text was changed: $(cat "$composer")" + done + + : > "$log"; : > "$composer"; echo 1 > "$drops" + rc=0; ring || rc=$? + [ "$rc" = 0 ] || fail "a ring whose first Enter is lost should still report rung, got rc $rc" + [ "$(cat "$log")" = "SUBMIT: $doorbell" ] \ + || fail "the retry Enter should submit the doorbell once:"$'\n'"$(cat "$log")" + [ ! -s "$composer" ] || fail "a lost Enter left the doorbell unsubmitted" + pass "inbox: the ring submits its own stuck doorbell, skips other pending text, and retries a lost Enter once on both paths" +} + test_idempotent_write_dedups_exact_body() { local state r1 r2 r3 r4 count text state="$TMP_ROOT/idem/state"; mkdir -p "$state" @@ -701,6 +802,7 @@ test_write_is_durable_and_exact test_doorbell_is_a_shell_noop test_doorbell_rejects_terminal_controls test_ring_skips_dead_agent +test_ring_submits_its_own_stuck_doorbell test_idempotent_write_dedups_exact_body test_idempotent_write_follows_concurrent_ack test_handled_mv_dedups_by_sequence diff --git a/tests/fm-teardown.test.sh b/tests/fm-teardown.test.sh index fc7511ac3c6..1423756bd7a 100755 --- a/tests/fm-teardown.test.sh +++ b/tests/fm-teardown.test.sh @@ -1247,6 +1247,173 @@ test_legacy_record_without_the_flag_refuses() { pass "a record predating spawn_gen refuses teardown until --legacy-record is passed" } +write_windowless_legacy_meta() { + local case_dir=$1 mode=$2 kind=$3 worktree + worktree=${4:-$case_dir/wt} + fm_write_meta "$case_dir/state/task-x1.meta" \ + "worktree=$worktree" \ + "project=$case_dir/project" \ + "kind=$kind" \ + "mode=$mode" \ + "harness=codex" +} + +test_windowless_legacy_record_with_gone_worktree_tears_down() { + local case_dir out + case_dir=$(make_case windowless-gone) + write_windowless_legacy_meta "$case_dir" no-mistakes ship "$case_dir/missing-wt" + seed_backlog_in_flight "$case_dir" + + out=$(run_teardown "$case_dir") \ + || fail "windowless-gone: teardown refused a leftover with no window, no spawn_gen, and no worktree" + printf '%s\n' "$out" | grep -Fq 'legacy record accepted without spawn_gen: endpoint missing' \ + || fail "windowless-gone: the teardown line did not log the missing-endpoint leftover: $out" + printf '%s\n' "$out" | grep -Fq 'window none' \ + || fail "windowless-gone: the teardown line did not say there was no window: $out" + [ "$(backlog_row_state "$case_dir")" = "done" ] \ + || fail "windowless-gone: teardown returned success with its backlog item still open" + assert_absent "$case_dir/state/task-x1.meta" \ + "windowless-gone: teardown left the leftover record" + pass "a windowless leftover with no spawn_gen and no worktree tears down without --legacy-record" +} + +test_windowless_legacy_record_tears_down_with_the_legacy_flag() { + local case_dir out + case_dir=$(make_case windowless-flag) + write_windowless_legacy_meta "$case_dir" no-mistakes ship "$case_dir/missing-wt" + seed_backlog_in_flight "$case_dir" + + out=$(run_teardown "$case_dir" --legacy-record) \ + || fail "windowless-flag: --legacy-record refused a leftover with no window and no spawn_gen" + printf '%s\n' "$out" | grep -Fq 'legacy record accepted without spawn_gen: endpoint missing' \ + || fail "windowless-flag: the teardown line did not log the missing-endpoint leftover: $out" + assert_absent "$case_dir/state/task-x1.meta" \ + "windowless-flag: teardown left the leftover record" + [ "$(backlog_row_state "$case_dir")" = "done" ] \ + || fail "windowless-flag: teardown returned success with its backlog item still open" + pass "a windowless leftover with no spawn_gen also tears down when --legacy-record is passed" +} + +test_windowless_legacy_record_still_refuses_unlanded_work() { + local case_dir rc before + case_dir=$(make_case windowless-unlanded) + write_windowless_legacy_meta "$case_dir" no-mistakes ship + seed_backlog_in_flight "$case_dir" + wt_commit_file "$case_dir" feature.txt unique-windowless-content "real unlanded work" + before=$(cksum "$case_dir/state/task-x1.meta" | awk '{print $1, $2}') + + set +e + run_teardown "$case_dir" > "$case_dir/stdout" 2> "$case_dir/stderr" + rc=$? + set -e + + expect_code 1 "$rc" "windowless-unlanded: a still-present unlanded worktree must refuse" + grep -q REFUSED "$case_dir/stderr" \ + || fail "windowless-unlanded: no REFUSED line for unlanded windowless work" + [ "$(cksum "$case_dir/state/task-x1.meta" | awk '{print $1, $2}')" = "$before" ] \ + || fail "windowless-unlanded: the unlanded refusal modified the task record" + [ "$(backlog_row_state "$case_dir")" = in_flight ] \ + || fail "windowless-unlanded: the unlanded refusal closed the backlog item anyway" + pass "a windowless leftover still refuses while its worktree holds unlanded work" +} + +assert_windowless_record_refuses() { # <case-dir> <description> <refusal> + local case_dir=$1 description=$2 refusal=$3 rc before + before=$(cksum "$case_dir/state/task-x1.meta" | awk '{print $1, $2}') + set +e + run_teardown "$case_dir" > "$case_dir/stdout" 2> "$case_dir/stderr" + rc=$? + set -e + expect_code 1 "$rc" "$description: a windowless record outside the leftover class must refuse" + grep -Fq "$refusal" "$case_dir/stderr" \ + || fail "$description: the refusal was not '$refusal': $(cat "$case_dir/stderr")" + [ "$(cksum "$case_dir/state/task-x1.meta" | awk '{print $1, $2}')" = "$before" ] \ + || fail "$description: the refusal modified the task record" +} + +test_windowless_record_outside_the_leftover_class_still_refuses() { + local case_dir + case_dir=$(make_case windowless-spawn-gen) + write_windowless_legacy_meta "$case_dir" no-mistakes ship "$case_dir/missing-wt" + printf '%s\n' 'spawn_gen=s1700000000.1.abc' >> "$case_dir/state/task-x1.meta" + seed_backlog_in_flight "$case_dir" + assert_windowless_record_refuses "$case_dir" windowless-spawn-gen "missing, empty, or ambiguous window endpoint" + + case_dir=$(make_case windowless-orca) + write_windowless_legacy_meta "$case_dir" no-mistakes ship "$case_dir/missing-wt" + printf '%s\n' 'backend=orca' 'terminal=term-7' >> "$case_dir/state/task-x1.meta" + seed_backlog_in_flight "$case_dir" + assert_windowless_record_refuses "$case_dir" windowless-orca "no spawn_gen that identifies one exact incarnation" + + case_dir=$(make_case windowless-no-backlog) + write_windowless_legacy_meta "$case_dir" no-mistakes ship "$case_dir/missing-wt" + assert_windowless_record_refuses "$case_dir" windowless-no-backlog "missing, empty, or ambiguous window endpoint" + + case_dir=$(make_case windowless-dup-project) + write_windowless_legacy_meta "$case_dir" no-mistakes ship "$case_dir/missing-wt" + printf '%s\n' "project=$case_dir/other-project" >> "$case_dir/state/task-x1.meta" + seed_backlog_in_flight "$case_dir" + assert_windowless_record_refuses "$case_dir" windowless-dup-project "no spawn_gen that identifies one exact incarnation" + case_dir=$(make_case windowless-foreign-binding) + write_windowless_legacy_meta "$case_dir" no-mistakes ship "$case_dir/missing-wt" + printf '%s\n' 'endpoint_task_id=task-other' >> "$case_dir/state/task-x1.meta" + seed_backlog_in_flight "$case_dir" + assert_windowless_record_refuses "$case_dir" windowless-foreign-binding "no spawn_gen that identifies one exact incarnation" + + case_dir=$(make_case windowless-terminal) + write_windowless_legacy_meta "$case_dir" no-mistakes ship "$case_dir/missing-wt" + printf '%s\n' 'terminal=term-7' >> "$case_dir/state/task-x1.meta" + seed_backlog_in_flight "$case_dir" + assert_windowless_record_refuses "$case_dir" windowless-terminal "no spawn_gen that identifies one exact incarnation" + + case_dir=$(make_case windowless-herdr-identity) + write_windowless_legacy_meta "$case_dir" no-mistakes ship "$case_dir/missing-wt" + printf '%s\n' 'backend=tmux' 'herdr_session=s1' 'herdr_pane_id=p1' >> "$case_dir/state/task-x1.meta" + seed_backlog_in_flight "$case_dir" + assert_windowless_record_refuses "$case_dir" windowless-herdr-identity "no spawn_gen that identifies one exact incarnation" + + case_dir=$(make_case windowless-cmux-identity) + write_windowless_legacy_meta "$case_dir" no-mistakes ship "$case_dir/missing-wt" + printf '%s\n' 'cmux_surface_id=surface-1' >> "$case_dir/state/task-x1.meta" + seed_backlog_in_flight "$case_dir" + assert_windowless_record_refuses "$case_dir" windowless-cmux-identity "no spawn_gen that identifies one exact incarnation" + + case_dir=$(make_case windowless-control-char) + write_windowless_legacy_meta "$case_dir" no-mistakes ship "$case_dir/missing"$'\t'"wt" + seed_backlog_in_flight "$case_dir" + assert_windowless_record_refuses "$case_dir" windowless-control-char "no spawn_gen that identifies one exact incarnation" + pass "a windowless record with a spawn_gen, a non-tmux backend or endpoint identity, no backlog validation, or ambiguous, foreign, or malformed identity still refuses" +} + +test_windowless_leftover_retries_its_retained_legacy_stamp_without_the_flag() { + local case_dir rc out + case_dir=$(make_case windowless-retry) + write_windowless_legacy_meta "$case_dir" no-mistakes ship "$case_dir/missing-wt" + printf '%s\n' 'pr=not-a-valid-url' >> "$case_dir/state/task-x1.meta" + seed_backlog_in_flight "$case_dir" + add_failing_truncate_perl "$case_dir" + + set +e + run_teardown "$case_dir" > "$case_dir/stdout" 2> "$case_dir/stderr" + rc=$? + set -e + expect_code 1 "$rc" "windowless-retry: an unrecordable close must fail the first attempt" + [ "$(legacy_meta_gen_count "$case_dir")" = 1 ] \ + || fail "windowless-retry: the failed attempt did not leave its legacy stamp on the record" + + rm -f "$case_dir/fakebin/perl" + sed -i.bak '/^pr=/d' "$case_dir/state/task-x1.meta" && rm -f "$case_dir/state/task-x1.meta.bak" + out=$(run_teardown "$case_dir") \ + || fail "windowless-retry: the flag-less retry refused the retained legacy stamp" + printf '%s\n' "$out" | grep -Fq 'legacy record accepted without spawn_gen: endpoint missing' \ + || fail "windowless-retry: the retry did not accept the missing-endpoint leftover: $out" + assert_absent "$case_dir/state/task-x1.meta" \ + "windowless-retry: the retry left the leftover record" + [ "$(backlog_row_state "$case_dir")" = "done" ] \ + || fail "windowless-retry: the retry returned success with its backlog item still open" + pass "a windowless leftover retries its retained legacy stamp without --legacy-record" +} + test_legacy_record_teardown_completes_when_landed_and_endpoint_dead() { local case_dir out case_dir=$(make_case legacy-allow) @@ -2839,6 +3006,32 @@ test_parked_own_run_is_aborted_before_teardown() { pass "a task's own parked no-mistakes run is aborted, not orphaned, before the worker is removed" } +# An abort can race a concurrent gate response: the run finishes with a +# passing-but-not-clean outcome (an explicitly approved Test/CI exception) +# instead of landing on `cancelled`. That is still a terminal, finished run, +# so teardown must conclude cleanly rather than refuse as still-parked. +test_parked_own_run_concludes_on_passed_with_override_after_abort() { + local case_dir rc head + case_dir=$(make_case parked-run-abort-passed-with-override) + write_meta "$case_dir" no-mistakes ship + land_shippable_commit "$case_dir" + head=$(git -C "$case_dir/wt" rev-parse HEAD) + + local rc=0 + FM_FAKE_AXI_STATUS="$(parked_axi_status_toon fm/task-x1 "$head")" \ + FM_FAKE_NM_ABORT_LOG="$case_dir/nm-abort.log" \ + FM_FAKE_AXI_STATUS_AFTER_ABORT='run: + id: "01RUN" + outcome: passed-with-override +ci_override_reason: "live checks not all passed: Lint (fail)"' \ + run_teardown "$case_dir" > "$case_dir/stdout" 2> "$case_dir/stderr" || rc=$? + + expect_code 0 "$rc" "parked-run-abort-passed-with-override: teardown should still succeed" + assert_no_grep "REFUSED" "$case_dir/stderr" \ + "parked-run-abort-passed-with-override: a passing override outcome must not be reported as still parked" + pass "a run that lands on passed-with-override after abort is still recognized as terminal" +} + # The pipeline advanced the parked run past the submitted head in its own # repo, so the run head object does not exist in the task copy at all and the # strict object-local identity rule cannot bind the run. The daemon's own @@ -3805,6 +3998,11 @@ test_content_fallback_refreshes_stale_origin_ref test_dirty_worktree_refuses test_gh_error_and_content_absent_refuses test_legacy_record_without_the_flag_refuses +test_windowless_legacy_record_with_gone_worktree_tears_down +test_windowless_legacy_record_tears_down_with_the_legacy_flag +test_windowless_legacy_record_still_refuses_unlanded_work +test_windowless_record_outside_the_leftover_class_still_refuses +test_windowless_leftover_retries_its_retained_legacy_stamp_without_the_flag test_legacy_record_teardown_completes_when_landed_and_endpoint_dead test_legacy_record_teardown_refuses_unlanded_work test_legacy_record_teardown_refuses_an_ambiguous_endpoint @@ -3822,6 +4020,7 @@ test_persistent_index_lock_exhausts_retries_and_refuses_loudly test_empty_retry_wait_uses_default_without_aborting test_fractional_legacy_retry_wait_refuses_without_arithmetic_error test_parked_own_run_is_aborted_before_teardown +test_parked_own_run_concludes_on_passed_with_override_after_abort test_parked_run_advanced_past_unfetched_head_is_still_aborted test_parked_run_with_mismatched_ledger_head_is_never_aborted test_parked_run_with_malformed_ledger_row_is_never_aborted diff --git a/tests/fm-wake-drain-unread-status.test.sh b/tests/fm-wake-drain-unread-status.test.sh index ccf8bb96abd..632d4d27561 100755 --- a/tests/fm-wake-drain-unread-status.test.sh +++ b/tests/fm-wake-drain-unread-status.test.sh @@ -158,6 +158,67 @@ test_pending_reply_resolution_surfaces_once() { pass "a pending-reply resolution buried under a later note surfaces once and closes OPEN DECISIONS" } +# The watcher's pending-reply close goes through the self-announced append, so +# it records its bytes as this home's own and never wakes. The drain must still +# present that reserved-key resolution in UNREAD STATUS, its only guaranteed +# presentation. +test_self_announced_pending_reply_close_still_surfaces() { + local dir state out status corr + dir=$(make_case self-announced-pending-reply) + state="$dir/state" + out="$dir/drain.out" + status="$state/task6.status" + + run_pending_reply() { + FM_STATE_OVERRIDE="$state" FM_PENDING_REPLY_NOW=5000 bash -c ' + . "$1"; . "$2"; shift 2; "$@" + ' _ "$ROOT/bin/fm-pending-reply-lib.sh" "$ROOT/bin/fm-wake-lib.sh" "$@" + } + + corr=$(run_pending_reply fm_pending_reply_create "$dir" "$state" task6 "ship it") \ + || fail "could not create the pending-reply record" + run_pending_reply fm_pending_reply_mark_delivered "$state" "$corr" \ + || fail "could not mark the pending-reply request delivered" + FM_STATE_OVERRIDE="$state" FM_PENDING_REPLY_NOW=5000 bash -c ' + . "$1"; rec=$(fm_pending_reply_path "$2" "$3") + fm_pending_reply_set "$rec" phase escalated && fm_pending_reply_set "$rec" escalated_epoch 4950 + ' _ "$ROOT/bin/fm-pending-reply-lib.sh" "$state" "$corr" \ + || fail "could not mark the pending-reply request escalated" + + printf 'blocked [key=pending-reply-%s]: pending-reply-missed: task=task6 pending-reply-id=%s request=ship it\n' \ + "$corr" "$corr" > "$status" + prime_status_seen "$state" "$status" || fail "could not mark the status file surfaced" + append_wake "$state" signal task6.status "signal: task6.status" \ + || fail "queueing the pending-reply escalation signal failed" + FM_STATE_OVERRIDE="$state" "$DRAIN" >/dev/null || fail "drain of the escalation failed" + printf 'done [corr=%s]: shipped after all\n' "$corr" >> "$status" + prime_status_seen "$state" "$status" || fail "could not mark the status file surfaced" + append_wake "$state" signal task6.status "signal: task6.status" \ + || fail "queueing the delayed reply signal failed" + FM_STATE_OVERRIDE="$state" "$DRAIN" >/dev/null || fail "drain of the delayed reply failed" + + run_pending_reply fm_pending_reply_try_resolve "$state" "$corr" \ + || fail "the delayed reply did not resolve the pending-reply record" + sed -E 's/ \[at=[0-9]+\]//' "$status" \ + | grep -F "resolved [key=pending-reply-$corr]: pending-reply-resolved:" >/dev/null \ + || fail "the resolve did not append the escalation close: $(cat "$status")" + [ -s "$state/.task6.home-appends" ] \ + || fail "the escalation close did not go through the self-announced append" + run_pending_reply fm_wake_signal_seen_current "$state" "$status" \ + || fail "the self-announced escalation close was left to re-wake this home" + + FM_STATE_OVERRIDE="$state" "$DRAIN" > "$out" || fail "drain after the escalation close failed" + sed -E 's/ \[at=[0-9]+\]//' "$out" \ + | grep -F "task6 resolved [key=pending-reply-$corr]: pending-reply-resolved: task=task6 pending-reply-id=$corr" >/dev/null \ + || fail "the self-announced pending-reply resolution was hidden from UNREAD STATUS: $(cat "$out")" + + FM_STATE_OVERRIDE="$state" "$DRAIN" > "$out" || fail "second drain after the escalation close failed" + if grep -F 'pending-reply-resolved:' "$out" >/dev/null; then + fail "an already-presented self-announced resolution was replayed: $(cat "$out")" + fi + pass "a self-announced pending-reply close does not wake yet still surfaces once in UNREAD STATUS" +} + test_unread_output_over_cap_remains_recoverable() { local dir state out status i payload dir=$(make_case unread-over-cap) @@ -228,11 +289,17 @@ test_retired_task_id_starts_new_status_unread() { printf "40@$(cat "$2")" > "$(status_signal_seen_marker_path "$STATE" reused)" printf "40@$(cat "$2")" > "$(status_heartbeat_seen_marker_path "$STATE" reused)" printf "40@$(cat "$2")" > "$(status_daemon_seen_marker_path "$STATE" reused)" + ledger=$(status_home_appends_path "$STATE/reused.status") + status_home_appends_record "$STATE/reused.status" 0 12 || exit 1 + [ -f "$ledger" ] || exit 1 + mkdir -p "$ledger.lock" || exit 1 + printf "%s\n" 2147483646 > "$ledger.lock/pid" || exit 1 status_retire_presentation_task "$STATE" reused || exit 1 for marker in \ "$(status_signal_seen_marker_path "$STATE" reused)" \ "$(status_heartbeat_seen_marker_path "$STATE" reused)" \ - "$(status_daemon_seen_marker_path "$STATE" reused)"; do + "$(status_daemon_seen_marker_path "$STATE" reused)" \ + "$ledger" "$ledger.lock"; do [ ! -e "$marker" ] && [ ! -L "$marker" ] || exit 1 done ' _ "$ROOT" "$dir/old-ident" || fail "retiring the reused task presentation state failed" @@ -379,6 +446,7 @@ test_already_presented_notes_are_not_replayed test_brand_new_note_after_presentation_is_surfaced test_signal_annotation_surfaces_every_unread_note_not_only_the_newest test_pending_reply_resolution_surfaces_once +test_self_announced_pending_reply_close_still_surfaces test_unread_output_over_cap_remains_recoverable test_snapshot_does_not_ack_a_later_append test_retired_task_id_starts_new_status_unread diff --git a/tests/fm-wake-queue.test.sh b/tests/fm-wake-queue.test.sh index 7924fd5b1c0..74feca66ce5 100755 --- a/tests/fm-wake-queue.test.sh +++ b/tests/fm-wake-queue.test.sh @@ -563,6 +563,243 @@ SH pass "a long-lived mate mid-turn is not a stall, but a queue frozen past the busy bound still alarms" } +# Agent liveness matches the exact window name from list-windows. Printing +# session:window makes the pane look missing, which is the leftover-row tests' +# ring-unsafe path and must keep the parent alarm. These cases print fm-mate +# and a claude foreground command so a proven-idle mate can actually be rung. +install_secondmate_alive_tmux() { # <fakebin> + local fakebin=$1 + cat > "$fakebin/tmux" <<'SH' +#!/usr/bin/env bash +set -u +case "${1:-}" in + list-windows) printf '%s\n' 'fm-mate' ;; + capture-pane) exit 0 ;; + display-message) + case "$*" in + *pane_current_command*) printf 'claude\n' ;; + *pane_tty*) exit 1 ;; + *cursor_y*) printf '0\n' ;; + *) printf '0\n' ;; + esac + ;; + send-keys) + while [ "$#" -gt 0 ]; do + case "$1" in + -l) shift; [ "$#" -gt 0 ] && printf '%s\n' "$1" >> "${FM_FAKE_TMUX_SENT:-/dev/null}" ;; + Enter) + printf '[ENTER]\n' >> "${FM_FAKE_TMUX_SENT:-/dev/null}" + if [ -n "${FM_FAKE_CHILD_WAKE_QUEUE:-}" ]; then + : > "$FM_FAKE_CHILD_WAKE_QUEUE" + fi + ;; + esac + shift + done + ;; + *) exit 0 ;; +esac +SH + chmod +x "$fakebin/tmux" +} + +install_secondmate_stall_date() { # <fakebin> + local fakebin=$1 real_date + real_date=$(command -v date) + cat > "$fakebin/date" <<SH +#!/usr/bin/env bash +if [ "\${1:-}" = +%s ]; then + cat "\${FM_FAKE_NOW_FILE:?}" +else + exec "$real_date" "\$@" +fi +SH + chmod +x "$fakebin/date" +} + +# A proven-idle, ring-safe mate with a leftover foreign row is rung so its +# own home can drain. The parent alarm stays silent when that ring actually +# empties the child's queue. +test_secondmate_proven_idle_ring_lets_the_child_drain() { + local dir state sub fakebin inbox_body inbox_rec steer + dir=$(make_case secondmate-proven-idle-drain) + state="$dir/state" + sub="$dir/secondmate" + fakebin="$dir/fakebin" + mkdir -p "$sub/state" + printf 'mate\n' > "$sub/.fm-secondmate-home" + printf 'window=firstmate:fm-mate\nkind=secondmate\nharness=claude\nbackend=tmux\nhome=%s\n' \ + "$sub" > "$state/mate.meta" + printf '100\t7\tcheck\trouted\tcheck: routed row\n' > "$sub/state/.wake-queue" + install_secondmate_alive_tmux "$fakebin" + install_secondmate_stall_date "$fakebin" + "$ROOT/bin/fm-busy-event.sh" arm "$state" mate >/dev/null \ + || fail "could not arm the mate's busy contract" + "$ROOT/bin/fm-busy-event.sh" apply "$state" mate idle --current-gen \ + --source claude-hook --event stop >/dev/null \ + || fail "could not mark the mate idle" + + printf '1000\n' > "$dir/now" + PATH="$fakebin:$PATH" FM_FAKE_NOW_FILE="$dir/now" FM_HOME="$dir" FM_ROOT_OVERRIDE="$ROOT" \ + FM_STATE_OVERRIDE="$state" FM_FAKE_TMUX_SENT="$dir/sent" \ + FM_SECONDMATE_WAKE_STALL_SECS=1 FM_POLL=1 FM_SIGNAL_GRACE=0 \ + FM_CHECK_INTERVAL=999999 FM_HEARTBEAT=999999 \ + "$ROOT/bin/fm-watch-checkpoint.sh" --seconds 1 > "$dir/watch-first.out" 2> "$dir/watch-first.err" || true + [ ! -s "$state/.wake-queue" ] || fail "the first observation of a leftover row produced an alert" + [ ! -s "$dir/sent" ] || fail "a proven-idle mate was rung before the stall interval" + + printf '1002\n' > "$dir/now" + PATH="$fakebin:$PATH" FM_FAKE_NOW_FILE="$dir/now" FM_HOME="$dir" FM_ROOT_OVERRIDE="$ROOT" \ + FM_STATE_OVERRIDE="$state" FM_FAKE_TMUX_SENT="$dir/sent" \ + FM_FAKE_CHILD_WAKE_QUEUE="$sub/state/.wake-queue" \ + FM_SECONDMATE_WAKE_STALL_SECS=1 FM_POLL=1 FM_SIGNAL_GRACE=0 \ + FM_CHECK_INTERVAL=999999 FM_HEARTBEAT=999999 \ + "$ROOT/bin/fm-watch-checkpoint.sh" --seconds 4 > "$dir/watch-ring.out" 2> "$dir/watch-ring.err" || true + ! grep -F 'secondmate wake-loop stalled' "$dir/watch-ring.out" >/dev/null \ + || fail "a proven-idle mate that drained after the ring still alarmed: $(cat "$dir/watch-ring.out")" + [ ! -s "$state/.wake-queue" ] \ + || fail "a proven-idle child-first ring published a parent stall notification" + [ ! -s "$sub/state/.wake-queue" ] \ + || fail "the child ring did not drain the leftover foreign row" + inbox_rec= + for inbox_rec in "$state/mate.inbox/"*.msg; do break; done + [ -f "$inbox_rec" ] || fail "the child-first ring did not write a drain steer record" + sed '/^--$/q' "$inbox_rec" | grep -Fx 'delivery=fire-and-forget' >/dev/null \ + || fail "the child-first ring did not write a fire-and-forget drain steer" + inbox_body=$(sed '1,/^--$/d' "$inbox_rec") + [ "$(printf '%s' "$inbox_body" | "$ROOT/bin/fm-operational-input.sh" kind)" = from-firstmate ] \ + || fail "the child-first drain steer lacks the from-firstmate marker, so the mate would read it as captain intervention: $inbox_body" + steer=$(printf '%s' "$inbox_body" | "$ROOT/bin/fm-operational-input.sh" body) + [[ $steer =~ ^delivery=[0-9a-f]{16}\ (.*)$ ]] \ + || fail "the child-first drain steer does not carry a fire-and-forget delivery id: $steer" + [ "${BASH_REMATCH[1]}" = "Drain pending rows in this home's wake queue, then resume idle supervision." ] \ + || fail "the child-first ring wrote the wrong drain instruction: $steer" + grep -F '[ENTER]' "$dir/sent" >/dev/null \ + || fail "the child-first ring did not submit the doorbell: $(cat "$dir/sent" 2>/dev/null)" + pass "a proven-idle leftover row is rung so the child home can drain without a parent alarm" +} + +# Busy and unknown panes are never typed into. Busy still defers inside the +# active-turn bound. Unknown keeps the parent alarm. Empty inbox is not idle +# proof, so the unknown fixture starts with no instruction records. +test_secondmate_busy_and_unknown_panes_are_not_rung() { + local dir state sub fakebin + dir=$(make_case secondmate-busy-unknown-no-ring) + state="$dir/state" + sub="$dir/secondmate" + fakebin="$dir/fakebin" + mkdir -p "$sub/state" + printf 'mate\n' > "$sub/.fm-secondmate-home" + printf 'window=firstmate:fm-mate\nkind=secondmate\nharness=claude\nbackend=tmux\nhome=%s\n' \ + "$sub" > "$state/mate.meta" + printf '100\t7\tcheck\trouted\tcheck: routed row\n' > "$sub/state/.wake-queue" + install_secondmate_alive_tmux "$fakebin" + install_secondmate_stall_date "$fakebin" + + "$ROOT/bin/fm-busy-event.sh" arm "$state" mate >/dev/null \ + || fail "could not arm the mate's busy contract" + printf '1000\n' > "$dir/now" + PATH="$fakebin:$PATH" FM_FAKE_NOW_FILE="$dir/now" FM_HOME="$dir" FM_ROOT_OVERRIDE="$ROOT" \ + FM_STATE_OVERRIDE="$state" FM_FAKE_TMUX_SENT="$dir/sent-busy" \ + FM_SECONDMATE_WAKE_STALL_SECS=1 FM_POLL=1 FM_SIGNAL_GRACE=0 \ + FM_CHECK_INTERVAL=999999 FM_HEARTBEAT=999999 \ + "$ROOT/bin/fm-watch-checkpoint.sh" --seconds 1 > "$dir/watch-busy-first.out" 2> "$dir/watch-busy-first.err" || true + printf '1002\n' > "$dir/now" + PATH="$fakebin:$PATH" FM_FAKE_NOW_FILE="$dir/now" FM_HOME="$dir" FM_ROOT_OVERRIDE="$ROOT" \ + FM_STATE_OVERRIDE="$state" FM_FAKE_TMUX_SENT="$dir/sent-busy" \ + FM_SECONDMATE_WAKE_STALL_SECS=1 FM_POLL=1 FM_SIGNAL_GRACE=0 \ + FM_CHECK_INTERVAL=999999 FM_HEARTBEAT=999999 \ + "$ROOT/bin/fm-watch-checkpoint.sh" --seconds 4 > "$dir/watch-busy.out" 2> "$dir/watch-busy.err" || true + ! grep -F 'secondmate wake-loop stalled' "$dir/watch-busy.out" >/dev/null \ + || fail "a busy mate was escalated as a stalled wake loop: $(cat "$dir/watch-busy.out")" + [ ! -s "$state/.wake-queue" ] || fail "a busy mate published a durable stall notification" + [ ! -e "$dir/sent-busy" ] || fail "a busy mate was rung" + [ ! -e "$state/mate.inbox" ] || fail "a busy mate received a drain steer" + + rm -f "$state/.secondmate-wake-progress-mate" "$state/.secondmate-wake-stall-mate" \ + "$state/.secondmate-wake-ring-mate" + rm -rf "$state/.secondmate-wake-stall-receipts" "$state/mate.busy-state" "$state/mate.busy-gen" + : > "$dir/sent-unknown" + printf '1000\n' > "$dir/now" + PATH="$fakebin:$PATH" FM_FAKE_NOW_FILE="$dir/now" FM_HOME="$dir" FM_ROOT_OVERRIDE="$ROOT" \ + FM_STATE_OVERRIDE="$state" FM_FAKE_TMUX_SENT="$dir/sent-unknown" \ + FM_SECONDMATE_WAKE_STALL_SECS=1 FM_POLL=1 FM_SIGNAL_GRACE=0 \ + FM_CHECK_INTERVAL=999999 FM_HEARTBEAT=999999 \ + "$ROOT/bin/fm-watch-checkpoint.sh" --seconds 1 > "$dir/watch-unknown-first.out" 2> "$dir/watch-unknown-first.err" || true + printf '1002\n' > "$dir/now" + PATH="$fakebin:$PATH" FM_FAKE_NOW_FILE="$dir/now" FM_HOME="$dir" FM_ROOT_OVERRIDE="$ROOT" \ + FM_STATE_OVERRIDE="$state" FM_FAKE_TMUX_SENT="$dir/sent-unknown" \ + FM_SECONDMATE_WAKE_STALL_SECS=1 FM_POLL=1 FM_SIGNAL_GRACE=0 \ + FM_CHECK_INTERVAL=999999 FM_HEARTBEAT=999999 \ + "$ROOT/bin/fm-watch-checkpoint.sh" --seconds 4 > "$dir/watch-unknown.out" 2> "$dir/watch-unknown.err" || true + grep -F 'check: secondmate wake-loop stalled: mate=mate row=7 idle=2s' "$dir/watch-unknown.out" >/dev/null \ + || fail "an unknown pane did not keep the parent alarm: $(cat "$dir/watch-unknown.out")" + [ ! -s "$dir/sent-unknown" ] || fail "an unknown pane was rung: $(cat "$dir/sent-unknown")" + [ ! -e "$state/mate.inbox" ] || fail "an unknown pane received a drain steer" + pass "busy panes defer without a ring and unknown panes keep the parent alarm" +} + +# After a proven-idle ring, the same leftover row is a genuine stall if the +# child home does not drain it. The second stall interval must still surface. +test_secondmate_genuine_stall_after_idle_ring_still_alarms() { + local dir state sub fakebin row_before stall_count + dir=$(make_case secondmate-genuine-stall-after-ring) + state="$dir/state" + sub="$dir/secondmate" + fakebin="$dir/fakebin" + mkdir -p "$sub/state" + printf 'mate\n' > "$sub/.fm-secondmate-home" + printf 'window=firstmate:fm-mate\nkind=secondmate\nharness=claude\nbackend=tmux\nhome=%s\n' \ + "$sub" > "$state/mate.meta" + printf '100\t7\tcheck\trouted\tcheck: routed row\n' > "$sub/state/.wake-queue" + row_before="$dir/foreign-before" + cp "$sub/state/.wake-queue" "$row_before" + install_secondmate_alive_tmux "$fakebin" + install_secondmate_stall_date "$fakebin" + "$ROOT/bin/fm-busy-event.sh" arm "$state" mate >/dev/null \ + || fail "could not arm the mate's busy contract" + "$ROOT/bin/fm-busy-event.sh" apply "$state" mate idle --current-gen \ + --source claude-hook --event stop >/dev/null \ + || fail "could not mark the mate idle" + + printf '1000\n' > "$dir/now" + PATH="$fakebin:$PATH" FM_FAKE_NOW_FILE="$dir/now" FM_HOME="$dir" FM_ROOT_OVERRIDE="$ROOT" \ + FM_STATE_OVERRIDE="$state" FM_FAKE_TMUX_SENT="$dir/sent" \ + FM_SECONDMATE_WAKE_STALL_SECS=1 FM_POLL=1 FM_SIGNAL_GRACE=0 \ + FM_CHECK_INTERVAL=999999 FM_HEARTBEAT=999999 \ + "$ROOT/bin/fm-watch-checkpoint.sh" --seconds 1 > "$dir/watch-first.out" 2> "$dir/watch-first.err" || true + + printf '1002\n' > "$dir/now" + PATH="$fakebin:$PATH" FM_FAKE_NOW_FILE="$dir/now" FM_HOME="$dir" FM_ROOT_OVERRIDE="$ROOT" \ + FM_STATE_OVERRIDE="$state" FM_FAKE_TMUX_SENT="$dir/sent" \ + FM_SECONDMATE_WAKE_STALL_SECS=1 FM_POLL=1 FM_SIGNAL_GRACE=0 \ + FM_CHECK_INTERVAL=999999 FM_HEARTBEAT=999999 \ + "$ROOT/bin/fm-watch-checkpoint.sh" --seconds 4 > "$dir/watch-ring.out" 2> "$dir/watch-ring.err" || true + ! grep -F 'secondmate wake-loop stalled' "$dir/watch-ring.out" >/dev/null \ + || fail "the first proven-idle ring published a parent alarm: $(cat "$dir/watch-ring.out")" + [ ! -s "$state/.wake-queue" ] || fail "the first proven-idle ring published a durable stall" + grep -F '[ENTER]' "$dir/sent" >/dev/null \ + || fail "the genuine-stall fixture never rang the child" + [ "$(cat "$state/.secondmate-wake-ring-mate" 2>/dev/null || true)" = "100-7" ] \ + || fail "the successful ring did not record the frozen row" + cmp -s "$row_before" "$sub/state/.wake-queue" \ + || fail "the unread ring rewrote the foreign queue" + + printf '1004\n' > "$dir/now" + PATH="$fakebin:$PATH" FM_FAKE_NOW_FILE="$dir/now" FM_HOME="$dir" FM_ROOT_OVERRIDE="$ROOT" \ + FM_STATE_OVERRIDE="$state" FM_FAKE_TMUX_SENT="$dir/sent" \ + FM_SECONDMATE_WAKE_STALL_SECS=1 FM_POLL=1 FM_SIGNAL_GRACE=0 \ + FM_CHECK_INTERVAL=999999 FM_HEARTBEAT=999999 \ + "$ROOT/bin/fm-watch-checkpoint.sh" --seconds 4 > "$dir/watch-stall.out" 2> "$dir/watch-stall.err" || true + grep -F 'check: secondmate wake-loop stalled: mate=mate row=7 idle=2s' "$dir/watch-stall.out" >/dev/null \ + || fail "a leftover row that survived the idle ring stayed hidden: $(cat "$dir/watch-stall.out")" + stall_count=$(grep -c 'secondmate-wake-loop-mate-' "$state/.wake-queue" || true) + [ "$stall_count" -eq 1 ] || fail "the genuine stall after a ring did not publish exactly one notification" + cmp -s "$row_before" "$sub/state/.wake-queue" \ + || fail "the parent alarm path rewrote the foreign queue" + pass "a leftover row that survives a proven-idle ring still surfaces as a genuine stall" +} + test_secondmate_stall_marker_rejects_symlink() { local dir state sub fakebin marker outside expected epoch dir=$(make_case secondmate-stall-marker-symlink) @@ -1657,6 +1894,148 @@ test_self_announced_append_guards() { pass "self-announced appends suppress only their own bytes and fail toward waking" } +# Two distinct --resolve-key closes after an OPEN DECISIONS fold record their +# own byte ranges, so the watcher's span classification never reports the +# answers. The fold alone does not mark the worker's decisions seen, because +# any actor's drain folds: a folded decision this home has not answered still +# classifies as a new signal. Once the watcher has classified the worker's +# decisions and nothing beyond them, only the owned-append ledger can vouch +# for the two answers sitting past that offset, and a later worker line past +# the recorded ranges still wakes. +test_separate_self_announced_answers_after_fold_are_owned() { + local dir state status rc events pre_answer ident + dir=$(make_case multi-answer-owned) + state="$dir/state" + status="$state/t.status" + + run_wake_lib() { + FM_STATE_OVERRIDE="$state" bash -c ' + . "$1"; shift; "$@" + ' _ "$ROOT/bin/fm-wake-lib.sh" "$@" + } + + { + printf 'needs-decision [key=k1]: pick REST or RPC\n' + printf 'needs-decision [key=k2]: pick us-east or eu-west\n' + printf 'needs-decision [key=k3]: pick a database\n' + } > "$status" + FM_STATE_OVERRIDE="$state" "$DRAIN" >/dev/null 2>"$dir/fold.err" \ + || fail "the OPEN DECISIONS fold drain failed" + run_wake_lib fm_wake_signal_seen_current "$state" "$status" \ + && fail "a fold alone marked unclassified worker decisions as seen" + + pre_answer=$(wc -c < "$status" | tr -d '[:space:]') + rc=0 + run_wake_lib fm_wake_status_append_self_announced "$state" "$status" \ + 'resolved [key=k1]: answered: REST' || rc=$? + [ "$rc" -eq 1 ] || fail "the first answer over unclassified decisions did not fail toward waking (rc=$rc)" + rc=0 + run_wake_lib fm_wake_status_append_self_announced "$state" "$status" \ + 'resolved [key=k2]: answered: eu-west' || rc=$? + [ "$rc" -eq 1 ] || fail "the second answer over unclassified decisions did not fail toward waking (rc=$rc)" + run_wake_lib fm_wake_signal_seen_current "$state" "$status" \ + && fail "unclassified worker decisions were hidden behind this home's answers" + + events=$(FM_STATE_OVERRIDE="$state" bash -c '. "$1"; status_span_first_actionable "$2" 0' _ "$ROOT/bin/fm-classify-lib.sh" "$status") \ + || fail "the unanswered folded decision was not classified as actionable" + [ "$events" = 'needs-decision [key=k3]: pick a database' ] \ + || fail "the span classification reported more than the unanswered decision: $events" + + ident=$(FM_STATE_OVERRIDE="$state" bash -c ' + . "$1"; _fm_open_decisions_file_ident "$2" + ' _ "$ROOT/bin/fm-classify-lib.sh" "$status") \ + || fail "could not read the status identity" + run_wake_lib fm_wake_status_seen_commit "$state" "$status" "$pre_answer" "$ident" \ + || fail "could not record the watcher classifying the worker's decisions" + run_wake_lib fm_wake_signal_seen_current "$state" "$status" \ + || fail "the owned answers past the classified offset were left to re-wake this home" + + printf 'blocked [key=creds]: need staging credentials\n' >> "$status" + run_wake_lib fm_wake_signal_seen_current "$state" "$status" \ + && fail "a later worker line after two owned answers was swallowed" + + pass "separate self-announced answers after a fold stay owned; worker decisions and later lines still wake" +} + +# The owned ledger only vouches for growth it recorded. A signature change +# with no growth past the classified offset, such as the log turning +# unreadable, must still read as unreported, before and after owned growth. +test_unreadable_status_is_not_owned() { + local dir state status + dir=$(make_case owned-unreadable) + state="$dir/state" + status="$state/t.status" + + run_wake_lib() { + FM_STATE_OVERRIDE="$state" bash -c ' + . "$1"; shift; "$@" + ' _ "$ROOT/bin/fm-wake-lib.sh" "$@" + } + + if [ "$(id -u)" -eq 0 ]; then + pass "unreadable status check skipped: root reads mode-000 files" + return 0 + fi + printf 'needs-decision [key=k1]: pick one\n' > "$status" + run_wake_lib fm_wake_status_mark_current "$state" "$status" \ + || fail "could not prime the announced baseline" + chmod 000 "$status" + if run_wake_lib fm_wake_signal_seen_current "$state" "$status"; then + chmod 600 "$status" + fail "an unreadable fully classified status read as already seen" + fi + chmod 600 "$status" + + run_wake_lib fm_wake_status_mark_current "$state" "$status" \ + || fail "could not re-prime the announced baseline" + run_wake_lib fm_wake_status_append_self_announced "$state" "$status" \ + 'resolved [key=k1]: answered: one' \ + || fail "the owned close was not self-announced" + printf 'needs-decision [key=k2]: pick two\n' >> "$status" + run_wake_lib fm_wake_status_mark_current "$state" "$status" \ + || fail "could not record the watcher classifying the worker line" + run_wake_lib fm_wake_status_append_self_announced "$state" "$status" \ + 'resolved [key=k2]: answered: two' \ + || fail "the second owned close was not self-announced" + chmod 000 "$status" + if run_wake_lib fm_wake_signal_seen_current "$state" "$status"; then + chmod 600 "$status" + fail "an unreadable status after owned growth read as already seen" + fi + chmod 600 "$status" + pass "an unreadable status still reads as unreported, with or without owned growth" +} + +test_folded_worker_resolved_is_not_owned_lag() { + local dir state status rc + dir=$(make_case folded-worker-resolved) + state="$dir/state" + status="$state/t.status" + + run_wake_lib() { + FM_STATE_OVERRIDE="$state" bash -c ' + . "$1"; shift; "$@" + ' _ "$ROOT/bin/fm-wake-lib.sh" "$@" + } + + { + printf 'needs-decision [key=budget]: approve spend?\n' + printf 'needs-decision [key=vendor]: vendor A or B?\n' + printf 'resolved [key=vendor]: picked vendor B myself, cheaper\n' + } > "$status" + FM_STATE_OVERRIDE="$state" "$DRAIN" >/dev/null 2>"$dir/fold.err" \ + || fail "the OPEN DECISIONS fold drain failed" + + rc=0 + run_wake_lib fm_wake_status_append_self_announced "$state" "$status" \ + 'resolved [key=budget]: answered: approved' || rc=$? + [ "$rc" -eq 1 ] || fail "a close over a folded worker resolved did not fail toward waking (rc=$rc)" + run_wake_lib fm_wake_signal_seen_current "$state" "$status" \ + && fail "a worker resolved in the folded span was treated as already owned" + + pass "a worker resolved in fold lag still wakes after this home's close" +} + # A trap that fires inside a lock's critical section abandons the holding # frame, and the exit path then re-acquires the same lock (a TERM inside a # recovery-marker section is the reproduced case: the watcher's reap wedged @@ -1960,6 +2339,49 @@ test_malformed_presentation_lock_reports_acquire_failure() { pass "malformed presentation locks report acquire failure instead of contention" } +# The owned-append ledger is wake-only: it must never withhold a captain-facing +# turn-ended annotation. An in-flight watcher classification that commits after +# this home's own close regresses the classified offset behind the owned bytes - +# exactly the state the wake scan treats as already owned - so the wake stays +# suppressed while the historical annotation must still present the line. +test_owned_growth_still_annotates_turn_ended() { + local dir state out err status pre_close ident + dir=$(make_case owned-historical) + state="$dir/state" + out="$dir/drain.out" + err="$dir/drain.err" + status="$state/scout.status" + + run_wake_lib() { + FM_STATE_OVERRIDE="$state" bash -c ' + . "$1"; shift; "$@" + ' _ "$ROOT/bin/fm-wake-lib.sh" "$@" + } + + printf 'needs-decision [key=budget]: approve spend?\n' > "$status" + prime_status_seen "$state" "$status" || fail "could not prime the scout seen marker" + pre_close=$(wc -c < "$status" | tr -d '[:space:]') + run_wake_lib fm_wake_status_append_self_announced "$state" "$status" \ + 'resolved [key=budget]: answered: approved' \ + || fail "the answerer close was not self-announced" + ident=$(FM_STATE_OVERRIDE="$state" bash -c ' + . "$1"; _fm_open_decisions_file_ident "$2" + ' _ "$ROOT/bin/fm-classify-lib.sh" "$status") \ + || fail "could not read the status identity" + run_wake_lib fm_wake_status_seen_commit "$state" "$status" "$pre_close" "$ident" \ + || fail "could not replay the stale watcher classification" + run_wake_lib fm_wake_signal_seen_current "$state" "$status" \ + || fail "owned-only growth did not suppress the wake" + + : > "$state/scout.turn-ended" + append_wake "$state" signal scout.turn-ended "signal: $state/scout.turn-ended" \ + || fail "turn-ended wake append failed" + FM_STATE_OVERRIDE="$state" "$DRAIN" > "$out" 2> "$err" || fail "drain failed" + sed -E 's/ \[at=[0-9]+\]//' "$out" | grep -F 'scout.status: resolved [key=budget]: answered: approved' >/dev/null \ + || fail "owned growth hid this home's own close from the turn-ended annotation: $(cat "$out")" + pass "owned growth suppresses the wake without hiding the turn-ended annotation" +} + # Drain-time historical annotation staleness: a turn-ended-only wake row must # not present an already-announced status line as a new update, while a status # file with unannounced bytes keeps its annotation and a direct status row is @@ -2019,10 +2441,17 @@ test_secondmate_declared_pause_rows_do_not_feed_stall_escalation test_secondmate_reprovisioned_queue_starts_a_fresh_interval test_secondmate_active_turn_defers_stall_until_the_turn_ends test_secondmate_long_lived_mate_mid_turn_is_not_a_stall +test_secondmate_proven_idle_ring_lets_the_child_drain +test_secondmate_busy_and_unknown_panes_are_not_rung +test_secondmate_genuine_stall_after_idle_ring_still_alarms test_secondmate_stall_marker_rejects_symlink test_acknowledged_stall_publication_survives_pre_marker_crash test_empty_prefix_mate_preserves_other_mate_receipt test_self_announced_append_guards +test_separate_self_announced_answers_after_fold_are_owned +test_unreadable_status_is_not_owned +test_folded_worker_resolved_is_not_owned_lag +test_owned_growth_still_annotates_turn_ended test_historical_annotation_skips_announced_status test_concurrent_append_and_drain test_signal_catchup_without_running_watcher diff --git a/tests/fm-watch-arm.test.sh b/tests/fm-watch-arm.test.sh index 33cd245700a..cd33c5a3b97 100755 --- a/tests/fm-watch-arm.test.sh +++ b/tests/fm-watch-arm.test.sh @@ -22,6 +22,25 @@ DRAIN="$ROOT/bin/fm-wake-drain.sh" TMP_ROOT=$(fm_test_tmproot fm-watch-arm-tests) +# A re-arm does real work before and during its first poll: it steals the dead +# watcher's lock, publishes and announces the downtime marker, then surfaces the +# recovery wake. That is a long run of short-lived processes, which a contended +# host - a changed-suite run beside three other suites - can slow far more than +# this suite's mostly sleeping poll loops. One re-arm measured about 2s idle, 5-7s +# beside three concurrent copies, and past the arm's default 10s confirmation +# deadline when the case was held to 15% of one CPU. These cases assert recovery, +# not a deadline, so under the CONTRIBUTING.md fixture-budget rule the arm gets an +# explicit confirmation budget with headroom, and each wait on it is an +# iteration-counted ceiling that outlasts that budget. A passing case returns as +# soon as the arm reports or exits, and a watcher that never surfaces its +# recovery still fails once the ceiling is spent. +REARM_CONFIRM_SECONDS=30 +# start_rearm_arm polls every 0.05s, so this outlasts the confirmation budget. +REARM_REPORT_POLLS=700 +# wait_for_exit polls every 0.1s. The arm can spend its confirmation budget again +# waiting for a successor before it reports a failure, so this outlasts it too. +REARM_EXIT_POLLS=400 + # Both starters background a real process the test later waits on, so they set a # global instead of echoing: a command substitution would make the pid a child of # a subshell this shell can no longer wait for. @@ -144,11 +163,16 @@ start_rearm_arm() { # <home> <state> <fakebin> <arm-out> [predecessor-arm-pid] local home=$1 state=$2 fakebin=$3 armout=$4 predecessor=${5:-} i PATH="$fakebin:$PATH" FM_HOME="$home" FM_STATE_OVERRIDE="$state" \ FM_POLL=1 FM_SIGNAL_GRACE=0 FM_CHECK_INTERVAL=999999 FM_HEARTBEAT=999999 \ + FM_ARM_CONFIRM_TIMEOUT="$REARM_CONFIRM_SECONDS" \ FM_WATCH_PREDECESSOR_ARM_PID="$predecessor" \ "$WATCH_ARM" --restart > "$armout" & ARM_PID=$! + # Wait for the arm to confirm its watcher or exit, within a ceiling that + # outlasts its confirmation budget. A fixed short count let a slow start fall + # through mid-confirmation, so the caller's next liveness check or exit wait + # began from an unknown point in the cycle. i=0 - while [ "$i" -lt 80 ]; do + while [ "$i" -lt "$REARM_REPORT_POLLS" ]; do grep -q '^watcher: started ' "$armout" 2>/dev/null && return 0 is_live_non_zombie "$ARM_PID" || return 0 sleep 0.05 @@ -288,7 +312,7 @@ test_rearm_resurfaces_durable_queue_and_remote_open_decision() { append_wake "$state" check startup-network 'check: startup-network' start_rearm_arm "$home" "$state" "$fakebin" "$armout" - wait_for_exit "$ARM_PID" 80 + wait_for_exit "$ARM_PID" "$REARM_EXIT_POLLS" status=$? [ "$status" -ne 124 ] \ || fail "re-arm stayed live instead of surfacing durable wakes and the still-open remote decision" @@ -321,7 +345,7 @@ test_rearm_resurfaces_durable_queue_and_remote_open_decision() { kill "$ARM_PID" 2>/dev/null || true wait "$ARM_PID" 2>/dev/null || true start_rearm_arm "$home" "$state" "$fakebin" "$dir/decision-only-arm.out" - wait_for_exit "$ARM_PID" 80 || fail "decision-only re-arm did not surface the open decision" + wait_for_exit "$ARM_PID" "$REARM_EXIT_POLLS" || fail "decision-only re-arm did not surface the open decision" decision_recovery_arm=$ARM_PID start_rearm_arm "$home" "$state" "$fakebin" "$dir/decision-handling-successor.out" "$decision_recovery_arm" is_live_non_zombie "$ARM_PID" || fail "decision handling successor re-triggered before the drain" @@ -343,7 +367,7 @@ test_rearm_resurfaces_durable_queue_and_remote_open_decision() { kill -TERM "$decision_successor" 2>/dev/null || fail "could not interrupt decision handling successor" wait "$decision_successor" 2>/dev/null || true start_rearm_arm "$home" "$state" "$fakebin" "$dir/interrupted-decision-arm.out" - wait_for_exit "$ARM_PID" 80 || fail "interrupted decision handling was not recovered on successor re-arm" + wait_for_exit "$ARM_PID" "$REARM_EXIT_POLLS" || fail "interrupted decision handling was not recovered on successor re-arm" grep -F 'check: rearm-resurface' "$dir/interrupted-decision-arm.out" >/dev/null \ || fail "successor did not re-surface the unacknowledged decision recovery" FM_HOME="$home" FM_STATE_OVERRIDE="$state" "$DRAIN" > "$dir/replayed-decision-drain.out" \ @@ -364,6 +388,65 @@ test_rearm_resurfaces_durable_queue_and_remote_open_decision() { pass "watch-arm: re-arm surfaces every queued wake and an open remote decision after downtime" } +# A contended host starves the short-lived processes a recovery cycle runs while +# this suite's own poll loops, which mostly sleep, keep their pace, so a re-arm +# that is still surfacing its recovery can look like one that stayed live. +# Reproduce that on any host: once the re-armed watcher has published its +# liveness beacon, every mktemp and readlink it runs - the lock and marker steps +# of its first poll and its exit - is delayed, so the cycle outlasts the roughly +# 8s that a fixed 80-poll wait allows on an idle host. +test_slow_rearm_recovery_is_still_surfaced() { + local dir home state fakebin armout first_arm watcher_pid tool real started status + dir=$(make_case slow-rearm-recovery) + home="$dir/home" + state="$dir/state" + fakebin="$dir/fakebin" + armout="$dir/arm.out" + mkdir -p "$home/data" + + start_rearm_arm "$home" "$state" "$fakebin" "$dir/first-arm.out" + first_arm=$ARM_PID + is_live_non_zombie "$first_arm" || fail "slow-recovery fixture watcher did not stay live" + watcher_pid=$(cat "$state/.watch.lock/pid" 2>/dev/null || true) + kill -KILL "$watcher_pid" 2>/dev/null || fail "could not abruptly stop slow-recovery fixture watcher" + wait "$first_arm" 2>/dev/null || true + append_wake "$state" check startup-network 'check: startup-network before a slow re-arm' + + # Removing the dead watcher's beacon makes the delay start exactly when the + # re-armed watcher publishes its own, so its startup and the arm's confirmation + # stay at full speed and only the work after confirmation is slowed. + rm -f "$state/.last-watcher-beat" + for tool in mktemp readlink; do + real=$(command -v "$tool") || fail "no $tool to delay" + cat > "$fakebin/$tool" <<SH +#!/bin/sh +[ -e "$state/.last-watcher-beat" ] && sleep 0.6 +exec "$real" "\$@" +SH + chmod +x "$fakebin/$tool" + done + + started=$(date +%s) + start_rearm_arm "$home" "$state" "$fakebin" "$armout" + wait_for_exit "$ARM_PID" "$REARM_EXIT_POLLS" + status=$? + [ "$status" -ne 124 ] \ + || fail "slow re-arm stayed live instead of surfacing its recovery" + expect_code 0 "$status" "slow re-arm recovery must close successfully" + grep -F 'check: rearm-resurface' "$armout" >/dev/null \ + || fail "slow re-arm did not report the durable recovery wake: $(cat "$armout")" + # Without this, a change that stopped the delay from applying would pass here + # while no longer testing a slow cycle at all. + [ $(( $(date +%s) - started )) -ge 12 ] \ + || fail "the delayed tools did not hold the re-arm past the old fixed wait" + FM_HOME="$home" FM_STATE_OVERRIDE="$state" "$DRAIN" > "$dir/drain.out" \ + || fail "slow re-arm recovery drain failed" + grep "$(printf '\tcheck\tstartup-network\t')" "$dir/drain.out" >/dev/null \ + || fail "wake queued before the slow re-arm was not drained" + ack_wakes "$state" || fail "slow re-arm handling acknowledgement failed" + pass "watch-arm: a re-arm whose recovery cycle runs slowly still surfaces it" +} + test_marker_publish_failure_retains_recovery_evidence() { local dir home state fakebin first_arm watcher_pid armout dir=$(make_case downtime-marker-publish-failure) @@ -388,7 +471,7 @@ test_marker_publish_failure_retains_recovery_evidence() { rmdir "$state/.watcher-down" armout="$dir/recovery-arm.out" start_rearm_arm "$home" "$state" "$fakebin" "$armout" - wait_for_exit "$ARM_PID" 80 || fail "stale-lock recovery did not surface downtime" + wait_for_exit "$ARM_PID" "$REARM_EXIT_POLLS" || fail "stale-lock recovery did not surface downtime" grep -F 'check: rearm-resurface' "$armout" >/dev/null \ || fail "stale-lock recovery did not emit the recovery wake: $(cat "$armout")" pass "watch-arm: marker publication failure retains stale-lock recovery evidence" @@ -406,7 +489,7 @@ test_delivery_gap_wake_is_recovered_once() { first_arm=$ARM_PID is_live_non_zombie "$first_arm" || fail "delivery-gap fixture watcher did not stay live" printf 'done: first delivered wake\n' > "$state/first.status" - wait_for_exit "$first_arm" 120 || fail "first watcher did not deliver its status wake" + wait_for_exit "$first_arm" "$REARM_EXIT_POLLS" || fail "first watcher did not deliver its status wake" grep -q '^signal:' "$dir/first-arm.out" \ || fail "first watcher did not report its delivered wake" @@ -416,7 +499,7 @@ test_delivery_gap_wake_is_recovered_once() { append_wake "$state" check startup-network 'check: startup-network during handling gap' start_rearm_arm "$home" "$state" "$fakebin" "$dir/gap-arm.out" - wait_for_exit "$ARM_PID" 80 || fail "successor missed the wake queued in the delivery gap" + wait_for_exit "$ARM_PID" "$REARM_EXIT_POLLS" || fail "successor missed the wake queued in the delivery gap" grep -F 'check: rearm-resurface' "$dir/gap-arm.out" >/dev/null \ || fail "delivery-gap successor did not emit one recovery wake: $(cat "$dir/gap-arm.out")" @@ -445,12 +528,12 @@ test_interrupted_handling_is_redrained_on_rearm() { first_arm=$ARM_PID is_live_non_zombie "$first_arm" || fail "interrupted-handling fixture watcher did not stay live" printf 'done: wake whose handling is interrupted\n' > "$state/interrupted.status" - wait_for_exit "$first_arm" 120 || fail "fixture watcher did not deliver its wake" + wait_for_exit "$first_arm" "$REARM_EXIT_POLLS" || fail "fixture watcher did not deliver its wake" grep "$(printf '\tsignal\tinterrupted.status\t')" "$state/.wake-queue" >/dev/null \ || fail "delivered wake was not durable before handling" start_rearm_arm "$home" "$state" "$fakebin" "$dir/crash-gap-recovery-arm.out" - wait_for_exit "$ARM_PID" 80 || fail "re-arm after a pre-successor crash stranded the durable wake" + wait_for_exit "$ARM_PID" "$REARM_EXIT_POLLS" || fail "re-arm after a pre-successor crash stranded the durable wake" recovery_arm=$ARM_PID grep -F 'check: rearm-resurface' "$dir/crash-gap-recovery-arm.out" >/dev/null \ || fail "re-arm after a pre-successor crash did not re-surface the durable wake" @@ -464,7 +547,7 @@ test_interrupted_handling_is_redrained_on_rearm() { [ -n "$generation_before" ] || fail "crash-gap recovery left no recovery generation" start_rearm_arm "$home" "$state" "$fakebin" "$dir/reason-emit-crash-replay.out" - wait_for_exit "$ARM_PID" 80 || fail "a crash after reason emission stranded the durable wake" + wait_for_exit "$ARM_PID" "$REARM_EXIT_POLLS" || fail "a crash after reason emission stranded the durable wake" recovery_arm=$ARM_PID grep -F 'check: rearm-resurface' "$dir/reason-emit-crash-replay.out" >/dev/null \ || fail "a crash after reason emission did not re-drain recovery" @@ -508,7 +591,7 @@ test_interrupted_handling_is_redrained_on_rearm() { esac start_rearm_arm "$home" "$state" "$fakebin" "$dir/recovery-arm.out" - wait_for_exit "$ARM_PID" 80 || fail "successor after interruption did not re-surface the pending wake" + wait_for_exit "$ARM_PID" "$REARM_EXIT_POLLS" || fail "successor after interruption did not re-surface the pending wake" grep -F 'check: rearm-resurface' "$dir/recovery-arm.out" >/dev/null \ || fail "successor after interruption did not emit durable recovery" FM_HOME="$home" FM_STATE_OVERRIDE="$state" "$DRAIN" > "$dir/replay-drain.out" \ @@ -536,7 +619,7 @@ test_malformed_marker_is_quarantined_once() { printf 'foreign state\n' > "$state/.watcher-down/payload" start_rearm_arm "$home" "$state" "$fakebin" "$dir/recovery-arm.out" - wait_for_exit "$ARM_PID" 80 || fail "malformed marker did not produce a bounded recovery wake" + wait_for_exit "$ARM_PID" "$REARM_EXIT_POLLS" || fail "malformed marker did not produce a bounded recovery wake" grep -F 'check: rearm-resurface' "$dir/recovery-arm.out" >/dev/null \ || fail "malformed marker did not emit the recovery wake" invalid_count=$(find "$state" -maxdepth 1 -type d -name '.watcher-down.invalid.*' | wc -l | tr -d '[:space:]') @@ -565,7 +648,7 @@ test_recovery_consumption_serializes_queue_publication() { is_live_non_zombie "$ARM_PID" || fail "acknowledged recovery fixture did not remain live" append_wake "$state" check startup-network 'check: concurrent startup-network' \ || fail "concurrent queue publication failed" - wait_for_exit "$ARM_PID" 80 \ + wait_for_exit "$ARM_PID" "$REARM_EXIT_POLLS" \ || fail "watcher missed publication after an acknowledged recovery handoff" grep -F 'check: rearm-resurface' "$dir/arm.out" >/dev/null \ || fail "publisher did not restore recovery evidence" @@ -598,7 +681,7 @@ test_restart_preserves_recovery_across_reused_pid_lock() { ln -s "$owner" "$state/.watch.lock" start_rearm_arm "$home" "$state" "$fakebin" "$armout" - wait_for_exit "$ARM_PID" 80 || fail "restart did not surface recovery after clearing a reused-pid lock" + wait_for_exit "$ARM_PID" "$REARM_EXIT_POLLS" || fail "restart did not surface recovery after clearing a reused-pid lock" grep -F 'check: rearm-resurface' "$armout" >/dev/null \ || fail "restart cleared reused-pid lock evidence without a recovery wake: $(cat "$armout")" is_live_non_zombie "$unrelated" || fail "restart signaled the unrelated process whose pid was reused" @@ -618,7 +701,7 @@ test_markerless_legacy_queue_is_recovered_on_arm() { printf '%s\n' "$row" > "$state/.wake-queue" start_rearm_arm "$home" "$state" "$fakebin" "$dir/arm.out" - wait_for_exit "$ARM_PID" 80 || fail "markerless legacy queue was stranded at re-arm" + wait_for_exit "$ARM_PID" "$REARM_EXIT_POLLS" || fail "markerless legacy queue was stranded at re-arm" grep -F 'check: rearm-resurface' "$dir/arm.out" >/dev/null \ || fail "markerless legacy queue did not trigger recovery" case "$(cat "$state/.watcher-down" 2>/dev/null || true)" in @@ -646,7 +729,7 @@ test_handling_window_close_keeps_the_acknowledgement_valid() { start_rearm_arm "$home" "$state" "$fakebin" "$dir/first-arm.out" is_live_non_zombie "$ARM_PID" || fail "handling-window fixture watcher did not stay live" printf 'done: wake handled while a watcher cycle closes\n' > "$state/handled.status" - wait_for_exit "$ARM_PID" 120 || fail "fixture watcher did not deliver its wake" + wait_for_exit "$ARM_PID" "$REARM_EXIT_POLLS" || fail "fixture watcher did not deliver its wake" grep "$(printf '\tsignal\thandled.status\t')" "$state/.wake-queue" >/dev/null \ || fail "delivered wake was not durable before handling" @@ -661,7 +744,7 @@ test_handling_window_close_keeps_the_acknowledgement_valid() { start_rearm_arm "$home" "$state" "$fakebin" "$dir/handling-window-arm.out" is_live_non_zombie "$ARM_PID" || fail "handling-window watcher did not stay live" printf 'done: wake published during handling\n' > "$state/during-handling.status" - wait_for_exit "$ARM_PID" 120 || fail "handling-window watcher did not deliver its wake" + wait_for_exit "$ARM_PID" "$REARM_EXIT_POLLS" || fail "handling-window watcher did not deliver its wake" grep "$(printf '\tsignal\tduring-handling.status\t')" "$state/.wake-queue" >/dev/null \ || fail "handling-window watcher did not durably append its wake" @@ -695,7 +778,7 @@ test_handling_window_close_keeps_the_acknowledgement_valid() { ! grep -F 'check: rearm-resurface' "$dir/next-arm.out" >/dev/null \ || fail "the watcher armed after acknowledgement re-announced a retired recovery" printf 'blocked: a later wake the live watcher must still surface\n' > "$state/later.status" - wait_for_exit "$ARM_PID" 120 || fail "the live watcher did not surface a later wake" + wait_for_exit "$ARM_PID" "$REARM_EXIT_POLLS" || fail "the live watcher did not surface a later wake" grep -q '^signal:' "$dir/next-arm.out" \ || fail "the watcher armed after acknowledgement never reached real supervision work: $(cat "$dir/next-arm.out")" pass "watch-arm: a watcher close during handling keeps the printed acknowledgement valid" @@ -714,7 +797,7 @@ test_moved_generation_acknowledgement_is_self_healing() { start_rearm_arm "$home" "$state" "$fakebin" "$dir/first-arm.out" is_live_non_zombie "$ARM_PID" || fail "moved-generation fixture watcher did not stay live" printf 'done: first handled wake\n' > "$state/first.status" - wait_for_exit "$ARM_PID" 120 || fail "fixture watcher did not deliver its first wake" + wait_for_exit "$ARM_PID" "$REARM_EXIT_POLLS" || fail "fixture watcher did not deliver its first wake" FM_HOME="$home" FM_STATE_OVERRIDE="$state" "$DRAIN" > "$dir/first-drain.out" \ 2> "$dir/first-drain.err" || fail "first drain did not present the durable wake" pair=$(drain_ack_pair "$dir/first-drain.err") \ @@ -729,7 +812,7 @@ test_moved_generation_acknowledgement_is_self_healing() { start_rearm_arm "$home" "$state" "$fakebin" "$dir/second-arm.out" is_live_non_zombie "$ARM_PID" || fail "second fixture watcher did not stay live" printf 'done: second wake in a newer recovery episode\n' > "$state/second.status" - wait_for_exit "$ARM_PID" 120 || fail "second fixture watcher did not deliver its wake" + wait_for_exit "$ARM_PID" "$REARM_EXIT_POLLS" || fail "second fixture watcher did not deliver its wake" second_generation=$(sed -n 's/^pending:downtime:\(.*\)$/\1/p' "$state/.watcher-down") [ -n "$second_generation" ] || fail "a wake after acknowledgement did not open a recovery episode" [ "$second_generation" != "$first_generation" ] \ @@ -846,6 +929,7 @@ test_attached_arm_reports_the_delivered_wake_after_drain test_arm_refuses_an_unusable_launch_confirm_window test_attached_arm_still_fails_on_a_wake_it_did_not_deliver test_rearm_resurfaces_durable_queue_and_remote_open_decision +test_slow_rearm_recovery_is_still_surfaced test_marker_publish_failure_retains_recovery_evidence test_delivery_gap_wake_is_recovered_once test_interrupted_handling_is_redrained_on_rearm diff --git a/tests/fm-watch-triage.test.sh b/tests/fm-watch-triage.test.sh index 8a94ebdf22f..490e431bbd1 100755 --- a/tests/fm-watch-triage.test.sh +++ b/tests/fm-watch-triage.test.sh @@ -174,7 +174,17 @@ record_pi_busy() { # <state-dir> <id> --source pi-ext --event agent-start } -reap() { kill "$1" 2>/dev/null || true; wait "$1" 2>/dev/null || true; } +# Stop an owned watcher. TERM must end it through its EXIT cleanup, so one still +# alive after the file's standard 100-tick budget fails the case here, with the +# process evidence wait_for_exit prints, instead of an unbounded wait hanging +# the whole suite until the CI job timeout. +reap() { + local rc + kill "$1" 2>/dev/null || true + wait_for_exit "$1" 100 + rc=$? + [ "$rc" -ne 124 ] || fail "watcher pid $1 did not exit within 10s of TERM" +} # --- pure classifier predicates (fm-classify-lib.sh) ------------------------ @@ -1607,6 +1617,74 @@ test_self_announced_close_after_open_decisions_fold_does_not_rewake() { pass "a close after OPEN DECISIONS fold never wakes its own home, and the next real note still does" } +# Any actor's drain folds OPEN DECISIONS, including a Pi branch drain, so a +# fold is no proof the watcher's owner saw the line. A fresh worker decision the +# fold already read must still wake when this home appended nothing. +test_folded_worker_decision_without_home_append_still_wakes() { + local dir state fakebin out status_file pid + dir=$(make_case folded-decision-wakes); state="$dir/state"; fakebin="$dir/fakebin"; out="$dir/watch.out" + status_file="$state/task.status" + printf 'working: building\n' > "$status_file" + prime_status_seen "$state" "$status_file" || fail "could not prime the announced baseline" + printf 'needs-decision [key=k3]: pick a region\n' >> "$status_file" + FM_STATE_OVERRIDE="$state" "$DRAIN" >/dev/null 2>"$dir/fold.err" \ + || fail "the OPEN DECISIONS fold drain failed" + export FM_FAKE_CREW_STATE='state: unknown · source: none · idle worker' + watch_bg "$state" "$fakebin" "$out" + pid=$! + wait_for_exit "$pid" 100 || fail "a folded worker decision with no home append was swallowed" + grep -F "signal: $status_file" "$out" >/dev/null \ + || fail "the folded worker decision did not surface as a signal: $(cat "$out")" + pass "a folded worker decision with no home append still wakes" +} + +# Two distinct --resolve-key answers to decisions the watcher never classified +# leave the marker alone, since a fold is no proof the watcher's owner saw them. +# That costs one wake for the worker's decisions, not one per answer, because +# both answers ride inside the same surfaced span; the watcher's own commit +# then covers them, so the next cycle is quiet and the next real note still +# wakes. The ledger's separate job - vouching for owned bytes the watcher has +# NOT classified - is pinned at library level by +# test_separate_self_announced_answers_after_fold_are_owned. +test_separate_self_announced_answers_after_fold_wake_once() { + local dir state fakebin out status_file pid rc answer + dir=$(make_case multi-answer-fold); state="$dir/state"; fakebin="$dir/fakebin"; out="$dir/watch.out" + status_file="$state/task.status" + { + printf 'needs-decision [key=k1]: pick REST or RPC\n' + printf 'needs-decision [key=k2]: pick us-east or eu-west\n' + } > "$status_file" + FM_STATE_OVERRIDE="$state" "$DRAIN" >/dev/null 2>"$dir/fold.err" \ + || fail "the OPEN DECISIONS fold drain failed" + for answer in 'resolved [key=k1]: answered: REST' 'resolved [key=k2]: answered: eu-west'; do + rc=0 + FM_STATE_OVERRIDE="$state" bash -c ' + . "$1"; fm_wake_status_append_self_announced "$2" "$3" "$4" + ' _ "$ROOT/bin/fm-wake-lib.sh" "$state" "$status_file" "$answer" || rc=$? + [ "$rc" -eq 1 ] || fail "an answer over unclassified worker decisions did not fail toward waking (rc=$rc)" + done + export FM_FAKE_CREW_STATE='state: unknown · source: none · idle worker' + watch_bg "$state" "$fakebin" "$out" + pid=$! + wait_for_exit "$pid" 100 || fail "the unclassified worker decisions were swallowed" + grep -F "signal: $status_file" "$out" >/dev/null \ + || fail "the worker decisions did not surface as a signal: $(cat "$out")" + ack_stopped_cycle "$state" || fail "could not handle the worker decisions' wake" + : > "$out" + watch_bg "$state" "$fakebin" "$out" + pid=$! + if ! wait_poll_cycle "$state" "$pid"; then + reap "$pid"; fail "the owned answers re-woke the watcher: $(cat "$out")" + fi + [ ! -s "$out" ] || { reap "$pid"; fail "the owned answers printed a wake reason: $(cat "$out")"; } + [ ! -s "$state/.wake-queue" ] || { reap "$pid"; fail "the owned answers enqueued another durable wake"; } + printf 'blocked: need staging credentials\n' >> "$status_file" + wait_for_exit "$pid" 100 || fail "a later worker line after two owned answers was swallowed" + grep -F "signal: $status_file" "$out" >/dev/null \ + || fail "the later worker line did not surface as a signal" + pass "separate answers over unclassified decisions wake once, and the next real note still does" +} + test_self_announced_close_after_fold_still_surfaces_folded_worker_failure() { local dir state fakebin out status_file pid rc dir=$(make_case self-close-folded-failure); state="$dir/state"; fakebin="$dir/fakebin"; out="$dir/watch.out" @@ -4152,6 +4230,52 @@ test_wedge_escalation_resets_when_pane_becomes_active() { pass "a pane becoming active again resets the consecutive wedge-escalation counter" } +# --- a stop request is honored mid-poll -------------------------------------- +# Every stopper (the arm's signal path, the away-mode daemon, reap above) waits +# for the watcher to exit after one TERM, so TERM must end it through its EXIT +# cleanup at any point of a poll. A TERM trap body cannot promise that: bash +# defers it until the blocked command returns, and bash 5.2 can drop it outright +# when it is pending as a command substitution is parsed, which left CI watchers +# polling after reap until the job timed out. The pane capture here blocks on a +# FIFO whose writer never writes, so only a TERM honored mid-poll stops the +# watcher inside the bound; the released lock and acknowledgeable stop record +# prove its cleanup still ran. +test_term_stops_a_watcher_blocked_inside_a_poll() { + local dir state fakebin out fifo window sig pid holder i rc + dir=$(make_case term-blocked-poll); state="$dir/state"; fakebin="$dir/fakebin" + out="$dir/watch.out"; fifo="$dir/pane.fifo"; window="test:fm-blocked-capture" + mkfifo "$fifo" + printf 'window=%s\nkind=ship\n' "$window" > "$state/blocked.meta" + printf 'working: implementing\n' > "$state/blocked.status" + sig=$(seen_sig "$state/blocked.status"); printf '%s' "$sig" > "$state/.seen-blocked_status" + # Opening the write end waits for the capture to open the read end, and the + # holder then keeps it open without writing, so that capture blocks mid-poll. + ( exec 3> "$fifo"; : > "$dir/capture-blocked"; exec sleep 30 ) & + holder=$! + PATH="$fakebin:$PATH" FM_FAKE_TMUX_WINDOW="$window" FM_FAKE_TMUX_CAPTURE="$fifo" \ + FM_STATE_OVERRIDE="$state" FM_POLL=1 FM_SIGNAL_GRACE=1 \ + FM_CHECK_INTERVAL=999999 FM_HEARTBEAT=999999 "$WATCH" > "$out" & + pid=$! + i=0 + while [ ! -e "$dir/capture-blocked" ] && [ "$i" -lt 300 ]; do + sleep 0.1 + i=$((i + 1)) + done + if [ ! -e "$dir/capture-blocked" ] || ! is_live_non_zombie "$pid"; then + kill "$holder" 2>/dev/null || true; reap "$pid" + fail "the watcher never blocked inside its pane capture: $(cat "$out")" + fi + kill "$pid" 2>/dev/null || true + wait_for_exit "$pid" 100 + rc=$? + kill "$holder" 2>/dev/null || true + wait "$holder" 2>/dev/null || true + [ "$rc" -ne 124 ] || fail "TERM did not stop a watcher blocked inside a poll" + [ ! -e "$state/.watch.lock" ] || fail "a watcher stopped mid-poll kept its singleton lock, so its cleanup did not run" + ack_stopped_cycle "$state" || fail "could not acknowledge the stop of a watcher blocked inside a poll" + pass "TERM stops a watcher blocked inside a poll and still runs its cleanup" +} + # --- busy pane duration bound: a completed-turn age gate on top of busy ----- # 2026-07 hibit-agent-focus-nonsteal-r1 incident: a busy pane (herdr "working" # and/or the harness's rendered busy footer) is unconditional, unbounded proof @@ -5721,8 +5845,7 @@ iso_utc_at() { # <epoch> } write_away_record() { # <state> - if ! FM_HOME="$(dirname "$1")" FM_STATE_OVERRIDE="$1" "$ROOT/bin/fm-afk-contract.sh" propose >/dev/null 2>&1 \ - || ! FM_HOME="$(dirname "$1")" FM_STATE_OVERRIDE="$1" "$ROOT/bin/fm-afk-contract.sh" confirm >/dev/null 2>&1; then + if ! FM_HOME="$(dirname "$1")" FM_STATE_OVERRIDE="$1" "$ROOT/bin/fm-afk-contract.sh" enter >/dev/null 2>&1; then fail "could not write the away-posture record in $1" fi } @@ -5985,6 +6108,8 @@ test_secondmate_status_note_surfaced_despite_busy_agent test_secondmate_buried_block_wakes_despite_busy_agent test_self_announced_close_does_not_rewake_but_next_note_does test_self_announced_close_after_open_decisions_fold_does_not_rewake +test_folded_worker_decision_without_home_append_still_wakes +test_separate_self_announced_answers_after_fold_wake_once test_self_announced_close_after_fold_still_surfaces_folded_worker_failure test_self_announced_close_after_fold_still_surfaces_folded_secondmate_lines test_actionable_signal_surfaced @@ -6009,6 +6134,7 @@ test_live_and_unproven_endpoints_still_wedge_escalate test_gone_report_rearms_when_the_endpoint_comes_back test_second_death_after_a_same_window_relaunch_reports_in_full test_identical_dead_display_of_a_successor_still_reports +test_term_stops_a_watcher_blocked_inside_a_poll test_busy_pane_below_turn_age_bound_is_absorbed test_busy_pane_stable_hash_escalates_past_turn_age_bound test_busy_pane_changing_hash_escalates_past_turn_age_bound diff --git a/tests/fm-x-mode.test.sh b/tests/fm-x-mode.test.sh index 9f45252bc70..9790ce42624 100755 --- a/tests/fm-x-mode.test.sh +++ b/tests/fm-x-mode.test.sh @@ -784,7 +784,7 @@ test_bootstrap_reports_missing_x_dependency() { home="$TMP_ROOT/boot-missing-x"; mkdir -p "$home" fakebin=$(fm_fakebin "$home") fm_fake_exit0 "$fakebin" tmux node no-mistakes chrome-devtools-axi curl - fm_fake_version_tool "$fakebin" lavish-axi FM_FAKE_LAVISH_AXI_VERSION 0.1.46 + fm_fake_version_tool "$fakebin" lavish-axi FM_FAKE_LAVISH_AXI_VERSION 0.1.77 cat > "$fakebin/gh-axi" <<'SH' #!/usr/bin/env bash if [ "${1:-}" = --version ]; then