diff --git a/.agents/skills/bearings/SKILL.md b/.agents/skills/bearings/SKILL.md index 99418401f38..0b83b200fc7 100644 --- a/.agents/skills/bearings/SKILL.md +++ b/.agents/skills/bearings/SKILL.md @@ -16,8 +16,8 @@ Generate a complete current snapshot from the fleet's current state, so the capt Plain `/bearings` returns only the concise four-section chat digest. Only `/bearings file` writes the dated markdown report artifact and then returns the concise four-section chat digest linked to that report. Only `/bearings atelier` builds the interactive fleet board beside that digest, through `bin/fm-bearings-board.sh` (its header owns every board mechanic and the fm-bearings-board.v1 payload contract). -A digest/build invocation is operationally read-only apart from those explicit per-mode artifacts: the dated report in file mode, and in atelier mode the board file plus the answer binding and source registration that `bin/fm-bearings-board.sh build` records through their own owners. -During that invocation it never tears down a task, merges a PR, dispatches new work, steers a worker, answers a decision, cleans up work, or mutates backlog or task state. +A digest/build invocation is operationally read-only apart from the cooldown-limited reconcile instruction and its `state/.reconcile-nudged` record, plus the explicit per-mode artifacts: the dated report in file mode, and in atelier mode the board file plus the answer binding and source registration that `bin/fm-bearings-board.sh build` records through their own owners. +During that invocation it never tears down a task, merges a PR, dispatches new work, steers a worker except through that reconcile hook, answers a decision, cleans up work, or mutates backlog or task state beyond the reconcile record. Board answers are acted on later under the normal authority rules; this skill's board-wake section explicitly owns the guarded routing at that time. ## Invocation modes @@ -34,8 +34,8 @@ Board answers are acted on later under the normal authority rules; this skill's ## What it does 1. **Gather live fleet state with one deterministic command.** - Run `bin/fm-bearings-snapshot.sh` at invocation time and read its compact output. - It is the single bounded, deterministic fleet-state source for Bearings and renders TOON by default. + Run `snapshot=$(bin/fm-bearings-snapshot.sh --json)` at invocation time and read that compact output. + It is the single bounded, deterministic fleet-state source for Bearings. Do not create or consult a second fleet-state reader, parser contract, status-event-tail interpretation, visible-session recap, ad-hoc project probe, or ad-hoc `gh-axi`/`gh` query. The command's header and `--help` output own its exact fields, bounds, opt-ins, and output contract. Keep the default local-only read unless the captain asks to include PRs. @@ -48,13 +48,22 @@ Board answers are acted on later under the normal authority rules; this skill's Until then it stays queued with the reason. The `(main-inventory)` gate is an action-free integrity warning rather than queued work. Render it under Charted Next with the related `omitted` disclosure, never invent an Underway row from backlog-only state, and never move it into Captain's Call. + The same holds for a secondmate home whose current state is unavailable, and for a readable home whose `invalidity` reports a backlog-vs-metadata mismatch: the mismatch is a repair notice about that home's own books, not a reason to drop its separately projected decisions, queued, landed, or live work. -2. **Compose the four-section chat digest from the fresh snapshot.** +2. **Ask any home whose own books disagree to reconcile them.** + When the snapshot reports a secondmate home whose `invalidity` is `orphan_in_flight`, `unowned_current`, or `terminal_in_flight`, that home's backlog and its own task metadata disagree and only that home may fix it. + Run `printf '%s\n' "$snapshot" | bin/fm-secondmate-reconcile.sh notify --snapshot -` inline immediately after gathering the snapshot, so the durable fire-and-forget enqueue finishes before digest composition without spawning any child or second snapshot. + The script header owns the cooldown window, non-blocking lock skips, stale-endpoint checks, retry, and fire-and-forget delivery contract; this hook arms no reply recovery or inbox escalation. + If the hook reports a skip or failure, continue composing the digest from the captured snapshot; a lock skip or known-undelivered send leaves the cooldown unset for a later recap. + A home is asked at most once per four-hour window, so running this on every recap costs nothing and cannot nag, while a mismatch still sitting there after the window earns one gentle re-nudge. + Never edit another home's backlog or metadata from here, and never expect or wait on a reply: the mate acts asynchronously from its durable inbox while the digest is composed from the snapshot already in hand. + +3. **Compose the four-section chat digest from the fresh snapshot.** The gather step is deterministic; your judgment is scoped to ranking the command's facts by what matters right now and writing scannable captain-facing prose. The chat response uses the four complete sections in the chat-response contract below, in the same order, each always present. Plain mode stops here and writes no report artifact. -3. **In explicit file mode only, compose and replace the detailed report file.** +4. **In explicit file mode only, compose and replace the detailed report file.** The report uses the same four complete sections as the chat, in the same order, and adds the detail the chat omits. Never read an earlier `data/status-report-*.md` to decide what to omit, include, describe as changed, or call current. Write the full report to `data/status-report-.md` using today's date. @@ -81,6 +90,8 @@ Compose the payload from the same snapshot with the same ranking judgment as the - Decision cards carry agent-authored copy: a short noun-phrase title, one-line `about` and `decide` context rows, and option labels with hints, with the recommended option marked. - Card `type` (decision, merge, credential) is your composing judgment from the row's content; no backlog field types a card for you. - When the card's task is a captain-gated WORK item (the answer should free it to proceed rather than complete it), set the card's `close: "release"` so the answer lifts the hold instead of closing the task; question-shaped items omit it. +- A Charted Next row's optional `kind` separates work from alarms: omit it (or set `"queued"`) for real queued work, and set `"warning"` on every action-free fleet-integrity notice - the `(main-inventory)` gate, an unavailable secondmate home, and an inventory-mismatch repair notice. The board badges a warning row `needs repair` instead of `waiting` and leaves it out of the Charted Next count, so those rows never read as dispatchable queued work. +- `charted_more` counts omitted queued rows only, while `charted_warning_more` counts omitted warning rows only; keep both counts separate whenever the board payload truncates Charted Next. - Every Captain's Call item and every Underway, Recently Landed, and Charted Next row carries an explicit `repo` field. Fill it from the snapshot and task records wherever known; use null or an empty string only as the deliberate genuinely-no-repo marker, in which case the template may show the internal id. Ids otherwise stay in the payload only as the routing channel, and composed reasons name blockers in plain words. Run `build` once after composing the payload. @@ -127,7 +138,7 @@ Rules that keep the contract unambiguous: - The four buckets are mutually exclusive, so every item is forced into exactly one: needs-your-action is Captain's Call, done is Recently Landed, self-progressing is Underway, and not-yet-started work or an action-free fleet-integrity warning is Charted Next. - The strict boundary keeps action-free items OUT of Captain's Call: a working or validating task, a queued item blocked on another task or a date, landed work, a completed scout's report pointer, a declared `paused:` external wait, and a bare recorded PR with no merge-ready signal each belong to one of the other three sections, never Captain's Call. - A secondmate's own row appears Underway only for `active_child_work`; `externally_held` belongs in Charted Next, and `unknown` belongs there as an unavailable-state gate unless its reason requires the captain's action. -- Do not suppress separately projected decisions, landed records, or gates from a `partial-structured` home merely because that secondmate's own row is `unknown`. +- Do not suppress separately projected decisions, landed records, or gates from a `partial-structured` home merely because that secondmate's own row is `unknown` or its `invalidity` reports an inventory mismatch. - Include the required direct address to the captain inside one item or empty-state sentence. - Every PR appears as the full `https://...` URL; a shorthand `#number` is fine only as a back-reference after the full URL has already appeared in the same digest. - The chat follows `AGENTS.md` section 9 and carries one scannable line per item. @@ -144,7 +155,7 @@ Rules that keep the contract unambiguous: ## Supervision discipline -During a digest/build invocation, this skill changes no fleet state beyond its explicit report or board artifacts, binding, and source registration. -Do not tear down a task, merge a PR, dispatch queued work, steer a worker, answer a queued decision, clean up work, or mutate any other `state/` or `data/` file during that invocation. +During a digest/build invocation, this skill changes no fleet state beyond its reconcile instruction and cooldown record, explicit report or board artifacts, binding, and source registration. +Do not tear down a task, merge a PR, dispatch queued work, steer a worker except through the reconcile hook, answer a queued decision, clean up work, or mutate any other `state/` or `data/` file during that invocation. If the state gathered for the digest suggests an action, name it in its section and leave it to the normal lifecycle and configured authority. On a later board wake, this read-only invocation rule yields to "Handling a board wake" and its guarded authority for captain-selected dispatches and merges. diff --git a/.agents/skills/bearings/assets/board-template.html b/.agents/skills/bearings/assets/board-template.html index 08a63c15db4..adff4203a34 100644 --- a/.agents/skills/bearings/assets/board-template.html +++ b/.agents/skills/bearings/assets/board-template.html @@ -435,6 +435,12 @@ return n; } function badge(tone, text) { return el("span", "fm-badge fm-badge--" + tone, text); } + /* Warnings ride the charted feed for layout only; they are alarms, not work, + so every count of queued work excludes them. */ + function isWarning(t) { return t && t.kind === "warning"; } + function chartedQueued(rows) { return (rows || []).filter(function (t) { return !isWarning(t); }); } + var chartedMoreQueued = data.charted_more || 0; + var chartedMoreWarnings = data.charted_warning_more || 0; function utf8ByteLength(text) { return new TextEncoder().encode(text).length; } var CHECK_SVG = ''; @@ -446,7 +452,7 @@ { n: callTotal, l: "need you", call: true }, { n: data.underway.length, l: "underway" }, { n: data.landed.length, l: "landed recently" }, - { n: data.charted.length + (data.charted_more || 0), l: "charted next" } + { n: chartedQueued(data.charted).length + chartedMoreQueued, l: "charted next" } ]; var strip = document.getElementById("bb-stats"); stats.forEach(function (s) { @@ -651,10 +657,12 @@ barBtn.disabled = !n; } - if (!data.charted.length) ch.appendChild(el("div", "bb-empty", "Nothing is queued.")); + if (!chartedQueued(data.charted).length && !chartedMoreQueued) { + ch.appendChild(el("div", "bb-empty", "Nothing is queued.")); + } data.charted.forEach(function (t) { var row = el("div", "bb-row"); - if (t.dispatchable) { + if (t.dispatchable && !isWarning(t)) { anyPickable = true; var pick = document.createElement("input"); pick.type = "checkbox"; pick.className = "bb-pick"; pick.value = t.id; @@ -677,14 +685,19 @@ var chSub = t.repo || t.id; main.appendChild(el("div", "bb-row__sub", t.reason ? t.reason + " · " + chSub : chSub)); row.appendChild(main); - if (t.reason) row.appendChild(badge("warn", "waiting")); + if (isWarning(t)) row.appendChild(badge("danger", "needs repair")); + else if (t.reason) row.appendChild(badge("warn", "waiting")); ch.appendChild(row); }); - var chartedTotal = data.charted.length + (data.charted_more || 0); + var chartedShown = chartedQueued(data.charted).length; + var chartedTotal = chartedShown + chartedMoreQueued; document.getElementById("bb-charted-sub").textContent = - data.charted_more ? "showing " + data.charted.length + " of " + chartedTotal : ""; - if (data.charted_more) { - ch.appendChild(el("span", "bb-morechip", "+" + data.charted_more + " more queued - ask firstmate for the full chart")); + chartedMoreQueued ? "showing " + chartedShown + " of " + chartedTotal : ""; + if (chartedMoreQueued) { + ch.appendChild(el("span", "bb-morechip", "+" + chartedMoreQueued + " more queued - ask firstmate for the full chart")); + } + if (chartedMoreWarnings) { + ch.appendChild(el("span", "bb-morechip", "+" + chartedMoreWarnings + " more repair warning" + (chartedMoreWarnings === 1 ? "" : "s") + " - ask firstmate for the full chart")); } if (anyPickable) { diff --git a/.agents/skills/bootstrap-diagnostics/SKILL.md b/.agents/skills/bootstrap-diagnostics/SKILL.md index f6c7b18ad14..f21cdbc7ad0 100644 --- a/.agents/skills/bootstrap-diagnostics/SKILL.md +++ b/.agents/skills/bootstrap-diagnostics/SKILL.md @@ -18,7 +18,7 @@ When any diagnostic needs captain attention, report the plain consequence and re - `MISSING: (install: )` - list the missing tools to the captain with a one-line purpose each plus the printed install commands, wait for consent (one approval may cover the list), then run `bin/fm-bootstrap.sh install `. For `treehouse`, this also covers an installed version whose `treehouse get` lacks `--lease`; treat it as an upgrade request. - For `no-mistakes`, this also covers an installed version older than 1.31.2, because crewmate validation briefs delegate gate mechanics to no-mistakes' version-matched guidance. + For `no-mistakes`, this also covers an installed version older than 1.46.0, because this repo's PR gate requires structured pipeline attestation that older builds do not write. For any axi-family tool - `gh-axi`, `atelier-axi`, `tasks-axi`, `quota-axi` - an installed version below its floor is a plain upgrade request; [`bin/fm-bootstrap.sh`](../../../bin/fm-bootstrap.sh) owns the floor policy, and never argue the floor down to whatever the home happens to have installed. For `tasks-axi`, this additionally covers an installed build that fails the separate feature probe (`bin/fm-tasks-axi-lib.sh` owns the definition); `config/backlog-backend=manual` only suppresses the verbose `BOOTSTRAP_INFO: tasks-axi available` fact, not this missing-tool report. For `quota-axi`, bootstrap requires it because firstmate reads its current output directly before resolving every crew-dispatch profile array; without it, report the missing requirement and do not choose around an unexamined candidate. diff --git a/.pi/extensions/fm-branch-supervision.ts b/.pi/extensions/fm-branch-supervision.ts index 083e05d7445..093023fd00b 100644 --- a/.pi/extensions/fm-branch-supervision.ts +++ b/.pi/extensions/fm-branch-supervision.ts @@ -63,14 +63,16 @@ import { createAgentSession, createBashToolDefinition, DefaultResourceLoader, + DynamicBorder, getAgentDir, ModelRuntime, SessionManager, type AgentSession, type ExtensionAPI, + type ExtensionCommandContext, type ToolDefinition, } from "@earendil-works/pi-coding-agent"; -import { Box, Container, Text } from "@earendil-works/pi-tui"; +import { Box, Container, fuzzyFilter, Input, SelectList, Text } from "@earendil-works/pi-tui"; import { Type } from "typebox"; import { type CalmPresentationState, @@ -86,6 +88,13 @@ import { writeEligibleRowsSnapshot, type BranchDispatchOffer, } from "./lib/fm-branch-dispatch.ts"; +import { + BRANCH_PICKER_MAX_VISIBLE, + buildBranchModelItems, + filterBranchPickerItems, + FOLLOW_MAIN_VALUE, + type BranchPickerItem, +} from "./lib/fm-branch-model-picker.ts"; import { encodeFirstmateOperationalInput } from "./lib/fm-operational-input.ts"; const extensionFile = fileURLToPath(import.meta.url); @@ -118,6 +127,13 @@ const branchCacheKey = `fm-branch-${createHash("sha256").update(fmHome).digest(" const MIRROR_MESSAGE_CAP = 4000; const MERGE_NOTE_BOAT = "⛵"; +// Carried inside the captain note's own text because that text is the only +// part of a custom message Pi gives the model (see mergeIntoMain). +const CAPTAIN_OUTCOME_INSTRUCTION = + "This is a supervision outcome delivered automatically by the supervision branch. " + + "It was not typed by the captain and it is not your own earlier output. " + + "Relay only this outcome to the captain now, in one short message, in captain outcome language. " + + "Do not restate or repeat any earlier answer."; type MirrorItem = { tag: "captain" | "main"; text: string }; type MirrorCursor = { file: string; index: number }; type Verdict = "routine" | "captain"; @@ -540,6 +556,31 @@ export default function (pi: ExtensionAPI) { // Pi; a crash inside Pi's // own delivery window leaves the outcome durable in the store, where // main's fm_branch_outcomes tool still reads it on demand. + // + // Pi keeps only `content` when it converts a custom message for the model: + // customType, display, and details never reach the provider. A captain note + // therefore has to carry its own identity inside `content`, or main receives + // an unattributed user message written in main's own captain-facing voice + // and cannot tell an incoming outcome from its own earlier answer. When that + // happens main re-emits its previous answer instead of relaying the outcome, + // and the outcome is lost. The typed operational envelope is what makes the + // note self-describing; it stays invisible to the captain because the note + // is never rendered. + // + // Encoding shells out, so it can fail on a broken checkout. This file's + // failure direction applies: an outcome that cannot be typed is still + // delivered, carrying the same instruction as plain text, because an + // untyped outcome main can still read beats an outcome the captain never + // sees. + function captainOutcomeInput(task: string, summary: string): string { + const body = `${CAPTAIN_OUTCOME_INSTRUCTION}\n\n${task}: ${summary}`; + try { + return encodeFirstmateOperationalInput("branch-outcome", body); + } catch { + return body; + } + } + function mergeIntoMain( expectedGeneration: number, seq: string, @@ -550,7 +591,11 @@ export default function (pi: ExtensionAPI) { ): boolean { if (!actingAsOwner(expectedGeneration)) return false; if (verdict === "captain") { - const message = { customType: "fm-branch-merge", content: `${task}: ${summary}`, display: false }; + const message = { + customType: "fm-branch-merge", + content: captainOutcomeInput(task, summary), + display: false, + }; pi.sendMessage(message, { triggerTurn: true, deliverAs: "followUp" }); } else { const message = { customType: "fm-branch-merge", content: `${MERGE_NOTE_BOAT} ${task}: ${summary}`, display: !(task === "fleet" && silent) }; @@ -1001,9 +1046,12 @@ ${context.command} // conversation and exposes no hook an extension can use to open either // picker, so this is the smallest supported equivalent: Pi's own catalog // intersected with the isolated branch runtime, then Pi's own supported - // thinking levels for the model just chosen, both through Pi's own selector - // dialog and with no parallel Firstmate model or effort list. The effort - // step follows the model step because the model decides which levels exist. + // thinking levels for the model just chosen, with no parallel Firstmate + // model or effort list. The model step shows that catalog through the same + // bounded, searchable SelectList primitive Pi's own /model dialog scrolls + // (pickBranchModel below); the effort step's menu is a handful of levels + // and stays on Pi's generic selector dialog. The effort step follows the + // model step because the model decides which levels exist. pi.registerCommand?.("supervision-model", { description: "Pick the model and reasoning effort Firstmate's Pi supervision branch uses, or follow main's.", handler: async (_args, ctx) => { @@ -1025,14 +1073,18 @@ ${context.command} ); return; } - const picked = await ctx.ui.select(`Supervision branch model (now: ${current})`, [followMain, ...available]); + const picked = await pickBranchModel( + ctx, + `Supervision branch model (now: ${current})`, + buildBranchModelItems(followMain, available, pin ? `${pin.provider}/${pin.modelId}` : null), + ); if (picked === undefined) return; // cancelled: the current choice stands // Whatever the model step resolves is also the model the effort step // builds its menu from, so it is captured here rather than resolved a // second time through another isolated runtime. let branchModel: BranchModel | undefined; try { - if (picked === followMain) { + if (picked === FOLLOW_MAIN_VALUE) { clearPinFile(modelPinFile); } else { const separator = picked.indexOf("/"); @@ -1052,7 +1104,7 @@ ${context.command} // The model choice is persisted; report it exactly, then run the effort // step on the model the branch will actually use. let modelReport: { message: string; warning: boolean }; - if (picked !== followMain) { + if (picked !== FOLLOW_MAIN_VALUE) { modelReport = { message: `Supervision branch model: ${picked}.`, warning: false }; } else { // Clearing the pin only follows main if main's model can actually be @@ -1099,6 +1151,83 @@ ${context.command} }, }); + // Step one of /supervision-model's dialog. Pi's generic extension selector + // renders every option at once with no search box, so a real eligible + // catalog ran off the top of the terminal; this shows the same rows through + // Pi's own SelectList - the bounded, scrolling primitive behind Pi's /model + // picker - with Pi's own Input and fuzzy filter above it for search. + // Pi's ModelSelectorComponent is deliberately NOT reused: its own selection + // handler writes the captain's default model through Pi's settings manager, + // which would move main's conversation as a side effect of pinning the + // branch, and it has no room for the "follow main" row or for Firstmate's + // branch-runtime eligibility filter. Ordering and filtering live in + // lib/fm-branch-model-picker.ts; everything here is Pi's own rendering. + // Returns the chosen item's value, or undefined when the captain cancels. + // Non-TUI modes have no custom component surface, so they keep Pi's generic + // selector: overflow is a terminal-rendering problem those modes do not have. + async function pickBranchModel( + ctx: ExtensionCommandContext, + title: string, + items: BranchPickerItem[], + ): Promise { + if (ctx.mode !== "tui" || typeof ctx.ui.custom !== "function") { + const picked = await ctx.ui.select( + title, + items.map((item) => item.label), + ); + if (picked === undefined) return undefined; + return items.find((item) => item.label === picked)?.value; + } + const picked = await ctx.ui.custom((tui, theme, keybindings, done) => { + const accent = (text: string) => theme.fg("accent", text); + const muted = (text: string) => theme.fg("muted", text); + const container = new Container(); + container.addChild(new DynamicBorder(accent)); + container.addChild(new Text(accent(theme.bold(title)), 1, 0)); + const search = new Input(); + search.focused = true; + container.addChild(search); + const listContainer = new Container(); + container.addChild(listContainer); + container.addChild(new Text(muted("type to search - up/down navigate - enter select - esc cancel"), 1, 0)); + container.addChild(new DynamicBorder(accent)); + + // SelectList takes its rows at construction, so a new query builds a new + // list into the same container rather than mutating the old one. + let list = buildList(""); + function buildList(query: string): SelectList { + const rebuilt = new SelectList(filterBranchPickerItems(items, query, fuzzyFilter), BRANCH_PICKER_MAX_VISIBLE, { + selectedPrefix: accent, + selectedText: accent, + description: muted, + scrollInfo: muted, + noMatch: muted, + }); + rebuilt.onSelect = (item) => done(item.value); + rebuilt.onCancel = () => done(null); + listContainer.clear(); + listContainer.addChild(rebuilt); + return rebuilt; + } + + const navigationKeys = ["tui.select.up", "tui.select.down", "tui.select.confirm", "tui.select.cancel"] as const; + return { + render: (width: number) => container.render(width), + invalidate: () => container.invalidate(), + handleInput: (data: string) => { + if (navigationKeys.some((key) => keybindings.matches(data, key))) { + list.handleInput(data); + } else { + search.handleInput(data); + list = buildList(search.getValue()); + } + tui.requestRender(); + }, + }; + }); + return picked === null ? undefined : picked; + } + // Step two of /supervision-model, shown after the model pick and driven by // Pi's own supported-level list for the model the branch will now use, so // the menu is the one Pi's own thinking selector would show and keeps no diff --git a/.pi/extensions/lib/fm-branch-model-picker.ts b/.pi/extensions/lib/fm-branch-model-picker.ts new file mode 100644 index 00000000000..9be0f66f9f4 --- /dev/null +++ b/.pi/extensions/lib/fm-branch-model-picker.ts @@ -0,0 +1,77 @@ +// Ordering and filtering for /supervision-model's bounded, searchable model +// picker. docs/configuration.md owns its operator-facing behavior. +// +// This file holds only the choices Firstmate owns - which entries exist, in +// which order, and which survive a search query - so they stay testable +// without a terminal. The picker's rendering, scrolling, key handling, and +// branch-only component-choice rationale live beside pickBranchModel in +// fm-branch-supervision.ts. + +/** One row of the supervision-branch picker. */ +export interface BranchPickerItem { + /** Stable identity of the choice, used to resolve the captain's pick. */ + value: string; + /** What the row shows, and what a search query is matched against. */ + label: string; + /** Optional trailing note, such as marking the current choice. */ + description?: string; +} + +/** Signature of Pi's own `fuzzyFilter`, injected so this file stays UI-free. */ +export type BranchPickerFuzzyFilter = (items: T[], query: string, getText: (item: T) => string) => T[]; + +/** + * Rows the picker shows at once. Pi's own model selector shows ten, and the + * bound is what keeps a long catalog scrolling inside the dialog instead of + * overflowing the terminal. + */ +export const BRANCH_PICKER_MAX_VISIBLE = 10; + +/** The stable identity of the "follow main" row, which is always first. */ +export const FOLLOW_MAIN_VALUE = "\0follow-main"; + +/** + * Builds the picker's rows: "follow main" first, then the eligible models in + * the order the caller resolved them. The current choice is marked so the + * captain can see what is pinned without leaving the dialog. + */ +export function buildBranchModelItems( + followMainLabel: string, + modelLabels: readonly string[], + currentPin: string | null, +): BranchPickerItem[] { + const followMain: BranchPickerItem = { + value: FOLLOW_MAIN_VALUE, + label: followMainLabel, + ...(currentPin === null ? { description: "current" } : {}), + }; + return [ + followMain, + ...modelLabels.map((label) => ({ + value: label, + label, + ...(currentPin !== null && label === currentPin ? { description: "current" } : {}), + })), + ]; +} + +/** + * Applies a search query while keeping "follow main" first. Pi's fuzzy filter + * ranks by match quality, which would otherwise be free to sort the "follow + * main" row below a model, so it is filtered separately and prepended + * whenever it still matches. An empty query keeps the built order. + */ +export function filterBranchPickerItems( + items: readonly BranchPickerItem[], + query: string, + fuzzy: BranchPickerFuzzyFilter, +): BranchPickerItem[] { + const trimmed = query.trim(); + if (trimmed === "") return [...items]; + const followMain = items.find((item) => item.value === FOLLOW_MAIN_VALUE); + const rest = items.filter((item) => item.value !== FOLLOW_MAIN_VALUE); + const matched = fuzzy([...rest], trimmed, (item) => item.label); + if (!followMain) return matched; + const followMainMatches = fuzzy([followMain], trimmed, (item) => item.label).length > 0; + return followMainMatches ? [followMain, ...matched] : matched; +} diff --git a/.pi/extensions/lib/fm-operational-input.ts b/.pi/extensions/lib/fm-operational-input.ts index 338312d3f64..ea071ab8720 100644 --- a/.pi/extensions/lib/fm-operational-input.ts +++ b/.pi/extensions/lib/fm-operational-input.ts @@ -13,6 +13,7 @@ export const FIRSTMATE_CURRENT_OPERATIONAL_KINDS = [ "away-supervisor", "from-firstmate", "launch-brief", + "branch-outcome", ] as const; export type FirstmateCurrentOperationalKind = diff --git a/AGENTS.md b/AGENTS.md index dbf81004dcc..7578a5a505a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -97,7 +97,8 @@ state/ runtime records and signals; gitignored .kimi-turnend-token firstmate-owned Kimi hook registry token for the task; removed by teardown .muse-session muse busy-source binding (sessions root plus task worktree) written by fm-spawn; removed by teardown .cursor-session cursor busy-source binding (projects root, task worktree, prior conversations) written by fm-spawn; removed by teardown - .inbox/ durable steering inbox: sequenced firstmate instruction records the worker acknowledges by moving them into its handled/ subdirectory; written by fm-send, re-rung and escalated by the watcher, removed by teardown (bin/fm-task-inbox-lib.sh) + .reconcile-nudged epoch second of the last inventory-reconcile nudge sent to this secondmate; bin/fm-secondmate-reconcile.sh owns its per-home cooldown window + .inbox/ durable steering inbox: sequenced firstmate instruction records the worker acknowledges by moving them into its handled/ subdirectory; written by fm-send, with ordinary records re-rung and escalated by the watcher while explicit fire-and-forget records are excluded from that ladder, and removed by teardown (bin/fm-task-inbox-lib.sh) .meta task metadata; each producer script's header owns its exact fields and mutation contract, with docs/configuration.md routing operator-facing backend and trace-context details .herdr-presentation quarantinable attempt and restart-binding journal for Herdr's optional visual projection; never task or endpoint authority; see docs/herdr-backend.md "Presentation spaces" .check.sh authenticated slow poll; the watcher dispatches validated PR data and the byte-identified Relay shim through trusted repository scripts, runs registered custom checks from hash-validated private snapshots, and rejects every other state check without execution @@ -105,7 +106,7 @@ state/ runtime records and signals; gitignored .pr-poll private validated data sidecar for the byte-static PR merge poll .pr-poll-registration private transactional provenance record binding the task, canonical metadata identity, sidecar, and static poll publication .pr-poll-retirement private identity-bound crash-recovery receipt for one exact validated merged result; removed after its poll artifacts retire - .pr-poll-merge-notified canonical PR identity of the last merge notification delivered for this task; bin/fm-pr-lib.sh owns duplicate suppression and replacement + .pr-poll-merge-notified canonical PR identity of the last merge outcome delivered for this task; bin/fm-pr-lib.sh owns the marker format and identity mechanics, while bin/fm-merge-outcome-lib.sh owns locked publication, duplicate suppression, and replacement branch-outcomes.jsonl .branch-outcomes-cursor Pi supervision-branch durable outcome store and its read cursor; bin/fm-branch-outcome.sh owns the format branch-session/ .branch-session .branch-mirror-cursor the branch's persistent conversation, its pointer, and the dialog-mirror cursor; extension-owned (docs/pi-supervision-branch.md) .branch-eligible-rows .branch-eligible-owner .main-eligible-rows per-actor wake-row claims and branch-owner evidence; docs/watcher-continuity.md owns the acknowledgement contract diff --git a/README.md b/README.md index cf5928d79e4..c1f9794195f 100644 --- a/README.md +++ b/README.md @@ -109,7 +109,7 @@ FM_PI_HARNESS=pi-signed pi-signed For Grok, `--trust` is needed once per clone so project hooks and the turn-end guard load; `/hooks-trust` inside Grok works too. For Pi, approve the project trust prompt once per clone on first launch so the tracked `.pi/extensions/*.ts` files auto-load. Pi's `/calm` toggle hides supported transcript chrome, including canonically classified Firstmate operational user rows, and uses a Calm-only animated working boat during active runs while preserving all model context and session data. -The hidden operational inputs remain ordinary user-role messages with unchanged delivery, ordering, authority, persistence, and exports. +Those Calm-hidden operational inputs remain ordinary user-role messages with unchanged delivery, ordering, authority, persistence, and exports. The preference persists for the effective Firstmate home, and toggling it off restores ordinary rendering. [Calm's current behavior and supported limits](docs/calm.md) are separate from its [version-scoped maintainer evidence](docs/calm-mode-feasibility.md). Pi's `/supervision-model` command pins a cheaper model and a shallower reasoning effort for the supervision branch alone, from the eligible models and thinking levels Pi itself reports, and with no pin the branch normally follows your own conversation's model and effort; see the [configuration schema](docs/configuration.md#pi-supervision-branch-model-and-effort-configsupervision-branch-model-configsupervision-branch-effort). diff --git a/bin/fm-bearings-board.sh b/bin/fm-bearings-board.sh index 907c0720dc7..1a2145a9b4a 100755 --- a/bin/fm-bearings-board.sh +++ b/bin/fm-bearings-board.sh @@ -113,7 +113,9 @@ validate_payload() { # def charted_item: type == "object" and repo_marker and (.id | slug(128)) and (.title | nonempty_string) and (.reason | type == "string") - and (.dispatchable | type == "boolean"); + and (.dispatchable | type == "boolean") + and ((has("kind") | not) or (.kind == "queued" or .kind == "warning")) + and (if .kind == "warning" then .dispatchable == false else true end); type == "object" and (.schema == $schema) and (.home | nonempty_string) @@ -125,6 +127,8 @@ validate_payload() { # and (.charted | type == "array") and ((has("charted_more") | not) or ((.charted_more | type == "number") and (.charted_more >= 0) and (.charted_more | floor == .))) + and ((has("charted_warning_more") | not) + or ((.charted_warning_more | type == "number") and (.charted_warning_more >= 0) and (.charted_warning_more | floor == .))) and ([.captains_call[] | call_item] | all) and ([.underway[] | underway_item] | all) and ([.landed[] | landed_item] | all) diff --git a/bin/fm-bearings-snapshot.sh b/bin/fm-bearings-snapshot.sh index c64f4226dbb..5537142f1db 100755 --- a/bin/fm-bearings-snapshot.sh +++ b/bin/fm-bearings-snapshot.sh @@ -113,6 +113,7 @@ Default is LOCAL-ONLY (no network); --include-prs is the only path that fetches. Default fields: schema, home, generated, prs, in_flight{id,kind,state,doing}, secondmates{id,state,doing,provenance,freshness,age_seconds,contradiction,reason}, + secondmate_reconcile{id,spawn_gen,host,kind,ids}, decisions_open{id,key,verb,summary,owner}, landed{id,what,artifact,owner}, gates{id,title,blocked_by,reason,owner}, reports{id,path}, recorded_prs{id,url}, unhealthy_endpoints{...} (only when non-empty), omitted{surface,reveal}. @@ -451,6 +452,9 @@ MODEL=$(printf '%s' "$SNAP" | jq \ prs: $prs, in_flight: (if $all_in_flight == 1 then $in_flight_all else $in_flight_all[:$in_flight_n] end), secondmates: (if $all_secondmates == 1 then $secondmates_all else $secondmates_all[:$secondmates_n] end), + secondmate_reconcile: [ (.secondmate_current.records // [])[] + | select(.reconcile_inventory != null) + | {id, spawn_gen:(.spawn_gen // null), host:(.host // null), kind:(.reconcile_inventory.kind // null), ids:((.reconcile_inventory.ids // []) | map(select(type == "string")) | sort)} ], decisions_open: (if $all_decisions == 1 then $decisions_all else $decisions_all[:$decisions_n] end), landed: ($done | map({id, what:(.title | trunc(70)), artifact:(.pr_url // .report_path // .local_note // "-"),owner:.home_id})), diff --git a/bin/fm-bootstrap.sh b/bin/fm-bootstrap.sh index 9829149dc96..55d32333901 100755 --- a/bin/fm-bootstrap.sh +++ b/bin/fm-bootstrap.sh @@ -51,7 +51,7 @@ # treehouse is also MISSING when its installed version lacks # "treehouse get --lease" support. # no-mistakes is also MISSING when its installed version is older than -# 1.31.2. +# 1.46.0 (structured pipeline attestation floor; see CONTRIBUTING.md). # The AXI-family floor policy is owned beside GH_AXI_MIN and # ATELIER_AXI_MIN below; the per-tool owners point there. An installed # build below its floor reports MISSING like no-mistakes, so the operator @@ -872,7 +872,7 @@ if ! BACKEND_TOOLS=$(fm_backend_required_tools "$BACKEND"); then BACKEND_TOOLS="" fi TOOLS="$BACKEND_TOOLS $COMMON_TOOLS" -NO_MISTAKES_MIN=1.31.2 +NO_MISTAKES_MIN=1.46.0 # AXI-FAMILY FLOOR POLICY. Every axi-family floor is the CURRENT LATEST published # version of that tool, captain-bumped periodically to keep the whole fleet on the # newest axi tools. It is NOT the minimum feature-introduced version. These floors diff --git a/bin/fm-brief.sh b/bin/fm-brief.sh index 4897271c04d..f31bfe67bdf 100755 --- a/bin/fm-brief.sh +++ b/bin/fm-brief.sh @@ -260,7 +260,8 @@ Report only true captain-relevant outcomes or a declared external wait by append \`echo "{state}: {one short line}" >> $STATUS_FILE\` States: working, needs-decision, blocked, $PAUSED_VERB, done, failed. Use \`$PAUSED_VERB: {why}\` (distinct from \`blocked:\`) only when your domain is deliberately idling on a known external wait you expect to clear on its own; use \`blocked:\` when you are stuck and need firstmate to act. -Use this only for material phase changes, a captain decision, a real blocker, a failure, or work ready for review. +Use this only for material phase changes, a captain decision, a real blocker, a failure, work ready for review, or work you landed. +Work you landed includes a merge you performed yourself under standing merge authority and one the captain merged on the forge: under that authority nothing is ever \"ready for review\", so a landed merge that goes unreported reaches the captain as silence. This is also how you return the answer to a marked from-firstmate request above. A marked request requires one correlated answer after the work; it does not require a separate receipt or start acknowledgement. Never append \`working:\` merely to acknowledge receipt or announce that a marked request has started. diff --git a/bin/fm-fleet-snapshot.sh b/bin/fm-fleet-snapshot.sh index c257d110be5..4daa3b10626 100755 --- a/bin/fm-fleet-snapshot.sh +++ b/bin/fm-fleet-snapshot.sh @@ -55,12 +55,17 @@ # failure reasons. Parent status and bounded terminal evidence are historical, # untrusted supplements only and never override readable structured-home facts. # Each structured-home record carries active_children, decisions_open, holds, -# queued, landed, endpoints, counts, and omitted. Actionable captain holds +# queued, landed, endpoints, counts, and omitted. Every successfully sampled +# home also carries reconcile_inventory independently of projection trust. +# Actionable captain holds # appear in decisions_open; blocked captain holds remain queued with metadata. # secondmate_landed: {records[],truncated[],unreadable[],partial[]} - the # compatibility landed-work roll-up derived from secondmate_current. Readable -# structured homes with an unknown current classification are partial, not -# unreadable, and retain independently trustworthy structured surfaces. +# structured homes are partial, not unreadable, when an unavailable child state +# or a backlog-vs-metadata inventory mismatch makes their summary incomplete; +# they retain independently trustworthy structured surfaces. An inventory +# mismatch also keeps the home's own current classification, which only an +# unavailable child state or an untrustworthy backlog collapses to unknown. # secondmate_guidance: return-channel action note for renderers and bearings. # # Compatibility: JSON is the primary machine-readable surface. @@ -425,7 +430,7 @@ backlog_json() { # [] - defaults to this home's $BACKLOG } task_json_lines() { - local meta id kind harness mode yolo project worktree home projects backend target status_log report_path + local meta id kind harness mode yolo project worktree home projects spawn_gen backend target status_log report_path local remote_host remote_root remote_state remote_rc remote_home_present local pr pr_source event_json current_json endpoint_exists agent_alive meta_json status_json report_json worktree_json home_json local last_event_raw current_state current_source pending_decision blocked_event report_present=0 pr_from_status @@ -443,6 +448,7 @@ task_json_lines() { worktree=$(meta_value "$meta" worktree) home=$(meta_value "$meta" home) projects=$(meta_value "$meta" projects) + spawn_gen=$(meta_value "$meta" spawn_gen) remote_host=$(meta_value "$meta" remote_host) remote_root=$(meta_value "$meta" remote_root) remote_home_present=null @@ -562,6 +568,7 @@ task_json_lines() { --arg worktree "$worktree" \ --arg home "$home" \ --arg projects "$projects" \ + --arg spawn_gen "$spawn_gen" \ --arg backend "$backend" \ --arg target "$target" \ --arg remote_host "$remote_host" \ @@ -589,6 +596,7 @@ task_json_lines() { mode:($mode // ""), yolo:($yolo // ""), project:($project // ""), + spawn_gen:($spawn_gen | if . == "" then null else . end), backend:$backend, remote:(if $remote_host == "" then null else {host:$remote_host,root:$remote_root} end), paths:{ @@ -760,7 +768,11 @@ secondmate_home_summary_json() { # | (if ($strict_invalidities | length) > 0 then $strict_invalidities[0] | del(.reason) elif ($unknown_children | length) > 0 then {kind:"child_current_unavailable",ids:($unknown_children | map(.id))} else {kind:null,ids:[]} end) as $invalidity - | (if $valid | not then "unknown" + | (if ($valid | not) + and (($unknown_children | length) > 0 + or (["orphan_in_flight","unowned_current","terminal_in_flight"] + | index($invalidity.kind) | not)) + then "unknown" elif any($decisions_all[]; .verb == "needs-decision" or .verb == "captain-hold") then "captain_decision" elif ($active_all | length) > 0 then "active_child_work" elif ($holds_all | length) > 0 then "externally_held" @@ -1140,8 +1152,8 @@ parent_evidence_reconciliation_json() { # local tasks=$1 registry union rows total_registered total shown truncated - local row id home host remote registered registry_error task status_file event_raw event_note event_epoch event_age - local activity_scan activities decisions reconciliation provenance freshness reason summary summary_rc summary_bytes summary_valid summary_reason summary_invalidity state current_reason terminal terminal_contradiction contradiction + local row id home host remote registered registry_error task sampled_spawn_gen status_file event_raw event_note event_epoch event_age + local activity_scan activities decisions reconciliation provenance freshness reason summary summary_rc summary_bytes summary_sampled summary_valid summary_reason summary_invalidity state current_reason terminal terminal_contradiction contradiction local records='[]' seen_homes='' registry=$(registry_secondmates_json) || return 1 union=$(jq -n --argjson registry "$registry" --argjson tasks "$tasks" ' @@ -1174,6 +1186,7 @@ secondmate_current_json() { # registered=$(printf '%s' "$row" | jq -r '.registered') registry_error=$(printf '%s' "$row" | jq -r '.registry_error // ""') task=$(printf '%s' "$row" | jq -c '.parent_task // {}') + sampled_spawn_gen=$(printf '%s' "$task" | jq -r '.spawn_gen // ""') status_file=$(printf '%s' "$task" | jq -r '.paths.status_log.path // ""') event_raw=$(printf '%s' "$task" | jq -r '.paths.status_log.last_event.raw // ""') event_note=$(printf '%s' "$task" | jq -r '.paths.status_log.last_event.note // ""') @@ -1189,6 +1202,7 @@ secondmate_current_json() { # reason=$registry_error summary='{}' + summary_sampled=false summary_valid=false if [ -z "$reason" ] && [ -z "$home" ]; then reason="no recorded secondmate home"; fi if [ -z "$reason" ]; then @@ -1237,6 +1251,7 @@ secondmate_current_json() { # summary_rc=$? fi if [ "$summary_rc" -ne 0 ]; then + summary='{}' [ "$summary_rc" -eq 124 ] && reason="structured home snapshot timed out" || reason="structured home snapshot failed" else summary_bytes=$(printf '%s' "$summary" | LC_ALL=C wc -c | tr -d ' ') @@ -1254,13 +1269,15 @@ secondmate_current_json() { # ' >/dev/null 2>&1; then reason="structured home snapshot was malformed or stale" else + summary_sampled=true summary_valid=$(printf '%s' "$summary" | jq -r '.valid') if [ "$summary_valid" != true ]; then summary_reason=$(printf '%s' "$summary" | jq -r '.reason // "unknown reason"') summary_invalidity=$(printf '%s' "$summary" | jq -r '.invalidity.kind // "unknown"') - if [ "$summary_invalidity" != child_current_unavailable ]; then - reason="structured home state invalid: $summary_reason" - fi + case "$summary_invalidity" in + child_current_unavailable|orphan_in_flight|unowned_current|terminal_in_flight) : ;; + *) reason="structured home state invalid: $summary_reason" ;; + esac fi fi fi @@ -1285,12 +1302,15 @@ secondmate_current_json() { # if printf '%s' "$terminal" | jq -e '.contradiction == true' >/dev/null; then contradiction=true; fi record=$(jq -n \ --arg id "$id" --arg home "$home" --arg host "$host" --argjson remote "$remote" --arg state "$state" --arg current_reason "$current_reason" --arg observed "$SNAPSHOT_NOW" \ + --arg spawn_gen "$sampled_spawn_gen" \ --argjson registered "$registered" --argjson summary "$summary" --argjson summary_valid "$summary_valid" --argjson decisions "$decisions" \ --argjson activities "$activities" --argjson activity_scan "$activity_scan" \ --argjson reconciliation "$reconciliation" --argjson terminal "$terminal" --argjson contradiction "$contradiction" \ --arg event_raw "$event_raw" --arg event_note "$event_note" --argjson event_age "$event_age" ' {id:$id,home:$home,host:($host | if . == "" then null else . end),remote:$remote,registered:$registered, + spawn_gen:($spawn_gen | if . == "" then null else . end), current:{state:$state,reason:($current_reason | if . == "" then null else . end)},invalidity:$summary.invalidity, + reconcile_inventory:$summary.invalidity, provenance:{selected:"structured-home",structured_home:$home,summary_valid:$summary_valid, trust:(if $summary_valid then "complete" else "partial-structured" end),parent_event_role:"historical-only"}, freshness:{status:"fresh",observed_at:$observed,age_seconds:0}, @@ -1315,11 +1335,14 @@ secondmate_current_json() { # fi record=$(jq -n \ --arg id "$id" --arg home "$home" --arg host "$host" --argjson remote "$remote" --arg reason "$reason" --arg observed "$SNAPSHOT_NOW" \ + --arg spawn_gen "$sampled_spawn_gen" \ --arg provenance "$provenance" --arg freshness "$freshness" --arg event_raw "$event_raw" --arg event_note "$event_note" \ --argjson registered "$registered" --argjson event_age "$event_age" --argjson activities "$activities" --argjson activity_scan "$activity_scan" \ - --argjson decisions "$decisions" --argjson terminal "$terminal" ' + --argjson decisions "$decisions" --argjson terminal "$terminal" --argjson summary "$summary" --argjson summary_sampled "$summary_sampled" ' {id:$id,home:($home | if . == "" then null else . end),host:($host | if . == "" then null else . end),remote:$remote,registered:$registered, + spawn_gen:($spawn_gen | if . == "" then null else . end), current:{state:"unknown",reason:$reason},invalidity:null, + reconcile_inventory:(if $summary_sampled then $summary.invalidity else null end), provenance:{selected:$provenance,structured_home:($home | if . == "" then null else . end),parent_event_role:"fallback-only-not-current"}, freshness:{status:$freshness,observed_at:$observed,age_seconds:$event_age}, active_children:[],decisions_open:[],holds:[],queued:[],landed:[],endpoints:[],counts:{active_children:0,decisions_open:0,holds:0,queued:0,landed:0,endpoints:0},omitted:[], @@ -1353,7 +1376,7 @@ secondmate_landed_from_current_json() { # | select(.current.state == "unknown" and .provenance.selected != "structured-home") | .home // ("<" + .id + ": unavailable>")], partial:[ $current.records[] - | select(.current.state == "unknown" and .provenance.selected == "structured-home") + | select(.provenance.selected == "structured-home" and .provenance.trust == "partial-structured") | .home // ("<" + .id + ": partial>")]} | .records |= sort_by([(.completion.date // ""), .id]) | .records |= reverse' } diff --git a/bin/fm-inactive-reconcile.sh b/bin/fm-inactive-reconcile.sh index 30d451db5ae..0706282264f 100755 --- a/bin/fm-inactive-reconcile.sh +++ b/bin/fm-inactive-reconcile.sh @@ -366,6 +366,13 @@ reconcile_direct_child_locked() { # [key=]: " shape the charter contract defines; +# - a main home reports to the captain through the durable wake queue. +# A poll observed in a secondmate home also receives a local durable wake after +# the upward write, so the mate can handle its own poll observation. +# No new state file and no new transport are involved. +# +# Normal operation deduplicates the task's latest canonical PR identity through +# the merge-notification marker owned by bin/fm-pr-lib.sh. Main-home wake keys +# also include that PR identity so distinct PRs for a reused task remain +# distinct in queue presentation. The outcome is published before the marker +# is committed, so a failed commit stays eligible for at-least-once retry and +# may rarely duplicate rather than leave a merge silent. +# +# Sourced by bin/fm-pr-merge.sh, bin/fm-watch.sh, and tests. No side effects on +# source beyond its sourced libraries. + +_FM_MERGE_OUTCOME_LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=bin/fm-pr-lib.sh +. "$_FM_MERGE_OUTCOME_LIB_DIR/fm-pr-lib.sh" +# shellcheck source=bin/fm-secondmate-parent-lib.sh +. "$_FM_MERGE_OUTCOME_LIB_DIR/fm-secondmate-parent-lib.sh" + +# The secondmate identity of the home reporting, or non-zero when this home is +# a main home (1) or carries an unusable identity marker (2). Mirrors +# bin/fm-inactive-reconcile.sh's home_secondmate_id, which owns the same +# marker's contract. +fm_merge_outcome_home_id() { # + local home=$1 marker id + marker="$home/.fm-secondmate-home" + if [ ! -e "$marker" ] && [ ! -L "$marker" ]; then + return 1 + fi + [ -f "$marker" ] && [ ! -L "$marker" ] || return 2 + [ "$(wc -c < "$marker")" -eq "$(LC_ALL=C tr -d '\0' < "$marker" | wc -c)" ] || return 2 + id=$(cat "$marker" 2>/dev/null) || return 2 + fm_pr_task_id_valid "$id" || return 2 + printf '%s\n' "$id" +} + +# Append to unless that exact line is already there, so a repeat +# report of the same merge cannot duplicate it. +fm_merge_outcome_append_once() { # + local path=$1 line=$2 + [ ! -L "$path" ] || return 1 + mkdir -p "$(dirname "$path")" || return 1 + if grep -Fqx -- "$line" "$path" 2>/dev/null; then + return 0 + fi + printf '%s\n' "$line" >> "$path" +} + +# shellcheck disable=SC2034 # Public result consumed by sourcing callers. +FM_MERGE_OUTCOME_ALREADY_RECORDED=false + +# fm_merge_outcome_report +# +# says who observed the merge, because that decides whether the +# existing poll path also needs a local wake: +# self - this home performed the merge. +# poll - this home's merge poll detected the merge, so the canonical outcome +# also wakes this home after any upward hop needed by a secondmate. +# +# Returns 0 when the outcome is recorded (or already was), 2 on an invalid +# request, 3 when this home's own role or parent binding cannot be read well +# enough to say where the outcome belongs, and 1 on any other failure to +# record. A caller that has already merged must report a non-zero return rather +# than treat it as success: the merge landed and the record did not. +fm_merge_outcome_report() { # + local home=$1 state=$2 id=$3 url=$4 origin=$5 + local self='' self_rc=0 destination='' line lock status=0 + local provider host path number + # shellcheck disable=SC2034 # Sourced wake helpers consume these scoped globals. + local STATE FM_WAKE_QUEUE FM_WAKE_QUEUE_LOCK + FM_MERGE_OUTCOME_ALREADY_RECORDED=false + case "$origin" in self|poll) ;; *) return 2 ;; esac + fm_pr_task_id_valid "$id" || return 2 + fm_pr_url_parse "$url" || return 2 + provider=$FM_PR_PROVIDER + host=$FM_PR_HOST + path=$FM_PR_PATH + number=$FM_PR_NUMBER + [ -d "$state" ] && [ ! -L "$state" ] || return 1 + + if self=$(fm_merge_outcome_home_id "$home"); then + fm_secondmate_parent_record_parse "$home/.fm-secondmate-parent" || return 3 + case "$FM_SECONDMATE_PARENT_ROUTE" in + local) + [ -n "$FM_SECONDMATE_PARENT_HOME" ] || return 3 + destination="$FM_SECONDMATE_PARENT_HOME/state/$self.status" + ;; + remote) destination="$state/parent-replies.status" ;; + *) return 3 ;; + esac + line="done [key=merged-$id]: merged $id $FM_PR_URL" + else + self_rc=$? + [ "$self_rc" -eq 1 ] || return 3 + fi + + STATE=$state + # shellcheck source=bin/fm-wake-lib.sh + . "$_FM_MERGE_OUTCOME_LIB_DIR/fm-wake-lib.sh" + lock="$state/$id.pr-poll-merge-notified.lock" + fm_lock_acquire_wait "$lock" || return 1 + if fm_pr_poll_merge_already_notified "$state" "$id" \ + "$provider" "$host" "$path" "$number"; then + # shellcheck disable=SC2034 # Public result consumed by sourcing callers. + FM_MERGE_OUTCOME_ALREADY_RECORDED=true + fm_lock_release "$lock" + return 0 + fi + + if [ -n "$destination" ]; then + fm_merge_outcome_append_once "$destination" "$line" || status=1 + fi + if [ "$status" -eq 0 ] && { [ "$origin" = poll ] || [ -z "$destination" ]; }; then + fm_wake_append check "merged-$id-$FM_PR_URL" \ + "check: merge landed: $id $FM_PR_URL" || status=1 + fi + if [ "$status" -eq 0 ]; then + fm_pr_poll_merge_mark_notified "$state" "$id" \ + "$provider" "$host" "$path" "$number" || status=1 + fi + fm_lock_release "$lock" + return "$status" +} diff --git a/bin/fm-operational-input.sh b/bin/fm-operational-input.sh index 11d6a459d56..d12b406fa73 100755 --- a/bin/fm-operational-input.sh +++ b/bin/fm-operational-input.sh @@ -28,7 +28,7 @@ FM_OPERATIONAL_MARK=$'\xE2\x81\xA3' FM_OPERATIONAL_PREFIX="${FM_OPERATIONAL_MARK}FIRSTMATE_OP: " FM_OPERATIONAL_VERSION=v1 FM_OPERATIONAL_HEADER_PREFIX="${FM_OPERATIONAL_PREFIX}${FM_OPERATIONAL_VERSION} " -FM_OPERATIONAL_KINDS='session-start watcher turn-end-guard away-supervisor launch-brief' +FM_OPERATIONAL_KINDS='session-start watcher turn-end-guard away-supervisor launch-brief branch-outcome' # Compatibility name retained for the away-mode owner and its tests. # shellcheck disable=SC2034 # Public source-library variable used by callers. @@ -204,6 +204,7 @@ Usage: Current construction kinds: session-start watcher turn-end-guard away-supervisor from-firstmate launch-brief + branch-outcome The from-firstmate kind uses its established live-charter-compatible carrier. EOF diff --git a/bin/fm-pending-reply-lib.sh b/bin/fm-pending-reply-lib.sh index 8db770288a7..6c8118cba25 100755 --- a/bin/fm-pending-reply-lib.sh +++ b/bin/fm-pending-reply-lib.sh @@ -1,8 +1,8 @@ #!/usr/bin/env bash # fm-pending-reply-lib.sh - parent-owned secondmate missed-report guards. # -# When the main firstmate delivers a marked from-firstmate request to a -# secondmate, this library records a durable parent-owned pending-reply +# When the main firstmate delivers a reply-bearing marked from-firstmate request +# to a secondmate, this library records a durable parent-owned pending-reply # expectation BEFORE delivery, embeds a privacy-safe correlation id in the # outbound message, and later resolves that expectation only from a correlated # parent status line or status-pointed document - never from transport success, diff --git a/bin/fm-pr-lib.sh b/bin/fm-pr-lib.sh index 88077c60ec4..384343650ae 100755 --- a/bin/fm-pr-lib.sh +++ b/bin/fm-pr-lib.sh @@ -948,8 +948,9 @@ fm_pr_poll_retirement_recover_all() { # exception is the same poll re-registered after its merge was already # surfaced. Its retirement state is scoped to one registration, so this marker # carries the canonical PR identity across registrations for the task. Only a -# matching identity is a no-op; a different PR for the same task reaches main -# and replaces the marker when its first notification is published. +# matching identity is a no-op; a different PR for the same task reaches its +# role-routed supervision destination and replaces the marker when its first +# outcome is published. fm_pr_poll_merge_marker_matches() { # local marker=$1 device=$2 expected_provider=$3 expected_host=$4 expected_path=$5 expected_number=$6 local version provider host path number diff --git a/bin/fm-pr-merge.sh b/bin/fm-pr-merge.sh index 238c5d573c7..d35bc9f30fb 100755 --- a/bin/fm-pr-merge.sh +++ b/bin/fm-pr-merge.sh @@ -27,6 +27,13 @@ # Extra args must not include --repo or -R in any form, including a bundled # short-option cluster such as -yR, because the repository comes only from the # URL, nor --sha on GitLab because the head comes only from the live read. +# +# After the forge command, this script confirms the PR is actually merged before +# reporting it; an auto-merge-queued or unconfirmed request leaves the poll armed +# and records no landed outcome. bin/fm-merge-outcome-lib.sh owns a confirmed +# merge's destination, normal-case deduplication, and at-least-once recovery. +# A landed merge whose outcome cannot be written is reported loudly rather than +# misreported as a failed merge. # Usage: fm-pr-merge.sh [-- ] set -eu @@ -37,6 +44,8 @@ STATE="${FM_STATE_OVERRIDE:-$FM_HOME/state}" # shellcheck source=bin/fm-pr-lib.sh . "$SCRIPT_DIR/fm-pr-lib.sh" +# shellcheck source=bin/fm-merge-outcome-lib.sh +. "$SCRIPT_DIR/fm-merge-outcome-lib.sh" # Role partition: merging is MAIN-owned; the Pi supervision branch reports the # green PR and never merges (contract: bin/fm-lease-lib.sh; no-op in homes # without a branch actor). @@ -245,6 +254,42 @@ FIELDS FM_PR_MERGE_HEAD=$live_head } +github_confirm_merged() { + local output state + if ! output=$(gh-axi pr view "$PR_NUMBER" --repo "$PR_OWNER/$PR_REPO" 2>/dev/null); then + printf 'actionable: GitHub accepted the merge request for %s but its landed state could not be confirmed; the merge poll remains armed\n' \ + "$URL" >&2 + return 2 + fi + if ! state=$(printf '%s\n' "$output" | awk ' + $1 == "state:" { count++; value=$2 } + END { if (count == 1 && value != "") print value; else exit 1 } + '); then + printf 'actionable: GitHub accepted the merge request for %s but its landed state could not be confirmed; the merge poll remains armed\n' \ + "$URL" >&2 + return 2 + fi + [ "$state" = merged ] +} + +gitlab_confirm_merged() { + local json state + if ! json=$(GITLAB_HOST="$FM_PR_HOST" glab mr view "$PR_NUMBER" \ + -R "$PROJECT_URL" -F json 2>/dev/null) || [ -z "$json" ]; then + printf 'actionable: GitLab accepted the merge request for %s but its landed state could not be confirmed; the merge poll remains armed\n' \ + "$URL" >&2 + return 2 + fi + if ! state=$(printf '%s' "$json" | jq -r \ + 'if type == "object" and (.state | type == "string") then .state else error("invalid state") end' \ + 2>/dev/null); then + printf 'actionable: GitLab accepted the merge request for %s but its landed state could not be confirmed; the merge poll remains armed\n' \ + "$URL" >&2 + return 2 + fi + [ "$state" = merged ] +} + case "$PROVIDER" in github) merge_args=() @@ -252,6 +297,9 @@ case "$PROVIDER" in merge_args=(--squash) fi gh-axi pr merge "$PR_NUMBER" --repo "$PR_OWNER/$PR_REPO" "${merge_args[@]+"${merge_args[@]}"}" "$@" + github_confirm_rc=0 + github_confirm_merged || github_confirm_rc=$? + [ "$github_confirm_rc" -eq 0 ] || exit 0 ;; gitlab) gitlab_verify_mergeable || exit 1 @@ -261,9 +309,28 @@ case "$PROVIDER" in # the conditions above are what authorize the merge. GITLAB_HOST="$FM_PR_HOST" glab mr merge "$PR_NUMBER" -R "$PROJECT_URL" \ --sha "$FM_PR_MERGE_HEAD" --yes "$@" + gitlab_confirm_rc=0 + gitlab_confirm_merged || gitlab_confirm_rc=$? + [ "$gitlab_confirm_rc" -eq 0 ] || exit 0 ;; *) echo "error: invalid PR merge request" >&2 exit 2 ;; esac + +# Reached only after the forge confirmed the merge landed: set -e exits on a +# refused or failed merge above, and a queued forge merge exits without an +# outcome while its existing poll remains armed. +outcome_rc=0 +fm_merge_outcome_report "$FM_HOME" "$STATE" "$ID" "$URL" self || outcome_rc=$? +case "$outcome_rc" in + 0) ;; + 3) + printf 'actionable: merged %s but could not report it upward: this home has no readable secondmate identity or parent binding (.fm-secondmate-home, .fm-secondmate-parent)\n' \ + "$URL" >&2 + ;; + *) + printf 'actionable: merged %s but could not record the outcome for supervision\n' "$URL" >&2 + ;; +esac diff --git a/bin/fm-remote-secondmate-control.sh b/bin/fm-remote-secondmate-control.sh index fc8cc5ec72e..98226d8b281 100755 --- a/bin/fm-remote-secondmate-control.sh +++ b/bin/fm-remote-secondmate-control.sh @@ -5,7 +5,7 @@ # fm-remote-secondmate-control.sh launch herdr [traceparent] # fm-remote-secondmate-control.sh state # fm-remote-secondmate-control.sh route -# fm-remote-secondmate-control.sh send +# fm-remote-secondmate-control.sh send [fire-and-forget] # fm-remote-secondmate-control.sh key # fm-remote-secondmate-control.sh capture [lines] # fm-remote-secondmate-control.sh observe @@ -186,8 +186,9 @@ cmd_launch() { } cmd_send() { - local id=$1 message=$2 rec ring_rc=0 meta meta_lock + local id=$1 message=$2 delivery_mode=${3:-} rec ring_rc=0 meta meta_lock validate_id "$id" + [ -z "$delivery_mode" ] || [ "$delivery_mode" = fire-and-forget ] || die "invalid send delivery mode" validate_home "$id" meta=$(meta_path "$id") meta_lock=$(fm_meta_lock_path "$meta") || die "remote secondmate metadata lock path is invalid" @@ -204,9 +205,9 @@ cmd_send() { # write is idempotent - re-running the same request after an ambiguous # transport failure lands on the existing record instead of a duplicate - so # the parent may safely repeat this leg. Exit 0 once the record durably - # exists; no ring outcome changes it, because the parent's pending-reply - # reconciliation owns loss detection for a remote request from here. - if ! rec=$(fm_task_inbox_write_idempotent "$CONTROL_STATE" "$id" "$message"); then + # exists; no ring outcome changes it, because the parent transport owns any + # retry or reply-tracking policy from here. + if ! rec=$(fm_task_inbox_write_idempotent "$CONTROL_STATE" "$id" "$message" "$delivery_mode"); then fm_lock_release "$meta_lock" die "steering-inbox record could not be written under $CONTROL_STATE/$id.inbox" fi @@ -327,7 +328,7 @@ case "${1:-}" in launch) shift; [ "$#" -ge 5 ] && [ "$#" -le 6 ] || usage; cmd_launch "$@" ;; state) shift; [ "$#" -eq 1 ] || usage; validate_id "$1"; validate_home "$1"; state_value "$1" ;; route) shift; [ "$#" -eq 1 ] || usage; cmd_route "$1" ;; - send) shift; [ "$#" -eq 2 ] || usage; cmd_send "$@" ;; + send) shift; [ "$#" -ge 2 ] && [ "$#" -le 3 ] || usage; cmd_send "$@" ;; key) shift; [ "$#" -eq 2 ] || usage; cmd_key "$@" ;; capture) shift; [ "$#" -ge 1 ] && [ "$#" -le 2 ] || usage; cmd_capture "$@" ;; observe) shift; [ "$#" -eq 1 ] || usage; cmd_observe "$@" ;; diff --git a/bin/fm-secondmate-reconcile.sh b/bin/fm-secondmate-reconcile.sh new file mode 100755 index 00000000000..28957311a68 --- /dev/null +++ b/bin/fm-secondmate-reconcile.sh @@ -0,0 +1,364 @@ +#!/usr/bin/env bash +# fm-secondmate-reconcile.sh - ask a secondmate to reconcile its own books, at +# most once per home per cooldown window. +# +# Usage: +# fm-secondmate-reconcile.sh notify [--snapshot |-] +# fm-secondmate-reconcile.sh nudged +# +# A backlog-vs-metadata inventory mismatch inside a secondmate home +# (orphan_in_flight, unowned_current, terminal_in_flight) no longer makes that +# home unreadable: bin/fm-fleet-snapshot.sh keeps its decisions, queued, landed, +# and live work and carries the mismatch for renderers. The books are still +# wrong, and only the home that owns them may fix them, so the parent sends one +# reconcile instruction and stops there. +# +# What this script owns: +# - reading the mismatch from an already-produced fleet snapshot, so nothing +# here re-parses another home's state or runs a second child summary; +# - the cooldown. One durable per-home timestamp records the last nudge, and a +# home is nudged only when that timestamp is older than the cooldown window +# (FM_RECONCILE_COOLDOWN_SECONDS, four hours). A recap or digest loop +# therefore cannot nag, while a mismatch still sitting there hours later +# earns one gentle re-nudge. Deliberately coarse: a timestamp cannot go +# stale, cannot mis-order against a concurrent snapshot, and cannot +# mis-classify a repair as a new problem, which an identity-precise record +# has to get right in every direction to avoid silently swallowing a nudge; +# - sending through bin/fm-send.sh's fire-and-forget plane, which records the +# instruction durably for local and remote mates alike while staying out of +# the steering inbox's re-ring and escalation ladder: the parent expects no +# reply, so nothing should chase one. +# +# What this script must never do: +# - edit the mate's backlog, metadata, or queue from the parent. The mate owns +# its own cleanup; the parent only asks. +# - block a snapshot or digest. The enqueue is a fast local durable write, and +# a send failure is reported, never fatal to the caller's own work. +# +# Lock acquisition is non-blocking. A busy reconcile, lifecycle-control, or +# metadata lock skips that home without starting its cooldown, so a later recap +# can retry. The sampled endpoint identity is revalidated before delivery, by +# fm-send under its final route lock, and before the cooldown commit so a retired +# endpoint is never nudged or allowed to silence its replacement. +# +# A persistent REMOTE secondmate's parent-side metadata intentionally has no +# spawn_gen (docs/remote-secondmates.md). Such a row is legitimate and markerless +# by construction, not corrupt, so it uses its sampled remote_host as the separate +# identity guard. The current metadata must still have no spawn_gen and must still +# name that host. A row with neither identity fails loudly. +# +# Exit status: 0 when no delivery or cooldown-recording failure is known, +# including when a home was skipped for lock contention or a stale endpoint; +# 1 when at least one due send failed or its cooldown could not be recorded. +# A known-undelivered send records nothing, so the next snapshot retries it; an +# unconfirmed send records the nudge, because a duplicate ask is worse than one +# the mate may already have. +# +# Output, one line per selected home in mismatch: +# sent: one reconcile instruction was recorded +# cooldown: nudged this recently; nothing sent +# skipped: lock a required lock was busy; cooldown unchanged +# stale: the sampled endpoint retired or changed +# failed: the steer could not be recorded +# sent-unrecorded: sent, but cooldown commit failed +set -u + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +FM_HOME="${FM_HOME:-${FM_ROOT_OVERRIDE:-$(cd "$SCRIPT_DIR/.." && pwd)}}" +STATE="${FM_STATE_OVERRIDE:-$FM_HOME/state}" + +# shellcheck source=bin/fm-wake-lib.sh +. "$SCRIPT_DIR/fm-wake-lib.sh" + +# One nudge per home per four hours. +FM_RECONCILE_COOLDOWN_SECONDS=${FM_RECONCILE_COOLDOWN_SECONDS:-14400} +case "$FM_RECONCILE_COOLDOWN_SECONDS" in + ''|*[!0-9]*) echo "fm-secondmate-reconcile: FM_RECONCILE_COOLDOWN_SECONDS must be a whole number of seconds" >&2; exit 2 ;; +esac + +ACTIVE_RECONCILE_LOCK= +ACTIVE_CONTROL_LOCK= +ACTIVE_META_LOCK= +release_active_locks() { + [ -z "$ACTIVE_META_LOCK" ] || fm_lock_release "$ACTIVE_META_LOCK" + ACTIVE_META_LOCK= + [ -z "$ACTIVE_CONTROL_LOCK" ] || fm_lock_release "$ACTIVE_CONTROL_LOCK" + ACTIVE_CONTROL_LOCK= + [ -z "$ACTIVE_RECONCILE_LOCK" ] || fm_lock_release "$ACTIVE_RECONCILE_LOCK" + ACTIVE_RECONCILE_LOCK= +} +trap release_active_locks EXIT +trap 'release_active_locks; exit 130' INT TERM + +usage() { + cat <<'EOF' +usage: fm-secondmate-reconcile.sh notify [--snapshot |-] + fm-secondmate-reconcile.sh nudged + +notify ask every secondmate home whose backlog disagrees with its own task + metadata to reconcile it, at most once per home per cooldown window. + Reads an fm-fleet-snapshot.v1 or fm-bearings.v1 document from + --snapshot (or runs fm-fleet-snapshot.sh --json when omitted). +nudged print the epoch second of the last reconcile nudge sent to . +EOF +} + +fail() { echo "fm-secondmate-reconcile: $*" >&2; exit 2; } + +nudge_path() { # + printf '%s/%s.reconcile-nudged\n' "$STATE" "$1" +} + +meta_field() { # + grep "^$2=" "$1" 2>/dev/null | tail -1 | cut -d= -f2- || true +} + +meta_spawn_gen() { + meta_field "$1" spawn_gen +} + +meta_remote_host() { + meta_field "$1" remote_host +} + +# revalidate_identity +# Confirms the row's sampled identity still matches the mate's current +# metadata. When a spawn generation was sampled, that generation alone is the +# identity, exactly as before. When none was sampled - the only legitimate +# case is a persistent remote secondmate, whose parent metadata never carries +# one - the sampled host substitutes, and the metadata must still carry no +# spawn_gen of its own or the row's assumed identity model no longer holds. +# Sets REVALIDATE_REASON to "no-identity" (nothing here can be safely +# identified; report failed) or "stale" (identified, but changed; report +# stale) on any non-zero return. +revalidate_identity() { # + local meta=$1 sampled_gen=$2 sampled_host=$3 cur_gen='' cur_host='' + if [ -f "$meta" ] && [ ! -L "$meta" ]; then + cur_gen=$(meta_spawn_gen "$meta") + cur_host=$(meta_remote_host "$meta") + fi + if [ -n "$sampled_gen" ]; then + if [ -z "$cur_gen" ]; then REVALIDATE_REASON=no-identity; return 1; fi + if [ "$cur_gen" != "$sampled_gen" ]; then REVALIDATE_REASON=stale; return 1; fi + return 0 + fi + if [ -z "$sampled_host" ]; then REVALIDATE_REASON=no-identity; return 1; fi + if [ -n "$cur_gen" ]; then REVALIDATE_REASON=stale; return 1; fi + if [ -z "$cur_host" ] || [ "$cur_host" != "$sampled_host" ]; then REVALIDATE_REASON=stale; return 1; fi + return 0 +} + +cmd_nudged() { + local id path + [ "$#" -eq 1 ] || { usage >&2; exit 2; } + id=$1 + case "$id" in ''|*/*|.*) fail "not a task id: $id" ;; esac + path=$(nudge_path "$id") + [ -f "$path" ] && [ ! -L "$path" ] || return 1 + cat "$path" +} + +delivery_id() { + local seed=$1 digest + if command -v shasum >/dev/null 2>&1; then + digest=$(printf '%s' "$seed" | shasum -a 256 | awk '{print $1}') || return 1 + elif command -v sha256sum >/dev/null 2>&1; then + digest=$(printf '%s' "$seed" | sha256sum | awk '{print $1}') || return 1 + elif command -v openssl >/dev/null 2>&1; then + digest=$(printf '%s' "$seed" | openssl dgst -sha256 2>/dev/null | awk '{print $NF}') || return 1 + else + return 1 + fi + printf '%s' "$digest" | cut -c1-16 +} + +# The instruction is deliberately independent of the sampled mismatch details. +# A delayed snapshot can therefore ask only for a check of the mate's current +# books, never prescribe a repair for rows that may already have changed. +reconcile_text() { + cat <<'EOF' +A fleet snapshot found that your home's backlog and task metadata disagreed. + +Please check your current books and, if they still disagree, reconcile them to match reality. Nothing outside your home has been changed, and no reply is expected. +EOF +} + +cmd_notify() { + local snapshot_src="" snapshot rows rc=0 now row_sep + while [ "$#" -gt 0 ]; do + case "$1" in + --snapshot) [ "$#" -ge 2 ] || fail "--snapshot needs a value"; snapshot_src=$2; shift 2 ;; + -h|--help) usage; exit 0 ;; + *) usage >&2; exit 2 ;; + esac + done + command -v jq >/dev/null 2>&1 || fail "jq is required" + + if [ -z "$snapshot_src" ]; then + snapshot=$("$SCRIPT_DIR/fm-fleet-snapshot.sh" --json) || fail "cannot read the fleet snapshot" + elif [ "$snapshot_src" = - ]; then + snapshot=$(cat) + else + [ -f "$snapshot_src" ] || fail "snapshot does not exist: $snapshot_src" + snapshot=$(cat "$snapshot_src") + fi + printf '%s' "$snapshot" | jq -e ' + .schema == "fm-fleet-snapshot.v1" or .schema == "fm-bearings.v1" + ' >/dev/null 2>&1 || fail "input is not an fm-fleet-snapshot.v1 or fm-bearings.v1 document" + + # Only a real inventory mismatch is a books problem the mate can fix; every + # other invalidity is either unreadable state or nothing to reconcile. + # spawn_gen is empty only for a persistent remote secondmate, whose parent + # metadata never carries one (bin/fm-spawn.sh's spawn_remote_secondmate()); + # host is its substitute identity there and is otherwise unused. Both are + # still character-restricted so a malformed sample cannot masquerade as + # either a live incarnation token or a live host. + # + # Rows join on ASCII unit separator (0x1F), not @tsv: bash's IFS-whitespace + # `read` collapses consecutive tabs, which would silently drop a + # legitimately empty spawn_gen or host field instead of preserving it. 0x1F + # is a control character, so the host filter below already excludes it from + # every field; it is passed in via --arg rather than written literally so no + # raw control byte sits in this source file. + row_sep=$(printf '\037') + rows=$(printf '%s' "$snapshot" | jq -r --arg sep "$row_sep" ' + (if .schema == "fm-bearings.v1" then + (.secondmate_reconcile // [])[] + | {id, spawn_gen:(.spawn_gen // ""), host:(.host // ""), kind:(.kind // ""), ids:(.ids // [])} + else + (.secondmate_current.records // [])[] + | select(.reconcile_inventory != null) + | {id, spawn_gen:(.spawn_gen // ""), host:(.host // ""), kind:(.reconcile_inventory.kind // ""), ids:(.reconcile_inventory.ids // [])} + end) + | select((.id | type) == "string" and (.id | test("^[A-Za-z0-9._-]+$"))) + | select((.spawn_gen | type) == "string" and (.spawn_gen | test("^[A-Za-z0-9._-]*$"))) + | select((.host | type) == "string" and (.host | test("[[:cntrl:]]") | not)) + | .kind as $kind + | select(["orphan_in_flight","unowned_current","terminal_in_flight"] | index($kind)) + | [.id, .spawn_gen, .host, $kind] + | join($sep)') + + local id sampled_spawn_gen sampled_host expected_remote_host kind path last age now delivered_at reconcile_lock control_lock meta meta_lock did send_rc + while IFS=$'\037' read -r id sampled_spawn_gen sampled_host kind; do + [ -n "${id:-}" ] || continue + path=$(nudge_path "$id") + reconcile_lock="$STATE/.$id.reconcile.lock" + if ! fm_lock_try_acquire "$reconcile_lock"; then + printf 'skipped: %s lock\n' "$id" + continue + fi + ACTIVE_RECONCILE_LOCK=$reconcile_lock + now=$(date +%s) + last= + if [ -f "$path" ] && [ ! -L "$path" ]; then last=$(cat "$path" 2>/dev/null || true); fi + case "$last" in ''|*[!0-9]*) last= ;; esac + if [ -n "$last" ]; then + age=$((now - last)) + # A clock that moved backwards must not silence the home forever. + if [ "$age" -ge 0 ] && [ "$age" -lt "$FM_RECONCILE_COOLDOWN_SECONDS" ]; then + printf 'cooldown: %s %s\n' "$id" "$age" + release_active_locks + continue + fi + fi + control_lock="$STATE/.control-$id.lock" + if ! fm_lock_try_acquire "$control_lock"; then + printf 'skipped: %s lock\n' "$id" + release_active_locks + continue + fi + ACTIVE_CONTROL_LOCK=$control_lock + meta="$STATE/$id.meta" + meta_lock=$(fm_meta_lock_path "$meta") || { + printf 'stale: %s %s\n' "$id" "$kind" + release_active_locks + continue + } + if ! fm_lock_try_acquire "$meta_lock"; then + printf 'skipped: %s lock\n' "$id" + release_active_locks + continue + fi + ACTIVE_META_LOCK=$meta_lock + if ! revalidate_identity "$meta" "$sampled_spawn_gen" "$sampled_host"; then + if [ "$REVALIDATE_REASON" = stale ]; then + printf 'stale: %s %s\n' "$id" "$kind" + else + printf 'failed: %s %s\n' "$id" "$kind" + rc=1 + fi + release_active_locks + continue + fi + did=$(delivery_id "$id:$sampled_spawn_gen:${last:-none}") || { + printf 'failed: %s %s\n' "$id" "$kind" + rc=1 + release_active_locks + continue + } + expected_remote_host= + [ -n "$sampled_spawn_gen" ] || expected_remote_host=$sampled_host + release_active_locks + send_rc=0 + FM_TASK_INBOX_LOCK_WAIT_SECS=0 FM_SEND_EXPECTED_SPAWN_GEN="$sampled_spawn_gen" \ + FM_SEND_EXPECTED_REMOTE_HOST="$expected_remote_host" \ + "$SCRIPT_DIR/fm-send.sh" "$id" --fire-and-forget "$did" \ + "$(reconcile_text)" >/dev/null 2>&1 || send_rc=$? + # exit 3 is "typed but unconfirmed": the mate may already hold the ask, so + # record the nudge rather than risk asking twice. + if [ "$send_rc" -ne 0 ] && [ "$send_rc" -ne 3 ]; then + printf 'failed: %s %s\n' "$id" "$kind" + rc=1 + continue + fi + delivered_at=$(date +%s) + if ! fm_lock_try_acquire "$reconcile_lock"; then + printf 'sent-unrecorded: %s %s\n' "$id" "$kind" + rc=1 + continue + fi + ACTIVE_RECONCILE_LOCK=$reconcile_lock + if ! fm_lock_try_acquire "$control_lock"; then + printf 'sent-unrecorded: %s %s\n' "$id" "$kind" + rc=1 + release_active_locks + continue + fi + ACTIVE_CONTROL_LOCK=$control_lock + if ! fm_lock_try_acquire "$meta_lock"; then + printf 'sent-unrecorded: %s %s\n' "$id" "$kind" + rc=1 + release_active_locks + continue + fi + ACTIVE_META_LOCK=$meta_lock + last= + if [ -f "$path" ] && [ ! -L "$path" ]; then last=$(cat "$path" 2>/dev/null || true); fi + case "$last" in ''|*[!0-9]*) last= ;; esac + if [ -n "$last" ] && [ "$last" -gt "$delivered_at" ]; then delivered_at=$last; fi + if revalidate_identity "$meta" "$sampled_spawn_gen" "$sampled_host" \ + && (umask 077; printf '%s\n' "$delivered_at" > "$path.tmp") \ + && mv -f -- "$path.tmp" "$path"; then + printf 'sent: %s %s\n' "$id" "$kind" + else + rm -f -- "$path.tmp" + # The mate has the instruction; only this home's cooldown record is + # missing, so say so rather than letting the next run ask again in silence. + printf 'sent-unrecorded: %s %s\n' "$id" "$kind" + rc=1 + fi + release_active_locks + done <&2; exit 2; } +cmd=$1; shift +case "$cmd" in + notify) cmd_notify "$@" ;; + nudged) cmd_nudged "$@" ;; + -h|--help) usage ;; + *) usage >&2; exit 2 ;; +esac diff --git a/bin/fm-send.sh b/bin/fm-send.sh index 413e89ac79a..b10f381ffd6 100755 --- a/bin/fm-send.sh +++ b/bin/fm-send.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash # Steer a task by durable record: write the message into the task's steering # inbox and ring a constant doorbell line into its terminal, best-effort. -# Usage: fm-send.sh [--resolve-key ]... +# Usage: fm-send.sh [--resolve-key ]... [--fire-and-forget ] # may be an exact task id, a legacy fm- task label resolved # through this home's state/.meta, or an explicit well-formed backend # target. fm-send refuses unresolved guesses rather than falling back to a @@ -28,11 +28,13 @@ # is idempotent: the remote leg deduplicates an exact re-run of the same # request onto the existing record (bin/fm-task-inbox-lib.sh), so after a lost # transport (ssh exit 255, completion unknown) fm-send retries the same leg -# once itself. A later re-run is idempotent only through the printed -# FM_PENDING_REPLY_EXISTING_CORR= command: it preserves the same -# correlation, body, and record, while a plain re-run mints a new correlation -# and delivers a separate record. A still-unconfirmed marked request keeps its -# reply expectation preserved for the record that may have landed. +# once itself. For an ordinary reply-bearing request, a later re-run is +# idempotent only through the printed FM_PENDING_REPLY_EXISTING_CORR= +# command: it preserves the same correlation, body, and record, while a plain +# re-run mints a new correlation and delivers a separate record. An explicit +# fire-and-forget request instead retries with its same caller-supplied delivery +# id. A still-unconfirmed reply-bearing request keeps its reply expectation +# preserved for the record that may have landed. # Pending-reply bookkeeping trouble after a durable enqueue NEVER exits # nonzero: with the recovery marker stored the watcher reconciles it silently, # and with both the commit and the marker lost the send prints a distinct @@ -40,14 +42,15 @@ # because a resend-inviting status there would duplicate a delivered # instruction. There is no delivered-unconfirmed # outcome on this plane: "did the doorbell land" is no longer the question - -# "was the message acted on" is, and that is answered asynchronously by the -# worker's acknowledgement move into handled/, with the watcher re-ringing an -# unacknowledged message and escalating a stuck one. bin/fm-task-inbox-lib.sh -# owns the record format, the doorbell line, and the re-ring ladder. The -# composer pre-check before the ring is ADVISORY only: when the composer -# visibly holds pending text the ring is skipped with a notice and the watcher -# re-rings later; no composer verdict is delivery proof on this plane, and a -# failed ring never fails the send. +# "was the message acted on" is, and that is answered asynchronously for an +# ordinary record by the worker's acknowledgement move into handled/, with the +# watcher re-ringing an unacknowledged message and escalating a stuck one. An +# explicit fire-and-forget record is excluded from that ladder. +# bin/fm-task-inbox-lib.sh owns the record format, the doorbell line, and the +# re-ring ladder. The composer pre-check before the ring is ADVISORY only: when +# the composer visibly holds pending text the ring is skipped with a notice and +# the watcher re-rings an ordinary record later; no composer verdict is +# delivery proof on this plane, and a failed ring never fails the send. # # TYPED - the LOCAL text that must reach the terminal itself: a harness-native # invocation (a leading "/", or a leading "$" to a codex target) must reach @@ -93,8 +96,9 @@ # marked - their behavior is unchanged. # # Parent-owned pending-reply expectation: every newly marked secondmate request -# also receives a privacy-safe correlation id and a durable parent record under -# state/pending-replies/ before delivery (bin/fm-pending-reply-lib.sh). Delivery +# except an explicit --fire-and-forget delivery receives a privacy-safe +# correlation id and a durable parent record under state/pending-replies/ before +# delivery (bin/fm-pending-reply-lib.sh). Delivery # success and reply success are separate facts: delivery never resolves the # expectation. On the inbox plane the durable enqueue IS delivery to the task's # record, so the expectation is marked delivered at enqueue time; when that @@ -107,7 +111,10 @@ # it armed rather than dropping it, and only a proven send failure discards it. # Set FM_PENDING_REPLY_EXISTING_CORR= when re-sending a recovery request # for an already-open expectation so a second record is not created. Direct -# unmarked captain input never creates one. +# unmarked captain input never creates one. A marked secondmate instruction +# sent with --fire-and-forget <16-hex-delivery-id> uses the same inbox transport +# without creating a reply expectation; its delivery id makes uncertain retries +# idempotent while allowing a later identical instruction to be distinct. # # Remote secondmate delivery: the send crosses fm-on.sh to a host-local leg # (bin/fm-remote-secondmate-control.sh cmd_send) that writes the message as a @@ -119,14 +126,19 @@ # own stderr attached. Transport loss (ssh exit 255) means completion unknown, # so fm-send retries the identical leg once - safe because the remote write # deduplicates the same request onto the same record - and a still-lost -# transport exits nonzero while preserving a marked request's reply +# transport exits nonzero while preserving a reply-bearing marked request's # expectation, since the record may have landed. Its error prints the exact # FM_PENDING_REPLY_EXISTING_CORR= resend command that preserves the body -# and makes a later remote enqueue deduplicate onto that same record. The -# remote host runs no re-ring ladder of -# its own: a swallowed remote doorbell surfaces through the parent's -# pending-reply recovery and escalation, whose recovery request re-rings the -# remote doorbell when it is enqueued. +# and makes a later remote enqueue deduplicate onto that same record. An +# unconfirmed fire-and-forget request exits 3 and names the same delivery id to +# retry. The remote host runs no re-ring ladder of its own: a swallowed ordinary +# doorbell surfaces through the parent's pending-reply recovery and escalation, +# whose recovery request re-rings the remote doorbell when it is enqueued; +# fire-and-forget delivery deliberately arms neither mechanism. Internal +# semantic callers may set FM_SEND_EXPECTED_SPAWN_GEN or +# FM_SEND_EXPECTED_REMOTE_HOST to require that sampled identity to still match +# during the final locked remote-route validation; unset or empty guards do not +# change ordinary sends. # # Decision closure (answerer-closes): pass --resolve-key (repeatable, # before the message) when this send answers an open keyed needs-decision: or @@ -421,6 +433,7 @@ fi # must precede --key or the message text; everything after the last flag is the # message exactly as before, so ordinary sends are byte-identical. RESOLVE_KEYS= +FIRE_AND_FORGET_ID= fm_send_add_resolve_key() { # local k=$1 case "$k" in @@ -448,6 +461,17 @@ while :; do fm_send_add_resolve_key "${1#--resolve-key=}" || exit 1 shift ;; + --fire-and-forget) + [ $# -ge 2 ] || { echo "error: --fire-and-forget requires a delivery id" >&2; exit 1; } + [ -z "$FIRE_AND_FORGET_ID" ] || { echo "error: duplicate --fire-and-forget" >&2; exit 1; } + FIRE_AND_FORGET_ID=$2 + shift 2 + ;; + --fire-and-forget=*) + [ -z "$FIRE_AND_FORGET_ID" ] || { echo "error: duplicate --fire-and-forget" >&2; exit 1; } + FIRE_AND_FORGET_ID=${1#--fire-and-forget=} + shift + ;; *) break ;; esac done @@ -515,6 +539,15 @@ fm_send_hold_resolved_id() { # return 1 } +if [ -n "$FIRE_AND_FORGET_ID" ]; then + printf '%s' "$FIRE_AND_FORGET_ID" | grep -Eq '^[a-f0-9]{16}$' \ + || { echo "error: --fire-and-forget delivery id must be 16 lowercase hex characters" >&2; exit 1; } + [ "$MARK_FROM_FIRSTMATE" = 1 ] \ + || { echo "error: --fire-and-forget requires a recorded secondmate task selector" >&2; exit 1; } + [ -z "$RESOLVE_KEYS" ] \ + || { echo "error: --fire-and-forget cannot accompany --resolve-key" >&2; exit 1; } +fi + if [ -n "$RESOLVE_KEYS" ]; then if [ -z "$TARGET_SELECTOR" ] || [ -z "$TARGET_META" ]; then echo "error: --resolve-key needs a task selector resolved through this home's metadata; an explicit backend target has no decision ledger here" >&2 @@ -604,6 +637,8 @@ fm_send_feed_resolved_holds() { # # error with the attempted resolution attached. if [ "${1:-}" = "--key" ]; then + [ -z "$FIRE_AND_FORGET_ID" ] \ + || { echo "error: --fire-and-forget cannot accompany --key" >&2; exit 1; } case "$*" in *--resolve-key*) echo "error: --resolve-key cannot accompany --key; answering a decision requires a text answer" >&2 @@ -628,7 +663,11 @@ else # The pre-marker answer text, kept for the closing resolved note so the # durable ledger records the plain answer without marker or corr bytes. RESOLVE_ANSWER_TEXT=$MESSAGE - if [ "$MARK_FROM_FIRSTMATE" = 1 ]; then + if [ "$MARK_FROM_FIRSTMATE" = 1 ] && [ -n "$FIRE_AND_FORGET_ID" ]; then + fm_message_mark_from_firstmate "$MESSAGE" MESSAGE + MESSAGE="${FM_FROMFIRST_MARK}delivery=${FIRE_AND_FORGET_ID} ${MESSAGE#"$FM_FROMFIRST_MARK"}" + FM_SEND_IDEMPOTENT=1 + elif [ "$MARK_FROM_FIRSTMATE" = 1 ]; then # Reuse an existing correlation id for recovery resends; otherwise create a # durable parent expectation before delivery. Transport success never # resolves that expectation (see fm-pending-reply-lib.sh). @@ -691,7 +730,7 @@ else # command: the pre-existing marker-first wire bytes are retained in stage 1. INBOX_PLANE=0 if [ -n "$TARGET_SELECTOR" ]; then - if [ "$TARGET_BACKEND" = remote ]; then + if [ -n "$FIRE_AND_FORGET_ID" ] || [ "$TARGET_BACKEND" = remote ]; then INBOX_PLANE=1 else case "$RESOLVE_ANSWER_TEXT" in @@ -706,7 +745,8 @@ else # home's steering inbox, written idempotently by the host-local leg, then # the remote doorbell rings, best-effort. One identical retry after ssh # 255 is safe by that idempotence; a still-lost transport preserves a - # marked request's reply expectation because the record may have landed. + # reply-bearing request's expectation, while fire-and-forget reports the + # delivery id that must be reused, because the record may have landed. REMOTE_META_LOCK=$(fm_meta_lock_path "$TARGET_META") || exit 1 if ! fm_task_inbox_lock_acquire "$REMOTE_META_LOCK"; then if [ "$PENDING_REPLY_CREATED" = 1 ] && [ -n "$PENDING_REPLY_CORR" ]; then @@ -717,11 +757,17 @@ else fi CURRENT_REMOTE_ID= CURRENT_REMOTE_HOST= + CURRENT_REMOTE_SPAWN_GEN= if [ -f "$TARGET_META" ]; then CURRENT_REMOTE_ID=$(fm_send_id_from_meta "$TARGET_META") CURRENT_REMOTE_HOST=$(fm_meta_get "$TARGET_META" remote_host) + CURRENT_REMOTE_SPAWN_GEN=$(fm_meta_get "$TARGET_META" spawn_gen) fi if [ "$CURRENT_REMOTE_ID" != "$TARGET_REMOTE_ID" ] \ + || { [ -n "${FM_SEND_EXPECTED_SPAWN_GEN:-}" ] \ + && [ "$CURRENT_REMOTE_SPAWN_GEN" != "$FM_SEND_EXPECTED_SPAWN_GEN" ]; } \ + || { [ -n "${FM_SEND_EXPECTED_REMOTE_HOST:-}" ] \ + && [ "$CURRENT_REMOTE_HOST" != "$FM_SEND_EXPECTED_REMOTE_HOST" ]; } \ || [ -z "$CURRENT_REMOTE_HOST" ] \ || [ "$CURRENT_REMOTE_HOST" != "$TARGET_REMOTE_HOST" ]; then fm_lock_release "$REMOTE_META_LOCK" @@ -733,16 +779,22 @@ else fi remote_rc=0 remote_completion_unknown=0 + REMOTE_SEND_ARGS=("$TARGET_REMOTE_ID" "$MESSAGE") + [ -z "$FIRE_AND_FORGET_ID" ] || REMOTE_SEND_ARGS+=(fire-and-forget) "$SCRIPT_DIR/fm-on.sh" "$TARGET_REMOTE_ID" fm-remote-secondmate-control.sh send \ - "$TARGET_REMOTE_ID" "$MESSAGE" < /dev/null || remote_rc=$? + "${REMOTE_SEND_ARGS[@]}" < /dev/null || remote_rc=$? if [ "$remote_rc" -eq 255 ]; then remote_completion_unknown=1 remote_rc=0 "$SCRIPT_DIR/fm-on.sh" "$TARGET_REMOTE_ID" fm-remote-secondmate-control.sh send \ - "$TARGET_REMOTE_ID" "$MESSAGE" < /dev/null || remote_rc=$? + "${REMOTE_SEND_ARGS[@]}" < /dev/null || remote_rc=$? fi fm_lock_release "$REMOTE_META_LOCK" if [ "$remote_rc" -ne 0 ] && [ "$remote_completion_unknown" -eq 1 ]; then + if [ -n "$FIRE_AND_FORGET_ID" ]; then + echo "error: fire-and-forget steer to remote secondmate $TARGET_REMOTE_ID is unconfirmed (delivery-id=$FIRE_AND_FORGET_ID); retry only with the same delivery id" >&2 + exit 3 + fi if [ -n "$PENDING_REPLY_CORR" ]; then fm_pending_reply_mark_delivery_unknown "$STATE" "$PENDING_REPLY_CORR" || true fi @@ -801,12 +853,16 @@ else fi CURRENT_INBOX_TARGET= CURRENT_INBOX_BACKEND= + CURRENT_INBOX_SPAWN_GEN= if [ -f "$TARGET_META" ]; then CURRENT_INBOX_TARGET=$(fm_backend_target_of_meta "$TARGET_META") CURRENT_INBOX_BACKEND=$(fm_backend_of_meta "$TARGET_META") + CURRENT_INBOX_SPAWN_GEN=$(fm_meta_get "$TARGET_META" spawn_gen) fi if [ "$CURRENT_INBOX_TARGET" != "$T" ] \ || [ "$CURRENT_INBOX_BACKEND" != "$TARGET_BACKEND" ] \ + || { [ -n "${FM_SEND_EXPECTED_SPAWN_GEN:-}" ] \ + && [ "$CURRENT_INBOX_SPAWN_GEN" != "$FM_SEND_EXPECTED_SPAWN_GEN" ]; } \ || [ -n "$(fm_meta_get "$TARGET_META" remote_host)" ]; then fm_lock_release "$INBOX_META_LOCK" if [ "$PENDING_REPLY_CREATED" = 1 ] && [ -n "$PENDING_REPLY_CORR" ]; then @@ -815,7 +871,14 @@ else echo "error: steer not sent to $INBOX_TASK_ID: the task retired or changed endpoint during target resolution" >&2 exit 1 fi - if ! INBOX_RECORD=$(fm_task_inbox_write "$STATE" "$INBOX_TASK_ID" "$MESSAGE"); then + if [ "${FM_SEND_IDEMPOTENT:-0}" = 1 ]; then + INBOX_RECORD=$(fm_task_inbox_write_idempotent "$STATE" "$INBOX_TASK_ID" "$MESSAGE" \ + "${FIRE_AND_FORGET_ID:+fire-and-forget}") || inbox_write_rc=$? + else + INBOX_RECORD=$(fm_task_inbox_write "$STATE" "$INBOX_TASK_ID" "$MESSAGE" \ + "${FIRE_AND_FORGET_ID:+fire-and-forget}") || inbox_write_rc=$? + fi + if [ "${inbox_write_rc:-0}" -ne 0 ]; then fm_lock_release "$INBOX_META_LOCK" if [ "$PENDING_REPLY_CREATED" = 1 ] && [ -n "$PENDING_REPLY_CORR" ]; then fm_pending_reply_discard_undelivered "$STATE" "$PENDING_REPLY_CORR" || true diff --git a/bin/fm-spawn.sh b/bin/fm-spawn.sh index c645ba5c2ff..52c406a4d2f 100755 --- a/bin/fm-spawn.sh +++ b/bin/fm-spawn.sh @@ -142,6 +142,15 @@ # origin, resolves the current remote default branch, and resets to its tip. # An unreachable origin, unresolved default branch, or non-clean worktree # refuses the spawn rather than risking a PR based on stale history. +# A slot whose only deviation is a stale submodule gitlink is refused by that +# same clean check, but is reported as a stale checkout naming each submodule +# and both pins; nothing is converged or removed, and no remedy is suggested. +# That report is only reached when each submodule's checked-out commit is +# already contained in one of its remotes, so a submodule carrying an unpushed +# commit keeps the conservative uncommitted-work refusal instead. That +# containment test reads local refs only and never fetches, so this gate stays +# usable offline; a stale remote-tracking ref can therefore make an unpushed +# commit look contained, which is exactly why no remedy command is printed. # Batch dispatch: pass one or more `id=repo` pairs instead of a single , e.g. # fm-spawn.sh fix-a-k3=projects/foo add-b-q7=projects/bar [--scout] # Each pair re-execs this script in single-task mode, so the single path stays the only @@ -1746,6 +1755,47 @@ validate_spawn_worktree() { # fi } +# A pooled slot whose only deviation is a submodule gitlink is stale, not dirty: +# an earlier refresh moved the superproject and left the submodule checkout on +# the pin the previous base recorded. The refusal still stands and this gate +# never touches the slot; it only names the cause, because "is not clean" while +# the operator's own `git status` reads clean gives neither a cause nor a remedy. +# A pin is only reported as stale when the commit the slot holds is already +# contained in one of the submodule's remotes. Anything that cannot be proven +# contained - an unpushed commit, a submodule with no remote, a git error - falls +# through to the conservative uncommitted-work refusal, as does any entry that is +# not exactly a clean submodule sitting on a different pin. The diagnosis is +# buffered and only emitted once every entry qualifies, so it can never +# contradict the verdict. +# +# No remedy command is printed, deliberately. That containment check reads local +# refs only and never fetches, because this gate has to stay usable offline. A +# remote-tracking ref that has gone stale - its upstream branch deleted or +# force-pushed, and never pruned - therefore still reads as containment, so a +# commit that is really unpushed can look contained. Naming the submodule and both +# pins is what the operator actually needs; printing a checkout command on a +# judgement that can be fooled could cost them that commit, so the remedy is left +# to the operator, who can see the whole picture. +describe_stale_submodule_pins() { # + local worktree=$1 status=$2 line path want have unpushed lines= + while IFS= read -r line; do + [ -n "$line" ] || continue + case $line in ' M '*) path=${line#' M '} ;; *) return 1 ;; esac + [ "$(git -C "$worktree" ls-files --stage -- "$path" 2>/dev/null | cut -c1-6)" = 160000 ] || return 1 + [ -z "$(git -C "$worktree/$path" status --porcelain 2>/dev/null)" ] || return 1 + want=$(git -C "$worktree" rev-parse --verify --quiet "HEAD:$path" 2>/dev/null) || return 1 + have=$(git -C "$worktree/$path" rev-parse --verify --quiet HEAD 2>/dev/null) || return 1 + [ "$want" != "$have" ] || return 1 + unpushed=$(git -C "$worktree/$path" log --format=%H --max-count=1 "$have" --not --remotes -- 2>/dev/null) || return 1 + [ -z "$unpushed" ] || return 1 + lines+="error: submodule '$path' is checked out at $have, but this base records $want"$'\n' + done <&2 +} + freshen_spawn_worktree_base() { # local worktree=$1 default target expected actual status if ! git -C "$worktree" fetch --quiet origin; then @@ -1769,12 +1819,16 @@ freshen_spawn_worktree_base() { # echo "error: '$target' is not a commit for pooled worktree '$worktree'; refusing to launch from a potentially stale base" >&2 return 1 } - status=$(git -C "$worktree" status --porcelain) || { + status=$(git -C "$worktree" -c core.quotePath=false status --porcelain) || { echo "error: could not inspect pooled worktree '$worktree' before refreshing its base" >&2 return 1 } if [ -n "$status" ]; then - echo "error: pooled worktree '$worktree' is not clean; refusing to discard uncommitted work while refreshing its base" >&2 + if describe_stale_submodule_pins "$worktree" "$status"; then + echo "error: pooled worktree '$worktree' has a stale submodule checkout, not uncommitted work; refusing to launch and leaving it untouched" >&2 + else + echo "error: pooled worktree '$worktree' is not clean; refusing to discard uncommitted work while refreshing its base" >&2 + fi return 1 fi if ! git -C "$worktree" reset --hard "$target" >/dev/null; then diff --git a/bin/fm-task-inbox-lib.sh b/bin/fm-task-inbox-lib.sh index 6ad482eafd3..31e1b9b198a 100644 --- a/bin/fm-task-inbox-lib.sh +++ b/bin/fm-task-inbox-lib.sh @@ -32,6 +32,7 @@ # Record format (fm_task_inbox_write / fm_task_inbox_body): # schema=fm-task-inbox.v1 # at= +# delivery=fire-and-forget present only when the re-ring ladder must ignore it # -- # @@ -137,14 +138,15 @@ fm_task_inbox_lock_acquire() { # # Write one record into the next sequence slot: temp-write, then atomic # rename. Prints the record path. Caller must hold .seq.lock. -_fm_task_inbox_write_record_locked() { # - local dir=$1 text=$2 seq tmp rec status=0 +_fm_task_inbox_write_record_locked() { # [delivery-mode] + local dir=$1 text=$2 delivery_mode=${3:-} seq tmp rec status=0 seq=$(fm_task_inbox_next_seq "$dir") rec="$dir/$seq.msg" tmp=$(mktemp "$dir/.staging.XXXXXX") || return 1 { printf 'schema=%s\n' "$FM_TASK_INBOX_SCHEMA" printf 'at=%s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" + [ "$delivery_mode" != fire-and-forget ] || printf 'delivery=fire-and-forget\n' printf -- '--\n' printf '%s' "$text" } > "$tmp" && mv "$tmp" "$rec" || status=1 @@ -154,13 +156,13 @@ _fm_task_inbox_write_record_locked() { # # Durably enqueue one steer: temp-write, then atomic rename into the next # sequence slot. Prints the record path. Fails without a partial record. -fm_task_inbox_write() { # - local state=$1 task=$2 text=$3 dir lock rec status=0 +fm_task_inbox_write() { # [delivery-mode] + local state=$1 task=$2 text=$3 delivery_mode=${4:-} dir lock rec status=0 dir=$(fm_task_inbox_dir "$state" "$task") mkdir -p "$dir/handled" || return 1 lock="$dir/.seq.lock" fm_task_inbox_lock_acquire "$lock" || return 1 - rec=$(_fm_task_inbox_write_record_locked "$dir" "$text") || status=1 + rec=$(_fm_task_inbox_write_record_locked "$dir" "$text" "$delivery_mode") || status=1 fm_lock_release "$lock" [ "$status" -eq 0 ] || return 1 printf '%s' "$rec" @@ -177,8 +179,8 @@ fm_task_inbox_write() { # # secondmate request embeds a per-request correlation token in its body. The # local plane keeps plain fm_task_inbox_write: its outcome is synchronous, so # a repeated identical local steer is a deliberate new instruction. -fm_task_inbox_write_idempotent() { # - local state=$1 task=$2 text=$3 dir lock want have f rec='' status=0 +fm_task_inbox_write_idempotent() { # [delivery-mode] + local state=$1 task=$2 text=$3 delivery_mode=${4:-} dir lock want have f rec='' status=0 dir=$(fm_task_inbox_dir "$state" "$task") mkdir -p "$dir/handled" || return 1 lock="$dir/.seq.lock" @@ -195,6 +197,11 @@ fm_task_inbox_write_idempotent() { # *) continue ;; esac fi + if [ "$delivery_mode" = fire-and-forget ]; then + fm_task_inbox_is_fire_and_forget "$f" || continue + elif fm_task_inbox_is_fire_and_forget "$f"; then + continue + fi if ! fm_task_inbox_body "$f" > "$have" 2>/dev/null; then case "$f" in "$dir"/*.msg) @@ -218,7 +225,7 @@ fm_task_inbox_write_idempotent() { # status=1 fi if [ "$status" -eq 0 ] && [ -z "$rec" ]; then - rec=$(_fm_task_inbox_write_record_locked "$dir" "$text") || status=1 + rec=$(_fm_task_inbox_write_record_locked "$dir" "$text" "$delivery_mode") || status=1 fi fm_lock_release "$lock" [ "$status" -eq 0 ] || return 1 @@ -275,12 +282,26 @@ fm_task_inbox_ring() { # [expected-label] return 0 } -# Oldest unhandled record by sequence, or fail when the inbox is empty. +fm_task_inbox_is_fire_and_forget() { # + local rec=$1 + if [ ! -f "$rec" ]; then + rec="${rec%/*}/handled/${rec##*/}" + [ -f "$rec" ] || return 1 + fi + awk ' + $0 == "--" { exit } + $0 == "delivery=fire-and-forget" { found=1 } + END { exit(found ? 0 : 1) } + ' "$rec" +} + +# Oldest escalation-tracked unhandled record, or fail when none is due. fm_task_inbox_oldest_unhandled() { # local dir best='' best_n=0 f n dir=$(fm_task_inbox_dir "$1" "$2") for f in "$dir"/*.msg; do [ -e "$f" ] || continue + fm_task_inbox_is_fire_and_forget "$f" && continue n=$(fm_task_inbox_seq_of "${f##*/}") || continue if [ -z "$best" ] || [ "$n" -lt "$best_n" ]; then best=$f diff --git a/bin/fm-teardown.sh b/bin/fm-teardown.sh index a24698569ea..eaa433746c2 100755 --- a/bin/fm-teardown.sh +++ b/bin/fm-teardown.sh @@ -2501,7 +2501,7 @@ cleanup_firstmate_home_children() { "$sub_state/$child_id.meta" "$sub_state/$child_id.pi-ext.ts" \ "$sub_state/$child_id.grok-turnend-token" "$sub_state/$child_id.kimi-turnend-token" \ "$sub_state/$child_id.muse-session" "$sub_state/$child_id.muse-session-current" \ - "$sub_state/$child_id.cursor-session" + "$sub_state/$child_id.cursor-session" "$sub_state/$child_id.reconcile-nudged" done } @@ -2818,7 +2818,8 @@ rm -f "$STATE/$ID.turn-ended" "$STATE/$ID.meta" \ "$STATE/$ID.kimi-turnend-token" "$STATE/$ID.muse-session" \ "$STATE/$ID.muse-session-current" "$STATE/$ID.cursor-session" \ "$STATE/$ID.control-relaunch" "$STATE/$ID.control-relaunch.meta-prior" \ - "$STATE/$ID.control-relaunch.brief-prior" "$STATE/$ID.control-relaunch.note" + "$STATE/$ID.control-relaunch.brief-prior" "$STATE/$ID.control-relaunch.note" \ + "$STATE/$ID.reconcile-nudged" # The steering inbox (bin/fm-task-inbox-lib.sh) is runtime state for the # retired endpoint; teardown only runs after landing is confirmed, so any # leftover unhandled steer here is moot rather than unlanded work. diff --git a/bin/fm-test-run.sh b/bin/fm-test-run.sh index 2d3a64bcadb..9a1d4a8be1c 100755 --- a/bin/fm-test-run.sh +++ b/bin/fm-test-run.sh @@ -175,7 +175,8 @@ family_for_basename() { fm-remote-reply.test.sh|fm-remote-secondmate-lifecycle-e2e.test.sh|\ fm-remote-secondmate-trace-context.test.sh|\ fm-secondmate-harness.test.sh|fm-secondmate-lifecycle-e2e.test.sh|\ - fm-secondmate-liveness.test.sh|fm-secondmate-safety.test.sh|fm-secondmate-sync.test.sh|\ + fm-secondmate-liveness.test.sh|fm-secondmate-reconcile.test.sh|\ + fm-secondmate-safety.test.sh|fm-secondmate-sync.test.sh|\ fm-startup-memory-budget.test.sh|fm-stow-cascade.test.sh|\ fm-send-secondmate-marker.test.sh|fm-shared-captain-inheritance.test.sh) printf '%s\n' secondmate @@ -218,7 +219,8 @@ family_for_basename() { fm-afk-inject-e2e.test.sh|fm-afk-return.test.sh) printf '%s\n' afk ;; - fm-bearings-snapshot.test.sh|fm-fleet-snapshot-view.test.sh) + fm-bearings-board-render.test.sh|fm-bearings-snapshot.test.sh|\ + fm-fleet-snapshot-view.test.sh) printf '%s\n' snapshot-bearings ;; fm-backend-cmux.test.sh|fm-backend-cmux-smoke.test.sh) diff --git a/bin/fm-turnend-guard-cursor.sh b/bin/fm-turnend-guard-cursor.sh index ed608d1b867..e09bdba3763 100755 --- a/bin/fm-turnend-guard-cursor.sh +++ b/bin/fm-turnend-guard-cursor.sh @@ -49,6 +49,12 @@ # park owner in state/.cursor-park-owner, and once a newer stop has published its # claim, an older park still running stands down without emitting. Newest stop # wins; the arm's own singleton keeps the overlap from starting a second watcher. +# +# PI STAND-DOWN. Exit 0 without parking when PI_CODING_AGENT=true and neither +# CURSOR_AGENT nor CURSOR_INVOKED_AS is set, so a Pi host that loaded +# .cursor/hooks.json via pi-cursor-sdk does not dual-watch against +# fm_watch_arm_pi. Cursor identity keeps parking despite a leaked +# PI_CODING_AGENT. docs/turnend-guard.md owns the contract. set -u SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" @@ -103,6 +109,14 @@ case "$SESSION_ID" in ''|*[!A-Za-z0-9._-]*) SESSION_ID=unknown ;; esac fm_primary_scope_matches "$FM_ROOT" "$STATE" || exit 0 +# Pi-host stand-down: docs/turnend-guard.md owns the PI_CODING_AGENT / +# CURSOR_AGENT / CURSOR_INVOKED_AS contract summarized in this script's header. +if [ "${PI_CODING_AGENT:-}" = "true" ] \ + && [ -z "${CURSOR_AGENT:-}" ] \ + && [ -z "${CURSOR_INVOKED_AS:-}" ]; then + exit 0 +fi + lock_acquire_bounded() { # local lock=$1 attempt=0 while [ "$attempt" -lt "$LOCK_ATTEMPTS" ]; do diff --git a/bin/fm-watch.sh b/bin/fm-watch.sh index 48ed9620956..633655f0933 100755 --- a/bin/fm-watch.sh +++ b/bin/fm-watch.sh @@ -49,14 +49,17 @@ # (state/.turn-ended, or the spawn record before any # turn completes). Past that bound, a declared external # wait or verified captain-held transfer uses the long -# pause recheck cadence; every other pane goes through -# the same wedge timer and surfaces with the identical -# "stale: ..." reason, escalation count, and -# demand-deep-inspection marker, for human inspection -# only - never an automatic interrupt, signal, or restart -# of the worker or its tool process. That wedge timer is -# exempt from the working deferral above: the busy signal -# a re-verification would read is the very one +# pause recheck cadence (under afk it is instead handed +# to the daemon as this plain reason, once per +# declaration; busy_turn_bound_check owns that handoff); +# every other pane goes through the same wedge timer and +# surfaces with the identical "stale: ..." reason, +# escalation count, and demand-deep-inspection marker, +# for human inspection only - never an automatic +# interrupt, signal, or restart of the worker or its +# tool process. That wedge timer is exempt from the +# working deferral above: the busy signal a +# re-verification would read is the very one # BUSY_TURN_MAX_SECS stopped trusting. # stale: (unread firstmate instruction: ...) # the steering-inbox ladder spent its delivery-attempt @@ -107,6 +110,17 @@ mkdir -p "$STATE" . "$SCRIPT_DIR/fm-push-transition-lib.sh" # shellcheck source=bin/fm-pr-lib.sh . "$SCRIPT_DIR/fm-pr-lib.sh" +# Single owner of durable merge-outcome publication, shared with +# bin/fm-pr-merge.sh so self and poll origins use the same role-routed outcome. +# The watcher still owns immediate delivery of its actionable poll result and +# poll retirement. +# This library is a canonical lint root in its own right, and it reaches the +# wake queue, PR identity, and secondmate parent libraries. Keep it an analysis +# boundary here for the same reason as the transition and inbox owners above and +# below: following its graph from this large runtime exceeds the bounded CI lint +# worker while adding no uncovered file. +# shellcheck source=/dev/null +. "$SCRIPT_DIR/fm-merge-outcome-lib.sh" # shellcheck source=bin/fm-x-lib.sh . "$SCRIPT_DIR/fm-x-lib.sh" # shellcheck source=bin/fm-check-lib.sh @@ -661,10 +675,43 @@ handle_paused_stale() { # # alter the separate non-busy classification. handle_paused_stale keeps the # exception bounded by re-surfacing it once per PAUSE_RESURFACE_SECS. Away mode # remains daemon-owned and receives the undecorated wake identity for its own -# classification. +# classification, which is why the declaration is read before the afk branch +# rather than after it. busy_turn_bound_check() { # - local win=$1 task=$2 h=$3 since_file=$4 escalation_file=$5 - if ! afk_present && status_is_paused_or_captain_held "$(last_status_line "$STATE/$task.status")"; then + local win=$1 task=$2 h=$3 since_file=$4 escalation_file=$5 key statusf declared + statusf="$STATE/$task.status" + if status_is_paused_or_captain_held "$(last_status_line "$statusf")"; then + if afk_present; then + # Away mode is daemon-owned, so this bound hands off the PLAIN wake identity + # and lets the daemon classify the declaration itself - the undecorated + # identity the rest of this function's contract promises. Running the wedge + # timer here instead would decorate the wake as a possible wedge, and that + # decoration overrides the daemon's own pause verdict for the pane: the + # ladder then climbs on every re-arm, escalating a crew that declared the + # wait itself once per FM_STALE_ESCALATE_SECS for as long as the wait lasts. + # The one-shot is keyed on the DECLARATION (the status log's signature), + # never on the pane hash: a busy pane's harness footer ticks on every + # capture, so a hash-keyed one-shot would re-fire on every poll and the + # daemon, which relaunches the watcher after each handled wake, would be + # woken in a loop for the whole declared wait. The suppressor therefore + # advances to the declaration rather than the hash, and the daemon is woken + # once per distinct declaration. The wedge timer, escalation count and + # write-deferral chain are cleared exactly as handle_paused_stale clears + # them, so an undeclared busy phase that had already started the timer does + # not resume its count the moment the declaration is lifted. Normal-mode + # pause tracking stays unwritten here, exactly as the idle away-mode handoff + # leaves it, because the daemon owns that bookkeeping. + key=$(window_key "$win") + rm -f "$since_file" "$escalation_file" + clear_write_tracking "$key" + declared="declared:$(fm_wake_signal_sig "$statusf" || true)" + if [ "$(cat "$STATE/.stale-$key" 2>/dev/null || true)" != "$declared" ]; then + fm_wake_append stale "$win" "stale: $win" || exit 1 + printf '%s' "$declared" > "$STATE/.stale-$key" + wake "stale: $win" + fi + return 0 + fi handle_paused_stale "$win" "$task" "$h" return 0 fi @@ -1264,28 +1311,23 @@ while :; do fi if [ -n "$out" ]; then reason="check: $c: $out" - if [ "$is_pr_poll" -eq 1 ] && [ "$out" = merged ] \ - && fm_pr_poll_merge_already_notified "$STATE" "$id" \ - "$provider" "$host" "$path" "$number"; then - # This exact merge was already surfaced to main once for this task - # (fm_pr_poll_merge_mark_notified below records that at first - # notification, and it survives a later re-registered poll for the - # same, already-merged task - bin/fm-pr-lib.sh owns why). A repeat - # identical detection is a no-op, not captain-facing progress - # (AGENTS.md section 8): absorb it rather than enqueue another - # main-blocking row, but still retire the poll so it stops firing. + if [ "$is_pr_poll" -eq 1 ] && [ "$out" = merged ]; then + merge_outcome_rc=0 + fm_merge_outcome_report "$FM_HOME" "$STATE" "$id" "$url" poll \ + || merge_outcome_rc=$? + if [ "$merge_outcome_rc" -ne 0 ]; then + triage_log "merge outcome for $id could not be recorded (rc=$merge_outcome_rc)" + exit 1 + fi retire_merged_pr_poll "$id" - triage_log "absorbed duplicate merged PR poll result for $id" touch "$STATE/.last-check" - continue + if [ "$FM_MERGE_OUTCOME_ALREADY_RECORDED" = true ]; then + triage_log "absorbed duplicate merged PR poll result for $id" + continue + fi + wake "$reason" fi fm_wake_append check "$c" "$reason" || exit 1 - if [ "$is_pr_poll" -eq 1 ] && [ "$out" = merged ]; then - fm_pr_poll_merge_mark_notified "$STATE" "$id" \ - "$provider" "$host" "$path" "$number" \ - || triage_log "merge notification receipt could not be recorded for $id" - retire_merged_pr_poll "$id" - fi touch "$STATE/.last-check" wake "$reason" fi @@ -1359,7 +1401,8 @@ EOF # Layer 1 backbone: pane staleness. Two consecutive identical hashes with no busy # signature means the crewmate finished, is waiting, or is wedged. Each distinct # stale hash is surfaced, absorbed, or timed toward escalation once (.stale-* - # remembers the hash already classified). + # remembers the hash already classified, or the declaration a busy pane's + # crossed turn bound already handed to the away-mode daemon). while IFS= read -r w; do kind=$(window_kind "$w") task=$(window_to_task "$w" "$STATE") diff --git a/docs/architecture.md b/docs/architecture.md index 57856bfcb1c..1021c532beb 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -27,16 +27,19 @@ A busy pane is otherwise exempt from staleness, but only until its latest `state A crew that declared an external wait (`paused:`) or a verified captain-held transfer is the one exception to that bound: its busy verdict supplies liveness while identifying the long-running foreground call as the declared wait, so it takes the bounded `FM_PAUSE_RESURFACE_SECS` recheck instead of a wedge escalation. Lifting the declaration restores the unchanged busy-pane wedge path, while a pane that is no longer busy returns to the existing idle declared-wait classification. That remaining wedge escalation is deliberately exempt from the working deferral above: the busy signal a re-check would read is the same one the bound has already stopped trusting, so deferring on it would only extend the bound by the allowance. +While away mode is active, a busy pane that crosses the bound under a declared wait is handed to the daemon as the plain wake identity instead of taking that recheck in the watcher, because the daemon owns triage there and a wake already decorated as a possible wedge would override the daemon's own declared-wait verdict; an undeclared busy pane past the bound still takes the wedge escalation in away mode. +That handoff is keyed on the declaration itself (the status log's signature) rather than on the pane capture, so a harness footer that ticks on every poll wakes the daemon once per declaration instead of once per poll, and it clears the wedge timer, escalation count, and worktree-write deferral exactly as the normal-mode absorber does, so an undeclared busy phase's timer does not resume when the declaration lifts. 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 row from every endpoint-recorded local secondmate home's durable wake queue without locking, consuming, or rewriting that foreign queue. Once that row reaches `FM_SECONDMATE_WAKE_STALL_SECS`, the primary appends one keyed `check` wake naming the mate, row sequence, and observed age; parent receipts and queued-key deduplication suppress repeats for the same row across watcher and handling crashes, while empty and younger 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 notification, idempotence, quiet-queue, and byte-for-byte foreign-row preservation guarantees. -When a canonical validated PR poll returns exactly `merged`, the watcher absorbs it only when the task's notification marker already binds that same canonical PR identity. -Otherwise it appends a durable notification, records the canonical identity in that marker (replacing a different PR previously recorded for the task), and publishes a private retirement receipt bound to the poll's registration, bytes, file identities, metadata, provider, URL, and task ID. +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. The retirement receipt makes poll cleanup safely retryable across restarts: fixed-path recovery revalidates the same evidence, removes the runnable check first, removes its registration and data sidecars, removes the receipt last, and preserves task metadata including `pr=` and `pr_head=`. A concurrent replacement remains armed, every non-merged or invalid observation remains unchanged, and retirement never performs task or persistent-secondmate cleanup. -`bin/fm-pr-lib.sh` owns the notification-marker and retirement-receipt formats plus their strict identity mechanics, while `bin/fm-watch.sh` owns duplicate absorption and the notifying path's queue-before-marker-before-retirement ordering. +`bin/fm-pr-lib.sh` owns the notification-marker and retirement-receipt formats plus their strict identity mechanics, [`bin/fm-merge-outcome-lib.sh`](../bin/fm-merge-outcome-lib.sh) owns role-routed publication, the local durable row, and marker ordering, and `bin/fm-watch.sh` owns immediate poll-result delivery and retirement. No-verb wakes, such as `working:` notes and bare turn-ended signals, are benign only when `bin/fm-crew-state.sh` reports positive evidence that the crew is still working: an actively running no-mistakes step attributed to that crew's current code, or an exact busy verdict from the semantic busy-state contract. A `kind=secondmate` task's status signal is the parent-directed reply stream and is never absorbed as provably working; only its bare turn-ended signal retains the ordinary absorb rule. A crew that declares `paused:` for a known external wait, or carries a verified `captain-held` transfer, is separately absorbed while idle and re-surfaced only on the longer pause cadence, rather than being treated as a possible wedge. @@ -238,7 +241,7 @@ Seeding is transactional: if validation, cloning, initialization, or registry up The same project may appear in multiple secondmate homes when their scopes differ, such as issue triage versus feature development. Secondmates are idle by default: after startup recovery reconciles only work already in their own home, an empty queue waits silently for routed tasks, and they never self-initiate surveys or audits. When called with `FM_HOME=` or when `FM_HOME` is already set to the active firstmate home, metadata-routed `fm-send.sh` requests to a live `kind=secondmate` use the live-charter-compatible `from-firstmate` carrier owned by `bin/fm-operational-input.sh`, so the secondmate returns terse answers through status lines and detailed answers through docs plus status pointers instead of replying only in its own chat. -The parent guards every marked request against a missing correlated report without reading the secondmate conversation; `bin/fm-pending-reply-lib.sh` owns the correlation, recovery, escalation, and retention contract. +The parent guards every reply-bearing marked request against a missing correlated report without reading the secondmate conversation; `bin/fm-pending-reply-lib.sh` owns the correlation, recovery, escalation, and retention contract, while `bin/fm-send.sh` owns the explicit fire-and-forget exception. Explicit backend-target sends and direct human typing stay unmarked, so captain intervention in a secondmate pane remains conversational. After seeding a secondmate, `fm-backlog-handoff.sh` validates the fleet-specific handoff, atomically delegates already-judged in-scope queued item moves to `tasks-axi mv`, and then sends a marked routed-work wake through the receiver's recorded endpoint. A durable move with a missing, failed, or unresolved wake is reported as failure rather than success; rerunning the same handoff recovers known-undelivered wake intent without moving the item again, while an unresolved delivery is never blindly resent. @@ -280,6 +283,9 @@ The helper requires a full canonical URL and rejects malformed URLs or repo over A `https://github.com///pull/` URL invokes `gh-axi pr merge --repo /`, defaults to `--squash`, and preserves explicit merge-method flags. A `https:////-/merge_requests/` URL (see [docs/gitlab-merge-watch.md](gitlab-merge-watch.md)) invokes `glab mr merge -R https:///`, so the instance comes from the URL, and adds no merge-method flag because the project's own merge method applies. That path merges only after one live read of the merge request confirms it is open, mergeable, conflict-free, with blocking discussions resolved and a successful pipeline at the current head, and it binds the merge to that verified head; recorded metadata is never the authority for those conditions because a rebase leaves it stale. +After either forge command returns, the script confirms the PR or MR is actually merged; an auto-merge-queued or unconfirmed request records no landed outcome and leaves its poll armed. +A confirmed merge leaves a durable role-routed outcome instead of living only in the merging agent's memory, and [`bin/fm-merge-outcome-lib.sh`](../bin/fm-merge-outcome-lib.sh)'s header owns its destination, shape, identity, normal-case deduplication, and at-least-once recovery. +The same emitter handles a merge firstmate performed and one its poll detected, while the watcher immediately delivers the emitter's local actionable poll row. Teardown is fail-closed for ship worktrees: dirty worktrees refuse, and committed work must be landed before the worktree is returned. [`bin/fm-teardown.sh`](../bin/fm-teardown.sh)'s header owns the landed-work proofs, PR-discovery fallback, and stale-lock recovery procedure. diff --git a/docs/calm.md b/docs/calm.md index 360151af399..bac41ae23d9 100644 --- a/docs/calm.md +++ b/docs/calm.md @@ -18,7 +18,7 @@ A mid-turn working note is assistant text in a message the model did not end its Hiding it removes the narration a model emits alongside its tool calls, while the genuine reply that ends a response stays visible. Text that is still streaming is never hidden, because suppressing it would also stop a genuine reply from streaming, so a working note is briefly visible before its row collapses. The narration is hidden only from the live transcript presentation, and remains in the message, model context, session storage, and `/export` artifacts. -The operational inputs remain ordinary user-role messages, while Pi's transcript layout renders their complete rows at zero height. +The operational inputs Calm classifies remain ordinary user-role messages, while Pi's transcript layout renders their complete rows at zero height. The session-start nudge remains on its existing non-displayed custom-message path. Outside Pi's same-name built-in override collision described below, Calm changes presentation only. diff --git a/docs/configuration.md b/docs/configuration.md index 91ae3712a42..fe735e17728 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -50,7 +50,11 @@ A no-change heartbeat outcome explicitly reported with `task=fleet` and `silent= Supervision is an easier job than the captain's own conversation, so the branch can run on a cheaper model than main. It is also an easier job than the captain's own conversation needs reasoning for, so the branch can run at a shallower effort than main as well. -The Pi `/supervision-model` command settles both in one flow: it opens Pi's own selector over the models that Pi reports with configured credentials and that this home's stored credentials let the isolated supervision branch resolve, plus a first "Follow main" entry, and then a second picker for the branch's reasoning effort. +The Pi `/supervision-model` command settles both in one flow: it opens a selector over the models that Pi reports with configured credentials and that this home's stored credentials let the isolated supervision branch resolve, plus a first "Follow main" entry, and then a second picker for the branch's reasoning effort. +In Pi's terminal TUI, the model step uses Pi's bounded scrolling list with its input and fuzzy filtering primitives, the same list primitive Pi's `/model` picker scrolls: typing filters the entries, "Follow main" stays the first entry whenever it still matches, and a long catalog scrolls inside the dialog instead of running off the terminal. +The non-TUI RPC, JSON, and print modes have no custom-component surface and keep Pi's generic selector without search, where terminal overflow does not apply. +The effort list is a handful of levels and stays on Pi's plain selector dialog. +Both picks change the supervision branch alone and never the captain's own conversation model or effort. It persists the model pick in gitignored `config/supervision-branch-model` and the effort pick in gitignored `config/supervision-branch-effort`, both under the effective Firstmate home, resolved from `FM_HOME`, then `FM_ROOT_OVERRIDE`, then the tracked code root derived from the extension path, or under `FM_CONFIG_OVERRIDE` when that test and specialized-setup override is present. Firstmate keeps no model catalog of its own; the list is the intersection of what Pi reports when the picker opens and what a fresh isolated branch runtime can run. A provider that exists only because an extension registered it inside the captain's session is not offered, while stored OAuth and API-key credentials retain their native credential type because Firstmate never copies, converts, installs, or overwrites credentials for the branch runtime. @@ -357,7 +361,7 @@ Secondmate homes inherit this file from the primary, so a secondmate's own crewm On session start the first mate detects what its required toolchain is missing or too old and lists each problem with either an exact install command or manual instructions. It installs automatically supported tools only after you say go; manual-only tools remain for you to install from the printed instructions. Required tools come in two parts: a universal toolchain every home needs regardless of backend, and a per-backend delta that follows the runtime backend actually resolved for this home. -The universal toolchain is node, git, gh with GitHub auth via `gh auth login`, no-mistakes v1.31.2 or newer, compatible gh-axi, chrome-devtools-axi, compatible atelier-axi, compatible tasks-axi per "Backlog backend" above, and compatible quota-axi. +The universal toolchain is node, git, gh with GitHub auth via `gh auth login`, no-mistakes v1.46.0 or newer, compatible gh-axi, chrome-devtools-axi, compatible atelier-axi, compatible tasks-axi per "Backlog backend" above, and compatible quota-axi. [`bin/fm-bootstrap.sh`](../bin/fm-bootstrap.sh) owns the axi-family floor policy and the gh-axi and atelier-axi floors, while [`bin/fm-tasks-axi-lib.sh`](../bin/fm-tasks-axi-lib.sh) and [`bin/fm-quota-axi-lib.sh`](../bin/fm-quota-axi-lib.sh) hold their own tools' floor constants. This section is the single owner of that universal toolchain list; backend guides' prerequisites point here and add only their backend-specific tools. In that list, no-mistakes runs the validation pipeline, gh-axi, chrome-devtools-axi, and atelier-axi cover GitHub, browser, and rich-review operations, and tasks-axi plus quota-axi back backlog mutations and quota-aware array dispatch. diff --git a/docs/pi-supervision-branch.md b/docs/pi-supervision-branch.md index 58d48284078..f1f04eb2122 100644 --- a/docs/pi-supervision-branch.md +++ b/docs/pi-supervision-branch.md @@ -53,6 +53,9 @@ The branch prompt frames mirrored text as context for judgment, never as instruc Stage one is unchanged: the bash watcher absorbs everything provably fine at zero token cost. Stage two is the branch's verdict on each handled event, reported through its `fm_branch_report` tool: `routine` merges without a follow-up turn, while `captain` merges with exactly one follow-up turn. The follow-up turn a `captain` verdict opens is itself the captain-visible outcome, so its merge note is delivered silently and never printed or rendered in Pi. +Because Pi gives the model only a custom message's `content`, that silent note normally carries both a relay instruction and the `branch-outcome` operational kind owned by `bin/fm-operational-input.sh` inside its own text. +This self-description lets main distinguish a new supervision outcome from its own earlier captain-facing answer; without it, main can mistake the outcome for that answer and re-emit the stale answer instead of relaying the outcome. +If envelope encoding fails, the note degrades to the same relay instruction as plain text rather than losing the outcome or opening another turn. A no-change heartbeat outcome explicitly reported with `task=fleet` and `silent=true` is also delivered silently with no rendered note, while every other `routine` outcome stays rendered with its sailboat prefix. The verdict criteria in the branch prompt mirror the captain-etiquette escalation list; doubt escalates. Main can read the durable outcome store on demand through its `fm_branch_outcomes` tool. @@ -85,6 +88,6 @@ What is new is only the attended path: outside away mode, the branch absorbs the ## Verification -Portable regressions: `tests/fm-pi-branch-extension.test.sh` (dispatch, default-on eligibility, main-only classification, eligible-row claim lifecycle, partial pre-drain recheck, fallback, filter, mirror, cache key, persistence, model pin, effort pin), `tests/fm-branch-supervision.test.sh` (prompt stability, store append-only, leases, guards, non-branch-home invariance), the branch-offer, heartbeat-offer, heartbeat-not-ridden-by-a-check, and main-only-check-class tests in `tests/fm-pi-watch-extension.test.sh`, the recovery test in `tests/fm-session-start.test.sh`, and the per-actor consume regression in `tests/fm-wake-queue.test.sh`. -Live guard: `FM_PI_BRANCH_LIVE_E2E=1 tests/fm-pi-branch-live-e2e.test.sh` exercises the real installed Pi SDK with no user credentials and no provider call; run it after every Pi upgrade and record the dated result in [docs/verification/runtime-backends.md](verification/runtime-backends.md). +Portable regressions: `tests/fm-pi-branch-extension.test.sh` (dispatch, default-on eligibility, main-only classification, eligible-row claim lifecycle, partial pre-drain recheck, fallback, filter, mirror, model-visible captain-outcome typing and plain-instruction fallback, cache key, persistence, model pin and searchable picker, effort pin), `tests/fm-branch-supervision.test.sh` (prompt stability, store append-only, leases, guards, non-branch-home invariance), the branch-offer, heartbeat-offer, heartbeat-not-ridden-by-a-check, and main-only-check-class tests in `tests/fm-pi-watch-extension.test.sh`, the recovery test in `tests/fm-session-start.test.sh`, and the per-actor consume regression in `tests/fm-wake-queue.test.sh`. +Live guard: `FM_PI_BRANCH_LIVE_E2E=1 tests/fm-pi-branch-live-e2e.test.sh` exercises the real installed Pi SDK's custom-message conversion and branch-session surfaces with no user credentials and no provider call; run it after every Pi upgrade and record the dated result in [docs/verification/runtime-backends.md](verification/runtime-backends.md). The strict typecheck in `tests/fm-pi-primary-types.test.sh` pins the extension against the installed Pi package. diff --git a/docs/remote-secondmates.md b/docs/remote-secondmates.md index 891f85cf07f..3099854056d 100644 --- a/docs/remote-secondmates.md +++ b/docs/remote-secondmates.md @@ -162,6 +162,9 @@ Backends that already refuse secondmate launch, currently Orca and cmux, remain Startup liveness recovery relaunches a dead or missing remote second mate through this same command, so recovery passes the same readiness gate rather than a weaker one. +A persistent remote route's parent metadata intentionally has no local spawn-generation marker and identifies the route by its recorded host instead. +The Bearings inventory-reconcile hook therefore accepts these markerless routes, revalidates the sampled host at delivery, and refuses a route that changed hosts; [`fm-secondmate-reconcile.sh`](../bin/fm-secondmate-reconcile.sh) owns the exact cooldown, identity, and reporting contract. + Send routed requests normally: ```sh @@ -170,10 +173,10 @@ FM_HOME= bin/fm-send.sh fm- '' The [`fm-send.sh` header](../bin/fm-send.sh) owns the exact delivery-status contract. A routed request is delivered as a durable record in the remote home's steering inbox plus a best-effort doorbell, never by typing the payload into the pane; exit 0 means the record durably exists. -An unconfirmed transport (SSH exit 255) is retried identically once and preserves a marked request's pending-reply expectation for the record that may have landed. +An unconfirmed transport (SSH exit 255) is retried identically once and preserves this ordinary reply-bearing request's pending-reply expectation for the record that may have landed. If it remains unconfirmed, only the exact `FM_PENDING_REPLY_EXISTING_CORR=` resend command printed by `fm-send` is safe to run later because it preserves the request body and lets the remote enqueue deduplicate onto the same record; a plain rerun mints a different correlation and is not idempotent. When deduplication finds that the worker already moved the matching record into `handled/`, the resend exits successfully without ringing the doorbell again. -The remote host runs no doorbell re-ring ladder of its own; a swallowed remote doorbell surfaces through the parent's pending-reply recovery and escalation, whose recovery request rings the doorbell again when it is enqueued. +The remote host runs no doorbell re-ring ladder of its own; a swallowed doorbell for an ordinary reply-bearing request surfaces through the parent's pending-reply recovery and escalation, whose recovery request rings the doorbell again when it is enqueued. `fm-peek.sh` and `fm-crew-state.sh` route remote-secondmate reads to the endpoint's host instead of consulting local worktree or backend state. An unreachable or unreadable remote read is unknown, not evidence that the endpoint is dead. @@ -243,6 +246,7 @@ The lifecycle test covers seeding a registered project that this machine has nev ```sh bin/fm-test-run.sh tests/fm-on.test.sh bin/fm-test-run.sh tests/fm-send-remote-delivery.test.sh +bin/fm-test-run.sh tests/fm-secondmate-reconcile.test.sh bin/fm-test-run.sh tests/fm-peek-remote.test.sh bin/fm-test-run.sh tests/fm-crew-state.test.sh bin/fm-test-run.sh tests/fm-remote-job.test.sh diff --git a/docs/scripts.md b/docs/scripts.md index 55717756b5b..7110190d9f1 100644 --- a/docs/scripts.md +++ b/docs/scripts.md @@ -18,6 +18,7 @@ The shared no-mistakes gate refusal for fleet lifecycle entrypoints is summarize | `fm-fleet-view.sh` | Render the fleet snapshot as a human Markdown view | | `fm-bearings-snapshot.sh` | Project the fleet snapshot to the compact TOON bearings view; local-only unless `--include-prs` | | `fm-bearings-board.sh` | Build and arm the stable interactive `/bearings atelier` fleet board | +| `fm-secondmate-reconcile.sh` | Ask each secondmate to reconcile an inventory mismatch through its durable inbox, limited by a per-home cooldown | | `fm-update.sh` | Fast-forward-only self-update of firstmate and local or remote secondmate homes | | `fm-on.sh` | Execute one tracked Firstmate command in a configured remote secondmate home, using its job worker except for the doctor bootstrap | | `fm-remote-job-lib.sh` | Shared bounded remote job queue, worker readiness, LaunchAgent contract, and filesystem-composed PATH | @@ -115,6 +116,7 @@ The shared no-mistakes gate refusal for fleet lifecycle entrypoints is summarize | `fm-pr-check-migrate.sh` | Quarantine older task polls without execution and rebuild only canonical polls | | `fm-pr-check.sh` | Record validated `pr=` and `pr_head=` values, then atomically arm a static merge poll | | `fm-pr-merge.sh` | Record PR metadata, then merge a task's canonical full GitHub or GitLab URL | +| `fm-merge-outcome-lib.sh` | Publish a confirmed merge's durable, role-routed supervision outcome | | `fm-promote.sh` | Promote a scout task in place to a protected ship task with an explicit delivery mode | | `fm-teardown.sh` | Fail-closed teardown: return landed ship worktrees, require completed scout deliverables, retire secondmate homes | | `fm-harness.sh` | Detect the running harness and resolve crew or secondmate harness, model, and effort | diff --git a/docs/supervision-protocols/cursor.md b/docs/supervision-protocols/cursor.md index f0e496641c3..e8d1ac899c2 100644 --- a/docs/supervision-protocols/cursor.md +++ b/docs/supervision-protocols/cursor.md @@ -27,5 +27,5 @@ Re-arm attaches to an existing healthy cycle when one is already present and fol See [`watcher-continuity.md`](../watcher-continuity.md) for the arm-layer successor and clean-close failure contract. Exit status 2 is a silent no-op on Cursor's `stop` step, so this adapter never blocks a turn end and instead forces one bounded follow-up, which [`turnend-guard.md`](../turnend-guard.md) accepts as an equal alternative. -That document owns the double loop bound, the supersession contract, and the compatibility limits, including that a Cursor primary must be launched with `--trust` for its project hooks to load at all. +That document owns the double loop bound, the supersession contract, the Pi-host stand-down, and the compatibility limits, including that a Cursor primary must be launched with `--trust` for its project hooks to load at all. Cursor's `beforeSubmitPrompt` step fires once for a real captain message and not for hook-driven follow-ups, so it could invalidate the baton at the start of this window, but that registration is deliberately deferred alongside the `preCompact` surface. diff --git a/docs/turnend-guard.md b/docs/turnend-guard.md index de9b5ed922e..c9852326f7a 100644 --- a/docs/turnend-guard.md +++ b/docs/turnend-guard.md @@ -113,6 +113,10 @@ Cursor cannot block a turn end at all: its blocked-response mapper returns an em Cursor runs that hook synchronously and awaits it, so one script owns both halves of the boundary. While supervision is needed it PARKS: it runs `bin/fm-watch-arm.sh` as its own tracked child, holds the boundary open until the watcher closes, and returns an actionable close as one `watcher`-kind follow-up, spending no model tokens while parked. This is the same between-turns shape as Claude's Stop auto-arm, so `fm_supervision_model` classifies Cursor as `autoarm` and the mid-turn pull guard accepts a fresh beacon without a live watcher. +The park stands down without arming when `PI_CODING_AGENT=true` and neither `CURSOR_AGENT` nor `CURSOR_INVOKED_AS` is set. +Pi-with-Cursor-provider sessions (pi-cursor-sdk) load project `.cursor/hooks.json` into the Pi process, and a Cursor park there would race Pi's extension-owned `fm_watch_arm_pi` continuity, resurface rearm wakes, and abort in-flight asks. +`fm-spawn`'s cursor launch clears `PI_CODING_AGENT`; a hand-started cursor-agent may still inherit it. +When either Cursor identity marker is present, the park still runs despite a leaked `PI_CODING_AGENT`. When the park cannot establish a cycle it asks this shared guard with `--cursor` and renders a returned exit 2 as one bounded `turn-end-guard` follow-up, capped by `FM_CURSOR_TURNEND_BLOCK_BUDGET` (default 3) consecutive unproductive nags per session; a delivered wake resets that budget because it is productive work. The follow-up loop is bounded TWICE, because either bound alone is insufficient. `loop_limit` in `.cursor/hooks.json` is Cursor's own ceiling and the only one that still holds if the adapter is broken or replaced: once `loop_count` reaches it Cursor stops invoking the hook, verified live. @@ -156,7 +160,7 @@ That warning uses `bin/fm-supervision-instructions.sh --repair-line`, so it alwa `tests/fm-turnend-guard.test.sh` covers the predicate, main and secondmate primary scope, child-worktree exclusion, `FM_HOME` and `FM_STATE_OVERRIDE` precedence, the live-lock and fresh-beacon guard predicate, the cooperative `--claude` claim wait, monotonic failed-epoch progression, bounded attended fail-open, post-alarm continuation suppression, positive recovery reset, the abandoned auto-arm claim cases that must block or clear instead of allowing a blind stop, Pi logical-run latching, missing-`jq` behavior, all five primary registrations, Grok native and legacy selection, typed field precedence, malformed input, and exactly-one-path safety. `tests/fm-guard-stale-banner.test.sh` covers the pull-guard predicate, including the persistent-model fresh-leftover-beacon negative control, the auto-arm model's healthy fresh-beacon-without-a-watcher case and stale-beacon alarm, and the extension model's live-watcher path, ownership-qualified fresh hand-off, held-lock failures, independently broken ownership signals, stale-beacon alarm, queued-wake warning, and Pi and pi-signed harness routing. It also covers true-reason banner wording and reason-keyed episode dedup surviving a beacon mtime change. -`tests/fm-cursor-primary.test.sh` covers the Cursor park end to end over real processes with no harness installed: each tracked Claude-shaped entrypoint standing down on a Cursor payload, both follow-up sources, the bounded repair nag and its reset, the nested loop bounds, supersession, away-mode and lock-ownership inertness, child-worktree exclusion, and that the adapter never exits 2. +`tests/fm-cursor-primary.test.sh` covers the Cursor park end to end over real processes with no harness installed: each tracked Claude-shaped entrypoint standing down on a Cursor payload, both follow-up sources, the bounded repair nag and its reset, the nested loop bounds, supersession, away-mode and lock-ownership inertness, Pi-host stand-down without Cursor identity and continued parking when `PI_CODING_AGENT` leaks alongside `CURSOR_AGENT` or `CURSOR_INVOKED_AS`, child-worktree exclusion, and that the adapter never exits 2. `FM_CURSOR_PRIMARY_LIVE_E2E=1 tests/fm-cursor-primary-live-e2e.test.sh` is the opt-in guard that proves the same behavior against the installed cursor-agent and fails naming the harness and version. `tests/fm-kimi-harness.test.sh` covers the separate Kimi crew hook's format preservation, idempotence, refusal cases, token guard, spawn registration, and teardown cleanup. `tests/fm-supervision-instructions.test.sh` covers recovery-line ownership and pi-signed's identity-preserving reuse of Pi's protocol. diff --git a/docs/verification/runtime-backends.md b/docs/verification/runtime-backends.md index 0b41062a155..ec9c95911e1 100644 --- a/docs/verification/runtime-backends.md +++ b/docs/verification/runtime-backends.md @@ -946,6 +946,7 @@ FM_HARNESS_LIVENESS_DRIFT=1 bin/fm-test-run.sh tests/fm-harness-liveness-drift-l ## Pi supervision branch The supervision-branch extension (`.pi/extensions/fm-branch-supervision.ts`, [docs/pi-supervision-branch.md](../pi-supervision-branch.md)) builds its persistent second session through the Pi SDK surface: `createAgentSession` (including its `model`, `modelRuntime`, and `thinkingLevel` options), `DefaultResourceLoader` with `extensionFactories`, `SessionManager`, `createBashToolDefinition` with a `spawnHook`, `sendCustomMessage`, the `before_provider_request` hook, the command context's model registry for picker candidates, a fresh `ModelRuntime` for isolated-branch resolution, and Pi's own `getSupportedThinkingLevels`/`clampThinkingLevel` plus its `getThinkingLevel` and `thinking_level_select` extension surface for effort. +In TUI mode, its `/supervision-model` model list is drawn with Pi's own `SelectList`, `Input`, `fuzzyFilter`, and `DynamicBorder` through the extension context's `ui.custom` surface, which is what bounds and searches a long catalog. Evidence produced 2026-08-25 on macOS 26.5.2 arm64, Node v24.13.1: @@ -958,8 +959,12 @@ Evidence produced 2026-08-25 on macOS 26.5.2 arm64, Node v24.13.1: Over its own local never-contacted provider it confirms that `getSupportedThinkingLevels` still returns `["off","minimal","low","medium","high","xhigh","max"]` for a model mapping every extended level, narrows to `["off","minimal","low","medium","high"]` for a reasoning model mapping none, returns `["off"]` for a non-reasoning model, and that `clampThinkingLevel` lowers `max` to `high` on the narrow model while collapsing an unrecognized token to `off` - which is why the extension rejects an unrecognized pin before that clamp can see it. It then proves through `session.thinkingLevel` that an explicit effort is applied on create, that a reopened session with no override restores its own recorded level, that an explicit effort beats that recorded level, and that an over-ceiling effort is clamped rather than refused. The recorded-level cases need a session file Pi will actually restore from, and Pi flushes one only once an assistant message exists, so the guard appends the level change and that message through the real `SessionManager` rather than hand-writing the format. -- Strict typecheck: `tests/fm-pi-primary-types.test.sh` printed `ok - tracked Pi extensions pass strict no-emit typecheck against Pi 0.81.1` with the branch extension and dispatch lib included. +- Picker primitives: on 2026-08-26, after the final portable-shell and sentinel fixes, `bin/fm-test-run.sh tests/fm-pi-branch-extension.test.sh` again printed `ok - the installed Pi still bounds the picker's list and ranks its search` against the same installed 0.81.1 package. + That case imports the real `SelectList`, `Input`, `fuzzyFilter`, and `DynamicBorder`, renders a 42-row catalog through the real `SelectList` at the visible bound the extension asks for, and fails naming the installed version if Pi stops exporting a primitive or stops bounding what it renders; it skips when no npm package is installed, and the portable stubbed cases in the same file hold the ordering, search, and branch-only-pin behavior everywhere. +- Strict typecheck: `tests/fm-pi-primary-types.test.sh` printed `ok - tracked Pi extensions pass strict no-emit typecheck against Pi 0.81.1` with the branch extension and its imported libraries included. This typecheck is also the enforcement for the extension's declared effort vocabulary: its bidirectional assertion against Pi's own `getThinkingLevel` return type fails the moment Pi adds or removes a thinking level, so the runtime list used to reject an unrecognized hand-edited pin cannot drift into a stale Firstmate catalog. +- Custom-message provider conversion: on 2026-08-26, `FM_PI_BRANCH_LIVE_E2E=1 bin/fm-test-run.sh tests/fm-pi-branch-live-e2e.test.sh` against installed `@earendil-works/pi-coding-agent` 0.84.1 printed `ok - real Pi SDK 0.84.1 delivers a custom message to the provider as user text carrying only content, so the captain outcome's typed envelope is what reaches the model`. + The guard passes a typed captain outcome and a plain rendered routine note through Pi's exported `convertToLlm`, proves that `customType` and `display` are not model-visible identity, and classifies the resulting provider text with `bin/fm-operational-input.sh`. Scope of this evidence: the installed signed `pi` CLI (0.82.0 at verification time) is a compiled binary whose bundled SDK is not importable from Node, so the importable npm package is the only surface the guard and the typecheck can pin. -The extension executes inside the signed CLI's own runtime, so a CLI upgrade can drift ahead of the pinned npm surface; refresh this record after every Pi upgrade by re-running both commands above (point `FM_PI_PACKAGE_DIR` at a matching npm install when one exists) and by watching the branch's own fallback line - every branch failure degrades to the pre-branch wake-to-main path by construction, which `tests/fm-pi-branch-extension.test.sh` holds with a broken generator and the live guard holds with the real SDK. +The extension executes inside the signed CLI's own runtime, so a CLI upgrade can drift ahead of the pinned npm surface; refresh this record after every Pi upgrade by re-running the live guard, picker regression, and strict typecheck above (point `FM_PI_PACKAGE_DIR` at a matching npm install when one exists) and by watching the branch's own fallback line - every branch failure degrades to the pre-branch wake-to-main path by construction, which `tests/fm-pi-branch-extension.test.sh` holds with a broken generator and the live guard holds with the real SDK. diff --git a/docs/watcher-continuity.md b/docs/watcher-continuity.md index 26738628fea..0655085ce28 100644 --- a/docs/watcher-continuity.md +++ b/docs/watcher-continuity.md @@ -9,7 +9,7 @@ Pi's `.pi/extensions/fm-primary-pi-watch.ts` and OpenCode's `.opencode/plugins/f 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`. -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 loop bounds and supersession baton. +Cursor's `.cursor/hooks.json` `stop` hook (`bin/fm-turnend-guard-cursor.sh`) owns routine tokenless re-arm for a Cursor primary by parking that awaited hook on `bin/fm-watch-arm.sh` and returning an actionable close as one follow-up; [`turnend-guard.md`](turnend-guard.md#harness-integrations) owns its Pi-host stand-down, loop bounds, and supersession baton. Claude's `.claude/settings.json` Stop `asyncRewake` hook (`bin/fm-claude-stop-autoarm.sh`) owns routine tokenless re-arm. The hook fires on every Stop, and an eligible primary with supervision need admits one home-scoped owner that foregrounds `bin/fm-watch-arm.sh` inside the hook-owned process tree. A numeric session-lock owner that fails the shared `fm_harness_pid_alive` predicate is reclaimed through `bin/fm-lock.sh` before auto-arm state changes, while a live owner, absent lock, or malformed lock keeps the competing hook inert. diff --git a/tests/assets/board-render-harness.mjs b/tests/assets/board-render-harness.mjs new file mode 100644 index 00000000000..e21a8d2dd5d --- /dev/null +++ b/tests/assets/board-render-harness.mjs @@ -0,0 +1,117 @@ +// Render a built bearings board's shipped inline script under a minimal DOM +// shim and print what the renderer actually produced, so board behavior is +// asserted through the real template rather than by reading its source. +// +// Usage: node board-render-harness.mjs +// Prints one JSON document: { stats:[{n,label}], charted:[{title,sub,badges,pickable}] } +import { readFileSync } from "node:fs"; + +const html = readFileSync(process.argv[2], "utf8"); + +class Node { + constructor(tag) { + this.tagName = tag; + this.className = ""; + this.children = []; + this.attributes = {}; + this._text = ""; + this.hidden = false; + this.disabled = false; + this.innerHTML = ""; + this.parentNode = null; + this.type = ""; + this.value = ""; + this.checked = false; + this.classList = { + add: (c) => { this.className = (this.className + " " + c).trim(); }, + contains: (c) => this.className.split(/\s+/).includes(c), + }; + } + get textContent() { + return this.children.length + ? this.children.map((c) => c.textContent).join("") + : this._text; + } + set textContent(v) { this._text = String(v); this.children = []; } + appendChild(n) { n.parentNode = this; this.children.push(n); return n; } + setAttribute(k, v) { this.attributes[k] = v; } + addEventListener() {} + querySelectorAll(sel) { + const want = sel.replace(/^\./, "").replace(/:checked$/, ""); + const checkedOnly = sel.endsWith(":checked"); + const out = []; + const walk = (n) => { + for (const c of n.children) { + if (c.className.split(/\s+/).includes(want) && (!checkedOnly || c.checked)) out.push(c); + walk(c); + } + }; + walk(this); + return out; + } +} + +const byId = new Map(); +const dataNode = new Node("script"); +dataNode.textContent = html + .split('")[0]; +byId.set("bearings-data", dataNode); + +globalThis.document = { + createElement: (tag) => new Node(tag), + // Lazily mint any element the page asks for: the shim tracks whatever ids + // the shipped template actually uses instead of pinning a fixed list. + getElementById: (id) => { + if (!byId.has(id)) { + const n = new Node("div"); + new Node("div").appendChild(n); + byId.set(id, n); + } + return byId.get(id); + }, + querySelector: (sel) => { + const id = "sel:" + sel; + if (!byId.has(id)) byId.set(id, new Node("div")); + return byId.get(id); + }, +}; +globalThis.window = {}; +globalThis.TextEncoder = TextEncoder; + +const script = html.slice(html.indexOf("")); +new Function(script)(); + +const badgesOf = (row) => + row.children + .filter((c) => c.className.includes("fm-badge")) + .map((c) => ({ tone: c.className.replace(/.*fm-badge--/, "").trim(), text: c.textContent })); + +const strip = byId.get("bb-stats") || new Node("div"); +const stats = strip.children.map((t) => ({ + n: Number(t.children.find((c) => c.className.includes("bb-stat__num"))?.textContent), + label: t.children.find((c) => c.className.includes("bb-stat__label"))?.textContent, +})); + +const ch = byId.get("bb-charted") || new Node("div"); +const charted = ch.children + .filter((r) => r.className.split(/\s+/).includes("bb-row")) + .map((row) => { + const main = row.children.find((c) => c.className.includes("bb-row__main")); + return { + title: main?.children.find((c) => c.className.includes("bb-row__title"))?.textContent ?? "", + sub: main?.children.find((c) => c.className.includes("bb-row__sub"))?.textContent ?? "", + badges: badgesOf(row), + pickable: row.children.some((c) => c.className.includes("bb-pick") && !c.className.includes("spacer")), + }; + }); +// A fail-closed render replaces the page body instead of the board sections, so +// surface it rather than reporting an empty board as a successful render. +const errorText = [...byId.entries()] + .filter(([k]) => k.startsWith("sel:")) + .flatMap(([, n]) => n.children.map((c) => c.textContent)) + .join(" "); +const empty = ch.children.filter((c) => c.className.includes("bb-empty")).map((c) => c.textContent); +const more = ch.children.filter((c) => c.className.includes("bb-morechip")).map((c) => c.textContent); + +process.stdout.write(JSON.stringify({ stats, charted, empty, more, error: errorText }) + "\n"); diff --git a/tests/fm-bearings-board-render.test.sh b/tests/fm-bearings-board-render.test.sh new file mode 100755 index 00000000000..d203c297f2f --- /dev/null +++ b/tests/fm-bearings-board-render.test.sh @@ -0,0 +1,135 @@ +#!/usr/bin/env bash +# Behavior tests for the shipped bearings board renderer +# (.agents/skills/bearings/assets/board-template.html), exercised through a real +# `fm-bearings-board.sh build` and then executed under the minimal DOM shim in +# tests/assets/board-render-harness.mjs. The assertions are on what the page +# renders - row badges, the stat strip, the empty state - never on the +# template's source text. +set -u + +# shellcheck source=tests/lib.sh +# shellcheck disable=SC1091 +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" + +BOARD="$ROOT/bin/fm-bearings-board.sh" +HARNESS="$ROOT/tests/assets/board-render-harness.mjs" +TMP_ROOT=$(fm_test_tmproot fm-bearings-board-render) + +command -v jq >/dev/null 2>&1 || { echo "skip: jq not found"; exit 0; } +command -v node >/dev/null 2>&1 || { echo "skip: node not found"; exit 0; } + +make_home() { # + local home="$TMP_ROOT/$1" fakebin + mkdir -p "$home/state" "$home/data" + fakebin=$(fm_fakebin "$home") + fm_fake_exit0 "$fakebin" atelier-axi + printf '%s\n' "$home" +} + +# Build the board from and return what the renderer produced. +render() { # [charted_more] [charted_warning_more] + local home=$1 charted=$2 more=${3:-0} warning_more=${4:-0} data="$1/payload.json" + jq -n --argjson charted "$charted" --argjson more "$more" --argjson warning_more "$warning_more" '{ + schema:"fm-bearings-board.v1", home:"render-home", generated:"2026-08-26T00:00Z", + prs_live:false, captains_call:[], underway:[], landed:[], + charted:$charted, charted_more:$more, charted_warning_more:$warning_more}' > "$data" + PATH="$home/fakebin:$PATH" FM_HOME="$home" \ + FM_STATE_OVERRIDE="$home/state" FM_DATA_OVERRIDE="$home/data" \ + FM_PROCEVENT_CLAIM_ROOT="$home/procevent-claims" \ + "$BOARD" build "$data" >/dev/null || fail "the board did not build" + node "$HARNESS" "$home/.atelier/bearings-board.html" \ + || fail "the built board could not be rendered" +} + +charted_next_count() { # + printf '%s' "$1" | jq -r '.stats[] | select(.label == "charted next") | .n' +} + +test_a_warning_row_reads_as_a_repair_not_as_queued_work() { + local home out + home=$(make_home warning-badge) + out=$(render "$home" '[ + {"id":"real-queued","repo":"sample","title":"Queued work","reason":"queued behind the cutover","dispatchable":true}, + {"id":"main-inventory","repo":"sample","title":"Main inventory integrity","reason":"main inventory","dispatchable":false,"kind":"warning"} + ]') + printf '%s' "$out" | jq -e '.error == ""' >/dev/null \ + || fail "the board rendered its fail-closed error instead of the fleet: $out" + printf '%s' "$out" | jq -e ' + (.charted | length) == 2 + and (.charted[0] | .title == "Queued work" + and [.badges[] | .text] == ["waiting"] and .pickable == true) + and (.charted[1] | .title == "Main inventory integrity" + and [.badges[] | .text] == ["needs repair"] + and [.badges[] | .tone] == ["danger"] + and .pickable == false) + ' >/dev/null || fail "a warning row did not read differently from queued work: $out" + pass "a warning row badges needs repair while queued work keeps waiting" +} + +test_warnings_are_excluded_from_the_charted_next_count() { + local home out + home=$(make_home warning-count) + out=$(render "$home" '[ + {"id":"queued-one","repo":"sample","title":"One","reason":"gated","dispatchable":true}, + {"id":"warn-one","repo":"sample","title":"Home unreadable","reason":"current home state unavailable","dispatchable":false,"kind":"warning"}, + {"id":"warn-two","repo":"sample","title":"Inventory mismatch","reason":"main inventory","dispatchable":false,"kind":"warning"} + ]') + [ "$(charted_next_count "$out")" = 1 ] \ + || fail "the charted next tally counted alarms as queued work: $out" + printf '%s' "$out" | jq -e '(.charted | length) == 3' >/dev/null \ + || fail "excluding warnings from the count also dropped their rows: $out" + pass "the charted next count counts queued work only, and still renders warnings" +} + +test_a_board_of_only_warnings_still_reports_nothing_queued() { + local home out + home=$(make_home warning-only) + out=$(render "$home" '[ + {"id":"warn-only","repo":"sample","title":"Home unreadable","reason":"current home state unavailable","dispatchable":false,"kind":"warning"} + ]') + [ "$(charted_next_count "$out")" = 0 ] \ + || fail "a warning-only board claimed queued work: $out" + printf '%s' "$out" | jq -e ' + (.empty | length) == 1 and (.empty[0] | test("Nothing is queued")) + and (.charted | length) == 1 + ' >/dev/null || fail "a warning-only board hid the warning or the empty state: $out" + pass "a warning-only board reports nothing queued and still shows the warning" +} + +test_omitted_warnings_never_count_as_more_queued() { + local home out + home=$(make_home warning-more) + out=$(render "$home" '[ + {"id":"warn-visible","repo":"sample","title":"Home unreadable","reason":"current home state unavailable","dispatchable":false,"kind":"warning"} + ]' 0 1) + [ "$(charted_next_count "$out")" = 0 ] \ + || fail "an omitted warning was counted as queued work: $out" + printf '%s' "$out" | jq -e ' + (.empty | length) == 1 and (.empty[0] | test("Nothing is queued")) + and (.more == ["+1 more repair warning - ask firstmate for the full chart"]) + and ([.more[] | select(test("more queued"))] | length) == 0 + ' >/dev/null || fail "an omitted warning was labeled as more queued: $out" + pass "omitted warnings remain separate from omitted queued work" +} + +test_an_omitted_kind_keeps_the_existing_queued_rendering() { + local home out + home=$(make_home default-kind) + out=$(render "$home" '[ + {"id":"with-reason","repo":"sample","title":"With reason","reason":"blocked on prep","dispatchable":true}, + {"id":"no-reason","repo":"sample","title":"No reason","reason":"","dispatchable":true} + ]' 2) + [ "$(charted_next_count "$out")" = 4 ] \ + || fail "an omitted kind changed the charted next tally: $out" + printf '%s' "$out" | jq -e ' + ([.charted[0].badges[] | .text] == ["waiting"]) + and (.charted[1].badges == []) + ' >/dev/null || fail "an omitted kind changed the existing queued badges: $out" + pass "an omitted kind renders exactly as queued work always did" +} + +test_a_warning_row_reads_as_a_repair_not_as_queued_work +test_warnings_are_excluded_from_the_charted_next_count +test_a_board_of_only_warnings_still_reports_nothing_queued +test_omitted_warnings_never_count_as_more_queued +test_an_omitted_kind_keeps_the_existing_queued_rendering diff --git a/tests/fm-bearings-board.test.sh b/tests/fm-bearings-board.test.sh index 3d4ad65c456..2219f8bf6be 100644 --- a/tests/fm-bearings-board.test.sh +++ b/tests/fm-bearings-board.test.sh @@ -139,6 +139,21 @@ test_build_refuses_malformed_payloads_before_touching_the_board() { set +e; out=$(run_board "$home" build "$data" 2>&1); rc=$?; set -e [ "$rc" -ne 0 ] || fail "a charted row without a dispatchable boolean was accepted" + write_valid_payload "$data" + jq '.charted[0].kind = "alarm"' "$data" > "$data.tmp" && mv "$data.tmp" "$data" + set +e; out=$(run_board "$home" build "$data" 2>&1); rc=$?; set -e + [ "$rc" -ne 0 ] || fail "an unknown charted kind was accepted" + + write_valid_payload "$data" + jq '.charted[0].kind = "warning"' "$data" > "$data.tmp" && mv "$data.tmp" "$data" + set +e; out=$(run_board "$home" build "$data" 2>&1); rc=$?; set -e + [ "$rc" -ne 0 ] || fail "a dispatchable warning row was accepted" + + write_valid_payload "$data" + jq '.charted_warning_more = -1' "$data" > "$data.tmp" && mv "$data.tmp" "$data" + set +e; out=$(run_board "$home" build "$data" 2>&1); rc=$?; set -e + [ "$rc" -ne 0 ] || fail "a negative omitted-warning count was accepted" + write_valid_payload "$data" jq '.captains_call[0].type = "verdict"' "$data" > "$data.tmp" && mv "$data.tmp" "$data" set +e; out=$(run_board "$home" build "$data" 2>&1); rc=$?; set -e @@ -370,8 +385,28 @@ test_build_refuses_a_template_without_exactly_one_slot() { pass "build refuses a template without exactly one data slot" } +test_charted_kind_is_optional_and_accepts_both_values() { + local home data + home=$(make_home chartedkind) + data="$home/payload.json" + write_valid_payload "$data" + jq '.charted = [ + {"id":"a","repo":"sample","title":"Queued","reason":"","dispatchable":true}, + {"id":"b","repo":"sample","title":"Queued too","reason":"gated","dispatchable":true,"kind":"queued"}, + {"id":"c","repo":"sample","title":"Integrity notice","reason":"main inventory","dispatchable":false,"kind":"warning"} + ] | .charted_warning_more = 2' "$data" > "$data.tmp" && mv "$data.tmp" "$data" + run_board "$home" build "$data" >/dev/null \ + || fail "an omitted, queued, and warning charted kind was refused" + extract_payload "$home/.atelier/bearings-board.html" | jq -e ' + ([.charted[] | .kind // "queued"]) == ["queued", "queued", "warning"] + and .charted_warning_more == 2 + ' >/dev/null || fail "the built board did not carry the charted kinds and omitted-warning count it was given" + pass "charted kind is optional and accepts queued and warning" +} + test_path_is_stable_and_home_scoped test_build_refuses_malformed_payloads_before_touching_the_board +test_charted_kind_is_optional_and_accepts_both_values test_build_injects_binds_then_arms test_registration_cannot_consume_before_any_origin_binding test_build_does_not_bind_or_arm_when_session_start_fails diff --git a/tests/fm-bearings-snapshot.test.sh b/tests/fm-bearings-snapshot.test.sh index 91738e8fa8a..9f3e470fb9f 100755 --- a/tests/fm-bearings-snapshot.test.sh +++ b/tests/fm-bearings-snapshot.test.sh @@ -492,6 +492,8 @@ test_bad_secondmate_homes_never_revive_parent_work() { and (.secondmates | any(.[]; .id == "unreadable" and (.reason | test("invalid home|unreadable")))) and (.secondmates | any(.[]; .id == "malformed" and (.reason | contains("unstructured current backlog row")))) and (.secondmates | any(.[]; .id == "timedout" and (.reason | contains("timed out")))) + and ([.secondmate_reconcile[].id] == ["malformed"]) + and (.secondmate_reconcile[0].kind == "unstructured_current") ' >/dev/null || fail "bad home outcomes revived stale work or lacked provenance: $json" pass "missing, invalid, unreadable, malformed, and timed-out homes stay explicit unknowns" } @@ -735,10 +737,14 @@ EOF "$ROOT/bin/fm-fleet-snapshot.sh" --json) printf '%s' "$canonical" | jq -e ' .secondmate_current.records[] | select(.id == "states") - | .current.state == "unknown" + | .current.state == "captain_decision" and (.current.reason | contains("live child state has no in-flight backlog item")) and (.current.reason | contains("parked=parked")) - ' >/dev/null || fail "unowned held child was silently dropped: $canonical" + and .provenance.selected == "structured-home" + and .provenance.trust == "partial-structured" + and .invalidity == {kind:"unowned_current",ids:["parked"]} + and [.decisions_open[].key] == ["parked"] + ' >/dev/null || fail "unowned held child lost its classification or decisions: $canonical" cat > "$mate/data/backlog.md" <<'EOF' ## In flight - [ ] done - Done child still in flight (repo: sample) (kind: ship) (since 2026-07-11) @@ -763,11 +769,14 @@ EOF "$ROOT/bin/fm-fleet-snapshot.sh" --json) printf '%s' "$canonical" | jq -e ' .secondmate_current.records[] | select(.id == "states") - | .current.state == "unknown" + | .current.state == "no_active_work" and (.current.reason | contains("terminal child state")) and (.current.reason | contains("done=done")) and (.current.reason | contains("failed=failed")) - ' >/dev/null || fail "terminal in-flight child states were silently dropped: $canonical" + and .provenance.selected == "structured-home" + and .provenance.trust == "partial-structured" + and .invalidity == {kind:"terminal_in_flight",ids:["done","failed"]} + ' >/dev/null || fail "terminal in-flight rows discarded the readable home: $canonical" pass "nonprogressing child states are explicit and inconsistent terminal rows invalidate" } @@ -1751,15 +1760,15 @@ EOF .secondmate_current.records[] | select(.id == "sshhip") | .current.state == "unknown" and (.current.reason | contains("in-flight backlog item has no child metadata: ordinary-orphan")) - and .provenance.selected != "structured-home" - and .invalidity == null - and .active_children == [] - and .decisions_open == [] - and .holds == [] - and .queued == [] - and .landed == [] - and .endpoints == [] - ' >/dev/null || fail "an unknown child masked a simultaneous ordinary orphan: $canonical" + and .provenance.selected == "structured-home" + and .provenance.trust == "partial-structured" + and .invalidity == {kind:"orphan_in_flight",ids:["ordinary-orphan"]} + and [.decisions_open[].id] == ["reviewer-decision"] + and [.holds[].id] == ["reviewer-decision"] + and [.queued[].id] == ["reviewer-decision"] + and [.landed[].id] == ["prior-release"] + and [.endpoints[].id] == ["unreadable-child"] + ' >/dev/null || fail "an ordinary orphan discarded a readable home alongside an unknown child: $canonical" sed '/ordinary-orphan/d' "$sshhip/data/backlog.md" > "$sshhip/data/backlog.next" mv "$sshhip/data/backlog.next" "$sshhip/data/backlog.md" @@ -1771,15 +1780,14 @@ EOF .secondmate_current.records[] | select(.id == "sshhip") | .current.state == "unknown" and (.current.reason | contains("live child state has no in-flight backlog item: unreadable-child=unknown")) - and .provenance.selected != "structured-home" - and .invalidity == null - and .active_children == [] - and .decisions_open == [] - and .holds == [] - and .queued == [] - and .landed == [] - and .endpoints == [] - ' >/dev/null || fail "an unowned unknown child received partial structured projection: $canonical" + and .provenance.selected == "structured-home" + and .provenance.trust == "partial-structured" + and .invalidity == {kind:"unowned_current",ids:["unreadable-child"]} + and [.decisions_open[].id] == ["reviewer-decision"] + and [.holds[].id] == ["reviewer-decision"] + and [.queued[].id] == ["reviewer-decision"] + and [.landed[].id] == ["prior-release"] + ' >/dev/null || fail "an unowned unknown child discarded the readable home: $canonical" sed '/## In flight/a\ - [ ] unreadable-child - Submit App Store build (repo: sshhip) (kind: ship)' \ "$sshhip/data/backlog.md" > "$sshhip/data/backlog.next" @@ -1857,16 +1865,14 @@ EOF "$ROOT/bin/fm-fleet-snapshot.sh" --json) printf '%s' "$canonical" | jq -e ' .secondmate_current.records[] | select(.id == "hibit") - | .current.state == "unknown" + | .current.state == "active_child_work" and (.current.reason | contains("in-flight backlog item has no child metadata: dogfood-program")) - and .provenance.selected != "structured-home" - and .active_children == [] - and .decisions_open == [] - and .holds == [] - and .queued == [] - and .landed == [] - and .endpoints == [] - ' >/dev/null || fail "an unrecognized worker kind no longer stayed strict: $canonical" + and .provenance.selected == "structured-home" + and .provenance.trust == "partial-structured" + and .invalidity == {kind:"orphan_in_flight",ids:["dogfood-program"]} + and [.active_children[].id] == ["hibit-worker"] + and [.endpoints[].id] == ["hibit-worker"] + ' >/dev/null || fail "an unrecognized worker kind hid the home's live work: $canonical" pass "mixed secondmate roles, partial state, and captain readiness project independently" } diff --git a/tests/fm-bootstrap.test.sh b/tests/fm-bootstrap.test.sh index 63758fcb701..52179a5b318 100755 --- a/tests/fm-bootstrap.test.sh +++ b/tests/fm-bootstrap.test.sh @@ -79,7 +79,7 @@ SH cat > "$fakebin/no-mistakes" <<'SH' #!/usr/bin/env bash if [ "${1:-}" = --version ]; then - printf '%s\n' "${FM_FAKE_NO_MISTAKES_VERSION:-no-mistakes version v1.31.2 (fake) 2026-06-27T00:02:18Z}" + printf '%s\n' "${FM_FAKE_NO_MISTAKES_VERSION:-no-mistakes version v1.46.0 (fake) 2026-06-27T00:02:18Z}" exit 0 fi exit 0 @@ -332,10 +332,10 @@ test_no_mistakes_min_version() { [ "$out" = "$missing" ] || fail "$label: expected '$missing', got: $out" ;; esac done <<'ROWS' -minimum no-mistakes version is accepted^no-mistakes version v1.31.2 (fake)^empty -newer no-mistakes minor is accepted^no-mistakes version v1.32.0 (fake)^empty +minimum no-mistakes version is accepted^no-mistakes version v1.46.0 (fake)^empty +newer no-mistakes minor is accepted^no-mistakes version v1.47.0 (fake)^empty newer no-mistakes major is accepted^no-mistakes version v2.0.0 (fake)^empty -older no-mistakes patch reports an upgrade^no-mistakes version v1.31.1 (fake)^missing +older no-mistakes patch reports an upgrade^no-mistakes version v1.45.4 (fake)^missing unparseable no-mistakes version reports an upgrade^no-mistakes development build^missing ROWS pass "bootstrap enforces no-mistakes minimum version" diff --git a/tests/fm-brief.test.sh b/tests/fm-brief.test.sh index 2f1a8bf302a..7b2ca514a6e 100755 --- a/tests/fm-brief.test.sh +++ b/tests/fm-brief.test.sh @@ -581,8 +581,12 @@ test_secondmate_marked_request_reporting_contract() { "secondmate charter lost detailed document pointers" assert_grep 'Report only true captain-relevant outcomes or a declared external wait' "$brief" \ "secondmate charter lost declared external waits" - assert_grep 'a captain decision, a real blocker, a failure, or work ready for review' "$brief" \ - "secondmate charter lost decisions, blockers, failures, or ready outcomes" + assert_grep 'a captain decision, a real blocker, a failure, work ready for review, or work you landed' "$brief" \ + "secondmate charter lost decisions, blockers, failures, ready outcomes, or landed work" + # Under standing merge authority nothing is ever "ready for review", so the + # landed merge is the trigger a charter without this line silently omits. + assert_grep 'a merge you performed yourself under standing merge authority and one the captain merged on the forge' "$brief" \ + "secondmate charter did not name a landed merge as a reporting trigger" assert_grep 'States: working, needs-decision, blocked, paused, done, failed.' "$brief" \ "secondmate charter changed the preserved status vocabulary" pass "fm-brief.sh: marked requests avoid generic acknowledgements and preserve material reporting" diff --git a/tests/fm-cursor-primary.test.sh b/tests/fm-cursor-primary.test.sh index 98201297c82..fb872c520fd 100755 --- a/tests/fm-cursor-primary.test.sh +++ b/tests/fm-cursor-primary.test.sh @@ -144,14 +144,16 @@ PARK_CHILD=' ' # Run the park as a child of the fake cursor harness that holds the home lock. +# Clear PI_CODING_AGENT so a Pi host session running this suite cannot make the +# Cursor park stand down before the fixture under test is exercised. run_park() { # [loop_count] [loop_ceiling] local dir=$1 loop=${2:-0} ceiling=${3:-} payload payload=$(printf '{"session_id":"sess-cursor","generation_id":"gen-%s","loop_count":%s,"status":"completed","hook_event_name":"stop","cursor_version":"2026.08.11-e8db854"}' "$loop" "$loop") if [ -n "$ceiling" ]; then - printf '%s' "$payload" | FM_HOME="$dir" FM_CURSOR_PARK_POLL=1 \ + printf '%s' "$payload" | env -u PI_CODING_AGENT FM_HOME="$dir" FM_CURSOR_PARK_POLL=1 \ FM_CURSOR_TURNEND_LOOP_CEILING="$ceiling" "$FAKE_CURSOR" -c "$PARK_CHILD" 2>/dev/null else - printf '%s' "$payload" | FM_HOME="$dir" FM_CURSOR_PARK_POLL=1 \ + printf '%s' "$payload" | env -u PI_CODING_AGENT FM_HOME="$dir" FM_CURSOR_PARK_POLL=1 \ "$FAKE_CURSOR" -c "$PARK_CHILD" 2>/dev/null fi } @@ -469,6 +471,46 @@ test_park_inert_when_afk() { pass "cursor park: inert while away mode is active" } +test_park_inert_under_pi_coding_agent() { + local dir out payload + dir=$(make_primary_dir "$TMP_ROOT/park-pi-host") + : > "$dir/state/task1.meta" + write_arm_fixture "$dir" actionable + payload=$(printf '{"session_id":"sess-cursor","generation_id":"gen-0","loop_count":0,"status":"completed","hook_event_name":"stop","cursor_version":"2026.08.11-e8db854"}') + # No Cursor identity markers: Pi host alone must stand the park down. + out=$(printf '%s' "$payload" | env -u CURSOR_AGENT -u CURSOR_INVOKED_AS \ + FM_HOME="$dir" PI_CODING_AGENT=true FM_CURSOR_PARK_POLL=1 \ + "$FAKE_CURSOR" -c "$PARK_CHILD" 2>/dev/null) + [ -z "$out" ] || fail "Pi-hosted Cursor SDK must not park or wake: $out" + [ ! -e "$dir/state/arm-ran" ] || fail "the park armed under PI_CODING_AGENT=true" + pass "cursor park: inert when PI_CODING_AGENT marks a Pi host session" +} + +test_park_still_parks_with_pi_leak_and_cursor_identity() { + local dir out body payload + dir=$(make_primary_dir "$TMP_ROOT/park-pi-leak-cursor") + : > "$dir/state/task1.meta" + write_arm_fixture "$dir" actionable + payload=$(printf '{"session_id":"sess-cursor","generation_id":"gen-0","loop_count":0,"status":"completed","hook_event_name":"stop","cursor_version":"2026.08.11-e8db854"}') + # Hand-started cursor-agent may inherit PI_CODING_AGENT; Cursor identity wins. + out=$(printf '%s' "$payload" | env -u CURSOR_INVOKED_AS \ + FM_HOME="$dir" PI_CODING_AGENT=true CURSOR_AGENT=1 FM_CURSOR_PARK_POLL=1 \ + "$FAKE_CURSOR" -c "$PARK_CHILD" 2>/dev/null) + [ -e "$dir/state/arm-ran" ] || fail "CURSOR_AGENT must still park despite PI_CODING_AGENT leak" + [ "$(kind_of_followup "$out")" = watcher ] \ + || fail "CURSOR_AGENT park must deliver the wake despite PI leak: $out" + body=$(followup_of "$out") + case "$body" in *'stale: fixture-win needs a look'*) ;; *) fail "CURSOR_AGENT wake reason missing: $body" ;; esac + rm -f "$dir/state/arm-ran" + out=$(printf '%s' "$payload" | env -u CURSOR_AGENT \ + FM_HOME="$dir" PI_CODING_AGENT=true CURSOR_INVOKED_AS=cursor-agent FM_CURSOR_PARK_POLL=1 \ + "$FAKE_CURSOR" -c "$PARK_CHILD" 2>/dev/null) + [ -e "$dir/state/arm-ran" ] || fail "CURSOR_INVOKED_AS must still park despite PI_CODING_AGENT leak" + [ "$(kind_of_followup "$out")" = watcher ] \ + || fail "CURSOR_INVOKED_AS park must deliver the wake despite PI leak: $out" + pass "cursor park: parks when PI_CODING_AGENT leaks alongside Cursor identity" +} + test_park_stands_down_when_away_mode_activates_before_commit() { local dir park_pid out waited budget_count dir=$(make_primary_dir "$TMP_ROOT/park-afk-transition") @@ -507,7 +549,7 @@ test_park_inert_without_session_lock() { dir=$(make_primary_dir "$TMP_ROOT/park-nolock") : > "$dir/state/task1.meta" write_arm_fixture "$dir" actionable - out=$(printf '%s' "$CURSOR_PAYLOAD" | FM_HOME="$dir" bash "$dir/bin/fm-turnend-guard-cursor.sh" 2>/dev/null) + out=$(printf '%s' "$CURSOR_PAYLOAD" | env -u PI_CODING_AGENT FM_HOME="$dir" bash "$dir/bin/fm-turnend-guard-cursor.sh" 2>/dev/null) [ -z "$out" ] || fail "a session that does not hold the home lock must not arm or wake: $out" [ ! -e "$dir/state/arm-ran" ] || fail "the park armed without owning the session lock" pass "cursor park: inert when this session does not hold the home lock" @@ -556,9 +598,9 @@ test_park_ignores_malformed_payload() { dir=$(make_primary_dir "$TMP_ROOT/park-malformed") : > "$dir/state/task1.meta" write_arm_fixture "$dir" actionable - out=$(printf 'not json at all' | FM_HOME="$dir" bash "$dir/bin/fm-turnend-guard-cursor.sh" 2>/dev/null) + out=$(printf 'not json at all' | env -u PI_CODING_AGENT FM_HOME="$dir" bash "$dir/bin/fm-turnend-guard-cursor.sh" 2>/dev/null) [ -z "$out" ] || fail "a malformed payload must fail open, got: $out" - out=$(printf '{"loop_count":"three","cursor_version":"x"}' | FM_HOME="$dir" bash "$dir/bin/fm-turnend-guard-cursor.sh" 2>/dev/null) + out=$(printf '{"loop_count":"three","cursor_version":"x"}' | env -u PI_CODING_AGENT FM_HOME="$dir" bash "$dir/bin/fm-turnend-guard-cursor.sh" 2>/dev/null) [ -z "$out" ] || fail "a non-numeric loop_count must fail open, got: $out" pass "cursor park: malformed payloads fail open without arming" } @@ -653,6 +695,8 @@ test_park_stands_down_when_superseded test_park_serializes_supersession_with_followup_commit test_superseded_park_does_not_consume_nag_budget test_park_inert_when_afk +test_park_inert_under_pi_coding_agent +test_park_still_parks_with_pi_leak_and_cursor_identity test_park_stands_down_when_away_mode_activates_before_commit test_park_inert_without_session_lock test_park_stands_down_after_session_takeover diff --git a/tests/fm-inactive-reconcile.test.sh b/tests/fm-inactive-reconcile.test.sh index c4621194206..97c23b56564 100755 --- a/tests/fm-inactive-reconcile.test.sh +++ b/tests/fm-inactive-reconcile.test.sh @@ -411,6 +411,25 @@ test_full_scan_budget_includes_wake_lock_wait() { pass "aggregate scan budget includes durable wake operations" } +# A secondmate home seeded without its parent binding cannot report ANY terminal +# outcome upward, and every later one fails for the same reason. The diagnostic +# has to name the binding, or three weeks of identical failures read as three +# weeks of unrelated report failures. +test_missing_parent_binding_names_itself() { + local out + make_world missing-binding + printf 'mate\n' > "$MATE/.fm-secondmate-home" + write_child "$MATE" child 'done: PR merged' + out=$(FM_FAKE_CREW_STATE='done' run_reconcile "$MATE" --startup) + case "$out" in + *"actionable: inactive terminal outcome needs parent report"*".fm-secondmate-parent"*) ;; + *) fail "a missing parent binding did not name itself: $out" ;; + esac + [ "$(outcome_count "$MATE" reported)" = 0 ] \ + || fail "an outcome that never reached a parent was recorded as reported" + pass "a secondmate home with no parent binding names the missing binding instead of failing quietly" +} + test_notice_recovery_does_not_duplicate_wake() { local record err seq generation make_world notice-recovery; bind_secondmate remote @@ -459,6 +478,7 @@ test_watcher_hook_and_idle_secondmate_exemption test_stalled_state_read_is_bounded_and_scan_progresses test_full_scan_budget_includes_wake_lock_wait test_notice_recovery_does_not_duplicate_wake +test_missing_parent_binding_names_itself test_reconciliation_never_calls_forge echo "all inactive reconciliation tests passed" diff --git a/tests/fm-operational-input.test.sh b/tests/fm-operational-input.test.sh index 2d1b1c39de3..cdea6d0ed56 100755 --- a/tests/fm-operational-input.test.sh +++ b/tests/fm-operational-input.test.sh @@ -28,7 +28,7 @@ test_current_generic_matrix() { [ "$prefix_hex" = e281a346495253544d4154455f4f503a20 ] \ || fail "current operational prefix lost the landed U+2063 FIRSTMATE_OP bytes: $prefix_hex" - for kind in session-start watcher turn-end-guard away-supervisor launch-brief; do + for kind in session-start watcher turn-end-guard away-supervisor launch-brief branch-outcome; do body="CURRENT_BODY_FOR_${kind}" fm_operational_input_encode "$kind" "$body" encoded \ || fail "could not encode current $kind fixture" diff --git a/tests/fm-pi-branch-extension.test.sh b/tests/fm-pi-branch-extension.test.sh index 16fd0b8c852..bf95b4587db 100644 --- a/tests/fm-pi-branch-extension.test.sh +++ b/tests/fm-pi-branch-extension.test.sh @@ -27,6 +27,7 @@ install_pi_branch_extension_fixture() { "$repo/node_modules/typebox" cp "$EXT" "$repo/.pi/extensions/fm-branch-supervision.ts" cp "$ROOT/.pi/extensions/lib/fm-branch-dispatch.ts" "$repo/.pi/extensions/lib/fm-branch-dispatch.ts" + cp "$ROOT/.pi/extensions/lib/fm-branch-model-picker.ts" "$repo/.pi/extensions/lib/fm-branch-model-picker.ts" cp "$ROOT/.pi/extensions/lib/fm-calm-visibility.ts" "$repo/.pi/extensions/lib/fm-calm-visibility.ts" cp "$ROOT/.pi/extensions/lib/fm-operational-input.ts" "$repo/.pi/extensions/lib/fm-operational-input.ts" mkdir -p "$repo/bin" @@ -48,6 +49,16 @@ export function getMarkdownTheme() { export class UserMessageComponent {} +export class DynamicBorder { + constructor(color) { + this.color = color; + } + invalidate() {} + render() { + return ["--"]; + } +} + export class ModelRuntime { constructor() { this.models = (globalThis.__fmBranchStaticModels?.() ?? []).map((model) => ({ ...model })); @@ -228,6 +239,64 @@ export class Box extends Container { this.bgFn = bgFn; } } + +export class Input { + constructor() { + this.value = ""; + this.focused = false; + } + getValue() { + return this.value; + } + setValue(value) { + this.value = value; + } + handleInput(data) { + this.value = data === "\u007f" ? this.value.slice(0, -1) : this.value + data; + } + invalidate() {} + render() { + return [this.value]; + } +} + +// Records every construction so a driver can assert the rows and the visible +// bound the extension asked Pi's real SelectList for. Navigation keys arrive +// as their keybinding ids because the driver's fake keybindings manager +// matches an id against the raw key data. +export class SelectList { + constructor(items, maxVisible, theme) { + this.items = items; + this.maxVisible = maxVisible; + this.theme = theme; + this.selectedIndex = 0; + (globalThis.__fmPickerLists ??= []).push({ items: items.map((item) => ({ ...item })), maxVisible }); + } + handleInput(data) { + if (data === "tui.select.down") { + this.selectedIndex = Math.min(this.selectedIndex + 1, this.items.length - 1); + } else if (data === "tui.select.up") { + this.selectedIndex = Math.max(0, this.selectedIndex - 1); + } else if (data === "tui.select.confirm") { + const item = this.items[this.selectedIndex]; + if (item) this.onSelect?.(item); + } else if (data === "tui.select.cancel") { + this.onCancel?.(); + } + } + getSelectedItem() { + return this.items[this.selectedIndex] ?? null; + } + invalidate() {} + render() { + return this.items.slice(0, this.maxVisible).map((item) => item.label); + } +} + +export function fuzzyFilter(items, query, getText) { + const needle = query.toLowerCase(); + return items.filter((item) => getText(item).toLowerCase().includes(needle)); +} JS cat > "$repo/node_modules/typebox/package.json" <<'JSON' {"name":"typebox","type":"module","exports":"./index.js"} @@ -262,7 +331,8 @@ JS # Shared driver preamble: a fake main-session ExtensionAPI with a synchronous # event bus (mirrors pi's EventEmitter-backed bus), captured handlers, and # captured main-bound messages. -DRIVER_PRELUDE=$(cat <<'JS' +DRIVER_PRELUDE_FILE="$TMP_ROOT/driver-prelude.js" +cat > "$DRIVER_PRELUDE_FILE" <<'JS' const { spawnSync } = await import("node:child_process"); const { mkdirSync, writeFileSync } = await import("node:fs"); const { pathToFileURL } = await import("node:url"); @@ -326,6 +396,60 @@ function makeCtx(extra) { }; } +// A TUI-mode context whose ui.custom runs the extension's real picker +// component headlessly: the factory receives a fake renderer, a pass-through +// theme, and a keybindings manager that matches a keybinding id against the +// raw key data, and then the next queued keystroke script is fed to the +// component's own handleInput. Keystrokes are either a keybinding id +// (navigation) or literal characters (search). mainModelWrites records every +// attempt to move the captain's own model, which pinning the branch must +// never do. +const uiKeystrokes = []; +const mainModelWrites = []; +function makeTuiCtx(extra) { + const base = makeCtx(extra); + return { + ...base, + get model() { + return mainModel; + }, + mode: "tui", + settingsManager: { + setDefaultModelAndProvider(provider, id) { + mainModelWrites.push({ provider, id }); + }, + }, + setModel(provider, id) { + mainModelWrites.push({ provider, id }); + }, + ui: { + ...base.ui, + async custom(factory) { + let result; + let settled = false; + const component = await factory( + { + requestRender() {}, + }, + { fg: (_color, text) => text, bold: (text) => text }, + { matches: (data, id) => data === id }, + (value) => { + result = value; + settled = true; + }, + ); + component.render(80); + for (const key of uiKeystrokes.shift() ?? ["tui.select.confirm"]) { + if (settled) break; + component.handleInput(key); + } + if (!settled) throw new Error("the picker script ended without a selection or a cancellation"); + return result; + }, + }, + }; +} + const busHandlers = new Map(); const bus = { on(channel, handler) { @@ -411,7 +535,7 @@ function outcomeScript(args) { const mod = await import(pathToFileURL(process.env.PLUGIN).href); mod.default(pi); JS -) +DRIVER_PRELUDE=$(cat "$DRIVER_PRELUDE_FILE") test_branch_dispatch_two_stage_filter_and_prefix_contract() { local repo home out status @@ -521,6 +645,18 @@ if (!sentToMain[2].message.content.includes("task-9: PR https://example.com/pr/9 if (/branch merged|\[routine\]|\[captain\]/.test(sentToMain[2].message.content)) { throw new Error(`captain note still has boilerplate: ${sentToMain[2].message.content}`); } +// What main's model actually receives. Pi keeps only `content` when it turns a +// custom message into a provider message - customType, display, and details are +// all dropped - so `content` IS the delivered payload, and these two files are +// the exact bytes main's model would read. The bash side classifies them with +// the REAL bin/fm-operational-input.sh so the protocol's own executable, not a +// pattern in this test, decides what was delivered. Pi's half of that contract +// is proven separately against the real SDK in fm-pi-branch-live-e2e.test.sh. +writeFileSync(`${home}/state/delivered-captain-note`, sentToMain[2].message.content); +writeFileSync(`${home}/state/delivered-routine-note`, sentToMain[0].message.content); +if (sentToMain.filter((sent) => sent.options.triggerTurn).length !== 1) { + throw new Error("one captain outcome must open exactly one turn on main"); +} // The store (the owned durable contract) holds all three outcomes in order, // and each merged note advanced the read cursor. @@ -623,6 +759,83 @@ EOF *) fail "cache key line missing from driver output: $out" ;; esac pass "branch owns accepted wakes with a stable prefix contract and verdict-driven merge delivery" + + # The delivered captain payload must identify itself to main's model. When it + # did not, main could not tell an incoming outcome from its own earlier answer + # and re-emitted that answer instead of relaying the outcome, silently losing + # it. The real protocol executable is the oracle here: it decides the kind and + # extracts the body, so this asserts delivered behavior rather than a shape + # this test already knows. + local kind body + kind=$(./bin/fm-operational-input.sh kind < "$home/state/delivered-captain-note") \ + || fail "captain outcome reaches main's model as unattributed text the model cannot tell from its own answer" + [ "$kind" = branch-outcome ] \ + || fail "captain outcome delivered as kind '$kind', not branch-outcome" + body=$(./bin/fm-operational-input.sh body < "$home/state/delivered-captain-note") \ + || fail "captain outcome envelope carries no readable body" + case "$body" in + *"task-9: PR https://example.com/pr/9"*) ;; + *) fail "captain outcome body lost the outcome itself: $body" ;; + esac + case "$body" in + *"Relay only this outcome"*"Do not restate or repeat any earlier answer"*) ;; + *) fail "captain outcome body never tells main to relay it instead of repeating: $body" ;; + esac + # The routine note is rendered in the TUI, and its renderer reads the glyph off + # the front of this same string, so it must stay plain text. + if ./bin/fm-operational-input.sh kind < "$home/state/delivered-routine-note" >/dev/null 2>&1; then + fail "routine note must stay plain rendered text, not typed operational input" + fi + pass "a captain outcome reaches main's model as typed, self-describing input while routine notes stay plain" +} + +test_captain_outcome_encoding_failure_delivers_plain_instruction() { + local repo home out status + repo="$TMP_ROOT/encoding-fallback-root" + home="$TMP_ROOT/encoding-fallback-home" + mkdir -p "$home/state" "$home/config" + install_pi_branch_extension_fixture "$repo" + PLUGIN="$repo/.pi/extensions/fm-branch-supervision.ts" FM_HOME="$home" FM_ROOT_OVERRIDE="$ROOT" \ + FM_OPERATIONAL_INPUT_SCRIPT="$repo/bin/missing-operational-input" \ + DRIVER_PRELUDE="$DRIVER_PRELUDE" node --input-type=module > "$TMP_ROOT/node-output" 2>&1 <<'EOF' +const prelude = process.env.DRIVER_PRELUDE; +await eval(`(async () => { ${prelude}; globalThis.__t = { dispatch, settle, sentToMain }; })()`); +const { dispatch, settle, sentToMain } = globalThis.__t; + +if (!dispatch("signal: encoding fallback probe").accepted) { + throw new Error("branch did not accept the encoding-fallback wake"); +} +await settle(() => (globalThis.__fmPrompts ?? []).length === 1, "encoding-fallback branch prompt"); +const session = globalThis.__fmSessions[0]; +const report = session.options.customTools.find((tool) => tool.name === "fm_branch_report"); +const result = await report.execute( + "encoding-fallback", + { task: "task-fallback", verdict: "captain", summary: "PR https://example.com/pr/fallback is ready" }, + undefined, + undefined, + {}, +); +if (result.isError) throw new Error(`fallback report failed: ${JSON.stringify(result)}`); +if (sentToMain.length !== 1) throw new Error(`fallback delivered ${sentToMain.length} notes instead of one`); +const delivered = sentToMain[0]; +if (delivered.message.display !== false) throw new Error("fallback captain note became visible"); +if (delivered.options.triggerTurn !== true || delivered.options.deliverAs !== "followUp") { + throw new Error(`fallback changed turn delivery: ${JSON.stringify(delivered.options)}`); +} +if (delivered.message.content.includes("FIRSTMATE_OP:")) { + throw new Error(`fallback unexpectedly carried an envelope: ${delivered.message.content}`); +} +if (!delivered.message.content.includes("Relay only this outcome") || + !delivered.message.content.includes("Do not restate or repeat any earlier answer") || + !delivered.message.content.includes("task-fallback: PR https://example.com/pr/fallback is ready")) { + throw new Error(`fallback lost its instruction or outcome: ${delivered.message.content}`); +} +process.exit(0); +EOF + status=$? + out=$(cat "$TMP_ROOT/node-output") + expect_code 0 "$status" "captain outcome encoding failure must degrade to plain instructed delivery: $out" + pass "a broken operational encoder still delivers one invisible instructed captain outcome as a follow-up" } test_branch_cache_key_is_per_home_stable() { @@ -1646,6 +1859,135 @@ EOF pass "unpinned branches follow main effort changes live while pinned branches stay fixed" } +test_supervision_model_picker_is_bounded_searchable_and_branch_only() { + local repo home out status + repo="$TMP_ROOT/pickerux-root" + home="$TMP_ROOT/pickerux-home" + mkdir -p "$home/state" "$home/config" + install_pi_branch_extension_fixture "$repo" + PLUGIN="$repo/.pi/extensions/fm-branch-supervision.ts" FM_HOME="$home" FM_ROOT_OVERRIDE="$ROOT" \ + DRIVER_PRELUDE="$DRIVER_PRELUDE" node --input-type=module > "$TMP_ROOT/node-output" 2>&1 <<'EOF' +const prelude = process.env.DRIVER_PRELUDE; +await eval(`(async () => { ${prelude}; globalThis.__t = { fire, makeCtx, makeTuiCtx, commands, registryModels, uiSelections, uiKeystrokes, mainModelWrites, home }; })()`); +const { fire, makeCtx, makeTuiCtx, commands, registryModels, uiSelections, uiKeystrokes, mainModelWrites, home } = globalThis.__t; +import { readFileSync } from "node:fs"; + +// A catalog long enough that rendering it whole would run off a terminal, +// plus one distinctively named model to search for and one model the +// isolated branch runtime cannot run. +registryModels.push({ provider: "anthropic", id: "main-model" }); +for (let i = 1; i <= 30; i += 1) registryModels.push({ provider: "anthropic", id: `bulk-model-${i}` }); +registryModels.push({ provider: "openai-codex", id: "cheap-oauth", authKind: "oauth" }); +registryModels.push({ provider: "dynamic", id: "extension-only", branchAvailable: false }); + +const command = commands.get("supervision-model"); +if (!command) throw new Error("the supervision-model command was not registered"); +fire("session_start", {}, makeCtx()); + +// The captain types a search query and confirms the one row it leaves. +globalThis.__fmPickerLists = []; +uiKeystrokes.push(["c", "h", "e", "a", "p", "tui.select.confirm"]); +uiSelections.push(undefined); // the effort step is cancelled, leaving that choice alone +await command.handler("", makeTuiCtx()); + +const lists = globalThis.__fmPickerLists; +if (lists.length < 2) throw new Error(`typing a query must rebuild the list: ${JSON.stringify(lists.map((l) => l.items.length))}`); +const opened = lists[0]; +if (opened.maxVisible !== 10) { + throw new Error(`the model list must stay bounded rather than rendering every row: maxVisible=${opened.maxVisible}`); +} +if (opened.items[0].label !== "Follow main (anthropic/main-model)") { + throw new Error(`following main must be the first row: ${JSON.stringify(opened.items.slice(0, 2))}`); +} +if (opened.items.length !== 33) { + throw new Error(`the opened list must offer following main plus every branch-runnable model: ${opened.items.length}`); +} +if (opened.items.some((item) => item.label.includes("extension-only"))) { + throw new Error("the picker widened past the branch runtime's eligibility filter"); +} +const filtered = lists[lists.length - 1]; +if (filtered.items.length !== 1 || filtered.items[0].value !== "openai-codex/cheap-oauth") { + throw new Error(`the search query did not narrow the list: ${JSON.stringify(filtered.items)}`); +} + +// The pick lands on the supervision branch alone. +if (readFileSync(`${home}/config/supervision-branch-model`, "utf8") !== "openai-codex/cheap-oauth\n") { + throw new Error("the searched-for pick was not persisted as the supervision branch model"); +} +if (mainModelWrites.length !== 0) { + throw new Error(`pinning the branch moved the captain's own model: ${JSON.stringify(mainModelWrites)}`); +} +if (makeCtx().model.id !== "main-model") throw new Error("the captain's own conversation model changed"); + +// A query that matches following main keeps that row first, and escape +// leaves every choice standing. +globalThis.__fmPickerLists = []; +uiKeystrokes.push(["m", "a", "i", "n", "tui.select.cancel"]); +await command.handler("", makeTuiCtx()); +const mainQuery = globalThis.__fmPickerLists[globalThis.__fmPickerLists.length - 1]; +if (mainQuery.items[0].label !== "Follow main (anthropic/main-model)") { + throw new Error(`a matching query must keep following main first: ${JSON.stringify(mainQuery.items.slice(0, 2))}`); +} +if (mainQuery.items.length < 2) throw new Error("a matching query dropped the models it also matched"); +if (readFileSync(`${home}/config/supervision-branch-model`, "utf8") !== "openai-codex/cheap-oauth\n") { + throw new Error("cancelling the picker changed the standing pin"); +} +process.exit(0); +EOF + status=$? + out=$(cat "$TMP_ROOT/node-output") + expect_code 0 "$status" "the model picker must be bounded, searchable, and branch-only: $out" + pass "supervision-model opens a bounded searchable list, follow main first, and pins the branch alone" +} + +test_branch_model_picker_keeps_follow_main_first_under_ranking() { + local repo out status + repo="$TMP_ROOT/pickerlib-root" + mkdir -p "$repo/.pi/extensions/lib" + cp "$ROOT/.pi/extensions/lib/fm-branch-model-picker.ts" "$repo/.pi/extensions/lib/fm-branch-model-picker.ts" + LIB="$repo/.pi/extensions/lib/fm-branch-model-picker.ts" node --input-type=module > "$TMP_ROOT/node-output" 2>&1 <<'EOF' +import { pathToFileURL } from "node:url"; + +const { buildBranchModelItems, filterBranchPickerItems, BRANCH_PICKER_MAX_VISIBLE, FOLLOW_MAIN_VALUE } = await import( + pathToFileURL(process.env.LIB).href +); + +const items = buildBranchModelItems("Follow main (anthropic/main-model)", ["anthropic/main-model", "openai/mainly-cheap"], null); +if (items[0].value !== FOLLOW_MAIN_VALUE) throw new Error("following main must be built as the first row"); +if (items[0].description !== "current") throw new Error("an absent pin must mark following main as the current choice"); +if (items[2].description !== undefined) throw new Error("a model that is not pinned must not be marked current"); + +const pinned = buildBranchModelItems("Follow main (anthropic/main-model)", ["anthropic/main-model"], "anthropic/main-model"); +if (pinned[0].description !== undefined) throw new Error("a pinned branch must not mark following main as current"); +if (pinned[1].description !== "current") throw new Error("the pinned model must be marked as the current choice"); + +// A ranking filter is free to sort a better match ahead of following main; +// the picker must still show following main first whenever it matches. +const rankReversing = (list, query, getText) => + list.filter((item) => getText(item).toLowerCase().includes(query.toLowerCase())).reverse(); +const matched = filterBranchPickerItems(items, "main", rankReversing); +if (matched[0].value !== FOLLOW_MAIN_VALUE) { + throw new Error(`ranking moved following main out of first place: ${JSON.stringify(matched)}`); +} +if (matched.length !== 3) throw new Error(`a matching query dropped rows it should keep: ${JSON.stringify(matched)}`); + +const narrowed = filterBranchPickerItems(items, "openai", rankReversing); +if (narrowed.length !== 1 || narrowed[0].value !== "openai/mainly-cheap") { + throw new Error(`a query that excludes following main must drop it: ${JSON.stringify(narrowed)}`); +} +const unfiltered = filterBranchPickerItems(items, " ", rankReversing); +if (unfiltered.length !== items.length || unfiltered[0].value !== FOLLOW_MAIN_VALUE) { + throw new Error("an empty query must keep the built order"); +} +if (BRANCH_PICKER_MAX_VISIBLE !== 10) throw new Error("the picker must keep a bounded visible row count"); +process.exit(0); +EOF + status=$? + out=$(cat "$TMP_ROOT/node-output") + expect_code 0 "$status" "the picker's ordering and filtering must hold: $out" + pass "branch model picker keeps follow main first and filters the eligible catalog" +} + test_supervision_model_command_picks_effort_after_the_model() { local repo home out status repo="$TMP_ROOT/effortcmd-root" @@ -2180,6 +2522,7 @@ test_branch_dispatch_classifies_main_only_rows_and_writes_the_eligible_snapshot( home="$TMP_ROOT/dispatch-classify-home" mkdir -p "$repo/.pi/extensions/lib" "$home/state" "$home/projects/approved" cp "$ROOT/.pi/extensions/lib/fm-branch-dispatch.ts" "$repo/.pi/extensions/lib/fm-branch-dispatch.ts" + cp "$ROOT/.pi/extensions/lib/fm-branch-model-picker.ts" "$repo/.pi/extensions/lib/fm-branch-model-picker.ts" printf 'project=%s/projects/approved\nwindow=fm-window\n' "$home" > "$home/state/task-a.meta" LIB="$repo/.pi/extensions/lib/fm-branch-dispatch.ts" FM_HOME="$home" GRANT="$ROOT/bin/fm-wake-grant.sh" \ node --input-type=module > "$TMP_ROOT/node-output" 2>&1 <<'EOF' @@ -2323,6 +2666,98 @@ EOF pass "scopeForUnreadWake excludes every main-only class without vetoing eligible task-local rows, and writes the eligible snapshot" } +# The model picker's bounded scrolling and its search ranking are Pi's own +# SelectList and fuzzyFilter, so the guarantee only holds while the installed +# Pi still exports them and still bounds what it renders. Stubs cannot answer +# that, so this runs against the real package and skips when it is absent. +test_real_pi_picker_primitives_stay_bounded_and_searchable() { + if ! command -v node >/dev/null 2>&1; then + echo "skip: node not found for the Pi picker primitives test" + return + fi + local package_dir fixture original_dir out status + package_dir=${FM_PI_PACKAGE_DIR:-"$(npm root -g 2>/dev/null)/@earendil-works/pi-coding-agent"} + if [ ! -f "$package_dir/package.json" ]; then + echo "skip: installed @earendil-works/pi-coding-agent package not found" + return + fi + fixture="$TMP_ROOT/real-picker-primitives" + mkdir -p "$fixture/lib" "$fixture/node_modules/@earendil-works" + cp "$ROOT/.pi/extensions/lib/fm-branch-model-picker.ts" "$fixture/lib/fm-branch-model-picker.ts" + ln -s "$package_dir" "$fixture/node_modules/@earendil-works/pi-coding-agent" + ln -s "$package_dir/node_modules/@earendil-works/pi-tui" "$fixture/node_modules/@earendil-works/pi-tui" + original_dir=$PWD + cd "$fixture" || fail "could not enter the Pi picker primitives fixture" + LIB="$fixture/lib/fm-branch-model-picker.ts" PI_VERSION_FILE="$package_dir/package.json" \ + node --input-type=module > "$TMP_ROOT/node-output" 2>&1 <<'JS' +import { pathToFileURL } from "node:url"; +import { readFileSync } from "node:fs"; + +const version = JSON.parse(readFileSync(process.env.PI_VERSION_FILE, "utf8")).version; +const { Input, SelectList, fuzzyFilter } = await import("@earendil-works/pi-tui"); +const { DynamicBorder } = await import("@earendil-works/pi-coding-agent"); +for (const [name, value] of [ + ["Input", Input], + ["SelectList", SelectList], + ["fuzzyFilter", fuzzyFilter], + ["DynamicBorder", DynamicBorder], +]) { + if (typeof value !== "function") { + throw new Error(`installed pi ${version} no longer exports ${name}, which the supervision model picker renders through`); + } +} + +const { buildBranchModelItems, filterBranchPickerItems, BRANCH_PICKER_MAX_VISIBLE } = await import( + pathToFileURL(process.env.LIB).href +); +const labels = Array.from({ length: 40 }, (_, i) => `anthropic/bulk-model-${i + 1}`); +labels.push("openai-codex/cheap-oauth"); +const items = buildBranchModelItems("Follow main (anthropic/main-model)", labels, null); + +// Pi's own list renders a bounded window plus at most one scroll indicator, +// which is what keeps a long catalog inside the dialog. +const passthrough = (text) => text; +const list = new SelectList(items, BRANCH_PICKER_MAX_VISIBLE, { + selectedPrefix: passthrough, + selectedText: passthrough, + description: passthrough, + scrollInfo: passthrough, + noMatch: passthrough, +}); +const lines = list.render(80); +if (lines.length > BRANCH_PICKER_MAX_VISIBLE + 1) { + throw new Error(`installed pi ${version} rendered ${lines.length} rows for ${items.length} models instead of a bounded window`); +} +if (!lines[0].includes("Follow main")) { + throw new Error(`installed pi ${version} did not render the first row the picker opens on`); +} + +// Pi's own fuzzy ranking drives the search box, and following main stays first. +const searched = filterBranchPickerItems(items, "cheap", fuzzyFilter); +if (searched.length !== 1 || searched[0].value !== "openai-codex/cheap-oauth") { + throw new Error(`installed pi ${version} fuzzy search did not narrow the catalog: ${JSON.stringify(searched)}`); +} +const mainSearch = filterBranchPickerItems(items, "main", fuzzyFilter); +if (mainSearch.length === 0 || mainSearch[0].label !== "Follow main (anthropic/main-model)") { + throw new Error(`installed pi ${version} fuzzy ranking moved following main out of first place`); +} + +// The search box is Pi's own single-line input. +const input = new Input(); +input.handleInput("c"); +input.handleInput("h"); +if (input.getValue() !== "ch") { + throw new Error(`installed pi ${version} Input no longer accumulates typed characters for the picker's search box`); +} +JS + status=$? + cd "$original_dir" || fail "could not leave the Pi picker primitives fixture" + out=$(cat "$TMP_ROOT/node-output") + expect_code 0 "$status" "the installed Pi must still provide the picker's bounded searchable primitives: $out" + [ -z "$out" ] || fail "Pi picker primitives test printed output: $out" + pass "the installed Pi still bounds the picker's list and ranks its search" +} + test_outcomes_tool_uses_stock_execution_and_export_consumers() { if ! command -v node >/dev/null 2>&1; then echo "skip: node not found for Pi outcomes rendering test" @@ -2338,6 +2773,7 @@ test_outcomes_tool_uses_stock_execution_and_export_consumers() { mkdir -p "$fixture/.pi/extensions/lib" "$fixture/node_modules/@earendil-works" cp "$EXT" "$fixture/.pi/extensions/fm-branch-supervision.ts" cp "$ROOT/.pi/extensions/lib/fm-branch-dispatch.ts" "$fixture/.pi/extensions/lib/fm-branch-dispatch.ts" + cp "$ROOT/.pi/extensions/lib/fm-branch-model-picker.ts" "$fixture/.pi/extensions/lib/fm-branch-model-picker.ts" cp "$ROOT/.pi/extensions/lib/fm-calm-visibility.ts" "$fixture/.pi/extensions/lib/fm-calm-visibility.ts" cp "$ROOT/.pi/extensions/lib/fm-operational-input.ts" "$fixture/.pi/extensions/lib/fm-operational-input.ts" ln -s "$package_dir" "$fixture/node_modules/@earendil-works/pi-coding-agent" @@ -2430,7 +2866,9 @@ JS } test_outcomes_tool_uses_stock_execution_and_export_consumers +test_real_pi_picker_primitives_stay_bounded_and_searchable test_branch_dispatch_two_stage_filter_and_prefix_contract +test_captain_outcome_encoding_failure_delivers_plain_instruction test_branch_dispatch_classifies_main_only_rows_and_writes_the_eligible_snapshot test_branch_cache_key_is_per_home_stable test_branch_default_on_heartbeat_afk_and_fallback @@ -2444,6 +2882,8 @@ test_branch_session_persists_across_process_restarts test_branch_model_pin_applies_and_absent_pin_keeps_the_default test_unpinned_branch_follows_main_model_changes_live test_supervision_model_command_persists_and_rebinds_the_live_branch +test_supervision_model_picker_is_bounded_searchable_and_branch_only +test_branch_model_picker_keeps_follow_main_first_under_ranking test_branch_effort_pin_applies_and_absent_pin_follows_main test_unpinned_branch_follows_main_effort_changes_live test_supervision_model_command_picks_effort_after_the_model diff --git a/tests/fm-pi-branch-live-e2e.test.sh b/tests/fm-pi-branch-live-e2e.test.sh index 4c4b9627f76..098883164ee 100644 --- a/tests/fm-pi-branch-live-e2e.test.sh +++ b/tests/fm-pi-branch-live-e2e.test.sh @@ -48,6 +48,7 @@ mkdir -p "$repo/.pi/extensions/lib" "$repo/node_modules/@earendil-works" \ "$home/state" "$home/config" "$agentdir" cp "$ROOT/.pi/extensions/fm-branch-supervision.ts" "$repo/.pi/extensions/fm-branch-supervision.ts" cp "$ROOT/.pi/extensions/lib/fm-branch-dispatch.ts" "$repo/.pi/extensions/lib/fm-branch-dispatch.ts" +cp "$ROOT/.pi/extensions/lib/fm-branch-model-picker.ts" "$repo/.pi/extensions/lib/fm-branch-model-picker.ts" cp "$ROOT/.pi/extensions/lib/fm-calm-visibility.ts" "$repo/.pi/extensions/lib/fm-calm-visibility.ts" cp "$ROOT/.pi/extensions/lib/fm-operational-input.ts" "$repo/.pi/extensions/lib/fm-operational-input.ts" mkdir -p "$repo/bin" @@ -456,3 +457,71 @@ if [ "$status" -ne 0 ] || [ "$out" != "EFFORT_OK" ]; then fail "real-SDK effort-pin guard failed against pi-coding-agent $PI_VERSION: $out" fi pass "real Pi SDK $PI_VERSION reports its own supported effort levels and applies an explicit branch effort over a reopened session's recorded level" + +# Fourth probe: the vendor contract the captain-outcome envelope rests on. Pi +# keeps ONLY `content` when it converts a custom message for the provider, so +# `content` is the entire payload main's model receives and is the only place a +# captain outcome can carry its own identity. When it carried none, main could +# not tell an incoming outcome from its own earlier answer and re-emitted that +# answer instead of relaying the outcome. This runs the real SDK's own +# convertToLlm over bytes the REAL protocol encoder produced, then hands the +# model-visible text back to the real parser, proving the delivery path end to +# end instead of assuming it. +captain_payload=$(printf 'relay this\n\ntask-9: PR ready' \ + | "$ROOT/bin/fm-operational-input.sh" encode branch-outcome) \ + || fail "the operational-input owner does not encode the branch-outcome kind" +CAPTAIN_PAYLOAD="$captain_payload" ROUTINE_PAYLOAD="⛵ task-9: worker healthy" \ + DELIVERY_DIR="$TMP_ROOT" PI_PACKAGE_DIR="$PI_PACKAGE_DIR" \ + node --input-type=module > "$TMP_ROOT/delivery-output" 2>&1 <<'EOF' +import { writeFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { pathToFileURL } from "node:url"; + +const pkg = resolve(process.env.PI_PACKAGE_DIR); +const { convertToLlm } = await import(pathToFileURL(`${pkg}/dist/index.js`).href); +if (typeof convertToLlm !== "function") { + throw new Error("this Pi no longer exports convertToLlm: the delivery contract is unproven"); +} + +const captainContent = process.env.CAPTAIN_PAYLOAD; +const routineContent = process.env.ROUTINE_PAYLOAD; +const converted = convertToLlm([ + { role: "custom", customType: "fm-branch-merge", content: captainContent, display: false, timestamp: 1 }, + { role: "custom", customType: "fm-branch-merge", content: routineContent, display: true, timestamp: 2 }, +]); +if (converted.length !== 2) { + throw new Error(`Pi no longer delivers one provider message per custom message: ${converted.length}`); +} +for (const message of converted) { + if (message.role !== "user") { + throw new Error(`Pi delivers a custom message as role ${message.role}, not user`); + } + if ("customType" in message || "display" in message) { + throw new Error("Pi now forwards customType or display, so content is no longer the whole payload"); + } +} +const textOf = (message) => + typeof message.content === "string" + ? message.content + : message.content.map((block) => block.text ?? "").join(""); +if (textOf(converted[0]) !== captainContent || textOf(converted[1]) !== routineContent) { + throw new Error("Pi altered custom-message content on the way to the provider"); +} +writeFileSync(`${process.env.DELIVERY_DIR}/live-delivered-captain`, textOf(converted[0])); +writeFileSync(`${process.env.DELIVERY_DIR}/live-delivered-routine`, textOf(converted[1])); +console.log("DELIVERY_OK"); +process.exit(0); +EOF +status=$? +out=$(cat "$TMP_ROOT/delivery-output") +if [ "$status" -ne 0 ] || [ "$out" != "DELIVERY_OK" ]; then + fail "real-SDK custom-message delivery guard failed against pi-coding-agent $PI_VERSION: $out" +fi +delivered_kind=$("$ROOT/bin/fm-operational-input.sh" kind < "$TMP_ROOT/live-delivered-captain") \ + || fail "pi-coding-agent $PI_VERSION delivered the captain outcome as text the protocol cannot type" +[ "$delivered_kind" = branch-outcome ] \ + || fail "pi-coding-agent $PI_VERSION delivered the captain outcome as kind '$delivered_kind'" +if "$ROOT/bin/fm-operational-input.sh" kind < "$TMP_ROOT/live-delivered-routine" >/dev/null 2>&1; then + fail "a routine note survived Pi conversion as typed operational input" +fi +pass "real Pi SDK $PI_VERSION delivers a custom message to the provider as user text carrying only content, so the captain outcome's typed envelope is what reaches the model" diff --git a/tests/fm-pi-primary-types.test.sh b/tests/fm-pi-primary-types.test.sh index 6c7c072ebf7..454b0a9c5cd 100755 --- a/tests/fm-pi-primary-types.test.sh +++ b/tests/fm-pi-primary-types.test.sh @@ -32,6 +32,7 @@ cp "$ROOT/.pi/extensions/fm-calm.ts" "$TMP_ROOT/fm-calm.ts" cp "$ROOT/.pi/extensions/fm-primary-pi-watch.ts" "$TMP_ROOT/fm-primary-pi-watch.ts" cp "$ROOT/.pi/extensions/fm-primary-turnend-guard.ts" "$TMP_ROOT/fm-primary-turnend-guard.ts" cp "$ROOT/.pi/extensions/lib/fm-branch-dispatch.ts" "$TMP_ROOT/lib/fm-branch-dispatch.ts" +cp "$ROOT/.pi/extensions/lib/fm-branch-model-picker.ts" "$TMP_ROOT/lib/fm-branch-model-picker.ts" cp "$ROOT/.pi/extensions/lib/fm-calm-assistant-layout.ts" "$TMP_ROOT/lib/fm-calm-assistant-layout.ts" cp "$ROOT/.pi/extensions/lib/fm-calm-operational-user-layout.ts" "$TMP_ROOT/lib/fm-calm-operational-user-layout.ts" cp "$ROOT/.pi/extensions/lib/fm-calm-visibility.ts" "$TMP_ROOT/lib/fm-calm-visibility.ts" diff --git a/tests/fm-pr-check-security.test.sh b/tests/fm-pr-check-security.test.sh index 47c6bad9a10..700dd74bf0d 100755 --- a/tests/fm-pr-check-security.test.sh +++ b/tests/fm-pr-check-security.test.sh @@ -92,6 +92,12 @@ SH cat > "$fakebin/gh-axi" <<'SH' #!/usr/bin/env bash printf '%s\n' "$*" >> "$FM_TEST_GH_AXI_LOG" +case "${1:-} ${2:-}" in + "pr view") + [ "$#" -eq 5 ] && [ "${4:-}" = --repo ] || exit 2 + printf 'pull_request:\n number: %s\n state: %s\n' "$3" "${FM_TEST_GH_MERGE_STATE:-merged}" + ;; +esac exit "${FM_TEST_GH_AXI_RC:-0}" SH # Plain glab, reproducing the real CLI's contract: its field output on stdout @@ -567,12 +573,23 @@ test_valid_recording_and_merge_derivation() { >/dev/null 2>/dev/null || fail "valid merge wrapper failed" grep -qxF 'pr merge 37 --repo my-org/repo_name.with-dots --merge' "$dir/gh-axi.log" \ || fail "merge wrapper did not preserve repository derivation and method" + # A merge this home performed leaves its own durable outcome, so the poll's + # confirmation is no longer the first the captain hears of it. Acknowledge that + # record before the watcher cycle below, which is what still retires the poll. + assert_grep 'https://github.com/my-org/repo_name.with-dots/pull/37' "$dir/home/state/.wake-queue" \ + "a merge this home performed left no durable outcome" + ack_watcher_cycle "$dir/home/state" || fail "merge outcome acknowledgement failed" + # With the merge already reported, the poll's own detection is a duplicate the + # watcher absorbs, so this cycle needs its own reason to end. + add_stop_custom_check "$dir" set +e FM_TEST_GH_STATE=MERGED run_watcher_bounded "$dir/home" "$dir/fakebin" > "$dir/merged-watch.out" 2> "$dir/merged-watch.err" rc=$? set -e [ "$rc" -eq 0 ] || fail "guarded merge poll retirement failed: $(cat "$dir/merged-watch.err")" assert_poll_absent "$dir/home/state" task-a + assert_no_grep "merged-task-a" "$dir/home/state/.wake-queue" \ + "the drained self-merge outcome was republished by its poll" grep -qxF 'pr=https://github.com/my-org/repo_name.with-dots/pull/37' "$dir/home/state/task-a.meta" \ || fail "guarded merge retirement removed pr metadata" grep -qxF "pr_head=$expected" "$dir/home/state/task-a.meta" \ @@ -3048,6 +3065,189 @@ test_merged_poll_reregistration_after_notification_is_absorbed() { pass "a repeat identical merged poll for an already-notified task is absorbed, never queued as a main-blocking row" } +# The captain merging a PR himself on the forge is the same outcome as a merge +# this home performed: bin/fm-merge-outcome-lib.sh carries both to the parent on +# the one reply channel, so no second watch path exists for the captain's case. +# The poll's own durable row still lands here, because the mate that owns the +# task still has to act on it. +seed_secondmate_home() { # [] + local dir=$1 route=${2:-remote} + printf '%s\n' mate-x > "$dir/home/.fm-secondmate-home" + printf 'schema=fm-secondmate-parent.v1\nroute=%s\n' "$route" \ + > "$dir/home/.fm-secondmate-parent" +} + +test_merged_poll_retries_a_failed_upward_report() { + local dir state rc replies url + url=https://github.com/o/r/pull/1 + dir=$(make_case merged-poll-upward-retry) + state="$dir/home/state" + replies="$state/parent-replies.status" + printf '%s\n' mate-x > "$dir/home/.fm-secondmate-home" + write_poll_meta "$state" task-a "$url" + seed_canonical_poll "$dir" task-a "$url" + add_stop_custom_check "$dir" + + set +e + FM_TEST_GH_STATE=MERGED run_watcher_bounded "$dir/home" "$dir/fakebin" \ + > "$dir/watch-1.out" 2> "$dir/watch-1.err" + rc=$? + set -e + [ "$rc" -ne 0 ] || fail "merged-poll-upward-retry: failed report did not keep the watcher loud" + [ -f "$state/task-a.check.sh" ] \ + || fail "merged-poll-upward-retry: failed report retired its retry poll" + [ ! -e "$state/task-a.pr-poll-merge-notified" ] \ + || fail "merged-poll-upward-retry: failed report was marked notified" + [ ! -e "$replies" ] \ + || fail "merged-poll-upward-retry: failed report wrote a parent reply" + + printf 'schema=fm-secondmate-parent.v1\nroute=remote\n' \ + > "$dir/home/.fm-secondmate-parent" + rm -f "$state/.last-check" + set +e + FM_TEST_GH_STATE=MERGED run_watcher_bounded "$dir/home" "$dir/fakebin" \ + > "$dir/watch-2.out" 2> "$dir/watch-2.err" + rc=$? + set -e + [ "$rc" -eq 0 ] || fail "merged-poll-upward-retry: retry failed: $(cat "$dir/watch-2.err")" + if [ ! -e "$replies" ]; then + ack_watcher_cycle "$state" \ + || fail "merged-poll-upward-retry: recovery acknowledgement failed" + rm -f "$state/.last-check" + set +e + FM_TEST_GH_STATE=MERGED run_watcher_bounded "$dir/home" "$dir/fakebin" \ + > "$dir/watch-3.out" 2> "$dir/watch-3.err" + rc=$? + set -e + [ "$rc" -eq 0 ] || fail "merged-poll-upward-retry: post-recovery retry failed: $(cat "$dir/watch-3.err")" + fi + assert_grep "done [key=merged-task-a]: merged task-a $url" "$replies" \ + "merged-poll-upward-retry: repaired binding did not receive the retry" + assert_poll_absent "$state" task-a + pass "a failed upward merge report keeps its poll armed for repair and retry" +} + +test_self_merge_and_poll_publish_one_outcome() { + local dir state replies url rc + url=https://github.com/o/r/pull/1 + + # Interleaving one: self publication commits before the poll observes the + # merge, so the poll absorbs the committed identity without reporting again. + dir=$(make_case merge-outcome-committed) + state="$dir/home/state" + replies="$state/parent-replies.status" + seed_secondmate_home "$dir" + write_task_meta "$dir" task-a + run_check_entry "$dir" task-a "$url" >/dev/null 2>"$dir/seed.err" \ + || fail "merge-outcome-committed: could not arm merge poll" + run_merge_entry "$dir" task-a "$url" >"$dir/merge.out" 2>"$dir/merge.err" \ + || fail "merge-outcome-committed: merge entrypoint failed: $(cat "$dir/merge.err")" + add_stop_custom_check "$dir" + set +e + FM_TEST_GH_STATE=MERGED run_watcher_bounded "$dir/home" "$dir/fakebin" \ + >"$dir/watch.out" 2>"$dir/watch.err" + rc=$? + set -e + [ "$rc" -eq 0 ] \ + || fail "merge-outcome-committed: watcher failed: $(cat "$dir/watch.err")" + [ "$(grep -c -F "$url" "$replies")" -eq 1 ] \ + || fail "merge-outcome-committed: self and poll reports produced duplicate outcomes" + assert_no_grep "check: $state/task-a.check.sh: merged" "$state/.wake-queue" \ + "merge-outcome-committed: absorbed poll published a second outcome" + assert_poll_absent "$state" task-a + + # Interleaving two: self publication lands but its marker commit fails. After + # that outcome is drained, the still-armed poll must publish it again rather + # than treating the interrupted attempt as complete and going silent. + dir=$(make_case merge-outcome-uncommitted) + state="$dir/home/state" + write_task_meta "$dir" task-a + run_check_entry "$dir" task-a "$url" >/dev/null 2>"$dir/seed.err" \ + || fail "merge-outcome-uncommitted: could not arm merge poll" + cat >"$dir/fakebin/mv" <<'SH' +#!/usr/bin/env bash +case " $* " in + *pr-poll-merge-notified*) exit 1 ;; +esac +exec "$FM_TEST_REAL_MV" "$@" +SH + chmod +x "$dir/fakebin/mv" + set +e + FM_TEST_REAL_MV="$REAL_MV" run_merge_entry "$dir" task-a "$url" \ + >"$dir/merge.out" 2>"$dir/merge.err" + rc=$? + set -e + [ "$rc" -eq 0 ] \ + || fail "merge-outcome-uncommitted: landed merge was reported as failed" + assert_grep "$url" "$state/.wake-queue" \ + "merge-outcome-uncommitted: interrupted publication emitted no outcome" + [ ! -e "$state/task-a.pr-poll-merge-notified" ] \ + || fail "merge-outcome-uncommitted: failed marker commit was treated as complete" + ack_watcher_cycle "$state" \ + || fail "merge-outcome-uncommitted: could not drain the first outcome" + assert_no_grep "$url" "$state/.wake-queue" \ + "merge-outcome-uncommitted: first outcome remained queued after its drain" + rm -f "$dir/fakebin/mv" "$state/.last-check" + + set +e + FM_TEST_GH_STATE=MERGED run_watcher_bounded "$dir/home" "$dir/fakebin" \ + >"$dir/watch.out" 2>"$dir/watch.err" + rc=$? + set -e + [ "$rc" -eq 0 ] \ + || fail "merge-outcome-uncommitted: poll retry failed: $(cat "$dir/watch.err")" + case "$(cat "$dir/watch.out")" in + check:*task-a.check.sh:*merged) ;; + *) fail "merge-outcome-uncommitted: poll retry did not re-emit the outcome" ;; + esac + assert_grep "$url" "$state/.wake-queue" \ + "merge-outcome-uncommitted: drained outcome was not durably re-emitted" + fm_pr_poll_merge_already_notified "$state" task-a github github.com o/r 1 \ + || fail "merge-outcome-uncommitted: successful retry did not commit the marker" + assert_poll_absent "$state" task-a + pass "staged self-merge and poll interleavings are never silent" +} + +test_merged_poll_reports_upward_from_a_secondmate_home_once() { + local dir state rc replies url + url=https://github.com/o/r/pull/1 + dir=$(make_case merged-poll-upward) + state="$dir/home/state" + replies="$state/parent-replies.status" + seed_secondmate_home "$dir" + write_poll_meta "$state" task-a "$url" + seed_canonical_poll "$dir" task-a "$url" + add_stop_custom_check "$dir" + + set +e + FM_TEST_GH_STATE=MERGED run_watcher_bounded "$dir/home" "$dir/fakebin" > "$dir/watch-1.out" 2> "$dir/watch-1.err" + rc=$? + set -e + [ "$rc" -eq 0 ] || fail "merged-poll-upward: watcher failed: $(cat "$dir/watch-1.err")" + case "$(cat "$dir/watch-1.out")" in + check:*task-a.check.sh:*merged) ;; + *) fail "merged-poll-upward: the poll's own row was lost: $(cat "$dir/watch-1.out")" ;; + esac + assert_grep "done [key=merged-task-a]: merged task-a $url" "$replies" \ + "merged-poll-upward: a merge this home did not perform was never reported upward" + [ "$(grep -c -F "$url" "$replies")" -eq 1 ] \ + || fail "merged-poll-upward: one detected merge produced more than one upward line" + ack_watcher_cycle "$state" || fail "merged-poll-upward: acknowledgement failed" + + # Re-registered for the same, already-reported merge: the absorbed duplicate + # must not tell the parent a second time either. + seed_canonical_poll "$dir" task-a "$url" + rm -f "$state/.last-check" + set +e + FM_TEST_GH_STATE=MERGED run_watcher_bounded "$dir/home" "$dir/fakebin" > "$dir/watch-2.out" 2> "$dir/watch-2.err" + rc=$? + set -e + [ "$rc" -eq 0 ] || fail "merged-poll-upward: second watcher cycle failed: $(cat "$dir/watch-2.err")" + [ "$(grep -c -F "$url" "$replies")" -eq 1 ] \ + || fail "merged-poll-upward: an absorbed duplicate detection reported the merge again" + pass "a merge detected by the poll is reported upward from a secondmate home exactly once" +} + test_different_merged_pr_for_same_task_is_not_absorbed() { local dir state rc dir=$(make_case different-merged-pr-not-absorbed) @@ -3080,7 +3280,8 @@ test_different_merged_pr_for_same_task_is_not_absorbed() { check:*task-a.check.sh:*merged) ;; *) fail "a different PR merge was absorbed: $(cat "$dir/watch-2.out")" ;; esac - grep -F "$(printf '\tcheck\t%s/task-a.check.sh\t' "$state")" "$state/.wake-queue" >/dev/null 2>&1 \ + grep -F "$(printf '\tcheck\tmerged-task-a-https://github.com/o/r/pull/2\t')" \ + "$state/.wake-queue" >/dev/null 2>&1 \ || fail "the different PR merge did not create a main-blocking wake row" fm_pr_poll_merge_already_notified "$state" task-a github github.com o/r 2 \ || fail "the marker was not advanced to the different PR identity" @@ -3149,10 +3350,12 @@ test_retirement_crash_recovery() { set -e [ "$rc" -eq 0 ] || fail "post-queue retry watcher failed: $(cat "$dir/watch.err")" assert_poll_absent "$state" task-a - raw_count=$(grep -c $'\tcheck\t.*task-a.check.sh\t' "$state/.wake-queue") + raw_count=$(grep -cF "$(printf '\tcheck\tmerged-task-a-https://github.com/o/r/pull/3\t')" \ + "$state/.wake-queue" || true) [ "$raw_count" -eq 1 ] || fail "post-queue retry did not publish exactly one new terminal row" FM_HOME="$dir/home" FM_ROOT_OVERRIDE="$ROOT" "$ROOT/bin/fm-wake-drain.sh" > "$dir/drain.out" 2>/dev/null - drain_count=$(grep -c $'\tcheck\t.*task-a.check.sh\t' "$dir/drain.out") + drain_count=$(grep -cF "$(printf '\tcheck\tmerged-task-a-https://github.com/o/r/pull/3\t')" \ + "$dir/drain.out" || true) [ "$drain_count" -eq 1 ] || fail "same-key crash retry rows did not deduplicate at drain" dir=$(make_case retirement-after-receipt) @@ -3467,6 +3670,9 @@ test_parser_matrix test_gitlab_merge_watch test_merged_poll_retires_once test_merged_poll_reregistration_after_notification_is_absorbed +test_merged_poll_retries_a_failed_upward_report +test_self_merge_and_poll_publish_one_outcome +test_merged_poll_reports_upward_from_a_secondmate_home_once test_different_merged_pr_for_same_task_is_not_absorbed test_persistent_secondmate_retirement_is_poll_only test_retirement_crash_recovery diff --git a/tests/fm-pr-merge.test.sh b/tests/fm-pr-merge.test.sh index 2367e8e5d22..d3842939ce4 100755 --- a/tests/fm-pr-merge.test.sh +++ b/tests/fm-pr-merge.test.sh @@ -24,6 +24,17 @@ # (o) glab or jq absent refuses before any state is recorded # (p) --sha in extra GitLab args fails fast, and still forwards on GitHub # (q) a GitLab refusal still leaves pr= recorded and the merge poll armed +# (r) a successful merge in a secondmate home reports the landed PR upward +# once, on the route its parent binding names, and a repeat merge of the +# same PR does not duplicate that line +# (s) a refused or failed merge reports nothing +# (t) a successful merge in a main home leaves a durable wake naming the PR +# (u) a secondmate home with no usable parent binding says so loudly instead +# of merging in silence +# (v) an accepted queued GitHub merge emits nothing and leaves its poll armed +# (w) an accepted queued GitLab merge emits nothing and leaves its poll armed +# (x) an uncommitted marker retry never loses the durable outcome +# (y) distinct merged PRs for a reused task each survive queue deduplication set -u # shellcheck source=tests/lib.sh @@ -71,6 +82,12 @@ add_gh_mocks() { cat > "$case_dir/fakebin/gh-axi" <<'SH' #!/usr/bin/env bash printf '%s\n' "$*" >> "$FM_TEST_GH_AXI_LOG" +case "${1:-} ${2:-}" in + "pr view") + [ "$#" -eq 5 ] && [ "${4:-}" = --repo ] || exit 2 + printf 'pull_request:\n number: %s\n state: %s\n' "$3" "${FM_TEST_GH_MERGE_STATE:-merged}" + ;; +esac exit 0 SH cat > "$case_dir/fakebin/gh" <&2 ; exit 1 ; } + : > "$case_dir/glab-merge-called" exit 0 ;; esac @@ -178,6 +200,7 @@ make_gitlab_case() { : > "$case_dir/gh-axi.log" : > "$case_dir/glab.log" write_mr_json "$case_dir/mr.json" "$@" + write_mr_json "$case_dir/mr-post.json" state=merged printf '%s\n' "$case_dir" } @@ -215,6 +238,7 @@ glab_merge_line() { run_pr_merge() { local case_dir=$1 rc; shift FM_ROOT_OVERRIDE="$ROOT" \ + FM_HOME="${FM_TEST_HOME:-$ROOT}" \ FM_STATE_OVERRIDE="$case_dir/state" \ FM_TEST_GH_AXI_LOG="$case_dir/gh-axi.log" \ FM_TEST_GLAB_LOG="$case_dir/glab.log" \ @@ -810,6 +834,295 @@ test_github_still_forwards_sha_arg() { pass "fm-pr-merge leaves GitHub extra-arg handling unchanged, including --sha" } + +# --- durable merge outcome --------------------------------------------------- +# A merge that lands must leave a record outside the merging agent's memory. +# bin/fm-merge-outcome-lib.sh owns where that record goes; these cases pin the +# behavior through the real merge entrypoint. + +# make_home_case [ []]: a case dir whose home is a +# secondmate home bound to a parent, or a plain main home when no route is +# given. Echoes the case dir; the home is "$case_dir/home". +make_home_case() { + local name=$1 route=${2:-} parent=${3:-} case_dir home + case_dir=$(make_case "$name") + home="$case_dir/home" + mkdir -p "$home" "$case_dir/wt" + if [ -n "$route" ]; then + printf '%s\n' mate-x >"$home/.fm-secondmate-home" + { + printf 'schema=fm-secondmate-parent.v1\n' + printf 'route=%s\n' "$route" + [ "$route" != local ] || printf 'parent_home=%s\n' "$parent" + } >"$home/.fm-secondmate-parent" + fi + printf '%s\n' "$case_dir" +} + +parent_reply_lines() { # + grep -c -F "$2" "$1" 2>/dev/null || true +} + +test_secondmate_merge_reports_upward_once() { + local case_dir replies url + url=https://github.com/example/repo/pull/61 + case_dir=$(make_home_case secondmate-merge-reports remote) + add_gh_mocks "$case_dir" 5555555555555555555555555555555555555555 + : >"$case_dir/gh-axi.log" + replies="$case_dir/state/parent-replies.status" + + FM_TEST_HOME="$case_dir/home" run_pr_merge "$case_dir" task-x1 "$url" \ + >"$case_dir/stdout" 2>"$case_dir/stderr" || fail "secondmate-merge-reports: merge failed" + + assert_grep "done [key=merged-task-x1]: merged task-x1 $url" "$replies" \ + "secondmate-merge-reports: the landed PR was not reported upward" + [ "$(wc -l <"$replies")" -eq 1 ] \ + || fail "secondmate-merge-reports: one merge produced more than one upward line" + + # The same merge again: the forge accepts it in this fixture, so only the + # at-most-once contract can keep the parent from being told twice. + FM_TEST_HOME="$case_dir/home" run_pr_merge "$case_dir" task-x1 "$url" \ + >"$case_dir/stdout2" 2>"$case_dir/stderr2" || fail "secondmate-merge-reports: repeat merge failed" + [ "$(parent_reply_lines "$replies" "$url")" -eq 1 ] \ + || fail "secondmate-merge-reports: a repeat merge of the same PR duplicated the upward line" + pass "a merge a secondmate home performs itself is reported upward exactly once" +} + +test_secondmate_merge_reports_on_the_local_route() { + local case_dir parent_status url + url=https://github.com/example/repo/pull/62 + case_dir=$(make_home_case secondmate-merge-local local "$TMP_ROOT/secondmate-merge-local/parent") + mkdir -p "$TMP_ROOT/secondmate-merge-local/parent/state" + add_gh_mocks "$case_dir" 6666666666666666666666666666666666666666 + : >"$case_dir/gh-axi.log" + parent_status="$TMP_ROOT/secondmate-merge-local/parent/state/mate-x.status" + + FM_TEST_HOME="$case_dir/home" run_pr_merge "$case_dir" task-x1 "$url" \ + >"$case_dir/stdout" 2>"$case_dir/stderr" || fail "secondmate-merge-local: merge failed" + + assert_grep "done [key=merged-task-x1]: merged task-x1 $url" "$parent_status" \ + "secondmate-merge-local: the landed PR did not reach the parent home's channel" + [ ! -e "$case_dir/state/parent-replies.status" ] \ + || fail "secondmate-merge-local: a local-route report also wrote the remote reply channel" + pass "a locally routed secondmate home reports the landed PR into its parent's own channel" +} + +test_failed_merge_reports_nothing() { + local case_dir rc + case_dir=$(make_home_case failed-merge-silent remote) + add_gh_mocks_merge_fails "$case_dir" + : >"$case_dir/gh-axi.log" + + set +e + FM_TEST_HOME="$case_dir/home" run_pr_merge "$case_dir" task-x1 https://github.com/example/repo/pull/63 \ + >"$case_dir/stdout" 2>"$case_dir/stderr" + rc=$? + set -e + + expect_code 1 "$rc" "failed-merge-silent: a failed merge should propagate" + assert_absent "$case_dir/state/parent-replies.status" \ + "failed-merge-silent: a merge that never landed was reported as landed" + pass "a refused or failed merge reports no outcome" +} + +test_gitlab_refusal_reports_nothing() { + local case_dir rc + case_dir=$(make_gitlab_case gitlab-refusal-silent state=merged) + mkdir -p "$case_dir/home" + printf '%s\n' mate-x >"$case_dir/home/.fm-secondmate-home" + printf 'schema=fm-secondmate-parent.v1\nroute=remote\n' >"$case_dir/home/.fm-secondmate-parent" + + set +e + FM_TEST_HOME="$case_dir/home" run_pr_merge "$case_dir" task-x1 "$MR_URL" \ + >"$case_dir/stdout" 2>"$case_dir/stderr" + rc=$? + set -e + + expect_code 1 "$rc" "gitlab-refusal-silent: a refused GitLab merge should exit non-zero" + assert_absent "$case_dir/state/parent-replies.status" \ + "gitlab-refusal-silent: a refused merge request was reported as landed" + pass "a GitLab merge refused before the forge call reports no outcome" +} + +test_gitlab_merge_reports_upward() { + local case_dir url + case_dir=$(make_gitlab_case gitlab-merge-reports) + mkdir -p "$case_dir/home" + printf '%s\n' mate-x >"$case_dir/home/.fm-secondmate-home" + printf 'schema=fm-secondmate-parent.v1\nroute=remote\n' >"$case_dir/home/.fm-secondmate-parent" + url=$MR_URL + + FM_TEST_HOME="$case_dir/home" run_pr_merge "$case_dir" task-x1 "$url" \ + >"$case_dir/stdout" 2>"$case_dir/stderr" || fail "gitlab-merge-reports: merge failed" + + assert_grep "done [key=merged-task-x1]: merged task-x1 $url" \ + "$case_dir/state/parent-replies.status" \ + "gitlab-merge-reports: a landed merge request was not reported upward" + pass "a landed GitLab merge request is reported upward on the same channel" +} + +test_queued_gitlab_merge_leaves_the_poll_armed() { + local case_dir + case_dir=$(make_gitlab_case queued-gitlab-merge) + mkdir -p "$case_dir/home" + : >"$case_dir/glab-stays-open" + + FM_TEST_HOME="$case_dir/home" run_pr_merge "$case_dir" task-x1 "$MR_URL" \ + >"$case_dir/stdout" 2>"$case_dir/stderr" \ + || fail "queued-gitlab-merge: accepted merge command failed" + + assert_absent "$case_dir/state/.wake-queue" \ + "queued-gitlab-merge: a queued merge was reported as landed" + [ -f "$case_dir/state/task-x1.check.sh" ] \ + || fail "queued-gitlab-merge: the merge poll was not left armed" + [ ! -e "$case_dir/state/task-x1.pr-poll-merge-notified" ] \ + || fail "queued-gitlab-merge: a queued merge was marked as reported" + pass "a queued GitLab merge stays silent and leaves confirmation to the armed poll" +} + +test_main_home_merge_leaves_a_durable_wake() { + local case_dir url + url=https://github.com/example/repo/pull/64 + case_dir=$(make_home_case main-merge-wake) + add_gh_mocks "$case_dir" 7777777777777777777777777777777777777777 + : >"$case_dir/gh-axi.log" + + FM_TEST_HOME="$case_dir/home" run_pr_merge "$case_dir" task-x1 "$url" \ + >"$case_dir/stdout" 2>"$case_dir/stderr" || fail "main-merge-wake: merge failed" + + assert_grep "$url" "$case_dir/state/.wake-queue" \ + "main-merge-wake: a merge this home performed left no durable record naming the PR" + [ "$(grep -c -F "$url" "$case_dir/state/.wake-queue")" -eq 1 ] \ + || fail "main-merge-wake: one merge produced more than one durable record" + assert_absent "$case_dir/state/parent-replies.status" \ + "main-merge-wake: a main home wrote a parent reply channel it does not have" + pass "a merge a main home performs itself leaves one durable wake naming the PR" +} + +test_queued_github_merge_leaves_the_poll_armed() { + local case_dir url + url=https://github.com/example/repo/pull/66 + case_dir=$(make_home_case queued-github-merge) + add_gh_mocks "$case_dir" 9999999999999999999999999999999999999999 + : >"$case_dir/gh-axi.log" + + FM_TEST_GH_MERGE_STATE=open FM_TEST_HOME="$case_dir/home" \ + run_pr_merge "$case_dir" task-x1 "$url" \ + >"$case_dir/stdout" 2>"$case_dir/stderr" \ + || fail "queued-github-merge: accepted merge command failed" + + assert_absent "$case_dir/state/.wake-queue" \ + "queued-github-merge: a queued merge was reported as landed" + [ -f "$case_dir/state/task-x1.check.sh" ] \ + || fail "queued-github-merge: the merge poll was not left armed" + [ ! -e "$case_dir/state/task-x1.pr-poll-merge-notified" ] \ + || fail "queued-github-merge: a queued merge was marked as reported" + pass "a queued GitHub merge stays silent and leaves confirmation to the armed poll" +} + +test_distinct_merged_prs_keep_distinct_wakes() { + local case_dir first_url second_url + first_url=https://github.com/example/repo/pull/68 + second_url=https://github.com/example/repo/pull/69 + case_dir=$(make_home_case distinct-merge-wakes) + add_gh_mocks "$case_dir" bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb + : >"$case_dir/gh-axi.log" + + FM_TEST_HOME="$case_dir/home" run_pr_merge "$case_dir" task-x1 "$first_url" \ + >"$case_dir/stdout-1" 2>"$case_dir/stderr-1" \ + || fail "distinct-merge-wakes: first merge failed" + rm -f "$case_dir/state/task-x1.check.sh" \ + "$case_dir/state/task-x1.pr-poll" \ + "$case_dir/state/task-x1.pr-poll-registration" + FM_TEST_HOME="$case_dir/home" run_pr_merge "$case_dir" task-x1 "$second_url" \ + >"$case_dir/stdout-2" 2>"$case_dir/stderr-2" \ + || fail "distinct-merge-wakes: second merge failed" + + [ "$(grep -c -F "$first_url" "$case_dir/state/.wake-queue")" -eq 1 ] \ + || fail "distinct-merge-wakes: first merge wake was missing or duplicated" + [ "$(grep -c -F "$second_url" "$case_dir/state/.wake-queue")" -eq 1 ] \ + || fail "distinct-merge-wakes: second merge wake was missing or duplicated" + FM_STATE_OVERRIDE="$case_dir/state" "$ROOT/bin/fm-wake-drain.sh" \ + >"$case_dir/drain.out" 2>"$case_dir/drain.err" \ + || fail "distinct-merge-wakes: wake drain failed" + assert_grep "$first_url" "$case_dir/drain.out" \ + "distinct-merge-wakes: queue deduplication collapsed the first PR" + assert_grep "$second_url" "$case_dir/drain.out" \ + "distinct-merge-wakes: queue deduplication collapsed the second PR" + pass "distinct merged PRs for one task retain distinct captain-facing wakes" +} + +test_uncommitted_marker_retry_is_never_silent() { + local case_dir url count + url=https://github.com/example/repo/pull/67 + case_dir=$(make_home_case uncommitted-wake-retry) + add_gh_mocks "$case_dir" aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + : >"$case_dir/gh-axi.log" + cat >"$case_dir/fakebin/mv" <<'SH' +#!/usr/bin/env bash +case "${!#}" in + *.pr-poll-merge-notified) + if mkdir "$FM_TEST_MARKER_FAILURE.claim" 2>/dev/null; then + exit 1 + fi + ;; +esac +exec "$FM_TEST_REAL_MV" "$@" +SH + chmod +x "$case_dir/fakebin/mv" + export FM_TEST_MARKER_FAILURE="$case_dir/marker-failure" + export FM_TEST_REAL_MV + FM_TEST_REAL_MV=$(command -v mv) + + FM_TEST_HOME="$case_dir/home" run_pr_merge "$case_dir" task-x1 "$url" \ + >"$case_dir/stdout-1" 2>"$case_dir/stderr-1" \ + || fail "uncommitted-wake-retry: landed merge was reported as failed" + assert_grep 'could not record the outcome' "$case_dir/stderr-1" \ + "uncommitted-wake-retry: failed marker commit was not loud" + [ -f "$case_dir/state/task-x1.check.sh" ] \ + || fail "uncommitted-wake-retry: failed commit disarmed the retry poll" + count=$(grep -c -F "$url" "$case_dir/state/.wake-queue") + [ "$count" -ge 1 ] \ + || fail "uncommitted-wake-retry: failed marker commit lost the durable outcome" + [ ! -e "$case_dir/state/task-x1.pr-poll-merge-notified" ] \ + || fail "uncommitted-wake-retry: failed marker commit was treated as complete" + + FM_TEST_HOME="$case_dir/home" run_pr_merge "$case_dir" task-x1 "$url" \ + >"$case_dir/stdout-2" 2>"$case_dir/stderr-2" \ + || fail "uncommitted-wake-retry: retry failed" + unset FM_TEST_MARKER_FAILURE FM_TEST_REAL_MV + count=$(grep -c -F "$url" "$case_dir/state/.wake-queue") + [ "$count" -ge 1 ] \ + || fail "uncommitted-wake-retry: retry left the merge silent" + [ -f "$case_dir/state/task-x1.pr-poll-merge-notified" ] \ + || fail "uncommitted-wake-retry: retry did not commit the canonical marker" + pass "an uncommitted marker retry preserves at least one durable outcome" +} + +test_secondmate_without_parent_binding_is_loud() { + local case_dir rc url + url=https://github.com/example/repo/pull/65 + case_dir=$(make_home_case unbound-secondmate) + add_gh_mocks "$case_dir" 8888888888888888888888888888888888888888 + : >"$case_dir/gh-axi.log" + # A secondmate identity with no parent binding: exactly the seeding gap that + # let three real merges land in silence. + printf '%s\n' mate-x >"$case_dir/home/.fm-secondmate-home" + + set +e + FM_TEST_HOME="$case_dir/home" run_pr_merge "$case_dir" task-x1 "$url" \ + >"$case_dir/stdout" 2>"$case_dir/stderr" + rc=$? + set -e + + expect_code 0 "$rc" "unbound-secondmate: the merge itself landed and must not be reported as failed" + assert_grep 'could not report it upward' "$case_dir/stderr" \ + "unbound-secondmate: a merge that could not be reported upward said nothing about it" + assert_absent "$case_dir/state/.wake-queue" \ + "unbound-secondmate: a secondmate home fell back to the main-home record" + pass "a secondmate home that cannot report upward says so instead of merging in silence" +} + test_records_pr_and_head_before_merging test_merge_failure_propagates_after_recording test_extra_merge_args_forwarded @@ -834,3 +1147,14 @@ test_gitlab_unreadable_state_refuses test_gitlab_invalid_head_refuses test_gitlab_missing_tool_refuses_before_recording test_gitlab_head_override_args_refuse_before_recording +test_secondmate_merge_reports_upward_once +test_secondmate_merge_reports_on_the_local_route +test_gitlab_merge_reports_upward +test_queued_gitlab_merge_leaves_the_poll_armed +test_failed_merge_reports_nothing +test_gitlab_refusal_reports_nothing +test_main_home_merge_leaves_a_durable_wake +test_queued_github_merge_leaves_the_poll_armed +test_distinct_merged_prs_keep_distinct_wakes +test_uncommitted_marker_retry_is_never_silent +test_secondmate_without_parent_binding_is_loud diff --git a/tests/fm-secondmate-harness.test.sh b/tests/fm-secondmate-harness.test.sh index 056f7e9df91..64fd2f1a27f 100755 --- a/tests/fm-secondmate-harness.test.sh +++ b/tests/fm-secondmate-harness.test.sh @@ -1230,7 +1230,7 @@ SH cat > "$fakebin/no-mistakes" <<'SH' #!/usr/bin/env bash if [ "${1:-}" = --version ]; then - printf '%s\n' 'no-mistakes version v1.31.2 (fake)' + printf '%s\n' 'no-mistakes version v1.46.0 (fake)' exit 0 fi exit 0 diff --git a/tests/fm-secondmate-liveness.test.sh b/tests/fm-secondmate-liveness.test.sh index 1051f5f5dc6..f9935450370 100755 --- a/tests/fm-secondmate-liveness.test.sh +++ b/tests/fm-secondmate-liveness.test.sh @@ -233,7 +233,7 @@ SH cat > "$fakebin/no-mistakes" <<'SH' #!/usr/bin/env bash if [ "${1:-}" = --version ]; then - printf '%s\n' 'no-mistakes version v1.31.2 (fake)' + printf '%s\n' 'no-mistakes version v1.46.0 (fake)' exit 0 fi exit 0 diff --git a/tests/fm-secondmate-reconcile.test.sh b/tests/fm-secondmate-reconcile.test.sh new file mode 100755 index 00000000000..fe63d5a5f5c --- /dev/null +++ b/tests/fm-secondmate-reconcile.test.sh @@ -0,0 +1,722 @@ +#!/usr/bin/env bash +# tests/fm-secondmate-reconcile.test.sh - the cooldown-limited reconcile ask. +# +# A backlog-vs-metadata inventory mismatch inside a secondmate home no longer +# blanks that home in the fleet snapshot, so the parent asks the home that owns +# those books to fix them. This suite pins that ask: it lands as a real durable +# steering record, a home is asked at most once per cooldown window however +# often the snapshot runs, a mismatch still sitting there after the window +# earns one gentle re-nudge, and the parent never touches the mate's own files. +set -u + +# shellcheck source=tests/secondmate-helpers.sh disable=SC1091 +. "$(dirname "${BASH_SOURCE[0]}")/secondmate-helpers.sh" + +RECONCILE="$ROOT/bin/fm-secondmate-reconcile.sh" +TMP_ROOT=$(fm_test_tmproot fm-secondmate-reconcile) + +command -v jq >/dev/null 2>&1 || { echo "skip: jq not found"; exit 0; } + +export FM_SEND_SETTLE=0 FM_SEND_SLEEP=0 FM_SEND_RETRIES=1 + +# A main home with one registered, live, local secondmate reachable through the +# fake tmux backend, so fm-send's real inbox plane is exercised end to end. +make_main_home() { # + local home="$TMP_ROOT/$1" mate="$TMP_ROOT/$1-mate" id=$2 abs fakebin + mkdir -p "$home/data" "$home/state" + seed_secondmate_home_marker "$mate" "$id" + abs=$(cd "$mate" && pwd -P) + printf -- '- %s - fixture domain (home: %s; scope: fixture; projects: sample; added 2026-08-26)\n' \ + "$id" "$abs" > "$home/data/secondmates.md" + cat > "$home/state/$id.meta" < [state] + jq -n --arg id "$2" --argjson inv "$3" --arg state "${4:-captain_decision}" '{ + schema:"fm-fleet-snapshot.v1", generated:"2026-08-26T00:00:00Z", + secondmate_current:{records:[{ + id:$id, home:("/tmp/" + $id), spawn_gen:("spawn-" + $id), + current:{state:$state, reason:null}, + invalidity:$inv, reconcile_inventory:($inv // {kind:null,ids:[]}), + provenance:{selected:"structured-home", trust:"partial-structured"}}]}}' > "$1" +} + +# The same shape, but for a persistent REMOTE secondmate: no spawn_gen (its +# parent metadata never carries one), host instead. +write_remote_snapshot() { # [state] + jq -n --arg id "$2" --arg host "$3" --argjson inv "$4" --arg state "${5:-captain_decision}" '{ + schema:"fm-fleet-snapshot.v1", generated:"2026-08-26T00:00:00Z", + secondmate_current:{records:[{ + id:$id, home:("/tmp/" + $id), host:$host, spawn_gen:null, + current:{state:$state, reason:null}, + invalidity:$inv, reconcile_inventory:($inv // {kind:null,ids:[]}), + provenance:{selected:"structured-home", trust:"partial-structured"}}]}}' > "$1" +} + +# A fake ssh that decodes fm-on.sh's base64 remote-home/argv payload and +# EXECUTES the real host-local leg (fm-remote-secondmate-control.sh) against a +# genuinely seeded remote-home fixture, exactly like the ssh stub proven in +# tests/fm-send-remote-delivery.test.sh. +make_remote_ssh_stub() { # -> echoes fakebin dir + local dir=$1 fb="$1/fakebin" + mkdir -p "$fb" + cat > "$fb/fake-ssh" <<'SH' +#!/usr/bin/env bash +set -u +cat > /dev/null +while [ "$#" -gt 0 ]; do + case "$1" in -o) shift 2 ;; --) shift; break ;; *) exit 90 ;; esac +done +shift 2 # host, fm-remote-entrypoint.sh +home_b64=$3 +argv_b64=$4 +remote_home=$(perl -MMIME::Base64=decode_base64 -e 'print decode_base64($ARGV[0])' "$home_b64") +rargs=() +while IFS= read -r -d '' a; do rargs+=("$a"); done \ + < <(perl -MMIME::Base64=decode_base64 -e 'print decode_base64($ARGV[0])' "$argv_b64") +cmd=${rargs[0]} +rc=0 +env FM_HOME="$remote_home" FM_ROOT_OVERRIDE="$FM_REMOTE_CODE_ROOT" \ + "$FM_REMOTE_CODE_ROOT/bin/$cmd" "${rargs[@]:1}" || rc=$? +exit "$rc" +SH + chmod +x "$fb/fake-ssh" + printf '%s\n' "$fb" +} + +# A seeded remote secondmate home the real host-local leg validates and writes +# into (identity marker, Firstmate-checkout shape, parent-route endpoint meta). +make_remote_secondmate_home() { # -> echoes remote home dir + local rh="$TMP_ROOT/$1-rhome" + mkdir -p "$rh/state/parent-route" "$rh/bin" + printf '%s\n' "$1" > "$rh/.fm-secondmate-home" + printf '# remote secondmate home fixture\n' > "$rh/AGENTS.md" + cat > "$rh/state/parent-route/$1.meta" < -> echoes home dir + local home="$TMP_ROOT/$1" id=$2 rhome host=$4 + mkdir -p "$home/data" "$home/state" + # Canonicalize: fm-on.sh's registry route parser rejects an empty path + # component, and $TMP_ROOT can carry one (a raw mktemp base under a + # trailing-slash TMPDIR), same as make_main_home's $abs above. + rhome=$(cd "$3" && pwd -P) + cat > "$home/state/$id.meta" < "$home/data/secondmates.md" < + find "$1/state/parent-route/$2.inbox" -maxdepth 1 -type f -name '*.msg' 2>/dev/null +} + +run_remote_notify() { # + local home=$1 fakebin=$2 snap=$3 + FM_SSH_BIN="$fakebin/fake-ssh" FM_REMOTE_CODE_ROOT="$ROOT" \ + PATH="$fakebin:$PATH" FM_HOME="$home" FM_ROOT_OVERRIDE="$ROOT" \ + FM_STATE_OVERRIDE="$home/state" \ + "$RECONCILE" notify --snapshot "$snap" +} + +# Age the home's cooldown record so the next run sees the window as elapsed. +age_cooldown() { # + printf '%s\n' "$(( $(date +%s) - $3 ))" > "$1/$2.reconcile-nudged" +} + +run_notify() { # [extra args...] + local home=$1 fakebin=$2 name=$3 snap=$4 + shift 4 + PATH="$fakebin:$PATH" FM_HOME="$home" FM_ROOT_OVERRIDE="$ROOT" \ + FM_STATE_OVERRIDE="$home/state" \ + FM_FAKE_TMUX_WINDOW="firstmate:fm-mate" \ + FM_FAKE_TMUX_LOG="$TMP_ROOT/$name-tmux.log" \ + FM_FAKE_TMUX_CAPTURE="$TMP_ROOT/$name-fake/pane.txt" \ + "$RECONCILE" notify --snapshot "$snap" "$@" +} + +inbox_records() { # + find "$1/$2.inbox" -maxdepth 1 -type f -name '*.msg' 2>/dev/null | wc -l | tr -d '[:space:]' +} + +# Content-and-name fingerprint of a whole home, so any parent-side write shows up. +fingerprint_tree() { # + find "$1" -type f -print0 2>/dev/null | LC_ALL=C sort -z \ + | while IFS= read -r -d '' f; do printf '%s %s\n' "${f#"$1"}" "$(cksum < "$f")"; done +} + +inbox_text() { # + local rec + for rec in "$1/$2.inbox"/*.msg; do + [ -f "$rec" ] || continue + bash -c '. "$1"; fm_task_inbox_body "$2"' _ "$ROOT/bin/fm-task-inbox-lib.sh" "$rec" + done +} + +hold_lock_until_released() { # + bash -c ' + . "$1" + fm_lock_acquire_wait "$2" + : > "$3" + while [ ! -f "$4" ]; do sleep 0.01; done + fm_lock_release "$2" + ' _ "$ROOT/bin/fm-wake-lib.sh" "$1" "$2" "$3" & +} + + +test_an_inventory_mismatch_asks_the_mate_once_per_window() { + local home mate fakebin snap out + { read -r home; read -r mate; read -r fakebin; } < <(make_main_home once mate) + snap="$home/snapshot.json" + write_snapshot "$snap" mate '{"kind":"orphan_in_flight","ids":["stale-scout","watch-row"]}' + + out=$(run_notify "$home" "$fakebin" once "$snap") || fail "the first reconcile ask failed: $out" + assert_contains "$out" "sent: mate orphan_in_flight" \ + "the first ask did not report what it sent: $out" + [ "$(inbox_records "$home/state" mate)" -eq 1 ] \ + || fail "the ask did not land as exactly one durable steering record" + assert_contains "$(inbox_text "$home/state" mate)" "check your current books" \ + "the instruction did not ask the mate to inspect its current state" + if printf '%s' "$(inbox_text "$home/state" mate)" | grep -Fq 'stale-scout'; then + fail "the instruction prescribed a repair from sampled details that can become stale" + fi + + # Every later recap sees the same mismatch; none of them may nag. + out=$(run_notify "$home" "$fakebin" once "$snap") || fail "the repeat run failed: $out" + assert_contains "$out" "cooldown: mate" "a repeated snapshot did not report the cooldown: $out" + run_notify "$home" "$fakebin" once "$snap" >/dev/null + run_notify "$home" "$fakebin" once "$snap" >/dev/null + [ "$(inbox_records "$home/state" mate)" -eq 1 ] \ + || fail "repeated snapshots asked the mate more than once inside the cooldown" + pass "a home in mismatch is asked once, and later recaps stay silent" +} + +test_a_mismatch_still_there_after_the_window_earns_one_more_nudge() { + local home mate fakebin snap out + { read -r home; read -r mate; read -r fakebin; } < <(make_main_home window mate) + snap="$home/snapshot.json" + write_snapshot "$snap" mate '{"kind":"orphan_in_flight","ids":["ghost"]}' + run_notify "$home" "$fakebin" window "$snap" >/dev/null || fail "the first ask failed" + + # Just inside four hours: still silent. + age_cooldown "$home/state" mate 14000 + out=$(run_notify "$home" "$fakebin" window "$snap") || fail "the in-window run failed: $out" + assert_contains "$out" "cooldown: mate" "an ask inside the window was not suppressed: $out" + [ "$(inbox_records "$home/state" mate)" -eq 1 ] || fail "an in-window ask was sent anyway" + + # Past four hours: exactly one gentle re-nudge, then silent again. + age_cooldown "$home/state" mate 14500 + out=$(run_notify "$home" "$fakebin" window "$snap") || fail "the past-window run failed: $out" + assert_contains "$out" "sent: mate orphan_in_flight" \ + "a mismatch outliving the window did not earn a re-nudge: $out" + [ "$(inbox_records "$home/state" mate)" -eq 2 ] \ + || fail "the re-nudge did not send exactly one more instruction" + run_notify "$home" "$fakebin" window "$snap" >/dev/null + [ "$(inbox_records "$home/state" mate)" -eq 2 ] \ + || fail "the re-nudge did not restart the cooldown" + pass "a mismatch outliving the cooldown earns one re-nudge, then goes quiet again" +} + +test_the_cooldown_starts_when_delivery_finishes() { + local home mate fakebin snap started nudged + { read -r home; read -r mate; read -r fakebin; } < <(make_main_home deliverytime mate) + snap="$home/snapshot.json" + write_snapshot "$snap" mate '{"kind":"orphan_in_flight","ids":["ghost"]}' + mv "$fakebin/tmux" "$fakebin/tmux-real" + cat > "$fakebin/tmux" <<'SH' +#!/usr/bin/env bash +set -u +if [ "${1:-}" = send-keys ]; then sleep 2; fi +exec "$(dirname "$0")/tmux-real" "$@" +SH + chmod +x "$fakebin/tmux" + + started=$(date +%s) + run_notify "$home" "$fakebin" deliverytime "$snap" >/dev/null \ + || fail "the delayed reconcile ask failed" + nudged=$(cat "$home/state/mate.reconcile-nudged") + [ "$nudged" -ge "$((started + 2))" ] \ + || fail "the cooldown began before delivery finished: start=$started nudged=$nudged" + pass "the cooldown begins when delivery finishes" +} + +test_the_window_is_four_hours() { + local home mate fakebin snap out + { read -r home; read -r mate; read -r fakebin; } < <(make_main_home fourhours mate) + snap="$home/snapshot.json" + write_snapshot "$snap" mate '{"kind":"terminal_in_flight","ids":["done-row"]}' + run_notify "$home" "$fakebin" fourhours "$snap" >/dev/null || fail "the first ask failed" + # One second short of four hours is still inside; one second past is not. + age_cooldown "$home/state" mate 14399 + out=$(run_notify "$home" "$fakebin" fourhours "$snap") + assert_contains "$out" "cooldown: mate" "the window was shorter than four hours: $out" + age_cooldown "$home/state" mate 14401 + out=$(run_notify "$home" "$fakebin" fourhours "$snap") + assert_contains "$out" "sent: mate" "the window was longer than four hours: $out" + pass "the cooldown window is four hours" +} + +test_each_home_carries_its_own_cooldown() { + local home mate fakebin snap out + { read -r home; read -r mate; read -r fakebin; } < <(make_main_home perhome mate) + # A second registered mate in the same home, so one nudge cannot silence the other. + cp "$home/state/mate.meta" "$home/state/other.meta" + sed -i.bak 's/fm-mate/fm-other/' "$home/state/other.meta" && rm -f "$home/state/other.meta.bak" + snap="$home/snapshot.json" + jq -n '{schema:"fm-fleet-snapshot.v1", generated:"2026-08-26T00:00:00Z", + secondmate_current:{records:[ + {id:"mate", home:"/tmp/mate", spawn_gen:"spawn-mate", current:{state:"captain_decision",reason:null}, + invalidity:{kind:"orphan_in_flight",ids:["a"]}, + reconcile_inventory:{kind:"orphan_in_flight",ids:["a"]}, + provenance:{selected:"structured-home",trust:"partial-structured"}}, + {id:"other", home:"/tmp/other", spawn_gen:"spawn-mate", current:{state:"captain_decision",reason:null}, + invalidity:{kind:"unowned_current",ids:["b"]}, + reconcile_inventory:{kind:"unowned_current",ids:["b"]}, + provenance:{selected:"structured-home",trust:"partial-structured"}}]}}' > "$snap" + out=$(run_notify "$home" "$fakebin" perhome "$snap") || fail "the first run failed: $out" + assert_contains "$out" "sent: mate" "the first home was not asked: $out" + assert_contains "$out" "sent: other" "the second home was not asked: $out" + [ "$(inbox_records "$home/state" mate)" -eq 1 ] || fail "the first home got the wrong count" + [ "$(inbox_records "$home/state" other)" -eq 1 ] || fail "the second home got the wrong count" + # Only one home's window elapses; the other must stay quiet. + age_cooldown "$home/state" mate 14500 + out=$(run_notify "$home" "$fakebin" perhome "$snap") + assert_contains "$out" "sent: mate" "an elapsed window did not re-nudge its own home: $out" + assert_contains "$out" "cooldown: other" "one home's nudge reset another home's window: $out" + pass "the cooldown is per home, not fleet-wide" +} + +test_the_ask_never_arms_a_reply_expectation_or_a_re_ring() { + local home mate fakebin snap ladder + { read -r home; read -r mate; read -r fakebin; } < <(make_main_home fireforget mate) + snap="$home/snapshot.json" + write_snapshot "$snap" mate '{"kind":"orphan_in_flight","ids":["ghost"]}' + run_notify "$home" "$fakebin" fireforget "$snap" >/dev/null || fail "the ask failed" + [ "$(inbox_records "$home/state" mate)" -eq 1 ] || fail "the ask was not durably recorded" + + # The parent expects no answer, so nothing may chase one. + [ "$(find "$home/state/pending-replies" -type f 2>/dev/null | wc -l | tr -d '[:space:]')" -eq 0 ] \ + || fail "the reconcile ask armed a pending-reply expectation" + + # The record stays unhandled. With the ladder's grace elapsed, an ordinary + # steer in that position is due for a re-ring; this one must stay invisible. + ladder=$(FM_TASK_INBOX_GRACE_SECS=0 bash -c '. "$1"; fm_task_inbox_due_action "$2" "$3"' _ \ + "$ROOT/bin/fm-task-inbox-lib.sh" "$home/state" mate 2>&1 || true) + [ "$ladder" = quiet ] \ + || fail "the unacknowledged reconcile record entered the re-ring ladder: $ladder" + + # Divergence check, so the assertion above cannot pass for the wrong reason: + # the same inbox, same grace, with an ordinary unhandled steer added. + PATH="$fakebin:$PATH" FM_HOME="$home" FM_ROOT_OVERRIDE="$ROOT" \ + FM_STATE_OVERRIDE="$home/state" \ + FM_FAKE_TMUX_WINDOW="firstmate:fm-mate" \ + FM_FAKE_TMUX_LOG="$TMP_ROOT/fireforget-tmux.log" \ + FM_FAKE_TMUX_CAPTURE="$TMP_ROOT/fireforget-fake/pane.txt" \ + "$ROOT/bin/fm-send.sh" mate "an ordinary steer that does expect handling" >/dev/null 2>&1 \ + || fail "the control steer could not be recorded" + ladder=$(FM_TASK_INBOX_GRACE_SECS=0 bash -c '. "$1"; fm_task_inbox_due_action "$2" "$3"' _ \ + "$ROOT/bin/fm-task-inbox-lib.sh" "$home/state" mate 2>&1 || true) + case "$ladder" in + ring\ *) ;; + *) fail "the ladder ignored an ordinary steer too, so the quiet verdict proved nothing: $ladder" ;; + esac + pass "the reconcile ask expects no reply and stays out of a ladder that still rings ordinary steers" +} + +test_a_readable_home_without_a_mismatch_is_never_asked() { + local home mate fakebin snap out + { read -r home; read -r mate; read -r fakebin; } < <(make_main_home quiet mate) + snap="$home/snapshot.json" + write_snapshot "$snap" mate '{"kind":"child_current_unavailable","ids":["x"]}' unknown + out=$(run_notify "$home" "$fakebin" quiet "$snap") || fail "notify failed: $out" + [ "$(inbox_records "$home/state" mate)" -eq 0 ] \ + || fail "an unavailable child state was mistaken for a books problem" + write_snapshot "$snap" mate '{"kind":null,"ids":[]}' no_active_work + out=$(run_notify "$home" "$fakebin" quiet "$snap") || fail "notify failed: $out" + [ "$(inbox_records "$home/state" mate)" -eq 0 ] || fail "a healthy home was asked to reconcile" + pass "only a backlog-vs-metadata mismatch produces an ask" +} + +test_the_parent_never_changes_the_mates_own_files() { + local home mate fakebin snap before after + { read -r home; read -r mate; read -r fakebin; } < <(make_main_home readonly mate) + snap="$home/snapshot.json" + write_snapshot "$snap" mate '{"kind":"orphan_in_flight","ids":["ghost"]}' + before=$(fingerprint_tree "$mate") + [ -n "$before" ] || fail "the mate fixture has no files to compare" + run_notify "$home" "$fakebin" readonly "$snap" >/dev/null || fail "the ask failed" + after=$(fingerprint_tree "$mate") + [ "$before" = "$after" ] \ + || fail "asking for a reconcile changed the mate's own files: $before / $after" + pass "the parent asks and changes nothing inside the mate's home" +} + +test_a_failed_send_is_retried_on_the_next_run() { + local home mate fakebin snap out rc + { read -r home; read -r mate; read -r fakebin; } < <(make_main_home retry mate) + snap="$home/snapshot.json" + write_snapshot "$snap" absent-mate '{"kind":"orphan_in_flight","ids":["ghost"]}' + set +e + out=$(run_notify "$home" "$fakebin" retry "$snap"); rc=$? + set -e + [ "$rc" -ne 0 ] || fail "an unroutable ask reported success: $out" + assert_contains "$out" "failed: absent-mate" "the failure was not reported: $out" + assert_absent "$home/state/absent-mate.reconcile-nudged" \ + "a failed ask started a cooldown and would never be retried" + pass "a failed ask starts no cooldown, so the next run retries it" +} + +test_busy_lifecycle_locks_never_hold_up_the_digest() { + local label home mate fakebin snap lock ready release holder notify out + for label in reconcile control meta; do + { read -r home; read -r mate; read -r fakebin; } < <(make_main_home "busy-$label" mate) + snap="$home/snapshot.json" + write_snapshot "$snap" mate '{"kind":"orphan_in_flight","ids":["ghost"]}' + case "$label" in + reconcile) lock="$home/state/.mate.reconcile.lock" ;; + control) lock="$home/state/.control-mate.lock" ;; + meta) lock="$home/state/.meta-mate.lock" ;; + esac + ready="$home/lock-ready" + release="$home/lock-release" + hold_lock_until_released "$lock" "$ready" "$release" + holder=$! + while [ ! -f "$ready" ]; do sleep 0.01; done + run_notify "$home" "$fakebin" "busy-$label" "$snap" > "$home/notify.out" 2>&1 & + notify=$! + sleep 0.2 + if kill -0 "$notify" 2>/dev/null; then + : > "$release" + wait "$notify" 2>/dev/null || true + wait "$holder" 2>/dev/null || true + fail "a busy $label lock blocked the reconcile path" + fi + wait "$notify" || fail "a busy $label lock made notify fail" + : > "$release" + wait "$holder" || fail "the $label lock holder failed" + out=$(cat "$home/notify.out") + assert_contains "$out" "skipped: mate lock" \ + "a busy $label lock was not reported as a skipped nudge: $out" + assert_absent "$home/state/mate.reconcile-nudged" \ + "a skipped $label-lock nudge started the cooldown" + done + pass "busy reconcile lifecycle locks never block the digest or start cooldown" +} + +test_concurrent_recaps_send_one_instruction() { + local home mate fakebin snap + { read -r home; read -r mate; read -r fakebin; } < <(make_main_home concurrent mate) + snap="$home/snapshot.json" + write_snapshot "$snap" mate '{"kind":"orphan_in_flight","ids":["ghost"]}' + run_notify "$home" "$fakebin" concurrent "$snap" >/dev/null 2>&1 & + run_notify "$home" "$fakebin" concurrent "$snap" >/dev/null 2>&1 & + wait + [ "$(inbox_records "$home/state" mate)" -eq 1 ] \ + || fail "two simultaneous recaps asked the mate twice" + pass "simultaneous recaps still ask the mate only once" +} + +test_a_delayed_snapshot_never_prescribes_a_stale_repair() { + local home mate fakebin old_snap new_snap out text + { read -r home; read -r mate; read -r fakebin; } < <(make_main_home delayed mate) + old_snap="$home/old-snapshot.json" + new_snap="$home/new-snapshot.json" + write_snapshot "$old_snap" mate '{"kind":"orphan_in_flight","ids":["already-repaired"]}' + write_snapshot "$new_snap" mate '{"kind":"unowned_current","ids":["current-row"]}' + + out=$(run_notify "$home" "$fakebin" delayed "$old_snap") \ + || fail "the delayed reconcile ask failed: $out" + assert_contains "$out" "sent: mate orphan_in_flight" \ + "the delayed snapshot did not produce the cooldown-limited check: $out" + out=$(run_notify "$home" "$fakebin" delayed "$new_snap") \ + || fail "the current snapshot reconcile failed: $out" + assert_contains "$out" "cooldown: mate" \ + "the per-home cooldown did not deduplicate the newer observation: $out" + [ "$(inbox_records "$home/state" mate)" -eq 1 ] \ + || fail "the old and new snapshots produced more than one ask inside the cooldown" + text=$(inbox_text "$home/state" mate) + assert_contains "$text" "check your current books" \ + "the delayed ask did not direct the mate to current state" + if printf '%s' "$text" | grep -Eq 'already-repaired|current-row'; then + fail "the delayed ask embedded sampled row details and could prescribe a stale repair: $text" + fi + pass "a delayed snapshot asks for a current check instead of prescribing a stale repair" +} + +test_a_stale_snapshot_never_targets_a_replacement_mate() { + local home mate fakebin snap out + { read -r home; read -r mate; read -r fakebin; } < <(make_main_home stale mate) + snap="$home/snapshot.json" + write_snapshot "$snap" mate '{"kind":"orphan_in_flight","ids":["old-ghost"]}' + awk '{ sub(/^spawn_gen=.*/, "spawn_gen=spawn-replacement"); print }' \ + "$home/state/mate.meta" > "$home/state/mate.meta.tmp" + mv "$home/state/mate.meta.tmp" "$home/state/mate.meta" + + out=$(run_notify "$home" "$fakebin" stale "$snap") \ + || fail "a stale snapshot made reconcile fail: $out" + assert_contains "$out" "stale: mate orphan_in_flight" \ + "the stale snapshot was not identified: $out" + [ "$(inbox_records "$home/state" mate)" -eq 0 ] \ + || fail "a replacement mate received its predecessor's reconcile ask" + assert_absent "$home/state/mate.reconcile-nudged" \ + "a replacement mate inherited cooldown from a stale snapshot" + pass "a stale snapshot cannot ask or silence a replacement mate" +} + +test_teardown_cannot_leave_its_replacement_in_cooldown() { + local home mate fakebin snap signal release lifecycle_done cooldown notify_pid lifecycle_pid + { read -r home; read -r mate; read -r fakebin; } < <(make_main_home lifecycle mate) + snap="$home/snapshot.json" + signal="$home/send-ringing" + release="$home/release-ring" + lifecycle_done="$home/lifecycle-done" + cooldown="$home/state/mate.reconcile-nudged" + write_snapshot "$snap" mate '{"kind":"orphan_in_flight","ids":["ghost"]}' + mv "$fakebin/tmux" "$fakebin/tmux-real" + cat > "$fakebin/tmux" <<'SH' +#!/usr/bin/env bash +set -u +if [ "${1:-}" = send-keys ]; then + : > "$FM_FAKE_TMUX_SEND_SIGNAL" + while [ ! -f "$FM_FAKE_TMUX_SEND_RELEASE" ]; do sleep 0.01; done +fi +exec "$(dirname "$0")/tmux-real" "$@" +SH + chmod +x "$fakebin/tmux" + + FM_FAKE_TMUX_SEND_SIGNAL="$signal" FM_FAKE_TMUX_SEND_RELEASE="$release" \ + run_notify "$home" "$fakebin" lifecycle "$snap" >/dev/null 2>&1 & + notify_pid=$! + while [ ! -f "$signal" ]; do sleep 0.01; done + + ( + . "$ROOT/bin/fm-wake-lib.sh" + fm_lock_acquire_wait "$home/state/.control-mate.lock" + fm_lock_acquire_wait "$home/state/.meta-mate.lock" + rm -rf "$home/state/mate.inbox" + rm -f "$home/state/mate.meta" "$home/state/mate.reconcile-nudged" + cat > "$home/state/mate.meta" < "$lifecycle_done" + ) & + lifecycle_pid=$! + + sleep 0.1 + : > "$release" + wait "$notify_pid" 2>/dev/null || true + wait "$lifecycle_pid" || fail "the simulated teardown and reseed failed" + [ -f "$lifecycle_done" ] || fail "the simulated lifecycle transition did not finish" + assert_absent "$cooldown" \ + "a retired mate's cooldown was recreated after its replacement was seeded" + pass "teardown retires the cooldown before a replacement can inherit it" +} + +# A persistent REMOTE secondmate's parent metadata never carries spawn_gen +# (bin/fm-spawn.sh's spawn_remote_secondmate() never writes one). This is the +# proven marker-bearing path's counterpart: same durable fire-and-forget +# delivery and cooldown behavior, driven end to end through the real remote +# transport, for a mate that legitimately has no generation marker at all. +test_a_markerless_remote_secondmate_is_nudged_once_per_window() { + local home rhome fakebin snap out + fakebin=$(make_remote_ssh_stub "$TMP_ROOT/remote-once") + rhome=$(make_remote_secondmate_home remote-once-mate) + home=$(make_remote_parent_home remote-once remote-once-mate "$rhome" remote-once-host) + snap="$home/snapshot.json" + write_remote_snapshot "$snap" remote-once-mate remote-once-host \ + '{"kind":"orphan_in_flight","ids":["stale-scout"]}' + + out=$(run_remote_notify "$home" "$fakebin" "$snap") \ + || fail "the first markerless reconcile ask failed: $out" + assert_contains "$out" "sent: remote-once-mate orphan_in_flight" \ + "a legitimately markerless remote mate got no ask: $out" + [ -n "$(remote_inbox_records "$rhome" remote-once-mate)" ] \ + || fail "the ask did not land as a durable record in the remote steering inbox" + [ -f "$home/state/remote-once-mate.reconcile-nudged" ] \ + || fail "a successful markerless ask did not start the cooldown" + + out=$(run_remote_notify "$home" "$fakebin" "$snap") \ + || fail "the repeat markerless run failed: $out" + assert_contains "$out" "cooldown: remote-once-mate" \ + "a repeated markerless snapshot did not report the cooldown: $out" + [ "$(remote_inbox_records "$rhome" remote-once-mate | grep -c . || true)" -eq 1 ] \ + || fail "repeated markerless snapshots asked the mate more than once inside the cooldown" + pass "a legitimately markerless persistent remote secondmate is nudged once per window" +} + +# The safety boundary the spawn_gen check protects for local mates has a +# host-keyed counterpart for markerless remote mates: a snapshot sampled +# before the route moved to a different host must never reach the new host's +# mate or arm its cooldown. +test_a_stale_remote_route_is_refused() { + local home rhome fakebin snap out + fakebin=$(make_remote_ssh_stub "$TMP_ROOT/remote-stale") + rhome=$(make_remote_secondmate_home remote-stale-mate) + home=$(make_remote_parent_home remote-stale remote-stale-mate "$rhome" old-host) + snap="$home/snapshot.json" + write_remote_snapshot "$snap" remote-stale-mate old-host \ + '{"kind":"orphan_in_flight","ids":["old-ghost"]}' + # The route was re-seeded to a different host since the snapshot was taken. + sed -i.bak 's/^remote_host=.*/remote_host=new-host/' "$home/state/remote-stale-mate.meta" \ + && rm -f "$home/state/remote-stale-mate.meta.bak" + + out=$(run_remote_notify "$home" "$fakebin" "$snap") \ + || fail "a stale remote-route snapshot made reconcile fail: $out" + assert_contains "$out" "stale: remote-stale-mate orphan_in_flight" \ + "a snapshot sampled from a retired remote route was not identified: $out" + [ -z "$(remote_inbox_records "$rhome" remote-stale-mate)" ] \ + || fail "a replacement remote route received its predecessor's reconcile ask" + assert_absent "$home/state/remote-stale-mate.reconcile-nudged" \ + "a replacement remote route inherited cooldown from a stale snapshot" + pass "a stale remote-route snapshot cannot ask or silence a replacement mate" +} + +test_route_replacement_during_send_is_refused() { + local home rhome fakebin snap signal release out rc notify_pid + fakebin=$(make_remote_ssh_stub "$TMP_ROOT/remote-send-race") + rhome=$(make_remote_secondmate_home remote-send-race-mate) + home=$(make_remote_parent_home remote-send-race remote-send-race-mate "$rhome" old-host) + snap="$home/snapshot.json" + signal="$home/fm-send-started" + release="$home/release-fm-send" + write_remote_snapshot "$snap" remote-send-race-mate old-host \ + '{"kind":"orphan_in_flight","ids":["old-ghost"]}' + cat > "$fakebin/dirname" <<'SH' +#!/usr/bin/env bash +set -u +case "${1:-}" in + */fm-send.sh) + : > "$FM_RECONCILE_RACE_SIGNAL" + while [ ! -f "$FM_RECONCILE_RACE_RELEASE" ]; do sleep 0.01; done + ;; +esac +case "${1:-}" in + */*) printf '%s\n' "${1%/*}" ;; + *) printf '.\n' ;; +esac +SH + chmod +x "$fakebin/dirname" + + rc=0 + FM_RECONCILE_RACE_SIGNAL="$signal" FM_RECONCILE_RACE_RELEASE="$release" \ + run_remote_notify "$home" "$fakebin" "$snap" > "$home/notify.out" 2>&1 & + notify_pid=$! + while [ ! -f "$signal" ]; do + kill -0 "$notify_pid" 2>/dev/null || fail "reconcile exited before entering fm-send" + sleep 0.01 + done + + . "$ROOT/bin/fm-wake-lib.sh" + fm_lock_acquire_wait "$home/state/.control-remote-send-race-mate.lock" + fm_lock_acquire_wait "$home/state/.meta-remote-send-race-mate.lock" + sed 's/^remote_host=.*/remote_host=new-host/' \ + "$home/state/remote-send-race-mate.meta" > "$home/state/remote-send-race-mate.meta.tmp" + mv "$home/state/remote-send-race-mate.meta.tmp" "$home/state/remote-send-race-mate.meta" + sed 's/host: old-host/host: new-host/' \ + "$home/data/secondmates.md" > "$home/data/secondmates.md.tmp" + mv "$home/data/secondmates.md.tmp" "$home/data/secondmates.md" + fm_lock_release "$home/state/.meta-remote-send-race-mate.lock" + fm_lock_release "$home/state/.control-remote-send-race-mate.lock" + : > "$release" + wait "$notify_pid" || rc=$? + out=$(cat "$home/notify.out") + + [ "$rc" -ne 0 ] || fail "a route replacement during send reported success: $out" + assert_contains "$out" "failed: remote-send-race-mate orphan_in_flight" \ + "a route replacement during send was not refused: $out" + [ -z "$(remote_inbox_records "$rhome" remote-send-race-mate)" ] \ + || fail "a replacement route received its predecessor's reconcile ask" + assert_absent "$home/state/remote-send-race-mate.reconcile-nudged" \ + "a refused route replacement started the cooldown" + pass "a route replacement between reconcile and fm-send cannot receive a stale ask" +} + +# A row with neither a spawn generation nor a host carries no safe identity at +# all - the markerless path must not swallow that case the way the original +# bug swallowed every markerless row. +test_a_row_with_no_identity_at_all_fails_loudly() { + local home rhome fakebin snap out + fakebin=$(make_remote_ssh_stub "$TMP_ROOT/remote-noid") + rhome=$(make_remote_secondmate_home remote-noid-mate) + home=$(make_remote_parent_home remote-noid remote-noid-mate "$rhome" remote-noid-host) + snap="$home/snapshot.json" + write_remote_snapshot "$snap" remote-noid-mate "" \ + '{"kind":"orphan_in_flight","ids":["ghost"]}' + + set +e + out=$(run_remote_notify "$home" "$fakebin" "$snap"); rc=$? + set -e + [ "$rc" -ne 0 ] || fail "a row with no sampled identity at all reported success: $out" + assert_contains "$out" "failed: remote-noid-mate orphan_in_flight" \ + "an unidentifiable row was not reported as failed: $out" + [ -z "$(remote_inbox_records "$rhome" remote-noid-mate)" ] \ + || fail "an unidentifiable row still received a reconcile ask" + assert_absent "$home/state/remote-noid-mate.reconcile-nudged" \ + "an unidentifiable row started a cooldown" + pass "a row with neither a spawn generation nor a host fails loudly instead of vanishing" +} + +test_an_inventory_mismatch_asks_the_mate_once_per_window +test_a_mismatch_still_there_after_the_window_earns_one_more_nudge +test_the_cooldown_starts_when_delivery_finishes +test_the_window_is_four_hours +test_each_home_carries_its_own_cooldown +test_the_ask_never_arms_a_reply_expectation_or_a_re_ring +test_a_readable_home_without_a_mismatch_is_never_asked +test_the_parent_never_changes_the_mates_own_files +test_a_failed_send_is_retried_on_the_next_run +test_busy_lifecycle_locks_never_hold_up_the_digest +test_concurrent_recaps_send_one_instruction +test_a_delayed_snapshot_never_prescribes_a_stale_repair +test_a_stale_snapshot_never_targets_a_replacement_mate +test_teardown_cannot_leave_its_replacement_in_cooldown +test_a_markerless_remote_secondmate_is_nudged_once_per_window +test_a_stale_remote_route_is_refused +test_route_replacement_during_send_is_refused +test_a_row_with_no_identity_at_all_fails_loudly diff --git a/tests/fm-secondmate-sync.test.sh b/tests/fm-secondmate-sync.test.sh index 3bc290a5342..93593ed72da 100755 --- a/tests/fm-secondmate-sync.test.sh +++ b/tests/fm-secondmate-sync.test.sh @@ -359,7 +359,7 @@ SH cat > "$fakebin/no-mistakes" <<'SH' #!/usr/bin/env bash if [ "${1:-}" = --version ]; then - printf '%s\n' 'no-mistakes version v1.31.2 (fake)' + printf '%s\n' 'no-mistakes version v1.46.0 (fake)' exit 0 fi exit 0 diff --git a/tests/fm-send-remote-delivery.test.sh b/tests/fm-send-remote-delivery.test.sh index 0b9a6de670e..8a686dc9cb0 100755 --- a/tests/fm-send-remote-delivery.test.sh +++ b/tests/fm-send-remote-delivery.test.sh @@ -37,6 +37,8 @@ set -u . "$ROOT/bin/fm-pending-reply-lib.sh" # shellcheck source=bin/fm-marker-lib.sh . "$ROOT/bin/fm-marker-lib.sh" +# shellcheck source=bin/fm-task-inbox-lib.sh +. "$ROOT/bin/fm-task-inbox-lib.sh" SEND="$ROOT/bin/fm-send.sh" DRAIN="$ROOT/bin/fm-wake-drain.sh" @@ -354,6 +356,38 @@ test_remote_retry_failure_preserves_ambiguous_expectation() { pass "fm-send remote: a failed retry cannot erase an earlier ambiguous delivery" } +test_remote_fire_and_forget_never_arms_reply_recovery() { + local dir fb ssh_log home rhome rc count delivery action + dir="$TMP_ROOT/remote-fire-and-forget"; mkdir -p "$dir" + fb=$(make_stubs "$dir"); ssh_log="$dir/ssh.log"; : > "$ssh_log" + rhome=$(setup_remote_secondmate_home remote-fire-and-forget) + home=$(setup_remote_parent_home remote-fire-and-forget "$rhome") + delivery=0123456789abcdef + + rc=0 + send_env "$fb" "$home" "$ssh_log" FM_FAKE_SSH_AFTER_AMBIGUOUS_RC=1 \ + "$SEND" rsm --fire-and-forget "$delivery" "reconcile your own books" \ + >"$dir/out" 2>"$dir/err" || rc=$? + expect_code 3 "$rc" "an ambiguous fire-and-forget delivery must report unconfirmed" + [ "$(find "$home/state/pending-replies" -maxdepth 1 -type f 2>/dev/null | wc -l | tr -d ' ')" = 0 ] \ + || fail "fire-and-forget delivery created a pending-reply expectation" + count=$(find "$rhome/state/parent-route/rsm.inbox" -name '*.msg' | wc -l | tr -d ' ') + [ "$count" = 1 ] || fail "the ambiguous fire-and-forget delivery did not land exactly once" + action=$(FM_TASK_INBOX_GRACE_SECS=0 FM_TASK_INBOX_RING_MAX=0 \ + fm_task_inbox_due_action "$rhome/state/parent-route" rsm) + [ "$action" = quiet ] || fail "the remote fire-and-forget record armed inbox escalation: $action" + + send_env "$fb" "$home" "$ssh_log" \ + "$SEND" rsm --fire-and-forget "$delivery" "reconcile your own books" \ + >"$dir/retry.out" 2>"$dir/retry.err" \ + || fail "the fire-and-forget retry failed" + count=$(find "$rhome/state/parent-route/rsm.inbox" -name '*.msg' | wc -l | tr -d ' ') + [ "$count" = 1 ] || fail "the same fire-and-forget delivery id created a duplicate remote record" + grep -F "delivery=$delivery" "$(remote_inbox_records "$rhome" | head -1)" >/dev/null \ + || fail "the remote record omitted its fire-and-forget delivery identity" + pass "fm-send remote: fire-and-forget delivery is idempotent without reply recovery" +} + test_remote_send_revalidates_after_retirement_lock() { local dir rhome meta lock ready release rc sender_pid holder_pid dir="$TMP_ROOT/remote-retire-race"; mkdir -p "$dir" @@ -425,6 +459,36 @@ test_remote_send_revalidates_parent_route_after_retirement_lock() { pass "fm-send remote: enqueue revalidates the parent route under its metadata lock" } +test_remote_expected_host_revalidates_final_route() { + local dir fb ssh_log home rhome rc err count + dir="$TMP_ROOT/remote-expected-host"; mkdir -p "$dir" + fb=$(make_stubs "$dir"); ssh_log="$dir/ssh.log"; : > "$ssh_log" + rhome=$(setup_remote_secondmate_home remote-expected-host) + home=$(setup_remote_parent_home remote-expected-host "$rhome") + + rc=0 + send_env "$fb" "$home" "$ssh_log" \ + FM_SEND_EXPECTED_SPAWN_GEN="" FM_SEND_EXPECTED_REMOTE_HOST=remote-mac \ + "$SEND" rsm --fire-and-forget 1111111111111111 "matching expected host" \ + >"$dir/match.out" 2>"$dir/match.err" || rc=$? + expect_code 0 "$rc" "a matching expected remote host must allow delivery" + count=$(remote_inbox_records "$rhome" | grep -c . || true) + [ "$count" = 1 ] || fail "a matching expected remote host did not deliver exactly once" + + rc=0 + send_env "$fb" "$home" "$ssh_log" \ + FM_SEND_EXPECTED_SPAWN_GEN="" FM_SEND_EXPECTED_REMOTE_HOST=retired-mac \ + "$SEND" rsm --fire-and-forget 2222222222222222 "stale expected host" \ + >"$dir/mismatch.out" 2>"$dir/mismatch.err" || rc=$? + [ "$rc" -ne 0 ] || fail "a mismatched expected remote host reported delivery" + err=$(cat "$dir/mismatch.err") + assert_contains "$err" "retired or changed route" \ + "a mismatched expected remote host did not report the route replacement: $err" + count=$(remote_inbox_records "$rhome" | grep -c . || true) + [ "$count" = 1 ] || fail "a mismatched expected remote host reached the remote inbox" + pass "fm-send remote: expected host is enforced by final route validation" +} + test_remote_resolve_key_closes_at_enqueue() { local dir fb ssh_log home rhome rc out dir="$TMP_ROOT/remote-key"; mkdir -p "$dir" @@ -620,8 +684,10 @@ test_local_pending_does_not_close_resolve_key() { test_remote_steer_lands_in_remote_inbox test_remote_rerun_is_idempotent test_remote_retry_failure_preserves_ambiguous_expectation +test_remote_fire_and_forget_never_arms_reply_recovery test_remote_send_revalidates_after_retirement_lock test_remote_send_revalidates_parent_route_after_retirement_lock +test_remote_expected_host_revalidates_final_route test_remote_resolve_key_closes_at_enqueue test_remote_slash_rides_inbox test_remote_real_failure_still_fails diff --git a/tests/fm-session-start.test.sh b/tests/fm-session-start.test.sh index 22bb09df23c..3a5157b370b 100755 --- a/tests/fm-session-start.test.sh +++ b/tests/fm-session-start.test.sh @@ -99,7 +99,7 @@ SH cat > "$fakebin/no-mistakes" <<'SH' #!/usr/bin/env bash if [ "${1:-}" = --version ]; then - printf '%s\n' 'no-mistakes version v1.31.2 (fake) 2026-06-27T00:02:18Z' + printf '%s\n' 'no-mistakes version v1.46.0 (fake) 2026-06-27T00:02:18Z' exit 0 fi exit 0 diff --git a/tests/fm-shared-captain-inheritance.test.sh b/tests/fm-shared-captain-inheritance.test.sh index 2db609b9857..77d2a7a2953 100755 --- a/tests/fm-shared-captain-inheritance.test.sh +++ b/tests/fm-shared-captain-inheritance.test.sh @@ -232,7 +232,7 @@ SH cat > "$fakebin/no-mistakes" <<'SH' #!/usr/bin/env bash if [ "${1:-}" = --version ]; then - printf '%s\n' 'no-mistakes version v1.31.2 (fake)' + printf '%s\n' 'no-mistakes version v1.46.0 (fake)' exit 0 fi exit 0 diff --git a/tests/fm-spawn-pool-base-freshen.test.sh b/tests/fm-spawn-pool-base-freshen.test.sh index 8827e679d6f..df3fa2ee9dc 100755 --- a/tests/fm-spawn-pool-base-freshen.test.sh +++ b/tests/fm-spawn-pool-base-freshen.test.sh @@ -227,11 +227,239 @@ test_unresolved_remote_default_refuses_pool() { pass "an unresolved remote default branch refuses the pooled worktree" } +# A slot left on a stale submodule pin is the field failure this diagnosis exists +# for: a refresh moved the superproject and left the submodule behind, so the +# refusal fires a spawn later, on a slot whose own `git status` looks clean to the +# operator. Nothing here is converged - the gate only has to say why. The fixture +# only builds the repositories; the residue itself is produced by a real spawn, so +# these tests cover the reset that actually strands the submodule. +make_submodule_case() { # + local name=$1 id=$2 case_dir home project origin pool publisher fakebin sub subpin1 subpin2 advanced + case_dir="$TMP_ROOT/$name" + home="$case_dir/home" + project="$case_dir/project" + origin="$case_dir/origin.git" + pool="$case_dir/pool" + publisher="$case_dir/publisher" + sub="$case_dir/sub-origin" + fakebin=$(make_spawn_fakebin "$case_dir/fake") + + mkdir -p "$home/data/$id" "$home/projects" "$home/state" "$home/config" + printf 'codex\n' > "$home/config/crew-harness" + printf 'brief for %s\n' "$id" > "$home/data/$id/brief.md" + touch "$home/state/.last-watcher-beat" + + git init --quiet -b main "$sub" + printf 'pin one\n' > "$sub/lib.txt" + git -C "$sub" add lib.txt + git -C "$sub" -c user.name='Firstmate Tests' -c user.email='tests@example.invalid' commit -qm sub-one + subpin1=$(git -C "$sub" rev-parse HEAD) + printf 'pin two\n' > "$sub/lib.txt" + git -C "$sub" -c user.name='Firstmate Tests' -c user.email='tests@example.invalid' commit -qam sub-two + subpin2=$(git -C "$sub" rev-parse HEAD) + git -C "$sub" checkout --quiet "$subpin1" + + git init --quiet -b main "$project" + printf 'base\n' > "$project/README.md" + git -C "$project" add README.md + git -C "$project" -c protocol.file.allow=always -c user.name='Firstmate Tests' -c user.email='tests@example.invalid' \ + submodule --quiet add "file://$sub" ui + git -C "$project" -c user.name='Firstmate Tests' -c user.email='tests@example.invalid' commit -qm initial + git clone --quiet --bare "$project" "$origin" + git -C "$project" remote add origin "file://$origin" + git -C "$project" worktree add --quiet --detach "$pool" HEAD + git -C "$pool" -c protocol.file.allow=always submodule --quiet update --init + + # Advance origin and move the submodule pin, exactly as the field incident did. + git clone --quiet "file://$origin" "$publisher" + git -C "$publisher" -c protocol.file.allow=always submodule --quiet update --init + git -C "$publisher/ui" checkout --quiet "$subpin2" + git -C "$publisher" -c user.name='Firstmate Tests' -c user.email='tests@example.invalid' commit -qam advance-pin + git -C "$publisher" push --quiet origin main + advanced=$(git -C "$publisher" rev-parse HEAD) + + printf '%s\n' "$case_dir|$home|$project|$pool|$fakebin|$subpin1|$subpin2|$advanced" +} + +read_submodule_case() { + IFS='|' read -r CASE_DIR HOME_DIR PROJECT_DIR POOL_DIR FAKEBIN_DIR SUBPIN1 SUBPIN2 ADVANCED_SHA < + local id=$1 out status + mkdir -p "$HOME_DIR/data/$id" + printf 'brief for %s\n' "$id" > "$HOME_DIR/data/$id/brief.md" + out=$(run_spawn "$id" --mode no-mistakes --yolo off) + status=$? + expect_code 0 "$status" "the spawn that moves the submodule pin should succeed" + assert_contains "$out" "spawned $id" "the spawn that moves the submodule pin did not report success" + [ "$(git -C "$POOL_DIR" rev-parse HEAD)" = "$ADVANCED_SHA" ] \ + || fail "the first spawn did not move the pooled base across the moved submodule pin" + [ "$(git -C "$POOL_DIR/ui" rev-parse HEAD)" = "$SUBPIN1" ] \ + || fail "the first spawn did not strand the submodule on the pin the old base recorded" +} + +test_stale_submodule_pin_explains_itself() { + local rec id out status before before_sub + id='pool-stale-pin-r7' + rec=$(make_submodule_case stale-pin "$id") + read_submodule_case "$rec" + strand_submodule_pin_via_spawn 'pool-stale-pin-seed-r7' + before=$(git -C "$POOL_DIR" rev-parse HEAD) + before_sub=$(git -C "$POOL_DIR/ui" rev-parse HEAD) + + out=$(run_spawn "$id" --mode no-mistakes --yolo off) + status=$? + [ "$status" -ne 0 ] || fail "the second spawn launched from a slot carrying a stale submodule pin" + assert_contains "$out" "stale submodule checkout" \ + "refusal did not name the cause as a stale submodule checkout" + assert_contains "$out" "submodule 'ui'" "refusal did not name the submodule" + assert_contains "$out" "$SUBPIN1" "refusal did not report the pin the slot actually has" + assert_contains "$out" "$SUBPIN2" "refusal did not report the pin the base records" + # No remedy is printed on purpose: the containment check reads local refs only, + # so a stale remote-tracking ref can make an unpushed commit look contained, and + # a checkout command on that judgement could cost the operator a commit. + assert_not_contains "$out" "submodule update --checkout" \ + "refusal printed a remedy command the containment check cannot stand behind" + assert_not_contains "$out" "refusing to discard uncommitted work" \ + "a stale pin was misreported as uncommitted work" + [ "$(git -C "$POOL_DIR" rev-parse HEAD)" = "$before" ] \ + || fail "spawn moved HEAD while refusing a stale submodule pin" + [ "$(git -C "$POOL_DIR/ui" rev-parse HEAD)" = "$before_sub" ] \ + || fail "spawn converged the submodule; this gate must never touch the slot" + if [ "${FM_TEST_EVIDENCE:-0}" = 1 ]; then + printf '# observed stale-pin refusal: %s\n' "$(printf '%s\n' "$out" | grep 'submodule' | head -n 1)" + fi + pass "two consecutive spawns across a moved submodule pin end in a refusal naming both pins and no remedy" +} + +test_unpushed_submodule_commit_is_still_uncommitted_work() { + local rec id out status unpushed before before_sub + id='pool-sub-unpushed-r10' + rec=$(make_submodule_case sub-unpushed "$id") + read_submodule_case "$rec" + strand_submodule_pin_via_spawn 'pool-sub-unpushed-seed-r10' + # A commit made inside the submodule and never pushed leaves the submodule work + # tree clean and the pins different - the same two facts a stale pin shows. Any + # checkout of the recorded pin would move HEAD off this commit and leave it + # unreferenced, so this case must keep the conservative refusal. + printf 'unlanded submodule work\n' > "$POOL_DIR/ui/unlanded.txt" + git -C "$POOL_DIR/ui" add unlanded.txt + git -C "$POOL_DIR/ui" -c user.name='Firstmate Tests' -c user.email='tests@example.invalid' \ + commit -qm unlanded-submodule-work + unpushed=$(git -C "$POOL_DIR/ui" rev-parse HEAD) + [ -z "$(git -C "$POOL_DIR/ui" status --porcelain)" ] \ + || fail "fixture did not leave the submodule work tree clean" + [ "$unpushed" != "$(git -C "$POOL_DIR" rev-parse "HEAD:ui")" ] \ + || fail "fixture did not leave the recorded pin different from what is checked out" + before=$(git -C "$POOL_DIR" rev-parse HEAD) + before_sub=$unpushed + + out=$(run_spawn "$id" --mode no-mistakes --yolo off) + status=$? + [ "$status" -ne 0 ] || fail "spawn launched from a slot holding an unpushed submodule commit" + assert_contains "$out" "refusing to discard uncommitted work" \ + "an unpushed submodule commit was not refused as uncommitted work" + assert_not_contains "$out" "stale submodule checkout" \ + "an unpushed submodule commit was misreported as a stale pin" + assert_not_contains "$out" "is checked out at" \ + "an unpushed submodule commit still drew the stale-pin diagnosis" + [ "$(git -C "$POOL_DIR/ui" rev-parse HEAD)" = "$before_sub" ] \ + || fail "spawn moved the submodule off its unpushed commit" + git -C "$POOL_DIR/ui" cat-file -e "$unpushed^{commit}" \ + || fail "the unpushed submodule commit did not survive the refusal" + assert_grep 'unlanded submodule work' "$POOL_DIR/ui/unlanded.txt" \ + "spawn discarded the unpushed submodule work while refusing the pool" + [ "$(git -C "$POOL_DIR" rev-parse HEAD)" = "$before" ] \ + || fail "spawn moved HEAD while refusing a slot holding an unpushed submodule commit" + pass "an unpushed submodule commit keeps the uncommitted-work refusal and survives it" +} + +test_work_inside_submodule_is_still_uncommitted_work() { + local rec id out status + id='pool-sub-work-r8' + rec=$(make_submodule_case sub-work "$id") + read_submodule_case "$rec" + strand_submodule_pin_via_spawn 'pool-sub-work-seed-r8' + # Put the submodule back on the pin the base records, so the ONLY deviation is + # real work inside it. This must never be softened into a stale-pin diagnosis. + git -C "$POOL_DIR/ui" checkout --quiet "$SUBPIN2" + printf 'work that must survive\n' > "$POOL_DIR/ui/keep-me.txt" + + out=$(run_spawn "$id" --mode no-mistakes --yolo off) + status=$? + [ "$status" -ne 0 ] || fail "spawn launched from a slot holding work inside a submodule" + assert_contains "$out" "refusing to discard uncommitted work" \ + "work inside a submodule was not refused as uncommitted work" + assert_not_contains "$out" "stale submodule checkout" \ + "real work inside a submodule was misreported as a stale pin" + assert_grep 'work that must survive' "$POOL_DIR/ui/keep-me.txt" \ + "spawn discarded work inside the submodule while refusing the pool" + pass "work inside a submodule is still refused as uncommitted work, not called stale" +} + +test_stale_pin_carrying_real_work_is_not_called_stale() { + local rec id out status + id='pool-sub-both-r9' + rec=$(make_submodule_case sub-both "$id") + read_submodule_case "$rec" + strand_submodule_pin_via_spawn 'pool-sub-both-seed-r9' + # Stale pin AND real work inside it: calling this merely stale would be wrong, so + # the refusal must stay the conservative one. + printf 'work that must survive\n' > "$POOL_DIR/ui/keep-me.txt" + + out=$(run_spawn "$id" --mode no-mistakes --yolo off) + status=$? + [ "$status" -ne 0 ] || fail "spawn launched from a slot with a stale pin and work inside it" + assert_contains "$out" "refusing to discard uncommitted work" \ + "a stale pin carrying real work was not refused as uncommitted work" + assert_not_contains "$out" "stale submodule checkout" \ + "a submodule holding real work was reported as merely stale" + assert_grep 'work that must survive' "$POOL_DIR/ui/keep-me.txt" \ + "spawn discarded work inside the submodule while refusing the pool" + pass "a stale pin carrying real work is refused conservatively, never called stale" +} + +test_stale_pin_beside_other_dirt_reports_one_verdict() { + local rec id out status + id='pool-sub-mixed-r11' + rec=$(make_submodule_case sub-mixed "$id") + read_submodule_case "$rec" + strand_submodule_pin_via_spawn 'pool-sub-mixed-seed-r11' + # Git sorts status paths, so the stale 'ui' entry is scanned before this file. + # The conservative verdict must not arrive contradicted by a stale-pin line. + printf 'notes the operator still wants\n' > "$POOL_DIR/zz-notes.txt" + + out=$(run_spawn "$id" --mode no-mistakes --yolo off) + status=$? + [ "$status" -ne 0 ] || fail "spawn launched from a slot with a stale pin beside an untracked file" + assert_contains "$out" "refusing to discard uncommitted work" \ + "a stale pin beside an untracked file was not refused as uncommitted work" + assert_not_contains "$out" "stale submodule checkout" \ + "a slot carrying more than a stale pin was reported as merely stale" + assert_not_contains "$out" "is checked out at" \ + "the stale-pin diagnosis was printed alongside the conservative refusal" + assert_grep 'notes the operator still wants' "$POOL_DIR/zz-notes.txt" \ + "spawn discarded the untracked file while refusing the pool" + pass "a stale pin beside other dirt yields the conservative refusal alone, with no stale-pin line" +} + test_stale_pool_base_refreshes_before_branching test_non_main_default_branch_refreshes_before_branching test_direct_pr_and_scout_refresh_before_launch test_dirty_pool_refuses_without_discarding_work test_unresolved_remote_default_refuses_pool test_unreachable_origin_refuses_stale_pool_base +test_stale_submodule_pin_explains_itself +test_unpushed_submodule_commit_is_still_uncommitted_work +test_work_inside_submodule_is_still_uncommitted_work +test_stale_pin_carrying_real_work_is_not_called_stale +test_stale_pin_beside_other_dirt_reports_one_verdict echo "# all fm-spawn-pool-base-freshen tests passed" diff --git a/tests/fm-startup-memory-budget.test.sh b/tests/fm-startup-memory-budget.test.sh index 262c168461a..e96ad1ad137 100755 --- a/tests/fm-startup-memory-budget.test.sh +++ b/tests/fm-startup-memory-budget.test.sh @@ -44,7 +44,7 @@ SH cat > "$fakebin/no-mistakes" <<'SH' #!/usr/bin/env bash if [ "${1:-}" = --version ]; then - printf '%s\n' 'no-mistakes version v1.31.2 (fake)' + printf '%s\n' 'no-mistakes version v1.46.0 (fake)' fi SH cat > "$fakebin/tasks-axi" <<'SH' diff --git a/tests/fm-task-inbox.test.sh b/tests/fm-task-inbox.test.sh index 84d44f4f967..02a4568c37d 100644 --- a/tests/fm-task-inbox.test.sh +++ b/tests/fm-task-inbox.test.sh @@ -278,6 +278,24 @@ test_ladder_writes_ignore_vanished_inbox() { pass "inbox: ladder bookkeeping ignores a concurrently removed inbox" } +test_fire_and_forget_records_never_enter_the_ladder() { + local state fire tracked action + state="$TMP_ROOT/fire-and-forget/state"; mkdir -p "$state" + fire=$(inbox_lib "$state" fm_task_inbox_write_idempotent "$state" t1 "one-shot steer" fire-and-forget) + age_path "$fire" + action=$(FM_TASK_INBOX_GRACE_SECS=0 FM_TASK_INBOX_RING_MAX=0 \ + inbox_lib "$state" fm_task_inbox_due_action "$state" t1) + [ "$action" = quiet ] || fail "a fire-and-forget record entered the re-ring ladder: $action" + tracked=$(inbox_lib "$state" fm_task_inbox_write "$state" t1 "tracked steer") + age_path "$tracked" + action=$(FM_TASK_INBOX_GRACE_SECS=0 FM_TASK_INBOX_RING_MAX=0 \ + inbox_lib "$state" fm_task_inbox_due_action "$state" t1) + [ "$action" = "escalate $tracked 0" ] \ + || fail "a fire-and-forget record hid the later tracked steer: $action" + [ -f "$fire" ] || fail "excluding fire-and-forget from escalation removed its durable record" + pass "inbox: fire-and-forget records stay durable and outside the ladder" +} + test_ring_ladder_policy() { local state rec action state="$TMP_ROOT/ladder/state"; mkdir -p "$state" @@ -485,6 +503,7 @@ test_idempotent_write_follows_concurrent_ack test_handled_mv_dedups_by_sequence test_concurrent_writers_never_clobber test_ladder_writes_ignore_vanished_inbox +test_fire_and_forget_records_never_enter_the_ladder test_ring_ladder_policy test_watcher_rerings_idle_pane_quietly test_watcher_waits_on_busy_pane diff --git a/tests/fm-watch-triage.test.sh b/tests/fm-watch-triage.test.sh index bf68537963f..1fcfc7be7d6 100755 --- a/tests/fm-watch-triage.test.sh +++ b/tests/fm-watch-triage.test.sh @@ -1897,6 +1897,208 @@ test_busy_declared_pause_is_rechecked_not_wedge_escalated() { pass "a busy pane under a declared pause is rechecked on the long cadence, and lifting the pause restores the wedge escalation" } +# --- declared pause + busy pane + AWAY MODE: the bound must hand off, not decorate +# Away mode is daemon-owned: the watcher reverts to one-shot and lets the daemon +# classify. The busy-turn bound used to be the one stale path that ignored that, +# running the wedge timer under afk and handing the daemon a wake already decorated +# as a possible wedge. That decoration outranks the daemon's own pause verdict, so a +# crew that declared the wait itself was wedge-escalated once per +# FM_STALE_ESCALATE_SECS for as long as the wait lasted, with the escalation count +# climbing into demand-deep-inspection on a pane nobody needed to inspect. +# Phase A pins the handoff: the plain window identity, no wedge timer, no escalation +# counter, and no normal-mode pause bookkeeping (the daemon owns that in away mode). +# Phase B re-arms on the same unchanged pane and pins the one-shot: a second wake +# here is what the climbing ladder looked like. Phase C drives the discriminator +# apart on the SAME afk, busy, over-age pane - lifting the declaration restores the +# wedge escalation, so this is the worker's declaration being honored rather than +# away mode silencing the escalator. +test_afk_busy_declared_pause_hands_off_plain_stale() { + local dir state fakebin out capture_file window key sig pid statusf + dir=$(make_case afk-busy-declared-pause); state="$dir/state"; fakebin="$dir/fakebin" + out="$dir/watch.out"; capture_file="$dir/pane.txt"; window="test:fm-afk-review-scout" + statusf="$state/afk-review-scout.status" + printf 'Working... (7200.4s) atelier-axi poll' > "$capture_file" + printf 'window=%s\nkind=scout\nharness=pi\n' "$window" > "$state/afk-review-scout.meta" + record_pi_busy "$state" afk-review-scout + printf 'paused: hosting the Atelier review, awaiting captain feedback\n' > "$statusf" + sig=$(seen_sig "$statusf"); printf '%s' "$sig" > "$state/.seen-afk-review-scout_status" + key=$(printf '%s' "$window" | tr ':/.' '___') + touch -t 200001010000 "$state/afk-review-scout.meta" + date '+%s' > "$state/.afk" + + # Phase A: past the bound, with the wedge threshold as low as it goes, the + # declaration is handed to the daemon undecorated instead of being wedge-timed. + PATH="$fakebin:$PATH" FM_FAKE_TMUX_WINDOW="$window" FM_FAKE_TMUX_CAPTURE="$capture_file" \ + FM_STATE_OVERRIDE="$state" FM_CREW_STATE_BIN="$fakebin/fm-crew-state.sh" \ + FM_FAKE_CREW_STATE='state: working · source: pane · harness busy (pi-ext)' \ + FM_BUSY_TURN_MAX_SECS=1 FM_STALE_ESCALATE_SECS=1 FM_PAUSE_RESURFACE_SECS=999 \ + FM_POLL=0.2 FM_SIGNAL_GRACE=1 \ + FM_CHECK_INTERVAL=999999 FM_HEARTBEAT=999999 "$WATCH" > "$out" & + pid=$! + wait_for_exit "$pid" 150 || { reap "$pid"; fail "the away-mode busy-turn bound never handed the declared pause to the daemon"; } + grep -Fx "stale: $window" "$out" >/dev/null \ + || fail "the away-mode busy-turn bound did not hand off the plain window identity: $(cat "$out")" + grep -F "possible wedge" "$out" >/dev/null \ + && fail "away mode decorated a declared pause as a possible wedge: $(cat "$out")" + [ ! -e "$state/.stale-since-$key" ] \ + || fail "the away-mode handoff started the wedge timer on a declared pause" + [ ! -e "$state/.wedge-escalations-$key" ] \ + || fail "the away-mode handoff incremented the wedge escalation count on a declared pause" + [ ! -e "$state/.paused-$key" ] \ + || fail "the away-mode handoff recorded normal-mode pause tracking instead of leaving it to the daemon" + ack_stopped_cycle "$state" || fail "could not acknowledge the away-mode declared-pause handoff" + + # Phase B: re-arm on the same unchanged pane. The bound has already handed this + # stale hash off, so it must stay silent rather than re-waking the daemon - a + # second wake here is the escalation ladder the wedge timer used to climb. + : > "$out" + PATH="$fakebin:$PATH" FM_FAKE_TMUX_WINDOW="$window" FM_FAKE_TMUX_CAPTURE="$capture_file" \ + FM_STATE_OVERRIDE="$state" FM_CREW_STATE_BIN="$fakebin/fm-crew-state.sh" \ + FM_FAKE_CREW_STATE='state: working · source: pane · harness busy (pi-ext)' \ + FM_BUSY_TURN_MAX_SECS=1 FM_STALE_ESCALATE_SECS=1 FM_PAUSE_RESURFACE_SECS=999 \ + FM_POLL=0.2 FM_SIGNAL_GRACE=1 \ + FM_CHECK_INTERVAL=999999 FM_HEARTBEAT=999999 "$WATCH" > "$out" & + pid=$! + wait_poll_cycle "$state" "$pid" || { reap "$pid"; fail "the away-mode bound re-woke on an already-handed-off declared pause: $(cat "$out")"; } + reap "$pid" + [ ! -s "$out" ] || fail "the away-mode bound re-surfaced an already-handed-off declared pause: $(cat "$out")" + [ ! -e "$state/.wedge-escalations-$key" ] \ + || fail "re-arming on an unchanged declared pause started a wedge escalation ladder" + ack_stopped_cycle "$state" || fail "could not acknowledge the intentional away-mode re-arm stop" + + # Phase C: lift the declaration on the SAME afk, busy, over-age pane. Nothing else + # changes, so a wedge escalation here proves the declaration was the discriminator. + printf 'working: resumed the review write-up\n' > "$statusf" + sig=$(seen_sig "$statusf"); printf '%s' "$sig" > "$state/.seen-afk-review-scout_status" + echo $(( $(date +%s) - 500 )) > "$state/.stale-since-$key" + : > "$out" + PATH="$fakebin:$PATH" FM_FAKE_TMUX_WINDOW="$window" FM_FAKE_TMUX_CAPTURE="$capture_file" \ + FM_STATE_OVERRIDE="$state" FM_CREW_STATE_BIN="$fakebin/fm-crew-state.sh" \ + FM_FAKE_CREW_STATE='state: working · source: pane · harness busy (pi-ext)' \ + FM_BUSY_TURN_MAX_SECS=1 FM_STALE_ESCALATE_SECS=240 FM_PAUSE_RESURFACE_SECS=999 \ + FM_POLL=0.2 FM_SIGNAL_GRACE=1 \ + FM_CHECK_INTERVAL=999999 FM_HEARTBEAT=999999 "$WATCH" > "$out" & + pid=$! + wait_for_exit "$pid" 150 || { reap "$pid"; fail "a lifted pause on an away-mode over-age busy pane no longer wedge-escalates"; } + grep -F "possible wedge" "$out" >/dev/null \ + || fail "the restored away-mode busy-turn escalation did not flag a possible wedge: $(cat "$out")" + pass "away mode hands a busy declared pause to the daemon as a plain stale, and lifting the declaration restores the wedge escalation" +} + +# --- declared pause + busy pane + AWAY MODE + a TICKING footer: one wake per declaration +# The static-pane case above cannot tell a hash-keyed one-shot from a +# declaration-keyed one, because its capture never changes between polls. The +# incident pane's harness footer ticks on every capture, so a one-shot keyed on the +# pane hash re-fires on every poll, and the daemon, which relaunches the watcher +# after each handled wake, is woken in a loop for the whole declared wait. This +# fixture's fake tmux renders a fresh footer on EVERY capture-pane and asserts that +# divergence outright on every re-arm (.hash- moves, .count- never +# climbs), so the one-wake assertion across five silent re-arms cannot pass +# vacuously on a pane that happened to sit still. Round 1 also starts from an +# undeclared wedge timer and escalation count, which the handoff must clear the +# way the normal-mode absorber does, so lifting the declaration later starts the +# wedge path from a fresh timer rather than resuming a stale count. +test_afk_busy_declared_pause_ticking_pane_hands_off_once() { + local dir state fakebin out drain_out window key sig pid statusf ticks round prev_hash cur_hash prev_ticks + dir=$(make_case afk-busy-declared-pause-ticking); state="$dir/state"; fakebin="$dir/fakebin" + out="$dir/watch.out"; drain_out="$dir/drain.out"; window="test:fm-afk-ticking-scout" + statusf="$state/afk-ticking-scout.status"; ticks="$dir/ticks" + cat > "$fakebin/tmux" <<'SH' +#!/usr/bin/env bash +set -u +case "${1:-}" in + list-windows) + [ -n "${FM_FAKE_TMUX_WINDOW:-}" ] && printf '%s\n' "${FM_FAKE_TMUX_WINDOW#*:}" + exit 0 ;; + capture-pane) + n=$(( $(cat "$FM_FAKE_TMUX_TICKS" 2>/dev/null || echo 0) + 1 )) + echo "$n" > "$FM_FAKE_TMUX_TICKS" + printf 'Working... (%d.%ds) atelier-axi poll' "$(( 7200 + n ))" "$(( n % 10 ))" + exit 0 ;; + display-message) + case "$*" in + *pane_current_command*) printf '%s\n' "${FM_FAKE_TMUX_CURRENT_COMMAND:-}"; exit 0 ;; + esac ;; +esac +exit 1 +SH + chmod +x "$fakebin/tmux" + printf 'window=%s\nkind=scout\nharness=pi\n' "$window" > "$state/afk-ticking-scout.meta" + record_pi_busy "$state" afk-ticking-scout + printf 'paused: hosting the Atelier review, awaiting captain feedback\n' > "$statusf" + sig=$(seen_sig "$statusf"); printf '%s' "$sig" > "$state/.seen-afk-ticking-scout_status" + key=$(printf '%s' "$window" | tr ':/.' '___') + touch -t 200001010000 "$state/afk-ticking-scout.meta" + date '+%s' > "$state/.afk" + # An undeclared busy phase already ran the wedge timer and escalated twice + # before the crew declared the wait. + echo $(( $(date +%s) - 500 )) > "$state/.stale-since-$key" + printf '2\n' > "$state/.wedge-escalations-$key" + date +%s > "$state/.writing-since-$key" + + # Round 1: the declaration is handed off once, undecorated, and the undeclared + # phase's wedge bookkeeping is cleared with it. + PATH="$fakebin:$PATH" FM_FAKE_TMUX_WINDOW="$window" FM_FAKE_TMUX_TICKS="$ticks" \ + FM_STATE_OVERRIDE="$state" FM_CREW_STATE_BIN="$fakebin/fm-crew-state.sh" \ + FM_FAKE_CREW_STATE='state: working · source: pane · harness busy (pi-ext)' \ + FM_BUSY_TURN_MAX_SECS=1 FM_STALE_ESCALATE_SECS=1 FM_PAUSE_RESURFACE_SECS=999 \ + FM_POLL=0.2 FM_SIGNAL_GRACE=1 \ + FM_CHECK_INTERVAL=999999 FM_HEARTBEAT=999999 "$WATCH" > "$out" & + pid=$! + wait_for_exit "$pid" 150 || { reap "$pid"; fail "the away-mode busy-turn bound never handed a ticking declared pause to the daemon"; } + grep -Fx "stale: $window" "$out" >/dev/null \ + || fail "the away-mode busy-turn bound did not hand off the plain window identity for a ticking pane: $(cat "$out")" + grep -F "possible wedge" "$out" >/dev/null \ + && fail "away mode decorated a ticking declared pause as a possible wedge: $(cat "$out")" + [ ! -e "$state/.stale-since-$key" ] \ + || fail "the away-mode handoff left the undeclared phase's wedge timer in place" + [ ! -e "$state/.wedge-escalations-$key" ] \ + || fail "the away-mode handoff left the undeclared phase's escalation count in place" + [ ! -e "$state/.writing-since-$key" ] \ + || fail "the away-mode handoff left the undeclared phase's write-deferral chain in place" + [ ! -e "$state/.paused-$key" ] \ + || fail "the away-mode handoff recorded normal-mode pause tracking on a ticking pane" + ack_stopped_cycle "$state" || fail "could not acknowledge the ticking declared-pause handoff" + + # Rounds 2-6: five consecutive re-arms on the same standing declaration. Every + # capture renders a new footer, so every poll lands on the changed-hash branch - + # the exact shape a hash-keyed one-shot re-fires on. Each round proves the pane + # really moved before it asserts silence, so the case cannot go vacuous. + round=2 + while [ "$round" -le 6 ]; do + prev_hash=$(cat "$state/.hash-$key" 2>/dev/null || true) + prev_ticks=$(cat "$ticks" 2>/dev/null || echo 0) + : > "$out" + PATH="$fakebin:$PATH" FM_FAKE_TMUX_WINDOW="$window" FM_FAKE_TMUX_TICKS="$ticks" \ + FM_STATE_OVERRIDE="$state" FM_CREW_STATE_BIN="$fakebin/fm-crew-state.sh" \ + FM_FAKE_CREW_STATE='state: working · source: pane · harness busy (pi-ext)' \ + FM_BUSY_TURN_MAX_SECS=1 FM_STALE_ESCALATE_SECS=1 FM_PAUSE_RESURFACE_SECS=999 \ + FM_POLL=0.2 FM_SIGNAL_GRACE=1 \ + FM_CHECK_INTERVAL=999999 FM_HEARTBEAT=999999 "$WATCH" > "$out" & + pid=$! + wait_poll_cycle "$state" "$pid" || { reap "$pid"; fail "re-arm $round on a ticking declared pause re-woke the daemon: $(cat "$out")"; } + reap "$pid" + cur_hash=$(cat "$state/.hash-$key" 2>/dev/null || true) + [ "$(cat "$ticks" 2>/dev/null || echo 0)" -gt "$prev_ticks" ] \ + || fail "re-arm $round never captured the pane, so its silence proves nothing" + [ -n "$cur_hash" ] && [ "$cur_hash" != "$prev_hash" ] \ + || fail "re-arm $round saw the same pane hash as the round before, so it cannot tell a hash-keyed one-shot from a declaration-keyed one" + [ "$(cat "$state/.count-$key" 2>/dev/null || echo missing)" = 0 ] \ + || fail "re-arm $round settled on a stable hash instead of ticking on every poll" + [ ! -s "$out" ] || fail "re-arm $round re-surfaced a standing declared pause on a ticking pane: $(cat "$out")" + [ ! -e "$state/.stale-since-$key" ] \ + || fail "re-arm $round started the wedge timer on a standing declared pause" + [ ! -e "$state/.wedge-escalations-$key" ] \ + || fail "re-arm $round climbed the wedge escalation ladder on a standing declared pause" + ack_stopped_cycle "$state" || fail "could not acknowledge the intentional re-arm $round stop" + round=$((round + 1)) + done + FM_STATE_OVERRIDE="$state" "$DRAIN" > "$drain_out" 2>/dev/null || true + grep "$(printf '\tstale\t')" "$drain_out" >/dev/null \ + && fail "the silent re-arms still queued a stale row for the standing declaration: $(cat "$drain_out")" + pass "away mode wakes the daemon once per declaration for a busy pane whose footer ticks on every capture" +} + # Behavioral proof that the production default (no FM_BUSY_TURN_MAX_SECS override # anywhere in this env) is 3600s: a completed turn 5 minutes old must not start a # wedge timer, while one 66 minutes old must - bracketing the default around 3600 @@ -2774,6 +2976,8 @@ test_busy_pane_turn_end_touch_resets_age test_busy_pane_repeated_escalation_reaches_demand_deep_inspection test_busy_pane_default_turn_age_bound_is_3600s test_busy_declared_pause_is_rechecked_not_wedge_escalated +test_afk_busy_declared_pause_hands_off_plain_stale +test_afk_busy_declared_pause_ticking_pane_hands_off_once test_nonterminal_stale_not_working_surfaced test_nonterminal_stale_paused_absorbed_then_resurfaced test_exited_declared_pause_is_bounded_but_live_gate_surfaces