diff --git a/.agents/skills/bootstrap-diagnostics/SKILL.md b/.agents/skills/bootstrap-diagnostics/SKILL.md index daa0c4e421..7bc3ac10b8 100644 --- a/.agents/skills/bootstrap-diagnostics/SKILL.md +++ b/.agents/skills/bootstrap-diagnostics/SKILL.md @@ -19,7 +19,8 @@ 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 `tasks-axi`, this also covers an installed build that fails the compatibility probe (`docs/configuration.md` "Backlog backend" owns the definition); `config/backlog-backend=manual` only suppresses the verbose `BOOTSTRAP_INFO: tasks-axi available` fact, not this missing-tool report. + For any axi-family tool - `gh-axi`, `lavish-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. - `MISSING_MANUAL: (instructions: )` - tell the captain why the tool is required and give them the printed instructions URL, but do not pass the tool to `bin/fm-bootstrap.sh install`; wait for the captain to complete the manual installation, then rerun session start to confirm the dependency is present. - `BACKEND_INVALID: (known: )` - the resolved runtime backend has no verified dependency or lifecycle contract, so do not dispatch work until the invalid `FM_BACKEND` or `config/backend` value is corrected to one of the listed backends. diff --git a/.agents/skills/secondmate-provisioning/SKILL.md b/.agents/skills/secondmate-provisioning/SKILL.md index 6d263e38ef..12b90bed3a 100644 --- a/.agents/skills/secondmate-provisioning/SKILL.md +++ b/.agents/skills/secondmate-provisioning/SKILL.md @@ -82,7 +82,7 @@ The slot stays reserved across restarts until the lease is released. Release happens only on explicit retirement or seed rollback, never on routine restart or recovery. `bin/fm-home-seed.sh` copies the charter into the secondmate home as `data/charter.md`. -It also writes the required `.fm-secondmate-home` identity marker, which is gitignored and must remain in place for home validation. +It also writes the gitignored `.fm-secondmate-parent` durable binding before the required `.fm-secondmate-home` identity marker; the parser header in [`bin/fm-secondmate-parent-lib.sh`](../../../bin/fm-secondmate-parent-lib.sh) owns the record contract, and both files must remain in place. `bin/fm-spawn.sh --secondmate` launches it through the secondmate harness path, resolving `config/secondmate-harness` -> `config/crew-harness` -> the primary's own harness unless an explicit per-spawn harness override is passed. `config/secondmate-harness` may also pin a concrete model and effort for the secondmate agent, in the SAME file rather than a new one: the format is a single whitespace-separated line ` [] []`, with only the first non-empty, non-comment line parsed. diff --git a/.gitignore b/.gitignore index 1e5e8642ef..cae904c651 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ data/ .no-mistakes/ .lavish/ .fm-secondmate-home +.fm-secondmate-parent .DS_Store __pycache__/ *.pyc diff --git a/.pi/extensions/fm-calm.ts b/.pi/extensions/fm-calm.ts index 1fb9cf12c4..13bafc6fe5 100644 --- a/.pi/extensions/fm-calm.ts +++ b/.pi/extensions/fm-calm.ts @@ -11,10 +11,18 @@ // diagnostic (see installCalmPresentationAdapter below) if a future Pi removes it; Pi // still exposes no global renderer for arbitrary built-in or custom rows. // docs/configuration.md owns the home-local Calm preference contract. +// +// Pi has one first-registration-wins ToolDefinition per tool name, with no merge or +// unregister operation. Keep Calm-off registration empty; keep Calm-on load-time +// registration synchronous because restored rows capture the registry before +// session_start; and collision-check only the later first-activation path, when +// getAllTools() is reliable. docs/calm-mode-feasibility.md owns the Pi-source evidence +// and docs/calm.md owns the user-facing behavior and non-retroactive first-toggle bound. import { randomUUID } from "node:crypto"; import { mkdirSync, readFileSync, + realpathSync, renameSync, rmSync, writeFileSync, @@ -25,6 +33,7 @@ import type { ExtensionAPI, ExtensionUIContext, ToolDefinition, + ToolInfo, ToolRenderResultOptions, } from "@earendil-works/pi-coding-agent"; import { @@ -84,6 +93,21 @@ const extensionFile = fileURLToPath(import.meta.url); const extensionDir = dirname(extensionFile); const root = resolve(extensionDir, "../.."); +// Resolves symlinks before comparing tool-ownership identity below: sourceInfo.path +// values come from independent path-resolution code paths (this module's own +// import.meta.url vs. Pi's extension loader), and macOS alone symlinks /tmp and /var +// to /private/..., so lexical string comparison alone spuriously reads a symlinked +// self-path as a foreign one. Falls back to the raw path for synthetic, non-file +// sourceInfo paths such as "" or "", which realpathSync rejects. +const realpathOrSelf = (path: string): string => { + try { + return realpathSync(path); + } catch { + return path; + } +}; +const extensionRealFile = realpathOrSelf(extensionFile); + // Each presentation adapter probes the exact Pi API it patches. If a future Pi removes // that API, only the affected adapter degrades; the rest of Calm keeps working. function installCalmPresentationAdapter(name: string, install: () => void): void { @@ -166,9 +190,9 @@ export default function (pi: ExtensionAPI) { registerFirstmateSyntheticPresentation(pi); - function registerBuiltIn( + function wrapBuiltIn( factory: DefinitionFactory, - ): void { + ): ToolDefinition { const definitions = new Map>(); const definitionFor = (cwd: string): ToolDefinition => { let definition = definitions.get(cwd); @@ -220,7 +244,7 @@ export default function (pi: ExtensionAPI) { return shell; }; - pi.registerTool({ + return { ...original, renderShell: "self", @@ -263,18 +287,106 @@ export default function (pi: ExtensionAPI) { refreshStandardShell(state, theme, context); return new Container(); }, + }; + } + + // Each wrapBuiltIn() call below has its own concrete TParams/TDetails/TState; the + // array holding all seven has no single sound instantiation, so it is typed the same + // way Pi's own ToolDefinition consumers erase this (any, any, any). + const wrappedBuiltIns: ToolDefinition[] = [ + wrapBuiltIn(createReadToolDefinition), + wrapBuiltIn(createBashToolDefinition), + wrapBuiltIn(createEditToolDefinition), + wrapBuiltIn(createWriteToolDefinition), + wrapBuiltIn(createGrepToolDefinition), + wrapBuiltIn(createFindToolDefinition), + wrapBuiltIn(createLsToolDefinition), + ]; + + // True once this extension has handled built-in registration for its lifetime: + // either all seven synchronously at load, or only the uncontested subset during + // first activation. + let builtInsRegistered = false; + + // Gate on Calm already being on at load time. This must stay synchronous and + // unconditional here (see file header): a foreign-claim check is not reachable at + // this point, while deferral would make restored rows capture the wrong definition. + // A Calm-off session or reload registers nothing and creates no collision exposure. + if (loadCalmPreference()) { + for (const tool of wrappedBuiltIns) pi.registerTool(tool); + builtInsRegistered = true; + } + + // Which of the 7 built-ins are currently owned by a different, non-builtin + // extension. Only safe to call once every extension has finished loading (see file + // header); never call this during the factory's own synchronous execution above. + function contestedBuiltIns(): ToolDefinition[] { + let registered: ToolInfo[]; + try { + registered = pi.getAllTools(); + } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + console.error(`Firstmate Calm: built-in ownership check unavailable, claiming every built-in unconditionally. ${reason}`); + return []; + } + return wrappedBuiltIns.filter((tool) => { + const owner = registered.find((info) => info.name === tool.name)?.sourceInfo; + return owner !== undefined && owner.source !== "builtin" && realpathOrSelf(owner.path) !== extensionRealFile; }); } - registerBuiltIn(createReadToolDefinition); - registerBuiltIn(createBashToolDefinition); - registerBuiltIn(createEditToolDefinition); - registerBuiltIn(createWriteToolDefinition); - registerBuiltIn(createGrepToolDefinition); - registerBuiltIn(createFindToolDefinition); - registerBuiltIn(createLsToolDefinition); + // The first time Calm turns on in a session that started off, claim every + // uncontested built-in and leave each contested tool and its owning extension + // untouched. Tell the user which built-in Calm could not take over, since Calm's + // presentation does not apply to it. + function activateBuiltInsIfNeeded(ui: ExtensionUIContext): void { + if (builtInsRegistered) return; + const contested = contestedBuiltIns(); + const contestedNames = new Set(contested.map((tool) => tool.name)); + for (const tool of wrappedBuiltIns) { + if (!contestedNames.has(tool.name)) pi.registerTool(tool); + } + builtInsRegistered = true; + if (contested.length === 0) return; + const names = contested.map((tool) => `"${tool.name}"`).join(", "); + const plural = contested.length > 1; + ui.notify( + `Firstmate Calm: the ${names} built-in tool${plural ? "s are" : " is"} already provided by another extension, so Calm may not fully function for ${plural ? "them" : "it"} this session.`, + "warning", + ); + for (const tool of contested) { + console.error(`Firstmate Calm: skipped claiming built-in "${tool.name}" because another extension already owns it.`); + } + } + + // Backstop for the one case activateBuiltInsIfNeeded cannot reach: Calm registered + // unconditionally at load time because it was already on, without any chance to + // check for a foreign claim first, so it can still silently lose a name to an + // earlier-loaded extension. Runs on every session_start reason because a reload + // rebuilds every extension's registrations from scratch, so last session's clean + // bill of health does not carry over. + function reportBuiltInLosses(): void { + if (!builtInsRegistered) return; + let registered: ToolInfo[]; + try { + registered = pi.getAllTools(); + } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + console.error(`Firstmate Calm: built-in ownership check unavailable. ${reason}`); + return; + } + for (const tool of wrappedBuiltIns) { + const owner = registered.find((info) => info.name === tool.name)?.sourceInfo; + if (owner && owner.source !== "builtin" && realpathOrSelf(owner.path) !== extensionRealFile) { + console.error( + `Firstmate Calm: another extension (${owner.path}) also claimed the built-in "${tool.name}" tool and won; Calm's presentation for it is unavailable this session.`, + ); + } + } + } pi.on("session_start", (_event, ctx) => { + reportBuiltInLosses(); exportRendering = false; setCalmPresentation(loadCalmPreference()); setCalmStockExportRendering(false); @@ -335,6 +447,7 @@ export default function (pi: ExtensionAPI) { const active = !calmPresentationIsActive(); persistCalmPreference(active); setCalmPresentation(active); + if (active) activateBuiltInsIfNeeded(ctx.ui); publishPresentationState(); applyWorkingPresentation(ctx.ui, true); ctx.ui.setHiddenThinkingLabel(active ? "" : undefined); diff --git a/AGENTS.md b/AGENTS.md index e89d216246..f01bcb723e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -71,7 +71,7 @@ config/backlog-backend backlog backend override; LOCAL, gitignored; absent or " config/backend runtime session-provider backend override for new tasks; LOCAL, gitignored; absent = falls through to runtime auto-detection (the runtime firstmate itself is executing inside), then tmux; tmux is the verified reference backend (docs/tmux-backend.md), while herdr, zellij, orca, and cmux are experimental spawn backends (docs/herdr-backend.md, docs/zellij-backend.md, docs/orca-backend.md, docs/cmux-backend.md) - herdr and cmux can also be selected by runtime auto-detection, zellij and orca never are (always explicit), and codex-app is not accepted; see docs/codex-app-backend.md; inherited by secondmate homes under the primary-authoritative contract in secondmate-provisioning config/calm Pi Calm presentation preference; LOCAL, gitignored, and not inherited; see docs/configuration.md "Pi Calm preference" config/startup-memory-budget primary-authoritative per-home startup-memory budget; LOCAL, gitignored, materialized as 7,500 estimated tokens by locked primary bootstrap and inherited into secondmate homes; see docs/configuration.md "Startup memory budget" -config/herdr-presentation-spaces optional presence flag for Herdr's default-off disposable single-task visual projection; LOCAL, gitignored; inherited by secondmate homes; see docs/herdr-backend.md "Optional presentation spaces" +config/herdr-presentation-spaces optional "off" opt-out from Herdr's default-on disposable single-task visual projection; LOCAL, gitignored; inherited by secondmate homes; see docs/herdr-backend.md "Presentation spaces" config/trace-context optional presence flag enabling default-off native W3C trace-context propagation to spawned agents; LOCAL, gitignored; inherited by secondmate homes; see docs/configuration.md "Trace context propagation" and docs/trace-context.md config/cmux-socket-password optional cmux control-socket password; LOCAL, gitignored; read fresh on every cmux CLI call and passed through without ever overriding an operator's own ambient CMUX_SOCKET_PASSWORD when absent (docs/cmux-backend.md "Setup") config/wedge-alarm optional away-mode wedge-alarm active-alert directives; LOCAL, gitignored; absent means auto (macOS Notification Center when available); see docs/wedge-alarm.md @@ -92,7 +92,7 @@ state/ volatile runtime signals; gitignored .grok-turnend-token firstmate-owned grok hook registry token for the task; removed by teardown .kimi-turnend-token firstmate-owned Kimi hook registry token for the task; removed by teardown .meta written by fm-spawn: window=, endpoint_task_id=, worktree=, project=, harness=, model=, effort=, kind=, mode=, yolo=, tasktmp=; an optional traceparent= only when trace context is enabled (docs/configuration.md "Trace context propagation"); kind=secondmate also records home= and projects=, plus remote_host=/remote_root=/remote_backend=/remote_herdr_session=/remote_target= for a remote route; a non-default runtime backend records further backend-specific fields (docs/configuration.md "Runtime backend"; bin/fm-backend.sh, section 8); fm-pr-check, including through fm-pr-merge, records one canonical pr= and the forge's pr_head= when available (GitHub pull requests and GitLab merge requests; docs/gitlab-merge-watch.md); fm-x-link appends x_request=, x_request_ts=, x_followups=, and optional x_platform=/x_reply_max_chars= for an X-mode-originated task (section 14) - .herdr-presentation quarantinable attempt and restart-binding journal for Herdr's optional visual projection; never task or endpoint authority; see docs/herdr-backend.md "Optional presentation spaces" + .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 X shim through trusted repository scripts, runs registered custom checks from hash-validated private snapshots, and rejects every other state check without execution .check-trust private content binding created by fm-check-register.sh for an intentional custom check .pr-poll private validated data sidecar for the byte-static PR merge poll @@ -111,6 +111,7 @@ state/ volatile runtime signals; gitignored public-followup/ generated private transport for promised public replies: commitment registrations, typed terminal-result inbox, accepted/rejected ledgers (section 14; bin/fm-public-followup.sh) x-poll.error x-poll.claim-error generated X-mode relay and offer-claim diagnostic dedupe markers .wake-queue durable queued wakes: epochseqkindkeypayload + ..open-decisions-cursor per-task byte cursor and folded open-decision set bounding the OPEN DECISIONS scan's cost to new status-log appends; written only by fm-classify-lib.sh's status_open_decisions_incremental, removed by teardown, safe to delete (forces one full re-fold) .afk durable away-mode flag; present = sub-supervisor may inject escalations (set by /afk, cleared on user return) .watch.lock .wake-queue.lock watcher singleton and queue serialization locks .claude-autoarm.lock .claude-autoarm-epoch .claude-autoarm-failure-notified .claude-autoarm-failure-alarmed .turnend-claude-blocks .turnend-claude-blocks.lock Claude Stop auto-arm single-flight, epoch, failure-episode, attended-alarm, guard-budget, and budget-lock records; never touch @@ -145,6 +146,7 @@ A lock-refused session must not spawn, steer, merge, drain the wake queue, repai Home-local stale Herdr projection cleanup and the six bootstrap MUTATING sweeps - non-executing legacy PR-check migration, fleet sync, secondmate convergence, secondmate liveness, pending remote handoff retry, and X-mode artifact writes - run only when this session actually holds the lock from step 1. The secondmate liveness sweep deterministically accounts for every registered secondmate: it relaunches only from the recovery-grade `dead` or `missing` states, preserves ambiguous, unreadable, or unreachable remote targets, and reports skipped or failed guarantees as `SECONDMATE_LIVENESS:` lines (`bin/fm-bootstrap.sh`; `bin/fm-backend.sh`'s `fm_backend_agent_state`; `docs/remote-secondmates.md`). 3. **Wake queue** - when locked, drains the durable wake queue and prints the raw records prominently as this turn's first work queue; a bounded, clearly labeled historical status-event annotation may follow a valid `signal` record but never replaces it or current-state reconciliation, and a lapsed watcher chain still surfaces here via the same guard alarm. + Every locked drain also prints a bounded fleet-wide `OPEN DECISIONS` section when durable decision records remain open, including when the queue itself is empty; reconcile those entries before continuing. When the lock could not be acquired and verified, the queue is left untouched because no session mutation is authorized, and the guard's tangle/watcher-liveness alarms still print in read-only advisory mode without drain, supervision repair, or checkout repair commands. 4. **Context digest** - the full contents of `data/projects.md`, `data/secondmates.md`, `data/captain.md`, `data/captain-shared.md`, and `data/learnings.md`, each clearly delimited. A file that does not exist prints an explicit `ABSENT` marker, never confused with an empty-but-present file: absence is meaningful (`captain.md` absent means use the firstmate repo's built-in defaults, `projects.md` absent means rebuild it from the clones under `projects/`, etc.). @@ -365,6 +367,7 @@ No turn ends blind while work is under way, including turns described as holding At the start of every wake-handling turn, drain the durable wake queue before peeking, reading beyond the reason line, steering, or starting work. Session start is the only exception because its one-shot digest already drained while locked or deliberately left the queue untouched in lock-refused read-only mode. +Treat any `OPEN DECISIONS` section from the drain as actionable reconciliation input even when no wake record was queued. A status line is a wake event, not current state; use `bin/fm-crew-state.sh` when current state matters, especially before re-escalating an old decision, blocker, or pause. A declared `paused:` event means a bounded external wait expected to clear on its own, while `blocked:` means firstmate action is needed. diff --git a/bin/backends/herdr.sh b/bin/backends/herdr.sh index cb677be0cf..a84c71a3bd 100644 --- a/bin/backends/herdr.sh +++ b/bin/backends/herdr.sh @@ -15,8 +15,9 @@ # herdr-verification-p2.md "Task container shape", refined by # docs/herdr-backend.md "Default task container shape"): ONE herdr workspace PER # FIRSTMATE HOME (the primary, and each secondmate, gets its own), ONE herdr TAB -# per task inside its home's workspace. An optional, default-off presentation -# flag creates a disposable workspace for a clean fresh task instead. That +# per task inside its home's workspace. The default-on presentation projection +# creates a disposable workspace for a clean fresh task instead unless the home +# opts out. That # workspace is a non-authoritative visual projection containing only the normal # task pane. Its random token and mutable label never authorize lookup, # adoption, reuse, closure, deletion, task ownership, or endpoint selection. @@ -108,8 +109,8 @@ FM_BACKEND_HERDR_ESCALATED_PREFIX=".herdr-escalated-" # at a seeded secondmate home's root, containing exactly that secondmate's id. # The primary firstmate home never carries this marker. FM_BACKEND_HERDR_SECONDMATE_MARKER=".fm-secondmate-home" -# The default-off presentation projection is intentionally separate from the -# authoritative task endpoint record. +# The presentation projection is intentionally separate from the authoritative +# task endpoint record. # A per-task journal lives under state/ as .herdr-presentation. # Version 1 records only the attempted projection's random correlator. # Version 2 additionally binds the successful projection's exact home, @@ -118,6 +119,36 @@ FM_BACKEND_HERDR_SECONDMATE_MARKER=".fm-secondmate-home" # No send, capture, Treehouse, or general task-ownership path reads it. FM_BACKEND_HERDR_PRESENTATION_JOURNAL_SUFFIX=".herdr-presentation" +# The config item a home writes to opt OUT of the projection. +FM_BACKEND_HERDR_PRESENTATION_CONFIG="herdr-presentation-spaces" + +# fm_backend_herdr_presentation_enabled : true when this home's +# children should be projected into disposable one-task workspaces +# (docs/herdr-backend.md "Presentation spaces" owns the full contract). +# Projection is ON by default, so an absent config file enables it; a home opts +# out by writing "off". Values are read with the whole-file whitespace-stripped +# convention the other scalar config items already use (config/backlog-backend, +# config/crew-harness), plus case folding. An empty file is the historical +# presence-based opt-in form and still means on, so no home that had the +# projection enabled can be turned off by the default flip. An unrecognized +# value warns and keeps the default rather than failing a spawn over a purely +# visual setting, so a typo is visible instead of silently disabling anything. +fm_backend_herdr_presentation_enabled() { # + local config_dir=${1:-} file value + [ -n "$config_dir" ] || return 0 + file="$config_dir/$FM_BACKEND_HERDR_PRESENTATION_CONFIG" + [ -f "$file" ] || return 0 + value=$(tr -d '[:space:]' < "$file" 2>/dev/null | tr '[:upper:]' '[:lower:]') || value="" + case "$value" in + off) return 1 ;; + ''|on) return 0 ;; + *) + echo "warning: $file: unrecognized value \"$value\"; herdr presentation spaces stay on (write \"off\" to opt out)" >&2 + return 0 + ;; + esac +} + # fm_backend_herdr_workspace_label: the per-firstmate-HOME herdr workspace # label (docs/herdr-backend.md "Default task container shape"). The PRIMARY home (no # secondmate marker) resolves to the constant "firstmate", byte-identical to diff --git a/bin/fm-backlog-handoff.sh b/bin/fm-backlog-handoff.sh index e83a857e8a..3a59f4b132 100755 --- a/bin/fm-backlog-handoff.sh +++ b/bin/fm-backlog-handoff.sh @@ -37,7 +37,7 @@ # item with a single-space or tab-indented continuation rather than risk leaving # it orphaned, because tasks-axi treats only two-or-more-space lines as body. # The move needs compatible `tasks-axi` on PATH, including atomic multi-ID `mv` -# (introduced in 0.2.2). Bootstrap requires it fleet-wide, so this works +# support. Bootstrap requires a compatible build fleet-wide, so this works # everywhere; the `config/backlog-backend=manual` knob only governs firstmate's # own hand-editing of its own backlog, not this validated helper. Idempotent: # re-running converges. Atomic: on any move failure nothing moves. @@ -355,7 +355,7 @@ remote_handoff() { # validate_backlog_file "main backlog" "$MAIN_BACKLOG" || return 1 validate_backlog_file "remote handoff outbox" "$outbox" || return 1 fm_tasks_axi_compatible || { - echo "error: tasks-axi with atomic multi-ID mv support (0.2.2+) is required to stage remote handoffs" >&2 + echo "error: a compatible tasks-axi with atomic multi-ID mv support is required to stage remote handoffs; run bin/fm-bootstrap.sh for the required version" >&2 return 1 } to_move=() @@ -540,7 +540,7 @@ if [ "$FAILED" -ne 0 ]; then fi if ! fm_tasks_axi_compatible; then - echo "error: tasks-axi with atomic multi-ID mv support (0.2.2+) is required to move backlog items" >&2 + echo "error: a compatible tasks-axi with atomic multi-ID mv support is required to move backlog items; run bin/fm-bootstrap.sh for the required version" >&2 exit 1 fi diff --git a/bin/fm-backlog-receive.sh b/bin/fm-backlog-receive.sh index b2aec10e10..15d9bde99a 100755 --- a/bin/fm-backlog-receive.sh +++ b/bin/fm-backlog-receive.sh @@ -163,7 +163,7 @@ for key in "${KEYS[@]}"; do done if [ "${#TO_MOVE[@]}" -gt 0 ]; then - fm_tasks_axi_compatible || die "tasks-axi 0.2.2+ is required for atomic backlog receipt" + fm_tasks_axi_compatible || die "a compatible tasks-axi is required for atomic backlog receipt; run bin/fm-bootstrap.sh for the required version" if ! MOVE_OUT=$(run_move "${TO_MOVE[@]}" 2>&1); then recovered=0 for lock in "$DELIVERED.lock" "$DEST.lock"; do diff --git a/bin/fm-bootstrap.sh b/bin/fm-bootstrap.sh index e9b54b7835..bbd3d6371e 100755 --- a/bin/fm-bootstrap.sh +++ b/bin/fm-bootstrap.sh @@ -52,16 +52,16 @@ # "treehouse get --lease" support. # no-mistakes is also MISSING when its installed version is older than # 1.31.2. +# The AXI-family floor policy is owned beside GH_AXI_MIN and +# LAVISH_AXI_MIN below; the per-tool owners point there. An installed +# build below its floor reports MISSING like no-mistakes, so the operator +# is asked to upgrade rather than silently running an older tool. +# tasks-axi feature probes remain a separate defense-in-depth check. # tasks-axi and quota-axi are required bootstrap tools (same class as -# lavish-axi). tasks-axi is also version and feature gated (0.1.1+ -# with update --archive-body and mv [...]); an installed but -# incompatible build reports MISSING like no-mistakes. A compatible -# tasks-axi default backend is silent. quota-axi is required for the -# agent-owned dispatch-profile array procedure in AGENTS.md section 4 -# and .agents/skills/quota-array-dispatch/SKILL.md, and is also version -# gated by fm-quota-axi-lib.sh, which owns that floor and its rationale. -# An older build reports MISSING like no-mistakes rather than passing -# silently while emitting auth semantics dispatch cannot scope. +# lavish-axi). A compatible tasks-axi default backend is silent. +# quota-axi is required for the agent-owned dispatch-profile array +# procedure in AGENTS.md section 4 and +# .agents/skills/quota-array-dispatch/SKILL.md. # On a primary home, the locked mutable path materializes the visible # default config/startup-memory-budget=7500 when absent. It never # guesses at malformed or unsafe existing files, and secondmate homes @@ -690,6 +690,15 @@ if ! BACKEND_TOOLS=$(fm_backend_required_tools "$BACKEND"); then fi TOOLS="$BACKEND_TOOLS $COMMON_TOOLS" NO_MISTAKES_MIN=1.31.2 +# 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 +# are expected to drift upward as new versions ship. Never lower a floor to the +# earliest release that happens to satisfy some depended-on behavior. The +# tasks-axi feature probes are an independent defense-in-depth concern, not part +# of its floor. +GH_AXI_MIN=0.1.29 +LAVISH_AXI_MIN=0.1.45 treehouse_supports_lease() { treehouse get --help 2>&1 | grep -Eq '(^|[^[:alnum:]_-])--lease([^[:alnum:]_-]|$)' @@ -1029,6 +1038,12 @@ fi if command -v no-mistakes >/dev/null 2>&1 && ! tool_version_at_least no-mistakes "$NO_MISTAKES_MIN"; then echo "MISSING: no-mistakes (install: $(install_cmd no-mistakes))" fi +if command -v gh-axi >/dev/null 2>&1 && ! tool_version_at_least gh-axi "$GH_AXI_MIN"; then + echo "MISSING: gh-axi (install: $(install_cmd gh-axi))" +fi +if command -v lavish-axi >/dev/null 2>&1 && ! tool_version_at_least lavish-axi "$LAVISH_AXI_MIN"; then + echo "MISSING: lavish-axi (install: $(install_cmd lavish-axi))" +fi if command -v quota-axi >/dev/null 2>&1 && ! fm_quota_axi_compatible; then echo "MISSING: quota-axi (install: $(install_cmd quota-axi))" fi diff --git a/bin/fm-classify-lib.sh b/bin/fm-classify-lib.sh index d80840f6a1..5284208cef 100755 --- a/bin/fm-classify-lib.sh +++ b/bin/fm-classify-lib.sh @@ -13,13 +13,17 @@ # daemon keeps its escalation-digest seen-markers; the watcher keeps its .seen-* # signatures). # -# The one exception is the absorb classification (crew_absorb_class and its -# working/paused wrappers). It is NOT a pure status-file read: it reuses -# bin/fm-crew-state.sh, which may make a bounded no-mistakes call, to decide -# whether a crew that just stopped its turn or went stale is working, deliberately -# paused, or neither. Callers run it ONLY on no-verb signal handling and first -# sighting of a stale hash, never on every wake, so the per-wake triage stays -# cheap. +# There are two documented exceptions. The absorb classification +# (crew_absorb_class and its working/paused wrappers) is NOT a pure status-file +# read: it reuses bin/fm-crew-state.sh, which may make a bounded no-mistakes call, +# to decide whether a crew that just stopped its turn or went stale is working, +# deliberately paused, or neither. Callers run it ONLY on no-verb signal handling +# and first sighting of a stale hash, never on every wake, so the per-wake triage +# stays cheap. status_open_decisions_incremental (see "incremental (cursor-backed) +# open-decisions fold" below) also writes: it persists a per-status-file byte +# cursor and folded open-set as a side effect, so a per-drain fleet-wide scan +# stays bounded by new appends instead of re-reading each task's whole lifetime +# log every time. # Directory of this library, used to locate the sibling fm-crew-state.sh reader. # Resolved at source time from BASH_SOURCE so it works whether sourced by a @@ -201,38 +205,260 @@ $set EOF printf '%s' "$out" } +# Fold ONE status line into an existing "\t\t\n"-per-line open +# set, applying the same needs-decision/blocked-opens, resolved/captain-held-closes +# rule status_open_decisions documents above. Pure text transform, no file I/O. +# This is the ONE place the per-line open/resolved rule is written; both the +# whole-file fold (status_open_decisions) and the incremental cursor-backed fold +# (status_open_decisions_incremental) below call this instead of re-deriving the +# rule, so the two consumption strategies can never drift apart on semantics. +_fm_decision_fold_line() { # + local open=$1 line=$2 resolve=$3 held=$4 verb key note stripped + stripped=${line//[[:space:]]/} + [ -n "$stripped" ] || { printf '%s' "$open"; return 0; } + verb=$(status_line_verb "$line") + key=$(_fm_decision_key "$line") || { printf '%s' "$open"; return 0; } + case "$verb" in + needs-decision|blocked) + note=$(status_line_note "$line") + open=$(_fm_decision_drop "$open" "$key") + [ -n "$open" ] && open="${open}"$'\n' + open="${open}${key}"$'\t'"${verb}"$'\t'"${note}"$'\n' + ;; + "$resolve"|"$held") + open=$(_fm_decision_drop "$open" "$key") + [ -n "$open" ] && open="${open}"$'\n' + ;; + esac + printf '%s' "$open" +} + # Fold the WHOLE status stream into the set of decisions still open. Prints one # TAB-separated "\t\t" line per still-open decision, in # most-recently-opened-last order; prints nothing when none are open. Pure read of # the file, no globals beyond the optional FM_CLASSIFY_RESOLVE_VERB override. This # is the durable open-set the fleet snapshot and any point-in-time consumer must use # instead of trusting the last status line. +# The scan_open_decisions wrapper below enumerates a whole directory rather than +# a single caller-chosen path, so a status file that is itself a symlink (e.g. +# escaping the state directory) is rejected outright with a plain [ -L ] check +# before any read - a cheap builtin, unlike fm_wake_latest_event's O_NOFOLLOW +# subprocess read, which exists for that function's much narrower payload-driven +# path resolution rather than this directory-local glob. status_open_decisions() { # - local f=$1 line verb key note resolve held open='' stripped - [ -f "$f" ] || return 0 + local f=$1 line resolve held open='' + [ -f "$f" ] && [ -r "$f" ] && [ ! -L "$f" ] || return 0 resolve=${FM_CLASSIFY_RESOLVE_VERB:-$FM_CLASSIFY_RESOLVE_VERB_DEFAULT} held=${FM_CLASSIFY_CAPTAIN_HELD_VERB:-$FM_CLASSIFY_CAPTAIN_HELD_VERB_DEFAULT} while IFS= read -r line || [ -n "$line" ]; do - stripped=${line//[[:space:]]/} - [ -n "$stripped" ] || continue - verb=$(status_line_verb "$line") - key=$(_fm_decision_key "$line") || continue - case "$verb" in - needs-decision|blocked) - note=$(status_line_note "$line") - open=$(_fm_decision_drop "$open" "$key") - [ -n "$open" ] && open="${open}"$'\n' - open="${open}${key}"$'\t'"${verb}"$'\t'"${note}"$'\n' - ;; - "$resolve"|"$held") - open=$(_fm_decision_drop "$open" "$key") - [ -n "$open" ] && open="${open}"$'\n' - ;; - esac + open=$(_fm_decision_fold_line "$open" "$line" "$resolve" "$held") done < "$f" printf '%s' "$open" } +# Fleet-wide wrapper around status_open_decisions: scans every task's status +# log under and prefixes each still-open decision with its owning task +# id, so a per-wake or per-session surface can print the consolidated open set +# without re-walking the fold itself. A thin directory scan only - the fold +# above remains the ONE place the open/resolved semantics are decided. Prints +# one "\t\t\t" line per open decision, in glob (task id) +# order; prints nothing when none are open. +scan_open_decisions() { # + local state=$1 f task open line + for f in "$state"/*.status; do + [ -e "$f" ] || continue + task=$(basename "$f"); task="${task%.status}" + open=$(status_open_decisions "$f") || continue + [ -n "$open" ] || continue + while IFS= read -r line; do + [ -n "$line" ] || continue + printf '%s\t%s\n' "$task" "$line" + done <`) and only ever +# appended to (`>>`) - never replaced, renamed, or rewritten in place. So the +# only two ways a cursor can go stale are a shrink (truncated) or the file at +# this path being a different file than before (replaced/rotated/recreated), +# which a changed device+inode makes an O(1) check via a single `stat` call - +# no content hashing, no re-reading the consumed prefix. Either signal falls +# back to a full re-fold of the whole current file from byte 0 - byte for byte +# what status_open_decisions itself would compute - and rewrites the cursor +# from that clean baseline. A same-inode, same-size, in-place byte edit is NOT +# detected; that is a deliberately accepted gap because no code path in this +# repo ever does that to a status file. +# +# The other real failure mode is OUR OWN read failing (a stat/wc/tail I/O +# error), not a malformed writer: every such read here is checked, and on +# failure this reports the already-trusted persisted set unchanged rather than +# risking a silent invalidation that would wipe it - never a bare "empty" as if +# nothing were open. +# +# Not a pure status-file read: this writes/rewrites the sibling cursor file as a +# side effect (state/..open-decisions-cursor), the library's second +# documented exception to the pure-read rule after crew_absorb_class. The write +# is atomic (temp file + rename), so a crash between calls leaves either the +# prior cursor or the new one, never a partial one. bin/fm-wake-drain.sh calls +# this only after releasing the wake-queue lock, so a hypothetical race between +# two overlapping drains can at worst redo a little folding work twice - never +# drop an open decision - because a losing writer's offset can only ever be +# equal to or behind an already-recorded byte position, and the next call +# re-derives from whatever offset actually landed on disk. +_fm_open_decisions_cursor_path() { # + local f=$1 dir base + dir=$(dirname "$f") + base=$(basename "$f") + printf '%s/.%s.open-decisions-cursor' "$dir" "${base%.status}" +} + +# Portable device:inode identity for the rotation/recreation check below. +_fm_open_decisions_file_ident() { # -> "dev:inode", empty on I/O failure + local f=$1 + if [ "$(uname -s 2>/dev/null)" = Darwin ]; then + LC_ALL=C stat -f '%d:%i' "$f" 2>/dev/null + else + LC_ALL=C stat -c '%d:%i' "$f" 2>/dev/null + fi +} + +status_open_decisions_incremental() { # + local f=$1 cf offset ident open='' trusted_open='' cursor_data first rest ident_line + local size cur_ident resolve held chunk_file chunk_size line + [ -f "$f" ] && [ -r "$f" ] && [ ! -L "$f" ] || return 0 + cf=$(_fm_open_decisions_cursor_path "$f") + offset=0 + ident='' + if [ -f "$cf" ] && [ -r "$cf" ] && [ ! -L "$cf" ]; then + if cursor_data=$(LC_ALL=C command cat "$cf" 2>/dev/null); then + first=${cursor_data%%$'\n'*} + case "$first" in + offset=*) + offset=${first#offset=} + case "$offset" in + ''|*[!0-9]*) offset=0 ;; + *) + case "$cursor_data" in + *$'\n'*) + rest=${cursor_data#*$'\n'} + ident_line=${rest%%$'\n'*} + case "$ident_line" in + ident=*) + ident=${ident_line#ident=} + case "$rest" in + *$'\n'*) open=${rest#*$'\n'} ;; + esac + trusted_open=$open + ;; + *) offset=0 ;; + esac + ;; + *) offset=0 ;; + esac + ;; + esac + ;; + esac + fi + fi + + # A stat/size-read failure is a genuine I/O error, not "the file is empty" - + # report the already-trusted persisted set unchanged rather than risking a + # silent invalidation that would wipe it. + cur_ident=$(_fm_open_decisions_file_ident "$f") || { printf '%s' "$trusted_open"; return 0; } + [ -n "$cur_ident" ] || { printf '%s' "$trusted_open"; return 0; } + size=$(LC_ALL=C wc -c < "$f" 2>/dev/null) \ + || { printf '%s' "$trusted_open"; return 0; } + size=${size//[[:space:]]/} + case "$size" in ''|*[!0-9]*) printf '%s' "$trusted_open"; return 0 ;; esac + + if [ -z "$ident" ] || [ "$ident" != "$cur_ident" ] || [ "$offset" -gt "$size" ]; then + offset=0 + open='' + fi + + if [ "$offset" -lt "$size" ]; then + chunk_file="$cf.read.$$" + tail -c "+$((offset + 1))" "$f" > "$chunk_file" 2>/dev/null \ + || { rm -f "$chunk_file"; printf '%s' "$trusted_open"; return 0; } + chunk_size=$(LC_ALL=C wc -c < "$chunk_file" 2>/dev/null) \ + || { rm -f "$chunk_file"; printf '%s' "$trusted_open"; return 0; } + chunk_size=${chunk_size//[[:space:]]/} + case "$chunk_size" in + ''|*[!0-9]*) rm -f "$chunk_file"; printf '%s' "$trusted_open"; return 0 ;; + esac + # Test-only observability seam (off by default, no production behavior + # change): when set, records exactly how many bytes THIS call folded, so a + # test can assert the incremental path stays bounded by new appends rather + # than re-reading the whole file, without relying on timing or source text. + [ -n "${FM_OPEN_DECISIONS_READ_PROBE:-}" ] \ + && printf '%s\t%s\n' "$f" "$chunk_size" >> "$FM_OPEN_DECISIONS_READ_PROBE" + resolve=${FM_CLASSIFY_RESOLVE_VERB:-$FM_CLASSIFY_RESOLVE_VERB_DEFAULT} + held=${FM_CLASSIFY_CAPTAIN_HELD_VERB:-$FM_CLASSIFY_CAPTAIN_HELD_VERB_DEFAULT} + while IFS= read -r line || [ -n "$line" ]; do + open=$(_fm_decision_fold_line "$open" "$line" "$resolve" "$held") + done < "$chunk_file" + rm -f "$chunk_file" + { + printf 'offset=%s\n' "$size" + printf 'ident=%s\n' "$cur_ident" + # An `if` (not `[ -n "$open" ] && printf ...`) so the group's exit status + # is always 0 even when open is empty (fully resolved) - a bare `&&` + # there would make the whole group fail on that condition, silently + # skipping the mv below and leaving the cursor stuck on the OLD offset. + if [ -n "$open" ]; then printf '%s' "$open"; fi + } > "$cf.tmp.$$" && mv -f "$cf.tmp.$$" "$cf" + fi + printf '%s' "$open" +} + +# Incremental sibling of scan_open_decisions: same fleet-wide directory walk and +# output shape ("\t\t\t" per open decision), but folds +# each task's status log through status_open_decisions_incremental instead of +# the whole-file status_open_decisions, so a fleet-wide per-drain scan stays +# bounded by new appends rather than total lifetime log size across every task. +scan_open_decisions_incremental() { # + local state=$1 f task open line + for f in "$state"/*.status; do + [ -e "$f" ] || continue + task=$(basename "$f"); task="${task%.status}" + open=$(status_open_decisions_incremental "$f") || continue + [ -n "$open" ] || continue + while IFS= read -r line; do + [ -n "$line" ] || continue + printf '%s\t%s\n' "$task" "$line" + done <" >&2; exit 2; } @@ -167,41 +169,20 @@ crew_busy_verdict() { # } # --- no-mistakes run lookup (authoritative when a run matches this branch) -- +# trim, strip_quotes, the bounded nm_run call, nm_field's TOON parse, and the +# branch+head attribution rule below are thin wrappers over the ONE owner in +# bin/fm-nm-run-lib.sh, shared with fm-teardown.sh's pre-teardown run abort. -trim() { - local s=${1:-} - s="${s#"${s%%[![:space:]]*}"}" - s="${s%"${s##*[![:space:]]}"}" - printf '%s' "$s" -} -strip_quotes() { - local s - s=$(trim "${1:-}") - case "$s" in - \"*\") s=${s#\"}; s=${s%\"} ;; - esac - trim "$s" -} - -# Bounded no-mistakes call in the worktree; stdout only, never fails the script. -HAVE_TIMEOUT=none -if command -v timeout >/dev/null 2>&1; then HAVE_TIMEOUT=timeout -elif command -v gtimeout >/dev/null 2>&1; then HAVE_TIMEOUT=gtimeout -elif command -v perl >/dev/null 2>&1; then HAVE_TIMEOUT=perl -fi +trim() { fm_nm_trim "$@"; } +strip_quotes() { fm_nm_strip_quotes "$@"; } nm_run() { # - case "$HAVE_TIMEOUT" in - timeout) ( cd "$WT" && timeout "$NM_TIMEOUT" no-mistakes "$@" ) 2>/dev/null || true ;; - gtimeout) ( cd "$WT" && gtimeout "$NM_TIMEOUT" no-mistakes "$@" ) 2>/dev/null || true ;; - perl) ( cd "$WT" && perl -e 'my $t = shift; my $pid = fork; die "fork failed" unless defined $pid; if (!$pid) { setpgrp(0, 0); exec @ARGV } local $SIG{ALRM} = sub { kill "TERM", -$pid; select undef, undef, undef, 0.2; kill "KILL", -$pid; exit 124 }; alarm $t; waitpid $pid, 0; exit($? >> 8)' "$NM_TIMEOUT" no-mistakes "$@" ) 2>/dev/null || true ;; - *) true ;; - esac + fm_nm_run "$WT" "$NM_TIMEOUT" "$@" } # Scalar value of a TOON key in the captured run output ($RUN_OUT). RUN_OUT="" nm_field() { # - printf '%s\n' "$RUN_OUT" | sed -n "s/^[[:space:]]*$1:[[:space:]]*\(.*\)/\1/p" | head -1 + fm_nm_field "$RUN_OUT" "$1" } # Finding count from a findings[N]{...} table header; empty when none. nm_findings_count() { @@ -385,40 +366,19 @@ nm_runs_status_for_branch() { # CREW_BRANCH=$(git -C "$WT" symbolic-ref --quiet --short HEAD 2>/dev/null || true) # 0 if the active axi-status run's head field matches this worktree's code -# identity. Branch match is a precondition (caller). Rules: -# - missing/empty head field: cannot bind; reject the run -# - equal commits (short or full SHA): match -# - worktree HEAD is an ancestor of run head: match (pipeline fix commits on -# the same history advanced the run tip) -# - run head is a strict ancestor of worktree HEAD: no match (local work -# advanced outside the run) -# - diverged / run head not in this worktree: no match (rewritten branch tip) +# identity. Branch match is a precondition (caller). Rule owned by +# fm_nm_head_matches_worktree in bin/fm-nm-run-lib.sh. nm_run_head_matches_worktree() { - local run_head local_full run_full + local run_head run_head=$(strip_quotes "$(nm_field head)") - [ -n "$run_head" ] || return 1 - local_full=$(git -C "$WT" rev-parse HEAD 2>/dev/null) || return 1 - run_full=$(git -C "$WT" rev-parse --verify "${run_head}^{commit}" 2>/dev/null) || return 1 - [ "$run_full" = "$local_full" ] && return 0 - if git -C "$WT" merge-base --is-ancestor "$local_full" "$run_full" 2>/dev/null; then - return 0 - fi - return 1 + fm_nm_head_matches_worktree "$WT" "$run_head" } # Coarse runs-list rows are " ...". 0 if the short # sha for this branch row matches the worktree head under the same rules as # nm_run_head_matches_worktree (equal, or local is ancestor of run tip). nm_coarse_head_matches_worktree() { # - local run_head=$1 local_full run_full - [ -n "$run_head" ] || return 1 - local_full=$(git -C "$WT" rev-parse HEAD 2>/dev/null) || return 1 - run_full=$(git -C "$WT" rev-parse --verify "${run_head}^{commit}" 2>/dev/null) || return 1 - [ "$run_full" = "$local_full" ] && return 0 - if git -C "$WT" merge-base --is-ancestor "$local_full" "$run_full" 2>/dev/null; then - return 0 - fi - return 1 + fm_nm_head_matches_worktree "$WT" "$1" } HAVE_RUN=0 diff --git a/bin/fm-guard.sh b/bin/fm-guard.sh index 5698d376ab..24151de92e 100755 --- a/bin/fm-guard.sh +++ b/bin/fm-guard.sh @@ -6,12 +6,18 @@ # non-default branch, because that means firstmate-on-itself work landed in the # primary instead of an isolated worktree. # Then, if a task is in flight (a state/.meta exists) or X-mode relay -# polling is active (state/x-watch.check.sh exists) and no identity-matched -# watcher has a liveness beacon (state/.last-watcher-beat, touched every poll -# cycle) fresh within FM_GUARD_GRACE seconds, prints a loud, clearly delimited -# banner so the agent cannot skim past it in the tool output of whatever it was -# doing - the one channel every harness has. The full banner is emitted once per -# distinct staleness episode in this FM_HOME (keyed to beacon mtime or absence); +# polling is active (state/x-watch.check.sh exists) and supervision is not +# healthy, prints a loud, clearly delimited banner so the agent cannot skim past +# it in the tool output of whatever it was doing - the one channel every harness +# has. Supervision health is MODEL-AWARE (fm_watcher_supervision_verdict in +# bin/fm-wake-lib.sh): under the Claude Stop auto-arm model the watcher runs only +# between turns, so mid-turn a fresh beacon with no live watcher is healthy and +# only a stale beacon (beyond FM_GUARD_GRACE) is a genuine lapse; under every +# persistent-watcher harness a live identity-matched watcher with a fresh beacon +# is required. The banner names the true failing condition (a missing live +# watcher process vs a genuinely stale beacon). The full banner is emitted once +# per distinct down-episode in this FM_HOME (keyed to the failing condition, not +# the beacon mtime, which a healthy between-turns watcher advances every poll); # later guarded commands in the same episode print a one-line reminder instead. # Episode state lives only under state/.guard-watcher-stale-banner (volatile, # bounded). Independent alarms (queued wakes, worktree tangle) are never @@ -43,18 +49,14 @@ STALE_BANNER_MARKER="$STATE/.guard-watcher-stale-banner" # shellcheck source=bin/fm-supervision-lib.sh . "$SCRIPT_DIR/fm-supervision-lib.sh" -# Deterministic episode key from beacon state: same continuous stale beacon -# (or continuous absence) shares a key; a recovered-then-restale beacon gets a -# new mtime and therefore a new episode. +# Deterministic episode key from the qualitative down-state (the failing +# condition), NOT the beacon mtime: under the auto-arm model a healthy +# between-turns watcher advances that mtime every poll, which made the "same +# episode" key change every turn and re-print the full banner. Keying on the +# failing condition keeps one continuous down-episode stable, while positive +# recovery clears the marker (below) and re-arms the next episode. fm_guard_stale_episode_key() { - local state=$1 beat m - beat="$state/.last-watcher-beat" - if [ -e "$beat" ]; then - m=$(fm_sup_stat_mtime "$beat") - printf 'beat:%s\n' "${m:-unknown}" - else - printf 'beat:absent\n' - fi + printf '%s\n' "$1" } # Claim the full banner for this episode. Exit 0 = print full banner (this call @@ -150,10 +152,9 @@ in_flight=$FM_SUP_IN_FLIGHT sources=$FM_SUP_SOURCES needed=$FM_SUP_NEEDED beacon_desc=$FM_SUP_BEACON_DESC -watcher_healthy=false -if fm_watcher_healthy "$STATE" "$WATCH" "$GRACE" "$FM_HOME"; then - watcher_healthy=true -fi +fm_watcher_supervision_verdict "$STATE" "$WATCH" "$GRACE" "$FM_HOME" +watcher_healthy=$FM_WATCHER_VERDICT_OK +watcher_down_reason=$FM_WATCHER_VERDICT_REASON if [ "$needed" = false ]; then # Leave the unhealthy state (nothing riding on the watcher): clear so a later # work or X-mode need + stale combination is a fresh episode even if the @@ -168,7 +169,7 @@ fi # bordered banner FIRST so it reads as an alarm, not a buried stderr line. Later # calls in the same episode get a one-line reminder only. if [ "$watcher_healthy" = false ]; then - episode_key=$(fm_guard_stale_episode_key "$STATE") + episode_key=$(fm_guard_stale_episode_key "$watcher_down_reason") episode_key=${episode_key%$'\n'} print_full_banner=0 if [ "$READ_ONLY" -eq 1 ]; then @@ -193,12 +194,17 @@ if [ "$watcher_healthy" = false ]; then { printf '●%s\n' "$rule" printf '● WATCHER DOWN - SUPERVISION IS OFF\n' + if [ "$watcher_down_reason" = no-watcher ]; then + watcher_cause=$(printf 'no live watcher process holds this home lock (last beat: %s)' "$beacon_desc") + else + watcher_cause=$(printf 'no watcher has a fresh beacon (last beat: %s, grace %ss)' "$beacon_desc" "$GRACE") + fi if [ "$in_flight" -gt 0 ]; then - printf '● %s task(s) in flight, but no watcher has a fresh beacon (last beat: %s, grace %ss).\n' "$in_flight" "$beacon_desc" "$GRACE" + printf '● %s task(s) in flight, but %s.\n' "$in_flight" "$watcher_cause" elif [ "$sources" -gt 0 ]; then - printf '● %s process-event source(s) registered, but no watcher has a fresh beacon (last beat: %s, grace %ss).\n' "$sources" "$beacon_desc" "$GRACE" + printf '● %s process-event source(s) registered, but %s.\n' "$sources" "$watcher_cause" else - printf '● X-mode relay polling needs supervision, but no watcher has a fresh beacon (last beat: %s, grace %ss).\n' "$beacon_desc" "$GRACE" + printf '● X-mode relay polling needs supervision, but %s.\n' "$watcher_cause" fi if [ "$READ_ONLY" -eq 1 ]; then printf '● This read-only session should report the lapse, not repair it.\n' diff --git a/bin/fm-home-seed.sh b/bin/fm-home-seed.sh index 46227b7648..6693ab1df7 100755 --- a/bin/fm-home-seed.sh +++ b/bin/fm-home-seed.sh @@ -16,8 +16,8 @@ # refuses a home with project clones or project-registry entries, so it # never converts populated homes in place. The charter brief # is copied to data/charter.md, newly cloned no-mistakes projects are -# initialized, an ignored .fm-secondmate-home identity marker is written, and -# data/secondmates.md is updated. +# initialized, an ignored .fm-secondmate-parent binding is published before +# the .fm-secondmate-home identity marker, and data/secondmates.md is updated. # Seeding is transactional: on validation, clone, init, or registry failure, # generated briefs, new homes, new project clones, and registry edits are # rolled back. Treehouse-acquired homes are returned only when the rollback @@ -40,8 +40,11 @@ PROJECTS="${FM_PROJECTS_OVERRIDE:-$FM_HOME/projects}" STATE="${FM_STATE_OVERRIDE:-$FM_HOME/state}" REG="$DATA/secondmates.md" SUB_HOME_MARKER=".fm-secondmate-home" +SUB_HOME_PARENT_MARKER=".fm-secondmate-parent" # shellcheck source=bin/fm-secondmate-registry-lib.sh . "$SCRIPT_DIR/fm-secondmate-registry-lib.sh" +# shellcheck source=bin/fm-secondmate-parent-lib.sh +. "$SCRIPT_DIR/fm-secondmate-parent-lib.sh" # shellcheck source=bin/fm-secondmate-charter-lib.sh . "$SCRIPT_DIR/fm-secondmate-charter-lib.sh" # shellcheck source=bin/fm-wake-lib.sh @@ -284,13 +287,17 @@ validate_operational_dirs() { validate_seed_leaf_files() { local home=$1 label path abs_home abs_path abs_home=$(resolved_path "$home") - for label in "data/projects.md" "data/charter.md" "$SUB_HOME_MARKER"; do + for label in "data/projects.md" "data/charter.md" "$SUB_HOME_MARKER" "$SUB_HOME_PARENT_MARKER"; do path="$home/$label" if [ -L "$path" ]; then echo "error: secondmate leaf file must not be a symlink: $path" >&2 return 1 fi [ -e "$path" ] || continue + if [ ! -f "$path" ]; then + echo "error: secondmate leaf file must be a regular file: $path" >&2 + return 1 + fi abs_path=$(resolved_path "$path") case "$abs_path" in "$abs_home"/*) ;; @@ -302,6 +309,21 @@ validate_seed_leaf_files() { done } +validate_existing_parent_binding() { + local home=$1 record recorded_parent requested_parent + record="$home/$SUB_HOME_PARENT_MARKER" + [ -f "$record" ] && [ ! -L "$record" ] || return 0 + fm_secondmate_parent_record_parse "$record" || return 0 + [ "$FM_SECONDMATE_PARENT_ROUTE" = local ] || return 0 + + recorded_parent=$(resolved_path "$FM_SECONDMATE_PARENT_HOME") + requested_parent=$(resolved_path "$FM_HOME") + [ "$recorded_parent" = "$requested_parent" ] && return 0 + printf 'error: secondmate home is bound to parent %s, not requested parent %s\n' \ + "$recorded_parent" "$requested_parent" >&2 + return 1 +} + validate_project_destination() { local home=$1 project=$2 dst projects_dir abs_home abs_projects abs_dst abs_active_home abs_root projects_dir="$home/projects" @@ -507,6 +529,7 @@ SEED_PARENT_BRIEF_DIR_CREATED=0 SEED_SUB_REG_EXISTED=0 SEED_CHARTER_EXISTED=0 SEED_MARKER_EXISTED=0 +SEED_PARENT_MARKER_EXISTED=0 restore_seed_file() { local existed=$1 backup=$2 path=$3 @@ -626,6 +649,7 @@ seed_rollback() { fi if [ -n "${SEED_BACKUP_DIR:-}" ] && [ "${SEED_HOME_BACKED_UP:-0}" = 1 ]; then restore_seed_file "$SEED_MARKER_EXISTED" "$SEED_BACKUP_DIR/marker" "$SEED_HOME/$SUB_HOME_MARKER" + restore_seed_file "$SEED_PARENT_MARKER_EXISTED" "$SEED_BACKUP_DIR/parent-marker" "$SEED_HOME/$SUB_HOME_PARENT_MARKER" restore_seed_file "$SEED_CHARTER_EXISTED" "$SEED_BACKUP_DIR/charter.md" "$SEED_HOME/data/charter.md" restore_seed_file "$SEED_SUB_REG_EXISTED" "$SEED_BACKUP_DIR/sub-projects.md" "$SEED_HOME/data/projects.md" fi @@ -850,6 +874,7 @@ seed_home() { validate_home_assignment "$id" "$home" validate_operational_dirs "$home" || return 1 validate_seed_leaf_files "$home" || return 1 + validate_existing_parent_binding "$home" || return 1 if [ "$no_projects" -eq 1 ]; then refuse_populated_projectless_home "$home" || return 1 if [ -f "$SEED_PARENT_BRIEF" ]; then @@ -869,6 +894,10 @@ seed_home() { SEED_MARKER_EXISTED=1 cp "$home/$SUB_HOME_MARKER" "$SEED_BACKUP_DIR/marker" fi + if [ -f "$home/$SUB_HOME_PARENT_MARKER" ]; then + SEED_PARENT_MARKER_EXISTED=1 + cp "$home/$SUB_HOME_PARENT_MARKER" "$SEED_BACKUP_DIR/parent-marker" + fi SEED_HOME_BACKED_UP=1 if [ ! -f "$SEED_PARENT_BRIEF" ]; then @@ -917,7 +946,19 @@ seed_home() { cp "$SEED_PARENT_BRIEF" "$home/data/charter.md" projects_csv=$(join_projects "$@") - printf '%s\n' "$id" > "$home/$SUB_HOME_MARKER" + # Durable record of this home's route to its parent, written once here next + # to the identity marker: the cleanup check in fm-teardown.sh reads it so a + # restart that drops the launch-time FM_PUBLIC_FOLLOWUP_PRIMARY_HOME prefix + # can still resolve the real parent instead of silently treating its relay + # as inactive. + { + printf 'schema=fm-secondmate-parent.v1\n' + printf 'route=local\n' + printf 'parent_home=%s\n' "$(resolved_path "$FM_HOME")" + } > "$home/$SUB_HOME_PARENT_MARKER.tmp.$$" + mv -f -- "$home/$SUB_HOME_PARENT_MARKER.tmp.$$" "$home/$SUB_HOME_PARENT_MARKER" + printf '%s\n' "$id" > "$home/$SUB_HOME_MARKER.tmp.$$" + mv -f -- "$home/$SUB_HOME_MARKER.tmp.$$" "$home/$SUB_HOME_MARKER" write_registry "$id" "$home" "$projects_csv" "$SEED_PARENT_BRIEF" validate_registry SEED_COMMITTED=1 diff --git a/bin/fm-nm-run-lib.sh b/bin/fm-nm-run-lib.sh new file mode 100644 index 0000000000..7c210c23f5 --- /dev/null +++ b/bin/fm-nm-run-lib.sh @@ -0,0 +1,73 @@ +#!/usr/bin/env bash +# Shared no-mistakes axi run attribution primitives. +# +# ONE owner for the branch+code-identity matching rule that decides whether a +# no-mistakes run belongs to a given worktree, used by fm-crew-state.sh +# (read-only current-state reporting) and fm-teardown.sh (pre-teardown run +# abort, see its "Fix 1" header comment). Getting this wrong in either +# direction is unsafe: a false negative hides a genuinely parked run, and a +# false positive lets teardown act on a run it does not own. +# +# Bounded call to `no-mistakes "$@"` in dir $1, timeout $2 seconds. The bounded +# form preserves stdout, stderr, and exit status; the checked form discards +# stderr, while fm_nm_run keeps the fail-open query contract for read-only callers. +fm_nm_run_bounded() { # + local dir=$1 timeout_secs=$2 have_timeout=none + shift 2 + if command -v timeout >/dev/null 2>&1; then have_timeout=timeout + elif command -v gtimeout >/dev/null 2>&1; then have_timeout=gtimeout + elif command -v perl >/dev/null 2>&1; then have_timeout=perl + fi + case "$have_timeout" in + timeout) ( cd "$dir" && timeout "$timeout_secs" no-mistakes "$@" ) ;; + gtimeout) ( cd "$dir" && gtimeout "$timeout_secs" no-mistakes "$@" ) ;; + perl) ( cd "$dir" && perl -e 'my $t = shift; my $pid = fork; die "fork failed" unless defined $pid; if (!$pid) { setpgrp(0, 0); exec @ARGV } local $SIG{ALRM} = sub { kill "TERM", -$pid; select undef, undef, undef, 0.2; kill "KILL", -$pid; exit 124 }; alarm $t; waitpid $pid, 0; exit($? >> 8)' "$timeout_secs" no-mistakes "$@" ) ;; + *) return 1 ;; + esac +} + +fm_nm_run_checked() { # + fm_nm_run_bounded "$@" 2>/dev/null +} + +fm_nm_run() { # + fm_nm_run_checked "$@" || true +} + +fm_nm_trim() { + local s=${1:-} + s="${s#"${s%%[![:space:]]*}"}" + s="${s%"${s##*[![:space:]]}"}" + printf '%s' "$s" +} + +fm_nm_strip_quotes() { + local s + s=$(fm_nm_trim "${1:-}") + case "$s" in + \"*\") s=${s#\"}; s=${s%\"} ;; + esac + fm_nm_trim "$s" +} + +# Scalar value of a TOON key in captured `axi status` output $1. +fm_nm_field() { # + printf '%s\n' "$1" | sed -n "s/^[[:space:]]*$2:[[:space:]]*\(.*\)/\1/p" | head -1 +} + +# 0 if run head $2 matches worktree $1's code identity, per the same rule +# everywhere this attribution is needed: +# - missing/empty head: cannot bind; reject +# - equal commits (short or full SHA): match +# - worktree HEAD is an ancestor of run head: match (pipeline fix commits on +# the same history advanced the run tip past local HEAD) +# - run head is a strict ancestor of worktree HEAD, or diverged: no match +# (local work advanced outside the run, or the branch tip was rewritten) +fm_nm_head_matches_worktree() { # + local wt=$1 run_head=$2 local_full run_full + [ -n "$run_head" ] || return 1 + local_full=$(git -C "$wt" rev-parse HEAD 2>/dev/null) || return 1 + run_full=$(git -C "$wt" rev-parse --verify "${run_head}^{commit}" 2>/dev/null) || return 1 + [ "$run_full" = "$local_full" ] && return 0 + git -C "$wt" merge-base --is-ancestor "$local_full" "$run_full" 2>/dev/null +} diff --git a/bin/fm-on.sh b/bin/fm-on.sh index 8af8ced3e3..5e24f2cef1 100755 --- a/bin/fm-on.sh +++ b/bin/fm-on.sh @@ -21,6 +21,14 @@ # This command explicitly disables agent forwarding, forwarding setup, and # configured SendEnv patterns. The remote entrypoint executes the selected # command under an empty environment with only its fixed runtime values. +# +# ServerAliveInterval/ServerAliveCountMax arm dead-peer detection so a vanished +# peer (a reboot, a dropped link) becomes a bounded ssh failure (exit 255) +# instead of an indefinite hang on a half-open TCP connection. The remote +# sshd answers keepalive probes independently of whatever the remote command +# is doing, so a legitimately long-but-alive remote command is never falsely +# killed. FM_SSH_ALIVE_INTERVAL and FM_SSH_ALIVE_COUNT_MAX override the +# defaults; the worst-case detection window is roughly interval * count. set -eu SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" @@ -88,9 +96,17 @@ ROOT_B64=$(printf '%s' "$ROOT" | encode_base64) HOME_B64=$(printf '%s' "$HOME_PATH" | encode_base64) ARGV_B64=$(printf '%s\0' "$COMMAND" "$@" | encode_base64) SSH_BIN=${FM_SSH_BIN:-ssh} +ALIVE_INTERVAL=${FM_SSH_ALIVE_INTERVAL:-15} +ALIVE_COUNT_MAX=${FM_SSH_ALIVE_COUNT_MAX:-3} +case "$ALIVE_INTERVAL" in ''|*[!0-9]*) die "FM_SSH_ALIVE_INTERVAL must be a positive integer: $ALIVE_INTERVAL" ;; esac +case "$ALIVE_COUNT_MAX" in ''|*[!0-9]*) die "FM_SSH_ALIVE_COUNT_MAX must be a positive integer: $ALIVE_COUNT_MAX" ;; esac +[ "$ALIVE_INTERVAL" -gt 0 ] || die "FM_SSH_ALIVE_INTERVAL must be a positive integer: $ALIVE_INTERVAL" +[ "$ALIVE_COUNT_MAX" -gt 0 ] || die "FM_SSH_ALIVE_COUNT_MAX must be a positive integer: $ALIVE_COUNT_MAX" "$SSH_BIN" \ -o ForwardAgent=no \ -o ClearAllForwardings=yes \ -o 'SendEnv=-*' \ + -o "ServerAliveInterval=$ALIVE_INTERVAL" \ + -o "ServerAliveCountMax=$ALIVE_COUNT_MAX" \ -- "$HOST" fm-remote-entrypoint.sh "$PROTOCOL" "$ROOT_B64" "$HOME_B64" "$ARGV_B64" diff --git a/bin/fm-quota-axi-lib.sh b/bin/fm-quota-axi-lib.sh index 441c9ce2c9..ca95db0683 100644 --- a/bin/fm-quota-axi-lib.sh +++ b/bin/fm-quota-axi-lib.sh @@ -2,17 +2,14 @@ # Shared quota-axi compatibility floor for the bootstrap diagnostic. # Usage: . bin/fm-quota-axi-lib.sh # -# 0.1.16 is the floor because it is the first build that reports each provider's -# credential sources independently and exposes Grok `state.authStatus`. Without -# those fields a dispatch candidate cannot be checked against the authentication -# surface it actually uses, which is how one harness's expired CLI token used to -# produce a captain-facing sign-out claim for a candidate that never read it. +# FM_QUOTA_AXI_MIN follows the axi-family floor policy owned beside the floor +# constants in bin/fm-bootstrap.sh. # # This file is the single owner of that version number. bin/fm-bootstrap.sh # turns a failing check into the operator-facing MISSING diagnostic, which is # what keeps an older build from reaching a dispatch intake at all. -FM_QUOTA_AXI_MIN=0.1.16 +FM_QUOTA_AXI_MIN=0.1.17 fm_quota_axi_compatible() { local timeout=${1:-} output parts major minor patch extra diff --git a/bin/fm-remote-entrypoint.sh b/bin/fm-remote-entrypoint.sh index eb42a4afd7..6763e8c955 100755 --- a/bin/fm-remote-entrypoint.sh +++ b/bin/fm-remote-entrypoint.sh @@ -23,7 +23,10 @@ set -eu PROTOCOL=1 DOCTOR_SHA256=7bb13d9fad8455978bf109d4681a3aa3cb170565c8a74be4ec7b520427db14c2 -SCRIPT_DIR=$(CDPATH='' cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P) +REAL_SOURCE=$(python3 -c 'import os, sys; print(os.path.realpath(sys.argv[1]))' "${BASH_SOURCE[0]}" 2>/dev/null) || + REAL_SOURCE=$(realpath "${BASH_SOURCE[0]}" 2>/dev/null) || + REAL_SOURCE=${BASH_SOURCE[0]} +SCRIPT_DIR=$(CDPATH='' cd "$(dirname "$REAL_SOURCE")" && pwd -P) # shellcheck source=bin/fm-remote-job-lib.sh . "$SCRIPT_DIR/fm-remote-job-lib.sh" diff --git a/bin/fm-remote-home-provision.sh b/bin/fm-remote-home-provision.sh index a46b4adc9a..252e17e97f 100755 --- a/bin/fm-remote-home-provision.sh +++ b/bin/fm-remote-home-provision.sh @@ -4,10 +4,15 @@ # Usage: # fm-remote-home-provision.sh < manifest # -# Manifest schema fm-remote-home-provision.v1 carries a base64 charter and one -# base64 project record per line. The remote code root is cloned into an absent -# home, project origins are cloned on this host, the project registry and charter -# are published, and the .fm-secondmate-home marker commits the seed last. +# Manifest schema fm-remote-home-provision.v1 carries a base64 charter, the +# base64 parent SSH alias, and one base64 project record per line. The remote +# code root is cloned into an absent home, project origins are cloned on this +# host, the project registry and charter are published, the durable +# .fm-secondmate-parent record names this home's route to its parent as +# "remote" - read by bin/fm-teardown.sh's cleanup gate so a delegated public +# reply promise, which the subsystem can only carry on the parent's own +# filesystem, is never mistaken for one this child could hold - and the +# .fm-secondmate-home marker commits the complete seed last. # A newly created home is removed on failure. An existing matching seeded home # is converged only through guarded ordinary-file updates and new project clones. set -eu @@ -71,6 +76,7 @@ rollback() { restore_owned_file data/charter.md || true restore_owned_file data/projects.md || true restore_owned_file .fm-secondmate-home || true + restore_owned_file .fm-secondmate-parent || true [ "$CREATED_BACKLOG" -eq 0 ] || rm -f -- "$FM_HOME/data/backlog.md" fi fi @@ -87,9 +93,18 @@ SCHEMA=$(manifest_value "$TMP/manifest" schema || true) [ "$SCHEMA" = fm-remote-home-provision.v1 ] || die "incompatible provisioning manifest" ID_B64=$(manifest_value "$TMP/manifest" id_b64 || true) CHARTER_B64=$(manifest_value "$TMP/manifest" charter_b64 || true) +# Optional so a manifest sent by a not-yet-updated parent (predating this +# field) still provisions; the durable parent record below simply omits the +# host in that case rather than refusing the whole seed. +PARENT_HOST_B64=$(manifest_value "$TMP/manifest" parent_host_b64 || true) COUNT=$(manifest_value "$TMP/manifest" project_count || true) base64_decode_to "$ID_B64" "$TMP/id" || die "manifest id is not valid base64" base64_decode_to "$CHARTER_B64" "$TMP/charter" || die "manifest charter is not valid base64" +PARENT_HOST= +if [ -n "$PARENT_HOST_B64" ]; then + base64_decode_to "$PARENT_HOST_B64" "$TMP/parent-host" || die "manifest parent host is not valid base64" + PARENT_HOST=$(cat "$TMP/parent-host") +fi ID=$(cat "$TMP/id") safe_id "$ID" || die "manifest carries an unsafe secondmate id" case "$COUNT" in ''|*[!0-9]*) die "manifest project count is invalid" ;; esac @@ -137,7 +152,7 @@ if [ -e "$FM_HOME" ] || [ -L "$FM_HOME" ]; then fi done mkdir -p "$TMP/before/data" - for rel in data/charter.md data/projects.md .fm-secondmate-home; do + for rel in data/charter.md data/projects.md .fm-secondmate-home .fm-secondmate-parent; do existing="$FM_HOME/$rel" if [ -e "$existing" ] || [ -L "$existing" ]; then [ -f "$existing" ] && [ ! -L "$existing" ] || die "existing remote home has unsafe owned file: $rel" @@ -223,6 +238,12 @@ chmod 600 "$FM_HOME/data/charter.md.tmp.$$" mv -f -- "$FM_HOME/data/charter.md.tmp.$$" "$FM_HOME/data/charter.md" cp "$PROJECT_REG" "$FM_HOME/data/projects.md.tmp.$$" mv -f -- "$FM_HOME/data/projects.md.tmp.$$" "$FM_HOME/data/projects.md" +{ + printf 'schema=fm-secondmate-parent.v1\n' + printf 'route=remote\n' + [ -z "$PARENT_HOST" ] || printf 'parent_host=%s\n' "$PARENT_HOST" +} > "$FM_HOME/.fm-secondmate-parent.tmp.$$" +mv -f -- "$FM_HOME/.fm-secondmate-parent.tmp.$$" "$FM_HOME/.fm-secondmate-parent" printf '%s\n' "$ID" > "$FM_HOME/.fm-secondmate-home.tmp.$$" mv -f -- "$FM_HOME/.fm-secondmate-home.tmp.$$" "$FM_HOME/.fm-secondmate-home" PUBLISHED=1 diff --git a/bin/fm-remote-home-seed.sh b/bin/fm-remote-home-seed.sh index 6288aa07e6..f89c9ee8aa 100755 --- a/bin/fm-remote-home-seed.sh +++ b/bin/fm-remote-home-seed.sh @@ -163,6 +163,13 @@ done printf 'schema=fm-remote-home-provision.v1\n' printf 'id_b64=%s\n' "$(printf '%s' "$ID" | encode)" printf 'charter_b64=%s\n' "$(encode < "$TMP/charter.remote")" + # The SSH alias reaching this host from the parent's own config, carried + # only so the remote-provisioned home can record durably that its parent + # lives on another machine (bin/fm-teardown.sh's cleanup gate). It is + # diagnostic identity, never a route the remote host could use to reach + # back; the parent's real filesystem path is never sent, since it names + # nothing on the remote filesystem. + printf 'parent_host_b64=%s\n' "$(printf '%s' "$HOST" | encode)" printf 'project_count=%s\n' "${#PROJECT_NAMES[@]}" cat "$TMP/project.records" } > "$TMP/manifest" diff --git a/bin/fm-remote-job-lib.sh b/bin/fm-remote-job-lib.sh index 53daf2752d..8739fa5459 100755 --- a/bin/fm-remote-job-lib.sh +++ b/bin/fm-remote-job-lib.sh @@ -915,6 +915,14 @@ fm_remote_job_ensure_worker() { # fm_remote_job_reload_launchagent "$account_home" "$uid" || return 1 FM_REMOTE_JOB_REPAIRED=1 fm_remote_job_wait_for_probe "$root" "$account_home" && return 0 + else + # A replaced Linux supervisor can lose its first ownership race while the + # prior supervisor finishes releasing the shared worker lock. Retry the + # idempotent start once, matching the bounded recovery already used above + # for launchd, before reporting a startup failure. + fm_remote_job_start_linux_worker "$root" "$account_home" || return 1 + FM_REMOTE_JOB_REPAIRED=1 + fm_remote_job_wait_for_probe "$root" "$account_home" && return 0 fi # shellcheck disable=SC2034 # Sourceable API consumed by the entrypoint and remote doctor. FM_REMOTE_JOB_ERROR="remote job worker did not report ready after startup" diff --git a/bin/fm-secondmate-parent-lib.sh b/bin/fm-secondmate-parent-lib.sh new file mode 100644 index 0000000000..6c0b061c46 --- /dev/null +++ b/bin/fm-secondmate-parent-lib.sh @@ -0,0 +1,64 @@ +#!/usr/bin/env bash +# shellcheck disable=SC2034 # parsed fields are output globals for sourcing callers. +# Parse the durable parent binding written into a seeded secondmate home. +# +# The fm-secondmate-parent.v1 record contains exactly one schema and route. +# A local route contains exactly one absolute parent_home and no parent_host. +# A remote route contains no parent_home; current provisioning includes its SSH +# alias as diagnostic-only parent_host, while legacy-compatible manifests may +# omit that field. +# Unknown fields are reserved for forward-compatible additions. +# Duplicate schema or route fields, a malformed local binding, an unsupported +# route or schema, and a symlinked record fail closed. +# Writers publish this record before .fm-secondmate-home so that the identity +# marker remains the seed-completion point. + +fm_secondmate_parent_record_parse() { + local file=$1 line schema='' route='' parent_home='' parent_host='' + local schema_count=0 route_count=0 parent_home_count=0 parent_host_count=0 + + FM_SECONDMATE_PARENT_ROUTE= + FM_SECONDMATE_PARENT_HOME= + FM_SECONDMATE_PARENT_HOST= + + [ -f "$file" ] && [ ! -L "$file" ] || return 1 + while IFS= read -r line || [ -n "$line" ]; do + case "$line" in + schema=*) + schema_count=$((schema_count + 1)) + schema=${line#schema=} + ;; + route=*) + route_count=$((route_count + 1)) + route=${line#route=} + ;; + parent_home=*) + parent_home_count=$((parent_home_count + 1)) + parent_home=${line#parent_home=} + ;; + parent_host=*) + parent_host_count=$((parent_host_count + 1)) + parent_host=${line#parent_host=} + ;; + esac + done < "$file" + + [ "$schema_count" -eq 1 ] || return 1 + [ "$route_count" -eq 1 ] || return 1 + [ "$schema" = fm-secondmate-parent.v1 ] || return 1 + case "$route" in + local) + [ "$parent_home_count" -eq 1 ] || return 1 + [ "$parent_host_count" -eq 0 ] || return 1 + [ -n "$parent_home" ] || return 1 + FM_SECONDMATE_PARENT_HOME=$parent_home + ;; + remote) + [ "$parent_home_count" -eq 0 ] || return 1 + ;; + *) return 1 ;; + esac + + FM_SECONDMATE_PARENT_ROUTE=$route + FM_SECONDMATE_PARENT_HOST=$parent_host +} diff --git a/bin/fm-session-start.sh b/bin/fm-session-start.sh index 193872144e..a9bd93e344 100755 --- a/bin/fm-session-start.sh +++ b/bin/fm-session-start.sh @@ -292,8 +292,10 @@ else fi # --- 3. wake-drain ------------------------------------------------------- -# Drained records are this turn's first work queue (AGENTS.md section 8); the -# drain also runs fm-guard.sh internally on the locked path, so the +# Drained records are this turn's first work queue, and the drain's separate +# OPEN DECISIONS section remains actionable even when that queue is empty +# (AGENTS.md sections 3 and 8). +# The drain also runs fm-guard.sh internally on the locked path, so the # tangle/watcher-liveness alarms land right here too, ahead of the bulk digest # below. The read-only path never touches the queue because it lacks mutation # authority, and another session may be actively draining it. It still runs diff --git a/bin/fm-spawn.sh b/bin/fm-spawn.sh index 860fbdf74f..f03b3dafe8 100755 --- a/bin/fm-spawn.sh +++ b/bin/fm-spawn.sh @@ -51,8 +51,8 @@ # outside herdr has no workspace to inherit and uses this home's own labeled # workspace, which must then match exactly one. --secondmate is the deliberate # exception: it stands up that secondmate home's own workspace. -# Herdr additionally supports a default-off presentation-only layout when the -# local config/herdr-presentation-spaces flag exists. A clean fresh task first +# Herdr additionally uses a default-on presentation-only layout unless the +# local config/herdr-presentation-spaces file says off. A clean fresh task first # writes state/.herdr-presentation atomically, then creates a disposable # workspace containing only the ordinary task pane. A successful clean create # upgrades its attempt journal with exact home, session, workspace, tab, pane, @@ -1434,7 +1434,7 @@ case "$BACKEND" in fi HERDR_PRESENTATION_JOURNAL=$(fm_backend_herdr_projection_journal_path "$STATE" "$ID") HERDR_PROJECTED=0 - if [ "$KIND" != secondmate ] && [ -f "$CONFIG/herdr-presentation-spaces" ]; then + if [ "$KIND" != secondmate ] && fm_backend_herdr_presentation_enabled "$CONFIG"; then HERDR_SES=$(fm_backend_herdr_session) HERDR_PARENT_LABEL=$(FM_HOME="$HERDR_LABEL_HOME" fm_backend_herdr_workspace_label) if [ -e "$HERDR_PRESENTATION_JOURNAL" ] || [ -L "$HERDR_PRESENTATION_JOURNAL" ]; then @@ -2211,6 +2211,10 @@ fi if [ "$KIND" = secondmate ]; then sq_home=$(shell_quote "$PROJ_ABS") sq_primary_home=$(shell_quote "$FM_HOME") + case "$HARNESS" in + claude) supervision_model=autoarm ;; + *) supervision_model=persistent ;; + esac # Deliver the primary's EFFECTIVE trace-context decision as a normalized on/off # literal (never the raw FM_TRACE_CONTEXT string) so a FM_TRACE_CONTEXT override # on the primary reaches the secondmate's OWN workers, not just the copied @@ -2218,7 +2222,7 @@ if [ "$KIND" = secondmate ]; then # not enable them across the launch boundary (bin/fm-trace-context-lib.sh header). # Reuse the single frozen decision from the carrier resolution above so the # injected carrier and this on/off snapshot are guaranteed to agree. - LAUNCH="FM_ROOT_OVERRIDE= FM_STATE_OVERRIDE= FM_DATA_OVERRIDE= FM_PROJECTS_OVERRIDE= FM_CONFIG_OVERRIDE= FM_PUBLIC_FOLLOWUP_PRIMARY_HOME=$sq_primary_home FM_HOME=$sq_home FM_TRACE_CONTEXT=$SPAWN_TRACE_EFFECTIVE $LAUNCH" + LAUNCH="FM_ROOT_OVERRIDE= FM_STATE_OVERRIDE= FM_DATA_OVERRIDE= FM_PROJECTS_OVERRIDE= FM_CONFIG_OVERRIDE= FM_PUBLIC_FOLLOWUP_PRIMARY_HOME=$sq_primary_home FM_HOME=$sq_home FM_TRACE_CONTEXT=$SPAWN_TRACE_EFFECTIVE FM_SUPERVISION_MODEL=$supervision_model $LAUNCH" fi # Export GOTMPDIR into the crewmate's pane shell so the agent and every child # process (go build, go test, ...) inherit it. Sent before the launch command so diff --git a/bin/fm-supervision-lib.sh b/bin/fm-supervision-lib.sh index dc223d344c..252d0c93c2 100644 --- a/bin/fm-supervision-lib.sh +++ b/bin/fm-supervision-lib.sh @@ -6,9 +6,12 @@ # work (a state/.meta exists) or an X-mode relay poll # (state/x-watch.check.sh), and whether its watcher has a fresh liveness beacon # (state/.last-watcher-beat, touched every poll cycle, within the grace window). -# bin/fm-guard.sh and bin/fm-turnend-guard.sh use fm_watcher_healthy from -# bin/fm-wake-lib.sh for their warning and block decisions, so a fresh leftover -# beacon never counts as a live watcher. The status fields here retain the +# bin/fm-turnend-guard.sh uses the PID-strict fm_watcher_healthy from +# bin/fm-wake-lib.sh for its block decision. bin/fm-guard.sh uses the model-aware +# fm_watcher_supervision_verdict (also in bin/fm-wake-lib.sh): under the Claude +# Stop auto-arm model, where the watcher only runs between turns, a fresh beacon +# with no live watcher is healthy; under persistent-watcher harnesses a live +# identity-matched watcher is still required. The status fields here retain the # beacon-age details used in their messages. # Portable mtime; Linux stat lacks -f, macOS stat lacks -c. diff --git a/bin/fm-tasks-axi-lib.sh b/bin/fm-tasks-axi-lib.sh index 54d02fcc9e..3c6e329e0a 100644 --- a/bin/fm-tasks-axi-lib.sh +++ b/bin/fm-tasks-axi-lib.sh @@ -2,14 +2,24 @@ # Shared tasks-axi backend selection and compatibility probe for bootstrap, # teardown, and secondmate backlog handoff. # Usage: . bin/fm-tasks-axi-lib.sh -# Compatible means tasks-axi --version reports 0.1.1 or newer, +# +# Compatible means tasks-axi --version reports FM_TASKS_AXI_MIN or newer, # `tasks-axi update --help` exposes --archive-body for recoverable note rewrites, # and `tasks-axi mv --help` exposes [...] for atomic multi-ID moves required -# by secondmate handoffs (introduced in tasks-axi 0.2.2). +# by secondmate handoffs. +# FM_TASKS_AXI_MIN follows the axi-family floor policy owned beside the floor +# constants in bin/fm-bootstrap.sh. +# The feature probes are a separate concern and stay as defense in depth for +# stripped or forked builds that advertise a current version without those flags. # `config/backlog-backend=manual` opts out of tasks-axi for routine firstmate # backlog mutations, but validated secondmate handoffs always use `tasks-axi mv`. # Absent or any other value keeps the default tasks-axi backend path, falling # back to manual mutation when the tool is not compatible. +# +# This file is the single owner of FM_TASKS_AXI_MIN. bin/fm-bootstrap.sh turns a +# failing check into the operator-facing MISSING diagnostic. + +FM_TASKS_AXI_MIN=0.2.4 fm_tasks_axi_version_parts() { local output @@ -21,17 +31,19 @@ fm_tasks_axi_version_parts() { } fm_tasks_axi_compatible() { - local parts major minor patch rest + local parts major minor patch extra + local min_major min_minor min_patch min_extra parts=$(fm_tasks_axi_version_parts) || return 1 [ -n "$parts" ] || return 1 - major=${parts%% *} - rest=${parts#* } - minor=${rest%% *} - patch=${rest##* } - - if [ "$major" -gt 0 ] || - { [ "$major" -eq 0 ] && [ "$minor" -gt 1 ]; } || - { [ "$major" -eq 0 ] && [ "$minor" -eq 1 ] && [ "$patch" -ge 1 ]; }; then + IFS=' ' read -r major minor patch extra <<< "$parts" + # An unparseable version is incompatible, never assumed current, so a + # development or vendored build cannot pass a floor it was never checked against. + [ -n "$major" ] && [ -n "$minor" ] && [ -n "$patch" ] && [ -z "$extra" ] || return 1 + IFS='.' read -r min_major min_minor min_patch min_extra <<< "$FM_TASKS_AXI_MIN" + [ -n "$min_major" ] && [ -n "$min_minor" ] && [ -n "$min_patch" ] && [ -z "$min_extra" ] || return 1 + if [ "$major" -gt "$min_major" ] || + { [ "$major" -eq "$min_major" ] && [ "$minor" -gt "$min_minor" ]; } || + { [ "$major" -eq "$min_major" ] && [ "$minor" -eq "$min_minor" ] && [ "$patch" -ge "$min_patch" ]; }; then fm_tasks_axi_update_has_archive_body && fm_tasks_axi_mv_has_multi_id return $? fi diff --git a/bin/fm-teardown.sh b/bin/fm-teardown.sh index e4dc71be36..12008a7011 100755 --- a/bin/fm-teardown.sh +++ b/bin/fm-teardown.sh @@ -86,6 +86,41 @@ # is present; teardown clears only a provably stale lock, then re-runs the safety # checks before any destructive return. Teardown output notes every wait, retry, and # removal so the operator can see what happened. +# +# Pre-teardown cleanup sequence (runs once every landed/discard-work safety +# refusal above has already passed, and BEFORE any worktree return, branch +# delete, or backend kill below - a still-active run or a leaked process may +# own live work in that worktree): +# Fix 1 - conclude the task's own no-mistakes run. A ship task's worktree can +# be torn down while its no-mistakes pipeline run is still PARKED at a gate +# (awaiting_approval/fix_review/any awaiting_agent field), with no worker +# left to ever answer it - the run then sits there holding a fleet slot +# indefinitely (observed 2026-08-03: runs parked 7h39m and parked at a +# post-CI approval gate after the worker was already cleaned up). A run +# with an autonomous step still under way (running/fixing/ci) is left +# alone: no-mistakes drives those against its own gate-repo clone, not the +# crew's worktree, so they are not orphaned by removing the worktree. +# conclude_task_no_mistakes_run attributes the active-or-most-recent run to +# THIS task only when its branch AND code identity (bin/fm-nm-run-lib.sh's +# fm_nm_head_matches_worktree, the same rule bin/fm-crew-state.sh uses) both +# match this worktree, then runs `no-mistakes axi abort --run ` for +# that verified run instance. A run already terminal +# (an outcome is set) or not parked at a gate is left untouched. Idempotent: +# an already-aborted run reads back terminal and is skipped on retry. +# Fix 2 - reap leaked descendant processes. A backgrounded/disowned process +# started under the worktree (or its per-task tasktmp) does not receive the +# SIGHUP/SIGTERM that closing the backend pane sends to its own foreground +# process group, so it survives reparented to init (observed 2026-08-03: +# two `go test` binaries, deadlines blown past by ~100x, pinning CPU for +# hours with no live task meta to attribute them to once teardown had +# already removed it). reap_task_worktree_processes finds every process +# whose CURRENT WORKING DIRECTORY is this task's own worktree or tasktmp +# root via `lsof -a -d cwd` (cheap: bounded by process count, not by +# walking the worktree's file tree) and sends TERM, then KILL after a short +# grace period to any survivor whose process identity still matches. Both +# roots are unique per task and never +# shared, so this can never reach another task's or the primary's +# processes. Idempotent: nothing left to find is a silent no-op. set -eu SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" @@ -96,6 +131,7 @@ DATA="${FM_DATA_OVERRIDE:-$FM_HOME/data}" CONFIG="${FM_CONFIG_OVERRIDE:-$FM_HOME/config}" SECONDMATE_REG="$DATA/secondmates.md" SUB_HOME_MARKER=".fm-secondmate-home" +SUB_HOME_PARENT_MARKER=".fm-secondmate-parent" # shellcheck source=bin/fm-tasks-axi-lib.sh . "$SCRIPT_DIR/fm-tasks-axi-lib.sh" # shellcheck source=bin/fm-backend.sh @@ -110,8 +146,12 @@ SUB_HOME_MARKER=".fm-secondmate-home" . "$SCRIPT_DIR/fm-public-followup-lib.sh" # shellcheck source=bin/fm-secondmate-registry-lib.sh . "$SCRIPT_DIR/fm-secondmate-registry-lib.sh" +# shellcheck source=bin/fm-secondmate-parent-lib.sh +. "$SCRIPT_DIR/fm-secondmate-parent-lib.sh" # shellcheck source=bin/fm-wake-lib.sh . "$SCRIPT_DIR/fm-wake-lib.sh" +# shellcheck source=bin/fm-nm-run-lib.sh +. "$SCRIPT_DIR/fm-nm-run-lib.sh" if [ "$#" -lt 1 ] || ! fm_task_id_path_safe "$1"; then echo "error: invalid teardown request" >&2 exit 2 @@ -298,7 +338,8 @@ remote_secondmate_teardown() { tmp="$SECONDMATE_REG.tmp.$$" grep -vE "^- $ID( |$)" "$SECONDMATE_REG" > "$tmp" || true mv -f -- "$tmp" "$SECONDMATE_REG" - rm -f -- "$STATE/$ID.status" "$STATE/$ID.meta" "$STATE/$ID.turn-ended" + rm -f -- "$STATE/$ID.status" "$STATE/$ID.meta" "$STATE/$ID.turn-ended" \ + "$STATE/.$ID.open-decisions-cursor" printf 'teardown %s complete (remote %s:%s)\n' "$ID" "$remote_host" "$remote_home" return 0 } @@ -365,12 +406,16 @@ PUBLIC_FOLLOWUP_WORK_HOME=main PUBLIC_FOLLOWUP_PARENT_UNRESOLVED=0 PUBLIC_FOLLOWUP_PARENT_RELAY_ACTIVE=0 PUBLIC_FOLLOWUP_RELAY_ACTIVE=0 +public_followup_canonical_home() { + local home=$1 + case "$home" in /*) ;; *) return 1 ;; esac + CDPATH='' cd -- "$home" 2>/dev/null && pwd -P +} public_followup_resolve_primary_home() { local parent=$1 child=$2 id=$3 parent_meta registry meta_home fm_pf_home_id_valid "secondmate:$id" || return 1 - case "$parent" in /*) ;; *) return 1 ;; esac - parent=$(CDPATH='' cd -- "$parent" 2>/dev/null && pwd -P) || return 1 - child=$(CDPATH='' cd -- "$child" 2>/dev/null && pwd -P) || return 1 + parent=$(public_followup_canonical_home "$parent") || return 1 + child=$(public_followup_canonical_home "$child") || return 1 [ "$parent" != "$child" ] || return 1 parent_meta="$parent/state/$id.meta" [ -f "$parent_meta" ] && [ ! -L "$parent_meta" ] || return 1 @@ -384,22 +429,64 @@ public_followup_resolve_primary_home() { } if [ -f "$FM_HOME/$SUB_HOME_MARKER" ]; then SECOND_MATE_ID=$(sed -n '1p' "$FM_HOME/$SUB_HOME_MARKER") - # A marked child only enters the primary-binding path when the authoritative - # parent relay is active. A child that has not opted into the relay must - # retain the old teardown path, even without a durable parent registry. - if [ -n "${FM_PUBLIC_FOLLOWUP_PRIMARY_HOME:-}" ]; then - if fm_pf_relay_active "$FM_PUBLIC_FOLLOWUP_PRIMARY_HOME"; then - PUBLIC_FOLLOWUP_PARENT_RELAY_ACTIVE=1 + # The durable parent record (written once at seeding, next to the identity + # marker) names this home's route to its parent: "local" when they share a + # filesystem, "remote" when the parent lives on another machine. Absent for + # a home seeded before this record existed, which preserves today's exact + # env-var-only behavior for that legacy home rather than guessing its route. + PARENT_ROUTE_FILE="$FM_HOME/$SUB_HOME_PARENT_MARKER" + PARENT_ROUTE_RECORD=absent + PARENT_ROUTE= + PARENT_ROUTE_HOME= + if [ -e "$PARENT_ROUTE_FILE" ] || [ -L "$PARENT_ROUTE_FILE" ]; then + PARENT_ROUTE_RECORD=invalid + if fm_secondmate_parent_record_parse "$PARENT_ROUTE_FILE"; then + PARENT_ROUTE=$FM_SECONDMATE_PARENT_ROUTE + PARENT_ROUTE_HOME=$FM_SECONDMATE_PARENT_HOME + PARENT_ROUTE_RECORD=valid fi - elif fm_pf_relay_active "$FM_HOME"; then - PUBLIC_FOLLOWUP_PARENT_RELAY_ACTIVE=1 fi - if [ "$PUBLIC_FOLLOWUP_PARENT_RELAY_ACTIVE" = 1 ]; then + if [ "$PARENT_ROUTE_RECORD" = invalid ]; then + PUBLIC_FOLLOWUP_PARENT_UNRESOLVED=1 + elif [ "$PARENT_ROUTE" = remote ]; then + # The entire promised-public-reply subsystem is same-filesystem by + # construction (bin/fm-public-followup-emit.sh header): a parent recorded + # on another machine can never hold a delegated promise for this child, so + # the delegated-parent path is out of scope and never refuses cleanup on + # its own. A token committed directly to THIS home's own .env is still a + # real, same-filesystem signal, so it is still checked - but read only + # from the file, never from the process environment, so an unrelated + # export in the remote host's own login shell cannot trigger it the way + # fm_pf_relay_active's environment-wins rule would. + if [ -f "$FM_HOME/.env" ]; then + HOME_ENV_TOKEN=$(fmx_env_get FMX_PAIRING_TOKEN "$FM_HOME/.env") + [ -z "$HOME_ENV_TOKEN" ] || PUBLIC_FOLLOWUP_PARENT_RELAY_ACTIVE=1 + fi + if [ "$PUBLIC_FOLLOWUP_PARENT_RELAY_ACTIVE" = 1 ]; then + PUBLIC_FOLLOWUP_PARENT_UNRESOLVED=1 + else + PUBLIC_FOLLOWUP_HOME= + PUBLIC_FOLLOWUP_STATE= + fi + elif [ "$PARENT_ROUTE" = local ]; then PUBLIC_FOLLOWUP_PARENT_UNRESOLVED=1 - if fm_pf_home_id_valid "secondmate:$SECOND_MATE_ID"; then + PRIMARY_HOME_CANDIDATE=${FM_PUBLIC_FOLLOWUP_PRIMARY_HOME:-$PARENT_ROUTE_HOME} + PARENT_BINDINGS_MATCH=1 + if [ -n "${FM_PUBLIC_FOLLOWUP_PRIMARY_HOME:-}" ]; then + LIVE_PARENT_HOME=$(public_followup_canonical_home \ + "$FM_PUBLIC_FOLLOWUP_PRIMARY_HOME") || PARENT_BINDINGS_MATCH=0 + DURABLE_PARENT_HOME=$(public_followup_canonical_home \ + "$PARENT_ROUTE_HOME") || PARENT_BINDINGS_MATCH=0 + if [ "$PARENT_BINDINGS_MATCH" = 1 ] \ + && [ "$LIVE_PARENT_HOME" != "$DURABLE_PARENT_HOME" ]; then + PARENT_BINDINGS_MATCH=0 + fi + fi + if [ "$PARENT_BINDINGS_MATCH" = 1 ] \ + && fm_pf_home_id_valid "secondmate:$SECOND_MATE_ID"; then PUBLIC_FOLLOWUP_WORK_HOME="secondmate:$SECOND_MATE_ID" if PUBLIC_FOLLOWUP_HOME=$(public_followup_resolve_primary_home \ - "${FM_PUBLIC_FOLLOWUP_PRIMARY_HOME:-}" "$FM_HOME" "$SECOND_MATE_ID"); then + "$PRIMARY_HOME_CANDIDATE" "$FM_HOME" "$SECOND_MATE_ID"); then PUBLIC_FOLLOWUP_STATE="$PUBLIC_FOLLOWUP_HOME/state" PUBLIC_FOLLOWUP_PARENT_UNRESOLVED=0 if [ "$FORCE" != "--force" ] \ @@ -412,8 +499,37 @@ if [ -f "$FM_HOME/$SUB_HOME_MARKER" ]; then fi fi else - PUBLIC_FOLLOWUP_HOME= - PUBLIC_FOLLOWUP_STATE= + # A home seeded before the durable record existed retains the legacy + # launch-time binding behavior unchanged. + PRIMARY_HOME_CANDIDATE=${FM_PUBLIC_FOLLOWUP_PRIMARY_HOME:-} + if [ -n "$PRIMARY_HOME_CANDIDATE" ]; then + if fm_pf_relay_active "$PRIMARY_HOME_CANDIDATE"; then + PUBLIC_FOLLOWUP_PARENT_RELAY_ACTIVE=1 + fi + elif fm_pf_relay_active "$FM_HOME"; then + PUBLIC_FOLLOWUP_PARENT_RELAY_ACTIVE=1 + fi + if [ "$PUBLIC_FOLLOWUP_PARENT_RELAY_ACTIVE" = 1 ]; then + PUBLIC_FOLLOWUP_PARENT_UNRESOLVED=1 + if fm_pf_home_id_valid "secondmate:$SECOND_MATE_ID"; then + PUBLIC_FOLLOWUP_WORK_HOME="secondmate:$SECOND_MATE_ID" + if PUBLIC_FOLLOWUP_HOME=$(public_followup_resolve_primary_home \ + "$PRIMARY_HOME_CANDIDATE" "$FM_HOME" "$SECOND_MATE_ID"); then + PUBLIC_FOLLOWUP_STATE="$PUBLIC_FOLLOWUP_HOME/state" + PUBLIC_FOLLOWUP_PARENT_UNRESOLVED=0 + if [ "$FORCE" != "--force" ] \ + && fm_pf_relay_active "$PUBLIC_FOLLOWUP_HOME"; then + PUBLIC_FOLLOWUP_RELAY_ACTIVE=1 + fi + else + PUBLIC_FOLLOWUP_HOME= + PUBLIC_FOLLOWUP_STATE= + fi + fi + else + PUBLIC_FOLLOWUP_HOME= + PUBLIC_FOLLOWUP_STATE= + fi fi elif [ "$KIND" = secondmate ]; then PUBLIC_FOLLOWUP_WORK_HOME="secondmate:$ID" @@ -1032,6 +1148,320 @@ validate_worktree_teardown_safety() { fi } +# Fix 1 (see script header): does the active-or-most-recent no-mistakes run in +# worktree $1 belong to THIS task, and is it parked at a gate awaiting an agent +# that is about to be removed? Prints nothing; returns 0 only on a genuine +# match so the caller knows it is safe to abort - never a guess. +NM_TEARDOWN_TIMEOUT=${FM_TEARDOWN_NM_TIMEOUT:-10} +case "$NM_TEARDOWN_TIMEOUT" in ''|*[!0-9]*) NM_TEARDOWN_TIMEOUT=10 ;; esac +TASK_RUN_ID= +task_status_is_own_parked_run() { # + local wt=$1 out=$2 branch run_id run_branch run_head status outcome awaiting has_gate + TASK_RUN_ID= + branch=$(git -C "$wt" symbolic-ref --quiet --short HEAD 2>/dev/null) || return 1 + [ -n "$branch" ] || return 1 + [ -n "$out" ] || return 1 + run_id=$(fm_nm_strip_quotes "$(fm_nm_field "$out" id)") + [ -n "$run_id" ] || return 1 + run_branch=$(fm_nm_strip_quotes "$(fm_nm_field "$out" branch)") + [ -n "$run_branch" ] && [ "$run_branch" = "$branch" ] || return 1 + run_head=$(fm_nm_strip_quotes "$(fm_nm_field "$out" head)") + fm_nm_head_matches_worktree "$wt" "$run_head" || return 1 + outcome=$(fm_nm_strip_quotes "$(fm_nm_field "$out" outcome)") + [ -z "$outcome" ] || return 1 + status=$(fm_nm_strip_quotes "$(fm_nm_field "$out" status)") + awaiting=$(printf '%s\n' "$out" | grep -E '^[[:space:]]*awaiting_agent:' | head -1 || true) + has_gate=$(printf '%s\n' "$out" | grep -Eq '^[[:space:]]*gate:[[:space:]]*' && echo 1 || echo 0) + case "$status" in + awaiting_approval|fix_review) TASK_RUN_ID=$run_id; return 0 ;; + esac + if [ -n "$awaiting" ] || [ "$has_gate" = 1 ]; then + TASK_RUN_ID=$run_id + return 0 + fi + return 1 +} + +task_run_is_own_parked_run() { # + local wt=$1 out + # Accepted best-effort residual: query failures stay fail-open because making + # no-mistakes availability a prerequisite would block ship tasks with no run. + out=$(fm_nm_run "$wt" "$NM_TEARDOWN_TIMEOUT" axi status) + task_status_is_own_parked_run "$wt" "$out" +} + +task_status_is_terminal_run() { # + local out=$1 expected_id=$2 run_id outcome + run_id=$(fm_nm_strip_quotes "$(fm_nm_field "$out" id)") + [ "$run_id" = "$expected_id" ] || return 1 + outcome=$(fm_nm_strip_quotes "$(fm_nm_field "$out" outcome)") + case "$outcome" in + cancelled|failed|passed|checks-passed) return 0 ;; + esac + return 1 +} + +task_status_is_run_not_found() { # + local actual expected + actual=$(fm_nm_trim "$1") + expected=$(printf 'error: "run \\"%s\\" not found"' "$2") + [ "$actual" = "$expected" ] +} + +# Abort THIS task's own parked no-mistakes run before the worker that would +# have answered its gate is removed, so no run is left orphaned holding a +# fleet slot. Only KIND=ship drives a no-mistakes validation of its own +# worktree (scouts and secondmates never do, mirroring bin/fm-crew-state.sh); +# a run not attributed to this exact branch+head is left completely alone. +conclude_task_no_mistakes_run() { # + local wt=$1 out run_id + [ "$KIND" = ship ] || return 0 + [ -d "$wt" ] || return 0 + command -v no-mistakes >/dev/null 2>&1 || return 0 + task_run_is_own_parked_run "$wt" || return 0 + run_id=$TASK_RUN_ID + echo "teardown: no-mistakes run for $ID is parked at a gate; aborting before the worker is removed" >&2 + # Accepted best-effort residual: abort supports run-id targeting but no atomic + # live-state condition; fully closing the resume race needs upstream compare-and-cancel. + fm_nm_run_checked "$wt" "$NM_TEARDOWN_TIMEOUT" axi abort --run "$run_id" >/dev/null 2>&1 || true + if out=$(fm_nm_run_bounded "$wt" "$NM_TEARDOWN_TIMEOUT" axi status --run "$run_id" 2>&1); then + task_status_is_terminal_run "$out" "$run_id" && return 0 + elif task_status_is_run_not_found "$out" "$run_id"; then + return 0 + fi + echo "REFUSED: no-mistakes run for $ID is still parked after axi abort; confirm it stopped (no-mistakes axi status) or abort it manually (no-mistakes axi abort --run ) before retrying teardown." >&2 + return 1 +} + +# Fix 2 (see script header): pids of every process whose CURRENT WORKING +# DIRECTORY is exactly $1 or under it, from one bounded system-wide `lsof -a +# -d cwd` scan (never the recursive +D file-tree walk, which lsof itself +# documents as slow). Never $$ (this script's own pid). Empty output when +# nothing matches; failure means the scan could not establish a safe result. +pids_with_cwd_under() { # + local dir=$1 out pid path line + [ -n "$dir" ] && [ -d "$dir" ] || return 0 + dir=$(cd "$dir" && pwd -P) || return 1 + out=$(lsof -a -d cwd -Fpn 2>/dev/null) || return 1 + [ -n "$out" ] || return 0 + pid= + while IFS= read -r line; do + case "$line" in + p*) + pid=${line#p} + case "$pid" in ''|*[!0-9]*) return 1 ;; esac + ;; + fcwd) [ -n "$pid" ] || return 1 ;; + n*) + [ -n "$pid" ] || return 1 + path=${line#n} + case "$path" in + "$dir"|"$dir"/*) + [ -n "$pid" ] && [ "$pid" != "$$" ] && printf '%s\n' "$pid" + ;; + esac + ;; + '') ;; + *) return 1 ;; + esac + done < + local pid=$1 proc_root stat_line starttime value + local -a stat_fields + proc_root=${FM_PROC_ROOT_OVERRIDE:-/proc} + if [ -r "$proc_root/$pid/stat" ]; then + stat_line=$(cat "$proc_root/$pid/stat" 2>/dev/null) || return 1 + read -r -a stat_fields <<< "${stat_line##*)}" + [ "${#stat_fields[@]}" -ge 20 ] || return 1 + starttime=${stat_fields[19]} + case "$starttime" in ''|*[!0-9]*) return 1 ;; esac + printf 'starttime=%s\n' "$starttime" + return 0 + fi + value=$(LC_ALL=C ps -p "$pid" -o lstart= 2>/dev/null) || return 1 + value=$(fm_nm_trim "$value") + [ -n "$value" ] || return 1 + case "$value" in *$'\n'*|*$'\r'*) return 1 ;; esac + printf 'lstart=%s\n' "$value" +} + +task_process_identity_matches() { # + local current + current=$(task_process_identity "$1") || return 1 + [ "$current" = "$2" ] +} + +task_pid_list_contains() { # + printf '%s\n' "$1" | grep -Fxq "$2" +} + +task_pids_under_roots() { # ... + TASK_PIDS= + TASK_PIDS_FAILED_DIR= + local dir dir_pids pids="" + for dir in "$@"; do + [ -n "$dir" ] || continue + if ! dir_pids=$(pids_with_cwd_under "$dir"); then + TASK_PIDS_FAILED_DIR=$dir + return 1 + fi + pids="$pids +$dir_pids" + done + TASK_PIDS=$(printf '%s\n' "$pids" | grep -E '^[0-9]+$' | sort -un || true) +} + +reap_task_backend_process_group() { #