diff --git a/.agents/skills/bootstrap-diagnostics/SKILL.md b/.agents/skills/bootstrap-diagnostics/SKILL.md index daa0c4e421..0169ced269 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 `gh-axi`, this also covers an installed version below the bootstrap-owned floor; treat it as an upgrade request so non-interactive PR merges keep a working bare `--squash` shorthand. + For `tasks-axi`, this also covers an installed build that fails the compatibility 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/.github/workflows/ci.yml b/.github/workflows/ci.yml index 064f1c1613..787f077d62 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -198,6 +198,11 @@ jobs: # worktree acquisition (presentation, workspace-per-home, autodetect). bin/fm-install-treehouse.sh "$RUNNER_TEMP/bin" echo "$RUNNER_TEMP/bin" >> "$GITHUB_PATH" + - name: Provision spawn secret baseline + run: | + set -eu + umask 077 + printf '%s\n' 'FM_TEST_BASELINE_SECRET=not-a-secret' > "$HOME/.secrets" - name: Assert Herdr pin and protocol floor run: | set -eu 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 9f90089778..f0af22e686 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 @@ -145,6 +145,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 +366,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-bootstrap.sh b/bin/fm-bootstrap.sh index d3d3bb0789..9b6bc031bb 100755 --- a/bin/fm-bootstrap.sh +++ b/bin/fm-bootstrap.sh @@ -52,8 +52,11 @@ # "treehouse get --lease" support. # no-mistakes is also MISSING when its installed version is older than # 1.31.2. +# gh-axi is also MISSING when its installed version is older than +# 0.1.29, the first release whose bare --squash shorthand works for +# firstmate's non-interactive PR merge path. # 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+ +# lavish-axi). tasks-axi is also version and feature gated (0.2.2+ # 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 @@ -690,6 +693,7 @@ if ! BACKEND_TOOLS=$(fm_backend_required_tools "$BACKEND"); then fi TOOLS="$BACKEND_TOOLS $COMMON_TOOLS" NO_MISTAKES_MIN=1.31.2 +GH_AXI_MIN=0.1.29 treehouse_supports_lease() { treehouse get --help 2>&1 | grep -Eq '(^|[^[:alnum:]_-])--lease([^[:alnum:]_-]|$)' @@ -1029,6 +1033,9 @@ 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 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..8ad7e6813b 100755 --- a/bin/fm-classify-lib.sh +++ b/bin/fm-classify-lib.sh @@ -207,9 +207,15 @@ EOF # 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 + [ -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 @@ -233,6 +239,30 @@ status_open_decisions() { # 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 <" >&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-quota-axi-lib.sh b/bin/fm-quota-axi-lib.sh index 441c9ce2c9..98bb30c117 100644 --- a/bin/fm-quota-axi-lib.sh +++ b/bin/fm-quota-axi-lib.sh @@ -7,6 +7,10 @@ # 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. +# 0.1.16 already emits schemaVersion 3 with per-model effectiveAvailability; +# 0.1.17 only adds optional runway under that same schema. quota-array-dispatch +# treats absent runway or pace as disclosed uncertainty, so the floor stays +# 0.1.16 rather than tracking the latest additive field. # # 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 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-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 f1ad34bdd9..bf8d70faed 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, @@ -149,6 +149,14 @@ # identity is owned by the parent home that holds its task metadata, while the # pane export happens on the remote host (bin/fm-remote-secondmate-control.sh). # Local spawns never pass it and resolve their own carrier exactly as before. +# Every actual spawn reads the variable names from ~/.secrets before any fleet +# mutation and launches the worker through `/usr/bin/env -u ...` so a +# login shell's baseline secrets cannot reach the crew. Missing, unreadable, +# empty, or unsupported baseline syntax refuses the spawn without printing +# any secret content. This intentionally removes environment-token fallbacks: +# harnesses and crew tools must use their managed auth/config stores or an +# explicit scoped runner, while fm-spawn reapplies its required launch settings +# after the scrub. set -eu SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" @@ -164,6 +172,204 @@ case "${1:-}" in -h|--help) usage; exit 0 ;; esac +# Print a shell-safe command prefix that removes every variable declared by the +# approved baseline. The parser deliberately accepts only the baseline's +# one-definition-per-line format plus comments, blanks, export-only names, and +# safe unset directives. Unknown shell syntax is a refusal: silently skipping a +# line could leave a baseline secret inherited by the worker. +secret_env_assignment_tail_supported() { + local value=$1 state=plain trailing=0 char next i=0 length + length=${#value} + + while [ "$i" -lt "$length" ]; do + char=${value:$i:1} + case "$state" in + single) + [ "$char" != "'" ] || state=plain + ;; + double) + case "$char" in + '"') state=plain ;; + \\) + i=$((i + 1)) + [ "$i" -lt "$length" ] || return 1 + ;; + '$') + i=$((i + 1)) + [ "$i" -lt "$length" ] || return 1 + next=${value:$i:1} + if [ "$next" = '{' ]; then + i=$((i + 1)) + [ "$i" -lt "$length" ] || return 1 + next=${value:$i:1} + [[ $next =~ ^[A-Za-z_]$ ]] || return 1 + while :; do + i=$((i + 1)) + [ "$i" -lt "$length" ] || return 1 + next=${value:$i:1} + [[ $next =~ ^[A-Za-z0-9_]$ ]] || break + done + [ "$next" = '}' ] || return 1 + else + [[ $next =~ ^[A-Za-z_]$ ]] || return 1 + while [ $((i + 1)) -lt "$length" ]; do + next=${value:$((i + 1)):1} + [[ $next =~ ^[A-Za-z0-9_]$ ]] || break + i=$((i + 1)) + done + fi + ;; + '`') return 1 ;; + esac + ;; + plain) + if [ "$trailing" -eq 1 ]; then + case "$char" in + ' '|$'\t') ;; + '#') return 0 ;; + *) return 1 ;; + esac + else + case "$char" in + "'") state=single ;; + '"') state=double ;; + ' '|$'\t') trailing=1 ;; + \\|'`'|';'|'&'|'|'|'<'|'>'|'('|')') return 1 ;; + '$') + i=$((i + 1)) + [ "$i" -lt "$length" ] || return 1 + next=${value:$i:1} + if [ "$next" = '{' ]; then + i=$((i + 1)) + [ "$i" -lt "$length" ] || return 1 + next=${value:$i:1} + [[ $next =~ ^[A-Za-z_]$ ]] || return 1 + while :; do + i=$((i + 1)) + [ "$i" -lt "$length" ] || return 1 + next=${value:$i:1} + [[ $next =~ ^[A-Za-z0-9_]$ ]] || break + done + [ "$next" = '}' ] || return 1 + else + [[ $next =~ ^[A-Za-z_]$ ]] || return 1 + while [ $((i + 1)) -lt "$length" ]; do + next=${value:$((i + 1)):1} + [[ $next =~ ^[A-Za-z0-9_]$ ]] || break + i=$((i + 1)) + done + fi + ;; + esac + fi + ;; + esac + i=$((i + 1)) + done + + [ "$state" = plain ] +} + +secret_env_scrub_prefix() { + local secrets_file line name read_status assignment_tail word_index + local assignment_re export_re blank_re unset_re + local -a line_names unset_words + local seen=' BASH_ENV ' prefix='/usr/bin/env -u BASH_ENV' count=0 line_no=0 + + if [ -z "${HOME:-}" ]; then + echo "error: HOME is unset; cannot locate approved secret baseline for crewmate environment scrub" >&2 + return 1 + fi + secrets_file=$HOME/.secrets + + if [ ! -f "$secrets_file" ] || [ ! -r "$secrets_file" ]; then + echo "error: cannot read approved secret baseline for crewmate environment scrub" >&2 + return 1 + fi + if ! { exec 9< "$secrets_file"; } 2>/dev/null; then + echo "error: cannot read approved secret baseline for crewmate environment scrub" >&2 + return 1 + fi + + assignment_re='^[[:space:]]*(export[[:space:]]+)?([A-Za-z_][A-Za-z0-9_]*)=' + export_re='^[[:space:]]*export[[:space:]]+([A-Za-z_][A-Za-z0-9_]*)[[:space:]]*(#.*)?$' + blank_re='^[[:space:]]*(#.*)?$' + unset_re='^[[:space:]]*unset[[:space:]]+(-[fv][[:space:]]+|--[[:space:]]+)?[A-Za-z_][A-Za-z0-9_]*([[:space:]]+[A-Za-z_][A-Za-z0-9_]*)*[[:space:]]*(#.*)?$' + + while :; do + line= + if IFS= read -r line <&9; then + read_status=0 + else + read_status=$? + fi + if [ "$read_status" -ne 0 ] && [ -z "$line" ]; then + if [ "$read_status" -ne 1 ]; then + exec 9<&- + echo "error: could not finish reading approved secret baseline for crewmate environment scrub" >&2 + return 1 + fi + break + fi + line_no=$((line_no + 1)) + + line_names=() + if [[ $line =~ $assignment_re ]]; then + name=${BASH_REMATCH[2]} + line_names+=("$name") + assignment_tail=${line#*=} + if ! secret_env_assignment_tail_supported "$assignment_tail"; then + exec 9<&- + echo "error: approved secret baseline has unsupported syntax at line $line_no; refusing incomplete crewmate environment scrub" >&2 + return 1 + fi + elif [[ $line =~ $export_re ]]; then + name=${BASH_REMATCH[1]} + line_names+=("$name") + elif [[ $line =~ $unset_re ]]; then + unset_words=() + IFS=$' \t' read -r -a unset_words <<< "$line" + if [ "${unset_words[1]:-}" != -f ]; then + word_index=1 + while [ "$word_index" -lt "${#unset_words[@]}" ]; do + name=${unset_words[$word_index]} + case "$name" in + -v|--) ;; + \#*) break ;; + *) line_names+=("$name") ;; + esac + word_index=$((word_index + 1)) + done + fi + elif [[ $line =~ $blank_re ]]; then + : + else + exec 9<&- + echo "error: approved secret baseline has unsupported syntax at line $line_no; refusing incomplete crewmate environment scrub" >&2 + return 1 + fi + + for name in "${line_names[@]+"${line_names[@]}"}"; do + count=$((count + 1)) + case "$seen" in + *" $name "*) ;; + *) + prefix="$prefix -u $name" + seen="$seen$name " + ;; + esac + done + [ "$read_status" -eq 0 ] || break + done + exec 9<&- + + if [ "$count" -eq 0 ]; then + echo "error: approved secret baseline exposed no variable names for scrub" >&2 + return 1 + fi + printf '%s' "$prefix" +} + FM_ROOT="${FM_ROOT_OVERRIDE:-$(cd "$SCRIPT_DIR/.." && pwd)}" FM_HOME="${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}}" @@ -214,6 +420,9 @@ SUB_HOME_MARKER=".fm-secondmate-home" # Fail closed before any fleet mutation: a no-mistakes gate agent must never spawn # a direct report (see bin/fm-gate-refuse-lib.sh). fm_refuse_if_gate_agent +# Resolve the scrub before the guard or any fleet mutation, while preserving the +# gate-agent refusal as the first authority check. +SECRET_ENV_SCRUB_PREFIX=$(secret_env_scrub_prefix) || exit 1 # Skip the watcher guard when re-exec'd for one pair of a batch (FM_SPAWN_NO_GUARD is # set by the batch loop below), so the guard runs once for the batch, not once per pair. [ -n "${FM_SPAWN_NO_GUARD:-}" ] || "$FM_ROOT/bin/fm-guard.sh" || true @@ -1408,7 +1617,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 @@ -2105,6 +2314,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 @@ -2112,8 +2325,9 @@ 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 +LAUNCH="$SECRET_ENV_SCRUB_PREFIX GOTMPDIR=$(shell_quote "$TASK_TMP/gotmp") /bin/bash -c $(shell_quote "$LAUNCH")" # 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 # the env is set when the agent starts; the brief sleep lets the export land. 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..0fe9f4ad09 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). +# 0.2.2 is the floor because multi-ID mv is the true minimum firstmate uses; +# earlier builds could pass a 0.1.1 version check and still fail handoff. +# Feature probes 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.2 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 14c8987153..81a21d6924 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 @@ -365,12 +405,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 +428,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 +498,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" @@ -1019,6 +1134,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() { #