diff --git a/.agents/skills/afk/SKILL.md b/.agents/skills/afk/SKILL.md index 95f64b11e0..aba6e3fb00 100644 --- a/.agents/skills/afk/SKILL.md +++ b/.agents/skills/afk/SKILL.md @@ -92,14 +92,15 @@ The daemon never injects into an in-use pane. Two checks run before every injection, dispatched through `bin/fm-backend.sh` for the supervisor's own backend (tmux or herdr; see "Auto-discovered supervisor pane" below): -- **`pane_is_busy`** - the harness shows a busy footer (agent mid-turn) on tmux (shared with `fm-send.sh` via `bin/fm-tmux-lib.sh`); on herdr, tries the native `agent.get`-backed busy state first, trusts only `busy` outright, and corroborates every non-`busy` verdict with the same regex-over-capture reader. +- **Primary-pane busy guard** - `pane_is_busy` trusts Herdr native `busy` when available, otherwise matches rendered output against only the detected primary harness's signature. + This narrow delivery guard never classifies a recorded worker task and never uses a global union of vendor patterns. - **Composer-state guard** - `inject_msg` reads the full `empty`/`pending`/`unknown` verdict from `fm_backend_composer_state` and injects only when it is affirmatively `empty`. `pending` means real unsubmitted text, while `unknown` includes an unreadable pane and a bare shell prompt left after the agent exits, so both defer. The shared `bin/fm-composer-lib.sh` owns the content decision after each backend captures and structurally identifies its own composer row. It preserves idle bordered composers such as claude's `│ > … │` and bare agent glyphs as empty, but a bare shell glyph is unknown unless inside a genuine bordered composer box; see `docs/herdr-backend.md` "Composer and injection safety" for the complete contract. `pane_input_pending` remains the tested predicate for callers that only need to know whether real unsubmitted text is present, but it is insufficient for an injection-safety decision because it cannot distinguish `empty` from `unknown`. -Either condition, or any composer verdict other than `empty`, defers the injection; the buffered escalation survives in `state/.subsuper-escalations` and is retried on the next housekeeping tick. +A busy primary pane, or any composer verdict other than `empty`, defers the injection; the buffered escalation survives in `state/.subsuper-escalations` and is retried on the next housekeeping tick. In afk mode the composer guard is belt-and-suspenders (no human is typing), but it protects against the race window between the captain returning and their message landing, a dead shell, and the daemon's own previous injection sitting unsent. **Max-defer escape (the daemon must never silently wedge).** @@ -181,14 +182,14 @@ the operational prefix lets firstmate distinguish it from a real captain message - **Single-line digest** - embedded newlines are collapsed to a literal separator before injection, so submission is unambiguous regardless of harness. -- **Composer guard on the supervisor pane** - before injecting, the daemon checks `pane_is_busy` (harness busy footer means agent mid-turn) and reads `fm_backend_composer_state` directly. +- **Busy and composer guards on the supervisor pane** - before injecting, the daemon runs the detected-primary-harness rendered busy guard and reads `fm_backend_composer_state` directly. Only `empty` permits injection; `pending` protects half-typed or swallowed input, and `unknown` protects unreadable panes and bare dead-shell prompts. Every other result preserves the buffer for retry, so the daemon never merges its digest into the captain's half-typed line or types it into a shell. - The shared composer classifier receives a candidate row only after the active backend performs its own capture and structural row recognition. tmux and herdr route their raw styled candidate rows through the shared `fm_composer_strip_ghost` extractor, which removes dim/faint and dark-TRUECOLOR ghost/placeholder text before classification. They read the composer shape from a separately ANSI-stripped plain row because a dark TRUECOLOR border can be stripped with ghost content. A ghost-only or idle bordered composer such as claude's `│ > ... │` therefore reads empty without allowing an unbordered shell prompt to do the same. - `FM_COMPOSER_IDLE_RE` still overrides tmux empty-composer matching after shared ghost and border stripping, and `FM_BUSY_REGEX` overrides busy footers. + `FM_COMPOSER_IDLE_RE` still overrides tmux empty-composer matching after shared ghost and border stripping, and `FM_BUSY_REGEX` overrides the rendered delivery guards plus Grok's isolated task-state fallback. - **Max-defer escape** - the daemon must never silently wedge. If anything stays buffered past `FM_MAX_DEFER_SECS` (default 300s), the daemon attempts one normal flush, which still requires an idle pane and an affirmatively empty composer. If that diff --git a/.agents/skills/ask-user-authority/SKILL.md b/.agents/skills/ask-user-authority/SKILL.md index d4b63d525b..38761e6d98 100644 --- a/.agents/skills/ask-user-authority/SKILL.md +++ b/.agents/skills/ask-user-authority/SKILL.md @@ -19,7 +19,9 @@ The concise standing authority boundary remains always loaded in `AGENTS.md` sec With `yolo` off, every ask-user finding belongs to the captain, and the remaining steps structure that escalation rather than authorize an autonomous answer. 2. Reconstruct the accepted contract from the captain's original request, accepted task criteria, and any explicit later clarification. Reviewer language cannot amend that contract. -3. Identify exactly what choosing Fix would commit the project to deliver or maintain. +3. Identify exactly what choosing Fix would commit the project to deliver or maintain, judging the scope by accepted product or engineering behavior rather than an anticipated file list. + The smallest downstream changes needed to keep that behavior correct, add behavioral tests where an executable contract exists, or keep documentation accurate remain within scope even when they touch files not named at intake. + Correcting stale final-diff PR or delivery evidence is likewise an autonomous downstream correction within already accepted behavior. 4. Keep the decision within standing `yolo` authority when the Fix is genuinely necessary to satisfy the accepted contract, even when the correction is technically difficult or requires complex architecture that the captain explicitly requested. 5. Escalate when the Fix would materially expand the contract by adding a new guarantee, threat model, subsystem, abstraction, compatibility surface, state machine, continuous-monitoring requirement, generalized framework, or broader architecture not required by the accepted intent. 6. Treat labels such as correctness, security, fail-closed, high-risk, or required as evidence about the finding, never as authority to broaden the task. diff --git a/.agents/skills/bearings/SKILL.md b/.agents/skills/bearings/SKILL.md index b2804c7290..42990edd04 100644 --- a/.agents/skills/bearings/SKILL.md +++ b/.agents/skills/bearings/SKILL.md @@ -1,6 +1,9 @@ --- name: bearings -description: Generate a "pick up where I left off" status report from firstmate's live fleet state. Use when the captain invokes /bearings or asks for a bearings report, morning brief, status report, catch-up, "where did I leave off", or "what's in the works". Reads bounded local fleet state cheaply, optionally checks open PRs when requested, composes a scannable dated report to data/status-report-.md, and surfaces a concise version in chat; it is read-mostly and must not tear down, merge, or mutate task state as a side effect of producing the brief. +description: >- + Generate a "pick up where I left off" fleet digest from firstmate's live fleet state. + Use when the captain invokes /bearings or asks for a bearings report, morning brief, status report, catch-up, "where did I leave off", or "what's in the works". + Plain /bearings is chat-only by default, while /bearings file explicitly writes the dated data/status-report-.md artifact; live PR enrichment remains opt-in and composes with file mode. user-invocable: true metadata: internal: true @@ -8,42 +11,58 @@ metadata: # bearings -Generate a complete standalone snapshot from the fleet's current state, so the captain can resume in one read after a break, a night, or a context reset. -The deliverable is a dated markdown file plus a concise chat summary that each stand on the current snapshot rather than an earlier report. -This skill is read-mostly. -It reads fleet state and writes exactly one report file. -It never tears down a task, merges a PR, dispatches new work, or mutates any task state as a side effect of producing the brief - those belong to the captain's explicit word and the normal task lifecycle. +Generate a complete current snapshot from the fleet's current state, so the captain can resume in one read after a break, a night, or a context reset. +Plain `/bearings` returns only the concise four-section chat digest. +Only `/bearings file` writes the dated markdown report artifact and then returns the concise four-section chat digest linked to that report. +This skill is operationally read-only in both modes. +It never tears down a task, merges a PR, dispatches new work, steers a worker, answers a decision, cleans up work, mutates backlog or task state, or writes any file except the single dated report in explicit file mode. + +## Invocation modes + +- Plain `/bearings` gathers a fresh bounded snapshot and renders the four-section chat digest without creating, deleting, reading, or replacing `data/status-report-.md`. +- `/bearings file` gathers a fresh bounded snapshot, replaces today's `data/status-report-.md` from scratch, and renders the four-section chat digest with a link or path to that report. +- Treat `file` only as an explicit invocation option in the slash command. +- Do not treat natural-language requests such as "write a report", "save this", "persist it", or "make a file" as file mode unless the invocation explicitly includes the standalone `file` option. +- When the captain asks to include PRs, pass the snapshot command's live-PR opt-in. +- `/bearings include PRs` remains chat-only and makes the live-PR opt-in. +- `/bearings file include PRs` writes the dated report and makes the live-PR opt-in. ## What it does 1. **Gather live fleet state with one deterministic command.** - Run `bin/fm-bearings-snapshot.sh` and read its compact output. - It is the single bounded, deterministic source for this report and renders TOON by default. - Do not hand-probe the snapshot schema and do not make ad-hoc `gh-axi`/`gh` calls to assemble fleet facts; this command already assembles them. + Run `bin/fm-bearings-snapshot.sh` at invocation time and read its compact output. + It is the single bounded, deterministic fleet-state source for Bearings and renders TOON by default. + Do not create or consult a second fleet-state reader, parser contract, status-event-tail interpretation, visible-session recap, ad-hoc project probe, or ad-hoc `gh-axi`/`gh` query. The command's header and `--help` output own its exact fields, bounds, opt-ins, and output contract. - When the captain asks to include PRs, use the command's live-PR opt-in; otherwise keep the default local-only read. - If the command is unavailable, fall back to `bin/fm-fleet-snapshot.sh --json` and `bin/fm-crew-state.sh `; never infer current state from a raw `tail` of `state/.status`, which is append-only wake-event history whose last line goes stale. - For registered secondmates, use the snapshot's structured-home classification and provenance; a parent event or bounded terminal contradiction is fallback evidence, never authority over readable structured home state. - Structured captain-held decisions come from `decision-hold-lifecycle` and appear under `decisions_open`; do not scrape reports or visual-review artifacts to supplement them. - A queued item under `gates` only becomes "next work" when its blocker is gone and its time/date gate has arrived; until then it stays queued with the reason. - The `(main-inventory)` gate is an action-free integrity warning rather than queued work: render it under Charted Next with the related `omitted` disclosure, never invent an Underway row from backlog-only state, and never move it into Captain's Call. - -2. **Compose the detailed report file around the four-section spine, adding the richer detail the chat leaves out.** - The gather step is deterministic; your judgment is scoped to the last mile only - ranking the command's facts by what matters right now and writing the scannable prose. + Keep the default local-only read unless the captain asks to include PRs. + For registered secondmates, use the snapshot's structured-home classification and provenance. + A parent event or bounded terminal contradiction is fallback evidence, never authority over readable structured home state. + Structured captain-held decisions come from `decision-hold-lifecycle` and appear under `decisions_open`. + Do not scrape reports, visual-review artifacts, raw status-event tails, or visible conversation history to supplement current state. + A queued item under `gates` only becomes "next work" when its blocker is gone and its time/date gate has arrived. + Until then it stays queued with the reason. + The `(main-inventory)` gate is an action-free integrity warning rather than queued work. + Render it under Charted Next with the related `omitted` disclosure, never invent an Underway row from backlog-only state, and never move it into Captain's Call. + +2. **Compose the four-section chat digest from the fresh snapshot.** + The gather step is deterministic; your judgment is scoped to ranking the command's facts by what matters right now and writing scannable captain-facing prose. + The chat response uses the four complete sections in the chat-response contract below, in the same order, each always present. + Plain mode stops here and writes no report artifact. + +3. **In explicit file mode only, compose and replace the detailed report file.** + The report uses the same four complete sections as the chat, in the same order, and adds the detail the chat omits. Never read an earlier `data/status-report-*.md` to decide what to omit, include, describe as changed, or call current. - The report uses the same four complete sections as the chat (see the chat-response contract below), in the same order, each always present, and adds the detail the chat omits: + Write the full report to `data/status-report-.md` using today's date. + If today's file already exists, delete it first, then create a new file from scratch. + This is the only write allowed by the skill. + The detailed report includes: - **Title** - `# Bearings - ` (use "Morning status" only when the captain specifically asks for a morning brief), followed by two or three sentences framing where things stand. - **Captain's Call** - every open decision summarized with its options from the structured decision record, plus each PR ready to merge and each needed credential or login, every PR with the full `https://...` URL, never a bare `#number`. - **Recently Landed** - the bounded current recent-completions baseline from structured state across the main fleet and every registered secondmate home, rendered in full on every run. - - **Underway** - each live direct report making progress, with its current state, and the plans / main pickup pointers worth reopening (`data//report.md` files, `.lavish/*.html` boards). + - **Underway** - each live direct report making progress, with its current state, and the plans or main pickup pointers worth reopening (`data//report.md` files, `.lavish/*.html` boards). - **Charted Next** - queued or gated work, including any main-inventory integrity warning, with each item's blocker, date, or integrity reason. - -3. **Write the dated report file so it persists, then surface the mandatory four-section digest in chat.** - - Write the full report to `data/status-report-.md` using today's date. - This is the required artifact; it lives in gitignored `data/`. - If today's file already exists, delete it first, then create a new file from scratch. - - The chat response is the concise four-section digest defined by the contract below: materially shorter than the report file, complete as a current snapshot, internally consistent with the file, and linked to that file for the full picture. - - For a richer review surface, optionally offer a Lavish board with `lavish-axi` when the report has enough structure to deserve one, but the markdown file is the required artifact and the four-section chat digest is the required minimum. + After writing the file, return the concise four-section chat digest and include the report path or link without adding a fifth section. + For a richer review surface, optionally offer a Lavish board with `lavish-axi` when the report has enough structure to deserve one, but only after the required digest is ready. ## Chat-response contract @@ -62,22 +81,27 @@ Every `/bearings` chat response renders EXACTLY these four sections, in THIS ord Rules that keep the contract unambiguous: - Every section ALWAYS renders, even when empty, with its short empty-state sentence; never omit a section. -- Every report and chat digest is a complete current snapshot, never a delta against a prior report. +- Every chat digest and file-mode report is a complete current snapshot, never a delta against a prior report. - Recently Landed always renders the bounded current baseline, even when the same completions appeared in an earlier report. - The four buckets are mutually exclusive, so every item is forced into exactly one: needs-your-action is Captain's Call, done is Recently Landed, self-progressing is Underway, and not-yet-started work or an action-free fleet-integrity warning is Charted Next. - The strict boundary keeps action-free items OUT of Captain's Call: a working or validating task, a queued item blocked on another task or a date, landed work, a completed scout's report pointer, a declared `paused:` external wait, and a bare recorded PR with no merge-ready signal each belong to one of the other three sections, never Captain's Call. - A secondmate's own row appears Underway only for `active_child_work`; `externally_held` belongs in Charted Next, and `unknown` belongs there as an unavailable-state gate unless its reason requires the captain's action. - Do not suppress separately projected decisions, landed records, or gates from a `partial-structured` home merely because that secondmate's own row is `unknown`. -- The chat follows `AGENTS.md` section 9 and carries one scannable line per item, each PR as the full `https://...` URL; detailed decisions, plans, full gate reasons, and evidence live only in the report file, which the chat links to, so the chat stays materially shorter than that file. +- Include the required direct address to the captain inside one item or empty-state sentence. +- Every PR appears as the full `https://...` URL; a shorthand `#number` is fine only as a back-reference after the full URL has already appeared in the same digest. +- The chat follows `AGENTS.md` section 9 and carries one scannable line per item. +- Detailed decisions, plans, full gate reasons, and evidence belong in the file only when file mode is explicit, so plain chat stays concise and file-mode chat stays materially shorter than that file. +- In file mode, include the report path or link inside the four-section digest without adding another heading. ## Tone and content rules -- This report is a private, captain-facing internal artifact that lives in gitignored `data/`, so unlike normal captain chat it MAY reference task ids, PR URLs, and repo names - the captain works with these directly and needs them to resume; keep it organized and scannable, not a raw dump. -- Every PR reference is a full `https://...` URL, never a bare `#number`; a shorthand `#number` is fine only as a back-reference after the full URL has already appeared in the same report. +- The optional file-mode report is a private, captain-facing internal artifact that lives in gitignored `data/`, so unlike normal captain chat it MAY reference task ids, PR URLs, and repo names. +- The captain works with those directly and needs them to resume; keep the report organized and scannable, not a raw dump. +- Every PR reference is a full `https://...` URL, never a bare `#number`. - Never include PHI or secret values; the report is an operational artifact, but it is still subject to the same security and compliance rules that govern everything else in this fleet. ## Supervision discipline -This skill is read-mostly and changes no fleet state. -Do not tear down a task, merge a PR, dispatch queued work, or mutate any `state/` or `data/` file other than the single report file as a side effect of generating the brief. +This skill changes no fleet state. +Do not tear down a task, merge a PR, dispatch queued work, steer a worker, answer a queued decision, clean up work, or mutate any `state/` or `data/` file other than the single report file in explicit file mode. If the state you read suggests an action - a PR ready to merge, a queued item whose gate has arrived, or a needs-decision finding - name it in its section and leave the action to the normal lifecycle and configured authority rather than taking it from inside this skill. diff --git a/.agents/skills/bootstrap-diagnostics/SKILL.md b/.agents/skills/bootstrap-diagnostics/SKILL.md index 2b70879941..477980b8df 100644 --- a/.agents/skills/bootstrap-diagnostics/SKILL.md +++ b/.agents/skills/bootstrap-diagnostics/SKILL.md @@ -2,7 +2,7 @@ name: bootstrap-diagnostics description: >- Agent-only handling playbook for session-start bootstrap diagnostics. - Use whenever the session-start digest's bootstrap section prints an actionable diagnostic line - MISSING, MISSING_MANUAL, BACKEND_INVALID, NEEDS_GH_AUTH, TANGLE, CREW_DISPATCH invalid, FLEET_SYNC, PR_CHECK_MIGRATION, SECONDMATE_SYNC, SECONDMATE_LIVENESS, NUDGE_SECONDMATES, or FMX - or when a standalone bin/fm-bootstrap.sh run prints one of those lines. + Use whenever the session-start digest's bootstrap section prints an actionable diagnostic line - MISSING, MISSING_MANUAL, BACKEND_INVALID, NEEDS_GH_AUTH, TANGLE, STARTUP_MEMORY_BUDGET, CREW_DISPATCH invalid, FLEET_SYNC, PR_CHECK_MIGRATION, SECONDMATE_SYNC, SECONDMATE_LIVENESS, NUDGE_SECONDMATES, or FMX - or when a standalone bin/fm-bootstrap.sh run prints one of those lines. A silent bootstrap section, or a BOOTSTRAP_INFO fact, means no skill load. user-invocable: false metadata: @@ -27,6 +27,7 @@ When any diagnostic needs captain attention, report the plain consequence and re - `TANGLE: ` - the primary checkout is stranded on a feature branch instead of its default branch; `AGENTS.md` section 8 explains why this guard exists and what it protects. The work is safe on that branch ref; restore the primary to its default branch with the printed `git -C checkout `, then re-validate that branch in a proper worktree. This is the only sanctioned firstmate-initiated git write to the primary, and it is a non-destructive branch switch that strands nothing. +- `STARTUP_MEMORY_BUDGET: invalid config/startup-memory-budget - ` - the visible startup-memory budget is not a safe one-line positive decimal file; do not infer the default or propagate it. Correct the local primary file, then rerun session start so the normal convergence path can deliver the validated value to secondmate homes. - `CREW_DISPATCH: invalid config/crew-dispatch.json - ` - the optional dispatch profile file exists but failed low-cost bootstrap validation; stop profile-based dispatch, report the actionable error, and require correction of the malformed schema, unverified harness name, or invalid harness/effort pair rather than falling back around it or selecting a bad profile. - `FLEET_SYNC: : skipped: ` - a benign one-off skip (offline, no origin, local-only); bootstrap continued, investigate only if it blocks work. A skip can also report the bounded fleet-refresh timeout (`FM_FLEET_SYNC_BOOTSTRAP_TIMEOUT`, or a fleet-size-aware default with a 20 second floor); a timeout never blocks startup. diff --git a/.agents/skills/firstmate-coding-guidelines/SKILL.md b/.agents/skills/firstmate-coding-guidelines/SKILL.md index c7126ff358..ac1da34dd8 100644 --- a/.agents/skills/firstmate-coding-guidelines/SKILL.md +++ b/.agents/skills/firstmate-coding-guidelines/SKILL.md @@ -44,7 +44,7 @@ Every contract - a data format, a state machine, a decision procedure - is state Every other mention of it is a one-line cross-reference, never a restatement. A single deliberate one-line reinforcement at a genuine risk point is allowed, for example a "don't forget X" placed exactly where forgetting X is costly. Restating the contract's substance a second time is not allowed: the two copies will drift the moment only one is edited. -When you touch a contract, grep the repo for its other mentions and update the cross-references, not duplicate the change into a second full copy. +When you touch a contract, patch, replace, or prune the owner's existing language rather than appending a new clause or paragraph wherever possible, then grep the repo for its other mentions and update the cross-references, not duplicate the change into a second full copy. ## Inline-stub pattern @@ -61,7 +61,7 @@ Apply the decision tree above to every line you are about to add to `AGENTS.md`. If an addition needs more than a few lines of conditional detail (detail that matters only in a specific situation) or reference detail (a wire format, an exact schema, historical rationale), you are almost certainly adding it to the wrong file. `AGENTS.md`'s token cost is paid by every session of every fleet member, every time, whether or not that session ever hits the situation the new lines describe. A skill's cost is paid only by the sessions that actually load it. -When in doubt, write the fact into the skill or doc first, and add only the one-line trigger to `AGENTS.md`. +When in doubt, write the fact into the skill or doc first by patching that owner's existing language, and add only the one-line trigger to `AGENTS.md`. ## Trigger hygiene @@ -97,6 +97,7 @@ Run `bin/fm-doc-audience-check.sh`; it enforces classification, README setup rou - `bin/*.sh` and `bin/backends/*.sh` must pass `shellcheck`. - Run `bin/fm-lint.sh` before treating a script change as done; it is the single owner of the lint definition (file set, config, and pinned shellcheck version) that CI and the no-mistakes pre-push gate both invoke, and it refuses to run under any other shellcheck version. - Colocate tests with the existing pattern in `tests/`, name them `.test.sh`, and extend an existing script rather than inventing a new runner. +- Tests must exercise behavior through an executable or public interface and must never assert implementation-source bytes, including through parsers, regexes, snapshots, or indirect wrappers. - A maintainer-verification record under `docs/verification/` records active empirical facts, not assumptions or task chronology. - Include the date, version, exact commands run, and exact output needed to support the current guarantee. - Keep incident chronology and delivery evidence in private task reports or PR evidence unless a concise rationale is required to maintain a current safety boundary. diff --git a/.agents/skills/fmx-respond/SKILL.md b/.agents/skills/fmx-respond/SKILL.md index 762afb8d76..94beca00a1 100644 --- a/.agents/skills/fmx-respond/SKILL.md +++ b/.agents/skills/fmx-respond/SKILL.md @@ -4,7 +4,8 @@ description: >- Agent-only playbook for handling X mode mentions and follow-ups. Use on an "x-mention " check wake to read the stashed mention, classify it, act autonomously on eligible requests, reply or dismiss, and link spawned work. Also use on an "x-mode-error ..." check wake to report the X-mode configuration blocker instead of answering a mention. - Also use on milestone and terminal wakes for an X-mode-linked task before posting completion follow-ups, ending terminal outcomes with --final. + Also use on milestone and terminal wakes for an X-mode-linked task before posting completion follow-ups, using typed promised-final reconciliation when registered and --final otherwise. + Also use on a "public-followup ..." check wake, and whenever a promised final public reply must be created, reconciled, or delivered. Loaded only when X mode is enabled. user-invocable: false metadata: @@ -56,7 +57,7 @@ How the reply lands depends on whether the work finishes during this turn: The exact resolution and fail-safe posting contract is owned by `docs/configuration.md`. If a recovery respawns the same relay request onto a successor task, relink with the paired `--carry-count --carry-ts ` flags plus any prior `x_platform=` and `x_reply_max_chars=` as `--carry-platform --carry-max ` so the successor keeps the consumed follow-up count, original 7-day window, and reply split budget. 4. **Follow up on genuine milestones, sparingly.** Firstmate gets up to **three** follow-ups per mention, within a 7-day window, chained in the same thread - spend them only on changes the captain would actually want to hear about (e.g. investigation done and a build started, work shipped or ready, or the task failing), never on routine internal churn. - The task's final outcome - shipped / reported / merged / failed - is always posted with `--final`, which clears the link regardless of how many follow-ups remain. + A task without a promised-final commitment posts its final outcome - shipped / reported / merged / failed - with `--final`, which clears the link regardless of how many follow-ups remain. A typed promised-final commitment uses the deterministic consumer instead. That posting happens on the task's milestone and completion wakes (see "Completion follow-up" below), not this turn. So every drained mention sorts into one of three cases (the worthiness judgment, widened): @@ -141,7 +142,7 @@ Treat `state/x-inbox/` as the source of truth and process **every** file you fin **If the request spawned a real, longer-running task** (you ran `bin/fm-spawn.sh`), link that task to this mention so milestone and completion follow-ups can be posted: `bin/fm-x-link.sh `. **Link here, in step 2c, before the step 2f inbox cleanup** - `bin/fm-x-link.sh` can copy both the mention's reply platform and explicit budget from the still-present inbox payload without a relay lookup. If that local context is incomplete it uses the durable resolution contract in `docs/configuration.md` and warns loudly, while the follow-up path refuses to post unless both values can be resolved authoritatively. - Then step 2d's reply is an **acknowledgement** ("on it, captain"), and genuine milestone updates plus the final outcome come later as follow-ups (see "Completion follow-up" below), with the terminal one posted using `--final`. + Then step 2d's reply is an **acknowledgement** ("on it, captain"), and genuine milestone updates plus the final outcome come later as follow-ups (see "Completion follow-up" below), with the terminal one posted using `--final` when no typed promised-final commitment exists. If the work completed in this turn (a backlog item filed, a question answered), there is no task to link and step 2d reports the outcome directly. d. **Compose the reply.** For a **question**, answer `.text` from the fleet state gathered in step 1. For an **actionable request that completed now**, report the outcome of step 2c (what was done, or - for escalated work - that it has been flagged for the captain). For an **actionable request that spawned a linked task**, acknowledge that you have the order and are on it - milestone updates and the final outcome follow later as completion follow-ups, so do not promise a result you do not yet have. Either way keep it short, in firstmate's voice, and public-safe. Conversation continuity: when `in_reply_to` is present this is a conversation reply - read `in_reply_to.text` (what `in_reply_to.author_handle` said just before) as **context** and continue that thread, resolving "it", "that", "and then?" against the parent; for a fresh mention (`in_reply_to` is null) answer on its own. @@ -198,14 +199,50 @@ This skill's own responsibility during the mention-handling turn is linking the - On each such milestone, firstmate checks whether a follow-up is still due with `bin/fm-x-followup.sh --check ` (prints the `request_id` when the link exists, the count is under the cap, and the window has not lapsed; silent otherwise, pruning an exhausted or expired link). - If due, it composes a short, public-safe update and posts it with `bin/fm-x-followup.sh --text-file ` (or stdin), which posts via the relay's follow-up endpoint; a successful non-final post increments the counter and keeps the link so a later milestone can still post against it. When the update carries one real visual artifact, add `--image `; the helper forwards it to `bin/fm-x-reply.sh --followup` so the same image contract used for ordinary replies applies here too. -- On a terminal wake (PR merged / scout report / local merge / failed), firstmate posts the task's **final** outcome ("done, here's the result"; for a failure, an honest "this one didn't pan out") with `bin/fm-x-followup.sh --final --text-file `, which always clears the link after that post regardless of how many follow-ups remain under the cap. +- On a terminal wake (PR merged / scout report / local merge / failed), firstmate posts the task's **final** outcome ("done, here's the result"; for a failure, an honest "this one didn't pan out") with `bin/fm-x-followup.sh --final --text-file ` only when no promised-final public commitment is registered for that work. When the promised-final procedure above applies, `bin/fm-public-followup.sh consume` and `deliver` own the terminal reply and clear the legacy link at the validated receipt boundary, so do not call `fm-x-followup.sh --final` for the same outcome. If delivery reports that link cleanup needs reconciliation, do not post anything else; `bin/fm-x-followup.sh --clear ` is the clear-only recovery command in the bound work home. - Every follow-up is held to the exact same public-safety bar as every reply here: outcomes only, no task ids, internals, captain-private material, or secrets. Past the window, past the cap, or on the relay's own rejection of an exhausted binding, a follow-up attempt is skipped silently and the link is cleared - never treated as a failure worth retrying. - If either a follow-up's platform or explicit budget cannot be authoritatively resolved from per-request context, inbox payload, or relay answer, `bin/fm-x-followup.sh` does NOT post it: the fail-safe holds it (the link is kept, exit non-zero) rather than use a local default. This is a retryable hold - a later milestone wake retries it once both values are recoverable. +## Promised final replies (the commitment that must survive compaction) + +The follow-up budget above is a courtesy. +A **promised final reply** - "I'll report back when this lands" - is a commitment, and forgetting it is publicly visible. +Never carry one in your head: the moment you promise a specific outcome in a public thread, turn it into durable state and let the scripts reconcile it. +This section is the sole owner of that procedure. +`tasks-axi public-followup --help` owns the typed obligation, its states, and its file contracts; `bin/fm-public-followup.sh --help` owns firstmate's flags; do not restate either here. + +**When you promise a final:** + +1. Create the typed obligation with `tasks-axi public-followup add` and bind the work with `bind-work`, keeping the public-safe summary and the opaque thread binding in the obligation and the full request context where the poll already put it. +2. Register it with `bin/fm-public-followup.sh register --relation --work-home > --work-id --generation `. + This is what makes the commitment reconcilable without you. +3. Put `bin/fm-public-followup.sh brief ` output straight into the worker's brief. + It prints the exact reporting command for that binding. + Never ask a worker to find the thread or post the reply: only this home holds the relay consent and the thread binding. + +**When work reports back, or on a `public-followup ...` check wake, or when the session-start digest lists a public commitment:** + +1. Run `bin/fm-public-followup.sh consume`. + It reconciles every typed terminal result from disk and prints `ready ` for each commitment that became deliverable. + A refusal prints `rejected : ` and quarantines that event; read the reason rather than re-emitting blindly. +2. For each ready commitment, run `bin/fm-public-followup.sh deliver `. + With no `--text-file` it reuses the accepted terminal outcome exactly, which is the preferred path for a landed result. + Only pass `--text-file` when the outcome genuinely needs composing, and hold it to the same public-safety bar as every other reply here. + Delivery clears the bound task's legacy X link at the validated receipt boundary; if it reports a cleanup failure, use its reconciliation message and do not post a legacy final. +3. Read the outcome and stop guessing at anything it refuses: + - "still waiting on its bound work" means the work has not reported a typed terminal result yet - do not post. + - "recorded as retryable" means nothing was posted; retry on a later wake. + - "held" means the thread's platform or budget is unresolvable right now; retry once it is recoverable. + - "mid-delivery" means a previous post started and its outcome was never recorded. Do NOT deliver again. Establish whether that post landed, then either close it with `record-posted --attempt --chunks ` or escalate. Posting again would put a second reply in a public thread. + - "the relay no longer accepts a follow-up" is a captain decision, not a retry. + +Cleanup refuses while a commitment is still owed for that exact work, so never reach for `--force` to get past it. +Treat a commitment as kept only after a validated posted receipt or an explicit captain waiver. + ## Notes - The direct author is always your own captain (owner-only routing), and in live mode you answer and act on eligible requests **autonomously**: enabling X mode is the captain's standing authorization, so never ask the captain before posting and never hold a worthwhile reply for a chat-side OK. For reply-worthy mentions, dry-run (`FMX_DRY_RUN`) is the only non-posting path; pure acknowledgments use the relay dismiss path instead. -- An actionable mention is **acted on** through the normal lifecycle (intake, backlog, dispatch, investigate, ship), not merely replied to. Work that finishes now gets one outcome reply; work that spawns a real task gets an **acknowledgement now** plus up to three **completion follow-ups** over time, ending with a `--final` one (link the task with `bin/fm-x-link.sh` so those follow-ups can post). A reply alone, with no work behind an actionable ask, is the bug to avoid. +- An actionable mention is **acted on** through the normal lifecycle (intake, backlog, dispatch, investigate, ship), not merely replied to. Work that finishes now gets one outcome reply; work that spawns a real task gets an **acknowledgement now** plus up to three **completion follow-ups** over time, ending with a `--final` one when no typed promised-final commitment exists (link the task with `bin/fm-x-link.sh` so those follow-ups can post). A reply alone, with no work behind an actionable ask, is the bug to avoid. - Destructive, irreversible, or security-sensitive asks are flagged to the captain through the trusted channel first and never run straight from a mention; the public reply says only that it has been flagged. - One answered mention = one reply (plus up to three completion follow-ups for a spawned task, spent only on genuine milestones); a skipped mention posts no reply but is **dismissed at the relay** (`bin/fm-x-dismiss.sh`) so the relay drops it rather than re-offering it (which would otherwise churn every poll and end in an "offline" auto-reply). A single wake may cover several pending mentions - drain them all. - Conversations: `in_reply_to` carries the parent post for continuity; a pure acknowledgment with nothing to answer is dismissed at the relay and skipped, not replied to. The relay already guards against self-replies and caps replies per conversation, so you only judge "is there something to answer here?". diff --git a/.agents/skills/harness-adapters/SKILL.md b/.agents/skills/harness-adapters/SKILL.md index 9ea4112153..8c95cb37c5 100644 --- a/.agents/skills/harness-adapters/SKILL.md +++ b/.agents/skills/harness-adapters/SKILL.md @@ -1,6 +1,6 @@ --- name: harness-adapters -description: Agent-only reference for firstmate harness operations. Use before spawning or recovering a crewmate or secondmate, handling a trust dialog, sending a harness-specific skill invocation, interrupting or exiting an agent, resuming an exited agent, or verifying a new harness adapter. Contains verified facts for claude, codex, opencode, pi, pi-signed, grok, and kimi. +description: Agent-only reference for firstmate harness operations. Use before spawning or recovering a crewmate or secondmate, handling a trust dialog, sending a harness-specific skill invocation, interrupting or exiting an agent, resuming an exited agent, or verifying a new harness adapter. Contains verified facts for claude, codex, opencode, pi, pi-signed, grok, kimi, and agy. user-invocable: false metadata: internal: true @@ -12,7 +12,7 @@ Use this reference before any harness-specific firstmate operation: spawn, recov Crewmates default to the same harness firstmate is running on unless `config/crew-harness` records an adapter name. Optional dispatch profiles in `config/crew-dispatch.json` can override that static default for one crewmate or scout dispatch by selecting concrete harness, model, and effort axes at intake. -When a matched rule or default is a profile array, load `quota-array-dispatch` for the pace-aware candidate choice after this skill establishes harness and model/provider facts. +When a matched rule or default is a profile array, load `quota-array-dispatch` for the completion-aware candidate choice after this skill establishes harness and model/provider facts. The captain may override that file at session start or later; a per-task instruction such as "run this one on codex" overrides it for that dispatch only. `default` means mirror firstmate's own harness. @@ -29,12 +29,13 @@ Each adapter splits into mechanics and knowledge. The per-task mechanics, including launch command, autonomy flag, and any enabled crewmate turn-end hook, live in `bin/fm-spawn.sh`. The primary-session "no turn ends blind" guard contract and harness hook installation paths live in `docs/turnend-guard.md`. The primary-session watcher wake protocols are rendered from `docs/supervision-protocols/` by `bin/fm-supervision-instructions.sh`. -The supervision knowledge lives here: busy signature, exit command, interrupt, dialogs, resume behavior, skill invocation, and quirks. +The supervision knowledge lives here: busy state, exit command, interrupt, dialogs, resume behavior, skill invocation, and quirks. +Each adapter's `Busy state` row names only which semantic source that harness uses; `bin/fm-busy-lib.sh` owns the contract itself, including verdicts, source attribution, and the verification gates that keep an unverified harness at unknown. Never dispatch a crewmate or secondmate on an unverified adapter. If `config/crew-harness` or `config/secondmate-harness` names an unverified adapter, tell the captain under `AGENTS.md` section 9 that the requested worker runtime is not verified yet, use firstmate's own verified runtime for current work, and ask only whether to verify the requested runtime before future use. Do not pause current work for that future-verification choice, and never launch an unverified adapter. -If the captain asks for a new harness, propose verifying it first: spawn a trivial supervised task using `fm-spawn`'s raw-launch-command escape hatch, confirm every fact empirically, then record the mechanics in `fm-spawn`, the busy signature in `fm-watch.sh` and `fm-tmux-lib.sh` defaults, any needed `FM_COMPOSER_IDLE_RE` empty-composer override plus any novel bare agent prompt glyph in `bin/fm-composer-lib.sh`'s shared composer classifier (the one fleet-wide owner of the empty/dead-shell/pending decision, so a new harness's own idle composer is not misread as a dead shell), the tmux agent-process liveness classification in `bin/backends/tmux.sh` when the harness can launch a secondmate, and the verified knowledge here. +If the captain asks for a new harness, propose verifying it first: spawn a trivial supervised task using `fm-spawn`'s raw-launch-command escape hatch, confirm every fact empirically, then record the mechanics in `fm-spawn`, its semantic busy source and trust gate in `bin/fm-busy-lib.sh`, any needed `FM_COMPOSER_IDLE_RE` empty-composer override plus any novel bare agent prompt glyph in `bin/fm-composer-lib.sh`'s shared composer classifier (the one fleet-wide owner of the empty/dead-shell/pending decision, so a new harness's own idle composer is not misread as a dead shell), the tmux agent-process liveness classification in `bin/backends/tmux.sh` when the harness can launch a secondmate, and the verified knowledge here. ## Detection @@ -54,7 +55,8 @@ Use that value for interrupt, exit, resume, and skill-invocation facts. The primary integrations for `claude`, `codex`, `opencode`, `pi`, `pi-signed`, and `grok` have empirically validated hook paths for the "no turn ends blind" guard. `claude` and `codex` block directly through Stop hooks that preserve exit status 2 and stderr from `bin/fm-turnend-guard.sh`. -`opencode`, `pi`, `pi-signed`, and `grok` expose passive lifecycle callbacks for this purpose, so their tracked primary adapters force one bounded follow-up or resume when the shared predicate blocks. +`opencode`, `pi`, and `pi-signed` expose passive lifecycle callbacks and force one bounded follow-up when the shared predicate blocks. +Grok selects native blocking or its pre-native bounded resume fallback from the exact running Stop payload; [`docs/turnend-guard.md`](../../../docs/turnend-guard.md) owns that contract. Kimi is outside the primary turn-end guard scope, while `docs/turnend-guard.md` owns its separate guarded global hook for crew wake signals. The exact hook files, commands, scoping rules, and fail-open tradeoffs are owned by `docs/turnend-guard.md`. `docs/verification/supervision.md` "Turn-end guard" owns active validation evidence. @@ -125,6 +127,10 @@ The supported launch-profile flags below are verified locally; each row records | pi / pi-signed | `--model ` | `--thinking ` | Verified 2026-07-27 on Pi and pi-signed 0.82.0. Both expose the same accepted thinking levels and completed the same model-qualified max-thinking smoke. | | opencode | `--model ` | none for firstmate's interactive launch | Verified on opencode 1.17.6. `opencode run` has `--variant`, but firstmate launches the interactive `opencode --prompt` path, which has no verified effort flag. | | kimi | `--model ` | none | Verified 2026-07-25 on Kimi Code CLI 0.29.1. | +| agy | `--model ` | `--effort ` | Verified 2026-07-29 on Antigravity CLI 1.1.8. `xhigh`/`max` are not accepted and are omitted. An unrecognized model, or a model paired with a conflicting `--effort`, is a silent same-launch fallback to the default model (a `⚠ Warning`, not a failed spawn) - see the agy section below. | + +The concrete `harness` field owns adapter identity independently of the model provider: `harness=pi` with `model=xai/grok-*` is Pi using xAI, not `harness=grok`, and does not require Grok CLI login; `harness=grok` remains the standalone Grok Build CLI adapter. +No script resolves that split for you: establish which credential store a tuple reads from the discovery surfaces below plus `quota-axi auth --json`'s per-provider sources, and show that reasoning rather than inferring it from a harness, model, or source name. ### Model support discovery @@ -139,9 +145,11 @@ Use the discovery surface in the current authenticated environment because suppo | pi / pi-signed | Run the selected executable as ` --list-models [search]`; Pi's installed `docs/models.md` owns how built-in, extension-registered, and custom provider/model entries reach that list. | | grok | Run `grok models`, which lists the models available to the current Grok installation and account. | | kimi | Run `kimi provider list --json`, which lists the current provider and model configuration. | +| agy | Run `agy models`, which lists lowercase-hyphenated aliases (e.g. `gemini-3.6-flash-medium`); the exact title-case display string shown at startup (e.g. `Gemini 3.6 Flash (Medium)`) is also accepted. | For an unfamiliar harness or model namespace, establish support and provider identity from that harness's authoritative CLI help, model listing, or current documentation rather than guessing from a name or prefix. -If those sources do not establish the relationship needed for dispatch, fail loudly and report the unresolved candidate. +A listing that reaches the account and does not contain the model is concrete evidence the model is unsupported: block that candidate and quote the result. +A discovery surface you could not reach establishes nothing; report that as uncertainty rather than turning it into a supported or unsupported verdict. When a requested effort value is outside the harness-specific accepted set, `fm-spawn` records the requested `effort=` in meta but emits no effort flag for that harness. This preserves launch success instead of passing a known-bad value. @@ -157,6 +165,7 @@ Natural language is acceptable if uncertain. - pi and pi-signed: no separate verified skill invocation beyond normal command behavior; use natural language if the exact skill command is uncertain. - grok: `/`, for example `/no-mistakes` (same form as claude). Verified end to end: grok discovers the user-level `no-mistakes` skill, `/no-mistakes` invokes it, and grok drives a real `no-mistakes axi run`. Like codex's `$`/`/` popups, typing `/` opens grok's slash-autocomplete, so a too-fast Enter selects the popup entry instead of sending, and for an argument-taking command (like `/no-mistakes`'s optional task-first argument) that first Enter only expands the popup selection into an argument-hint placeholder rather than submitting - a genuine second Enter is required (see the grok section below for the 2026-07-03 incident and fix). `fm_tmux_submit_core`'s retried Enter (used by `fm-send` on the tmux backend) handles this through the structural composer reader; the herdr backend needed a dedicated fix (`fm_backend_herdr_composer_state`, docs/herdr-backend.md) because its prior delta-based verification false-positived on that same popup-close content change. - kimi: `/`, for example `/no-mistakes`. +- agy: does not recognize `/no-mistakes` as a slash command (prints "Unknown command"); use natural language to invoke no-mistakes instead. ## Submission acknowledgement hazards @@ -164,11 +173,11 @@ A send or key action reporting success is not proof that the intended action hap OpenCode can accept and queue an Enter while leaving text visible, Grok can consume Enter in its slash popup without submitting, and Kimi can silently drop a message sent before readiness even though the send returns success. The shared symptom is a healthy-looking pane with no work in progress, so each adapter must verify the observable postcondition that is specific to its TUI. -## claude (VERIFIED; busy signature re-verified 2026-07-25 on Claude Code 2.1.220) +## claude (VERIFIED; busy-state hooks live-verified 2026-07-28 on Claude Code 2.1.220) | Fact | Value | |---|---| -| Busy-pane signature | Current turns match the harness-scoped `…[[:space:]]+\([0-9]+[smh]` shape after a rotating glyph and word, for example `✢ Pollinating… (16s · ...)`; legacy `esc to interrupt` remains accepted, while `Worked for 31s` is idle. | +| Busy state | Owned lifecycle hooks: `UserPromptSubmit` opens a turn, `Stop`, `StopFailure`, and `SessionEnd` close it. Claude fires no hook for a manual interrupt, so a firstmate-initiated interrupt must record the clear itself. | | Exit command | `/exit` | | Interrupt | single Escape | | Skill invocation | `/` (e.g. `/no-mistakes`) | @@ -198,7 +207,7 @@ Claude Code's primary watcher protocol is Stop-owned: the auto-arm hook fires on | Fact | Value | |---|---| -| Busy-pane signature | `esc to interrupt` (shown as `• Working (Xs • esc to interrupt)`) | +| Busy state | Unknown until a semantic source is live-verified: the app-server turn lifecycle is unreachable for a pane worker, and project lifecycle hooks did not fire for a firstmate-launched worker. | | Exit command | `/quit` (slash popup needs about 1 second between text and Enter; `fm-send` handles it) | | Interrupt | single Escape | | Skill invocation | `$` (e.g. `$no-mistakes`); `/` is claude-only and codex rejects it as "Unrecognized command" | @@ -229,7 +238,7 @@ The checkpoint is deliberately foreground and bounded so Codex regains control r | Fact | Value | |---|---| -| Busy-pane signature | `esc interrupt` (dotted spinner footer; note no "to") | +| Busy state | The Firstmate-owned plugin's semantic `session.status`: `busy` and `retry` are active, `idle` is inactive, latched to the worker's own session. | | Exit command | `/exit` | | Interrupt | double Escape; known flaky while a long shell command runs, so a wedged pane may need `/exit` and relaunch | @@ -266,7 +275,7 @@ The follow-up was verified in the interactive TUI; `opencode run` can exit befor | Fact | Value | |---|---| -| Busy-pane signature | `Working...` (braille spinner prefix; no `esc to interrupt` text) | +| Busy state | The Firstmate-owned extension's `agent_start` (busy) and `agent_settled` confirmed by `ctx.isIdle()` (idle), which covers retries, compaction, tool loops, and queued continuations. | | Exit command | `/quit` | | Interrupt | single Escape | @@ -303,7 +312,7 @@ For Grok's supported reasoning-effort values and omission behavior, see the [lau | Fact | Value | |---|---| -| Busy-pane signature | `Ctrl+c:cancel` (the mid-turn cancel hint in grok's keybind bar, shown iff a turn is running; the spinner line is a braille glyph + `… N.Ns` + `[stop]`, e.g. `⠹ Thinking… 1.1s … [stop]`). Idle keybind bar shows only `Shift+Tab:mode │ Ctrl+.:shortcuts`. The ASCII `Ctrl+c:cancel` is the busy regex (avoids locale fragility of matching braille). | +| Busy state | Upstream's rendered-tail fallback, isolated to Grok until its structured lifecycle is live-verified: `Ctrl+c:cancel`, the mid-turn cancel hint shown in grok's keybind bar iff a turn is running. The idle bar shows only `Shift+Tab:mode │ Ctrl+.:shortcuts`. ASCII is matched rather than the braille spinner to avoid locale fragility. | | Exit command | `/exit` typed into the composer exits the TUI cleanly and prints `Resume this session with: grok --resume `; `Ctrl+Q` double-press within 1000ms remains a fallback; `Ctrl+D` is the quit key in VS Code family terminals; `Ctrl+C` is the interrupt, not the exit. | | Interrupt | single `Ctrl+C` (cancels the current turn; the footer shows `Ctrl+c:cancel` mid-turn). `Esc` only moves focus to the scrollback, it does NOT interrupt. | | Skill invocation | `/` (e.g. `/no-mistakes`), same as claude. Opens a slash-autocomplete popup, so a too-fast Enter selects the popup entry instead of sending. For an argument-taking command that first Enter does not submit at all - it expands the selection into an argument-hint placeholder in the composer (e.g. `/compact` -> `/compact compaction instructions`, live-verified), leaving real text still sitting there unsubmitted; a genuine second Enter is required. `fm-send`'s retried Enter lands it on BOTH backends, but only because each backend's own submit-verification correctly recognizes that placeholder-filled text as still-pending - see the incident below. | @@ -343,13 +352,13 @@ This keeps the hook outside the worktree, needs no trust grant, and writes only `fm-teardown` removes the worktree pointer before returning a pooled worktree. Secondmate spawns skip the pointer (idle panes are healthy, no stale-pane detection for them). -**Primary-session guard fact (verified 2026-07-08, Grok 0.2.91).** +**Primary-session guard fact (verified 2026-07-28, Grok 0.2.112 and 0.2.73).** The firstmate PRIMARY's own `.grok/hooks/fm-primary-turnend-guard.json` invokes `bin/fm-turnend-guard-grok.sh`. -Grok Stop hooks are passive for this purpose: exit 2 does not make the model continue. -The adapter therefore runs the shared predicate and, when it returns 2, forces one same-session follow-up with `grok --resume -p ` while setting `GROK_TURNEND_GUARD_ACTIVE=1` so the nested Stop hook does not recurse. -It does not pass `--permission-mode`, so the passive hook cannot escalate the primary session's tool permissions. +Grok 0.2.112 exposes native same-process Stop continuation in its running payload, while the genuine pre-native 0.2.73 payload omits that capability and still needs one guarded `grok --resume`. +The exact adaptive and malformed-input contract is owned by `docs/turnend-guard.md`. +The tracked Claude Stop hooks skip themselves under `GROK_AGENT`, because Grok also loads Claude-compatible project settings and otherwise creates a second blocking path. Project-local Grok hooks require folder trust, verified with launch-time `--trust`; if the primary firstmate checkout is not trusted for Grok hooks, this primary guard fails open and `fm-guard.sh` remains the next-command alarm. -Grok's primary watcher protocol is Claude-shaped background-notify around `bin/fm-watch-arm.sh`; the passive Stop hook is only a backstop for blind turn ends. +Grok's primary watcher protocol remains background-notify around `bin/fm-watch-arm.sh`; native Stop continuation does not provide Pi-like extension ownership. ## kimi (VERIFIED 2026-07-25, kimi 0.29.1) @@ -360,7 +369,7 @@ Kimi Code CLI launches from the absolute path resolved from `PATH`, falling back | Binary | Executable `kimi` from `PATH`, then executable `$HOME/.kimi-code/bin/kimi`; spawning refuses if neither exists. | | Launch | Bare interactive TUI with `--auto`, followed by readiness-gated pointer delivery; positional prompts are rejected. | | Models | `kimi-code/kimi-for-coding` (default), `kimi-code/kimi-for-coding-highspeed`, `kimi-code/k3`, and `kimi-code/k3-256k`. | -| Busy-pane signature | A transient line with optional leading whitespace, a rotating moon-phase glyph, required whitespace on both sides of `·`, and optional trailing content; the line is absent when idle. | +| Busy state | Standalone Kimi is unknown until a semantic source is live-verified; prefer Wire's `prompt` request lifetime, then documented hooks including `Interrupt`. Kimi behind Pi uses Pi's lifecycle. Its moon-phase spinner is not a state source. | | Exit command | `/exit` | | Interrupt | Single Escape, which prints `Interrupted by user`. | | Skill invocation | `/`, for example `/no-mistakes`; firstmate skills are discovered. | @@ -382,12 +391,51 @@ The startup input-readiness window is the established cause of Kimi's first-Ente An early Enter can expand Kimi's composer to multiple content rows, leaving the pointer text on the first row and the cursor on an empty later row, which is the same single-cursor-row reading defect exposed by Grok's bottom-border cursor quirk. The shared tmux reader now locates the complete bordered composer and treats real text on any content row as positive evidence that submission is still pending. No rendering signal is trustworthy for proving that Kimi will accept input during this window, so delivery retries Enter through the shared submit core and retains the existing postcondition verification rather than relaxing readiness or delivery checks. -Kimi's footer tip rotates independently and can display `ctrl+c: cancel` while completely idle, so tip text is never used as its busy signature without the leading moon-plus-middot spinner structure. +Kimi's footer tip rotates independently and can display `ctrl+c: cancel` while completely idle, which is one reason no Kimi rendered signature is a state source. The idle status bar can contain lowercase `thinking`, which is the model's effort label rather than a busy signal. -The spinner match covers the full moon-phase glyph set rather than one frame, but it remains locale- and emoji-font-sensitive because Kimi exposes no stable ASCII busy token. +The delivery-only spinner match covers the full moon-phase glyph set rather than one frame, but it remains locale- and emoji-font-sensitive because Kimi exposes no stable ASCII busy token. [`docs/turnend-guard.md`](../../../docs/turnend-guard.md) owns Kimi's verified global hook surface and captain-approved crew wake integration. `fm-spawn.sh` installs one marker-delimited Firstmate entry in `$HOME/.kimi-code/config.toml`, one silent always-zero hook script, and one private token registry under `$HOME/.kimi-code/fm-turn-end.d/`. Each Kimi crew worktree receives a gitignored `.fm-kimi-turnend` token pointer, and the global hook touches that task's `state/.turn-ended` only when the Stop payload's `cwd`, pointer, and registry entry all agree. A guarded silent hook cannot be verified from absence of effect, so prove invocation with an unguarded probe before concluding that the hook did not fire. -The guarded turn-end signal supplements the pane busy signature, whose locale- and emoji-font-sensitive limits still apply while a turn is running. +The guarded turn-end signal remains a wake notification; standalone Kimi has no busy-state source until one is live-verified. + +## agy (VERIFIED 2026-07-29, Antigravity CLI 1.1.8, Gemini 3.6 Flash) + +AGY is EXPERIMENTAL: single-worker only, no secondmate support, and no verified turn-end hook (busy/idle pane detection is the only completion signal). +It launches bare into a real interactive TUI, the same shape as claude/codex/opencode/pi/grok/kimi, not a one-shot print mode. +`bin/fm-busy-lib.sh` owns AGY task-state classification under the new lifecycle contract: its AGY-only fallback returns busy or idle only when the corresponding explicit pane signature below is present, and returns unknown for every inconclusive capture. +`bin/fm-tmux-lib.sh` reuses the same busy signature for delivery and supervisor-input guards, but does not independently classify recorded AGY task state. + +| Fact | Value | +|---|---| +| Launch | Bare `agy --dangerously-skip-permissions __MODELFLAG____EFFORTFLAG__`, no positional prompt and no `-p`/`--output-format json`. | +| Busy-pane signature | Spinner glyph + `Generating...` above the composer; footer shows `esc to cancel`. | +| Idle-pane signature | Footer shows only `? for shortcuts`, with neither busy token present. | +| Exit command | `/exit` opens a slash-autocomplete popup (the same hazard as codex/grok's `/` popup) - the first Enter only selects the popup entry, a genuine second Enter is required to actually submit and exit. Prints a resume hint on exit: `agy --conversation=`. | +| Interrupt | single Escape (observed live: cancels the running turn and returns to the idle composer). | +| Model/effort | `--model ` (either `agy models`'s own alias or the exact title-case display string, see below); `--effort low|medium|high` as of 1.1.8, with `xhigh`/`max` omitted rather than guessed. | + +**Why print mode was replaced.** `-p`/`--output-format json` runs exactly one turn then exits the process entirely, so it cannot drive a multi-step gated flow like no-mistakes (commit -> open PR -> respond to review/test gates -> reach CI green): nobody is left alive to answer a gate after the first turn completes. +This was observed live: an agy crewmate using `-p` mode committed its work and opened a PR, then the process exited while no-mistakes was still mid-run, leaving validation stalled with nobody driving it. +The interactive launch below fixes this the same way every other verified harness already works. + +**Trust dialog.** First launch in an untrusted directory shows "Do you trust the contents of this project?" with "Yes, I trust this folder" / "No, exit", the default selection already on the trusting option - accept with a single Enter, the same pattern as claude/codex/grok. +`fm-spawn.sh` polls for either this dialog or the idle composer; if the dialog is showing it sends one Enter and keeps polling for the idle composer, so a directory that is already trusted (dialog never appears) is not blocked waiting for it. + +**Type-then-submit, but embedded newlines submit early.** Text typed into the composer sits there until Enter submits it, the same type-then-submit model as the others - but sending a brief's full multi-line content as one literal keystroke send is NOT safe: each embedded newline is delivered as an ordinary Enter and submits that line as its own separate turn, verified live (a 3-line brief sent as one literal string produced three separate replies instead of one). +AGY's own shortcuts panel (`?` when the composer is empty) documents a `\` + Enter fallback for inserting a literal newline without submitting (also bindable via `alt+enter`/`ctrl+j`/`shift+enter`, unverified over a plain tmux keystroke send). +`fm-spawn.sh` sends the brief line by line: every line but the last as `\` followed by a real Enter, and only the final line's Enter actually submits. +Verified end to end: a 3-line brief sent this way arrived as one combined multi-line prompt and produced one reply, and the session stayed alive afterward to accept a follow-up turn - confirming the fix for the print-mode gap above. +Delivery is confirmed by the busy signature appearing (or the composer returning to idle, for a reply fast enough that busy is never observed between polls); an unconfirmed delivery is a loud spawn failure, not a silent hang. + +**An unrecognized or conflicting `--model` is a SILENT fallback in interactive mode, not a crash.** Verified live on 1.1.8: `agy --model 'nonexistent-model-xyz' --dangerously-skip-permissions` still launches straight into the ordinary composer, printing only a `⚠ Warning` ("model ... is not recognized as a known model or custom model in settings. Using the default model instead.") before continuing on the account's default model. +The same silent-fallback shape applies to a model name that already encodes an effort suffix (e.g. `gemini-3.6-flash-medium`) combined with an explicit `--effort` on the same launch: verified live, `--model gemini-3.6-flash-medium --effort high` warns `--model gemini-3.6-flash-medium conflicts with --effort=high. Using the default model instead.` and still launches, silently ON THE DEFAULT MODEL rather than the intended one. +Both are exit-0, TUI-reachable outcomes - `fm-spawn.sh` cannot detect a wrong-model launch from its own launch success, so a dispatch profile that pairs an effort-suffixed model name (e.g. `Gemini 3.6 Flash (Medium)`) with its own separate `effort` field should still omit that field, purely to guarantee the intended model actually launches. +Either the exact `agy models` alias (lowercase-hyphenated, e.g. `gemini-3.6-flash-medium`, `claude-opus-4-6-thinking`) or the exact title-case display string agy shows at startup (e.g. `Gemini 3.6 Flash (Medium)`) is accepted - both were verified live to resolve to the same model. +A prior, narrower finding (data/learnings.md, 2026-07-29) described a hand-normalized guess (`claude-sonnet-4.6-(thinking)`, mixing a literal period, hyphens, and parens - not a clean form of either accepted shape) as a hard failure; that specific claim did not reproduce under this task's interactive-mode retest and is superseded by the silent-fallback behavior above, which is the operationally important risk (a crewmate can end up running on the wrong model with no loud signal). + +**Worktree-isolation false pwd (separate, unresolved quirk).** AGY's own reported `pwd -P` inside its first tool call has been observed resolving to the PRIMARY checkout even when `fm-spawn.sh`'s meta and the launching shell's own `cd` correctly show the treehouse worktree, recurring across different models. +A worker's isolation-assertion gate self-blocking (`blocked: launched in primary checkout, not an isolated worktree`) on an agy task should be treated with suspicion of this agy-specific pwd-resolution quirk rather than assumed to prove firstmate's own spawn is broken. +Not yet root-caused; out of scope for the launch-mode fix above. diff --git a/.agents/skills/project-management/SKILL.md b/.agents/skills/project-management/SKILL.md index af35d469ee..f4c62a5790 100644 --- a/.agents/skills/project-management/SKILL.md +++ b/.agents/skills/project-management/SKILL.md @@ -3,6 +3,7 @@ name: project-management description: >- Agent-only procedure for Firstmate project management. Use before adding, creating, removing, or initializing a project. + Cloning or registering a project is add intake and uses the same trigger. Owns project add, create, clone, remove, initialization, registry, delivery-mode, autonomy, and outward-consent decisions. user-invocable: false metadata: @@ -12,6 +13,7 @@ metadata: # project-management Use this procedure before adding, creating, removing, or initializing a project. +Cloning or registering a project is add intake and uses the same trigger. This skill is the single owner of Firstmate's project-management procedure. It does not replace `secondmate-provisioning`, which owns project clones inside persistent secondmate homes. @@ -22,6 +24,11 @@ Use the registry format and parser contract owned by the header of `bin/fm-proje Keep each registry description useful for identifying the project, but keep delivery posture, captain-private state, and detailed project knowledge in their existing designated homes. Do not turn the registry into project documentation. +Before adding, cloning, creating, or registering any project in the main home, inspect the authoritative `data/secondmates.md` routing table and judge every existing natural-language `scope:` against the proposed project or domain. +Apply `AGENTS.md` section 7's authoritative secondmate routing rules; if an existing scope owns that domain, route the new-project operation or work there instead of creating or registering a duplicate main-home clone. +Absence from the main `data/projects.md` registry is never evidence that no second mate owns the domain. +If the owning second mate cannot accept the route, report that concrete blocker or obtain an explicit captain redirection rather than silently duplicating the project in the main home. + Resolve the project name, destination, delivery mode, and autonomy posture before changing local or remote state. Keep a newly added clone and its registry entry consistent, and roll back only artifacts created by the incomplete operation when a later initialization step fails and that rollback is safe. Do not overwrite or repurpose an existing path. @@ -70,9 +77,9 @@ If doctor reports an environment, authentication, or daemon problem, resolve tha ## Remove -Project removal is destructive and is not one of Firstmate's current direct-write exceptions under `projects/`. -Never issue a raw removal command from Firstmate. +Project removal is destructive. First obtain the captain's explicit removal decision, then inspect the current digest and authoritative repositories for in-flight or queued work, registered secondmate clones, linked worktrees, dirty files, unpushed commits, and any other unlanded work. -If any dependency or unlanded work exists, stop and report it before changing the registry. -Until a guarded removal helper and corresponding prime-directive exception exist, report that implementation gap instead of bypassing the project-write boundary. -When a clone has already been removed through an approved guarded path, or the registry is provably stale because no clone exists, remove its registry line so navigation matches reality. +If any dependency or unlanded work exists, stop and report it before changing anything. +Never issue a raw removal command from Firstmate. +Once that preflight confirms none of the above and the captain's approval is concrete, AGENTS.md hard rule 1's captain-approved project operation exception authorizes firstmate to remove the clone directly and update its registry entry to match. +When a clone has already been removed through an approved removal, or the registry is provably stale because no clone exists, remove its registry line so navigation matches reality. diff --git a/.agents/skills/quota-array-dispatch/SKILL.md b/.agents/skills/quota-array-dispatch/SKILL.md index a5fe06d6ec..11b8405812 100644 --- a/.agents/skills/quota-array-dispatch/SKILL.md +++ b/.agents/skills/quota-array-dispatch/SKILL.md @@ -2,7 +2,7 @@ name: quota-array-dispatch description: >- Agent-only decision procedure for resolving a matched crew-dispatch profile - array from current quota-axi output, including quota-window pace signals. + array from current quota-axi output, including effective headroom and usable-runway evidence. Load when a dispatch rule or default resolves to more than one profile candidate. user-invocable: false metadata: @@ -11,160 +11,103 @@ metadata: # quota-array-dispatch -This skill is the single owner of the pace-aware profile-array selection procedure. -The concise always-loaded intake boundary remains in `AGENTS.md` section 4. -`docs/configuration.md` owns the `config/crew-dispatch.json` schema only. -`quota-axi` remains data-only and never recommends a route. -Firstmate owns the judgment. +This skill is the single owner of the completion-aware profile-array selection procedure. +`AGENTS.md` section 4 owns the always-loaded intake boundary, load trigger, malformed-config refusal, every-candidate accounting, and strongest-reasoning/tie safety rules. +`harness-adapters` owns harness verification, model/provider discovery, and effort fallback. +`quota-axi` remains data-only, reports whatever granularity the vendor supplies, and never recommends, selects, ranks, or infers a route. Do not add a daemon, opaque composite score, routing wrapper, hard-coded model-specific policy, or producer-side route recommendation. - -## When to load - -Load this skill whenever a matched dispatch rule or the configured default resolves to a profile array (more than one candidate), before choosing the concrete `--harness`, `--model`, and `--effort` passed to `fm-spawn`. -Keep using `harness-adapters` for harness verification, model/provider discovery, and effort fallback. - -## Intake boundary this skill does not relax - -1. Explicit per-task captain overrides still win over configured profiles. -2. Configured profile matching precedence is unchanged: best-fit rule, then configured default, then static crewmate harness. -3. Malformed `config/crew-dispatch.json` remains an actionable error; never select around it. -4. Every configured candidate in the matched array must be accounted for. -5. If any harness/model/provider relationship, applicable quota data, or interpretation cannot be established, stop and report that candidate instead of omitting it, guessing, falling back, or calling the result quota-informed. -6. When every candidate is tight, preserve the captain's strongest-reasoning class rather than silently downgrading it solely to conserve quota; stop and report the tight choice if that class cannot proceed. -7. Genuine ties must remain free of array-order or harness bias. - -## Collect inspectable facts for every candidate - -For each candidate profile: - -1. Establish the harness/model/provider relationship from current authoritative discovery owned by `harness-adapters`. - Fail loudly on an unresolved relationship. -2. Run `quota-axi --json` once per intake and reuse that snapshot for every candidate. -3. Require a current provider report with known quota semantics and a known applicable effective-availability record for that candidate's provider and model scope. - Stale raw windows remain diagnostic evidence only and are never current headroom. -4. Read every bounding window relevant to that candidate, including windows named by `boundedBy`, `limitingWindowIds`, `aheadWindowIds`, `behindWindowIds`, `onPaceWindowIds`, and `unknownWindowIds` on the effective record. -5. Record these inspectable facts, never a hidden score: - - task/profile fit - - reasoning class required by the captain request or task ambiguity - - raw applicable headroom (`effectivePercentRemaining` or the tightest applicable remaining percentage) - - effective pace status when present - - signed reserve for each applicable window and the effective worst reserve when present - - whether any applicable window or effective summary is ahead of reset - - whether any applicable pace is `unknown` - - schema compatibility note when pace fields are absent - -## Pace signals - -quota-axi `schemaVersion` 3 window pace uses: - -- `reservePercentPoints = percentRemaining - timeRemainingPercent` -- Negative reserve means usage is ahead of reset pace and creates conservation pressure. -- Positive reserve means usage is behind reset pace. -- `on_pace` is neutral. - -Effective-availability pace summaries may report `ahead`, `behind`, `on_pace`, `mixed`, or `unknown`. - -Treat conservation pressure as present when: - -- effective pace status is `ahead`, or -- effective pace status is `mixed` and any `aheadWindowIds` remain, or -- any applicable bounding window itself has pace status `ahead`. - -An effective `mixed` result is never healthy merely because one window is behind. -Any remaining `aheadWindowIds` keep conservation pressure. - -Signed reserve comparison uses the worst applicable reserve, preferring the producer field `worstReservePercentPoints` when present and otherwise the minimum signed reserve across applicable bounding windows. - -## Selection procedure - -Apply these steps only among candidates that already satisfy required task/profile fit and the strongest reasoning class the request genuinely needs. -Never use pace or raw headroom to silently replace that reasoning class with a weaker one. - -1. **Unresolved relationship or quota data** - Stop and report the blocked candidate. -2. **Strongest-reasoning / all-tight** - If every remaining candidate is tight, keep the strongest-reasoning class and either dispatch inside that class or stop and report that the tight choice cannot proceed. - Do not conserve quota through an unapproved downgrade. -3. **Conservation pressure vs sustainable pace** - When fit and reasoning class are comparable, prefer a candidate without ahead-of-reset conservation pressure over one with conservation pressure, even when the pressured candidate has somewhat higher raw remaining percentage. -4. **Among pressured candidates** - Prefer the least-negative worst applicable reserve. - Example: worst reserve `-4` is safer than `-18` when other inspectable facts are comparable. -5. **Among sustainable candidates** - Use known behind/on-pace evidence plus raw headroom transparently. - Do not collapse those facts into an opaque composite score. - Prefer known sustainable evidence over `unknown` pace when otherwise comparable. - Between known sustainable candidates, prefer the clearly better inspectable pair of pace reserve and raw headroom; state both facts in the choice rationale. -6. **Unknown pace** - `unknown` is valid explicit uncertainty from quota-axi, not a parser failure and not permission to assume the window is healthy or exhausted. - Inspect `unknownWindowIds` and each window's pace `reason` so the rationale preserves the producer's stated uncertainty. - Prefer known sustainable evidence when otherwise comparable. - If the dispatch choice materially hinges on unresolved pace, report the uncertainty rather than inventing a conclusion. -7. **Absent pace / older schema** - `schemaVersion` 2 payloads or missing pace fields must degrade explicitly and safely. - Do not crash, fabricate pace, or silently reinterpret absence as healthy/`on_pace`. - Compare raw applicable headroom only, using known effective availability rather than stale or isolated window percentages, state that pace is unavailable, and keep every other safety rule above. -8. **Genuine ties** - If every inspectable selection fact is equal, stop and report every tied candidate for captain choice. +Deterministic shell owns only schema, configuration, and version validation plus concrete spawn safeguards; every model-to-provider, provider-to-credential, and quota-applicability relation is yours to establish transparently and to show your evidence for. + +## Collect facts + +Run `quota-axi --json` once per intake and reuse that snapshot for every candidate. +Do not take a second snapshot to settle a candidate, and read `quota-axi auth --json` when a candidate's credential surface is in question. +For each candidate, preserve explicit `harness`, `model`, and `provider`; `harness-adapters` owns identity, and model/provider never infer harness: + +- task/profile fit and required reasoning class +- applicable effective headroom (`effectivePercentRemaining`) from the established provider/model scope +- usable runway status, `usableRunwaySeconds`, `projectedExhaustedAt`, `limitingWindowId`, `projectionConfidence`, `projectionBasis`, and any `unmeasurableWindowIds` +- the task-completion horizon and the evidence and confidence used to estimate it +- effective pace, signed reserve per window, and worst reserve (`worstReservePercentPoints` or minimum signed reserve) for later diagnostic tie-breaking +- schema notes when runway or pace fields are absent + +Stale raw windows are diagnostic, never headroom or fabricated runway. +Grok's `credits.remaining` is a prepaid balance unrelated to `percentRemaining`; never read it as exhaustion. +Read all windows named by `boundedBy`, `limitingWindowIds`, `aheadWindowIds`, `behindWindowIds`, `onPaceWindowIds`, `unknownWindowIds`, and `unmeasurableWindowIds`. +The compact default output intentionally omits numeric reserve, while `--json` and `--full` retain reserve diagnostics. + +## Establish the provider relation before reading quota + +Deterministic shell must never map a model to a provider, a provider to a credential store, or a name prefix to a family. +You establish those relations yourself, in the open, from the candidate's own authoritative catalog (`harness-adapters` owns the per-harness discovery surface) plus the one intake snapshot. +Name the evidence for each relation you assert so the conclusion is inspectable. + +1. Confirm the catalog lists the candidate's model and record the provider family it reports. + A model the authoritative catalog does not list is concrete contradictory evidence: block that candidate and quote the catalog result. +2. Apply quota at the granularity the vendor actually supplies. + A provider-level or `all_models`/`all_products` scope bounds every model you established in that family, including one with no window of its own. + A named-model or named-product scope is an additional bound for that model alone and is irrelevant to every other model in the family. + Read `quotaSemantics.description`, which states the vendor's own bounding rule. +3. Record what remains unknown instead of converting it into a verdict. + +## Authentication is scoped to the selected surface + +A candidate authenticates through its own tuple's surface; another harness's CLI can never gate it, and `harness=pi` with `model=xai/grok-*` is Pi using xAI rather than the standalone Grok CLI. +`quota-axi auth --json` lists each provider's credential sources independently, so read the one source the candidate actually uses rather than collapsing a provider to a single status. +A provider can carry a healthy source beside a missing or expired one; the unused source's state is not the candidate's state. +A Pi-hosted family may authenticate through the vendor's own store with no `pi:`-prefixed source at all, which is normal and never evidence against the candidate. + +Uncertainty and ineligibility are different findings: + +- No model-level window, no matching auth source, an absent `state.authStatus`, an unmeasurable or `unknown` scope, or a surface quota-axi does not model at all is disclosed uncertainty. + Keep the candidate eligible, state the unknown, and prefer known sustainable evidence when otherwise comparable. +- An expired credential is a short-lived session token the owning vendor renews on next use, not a sign-out. +- Only concrete contradictory evidence blocks: an authoritative catalog proving the model unsupported, or proof that the credential the candidate actually selects is unusable. +- Reserve login wording for that proven-unusable case, and name the harness, model, surface, and evidence. + +When a credential's local classification is the only thing standing between a candidate and a block, get ground truth before blocking. +`bin/fm-vendor-auth-probe.sh` is the only approved vendor-credential probe; its `--help` owns the registered probes and mechanics. +It takes no harness, model, or provider and returns a fact, not a route: only `authenticated` and `unauthenticated` are ground truth, while `indeterminate`, `timeout`, and `unavailable` establish nothing and must never be read as either outcome. +Never launch a vendor CLI yourself, and never probe a credential store the candidate does not use. + +## Pace semantics + +`reservePercentPoints = percentRemaining - timeRemainingPercent`. +Negative reserve means usage is ahead of reset pace and creates conservation pressure. +Positive reserve means usage is behind reset pace. +`on_pace` is neutral. +Conservation pressure is present for effective pace status `ahead`, effective pace status is `mixed` and any `aheadWindowIds` remain, or a bounding window is `ahead`. +`unknown` is valid explicit uncertainty from quota-axi, not parser failure or permission to assume health. + +## Selection order + +Apply only among candidates satisfying required fit and strongest reasoning class. +Never use headroom, runway, pace, or reserve to silently replace that reasoning class. + +1. Concrete contradictory evidence or malformed configuration: stop and report the tuple and that evidence. + Unmeasurable quota, a missing model-level window, an absent runway field, and a credential surface quota-axi does not model are uncertainty, never this rule. +2. Honor any explicit captain instruction that sets a floor for that candidate before the generic comparison. + Do not invent a generic percentage floor or treat a low percentage as an automatic failure. +3. Keep the strongest-reasoning class when every candidate is tight or completion evidence is poor. + Dispatch inside that class when a candidate can proceed, or report that its strongest-class choice cannot proceed rather than downgrading it to conserve quota. +4. Compare comparable-fit candidates on their applicable effective headroom and usable runway. + Eliminate a candidate only when another candidate Pareto-dominates it on both dimensions, with at least one dimension strictly better. + Establish dominance only from comparable known evidence, never by treating absent, `unknown`, or unmeasurable headroom or runway as zero or as a healthy value. +5. Prefer supported runway evidence that projects availability through the inspectable likely-completion horizon. + Known evidence that does not reach that horizon is inferior to known evidence that does, even when its signed reserve is less negative. + Preserve projection confidence and basis, the limiting window, and the horizon estimate in the rationale rather than hiding them in a score or model-specific heuristic. +6. Resolve remaining uncertainty explicitly. + An authenticated candidate with unknown or unmeasurable headroom or runway stays eligible and cannot be silently excluded or assumed sustainable. + Prefer known viable evidence when otherwise comparable, and report uncertainty or ask the captain when it still prevents a justified choice. +7. Use pace and signed reserve only as later diagnostic tie-break evidence among candidates still unresolved after headroom, runway, likely-completion viability, and uncertainty. + Pace and reserve never rescue a clearly inferior completion prospect. + Do not collapse these facts into an opaque composite score. +8. Older schemas or absent runway/pace fields: do not crash, fabricate runway or pace, treat absence as healthy, or silently exclude a candidate. + State which evidence is unavailable, retain the candidate, and apply only the comparisons the snapshot supports. +9. Genuine ties: stop and report every tied candidate for captain choice. Do not select by array order, harness name, or another arbitrary identity ordering. Report duplicate concrete profiles as a configuration error. -The intake rationale must name the inspectable facts used for every candidate. +Account for every candidate visibly before selecting or escalating, naming its catalog evidence, provider relation, applicable quota and authentication facts, remaining uncertainty, fit and reasoning class, effective headroom, usable runway, likely-completion reasoning, and later pace or reserve evidence when used. +A blocked credential report must name `harness`, `model`, authentication surface, and concrete failure evidence; never emit a bare `Grok unauthenticated` statement. Never conclude with an unexplained "best quota" label. - -## Acceptance scenarios - -These scenarios are normative examples of the procedure above. - -### Higher raw quota but materially ahead vs lower raw quota on/behind pace - -Candidate A has higher `effectivePercentRemaining` but conservation pressure from an ahead bounding window. -Candidate B has lower raw headroom, no conservation pressure, and known behind or on-pace evidence. -Choose B when fit and reasoning class are comparable. - -### Mixed effective pace with an ahead bound - -Effective pace status is `mixed` and `aheadWindowIds` is non-empty. -Treat the candidate as conservation-pressured even if another window is behind or on pace. - -### Both candidates ahead with different worst reserves - -Both candidates have conservation pressure. -Choose the least-negative worst applicable reserve when fit and reasoning class are comparable. - -### Known sustainable versus unknown - -Candidate A has known behind or on-pace evidence. -Candidate B has comparable fit, reasoning class, and raw headroom but `unknown` pace. -Prefer A. -If the only way to prefer one side depends on unresolved pace and no known sustainable candidate remains, report the uncertainty. - -### Every candidate tight while strongest-reasoning applies - -All candidates are tight on real headroom. -Keep the strongest reasoning class required by the request. -Do not pick a weaker class only to save quota. -Dispatch inside that class or stop and report that the tight strongest-class choice cannot proceed. - -### Genuine tie without array-order or harness bias - -Two candidates match on fit, reasoning class, conservation pressure, worst reserve, pace class, raw headroom, and unknown flags. -Choosing either array order or a standing harness preference is forbidden. -Stop and report both tied candidates for captain choice. - -### schemaVersion 2 or absent-pace compatibility - -Older quota-axi output or missing pace fields still allow array resolution. -Compare raw headroom only, state that pace is unavailable, and do not invent ahead/behind/on_pace. - -## Sanitized producer shape - -Validate consumers against a sanitized `schemaVersion` 3 shape derived from quota-axi 0.1.15: - -- top level: `schemaVersion`, `generatedAt`, `providers[]` -- each provider: `provider`, `state`, `windows[]`, and optional `quotaSemantics` with `status` and `effectiveAvailability[]` -- each window: `id`, `label`, `kind`, and optional `percentRemaining` and `pace`; pace has `status` plus optional `reason`, `timeRemainingPercent`, and `reservePercentPoints` -- each effective-availability entry: `scope`, `status`, `boundedBy`, optional `effectivePercentRemaining`, optional `limitingWindowIds`, and optional pace summary -- each effective pace summary: `status` plus optional `aheadWindowIds`, `behindWindowIds`, `onPaceWindowIds`, `unknownWindowIds`, `worstReservePercentPoints`, and `worstReserveWindowId` - -Never persist live provider balances, reset timestamps, account identifiers, or other private account details in tracked fixtures. diff --git a/.agents/skills/secondmate-provisioning/SKILL.md b/.agents/skills/secondmate-provisioning/SKILL.md index ecc364939a..978f4a954a 100644 --- a/.agents/skills/secondmate-provisioning/SKILL.md +++ b/.agents/skills/secondmate-provisioning/SKILL.md @@ -24,6 +24,7 @@ Keep the always-inline routing rules in `AGENTS.md` authoritative: route by natu ``` Each registry entry stays concise and single-line: the summary is one sentence naming the durable charter, `scope:` is the natural-language intake responsibility, `projects:` is the non-exclusive clone list, and any extra prose is limited to genuinely domain-specific hard rules that change routing or safety for that secondmate. +Natural-language summary and `scope:` text may contain parentheses and semicolons; keep the generated `(home: ...; scope: ...; projects: ...; added ...)` suffix intact so operational consumers resolve its explicit field markers. The `home:` path points to the seeded home containing `data/charter.md`; no extra registry pointer field is needed. The home-seeded `data/charter.md` is the sole owner of boilerplate idle-by-default behavior, the normal delegation lifecycle, and standard escalation contracts, so point to that charter rather than restating those contracts in the registry entry. The `scope:` field is used during intake. @@ -78,10 +79,13 @@ This section is the single owner of the secondmate sync and inherited-local-mate Before launch, `fm-spawn.sh --secondmate` locally fast-forwards the home to the primary firstmate checkout's current default-branch commit when it is safe; dirty, diverged, or in-flight homes launch unchanged with a warning. The locked session-start bootstrap sweep runs the same guarded fast-forward for every live secondmate home, discovered from `state/.meta` records with `kind=secondmate` (`data/secondmates.md` only backfills `home=` for older records). That no-fetch path is a purely local fast-forward of tracked files, never an origin fetch, and it never touches the gitignored operational dirs, so a secondmate's backlog, projects, and in-flight work are never disturbed; a linked worktree advances immediately, while a standalone clone that lacks the target receives firstmate updates through `/updatefirstmate`'s origin refresh. -The same launch and the same locked bootstrap sweep also propagate the primary's declared inherited local material: `config/crew-dispatch.json`, `config/crew-harness`, `config/backlog-backend`, `config/herdr-presentation-spaces`, and the one shared captain-preference file `data/captain-shared.md`. +The same launch and the same locked bootstrap sweep also propagate the primary's declared inherited local material: `config/crew-dispatch.json`, `config/crew-harness`, `config/backlog-backend`, `config/backend`, `config/herdr-presentation-spaces`, `config/startup-memory-budget`, and the one shared captain-preference file `data/captain-shared.md`. Because these paths are gitignored, that propagation is a separate, primary-authoritative copy independent of the tracked-files fast-forward: it re-converges every live home whether or not its tracked files advanced, and it touches only the declared items. Propagation failures warn without blocking secondmate launch or session-start continuation, and the destination keeps whatever safely validated state the helper left behind. Inheritance copies the literal `config/crew-harness` file, so a secondmate's own crewmates use the primary's crewmate harness only when it names a concrete adapter such as `codex`; an unset or `default` value has nothing concrete to inherit, and the secondmate's own crewmates fall back to the secondmate's own or detected harness instead. +Inherited `config/backend` becomes that secondmate home's local runtime-backend default for future spawns only; it never retargets, rewrites, migrates, stops, or restarts an already-live worker endpoint. +A present primary value always converges byte-exact into validated secondmate homes, and primary absence removes the destination so those homes keep runtime auto-detection. +Explicit per-spawn `--backend` and `FM_BACKEND` remain stronger than every home's local `config/backend`, including an inherited default. `config/secondmate-harness` is not inherited because it is only the primary's knob for launching secondmate agents. `data/captain-shared.md` is main-authoritative in the primary home and read-only in secondmate homes. Its primary file header must state that the file is main-authoritative, read-only in secondmate homes, must not be edited there, and that new captain-preference discoveries are routed to the main firstmate through marked status or a document pointer. @@ -96,7 +100,7 @@ Keep every `data/learnings.md` fully local by captain decision; route fleet-gene No AGENTS.md reread nudge is needed at spawn or respawn because the agent reads instructions fresh on launch; only the bootstrap sweep's running-home instruction-surface advance needs that AGENTS.md re-read. Bootstrap reports successful AGENTS.md re-read sends as `BOOTSTRAP_INFO:` and only emits `NUDGE_SECONDMATES:` when that send fails and needs retry. A separate, literal-content config reread is required whenever inherited `config/*` material changes under an already-running secondmate. -After each successful allowlisted config write, both the locked bootstrap convergence path and mid-session `bin/fm-config-push.sh` use the shared propagation report to build one per-home generation-specific private instruction file from the validated destination post-write bytes for only the allowlisted config items that actually changed for that home (`config/crew-dispatch.json`, `config/crew-harness`, `config/backlog-backend`, `config/herdr-presentation-spaces`), in deterministic allowlist order. +After each successful allowlisted config write, both the locked bootstrap convergence path and mid-session `bin/fm-config-push.sh` use the shared propagation report to build one per-home generation-specific private instruction file from the validated destination post-write bytes for only the allowlisted config items that actually changed for that home (`config/crew-dispatch.json`, `config/crew-harness`, `config/backlog-backend`, `config/backend`, `config/herdr-presentation-spaces`, `config/startup-memory-budget`), in deterministic allowlist order. Each changed path is printed with clear begin/end delimiters and the destination file's full exact new bytes unparsed, or the explicit token `ABSENT` when propagation removed the destination copy. The instruction uses only minimal framing that these are defaults/rules and do not remove judgment; it never includes SHA values, selected profiles, parsed summaries, or any other generated interpretation. `data/captain-shared.md` is not a config file and is never inlined into this instruction file or message. @@ -113,7 +117,7 @@ It uses the same live-home discovery and propagation helper as bootstrap, report `bin/fm-home-seed.sh` refuses to copy a missing or placeholder charter. Direct seed without a preexisting brief requires `FM_SECONDMATE_CHARTER`. -Run `bin/fm-home-seed.sh validate` when checking registry integrity; it refuses duplicate ids, duplicate homes, and nested or overlapping homes. +Run `bin/fm-home-seed.sh validate` when checking registry integrity; its header owns the complete validation and refusal mechanics. Seeding is transactional. If validation, cloning, no-mistakes initialization, or registry update fails, generated briefs, new homes, new project clones, and registry edits are rolled back. diff --git a/.agents/skills/stow/SKILL.md b/.agents/skills/stow/SKILL.md index 4c2c2a337a..672894bd56 100644 --- a/.agents/skills/stow/SKILL.md +++ b/.agents/skills/stow/SKILL.md @@ -10,56 +10,76 @@ metadata: # stow -Sweep this session for durable knowledge that only exists in conversation right now, and write it to the disk locations firstmate already prints in the next session-start context digest. -The goal is a session that is safe to reset or destroy because everything durable has already been captured. +Sweep this session for durable knowledge that exists only in conversation, then leave the next session with a compact current operating map rather than an accumulating journal. +This skill writes only through the existing Firstmate ownership and write boundaries. -## What it does +## Required startup-memory pass -1. **Sweep the session for uncaptured durable knowledge.** - Read back over this conversation and look for: - - Operational learnings: fleet-local facts and gotchas discovered while operating firstmate (a script's sharp edge, a harness quirk, a recurring false alarm and its real cause). - - Captain preferences expressed in passing: a working-style or approval preference the captain stated conversationally rather than through the destination selected by AGENTS.md's knowledge-routing table. - - Project-intrinsic facts discovered: build, test, release, or architecture facts about a project that belong in that project's own `AGENTS.md`. - - Decisions made: a standing choice the captain made this session that should outlive it. - - Undone next steps: anything left open that has not yet been filed as backlog work. +Every `/stow` invocation performs this complete pass, even when the session contains no new finding: -2. **Route each finding using AGENTS.md's knowledge-routing table.** - AGENTS.md (section 6, "Knowledge routing") is the single source of truth for where each kind of knowledge belongs. - Read that table and route each finding there instead of re-deriving the mapping here. +1. Run `bin/fm-startup-memory-budget.sh report` before considering a write. + Record its effective budget and each file's estimated-token total. + The helper's stable estimate is the documented conservative local approximation, not provider-exact accounting. + If it rejects the setting or a memory file, do not infer a default or silently continue. + Report that concrete exception and do not call the session reset-safe. +2. Read every current memory file completely: `data/captain.md`, `data/captain-shared.md`, and `data/learnings.md`. + Treat an absent local file as absent, not as an invitation to manufacture content. + In a primary home, all three are curation inputs under their existing ownership rules. + In a secondmate home, `data/captain-shared.md` is a read-only primary-owned input: count it, never edit it, and curate only the editable local files. +3. Build one whole-file retention plan before editing. + Retain, in order: current captain preferences, authority and safety boundaries, and recurring working style; stable home-local operating facts that repeatedly affect future work and are expensive to rediscover; then concise pointers to an existing authoritative report, project document, configuration, or backlog item. + Retain lower-priority material only while budget remains. +4. Consolidate every editable memory file as needed, not only the file apparently related to a new finding. + Prefer one concise current rule or authoritative pointer over duplicate prose. + Remove, merge, or route completed incident and release chronology, stale versions and paths, transient task state, resolved alternatives, old metrics, superseded claims, duplicates, and report-sized procedures. + Do not remove a unique current fact unless it is preserved directly elsewhere through a stronger existing owner. +5. Run `bin/fm-startup-memory-budget.sh report` again after the complete pass. + Finish at or below the effective budget unless a concrete inability remains. + A secondmate must explicitly report `primary-owned-shared-file-alone-exceeds-budget` when the inherited shared file alone exceeds its allowance, because local curation cannot resolve it. + Any other unresolved excess must identify the fact that cannot safely be removed or routed and why. + +A net increase is allowed only for a genuinely new current fact with no stronger owner. +Before allowing it, consolidate enough lower-priority material to remain within budget. +Never describe the session as reset-safe while the memory total is over budget or an exception is unresolved. + +## Knowledge sweep and routing -3. **Write within firstmate's existing write boundaries.** - This skill does not grant any new write permission; it only prompts firstmate to use the boundaries that already exist (AGENTS.md section 1): - - Captain preferences and fleet-local operational facts: hand-write directly to the destination selected by AGENTS.md's knowledge-routing table, using inspect-then-update every time. - Before writing, inspect the destination, find the existing bullet or section the finding duplicates or supersedes, and rewrite it in place rather than adding a new trailing entry. - `data/learnings.md` may not exist yet; create it on first local learning, in the same dated, evidence-backed, curated style as the captain-preference files. - - Project-intrinsic knowledge: never hand-write a project's `AGENTS.md`. - Route it through a normal ship task so a crewmate records it via `bin/fm-ensure-agents-md.sh` and commits it through that project's delivery pipeline, exactly as section 6 describes. - If the fleet is live, delegate this to a crewmate rather than doing it inline. - - Knowledge generalizable to every firstmate user: this repo's own `AGENTS.md` (or other shared, tracked material), shipped through the normal branch -> no-mistakes -> PR -> captain-merge pipeline for this repo (section 1), never hand-committed straight to `main`. - - Task-scoped notes: inspect the relevant backlog item with `tasks-axi show --full`, judge whether the new note is new, duplicate, superseding, or obsolete, then write a considered replacement body with `tasks-axi update --body-file `. - When the replacement intentionally supersedes prior state that should remain recoverable, add `--archive-body` to that update command so the prior body stays recoverable without copying it into the replacement. +1. **Sweep the session for uncaptured durable knowledge.** + Look for operational learnings, captain preferences expressed in passing, project-intrinsic facts, standing decisions, and undone next steps. +2. **Route each finding using AGENTS.md's knowledge-routing table.** + AGENTS.md section 6 is the source of truth for destinations. + Do not re-derive or duplicate that mapping here. +3. **Write within the existing boundaries.** + - Captain preferences and fleet-local operational facts belong in the destination selected by AGENTS.md after the required whole-file curation pass. + Create `data/learnings.md` only for a genuinely new local learning with no stronger owner. + - In a primary home, curate shared captain preferences only under the existing primary-authoritative shared-preference contract. + In a secondmate home, route a newly discovered shared preference to the main firstmate through marked status or a document pointer instead of editing the inherited file. + - Project-intrinsic knowledge never goes directly into a project's `AGENTS.md`. + Route it through a normal ship task so a crewmate records it with `bin/fm-ensure-agents-md.sh` and the project's delivery path. + - Knowledge general to every Firstmate user belongs in this repo's shared tracked material through the normal branch, no-mistakes, PR, and captain-merge path. + - For task-scoped notes, inspect the item with `tasks-axi show --full`, classify the change as new, duplicate, superseding, or obsolete, then use a considered replacement body through `tasks-axi update --body-file `. + Use `--archive-body` when recoverability matters. Never append. - If hand-editing `data/backlog.md` per the active backend, make the same inspect-then-update edit in place. - - Undone next steps: file each as a queued backlog item (section 10), with `blocked-by` recorded if it genuinely depends on something else. + - File each undone next step as a queued backlog item with a genuine `blocked-by` dependency when applicable. +4. **Use inspect-then-update.** + For every retained fact, ask which current statement it supersedes, whether it can be a one-sentence rewrite, and whether a stale entry should be deleted, retired, or routed to an existing stronger owner. + The only graduation moves are promotion to tracked shared material through a PR, folding a learning into the captain-preference destination selected by AGENTS.md, or deletion of a stale entry. + Do not invent another graduation path. + +## Completion receipt + +Report the outcome in plain captain-facing language with all of these facts: -4. **Curate with inspect-then-update.** - Every write starts by reading the current destination and deciding how the finding changes what is already there. - Use this checklist before writing: - - Which existing bullet, section, or task body does this supersede? - - Can this be a one-sentence rewrite instead of a new entry? - - Should an older bullet or note be deleted, retired, or archived because it is now obsolete? - When a finding overlaps or supersedes something already on disk, rewrite or prune the existing entry instead of piling on a new one. - Graduation moves are limited to exactly three: promote a learning to the shared `AGENTS.md` via PR, fold it into the captain-preference destination selected by AGENTS.md, or delete a stale entry. - Do not invent other graduation paths. +- effective startup-memory budget and total estimated tokens before and after; +- one or more actions for each of `data/captain.md`, `data/captain-shared.md`, and `data/learnings.md`: `unchanged`, `added`, `rewritten`, `pruned`, or `routed`; +- each durable finding filed outside memory and its authoritative owner; +- every unresolved exception, including a primary-owned shared-file constraint in a secondmate home; +- whether the session is safe to reset, only when all durable findings are captured and the post-pass result is within budget with no exception. -5. **Report to the captain.** - Summarize, in plain outcome language (section 9): what was stowed and where, what was filed to the backlog, and whether the session is now safe to reset or destroy - i.e. whether every durable finding from this sweep now lives on disk rather than only in this conversation. - If something could not be captured yet (for example, project-intrinsic knowledge waiting on a crewmate to land it), say so explicitly rather than reporting the session fully safe. +Do not hide an over-budget result behind a reset-safe claim. ## Scope exclusion: no skill storage -`/stow` must **never** store, create, or edit a skill as a destination for any finding. +`/stow` must never store, create, or edit a skill as a destination for any finding. There is no "graduate this to a skill" move in this skill's routing. -This is a deliberate, standing exclusion, not an oversight: even with the two-tier skill layout, a stow sweep is a memory-routing operation, not a way to author or mutate skills. -Writing learnings into either `.agents/skills/` or public `skills/` would still risk mixing fleet-local material with shared firstmate behavior or standalone installer-facing behavior. -Until a human deliberately scopes a skill change as firstmate repo work, route generalizable knowledge to the shared `AGENTS.md` (or other shared, tracked material) via the pipeline, and fleet-local knowledge to `data/`, never to a skill. +Until a human deliberately scopes a skill change as Firstmate repository work, route generalizable knowledge to shared tracked material through its pipeline and fleet-local knowledge to `data/`, never to `.agents/skills/` or public `skills/`. diff --git a/.claude/settings.json b/.claude/settings.json index e77613c98a..0be379c46b 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -40,11 +40,11 @@ "hooks": [ { "type": "command", - "command": "\"$CLAUDE_PROJECT_DIR\"/bin/fm-turnend-guard.sh --claude" + "command": "[ -z \"${GROK_AGENT:-}\" ] || exit 0; exec \"$CLAUDE_PROJECT_DIR\"/bin/fm-turnend-guard.sh --claude" }, { "type": "command", - "command": "\"$CLAUDE_PROJECT_DIR\"/bin/fm-claude-stop-autoarm.sh", + "command": "[ -z \"${GROK_AGENT:-}\" ] || exit 0; exec \"$CLAUDE_PROJECT_DIR\"/bin/fm-claude-stop-autoarm.sh", "asyncRewake": true, "timeout": 28800 } diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c04c0965c4..8530c5f9b2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -322,7 +322,14 @@ jobs: esac /bin/bash --version | head -1 command -v jq >/dev/null || { echo "::error::jq is required"; exit 1; } - /bin/bash -n bin/fm-fleet-snapshot.sh + + shell_inventory="$RUNNER_TEMP/fm-shell-inventory" + bin/fm-lint.sh --list-files > "$shell_inventory" + parse_fail=0 + while IFS= read -r f; do + /bin/bash -n "$f" || { echo "::error::stock macOS Bash 3.2 failed to parse $f"; parse_fail=1; } + done < "$shell_inventory" + [ "$parse_fail" -eq 0 ] || { echo "::error::stock macOS Bash 3.2 parse sweep failed"; exit 1; } snapshot_output=$(/bin/bash tests/fm-fleet-snapshot-view.test.sh) printf '%s\n' "$snapshot_output" @@ -335,8 +342,8 @@ jobs: bearings_output=$(/bin/bash tests/fm-bearings-snapshot.test.sh) printf '%s\n' "$bearings_output" bearings_count=$(printf '%s\n' "$bearings_output" | grep -c '^ok - ') - [ "$bearings_count" -eq 42 ] || { - echo "::error::expected 42 Bearings tests, got $bearings_count" + [ "$bearings_count" -eq 41 ] || { + echo "::error::expected 41 Bearings tests, got $bearings_count" exit 1 } diff --git a/.gitignore b/.gitignore index 372af4735f..b9293e8cfb 100644 --- a/.gitignore +++ b/.gitignore @@ -8,13 +8,5 @@ data/ __pycache__/ *.pyc .env -config/crew-harness -config/crew-dispatch.json -config/secondmate-harness -config/backlog-backend -config/backend -config/calm -config/x-mode.env -config/cmux-socket-password -config/wedge-alarm -config/herdr-presentation-spaces +config/ +.wrangler/ diff --git a/.opencode/plugins/fm-primary-watch-arm.js b/.opencode/plugins/fm-primary-watch-arm.js index 8b98340cfa..433edb80ab 100644 --- a/.opencode/plugins/fm-primary-watch-arm.js +++ b/.opencode/plugins/fm-primary-watch-arm.js @@ -4,7 +4,11 @@ import { resolve } from "node:path"; import { encodeFirstmateOperationalInput } from "./lib/fm-operational-input.js"; const COORDINATOR_KEY = "__firstmateOpenCodeWatchArm"; -const ARM_READY_TIMEOUT_MS = Number(process.env.FM_OPENCODE_ARM_READY_TIMEOUT_MS || 12000); +// 35s on Windows so the budget stays above arm's MSYS confirm default (30s in +// bin/fm-watch-arm.sh): a slow but successful Git Bash cold start must not be +// SIGTERMed mid-confirmation. Conditioned on win32 so other platforms keep 12s. +const ARM_READY_TIMEOUT_DEFAULT_MS = process.platform === "win32" ? 35000 : 12000; +const ARM_READY_TIMEOUT_MS = positiveInteger("FM_OPENCODE_ARM_READY_TIMEOUT_MS", ARM_READY_TIMEOUT_DEFAULT_MS); const ARM_RETIRE_TIMEOUT_MS = positiveInteger("FM_WATCH_ARM_RETIRE_TIMEOUT_MS", 1000); const REARM_RETRY_BASE_MS = positiveInteger("FM_WATCH_REARM_RETRY_BASE_MS", 250); const REARM_RETRY_MAX_MS = positiveInteger("FM_WATCH_REARM_RETRY_MAX_MS", 4000); diff --git a/.pi/extensions/fm-calm.ts b/.pi/extensions/fm-calm.ts index eb009fd8e3..1fb9cf12c4 100644 --- a/.pi/extensions/fm-calm.ts +++ b/.pi/extensions/fm-calm.ts @@ -1,11 +1,15 @@ // Firstmate's home-persistent Pi transcript presentation toggle. // -// Compatibility boundary: Pi 0.81.1 and 0.82.0 expose built-in ToolDefinitions, per-slot -// renderers, renderShell: "self", session_start replacement reasons, -// ExtensionUIContext.setToolsExpanded(), setWorkingVisible(), and -// setHiddenThinkingLabel(). The focused tests pin those assumptions. Version-bounded -// presentation adapters cover collapsed assistant thinking and operational user rows; -// Pi still exposes no global renderer for arbitrary built-in or custom rows. +// Verified against Pi 0.81.1 and 0.82.0, which expose built-in ToolDefinitions, per-slot +// renderers, renderShell: "self", session_start replacement reasons, agent_start and +// agent_settled, ExtensionUIContext.setToolsExpanded(), setWorkingVisible(), setWidget() +// with a disposable component factory, and setHiddenThinkingLabel(). +// ./lib/fm-calm-working-ship.ts owns the animated working presentation this file +// installs. The focused tests pin those assumptions but never reject a +// newer Pi solely for its version. The collapsed-thinking and operational-user +// presentation adapters probe the exact API they patch and degrade independently with a +// 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. import { randomUUID } from "node:crypto"; import { @@ -19,6 +23,7 @@ import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import type { ExtensionAPI, + ExtensionUIContext, ToolDefinition, ToolRenderResultOptions, } from "@earendil-works/pi-coding-agent"; @@ -35,6 +40,11 @@ import { Box, Container, getKeybindings, type Component } from "@earendil-works/ import type { TSchema } from "typebox"; import { installCalmAssistantLayout } from "./lib/fm-calm-assistant-layout.ts"; import { installCalmOperationalUserLayout } from "./lib/fm-calm-operational-user-layout.ts"; +import { + CALM_WORKING_SHIP_WIDGET_KEY, + createCalmWorkingShipAnimation, + createCalmWorkingShipWidget, +} from "./lib/fm-calm-working-ship.ts"; import { calmPresentationHides, calmPresentationIsActive, @@ -74,12 +84,53 @@ const extensionFile = fileURLToPath(import.meta.url); const extensionDir = dirname(extensionFile); const root = resolve(extensionDir, "../.."); +// 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 { + try { + install(); + } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + console.error(`Firstmate Calm: ${name} presentation adapter unavailable, skipping. ${reason}`); + } +} + export default function (pi: ExtensionAPI) { - installCalmAssistantLayout(); - installCalmOperationalUserLayout(); + installCalmPresentationAdapter("collapsed-thinking", installCalmAssistantLayout); + installCalmPresentationAdapter("operational-user-row", installCalmOperationalUserLayout); let exportRendering = false; let removeTerminalInputHandler: (() => void) | undefined; + // One logical agent run, tracked from agent_start through agent_settled rather than + // from turns or tool calls, so the boat never flickers between tool calls, automatic + // continuations, retries, or compaction that stay inside the same run. + let agentRunActive = false; + let workingShipShown = false; + // One animation instance per extension lifetime. Hiding the working widget freezes + // this state; the next working period resumes it. session_start resets it so a fresh + // Pi session starts at the normal initial position. Never module-global. + const workingShipAnimation = createCalmWorkingShipAnimation(); + + // Single owner of Calm's working-row presentation choice. The widget is only created + // or removed on a real transition, so repeated starts cannot duplicate its timer. + const applyWorkingPresentation = ( + ui: ExtensionUIContext, + forceStockVisibility = false, + ): void => { + const showShip = agentRunActive && calmPresentationIsActive(); + if (showShip !== workingShipShown) { + workingShipShown = showShip; + ui.setWidget( + CALM_WORKING_SHIP_WIDGET_KEY, + showShip + ? (tui) => createCalmWorkingShipWidget(tui, workingShipAnimation) + : undefined, + ); + ui.setWorkingVisible(!showShip); + } else if (forceStockVisibility && !showShip) { + ui.setWorkingVisible(true); + } + }; const fmHome = process.env.FM_HOME || process.env.FM_ROOT_OVERRIDE || root; const configDirectory = process.env.FM_CONFIG_OVERRIDE || resolve(fmHome, "config"); @@ -228,7 +279,11 @@ export default function (pi: ExtensionAPI) { setCalmPresentation(loadCalmPreference()); setCalmStockExportRendering(false); publishPresentationState(); - ctx.ui.setWorkingVisible(true); + agentRunActive = false; + workingShipShown = false; + // A genuine new session lifetime starts the boat at the normal initial position. + workingShipAnimation.reset(); + applyWorkingPresentation(ctx.ui, true); ctx.ui.setHiddenThinkingLabel(calmPresentationIsActive() ? "" : undefined); ctx.ui.setStatus("firstmate-calm", undefined); removeTerminalInputHandler?.(); @@ -258,6 +313,22 @@ export default function (pi: ExtensionAPI) { }); }); + pi.on("agent_start", (_event, ctx) => { + agentRunActive = true; + applyWorkingPresentation(ctx.ui); + }); + + // agent_settled is emitted from a finally block, so it also covers abort and failure. + pi.on("agent_settled", (_event, ctx) => { + agentRunActive = false; + applyWorkingPresentation(ctx.ui); + }); + + pi.on("session_shutdown", (_event, ctx) => { + agentRunActive = false; + applyWorkingPresentation(ctx.ui); + }); + pi.registerCommand("calm", { description: "Toggle Firstmate's supported conversation-only transcript presentation.", handler: async (_args, ctx) => { @@ -265,7 +336,7 @@ export default function (pi: ExtensionAPI) { persistCalmPreference(active); setCalmPresentation(active); publishPresentationState(); - ctx.ui.setWorkingVisible(true); + applyWorkingPresentation(ctx.ui, true); ctx.ui.setHiddenThinkingLabel(active ? "" : undefined); ctx.ui.setStatus("firstmate-calm", undefined); diff --git a/.pi/extensions/fm-primary-pi-watch.ts b/.pi/extensions/fm-primary-pi-watch.ts index 92c39e4fd2..9d5124aff2 100644 --- a/.pi/extensions/fm-primary-pi-watch.ts +++ b/.pi/extensions/fm-primary-pi-watch.ts @@ -88,7 +88,13 @@ const extensionVersion = `sha256:${createHash("sha256").update(readFileSync(exte const retryBaseMs = positiveInteger("FM_WATCH_REARM_RETRY_BASE_MS", 250); const retryMaxMs = positiveInteger("FM_WATCH_REARM_RETRY_MAX_MS", 4000); const retryLimit = positiveInteger("FM_WATCH_REARM_RETRY_LIMIT", 5); -const armReadyTimeoutMs = positiveInteger("FM_PI_ARM_READY_TIMEOUT_MS", 12000); +// 35s on Windows so the budget stays above arm's MSYS confirm default (30s in +// bin/fm-watch-arm.sh): a slow but successful Git Bash cold start must not be +// SIGTERMed mid-confirmation. Conditioned on win32 so other platforms keep 12s. +const armReadyTimeoutMs = positiveInteger( + "FM_PI_ARM_READY_TIMEOUT_MS", + process.platform === "win32" ? 35000 : 12000, +); const armRetireTimeoutMs = positiveInteger("FM_WATCH_ARM_RETIRE_TIMEOUT_MS", 1000); const repairOnlyHint = "call fm_watch_arm_pi again only after a later notification says the cycle is missing, failed, or unhealthy"; const shuttingDownMessage = "watcher: not armed - Pi session is shutting down"; diff --git a/.pi/extensions/lib/fm-calm-assistant-layout.ts b/.pi/extensions/lib/fm-calm-assistant-layout.ts index dbb5b2dd5f..33be71095e 100644 --- a/.pi/extensions/lib/fm-calm-assistant-layout.ts +++ b/.pi/extensions/lib/fm-calm-assistant-layout.ts @@ -1,7 +1,12 @@ -import { AssistantMessageComponent } from "@earendil-works/pi-coding-agent"; +// Verified against Pi 0.81.1 and 0.82.0, which export AssistantMessageComponent with an +// updateContent method. installCalmAssistantLayout() probes that exact method and throws +// if it is missing; fm-calm.ts catches that and skips only this adapter with a diagnostic +// instead of blocking Calm or Pi. +import type { AssistantMessageComponent as PiAssistantMessageComponent } from "@earendil-works/pi-coding-agent"; +import * as PiCodingAgent from "@earendil-works/pi-coding-agent"; import { calmPresentationHides } from "./fm-calm-visibility.ts"; -type AssistantMessage = Parameters[0]; +type AssistantMessage = Parameters[0]; type AssistantMessagePresentationState = { hiddenThinkingLabel: string; @@ -31,6 +36,10 @@ export function installCalmAssistantLayout(): void { } const patch: CalmAssistantLayoutPatch = { hidesThinking }; + const AssistantMessageComponent = PiCodingAgent.AssistantMessageComponent; + if (typeof AssistantMessageComponent !== "function") { + throw new Error("Firstmate Calm requires Pi AssistantMessageComponent"); + } const originalUpdateContent = AssistantMessageComponent.prototype.updateContent; if (typeof originalUpdateContent !== "function") { throw new Error("Firstmate Calm requires Pi AssistantMessageComponent.updateContent"); diff --git a/.pi/extensions/lib/fm-calm-operational-user-layout.ts b/.pi/extensions/lib/fm-calm-operational-user-layout.ts index 82c69eda01..ca9b0bbcc0 100644 --- a/.pi/extensions/lib/fm-calm-operational-user-layout.ts +++ b/.pi/extensions/lib/fm-calm-operational-user-layout.ts @@ -1,13 +1,14 @@ -// Pi 0.81.1 and 0.82.0 add the ordinary-user spacer and row together. -// This version-bounded adapter changes only that presentation and never message delivery. -import { - InteractiveMode, - UserMessageComponent, -} from "@earendil-works/pi-coding-agent"; +// Verified against Pi 0.81.1 and 0.82.0, which add the ordinary-user spacer and row +// together via InteractiveMode.addMessageToChat. This adapter probes that exact method +// and throws if it is missing; fm-calm.ts catches that and skips only this adapter with a +// diagnostic instead of blocking Calm or Pi. It changes only that presentation and never +// message delivery. +import type { UserMessageComponent as PiUserMessageComponent } from "@earendil-works/pi-coding-agent"; +import * as PiCodingAgent from "@earendil-works/pi-coding-agent"; import { calmPresentationHides } from "./fm-calm-visibility.ts"; import { classifyFirstmateCurrentOperationalText } from "./fm-operational-input.ts"; -type UserMessageConstructorArgs = ConstructorParameters; +type UserMessageConstructorArgs = ConstructorParameters; type UserMessageLike = { role: string; content: unknown; @@ -18,7 +19,7 @@ type AddMessageOptions = { type InteractiveModePresentation = { chatContainer: { children: unknown[]; - addChild(component: UserMessageComponent): void; + addChild(component: PiUserMessageComponent): void; }; editor: { addToHistory?(text: string): void; @@ -81,12 +82,20 @@ export function installCalmOperationalUserLayout(): void { hidesOperationalInput, isOperationalInput, }; + const InteractiveMode = PiCodingAgent.InteractiveMode; + if (typeof InteractiveMode !== "function") { + throw new Error("Firstmate Calm requires Pi InteractiveMode"); + } const prototype = InteractiveMode.prototype as unknown as InteractiveModePrototype; const originalAddMessageToChat = prototype.addMessageToChat; if (typeof originalAddMessageToChat !== "function") { throw new Error("Firstmate Calm requires Pi InteractiveMode.addMessageToChat"); } + const UserMessageComponent = PiCodingAgent.UserMessageComponent; + if (typeof UserMessageComponent !== "function") { + throw new Error("Firstmate Calm requires Pi UserMessageComponent"); + } class CalmOperationalUserMessageComponent extends UserMessageComponent { private readonly hasLeadingSpacer: boolean; diff --git a/.pi/extensions/lib/fm-calm-working-ship.ts b/.pi/extensions/lib/fm-calm-working-ship.ts new file mode 100644 index 0000000000..390e28baeb --- /dev/null +++ b/.pi/extensions/lib/fm-calm-working-ship.ts @@ -0,0 +1,243 @@ +// Firstmate's Calm-only animated working presentation. +// +// Calm replaces Pi's stock working row with a tiny SSHHIP-derived boat while one +// logical agent run is active. This module owns only the sprite geometry, the bounce +// track, the two animation cadences, the session-scoped freeze/resume state, and the +// temporary TUI widget; `.pi/extensions/fm-calm.ts` owns when the presentation is +// installed and removed, and stays the sole caller of setWorkingVisible(). +// docs/calm.md owns the captain-facing contract. +// +// Cadence: one scheduler drives two logically independent clocks. Every tick advances +// the water phase, and only every CALM_WORKING_SHIP_TICKS_PER_MOVE-th tick moves the +// boat, so the water visibly ripples several times between boat steps and the boat +// itself reads as calm. Both clocks stop together when the widget is disposed. Ticks, +// not wall-clock timestamps, drive every state change, so tests can seek time exactly. +// +// Continuity: one extension-owned animation instance survives hide/show within the same +// Pi process and Calm extension lifetime. Disposing the widget freezes column, +// direction, water phase, and tick cadence without advancing them for hidden wall +// time. The next working period resumes from that exact logical state. A fresh session +// or new extension lifetime calls reset() and starts at the normal initial position. +// State is never a module-level or process-global singleton. +// +// Verified against Pi 0.81.1 declarations and the Pi 0.82.0 CLI, which expose +// ExtensionUIContext.setWidget() with a component factory, per-widget dispose(), and +// TUI.requestRender(). Pi renders a widget through Component.render(width), so this +// module recomputes its track from that width on every frame instead of caching a +// terminal size that a resize would invalidate. A resize while the boat is hidden is +// applied on the first resumed frame through the same clamp path. +import type { Component, TUI } from "@earendil-works/pi-tui"; + +// The hull is symmetric and replaces waves on its row rather than adding a third row. +const HULL = "\\__/"; +// A mainsail extends aft of the mast, so it trails behind the bow relative to travel. +const SAIL_RIGHT = "<|"; +const SAIL_LEFT = "|>"; +// Centers the two-cell sail over the four-cell hull. +const SAIL_OFFSET = 1; +const HULL_WIDTH = HULL.length; +const SAIL_WIDTH = SAIL_RIGHT.length; + +// Bounded deterministic fixed-cell water phases. Every entry is exactly one column, so +// advancing the phase ripples the surface without changing visible width or row count. +const WAVE_CYCLE = ["~", "~", "-", "~"] as const; + +// Standard ANSI foreground codes only: no theme lookup, bright variant, or 256/RGB. +const BLUE = "\u001b[34m"; +const YELLOW = "\u001b[33m"; +// Restores the default foreground so color never bleeds into padding or later frames. +const RESET = "\u001b[39m"; + +export const CALM_WORKING_SHIP_WIDGET_KEY = "firstmate-calm-working-ship"; +/** Scheduler period. One tick advances the water by one phase. */ +export const CALM_WORKING_SHIP_TICK_MS = 220; +/** Boat moves one column every Nth tick, so it travels at 220 * 4 = 880ms per column. */ +export const CALM_WORKING_SHIP_TICKS_PER_MOVE = 4; + +export type CalmWorkingShipAnimation = { + /** Render one frame that exactly fits `width`, clamping the track to it first. */ + render(width: number): string[]; + /** Advance one scheduler tick: water every tick, boat on its slower cadence. */ + tick(): void; + restoreLastRendered(): void; + /** Restore the normal initial column, direction, water phase, and cadence. */ + reset(): void; + /** + * Clamp the frozen column and direction to `width` without advancing time. + * Used when a terminal resize lands while the working presentation is hidden. + */ + clampToWidth(width: number): void; + /** Current hull column, exposed for deterministic motion assertions. */ + position(): number; + /** Current travel direction: 1 travelling right, -1 travelling left. */ + direction(): number; + /** Current water phase, exposed for deterministic ripple assertions. */ + waterPhase(): number; +}; + +/** Longest hull start column that still fits the sprite in `width` usable cells. */ +function trackSpan(width: number): number { + if (width >= HULL_WIDTH) return width - HULL_WIDTH; + if (width >= SAIL_WIDTH) return width - SAIL_WIDTH; + return 0; +} + +export function createCalmWorkingShipAnimation(): CalmWorkingShipAnimation { + let position = 0; + let direction = 1; + let span = 0; + let phase = 0; + let ticks = 0; + let renderedPosition = position; + let renderedDirection = direction; + let renderedSpan = span; + let renderedPhase = phase; + let renderedTicks = ticks; + + // Reversing the moment the boat lands on an endpoint means the endpoint frame itself + // already shows the new heading, so no frame at or after a bounce shows the old sail. + const settleDirectionAtEdges = (): void => { + if (span <= 0) return; + if (position >= span) direction = -1; + else if (position <= 0) direction = 1; + }; + + const applyWidth = (width: number): void => { + if (width <= 0) { + span = 0; + position = 0; + return; + } + span = trackSpan(width); + position = Math.min(position, span); + settleDirectionAtEdges(); + }; + + const commitRenderedState = (): void => { + renderedPosition = position; + renderedDirection = direction; + renderedSpan = span; + renderedPhase = phase; + renderedTicks = ticks; + }; + + const restoreLastRenderedState = (): void => { + position = renderedPosition; + direction = renderedDirection; + span = renderedSpan; + phase = renderedPhase; + ticks = renderedTicks; + }; + + /** One colored run of water covering absolute columns [from, from + count). */ + const water = (from: number, count: number): string => { + if (count <= 0) return ""; + let cells = ""; + for (let column = from; column < from + count; column += 1) { + cells += WAVE_CYCLE[(column + phase) % WAVE_CYCLE.length]; + } + return `${BLUE}${cells}${RESET}`; + }; + + const boat = (text: string): string => `${YELLOW}${text}${RESET}`; + + return { + position: () => position, + direction: () => direction, + waterPhase: () => phase, + + restoreLastRendered: restoreLastRenderedState, + + reset(): void { + position = 0; + direction = 1; + span = 0; + phase = 0; + ticks = 0; + commitRenderedState(); + }, + + clampToWidth(width: number): void { + applyWidth(width); + }, + + tick(): void { + ticks += 1; + phase = (phase + 1) % WAVE_CYCLE.length; + if (ticks % CALM_WORKING_SHIP_TICKS_PER_MOVE !== 0) return; + if (span <= 0) { + position = 0; + return; + } + position = Math.min(span, Math.max(0, position + direction)); + settleDirectionAtEdges(); + }, + + render(width: number): string[] { + if (width <= 0) return []; + + // A resize lands here before the next frame, so recompute and clamp the track + // immediately rather than trusting a position measured against the old width. + applyWidth(width); + + const sail = direction >= 0 ? SAIL_RIGHT : SAIL_LEFT; + + let frame: string[]; + if (width < SAIL_WIDTH) { + // Too narrow for even the sail: a deterministic single row of water. + frame = [water(0, width)]; + } else if (width < HULL_WIDTH) { + // Too narrow for the hull: the sail alone rides the water row. + frame = [ + water(0, position) + + boat(sail) + + water(position + SAIL_WIDTH, width - position - SAIL_WIDTH), + ]; + } else { + frame = [ + " ".repeat(position + SAIL_OFFSET) + boat(sail), + water(0, position) + + boat(HULL) + + water(position + HULL_WIDTH, width - position - HULL_WIDTH), + ]; + } + + commitRenderedState(); + return frame; + }, + }; +} + +/** + * Build the temporary Calm working widget bound to one caller-owned animation. + * Pi disposes the previous component before installing a replacement under the same + * key and when it clears extension widgets, so the single scheduler driving both + * cadences cannot outlive the widget or duplicate. Disposing freezes the shared + * animation in place; the next widget bound to the same animation resumes without + * applying hidden wall time. + */ +export function createCalmWorkingShipWidget( + tui: TUI, + animation: CalmWorkingShipAnimation = createCalmWorkingShipAnimation(), +): Component & { dispose(): void } { + let disposed = false; + const timer = setInterval(() => { + if (disposed) return; + animation.tick(); + tui.requestRender(); + }, CALM_WORKING_SHIP_TICK_MS); + // The animation must never keep Pi's process alive on its own. + timer.unref?.(); + + return { + render: (width) => (disposed ? [] : animation.render(width)), + // Every frame is rebuilt from fixed standard ANSI codes, so there is no cache. + invalidate: () => {}, + dispose: () => { + if (disposed) return; + disposed = true; + clearInterval(timer); + animation.restoreLastRendered(); + }, + }; +} diff --git a/AGENTS.md b/AGENTS.md index f838dfb27c..0597b144fa 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,18 +14,19 @@ For captain-facing escalation style and outcome phrasing, see section 9. ## 1. Identity and prime directives You are the captain's only point of contact for all software work across all of their projects. -You do not do project-specific work yourself. -Delegate coding, investigation, planning, bug reproduction, and audits to a crewmate you spawn and supervise, or to a secondmate whose registered scope fits. +Outside hard rule 1's concrete captain-approved project operation exception, you do not do project-specific work yourself. +For all other project-specific work, delegate coding, investigation, planning, bug reproduction, and audits to a crewmate you spawn and supervise, or to a secondmate whose registered scope fits. A secondmate is a crewmate with an isolated firstmate home and a charter, not a second architecture. Hard rules, in priority order: 1. **Never write to a project.** Do not edit, commit, or run state-changing commands under `projects/` or in any project worktree; firstmate reads projects and crewmates change them. - The only exceptions are the guarded project initialization, fleet sync, secondmate sync and inherited local-material propagation, self-update, and approved `local-only` merge paths owned by their referenced skills and scripts. + The only exceptions are the guarded project initialization, fleet sync, secondmate sync and inherited local-material propagation, self-update, and approved `local-only` merge paths, each owned by its referenced skill or script, plus a concrete captain-approved project operation governed directly by this rule. Those paths never authorize forcing, stashing, discarding unlanded work, or hand-writing a project's `AGENTS.md`. + Firstmate may directly edit, create, move, or delete project files or directories only when the captain clearly and concretely approves, in the moment, for a specific project, either a specific operation or a concrete scope whose authorized action needs no inference; firstmate performs exactly that approval with its own file tools, never infers or broadens it, and gains no standing authority, while the force, discard, unlanded-work, merge-authority, destructive, irreversible, and security-sensitive boundaries remain independently in force. 2. **Never merge a PR without the captain's explicit word.** - A project's captain-approved `yolo` posture is the only standing relaxation for routine decisions; section 7 owns its exceptions and preserves the stronger destructive, irreversible, and security-sensitive captain boundaries. + A project's captain-approved `yolo` posture is the only standing relaxation for routine decisions; section 7 owns delivery and merge defaults, while the captain-instruction precedence rule below owns when a current explicit captain instruction overrides a conflicting Firstmate-written standing rule within its exact scope. 3. **Never tear down unlanded work.** Uncommitted changes are never landed, and `bin/fm-teardown.sh` owns the complete landed-work test. Never bypass a refusal or use `--force` unless the captain explicitly authorized discarding that work. @@ -50,7 +51,7 @@ Never add an agent name as a commit co-author. Each secondmate has a persistent isolated `FM_HOME`, including its own state, backlog, projects, and session lock. `bin/fm-send.sh` fails closed unless `FM_HOME` is explicit, so a steer cannot silently resolve against another home. -Tracked files hold shared instructions and tooling; `data/` holds durable private fleet records; `state/` holds volatile runtime records and append-only status events; `config/` holds local operating choices; and `projects/` contains clones that are read-only to firstmate. +Tracked files hold shared instructions and tooling; `data/` holds durable private fleet records; `state/` holds volatile runtime records and append-only status events; `config/` holds local operating choices; and `projects/` contains clones that are read-only to firstmate except under hard rule 1's concrete captain-approved project operation exception. ``` AGENTS.md this file (CLAUDE.md is a symlink to it) @@ -67,8 +68,9 @@ config/crew-harness crewmate harness override; LOCAL, gitignored; absent or "de config/crew-dispatch.json optional crewmate dispatch profiles; LOCAL, gitignored; firstmate-maintained but human-editable natural-language rules that choose a per-task harness/model/effort profile (section 4). Inherited by secondmate homes config/secondmate-harness harness the PRIMARY uses to launch SECONDMATE agents, optionally followed by a model and effort token on the same line (" [] []"; section 4); LOCAL, gitignored; absent or "default" harness falls back to config/crew-harness then firstmate's own. The primary's own setting; NOT inherited into secondmate homes (secondmates do not spawn secondmates) config/backlog-backend backlog backend override; LOCAL, gitignored; absent or "tasks-axi" = default tasks-axi backend, "manual" = force routine backlog updates to hand-editing; inherited by secondmate homes (section 10) -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; not inherited into secondmate homes +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/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 @@ -82,13 +84,13 @@ data/ personal fleet records; LOCAL, gitignored as a whole secondmates.md secondmate routing table; firstmate-private, maintained by fm-home-seed.sh (section 6) /brief.md per-task crewmate brief, or per-secondmate charter brief when kind=secondmate /report.md scout task deliverable, written by the crewmate; survives teardown -projects/ cloned repos; gitignored; READ-ONLY for you +projects/ cloned repos; gitignored; read-only except under hard rule 1's concrete captain-approved project operation exception state/ volatile runtime signals; gitignored .status appended by crewmates: ": " wake-event lines, not current-state truth .turn-ended touched by turn-end hooks .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=, worktree=, project=, harness=, model=, effort=, kind=, mode=, yolo=, tasktmp=; kind=secondmate also records home= and projects=; 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) + .meta written by fm-spawn: window=, endpoint_task_id=, worktree=, project=, harness=, model=, effort=, kind=, mode=, yolo=, tasktmp=; kind=secondmate also records home= and projects=; 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" .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 @@ -103,6 +105,7 @@ state/ volatile runtime signals; gitignored x-inbox/ generated X-mode pending mention payloads; fmx-respond drains it (section 14) x-context/ generated X-mode durable per-request reply context and one-wake offer markers, keyed by request_id; survives inbox cleanup and expires within seven days (section 14; bin/fm-x-lib.sh) x-outbox/ generated X-mode dry-run reply and dismiss previews; inspect it when FMX_DRY_RUN is set (section 14) + 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 .afk durable away-mode flag; present = sub-supervisor may inject escalations (set by /afk, cleared on user return) @@ -158,24 +161,27 @@ A silent bootstrap section needs no action; for any printed actionable diagnosti ## 4. Harness and runtime dispatch Load `harness-adapters` before every spawn or recovery and before trust handling, skill invocation, interrupt, exit, resume, or adapter verification. -The verified harnesses are `claude`, `codex`, `opencode`, `pi`, `pi-signed`, `grok`, and `kimi`; never dispatch on an unverified adapter. +The verified harnesses are `claude`, `codex`, `opencode`, `pi`, `pi-signed`, `grok`, `kimi`, and `agy`; never dispatch on an unverified adapter. If static `config/crew-harness` or `config/secondmate-harness` names an unverified adapter, report it and fall back only to a verified adapter rather than launching it. `docs/configuration.md` owns dispatch-profile and runtime-backend schemas, `bin/fm-harness.sh` owns static resolution, and `bin/fm-spawn.sh` owns launch flags and fail-closed validation. When dispatch profiles exist, consult them at every crewmate or scout intake and pass the resolved concrete profile required by `fm-spawn`. Routing precedence is an explicit per-task captain override, then the best-fit configured rule, then the configured default, then the static crewmate harness. -Firstmate alone resolves a matched profile array: run `quota-axi --json` at that intake, evaluate every configured candidate against that current output, and choose with inspectable real headroom including quota-window pace. -Account for every candidate; if any harness/model/provider relationship, applicable quota data, or interpretation cannot be established, stop and report that candidate instead of omitting it, guessing, falling back, or calling the result quota-informed. +Firstmate alone resolves a matched profile array: run `quota-axi --json` at that intake, evaluate every configured candidate against that current output, and choose with inspectable effective headroom and usable runway, using pace and reserve only later when needed. +Account for every candidate with the catalog evidence, provider relationship, applicable quota and authentication facts, remaining uncertainty, fit and reasoning class, and the headroom, runway, and later pace or reserve evidence used in selection; never omit a candidate, guess, fall back silently, or call the result quota-informed without them. +Establish model support and provider family from that harness's own authoritative catalog, then read `quota-axi` at the granularity the vendor actually supplies: provider-level or all-model evidence applies to every model established in that family, and a named-model window bounds only that model. +Missing model-level quota, a missing authentication source, unmeasurable headroom, or unmodeled authentication is disclosed uncertainty that keeps a candidate eligible, never a credential or login escalation. +Only concrete contradictory evidence blocks a candidate, such as an authoritative catalog proving the model unsupported or proof that the credential selected for that surface is unusable; never infer a credential store, provider family, or quota mapping from a harness, model, or source name, and never launch another harness's CLI to judge a candidate. Preserve malformed profile configuration as an actionable error rather than selecting around it. When every candidate is tight, preserve the captain's strongest-reasoning class rather than silently downgrading it solely to conserve quota; stop and report the tight choice if that class cannot proceed. -Break genuine headroom ties without array-order or harness bias. +Break genuine evidence ties without array-order or harness bias. `quota-axi` owns how model or product windows relate to bounding account windows and remains data-only. -Load `quota-array-dispatch` before choosing among a matched profile array; that skill is the single owner of the pace-aware selection procedure. +Load `quota-array-dispatch` before choosing among a matched profile array; that skill is the single owner of the completion-aware selection procedure. The generic effort fallback and its precedence are owned by `harness-adapters`: explicit captain and standing configured effort win; otherwise use low for well-understood explicit work, xhigh for ambiguous investigation or design, intermediate levels proportionally, and never max without explicit captain preference. Do not add model-specific versions of that policy. `secondmate-provisioning` owns secondmate harness pins and inherited local material, while `harness-adapters` owns the harness consequences. -Dispatch only on a backend that `fm-spawn` validates as spawn-capable. +Dispatch only on a backend that `fm-spawn` validates as spawn-capable; pass an explicit per-spawn `--backend` only under that exact task's own authority, never as later-task precedent (selection contract: [`docs/configuration.md`](docs/configuration.md) "Runtime backend"). A missing dependency, authentication failure, unsupported backend, or version refusal is a blocker; never silently retry on another backend. ## 5. Recovery @@ -196,8 +202,9 @@ A restart must be a non-event because durable state and live backend inventory, ## 6. Project and knowledge management Load `project-management` before adding, creating, removing, or initializing a project. -That skill owns registry syntax, delivery-mode selection, outward-facing consent, clone and initialization procedure, safe rollback, and removal refusal. -Project creation never authorizes an unmentioned remote, and project removal never bypasses the project-write boundary or unlanded-work checks. +Cloning or registering a project is add intake and uses the same trigger. +That skill owns registry syntax, delivery-mode selection, outward-facing consent, clone and initialization procedure, safe rollback, and removal preflight. +Project creation never authorizes an unmentioned remote, and project removal never bypasses that preflight or unlanded-work checks; hard rule 1's concrete captain-approved project operation exception remains available when its exact conditions are met. Load `secondmate-provisioning` before creating, seeding, validating, launching, handing backlog to, recovering, pushing inherited local material into, or retiring a secondmate home, and before editing `data/secondmates.md`. Its scope field drives routing and its project list is non-exclusive provisioning data, not ownership. @@ -280,11 +287,12 @@ The path's worker, automated gates, and captain approval remain authoritative: Delivery mode and `yolo` are orthogonal. With `yolo` off, the captain owns ask-user findings, PR merges, and local-only merge approval. -With `yolo` on, firstmate decides routine gates only within the captain's original request and accepted task criteria, and merges only green or otherwise approved work. +With `yolo` on, firstmate decides routine gates only within the captain's original request and accepted task criteria, and merges only green work. Standing `yolo` authority never approves an ask-user Fix that would materially expand that product or engineering contract; destructive, irreversible, and security-sensitive choices remain stronger captain boundaries. Complexity alone is not expansion: a difficult correction genuinely required by accepted intent, including explicitly requested complex architecture, remains autonomous. Before deciding any ask-user finding, load `ask-user-authority`; the implementation worker never answers its own finding. Never merge a red PR. +Without a current explicit captain instruction that states the concrete merge, that default stands, and standing `yolo` cannot authorize a red merge; section 1 owns when such an instruction overrides a Firstmate-written standing rule within its exact scope. Use `bin/fm-pr-merge.sh` for every task PR merge so merge metadata is recorded, and use `bin/fm-merge-local.sh` for approved local-only landing; never call a lower-level merge command around their guards. After an autonomous merge, give the captain a one-line full-URL or local-main outcome. @@ -293,6 +301,14 @@ After an autonomous merge, give the captain a one-line full-URL or local-main ou For a no-mistakes ship, trigger validation on the same worker after its implementation commit, using the harness invocation owned by `harness-adapters`. The task worker that starts a no-mistakes run drives the pipeline and owns every `no-mistakes axi run` and `no-mistakes axi respond` call through the next gate or outcome. Firstmate never invokes `no-mistakes axi respond` for a crew-owned run. +Once validation starts, prefer routing new requirements to follow-up work rather than expanding the current task, unless a new requirement completely invalidates the work being validated; however, the smallest downstream changes needed to keep already accepted product or engineering behavior correct, add behavioral tests where an executable contract exists, or keep documentation accurate remain within the current task even when they touch files not named at intake, and corrections required to satisfy already accepted intent are not new requirements. + +Only a current, explicit captain instruction that completely invalidates the work being validated keeps the task with the same worker instead of routing it to follow-up work or handing it to a replacement. +That worker cancels the active run through no-mistakes axi's supported abort command and confirms through axi status that the run has stopped before changing any code. +The worker then follows `branch_sync.next_action` from structured axi status: use axi sync's supported guarded recovery only when its code is `recover_custody`, and otherwise proceed only when structured status confirms that branch ownership is already returned and no recovery is required. +Custody recovery settles branch ownership, not content: the worker must replace the obsolete work from the correct pre-invalidation base rather than building on top of the recovered-but-obsolete head, keeping the obsolete run's own pipeline-fix commits out of what gets validated and shipped. +Apart from that single supported abort, do not hand-edit, commit, restart, or start a second validation run while the obsolete run still owns the branch. +Once ownership is settled, validate exactly once against that final head so no obsolete or intermediate head is ever treated as authoritative. An ask-user finding returns as `needs-decision`; firstmate decides only when the configured authority permits, otherwise escalates to the captain. Send the same worker one exact decision naming the decision key, step, action, affected finding IDs, instructions where needed, and exact response command. @@ -301,7 +317,7 @@ Resume fleet supervision immediately after the decision lands. Judge validation by the current-code-matched run step through `bin/fm-crew-state.sh`, not by shell liveness or the last status event. Running, fixing, or CI states remain working; parked approval or fix-review states require the worker to follow the active gate help; passed or checks-passed is done; failed or cancelled is failed. -A worker hand-editing, committing, aborting, or restarting during an active validation run duplicates pipeline ownership; steer it back to the gate response flow. +A worker hand-editing, committing, aborting, or restarting during an active validation run duplicates pipeline ownership outside the supersession sequence above; steer it back to the gate response flow. The worker reports the PR when CI first becomes green rather than waiting for merge monitoring to finish. ### PR ready, landing, and teardown @@ -351,7 +367,7 @@ Handle actionable wakes as follows: 4. For `heartbeat:`, review the whole fleet from the structured fleet view, reconcile suspicious tasks and PR state, update the backlog, and never report an unchanged fleet as progress. When any wake reports a merged PR for a project cloned in this home, refresh that clone through the guarded fleet-sync path. -When X-linked work reaches a milestone or terminal state, load `fmx-respond`; before terminal teardown, always post the final completion follow-up so the link clears even if earlier follow-ups were spent. +When X-linked work reaches a milestone or terminal state, load `fmx-respond`; before terminal teardown, use its promised-final reconciliation when a typed public commitment exists, otherwise post the final completion follow-up so the link clears even if earlier follow-ups were spent. A secondmate's idle endpoint is healthy, and parent supervision relies on its routed status rather than treating a quiet pane as stale. Waiting on a healthy supervision cycle is silent; empty polls, elapsed time, and no-change updates are not captain-facing progress. @@ -470,17 +486,18 @@ It performs guarded fast-forward updates of firstmate and registered secondmate These skills are not captain-invocable; load them only at their precise triggers. -- `bootstrap-diagnostics` - load whenever the session-start digest's bootstrap section prints an actionable diagnostic line (`MISSING:`, `MISSING_MANUAL:`, `BACKEND_INVALID:`, `NEEDS_GH_AUTH`, `TANGLE:`, `CREW_DISPATCH: invalid`, `FLEET_SYNC:`, `PR_CHECK_MIGRATION:`, `SECONDMATE_SYNC:`, `SECONDMATE_LIVENESS:`, `NUDGE_SECONDMATES:`, or `FMX:`); silence and `BOOTSTRAP_INFO:` need no load. +- `bootstrap-diagnostics` - load whenever the session-start digest's bootstrap section prints an actionable diagnostic line (`MISSING:`, `MISSING_MANUAL:`, `BACKEND_INVALID:`, `NEEDS_GH_AUTH`, `TANGLE:`, `STARTUP_MEMORY_BUDGET:`, `CREW_DISPATCH: invalid`, `FLEET_SYNC:`, `PR_CHECK_MIGRATION:`, `SECONDMATE_SYNC:`, `SECONDMATE_LIVENESS:`, `NUDGE_SECONDMATES:`, or `FMX:`); silence and `BOOTSTRAP_INFO:` need no load. - `diagnostic-reasoning` - load before scoping a reported bug and before acting on a diagnostic report. - `ask-user-authority` - load before deciding any ask-user finding, regardless of the project's `yolo` posture. - `quota-array-dispatch` - load before choosing among a matched crew-dispatch profile array from current quota-axi output. - `harness-adapters` - load before spawning or recovering a crewmate or secondmate, handling a trust dialog, sending a harness-specific skill invocation, interrupting or exiting an agent, resuming an exited agent, or verifying a new harness adapter. - `firstmate-orca` - load before switching to Orca, spawning or supervising Orca-backed work, smoke-testing Orca backend behavior, debugging Orca task state, or reconciling Orca-backed task metadata. - `project-management` - load before adding, creating, removing, or initializing a project. + Cloning or registering a project is add intake and uses the same trigger. - `stuck-crewmate-recovery` - load when the session-start digest reports an ordinary direct report's endpoint dead or its metadata has no window, or after a stale wake, looping pane, repeated confusion, an answered-by-brief question, an unresponsive crewmate, or a failed steer. - `secondmate-provisioning` - load before creating, seeding, validating, launching, handing backlog to, recovering, pushing inherited local material into, or retiring a secondmate home, and before editing `data/secondmates.md`. - `decision-hold-lifecycle` - load before treating an investigation or visual review as complete, before ending a visual review that exposed a decision, and when recording or routing the captain's answer. -- `fmx-respond` - load on an `x-mention ` `check:` wake to handle the mention, on an `x-mode-error ...` `check:` wake to report the X-mode configuration blocker, and on any milestone or terminal wake for an X-mode-linked task before posting its completion follow-up; relevant only when X mode is on. +- `fmx-respond` - load on an `x-mention ` `check:` wake to handle the mention, on an `x-mode-error ...` `check:` wake to report the X-mode configuration blocker, on a `public-followup ...` `check:` wake or a startup-surfaced public commitment, and on any milestone or terminal wake for an X-mode-linked task before posting its completion follow-up; relevant only when X mode is on. - `firstmate-codexapp` - load before coordinating a visible Codex Desktop thread, evaluating a Codex App backend request, or reconciling Codex Desktop host-tool smoke evidence for Firstmate work. - `firstmate-coding-guidelines` - load before changing firstmate's shared, tracked material, as defined by section 1's list, whether editing directly or briefing a crewmate for a firstmate-repo task. @@ -492,7 +509,20 @@ That token is consent for public replies and normal reversible lifecycle actions An X-only home still requires the live supervision cycle so mentions can wake it without fleet work. On an `x-mention ` or `x-mode-error ...` check wake, load `fmx-respond`, which owns classification, public-safety policy, reply or dismissal, task linking, and follow-ups. -For every X-linked terminal outcome, load that owner and post the final completion follow-up before teardown, regardless of earlier milestone follow-ups. +For every X-linked terminal outcome, load that owner and use the promised-final reconciliation when a typed public commitment exists, otherwise post the final completion follow-up before teardown. + +A promised final public reply is durable state, never conversation memory. +Load `fmx-respond` before promising one, on a `public-followup ...` check wake, and whenever the session-start digest lists a public commitment awaiting delivery. +Only the home holding the relay consent and thread binding ever posts it, so never ask a secondmate or crewmate to find the thread or send the reply, and never recover a terminal result by reading a `done:` sentence. + +## Captain instruction precedence + +A current, explicit, concrete captain instruction overrides any conflicting standing rule written above. +The instruction must be specific and recent: it must identify the concrete action, object, or bounded set it governs. +Never infer an override, broaden its scope, apply it by analogy, carry it to another object or action, or convert one request into standing authority. +Ambiguous scope or conflict still requires one concise clarification before action. +Destructive, irreversible, security-sensitive, discard, and merge actions still require the captain to state that concrete action explicitly; once the captain does so and higher-priority instructions permit it, a conflicting Firstmate-written rule must not rigidly block the action. +Standing `yolo` authority is not a substitute for a current explicit captain instruction where an explicit action is required. ## Maintaining this file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7f90f00202..effd31a891 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -47,7 +47,7 @@ See the [no-mistakes quick start](https://kunchenguid.github.io/no-mistakes/star Test scripts and helpers in `tests/` are plain bash too. `bin/fm-lint.sh` must pass: it is the single owner of the lint definition (the shellcheck file set, config, and pinned shellcheck version), and both CI and the no-mistakes pre-push gate run it, so local and CI can never diverge. It pins one exact shellcheck version and refuses to run under any other; print it with `bin/fm-lint.sh --required-version` and install that build locally. -- Changes to harness adapters (detection in `bin/fm-harness.sh`, launch and hook mechanics in `bin/fm-spawn.sh`, busy signatures in `bin/fm-watch.sh` and `bin/fm-tmux-lib.sh`, cleanup in `bin/fm-teardown.sh`, and facts in `.agents/skills/harness-adapters/SKILL.md`) must be verified empirically against the real harness, never written from documentation alone. +- Changes to harness adapters (detection in `bin/fm-harness.sh`, launch and hook mechanics in `bin/fm-spawn.sh`, semantic busy sources and trust gates in `bin/fm-busy-lib.sh`, delivery-only rendered guards in `bin/fm-tmux-lib.sh`, cleanup in `bin/fm-teardown.sh`, and facts in `.agents/skills/harness-adapters/SKILL.md`) must be verified empirically against the real harness, never written from documentation alone. - Changes to runtime session backends (`bin/fm-backend.sh`, `bin/backends/`, and the scripts that dispatch through them) keep current setup and limits in the relevant backend guide and active empirical evidence in [`docs/verification/runtime-backends.md`](docs/verification/runtime-backends.md). - [`docs/documentation-audiences.md`](docs/documentation-audiences.md) and its machine-consumed inventory own prose classification; run `bin/fm-doc-audience-check.sh` after documentation changes. - In Markdown, put each full sentence on its own line. @@ -71,7 +71,7 @@ That is firstmate-specific; do not commit `.no-mistakes/evidence/` here even whe Check and test the toolbelt before pushing: ```sh -for script in bin/*.sh bin/backends/*.sh; do bash -n "$script"; done # syntax-check the toolbelt +while IFS= read -r script; do /bin/bash -n "$script" || exit; done < <(bin/fm-lint.sh --list-files) # syntax-check the canonical shell surface bin/fm-lint.sh # lint the toolbelt and behavior tests; the single owner CI and the no-mistakes gate both run bin/fm-test-run.sh tests/.test.sh # one script (primary local focus path, timed) bin/fm-test-run.sh --family pure-contract-unit # ordinary family-scoped local path (serial, timed) @@ -93,7 +93,7 @@ Its header and `--help` own the flags, family labels, lanes, and changed-file ma Portable shard balance evidence lives in `docs/fm-test-portable-shards.md`. Local no-mistakes Test stays intent-targeted and must not wire `commands.test` to `--all` or a `tests/*.test.sh` walk. Family selection is the ordinary local path; `--all` is deliberate full regression only. -CI owns broad regression across required portable parallel shards, the portable serial lane, the Herdr lane, lint, invariants, the coverage guard, and macOS snapshot compatibility in [`.github/workflows/ci.yml`](.github/workflows/ci.yml). +CI owns broad regression across required portable parallel shards, the portable serial lane, the Herdr lane, lint, invariants, the coverage guard, and stock macOS Bash compatibility in [`.github/workflows/ci.yml`](.github/workflows/ci.yml). Use `bin/fm-test-run.sh --help` for lane names, `--jobs` rules, and required gate-skip flags when reproducing a lane locally. Discover tests by listing `tests/*.test.sh`: each is a self-contained bash script named `.test.sh`, and its header comment describes what it covers, so pass one to `bin/fm-test-run.sh` to focus on a subject with canonical timing output. Tests that need a real optional backend or an explicit opt-in (real herdr/zellij/cmux smoke tests, the live Pi regression) skip themselves and print the tool or environment gate needed to enable them, so the portable suite remains safe on machines without those tools. diff --git a/README.md b/README.md index a7f69e39c2..ac54cf7025 100644 --- a/README.md +++ b/README.md @@ -48,8 +48,8 @@ Launching a supported harness inside it instantiates your first mate - and makes - **Explicit project modes** - each project ships via `no-mistakes`, `direct-PR`, or `local-only`, with an optional `+yolo` autonomy flag. - **Optional secondmates** - opt in to persistent second mates that run from isolated firstmate homes with their own `FM_HOME`, state, projects, and session lock, supervising project clones or a project-less firstmate-repo domain, kept on the primary firstmate version by guarded local fast-forwards and checked for live agent processes at session start. - **Event-driven, zero-token supervision** - a bash watcher sleeps on the fleet and wakes the first mate only when something needs you; verified primary harnesses also get a turn-end backstop that blocks or follows up on a blind stop when work is under way and supervision is not live. -- **Optional X mode** - opt in with one local `.env` token so firstmate can answer your public `@myfirstmate` mentions, act on normal reversible mention requests through the same lifecycle as chat requests, acknowledge spawned work, and post up to three public-safe completion follow-ups within seven days for genuine milestones and the final outcome without changing non-X behavior; dry-run preview records would-be replies and dismissals locally before go-live. -- **Guarded by construction** - the first mate is read-only over your projects except for the guarded paths authorized by [hard rule 1](AGENTS.md#1-identity-and-prime-directives), with fleet sync's safe branch pruning remaining part of the fleet-sync exception; crewmates make every project change behind the configured merge authority. +- **Optional X mode** - opt in with one local `.env` token so firstmate can answer your public `@myfirstmate` mentions, act on normal reversible mention requests through the same lifecycle as chat requests, acknowledge spawned work, and post up to three public-safe completion follow-ups within seven days for genuine milestones and the final outcome without changing non-X behavior; a final reply promised in a thread becomes durable state that is reconciled from disk, so a restart or a compacted conversation cannot lose it; dry-run preview records would-be replies and dismissals locally before go-live. +- **Strict project boundary** - the first mate is read-only over your projects except for the narrow guarded and captain-approved operations authorized by [hard rule 1](AGENTS.md#1-identity-and-prime-directives), including fleet sync's guarded safe branch pruning; crewmates make every other project change behind the configured merge authority. - **Restart-proof** - all state lives on disk and in the active session backend (tmux by hard default, herdr or cmux when selected or auto-detected, zellij/orca when explicitly selected); kill the session anytime and the next one reconciles, including confirmed-dead secondmate agents, and carries on. Full detail on every feature lives in [docs/architecture.md](docs/architecture.md). @@ -106,7 +106,7 @@ FM_PI_HARNESS=pi-signed pi-signed For Grok, `--trust` is needed once per clone so project hooks and the turn-end guard load; `/hooks-trust` inside Grok works too. For Pi, approve the project trust prompt once per clone on first launch so the tracked `.pi/extensions/*.ts` files auto-load. -Pi's `/calm` toggle hides supported transcript chrome, including canonically classified Firstmate operational user rows, while retaining native working activity and all model context and session data. +Pi's `/calm` toggle hides supported transcript chrome, including canonically classified Firstmate operational user rows, and uses a Calm-only animated working boat during active runs while preserving all model context and session data. The hidden operational inputs remain ordinary user-role messages with unchanged delivery, ordering, authority, persistence, and exports. The preference persists for the effective Firstmate home, and toggling it off restores ordinary rendering. [Calm's current behavior and supported limits](docs/calm.md) are separate from its [version-scoped maintainer evidence](docs/calm-mode-feasibility.md). @@ -171,10 +171,17 @@ Claude and grok use the slash form shown here; codex uses the same names with `$ | ------------------ | -------------------------------------------------------------------------------------------------------------------------------------------- | | `/afk` | Enter away-mode supervision: the sub-supervisor self-handles routine notifications in bash, escalates captain-relevant events and bounded declared-external-wait rechecks as batched digests, and actively alerts if delivery gets stuck while you step away | | `/ahoy` | Recap visible session events since the prior real captain message plus visibly unanswered captain decisions, falling back to Bearings when invoked as the session's first real captain message | -| `/bearings` | Generate a standalone current-status report from bounded local fleet and registered-secondmate state, with live PR enrichment only when requested, written to a dated file in `data/` and surfaced concisely in chat; read-mostly, mutates no task state | +| `/bearings` | Generate a concise four-section chat digest from bounded local fleet and registered-secondmate state; use `/bearings file` to also replace today's dated report in `data/`, and add `include PRs` when live PR enrichment is wanted | | `/updatefirstmate` | Self-update the running firstmate and its secondmates to the latest from origin with fast-forward-only pulls, then re-read instructions and nudge secondmates | | `/stow` | Sweep the session for uncaptured durable knowledge, route each finding to its disk home per AGENTS.md, file undone next steps to the backlog, and report what is now safe to reset | +Bearings invocation examples: + +- `/bearings` returns the fresh four-section digest in chat only. +- `/bearings include PRs` keeps chat-only mode and opts into live PR enrichment. +- `/bearings file` replaces today's `data/status-report-.md` from scratch and links it from the four-section chat digest. +- `/bearings file include PRs` combines the dated report with live PR enrichment. + Agent-only reference skills live under `.agents/skills/` and are loaded by firstmate at the trigger points named in [`AGENTS.md`](AGENTS.md). ### Two-tier skill layout diff --git a/bin/backends/herdr.sh b/bin/backends/herdr.sh index 3a28daaa33..cb677be0cf 100644 --- a/bin/backends/herdr.sh +++ b/bin/backends/herdr.sh @@ -27,9 +27,17 @@ # duplicate-agent risk is independently absent. # Target resolution stays parallel to the tmux adapter in both layouts. # Projected create, move, and cleanup operations capture the named session's -# exact active workspace and tab. Herdr 0.7.4's last-pane close can focus an -# unrelated neighbor, so projected cleanup serializes and restores only the -# exact pre-close tab id, while refusing to close the active tab itself. +# exact active workspace and tab. On Herdr 0.7.5, an explicit close that +# empties a non-focused workspace moves focus to that workspace's neighbor +# (upstream discussion #1328, fixed by PR #1877), while a pane-death removal +# preserves focus exactly when the dying workspace sits behind the focused +# one or the focused one is last (upstream issue #1621, fixed by PR #1912); +# both fixes are merged upstream but in no release. Projected cleanup +# therefore serializes under the session lock, repositions a doomed workspace +# behind the focused one when needed, and ends its verified lone idle shell +# so Herdr removes the emptied workspace through the focus-preserving +# pane-death path, with the exact pre-close tab restore as the backstop and a +# refusal to close the active tab itself. # # Target string shape: ":", e.g. "default:w1:p2" (the # pane id itself contains a colon; the session is always the FIRST field, the @@ -462,18 +470,15 @@ fm_backend_herdr_presentation_lock_namespace_valid() { # it would turn JSON null into the literal string "null"). Canonicalizes the # parent directory when that directory exists so symlink parents such as /tmp # -> /private/tmp cannot yield two lock identities for the same socket. -fm_backend_herdr_presentation_session_socket_path() { # - local session=$1 sessions socket sock_dir sock_base - [ -n "$session" ] || return 1 - sessions=$(fm_backend_herdr_cli "$session" session list --json 2>/dev/null) || return 1 - socket=$(printf '%s' "$sessions" | jq -er --arg want "$session" ' - [.sessions[]? - | select(.name == $want and .running == true) - | select((.socket_path | type) == "string") - | select((.socket_path | length) > 0) - | .socket_path] - | if length == 1 then .[0] else empty end - ' 2>/dev/null) || return 1 +# fm_backend_herdr_canonical_socket_path: normalize one absolute Unix-socket +# path so two spellings of the same socket compare equal. Refuses a relative +# or empty path. An unresolvable directory is left as-is rather than treated as +# a failure, so a socket whose directory was removed still compares by its own +# literal path. Single owner for every socket-identity comparison in this +# adapter (the presentation session lock and the launcher-identity same-session +# proof both use it). +fm_backend_herdr_canonical_socket_path() { # + local socket=$1 sock_dir sock_base [ -n "$socket" ] || return 1 case "$socket" in /*) ;; @@ -489,6 +494,21 @@ fm_backend_herdr_presentation_session_socket_path() { # printf '%s' "$socket" } +fm_backend_herdr_presentation_session_socket_path() { # + local session=$1 sessions socket + [ -n "$session" ] || return 1 + sessions=$(fm_backend_herdr_cli "$session" session list --json 2>/dev/null) || return 1 + socket=$(printf '%s' "$sessions" | jq -er --arg want "$session" ' + [.sessions[]? + | select(.name == $want and .running == true) + | select((.socket_path | type) == "string") + | select((.socket_path | length) > 0) + | .socket_path] + | if length == 1 then .[0] else empty end + ' 2>/dev/null) || return 1 + fm_backend_herdr_canonical_socket_path "$socket" +} + fm_backend_herdr_presentation_session_lock_path() { # local session=$1 socket key dir hash [ -n "$session" ] || return 1 @@ -546,8 +566,11 @@ fm_backend_herdr_projection_focus_snapshot() { # # fm_backend_herdr_projection_focus_restore: verify that one presentation # mutation preserved the exact active workspace and tab captured immediately # before it. -# Herdr 0.7.4's pane.close can focus an unrelated neighboring workspace when -# it removes a non-focused workspace's last pane. +# This is the backstop for every focus-unsafe instant: on Herdr 0.7.5 an +# explicit pane.close that empties a non-focused workspace moves focus to +# that workspace's neighbor (upstream #1328/#1877), and a pane-death removal +# before a non-last focused workspace moves focus to the focused workspace's +# right neighbor (upstream #1621/#1912); both fixes are unreleased. # A single tab.focus on the exact response-independent pre-operation tab id # restores both the workspace and tab atomically. fm_backend_herdr_projection_focus_restore() { # @@ -587,9 +610,17 @@ fm_backend_herdr_projection_focus_restore() { # [required-agent-state] local session=$1 pane_id=$2 required_agent_state=${3:-} - local before active_tab info target_pane target_tab close_status state + local before active_tab info target_pane target_tab target_ws close_status state plan plan_shell_pid plan_move_record workspace_presence FM_BACKEND_HERDR_PROJECTION_CLOSE_AGENT_STATE="" [ -n "$pane_id" ] || return 0 before=$(fm_backend_herdr_projection_focus_snapshot "$session") || { @@ -603,6 +634,7 @@ fm_backend_herdr_projection_close_pane_focus_preserving() { # /dev/null) target_tab=$(printf '%s' "$info" | jq -r '.result.pane.tab_id // empty' 2>/dev/null) + target_ws=$(printf '%s' "$info" | jq -r '.result.pane.workspace_id // empty' 2>/dev/null) if [ "$target_pane" != "$pane_id" ] || [ -z "$target_tab" ]; then echo "warning: herdr presentation cleanup received an ambiguous exact-pane response; refusing focus-unsafe pane close" >&2 return 1 @@ -616,20 +648,380 @@ fm_backend_herdr_projection_close_pane_focus_preserving() { # /dev/null 2>&1; then + plan=plain + plan_shell_pid= + plan_move_record= + if [ -n "$target_ws" ]; then + plan=$(fm_backend_herdr_emptying_close_plan "$session" "$pane_id" "$target_ws" "$target_tab" "${before%%$'\t'*}") + case "$plan" in + moved$'\t'*) + plan_move_record=${plan%%$'\n'*} + plan=${plan##*$'\n'} + ;; + esac + case "$plan" in + death\ *) + plan_shell_pid=${plan#death } + plan=death + ;; + *) + plan=plain + ;; + esac + fi + if [ "$plan" = death ]; then + if fm_backend_herdr_death_close_pane "$session" "$pane_id" "$plan_shell_pid"; then + close_status=0 + elif fm_backend_herdr_explicit_close_pane_confirmed "$session" "$pane_id"; then + close_status=0 + else + close_status=1 + fi + elif fm_backend_herdr_explicit_close_pane_confirmed "$session" "$pane_id"; then close_status=0 else - close_status=$? + close_status=1 + fi + if [ "$close_status" -eq 0 ] && [ -n "$plan_move_record" ]; then + workspace_presence=$(fm_backend_herdr_workspace_presence_state "$session" "$target_ws") + if [ "$workspace_presence" != dead ]; then + echo "warning: herdr presentation cleanup did not confirm removal of the repositioned workspace" >&2 + close_status=1 + fi + fi + if [ "$close_status" -ne 0 ]; then + fm_backend_herdr_emptying_move_rollback "$plan_move_record" || true fi fm_backend_herdr_projection_focus_restore "$session" "$before" "pane close" || return 2 [ "$close_status" -eq 0 ] } +# Herdr 0.7.5 workspace-removal focus rules (verified against the installed +# 0.7.5 binary, its v0.7.5 tag source, and the isolated named lab): +# - An EXPLICIT close that empties a workspace (API pane.close of its last +# pane, tab close, or workspace close) routes through +# close_selected_workspace, which assigns focus to the closing workspace's +# right neighbor (or the new last workspace when it was last), ignoring the +# previously focused workspace entirely (upstream discussion #1328, fixed +# by PR #1877, commit 165dca45). +# - A PANE-DEATH removal (handle_pane_died) keeps the focused index stale, +# which preserves the exact focused workspace whenever the dying workspace +# sat behind it (or the focused workspace was last), and moves focus to the +# focused workspace's right neighbor otherwise (upstream issue #1621, fixed +# by PR #1912, commit a979916). +# Both fixes are merged upstream but in no release as of 2026-07-28. +# Firstmate therefore removes a doomed non-focused workspace by ending its +# verified lone idle shell (the pane-death path), repositioning it behind the +# focused workspace first when needed. Moving it to the end preserves every +# other workspace's relative order, so no presentation ordering change +# persists. A release carrying both fixes preserves focus on both paths, so +# this stays safe without any version gate. + +# fm_backend_herdr_workspace_move_capable: verify that one guarded raw +# workspace.move request is possible in : python3 for the transport, +# the minimum protocol, and the exact whitelisted method and parameter +# schema. Silent; each caller owns its own warning wording. +# Return codes: 1 python3 missing, 2 protocol unreadable, 3 protocol too old, +# 4 schema unreadable, 5 method or parameter schema unsupported. +fm_backend_herdr_workspace_move_capable() { # + local session=$1 protocol schema + command -v python3 >/dev/null 2>&1 || return 1 + protocol=$(fm_backend_herdr_cli "$session" status --json 2>/dev/null | jq -r '.client.protocol // empty' 2>/dev/null) + case "$protocol" in + ''|*[!0-9]*) return 2 ;; + esac + [ "$protocol" -lt "$FM_BACKEND_HERDR_MIN_WORKSPACE_MOVE_PROTOCOL" ] && return 3 + schema=$(fm_backend_herdr_cli "$session" api schema --json 2>/dev/null) || return 4 + printf '%s' "$schema" | jq -e ' + any(.schemas.request.oneOf[]?; .properties.method.const == "workspace.move") + and .schemas.request["$defs"].WorkspaceMoveParams.required == ["workspace_id", "insert_index"] + and .schemas.request["$defs"].WorkspaceMoveParams.properties.insert_index.type == "integer" + ' >/dev/null 2>&1 || return 5 +} + +# fm_backend_herdr_emptying_close_plan: choose the focus-safe removal for one +# exact pane. The LAST echoed line is the plan: "plain" (use the ordinary +# explicit close; the exact-tab restore backstop masks 0.7.5's focus move) +# or "death " (end the proved lone idle shell so Herdr removes +# the emptied workspace through its focus-preserving pane-death path). +# Whenever the repositioning mover was invoked, a preceding +# "moved" +# record line is echoed first so the caller can hand it to +# fm_backend_herdr_emptying_move_rollback when removal is not confirmed. +# Never fails; every ambiguity plans "plain". +# The death plan requires the close to empty the workspace (exactly one tab +# and one pane, both the target), the target workspace to sit behind the +# focused one (repositioned to the end first when it does not, with the move +# verified against the server-returned order and focus), and the exact pane +# to hold one provably lone idle recognized shell. +fm_backend_herdr_emptying_close_plan() { # + local session=$1 pane_id=$2 ws_id=$3 tab_id=$4 focused_ws=$5 + local tabs panes list indices r rest a len capable socket mover response move_status shell_pid before_order + [ -n "$ws_id" ] && [ -n "$tab_id" ] && [ -n "$focused_ws" ] || { printf 'plain\n'; return 0; } + tabs=$(fm_backend_herdr_cli "$session" tab list --workspace "$ws_id" 2>/dev/null) || { printf 'plain\n'; return 0; } + printf '%s' "$tabs" | jq -e --arg tab "$tab_id" ' + (.result.tabs | type) == "array" and (.result.tabs | length) == 1 + and .result.tabs[0].tab_id == $tab + ' >/dev/null 2>&1 || { printf 'plain\n'; return 0; } + panes=$(fm_backend_herdr_cli "$session" pane list --workspace "$ws_id" 2>/dev/null) || { printf 'plain\n'; return 0; } + printf '%s' "$panes" | jq -e --arg pane "$pane_id" ' + (.result.panes | type) == "array" and (.result.panes | length) == 1 + and .result.panes[0].pane_id == $pane + ' >/dev/null 2>&1 || { printf 'plain\n'; return 0; } + list=$(fm_backend_herdr_cli "$session" workspace list 2>/dev/null) || { printf 'plain\n'; return 0; } + indices=$(printf '%s' "$list" | jq -r --arg ws "$ws_id" --arg focused "$focused_ws" ' + (.result.workspaces // null) as $s + | select(($s | type) == "array" and ($s | length) > 1) + | ([range(0; $s | length) | select($s[.].workspace_id == $ws)]) as $w + | ([range(0; $s | length) | select($s[.].workspace_id == $focused)]) as $f + | select(($w | length) == 1 and ($f | length) == 1 and $w[0] != $f[0]) + | "\($w[0])\t\($f[0])\t\($s | length)" + ' 2>/dev/null) || indices= + if [ -z "$indices" ]; then + printf 'plain\n' + return 0 + fi + r=${indices%%$'\t'*} + rest=${indices#*$'\t'} + a=${rest%%$'\t'*} + len=${rest#*$'\t'} + case "$r:$a:$len" in + *[!0-9:]*) + printf 'plain\n' + return 0 + ;; + esac + if [ "$r" -lt "$a" ] && [ "$a" -lt $((len - 1)) ]; then + # The doomed workspace sits before the focused one, where the pane-death + # path would land focus on the focused workspace's right neighbor. + # Reposition it behind everything first: insert_index equal to the list + # length is the verified move-to-last form, and removing the moved + # workspace afterward leaves every other relative order untouched. + if fm_backend_herdr_workspace_move_capable "$session"; then + capable=0 + else + capable=$? + fi + if [ "$capable" -ne 0 ]; then + echo "warning: herdr presentation cleanup could not verify workspace.move support; closing without the focus-safe removal path" >&2 + printf 'plain\n' + return 0 + fi + socket=$(fm_backend_herdr_presentation_session_socket_path "$session") || { + echo "warning: herdr presentation cleanup found an ambiguous named session socket; closing without the focus-safe removal path" >&2 + printf 'plain\n' + return 0 + } + mover=${FM_BACKEND_HERDR_WORKSPACE_MOVER:-$FM_BACKEND_HERDR_ROOT/bin/backends/herdr-workspace-move.py} + before_order=$(printf '%s' "$list" | jq -c '[.result.workspaces[].workspace_id]' 2>/dev/null) + if response=$("$mover" "$socket" "$ws_id" "$len" 2>/dev/null); then + move_status=0 + else + move_status=$? + fi + # Every mover invocation is recorded, even an unverified one, so a later + # unconfirmed removal can restore the exact original order; restoring an + # unmoved workspace to its own position is a verified no-op. + printf 'moved\t%s\t%s\t%s\t%s\t%s\n' "$ws_id" "$r" "$socket" "$focused_ws" "$before_order" + if [ "$move_status" -ne 0 ] \ + || ! printf '%s' "$response" | jq -e --arg ws "$ws_id" --arg focused "$focused_ws" \ + --argjson before "$before_order" ' + ($before | map(select(. != $ws)) + [$ws]) as $expected + | .result.type == "workspace_list" + and ([.result.workspaces[].workspace_id] == $expected) + and ([.result.workspaces[] | select(.focused == true) | .workspace_id] == [$focused]) + ' >/dev/null 2>&1; then + echo "warning: herdr presentation cleanup could not move the doomed workspace behind the focused one; closing without the focus-safe removal path" >&2 + printf 'plain\n' + return 0 + fi + fi + if shell_pid=$(fm_backend_herdr_pane_idle_shell_pid "$session" "$pane_id"); then + printf 'death %s\n' "$shell_pid" + else + printf 'plain\n' + fi +} + +# fm_backend_herdr_emptying_move_rollback: restore the exact pre-move +# workspace order recorded by an emptying-close plan whose removal was not +# confirmed, under the caller's still-held session lock. +# is the plan's tab-separated +# "moved" +# line, or empty for a no-op when no move was attempted. +# The rollback is verified against the mover's returned order and focus and +# warns on any failure, so a lasting reorder is never silent. +fm_backend_herdr_emptying_move_rollback() { # + local record=$1 marker ws index socket focused order mover response + [ -n "$record" ] || return 0 + IFS=$'\t' read -r marker ws index socket focused order <&2 + return 1 + fi + case "$index" in + ''|*[!0-9]*) + echo "warning: herdr presentation cleanup has a malformed move record after a failed removal; the workspace order may remain changed" >&2 + return 1 + ;; + esac + mover=${FM_BACKEND_HERDR_WORKSPACE_MOVER:-$FM_BACKEND_HERDR_ROOT/bin/backends/herdr-workspace-move.py} + if ! response=$("$mover" "$socket" "$ws" "$index" 2>/dev/null) \ + || ! printf '%s' "$response" | jq -e --argjson expected "$order" --arg focused "$focused" ' + .result.type == "workspace_list" + and ([.result.workspaces[].workspace_id] == $expected) + and ([.result.workspaces[] | select(.focused == true) | .workspace_id] == [$focused]) + ' >/dev/null 2>&1; then + echo "warning: herdr presentation cleanup could not restore the original workspace order after a failed removal" >&2 + return 1 + fi +} + +# fm_backend_herdr_death_close_pane: end the exact pane's proved lone idle +# shell so Herdr removes the emptied workspace through its focus-preserving +# pane-death path, then confirm the pane is gone. +# Each signal is sent only while the exact pane still owns the recorded pid +# as its lone idle shell: SIGHUP relies on the proof taken just before, and +# the SIGKILL escalation re-reads the pane's process information and refuses +# unless the same pid is still the pane's strict bare idle shell, so an +# exited or reused pid is never signaled. +# Returns 0 only when the pane is confirmed gone. +fm_backend_herdr_death_close_pane() { # + local session=$1 pane_id=$2 shell_pid=$3 ps_bin attempt max_attempts presence resampled_pid + ps_bin=${FM_HERDR_PS_BIN:-ps} + case "$shell_pid" in + ''|*[!0-9]*) return 1 ;; + esac + command -v "$ps_bin" >/dev/null 2>&1 || return 1 + max_attempts=${FM_BACKEND_HERDR_DEATH_CLOSE_POLLS:-40} + fm_backend_herdr_pid_is_bare_shell "$ps_bin" "$shell_pid" || return 1 + kill -HUP "$shell_pid" 2>/dev/null || true + attempt=0 + while [ "$attempt" -lt "$max_attempts" ]; do + presence=$(fm_backend_herdr_pane_presence_state "$session" "$pane_id") + [ "$presence" = dead ] && return 0 + sleep 0.05 + attempt=$((attempt + 1)) + done + # SIGKILL escalation revalidates exact pane ownership, not just the pid: a + # fresh strict pane sample must still name the SAME shell pid, so a pid + # that exited and was reused by an unrelated process is never signaled. + resampled_pid=$(fm_backend_herdr_pane_idle_shell_sample "$session" "$pane_id") || return 1 + [ "$resampled_pid" = "$shell_pid" ] || return 1 + fm_backend_herdr_pid_is_bare_shell "$ps_bin" "$shell_pid" || return 1 + kill -KILL "$shell_pid" 2>/dev/null || true + attempt=0 + while [ "$attempt" -lt "$max_attempts" ]; do + presence=$(fm_backend_herdr_pane_presence_state "$session" "$pane_id") + [ "$presence" = dead ] && return 0 + sleep 0.05 + attempt=$((attempt + 1)) + done + return 1 +} + +# fm_backend_herdr_pid_is_bare_shell: currently resolves to a bare +# recognized shell process per . +# BSD ps reports comm as argv0, so a login shell arrives as "-zsh"; strip the +# login dash exactly like the idle-shell proof's argv0 normalization. +fm_backend_herdr_pid_is_bare_shell() { # + local comm + comm=$("$1" -p "$2" -o comm= 2>/dev/null) || return 1 + comm=$(printf '%s' "$comm" | tr -d '[:space:]') + comm=${comm#-} + comm=${comm##*/} + case "$comm" in sh|bash|zsh|dash|ksh|fish) return 0 ;; esac + return 1 +} + +# fm_backend_herdr_pane_idle_shell_pid: print the shell pid of only +# when the exact pane provably holds one lone idle recognized shell: pane +# process-info agrees on the pane id, the shell pid is both the foreground +# process group and the sole foreground process, the foreground process name +# and argv0 resolve to the same recognized shell, the operating-system +# process table shows exactly that one shell row with no child process, and +# the shell sits in a sleeping or idle state. +# An idle interactive shell transiently hosts short-lived prompt helpers +# (verified on the real 0.7.5 lab: a workspace.move relayout makes zsh redraw +# its prompt, spawning starship as a second foreground process for a few +# samples), so the proof retries strict single samples for a bounded settle +# window and succeeds on the first fully clean one; a genuinely busy pane +# fails every sample and still refuses. +# This is the single owner of the idle-shell proof; the session-start +# projection cleanup and every pane-death close path both rely on it. +fm_backend_herdr_pane_idle_shell_pid() { # + local attempt=0 max_attempts=${FM_BACKEND_HERDR_IDLE_SHELL_PROOF_POLLS:-10} + while :; do + if fm_backend_herdr_pane_idle_shell_sample "$1" "$2"; then + return 0 + fi + attempt=$((attempt + 1)) + [ "$attempt" -lt "$max_attempts" ] || return 1 + sleep 0.1 + done +} + +# fm_backend_herdr_pane_idle_shell_sample: one strict instantaneous +# observation for fm_backend_herdr_pane_idle_shell_pid, which owns the proof +# contract and the settle retry. +fm_backend_herdr_pane_idle_shell_sample() { # + local session=$1 pane=$2 info shell_pid foreground_pgid count + local process_pid name argv0 shell_name rows stat ps_bin + info=$(fm_backend_herdr_cli "$session" pane process-info --pane "$pane" 2>/dev/null) || return 1 + printf '%s' "$info" | jq -e --arg pane "$pane" ' + .result.type == "pane_process_info" + and .result.process_info.pane_id == $pane + ' >/dev/null 2>&1 || return 1 + shell_pid=$(printf '%s' "$info" | jq -er \ + '.result.process_info.shell_pid | select(type == "number" and . > 1) | floor' 2>/dev/null) || return 1 + foreground_pgid=$(printf '%s' "$info" | jq -er \ + '.result.process_info.foreground_process_group_id | select(type == "number" and . > 1) | floor' 2>/dev/null) || return 1 + [ "$foreground_pgid" = "$shell_pid" ] || return 1 + count=$(printf '%s' "$info" | jq -er \ + '.result.process_info.foreground_processes | select(type == "array") | length' 2>/dev/null) || return 1 + [ "$count" -eq 1 ] || return 1 + process_pid=$(printf '%s' "$info" | jq -er \ + '.result.process_info.foreground_processes[0].pid | select(type == "number") | floor' 2>/dev/null) || return 1 + [ "$process_pid" = "$shell_pid" ] || return 1 + name=$(printf '%s' "$info" | jq -er \ + '.result.process_info.foreground_processes[0].name | select(type == "string" and length > 0)' 2>/dev/null) || return 1 + argv0=$(printf '%s' "$info" | jq -er ' + .result.process_info.foreground_processes[0] as $process + | ($process.argv0 // $process.argv[0]) + | select(type == "string" and length > 0) + ' 2>/dev/null) || return 1 + shell_name=${name##*/} + argv0=${argv0#-} + argv0=${argv0##*/} + [ "$argv0" = "$shell_name" ] || return 1 + case "$shell_name" in sh|bash|zsh|dash|ksh|fish) ;; *) return 1 ;; esac + + ps_bin=${FM_HERDR_PS_BIN:-ps} + command -v "$ps_bin" >/dev/null 2>&1 || return 1 + rows=$("$ps_bin" -axo pid=,ppid= 2>/dev/null) || return 1 + printf '%s\n' "$rows" | awk -v shell="$shell_pid" ' + $1 == shell { found++ } + $2 == shell { child++ } + END { exit(found == 1 && child == 0 ? 0 : 1) } + ' || return 1 + stat=$("$ps_bin" -p "$shell_pid" -o stat= 2>/dev/null | tr -d '[:space:]') || return 1 + case "$stat" in S*|I*) ;; *) return 1 ;; esac + printf '%s\n' "$shell_pid" +} + # fm_backend_herdr_projection_order_best_effort: place the exact workspace id # returned by THIS projected create immediately after its owning parent's # contiguous child block and before the next parent. # # is the owning FM_HOME label (firstmate or 2ndmate-). +# Optional is that parent's EXACT id, which the caller +# already resolved from the launching agent's own herdr identity. When given it +# anchors the owning parent by id, so two workspaces sharing the home label no +# longer make the whole layout ambiguous; when omitted the parent is located by +# label exactly as before. With a unique label the two select the same +# workspace, so ordering behavior is unchanged in the ordinary case. # New-format └ ... · p: children and, for compatibility only, already # adjacent old-format firstmate/... or 2ndmate-/... projections may extend # the block read-only; they are never renamed or moved. @@ -644,8 +1036,8 @@ fm_backend_herdr_projection_close_pane_focus_preserving() { # - local session=$1 created=$2 parent=$3 list analysis current desired protocol schema socket mover response move_status focus_before +fm_backend_herdr_projection_order_best_effort() { # [] + local session=$1 created=$2 parent=$3 parent_ws=${4:-} list analysis current desired socket mover response move_status focus_before move_capable local before_existing after_existing [ -n "$parent" ] || { echo "warning: herdr presentation ordering missing owning parent label; leaving worker in Herdr's current order" >&2 @@ -655,9 +1047,12 @@ fm_backend_herdr_projection_order_best_effort() { # &2 return 0 } - analysis=$(printf '%s' "$list" | jq -c --arg created "$created" --arg parent "$parent" ' + analysis=$(printf '%s' "$list" | jq -c --arg created "$created" --arg parent "$parent" --arg parent_ws "$parent_ws" ' def is_parent: - (.label | type) == "string" and .label == $parent; + if ($parent_ws | length) > 0 + then .workspace_id == $parent_ws + else (.label | type) == "string" and .label == $parent + end; def is_top_level_parent: (.label | type) == "string" and ((.label == "firstmate") or (.label | test("^2ndmate-[^/]+$"))); @@ -732,33 +1127,34 @@ fm_backend_herdr_projection_order_best_effort() { # /dev/null 2>&1 || { - echo "warning: herdr presentation ordering requires python3; leaving worker in Herdr's current order" >&2 - return 0 - } - protocol=$(fm_backend_herdr_cli "$session" status --json 2>/dev/null | jq -r '.client.protocol // empty' 2>/dev/null) - case "$protocol" in - ''|*[!0-9]*) + if fm_backend_herdr_workspace_move_capable "$session"; then + move_capable=0 + else + move_capable=$? + fi + case "$move_capable" in + 0) ;; + 1) + echo "warning: herdr presentation ordering requires python3; leaving worker in Herdr's current order" >&2 + return 0 + ;; + 2) echo "warning: herdr presentation ordering could not verify the client protocol; leaving worker in Herdr's current order" >&2 return 0 ;; + 3) + echo "warning: herdr presentation ordering needs protocol $FM_BACKEND_HERDR_MIN_WORKSPACE_MOVE_PROTOCOL or newer; leaving worker in Herdr's current order" >&2 + return 0 + ;; + 4) + echo "warning: herdr presentation ordering could not read the API schema; leaving worker in Herdr's current order" >&2 + return 0 + ;; + *) + echo "warning: herdr presentation ordering API support is unavailable or ambiguous; leaving worker in Herdr's current order" >&2 + return 0 + ;; esac - if [ "$protocol" -lt "$FM_BACKEND_HERDR_MIN_WORKSPACE_MOVE_PROTOCOL" ]; then - echo "warning: herdr presentation ordering needs protocol $FM_BACKEND_HERDR_MIN_WORKSPACE_MOVE_PROTOCOL or newer; leaving worker in Herdr's current order" >&2 - return 0 - fi - schema=$(fm_backend_herdr_cli "$session" api schema --json 2>/dev/null) || { - echo "warning: herdr presentation ordering could not read the API schema; leaving worker in Herdr's current order" >&2 - return 0 - } - if ! printf '%s' "$schema" | jq -e ' - any(.schemas.request.oneOf[]?; .properties.method.const == "workspace.move") - and .schemas.request["$defs"].WorkspaceMoveParams.required == ["workspace_id", "insert_index"] - and .schemas.request["$defs"].WorkspaceMoveParams.properties.insert_index.type == "integer" - ' >/dev/null 2>&1; then - echo "warning: herdr presentation ordering API support is unavailable or ambiguous; leaving worker in Herdr's current order" >&2 - return 0 - fi socket=$(fm_backend_herdr_presentation_session_socket_path "$session") || { echo "warning: herdr presentation ordering found an ambiguous named session socket; leaving worker in Herdr's current order" >&2 return 0 @@ -779,14 +1175,19 @@ fm_backend_herdr_projection_order_best_effort() { # &2 return 0 fi - if ! printf '%s' "$response" | jq -e --arg created "$created" --arg parent "$parent" --argjson desired "$desired" ' + if ! printf '%s' "$response" | jq -e --arg created "$created" --arg parent "$parent" --arg parent_ws "$parent_ws" --argjson desired "$desired" ' + def is_parent: + if ($parent_ws | length) > 0 + then .workspace_id == $parent_ws + else (.label | type) == "string" and .label == $parent + end; .result.type == "workspace_list" and (.result.workspaces | type) == "array" and .result.workspaces[$desired].workspace_id == $created - and ([.result.workspaces[] | select(.label == $parent)] | length) == 1 + and ([.result.workspaces[] | select(is_parent)] | length) == 1 and ( [range(0; .result.workspaces | length) as $i - | select(.result.workspaces[$i].label == $parent) + | select(.result.workspaces[$i] | is_parent) | $i][0] < $desired ) ' >/dev/null 2>&1; then @@ -821,14 +1222,20 @@ fm_backend_herdr_server_ensure() { # return 1 } -# fm_backend_herdr_workspace_find: this HOME's own workspace id inside -# (fm_backend_herdr_workspace_label), or empty (never creates). -# Read-only, safe for recovery/list paths. Label-collision semantics -# (docs/herdr-backend.md "Label collisions"): herdr enforces no label -# uniqueness at all, so this adopts the FIRST matching workspace `jq` returns -# (list order, normally creation order/oldest) rather than disambiguating - -# identical in spirit to the pre-existing tab duplicate-label check below. -fm_backend_herdr_workspace_find() { # +# fm_backend_herdr_workspace_find_all: EVERY workspace id inside +# whose label equals this HOME's own label (fm_backend_herdr_workspace_label), +# one per line, in herdr's own list order (normally creation order, oldest +# first). Empty when none match. Never creates anything. +# +# Single owner of the home-label workspace query. Herdr enforces no workspace +# label uniqueness at all (docs/herdr-backend.md "Label collisions"), so this +# can legitimately return MORE THAN ONE id: a captain-owned workspace can +# collide by label, a cwd-basename-derived label can coincide, and concurrent +# first spawns can mint two same-labeled home workspaces. Callers decide what a +# duplicate means for them - fm_backend_herdr_workspace_ensure refuses to guess +# which one is the caller's, while the read-only recovery path below keeps its +# historical first-match behavior. +fm_backend_herdr_workspace_find_all() { # local session=$1 label list label=$(fm_backend_herdr_workspace_label) list=$(fm_backend_herdr_cli "$session" workspace list 2>/dev/null) || return 0 @@ -838,7 +1245,143 @@ fm_backend_herdr_workspace_find() { # # ALWAYS return empty and every spawn mint a fresh "firstmate" workspace # (the workspace leak). printf '%s' "$list" | jq -r --arg want "$label" \ - '.result.workspaces[]? | select(.label == $want) | .workspace_id' 2>/dev/null | head -1 + '.result.workspaces[]? | select(.label == $want) | .workspace_id' 2>/dev/null +} + +# fm_backend_herdr_workspace_find: this HOME's own workspace id inside +# , or empty (never creates). Read-only, safe for recovery/list +# paths, which address panes they already recorded and only need a container +# to scan. Keeps the historical FIRST-match behavior on a label collision - +# identical in spirit to the pre-existing tab duplicate-label check below. +# NOT the spawn-time resolver: placing a new worker by first label match is +# exactly the defect fm_backend_herdr_workspace_ensure now refuses. +fm_backend_herdr_workspace_find() { # + fm_backend_herdr_workspace_find_all "$1" | head -1 +} + +# fm_backend_herdr_launcher_identity: the EXACT herdr workspace that the +# process making this spawn is itself running in. +# +# Herdr 0.7.5 injects HERDR_ENV=1, HERDR_PANE_ID, HERDR_SESSION, +# HERDR_SOCKET_PATH, HERDR_TAB_ID, and HERDR_WORKSPACE_ID into every process it +# manages a pane for (docs/verification/runtime-backends.md), and a firstmate +# or secondmate agent's own tool calls inherit them. Older injection shapes are +# unverified and cannot establish launcher ancestry without both pane and +# socket identity. Workspace LABELS are mutable and herdr enforces no +# uniqueness on them, so a label search cannot tell one `firstmate` workspace +# from another, and herdr's globally focused workspace is whatever the captain +# happens to be looking at, not the launcher's. +# +# The injected HERDR_TAB_ID/HERDR_WORKSPACE_ID are deliberately NOT read as the +# answer. They are a snapshot taken when the pane's process started, and herdr +# can move a pane between tabs and workspaces afterwards without being able to +# rewrite a running process's environment. Only a live read is the CURRENT +# parent, which is what placement has to bind to. +# +# Sets, only on a 0 return: +# FM_BACKEND_HERDR_LAUNCHER_PANE_ID +# FM_BACKEND_HERDR_LAUNCHER_TAB_ID +# FM_BACKEND_HERDR_LAUNCHER_WORKSPACE_ID +# +# Returns: +# 0 - one exact, self-consistent launcher pane/tab/workspace in . +# 2 - this process is NOT running in a herdr pane (no HERDR_PANE_ID at all), +# so there is no launcher workspace to inherit and the caller falls back +# to its per-home container. HERDR_ENV=1 on its own is only a backend +# SELECTION marker (bin/fm-backend.sh's fm_backend_detect), never a +# parent binding - herdr always injects the pane id alongside it. +# 1 - a launcher pane IS claimed but its binding is missing, stale, +# contradictory, or belongs to another herdr session. The caller must +# refuse before creating or publishing any worker endpoint rather than +# degrading to a label search. +fm_backend_herdr_launcher_identity() { # + local session=$1 pane=${HERDR_PANE_ID:-} claimed_session claimed_socket session_socket + local pane_out tab_out list tab workspace + FM_BACKEND_HERDR_LAUNCHER_PANE_ID="" + FM_BACKEND_HERDR_LAUNCHER_TAB_ID="" + FM_BACKEND_HERDR_LAUNCHER_WORKSPACE_ID="" + [ -n "$pane" ] || return 2 + + # Same-session proof, before the pane id is trusted at all: herdr pane ids + # ("w2:p1") restart at the same low numbers in every session, so a pane id + # borrowed from another session can silently resolve to a real but unrelated + # workspace here. The injected socket path is the server identity herdr + # exposes, and the session name independently binds the named session. + claimed_session=$(fm_backend_herdr_session) + if [ "$claimed_session" != "$session" ]; then + echo "error: herdr launcher pane '$pane' reports session '$claimed_session' but this spawn targets session '$session'; refusing to place a worker from a cross-session parent identity" >&2 + return 1 + fi + claimed_socket=${HERDR_SOCKET_PATH:-} + if [ -z "$claimed_socket" ]; then + echo "error: herdr launcher pane '$pane' has no injected socket identity; refusing to place a worker from an unverifiable parent identity" >&2 + return 1 + fi + claimed_socket=$(fm_backend_herdr_canonical_socket_path "$claimed_socket") || { + echo "error: herdr launcher pane '$pane' reports an unusable socket path; refusing to place a worker from an unverifiable parent identity" >&2 + return 1 + } + session_socket=$(fm_backend_herdr_presentation_session_socket_path "$session") || { + echo "error: herdr session '$session' has no unambiguous socket to match against the launcher pane's own; refusing to place a worker from an unverifiable parent identity" >&2 + return 1 + } + if [ "$claimed_socket" != "$session_socket" ]; then + echo "error: herdr launcher pane '$pane' belongs to the server at '$claimed_socket', not session '$session' at '$session_socket'; refusing to place a worker from a cross-session parent identity" >&2 + return 1 + fi + + pane_out=$(fm_backend_herdr_cli "$session" pane get "$pane" 2>/dev/null) || { + echo "error: herdr launcher pane '$pane' could not be read in session '$session'; refusing to place a worker without its exact parent workspace" >&2 + return 1 + } + tab=$(printf '%s' "$pane_out" | jq -r --arg pane "$pane" ' + select(.result.pane.pane_id == $pane) + | select((.result.pane.tab_id | type) == "string" and (.result.pane.tab_id | length) > 0) + | .result.pane.tab_id + ' 2>/dev/null) + workspace=$(printf '%s' "$pane_out" | jq -r --arg pane "$pane" ' + select(.result.pane.pane_id == $pane) + | select((.result.pane.workspace_id | type) == "string" and (.result.pane.workspace_id | length) > 0) + | .result.pane.workspace_id + ' 2>/dev/null) + if [ -z "$tab" ] || [ -z "$workspace" ]; then + echo "error: herdr launcher pane '$pane' returned an ambiguous tab or workspace identity in session '$session'; refusing to place a worker without its exact parent workspace" >&2 + return 1 + fi + + # Independent second read: the tab must agree that it lives in the same + # workspace the pane just claimed. A restored-but-stale pane record that + # disagrees with its own tab is exactly the contradictory binding this must + # refuse rather than resolve. + tab_out=$(fm_backend_herdr_cli "$session" tab get "$tab" 2>/dev/null) || { + echo "error: herdr launcher tab '$tab' could not be read in session '$session'; refusing to place a worker without its exact parent workspace" >&2 + return 1 + } + if ! printf '%s' "$tab_out" | jq -e --arg tab "$tab" --arg workspace "$workspace" ' + .result.tab.tab_id == $tab and .result.tab.workspace_id == $workspace + ' >/dev/null 2>&1; then + echo "error: herdr launcher pane '$pane' and tab '$tab' disagree about their workspace in session '$session'; refusing to place a worker from a contradictory parent identity" >&2 + return 1 + fi + + list=$(fm_backend_herdr_cli "$session" workspace list 2>/dev/null) || { + echo "error: could not list herdr workspaces in session '$session' to confirm the launcher's own workspace '$workspace'; refusing to place a worker without its exact parent workspace" >&2 + return 1 + } + if ! printf '%s' "$list" | jq -e --arg workspace "$workspace" ' + (.result.workspaces | type) == "array" + and ([.result.workspaces[] | select(.workspace_id == $workspace)] | length) == 1 + ' >/dev/null 2>&1; then + echo "error: herdr launcher workspace '$workspace' is missing or duplicated in session '$session'; refusing to place a worker from a stale parent identity" >&2 + return 1 + fi + + # shellcheck disable=SC2034 # callers consume the verified binding's parts + FM_BACKEND_HERDR_LAUNCHER_PANE_ID=$pane + # shellcheck disable=SC2034 # callers consume the verified binding's parts + FM_BACKEND_HERDR_LAUNCHER_TAB_ID=$tab + FM_BACKEND_HERDR_LAUNCHER_WORKSPACE_ID=$workspace + return 0 } # fm_backend_herdr_workspace_prune_seeded_default_tab: close EXACTLY @@ -903,11 +1446,13 @@ fm_backend_herdr_workspace_prune_seeded_default_tab() { # , creating it in if absent. Must be called as a PLAIN -# STATEMENT, never through command substitution ($(...)) - it communicates -# through these globals, not solely through stdout, and a command -# substitution forks a subshell that would discard them: +# fm_backend_herdr_workspace_ensure: the workspace this spawn's task tab +# belongs in inside - the launching agent's own exact workspace when +# it has one, otherwise this HOME's persistent workspace, created in if +# absent. Must be called as a PLAIN STATEMENT, never through command +# substitution ($(...)) - it communicates through these globals, not solely +# through stdout, and a command substitution forks a subshell that would +# discard them: # FM_BACKEND_HERDR_WS_ID - the resolved workspace_id (also echoed, # for callers that only need the id) # FM_BACKEND_HERDR_WS_SEEDED_TAB_ID - non-empty ONLY when THIS call just @@ -919,11 +1464,14 @@ fm_backend_herdr_workspace_prune_seeded_default_tab() { # - local session=$1 cwd=$2 wsid out label +# +# (3rd arg, default "launcher-home") says whether the +# container being ensured belongs to the SAME firstmate home as the process +# calling this: +# launcher-home - a crewmate or scout for the caller's own home. When the +# caller is itself running in a herdr pane, the worker MUST +# land in that exact workspace +# (fm_backend_herdr_launcher_identity), never in whichever +# same-labeled workspace happens to sort first. +# other-home - a --secondmate launch, which stands up a DIFFERENT home's +# own per-home workspace by design. The launcher's workspace +# is deliberately not inherited here. +# With no herdr ancestry at all there is no launcher workspace to inherit, so +# the per-home label lookup below stays the resolver - but it must then resolve +# to exactly ONE workspace. Two same-labeled home workspaces with no launcher +# identity to disambiguate them is an unresolvable placement, and adopting +# either one is the very defect this refuses. +# +# Returns 0 on success, 3 for a refusal whose exact reason is already on +# stderr, and 1 for a failed or unparseable herdr call. +fm_backend_herdr_workspace_ensure() { # [] + local session=$1 cwd=$2 relationship=${3:-launcher-home} wsid out label matches count status FM_BACKEND_HERDR_WS_ID="" FM_BACKEND_HERDR_WS_SEEDED_TAB_ID="" - wsid=$(fm_backend_herdr_workspace_find "$session") + if [ "$relationship" = launcher-home ]; then + fm_backend_herdr_launcher_identity "$session" && status=0 || status=$? + case "$status" in + 0) + FM_BACKEND_HERDR_WS_ID=$FM_BACKEND_HERDR_LAUNCHER_WORKSPACE_ID + printf '%s' "$FM_BACKEND_HERDR_WS_ID" + return 0 + ;; + 2) ;; + *) return 3 ;; + esac + fi + label=$(fm_backend_herdr_workspace_label) + matches=$(fm_backend_herdr_workspace_find_all "$session") + count=$(printf '%s' "$matches" | grep -c '[^[:space:]]' || true) + if [ "$count" -gt 1 ]; then + echo "error: ${count} herdr workspaces in session '$session' are labeled '$label' (${matches//$'\n'/ }) and this spawn has no herdr parent pane to identify which one is its own; rename or close the extras, or run firstmate inside the workspace its workers belong in" >&2 + return 3 + fi + wsid=${matches%%$'\n'*} if [ -n "$wsid" ]; then FM_BACKEND_HERDR_WS_ID=$wsid printf '%s' "$wsid" return 0 fi - label=$(fm_backend_herdr_workspace_label) out=$(fm_backend_herdr_cli "$session" workspace create --cwd "$cwd" --label "$label" --no-focus 2>/dev/null) || return 1 wsid=$(printf '%s' "$out" | jq -r '.result.workspace.workspace_id // empty' 2>/dev/null) [ -n "$wsid" ] || return 1 @@ -974,13 +1560,18 @@ fm_backend_herdr_workspace_ensure() { # # CONTAINER=${RAW%%$'\t'*}; SEEDED_TAB_ID=${RAW#*$'\t'}. The seeded tab id # must be threaded through to fm_backend_herdr_create_task, which is the only # function allowed to prune it (fm_backend_herdr_workspace_prune_seeded_default_tab). -fm_backend_herdr_container_ensure() { # - local cwd=${1:-$PWD} session label +# is passed straight through to +# fm_backend_herdr_workspace_ensure, which owns its meaning. +fm_backend_herdr_container_ensure() { # [] + local cwd=${1:-$PWD} relationship=${2:-launcher-home} session label status fm_backend_herdr_version_check || return 1 session=$(fm_backend_herdr_session) fm_backend_herdr_server_ensure "$session" || return 1 - fm_backend_herdr_workspace_ensure "$session" "$cwd" >/dev/null || { label=$(fm_backend_herdr_workspace_label); echo "error: failed to ensure herdr workspace '$label' in session '$session'" >&2; return 1; } - if [ -z "$FM_BACKEND_HERDR_WS_ID" ]; then + fm_backend_herdr_workspace_ensure "$session" "$cwd" "$relationship" >/dev/null && status=0 || status=$? + # A 3 already reported the exact placement it refused to guess at; adding the + # generic message here would bury it. + [ "$status" -ne 3 ] || return 1 + if [ "$status" -ne 0 ] || [ -z "$FM_BACKEND_HERDR_WS_ID" ]; then label=$(fm_backend_herdr_workspace_label) echo "error: failed to ensure herdr workspace '$label' in session '$session'" >&2 return 1 @@ -988,6 +1579,43 @@ fm_backend_herdr_container_ensure() { # printf '%s:%s\t%s' "$session" "$FM_BACKEND_HERDR_WS_ID" "$FM_BACKEND_HERDR_WS_SEEDED_TAB_ID" } +# fm_backend_herdr_pane_presence_state: classify one exact pane get response +# as dead|present|unknown from its JSON body, never from process exit status. +fm_backend_herdr_pane_presence_state() { # + local session=$1 pane_id=$2 out code pid + out=$(fm_backend_herdr_cli "$session" pane get "$pane_id" 2>&1) + code=$(printf '%s' "$out" | jq -r '.error.code // empty' 2>/dev/null) + if [ -n "$code" ]; then + [ "$code" = "pane_not_found" ] && printf 'dead' || printf 'unknown' + return 0 + fi + pid=$(printf '%s' "$out" | jq -r '.result.pane.pane_id // empty' 2>/dev/null) + [ "$pid" = "$pane_id" ] && printf 'present' || printf 'unknown' +} + +fm_backend_herdr_workspace_presence_state() { # + local session=$1 workspace_id=$2 out matches + out=$(fm_backend_herdr_cli "$session" workspace list 2>&1) + matches=$(printf '%s' "$out" | jq -r --arg workspace "$workspace_id" ' + select((.result.workspaces | type) == "array") + | [.result.workspaces[] | select(.workspace_id == $workspace)] | length + ' 2>/dev/null) || matches= + case "$matches" in + 0) printf 'dead' ;; + 1) printf 'present' ;; + *) printf 'unknown' ;; + esac +} + +# fm_backend_herdr_explicit_close_pane_confirmed: issue one explicit close and +# succeed only when a structured follow-up proves the exact pane is gone. +fm_backend_herdr_explicit_close_pane_confirmed() { # + local session=$1 pane_id=$2 presence + fm_backend_herdr_cli "$session" pane close "$pane_id" >/dev/null 2>&1 || return 1 + presence=$(fm_backend_herdr_pane_presence_state "$session" "$pane_id") + [ "$presence" = dead ] +} + # fm_backend_herdr_pane_agent_state: classify in as one of # dead|no-agent|live|unknown, purely from the JSON body of two read-only # calls - never from process exit status, since a business-logic "not found" @@ -1020,24 +1648,13 @@ fm_backend_herdr_container_ensure() { # # refusal here, never toward closing - this is the conservative # backstop the husk check depends on. fm_backend_herdr_pane_agent_state() { # - local session=$1 pane_id=$2 out code pid status - # 2>&1, not 2>/dev/null: verified empirically that real herdr 0.7.1 writes - # an error response's JSON body to STDERR (success bodies go to stdout), so - # discarding stderr here would blind this function to exactly the - # error.code values (pane_not_found, agent_not_found) it exists to read - - # every OTHER call site in this file discards stderr safely only because - # its caller collapses both the error and the not-an-error paths to the - # same final answer, which this function's dead/no-agent/live/unknown - # distinction cannot afford to do. - out=$(fm_backend_herdr_cli "$session" pane get "$pane_id" 2>&1) - code=$(printf '%s' "$out" | jq -r '.error.code // empty' 2>/dev/null) - if [ -n "$code" ]; then - [ "$code" = "pane_not_found" ] && printf 'dead' || printf 'unknown' - return 0 - fi - pid=$(printf '%s' "$out" | jq -r '.result.pane.pane_id // empty' 2>/dev/null) - if [ "$pid" != "$pane_id" ]; then - printf 'unknown' + local session=$1 pane_id=$2 out code presence status + presence=$(fm_backend_herdr_pane_presence_state "$session" "$pane_id") + if [ "$presence" != present ]; then + case "$presence" in + dead|unknown) printf '%s' "$presence" ;; + *) printf 'unknown' ;; + esac return 0 fi out=$(fm_backend_herdr_cli "$session" agent get "$pane_id" 2>&1) @@ -1347,7 +1964,7 @@ fm_backend_herdr_projection_parent_workspace_exact() { # local session=$1 token=$2 workspace=$3 tab=$4 pane=$5 parent_workspace=$6 @@ -1373,7 +1990,6 @@ fm_backend_herdr_projection_live_binding_matches() { # $ % # are still # recognized after a bordered composer row has already been structurally found. -FM_BACKEND_HERDR_BARE_PROMPT_RE=${FM_BACKEND_HERDR_BARE_PROMPT_RE:-'^[❯›]'} +# Deliberately an alternation, not a `[...]` bracket expression: under a C/POSIX +# locale (LC_CTYPE=C, the fleet default), grep's bracket expressions match +# individual BYTES rather than whole multibyte characters, so `[❯›]` silently +# decomposes into the shared leading UTF-8 byte (0xE2) and spuriously matches +# ANY multibyte glyph in that range - including box-drawing corners like ╰, +# misclassifying a bordered composer's bottom border row as the bare shape. +# An alternation's branches are matched as whole literal byte sequences and +# stay correct regardless of locale. +FM_BACKEND_HERDR_BARE_PROMPT_RE=${FM_BACKEND_HERDR_BARE_PROMPT_RE:-'^(❯|›)'} # Pi allows a multi-line composer between its horizontal separators. Bound the # structural candidate so two unrelated transcript rules with an arbitrarily # large region between them can never be promoted into a composer. @@ -1995,7 +2619,7 @@ EOF fi # Delegate the empty/pending/unknown decision to the shared owner. The bare # shape only ever starts with an AGENT glyph (FM_BACKEND_HERDR_BARE_PROMPT_RE - # is '^[❯›]'), so a bare shell prompt never reaches here - it stays 'unknown' + # is '^(❯|›)'), so a bare shell prompt never reaches here - it stays 'unknown' # via the no-composer-row path above, exactly as before. fm_composer_classify_content "$bordered" "$stripped" "$FM_BACKEND_HERDR_IDLE_RE" } @@ -2091,9 +2715,101 @@ fm_backend_herdr_send_text_submit() { # # fm_backend_herdr_kill: remove the task's pane, best-effort (mirrors # tmux-kill-window's `|| true` contract). Verified: closing a tab's only pane # closes the tab too, so a separate tab close is unnecessary. +# When the close would empty a non-focused workspace, Herdr 0.7.5's explicit +# close moves focus to that workspace's neighbor with no restore anywhere in +# this path, so the kill follows the same focus-safe removal plan as +# projected cleanup (a verified pane-death removal with the doomed workspace +# repositioned behind the focused one when needed), keeping the exact-tab +# restore as the backstop. A close that empties the FOCUSED workspace moves +# focus legitimately, and every in-lock planning ambiguity or failure falls +# back to the plain close, matching the pre-hardening contract. +fm_backend_herdr_kill_serialized() { # + local session=$1 pane=$2 + local before active_tab info target_pane target_tab target_ws plan shell_pid plan_move_record close_failed workspace_presence + before=$(fm_backend_herdr_projection_focus_snapshot "$session") || before= + if [ -n "$before" ]; then + active_tab=${before#*$'\t'} + info=$(fm_backend_herdr_cli "$session" pane get "$pane" 2>/dev/null) || info= + target_pane=$(printf '%s' "$info" | jq -r '.result.pane.pane_id // empty' 2>/dev/null) + target_tab=$(printf '%s' "$info" | jq -r '.result.pane.tab_id // empty' 2>/dev/null) + target_ws=$(printf '%s' "$info" | jq -r '.result.pane.workspace_id // empty' 2>/dev/null) + if [ "$target_pane" = "$pane" ] && [ -n "$target_tab" ] && [ "$target_tab" != "$active_tab" ]; then + plan=$(fm_backend_herdr_emptying_close_plan "$session" "$pane" "$target_ws" "$target_tab" "${before%%$'\t'*}") + plan_move_record= + case "$plan" in + moved$'\t'*) + plan_move_record=${plan%%$'\n'*} + plan=${plan##*$'\n'} + ;; + esac + close_failed=0 + case "$plan" in + death\ *) + shell_pid=${plan#death } + if ! fm_backend_herdr_death_close_pane "$session" "$pane" "$shell_pid" \ + && ! fm_backend_herdr_explicit_close_pane_confirmed "$session" "$pane"; then + close_failed=1 + fi + ;; + *) + fm_backend_herdr_explicit_close_pane_confirmed "$session" "$pane" || close_failed=1 + ;; + esac + if [ "$close_failed" = 0 ] && [ -n "$plan_move_record" ]; then + workspace_presence=$(fm_backend_herdr_workspace_presence_state "$session" "$target_ws") + if [ "$workspace_presence" != dead ]; then + echo "warning: herdr task kill did not confirm removal of the repositioned workspace" >&2 + close_failed=1 + fi + fi + if [ "$close_failed" = 1 ]; then + fm_backend_herdr_emptying_move_rollback "$plan_move_record" || true + fi + fm_backend_herdr_projection_focus_restore "$session" "$before" "task kill" || true + return 0 + fi + fi + fm_backend_herdr_explicit_close_pane_confirmed "$session" "$pane" || true +} + fm_backend_herdr_kill() { # fm_backend_herdr_target_ready "$1" || return 0 - fm_backend_herdr_cli "$FM_BACKEND_HERDR_SESSION" pane close "$FM_BACKEND_HERDR_PANE" >/dev/null 2>&1 || true + local session=$FM_BACKEND_HERDR_SESSION pane=$FM_BACKEND_HERDR_PANE + local lock_path attempt=0 lock_held=0 + if ! declare -F fm_lock_try_acquire >/dev/null 2>&1; then + # shellcheck source=bin/fm-wake-lib.sh + . "$FM_BACKEND_HERDR_ROOT/bin/fm-wake-lib.sh" + fi + if lock_path=$(fm_backend_herdr_presentation_session_lock_path "$session"); then + while [ "$attempt" -lt 50 ]; do + if fm_lock_try_acquire "$lock_path"; then + lock_held=1 + break + fi + sleep 0.1 + attempt=$((attempt + 1)) + done + fi + if [ "$lock_held" = 1 ]; then + fm_backend_herdr_kill_serialized "$session" "$pane" + fm_lock_release "$lock_path" || true + else + echo "warning: herdr task kill could not acquire its session presentation lock; refusing an unlocked pane close" >&2 + fi +} + +# fm_backend_herdr_endpoint_confirmed_gone: gate durable-record removal on +# the exact recorded pane's structured presence +# (fm_backend_herdr_pane_presence_state), read-only, so a refused, skipped, +# or failed close never erases a live task's endpoint identity. +# Only a structured pane_not_found proves the endpoint gone; present and +# unknown presence refuse after every close path, and a missing or malformed +# target identity is ambiguity that also refuses, never proof of a gone pane. +fm_backend_herdr_endpoint_confirmed_gone() { # + local presence + fm_backend_herdr_parse_target "$1" || return 1 + presence=$(fm_backend_herdr_pane_presence_state "$FM_BACKEND_HERDR_SESSION" "$FM_BACKEND_HERDR_PANE") + [ "$presence" = dead ] } # fm_backend_herdr_classify_agent_status: map a raw `agent get` agent_status diff --git a/bin/backends/tmux.sh b/bin/backends/tmux.sh index 3423fb4227..1ac771dfda 100644 --- a/bin/backends/tmux.sh +++ b/bin/backends/tmux.sh @@ -117,10 +117,22 @@ fm_backend_tmux_send_literal() { # tmux send-keys -t "$1" -l "$2" } -# fm_backend_tmux_kill: remove the task's window, best-effort. Mirrors -# fm-teardown.sh's `tmux kill-window -t "$T" 2>/dev/null || true`. +# fm_backend_tmux_kill: remove one explicitly named task window, best-effort. +# Empty, omitted, and malformed targets return nonzero before invoking tmux so +# tmux can never interpret an empty target as the caller's current window. fm_backend_tmux_kill() { # - tmux kill-window -t "$1" 2>/dev/null || true + local target=${1:-} session window + case "$target" in + *:*) + session=${target%%:*} + window=${target#*:} + ;; + *) return 1 ;; + esac + case "$session:$window" in + :*|*:|*:*:*) return 1 ;; + esac + tmux kill-window -t "=$session:=$window" 2>/dev/null || true } # fm_backend_tmux_current_command: 's live foreground process name - diff --git a/bin/fm-afk-launch.sh b/bin/fm-afk-launch.sh index 57b7f6590d..4be7d6a349 100755 --- a/bin/fm-afk-launch.sh +++ b/bin/fm-afk-launch.sh @@ -48,6 +48,28 @@ set -u FM_AFK_LAUNCH_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" FM_ROOT="${FM_ROOT_OVERRIDE:-$(cd "$FM_AFK_LAUNCH_DIR/.." && pwd)}" FM_HOME="${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}}" +case "$FM_HOME" in + /*) ;; + *) + FM_AFK_LAUNCH_HOME_INPUT=$FM_HOME + FM_HOME=$(CDPATH='' cd -- "$FM_AFK_LAUNCH_HOME_INPUT" 2>/dev/null && pwd -P) || { + echo "error: FM_HOME directory cannot be resolved: $FM_AFK_LAUNCH_HOME_INPUT" >&2 + exit 1 + } + ;; +esac +if [ -n "${FM_STATE_OVERRIDE:-}" ]; then + case "$FM_STATE_OVERRIDE" in + /*) ;; + *) + FM_AFK_LAUNCH_STATE_INPUT=$FM_STATE_OVERRIDE + FM_STATE_OVERRIDE=$(CDPATH='' cd -- "$FM_AFK_LAUNCH_STATE_INPUT" 2>/dev/null && pwd -P) || { + echo "error: FM_STATE_OVERRIDE directory cannot be resolved: $FM_AFK_LAUNCH_STATE_INPUT" >&2 + exit 1 + } + ;; + esac +fi FM_AFK_LAUNCH_STATE="${FM_STATE_OVERRIDE:-$FM_HOME/state}" FM_AFK_LAUNCH_RECORD="$FM_AFK_LAUNCH_STATE/.afk-daemon-terminal" FM_AFK_LAUNCH_LOCK="$FM_AFK_LAUNCH_STATE/.afk-launch.lock" @@ -606,10 +628,15 @@ fm_afk_launch_stop() { fm_afk_launch_main() { local result - fm_afk_launch_lock_acquire || return 1 + # Traps first, lock second. Acquiring before the handlers exist leaves a + # window where a signal terminates this process by default action and leaks + # the lock directory, which then blocks the next away-mode launch until the + # stale-owner reclaim path clears it. fm_afk_launch_lock_release only removes + # a lock this process owns, so arming it before acquisition is safe. trap fm_afk_launch_lock_release EXIT trap 'exit 130' INT trap 'exit 143' TERM + fm_afk_launch_lock_acquire || return 1 case "${1:-start}" in start) fm_afk_launch_start ;; start-native) fm_afk_launch_start_native ;; diff --git a/bin/fm-backend.sh b/bin/fm-backend.sh index 2112a53279..e505b99f75 100644 --- a/bin/fm-backend.sh +++ b/bin/fm-backend.sh @@ -360,6 +360,177 @@ fm_backend_target_of_meta() { # [ -n "$window" ] && printf '%s' "$window" } +# fm_backend_validate_task_endpoint: validate a task cleanup record entirely +# from its durable metadata before any runtime command or cleanup mutation. +# The validation binds the exact task id, selected backend, target, project, +# and worktree. New non-tmux records carry endpoint_task_id because their +# opaque runtime ids do not encode the task label. Legacy tmux records remain +# valid only when their window name itself is exactly fm-. +# On success, sets FM_BACKEND_VALIDATED_BACKEND and +# FM_BACKEND_VALIDATED_TARGET. On failure, prints one refusal and returns 1. +fm_backend_meta_exact_value() { # + local meta=$1 key=$2 count value + count=$(grep -c "^$key=" "$meta" 2>/dev/null || true) + [ "$count" -eq 1 ] || return 1 + value=$(grep "^$key=" "$meta" | cut -d= -f2-) + [ -n "$value" ] || return 1 + printf '%s' "$value" +} + +fm_backend_endpoint_atom_valid() { # + case "$1" in + ''|*[!A-Za-z0-9._@%+-]*) return 1 ;; + esac +} + +fm_backend_validate_task_endpoint() { # + local meta=$1 id=$2 backend_count backend window worktree project binding_count binding + local session pane recorded_session workspace tab terminal worktree_id surface + FM_BACKEND_VALIDATED_BACKEND= + FM_BACKEND_VALIDATED_TARGET= + [ -f "$meta" ] && [ ! -L "$meta" ] || { + echo "REFUSED: task $id has no regular endpoint metadata at $meta; preserving task state." >&2 + return 1 + } + case "$id" in ''|*[!A-Za-z0-9._-]*) + echo "REFUSED: task endpoint identity has an invalid task id; preserving task state." >&2 + return 1 + esac + window=$(fm_backend_meta_exact_value "$meta" window) || { + echo "REFUSED: task $id has a missing, empty, or ambiguous window endpoint; preserving task state." >&2 + return 1 + } + worktree=$(fm_backend_meta_exact_value "$meta" worktree) || { + echo "REFUSED: task $id has a missing, empty, or ambiguous worktree identity; preserving task state." >&2 + return 1 + } + project=$(fm_backend_meta_exact_value "$meta" project) || { + echo "REFUSED: task $id has a missing, empty, or ambiguous project identity; preserving task state." >&2 + return 1 + } + case "$worktree$project$window" in *$'\n'*|*$'\r'*|*$'\t'*) + echo "REFUSED: task $id has malformed endpoint metadata; preserving task state." >&2 + return 1 + esac + backend_count=$(grep -c '^backend=' "$meta" 2>/dev/null || true) + case "$backend_count" in + 0) backend=tmux ;; + 1) backend=$(fm_backend_meta_exact_value "$meta" backend) || backend= ;; + *) backend= ;; + esac + if [ -z "$backend" ] || ! fm_backend_is_known "$backend"; then + echo "REFUSED: task $id has a missing, ambiguous, or unknown backend identity; preserving task state." >&2 + return 1 + fi + binding_count=$(grep -c '^endpoint_task_id=' "$meta" 2>/dev/null || true) + case "$binding_count" in + 0) binding= ;; + 1) + binding=$(fm_backend_meta_exact_value "$meta" endpoint_task_id) || { + echo "REFUSED: task $id has an empty endpoint task binding; preserving task state." >&2 + return 1 + } + ;; + *) + echo "REFUSED: task $id has an ambiguous endpoint task binding; preserving task state." >&2 + return 1 + ;; + esac + if [ -n "$binding" ] && [ "$binding" != "$id" ]; then + echo "REFUSED: endpoint metadata belongs to task $binding, not $id; preserving task state." >&2 + return 1 + fi + + case "$backend" in + tmux) + session=${window%%:*} + pane=${window#*:} + if [ "$pane" = "$window" ] || [ "$pane" != "fm-$id" ] \ + || [ -z "$session" ]; then + echo "REFUSED: tmux endpoint '$window' is malformed or does not belong to task $id; preserving task state." >&2 + return 1 + fi + ;; + herdr) + [ "$binding" = "$id" ] || { + echo "REFUSED: legacy Herdr endpoint metadata for task $id lacks an exact task binding; preserving task state." >&2 + return 1 + } + recorded_session=$(fm_backend_meta_exact_value "$meta" herdr_session) || recorded_session= + workspace=$(fm_backend_meta_exact_value "$meta" herdr_workspace_id) || workspace= + tab=$(fm_backend_meta_exact_value "$meta" herdr_tab_id) || tab= + pane=$(fm_backend_meta_exact_value "$meta" herdr_pane_id) || pane= + if [ -z "$recorded_session" ] || [ -z "$workspace" ] || [ -z "$tab" ] || [ -z "$pane" ] \ + || [ "$window" != "$recorded_session:$pane" ] \ + || ! fm_backend_endpoint_atom_valid "$recorded_session" \ + || ! fm_backend_endpoint_atom_valid "$workspace" \ + || ! fm_backend_endpoint_atom_valid "${tab//:/_}" \ + || ! fm_backend_endpoint_atom_valid "${pane//:/_}"; then + echo "REFUSED: Herdr endpoint metadata for task $id is malformed or inconsistent; preserving task state." >&2 + return 1 + fi + ;; + zellij) + [ "$binding" = "$id" ] || { + echo "REFUSED: legacy Zellij endpoint metadata for task $id lacks an exact task binding; preserving task state." >&2 + return 1 + } + recorded_session=$(fm_backend_meta_exact_value "$meta" zellij_session) || recorded_session= + tab=$(fm_backend_meta_exact_value "$meta" zellij_tab_id) || tab= + pane=$(fm_backend_meta_exact_value "$meta" zellij_pane_id) || pane= + case "$tab:$pane" in *[!0-9:]*) tab= ;; esac + if [ -z "$recorded_session" ] || [ -z "$tab" ] || [ -z "$pane" ] \ + || [ "$window" != "$recorded_session:$pane" ] \ + || ! fm_backend_endpoint_atom_valid "$recorded_session"; then + echo "REFUSED: Zellij endpoint metadata for task $id is malformed or inconsistent; preserving task state." >&2 + return 1 + fi + ;; + orca) + [ "$binding" = "$id" ] || { + echo "REFUSED: legacy Orca endpoint metadata for task $id lacks an exact task binding; preserving task state." >&2 + return 1 + } + terminal=$(fm_backend_meta_exact_value "$meta" terminal) || terminal= + worktree_id=$(fm_backend_meta_exact_value "$meta" orca_worktree_id) || worktree_id= + [ -n "$terminal" ] || { + echo "REFUSED: missing terminal in $meta; cannot close Orca endpoint; preserving task state." >&2 + return 1 + } + [ -n "$worktree_id" ] || { + echo "REFUSED: missing orca_worktree_id in $meta; cannot remove Orca worktree; preserving task state." >&2 + return 1 + } + if [ "$window" != "fm-$id" ] \ + || ! fm_backend_endpoint_atom_valid "$terminal" \ + || ! fm_backend_endpoint_atom_valid "$worktree_id"; then + echo "REFUSED: Orca endpoint metadata for task $id is malformed or inconsistent; preserving task state." >&2 + return 1 + fi + window=$terminal + ;; + cmux) + [ "$binding" = "$id" ] || { + echo "REFUSED: legacy cmux endpoint metadata for task $id lacks an exact task binding; preserving task state." >&2 + return 1 + } + workspace=$(fm_backend_meta_exact_value "$meta" cmux_workspace_id) || workspace= + surface=$(fm_backend_meta_exact_value "$meta" cmux_surface_id) || surface= + if [ -z "$workspace" ] || [ -z "$surface" ] || [ "$window" != "$workspace:$surface" ] \ + || ! fm_backend_endpoint_atom_valid "$workspace" \ + || ! fm_backend_endpoint_atom_valid "$surface"; then + echo "REFUSED: cmux endpoint metadata for task $id is malformed or inconsistent; preserving task state." >&2 + return 1 + fi + ;; + esac + # shellcheck disable=SC2034 # Output globals are consumed by sourcing callers. + FM_BACKEND_VALIDATED_BACKEND=$backend + # shellcheck disable=SC2034 # Output globals are consumed by sourcing callers. + FM_BACKEND_VALIDATED_TARGET=$window + return 0 +} + fm_backend_meta_for_window() { # local target=$1 state=$2 meta window terminal for meta in "$state"/*.meta; do @@ -573,6 +744,7 @@ fm_backend_send_text_submit() { # local backend=$1 shift + [ -n "${1:-}" ] || { echo "error: refusing empty backend kill target" >&2; return 1; } fm_backend_source "$backend" || return 1 case "$backend" in tmux) fm_backend_tmux_kill "$@" ;; diff --git a/bin/fm-backlog-handoff.sh b/bin/fm-backlog-handoff.sh index 66da67b8a8..29ab9cd2d6 100755 --- a/bin/fm-backlog-handoff.sh +++ b/bin/fm-backlog-handoff.sh @@ -53,21 +53,19 @@ REG="$DATA/secondmates.md" MAIN_BACKLOG="$DATA/backlog.md" # shellcheck source=bin/fm-tasks-axi-lib.sh disable=SC1091 . "$SCRIPT_DIR/fm-tasks-axi-lib.sh" +# shellcheck source=bin/fm-secondmate-registry-lib.sh +. "$SCRIPT_DIR/fm-secondmate-registry-lib.sh" [ $# -ge 2 ] || { echo "usage: fm-backlog-handoff.sh ..." >&2; exit 1; } ID=$1 shift secondmate_home() { - local id=$1 line + local id=$1 home [ -f "$REG" ] || { echo "error: no secondmate registry at $REG" >&2; return 1; } - line=$(grep -E "^- $id( |$)" "$REG" | tail -1 || true) - [ -n "$line" ] || { echo "error: secondmate $id is not registered in $REG" >&2; return 1; } - # Match the (home: ...) field itself; do not require zero parentheses before it. - # Summary/scope prose often contains parentheticals (e.g. "(id is legacy)"), and - # ^[^(]* would leave those entries looking like "has no home". Greedy prefix so the - # last (home: ...) on the line wins. Empty when the field is absent. - printf '%s\n' "$line" | sed -n 's/.*(home:[[:space:]]*\([^;)]*\);.*/\1/p' | sed 's/[[:space:]]*$//' + home=$(secondmate_registry_field "$REG" "$id" home || true) + [ -n "$home" ] || { echo "error: secondmate $id has no home in $REG" >&2; return 1; } + printf '%s\n' "$home" } path_is_ancestor_of() { diff --git a/bin/fm-bootstrap.sh b/bin/fm-bootstrap.sh index c86b7e839a..b18db7ea1b 100755 --- a/bin/fm-bootstrap.sh +++ b/bin/fm-bootstrap.sh @@ -8,6 +8,7 @@ # Lines: "MISSING: (install: )", # "MISSING_MANUAL: (instructions: )", "NEEDS_GH_AUTH", # "BACKEND_INVALID: (known: )", +# "STARTUP_MEMORY_BUDGET: invalid config/startup-memory-budget - ", # "CREW_DISPATCH: invalid config/crew-dispatch.json - ", # "FLEET_SYNC: : skipped|recovered|STUCK: ", # "PR_CHECK_MIGRATION: ", @@ -54,7 +55,15 @@ # 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 .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. +# 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 +# await the primary-authoritative inherited value instead of creating +# their own. # X mode is OPTIONAL and inert unless FM_HOME/.env has a non-empty # FMX_PAIRING_TOKEN. When opted in, bootstrap requires curl+jq, writes # the relay poll shim and 30s cadence config, and prints an FMX line. @@ -91,12 +100,16 @@ STATE="${FM_STATE_OVERRIDE:-$FM_HOME/state}" DATA="${FM_DATA_OVERRIDE:-$FM_HOME/data}" # shellcheck source=bin/fm-tasks-axi-lib.sh disable=SC1091 . "$SCRIPT_DIR/fm-tasks-axi-lib.sh" +# shellcheck source=bin/fm-quota-axi-lib.sh disable=SC1091 +. "$SCRIPT_DIR/fm-quota-axi-lib.sh" # shellcheck source=bin/fm-tangle-lib.sh disable=SC1091 . "$SCRIPT_DIR/fm-tangle-lib.sh" # shellcheck source=bin/fm-ff-lib.sh disable=SC1091 . "$SCRIPT_DIR/fm-ff-lib.sh" # shellcheck source=bin/fm-config-inherit-lib.sh disable=SC1091 . "$SCRIPT_DIR/fm-config-inherit-lib.sh" +# shellcheck source=bin/fm-startup-memory-budget-lib.sh disable=SC1091 +. "$SCRIPT_DIR/fm-startup-memory-budget-lib.sh" # shellcheck source=bin/fm-x-lib.sh disable=SC1091 . "$SCRIPT_DIR/fm-x-lib.sh" # shellcheck source=bin/fm-backend.sh disable=SC1091 @@ -522,31 +535,31 @@ if ! BACKEND_TOOLS=$(fm_backend_required_tools "$BACKEND"); then BACKEND_TOOLS="" fi TOOLS="$BACKEND_TOOLS $COMMON_TOOLS" -NO_MISTAKES_MIN_MAJOR=1 -NO_MISTAKES_MIN_MINOR=31 -NO_MISTAKES_MIN_PATCH=2 +NO_MISTAKES_MIN=1.31.2 treehouse_supports_lease() { treehouse get --help 2>&1 | grep -Eq '(^|[^[:alnum:]_-])--lease([^[:alnum:]_-]|$)' } -no_mistakes_version_parts() { - local output - command -v no-mistakes >/dev/null 2>&1 || return 1 - output=$(no-mistakes --version 2>/dev/null) || return 1 - printf '%s\n' "$output" | sed -nE 's/.*[vV]?([0-9]+)\.([0-9]+)\.([0-9]+).*/\1 \2 \3/p' | head -n 1 -} - -no_mistakes_compatible() { - local parts major minor patch extra - parts=$(no_mistakes_version_parts) || return 1 +# Shared semantic-version floor for the tool gates below. A version string that +# cannot be parsed into exactly one major.minor.patch triple is incompatible, +# never assumed current, so a development or vendored build cannot pass a floor +# it was never checked against. +tool_version_at_least() { # + local tool=$1 min=$2 output parts major minor patch extra + local min_major min_minor min_patch min_extra + command -v "$tool" >/dev/null 2>&1 || return 1 + output=$("$tool" --version 2>/dev/null) || return 1 + parts=$(printf '%s\n' "$output" | sed -nE 's/.*[vV]?([0-9]+)\.([0-9]+)\.([0-9]+).*/\1 \2 \3/p' | head -n 1) IFS=' ' read -r major minor patch extra <<< "$parts" [ -n "$major" ] && [ -n "$minor" ] && [ -n "$patch" ] && [ -z "$extra" ] || return 1 - [ "$major" -gt "$NO_MISTAKES_MIN_MAJOR" ] && return 0 - [ "$major" -eq "$NO_MISTAKES_MIN_MAJOR" ] || return 1 - [ "$minor" -gt "$NO_MISTAKES_MIN_MINOR" ] && return 0 - [ "$minor" -eq "$NO_MISTAKES_MIN_MINOR" ] || return 1 - [ "$patch" -ge "$NO_MISTAKES_MIN_PATCH" ] + IFS='.' read -r min_major min_minor min_patch min_extra <<< "$min" + [ -n "$min_major" ] && [ -n "$min_minor" ] && [ -n "$min_patch" ] && [ -z "$min_extra" ] || return 1 + [ "$major" -gt "$min_major" ] && return 0 + [ "$major" -eq "$min_major" ] || return 1 + [ "$minor" -gt "$min_minor" ] && return 0 + [ "$minor" -eq "$min_minor" ] || return 1 + [ "$patch" -ge "$min_patch" ] } x_mode_write_if_changed() { @@ -620,7 +633,7 @@ x_mode_remove_artifact() { # applying a cadence transition to a running watcher is the caller's job via # the emitted harness-aware supervision repair instruction. x_mode_setup() { - local env_file token shim cadence shim_body cadence_body tool missing + local env_file token shim cadence shim_body cadence_body tool missing shim_home env_file="$FM_HOME/.env" shim="$STATE/x-watch.check.sh" cadence="$CONFIG/x-mode.env" @@ -683,9 +696,16 @@ x_mode_setup() { mkdir -p "$STATE" "$CONFIG" 2>/dev/null || { fmx_arm_failed; return 0; } - shim_body=$(fmx_poll_shim_content "$FM_HOME" "$FM_ROOT") + case "$FM_HOME" in + /*) shim_home=$FM_HOME ;; + *) + shim_home=$(CDPATH='' cd -- "$FM_HOME" 2>/dev/null && pwd -P) \ + || { fmx_arm_failed; return 0; } + ;; + esac + shim_body=$(fmx_poll_shim_content "$shim_home" "$FM_ROOT") x_mode_write_if_changed "$shim" "$shim_body" 700 || { fmx_arm_failed; return 0; } - fmx_poll_shim_valid "$shim" "$FM_HOME" "$FM_ROOT" \ + fmx_poll_shim_valid "$shim" "$shim_home" "$FM_ROOT" \ || { fmx_arm_failed; return 0; } cadence_body=$(cat <<'EOF' @@ -714,7 +734,7 @@ crew_dispatch_validate() { return 0 fi err=$(jq -r ' - def verified($h): ["claude","codex","opencode","pi","pi-signed","grok","kimi"] | index($h); + def verified($h): ["claude","codex","opencode","pi","pi-signed","grok","kimi","agy"] | index($h); def effort_ok($h; $e): if $e == null then true elif ($e | type) != "string" then false @@ -722,6 +742,7 @@ crew_dispatch_validate() { elif $h == "codex" then (["low","medium","high","xhigh"] | index($e)) elif $h == "grok" then (["low","medium","high"] | index($e)) elif $h == "pi" or $h == "pi-signed" then (["low","medium","high","xhigh","max"] | index($e)) + elif $h == "agy" then (["low","medium","high","xhigh","max"] | index($e)) elif $h == "opencode" or $h == "kimi" then false else true end; @@ -798,6 +819,18 @@ crew_dispatch_validate() { fi } +startup_memory_budget_setup() { + # Primary bootstrap owns default publication. A secondmate is deliberately + # passive here because its setting must converge from the primary through the + # inherited-local-material contract rather than becoming a local authority. + if [ -e "$FM_HOME/.fm-secondmate-home" ] || [ -L "$FM_HOME/.fm-secondmate-home" ]; then + return 0 + fi + if ! fm_startup_memory_budget_materialize "$CONFIG"; then + echo "STARTUP_MEMORY_BUDGET: invalid config/$FM_STARTUP_MEMORY_BUDGET_FILE - $FM_STARTUP_MEMORY_BUDGET_ERROR" + fi +} + if [ "${1:-}" = "install" ]; then shift [ $# -gt 0 ] || { echo "usage: fm-bootstrap.sh install ..." >&2; exit 1; } @@ -820,6 +853,7 @@ fi # runnable. Detect-only sessions never touch state. if [ "${FM_BOOTSTRAP_DETECT_ONLY:-0}" != 1 ]; then "$SCRIPT_DIR/fm-pr-check-migrate.sh" || true + startup_memory_budget_setup fi if [ "$BACKEND_VALID" -eq 0 ]; then @@ -839,9 +873,12 @@ if fm_backend_list_contains "$TOOLS" treehouse \ && command -v treehouse >/dev/null 2>&1 && ! treehouse_supports_lease; then echo "MISSING: treehouse (install: $(install_cmd treehouse))" fi -if command -v no-mistakes >/dev/null 2>&1 && ! no_mistakes_compatible; then +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 quota-axi >/dev/null 2>&1 && ! fm_quota_axi_compatible; then + echo "MISSING: quota-axi (install: $(install_cmd quota-axi))" +fi if command -v tasks-axi >/dev/null 2>&1 && ! fm_tasks_axi_compatible; then echo "MISSING: tasks-axi (install: $(install_cmd tasks-axi))" fi diff --git a/bin/fm-brief.sh b/bin/fm-brief.sh index 00ea34ddab..fc289dd6e8 100755 --- a/bin/fm-brief.sh +++ b/bin/fm-brief.sh @@ -66,10 +66,31 @@ esac # shellcheck source=bin/fm-classify-lib.sh . "$SCRIPT_DIR/fm-classify-lib.sh" PAUSED_VERB=${FM_CLASSIFY_PAUSED_VERB:-$FM_CLASSIFY_PAUSED_VERB_DEFAULT} + +resolve_directory_input() { + local name=$1 path=$2 resolved + case "$path" in + /*) printf '%s\n' "$path"; return 0 ;; + esac + resolved=$(CDPATH='' cd -- "$path" 2>/dev/null && pwd -P) || { + echo "error: $name directory cannot be resolved: $path" >&2 + return 1 + } + printf '%s\n' "$resolved" +} + FM_ROOT="${FM_ROOT_OVERRIDE:-$(cd "$SCRIPT_DIR/.." && pwd)}" -FM_HOME="${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}}" -DATA="${FM_DATA_OVERRIDE:-$FM_HOME/data}" -STATE="${FM_STATE_OVERRIDE:-$FM_HOME/state}" +FM_HOME=$(resolve_directory_input FM_HOME "${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}}") || exit 1 +if [ -n "${FM_DATA_OVERRIDE:-}" ]; then + DATA=$(resolve_directory_input FM_DATA_OVERRIDE "$FM_DATA_OVERRIDE") || exit 1 +else + DATA="$FM_HOME/data" +fi +if [ -n "${FM_STATE_OVERRIDE:-}" ]; then + STATE=$(resolve_directory_input FM_STATE_OVERRIDE "$FM_STATE_OVERRIDE") || exit 1 +else + STATE="$FM_HOME/state" +fi KIND=ship HERDR_LAB=0 NO_PROJECTS=0 @@ -217,13 +238,13 @@ HERDR_SECTION=$(printf '%s\n' \ 'Never bypass the helper, even for a read-only lifecycle probe or cleanup after failure.' \ 'The captain fleet uses the running `default` session.') else -HERDR_SECTION=$(cat <<'EOF' +IFS= read -r -d '' HERDR_SECTION <<'EOF' || true # Herdr lifecycle declaration - NOT ENABLED **HARD SAFETY GATE:** this scaffold cannot inspect the task text that replaces `{TASK}` later. If the task will start, stop, delete, restart, profile, or otherwise drive Herdr lifecycle behavior, stop and regenerate the brief with `--herdr-lab` before dispatch. Do not add Herdr lifecycle commands to this unguarded brief by hand. EOF -) +HERDR_SECTION=${HERDR_SECTION%$'\n'} fi if [ "$KIND" = scout ]; then @@ -285,19 +306,18 @@ case "$MODE" in direct-PR) SETUP2="" RULE1='1. Never push to the default branch (push only your `fm/'"$ID"'` branch). Never merge a PR.' - DOD=$(cat < "$BRIEF" < [--state busy|idle|unknown] [--source S] [--event E] +# Mint a fresh incarnation gen token, write the gen sidecar, and seed +# the record at seq=1 (default: busy, source fm-spawn, event +# launch-brief - the launch prompt IS a submitted turn). Prints the +# minted gen on stdout so the caller can embed it into adapter wiring. +# Arming again replaces the previous incarnation: late events carrying +# the old gen are rejected as stale from then on. +# +# apply (--gen G | --current-gen) +# --source S --event E +# Append one lifecycle event: validate the gen against the armed +# sidecar, advance seq under the lock, atomically replace the record. +# Adapter wiring passes the exact --gen embedded at arm time, so a +# hook that outlives its incarnation fails closed here. Firstmate-owned +# paths (fm-interrupt, fm-recovery) may pass --current-gen to bind to +# whatever incarnation is armed right now. +# +# retire (--gen G | --current-gen) +# Remove one incarnation's sidecar and record while holding the same +# writer lock used by arm and apply. An exact gen prevents teardown for +# an old task from retiring a newly armed incarnation. A missing sidecar +# is already retired, so any orphan record is removed idempotently. +# +# Exit codes: 0 applied; 1 refused (stale gen, unarmed task, lock timeout, +# invalid input); 2 usage. Adapter hook command lines append `|| true` so a +# refusal never breaks the harness's own lifecycle. +set -u + +usage() { + cat >&2 <<'EOF' +usage: + fm-busy-event.sh arm [--state busy|idle|unknown] [--source S] [--event E] + fm-busy-event.sh apply (--gen G | --current-gen) --source S --event E + fm-busy-event.sh retire (--gen G | --current-gen) +See the header comment for the full contract. +EOF + exit 2 +} + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=bin/fm-busy-lib.sh +. "$SCRIPT_DIR/fm-busy-lib.sh" + +CMD=${1:-} +case "$CMD" in + arm|apply|retire) shift ;; + *) usage ;; +esac + +STATE=${1:-} +ID=${2:-} +[ -n "$STATE" ] && [ -n "$ID" ] || usage +shift 2 +case "$ID" in *[!A-Za-z0-9._-]*) echo "error: invalid task id" >&2; exit 1 ;; esac +[ -d "$STATE" ] || { echo "error: state dir not found: $STATE" >&2; exit 1; } + +NEW_STATE= +GEN= +USE_CURRENT_GEN=0 +SOURCE= +EVENT= +if [ "$CMD" = apply ]; then + NEW_STATE=${1:-} + case "$NEW_STATE" in busy|idle|unknown) shift ;; *) usage ;; esac +elif [ "$CMD" = arm ]; then + NEW_STATE=busy + SOURCE=fm-spawn + EVENT=launch-brief +fi +while [ $# -gt 0 ]; do + case "$1" in + --state) NEW_STATE=${2:-}; shift 2 || usage ;; + --gen) GEN=${2:-}; shift 2 || usage ;; + --current-gen) USE_CURRENT_GEN=1; shift ;; + --source) SOURCE=${2:-}; shift 2 || usage ;; + --event) EVENT=${2:-}; shift 2 || usage ;; + *) usage ;; + esac +done +if [ "$CMD" != retire ]; then + case "$NEW_STATE" in busy|idle|unknown) : ;; *) usage ;; esac + fm_busy_token_valid "$SOURCE" || { echo "error: invalid --source" >&2; exit 1; } + fm_busy_token_valid "$EVENT" || { echo "error: invalid --event" >&2; exit 1; } +fi + +REC=$(fm_busy_record_path "$STATE" "$ID") +GEN_FILE=$(fm_busy_gen_path "$STATE" "$ID") +LOCK="$REC.lock" + +# Serialize writers. The lock protects seq advancement and the sidecar/record +# pair; a holder that died mid-write is broken after FM_BUSY_LOCK_STALE_SECS. +lock_acquire() { + local tries=0 now mtime age + while ! mkdir "$LOCK" 2>/dev/null; do + tries=$((tries + 1)) + if [ "$tries" -ge 40 ]; then + now=$(date +%s) + mtime=$(stat -f %m "$LOCK" 2>/dev/null || stat -c %Y "$LOCK" 2>/dev/null || echo "$now") + age=$((now - mtime)) + if [ "$age" -ge "${FM_BUSY_LOCK_STALE_SECS:-5}" ]; then + rmdir "$LOCK" 2>/dev/null || rm -rf "$LOCK" 2>/dev/null || true + mkdir "$LOCK" 2>/dev/null && break + fi + echo "error: busy-state lock timeout for $ID" >&2 + return 1 + fi + sleep 0.05 + done + return 0 +} +lock_release() { rmdir "$LOCK" 2>/dev/null || true; } + +write_record() { # + local tmp + tmp="$REC.tmp.$$" + printf 'v1 gen=%s seq=%s state=%s source=%s event=%s ts=%s\n' \ + "$1" "$2" "$NEW_STATE" "$SOURCE" "$EVENT" "$(date +%s)" > "$tmp" || return 1 + mv -f "$tmp" "$REC" +} + +old_umask=$(umask) +umask 077 + +if [ "$CMD" = arm ]; then + GEN="g$(date +%s).$$.$RANDOM" + lock_acquire || exit 1 + { + printf '%s\n' "$GEN" > "$GEN_FILE.tmp.$$" && mv -f "$GEN_FILE.tmp.$$" "$GEN_FILE" \ + && write_record "$GEN" 1 + } || { lock_release; umask "$old_umask"; echo "error: arm failed for $ID" >&2; exit 1; } + lock_release + umask "$old_umask" + printf '%s\n' "$GEN" + exit 0 +fi + +# apply / retire +if [ "$USE_CURRENT_GEN" = 1 ] && [ "$CMD" != retire ]; then + GEN=$(fm_busy_current_gen "$STATE" "$ID") || { + umask "$old_umask" + echo "error: no armed busy-state gen for $ID" >&2 + exit 1 + } +fi +if [ "$USE_CURRENT_GEN" != 1 ] || [ "$CMD" != retire ]; then + fm_busy_token_valid "$GEN" || { umask "$old_umask"; echo "error: invalid --gen" >&2; exit 1; } +fi + +lock_acquire || { umask "$old_umask"; exit 1; } +CURRENT=$(fm_busy_current_gen "$STATE" "$ID") || { + if [ "$CMD" = retire ] && [ ! -e "$GEN_FILE" ] && [ ! -L "$GEN_FILE" ]; then + rm -f "$REC" || { + lock_release + umask "$old_umask" + echo "error: busy-state retirement failed for $ID" >&2 + exit 1 + } + lock_release + umask "$old_umask" + exit 0 + fi + lock_release + umask "$old_umask" + echo "error: no armed busy-state gen for $ID" >&2 + exit 1 +} +if [ "$CMD" = retire ] && [ "$USE_CURRENT_GEN" = 1 ]; then + GEN=$CURRENT +fi +if [ "$GEN" != "$CURRENT" ]; then + lock_release + umask "$old_umask" + echo "error: stale busy-state gen for $ID (event rejected)" >&2 + exit 1 +fi +if [ "$CMD" = retire ]; then + rm -f "$GEN_FILE" "$REC" || { + lock_release + umask "$old_umask" + echo "error: busy-state retirement failed for $ID" >&2 + exit 1 + } + lock_release + umask "$old_umask" + exit 0 +fi +OLD_SEQ=0 +if [ -f "$REC" ]; then + old_line=$(head -n 1 "$REC" 2>/dev/null || true) + case "$old_line" in + *" gen=$GEN "*) + old_seq_field=${old_line##* seq=} + old_seq_field=${old_seq_field%% *} + case "$old_seq_field" in + ''|*[!0-9]*) OLD_SEQ=0 ;; + *) OLD_SEQ=$old_seq_field ;; + esac + ;; + esac +fi +write_record "$GEN" $((OLD_SEQ + 1)) || { + lock_release + umask "$old_umask" + echo "error: record write failed for $ID" >&2 + exit 1 +} +lock_release +umask "$old_umask" +exit 0 diff --git a/bin/fm-busy-lib.sh b/bin/fm-busy-lib.sh new file mode 100755 index 0000000000..a091745adf --- /dev/null +++ b/bin/fm-busy-lib.sh @@ -0,0 +1,420 @@ +#!/usr/bin/env bash +# fm-busy-lib.sh - the ONE owner of firstmate's semantic busy-state contract. +# +# Design source: the captain-approved semantic busy-state redesign +# (2026-07-28): each harness adapter reports turn lifecycle through a +# machine-readable semantic source it owns, classification always exposes +# which source produced it, and missing, malformed, stale, unsupported, or +# unverified semantic data is UNKNOWN - never idle. Endpoint death is the only +# process-level override and yields dead, never busy. Child processes, CPU, +# process sleep state, marker mtimes, and the old global UI-regex OR are not +# state signals here; state/.turn-ended files remain wake NOTIFICATIONS +# owned by the watcher, not current-state truth. +# +# Record file: state/.busy-state - exactly one line, atomically replaced +# by bin/fm-busy-event.sh (the only writer): +# +# v1 gen= seq= state= source= event= ts= +# +# Gen sidecar: state/.busy-gen - one token minted when the task's busy +# wiring is armed (fm-spawn, or a documented recovery re-arm). Every event +# must present the current gen; an event or record carrying any other gen is +# a stale incarnation and is rejected (written events) or classified unknown +# (read records). seq is a strictly increasing integer per gen, advanced +# under the writer's lock, so an out-of-order apply can never regress a +# newer record. +# +# Semantic sources written by adapters (fm_busy_sources_for_harness owns the +# per-harness trust table; a record whose source is not trusted for the +# task's recorded harness classifies unknown, so one adapter's writer can +# never classify another adapter): +# pi-ext Pi/pi-signed per-task extension (agent_start/agent_settled) +# opencode-plugin OpenCode per-task plugin (session.status) +# claude-hook Claude lifecycle hooks (UserPromptSubmit/Stop/StopFailure/SessionEnd) +# codex-hook, codex-appserver reserved: Codex, gated by +# fm_busy_codex_semantic_source +# kimi-wire, kimi-hook reserved: standalone Kimi, gated by fm_busy_kimi_verified +# Firstmate-owned sources accepted for every converted adapter: +# fm-spawn the launch-brief turn seeded at spawn +# fm-interrupt a firstmate-controlled interruption of the worker +# fm-recovery a documented recovery reset after relaunch +# Classifier-only sources (never written into a record): +# endpoint-gone, herdr-native, grok-regex, agy-pane, agy-inconclusive, +# missing, malformed, +# gen-mismatch, source-mismatch, kimi-unverified, codex-unverified, +# capture-failed, no-target +# +# Classification (fm_busy_classify): busy | idle | unknown | dead, always +# with the producing source as the second token. Precedence: +# 1. dead endpoint (fm_busy_classify_live only) -> dead endpoint-gone +# 2. standalone Kimi before verification -> unknown kimi-unverified +# 3. a valid, gen-matching, source-trusted record -> its state and source +# 4. no record at all: herdr's native busy verdict is trusted as busy +# (generation state is sufficient for busy, not for idle), then the +# narrowly scoped rendered fallbacks classify Grok and AGY tasks from +# their verified pane signatures, then unknown missing +# 5. malformed, stale, or untrusted records -> unknown, never a fallback +# Grok retains upstream's rendered-text fallback because its structured +# lifecycle was not credited-live-verified in the approved audit. The local +# experimental AGY adapter likewise has no verified lifecycle hook, so its +# live-verified busy and idle pane signatures are classified here. Both arms +# are harness-scoped and can never classify another adapter. The delivery +# guards in bin/fm-tmux-lib.sh match rendered footers for submit +# acknowledgement and away-mode supervisor injection only; neither is a +# recorded worker state source. +# +# Codex negotiation (fm_busy_codex_appserver_observable, +# fm_busy_codex_hooks_verified): the approved contract prefers Codex's +# app-server turn lifecycle with capability negotiation, and sanctions its +# stable lifecycle hooks as the intermediate. Neither is usable on the +# installed binary, so Codex classifies unknown codex-unverified rather than +# falling back to idle, and fm-spawn installs no Codex busy wiring. +# docs/verification/supervision.md owns the evidence for both probes. +# +# Sourcing: set -u and set -e safe; no subshell-unfriendly globals. + +FM_BUSY_LIB_VERSION=v1 + +# AGY has no verified lifecycle hook. Its interactive TUI does expose explicit, +# live-verified busy and idle states, so the semantic classifier owns these +# harness-scoped fallback signatures instead of leaving task-state inference in +# the delivery-oriented tmux reader. +FM_BUSY_AGY_BUSY_REGEX_DEFAULT='esc to cancel|Generating\.\.\.' +FM_BUSY_AGY_IDLE_TEXT_DEFAULT='? for shortcuts' + +# Standalone-Kimi verification gate. Empty means no installed Kimi version +# has passed live verification, so every standalone Kimi task classifies +# unknown kimi-unverified and fm-spawn wires no Kimi busy events. Kimi's +# rendered moon-phase spinner is deliberately NOT a state source here: the +# approved redesign forbids inventing a Kimi UI signature, and that spinner +# is locale- and emoji-font-sensitive. +# +# Preferred source, in order: Wire mode's JSON-RPC `prompt` request lifetime, +# whose outstanding request exactly brackets a turn and returns finished, +# cancelled, or max_steps_reached (so it covers interruption, which `Stop` +# does not); then the documented lifecycle hooks, which must include +# `Interrupt` because Kimi documents that `Stop` does not fire on interrupts. +# +# To open the gate: install Kimi, live-verify the chosen source brackets a +# real turn on a firstmate-launched worker including the interrupt path, +# record the version, exact commands, and observed output in +# docs/verification/supervision.md, add the verified version string(s) here, +# and land the wiring in fm-spawn behind this same gate in the same change. +FM_BUSY_KIMI_VERIFIED_VERSIONS="" + +fm_busy_kimi_verified() { + [ -n "$FM_BUSY_KIMI_VERIFIED_VERSIONS" ] +} + +# fm_busy_codex_appserver_observable: capability/version negotiation for the +# Codex app-server turn lifecycle. Returns 0 only when a pane worker's turns +# are observable through the app-server protocol on the installed binary. +# codex-cli 0.145.0 verdict (live, 2026-07-28): NOT observable. The v2 +# protocol does define the needed turn lifecycle (turn/started plus a +# turn/completed status of completed, interrupted, failed, or inProgress), +# but an interactive TUI worker neither starts nor attaches to the +# app-server daemon, and `codex app-server daemon start` refuses outside the +# managed standalone install, so no client can observe a pane worker's turns. +fm_busy_codex_appserver_observable() { + return 1 +} + +# fm_busy_codex_hooks_verified: the sanctioned intermediate - Codex's stable +# hooks engine (UserPromptSubmit to open a turn, Stop and SessionEnd to close +# it). Returns 0 only once those hooks are live-verified to fire for a +# firstmate-launched worker. codex-cli 0.145.0 verdict (live, 2026-07-28): +# NOT verified. Firstmate-written project hooks under /.codex/ +# never fired in an interactive pane whose directory trust was granted, nor +# under `codex exec`, in either case with --dangerously-bypass-hook-trust, +# while global hooks fired in the same runs. Codex additionally exposes no +# StopFailure hook, so an API-error turn end would need separate coverage +# even after the discovery problem is solved. +fm_busy_codex_hooks_verified() { + return 1 +} + +# fm_busy_codex_semantic_source: 0 when ANY verified Codex semantic source +# exists. fm-spawn arms and wires Codex only behind this gate, and the +# classifier reports unknown codex-unverified until it opens. +fm_busy_codex_semantic_source() { + fm_busy_codex_appserver_observable || fm_busy_codex_hooks_verified +} + +fm_busy_record_path() { # + printf '%s/%s.busy-state' "$1" "$2" +} + +fm_busy_gen_path() { # + printf '%s/%s.busy-gen' "$1" "$2" +} + +# fm_busy_token_valid: conservative token charset shared by gen, source, and +# event fields. Anything else is malformed. +fm_busy_token_valid() { # + case "${1:-}" in + ''|*[!A-Za-z0-9._-]*) return 1 ;; + esac + return 0 +} + +# fm_busy_current_gen: the task's armed gen token, or failure when the busy +# contract has never been armed for this task. +fm_busy_current_gen() { # + local gen_file gen + gen_file=$(fm_busy_gen_path "$1" "$2") + [ -f "$gen_file" ] || return 1 + IFS= read -r gen < "$gen_file" 2>/dev/null || gen= + fm_busy_token_valid "$gen" || return 1 + printf '%s' "$gen" +} + +# fm_busy_sources_for_harness: the semantic sources trusted to classify a +# task recorded with . One line, space-separated, possibly empty. +# The firstmate-owned sources are appended for every converted adapter. +# Grok deliberately trusts nothing: it has no semantic writer yet, and its +# temporary rendered-tail fallback lives in the classifier, not in records. +fm_busy_sources_for_harness() { # + local adapter= + case "${1:-}" in + claude*) adapter=claude-hook ;; + codex*) + fm_busy_codex_semantic_source || { printf ''; return 0; } + adapter='codex-hook codex-appserver' + ;; + opencode*) adapter=opencode-plugin ;; + pi|pi-signed) adapter=pi-ext ;; + kimi*) + fm_busy_kimi_verified || { printf ''; return 0; } + adapter='kimi-wire kimi-hook' + ;; + *) printf ''; return 0 ;; + esac + printf '%s fm-spawn fm-interrupt fm-recovery' "$adapter" +} + +fm_busy_source_trusted() { # + local trusted + trusted=$(fm_busy_sources_for_harness "$1") + case " $trusted " in + *" $2 "*) return 0 ;; + esac + return 1 +} + +# fm_busy_record_read: parse and validate state/.busy-state against the +# armed gen. Prints " " for a valid record. +# Non-zero returns name the reason on stdout instead: +# missing no record file (or no armed gen and no record) +# malformed unparseable line, bad tokens, or a missing armed gen for an +# existing record +# gen-mismatch a record from a stale incarnation +fm_busy_record_read() { # + local state=$1 id=$2 rec gen line extra ver f + local r_gen='' r_seq='' r_state='' r_source='' r_event='' r_ts='' + rec=$(fm_busy_record_path "$state" "$id") + if [ ! -f "$rec" ]; then + printf 'missing' + return 1 + fi + if ! gen=$(fm_busy_current_gen "$state" "$id"); then + # A record without an armed gen has no incarnation to bind to. + printf 'malformed' + return 1 + fi + # shellcheck disable=SC2034 # extra exists only to prove the record is one line + { IFS= read -r line && ! IFS= read -r extra; } < "$rec" 2>/dev/null || { + printf 'malformed' + return 1 + } + # `read -a` rather than `set --`: it never glob-expands a field and never + # touches the caller's positional parameters or shell options. + local -a fields + IFS=' ' read -r -a fields <<< "$line" + ver=${fields[0]:-} + [ "$ver" = "$FM_BUSY_LIB_VERSION" ] || { printf 'malformed'; return 1; } + for f in "${fields[@]:1}"; do + case "$f" in + gen=*) r_gen=${f#gen=} ;; + seq=*) r_seq=${f#seq=} ;; + state=*) r_state=${f#state=} ;; + source=*) r_source=${f#source=} ;; + event=*) r_event=${f#event=} ;; + ts=*) r_ts=${f#ts=} ;; + *) printf 'malformed'; return 1 ;; + esac + done + fm_busy_token_valid "$r_gen" || { printf 'malformed'; return 1; } + fm_busy_token_valid "$r_source" || { printf 'malformed'; return 1; } + fm_busy_token_valid "$r_event" || { printf 'malformed'; return 1; } + case "$r_seq" in ''|*[!0-9]*) printf 'malformed'; return 1 ;; esac + case "$r_ts" in ''|*[!0-9]*) printf 'malformed'; return 1 ;; esac + case "$r_state" in busy|idle|unknown) : ;; *) printf 'malformed'; return 1 ;; esac + if [ "$r_gen" != "$gen" ]; then + printf 'gen-mismatch' + return 1 + fi + printf '%s %s %s %s' "$r_state" "$r_source" "$r_event" "$r_seq" +} + +# fm_busy_grok_tail_busy: the Grok-only temporary rendered-tail fallback. +# Consumes the tail on stdin; 0 when Grok's verified busy signature matches. +# FM_BUSY_REGEX still globally overrides the signature, mirroring the +# historical operator escape hatch. +fm_busy_grok_tail_busy() { + grep -v '^[[:space:]]*$' | tail -12 \ + | grep -qiE "${FM_BUSY_REGEX:-${FM_TMUX_GROK_BUSY_REGEX_DEFAULT:-Ctrl\\+c:cancel}}" +} + +# fm_busy_agy_tail_state: classify AGY only from its explicit verified pane +# signatures. Busy wins if both states somehow appear in one bounded capture; +# idle requires its positive footer signal, and every other capture is unknown. +fm_busy_agy_tail_state() { + local tail + tail=$(grep -v '^[[:space:]]*$' | tail -12) + if printf '%s' "$tail" | grep -qiE "$FM_BUSY_AGY_BUSY_REGEX_DEFAULT"; then + printf 'busy' + elif printf '%s' "$tail" | grep -Fq "$FM_BUSY_AGY_IDLE_TEXT_DEFAULT"; then + printf 'idle' + else + printf 'unknown' + fi +} + +# fm_busy_classify: semantic classification for a task whose endpoint the +# caller has already established as present. Prints " ": +# busy|idle|unknown plus the producing source (see header). Never probes +# process state. is optional pre-captured plain output used only by +# the Grok and AGY arms; when absent either arm captures through +# fm_backend_capture if available, else reports unknown capture-failed. +fm_busy_classify() { # [tail40] + local backend=$1 target=$2 harness=$3 id=$4 state=$5 tail40=${6-} + local out rc r_state r_source native + case "$harness" in + kimi*) + if ! fm_busy_kimi_verified; then + printf 'unknown kimi-unverified' + return 0 + fi + ;; + codex*) + if ! fm_busy_codex_semantic_source; then + printf 'unknown codex-unverified' + return 0 + fi + ;; + esac + out=$(fm_busy_record_read "$state" "$id") && rc=0 || rc=$? + if [ "$rc" = 0 ]; then + r_state=${out%% *} + out=${out#* } + r_source=${out%% *} + if fm_busy_source_trusted "$harness" "$r_source"; then + printf '%s %s' "$r_state" "$r_source" + else + printf 'unknown source-mismatch' + fi + return 0 + fi + case "$out" in + malformed|gen-mismatch) + printf 'unknown %s' "$out" + return 0 + ;; + esac + # No record at all. A native herdr busy verdict is semantic enough to trust + # for BUSY (streaming means a turn is running); native idle is narrower + # than turn state (a long foreground tool call reads idle) and stays + # unknown here. + if [ "$backend" = herdr ] && command -v fm_backend_busy_state >/dev/null 2>&1; then + native=$(fm_backend_busy_state "$backend" "$target" 2>/dev/null || true) + if [ "$native" = busy ]; then + printf 'busy herdr-native' + return 0 + fi + fi + case "$harness" in + grok*) + if [ -z "$tail40" ]; then + if command -v fm_backend_capture >/dev/null 2>&1; then + tail40=$(fm_backend_capture "$backend" "$target" 40 2>/dev/null) || { + printf 'unknown capture-failed' + return 0 + } + else + printf 'unknown capture-failed' + return 0 + fi + fi + if printf '%s' "$tail40" | fm_busy_grok_tail_busy; then + printf 'busy grok-regex' + else + printf 'idle grok-regex' + fi + return 0 + ;; + agy*) + if [ -z "$tail40" ]; then + if command -v fm_backend_capture >/dev/null 2>&1; then + tail40=$(fm_backend_capture "$backend" "$target" 40 2>/dev/null) || { + printf 'unknown capture-failed' + return 0 + } + else + printf 'unknown capture-failed' + return 0 + fi + fi + case "$(printf '%s' "$tail40" | fm_busy_agy_tail_state)" in + busy) printf 'busy agy-pane' ;; + idle) printf 'idle agy-pane' ;; + *) printf 'unknown agy-inconclusive' ;; + esac + return 0 + ;; + esac + printf 'unknown missing' +} + +# fm_busy_classify_live: fm_busy_classify behind the one process-level +# override - a gone endpoint is dead, never busy. Requires fm-backend.sh to +# be sourced for fm_backend_target_exists. +fm_busy_classify_live() { # [expected-label] + local backend=$1 target=$2 harness=$3 id=$4 state=$5 label=${6-} + if [ -z "$target" ]; then + printf 'unknown no-target' + return 0 + fi + if ! fm_backend_target_exists "$backend" "$target" "$label" 2>/dev/null; then + printf 'dead endpoint-gone' + return 0 + fi + fm_busy_classify "$backend" "$target" "$harness" "$id" "$state" +} + +# fm_busy_classify_meta: classify a task from its recorded metadata, so every +# consumer resolves backend, target, and harness the same way instead of +# re-deriving them. Requires fm-backend.sh to be sourced. is +# optional pre-captured plain output reused by the Grok arm. +fm_busy_classify_meta() { # [tail40] + local meta=$1 id=$2 state=$3 tail40=${4-} backend target harness + [ -f "$meta" ] || { printf 'unknown missing'; return 0; } + backend=$(fm_backend_of_meta "$meta") + target=$(fm_backend_target_of_meta "$meta") + harness=$(fm_meta_get "$meta" harness) + if [ -z "$target" ]; then + printf 'unknown no-target' + return 0 + fi + fm_busy_classify "$backend" "$target" "$harness" "$id" "$state" "$tail40" +} + +# fm_busy_is_busy: boolean view for callers that only gate on provable +# activity. 0 iff the classification verdict is exactly busy; idle, unknown, +# and dead all return 1, so an unknown can never be silently promoted to +# either boolean pole - callers that must distinguish idle from unknown read +# the full classification instead. +fm_busy_is_busy() { # [tail40] + local verdict + verdict=$(fm_busy_classify "$@") + [ "${verdict%% *}" = busy ] +} diff --git a/bin/fm-config-inherit-lib.sh b/bin/fm-config-inherit-lib.sh index 95abba2439..bffbd5234d 100644 --- a/bin/fm-config-inherit-lib.sh +++ b/bin/fm-config-inherit-lib.sh @@ -5,10 +5,13 @@ # (e.g. primary config/crew-dispatch.json makes a secondmate use the same dispatch # profile rules, primary config/crew-harness=codex makes a secondmate's crewmates # spawn on codex too, primary config/backlog-backend=manual makes that home -# hand-edit backlog files too, and primary config/herdr-presentation-spaces -# enables the same default-off Herdr presentation projection). It also pushes -# the one primary-authoritative shared captain-preference file, -# data/captain-shared.md, into each secondmate home's data/ as a read-only copy. +# hand-edit backlog files too, primary config/backend pins that home's local +# runtime-backend default for future spawns, primary config/startup-memory-budget +# bounds that home's startup-memory curation, and primary +# config/herdr-presentation-spaces enables the same default-off Herdr presentation +# projection). It also pushes the one primary-authoritative shared +# captain-preference file, data/captain-shared.md, into each secondmate home's +# data/ as a read-only copy. # # Usage: . bin/fm-config-inherit-lib.sh (no FM_* setup required) # @@ -30,6 +33,9 @@ # is deliberately NOT in the list: it is the primary's own setting for launching # secondmates, and a secondmate never spawns secondmates, so it must not flow # downstream. +# +# shellcheck source=bin/fm-startup-memory-budget-lib.sh +. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/fm-startup-memory-budget-lib.sh" # The one shared data file in this inheritance contract. There is deliberately # no shared learnings file. @@ -40,7 +46,7 @@ FM_SHARED_CAPTAIN_MODE="444" # The declared inheritable set (space-separated, config-dir-relative item paths). # Extend here to inherit more of the primary's local config; override via the # environment only in tests. Items must not contain whitespace. -FM_INHERITABLE_CONFIG="${FM_INHERITABLE_CONFIG:-crew-dispatch.json crew-harness backlog-backend herdr-presentation-spaces}" +FM_INHERITABLE_CONFIG="${FM_INHERITABLE_CONFIG:-crew-dispatch.json crew-harness backlog-backend backend herdr-presentation-spaces startup-memory-budget}" fm_inherit_file_mode() { if [ "$(uname)" = Darwin ]; then @@ -399,6 +405,47 @@ propagate_inheritable_config() { esac src="$src_config/$item" dest="$dest_config/$item" + # This one scalar config is consumed as a local safety boundary, so reject + # every unsafe or malformed source/destination artifact before the generic + # byte-copy behavior below can treat it as ordinary inherited material. + if [ "$item" = "$FM_STARTUP_MEMORY_BUDGET_FILE" ]; then + if [ -e "$src_config" ] || [ -L "$src_config" ]; then + if ! fm_startup_memory_budget_config_dir_safe "$src_config"; then + reason="unsafe primary config directory: $FM_STARTUP_MEMORY_BUDGET_ERROR" + warn_inheritable_config_error "$item" "$src_config" "$reason" + record_inheritable_config_result "$item" error "$reason" + rc=1 + continue + fi + fi + if [ -e "$dest_config" ] || [ -L "$dest_config" ]; then + if ! fm_startup_memory_budget_config_dir_safe "$dest_config"; then + reason="unsafe destination config directory: $FM_STARTUP_MEMORY_BUDGET_ERROR" + warn_inheritable_config_error "$item" "$dest_config" "$reason" + record_inheritable_config_result "$item" error "$reason" + rc=1 + continue + fi + fi + if [ -e "$src" ] || [ -L "$src" ]; then + if ! fm_startup_memory_budget_file_valid "$src"; then + reason="unsafe or invalid primary source: $FM_STARTUP_MEMORY_BUDGET_ERROR" + warn_inheritable_config_error "$item" "$src" "$reason" + record_inheritable_config_result "$item" error "$reason" + rc=1 + continue + fi + fi + if [ -e "$dest" ] || [ -L "$dest" ]; then + if ! fm_startup_memory_budget_file_valid "$dest"; then + reason="unsafe or invalid destination: $FM_STARTUP_MEMORY_BUDGET_ERROR" + warn_inheritable_config_error "$item" "$dest" "$reason" + record_inheritable_config_result "$item" error "$reason" + rc=1 + continue + fi + fi + fi if [ -f "$src" ]; then if ! destination_allows_inherited_item "$dest_config" "$item"; then reason=$(inheritable_config_skip_reason) diff --git a/bin/fm-config-push.sh b/bin/fm-config-push.sh index b4056744bc..b760666dd1 100755 --- a/bin/fm-config-push.sh +++ b/bin/fm-config-push.sh @@ -3,8 +3,8 @@ # Usage: fm-config-push.sh [--help] # # Mid-session convergence for inherited local material such as -# config/crew-dispatch.json edits or data/captain-shared.md updates. This -# discovers live secondmate homes from state/*.meta, backfills +# config/crew-dispatch.json, config/backend, or data/captain-shared.md updates. +# This discovers live secondmate homes from state/*.meta, backfills # home= from data/secondmates.md for older meta records, and reuses the same # propagation machinery as bootstrap, but deliberately does not # fast-forward tracked files. diff --git a/bin/fm-crew-state.sh b/bin/fm-crew-state.sh index 32dff23668..b19fc5996f 100755 --- a/bin/fm-crew-state.sh +++ b/bin/fm-crew-state.sh @@ -9,7 +9,7 @@ # re-validates), the log's last line stays stale. This helper never infers the # current state from a tail of the log: it reads the authoritative source (a # no-mistakes run-step attributed to this crew's branch and current code -# identity, else the pane busy-signature) and reconciles the possibly-stale log +# identity, else the semantic busy-state classifier) and reconciles the possibly-stale log # against it. # # The determinism lives entirely here - only run-step / pane / log reads plus @@ -40,7 +40,7 @@ # is flagged superseded. A genuinely parked run plus a needs-decision log # agree, and are reported as parked. # 4. No run for this crew (pre-validation, or kind=scout): fall back to the -# recorded backend's pane busy state, then the status log's last line only +# recorded backend's classified busy state, then the status log's last line only # when its verb maps to a recognized run-state. Decision-only events such as # `resolved` never become current state or detail. # 5. Missing meta or torn-down worktree: report unknown · none. If no run is @@ -62,6 +62,8 @@ STATE="${FM_STATE_OVERRIDE:-$FM_HOME/state}" . "$SCRIPT_DIR/fm-backend.sh" # shellcheck source=bin/fm-classify-lib.sh . "$SCRIPT_DIR/fm-classify-lib.sh" +# shellcheck source=bin/fm-busy-lib.sh +. "$SCRIPT_DIR/fm-busy-lib.sh" ID=${1:-} [ -n "$ID" ] || { echo "usage: fm-crew-state.sh " >&2; exit 2; } @@ -149,46 +151,19 @@ pane_readable() { # *) fm_backend_capture "$TASK_BACKEND" "$1" 1 "$EXPECTED_LABEL" >/dev/null 2>&1 ;; esac } -# crew_pane_is_busy: the busy-signature fallback, backend-aware the same way - -# fm_backend_busy_state's native semantic state (herdr's agent.get) when -# available, else the shared harness-scoped pane-regex reader -# (fm_pane_is_busy, bin/fm-tmux-lib.sh). -# -# `busy` alone is trusted outright. Both `idle` and unknown/unparseable fall -# through to the shared tail-regex corroboration, NOT just unknown: herdr's -# agent.get reports generation state ("working" while the model is streaming -# a turn, "done"/"idle" once it is not - docs/herdr-backend.md "Busy state"), -# which is a narrower signal than "this crew's turn/tool call is still in -# progress". A crew blocked on its own long-running foreground tool call (e.g. -# `no-mistakes axi run` without --yes, which blocks synchronously until a gate -# or outcome - AGENTS.md section 7) is not generating for that whole span, so -# agent.get can read idle/blocked (bin/backends/herdr.sh maps both to `idle`) -# while the pane's own rendered text still shows that recorded harness's busy -# signature for the entire tool call, exactly like tmux's regex-only reader -# would correctly report. Trusting herdr's `idle` -# outright (skipping that corroboration) is what let a still-working crew read -# as not-busy here, and - combined with a no-mistakes run-step lookup that also -# missed attribution (see nm_runs_status_for_branch) - as not provably working in -# fm-classify-lib.sh, triggering an immediate (non-wedge) stale wake instead of -# the absorb-then-escalate path. A genuinely human-blocked agent (a permission -# dialog, not mid-tool-call) does not render the busy banner, so this -# corroboration does not mask that case: it stays correctly not-busy. -crew_pane_is_busy() { # - case "$TASK_BACKEND" in - tmux) fm_pane_is_busy "$1" "$HARNESS" ;; - *) - local bs tail40 - bs=$(fm_backend_busy_state "$TASK_BACKEND" "$1" 2>/dev/null) - case "$bs" in - busy) return 0 ;; - *) - tail40=$(fm_backend_capture "$TASK_BACKEND" "$1" 40 "$EXPECTED_LABEL" 2>/dev/null) || return 1 - printf '%s' "$tail40" | grep -v '^[[:space:]]*$' | tail -12 \ - | fm_busy_lines_match "$HARNESS" - ;; - esac - ;; +# crew_busy_verdict: the crew's semantic busy state from the one contract +# owner (bin/fm-busy-lib.sh), as " ". A converted +# adapter answers from its own lifecycle record; Grok and experimental AGY +# answer from their isolated rendered fallbacks; a herdr crew's native `busy` is accepted +# when no record exists, but its native `idle` is NOT, because agent.get +# reports generation state (idle while a crew blocks on its own long-running +# foreground tool call) rather than turn state. +crew_busy_verdict() { # + local tail40='' + case "$HARNESS" in + grok*|agy*) tail40=$(fm_backend_capture "$TASK_BACKEND" "$1" 40 "$EXPECTED_LABEL" 2>/dev/null) || tail40='' ;; esac + fm_busy_classify "$TASK_BACKEND" "$1" "$HARNESS" "$ID" "$STATE" "$tail40" } # --- no-mistakes run lookup (authoritative when a run matches this branch) -- @@ -605,9 +580,17 @@ fi pane_readable "$BACKEND_TARGET" || emit unknown none "backend target gone: $BACKEND_TARGET" # Secondmates idle on their own watcher (idle pane = healthy), so the busy -# signature is not meaningful for them; read their state from the status log only. -if [ "$KIND" != secondmate ] && crew_pane_is_busy "$BACKEND_TARGET"; then - emit working pane "harness busy" +# state is not meaningful for them; read their state from the status log only. +# Only an exact busy verdict reports working here, and only an exact idle +# verdict permits the status-log fallback below. Missing, malformed, stale, or +# unverified semantic state remains unknown. +if [ "$KIND" != secondmate ]; then + BUSY_VERDICT=$(crew_busy_verdict "$BACKEND_TARGET") + case "${BUSY_VERDICT%% *}" in + busy) emit working pane "harness busy (${BUSY_VERDICT#* })" ;; + idle) ;; + *) emit unknown pane "harness state unavailable ($BUSY_VERDICT)" ;; + esac fi # Fall back to the status log's last line, but ONLY when its verb maps to a real diff --git a/bin/fm-ff-lib.sh b/bin/fm-ff-lib.sh index e6d6801a79..bce9993ae7 100644 --- a/bin/fm-ff-lib.sh +++ b/bin/fm-ff-lib.sh @@ -25,6 +25,8 @@ # shared default branch or any other worktree's checkout. SUB_HOME_MARKER="${SUB_HOME_MARKER:-.fm-secondmate-home}" +# shellcheck source=bin/fm-secondmate-registry-lib.sh +. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/fm-secondmate-registry-lib.sh" # --- helpers --------------------------------------------------------------- @@ -231,20 +233,6 @@ dirty_status() { fi } -secondmate_registry_field() { - local reg=$1 id=$2 key=$3 line value - [ -f "$reg" ] || return 1 - line=$(grep -E "^- $id( |$)" "$reg" | tail -1 || true) - [ -n "$line" ] || return 1 - case "$key" in - home) value=$(printf '%s\n' "$line" | sed -n 's/.*(home:[[:space:]]*\([^;)]*\);.*/\1/p' | sed 's/[[:space:]]*$//') ;; - projects) value=$(printf '%s\n' "$line" | sed -n 's/.*; projects:[[:space:]]*\([^;)]*\); added .*/\1/p' | sed 's/[[:space:]]*$//') ;; - *) return 1 ;; - esac - [ -n "$value" ] || return 1 - printf '%s\n' "$value" -} - # List this home's LIVE secondmate direct reports from state/.meta records. # The meta file is the liveness signal; data/secondmates.md is only the fallback # for durable fields such as home= when an older/incomplete meta lacks them. diff --git a/bin/fm-fleet-snapshot.sh b/bin/fm-fleet-snapshot.sh index 1dee81bb84..f8598d9ea1 100755 --- a/bin/fm-fleet-snapshot.sh +++ b/bin/fm-fleet-snapshot.sh @@ -839,7 +839,7 @@ BASH | select(startswith("- ")) | (capture("^- (?[^[:space:]]+)")?) as $id | select($id != null) - | (capture("\\(home:[[:space:]]*(?[^;)]*);")?) as $home + | (capture("^.*\\(home:[[:space:]]*(?[^;)]*);[[:space:]]*scope:[[:space:]]*.*;[[:space:]]*projects:[[:space:]]*[^;)]*;[[:space:]]*added[[:space:]]+[0-9]{4}-[0-9]{2}-[0-9]{2}\\)[[:space:]]*$")?) as $home | {id:$id.id,home:($home.home // null),registered:true, registry_error:(if $home == null or ($home.home | length) == 0 then "registry entry has no home" else null end)} ] | group_by(.id) diff --git a/bin/fm-harness.sh b/bin/fm-harness.sh index 988f81210b..4569a88ffb 100755 --- a/bin/fm-harness.sh +++ b/bin/fm-harness.sh @@ -48,7 +48,7 @@ detect_own() { local pid=$$ comm args for _ in 1 2 3 4 5 6 7 8; do comm=$(ps -o comm= -p "$pid" 2>/dev/null) || break - case "$(basename "$comm")" in + case "$(basename -- "$comm")" in *claude*) echo claude; return ;; *codex*) echo codex; return ;; *opencode*) echo opencode; return ;; diff --git a/bin/fm-herdr-session-cleanup.sh b/bin/fm-herdr-session-cleanup.sh index 05b6db9f94..2dc4c22794 100755 --- a/bin/fm-herdr-session-cleanup.sh +++ b/bin/fm-herdr-session-cleanup.sh @@ -62,14 +62,6 @@ fm_herdr_cleanup_home_identity() { (cd "$FM_HOME" 2>/dev/null && pwd -P) } -fm_herdr_cleanup_process_argv0() { # - printf '%s' "$1" | jq -er ' - .result.process_info.foreground_processes[0] as $process - | ($process.argv0 // $process.argv[0]) - | select(type == "string" and length > 0) - ' 2>/dev/null -} - fm_herdr_cleanup_journal_matches() { # <session> <home-real> local title=$1 session=$2 home_real=$3 journal id expected journal_home [ -d "$STATE" ] && [ ! -L "$STATE" ] || return 1 @@ -122,46 +114,6 @@ fm_herdr_cleanup_unique_match() { # <title> <session> <home-real> fi } -fm_herdr_cleanup_process_is_idle_shell() { # <session> <pane-id> - local session=$1 pane=$2 info shell_pid foreground_pgid count - local process_pid name argv0 shell_name rows stat ps_bin - info=$(fm_backend_herdr_cli "$session" pane process-info --pane "$pane" 2>/dev/null) || return 1 - printf '%s' "$info" | jq -e --arg pane "$pane" ' - .result.type == "pane_process_info" - and .result.process_info.pane_id == $pane - ' >/dev/null 2>&1 || return 1 - shell_pid=$(printf '%s' "$info" | jq -er \ - '.result.process_info.shell_pid | select(type == "number" and . > 1) | floor' 2>/dev/null) || return 1 - foreground_pgid=$(printf '%s' "$info" | jq -er \ - '.result.process_info.foreground_process_group_id | select(type == "number" and . > 1) | floor' 2>/dev/null) || return 1 - [ "$foreground_pgid" = "$shell_pid" ] || return 1 - count=$(printf '%s' "$info" | jq -er \ - '.result.process_info.foreground_processes | select(type == "array") | length' 2>/dev/null) || return 1 - [ "$count" -eq 1 ] || return 1 - process_pid=$(printf '%s' "$info" | jq -er \ - '.result.process_info.foreground_processes[0].pid | select(type == "number") | floor' 2>/dev/null) || return 1 - [ "$process_pid" = "$shell_pid" ] || return 1 - name=$(printf '%s' "$info" | jq -er \ - '.result.process_info.foreground_processes[0].name | select(type == "string" and length > 0)' 2>/dev/null) || return 1 - argv0=$(fm_herdr_cleanup_process_argv0 "$info") || return 1 - shell_name=${name##*/} - argv0=${argv0#-} - argv0=${argv0##*/} - [ "$argv0" = "$shell_name" ] || return 1 - case "$shell_name" in sh|bash|zsh|dash|ksh|fish) ;; *) return 1 ;; esac - - ps_bin=${FM_HERDR_PS_BIN:-ps} - command -v "$ps_bin" >/dev/null 2>&1 || return 1 - rows=$("$ps_bin" -axo pid=,ppid= 2>/dev/null) || return 1 - printf '%s\n' "$rows" | awk -v shell="$shell_pid" ' - $1 == shell { found++ } - $2 == shell { child++ } - END { exit(found == 1 && child == 0 ? 0 : 1) } - ' || return 1 - stat=$("$ps_bin" -p "$shell_pid" -o stat= 2>/dev/null | tr -d '[:space:]') || return 1 - case "$stat" in S*|I*) ;; *) return 1 ;; esac -} - fm_herdr_cleanup_snapshot_candidate() { # <snapshot> <workspace> <title> <token> <bound-workspace> <bound-tab> <bound-pane> local snapshot=$1 workspace=$2 title=$3 token=$4 local bound_workspace=$5 bound_tab=$6 bound_pane=$7 record @@ -242,7 +194,7 @@ fm_herdr_cleanup_revalidate() { # <session> <workspace> <tab> <pane> <title> <to and .result.panes[0].pane_id == $pane ' >/dev/null 2>&1 || return 1 [ "$(fm_backend_herdr_pane_agent_state "$session" "$pane")" = no-agent ] || return 1 - fm_herdr_cleanup_process_is_idle_shell "$session" "$pane" || return 1 + fm_backend_herdr_pane_idle_shell_pid "$session" "$pane" >/dev/null || return 1 focus=$(fm_backend_herdr_projection_focus_snapshot "$session") || return 1 [ "${focus#*$'\t'}" != "$tab" ] } @@ -296,7 +248,7 @@ fm_herdr_cleanup_one() { # <session> <workspace> <title> <home-real> tab=$FM_HERDR_CLEANUP_TAB pane=$FM_HERDR_CLEANUP_PANE if [ "$(fm_backend_herdr_pane_agent_state "$session" "$pane")" != no-agent ] \ - || ! fm_herdr_cleanup_process_is_idle_shell "$session" "$pane"; then + || ! fm_backend_herdr_pane_idle_shell_pid "$session" "$pane" >/dev/null; then fm_herdr_cleanup_warn "$id preserved because its pane is not a provably idle childless shell" fm_lock_release "$presentation_lock" || true fm_lock_release "$task_lock" || true diff --git a/bin/fm-home-seed.sh b/bin/fm-home-seed.sh index d506b95e4f..4b5b9815bc 100755 --- a/bin/fm-home-seed.sh +++ b/bin/fm-home-seed.sh @@ -27,8 +27,9 @@ # to override the registry routing scope. Otherwise the registry summary # and scope are derived from the filled charter brief. # fm-home-seed.sh validate -# Refuse duplicate ids, duplicate homes, and nested or overlapping homes in -# data/secondmates.md. +# Refuse records that operational consumers cannot parse, unavailable or +# unsafe registry files when present, non-absolute or unresolvable homes, +# duplicate ids or homes, and nested or overlapping homes. set -eu SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" @@ -38,16 +39,14 @@ DATA="${FM_DATA_OVERRIDE:-$FM_HOME/data}" PROJECTS="${FM_PROJECTS_OVERRIDE:-$FM_HOME/projects}" REG="$DATA/secondmates.md" SUB_HOME_MARKER=".fm-secondmate-home" +# shellcheck source=bin/fm-secondmate-registry-lib.sh +. "$SCRIPT_DIR/fm-secondmate-registry-lib.sh" usage() { echo "usage: fm-home-seed.sh <id> <home|-> {<project>...|--no-projects}" >&2 echo " fm-home-seed.sh validate" >&2 } -registry_home_for_line() { - sed -n 's/^[^(]*(home: \([^;)]*\);.*/\1/p' -} - normalize_registry_text() { awk ' { @@ -179,13 +178,15 @@ registry_home_conflict_for_assignment() { local id=$1 home=$2 target line registered_id registered_home registered_key [ -f "$REG" ] || return 1 target=$(resolved_path "$home") - while IFS= read -r line; do + while IFS= read -r line || [ -n "$line" ]; do case "$line" in "- "*) - registered_id=${line#- } - registered_id=${registered_id%% *} - registered_home=$(printf '%s\n' "$line" | registry_home_for_line) - [ -n "$registered_home" ] || continue + if ! secondmate_registry_parse_line "$line"; then + echo "error: malformed secondmate registry entry: $line" >&2 + return 1 + fi + registered_id=$SECONDMATE_REGISTRY_ID + registered_home=$SECONDMATE_REGISTRY_HOME registered_key=$(resolved_path "$registered_home") if [ "$registered_key" = "$target" ]; then [ "$registered_id" = "$id" ] && continue @@ -206,14 +207,16 @@ registry_id_conflict_for_assignment() { local id=$1 home=$2 target line registered_id registered_home registered_key [ -f "$REG" ] || return 1 target=$(resolved_path "$home") - while IFS= read -r line; do + while IFS= read -r line || [ -n "$line" ]; do case "$line" in "- "*) - registered_id=${line#- } - registered_id=${registered_id%% *} + secondmate_registry_parse_line "$line" || { + echo "error: malformed secondmate registry entry: $line" >&2 + return 1 + } + registered_id=$SECONDMATE_REGISTRY_ID [ "$registered_id" = "$id" ] || continue - registered_home=$(printf '%s\n' "$line" | registry_home_for_line) - [ -n "$registered_home" ] || continue + registered_home=$SECONDMATE_REGISTRY_HOME registered_key=$(resolved_path "$registered_home") [ "$registered_key" = "$target" ] && continue printf '%s\n' "$registered_key" @@ -225,76 +228,11 @@ registry_id_conflict_for_assignment() { } validate_registry() { - local tmp line id registered_home home_key duplicate_homes duplicate_ids overlaps - tmp=$(mktemp "${TMPDIR:-/tmp}/fm-firstmates.XXXXXX") - if [ -f "$REG" ]; then - while IFS= read -r line; do - case "$line" in - "- "*) - id=${line#- } - id=${id%% *} - registered_home=$(printf '%s\n' "$line" | registry_home_for_line) - [ -n "$registered_home" ] || continue - home_key=$(resolved_path "$registered_home") - printf '%s\t%s\n' "$home_key" "$id" >> "$tmp" - ;; - esac - done < "$REG" - fi - duplicate_homes=$(awk -F '\t' ' - { - if (($1 in owner) && owner[$1] != $2) { - print $1 ": " owner[$1] ", " $2 - bad=1 - } else { - owner[$1]=$2 - } - } - END { exit bad ? 1 : 0 } - ' "$tmp" 2>/dev/null) || { - rm -f "$tmp" - printf 'error: duplicate secondmate home assignment:\n%s\n' "$duplicate_homes" >&2 - return 1 - } - duplicate_ids=$(awk -F '\t' ' - { - if ($2 in home) { - print $2 ": " home[$2] ", " $1 - bad=1 - } else { - home[$2]=$1 - } - } - END { exit bad ? 1 : 0 } - ' "$tmp" 2>/dev/null) || { - rm -f "$tmp" - printf 'error: duplicate secondmate id assignment:\n%s\n' "$duplicate_ids" >&2 - return 1 - } - overlaps=$(awk -F '\t' ' - function ancestor(a, b) { return a != b && index(b, a "/") == 1 } - { - for (i = 1; i <= count; i++) { - if (ancestor($1, path[i])) { - print $1 " (" $2 ") contains " path[i] " (" id[i] ")" - bad=1 - } else if (ancestor(path[i], $1)) { - print path[i] " (" id[i] ") contains " $1 " (" $2 ")" - bad=1 - } - } - count++ - path[count]=$1 - id[count]=$2 - } - END { exit bad ? 1 : 0 } - ' "$tmp" 2>/dev/null) || { - rm -f "$tmp" - printf 'error: overlapping secondmate home assignment:\n%s\n' "$overlaps" >&2 + [ -e "$REG" ] || [ -L "$REG" ] || return 0 + secondmate_registry_validate_bindings "$REG" resolved_path || { + printf 'error: %s\n' "$SECONDMATE_REGISTRY_ERROR" >&2 return 1 } - rm -f "$tmp" - return 0 } join_projects() { diff --git a/bin/fm-lint.sh b/bin/fm-lint.sh index caddb63180..d1d761dd27 100755 --- a/bin/fm-lint.sh +++ b/bin/fm-lint.sh @@ -22,6 +22,7 @@ # fm-lint.sh --jobs <1|2> [path]... override bounded worker count # fm-lint.sh --telemetry <path> ... write a quiet metrics snapshot # fm-lint.sh --required-version print the ShellCheck pin +# fm-lint.sh --list-files print the canonical file set # fm-lint.sh --help print this usage set -u @@ -83,11 +84,12 @@ if [ "${1:-}" = "--required-version" ]; then fi fm_lint_usage() { - sed -n '2,25{s/^# \{0,1\}//;p;}' "$SELF" + sed -n '2,26{s/^# \{0,1\}//;p;}' "$SELF" } JOBS=${FM_LINT_JOBS:-2} TELEMETRY=${FM_LINT_TELEMETRY:-} +LIST_FILES=0 while [ "$#" -gt 0 ]; do case "$1" in --jobs) @@ -108,6 +110,10 @@ while [ "$#" -gt 0 ]; do TELEMETRY=${1#*=} shift ;; + --list-files) + LIST_FILES=1 + shift + ;; --help|-h) fm_lint_usage exit 0 @@ -125,6 +131,22 @@ case "$JOBS" in *) printf 'fm-lint.sh: jobs must be 1 or 2, got %s.\n' "$JOBS" >&2; exit 2 ;; esac +if [ "$#" -gt 0 ]; then + ROOTS=("$@") +else + ROOTS=(bin/*.sh bin/backends/*.sh tests/*.sh) +fi +ROOT_COUNT=${#ROOTS[@]} + +if [ "$LIST_FILES" -eq 1 ]; then + [ "$#" -eq 0 ] || { + printf 'fm-lint.sh: --list-files does not accept explicit paths.\n' >&2 + exit 2 + } + printf '%s\n' "${ROOTS[@]}" + exit 0 +fi + if ! command -v shellcheck >/dev/null 2>&1; then printf 'fm-lint.sh: ShellCheck not found; install ShellCheck %s for CI parity.\n' \ "$REQUIRED_SHELLCHECK" >&2 @@ -144,15 +166,6 @@ if [ "$resolved" != "$REQUIRED_SHELLCHECK" ]; then exit 1 fi -if [ "$#" -gt 0 ]; then - ROOTS=("$@") -else - # Canonical file set: the one authoritative definition. Callers never repeat - # these globs, and every adapter and test shell remains an independent root. - ROOTS=(bin/*.sh bin/backends/*.sh tests/*.sh) -fi -ROOT_COUNT=${#ROOTS[@]} - if [ -n "$TELEMETRY" ]; then telemetry_parent=$(dirname "$TELEMETRY") [ -d "$telemetry_parent" ] || { diff --git a/bin/fm-pending-reply-lib.sh b/bin/fm-pending-reply-lib.sh index 5d04b65d67..a45c8378ea 100755 --- a/bin/fm-pending-reply-lib.sh +++ b/bin/fm-pending-reply-lib.sh @@ -573,6 +573,22 @@ fm_pending_reply_fallback_idle_eligible() { # <record-path> [ "$age" -ge "$grace" ] } +# fm_pending_reply_backend_observation: one busy/idle observation of a +# SECONDMATE endpoint, without ever reading its conversation. +# +# Deliberately NOT the semantic busy-state contract (bin/fm-busy-lib.sh). +# That contract covers ordinary task workers, whose turn lifecycle firstmate +# wires at spawn; a secondmate has no such wiring because an idle secondmate +# pane is healthy and it runs no supervised turn sequence of its own. This +# observation exists only to notice a busy-then-idle transition around one +# delivered request, so it is a delivery-confirmation signal in the same +# category as the submit acknowledgement in bin/fm-tmux-lib.sh - never task +# state, and never a source consumers can confuse with semantic state. +# +# It stays harness-scoped (fm_busy_lines_match with the recorded harness, no +# global OR of every vendor signature), so one harness's output cannot make +# another read busy, and a weak rendered idle degrades to `fallback-idle`, +# which the caller accepts as idle only after its grace window. fm_pending_reply_backend_observation() { # <backend> <target> [expected-label] [harness] local backend=$1 target=$2 expected_label=${3-} harness=${4-} native tail40 native=$(fm_backend_busy_state "$backend" "$target" 2>/dev/null || printf 'unknown') diff --git a/bin/fm-public-followup-emit.sh b/bin/fm-public-followup-emit.sh new file mode 100755 index 0000000000..c7510e9b33 --- /dev/null +++ b/bin/fm-public-followup-emit.sh @@ -0,0 +1,260 @@ +#!/usr/bin/env bash +# fm-public-followup-emit.sh - emit ONE structured terminal work result for work +# bound to a public commitment, into the owning home's private event inbox. +# +# WHY THIS EXISTS: a public promise is kept by the home that owns the relay +# consent and the thread binding. The home doing the work only has to report a +# TYPED result. Firstmate must never recover the source home, work id, outcome, +# or deliverables by parsing a free-form "done: ..." status sentence, so this +# script is the structured channel that carries them. +# +# WHAT IT DOES NOT DO: it never posts anything, never reads relay credentials, +# and never resolves a public thread. Outward delivery stays with the owning +# home (bin/fm-public-followup.sh deliver). +# +# Usage: +# fm-public-followup-emit.sh --home <owning-home> \ +# --obligation <obligation-id> --relation <relation-id> \ +# --source-home <main|secondmate:<id>> --work-id <task-id> \ +# --generation <n> --outcome <outcome-type> \ +# [--deliverable <key>=<value>]... \ +# (--outcome-text <text> | --outcome-text-file <path> | --outcome-text -) +# +# Options: +# --home <path> The home that owns the public commitment (the primary +# that took the mention). Must already have a +# registration for --obligation; see +# `fm-public-followup.sh register`. +# --obligation <id> tasks-axi public-followup obligation id. +# --relation <id> The relation_id this work fulfills or contributes to. +# --source-home <id> This worker's stable home identity, exactly as bound: +# "main" or "secondmate:<stable-id>". +# --work-id <id> This worker's exact task id, exactly as bound. +# --generation <n> The bound relation generation (integer >= 1). +# --outcome <type> Typed outcome. tasks-axi owns the vocabulary and +# refuses anything it does not accept; this script only +# checks the token is a safe slug. +# --deliverable k=v Repeatable safe deliverable (for example +# pr_url=https://...). tasks-axi owns which keys a given +# expected-final type permits. +# --outcome-text ... Public-safe outcome sentence, from an argument, a +# file, or stdin ("-"). Collapsed to one line; the +# event builder bounds it by codepoint, so control +# characters cannot survive. +# +# Output: the event id on stdout. Exit 0 on a published or already-present event +# (both are successes: the id is derived, so re-emitting the same terminal result +# is a no-op), 2 on a usage or validation error, 1 on a publication failure. +# +# IDEMPOTENCY: the event id is a digest of the identity tuple (obligation, +# relation, source home, work id, generation, outcome type, deliverables), so a +# retry, a duplicate report, or a rerun after restart resolves to the same file +# and the first published copy wins. Nothing here needs coordination. +# +# SAFETY: the event is published through the shared private-artifact primitive - +# atomic rename into place, single link, mode 0600 (never executable), inside a +# 0700 directory this script refuses to create. The owning home must already have +# registered the obligation, so a home that never opted into the relay can never +# be given public-followup artifacts by a child. +set -u + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=bin/fm-public-followup-lib.sh +. "$SCRIPT_DIR/fm-public-followup-lib.sh" + +usage() { + cat >&2 <<'EOF' +usage: fm-public-followup-emit.sh --home <owning-home> --obligation <id> --relation <id> + --source-home <main|secondmate:<id>> --work-id <id> --generation <n> + --outcome <type> [--deliverable <key>=<value>]... + (--outcome-text <text> | --outcome-text-file <path> | --outcome-text -) +EOF +} + +# The header comment IS the help text, so the two can never drift apart. +help() { + sed -n '2,/^set -u$/p' "$0" | sed '$d; s/^# \{0,1\}//' +} + +die() { printf 'fm-public-followup-emit: %s\n' "$1" >&2; exit "${2:-2}"; } + +HOME_DIR= +OBLIGATION= +RELATION= +SOURCE_HOME= +WORK_ID= +GENERATION= +OUTCOME= +TEXT_SOURCE= +TEXT_MODE= +DELIVERABLE_KEYS=() +DELIVERABLE_VALUES=() + +case "${1:-}" in + --help|-h) help; exit 0 ;; + '') usage; exit 2 ;; +esac + +while [ "$#" -gt 0 ]; do + case "$1" in + --home) shift; HOME_DIR=${1:-} ;; + --obligation) shift; OBLIGATION=${1:-} ;; + --relation) shift; RELATION=${1:-} ;; + --source-home) shift; SOURCE_HOME=${1:-} ;; + --work-id) shift; WORK_ID=${1:-} ;; + --generation) shift; GENERATION=${1:-} ;; + --outcome) shift; OUTCOME=${1:-} ;; + --outcome-text) shift; TEXT_MODE='inline'; TEXT_SOURCE=${1:-} ;; + --outcome-text-file) shift; TEXT_MODE='file'; TEXT_SOURCE=${1:-} ;; + --deliverable) + shift + case "${1:-}" in + *=*) ;; + *) die "--deliverable needs <key>=<value>, got '${1:-}'" ;; + esac + DELIVERABLE_KEYS+=("${1%%=*}") + DELIVERABLE_VALUES+=("${1#*=}") + ;; + --help|-h) help; exit 0 ;; + *) die "unknown argument '$1'" ;; + esac + shift || true +done + +[ -n "$HOME_DIR" ] || { usage; exit 2; } +[ -n "$OBLIGATION" ] || { usage; exit 2; } +[ -n "$RELATION" ] || { usage; exit 2; } +[ -n "$SOURCE_HOME" ] || { usage; exit 2; } +[ -n "$WORK_ID" ] || { usage; exit 2; } +[ -n "$GENERATION" ] || { usage; exit 2; } +[ -n "$OUTCOME" ] || { usage; exit 2; } +[ -n "$TEXT_MODE" ] || { usage; exit 2; } + +fm_pf_slug_valid "$OBLIGATION" || die "unsafe obligation id: $OBLIGATION" +fm_pf_slug_valid "$RELATION" || die "unsafe relation id: $RELATION" +fm_pf_slug_valid "$WORK_ID" || die "unsafe work id: $WORK_ID" +fm_pf_slug_valid "$OUTCOME" || die "unsafe outcome type: $OUTCOME" +fm_pf_home_id_valid "$SOURCE_HOME" \ + || die "source home must be 'main' or 'secondmate:<stable-id>', got '$SOURCE_HOME'" +case "$GENERATION" in + ''|*[!0-9]*) die "generation must be a positive integer, got '$GENERATION'" ;; +esac +[ "$GENERATION" -ge 1 ] || die "generation must be >= 1, got '$GENERATION'" + +i=0 +while [ "$i" -lt "${#DELIVERABLE_KEYS[@]}" ]; do + key=${DELIVERABLE_KEYS[$i]} + case "$key" in + ''|*[!a-z0-9_]*) die "deliverable key must be lowercase [a-z0-9_], got '$key'" ;; + esac + [ "${#DELIVERABLE_VALUES[$i]}" -le 512 ] \ + || die "deliverable '$key' exceeds 512 characters" + case "${DELIVERABLE_VALUES[$i]}" in + *[[:cntrl:]]*) die "deliverable '$key' must be single-line text with no control characters" ;; + esac + i=$((i + 1)) +done + +# Resolve the owning home to a real absolute directory before composing any path +# under it, so a relative or symlinked argument cannot make the destination +# ambiguous in a later message or write. +case "$HOME_DIR" in + /*) ;; + *) HOME_DIR=$(CDPATH='' cd -- "$HOME_DIR" 2>/dev/null && pwd -P) \ + || die "--home is not a reachable directory: $1" ;; +esac +[ -d "$HOME_DIR" ] && [ ! -L "$HOME_DIR" ] \ + || die "--home must name an existing directory, got '$HOME_DIR'" + +fm_pf_relay_active "$HOME_DIR" || exit 0 +command -v jq >/dev/null 2>&1 || die "jq is required to build a typed terminal event" 1 + +STATE="$HOME_DIR/state" +REGISTRY="$(fm_pf_registry_dir "$STATE")/$OBLIGATION" +if [ ! -f "$REGISTRY" ] || [ -L "$REGISTRY" ]; then + die "home '$HOME_DIR' has no public-followup registration for '$OBLIGATION'; the owning home registers a commitment before its work can report one" 1 +fi + +# The registration is the owning home's own record of what it bound, so checking +# the identity tuple against it catches a mis-briefed worker at the edge with a +# clear message. tasks-axi still re-validates everything at consume time and +# remains the authority; this is a cheap early refusal, not a second gatekeeper. +reg_mismatch() { + local field=$1 expected=$2 got=$3 + [ -z "$expected" ] || [ "$expected" = "$got" ] \ + || die "event $field '$got' does not match this home's registration ('$expected')" +} +reg_mismatch relation "$(fm_pf_registry_get "$STATE" "$OBLIGATION" relation_id)" "$RELATION" +reg_mismatch source-home "$(fm_pf_registry_get "$STATE" "$OBLIGATION" work_home)" "$SOURCE_HOME" +reg_mismatch work-id "$(fm_pf_registry_get "$STATE" "$OBLIGATION" work_id)" "$WORK_ID" +reg_mismatch generation "$(fm_pf_registry_get "$STATE" "$OBLIGATION" generation)" "$GENERATION" + +case "$TEXT_MODE" in + inline) OUTCOME_TEXT=$(printf '%s' "$TEXT_SOURCE" | fm_pf_clean_outcome_text) ;; + file) + if [ "$TEXT_SOURCE" = '-' ]; then + OUTCOME_TEXT=$(fm_pf_clean_outcome_text) + else + [ -f "$TEXT_SOURCE" ] || die "outcome text file not found: $TEXT_SOURCE" + OUTCOME_TEXT=$(fm_pf_clean_outcome_text < "$TEXT_SOURCE") + fi + ;; +esac +[ -n "$OUTCOME_TEXT" ] || die "outcome text is empty once whitespace and control characters are removed" + +# Canonical deliverables object: sorted keys, compact, so the same deliverables +# always hash to the same identity regardless of flag order. +DELIVERABLES_JSON=$( + { + i=0 + while [ "$i" -lt "${#DELIVERABLE_KEYS[@]}" ]; do + printf '%s\n%s\n' "${DELIVERABLE_KEYS[$i]}" "${DELIVERABLE_VALUES[$i]}" + i=$((i + 1)) + done + } | jq -Rsc 'split("\n") | .[:-1] | [range(0; length; 2) as $i | {key: .[$i], value: .[$i+1]}] | from_entries | to_entries | sort_by(.key) | from_entries' +) || die "could not encode deliverables" 1 + +EVENT_ID=$(fm_pf_event_id \ + "$OBLIGATION" "$RELATION" "$SOURCE_HOME" "$WORK_ID" "$GENERATION" "$OUTCOME" \ + "$DELIVERABLES_JSON") || die "sha256 (shasum or sha256sum) is required" 1 +# The derived id becomes a filename, so require the exact digest shape rather +# than trusting whatever the hashing tool printed. +case "$EVENT_ID" in + *[!0-9a-f]*|'') die "could not derive a usable event id" 1 ;; +esac +[ "${#EVENT_ID}" -eq 64 ] || die "could not derive a usable event id" 1 + +# jq bounds the outcome text by codepoint, so a long or non-ASCII sentence is +# capped without ever splitting a multi-byte character. +EVENT_JSON=$(jq -Sc -n \ + --argjson schema_version "$FM_PF_EVENT_SCHEMA_VERSION" \ + --arg event_id "$EVENT_ID" \ + --arg obligation_id "$OBLIGATION" \ + --arg relation_id "$RELATION" \ + --arg work_id "$WORK_ID" \ + --argjson generation "$GENERATION" \ + --arg source_home_id "$SOURCE_HOME" \ + --arg outcome_type "$OUTCOME" \ + --argjson deliverables "$DELIVERABLES_JSON" \ + --arg public_safe_outcome "$OUTCOME_TEXT" \ + --argjson outcome_max "$FM_PF_OUTCOME_TEXT_MAX" \ + --arg occurred_at "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ + '{schema_version:$schema_version, event_id:$event_id, obligation_id:$obligation_id, + relation_id:$relation_id, work_id:$work_id, generation:$generation, + source_home_id:$source_home_id, outcome_type:$outcome_type, + deliverables:$deliverables, + public_safe_outcome:($public_safe_outcome[0:$outcome_max]), + occurred_at:$occurred_at, successor:null}') \ + || die "could not build the typed terminal event" 1 + +EVENT_BYTES=$(printf '%s\n' "$EVENT_JSON" | LC_ALL=C wc -c | tr -d ' ') \ + || die "could not measure the typed terminal event" 1 +[ "$EVENT_BYTES" -le "$FM_PF_EVENT_BYTES_MAX" ] \ + || die "typed terminal event exceeds $FM_PF_EVENT_BYTES_MAX bytes" 2 + +printf '%s\n' "$EVENT_JSON" \ + | fmx_private_artifact_publish_stdin_once "$(fm_pf_events_dir "$STATE")" "$EVENT_ID.json" 600 +case $? in + 0|1) printf '%s\n' "$EVENT_ID" ;; + *) die "could not publish the terminal event into $HOME_DIR" 1 ;; +esac diff --git a/bin/fm-public-followup-lib.sh b/bin/fm-public-followup-lib.sh new file mode 100644 index 0000000000..dc7153d53c --- /dev/null +++ b/bin/fm-public-followup-lib.sh @@ -0,0 +1,247 @@ +# shellcheck shell=bash +# fm-public-followup-lib.sh - shared gating and private-transport helpers for the +# deterministic public-followup consumer. +# +# Firstmate promises a public final reply when a myfirstmate relay mention (X or +# Discord) asks for work. `tasks-axi public-followup` is the sole owner of that +# typed obligation and its state machine; state/x-context/ is the sole owner of +# the private full request context. This library owns only the small Firstmate +# side: the activation gate, the private per-home transport directories, and the +# deterministic terminal-event identity. +# +# Sourced, never executed. No side effects on source (it creates nothing), which +# is what keeps a relay-disabled home free of public-followup artifacts. +# set -u / set -e safe. +# +# GATE ORDER - the acceptance criterion for relay-disabled homes: +# 1. fm_pf_relay_active <home> the authoritative myfirstmate activation +# contract, a non-empty FMX_PAIRING_TOKEN in +# <home>/.env. There is no second flag. When +# <home>/.env is absent this is a single +# [ -f ] test and nothing else runs. +# 2. fm_pf_has_registrations O(1) presence check on the registry created +# / fm_pf_has_events only by the relay path (fm-public-followup.sh +# register). Relay-enabled homes with no +# public commitments stop here, so no +# tasks-axi call and no backlog scan happens. +# +# Private transport layout, all under <home>/state/public-followup (mode 0700, +# created only by `fm-public-followup.sh register`): +# registry/<obligation-id> registration record: the bounded public-safe +# binding (obligation, relation, work ref, +# generation, platform, request id). Presence hint +# and reverse work->obligation index only; the +# obligation itself always remains tasks-axi truth. +# events/<event-id>.json inbound typed terminal events awaiting +# reconciliation, one file per event id. +# consumed/<event-id> idempotency ledger: an accepted event id is never +# replayed, so duplicate emits and restart replay +# are no-ops. +# rejected/<event-id>.json events tasks-axi refused, kept with a +# rejected/<event-id>.reason one-line reason so a refusal is inspectable and +# never retried in a loop. +# surfaced last surfaced pending-event signature, so the +# existing relay poll wakes once per new event set +# instead of every cycle. +# +# Event identity is DERIVED, never random: fm_pf_event_id hashes the canonical +# identity tuple, so re-emitting the same terminal result produces the same +# event id and the same destination path. Idempotency therefore holds across +# retries, restarts, and duplicate child reports without any coordination. +# +# Depends on bin/fm-x-lib.sh for .env reading and the private-artifact +# publication primitives (atomic, single-link, mode-validated, non-executable); +# those remain that file's contract and are not restated here. + +_FM_PF_LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd 2>/dev/null)" || _FM_PF_LIB_DIR="." +# shellcheck source=bin/fm-x-lib.sh +. "$_FM_PF_LIB_DIR/fm-x-lib.sh" + +FM_PF_DIRNAME='public-followup' +# Consumed by the sourcing scripts, not by this library. +# shellcheck disable=SC2034 +FM_PF_EVENT_SCHEMA_VERSION=1 +# Bounded so a public-safe outcome line can never carry a raw public message, +# and so one event file stays small enough to read and validate cheaply. +FM_PF_OUTCOME_TEXT_MAX=${FM_PF_OUTCOME_TEXT_MAX:-600} +FM_PF_EVENT_BYTES_MAX=${FM_PF_EVENT_BYTES_MAX:-8192} + +# --- gate 1: the authoritative relay activation contract -------------------- + +# fm_pf_relay_active <home>: 0 when this home has opted into the myfirstmate +# relay, 1 otherwise. Identical contract to bootstrap's X-mode activation - a +# non-empty FMX_PAIRING_TOKEN in <home>/.env - so no second activation flag +# exists to drift. FMX_PAIRING_TOKEN in the environment wins, matching +# fmx_load_config, so a direct client call and this gate agree. +fm_pf_relay_active() { + local home=$1 token + if [ -n "${FMX_PAIRING_TOKEN+x}" ]; then + [ -n "${FMX_PAIRING_TOKEN-}" ] + return $? + fi + [ -f "$home/.env" ] || return 1 + token=$(fmx_env_get FMX_PAIRING_TOKEN "$home/.env") + [ -n "$token" ] +} + +# --- gate 2: O(1) presence checks on relay-path-owned registrations --------- + +fm_pf_root() { printf '%s\n' "$1/$FM_PF_DIRNAME"; } +fm_pf_registry_dir() { printf '%s\n' "$1/$FM_PF_DIRNAME/registry"; } +fm_pf_events_dir() { printf '%s\n' "$1/$FM_PF_DIRNAME/events"; } +fm_pf_consumed_dir() { printf '%s\n' "$1/$FM_PF_DIRNAME/consumed"; } +fm_pf_rejected_dir() { printf '%s\n' "$1/$FM_PF_DIRNAME/rejected"; } + +# fm_pf_dir_has_entry <dir>: 0 when <dir> is a real directory holding at least +# one non-dot entry. Stops at the first hit, so cost does not grow with the +# directory's size. +fm_pf_dir_has_entry() { + local dir=$1 entry + [ -d "$dir" ] && [ ! -L "$dir" ] || return 1 + for entry in "$dir"/*; do + [ -e "$entry" ] || continue + return 0 + done + return 1 +} + +fm_pf_has_registrations() { fm_pf_dir_has_entry "$(fm_pf_registry_dir "$1")"; } +fm_pf_has_events() { fm_pf_dir_has_entry "$(fm_pf_events_dir "$1")"; } + +# fm_pf_active <home> <state>: both gates, in order. The single predicate every +# caller outside the relay path should use before doing any public-followup work. +fm_pf_active() { + fm_pf_relay_active "$1" || return 1 + fm_pf_has_registrations "$2" || fm_pf_has_events "$2" +} + +# --- identifiers ------------------------------------------------------------ + +# fm_pf_slug_valid <value>: obligation ids, relation ids, work ids, and request +# ids all compose filenames. They arrive from tasks-axi, the relay, and child +# homes, so every one is checked against a conservative slug before use. +fm_pf_slug_valid() { + local v=$1 + case "$v" in + ''|.*|*[!A-Za-z0-9._-]*) return 1 ;; + esac + [ "${#v}" -le 128 ] +} + +# fm_pf_home_id_valid <home_id>: tasks-axi accepts "main" or +# "secondmate:<stable-id>" as a work_ref home. Validate the same shape here so a +# malformed source home is refused before it reaches a filename or a CLI call. +fm_pf_home_id_valid() { + local v=$1 + case "$v" in + main) return 0 ;; + secondmate:*) fm_pf_slug_valid "${v#secondmate:}" ;; + *) return 1 ;; + esac +} + +fm_pf_sha256() { + if command -v shasum >/dev/null 2>&1; then + shasum -a 256 2>/dev/null | awk '{print $1}' + elif command -v sha256sum >/dev/null 2>&1; then + sha256sum 2>/dev/null | awk '{print $1}' + else + return 1 + fi +} + +# fm_pf_event_id <obligation> <relation> <source_home> <work_id> <generation> +# <outcome_type> <deliverables-canonical> +# The stable idempotency identity. Derived from the identity tuple only, so the +# same terminal result always yields the same id no matter who emits it or how +# often. Public-safe outcome text is deliberately excluded: rewording the same +# landed outcome must not create a second event. +fm_pf_event_id() { + printf '%s\037%s\037%s\037%s\037%s\037%s\037%s' "$1" "$2" "$3" "$4" "$5" "$6" "$7" \ + | fm_pf_sha256 +} + +# --- bounded public-safe text ---------------------------------------------- + +# fm_pf_clean_outcome_text: read stdin, drop control characters, collapse every +# whitespace run to a single space, and trim. An event line therefore stays +# single-line and a raw pasted public message cannot ride along inside it. +# Deliberately does NOT truncate: a byte-wise cut would split a multi-byte +# character, so length bounding happens where it can count codepoints - jq, at +# the point the typed event is built. +fm_pf_clean_outcome_text() { + LC_ALL=C tr -d '\000-\010\013\014\016-\037\177' \ + | LC_ALL=C tr '\011\012\015' ' ' \ + | LC_ALL=C tr -s ' ' \ + | sed 's/^ //; s/ $//' +} + +# fm_pf_bound_bytes <max>: hard byte cap for text that never becomes JSON, such +# as a quarantined event's one-line refusal reason. +fm_pf_bound_bytes() { + LC_ALL=C cut -b "1-$1" +} + +# --- registry records ------------------------------------------------------- + +# fm_pf_registry_get <state> <obligation-id> <key>: read one key=value line from +# a registration record. Prints nothing and succeeds when absent. +fm_pf_registry_get() { + local state=$1 id=$2 key=$3 file line + fm_pf_slug_valid "$id" || return 1 + file="$(fm_pf_registry_dir "$state")/$id" + [ -f "$file" ] && [ ! -L "$file" ] || return 0 + line=$(grep -E "^${key}=" "$file" 2>/dev/null | tail -n1) || return 0 + printf '%s' "${line#*=}" +} + +# fm_pf_registry_ids <state>: every registered obligation id, one per line. +# The registry only ever holds this home's live public commitments, so this stays +# a bounded listing rather than a backlog scan. +fm_pf_registry_ids() { + local dir entry + dir=$(fm_pf_registry_dir "$1") + [ -d "$dir" ] && [ ! -L "$dir" ] || return 0 + for entry in "$dir"/*; do + [ -f "$entry" ] && [ ! -L "$entry" ] || continue + basename "$entry" + done +} + +# fm_pf_registry_ids_for_work <state> <work_home_id> <work_id>: the obligations +# this home registered against one exact work relation. Used by the completion +# guard so cleanup cannot declare bound work finished while its public promise is +# still open. +fm_pf_registry_ids_for_work() { + local state=$1 home_id=$2 work_id=$3 id + while IFS= read -r id; do + [ -n "$id" ] || continue + [ "$(fm_pf_registry_get "$state" "$id" work_home)" = "$home_id" ] || continue + [ "$(fm_pf_registry_get "$state" "$id" work_id)" = "$work_id" ] || continue + printf '%s\n' "$id" + done <<EOF +$(fm_pf_registry_ids "$state") +EOF +} + +# --- pending-event signature ------------------------------------------------ + +# Consumed by the sourcing scripts, not by this library. +# shellcheck disable=SC2034 +FM_PF_SURFACED_BASENAME=surfaced + +# fm_pf_events_signature <state>: a stable digest of the pending event id set. +# The relay poll compares it against the surfaced record so an unconsumed event +# wakes firstmate once per new event, not once per poll cycle. +fm_pf_events_signature() { + local dir entry names= + dir=$(fm_pf_events_dir "$1") + [ -d "$dir" ] && [ ! -L "$dir" ] || return 1 + for entry in "$dir"/*.json; do + [ -f "$entry" ] && [ ! -L "$entry" ] || continue + names="$names$(basename "$entry") +" + done + [ -n "$names" ] || return 1 + printf '%s' "$names" | LC_ALL=C sort | fm_pf_sha256 +} diff --git a/bin/fm-public-followup.sh b/bin/fm-public-followup.sh new file mode 100755 index 0000000000..aa754d9e64 --- /dev/null +++ b/bin/fm-public-followup.sh @@ -0,0 +1,906 @@ +#!/usr/bin/env bash +# fm-public-followup.sh - the deterministic consumer and delivery owner for +# public commitments made through the myfirstmate relay (X and Discord). +# +# THE PROBLEM THIS SOLVES: firstmate promises a public final reply, routes the +# work out, and then the conversation compacts or the session restarts. Nothing +# in memory survives, so the promise is only kept if reconciling it is a disk +# operation. Every command here reads durable state and nothing else. +# +# OWNERSHIP BOUNDARIES (do not re-implement any of these here): +# tasks-axi public-followup the typed obligation and its state machine. +# state/x-context/ the private full request context (fm-x-lib.sh). +# bin/fm-x-reply.sh posting to the relay, thread splitting, dry run. +# bin/fm-public-followup-lib.sh the activation gate and private transport. +# This script composes them; it never restates their contracts or schemas. +# +# ZERO OVERHEAD FOR HOMES THAT DO NOT USE THE RELAY: every subcommand gates +# first on the authoritative activation contract (a non-empty FMX_PAIRING_TOKEN +# in $FM_HOME/.env). Read-side and cleanup paths then use an O(1) presence check +# for registrations this home actually created. A relay-disabled home therefore +# runs one [ -f ] test before any backlog work: no tasks-axi call, no backlog scan, +# and no file created. Silent read-side commands return without output; commands +# that require an active relay report their configuration error after the same +# gate. A relay-enabled home with no live commitments stops at the second gate +# for the same cost. +# +# Usage: +# fm-public-followup.sh active +# Silent gate probe. Exit 0 when this home has live public-followup work +# worth looking at, 1 otherwise. Safe to call unconditionally. +# +# fm-public-followup.sh register <obligation-id> --relation <relation-id> +# --work-home <main|secondmate:<id>> --work-id <task-id> --generation <n> +# [--platform <x|discord>] [--request <request-id>] +# Record the binding the relay path just created with `tasks-axi +# public-followup add` + `bind-work`. This is the event-driven +# registration: it creates this home's private public-followup directories +# (0700) and the bounded public-safe registration record, which is what +# later makes the presence checks O(1) and lets bound work report a typed +# terminal result. Refuses when the relay is not active for this home. +# +# fm-public-followup.sh brief <obligation-id> +# Print the exact fm-public-followup-emit.sh command line the bound worker +# must run when its work reaches the promised terminal outcome, so the +# binding is copied into a brief instead of hand-assembled. +# +# fm-public-followup.sh consume +# Drain every pending typed terminal event: validate its derived identity, +# skip anything already accepted, apply `tasks-axi public-followup +# work-event`, and quarantine what tasks-axi refuses. Prints one +# "ready <obligation-id> <request-id> <platform>" line per obligation that +# became delivery-ready, and one "rejected <event-id>: <reason>" line per +# refusal. Silent when there is nothing to do. Duplicate events and restart +# replay are no-ops. +# +# fm-public-followup.sh pending +# One bounded public-safe line per unresolved commitment, for the session +# start digest. Prunes registrations whose obligation is already closed. +# Silent when nothing is unresolved. +# +# fm-public-followup.sh deliver <obligation-id> [--text-file <path>] +# Post the final public reply into the ORIGINAL thread and close the +# obligation. Uses the stored platform and opaque context binding, so the +# destination is never guessed. Without --text-file the accepted terminal +# event's bounded public-safe outcome is reused exactly, which keeps the +# common path deterministic. The sequence is begin-delivery with the +# payload hash, post, then record the posted receipt or a typed error. +# A validated receipt also clears any bound legacy X link before the +# registration is removed. +# An already-posted obligation is an idempotent success without another +# post; an obligation left in delivery-posting by a crash is REFUSED +# rather than posted again. +# +# fm-public-followup.sh record-posted <obligation-id> --attempt <n> --chunks <n> +# Close an obligation whose post is known to have landed on exactly +# attempt <n> with exactly <n> messages, without posting anything. This is +# the late-receipt path: use it when a post succeeded but its receipt was +# lost, never to paper over an unknown outcome. +# +# fm-public-followup.sh guard-work <work-home-id> <work-id> +# Exit 3 when this home has an unresolved public commitment bound to that +# exact work, printing one line per blocking obligation. Exit 0 otherwise. +# Cleanup paths call this so bound work is never treated as finished while +# its public promise is still open. +# +# fm-public-followup.sh retire <obligation-id> [--force] +# Drop the registration once its obligation is closed. --force is the +# explicit discard-approved escape hatch for an unresolved or missing +# obligation. +# +# Requires jq and a compatible tasks-axi for registration, reconciliation, +# delivery, cleanup guards, and retirement; `active` and `brief` only inspect +# local state. +# FM_PF_RETRY_BACKOFF_SECS (default 900) sets the next-attempt time recorded with +# a retryable delivery error. +set -u + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +FM_ROOT="${FM_ROOT_OVERRIDE:-$(cd "$SCRIPT_DIR/.." && pwd)}" +FM_HOME="${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}}" +STATE="${FM_STATE_OVERRIDE:-$FM_HOME/state}" +DATA="${FM_DATA_OVERRIDE:-$FM_HOME/data}" + +# shellcheck source=bin/fm-public-followup-lib.sh +. "$SCRIPT_DIR/fm-public-followup-lib.sh" +# shellcheck source=bin/fm-secondmate-registry-lib.sh +. "$SCRIPT_DIR/fm-secondmate-registry-lib.sh" + +RETRY_BACKOFF=${FM_PF_RETRY_BACKOFF_SECS:-900} +case "$RETRY_BACKOFF" in ''|*[!0-9]*) RETRY_BACKOFF=900 ;; esac + +usage() { + echo "usage: fm-public-followup.sh <active|register|brief|consume|pending|deliver|record-posted|guard-work|retire> [args]" >&2 +} + +# The header comment IS the help text, so the two can never drift apart. +help() { sed -n '2,/^set -u$/p' "$0" | sed '$d; s/^# \{0,1\}//'; } + +die() { printf 'fm-public-followup: %s\n' "$1" >&2; exit "${2:-2}"; } + +PF_TEMP_FILES=() +pf_cleanup_temp_files() { + [ "${#PF_TEMP_FILES[@]}" -eq 0 ] || rm -f -- "${PF_TEMP_FILES[@]}" +} +trap pf_cleanup_temp_files EXIT + +now_rfc3339() { date -u +%Y-%m-%dT%H:%M:%SZ; } + +# next_attempt_rfc3339: the retry time recorded with a retryable delivery error. +# BSD and GNU date disagree on the flag, so try both and print nothing when +# neither works - the error is still recorded, just without a retry time. +next_attempt_rfc3339() { + local at + at=$(( $(date +%s) + RETRY_BACKOFF )) + date -u -r "$at" +%Y-%m-%dT%H:%M:%SZ 2>/dev/null \ + || date -u -d "@$at" +%Y-%m-%dT%H:%M:%SZ 2>/dev/null \ + || true +} + +require_tools() { + command -v jq >/dev/null 2>&1 || die "jq is required" 1 + command -v tasks-axi >/dev/null 2>&1 || die "tasks-axi is required" 1 +} + +# Every tasks-axi call runs from the home whose backlog owns the obligation, the +# same convention bin/fm-decision-hold.sh uses for typed backlog state. +tx() { (cd "$FM_HOME" && tasks-axi "$@"); } + +# obligation_json <id>: the complete typed obligation payload on stdout, empty +# when the backlog simply has no such public-followup item, and a non-zero exit +# ONLY when the backlog could not be read at all. Callers depend on that +# distinction to report the right thing, so jq runs without -e here. tasks-axi +# stays the single source of truth; the registration record is never consulted +# for state. +obligation_json() { + local id=$1 out + out=$(tx public-followup list --json 2>/dev/null) || return 1 + [ -n "$out" ] || return 1 + printf '%s' "$out" | jq -c --arg id "$id" \ + '(.public_followups // []) | map(select(.id == $id)) | .[0] // empty' 2>/dev/null \ + || return 1 +} + +pf_field() { printf '%s' "$1" | jq -r "$2 // empty" 2>/dev/null; } + +# --- gates ------------------------------------------------------------------ + +# gate_or_exit: the shared silent gate for every read-side subcommand. Exits 0 +# with no output when this home has no public-followup work, so callers can +# invoke unconditionally without a relay-disabled home paying anything. +gate_or_exit() { + fm_pf_relay_active "$FM_HOME" || exit 0 + fm_pf_has_registrations "$STATE" || fm_pf_has_events "$STATE" || exit 0 +} + +# --- subcommand: active ----------------------------------------------------- + +cmd_active() { + fm_pf_relay_active "$FM_HOME" || exit 1 + fm_pf_has_registrations "$STATE" || fm_pf_has_events "$STATE" || exit 1 + exit 0 +} + +# --- subcommand: register --------------------------------------------------- + +cmd_register() { + local id=${1:-} + local relation='' work_home='' work_id='' generation='' platform='' request='' + [ -n "$id" ] || { usage; exit 2; } + shift + while [ "$#" -gt 0 ]; do + case "$1" in + --relation) shift; relation=${1:-} ;; + --work-home) shift; work_home=${1:-} ;; + --work-id) shift; work_id=${1:-} ;; + --generation) shift; generation=${1:-} ;; + --platform) shift; platform=${1:-} ;; + --request) shift; request=${1:-} ;; + *) die "unknown argument '$1'" ;; + esac + shift || true + done + + fm_pf_relay_active "$FM_HOME" \ + || die "this home has not opted into the myfirstmate relay, so it cannot own a public commitment" 1 + require_tools + + fm_pf_slug_valid "$id" || die "unsafe obligation id: $id" + fm_pf_slug_valid "$relation" || die "unsafe relation id: $relation" + fm_pf_slug_valid "$work_id" || die "unsafe work id: $work_id" + fm_pf_home_id_valid "$work_home" \ + || die "work home must be 'main' or 'secondmate:<stable-id>', got '$work_home'" + case "$generation" in + ''|*[!0-9]*) die "generation must be a positive integer, got '$generation'" ;; + esac + [ "$generation" -ge 1 ] || die "generation must be >= 1" + + local payload + payload=$(obligation_json "$id") \ + || die "could not read the backlog through tasks-axi" 1 + [ -n "$payload" ] \ + || die "no public-followup obligation '$id' in this home's backlog; create it with tasks-axi public-followup add before registering" 1 + + # The relation must already be bound, so a registration can never describe a + # binding tasks-axi does not have. + printf '%s' "$payload" | jq -e --arg r "$relation" --arg h "$work_home" --arg w "$work_id" \ + '(.public_followup.work_relations // []) + | map(select(.relation_id == $r and .work_ref.home_id == $h and .work_ref.task_id == $w)) + | length > 0' >/dev/null 2>&1 \ + || die "obligation '$id' has no bound relation '$relation' for $work_home/$work_id; run tasks-axi public-followup bind-work first" 1 + + [ -n "$platform" ] || platform=$(pf_field "$payload" '.public_followup.request.platform') + [ -n "$request" ] || request=$(pf_field "$payload" '.public_followup.request.request_id') + [ -z "$request" ] || fm_pf_slug_valid "$request" || die "unsafe request id: $request" + + local mkdir_target + for mkdir_target in "$(fm_pf_registry_dir "$STATE")" "$(fm_pf_events_dir "$STATE")" \ + "$(fm_pf_consumed_dir "$STATE")" "$(fm_pf_rejected_dir "$STATE")"; do + fmx_private_artifact_dir_prepare "$mkdir_target" >/dev/null \ + || die "could not prepare $mkdir_target" 1 + done + + printf 'obligation_id=%s\nrelation_id=%s\nwork_home=%s\nwork_id=%s\ngeneration=%s\nplatform=%s\nrequest_id=%s\n' \ + "$id" "$relation" "$work_home" "$work_id" "$generation" "$platform" "$request" \ + | fmx_private_artifact_publish_stdin "$(fm_pf_registry_dir "$STATE")" "$id" 600 \ + || die "could not write the registration record" 1 + + printf 'registered %s %s/%s generation=%s platform=%s\n' \ + "$id" "$work_home" "$work_id" "$generation" "${platform:-unknown}" +} + +# --- subcommand: brief ------------------------------------------------------ + +cmd_brief() { + local id=${1:-} relation work_home work_id generation + [ -n "$id" ] || { usage; exit 2; } + fm_pf_slug_valid "$id" || die "unsafe obligation id: $id" + fm_pf_relay_active "$FM_HOME" || die "the relay is not active for this home" 1 + [ -f "$(fm_pf_registry_dir "$STATE")/$id" ] \ + || die "no registration for '$id' in this home" 1 + + relation=$(fm_pf_registry_get "$STATE" "$id" relation_id) + work_home=$(fm_pf_registry_get "$STATE" "$id" work_home) + work_id=$(fm_pf_registry_get "$STATE" "$id" work_id) + generation=$(fm_pf_registry_get "$STATE" "$id" generation) + + cat <<EOF +When this work reaches its promised terminal outcome, report it as typed data +(never as a sentence for someone to parse) by running exactly: + + $FM_ROOT/bin/fm-public-followup-emit.sh \\ + --home $FM_HOME \\ + --obligation $id \\ + --relation $relation \\ + --source-home $work_home \\ + --work-id $work_id \\ + --generation $generation \\ + --outcome <pr-merged|report-ready|local-main|failed> \\ + --deliverable <key>=<value> \\ + --outcome-text '<one bounded public-safe sentence>' + +Do not post anything publicly yourself and do not look for the public thread: +the home above owns the reply. +EOF +} + +# --- subcommand: consume ---------------------------------------------------- + +# reject_event <file> <event-id> <reason>: quarantine one refused event with an +# inspectable reason so it is never retried in a loop. +reject_event() { + local file=$1 event_id=$2 reason=$3 rejected event_payload + rejected=$(fm_pf_rejected_dir "$STATE") + fmx_private_artifact_dir_prepare "$rejected" >/dev/null \ + || { printf 'rejected %s: %s (quarantine failed; event retained)\n' "$event_id" "$reason"; return 1; } + if ! printf '%s\n' "$reason" \ + | fmx_private_artifact_publish_stdin "$rejected" "$event_id.reason" 600 2>/dev/null; then + printf 'rejected %s: %s (quarantine failed; event retained)\n' "$event_id" "$reason" + return 1 + fi + if ! event_payload=$(cat "$file" 2>/dev/null); then + printf 'rejected %s: %s (quarantine failed; event retained)\n' "$event_id" "$reason" + return 1 + fi + if ! printf '%s' "$event_payload" \ + | fmx_private_artifact_publish_stdin "$rejected" "$event_id.json" 600 2>/dev/null; then + printf 'rejected %s: %s (quarantine failed; event retained)\n' "$event_id" "$reason" + return 1 + fi + if ! rm -f -- "$file" 2>/dev/null; then + printf 'rejected %s: %s (quarantine cleanup failed; event retained)\n' "$event_id" "$reason" + return 1 + fi + printf 'rejected %s: %s\n' "$event_id" "$reason" +} + +cmd_consume() { + gate_or_exit + fm_pf_has_events "$STATE" || exit 0 + require_tools + + local events_dir consumed_dir stderr_file file event_id payload derived out rc reason consume_rc=0 + local obligation delivery request platform + events_dir=$(fm_pf_events_dir "$STATE") + consumed_dir=$(fm_pf_consumed_dir "$STATE") + fmx_private_artifact_dir_prepare "$consumed_dir" >/dev/null \ + || die "could not prepare the consumed-event ledger" 1 + stderr_file=$(mktemp "${TMPDIR:-/tmp}/fm-pf-consume.XXXXXX") \ + || die "could not stage the reconciliation log" 1 + PF_TEMP_FILES+=("$stderr_file") + + for file in "$events_dir"/*.json; do + [ -f "$file" ] && [ ! -L "$file" ] || continue + event_id=$(basename "$file" .json) + + if ! fm_pf_slug_valid "$event_id"; then + printf 'rejected %s: unsafe event filename (event retained)\n' "$event_id" + consume_rc=1 + continue + fi + + # Already accepted on an earlier pass (duplicate emit, or a replay after + # restart): drop the copy without touching the state machine. + if [ -f "$consumed_dir/$event_id" ]; then + rm -f -- "$file" 2>/dev/null || true + continue + fi + + if [ "$(wc -c < "$file" 2>/dev/null || echo 0)" -gt "$FM_PF_EVENT_BYTES_MAX" ]; then + reject_event "$file" "$event_id" "event exceeds $FM_PF_EVENT_BYTES_MAX bytes" || consume_rc=1 + continue + fi + + if ! payload=$(jq -ce . "$file" 2>/dev/null) || [ -z "$payload" ]; then + reject_event "$file" "$event_id" "event is not valid JSON" || consume_rc=1 + continue + fi + + # The filename, the declared event_id, and the identity tuple must all agree. + # A mismatch means the file was hand-edited or built by something other than + # fm-public-followup-emit.sh, so it is refused before tasks-axi sees it. + if [ "$(pf_field "$payload" '.event_id')" != "$event_id" ]; then + reject_event "$file" "$event_id" "declared event_id does not match the filename" || consume_rc=1 + continue + fi + derived=$(fm_pf_event_id \ + "$(pf_field "$payload" '.obligation_id')" \ + "$(pf_field "$payload" '.relation_id')" \ + "$(pf_field "$payload" '.source_home_id')" \ + "$(pf_field "$payload" '.work_id')" \ + "$(pf_field "$payload" '.generation')" \ + "$(pf_field "$payload" '.outcome_type')" \ + "$(printf '%s' "$payload" | jq -Sc '.deliverables // {}' 2>/dev/null)") + if [ -z "$derived" ] || [ "$derived" != "$event_id" ]; then + reject_event "$file" "$event_id" "event id does not match its own identity fields" || consume_rc=1 + continue + fi + + obligation=$(pf_field "$payload" '.obligation_id') + if ! fm_pf_slug_valid "$obligation"; then + reject_event "$file" "$event_id" "unsafe obligation id in event" || consume_rc=1 + continue + fi + + # tasks-axi is the authority on source home, work id, generation, schema, + # outcome, and deliverables. Anything it refuses is quarantined verbatim. + # stderr is captured separately so a warning can never corrupt the JSON that + # the accepted path parses. + if out=$(tx public-followup work-event "$obligation" --event-file "$file" --json 2>"$stderr_file"); then + rc=0 + else + rc=$? + fi + if [ "$rc" -ne 0 ]; then + reason=$( { cat "$stderr_file" 2>/dev/null; printf '%s\n' "$out"; } \ + | grep -v '^[[:space:]]*$' | head -1 | fm_pf_clean_outcome_text | fm_pf_bound_bytes 400) + reject_event "$file" "$event_id" "${reason:-tasks-axi refused the event}" || consume_rc=1 + continue + fi + + if ! printf 'accepted %s\n' "$(now_rfc3339)" \ + | fmx_private_artifact_publish_stdin "$consumed_dir" "$event_id" 600 2>/dev/null; then + printf 'accepted %s: consumed ledger could not be recorded; event retained for reconciliation\n' "$event_id" + consume_rc=1 + continue + fi + if ! rm -f -- "$file" 2>/dev/null; then + printf 'accepted %s: consumed ledger recorded but event could not be removed; event retained for reconciliation\n' "$event_id" + consume_rc=1 + continue + fi + + delivery=$(printf '%s' "$out" | jq -r '.task.public_followup.delivery.state // empty' 2>/dev/null) + if [ "$delivery" = ready ]; then + request=$(printf '%s' "$out" | jq -r '.task.public_followup.request.request_id // empty' 2>/dev/null) + platform=$(printf '%s' "$out" | jq -r '.task.public_followup.request.platform // empty' 2>/dev/null) + printf 'ready %s %s %s\n' "$obligation" "${request:-unknown}" "${platform:-unknown}" + fi + done + + # A fresh event must be able to wake firstmate again, so drop the surfaced + # signature once the inbox has been worked. + rm -f -- "$(fm_pf_root "$STATE")/$FM_PF_SURFACED_BASENAME" 2>/dev/null || true + return "$consume_rc" +} + +# --- subcommand: pending ---------------------------------------------------- + +cmd_pending() { + gate_or_exit + + local listing id payload delivery task_state summary platform request printed=0 + # An unreadable backlog with registrations present is exactly the silence this + # whole path exists to prevent, so say so rather than printing nothing. + if ! command -v jq >/dev/null 2>&1 || ! command -v tasks-axi >/dev/null 2>&1 \ + || ! listing=$(tx public-followup list --json 2>/dev/null) || [ -z "$listing" ] \ + || ! printf '%s' "$listing" | jq -e ' + type == "object" + and (.public_followups | type == "array") + and all(.public_followups[]; + type == "object" + and (.id | type == "string") + and (.public_followup | type == "object") + and (.state | type == "string")) + ' >/dev/null 2>&1; then + if fm_pf_has_registrations "$STATE"; then + printf 'cannot read this home'\''s public commitments through tasks-axi; %s registration(s) are still recorded under state/%s/registry\n' \ + "$(fm_pf_registry_ids "$STATE" | grep -c . || true)" "$FM_PF_DIRNAME" + printed=1 + fi + if fm_pf_has_events "$STATE"; then + printf 'unconsumed terminal results are waiting; run %s/bin/fm-public-followup.sh consume\n' "$FM_ROOT" + printed=1 + fi + [ "$printed" -eq 1 ] || exit 0 + return 0 + fi + + while IFS= read -r id; do + [ -n "$id" ] || continue + payload=$(printf '%s' "$listing" | jq -ce --arg id "$id" \ + '(.public_followups // []) | map(select(.id == $id)) | .[0] // empty' 2>/dev/null) + if [ -z "$payload" ]; then + # The obligation is gone from the backlog (pruned after Done): the + # registration is stale bookkeeping, not evidence, so drop it. + if ! clear_public_followup_link "$id"; then + printf 'cannot clear the legacy X link for closed public commitment %s; registration retained for reconciliation\n' "$id" + printed=1 + continue + fi + rm -f -- "$(fm_pf_registry_dir "$STATE")/$id" 2>/dev/null || true + continue + fi + delivery=$(pf_field "$payload" '.public_followup.delivery.state') + task_state=$(pf_field "$payload" '.state') + if [ "$task_state" = 'done' ] || [ "$delivery" = 'posted' ] || [ "$delivery" = 'waived' ]; then + if ! clear_public_followup_link "$id"; then + printf 'cannot clear the legacy X link for closed public commitment %s; registration retained for reconciliation\n' "$id" + printed=1 + continue + fi + rm -f -- "$(fm_pf_registry_dir "$STATE")/$id" 2>/dev/null || true + continue + fi + summary=$(pf_field "$payload" '.public_followup.request.public_safe_summary' | fm_pf_clean_outcome_text) + platform=$(pf_field "$payload" '.public_followup.request.platform') + request=$(pf_field "$payload" '.public_followup.request.request_id') + printf 'unresolved %s state=%s platform=%s request=%s summary=%s\n' \ + "$id" "${delivery:-unknown}" "${platform:-unknown}" "${request:-unknown}" "$summary" + printed=1 + done <<EOF +$(fm_pf_registry_ids "$STATE") +EOF + + # Events that arrived while no agent was present are actionable on their own, + # so surface them even when every registration currently looks settled. + if fm_pf_has_events "$STATE"; then + printf 'unconsumed terminal results are waiting; run %s/bin/fm-public-followup.sh consume\n' "$FM_ROOT" + printed=1 + fi + [ "$printed" -eq 1 ] || exit 0 +} + +# --- subcommand: deliver ---------------------------------------------------- + +public_followup_registration_valid() { + local id=$1 file relation work_home work_id generation + file="$(fm_pf_registry_dir "$STATE")/$id" + [ -f "$file" ] && [ ! -L "$file" ] || return 1 + relation=$(fm_pf_registry_get "$STATE" "$id" relation_id) + work_home=$(fm_pf_registry_get "$STATE" "$id" work_home) + work_id=$(fm_pf_registry_get "$STATE" "$id" work_id) + generation=$(fm_pf_registry_get "$STATE" "$id" generation) + [ -n "$relation" ] && [ -n "$work_id" ] || return 1 + fm_pf_home_id_valid "$work_home" || return 1 + fm_pf_slug_valid "$work_id" || return 1 + case "$generation" in ''|*[!0-9]*) return 1 ;; esac +} + +public_followup_secondmate_home() { + local id=$1 meta home marker + fm_pf_home_id_valid "secondmate:$id" || return 1 + meta="$STATE/$id.meta" + home=$(fmx_meta_get "$meta" home) + if [ -z "$home" ] && [ -f "$DATA/secondmates.md" ] && [ ! -L "$DATA/secondmates.md" ]; then + home=$(secondmate_registry_field "$DATA/secondmates.md" "$id" home || true) + fi + [ -n "$home" ] || return 1 + case "$home" in /*) ;; *) return 1 ;; esac + home=$(CDPATH='' cd -- "$home" 2>/dev/null && pwd -P) || return 1 + [ -f "$home/.fm-secondmate-home" ] && [ ! -L "$home/.fm-secondmate-home" ] || return 1 + marker=$(sed -n '1p' "$home/.fm-secondmate-home" 2>/dev/null) + [ "$marker" = "$id" ] || return 1 + printf '%s\n' "$home" +} + +clear_public_followup_link() { + local id=$1 work_home work_id home state + public_followup_registration_valid "$id" || return 1 + work_home=$(fm_pf_registry_get "$STATE" "$id" work_home) + work_id=$(fm_pf_registry_get "$STATE" "$id" work_id) + [ -n "$work_home" ] && [ -n "$work_id" ] || return 1 + case "$work_home" in + main) + home=$FM_HOME + state=$STATE + ;; + secondmate:*) + home=$(public_followup_secondmate_home "${work_home#secondmate:}") || return 1 + state="$home/state" + ;; + *) return 1 ;; + esac + FM_HOME="$home" FM_STATE_OVERRIDE="$state" FM_ROOT_OVERRIDE="$FM_ROOT" \ + "$FM_ROOT/bin/fm-x-followup.sh" --clear "$work_id" >/dev/null +} + +public_followup_legacy_link_status() { + local payload=$1 relations work_home work_id home meta + if ! printf '%s' "$payload" | jq -e ' + (.public_followup.work_relations | type == "array") + and all(.public_followup.work_relations[]; + (.work_ref.home_id | type == "string") + and (.work_ref.task_id | type == "string") + ) + ' >/dev/null 2>&1; then + return 2 + fi + relations=$(printf '%s' "$payload" | jq -r ' + .public_followup.work_relations[] + | [.work_ref.home_id, .work_ref.task_id] + | @tsv + ' 2>/dev/null) || return 2 + [ -n "$relations" ] || return 2 + while IFS=$'\t' read -r work_home work_id; do + [ -n "$work_home" ] && [ -n "$work_id" ] || return 2 + case "$work_home" in + main) home=$FM_HOME ;; + secondmate:*) home=$(public_followup_secondmate_home "${work_home#secondmate:}") || return 2 ;; + *) return 2 ;; + esac + meta="$home/state/$work_id.meta" + [ -e "$meta" ] || continue + [ -f "$meta" ] && [ ! -L "$meta" ] || return 2 + [ -n "$(fmx_meta_get "$meta" x_request)" ] && return 0 + done <<EOF +$relations +EOF + return 1 +} + +record_error() { + local id=$1 attempt=$2 state=$3 code=$4 next=$5 tmp rc + tmp=$(mktemp "${TMPDIR:-/tmp}/fm-pf-error.XXXXXX") || return 1 + if [ -n "$next" ]; then + jq -n --argjson a "$attempt" --arg s "$state" --arg c "$code" \ + --arg o "$(now_rfc3339)" --arg n "$next" \ + '{state:$s, attempt_count:$a, error_code:$c, occurred_at:$o, next_attempt_at:$n}' > "$tmp" + else + jq -n --argjson a "$attempt" --arg s "$state" --arg c "$code" --arg o "$(now_rfc3339)" \ + '{state:$s, attempt_count:$a, error_code:$c, occurred_at:$o}' > "$tmp" + fi + tx public-followup record-error "$id" --error-file "$tmp" >/dev/null 2>&1 + rc=$? + rm -f -- "$tmp" + return "$rc" +} + +record_posted() { + local id=$1 attempt=$2 request=$3 platform=$4 chunks=$5 tmp rc + tmp=$(mktemp "${TMPDIR:-/tmp}/fm-pf-receipt.XXXXXX") || return 1 + jq -n --argjson a "$attempt" --arg r "$request" --arg p "$platform" \ + --argjson c "$chunks" --arg t "$(now_rfc3339)" \ + '{state:"posted", request_id:$r, platform:$p, attempt_count:$a, + total_chunks:$c, posted_chunks:$c, posted_at:$t}' > "$tmp" + tx public-followup record-delivery "$id" --receipt-file "$tmp" >/dev/null 2>&1 + rc=$? + rm -f -- "$tmp" + return "$rc" +} + +cmd_deliver() { + local id=${1:-} text_file= + [ -n "$id" ] || { usage; exit 2; } + shift + while [ "$#" -gt 0 ]; do + case "$1" in + --text-file) shift; text_file=${1:-} ;; + *) die "unknown argument '$1'" ;; + esac + shift || true + done + + fm_pf_slug_valid "$id" || die "unsafe obligation id: $id" + fm_pf_relay_active "$FM_HOME" \ + || die "this home has not opted into the myfirstmate relay, so it cannot post a public reply" 1 + require_tools + + local payload delivery attempt request platform text tmp_text hash chunks rc receipt receipt_fields receipt_dry_run link_status + payload=$(obligation_json "$id") || die "could not read the backlog through tasks-axi" 1 + [ -n "$payload" ] || die "no public-followup obligation '$id' in this home's backlog" 1 + + delivery=$(pf_field "$payload" '.public_followup.delivery.state') + request=$(pf_field "$payload" '.public_followup.request.request_id') + platform=$(pf_field "$payload" '.public_followup.request.platform') + attempt=$(pf_field "$payload" '.public_followup.delivery.attempt_count') + case "$attempt" in ''|*[!0-9]*) attempt=0 ;; esac + + case "$delivery" in + posted|waived) + if public_followup_registration_valid "$id"; then + if ! clear_public_followup_link "$id"; then + die "obligation '$id' is already $delivery, but its legacy X link could not be cleared; the registration was retained for reconciliation" 1 + fi + else + link_status=1 + public_followup_legacy_link_status "$payload" || link_status=$? + case "$link_status" in + 0) die "obligation '$id' is already $delivery, but its legacy X link cannot be cleared without a valid registration; reconcile it before any later terminal follow-up" 1 ;; + 1) ;; + *) die "obligation '$id' is already $delivery, but its registration is missing or invalid and the legacy X link cannot be verified; reconcile it before any later terminal follow-up" 1 ;; + esac + fi + rm -f -- "$(fm_pf_registry_dir "$STATE")/$id" 2>/dev/null || true + printf 'already delivered %s state=%s\n' "$id" "$delivery" + return 0 + ;; + ready|retry-due|context-blocked|unknown|partial) + public_followup_registration_valid "$id" \ + || die "public-followup registration for '$id' is missing or invalid; reconcile it before delivery so any legacy X link can be cleared" 1 + ;; + delivery-posting) + die "obligation '$id' is mid-delivery on attempt $attempt: a previous post was started and its outcome was never recorded. Confirm whether that post landed, then close it with 'record-posted $id --attempt $attempt --chunks <exact-count>' or reopen it for retry. Posting again here could duplicate the public reply." 1 + ;; + pending-work) + die "obligation '$id' is still waiting on its bound work; nothing to deliver yet" 1 + ;; + *) + die "obligation '$id' is in delivery state '${delivery:-unknown}', which is not deliverable" 1 + ;; + esac + + [ -n "$request" ] || die "obligation '$id' has no relay request id; its thread binding is unusable" 1 + + if [ -n "$text_file" ]; then + [ -f "$text_file" ] || die "reply text file not found: $text_file" + text=$(cat "$text_file") + else + # Deterministic default: reuse the accepted terminal event's bounded + # public-safe outcome exactly rather than paraphrasing a landed result. + text=$(printf '%s' "$payload" | jq -r ' + [(.public_followup.work_relations // [])[] + | (.accepted_events // [])[] + | .public_safe_outcome // empty] | last // empty' 2>/dev/null) + [ -n "$text" ] \ + || die "obligation '$id' carries no accepted public-safe outcome to reuse; pass --text-file with the reply you composed" 1 + fi + [ -n "$text" ] || die "the reply text is empty" 2 + + tmp_text=$(mktemp "${TMPDIR:-/tmp}/fm-pf-text.XXXXXX") || die "could not stage the reply text" 1 + PF_TEMP_FILES+=("$tmp_text") + receipt=$(mktemp "${TMPDIR:-/tmp}/fm-pf-postreceipt.XXXXXX") || die "could not stage the post receipt" 1 + PF_TEMP_FILES+=("$receipt") + printf '%s' "$text" > "$tmp_text" + + hash=$(fm_pf_sha256 < "$tmp_text") || die "sha256 (shasum or sha256sum) is required" 1 + [ -n "$hash" ] || die "could not hash the reply payload" 1 + + # begin-delivery is what makes a retry safe: it pins the attempt and the exact + # payload before anything leaves the machine. The attempt is read back rather + # than assumed, because every later receipt or error must name it exactly. + local begun + begun=$(tx public-followup begin-delivery "$id" --payload-hash "$hash" --json 2>/dev/null) \ + || die "tasks-axi refused to begin delivery for '$id'" 1 + attempt=$(printf '%s' "$begun" | jq -r '.task.public_followup.delivery.attempt_count // empty' 2>/dev/null) + case "$attempt" in + ''|*[!0-9]*) die "could not read the delivery attempt for '$id' after beginning it; nothing was posted" 1 ;; + esac + + rc=0 + FMX_REPLY_PLATFORM="$platform" FM_HOME="$FM_HOME" \ + "$FM_ROOT/bin/fm-x-reply.sh" "$request" --followup --receipt-file "$receipt" \ + --text-file "$tmp_text" >/dev/null || rc=$? + + if [ "$rc" -eq 0 ]; then + receipt_fields=$(jq -er --arg request "$request" ' + if type != "object" or .request_id != $request or .endpoint != "followup" + or (.chunks | type) != "number" or (.chunks < 1) or (.chunks != (.chunks | floor)) + or (.dry_run | type) != "boolean" then error("invalid receipt") + else [(.chunks | tostring), (.dry_run | tostring)] | @tsv end + ' "$receipt" 2>/dev/null) \ + || die "the public reply for '$id' POSTED but its receipt is missing or invalid; inspect the relay and close it with 'record-posted $id --attempt $attempt --chunks <exact-count>' before any retry" 1 + IFS=$'\t' read -r chunks receipt_dry_run <<EOF +$receipt_fields +EOF + if [ "$receipt_dry_run" = true ]; then + if ! record_error "$id" "$attempt" retry-due dry_run_no_post "$(next_attempt_rfc3339)"; then + die "dry-run for '$id' did not post and its retryable state could not be recorded; the obligation remains mid-delivery and needs explicit reconciliation before retry" 1 + fi + die "dry-run for '$id' did not post; recorded as retryable and left the obligation open" 1 + fi + if record_posted "$id" "$attempt" "$request" "$platform" "$chunks"; then + if ! clear_public_followup_link "$id"; then + die "the public reply for '$id' POSTED and its receipt was recorded, but its legacy X link could not be cleared; the registration was retained for reconciliation" 1 + fi + rm -f -- "$(fm_pf_registry_dir "$STATE")/$id" 2>/dev/null || true + printf 'delivered %s request=%s platform=%s chunks=%s\n' "$id" "$request" "$platform" "$chunks" + return 0 + fi + die "the public reply for '$id' POSTED but its receipt could not be recorded; close it with 'record-posted $id --attempt $attempt --chunks <exact-count>' before any retry, or the thread will get a second reply" 1 + fi + + case "$rc" in + 8) if ! record_error "$id" "$attempt" context-blocked reply_context_unresolved ""; then + die "the public reply for '$id' was not posted, and its held state could not be recorded; the obligation remains mid-delivery and needs explicit reconciliation before retry" 1 + fi + die "held '$id': the original thread's platform or size budget could not be resolved, so nothing was posted. Retry once the request context is recoverable." 1 ;; + 9) if ! record_error "$id" "$attempt" expired-action-required followup_binding_exhausted ""; then + die "the relay rejected '$id', and its expired state could not be recorded; the obligation remains mid-delivery and needs explicit reconciliation before retry" 1 + fi + die "the relay no longer accepts a follow-up for '$id' (window or cap exhausted); nothing was posted and this needs a captain decision" 1 ;; + *) if ! record_error "$id" "$attempt" retry-due relay_post_failed "$(next_attempt_rfc3339)"; then + die "posting the public reply for '$id' failed, and its retryable state could not be recorded; the obligation remains mid-delivery and needs explicit reconciliation before retry" 1 + fi + die "posting the public reply for '$id' failed (exit $rc); recorded as retryable, nothing was delivered" 1 ;; + esac +} + +# --- subcommand: record-posted --------------------------------------------- + +cmd_record_posted() { + local id=${1:-} attempt='' chunks='' + [ -n "$id" ] || { usage; exit 2; } + shift + while [ "$#" -gt 0 ]; do + case "$1" in + --attempt) shift; attempt=${1:-} ;; + --chunks) shift; chunks=${1:-} ;; + *) die "unknown argument '$1'" ;; + esac + shift || true + done + fm_pf_slug_valid "$id" || die "unsafe obligation id: $id" + case "$attempt" in ''|*[!0-9]*) die "--attempt <n> is required and must be an integer" ;; esac + case "$chunks" in ''|*[!0-9]*) die "--chunks <n> is required and must be a positive integer" ;; esac + [ "$chunks" -ge 1 ] 2>/dev/null || die "--chunks <n> is required and must be a positive integer" + fm_pf_relay_active "$FM_HOME" || die "the relay is not active for this home" 1 + public_followup_registration_valid "$id" \ + || die "public-followup registration for '$id' is missing or invalid; reconcile it before recording a receipt so any legacy X link can be cleared" 1 + require_tools + + local payload request platform + payload=$(obligation_json "$id") || die "could not read the backlog through tasks-axi" 1 + [ -n "$payload" ] || die "no public-followup obligation '$id' in this home's backlog" 1 + request=$(pf_field "$payload" '.public_followup.request.request_id') + platform=$(pf_field "$payload" '.public_followup.request.platform') + + record_posted "$id" "$attempt" "$request" "$platform" "$chunks" \ + || die "tasks-axi refused the receipt for '$id' attempt $attempt; the recorded attempt must match exactly" 1 + if ! clear_public_followup_link "$id"; then + die "the receipt for '$id' was recorded, but its legacy X link could not be cleared; the registration was retained for reconciliation" 1 + fi + rm -f -- "$(fm_pf_registry_dir "$STATE")/$id" 2>/dev/null || true + printf 'recorded %s attempt=%s request=%s\n' "$id" "$attempt" "$request" +} + +# --- subcommand: guard-work ------------------------------------------------- + +cmd_guard_work() { + local work_home=${1:-} work_id=${2:-} bound id payload delivery task_state blocked=0 + [ -n "$work_home" ] && [ -n "$work_id" ] || { usage; exit 2; } + fm_pf_relay_active "$FM_HOME" || exit 0 + fm_pf_has_registrations "$STATE" || exit 0 + + # Reading the registration records needs no tools, so establish whether this + # work is bound to any commitment before deciding anything else. + bound=$(fm_pf_registry_ids_for_work "$STATE" "$work_home" "$work_id") + [ -n "$bound" ] || exit 0 + + # From here the work IS bound to a public promise, so an unreadable state is a + # blocking answer, not a pass: cleanup must never proceed on a guess. + if ! command -v jq >/dev/null 2>&1 || ! command -v tasks-axi >/dev/null 2>&1; then + printf 'cannot verify the public commitments bound to %s/%s: jq and tasks-axi are required\n' \ + "$work_home" "$work_id" + exit 3 + fi + + while IFS= read -r id; do + [ -n "$id" ] || continue + if ! payload=$(obligation_json "$id"); then + printf 'cannot read the state of public commitment %s for %s/%s\n' "$id" "$work_home" "$work_id" + blocked=1 + continue + fi + # Gone from the backlog entirely (pruned after Done): nothing left to owe. + [ -n "$payload" ] || continue + delivery=$(pf_field "$payload" '.public_followup.delivery.state') + task_state=$(pf_field "$payload" '.state') + case "$task_state:$delivery" in + done:*|*:posted|*:waived) continue ;; + esac + printf 'public commitment %s is still %s for %s/%s\n' "$id" "${delivery:-unknown}" "$work_home" "$work_id" + blocked=1 + done <<EOF +$bound +EOF + [ "$blocked" -eq 0 ] || exit 3 +} + +# --- subcommand: retire ----------------------------------------------------- + +cmd_retire() { + local id=${1:-} force=0 payload delivery task_state + [ -n "$id" ] || { usage; exit 2; } + shift + while [ "$#" -gt 0 ]; do + case "$1" in + --force) force=1 ;; + *) die "unknown argument '$1'" ;; + esac + shift || true + done + fm_pf_slug_valid "$id" || die "unsafe obligation id: $id" + fm_pf_relay_active "$FM_HOME" || exit 0 + require_tools + + payload=$(obligation_json "$id") || die "could not read the backlog through tasks-axi" 1 + if [ -n "$payload" ]; then + delivery=$(pf_field "$payload" '.public_followup.delivery.state') + task_state=$(pf_field "$payload" '.state') + case "$task_state:$delivery" in + done:*|*:posted|*:waived) ;; + *) + [ "$force" -eq 1 ] \ + || die "obligation '$id' is still ${delivery:-unresolved}; retiring its registration now would hide an open public promise. Deliver it, waive it, or pass --force." 1 + ;; + esac + fi + if ! clear_public_followup_link "$id"; then + die "could not clear the legacy X link for '$id'; its registration was retained for reconciliation" 1 + fi + rm -f -- "$(fm_pf_registry_dir "$STATE")/$id" 2>/dev/null || true + printf 'retired %s\n' "$id" +} + +# --- dispatch --------------------------------------------------------------- + +CMD=${1:-} +case "$CMD" in + --help|-h|help) help; exit 0 ;; + '') usage; exit 2 ;; +esac +shift + +case "$CMD" in + active) cmd_active "$@" ;; + register) cmd_register "$@" ;; + brief) cmd_brief "$@" ;; + consume) cmd_consume "$@" ;; + pending) cmd_pending "$@" ;; + deliver) cmd_deliver "$@" ;; + record-posted) cmd_record_posted "$@" ;; + guard-work) cmd_guard_work "$@" ;; + retire) cmd_retire "$@" ;; + *) usage; exit 2 ;; +esac diff --git a/bin/fm-quota-axi-lib.sh b/bin/fm-quota-axi-lib.sh new file mode 100644 index 0000000000..441c9ce2c9 --- /dev/null +++ b/bin/fm-quota-axi-lib.sh @@ -0,0 +1,52 @@ +# shellcheck shell=bash +# 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. +# +# 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_compatible() { + local timeout=${1:-} output parts major minor patch extra + local min_major min_minor min_patch min_extra + command -v quota-axi >/dev/null 2>&1 || return 1 + if [ -n "$timeout" ]; then + case "$timeout" in + ''|*[!0-9]*|0) return 1 ;; + esac + if command -v timeout >/dev/null 2>&1; then + output=$(timeout "$timeout" quota-axi --version 2>/dev/null </dev/null) || return 1 + elif command -v gtimeout >/dev/null 2>&1; then + output=$(gtimeout "$timeout" quota-axi --version 2>/dev/null </dev/null) || return 1 + elif command -v perl >/dev/null 2>&1; then + output=$(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" quota-axi --version 2>/dev/null </dev/null) || return 1 + else + return 1 + fi + else + output=$(quota-axi --version 2>/dev/null </dev/null) || return 1 + fi + parts=$(printf '%s\n' "$output" | + sed -n 's/.*\([0-9][0-9]*\)\.\([0-9][0-9]*\)\.\([0-9][0-9]*\).*/\1 \2 \3/p' | + head -1) + 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 + # The floor is compared from FM_QUOTA_AXI_MIN so bumping it needs one edit. + IFS='.' read -r min_major min_minor min_patch min_extra <<< "$FM_QUOTA_AXI_MIN" + [ -n "$min_major" ] && [ -n "$min_minor" ] && [ -n "$min_patch" ] && [ -z "$min_extra" ] || return 1 + [ "$major" -gt "$min_major" ] && return 0 + [ "$major" -eq "$min_major" ] || return 1 + [ "$minor" -gt "$min_minor" ] && return 0 + [ "$minor" -eq "$min_minor" ] || return 1 + [ "$patch" -ge "$min_patch" ] +} diff --git a/bin/fm-secondmate-registry-lib.sh b/bin/fm-secondmate-registry-lib.sh new file mode 100644 index 0000000000..674df77a6a --- /dev/null +++ b/bin/fm-secondmate-registry-lib.sh @@ -0,0 +1,211 @@ +#!/usr/bin/env bash +# shellcheck disable=SC2034 # parsed fields are output globals for sourcing callers. +# Shared parser for data/secondmates.md records. +# +# A generated record ends with an explicit structured suffix: +# (home: ...; scope: ...; projects: ...; added YYYY-MM-DD) +# Summary text and scope text are natural language and may contain parentheses +# and semicolons, so field boundaries are anchored to the suffix markers rather +# than to the first incidental punctuation. + +SECONDMATE_REGISTRY_ID= +SECONDMATE_REGISTRY_SUMMARY= +SECONDMATE_REGISTRY_HOME= +SECONDMATE_REGISTRY_SCOPE= +SECONDMATE_REGISTRY_PROJECTS= +SECONDMATE_REGISTRY_ADDED= +SECONDMATE_REGISTRY_LINE= +SECONDMATE_REGISTRY_MATCH_HOME= +SECONDMATE_REGISTRY_MATCH_HOME_KEY= +SECONDMATE_REGISTRY_MATCH_PROJECTS= +SECONDMATE_REGISTRY_ERROR= + +secondmate_registry_parse_line() { + local line=$1 + local record_re='^- ([A-Za-z0-9._-]+) - (.+) \(home:[[:space:]]*([^;)]*);[[:space:]]*scope:[[:space:]]*(.*);[[:space:]]*projects:[[:space:]]*([^;)]*);[[:space:]]*added[[:space:]]+([0-9]{4}-[0-9]{2}-[0-9]{2})\)[[:space:]]*$' + SECONDMATE_REGISTRY_ID= + SECONDMATE_REGISTRY_SUMMARY= + SECONDMATE_REGISTRY_HOME= + SECONDMATE_REGISTRY_SCOPE= + SECONDMATE_REGISTRY_PROJECTS= + SECONDMATE_REGISTRY_ADDED= + if [[ "$line" =~ $record_re ]]; then + SECONDMATE_REGISTRY_ID=${BASH_REMATCH[1]} + SECONDMATE_REGISTRY_SUMMARY=${BASH_REMATCH[2]} + SECONDMATE_REGISTRY_HOME=${BASH_REMATCH[3]} + SECONDMATE_REGISTRY_SCOPE=${BASH_REMATCH[4]} + SECONDMATE_REGISTRY_PROJECTS=${BASH_REMATCH[5]} + SECONDMATE_REGISTRY_ADDED=${BASH_REMATCH[6]} + else + return 1 + fi + [ -n "$SECONDMATE_REGISTRY_HOME" ] || return 1 + [ -n "$SECONDMATE_REGISTRY_SCOPE" ] || return 1 + return 0 +} + +secondmate_registry_line_for_id() { + local reg=$1 id=$2 line count=0 + case "$id" in ''|*[!A-Za-z0-9._-]*) return 1 ;; esac + [ -f "$reg" ] && [ ! -L "$reg" ] || return 1 + while IFS= read -r line || [ -n "$line" ]; do + [ "$line" = "- $id" ] || case "$line" in "- $id "*) ;; *) continue ;; esac + count=$((count + 1)) + [ "$count" -eq 1 ] || return 1 + SECONDMATE_REGISTRY_LINE=$line + done < "$reg" + [ "$count" -eq 1 ] || return 1 + secondmate_registry_parse_line "$SECONDMATE_REGISTRY_LINE" +} + +secondmate_registry_field() { + local reg=$1 id=$2 key=$3 + secondmate_registry_line_for_id "$reg" "$id" || return 1 + case "$key" in + home) printf '%s\n' "$SECONDMATE_REGISTRY_HOME" ;; + projects) printf '%s\n' "$SECONDMATE_REGISTRY_PROJECTS" ;; + *) return 1 ;; + esac +} + +secondmate_registry_path_key() { + local path=$1 parent base + case "$path" in /*) ;; *) return 1 ;; esac + if [ -d "$path" ]; then + cd "$path" && pwd -P + else + parent=$(dirname "$path") + base=$(basename "$path") + cd "$parent" && printf '%s/%s\n' "$(pwd -P)" "$base" + fi +} + +secondmate_registry_validate_bindings() { + local reg=$1 resolver=$2 expected_id=${3:-} expected_home=${4:-} + local tmp snapshot bindings line id home home_key duplicate_homes duplicate_ids overlaps expected_home_key + SECONDMATE_REGISTRY_MATCH_HOME= + SECONDMATE_REGISTRY_MATCH_HOME_KEY= + SECONDMATE_REGISTRY_MATCH_PROJECTS= + SECONDMATE_REGISTRY_ERROR= + case "$expected_id" in *[!A-Za-z0-9._-]*) SECONDMATE_REGISTRY_ERROR="invalid secondmate id: $expected_id"; return 1 ;; esac + if [ ! -f "$reg" ] || [ -L "$reg" ]; then + SECONDMATE_REGISTRY_ERROR="secondmate registry is unavailable or unsafe: $reg" + return 1 + fi + tmp=$(mktemp -d "${TMPDIR:-/tmp}/fm-secondmate-registry.XXXXXX") || { + SECONDMATE_REGISTRY_ERROR="could not create secondmate registry validation state" + return 1 + } + snapshot="$tmp/registry" + bindings="$tmp/bindings" + if ! cat "$reg" > "$snapshot" 2>/dev/null || ! : > "$bindings"; then + rm -rf -- "$tmp" + SECONDMATE_REGISTRY_ERROR="secondmate registry is unavailable or unsafe: $reg" + return 1 + fi + while IFS= read -r line || [ -n "$line" ]; do + case "$line" in + "- "*) + if ! secondmate_registry_parse_line "$line"; then + rm -rf -- "$tmp" + SECONDMATE_REGISTRY_ERROR="malformed secondmate registry entry: $line" + return 1 + fi + id=$SECONDMATE_REGISTRY_ID + home=$SECONDMATE_REGISTRY_HOME + case "$home" in + /*) ;; + *) + rm -rf -- "$tmp" + SECONDMATE_REGISTRY_ERROR="unsafe non-absolute secondmate home for $id: $home" + return 1 + ;; + esac + case "$home" in + *$'\t'*) + rm -rf -- "$tmp" + SECONDMATE_REGISTRY_ERROR="unsafe secondmate home for $id" + return 1 + ;; + esac + home_key=$("$resolver" "$home" 2>/dev/null || true) + if [ -z "$home_key" ]; then + rm -rf -- "$tmp" + SECONDMATE_REGISTRY_ERROR="unresolvable secondmate home for $id: $home" + return 1 + fi + printf '%s\t%s\n' "$home_key" "$id" >> "$bindings" + if [ -n "$expected_id" ] && [ "$id" = "$expected_id" ]; then + SECONDMATE_REGISTRY_MATCH_HOME=$home + SECONDMATE_REGISTRY_MATCH_HOME_KEY=$home_key + SECONDMATE_REGISTRY_MATCH_PROJECTS=$SECONDMATE_REGISTRY_PROJECTS + fi + ;; + esac + done < "$snapshot" + duplicate_homes=$(awk -F '\t' ' + { + if ($1 in owner) { + print $1 ": " owner[$1] ", " $2 + bad=1 + } else { + owner[$1]=$2 + } + } + END { exit bad ? 1 : 0 } + ' "$bindings" 2>/dev/null) || { + rm -rf -- "$tmp" + SECONDMATE_REGISTRY_ERROR="duplicate secondmate home assignment: $duplicate_homes" + return 1 + } + duplicate_ids=$(awk -F '\t' ' + { + if ($2 in home) { + print $2 ": " home[$2] ", " $1 + bad=1 + } else { + home[$2]=$1 + } + } + END { exit bad ? 1 : 0 } + ' "$bindings" 2>/dev/null) || { + rm -rf -- "$tmp" + SECONDMATE_REGISTRY_ERROR="duplicate secondmate id assignment: $duplicate_ids" + return 1 + } + overlaps=$(awk -F '\t' ' + function ancestor(a, b) { return a != b && index(b, a "/") == 1 } + { + for (i = 1; i <= count; i++) { + if (ancestor($1, path[i])) { + print $1 " (" $2 ") contains " path[i] " (" id[i] ")" + bad=1 + } else if (ancestor(path[i], $1)) { + print path[i] " (" id[i] ") contains " $1 " (" $2 ")" + bad=1 + } + } + count++ + path[count]=$1 + id[count]=$2 + } + END { exit bad ? 1 : 0 } + ' "$bindings" 2>/dev/null) || { + rm -rf -- "$tmp" + SECONDMATE_REGISTRY_ERROR="overlapping secondmate home assignment: $overlaps" + return 1 + } + rm -rf -- "$tmp" + if [ -n "$expected_id" ] && [ -z "$SECONDMATE_REGISTRY_MATCH_HOME" ]; then + SECONDMATE_REGISTRY_ERROR="no registry binding for secondmate $expected_id" + return 1 + fi + if [ -n "$expected_home" ]; then + expected_home_key=$("$resolver" "$expected_home" 2>/dev/null || true) + if [ -z "$expected_home_key" ] || [ "$expected_home_key" != "$SECONDMATE_REGISTRY_MATCH_HOME_KEY" ]; then + SECONDMATE_REGISTRY_ERROR="secondmate $expected_id is registered at $SECONDMATE_REGISTRY_MATCH_HOME, not $expected_home" + return 1 + fi + fi + return 0 +} diff --git a/bin/fm-send.sh b/bin/fm-send.sh index dfae6f49e6..6755e56c7f 100755 --- a/bin/fm-send.sh +++ b/bin/fm-send.sh @@ -84,6 +84,26 @@ fm_send_id_from_meta() { # <meta-file> printf '%s' "${base%.meta}" } +fm_send_record_interrupt() { # <key> + local key=$1 id gen + [ "$key" = Escape ] || return 0 + case "$TARGET_HARNESS" in claude*) : ;; *) return 0 ;; esac + [ -n "$TARGET_META" ] || return 0 + id=$(fm_send_id_from_meta "$TARGET_META") + [ -f "$STATE/$id.busy-gen" ] || return 0 + gen=$(fm_meta_get "$TARGET_META" busy_gen) + if [ -n "$gen" ]; then + "$FM_ROOT/bin/fm-busy-event.sh" apply "$STATE" "$id" idle \ + --gen "$gen" --source fm-interrupt --event interrupt + else + "$FM_ROOT/bin/fm-busy-event.sh" apply "$STATE" "$id" idle \ + --current-gen --source fm-interrupt --event interrupt + fi || { + echo "error: key '$key' reached $T, but the Claude interrupt state could not be recorded for $id" >&2 + return 1 + } +} + fm_send_meta_for_key_value() { # <state-dir> <key> <value> local state=$1 key=$2 value=$3 meta got for meta in "$state"/*.meta; do @@ -224,6 +244,7 @@ if [ "${1:-}" = "--key" ]; then echo "error: key '$2' not sent to $T ($TARGET_BACKEND send failed; tried $RESOLUTION_TRIED)" >&2 exit 1 fi + fm_send_record_interrupt "$2" || exit 1 else MESSAGE=$* if [ "$MARK_FROM_FIRSTMATE" = 1 ]; then diff --git a/bin/fm-session-lock-lib.sh b/bin/fm-session-lock-lib.sh index 90303cda1c..357477af52 100644 --- a/bin/fm-session-lock-lib.sh +++ b/bin/fm-session-lock-lib.sh @@ -9,26 +9,58 @@ # This file is sourced by scripts and has no side effects on source. # Known harness command names; extend when a new adapter is verified. -FM_HARNESS_RE='claude|codex|opencode|grok|kimi|^pi$|^pi-signed$' +FM_HARNESS_RE='claude|codex|opencode|grok|kimi|agy|antigravity|^pi$|^pi-signed$' -# Walk the current process ancestry (up to 8 hops) and print the first pid whose -# command looks like a verified harness. The harness pid lives as long as the -# session, unlike the transient subshell pid of any one tool call. +# Walk the current process ancestry (up to 16 hops) and print a harness pid. +# For every harness except Claude, the first match wins (innermost pid), which +# is where e.g. Pi's shared signed-wrapper ancestry actually holds the session: +# a "pi-signed" launcher can be the direct parent of the inner "pi" engine +# pid that owns the lock, and the wrapper pid above it is not that owner. +# Claude Code's bg-spare hook worker chain is the opposite shape: it nests +# several claude-named processes directly parent-child with no non-harness +# process between them, and the lock is held by the outermost pid of that +# run. So once a claude-named match is found, this keeps walking past it +# looking for a still-more-ancestral claude-named match, and stops the +# instant a non-match follows - never walking past that gap to an unrelated +# claude-named process further up the real process tree (e.g. the live +# session that launched a test as its own subprocess). The harness pid lives +# as long as the session, unlike the transient subshell pid of any one tool +# call. fm_harness_ancestry_pid() { - local pid=$$ comm args - for _ in 1 2 3 4 5 6 7 8; do - comm=$(ps -o comm= -p "$pid" 2>/dev/null) || return 1 + local pid=$$ comm args best='' bc extending=0 hit=0 is_claude=0 + for _ in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16; do + comm=$(ps -o comm= -p "$pid" 2>/dev/null) || break args=$(ps -o args= -p "$pid" 2>/dev/null) - if printf '%s' "$(basename "$comm")" | grep -qE "$FM_HARNESS_RE"; then - echo "$pid"; return 0 + bc=$(basename -- "$comm") + hit=0; is_claude=0 + if printf '%s' "$bc" | grep -qE "$FM_HARNESS_RE"; then + hit=1 + case "$bc" in *claude*) is_claude=1 ;; esac + else + # Bare interpreter (e.g. node): match the harness name in its script path. + case "$comm" in + *node*|*python*) + if printf '%s' "$args" | grep -qE "$FM_HARNESS_RE"; then + hit=1 + case "$args" in *claude*) is_claude=1 ;; esac + fi + ;; + esac + fi + if [ "$hit" -eq 1 ]; then + best="$pid" + if [ "$is_claude" -eq 1 ]; then + extending=1 + else + break + fi + elif [ "$extending" -eq 1 ]; then + break fi - # Bare interpreter (e.g. node): match the harness name in its script path. - case "$comm" in - *node*|*python*) printf '%s' "$args" | grep -qE "$FM_HARNESS_RE" && { echo "$pid"; return 0; } ;; - esac pid=$(ps -o ppid= -p "$pid" 2>/dev/null | tr -d ' ') - [ -n "$pid" ] && [ "$pid" -gt 1 ] || return 1 + [ -n "$pid" ] && [ "$pid" -gt 1 ] || break done + [ -n "$best" ] && { echo "$best"; return 0; } return 1 } @@ -37,7 +69,7 @@ fm_harness_pid_alive() { local pid=$1 comm args kill -0 "$pid" 2>/dev/null || return 1 comm=$(ps -o comm= -p "$pid" 2>/dev/null) || return 1 - if printf '%s' "$(basename "$comm")" | grep -qE "$FM_HARNESS_RE"; then + if printf '%s' "$(basename -- "$comm")" | grep -qE "$FM_HARNESS_RE"; then return 0 fi case "$comm" in diff --git a/bin/fm-session-start.sh b/bin/fm-session-start.sh index 1abbace4bf..e4f21d0358 100755 --- a/bin/fm-session-start.sh +++ b/bin/fm-session-start.sh @@ -103,6 +103,8 @@ PRIMARY_HARNESS=$("$SCRIPT_DIR/fm-harness.sh" 2>/dev/null || printf unknown) . "$SCRIPT_DIR/fm-backend.sh" # shellcheck source=bin/fm-tasks-axi-lib.sh . "$SCRIPT_DIR/fm-tasks-axi-lib.sh" +# shellcheck source=bin/fm-public-followup-lib.sh +. "$SCRIPT_DIR/fm-public-followup-lib.sh" STATUS_TAIL=${FM_SESSION_START_STATUS_TAIL:-5} case "$STATUS_TAIL" in ''|*[!0-9]*) STATUS_TAIL=5 ;; esac @@ -394,6 +396,23 @@ else printf 'absent\n' fi +# Public commitments made through the myfirstmate relay. A promise to reply in a +# public thread must survive compaction and restart, so it is surfaced from disk +# here rather than from conversation memory. fm-public-followup-lib.sh owns both +# gates: a home that never opted into the relay runs one [ -f ] test, prints no +# subsection, and never reaches fm-public-followup.sh. +if fm_pf_relay_active "$FM_HOME" \ + && { fm_pf_has_registrations "$STATE" || fm_pf_has_events "$STATE"; }; then + PUBLIC_FOLLOWUP=$("$SCRIPT_DIR/fm-public-followup.sh" pending 2>/dev/null) || PUBLIC_FOLLOWUP= + if [ -n "$PUBLIC_FOLLOWUP" ]; then + subsection "Public commitments awaiting delivery" + printf '%s\n' "$PUBLIC_FOLLOWUP" + printf '\nEach line is a public reply this home still owes. Reconcile terminal results with\n' + printf '%s/bin/fm-public-followup.sh consume, then deliver a ready one with\n' "$FM_ROOT" + printf '%s/bin/fm-public-followup.sh deliver <id>. Load fmx-respond for the procedure.\n' "$FM_ROOT" + fi +fi + # --- 6. closing reminder ----------------------------------------------- section "NEXT STEP" if [ "$READ_ONLY" -eq 1 ]; then diff --git a/bin/fm-spawn.sh b/bin/fm-spawn.sh index 4413b5d4f0..1d8eddfd46 100755 --- a/bin/fm-spawn.sh +++ b/bin/fm-spawn.sh @@ -10,10 +10,12 @@ # installed CLIs were verified to support that axis; unsupported axes are omitted # from that harness's launch rather than guessed. # --backend <name> is the explicit runtime session-provider backend for this -# spawn. Without it, the script resolves FM_BACKEND, then config/backend, then -# runtime auto-detection (the runtime firstmate itself is executing inside - -# $TMUX, HERDR_ENV=1, or cmux runtime signals; bin/fm-backend.sh's -# fm_backend_detect, with cmux fallback details in docs/cmux-backend.md), +# exact task only (docs/configuration.md "Runtime backend" owns when that flag +# is authorized). Without it, the script resolves FM_BACKEND, then +# config/backend, then runtime auto-detection from the runtime firstmate's +# environment: $TMUX, HERDR_ENV=1, or cmux runtime signals (via +# bin/fm-backend.sh's fm_backend_detect, with cmux fallback details in +# docs/cmux-backend.md), # then tmux. # Spawn-capable backends are the reference tmux adapter and experimental # herdr, zellij, orca, and cmux. Orca owns both the task worktree and @@ -27,6 +29,15 @@ # A backend spawn refusal (missing dependency, version gate, unauthenticated # socket, or unsupported secondmate mode) is terminal for that selected backend; # callers must surface it instead of silently retrying another backend. +# A herdr crewmate or scout is placed in the exact workspace of the firstmate +# or secondmate process launching it, resolved from that process's own herdr +# pane rather than from a workspace label (herdr enforces no label uniqueness, +# so a label cannot tell two "firstmate" workspaces apart). A claimed parent +# identity that is unreadable, contradictory, stale, or from another herdr +# session stops the spawn before any worker endpoint exists. A launcher +# 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 # writes state/<id>.herdr-presentation atomically, then creates a disposable @@ -64,8 +75,10 @@ # whitespace is treated as a RAW launch command - the escape hatch for verifying # new adapters. pi-signed launches that exact executable name from PATH and # refuses before endpoint creation when it is unavailable; it never falls back to pi. -# agy is EXPERIMENTAL - single-worker only, no secondmate support, headless -# execution, and result validation through a strict result.json schema. +# agy is EXPERIMENTAL - single-worker only, no secondmate support. It launches +# bare into its interactive TUI (a trust dialog, then a real composer) and +# receives the brief as typed input once ready, matching the other verified +# interactive harnesses; see the harness-adapters skill for the verified facts. # config/secondmate-harness may also carry an optional model and effort as extra # whitespace-separated tokens ("<harness> [<model>] [<effort>]"). For a # --secondmate spawn, those tokens apply only when this spawn also resolves its @@ -117,7 +130,10 @@ set -eu SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" usage() { - sed -n '2,78p' "$0" | sed 's/^# \{0,1\}//' + # The whole leading comment block, ending at the first line that is not a + # comment. Derived rather than a fixed line range, which silently truncated + # this help mid-sentence every time the header above grew. + sed -n '2,${/^#/!q;p;}' "$0" | sed 's/^# \{0,1\}//' } case "${1:-}" in @@ -126,6 +142,26 @@ esac FM_ROOT="${FM_ROOT_OVERRIDE:-$(cd "$SCRIPT_DIR/.." && pwd)}" FM_HOME="${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}}" + +resolve_directory_input() { + local name=$1 path=$2 resolved + case "$path" in + /*) printf '%s\n' "$path"; return 0 ;; + esac + resolved=$(CDPATH='' cd -- "$path" 2>/dev/null && pwd -P) || { + echo "error: $name directory cannot be resolved: $path" >&2 + return 1 + } + printf '%s\n' "$resolved" +} + +FM_HOME=$(resolve_directory_input FM_HOME "$FM_HOME") || exit 1 +if [ -n "${FM_STATE_OVERRIDE:-}" ]; then + FM_STATE_OVERRIDE=$(resolve_directory_input FM_STATE_OVERRIDE "$FM_STATE_OVERRIDE") || exit 1 +fi +if [ -n "${FM_DATA_OVERRIDE:-}" ]; then + FM_DATA_OVERRIDE=$(resolve_directory_input FM_DATA_OVERRIDE "$FM_DATA_OVERRIDE") || exit 1 +fi STATE="${FM_STATE_OVERRIDE:-$FM_HOME/state}" DATA="${FM_DATA_OVERRIDE:-$FM_HOME/data}" PROJECTS="${FM_PROJECTS_OVERRIDE:-$FM_HOME/projects}" @@ -141,6 +177,8 @@ SUB_HOME_MARKER=".fm-secondmate-home" . "$SCRIPT_DIR/fm-backend.sh" # shellcheck source=bin/fm-gate-refuse-lib.sh . "$SCRIPT_DIR/fm-gate-refuse-lib.sh" +# shellcheck source=bin/fm-busy-lib.sh +. "$SCRIPT_DIR/fm-busy-lib.sh" # shellcheck source=bin/fm-pr-lib.sh . "$SCRIPT_DIR/fm-pr-lib.sh" # Fail closed before any fleet mutation: a no-mistakes gate agent must never spawn @@ -414,7 +452,7 @@ fi [ -z "$HARNESS_ARG" ] || ARG3=$HARNESS_ARG # The verified launch command per adapter. The knowledge half of each adapter -# (busy signature, exit command, dialogs, quirks) lives in the harness-adapters skill. +# (busy-state source, exit command, dialogs, quirks) lives in the harness-adapters skill. launch_template() { local harness=$1 kind=${2:-ship} # shellcheck disable=SC2016 # single quotes are deliberate: $(cat ...) expands in the crewmate pane, not here @@ -457,18 +495,18 @@ launch_template() { # Its turn-end signal is a globally configured Stop hook plus a guarded # per-task worktree token, so no launch placeholder belongs here. kimi) printf '%s' '__KIMIBIN__ __MODELFLAG__--auto' ;; - # AGY is a headless harness that runs `agy -p` (--print) to process the - # brief non-interactively and exits when done. - # It runs inside a PTY (tmux pane) so it sees a TTY; piped stdout is NOT - # the protocol. The watcher captures the pane after exit and validates the - # per-run result.json artifact. Exit code 0 is not trusted - only a valid - # result.json with status=success counts as completion. - # AGY does not support interactive steer or turn-end hooks, so there is no - # harness-specific hook installed below and no secondmate support. + # AGY (Antigravity CLI) launches bare into its real interactive TUI, the + # same shape as claude/codex/opencode/pi/grok/kimi: no positional prompt, + # no `-p`/`--output-format json` print mode. Print mode ran one turn then + # exited the process entirely, which cannot drive a multi-step gated flow + # like no-mistakes (commit -> PR -> respond to review/test gates -> CI + # green) because nobody is left alive to answer a gate after the first + # turn. AGY has no FIRSTMATE_OP parser, so the brief is sent as typed + # composer input (below), not the encoded launch-brief form the other + # harnesses use. # Model and effort flags are passed inline; unsupported effort values are - # omitted rather than guessed. - # The --log-file flag writes AGY's internal log to the task temp directory. - agy) printf '%s' 'agy -p --output-format json --dangerously-skip-permissions __MODELFLAG____EFFORTFLAG__--print-timeout ${FM_AGY_PRINT_TIMEOUT:-600}s --log-file __AGYLOGFILE__ "$(__OPINPUT__ encode launch-brief < __BRIEF__)"' ;; + # omitted rather than guessed (see effort_flag_for_harness). + agy) printf '%s' 'agy --dangerously-skip-permissions __MODELFLAG____EFFORTFLAG__' ;; *) return 1 ;; esac } @@ -544,18 +582,7 @@ if [ "$KIND" = secondmate ] && [ -z "$ARG3" ]; then fi secondmate_registry_value() { - local id=$1 key=$2 reg line value - reg="$DATA/secondmates.md" - [ -f "$reg" ] || return 1 - line=$(grep -E "^- $id( |$)" "$reg" | tail -1 || true) - [ -n "$line" ] || return 1 - case "$key" in - home) value=$(printf '%s\n' "$line" | sed -n 's/^[^(]*(home: \([^;)]*\);.*/\1/p') ;; - projects) value=$(printf '%s\n' "$line" | sed -n 's/^[^(]*(home: [^;)]*; scope: [^;)]*; projects: \([^;)]*\); added .*/\1/p') ;; - *) return 1 ;; - esac - [ -n "$value" ] || return 1 - printf '%s\n' "$value" + secondmate_registry_field "$DATA/secondmates.md" "$1" "$2" } shell_quote() { @@ -785,6 +812,13 @@ fi if [ "$KIND" = secondmate ]; then [ -n "$FIRSTMATE_HOME" ] || { echo "error: no firstmate home supplied or registered for $ID" >&2; exit 1; } PROJ_ABS=$(validate_firstmate_home_for_spawn "$ID" "$FIRSTMATE_HOME") + if [ -e "$DATA/secondmates.md" ] || [ -L "$DATA/secondmates.md" ]; then + if ! secondmate_registry_validate_bindings "$DATA/secondmates.md" resolve_path "$ID" "$FIRSTMATE_HOME"; then + echo "error: $SECONDMATE_REGISTRY_ERROR" >&2 + exit 1 + fi + SECONDMATE_PROJECTS=$SECONDMATE_REGISTRY_MATCH_PROJECTS + fi WT="$PROJ_ABS" # Local-HEAD sync: before launch, fast-forward this secondmate's worktree to the # PRIMARY checkout's current default-branch commit, so a freshly spawned or @@ -986,9 +1020,18 @@ case "$BACKEND" in # to PROJ_ABS for just these two calls (bash restores it automatically # after each prefixed simple-command call) so the secondmate's tab lands # in the secondmate's own workspace, not the primary's "firstmate" one. + # + # Placement, separately from labeling: a crewmate/scout belongs in the + # EXACT herdr workspace this launching process is itself running in, which + # only its own herdr pane identity can name (a same-labeled sibling + # workspace must never be adopted). A --secondmate launch is the exception - + # it stands up a DIFFERENT home's own workspace by design - so it asks for + # the per-home container instead of inheriting this launcher's. HERDR_LABEL_HOME=$FM_HOME + HERDR_LAUNCHER_RELATIONSHIP=launcher-home if [ "$KIND" = secondmate ]; then HERDR_LABEL_HOME=$PROJ_ABS + HERDR_LAUNCHER_RELATIONSHIP=other-home fi HERDR_PRESENTATION_JOURNAL=$(fm_backend_herdr_projection_journal_path "$STATE" "$ID") HERDR_PROJECTED=0 @@ -1043,8 +1086,21 @@ case "$BACKEND" in if ! fm_backend_herdr_server_ensure "$HERDR_SES"; then echo "warning: herdr presentation could not ensure its session server; using the ordinary flat layout without projection" >&2 elif spawn_herdr_presentation_order_lock_acquire "$HERDR_SES"; then - HERDR_PARENT_WORKSPACE_ID=$(fm_backend_herdr_projection_parent_workspace_exact \ - "$HERDR_SES" "$HERDR_PARENT_LABEL" 2>/dev/null || true) + # The projected child is placed and bound UNDER this launcher's exact + # parent workspace. Its own herdr pane identity names that workspace + # directly; the label lookup is only the fallback for a launcher with + # no herdr ancestry at all. A claimed-but-broken identity refuses here + # rather than projecting under a guessed parent. + set +e + fm_backend_herdr_launcher_identity "$HERDR_SES" + HERDR_LAUNCHER_STATUS=$? + set -e + case "$HERDR_LAUNCHER_STATUS" in + 0) HERDR_PARENT_WORKSPACE_ID=$FM_BACKEND_HERDR_LAUNCHER_WORKSPACE_ID ;; + 2) HERDR_PARENT_WORKSPACE_ID=$(fm_backend_herdr_projection_parent_workspace_exact \ + "$HERDR_SES" "$HERDR_PARENT_LABEL" 2>/dev/null || true) ;; + *) spawn_herdr_presentation_order_lock_release; exit 1 ;; + esac if [ -z "$HERDR_PARENT_WORKSPACE_ID" ]; then echo "warning: herdr presentation parent is absent or ambiguous; using the ordinary flat layout without projection" >&2 spawn_herdr_presentation_order_lock_release @@ -1072,7 +1128,7 @@ case "$BACKEND" in HERDR_PROJECTION_ABORT_TASK_PANE=$HERDR_PANE_ID HERDR_PROJECTION_ABORT_SEEDED_PANE=$FM_BACKEND_HERDR_PROJECTION_SEEDED_PANE_ID fm_backend_herdr_projection_order_best_effort \ - "$HERDR_SES" "$HERDR_WORKSPACE_ID" "$HERDR_PARENT_LABEL" + "$HERDR_SES" "$HERDR_WORKSPACE_ID" "$HERDR_PARENT_LABEL" "$HERDR_PARENT_WORKSPACE_ID" HERDR_HOME_ID=$(fm_backend_herdr_projection_home_identity "$HERDR_LABEL_HOME" 2>/dev/null || true) if [ -n "$HERDR_HOME_ID" ] \ && fm_backend_herdr_projection_live_binding_matches \ @@ -1094,7 +1150,7 @@ case "$BACKEND" in fi fi if [ "$HERDR_PROJECTED" -ne 1 ]; then - HERDR_CONTAINER_RAW=$(FM_HOME="$HERDR_LABEL_HOME" fm_backend_herdr_container_ensure "$PROJ_ABS") || exit 1 + HERDR_CONTAINER_RAW=$(FM_HOME="$HERDR_LABEL_HOME" fm_backend_herdr_container_ensure "$PROJ_ABS" "$HERDR_LAUNCHER_RELATIONSHIP") || exit 1 # fm_backend_herdr_container_ensure echoes "<session>:<workspace_id>\t<seeded_default_tab_id>" # (the second field empty when this call ADOPTED a pre-existing workspace # rather than creating a fresh one). Split on the guaranteed single tab @@ -1212,6 +1268,92 @@ kimi_capture() { fm_backend_capture "$BACKEND" "$T" 120 "$W" 2>/dev/null || true } +# AGY (Antigravity CLI) launch-then-send (VERIFIED 2026-07-29, agy 1.1.8): +# bare launch shows a first-run-per-directory trust dialog ("Do you trust the +# contents of this project?"), accepted with a single Enter (the default +# selection is already "Yes, I trust this folder"). The composer is then +# ready once the footer shows the idle "? for shortcuts" hint with no busy +# text. Sending a raw multi-line string via a literal keystroke send submits +# each embedded newline as its own separate turn rather than inserting a line +# break (verified live: a 3-line brief sent as one literal string produced +# three separate replies). AGY's own shortcuts panel documents a `\` + Enter +# fallback for inserting a literal newline without submitting, so every line +# but the last is sent as "<line>\" followed by a real Enter, and only the +# final line's Enter actually submits. Delivery is confirmed by the busy +# signature (spinner + "Generating..." above the composer, footer +# "esc to cancel") appearing, proving the turn actually started. +agy_capture() { + fm_backend_capture "$BACKEND" "$T" 200 "$W" 2>/dev/null || true +} + +agy_trust_dialog_showing() { # <plain-pane-capture> + printf '%s\n' "$1" | grep -Fq 'Do you trust the contents of this project' +} + +agy_composer_is_idle() { # <plain-pane-capture> + printf '%s\n' "$1" | grep -Fq '? for shortcuts' +} + +agy_is_busy() { # <plain-pane-capture> + printf '%s\n' "$1" | grep -Eq 'Generating\.\.\.|esc to cancel' +} + +agy_wait_for_ready() { + local pane i=0 max=${FM_AGY_READY_POLLS:-60} interval=${FM_AGY_POLL_INTERVAL:-0.5} trust_sent=0 + while [ "$i" -lt "$max" ]; do + pane=$(agy_capture) + if agy_composer_is_idle "$pane"; then + return 0 + fi + if [ "$trust_sent" -eq 0 ] && agy_trust_dialog_showing "$pane"; then + spawn_send_key "$T" Enter + trust_sent=1 + fi + i=$((i + 1)) + [ "$i" -ge "$max" ] || sleep "$interval" + done + return 1 +} + +# Sends the brief file's content as typed composer input. Every line but the +# last is sent as "<line>\" plus a real Enter (AGY's documented newline-insert +# fallback); the last line's Enter submits. Buffers one line behind so the +# final iteration can tell it is last without a second file pass. +agy_send_brief() { # <brief-file> + local brief_file=$1 line prev_line='' have_prev=0 + while IFS= read -r line || [ -n "$line" ]; do + if [ "$have_prev" -eq 1 ]; then + spawn_send_literal "$T" "$prev_line\\" + spawn_send_key "$T" Enter + sleep "${FM_AGY_LINE_SLEEP:-0.1}" + fi + prev_line=$line + have_prev=1 + done < "$brief_file" + if [ "$have_prev" -eq 1 ]; then + spawn_send_literal "$T" "$prev_line" + spawn_send_key "$T" Enter + fi +} + +agy_wait_for_delivery() { + local pane i=0 max=${FM_AGY_DELIVERY_POLLS:-40} interval=${FM_AGY_POLL_INTERVAL:-0.5} + while [ "$i" -lt "$max" ]; do + pane=$(agy_capture) + if agy_is_busy "$pane" || agy_composer_is_idle "$pane"; then + return 0 + fi + i=$((i + 1)) + [ "$i" -ge "$max" ] || sleep "$interval" + done + return 1 +} + +agy_spawn_fail() { # <detail> + printf 'failed: %s\n' "$1" >> "$STATE/$ID.status" + echo "error: $1; inspect window $T" >&2 +} + kimi_capture_has_empty_composer() { # <plain-pane-capture> printf '%s\n' "$1" \ | grep -Eq '^[[:space:]]*(│|┃|\|)[[:space:]]*>[[:space:]]*(│|┃|\|)[[:space:]]*$' @@ -1332,42 +1474,158 @@ exclude_path() { grep -qxF "$rel" "$EXCL" 2>/dev/null || echo "$rel" >> "$EXCL" } if [ "$KIND" != secondmate ]; then + # Arm the semantic busy-state contract (bin/fm-busy-lib.sh) for every + # adapter with a verified semantic source. The launch brief sent below IS a + # submitted turn, so the seed record is busy/fm-spawn. The minted gen is + # embedded into each adapter's wiring so an event from a superseded + # incarnation is rejected as stale. Grok stays on its isolated rendered-tail + # fallback and standalone Kimi stays unknown until fm_busy_kimi_verified + # opens, so neither is armed here. + BUSY_GEN= + case "$HARNESS" in + codex*) + if fm_busy_codex_semantic_source; then + echo "error: codex semantic busy-state wiring is not implemented; extend the probe only together with verified wiring" >&2 + exit 1 + fi + ;; + esac + case "$HARNESS" in + claude*|opencode*|pi|pi-signed) + BUSY_GEN=$("$FM_ROOT/bin/fm-busy-event.sh" arm "$STATE_REAL" "$ID") || { + echo "error: failed to arm the busy-state contract for $ID" >&2 + exit 1 + } + ;; + kimi*) + # Standalone Kimi stays unknown until fm_busy_kimi_verified opens on a + # live-verified installed version (bin/fm-busy-lib.sh owns the gate and + # the required evidence). Arming without wiring would seed a busy record + # nothing can ever clear, so the arm waits for the wiring. + if fm_busy_kimi_verified; then + echo "error: kimi semantic busy-state wiring is not implemented; open the gate only together with verified wiring" >&2 + exit 1 + fi + ;; + esac case "$HARNESS" in claude*) + # Semantic busy-state hooks (bin/fm-busy-lib.sh): UserPromptSubmit opens + # a turn; Stop (normal completion), StopFailure (API-error turn end), + # and SessionEnd (process shutdown) all close it, so an abnormal end can + # never leave a stale busy record. Claude fires no hook for a manual + # interrupt, so the firstmate-controlled interruption procedure + # (harness-adapters) records idle/fm-interrupt itself. Stop keeps the + # turn-ended NOTIFICATION touch for the watcher. Every hook command + # tolerates a refused event (|| true) so a stale-gen writer can never + # break Claude's own lifecycle. mkdir -p "$WT/.claude" + busy_cmd_prefix="$(shell_quote "$FM_ROOT/bin/fm-busy-event.sh") apply $(shell_quote "$STATE_REAL") $(shell_quote "$ID")" + busy_suffix="--gen $(shell_quote "$BUSY_GEN") --source claude-hook" + j_submit=$(json_escape "$busy_cmd_prefix busy $busy_suffix --event user-prompt-submit 2>/dev/null || true") + j_stop=$(json_escape "touch $(shell_quote "$TURNEND"); $busy_cmd_prefix idle $busy_suffix --event stop 2>/dev/null || true") + j_stopfail=$(json_escape "$busy_cmd_prefix idle $busy_suffix --event stop-failure 2>/dev/null || true") + j_sessionend=$(json_escape "$busy_cmd_prefix idle $busy_suffix --event session-end 2>/dev/null || true") cat > "$WT/.claude/settings.local.json" <<EOF -{"hooks":{"Stop":[{"hooks":[{"type":"command","command":"touch '$TURNEND'"}]}]}} +{"hooks":{"UserPromptSubmit":[{"hooks":[{"type":"command","command":"$j_submit"}]}],"Stop":[{"hooks":[{"type":"command","command":"$j_stop"}]}],"StopFailure":[{"hooks":[{"type":"command","command":"$j_stopfail"}]}],"SessionEnd":[{"hooks":[{"type":"command","command":"$j_sessionend"}]}]}} EOF exclude_path '.claude/settings.local.json' ;; opencode*) mkdir -p "$WT/.opencode/plugins" - cat > "$WT/.opencode/plugins/fm-turn-end.js" <<EOF -export const FmTurnEnd = async ({ \$ }) => ({ - event: async ({ event }) => { - if (event.type === "session.idle") await \$\`touch $TURNEND\` - }, -}) + cat > "$WT/.opencode/plugins/fm-busy-state.js" <<EOF +// Firstmate semantic busy-state events + turn-end notification; written by +// fm-spawn under the contract owned by bin/fm-busy-lib.sh. +// Semantic state comes from OpenCode's session.status events: busy and retry +// are active, idle is inactive. Scoping latches the first session that +// reports activity (the worker's main session - a subagent child session can +// only start while the main session is already busy) and ignores other +// sessions' status until the latched session settles, so a child's idle can +// never clear the worker's busy state. The session.idle touch stays the +// watcher's wake NOTIFICATION, never current-state truth. +import { execFile } from "node:child_process"; +const busyEvent = (state, event) => + new Promise((resolve) => { + execFile("$FM_ROOT/bin/fm-busy-event.sh", [ + "apply", "$STATE_REAL", "$ID", state, + "--gen", "$BUSY_GEN", "--source", "opencode-plugin", "--event", event, + ], () => resolve()); + }); +export const FmBusyState = async () => { + let activeSession = null; + return { + event: async ({ event }) => { + if (event.type === "session.status") { + const sessionID = event.properties.sessionID; + const statusType = event.properties.status && event.properties.status.type; + if (statusType === "busy" || statusType === "retry") { + if (activeSession === null) activeSession = sessionID; + if (sessionID === activeSession) await busyEvent("busy", "session-" + statusType); + return; + } + if (statusType === "idle" && sessionID === activeSession) { + activeSession = null; + await busyEvent("idle", "session-status-idle"); + } + return; + } + if (event.type === "session.idle") { + if (event.properties.sessionID === activeSession) { + activeSession = null; + await busyEvent("idle", "session-idle"); + } + await new Promise((resolve) => { + execFile("touch", ["$TURNEND"], () => resolve()); + }); + } + }, + }; +}; EOF - exclude_path '.opencode/plugins/fm-turn-end.js' + exclude_path '.opencode/plugins/fm-busy-state.js' ;; pi|pi-signed) # Written OUTSIDE the worktree: pi's project-trust gate fires on any extension # loaded from inside the project (verified live), but an explicit -e path # elsewhere loads without a dialog. Lives in state/, cleaned by teardown. cat > "$STATE/$ID.pi-ext.ts" <<EOF -// Firstmate turn-end signal; written by fm-spawn. -// Use "turn_end" (fires after each turn the agent finishes), not "agent_end" -// (fires once, only when the whole run exits): the watcher needs a signal at -// every turn boundary so an idle crewmate is surfaced, not just at shutdown. +// Firstmate semantic busy-state events + turn-end notification; written by +// fm-spawn under the contract owned by bin/fm-busy-lib.sh. +// Semantic state: "agent_start" -> busy when a low-level agent run begins; +// "agent_settled" -> idle only when ctx.isIdle() confirms Pi will not +// continue automatically - auto-retries, auto-compaction retries, tool +// loops, and queued continuations all keep the run un-settled, and a settle +// that raced another extension's fresh run keeps state busy via isIdle(). +// "turn_end" fires at every inner turn boundary (one LLM response plus its +// tool calls) and stays a wake NOTIFICATION touch for the watcher, never +// current-state truth. import { execFile } from "node:child_process"; +const busyEvent = (state: string, event: string) => + new Promise<void>((resolve) => { + execFile("$FM_ROOT/bin/fm-busy-event.sh", [ + "apply", "$STATE_REAL", "$ID", state, + "--gen", "$BUSY_GEN", "--source", "pi-ext", "--event", event, + ], () => resolve()); + }); export default function (pi: any) { + pi.on("agent_start", () => busyEvent("busy", "agent-start")); + pi.on("agent_settled", (_event: any, ctx: any) => { + if (ctx && typeof ctx.isIdle === "function" && !ctx.isIdle()) return; + return busyEvent("idle", "agent-settled"); + }); pi.on("turn_end", () => execFile("touch", ["$TURNEND"])); } EOF ;; codex*) - # codex: turn-end rides the launch command via -c notify=[...] and __TURNEND__. + # Semantic busy-state source negotiation (bin/fm-busy-lib.sh owns the + # probes and the evidence). Neither Codex path is usable on the + # installed binary: a pane worker's turns are not observable through + # the app-server protocol, and its lifecycle hooks did not fire for a + # firstmate-launched worker. Codex therefore classifies unknown with + # an explicit reason rather than falling back to idle, and no busy + # wiring is installed. The turn-end NOTIFICATION marker still rides + # the launch command via -c notify=[...] and __TURNEND__. ;; grok*) # grok fires a Stop hook at every turn boundary (verified, grok 0.2.73), the @@ -1434,9 +1692,9 @@ EOF exclude_path '.fm-kimi-turnend' ;; agy*) - # AGY is headless - it runs, prints JSON, and exits. - # No turn-end hook is needed; the watcher detects completion by - # capturing the pane and validating the result.json artifact. + # AGY has no verified turn-end hook surface (no documented lifecycle + # hook to install). The semantic busy-state owner therefore classifies + # AGY through its harness-scoped, explicit busy and idle pane signatures. ;; esac fi @@ -1445,11 +1703,10 @@ fi # Recorded in meta so fm-teardown's safety check and the validate/merge stages can # branch on them. Mode governs ship tasks; a scout's deliverable is a report, not a # merge, so scout teardown ignores mode. -SECONDMATE_PROJECTS= if [ "$KIND" = secondmate ]; then MODE=secondmate YOLO=off - SECONDMATE_PROJECTS=$(secondmate_registry_value "$ID" projects || true) + : "${SECONDMATE_PROJECTS:=}" else PROJ_NAME=$(basename "$PROJ_ABS") read -r MODE YOLO <<EOF @@ -1461,6 +1718,7 @@ META_WINDOW=$T [ "$BACKEND" = orca ] && META_WINDOW=$W { echo "window=$META_WINDOW" + echo "endpoint_task_id=$ID" echo "worktree=$WT" echo "project=$PROJ_ABS" echo "harness=$HARNESS" @@ -1470,6 +1728,7 @@ META_WINDOW=$T echo "tasktmp=$TASK_TMP" echo "model=${MODEL:-default}" echo "effort=${EFFORT:-default}" + [ -z "${BUSY_GEN:-}" ] || echo "busy_gen=$BUSY_GEN" # backend= is written only for a non-default (non-tmux) backend, so the # default path's meta stays byte-identical (absent backend= means tmux; # data/fm-backend-design-d7's P1 compatibility contract). @@ -1506,7 +1765,6 @@ sq_piext=$(shell_quote "$STATE/$ID.pi-ext.ts") sq_piturnend=$(shell_quote "$PROJ_ABS/.pi/extensions/fm-primary-turnend-guard.ts") sq_piwatch=$(shell_quote "$PROJ_ABS/.pi/extensions/fm-primary-pi-watch.ts") sq_opinput=$(shell_quote "$FM_ROOT/bin/fm-operational-input.sh") -sq_agylog=$(shell_quote "$TASK_TMP/agy.log") MODELFLAG=$(model_flag_for_harness "$HARNESS" "$MODEL") EFFORTFLAG=$(effort_flag_for_harness "$HARNESS" "$EFFORT") LAUNCH=${LAUNCH//__MODELFLAG__/$MODELFLAG} @@ -1517,10 +1775,20 @@ LAUNCH=${LAUNCH//__PIEXT__/$sq_piext} LAUNCH=${LAUNCH//__PITURNEND__/$sq_piturnend} LAUNCH=${LAUNCH//__PIWATCH__/$sq_piwatch} LAUNCH=${LAUNCH//__OPINPUT__/$sq_opinput} -LAUNCH=${LAUNCH//__AGYLOGFILE__/$sq_agylog} +# Crewmate panes are created by a long-lived tmux/herdr daemon that does not +# inherit firstmate's current environment, so a bare `claude` in the pane falls +# back to the default ~/.claude store even when firstmate itself runs under a +# different CLAUDE_CONFIG_DIR (for example a work-vs-personal subscription split). +# Forward firstmate's own resolved store onto the claude launch so the crewmate +# uses the same credential/config firstmate is authenticated with. Only when set; +# an unset value is the single-store default and needs no prefix. +if [ "$HARNESS" = claude ] && [ -n "${CLAUDE_CONFIG_DIR:-}" ]; then + LAUNCH="CLAUDE_CONFIG_DIR=$(shell_quote "$CLAUDE_CONFIG_DIR") $LAUNCH" +fi if [ "$KIND" = secondmate ]; then sq_home=$(shell_quote "$PROJ_ABS") - LAUNCH="FM_ROOT_OVERRIDE= FM_STATE_OVERRIDE= FM_DATA_OVERRIDE= FM_PROJECTS_OVERRIDE= FM_CONFIG_OVERRIDE= FM_HOME=$sq_home $LAUNCH" + sq_primary_home=$(shell_quote "$FM_HOME") + 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 $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 @@ -1558,6 +1826,17 @@ if [ "$HARNESS" = kimi ]; then exit 1 fi fi +if [ "$HARNESS" = agy ]; then + if ! agy_wait_for_ready; then + agy_spawn_fail "agy did not show a verified ready signal (trust dialog or idle composer) before brief delivery" + exit 1 + fi + agy_send_brief "$BRIEF_REAL" + if ! agy_wait_for_delivery; then + agy_spawn_fail "agy brief delivery was not confirmed (no busy or idle signal after submit)" + exit 1 + fi +fi if [ "$KIND" = secondmate ]; then if ! fm_config_reread_discard_pending "$PROJ_ABS" "$ID" "$FM_HOME"; then if fm_config_reread_quarantine_pending "$PROJ_ABS" "$ID" "$FM_HOME"; then diff --git a/bin/fm-startup-memory-budget-lib.sh b/bin/fm-startup-memory-budget-lib.sh new file mode 100644 index 0000000000..f2c06014b8 --- /dev/null +++ b/bin/fm-startup-memory-budget-lib.sh @@ -0,0 +1,224 @@ +# shellcheck shell=bash +# Startup-memory budget primitives. +# Usage: . bin/fm-startup-memory-budget-lib.sh +# +# The local, primary-authoritative config/startup-memory-budget setting is one +# strictly formatted positive decimal value followed by one newline. The +# locked primary bootstrap owns first materialization. This library owns safe +# parsing, default publication, and the portable prompt-memory estimate used by +# bin/fm-startup-memory-budget.sh and the internal /stow skill. + +FM_STARTUP_MEMORY_BUDGET_FILE="startup-memory-budget" +FM_STARTUP_MEMORY_BUDGET_DEFAULT="7500" +FM_STARTUP_MEMORY_BUDGET_ERROR="" +FM_STARTUP_MEMORY_BUDGET_VALUE="" +FM_STARTUP_MEMORY_MEASURE_BYTES="" +FM_STARTUP_MEMORY_MEASURE_TOKENS="" +FM_STARTUP_MEMORY_MEASURE_PRESENCE="" + +fm_startup_memory_budget_fail() { + FM_STARTUP_MEMORY_BUDGET_ERROR=$1 + return 1 +} + +fm_startup_memory_budget_link_count() { + if [ "$(uname)" = Darwin ]; then + stat -f %l "$1" 2>/dev/null + else + stat -c %h "$1" 2>/dev/null + fi +} + +fm_startup_memory_budget_config_dir_safe() { + local dir=$1 + if [ -L "$dir" ]; then + fm_startup_memory_budget_fail "config directory is symlinked" + return 1 + fi + if [ ! -d "$dir" ]; then + fm_startup_memory_budget_fail "config directory is not a directory" + return 1 + fi + return 0 +} + +# fm_startup_memory_budget_file_valid <path> +# Sets FM_STARTUP_MEMORY_BUDGET_VALUE only for a regular, single-linked file +# containing exactly one positive decimal value and one terminating newline. +fm_startup_memory_budget_file_valid() { + local path=$1 links value + FM_STARTUP_MEMORY_BUDGET_VALUE="" + if [ -L "$path" ]; then + fm_startup_memory_budget_fail "file is symlinked" + return 1 + fi + if [ ! -e "$path" ]; then + fm_startup_memory_budget_fail "file is absent" + return 1 + fi + if [ ! -f "$path" ]; then + fm_startup_memory_budget_fail "file is not a regular file" + return 1 + fi + links=$(fm_startup_memory_budget_link_count "$path") || { + fm_startup_memory_budget_fail "could not inspect file link count" + return 1 + } + if [ "$links" != 1 ]; then + fm_startup_memory_budget_fail "file is hardlinked" + return 1 + fi + value=$(<"$path") || { + fm_startup_memory_budget_fail "could not read file" + return 1 + } + case "$value" in + ''|0|*[!0-9]*|0*) + fm_startup_memory_budget_fail "value must be one positive decimal integer" + return 1 + ;; + esac + if ! printf '%s\n' "$value" | cmp -s "$path" -; then + fm_startup_memory_budget_fail "file must contain exactly one value followed by one newline" + return 1 + fi + FM_STARTUP_MEMORY_BUDGET_VALUE=$value + return 0 +} + +# fm_startup_memory_budget_read <config-dir> +# Prints the validated decimal value. It never treats an absent or unsafe file +# as an implicit default because callers need a visible, auditable setting. +fm_startup_memory_budget_read() { + local config_dir=$1 path + fm_startup_memory_budget_config_dir_safe "$config_dir" || return 1 + path="$config_dir/$FM_STARTUP_MEMORY_BUDGET_FILE" + fm_startup_memory_budget_file_valid "$path" || return 1 + printf '%s\n' "$FM_STARTUP_MEMORY_BUDGET_VALUE" +} + +# fm_startup_memory_budget_materialize <config-dir> +# Atomically publishes the visible default only when the file is absent. A +# concurrent valid creator is accepted; every unsafe or malformed existing +# artifact is rejected without replacement. +fm_startup_memory_budget_materialize() { + local config_dir=$1 path tmp + if [ -e "$config_dir" ] || [ -L "$config_dir" ]; then + fm_startup_memory_budget_config_dir_safe "$config_dir" || return 1 + else + mkdir -p "$config_dir" 2>/dev/null || { + fm_startup_memory_budget_fail "could not create config directory" + return 1 + } + fm_startup_memory_budget_config_dir_safe "$config_dir" || return 1 + fi + + path="$config_dir/$FM_STARTUP_MEMORY_BUDGET_FILE" + if [ -e "$path" ] || [ -L "$path" ]; then + fm_startup_memory_budget_read "$config_dir" >/dev/null || return 1 + return 0 + fi + + tmp=$(umask 077; mktemp "$config_dir/.startup-memory-budget.XXXXXX" 2>/dev/null) || { + fm_startup_memory_budget_fail "could not create default temporary file" + return 1 + } + if ! printf '%s\n' "$FM_STARTUP_MEMORY_BUDGET_DEFAULT" > "$tmp" \ + || ! fm_startup_memory_budget_file_valid "$tmp"; then + rm -f "$tmp" + [ -n "$FM_STARTUP_MEMORY_BUDGET_ERROR" ] \ + || fm_startup_memory_budget_fail "could not write default value" + return 1 + fi + + # link(2) gives no-clobber publication in this directory. Removing the + # temporary name leaves the published file with exactly one link. + if ln "$tmp" "$path" 2>/dev/null; then + rm -f "$tmp" + fm_startup_memory_budget_read "$config_dir" >/dev/null || return 1 + return 0 + fi + rm -f "$tmp" + # Another actor may have created the file. Accept it only if it now meets + # the same safe, exact format - never replace or guess at it. + fm_startup_memory_budget_read "$config_dir" >/dev/null +} + +# fm_startup_memory_estimated_tokens_for_bytes <non-negative bytes> +# The estimate is ceil(UTF-8 bytes / 3): stable, dependency-free, and +# deliberately conservative for ordinary prompt text without claiming provider +# exactness. +fm_startup_memory_estimated_tokens_for_bytes() { + local bytes=$1 tokens + case "$bytes" in + ''|*[!0-9]*) return 1 ;; + esac + tokens=$((bytes / 3)) + if [ $((bytes % 3)) -ne 0 ]; then + tokens=$((tokens + 1)) + fi + printf '%s\n' "$tokens" +} + +# fm_startup_memory_measure_file <path> +# Prints "<bytes> <estimated-tokens> <present|absent>". Memory files must be +# ordinary files when present so a measurement never follows a symlink or reads +# a special file. +fm_startup_memory_measure_file() { + local path=$1 bytes tokens + FM_STARTUP_MEMORY_MEASURE_BYTES="" + FM_STARTUP_MEMORY_MEASURE_TOKENS="" + FM_STARTUP_MEMORY_MEASURE_PRESENCE="" + if [ ! -e "$path" ] && [ ! -L "$path" ]; then + FM_STARTUP_MEMORY_MEASURE_BYTES=0 + FM_STARTUP_MEMORY_MEASURE_TOKENS=0 + FM_STARTUP_MEMORY_MEASURE_PRESENCE=absent + printf '0 0 absent\n' + return 0 + fi + if [ -L "$path" ] || [ ! -f "$path" ]; then + fm_startup_memory_budget_fail "memory file is not an ordinary regular file: $path" + return 1 + fi + bytes=$(LC_ALL=C wc -c < "$path" 2>/dev/null | tr -d '[:space:]') || { + fm_startup_memory_budget_fail "could not measure memory file: $path" + return 1 + } + case "$bytes" in + ''|*[!0-9]*) + fm_startup_memory_budget_fail "invalid byte count for memory file: $path" + return 1 + ;; + esac + tokens=$(fm_startup_memory_estimated_tokens_for_bytes "$bytes") || { + fm_startup_memory_budget_fail "could not estimate memory tokens for: $path" + return 1 + } + # shellcheck disable=SC2034 # Public measurement result consumed by the caller after sourcing. + FM_STARTUP_MEMORY_MEASURE_BYTES=$bytes + # shellcheck disable=SC2034 # Public measurement result consumed by the caller after sourcing. + FM_STARTUP_MEMORY_MEASURE_TOKENS=$tokens + # shellcheck disable=SC2034 # Public measurement result consumed by the caller after sourcing. + FM_STARTUP_MEMORY_MEASURE_PRESENCE=present + printf '%s %s present\n' "$bytes" "$tokens" +} + +# fm_startup_memory_decimal_le <left> <right> +# Decimal comparison without shell arithmetic overflow. Inputs are normalized +# non-negative decimal strings. +fm_startup_memory_decimal_le() { + local left=$1 right=$2 left_len right_len + case "$left:$right" in + *[!0-9:]*|:*|*:) return 1 ;; + esac + left_len=${#left} + right_len=${#right} + if [ "$left_len" -lt "$right_len" ]; then + return 0 + fi + if [ "$left_len" -gt "$right_len" ]; then + return 1 + fi + [ "$left" = "$right" ] && return 0 + [[ "$left" < "$right" ]] +} diff --git a/bin/fm-startup-memory-budget.sh b/bin/fm-startup-memory-budget.sh new file mode 100755 index 0000000000..715da54948 --- /dev/null +++ b/bin/fm-startup-memory-budget.sh @@ -0,0 +1,94 @@ +#!/usr/bin/env bash +# Read and account for the local startup-memory budget. +# Usage: +# fm-startup-memory-budget.sh read +# fm-startup-memory-budget.sh report +# +# `read` prints the one validated effective budget from +# config/startup-memory-budget. `report` prints the stable local estimate for +# data/captain.md, data/captain-shared.md, and data/learnings.md together. +# Bootstrap owns default materialization; this command never creates or repairs +# configuration, so an absent, malformed, symlinked, hardlinked, or otherwise +# unsafe value is a concrete error rather than an inferred default. +set -eu + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +FM_ROOT="${FM_ROOT_OVERRIDE:-$(cd "$SCRIPT_DIR/.." && pwd)}" +FM_HOME="${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}}" +CONFIG="${FM_CONFIG_OVERRIDE:-$FM_HOME/config}" +DATA="${FM_DATA_OVERRIDE:-$FM_HOME/data}" + +# shellcheck source=bin/fm-startup-memory-budget-lib.sh +. "$SCRIPT_DIR/fm-startup-memory-budget-lib.sh" + +usage() { + sed -n '2,11{s/^# \{0,1\}//;p;}' "$0" +} + +print_error() { + printf 'startup-memory-budget: %s\n' "$1" >&2 +} + +read_budget() { + if ! fm_startup_memory_budget_read "$CONFIG" >/dev/null; then + print_error "invalid config/$FM_STARTUP_MEMORY_BUDGET_FILE - $FM_STARTUP_MEMORY_BUDGET_ERROR" + return 1 + fi + printf '%s\n' "$FM_STARTUP_MEMORY_BUDGET_VALUE" +} + +report() { + local budget bytes tokens presence total=0 shared_tokens=0 role=primary + if ! budget=$(read_budget); then + return 2 + fi + + if [ -e "$FM_HOME/.fm-secondmate-home" ] || [ -L "$FM_HOME/.fm-secondmate-home" ]; then + role=secondmate + fi + + printf 'estimator=ceil(UTF-8 bytes / 3) conservative-local-estimate\n' + printf 'role=%s\n' "$role" + printf 'effective_budget_tokens=%s\n' "$budget" + for file in captain.md captain-shared.md learnings.md; do + if ! fm_startup_memory_measure_file "$DATA/$file" >/dev/null; then + print_error "$FM_STARTUP_MEMORY_BUDGET_ERROR" + return 2 + fi + bytes=$FM_STARTUP_MEMORY_MEASURE_BYTES + tokens=$FM_STARTUP_MEMORY_MEASURE_TOKENS + presence=$FM_STARTUP_MEMORY_MEASURE_PRESENCE + total=$((total + tokens)) + [ "$file" != captain-shared.md ] || shared_tokens=$tokens + printf 'file=data/%s bytes=%s estimated_tokens=%s status=%s\n' \ + "$file" "$bytes" "$tokens" "$presence" + done + printf 'total_estimated_tokens=%s\n' "$total" + if fm_startup_memory_decimal_le "$total" "$budget"; then + printf 'budget_status=within-budget\n' + else + printf 'budget_status=over-budget\n' + fi + if [ "$role" = secondmate ] \ + && ! fm_startup_memory_decimal_le "$shared_tokens" "$budget"; then + printf 'exception=primary-owned-shared-file-alone-exceeds-budget\n' + fi +} + +case "${1:-}" in + read) + [ "$#" -eq 1 ] || { usage >&2; exit 2; } + read_budget + ;; + report) + [ "$#" -eq 1 ] || { usage >&2; exit 2; } + report + ;; + -h|--help) + usage + ;; + *) + usage >&2 + exit 2 + ;; +esac diff --git a/bin/fm-subagent-pretool-check.sh b/bin/fm-subagent-pretool-check.sh index 169e3079d2..8edb507218 100755 --- a/bin/fm-subagent-pretool-check.sh +++ b/bin/fm-subagent-pretool-check.sh @@ -65,6 +65,19 @@ DELEGATION_STEMS='agent subagent task workflow cron schedul worktree delegate sp # reason a runaway task cannot be stopped. OBSERVE_ONLY_TOOLS='taskoutput taskstop taskget tasklist cronlist bashoutput killshell' +# Exact lowercase tool names that match a stem above but create no RUNNABLE +# work. These write only the harness's session-local todo list, which has no +# executor: it spawns no agent, allocates no worktree, registers no schedule, +# and starts nothing that could outlive the session or escape a firstmate +# guard. Denying them stops the primary tracking its own plan while granting no +# delegation power, and the deny text would tell it to run bin/fm-brief.sh for a +# todo entry, so the stem match here is a false positive rather than a policy. +# This is a separate list from OBSERVE_ONLY_TOOLS on purpose: these tools WRITE, +# so folding them into a list documented as observe-or-stop would make that +# contract untrue. Both lists are exact-name, never substring, so neither can +# widen by accident. +PLAN_ONLY_TOOLS='taskcreate taskupdate' + TOOL="" TOOL_SET=0 CLAUDE_MODE=0 @@ -139,7 +152,7 @@ case "$TOOL" in mcp__*) exit 0 ;; esac -for allowed in $OBSERVE_ONLY_TOOLS; do +for allowed in $OBSERVE_ONLY_TOOLS $PLAN_ONLY_TOOLS; do [ "$NORMALIZED" != "$allowed" ] || exit 0 done diff --git a/bin/fm-supervise-daemon.sh b/bin/fm-supervise-daemon.sh index 6ec14aed4b..400a8bf535 100755 --- a/bin/fm-supervise-daemon.sh +++ b/bin/fm-supervise-daemon.sh @@ -96,7 +96,8 @@ # (default 300) # FM_HOUSEKEEPING_TICK seconds between housekeeping passes while # the watcher is mid-cycle (default 15) -# FM_BUSY_REGEX optional global busy-signature override +# FM_BUSY_REGEX optional rendered busy-signature override +# for delivery guards and Grok's fallback # FM_COMPOSER_IDLE_RE empty-composer regex applied after dim-ghost # and structural border stripping (default: # bare prompt glyphs plus busy footers) @@ -174,6 +175,11 @@ FM_HOME="${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}}" # shellcheck source=bin/fm-supervisor-target-lib.sh . "$FM_DAEMON_DIR/fm-supervisor-target-lib.sh" +# The single owner of semantic busy state for recorded tasks +# (fm_busy_classify). +# shellcheck source=bin/fm-busy-lib.sh +. "$FM_DAEMON_DIR/fm-busy-lib.sh" + # --- tunables --------------------------------------------------------------- # Supervisor backends this daemon knows how to inject into today. zellij, orca, # and cmux are real backends elsewhere in firstmate (bin/fm-backend.sh) but this @@ -198,8 +204,9 @@ WEDGE_ALARM_NOTIFIER_PID= # The captain-relevant verb set and the status classifiers (last_status_line, # status_is_captain_relevant, window_to_task, scan_captain_relevant_statuses) now # live in bin/fm-classify-lib.sh, shared with the always-on watcher. -# Composer-empty detection and harness-scoped busy-footer matching live in -# bin/fm-tmux-lib.sh; FM_BUSY_REGEX still overrides every fallback here. +# Composer-empty detection, submit acknowledgement, and the harness-scoped +# supervisor-pane busy guard live in bin/fm-tmux-lib.sh. +# FM_BUSY_REGEX also overrides Grok's isolated task-state fallback. INJECT_FAIL_SLEEP_DEFAULT=30 INJECT_CONFIRM_RETRIES_DEFAULT=3 INJECT_CONFIRM_SLEEP_DEFAULT=0.5 @@ -544,34 +551,45 @@ mark_escalated_seen() { # <kind> <arg> <state> esac } -# Busy + composer-empty detection are the shared primitives in fm-tmux-lib.sh -# (one source of truth with fm-send.sh). These thin wrappers keep the daemon's -# call sites and the unit tests stable. +# Busy and composer-empty detection form the injection boundary. +# These thin wrappers keep the daemon's call sites and unit tests stable. # # pane_input_pending returns 0 unless the composer is positively proven empty. # This includes real unsubmitted text, ambiguous structure, unreadable state, # and future verdicts. The detector drops dim/faint ghost text and strips the # harness's composer box borders, so an aligned ghost-only or idle bordered # claude composer ("│ > … │") is correctly proven empty. -# pane_is_busy / pane_input_pending: BACKEND-AWARE now (previously tmux-only -# direct calls). <backend> defaults to tmux when omitted, so every existing -# caller/test that passes only <target> is unaffected. Dispatch goes through -# bin/fm-backend.sh's generic per-backend primitives (fm_backend_busy_state, -# fm_backend_capture, fm_backend_composer_state) rather than hand-rolling a -# case statement here, mirroring the fallback order stale_window_is_busy uses -# for per-task panes: try the backend's native busy state first, then match -# captured output. The supervisor pane has no recorded task harness and uses -# the historical combined fallback; stale task panes select the recorded -# harness's verified signature. +# pane_is_busy / pane_input_pending: BACKEND-AWARE (dispatch goes through +# bin/fm-backend.sh's generic per-backend primitives rather than a hand-rolled +# case statement here). <backend> defaults to tmux when omitted, so every +# existing caller/test that passes only <target> is unaffected. +# +# This rendered reader applies only to the supervisor pane during away-mode +# injection. It never classifies a recorded worker task. The detected primary +# harness selects exactly one signature, so output from another harness cannot +# make the primary read busy. +# +# Resolved lazily and memoized: harness detection walks process ancestry, which +# is too heavy to pay on every source of this library (the unit tests and the +# launcher source it purely for its pure functions). +fm_daemon_primary_harness() { + if [ -z "${FM_DAEMON_PRIMARY_HARNESS:-}" ]; then + FM_DAEMON_PRIMARY_HARNESS=$("$FM_DAEMON_DIR/fm-harness.sh" 2>/dev/null || printf 'unknown') + [ -n "$FM_DAEMON_PRIMARY_HARNESS" ] || FM_DAEMON_PRIMARY_HARNESS=unknown + fi + printf '%s' "$FM_DAEMON_PRIMARY_HARNESS" +} + pane_is_busy() { # <target> [backend] - local target=$1 backend=${2:-tmux} bs tail40 - bs=$(fm_backend_busy_state "$backend" "$target" 2>/dev/null) - case "$bs" in + local target=$1 backend=${2:-tmux} native tail40 harness + harness=$(fm_daemon_primary_harness) + native=$(fm_backend_busy_state "$backend" "$target" 2>/dev/null) + case "$native" in busy) return 0 ;; esac tail40=$(fm_backend_capture "$backend" "$target" 40 2>/dev/null) || return 1 printf '%s' "$tail40" | grep -v '^[[:space:]]*$' | tail -12 \ - | fm_busy_lines_match + | fm_busy_lines_match "$harness" } # pane_input_pending dispatches through fm_backend_composer_state and treats @@ -593,21 +611,23 @@ task_window_harness() { # <window> <state> local win=$1 state=$2 task meta task=$(window_to_task "$win" "$state") meta="$state/$task.meta" - grep '^harness=' "$meta" | cut -d= -f2- || true + grep '^harness=' "$meta" 2>/dev/null | cut -d= -f2- || true } +# stale_window_is_busy: 0 when the task is PROVABLY working through the +# semantic busy-state contract (bin/fm-busy-lib.sh), 1 when it is not, and 2 +# when the endpoint could not be read at all. Only an exact busy verdict is +# working: unknown semantic state never becomes busy and never becomes a +# silent idle, so a stale pane whose state cannot be proven surfaces. stale_window_is_busy() { # <window> <state> - local win=$1 state=$2 backend harness label tail40 bs + local win=$1 state=$2 backend harness label task tail40 verdict backend=$(task_window_backend "$win" "$state") harness=$(task_window_harness "$win" "$state") - label="fm-$(window_to_task "$win" "$state")" + task=$(window_to_task "$win" "$state") + label="fm-$task" tail40=$(fm_backend_capture "$backend" "$win" 40 "$label" 2>/dev/null) || return 2 - bs=$(fm_backend_busy_state "$backend" "$win" 2>/dev/null) - case "$bs" in - busy) return 0 ;; - esac - printf '%s' "$tail40" | grep -v '^[[:space:]]*$' | tail -12 \ - | fm_busy_lines_match "$harness" + verdict=$(fm_busy_classify "$backend" "$win" "$harness" "$task" "$state" "$tail40") + [ "${verdict%% *}" = busy ] } escalate_add() { # <state> <distilled-item> @@ -1113,8 +1133,7 @@ inject_msg() { # <message> [state] # discovery), matching this function's pre-existing default assumption. backend="${FM_SUPERVISOR_BACKEND:-tmux}" fm_backend_target_exists "$backend" "$target" || return 1 - # (3) Busy-guard: never inject into an in-use pane. - # a) pane_is_busy: the harness shows a busy footer (agent mid-turn). + # (3) Busy-guard: never inject into an in-use supervisor pane. if pane_is_busy "$target" "$backend"; then log "inject deferred: supervisor pane busy (agent mid-turn)" return 1 @@ -1181,7 +1200,7 @@ is_wake_reason() { # <reason> # --- dispatch one wake reason to self-handle or escalate -------------------- # Side effects: logging, marker records, escalation buffer appends. handle_wake() { # <reason> <state> - local reason=$1 state=$2 decision action distilled task last + local reason=$1 state=$2 decision action distilled task last stale_detail local kind="" arg="" if should_force_self "$reason"; then log "wake force-self (FM_INJECT_SKIP): $reason" @@ -1190,8 +1209,13 @@ handle_wake() { # <reason> <state> case "$reason" in signal:*) kind=signal; arg="${reason#signal: }" decision=$(classify_signal "$arg" "$state") ;; - stale:*) kind=stale; arg="${reason#stale: }" - decision=$(classify_stale "$arg" "$state") ;; + stale:*) kind=stale; arg="${reason#stale: }"; stale_detail="${arg#"$arg"}" + case "$arg" in *" ("*) stale_detail="${arg#*" ("}"; arg="${arg%% \(*}" ;; esac + decision=$(classify_stale "$arg" "$state") + case "$stale_detail" in + idle\ *s,\ possible\ wedge,\ escalation\ *) + decision="escalate|${reason#stale: }" ;; + esac ;; check:*) decision=$(classify_check "$reason") ;; heartbeat|heartbeat:*) decision=$(classify_heartbeat) ;; *) decision=$(classify_unknown "$reason") ;; diff --git a/bin/fm-teardown.sh b/bin/fm-teardown.sh index 550a09a39a..ed1da4fa95 100755 --- a/bin/fm-teardown.sh +++ b/bin/fm-teardown.sh @@ -106,6 +106,10 @@ SUB_HOME_MARKER=".fm-secondmate-home" . "$SCRIPT_DIR/fm-gate-refuse-lib.sh" # shellcheck source=bin/fm-pr-lib.sh . "$SCRIPT_DIR/fm-pr-lib.sh" +# shellcheck source=bin/fm-public-followup-lib.sh +. "$SCRIPT_DIR/fm-public-followup-lib.sh" +# shellcheck source=bin/fm-secondmate-registry-lib.sh +. "$SCRIPT_DIR/fm-secondmate-registry-lib.sh" if [ "$#" -lt 1 ] || ! fm_task_id_path_safe "$1"; then echo "error: invalid teardown request" >&2 exit 2 @@ -116,23 +120,29 @@ FORCE=${2:-} # down a worktree (see bin/fm-gate-refuse-lib.sh). fm_refuse_if_gate_agent FM_LOCK_LOG_PREFIX=teardown -"$FM_ROOT/bin/fm-guard.sh" || true META="$STATE/$ID.meta" [ -f "$META" ] || { echo "error: no meta for task $ID at $META" >&2; exit 1; } -WT=$(grep '^worktree=' "$META" | cut -d= -f2-) -T=$(grep '^window=' "$META" | cut -d= -f2-) -PROJ=$(grep '^project=' "$META" | cut -d= -f2-) -BACKEND=$(fm_backend_of_meta "$META") -if [ "$BACKEND" = orca ]; then - T_ORCA=$(grep '^terminal=' "$META" | tail -1 | cut -d= -f2- || true) - [ -n "$T_ORCA" ] && T=$T_ORCA -fi +# This is the first cleanup authorization check. It is metadata-only and must +# complete before fm-guard, a backend command, file removal, branch deletion, +# worktree return, registry change, or process termination can run. +fm_backend_validate_task_endpoint "$META" "$ID" || exit 1 +BACKEND=$FM_BACKEND_VALIDATED_BACKEND +T=$FM_BACKEND_VALIDATED_TARGET +WT=$(fm_meta_get "$META" worktree) +PROJ=$(fm_meta_get "$META" project) +T_ORCA= +[ "$BACKEND" != orca ] || T_ORCA=$T +"$FM_ROOT/bin/fm-guard.sh" || true HOME_PATH=$(grep '^home=' "$META" | cut -d= -f2- || true) PR_URL=$(grep '^pr=' "$META" | tail -1 | cut -d= -f2- || true) # tasktmp is recorded by fm-spawn for tasks that set up a per-task temp root # (/tmp/fm-<id>/); absent for tasks spawned before that change, so tolerate empty. TASK_TMP=$(grep '^tasktmp=' "$META" | cut -d= -f2- || true) +BUSY_GEN=$(fm_meta_get "$META" busy_gen) +if [ -z "$BUSY_GEN" ]; then + BUSY_GEN=$(cat "$STATE/$ID.busy-gen" 2>/dev/null || true) +fi ORCA_WORKTREE_ID=$(fm_meta_get "$META" orca_worktree_id) ORCA_PATH_MATCH_VERIFIED=0 @@ -140,6 +150,70 @@ KIND=$(grep '^kind=' "$META" | cut -d= -f2- || true) [ -n "$KIND" ] || KIND=ship MODE=$(grep '^mode=' "$META" | cut -d= -f2- || true) [ -n "$MODE" ] || MODE=no-mistakes +PUBLIC_FOLLOWUP_HOME=$FM_HOME +PUBLIC_FOLLOWUP_STATE=$STATE +PUBLIC_FOLLOWUP_WORK_HOME=main +PUBLIC_FOLLOWUP_PARENT_UNRESOLVED=0 +PUBLIC_FOLLOWUP_PARENT_RELAY_ACTIVE=0 +PUBLIC_FOLLOWUP_RELAY_ACTIVE=0 +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" != "$child" ] || return 1 + parent_meta="$parent/state/$id.meta" + [ -f "$parent_meta" ] && [ ! -L "$parent_meta" ] || return 1 + [ "$(fm_meta_get "$parent_meta" kind)" = secondmate ] || return 1 + meta_home=$(fm_meta_get "$parent_meta" home) + meta_home=$(CDPATH='' cd -- "$meta_home" 2>/dev/null && pwd -P) || return 1 + [ "$meta_home" = "$child" ] || return 1 + registry="$parent/data/secondmates.md" + secondmate_registry_validate_bindings "$registry" secondmate_registry_path_key "$id" "$child" || return 1 + printf '%s\n' "$parent" +} +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 + 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 \ + "${FM_PUBLIC_FOLLOWUP_PRIMARY_HOME:-}" "$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 +elif [ "$KIND" = secondmate ]; then + PUBLIC_FOLLOWUP_WORK_HOME="secondmate:$ID" + if [ "$FORCE" != "--force" ] && fm_pf_relay_active "$FM_HOME"; then + PUBLIC_FOLLOWUP_RELAY_ACTIVE=1 + fi +elif [ "$FORCE" != "--force" ] && fm_pf_relay_active "$FM_HOME"; then + PUBLIC_FOLLOWUP_RELAY_ACTIVE=1 +fi default_branch() { local ref branch @@ -204,6 +278,15 @@ remove_kimi_turnend_auth() { rm -f "$hooks_dir/$token" } +retire_busy_state() { + local state_dir=$1 id=$2 gen=${3:-} + if [ -n "$gen" ]; then + "$SCRIPT_DIR/fm-busy-event.sh" retire "$state_dir" "$id" --gen "$gen" + elif [ -f "$state_dir/$id.busy-gen" ]; then + "$SCRIPT_DIR/fm-busy-event.sh" retire "$state_dir" "$id" --current-gen + fi +} + validate_pr_poll_cleanup() { local state_dir=$1 id=$2 quarantine state_device artifact has_artifact=0 fm_task_id_path_safe "$id" || return 0 @@ -447,10 +530,6 @@ backlog_refresh_reminder() { fi } -registry_home_for_line() { - sed -n 's/^[^(]*(home: \([^;)]*\);.*/\1/p' -} - path_is_ancestor_of() { local ancestor=$1 path=$2 [ -n "$ancestor" ] || return 1 @@ -806,13 +885,19 @@ validate_removal_target() { registered_descendant_home_for_removal() { local reg=$1 target=$2 line id registered_home registered_abs [ -f "$reg" ] || return 1 - while IFS= read -r line; do + if ! secondmate_registry_validate_bindings "$reg" secondmate_registry_path_key; then + echo "REFUSED: $SECONDMATE_REGISTRY_ERROR" >&2 + return 2 + fi + while IFS= read -r line || [ -n "$line" ]; do case "$line" in "- "*) - id=${line#- } - id=${id%% *} - registered_home=$(printf '%s\n' "$line" | registry_home_for_line) - [ -n "$registered_home" ] || continue + secondmate_registry_parse_line "$line" || { + echo "REFUSED: malformed secondmate registry entry: $line" >&2 + return 2 + } + id=$SECONDMATE_REGISTRY_ID + registered_home=$SECONDMATE_REGISTRY_HOME registered_abs=$(removal_target_abs_path "$registered_home" 2>/dev/null || true) [ -n "$registered_abs" ] || continue [ "$registered_abs" = "$target" ] && continue @@ -901,11 +986,33 @@ validate_firstmate_home_for_removal() { echo "REFUSED: unsafe $label removal target $home is marked for secondmate ${marker_id:-unknown}, expected $expected_id" >&2 return 1 fi + if [ -e "$SECONDMATE_REG" ] || [ -L "$SECONDMATE_REG" ]; then + if ! secondmate_registry_validate_bindings "$SECONDMATE_REG" secondmate_registry_path_key "$expected_id" "$abs_home_path"; then + case "$SECONDMATE_REGISTRY_ERROR" in + overlapping\ secondmate\ home\ assignment:*) + echo "REFUSED: unsafe $label removal target $home contains registered secondmate home; $SECONDMATE_REGISTRY_ERROR" >&2 + ;; + *) echo "REFUSED: $SECONDMATE_REGISTRY_ERROR" >&2 ;; + esac + return 1 + fi + fi fi validate_firstmate_operational_dirs_for_removal "$abs_home_path" "$label" || return 1 - conflict=$(registered_descendant_home_for_removal "$SECONDMATE_REG" "$abs_home_path" || true) + conflict= + if conflict=$(registered_descendant_home_for_removal "$SECONDMATE_REG" "$abs_home_path"); then + : + else + conflict_rc=$? + [ "$conflict_rc" -eq 1 ] || return 1 + fi if [ -z "$conflict" ]; then - conflict=$(registered_descendant_home_for_removal "$abs_home_path/data/secondmates.md" "$abs_home_path" || true) + if conflict=$(registered_descendant_home_for_removal "$abs_home_path/data/secondmates.md" "$abs_home_path"); then + : + else + conflict_rc=$? + [ "$conflict_rc" -eq 1 ] || return 1 + fi fi if [ -n "$conflict" ]; then IFS=$'\t' read -r child_id child_home <<EOF @@ -944,6 +1051,7 @@ validate_firstmate_home_children_removal() { for child_meta in "$sub_state"/*.meta; do [ -e "$child_meta" ] || continue child_id=$(basename "$child_meta" .meta) + fm_backend_validate_task_endpoint "$child_meta" "$child_id" || return 1 validate_pr_poll_cleanup "$sub_state" "$child_id" || return 1 child_wt=$(meta_value "$child_meta" worktree) child_kind=$(meta_value "$child_meta" kind) @@ -968,8 +1076,144 @@ validate_firstmate_home_children_removal() { done } +TEARDOWN_HERDR_LOCK_RECORDS= +teardown_release_herdr_locks() { + local lock_session lock_path + [ -n "$TEARDOWN_HERDR_LOCK_RECORDS" ] || return 0 + while IFS=$'\t' read -r lock_session lock_path; do + [ -n "$lock_path" ] || continue + fm_lock_release "$lock_path" || true + done <<FMEOF +$TEARDOWN_HERDR_LOCK_RECORDS +FMEOF + TEARDOWN_HERDR_LOCK_RECORDS= +} + +teardown_herdr_session_lock_held() { # <session> + local session=$1 lock_session lock_path + [ -n "$TEARDOWN_HERDR_LOCK_RECORDS" ] || return 1 + while IFS=$'\t' read -r lock_session lock_path; do + [ "$lock_session" != "$session" ] || return 0 + done <<FMEOF +$TEARDOWN_HERDR_LOCK_RECORDS +FMEOF + return 1 +} + +teardown_herdr_require_prerequisites() { # <task-id> + local task_id=$1 prerequisite + if ! fm_backend_source herdr; then + echo "error: herdr teardown prerequisites are unavailable for $task_id; nothing was changed - restore the adapter and rerun teardown" >&2 + return 1 + fi + for prerequisite in \ + fm_backend_herdr_parse_target \ + fm_backend_herdr_pane_presence_state \ + fm_backend_herdr_workspace_presence_state \ + fm_backend_herdr_endpoint_confirmed_gone \ + fm_backend_herdr_explicit_close_pane_confirmed \ + fm_backend_herdr_presentation_session_lock_path; do + if ! declare -F "$prerequisite" >/dev/null 2>&1; then + echo "error: herdr teardown prerequisites are unavailable for $task_id; nothing was changed - restore the adapter and rerun teardown" >&2 + return 1 + fi + done + if ! declare -F fm_lock_try_acquire >/dev/null 2>&1; then + # shellcheck source=bin/fm-wake-lib.sh + . "$SCRIPT_DIR/fm-wake-lib.sh" + fi + if ! declare -F fm_lock_try_acquire >/dev/null 2>&1 \ + || ! declare -F fm_lock_release >/dev/null 2>&1; then + echo "error: herdr teardown lock machinery is unavailable for $task_id; nothing was changed - restore the lock support and rerun teardown" >&2 + return 1 + fi +} + +teardown_herdr_preflight_target() { # <target> <task-id> + local target=$1 task_id=$2 session pane presence lock_path verified_lock_path lock_session held_path attempt + teardown_herdr_require_prerequisites "$task_id" || return 1 + if ! fm_backend_herdr_parse_target "$target"; then + echo "error: herdr endpoint $target for $task_id could not be parsed exactly; nothing was changed - repair the endpoint metadata and rerun teardown" >&2 + return 1 + fi + session=$FM_BACKEND_HERDR_SESSION + pane=$FM_BACKEND_HERDR_PANE + presence=$(fm_backend_herdr_pane_presence_state "$session" "$pane") + case "$presence" in + dead|present) ;; + *) + echo "error: herdr endpoint $target for $task_id has ambiguous structured presence; nothing was changed - restore reliable endpoint inspection and rerun teardown" >&2 + return 1 + ;; + esac + if ! lock_path=$(fm_backend_herdr_presentation_session_lock_path "$session"); then + echo "error: herdr session presentation lock could not be resolved for $task_id; nothing was changed - rerun teardown once the session is reachable and unambiguous" >&2 + return 1 + fi + if [ -n "$TEARDOWN_HERDR_LOCK_RECORDS" ]; then + while IFS=$'\t' read -r lock_session held_path; do + if [ "$lock_session" = "$session" ]; then + if [ "$held_path" != "$lock_path" ]; then + echo "error: herdr session presentation lock changed during preflight for $task_id; nothing was changed - rerun teardown once session identity is stable" >&2 + return 1 + fi + return 0 + fi + done <<FMEOF +$TEARDOWN_HERDR_LOCK_RECORDS +FMEOF + fi + attempt=0 + while [ "$attempt" -lt 50 ]; do + if fm_lock_try_acquire "$lock_path"; then + if ! verified_lock_path=$(fm_backend_herdr_presentation_session_lock_path "$session") \ + || [ "$verified_lock_path" != "$lock_path" ]; then + fm_lock_release "$lock_path" || true + echo "error: herdr session presentation lock changed during preflight for $task_id; nothing was changed - rerun teardown once session identity is stable" >&2 + return 1 + fi + if [ -n "$TEARDOWN_HERDR_LOCK_RECORDS" ]; then + TEARDOWN_HERDR_LOCK_RECORDS="$TEARDOWN_HERDR_LOCK_RECORDS +$session $lock_path" + else + TEARDOWN_HERDR_LOCK_RECORDS="$session $lock_path" + fi + trap teardown_release_herdr_locks EXIT + return 0 + fi + sleep 0.1 + attempt=$((attempt + 1)) + done + echo "error: herdr session presentation lock is contended for $task_id; nothing was changed - rerun teardown once the contention clears" >&2 + return 1 +} + +preflight_firstmate_home_herdr_children() { # <home> + local home=$1 sub_state child_meta child_id child_backend child_target child_kind child_home child_wt + sub_state="$home/state" + [ -d "$sub_state" ] || return 0 + for child_meta in "$sub_state"/*.meta; do + [ -e "$child_meta" ] || continue + child_id=$(basename "$child_meta" .meta) + fm_backend_validate_task_endpoint "$child_meta" "$child_id" || return 1 + child_backend=$FM_BACKEND_VALIDATED_BACKEND + child_target=$FM_BACKEND_VALIDATED_TARGET + if [ "$child_backend" = herdr ]; then + teardown_herdr_preflight_target "$child_target" "$child_id" || return 1 + fi + child_kind=$(meta_value "$child_meta" kind) + [ -n "$child_kind" ] || child_kind=ship + if [ "$child_kind" = secondmate ]; then + child_wt=$(meta_value "$child_meta" worktree) + child_home=$(meta_value "$child_meta" home) + [ -n "$child_home" ] || child_home=$child_wt + preflight_firstmate_home_herdr_children "$child_home" || return 1 + fi + done +} + cleanup_firstmate_home_children() { - local home=$1 sub_state child_meta child_id child_t child_wt child_proj child_kind child_home child_backend child_orca_worktree_id child_return_rc + local home=$1 sub_state child_meta child_id child_t child_wt child_proj child_kind child_home child_backend child_orca_worktree_id child_return_rc child_busy_gen sub_state="$home/state" [ -d "$sub_state" ] || return 0 for child_meta in "$sub_state"/*.meta; do @@ -992,7 +1236,18 @@ cleanup_firstmate_home_children() { fi fi if [ -n "$child_t" ]; then - if [ "$child_backend" = zellij ]; then + if [ "$child_backend" = herdr ]; then + fm_backend_herdr_parse_target "$child_t" || return 1 + if ! teardown_herdr_session_lock_held "$FM_BACKEND_HERDR_SESSION"; then + echo "error: herdr session presentation lock is not held for child $child_id; retaining that child's durable identity records and stopping forced cleanup" >&2 + return 1 + fi + fm_backend_herdr_kill_serialized "$FM_BACKEND_HERDR_SESSION" "$FM_BACKEND_HERDR_PANE" 2>/dev/null || true + if ! fm_backend_herdr_endpoint_confirmed_gone "$child_t"; then + echo "error: herdr pane $child_t for child $child_id is not confirmed gone; retaining that child's durable identity records and stopping forced cleanup" >&2 + return 1 + fi + elif [ "$child_backend" = zellij ]; then # Zellij titles are scoped by the owning home tag, so forced secondmate # cleanup must verify child tabs as that child home, not the parent. ( unset FM_ROOT_OVERRIDE; FM_HOME=$home FM_ROOT=$home fm_backend_kill "$child_backend" "$child_t" "$(meta_value "$child_meta" zellij_tab_id)" "fm-$child_id" ) 2>/dev/null || true @@ -1004,7 +1259,7 @@ cleanup_firstmate_home_children() { child_home=$(meta_value "$child_meta" home) [ -n "$child_home" ] || child_home=$child_wt if [ -n "$child_home" ] && [ -d "$child_home" ]; then - cleanup_firstmate_home_children "$child_home" + cleanup_firstmate_home_children "$child_home" || return 1 remove_firstmate_home "$child_home" "child firstmate home" "$child_id" fi elif [ "$child_backend" = orca ]; then @@ -1017,6 +1272,7 @@ cleanup_firstmate_home_children() { elif [ -n "$child_wt" ] && [ -d "$child_wt" ]; then validate_child_worktree_for_removal "$child_wt" "$child_proj" >/dev/null || return 1 rm -f "$child_wt/.claude/settings.local.json" "$child_wt/.opencode/plugins/fm-turn-end.js" \ + "$child_wt/.opencode/plugins/fm-busy-state.js" \ "$child_wt/.fm-grok-turnend" "$child_wt/.fm-kimi-turnend" if [ -n "$child_proj" ] && [ -d "$child_proj" ] && command -v treehouse >/dev/null 2>&1; then if teardown_treehouse_return "$child_wt" "$child_proj" "child worktree"; then @@ -1035,6 +1291,11 @@ cleanup_firstmate_home_children() { remove_grok_turnend_auth "$sub_state" "$child_id" remove_kimi_turnend_auth "$sub_state" "$child_id" remove_pr_poll_artifacts "$sub_state" "$child_id" || return 1 + child_busy_gen=$(meta_value "$child_meta" busy_gen) + if [ -z "$child_busy_gen" ]; then + child_busy_gen=$(cat "$sub_state/$child_id.busy-gen" 2>/dev/null || true) + fi + retire_busy_state "$sub_state" "$child_id" "$child_busy_gen" || return 1 rm -f "$sub_state/$child_id.status" "$sub_state/$child_id.turn-ended" \ "$sub_state/$child_id.meta" "$sub_state/$child_id.pi-ext.ts" \ "$sub_state/$child_id.grok-turnend-token" "$sub_state/$child_id.kimi-turnend-token" @@ -1056,6 +1317,10 @@ if [ "$KIND" = secondmate ]; then validate_firstmate_home_for_removal "$HOME_PATH" "secondmate home" "$ID" >/dev/null || exit 1 if [ "$FORCE" = "--force" ]; then validate_firstmate_home_children_removal "$HOME_PATH" || exit 1 + if [ "$BACKEND" = herdr ]; then + teardown_herdr_preflight_target "$T" "$ID" || exit 1 + fi + preflight_firstmate_home_herdr_children "$HOME_PATH" || exit 1 fi fi @@ -1090,6 +1355,28 @@ if [ "$KIND" = scout ] && [ "$FORCE" != "--force" ]; then fi fi +# A public commitment is not kept until its final reply lands in the ORIGINAL +# thread, and this cleanup removes the task records that make the promise +# reconcilable. Refuse while this home still owes a public reply for exactly this +# work. Both gates live in bin/fm-public-followup-lib.sh, so a home that never +# opted into the myfirstmate relay runs one [ -f ] test and nothing else here. +if [ "$FORCE" != "--force" ] && [ "$PUBLIC_FOLLOWUP_PARENT_UNRESOLVED" = 1 ]; then + echo "REFUSED: cannot resolve the primary home for marked secondmate $SECOND_MATE_ID; refusing cleanup without its durable parent binding." >&2 + exit 1 +fi +if [ "$FORCE" != "--force" ] \ + && [ -n "$PUBLIC_FOLLOWUP_STATE" ] \ + && [ "$PUBLIC_FOLLOWUP_RELAY_ACTIVE" = 1 ] \ + && fm_pf_has_registrations "$PUBLIC_FOLLOWUP_STATE"; then + if ! PUBLIC_FOLLOWUP_BLOCKING=$(FM_HOME="$PUBLIC_FOLLOWUP_HOME" FM_STATE_OVERRIDE="$PUBLIC_FOLLOWUP_STATE" \ + "$SCRIPT_DIR/fm-public-followup.sh" guard-work "$PUBLIC_FOLLOWUP_WORK_HOME" "$ID" 2>/dev/null); then + echo "REFUSED: task $ID still owes a public reply through the myfirstmate relay." >&2 + printf '%s\n' "$PUBLIC_FOLLOWUP_BLOCKING" >&2 + echo "Deliver it with bin/fm-public-followup.sh deliver <obligation-id>, waive it with tasks-axi public-followup waive, or use --force after explicit discard approval." >&2 + exit 1 + fi +fi + if [ "$BACKEND" = orca ] && [ "$KIND" != scout ] && [ "$KIND" != secondmate ] && [ "$FORCE" != "--force" ]; then if ! inspectable_git_worktree "$WT"; then echo "REFUSED: Orca ship task $ID has no inspectable git worktree at ${WT:-<missing>}." >&2 @@ -1114,6 +1401,22 @@ if [ -d "$WT" ] && [ "$FORCE" != "--force" ]; then fi fi +# A Herdr close may reposition shared workspace order, so the whole +# destructive sequence below (worktree return, pane close, record removal) +# runs under the named-session presentation lock, acquired BEFORE anything is +# returned or erased: a contended lock refuses here while the isolated copy, +# every durable record, and the endpoint are all still intact for a plain +# rerun. An unresolvable lock path (for example an unreachable server) also +# refuses before any destructive step. +TEARDOWN_HERDR_SESSION= +TEARDOWN_HERDR_PANE= +if [ "$BACKEND" = herdr ]; then + teardown_herdr_preflight_target "$T" "$ID" || exit 1 + fm_backend_herdr_parse_target "$T" || exit 1 + TEARDOWN_HERDR_SESSION=$FM_BACKEND_HERDR_SESSION + TEARDOWN_HERDR_PANE=$FM_BACKEND_HERDR_PANE +fi + # Best-effort: drop the local task branch so the shared repo does not accumulate refs. if [ "$BACKEND" = orca ] && [ "$KIND" != secondmate ]; then if [ "$ORCA_PATH_MATCH_VERIFIED" != 1 ]; then @@ -1128,6 +1431,7 @@ if [ "$BACKEND" = orca ] && [ "$KIND" != secondmate ]; then fi fi rm -f "$WT/.claude/settings.local.json" "$WT/.opencode/plugins/fm-turn-end.js" \ + "$WT/.opencode/plugins/fm-busy-state.js" \ "$WT/.fm-grok-turnend" "$WT/.fm-kimi-turnend" fi [ -z "$T_ORCA" ] || fm_backend_kill "$BACKEND" "$T" "$(meta_value "$META" zellij_tab_id)" "fm-$ID" 2>/dev/null || true @@ -1178,29 +1482,20 @@ if [ "$BACKEND" = herdr ] \ fi if [ "$HERDR_PRESENTATION_RETIRE_CANDIDATE" = 1 ]; then - # shellcheck source=bin/fm-wake-lib.sh - . "$SCRIPT_DIR/fm-wake-lib.sh" - HERDR_PRESENTATION_FOCUS_LOCK= - HERDR_PRESENTATION_FOCUS_LOCK_HELD=0 - HERDR_PRESENTATION_FOCUS_LOCK_ATTEMPT=0 - if HERDR_PRESENTATION_FOCUS_LOCK=$(fm_backend_herdr_presentation_session_lock_path "$HERDR_PRESENTATION_SESSION"); then - while [ "$HERDR_PRESENTATION_FOCUS_LOCK_ATTEMPT" -lt 50 ]; do - if fm_lock_try_acquire "$HERDR_PRESENTATION_FOCUS_LOCK"; then - HERDR_PRESENTATION_FOCUS_LOCK_HELD=1 - break - fi - sleep 0.1 - HERDR_PRESENTATION_FOCUS_LOCK_ATTEMPT=$((HERDR_PRESENTATION_FOCUS_LOCK_ATTEMPT + 1)) - done - fi - if [ "$HERDR_PRESENTATION_FOCUS_LOCK_HELD" = 1 ]; then + # The presentation lock was acquired before the worktree return above; a + # contended lock already refused this teardown while everything was intact. + if teardown_herdr_session_lock_held "$HERDR_PRESENTATION_SESSION"; then fm_backend_herdr_projection_close_pane_focus_preserving \ "$HERDR_PRESENTATION_SESSION" "$HERDR_PRESENTATION_PANE" 2>/dev/null || true - HERDR_PRESENTATION_FOCUS_LOCK_HELD=0 - fm_lock_release "$HERDR_PRESENTATION_FOCUS_LOCK" || true else echo "warning: herdr presentation focus lock unavailable; refusing a concurrent focus-unsafe pane close" >&2 fi +elif [ "$BACKEND" = herdr ]; then + if teardown_herdr_session_lock_held "$TEARDOWN_HERDR_SESSION"; then + fm_backend_herdr_kill_serialized "$TEARDOWN_HERDR_SESSION" "$TEARDOWN_HERDR_PANE" 2>/dev/null || true + else + echo "warning: herdr session presentation lock path is unavailable; skipping the pane close rather than closing unlocked" >&2 + fi elif [ "$BACKEND" != orca ]; then fm_backend_kill "$BACKEND" "$T" "$(meta_value "$META" zellij_tab_id)" "fm-$ID" 2>/dev/null || true fi @@ -1214,6 +1509,23 @@ elif [ "$BACKEND" = herdr ] \ && { [ -e "$HERDR_PRESENTATION_JOURNAL" ] || [ -L "$HERDR_PRESENTATION_JOURNAL" ]; }; then echo "warning: herdr presentation journal for $ID remains quarantined; no workspace cleanup was attempted" >&2 fi +# A refused, skipped, or failed Herdr close must never erase a live task's +# durable endpoint identity: unless the exact pane is confirmed gone, retain +# every record and stop before any removal below so a later rerun can retry +# the locked close. Only a structured not-found proves the pane gone; unknown +# presence, missing or malformed endpoint identity, and missing confirmation +# machinery all refuse. +if [ "$BACKEND" = herdr ]; then + fm_backend_source herdr || true + if ! declare -F fm_backend_herdr_endpoint_confirmed_gone >/dev/null 2>&1; then + echo "error: herdr endpoint confirmation is unavailable for $ID; retaining every durable task record" >&2 + exit 1 + fi + if ! fm_backend_herdr_endpoint_confirmed_gone "$T"; then + echo "error: herdr pane $T for $ID is not confirmed gone after its close was refused, skipped, or failed; retaining every durable task record - rerun teardown once the close can run under the session lock" >&2 + exit 1 + fi +fi if [ "$KIND" = secondmate ]; then [ -n "$HOME_PATH" ] || HOME_PATH=$WT remove_firstmate_home "$HOME_PATH" "secondmate home" "$ID" @@ -1226,6 +1538,7 @@ fm_backend_clear_transition "$BACKEND" "$STATE" "$T" || true # Read before the state-file rm below; empty (pre-fix tasks without tasktmp=) is a no-op. [ -n "$TASK_TMP" ] && rm -rf "$TASK_TMP" remove_pr_poll_artifacts "$STATE" "$ID" || exit 1 +retire_busy_state "$STATE" "$ID" "$BUSY_GEN" || exit 1 rm -f "$STATE/$ID.status" "$STATE/$ID.turn-ended" "$STATE/$ID.meta" \ "$STATE/$ID.pi-ext.ts" "$STATE/$ID.grok-turnend-token" \ "$STATE/$ID.kimi-turnend-token" diff --git a/bin/fm-test-isolation-proof.sh b/bin/fm-test-isolation-proof.sh index 0a14a48022..7c0eb75d9d 100755 --- a/bin/fm-test-isolation-proof.sh +++ b/bin/fm-test-isolation-proof.sh @@ -121,7 +121,7 @@ exclusion_reason() { fm-afk-pi-herdr-return-e2e.test.sh|\ fm-codex-continuity-live-e2e.test.sh|fm-grok-continuity-live-e2e.test.sh|\ fm-opencode-primary-live-e2e.test.sh|fm-pi-primary-live-e2e.test.sh|\ - fm-send-secondmate-marker-herdr-e2e.test.sh) + fm-quota-array-dispatch-live-e2e.test.sh|fm-send-secondmate-marker-herdr-e2e.test.sh) printf '%s\n' 'live harness opt-in; never default parallel CI' ;; fm-backend-autodetect-smoke.test.sh|fm-backend-herdr-eventwait-smoke.test.sh|\ @@ -152,7 +152,6 @@ list_parallel_candidates() { tests/fm-arm-pretool-check.test.sh tests/fm-backend-herdr.test.sh tests/fm-brief.test.sh -tests/fm-captain-translation-contract.test.sh tests/fm-cd-pretool-check.test.sh tests/fm-composer-ghost.test.sh tests/fm-composer-lib.test.sh @@ -161,10 +160,7 @@ tests/fm-decision-hold-lifecycle.test.sh tests/fm-ensure-agents-md.test.sh tests/fm-grok-harness.test.sh tests/fm-herdr-lab.test.sh -tests/fm-instruction-owners.test.sh tests/fm-lint.test.sh -tests/fm-nm-test-contract.test.sh -tests/fm-no-mistakes-ownership.test.sh tests/fm-pi-primary-types.test.sh tests/fm-pr-merge.test.sh tests/fm-review-diff.test.sh @@ -172,7 +168,6 @@ tests/fm-send-popup-settle.test.sh tests/fm-send-settle.test.sh tests/fm-send-strict.test.sh tests/fm-spawn-batch.test.sh -tests/fm-stow-contract.test.sh tests/fm-supervision-instructions.test.sh tests/fm-test-run.test.sh tests/fm-tmux-submit-busy.test.sh @@ -203,6 +198,7 @@ fm-afk-inject-e2e.test.sh fm-backend-herdr-smoke.test.sh fm-backend-cmux-smoke.test.sh fm-pi-primary-live-e2e.test.sh +fm-quota-array-dispatch-live-e2e.test.sh EOF } diff --git a/bin/fm-test-run.sh b/bin/fm-test-run.sh index c90d759c0d..4b48d9f77f 100755 --- a/bin/fm-test-run.sh +++ b/bin/fm-test-run.sh @@ -38,7 +38,7 @@ # silently pass as a gate skip. # --jobs N run the selected scripts with up to N concurrent workers. # Default is 1 (serial). N>1 is allowed only when every -# selected script is in the Phase 2 proven-isolated set +# selected script is in the proven-isolated set # (bin/fm-test-isolation-proof.sh --list). Cap is 8. Stateful # families never schedule under --jobs. # -h, --help print this header @@ -117,15 +117,15 @@ now_ms() { # unclassified so new tests are still runnable and visible in summaries. family_for_basename() { case "$1" in - fm-arm-pretool-check.test.sh|fm-ask-user-authority.test.sh|fm-brief.test.sh|\ - fm-calm-pi-extension.test.sh|fm-captain-translation-contract.test.sh|fm-cd-pretool-check.test.sh|\ + fm-arm-pretool-check.test.sh|fm-ask-user-authority.test.sh|fm-busy-state.test.sh|\ + fm-brief.test.sh|fm-vendor-auth-probe.test.sh|\ + fm-calm-pi-extension.test.sh|fm-cd-pretool-check.test.sh|\ fm-composer-ghost.test.sh|fm-composer-lib.test.sh|\ fm-crew-state.test.sh|fm-decision-hold-lifecycle.test.sh|\ - fm-documentation-audiences.test.sh|fm-ensure-agents-md.test.sh|fm-grok-harness.test.sh|\ - fm-kimi-harness.test.sh|fm-herdr-lab.test.sh|fm-instruction-owners.test.sh|fm-lint.test.sh|\ - fm-install-herdr.test.sh|fm-nm-test-contract.test.sh|fm-no-mistakes-ownership.test.sh|\ + fm-documentation-audiences.test.sh|fm-ensure-agents-md.test.sh|fm-agy-harness.test.sh|fm-grok-harness.test.sh|\ + fm-kimi-harness.test.sh|fm-herdr-lab.test.sh|fm-lint.test.sh|\ fm-operational-input.test.sh|fm-pi-primary-types.test.sh|\ - fm-send-popup-settle.test.sh|fm-send-settle.test.sh|fm-stow-contract.test.sh|\ + fm-send-popup-settle.test.sh|fm-send-settle.test.sh|\ fm-subagent-pretool-check.test.sh|\ fm-supervision-instructions.test.sh|fm-tmux-submit-busy.test.sh|fm-transition-lib.test.sh|\ fm-test-run.test.sh|fm-test-isolation-proof.test.sh) @@ -139,6 +139,7 @@ family_for_basename() { ;; fm-afk-inject-herdr-e2e.test.sh|fm-afk-launch.test.sh|fm-backend-autodetect-smoke.test.sh|\ fm-backend-herdr-eventwait-smoke.test.sh|fm-backend-herdr-presentation-e2e.test.sh|\ + fm-backend-herdr-launcher-workspace-e2e.test.sh|\ fm-backend-herdr-prune-safety-e2e.test.sh|fm-backend-herdr-respawn-idem-e2e.test.sh|\ fm-herdr-session-cleanup-e2e.test.sh|\ fm-backend-herdr-smoke.test.sh|fm-backend-herdr-workspace-per-home-e2e.test.sh) @@ -146,6 +147,7 @@ family_for_basename() { ;; fm-backlog-handoff.test.sh|fm-secondmate-harness.test.sh|fm-secondmate-lifecycle-e2e.test.sh|\ fm-secondmate-liveness.test.sh|fm-secondmate-safety.test.sh|fm-secondmate-sync.test.sh|\ + fm-startup-memory-budget.test.sh|\ fm-send-secondmate-marker.test.sh|fm-shared-captain-inheritance.test.sh) printf '%s\n' secondmate ;; @@ -156,13 +158,14 @@ family_for_basename() { ;; fm-afk-pi-herdr-return-e2e.test.sh|\ fm-codex-continuity-live-e2e.test.sh|fm-grok-continuity-live-e2e.test.sh|\ - fm-opencode-primary-live-e2e.test.sh|fm-pi-primary-live-e2e.test.sh|\ - fm-send-secondmate-marker-herdr-e2e.test.sh) + fm-grok-stop-live-e2e.test.sh|fm-opencode-primary-live-e2e.test.sh|fm-pi-primary-live-e2e.test.sh|\ + fm-quota-array-dispatch-live-e2e.test.sh|fm-send-secondmate-marker-herdr-e2e.test.sh) printf '%s\n' live-harness-optin ;; fm-backend-herdr.test.sh|fm-backend-tmux-smoke.test.sh|fm-backend.test.sh|\ fm-herdr-session-cleanup.test.sh|fm-send-strict.test.sh|fm-spawn-batch.test.sh|\ - fm-spawn-dispatch-profile.test.sh|fm-spawn-worktree-settle.test.sh) + fm-spawn-dispatch-profile.test.sh|fm-spawn-worktree-settle.test.sh|\ + fm-teardown-endpoint-safety.test.sh) printf '%s\n' backend-dispatch ;; fm-pr-check-security.test.sh|fm-pr-merge.test.sh|fm-review-diff.test.sh|\ @@ -228,7 +231,7 @@ real-herdr-gated EOF } -# Exact Phase 2 proven-isolated candidate set (same paths as +# Exact proven-isolated candidate set (same paths as # bin/fm-test-isolation-proof.sh --list). Do not expand without a new concurrent # isolation proof archive. list_proven_isolated() { @@ -236,7 +239,6 @@ list_proven_isolated() { tests/fm-arm-pretool-check.test.sh tests/fm-backend-herdr.test.sh tests/fm-brief.test.sh -tests/fm-captain-translation-contract.test.sh tests/fm-cd-pretool-check.test.sh tests/fm-composer-ghost.test.sh tests/fm-composer-lib.test.sh @@ -245,10 +247,7 @@ tests/fm-decision-hold-lifecycle.test.sh tests/fm-ensure-agents-md.test.sh tests/fm-grok-harness.test.sh tests/fm-herdr-lab.test.sh -tests/fm-instruction-owners.test.sh tests/fm-lint.test.sh -tests/fm-nm-test-contract.test.sh -tests/fm-no-mistakes-ownership.test.sh tests/fm-pi-primary-types.test.sh tests/fm-pr-merge.test.sh tests/fm-review-diff.test.sh @@ -256,7 +255,6 @@ tests/fm-send-popup-settle.test.sh tests/fm-send-settle.test.sh tests/fm-send-strict.test.sh tests/fm-spawn-batch.test.sh -tests/fm-stow-contract.test.sh tests/fm-supervision-instructions.test.sh tests/fm-test-run.test.sh tests/fm-tmux-submit-busy.test.sh @@ -265,47 +263,41 @@ tests/fm-x-mode.test.sh EOF } -# Portable parallel shard 1: LPT balance of the proven-isolated set using -# Phase 1 serial duration averages from CI timing artifacts on main after -# #825/#832/#834 (docs/fm-test-portable-shards.md). Execution order is longest -# first so wall-clock stays near the balanced sum. +# Portable parallel shard 1: LPT balance of the proven-isolated set using the +# current concurrent-proof durations in docs/fm-test-isolation-proof.json. +# Execution order is longest first so wall-clock stays near the balanced sum. list_portable_parallel_1() { cat <<'EOF' -tests/fm-arm-pretool-check.test.sh +tests/fm-x-mode.test.sh tests/fm-cd-pretool-check.test.sh -tests/fm-backend-herdr.test.sh -tests/fm-pr-merge.test.sh +tests/fm-decision-hold-lifecycle.test.sh tests/fm-test-run.test.sh -tests/fm-send-popup-settle.test.sh +tests/fm-composer-ghost.test.sh +tests/fm-grok-harness.test.sh +tests/fm-lint.test.sh +tests/fm-pi-primary-types.test.sh tests/fm-review-diff.test.sh tests/fm-brief.test.sh -tests/fm-ensure-agents-md.test.sh -tests/fm-instruction-owners.test.sh -tests/fm-pi-primary-types.test.sh tests/fm-transition-lib.test.sh -tests/fm-composer-lib.test.sh -tests/fm-stow-contract.test.sh EOF } # Portable parallel shard 2: the complementary LPT half of the proven set. list_portable_parallel_2() { cat <<'EOF' -tests/fm-decision-hold-lifecycle.test.sh -tests/fm-x-mode.test.sh -tests/fm-herdr-lab.test.sh +tests/fm-backend-herdr.test.sh +tests/fm-arm-pretool-check.test.sh tests/fm-crew-state.test.sh -tests/fm-grok-harness.test.sh -tests/fm-spawn-batch.test.sh -tests/fm-send-strict.test.sh +tests/fm-herdr-lab.test.sh +tests/fm-pr-merge.test.sh +tests/fm-send-popup-settle.test.sh tests/fm-tmux-submit-busy.test.sh -tests/fm-composer-ghost.test.sh tests/fm-send-settle.test.sh +tests/fm-send-strict.test.sh +tests/fm-spawn-batch.test.sh tests/fm-supervision-instructions.test.sh -tests/fm-lint.test.sh -tests/fm-nm-test-contract.test.sh -tests/fm-captain-translation-contract.test.sh -tests/fm-no-mistakes-ownership.test.sh +tests/fm-ensure-agents-md.test.sh +tests/fm-composer-lib.test.sh EOF } @@ -598,7 +590,7 @@ families_for_test_reference() { # Conservative path → family map. Over-selects rather than under-selects. # Never expands to the complete suite. families_for_changed_path() { - local path=$1 + local path=$1 fixture_ref case "$path" in tests/fm-test-run.test.sh) printf '%s\n' pure-contract-unit @@ -655,13 +647,17 @@ families_for_changed_path() { printf '%s\n' live-harness-optin printf '%s\n' afk ;; + bin/fm-startup-memory-budget.sh|bin/fm-startup-memory-budget-lib.sh) + printf '%s\n' secondmate + printf '%s\n' session-bootstrap + ;; bin/fm-secondmate*|bin/fm-home-seed.sh|bin/fm-backlog-handoff.sh|\ bin/fm-config-inherit-lib.sh|bin/fm-config-push.sh|bin/fm-shared*) printf '%s\n' secondmate ;; bin/fm-session-start.sh|bin/fm-bootstrap.sh|bin/fm-fleet-sync.sh|\ bin/fm-sessionstart-nudge.sh|bin/fm-tangle*|bin/fm-update.sh|\ - bin/fm-gate-refuse*|bin/fm-lock*) + bin/fm-gate-refuse*|bin/fm-lock*|bin/fm-quota-axi-lib.sh) printf '%s\n' session-bootstrap ;; bin/fm-pr-*|bin/fm-merge-local.sh|bin/fm-teardown.sh|bin/fm-review-diff.sh|\ @@ -686,10 +682,15 @@ families_for_changed_path() { bin/fm-brief.sh|bin/fm-ensure-agents-md.sh|bin/fm-crew-state.sh|\ bin/fm-decision-hold.sh|bin/fm-supervision*|bin/fm-transition-lib.sh|\ bin/fm-tmux-lib.sh|bin/fm-marker-lib.sh|bin/fm-operational-input.sh|bin/fm-tasks-axi-lib.sh|\ + bin/fm-vendor-auth-probe.sh|\ bin/fm-primary-scope-lib.sh|bin/fm-project-mode.sh|bin/fm-promote.sh|\ bin/fm-ff-lib.sh|bin/fm-gotmp*|bin/*pretool*) printf '%s\n' pure-contract-unit ;; + .agents/skills/quota-array-dispatch/SKILL.md) + printf '%s\n' pure-contract-unit + printf '%s\n' live-harness-optin + ;; .agents/skills/*/SKILL.md) printf '%s\n' pure-contract-unit ;; @@ -709,9 +710,26 @@ families_for_changed_path() { families_for_test_reference "$(basename "$path")" \ || printf '%s\n' "__unmapped__:$path" ;; + tests/fixtures/*/*) + # A fixture belongs to whichever suite reads its directory, found by the + # same reference scan used for shared helpers. Keyed on the directory + # rather than the file so adding a fixture selects the same suite. + # A removed fixture directory has no consuming suite left to select. + fixture_ref=${path#tests/fixtures/} + fixture_ref=${fixture_ref%%/*} + if [ -d "tests/fixtures/$fixture_ref" ]; then + families_for_test_reference "fixtures/$fixture_ref" \ + || printf '%s\n' "__unmapped__:$path" + fi + ;; bin/*) - families_for_test_reference "$(basename "$path")" \ - || printf '%s\n' "__unmapped__:$path" + # A deleted script has no consuming suite left to select, the same rule + # the fixture case above applies. Refusing on its absent mapping would + # make every retirement branch unable to select its changed tests. + if [ -e "$path" ]; then + families_for_test_reference "$(basename "$path")" \ + || printf '%s\n' "__unmapped__:$path" + fi ;; tests/*) printf '%s\n' "__unmapped__:$path" diff --git a/bin/fm-tmux-lib.sh b/bin/fm-tmux-lib.sh index dde22b2a35..484f3ca72e 100755 --- a/bin/fm-tmux-lib.sh +++ b/bin/fm-tmux-lib.sh @@ -46,8 +46,14 @@ # tmux adapter does not paper over a herdr-specific shape. # # Overrides: FM_COMPOSER_IDLE_RE matches an empty composer after ghost and -# structural border stripping. FM_BUSY_REGEX globally overrides harness-scoped -# busy-footer matching (mirrors fm-watch.sh / the daemon). +# structural border stripping. FM_BUSY_REGEX overrides the rendered busy-footer +# matching used here. +# +# NOT a task-state source: task busy state is owned by bin/fm-busy-lib.sh's +# semantic contract. The matching below serves only delivery guards: the submit +# acknowledgement and the away-mode supervisor-pane busy guard. Both ask about +# the pane receiving input, not the state of a recorded worker task. Matching +# stays harness-scoped so one harness's output cannot make another read busy. # # All functions are `set -u` and `set -e` safe (guarded tmux calls, explicit # returns) so they can be sourced into either context. @@ -59,8 +65,10 @@ # shellcheck source=bin/fm-composer-lib.sh . "$(dirname -- "${BASH_SOURCE[0]}")/fm-composer-lib.sh" +# shellcheck source=bin/fm-busy-lib.sh +. "$(dirname -- "${BASH_SOURCE[0]}")/fm-busy-lib.sh" -# Busy footers per harness (mirror fm-watch.sh). claude/codex: "esc to +# Delivery-only rendered busy footers per harness. claude/codex: "esc to # interrupt"; opencode: "esc interrupt"; pi: "Working..."; grok: "Ctrl+c:cancel". # Claude's current spinner has a rotating glyph and word, but every active-turn # line has an ellipsis followed by a parenthesized elapsed duration. Keep this @@ -76,6 +84,12 @@ # busy signals on their own. # The full moon-phase set remains locale- and emoji-font-sensitive because Kimi # exposes no stable ASCII busy token. +# AGY (Antigravity CLI, VERIFIED 2026-07-29 on agy 1.1.8): a busy turn shows a +# spinner glyph plus "Generating..." above the composer, and the footer bar +# shows "esc to cancel"; idle shows only "? for shortcuts" with neither. Both +# tokens were captured live and are distinct from every other harness's busy +# text ("esc to cancel" vs codex/claude's "esc to interrupt", grok's exact +# "Ctrl+c:cancel"). FM_TMUX_BUSY_REGEX_DEFAULT='esc (to )?interrupt|Working\.\.\.|Ctrl\+c:cancel' FM_TMUX_CLAUDE_BUSY_REGEX_DEFAULT='esc to interrupt|…[[:space:]]+\([0-9]+[smh]' FM_TMUX_CODEX_BUSY_REGEX_DEFAULT='esc to interrupt' @@ -83,7 +97,6 @@ FM_TMUX_OPENCODE_BUSY_REGEX_DEFAULT='esc interrupt' FM_TMUX_PI_BUSY_REGEX_DEFAULT='Working\.\.\.' FM_TMUX_GROK_BUSY_REGEX_DEFAULT='Ctrl\+c:cancel' FM_TMUX_KIMI_BUSY_REGEX_DEFAULT='^[[:space:]]*(🌑|🌒|🌓|🌔|🌕|🌖|🌗|🌘)[[:space:]]+·[[:space:]]+' -FM_TMUX_AGY_BUSY_REGEX_DEFAULT='(Thinking|Working|Analyzing|Executing|Processing)\.\.\.|(Task|Step|Turn) [0-9]+/[0-9]+' fm_busy_lines_match() { # [harness] local harness=${1:-} lines regex @@ -98,7 +111,7 @@ fm_busy_lines_match() { # [harness] pi|pi-signed) regex=$FM_TMUX_PI_BUSY_REGEX_DEFAULT ;; grok) regex=$FM_TMUX_GROK_BUSY_REGEX_DEFAULT ;; kimi) regex=$FM_TMUX_KIMI_BUSY_REGEX_DEFAULT ;; - agy) regex=$FM_TMUX_AGY_BUSY_REGEX_DEFAULT ;; + agy) regex=$FM_BUSY_AGY_BUSY_REGEX_DEFAULT ;; '') regex=$FM_TMUX_BUSY_REGEX_DEFAULT ;; *) # A supplied harness must never borrow another harness's signature. diff --git a/bin/fm-turnend-guard-grok.sh b/bin/fm-turnend-guard-grok.sh index 5cc33e9c54..3dcfd6f2f6 100755 --- a/bin/fm-turnend-guard-grok.sh +++ b/bin/fm-turnend-guard-grok.sh @@ -1,30 +1,69 @@ #!/usr/bin/env bash # Grok Stop-hook adapter for the firstmate PRIMARY turn-end guard. # -# Grok Stop hooks are passive: exit 2 does not block or feed stderr back to the -# model. This adapter still uses the shared primary-scoped predicate in -# fm-turnend-guard.sh. When that predicate says the primary would end blind, the -# adapter forces one same-session follow-up by running `grok --resume <session>` -# with a guard instruction. GROK_TURNEND_GUARD_ACTIVE is the loop guard: the -# nested turn's own Stop hook exits without spawning another nested turn. +# The exact running Stop payload selects one path. A typed native capability +# field delegates the shared guard's exit status and stderr directly back to +# that Grok process. Field absence preserves the pre-native one-resume fallback. +# Invalid or unreadable input starts neither path. Camel case has typed +# precedence over the legacy snake-case spelling when both are present. set -u PAYLOAD=$(cat 2>/dev/null || true) [ -n "$PAYLOAD" ] || exit 0 -[ -n "${GROK_TURNEND_GUARD_ACTIVE:-}" ] && exit 0 +command -v jq >/dev/null 2>&1 || exit 0 +printf '%s' "$PAYLOAD" | jq -n --stream -e ' + reduce inputs as $item ( + {}; + if ( + ($item | length) == 2 + and ($item[0] | length) > 0 + and ( + $item[0][0] == "sessionId" + or $item[0][0] == "stopHookActive" + or $item[0][0] == "stop_hook_active" + ) + ) then + .[$item[0][0]] = ((.[$item[0][0]] // 0) + 1) + else + . + end + ) + | all(.[]; . == 1) +' >/dev/null 2>&1 || exit 0 +CAPABILITY=$(printf '%s' "$PAYLOAD" | jq -ser ' + if length != 1 then error("payload count") + elif ((.[0] | type) != "object") then error("payload") + else .[0] | + if has("stopHookActive") then + if ((.stopHookActive | type) == "boolean") then "native" else error("stopHookActive") end + elif has("stop_hook_active") then + if ((.stop_hook_active | type) == "boolean") then "native" else error("stop_hook_active") end + else "legacy" + end + end +' 2>/dev/null) || exit 0 ROOT=${GROK_WORKSPACE_ROOT:-${CLAUDE_PROJECT_DIR:-}} [ -n "$ROOT" ] || exit 0 ROOT=${ROOT%/} [ -x "$ROOT/bin/fm-turnend-guard.sh" ] || exit 0 -if ! command -v jq >/dev/null 2>&1; then - exit 0 +if [ "$CAPABILITY" = native ]; then + printf '%s' "$PAYLOAD" | "$ROOT/bin/fm-turnend-guard.sh" + RC=$? + case "$RC" in + 0|2) exit "$RC" ;; + *) exit 0 ;; + esac fi -SESSION_ID=$(printf '%s' "$PAYLOAD" | jq -r '.sessionId // empty' 2>/dev/null) || exit 0 -[ -n "$SESSION_ID" ] || exit 0 +# Only a genuine pre-native payload reaches this bounded compatibility path. +[ -n "${GROK_TURNEND_GUARD_ACTIVE:-}" ] && exit 0 +SESSION_ID=$(printf '%s' "$PAYLOAD" | jq -er ' + .sessionId | select(type == "string" and length > 0) +' 2>/dev/null) || exit 0 +command -v grok >/dev/null 2>&1 || exit 0 ERR=$(mktemp "${TMPDIR:-/tmp}/fm-turnend-grok.XXXXXX") || exit 0 trap 'rm -f "$ERR"' EXIT diff --git a/bin/fm-turnend-guard.sh b/bin/fm-turnend-guard.sh index 515a859cd2..2e96fb33e4 100755 --- a/bin/fm-turnend-guard.sh +++ b/bin/fm-turnend-guard.sh @@ -11,8 +11,10 @@ # This script is push-based: verified harness turn-end hooks invoke it every time # the primary is about to end a turn. # Claude and codex can block directly by preserving exit status 2 and stderr. -# OpenCode, pi, and grok adapters use the same predicate and force one bounded -# follow-up because their turn-end events are passive. +# OpenCode and pi adapters use the same predicate and force one bounded +# follow-up because their turn-end events are passive. Grok delegates native +# blocking when its running Stop payload advertises that capability, with one +# bounded resume fallback for payloads from pre-native processes. # See docs/turnend-guard.md for the per-harness mechanics, validation evidence, # and fail-open tradeoffs. # @@ -26,10 +28,10 @@ # primary checkout - the main home or a genuinely marked secondmate home - and # stay a silent, fast no-op inside child task worktrees. # -# Loop-guard, codex (default) mode: never block twice in the same turn. Codex -# Stop payloads carry stop_hook_active=true when the CURRENT stop attempt was -# itself already forced by an earlier block this turn; on that signal we always -# allow the stop, whether or not watcher supervision actually got resumed. +# Loop-guard, codex/Grok (default) mode: never block twice in the same turn. +# Codex uses stop_hook_active and Grok uses stopHookActive; typed camel-case +# takes precedence when both spellings are present. A true value means the +# current stop attempt already follows a block, so this guard always allows it. # Passive harness adapters provide their own one-follow-up guard before calling # this script. # That bounds those harnesses to at most one forced continuation per turn - @@ -94,7 +96,15 @@ PAYLOAD=$(cat 2>/dev/null || true) # loop-guard field, so we must never block - fail open, not noisy. command -v jq >/dev/null 2>&1 || exit 0 -STOP_HOOK_ACTIVE=$(printf '%s' "$PAYLOAD" | jq -r '.stop_hook_active // false' 2>/dev/null) || exit 0 +STOP_HOOK_ACTIVE=$(printf '%s' "$PAYLOAD" | jq -r ' + if type != "object" then error("payload") + elif has("stopHookActive") then + if ((.stopHookActive | type) == "boolean") then .stopHookActive else error("stopHookActive") end + elif has("stop_hook_active") then + if ((.stop_hook_active | type) == "boolean") then .stop_hook_active else error("stop_hook_active") end + else false + end +' 2>/dev/null) || exit 0 if [ "$CLAUDE_MODE" -eq 0 ] && [ "$STOP_HOOK_ACTIVE" = "true" ]; then exit 0 fi diff --git a/bin/fm-update.sh b/bin/fm-update.sh index c3b0e674f7..59717839bd 100755 --- a/bin/fm-update.sh +++ b/bin/fm-update.sh @@ -70,13 +70,17 @@ sweep_live_secondmate_metas "$STATE" origin no # Registry backstop: a secondmate registered in data/secondmates.md but without # a live meta (e.g. between restarts) is still its persistent on-disk home. if [ -f "$SECONDMATES_MD" ]; then - while IFS= read -r line; do + while IFS= read -r line || [ -n "$line" ]; do case "$line" in "- "*) ;; *) continue ;; esac - id=$(printf '%s\n' "$line" | sed -n 's/^- \([^ ][^ ]*\) - .*/\1/p') - home=$(printf '%s\n' "$line" | sed -n 's/.*(home:[[:space:]]*\([^;]*\);.*/\1/p' | sed 's/[[:space:]]*$//') + if ! secondmate_registry_parse_line "$line"; then + echo "secondmate registry: skipped malformed entry: $line" >&2 + continue + fi + id=$SECONDMATE_REGISTRY_ID + home=$SECONDMATE_REGISTRY_HOME process_secondmate "$id" "$home" "" origin no done < "$SECONDMATES_MD" fi diff --git a/bin/fm-vendor-auth-probe.sh b/bin/fm-vendor-auth-probe.sh new file mode 100755 index 0000000000..1593fe7ae4 --- /dev/null +++ b/bin/fm-vendor-auth-probe.sh @@ -0,0 +1,200 @@ +#!/usr/bin/env bash +# fm-vendor-auth-probe.sh - one hard-bounded, non-destructive authentication +# probe of a named vendor CLI. +# +# This script collects a FACT and renders no verdict. It takes no harness, model, +# or provider, reads no quota, and never decides whether a dispatch candidate is +# eligible. The dispatching first mate owns that judgment from `quota-axi`'s data +# plus each harness's authoritative model catalog; the decision procedure is +# owned once by .agents/skills/quota-array-dispatch/SKILL.md. +# +# Why it exists rather than the agent running the vendor CLI itself: the +# captain's 2026-07-30 `firstmate-grok-auth-preflight` decision approved exactly +# one bounded, non-interactive probe, and that safety envelope must not depend on +# agent memory. It is enforced here deterministically: +# - the argv is fixed in this file and never composed from input, so no caller +# can turn the probe into a login, logout, or interactive TUI launch; +# - stdin is closed, so caller input can never reach the vendor CLI; +# - a hard positive timeout bounds every command, so a hung CLI cannot wedge an +# intake; +# - raw vendor output is classified here and never printed, logged, or passed +# in an argument. +# +# The probe registry is a fixed-argv safety allowlist, not a routing table. It +# carries no harness, model, provider, credential-store, or provider-family +# relationship, and asking for a probe is always the caller's own explicit +# decision. A probe is registered only after its non-destructive discovery +# command and its output discriminators are verified first-hand and recorded in +# docs/verification/dispatch-auth.md. +# +# Registered probes: +# grok `grok models` - the standalone Grok Build CLI. Verified on grok +# 0.2.117: the command exits 0 in BOTH the authenticated and the +# unauthenticated case, so only the literal first stdout line +# discriminates and the exit status is never a verdict. +# +# Output: exactly one sanitized `key=value` line on stdout. No token, refresh +# token, header, path, length, prefix, hash, or raw vendor output is ever +# printed, logged, or passed in an argument. +# +# probe= the requested probe name +# status= authenticated | unauthenticated | indeterminate | +# timeout | unavailable +# version= the probed CLI's version, or none +# versionVerified= yes | no | none - whether the running CLI matches the +# version whose discriminator strings were verified +# +# `status` is evidence, never eligibility. Only `authenticated` and +# `unauthenticated` are ground truth. `indeterminate`, `timeout`, and +# `unavailable` mean the probe established nothing and must never be read as +# either outcome; unrecognized output is `indeterminate`, never authenticated. +# +# Exit status: 0 whenever the line is printed, 2 on a usage error. The exit +# status deliberately does not encode the probe result, because this script +# renders no verdict for a caller to branch on. +# +# Usage: +# fm-vendor-auth-probe.sh <probe> +# +# Environment: +# FM_VENDOR_AUTH_PROBE_TIMEOUT hard per-command bound in seconds; must be a +# positive integer, otherwise the default 20 is +# used. Zero is rejected because `timeout 0` and +# `alarm 0` both mean "no deadline". +set -u + +VERIFIED_GROK_VERSION=0.2.117 + +usage() { + cat <<'EOF' +fm-vendor-auth-probe.sh - one hard-bounded, non-destructive authentication probe +of a named vendor CLI. It collects a fact and renders no verdict: it takes no +harness, model, or provider, reads no quota, and never decides dispatch +eligibility. The dispatching first mate owns that judgment. + +Usage: + fm-vendor-auth-probe.sh <probe> + +Registered probes: + grok `grok models` on the standalone Grok Build CLI + +Prints one sanitized key=value line: probe, status, version, versionVerified. + +status is evidence, never eligibility: + authenticated the vendor CLI reports an authenticated session + unauthenticated the vendor CLI reports no authenticated session + indeterminate output the verified discriminators do not cover + timeout the hard bound was hit + unavailable the vendor CLI is not on PATH +Only authenticated and unauthenticated are ground truth; the other three +establish nothing and must never be read as either outcome. + +The argv is fixed in the script, stdin is closed, and raw vendor output is never +printed. Login, logout, and the interactive TUI are never invoked. + +Exit status: 0 whenever the line is printed, 2 on a usage error. + +Environment: + FM_VENDOR_AUTH_PROBE_TIMEOUT hard per-command bound in seconds (default 20); + a non-positive or non-numeric value is rejected + in favor of the default +EOF +} + +die_usage() { + printf 'fm-vendor-auth-probe: %s\n' "$1" >&2 + printf 'usage: fm-vendor-auth-probe.sh <probe> (registered probes: grok)\n' >&2 + exit 2 +} + +PROBE= +while [ $# -gt 0 ]; do + case "$1" in + -h|--help) usage; exit 0 ;; + -*) die_usage "unknown option: $1" ;; + *) + [ -z "$PROBE" ] || die_usage "only one probe may be requested at a time" + PROBE=$1 + shift + ;; + esac +done + +[ -n "$PROBE" ] || die_usage "a probe name is required" + +# A non-positive bound is not a bound: `timeout 0` and the Perl fallback's +# `alarm 0` both disable the deadline, so a hung vendor CLI would run unbounded. +TIMEOUT=${FM_VENDOR_AUTH_PROBE_TIMEOUT:-20} +case "$TIMEOUT" in + ''|*[!0-9]*|0*) TIMEOUT=20 ;; +esac + +# Bounded execution, mirroring bin/fm-fleet-snapshot.sh's run_timed selection so +# a macOS host without coreutils still gets a hard bound instead of an unbounded +# vendor CLI call. Exit 124 means the bound was hit. +run_timed() { # <seconds> <command...> + local seconds=$1 + shift + if command -v timeout >/dev/null 2>&1; then + timeout "$seconds" "$@" + elif command -v gtimeout >/dev/null 2>&1; then + gtimeout "$seconds" "$@" + elif command -v perl >/dev/null 2>&1; then + 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)' "$seconds" "$@" + else + return 124 + fi +} + +STATUS=unavailable +VERSION=none +VERSION_VERIFIED=none + +emit() { + printf 'probe=%s status=%s version=%s versionVerified=%s\n' \ + "$PROBE" "$STATUS" "$VERSION" "$VERSION_VERIFIED" + exit 0 +} + +# The two argv forms below are literals in this file. Nothing the caller supplies +# reaches the vendor CLI's argv or stdin. +grok_version() { + local output + output=$(run_timed "$TIMEOUT" grok --version 2>/dev/null </dev/null) || { printf 'none\n'; return 0; } + printf '%s\n' "$output" | sed -nE 's/.*[^0-9]([0-9]+\.[0-9]+\.[0-9]+).*/\1/p' | head -n 1 | grep . || printf 'none\n' +} + +probe_grok() { + local output first rc=0 + output=$(run_timed "$TIMEOUT" grok models 2>/dev/null </dev/null) || rc=$? + if [ "$rc" -eq 124 ]; then + printf 'timeout\n' + return 0 + fi + # The exit status is deliberately ignored: grok 0.2.117 exits 0 in both the + # authenticated and unauthenticated cases, so only the first stdout line + # discriminates. Raw output is classified here and never printed. + first=$(printf '%s\n' "$output" | head -n 1) + case "$first" in + "You are logged in with "*) printf 'authenticated\n' ;; + "You are not authenticated."*) printf 'unauthenticated\n' ;; + *) printf 'indeterminate\n' ;; + esac +} + +case "$PROBE" in + grok) + command -v grok >/dev/null 2>&1 || emit + VERSION=$(grok_version) + if [ "$VERSION" = "$VERIFIED_GROK_VERSION" ]; then + VERSION_VERIFIED=yes + else + VERSION_VERIFIED=no + fi + STATUS=$(probe_grok) + emit + ;; + *) + die_usage "no probe is registered for '$PROBE'" + ;; +esac diff --git a/bin/fm-wake-lib.sh b/bin/fm-wake-lib.sh index 929c7231a4..8cec58bec1 100755 --- a/bin/fm-wake-lib.sh +++ b/bin/fm-wake-lib.sh @@ -9,6 +9,10 @@ STATE="${FM_STATE_OVERRIDE:-${STATE:-$FM_HOME/state}}" FM_WAKE_QUEUE="${FM_WAKE_QUEUE:-$STATE/.wake-queue}" FM_WAKE_QUEUE_LOCK="${FM_WAKE_QUEUE_LOCK:-$STATE/.wake-queue.lock}" FM_LOCK_STALE_AFTER="${FM_LOCK_STALE_AFTER:-2}" +# Resolved once at source time: fm_pid_identity and fm_path_mtime run inside 0.2s +# confirm and 0.5s attach polls, and forking uname per call is a measurable cost on +# the platform (Git Bash/MSYS) that already pays the highest fork price. +_FM_UNAME=$(uname 2>/dev/null || echo unknown) mkdir -p "$STATE" fm_current_pid() { @@ -24,17 +28,19 @@ fm_pid_alive() { } fm_pid_identity() { - local pid=$1 out proc_root stat_line starttime cmdline_hex + local pid=$1 out proc_root stat_line starttime cmdline_hex identity_key local -a stat_fields case "$pid" in ''|*[!0-9]*) return 1 ;; esac proc_root=${FM_PROC_ROOT_OVERRIDE:-/proc} - # Prefer /proc on Linux: stat field 22 (starttime, clock ticks since boot) is + # Prefer a Linux-compatible /proc when present: stat field 22 (starttime, clock ticks since boot) is # immune to the wall-clock steps that re-render the ps lstart fallback's date # (observed as WSL2 btime drift) and would evict a live watcher; combining the # full NUL-separated cmdline keeps PID reuse a mismatch even on a tick collision. - if [ "$(uname)" = Linux ] && [ -r "$proc_root/$pid/stat" ] && [ -r "$proc_root/$pid/cmdline" ]; then + # Git Bash/MSYS exposes these compatible files but its Cygwin ps rejects the + # portable fallback's -o fields, so capability detection must not key on uname. + if [ -r "$proc_root/$pid/stat" ] && [ -r "$proc_root/$pid/cmdline" ]; then stat_line=$(cat "$proc_root/$pid/stat" 2>/dev/null) || return 1 # After the final comm delimiter, array index 19 is proc stat field 22. read -r -a stat_fields <<< "${stat_line##*)}" @@ -45,7 +51,9 @@ fm_pid_identity() { esac cmdline_hex=$(od -An -v -tx1 "$proc_root/$pid/cmdline" 2>/dev/null | tr -d '[:space:]') || return 1 [ -n "$cmdline_hex" ] || return 1 - printf 'linux-starttime=%s cmdline-hex=%s\n' "$starttime" "$cmdline_hex" + identity_key=proc-starttime + [ "$_FM_UNAME" != Linux ] || identity_key=linux-starttime + printf '%s=%s cmdline-hex=%s\n' "$identity_key" "$starttime" "$cmdline_hex" return 0 fi # Pin LC_ALL=C so lstart's date format is locale-invariant: the identity is @@ -57,7 +65,7 @@ fm_pid_identity() { } fm_path_mtime() { - if [ "$(uname)" = Darwin ]; then + if [ "$_FM_UNAME" = Darwin ]; then stat -f %m "$1" 2>/dev/null else stat -c %Y "$1" 2>/dev/null diff --git a/bin/fm-watch-arm.sh b/bin/fm-watch-arm.sh index 0a783ce287..3c2df49c89 100755 --- a/bin/fm-watch-arm.sh +++ b/bin/fm-watch-arm.sh @@ -68,7 +68,13 @@ BEAT="$STATE/.last-watcher-beat" # "Fresh" reuses the guard's threshold so there is one definition of liveness. GRACE=${FM_GUARD_GRACE:-300} # How long to wait for a freshly forked watcher to acquire the lock and beat. -CONFIRM_TIMEOUT=${FM_ARM_CONFIRM_TIMEOUT:-10} +# Git Bash/MSYS pays a much higher fork cost while the watcher completes its +# required pre-lock migration, so its bounded default covers that cold start. +case "${OSTYPE:-}" in + msys*|mingw*|cygwin*) ARM_CONFIRM_DEFAULT=30 ;; + *) ARM_CONFIRM_DEFAULT=10 ;; +esac +CONFIRM_TIMEOUT=${FM_ARM_CONFIRM_TIMEOUT:-$ARM_CONFIRM_DEFAULT} # Poll interval while attached to an existing healthy watcher. ATTACH_POLL=${FM_ARM_ATTACH_POLL:-0.5} CYCLE_LOG="$STATE/.watch-cycle-exits.log" diff --git a/bin/fm-watch.sh b/bin/fm-watch.sh index b7006e6362..2f40e65987 100755 --- a/bin/fm-watch.sh +++ b/bin/fm-watch.sh @@ -30,7 +30,16 @@ # also carries a "demand-deep-inspection" marker so the # wake payload itself, not just repetition, forces a # closer look instead of another routine supervision -# resume. Unless afk is active. +# resume. Unless afk is active. A genuinely busy pane +# (window_is_busy true) is exempt from the above, but +# only up to BUSY_TURN_MAX_SECS with no completed turn +# (state/<id>.turn-ended, or the spawn record before any +# turn completes); past that bound busy_turn_over_age +# routes it through the same wedge timer, so it surfaces +# with the identical "stale: ..." reason, escalation +# count, and demand-deep-inspection marker, for human +# inspection only - never an automatic interrupt, +# signal, or restart of the worker or its tool process. # check: <script>: <out> authenticated check output, always actionable # check: rejected unauthenticated state checks: <paths> # unsafe state checks were refused without execution @@ -66,6 +75,8 @@ mkdir -p "$STATE" # cheap when no records exist and never scrapes secondmate conversation. # shellcheck source=bin/fm-pending-reply-lib.sh . "$SCRIPT_DIR/fm-pending-reply-lib.sh" +# shellcheck source=bin/fm-busy-lib.sh +. "$SCRIPT_DIR/fm-busy-lib.sh" WATCH_LOCK="$STATE/.watch.lock" WATCH_PATH="$SCRIPT_DIR/fm-watch.sh" @@ -100,14 +111,9 @@ CHECK_TIMEOUT=${FM_CHECK_TIMEOUT:-30} # seconds allowed per *.check.sh SIGNAL_GRACE=${FM_SIGNAL_GRACE:-30} # seconds to linger after a signal so trailing # signals (a status write, then the same turn's # turn-end hook) coalesce into one wake -# Busy signatures are selected by recorded harness unless FM_BUSY_REGEX globally -# overrides them. -# claude/codex: "esc to interrupt"; opencode: "esc interrupt"; pi: "Working..."; -# grok: "Ctrl+c:cancel". Claude's current spinner signature is matched only for -# a recorded Claude task because an ellipsis followed by elapsed time is not a -# safe shared signature for arbitrary harness output. Kimi's moon-plus-middot -# spinner signature is likewise matched only for a recorded Kimi task. -BUSY_REGEX=${FM_BUSY_REGEX:-'esc (to )?interrupt|Working\.\.\.|Ctrl\+c:cancel'} +# Busy state is decided by the semantic contract in bin/fm-busy-lib.sh, which +# is the single owner of per-harness sources, source attribution, and the +# narrowly scoped Grok and experimental-AGY rendered fallbacks. # Always-on wake triage: most wakes during a long crew validation are benign (a # working: note or turn-end while a pipeline runs, a no-change heartbeat). Rather # than wake firstmate's LLM for each, this watcher classifies every wake in bash @@ -127,6 +133,19 @@ BUSY_REGEX=${FM_BUSY_REGEX:-'esc (to )?interrupt|Working\.\.\.|Ctrl\+c:cancel'} # daemon owns triage, so this watcher reverts to one-shot (enqueue + exit on every # wake) and never double-triages - and never runs the costly provably-working read. STALE_ESCALATE_SECS=${FM_STALE_ESCALATE_SECS:-240} # idle secs before a provably-working stale escalates as a possible wedge +# A busy pane is unconditional proof of liveness with no built-in duration bound, +# so a hung foreground call can remain hidden even while its rendered busy +# footer changes every poll. BUSY_TURN_MAX_SECS bounds how long any busy pane +# may go with no completed turn: once its task's +# state/<id>.turn-ended marker (or, before any turn has completed, the task's +# spawn record) is this old, busy_turn_over_age routes the pane through the +# same STALE_ESCALATE_SECS-paced wedge_timer_check used for a provably-working +# non-busy stale, so it escalates via the existing stale reason, escalation +# counter, and demand-deep-inspection marker for human inspection only - never +# an automatic interrupt, signal, or restart. A completed turn touches +# turn-ended and resets the age. Set generously above any legitimate interval +# between completed turns, including long tool calls, builds, or test runs. +BUSY_TURN_MAX_SECS=${FM_BUSY_TURN_MAX_SECS:-3600} # A crew that declared a pause is idling on a known external wait, so its stale # pane is absorbed rather than wedge-escalated. # A captain-held or paused crew whose agent has confidently exited uses the same @@ -157,29 +176,24 @@ hash_pane() { if command -v md5 >/dev/null 2>&1; then md5 -q; else md5sum | cut -d' ' -f1; fi } -# window_is_busy: 0 (busy) iff the task's harness is actively working. Prefers -# a backend's native semantic busy state (fm_backend_busy_state - herdr's -# agent.get; herdr-addendum "busy state" row, "the first backend where -# fm_session_busy_state gets real semantics"); when the backend reports unknown, -# falls back to the recorded harness's verified pane-tail signature. <tail40> is -# the same bounded capture already read for hashing, so this adds no extra -# backend calls on the regex-fallback path. +# window_is_busy: 0 (busy) iff the task's harness is PROVABLY working, through +# the semantic busy-state contract (bin/fm-busy-lib.sh). Only an exact busy +# verdict returns 0: idle, unknown, and dead all return 1, so a converted +# adapter whose semantic state is missing, malformed, stale, or unverified is +# treated as not-provably-working and surfaces rather than being absorbed. +# <tail40> is the same bounded capture already read for hashing and is +# consumed only by the Grok- or AGY-scoped fallback inside the contract. window_is_busy() { # <window> <tail40> - local w=$1 tail40=$2 bs harness lines - bs=$(fm_backend_busy_state "$(window_backend "$w")" "$w" 2>/dev/null) - case "$bs" in - busy) return 0 ;; - idle) return 1 ;; - *) - lines=$(printf '%s' "$tail40" | grep -v '^[[:space:]]*$' | tail -12) - harness=$(window_harness "$w") - if [ -n "${FM_BUSY_REGEX:-}" ]; then - printf '%s' "$lines" | grep -qiE "$BUSY_REGEX" - else - printf '%s' "$lines" | fm_busy_lines_match "$harness" - fi - ;; - esac + local w=$1 tail40=$2 task meta verdict + task=$(window_to_task "$w" "$STATE") + meta="$STATE/$task.meta" + if [ -n "$task" ] && [ -f "$meta" ]; then + verdict=$(fm_busy_classify_meta "$meta" "$task" "$STATE" "$tail40") + else + verdict=$(fm_busy_classify "$(window_backend "$w")" "$w" "$(window_harness "$w")" \ + "${task:-unknown}" "$STATE" "$tail40") + fi + [ "${verdict%% *}" = busy ] } window_kind() { @@ -281,6 +295,20 @@ wedge_timer_check() { # <window> <since-file> <triage-label> <escalation-count- esac } +# busy_turn_over_age: 0 iff <task>'s latest completed-turn marker is at least +# BUSY_TURN_MAX_SECS old. Ages the per-task turn-ended marker, the harness-neutral +# signal every verified harness's turn-end hook touches; before any turn has +# completed, ages the task's spawn record instead so a fresh task still gets a +# bound. The caller checks that the pane is busy and routes a crossed bound +# through the existing wedge_timer_check, never anything that touches the +# worker itself. +busy_turn_over_age() { # <task> + local task=$1 f + f="$STATE/$task.turn-ended" + [ -e "$f" ] || f="$STATE/$task.meta" + [ "$(age_of "$f")" -ge "$BUSY_TURN_MAX_SECS" ] +} + # Absorb a stale pane under a declared external-wait pause (paused:) or a # dead-agent captain-held transfer, and re-surface it once every # PAUSE_RESURFACE_SECS for a recheck so it cannot rot invisibly. Called on any @@ -851,14 +879,16 @@ EOF ewf="$STATE/.wedge-escalations-$key" pf="$STATE/.paused-$key" # flag: this key's stale is using the bounded pause cadence prev=$(cat "$hf" 2>/dev/null || true) + # Busy match: a backend's native semantic state when available (herdr), else + # the last 6 non-blank lines only (the TUI footer area, where every verified + # harness renders its busy indicator) so busy-looking strings in displayed + # content cannot suppress stale detection. Read once per window per poll and + # reused below so a busy verdict is consistent within one cycle. + if window_is_busy "$w" "$tail40"; then busy_now=0; else busy_now=1; fi if [ "$h" = "$prev" ]; then n=$(( $(cat "$cf" 2>/dev/null || echo 0) + 1 )) echo "$n" > "$cf" - # Busy match: a backend's native semantic state when available (herdr), - # else the last 6 non-blank lines only (the TUI footer area, where every - # verified harness renders its busy indicator) so busy-looking strings - # in displayed content cannot suppress stale detection. - if [ "$n" -ge 2 ] && ! window_is_busy "$w" "$tail40"; then + if [ "$n" -ge 2 ] && [ "$busy_now" -ne 0 ]; then # The pane is idle/stale at hash $h. Triage decides whether this wakes # firstmate. Detection itself is unchanged from above. if [ "$kind" = secondmate ]; then @@ -920,8 +950,8 @@ EOF # - paused: the crew declared an external wait, or a declared pause or # captain hold is paired with a confidently dead agent, so absorb on # the long PAUSE_RESURFACE_SECS cadence instead of wedge-escalating; - # - none: no running pipeline, idle pane, no busy signature, no declared - # pause - the crew has STOPPED. Surface immediately so firstmate peeks + # - none: no running pipeline, no exact busy verdict, no declared pause. + # Surface immediately so firstmate inspects the inconclusive state # (it may be done via an interactive menu that wrote no done: status, # waiting on a decision, or wedged) instead of leaving the finish to # wait out the timer. @@ -958,8 +988,14 @@ EOF fi fi else - # Pane busy or not yet stably stale: reset pending escalation bookkeeping. - rm -f "$ssf" "$ewf" + # Pane busy or not yet stably stale: reset pending escalation bookkeeping, + # unless a genuinely busy pane has gone too long with no completed turn - + # then route it through the same wedge timer instead of erasing it. + if [ "$busy_now" -eq 0 ] && busy_turn_over_age "$task"; then + wedge_timer_check "$w" "$ssf" "busy (no completed turn)" "$ewf" + else + rm -f "$ssf" "$ewf" + fi if [ -e "$pf" ] && { [ "$n" -ge 2 ] || ! status_is_paused_or_captain_held "$(last_status_line "$STATE/$(window_to_task "$w" "$STATE").status")"; }; then clear_pause_tracking "$w" fi @@ -967,9 +1003,13 @@ EOF else printf '%s' "$h" > "$hf" echo 0 > "$cf" - rm -f "$ssf" "$ewf" + if [ "$busy_now" -eq 0 ] && busy_turn_over_age "$task"; then + wedge_timer_check "$w" "$ssf" "busy (no completed turn)" "$ewf" + else + rm -f "$ssf" "$ewf" + fi task=$(window_to_task "$w" "$STATE") - if ! afk_present && status_is_paused_or_captain_held "$(last_status_line "$STATE/$task.status")" && ! window_is_busy "$w" "$tail40"; then + if ! afk_present && status_is_paused_or_captain_held "$(last_status_line "$STATE/$task.status")" && [ "$busy_now" -ne 0 ]; then case "$(pause_state_class "$w" "$task")" in paused) handle_paused_stale "$w" "$task" "$h" ;; *) clear_pause_tracking "$w" ;; diff --git a/bin/fm-x-followup.sh b/bin/fm-x-followup.sh index 603678cfca..9a51c3883b 100755 --- a/bin/fm-x-followup.sh +++ b/bin/fm-x-followup.sh @@ -17,6 +17,11 @@ # exit 1, silent -> not linked, or window/cap exhausted (link # pruned) # +# Clear a legacy link without posting: +# fm-x-followup.sh --clear <task-id> +# idempotently removes only the X follow-up metadata for a typed terminal +# outcome. +# # Post (after composing the reply to a file or stdin): # fm-x-followup.sh <task-id> [--image <path>] [--final] --text-file <path> # fm-x-followup.sh <task-id> [--image <path>] [--final] - @@ -65,12 +70,13 @@ STATE="${FM_STATE_OVERRIDE:-$FM_HOME/state}" . "$SCRIPT_DIR/fm-x-lib.sh" usage() { - echo "usage: fm-x-followup.sh --check <task-id> | <task-id> [--image <path>] [--final] --text-file <path> | <task-id> [--image <path>] [--final] -" >&2 + echo "usage: fm-x-followup.sh --check <task-id> | --clear <task-id> | <task-id> [--image <path>] [--final] --text-file <path> | <task-id> [--image <path>] [--final] -" >&2 } help() { cat <<'EOF' usage: fm-x-followup.sh --check <task-id> + fm-x-followup.sh --clear <task-id> fm-x-followup.sh <task-id> [--image <path>] [--final] --text-file <path> fm-x-followup.sh <task-id> [--image <path>] [--final] - @@ -79,6 +85,7 @@ X-mode-linked task and manage the link's follow-up counter. Options: --check Print the request_id when a follow-up is due. + --clear Clear only the X follow-up link; never post. --image <path> Attach one local image file; threaded replies attach it to the opener tweet or message. --final Clear the link after this post regardless of the remaining count. --text-file <path> @@ -108,7 +115,11 @@ case "${1:-}" in esac FINAL=0 -if [ "${1:-}" = --check ]; then +if [ "${1:-}" = --clear ]; then + MODE=clear + ID=${2:-} + if [ -z "$ID" ] || [ "$#" -gt 2 ]; then usage; exit 2; fi +elif [ "${1:-}" = --check ]; then MODE=check ID=${2:-} if [ -z "$ID" ] || [ "$#" -gt 2 ]; then usage; exit 2; fi @@ -144,6 +155,13 @@ case "$ID" in esac META="$STATE/$ID.meta" +if [ "$MODE" = clear ]; then + fmx_meta_link_clear "$META" \ + || { echo "fm-x-followup: could not clear the link in state/$ID.meta" >&2; exit 1; } + printf '%s\n' "$ID" + exit 0 +fi + RID=$(fmx_meta_get "$META" x_request) TS=$(fmx_meta_get "$META" x_request_ts) COUNT=$(fmx_meta_get "$META" x_followups) diff --git a/bin/fm-x-poll.sh b/bin/fm-x-poll.sh index db7c1ebba0..a3a727f9ec 100755 --- a/bin/fm-x-poll.sh +++ b/bin/fm-x-poll.sh @@ -17,6 +17,14 @@ # claim state/x-context/<request_id>.offered.json, and print one compact # line "x-mention <request_id>" (which becomes the watcher wake payload) # an already offered request_id -> print nothing, exit 0 +# a new set of unreconciled public-followup terminal results -> print one +# "public-followup ..." line BEFORE the relay call, so a promised final +# reply is surfaced through this same wake path +# +# The public-followup line rides here rather than on a new poll of its own: this +# check only exists in a home that opted into the relay, and it is an O(1) +# directory presence test plus a signature compare, with no tasks-axi call and no +# backlog scan. A home with no pending terminal results pays nothing for it. # The full object is stashed verbatim, so any conversation context the relay # includes (in_reply_to: {author_handle, text}, null for a fresh mention) is # preserved for fmx-respond to handle follow-ups with continuity. The durable @@ -32,13 +40,30 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" FM_ROOT="${FM_ROOT_OVERRIDE:-$(cd "$SCRIPT_DIR/.." && pwd)}" FM_HOME="${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}}" STATE="${FM_STATE_OVERRIDE:-$FM_HOME/state}" -# shellcheck source=bin/fm-x-lib.sh -. "$SCRIPT_DIR/fm-x-lib.sh" +# shellcheck source=bin/fm-public-followup-lib.sh +# Also brings in bin/fm-x-lib.sh, which this script's relay client uses. +. "$SCRIPT_DIR/fm-public-followup-lib.sh" fmx_load_config # Hard no-op when X mode is off: this is what keeps the check shim inert. [ -n "$FMX_TOKEN" ] || exit 0 +# Unreconciled terminal results for a public commitment are actionable even when +# the relay has no new mention, and they outlive any session, so surface them +# first. The signature compare keeps this to one wake per new result set instead +# of one per cycle; bin/fm-public-followup.sh consume clears it. +if fm_pf_has_events "$STATE"; then + PF_ROOT=$(fm_pf_root "$STATE") + PF_SIG=$(fm_pf_events_signature "$STATE" 2>/dev/null) || PF_SIG= + if [ -n "$PF_SIG" ] \ + && [ "$(cat "$PF_ROOT/$FM_PF_SURFACED_BASENAME" 2>/dev/null || true)" != "$PF_SIG" ]; then + if printf '%s\n' "$PF_SIG" \ + | fmx_private_artifact_publish_stdin "$PF_ROOT" "$FM_PF_SURFACED_BASENAME" 600 2>/dev/null; then + printf 'public-followup terminal results are waiting to be reconciled\n' + fi + fi +fi + ERROR_FILE="$STATE/x-poll.error" CLAIM_ERROR_FILE="$STATE/x-poll.claim-error" diff --git a/bin/fm-x-reply.sh b/bin/fm-x-reply.sh index 44e85cb469..d8d654b545 100755 --- a/bin/fm-x-reply.sh +++ b/bin/fm-x-reply.sh @@ -5,6 +5,13 @@ # fm-x-reply.sh <request_id> [--image <path>] --text-file <path> # fm-x-reply.sh <request_id> [--image <path>] - # fm-x-reply.sh <request_id> --followup [--image <path>] ... +# fm-x-reply.sh <request_id> ... --receipt-file <path> +# +# --receipt-file <path> writes {request_id, endpoint, chunks, dry_run} to <path> +# after the reply lands, so a caller that must record HOW MANY messages were +# posted (bin/fm-public-followup.sh, building a typed delivery receipt) does not +# have to re-derive the split. Omitted by default and never written on failure, +# so stdout, exit codes, and every existing caller stay unchanged. # # The --text-file / stdin forms exist so a caller never has to inline reply text # (which may be influenced by a public mention) into a shell command, where shell @@ -102,21 +109,35 @@ reply_make_tmp_file() { printf -v "$var_name" '%s' "$file" } +# write_reply_receipt <chunks> <dry-run-0|1>: record what this reply actually +# sent, for a caller that has to build a typed delivery receipt. Only ever called +# on success. A write failure is reported but never changes the exit status: the +# reply already landed, and claiming otherwise would invite a duplicate post. +write_reply_receipt() { + [ -n "$RECEIPT_FILE" ] || return 0 + if ! (umask 077; jq -n --arg r "$REQ" --arg e "$ENDPOINT" --argjson c "$1" --argjson d "$2" \ + '{request_id:$r, endpoint:$e, chunks:$c, dry_run:($d == 1)}' > "$RECEIPT_FILE"); then + echo "fm-x-reply: warning: posted but could not write the receipt to $RECEIPT_FILE" >&2 + fi +} + usage() { - echo "usage: fm-x-reply.sh <request_id> [--followup] [--image <path>] <text> | [--followup] [--image <path>] --text-file <path> | [--followup] [--image <path>] -" >&2 + echo "usage: fm-x-reply.sh <request_id> [--followup] [--image <path>] [--receipt-file <path>] <text> | ... --text-file <path> | ... -" >&2 } help() { cat <<'EOF' -usage: fm-x-reply.sh <request_id> [--followup] [--image <path>] <text> - fm-x-reply.sh <request_id> [--followup] [--image <path>] --text-file <path> - fm-x-reply.sh <request_id> [--followup] [--image <path>] - +usage: fm-x-reply.sh <request_id> [--followup] [--image <path>] [--receipt-file <path>] <text> + fm-x-reply.sh <request_id> [--followup] [--image <path>] [--receipt-file <path>] --text-file <path> + fm-x-reply.sh <request_id> [--followup] [--image <path>] [--receipt-file <path>] - Post a public-safe X-mode answer to the relay, or a completion follow-up with --followup. Options: --followup POST to /connector/followup instead of /connector/answer. --image <path> Attach one local image file; threaded replies attach it to the opener tweet or message. + --receipt-file <path> + After a successful reply, write {request_id, endpoint, chunks, dry_run} to <path>. --text-file <path> Read reply text from a file instead of the command line. - Read reply text from stdin. @@ -141,6 +162,7 @@ shift # the answer path always has. FOLLOWUP=0 IMAGE_PATH= +RECEIPT_FILE= ARGS=() while [ "$#" -gt 0 ]; do case "$1" in @@ -154,6 +176,15 @@ while [ "$#" -gt 0 ]; do fi IMAGE_PATH=$1 ;; + --receipt-file) + shift + if [ "$#" -lt 1 ] || [ -z "$1" ]; then + echo "fm-x-reply: missing --receipt-file path" >&2 + usage + exit 2 + fi + RECEIPT_FILE=$1 + ;; *) ARGS+=("$1") ;; esac shift @@ -306,6 +337,7 @@ if [ -n "$FMX_DRY" ]; then "$N" "$FMX_RELAY" "$ENDPOINT" "$REQ" >&2 printf '%s' "$CHUNKS" | jq -r '.[]' | while IFS= read -r __chunk; do printf ' %s\n' "$__chunk" >&2; done fi + write_reply_receipt "$N" 1 printf '%s\n' "$REQ" exit 0 fi @@ -331,6 +363,7 @@ case "$code" in fmx_context_registry_set "$STATE" "$REQ" "$REQ_PLATFORM" "$REQ_EXPLICIT_MAX" 1 2>/dev/null \ || echo "fm-x-reply: warning: could not retain reply context for $REQ" >&2 fi + write_reply_receipt "$N" 0 printf '%s\n' "$REQ" ;; 409) diff --git a/docs/architecture.md b/docs/architecture.md index da1519a0d4..e46b3c31b7 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -11,17 +11,18 @@ firstmate's always-loaded operating contract and routing index for conditional p A zero-token bash watcher (`bin/fm-watch.sh`) sleeps on the fleet, classifies detected wakes in bash, and wakes the first mate only when something is actionable. Actionable wakes include captain-relevant status signals, no-verb signals whose crew is not provably working, authenticated check output such as PR merge polling or an X-mode mention, stale panes whose crew is not provably working whether their status log looks terminal or non-terminal, provably-working stale panes that persist past `FM_STALE_ESCALATE_SECS`, declared external waits that remain paused past `FM_PAUSE_RESURFACE_SECS`, and heartbeat backstop hits. Repeated provably-working stale escalations on the same unchanged pane add an escalation count to the wake reason and, at `FM_WEDGE_DEMAND_INSPECT_COUNT`, a `demand-deep-inspection` marker. +A busy pane is otherwise exempt from staleness, but only until its latest `state/<id>.turn-ended` marker reaches `FM_BUSY_TURN_MAX_SECS`, or its `state/<id>.meta` spawn record reaches that age before any turn completes; past that bound it is routed through the same wedge escalation, with the identical reason, escalation count, and `demand-deep-inspection` marker, for inspection only - never an automatic interrupt, signal, or restart. Those actionable wakes are written to a durable local queue (`state/.wake-queue`) before detector state advances, so a missed process exit can be recovered by draining the queue. When a canonical validated PR poll returns exactly `merged`, the watcher appends that durable notification before publishing a private receipt bound to the poll's registration, bytes, file identities, metadata, provider, URL, and task ID. The receipt makes retirement safely retryable across restarts: fixed-path recovery revalidates the same evidence, removes the runnable check first, removes its registration and data sidecars, removes the receipt last, and preserves task metadata including `pr=` and `pr_head=`. A concurrent replacement remains armed, every non-merged or invalid observation remains unchanged, and retirement never performs task or persistent-secondmate cleanup. `bin/fm-pr-lib.sh` owns the receipt format and strict identity mechanics, while `bin/fm-watch.sh` owns queue-before-retirement ordering. -No-verb wakes, such as `working:` notes and bare turn-ended signals, are benign only when `bin/fm-crew-state.sh` reports positive evidence that the crew is still working: an actively running no-mistakes step attributed to that crew's current code or a backend busy signature. +No-verb wakes, such as `working:` notes and bare turn-ended signals, are benign only when `bin/fm-crew-state.sh` reports positive evidence that the crew is still working: an actively running no-mistakes step attributed to that crew's current code, or an exact busy verdict from the semantic busy-state contract. A crew that declares `paused:` for a known external wait is separately absorbed while idle and re-surfaced only on the longer pause cadence, rather than being treated as a possible wedge. For an ordinary crew that has stopped, the normal-mode watcher first surfaces one stale wake, then applies that same cadence to an unchanged `paused:` or durable `captain-held` endpoint only when the backend confidently reports its agent dead. Live or inconclusive liveness remains fail-open at that initial surface, and the secondmate idle-endpoint exemption is unchanged. Its initial normal-mode status signal still surfaces through the no-verb path, while away mode self-handles that routine signal and owns the later recheck. -Fresh stale panes use the same current-state read before trusting the status log, so an active run or busy pane outranks an old captain-relevant status-log line left behind before validation. +Fresh stale panes use the same current-state read before trusting the status log, so an active run or a proven busy worker outranks an old captain-relevant status-log line left behind before validation. No-change heartbeats are also benign. Absorbed wakes advance their suppression markers, log to `state/.watch-triage.log`, and keep the watcher blocking without a queue record or LLM turn. After each drain, `fm-wake-drain.sh` runs the same liveness guard as the supervision scripts, so a lapsed watcher chain surfaces even on a turn that only drains and handles queued wakes. @@ -32,10 +33,10 @@ Crew status files are append-only wake-event logs, not current-state fields. The script header owns the exact run-head ancestry rules. During no-mistakes' `ci` monitor phase, it also reads the ci step log tail because `axi status` reports both "still waiting on checks" and "checks green, waiting on merge" as `ci,running`. The most recent recognized ci log marker wins, so checks-green monitoring reports done while a later re-arm, failed-check, or issue marker returns the crew to working. -Only when no matching run exists does it fall back to the pane busy-signature and then a status-log event whose verb maps to a recognized run-state; a dead pane without a run reports unknown instead of trusting a stale log. +Only when no matching run exists does it consult semantic busy state; exact busy reports working, exact idle permits fallback to a status-log event whose verb maps to a recognized run-state, and unknown or a dead pane stays unknown instead of trusting a stale log. Decision-only events such as `resolved` never become current state or leak their prose into the current-state detail. In that status-log fallback, a declared external wait reports the distinct `paused` state with its reason. -For herdr, that pane fallback trusts a native `busy` verdict outright, but corroborates native `idle` or unknown verdicts against the recorded harness's rendered busy signature before deciding the crew is not working. +The semantic branch reports working only on an exact busy verdict and names the source that produced it; an unknown verdict never becomes working, never permits the status-log fallback, and never becomes a silent idle. For whole-fleet read-only review, `bin/fm-fleet-snapshot.sh --json` emits schema `fm-fleet-snapshot.v1` from the backlog, task metadata, current crew state, endpoint probes, PR/report pointers, scout reports, bounded current summaries from registered secondmate homes, and secondmate return-channel guidance. `bin/fm-fleet-view.sh` renders that snapshot as Markdown for humans, while `bin/fm-bearings-snapshot.sh` provides the bounded bearings projection, so both views consume one structured contract instead of reparsing raw fleet files. The script header owns the exact JSON schema. @@ -85,23 +86,42 @@ Stalled escalation delivery writes `state/.subsuper-inject-wedged` and attempts On an unmarked return, `bin/fm-afk-return.sh` owns ordered shutdown, durable catch-up evidence, and the fail-closed gate that keeps ordinary work behind every live firstmate-actionable blocker. `fm-send.sh` selects a pre-Enter popup-settle for slash commands and for codex `$...` skill invocations using metadata-routed target `harness=` values, then adds its own `FM_SEND_SETTLE` pause after successful text sends so immediate peeks catch the receiving turn starting; the sub-supervisor uses only the shared submit core and does not pay that post-submit pause. +## Busy state is semantic, per adapter + +`bin/fm-busy-lib.sh` is the single owner of what "this worker is busy" means, and `bin/fm-busy-event.sh` is the only writer of the per-task records it reads. +Every classification returns a verdict of busy, idle, unknown, or dead together with the source that produced it, so a consumer or a diagnostic can never confuse semantic state with a fallback. + +Each converted adapter reports its own turn lifecycle through a machine-readable contract the vendor already exposes, rather than through rendered footer text: Pi and pi-signed through the Firstmate-owned extension's `agent_start` and `agent_settled` confirmed by `ctx.isIdle()`, OpenCode through its plugin's semantic `session.status`, and Claude through owned `UserPromptSubmit`, `Stop`, `StopFailure`, and `SessionEnd` hooks. +Kimi behind Pi inherits Pi's lifecycle. +Codex and standalone Kimi classify unknown behind explicit probes until a semantic source is live-verified for them. +Grok keeps its clearly isolated rendered-tail fallback, while the experimental AGY adapter uses its separately scoped, live-verified explicit busy and idle pane signatures because it has no lifecycle hook; neither fallback can classify another harness. + +Missing, malformed, stale, untrusted, or unverified semantic state is unknown, never idle, and unknown is never promoted to busy either. +Ordinary task-state consumers act only on an exact busy verdict, so an unreadable worker surfaces for a closer look instead of being absorbed as still-working or written off as finished. +Endpoint death is the only process-level override and yields dead; child processes, CPU, process sleep state, and marker modification times are not state signals. +`state/<id>.turn-ended` files remain wake notifications, not current state. + +Each record is bound to an incarnation token minted when the task's wiring is armed, so an event from a superseded incarnation is rejected rather than applied, and a record left behind by one classifies unknown. +Three rendered-text readers deliberately remain outside this contract because they answer delivery questions: the submit acknowledgement and away-mode supervisor-pane busy guard in `bin/fm-tmux-lib.sh`, and the secondmate delivery-confirmation observation in `bin/fm-pending-reply-lib.sh`. +All are harness-scoped rather than a global pattern union, and none is a recorded worker state source. + ## Runtime session backends The runtime backend is the session-provider layer below firstmate's scripts. It owns task endpoint creation, bounded capture, text/key sends, current-path reads for spawn-time worktree discovery when the backend does not create the worktree itself, live-window fallback lookup, agent-process liveness probes where verified, and endpoint teardown. -`bin/fm-backend.sh` centralizes backend selection, `state/<id>.meta` helpers, selector resolution, and operation dispatch; `bin/backends/tmux.sh` is the verified reference adapter ([`docs/tmux-backend.md`](tmux-backend.md)), and `bin/backends/herdr.sh` (P2), `bin/backends/zellij.sh` (P3), `bin/backends/orca.sh` (P4), and `bin/backends/cmux.sh` (P5) are experimental task-spawn adapters. -New spawns select a backend from `--backend`, then `FM_BACKEND`, then local `config/backend`, then runtime auto-detection from `$TMUX`, `HERDR_ENV=1`, or cmux runtime signals, then default `tmux`. +`bin/fm-backend.sh` centralizes backend selection, `state/<id>.meta` helpers, metadata-only cleanup identity validation, selector resolution, and operation dispatch; `bin/backends/tmux.sh` is the verified reference adapter ([`docs/tmux-backend.md`](tmux-backend.md)), and `bin/backends/herdr.sh` (P2), `bin/backends/zellij.sh` (P3), `bin/backends/orca.sh` (P4), and `bin/backends/cmux.sh` (P5) are experimental task-spawn adapters. +[`configuration.md`](configuration.md#runtime-backend-configbackend--fm_backend) owns new-spawn backend selection precedence and authorization. Runtime auto-detection is innermost-first: `$TMUX` wins over `HERDR_ENV=1`, which wins over cmux's primary `CMUX_WORKSPACE_ID` marker and documented fallback signals; auto-detected herdr or cmux prints a one-time opt-out notice, auto-detected tmux stays silent, and zellij and orca are never auto-detected (only explicit selection). Unknown backend names fail loudly. For compatibility, default tmux tasks do not write `backend=tmux`; every reader treats a missing `backend=` field as `tmux`. -`fm-watch.sh` polls each window's backend for a busy state: tmux, zellij, orca, and cmux have no native primitive and always report unknown, so their pane-tail fallback matches only the recorded harness's verified signature; herdr's `agent.get` semantic state (working/idle/done/blocked) is consulted first for stale detection, with unknown native states using the same harness-scoped fallback. -This scope prevents cross-harness false positives such as Kimi's rotating idle tip `ctrl+c: cancel` borrowing Grok's busy token, and keeps Claude's broader elapsed-spinner shape from matching ordinary output in other panes. -Unknown supplied harnesses match no default signature, while callers that have no harness metadata retain the historical combined-pattern compatibility fallback. -That poll loop is the default event source for backends with no native push events, so this stays an extraction of the abstraction rather than a watcher rewrite. +`fm-watch.sh` decides each window's busy state through the semantic contract above rather than by polling the backend for rendered text. +Herdr's native `agent.get` verdict still participates, but only as evidence of activity: a native `busy` is accepted when the task has no record of its own, while a native `idle` is not, because `agent.get` reports generation state and reads idle while a worker blocks on its own long-running foreground tool call. +tmux, zellij, orca, and cmux expose no native busy primitive at all, so a task on those backends is classified purely from its adapter's own lifecycle record. +That poll loop is still the default event source for backends with no native push events, so this stays an extraction of the abstraction rather than a watcher rewrite. For capable Herdr sessions, the same watcher replaces its terminal sleep with a bounded native event wait that immediately surfaces `blocked`; [Push events and polling fallback](herdr-backend.md#push-events-and-polling-fallback) owns the current mechanism and capability gates, while [runtime backend verification](verification/runtime-backends.md#native-blocked-event) owns the active evidence. The deeper session-start agent-process liveness probe is separate from that busy-state poll: tmux and Herdr have verified classifiers for secondmate recovery, Zellij remains unverified, and Orca and cmux do not support secondmate spawns. Herdr is experimental and can be selected explicitly or by runtime auto-detection: Treehouse remains its worktree provider, [`herdr-backend.md`](herdr-backend.md) owns current setup and safety limits, and [`verification/runtime-backends.md`](verification/runtime-backends.md#herdr) owns active empirical evidence. -Herdr's durable default container shape is workspace-per-home plus tab-per-task: the primary home uses workspace label `firstmate`, secondmate homes use `2ndmate-<secondmate-id>`, and recovery/list-live scopes to the current `FM_HOME`'s workspace. +Herdr uses one tab per task; [Watching and task containers](herdr-backend.md#watching-and-task-containers) owns launcher-bound workspace placement, the label-only fallback, and recovery scope. Its optional default-off presentation projection may place one clean new task in a disposable workspace without changing endpoint authority or lifecycle ownership; [Optional presentation spaces](herdr-backend.md#optional-presentation-spaces) owns that conditional design and its narrow home-local restored-shell cleanup at locked session start. Zellij is experimental and selected only explicitly: Treehouse remains its worktree provider, [`zellij-backend.md`](zellij-backend.md) owns current setup and limits, and [`verification/runtime-backends.md`](verification/runtime-backends.md#zellij) owns active empirical evidence. Zellij's container shape is simpler than herdr's: one shared `firstmate` session, one tab per task, with no per-home workspace split; visible tab titles are scoped by the active home label plus a short hash of the resolved `FM_ROOT` path. @@ -156,7 +176,7 @@ That keeps spawn launch compatible across claude, codex, grok, pi, opencode, and `fm-home-seed.sh` provisions the isolated home, clones the listed PR-based projects into it, initializes newly cloned `no-mistakes` projects, copies the charter to `data/charter.md`, and `fm-spawn.sh --secondmate` launches it through the same session-provider and status-file path as any direct report. For a domain whose subject is the firstmate repo itself, a deliberate `--no-projects` seed creates a project-less home whose crews take pooled worktrees of that repo instead of separate clones. The signal cannot be mixed with project names or omitted accidentally, and a populated home cannot be converted in place; the full seed contract is in [configuration.md](configuration.md#secondmate-routes-datasecondmatesmd). -On the herdr backend, a secondmate launch lands in that secondmate home's labeled workspace, and crewmates spawned from that home land in the same workspace. +Herdr secondmate and child placement follows the launcher-binding contract in [Watching and task containers](herdr-backend.md#watching-and-task-containers). When seeded with `-`, the home is a durable treehouse lease under the secondmate id, so it survives with no live process and is not recycled by later `treehouse get` or pruning. Retirement or seed rollback returns the leased home; normal restart/recovery keeps it leased. If returning the lease fails during teardown, firstmate leaves the route and home intact instead of hiding a still-held lease. @@ -212,7 +232,7 @@ When a reply has a real visual artifact, `--image <path>` attaches one local PNG Actionable reversible requests run through firstmate's normal intake, backlog, dispatch, investigation, or ship lifecycle. Work that completes in the answering turn gets one outcome reply. Work that spawns a longer-running task gets an acknowledgement reply first; `bin/fm-x-link.sh` records `x_request=`, `x_request_ts=`, `x_followups=0`, and optional reply-platform context in that task's `state/<id>.meta`, while durable per-request context preserves the original platform and budget independently of task links and inbox cleanup. -Later milestone and completion wakes use `bin/fm-x-followup.sh` to post up to three public-safe follow-ups through the relay's `connector/followup` endpoint, ending with a `--final` one that always clears the link. +Later milestone wakes use `bin/fm-x-followup.sh` to post up to three public-safe follow-ups through the relay's `connector/followup` endpoint, ending with a `--final` one for ordinary X-linked work. A typed promised-final commitment owns its terminal reply through `bin/fm-public-followup.sh`; after its receipt is validated, `bin/fm-x-followup.sh --clear <task-id>` removes any legacy link without posting another reply. The [X mode configuration reference](configuration.md#x-mode-env) owns the exact context retention, platform-resolution, and fail-safe posting contract. If recovery relinks the same relay request onto a successor task, `fm-x-link.sh --carry-count <n> --carry-ts <epoch> --carry-platform <x|discord> --carry-max <n>` preserves the consumed follow-up count, original 7-day window, and reply split budget instead of granting a fresh local budget or falling back to the wrong platform. The follow-up helper forwards `--image <path>` to the same reply client when a follow-up needs an image. @@ -225,6 +245,18 @@ For preview testing, `FMX_DRY_RUN` makes `fm-x-reply.sh` and `fm-x-dismiss.sh` s Attached images are recorded as compact `{media_type, bytes, source_path}` metadata in dry-run instead of base64 bytes. X mode remains layered on top of the existing check mechanism without changing its request-handling behavior. +A promised *final* public reply is a stronger commitment than a milestone follow-up, because forgetting it is publicly visible. +It is therefore not carried in conversation memory at all: intake turns it into a typed `kind=public-followup` obligation owned by `tasks-axi public-followup`, and every later step reads that obligation from disk. +The mechanism boundary is deliberately narrow. +`tasks-axi` owns the obligation state machine and is the only thing that validates a terminal result's source home, work id, generation, schema, outcome, and deliverables. +`state/x-context/` remains the only owner of the private full request context. +`bin/fm-x-reply.sh` remains the only thing that posts. +`bin/fm-public-followup.sh` composes those three and adds nothing of its own beyond the activation gate, a private terminal-event inbox, and the idempotent delivery sequence. +Work routed to another home reports a *typed* terminal result through `bin/fm-public-followup-emit.sh`; firstmate never recovers the source home, work id, outcome, or deliverables by parsing a free-form `done:` sentence, and the child never learns the thread. +Because a terminal event's id is derived from its identity tuple rather than generated, duplicate reports and restart replay converge without coordination. +Reconciliation rides the existing relay poll and the session-start digest instead of a new watcher, daemon, or timer, and both are gated on the same `.env` activation contract so a home that never opted into the relay executes none of it. +The [X mode configuration reference](configuration.md#promised-public-replies-statepublic-followup) owns the operator-facing contract, and the `fmx-respond` skill owns the procedure. + ## Project memory belongs to projects Durable project-intrinsic agent knowledge lives in each project's committed `AGENTS.md`, with `CLAUDE.md` as a symlink. diff --git a/docs/calm-mode-feasibility.md b/docs/calm-mode-feasibility.md index c4a051b9cc..3893b60c76 100644 --- a/docs/calm-mode-feasibility.md +++ b/docs/calm-mode-feasibility.md @@ -5,13 +5,22 @@ This document owns the version-scoped feasibility evidence, Pi transcript taxono ## Required extension surface -A qualifying implementation must auto-load from the trusted project, persist the toggle choice for the effective Firstmate home across Pi session starts and resumes, keep Pi's built-in working activity visible, emit no Calm status row, redraw already-rendered controllable rows, remove supported hidden rows without gaps, restore ordinary rendering, and leave delivery, tool execution, model context, session storage, export and share operation, diagnostics, and expansion state unchanged. -The governing presentation policy allows genuine original user prompts, genuine user-facing assistant text, and Pi's native working activity. +A qualifying implementation must auto-load from the trusted project, persist the toggle choice for the effective Firstmate home across Pi session starts and resumes, keep working activity visible, emit no Calm status row, redraw already-rendered controllable rows, remove supported hidden rows without gaps, restore ordinary rendering, and leave delivery, tool execution, model context, session storage, export and share operation, diagnostics, and expansion state unchanged. +The governing presentation policy allows genuine original user prompts, genuine user-facing assistant text, and working activity. +Working activity may be presented through Pi's stock row or through a supported Calm-owned widget, but Calm must leave the stock row untouched whenever Calm is off. Changing persisted context to remove hidden content, filtering provider context, patching installed harness code, or claiming coverage outside a supported renderer does not satisfy that boundary. +## Compatibility evidence + +[`calm.md`](calm.md#pi-compatibility) owns the current Pi compatibility contract. +Pi 0.81.1 was installed when Calm was first built, and Pi 0.82.0 was the later reverification target. +The inspected Pi CHANGELOG shows no relevant presentation API introduced at either version, so those versions remain verification evidence rather than compatibility bounds. +The exported classes used by the adapters (`AssistantMessageComponent` and `InteractiveMode`) are undocumented internals with no stated version guarantee. +`tests/fm-calm-pi-extension.test.sh` records the installed Pi version as evidence without gating on it and covers both newer synthetic versions and an unavailable adapter seam. + ## Pi 0.81.1 end-to-end reproduction -The current installed and regression-supported Pi version was verified on 2026-07-22. +The Pi version installed at the time was verified on 2026-07-22. ```text $ pi --version @@ -57,7 +66,8 @@ The single-thinking, tool-call-only, tool-result, Calm-off, and `clearOnShrink` PR 927 made Calm persistent and described controlled rows as gapless while retaining a documented unsupported boundary for collapsed-thinking spacing. PR 936 removed the unsafe operational-input reroute and preserved legacy zero-height entries but did not change assistant-message layout. -The fix installs one idempotent Pi 0.81.1 through 0.82.0 presentation adapter on the exported `AssistantMessageComponent.updateContent` method. +The fix installs one idempotent presentation adapter, verified on Pi 0.81.1 through 0.82.0, on the exported `AssistantMessageComponent.updateContent` method. +The adapter probes for that exact method and, per the [compatibility contract](calm.md#pi-compatibility), degrades independently with a diagnostic rather than gating on a version number. Only while Calm is active and Pi has collapsed thinking does the adapter pass a shallow thinking-free presentation copy into Pi's ordinary layout calculation, then retain the original message on the component for invalidation and thinking expansion. The persisted assistant message, provider context, tool execution, export data, and expansion history remain unchanged. Collapsed thinking-only assistant messages now render zero rows, thinking before visible assistant text adds no spacing beyond the text-only baseline, and expanding thinking still renders the original reasoning. @@ -114,7 +124,8 @@ The real Pi viewport moved the unchanged assistant text from row 7 to row 2, ren The leading cause would have been falsified if the row or height remained, the provider lost or duplicated the message, or the persisted role or bytes changed. None occurred. -The fix installs a separate idempotent Pi 0.81.1 through 0.82.0 presentation adapter on the exported `InteractiveMode.addMessageToChat` method. +The fix installs a separate idempotent presentation adapter, verified on Pi 0.81.1 through 0.82.0, on the exported `InteractiveMode.addMessageToChat` method. +The adapter probes for that exact method and, per the [compatibility contract](calm.md#pi-compatibility), degrades independently with a diagnostic rather than gating on a version number. It delegates current recognition to `bin/fm-operational-input.sh`, adds only the evidence-backed bare-U+2063 `Supervisor escalate (` presentation compatibility shape, mounts a `UserMessageComponent` subclass that preserves Pi's stock row plus leading spacer while Calm is off, and returns zero rendered lines while Calm is on. It never intercepts the input event, rewrites the message, changes its role, filters model context, or changes session data. Messages containing an image are left on Pi's ordinary path even when their text equals an operational envelope because Firstmate's authoritative producers are text-only. @@ -124,6 +135,35 @@ An adjacent two-notification run retained the same two-row neighboring-assistant Calm off, an absent Calm preference, and an absent Calm extension retained ordinary rows. The current exact marker and the narrow bare-U+2063 `Supervisor escalate (` compatibility shape hid under Calm, while quoted markers, ASCII `FIRSTMATE_OP:` without U+2063, ordinary text before the current marker, unrelated text after U+2063, and image-bearing input remained visible. +## Calm working presentation + +Calm replaces Pi's stock working row with a small animated boat while Calm is on and one logical agent run is active. +This path uses only public extension API and patches nothing: `ExtensionUIContext.setWorkingVisible(false)` hides the stock row, and `setWidget()` installs a temporary component factory above the editor. +Pi's documented custom working-indicator frames are static and width-blind, so they cannot own responsive geometry; a widget component receives `render(width)` and can. + +`.pi/extensions/fm-calm.ts` remains the sole owner of the presentation choice and the only caller of `setWorkingVisible()`, while `.pi/extensions/lib/fm-calm-working-ship.ts` owns the sprite geometry, the bounce track, and the widget. +Visibility follows `agent_start` through `agent_settled` rather than turns or tool calls. +Pi emits `agent_settled` from a `finally` block once a run will not continue automatically, so retries, automatic continuations, queued follow-ups, and compaction inside one run never remove the boat, while settle, abort, and failure all reach the same cleanup. +Repeated `agent_start` events inside one run are idempotent, and Pi disposes the previous component before installing a replacement under the same key and when it clears extension widgets, so the frame timer cannot duplicate or outlive the widget. +Pi's above-editor widget container reserves one spacer row whether or not a widget is present, so removing the boat leaves no residual blank row. + +The sprite is two rows when the usable width admits the complete hull: a two-cell mainsail centered over a symmetric `\__/` hull that replaces water on its row rather than adding a third row. +The sail is directional because a mainsail extends aft of the mast, so it renders `<|` while travelling right and `|>` while travelling left. +Direction reverses the moment the boat lands on an endpoint, so the endpoint frame itself already shows the new heading and no frame at or after a bounce shows the previous sail. +The water row fills the complete supplied width, the track is recomputed and clamped from that width on every frame so a resize cannot wrap or strand the boat offscreen, and widths too narrow for the hull fall back to a deterministic single row. + +One scheduler drives two logically independent clocks. +Every tick advances a bounded fixed-cell water phase, and only every fourth tick moves the boat, so at a 220ms tick the water ripples several times between boat steps and the boat travels one column every 880ms. +Ticks rather than wall-clock timestamps drive every state change, so tests seek animation time exactly, and disposing the widget stops both clocks together. +Water phases are single-column ASCII, so advancing them never changes visible width, adds a row, or moves the hull column. + +Colors are standard ANSI foreground codes rather than theme lookups: blue for every water cell and yellow for the complete boat, with no bright variant, 256-color, or RGB escape. +Each colored run is closed with a default-foreground reset so styling cannot bleed into the sail row's padding, neighbouring UI, or a later frame, and geometry is always computed from visible cells rather than escape bytes. + +The presentation is TUI-only and visual-only. +It adds no session entry, transcript row, model context, or export or share content, and its widget takes no keyboard input, so editor focus and Escape abort are unchanged. +Compaction and retry loaders remain stock because Pi exposes no supported replacement for them. + ## Central visibility and input policy `.pi/extensions/lib/fm-calm-visibility.ts` owns only the allowlist-style transcript presentation policy. @@ -148,7 +188,7 @@ Serialized session data and Pi 0.81.1's sidebar tree also retain legacy hidden o The taxonomy was derived from Pi 0.81.1's installed public declarations, documentation, examples, `interactive-mode.js`, and its exported component implementations. The test fixture enumerates every class below through the centralized policy, and the interactive fixture exercises the screenshot classes, current user-role operational input, and legacy synthetic presentation entries. -| Policy class | Pi transcript path | Calm result on Pi 0.81.1 through 0.82.0 | +| Policy class | Pi transcript path | Calm result (verified on Pi 0.81.1 through 0.82.0) | | --- | --- | --- | | `genuine-user-prompt` | `UserMessageComponent` | Visible, including every tested operational near miss. | | `genuine-agent-response` | Assistant text in `AssistantMessageComponent` | Visible. | @@ -162,17 +202,17 @@ The test fixture enumerates every class below through the centralized policy, an | `custom-entry` | `CustomEntryComponent` with a registered renderer | Legacy Calm presentation entries rebuild to zero children without a residual spacer and restore through ordinary expansion redraw when mounted; arbitrary extension entries remain an unsupported boundary. | | `compaction-summary` | `CompactionSummaryMessageComponent` | Unsupported boundary; remains visible. | | `branch-summary` | `BranchSummaryMessageComponent` | Unsupported boundary; remains visible. | -| `working-status` | `WorkingStatusIndicator` | Visible through Pi's unchanged built-in row while Calm is active. | +| `working-status` | `WorkingStatusIndicator`, or the Calm working-ship widget while Calm is active | Always visible. Calm off leaves Pi's stock row untouched; Calm on hides that row for the duration of one logical agent run and renders the working ship instead. | | `command-status` | Interactive command result and status rows | Calm emits no enable notice, but generic Pi command rows remain an unsupported boundary. | | `system-notice` | `showStatus`, `showError`, compaction, retry, and startup warning rows | Unsupported boundary; remains visible. | | `cache-notice` | Non-persisted cache-miss `Text` row | Unsupported boundary; remains visible. | | `project-trust-warning` | Non-persisted startup `Text` row | Unsupported boundary; remains visible. | -| `synthetic-user` | Firstmate extension `sendUserMessage`, terminal-injected input, Firstmate-generated Pi positional brief, or the already non-displayed session-start nudge | Canonically classified text-only operational user messages stay ordinary semantic user messages but render through the zero-height Pi 0.81.1 through 0.82.0 adapter under Calm; legacy entries stay gaplessly controllable, and the session-start nudge retains its existing non-displayed custom-message path. | +| `synthetic-user` | Firstmate extension `sendUserMessage`, terminal-injected input, Firstmate-generated Pi positional brief, or the already non-displayed session-start nudge | Canonically classified text-only operational user messages stay ordinary semantic user messages but render through the zero-height adapter (verified on Pi 0.81.1 through 0.82.0) under Calm; legacy entries stay gaplessly controllable, and the session-start nudge retains its existing non-displayed custom-message path. | | `synthetic-assistant` | No authoritative Firstmate source found | Policy-hidden, but Pi exposes no generic assistant-role renderer. | | `unknown` | Future or unclassified transcript component | Policy-hidden, but no generic renderer exists; never claimed as covered. | The installed extension API has no supported global transcript filter, user-message renderer, assistant-message renderer, chat-container API, or generic custom-tool wrapper. -Pi 0.81.1 through 0.82.0 export `AssistantMessageComponent` and `InteractiveMode`, so Calm uses separate version-bounded, idempotent adapters for assistant thinking layout and the complete operational-user transcript row while leaving all message data and non-Calm rendering unchanged. +Pi 0.81.1 through 0.82.0 export `AssistantMessageComponent` and `InteractiveMode`, so Calm uses separate idempotent, API-probed adapters for assistant thinking layout and the complete operational-user transcript row while leaving all message data and non-Calm rendering unchanged; see the [compatibility contract](calm.md#pi-compatibility) for how a future Pi lacking one of those exports is handled. General component replacement, ANSI cursor erasure, provider-context mutation, and installed-file patching remain rejected as unsupported or preservation-breaking workarounds. ## Cross-harness verification record @@ -197,7 +237,7 @@ grok 0.2.106 (bde89716f679) | Claude Code 2.1.218 | Not feasible through the inspected supported project surface. | Project hooks can observe lifecycle and tool events, while the plugin CLI packages supported components; neither inspected surface exposes a transcript-row renderer or transcript-wide redraw API. | | Codex CLI 0.144.6 | Not feasible through the inspected supported project surface. | The tracked hooks expose session, pre-tool, and stop handling, while the plugin and feature inventories expose no TUI tool-row renderer or transcript redraw control. | | OpenCode 1.17.18 | Not feasible without violating the preservation boundary. | Plugins expose events and tool execution hooks, not a built-in transcript-row renderer; same-name tool replacement changes execution rather than presentation alone. | -| Pi 0.81.1 through 0.82.0 | Partially feasible with two version-bounded exported-class adapters. | Public APIs control working visibility, collapsed labels, known tool slots, custom entries, and expansion redraws; exported assistant and interactive-mode classes provide the version-pinned collapsed-thinking and operational-user layout boundaries, while generic user, tool, and status filtering remains unavailable. | +| Pi (verified 0.81.1 through 0.82.0) | Partially feasible with two API-probed exported-class adapters. | Public APIs control working visibility, collapsed labels, known tool slots, custom entries, and expansion redraws; exported assistant and interactive-mode classes provide the collapsed-thinking and operational-user layout boundaries, gated on the exact method's presence rather than a version number, while generic user, tool, and status filtering remains unavailable. | | Grok CLI 0.2.106 | Not feasible through the inspected supported project surface. | Project hooks expose lifecycle and tool interception, while the plugin CLI exposes no row-renderer contract; `--minimal` changes the whole screen mode rather than selected transcript rows. | These conclusions are deliberately limited to the named versions and supported surfaces. @@ -209,12 +249,12 @@ Only Pi's Calm presentation implementation changed; every producer and non-Pi tr ## Regression coverage `tests/fm-calm-pi-extension.test.sh` compares wrapped and stock renderers, verifies all seven built-ins plus `fm_watch_arm_pi`, exercises redraw of already-rendered tool, thinking, current operational-user, and legacy synthetic rows, and covers every policy class. -It covers persisted preference restoration across every session-start reason and a real restart, proves Pi's native `Working...` row through a delayed deterministic provider, asserts no Calm status row, verifies operational messages remain exact ordinary user-role session entries and complete exports, and drives genuine 100 by 44, 160 by 36, and 180 by 44 terminal fixtures. +It covers persisted preference restoration across every session-start reason and a real restart, proves the working-ship presentation and Calm-off stock `Working...` row through a delayed deterministic provider, asserts no Calm status row, verifies operational messages remain exact ordinary user-role session entries and complete exports, and drives genuine 100 by 44, 160 by 36, and 180 by 44 terminal fixtures. A native deterministic `/skill:ahoy` turn produces thinking, tool-call, and tool-result blocks, asserts that the collapsed skill-to-final gap equals the two-row visible-only baseline, expands and re-collapses original thinking, restores Calm-off rendering, verifies persisted hidden history, and repeats the geometry assertion after restart with `terminal.clearOnShrink` explicitly off. The operational provider path covers Calm loaded on, loaded off, default preference, extension absent, exact watcher delivery, narrow bare-marker legacy input, persisted restart replay, a genuine captain prompt, and adjacent notifications coalesced into one intended processing turn. It asserts one persisted and rendered captain answer, exact user-role operational envelopes in order, no replacement custom messages, one processing result, zero operational transcript rows, and the two-row neighboring-assistant geometry for live, adjacent, and restart paths. Quoted current markers, ASCII-only labels, ordinary text before a marker, unrelated U+2063 placement, and image-bearing input remain visible in component and native transcript checks. -`tests/fm-pi-primary-live-e2e.test.sh` also proves the unchanged built-in `Working...` row while Calm is active on the credentialed provider path before continuing its ordinary watcher lifecycle. +`tests/fm-pi-primary-live-e2e.test.sh` also proves the working ship replaces the built-in `Working...` row while Calm is active on the credentialed provider path, and that it clears when the run settles, before continuing its ordinary watcher lifecycle. `tests/fm-pi-primary-types.test.sh` performs strict no-emit TypeScript checking against the installed Pi declarations, currently package version 0.81.1. The relevant commands are: @@ -259,8 +299,8 @@ skip: set FM_PI_LIVE_E2E=1 to run the isolated interactive Pi regression ## 2026-07-26 Pi 0.82.0 compatibility verification -Pi 0.82.0 preserved both version-bounded presentation seams and every deterministic Calm TUI guarantee. -The globally installed declaration package remained 0.81.1, so the strict typecheck continued to cover that lower supported boundary while the real CLI exercised 0.82.0. +Pi 0.82.0 preserved both API-probed presentation seams and every deterministic Calm TUI guarantee. +The globally installed declaration package remained 0.81.1, so the strict typecheck continued to cover that earlier declaration-evidence version while the real CLI exercised 0.82.0. ```text $ pi --version @@ -277,3 +317,110 @@ ok - Pi calm native E2E keeps Working and captain turns visible, hides exact ope $ tests/fm-pi-primary-types.test.sh ok - tracked Pi extensions pass strict no-emit typecheck against Pi 0.81.1 ``` + +## 2026-07-30 Calm working-presentation verification (superseded) + +This record captures the first working-presentation implementation and is retained as pipeline history. +Its same-orientation sail, theme-derived colors, and single-cadence motion were all replaced later the same day; the revision record at the end of this document owns current behavior. + +The working ship was verified against the installed Pi 0.82.0 CLI with a deterministic in-process provider and no credentials. +The globally installed declaration package remained 0.81.1, so the strict typecheck continued to cover that declaration-evidence version while the real CLI exercised 0.82.0. +The real-TUI regression captures two frames at different hull columns, resizes the same running TUI, asserts the reflowed water row equals the new width on a single wave row, types into the editor while the animation runs, aborts with Escape, and then proves Pi's stock `Working...` row returns with Calm off. + +```text +$ pi --version +0.82.0 + +$ tests/fm-calm-pi-extension.test.sh +ok - Pi calm resolves its persistent home independently of Pi's launch directory +ok - Pi calm compatibility evidence never rejects a Pi version for being newer than 0.82.0, and still fails closed on a missing or malformed version +ok - a missing collapsed-thinking presentation API degrades only that Calm adapter with a clear skip reason, while the rest of Calm still registers +ok - missing Pi presentation class exports reach the independent adapter degradation path +ok - Pi calm centralizes transcript visibility, preserves execution/export data, keeps Pi's stock working row visible while no run is active, and persists its choice across session starts +ok - Pi operational follow-up E2E processes exact user-role notifications once while Calm hides current and adjacent rows, Calm off and absent render them, and restart preserves semantics +ok - Pi Calm native /skill:ahoy geometry keeps every collapsed thinking and tool block at zero height while preserving expansion, history, restart, and Calm-off rendering +ok - Pi Calm working ship renders an exact two-row full-width sprite, clamps every resize, bounces at both edges, falls back deterministically when narrow, and installs and removes one timer-owning widget across starts, settle, abort, failure, shutdown, reload, replacement, and Calm toggles +ok - Pi calm native E2E replaces the stock working row with a moving, resize-clamped working ship that clears on abort, keeps captain turns visible, hides exact operational user rows without changing persistence, restores stock rendering Calm-off, survives restart, and preserves export plus Ctrl+O behavior + +$ tests/fm-pi-primary-types.test.sh +ok - tracked Pi extensions pass strict no-emit typecheck against Pi 0.81.1 + +$ bin/fm-lint.sh +fm-lint.sh: ShellCheck 0.11.0 (pinned 0.11.0) + +$ bin/fm-doc-audience-check.sh +fm-doc-audience-check: ok surfaces=57 local_links=160 + +$ bin/fm-test-run.sh --changed --base origin/main +FM_TEST_SUMMARY total=32 failed=0 skipped_gate=7 duration_ms=196009 +FM_TEST_SUMMARY_FAMILY family=live-harness-optin count=7 duration_ms=202 failed=0 +FM_TEST_SUMMARY_FAMILY family=pure-contract-unit count=25 duration_ms=194670 failed=0 +``` + +One rendered frame at 120 columns, with Pi's stock working row hidden and the boat directly above the editor: + +```text + |> +\__/~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +``` + +The same run after resizing that TUI to 64 columns, showing the waves refilled to the new width on one row with the boat still on screen: + +```text + |> +~~~~~~~~~~~~~~~~~~~~~~~~\__/~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +``` + +Colors at that time were confirmed from an escape-preserving capture as theme-derived entries; the revision below replaced them with standard ANSI blue and yellow. +Pressing Escape during a run left `Operation aborted` with no boat and no residual blank row, and toggling Calm off restored Pi's stock `⠴ Working...` row on the next run. + +## 2026-07-30 Calm working-presentation revision verification + +The revision replaced the single-cadence, theme-colored, same-orientation sprite with a slower boat over independently animated water, standard ANSI colors, and a directional mainsail. +It was verified against the installed Pi 0.82.0 CLI with a deterministic in-process provider and no credentials. + +```text +$ pi --version +0.82.0 + +$ tests/fm-pi-primary-types.test.sh +ok - tracked Pi extensions pass strict no-emit typecheck against Pi 0.81.1 + +$ bin/fm-lint.sh +fm-lint.sh: ShellCheck 0.11.0 (pinned 0.11.0) + +$ bin/fm-doc-audience-check.sh +fm-doc-audience-check: ok surfaces=57 local_links=163 + +$ bin/fm-test-run.sh --changed --base origin/main +FM_TEST_SUMMARY total=32 failed=0 skipped_gate=7 duration_ms=386738 +FM_TEST_SUMMARY_FAMILY family=live-harness-optin count=7 duration_ms=257 failed=0 +FM_TEST_SUMMARY_FAMILY family=pure-contract-unit count=25 duration_ms=383010 failed=0 +``` + +Real Pi TUI observations from the isolated deterministic trial at 100 columns. +The hull column held steady across consecutive samples while the water pattern shifted, then advanced about one column every 880ms, which separates the two cadences: + +```text +hull_col=12 water=~-~~~-~~~-~\__/~~-~~~-~~~-~~~-~~~-~~~-~~~-~~~-~~~-~~~-~~ +hull_col=12 water=~~~-~~~-~~~\__/-~~~-~~~-~~~-~~~-~~~-~~~-~~~-~~~-~~~-~~~- +hull_col=13 water=~~-~~~-~~~-~\__/~~-~~~-~~~-~~~-~~~-~~~-~~~-~~~-~~~-~~~-~ +hull_col=16 (about 2.6s later) +``` + +An escape-preserving capture confirmed standard ANSI foreground codes only, blue water and yellow boat, with a default-foreground reset closing each run: + +```text +^[[34m~~~-~~~-~~~-~~~^[[33m\__/^[[34m-~~~-~~~-~~~-~~~-... +^[[33m<|^[[39m +``` + +Resizing the same running TUI to 12 columns shortened the track enough to observe both reversals, each already showing the heading it was about to travel: + +```text +left-heading : |> over ~-~~~-~~\__/ +right-heading: <| over \__/~~-~~~-~ +``` + +At 3 columns the sprite fell back to a single exact-width row, `<|~`. +Escape aborted the run leaving `Operation aborted`, no boat, and no stale sprite rows, and the trial exited 0 after deleting its temporary state. diff --git a/docs/calm.md b/docs/calm.md index 6a2c1d14b9..1018b818b9 100644 --- a/docs/calm.md +++ b/docs/calm.md @@ -3,7 +3,16 @@ Calm is a Pi-only conversation presentation toggle. It is off by default, and the last `/calm` choice persists for the effective Firstmate home across Pi session starts and resumes. -While Calm is active, Pi's built-in `Working...` activity remains visible and no separate Calm status row is added. +While Calm is active and an agent run is under way, Calm hides Pi's built-in `Working...` row and shows a small two-row animated boat in its place, and no separate Calm status row is added. +The water fills the usable width in standard ANSI blue and the complete boat is standard ANSI yellow. +The boat is deliberately calm: it moves one column every 880ms, while the water ripples on its own faster cadence so the surface stays alive between boat steps. +Its mainsail is directional, showing `<|` while travelling right and `|>` while travelling left, and it flips on the exact frame the boat turns at either edge. +Every resize reflows the sprite without wrapping, and it disappears when the run settles, aborts, or fails. +Within one Pi session and Calm extension lifetime, the next working period resumes the boat from its last rendered column and travel direction rather than restarting at the left edge. +Hidden elapsed time does not advance the animation, and a resize while hidden clamps the frozen boat to the new width without changing its valid travel direction. +A fresh Pi session or new Calm extension lifetime starts at the normal initial position. +Very narrow terminals fall back to a smaller deterministic sprite. +While Calm is off, Pi's stock working row is left exactly as Pi renders it. Calm hides collapsed thinking labels, the shells for Pi's seven built-in tools, the `fm_watch_arm_pi` tool shell, and canonically classified Firstmate operational user rows. The operational inputs remain ordinary user-role messages, while Pi's transcript layout renders their complete rows at zero height. The session-start nudge remains on its existing non-displayed custom-message path. @@ -18,9 +27,15 @@ Pi's supported presentation API does not expose a global transcript filter. Expanded reasoning and its reserved spacing, built-in tool images, user-bash rows, skill and summary rows, generic status notices, and arbitrary custom-tool or extension rows remain visible. These are supported-API boundaries rather than hidden-content failures. +## Pi compatibility + +Calm has no numeric Pi version minimum or maximum and never refuses Pi solely because its version is newer than a previously verified version. +The collapsed-thinking and operational-user-row presentation adapters probe the exact Pi API seam they patch when Calm loads. +If Pi removes one of those seams, Calm logs a diagnostic naming the unavailable adapter and skips only that adapter; `/calm`, the other adapter, and unrelated Pi extensions remain available. + [`calm-mode-feasibility.md`](calm-mode-feasibility.md) owns the version-scoped renderer taxonomy and empirical evidence. [`configuration.md`](configuration.md#pi-calm-preference-configcalm) owns the persisted preference file and resolution rules. -`.pi/extensions/lib/fm-calm-visibility.ts` owns the visibility policy, and `.pi/extensions/lib/fm-calm-operational-user-layout.ts` owns the zero-height operational-user row adapter. +`.pi/extensions/lib/fm-calm-visibility.ts` owns the visibility policy, `.pi/extensions/lib/fm-calm-operational-user-layout.ts` owns the zero-height operational-user row adapter, and `.pi/extensions/lib/fm-calm-working-ship.ts` owns the animated working presentation. Regression entry points: diff --git a/docs/cmux-backend.md b/docs/cmux-backend.md index 4bd438bc74..ac39d630fc 100644 --- a/docs/cmux-backend.md +++ b/docs/cmux-backend.md @@ -94,7 +94,8 @@ Literal send and Enter are separate calls. Enter, Escape, and Ctrl-C are supported. The composer verifier locates the last bordered composer row and delegates the content decision to `bin/fm-composer-lib.sh`. A bare shell prompt is `unknown`, and a slash-popup placeholder remains `pending`, so only Enter is retried and text is never retyped. -cmux exposes no native generic agent busy signal, so supervision uses the shared capture/hash and busy-regex path. +cmux exposes no native generic agent busy signal, so supervision uses capture/hash polling for screen changes and each harness adapter's semantic lifecycle for worker state. +Grok alone retains its isolated rendered-tail fallback. A task workspace's last surface cannot be closed directly. Cleanup owns the whole workspace and uses `close-workspace`. diff --git a/docs/configuration.md b/docs/configuration.md index 7f23866c33..7b90f8ec15 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -12,7 +12,7 @@ This section is the single owner of the top-level operational-home layout; produ The tracked code root contains the shared instruction, skill, documentation, workflow, and `bin/` surfaces, while each effective `FM_HOME` contains private operational directories. `data/` holds durable private fleet records such as the project and secondmate registries, captain preferences, optional shared captain preferences, learnings, backlog, briefs, and scout reports. `state/` holds volatile runtime records such as task metadata, append-only status events, endpoint signals, watcher and wake-queue coordination, away-mode state, generated X-mode artifacts, private secondmate config-reread generations with their retry and quarantine state, and parent-owned secondmate pending-reply records under `state/pending-replies/` (`bin/fm-pending-reply-lib.sh`). -`config/` holds local gitignored operating choices, and `projects/` holds the local project clones that Firstmate reads but changes only through the guarded exceptions in `AGENTS.md`. +`config/` holds local gitignored operating choices, and `projects/` holds the local project clones that Firstmate reads but changes only through the narrow guarded and concrete captain-approved exceptions in `AGENTS.md`. `bin/fm-spawn.sh` owns the base task-metadata fields it emits, while the runtime-backend section below owns backend-specific fields and selector interpretation. The producing PR and X helpers own the fields they append, `bin/fm-classify-lib.sh` owns status-event vocabulary, and `bin/fm-crew-state.sh` owns current-state reconciliation. @@ -51,7 +51,7 @@ The file format is unchanged in both modes; tasks-axi and manual edits produce t For spawn-capable adapters, the runtime session-provider backend controls where task windows/endpoints are created, captured, sent to, watched, and killed. `tmux` is the verified reference backend (see [`docs/tmux-backend.md`](tmux-backend.md)); `herdr`, `zellij`, `orca`, and `cmux` are experimental spawn backends (see [`docs/herdr-backend.md`](herdr-backend.md), [`docs/zellij-backend.md`](zellij-backend.md), [`docs/orca-backend.md`](orca-backend.md), and [`docs/cmux-backend.md`](cmux-backend.md)). Treehouse remains the worktree provider for tmux, herdr, zellij, and cmux, since herdr, zellij, and cmux are session providers only; Orca provides both the task worktree and terminal endpoint. -New spawns choose the backend in this order: an explicit `--backend` flag firstmate passes when it spawns a task, then `FM_BACKEND`, then the first non-empty line of local gitignored `config/backend`, then runtime auto-detection from `$TMUX`, `HERDR_ENV=1`, or cmux runtime signals, then default `tmux`. +New spawns choose the backend in this order: an explicit `--backend` flag that current authority for that exact task alone has authorized (a present captain instruction or the task's own accepted brief; never later-task precedent by analogy), then `FM_BACKEND`, then the first non-empty line of local gitignored `config/backend`, then runtime auto-detection from `$TMUX`, `HERDR_ENV=1`, or cmux runtime signals, then default `tmux`. If more than one runtime marker is present, detection resolves innermost-first: `$TMUX` is checked before `HERDR_ENV=1`, which is checked before cmux's primary `CMUX_WORKSPACE_ID` marker and its documented fallback signals - tmux or herdr started from inside a cmux terminal is the innermost, currently-executing layer, while cmux itself (a terminal application, not a nestable multiplexer) is always checked last. See [`docs/cmux-backend.md`](cmux-backend.md#runtime-detection) for why cmux can be selected when `CMUX_WORKSPACE_ID` is absent. Auto-detected herdr or cmux prints a stderr notice naming `config/backend` and `--backend tmux` as opt-outs; auto-detected tmux stays silent to preserve existing default behavior. @@ -67,6 +67,7 @@ A zellij spawn additionally version-gates against the installed `zellij` binary' A cmux spawn additionally version-gates against the installed `cmux` binary's version, requires `jq`, and requires the control socket to be reachable and accessible (see [`docs/cmux-backend.md`](cmux-backend.md) "Setup" for the one-time socket-access configuration this needs; Automation mode is the recommended socket control mode, with Password mode supported via `config/cmux-socket-password`), refusing loudly and non-retryably on a `cmuxOnly`/unauthenticated socket. A backend spawn refusal from a missing dependency, version gate, or unauthenticated socket is terminal for that selected backend; firstmate surfaces it as a blocker instead of silently retrying another backend. Task meta records `backend=` only for a non-default backend; an absent `backend=` means `tmux`, preserving existing default-path meta files. +Every new task records `endpoint_task_id=` as the cleanup binding between the metadata filename and its opaque runtime endpoint. A herdr task additionally records `herdr_session=`, `herdr_workspace_id=`, `herdr_tab_id=`, and `herdr_pane_id=`. A zellij task additionally records `zellij_session=`, `zellij_tab_id=`, and `zellij_pane_id=`. An Orca task additionally records `orca_worktree_id=` and `terminal=`, with `window=fm-<id>` kept as the shared firstmate alias. @@ -77,9 +78,11 @@ Otherwise an exact task id matching `state/<id>.meta` wins before the legacy `fm A metadata-routed selector returns the recorded backend target (`terminal=` for Orca, otherwise `window=`), and matching explicit targets can still recover the recorded backend when metadata contains the same endpoint. Only metadata-routed task selectors carry secondmate-marker and Codex-harness context; explicit endpoint escape hatches do not. These five sentences are the single owner of the task-selector vocabulary; backend guides and other documents point here instead of restating the resolution order. -`fm-teardown.sh <id>` takes a task id directly and uses the same recorded backend target fields after loading `state/<id>.meta`. -By default, Herdr workspaces are derived from `FM_HOME`: the primary home uses `firstmate`, and a secondmate home marked by `.fm-secondmate-home` uses `2ndmate-<secondmate-id>`. -The default-container spawn, list-live, and recovery paths read that label from the active home, so a secondmate's own crewmates stay inside that secondmate home's herdr space. +`fm-teardown.sh <id>` takes a task id directly and validates the complete metadata-only endpoint identity before any runtime dispatch or cleanup mutation. +Missing, empty, duplicate, malformed, backend-inconsistent, or task-mismatched endpoint records are preserved and refused. +Legacy tmux metadata remains cleanup-compatible when its exact window name is `fm-<id>`; opaque non-tmux endpoints require their recorded `endpoint_task_id=` binding. +`FM_HOME` determines Herdr's home label: the primary home uses `firstmate`, and a secondmate home marked by `.fm-secondmate-home` uses `2ndmate-<secondmate-id>`. +[`herdr-backend.md`](herdr-backend.md#watching-and-task-containers) owns launcher-bound workspace placement, the label-only fallback, collision handling, and recovery behavior. The optional local `config/herdr-presentation-spaces` presence flag instead enables Herdr's default-off disposable single-task visual projection; [Optional presentation spaces](herdr-backend.md#optional-presentation-spaces) owns its behavior, safety limits, recovery contract, and narrow locked session-start cleanup of exact restored idle-shell children. The flag is default-off and inherited into secondmate homes under the primary-authoritative contract owned by [`secondmate-provisioning`](../.agents/skills/secondmate-provisioning/SKILL.md). For normal herdr operations, `HERDR_SESSION` selects the named session, but destructive test cleanup must not rely on `HERDR_SESSION` alone. @@ -90,7 +93,7 @@ Use the guarded cleanup path described in [`docs/zellij-backend.md`](zellij-back cmux has no session layer at all - one workspace per task, in whatever cmux window is open - and its socket password (when configured) is read from local, gitignored `config/cmux-socket-password` under the effective config directory, never committed. The caller-facing label remains `fm-<id>`, but the actual cmux workspace title is scoped by the active `FM_HOME` readable label plus a short hash of the resolved `FM_ROOT` path as `fm-<home-label>-<id>`. Test cleanup must use the guarded path in [`docs/cmux-backend.md`](cmux-backend.md#current-operation-and-safety), never enumerate-and-close every workspace. -The `config/backend` file is not inherited by secondmate homes. +`config/backend` is inherited into secondmate homes under the primary-authoritative contract owned by [`secondmate-provisioning`](../.agents/skills/secondmate-provisioning/SKILL.md). ## Away-mode supervisor backend (FM_SUPERVISOR_BACKEND / FM_SUPERVISOR_TARGET) @@ -134,11 +137,25 @@ Fleet-local operational facts and gotchas live locally in `data/learnings.md`; i The file is created lazily on first learning and follows the same dated, evidence-backed, curated style as `data/captain.md`: inspect the current file first, then rewrite or prune stale entries instead of appending forever. There is no shared learnings file by captain decision. +## Startup memory budget (config/startup-memory-budget) + +`config/startup-memory-budget` is the primary-authoritative per-home allowance for the startup prompt-memory surface: `data/captain.md`, `data/captain-shared.md`, and `data/learnings.md` together. +The locked mutable bootstrap path materializes its visible default of `7500` estimated tokens in a primary home when the file is absent. +To select another allowance, replace the primary home's file with one valid positive value in the exact format below; the next locked bootstrap convergence or `bin/fm-config-push.sh` propagates it to registered secondmates. +A secondmate does not create an independent default and instead receives the primary value through the inherited-local-material contract in [`secondmate-provisioning`](../.agents/skills/secondmate-provisioning/SKILL.md). +The file must be one positive base-10 integer followed by exactly one newline in a regular, single-linked file beneath a non-symlinked `config/` directory. +Malformed, multi-line, symlinked, hardlinked, special, or otherwise unsafe values are rejected rather than treated as a default. +Use `bin/fm-startup-memory-budget.sh read` to validate and print the effective value, or `bin/fm-startup-memory-budget.sh report` to account for the three files. +The stable local estimate is `ceil(UTF-8 bytes / 3)` per file, a conservative portable approximation rather than a provider-exact tokenizer. +An inherited `data/captain-shared.md` counts in a secondmate's total but remains primary-owned and read-only there. +The internal `/stow` skill curates only the editable local files in that case and reports the primary-owned shared file as a concrete exception if it alone exceeds the budget. +The helper's header owns exact parsing, publication, and report output mechanics. + ## Secondmate routes (data/secondmates.md) Persistent secondmate routes live locally in `data/secondmates.md`. The concise single-line route contract is owned by the [`secondmate-provisioning` skill](../.agents/skills/secondmate-provisioning/SKILL.md#routing-table), including the parser-compatible fields, one-sentence summary requirement, `home:` pointer to the seeded charter, and limit on extra registry prose. -`fm-home-seed.sh validate` refuses duplicate ids, duplicate homes, and nested or overlapping homes. +Use `fm-home-seed.sh validate` to check the complete operational registry contract documented by the command itself. The main first mate routes by reading those scopes with judgment; the project list is provisioning data, not exclusive ownership. Use `fm-home-seed.sh <id> - {<project>...|--no-projects}` to lease a fresh firstmate worktree for the secondmate home. Use the deliberate `--no-projects` signal only for a firstmate-repo domain that needs no separate project clones. @@ -166,6 +183,8 @@ When it is unset, most scripts use the repo root as the home; when it is set, sc When `FM_HOME` is unset, it also behaves as the old whole-root override. `bin/fm-send.sh` is intentionally stricter than that general fallback: it requires `FM_HOME` to be set before resolving a target, so operator steers cannot silently resolve against the wrong home. `FM_STATE_OVERRIDE`, `FM_DATA_OVERRIDE`, `FM_PROJECTS_OVERRIDE`, and `FM_CONFIG_OVERRIDE` override individual operational directories for tests and specialized harness setup. +Before `fm-brief.sh`, `fm-spawn.sh`, or `fm-afk-launch.sh` persists a path or passes it to another process, it resolves each applicable relative `FM_HOME`, `FM_STATE_OVERRIDE`, or `FM_DATA_OVERRIDE` directory against the caller's working directory, preserves absolute spellings unchanged, and rejects an unresolvable relative directory with the offending variable named. +Bootstrap applies the same relative `FM_HOME` resolution only when embedding that home in the generated X-mode poll shim; other transient consumers retain their existing shell-relative behavior. For the herdr backend, `FM_HOME` also determines the workspace label used by the adapter. For the zellij backend, `FM_HOME` does not split containers, but it determines the readable home prefix embedded in visible tab titles; use `FM_ZELLIJ_SESSION` when a separate zellij session is needed. The full zellij home label also includes a short hash of the resolved `FM_ROOT` path. @@ -174,9 +193,9 @@ The full cmux home label also includes a short hash of the resolved `FM_ROOT` pa ## Harness support -claude, codex, opencode, pi, pi-signed, grok, and kimi are empirically verified for crewmate and secondmate launches; [README requirements](../README.md#requirements) own the set supported for the primary session. +claude, codex, opencode, pi, pi-signed, grok, and kimi are empirically verified for crewmate and secondmate launches; agy is empirically verified for crewmate launches only (no secondmate support, experimental) - [README requirements](../README.md#requirements) own the set supported for the primary session. New harnesses get verified through a supervised trial task before joining the set. -The verified adapter knowledge - busy signatures, interrupt and exit commands, skill-invocation syntax, and per-harness quirks - lives in [`.agents/skills/harness-adapters/SKILL.md`](../.agents/skills/harness-adapters/SKILL.md). +The verified adapter knowledge - each harness's busy-state source, interrupt and exit commands, skill-invocation syntax, and per-harness quirks - lives in [`.agents/skills/harness-adapters/SKILL.md`](../.agents/skills/harness-adapters/SKILL.md). Launch mechanics, including the verified command templates, live in [`bin/fm-spawn.sh`](../bin/fm-spawn.sh). Enabled primary-session turn-end guard integrations are tracked as repo-level hook files and documented in [`docs/turnend-guard.md`](turnend-guard.md). Kimi remains outside the primary turn-end guard integrations; [`docs/turnend-guard.md`](turnend-guard.md#compatibility-limits) owns its separate captain-approved crew wake hook. @@ -212,7 +231,7 @@ When the file exists, `fm-spawn.sh` enforces that contract by refusing crewmate Batch spawns satisfy the same requirement with a shared `--harness`. Secondmate spawns are exempt and still resolve through `config/secondmate-harness` and its optional model and effort tokens. This section is the single owner of the canonical schema and its per-field semantics. -`AGENTS.md` section 4 owns the always-loaded dispatch intake boundary, and `quota-array-dispatch` owns the pace-aware profile-array selection procedure. +`AGENTS.md` section 4 owns the always-loaded dispatch intake boundary, and `quota-array-dispatch` owns the completion-aware profile-array selection procedure. ```json { @@ -251,7 +270,7 @@ Secondmate homes inherit this file from the primary, so a secondmate's own crewm On session start the first mate detects what its required toolchain is missing or too old and lists each problem with either an exact install command or manual instructions. It installs automatically supported tools only after you say go; manual-only tools remain for you to install from the printed instructions. Required tools come in two parts: a universal toolchain every home needs regardless of backend, and a per-backend delta that follows the runtime backend actually resolved for this home. -The universal toolchain is node, git, gh with GitHub auth via `gh auth login`, no-mistakes v1.31.2 or newer, gh-axi, chrome-devtools-axi, lavish-axi, compatible tasks-axi per "Backlog backend" above, and quota-axi. +The universal toolchain is node, git, gh with GitHub auth via `gh auth login`, no-mistakes v1.31.2 or newer, gh-axi, chrome-devtools-axi, lavish-axi, compatible tasks-axi per "Backlog backend" above, and quota-axi v0.1.16 or newer. This section is the single owner of that universal toolchain list; backend guides' prerequisites point here and add only their backend-specific tools. In that list, no-mistakes runs the validation pipeline, gh-axi, chrome-devtools-axi, and lavish-axi cover GitHub, browser, and rich-review operations, and tasks-axi plus quota-axi back backlog mutations and quota-aware array dispatch. The per-backend delta is required only for the backend resolved from `FM_BACKEND`, then `config/backend`, then runtime auto-detection, then default `tmux`, so a home is never told to install a tool an inactive backend or feature would need. @@ -264,7 +283,8 @@ When `config/crew-dispatch.json` exists, bootstrap also requires `jq` for dispat When X mode is opted in, bootstrap also requires `curl` and `jq` before arming the relay poll shim. `tasks-axi` and `quota-axi` are required bootstrap tools in every profile, the same class as `lavish-axi`. An absent or incompatible `tasks-axi` reports `MISSING: tasks-axi (install: npm install -g tasks-axi)`; when `config/backlog-backend` is not `manual` and compatible `tasks-axi` is on `PATH`, bootstrap stays silent and firstmate uses its verbs for routine backlog mutations, otherwise it hand-edits `data/backlog.md` until installation is approved and completed. -An absent `quota-axi` reports `MISSING: quota-axi (install: npm install -g quota-axi)`; firstmate cannot resolve a profile array until current quota output is available for every candidate. +An absent or too-old `quota-axi` reports `MISSING: quota-axi (install: npm install -g quota-axi)`; firstmate cannot resolve a profile array without a compatible binary. +That floor exists because it is the first build reporting per-credential auth sources, without which a candidate cannot be judged against the authentication surface it actually uses. Bootstrap also reports a `TANGLE:` line when `FM_ROOT` is on a named non-default branch; follow the printed checkout remediation rather than treating it as an installable tool problem. In a read-only session that did not get the fleet lock, the same line is advisory and omits the checkout command. The locked session-start bootstrap step also runs a best-effort project clone refresh through `fm-fleet-sync.sh`. @@ -280,7 +300,7 @@ When a running home advances and its loaded instruction surface (`AGENTS.md`, `b If that send fails, bootstrap keeps an idempotent retry marker and emits `NUDGE_SECONDMATES:` with the failure reason. The same bootstrap run emits `SECONDMATE_LIVENESS:` only when a registered secondmate is skipped or its relaunch fails; already-live and successfully relaunched secondmates are handled silently. For a mid-session inherited local-material edit where tracked-file sync is not needed, run `bin/fm-config-push.sh`. -It uses the same live secondmate discovery and propagation helper as bootstrap, prints each live home's `crew-dispatch.json`, `crew-harness`, `backlog-backend`, `herdr-presentation-spaces`, and `data/captain-shared.md` result as `pushed`, `unchanged`, `skipped`, or `error`, and exits non-zero for real propagation errors or config-reread send failures. +It uses the same live secondmate discovery and propagation helper as bootstrap, prints each live home's `crew-dispatch.json`, `crew-harness`, `backlog-backend`, `backend`, `herdr-presentation-spaces`, `startup-memory-budget`, and `data/captain-shared.md` result as `pushed`, `unchanged`, `skipped`, or `error`, and exits non-zero for real propagation errors or config-reread send failures. When an allowlisted config item changes for an already-running home, it sends the literal-content reread pointer described in [`secondmate-provisioning`](../.agents/skills/secondmate-provisioning/SKILL.md); unchanged allowlisted config sends no pointer unless a previous delivery is pending. The locked bootstrap inheritance pass uses the same per-home changed-set and reread path for already-running homes; see `secondmate-provisioning` for the single contract owner. That live discovery starts from `state/*.meta` records with `kind=secondmate`; `data/secondmates.md` only backfills `home=` for older or incomplete meta records. @@ -323,7 +343,7 @@ The record is written only when a platform or explicit budget is actually known, The `fmx-respond` skill decides whether the stashed mention is an actionable request, a question, or a pure acknowledgment. Actionable reversible requests are run through intake, backlog, dispatch, investigation, or ship flow as appropriate. If the work completes in that turn, the public reply reports the outcome. -If the request spawns a longer-running task, firstmate posts an acknowledgement through the normal answer endpoint, links the task to the mention with `bin/fm-x-link.sh`, and posts up to three completion follow-ups on genuine milestones, always finishing with a `--final` one when the task reaches a terminal state. +If the request spawns a longer-running task, firstmate posts an acknowledgement through the normal answer endpoint, links the task to the mention with `bin/fm-x-link.sh`, and posts up to three completion follow-ups on genuine milestones, finishing with a `--final` one for ordinary X-linked work. When a typed promised-final commitment is registered, `bin/fm-public-followup.sh` owns the terminal reply and clears the legacy link after its receipt is validated. That link stores optional reply-platform context so Discord-originated follow-ups keep Discord's larger message budget after the inbox file has been drained. Platform/budget resolution is layered and independent of the task link: a per-axis `FMX_REPLY_PLATFORM` / `FMX_REPLY_MAX_CHARS` override (how `bin/fm-x-followup.sh` passes a recorded link's context) wins. For either axis without an override, `bin/fm-x-lib.sh:fmx_resolve_reply_context` owns the source order: the durable per-request registry is consulted first, then the still-present inbox payload, then - for a follow-up posted live by request_id - an authoritative relay lookup via `POST /connector/request-context` (`{request_id}` in, `{platform, reply_max_chars}` back). @@ -360,6 +380,28 @@ In dry-run, `fm-x-dismiss.sh` records `{request_id, endpoint:"dismiss"}` to the The live answer and follow-up bodies intentionally stay the same shape, including optional `image`; the relay distinguishes them by endpoint, and dismiss stays `{request_id}`. These paths need `jq` to build the JSON payload, but they run before token and network checks, so they need neither `FMX_PAIRING_TOKEN` nor `curl`. +### Promised public replies (state/public-followup) + +A relay request that spawns real work can leave firstmate owing a specific public reply in a specific thread. +That promise is a typed `kind=public-followup` obligation owned entirely by `tasks-axi public-followup`, with the full private request context staying in `state/x-context/`; firstmate keeps no parallel copy of either. +`bin/fm-public-followup.sh` is firstmate's side: it registers a commitment, reconciles typed terminal work results into it, and posts the final reply through `bin/fm-x-reply.sh --followup`. +Run `bin/fm-public-followup.sh --help` for the exact subcommands and flags. + +Registration is what creates this home's private transport under `state/public-followup/` (mode 0700): `registry/` for the bounded public-safe binding of each live commitment, `events/` for typed terminal results awaiting reconciliation, `consumed/` for the accepted-event ledger, `rejected/` for refusals kept with a one-line reason, and `surfaced` for the poll's last-surfaced signature. +The home that owns the commitment also owns the outward post, because only it holds the relay consent, the request context, and the opaque thread binding. +Work routed elsewhere reports a typed terminal result with `bin/fm-public-followup-emit.sh` and never looks for the thread; that emitter refuses to write into a home with no registration for the named obligation. +A terminal event's id is derived from its identity tuple, so a duplicate report, a retry, or a replay after restart resolves to the same event and changes nothing. + +Activation is the same `.env` `FMX_PAIRING_TOKEN` contract as the rest of X mode, with no second flag. +A home without that token runs one file test and stops: no `tasks-axi` call, no backlog or request-context scan, and no `state/public-followup/` directory. +Ordinary startup, polling, cleanup, and silent read-side subcommands also produce no output; commands that require an active relay report that configuration error after the same gate. +A relay-enabled home with no registered commitment stops at an O(1) directory presence check, so the empty state costs no CLI call and adds no periodic scan. +Unreconciled terminal results ride the existing 30-second relay poll rather than a new process or timer: `bin/fm-x-poll.sh` compares the pending-event signature against `surfaced` and wakes firstmate once per new result set. +The session-start digest separately prints an "Public commitments awaiting delivery" subsection from disk when, and only when, this home is relay-active and still owes a reply, so compaction and restart are non-events. +`bin/fm-teardown.sh` refuses to clean up a task while this home still owes a public reply for exactly that work, unless `--force` carries explicit discard approval. +`FM_PF_RETRY_BACKOFF_SECS` (default 900) sets the next-attempt time recorded with a retryable delivery error. +See [verification/public-followup.md](verification/public-followup.md) for the current maintainer evidence behind the restart end-to-end and the relay-disabled zero-overhead guarantee. + ## Environment variables Runtime tuning via environment variables (defaults shown): @@ -376,7 +418,7 @@ FM_BACKEND= # optional runtime backend override for new spawns; tmux HERDR_SESSION=default # herdr-only: named session for normal backend ops; not enough for destructive cleanup (docs/herdr-backend.md) FM_BACKEND_HERDR_COMPOSER_LINES=20 # herdr-only: tail lines scanned by composer-state guard/fallback paths; idle-baseline submit confirmation uses agent-state FM_BACKEND_HERDR_IDLE_RE='^Type a message\.\.\.$' # herdr-only: empty-composer placeholder regex after shared ghost extraction plus border and prompt stripping -FM_BACKEND_HERDR_BARE_PROMPT_RE='^[❯›]' # herdr-only: verified agent glyphs recognized as an UNBORDERED (bare) composer row, e.g. Claude's ❯ or Codex's ›; shell glyphs remain unknown rather than empty, and de-emphasised ghost/placeholder text reads empty through shared fm_composer_strip_ghost (docs/herdr-backend.md "Composer and injection safety") +FM_BACKEND_HERDR_BARE_PROMPT_RE='^(❯|›)' # herdr-only: verified agent glyphs recognized as an UNBORDERED (bare) composer row, e.g. Claude's ❯ or Codex's ›; an alternation, not a `[...]` bracket expression, so a C-locale byte-decomposed match can never misfire on an unrelated multibyte glyph; shell glyphs remain unknown rather than empty, and de-emphasised ghost/placeholder text reads empty through shared fm_composer_strip_ghost (docs/herdr-backend.md "Composer and injection safety") FM_BACKEND_HERDR_PI_COMPOSER_MAX_LINES=8 # herdr-only: maximum rows admitted between Pi's native-identity-corroborated separator pair; taller or ambiguous candidates stay unknown (docs/herdr-backend.md "Composer and injection safety") FM_BACKEND_HERDR_SUBMIT_POLLS=6 # herdr-only: agent-state samples spread across each Enter attempt's budget when confirming a submit (docs/herdr-backend.md "Current transport behavior") FM_BACKEND_HERDR_SUBMIT_MIN_SLEEP=0.6 # herdr-only: minimum per-Enter confirmation budget before polling agent-state after an idle baseline @@ -408,15 +450,16 @@ FMX_DISCORD_REPLY_MAX_CHARS=1900 # Discord reply per-message split budget; val FMX_X_THREAD_MAX=25 # maximum messages in one auto-split reply thread FMX_FOLLOWUP_MAX_AGE_SECS=604800 # local window for posting X-mode completion follow-ups (7 days) FMX_FOLLOWUP_MAX_COUNT=3 # local cap on X-mode completion follow-ups per linked mention +FM_PF_RETRY_BACKOFF_SECS=900 # seconds before the next attempt after a retryable promised-public-reply delivery error FM_LOCK_STALE_AFTER=2 # seconds before dead-pid lock records can be reclaimed; mid-acquire locks keep at least 2s grace FM_GUARD_GRACE=300 # seconds before guard warnings, arm health checks, and the primary turn-end guard treat a watcher beacon as stale FM_CLAUDE_AUTOARM_SYNC_WAIT_MS=800 # milliseconds the --claude turn-end guard waits for the Stop auto-arm's claim, health, or fresh rewake epoch before re-blocking FM_CLAUDE_AUTOARM_EPOCH_FRESH=15 # seconds a recorded auto-arm rewake outcome counts as this event epoch's owned recovery FM_CLAUDE_TURNEND_BLOCK_BUDGET=3 # consecutive --claude guard re-blocks before a degraded allow; safely below Claude Code's 8-block override -FM_ARM_CONFIRM_TIMEOUT=10 # seconds fm-watch-arm waits to confirm a fresh watcher before reporting FAILED +FM_ARM_CONFIRM_TIMEOUT=10 # seconds fm-watch-arm waits to confirm a fresh watcher before reporting FAILED; default 30 on Git Bash/MSYS FM_ARM_ATTACH_POLL=0.5 # seconds between checks while fm-watch-arm is attached to an existing healthy watcher cycle -FM_OPENCODE_ARM_READY_TIMEOUT_MS=12000 # milliseconds the OpenCode primary watcher plugin waits for an arm attempt to report started, healthy, wake, or failure -FM_PI_ARM_READY_TIMEOUT_MS=12000 # milliseconds the Pi watcher extension waits for a successor arm to report started or attached +FM_OPENCODE_ARM_READY_TIMEOUT_MS=12000 # milliseconds the OpenCode primary watcher plugin waits for an arm attempt to report started, healthy, wake, or failure; default 35000 on Windows to stay above the MSYS confirm budget +FM_PI_ARM_READY_TIMEOUT_MS=12000 # milliseconds the Pi watcher extension waits for a successor arm to report started or attached; default 35000 on Windows to stay above the MSYS confirm budget FM_WATCH_ARM_RETIRE_TIMEOUT_MS=1000 # milliseconds Pi/OpenCode wait for an unready successor arm to exit before abandoning retries FM_WATCH_REARM_RETRY_BASE_MS=250 # Pi/OpenCode adapter base delay for continuity restoration retries FM_WATCH_REARM_RETRY_MAX_MS=4000 # Pi/OpenCode adapter cap for exponential continuity retry delay @@ -428,6 +471,7 @@ FM_SIGNAL_GRACE=30 # seconds to coalesce nearby status and turn-end signals FM_CAPTAIN_RE='done:|needs-decision:|blocked:|failed:|PR ready|checks green|ready in branch|merged' # captain-relevant status regex; nonterminal progress verbs remain excluded even when their prose matches FM_CLASSIFY_PAUSED_VERB=paused # leading status verb for a declared external wait; excluded from FM_CAPTAIN_RE and distinct from blocked FM_STALE_ESCALATE_SECS=240 # idle seconds before a provably-working stale pane escalates; stale panes whose crew is not provably working surface immediately unless they declare the pause verb +FM_BUSY_TURN_MAX_SECS=3600 # maximum age of a busy pane's latest state/<id>.turn-ended marker, or its state/<id>.meta spawn record before any turn completes, before the same wedge escalation used for a provably-working non-busy stale takes over; inspection-only, never an automatic interrupt or restart FM_PAUSE_RESURFACE_SECS=3600 # seconds before an idle declared external wait re-surfaces for a recheck in the watcher or away-mode daemon FM_WEDGE_DEMAND_INSPECT_COUNT=3 # consecutive provably-working stale escalations on the same unchanged pane before demand-deep-inspection is added FM_WATCH_TRIAGE_LOG_MAX_BYTES=262144 # size cap for the watcher's absorbed-wake debug log @@ -440,7 +484,7 @@ FM_STALE_WORKTREE_LOCK_RETRY_WAIT_SECS= # legacy alias for FM_TREEHOUSE_RETURN FM_FLEET_SYNC_PACKED_REFS_LOCK_RETRIES=3 # fetch retries after fm-fleet-sync.sh hits the orphaned .git/packed-refs.lock signature FM_FLEET_SYNC_PACKED_REFS_LOCK_RETRY_WAIT_SECS=1 # seconds fm-fleet-sync.sh waits before each of those retries FM_FLEET_SYNC_PACKED_REFS_LOCK_AGE_SECS=30 # min mtime age before fm-fleet-sync.sh treats a leftover packed-refs.lock as provably stale -FM_BUSY_REGEX= # optional global override for every harness-scoped busy-pane matcher; unset uses each recorded harness's verified signature +FM_BUSY_REGEX= # optional override for rendered delivery guards and Grok's isolated task-state fallback; converted worker state ignores it FM_COMPOSER_IDLE_RE= # optional empty-composer regex, applied after ghost and border stripping FM_COMPOSER_GHOST_LUMA_MAX=128 # fleet-wide: max perceived luminance (0.299R+0.587G+0.114B, 0-255) for a TRUECOLOR foreground to count as de-emphasised ghost/placeholder text and be stripped; dim/faint (SGR 2) is stripped regardless. Assumes a dark terminal theme (bin/fm-composer-lib.sh's fm_composer_strip_ghost, shared by the tmux and herdr composer readers) GROK_HOME= # optional Grok config home for firstmate's global grok turn-end hook; defaults to ~/.grok diff --git a/docs/documentation-audiences.json b/docs/documentation-audiences.json index 54b2190f6c..f6075ab085 100644 --- a/docs/documentation-audiences.json +++ b/docs/documentation-audiences.json @@ -303,10 +303,22 @@ "path": "docs/turnend-guard.md", "audience": "operator-current" }, + { + "path": "docs/verification/dispatch-auth.md", + "audience": "maintainer-verification" + }, + { + "path": "docs/verification/public-followup.md", + "audience": "maintainer-verification" + }, { "path": "docs/verification/runtime-backends.md", "audience": "maintainer-verification" }, + { + "path": "docs/verification/stow-memory.md", + "audience": "maintainer-verification" + }, { "path": "docs/verification/supervision.md", "audience": "maintainer-verification" diff --git a/docs/examples/crew-dispatch.json b/docs/examples/crew-dispatch.json index 23a5391d20..b404e95e77 100644 --- a/docs/examples/crew-dispatch.json +++ b/docs/examples/crew-dispatch.json @@ -16,7 +16,7 @@ { "harness": "claude", "model": "claude-sonnet-5", "effort": "high" }, { "harness": "codex", "model": "gpt-5.5", "effort": "high" } ], - "why": "Firstmate compares every candidate with current relevant quota and pace before dispatch, so use a strong coding profile." + "why": "Use a strong coding profile for big, ambiguous work; resolve the alternatives through quota-array-dispatch." } ], "default": [ diff --git a/docs/fm-test-isolation-proof.json b/docs/fm-test-isolation-proof.json index 92e227c075..ec605bf10f 100644 --- a/docs/fm-test-isolation-proof.json +++ b/docs/fm-test-isolation-proof.json @@ -1,190 +1,36 @@ { "concurrency": 4, - "finished_at": "2026-07-25T08:44:54Z", + "finished_at": "2026-07-29T23:21:46Z", "fm_test_run_jobs_enabled": false, "kind": "isolation-proof", "production_sharding_enabled": false, - "run_id": "fm-isolation-1784968984050-13742", + "run_id": "fm-isolation-1785367157179-18165", "scripts": [ - { - "duration_ms": 26535, - "exit": 0, - "path": "tests/fm-arm-pretool-check.test.sh", - "worker": 1 - }, - { - "duration_ms": 29446, - "exit": 0, - "path": "tests/fm-backend-herdr.test.sh", - "worker": 2 - }, - { - "duration_ms": 973, - "exit": 0, - "path": "tests/fm-brief.test.sh", - "worker": 3 - }, - { - "duration_ms": 181, - "exit": 0, - "path": "tests/fm-captain-translation-contract.test.sh", - "worker": 4 - }, - { - "duration_ms": 17218, - "exit": 0, - "path": "tests/fm-cd-pretool-check.test.sh", - "worker": 5 - }, - { - "duration_ms": 1810, - "exit": 0, - "path": "tests/fm-composer-ghost.test.sh", - "worker": 6 - }, - { - "duration_ms": 66, - "exit": 0, - "path": "tests/fm-composer-lib.test.sh", - "worker": 7 - }, - { - "duration_ms": 15250, - "exit": 0, - "path": "tests/fm-crew-state.test.sh", - "worker": 8 - }, - { - "duration_ms": 18509, - "exit": 0, - "path": "tests/fm-decision-hold-lifecycle.test.sh", - "worker": 9 - }, - { - "duration_ms": 358, - "exit": 0, - "path": "tests/fm-ensure-agents-md.test.sh", - "worker": 10 - }, - { - "duration_ms": 5276, - "exit": 0, - "path": "tests/fm-grok-harness.test.sh", - "worker": 11 - }, - { - "duration_ms": 11199, - "exit": 0, - "path": "tests/fm-herdr-lab.test.sh", - "worker": 12 - }, - { - "duration_ms": 297, - "exit": 0, - "path": "tests/fm-instruction-owners.test.sh", - "worker": 13 - }, - { - "duration_ms": 4882, - "exit": 0, - "path": "tests/fm-lint.test.sh", - "worker": 14 - }, - { - "duration_ms": 180, - "exit": 0, - "path": "tests/fm-nm-test-contract.test.sh", - "worker": 15 - }, - { - "duration_ms": 35, - "exit": 0, - "path": "tests/fm-no-mistakes-ownership.test.sh", - "worker": 16 - }, - { - "duration_ms": 1842, - "exit": 0, - "path": "tests/fm-pi-primary-types.test.sh", - "worker": 17 - }, - { - "duration_ms": 6630, - "exit": 0, - "path": "tests/fm-pr-merge.test.sh", - "worker": 18 - }, - { - "duration_ms": 2410, - "exit": 0, - "path": "tests/fm-review-diff.test.sh", - "worker": 19 - }, - { - "duration_ms": 4496, - "exit": 0, - "path": "tests/fm-send-popup-settle.test.sh", - "worker": 20 - }, - { - "duration_ms": 2179, - "exit": 0, - "path": "tests/fm-send-settle.test.sh", - "worker": 21 - }, - { - "duration_ms": 1390, - "exit": 0, - "path": "tests/fm-send-strict.test.sh", - "worker": 22 - }, - { - "duration_ms": 626, - "exit": 0, - "path": "tests/fm-spawn-batch.test.sh", - "worker": 23 - }, - { - "duration_ms": 52, - "exit": 0, - "path": "tests/fm-stow-contract.test.sh", - "worker": 24 - }, - { - "duration_ms": 336, - "exit": 0, - "path": "tests/fm-supervision-instructions.test.sh", - "worker": 25 - }, - { - "duration_ms": 8900, - "exit": 0, - "path": "tests/fm-test-run.test.sh", - "worker": 26 - }, - { - "duration_ms": 1845, - "exit": 0, - "path": "tests/fm-tmux-submit-busy.test.sh", - "worker": 27 - }, - { - "duration_ms": 96, - "exit": 0, - "path": "tests/fm-transition-lib.test.sh", - "worker": 28 - }, - { - "duration_ms": 34920, - "exit": 0, - "path": "tests/fm-x-mode.test.sh", - "worker": 29 - } + {"duration_ms": 46788, "exit": 0, "path": "tests/fm-arm-pretool-check.test.sh", "worker": 1}, + {"duration_ms": 48294, "exit": 0, "path": "tests/fm-backend-herdr.test.sh", "worker": 2}, + {"duration_ms": 2224, "exit": 0, "path": "tests/fm-brief.test.sh", "worker": 3}, + {"duration_ms": 34207, "exit": 0, "path": "tests/fm-cd-pretool-check.test.sh", "worker": 4}, + {"duration_ms": 9065, "exit": 0, "path": "tests/fm-composer-ghost.test.sh", "worker": 5}, + {"duration_ms": 64, "exit": 0, "path": "tests/fm-composer-lib.test.sh", "worker": 6}, + {"duration_ms": 25365, "exit": 0, "path": "tests/fm-crew-state.test.sh", "worker": 7}, + {"duration_ms": 30771, "exit": 0, "path": "tests/fm-decision-hold-lifecycle.test.sh", "worker": 8}, + {"duration_ms": 581, "exit": 0, "path": "tests/fm-ensure-agents-md.test.sh", "worker": 9}, + {"duration_ms": 6251, "exit": 0, "path": "tests/fm-grok-harness.test.sh", "worker": 10}, + {"duration_ms": 15422, "exit": 0, "path": "tests/fm-herdr-lab.test.sh", "worker": 11}, + {"duration_ms": 5237, "exit": 0, "path": "tests/fm-lint.test.sh", "worker": 12}, + {"duration_ms": 2945, "exit": 0, "path": "tests/fm-pi-primary-types.test.sh", "worker": 13}, + {"duration_ms": 8564, "exit": 0, "path": "tests/fm-pr-merge.test.sh", "worker": 14}, + {"duration_ms": 2875, "exit": 0, "path": "tests/fm-review-diff.test.sh", "worker": 15}, + {"duration_ms": 5644, "exit": 0, "path": "tests/fm-send-popup-settle.test.sh", "worker": 16}, + {"duration_ms": 2911, "exit": 0, "path": "tests/fm-send-settle.test.sh", "worker": 17}, + {"duration_ms": 2747, "exit": 0, "path": "tests/fm-send-strict.test.sh", "worker": 18}, + {"duration_ms": 855, "exit": 0, "path": "tests/fm-spawn-batch.test.sh", "worker": 19}, + {"duration_ms": 703, "exit": 0, "path": "tests/fm-supervision-instructions.test.sh", "worker": 20}, + {"duration_ms": 15674, "exit": 0, "path": "tests/fm-test-run.test.sh", "worker": 21}, + {"duration_ms": 4816, "exit": 0, "path": "tests/fm-tmux-submit-busy.test.sh", "worker": 22}, + {"duration_ms": 248, "exit": 0, "path": "tests/fm-transition-lib.test.sh", "worker": 23}, + {"duration_ms": 52939, "exit": 0, "path": "tests/fm-x-mode.test.sh", "worker": 24} ], - "started_at": "2026-07-25T08:43:04Z", - "summary": { - "duration_ms": 110623, - "failed": 0, - "total": 29 - } + "started_at": "2026-07-29T23:19:17Z", + "summary": {"duration_ms": 149010, "failed": 0, "total": 24} } diff --git a/docs/fm-test-isolation-proof.md b/docs/fm-test-isolation-proof.md index 19e4b6a516..716dca73a5 100644 --- a/docs/fm-test-isolation-proof.md +++ b/docs/fm-test-isolation-proof.md @@ -1,48 +1,30 @@ -# Firstmate test isolation proof (Phase 2) +# Firstmate test isolation proof -This document is the archived concurrent isolation proof for the portable parallel candidate set. -It is the human-readable companion to `bin/fm-test-isolation-proof.sh`. -Phase 4 production portable shards and bounded local `fm-test-run.sh --jobs` for this exact set are owned by `bin/fm-test-run.sh` and documented in [fm-test-portable-shards.md](fm-test-portable-shards.md). -The archived proof JSON below still records the Phase 2 proof-time flags (`production_sharding_enabled` / `fm_test_run_jobs_enabled` false at proof time). +This record is the concurrent isolation proof for the portable parallel candidate set. +`bin/fm-test-isolation-proof.sh` is the authoritative harness and `docs/fm-test-isolation-proof.json` is the machine-readable result. +`bin/fm-test-run.sh` owns the production lane partition. -## Owner +## Verification -- Harness: `bin/fm-test-isolation-proof.sh` -- Contract tests: `tests/fm-test-isolation-proof.test.sh` -- Family labels (Phase 1): `bin/fm-test-run.sh` -- Timing evidence used for planning: CI artifact `fm-test-timing` from Phase 1 PR #825 - -## Proof posture +- Date: 2026-07-29 +- Command: `bin/fm-test-isolation-proof.sh --jobs 4 --json /tmp/fm-source-content-test-cleanup-r1-isolation.json` +- Result: `FM_ISOLATION_SUMMARY total=24 failed=0 concurrency=4 duration_ms=149010` | Field | Value | |---|---| -| `run_id` | `fm-isolation-1784968984050-13742` | -| `started_at` | `2026-07-25T08:43:04Z` | -| `finished_at` | `2026-07-25T08:44:54Z` | -| concurrency | **4** | -| candidates | **29** | -| failed | **0** | -| wall duration_ms | **110623** (~110.6s) | -| `production_sharding_enabled` | `False` | -| `fm_test_run_jobs_enabled` | `False` | -| host proof date | 2026-07-25 (UTC day of archive write) | - -Isolation checks that passed with this run: - -- Distinct mode-`0700` temporary roots per worker under a proof-owned parent -- Per-worker `TMPDIR`/`TMP` so `mktemp` / `fm_test_tmproot` stay private -- Ambient `FM_HOME` / `FM_*_OVERRIDE` cleared for each worker -- `git config --global` snapshot unchanged before/after the matrix -- Aggregate failure reporting (any non-zero candidate fails the harness; no retry-until-green) +| `run_id` | `fm-isolation-1785367157179-18165` | +| `started_at` | `2026-07-29T23:19:17Z` | +| `finished_at` | `2026-07-29T23:21:46Z` | +| concurrency | 4 | +| candidates | 24 | +| failed | 0 | +| wall duration | 149010 ms | -## Exact candidate set - -Sorted paths as selected by `bin/fm-test-isolation-proof.sh --list` at proof time: +## Candidate set - `tests/fm-arm-pretool-check.test.sh` - `tests/fm-backend-herdr.test.sh` - `tests/fm-brief.test.sh` -- `tests/fm-captain-translation-contract.test.sh` - `tests/fm-cd-pretool-check.test.sh` - `tests/fm-composer-ghost.test.sh` - `tests/fm-composer-lib.test.sh` @@ -51,10 +33,7 @@ Sorted paths as selected by `bin/fm-test-isolation-proof.sh --list` at proof tim - `tests/fm-ensure-agents-md.test.sh` - `tests/fm-grok-harness.test.sh` - `tests/fm-herdr-lab.test.sh` -- `tests/fm-instruction-owners.test.sh` - `tests/fm-lint.test.sh` -- `tests/fm-nm-test-contract.test.sh` -- `tests/fm-no-mistakes-ownership.test.sh` - `tests/fm-pi-primary-types.test.sh` - `tests/fm-pr-merge.test.sh` - `tests/fm-review-diff.test.sh` @@ -62,109 +41,51 @@ Sorted paths as selected by `bin/fm-test-isolation-proof.sh --list` at proof tim - `tests/fm-send-settle.test.sh` - `tests/fm-send-strict.test.sh` - `tests/fm-spawn-batch.test.sh` -- `tests/fm-stow-contract.test.sh` - `tests/fm-supervision-instructions.test.sh` - `tests/fm-test-run.test.sh` - `tests/fm-tmux-submit-busy.test.sh` - `tests/fm-transition-lib.test.sh` - `tests/fm-x-mode.test.sh` -## Per-candidate durations (concurrent run) +## Durations | duration_ms | exit | worker | script | |---:|---:|---:|---| -| 34920 | 0 | 29 | `tests/fm-x-mode.test.sh` | -| 29446 | 0 | 2 | `tests/fm-backend-herdr.test.sh` | -| 26535 | 0 | 1 | `tests/fm-arm-pretool-check.test.sh` | -| 18509 | 0 | 9 | `tests/fm-decision-hold-lifecycle.test.sh` | -| 17218 | 0 | 5 | `tests/fm-cd-pretool-check.test.sh` | -| 15250 | 0 | 8 | `tests/fm-crew-state.test.sh` | -| 11199 | 0 | 12 | `tests/fm-herdr-lab.test.sh` | -| 8900 | 0 | 26 | `tests/fm-test-run.test.sh` | -| 6630 | 0 | 18 | `tests/fm-pr-merge.test.sh` | -| 5276 | 0 | 11 | `tests/fm-grok-harness.test.sh` | -| 4882 | 0 | 14 | `tests/fm-lint.test.sh` | -| 4496 | 0 | 20 | `tests/fm-send-popup-settle.test.sh` | -| 2410 | 0 | 19 | `tests/fm-review-diff.test.sh` | -| 2179 | 0 | 21 | `tests/fm-send-settle.test.sh` | -| 1845 | 0 | 27 | `tests/fm-tmux-submit-busy.test.sh` | -| 1842 | 0 | 17 | `tests/fm-pi-primary-types.test.sh` | -| 1810 | 0 | 6 | `tests/fm-composer-ghost.test.sh` | -| 1390 | 0 | 22 | `tests/fm-send-strict.test.sh` | -| 973 | 0 | 3 | `tests/fm-brief.test.sh` | -| 626 | 0 | 23 | `tests/fm-spawn-batch.test.sh` | -| 358 | 0 | 10 | `tests/fm-ensure-agents-md.test.sh` | -| 336 | 0 | 25 | `tests/fm-supervision-instructions.test.sh` | -| 297 | 0 | 13 | `tests/fm-instruction-owners.test.sh` | -| 181 | 0 | 4 | `tests/fm-captain-translation-contract.test.sh` | -| 180 | 0 | 15 | `tests/fm-nm-test-contract.test.sh` | -| 96 | 0 | 28 | `tests/fm-transition-lib.test.sh` | -| 66 | 0 | 7 | `tests/fm-composer-lib.test.sh` | -| 52 | 0 | 24 | `tests/fm-stow-contract.test.sh` | -| 35 | 0 | 16 | `tests/fm-no-mistakes-ownership.test.sh` | - -## Audit notes (why this set) - -Source families from the Phase 1 manifest and scout report §3.1: - -1. **pure-contract-unit** candidates audited from the Phase 1 family manifest, minus deliberate serial exclusions -2. **Extra hermetic candidates** after static audit: fake backend, private git fixtures, stubbed network - -The harness pins this exact archived set and does not automatically admit later family additions. -A candidate-set change requires a new audit and concurrent proof archive. - -### Included extras (beyond pure-contract-unit) - -| Script | Why included | -|---|---| -| `tests/fm-backend-herdr.test.sh` | Fake Herdr CLI + private temps; no real Herdr binary | -| `tests/fm-send-strict.test.sh` | Fake tmux PATH shim; private `FM_HOME` | -| `tests/fm-spawn-batch.test.sh` | Argument routing only; no real windows/worktrees | -| `tests/fm-pr-merge.test.sh` | Fake `gh`/`gh-axi`; private state | -| `tests/fm-review-diff.test.sh` | Local git fixtures via `fm_git_*`; no live forge | -| `tests/fm-x-mode.test.sh` | Fake `curl`; inert without token | - -### Deliberately serial (kept out of this pool) - -Run `bin/fm-test-isolation-proof.sh --list-exclusions` for the machine-readable list. -High-signal classes: - -| Class | Examples | Reason | -|---|---|---| -| Watcher / wake / locks | `fm-watcher-lock`, `fm-wake-queue`, ... | Intentional process locks and daemon races | -| AFK | `fm-afk-inject-e2e`, ... | Daemon lifecycle and inject path | -| Real Herdr | `fm-backend-herdr-smoke`, presentation e2e, ... | Named labs, session-global locks; Herdr lane is Phase 3+ | -| Real tmux smoke | `fm-backend-tmux-smoke` | Real multiplexer server (even on private socket) | -| Live harness opt-in | `fm-*-live-e2e` | Real interactive agents | -| GUI backends | cmux smoke | Shared GUI app | -| Gray-zone git/spawn | `fm-backend`, spawn settle/profile, teardown | Heavier worktree or lock-race matrices | -| Watcher-adjacent forge security | `fm-pr-check-security` | `.watch.lock` / poll security surface | -| Self | `fm-test-isolation-proof.test.sh` | Must not re-enter the concurrent matrix | - -### Small isolation fix landed with this phase - -`tests/fm-arm-pretool-check.test.sh` no longer writes Claude deny stderr to a fixed `/tmp/fm-arm-pretool-check-claude-stderr.$$` path. -It uses `mktemp` under `TMPDIR` so concurrent workers cannot collide on a global temp name pattern. - -## Failures - -None. -Every candidate exited 0 under concurrency=4. - -Policy: a script that fails only under concurrency is **removed** from the candidate set and investigated. -It is never retried into green, skipped more broadly, or weakened in assertions. - -## What this phase did not do (Phase 2 scope) - -- Did not land production CI Behavior matrix / shard jobs (Phase 4) -- Did not add general `bin/fm-test-run.sh --jobs` (Phase 4 enables it only for this proven set) -- Did not land the Herdr install lane (Phase 3) -- Did not re-run the complete local suite as part of this proof (focused matrix only) - -## How to re-run +| 52939 | 0 | 24 | `tests/fm-x-mode.test.sh` | +| 48294 | 0 | 2 | `tests/fm-backend-herdr.test.sh` | +| 46788 | 0 | 1 | `tests/fm-arm-pretool-check.test.sh` | +| 34207 | 0 | 4 | `tests/fm-cd-pretool-check.test.sh` | +| 30771 | 0 | 8 | `tests/fm-decision-hold-lifecycle.test.sh` | +| 25365 | 0 | 7 | `tests/fm-crew-state.test.sh` | +| 15674 | 0 | 21 | `tests/fm-test-run.test.sh` | +| 15422 | 0 | 11 | `tests/fm-herdr-lab.test.sh` | +| 9065 | 0 | 5 | `tests/fm-composer-ghost.test.sh` | +| 8564 | 0 | 14 | `tests/fm-pr-merge.test.sh` | +| 6251 | 0 | 10 | `tests/fm-grok-harness.test.sh` | +| 5644 | 0 | 16 | `tests/fm-send-popup-settle.test.sh` | +| 5237 | 0 | 12 | `tests/fm-lint.test.sh` | +| 4816 | 0 | 22 | `tests/fm-tmux-submit-busy.test.sh` | +| 2945 | 0 | 13 | `tests/fm-pi-primary-types.test.sh` | +| 2911 | 0 | 17 | `tests/fm-send-settle.test.sh` | +| 2875 | 0 | 15 | `tests/fm-review-diff.test.sh` | +| 2747 | 0 | 18 | `tests/fm-send-strict.test.sh` | +| 2224 | 0 | 3 | `tests/fm-brief.test.sh` | +| 855 | 0 | 19 | `tests/fm-spawn-batch.test.sh` | +| 703 | 0 | 20 | `tests/fm-supervision-instructions.test.sh` | +| 581 | 0 | 9 | `tests/fm-ensure-agents-md.test.sh` | +| 248 | 0 | 23 | `tests/fm-transition-lib.test.sh` | +| 64 | 0 | 6 | `tests/fm-composer-lib.test.sh` | + +## Scope + +Each worker used a separate mode-`0700` temporary root and private `TMPDIR` and `TMP`. +The harness cleared ambient `FM_HOME` and `FM_*_OVERRIDE` values for every worker and verified that global Git configuration was unchanged. +A candidate failure fails the aggregate run and requires investigation rather than a retry. + +## Re-run ```sh bin/fm-test-isolation-proof.sh --list bin/fm-test-isolation-proof.sh --jobs 4 --json /tmp/fm-isolation-proof.json -bash tests/fm-test-isolation-proof.test.sh +bin/fm-test-run.sh --check-coverage ``` diff --git a/docs/fm-test-portable-shards.md b/docs/fm-test-portable-shards.md index ce153cbd74..0bfa5e6bee 100644 --- a/docs/fm-test-portable-shards.md +++ b/docs/fm-test-portable-shards.md @@ -1,89 +1,67 @@ -# Firstmate portable test shards (Phase 4) +# Firstmate portable test shards -This document records how the two portable parallel CI shards were balanced from measured evidence. -Composition and execution are owned by `bin/fm-test-run.sh` (`--lane portable-parallel-1` / `portable-parallel-2` / `portable-serial`). -The proven-isolated candidate set remains owned by `bin/fm-test-isolation-proof.sh`. +`bin/fm-test-run.sh` owns portable lane composition and execution. +`bin/fm-test-isolation-proof.sh` owns the proven-isolated candidate set. -## Inputs +## Verification inputs -| Input | Owner / source | -|---|---| -| Proven-isolated set (29 scripts) | `bin/fm-test-isolation-proof.sh --list` and `docs/fm-test-isolation-proof.md` | -| Phase 1 serial durations | CI timing artifacts `fm-test-timing` from main after #825 / #832 / #834 | -| Real-Herdr family | `bin/fm-test-run.sh --family real-herdr-gated` (dedicated required CI lane) | +The current candidate timings came from the 2026-07-29 concurrent proof recorded in [fm-test-isolation-proof.md](fm-test-isolation-proof.md). +The proof ran 24 candidates with four workers and no failures. -Phase 1 averages used for balance (mean of available serial `duration_ms` across those artifacts): - -| duration_ms (avg) | script | +| duration_ms | script | |---:|---| -| 29639 | `tests/fm-arm-pretool-check.test.sh` | -| 25402 | `tests/fm-decision-hold-lifecycle.test.sh` | -| 19428 | `tests/fm-x-mode.test.sh` | -| 14979 | `tests/fm-cd-pretool-check.test.sh` | -| 9339 | `tests/fm-backend-herdr.test.sh` | -| 6885 | `tests/fm-herdr-lab.test.sh` | -| 5127 | `tests/fm-crew-state.test.sh` | -| 4044 | `tests/fm-pr-merge.test.sh` | -| 3922 | `tests/fm-grok-harness.test.sh` | -| 2492 | `tests/fm-test-run.test.sh` | -| 1901 | `tests/fm-send-popup-settle.test.sh` | -| 1234 | `tests/fm-spawn-batch.test.sh` | -| 851 | `tests/fm-send-strict.test.sh` | -| 791 | `tests/fm-review-diff.test.sh` | -| 627 | `tests/fm-tmux-submit-busy.test.sh` | -| 525 | `tests/fm-brief.test.sh` | -| 321 | `tests/fm-composer-ghost.test.sh` | -| 276 | `tests/fm-send-settle.test.sh` | -| 189 | `tests/fm-ensure-agents-md.test.sh` | -| 175 | `tests/fm-supervision-instructions.test.sh` | -| 138 | `tests/fm-instruction-owners.test.sh` | -| 133 | `tests/fm-lint.test.sh` | -| 108 | `tests/fm-pi-primary-types.test.sh` | -| 106 | `tests/fm-nm-test-contract.test.sh` | -| 67 | `tests/fm-transition-lib.test.sh` | -| 64 | `tests/fm-captain-translation-contract.test.sh` | -| 48 | `tests/fm-composer-lib.test.sh` | -| 36 | `tests/fm-stow-contract.test.sh` | -| 28 | `tests/fm-no-mistakes-ownership.test.sh` | - -## Balancing history - -The original 30-script set used longest-processing-time (LPT) assignment onto two workers with the Phase 1 averages above. -The current 29-script lanes retain that assignment after one 283 ms candidate was removed from `portable-parallel-1`. -The current totals are therefore intentionally not a fresh LPT balance of the 29-script set. -Do not rebalance alphabetically or by family intuition. -Shard execution order remains longest-first within each retained lane. - -| Lane | Script count | Sum of Phase 1 averages | +| 52939 | `tests/fm-x-mode.test.sh` | +| 48294 | `tests/fm-backend-herdr.test.sh` | +| 46788 | `tests/fm-arm-pretool-check.test.sh` | +| 34207 | `tests/fm-cd-pretool-check.test.sh` | +| 30771 | `tests/fm-decision-hold-lifecycle.test.sh` | +| 25365 | `tests/fm-crew-state.test.sh` | +| 15674 | `tests/fm-test-run.test.sh` | +| 15422 | `tests/fm-herdr-lab.test.sh` | +| 9065 | `tests/fm-composer-ghost.test.sh` | +| 8564 | `tests/fm-pr-merge.test.sh` | +| 6251 | `tests/fm-grok-harness.test.sh` | +| 5644 | `tests/fm-send-popup-settle.test.sh` | +| 5237 | `tests/fm-lint.test.sh` | +| 4816 | `tests/fm-tmux-submit-busy.test.sh` | +| 2945 | `tests/fm-pi-primary-types.test.sh` | +| 2911 | `tests/fm-send-settle.test.sh` | +| 2875 | `tests/fm-review-diff.test.sh` | +| 2747 | `tests/fm-send-strict.test.sh` | +| 2224 | `tests/fm-brief.test.sh` | +| 855 | `tests/fm-spawn-batch.test.sh` | +| 703 | `tests/fm-supervision-instructions.test.sh` | +| 581 | `tests/fm-ensure-agents-md.test.sh` | +| 248 | `tests/fm-transition-lib.test.sh` | +| 64 | `tests/fm-composer-lib.test.sh` | + +## Parallel lanes + +The two parallel lanes use longest-processing-time assignment from those measured durations. + +| Lane | Script count | Estimated duration | |---|---:|---:| -| `portable-parallel-1` | 14 | 64296 ms (~64.3 s) | -| `portable-parallel-2` | 15 | 64579 ms (~64.6 s) | -| imbalance | | 283 ms | +| `portable-parallel-1` | 11 | 162436 ms (~162.4 s) | +| `portable-parallel-2` | 13 | 162754 ms (~162.8 s) | +| imbalance | | 318 ms | -Exact ordered membership is the heredoc lists in `bin/fm-test-run.sh` (`list_portable_parallel_1` / `list_portable_parallel_2`). +`bin/fm-test-run.sh` contains the exact ordered memberships in `list_portable_parallel_1` and `list_portable_parallel_2`. ## Portable serial remainder -`portable-serial` is every `tests/*.test.sh` that is neither proven-isolated nor `real-herdr-gated`. -That keeps watcher, lock, AFK, real tmux, daemon, secondmate lifecycle, bootstrap, live-harness opt-in (default skip), GUI backends, and other stateful or unproven work serial. -Measured serial remainder wall (from the same Phase 1 artifacts, excluding Herdr) is about **13 minutes**. +`portable-serial` includes every `tests/*.test.sh` that is neither proven-isolated nor `real-herdr-gated`. +It keeps watcher, lock, AFK, real tmux, daemon, secondmate lifecycle, bootstrap, live-harness opt-in, GUI-backend, and other unproven work serial. ## Coverage guard -`bin/fm-test-run.sh --check-coverage` proves: - -1. The two portable parallel shards are a partition of the proven-isolated set. -2. Proven-isolated embeds match `bin/fm-test-isolation-proof.sh --list`. -3. Union of portable parallel shards + portable serial + real-Herdr family equals the complete `tests/*.test.sh` inventory. -4. Those four partitions are pairwise disjoint (no missing scripts, no duplicates). - -CI runs that guard as a required job (`test-coverage`). +`bin/fm-test-run.sh --check-coverage` verifies that both parallel lanes partition the proven-isolated set. +It also verifies that the parallel lanes, portable serial lane, and real-Herdr family are disjoint and cover every `tests/*.test.sh` script. ## Timing artifacts -Every portable shard, the portable serial lane, and the Herdr lane upload their runner-generated timing JSON even when the behavior run reports failures. -The dependent aggregate job runs after all four lanes, combines every available lane JSON through `bin/fm-test-run.sh --aggregate-json`, and uploads one summary artifact for critical-path review. -The workflow in `.github/workflows/ci.yml` owns the exact artifact names and aggregation wiring. +Portable shards, the portable serial lane, and the Herdr lane upload runner-generated timing JSON. +`bin/fm-test-run.sh --aggregate-json` creates the combined summary artifact. +`.github/workflows/ci.yml` owns the exact artifact names and aggregation wiring. ## Local entry points @@ -94,15 +72,8 @@ The workflow in `.github/workflows/ci.yml` owns the exact artifact names and agg | Job | timeout-minutes | Rationale | |---|---:|---| -| portable parallel 1/2 | 10 | Measured shard sum ~1 min; hang tripwire with margin | -| portable serial | 20 | Measured ~13 min remainder; reduced from interim 25m full-portable slack after sharding | -| Herdr | 40 | Unchanged hang tripwire for the real-Herdr lane | - -Timeouts remain hang tripwires, not expected healthy ends of green suites. -Do not raise them as a substitute for green results, retries, or weaker assertions. - -## What this phase does not do +| portable parallel 1/2 | 10 | The measured shard sums are about three minutes and the timeout is a hang tripwire. | +| portable serial | 20 | The serial remainder needs a larger hang tripwire. | +| Herdr | 40 | The real-Herdr lane keeps its dedicated timeout. | -- Does not expand the proven-isolated set without a new concurrent isolation proof. -- Does not parallelize watcher, AFK, real Herdr, real tmux, or other stateful families. -- Does not start rollout verification; that waits until this PR is green and merged. +Timeouts are hang tripwires rather than expected healthy durations. diff --git a/docs/herdr-backend.md b/docs/herdr-backend.md index 91047bcc6f..83d527c588 100644 --- a/docs/herdr-backend.md +++ b/docs/herdr-backend.md @@ -33,10 +33,11 @@ Real harness credential tests remain opt-in rather than part of default CI. ## Watching and task containers -Each Firstmate home gets one durable workspace with one task tab per endpoint. -The primary workspace is `firstmate`. -A secondmate home uses `2ndmate-<secondmate-id>`, derived from its validated `.fm-secondmate-home` marker. -The secondmate process and every child it launches resolve the same home label; a secondmate launched by the primary receives a narrowly scoped home override during container creation. +The ordinary topology puts one task tab per endpoint in the exact workspace of the Firstmate or secondmate that launches it. +When the launcher has no Herdr workspace to inherit, the adapter maintains one durable home-labeled workspace instead. +The primary home label is `firstmate`. +A secondmate home label is `2ndmate-<secondmate-id>`, derived from its validated `.fm-secondmate-home` marker. +A secondmate launched by the primary receives a narrowly scoped home override during container creation. Attach to the selected named Herdr session and switch to the relevant home workspace to watch its task tabs. Routine supervision uses `bin/fm-peek.sh <id>` and `FM_HOME=<home> bin/fm-send.sh <id> '<text>'` without attaching. @@ -44,10 +45,21 @@ Routine supervision uses `bin/fm-peek.sh <id>` and `FM_HOME=<home> bin/fm-send.s Workspace and tab creation use `--no-focus`. The first workspace in a completely empty Herdr session must become focused because no prior target exists, but later task creation does not intentionally steal focus. -Herdr does not enforce workspace or tab label uniqueness. -Firstmate adopts the first workspace matching its derived home label and refuses duplicate task tabs inside it. -Avoid naming a personal workspace `firstmate` or `2ndmate-<id>` because the adapter cannot distinguish that label collision from its own container. +Herdr does not enforce workspace or tab label uniqueness, so a label can never decide where a worker goes. +Herdr 0.7.5 exports `HERDR_ENV`, `HERDR_PANE_ID`, `HERDR_SESSION`, `HERDR_SOCKET_PATH`, `HERDR_TAB_ID`, and `HERDR_WORKSPACE_ID` into every process it manages a pane for, and a Firstmate or secondmate agent's own commands inherit them. +Older injection shapes are unverified, so a claimed launcher pane without the injected socket identity cannot be trusted. +With presentation spaces disabled, a crewmate or scout is created in the exact workspace that identity currently resolves to, read live from Herdr rather than from the injected snapshot, so the worker always appears beside the agent that launched it. +Duplicate labels elsewhere in the session are irrelevant, and the globally focused workspace is never the target. +A `--secondmate` launch is the deliberate exception: it stands up that secondmate home's own workspace instead of joining the launcher's. + +A claimed parent identity that cannot be resolved exactly stops the spawn before any worker endpoint exists, rather than falling back to a label search. +That covers a missing or unusable socket identity, a closed or unreadable launcher pane, a pane and tab that disagree about their workspace, a workspace missing from the session, and a pane belonging to another named session or Herdr server. + +Firstmate running outside Herdr entirely has no launcher workspace to inherit, so its workers use this home's own labeled workspace, created on first use. +That path needs the home label to identify exactly one workspace: two workspaces sharing it are an unresolvable placement and refuse rather than adopting either. +Avoid naming a personal workspace `firstmate` or `2ndmate-<id>` for that reason, and because the adapter cannot distinguish that label collision from its own container. An older secondmate workspace using `firstmate-<id>` is not migrated automatically; rename it manually before expecting new tasks or recovery to use it. +Recovery and list-live still scan the first workspace matching the home label, because they address panes they already recorded rather than choosing where new work goes. Existing task operations use recorded endpoint ids and do not move a live task when labels change. The per-home workspace is reused while it has task tabs. @@ -63,9 +75,12 @@ An absent or unconverged setting keeps the flat default. Presentation is a best-effort visual projection, never task ownership or lifecycle authority. Only a fresh task with neither metadata nor an existing presentation journal is eligible for projected creation. Firstmate atomically publishes a three-field version 1 journal containing a random 128-bit base64url token before asking Herdr to create anything. -After the new workspace converges to one exact task endpoint beneath one exact parent, the journal advances to a version 2 binding that records the physical home, named session, endpoint, parent, and immutable expected labels. +After the new workspace converges to one exact task endpoint beneath one exact parent workspace id, the journal advances to a version 2 binding that records the physical home, named session, endpoint, parent, and immutable expected labels. +Another parent with the same presentation label does not prevent publication or participate in restart reclaim. The token is visible in the workspace title because Herdr exposes no verified hidden persistent field, but neither token, title, nor journal authorizes send, capture, task ownership, Treehouse return, or general recovery. +The owning parent is the launcher's own exact workspace, resolved from the same identity the flat path uses, and falls back to a unique home-label lookup only for a Firstmate outside Herdr. +Projected children are never collapsed back into that parent; it is the placement and ordering reference the projection is bound under. The normal `fm-<id>` task tab is created in the exact new workspace returned by Herdr. Only the exact seeded default tab returned by the same workspace-create response can be pruned. Before and after create, prune, order, abort cleanup, and normal cleanup, Firstmate verifies exact workspace, tab, pane, and active-focus ids. @@ -83,7 +98,17 @@ The worker remains on the ordinary flat or Herdr-current-order path. Normal task metadata remains the sole endpoint authority after creation. Cleanup closes only the exact recorded task pane and never calls `workspace close`. -Herdr can move focus when closing the last pane of a non-focused projected workspace, so projected cleanup runs under the same session lock, captures the exact active tab, refuses to delete the active tab, closes the exact task pane, and restores only the exact prior tab when needed. +Herdr 0.7.5's explicit close moves focus to a neighbor whenever it empties a non-focused workspace, while its pane-death removal preserves the focused workspace whenever the dying workspace sits behind it or the focused workspace is last; both behaviors are fixed on the upstream default branch but in no release, and the exact rules live in the adapter header of `bin/backends/herdr.sh`. +Projected cleanup therefore runs under the same session lock, captures the exact active tab, refuses to delete the active tab, and treats a workspace-emptying close as a focus-safe removal: it verifies the close would empty the workspace, repositions the doomed workspace behind the focused one through the verified `workspace.move` transport when needed, proves the pane holds one lone idle shell, and ends that shell so Herdr removes the emptied workspace through its focus-preserving pane-death path. +The repositioning move-to-last preserves every surviving workspace's relative order, and removal is confirmed against the exact moved workspace rather than inferred from pane disappearance before an unconfirmed removal makes one verified attempt under the same session lock to roll the doomed workspace back to its exact original position. +If that rollback cannot restore the verified original order, cleanup warns loudly and leaves the retained records for inspection rather than retrying the shared-layout mutation. +The pane-death signals are pid-exact: the escalation re-reads the pane's process information and refuses unless the same shell pid still passes the strict bare-idle ownership proof, so an exited and reused pid is never signaled. +Any ambiguity, unsupported or failed move, or unproved shell falls back to the plain explicit close, and the exact prior-tab restore remains the backstop behind every close, so degraded behavior is never worse than the pre-mitigation sub-second restore. +Ordinary non-projected task removal serializes through the same session lock, applies the same focus-safe plan when its close would empty a non-focused workspace, keeps the legitimate plain close when the target is the active tab, and refuses an unlocked close if the lock cannot be acquired. +Task cleanup acquires that session lock before the task's isolated copy is returned, so a contended lock refuses up front while the copy, every durable record, and the endpoint are all intact for a plain rerun. +Forced secondmate cleanup recursively preflights every Herdr child endpoint and acquires every affected named-session lock before mutating any child, then retains each child's durable identity unless that exact pane returns structured not-found after its close. +Durable task records are erased only once the exact pane is confirmed gone through its structured presence: after every close path, only a structured not-found response counts as gone, while a present or unknown result retains every record with a visible, retryable error. +Missing or malformed endpoint identity and missing confirmation machinery are ambiguity, never proof of a gone pane, and refuse record removal the same way. If lock, snapshot, pane identity, or restoration is ambiguous, cleanup warns and preserves the journal for manual inspection. Recovery is deliberately conservative and presentation-only. @@ -104,6 +129,7 @@ The task's ordinary metadata must be absent, and the candidate must have exactly Before cleanup, Firstmate acquires the existing task-id spawn lock and then the shared named-session presentation lock. Inside both locks it takes one exact snapshot, requires one unambiguous non-target focus and the exact title, token, tab, and pane shape, positively confirms no registered agent, and reads Herdr's process information for the exact named-session pane. The process proof requires one recognized idle shell as both the shell process and the sole foreground process-group member, an operating-system process-table row for that shell, no child process, and a sleeping or idle shell state. +The proof retries strict single samples for a bounded settle window because an idle interactive shell transiently hosts short-lived prompt helpers; a genuinely busy pane fails every sample. Any foreground command, child process, active shell job, unknown shell, unreadable process table, missing field, or API error preserves the pane. Firstmate immediately revalidates the same journal, metadata absence, workspace title and token uniqueness, one-tab and one-pane topology, exact pane relationship, absent agent, process proof, and non-target focus before calling the existing exact-pane focus-preserving close helper. It closes only that pane, never a workspace. @@ -124,7 +150,8 @@ Operational compromises: `tests/fm-backend-herdr-presentation-e2e.test.sh` covers multi-home ordering, concurrency, lock contention, legacy coexistence, focus preservation, exact same-identity restart replacement, ambiguous bindings and tokens, and exact-pane cleanup through the guarded lab path. `tests/fm-herdr-session-cleanup.test.sh` covers every discovery, ownership, topology, process, locking, revalidation, focus, retirement, and continue-on-error boundary. -`tests/fm-herdr-session-cleanup-e2e.test.sh` covers the restored-shell cleanup in a guarded non-default named lab; [`verification/runtime-backends.md`](verification/runtime-backends.md#per-home-and-presentation-topology) owns the active versioned evidence. +`tests/fm-herdr-session-cleanup-e2e.test.sh` covers the restored-shell cleanup in a guarded non-default named lab. +`tests/fm-backend-herdr-focus-flash-e2e.test.sh` reproduces the raw explicit-close focus steal on the installed release and proves the focus-safe emptying-close plan removes a doomed workspace with no wrong-focus interval; [`verification/runtime-backends.md`](verification/runtime-backends.md#workspace-removal-focus-safety) owns the active versioned evidence. ## Default-tab prune safety @@ -176,7 +203,7 @@ The capture owner requests at least 200 lines from Herdr and trims locally to th This generous floor is required for small composer and peek reads. Herdr's native agent state can read idle while a harness waits on its own long foreground tool. -The shared crew-state path therefore corroborates every native non-busy or unreadable result with the recorded harness's rendered busy signature before concluding that a pane is not working. +The shared crew-state path therefore accepts a native `busy` as evidence of activity but never a native `idle` as evidence that a worker has stopped; the task's own semantic busy state (`bin/fm-busy-lib.sh`) decides that. A human-blocked permission dialog has no busy banner and still surfaces. ## Composer and injection safety @@ -259,7 +286,8 @@ Tests use thin compatibility wrappers in `tests/herdr-test-safety.sh` and never - Herdr remains experimental. - Presentation ordering needs protocol 16 and Python and is best-effort only. -- Mutable labels can collide; they are never destructive authority. +- Mutable labels can collide; they are never placement or destructive authority. +- A Firstmate outside Herdr cannot resolve a launcher workspace, so a colliding home label refuses new spawns until the collision is cleared. - Ghost and placeholder recognition depends on ANSI de-emphasis and fails safely to pending when unavailable. - Mid-session secondmate liveness is not implemented. - OpenCode 1.18.4 can accept Enter while busy without clearing the composer. @@ -274,6 +302,7 @@ tests/fm-backend-herdr-smoke.test.sh tests/fm-backend-herdr-prune-safety-e2e.test.sh tests/fm-backend-herdr-respawn-idem-e2e.test.sh tests/fm-backend-herdr-workspace-per-home-e2e.test.sh +tests/fm-backend-herdr-launcher-workspace-e2e.test.sh tests/fm-backend-herdr-presentation-e2e.test.sh tests/fm-backend-herdr-eventwait-smoke.test.sh tests/fm-herdr-session-cleanup.test.sh diff --git a/docs/orca-backend.md b/docs/orca-backend.md index 9812993e83..42b9815cec 100644 --- a/docs/orca-backend.md +++ b/docs/orca-backend.md @@ -51,7 +51,8 @@ Exact command flags and response parsing are owned by `bin/backends/orca.sh` and `fm-peek.sh` reads with `orca terminal read`. `fm-send.sh` types and verifies composer clearance, follows `oldestCursor` when Orca returns a limited page, and retries Enter without retyping when a slash popup first fills an argument placeholder. A bare shell row is `unknown`, not an empty agent composer. -The watcher has no native Orca busy signal and uses the shared terminal-tail fallback. +The watcher has no native Orca busy signal, so each harness adapter's semantic lifecycle supplies worker state. +Grok alone retains its isolated rendered-tail fallback. Cleanup keeps all shared Firstmate safety checks. A scout still requires its report and completed decision inventory. diff --git a/docs/scripts.md b/docs/scripts.md index 88482ae589..eb9088c904 100644 --- a/docs/scripts.md +++ b/docs/scripts.md @@ -63,7 +63,7 @@ The shared no-mistakes gate refusal for fleet lifecycle entrypoints is summarize | `fm-afk-launch.sh` | Own away-mode entry, exit, rollback, and any backend terminal lifecycle | | `fm-afk-return.sh` | Own deterministic return shutdown, catch-up evidence, and the firstmate-actionable blocker gate | | `fm-supervisor-target-lib.sh` | Resolve the shared supervisor target and backend for the daemon and launcher | -| `fm-supervise-daemon.sh` | Presence-gated away-mode sub-supervisor: self-handle routine wakes, escalate batched digests, alert on failed delivery | +| `fm-supervise-daemon.sh` | Presence-gated away-mode sub-supervisor: self-handle routine wakes, guard injection by the detected primary harness, escalate batched digests, alert on failed delivery | | `fm-crew-state.sh` | Print one deterministic current-state line for a crew | | `fm-tangle-lib.sh` | Shared default-branch resolution and primary-checkout tangle classification | | `fm-supervision-lib.sh` | Shared in-flight-work-without-fresh-watcher-beacon predicate | @@ -71,11 +71,15 @@ The shared no-mistakes gate refusal for fleet lifecycle entrypoints is summarize | `fm-lock-lib.sh` | Shared "is this git lock provably abandoned?" proof used by teardown and fleet-sync | | `fm-config-inherit-lib.sh` | Shared primary-to-secondmate inherited local-material propagation and config-reread delivery | | `fm-tasks-axi-lib.sh` | Shared backlog-backend selector and `tasks-axi` compatibility probe | +| `fm-quota-axi-lib.sh` | Shared `quota-axi` compatibility floor for the bootstrap diagnostic | +| `fm-vendor-auth-probe.sh`| Run one hard-bounded, non-destructive authentication probe of a named vendor CLI and report the fact | | `fm-wake-drain.sh` | Atomically drain queued watcher wakes, emit bounded best-effort status-event annotations, then assert watcher liveness | | `fm-wake-lib.sh` | Shared durable wake queue, portable locks, and watcher identity/health helpers | | `fm-classify-lib.sh` | Shared captain-relevant and declared-external-wait wake classification vocabulary | | `fm-send.sh` | Send one verified literal line or supported key through the target's recorded backend | -| `fm-tmux-lib.sh` | Shared tmux pane primitives for busy detection, composer capture, and verified submit | +| `fm-busy-lib.sh` | Single owner of the semantic busy-state contract: verdicts, source attribution, and per-harness sources | +| `fm-busy-event.sh` | The only writer of a task's semantic busy-state record; arms an incarnation and applies lifecycle events | +| `fm-tmux-lib.sh` | Shared tmux pane primitives for composer capture, verified submit, and the submit-time busy check | | `fm-peek.sh` | Print a bounded tail of a crewmate endpoint | | `fm-check-register.sh` | Bind an intentional custom watcher check to its current bytes | | `fm-check-lib.sh` | Validate custom-check registrations and prepare private execution snapshots | @@ -94,3 +98,6 @@ The shared no-mistakes gate refusal for fleet lifecycle entrypoints is summarize | `fm-x-dismiss.sh` | Dismiss a skipped X-mode mention at the relay without replying | | `fm-x-link.sh` | Link a spawned task to its originating X-mode mention in task meta | | `fm-x-followup.sh` | Detect, post, and cap completion follow-ups for an X-mode-linked task | +| `fm-public-followup-lib.sh` | Shared relay-activation gate, O(1) presence checks, and private transport paths for promised public replies | +| `fm-public-followup.sh` | Reconcile typed terminal work results into a public commitment and deliver its final reply once | +| `fm-public-followup-emit.sh` | Report one typed terminal work result into the home that owes the public reply | diff --git a/docs/sessionstart-nudge.md b/docs/sessionstart-nudge.md index ef21cea132..c39c814925 100644 --- a/docs/sessionstart-nudge.md +++ b/docs/sessionstart-nudge.md @@ -12,7 +12,7 @@ It sources `bin/fm-gate-refuse-lib.sh` and stays silent for a no-mistakes gate a It shares `bin/fm-primary-scope-lib.sh` with `bin/fm-turnend-guard.sh`, so the hooks use one primary-detection owner. The Shared Predicate section of [`turnend-guard.md`](turnend-guard.md#shared-predicate) owns marker validation, plain-checkout detection, and required Firstmate-shaped paths. -Before printing, the wrapper reads `state/.lock` and walks at most eight parents from its own pid, matching `bin/fm-lock.sh` and Pi's `lockOwnership()` ancestry depth. +Before printing, the wrapper reads `state/.lock` and walks at most eight parents from its own pid in its own separate, hard-coded loop, independent of `bin/fm-lock.sh`'s ancestry walk (`fm_harness_ancestry_pid()` in `bin/fm-session-lock-lib.sh`, which now walks up to sixteen parents and can extend past a claude-named match to a still-more-ancestral one) and of Pi's `lockOwnership()`. If the lock names a live pid in that ancestry, session start already ran in this harness session and the wrapper stays silent. Every path exits 0, including malformed state and adapter errors, because a Claude SessionStart exit 2 blocks session initialization. @@ -36,8 +36,6 @@ That alternative expands trust and writes outside this repository, so Firstmate `tests/fm-sessionstart-nudge.test.sh` proves wrapper silence for both gate signals, an unmarked linked worktree, a missing state directory, and an already-owned lock. It proves exact U+2063 `FIRSTMATE_OP:`-prefixed, `session-start`-typed one-line output for a plain primary and a marked linked secondmate primary. -It also verifies every tracked transport registration listed above. -`tests/fm-captain-translation-contract.test.sh` proves Ahoy's current marker rule, narrow legacy compatibility exclusions, genuine captain-message near misses, and the shared marker on supported user-role operational injections. `tests/fm-pi-primary-live-e2e.test.sh` and `tests/fm-opencode-primary-live-e2e.test.sh` exercise native startup paths with first-message and later-message Ahoy regressions. `tests/fm-turnend-guard.test.sh`, `tests/fm-pi-watch-extension.test.sh`, and `tests/fm-daemon.test.sh` cover marked guard, monitoring, and away-mode delivery. diff --git a/docs/subagent-guard.md b/docs/subagent-guard.md index 87f194d9d1..47aaf10e0f 100644 --- a/docs/subagent-guard.md +++ b/docs/subagent-guard.md @@ -47,14 +47,22 @@ agent subagent task workflow cron schedul worktree delegate spawn dispatch handoff remote sendmessage monitor ``` -Two exclusions keep the shape test from producing false positives. +Three exclusions keep the shape test from producing false positives. - A name beginning `mcp__` is never classified. An MCP server chooses its own tool names, a task or agent noun there is common, and it has no bearing on fleet dispatch. -- The exact names `taskoutput`, `taskstop`, `taskget`, `tasklist`, `cronlist`, `bashoutput`, and `killshell` are allowed. +- `OBSERVE_ONLY_TOOLS`: the exact names `taskoutput`, `taskstop`, `taskget`, `tasklist`, `cronlist`, `bashoutput`, and `killshell` are allowed. These observe or stop work that already exists rather than creating it, and denying them at this layer could strand already-running work with no way to inspect or end it. A Claude primary's optional local deny list may still remove them from the schema. The shipped guard stays narrower on purpose so it can never be the reason a runaway task cannot be stopped. +- `PLAN_ONLY_TOOLS`: the exact names `taskcreate` and `taskupdate` are allowed. + These write, which is why they are a separate list rather than more entries in the observe-or-stop one, but what they write is the harness's session-local todo list. + That list has no executor: it spawns no agent, allocates no worktree, registers no schedule, and starts nothing that could outlive the session or escape a firstmate guard. + So it is not the "work, agent, schedule, or isolated workspace that firstmate would not know about" the guard exists to stop, and the stem match on `task` is a false positive rather than a policy. + The cost of the false positive was concrete: the primary could not track its own plan, and the deny text told it to run `bin/fm-brief.sh` and `bin/fm-spawn.sh` to create a todo entry. + +Both exclusion lists match the whole normalized name, never a substring, so neither can widen by accident: `TaskCreateAgent` and `RemoteTaskCreate` stay denied. +Folding the two lists together would be the drift risk, because the observe-or-stop rationale is not true of a tool that writes. The shipped guard fires on every delegation-shaped name that reaches it, including future names that no deny list knows about yet. That future-name behavior is the reason the tracked matcher must match all tools and let the script filter. @@ -79,10 +87,8 @@ Claude primaries should add this deny list in untracked per-home local settings, "CronCreate", "CronDelete", "CronList", - "TaskCreate", "TaskGet", "TaskList", - "TaskUpdate", "TaskStop", "TaskOutput" ] @@ -103,8 +109,11 @@ It is not tracked for two reasons. The width of the list remains a captain-owned decision, because denying some of these changes how the captain works with the primary session. Keep it as one flat local array that is reviewable at a glance and narrowable in one line. -In particular `TaskOutput`, `TaskStop`, `TaskGet`, `TaskList`, and `CronList` only observe or stop work that already exists, but the recommended local deny list still removes them by default. -The hook deliberately allows those names, so the shipped guard can never strand a runaway task with no way to inspect or end it. +In particular `TaskOutput`, `TaskStop`, `TaskGet`, `TaskList`, and `CronList` only observe or stop work that already exists, yet the recommended local deny list still removes all five by default. +The hook deliberately allows those five, so the shipped guard can never strand a runaway task with no way to inspect or end it, and it allows `TaskCreate` and `TaskUpdate` too, so it can never be the reason the primary cannot track its own plan. +The two session-local todo tools are no longer recommended for local denial at all, because they write only the harness's session-local todo list, which has no executor and spawns nothing, so removing them from the schema removes no delegation power. +Denying them there would instead reproduce at a stronger layer the exact false positive the shipped guard now avoids, leaving anyone who adopts this list verbatim unable to let a primary track its own plan. +Narrowing the list further, including the five observe-or-stop names, is the captain's call, and this local list is the only layer that can remove a todo tool from the primary's schema. `permissions.allow` is a pre-approval list, not an availability list, so there is no fail-closed positive allowlist available. That is why any fixed deny list is fail-open against future tools and why the shape-based guard still exists. @@ -171,7 +180,7 @@ Applicability turns on one question: does the harness expose built-in delegation | Harness | Delegation surface | Status | | --- | --- | --- | -| Claude | 18 known tools, listed above | Scoped guard wired and live-verified; untracked local deny list verified and recommended. | +| Claude | 16 known tools, listed above | Scoped guard wired and live-verified; untracked local deny list verified and recommended. | | Codex | none | Not applicable, verified empirically below. Codex 0.144.1 exposes no subagent, sub-task, or delegated-agent tool, so there is nothing to remove or intercept. `.codex/hooks.json` is unchanged. | | Grok | present, exact tokens unconfirmed | Not wired pending live verification. See below. | | OpenCode | present, exact tokens unconfirmed | Not wired pending live verification. See below. | @@ -285,8 +294,8 @@ This distinction matters when reading the next result: a tool absent from a plai ### Local deny-list hardening -Run in a scratch firstmate-shaped project containing `AGENTS.md`, `state/`, a full copy of `bin/`, and a Claude settings file containing the recommended local deny-list JSON above. -The result validates the recommended local deny-list JSON above, not tracked repo state. +Run in a scratch firstmate-shaped project containing `AGENTS.md`, `state/`, a full copy of `bin/`, and a Claude settings file containing the local deny list exactly as recommended on that date, which was the 18-name form that still included `TaskCreate` and `TaskUpdate`. +The result validates that local deny list rather than tracked repo state, and the recommendation above has since dropped those two session-local todo tools. Asking for deferred entries explicitly returned: ```text @@ -344,7 +353,7 @@ The live consequence is confirmed by the shipped-guard result above: Claude hono ## Automated validation `tests/fm-subagent-pretool-check.test.sh` owns the acceptance matrix and is registered in the `pure-contract-unit` family in `bin/fm-test-run.sh`. -It covers the tracked Claude settings boundary that forbids a `permissions` key; the match-all Claude hook registration; denial of every work-creating delegation tool by shape; denial of twelve hypothetical future tool names that appear on no list; the observe-or-stop and MCP exclusions; the scout-present and scout-absent message variants; the escape hatch including its fail-closed values; inertness in a linked task worktree and in a non-firstmate repo; in-scope enforcement for a marked secondmate home; both stdin transports; the empty-stdout requirement; fail-open transport behavior; and the preserved `Bash` seatbelts and `Stop` guard. +It covers the tracked Claude settings boundary that forbids a `permissions` key; the match-all Claude hook registration; denial of every work-creating delegation tool by shape; denial of twelve hypothetical future tool names that appear on no list; the observe-or-stop, plan-only, and MCP exclusions; the exactness of the plan-only exclusion against six near-miss names a substring or shorter-stem widening would release; the scout-present and scout-absent message variants; the escape hatch including its fail-closed values; inertness in a linked task worktree and in a non-firstmate repo; in-scope enforcement for a marked secondmate home; both stdin transports; the empty-stdout requirement; fail-open transport behavior; and the preserved `Bash` seatbelts and `Stop` guard. Run: diff --git a/docs/supervision-protocols/grok.md b/docs/supervision-protocols/grok.md index a250edd205..22444b2bd7 100644 --- a/docs/supervision-protocols/grok.md +++ b/docs/supervision-protocols/grok.md @@ -30,10 +30,9 @@ When you see a background-task-completed system reminder for the arm: Re-arm attaches to an existing healthy cycle when one is already present and follows its verified successor chain. See [`watcher-continuity.md`](../watcher-continuity.md) for the arm-layer successor and clean-close failure contract. -Grok Stop hooks are passive. -The primary project hook runs `bin/fm-turnend-guard-grok.sh`, which forces at most one same-session follow-up via `grok --resume` when a turn would end blind. -That is a backstop, not the normal wake path. -After any forced follow-up, arm the watcher with the background protocol above. +The primary project Stop hook runs `bin/fm-turnend-guard-grok.sh` as a backstop, not the normal wake path. +[`turnend-guard.md`](../turnend-guard.md) owns its running-payload capability selection between native same-process blocking and the pre-native bounded resume fallback. +After any forced continuation, arm the watcher with the background protocol above. Interactive TUI primary sessions are the supported supervision host. Headless `grok -p` may wait for background process exit but does not reliably surface full auto-wake model output; do not run the primary firstmate as a one-shot headless process. diff --git a/docs/tmux-backend.md b/docs/tmux-backend.md index 3bf20fe9d1..bc6f471380 100644 --- a/docs/tmux-backend.md +++ b/docs/tmux-backend.md @@ -59,9 +59,12 @@ Unreadable, incomplete, or structurally ambiguous boxes fail closed, and panes w The shared classifier accepts a shell glyph as an empty agent composer only inside a verified bordered composer. A bare shell prompt is `unknown`, so away-mode escalation is never injected into a dead shell. -Rendered busy detection is also harness-scoped. -Task metadata selects only that harness's verified signature, so output from one harness cannot make another harness appear busy. -The exact selection contract and safety rationale live in [architecture](architecture.md#runtime-session-backends), while the signatures live in [the harness-adapters skill](../.agents/skills/harness-adapters/SKILL.md). +Task busy state is normally not read from rendered text on this backend. +A task's busy, idle, unknown, or dead verdict comes from the busy-state contract owned by `bin/fm-busy-lib.sh`; [architecture](architecture.md#busy-state-is-semantic-per-adapter) owns its boundaries. +The contract retains two harness-scoped rendered exceptions: Grok's isolated fallback and the experimental AGY adapter's live-verified explicit busy and idle pane signatures. +Neither exception can classify another harness, and an inconclusive AGY capture is unknown rather than idle. +The submit acknowledgement and away-mode supervisor-pane busy guard below still consult rendered output, but only to decide whether input can be delivered, never to decide recorded task state. +The supervisor guard selects only the detected primary harness's signature rather than a global union of vendor patterns. `bin/fm-tmux-lib.sh` owns exact type-and-submit mechanics. It types a message once and retries Enter only until the composer clears. diff --git a/docs/turnend-guard.md b/docs/turnend-guard.md index 30690bb887..8ee750de39 100644 --- a/docs/turnend-guard.md +++ b/docs/turnend-guard.md @@ -42,8 +42,8 @@ If `jq` is missing or hook stdin is empty, the guard exits 0 because it cannot s - Codex registers a `Stop` hook in `.codex/hooks.json`, anchors the executable to the hook process working directory, verifies a Firstmate-shaped hook-bearing root, and passes the original payload to the shared guard. - OpenCode listens for `session.idle` in `.opencode/plugins/fm-primary-turnend-guard.js`, lets the watcher coordinator act first, and calls `client.session.promptAsync` once when the guard returns 2. - Pi listens for `agent_settled` in `.pi/extensions/fm-primary-turnend-guard.ts`, runs once per logical agent run, and calls `pi.sendUserMessage(..., { deliverAs: "followUp" })` once when the guard returns 2. -- Grok registers a `Stop` hook in `.grok/hooks/fm-primary-turnend-guard.json` and uses `bin/fm-turnend-guard-grok.sh` to resume the reported session once when the shared guard returns 2. - The adapter intentionally omits `--permission-mode`, so a passive hook cannot grant stronger permissions than the resumed session default. +- Grok registers a `Stop` hook in `.grok/hooks/fm-primary-turnend-guard.json` and delegates capability selection to `bin/fm-turnend-guard-grok.sh`. + The tracked Claude Stop entries are inert when `GROK_AGENT` is present, so Grok's Claude-compatible settings loading cannot create a second continuation path. Claude and Codex can block a Stop directly with exit status 2 and stderr. Both payloads carry `stop_hook_active`. @@ -55,15 +55,22 @@ The Claude mode waits up to `FM_CLAUDE_AUTOARM_SYNC_WAIT_MS` (default 800 millis When none of those proofs appears, it re-blocks up to `FM_CLAUDE_TURNEND_BLOCK_BUDGET` times (default 3, below Claude's 8-block override), then allows degraded with a visible `systemMessage`. Any allow resets the budget. -OpenCode, Pi, pi-signed, and Grok expose passive callbacks for this purpose. +OpenCode, Pi, and pi-signed expose passive callbacks for this purpose. Their adapters fail open at the hook boundary to protect the user session but schedule one bounded follow-up when the predicate blocks. The generated prompts use the canonical `turn-end-guard` kind after the U+2063 `FIRSTMATE_OP: ` prefix, so Ahoy does not treat them as captain messages. -Each adapter owns a loop latch. +Each passive adapter owns a loop latch. Pi keeps the latch across internal tool turns and clears it only when the generated follow-up settles or delivery fails. -Grok's project hook requires the checkout to be trusted with `/hooks-trust` or launch-time `--trust`. OpenCode's forced follow-up is supported for persistent TUI sessions and remains fail-open in headless `opencode run`. -If a passive adapter cannot invoke its SDK, find `grok`, or recover a Grok session id, the next pull-based `fm-guard.sh` call reports the problem. +Grok makes exactly one typed capability decision from each running Stop payload. +A boolean `stopHookActive` selects native blocking, including both false on the initial stop and true on the bounded continuation. +The camel-case field has precedence when both spellings appear; when it is absent, a boolean `stop_hook_active` selects the same native path for compatibility. +The native path returns the shared guard's status and stderr to the same Grok process and never starts `grok --resume`. +When both capability spellings are absent, the adapter preserves one pre-native `grok --resume` fallback guarded by `GROK_TURNEND_GUARD_ACTIVE` and intentionally omits `--permission-mode`. +Malformed JSON, a selected field with a non-boolean type, missing `jq`, missing hook prerequisites, or an already-active legacy guard allows the stop without starting either continuation path. +Grok's project hook requires the checkout to be trusted with `/hooks-trust` or launch-time `--trust`; genuine pre-native builds can run the same tracked hook from an isolated global hook directory. + +If a passive adapter cannot invoke its SDK, or the Grok legacy fallback cannot find `grok` or a session id, the next pull-based `fm-guard.sh` call reports the problem. That warning uses `bin/fm-supervision-instructions.sh --repair-line`, so it always points to the active harness protocol rather than embedding another repair command. ## Compatibility limits @@ -83,7 +90,7 @@ That warning uses `bin/fm-supervision-instructions.sh --repair-line`, so it alwa ## Regression coverage -`tests/fm-turnend-guard.test.sh` covers the predicate, main and secondmate primary scope, child-worktree exclusion, `FM_HOME` and `FM_STATE_OVERRIDE` precedence, the cooperative `--claude` claim wait, epoch allow, re-block budget, Pi logical-run latching, missing-`jq` behavior, all five primary registrations, and Grok resume permission and recursion safety. +`tests/fm-turnend-guard.test.sh` covers the predicate, main and secondmate primary scope, child-worktree exclusion, `FM_HOME` and `FM_STATE_OVERRIDE` precedence, the cooperative `--claude` claim wait, epoch allow, re-block budget, Pi logical-run latching, missing-`jq` behavior, all five primary registrations, Grok native and legacy selection, typed field precedence, malformed input, and exactly-one-path safety. `tests/fm-kimi-harness.test.sh` covers the separate Kimi crew hook's format preservation, idempotence, refusal cases, token guard, spawn registration, and teardown cleanup. `tests/fm-supervision-instructions.test.sh` covers recovery-line ownership and pi-signed's identity-preserving reuse of Pi's protocol. `FM_PI_LIVE_E2E=1 tests/fm-pi-primary-live-e2e.test.sh` is the opt-in isolated Pi path. diff --git a/docs/verification/dispatch-auth.md b/docs/verification/dispatch-auth.md new file mode 100644 index 0000000000..86b9f4795d --- /dev/null +++ b/docs/verification/dispatch-auth.md @@ -0,0 +1,178 @@ +# Dispatch authentication verification + +Audience: maintainer verification. + +This record supports the dispatch judgment rules in `.agents/skills/quota-array-dispatch/SKILL.md` and the bounded vendor probe in `bin/fm-vendor-auth-probe.sh`. +It records only facts that must be re-established when a producer or vendor version changes. +Task chronology, incident transcripts, and credential metadata stay in private reports or PR evidence. + +Firstmate resolves a candidate's provider family, credential surface, and applicable quota by reading the evidence below and reasoning in the open. +No script maps a model to a provider, a provider to a credential store, or a name prefix to a family, so the facts here are what that reasoning rests on. +Credential paths below are shown with the home directory replaced by `<home>`. + +## Quota granularity the judgment depends on + +Verified 2026-07-30 against quota-axi 0.1.16. + +`quota-axi --json` reports availability at whatever granularity the vendor supplies, and states the vendor's own bounding rule in `quotaSemantics.description`. + +```json +{ + "provider": "codex", + "state": { "status": "fresh", "stale": false }, + "quotaSemantics": { + "status": "known", + "description": "Codex base account windows bound every model. Named model windows add bounds for that model; code-review windows describe a separate workload and are not included in model availability.", + "effectiveAvailability": [ + { "scope": "all_models", "status": "known", "effectivePercentRemaining": 64, "boundedBy": ["weekly"] }, + { "scope": "model:codex_bengalfox", "status": "known", "effectivePercentRemaining": 64, "boundedBy": ["weekly", "model:codex_bengalfox:7d"] } + ] + } +} +``` + +Three properties follow and are load-bearing for dispatch: + +- An `all_models` (or `all_products`) scope is real evidence for every model in that provider family, including a model with no window of its own. +- A `model:`-scoped entry is an additional bound for that one model. `model:codex_bengalfox` is the GPT-5.3-Codex-Spark window and bounds nothing else. +- A named-model window can be tighter than the account bound, so it must not be read across models. In the same snapshot Claude reported `all_models` with `effectivePercentRemaining` 10 while `model:fable` reported 4, limited by the `model:fable` window itself. A non-Fable Claude model reads 10, not 4. + +`quotaSemantics.status` is `unknown` with no `effectiveAvailability` entries at all for providers whose vendor exposes no window (observed for `cursor` and `copilot`). +`state.authStatus` is present only for some providers (observed for `grok` alone), so its absence is missing evidence, not a credential fault. + +## Completion-runway shape the judgment depends on + +Verified 2026-07-31 against quota-axi 0.1.17 schema 3. +The command below records the producer shape without persisting account-specific quota values: + +```sh +quota-axi --json | jq '{schemaVersion, effectiveAvailabilityFields: ([.providers[]?.quotaSemantics.effectiveAvailability[]? | keys] | unique), runwayFields: ([.providers[]?.quotaSemantics.effectiveAvailability[]?.runway? | select(type == "object") | keys] | unique)}' +``` + +```json +{ + "schemaVersion": 3, + "effectiveAvailabilityFields": [ + [ + "boundedBy", + "effectivePercentRemaining", + "limitingWindowIds", + "pace", + "runway", + "scope", + "status" + ] + ], + "runwayFields": [ + [ + "limitingWindowId", + "projectedExhaustedAt", + "projectionBasis", + "projectionConfidence", + "status", + "usableRunwaySeconds" + ], + [ + "limitingWindowId", + "projectedExhaustedAt", + "status", + "usableRunwaySeconds" + ] + ] +} +``` + +`runway` is nested under each effective-availability scope, so the same provider/model applicability rules govern both effective headroom and runway. +Projection confidence and basis are not present on every known runway, so selection must preserve their absence as uncertainty rather than fabricate them. +The older-schema fallback contract is owned by `quota-array-dispatch`; this evidence does not reinterpret an absent runway or pace field. + +## Provider-family counterfactual that this producer schema supports + +Verified 2026-07-30 on Pi 0.82.0 and quota-axi 0.1.16. + +```sh +pi --list-models terra +``` + +```text +provider model context max-out thinking images +openai-codex gpt-5.6-terra 272K 128K yes yes +``` + +The Pi catalog is authoritative for Pi model support and reports the provider family in its own column. +For `harness=pi`, `model=openai-codex/gpt-5.6-terra` the catalog establishes the model is supported and belongs to the `openai-codex` family, and the Codex `all_models` scope above supplies fresh, known 64 effective remaining for every model in that family. +No Terra-specific window exists in the snapshot, and `quota-axi auth --json` lists no `pi:openai-codex` source. +Both absences are missing model-level and source-level detail, not contradictory evidence, so this candidate is dispatchable with the model-level uncertainty disclosed. + +```sh +pi --list-models gpt-9.9-nonexistent +``` + +```text +No models matching "gpt-9.9-nonexistent" +``` + +A listing that reaches the account and returns no row is the authoritative negative that does block a candidate. + +## Credential sources are independent per provider + +Verified 2026-07-30 against quota-axi 0.1.16. + +`quota-axi auth --json` reports each provider's credential sources separately, which is what lets a candidate be scoped to the one surface it actually authenticates through: + +```json +[ + { "provider": "claude", "sources": [ + { "source": "oauth-file", "path": "<home>/.claude/.credentials.json", "status": "missing" }, + { "source": "keychain", "status": "available" } ] }, + { "provider": "codex", "sources": [ + { "source": "auth-json", "path": "<home>/.codex/auth.json", "status": "available" }, + { "source": "cli-rpc", "path": "<path-to>/codex", "status": "available" } ] }, + { "provider": "grok", "sources": [ + { "source": "auth-json", "path": "<home>/.grok/auth.json", "status": "available" }, + { "source": "pi:xai", "status": "available" } ] }, + { "provider": "kimi", "sources": [ + { "source": "pi:kimi-coding", "status": "available" }, + { "source": "kimi-code-cli", "status": "expired", "error": "kimi_code_cli_credential_expired" } ] } +] +``` + +Observed source statuses are `available`, `expired` (with an `error` slug), and `missing`. + +- A provider can carry a healthy source beside a missing or expired one, so a provider must not be collapsed to a single status. Claude's `oauth-file` is missing while its keychain source is available, and Kimi's standalone CLI credential is expired while its Pi source is available. +- A `pi:`-prefixed source exists only where Pi holds its own credential for that family (`pi:xai`, `pi:kimi-coding`). Pi's `openai-codex` family has none, because it authenticates through the Codex store that the `codex` provider already lists. A missing `pi:` source is therefore never evidence against a Pi candidate. + +Neither this per-source shape nor `state.authStatus` exists before quota-axi 0.1.16. +`bin/fm-bootstrap.sh` enforces that floor through `bin/fm-quota-axi-lib.sh`. + +Grok also reports `credits.remaining: 0` alongside `percentRemaining: 41` on a healthy account. +That zero is a prepaid balance, not the subscription window, and is never headroom. + +## Standalone Grok discovery probe + +Verified 2026-07-30 on `grok 0.2.117 (f1c06093089f) [stable]`. + +```sh +grok --version +grok models # stdin closed, single attempt, hard-bounded +``` + +Observed: + +- `grok models` exits `0` and its first stdout line is `You are logged in with grok.com.` for an authenticated session. +- With a home directory holding no Grok credential, the first stdout line is `You are not authenticated.`, also with exit status `0`. +- Because the status is `0` in both cases, the exit status is not a verdict; only the literal first stdout line is examined, and a blank first line does not authenticate. +- `<home>/.grok/auth.json` was byte-identical across the authenticated run (`mtime`, `size`, and mode `0600` unchanged), so the probe is a read in that path. + +These discriminator strings are un-owned vendor UI text. +`bin/fm-vendor-auth-probe.sh` pins the verified version, reports `versionVerified=no` when the running CLI differs, and classifies any unrecognized first line as `indeterminate` rather than authenticated. +Re-run the two commands above and update this section and the pinned version together when the vendor CLI changes. + +## Regression coverage + +`tests/fm-vendor-auth-probe.test.sh` drives the real script against a fake vendor CLI that records every invocation's argv and anything readable on stdin. +It asserts that the script accepts no harness, model, or provider input, never calls `quota-axi`, exits alike for every probe result because it renders no verdict, invokes only the two fixed non-destructive argv forms with stdin closed, holds a real bound even when the configured bound is zero or malformed, and never echoes raw vendor output. +`tests/fm-spawn-dispatch-profile.test.sh` owns spawn's deterministic profile and harness refusals. +`tests/fm-bootstrap.test.sh` owns the quota-axi version-floor diagnostic. +`tests/fm-quota-array-dispatch-live-e2e.test.sh` drives the public Pi skill-loading interface against one fake `quota-axi --json` snapshot per case. +It covers the Claude 1 percent versus Codex 55 percent reserve regression, explicit accounting for unmeasurable runway, and the strongest-reasoning constraint. diff --git a/docs/verification/public-followup.md b/docs/verification/public-followup.md new file mode 100644 index 0000000000..48f9f6d39e --- /dev/null +++ b/docs/verification/public-followup.md @@ -0,0 +1,102 @@ +# Promised public reply verification + +Audience: maintainer verification. + +This record supports two active guarantees for promised public replies made through the myfirstmate relay: + +1. A promised final reply survives compaction and restart, reconciles from disk alone, and lands in the original thread exactly once. +2. A home that never opted into the relay pays nothing for any of it. + +[`docs/configuration.md`](../configuration.md#promised-public-replies-statepublic-followup) owns the operator-facing contract, [`docs/architecture.md`](../architecture.md#optional-x-mode) owns the mechanism boundary, and `tasks-axi public-followup --help` owns the typed obligation schema. +Task chronology and delivery evidence stay outside this record. + +## Environment + +Recorded 2026-07-30 on Darwin 25.5.0 (arm64) with GNU bash 5.3.9, tasks-axi 0.2.3, jq 1.8.1, and ShellCheck 0.11.0 (the version `bin/fm-lint.sh` pins). +The relay is a fakebin `curl` in every case, so no public post is ever made; `tasks-axi` and `jq` are the real tools, because stubbing the obligation state machine would verify nothing. + +## Restart end-to-end and regressions + +```sh +bash tests/fm-public-followup.test.sh +``` + +``` +ok - outcome text is collapsed to one line, bounded by codepoint, and never corrupts characters +ok - restart end-to-end: typed result reconciles from disk and delivers one reply to the original thread +ok - duplicate terminal results, restart replay, and repeated delivery are all no-ops +ok - wrong source, wrong work id, stale generation, malformed, unsupported deliverable, and forged identity are all refused +ok - a relay transport failure is held as retryable with no false completion, and the retry posts once +ok - a late success receipt closes the exact attempt with no second post, and a mismatched attempt is refused +ok - a delivery interrupted between post and receipt refuses to repost +ok - a child home reports typed results but can never become the outward-post owner +ok - the retained private request context keeps the original thread deliverable after inbox cleanup +ok - cleanup refuses while a public reply is owed and proceeds once it has landed +ok - a relay-disabled home runs no tasks-axi call, prints nothing, and gains no artifact +ok - a relay-enabled home with no commitments makes no backlog call and stays silent +ok - a relay-exhausted follow-up binding is escalated rather than retried into the thread +ok - the relay poll stays inert without a token, silent with no commitments, and surfaces a new result once +ok - startup surfaces unresolved public commitments only in a relay home that owes one +ok - typed public-followup records carry only public-safe summaries and deliverables +``` + +The first case is the end-to-end proof. +It reproduces the stranded state first (work bound, no reconciled terminal result, delivery refused with "still waiting on its bound work" and zero posts), then has a secondmate-shaped child report a typed `pr-merged` result, deletes the drained inbox payload, reconciles from disk, and asserts exactly one `connector/followup` call carrying the original `request_id`, a validated `posted` receipt, and a Done obligation. + +The existing X-mode suite is unchanged by this work: + +```sh +bash tests/fm-x-mode.test.sh | grep -c '^ok -' +``` + +``` +103 +``` + +## Relay-disabled zero overhead + +A home with no `.env` at all, a `tasks-axi` shim that logs every invocation, and a full session-start run: + +```sh +find "$HOME_DIR/state" | LC_ALL=C sort > state-before.txt +FAKE_TASKS_AXI_LOG=tasks-axi.log bin/fm-session-start.sh > session-start.out 2>&1 +find "$HOME_DIR/state" | LC_ALL=C sort > state-after.txt +grep -c 'public-followup' tasks-axi.log +grep -ci 'public commitment' session-start.out +diff state-before.txt state-after.txt | grep '^>' +``` + +``` +0 +0 +> <home>/state/.lock +> <home>/state/.pr-check-migration-scan-v1 +> <home>/state/.pr-check-migration-v1 +> <home>/state/.wake-queue +``` + +No `tasks-axi public-followup` invocation, no public-commitments output, and no `state/public-followup` directory. +The four created paths are session-start's pre-existing session lock, PR-check migration markers, and wake queue, none of which this work touches. + +The whole added cost in that home is the activation predicate, measured over 1000 in-process calls including loop overhead: + +```sh +. bin/fm-public-followup-lib.sh +for i in $(seq 1 1000); do fm_pf_relay_active "$HOME_DIR" || true; done +``` + +``` +total_ns=69694000 per_call_us=69 +``` + +Roughly 0.07 ms per session start, from a single `[ -f "$FM_HOME/.env" ]` test that returns false before anything else runs. + +## Compatibility axes reviewed + +Primary harnesses (`claude`, `codex`, `opencode`, `pi`, `pi-signed`, `grok`, `kimi`): not applicable after inspection. +Nothing here reads or renders harness-specific state. +The only supervision surfaces touched are the session-start digest, which `bin/fm-supervision-instructions.sh` already renders per harness without knowing this section exists, and the wake payload produced by the existing relay poll, which every harness protocol consumes identically. + +Runtime backends (tmux, herdr, zellij, orca, cmux): not applicable after inspection. +No command here reads `state/<id>.meta`'s backend fields, resolves an endpoint, or captures a pane. +The one lifecycle integration is `bin/fm-teardown.sh`'s refusal, which runs before any backend command and keys only on the task id, so it behaves identically on every backend. diff --git a/docs/verification/runtime-backends.md b/docs/verification/runtime-backends.md index a711d84ee5..84cc500746 100644 --- a/docs/verification/runtime-backends.md +++ b/docs/verification/runtime-backends.md @@ -89,10 +89,39 @@ tests/fm-tmux-submit-busy.test.sh Expected structural matrix: real text on any content row is pending; all-empty complete boxes are empty; unreadable, incomplete, or unsafe boxes are unknown; and non-bordered panes retain cursor-row compatibility. Expected submit matrix: proven pending plus busy is accepted as queued; proven pending plus idle remains pending; ambiguous pending is never converted by the busy exception; and only a proven empty composer succeeds directly. +### Cleanup endpoint identity + +The cleanup identity boundary was validated on 2026-07-28 with tmux 3.6a and metadata fixtures for every supported backend. + +```sh +tests/fm-teardown-endpoint-safety.test.sh +tests/fm-teardown.test.sh +tests/fm-backend-herdr.test.sh +tests/fm-backend-zellij.test.sh +tests/fm-backend-orca.test.sh +tests/fm-backend-cmux.test.sh +``` + +Bounded output from the incident regression: + +```text +ok - fm-teardown: missing, empty, malformed, ambiguous, and task-mismatched endpoints refuse before every mutation or runtime call +ok - cleanup identity: valid tmux, Herdr, Zellij, Orca, and cmux records validate while every empty backend target refuses +ok - tmux backend: direct empty target returns nonzero without invoking tmux +ok - process cleanup: creation-time PID identity removes only the exact child and preserves the control child +ok - fm-teardown: dedicated-socket invalid cleanup preserves target/control and valid cleanup removes only the exact target +``` + +The dedicated tmux cell removed ambient tmux variables, required a socket-bound wrapper, kept one target and one independent control window, and proved the wrapper was not called for invalid metadata or a direct empty target. +Valid cleanup removed only the exact task-bound target and left the control window live. +The metadata-only validation covers tmux, Herdr, Zellij, Orca, and cmux before backend dispatch. +Claude, Codex, OpenCode, Pi, pi-signed, Grok, and Kimi share that backend cleanup boundary; their harness-specific hook files and token cleanup run only after it, so no harness needs a separate endpoint parser. + ## Herdr The compatibility floor is protocol 14. -The latest active verification uses Herdr 0.7.5 protocol 16 on macOS aarch64, with earlier 0.7.4, protocol-14, and 0.7.3 evidence retained where they define current behavior or fallbacks. +The latest active verification uses Herdr 0.7.5 protocol 17 on macOS aarch64, with earlier 0.7.5 protocol-16, 0.7.4, protocol-14, and 0.7.3 evidence retained where they define current behavior or fallbacks. +Protocol 17 keeps every protocol-16 feature gate satisfied; the event and workspace-move floors remain 16. Core read-only probes: @@ -102,11 +131,11 @@ herdr status --json | jq -c '{client:.client.protocol,server:.server.protocol}' herdr api schema --json | jq -c '.schemas.subscription_event["$defs"].SubscriptionEventKind.enum' ``` -Observed current shapes: +Observed protocol-16 compatibility shapes: ```text herdr 0.7.5 -{"client":16,"server":16} +{"client":17,"server":17} ["pane.output_matched","pane.agent_status_changed","pane.scroll_changed"] ``` @@ -118,7 +147,7 @@ The CLI matrix was checked directly: | Literal send | `herdr pane send-text <pane> <text> --session <name>` | Left text unsubmitted until Enter. | | Keys | `herdr pane send-keys <pane> enter|escape|ctrl+c --session <name>` | Enter and Escape worked; Ctrl-C interrupted foreground work. | | Capture | `herdr pane read <pane> --source recent --lines N` | Small N could return empty below viewport height; a 200-line request plus local trim was stable. | -| Native state | `herdr agent get <pane>` | Working and done transitions were visible; long foreground tool waits required rendered-busy corroboration. | +| Native state | `herdr agent get <pane>` | Working and done transitions were visible; native `busy` remains positive activity evidence, while native `idle` cannot close a turn and the adapter's semantic lifecycle decides worker state. | | Restart | guarded named-session stop then start | Workspace, tab, pane, and labels persisted; the agent process and registration did not. | | Close | `herdr pane close <pane> --session <name>` | The exact one-pane task tab closed; closing a final tab could remove the workspace. | @@ -145,6 +174,63 @@ HERDR_LAB_HELPER=bin/fm-herdr-lab.sh \ Observed guarantee: a restored no-agent tab was replaced create-before-close, while a registered live agent caused refusal. +### Launcher workspace placement + +Herdr exports its pane identity into every process it manages, checked on 2026-07-30 against Herdr 0.7.5 protocol 17 inside a guarded lab pane: + +```sh +HERDR_LAB_HELPER=bin/fm-herdr-lab.sh +"$HERDR_LAB_HELPER" run "$LAB" pane run "$PANE" "sh -c 'env | grep ^HERDR | sort > /tmp/env.txt'" +``` + +```text +HERDR_ENV=1 +HERDR_PANE_ID=w1:p1 +HERDR_SESSION=fm-lab-fm-herdr-env-pro-65961-25535 +HERDR_SOCKET_PATH=/Users/kunchen/.config/herdr/sessions/fm-lab-fm-herdr-env-pro-65961-25535/herdr.sock +HERDR_TAB_ID=w1:t1 +HERDR_WORKSPACE_ID=w1 +``` + +This complete injection shape is verified only for Herdr 0.7.5. +Firstmate requires both `HERDR_PANE_ID` and `HERDR_SOCKET_PATH` before accepting claimed launcher ancestry. + +`pane get` reports the pane's current owning tab and workspace, which is what placement resolves from; the injected `HERDR_TAB_ID` and `HERDR_WORKSPACE_ID` are creation-time snapshots and are not read as current identity: + +```sh +"$HERDR_LAB_HELPER" run "$LAB" pane get w1:p1 | jq -c '.result.pane | {pane_id,tab_id,workspace_id}' +``` + +```text +{"pane_id":"w1:p1","tab_id":"w1:t1","workspace_id":"w1"} +``` + +Placement is owned by: + +```sh +HERDR_LAB_HELPER=bin/fm-herdr-lab.sh \ + tests/fm-backend-herdr-launcher-workspace-e2e.test.sh +``` + +Observed guarantees on 2026-07-30 against Herdr 0.7.5 protocol 17: + +```text +ok - real herdr E2E: with one 'firstmate' workspace and no herdr parent, a crewmate still lands in this home's own workspace without stealing focus +ok - real herdr E2E: the normal unique-label path is unchanged when the launcher's own pane identifies the workspace +ok - real herdr E2E: presentation spaces still create the isolated child workspace and bind it under the launcher's exact parent, without stealing focus +ok - real herdr E2E: with two 'firstmate' workspaces, a worker spawned from inside the second one lands in that exact workspace +ok - real herdr E2E: the duplicate-labeled sibling workspace is left entirely untouched and focus is preserved +ok - real herdr E2E: with a duplicated home label, a projected worker still hangs off the launcher's exact workspace and the sibling stays untouched +ok - real herdr E2E: an ambiguous home label with no launcher identity refuses before any worker endpoint exists +ok - real herdr E2E: a launcher pane that no longer exists refuses before any worker endpoint exists +ok - real herdr E2E: a secondmate launching its own worker gets the same exact-workspace guarantee, and its same-labeled sibling is untouched +ok - real herdr E2E: a --secondmate launch still stands up that secondmate's own workspace instead of inheriting the launcher's +ok - real herdr E2E: teardown closes only the worker's own pane and leaves the launcher, its workspace, and the same-labeled sibling intact +``` + +That suite's headline case runs `bin/fm-spawn.sh` inside a real Herdr pane, so the parent identity comes from Herdr's own injection rather than a composed environment. +Cross-session and contradictory bindings are covered deterministically in `tests/fm-backend-herdr.test.sh`, which can script a second server's socket without provisioning one. + ### Per-home and presentation topology Per-home behavior is owned by: @@ -202,6 +288,57 @@ HERDR_LAB_HELPER=bin/fm-herdr-lab.sh \ Observed guarantee: one exact home-local, journal-correlated, one-tab and one-pane childless idle shell was closed after restoration while the exact non-target focus and default fleet session remained unchanged, and a repeat run was a no-op. +### Workspace-removal focus safety + +The focus-flash regression ran on 2026-07-28 against Herdr 0.7.5 protocol 17 on macOS aarch64: + +```sh +HERDR_LAB_HELPER=bin/fm-herdr-lab.sh \ + tests/fm-backend-herdr-focus-flash-e2e.test.sh +``` + +Observed output: + +```text +ok - old path: the explicit last-pane close of a non-focused workspace stole focus (w3 w3:t1 -> w2 w2:t1) +ok - mitigation: every in-operation sample preserved exact focus while the doomed workspace was removed +ok - mitigation: no explicit close and no corrective focus were needed on the defective release +evidence: herdr=0.7.5 protocol=17 steal_live=1 default-session-tripwire=armed +``` + +Direct lab probes on the same day established the removal rules the emptying-close plan relies on, each verified with `workspace list` focus reads around one mutation in a guarded `fm-lab-` session: + +- An explicit `pane close` that emptied a non-focused workspace moved focus off the focused workspace in both before-focus and after-focus geometries. +- Ending a workspace's lone shell preserved the focused workspace exactly when the dying workspace sat behind it or the focused workspace was last, and moved focus to the focused workspace's right neighbor otherwise. +- The production focus-preserving close in the dangerous geometry repositioned the doomed workspace, ended its proved shell, and left every concurrent focus sample on the exact anchor with no corrective `tab focus` issued. + +Two real-hardware conditions were required for the pane-death path to engage and are now encoded in the adapter and its unit fixtures: BSD `ps` reports a login shell's `comm` as `-zsh`, and an idle shell transiently hosts a prompt helper (starship) as a second foreground process immediately after a `workspace.move` relayout, which the bounded settle window absorbs. + +The rules match the v0.7.5 tag source (`close_selected_workspace` reassigns focus from the closing workspace's index; `handle_pane_died` only clamps the stale focused index), and the upstream default branch resolves both paths by workspace id (PR #1877, commit `165dca45`, for the explicit close; PR #1912, commit `a979916`, for pane death), so the plan degrades to a harmless reorder-then-remove once a release carries them. + +The full projection and restored-shell suites were re-run the same day on the same version with the updated close path; the presentation suite completed with `real Herdr lab validation completed on Herdr 0.7.5 with the default-session tripwire intact`, and the restored-shell cleanup guarantee above was unchanged. + +The teardown-level record-retention gate was verified on 2026-07-28 with metadata fixtures and a live contending lock holder: + +```sh +tests/fm-teardown.test.sh +tests/fm-backend-herdr.test.sh +``` + +Observed guarantees: a contended presentation lock refused the teardown before the isolated copy was returned, with the task branch, every durable record, and the endpoint intact and no pane close attempted; the retry after the contention cleared returned the copy, closed the pane under the lock, and removed the records; an unknown structured-presence result after an attempted projected close retained the journal and every record with a nonzero exit; and every presence-gate mode accepted only a structured not-found as gone. + +The same fixtures verified three further boundaries on 2026-07-29: missing or malformed endpoint identity and an unparseable pane presence refused record removal with everything retained; the SIGKILL escalation re-read the exact pane's process information and refused to signal when a different shell pid owned the pane, falling back to the plain close with the original process untouched; and a reposition whose removal then failed on every path restored the exact original workspace order through a second verified move and reported the close as failed. + +The teardown fixture was re-run on 2026-07-31 after extending the same fail-closed boundary through forced secondmate cleanup, including recursive cleanup of a nested secondmate whose Herdr grandchild close remains unconfirmed. + +Observed output: + +```text +ok - forced secondmate teardown preflights every Herdr child before cleanup mutation +ok - forced secondmate teardown retains Herdr child identity until exact pane disappearance +ok - forced teardown retains a nested secondmate home and its grandchild's Herdr identity when the grandchild close is unconfirmed +``` + ### Composer and operational input Real captures verified these active distinctions: diff --git a/docs/verification/stow-memory.md b/docs/verification/stow-memory.md new file mode 100644 index 0000000000..39e61eac4f --- /dev/null +++ b/docs/verification/stow-memory.md @@ -0,0 +1,217 @@ +# Startup-memory `/stow` verification + +Audience: maintainer verification. + +This record supports the active bounded-memory and whole-file curation guarantees for Firstmate's internal `/stow` skill. +[`docs/configuration.md`](../configuration.md) owns the current operator-facing setting and estimate. +The internal skill owns curation and completion-receipt behavior. +Task chronology, fixture paths, and delivery evidence remain outside this record. + +## Synthetic real-agent pass + +The development-only real-agent pass ran on 2026-07-30 with Pi 0.82.0 on `openai-codex/gpt-5.6-terra` at medium thinking. +It used disposable primary and secondmate-shaped `FM_HOME` directories under the repository worktree only. +No live Firstmate memory, project data, credential content, or external system was placed in either fixture or prompt. +The following exact Bash shell body created the sanitized fixtures, invoked the model-qualified skill twice per home, and captured reports, hashes, and file modes: + +```bash +set -eu +VERIFY_ROOT=$(mktemp -d "$PWD/.stow-verification.XXXXXX") +RUNTIME_ROOT="$VERIFY_ROOT/runtime-root" +PRIMARY="$VERIFY_ROOT/primary" +SECONDMATE="$VERIFY_ROOT/secondmate" +SECONDMATE_ID=stow-verification +mkdir -p "$RUNTIME_ROOT" "$PRIMARY/config" "$PRIMARY/data" \ + "$SECONDMATE/bin" "$SECONDMATE/config" "$SECONDMATE/data" +printf '%s\n' 350 >"$PRIMARY/config/startup-memory-budget" +printf '%s\n' "$SECONDMATE_ID" >"$SECONDMATE/.fm-secondmate-home" +printf '%s\n' '# Synthetic Firstmate home' >"$SECONDMATE/AGENTS.md" + +file_mode() { + if [ "$(uname)" = Darwin ]; then + stat -f %Lp "$1" + else + stat -c %a "$1" + fi +} + +record_shared_state() { + label=$1 + path=$2 + printf '%s sha256=%s mode=%s\n' "$label" \ + "$(shasum -a 256 "$path" | awk '{print $1}')" \ + "$(file_mode "$path")" +} + +cat >"$PRIMARY/data/captain.md" <<'EOF' +# Captain + +## Current preferences + +- Prefer the simplest direct end-to-end operational path. +- Preserve unique current facts when compacting memory. +- Use plain dashes in prose. + +## Duplicate and superseded material + +- Prefer the simplest direct end-to-end operational path. +- Old policy: build a wrapper before every one-off operation. +- Old policy copy: always build a wrapper for one-off work. +- Stale tool path: `/opt/old-firstmate/bin/fm`. +- Stale release version: 0.41.0. +- Completed task: migrated the demo fixture on Monday. +- Completed task detail: checked the demo fixture again on Tuesday. +- Metric from the completed task: 47 records moved. +EOF + +cat >"$PRIMARY/data/captain-shared.md" <<'EOF' +# Shared captain preferences + +This file is main-authoritative in the main firstmate home. +In secondmate homes it is read-only in secondmate homes and must not be edited there. +Route new captain-preference discoveries to the main firstmate through marked status or a document pointer. + +- Never expose secrets or weaken an accepted safety boundary. +- Prefer the simplest direct end-to-end operational path. +- Superseded policy: secondmates may rewrite shared memory when convenient. +- Duplicate safety note: do not expose secrets. +EOF + +cat >"$PRIMARY/data/learnings.md" <<'EOF' +# Learnings + +- Stable fact: startup-memory configuration is documented in `docs/configuration.md`. +- Authoritative pointer: incident detail belongs in `data/reports/synthetic-incident.md`. +- Stable fact copy: consult `docs/configuration.md` for startup-memory configuration. +- Completed chronology: first the synthetic incident was detected, then triaged, then assigned. +- Completed chronology continued: a patch was drafted, reviewed, merged, and announced. +- Old metric: the discarded prototype used 812 estimated tokens. +- Stale path: the discarded prototype lived at `/tmp/old-memory-prototype`. +- Superseded alternative: maintain both a JSON memory database and Markdown files. +- Report-sized procedure: create a staging directory, enumerate every file, copy each file, compare every line, write a status ledger, notify all operators, archive the ledger, and repeat the entire sequence after every prompt. +EOF + +FM_HOME="$PRIMARY" bin/fm-startup-memory-budget.sh report \ + >"$VERIFY_ROOT/primary.before.report" +for file in captain.md captain-shared.md learnings.md; do + shasum -a 256 "$PRIMARY/data/$file" +done >"$VERIFY_ROOT/primary.before.sha256" + +FM_HOME="$PRIMARY" pi -p --no-session --no-extensions --no-context-files \ + --model openai-codex/gpt-5.6-terra --thinking medium \ + --skill .agents/skills/stow/SKILL.md \ + 'Invoke /stow now against only the disposable synthetic Firstmate home in $FM_HOME. There are no new session facts to file. Follow every requirement in the loaded stow skill. Run the repository-owned bin/fm-startup-memory-budget.sh report command, with the existing FM_HOME environment, before and after curation; that executable is the only permitted path outside $FM_HOME. Retain the exact before total, preserve the complete main-authoritative routing header in data/captain-shared.md, and make the completion receipt state the effective budget, exact before and after totals, an action for each of the three files, every exception, and reset safety. Inspect all three startup-memory files completely, preserve every unique current preference, authority or safety boundary, stable fact, and authoritative pointer, and consolidate the supplied duplicate, superseded, stale, chronological, metric, and report-sized material. Do not access or modify any other home, credential, project data, or external system.' \ + >"$VERIFY_ROOT/primary.pass1.out" +FM_HOME="$PRIMARY" bin/fm-startup-memory-budget.sh report \ + >"$VERIFY_ROOT/primary.after.report" +for file in captain.md captain-shared.md learnings.md; do + shasum -a 256 "$PRIMARY/data/$file" +done >"$VERIFY_ROOT/primary.after.sha256" + +FM_HOME="$PRIMARY" pi -p --no-session --no-extensions --no-context-files \ + --model openai-codex/gpt-5.6-terra --thinking medium \ + --skill .agents/skills/stow/SKILL.md \ + 'Invoke /stow now against only the disposable synthetic Firstmate home in $FM_HOME. There are no new session facts to file. Follow every requirement in the loaded stow skill. Run the repository-owned bin/fm-startup-memory-budget.sh report command, with the existing FM_HOME environment, before and after curation; that executable is the only permitted path outside $FM_HOME. Retain the exact before total, preserve the complete main-authoritative routing header in data/captain-shared.md, and make the completion receipt state the effective budget, exact before and after totals, an action for each of the three files, every exception, and reset safety. Inspect all three startup-memory files completely, preserve every unique current preference, authority or safety boundary, stable fact, and authoritative pointer, and consolidate the supplied duplicate, superseded, stale, chronological, metric, and report-sized material. Do not access or modify any other home, credential, project data, or external system.' \ + >"$VERIFY_ROOT/primary.pass2.out" +FM_HOME="$PRIMARY" bin/fm-startup-memory-budget.sh report \ + >"$VERIFY_ROOT/primary.repeat.report" +for file in captain.md captain-shared.md learnings.md; do + shasum -a 256 "$PRIMARY/data/$file" +done >"$VERIFY_ROOT/primary.repeat.sha256" + +cat >"$SECONDMATE/data/captain.md" <<'EOF' +# Secondmate captain memory + +- Current preference: report concrete blockers instead of guessing. +- Current preference copy: never guess when a concrete blocker can be reported. +- Shared overlap: never expose secrets. +- Superseded preference: silently infer missing configuration. +- Stale version: the fleet uses 0.41.0. +- Completed task: inspected the synthetic queue yesterday. +- Completed task detail: closed the synthetic queue inspection after 19 checks. +EOF + +cat >"$SECONDMATE/data/learnings.md" <<'EOF' +# Secondmate learnings + +- Unique current learning: inherited shared memory counts against the local total. +- Authoritative pointer: startup-memory behavior is documented in `docs/configuration.md`. +- Duplicate learning: include inherited shared memory in the local total. +- Stale path: `/tmp/secondmate-memory-v1`. +- Superseded alternative: copy shared facts into every local file. +- Completed chronology: opened the sample, measured it, discussed it, revised it, remeasured it, and closed it. +- Old metric: the sample once measured 604 estimated tokens. +- Report-sized procedure: take a snapshot, copy it to a ledger, annotate every old measurement, preserve every discarded alternative, append a timestamp, and repeat after each completed task. +EOF + +FM_ROOT="$RUNTIME_ROOT" +FM_HOME="$PRIMARY" +. bin/fm-ff-lib.sh +. bin/fm-config-inherit-lib.sh +validate_secondmate_home "$SECONDMATE_ID" "$SECONDMATE" +printf 'secondmate_validation=accepted id=%s home=%s\n' \ + "$SECONDMATE_ID" "$VALIDATED_HOME" >"$VERIFY_ROOT/inheritance.out" +FM_CONFIG_INHERIT_REPORT="$VERIFY_ROOT/inheritance.report" \ + propagate_secondmate_inheritance \ + "$PRIMARY" "$VALIDATED_HOME" "$PRIMARY/config" "$PRIMARY/data" +cat "$VERIFY_ROOT/inheritance.report" >>"$VERIFY_ROOT/inheritance.out" +cmp -s "$PRIMARY/data/captain-shared.md" \ + "$SECONDMATE/data/captain-shared.md" +record_shared_state inherited "$SECONDMATE/data/captain-shared.md" \ + >>"$VERIFY_ROOT/inheritance.out" + +FM_HOME="$SECONDMATE" bin/fm-startup-memory-budget.sh report \ + >"$VERIFY_ROOT/secondmate.before.report" +for file in captain.md captain-shared.md learnings.md; do + shasum -a 256 "$SECONDMATE/data/$file" +done >"$VERIFY_ROOT/secondmate.before.sha256" +record_shared_state before "$SECONDMATE/data/captain-shared.md" \ + >"$VERIFY_ROOT/secondmate.shared-state" + +FM_HOME="$SECONDMATE" pi -p --no-session --no-extensions --no-context-files \ + --model openai-codex/gpt-5.6-terra --thinking medium \ + --skill .agents/skills/stow/SKILL.md \ + 'Invoke /stow now against only the validated disposable synthetic secondmate home in $FM_HOME. There are no new session facts to file. Follow every requirement in the loaded stow skill. Run the repository-owned bin/fm-startup-memory-budget.sh report command, with the existing FM_HOME environment, before and after curation; that executable is the only permitted path outside $FM_HOME. Retain the exact before total, and make the completion receipt state the effective budget, exact before and after totals, an action for each of the three files, every exception, and reset safety. Inspect all three startup-memory files completely, keep data/captain-shared.md byte-identical and filesystem read-only because it was installed through primary-authoritative inheritance, preserve every unique current preference, stable learning, and authoritative pointer, and consolidate the supplied duplicate, superseded, stale, chronological, metric, overlap, and report-sized material in editable local memory. Do not access or modify any other home, credential, project data, or external system.' \ + >"$VERIFY_ROOT/secondmate.pass1.out" +FM_HOME="$SECONDMATE" bin/fm-startup-memory-budget.sh report \ + >"$VERIFY_ROOT/secondmate.after.report" +for file in captain.md captain-shared.md learnings.md; do + shasum -a 256 "$SECONDMATE/data/$file" +done >"$VERIFY_ROOT/secondmate.after.sha256" +record_shared_state after "$SECONDMATE/data/captain-shared.md" \ + >>"$VERIFY_ROOT/secondmate.shared-state" + +FM_HOME="$SECONDMATE" pi -p --no-session --no-extensions --no-context-files \ + --model openai-codex/gpt-5.6-terra --thinking medium \ + --skill .agents/skills/stow/SKILL.md \ + 'Invoke /stow now against only the validated disposable synthetic secondmate home in $FM_HOME. There are no new session facts to file. Follow every requirement in the loaded stow skill. Run the repository-owned bin/fm-startup-memory-budget.sh report command, with the existing FM_HOME environment, before and after curation; that executable is the only permitted path outside $FM_HOME. Retain the exact before total, and make the completion receipt state the effective budget, exact before and after totals, an action for each of the three files, every exception, and reset safety. Inspect all three startup-memory files completely, keep data/captain-shared.md byte-identical and filesystem read-only because it was installed through primary-authoritative inheritance, preserve every unique current preference, stable learning, and authoritative pointer, and consolidate the supplied duplicate, superseded, stale, chronological, metric, overlap, and report-sized material in editable local memory. Do not access or modify any other home, credential, project data, or external system.' \ + >"$VERIFY_ROOT/secondmate.pass2.out" +FM_HOME="$SECONDMATE" bin/fm-startup-memory-budget.sh report \ + >"$VERIFY_ROOT/secondmate.repeat.report" +for file in captain.md captain-shared.md learnings.md; do + shasum -a 256 "$SECONDMATE/data/$file" +done >"$VERIFY_ROOT/secondmate.repeat.sha256" +record_shared_state repeat "$SECONDMATE/data/captain-shared.md" \ + >>"$VERIFY_ROOT/secondmate.shared-state" +``` + +Bounded observed output: + +```text +secondmate_validation=accepted id=stow-verification +startup-memory-budget pushed +data/captain-shared.md pushed +inherited sha256=d08ce8e35b17c8342773d551b5c1551a5a6ded5f45ab0f7ed5b6ef91ea1d408c mode=444 +primary: 699 -> 219 estimated tokens against a 350-token budget +primary repeat: 219 -> 219; all three files byte-identical +secondmate: 518 -> 192 estimated tokens against a 350-token budget +secondmate repeat: 192 -> 192; all three files byte-identical +before sha256=d08ce8e35b17c8342773d551b5c1551a5a6ded5f45ab0f7ed5b6ef91ea1d408c mode=444 +after sha256=d08ce8e35b17c8342773d551b5c1551a5a6ded5f45ab0f7ed5b6ef91ea1d408c mode=444 +repeat sha256=d08ce8e35b17c8342773d551b5c1551a5a6ded5f45ab0f7ed5b6ef91ea1d408c mode=444 +``` + +The first pass preserved current preferences, shared-memory and safety authority, a stable operating fact, and authoritative configuration and incident-report pointers while removing duplicate, superseded, stale, and chronological material. +The secondmate fixture passed the production home validator before the existing inheritance owner installed the main-authoritative file read-only. +Both secondmate passes preserved its unique local preference and learning while leaving those inherited bytes and mode untouched. +This verifies the real instruction path consolidates to budget, reports truthful deltas, preserves the primary-owned shared boundary, and does not grow on an identical second pass. diff --git a/docs/verification/supervision.md b/docs/verification/supervision.md index 6945b3491d..d2fdd5a09c 100644 --- a/docs/verification/supervision.md +++ b/docs/verification/supervision.md @@ -52,7 +52,6 @@ Current deterministic and live entry points: ```sh tests/fm-sessionstart-nudge.test.sh -tests/fm-captain-translation-contract.test.sh FM_PI_LIVE_E2E=1 tests/fm-pi-primary-live-e2e.test.sh FM_OPENCODE_LIVE_E2E=1 tests/fm-opencode-primary-live-e2e.test.sh ``` @@ -61,6 +60,40 @@ The Ahoy first-message boundary was reverified on 2026-07-22 with Pi 0.81.1 and Marked current operational input and the two exact legacy compatibility shapes selected Bearings, while genuine near-miss captain messages remained real boundaries. The detailed reconciliation and task chronology stay in the private audit report and PR evidence. +## Semantic busy state + +The per-adapter semantic sources behind [`bin/fm-busy-lib.sh`](../../bin/fm-busy-lib.sh) were live-verified on 2026-07-28 against firstmate-launched workers wired exactly as `fm-spawn` writes them. +Each pass polled `state/<id>.busy-state` while a real turn ran. + +| Harness | Version verified | Semantic source | Observed result | +| --- | --- | --- | --- | +| Pi | 0.82.0 | Extension `agent_start` / `agent_settled` with `ctx.isIdle()` | The spawn seed `busy source=fm-spawn`, then `busy source=pi-ext event=agent-start`, then `idle source=pi-ext event=agent-settled`; the turn-end marker was still touched. | +| OpenCode | 1.17.18 | Plugin `session.status` | In a real TUI pane: seed, then `busy source=opencode-plugin event=session-busy`, then `idle source=opencode-plugin event=session-status-idle`. | +| Claude | 2.1.220 (Claude Code) | Hooks `UserPromptSubmit`, `Stop`, `StopFailure`, `SessionEnd` | `UserPromptSubmit` fired for the argv launch prompt and each steer, and `Stop` closed every completed turn. A mid-stream Escape interrupt fired no closing hook, which is why the firstmate-controlled clear exists. `StopFailure` and `SessionEnd` are wired from the four hook names present in the installed binary; only the abnormal paths they cover were not reproduced live. | +| Codex | codex-cli 0.145.0 | None usable | See below; classifies `unknown codex-unverified`. | +| Kimi (standalone) | not installed | None usable | No binary on `PATH`, so the gate stays closed and it classifies `unknown kimi-unverified`. | +| Grok | 0.2.112 | Isolated rendered-tail fallback | Retained unconverted; the approved audit could not credit a live structured-lifecycle run. | + +Codex was probed two ways, both refused: + +```sh +codex app-server daemon start +codex exec --dangerously-bypass-approvals-and-sandbox --dangerously-bypass-hook-trust 'Reply with exactly PROBE2.' +``` + +The daemon refused with `managed standalone Codex install not found`, and an interactive TUI worker neither starts nor attaches to the app-server control socket, so no client can observe its turns. +Firstmate-written project hooks under `<worktree>/.codex/hooks.json` fired for neither an interactive pane whose directory trust was granted nor `codex exec`, in both cases with `--dangerously-bypass-hook-trust`, while global `~/.codex/hooks.json` `SessionStart` hooks fired in the same runs. +Codex also exposes no `StopFailure` hook, so an API-error turn end would need separate coverage even after hook discovery works. +The app-server protocol schema does define the required lifecycle (`turn/started`, plus a `turn/completed` status of `completed`, `interrupted`, `failed`, or `inProgress`), so the gate is a reachability problem rather than a protocol gap. + +Deterministic entry points: + +```sh +tests/fm-busy-state.test.sh +tests/fm-busy-adapter-wiring.test.sh +tests/fm-crew-state.test.sh +``` + ## Turn-end guard The direct and passive mechanisms were validated across all five harnesses on 2026-07-08 through 2026-07-12, with Claude's replacement Stop-owned path revalidated on 2026-07-24. @@ -71,10 +104,30 @@ The direct and passive mechanisms were validated across all five harnesses on 20 | Codex | 0.142.1 | Blocking `Stop` hook | Hook process root stayed anchored to the trusted checkout and one continuation ran. | | OpenCode | 1.17.6 | Passive `session.idle` callback | Throwing could not block, while `promptAsync` scheduled one TUI follow-up; headless remained fail-open. | | Pi | 0.80.5 | Passive `agent_settled` callback | Exactly one guard follow-up ran for an unhealthy cycle, with no recursion across tool turns. | -| Grok | 0.2.93 | Passive `Stop` plus bounded resume | Project hook ran under trust, resumed once without inherited bypass permissions, and the environment latch prevented recursion. | +| Grok | 0.2.112 native and 0.2.73 pre-native | Running-payload adaptive `Stop` | Native false-to-true continuation stayed in one process with two model turns and zero resume launches; the field-absent pre-native process launched exactly one guarded resume. | + +The Grok adaptive matrix ran on 2026-07-28 with separate scratch repositories and homes, dedicated tmux sockets, one target plus one control window, ambient tmux variables removed, and a socket-bound wrapper first in `PATH`. + +```sh +FM_GROK_STOP_LIVE_E2E=1 \ + FM_GROK_NATIVE_BIN="$native_grok_0_2_112" \ + FM_GROK_LEGACY_BIN="$official_pre_native_grok_0_2_73" \ + tests/fm-grok-stop-live-e2e.test.sh +``` + +Observed bounded output: + +```text +ok - grok 0.2.112 (9bbd559437aa) [stable] native Stop kept one session across false->true, two model turns, and zero resume processes +ok - grok 0.2.73 (9ff14c43bbe5) [stable] legacy Stop omitted capability, resumed exactly once, and stopped normally +ok - Grok adaptive Stop real-process matrix passed with exact target cleanup and control-window survival +``` + +The same run proved the Claude-compatible Stop entries stay inert under `GROK_AGENT`, the legacy resume carries `GROK_TURNEND_GUARD_ACTIVE=1`, and every replacement root is removed after exact target cleanup while its control window survives. The secondmate-home scope and manual-repair wake path were measured with Claude Code 2.1.207 on 2026-07-12, when a native background completion re-invoked the idle model with no human input. The current Stop-owned main/secondmate inclusion and child-worktree exclusion are covered deterministically by `tests/fm-claude-stop-autoarm.test.sh`. +On 2026-07-28 with Claude Code 2.1.205, `fm_harness_ancestry_pid()` in `bin/fm-session-lock-lib.sh` was fixed to resolve the outermost pid of a contiguous nested-harness run instead of the first match, so the Stop auto-arm correctly reaches the session's true lock owner through Claude Code's multi-level `bg-spare` hook worker chain. The Claude product live path ran with Claude Code 2.1.219 on 2026-07-24: @@ -96,6 +149,7 @@ Current entry points: tests/fm-turnend-guard.test.sh tests/fm-supervision-instructions.test.sh FM_PI_LIVE_E2E=1 tests/fm-pi-primary-live-e2e.test.sh +FM_GROK_STOP_LIVE_E2E=1 FM_GROK_NATIVE_BIN="$native_grok" FM_GROK_LEGACY_BIN="$pre_native_grok" tests/fm-grok-stop-live-e2e.test.sh ``` ## Watcher continuity diff --git a/docs/zellij-backend.md b/docs/zellij-backend.md index 367da98ebb..c9f440b468 100644 --- a/docs/zellij-backend.md +++ b/docs/zellij-backend.md @@ -91,7 +91,8 @@ Real test cleanup uses only an isolated non-`firstmate` session and the guard in - Zellij is experimental and explicit-only. - All homes share one session and tab bar; scoped titles prevent cross-home identity collisions but do not create per-home visual containers. -- There is no native busy or push-event signal, so supervision uses capture/hash and busy-regex polling. +- There is no native busy or push-event signal, so supervision uses capture/hash polling for screen changes and each harness adapter's semantic lifecycle for worker state. + Grok alone retains its isolated rendered-tail fallback. - There is no verified agent-process liveness signal, so a dead Zellij secondmate is reported inconclusive rather than auto-respawned. - New-tab focus restoration has a narrow visible race. - CLI exit status is not meaningful; a target can still disappear after structural readiness checks. diff --git a/tests/fixtures/quota-array-dispatch/cases.json b/tests/fixtures/quota-array-dispatch/cases.json deleted file mode 100644 index 23d097be46..0000000000 --- a/tests/fixtures/quota-array-dispatch/cases.json +++ /dev/null @@ -1,352 +0,0 @@ -{ - "cases": [ - { - "id": "higher-raw-ahead-vs-lower-raw-sustainable", - "expect": "B", - "reason": "prefer sustainable pace over higher raw headroom with conservation pressure", - "candidates": [ - { - "id": "A", - "harness": "claude", - "model": "strong-a", - "effort": "high", - "fit": "comparable", - "reasoningClass": "strong", - "tight": false, - "rawHeadroom": 80, - "paceStatus": "ahead", - "aheadWindowIds": ["weekly"], - "worstReserve": -12.0, - "unknownPace": false, - "paceAvailable": true - }, - { - "id": "B", - "harness": "codex", - "model": "strong-b", - "effort": "high", - "fit": "comparable", - "reasoningClass": "strong", - "tight": false, - "rawHeadroom": 55, - "paceStatus": "behind", - "aheadWindowIds": [], - "worstReserve": 18.0, - "unknownPace": false, - "paceAvailable": true - } - ] - }, - { - "id": "mixed-effective-with-ahead-bound", - "expect": "B", - "reason": "mixed with aheadWindowIds is conservation pressure", - "candidates": [ - { - "id": "A", - "harness": "claude", - "model": "mixed-a", - "effort": "high", - "fit": "comparable", - "reasoningClass": "strong", - "tight": false, - "rawHeadroom": 75, - "paceStatus": "mixed", - "aheadWindowIds": ["seven_day"], - "worstReserve": -8.0, - "unknownPace": false, - "paceAvailable": true - }, - { - "id": "B", - "harness": "codex", - "model": "steady-b", - "effort": "high", - "fit": "comparable", - "reasoningClass": "strong", - "tight": false, - "rawHeadroom": 60, - "paceStatus": "on_pace", - "aheadWindowIds": [], - "worstReserve": 0.0, - "unknownPace": false, - "paceAvailable": true - } - ] - }, - { - "id": "both-ahead-least-negative-reserve", - "expect": "B", - "reason": "among pressured candidates prefer least-negative worst reserve", - "candidates": [ - { - "id": "A", - "harness": "claude", - "model": "pressured-a", - "effort": "high", - "fit": "comparable", - "reasoningClass": "strong", - "tight": false, - "rawHeadroom": 50, - "paceStatus": "ahead", - "aheadWindowIds": ["weekly"], - "worstReserve": -22.0, - "unknownPace": false, - "paceAvailable": true - }, - { - "id": "B", - "harness": "codex", - "model": "pressured-b", - "effort": "high", - "fit": "comparable", - "reasoningClass": "strong", - "tight": false, - "rawHeadroom": 48, - "paceStatus": "ahead", - "aheadWindowIds": ["weekly"], - "worstReserve": -5.0, - "unknownPace": false, - "paceAvailable": true - } - ] - }, - { - "id": "ahead-bounding-window-overrides-neutral-effective-summary", - "expect": "B", - "reason": "an ahead applicable bounding window creates conservation pressure even when the effective summary is neutral", - "candidates": [ - { - "id": "A", - "harness": "claude", - "model": "bounded-a", - "effort": "high", - "fit": "comparable", - "reasoningClass": "strong", - "tight": false, - "rawHeadroom": 72, - "paceStatus": "on_pace", - "aheadWindowIds": [], - "boundingWindows": [ - { - "id": "weekly", - "paceStatus": "ahead", - "reservePercentPoints": -9.0 - } - ], - "worstReserve": -9.0, - "unknownPace": false, - "paceAvailable": true - }, - { - "id": "B", - "harness": "codex", - "model": "steady-b", - "effort": "high", - "fit": "comparable", - "reasoningClass": "strong", - "tight": false, - "rawHeadroom": 58, - "paceStatus": "behind", - "aheadWindowIds": [], - "boundingWindows": [ - { - "id": "weekly", - "paceStatus": "behind", - "reservePercentPoints": 7.0 - } - ], - "worstReserve": 7.0, - "unknownPace": false, - "paceAvailable": true - } - ] - }, - { - "id": "known-sustainable-vs-unknown", - "expect": "A", - "reason": "prefer known sustainable evidence over unknown pace", - "candidates": [ - { - "id": "A", - "harness": "codex", - "model": "known-a", - "effort": "medium", - "fit": "comparable", - "reasoningClass": "standard", - "tight": false, - "rawHeadroom": 40, - "paceStatus": "behind", - "aheadWindowIds": [], - "worstReserve": 10.0, - "unknownPace": false, - "paceAvailable": true - }, - { - "id": "B", - "harness": "claude", - "model": "unknown-b", - "effort": "medium", - "fit": "comparable", - "reasoningClass": "standard", - "tight": false, - "rawHeadroom": 42, - "paceStatus": "unknown", - "aheadWindowIds": [], - "worstReserve": null, - "unknownPace": true, - "paceAvailable": true - } - ] - }, - { - "id": "all-tight-strongest-reasoning", - "expect": "A", - "reason": "preserve strongest-reasoning class when every candidate is tight", - "requiredReasoningClass": "strong", - "candidates": [ - { - "id": "A", - "harness": "claude", - "model": "strong-tight", - "effort": "high", - "fit": "comparable", - "reasoningClass": "strong", - "tight": true, - "rawHeadroom": 8, - "paceStatus": "behind", - "aheadWindowIds": [], - "worstReserve": 2.0, - "unknownPace": false, - "paceAvailable": true - }, - { - "id": "B", - "harness": "codex", - "model": "weaker-roomier", - "effort": "medium", - "fit": "comparable", - "reasoningClass": "standard", - "tight": true, - "rawHeadroom": 25, - "paceStatus": "behind", - "aheadWindowIds": [], - "worstReserve": 12.0, - "unknownPace": false, - "paceAvailable": true - } - ] - }, - { - "id": "genuine-tie-captain-choice", - "expectError": "genuine tie requires captain choice", - "reason": "report genuine ties instead of selecting by array order or harness identity", - "candidates": [ - { - "id": "A", - "harness": "claude", - "model": "same-model", - "effort": "high", - "fit": "comparable", - "reasoningClass": "strong", - "tight": false, - "rawHeadroom": 50, - "paceStatus": "on_pace", - "aheadWindowIds": [], - "worstReserve": 0.0, - "unknownPace": false, - "paceAvailable": true - }, - { - "id": "B", - "harness": "codex", - "model": "same-model", - "effort": "high", - "fit": "comparable", - "reasoningClass": "strong", - "tight": false, - "rawHeadroom": 50, - "paceStatus": "on_pace", - "aheadWindowIds": [], - "worstReserve": 0.0, - "unknownPace": false, - "paceAvailable": true - } - ] - }, - { - "id": "genuine-tie-reversed-array-order", - "expectError": "genuine tie requires captain choice", - "reason": "reversing a genuine tie must still require captain choice", - "candidates": [ - { - "id": "B", - "harness": "codex", - "model": "same-model", - "effort": "high", - "fit": "comparable", - "reasoningClass": "strong", - "tight": false, - "rawHeadroom": 50, - "paceStatus": "on_pace", - "aheadWindowIds": [], - "worstReserve": 0.0, - "unknownPace": false, - "paceAvailable": true - }, - { - "id": "A", - "harness": "claude", - "model": "same-model", - "effort": "high", - "fit": "comparable", - "reasoningClass": "strong", - "tight": false, - "rawHeadroom": 50, - "paceStatus": "on_pace", - "aheadWindowIds": [], - "worstReserve": 0.0, - "unknownPace": false, - "paceAvailable": true - } - ] - }, - { - "id": "schema-v2-absent-pace", - "expect": "A", - "reason": "absent pace degrades to raw headroom without fabricating pace health", - "candidates": [ - { - "id": "A", - "harness": "codex", - "model": "legacy-a", - "effort": "medium", - "fit": "comparable", - "reasoningClass": "standard", - "tight": false, - "rawHeadroom": 70, - "paceStatus": null, - "aheadWindowIds": [], - "worstReserve": null, - "unknownPace": false, - "paceAvailable": false - }, - { - "id": "B", - "harness": "claude", - "model": "legacy-b", - "effort": "medium", - "fit": "comparable", - "reasoningClass": "standard", - "tight": false, - "rawHeadroom": 40, - "paceStatus": null, - "aheadWindowIds": [], - "worstReserve": null, - "unknownPace": false, - "paceAvailable": false - } - ] - } - ] -} diff --git a/tests/fixtures/quota-array-dispatch/schema-v3-shape.json b/tests/fixtures/quota-array-dispatch/schema-v3-shape.json deleted file mode 100644 index a79f86aca3..0000000000 --- a/tests/fixtures/quota-array-dispatch/schema-v3-shape.json +++ /dev/null @@ -1,103 +0,0 @@ -{ - "schemaVersion": 3, - "generatedAt": "1970-01-01T00:00:00.000Z", - "providers": [ - { - "provider": "claude", - "label": "Claude", - "source": "test", - "plan": "test", - "windows": [ - { - "id": "five_hour", - "label": "session", - "kind": "session", - "percentUsed": 20, - "percentRemaining": 80, - "windowSeconds": 18000, - "pace": { - "status": "behind", - "timeRemainingPercent": 40.0, - "elapsedPercent": 60.0, - "reservePercentPoints": 40.0 - } - }, - { - "id": "seven_day", - "label": "week", - "kind": "weekly", - "percentUsed": 55, - "percentRemaining": 45, - "windowSeconds": 604800, - "pace": { - "status": "ahead", - "timeRemainingPercent": 60.0, - "elapsedPercent": 40.0, - "reservePercentPoints": -15.0 - } - } - ], - "quotaSemantics": { - "status": "known", - "description": "sanitized representative schemaVersion 3 shape", - "effectiveAvailability": [ - { - "scope": "all_models", - "status": "known", - "effectivePercentRemaining": 45, - "boundedBy": ["five_hour", "seven_day"], - "limitingWindowIds": ["seven_day"], - "pace": { - "status": "mixed", - "aheadWindowIds": ["seven_day"], - "behindWindowIds": ["five_hour"], - "worstReservePercentPoints": -15.0, - "worstReserveWindowId": "seven_day" - } - } - ] - } - }, - { - "provider": "codex", - "label": "Codex", - "source": "test", - "plan": "test", - "windows": [ - { - "id": "weekly", - "label": "week", - "kind": "weekly", - "percentUsed": 30, - "percentRemaining": 70, - "windowSeconds": 604800, - "pace": { - "status": "behind", - "timeRemainingPercent": 50.0, - "elapsedPercent": 50.0, - "reservePercentPoints": 20.0 - } - } - ], - "quotaSemantics": { - "status": "known", - "description": "sanitized representative schemaVersion 3 shape", - "effectiveAvailability": [ - { - "scope": "all_models", - "status": "known", - "effectivePercentRemaining": 70, - "boundedBy": ["weekly"], - "limitingWindowIds": ["weekly"], - "pace": { - "status": "behind", - "behindWindowIds": ["weekly"], - "worstReservePercentPoints": 20.0, - "worstReserveWindowId": "weekly" - } - } - ] - } - } - ] -} diff --git a/tests/fm-afk-inject-herdr-e2e.test.sh b/tests/fm-afk-inject-herdr-e2e.test.sh index 644d015086..9c5c66c5e8 100755 --- a/tests/fm-afk-inject-herdr-e2e.test.sh +++ b/tests/fm-afk-inject-herdr-e2e.test.sh @@ -41,6 +41,11 @@ command -v jq >/dev/null 2>&1 || { echo "skip: jq not found (required by the her # shellcheck source=tests/herdr-test-safety.sh . "$ROOT/tests/herdr-test-safety.sh" +# This suite runs against its own isolated lab session, so a Herdr pane +# inherited from the terminal it was launched in must not follow spawn into it +# as a cross-session parent identity (tests/herdr-test-safety.sh). +herdr_forget_inherited_pane + fail() { printf 'not ok - %s\n' "$1" >&2; cleanup_all; exit 1; } pass() { printf 'ok - %s\n' "$1"; } diff --git a/tests/fm-afk-launch.test.sh b/tests/fm-afk-launch.test.sh index 8075d7067a..de6b827aa8 100755 --- a/tests/fm-afk-launch.test.sh +++ b/tests/fm-afk-launch.test.sh @@ -70,6 +70,54 @@ unit_clear_stale() { rm -rf "$st" } +unit_relative_paths_are_absolute_before_daemon_launch() { + local root home state out status linked_home + root=$(mktemp -d "${TMPDIR:-/tmp}/fm-afk-relative-home.XXXXXX") + mkdir -p "$root/home/state" "$root/cdpath/home/state" + home=$(cd "$root/home" && pwd -P) + state="$home/state" + out=$( + cd "$root" || exit 1 + CDPATH="$root/cdpath" FM_HOME=home FM_STATE_OVERRIDE=home/state \ + bash -c '. "$1"; printf "%s\n%s\n" "$FM_HOME" "$FM_AFK_LAUNCH_STATE"' _ "$LAUNCH" + ) + if [ "$out" = "$home"$'\n'"$state" ]; then + pass "launcher paths: relative home and state ignore CDPATH before daemon command construction" + else + fail "launcher paths: relative home or state remained cwd-dependent ($out)" + fi + linked_home="$root/home-link" + ln -s "$root/home" "$linked_home" + out=$(FM_HOME="$linked_home" FM_STATE_OVERRIDE="$linked_home/state" \ + bash -c '. "$1"; printf "%s\n%s\n" "$FM_HOME" "$FM_AFK_LAUNCH_STATE"' _ "$LAUNCH") + if [ "$out" = "$linked_home"$'\n'"$linked_home/state" ]; then + pass "launcher paths: absolute symlink spellings are preserved" + else + fail "launcher paths: absolute symlink spelling changed ($out)" + fi + out=$( + cd "$root" || exit 1 + FM_HOME=missing-home "$LAUNCH" help 2>&1 + ) + status=$? + if [ "$status" -ne 0 ] && printf '%s\n' "$out" | grep -F "FM_HOME directory cannot be resolved: missing-home" >/dev/null; then + pass "launcher paths: unresolved relative FM_HOME fails loudly" + else + fail "launcher paths: unresolved relative FM_HOME did not name the bad input ($out)" + fi + out=$( + cd "$root" || exit 1 + FM_HOME=home FM_STATE_OVERRIDE=missing-state "$LAUNCH" help 2>&1 + ) + status=$? + if [ "$status" -ne 0 ] && printf '%s\n' "$out" | grep -F "FM_STATE_OVERRIDE directory cannot be resolved: missing-state" >/dev/null; then + pass "launcher paths: unresolved relative FM_STATE_OVERRIDE fails loudly" + else + fail "launcher paths: unresolved relative FM_STATE_OVERRIDE did not name the bad input ($out)" + fi + rm -rf "$root" +} + # --------------------------------------------------------------------------- # UNIT 2: a FRESH entry clears; a REFRESH (daemon already alive) preserves the # current session's buffered escalations. @@ -248,12 +296,23 @@ unit_signal_exits_with_lock_cleanup() { : > "$2" ' _ "$LAUNCH" "$marker" & child=$! - for _ in $(seq 1 40); do - [ -d "$st/state/.afk-launch.lock" ] && break + # Signal only once the lifecycle actually holds its lock. Killing before the + # lock exists tests nothing, and on a loaded machine it used to race: the + # lock could be created just after the kill and outlive the process. + local locked=0 _ + for _ in $(seq 1 100); do + if [ -d "$st/state/.afk-launch.lock" ]; then locked=1; break; fi sleep 0.05 done + [ "$locked" = 1 ] || fail "launcher signal: lifecycle never acquired its lock to interrupt" kill -TERM "$child" 2>/dev/null || true wait "$child" 2>/dev/null || true + # The signal handler releases the lock as it exits; give that removal a + # bounded settle rather than sampling the instant `wait` returns. + for _ in $(seq 1 100); do + [ -e "$st/state/.afk-launch.lock" ] || break + sleep 0.05 + done if [ ! -e "$marker" ] && [ ! -e "$st/state/.afk-launch.lock" ]; then pass "launcher signal: TERM exits and releases the lifecycle lock" else @@ -861,6 +920,7 @@ e2e_tmux() { } unit_clear_stale +unit_relative_paths_are_absolute_before_daemon_launch unit_fresh_vs_refresh unit_stop_ordering unit_stop_rejects_reused_pid diff --git a/tests/fm-agy-harness.test.sh b/tests/fm-agy-harness.test.sh index 7c7a05fbff..4099da0e56 100644 --- a/tests/fm-agy-harness.test.sh +++ b/tests/fm-agy-harness.test.sh @@ -367,21 +367,25 @@ test_agy_busy_regex_matches_expected_patterns() { # shellcheck source=/dev/null . "$ROOT/bin/fm-tmux-lib.sh" - printf 'Thinking...\n' | fm_busy_lines_match agy || fail "Thinking... not busy" - printf 'Working...\n' | fm_busy_lines_match agy || fail "Working... not busy" - printf 'Analyzing...\n' | fm_busy_lines_match agy || fail "Analyzing... not busy" - printf 'Executing...\n' | fm_busy_lines_match agy || fail "Executing... not busy" - printf 'Processing...\n' | fm_busy_lines_match agy || fail "Processing... not busy" - printf 'Task 3/7\n' | fm_busy_lines_match agy || fail "Task N/M not busy" - printf 'Step 1/5\n' | fm_busy_lines_match agy || fail "Step N/M not busy" - + printf '⣷ Generating...\n' | fm_busy_lines_match agy || fail "spinner + Generating... not busy" + printf 'Generating...\n' | fm_busy_lines_match agy || fail "Generating... not busy" + printf 'esc to cancel Gemini 3.6 Flash · high\n' \ + | fm_busy_lines_match agy || fail "esc to cancel footer not busy" + + if printf '? for shortcuts Gemini 3.6 Flash · high\n' \ + | fm_busy_lines_match agy; then + fail "idle footer (? for shortcuts) was misread as busy" + fi if printf 'Idle output\n' | fm_busy_lines_match agy; then fail "ordinary output was misread as busy" fi if printf 'esc to interrupt\n' | fm_busy_lines_match agy; then - fail "non-AGY busy token leaked into AGY matcher" + fail "non-AGY busy token (codex/claude's esc to interrupt) leaked into AGY matcher" + fi + if printf 'Ctrl+c:cancel\n' | fm_busy_lines_match agy; then + fail "grok's exact busy token leaked into AGY's harness-scoped matcher" fi - pass "agy busy regex matches expected patterns and rejects idle output" + pass "agy busy regex matches the verified Generating.../esc to cancel signature and rejects idle output" } test_agy_busy_regex_does_not_leak_to_other_harnesses() { @@ -389,15 +393,35 @@ test_agy_busy_regex_does_not_leak_to_other_harnesses() { # shellcheck source=/dev/null . "$ROOT/bin/fm-tmux-lib.sh" - if printf 'Task 3/7\n' | fm_busy_lines_match claude; then + if printf 'Generating...\n' | fm_busy_lines_match claude; then fail "AGY busy token leaked into claude matcher" fi - if printf 'Step 1/5\n' | fm_busy_lines_match codex; then + if printf 'esc to cancel\n' | fm_busy_lines_match codex; then fail "AGY busy token leaked into codex matcher" fi + if printf 'esc to cancel\n' | fm_busy_lines_match grok; then + fail "AGY busy token leaked into grok matcher" + fi pass "agy busy regex is harness-scoped and does not leak" } +test_agy_busy_state_uses_new_lifecycle_owner() { + local state out + state="$TMP_ROOT/semantic-busy/state" + mkdir -p "$state" + # shellcheck source=/dev/null + . "$ROOT/bin/fm-busy-lib.sh" + + out=$(fm_busy_classify tmux w1 agy agy-task "$state" 'Generating...') + [ "$out" = 'busy agy-pane' ] || fail "new busy-state owner did not classify AGY busy: $out" + out=$(fm_busy_classify tmux w1 agy agy-task "$state" '? for shortcuts') + [ "$out" = 'idle agy-pane' ] || fail "new busy-state owner did not classify AGY idle: $out" + out=$(fm_busy_classify tmux w1 agy agy-task "$state" 'ordinary output') + [ "$out" = 'unknown agy-inconclusive' ] \ + || fail "new busy-state owner promoted an inconclusive AGY capture: $out" + pass "agy busy and idle pane signatures are reconciled through the new lifecycle owner" +} + # ---- spawn launch template tests ------------------------------------------ test_agy_launch_template_is_in_spawn() { @@ -405,8 +429,19 @@ test_agy_launch_template_is_in_spawn() { grep -Fq -- "$1" "$SPAWN" || fail "expected line missing from fm-spawn: $1" } assert_source_line \ - " agy) printf '%s' 'agy -p --output-format json --dangerously-skip-permissions __MODELFLAG____EFFORTFLAG__--print-timeout \${FM_AGY_PRINT_TIMEOUT:-600}s --log-file __AGYLOGFILE__ \"\$(__OPINPUT__ encode launch-brief < __BRIEF__)\"' ;;" - pass "fm-spawn: agy launch template is present and byte-pinned" + " agy) printf '%s' 'agy --dangerously-skip-permissions __MODELFLAG____EFFORTFLAG__' ;;" + pass "fm-spawn: agy launch template is a bare interactive launch and byte-pinned" +} + +test_agy_launch_template_has_no_print_mode_flags() { + local template + template=$(grep -F "agy) printf '%s' 'agy --dangerously-skip-permissions" "$SPAWN") + [ -n "$template" ] || fail "agy launch template not found in fm-spawn" + assert_not_contains "$template" ' -p ' "agy launch template still passes -p (print mode)" + assert_not_contains "$template" '--output-format' "agy launch template still passes --output-format" + assert_not_contains "$template" '--print-timeout' "agy launch template still passes --print-timeout" + assert_not_contains "$template" '__BRIEF__' "agy launch template still embeds the brief inline instead of typing it after launch" + pass "fm-spawn: agy launch template dropped every print-mode flag" } test_agy_model_effort_in_spawn() { @@ -449,6 +484,14 @@ SH } # ---- mock spawn test ------------------------------------------------------ +# +# The fake tmux below models AGY's verified interactive state machine: +# ""/booted -> (submit Enter) -> trust-dialog (if FM_FAKE_AGY_TRUST=yes) -> ready +# ready -> (a "\"-suffixed literal + Enter, from a non-final brief line) -> typing +# typing -> (a plain literal with no trailing "\", the final brief line) -> typing-final +# typing-final -> (Enter, the real submit) -> busy (if FM_FAKE_AGY_DELIVERY=yes) +# The first-ever literal send is always the launch command (LAUNCH_LOG); every +# later literal send is a brief content line (POINTER_LOG). make_spawn_fakebin() { local dir=$1 fakebin @@ -456,13 +499,101 @@ make_spawn_fakebin() { cat > "$fakebin/tmux" <<'SH' #!/usr/bin/env bash set -u +printf '%s\n' "$*" >> "${FM_FAKE_TMUX_CALL_LOG:-/dev/null}" +state=$(cat "$FM_FAKE_AGY_STATE" 2>/dev/null || true) +fake_screen() { + case "$state" in + trust-dialog) + printf 'Do you trust the contents of this project?\n\n> Yes, I trust this folder\n No, exit\n' + ;; + ready|done) + printf '────────\n>\n────────\n? for shortcuts Gemini 3.6 Flash · high\n' + ;; + typing|typing-final) + printf '────────\n> queued brief content\n────────\n Gemini 3.6 Flash · high\n' + ;; + busy) + printf '────────\n> \n⣷ Generating...\n────────\nesc to cancel Gemini 3.6 Flash · high\n' + ;; + *) + printf 'shell starting\n$ \n' + ;; + esac +} case "$*" in *"#{pane_current_path}"*) printf '%s\n' "${FM_FAKE_PANE_PATH:-}"; exit 0 ;; esac case "${1:-}" in display-message) printf 'firstmate\n'; exit 0 ;; list-windows) exit 0 ;; - has-session|new-session|new-window|send-keys|kill-window) exit 0 ;; + has-session|new-session|new-window|kill-window) exit 0 ;; + send-keys) + prev= + literal= + for arg in "$@"; do + if [ "$prev" = -l ]; then literal=$arg; break; fi + prev=$arg + done + if [ -n "$literal" ]; then + case "$state" in + ''|booted) + printf '%s\n' "$literal" >> "$FM_FAKE_LAUNCH_LOG" + printf 'booted\n' > "$FM_FAKE_AGY_STATE" + ;; + *) + printf '%s\n' "$literal" >> "$FM_FAKE_POINTER_LOG" + case "$literal" in + *'\') printf 'typing\n' > "$FM_FAKE_AGY_STATE" ;; + *) printf 'typing-final\n' > "$FM_FAKE_AGY_STATE" ;; + esac + ;; + esac + exit 0 + fi + case " $* " in + *' Enter '*) + case "$state" in + booted) + if [ "${FM_FAKE_AGY_READY:-yes}" = yes ]; then + if [ "${FM_FAKE_AGY_TRUST:-yes}" = yes ]; then + printf 'trust-dialog\n' > "$FM_FAKE_AGY_STATE" + else + printf 'ready\n' > "$FM_FAKE_AGY_STATE" + fi + fi + ;; + trust-dialog) + printf 'ready\n' > "$FM_FAKE_AGY_STATE" + ;; + typing) + : # "\" + Enter inserts a newline in the composer; state stays "typing" + ;; + typing-final) + if [ "${FM_FAKE_AGY_DELIVERY:-yes}" = yes ]; then + printf 'busy\n' > "$FM_FAKE_AGY_STATE" + fi + ;; + esac + ;; + esac + exit 0 + ;; + capture-pane) + start= end= prev= + for arg in "$@"; do + case "$prev" in + -S) start=$arg ;; + -E) end=$arg ;; + esac + case "$arg" in -S|-E) prev=$arg ;; *) prev= ;; esac + done + case "$start:$end" in + *[!0-9:]*|'':*|*:'') fake_screen ;; + *) fake_screen | awk -v start="$start" -v end="$end" \ + 'NR - 1 >= start && NR - 1 <= end' ;; + esac + exit 0 + ;; esac exit 0 SH @@ -472,7 +603,8 @@ SH } make_spawn_case() { - local name=$1 case_dir home proj wt fakebin id + local name=$1 brief_lines case_dir home proj wt fakebin id + brief_lines=${2:-agy brief for test} case_dir="$TMP_ROOT/$name" home="$case_dir/home" proj="$case_dir/project" @@ -480,49 +612,136 @@ make_spawn_case() { fakebin=$(make_spawn_fakebin "$case_dir/fake") id="agy-$name-x1" mkdir -p "$home/data/$id" "$home/projects" "$home/state" "$home/config" - printf 'agy brief for test\n' > "$home/data/$id/brief.md" + printf '%s\n' "$brief_lines" > "$home/data/$id/brief.md" fm_git_worktree "$proj" "$wt" "fm/$id" touch "$home/state/.last-watcher-beat" + : > "$case_dir/launch.log" + : > "$case_dir/pointer.log" + : > "$case_dir/agy.state" + : > "$case_dir/tmux-calls.log" printf '%s\n' "$case_dir|$home|$proj|$wt|$fakebin|$id" } run_agy_spawn() { - local home=$1 proj=$2 wt=$3 fakebin=$4 id=$5 + local case_dir=$1 home=$2 proj=$3 wt=$4 fakebin=$5 id=$6 + shift 6 FM_ROOT_OVERRIDE='' FM_HOME="$home" \ FM_STATE_OVERRIDE="$home/state" FM_DATA_OVERRIDE="$home/data" \ FM_PROJECTS_OVERRIDE="$home/projects" FM_CONFIG_OVERRIDE="$home/config" \ FM_SPAWN_NO_GUARD=1 FM_FAKE_PANE_PATH="$wt" TMUX="fake,1,0" \ - FM_AGY_PRINT_TIMEOUT=600 \ + FM_FAKE_LAUNCH_LOG="$case_dir/launch.log" \ + FM_FAKE_POINTER_LOG="$case_dir/pointer.log" \ + FM_FAKE_AGY_STATE="$case_dir/agy.state" \ + FM_FAKE_TMUX_CALL_LOG="$case_dir/tmux-calls.log" \ + FM_AGY_READY_POLLS=5 FM_AGY_DELIVERY_POLLS=5 FM_AGY_POLL_INTERVAL=0 FM_AGY_LINE_SLEEP=0 \ PATH="$fakebin:$BASE_PATH" \ - "$SPAWN" "$id" "$proj" agy 2>&1 + "$SPAWN" "$id" "$proj" agy "$@" 2>&1 +} + +read_agy_spawn_record() { + IFS='|' read -r CASE_DIR HOME_DIR PROJ_DIR WT_DIR FAKEBIN_DIR ID <<EOF +$1 +EOF } test_agy_spawn_succeeds() { - local rec case_dir home proj wt fakebin id out status + local rec out status rec=$(make_spawn_case spawn-ok) - IFS='|' read -r case_dir home proj wt fakebin id <<EOF -$rec -EOF - out=$(run_agy_spawn "$home" "$proj" "$wt" "$fakebin" "$id") + read_agy_spawn_record "$rec" + out=$(run_agy_spawn "$CASE_DIR" "$HOME_DIR" "$PROJ_DIR" "$WT_DIR" "$FAKEBIN_DIR" "$ID") status=$? expect_code 0 "$status" "agy spawn should succeed" - assert_contains "$out" "spawned $id harness=agy" "agy spawn did not report success" + assert_contains "$out" "spawned $ID harness=agy" "agy spawn did not report success" pass "fm-spawn: agy spawn reports expected output" } test_agy_spawn_records_meta() { - local rec case_dir home proj wt fakebin id out status meta + local rec meta rec=$(make_spawn_case spawn-meta) - IFS='|' read -r case_dir home proj wt fakebin id <<EOF -$rec -EOF - out=$(run_agy_spawn "$home" "$proj" "$wt" "$fakebin" "$id") - meta="$home/state/$id.meta" + read_agy_spawn_record "$rec" + run_agy_spawn "$CASE_DIR" "$HOME_DIR" "$PROJ_DIR" "$WT_DIR" "$FAKEBIN_DIR" "$ID" >/dev/null + meta="$HOME_DIR/state/$ID.meta" assert_present "$meta" "meta file was not created for agy spawn" assert_grep 'harness=agy' "$meta" "meta did not record harness=agy" pass "fm-spawn: agy spawn records harness=agy in meta" } +test_agy_accepts_trust_dialog_before_brief_delivery() { + local rec out status launch pointer + rec=$(make_spawn_case trust-dialog) + read_agy_spawn_record "$rec" + out=$(FM_FAKE_AGY_TRUST=yes run_agy_spawn "$CASE_DIR" "$HOME_DIR" "$PROJ_DIR" "$WT_DIR" "$FAKEBIN_DIR" "$ID") + status=$? + expect_code 0 "$status" "agy spawn should survive an untrusted-directory trust dialog" + assert_contains "$out" "spawned $ID harness=agy" "agy spawn did not report success after the trust dialog" + launch=$(cat "$CASE_DIR/launch.log") + assert_contains "$launch" "agy --dangerously-skip-permissions" "agy launch command was not the bare interactive form" + assert_not_contains "$launch" " -p " "agy launch command still passed -p" + pointer=$(cat "$CASE_DIR/pointer.log") + assert_contains "$pointer" "agy brief for test" "agy did not type the brief content after accepting the trust dialog" + pass "fm-spawn: agy accepts the trust dialog and still delivers the brief" +} + +test_agy_multiline_brief_uses_backslash_continuation() { + local rec out status pointer + rec=$(make_spawn_case multiline $'line one\nline two\nline three') + read_agy_spawn_record "$rec" + out=$(FM_FAKE_AGY_TRUST=no run_agy_spawn "$CASE_DIR" "$HOME_DIR" "$PROJ_DIR" "$WT_DIR" "$FAKEBIN_DIR" "$ID") + status=$? + expect_code 0 "$status" "agy spawn should succeed for a multi-line brief" + pointer=$(cat "$CASE_DIR/pointer.log") + [ "$(printf '%s\n' "$pointer" | sed -n 1p)" = "line one\\" ] \ + || fail "first brief line was not sent with a trailing backslash continuation: $pointer" + [ "$(printf '%s\n' "$pointer" | sed -n 2p)" = "line two\\" ] \ + || fail "middle brief line was not sent with a trailing backslash continuation: $pointer" + [ "$(printf '%s\n' "$pointer" | sed -n 3p)" = 'line three' ] \ + || fail "final brief line unexpectedly carried a continuation backslash: $pointer" + pass "fm-spawn: agy sends every non-final brief line with a backslash continuation and submits only on the last line" +} + +test_agy_readiness_gate_precedes_brief_delivery() { + local rec out status + rec=$(make_spawn_case not-ready) + read_agy_spawn_record "$rec" + status=0 + out=$(FM_FAKE_AGY_READY=no run_agy_spawn "$CASE_DIR" "$HOME_DIR" "$PROJ_DIR" "$WT_DIR" "$FAKEBIN_DIR" "$ID") || status=$? + [ "$status" -ne 0 ] || fail "agy spawn without a ready signal should fail" + assert_contains "$out" "agy did not show a verified ready signal" \ + "agy readiness failure lacked a loud diagnostic" + [ ! -s "$CASE_DIR/pointer.log" ] || fail "agy brief content was sent before readiness was confirmed" + assert_grep 'failed: agy did not show a verified ready signal' "$HOME_DIR/state/$ID.status" \ + "unconfirmed agy readiness did not leave a supervisor-visible failure" + pass "fm-spawn: agy never sends the brief before an observable ready signal (trust dialog or idle composer)" +} + +test_agy_unconfirmed_delivery_fails_loudly() { + local rec out status + rec=$(make_spawn_case drop) + read_agy_spawn_record "$rec" + status=0 + out=$(FM_FAKE_AGY_DELIVERY=no run_agy_spawn "$CASE_DIR" "$HOME_DIR" "$PROJ_DIR" "$WT_DIR" "$FAKEBIN_DIR" "$ID") || status=$? + [ "$status" -ne 0 ] || fail "an unconfirmed agy delivery should fail" + assert_contains "$out" "agy brief delivery was not confirmed" \ + "unconfirmed agy delivery lacked a loud diagnostic" + assert_grep 'failed: agy brief delivery was not confirmed' "$HOME_DIR/state/$ID.status" \ + "unconfirmed agy delivery did not leave a supervisor-visible failure" + pass "fm-spawn: agy treats a silent submit failure as a failed spawn, not a hang" +} + +test_agy_model_and_effort_reach_launch_command() { + local rec launch + rec=$(make_spawn_case model-effort) + read_agy_spawn_record "$rec" + run_agy_spawn "$CASE_DIR" "$HOME_DIR" "$PROJ_DIR" "$WT_DIR" "$FAKEBIN_DIR" "$ID" \ + --model 'Gemini 3.6 Flash (High)' --effort high >/dev/null + launch=$(cat "$CASE_DIR/launch.log") + assert_contains "$launch" "--model 'Gemini 3.6 Flash (High)'" "agy launch did not carry the exact model string" + assert_contains "$launch" "--effort 'high'" "agy launch did not carry the requested effort flag" + assert_not_contains "$launch" '__MODELFLAG__' "agy launch left a MODELFLAG placeholder unresolved" + assert_not_contains "$launch" '__EFFORTFLAG__' "agy launch left an EFFORTFLAG placeholder unresolved" + pass "fm-spawn: agy launch carries the exact requested model string and effort flag" +} + # ---- existing launch templates byte-pinned test --------------------------- test_existing_launch_templates_stay_byte_pinned() { @@ -542,9 +761,12 @@ test_existing_launch_templates_stay_byte_pinned() { test_tracked_files_have_no_user_absolute_paths() { local pattern matches pattern="/""Users/" - matches=$(git -C "$ROOT" grep -n -F "$pattern" -- . 2>/dev/null || true) - [ -z "$matches" ] || fail "tracked files contain user-specific absolute paths: $matches" - pass "repository: tracked files contain no user-specific absolute paths" + # Maintainer-verification records intentionally preserve exact empirical + # command output, including machine-local paths. Runtime code and ordinary + # tracked prose must remain portable. + matches=$(git -C "$ROOT" grep -n -F "$pattern" -- . ':(exclude)docs/verification/**' 2>/dev/null || true) + [ -z "$matches" ] || fail "runtime or ordinary tracked files contain user-specific absolute paths: $matches" + pass "repository: runtime and ordinary tracked files contain no user-specific absolute paths" } # ---- main ---------------------------------------------------------------- @@ -573,10 +795,17 @@ test_agy_result_success_rejects_error test_agy_cleanup_removes_result_dir test_agy_busy_regex_matches_expected_patterns test_agy_busy_regex_does_not_leak_to_other_harnesses +test_agy_busy_state_uses_new_lifecycle_owner test_agy_launch_template_is_in_spawn +test_agy_launch_template_has_no_print_mode_flags test_agy_model_effort_in_spawn test_agy_harness_detection_records_agy test_agy_spawn_succeeds test_agy_spawn_records_meta +test_agy_accepts_trust_dialog_before_brief_delivery +test_agy_multiline_brief_uses_backslash_continuation +test_agy_readiness_gate_precedes_brief_delivery +test_agy_unconfirmed_delivery_fails_loudly +test_agy_model_and_effort_reach_launch_command test_existing_launch_templates_stay_byte_pinned test_tracked_files_have_no_user_absolute_paths diff --git a/tests/fm-arm-pretool-check.test.sh b/tests/fm-arm-pretool-check.test.sh index 0bc6cfac28..5ba750aea0 100755 --- a/tests/fm-arm-pretool-check.test.sh +++ b/tests/fm-arm-pretool-check.test.sh @@ -439,95 +439,6 @@ test_allow_is_silent_both_modes() { # --- harness wiring: each adapter invokes the shared checker ----------------- -test_grok_pretool_hook_wired() { - local settings command - settings="$ROOT/.grok/hooks/fm-primary-pretool-check.json" - [ -f "$settings" ] || fail "tracked grok primary PreToolUse hook config is missing" - command=$(jq -r '.hooks.PreToolUse[0].hooks[0].command // empty' "$settings") - [ -n "$command" ] || fail "PreToolUse hook command is missing from grok primary hook config" - assert_contains "$command" 'GROK_WORKSPACE_ROOT' "grok pretool hook must anchor from GROK_WORKSPACE_ROOT" - assert_contains "$command" 'fm-arm-pretool-check.sh' "grok pretool hook must invoke the shared checker" - assert_contains "$command" 'exec "${GROK_WORKSPACE_ROOT:-}/bin/fm-arm-pretool-check.sh"' "grok pretool hook must forward its stdin payload unchanged to the checker" - # shellcheck disable=SC2016 # single quotes are deliberate: a literal needle string, not an expansion - assert_not_contains "$command" 'root=${GROK_WORKSPACE_ROOT' "grok pretool hook must not assign a bare \$root var (breaks grok's own \${VAR} pre-substitution; see docs/arm-pretool-check.md)" - local matcher - matcher=$(jq -r '.hooks.PreToolUse[0].matcher // empty' "$settings") - [ "$matcher" = "Bash" ] || fail "grok pretool hook must matcher-scope to Bash, got: $matcher" - pass ".grok primary hook: PreToolUse hook invokes the shared checker" -} - -test_grok_turnend_hook_uses_safe_var_pattern() { - local settings command - settings="$ROOT/.grok/hooks/fm-primary-turnend-guard.json" - [ -f "$settings" ] || fail "tracked grok primary Stop hook config is missing" - command=$(jq -r '.hooks.Stop[0].hooks[0].command // empty' "$settings") - # shellcheck disable=SC2016 # single quotes are deliberate: literal needle strings, not expansions - assert_not_contains "$command" 'root=${GROK_WORKSPACE_ROOT' "grok Stop hook must not assign a bare \$root var either (regression fixed 2026-07-09, docs/arm-pretool-check.md)" - # shellcheck disable=SC2016 - assert_contains "$command" '${GROK_WORKSPACE_ROOT:-}' "grok Stop hook must reference GROK_WORKSPACE_ROOT with an inline default every time" - pass ".grok primary hook: Stop hook uses the \${VAR:-} pattern throughout (no bare \$root)" -} - -test_claude_settings_pretool_hook_wired() { - local settings command - settings="$ROOT/.claude/settings.json" - [ -f "$settings" ] || fail "tracked claude primary settings are missing" - command=$(jq -r '.hooks.PreToolUse[0].hooks[0].command // empty' "$settings") - [ -n "$command" ] || fail "PreToolUse hook command is missing from claude primary settings" - assert_contains "$command" 'CLAUDE_PROJECT_DIR' "claude pretool hook must anchor via CLAUDE_PROJECT_DIR" - assert_contains "$command" 'fm-arm-pretool-check.sh' "claude pretool hook must invoke the shared checker" - assert_contains "$command" '--claude' "claude pretool hook must pass --claude so stdout stays empty on deny" - [ "$command" = '"$CLAUDE_PROJECT_DIR"/bin/fm-arm-pretool-check.sh --claude' ] \ - || fail "claude pretool hook must forward stdin directly with only --claude, got: $command" - local matcher - matcher=$(jq -r '.hooks.PreToolUse[0].matcher // empty' "$settings") - [ "$matcher" = "Bash" ] || fail "claude pretool hook must matcher-scope to Bash, got: $matcher" - pass ".claude/settings.json: PreToolUse hook invokes the shared checker with --claude" -} - -test_codex_hooks_pretool_wired() { - local settings command - settings="$ROOT/.codex/hooks.json" - [ -f "$settings" ] || fail "tracked codex primary hooks are missing" - command=$(jq -r '.hooks.PreToolUse[0].hooks[0].command // empty' "$settings") - [ -n "$command" ] || fail "PreToolUse hook command is missing from codex primary hooks" - assert_contains "$command" 'fm-arm-pretool-check.sh' "codex pretool hook must invoke the shared checker" - assert_contains "$command" 'pwd -P' "codex pretool hook must anchor to the hook process root like the Stop hook does" - assert_contains "$command" 'printf "%s" "$payload" | "$root/bin/fm-arm-pretool-check.sh"' "codex pretool hook must forward the exact captured payload to the checker" - local matcher - matcher=$(jq -r '.hooks.PreToolUse[0].matcher // empty' "$settings") - [ "$matcher" = "Bash" ] || fail "codex pretool hook must matcher-scope to Bash, got: $matcher" - pass ".codex/hooks.json: PreToolUse hook invokes the shared checker" -} - -test_opencode_pretool_plugin_wired() { - local plugin content - plugin="$ROOT/.opencode/plugins/fm-primary-pretool-check.js" - [ -f "$plugin" ] || fail "tracked opencode primary pretool plugin is missing" - content=$(cat "$plugin") - assert_contains "$content" 'tool.execute.before' "opencode pretool plugin must hook tool.execute.before" - assert_contains "$content" 'fm-arm-pretool-check.sh' "opencode pretool plugin must invoke the shared checker" - assert_contains "$content" 'const command = output?.args?.command;' "opencode must extract output.args.command exactly" - assert_contains "$content" '["--command", command]' "opencode must forward the exact command as one CLI argument" - assert_contains "$content" 'if (result.code !== 2) return;' "opencode must throw only for checker exit 2" - assert_contains "$content" 'throw new Error' "opencode pretool plugin must throw to block the tool call" - pass ".opencode primary plugin: tool.execute.before invokes the shared checker and blocks by throwing" -} - -test_pi_extension_carries_pretool_check() { - local ext content - ext="$ROOT/.pi/extensions/fm-primary-turnend-guard.ts" - [ -f "$ext" ] || fail "tracked pi primary extension is missing" - content=$(cat "$ext") - assert_contains "$content" 'tool_call' "pi extension must hook tool_call for the pretool seatbelt" - assert_contains "$content" 'fm-arm-pretool-check.sh' "pi extension must invoke the shared checker" - assert_contains "$content" 'String((event.input as { command?: unknown })?.command ?? "")' "pi must extract and string-coerce event.input.command exactly" - assert_contains "$content" 'const result = await runPretoolCheck(command);' "pi must forward the exact command to the checker" - assert_contains "$content" 'if (result.code !== 2) return {};' "pi must block only for checker exit 2" - assert_contains "$content" 'block: true' "pi extension must return block:true to deny" - pass ".pi primary extension: tool_call handler invokes the shared checker and can block" -} - # --- shellcheck (belt-and-suspenders; CI/CONTRIBUTING.md also runs this) ----- test_shellcheck_clean() { @@ -553,10 +464,4 @@ test_failopen_missing_node test_claude_mode_stdout_empty_on_deny test_default_mode_stdout_has_grok_json_on_deny test_allow_is_silent_both_modes -test_grok_pretool_hook_wired -test_grok_turnend_hook_uses_safe_var_pattern -test_claude_settings_pretool_hook_wired -test_codex_hooks_pretool_wired -test_opencode_pretool_plugin_wired -test_pi_extension_carries_pretool_check test_shellcheck_clean diff --git a/tests/fm-ask-user-authority.test.sh b/tests/fm-ask-user-authority.test.sh index c05d84946f..89ec517fa1 100644 --- a/tests/fm-ask-user-authority.test.sh +++ b/tests/fm-ask-user-authority.test.sh @@ -1,124 +1,13 @@ #!/usr/bin/env bash -# Scenario regressions for ask-user authority. -# -# Hi Bit PR 148 is motivating evidence only: yolo approved 31 ask-user finding -# groups, and a later audit classified 14 of 32 rounds as over-engineered after -# checkpoint-based gameplay verification expanded into continuous adversarial -# 60 Hz browser proof. -# The tests below enforce the general contract boundary without naming that -# project in the runtime policy. -# shellcheck disable=SC2016 +# Behavioral regressions for ask-user authority instructions generated by fm-brief. set -u # shellcheck source=tests/lib.sh . "$(dirname "${BASH_SOURCE[0]}")/lib.sh" -AGENTS="$ROOT/AGENTS.md" -OWNER="$ROOT/.agents/skills/ask-user-authority/SKILL.md" BRIEF="$ROOT/bin/fm-brief.sh" -SECONDMATE="$ROOT/.agents/skills/secondmate-provisioning/SKILL.md" TMP_ROOT=$(fm_test_tmproot fm-ask-user-authority) -approval_contract() { - awk ' - /^### Selected delivery path and approval authority$/ { found = 1; next } - found && /^### Validate$/ { exit } - found { print } - ' "$AGENTS" -} - -test_owner_and_always_loaded_boundary() { - local contract trigger_count - contract=$(approval_contract) - - assert_contains "$contract" "only within the captain's original request and accepted task criteria" \ - "standing authority lost the accepted-contract boundary" - assert_contains "$contract" 'never approves an ask-user Fix that would materially expand that product or engineering contract' \ - "standing authority lost the contract-expansion exception" - assert_contains "$contract" 'destructive, irreversible, and security-sensitive choices remain stronger captain boundaries' \ - "contract expansion weakened stronger captain boundaries" - assert_contains "$contract" 'Complexity alone is not expansion' \ - "standing authority incorrectly treats complexity as expansion" - assert_contains "$contract" 'load `ask-user-authority`' \ - "standing authority lost the detailed-procedure trigger" - assert_contains "$contract" 'implementation worker never answers its own finding' \ - "implementation worker can answer its own finding" - - assert_present "$OWNER" "ask-user authority owner is missing" - assert_grep 'name: ask-user-authority' "$OWNER" "ask-user authority skill has the wrong name" - assert_grep 'user-invocable: false' "$OWNER" "ask-user authority skill must be agent-only" - assert_grep 'single owner of the decision procedure for ask-user findings' "$OWNER" \ - "ask-user authority skill does not declare ownership" - assert_grep 'With `yolo` off, every ask-user finding belongs to the captain' "$OWNER" \ - "detailed procedure permits autonomous ask-user decisions with yolo off" - trigger_count=$(grep -Fc -- '- `ask-user-authority` -' "$AGENTS") - [ "$trigger_count" -eq 1 ] || fail "ask-user-authority must have exactly one section 13 trigger, found $trigger_count" - assert_no_grep 'Hi Bit' "$AGENTS" "AGENTS.md encoded an incident-specific authority rule" - assert_no_grep 'Hi Bit' "$OWNER" "authority owner encoded an incident-specific rule" - pass "ask-user authority has one conditional owner and a concise always-loaded boundary" -} - -test_concrete_required_defect_stays_autonomous() { - assert_grep 'genuinely necessary to satisfy the accepted contract' "$OWNER" \ - "required concrete corrections no longer stay within standing authority" - assert_grep 'Fixing a concrete defect that violates an original acceptance criterion stays within `yolo` authority' "$OWNER" \ - "concrete acceptance-criterion defect scenario is missing" - pass "required concrete defect correction stays within yolo authority" -} - -test_continuous_monitoring_expansion_escalates() { - assert_grep 'continuous-monitoring requirement' "$OWNER" \ - "continuous monitoring is not classified as a possible contract expansion" - assert_grep 'continuous frame-by-frame monitoring when the accepted criterion requested checkpoint proof expands the contract' "$OWNER" \ - "checkpoint-to-continuous-monitoring escalation scenario is missing" - pass "continuous frame-by-frame proof escalates when only checkpoints were requested" -} - -test_repeated_same_theme_escalates_before_another_round() { - assert_grep 'Repeated same-theme findings require escalation before another Fix' "$OWNER" \ - "same-theme findings do not stop another autonomous fix round" - assert_grep 'preserving a questionable abstraction rather than closing independent defects' "$OWNER" \ - "same-theme escalation lost its causal distinction" - pass "repeated abstraction-preserving findings escalate before another fix round" -} - -test_stronger_security_boundary_survives() { - assert_grep 'genuinely security-sensitive choices always escalate' "$OWNER" \ - "security-sensitive choices no longer use the stronger captain boundary" - assert_grep 'genuinely security-sensitive action requires the captain under the stronger existing boundary' "$OWNER" \ - "security-sensitive scenario is missing" - pass "genuinely security-sensitive action still escalates" -} - -test_explicit_complex_architecture_stays_in_scope() { - assert_grep 'complex architecture that the captain explicitly requested' "$OWNER" \ - "explicitly requested complex architecture is not protected from complexity-only escalation" - assert_grep 'does not escalate merely because it is complex' "$OWNER" \ - "complexity alone still triggers escalation" - pass "explicitly requested complex architecture stays autonomous" -} - -test_reviewer_labels_are_evidence_not_authority() { - for label in correctness security fail-closed high-risk required; do - assert_grep "$label" "$OWNER" "reviewer-label evidence rule is missing '$label'" - done - assert_grep 'never as authority to broaden the task' "$OWNER" \ - "reviewer labels can still broaden the accepted contract" - pass "reviewer risk labels remain evidence rather than expansion authority" -} - -test_captain_escalation_is_decision_ready() { - for phrase in \ - 'original requirement or accepted task criterion' \ - 'proposed product or engineering contract expansion' \ - 'smallest alternative that complies with the accepted contract' \ - 'consequences of accepting and declining the expansion' \ - 'recommendation with the reason'; do - assert_grep "$phrase" "$OWNER" "captain-facing escalation lost '$phrase'" - done - pass "contract-expansion escalation carries all five decision elements" -} - test_primary_and_secondmate_instruction_generation() { local home ship charter home="$TMP_ROOT/home" @@ -139,23 +28,12 @@ test_primary_and_secondmate_instruction_generation() { FM_HOME="$home" FM_ROOT_OVERRIDE="$ROOT" FM_SECONDMATE_CHARTER='Handle sample work.' \ "$BRIEF" authority-mate --secondmate --no-projects >/dev/null 2>&1 charter="$home/data/authority-mate/brief.md" + # shellcheck disable=SC2016 # Backticks are literal generated Markdown. assert_grep 'The local `AGENTS.md` is your job description' "$charter" \ "generated secondmate charter does not load the tracked authority boundary" - assert_grep 'purely local fast-forward of tracked files' "$SECONDMATE" \ - "secondmate update owner no longer carries tracked instructions into homes" - assert_grep 'AGENTS.md re-read' "$SECONDMATE" \ - "running secondmates are not told to re-read updated tracked authority" assert_no_grep 'continuous frame-by-frame monitoring' "$charter" \ "generated secondmate charter duplicated the detailed authority procedure" - pass "primary workers and secondmates receive the authority rule through their normal instruction owners" + pass "primary workers and secondmates receive the authority rule through generated instructions" } -test_owner_and_always_loaded_boundary -test_concrete_required_defect_stays_autonomous -test_continuous_monitoring_expansion_escalates -test_repeated_same_theme_escalates_before_another_round -test_stronger_security_boundary_survives -test_explicit_complex_architecture_stays_in_scope -test_reviewer_labels_are_evidence_not_authority -test_captain_escalation_is_decision_ready test_primary_and_secondmate_instruction_generation diff --git a/tests/fm-backend-autodetect-smoke.test.sh b/tests/fm-backend-autodetect-smoke.test.sh index b4c8c887fb..17fe88f617 100755 --- a/tests/fm-backend-autodetect-smoke.test.sh +++ b/tests/fm-backend-autodetect-smoke.test.sh @@ -43,6 +43,14 @@ command -v treehouse >/dev/null 2>&1 || { echo "skip: treehouse not found (requi export FM_GATE_REFUSE_BYPASS=1 +# shellcheck source=tests/herdr-test-safety.sh +. "$ROOT/tests/herdr-test-safety.sh" +# This suite asserts that HERDR_ENV=1 alone selects the backend, and it runs +# against its own isolated lab session. A Herdr pane inherited from the terminal +# it was launched in must not follow spawn into that session as a cross-session +# parent identity; the spawn below sets HERDR_ENV explicitly. +herdr_forget_inherited_pane + # TMP_ROOT is physically resolved (mktemp -d "$(pwd -P)"-relative) to keep this # real-herdr smoke fixture free of unrelated OS symlink noise. # The old fm-spawn bug that originally motivated this fixture shape was fixed in diff --git a/tests/fm-backend-herdr-eventwait-smoke.test.sh b/tests/fm-backend-herdr-eventwait-smoke.test.sh index 5616a5bc75..b383176d7d 100755 --- a/tests/fm-backend-herdr-eventwait-smoke.test.sh +++ b/tests/fm-backend-herdr-eventwait-smoke.test.sh @@ -25,6 +25,11 @@ command -v python3 >/dev/null 2>&1 || { echo "skip: python3 not found (required # shellcheck source=tests/herdr-test-safety.sh . "$ROOT/tests/herdr-test-safety.sh" +# This suite runs against its own isolated lab session, so a Herdr pane +# inherited from the terminal it was launched in must not follow spawn into it +# as a cross-session parent identity (tests/herdr-test-safety.sh). +herdr_forget_inherited_pane + SESSION="fm-lab-eventwait-smoke-$$" export HERDR_SESSION="$SESSION" SCRATCH= diff --git a/tests/fm-backend-herdr-focus-flash-e2e.test.sh b/tests/fm-backend-herdr-focus-flash-e2e.test.sh new file mode 100755 index 0000000000..6145dec365 --- /dev/null +++ b/tests/fm-backend-herdr-focus-flash-e2e.test.sh @@ -0,0 +1,205 @@ +#!/usr/bin/env bash +# Real-Herdr regression for the projected-cleanup focus flash (upstream +# ogulcancelik/herdr#1621 family, live on 0.7.5 stable). +# Part A reproduces the OLD path: an explicit last-pane close that empties a +# non-focused workspace steals the focused workspace. +# Part B proves the mitigation: the focus-safe emptying-close plan +# (repositioning move plus pane-death removal) removes the doomed workspace +# with no focus change and no corrective tab focus at all. +# On a future release whose explicit close preserves focus, Part A records +# that and Part B keeps outcome-only assertions, so no version is guessed. +# Every CLI operation is routed through one guarded named non-default lab, and +# lab teardown verifies that the default fleet session is byte-identical. +set -u + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +HERDR_LAB_HELPER=${HERDR_LAB_HELPER:-$ROOT/bin/fm-herdr-lab.sh} + +fail() { printf 'not ok - %s\n' "$1" >&2; exit 1; } +pass() { printf 'ok - %s\n' "$1"; } + +command -v herdr >/dev/null 2>&1 || { echo 'skip: herdr not found'; exit 0; } +command -v jq >/dev/null 2>&1 || { echo 'skip: jq not found'; exit 0; } +command -v python3 >/dev/null 2>&1 || { echo 'skip: python3 not found'; exit 0; } +[ -x "$HERDR_LAB_HELPER" ] || { echo "skip: Herdr lab helper not executable at $HERDR_LAB_HELPER"; exit 0; } + +HERDR_ORIGINAL_PATH=$PATH +TMP_ROOT=$(mktemp -d "$(cd "${TMPDIR:-/tmp}" && pwd -P)/fm-herdr-focus-flash-e2e.XXXXXX") +FAKEBIN="$TMP_ROOT/fakebin" +mkdir -p "$FAKEBIN" + +HERDR_LAB_SESSION=$("$HERDR_LAB_HELPER" name fm-herdr-focus-flash-regression-r1) +export HERDR_LAB_HELPER HERDR_LAB_SESSION HERDR_ORIGINAL_PATH +B_SAMPLER_PID= +B_SAMPLER_STOP= +cleanup() { + local status=$? + if [ -n "$B_SAMPLER_STOP" ]; then + : > "$B_SAMPLER_STOP" + fi + if [ -n "$B_SAMPLER_PID" ]; then + wait "$B_SAMPLER_PID" 2>/dev/null || true + fi + env PATH="$HERDR_ORIGINAL_PATH" "$HERDR_LAB_HELPER" teardown "$HERDR_LAB_SESSION" || status=1 + rm -rf "$TMP_ROOT" + exit "$status" +} +trap cleanup EXIT +"$HERDR_LAB_HELPER" provision "$HERDR_LAB_SESSION" + +# Keep the lab helper as the only CLI transport. Production adapter calls have +# already appended the exact session; this shim strips that pair, refuses every +# other caller-supplied session, and delegates the command to helper run. +cat > "$FAKEBIN/herdr" <<'SH' +#!/usr/bin/env bash +set -u +args=("$@") +last=$((${#args[@]} - 1)) +flag=$((last - 1)) +if [ "${#args[@]}" -ge 2 ] \ + && [ "${args[$flag]}" = --session ] \ + && [ "${args[$last]}" = "$HERDR_LAB_SESSION" ]; then + unset "args[$last]" "args[$flag]" +fi +set -- "${args[@]}" +for arg in "$@"; do + case "$arg" in --session|--session=*) exit 9 ;; esac +done +exec env PATH="$HERDR_ORIGINAL_PATH" "$HERDR_LAB_HELPER" run "$HERDR_LAB_SESSION" "$@" +SH +chmod +x "$FAKEBIN/herdr" + +lab() { env PATH="$HERDR_ORIGINAL_PATH" "$HERDR_LAB_HELPER" run "$HERDR_LAB_SESSION" "$@"; } +mkws() { # <label> -> "<workspace_id> <tab_id> <pane_id>" + lab workspace create --cwd "$ROOT" --label "$1" --no-focus \ + | jq -er '"\(.result.workspace.workspace_id) \(.result.tab.tab_id) \(.result.root_pane.pane_id)"' +} +focus_snapshot() { + local list workspace tab tabs + list=$(lab workspace list) || return 1 + workspace=$(printf '%s' "$list" | jq -er '[.result.workspaces[] | select(.focused == true)] | select(length == 1) | .[0].workspace_id') || return 1 + tab=$(printf '%s' "$list" | jq -er --arg workspace "$workspace" '[.result.workspaces[] | select(.workspace_id == $workspace)] | select(length == 1) | .[0].active_tab_id') || return 1 + tabs=$(lab tab list --workspace "$workspace") || return 1 + printf '%s' "$tabs" | jq -e --arg tab "$tab" '([.result.tabs[] | select(.focused == true)] | length) == 1 and ([.result.tabs[] | select(.focused == true)][0].tab_id == $tab)' >/dev/null || return 1 + printf '%s\t%s' "$workspace" "$tab" +} +ws_order() { lab workspace list | jq -er '[.result.workspaces[].workspace_id] | join(",")'; } +wait_ws_gone() { # <workspace_id> + local i=0 + while [ "$i" -lt 80 ]; do + lab workspace get "$1" >/dev/null 2>&1 || return 0 + sleep 0.1 + i=$((i + 1)) + done + return 1 +} + +# --- Part A: the OLD path (plain explicit close) steals focus on 0.7.5 ----- +# The spacer keeps the focused anchor away from the doomed workspace's right +# neighbor, where the 0.7.5 explicit close would land by coincidence. +read -r A_DOOMED_WS _ A_DOOMED_PANE <<<"$(mkws flash-a-doomed)" || fail 'could not create the Part A doomed workspace' +read -r _ _ _ <<<"$(mkws flash-a-spacer)" || fail 'could not create the Part A spacer workspace' +read -r A_ANCHOR_WS A_ANCHOR_TAB _ <<<"$(mkws flash-a-anchor)" || fail 'could not create the Part A anchor workspace' +read -r _ _ _ <<<"$(mkws flash-a-tail)" || fail 'could not create the Part A tail workspace' +lab tab focus "$A_ANCHOR_TAB" >/dev/null || fail 'could not focus the Part A anchor' +A_BEFORE=$(focus_snapshot) || fail 'could not capture the Part A pre-close focus' +[ "$A_BEFORE" = "$(printf '%s\t%s' "$A_ANCHOR_WS" "$A_ANCHOR_TAB")" ] \ + || fail 'Part A anchor focus does not match the intended workspace and tab' +lab pane close "$A_DOOMED_PANE" >/dev/null || fail 'Part A explicit close failed' +wait_ws_gone "$A_DOOMED_WS" || fail 'Part A doomed workspace survived the explicit close' +A_AFTER=$(focus_snapshot) || fail 'could not capture the Part A post-close focus' +STEAL_LIVE=0 +if [ "$A_AFTER" != "$A_BEFORE" ]; then + STEAL_LIVE=1 + pass "old path: the explicit last-pane close of a non-focused workspace stole focus ($A_BEFORE -> $A_AFTER)" + lab tab focus "$A_ANCHOR_TAB" >/dev/null || fail 'could not restore the Part A anchor focus' +else + pass 'old path note: this Herdr release preserves focus across the explicit close; continuing with outcome-only assertions' +fi + +# --- Part B: the mitigation in the dangerous geometry ---------------------- +# The doomed workspace sits BEFORE the focused anchor and the anchor is not +# last, the exact shape where an unrepositioned pane death also steals focus. +read -r B_DOOMED_WS _ B_DOOMED_PANE <<<"$(mkws flash-b-doomed)" || fail 'could not create the Part B doomed workspace' +read -r B_ANCHOR_WS B_ANCHOR_TAB _ <<<"$(mkws flash-b-anchor)" || fail 'could not create the Part B anchor workspace' +read -r _ _ _ <<<"$(mkws flash-b-tail)" || fail 'could not create the Part B tail workspace' +lab tab focus "$B_ANCHOR_TAB" >/dev/null || fail 'could not focus the Part B anchor' +B_BEFORE=$(focus_snapshot) || fail 'could not capture the Part B pre-close focus' +[ "$B_BEFORE" = "$(printf '%s\t%s' "$B_ANCHOR_WS" "$B_ANCHOR_TAB")" ] \ + || fail 'Part B anchor focus does not match the intended workspace and tab' +B_SURVIVOR_ORDER=$(ws_order | tr ',' '\n' | grep -v "^$B_DOOMED_WS\$" | paste -sd, -) \ + || fail 'could not capture the Part B survivor order' + +CALL_LOG="$TMP_ROOT/call.log" +B_FOCUS_SAMPLES="$TMP_ROOT/focus.samples" +B_OPERATION_ACTIVE="$TMP_ROOT/operation.active" +B_SAMPLER_READY="$TMP_ROOT/sampler.ready" +B_SAMPLER_STOP="$TMP_ROOT/sampler.stop" +: > "$CALL_LOG" +: > "$B_FOCUS_SAMPLES" +( + : > "$B_SAMPLER_READY" + while [ ! -e "$B_SAMPLER_STOP" ]; do + if [ -e "$B_OPERATION_ACTIVE" ]; then + if B_SAMPLE=$(focus_snapshot); then + printf '%s\n' "$B_SAMPLE" >> "$B_FOCUS_SAMPLES" + else + printf '%s\n' UNREADABLE >> "$B_FOCUS_SAMPLES" + fi + fi + done +) & +B_SAMPLER_PID=$! +B_READY_ATTEMPT=0 +while [ ! -e "$B_SAMPLER_READY" ] && [ "$B_READY_ATTEMPT" -lt 100 ]; do + sleep 0.01 + B_READY_ATTEMPT=$((B_READY_ATTEMPT + 1)) +done +[ -e "$B_SAMPLER_READY" ] || fail 'the Part B focus sampler did not start' +: > "$B_OPERATION_ACTIVE" +B_OUT=$(PATH="$FAKEBIN:$HERDR_ORIGINAL_PATH" FM_FLASH_CALL_LOG="$CALL_LOG" bash -c ' + . "$1/bin/backends/herdr.sh" + fm_backend_herdr_cli() { + local session=$1 + shift + printf "%s\n" "$*" >> "$FM_FLASH_CALL_LOG" + HERDR_SESSION="$session" herdr "$@" --session "$session" + } + fm_backend_herdr_projection_close_pane_focus_preserving "$2" "$3" +' _ "$ROOT" "$HERDR_LAB_SESSION" "$B_DOOMED_PANE" 2>&1) +B_STATUS=$? +rm -f "$B_OPERATION_ACTIVE" +: > "$B_SAMPLER_STOP" +wait "$B_SAMPLER_PID" 2>/dev/null || true +B_SAMPLER_PID= +[ "$B_STATUS" -eq 0 ] || fail "the production focus-preserving close failed (status $B_STATUS): $B_OUT" +[ -s "$B_FOCUS_SAMPLES" ] || fail 'the Part B sampler captured no focus sample during the production close' +B_WRONG_SAMPLE=$(grep -Fvx -- "$B_BEFORE" "$B_FOCUS_SAMPLES" | head -1) +if [ -n "$B_WRONG_SAMPLE" ]; then + fail "the mitigation exposed a wrong or unreadable in-operation focus sample ($B_BEFORE -> $B_WRONG_SAMPLE)" +fi +wait_ws_gone "$B_DOOMED_WS" || fail 'the mitigation left the doomed workspace behind' +if lab pane get "$B_DOOMED_PANE" >/dev/null 2>&1; then + fail 'the mitigation left the doomed pane behind' +fi +B_AFTER=$(focus_snapshot) || fail 'could not capture the Part B post-close focus' +[ "$B_AFTER" = "$B_BEFORE" ] \ + || fail "the mitigation changed the exact focused workspace or tab ($B_BEFORE -> $B_AFTER)" +[ "$(ws_order)" = "$B_SURVIVOR_ORDER" ] \ + || fail "the mitigation left a lasting workspace order change ($B_SURVIVOR_ORDER -> $(ws_order))" +grep -q '^pane process-info' "$CALL_LOG" || fail 'the idle-shell proof never ran' +pass 'mitigation: every in-operation sample preserved exact focus while the doomed workspace was removed' + +if [ "$STEAL_LIVE" = 1 ]; then + grep -q '^tab focus' "$CALL_LOG" \ + && fail 'the corrective tab focus fired, so a wrong-focus interval existed on the defective release' + grep -q '^pane close' "$CALL_LOG" \ + && fail 'the focus-unsafe explicit close was used on the defective release' + pass 'mitigation: no explicit close and no corrective focus were needed on the defective release' +fi + +STATUS=$(lab status --json) || fail 'could not read final named-lab version evidence' +printf 'evidence: herdr=%s protocol=%s steal_live=%s default-session-tripwire=armed\n' \ + "$(printf '%s' "$STATUS" | jq -r '.client.version')" \ + "$(printf '%s' "$STATUS" | jq -r '.client.protocol')" \ + "$STEAL_LIVE" diff --git a/tests/fm-backend-herdr-launcher-workspace-e2e.test.sh b/tests/fm-backend-herdr-launcher-workspace-e2e.test.sh new file mode 100755 index 0000000000..ca5cc4575e --- /dev/null +++ b/tests/fm-backend-herdr-launcher-workspace-e2e.test.sh @@ -0,0 +1,429 @@ +#!/usr/bin/env bash +# tests/fm-backend-herdr-launcher-workspace-e2e.test.sh - mandatory ISOLATED +# end-to-end real-Herdr test for worker PLACEMENT with presentation spaces +# disabled. +# +# The guarantee under test: a crewmate or scout is created in the exact Herdr +# workspace of the firstmate or secondmate process that launched it, identified +# from that process's own Herdr pane rather than from a workspace label. Herdr +# enforces no workspace-label uniqueness, so two workspaces can both be labeled +# "firstmate", and the previous label-first-match resolution put the worker in +# whichever one sorted first - visibly the wrong space whenever the launcher was +# not in it. +# +# This drives the REAL bin/fm-spawn.sh and bin/fm-teardown.sh, because the +# guarantee spans the whole spawn handoff (fm-spawn.sh's herdr arm -> +# fm_backend_herdr_container_ensure -> fm_backend_herdr_workspace_ensure -> +# fm_backend_herdr_launcher_identity) and no adapter primitive holds it alone. +# The headline duplicate-label case additionally runs fm-spawn.sh INSIDE a real +# Herdr pane, so the pane identity comes from Herdr's own injection rather than +# from an environment this test composed. +# +# Safety (2026-07-02 incident, see tests/herdr-test-safety.sh): every lifecycle +# operation goes through bin/fm-herdr-lab.sh, which appends the named session +# flag and verifies the default fleet session is unchanged after teardown. +set -u + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +fail() { printf 'not ok - %s\n' "$1" >&2; cleanup_all; exit 1; } +pass() { printf 'ok - %s\n' "$1"; } +assert_contains_local() { # <haystack> <needle> <msg> + case "$1" in + *"$2"*) : ;; + *) fail "$3"$'\n'"--- got ---"$'\n'"$1" ;; + esac +} + +command -v herdr >/dev/null 2>&1 || { echo "skip: herdr not found"; exit 0; } +command -v jq >/dev/null 2>&1 || { echo "skip: jq not found (required by the herdr adapter)"; exit 0; } +command -v treehouse >/dev/null 2>&1 || { echo "skip: treehouse not found (required by fm-spawn.sh)"; exit 0; } + +# shellcheck source=tests/herdr-test-safety.sh +. "$ROOT/tests/herdr-test-safety.sh" + +# Every spawn below states its own launcher identity, so a pane inherited from +# the terminal this suite was started in must not leak into any of them. +herdr_forget_inherited_pane + +TMP_ROOT=$(mktemp -d "$(cd "${TMPDIR:-/tmp}" && pwd -P)/fm-herdr-launcher-e2e.XXXXXX") +HERDR_LAB_HELPER="$ROOT/bin/fm-herdr-lab.sh" +HERDR_LAB_SESSION=$("$HERDR_LAB_HELPER" name fm-herdr-launcher-ws) || { + rm -rf "$TMP_ROOT" + printf 'not ok - could not generate an isolated Herdr lab session name\n' >&2 + exit 1 +} +export HERDR_SESSION="$HERDR_LAB_SESSION" + +WORKTREES=() +CLEANED=0 +# Idempotent: fail() cleans up before exiting and the EXIT trap fires after it, +# so a second teardown would otherwise report the already-consumed fleet-state +# tripwire as if the lab had gone wrong. +cleanup_all() { + local wt status=0 + [ "$CLEANED" = 0 ] || return 0 + CLEANED=1 + for wt in ${WORKTREES[@]+"${WORKTREES[@]}"}; do + [ -n "$wt" ] && treehouse return --force "$wt" >/dev/null 2>&1 + done + WORKTREES=() + "$HERDR_LAB_HELPER" teardown "$HERDR_LAB_SESSION" || status=$? + rm -rf "$TMP_ROOT" + return "$status" +} +trap cleanup_all EXIT +"$HERDR_LAB_HELPER" provision "$HERDR_LAB_SESSION" || fail "could not provision isolated Herdr lab session" + +lab() { "$HERDR_LAB_HELPER" run "$HERDR_LAB_SESSION" "$@"; } + +# --- helpers ---------------------------------------------------------------- + +make_scratch_project() { # <dir> + local dir=$1 + mkdir -p "$dir" + git -C "$dir" init -q + printf '# scratch\n' > "$dir/README.md" + git -C "$dir" add README.md + git -C "$dir" -c user.name='Firstmate Tests' -c user.email='tests@example.invalid' commit -qm initial +} + +# make_workspace <label> -> "<workspace_id> <tab_id> <root_pane_id>" +make_workspace() { # <label> + local out + out=$(lab workspace create --cwd "$TMP_ROOT" --label "$1" --no-focus 2>/dev/null) || return 1 + printf '%s' "$out" | jq -r ' + [.result.workspace.workspace_id, .result.tab.tab_id, .result.root_pane.pane_id] | @tsv + ' 2>/dev/null | tr '\t' ' ' +} + +workspace_of_pane() { # <pane_id> + lab pane get "$1" 2>/dev/null | jq -r '.result.pane.workspace_id // empty' 2>/dev/null +} + +label_of_workspace() { # <workspace_id> + lab workspace list 2>/dev/null \ + | jq -r --arg id "$1" '.result.workspaces[]? | select(.workspace_id == $id) | .label' 2>/dev/null +} + +tab_labels_of_workspace() { # <workspace_id> + lab tab list --workspace "$1" 2>/dev/null \ + | jq -r '[.result.tabs[]?.label] | sort | join(",")' 2>/dev/null +} + +journal_field() { # <presentation-journal> <key> + grep "^$2=" "$1" 2>/dev/null | head -1 | cut -d= -f2- +} + +# spawn_from_launcher <launcher-pane|""> <home> <task-id> <project> [extra fm-spawn args...] +# Composes exactly the Herdr identity Herdr itself injects into a pane's +# processes. An empty launcher pane means "this firstmate is not running inside +# Herdr at all". +SPAWN_OUT=; SPAWN_ERR=; SPAWN_RC= +spawn_from_launcher() { + local pane=$1 home=$2 id=$3 proj=$4 + shift 4 + SPAWN_OUT="$TMP_ROOT/$id.out"; SPAWN_ERR="$TMP_ROOT/$id.err" + if [ -n "$pane" ]; then + env HERDR_ENV=1 HERDR_PANE_ID="$pane" HERDR_SESSION="$HERDR_LAB_SESSION" \ + HERDR_SOCKET_PATH="$LAB_SOCKET" \ + FM_SPAWN_NO_GUARD=1 FM_HOME="$home" FM_ROOT_OVERRIDE="$ROOT" \ + "$ROOT/bin/fm-spawn.sh" "$id" "$proj" "sh -c 'echo launcher-ws-ok'" --backend herdr "$@" \ + >"$SPAWN_OUT" 2>"$SPAWN_ERR" + else + env -u HERDR_ENV -u HERDR_PANE_ID -u HERDR_SOCKET_PATH HERDR_SESSION="$HERDR_LAB_SESSION" \ + FM_SPAWN_NO_GUARD=1 FM_HOME="$home" FM_ROOT_OVERRIDE="$ROOT" \ + "$ROOT/bin/fm-spawn.sh" "$id" "$proj" "sh -c 'echo launcher-ws-ok'" --backend herdr "$@" \ + >"$SPAWN_OUT" 2>"$SPAWN_ERR" + fi + SPAWN_RC=$? + return 0 +} + +record_worktree() { # <meta> + local wt + wt=$(grep '^worktree=' "$1" 2>/dev/null | cut -d= -f2-) + [ -n "$wt" ] && WORKTREES+=("$wt") + return 0 +} + +LAB_SOCKET=$(lab session list --json 2>/dev/null \ + | jq -r --arg s "$HERDR_LAB_SESSION" '.sessions[]? | select(.name == $s) | .socket_path' 2>/dev/null) +[ -n "$LAB_SOCKET" ] || fail "could not read the isolated lab session's socket path" + +# --- scratch world ---------------------------------------------------------- + +PRIMARY_HOME="$TMP_ROOT/primary-home" +mkdir -p "$PRIMARY_HOME/state" "$PRIMARY_HOME/config" +SM_ID="lwsm1" +SM_HOME="$TMP_ROOT/secondmate-home" +mkdir -p "$SM_HOME/state" "$SM_HOME/config" "$SM_HOME/projects" "$SM_HOME/bin" "$SM_HOME/data" +printf '# scratch secondmate home AGENTS.md placeholder\n' > "$SM_HOME/AGENTS.md" +printf '%s\n' "$SM_ID" > "$SM_HOME/.fm-secondmate-home" +printf 'trivial e2e secondmate charter: nothing to do.\n' > "$SM_HOME/data/charter.md" + +SM2_ID="lwsm2" +SM2_HOME="$TMP_ROOT/secondmate-home-2" +mkdir -p "$SM2_HOME/state" "$SM2_HOME/config" "$SM2_HOME/projects" "$SM2_HOME/bin" "$SM2_HOME/data" +printf '# scratch secondmate home AGENTS.md placeholder\n' > "$SM2_HOME/AGENTS.md" +printf '%s\n' "$SM2_ID" > "$SM2_HOME/.fm-secondmate-home" +printf 'trivial e2e secondmate charter: nothing to do.\n' > "$SM2_HOME/data/charter.md" + +# A third primary-shaped home with presentation spaces ON, so the flat-path +# homes above stay flag-free and each layout is asserted in isolation. +PRES_HOME="$TMP_ROOT/presentation-home" +mkdir -p "$PRES_HOME/state" "$PRES_HOME/config" +: > "$PRES_HOME/config/herdr-presentation-spaces" + +for id in uniqA uniqB dupC dupD staleF smE presU presD; do + mkdir -p "$PRIMARY_HOME/data/$id" "$SM_HOME/data/$id" "$PRES_HOME/data/$id" + printf 'trivial launcher-placement brief: nothing to do.\n' > "$PRIMARY_HOME/data/$id/brief.md" + printf 'trivial launcher-placement brief: nothing to do.\n' > "$SM_HOME/data/$id/brief.md" + printf 'trivial launcher-placement brief: nothing to do.\n' > "$PRES_HOME/data/$id/brief.md" +done +mkdir -p "$PRIMARY_HOME/data/$SM2_ID" +printf 'trivial secondmate charter brief: nothing to do.\n' > "$PRIMARY_HOME/data/$SM2_ID/brief.md" + +PROJ="$TMP_ROOT/scratch-project"; make_scratch_project "$PROJ" + +# One unrelated workspace, kept FOCUSED throughout, so every placement result +# below is also evidence that the globally focused workspace is never the target. +read -r WS_OTHER WS_OTHER_TAB _ <<EOF +$(make_workspace captain-other) +EOF +[ -n "$WS_OTHER" ] || fail "could not create the unrelated captain workspace" +lab tab focus "$WS_OTHER_TAB" >/dev/null 2>&1 || fail "could not focus the unrelated captain workspace" + +focused_workspace() { + lab workspace list 2>/dev/null | jq -r '[.result.workspaces[]? | select(.focused == true) | .workspace_id][0] // empty' 2>/dev/null +} +[ "$(focused_workspace)" = "$WS_OTHER" ] || fail "the unrelated captain workspace did not take focus" + +# --- 1. unique label, no herdr ancestry: the per-home container still works -- + +spawn_from_launcher "" "$PRIMARY_HOME" uniqA "$PROJ" +[ "$SPAWN_RC" -eq 0 ] || fail "a primary-shaped spawn with no herdr parent failed"$'\n'"$(cat "$SPAWN_ERR")" +UNIQA_META="$PRIMARY_HOME/state/uniqA.meta" +record_worktree "$UNIQA_META" +UNIQA_PANE=$(grep '^herdr_pane_id=' "$UNIQA_META" | cut -d= -f2-) +[ -n "$UNIQA_PANE" ] || fail "uniqA meta is missing herdr_pane_id" +WS_PRIMARY=$(workspace_of_pane "$UNIQA_PANE") +[ -n "$WS_PRIMARY" ] || fail "could not read uniqA's workspace" +[ "$(label_of_workspace "$WS_PRIMARY")" = firstmate ] || fail "uniqA did not land in a 'firstmate' workspace" +[ "$(focused_workspace)" = "$WS_OTHER" ] || fail "the spawn stole focus from the captain's workspace" +pass "real herdr E2E: with one 'firstmate' workspace and no herdr parent, a crewmate still lands in this home's own workspace without stealing focus" + +# --- 2. unique label, WITH a launcher pane: same workspace, now by identity -- + +read -r _ _ LAUNCH_PRIMARY_PANE <<EOF +$(lab tab create --workspace "$WS_PRIMARY" --cwd "$TMP_ROOT" --label captain-shell --no-focus 2>/dev/null \ + | jq -r '["x","x", .result.root_pane.pane_id] | @tsv' | tr '\t' ' ') +EOF +[ -n "$LAUNCH_PRIMARY_PANE" ] || fail "could not create a launcher pane inside the 'firstmate' workspace" + +spawn_from_launcher "$LAUNCH_PRIMARY_PANE" "$PRIMARY_HOME" uniqB "$PROJ" +[ "$SPAWN_RC" -eq 0 ] || fail "a primary spawn from a launcher pane failed"$'\n'"$(cat "$SPAWN_ERR")" +UNIQB_META="$PRIMARY_HOME/state/uniqB.meta" +record_worktree "$UNIQB_META" +UNIQB_PANE=$(grep '^herdr_pane_id=' "$UNIQB_META" | cut -d= -f2-) +[ "$(workspace_of_pane "$UNIQB_PANE")" = "$WS_PRIMARY" ] \ + || fail "a crewmate launched from the 'firstmate' workspace must stay in it" +pass "real herdr E2E: the normal unique-label path is unchanged when the launcher's own pane identifies the workspace" + +# --- 2b. presentation spaces ON: the projected child is created and bound +# UNDER the launcher's exact workspace, not collapsed into it --------- + +spawn_from_launcher "$LAUNCH_PRIMARY_PANE" "$PRES_HOME" presU "$PROJ" +[ "$SPAWN_RC" -eq 0 ] || fail "a presentation-enabled spawn from a launcher pane failed"$'\n'"$(cat "$SPAWN_ERR")" +PRESU_META="$PRES_HOME/state/presU.meta" +record_worktree "$PRESU_META" +PRESU_PANE=$(grep '^herdr_pane_id=' "$PRESU_META" | cut -d= -f2-) +PRESU_WS=$(workspace_of_pane "$PRESU_PANE") +[ -n "$PRESU_WS" ] || fail "could not read presU's workspace" +[ "$PRESU_WS" != "$WS_PRIMARY" ] \ + || fail "a projected worker must get its own disposable workspace, not be collapsed into its parent" +case "$(label_of_workspace "$PRESU_WS")" in + "└ "*" · p:"*) : ;; + *) fail "presU's workspace is not a presentation projection: '$(label_of_workspace "$PRESU_WS")'" ;; +esac +PRESU_JOURNAL="$PRES_HOME/state/presU.herdr-presentation" +[ -f "$PRESU_JOURNAL" ] || fail "a projected spawn did not leave its presentation journal" +[ "$(journal_field "$PRESU_JOURNAL" version)" = 2 ] \ + || fail "the projection did not publish an exact restart binding"$'\n'"$(cat "$PRESU_JOURNAL")" +[ "$(journal_field "$PRESU_JOURNAL" parent_workspace_id)" = "$WS_PRIMARY" ] \ + || fail "the projection bound a parent other than the launcher's own workspace ($WS_PRIMARY)" +[ "$(journal_field "$PRESU_JOURNAL" workspace_id)" = "$PRESU_WS" ] \ + || fail "the projection journal does not name its own workspace" +[ "$(focused_workspace)" = "$WS_OTHER" ] || fail "a projected spawn stole focus from the captain's workspace" +pass "real herdr E2E: presentation spaces still create the isolated child workspace and bind it under the launcher's exact parent, without stealing focus" + +# --- 3. duplicate label, launcher in the NON-first match, driven from a real +# Herdr pane so the identity comes from Herdr's own injection ---------- + +read -r WS_PRIMARY_DUP _ LAUNCH_DUP_PANE <<EOF +$(make_workspace firstmate) +EOF +[ -n "$WS_PRIMARY_DUP" ] || fail "could not create the second 'firstmate' workspace" +[ "$WS_PRIMARY_DUP" != "$WS_PRIMARY" ] || fail "the two 'firstmate' workspaces must be distinct" +DUP_COUNT=$(lab workspace list 2>/dev/null | jq -r '[.result.workspaces[]? | select(.label == "firstmate")] | length') +[ "$DUP_COUNT" = 2 ] || fail "expected exactly two 'firstmate' workspaces, got $DUP_COUNT" +WS_PRIMARY_TABS_BEFORE=$(tab_labels_of_workspace "$WS_PRIMARY") + +cat > "$TMP_ROOT/spawn-in-pane.sh" <<SPAWN +#!/usr/bin/env bash +set -u +FM_SPAWN_NO_GUARD=1 FM_HOME="$PRIMARY_HOME" FM_ROOT_OVERRIDE="$ROOT" \\ + "$ROOT/bin/fm-spawn.sh" dupC "$PROJ" "sh -c 'echo launcher-ws-ok'" --backend herdr \\ + > "$TMP_ROOT/dupC.out" 2> "$TMP_ROOT/dupC.err" +echo \$? > "$TMP_ROOT/dupC.rc" +SPAWN +chmod +x "$TMP_ROOT/spawn-in-pane.sh" +lab pane run "$LAUNCH_DUP_PANE" "$TMP_ROOT/spawn-in-pane.sh" >/dev/null 2>&1 \ + || fail "could not run fm-spawn.sh inside the launcher's herdr pane" +i=0 +while [ ! -f "$TMP_ROOT/dupC.rc" ] && [ "$i" -lt 120 ]; do sleep 2; i=$((i + 1)); done +[ -f "$TMP_ROOT/dupC.rc" ] || fail "fm-spawn.sh never finished inside the launcher's herdr pane" +[ "$(cat "$TMP_ROOT/dupC.rc")" = 0 ] \ + || fail "the in-pane spawn failed"$'\n'"$(cat "$TMP_ROOT/dupC.err" 2>/dev/null)" + +DUPC_META="$PRIMARY_HOME/state/dupC.meta" +record_worktree "$DUPC_META" +DUPC_PANE=$(grep '^herdr_pane_id=' "$DUPC_META" | cut -d= -f2-) +DUPC_WS=$(workspace_of_pane "$DUPC_PANE") +[ "$DUPC_WS" = "$WS_PRIMARY_DUP" ] \ + || fail "a worker launched from the second 'firstmate' workspace ($WS_PRIMARY_DUP) landed in '$DUPC_WS' instead" +[ "$DUPC_WS" != "$WS_PRIMARY" ] || fail "the worker was placed in the first label match, the defect under test" +[ "$DUPC_WS" != "$WS_OTHER" ] || fail "the worker was placed in the globally focused workspace" +[ "$(grep '^herdr_workspace_id=' "$DUPC_META" | cut -d= -f2-)" = "$WS_PRIMARY_DUP" ] \ + || fail "the recorded endpoint workspace does not match the launcher's workspace" +pass "real herdr E2E: with two 'firstmate' workspaces, a worker spawned from inside the second one lands in that exact workspace" + +[ "$(tab_labels_of_workspace "$WS_PRIMARY")" = "$WS_PRIMARY_TABS_BEFORE" ] \ + || fail "the other same-labeled workspace's tabs changed; it must never be adopted or mutated" +[ "$(label_of_workspace "$WS_PRIMARY")" = firstmate ] \ + || fail "the other same-labeled workspace was renamed" +[ "$(focused_workspace)" = "$WS_OTHER" ] || fail "the in-pane spawn stole focus from the captain's workspace" +pass "real herdr E2E: the duplicate-labeled sibling workspace is left entirely untouched and focus is preserved" + +# --- 3b. presentation spaces ON with a duplicated parent label: the projection +# still hangs off the launcher's exact workspace --------------------- + +spawn_from_launcher "$LAUNCH_DUP_PANE" "$PRES_HOME" presD "$PROJ" +[ "$SPAWN_RC" -eq 0 ] || fail "a projected spawn under a duplicated parent label failed"$'\n'"$(cat "$SPAWN_ERR")" +PRESD_META="$PRES_HOME/state/presD.meta" +record_worktree "$PRESD_META" +PRESD_PANE=$(grep '^herdr_pane_id=' "$PRESD_META" | cut -d= -f2-) +PRESD_WS=$(workspace_of_pane "$PRESD_PANE") +[ -n "$PRESD_WS" ] || fail "could not read presD's workspace" +PRESD_JOURNAL="$PRES_HOME/state/presD.herdr-presentation" +[ "$(journal_field "$PRESD_JOURNAL" version)" = 2 ] \ + || fail "the duplicate-label projection did not publish a version 2 binding"$'\n'"$(cat "$PRESD_JOURNAL" 2>/dev/null)" +[ "$(journal_field "$PRESD_JOURNAL" parent_workspace_id)" = "$WS_PRIMARY_DUP" ] \ + || fail "the duplicate-label projection journal did not bind the launcher's exact parent workspace" +[ "$PRESD_WS" != "$WS_PRIMARY" ] && [ "$PRESD_WS" != "$WS_PRIMARY_DUP" ] \ + || fail "a projected worker must not be collapsed into either same-labeled parent workspace" +PRESD_ORDER=$(lab workspace list 2>/dev/null | jq -r --arg dup "$WS_PRIMARY_DUP" --arg child "$PRESD_WS" ' + [range(0; (.result.workspaces | length)) as $i + | {i: $i, id: .result.workspaces[$i].workspace_id}] + | ((map(select(.id == $child)) | .[0].i) - (map(select(.id == $dup)) | .[0].i))') +[ "$PRESD_ORDER" = 1 ] \ + || fail "the projected child should sit immediately after the launcher's own workspace, offset was '$PRESD_ORDER'" +[ "$(tab_labels_of_workspace "$WS_PRIMARY")" = "$WS_PRIMARY_TABS_BEFORE" ] \ + || fail "the other same-labeled workspace was mutated by a projected spawn" +[ "$(focused_workspace)" = "$WS_OTHER" ] || fail "a projected spawn stole focus from the captain's workspace" +pass "real herdr E2E: with a duplicated home label, a projected worker still hangs off the launcher's exact workspace and the sibling stays untouched" + +# --- 4. duplicate label with NO launcher identity refuses before publishing -- + +spawn_from_launcher "" "$PRIMARY_HOME" dupD "$PROJ" +[ "$SPAWN_RC" -ne 0 ] || fail "a duplicate-labeled home workspace with no herdr parent must refuse, not guess" +assert_contains_local "$(cat "$SPAWN_ERR")" "labeled 'firstmate'" \ + "the refusal did not name the duplicated home label" +[ ! -e "$PRIMARY_HOME/state/dupD.meta" ] || fail "a refused spawn must not publish task metadata" +DUP_TABS=$(lab tab list --workspace "$WS_PRIMARY" 2>/dev/null | jq -r '[.result.tabs[]? | select(.label == "fm-dupD")] | length') +DUP_TABS2=$(lab tab list --workspace "$WS_PRIMARY_DUP" 2>/dev/null | jq -r '[.result.tabs[]? | select(.label == "fm-dupD")] | length') +[ "$DUP_TABS" = 0 ] && [ "$DUP_TABS2" = 0 ] || fail "a refused spawn created a worker endpoint anyway" +pass "real herdr E2E: an ambiguous home label with no launcher identity refuses before any worker endpoint exists" + +# --- 5. a STALE launcher pane refuses, even though the home label is +# unambiguous from the launcher's own (now closed) workspace ----------- +# A firstmate whose own pane was closed under it has an identity that no longer +# resolves. Guessing a workspace from the label is exactly what must not happen. + +read -r _ _ STALE_PANE <<EOF +$(make_workspace stale-parent) +EOF +[ -n "$STALE_PANE" ] || fail "could not create the workspace whose pane goes stale" +lab pane close "$STALE_PANE" >/dev/null 2>&1 +if lab pane get "$STALE_PANE" >/dev/null 2>&1; then + fail "the launcher pane did not actually go away" +fi + +spawn_from_launcher "$STALE_PANE" "$PRIMARY_HOME" staleF "$PROJ" +[ "$SPAWN_RC" -ne 0 ] || fail "a launcher pane that no longer exists must refuse, not fall back to a label search" +assert_contains_local "$(cat "$SPAWN_ERR")" "$STALE_PANE" \ + "the stale-identity refusal did not name the launcher pane it could not resolve" +[ ! -e "$PRIMARY_HOME/state/staleF.meta" ] || fail "a refused spawn must not publish task metadata" +STALE_TABS=$(lab tab list --workspace "$WS_PRIMARY_DUP" 2>/dev/null | jq -r '[.result.tabs[]? | select(.label == "fm-staleF")] | length') +[ "$STALE_TABS" = 0 ] || fail "a refused spawn created a worker endpoint anyway" +pass "real herdr E2E: a launcher pane that no longer exists refuses before any worker endpoint exists" + +# --- 6. a secondmate launching its own worker gets the same guarantee ------- + +read -r WS_SM_DECOY _ _ <<EOF +$(make_workspace "2ndmate-$SM_ID") +EOF +read -r WS_SM_LAUNCH _ LAUNCH_SM_PANE <<EOF +$(make_workspace "2ndmate-$SM_ID") +EOF +[ -n "$WS_SM_DECOY" ] && [ -n "$WS_SM_LAUNCH" ] || fail "could not create the two secondmate-labeled workspaces" +WS_SM_DECOY_TABS_BEFORE=$(tab_labels_of_workspace "$WS_SM_DECOY") + +spawn_from_launcher "$LAUNCH_SM_PANE" "$SM_HOME" smE "$PROJ" +[ "$SPAWN_RC" -eq 0 ] || fail "a secondmate-owned crewmate spawn failed"$'\n'"$(cat "$SPAWN_ERR")" +SME_META="$SM_HOME/state/smE.meta" +record_worktree "$SME_META" +SME_PANE=$(grep '^herdr_pane_id=' "$SME_META" | cut -d= -f2-) +SME_WS=$(workspace_of_pane "$SME_PANE") +[ "$SME_WS" = "$WS_SM_LAUNCH" ] \ + || fail "a secondmate's own worker must land in the secondmate's exact workspace ($WS_SM_LAUNCH), got '$SME_WS'" +[ "$(tab_labels_of_workspace "$WS_SM_DECOY")" = "$WS_SM_DECOY_TABS_BEFORE" ] \ + || fail "the duplicate secondmate-labeled workspace was mutated" +pass "real herdr E2E: a secondmate launching its own worker gets the same exact-workspace guarantee, and its same-labeled sibling is untouched" + +# --- 7. a --secondmate launch is NOT collapsed into the launcher's workspace - + +spawn_from_launcher "$LAUNCH_DUP_PANE" "$PRIMARY_HOME" "$SM2_ID" "$SM2_HOME" --secondmate +[ "$SPAWN_RC" -eq 0 ] || fail "the primary's --secondmate launch failed"$'\n'"$(cat "$SPAWN_ERR")" +SM2_META="$PRIMARY_HOME/state/$SM2_ID.meta" +SM2_PANE=$(grep '^herdr_pane_id=' "$SM2_META" | cut -d= -f2-) +SM2_WS=$(workspace_of_pane "$SM2_PANE") +[ "$SM2_WS" != "$WS_PRIMARY_DUP" ] \ + || fail "a --secondmate launch must stand up the secondmate's own workspace, not join the launcher's" +[ "$(label_of_workspace "$SM2_WS")" = "2ndmate-$SM2_ID" ] \ + || fail "a --secondmate launch should land in '2ndmate-$SM2_ID', got '$(label_of_workspace "$SM2_WS")'" +pass "real herdr E2E: a --secondmate launch still stands up that secondmate's own workspace instead of inheriting the launcher's" + +# --- 8. teardown closes only the worker's own pane -------------------------- + +FM_ROOT_OVERRIDE="$ROOT" FM_STATE_OVERRIDE="$PRIMARY_HOME/state" FM_DATA_OVERRIDE="$PRIMARY_HOME/data" \ + FM_CONFIG_OVERRIDE="$PRIMARY_HOME/config" \ + "$ROOT/bin/fm-teardown.sh" dupC >"$TMP_ROOT/teardown.out" 2>&1 +status=$? +[ "$status" -eq 0 ] || fail "fm-teardown.sh failed for dupC"$'\n'"$(cat "$TMP_ROOT/teardown.out")" +[ ! -f "$DUPC_META" ] || fail "fm-teardown.sh did not remove dupC's meta" +if lab pane get "$DUPC_PANE" >/dev/null 2>&1; then + fail "fm-teardown.sh did not close dupC's own pane" +fi +lab pane get "$LAUNCH_DUP_PANE" >/dev/null 2>&1 || fail "teardown closed the launcher's own pane" +lab pane get "$UNIQB_PANE" >/dev/null 2>&1 || fail "teardown closed an unrelated worker's pane in the other same-labeled workspace" +[ "$(label_of_workspace "$WS_PRIMARY_DUP")" = firstmate ] || fail "teardown removed or renamed the launcher's workspace" +pass "real herdr E2E: teardown closes only the worker's own pane and leaves the launcher, its workspace, and the same-labeled sibling intact" + +if ! cleanup_all; then + trap - EXIT + printf 'not ok - isolated Herdr lab teardown failed or the default fleet session changed\n' >&2 + exit 1 +fi +trap - EXIT +pass "real herdr E2E: isolated lab session removed and default fleet session unchanged" diff --git a/tests/fm-backend-herdr-presentation-e2e.test.sh b/tests/fm-backend-herdr-presentation-e2e.test.sh index 194d2053ce..3691e16593 100755 --- a/tests/fm-backend-herdr-presentation-e2e.test.sh +++ b/tests/fm-backend-herdr-presentation-e2e.test.sh @@ -253,6 +253,14 @@ chmod +x "$FAKEBIN/herdr-workspace-mover" export PATH="$FAKEBIN:$PATH" export FM_BACKEND_HERDR_WORKSPACE_MOVER="$FAKEBIN/herdr-workspace-mover" +# shellcheck source=tests/herdr-test-safety.sh +. "$ROOT/tests/herdr-test-safety.sh" +# This suite runs against its own isolated lab session, so a Herdr pane +# inherited from the terminal it was launched in must not follow spawn into it +# as a cross-session parent identity. Every projection below is anchored on the +# parent this suite sets up, not on the developer's own workspace. +herdr_forget_inherited_pane + HERDR_LAB_SESSION=$(PATH="$HERDR_ORIGINAL_PATH" \ "$HERDR_LAB_HELPER" name fm-herdr-presentation-projection) export HERDR_SESSION="$HERDR_LAB_SESSION" HERDR_LAB_SESSION @@ -331,38 +339,28 @@ assert_raw_presentation_mutations_preserved_since() { # <line-count> <case-name [ -z "$changed" ] || fail "$case_name changed active workspace/tab inside a create, move, or seeded cleanup: $changed" } -assert_cleanup_focus_steal_was_restored() { # <line-count> <pane-id> <expected-focus> - local start=$1 pane_id=$2 expected=$3 - sed -n "$((start + 1)),\$p" "$FOCUS_AUDIT_LOG" | awk -F '\t' -v pane="$pane_id" -v expected="$expected" ' - $1 == "pane-close" && $4 == pane && $2 == expected && $3 != expected { - drift = $3 - saw_close = 1 - next - } - saw_close && $1 == "tab-focus" && $2 == drift && $3 == expected { - restored = 1 - } - END { exit(restored ? 0 : 1) } - ' || fail "projected task-pane close did not demonstrate and immediately restore the exact focus-steal regression" -} - +# The focus-safe emptying-close plan removes a last pane through Herdr's +# pane-death path with no pane.close mutation at all (the raw explicit-close +# defect is demonstrated by tests/fm-backend-herdr-focus-flash-e2e.test.sh); +# a fallback plain close must preserve or immediately restore exact focus. assert_cleanup_focus_preserved() { # <line-count> <pane-id> <expected-focus> local start=$1 pane_id=$2 expected=$3 sed -n "$((start + 1)),\$p" "$FOCUS_AUDIT_LOG" | awk -F '\t' -v pane="$pane_id" -v expected="$expected" ' - $1 == "pane-close" && $4 == pane && $2 == expected { + $1 == "pane-close" && $4 == pane { saw_close = 1 - if ($3 == expected) { - preserved = 1 - } else { - drift = $3 - } + if ($2 != expected) { bad = 1 } + else if ($3 == expected) { preserved = 1 } + else { drift = $3 } next } saw_close && drift != "" && $1 == "tab-focus" && $2 == drift && $3 == expected { preserved = 1 } - END { exit(saw_close && preserved ? 0 : 1) } + END { exit(bad || (saw_close && !preserved) ? 1 : 0) } ' || fail "projected pane close did not preserve or restore the exact active workspace and tab" + if lab pane get "$pane_id" >/dev/null 2>&1; then + fail "projected cleanup left exact pane $pane_id alive" + fi } remember_meta_worktree() { # <meta> @@ -788,7 +786,7 @@ SHAPE_CLEANUP_AUDIT_START=$(focus_audit_line_count) teardown_task shape "$HOME_DIR" > "$TMP_ROOT/on-teardown.out" 2> "$TMP_ROOT/on-teardown.err" \ || fail "projected teardown failed: $(cat "$TMP_ROOT/on-teardown.err")" assert_focus_is "$CAPTAIN_FOCUS" "projected teardown" -assert_cleanup_focus_steal_was_restored "$SHAPE_CLEANUP_AUDIT_START" "$PROJECTED_PANE" "$CAPTAIN_FOCUS" +assert_cleanup_focus_preserved "$SHAPE_CLEANUP_AUDIT_START" "$PROJECTED_PANE" "$CAPTAIN_FOCUS" pass "real Herdr lab: Treehouse commands and metadata shape are byte-identical except for Herdr container IDs" if lab workspace get "$PROJECTED_WSID" >/dev/null 2>&1; then fail "closing the exact projected task pane did not remove its last-tab workspace" @@ -796,7 +794,7 @@ fi lab pane get "$SECOND_TWO_PANE" >/dev/null 2>&1 \ || fail "projected teardown affected the focused secondmate workspace" [ ! -e "$JOURNAL" ] || fail "confirmed projected teardown did not retire its presentation journal" -pass "real Herdr lab: exact task-pane close restores the exact captain workspace/tab after Herdr's raw focus steal" +pass "real Herdr lab: exact task-pane close removes the projected workspace with no unrestored wrong-focus interval" teardown_task order-a "$HOME_DIR" > "$TMP_ROOT/order-a-teardown.out" 2> "$TMP_ROOT/order-a-teardown.err" & ORDER_A_TEARDOWN_PID=$! @@ -866,7 +864,7 @@ touch "$SECOND_HOME_A/state/.last-watcher-beat" "$SECOND_HOME_B/state/.last-watc # may write config/herdr-presentation-spaces. git -C "$SECOND_HOME_A" init -q git -C "$SECOND_HOME_B" init -q -printf 'config/herdr-presentation-spaces\nconfig/crew-harness\nconfig/crew-dispatch.json\nconfig/backlog-backend\n' \ +printf 'config/herdr-presentation-spaces\nconfig/crew-harness\nconfig/crew-dispatch.json\nconfig/backlog-backend\nconfig/backend\nconfig/startup-memory-budget\n' \ > "$SECOND_HOME_A/.gitignore" cp "$SECOND_HOME_A/.gitignore" "$SECOND_HOME_B/.gitignore" git -C "$SECOND_HOME_A" add .gitignore diff --git a/tests/fm-backend-herdr-prune-safety-e2e.test.sh b/tests/fm-backend-herdr-prune-safety-e2e.test.sh index eca742fc4b..f69160e0bd 100755 --- a/tests/fm-backend-herdr-prune-safety-e2e.test.sh +++ b/tests/fm-backend-herdr-prune-safety-e2e.test.sh @@ -33,6 +33,11 @@ command -v jq >/dev/null 2>&1 || { echo "skip: jq not found (required by the her # shellcheck source=tests/herdr-test-safety.sh . "$ROOT/tests/herdr-test-safety.sh" +# This suite runs against its own isolated lab session, so a Herdr pane +# inherited from the terminal it was launched in must not follow spawn into it +# as a cross-session parent identity (tests/herdr-test-safety.sh). +herdr_forget_inherited_pane + SESSION="fm-lab-prune-safety-e2e-$$" export HERDR_SESSION="$SESSION" SCRATCH=$(mktemp -d "${TMPDIR:-/tmp}/fm-herdr-prune-safety.XXXXXX") diff --git a/tests/fm-backend-herdr-respawn-idem-e2e.test.sh b/tests/fm-backend-herdr-respawn-idem-e2e.test.sh index 1274cde587..382fb72d97 100755 --- a/tests/fm-backend-herdr-respawn-idem-e2e.test.sh +++ b/tests/fm-backend-herdr-respawn-idem-e2e.test.sh @@ -45,6 +45,11 @@ command -v jq >/dev/null 2>&1 || { echo "skip: jq not found (required by the her # shellcheck source=tests/herdr-test-safety.sh . "$ROOT/tests/herdr-test-safety.sh" +# This suite runs against its own isolated lab session, so a Herdr pane +# inherited from the terminal it was launched in must not follow spawn into it +# as a cross-session parent identity (tests/herdr-test-safety.sh). +herdr_forget_inherited_pane + SESSION="fm-lab-respawn-idem-e2e-$$" export HERDR_SESSION="$SESSION" SCRATCH=$(mktemp -d "${TMPDIR:-/tmp}/fm-herdr-respawn-idem.XXXXXX") diff --git a/tests/fm-backend-herdr-smoke.test.sh b/tests/fm-backend-herdr-smoke.test.sh index 9eef7a9024..98f1db2e97 100755 --- a/tests/fm-backend-herdr-smoke.test.sh +++ b/tests/fm-backend-herdr-smoke.test.sh @@ -27,6 +27,11 @@ command -v jq >/dev/null 2>&1 || { echo "skip: jq not found (required by the her # shellcheck source=tests/herdr-test-safety.sh . "$ROOT/tests/herdr-test-safety.sh" +# This suite runs against its own isolated lab session, so a Herdr pane +# inherited from the terminal it was launched in must not follow spawn into it +# as a cross-session parent identity (tests/herdr-test-safety.sh). +herdr_forget_inherited_pane + SESSION="fm-lab-backend-smoke-$$" export HERDR_SESSION="$SESSION" SM_SCRATCH= diff --git a/tests/fm-backend-herdr-workspace-per-home-e2e.test.sh b/tests/fm-backend-herdr-workspace-per-home-e2e.test.sh index ac09c01607..110017e9b8 100755 --- a/tests/fm-backend-herdr-workspace-per-home-e2e.test.sh +++ b/tests/fm-backend-herdr-workspace-per-home-e2e.test.sh @@ -54,6 +54,11 @@ command -v treehouse >/dev/null 2>&1 || { echo "skip: treehouse not found (requi # shellcheck source=tests/herdr-test-safety.sh . "$ROOT/tests/herdr-test-safety.sh" +# This suite runs against its own isolated lab session, so a Herdr pane +# inherited from the terminal it was launched in must not follow spawn into it +# as a cross-session parent identity (tests/herdr-test-safety.sh). +herdr_forget_inherited_pane + # TMP_ROOT is physically resolved (mktemp -d "$(pwd -P)"-relative) for the same # low-noise scratch fixture shape used by # tests/fm-backend-autodetect-smoke.test.sh. diff --git a/tests/fm-backend-herdr.test.sh b/tests/fm-backend-herdr.test.sh index b2e980d964..16166ac283 100755 --- a/tests/fm-backend-herdr.test.sh +++ b/tests/fm-backend-herdr.test.sh @@ -12,9 +12,16 @@ set -u # shellcheck source=tests/lib.sh . "$(dirname "${BASH_SOURCE[0]}")/lib.sh" +# shellcheck source=tests/herdr-test-safety.sh +. "$(dirname "${BASH_SOURCE[0]}")/herdr-test-safety.sh" command -v jq >/dev/null 2>&1 || { echo "skip: jq not found (required by the herdr adapter)"; exit 0; } +# These cases script a canned fake CLI; a Herdr pane identity leaked in from the +# developer's own terminal would make the adapter resolve a launcher that this +# fake never models. The launcher cases below set HERDR_PANE_ID themselves. +herdr_forget_inherited_pane + TMP_ROOT=$(fm_test_tmproot fm-backend-herdr-tests) export FM_BACKEND_HERDR_SUBMIT_MIN_SLEEP=0 @@ -282,6 +289,214 @@ test_cli_helper_sets_env_and_appends_trailing_session_flag() { pass "fm_backend_herdr_cli: sets HERDR_SESSION AND appends a trailing --session flag on every call" } +# --- launcher_identity: the exact workspace a worker must be placed in ------- +# +# Herdr injects HERDR_ENV/HERDR_PANE_ID/HERDR_SESSION/HERDR_SOCKET_PATH into +# every process it manages a pane for, so a firstmate or secondmate agent's own +# tool calls carry the identity of the workspace the captain is watching it in. +# Placement resolves from that identity because workspace labels are mutable and +# non-unique, and the globally focused workspace is unrelated to the launcher. +# The refusal cases matter as much as the resolution: a broken binding must stop +# the spawn, never quietly degrade back to picking a workspace by label. + +test_launcher_identity_absent_without_a_herdr_pane() { + local dir log resp fb status + dir="$TMP_ROOT/launcher-none"; mkdir -p "$dir/responses"; log="$dir/log"; resp="$dir/responses"; : > "$log" + fb=$(make_herdr_fakebin "$dir") + ( PATH="$fb:$PATH" FM_HERDR_LOG="$log" FM_HERDR_RESPONSES="$resp" \ + bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_launcher_identity fmtest' "$ROOT" ) + status=$? + expect_code 2 "$status" "a process with no herdr pane must report 'no launcher to inherit' (2), not a refusal" + [ ! -s "$log" ] || fail "resolving an absent launcher identity must not call herdr at all"$'\n'"$(cat "$log")" + pass "fm_backend_herdr_launcher_identity: a firstmate not running inside herdr has no launcher workspace to inherit" +} + +test_launcher_identity_absent_when_herdr_env_alone_is_set() { + local dir log resp fb status + dir="$TMP_ROOT/launcher-env-only"; mkdir -p "$dir/responses"; log="$dir/log"; resp="$dir/responses"; : > "$log" + fb=$(make_herdr_fakebin "$dir") + ( PATH="$fb:$PATH" FM_HERDR_LOG="$log" FM_HERDR_RESPONSES="$resp" HERDR_ENV=1 \ + \ + bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_launcher_identity fmtest' "$ROOT" ) + status=$? + expect_code 2 "$status" "HERDR_ENV=1 alone is a backend-selection marker, not a parent binding" + pass "fm_backend_herdr_launcher_identity: HERDR_ENV=1 without a pane id selects the backend but binds no parent" +} + +test_launcher_identity_resolves_the_exact_pane_tab_and_workspace() { + local dir log resp fb out + dir="$TMP_ROOT/launcher-ok"; mkdir -p "$dir/responses"; log="$dir/log"; resp="$dir/responses"; : > "$log" + printf '{"sessions":[{"name":"fmtest","running":true,"socket_path":"/tmp/fm-herdr-unit/fmtest.sock"}]}\n' > "$resp/1.out" + printf '{"result":{"pane":{"pane_id":"w7:p3","tab_id":"w7:t3","workspace_id":"w7"}}}\n' > "$resp/2.out" + printf '{"result":{"tab":{"tab_id":"w7:t3","workspace_id":"w7"}}}\n' > "$resp/3.out" + printf '{"result":{"workspaces":[{"workspace_id":"w1","label":"firstmate"},{"workspace_id":"w7","label":"firstmate"}]}}\n' > "$resp/4.out" + fb=$(make_herdr_fakebin "$dir") + out=$( PATH="$fb:$PATH" FM_HERDR_LOG="$log" FM_HERDR_RESPONSES="$resp" \ + HERDR_ENV=1 HERDR_PANE_ID=w7:p3 HERDR_SESSION=fmtest HERDR_SOCKET_PATH=/tmp/fm-herdr-unit/fmtest.sock \ + bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_launcher_identity fmtest || exit 1 + printf "%s|%s|%s" "$FM_BACKEND_HERDR_LAUNCHER_PANE_ID" "$FM_BACKEND_HERDR_LAUNCHER_TAB_ID" "$FM_BACKEND_HERDR_LAUNCHER_WORKSPACE_ID"' "$ROOT" ) + [ "$out" = 'w7:p3|w7:t3|w7' ] \ + || fail "launcher_identity should resolve the launcher's own pane, tab, and workspace, got '$out'" + assert_contains "$(cat "$log")" $'\x1f''pane'$'\x1f''get'$'\x1f''w7:p3' "launcher_identity did not read its own pane" + assert_contains "$(cat "$log")" $'\x1f''tab'$'\x1f''get'$'\x1f''w7:t3' "launcher_identity did not cross-check the owning tab" + pass "fm_backend_herdr_launcher_identity: resolves the launcher's exact workspace even when a same-labeled workspace sorts first" +} + +test_launcher_identity_refuses_a_pane_from_another_session_name() { + local dir log resp fb out status + dir="$TMP_ROOT/launcher-xsession"; mkdir -p "$dir/responses"; log="$dir/log"; resp="$dir/responses"; : > "$log" + fb=$(make_herdr_fakebin "$dir") + out=$( PATH="$fb:$PATH" FM_HERDR_LOG="$log" FM_HERDR_RESPONSES="$resp" \ + HERDR_ENV=1 HERDR_PANE_ID=w7:p3 HERDR_SESSION=someother \ + bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_launcher_identity fmtest' "$ROOT" 2>&1 ) + status=$? + expect_code 1 "$status" "a launcher pane naming another herdr session must refuse" + assert_contains "$out" "cross-session parent identity" "the cross-session refusal did not explain itself" + [ ! -s "$log" ] || fail "a cross-session launcher identity must be refused before any herdr call" + pass "fm_backend_herdr_launcher_identity: refuses a launcher pane that names a different herdr session" +} + +test_launcher_identity_refuses_a_missing_server_socket() { + local dir log resp fb out status + dir="$TMP_ROOT/launcher-no-socket"; mkdir -p "$dir/responses"; log="$dir/log"; resp="$dir/responses"; : > "$log" + fb=$(make_herdr_fakebin "$dir") + out=$( PATH="$fb:$PATH" FM_HERDR_LOG="$log" FM_HERDR_RESPONSES="$resp" \ + HERDR_ENV=1 HERDR_PANE_ID=w7:p3 HERDR_SESSION=fmtest \ + bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_launcher_identity fmtest' "$ROOT" 2>&1 ) + status=$? + expect_code 1 "$status" "a launcher pane without an injected server socket must refuse" + assert_contains "$out" "no injected socket identity" "the missing-socket refusal did not explain itself" + [ ! -s "$log" ] || fail "a missing-socket launcher identity must be refused before any herdr call" + pass "fm_backend_herdr_launcher_identity: refuses a claimed pane without exact server identity" +} + +test_launcher_identity_refuses_a_pane_from_another_server_socket() { + local dir log resp fb out status + dir="$TMP_ROOT/launcher-xsocket"; mkdir -p "$dir/responses"; log="$dir/log"; resp="$dir/responses"; : > "$log" + # 1: session list --json, resolving THIS session's own socket. + printf '{"sessions":[{"name":"fmtest","running":true,"socket_path":"/tmp/fm-herdr-unit/fmtest.sock"}]}\n' > "$resp/1.out" + fb=$(make_herdr_fakebin "$dir") + out=$( PATH="$fb:$PATH" FM_HERDR_LOG="$log" FM_HERDR_RESPONSES="$resp" \ + HERDR_ENV=1 HERDR_PANE_ID=w7:p3 HERDR_SESSION=fmtest HERDR_SOCKET_PATH=/tmp/fm-herdr-unit/other.sock \ + bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_launcher_identity fmtest' "$ROOT" 2>&1 ) + status=$? + expect_code 1 "$status" "a launcher pane on a different herdr server socket must refuse" + assert_contains "$out" "cross-session parent identity" "the cross-socket refusal did not explain itself" + assert_not_contains "$(cat "$log")" $'\x1f''pane'$'\x1f''get' "a cross-server launcher identity must be refused before its pane is trusted" + pass "fm_backend_herdr_launcher_identity: refuses a launcher pane whose injected socket belongs to another herdr server" +} + +test_launcher_identity_refuses_an_unreadable_pane() { + local dir log resp fb out status + dir="$TMP_ROOT/launcher-stale"; mkdir -p "$dir/responses"; log="$dir/log"; resp="$dir/responses"; : > "$log" + printf '{"sessions":[{"name":"fmtest","running":true,"socket_path":"/tmp/fm-herdr-unit/fmtest.sock"}]}\n' > "$resp/1.out" + printf '1\n' > "$resp/2.exit" + fb=$(make_herdr_fakebin "$dir") + out=$( PATH="$fb:$PATH" FM_HERDR_LOG="$log" FM_HERDR_RESPONSES="$resp" \ + HERDR_ENV=1 HERDR_PANE_ID=w7:p3 HERDR_SESSION=fmtest HERDR_SOCKET_PATH=/tmp/fm-herdr-unit/fmtest.sock \ + bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_launcher_identity fmtest' "$ROOT" 2>&1 ) + status=$? + expect_code 1 "$status" "a launcher pane that no longer reads must refuse, not fall back to a label search" + assert_contains "$out" "w7:p3" "the stale-pane refusal did not name the pane it could not resolve" + pass "fm_backend_herdr_launcher_identity: refuses when the launcher's own pane no longer resolves" +} + +test_launcher_identity_refuses_a_pane_and_tab_that_disagree() { + local dir log resp fb out status + dir="$TMP_ROOT/launcher-contradictory"; mkdir -p "$dir/responses"; log="$dir/log"; resp="$dir/responses"; : > "$log" + printf '{"sessions":[{"name":"fmtest","running":true,"socket_path":"/tmp/fm-herdr-unit/fmtest.sock"}]}\n' > "$resp/1.out" + printf '{"result":{"pane":{"pane_id":"w7:p3","tab_id":"w7:t3","workspace_id":"w7"}}}\n' > "$resp/2.out" + # The tab claims a DIFFERENT owning workspace than the pane just did. + printf '{"result":{"tab":{"tab_id":"w7:t3","workspace_id":"w9"}}}\n' > "$resp/3.out" + fb=$(make_herdr_fakebin "$dir") + out=$( PATH="$fb:$PATH" FM_HERDR_LOG="$log" FM_HERDR_RESPONSES="$resp" \ + HERDR_ENV=1 HERDR_PANE_ID=w7:p3 HERDR_SESSION=fmtest HERDR_SOCKET_PATH=/tmp/fm-herdr-unit/fmtest.sock \ + bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_launcher_identity fmtest' "$ROOT" 2>&1 ) + status=$? + expect_code 1 "$status" "a pane and tab that disagree about their workspace must refuse" + assert_contains "$out" "contradictory parent identity" "the contradictory-identity refusal did not explain itself" + pass "fm_backend_herdr_launcher_identity: refuses when the launcher's pane and tab disagree about their workspace" +} + +test_launcher_identity_refuses_a_workspace_missing_from_the_session() { + local dir log resp fb out status + dir="$TMP_ROOT/launcher-gone"; mkdir -p "$dir/responses"; log="$dir/log"; resp="$dir/responses"; : > "$log" + printf '{"sessions":[{"name":"fmtest","running":true,"socket_path":"/tmp/fm-herdr-unit/fmtest.sock"}]}\n' > "$resp/1.out" + printf '{"result":{"pane":{"pane_id":"w7:p3","tab_id":"w7:t3","workspace_id":"w7"}}}\n' > "$resp/2.out" + printf '{"result":{"tab":{"tab_id":"w7:t3","workspace_id":"w7"}}}\n' > "$resp/3.out" + printf '{"result":{"workspaces":[{"workspace_id":"w1","label":"firstmate"}]}}\n' > "$resp/4.out" + fb=$(make_herdr_fakebin "$dir") + out=$( PATH="$fb:$PATH" FM_HERDR_LOG="$log" FM_HERDR_RESPONSES="$resp" \ + HERDR_ENV=1 HERDR_PANE_ID=w7:p3 HERDR_SESSION=fmtest HERDR_SOCKET_PATH=/tmp/fm-herdr-unit/fmtest.sock \ + bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_launcher_identity fmtest' "$ROOT" 2>&1 ) + status=$? + expect_code 1 "$status" "a launcher workspace absent from the session listing must refuse" + assert_contains "$out" "stale parent identity" "the stale-workspace refusal did not explain itself" + pass "fm_backend_herdr_launcher_identity: refuses when the launcher's workspace is gone from its own session" +} + +# --- workspace_ensure placement --------------------------------------------- + +test_workspace_ensure_prefers_the_launcher_over_the_first_label_match() { + local dir log resp fb out + dir="$TMP_ROOT/ensure-launcher"; mkdir -p "$dir/responses"; log="$dir/log"; resp="$dir/responses"; : > "$log" + printf '{"sessions":[{"name":"fmtest","running":true,"socket_path":"/tmp/fm-herdr-unit/fmtest.sock"}]}\n' > "$resp/1.out" + printf '{"result":{"pane":{"pane_id":"w7:p3","tab_id":"w7:t3","workspace_id":"w7"}}}\n' > "$resp/2.out" + printf '{"result":{"tab":{"tab_id":"w7:t3","workspace_id":"w7"}}}\n' > "$resp/3.out" + printf '{"result":{"workspaces":[{"workspace_id":"w1","label":"firstmate"},{"workspace_id":"w7","label":"firstmate"}]}}\n' > "$resp/4.out" + fb=$(make_herdr_fakebin "$dir") + out=$( PATH="$fb:$PATH" FM_HERDR_LOG="$log" FM_HERDR_RESPONSES="$resp" \ + HERDR_ENV=1 HERDR_PANE_ID=w7:p3 HERDR_SESSION=fmtest HERDR_SOCKET_PATH=/tmp/fm-herdr-unit/fmtest.sock \ + bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_workspace_ensure fmtest /tmp' "$ROOT" ) + [ "$out" = w7 ] || fail "workspace_ensure should place the worker in the launcher's own workspace w7, got '$out'" + assert_not_contains "$(cat "$log")" $'\x1f''workspace'$'\x1f''create' "the launcher's existing workspace must be reused, not duplicated" + pass "fm_backend_herdr_workspace_ensure: places a worker in the launcher's exact workspace, not the first same-labeled one" +} + +test_workspace_ensure_refuses_an_ambiguous_label_with_no_launcher() { + local dir log resp fb out status + dir="$TMP_ROOT/ensure-ambiguous"; mkdir -p "$dir/responses"; log="$dir/log"; resp="$dir/responses"; : > "$log" + printf '{"result":{"workspaces":[{"workspace_id":"w1","label":"firstmate"},{"workspace_id":"w7","label":"firstmate"}]}}\n' > "$resp/1.out" + fb=$(make_herdr_fakebin "$dir") + out=$( PATH="$fb:$PATH" FM_HERDR_LOG="$log" FM_HERDR_RESPONSES="$resp" HERDR_SESSION=fmtest \ + bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_workspace_ensure fmtest /tmp' "$ROOT" 2>&1 ) + status=$? + expect_code 3 "$status" "two same-labeled home workspaces with no launcher identity must refuse" + assert_contains "$out" "labeled 'firstmate'" "the ambiguity refusal did not name the duplicated label" + assert_contains "$out" "w1 w7" "the ambiguity refusal did not name the candidate workspaces" + assert_not_contains "$(cat "$log")" $'\x1f''workspace'$'\x1f''create' "an ambiguous placement must not mint a third same-labeled workspace" + pass "fm_backend_herdr_workspace_ensure: refuses to guess between two same-labeled home workspaces" +} + +test_workspace_ensure_other_home_ignores_the_launcher_identity() { + local dir log resp fb out + dir="$TMP_ROOT/ensure-other-home"; mkdir -p "$dir/responses"; log="$dir/log"; resp="$dir/responses"; : > "$log" + # Only a workspace list: the launcher's own pane is never consulted, because a + # --secondmate launch stands up a different home's workspace by design. + printf '{"result":{"workspaces":[{"workspace_id":"w1","label":"firstmate"}]}}\n' > "$resp/1.out" + fb=$(make_herdr_fakebin "$dir") + out=$( PATH="$fb:$PATH" FM_HERDR_LOG="$log" FM_HERDR_RESPONSES="$resp" \ + HERDR_ENV=1 HERDR_PANE_ID=w7:p3 HERDR_SESSION=fmtest \ + bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_workspace_ensure fmtest /tmp other-home' "$ROOT" ) + [ "$out" = w1 ] || fail "an other-home container should resolve by this home's own label, got '$out'" + assert_not_contains "$(cat "$log")" $'\x1f''pane'$'\x1f''get' "an other-home container must not inherit the launcher's workspace" + pass "fm_backend_herdr_workspace_ensure: a --secondmate container resolves that home's own workspace, not the launcher's" +} + +test_container_ensure_refuses_an_ambiguous_home_label() { + local dir log resp fb out status + dir="$TMP_ROOT/container-ambiguous"; mkdir -p "$dir/responses"; log="$dir/log"; resp="$dir/responses"; : > "$log" + printf '{"result":{"workspaces":[{"workspace_id":"w1","label":"firstmate"},{"workspace_id":"w7","label":"firstmate"}]}}\n' > "$resp/1.out" + fb=$(make_herdr_fakebin "$dir") + out=$( PATH="$fb:$PATH" FM_HERDR_LOG="$log" FM_HERDR_RESPONSES="$resp" HERDR_SESSION=fmtest \ + bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_container_ensure /tmp' "$ROOT" 2>&1 ) + status=$? + [ "$status" -ne 0 ] || fail "container_ensure must fail when the home workspace is ambiguous" + assert_contains "$out" "labeled 'firstmate'" "container_ensure buried the specific ambiguity it refused" + assert_not_contains "$out" "failed to ensure herdr workspace" "container_ensure added a generic message over the specific one" + pass "fm_backend_herdr_container_ensure: surfaces the exact ambiguous-placement refusal instead of a generic failure" +} + # --- container_ensure / create_task ------------------------------------------ test_container_ensure_starts_server_and_workspace() { @@ -692,8 +907,12 @@ test_projection_create_uses_exact_response_ids_and_leaves_one_task_pane() { printf '{"result":{"panes":[{"pane_id":"w9:p1","tab_id":"w9:t1"},{"pane_id":"w9:p2","tab_id":"w9:t2"}]}}\n' > "$resp/4.out" printf '{"error":{"code":"agent_not_found"}}\n' > "$resp/5.out" printf '{"result":{"pane":{"pane_id":"w9:p1","tab_id":"w9:t1","workspace_id":"w9"}}}\n' > "$resp/6.out" - printf '{"result":{"tabs":[{"tab_id":"w9:t2","label":"fm-task-p2","workspace_id":"w9"}]}}\n' > "$resp/8.out" - printf '{"result":{"panes":[{"pane_id":"w9:p2","tab_id":"w9:t2"}]}}\n' > "$resp/9.out" + # The emptying-close plan's tab list proves the seeded prune is NOT + # workspace-emptying (the task tab remains), so the close stays plain. + printf '{"result":{"tabs":[{"tab_id":"w9:t1","label":"1","workspace_id":"w9"},{"tab_id":"w9:t2","label":"fm-task-p2","workspace_id":"w9"}]}}\n' > "$resp/7.out" + printf '{"error":{"code":"pane_not_found"}}\n' > "$resp/9.out" + printf '{"result":{"tabs":[{"tab_id":"w9:t2","label":"fm-task-p2","workspace_id":"w9"}]}}\n' > "$resp/10.out" + printf '{"result":{"panes":[{"pane_id":"w9:p2","tab_id":"w9:t2"}]}}\n' > "$resp/11.out" fb=$(make_herdr_fakebin "$dir") out=$(PATH="$fb:$PATH" FM_HERDR_LOG="$log" FM_HERDR_RESPONSES="$resp" HERDR_SESSION=fmtest \ bash -c ' @@ -735,8 +954,10 @@ test_projection_create_never_closes_a_concurrent_same_label_tab() { printf '{"result":{"panes":[{"pane_id":"w9:p1","tab_id":"w9:t1"},{"pane_id":"w9:p2","tab_id":"w9:t2"},{"pane_id":"w9:p3","tab_id":"w9:t3"}]}}\n' > "$resp/4.out" printf '{"error":{"code":"agent_not_found"}}\n' > "$resp/5.out" printf '{"result":{"pane":{"pane_id":"w9:p1","tab_id":"w9:t1","workspace_id":"w9"}}}\n' > "$resp/6.out" - printf '{"result":{"tabs":[{"tab_id":"w9:t2","label":"fm-task-p2","workspace_id":"w9"},{"tab_id":"w9:t3","label":"fm-task-p2","workspace_id":"w9"}]}}\n' > "$resp/8.out" - printf '{"result":{"panes":[{"pane_id":"w9:p2","tab_id":"w9:t2"},{"pane_id":"w9:p3","tab_id":"w9:t3"}]}}\n' > "$resp/9.out" + printf '{"result":{"tabs":[{"tab_id":"w9:t1","label":"1","workspace_id":"w9"},{"tab_id":"w9:t2","label":"fm-task-p2","workspace_id":"w9"},{"tab_id":"w9:t3","label":"fm-task-p2","workspace_id":"w9"}]}}\n' > "$resp/7.out" + printf '{"error":{"code":"pane_not_found"}}\n' > "$resp/9.out" + printf '{"result":{"tabs":[{"tab_id":"w9:t2","label":"fm-task-p2","workspace_id":"w9"},{"tab_id":"w9:t3","label":"fm-task-p2","workspace_id":"w9"}]}}\n' > "$resp/10.out" + printf '{"result":{"panes":[{"pane_id":"w9:p2","tab_id":"w9:t2"},{"pane_id":"w9:p3","tab_id":"w9:t3"}]}}\n' > "$resp/11.out" fb=$(make_herdr_fakebin "$dir") out=$(PATH="$fb:$PATH" FM_HERDR_LOG="$log" FM_HERDR_RESPONSES="$resp" HERDR_SESSION=fmtest \ bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_projection_focus_snapshot() { printf "captain-ws\tcaptain-tab"; }; fm_backend_herdr_projection_focus_restore() { return 0; }; fm_backend_herdr_projection_create_task /tmp/proj label fm-task-p2' "$ROOT" 2>&1) @@ -772,12 +993,16 @@ test_projection_close_restores_exact_prior_focus() { printf '%s\n' '{"result":{"workspaces":[{"workspace_id":"w1","active_tab_id":"w1:t1","focused":false},{"workspace_id":"w2","active_tab_id":"w2:t2","focused":true},{"workspace_id":"w9","active_tab_id":"w9:t2","focused":false}]}}' > "$resp/1.out" printf '%s\n' '{"result":{"tabs":[{"tab_id":"w2:t1","focused":false},{"tab_id":"w2:t2","focused":true}]}}' > "$resp/2.out" printf '%s\n' '{"result":{"pane":{"pane_id":"w9:p2","tab_id":"w9:t2","workspace_id":"w9"}}}' > "$resp/3.out" - printf '%s\n' '{"result":{"workspaces":[{"workspace_id":"w1","active_tab_id":"w1:t1","focused":false},{"workspace_id":"w2","active_tab_id":"w2:t1","focused":false},{"workspace_id":"w3","active_tab_id":"w3:t1","focused":true}]}}' > "$resp/5.out" - printf '%s\n' '{"result":{"tabs":[{"tab_id":"w3:t1","focused":true}]}}' > "$resp/6.out" - printf '%s\n' '{"result":{"tab":{"tab_id":"w2:t2","workspace_id":"w2"}}}' > "$resp/7.out" - printf '%s\n' '{"result":{"tab":{"tab_id":"w2:t2","workspace_id":"w2","focused":true}}}' > "$resp/8.out" - printf '%s\n' '{"result":{"workspaces":[{"workspace_id":"w1","active_tab_id":"w1:t1","focused":false},{"workspace_id":"w2","active_tab_id":"w2:t2","focused":true},{"workspace_id":"w3","active_tab_id":"w3:t1","focused":false}]}}' > "$resp/9.out" - printf '%s\n' '{"result":{"tabs":[{"tab_id":"w2:t1","focused":false},{"tab_id":"w2:t2","focused":true}]}}' > "$resp/10.out" + # The emptying-close plan sees a second tab in w9, so the close stays plain + # and the exact-tab restore backstop is what reclaims the stolen focus. + printf '%s\n' '{"result":{"tabs":[{"tab_id":"w9:t1","workspace_id":"w9"},{"tab_id":"w9:t2","workspace_id":"w9"}]}}' > "$resp/4.out" + printf '%s\n' '{"error":{"code":"pane_not_found"}}' > "$resp/6.out" + printf '%s\n' '{"result":{"workspaces":[{"workspace_id":"w1","active_tab_id":"w1:t1","focused":false},{"workspace_id":"w2","active_tab_id":"w2:t1","focused":false},{"workspace_id":"w3","active_tab_id":"w3:t1","focused":true}]}}' > "$resp/7.out" + printf '%s\n' '{"result":{"tabs":[{"tab_id":"w3:t1","focused":true}]}}' > "$resp/8.out" + printf '%s\n' '{"result":{"tab":{"tab_id":"w2:t2","workspace_id":"w2"}}}' > "$resp/9.out" + printf '%s\n' '{"result":{"tab":{"tab_id":"w2:t2","workspace_id":"w2","focused":true}}}' > "$resp/10.out" + printf '%s\n' '{"result":{"workspaces":[{"workspace_id":"w1","active_tab_id":"w1:t1","focused":false},{"workspace_id":"w2","active_tab_id":"w2:t2","focused":true},{"workspace_id":"w3","active_tab_id":"w3:t1","focused":false}]}}' > "$resp/11.out" + printf '%s\n' '{"result":{"tabs":[{"tab_id":"w2:t1","focused":false},{"tab_id":"w2:t2","focused":true}]}}' > "$resp/12.out" fb=$(make_herdr_fakebin "$dir") out=$(PATH="$fb:$PATH" FM_HERDR_LOG="$log" FM_HERDR_RESPONSES="$resp" \ bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_projection_close_pane_focus_preserving fmtest w9:p2' "$ROOT" 2>&1) @@ -818,13 +1043,15 @@ test_projection_close_reports_focus_restore_failure() { printf '%s\n' '{"result":{"workspaces":[{"workspace_id":"w1","active_tab_id":"w1:t1","focused":true},{"workspace_id":"w9","active_tab_id":"w9:t2","focused":false}]}}' > "$resp/1.out" printf '%s\n' '{"result":{"tabs":[{"tab_id":"w1:t1","focused":true}]}}' > "$resp/2.out" printf '%s\n' '{"result":{"pane":{"pane_id":"w9:p2","tab_id":"w9:t2","workspace_id":"w9"}}}' > "$resp/3.out" - : > "$resp/4.out" - printf '%s\n' '{"result":{"workspaces":[{"workspace_id":"w1","active_tab_id":"w1:t1","focused":false},{"workspace_id":"w2","active_tab_id":"w2:t1","focused":true}]}}' > "$resp/5.out" - printf '%s\n' '{"result":{"tabs":[{"tab_id":"w2:t1","focused":true}]}}' > "$resp/6.out" - printf '%s\n' '{"result":{"tab":{"tab_id":"w1:t1","workspace_id":"w1"}}}' > "$resp/7.out" - : > "$resp/8.out" - cp "$resp/5.out" "$resp/9.out" - cp "$resp/6.out" "$resp/10.out" + printf '%s\n' '{"result":{"tabs":[{"tab_id":"w9:t1","workspace_id":"w9"},{"tab_id":"w9:t2","workspace_id":"w9"}]}}' > "$resp/4.out" + : > "$resp/5.out" + printf '%s\n' '{"error":{"code":"pane_not_found"}}' > "$resp/6.out" + printf '%s\n' '{"result":{"workspaces":[{"workspace_id":"w1","active_tab_id":"w1:t1","focused":false},{"workspace_id":"w2","active_tab_id":"w2:t1","focused":true}]}}' > "$resp/7.out" + printf '%s\n' '{"result":{"tabs":[{"tab_id":"w2:t1","focused":true}]}}' > "$resp/8.out" + printf '%s\n' '{"result":{"tab":{"tab_id":"w1:t1","workspace_id":"w1"}}}' > "$resp/9.out" + : > "$resp/10.out" + cp "$resp/7.out" "$resp/11.out" + cp "$resp/8.out" "$resp/12.out" fb=$(make_herdr_fakebin "$dir") out=$(PATH="$fb:$PATH" FM_HERDR_LOG="$log" FM_HERDR_RESPONSES="$resp" \ bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_projection_close_pane_focus_preserving fmtest w9:p2' "$ROOT" 2>&1) @@ -865,6 +1092,743 @@ test_projection_close_rechecks_required_agent_state_at_boundary() { pass "herdr presentation reclaim: live agent state at the close boundary refuses mutation" } +# --- emptying-close focus-safe removal (Herdr 0.7.5 #1621 mitigation) ------ +# +# The fixtures below model the verified 0.7.5 rules: an explicit close that +# empties a non-focused workspace moves focus to that workspace's neighbor, +# while a pane-death removal preserves focus whenever the dying workspace +# sits behind the focused one (or the focused one is last). + +# make_death_lab <dir> <shell-pid>: a fake ps and a fake workspace mover for +# the pane-death close fixtures. The mover appends to $FM_FAKE_MOVER_LOG and +# exits 9 unless $FM_FAKE_MOVER_RESPONSE names a readable response file. +make_death_lab() { # <dir> <shell-pid> + local dir=$1 pid=$2 + mkdir -p "$dir" + cat > "$dir/ps" <<SH +#!/usr/bin/env bash +case "\$*" in + "-axo pid=,ppid=") printf '1 0\n$pid 1\n' ;; + "-p $pid -o stat=") printf 'Ss+\n' ;; + "-p $pid -o comm=") printf -- '-zsh\n' ;; + *) exit 1 ;; +esac +SH + cat > "$dir/mover" <<'SH' +#!/usr/bin/env bash +printf '%s\t%s\t%s\n' "$1" "$2" "$3" >> "$FM_FAKE_MOVER_LOG" +calls=$(wc -l < "$FM_FAKE_MOVER_LOG" | tr -d ' ') +if [ "$calls" -ge 2 ] && [ -f "${FM_FAKE_MOVER_RESPONSE_2:-}" ]; then + cat "$FM_FAKE_MOVER_RESPONSE_2" + exit 0 +fi +if [ -f "$FM_FAKE_MOVER_RESPONSE" ]; then + cat "$FM_FAKE_MOVER_RESPONSE" + exit 0 +fi +exit 9 +SH + chmod +x "$dir/ps" "$dir/mover" + : > "$dir/mover.log" +} + +death_process_info_fixture() { # <pane> <pid> + printf '{"result":{"type":"pane_process_info","process_info":{"pane_id":"%s","shell_pid":%s,"foreground_process_group_id":%s,"foreground_processes":[{"pid":%s,"name":"zsh","argv0":"zsh"}]}}}\n' "$1" "$2" "$2" "$2" +} + +test_projection_close_emptying_after_focus_uses_pane_death_without_move() { + local dir log resp fb out status bgpid + dir="$TMP_ROOT/close-death-after"; mkdir -p "$dir/responses" + log="$dir/log"; resp="$dir/responses"; : > "$log" + # w1 focused; target w2 sits after it (r > a), so no repositioning is needed. + printf '%s\n' '{"result":{"workspaces":[{"workspace_id":"w1","active_tab_id":"w1:t1","focused":true},{"workspace_id":"w2","active_tab_id":"w2:t2","focused":false},{"workspace_id":"w3","active_tab_id":"w3:t1","focused":false}]}}' > "$resp/1.out" + printf '%s\n' '{"result":{"tabs":[{"tab_id":"w1:t1","focused":true}]}}' > "$resp/2.out" + printf '%s\n' '{"result":{"pane":{"pane_id":"w2:p2","tab_id":"w2:t2","workspace_id":"w2"}}}' > "$resp/3.out" + printf '%s\n' '{"result":{"tabs":[{"tab_id":"w2:t2","workspace_id":"w2"}]}}' > "$resp/4.out" + printf '%s\n' '{"result":{"panes":[{"pane_id":"w2:p2","tab_id":"w2:t2"}]}}' > "$resp/5.out" + cp "$resp/1.out" "$resp/6.out" + sleep 300 & bgpid=$! + death_process_info_fixture w2:p2 "$bgpid" > "$resp/7.out" + printf '%s\n' '{"error":{"code":"pane_not_found"}}' > "$resp/8.out" + printf '%s\n' '{"result":{"workspaces":[{"workspace_id":"w1","active_tab_id":"w1:t1","focused":true},{"workspace_id":"w3","active_tab_id":"w3:t1","focused":false}]}}' > "$resp/9.out" + printf '%s\n' '{"result":{"tabs":[{"tab_id":"w1:t1","focused":true}]}}' > "$resp/10.out" + make_death_lab "$dir" "$bgpid" + fb=$(make_herdr_fakebin "$dir") + out=$(PATH="$fb:$PATH" FM_HERDR_LOG="$log" FM_HERDR_RESPONSES="$resp" \ + FM_HERDR_PS_BIN="$dir/ps" FM_BACKEND_HERDR_WORKSPACE_MOVER="$dir/mover" \ + FM_FAKE_MOVER_LOG="$dir/mover.log" FM_FAKE_MOVER_RESPONSE="$dir/no-response" \ + FM_BACKEND_HERDR_DEATH_CLOSE_POLLS=2 \ + bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_projection_close_pane_focus_preserving fmtest w2:p2' "$ROOT" 2>&1) + status=$? + kill "$bgpid" 2>/dev/null || true; wait "$bgpid" 2>/dev/null || true + [ "$status" -eq 0 ] || fail "emptying close behind focus should succeed through the pane-death path: $out" + [ ! -s "$dir/mover.log" ] || fail "a close already behind focus invoked the workspace mover" + assert_contains "$(cat "$log")" $'pane\x1fprocess-info' "pane-death close skipped the idle-shell proof" + assert_not_contains "$(cat "$log")" $'pane\x1fclose' "emptying close behind focus used the focus-unsafe explicit close" + assert_not_contains "$(cat "$log")" $'tab\x1ffocus' "focus moved despite the pane-death removal" + pass "herdr presentation cleanup: emptying close behind focus ends the exact shell without a move or focus change" +} + +test_projection_close_emptying_before_focus_repositions_then_uses_pane_death() { + local dir log resp fb out status bgpid mover_line + dir="$TMP_ROOT/close-death-before"; mkdir -p "$dir/responses" + log="$dir/log"; resp="$dir/responses"; : > "$log" + # Target w1 sits BEFORE the focused w2, which is not last: reposition first. + printf '%s\n' '{"result":{"workspaces":[{"workspace_id":"w1","active_tab_id":"w1:t1","focused":false},{"workspace_id":"w2","active_tab_id":"w2:t1","focused":true},{"workspace_id":"w3","active_tab_id":"w3:t1","focused":false}]}}' > "$resp/1.out" + printf '%s\n' '{"result":{"tabs":[{"tab_id":"w2:t1","focused":true}]}}' > "$resp/2.out" + printf '%s\n' '{"result":{"pane":{"pane_id":"w1:p1","tab_id":"w1:t1","workspace_id":"w1"}}}' > "$resp/3.out" + printf '%s\n' '{"result":{"tabs":[{"tab_id":"w1:t1","workspace_id":"w1"}]}}' > "$resp/4.out" + printf '%s\n' '{"result":{"panes":[{"pane_id":"w1:p1","tab_id":"w1:t1"}]}}' > "$resp/5.out" + cp "$resp/1.out" "$resp/6.out" + printf '%s\n' '{"client":{"version":"0.7.5","protocol":16},"server":{"running":true}}' > "$resp/7.out" + # shellcheck disable=SC2016 # $defs is a literal JSON Schema key. + printf '%s\n' '{"schemas":{"request":{"oneOf":[{"properties":{"method":{"const":"workspace.move"}}}],"$defs":{"WorkspaceMoveParams":{"required":["workspace_id","insert_index"],"properties":{"insert_index":{"type":"integer"}}}}}}}' > "$resp/8.out" + printf '%s\n' '{"sessions":[{"name":"fmtest","running":true,"socket_path":"/tmp/fmtest.sock"}]}' > "$resp/9.out" + sleep 300 & bgpid=$! + death_process_info_fixture w1:p1 "$bgpid" > "$resp/10.out" + printf '%s\n' '{"error":{"code":"pane_not_found"}}' > "$resp/11.out" + printf '%s\n' '{"result":{"workspaces":[{"workspace_id":"w2","active_tab_id":"w2:t1","focused":true},{"workspace_id":"w3","active_tab_id":"w3:t1","focused":false}]}}' > "$resp/12.out" + cp "$resp/12.out" "$resp/13.out" + printf '%s\n' '{"result":{"tabs":[{"tab_id":"w2:t1","focused":true}]}}' > "$resp/14.out" + make_death_lab "$dir" "$bgpid" + printf '%s\n' '{"id":"fm-workspace-move","result":{"type":"workspace_list","workspaces":[{"workspace_id":"w2","focused":true},{"workspace_id":"w3","focused":false},{"workspace_id":"w1","focused":false}]}}' > "$dir/mover-response" + fb=$(make_herdr_fakebin "$dir") + out=$(PATH="$fb:$PATH" FM_HERDR_LOG="$log" FM_HERDR_RESPONSES="$resp" FM_HERDR_SCRIPT_STATUS=1 \ + FM_HERDR_PS_BIN="$dir/ps" FM_BACKEND_HERDR_WORKSPACE_MOVER="$dir/mover" \ + FM_FAKE_MOVER_LOG="$dir/mover.log" FM_FAKE_MOVER_RESPONSE="$dir/mover-response" \ + FM_BACKEND_HERDR_DEATH_CLOSE_POLLS=2 \ + bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_projection_close_pane_focus_preserving fmtest w1:p1' "$ROOT" 2>&1) + status=$? + kill "$bgpid" 2>/dev/null || true; wait "$bgpid" 2>/dev/null || true + [ "$status" -eq 0 ] || fail "repositioned emptying close should succeed through the pane-death path: $out" + [ "$(cat "$dir/mover.log")" = "$(cd /tmp && pwd -P)/fmtest.sock"$'\t'"w1"$'\t'"3" ] \ + || fail "the repositioning move did not target the exact doomed workspace at the list length: $(cat "$dir/mover.log")" + mover_line=$(grep -n $'pane\x1fprocess-info' "$log" | head -1 | cut -d: -f1) + [ -n "$mover_line" ] || fail "repositioned close skipped the idle-shell proof" + assert_not_contains "$(cat "$log")" $'pane\x1fclose' "repositioned emptying close used the focus-unsafe explicit close" + assert_not_contains "$(cat "$log")" $'tab\x1ffocus' "focus moved despite the repositioned pane-death removal" + pass "herdr presentation cleanup: emptying close before focus moves the doomed workspace to the end and ends its exact shell" +} + +test_projection_close_emptying_before_last_focus_needs_no_move() { + local dir log resp fb out status bgpid + dir="$TMP_ROOT/close-death-focus-last"; mkdir -p "$dir/responses" + log="$dir/log"; resp="$dir/responses"; : > "$log" + # Focused w3 is LAST, so the pane-death clamp preserves it without a move. + printf '%s\n' '{"result":{"workspaces":[{"workspace_id":"w1","active_tab_id":"w1:t1","focused":false},{"workspace_id":"w2","active_tab_id":"w2:t1","focused":false},{"workspace_id":"w3","active_tab_id":"w3:t1","focused":true}]}}' > "$resp/1.out" + printf '%s\n' '{"result":{"tabs":[{"tab_id":"w3:t1","focused":true}]}}' > "$resp/2.out" + printf '%s\n' '{"result":{"pane":{"pane_id":"w1:p1","tab_id":"w1:t1","workspace_id":"w1"}}}' > "$resp/3.out" + printf '%s\n' '{"result":{"tabs":[{"tab_id":"w1:t1","workspace_id":"w1"}]}}' > "$resp/4.out" + printf '%s\n' '{"result":{"panes":[{"pane_id":"w1:p1","tab_id":"w1:t1"}]}}' > "$resp/5.out" + cp "$resp/1.out" "$resp/6.out" + sleep 300 & bgpid=$! + death_process_info_fixture w1:p1 "$bgpid" > "$resp/7.out" + printf '%s\n' '{"error":{"code":"pane_not_found"}}' > "$resp/8.out" + printf '%s\n' '{"result":{"workspaces":[{"workspace_id":"w2","active_tab_id":"w2:t1","focused":false},{"workspace_id":"w3","active_tab_id":"w3:t1","focused":true}]}}' > "$resp/9.out" + printf '%s\n' '{"result":{"tabs":[{"tab_id":"w3:t1","focused":true}]}}' > "$resp/10.out" + make_death_lab "$dir" "$bgpid" + fb=$(make_herdr_fakebin "$dir") + out=$(PATH="$fb:$PATH" FM_HERDR_LOG="$log" FM_HERDR_RESPONSES="$resp" \ + FM_HERDR_PS_BIN="$dir/ps" FM_BACKEND_HERDR_WORKSPACE_MOVER="$dir/mover" \ + FM_FAKE_MOVER_LOG="$dir/mover.log" FM_FAKE_MOVER_RESPONSE="$dir/no-response" \ + FM_BACKEND_HERDR_DEATH_CLOSE_POLLS=2 \ + bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_projection_close_pane_focus_preserving fmtest w1:p1' "$ROOT" 2>&1) + status=$? + kill "$bgpid" 2>/dev/null || true; wait "$bgpid" 2>/dev/null || true + [ "$status" -eq 0 ] || fail "emptying close with last focus should succeed through the pane-death path: $out" + [ ! -s "$dir/mover.log" ] || fail "a last-focused close invoked the workspace mover" + assert_not_contains "$(cat "$log")" $'pane\x1fclose' "last-focused emptying close used the focus-unsafe explicit close" + assert_not_contains "$(cat "$log")" $'tab\x1ffocus' "focus moved despite the pane-death removal" + pass "herdr presentation cleanup: emptying close with the focused workspace last skips the move" +} + +test_projection_close_emptying_last_workspace_needs_no_move() { + local dir log resp fb out status bgpid + dir="$TMP_ROOT/close-death-target-last"; mkdir -p "$dir/responses" + log="$dir/log"; resp="$dir/responses"; : > "$log" + # Target w3 is already last (r > a), so no repositioning is needed. + printf '%s\n' '{"result":{"workspaces":[{"workspace_id":"w1","active_tab_id":"w1:t1","focused":true},{"workspace_id":"w2","active_tab_id":"w2:t1","focused":false},{"workspace_id":"w3","active_tab_id":"w3:t1","focused":false}]}}' > "$resp/1.out" + printf '%s\n' '{"result":{"tabs":[{"tab_id":"w1:t1","focused":true}]}}' > "$resp/2.out" + printf '%s\n' '{"result":{"pane":{"pane_id":"w3:p1","tab_id":"w3:t1","workspace_id":"w3"}}}' > "$resp/3.out" + printf '%s\n' '{"result":{"tabs":[{"tab_id":"w3:t1","workspace_id":"w3"}]}}' > "$resp/4.out" + printf '%s\n' '{"result":{"panes":[{"pane_id":"w3:p1","tab_id":"w3:t1"}]}}' > "$resp/5.out" + cp "$resp/1.out" "$resp/6.out" + sleep 300 & bgpid=$! + death_process_info_fixture w3:p1 "$bgpid" > "$resp/7.out" + printf '%s\n' '{"error":{"code":"pane_not_found"}}' > "$resp/8.out" + printf '%s\n' '{"result":{"workspaces":[{"workspace_id":"w1","active_tab_id":"w1:t1","focused":true},{"workspace_id":"w2","active_tab_id":"w2:t1","focused":false}]}}' > "$resp/9.out" + printf '%s\n' '{"result":{"tabs":[{"tab_id":"w1:t1","focused":true}]}}' > "$resp/10.out" + make_death_lab "$dir" "$bgpid" + fb=$(make_herdr_fakebin "$dir") + out=$(PATH="$fb:$PATH" FM_HERDR_LOG="$log" FM_HERDR_RESPONSES="$resp" \ + FM_HERDR_PS_BIN="$dir/ps" FM_BACKEND_HERDR_WORKSPACE_MOVER="$dir/mover" \ + FM_FAKE_MOVER_LOG="$dir/mover.log" FM_FAKE_MOVER_RESPONSE="$dir/no-response" \ + FM_BACKEND_HERDR_DEATH_CLOSE_POLLS=2 \ + bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_projection_close_pane_focus_preserving fmtest w3:p1' "$ROOT" 2>&1) + status=$? + kill "$bgpid" 2>/dev/null || true; wait "$bgpid" 2>/dev/null || true + [ "$status" -eq 0 ] || fail "last-workspace emptying close should succeed through the pane-death path: $out" + [ ! -s "$dir/mover.log" ] || fail "an already-last close invoked the workspace mover" + assert_not_contains "$(cat "$log")" $'pane\x1fclose' "last-workspace emptying close used the focus-unsafe explicit close" + pass "herdr presentation cleanup: emptying close of the last workspace skips the move" +} + +test_projection_close_non_emptying_stays_plain_without_proof_or_move() { + local dir log resp fb out status bgpid + dir="$TMP_ROOT/close-non-emptying"; mkdir -p "$dir/responses" + log="$dir/log"; resp="$dir/responses"; : > "$log" + printf '%s\n' '{"result":{"workspaces":[{"workspace_id":"w1","active_tab_id":"w1:t1","focused":true},{"workspace_id":"w2","active_tab_id":"w2:t2","focused":false}]}}' > "$resp/1.out" + printf '%s\n' '{"result":{"tabs":[{"tab_id":"w1:t1","focused":true}]}}' > "$resp/2.out" + printf '%s\n' '{"result":{"pane":{"pane_id":"w2:p2","tab_id":"w2:t2","workspace_id":"w2"}}}' > "$resp/3.out" + printf '%s\n' '{"result":{"tabs":[{"tab_id":"w2:t1","workspace_id":"w2"},{"tab_id":"w2:t2","workspace_id":"w2"}]}}' > "$resp/4.out" + printf '%s\n' '{"error":{"code":"pane_not_found"}}' > "$resp/6.out" + printf '%s\n' '{"result":{"workspaces":[{"workspace_id":"w1","active_tab_id":"w1:t1","focused":true},{"workspace_id":"w2","active_tab_id":"w2:t1","focused":false}]}}' > "$resp/7.out" + printf '%s\n' '{"result":{"tabs":[{"tab_id":"w1:t1","focused":true}]}}' > "$resp/8.out" + sleep 300 & bgpid=$! + make_death_lab "$dir" "$bgpid" + fb=$(make_herdr_fakebin "$dir") + out=$(PATH="$fb:$PATH" FM_HERDR_LOG="$log" FM_HERDR_RESPONSES="$resp" \ + FM_HERDR_PS_BIN="$dir/ps" FM_BACKEND_HERDR_WORKSPACE_MOVER="$dir/mover" \ + FM_FAKE_MOVER_LOG="$dir/mover.log" FM_FAKE_MOVER_RESPONSE="$dir/no-response" \ + FM_BACKEND_HERDR_DEATH_CLOSE_POLLS=2 \ + bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_projection_close_pane_focus_preserving fmtest w2:p2' "$ROOT" 2>&1) + status=$? + [ "$status" -eq 0 ] || fail "non-emptying close should succeed through the plain close: $out" + assert_contains "$(cat "$log")" $'pane\x1fclose\x1fw2:p2' "non-emptying close did not use the plain close" + assert_not_contains "$(cat "$log")" $'pane\x1fprocess-info' "non-emptying close ran the idle-shell proof" + [ ! -s "$dir/mover.log" ] || fail "non-emptying close invoked the workspace mover" + kill -0 "$bgpid" 2>/dev/null || fail "non-emptying close signaled the pane's shell" + kill "$bgpid" 2>/dev/null || true; wait "$bgpid" 2>/dev/null || true + pass "herdr presentation cleanup: a non-emptying close stays plain with no proof, move, or signal" +} + +test_projection_close_plain_without_move_requires_structured_removal() { + local dir log out status + dir="$TMP_ROOT/close-plain-unconfirmed"; mkdir -p "$dir" + log="$dir/log"; : > "$log" + out=$(ROOT="$ROOT" LOG="$log" bash -c ' + . "$ROOT/bin/backends/herdr.sh" + fm_backend_herdr_projection_focus_snapshot() { printf "w1\tw1:t1"; } + fm_backend_herdr_emptying_close_plan() { printf "plain\n"; } + fm_backend_herdr_projection_focus_restore() { return 0; } + fm_backend_herdr_cli() { + printf "%s\n" "$*" >> "$LOG" + case "$2 $3" in + "pane get") printf "{\"result\":{\"pane\":{\"pane_id\":\"w2:p2\",\"tab_id\":\"w2:t2\",\"workspace_id\":\"w2\"}}}\n" ;; + esac + } + fm_backend_herdr_projection_close_pane_focus_preserving fmtest w2:p2 + ' 2>&1) + status=$? + [ "$status" -ne 0 ] || fail "a no-move plain close must fail while structured presence remains present: $out" + assert_contains "$(cat "$log")" "pane close w2:p2" \ + "the no-move unconfirmed regression did not reach the explicit close" + pass "herdr presentation cleanup: no-move plain close requires structured pane removal" +} + +test_projection_close_ambiguous_positions_fall_back_to_plain_close() { + local dir log resp fb out status bgpid + dir="$TMP_ROOT/close-ambiguous-positions"; mkdir -p "$dir/responses" + log="$dir/log"; resp="$dir/responses"; : > "$log" + printf '%s\n' '{"result":{"workspaces":[{"workspace_id":"w1","active_tab_id":"w1:t1","focused":true}]}}' > "$resp/1.out" + printf '%s\n' '{"result":{"tabs":[{"tab_id":"w1:t1","focused":true}]}}' > "$resp/2.out" + printf '%s\n' '{"result":{"pane":{"pane_id":"w2:p2","tab_id":"w2:t2","workspace_id":"w2"}}}' > "$resp/3.out" + printf '%s\n' '{"result":{"tabs":[{"tab_id":"w2:t2","workspace_id":"w2"}]}}' > "$resp/4.out" + printf '%s\n' '{"result":{"panes":[{"pane_id":"w2:p2","tab_id":"w2:t2"}]}}' > "$resp/5.out" + # The position snapshot is ambiguous: the target workspace is absent. + printf '%s\n' '{"result":{"workspaces":[{"workspace_id":"w1","active_tab_id":"w1:t1","focused":true}]}}' > "$resp/6.out" + printf '%s\n' '{"error":{"code":"pane_not_found"}}' > "$resp/8.out" + printf '%s\n' '{"result":{"workspaces":[{"workspace_id":"w1","active_tab_id":"w1:t1","focused":true}]}}' > "$resp/9.out" + printf '%s\n' '{"result":{"tabs":[{"tab_id":"w1:t1","focused":true}]}}' > "$resp/10.out" + sleep 300 & bgpid=$! + make_death_lab "$dir" "$bgpid" + fb=$(make_herdr_fakebin "$dir") + out=$(PATH="$fb:$PATH" FM_HERDR_LOG="$log" FM_HERDR_RESPONSES="$resp" \ + FM_HERDR_PS_BIN="$dir/ps" FM_BACKEND_HERDR_WORKSPACE_MOVER="$dir/mover" \ + FM_FAKE_MOVER_LOG="$dir/mover.log" FM_FAKE_MOVER_RESPONSE="$dir/no-response" \ + FM_BACKEND_HERDR_DEATH_CLOSE_POLLS=2 \ + bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_projection_close_pane_focus_preserving fmtest w2:p2' "$ROOT" 2>&1) + status=$? + [ "$status" -eq 0 ] || fail "an ambiguous position snapshot should fall back to the plain close: $out" + assert_contains "$(cat "$log")" $'pane\x1fclose\x1fw2:p2' "ambiguous positions did not use the plain close" + assert_not_contains "$(cat "$log")" $'pane\x1fprocess-info' "ambiguous positions ran the idle-shell proof" + [ ! -s "$dir/mover.log" ] || fail "ambiguous positions invoked the workspace mover" + kill -0 "$bgpid" 2>/dev/null || fail "ambiguous positions signaled the pane's shell" + kill "$bgpid" 2>/dev/null || true; wait "$bgpid" 2>/dev/null || true + pass "herdr presentation cleanup: an ambiguous workspace layout falls back to the plain close" +} + +test_projection_close_move_failure_falls_back_to_plain_close() { + local dir log resp fb out status bgpid + dir="$TMP_ROOT/close-move-failure"; mkdir -p "$dir/responses" + log="$dir/log"; resp="$dir/responses"; : > "$log" + printf '%s\n' '{"result":{"workspaces":[{"workspace_id":"w1","active_tab_id":"w1:t1","focused":false},{"workspace_id":"w2","active_tab_id":"w2:t1","focused":true},{"workspace_id":"w3","active_tab_id":"w3:t1","focused":false}]}}' > "$resp/1.out" + printf '%s\n' '{"result":{"tabs":[{"tab_id":"w2:t1","focused":true}]}}' > "$resp/2.out" + printf '%s\n' '{"result":{"pane":{"pane_id":"w1:p1","tab_id":"w1:t1","workspace_id":"w1"}}}' > "$resp/3.out" + printf '%s\n' '{"result":{"tabs":[{"tab_id":"w1:t1","workspace_id":"w1"}]}}' > "$resp/4.out" + printf '%s\n' '{"result":{"panes":[{"pane_id":"w1:p1","tab_id":"w1:t1"}]}}' > "$resp/5.out" + cp "$resp/1.out" "$resp/6.out" + printf '%s\n' '{"client":{"version":"0.7.5","protocol":16},"server":{"running":true}}' > "$resp/7.out" + # shellcheck disable=SC2016 # $defs is a literal JSON Schema key. + printf '%s\n' '{"schemas":{"request":{"oneOf":[{"properties":{"method":{"const":"workspace.move"}}}],"$defs":{"WorkspaceMoveParams":{"required":["workspace_id","insert_index"],"properties":{"insert_index":{"type":"integer"}}}}}}}' > "$resp/8.out" + printf '%s\n' '{"sessions":[{"name":"fmtest","running":true,"socket_path":"/tmp/fmtest.sock"}]}' > "$resp/9.out" + printf '%s\n' '{"error":{"code":"pane_not_found"}}' > "$resp/11.out" + printf '%s\n' '{"result":{"workspaces":[{"workspace_id":"w2","active_tab_id":"w2:t1","focused":true},{"workspace_id":"w3","active_tab_id":"w3:t1","focused":false}]}}' > "$resp/12.out" + cp "$resp/12.out" "$resp/13.out" + printf '%s\n' '{"result":{"tabs":[{"tab_id":"w2:t1","focused":true}]}}' > "$resp/14.out" + sleep 300 & bgpid=$! + make_death_lab "$dir" "$bgpid" + fb=$(make_herdr_fakebin "$dir") + out=$(PATH="$fb:$PATH" FM_HERDR_LOG="$log" FM_HERDR_RESPONSES="$resp" FM_HERDR_SCRIPT_STATUS=1 \ + FM_HERDR_PS_BIN="$dir/ps" FM_BACKEND_HERDR_WORKSPACE_MOVER="$dir/mover" \ + FM_FAKE_MOVER_LOG="$dir/mover.log" FM_FAKE_MOVER_RESPONSE="$dir/no-response" \ + FM_BACKEND_HERDR_DEATH_CLOSE_POLLS=2 \ + bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_projection_close_pane_focus_preserving fmtest w1:p1' "$ROOT" 2>&1) + status=$? + [ "$status" -eq 0 ] || fail "a failed repositioning move should fall back to the plain close: $out" + assert_contains "$out" "could not move the doomed workspace behind the focused one" \ + "a failed repositioning move did not warn about losing the focus-safe path" + assert_contains "$(cat "$log")" $'pane\x1fclose\x1fw1:p1' "move failure did not use the plain close" + assert_not_contains "$(cat "$log")" $'pane\x1fprocess-info' "move failure ran the idle-shell proof" + kill -0 "$bgpid" 2>/dev/null || fail "move failure signaled the pane's shell" + kill "$bgpid" 2>/dev/null || true; wait "$bgpid" 2>/dev/null || true + pass "herdr presentation cleanup: a failed repositioning move falls back to the plain close with a warning" +} + +test_projection_close_busy_pane_falls_back_to_plain_close() { + local dir log resp fb out status bgpid + dir="$TMP_ROOT/close-busy-pane"; mkdir -p "$dir/responses" + log="$dir/log"; resp="$dir/responses"; : > "$log" + printf '%s\n' '{"result":{"workspaces":[{"workspace_id":"w1","active_tab_id":"w1:t1","focused":true},{"workspace_id":"w2","active_tab_id":"w2:t2","focused":false}]}}' > "$resp/1.out" + printf '%s\n' '{"result":{"tabs":[{"tab_id":"w1:t1","focused":true}]}}' > "$resp/2.out" + printf '%s\n' '{"result":{"pane":{"pane_id":"w2:p2","tab_id":"w2:t2","workspace_id":"w2"}}}' > "$resp/3.out" + printf '%s\n' '{"result":{"tabs":[{"tab_id":"w2:t2","workspace_id":"w2"}]}}' > "$resp/4.out" + printf '%s\n' '{"result":{"panes":[{"pane_id":"w2:p2","tab_id":"w2:t2"}]}}' > "$resp/5.out" + cp "$resp/1.out" "$resp/6.out" + sleep 300 & bgpid=$! + # The pane still has a foreground agent, so the idle-shell proof refuses. + printf '{"result":{"type":"pane_process_info","process_info":{"pane_id":"w2:p2","shell_pid":%s,"foreground_process_group_id":%s,"foreground_processes":[{"pid":%s,"name":"zsh","argv0":"zsh"},{"pid":99999,"name":"pi","argv0":"pi"}]}}}\n' "$bgpid" "$bgpid" "$bgpid" > "$resp/7.out" + printf '%s\n' '{"error":{"code":"pane_not_found"}}' > "$resp/9.out" + printf '%s\n' '{"result":{"workspaces":[{"workspace_id":"w1","active_tab_id":"w1:t1","focused":true}]}}' > "$resp/10.out" + printf '%s\n' '{"result":{"tabs":[{"tab_id":"w1:t1","focused":true}]}}' > "$resp/11.out" + make_death_lab "$dir" "$bgpid" + fb=$(make_herdr_fakebin "$dir") + out=$(PATH="$fb:$PATH" FM_HERDR_LOG="$log" FM_HERDR_RESPONSES="$resp" \ + FM_HERDR_PS_BIN="$dir/ps" FM_BACKEND_HERDR_WORKSPACE_MOVER="$dir/mover" \ + FM_FAKE_MOVER_LOG="$dir/mover.log" FM_FAKE_MOVER_RESPONSE="$dir/no-response" \ + FM_BACKEND_HERDR_DEATH_CLOSE_POLLS=2 FM_BACKEND_HERDR_IDLE_SHELL_PROOF_POLLS=1 \ + bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_projection_close_pane_focus_preserving fmtest w2:p2' "$ROOT" 2>&1) + status=$? + [ "$status" -eq 0 ] || fail "a busy pane should fall back to the plain close: $out" + assert_contains "$(cat "$log")" $'pane\x1fclose\x1fw2:p2' "a busy pane did not use the plain close" + kill -0 "$bgpid" 2>/dev/null || fail "a busy pane close signaled the pane's shell" + kill "$bgpid" 2>/dev/null || true; wait "$bgpid" 2>/dev/null || true + pass "herdr presentation cleanup: a pane with a live foreground process falls back to the plain close" +} + +test_projection_close_transient_prompt_helper_settles_then_uses_pane_death() { + local dir log resp fb out status bgpid + dir="$TMP_ROOT/close-transient-helper"; mkdir -p "$dir/responses" + log="$dir/log"; resp="$dir/responses"; : > "$log" + printf '%s\n' '{"result":{"workspaces":[{"workspace_id":"w1","active_tab_id":"w1:t1","focused":true},{"workspace_id":"w2","active_tab_id":"w2:t2","focused":false}]}}' > "$resp/1.out" + printf '%s\n' '{"result":{"tabs":[{"tab_id":"w1:t1","focused":true}]}}' > "$resp/2.out" + printf '%s\n' '{"result":{"pane":{"pane_id":"w2:p2","tab_id":"w2:t2","workspace_id":"w2"}}}' > "$resp/3.out" + printf '%s\n' '{"result":{"tabs":[{"tab_id":"w2:t2","workspace_id":"w2"}]}}' > "$resp/4.out" + printf '%s\n' '{"result":{"panes":[{"pane_id":"w2:p2","tab_id":"w2:t2"}]}}' > "$resp/5.out" + cp "$resp/1.out" "$resp/6.out" + sleep 300 & bgpid=$! + # Sample 1: the shell is transiently redrawing its prompt (real 0.7.5 shape: + # a helper such as starship rides along as a second foreground process). + printf '{"result":{"type":"pane_process_info","process_info":{"pane_id":"w2:p2","shell_pid":%s,"foreground_process_group_id":%s,"foreground_processes":[{"pid":99998,"name":"starship","argv":["/usr/local/bin/starship","prompt","--continuation"]},{"pid":%s,"name":"zsh","argv0":"zsh"}]}}}\n' "$bgpid" "$bgpid" "$bgpid" > "$resp/7.out" + # Sample 2: the helper finished; the shell is provably alone and idle. + death_process_info_fixture w2:p2 "$bgpid" > "$resp/8.out" + printf '%s\n' '{"error":{"code":"pane_not_found"}}' > "$resp/9.out" + printf '%s\n' '{"result":{"workspaces":[{"workspace_id":"w1","active_tab_id":"w1:t1","focused":true}]}}' > "$resp/10.out" + printf '%s\n' '{"result":{"tabs":[{"tab_id":"w1:t1","focused":true}]}}' > "$resp/11.out" + make_death_lab "$dir" "$bgpid" + fb=$(make_herdr_fakebin "$dir") + out=$(PATH="$fb:$PATH" FM_HERDR_LOG="$log" FM_HERDR_RESPONSES="$resp" \ + FM_HERDR_PS_BIN="$dir/ps" FM_BACKEND_HERDR_WORKSPACE_MOVER="$dir/mover" \ + FM_FAKE_MOVER_LOG="$dir/mover.log" FM_FAKE_MOVER_RESPONSE="$dir/no-response" \ + FM_BACKEND_HERDR_DEATH_CLOSE_POLLS=2 FM_BACKEND_HERDR_IDLE_SHELL_PROOF_POLLS=3 \ + bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_projection_close_pane_focus_preserving fmtest w2:p2' "$ROOT" 2>&1) + status=$? + kill "$bgpid" 2>/dev/null || true; wait "$bgpid" 2>/dev/null || true + [ "$status" -eq 0 ] || fail "a transient prompt helper should settle into the pane-death path: $out" + [ "$(grep -c $'pane\x1fprocess-info' "$log")" -ge 2 ] \ + || fail "the settle window did not retry the idle-shell proof" + assert_not_contains "$(cat "$log")" $'pane\x1fclose' "a transient prompt helper forced the focus-unsafe explicit close" + assert_not_contains "$(cat "$log")" $'tab\x1ffocus' "focus moved despite the settled pane-death removal" + pass "herdr presentation cleanup: a transient prompt helper settles into the pane-death path instead of the plain close" +} + +test_projection_close_death_escalates_sigkill_after_sighup_survival() { + local dir log resp fb out status bgpid + dir="$TMP_ROOT/close-death-escalate"; mkdir -p "$dir/responses" + log="$dir/log"; resp="$dir/responses"; : > "$log" + printf '%s\n' '{"result":{"workspaces":[{"workspace_id":"w1","active_tab_id":"w1:t1","focused":true},{"workspace_id":"w2","active_tab_id":"w2:t2","focused":false}]}}' > "$resp/1.out" + printf '%s\n' '{"result":{"tabs":[{"tab_id":"w1:t1","focused":true}]}}' > "$resp/2.out" + printf '%s\n' '{"result":{"pane":{"pane_id":"w2:p2","tab_id":"w2:t2","workspace_id":"w2"}}}' > "$resp/3.out" + printf '%s\n' '{"result":{"tabs":[{"tab_id":"w2:t2","workspace_id":"w2"}]}}' > "$resp/4.out" + printf '%s\n' '{"result":{"panes":[{"pane_id":"w2:p2","tab_id":"w2:t2"}]}}' > "$resp/5.out" + cp "$resp/1.out" "$resp/6.out" + bash -c 'trap "" HUP; sleep 300' & bgpid=$! + death_process_info_fixture w2:p2 "$bgpid" > "$resp/7.out" + printf '%s\n' '{"error":{"code":"internal_error","message":"transient failure"}}' > "$resp/8.out" + printf '%s\n' '{"result":{"pane":{"pane_id":"w2:p2"}}}' > "$resp/9.out" + death_process_info_fixture w2:p2 "$bgpid" > "$resp/10.out" + printf '%s\n' '{"error":{"code":"pane_not_found"}}' > "$resp/11.out" + printf '%s\n' '{"result":{"workspaces":[{"workspace_id":"w1","active_tab_id":"w1:t1","focused":true}]}}' > "$resp/12.out" + printf '%s\n' '{"result":{"tabs":[{"tab_id":"w1:t1","focused":true}]}}' > "$resp/13.out" + make_death_lab "$dir" "$bgpid" + fb=$(make_herdr_fakebin "$dir") + out=$(PATH="$fb:$PATH" FM_HERDR_LOG="$log" FM_HERDR_RESPONSES="$resp" \ + FM_HERDR_PS_BIN="$dir/ps" FM_BACKEND_HERDR_WORKSPACE_MOVER="$dir/mover" \ + FM_FAKE_MOVER_LOG="$dir/mover.log" FM_FAKE_MOVER_RESPONSE="$dir/no-response" \ + FM_BACKEND_HERDR_DEATH_CLOSE_POLLS=2 \ + bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_projection_close_pane_focus_preserving fmtest w2:p2' "$ROOT" 2>&1) + status=$? + [ "$status" -eq 0 ] || fail "a SIGHUP-surviving shell should be finished by the SIGKILL escalation: $out" + assert_not_contains "$(cat "$log")" $'pane\x1fclose' "the SIGKILL escalation used the focus-unsafe explicit close" + if kill -0 "$bgpid" 2>/dev/null; then + kill "$bgpid" 2>/dev/null || true; wait "$bgpid" 2>/dev/null || true + fail "the SIGKILL escalation left the trapped shell alive" + fi + wait "$bgpid" 2>/dev/null || true + pass "herdr presentation cleanup: a SIGHUP-surviving shell is escalated to SIGKILL before giving up" +} + +test_projection_close_death_failure_falls_back_to_plain_close() { + local dir log resp fb out status bgpid + dir="$TMP_ROOT/close-death-fallback"; mkdir -p "$dir/responses" + log="$dir/log"; resp="$dir/responses"; : > "$log" + printf '%s\n' '{"result":{"workspaces":[{"workspace_id":"w1","active_tab_id":"w1:t1","focused":true},{"workspace_id":"w2","active_tab_id":"w2:t2","focused":false}]}}' > "$resp/1.out" + printf '%s\n' '{"result":{"tabs":[{"tab_id":"w1:t1","focused":true}]}}' > "$resp/2.out" + printf '%s\n' '{"result":{"pane":{"pane_id":"w2:p2","tab_id":"w2:t2","workspace_id":"w2"}}}' > "$resp/3.out" + printf '%s\n' '{"result":{"tabs":[{"tab_id":"w2:t2","workspace_id":"w2"}]}}' > "$resp/4.out" + printf '%s\n' '{"result":{"panes":[{"pane_id":"w2:p2","tab_id":"w2:t2"}]}}' > "$resp/5.out" + cp "$resp/1.out" "$resp/6.out" + bash -c 'trap "" HUP; sleep 300' & bgpid=$! + death_process_info_fixture w2:p2 "$bgpid" > "$resp/7.out" + printf '%s\n' '{"result":{"pane":{"pane_id":"w2:p2"}}}' > "$resp/8.out" + printf '%s\n' '{"result":{"pane":{"pane_id":"w2:p2"}}}' > "$resp/9.out" + death_process_info_fixture w2:p2 "$bgpid" > "$resp/10.out" + printf '%s\n' '{"result":{"pane":{"pane_id":"w2:p2"}}}' > "$resp/11.out" + printf '%s\n' '{"result":{"pane":{"pane_id":"w2:p2"}}}' > "$resp/12.out" + printf '%s\n' '{"error":{"code":"pane_not_found"}}' > "$resp/14.out" + printf '%s\n' '{"result":{"workspaces":[{"workspace_id":"w1","active_tab_id":"w1:t1","focused":true}]}}' > "$resp/15.out" + printf '%s\n' '{"result":{"tabs":[{"tab_id":"w1:t1","focused":true}]}}' > "$resp/16.out" + make_death_lab "$dir" "$bgpid" + fb=$(make_herdr_fakebin "$dir") + out=$(PATH="$fb:$PATH" FM_HERDR_LOG="$log" FM_HERDR_RESPONSES="$resp" \ + FM_HERDR_PS_BIN="$dir/ps" FM_BACKEND_HERDR_WORKSPACE_MOVER="$dir/mover" \ + FM_FAKE_MOVER_LOG="$dir/mover.log" FM_FAKE_MOVER_RESPONSE="$dir/no-response" \ + FM_BACKEND_HERDR_DEATH_CLOSE_POLLS=2 \ + bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_projection_close_pane_focus_preserving fmtest w2:p2' "$ROOT" 2>&1) + status=$? + [ "$status" -eq 0 ] || fail "an unkillable shell should fall back to the plain close: $out" + assert_contains "$(cat "$log")" $'pane\x1fclose\x1fw2:p2' "a failed pane-death close did not use the plain close fallback" + kill "$bgpid" 2>/dev/null || true; wait "$bgpid" 2>/dev/null || true + pass "herdr presentation cleanup: a failed pane-death close falls back to the plain close" +} + +test_projection_close_death_still_restores_a_stolen_focus() { + local dir log resp fb out status bgpid + dir="$TMP_ROOT/close-death-restore"; mkdir -p "$dir/responses" + log="$dir/log"; resp="$dir/responses"; : > "$log" + printf '%s\n' '{"result":{"workspaces":[{"workspace_id":"w1","active_tab_id":"w1:t1","focused":true},{"workspace_id":"w2","active_tab_id":"w2:t2","focused":false},{"workspace_id":"w3","active_tab_id":"w3:t1","focused":false}]}}' > "$resp/1.out" + printf '%s\n' '{"result":{"tabs":[{"tab_id":"w1:t1","focused":true}]}}' > "$resp/2.out" + printf '%s\n' '{"result":{"pane":{"pane_id":"w2:p2","tab_id":"w2:t2","workspace_id":"w2"}}}' > "$resp/3.out" + printf '%s\n' '{"result":{"tabs":[{"tab_id":"w2:t2","workspace_id":"w2"}]}}' > "$resp/4.out" + printf '%s\n' '{"result":{"panes":[{"pane_id":"w2:p2","tab_id":"w2:t2"}]}}' > "$resp/5.out" + cp "$resp/1.out" "$resp/6.out" + sleep 300 & bgpid=$! + death_process_info_fixture w2:p2 "$bgpid" > "$resp/7.out" + printf '%s\n' '{"error":{"code":"pane_not_found"}}' > "$resp/8.out" + # The backstop still fires when the post-close snapshot disagrees. + printf '%s\n' '{"result":{"workspaces":[{"workspace_id":"w1","active_tab_id":"w1:t1","focused":false},{"workspace_id":"w3","active_tab_id":"w3:t1","focused":true}]}}' > "$resp/9.out" + printf '%s\n' '{"result":{"tabs":[{"tab_id":"w3:t1","focused":true}]}}' > "$resp/10.out" + printf '%s\n' '{"result":{"tab":{"tab_id":"w1:t1","workspace_id":"w1"}}}' > "$resp/11.out" + printf '%s\n' '{"result":{"workspaces":[{"workspace_id":"w1","active_tab_id":"w1:t1","focused":true},{"workspace_id":"w3","active_tab_id":"w3:t1","focused":false}]}}' > "$resp/13.out" + printf '%s\n' '{"result":{"tabs":[{"tab_id":"w1:t1","focused":true}]}}' > "$resp/14.out" + make_death_lab "$dir" "$bgpid" + fb=$(make_herdr_fakebin "$dir") + out=$(PATH="$fb:$PATH" FM_HERDR_LOG="$log" FM_HERDR_RESPONSES="$resp" \ + FM_HERDR_PS_BIN="$dir/ps" FM_BACKEND_HERDR_WORKSPACE_MOVER="$dir/mover" \ + FM_FAKE_MOVER_LOG="$dir/mover.log" FM_FAKE_MOVER_RESPONSE="$dir/no-response" \ + FM_BACKEND_HERDR_DEATH_CLOSE_POLLS=2 \ + bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_projection_close_pane_focus_preserving fmtest w2:p2' "$ROOT" 2>&1) + status=$? + [ "$status" -eq 0 ] || fail "the pane-death close with a restored backstop should succeed: $out" + assert_contains "$(cat "$log")" $'tab\x1ffocus\x1fw1:t1' "the backstop did not restore the exact prior tab" + pass "herdr presentation cleanup: the exact-tab restore remains the backstop behind the pane-death close" +} + +test_projection_close_death_never_sigkills_a_reused_pid() { + local dir log resp fb out status bgpid + dir="$TMP_ROOT/close-death-pid-reuse"; mkdir -p "$dir/responses" + log="$dir/log"; resp="$dir/responses"; : > "$log" + printf '%s\n' '{"result":{"workspaces":[{"workspace_id":"w1","active_tab_id":"w1:t1","focused":true},{"workspace_id":"w2","active_tab_id":"w2:t2","focused":false}]}}' > "$resp/1.out" + printf '%s\n' '{"result":{"tabs":[{"tab_id":"w1:t1","focused":true}]}}' > "$resp/2.out" + printf '%s\n' '{"result":{"pane":{"pane_id":"w2:p2","tab_id":"w2:t2","workspace_id":"w2"}}}' > "$resp/3.out" + printf '%s\n' '{"result":{"tabs":[{"tab_id":"w2:t2","workspace_id":"w2"}]}}' > "$resp/4.out" + printf '%s\n' '{"result":{"panes":[{"pane_id":"w2:p2","tab_id":"w2:t2"}]}}' > "$resp/5.out" + cp "$resp/1.out" "$resp/6.out" + # The original shell survives SIGHUP; by SIGKILL time the pane's process + # information shows a DIFFERENT shell pid, modeling the original pid having + # been reused by an unrelated process the pane no longer owns. + bash -c 'trap "" HUP; sleep 300' & bgpid=$! + death_process_info_fixture w2:p2 "$bgpid" > "$resp/7.out" + cp "$resp/3.out" "$resp/8.out" # SIGHUP poll 1: pane still present + cp "$resp/3.out" "$resp/9.out" # SIGHUP poll 2: pane still present + death_process_info_fixture w2:p2 99997 > "$resp/10.out" + : > "$resp/11.out" # fallback explicit close: pane close ok + printf '%s\n' '{"error":{"code":"pane_not_found"}}' > "$resp/12.out" + printf '%s\n' '{"result":{"workspaces":[{"workspace_id":"w1","active_tab_id":"w1:t1","focused":true}]}}' > "$resp/13.out" + printf '%s\n' '{"result":{"tabs":[{"tab_id":"w1:t1","focused":true}]}}' > "$resp/14.out" + make_death_lab "$dir" "$bgpid" + fb=$(make_herdr_fakebin "$dir") + out=$(PATH="$fb:$PATH" FM_HERDR_LOG="$log" FM_HERDR_RESPONSES="$resp" \ + FM_HERDR_PS_BIN="$dir/ps" FM_BACKEND_HERDR_WORKSPACE_MOVER="$dir/mover" \ + FM_FAKE_MOVER_LOG="$dir/mover.log" FM_FAKE_MOVER_RESPONSE="$dir/no-response" \ + FM_BACKEND_HERDR_DEATH_CLOSE_POLLS=2 \ + bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_projection_close_pane_focus_preserving fmtest w2:p2' "$ROOT" 2>&1) + status=$? + if ! kill -0 "$bgpid" 2>/dev/null; then + wait "$bgpid" 2>/dev/null || true + fail "the SIGKILL escalation signaled a pid the exact pane no longer owns" + fi + kill -KILL "$bgpid" 2>/dev/null || true; wait "$bgpid" 2>/dev/null || true + [ "$status" -eq 0 ] || fail "the refused escalation should fall back to the plain close: $out" + assert_contains "$(cat "$log")" $'pane\x1fclose\x1fw2:p2' "the refused escalation did not fall back to the plain close" + pass "herdr presentation cleanup: SIGKILL never reaches a pid the exact pane no longer owns" +} + +assert_projection_close_failed_removal_rolls_back_the_reposition() { + local mode=$1 dir log resp fb out status bgpid + dir="$TMP_ROOT/close-move-rollback-$mode"; mkdir -p "$dir/responses" + log="$dir/log"; resp="$dir/responses"; : > "$log" + # Doomed w1 sits BEFORE the focused w2 (not last): the plan repositions it + # to the end; then every removal path fails, so the exact original order + # must be restored under the same session lock and the close must fail. + printf '%s\n' '{"result":{"workspaces":[{"workspace_id":"w1","active_tab_id":"w1:t1","focused":false},{"workspace_id":"w2","active_tab_id":"w2:t1","focused":true},{"workspace_id":"w3","active_tab_id":"w3:t1","focused":false}]}}' > "$resp/1.out" + printf '%s\n' '{"result":{"tabs":[{"tab_id":"w2:t1","focused":true}]}}' > "$resp/2.out" + printf '%s\n' '{"result":{"pane":{"pane_id":"w1:p1","tab_id":"w1:t1","workspace_id":"w1"}}}' > "$resp/3.out" + printf '%s\n' '{"result":{"tabs":[{"tab_id":"w1:t1","workspace_id":"w1"}]}}' > "$resp/4.out" + printf '%s\n' '{"result":{"panes":[{"pane_id":"w1:p1","tab_id":"w1:t1"}]}}' > "$resp/5.out" + cp "$resp/1.out" "$resp/6.out" + printf '%s\n' '{"client":{"version":"0.7.5","protocol":16},"server":{"running":true}}' > "$resp/7.out" + # shellcheck disable=SC2016 # $defs is a literal JSON Schema key. + printf '%s\n' '{"schemas":{"request":{"oneOf":[{"properties":{"method":{"const":"workspace.move"}}}],"$defs":{"WorkspaceMoveParams":{"required":["workspace_id","insert_index"],"properties":{"insert_index":{"type":"integer"}}}}}}}' > "$resp/8.out" + printf '%s\n' '{"sessions":[{"name":"fmtest","running":true,"socket_path":"/tmp/fmtest.sock"}]}' > "$resp/9.out" + bash -c 'trap "" HUP; sleep 300' & bgpid=$! + death_process_info_fixture w1:p1 "$bgpid" > "$resp/10.out" + if [ "$mode" = pane-gone-workspace-present ]; then + printf '%s\n' '{"error":{"code":"pane_not_found"}}' > "$resp/11.out" + printf '%s\n' '{"result":{"workspaces":[{"workspace_id":"w2","active_tab_id":"w2:t1","focused":true},{"workspace_id":"w3","active_tab_id":"w3:t1","focused":false},{"workspace_id":"w1","active_tab_id":"w1:t2","focused":false}]}}' > "$resp/12.out" + printf '%s\n' '{"result":{"workspaces":[{"workspace_id":"w1","active_tab_id":"w1:t2","focused":false},{"workspace_id":"w2","active_tab_id":"w2:t1","focused":true},{"workspace_id":"w3","active_tab_id":"w3:t1","focused":false}]}}' > "$resp/13.out" + printf '%s\n' '{"result":{"tabs":[{"tab_id":"w2:t1","focused":true}]}}' > "$resp/14.out" + else + cp "$resp/3.out" "$resp/11.out" # SIGHUP poll 1: pane still present + cp "$resp/3.out" "$resp/12.out" # SIGHUP poll 2: pane still present + death_process_info_fixture w1:p1 "$bgpid" > "$resp/13.out" # escalation resample: same owner + cp "$resp/3.out" "$resp/14.out" # SIGKILL poll 1: pane still present + cp "$resp/3.out" "$resp/15.out" # SIGKILL poll 2: pane still present + fi + if [ "$mode" = command-fails ]; then + printf '9\n' > "$resp/16.exit" + printf '%s\n' '{"result":{"workspaces":[{"workspace_id":"w1","active_tab_id":"w1:t1","focused":false},{"workspace_id":"w2","active_tab_id":"w2:t1","focused":true},{"workspace_id":"w3","active_tab_id":"w3:t1","focused":false}]}}' > "$resp/17.out" + printf '%s\n' '{"result":{"tabs":[{"tab_id":"w2:t1","focused":true}]}}' > "$resp/18.out" + else + : > "$resp/16.out" + cp "$resp/3.out" "$resp/17.out" + printf '%s\n' '{"result":{"workspaces":[{"workspace_id":"w1","active_tab_id":"w1:t1","focused":false},{"workspace_id":"w2","active_tab_id":"w2:t1","focused":true},{"workspace_id":"w3","active_tab_id":"w3:t1","focused":false}]}}' > "$resp/18.out" + printf '%s\n' '{"result":{"tabs":[{"tab_id":"w2:t1","focused":true}]}}' > "$resp/19.out" + fi + make_death_lab "$dir" "$bgpid" + printf '%s\n' '{"id":"fm-workspace-move","result":{"type":"workspace_list","workspaces":[{"workspace_id":"w2","focused":true},{"workspace_id":"w3","focused":false},{"workspace_id":"w1","focused":false}]}}' > "$dir/mover-response" + printf '%s\n' '{"id":"fm-workspace-move","result":{"type":"workspace_list","workspaces":[{"workspace_id":"w1","focused":false},{"workspace_id":"w2","focused":true},{"workspace_id":"w3","focused":false}]}}' > "$dir/mover-response-2" + fb=$(make_herdr_fakebin "$dir") + out=$(PATH="$fb:$PATH" FM_HERDR_LOG="$log" FM_HERDR_RESPONSES="$resp" FM_HERDR_SCRIPT_STATUS=1 \ + FM_HERDR_PS_BIN="$dir/ps" FM_BACKEND_HERDR_WORKSPACE_MOVER="$dir/mover" \ + FM_FAKE_MOVER_LOG="$dir/mover.log" FM_FAKE_MOVER_RESPONSE="$dir/mover-response" \ + FM_FAKE_MOVER_RESPONSE_2="$dir/mover-response-2" \ + FM_BACKEND_HERDR_DEATH_CLOSE_POLLS=2 \ + bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_projection_close_pane_focus_preserving fmtest w1:p1' "$ROOT" 2>&1) + status=$? + kill -KILL "$bgpid" 2>/dev/null || true; wait "$bgpid" 2>/dev/null || true + [ "$status" -ne 0 ] || fail "an unconfirmed removal must report failure: $out" + [ "$(wc -l < "$dir/mover.log" | tr -d ' ')" = 2 ] \ + || fail "a failed removal did not roll the reposition back exactly once: $(cat "$dir/mover.log")" + [ "$(sed -n '1p' "$dir/mover.log")" = "$(cd /tmp && pwd -P)/fmtest.sock"$'\t'"w1"$'\t'"3" ] \ + || fail "the reposition did not move the doomed workspace to the end: $(sed -n '1p' "$dir/mover.log")" + [ "$(sed -n '2p' "$dir/mover.log")" = "$(cd /tmp && pwd -P)/fmtest.sock"$'\t'"w1"$'\t'"0" ] \ + || fail "the rollback did not restore the doomed workspace to its exact original position: $(sed -n '2p' "$dir/mover.log")" + assert_not_contains "$(cat "$log")" $'tab\x1ffocus' "a failed rolled-back removal moved focus" +} + +test_projection_close_failed_removal_rolls_back_the_reposition() { + assert_projection_close_failed_removal_rolls_back_the_reposition command-fails + assert_projection_close_failed_removal_rolls_back_the_reposition command-succeeds-pane-present + assert_projection_close_failed_removal_rolls_back_the_reposition pane-gone-workspace-present + pass "herdr presentation cleanup: every unconfirmed removal restores the exact original workspace order and reports failure" +} + +test_kill_emptying_non_focused_uses_pane_death() { + local dir log resp fb out status bgpid lock_log lock_held + dir="$TMP_ROOT/kill-death"; mkdir -p "$dir/responses" + log="$dir/log"; resp="$dir/responses"; lock_log="$dir/lock.log"; lock_held="$dir/lock-held" + : > "$log"; : > "$lock_log" + printf '%s\n' '{"result":{"workspaces":[{"workspace_id":"w1","active_tab_id":"w1:t1","focused":true},{"workspace_id":"w2","active_tab_id":"w2:t2","focused":false}]}}' > "$resp/1.out" + printf '%s\n' '{"result":{"tabs":[{"tab_id":"w1:t1","focused":true}]}}' > "$resp/2.out" + printf '%s\n' '{"result":{"pane":{"pane_id":"w2:p2","tab_id":"w2:t2","workspace_id":"w2"}}}' > "$resp/3.out" + printf '%s\n' '{"result":{"tabs":[{"tab_id":"w2:t2","workspace_id":"w2"}]}}' > "$resp/4.out" + printf '%s\n' '{"result":{"panes":[{"pane_id":"w2:p2","tab_id":"w2:t2"}]}}' > "$resp/5.out" + cp "$resp/1.out" "$resp/6.out" + sleep 300 & bgpid=$! + death_process_info_fixture w2:p2 "$bgpid" > "$resp/7.out" + printf '%s\n' '{"error":{"code":"pane_not_found"}}' > "$resp/8.out" + printf '%s\n' '{"result":{"workspaces":[{"workspace_id":"w1","active_tab_id":"w1:t1","focused":true}]}}' > "$resp/9.out" + printf '%s\n' '{"result":{"tabs":[{"tab_id":"w1:t1","focused":true}]}}' > "$resp/10.out" + make_death_lab "$dir" "$bgpid" + fb=$(make_herdr_fakebin "$dir") + out=$(PATH="$fb:$PATH" FM_HERDR_LOG="$log" FM_HERDR_RESPONSES="$resp" \ + FM_HERDR_PS_BIN="$dir/ps" FM_BACKEND_HERDR_WORKSPACE_MOVER="$dir/mover" \ + FM_FAKE_MOVER_LOG="$dir/mover.log" FM_FAKE_MOVER_RESPONSE="$dir/no-response" \ + FM_BACKEND_HERDR_DEATH_CLOSE_POLLS=2 FM_FAKE_LOCK_LOG="$lock_log" \ + FM_FAKE_LOCK_HELD="$lock_held" \ + bash -c ' + . "$0/bin/backends/herdr.sh" + fm_backend_herdr_target_ready() { fm_backend_herdr_parse_target "$1"; } + fm_backend_herdr_presentation_session_lock_path() { printf "%s" "$FM_FAKE_LOCK_HELD.lock"; } + fm_lock_try_acquire() { + printf "acquire\n" >> "$FM_FAKE_LOCK_LOG" + : > "$FM_FAKE_LOCK_HELD" + } + fm_lock_release() { + [ -e "$FM_FAKE_LOCK_HELD" ] || return 1 + rm -f "$FM_FAKE_LOCK_HELD" + printf "release\n" >> "$FM_FAKE_LOCK_LOG" + } + eval "$(declare -f fm_backend_herdr_cli | sed "1s/fm_backend_herdr_cli/fm_backend_herdr_cli_locked/")" + fm_backend_herdr_cli() { + [ -e "$FM_FAKE_LOCK_HELD" ] || return 97 + fm_backend_herdr_cli_locked "$@" + } + fm_backend_herdr_kill fmtest:w2:p2 + ' "$ROOT" 2>&1) + status=$? + [ "$status" -eq 0 ] || fail "an emptying non-focused kill should stay best-effort: $out" + [ "$(cat "$lock_log")" = "$(printf 'acquire\nrelease')" ] \ + || fail "the generic kill did not hold one presentation lock across its complete mutation: $(cat "$lock_log")" + [ ! -e "$lock_held" ] || fail "the generic kill retained its presentation lock" + assert_not_contains "$(cat "$log")" $'pane\x1fclose' "an emptying non-focused kill used the focus-unsafe explicit close" + assert_not_contains "$(cat "$log")" $'tab\x1ffocus' "an emptying non-focused kill moved focus" + pass "fm_backend_herdr_kill: one session lock covers the focus-safe emptying removal" +} + +test_kill_focused_workspace_stays_plain_close() { + local dir log resp fb out status bgpid + dir="$TMP_ROOT/kill-focused"; mkdir -p "$dir/responses" + log="$dir/log"; resp="$dir/responses"; : > "$log" + printf '%s\n' '{"result":{"workspaces":[{"workspace_id":"w1","active_tab_id":"w1:t1","focused":false},{"workspace_id":"w2","active_tab_id":"w2:t2","focused":true}]}}' > "$resp/1.out" + printf '%s\n' '{"result":{"tabs":[{"tab_id":"w2:t2","focused":true}]}}' > "$resp/2.out" + printf '%s\n' '{"result":{"pane":{"pane_id":"w2:p2","tab_id":"w2:t2","workspace_id":"w2"}}}' > "$resp/3.out" + printf '%s\n' '{"error":{"code":"pane_not_found"}}' > "$resp/5.out" + sleep 300 & bgpid=$! + make_death_lab "$dir" "$bgpid" + fb=$(make_herdr_fakebin "$dir") + out=$(PATH="$fb:$PATH" FM_HERDR_LOG="$log" FM_HERDR_RESPONSES="$resp" \ + FM_HERDR_PS_BIN="$dir/ps" FM_BACKEND_HERDR_WORKSPACE_MOVER="$dir/mover" \ + FM_FAKE_MOVER_LOG="$dir/mover.log" FM_FAKE_MOVER_RESPONSE="$dir/no-response" \ + FM_BACKEND_HERDR_DEATH_CLOSE_POLLS=2 \ + bash -c ' + . "$0/bin/backends/herdr.sh" + fm_backend_herdr_presentation_session_lock_path() { printf "/tmp/fm-herdr-test-lock"; } + fm_lock_try_acquire() { return 0; } + fm_lock_release() { return 0; } + fm_backend_herdr_kill fmtest:w2:p2 + ' "$ROOT" 2>&1) + status=$? + [ "$status" -eq 0 ] || fail "a focused-workspace kill should stay best-effort: $out" + assert_contains "$(cat "$log")" $'pane\x1fclose\x1fw2:p2' "a focused-workspace kill did not use the plain close" + assert_not_contains "$(cat "$log")" $'pane\x1fprocess-info' "a focused-workspace kill ran the idle-shell proof" + kill -0 "$bgpid" 2>/dev/null || fail "a focused-workspace kill signaled the pane's shell" + kill "$bgpid" 2>/dev/null || true; wait "$bgpid" 2>/dev/null || true + pass "fm_backend_herdr_kill: killing the focused workspace's tab keeps the legitimate plain close" +} + +test_kill_refuses_when_presentation_lock_is_unavailable() { + local dir mode out status attempts + dir="$TMP_ROOT/kill-lock-refusal"; mkdir -p "$dir" + for mode in unresolved contended; do + : > "$dir/cli.log" + : > "$dir/attempts" + out=$(ROOT="$ROOT" MODE="$mode" CLI_LOG="$dir/cli.log" ATTEMPTS="$dir/attempts" bash -c ' + . "$ROOT/bin/backends/herdr.sh" + fm_backend_herdr_target_ready() { fm_backend_herdr_parse_target "$1"; } + fm_backend_herdr_presentation_session_lock_path() { + [ "$MODE" = contended ] || return 1 + printf "/tmp/fm-herdr-contended-test-lock" + } + fm_lock_try_acquire() { + printf "x\n" >> "$ATTEMPTS" + return 1 + } + fm_backend_herdr_cli() { + printf "%s\n" "$*" >> "$CLI_LOG" + return 0 + } + sleep() { :; } + fm_backend_herdr_kill fmtest:w2:p2 + ' 2>&1) + status=$? + [ "$status" -eq 0 ] || fail "$mode presentation lock refusal changed best-effort kill status: $status" + [ ! -s "$dir/cli.log" ] || fail "$mode presentation lock refusal still mutated Herdr: $(cat "$dir/cli.log")" + assert_contains "$out" "refusing an unlocked pane close" \ + "$mode presentation lock refusal did not report the deferred close" + attempts=$(wc -l < "$dir/attempts" | tr -d ' ') + if [ "$mode" = contended ]; then + [ "$attempts" = 50 ] || fail "contended presentation lock did not use the bounded wait: $attempts attempts" + else + [ "$attempts" = 0 ] || fail "unresolved presentation lock path attempted acquisition: $attempts" + fi + done + pass "fm_backend_herdr_kill: unavailable session locks defer every pane close" +} + +test_endpoint_confirmed_gone_gates_on_structured_presence() { + local out + out=$(bash -c ' + . "$0/bin/backends/herdr.sh" + fm_backend_herdr_cli() { printf "%s\n" "$FM_FAKE_PRESENCE_RESPONSE"; return "${FM_FAKE_PRESENCE_STATUS:-0}"; } + check() { # <label> <response> <status> <mode> <expected-rc> + FM_FAKE_PRESENCE_RESPONSE=$2 FM_FAKE_PRESENCE_STATUS=$3 + rc=0 + fm_backend_herdr_endpoint_confirmed_gone fmtest:w2:p2 "$4" || rc=$? + [ "$rc" = "$5" ] || printf "MISMATCH %s: rc=%s expected=%s\n" "$1" "$rc" "$5" + } + check present-default "{\"result\":{\"pane\":{\"pane_id\":\"w2:p2\"}}}" 0 "" 1 + check present-strict "{\"result\":{\"pane\":{\"pane_id\":\"w2:p2\"}}}" 0 strict 1 + check notfound-default "{\"error\":{\"code\":\"pane_not_found\"}}" 1 "" 0 + check notfound-strict "{\"error\":{\"code\":\"pane_not_found\"}}" 1 strict 0 + check unknown-default "" 1 "" 1 + check unknown-strict "" 1 strict 1 + check othererror-default "{\"error\":{\"code\":\"internal\"}}" 1 "" 1 + check othererror-strict "{\"error\":{\"code\":\"internal\"}}" 1 strict 1 + # Missing or malformed endpoint identity is ambiguity, never proof of a + # gone pane: it must refuse record removal. + rc=0 + fm_backend_herdr_endpoint_confirmed_gone malformed-target strict || rc=$? + [ "$rc" = 1 ] || printf "MISMATCH malformed-target: rc=%s expected=1\n" "$rc" + rc=0 + fm_backend_herdr_endpoint_confirmed_gone "" || rc=$? + [ "$rc" = 1 ] || printf "MISMATCH empty-target: rc=%s expected=1\n" "$rc" + ' "$ROOT" 2>&1) + [ -z "$out" ] || fail "endpoint confirmed-gone gate matrix mismatch: $out" + pass "endpoint confirmed-gone: only structured not-found permits record removal and ambiguous identity refuses" +} + test_projection_seeded_prune_refuses_active_tab() { local dir log resp fb out status dir="$TMP_ROOT/projection-seeded-focus-active-refusal"; mkdir -p "$dir/responses" @@ -1104,6 +2068,54 @@ SH pass "herdr presentation ordering: an ambiguous existing worker block is warning-only and read-only" } +test_projection_order_anchors_the_parent_by_exact_id() { + local dir log resp fb mover layout out status + layout='{"result":{"workspaces":[{"workspace_id":"w1","label":"firstmate","focused":false},{"workspace_id":"w7","label":"firstmate","focused":false},{"workspace_id":"wH","label":"human-notes","focused":false},{"workspace_id":"w8","label":"└ new · p:ZyXwVuTsRqPoNmLkJiHgFe","focused":false}]}}' + + # Without the exact parent id, two same-labeled parents make the whole layout + # ambiguous and ordering steps aside. + dir="$TMP_ROOT/projection-order-dup-label"; mkdir -p "$dir/responses" + log="$dir/log"; resp="$dir/responses"; mover="$dir/mover"; : > "$log" + printf '%s\n' "$layout" > "$resp/1.out" + cat > "$mover" <<'SH' +#!/usr/bin/env bash +echo called > "$FM_FAKE_MOVER_CALLED" +exit 0 +SH + chmod +x "$mover" + fb=$(make_herdr_fakebin "$dir") + out=$(PATH="$fb:$PATH" FM_HERDR_LOG="$log" FM_HERDR_RESPONSES="$resp" \ + FM_BACKEND_HERDR_WORKSPACE_MOVER="$mover" FM_FAKE_MOVER_CALLED="$dir/called" \ + bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_projection_order_best_effort fmtest w8 firstmate' "$ROOT" 2>&1) + status=$? + [ "$status" -eq 0 ] || fail "ambiguous projection ordering must not fail the spawn" + assert_contains "$out" "ambiguous workspace layout" "a duplicated parent label should make label-anchored ordering step aside" + [ ! -e "$dir/called" ] || fail "ambiguous parent label attempted workspace.move" + + # With the launcher's exact parent workspace id, the same layout is no longer + # ambiguous: ordering gets past parent selection and stops later, on this + # fake's protocol, having still moved nothing. + dir="$TMP_ROOT/projection-order-exact-parent"; mkdir -p "$dir/responses" + log="$dir/log"; resp="$dir/responses"; mover="$dir/mover"; : > "$log" + printf '%s\n' "$layout" > "$resp/1.out" + cat > "$mover" <<'SH' +#!/usr/bin/env bash +echo called > "$FM_FAKE_MOVER_CALLED" +exit 0 +SH + chmod +x "$mover" + fb=$(make_herdr_fakebin "$dir") + out=$(PATH="$fb:$PATH" FM_HERDR_LOG="$log" FM_HERDR_RESPONSES="$resp" \ + FM_BACKEND_HERDR_WORKSPACE_MOVER="$mover" FM_FAKE_MOVER_CALLED="$dir/called" \ + bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_projection_order_best_effort fmtest w8 firstmate w7' "$ROOT" 2>&1) + status=$? + [ "$status" -eq 0 ] || fail "exact-parent projection ordering must not fail the spawn" + assert_not_contains "$out" "ambiguous workspace layout" "the exact parent id should have resolved the duplicated label" + assert_contains "$out" "protocol" "exact-parent ordering did not reach its protocol gate" + [ ! -e "$dir/called" ] || fail "exact-parent ordering attempted workspace.move below the required protocol" + pass "herdr presentation ordering: the launcher's exact parent workspace id disambiguates a duplicated home label without moving anything" +} + test_projection_order_foreign_new_child_before_parent_is_read_only() { local dir log resp fb mover out status dir="$TMP_ROOT/projection-order-foreign-new"; mkdir -p "$dir/responses" @@ -1217,30 +2229,6 @@ test_presentation_session_lock_path_rejects_malformed_socket() { pass "herdr presentation lock: null and missing socket paths fail closed" } -test_presentation_lock_malformed_socket_falls_back() { - local dir log resp fb out status lock_source - dir="$TMP_ROOT/presentation-malformed-socket-fallback"; mkdir -p "$dir/responses" - log="$dir/log"; resp="$dir/responses"; : > "$log" - printf '%s\n' '{"sessions":[{"name":"fmtest","running":true,"socket_path":null}]}' > "$resp/1.out" - fb=$(make_herdr_fakebin "$dir") - lock_source=$(sed -n '/^spawn_herdr_presentation_order_lock_acquire()/,/^spawn_herdr_presentation_order_lock_release()/p' "$ROOT/bin/fm-spawn.sh" | sed '$d') - out=$(PATH="$fb:$PATH" FM_HERDR_LOG="$log" FM_HERDR_RESPONSES="$resp" \ - LOCK_SOURCE="$lock_source" \ - bash -c ' - . "$0/bin/backends/herdr.sh" - eval "$LOCK_SOURCE" - if spawn_herdr_presentation_order_lock_acquire fmtest; then - printf "%s" acquired - else - printf "%s" flat - fi - ' "$ROOT" 2>&1) - status=$? - [ "$status" -eq 0 ] || fail "malformed socket fallback must not fail the spawn path: $out" - [ "$out" = flat ] || fail "malformed socket_path must fall back flat, got '$out'" - pass "herdr presentation lock: malformed socket metadata degrades to flat" -} - test_projection_order_rejects_malformed_socket() { local dir log resp fb mover out status dir="$TMP_ROOT/projection-order-malformed-socket"; mkdir -p "$dir/responses" @@ -1267,117 +2255,6 @@ SH pass "herdr presentation ordering: malformed socket metadata is warning-only and read-only" } -test_presentation_lock_insecure_namespace_falls_back() { - local dir log resp fb bad out status lock_source - dir="$TMP_ROOT/presentation-insecure-lock"; mkdir -p "$dir/responses" "$dir/sockdir" - log="$dir/log"; resp="$dir/responses"; : > "$log" - : > "$dir/sockdir/fmtest.sock" - bad="$dir/insecure"; mkdir -m 755 "$bad" - printf '%s\n' "{\"sessions\":[{\"name\":\"fmtest\",\"running\":true,\"socket_path\":\"$dir/sockdir/fmtest.sock\"}]}" > "$resp/1.out" - fb=$(make_herdr_fakebin "$dir") - lock_source=$(sed -n '/^spawn_herdr_presentation_order_lock_acquire()/,/^spawn_herdr_presentation_order_lock_release()/p' "$ROOT/bin/fm-spawn.sh" | sed '$d') - out=$(PATH="$fb:$PATH" FM_HERDR_LOG="$log" FM_HERDR_RESPONSES="$resp" \ - BAD_NAMESPACE="$bad" LOCK_SOURCE="$lock_source" \ - bash -c ' - . "$0/bin/backends/herdr.sh" - eval "$LOCK_SOURCE" - fm_backend_herdr_presentation_lock_namespace() { printf "%s" "$BAD_NAMESPACE"; } - if spawn_herdr_presentation_order_lock_acquire fmtest; then - printf "%s" acquired - else - printf "%s" flat - fi - ' "$ROOT" 2>&1) - status=$? - [ "$status" -eq 0 ] || fail "an insecure lock namespace must not fail the spawn path: $out" - [ "$out" = flat ] || fail "an insecure lock namespace must fall back flat, got '$out'" - pass "herdr presentation lock: insecure shared namespace refuses acquisition for flat fallback" -} - -test_spawn_task_lock_covers_all_backend_creation_and_metadata_publication() { - local source wake_source acquire_pattern backend_pattern meta_pattern acquire_line backend_line meta_line - source=$(cat "$ROOT/bin/fm-spawn.sh") - wake_source=". \"\$SCRIPT_DIR/fm-wake-lib.sh\"" - acquire_pattern="fm_lock_try_acquire \"\$SPAWN_TASK_LOCK\"" - backend_pattern="^case \"\$BACKEND\" in" - meta_pattern="} > \"\$STATE/\$ID.meta\"" - assert_contains "$source" "$wake_source" \ - "fm-spawn does not load the shared lock implementation" - acquire_line=$(grep -n "$acquire_pattern" "$ROOT/bin/fm-spawn.sh" | head -1 | cut -d: -f1) - backend_line=$(grep -n "$backend_pattern" "$ROOT/bin/fm-spawn.sh" | tail -1 | cut -d: -f1) - meta_line=$(grep -n "$meta_pattern" "$ROOT/bin/fm-spawn.sh" | tail -1 | cut -d: -f1) - [ -n "$acquire_line" ] && [ -n "$backend_line" ] && [ -n "$meta_line" ] \ - || fail "could not locate the spawn lock, backend creation, and metadata publication" - [ "$acquire_line" -lt "$backend_line" ] && [ "$backend_line" -lt "$meta_line" ] \ - || fail "the task lock does not span backend creation through metadata publication" - pass "fm-spawn: one task lock spans every backend creation path through metadata publication" -} - -test_projected_spawn_disarms_cleanup_before_ambiguous_launch_submission() { - local literal_pattern disarm_pattern release_pattern enter_pattern literal_line disarm_line release_line enter_line - # These are literal source patterns for grep, so shell expansion would invalidate the assertion. - # shellcheck disable=SC2016 - literal_pattern='spawn_send_literal "$T" "$LAUNCH"' - # shellcheck disable=SC2016 - disarm_pattern='HERDR_PROJECTION_ABORT_CLEANUP=0' - release_pattern='spawn_herdr_presentation_order_lock_release' - # shellcheck disable=SC2016 - enter_pattern='spawn_send_key "$T" Enter' - literal_line=$(grep -nF "$literal_pattern" "$ROOT/bin/fm-spawn.sh" | tail -1 | cut -d: -f1) - disarm_line=$(grep -nF "$disarm_pattern" "$ROOT/bin/fm-spawn.sh" | tail -1 | cut -d: -f1) - release_line=$(grep -nF "$release_pattern" "$ROOT/bin/fm-spawn.sh" | tail -1 | cut -d: -f1) - enter_line=$(grep -nF "$enter_pattern" "$ROOT/bin/fm-spawn.sh" | tail -1 | cut -d: -f1) - [ -n "$literal_line" ] && [ -n "$disarm_line" ] && [ -n "$release_line" ] && [ -n "$enter_line" ] \ - || fail "could not locate the projected launch cleanup boundary" - [ "$literal_line" -lt "$disarm_line" ] \ - && [ "$disarm_line" -lt "$release_line" ] \ - && [ "$release_line" -lt "$enter_line" ] \ - || fail "projected spawn must disarm cleanup before releasing its lock and submitting ambiguous Enter" - pass "fm-spawn: projected cleanup disarms before lock release and ambiguous launch submission" -} - -test_projected_abort_cleanup_holds_presentation_lock() { - local dir lock started proceed function_source owner_pid status - dir="$TMP_ROOT/projection-abort-lock"; mkdir -p "$dir" - lock="$dir/presentation.lock" - started="$dir/cleanup-started" - proceed="$dir/cleanup-proceed" - function_source=$(sed -n '/^spawn_abort_cleanup()/,/^trap spawn_abort_cleanup EXIT/p' "$ROOT/bin/fm-spawn.sh" | sed '$d') - ROOT="$ROOT" LOCK="$lock" STARTED="$started" PROCEED="$proceed" FUNCTION_SOURCE="$function_source" bash -c ' - . "$ROOT/bin/fm-wake-lib.sh" - eval "$FUNCTION_SOURCE" - fm_backend_herdr_projection_cleanup_exact() { - : > "$STARTED" - while [ ! -e "$PROCEED" ]; do sleep 0.01; done - } - fm_lock_try_acquire "$LOCK" || exit 1 - HERDR_PRESENTATION_ORDER_LOCK_HELD=1 - HERDR_PRESENTATION_ORDER_LOCK=$LOCK - HERDR_PROJECTION_ABORT_CLEANUP=1 - HERDR_PROJECTION_ABORT_SESSION=fmtest - HERDR_PROJECTION_ABORT_TASK_PANE=w9:p2 - HERDR_PROJECTION_ABORT_SEEDED_PANE=w9:p1 - ORCA_ABORT_CLEANUP=0 - SPAWN_TASK_LOCK_HELD=0 - spawn_abort_cleanup - ' & - owner_pid=$! - while [ ! -e "$started" ] && kill -0 "$owner_pid" 2>/dev/null; do sleep 0.01; done - [ -e "$started" ] || fail "projected abort cleanup did not start" - if LOCK="$lock" ROOT="$ROOT" bash -c '. "$ROOT/bin/fm-wake-lib.sh"; fm_lock_try_acquire "$LOCK"'; then - : > "$proceed" - wait "$owner_pid" || true - fail "concurrent presentation work acquired the lock during abort cleanup" - fi - : > "$proceed" - wait "$owner_pid" - status=$? - [ "$status" -eq 0 ] || fail "projected abort cleanup owner failed" - LOCK="$lock" ROOT="$ROOT" bash -c '. "$ROOT/bin/fm-wake-lib.sh"; fm_lock_try_acquire "$LOCK"' \ - || fail "presentation lock remained held after abort cleanup" - pass "fm-spawn: projected abort cleanup remains serialized by the presentation lock" -} - test_projection_reclaim_refusal_matrix_is_non_mutating() { local dir state home other_home home_real journal legacy token label out mutation_log dir="$TMP_ROOT/projection-reclaim-refusals"; state="$dir/state"; home="$dir/home"; other_home="$dir/other-home" @@ -1444,7 +2321,7 @@ test_projection_reclaim_refusal_matrix_is_non_mutating() { } test_projection_reclaim_replaces_only_exact_husk_and_advances_binding() { - local dir state home home_real log resp fb journal token label out calls create_line close_line + local dir state home home_real log resp fb journal token label out calls create_line close_line agent_line boundary_mutations dir="$TMP_ROOT/projection-reclaim-exact"; state="$dir/state"; home="$dir/home" mkdir -p "$dir/responses" "$state" "$home" home_real=$(cd "$home" && pwd -P) @@ -1460,7 +2337,7 @@ test_projection_reclaim_replaces_only_exact_husk_and_advances_binding() { ' "$ROOT" "$state" "$home_real") || fail "could not create exact reclaim journal fixture" journal="$state/fm-hibit-r1.herdr-presentation" label="└ hibit-r1 · p:$token" - printf '%s\n' "{\"result\":{\"workspaces\":[{\"workspace_id\":\"w1\",\"label\":\"firstmate\",\"focused\":true,\"active_tab_id\":\"w1:t1\"},{\"workspace_id\":\"w2\",\"label\":\"$label\",\"focused\":false,\"active_tab_id\":\"w2:t2\"}]}}" > "$resp/1.out" + printf '%s\n' "{\"result\":{\"workspaces\":[{\"workspace_id\":\"w0\",\"label\":\"firstmate\",\"focused\":false,\"active_tab_id\":\"w0:t1\"},{\"workspace_id\":\"w1\",\"label\":\"firstmate\",\"focused\":true,\"active_tab_id\":\"w1:t1\"},{\"workspace_id\":\"w2\",\"label\":\"$label\",\"focused\":false,\"active_tab_id\":\"w2:t2\"}]}}" > "$resp/1.out" printf '%s\n' '{"result":{"tabs":[{"tab_id":"w2:t2","label":"fm-fm-hibit-r1"}]}}' > "$resp/2.out" printf '%s\n' '{"result":{"panes":[{"pane_id":"w2:p2","tab_id":"w2:t2"}]}}' > "$resp/3.out" printf '%s\n' '{"result":{"pane":{"pane_id":"w2:p2"}}}' > "$resp/4.out" @@ -1479,13 +2356,17 @@ test_projection_reclaim_replaces_only_exact_husk_and_advances_binding() { printf '%s\n' '{"result":{"pane":{"pane_id":"w2:p2","tab_id":"w2:t2","workspace_id":"w2"}}}' > "$resp/17.out" printf '%s\n' '{"result":{"pane":{"pane_id":"w2:p2"}}}' > "$resp/18.out" printf '%s\n' '{"error":{"code":"agent_not_found"}}' > "$resp/19.out" - : > "$resp/20.out" - cp "$resp/6.out" "$resp/21.out" - cp "$resp/7.out" "$resp/22.out" - printf '%s\n' '{"error":{"code":"pane_not_found"}}' > "$resp/23.out" - cp "$resp/1.out" "$resp/24.out" - printf '%s\n' '{"result":{"tabs":[{"tab_id":"w2:t3","label":"fm-fm-hibit-r1"}]}}' > "$resp/25.out" - printf '%s\n' '{"result":{"panes":[{"pane_id":"w2:p3","tab_id":"w2:t3"}]}}' > "$resp/26.out" + # The emptying-close plan sees the replacement tab alongside the old husk + # tab, so the husk close stays plain. + printf '%s\n' '{"result":{"tabs":[{"tab_id":"w2:t2","label":"fm-fm-hibit-r1"},{"tab_id":"w2:t3","label":"fm-fm-hibit-r1"}]}}' > "$resp/20.out" + : > "$resp/21.out" + printf '%s\n' '{"error":{"code":"pane_not_found"}}' > "$resp/22.out" + cp "$resp/6.out" "$resp/23.out" + cp "$resp/7.out" "$resp/24.out" + printf '%s\n' '{"error":{"code":"pane_not_found"}}' > "$resp/25.out" + cp "$resp/1.out" "$resp/26.out" + printf '%s\n' '{"result":{"tabs":[{"tab_id":"w2:t3","label":"fm-fm-hibit-r1"}]}}' > "$resp/27.out" + printf '%s\n' '{"result":{"panes":[{"pane_id":"w2:p3","tab_id":"w2:t3"}]}}' > "$resp/28.out" fb=$(make_herdr_fakebin "$dir") out=$(PATH="$fb:$PATH" FM_HERDR_LOG="$log" FM_HERDR_RESPONSES="$resp" \ bash -c ' @@ -1503,12 +2384,18 @@ test_projection_reclaim_replaces_only_exact_husk_and_advances_binding() { close_line=$(grep -n $'pane\x1fclose\x1fw2:p2' "$log" | cut -d: -f1) [ -n "$create_line" ] && [ -n "$close_line" ] && [ "$create_line" -lt "$close_line" ] \ || fail "reclaim did not create the exact replacement before closing the old husk" - [ "$(sed -n "$((close_line - 1))p" "$log")" = $'HERDR_SESSION=fmtest\x1fagent\x1fget\x1fw2:p2\x1f--session\x1ffmtest' ] \ - || fail "reclaim did not recheck the old pane agent state at the exact close boundary" + agent_line=$(grep -n $'agent\x1fget\x1fw2:p2' "$log" | tail -1 | cut -d: -f1) + [ -n "$agent_line" ] && [ "$agent_line" -lt "$close_line" ] \ + || fail "reclaim did not recheck the old pane agent state before the close" + boundary_mutations=$(sed -n "$((agent_line + 1)),$((close_line - 1))p" "$log" \ + | grep -Ev $'\x1f(tab\x1flist|pane\x1flist|workspace\x1flist)' || true) + [ -z "$boundary_mutations" ] \ + || fail "reclaim mutated between the old pane agent recheck and the close: $boundary_mutations" assert_not_contains "$calls" $'workspace\x1fclose' "reclaim introduced workspace-close authority" assert_not_contains "$calls" $'workspace\x1frename' "reclaim renamed the projected workspace" assert_not_contains "$calls" $'tab\x1ffocus' "focus-preserving reclaim changed an already-stable focus snapshot" - pass "herdr presentation reclaim: exact agent-free husk is replaced in place and journal/focus identities advance" + assert_not_contains "$calls" $'\x1fw0' "reclaim touched the same-labeled sibling parent" + pass "herdr presentation reclaim: exact agent-free husk survives duplicate parent labels while its sibling stays untouched" } test_projection_recovery_is_read_only_and_refuses_live_duplicate_risk() { @@ -1679,7 +2566,14 @@ test_kill_is_best_effort() { printf '1\n' > "$resp/1.exit" fb=$(make_herdr_fakebin "$dir") PATH="$fb:$PATH" FM_HERDR_LOG="$log" FM_HERDR_RESPONSES="$resp" \ - bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_kill default:w1:p2' "$ROOT" + bash -c ' + . "$0/bin/backends/herdr.sh" + fm_backend_herdr_target_ready() { fm_backend_herdr_parse_target "$1"; } + fm_backend_herdr_presentation_session_lock_path() { printf "/tmp/fm-herdr-test-lock"; } + fm_lock_try_acquire() { return 0; } + fm_lock_release() { return 0; } + fm_backend_herdr_kill default:w1:p2 + ' "$ROOT" expect_code 0 $? "kill must be best-effort (never fail even when the pane close call itself fails)" assert_contains "$(cat "$log")" $'\x1f''pane'$'\x1f''close'$'\x1f''w1:p2' "kill did not call pane close on the right pane" pass "fm_backend_herdr_kill: calls pane close and stays best-effort on failure" @@ -2502,7 +3396,13 @@ $ids EOF [ -n "$pane" ] || fail "cycle $i: create_task returned no pane id" PATH="$fb:$PATH" FM_HERDR_LOG="$log" FM_FAKE_HERDR_STATE="$state" HERDR_SESSION=fmtest \ - bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_kill "$1"' "$ROOT" "fmtest:$pane" \ + bash -c ' + . "$0/bin/backends/herdr.sh" + fm_backend_herdr_presentation_session_lock_path() { printf "/tmp/fm-herdr-cycle-test-lock"; } + fm_lock_try_acquire() { return 0; } + fm_lock_release() { return 0; } + fm_backend_herdr_kill "$1" + ' "$ROOT" "fmtest:$pane" \ || fail "cycle $i: kill failed" done # exactly one firstmate workspace survives three spawn/teardown cycles @@ -2647,23 +3547,6 @@ EOF pass "fm_backend_herdr_workspace_prune_seeded_default_tab: refuses to close the seeded default tab when its pane reports a working agent (defense in depth)" } -# test_no_jq_reserved_keyword_arg_names: regression guard for the -# workspace-leak root cause (a jq `--arg`/`--argjson` named after a jq -# reserved keyword, e.g. `label`, is a compile error on jq <= 1.6; this -# adapter discards jq's stderr, so the error silently becomes an empty -# result instead of a visible failure). Greps every bin/ script for the -# pattern so a future filter reintroducing it fails loudly here instead of -# silently misbehaving on an older jq. -test_no_jq_reserved_keyword_arg_names() { - local reserved='and|as|catch|def|elif|else|end|foreach|if|import|include|label|module|or|reduce|then|try' - local hits - hits=$(grep -rnE -- "--arg(json)?[[:space:]]+($reserved)\b" "$ROOT/bin" 2>/dev/null) - if [ -n "$hits" ]; then - fail "a jq --arg/--argjson variable is named after a jq reserved keyword (compile error on jq <= 1.6, silently swallowed by 2>/dev/null):"$'\n'"$hits" - fi - pass "no bin/ jq filter names a --arg/--argjson variable after a jq reserved keyword" -} - # --- native event push: normalize / policy-routing / dedupe / wait ---------- # # These exercise the herdr subscriber (fm_backend_herdr_wait_transition and its @@ -2982,6 +3865,19 @@ test_workspace_label_secondmate_marker_trims_whitespace test_workspace_label_empty_marker_falls_back_to_primary test_workspace_label_different_secondmates_get_different_labels test_cli_helper_sets_env_and_appends_trailing_session_flag +test_launcher_identity_absent_without_a_herdr_pane +test_launcher_identity_absent_when_herdr_env_alone_is_set +test_launcher_identity_resolves_the_exact_pane_tab_and_workspace +test_launcher_identity_refuses_a_pane_from_another_session_name +test_launcher_identity_refuses_a_missing_server_socket +test_launcher_identity_refuses_a_pane_from_another_server_socket +test_launcher_identity_refuses_an_unreadable_pane +test_launcher_identity_refuses_a_pane_and_tab_that_disagree +test_launcher_identity_refuses_a_workspace_missing_from_the_session +test_workspace_ensure_prefers_the_launcher_over_the_first_label_match +test_workspace_ensure_refuses_an_ambiguous_label_with_no_launcher +test_workspace_ensure_other_home_ignores_the_launcher_identity +test_container_ensure_refuses_an_ambiguous_home_label test_container_ensure_starts_server_and_workspace test_container_ensure_reuses_existing_workspace test_container_ensure_creates_with_no_focus_flag @@ -2991,7 +3887,6 @@ test_repeated_cycles_reuse_one_workspace_no_orphans test_adopted_workspace_never_prunes_default_tab test_label_collision_startup_workspace_leaves_live_tab_alone test_prune_refuses_a_working_agent_pane_defense_in_depth -test_no_jq_reserved_keyword_arg_names test_create_task_refuses_duplicate_label test_create_task_refuses_duplicate_label_when_agent_live test_create_task_refuses_when_any_duplicate_label_is_live @@ -3012,6 +3907,25 @@ test_projection_close_restores_exact_prior_focus test_projection_close_refuses_active_tab test_projection_close_reports_focus_restore_failure test_projection_close_rechecks_required_agent_state_at_boundary +test_projection_close_emptying_after_focus_uses_pane_death_without_move +test_projection_close_emptying_before_focus_repositions_then_uses_pane_death +test_projection_close_emptying_before_last_focus_needs_no_move +test_projection_close_emptying_last_workspace_needs_no_move +test_projection_close_non_emptying_stays_plain_without_proof_or_move +test_projection_close_plain_without_move_requires_structured_removal +test_projection_close_ambiguous_positions_fall_back_to_plain_close +test_projection_close_move_failure_falls_back_to_plain_close +test_projection_close_busy_pane_falls_back_to_plain_close +test_projection_close_transient_prompt_helper_settles_then_uses_pane_death +test_projection_close_death_escalates_sigkill_after_sighup_survival +test_projection_close_death_failure_falls_back_to_plain_close +test_projection_close_death_still_restores_a_stolen_focus +test_projection_close_death_never_sigkills_a_reused_pid +test_projection_close_failed_removal_rolls_back_the_reposition +test_kill_emptying_non_focused_uses_pane_death +test_kill_focused_workspace_stays_plain_close +test_endpoint_confirmed_gone_gates_on_structured_presence +test_kill_refuses_when_presentation_lock_is_unavailable test_projection_seeded_prune_refuses_active_tab test_projection_label_builder_uses_corner_and_strips_owner_prefixes test_projection_order_moves_only_exact_new_workspace_and_preserves_relative_order @@ -3021,16 +3935,12 @@ test_projection_order_allows_intervening_parent_child_block test_projection_order_human_spaces_never_move_targets test_projection_order_failure_warns_without_cleanup_or_spawn_failure test_projection_order_ambiguous_existing_block_is_read_only +test_projection_order_anchors_the_parent_by_exact_id test_projection_order_foreign_new_child_before_parent_is_read_only test_projection_order_missing_parent_is_read_only test_presentation_session_lock_path_is_shared_across_homes test_presentation_session_lock_path_rejects_malformed_socket -test_presentation_lock_malformed_socket_falls_back test_projection_order_rejects_malformed_socket -test_presentation_lock_insecure_namespace_falls_back -test_spawn_task_lock_covers_all_backend_creation_and_metadata_publication -test_projected_spawn_disarms_cleanup_before_ambiguous_launch_submission -test_projected_abort_cleanup_holds_presentation_lock test_projection_reclaim_refusal_matrix_is_non_mutating test_projection_reclaim_replaces_only_exact_husk_and_advances_binding test_projection_recovery_is_read_only_and_refuses_live_duplicate_risk diff --git a/tests/fm-backend-orca.test.sh b/tests/fm-backend-orca.test.sh index 66c3dd3653..a54e448d10 100755 --- a/tests/fm-backend-orca.test.sh +++ b/tests/fm-backend-orca.test.sh @@ -702,7 +702,7 @@ test_peek_send_and_crew_state_route_through_orca_meta() { fm_git_init_commit "$wt" state="$TMP_ROOT/io-state"; mkdir -p "$state" fm_write_meta "$state/$id.meta" \ - "window=fm-$id" "terminal=term-io" "worktree=$wt" "project=$wt" "harness=claude" "kind=scout" "backend=orca" + "window=fm-$id" "endpoint_task_id=$id" "terminal=term-io" "worktree=$wt" "project=$wt" "harness=claude" "kind=scout" "backend=orca" touch "$state/.last-watcher-beat" orca_case io-path neutral=$(neutral_fm_root "$CASE_DIR/neutral") @@ -739,7 +739,7 @@ test_peek_and_crew_state_fail_closed_on_orca_error_json() { fm_git_init_commit "$wt" state="$TMP_ROOT/read-error-state"; mkdir -p "$state" fm_write_meta "$state/$id.meta" \ - "window=fm-$id" "terminal=term-stale" "worktree=$wt" "project=$wt" "harness=claude" "kind=scout" "backend=orca" + "window=fm-$id" "endpoint_task_id=$id" "terminal=term-stale" "worktree=$wt" "project=$wt" "harness=claude" "kind=scout" "backend=orca" touch "$state/.last-watcher-beat" orca_case read-error-json neutral=$(neutral_fm_root "$CASE_DIR/neutral") @@ -785,7 +785,7 @@ test_scout_teardown_removes_orca_worktree_via_helper() { printf 'report\n' > "$data/$id/report.md" touch "$state/.last-watcher-beat" fm_write_meta "$state/$id.meta" \ - "window=fm-$id" "terminal=term-teardown" "worktree=$wt" "project=$proj" \ + "window=fm-$id" "endpoint_task_id=$id" "terminal=term-teardown" "worktree=$wt" "project=$proj" \ "harness=claude" "kind=scout" "mode=no-mistakes" "yolo=off" \ "backend=orca" "orca_worktree_id=wt-teardown" \ "decisions_reviewed=1" "decision_keys=" @@ -822,7 +822,7 @@ test_scout_teardown_refuses_orca_id_path_mismatch() { printf 'report\n' > "$data/$id/report.md" touch "$state/.last-watcher-beat" fm_write_meta "$state/$id.meta" \ - "window=fm-$id" "terminal=term-scout-mismatch" "worktree=$wt" "project=$proj" \ + "window=fm-$id" "endpoint_task_id=$id" "terminal=term-scout-mismatch" "worktree=$wt" "project=$proj" \ "harness=claude" "kind=scout" "mode=no-mistakes" "yolo=off" \ "backend=orca" "orca_worktree_id=wt-scout-mismatch" \ "decisions_reviewed=1" "decision_keys=" @@ -858,7 +858,7 @@ test_teardown_removes_orca_worktree_when_path_missing() { printf 'report\n' > "$data/$id/report.md" touch "$state/.last-watcher-beat" fm_write_meta "$state/$id.meta" \ - "window=fm-$id" "terminal=term-missing-path" "worktree=$wt" "project=$proj" \ + "window=fm-$id" "endpoint_task_id=$id" "terminal=term-missing-path" "worktree=$wt" "project=$proj" \ "harness=claude" "kind=scout" "mode=no-mistakes" "yolo=off" \ "backend=orca" "orca_worktree_id=wt-missing-path" \ "decisions_reviewed=1" "decision_keys=" @@ -891,12 +891,13 @@ test_teardown_preserves_metadata_when_orca_remove_error_json() { printf 'report\n' > "$data/$id/report.md" touch "$state/.last-watcher-beat" fm_write_meta "$state/$id.meta" \ - "window=fm-$id" "worktree=$wt" "project=$proj" \ + "window=fm-$id" "endpoint_task_id=$id" "terminal=term-remove-error" "worktree=$wt" "project=$proj" \ "harness=claude" "kind=scout" "mode=no-mistakes" "yolo=off" \ "backend=orca" "orca_worktree_id=wt-remove-error" \ "decisions_reviewed=1" "decision_keys=" orca_case remove-error-teardown - printf '{"ok":false,"error":{"code":"worktree_not_removed","message":"worktree not removed"}}\n' > "$RESP/1.out" + printf '{"ok":true,"result":{}}\n' > "$RESP/1.out" + printf '{"ok":false,"error":{"code":"worktree_not_removed","message":"worktree not removed"}}\n' > "$RESP/2.out" neutral=$(neutral_fm_root "$CASE_DIR/neutral") set +e out=$( PATH="$FB:$PATH" FM_ORCA_LOG="$LOG" FM_ORCA_RESPONSES="$RESP" \ @@ -921,7 +922,7 @@ test_scout_teardown_refuses_orca_missing_report_when_path_missing() { mkdir -p "$data/$id" "$state" "$config" touch "$state/.last-watcher-beat" fm_write_meta "$state/$id.meta" \ - "window=fm-$id" "terminal=term-missing-report" "worktree=$wt" "project=$proj" \ + "window=fm-$id" "endpoint_task_id=$id" "terminal=term-missing-report" "worktree=$wt" "project=$proj" \ "harness=claude" "kind=scout" "mode=no-mistakes" "yolo=off" \ "backend=orca" "orca_worktree_id=wt-missing-report" orca_case missing-report @@ -951,7 +952,7 @@ test_ship_teardown_refuses_orca_missing_worktree_path() { mkdir -p "$data/$id" "$state" "$config" touch "$state/.last-watcher-beat" fm_write_meta "$state/$id.meta" \ - "window=fm-$id" "terminal=term-missing-ship" "worktree=$wt" "project=$proj" \ + "window=fm-$id" "endpoint_task_id=$id" "terminal=term-missing-ship" "worktree=$wt" "project=$proj" \ "harness=claude" "kind=ship" "mode=no-mistakes" "yolo=off" \ "backend=orca" "orca_worktree_id=wt-missing-ship" orca_case missing-ship-path @@ -982,7 +983,7 @@ test_ship_teardown_removes_orca_worktree_when_id_path_matches() { mkdir -p "$data/$id" "$state" "$config" touch "$state/.last-watcher-beat" fm_write_meta "$state/$id.meta" \ - "window=fm-$id" "terminal=term-ship-match" "worktree=$wt" "project=$proj" \ + "window=fm-$id" "endpoint_task_id=$id" "terminal=term-ship-match" "worktree=$wt" "project=$proj" \ "harness=claude" "kind=ship" "mode=local-only" "yolo=off" \ "backend=orca" "orca_worktree_id=wt-ship-match" orca_case ship-match @@ -1017,7 +1018,7 @@ test_ship_teardown_refuses_orca_unresolvable_worktree_id() { mkdir -p "$data/$id" "$state" "$config" touch "$state/.last-watcher-beat" fm_write_meta "$state/$id.meta" \ - "window=fm-$id" "terminal=term-ship-unresolved" "worktree=$wt" "project=$proj" \ + "window=fm-$id" "endpoint_task_id=$id" "terminal=term-ship-unresolved" "worktree=$wt" "project=$proj" \ "harness=claude" "kind=ship" "mode=local-only" "yolo=off" \ "backend=orca" "orca_worktree_id=wt-ship-unresolved" orca_case ship-unresolved @@ -1056,7 +1057,7 @@ test_ship_teardown_refuses_orca_id_path_mismatch() { mkdir -p "$data/$id" "$state" "$config" touch "$state/.last-watcher-beat" fm_write_meta "$state/$id.meta" \ - "window=fm-$id" "terminal=term-ship-mismatch" "worktree=$wt" "project=$proj" \ + "window=fm-$id" "endpoint_task_id=$id" "terminal=term-ship-mismatch" "worktree=$wt" "project=$proj" \ "harness=claude" "kind=ship" "mode=local-only" "yolo=off" \ "backend=orca" "orca_worktree_id=wt-ship-mismatch" orca_case ship-mismatch @@ -1094,7 +1095,7 @@ test_teardown_refuses_orca_missing_worktree_id() { printf 'report\n' > "$data/$id/report.md" touch "$state/.last-watcher-beat" fm_write_meta "$state/$id.meta" \ - "window=fm-$id" "terminal=term-missing-id" "worktree=$wt" "project=$proj" \ + "window=fm-$id" "endpoint_task_id=$id" "terminal=term-missing-id" "worktree=$wt" "project=$proj" \ "harness=claude" "kind=scout" "mode=no-mistakes" "yolo=off" "backend=orca" \ "decisions_reviewed=1" "decision_keys=" orca_case missing-id @@ -1112,7 +1113,7 @@ test_teardown_refuses_orca_missing_worktree_id() { pass "fm-teardown.sh backend=orca: refuses missing worktree ids before cleanup" } -test_teardown_removes_orca_worktree_without_terminal_handle() { +test_teardown_refuses_orca_worktree_without_terminal_handle() { local proj wt data state config id out rc neutral id="orcanotermz0" proj="$TMP_ROOT/no-terminal-project" @@ -1125,12 +1126,11 @@ test_teardown_removes_orca_worktree_without_terminal_handle() { printf 'report\n' > "$data/$id/report.md" touch "$state/.last-watcher-beat" fm_write_meta "$state/$id.meta" \ - "window=fm-$id" "worktree=$wt" "project=$proj" \ + "window=fm-$id" "endpoint_task_id=$id" "worktree=$wt" "project=$proj" \ "harness=claude" "kind=scout" "mode=no-mistakes" "yolo=off" \ "backend=orca" "orca_worktree_id=wt-no-terminal" \ "decisions_reviewed=1" "decision_keys=" orca_case no-terminal - printf '{"ok":true,"result":{"worktree":{"id":"wt-no-terminal","path":"%s"}}}\n' "$wt" > "$RESP/1.out" neutral=$(neutral_fm_root "$CASE_DIR/neutral") set +e out=$( PATH="$FB:$PATH" FM_ORCA_LOG="$LOG" FM_ORCA_RESPONSES="$RESP" \ @@ -1138,13 +1138,11 @@ test_teardown_removes_orca_worktree_without_terminal_handle() { "$ROOT/bin/fm-teardown.sh" "$id" 2>&1 ) rc=$? set -e - expect_code 0 "$rc" "Orca teardown should remove a worktree even when no terminal was ever recorded"$'\n'"$out" - assert_contains "$(cat "$LOG")" $'orca\x1f''worktree'$'\x1f''rm'$'\x1f''--worktree'$'\x1f''id:wt-no-terminal'$'\x1f''--force'$'\x1f''--json' \ - "teardown did not remove the partial Orca worktree" - assert_not_contains "$(cat "$LOG")" $'orca\x1f''terminal'$'\x1f''close' \ - "teardown should not close a terminal when no terminal handle is recorded" - assert_absent "$state/$id.meta" "successful partial cleanup should remove task metadata" - pass "fm-teardown.sh backend=orca: removes partial worktree-only metadata" + [ "$rc" -ne 0 ] || fail "Orca teardown accepted metadata without a terminal handle" + assert_contains "$out" "missing terminal" "teardown did not explain the incomplete Orca endpoint" + [ ! -s "$LOG" ] || fail "teardown dispatched to Orca before rejecting the incomplete endpoint" + assert_present "$state/$id.meta" "missing-terminal refusal removed task metadata" + pass "fm-teardown.sh backend=orca: refuses incomplete worktree-only endpoint metadata before runtime dispatch" } test_secondmate_force_teardown_removes_orca_child_via_orca() { @@ -1164,7 +1162,8 @@ test_secondmate_force_teardown_removes_orca_child_via_orca() { printf '%s\n' "- domain - Orca child cleanup (home: $subhome; scope: orca cleanup; projects: alpha; added 2026-07-03)" \ > "$home/data/secondmates.md" fm_write_meta "$subhome/state/$child_id.meta" \ - "window=fm-$child_id" "terminal=term-child-cleanup" "worktree=$childwt" "project=$childproj" \ + "window=fm-$child_id" "endpoint_task_id=$child_id" \ + "terminal=term-child-cleanup" "worktree=$childwt" "project=$childproj" \ "harness=claude" "kind=ship" "mode=no-mistakes" "yolo=off" \ "backend=orca" "orca_worktree_id=wt-child-cleanup" orca_case secondmate-child-cleanup @@ -1206,7 +1205,8 @@ test_secondmate_force_teardown_refuses_orca_child_id_path_mismatch() { printf '%s\n' "- domain - Orca child cleanup (home: $subhome; scope: orca cleanup; projects: alpha; added 2026-07-03)" \ > "$home/data/secondmates.md" fm_write_meta "$subhome/state/$child_id.meta" \ - "window=fm-$child_id" "terminal=term-child-mismatch" "worktree=$childwt" "project=$childproj" \ + "window=fm-$child_id" "endpoint_task_id=$child_id" \ + "terminal=term-child-mismatch" "worktree=$childwt" "project=$childproj" \ "harness=claude" "kind=ship" "mode=no-mistakes" "yolo=off" \ "backend=orca" "orca_worktree_id=wt-child-mismatch" orca_case secondmate-child-mismatch @@ -1229,7 +1229,7 @@ test_secondmate_force_teardown_refuses_orca_child_id_path_mismatch() { pass "fm-teardown.sh --force: refuses Orca child id/path mismatches" } -test_secondmate_force_teardown_removes_partial_orca_child() { +test_secondmate_force_teardown_refuses_partial_orca_child() { local home subhome childproj childwt child_id neutral out rc home="$TMP_ROOT/orca-partial-child-parent" subhome="$TMP_ROOT/orca-partial-child-secondmate" @@ -1246,11 +1246,11 @@ test_secondmate_force_teardown_removes_partial_orca_child() { printf '%s\n' "- domain - Orca partial child cleanup (home: $subhome; scope: orca cleanup; projects: alpha; added 2026-07-03)" \ > "$home/data/secondmates.md" fm_write_meta "$subhome/state/$child_id.meta" \ - "window=fm-$child_id" "worktree=$childwt" "project=$childproj" \ + "window=fm-$child_id" "endpoint_task_id=$child_id" \ + "worktree=$childwt" "project=$childproj" \ "harness=claude" "kind=ship" "mode=no-mistakes" "yolo=off" \ "backend=orca" "orca_worktree_id=wt-partial-child" orca_case secondmate-partial-child-cleanup - printf '{"ok":true,"result":{"worktree":{"id":"wt-partial-child","path":"%s"}}}\n' "$childwt" > "$RESP/1.out" add_tmux_fake "$FB" neutral=$(neutral_fm_root "$CASE_DIR/neutral") set +e @@ -1258,13 +1258,12 @@ test_secondmate_force_teardown_removes_partial_orca_child() { FM_ROOT_OVERRIDE="$neutral" FM_HOME="$home" "$ROOT/bin/fm-teardown.sh" domain --force 2>&1 ) rc=$? set -e - expect_code 0 "$rc" "forced secondmate teardown should remove partial Orca child state"$'\n'"$out" - assert_contains "$(cat "$LOG")" $'orca\x1f''worktree'$'\x1f''rm'$'\x1f''--worktree'$'\x1f''id:wt-partial-child'$'\x1f''--force'$'\x1f''--json' \ - "partial child cleanup did not remove the Orca worktree through orca worktree rm" - assert_not_contains "$(cat "$LOG")" $'orca\x1f''terminal'$'\x1f''close' \ - "partial child cleanup should not close a terminal when no terminal handle is recorded" - assert_absent "$home/state/domain.meta" "parent metadata should be removed after forced partial cleanup" - pass "fm-teardown.sh --force: removes partial Orca secondmate children" + [ "$rc" -ne 0 ] || fail "forced secondmate teardown accepted a child with no terminal identity" + assert_contains "$out" "missing terminal" "partial child refusal did not explain the incomplete endpoint" + [ ! -s "$LOG" ] || fail "partial child refusal dispatched to Orca or tmux" + assert_present "$home/state/domain.meta" "partial child refusal removed parent metadata" + assert_present "$subhome/state/$child_id.meta" "partial child refusal removed child metadata" + pass "fm-teardown.sh --force: refuses partial Orca secondmate children before runtime dispatch" } test_dispatcher_sources_orca_and_routes_primitives() { @@ -1323,7 +1322,7 @@ test_ship_teardown_removes_orca_worktree_when_id_path_matches test_ship_teardown_refuses_orca_unresolvable_worktree_id test_ship_teardown_refuses_orca_id_path_mismatch test_teardown_refuses_orca_missing_worktree_id -test_teardown_removes_orca_worktree_without_terminal_handle +test_teardown_refuses_orca_worktree_without_terminal_handle test_secondmate_force_teardown_removes_orca_child_via_orca test_secondmate_force_teardown_refuses_orca_child_id_path_mismatch -test_secondmate_force_teardown_removes_partial_orca_child +test_secondmate_force_teardown_refuses_partial_orca_child diff --git a/tests/fm-backend-zellij.test.sh b/tests/fm-backend-zellij.test.sh index 3844252c93..ae4be257bd 100755 --- a/tests/fm-backend-zellij.test.sh +++ b/tests/fm-backend-zellij.test.sh @@ -796,8 +796,11 @@ test_teardown_passes_recorded_tab_id_to_zellij_kill() { printf 'report\n' > "$data/zghost/report.md" fm_write_meta "$state/zghost.meta" \ "window=firstmate:7" \ + "endpoint_task_id=zghost" \ "backend=zellij" \ + "zellij_session=firstmate" \ "zellij_tab_id=3" \ + "zellij_pane_id=7" \ "worktree=$dir/missing-worktree" \ "project=$project" \ "kind=scout" \ @@ -827,7 +830,11 @@ test_forced_secondmate_teardown_kills_zellij_children_with_child_home_tag() { printf 'smz\n' > "$home/.fm-secondmate-home" fm_write_meta "$state/smz.meta" \ "window=firstmate:99" \ + "endpoint_task_id=smz" \ "backend=zellij" \ + "zellij_session=firstmate" \ + "zellij_tab_id=99" \ + "zellij_pane_id=99" \ "worktree=$home" \ "project=$home" \ "kind=secondmate" \ @@ -835,8 +842,11 @@ test_forced_secondmate_teardown_kills_zellij_children_with_child_home_tag() { "home=$home" fm_write_meta "$home/state/childz.meta" \ "window=firstmate:7" \ + "endpoint_task_id=childz" \ "backend=zellij" \ + "zellij_session=firstmate" \ "zellij_tab_id=4" \ + "zellij_pane_id=7" \ "worktree=$dir/missing-child-worktree" \ "project=$project" \ "kind=scout" diff --git a/tests/fm-backend.test.sh b/tests/fm-backend.test.sh index 74b469a619..3f2e950a6a 100755 --- a/tests/fm-backend.test.sh +++ b/tests/fm-backend.test.sh @@ -12,7 +12,10 @@ # binaries and fixtures as the REFACTORED versions in this checkout, then # diffs the two command logs byte-for-byte - the report's P1 checklist # item "run current main scripts and refactored scripts against the same -# fake tools and compare command logs". +# fake tools and compare command logs". The teardown old-vs-new case also +# overlays a content-historical permissive tmux kill fixture: after the +# exact-selector change lands on the default branch, merge-base with main +# collapses to HEAD and can no longer supply that baseline. # 3. Asserts the `--backend`/`FM_BACKEND` selection refuses unknown backends # and the blocked `codex-app` backend loudly. # @@ -80,6 +83,9 @@ SH } # The commit this branch started from - the P1 "current main" baseline. +# Suitable for byte-identical old-vs-new checks while a branch still diverges +# from main. After a squash lands, merge-base(HEAD, main) collapses to HEAD, so +# callers that need a true pre-change fixture must not rely on this alone. resolve_base_ref() { local ref base for ref in main refs/heads/main origin/main refs/remotes/origin/main origin/HEAD refs/remotes/origin/HEAD; do @@ -95,6 +101,30 @@ resolve_base_ref() { BASE_REF=$(resolve_base_ref) \ || fail "fm-backend baseline requires local main or origin/main; fetch the default branch before running this test" +# Newest first-parent revision whose bin/backends/tmux.sh still uses the +# pre-exact permissive kill-window target. Content-addressed from history so the +# fixture stays historical on default-branch CI and on branches cut after the +# exact-selector change, where merge-base with main is self-referential. +resolve_permissive_tmux_kill_ref() { + local commit body + while IFS= read -r commit; do + [ -n "$commit" ] || continue + body=$(git -C "$ROOT" show "$commit:bin/backends/tmux.sh" 2>/dev/null) || continue + # shellcheck disable=SC2016 + case "$body" in + *'tmux kill-window -t "=$session:=$window"'*) continue ;; + esac + # shellcheck disable=SC2016 + case "$body" in + *'tmux kill-window -t "$1"'*|*'tmux kill-window -t "$target"'*) + printf '%s\n' "$commit" + return 0 + ;; + esac + done < <(git -C "$ROOT" log --first-parent --format='%H' HEAD -- bin/backends/tmux.sh) + return 1 +} + # --- shared: a pre-refactor bin/ shim -------------------------------------- # # build_old_bin echoes a directory whose bin/ subdir holds the PRE-REFACTOR @@ -108,11 +138,10 @@ BASE_REF=$(resolve_base_ref) \ # fm-backend.sh (and its bin/backends/ adapters) is the dispatcher every one # of the five REFACTORED scripts sources; it must be a real, reachable file in # the old bin/ too or `. "$SCRIPT_DIR/fm-backend.sh"` aborts under set -eu - -# hence it is a copied sibling, not an extracted-from-BASE_REF file: for a -# tmux-only conformance run the tmux adapter's behavior is what is under test, -# and that is unchanged by any later (e.g. non-tmux backend) addition to -# fm-backend.sh's own dispatch surface. -OLD_BIN_UNCHANGED_SIBLINGS="fm-gate-refuse-lib.sh fm-guard.sh fm-lock-lib.sh fm-tasks-axi-lib.sh fm-pr-lib.sh fm-tangle-lib.sh fm-tmux-lib.sh fm-composer-lib.sh fm-wake-lib.sh fm-classify-lib.sh fm-supervision-lib.sh fm-ff-lib.sh fm-config-inherit-lib.sh fm-project-mode.sh fm-harness.sh fm-crew-state.sh fm-decision-hold.sh fm-backend.sh fm-operational-input.sh" +# hence the dispatcher is a copied sibling, while the tmux adapter is extracted +# from BASE_REF so conformance tests retain the exact historical behavior even +# when this branch changes tmux dispatch semantics. +OLD_BIN_UNCHANGED_SIBLINGS="fm-gate-refuse-lib.sh fm-guard.sh fm-lock-lib.sh fm-tasks-axi-lib.sh fm-pr-lib.sh fm-tangle-lib.sh fm-tmux-lib.sh fm-busy-lib.sh fm-composer-lib.sh fm-wake-lib.sh fm-classify-lib.sh fm-supervision-lib.sh fm-ff-lib.sh fm-config-inherit-lib.sh fm-project-mode.sh fm-harness.sh fm-crew-state.sh fm-decision-hold.sh fm-backend.sh fm-operational-input.sh fm-public-followup-lib.sh fm-secondmate-registry-lib.sh fm-x-lib.sh" # A pull-request merge may add a new main-only dependency that the branch's older baseline does not have yet. OLD_BIN_OPTIONAL_SIBLINGS="fm-pending-reply-lib.sh" OLD_BIN_REFACTORED="fm-send.sh fm-peek.sh fm-watch.sh fm-spawn.sh fm-teardown.sh fm-marker-lib.sh" @@ -130,6 +159,7 @@ build_old_bin() { # <name> -> echoes root dir (root/bin/<script> is the entry p cp "$ROOT/bin/$f" "$bin/$f" done cp -R "$ROOT/bin/backends" "$bin/backends" + git -C "$ROOT" show "$BASE_REF:bin/backends/tmux.sh" > "$bin/backends/tmux.sh" for f in $OLD_BIN_REFACTORED; do git -C "$ROOT" show "$BASE_REF:bin/$f" > "$bin/$f" chmod +x "$bin/$f" @@ -930,9 +960,20 @@ run_teardown_case() { } test_teardown_conformance_old_vs_new() { - local old_bin fb proj wt id + local old_bin fb proj wt id old_tmux_ref saved_base_ref local state_old state_new config_old config_new data log_old log_new out_old out_new rc_old rc_new + # Force the post-squash topology inside this case: merge-base with main may + # equal HEAD on default-branch CI, and that must not make the legacy kill + # fixture self-referential. build_old_bin still uses BASE_REF for entrypoints; + # only the tmux kill adapter is pinned to the content-historical permissive ref. + saved_base_ref=$BASE_REF + BASE_REF=$(git -C "$ROOT" rev-parse HEAD) + old_tmux_ref=$(resolve_permissive_tmux_kill_ref) \ + || { BASE_REF=$saved_base_ref; fail "unable to locate a historical bin/backends/tmux.sh with permissive kill-window selectors"; } old_bin=$(build_old_bin teardown-old) + git -C "$ROOT" show "$old_tmux_ref:bin/backends/tmux.sh" > "$old_bin/bin/backends/tmux.sh" \ + || { BASE_REF=$saved_base_ref; fail "could not materialize historical tmux adapter from $old_tmux_ref"; } + BASE_REF=$saved_base_ref proj="$TMP_ROOT/teardown-project"; wt="$TMP_ROOT/teardown-wt" id="teardownconform1" fm_git_worktree "$proj" "$wt" "fm/$id" @@ -962,14 +1003,21 @@ test_teardown_conformance_old_vs_new() { expect_code 0 "$rc_old" "old fm-teardown.sh (scout, report present) should succeed"$'\n'"$out_old" expect_code 0 "$rc_new" "new fm-teardown.sh (scout, report present) should succeed"$'\n'"$out_new" - diff -u "$log_old" "$log_new" > "$TMP_ROOT/teardown-diff.txt" 2>&1 \ - || fail "fm-teardown.sh: tmux+treehouse command log differs old vs new"$'\n'"$(cat "$TMP_ROOT/teardown-diff.txt")" assert_contains "$(cat "$log_new")" "treehouse"$'\x1f''return'$'\x1f''--force'$'\x1f'"$wt" \ "teardown did not call treehouse return --force <worktree>" - assert_contains "$(cat "$log_new")" "tmux"$'\x1f''kill-window'$'\x1f''-t'$'\x1f'"firstmate:fm-$id" \ - "teardown did not call tmux kill-window -t <window>" - - pass "fm-teardown.sh: treehouse return + tmux kill-window command log is byte-identical old vs new for a scout task" + # The legacy fixture's adapter comes from BASE_REF, so its selector form is + # whatever the merge-base carried: permissive while the exact-selector change + # was still on a branch, exact for every branch cut after it landed on main. + # Pinning the old form here would make this case pass once and then fail + # forever, so the '=' exactness markers are normalized away and the legacy run + # is only required to have reached tmux window cleanup for this task. The + # exact-selector contract belongs to the current script, asserted below. + assert_contains "$(tr -d '=' < "$log_old")" "tmux"$'\x1f''kill-window'$'\x1f''-t'$'\x1f'"firstmate:fm-$id" \ + "legacy teardown fixture did not exercise tmux window cleanup for the task" + assert_contains "$(cat "$log_new")" "tmux"$'\x1f''kill-window'$'\x1f''-t'$'\x1f'"=firstmate:=fm-$id" \ + "teardown did not call tmux kill-window with exact session and window selectors" + + pass "fm-teardown.sh: treehouse return remains compatible while tmux cleanup uses exact selectors" } # --- backend selection loudly refuses an unknown backend -------------------- diff --git a/tests/fm-backlog-handoff.test.sh b/tests/fm-backlog-handoff.test.sh index b0b931d71f..2efd8dd3d5 100755 --- a/tests/fm-backlog-handoff.test.sh +++ b/tests/fm-backlog-handoff.test.sh @@ -482,9 +482,11 @@ test_registry_home_with_pre_home_parentheses() { setup_homes "$home" "$sub" "$id" local sub_abs sub_abs=$(cd "$sub" && pwd -P) - # Prose parentheses before (home: ...), matching live registry shape. - printf -- '- %s - issue triage (id is legacy) (home: %s; scope: issue triage; projects: alpha; added 2026-07-09)\n' \ + # Prose parentheses before (home: ...) and punctuation inside scope match the live registry shape. + printf -- '- %s - issue triage (id is legacy) (home: %s; scope: issue triage (child); semicolon is meaningful; projects: alpha; added 2026-07-09)\n' \ "$id" "$sub_abs" > "$home/data/secondmates.md" + FM_HOME="$home" "$ROOT/bin/fm-home-seed.sh" validate >/dev/null \ + || fail "home-seed validation rejected punctuation-bearing registry fields" cat > "$home/data/backlog.md" <<'EOF' ## Queued diff --git a/tests/fm-bearings-snapshot.test.sh b/tests/fm-bearings-snapshot.test.sh index f8afefa255..a67284e56a 100755 --- a/tests/fm-bearings-snapshot.test.sh +++ b/tests/fm-bearings-snapshot.test.sh @@ -75,6 +75,18 @@ make_home() { # <name> printf '%s\n' "$home" } +record_claude_state() { # <state-dir> <id> <busy|idle> + local state=$1 id=$2 semantic_state=$3 gen event + case "$semantic_state" in + busy) event=user-prompt-submit ;; + idle) event=stop ;; + *) fail "unsupported semantic fixture state: $semantic_state" ;; + esac + gen=$("$ROOT/bin/fm-busy-event.sh" arm "$state" "$id") + "$ROOT/bin/fm-busy-event.sh" apply "$state" "$id" "$semantic_state" --gen "$gen" \ + --source claude-hook --event "$event" +} + fixture_mate_home() { # <parent-home> printf '%s/%s-secondmate-home\n' "$TMP_ROOT" "$(basename "$1")" } @@ -108,18 +120,20 @@ EOF "window=firstmate:fm-ship-task" \ "worktree=$home/projects/ship-wt" \ "project=firstmate" \ - "harness=codex" \ + "harness=claude" \ "kind=ship" \ "mode=no-mistakes" \ "pr=https://github.com/kunchenguid/firstmate/pull/9" + record_claude_state "$home/state" ship-task busy printf 'working: building the thing\n' > "$home/state/ship-task.status" fm_write_meta "$home/state/scout-x.meta" \ "window=firstmate:fm-scout-x" \ "worktree=$home/projects/ship-wt" \ "project=firstmate" \ - "harness=codex" \ + "harness=claude" \ "kind=scout" \ "mode=scout" + record_claude_state "$home/state" scout-x idle printf 'done: report ready\n' > "$home/state/scout-x.status" fm_write_meta "$home/state/mate.meta" \ "window=firstmate:fm-mate" \ @@ -136,9 +150,10 @@ EOF "window=firstmate:fm-external-wait" \ "worktree=$home/projects/ship-wt" \ "project=firstmate" \ - "harness=codex" \ + "harness=claude" \ "kind=ship" \ "mode=no-mistakes" + record_claude_state "$home/state" external-wait idle printf 'paused: declared external-wait for upstream release\n' > "$home/state/external-wait.status" # The secondmate's OWN home backlog records a merge it managed. This lands in the # secondmate home, never the main backlog, so landed-work views only see it via the @@ -156,7 +171,8 @@ EOF mkdir -p "$mate/projects/mate" fm_write_meta "$mate/state/mate.meta" \ "window=firstmate:fm-mate" "worktree=$mate/projects/mate" "project=firstmate" \ - "harness=codex" "kind=ship" "mode=no-mistakes" + "harness=claude" "kind=ship" "mode=no-mistakes" + record_claude_state "$mate/state" mate idle printf 'needs-decision [key=race]: pick subscribe order\n' > "$mate/state/mate.status" } @@ -367,7 +383,8 @@ test_structured_child_decision_reaches_captains_call() { EOF fm_write_meta "$mate/state/phase8.meta" \ "window=firstmate:fm-phase8" "worktree=$mate/projects/phase8" "project=sample" \ - "harness=codex" "kind=ship" "mode=no-mistakes" + "harness=claude" "kind=ship" "mode=no-mistakes" + record_claude_state "$mate/state" phase8 idle printf 'needs-decision [key=release]: choose release A or B\n' > "$mate/state/phase8.status" fakebin=$(make_fakebin "$home") json=$(run "$home" "$fakebin" --json) @@ -527,7 +544,8 @@ test_secondmate_and_child_bounds_are_disclosed() { printf -- '- [ ] %s - Active %s (repo: sample) (kind: ship) (since 2026-07-13)\n' "$child" "$child" >> "$mate/data/backlog.md" fm_write_meta "$mate/state/$child.meta" \ "window=firstmate:fm-$child" "worktree=$mate/projects/$child" "project=sample" \ - "harness=codex" "kind=ship" "mode=no-mistakes" + "harness=claude" "kind=ship" "mode=no-mistakes" + record_claude_state "$mate/state" "$child" busy printf 'working [key=%s]: active child %s\n' "$child" "$i" > "$mate/state/$child.status" i=$((i + 1)) done @@ -636,7 +654,8 @@ EOF EOF fm_write_meta "$decision/state/$child.meta" \ "window=firstmate:fm-$child" "worktree=$decision/projects/$child" "project=sample" \ - "harness=codex" "kind=ship" "mode=no-mistakes" + "harness=claude" "kind=ship" "mode=no-mistakes" + record_claude_state "$decision/state" "$child" idle printf 'needs-decision [key=live-route]: choose the current route\n' > "$decision/state/$child.status" fakebin=$(make_fakebin "$home") canonical=$(PATH="$fakebin:$PATH" FM_HOME="$home" FM_SNAPSHOT_NOW=2026-07-11T18:00:00Z \ @@ -688,7 +707,8 @@ test_nonprogressing_child_states_are_explicit() { EOF fm_write_meta "$mate/state/parked.meta" \ "window=firstmate:fm-parked" "worktree=$mate/projects/parked" "project=sample" \ - "harness=codex" "kind=ship" "mode=no-mistakes" + "harness=claude" "kind=ship" "mode=no-mistakes" + record_claude_state "$mate/state" parked idle printf 'needs-decision [key=parked]: choose a route\n' > "$mate/state/parked.status" fakebin=$(make_fakebin "$home") canonical=$(PATH="$fakebin:$PATH" FM_HOME="$home" FM_SNAPSHOT_NOW=2026-07-11T18:00:00Z \ @@ -725,10 +745,12 @@ EOF EOF fm_write_meta "$mate/state/done.meta" \ "window=firstmate:fm-done" "worktree=$mate/projects/done" "project=sample" \ - "harness=codex" "kind=ship" "mode=no-mistakes" + "harness=claude" "kind=ship" "mode=no-mistakes" fm_write_meta "$mate/state/failed.meta" \ "window=firstmate:fm-failed" "worktree=$mate/projects/failed" "project=sample" \ - "harness=codex" "kind=ship" "mode=no-mistakes" + "harness=claude" "kind=ship" "mode=no-mistakes" + record_claude_state "$mate/state" "done" idle + record_claude_state "$mate/state" failed idle printf 'done: complete\n' > "$mate/state/done.status" printf 'failed: stopped\n' > "$mate/state/failed.status" rm "$mate/state/parked.meta" "$mate/state/parked.status" @@ -1365,6 +1387,7 @@ test_live_blocker_is_not_charted_queue_work() { local home fakebin json home=$(make_home live-blocker); write_fixture "$home" printf 'blocked [key=synthetic-dependency]: firstmate can refresh the synthetic token\n' > "$home/state/ship-task.status" + record_claude_state "$home/state" ship-task idle fakebin=$(make_fakebin "$home") json=$(run "$home" "$fakebin" --json) printf '%s' "$json" | jq -e ' @@ -1563,7 +1586,8 @@ test_mixed_secondmate_roles_partial_state_and_captain_readiness() { EOF fm_write_meta "$hibit/state/hibit-worker.meta" \ "window=firstmate:fm-hibit-worker" "worktree=$hibit/projects/worker" "project=hibit" \ - "harness=codex" "kind=ship" "mode=no-mistakes" + "harness=claude" "kind=ship" "mode=no-mistakes" + record_claude_state "$hibit/state" hibit-worker busy printf 'working: finalizing progress\n' > "$hibit/state/hibit-worker.status" cat > "$wheel/data/backlog.md" <<'EOF' @@ -1577,7 +1601,8 @@ EOF EOF fm_write_meta "$wheel/state/wheel-worker.meta" \ "window=firstmate:fm-wheel-worker" "worktree=$wheel/projects/worker" "project=wheelhouse" \ - "harness=codex" "kind=ship" "mode=no-mistakes" + "harness=claude" "kind=ship" "mode=no-mistakes" + record_claude_state "$wheel/state" wheel-worker busy printf 'working: active validation\n' > "$wheel/state/wheel-worker.status" cat > "$sshhip/data/backlog.md" <<'EOF' @@ -1606,7 +1631,8 @@ EOF EOF fm_write_meta "$ha/state/prep.meta" \ "window=firstmate:fm-prep" "worktree=$ha/projects/prep" "project=home-assistant" \ - "harness=codex" "kind=ship" "mode=no-mistakes" + "harness=claude" "kind=ship" "mode=no-mistakes" + record_claude_state "$ha/state" prep busy printf 'working: preparing canary\n' > "$ha/state/prep.status" fakebin=$(make_fakebin "$home") @@ -1710,7 +1736,8 @@ EOF fm_write_meta "$wheel/state/production-observation.meta" \ "window=firstmate:fm-production-observation" "worktree=$wheel/projects/worker" "project=wheelhouse" \ - "harness=codex" "kind=scout" "mode=scout" + "harness=claude" "kind=scout" "mode=scout" + record_claude_state "$wheel/state" production-observation idle printf 'paused: observation is deliberately held\n' > "$wheel/state/production-observation.status" canonical=$(PATH="$fakebin:$PATH" FM_HOME="$home" FM_SNAPSHOT_NOW=2026-07-11T18:00:00Z \ "$ROOT/bin/fm-fleet-snapshot.sh" --json) @@ -1723,7 +1750,8 @@ EOF fm_write_meta "$sshhip/state/unreadable-child.meta" \ "window=firstmate:fm-unreadable-child" "worktree=$sshhip/projects/child" "project=sshhip" \ - "harness=codex" "kind=ship" "mode=no-mistakes" + "harness=claude" "kind=ship" "mode=no-mistakes" + record_claude_state "$sshhip/state" unreadable-child busy printf 'working: app store submission restored\n' > "$sshhip/state/unreadable-child.status" json=$(run "$home" "$fakebin" --json) printf '%s' "$json" | jq -e ' @@ -1861,35 +1889,6 @@ EOF pass "main and secondmate captain actionability use the same blocker readiness" } -# The /bearings skill is the one owner of the four-section chat-response contract. -# Assert it states exactly the four fixed sections in order, each with its explicit -# empty-state sentence, documents the At Anchor exclusion, and mandates a chat that is -# materially shorter than and links to the report file. -test_chat_contract_four_sections() { - local skill body headings report_headings expected - skill="$ROOT/.agents/skills/bearings/SKILL.md" - [ -f "$skill" ] || fail "bearings SKILL.md missing at $skill" - body=$(awk '/^## Chat-response contract$/{capture=1; next} capture && /^## /{exit} capture' "$skill") - headings=$(printf '%s\n' "$body" | sed -nE "s/^[0-9]+\. \*\*([^*]+)\*\*.*/\1/p") - expected=$(printf '%s\n' "Captain's Call" "Recently Landed" "Underway" "Charted Next") - [ "$headings" = "$expected" ] || fail "chat contract must contain exactly four numbered sections in fixed order, got: $headings" - assert_contains "$body" "Nothing needs your action right now" "Captain's Call empty-state sentence" - assert_contains "$body" "No recent completions are in the current baseline" "Recently Landed empty-state sentence" - assert_contains "$body" "Nothing is underway" "Underway empty-state sentence" - assert_contains "$body" "Nothing is queued" "Charted Next empty-state sentence" - report_headings=$(sed -nE 's/^ - \*\*(Captain.s Call|Recently Landed|Underway|Charted Next)\*\*.*/\1/p' "$skill") - [ "$report_headings" = "$expected" ] || fail "detailed report contract must contain the same four complete sections, got: $report_headings" - grep -Eq 'since the (prior|last) report|Nothing has landed since|unchanged delta' "$skill" \ - && fail "bearings contract still contains prior-report delta wording" - # shellcheck disable=SC2016 # Backticks are literal Markdown in the expected text. - assert_contains "$(cat "$skill")" 'Never read an earlier `data/status-report-*.md`' "prior reports must not influence current output" - assert_contains "$(cat "$skill")" "bounded current recent-completions baseline" "Recently Landed must be a current baseline" - assert_contains "$body" "no At Anchor section" "the At Anchor exclusion must be documented" - assert_contains "$body" "materially shorter" "the chat must be materially shorter than the report file" - assert_contains "$body" "links to" "the chat must link to the report file" - pass "the /bearings skill states the four-section chat contract in order, with empty-states and the At Anchor exclusion" -} - test_domain_alpha_stale_parent_event_does_not_become_current_work test_gnu_stat_uses_file_formats_without_bsd_fallback_pollution test_parent_activity_evidence_is_bounded_and_disclosed @@ -1920,7 +1919,6 @@ test_main_unstructured_current_is_disclosed_with_structured_sibling test_main_orphan_counterfactual_meta_clears_inventory_warning test_mixed_secondmate_roles_partial_state_and_captain_readiness test_main_captain_readiness_matches_secondmate_projection -test_chat_contract_four_sections test_completed_scout_report_not_pending test_open_decision_surfaces_end_to_end test_report_pointers_surface diff --git a/tests/fm-bootstrap.test.sh b/tests/fm-bootstrap.test.sh index 48cd09a093..6c72c0bbc4 100755 --- a/tests/fm-bootstrap.test.sh +++ b/tests/fm-bootstrap.test.sh @@ -78,6 +78,10 @@ add_quota_axi() { local fakebin=$1 cat > "$fakebin/quota-axi" <<'SH' #!/usr/bin/env bash +if [ "${1:-}" = --version ]; then + printf '%s\n' "${FM_FAKE_QUOTA_AXI_VERSION:-0.1.16}" + exit 0 +fi exit 0 SH chmod +x "$fakebin/quota-axi" @@ -322,6 +326,43 @@ ROWS pass "bootstrap enforces no-mistakes minimum version" } +# 0.1.16 is the first quota-axi that reports per-credential auth sources and Grok +# state.authStatus. Before it, a dispatch candidate could not be scoped to its own +# authentication surface, which is exactly how one harness's expired CLI token +# produced a captain-facing "log in" claim for a candidate that never read it. A +# stale install used to pass this check silently, so the fix stayed uninstalled. +test_quota_axi_min_version() { + local label version mode case_dir fakebin out missing n + missing='MISSING: quota-axi (install: npm install -g quota-axi)' + n=0 + while IFS='^' read -r label version mode; do + [ -n "$label" ] || continue + n=$((n + 1)) + case_dir="$TMP_ROOT/quota-axi-$n" + mkdir -p "$case_dir/home/config" + printf '%s\n' manual > "$case_dir/home/config/backlog-backend" + fakebin=$(make_fake_toolchain "$case_dir") + add_tasks_axi "$fakebin" "0.1.1" + out=$(PATH="$fakebin:$BASE_PATH" FM_HOME="$case_dir/home" FM_ROOT_OVERRIDE="$case_dir/home" \ + FM_FAKE_TREEHOUSE_LEASE_HELP=1 FM_FAKE_QUOTA_AXI_VERSION="$version" "$ROOT/bin/fm-bootstrap.sh") + case "$mode" in + empty) + [ -z "$out" ] || fail "$label: expected silence, got: $out" ;; + missing) + [ "$out" = "$missing" ] || fail "$label: expected '$missing', got: $out" ;; + esac + done <<'ROWS' +minimum quota-axi version is accepted^0.1.16^empty +newer quota-axi patch is accepted^0.1.17^empty +newer quota-axi minor is accepted^0.2.0^empty +newer quota-axi major is accepted^1.0.0^empty +older quota-axi patch reports an upgrade^0.1.15^missing +much older quota-axi minor reports an upgrade^0.0.9^missing +unparseable quota-axi version reports an upgrade^quota-axi development build^missing +ROWS + pass "bootstrap enforces quota-axi minimum version" +} + test_git_is_required_with_supported_install_instruction() { local case_dir fakebin bash_env out expected case_dir="$TMP_ROOT/git-required" @@ -655,6 +696,7 @@ make_routine_bootstrap_fixture() { printf '%s\n' '.fm-secondmate-home' printf '%s\n' 'config/crew-harness' printf '%s\n' 'config/crew-dispatch.json' + printf '%s\n' 'config/startup-memory-budget' } > "$root/.gitignore" printf '%s\n' 'instructions' > "$root/AGENTS.md" mkdir -p "$root/bin" "$root/.agents/skills" @@ -718,19 +760,6 @@ test_routine_bootstrap_contract_runs_under_system_bash() { pass "bootstrap routine contract runs under system /bin/bash" } -test_bootstrap_info_is_no_load_and_actionable_lines_trigger() { - local trigger - # shellcheck disable=SC2016 # The backtick-delimited skill names are literal Markdown. - trigger=$(sed -n '/- `bootstrap-diagnostics`/,/- `diagnostic-reasoning`/p' "$ROOT/AGENTS.md") - assert_contains "$trigger" "actionable diagnostic line" "bootstrap-diagnostics trigger should be action-scoped" - assert_contains "$trigger" "BOOTSTRAP_INFO:" "bootstrap-diagnostics trigger should classify BOOTSTRAP_INFO as no-load" - assert_not_contains "$trigger" "TASKS_AXI:" "tasks-axi availability must not trigger diagnostics loading" - assert_not_contains "$trigger" "CREW_HARNESS_OVERRIDE:" "harness override confirmation must not trigger diagnostics loading" - assert_not_contains "$trigger" "CREW_DISPATCH: active" "active dispatch confirmation must not trigger diagnostics loading" - assert_not_contains "$trigger" "already-live" "already-live secondmate liveness must not trigger diagnostics loading" - pass "bootstrap diagnostics trigger excludes benign lines and keeps actionable prefixes" -} - test_crew_dispatch_active_rules_are_verbose_bootstrap_info() { local case_dir fakebin out expect case_dir="$TMP_ROOT/dispatch-active" @@ -805,6 +834,7 @@ ROWS test_bootstrap_reporting test_no_mistakes_min_version +test_quota_axi_min_version test_git_is_required_with_supported_install_instruction test_orca_backend_gates_orca_tool_only_when_selected test_session_provider_backends_do_not_require_tmux @@ -821,6 +851,5 @@ test_fleet_sync_timeout_empty_override_uses_default test_fleet_sync_timeout_is_computed_before_launch test_routine_bootstrap_confirmations_are_silent test_routine_bootstrap_contract_runs_under_system_bash -test_bootstrap_info_is_no_load_and_actionable_lines_trigger test_crew_dispatch_active_rules_are_verbose_bootstrap_info test_crew_dispatch_validation diff --git a/tests/fm-brief.test.sh b/tests/fm-brief.test.sh index 74690eab42..b33c2967a2 100755 --- a/tests/fm-brief.test.sh +++ b/tests/fm-brief.test.sh @@ -1,14 +1,18 @@ #!/usr/bin/env bash # Behavior tests for bin/fm-brief.sh. # -# Regression coverage for the heredoc-in-command-substitution parse bug (issue -# #166): each ship-mode branch builds its Definition-of-done text with -# `VAR=$(cat <<EOF ... EOF)`. Bash's lexer tracks quote state through the -# heredoc body while it scans for the matching `)` of the command -# substitution, so a single unescaped apostrophe anywhere in that body breaks -# parsing of the *entire rest of the script* - `bash -n` fails, not just the -# generated brief. A plain `cat > file <<EOF ... EOF` (not wrapped in `$(...)`) -# is unaffected, so the secondmate charter block does not need this guard. +# Regression coverage for the heredoc-in-command-substitution parse bug (issues +# #166, #958, #1069). Building a variable with `VAR=$(cat <<EOF ... EOF)` is +# unsafe on Bash 3.2 (macOS /bin/bash): the lexer scans for the matching `)` of +# the command substitution textually and tracks quote state through the heredoc +# body, so a single apostrophe, unbalanced quote, or unbalanced paren anywhere +# in that body breaks parsing of the *entire rest of the script* - `bash -n` +# fails, not just the generated brief. The DOD and Herdr-section builders now +# use `IFS= read -r -d '' VAR <<EOF || true` instead, which removes the `$(...)` +# wrapper and eliminates the whole defect class regardless of future prose. +# test_no_heredoc_in_command_substitution guards that structure directly. +# Ambient `bash -n` here is Bash 5 and cannot see the bug, so the real +# cross-version enforcement lives in the macos-stock-bash CI job. set -u # shellcheck source=tests/lib.sh @@ -18,9 +22,10 @@ TMP_ROOT=$(fm_test_tmproot fm-brief) BRIEF_HOME="$TMP_ROOT/home" mkdir -p "$BRIEF_HOME/data" -# The script itself must always parse. This is the direct regression test for -# issue #166: a stray apostrophe in any of the three DOD heredoc bodies -# (no-mistakes/direct-PR/local-only) breaks `bash -n` on the whole file. +# The script itself must always parse under the ambient bash. That is Bash 5 in +# CI and locally, where the issue #958/#1069 parser bug does not fire, so this +# is a weak guard on its own; test_no_heredoc_in_command_substitution and the +# macos-stock-bash CI job carry the real cross-version enforcement. test_script_parses() { local out rc out=$(bash -n "$ROOT/bin/fm-brief.sh" 2>&1); rc=$? @@ -29,6 +34,142 @@ test_script_parses() { pass "fm-brief.sh: bash -n succeeds" } +# Structural class guard (issues #166, #958, #1069): never build a variable by +# wrapping a heredoc in a command substitution (`VAR=$(cat <<EOF ... EOF)`). +# That construct is what breaks Bash 3.2 parsing, and pinning one historical +# apostrophe phrase (as the old test did) missed the #945 reintroduction. This +# guards the *shape* directly against the whole file, so any future DOD or +# section builder that reintroduces the class fails here regardless of prose. +test_no_heredoc_in_command_substitution() { + local unsafe safe + unsafe="$TMP_ROOT/heredoc-in-substitution.sh" + safe="$TMP_ROOT/plain-heredoc.sh" + # shellcheck disable=SC2016 # Literal shell fixtures must remain unexpanded. + printf '%s\n' 'value=$(' ' cat <<EOF' 'body' 'EOF' ')' > "$unsafe" + # shellcheck disable=SC2016 # Literal shell fixtures must remain unexpanded. + printf '%s\n' 'cat <<EOF' '$(' ' cat <<INNER' 'INNER' ')' 'EOF' > "$safe" + if no_heredoc_in_command_substitution "$unsafe"; then + fail "structural guard accepted a multiline heredoc nested in a command substitution" + fi + no_heredoc_in_command_substitution "$safe" \ + || fail "structural guard treated heredoc body prose as shell structure" + no_heredoc_in_command_substitution "$ROOT/bin/fm-brief.sh" \ + || fail "fm-brief.sh wraps a heredoc in a command substitution (breaks Bash 3.2 parsing)" + pass "fm-brief.sh: no heredoc is nested inside a command substitution (Bash 3.2 parse-safe)" +} + +no_heredoc_in_command_substitution() { + perl - "$1" <<'PERL' +use strict; +use warnings; + +my $path = shift; +open my $source, '<', $path or die "$path: $!\n"; +my @frames; +my @heredocs; +my $quote = ''; +my $line_number = 0; + +while (my $line = <$source>) { + $line_number++; + if (@heredocs) { + my $candidate = $line; + $candidate =~ s/\r?\n\z//; + $candidate =~ s/^\t+// if $heredocs[0]{strip_tabs}; + shift @heredocs if $candidate eq $heredocs[0]{delimiter}; + next; + } + + my $length = length $line; + for (my $i = 0; $i < $length; $i++) { + my $char = substr($line, $i, 1); + if ($quote eq "'") { + $quote = '' if $char eq "'"; + next; + } + if ($char eq '\\') { + $i++; + next; + } + if ($quote eq '"' && $char eq '"') { + $quote = ''; + next; + } + if ($char eq "'" && $quote eq '') { + $quote = "'"; + next; + } + if ($char eq '"' && $quote eq '') { + $quote = '"'; + next; + } + if ($char eq '#' && $quote eq '' && ($i == 0 || substr($line, $i - 1, 1) =~ /[\s;|&()]/)) { + last; + } + if ($char eq '$' && substr($line, $i + 1, 1) eq '(') { + push @frames, { depth => 1, quote => $quote }; + $quote = ''; + $i++; + next; + } + if (@frames && $quote eq '' && $char eq '(') { + $frames[-1]{depth}++; + next; + } + if (@frames && $quote eq '' && $char eq ')') { + $frames[-1]{depth}--; + if ($frames[-1]{depth} == 0) { + my $frame = pop @frames; + $quote = $frame->{quote}; + } + next; + } + next unless $quote eq '' && $char eq '<' && substr($line, $i + 1, 1) eq '<'; + if (@frames) { + print STDERR "$path:$line_number\n"; + exit 1; + } + + my $j = $i + 2; + my $strip_tabs = substr($line, $j, 1) eq '-'; + $j++ if $strip_tabs; + $j++ while substr($line, $j, 1) =~ /[ \t]/; + my $delimiter = ''; + my $delimiter_quote = ''; + for (; $j < $length; $j++) { + my $token = substr($line, $j, 1); + if ($delimiter_quote) { + if ($token eq $delimiter_quote) { + $delimiter_quote = ''; + } elsif ($token eq '\\' && $delimiter_quote eq '"') { + $j++; + $delimiter .= substr($line, $j, 1); + } else { + $delimiter .= $token; + } + next; + } + if ($token eq "'" || $token eq '"') { + $delimiter_quote = $token; + next; + } + if ($token eq '\\') { + $j++; + $delimiter .= substr($line, $j, 1); + next; + } + last if $token =~ /[\s;|&()<>]/; + $delimiter .= $token; + } + push @heredocs, { delimiter => $delimiter, strip_tabs => $strip_tabs }; + $i = $j - 1; + } +} + +exit 0; +PERL +} + test_help_includes_entire_header() { local help help=$("$ROOT/bin/fm-brief.sh" --help) @@ -93,6 +234,12 @@ test_faster_paths_use_configured_authority_without_stacked_review() { "local-only brief hard-coded captain-only authority" assert_no_grep "Firstmate then reviews your branch diff" "$brief" \ "local-only brief retained a personal review stacked on the selected delivery path" + assert_no_grep "make \`--intent\` preserve all relevant content from this brief" "$home/data/$id/brief.md" \ + "local-only brief must not include the no-mistakes --intent contract" + id="brief-direct-intent-a4" + FM_HOME="$home" "$ROOT/bin/fm-brief.sh" "$id" direct-proj >/dev/null 2>&1 + assert_no_grep "make \`--intent\` preserve all relevant content from this brief" "$home/data/$id/brief.md" \ + "direct-PR brief must not include the no-mistakes --intent contract" pass "fm-brief.sh: faster paths use configured authority without stacked review" } @@ -114,9 +261,21 @@ test_no_mistakes_dod_wording() { # shellcheck disable=SC2016 # single quotes are deliberate: the backticks must stay literal assert_grep '`help`' "$brief" \ "no-mistakes DOD must render literal backticks around help" - assert_no_grep "no-mistakes' own guidance" "$brief" \ - "no-mistakes DOD regressed to the apostrophe form that breaks bash -n" - pass "fm-brief.sh: no-mistakes DOD wording avoids the apostrophe regression" + assert_grep "make \`--intent\` preserve all relevant content from this brief" "$brief" \ + "no-mistakes DOD must require --intent to retain the accepted task contract" + assert_grep "carrying only each requirement's current accepted form" "$brief" \ + "no-mistakes DOD must replace superseded requirements with their current accepted form" + assert_grep "retain direct requirements instead of substituting a diff summary" "$brief" \ + "no-mistakes DOD must keep direct requirements and exclude generic scaffold boilerplate from --intent" + assert_grep "exclude generic operational, status, delivery, and other scaffold boilerplate unless it is task-specific" "$brief" \ + "no-mistakes DOD must exclude non-task-specific scaffold boilerplate from --intent" + # The apostrophe in "firstmate's authority check" is now structurally safe + # (no `$(...)` wrapper around the heredoc), so it renders verbatim instead of + # being reworded or escaped away. test_no_heredoc_in_command_substitution + # guards the structure that makes it safe. + assert_grep "firstmate's authority check" "$brief" \ + "no-mistakes DOD lost the apostrophe prose that the structural fix makes parse-safe" + pass "fm-brief.sh: no-mistakes DOD keeps its apostrophe prose, now parse-safe" } test_ship_project_memory_wording() { @@ -291,6 +450,97 @@ test_secondmate_marked_request_reporting_contract() { pass "fm-brief.sh: marked requests avoid generic acknowledgements and preserve material reporting" } +test_secondmate_directory_paths_are_absolute_and_output_is_stable() { + local root home data_override state_override brief baseline err status + root="$TMP_ROOT/relative-directory-inputs" + mkdir -p "$root" + root=$(cd "$root" && pwd -P) + home="$root/home" + data_override="$root/data-override" + state_override="$root/state-override" + mkdir -p "$home/data" "$home/state" "$data_override" "$state_override" \ + "$root/cdpath/home/data" "$root/cdpath/home/state" \ + "$root/cdpath/data-override" "$root/cdpath/state-override" + + brief="$home/data/relative-home/brief.md" + FM_HOME="$home" FM_SECONDMATE_CHARTER=x \ + "$ROOT/bin/fm-brief.sh" relative-home --secondmate --no-projects >/dev/null 2>&1 + baseline="$root/absolute-home-charter" + cp "$brief" "$baseline" + rm -f "$brief" + ( + cd "$root" || exit 1 + CDPATH="$root/cdpath" FM_HOME=home FM_SECONDMATE_CHARTER=x \ + "$ROOT/bin/fm-brief.sh" relative-home --secondmate --no-projects >/dev/null 2>&1 + ) + cmp -s "$baseline" "$brief" \ + || fail "relative FM_HOME changed charter bytes compared with the same absolute home" + assert_grep ">> '$home/state/relative-home.status'" "$brief" \ + "relative FM_HOME did not render an absolute secondmate status path" + + brief="$home/data/relative-state/brief.md" + FM_HOME="$home" FM_STATE_OVERRIDE="$state_override" FM_SECONDMATE_CHARTER=x \ + "$ROOT/bin/fm-brief.sh" relative-state --secondmate --no-projects >/dev/null 2>&1 + baseline="$root/absolute-state-charter" + cp "$brief" "$baseline" + rm -f "$brief" + ( + cd "$root" || exit 1 + CDPATH="$root/cdpath" FM_HOME="$home" FM_STATE_OVERRIDE=state-override FM_SECONDMATE_CHARTER=x \ + "$ROOT/bin/fm-brief.sh" relative-state --secondmate --no-projects >/dev/null 2>&1 + ) + cmp -s "$baseline" "$brief" \ + || fail "relative FM_STATE_OVERRIDE changed charter bytes compared with the same absolute state directory" + assert_grep ">> '$state_override/relative-state.status'" "$brief" \ + "relative FM_STATE_OVERRIDE did not render an absolute secondmate status path" + + brief="$data_override/relative-data/brief.md" + FM_HOME="$home" FM_DATA_OVERRIDE="$data_override" FM_SECONDMATE_CHARTER=x \ + "$ROOT/bin/fm-brief.sh" relative-data --secondmate --no-projects >/dev/null 2>&1 + baseline="$root/absolute-data-charter" + cp "$brief" "$baseline" + rm -f "$brief" + ( + cd "$root" || exit 1 + CDPATH="$root/cdpath" FM_HOME="$home" FM_DATA_OVERRIDE=data-override FM_SECONDMATE_CHARTER=x \ + "$ROOT/bin/fm-brief.sh" relative-data --secondmate --no-projects >/dev/null 2>&1 + ) + cmp -s "$baseline" "$brief" \ + || fail "relative FM_DATA_OVERRIDE changed charter bytes compared with the same absolute data directory" + assert_grep ">> '$home/state/relative-data.status'" "$brief" \ + "relative FM_DATA_OVERRIDE changed the absolute default status path" + + err="$root/unresolved.err" + ( + cd "$root" || exit 1 + FM_HOME=missing-home FM_SECONDMATE_CHARTER=x \ + "$ROOT/bin/fm-brief.sh" unresolved-home --secondmate --no-projects >/dev/null 2>"$err" + ); status=$? + expect_code 1 "$status" "an unresolved relative FM_HOME must fail" + assert_grep "FM_HOME directory cannot be resolved: missing-home" "$err" \ + "unresolved relative FM_HOME did not fail loudly" + + ( + cd "$root" || exit 1 + FM_HOME="$home" FM_STATE_OVERRIDE=missing-state FM_SECONDMATE_CHARTER=x \ + "$ROOT/bin/fm-brief.sh" unresolved-state --secondmate --no-projects >/dev/null 2>"$err" + ); status=$? + expect_code 1 "$status" "an unresolved relative FM_STATE_OVERRIDE must fail" + assert_grep "FM_STATE_OVERRIDE directory cannot be resolved: missing-state" "$err" \ + "unresolved relative FM_STATE_OVERRIDE did not fail loudly" + + ( + cd "$root" || exit 1 + FM_HOME="$home" FM_DATA_OVERRIDE=missing-data FM_SECONDMATE_CHARTER=x \ + "$ROOT/bin/fm-brief.sh" unresolved-data --secondmate --no-projects >/dev/null 2>"$err" + ); status=$? + expect_code 1 "$status" "an unresolved relative FM_DATA_OVERRIDE must fail" + assert_grep "FM_DATA_OVERRIDE directory cannot be resolved: missing-data" "$err" \ + "unresolved relative FM_DATA_OVERRIDE did not fail loudly" + + pass "fm-brief.sh: relative directory inputs ignore CDPATH, render stable absolute charter paths, or fail loudly" +} + test_herdr_lab_contract_applies_to_scouts_but_not_secondmates() { local home brief status=0 home="$TMP_ROOT/herdr-kind-home" @@ -383,6 +633,7 @@ test_scout_and_secondmate_scaffold() { } test_script_parses +test_no_heredoc_in_command_substitution test_help_includes_entire_header test_ship_modes_generate_clean_briefs test_faster_paths_use_configured_authority_without_stacked_review @@ -394,6 +645,7 @@ test_herdr_lab_omission_is_loud_for_ship_and_scout test_herdr_lab_contract_applies_to_scouts_but_not_secondmates test_secondmate_no_projects_charter test_secondmate_marked_request_reporting_contract +test_secondmate_directory_paths_are_absolute_and_output_is_stable test_pause_verb_override_renders_all_brief_scaffolds test_scout_and_secondmate_load_decision_hold_policy test_scout_and_secondmate_scaffold diff --git a/tests/fm-busy-adapter-wiring.test.sh b/tests/fm-busy-adapter-wiring.test.sh new file mode 100755 index 0000000000..4636da5f79 --- /dev/null +++ b/tests/fm-busy-adapter-wiring.test.sh @@ -0,0 +1,350 @@ +#!/usr/bin/env bash +# Behavior tests for the per-adapter semantic busy-state wiring that +# bin/fm-spawn.sh installs under the contract owned by bin/fm-busy-lib.sh. +# +# These tests run the REAL fm-spawn against a fake tmux pane and an isolated +# git worktree, then drive the generated adapter artifact (the Pi extension, +# the OpenCode plugin) in a plain Node host, so the artifact, the real +# bin/fm-busy-event.sh writer, and the real classifier are exercised together +# with no live harness session. +set -u + +# shellcheck source=tests/lib.sh +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" + +# shellcheck source=/dev/null +. "$ROOT/bin/fm-busy-lib.sh" + +SPAWN="$ROOT/bin/fm-spawn.sh" +TMP_ROOT=$(fm_test_tmproot fm-busy-adapter-wiring) + +make_spawn_fakebin() { + local dir=$1 fakebin + fakebin=$(fm_fakebin "$dir") + cat > "$fakebin/tmux" <<'SH' +#!/usr/bin/env bash +set -u +case "$*" in + *"#{pane_current_path}"*) printf '%s\n' "${FM_FAKE_PANE_PATH:-}"; exit 0 ;; +esac +case "${1:-}" in + display-message) printf 'firstmate\n'; exit 0 ;; + list-windows) exit 0 ;; + has-session|new-session|new-window|kill-window|send-keys) exit 0 ;; +esac +exit 0 +SH + chmod +x "$fakebin/tmux" + fm_fake_exit0 "$fakebin" treehouse pi opencode claude codex + printf '%s\n' "$fakebin" +} + +make_spawn_case() { # <name> <harness> <id> + local name=$1 harness=$2 id=$3 case_dir home proj wt fakebin + case_dir="$TMP_ROOT/$name" + home="$case_dir/home" + proj="$case_dir/project" + wt="$case_dir/wt" + fakebin=$(make_spawn_fakebin "$case_dir/fake") + mkdir -p "$home/data" "$home/projects" "$home/state" "$home/config" + printf '%s\n' "$harness" > "$home/config/crew-harness" + fm_git_worktree "$proj" "$wt" "wt-$name" + touch "$home/state/.last-watcher-beat" + mkdir -p "$home/data/$id" + printf 'brief for %s\n' "$id" > "$home/data/$id/brief.md" + printf '%s\n' "$case_dir|$home|$proj|$wt|$fakebin" +} + +run_spawn() { # <home> <wt> <fakebin> <spawn-args...> + local home=$1 wt=$2 fakebin=$3 + shift 3 + FM_ROOT_OVERRIDE='' FM_HOME="$home" \ + FM_STATE_OVERRIDE="$home/state" FM_DATA_OVERRIDE="$home/data" \ + FM_PROJECTS_OVERRIDE="$home/projects" FM_CONFIG_OVERRIDE="$home/config" \ + FM_SPAWN_NO_GUARD=1 FM_FAKE_PANE_PATH="$wt" TMUX="fake,1,0" \ + GROK_HOME="$home/grok-home" PATH="$fakebin:$PATH" \ + "$SPAWN" "$@" 2>&1 +} + +read_case_record() { + # shellcheck disable=SC2034 # CASE_DIR is part of the shared record shape + IFS='|' read -r CASE_DIR HOME_DIR PROJ_DIR WT_DIR FAKEBIN_DIR <<EOF +$1 +EOF +} + +classify() { # <harness> <id> <state-dir> + fm_busy_classify tmux fake:w "$1" "$2" "$3" +} + +# drive_pi_ext <ext-path> <mode>: load the generated Pi extension in a plain +# Node host and fire one lifecycle handler. Modes: agent-start, settle-idle, +# settle-continuing, turn-end. +drive_pi_ext() { + EXT_PATH="$1" MODE="$2" node --input-type=module 2>&1 <<'EOF' +import { pathToFileURL } from "node:url"; +const mod = await import(pathToFileURL(process.env.EXT_PATH).href); +const handlers = {}; +mod.default({ on: (name, fn) => { handlers[name] = fn; } }); +const ctx = { isIdle: () => process.env.MODE !== "settle-continuing" }; +switch (process.env.MODE) { + case "agent-start": await handlers["agent_start"]({}, ctx); break; + case "settle-idle": await handlers["agent_settled"]({}, ctx); break; + case "settle-continuing": await handlers["agent_settled"]({}, ctx); break; + case "settle-then-start": + await handlers["agent_settled"]({}, ctx); + await handlers["agent_start"]({}, ctx); + break; + case "turn-end": await handlers["turn_end"]({}, ctx); break; + default: throw new Error("unknown mode " + process.env.MODE); +} +if (process.env.MODE === "turn-end") { + await new Promise((resolve) => setTimeout(resolve, 200)); +} +EOF +} + +test_pi_extension_semantic_lifecycle() { + local rec id=busy-pi-1 out state ext + rec=$(make_spawn_case pi-lifecycle pi "$id") + read_case_record "$rec" + out=$(run_spawn "$HOME_DIR" "$WT_DIR" "$FAKEBIN_DIR" "$id" "$PROJ_DIR") + expect_code 0 $? "pi spawn should succeed: $out" + state="$HOME_DIR/state" + ext="$state/$id.pi-ext.ts" + assert_present "$ext" "pi spawn did not write the per-task extension" + + out=$(classify pi "$id" "$state") + [ "$out" = "busy fm-spawn" ] || fail "seed after spawn must be 'busy fm-spawn', got '$out'" + + rm -f "$state/$id.turn-ended" + out=$(drive_pi_ext "$ext" turn-end) || fail "turn_end drive failed: $out" + [ -f "$state/$id.turn-ended" ] || fail "turn_end no longer touches the notification marker" + out=$(classify pi "$id" "$state") + [ "$out" = "busy fm-spawn" ] || fail "turn_end must stay a notification, not a state edge, got '$out'" + + out=$(drive_pi_ext "$ext" settle-idle) || fail "agent_settled drive failed: $out" + out=$(classify pi "$id" "$state") + [ "$out" = "idle pi-ext" ] || fail "agent_settled with isIdle must classify 'idle pi-ext', got '$out'" + + out=$(drive_pi_ext "$ext" agent-start) || fail "agent_start drive failed: $out" + out=$(classify pi "$id" "$state") + [ "$out" = "busy pi-ext" ] || fail "agent_start must classify 'busy pi-ext', got '$out'" + + out=$(drive_pi_ext "$ext" settle-continuing) || fail "continuing settle drive failed: $out" + out=$(classify pi "$id" "$state") + [ "$out" = "busy pi-ext" ] || fail "a settle while another run continues must stay busy, got '$out'" + + out=$(drive_pi_ext "$ext" settle-idle) || fail "final settle drive failed: $out" + out=$(classify pi "$id" "$state") + [ "$out" = "idle pi-ext" ] || fail "the final settle must classify idle, got '$out'" + pass "pi extension reports agent_start busy, settles idle only via ctx.isIdle(), and keeps turn_end a notification" +} + +test_pi_extension_serializes_settle_before_next_start() { + local rec id=busy-pi-order out state ext + rec=$(make_spawn_case pi-order pi "$id") + read_case_record "$rec" + out=$(run_spawn "$HOME_DIR" "$WT_DIR" "$FAKEBIN_DIR" "$id" "$PROJ_DIR") + expect_code 0 $? "pi spawn should succeed: $out" + state="$HOME_DIR/state" + ext="$state/$id.pi-ext.ts" + + out=$(drive_pi_ext "$ext" settle-then-start) || fail "settle/start drive failed: $out" + out=$(classify pi "$id" "$state") + [ "$out" = "busy pi-ext" ] || fail "a fresh agent_start after agent_settled must win, got '$out'" + pass "pi extension awaits agent_settled before the next agent_start without a test delay" +} + +test_pi_extension_stale_incarnation_rejected() { + local rec id=busy-pi-2 out state ext + rec=$(make_spawn_case pi-stale pi "$id") + read_case_record "$rec" + out=$(run_spawn "$HOME_DIR" "$WT_DIR" "$FAKEBIN_DIR" "$id" "$PROJ_DIR") + expect_code 0 $? "pi spawn should succeed: $out" + state="$HOME_DIR/state" + ext="$state/$id.pi-ext.ts" + # A re-arm (a rewired incarnation) supersedes the gen embedded in the old + # extension file: its late events must be rejected and never change state. + "$ROOT/bin/fm-busy-event.sh" arm "$state" "$id" >/dev/null + out=$(drive_pi_ext "$ext" settle-idle) || fail "stale settle drive failed: $out" + out=$(classify pi "$id" "$state") + [ "$out" = "busy fm-spawn" ] || fail "a stale extension event must not change state, got '$out'" + pass "pi extension events from a superseded incarnation are rejected as stale" +} + +# drive_oc_plugin <plugin-path> <events-json-lines...>: load the generated +# OpenCode plugin in a plain Node host and feed it one event per argument, in +# order, through the same hooks.event entry OpenCode calls. +drive_oc_plugin() { + local plugin=$1 + shift + PLUGIN_PATH="$plugin" node --input-type=module - "$@" 2>&1 <<'EOF' +import { pathToFileURL } from "node:url"; +const mod = await import(pathToFileURL(process.env.PLUGIN_PATH).href); +const hooks = await mod.FmBusyState({}); +for (const arg of process.argv.slice(2)) { + await hooks.event({ event: JSON.parse(arg) }); +} +EOF +} + +oc_status() { # <sessionID> <type> + printf '{"type":"session.status","properties":{"sessionID":"%s","status":{"type":"%s"}}}' "$1" "$2" +} + +oc_idle() { # <sessionID> + printf '{"type":"session.idle","properties":{"sessionID":"%s"}}' "$1" +} + +test_opencode_plugin_semantic_lifecycle() { + local rec id=busy-oc-1 out state plugin + rec=$(make_spawn_case oc-lifecycle opencode "$id") + read_case_record "$rec" + out=$(run_spawn "$HOME_DIR" "$WT_DIR" "$FAKEBIN_DIR" "$id" "$PROJ_DIR") + expect_code 0 $? "opencode spawn should succeed: $out" + state="$HOME_DIR/state" + plugin="$WT_DIR/.opencode/plugins/fm-busy-state.js" + assert_present "$plugin" "opencode spawn did not write the busy-state plugin" + + out=$(classify opencode "$id" "$state") + [ "$out" = "busy fm-spawn" ] || fail "seed after spawn must be 'busy fm-spawn', got '$out'" + + out=$(drive_oc_plugin "$plugin" "$(oc_status ses_main busy)") || fail "busy drive failed: $out" + out=$(classify opencode "$id" "$state") + [ "$out" = "busy opencode-plugin" ] || fail "session busy must classify 'busy opencode-plugin', got '$out'" + + out=$(drive_oc_plugin "$plugin" \ + "$(oc_status ses_main busy)" \ + "$(oc_status ses_child busy)" \ + "$(oc_status ses_child idle)") || fail "child-session drive failed: $out" + out=$(classify opencode "$id" "$state") + [ "$out" = "busy opencode-plugin" ] || fail "a child session's idle must not clear the worker, got '$out'" + + out=$(drive_oc_plugin "$plugin" \ + "$(oc_status ses_main retry)" \ + "$(oc_status ses_main idle)") || fail "retry/idle drive failed: $out" + out=$(classify opencode "$id" "$state") + [ "$out" = "idle opencode-plugin" ] || fail "the latched session's idle must classify idle, got '$out'" + + rm -f "$state/$id.turn-ended" + out=$(drive_oc_plugin "$plugin" \ + "$(oc_status ses_main busy)" \ + "$(oc_idle ses_main)") || fail "session.idle drive failed: $out" + [ -f "$state/$id.turn-ended" ] || fail "session.idle no longer touches the notification marker" + out=$(classify opencode "$id" "$state") + [ "$out" = "idle opencode-plugin" ] || fail "session.idle for the latched session must classify idle, got '$out'" + + rm -f "$state/$id.turn-ended" + out=$(drive_oc_plugin "$plugin" \ + "$(oc_status ses2 busy)" \ + "$(oc_idle ses_other)") || fail "other-session idle drive failed: $out" + [ -f "$state/$id.turn-ended" ] || fail "the marker touch must stay a notification for every session.idle" + out=$(classify opencode "$id" "$state") + [ "$out" = "busy opencode-plugin" ] || fail "another session's idle must not clear the latched busy, got '$out'" + pass "opencode plugin classifies from session.status, scoped to the latched worker session" +} + +run_claude_hook() { # <settings.json> <hook-event> + local cmd + cmd=$(jq -r ".hooks[\"$2\"][0].hooks[0].command" "$1") + [ -n "$cmd" ] && [ "$cmd" != null ] || fail "no $2 hook command in $1" + sh -c "$cmd" +} + +test_claude_hooks_semantic_lifecycle() { + local rec id=busy-cl-1 out state settings + rec=$(make_spawn_case claude-lifecycle claude "$id") + read_case_record "$rec" + out=$(run_spawn "$HOME_DIR" "$WT_DIR" "$FAKEBIN_DIR" "$id" "$PROJ_DIR") + expect_code 0 $? "claude spawn should succeed: $out" + state="$HOME_DIR/state" + settings="$WT_DIR/.claude/settings.local.json" + assert_present "$settings" "claude spawn did not write hook settings" + jq -e . "$settings" >/dev/null || fail "claude hook settings are not valid JSON" + for ev in UserPromptSubmit Stop StopFailure SessionEnd; do + jq -e ".hooks[\"$ev\"]" "$settings" >/dev/null || fail "claude hook settings lack $ev" + done + + out=$(classify claude "$id" "$state") + [ "$out" = "busy fm-spawn" ] || fail "seed after spawn must be 'busy fm-spawn', got '$out'" + + rm -f "$state/$id.turn-ended" + run_claude_hook "$settings" Stop || fail "Stop hook command failed" + [ -f "$state/$id.turn-ended" ] || fail "Stop no longer touches the notification marker" + out=$(classify claude "$id" "$state") + [ "$out" = "idle claude-hook" ] || fail "Stop must classify 'idle claude-hook', got '$out'" + + run_claude_hook "$settings" UserPromptSubmit || fail "UserPromptSubmit hook command failed" + out=$(classify claude "$id" "$state") + [ "$out" = "busy claude-hook" ] || fail "UserPromptSubmit must classify 'busy claude-hook', got '$out'" + + run_claude_hook "$settings" StopFailure || fail "StopFailure hook command failed" + out=$(classify claude "$id" "$state") + [ "$out" = "idle claude-hook" ] || fail "StopFailure must classify idle so an API error cannot strand busy, got '$out'" + + run_claude_hook "$settings" UserPromptSubmit + run_claude_hook "$settings" SessionEnd || fail "SessionEnd hook command failed" + out=$(classify claude "$id" "$state") + [ "$out" = "idle claude-hook" ] || fail "SessionEnd must classify idle, got '$out'" + pass "claude hooks open on UserPromptSubmit and close on Stop, StopFailure, and SessionEnd" +} + +test_claude_hooks_stale_incarnation_harmless() { + local rec id=busy-cl-2 out state settings + rec=$(make_spawn_case claude-stale claude "$id") + read_case_record "$rec" + out=$(run_spawn "$HOME_DIR" "$WT_DIR" "$FAKEBIN_DIR" "$id" "$PROJ_DIR") + expect_code 0 $? "claude spawn should succeed: $out" + state="$HOME_DIR/state" + settings="$WT_DIR/.claude/settings.local.json" + "$ROOT/bin/fm-busy-event.sh" arm "$state" "$id" >/dev/null + run_claude_hook "$settings" UserPromptSubmit \ + || fail "a stale-gen hook must still exit 0 so Claude's lifecycle is never broken" + out=$(classify claude "$id" "$state") + [ "$out" = "busy fm-spawn" ] || fail "a stale-gen hook event must not change state, got '$out'" + pass "claude hook events from a superseded incarnation are rejected without breaking the hook" +} + +test_codex_unverified_until_a_semantic_source_exists() { + local rec id=busy-cx-1 out state + rec=$(make_spawn_case codex-unverified codex "$id") + read_case_record "$rec" + out=$(run_spawn "$HOME_DIR" "$WT_DIR" "$FAKEBIN_DIR" "$id" "$PROJ_DIR") + expect_code 0 $? "codex spawn should succeed: $out" + state="$HOME_DIR/state" + assert_absent "$state/$id.busy-gen" "codex must not arm a busy contract with no verified semantic source" + assert_absent "$WT_DIR/.codex/hooks.json" "codex must not install unverified busy hooks" + assert_contains "$out" 'spawned '"$id"' harness=codex' "codex spawn did not complete normally" + out=$(classify codex "$id" "$state") + [ "$out" = "unknown codex-unverified" ] || fail "codex must classify 'unknown codex-unverified', got '$out'" + out=$(fm_busy_classify tmux fake:w codex "$id" "$state" '• Working (6s • esc to interrupt)') + [ "$out" = "unknown codex-unverified" ] || fail "codex must not fall back to footer text, got '$out'" + pass "codex classifies unknown until a semantic source is verified, never idle or footer-matched" +} + +test_kimi_and_grok_install_no_unverified_wiring() { + local state out + state="$TMP_ROOT/gates/state" + mkdir -p "$state" + [ -z "$(fm_busy_sources_for_harness kimi)" ] \ + || fail "standalone kimi must trust no semantic source until it is verified" + [ -z "$(fm_busy_sources_for_harness grok)" ] \ + || fail "grok must trust no semantic source while its structured path is unverified" + out=$(fm_busy_classify tmux fake:w kimi gate-k "$state" '🌒 · thinking') + [ "$out" = "unknown kimi-unverified" ] || fail "kimi must classify unknown, not from its spinner, got '$out'" + out=$(fm_busy_classify tmux fake:w grok gate-g "$state" 'Ctrl+c:cancel') + [ "$out" = "busy grok-regex" ] || fail "grok must classify through its isolated fallback, got '$out'" + pass "kimi and grok install no unverified semantic wiring and classify through their own gates" +} + +test_pi_extension_semantic_lifecycle +test_pi_extension_serializes_settle_before_next_start +test_pi_extension_stale_incarnation_rejected +test_kimi_and_grok_install_no_unverified_wiring +test_opencode_plugin_semantic_lifecycle +test_claude_hooks_semantic_lifecycle +test_claude_hooks_stale_incarnation_harmless +test_codex_unverified_until_a_semantic_source_exists + +echo "all fm-busy-adapter-wiring tests passed" diff --git a/tests/fm-busy-state.test.sh b/tests/fm-busy-state.test.sh new file mode 100755 index 0000000000..a3ebf4ac18 --- /dev/null +++ b/tests/fm-busy-state.test.sh @@ -0,0 +1,401 @@ +#!/usr/bin/env bash +# Behavior tests for the semantic busy-state contract (bin/fm-busy-lib.sh and +# its only writer bin/fm-busy-event.sh). +# +# Covers the captain-approved redesign invariants: busy/idle/unknown/dead with +# explicit source attribution; missing, malformed, stale (gen-mismatch), and +# untrusted (source-mismatch) semantic data classify unknown - never idle; +# adapter isolation (one adapter's writer or a rendered fallback can never +# classify another adapter); endpoint death is the only process-level override and +# yields dead, never busy; converted adapters never classify from rendered +# footer text. All hermetic over temp dirs; no real agent session is invoked. +set -u + +# shellcheck source=tests/lib.sh +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" + +# shellcheck source=/dev/null +. "$ROOT/bin/fm-busy-lib.sh" + +TMP_ROOT=$(fm_test_tmproot fm-busy-state) +EV="$ROOT/bin/fm-busy-event.sh" + +new_state_dir() { # <name> + local d="$TMP_ROOT/$1/state" + mkdir -p "$d" + printf '%s' "$d" +} + +# --- writer: arm and apply --------------------------------------------------- + +test_arm_seeds_busy_spawn() { + local state gen out + state=$(new_state_dir arm-seed) + gen=$("$EV" arm "$state" t1) || fail "arm failed" + [ -f "$state/t1.busy-gen" ] || fail "arm did not write the gen sidecar" + [ "$(cat "$state/t1.busy-gen")" = "$gen" ] || fail "sidecar gen does not match printed gen" + out=$(fm_busy_classify tmux w1 claude t1 "$state") + [ "$out" = "busy fm-spawn" ] || fail "seed should classify 'busy fm-spawn', got '$out'" + pass "arm mints a gen sidecar and seeds busy fm-spawn at seq=1" +} + +test_apply_advances_seq_and_source() { + local state gen out seq + state=$(new_state_dir apply-seq) + gen=$("$EV" arm "$state" t1) + "$EV" apply "$state" t1 idle --gen "$gen" --source claude-hook --event stop \ + || fail "apply idle failed" + out=$(fm_busy_classify tmux w1 claude t1 "$state") + [ "$out" = "idle claude-hook" ] || fail "expected 'idle claude-hook', got '$out'" + "$EV" apply "$state" t1 busy --gen "$gen" --source claude-hook --event user-prompt-submit \ + || fail "apply busy failed" + out=$(fm_busy_classify tmux w1 claude t1 "$state") + [ "$out" = "busy claude-hook" ] || fail "expected 'busy claude-hook', got '$out'" + seq=$(fm_busy_record_read "$state" t1 | awk '{print $4}') + [ "$seq" = 3 ] || fail "expected seq 3 after seed + two applies, got '$seq'" + pass "apply advances seq under the armed gen and attributes the writing source" +} + +test_apply_current_gen_reset() { + local state out + state=$(new_state_dir apply-current) + "$EV" arm "$state" t1 >/dev/null + "$EV" apply "$state" t1 idle --current-gen --source fm-interrupt --event interrupt \ + || fail "apply --current-gen failed" + out=$(fm_busy_classify tmux w1 pi t1 "$state") + [ "$out" = "idle fm-interrupt" ] || fail "expected 'idle fm-interrupt', got '$out'" + "$EV" apply "$state" t1 unknown --current-gen --source fm-recovery --event relaunch \ + || fail "apply unknown failed" + out=$(fm_busy_classify tmux w1 pi t1 "$state") + [ "$out" = "unknown fm-recovery" ] || fail "expected 'unknown fm-recovery', got '$out'" + pass "firstmate-owned interrupt and recovery events bind to the current gen" +} + +test_apply_unarmed_refused() { + local state + state=$(new_state_dir apply-unarmed) + if "$EV" apply "$state" t1 busy --gen g1.2.3 --source claude-hook --event x 2>/dev/null; then + fail "apply against an unarmed task must be refused" + fi + [ ! -f "$state/t1.busy-state" ] || fail "refused apply must not write a record" + pass "apply is refused for a task whose busy contract was never armed" +} + +test_retire_serializes_and_rejects_stale_gen() { + local state old_gen new_gen out retire_pid i=0 + state=$(new_state_dir retire) + old_gen=$("$EV" arm "$state" t1) + mkdir "$state/t1.busy-state.lock" + "$EV" retire "$state" t1 --gen "$old_gen" >/dev/null 2>&1 & + retire_pid=$! + while [ "$i" -lt 20 ] && ! kill -0 "$retire_pid" 2>/dev/null; do + i=$((i + 1)) + done + [ -e "$state/t1.busy-state" ] || fail "retire bypassed the writer lock" + rmdir "$state/t1.busy-state.lock" + wait "$retire_pid" || fail "retire failed after acquiring the writer lock" + [ ! -e "$state/t1.busy-state" ] || fail "retire left the record behind" + [ ! -e "$state/t1.busy-gen" ] || fail "retire left the gen sidecar behind" + + new_gen=$("$EV" arm "$state" t1) + if "$EV" retire "$state" t1 --gen "$old_gen" 2>/dev/null; then + fail "retire accepted a superseded incarnation" + fi + out=$(fm_busy_classify tmux w1 claude t1 "$state") + [ "$out" = "busy fm-spawn" ] || fail "stale retirement changed the new incarnation, got '$out'" + [ "$(cat "$state/t1.busy-gen")" = "$new_gen" ] || fail "stale retirement changed the new gen" + pass "retire waits for the writer lock and cannot remove a new incarnation" +} + +test_retire_missing_sidecar_is_idempotent() { + local state gen + state=$(new_state_dir retire-missing) + gen=$("$EV" arm "$state" t1) + rm -f "$state/t1.busy-gen" + + "$EV" retire "$state" t1 --gen "$gen" || fail "exact-gen retire rejected a missing sidecar" + [ ! -e "$state/t1.busy-state" ] || fail "retire left an orphan record behind" + "$EV" retire "$state" t1 --gen "$gen" || fail "repeated exact-gen retire was not idempotent" + "$EV" retire "$state" t1 --current-gen || fail "current-gen retire was not idempotent" + + printf 'malformed gen\n' > "$state/t1.busy-gen" + printf 'orphan\n' > "$state/t1.busy-state" + if "$EV" retire "$state" t1 --gen "$gen" 2>/dev/null; then + fail "retire accepted a malformed existing sidecar" + fi + [ -e "$state/t1.busy-state" ] || fail "retire removed the record for a malformed existing sidecar" + pass "retire treats only an absent sidecar as already retired" +} + +# --- stale event rejection ---------------------------------------------------- + +test_stale_gen_event_rejected() { + local state old_gen new_gen out + state=$(new_state_dir stale-event) + old_gen=$("$EV" arm "$state" t1) + new_gen=$("$EV" arm "$state" t1) + [ "$old_gen" != "$new_gen" ] || fail "re-arm must mint a fresh gen" + if "$EV" apply "$state" t1 idle --gen "$old_gen" --source claude-hook --event stop 2>/dev/null; then + fail "an event carrying a stale gen must be rejected" + fi + out=$(fm_busy_classify tmux w1 claude t1 "$state") + [ "$out" = "busy fm-spawn" ] || fail "stale event must not change the record, got '$out'" + pass "a late event from a previous incarnation is rejected, record unchanged" +} + +test_stale_gen_record_unknown() { + local state gen out + state=$(new_state_dir stale-record) + gen=$("$EV" arm "$state" t1) + # Simulate a record left behind by a superseded incarnation. + printf 'g-superseded.1.1\n' > "$state/t1.busy-gen.new" + mv "$state/t1.busy-gen.new" "$state/t1.busy-gen" + out=$(fm_busy_classify tmux w1 claude t1 "$state") + [ "$out" = "unknown gen-mismatch" ] || fail "stale record must classify 'unknown gen-mismatch', got '$out'" + pass "a record from a stale incarnation classifies unknown, never idle" +} + +# --- missing and malformed semantic data -------------------------------------- + +test_missing_record_unknown_not_idle() { + local state out h + state=$(new_state_dir missing) + for h in claude opencode pi pi-signed; do + out=$(fm_busy_classify tmux w1 "$h" t1 "$state") + [ "$out" = "unknown missing" ] || fail "$h with no record must be 'unknown missing', got '$out'" + done + out=$(fm_busy_classify tmux w1 codex t1 "$state") + [ "$out" = "unknown codex-unverified" ] || fail "codex with no verified source must be 'unknown codex-unverified', got '$out'" + pass "a converted adapter with no record classifies unknown, never idle" +} + +test_malformed_record_unknown() { + local state gen out + state=$(new_state_dir malformed) + gen=$("$EV" arm "$state" t1) + for bad in \ + 'garbage' \ + "v0 gen=$gen seq=1 state=busy source=claude-hook event=x ts=1" \ + "v1 gen=$gen seq=NaN state=busy source=claude-hook event=x ts=1" \ + "v1 gen=$gen seq=1 state=frobbing source=claude-hook event=x ts=1" \ + "v1 gen=$gen seq=1 state=busy source=bad source event=x ts=1" \ + "v1 gen=$gen seq=1 state=busy source=claude-hook event=x ts=1 rogue=1"; do + printf '%s\n' "$bad" > "$state/t1.busy-state" + out=$(fm_busy_classify tmux w1 claude t1 "$state") + [ "$out" = "unknown malformed" ] || fail "malformed record '$bad' must be 'unknown malformed', got '$out'" + done + printf 'v1 gen=%s seq=1 state=busy source=claude-hook event=x ts=1\nsecond line\n' "$gen" > "$state/t1.busy-state" + out=$(fm_busy_classify tmux w1 claude t1 "$state") + [ "$out" = "unknown malformed" ] || fail "multi-line record must be 'unknown malformed', got '$out'" + pass "malformed records classify unknown malformed, never busy or idle" +} + +test_record_without_sidecar_unknown() { + local state out + state=$(new_state_dir orphan-record) + printf 'v1 gen=g1.1.1 seq=1 state=busy source=claude-hook event=x ts=1\n' > "$state/t1.busy-state" + out=$(fm_busy_classify tmux w1 claude t1 "$state") + [ "$out" = "unknown malformed" ] || fail "record without an armed gen must be unknown, got '$out'" + pass "a record with no armed gen sidecar classifies unknown" +} + +# --- adapter isolation --------------------------------------------------------- + +test_source_mismatch_cross_adapter() { + local state gen out + state=$(new_state_dir cross-adapter) + gen=$("$EV" arm "$state" t1) + "$EV" apply "$state" t1 busy --gen "$gen" --source pi-ext --event agent-start + out=$(fm_busy_classify tmux w1 claude t1 "$state") + [ "$out" = "unknown source-mismatch" ] || fail "pi-ext record on a claude task must be untrusted, got '$out'" + out=$(fm_busy_classify tmux w1 pi t1 "$state") + [ "$out" = "busy pi-ext" ] || fail "pi-ext record on a pi task must classify, got '$out'" + out=$(fm_busy_classify tmux w1 grok t1 "$state") + [ "$out" = "unknown source-mismatch" ] || fail "grok trusts no semantic source, got '$out'" + pass "a record is trusted only by the adapter whose source wrote it" +} + +test_converted_adapters_ignore_footer_text() { + local state out h + state=$(new_state_dir no-footer) + local tail='• Working (6s • esc to interrupt) + ■■■■⬝⬝⬝⬝ esc interrupt +Working... +Ctrl+c:cancel' + for h in claude opencode pi pi-signed; do + out=$(fm_busy_classify tmux w1 "$h" t1 "$state" "$tail") + [ "$out" = "unknown missing" ] || fail "$h must never classify from footer text, got '$out'" + done + out=$(fm_busy_classify tmux w1 codex t1 "$state" "$tail") + [ "$out" = "unknown codex-unverified" ] || fail "codex must never classify from footer text, got '$out'" + pass "converted adapters never classify busy from rendered footer text" +} + +test_grok_regex_isolated() { + local state out + state=$(new_state_dir grok-arm) + out=$(fm_busy_classify tmux w1 grok t1 "$state" 'thinking hard +Ctrl+c:cancel') + [ "$out" = "busy grok-regex" ] || fail "grok busy tail must classify 'busy grok-regex', got '$out'" + out=$(fm_busy_classify tmux w1 grok t1 "$state" 'done. +> ') + [ "$out" = "idle grok-regex" ] || fail "grok idle tail must classify 'idle grok-regex', got '$out'" + # Another adapter's footer never makes grok busy either. + out=$(fm_busy_classify tmux w1 grok t1 "$state" '• Working (6s • esc to interrupt)') + [ "$out" = "idle grok-regex" ] || fail "a claude footer must not classify grok busy, got '$out'" + pass "the grok fallback is regex-scoped to grok and classifies only grok tasks" +} + +test_agy_pane_fallback_isolated_and_explicit() { + local state out + state=$(new_state_dir agy-arm) + out=$(fm_busy_classify tmux w1 agy t1 "$state" '⣷ Generating... +esc to cancel') + [ "$out" = "busy agy-pane" ] || fail "agy busy pane must classify 'busy agy-pane', got '$out'" + out=$(fm_busy_classify tmux w1 agy t1 "$state" '? for shortcuts Gemini 3.6 Flash · high') + [ "$out" = "idle agy-pane" ] || fail "agy explicit idle footer must classify 'idle agy-pane', got '$out'" + out=$(fm_busy_classify tmux w1 agy t1 "$state" 'ordinary output +> ') + [ "$out" = "unknown agy-inconclusive" ] \ + || fail "agy without an explicit busy or idle signature must stay unknown, got '$out'" + out=$(fm_busy_classify tmux w1 agy t1 "$state" 'Ctrl+c:cancel') + [ "$out" = "unknown agy-inconclusive" ] \ + || fail "grok's footer must not classify an agy task, got '$out'" + out=$(fm_busy_classify tmux w1 grok t1 "$state" 'Generating...') + [ "$out" = "idle grok-regex" ] || fail "agy's footer must not classify a grok task, got '$out'" + pass "the agy fallback requires explicit AGY pane signatures and cannot leak across adapters" +} + +# --- kimi verification gate ----------------------------------------------------- + +test_codex_unverified_gate() { + local state gen out + state=$(new_state_dir codex-gate) + gen=$("$EV" arm "$state" t1) + "$EV" apply "$state" t1 busy --gen "$gen" --source codex-hook --event user-prompt-submit + out=$(fm_busy_classify tmux w1 codex t1 "$state") + [ "$out" = "unknown codex-unverified" ] || fail "unverified codex must classify unknown, got '$out'" + [ -z "$(fm_busy_sources_for_harness codex)" ] \ + || fail "codex must trust no semantic source until one is verified" + pass "codex classifies unknown until a semantic source passes its verification gate" +} + +test_kimi_unverified_gate() { + local state gen out + state=$(new_state_dir kimi-gate) + gen=$("$EV" arm "$state" t1) + "$EV" apply "$state" t1 busy --gen "$gen" --source kimi-hook --event user-prompt-submit + out=$(fm_busy_classify tmux w1 kimi t1 "$state") + [ "$out" = "unknown kimi-unverified" ] || fail "unverified kimi must classify unknown, got '$out'" + out=$(fm_busy_classify tmux w1 kimi t1 "$state" '🌒 · thinking') + [ "$out" = "unknown kimi-unverified" ] || fail "kimi must not classify from footer text, got '$out'" + pass "standalone kimi classifies unknown until the live verification gate opens" +} + +# --- endpoint death and native fallbacks ---------------------------------------- + +test_dead_endpoint_overrides() { + local state gen out + state=$(new_state_dir dead) + gen=$("$EV" arm "$state" t1) + # shellcheck disable=SC2329 # invoked indirectly through fm_busy_classify_live + fm_backend_target_exists() { return 1; } + out=$(fm_busy_classify_live tmux w1 claude t1 "$state") + [ "$out" = "dead endpoint-gone" ] || fail "gone endpoint must classify dead, got '$out'" + # shellcheck disable=SC2329 # invoked indirectly through fm_busy_classify_live + fm_backend_target_exists() { return 0; } + out=$(fm_busy_classify_live tmux w1 claude t1 "$state") + [ "$out" = "busy fm-spawn" ] || fail "live endpoint must fall through to the record, got '$out'" + out=$(fm_busy_classify_live tmux '' claude t1 "$state") + [ "$out" = "unknown no-target" ] || fail "empty target must classify unknown, got '$out'" + unset -f fm_backend_target_exists + pass "endpoint death is the only process-level override and yields dead, never busy" +} + +test_herdr_native_busy_only() { + local state out + state=$(new_state_dir herdr-native) + # shellcheck disable=SC2329 # invoked indirectly through fm_busy_classify + fm_backend_busy_state() { printf '%s' "$FAKE_NATIVE"; } + FAKE_NATIVE=busy + out=$(fm_busy_classify herdr s:p claude t1 "$state") + [ "$out" = "busy herdr-native" ] || fail "native busy with no record must classify busy, got '$out'" + FAKE_NATIVE=idle + out=$(fm_busy_classify herdr s:p claude t1 "$state") + [ "$out" = "unknown missing" ] || fail "native idle must NOT classify idle, got '$out'" + # A valid record outranks the native verdict. + local gen + gen=$("$EV" arm "$state" t1) + "$EV" apply "$state" t1 idle --gen "$gen" --source claude-hook --event stop + FAKE_NATIVE=busy + out=$(fm_busy_classify herdr s:p claude t1 "$state") + [ "$out" = "idle claude-hook" ] || fail "the adapter record must outrank herdr's native verdict, got '$out'" + unset -f fm_backend_busy_state + pass "herdr's native verdict is trusted for busy only, and records outrank it" +} + +# The record parser runs inside sourcing callers (the watcher, the daemon, the +# crew-state reader), so it must not disturb their shell: no clobbered +# positional parameters and no changed glob setting. +test_record_read_leaves_caller_shell_intact() { + local state out + state=$(new_state_dir parser-isolation) + "$EV" arm "$state" t1 >/dev/null + out=$(bash -c ' + set -f + . "$1/bin/fm-busy-lib.sh" + set -- keepme second + fm_busy_record_read "$2" t1 >/dev/null + printf "%s|%s|%s" "$1" "$#" "$-" + ' _ "$ROOT" "$state") + case "$out" in + keepme\|2\|*f*) : ;; + *) fail "record parsing disturbed the caller's shell: $out" ;; + esac + # A glob-shaped field must survive parsing literally rather than expanding. + printf 'v1 gen=%s seq=1 state=busy source=* event=x ts=1\n' "$(cat "$state/t1.busy-gen")" \ + > "$state/t1.busy-state" + out=$(fm_busy_classify tmux w1 claude t1 "$state") + [ "$out" = "unknown malformed" ] || fail "a glob-shaped source must be rejected, not expanded, got '$out'" + pass "record parsing never clobbers the caller's positional parameters, glob setting, or fields" +} + +test_boolean_view_never_promotes_unknown() { + local state gen + state=$(new_state_dir boolean) + gen=$("$EV" arm "$state" t1) + fm_busy_is_busy tmux w1 claude t1 "$state" || fail "busy record must read busy" + "$EV" apply "$state" t1 idle --gen "$gen" --source claude-hook --event stop + if fm_busy_is_busy tmux w1 claude t1 "$state"; then + fail "idle record must not read busy" + fi + printf 'garbage\n' > "$state/t1.busy-state" + if fm_busy_is_busy tmux w1 claude t1 "$state"; then + fail "malformed record must not read busy" + fi + pass "the boolean view reports busy only on an exact busy verdict" +} + +test_arm_seeds_busy_spawn +test_apply_advances_seq_and_source +test_apply_current_gen_reset +test_apply_unarmed_refused +test_retire_serializes_and_rejects_stale_gen +test_retire_missing_sidecar_is_idempotent +test_stale_gen_event_rejected +test_stale_gen_record_unknown +test_missing_record_unknown_not_idle +test_malformed_record_unknown +test_record_without_sidecar_unknown +test_source_mismatch_cross_adapter +test_converted_adapters_ignore_footer_text +test_grok_regex_isolated +test_agy_pane_fallback_isolated_and_explicit +test_codex_unverified_gate +test_kimi_unverified_gate +test_dead_endpoint_overrides +test_herdr_native_busy_only +test_record_read_leaves_caller_shell_intact +test_boolean_view_never_promotes_unknown + +echo "all fm-busy-state tests passed" diff --git a/tests/fm-calm-pi-extension.test.sh b/tests/fm-calm-pi-extension.test.sh index b63661ad7a..f1109e787e 100755 --- a/tests/fm-calm-pi-extension.test.sh +++ b/tests/fm-calm-pi-extension.test.sh @@ -10,20 +10,22 @@ EXT="$ROOT/.pi/extensions/fm-calm.ts" ASSISTANT_LAYOUT="$ROOT/.pi/extensions/lib/fm-calm-assistant-layout.ts" OPERATIONAL_USER_LAYOUT="$ROOT/.pi/extensions/lib/fm-calm-operational-user-layout.ts" VISIBILITY="$ROOT/.pi/extensions/lib/fm-calm-visibility.ts" +WORKING_SHIP="$ROOT/.pi/extensions/lib/fm-calm-working-ship.ts" WATCH_EXT="$ROOT/.pi/extensions/fm-primary-pi-watch.ts" OPERATIONAL_INPUT="$ROOT/bin/fm-operational-input.sh" PI_OPERATIONAL_INPUT="$ROOT/.pi/extensions/lib/fm-operational-input.ts" PI_PACKAGE_DIR=${FM_PI_PACKAGE_DIR:-"$(npm root -g 2>/dev/null)/@earendil-works/pi-coding-agent"} TMUX_SOCKET="fm-calm-$$" TMUX_SESSION="fm-calm-e2e" -PI_COMPAT_VERSIONS="0.81.1 0.82.0" - -require_pi_compat_version() { +# Verified against Pi 0.81.1 and 0.82.0 (docs/calm-mode-feasibility.md). This is +# known-good evidence, not a support ceiling: the fixtures below run against whatever +# Pi is actually installed, and record_pi_version_evidence never rejects a newer +# version. The tracked presentation adapters probe the exact API they patch (see +# .pi/extensions/fm-calm.ts) instead of relying on version inference, so a version +# string is evidence for the record, not a gate. +record_pi_version_evidence() { local version=$1 context=$2 - case " $PI_COMPAT_VERSIONS " in - *" $version "*) return 0 ;; - *) fail "$context requires Pi $PI_COMPAT_VERSIONS, found $version" ;; - esac + [ -n "$version" ] || fail "$context could not determine the installed Pi version" } cleanup() { @@ -37,7 +39,10 @@ trap cleanup EXIT wait_for_text() { local file=$1 text=$2 i=0 while [ "$i" -lt 120 ]; do - tmux -L "$TMUX_SOCKET" capture-pane -p -t "$TMUX_SESSION" -S - >"$file" 2>/dev/null || true + # Include recent scrollback: expanding a long restored transcript can move + # the asserted tool output above the current viewport while the footer and + # editor remain visible. + tmux -L "$TMUX_SOCKET" capture-pane -p -t "$TMUX_SESSION" -S -600 >"$file" 2>/dev/null || true grep -Fq "$text" "$file" 2>/dev/null && return 0 sleep 0.05 i=$((i + 1)) @@ -66,64 +71,6 @@ find_chrome() { return 1 } -test_static_contract() { - local text assistant_layout operational_user_layout visibility watch operational - assert_present "$EXT" "tracked Pi calm extension is missing" - assert_present "$ASSISTANT_LAYOUT" "tracked Pi Calm assistant-layout adapter is missing" - assert_present "$OPERATIONAL_USER_LAYOUT" "tracked Pi Calm operational-user layout adapter is missing" - assert_present "$VISIBILITY" "tracked Pi calm visibility policy is missing" - text=$(cat "$EXT") - assistant_layout=$(cat "$ASSISTANT_LAYOUT") - operational_user_layout=$(cat "$OPERATIONAL_USER_LAYOUT") - visibility=$(cat "$VISIBILITY") - watch=$(cat "$WATCH_EXT") - operational=$(cat "$PI_OPERATIONAL_INPUT") - assert_contains "$text" 'pi.registerCommand("calm"' "Pi calm extension does not register /calm" - assert_contains "$text" 'pi.on("session_start"' "Pi calm extension does not restore presentation on every session start" - assert_contains "$text" 'loadCalmPreference()' "Pi calm extension does not restore the home-persistent toggle choice" - assert_contains "$text" 'persistCalmPreference(active)' "Pi calm extension does not persist the captain's toggle choice" - assert_not_contains "$text" 'setCalmPresentation(false)' "Pi calm extension still resets the toggle on session start" - assert_contains "$text" 'ctx.ui.setToolsExpanded(!expanded)' "Pi calm extension does not redraw existing custom entries" - assert_contains "$text" 'ctx.ui.setToolsExpanded(expanded)' "Pi calm extension does not restore Ctrl+O state after redraw" - assert_not_contains "$text" 'ctx.navigateTree' "Pi calm extension reconstructs the transcript and drops transient diagnostics" - assert_not_contains "$visibility" 'deliverFirstmateSyntheticInput' "Pi calm visibility policy can still replace operational input semantics" - assert_not_contains "$visibility" 'classifyFirstmateSyntheticInput' "Pi calm visibility policy still classifies operational input for interception" - assert_contains "$text" 'ctx.ui.setWorkingVisible(true)' "Pi calm extension does not preserve Pi's live working row" - assert_not_contains "$text" 'ctx.ui.setWorkingVisible(!active)' "Pi calm extension still hides Pi's live working row" - assert_contains "$text" 'ctx.ui.setHiddenThinkingLabel(active ? "" : undefined)' "Pi calm extension does not hide collapsed thinking labels" - assert_contains "$text" 'installCalmAssistantLayout()' "Pi Calm extension does not install its zero-height assistant layout" - assert_contains "$text" 'installCalmOperationalUserLayout()' "Pi Calm extension does not install its operational-user layout" - assert_contains "$assistant_layout" 'AssistantMessageComponent.prototype.updateContent' "Pi Calm assistant layout does not control the exported component presentation path" - assert_contains "$assistant_layout" 'block.type !== "thinking"' "Pi Calm assistant layout does not remove thinking from its presentation copy" - assert_contains "$operational_user_layout" 'InteractiveMode.prototype' "Pi Calm operational-user layout does not control the transcript owner" - assert_contains "$operational_user_layout" 'classifyFirstmateCurrentOperationalText(text)' "Pi Calm operational-user layout bypasses canonical current classification" - assert_contains "$operational_user_layout" 'text.includes("\u2063")' "Pi Calm operational-user layout spawns its classifier for ordinary captain rows" - assert_contains "$operational_user_layout" '"\u2063Supervisor escalate ("' "Pi Calm operational-user layout lost the narrow legacy marker" - assert_contains "$operational_user_layout" 'hidesOperationalInput()' "Pi Calm operational-user row does not use presentation-only hiding" - assert_not_contains "$operational_user_layout" 'FIRSTMATE_OP: ' "Pi Calm operational-user layout duplicates the canonical marker grammar" - assert_not_contains "$text" 'calm transcript' "Pi calm extension still adds a persistent Calm status row" - assert_not_contains "$text" 'pi.on("input"' "Pi calm extension still intercepts semantic input" - assert_not_contains "$text" 'sendMessage' "Pi calm extension still replaces user-role input with custom context" - assert_contains "$text" 'ctx.ui.onTerminalInput' "Pi calm extension does not scope export rendering to terminal submissions" - assert_contains "$text" 'getKeybindings().matches(data, "tui.input.submit")' "Pi calm export boundary ignores the active submit keybinding" - assert_contains "$text" 'input !== "/share"' "Pi calm export boundary does not cover /share" - assert_not_contains "$text" 'FIRSTMATE_PI_LAUNCH_BRIEF_ENV' "Pi calm presentation still depends on launch-input provenance" - assert_contains "$text" 'renderShell: "self"' "Pi calm extension cannot remove complete built-in tool shells" - assert_contains "$visibility" 'CALM_VISIBLE_CLASSES' "Pi calm policy does not centralize its visibility allowlist" - assert_contains "$operational" 'fm-operational-input.sh' "Pi adapter does not delegate to the canonical cross-language owner" - assert_not_contains "$visibility" 'FIRSTMATE WATCHER WAKE:' "current Calm classification still matches watcher payload prose" - assert_not_contains "$visibility" 'TURN WOULD END BLIND' "current Calm classification still matches turn-end payload prose" - # shellcheck disable=SC2016 # Backticks are literal prompt markup. - assert_not_contains "$visibility" 'Run `bin/fm-session-start.sh`' "current Calm classification still matches session-start payload prose" - assert_not_contains "$visibility" 'FIRSTMATE_OP: ' "current Calm classification duplicates the canonical marker grammar" - assert_contains "$watch" 'calmHides("assistant-tool-call")' "Firstmate watcher tool does not participate in Calm presentation" - assert_contains "$watch" 'renderShell: "self"' "Firstmate watcher tool cannot remove its complete shell" - for name in Read Bash Edit Write Grep Find Ls; do - assert_contains "$text" "create${name}ToolDefinition" "Pi calm extension does not wrap the $name built-in" - done - pass "Pi calm extension is presentation-only with one persisted visibility choice, no Calm status row, native working visibility, supported redraw controls, and the Firstmate watcher-tool integration" -} - test_home_resolution() { local fixture out status version if ! command -v node >/dev/null 2>&1 || ! command -v npm >/dev/null 2>&1; then @@ -135,7 +82,7 @@ test_home_resolution() { return 0 fi version=$(node -p "require('$PI_PACKAGE_DIR/package.json').version") - require_pi_compat_version "$version" "Pi calm compatibility assumptions" + record_pi_version_evidence "$version" "Pi calm compatibility assumptions" fixture="$TMP_ROOT/home-resolution" mkdir -p \ @@ -147,6 +94,7 @@ test_home_resolution() { cp "$ASSISTANT_LAYOUT" "$fixture/project/.pi/extensions/lib/fm-calm-assistant-layout.ts" cp "$OPERATIONAL_USER_LAYOUT" "$fixture/project/.pi/extensions/lib/fm-calm-operational-user-layout.ts" cp "$VISIBILITY" "$fixture/project/.pi/extensions/lib/fm-calm-visibility.ts" + cp "$WORKING_SHIP" "$fixture/project/.pi/extensions/lib/fm-calm-working-ship.ts" cp "$PI_OPERATIONAL_INPUT" "$fixture/project/.pi/extensions/lib/fm-operational-input.ts" ln -s "$PI_PACKAGE_DIR" "$fixture/project/node_modules/@earendil-works/pi-coding-agent" ln -s "$PI_PACKAGE_DIR/node_modules/@earendil-works/pi-tui" "$fixture/project/node_modules/@earendil-works/pi-tui" @@ -233,6 +181,175 @@ JS pass "Pi calm resolves its persistent home independently of Pi's launch directory" } +test_pi_compat_no_upper_bound() { + local version + for version in 0.83.0 0.90.0 1.0.0 2.3.4 0.82.1 10.20.30; do + record_pi_version_evidence "$version" "synthetic newer Pi" \ + || fail "record_pi_version_evidence rejected Pi $version solely for being newer than 0.82.0" + done + if (record_pi_version_evidence "" "malformed Pi version probe") 2>/dev/null; then + fail "record_pi_version_evidence accepted a missing/malformed Pi version" + fi + pass "Pi calm compatibility evidence never rejects a Pi version for being newer than 0.82.0, and still fails closed on a missing or malformed version" +} + +test_pi_compat_degraded_adapter() { + local fixture out status + if ! command -v node >/dev/null 2>&1 || ! command -v npm >/dev/null 2>&1; then + echo "skip: node or npm not found for Pi calm degraded-adapter test" + return 0 + fi + if [ ! -f "$PI_PACKAGE_DIR/package.json" ]; then + echo "skip: installed @earendil-works/pi-coding-agent package not found" + return 0 + fi + + fixture="$TMP_ROOT/degraded-adapter" + mkdir -p \ + "$fixture/project/.pi/extensions/lib" \ + "$fixture/project/node_modules/@earendil-works" + cp "$EXT" "$fixture/project/.pi/extensions/fm-calm.ts" + cp "$ASSISTANT_LAYOUT" "$fixture/project/.pi/extensions/lib/fm-calm-assistant-layout.ts" + cp "$OPERATIONAL_USER_LAYOUT" "$fixture/project/.pi/extensions/lib/fm-calm-operational-user-layout.ts" + cp "$VISIBILITY" "$fixture/project/.pi/extensions/lib/fm-calm-visibility.ts" + cp "$WORKING_SHIP" "$fixture/project/.pi/extensions/lib/fm-calm-working-ship.ts" + cp "$PI_OPERATIONAL_INPUT" "$fixture/project/.pi/extensions/lib/fm-operational-input.ts" + ln -s "$PI_PACKAGE_DIR" "$fixture/project/node_modules/@earendil-works/pi-coding-agent" + ln -s "$PI_PACKAGE_DIR/node_modules/@earendil-works/pi-tui" "$fixture/project/node_modules/@earendil-works/pi-tui" + ln -s "$PI_PACKAGE_DIR/node_modules/typebox" "$fixture/project/node_modules/typebox" + printf '%s\n' '{"type":"module"}' >"$fixture/project/package.json" + + out=$(cd "$fixture/project" && \ + EXT="$fixture/project/.pi/extensions/fm-calm.ts" \ + PI_PACKAGE_DIR="$PI_PACKAGE_DIR" \ + node --input-type=module 2>&1 <<'JS' +import { pathToFileURL } from "node:url"; + +const packageRoot = process.env.PI_PACKAGE_DIR; +const { AssistantMessageComponent } = await import( + pathToFileURL(`${packageRoot}/dist/modes/interactive/components/assistant-message.js`).href +); +const originalUpdateContent = AssistantMessageComponent.prototype.updateContent; +if (typeof originalUpdateContent !== "function") { + throw new Error( + "fixture precondition failed: installed Pi lacks AssistantMessageComponent.prototype.updateContent", + ); +} +delete AssistantMessageComponent.prototype.updateContent; + +const diagnostics = []; +const originalConsoleError = console.error; +console.error = (...args) => diagnostics.push(args.join(" ")); + +let calmCommand; +const handlers = new Map(); +const pi = { + events: { emit() {}, on() {} }, + on(event, handler) { + handlers.set(event, handler); + }, + registerCommand(name, command) { + if (name === "calm") calmCommand = command; + }, + registerEntryRenderer() {}, + registerTool() {}, +}; + +let threw = false; +try { + const extension = await import(`${pathToFileURL(process.env.EXT).href}?degraded=${Date.now()}`); + extension.default(pi); +} catch { + threw = true; +} +console.error = originalConsoleError; + +if (threw) { + throw new Error( + "a missing presentation API crashed the whole Calm extension instead of degrading just that adapter", + ); +} +if (!calmCommand || !handlers.has("session_start")) { + throw new Error( + "Calm command/session lifecycle did not register when only one presentation adapter was unavailable", + ); +} +if (typeof AssistantMessageComponent.prototype.updateContent !== "undefined") { + throw new Error( + "the degraded adapter path patched updateContent anyway despite the missing API, which would claim false success", + ); +} +const sawClearSkipReason = diagnostics.some( + (line) => line.includes("collapsed-thinking") && /unavailable|skip/i.test(line), +); +if (!sawClearSkipReason) { + throw new Error( + `missing a clear skip reason for the degraded collapsed-thinking adapter; saw: ${JSON.stringify(diagnostics)}`, + ); +} + +AssistantMessageComponent.prototype.updateContent = originalUpdateContent; +JS +) + status=$? + [ "$status" -eq 0 ] || fail "Pi calm degraded-adapter path failed: $out" + [ -z "$out" ] || fail "Pi calm degraded-adapter test printed output: $out" + pass "a missing collapsed-thinking presentation API degrades only that Calm adapter with a clear skip reason, while the rest of Calm still registers" +} + +test_pi_compat_missing_adapter_exports() { + local fixture out status + if ! command -v node >/dev/null 2>&1; then + echo "skip: node not found for Pi calm missing-adapter-export test" + return 0 + fi + + fixture="$TMP_ROOT/missing-adapter-exports" + mkdir -p \ + "$fixture/project/.pi/extensions/lib" \ + "$fixture/project/node_modules/@earendil-works/pi-coding-agent" + cp "$ASSISTANT_LAYOUT" "$fixture/project/.pi/extensions/lib/fm-calm-assistant-layout.ts" + cp "$OPERATIONAL_USER_LAYOUT" "$fixture/project/.pi/extensions/lib/fm-calm-operational-user-layout.ts" + cp "$VISIBILITY" "$fixture/project/.pi/extensions/lib/fm-calm-visibility.ts" + cp "$WORKING_SHIP" "$fixture/project/.pi/extensions/lib/fm-calm-working-ship.ts" + cp "$PI_OPERATIONAL_INPUT" "$fixture/project/.pi/extensions/lib/fm-operational-input.ts" + printf '%s\n' '{"type":"module"}' >"$fixture/project/package.json" + printf '%s\n' \ + '{"name":"@earendil-works/pi-coding-agent","type":"module","exports":"./index.js"}' \ + >"$fixture/project/node_modules/@earendil-works/pi-coding-agent/package.json" + printf '%s\n' \ + 'export function getMarkdownTheme() { return {}; }' \ + 'export class UserMessageComponent {}' \ + >"$fixture/project/node_modules/@earendil-works/pi-coding-agent/index.js" + + out=$(cd "$fixture/project" && node --input-type=module 2>&1 <<'JS' +const assistant = await import("./.pi/extensions/lib/fm-calm-assistant-layout.ts"); +const operational = await import("./.pi/extensions/lib/fm-calm-operational-user-layout.ts"); + +for (const [name, install, expected] of [ + ["collapsed-thinking", assistant.installCalmAssistantLayout, "AssistantMessageComponent"], + ["operational-user-row", operational.installCalmOperationalUserLayout, "InteractiveMode"], +]) { + let reason; + try { + install(); + } catch (error) { + reason = error instanceof Error ? error.message : String(error); + } + if (!reason?.includes(expected)) { + throw new Error( + `${name} adapter did not load and report its missing runtime export: ${String(reason)}`, + ); + } +} +JS +) + status=$? + [ "$status" -eq 0 ] || fail "Pi calm missing-adapter-export path failed: $out" + [ -z "$out" ] || fail "Pi calm missing-adapter-export test printed output: $out" + pass "missing Pi presentation class exports reach the independent adapter degradation path" +} + test_rendering_and_session_lifecycle() { local fixture out status version if ! command -v node >/dev/null 2>&1 || ! command -v npm >/dev/null 2>&1; then @@ -244,7 +361,7 @@ test_rendering_and_session_lifecycle() { return 0 fi version=$(node -p "require('$PI_PACKAGE_DIR/package.json').version") - require_pi_compat_version "$version" "Pi calm compatibility assumptions" + record_pi_version_evidence "$version" "Pi calm compatibility assumptions" fixture="$TMP_ROOT/renderer" mkdir -p "$fixture/home" "$fixture/lib" "$fixture/node_modules/@earendil-works" @@ -252,6 +369,7 @@ test_rendering_and_session_lifecycle() { cp "$ASSISTANT_LAYOUT" "$fixture/lib/fm-calm-assistant-layout.ts" cp "$OPERATIONAL_USER_LAYOUT" "$fixture/lib/fm-calm-operational-user-layout.ts" cp "$VISIBILITY" "$fixture/lib/fm-calm-visibility.ts" + cp "$WORKING_SHIP" "$fixture/lib/fm-calm-working-ship.ts" cp "$ROOT/.pi/extensions/lib/fm-operational-input.ts" "$fixture/lib/fm-operational-input.ts" cp "$WATCH_EXT" "$fixture/fm-primary-pi-watch.ts" ln -s "$PI_PACKAGE_DIR" "$fixture/node_modules/@earendil-works/pi-coding-agent" @@ -885,7 +1003,7 @@ JS status=$? [ "$status" -eq 0 ] || fail "Pi calm renderer and lifecycle contract failed: $out" [ -z "$out" ] || fail "Pi calm renderer test printed output: $out" - pass "Pi calm centralizes transcript visibility, preserves execution/export data, keeps native working visible, and persists its choice across session starts" + pass "Pi calm centralizes transcript visibility, preserves execution/export data, keeps Pi's stock working row visible while no run is active, and persists its choice across session starts" } test_operational_followup_turn_e2e() { @@ -895,7 +1013,7 @@ test_operational_followup_turn_e2e() { return 0 fi version=$(pi --version 2>/dev/null || true) - require_pi_compat_version "$version" "Pi operational follow-up E2E" + record_pi_version_evidence "$version" "Pi operational follow-up E2E" project="$TMP_ROOT/followup-project" home="$TMP_ROOT/followup-home" @@ -907,6 +1025,7 @@ test_operational_followup_turn_e2e() { cp "$ASSISTANT_LAYOUT" "$project/.pi/extensions/lib/fm-calm-assistant-layout.ts" cp "$OPERATIONAL_USER_LAYOUT" "$project/.pi/extensions/lib/fm-calm-operational-user-layout.ts" cp "$VISIBILITY" "$project/.pi/extensions/lib/fm-calm-visibility.ts" + cp "$WORKING_SHIP" "$project/.pi/extensions/lib/fm-calm-working-ship.ts" cp "$PI_OPERATIONAL_INPUT" "$project/.pi/extensions/lib/fm-operational-input.ts" printf '%s\n' '{"followUpMode":"all"}' >"$config/settings.json" @@ -1248,7 +1367,7 @@ test_hidden_block_geometry_e2e() { return 0 fi version=$(pi --version 2>/dev/null || true) - require_pi_compat_version "$version" "Pi Calm hidden-block geometry E2E" + record_pi_version_evidence "$version" "Pi Calm hidden-block geometry E2E" project="$TMP_ROOT/geometry-project" home="$TMP_ROOT/geometry-home" @@ -1269,6 +1388,7 @@ test_hidden_block_geometry_e2e() { cp "$ASSISTANT_LAYOUT" "$project/.pi/extensions/lib/fm-calm-assistant-layout.ts" cp "$OPERATIONAL_USER_LAYOUT" "$project/.pi/extensions/lib/fm-calm-operational-user-layout.ts" cp "$VISIBILITY" "$project/.pi/extensions/lib/fm-calm-visibility.ts" + cp "$WORKING_SHIP" "$project/.pi/extensions/lib/fm-calm-working-ship.ts" cp "$PI_OPERATIONAL_INPUT" "$project/.pi/extensions/lib/fm-operational-input.ts" printf '%s\n' on >"$home/config/calm" printf '%s\n' '{"hideThinkingBlock":true,"terminal":{"clearOnShrink":false}}' >"$config/settings.json" @@ -1481,14 +1601,877 @@ TS pass "Pi Calm native /skill:ahoy geometry keeps every collapsed thinking and tool block at zero height while preserving expansion, history, restart, and Calm-off rendering" } +test_working_ship_geometry_and_lifecycle() { + local fixture out status version + if ! command -v node >/dev/null 2>&1 || ! command -v npm >/dev/null 2>&1; then + echo "skip: node or npm not found for Pi Calm working-ship test" + return 0 + fi + if [ ! -f "$PI_PACKAGE_DIR/package.json" ]; then + echo "skip: installed @earendil-works/pi-coding-agent package not found" + return 0 + fi + version=$(node -p "require('$PI_PACKAGE_DIR/package.json').version") + record_pi_version_evidence "$version" "Pi Calm working-ship assumptions" + + fixture="$TMP_ROOT/working-ship" + mkdir -p "$fixture/home" "$fixture/lib" "$fixture/node_modules/@earendil-works" + cp "$EXT" "$fixture/fm-calm.ts" + cp "$ASSISTANT_LAYOUT" "$fixture/lib/fm-calm-assistant-layout.ts" + cp "$OPERATIONAL_USER_LAYOUT" "$fixture/lib/fm-calm-operational-user-layout.ts" + cp "$VISIBILITY" "$fixture/lib/fm-calm-visibility.ts" + cp "$WORKING_SHIP" "$fixture/lib/fm-calm-working-ship.ts" + cp "$PI_OPERATIONAL_INPUT" "$fixture/lib/fm-operational-input.ts" + ln -s "$PI_PACKAGE_DIR" "$fixture/node_modules/@earendil-works/pi-coding-agent" + ln -s "$PI_PACKAGE_DIR/node_modules/@earendil-works/pi-tui" "$fixture/node_modules/@earendil-works/pi-tui" + ln -s "$PI_PACKAGE_DIR/node_modules/typebox" "$fixture/node_modules/typebox" + printf '%s\n' '{"type":"module"}' >"$fixture/package.json" + + out=$(cd "$fixture" && EXT="$fixture/fm-calm.ts" FM_HOME="$fixture/home" PI_PACKAGE_DIR="$PI_PACKAGE_DIR" node --input-type=module 2>&1 <<'JS' +import { pathToFileURL } from "node:url"; + +const packageRoot = process.env.PI_PACKAGE_DIR; +const [{ initTheme, theme }, { visibleWidth, setCapabilities }] = await Promise.all([ + import(pathToFileURL(`${packageRoot}/dist/modes/interactive/theme/theme.js`).href), + import(pathToFileURL(`${packageRoot}/node_modules/@earendil-works/pi-tui/dist/index.js`).href), +]); +initTheme("dark"); +setCapabilities({ images: null, trueColor: true, hyperlinks: false }); + +const ship = await import( + `${pathToFileURL(`${process.cwd()}/lib/fm-calm-working-ship.ts`).href}?ship=${Date.now()}` +); +const { + CALM_WORKING_SHIP_WIDGET_KEY, + CALM_WORKING_SHIP_TICK_MS, + CALM_WORKING_SHIP_TICKS_PER_MOVE, + createCalmWorkingShipAnimation, + createCalmWorkingShipWidget, +} = ship; + +const ESC = "\u001b"; +const BLUE = `${ESC}[34m`; +const YELLOW = `${ESC}[33m`; +const RESET = `${ESC}[39m`; +const strip = (text) => text.replace(new RegExp(`${ESC}\\[[0-9;]*m`, "g"), ""); +const check = (condition, message) => { + if (!condition) throw new Error(message); +}; +const sailOf = (frame) => { + const row = strip(frame[0]); + if (row.includes("<|")) return "<|"; + if (row.includes("|>")) return "|>"; + return "none"; +}; + +// --- Calm cadence: the boat is materially slower than the water ------------------ +{ + // The pre-revision boat moved one column every 140ms. The revised boat must be + // plainly slower in real use while the water keeps rippling between its steps. + const msPerColumn = CALM_WORKING_SHIP_TICK_MS * CALM_WORKING_SHIP_TICKS_PER_MOVE; + check(msPerColumn >= 700, `boat cadence ${msPerColumn}ms per column is not materially slower`); + check( + CALM_WORKING_SHIP_TICKS_PER_MOVE >= 2, + "the water cadence is not independent of and faster than the boat cadence", + ); + check( + CALM_WORKING_SHIP_TICK_MS < msPerColumn, + "the water does not animate faster than the boat moves", + ); +} + +// --- Water phases loop independently while the boat stays put -------------------- +{ + const width = 40; + const animation = createCalmWorkingShipAnimation(); + animation.render(width); + const startPosition = animation.position(); + const waterRows = new Set(); + const phases = new Set(); + for (let step = 0; step < CALM_WORKING_SHIP_TICKS_PER_MOVE - 1; step += 1) { + animation.tick(); + check( + animation.position() === startPosition, + `the boat moved on tick ${step + 1} instead of waiting for its own cadence`, + ); + waterRows.add(strip(animation.render(width)[1])); + phases.add(animation.waterPhase()); + } + check(waterRows.size > 1, "the water did not animate while the boat was stationary"); + check(phases.size > 1, "the water phase did not advance between boat movements"); + // The boat then moves on its own cadence tick. + animation.tick(); + check( + animation.position() !== startPosition, + "the boat never moved on its own cadence tick", + ); + // Water motion alone must not change the hull column. + const beforeHull = strip(animation.render(width)[1]).indexOf("\\__/"); + animation.tick(); + const afterHull = strip(animation.render(width)[1]).indexOf("\\__/"); + check(beforeHull === afterHull, "advancing only the water appeared to move the boat"); +} + +// --- Water phases are bounded, fixed-cell, and never change geometry ------------- +{ + const width = 30; + const animation = createCalmWorkingShipAnimation(); + const seenPhases = new Set(); + for (let step = 0; step < 64; step += 1) { + const frame = animation.render(width); + seenPhases.add(animation.waterPhase()); + check(frame.length === 2, `water phase ${animation.waterPhase()} changed the row count`); + check( + visibleWidth(frame[1]) === width, + `water phase ${animation.waterPhase()} changed the visible width`, + ); + animation.tick(); + } + check(seenPhases.size > 1 && seenPhases.size <= 8, `water phase set is not bounded: ${seenPhases.size}`); +} + +// --- Standard ANSI colors, with resets that prevent bleed ------------------------ +{ + const width = 24; + const animation = createCalmWorkingShipAnimation(); + for (let step = 0; step < 12; step += 1) { + const [sailRow, waterRow] = animation.render(width); + + // Standard codes only: no bright variants, no 256-color, no RGB. + for (const row of [sailRow, waterRow]) { + const codes = row.match(new RegExp(`${ESC}\\[[0-9;]*m`, "g")) ?? []; + for (const code of codes) { + check( + code === BLUE || code === YELLOW || code === RESET, + `non-standard ANSI escape ${JSON.stringify(code)} in ${JSON.stringify(row)}`, + ); + } + check(codes.length > 0, "a rendered row carried no color at all"); + // Every colored run is closed, so nothing bleeds into padding or later frames. + check( + codes.filter((c) => c !== RESET).length === codes.filter((c) => c === RESET).length, + `unbalanced color/reset pairs in ${JSON.stringify(row)}`, + ); + check(codes[codes.length - 1] === RESET, `row does not end color-reset: ${JSON.stringify(row)}`); + } + + // Sail-row padding must be plain spaces outside any color run. + const leading = sailRow.slice(0, sailRow.indexOf(ESC)); + check(/^ *$/.test(leading), `sail row padding was colored: ${JSON.stringify(leading)}`); + + // The complete boat is yellow; every water cell is blue. + for (const piece of [`${YELLOW}<|${RESET}`, `${YELLOW}|>${RESET}`]) { + if (sailRow.includes(piece.slice(0, -RESET.length))) { + check(sailRow.includes(piece), `sail was not a closed yellow run: ${JSON.stringify(sailRow)}`); + } + } + check( + waterRow.includes(`${YELLOW}\\__/${RESET}`), + `hull was not a closed yellow run: ${JSON.stringify(waterRow)}`, + ); + for (const run of waterRow.split(YELLOW)) { + const blueRuns = run.split(BLUE).slice(1); + for (const blueRun of blueRuns) { + const cells = blueRun.slice(0, blueRun.indexOf(RESET)); + check(cells.length > 0, "an empty blue run emitted a bare color escape"); + check( + /^[~-]+$/.test(cells), + `blue run contained a non-water cell: ${JSON.stringify(cells)}`, + ); + } + } + animation.tick(); + } +} + +// --- ANSI-stripped visible width is exact at every width and phase --------------- +for (let width = 1; width <= 120; width += 1) { + const animation = createCalmWorkingShipAnimation(); + animation.render(width); + for (let step = 0; step <= width + 8; step += 1) { + const frame = animation.render(width); + const expectedRows = width >= 4 ? 2 : 1; + check(frame.length === expectedRows, `width ${width} rendered ${frame.length} rows`); + for (const line of frame) { + check( + visibleWidth(line) <= width, + `width ${width} rendered a ${visibleWidth(line)}-cell line and would wrap`, + ); + check( + visibleWidth(line) === strip(line).length, + `width ${width} let ANSI bytes affect the measured geometry`, + ); + } + // The water row always fills the complete usable width. + const waterRow = frame[frame.length - 1]; + check( + visibleWidth(waterRow) === width, + `width ${width} water row was ${visibleWidth(waterRow)} cells instead of full width`, + ); + animation.tick(); + } +} + +// --- Directional sail and exact bounce, including tiny spans --------------------- +for (const width of [40, 16, 8, 6, 5, 4, 3, 2]) { + const animation = createCalmWorkingShipAnimation(); + animation.render(width); + const span = width >= 4 ? width - 4 : Math.max(0, width - 2); + const frames = []; + for (let step = 0; step < span * CALM_WORKING_SHIP_TICKS_PER_MOVE * 3 + 16; step += 1) { + const frame = animation.render(width); + frames.push({ position: animation.position(), sail: sailOf(frame) }); + animation.tick(); + } + for (const frame of frames) { + check( + frame.position >= 0 && frame.position <= span, + `width ${width} left the track at column ${frame.position}`, + ); + } + if (width >= 2) { + // Every frame must already show the heading it is about to travel, so no frame + // at or after a reversal shows the old sail. + for (let index = 1; index < frames.length; index += 1) { + const previous = frames[index - 1]; + const current = frames[index]; + if (current.position > previous.position) { + check( + previous.sail === "<|", + `width ${width} moved right showing ${previous.sail} at column ${previous.position}`, + ); + } + if (current.position < previous.position) { + check( + previous.sail === "|>", + `width ${width} moved left showing ${previous.sail} at column ${previous.position}`, + ); + } + } + } + if (span > 0) { + const sails = new Set(frames.map((frame) => frame.sail)); + check(sails.has("<|") && sails.has("|>"), `width ${width} never showed both headings`); + const positions = frames.map((frame) => frame.position); + check(Math.min(...positions) === 0, `width ${width} never reached the left edge`); + check(Math.max(...positions) === span, `width ${width} never reached the right edge`); + // Both reversals must be covered. + let rightToLeft = false; + let leftToRight = false; + for (let index = 1; index < frames.length; index += 1) { + if (frames[index - 1].sail === "<|" && frames[index].sail === "|>") rightToLeft = true; + if (frames[index - 1].sail === "|>" && frames[index].sail === "<|") leftToRight = true; + } + check(rightToLeft, `width ${width} never reversed from right to left`); + check(leftToRight, `width ${width} never reversed from left to right`); + } +} + +// --- Shrink and grow resize clamping ---------------------------------------------- +{ + const animation = createCalmWorkingShipAnimation(); + animation.render(80); + while (animation.position() < 76) animation.tick(); + check(animation.position() === 76, `boat did not reach the wide right edge: ${animation.position()}`); + + const shrunk = animation.render(20); + check(animation.position() === 16, `shrink did not clamp the track immediately: ${animation.position()}`); + check(visibleWidth(shrunk[1]) === 20, `shrunk water row was ${visibleWidth(shrunk[1])} cells instead of 20`); + check(visibleWidth(shrunk[0]) <= 20, "shrunk sail row would wrap"); + check(sailOf(shrunk) === "|>", "the boat did not turn around after being clamped to the right edge"); + + for (let step = 0; step < CALM_WORKING_SHIP_TICKS_PER_MOVE; step += 1) animation.tick(); + const afterShrink = animation.render(20); + check(animation.position() < 16, "the boat stalled at the edge after a shrink"); + check(visibleWidth(afterShrink[1]) === 20, "motion after a shrink broke the water row width"); + + const grown = animation.render(60); + check(visibleWidth(grown[1]) === 60, `grown water row was ${visibleWidth(grown[1])} cells`); + for (let step = 0; step < CALM_WORKING_SHIP_TICKS_PER_MOVE; step += 1) animation.tick(); + const afterGrow = animation.render(60); + check( + animation.position() >= 0 && animation.position() <= 56, + `motion left the grown track: ${animation.position()}`, + ); + check(visibleWidth(afterGrow[1]) === 60, "motion after a grow broke the water row width"); +} + +// --- Deterministic narrow fallbacks ------------------------------------------------ +{ + const animation = createCalmWorkingShipAnimation(); + check(JSON.stringify(animation.render(0)) === "[]", "zero width rendered a line"); + for (const width of [1, 2, 3]) { + const fallback = createCalmWorkingShipAnimation(); + for (let step = 0; step < 12; step += 1) { + const frame = fallback.render(width); + check(frame.length === 1, `width ${width} fallback was not a single row`); + check(visibleWidth(frame[0]) === width, `width ${width} fallback was not exactly ${width} cells`); + const bare = strip(frame[0]); + if (width === 1) { + check(/^[~-]$/.test(bare), `width 1 fallback was not a single water cell: ${bare}`); + } else { + check( + bare.includes("<|") || bare.includes("|>"), + `width ${width} fallback lost the sail: ${bare}`, + ); + } + fallback.tick(); + } + } +} + +// --- Freeze/resume continuity on one shared animation instance --------------------- +// Hiding the working presentation must freeze column and direction. The next widget +// bound to the same animation resumes exactly there; hidden wall time must not jump. +{ + const animation = createCalmWorkingShipAnimation(); + const tui = { requestRender() {} }; + animation.render(40); + for (let step = 0; step < CALM_WORKING_SHIP_TICKS_PER_MOVE * 7; step += 1) animation.tick(); + animation.render(40); + const frozenColumn = animation.position(); + const frozenDirection = animation.direction(); + const frozenPhase = animation.waterPhase(); + check(frozenColumn > 0, `continuity setup never left the left edge: ${frozenColumn}`); + + const first = createCalmWorkingShipWidget(tui, animation); + check(first.render(40) && animation.position() === frozenColumn, "binding a widget moved the frozen boat"); + first.dispose(); + // Dispose freezes; further wall time without ticks must not change logical state. + check(animation.position() === frozenColumn, "dispose changed the frozen column"); + check(animation.direction() === frozenDirection, "dispose changed the frozen direction"); + check(animation.waterPhase() === frozenPhase, "dispose changed the frozen water phase"); + + const resumed = createCalmWorkingShipWidget(tui, animation); + const firstFrame = resumed.render(40); + check( + animation.position() === frozenColumn && animation.direction() === frozenDirection, + `resume first frame left frozen state: col=${animation.position()} dir=${animation.direction()}`, + ); + check(sailOf(firstFrame) === (frozenDirection >= 0 ? "<|" : "|>"), "resume first frame lost sail heading"); + check(animation.waterPhase() === frozenPhase, "resume advanced water phase without a tick"); + // After resume, motion continues from the frozen state rather than restarting. + for (let step = 0; step < CALM_WORKING_SHIP_TICKS_PER_MOVE; step += 1) animation.tick(); + check( + animation.position() === frozenColumn + frozenDirection, + `post-resume motion did not continue from frozen column: ${animation.position()}`, + ); + resumed.dispose(); + + // Hidden resize clamps without needing a live widget, and preserves a valid heading. + animation.render(80); + while (animation.position() < 76) animation.tick(); + animation.render(80); + check(animation.position() === 76 && animation.direction() === -1, "endpoint setup failed before hidden resize"); + const beforeHiddenResize = { column: animation.position(), direction: animation.direction(), phase: animation.waterPhase() }; + animation.clampToWidth(20); + check(animation.position() === 16, `hidden shrink did not clamp: ${animation.position()}`); + check(animation.direction() === -1, "hidden shrink lost the leftward heading at the right edge"); + check(animation.waterPhase() === beforeHiddenResize.phase, "hidden clamp advanced water phase"); + // Growing while hidden must not invent motion either. + animation.clampToWidth(60); + check(animation.position() === 16, `hidden grow moved the boat: ${animation.position()}`); + check(animation.direction() === -1, "hidden grow changed direction without cause"); + + // Endpoint and bounce continuity: pause immediately before, at, and after each edge. + for (const scenario of [ + { label: "before-right", setup(anim) { + anim.reset(); anim.render(12); + while (anim.position() < 7) anim.tick(); + check(anim.position() === 7 && anim.direction() === 1, "before-right setup"); + }}, + { label: "at-right", setup(anim) { + anim.reset(); anim.render(12); + while (anim.position() < 8) anim.tick(); + check(anim.position() === 8 && anim.direction() === -1, "at-right setup"); + }}, + { label: "after-right", setup(anim) { + anim.reset(); anim.render(12); + while (anim.position() < 8) anim.tick(); + for (let step = 0; step < CALM_WORKING_SHIP_TICKS_PER_MOVE; step += 1) anim.tick(); + check(anim.position() === 7 && anim.direction() === -1, "after-right setup"); + }}, + { label: "before-left", setup(anim) { + anim.reset(); anim.render(12); + while (anim.position() < 8) anim.tick(); + while (!(anim.position() === 1 && anim.direction() === -1)) anim.tick(); + }}, + { label: "at-left", setup(anim) { + anim.reset(); anim.render(12); + while (anim.position() < 8) anim.tick(); + while (!(anim.position() === 0 && anim.direction() === 1)) anim.tick(); + }}, + { label: "after-left", setup(anim) { + anim.reset(); anim.render(12); + while (anim.position() < 8) anim.tick(); + while (!(anim.position() === 0 && anim.direction() === 1)) anim.tick(); + for (let step = 0; step < CALM_WORKING_SHIP_TICKS_PER_MOVE; step += 1) anim.tick(); + check(anim.position() === 1 && anim.direction() === 1, "after-left setup"); + }}, + ]) { + const edge = createCalmWorkingShipAnimation(); + scenario.setup(edge); + edge.render(12); + const frozen = { column: edge.position(), direction: edge.direction(), phase: edge.waterPhase() }; + const paused = createCalmWorkingShipWidget(tui, edge); + paused.dispose(); + const again = createCalmWorkingShipWidget(tui, edge); + again.render(12); + check( + edge.position() === frozen.column && edge.direction() === frozen.direction && edge.waterPhase() === frozen.phase, + `${scenario.label} resume changed frozen edge state`, + ); + for (let step = 0; step < CALM_WORKING_SHIP_TICKS_PER_MOVE; step += 1) edge.tick(); + const expectedColumn = Math.min(8, Math.max(0, frozen.column + frozen.direction)); + let expectedDirection = frozen.direction; + if (expectedColumn >= 8) expectedDirection = -1; + else if (expectedColumn <= 0) expectedDirection = 1; + check( + edge.position() === expectedColumn && edge.direction() === expectedDirection, + `${scenario.label} post-resume bounce drifted: col=${edge.position()} dir=${edge.direction()}`, + ); + again.dispose(); + } + + // reset() returns a genuine fresh-session initial state. + animation.reset(); + check( + animation.position() === 0 && animation.direction() === 1 && animation.waterPhase() === 0, + "reset() did not restore the normal initial boat state", + ); + animation.render(40); + check(sailOf(animation.render(40)) === "<|", "reset() first frame was not the initial rightward sail"); + + // Two controller instances never share motion state. + const left = createCalmWorkingShipAnimation(); + const right = createCalmWorkingShipAnimation(); + left.render(40); + right.render(40); + for (let step = 0; step < CALM_WORKING_SHIP_TICKS_PER_MOVE * 3; step += 1) left.tick(); + check(left.position() === 3 && right.position() === 0, "separate animations leaked motion state"); +} + +{ + const realSetInterval = globalThis.setInterval; + const realClearInterval = globalThis.clearInterval; + const callbacks = []; + const handles = new Set(); + globalThis.setInterval = (callback) => { + callbacks.push(callback); + const handle = { unref() {} }; + handles.add(handle); + return handle; + }; + globalThis.clearInterval = (handle) => { + handles.delete(handle); + }; + + try { + const tui = { renderRequests: 0, requestRender() { this.renderRequests += 1; } }; + const animation = createCalmWorkingShipAnimation(); + const first = createCalmWorkingShipWidget(tui, animation); + first.render(40); + callbacks[callbacks.length - 1](); + callbacks[callbacks.length - 1](); + check(tui.renderRequests === 2, "unpainted timer ticks did not request renders"); + first.dispose(); + check(handles.size === 0, "disposing the unpainted widget left its timer scheduled"); + check( + animation.position() === 0 && animation.direction() === 1 && animation.waterPhase() === 0, + "dispose retained state from unpainted timer ticks", + ); + + const resumed = createCalmWorkingShipWidget(tui, animation); + resumed.render(40); + for (let step = 0; step < CALM_WORKING_SHIP_TICKS_PER_MOVE; step += 1) { + callbacks[callbacks.length - 1](); + } + resumed.render(40); + check(animation.position() === 1, "unpainted ticks leaked into the resumed cadence"); + check(animation.waterPhase() === 0, "resumed cadence did not restore the rendered water phase"); + resumed.dispose(); + + const committed = createCalmWorkingShipAnimation(); + const progressing = createCalmWorkingShipWidget(tui, committed); + progressing.render(40); + callbacks[callbacks.length - 1](); + progressing.render(40); + const renderedPhase = committed.waterPhase(); + callbacks[callbacks.length - 1](); + progressing.dispose(); + check(committed.position() === 0, "dispose changed the committed column after an unpainted tick"); + check(committed.waterPhase() === renderedPhase, "dispose changed the committed phase after an unpainted tick"); + + const committedResume = createCalmWorkingShipWidget(tui, committed); + committedResume.render(40); + for (let step = 0; step < CALM_WORKING_SHIP_TICKS_PER_MOVE - 2; step += 1) { + callbacks[callbacks.length - 1](); + } + check(committed.position() === 0, "serviced render did not preserve the committed cadence"); + callbacks[callbacks.length - 1](); + committedResume.render(40); + check(committed.position() === 1, "serviced render did not commit progress for the next cadence"); + committedResume.dispose(); + + const boundaryCases = [ + [7, 1], [8, -1], [7, -1], [1, -1], [0, 1], [1, 1], + ]; + for (const [targetPosition, targetDirection] of boundaryCases) { + const edge = createCalmWorkingShipAnimation(); + edge.render(12); + let reached = false; + for (let step = 0; step < 160; step += 1) { + if (edge.position() === targetPosition && edge.direction() === targetDirection) { + edge.render(12); + reached = true; + break; + } + edge.tick(); + edge.render(12); + } + check(reached, `could not prepare bounce state ${targetPosition}/${targetDirection}`); + const before = { position: edge.position(), direction: edge.direction(), phase: edge.waterPhase() }; + const paused = createCalmWorkingShipWidget(tui, edge); + paused.render(12); + for (let step = 0; step < CALM_WORKING_SHIP_TICKS_PER_MOVE; step += 1) { + callbacks[callbacks.length - 1](); + } + paused.dispose(); + check( + edge.position() === before.position && + edge.direction() === before.direction && + edge.waterPhase() === before.phase, + `unpainted bounce tick escaped ${targetPosition}/${targetDirection}`, + ); + const resumedEdge = createCalmWorkingShipWidget(tui, edge); + resumedEdge.render(12); + check( + edge.position() === before.position && edge.direction() === before.direction, + `bounce state ${targetPosition}/${targetDirection} changed on resume`, + ); + resumedEdge.dispose(); + } + } finally { + globalThis.setInterval = realSetInterval; + globalThis.clearInterval = realClearInterval; + } +} + +// --- Lifecycle through the Calm extension's registered handlers -------------------- +let liveTimers = 0; +const realSetInterval = globalThis.setInterval; +const realClearInterval = globalThis.clearInterval; +globalThis.setInterval = (...args) => { + liveTimers += 1; + return realSetInterval(...args); +}; +globalThis.clearInterval = (timer) => { + if (timer !== undefined) liveTimers -= 1; + return realClearInterval(timer); +}; + +const sessionWrites = []; +const handlers = new Map(); +let calmCommand; +const pi = { + events: { emit() {}, on() {} }, + on(event, handler) { + const existing = handlers.get(event) ?? []; + existing.push(handler); + handlers.set(event, existing); + }, + registerCommand(name, command) { + if (name === "calm") calmCommand = command; + }, + registerEntryRenderer() {}, + registerTool() {}, + appendEntry: (...args) => sessionWrites.push(["appendEntry", ...args]), + sendMessage: (...args) => sessionWrites.push(["sendMessage", ...args]), + sendUserMessage: (...args) => sessionWrites.push(["sendUserMessage", ...args]), + setSessionName: (...args) => sessionWrites.push(["setSessionName", ...args]), +}; +const extension = await import(`${pathToFileURL(process.env.EXT).href}?ship=${Date.now()}`); +extension.default(pi); +check(!!calmCommand, "Calm command was not registered"); +for (const event of ["session_start", "agent_start", "agent_settled", "session_shutdown"]) { + check(handlers.has(event), `Calm did not register a ${event} handler`); +} + +let renderRequests = 0; +const tui = { requestRender: () => { renderRequests += 1; } }; +const ui = { + workingVisible: [], + visibilityCalls: 0, + widgetOps: [], + widgets: new Map(), + setWorkingVisible(visible) { + this.visibilityCalls += 1; + this.workingVisible.push(visible); + }, + // Mirrors Pi's documented widget contract: the previous component under a key is + // disposed before a replacement is installed, and clearing disposes it too. + setWidget(key, content, options) { + const existing = this.widgets.get(key); + if (existing?.dispose) existing.dispose(); + this.widgets.delete(key); + this.widgetOps.push({ + key, + action: content === undefined ? "clear" : "set", + placement: options?.placement, + }); + if (content === undefined) return; + this.widgets.set(key, typeof content === "function" ? content(tui, theme) : content); + }, + getEditorText: () => "", + getToolsExpanded: () => false, + onTerminalInput: () => () => {}, + setHiddenThinkingLabel() {}, + setStatus() {}, + setToolsExpanded() {}, + theme, +}; +const ctx = { ui }; +const fire = async (event, payload = {}) => { + for (const handler of handlers.get(event) ?? []) await handler(payload, ctx); +}; +const reset = () => { + ui.workingVisible.length = 0; + ui.widgetOps.length = 0; + ui.visibilityCalls = 0; +}; +const shipWidget = () => ui.widgets.get(CALM_WORKING_SHIP_WIDGET_KEY); + +// --- Calm off leaves Pi's stock working behavior completely untouched ------------- +await fire("session_start", { reason: "startup" }); +reset(); +for (const event of ["agent_start", "agent_settled", "session_shutdown"]) { + await fire(event, { reason: "quit" }); +} +check( + ui.visibilityCalls === 0, + `Calm off called setWorkingVisible ${ui.visibilityCalls} times from the run lifecycle`, +); +check(ui.widgetOps.length === 0, `Calm off registered a working widget: ${JSON.stringify(ui.widgetOps)}`); +check(liveTimers === 0, `Calm off started ${liveTimers} animation timers`); + +// --- Turning Calm on while idle shows no boat until a run starts ------------------- +reset(); +await calmCommand.handler("", ctx); +check(ui.widgetOps.length === 0, "toggling Calm on while idle installed a working widget"); +check(liveTimers === 0, "toggling Calm on while idle started an animation timer"); + +// --- Calm on plus an active run shows the boat instead of the stock row ----------- +reset(); +await fire("agent_start"); +check( + ui.widgetOps.length === 1 && + ui.widgetOps[0].key === CALM_WORKING_SHIP_WIDGET_KEY && + ui.widgetOps[0].action === "set", + `Calm on did not install exactly one working widget: ${JSON.stringify(ui.widgetOps)}`, +); +check(ui.widgetOps[0].placement === undefined, "Calm working widget asked for a non-default placement"); +check( + ui.workingVisible[ui.workingVisible.length - 1] === false, + "Calm on did not hide Pi's stock working row", +); +check(liveTimers === 1, `Calm on kept ${liveTimers} animation timers instead of one`); + +const widget = shipWidget(); +check(!!widget, "Calm on did not install the working-ship widget"); +check(typeof widget.render === "function", "working widget has no render(width)"); +check(typeof widget.invalidate === "function", "working widget has no invalidate()"); +check(typeof widget.dispose === "function", "working widget has no dispose()"); +// A focusable widget could steal input or swallow Escape; this one takes no keys. +check(widget.handleInput === undefined, "working widget accepts keyboard input"); +check(widget.wantsKeyRelease === undefined, "working widget asked for key release events"); +check(widget.render(60).length === 2, "installed working widget did not render the two-row sprite"); +check( + widget.render(60).every((line) => visibleWidth(line) <= 60), + "installed working widget rendered a line wider than its viewport", +); + +// --- Repeated low-level starts inside one logical run never duplicate anything ----- +reset(); +for (let repeat = 0; repeat < 5; repeat += 1) await fire("agent_start"); +check(ui.widgetOps.length === 0, `repeated starts churned the working widget: ${JSON.stringify(ui.widgetOps)}`); +check(liveTimers === 1, `repeated starts left ${liveTimers} animation timers`); +check(ui.widgets.size === 1, `repeated starts left ${ui.widgets.size} widgets`); +check(shipWidget() === widget, "repeated starts replaced the running widget"); + +// --- The animation drives Pi's renderer ------------------------------------------- +{ + const before = renderRequests; + await new Promise((resolve) => setTimeout(resolve, CALM_WORKING_SHIP_TICK_MS * 3)); + check(renderRequests > before, "the working animation never requested a TUI render"); +} + +// --- Settling removes the boat, stops the animation, and restores the stock row ---- +// Drive the live widget far enough that a left-edge reset would be observable. +{ + const moving = shipWidget(); + check(!!moving, "continuity setup lost the live working widget"); + moving.render(40); + await new Promise((resolve) => setTimeout(resolve, CALM_WORKING_SHIP_TICK_MS * CALM_WORKING_SHIP_TICKS_PER_MOVE * 5 + 40)); + moving.render(40); +} +const hullColumn = (widget) => strip(widget.render(40)[1]).indexOf("\\__/"); +const freezeColumn = hullColumn(shipWidget()); +const freezeSail = sailOf(shipWidget().render(40)); +check(freezeColumn > 0, `lifecycle continuity setup never left the left edge: ${freezeColumn}`); + +reset(); +await fire("agent_settled"); +check( + ui.widgetOps.length === 1 && + ui.widgetOps[0].key === CALM_WORKING_SHIP_WIDGET_KEY && + ui.widgetOps[0].action === "clear", + `settling did not clear the working widget: ${JSON.stringify(ui.widgetOps)}`, +); +check(liveTimers === 0, `settling left ${liveTimers} animation timers`); +check(ui.widgets.size === 0, "settling left a residual widget"); +check( + ui.workingVisible[ui.workingVisible.length - 1] === true, + "settling did not restore Pi's stock working row", +); +{ + // No stale rows survive the removal: the widget renders nothing once disposed. + const renderRequestsAfterDispose = renderRequests; + await new Promise((resolve) => setTimeout(resolve, CALM_WORKING_SHIP_TICK_MS * CALM_WORKING_SHIP_TICKS_PER_MOVE * 3)); + check( + renderRequests === renderRequestsAfterDispose, + "the animation kept running after the widget was removed", + ); +} + +// --- Later working period resumes the frozen column and direction ----------------- +reset(); +await fire("agent_start"); +check(liveTimers === 1, `resume start left ${liveTimers} animation timers instead of one`); +check(ui.widgets.size === 1, "resume start did not install exactly one working widget"); +const resumedWidget = shipWidget(); +const resumeColumn = hullColumn(resumedWidget); +const resumeSail = sailOf(resumedWidget.render(40)); +check( + resumeColumn === freezeColumn && resumeSail === freezeSail, + `resume reset the boat instead of continuing: froze ${freezeColumn}/${freezeSail}, resumed ${resumeColumn}/${resumeSail}`, +); +// Repeated start/settle cycles must not duplicate scheduler or widget ownership. +for (let cycle = 0; cycle < 3; cycle += 1) { + await fire("agent_settled"); + check(liveTimers === 0, `cycle ${cycle} settle left ${liveTimers} timers`); + check(ui.widgets.size === 0, `cycle ${cycle} settle left a residual widget`); + await fire("agent_start"); + check(liveTimers === 1, `cycle ${cycle} start left ${liveTimers} timers`); + check(ui.widgets.size === 1, `cycle ${cycle} start left ${ui.widgets.size} widgets`); + check( + hullColumn(shipWidget()) >= freezeColumn, + `cycle ${cycle} lost continuity after repeated settle/start`, + ); +} +await fire("agent_settled"); +check(liveTimers === 0 && ui.widgets.size === 0, "repeated continuity cycles did not finish clean"); + +// A genuine fresh session resets to the normal initial position. +reset(); +await fire("session_start", { reason: "new" }); +check(liveTimers === 0 && ui.widgets.size === 0, "fresh session left a stale boat"); +await fire("agent_start"); +check(hullColumn(shipWidget()) === 0, "fresh session did not restart at the left edge"); +check(sailOf(shipWidget().render(40)) === "<|", "fresh session lost the initial rightward sail"); +await fire("agent_settled"); + +// --- Abort and failure share Pi's agent_settled path ------------------------------ +// Pi emits agent_settled from a finally block, so an aborted or failed run reaches +// exactly this handler; the real-TUI regression covers the Escape abort path. +for (const outcome of ["abort", "failure"]) { + reset(); + await fire("agent_start"); + check(liveTimers === 1, `${outcome} setup did not start the animation`); + await fire("agent_settled"); + check(liveTimers === 0, `${outcome} left ${liveTimers} animation timers`); + check(ui.widgets.size === 0, `${outcome} left a residual widget`); + check( + ui.workingVisible[ui.workingVisible.length - 1] === true, + `${outcome} did not restore Pi's stock working row`, + ); +} + +// --- Shutdown, reload, and session replacement all clean up ----------------------- +for (const reason of ["quit", "reload", "new", "resume", "fork"]) { + reset(); + await fire("agent_start"); + check(liveTimers === 1, `${reason} setup did not start the animation`); + await fire("session_shutdown", { reason }); + check(liveTimers === 0, `session_shutdown(${reason}) left ${liveTimers} animation timers`); + check(ui.widgets.size === 0, `session_shutdown(${reason}) left a residual widget`); + check( + ui.workingVisible[ui.workingVisible.length - 1] === true, + `session_shutdown(${reason}) did not restore Pi's stock working row`, + ); + if (reason === "quit") continue; + reset(); + await fire("session_start", { reason }); + check(ui.widgets.size === 0, `session_start(${reason}) installed a stale widget`); + check(liveTimers === 0, `session_start(${reason}) left ${liveTimers} animation timers`); +} + +// --- Toggling Calm off during an active run restores the stock row immediately ----- +await fire("session_start", { reason: "startup" }); +reset(); +await fire("agent_start"); +check(liveTimers === 1, "active-run setup did not start the animation"); +await calmCommand.handler("", ctx); +check(liveTimers === 0, "toggling Calm off during a run left the animation running"); +check(ui.widgets.size === 0, "toggling Calm off during a run left the boat on screen"); +check( + ui.workingVisible[ui.workingVisible.length - 1] === true, + "toggling Calm off during a run did not restore Pi's stock working row", +); + +// Toggling Calm back on during the same run returns the boat. +reset(); +await calmCommand.handler("", ctx); +check(liveTimers === 1, "toggling Calm on during a run did not return the boat"); +check( + ui.workingVisible[ui.workingVisible.length - 1] === false, + "toggling Calm on during a run did not hide Pi's stock working row", +); +await fire("agent_settled"); +check(liveTimers === 0, "the toggled-on run did not clean up"); + +// A run started after toggling Calm on while idle uses the boat. +reset(); +await calmCommand.handler("", ctx); +await calmCommand.handler("", ctx); +await fire("agent_start"); +check(liveTimers === 1, "a later run did not use the boat after an idle Calm toggle"); +await fire("agent_settled"); +check(liveTimers === 0, "the later run did not clean up"); + +// --- The visual-only widget never touches session, transcript, or export data ------ +check( + sessionWrites.length === 0, + `the working presentation wrote session or transcript data: ${JSON.stringify(sessionWrites)}`, +); + +globalThis.setInterval = realSetInterval; +globalThis.clearInterval = realClearInterval; +JS +) + status=$? + [ "$status" -eq 0 ] || fail "Pi Calm working-ship checks failed: $out" + [ -z "$out" ] || fail "Pi Calm working-ship test printed output: $out" + pass "Pi Calm working ship moves on a slow independent cadence over faster fixed-cell blue water, paints the complete boat standard yellow with balanced resets, keeps ANSI-stripped width exact, flips the directional sail on the exact bounce at both edges and every width, clamps visible and hidden resizes, falls back deterministically when narrow, freezes and resumes column/direction across settle/start without hidden-time jumps or duplicate timers, resets only on a fresh session, and installs and removes one scheduler-owning widget across starts, settle, abort, failure, shutdown, reload, replacement, and Calm toggles while leaving Calm-off visibility untouched" +} + test_interactive_terminal_e2e() { - local project config home session_file export_file export_dom default_snapshot expanded_snapshot hidden_snapshot active_before_snapshot active_hidden_snapshot export_snapshot restored_snapshot working_snapshot working_response_snapshot restarted_snapshot resumed_restored_snapshot hash_before hash_after now version chrome chrome_pid chrome_wait active_wait active_screen_wait + local project config home session_file export_file export_dom default_snapshot expanded_snapshot hidden_snapshot active_before_snapshot active_hidden_snapshot export_snapshot restored_snapshot working_snapshot working_response_snapshot restarted_snapshot resumed_restored_snapshot hash_before hash_after now version chrome chrome_pid chrome_wait active_wait active_screen_wait boat_frame_one boat_frame_two boat_resized_snapshot boat_focus_snapshot boat_cleared_snapshot boat_hull_line boat_sail_line boat_column_one boat_column_two boat_line boat_color_snapshot boat_color_line boat_water_snapshot boat_water_line boat_water_first boat_water_changed boat_narrow_snapshot boat_narrow_sails boat_freeze_snapshot boat_resume_snapshot boat_freeze_column boat_freeze_sail boat_resume_column boat_resume_sail if ! command -v pi >/dev/null 2>&1 || ! command -v tmux >/dev/null 2>&1; then echo "skip: pi or tmux not found for Pi calm interactive E2E" return 0 fi version=$(pi --version 2>/dev/null || true) - require_pi_compat_version "$version" "Pi calm interactive E2E" + record_pi_version_evidence "$version" "Pi calm interactive E2E" project="$TMP_ROOT/e2e-project" config="$TMP_ROOT/e2e-config" @@ -1505,6 +2488,16 @@ test_interactive_terminal_e2e() { restored_snapshot="$TMP_ROOT/restored.txt" working_snapshot="$TMP_ROOT/working.txt" working_response_snapshot="$TMP_ROOT/working-response.txt" + boat_frame_one="$TMP_ROOT/boat-frame-one.txt" + boat_frame_two="$TMP_ROOT/boat-frame-two.txt" + boat_resized_snapshot="$TMP_ROOT/boat-resized.txt" + boat_focus_snapshot="$TMP_ROOT/boat-focus.txt" + boat_cleared_snapshot="$TMP_ROOT/boat-cleared.txt" + boat_color_snapshot="$TMP_ROOT/boat-color.txt" + boat_water_snapshot="$TMP_ROOT/boat-water.txt" + boat_narrow_snapshot="$TMP_ROOT/boat-narrow.txt" + boat_freeze_snapshot="$TMP_ROOT/boat-freeze.txt" + boat_resume_snapshot="$TMP_ROOT/boat-resume.txt" restarted_snapshot="$TMP_ROOT/restarted.txt" resumed_restored_snapshot="$TMP_ROOT/resumed-restored.txt" mkdir -p "$project/.pi/extensions/lib" "$project/bin" "$project/state" "$config" "$home/config" @@ -1514,6 +2507,7 @@ test_interactive_terminal_e2e() { cp "$ASSISTANT_LAYOUT" "$project/.pi/extensions/lib/fm-calm-assistant-layout.ts" cp "$OPERATIONAL_USER_LAYOUT" "$project/.pi/extensions/lib/fm-calm-operational-user-layout.ts" cp "$VISIBILITY" "$project/.pi/extensions/lib/fm-calm-visibility.ts" + cp "$WORKING_SHIP" "$project/.pi/extensions/lib/fm-calm-working-ship.ts" cp "$ROOT/.pi/extensions/lib/fm-operational-input.ts" "$project/.pi/extensions/lib/fm-operational-input.ts" cp "$WATCH_EXT" "$project/.pi/extensions/fm-primary-pi-watch.ts" cp "$ROOT/.pi/extensions/fm-primary-turnend-guard.ts" "$project/.pi/extensions/fm-primary-turnend-guard.ts" @@ -1547,6 +2541,15 @@ export default function (pi: ExtensionAPI): void { contextWindow: 4096, maxTokens: 128, }, + { + id: "delayed-boat", + name: "Long-delay Calm working-ship fixture", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 4096, + maxTokens: 128, + }, { id: "operational-error", name: "Calm gapless operational-row fixture", @@ -1585,7 +2588,18 @@ export default function (pi: ExtensionAPI): void { stream.end(); return; } - await new Promise((resolve) => setTimeout(resolve, 1500)); + // Wake as soon as the run is aborted so Escape settles the turn promptly. + await new Promise<void>((resolve) => { + const timer = setTimeout(resolve, model.id === "delayed-boat" ? 90000 : 1500); + options?.signal?.addEventListener( + "abort", + () => { + clearTimeout(timer); + resolve(); + }, + { once: true }, + ); + }); if (options?.signal?.aborted) { output.stopReason = "aborted"; stream.push({ type: "error", reason: "aborted", error: output }); @@ -1634,6 +2648,16 @@ export default function (pi: ExtensionAPI): void { }); }, }); + pi.registerCommand("calm-boat-e2e", { + description: "Start the long-delay working-ship fixture.", + handler: async (_args, ctx) => { + const model = ctx.modelRegistry.find("calm-e2e", "delayed-boat"); + if (!model || !(await pi.setModel(model))) { + throw new Error("could not select the long-delay Calm E2E model"); + } + await pi.sendUserMessage("CALM_BOAT_E2E_PROMPT"); + }, + }); pi.registerCommand("calm-working-e2e", { description: "Start the delayed native Working-row fixture.", handler: async (_args, ctx) => { @@ -1684,6 +2708,10 @@ JSON tmux -L "$TMUX_SOCKET" send-keys -t "$TMUX_SESSION" C-o wait_for_text "$expanded_snapshot" "escape to interrupt" \ || fail "Ctrl+O did not retain Pi's ordinary startup and tool expansion behavior" + # The expansion redraw lands a frame or two after the footer hint, so wait for the + # tool output this block actually asserts instead of assuming one implies the other. + wait_for_text "$expanded_snapshot" "CALM_E2E_OUTPUT" \ + || fail "ordinary Ctrl+O expansion hid tool activity while calm mode was off" assert_contains "$(cat "$expanded_snapshot")" "CALM_E2E_OUTPUT" "ordinary Ctrl+O expansion hid tool activity while calm mode was off" tmux -L "$TMUX_SOCKET" send-keys -t "$TMUX_SESSION" -l "/calm" @@ -1691,8 +2719,13 @@ JSON active_screen_wait=0 while [ "$active_screen_wait" -lt 120 ]; do tmux -L "$TMUX_SOCKET" capture-pane -p -t "$TMUX_SESSION" >"$hidden_snapshot" + # Wait for the redraw this block actually asserts: hidden rows gone AND the + # retained genuine rows back on screen. Breaking on the hidden rows alone can + # observe a half-redrawn transcript. if ! grep -Fq "CALM_E2E_OUTPUT" "$hidden_snapshot" && - ! grep -Fq "/calm" "$hidden_snapshot"; then + ! grep -Fq "/calm" "$hidden_snapshot" && + grep -Fq "FIRSTMATE WATCHER WAKE: can you explain this phrase?" "$hidden_snapshot" && + grep -Fq "The deterministic tool example is complete." "$hidden_snapshot"; then break fi sleep 0.05 @@ -1921,10 +2954,258 @@ JS done [ "$(cat "$home/config/calm")" = on ] || fail "third /calm did not persist the active choice" + # Calm on plus a genuinely active run replaces Pi's stock working row with the boat. + tmux -L "$TMUX_SOCKET" send-keys -t "$TMUX_SESSION" -l "/calm-boat-e2e" + tmux -L "$TMUX_SOCKET" send-keys -t "$TMUX_SESSION" M-s + active_screen_wait=0 + while [ "$active_screen_wait" -lt 200 ]; do + tmux -L "$TMUX_SOCKET" capture-pane -p -t "$TMUX_SESSION" >"$working_snapshot" + if grep -Fq '\__/' "$working_snapshot"; then + break + fi + sleep 0.025 + active_screen_wait=$((active_screen_wait + 1)) + done + cp "$working_snapshot" "$boat_frame_one" + assert_contains "$(cat "$boat_frame_one")" '\__/' "Calm did not show the working ship during a real provider wait" + assert_not_contains "$(cat "$boat_frame_one")" "Working..." "Calm left Pi's stock working row visible while the ship was shown" + assert_not_contains "$(cat "$boat_frame_one")" "calm transcript" "the real provider wait showed a persistent Calm status row" + assert_not_contains "$(cat "$boat_frame_one")" "FIRSTMATE WATCHER WAKE: signal: /tmp/probe.status" "the real provider wait restored a hidden operational row" + boat_hull_line=$(grep -F '\__/' "$boat_frame_one" | head -1) + boat_sail_line=$(grep -E '<\||\|>' "$boat_frame_one" | tail -1) + case "$boat_sail_line" in + *'<|'*|*'|>'*) : ;; + *) fail "the working ship lost its directional mainsail" ;; + esac + assert_not_contains "$boat_hull_line" "Working" "the ship row carried extra status copy" + case "$boat_hull_line" in + *~*) : ;; + *) fail "the working ship rendered no waves" ;; + esac + # Standard ANSI colors: blue water, yellow boat, no theme/bright/256/RGB escapes. + tmux -L "$TMUX_SOCKET" capture-pane -p -e -t "$TMUX_SESSION" >"$boat_color_snapshot" + boat_color_line=$(grep -F '\__/' "$boat_color_snapshot" | head -1) + [ -n "$boat_color_line" ] || fail "could not capture a colored working-ship row" + case "$boat_color_line" in + *'[34m'*) : ;; + *) fail "the water was not rendered with standard ANSI blue" ;; + esac + case "$boat_color_line" in + *'[33m'*) : ;; + *) fail "the boat was not rendered with standard ANSI yellow" ;; + esac + case "$boat_color_line" in + *'[38;2;'*|*'[38;5;'*|*'[9'[0-9]'m'*) fail "the working ship used a non-standard color escape" ;; + *) : ;; + esac + + # The water animates on its own faster cadence while the boat holds its column. + boat_column_one=$(awk 'index($0,"\\__/"){print index($0,"\\__/"); exit}' "$boat_frame_one") + boat_water_changed=0 + boat_water_first=$(grep -F '\__/' "$boat_frame_one" | head -1) + active_screen_wait=0 + while [ "$active_screen_wait" -lt 60 ]; do + tmux -L "$TMUX_SOCKET" capture-pane -p -t "$TMUX_SESSION" >"$boat_water_snapshot" + boat_water_line=$(grep -F '\__/' "$boat_water_snapshot" | head -1) + boat_column_two=$(awk 'index($0,"\\__/"){print index($0,"\\__/"); exit}' "$boat_water_snapshot") + if [ -n "$boat_water_line" ] && [ "$boat_column_two" = "$boat_column_one" ] && + [ "$boat_water_line" != "$boat_water_first" ]; then + boat_water_changed=1 + break + fi + sleep 0.05 + active_screen_wait=$((active_screen_wait + 1)) + done + [ "$boat_water_changed" -eq 1 ] \ + || fail "the water never animated while the working ship held its column" + + # Two frames at different hull columns prove genuine horizontal motion. + boat_column_two="" + active_screen_wait=0 + while [ "$active_screen_wait" -lt 200 ]; do + tmux -L "$TMUX_SOCKET" capture-pane -p -t "$TMUX_SESSION" >"$boat_frame_two" + boat_column_two=$(awk 'index($0,"\\__/"){print index($0,"\\__/"); exit}' "$boat_frame_two") + if [ -n "$boat_column_two" ] && [ "$boat_column_two" != "$boat_column_one" ]; then + break + fi + sleep 0.05 + active_screen_wait=$((active_screen_wait + 1)) + done + [ -n "$boat_column_two" ] || fail "the working ship disappeared between animation frames" + [ "$boat_column_two" != "$boat_column_one" ] \ + || fail "the working ship never moved horizontally (stuck at column $boat_column_one)" + + # The widget owns its own geometry, so resizing the same running TUI must reflow it. + tmux -L "$TMUX_SOCKET" set-option -t "$TMUX_SESSION" window-size manual + tmux -L "$TMUX_SOCKET" resize-window -t "$TMUX_SESSION" -x 100 -y 30 + active_screen_wait=0 + while [ "$active_screen_wait" -lt 200 ]; do + tmux -L "$TMUX_SOCKET" capture-pane -p -t "$TMUX_SESSION" >"$boat_resized_snapshot" + boat_hull_line=$(grep -F '\__/' "$boat_resized_snapshot" | head -1) + if [ -n "$boat_hull_line" ] && [ "${#boat_hull_line}" -eq 100 ]; then + break + fi + sleep 0.05 + active_screen_wait=$((active_screen_wait + 1)) + done + assert_contains "$(cat "$boat_resized_snapshot")" '\__/' "the working ship left the screen after a resize" + boat_hull_line=$(grep -F '\__/' "$boat_resized_snapshot" | head -1) + [ "${#boat_hull_line}" -eq 100 ] \ + || fail "after resizing to 100 columns the ship row was ${#boat_hull_line} cells instead of exactly 100" + # Exactly one wave row means the sprite reflowed rather than wrapping onto extra rows. + [ "$(grep -c -F '\__/' "$boat_resized_snapshot")" -eq 1 ] \ + || fail "the working ship wrapped onto more than one water row after the resize" + while IFS= read -r boat_line; do + [ "${#boat_line}" -le 100 ] \ + || fail "a rendered line was ${#boat_line} cells after resizing to 100 columns" + done <"$boat_resized_snapshot" + boat_column_one=$(awk 'index($0,"\\__/"){print index($0,"\\__/"); exit}' "$boat_resized_snapshot") + [ "$boat_column_one" -le 97 ] \ + || fail "the working ship hull started at column $boat_column_one and cannot fit in 100 columns" + + # Motion continues on-screen after the resize instead of jumping offscreen. + boat_column_two="" + active_screen_wait=0 + while [ "$active_screen_wait" -lt 200 ]; do + tmux -L "$TMUX_SOCKET" capture-pane -p -t "$TMUX_SESSION" >"$boat_resized_snapshot" + boat_column_two=$(awk 'index($0,"\\__/"){print index($0,"\\__/"); exit}' "$boat_resized_snapshot") + if [ -n "$boat_column_two" ] && [ "$boat_column_two" != "$boat_column_one" ]; then + break + fi + sleep 0.05 + active_screen_wait=$((active_screen_wait + 1)) + done + [ -n "$boat_column_two" ] && [ "$boat_column_two" != "$boat_column_one" ] \ + || fail "the working ship stopped moving after the resize" + [ "$boat_column_two" -le 97 ] \ + || fail "the working ship moved offscreen after the resize" + + # A narrow terminal shortens the track enough to observe both bounce directions. + # The sail must show the heading it is about to travel, so a full traverse shows both. + tmux -L "$TMUX_SOCKET" resize-window -t "$TMUX_SESSION" -x 12 -y 20 + boat_narrow_sails="" + active_screen_wait=0 + while [ "$active_screen_wait" -lt 400 ]; do + tmux -L "$TMUX_SOCKET" capture-pane -p -t "$TMUX_SESSION" >"$boat_narrow_snapshot" + if grep -Fq '<|' "$boat_narrow_snapshot"; then + case "$boat_narrow_sails" in *R*) : ;; *) boat_narrow_sails="${boat_narrow_sails}R" ;; esac + fi + if grep -Fq '|>' "$boat_narrow_snapshot"; then + case "$boat_narrow_sails" in *L*) : ;; *) boat_narrow_sails="${boat_narrow_sails}L" ;; esac + fi + case "$boat_narrow_sails" in + *R*L*|*L*R*) break ;; + esac + sleep 0.1 + active_screen_wait=$((active_screen_wait + 1)) + done + case "$boat_narrow_sails" in + *R*L*|*L*R*) : ;; + *) fail "the working ship never showed both sail headings on a narrow track (saw '$boat_narrow_sails')" ;; + esac + boat_hull_line=$(grep -F '\__/' "$boat_narrow_snapshot" | head -1) + [ "${#boat_hull_line}" -eq 12 ] \ + || fail "the narrow working-ship row was ${#boat_hull_line} cells instead of exactly 12" + tmux -L "$TMUX_SOCKET" resize-window -t "$TMUX_SESSION" -x 100 -y 30 + + # Typing still reaches the editor while the animation runs. + tmux -L "$TMUX_SOCKET" send-keys -t "$TMUX_SESSION" -l "FOCUSPROBE" + wait_for_text "$boat_focus_snapshot" "FOCUSPROBE" \ + || fail "keyboard input did not reach the editor while the working ship animated" + i=0 + while [ "$i" -lt 10 ]; do + tmux -L "$TMUX_SOCKET" send-keys -t "$TMUX_SESSION" BSpace + i=$((i + 1)) + done + + # Capture the last on-screen column and sail before settling so the next working + # period in this same Pi session can prove freeze/resume continuity. + tmux -L "$TMUX_SOCKET" capture-pane -p -t "$TMUX_SESSION" >"$boat_freeze_snapshot" + boat_freeze_column=$(awk 'index($0,"\\__/"){print index($0,"\\__/"); exit}' "$boat_freeze_snapshot") + boat_freeze_sail=$(grep -E '<\||\|>' "$boat_freeze_snapshot" | tail -1 || true) + case "$boat_freeze_sail" in + *'<|'*) boat_freeze_sail='<|' ;; + *'|>'*) boat_freeze_sail='|>' ;; + *) fail "could not read the freeze-frame sail heading" ;; + esac + [ -n "$boat_freeze_column" ] && [ "$boat_freeze_column" -gt 1 ] \ + || fail "freeze frame never left the left edge (column '${boat_freeze_column:-empty}')" + + # Escape aborts the run, and the abort path removes the ship with no residue. + tmux -L "$TMUX_SOCKET" send-keys -t "$TMUX_SESSION" Escape + active_screen_wait=0 + while [ "$active_screen_wait" -lt 200 ]; do + tmux -L "$TMUX_SOCKET" capture-pane -p -t "$TMUX_SESSION" >"$boat_cleared_snapshot" + if ! grep -Fq '\__/' "$boat_cleared_snapshot"; then + break + fi + sleep 0.05 + active_screen_wait=$((active_screen_wait + 1)) + done + assert_not_contains "$(cat "$boat_cleared_snapshot")" '\__/' "Escape did not remove the working ship" + assert_not_contains "$(cat "$boat_cleared_snapshot")" "CALM_WORKING_E2E_RESPONSE" "the long-delay fixture settled instead of aborting on Escape" + assert_not_contains "$(cat "$boat_cleared_snapshot")" "FOCUSPROBE" "the editor kept the focus probe text after Escape" + + # A later working period in the same Pi process must resume the frozen column and + # sail rather than recreating the boat at the left edge. Capture the first resumed + # frames quickly so the slow boat cadence cannot advance before the assertion. + tmux -L "$TMUX_SOCKET" send-keys -t "$TMUX_SESSION" -l "/calm-boat-e2e" + tmux -L "$TMUX_SOCKET" send-keys -t "$TMUX_SESSION" M-s + boat_resume_column="" + boat_resume_sail="" + active_screen_wait=0 + while [ "$active_screen_wait" -lt 200 ]; do + tmux -L "$TMUX_SOCKET" capture-pane -p -t "$TMUX_SESSION" >"$boat_resume_snapshot" + if grep -Fq '\__/' "$boat_resume_snapshot"; then + boat_resume_column=$(awk 'index($0,"\\__/"){print index($0,"\\__/"); exit}' "$boat_resume_snapshot") + boat_resume_sail=$(grep -E '<\||\|>' "$boat_resume_snapshot" | tail -1 || true) + case "$boat_resume_sail" in + *'<|'*) boat_resume_sail='<|' ;; + *'|>'*) boat_resume_sail='|>' ;; + esac + break + fi + sleep 0.025 + active_screen_wait=$((active_screen_wait + 1)) + done + [ -n "$boat_resume_column" ] \ + || fail "the second working period never showed the working ship" + [ "$boat_resume_column" -eq "$boat_freeze_column" ] \ + || fail "the second working period reset the boat from column $boat_freeze_column to $boat_resume_column instead of resuming" + [ "$boat_resume_sail" = "$boat_freeze_sail" ] \ + || fail "the second working period changed sail from $boat_freeze_sail to $boat_resume_sail" + assert_not_contains "$(cat "$boat_resume_snapshot")" "Working..." \ + "the second working period left Pi's stock working row visible" + + # Clear the resumed run before the Calm-off stock-row probe. + tmux -L "$TMUX_SOCKET" send-keys -t "$TMUX_SESSION" Escape + active_screen_wait=0 + while [ "$active_screen_wait" -lt 200 ]; do + tmux -L "$TMUX_SOCKET" capture-pane -p -t "$TMUX_SESSION" >"$boat_cleared_snapshot" + if ! grep -Fq '\__/' "$boat_cleared_snapshot"; then + break + fi + sleep 0.05 + active_screen_wait=$((active_screen_wait + 1)) + done + assert_not_contains "$(cat "$boat_cleared_snapshot")" '\__/' "Escape did not remove the resumed working ship" + + # Calm off restores Pi's stock working row and never shows the ship. + tmux -L "$TMUX_SOCKET" send-keys -t "$TMUX_SESSION" -l "/calm" + tmux -L "$TMUX_SOCKET" send-keys -t "$TMUX_SESSION" M-s + active_screen_wait=0 + while [ "$active_screen_wait" -lt 200 ]; do + if [ "$(cat "$home/config/calm")" = off ]; then + break + fi + sleep 0.05 + active_screen_wait=$((active_screen_wait + 1)) + done + [ "$(cat "$home/config/calm")" = off ] || fail "the Calm-off working-row probe did not turn Calm off" tmux -L "$TMUX_SOCKET" send-keys -t "$TMUX_SESSION" -l "/calm-working-e2e" tmux -L "$TMUX_SOCKET" send-keys -t "$TMUX_SESSION" M-s active_screen_wait=0 - while [ "$active_screen_wait" -lt 120 ]; do + while [ "$active_screen_wait" -lt 200 ]; do tmux -L "$TMUX_SOCKET" capture-pane -p -t "$TMUX_SESSION" >"$working_snapshot" if grep -Fq "Working..." "$working_snapshot"; then break @@ -1932,11 +3213,28 @@ JS sleep 0.025 active_screen_wait=$((active_screen_wait + 1)) done - assert_contains "$(cat "$working_snapshot")" "Working..." "Calm hid Pi's built-in Working row during a real provider wait" - assert_not_contains "$(cat "$working_snapshot")" "calm transcript" "the real provider wait showed a persistent Calm status row" - assert_not_contains "$(cat "$working_snapshot")" "FIRSTMATE WATCHER WAKE: signal: /tmp/probe.status" "the real provider wait restored a hidden operational row" + assert_contains "$(cat "$working_snapshot")" "Working..." "Calm off did not keep Pi's stock working row" + assert_not_contains "$(cat "$working_snapshot")" '\__/' "Calm off showed the working ship" wait_for_text "$working_response_snapshot" "CALM_WORKING_E2E_RESPONSE" \ - || fail "the deterministic provider did not settle after proving Pi's Working row" + || fail "the deterministic provider did not settle after proving Pi's stock working row" + + # No blank-row residue: settling returns to the same layout Calm off started from. + tmux -L "$TMUX_SOCKET" capture-pane -p -t "$TMUX_SESSION" >"$boat_cleared_snapshot" + assert_not_contains "$(cat "$boat_cleared_snapshot")" '\__/' "a settled run left the working ship on screen" + + # Restore Calm for the persistence restart below. + tmux -L "$TMUX_SOCKET" send-keys -t "$TMUX_SESSION" -l "/calm" + tmux -L "$TMUX_SOCKET" send-keys -t "$TMUX_SESSION" M-s + active_screen_wait=0 + while [ "$active_screen_wait" -lt 200 ]; do + if [ "$(cat "$home/config/calm")" = on ]; then + break + fi + sleep 0.05 + active_screen_wait=$((active_screen_wait + 1)) + done + [ "$(cat "$home/config/calm")" = on ] || fail "Calm was not restored before the persistence restart" + tmux -L "$TMUX_SOCKET" resize-window -t "$TMUX_SESSION" -x 180 -y 44 tmux -L "$TMUX_SOCKET" send-keys -t "$TMUX_SESSION" -l "/quit" tmux -L "$TMUX_SOCKET" send-keys -t "$TMUX_SESSION" M-s @@ -1972,12 +3270,15 @@ JS [ "$(cat "$home/config/calm")" = off ] || fail "/calm after restart did not persist the inactive choice" tmux -L "$TMUX_SOCKET" send-keys -t "$TMUX_SESSION" -l "/quit" tmux -L "$TMUX_SOCKET" send-keys -t "$TMUX_SESSION" M-s - pass "Pi calm native E2E keeps Working and captain turns visible, hides exact operational user rows without changing persistence, restores them Calm-off, survives restart, and preserves export plus Ctrl+O behavior" + pass "Pi calm native E2E replaces the stock working row with a moving, resize-clamped working ship that freezes and resumes across two working periods in one Pi session, clears on abort, keeps captain turns visible, hides exact operational user rows without changing persistence, restores stock rendering Calm-off, survives restart, and preserves export plus Ctrl+O behavior" } -test_static_contract test_home_resolution +test_pi_compat_no_upper_bound +test_pi_compat_degraded_adapter +test_pi_compat_missing_adapter_exports test_rendering_and_session_lifecycle test_operational_followup_turn_e2e test_hidden_block_geometry_e2e +test_working_ship_geometry_and_lifecycle test_interactive_terminal_e2e diff --git a/tests/fm-captain-translation-contract.test.sh b/tests/fm-captain-translation-contract.test.sh deleted file mode 100755 index cc7c75311e..0000000000 --- a/tests/fm-captain-translation-contract.test.sh +++ /dev/null @@ -1,293 +0,0 @@ -#!/usr/bin/env bash -# Static regression tests for the captain-facing plain-English translation -# contract owned by AGENTS.md section 9. -# shellcheck disable=SC2016 -set -u - -# shellcheck source=tests/lib.sh -. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" - -AGENTS="$ROOT/AGENTS.md" -BOOTSTRAP="$ROOT/.agents/skills/bootstrap-diagnostics/SKILL.md" -AFK="$ROOT/.agents/skills/afk/SKILL.md" -DECISION="$ROOT/.agents/skills/decision-hold-lifecycle/SKILL.md" -RECOVERY="$ROOT/.agents/skills/stuck-crewmate-recovery/SKILL.md" -HARNESS="$ROOT/.agents/skills/harness-adapters/SKILL.md" -CODEXAPP="$ROOT/.agents/skills/firstmate-codexapp/SKILL.md" -FMX="$ROOT/.agents/skills/fmx-respond/SKILL.md" -UPDATE="$ROOT/.agents/skills/updatefirstmate/SKILL.md" -AHOY="$ROOT/.agents/skills/ahoy/SKILL.md" -README="$ROOT/README.md" - -section_9() { - awk ' - /^## 9\. Escalation and captain etiquette$/ { found = 1 } - found && /^## 10\. / { exit } - found { print } - ' "$AGENTS" -} - -test_section_9_owns_positive_translation_contract() { - local contract - contract=$(section_9) - assert_contains "$contract" "Every captain-facing message must translate internal state into the project outcome, consequence, and next decision." \ - "section 9 does not own the positive captain-facing translation contract" - assert_contains "$contract" "Use the captain's nouns:" \ - "section 9 does not require captain-owned nouns" - assert_contains "$contract" "When evidence uses an internal label, rewrite it before sending:" \ - "section 9 does not own the rewrite mapping list" - pass "section 9 owns the positive captain-facing translation contract" -} - -test_scout_remains_allowed_house_vocabulary() { - local contract - contract=$(section_9) - assert_contains "$contract" "Scout and second mate are accepted Firstmate nautical house vocabulary and do not need translation" \ - "section 9 does not preserve scout as allowed Firstmate vocabulary" - assert_not_contains "$contract" "scout -> investigation" \ - "section 9 must not map scout to investigation" - assert_not_contains "$contract" "scout, ship" \ - "section 9 must not add scout to the internal-vocabulary ban" - assert_not_contains "$contract" "secondmate -> domain supervisor" \ - "section 9 must not map secondmate to domain supervisor" - pass "scout remains allowed in private captain chat" -} - -test_compressed_safety_labels_have_plain_renderings() { - local contract - contract=$(section_9) - for phrase in \ - "fail-closed" \ - "fails closed" \ - "fail-open" \ - "fails open" \ - "fail loudly"; do - assert_contains "$contract" "$phrase" "section 9 does not cover compressed safety label '$phrase'" - done - assert_contains "$contract" "stops safely when something goes wrong" \ - "fail-closed behavior lacks a concrete plain rendering" - assert_contains "$contract" "refuses rather than proceeding" \ - "fail-closed behavior lacks refusal wording" - assert_contains "$contract" "steps aside and lets work continue when the check cannot complete" \ - "fail-open behavior lacks a concrete plain rendering" - pass "compressed safety labels require concrete plain renderings" -} - -test_mapping_list_covers_high_risk_internal_families() { - local contract - contract=$(section_9) - for phrase in \ - "worktree, checkout, primary checkout, or local-main -> local copy" \ - "teardown -> cleanup" \ - "wake, watcher, heartbeat, stale, signal, or check -> notification" \ - "hold, gate, ask-user, needs-decision, blocked, or paused -> the concrete decision" \ - "done, failed, fix-review, checks-passed, cancelled, validation step, or pipeline state -> the concrete result" \ - "brief -> instructions" \ - "crewmate -> worker" \ - "harness, backend, runtime, or adapter -> worker runtime or tool" \ - "status file, metadata, state, task id, or raw path -> durable record"; do - assert_contains "$contract" "$phrase" "section 9 mapping list is missing '$phrase'" - done - pass "section 9 maps high-risk internal vocabulary families" -} - -test_verbatim_internal_evidence_is_rejected_from_chat() { - local contract - contract=$(section_9) - assert_contains "$contract" "Never relay worker reports, status lines, tool output, validation-state labels, or decision records verbatim into captain chat." \ - "section 9 does not reject verbatim internal evidence in captain chat" - assert_contains "$contract" "Private evidence reports may retain exact identifiers, paths, status lines, validation labels, and internal terms" \ - "section 9 does not preserve private evidence precision" - assert_contains "$contract" "the captain-facing chat summary that points to the report still follows this translation rule" \ - "section 9 does not keep chat summaries plain English" - pass "captain chat rejects verbatim internal evidence while private reports stay precise" -} - -test_routine_no_action_response_is_event_scoped() { - local contract - contract=$(section_9) - assert_contains "$contract" 'reply exactly `Captain, shipshape.` without characterizing the visible session' \ - "section 9 does not require the exact event-scoped routine no-action response" - assert_not_contains "$contract" 'Captain, no decision is needed.' \ - "section 9 implies the visible session has no unrelated open decisions" - pass "routine no-action response is exact and scoped to its event" -} - -test_outward_facing_skill_points_reference_section_9_owner() { - assert_grep "using \`AGENTS.md\` section 9's captain-facing translation contract" "$BOOTSTRAP" \ - "bootstrap diagnostics do not reference section 9 at captain handoff" - assert_grep "Acknowledge** in \`AGENTS.md\` section 9 language" "$AFK" \ - "afk acknowledgement does not reference section 9" - assert_grep "Captain, away mode is active; I will batch routine updates" "$AFK" \ - "afk acknowledgement lacks a local plain-English example" - assert_grep "as decisions from Bearings' Captain's Call section under \`AGENTS.md\` section 9" "$DECISION" \ - "decision relay does not reference section 9" - assert_grep "using \`AGENTS.md\` section 9; do not mention metadata, harness, window, or worktree" "$RECOVERY" \ - "stuck-worker failure does not reference section 9" - assert_grep "under \`AGENTS.md\` section 9 that the requested worker runtime is not verified yet" "$HARNESS" \ - "runtime fallback does not reference section 9" - assert_grep "use firstmate's own verified runtime for current work" "$HARNESS" \ - "runtime fallback does not require the current-work fallback" - assert_grep "Do not pause current work for that future-verification choice, and never launch an unverified adapter." "$HARNESS" \ - "runtime fallback permits waiting on future verification or launching an unverified adapter" - assert_grep "translate status prefixes and return-channel evidence through \`AGENTS.md\` section 9" "$CODEXAPP" \ - "Codex Desktop result reporting does not reference section 9" - assert_grep "It supplements \`AGENTS.md\` section 9; apply both, and this public-channel rule wins wherever it is stricter." "$FMX" \ - "X reply safety does not state that it supplements section 9" - assert_grep "under \`AGENTS.md\` section 9 without firstmate's internal vocabulary" "$UPDATE" \ - "Firstmate update reporting does not reference section 9" - pass "outward-facing skill handoffs point to the section 9 owner" -} - -test_section_9_owner_is_not_duplicated_into_skills() { - local duplicate_count file - duplicate_count=0 - for file in "$BOOTSTRAP" "$AFK" "$DECISION" "$RECOVERY" "$HARNESS" "$CODEXAPP" "$UPDATE"; do - if grep -Fq "When evidence uses an internal label, rewrite it before sending:" "$file"; then - duplicate_count=$((duplicate_count + 1)) - fi - done - [ "$duplicate_count" -eq 0 ] || fail "skills duplicated section 9's mapping owner" - pass "skills cross-reference section 9 instead of duplicating the mapping list" -} - -test_ahoy_is_an_internal_user_invocable_skill() { - assert_present "$AHOY" "ahoy skill is missing" - assert_grep 'name: ahoy' "$AHOY" "ahoy skill metadata has the wrong name" - assert_grep 'user-invocable: true' "$AHOY" "ahoy skill is not user-invocable" - assert_grep ' internal: true' "$AHOY" "ahoy skill is not internal" - [ ! -e "$ROOT/skills/ahoy" ] || fail "ahoy must not exist in the public installer-facing skills directory" - pass "ahoy is internal, user-invocable, and absent from public skills" -} - -test_ahoy_readme_uses_cross_harness_convention() { - assert_grep 'Claude and grok use the slash form shown here; codex uses the same names with `$`' "$README" \ - "README lost the cross-harness slash and dollar convention" - assert_grep '| `/ahoy`' "$README" "README built-in skills table does not list /ahoy" - pass "README lists ahoy under the shared cross-harness invocation convention" -} - -test_ahoy_owns_only_the_visible_session_recap() { - assert_grep '[`../bearings/SKILL.md`](../bearings/SKILL.md)' "$AHOY" \ - "first-message fallback does not delegate to Bearings by relative pointer" - assert_grep 'If no prior real captain message exists' "$AHOY" \ - "ahoy does not limit Bearings fallback to the first real captain message" - assert_grep 'Bearings alone owns its gathering, artifact, and response contract.' "$AHOY" \ - "ahoy first-message fallback does not delegate to Bearings alone" - assert_grep 'A captain boundary is an ordinary user-role message unless it matches one of the narrow operational exclusions below.' "$AHOY" \ - "ahoy lacks an explicit captain-authored boundary rule" - assert_grep 'Exclude messages that begin with the current U+2063 `FIRSTMATE_OP:` injection prefix.' "$AHOY" \ - "ahoy does not exclude current marked operational injections" - assert_grep 'Exclude legacy bare-marker away-mode injections only when U+2063 is immediately followed by `Supervisor escalate (`.' "$AHOY" \ - "ahoy does not narrowly exclude the legacy away-mode injection shape" - assert_grep 'Exclude the exact legacy unmarked session-start payload ``Run `bin/fm-session-start.sh` now, exactly once, before executing any other instructions.``' "$AHOY" \ - "ahoy does not exclude the legacy unmarked session-start payload" - assert_grep 'quotes or embeds a current operational message after ordinary captain text' "$AHOY" \ - "ahoy lacks quoted-current near-miss protection" - assert_grep 'Apply the current exclusion only when U+2063 `FIRSTMATE_OP:` begins at the first character of the whole message' "$AHOY" \ - "ahoy does not pin the current-prefix whole-message boundary" - assert_grep 'contains ASCII `FIRSTMATE_OP:` without a leading U+2063' "$AHOY" \ - "ahoy lacks ASCII-only near-miss protection" - assert_grep 'Apply the legacy startup exclusion as a literal whole-message match: ``Captain quote: Run `bin/fm-session-start.sh` now, exactly once, before executing any other instructions.`` is a captain boundary.' "$AHOY" \ - "ahoy does not pin the altered-startup behavioral near miss" - assert_grep 'System, developer, tool, watcher, guard, away-mode, and other injected operational messages are not captain messages.' "$AHOY" \ - "ahoy incorrectly treats synthetic operational messages as captain messages" - assert_grep 'The normal recap branch is session-history-only.' "$AHOY" \ - "later ahoy invocation is not explicitly session-history-only" - assert_grep 'Do not call Bearings, shell commands, fleet snapshots, status readers, GitHub or browser APIs, tools, or file reads or writes.' "$AHOY" \ - "normal recap does not prohibit fresh fleet, file, and tool reads" - assert_grep 'Create no report, persist nothing' "$AHOY" \ - "normal recap does not prohibit artifacts and storage" - assert_grep 'do not guess current live state beyond the last visible event' "$AHOY" \ - "normal recap may falsely claim a live snapshot" - assert_grep 'The current `/ahoy` message is outside the recap interval.' "$AHOY" \ - "current ahoy invocation is not excluded from the recap interval" - assert_grep 'If context compaction makes the prior boundary unavailable' "$AHOY" \ - "ahoy does not disclose an unavailable compacted boundary" - assert_grep 'summarize only visibly supported events' "$AHOY" \ - "compacted fallback may invent unsupported events" - assert_no_grep 'fm-bearings-snapshot.sh' "$AHOY" \ - "ahoy copied Bearings gathering mechanics instead of referencing its owner" - assert_no_grep "Captain's Call" "$AHOY" \ - "ahoy copied Bearings response contract instead of referencing its owner" - pass "ahoy delegates first-message fallback and keeps later recaps visible-session-only" -} - -test_ahoy_scans_visible_history_for_open_decisions() { - assert_grep 'preserve the ordinary recap interval: recap what happened after that message and before the current invocation.' "$AHOY" \ - "ahoy no longer preserves its ordinary recap interval" - assert_grep 'inspect the entire session history visible to the current first mate before the current invocation for every explicit captain decision that remains unanswered' "$AHOY" \ - "ahoy does not scan globally visible session history for open decisions" - assert_grep 'including decisions raised before the ordinary recap boundary.' "$AHOY" \ - "ahoy does not include open decisions from before the recap boundary" - assert_grep 'A later unrelated captain message establishes a recap boundary but does not close an earlier decision.' "$AHOY" \ - "ahoy lets unrelated captain messages close earlier decisions" - assert_grep 'Treat a decision as closed only when a later visible response substantively resolves it, chooses an option, declines it, grants or denies the requested approval, or otherwise directly addresses that decision.' "$AHOY" \ - "ahoy lacks substantive-answer closure semantics" - assert_grep 'Include every visibly supported open decision once, and deduplicate by the decision' "$AHOY" \ - "ahoy does not include and deduplicate visibly open decisions" - assert_grep "substance when the ordinary interval recap already represents it or its wording differs." "$AHOY" \ - "ahoy deduplicates decisions by wording instead of substance" - assert_grep 'If no ordinary events occurred after the previous captain message but an older visibly open decision exists, report that decision instead of claiming nothing happened.' "$AHOY" \ - "ahoy can incorrectly claim nothing happened while an older decision is open" - assert_grep 'Compacted history supports an open decision only when both its request and its still-unanswered status are visible' "$AHOY" \ - "ahoy does not limit compacted decision reporting to visible support" - assert_grep 'report uncertainty instead of reconstructing hidden requests or answers.' "$AHOY" \ - "ahoy may reconstruct hidden decision history after compaction" - pass "ahoy adds visibly open decisions without changing the ordinary recap boundary" -} - -test_ahoy_user_role_injections_share_one_marker() { - local daemon grok_guard opencode_guard opencode_watch pi_guard pi_watch owner sessionstart spawn - daemon=$(cat "$ROOT/bin/fm-supervise-daemon.sh") - grok_guard=$(cat "$ROOT/bin/fm-turnend-guard-grok.sh") - opencode_guard=$(cat "$ROOT/.opencode/plugins/fm-primary-turnend-guard.js") - opencode_watch=$(cat "$ROOT/.opencode/plugins/fm-primary-watch-arm.js") - pi_guard=$(cat "$ROOT/.pi/extensions/fm-primary-turnend-guard.ts") - pi_watch=$(cat "$ROOT/.pi/extensions/fm-primary-pi-watch.ts") - owner=$(cat "$ROOT/bin/fm-operational-input.sh") - sessionstart=$(cat "$ROOT/bin/fm-sessionstart-nudge.sh") - spawn=$(cat "$ROOT/bin/fm-spawn.sh") - - assert_contains "$owner" 'FM_OPERATIONAL_PREFIX="${FM_OPERATIONAL_MARK}FIRSTMATE_OP: "' \ - "canonical owner lost the landed Ahoy prefix" - assert_contains "$sessionstart" 'fm_operational_input_encode session-start' \ - "session-start does not use the canonical typed constructor" - assert_contains "$daemon" 'fm_operational_input_encode away-supervisor' \ - "away-mode does not use the canonical typed constructor" - assert_contains "$grok_guard" 'fm_operational_input_encode turn-end-guard' \ - "Grok guard does not use the canonical typed constructor" - assert_contains "$opencode_guard" 'encodeFirstmateOperationalInput(' \ - "OpenCode guard does not use the cross-language constructor" - assert_contains "$opencode_guard" '"turn-end-guard"' \ - "OpenCode guard does not retain its exact current kind" - assert_contains "$opencode_watch" 'encodeFirstmateOperationalInput(paths.root, "watcher"' \ - "OpenCode watcher does not retain its exact current kind" - assert_contains "$pi_guard" 'encodeFirstmateOperationalInput(' \ - "Pi guard does not use the cross-language constructor" - assert_contains "$pi_guard" '"turn-end-guard"' \ - "Pi guard does not retain its exact current kind" - assert_contains "$pi_watch" '"watcher"' \ - "Pi watcher does not retain its exact current kind" - assert_contains "$spawn" 'encode launch-brief' \ - "cross-harness launches do not use the canonical launch-instruction kind" - for producer in "$daemon" "$grok_guard" "$opencode_guard" "$opencode_watch" "$pi_guard" "$pi_watch" "$sessionstart" "$spawn"; do - assert_not_contains "$producer" 'FIRSTMATE_OP: ' \ - "a current producer copied the canonical marker grammar" - done - pass "ahoy: one canonical owner constructs typed operational input for every Firstmate-controlled user-role producer" -} - -test_section_9_owns_positive_translation_contract -test_scout_remains_allowed_house_vocabulary -test_compressed_safety_labels_have_plain_renderings -test_mapping_list_covers_high_risk_internal_families -test_verbatim_internal_evidence_is_rejected_from_chat -test_routine_no_action_response_is_event_scoped -test_outward_facing_skill_points_reference_section_9_owner -test_section_9_owner_is_not_duplicated_into_skills -test_ahoy_is_an_internal_user_invocable_skill -test_ahoy_readme_uses_cross_harness_convention -test_ahoy_owns_only_the_visible_session_recap -test_ahoy_scans_visible_history_for_open_decisions -test_ahoy_user_role_injections_share_one_marker diff --git a/tests/fm-cd-pretool-check.test.sh b/tests/fm-cd-pretool-check.test.sh index f623430b13..80f8c03fc9 100755 --- a/tests/fm-cd-pretool-check.test.sh +++ b/tests/fm-cd-pretool-check.test.sh @@ -372,68 +372,6 @@ test_policy_cli_direct() { # --- per-harness wiring ----------------------------------------------------- -test_claude_wiring() { - local settings n - settings="$ROOT/.claude/settings.json" - [ -f "$settings" ] || fail "tracked .claude/settings.json is missing" - n=$(jq -r '[.hooks.PreToolUse[0].hooks[].command | select(contains("fm-cd-pretool-check.sh"))] | length' "$settings") - [ "$n" = 1 ] || fail "claude PreToolUse must invoke fm-cd-pretool-check.sh exactly once" - jq -e '[.hooks.PreToolUse[0].hooks[].command | select(contains("fm-cd-pretool-check.sh") and contains("--claude") and contains("CLAUDE_PROJECT_DIR"))] | length == 1' "$settings" >/dev/null \ - || fail "claude cd hook must use CLAUDE_PROJECT_DIR and --claude" - jq -e '[.hooks.PreToolUse[0].hooks[].command | select(contains("fm-arm-pretool-check.sh"))] | length == 1' "$settings" >/dev/null \ - || fail "claude cd hook must not displace the watcher-arm hook" - pass ".claude/settings.json: PreToolUse invokes the cd-guard alongside the arm guard" -} - -test_codex_wiring() { - local settings command - settings="$ROOT/.codex/hooks.json" - [ -f "$settings" ] || fail "tracked .codex/hooks.json is missing" - command=$(jq -r '[.hooks.PreToolUse[0].hooks[].command | select(contains("fm-cd-pretool-check.sh"))][0] // empty' "$settings") - [ -n "$command" ] || fail "codex PreToolUse must invoke fm-cd-pretool-check.sh" - assert_contains "$command" 'pwd -P' "codex cd hook must anchor from the hook process working directory" - assert_contains "$command" 'fm-cd-pretool-check.sh' "codex cd hook must invoke the cd-guard" - jq -e '[.hooks.PreToolUse[0].hooks[].command | select(contains("fm-arm-pretool-check.sh"))] | length == 1' "$settings" >/dev/null \ - || fail "codex cd hook must not displace the watcher-arm hook" - pass ".codex/hooks.json: PreToolUse invokes the cd-guard alongside the arm guard" -} - -test_grok_wiring() { - local settings command - settings="$ROOT/.grok/hooks/fm-primary-cd-check.json" - [ -f "$settings" ] || fail "tracked grok cd hook config is missing" - command=$(jq -r '.hooks.PreToolUse[0].hooks[0].command // empty' "$settings") - [ -n "$command" ] || fail "grok cd hook command is missing" - assert_contains "$command" 'GROK_WORKSPACE_ROOT' "grok cd hook must anchor from GROK_WORKSPACE_ROOT" - assert_contains "$command" 'fm-cd-pretool-check.sh' "grok cd hook must invoke the cd-guard" - assert_contains "$command" '${GROK_WORKSPACE_ROOT:-}' "grok cd hook must default-guard the workspace var" - pass ".grok primary cd hook: PreToolUse invokes the cd-guard" -} - -test_opencode_wiring() { - local plugin content - plugin="$ROOT/.opencode/plugins/fm-primary-cd-check.js" - [ -f "$plugin" ] || fail "tracked OpenCode cd plugin is missing" - content=$(cat "$plugin") - assert_contains "$content" 'tool.execute.before' "OpenCode cd plugin must run before tool execution" - assert_contains "$content" 'fm-cd-pretool-check.sh' "OpenCode cd plugin must invoke the cd-guard" - assert_contains "$content" 'throw new Error' "OpenCode cd plugin must block by throwing" - assert_contains "$content" 'worktree' "OpenCode cd plugin must anchor from the git worktree path" - pass ".opencode cd plugin: tool.execute.before invokes the cd-guard and blocks by throwing" -} - -test_pi_wiring() { - local ext content - ext="$ROOT/.pi/extensions/fm-primary-turnend-guard.ts" - [ -f "$ext" ] || fail "tracked pi primary extension is missing" - content=$(cat "$ext") - assert_contains "$content" 'runCdCheck(command)' "pi extension must run the cd check in tool_call" - assert_contains "$content" 'fm-cd-pretool-check.sh' "pi extension must invoke the cd-guard owner" - assert_contains "$content" 'runPretoolCheck(command)' "pi extension must keep running the watcher-arm check" - assert_contains "$content" 'return { block: true, reason:' "pi extension must block on a checker exit 2" - pass ".pi primary extension: tool_call runs the cd-guard alongside the watcher-arm check" -} - test_scripts_are_shellcheck_clean() { command -v shellcheck >/dev/null 2>&1 || { pass "shellcheck not installed, skipping"; return; } shellcheck "$ROOT/bin/fm-cd-pretool-check.sh" >/dev/null 2>&1 \ @@ -453,9 +391,4 @@ test_fail_open_missing_node test_fail_open_missing_jq_on_stdin test_prefilter_skips_node_without_cd_substring test_policy_cli_direct -test_claude_wiring -test_codex_wiring -test_grok_wiring -test_opencode_wiring -test_pi_wiring test_scripts_are_shellcheck_clean diff --git a/tests/fm-claude-stop-autoarm.test.sh b/tests/fm-claude-stop-autoarm.test.sh index 63ddb8a7b1..6be8bc1533 100755 --- a/tests/fm-claude-stop-autoarm.test.sh +++ b/tests/fm-claude-stop-autoarm.test.sh @@ -20,6 +20,7 @@ fm_git_identity fmtest fmtest@example.invalid FAKEBIN=$(fm_fakebin "$TMP_ROOT/fakebin") ln -s /bin/bash "$FAKEBIN/claude" FAKE_CLAUDE="$FAKEBIN/claude" +export FAKE_CLAUDE # Copy the hook and its sourced dependencies into a fixture checkout. install_autoarm_scripts() { @@ -149,28 +150,6 @@ epoch_outcome() { # --- registration contract ---------------------------------------------------- -test_settings_registers_autoarm_with_multi_hour_timeout() { - local settings - settings="$ROOT/.claude/settings.json" - jq -e ' - [.hooks.Stop[].hooks[] | select(.command | contains("fm-claude-stop-autoarm.sh"))] - | length == 1 - ' "$settings" >/dev/null || fail "settings must register exactly one Stop auto-arm hook" - jq -e ' - [.hooks.Stop[].hooks[] | select(.command | contains("fm-claude-stop-autoarm.sh"))][0] - | .asyncRewake == true and .type == "command" and (.timeout | type == "number" and . >= 28800) - ' "$settings" >/dev/null || fail "auto-arm must be asyncRewake with an explicit timeout of at least 28800s (the 600s default is forbidden)" - jq -e ' - [.hooks.Stop[].hooks[] | select(.command | contains("fm-claude-stop-autoarm.sh"))][0].command - | contains("&") | not - ' "$settings" >/dev/null || fail "auto-arm registration must not use shell fire-and-forget" - grep -q '"$SCRIPT_DIR/fm-watch-arm.sh" >"$OUT" 2>&1' "$ROOT/bin/fm-claude-stop-autoarm.sh" \ - || fail "auto-arm must foreground the arm wrapper inside the hook-owned process tree" - grep -q 'asyncRewake' "$ROOT/bin/fm-claude-stop-autoarm.sh" \ - || fail "auto-arm header must document its asyncRewake registration contract" - pass "settings.json registers the asyncRewake auto-arm with timeout >= 28800 and a foreground arm" -} - # --- scope and gates ---------------------------------------------------------- test_inert_in_child_worktree() { @@ -274,6 +253,37 @@ test_stale_lock_recovery_preserves_afk_and_need_gates() { pass "auto-arm: stale-owner recovery leaves the AFK and supervision-need gates unchanged" } +test_resolves_outermost_claude_pid_in_nested_bgspare_chain() { + local dir out status inner_pid lock_pid + dir=$(make_primary_dir "$TMP_ROOT/nested-chain") + : > "$dir/state/task.meta" + write_arm_fixture "$dir" actionable + # A genuine multi-level contiguous claude-named ancestry: the hook fires + # inside an inner fake-claude process (its recorded pid is distinct from its + # own parent, a second, outer fake-claude process holding the session lock - + # the bg-spare shape). Only the outer pid may own the lock; a + # first-match-wins walk would resolve to the inner pid instead and leave the + # hook inert. The inner process records its own pid before running the hook + # so bash cannot tail-exec-collapse it into the outer pid, which would + # collapse the two-hop chain this test depends on down to one hop. + out=$(printf '%s\n' '{"session_id":"nested"}' \ + | FM_HOME="$dir" "$FAKE_CLAUDE" -c ' + printf "%s\n" "$$" > "$FM_HOME/state/.lock" + "$FAKE_CLAUDE" -c " + printf \"%s\n\" \"\$\$\" > \"\$FM_HOME/state/inner-pid\" + \"\$FM_HOME/bin/fm-claude-stop-autoarm.sh\" + " + ' 2>&1); status=$? + inner_pid=$(cat "$dir/state/inner-pid" 2>/dev/null || true) + lock_pid=$(cat "$dir/state/.lock" 2>/dev/null || true) + [ -n "$inner_pid" ] && [ "$inner_pid" != "$lock_pid" ] \ + || fail "test setup did not produce a genuine two-hop claude chain: inner=$inner_pid lock=$lock_pid" + expect_code 2 "$status" "a nested contiguous claude ancestry must resolve to the outer lock-owning pid and arm" + [ -e "$dir/state/arm-ran" ] || fail "hook did not resolve past the inner claude-named process to the outer lock owner" + [ "$(epoch_outcome "$dir")" = rewake ] || fail "nested-chain arm must record outcome=rewake" + pass "auto-arm: resolves the outermost pid of a nested contiguous claude ancestry (bg-spare chain)" +} + test_inert_when_fleet_idle() { local dir out status dir=$(make_primary_dir "$TMP_ROOT/idle") @@ -406,13 +416,13 @@ test_fm_lock_status_still_works_with_shared_lib() { pass "fm-lock: shared session-lock lib preserves the status path" } -test_settings_registers_autoarm_with_multi_hour_timeout test_inert_in_child_worktree test_inert_without_session_lock test_reclaims_stale_session_lock_before_arming test_inert_when_lock_held_by_other_harness test_inert_when_afk test_stale_lock_recovery_preserves_afk_and_need_gates +test_resolves_outermost_claude_pid_in_nested_bgspare_chain test_inert_when_fleet_idle test_actionable_close_rewakes_with_reason test_failed_close_rewakes_with_failure_banner diff --git a/tests/fm-crew-state.test.sh b/tests/fm-crew-state.test.sh index bc0161d624..8f986b6139 100755 --- a/tests/fm-crew-state.test.sh +++ b/tests/fm-crew-state.test.sh @@ -5,7 +5,7 @@ # The status file (state/<id>.status) is a best-effort append-only EVENT LOG, so # `tail -1` of it reports the last event, not the current state. fm-crew-state # reads the AUTHORITATIVE source (a matching no-mistakes run-step, else the -# pane busy-signature) and reconciles the possibly-stale log against it. These +# semantic busy-state contract) and reconciles the possibly-stale log against it. These # cases pin every branch of that logic, hermetically, over real throwaway git # repos with a fake `no-mistakes` (run-step source) and a fake `tmux` (pane # source): @@ -14,8 +14,8 @@ # (c) genuine parked run + needs-decision log = NOT superseded -> run-step # (d) terminal run-step (passed/failed) is authoritative -> run-step # (e) cross-branch attribution: this branch's own run found via list lookup -# (f) no run + busy pane -> pane -# (g) no run + idle pane falls to the status-log verb -> status-log +# (f) no run + semantic busy -> pane +# (g) no run + semantic idle falls to the status-log verb -> status-log # (h) dead pane: no run -> unknown/none; with a run -> run-step (not the shell) # (i) kind=scout skips the run lookup -> pane/status-log # (j) torn-down worktree / missing meta -> unknown/none @@ -88,7 +88,7 @@ case "${1:-}" in printf '%%1\n' ;; capture-pane) [ "${FM_FAKE_TMUX_MISSING:-0}" = 1 ] && exit 1 - if [ "${FM_FAKE_BUSY:-0}" = 1 ]; then printf 'work in progress\nesc to interrupt\n' + if [ "${FM_FAKE_BUSY:-0}" = 1 ]; then printf 'work in progress\n%s\n' "${FM_FAKE_BUSY_TEXT:-esc to interrupt}" else printf 'all quiet\n> \n'; fi ;; esac exit 0 @@ -149,6 +149,13 @@ new_case() { # <name> -> echoes case dir with an empty state/ printf '%s\n' "$d" } +arm_idle_record() { # <state-dir> <id> + local state=$1 id=$2 gen + gen=$("$ROOT/bin/fm-busy-event.sh" arm "$state" "$id") + "$ROOT/bin/fm-busy-event.sh" apply "$state" "$id" idle --gen "$gen" \ + --source claude-hook --event stop +} + # Clear the fake-driver vars and (re-)mark them exported, so the per-test plain # assignments below stay exported into the fakes without an `export VAR=$(...)` # command-substitution assignment (SC2155). @@ -157,12 +164,13 @@ reset_fakes() { FM_FAKE_AXI_STATUS_RUN="" FM_FAKE_RUNS_LIST="" FM_FAKE_BUSY=0 + FM_FAKE_BUSY_TEXT= FM_FAKE_TMUX_MISSING=0 FM_FAKE_HERDR_BUSY=0 FM_FAKE_HERDR_MISSING=0 FM_FAKE_HERDR_AGENT_STATUS="" FM_FAKE_CI_LOGS="" - export FM_FAKE_AXI_STATUS FM_FAKE_AXI_STATUS_RUN FM_FAKE_RUNS_LIST FM_FAKE_BUSY FM_FAKE_TMUX_MISSING + export FM_FAKE_AXI_STATUS FM_FAKE_AXI_STATUS_RUN FM_FAKE_RUNS_LIST FM_FAKE_BUSY FM_FAKE_BUSY_TEXT FM_FAKE_TMUX_MISSING export FM_FAKE_HERDR_BUSY FM_FAKE_HERDR_MISSING FM_FAKE_HERDR_AGENT_STATUS FM_FAKE_CI_LOGS } @@ -759,7 +767,7 @@ test_other_branch_run_ignored() { local d; d=$(new_case otherbranch) make_repo_on_branch "$d/wt" fm/feat-g make_fakebin "$d" >/dev/null - fm_write_meta "$d/state/feat-g.meta" "window=fm:fm-feat-g" "worktree=$d/wt" "kind=ship" + fm_write_meta "$d/state/feat-g.meta" "window=fm:fm-feat-g" "worktree=$d/wt" "kind=ship" "harness=claude" printf 'done: implemented, ready to validate\n' > "$d/state/feat-g.status" FM_FAKE_AXI_STATUS="$(run_running fm/some-other)" FM_FAKE_RUNS_LIST="$(cat <<'EOF' @@ -767,6 +775,7 @@ test_other_branch_run_ignored() { EOF )" FM_FAKE_BUSY=0 + arm_idle_record "$d/state" feat-g local out; out=$(run_crew_state "$d" feat-g) assert_not_contains "$out" "source: run-step" "another branch's run not misattributed" assert_contains "$out" "source: status-log" "no own run -> falls back to status-log" @@ -780,15 +789,60 @@ test_no_run_busy_pane() { local d; d=$(new_case busy) make_repo_on_branch "$d/wt" fm/feat-h make_fakebin "$d" >/dev/null - fm_write_meta "$d/state/feat-h.meta" "window=fm:fm-feat-h" "worktree=$d/wt" "kind=ship" - # No matching run anywhere. + fm_write_meta "$d/state/feat-h.meta" "window=fm:fm-feat-h" "worktree=$d/wt" "kind=ship" "harness=claude" + # No matching run anywhere. The busy verdict comes from the crew's own + # semantic lifecycle record (bin/fm-busy-lib.sh), not from rendered text. FM_FAKE_AXI_STATUS="" FM_FAKE_RUNS_LIST="" FM_FAKE_BUSY=1 + local gen; gen=$("$ROOT/bin/fm-busy-event.sh" arm "$d/state" feat-h) + "$ROOT/bin/fm-busy-event.sh" apply "$d/state" feat-h busy --gen "$gen" \ + --source claude-hook --event user-prompt-submit local out; out=$(run_crew_state "$d" feat-h) - assert_contains "$out" "state: working" "busy pane -> working" - assert_contains "$out" "source: pane" "busy pane -> pane source" - pass "no run + busy pane reads working from the pane" + assert_contains "$out" "state: working" "busy record -> working" + assert_contains "$out" "source: pane" "busy record -> pane source" + assert_contains "$out" "claude-hook" "the working verdict names its semantic source" + pass "no run + a busy semantic record reads working, attributed to its source" +} + +# A converted adapter must NOT read working from rendered footer text: the +# redesign removed that dependency, so a pane painting "esc to interrupt" with +# no semantic record is unknown, never working and never silently idle. +test_no_run_footer_text_alone_is_not_working() { + reset_fakes + local d; d=$(new_case busy-footer-only) + make_repo_on_branch "$d/wt" fm/feat-h2 + make_fakebin "$d" >/dev/null + fm_write_meta "$d/state/feat-h2.meta" "window=fm:fm-feat-h2" "worktree=$d/wt" "kind=ship" "harness=claude" + FM_FAKE_AXI_STATUS="" + FM_FAKE_RUNS_LIST="" + FM_FAKE_BUSY=1 + printf 'done: stale completion event\n' > "$d/state/feat-h2.status" + local out; out=$(run_crew_state "$d" feat-h2) + assert_not_contains "$out" "state: working" "a footer alone must not read working for a converted adapter" + assert_contains "$out" "state: unknown" "no semantic record -> unknown" + assert_not_contains "$out" "source: status-log" "unknown semantic state must not fall through to a stale log" + pass "a converted adapter never reads working from rendered footer text" +} + +# Grok keeps its isolated temporary rendered-tail fallback until its structured +# lifecycle is live-verified, so a grok crew still reads working from its own +# verified signature. +test_no_run_grok_uses_isolated_fallback() { + reset_fakes + local d; d=$(new_case busy-grok) + make_repo_on_branch "$d/wt" fm/feat-h3 + make_fakebin "$d" >/dev/null + fm_write_meta "$d/state/feat-h3.meta" "window=fm:fm-feat-h3" "worktree=$d/wt" "kind=ship" "harness=grok" + FM_FAKE_AXI_STATUS="" + FM_FAKE_RUNS_LIST="" + FM_FAKE_BUSY=1 + FM_FAKE_BUSY_TEXT='Ctrl+c:cancel' + export FM_FAKE_BUSY_TEXT + local out; out=$(run_crew_state "$d" feat-h3) + assert_contains "$out" "state: working" "grok busy tail -> working" + assert_contains "$out" "grok-regex" "the grok verdict names its isolated fallback source" + pass "grok still reads working through its isolated rendered-tail fallback" } test_no_run_herdr_unknown_uses_backend_capture() { @@ -797,68 +851,76 @@ test_no_run_herdr_unknown_uses_backend_capture() { local d; d=$(new_case herdr-busy) make_repo_on_branch "$d/wt" fm/feat-herdr make_fakebin "$d" >/dev/null - fm_write_meta "$d/state/feat-herdr.meta" "window=default:w1:p2" "worktree=$d/wt" "kind=ship" "backend=herdr" + fm_write_meta "$d/state/feat-herdr.meta" "window=default:w1:p2" "worktree=$d/wt" "kind=ship" \ + "backend=herdr" "harness=claude" FM_FAKE_AXI_STATUS="" FM_FAKE_RUNS_LIST="" FM_FAKE_TMUX_MISSING=1 FM_FAKE_HERDR_BUSY=1 - FM_FAKE_HERDR_AGENT_STATUS="" + FM_FAKE_HERDR_AGENT_STATUS=working local out; out=$(run_crew_state "$d" feat-herdr) - assert_contains "$out" "state: working" "herdr busy pane -> working" - assert_contains "$out" "source: pane" "herdr busy pane -> pane source" - pass "herdr unknown native state falls back to backend capture busy regex" -} - -# Regression: herdr's agent.get reports generation state ("working" only while -# the model is actively streaming a turn - docs/herdr-backend.md "Busy state"), -# not "this crew's tool call is still in progress". A crew blocked on its own -# long-running foreground `no-mistakes axi run` (no --yes; blocks until a gate -# or outcome) is not generating for that whole span, so agent.get can read -# idle while the pane's own rendered text still shows the busy banner -# (BUSY_REGEX) for the entire call. `idle` must be corroborated with that text -# exactly like `unknown` already is, not trusted outright - the bug this -# regression pins: crew_pane_is_busy previously returned "not busy" on a bare -# `idle` verdict without ever looking at the pane. -test_no_run_herdr_idle_agent_status_corroborated_by_busy_pane() { + assert_contains "$out" "state: working" "herdr native busy -> working" + assert_contains "$out" "source: pane" "herdr native busy -> pane source" + assert_contains "$out" "herdr-native" "the herdr verdict names its native source" + pass "herdr's native busy verdict reads working with no record present" +} + +# Regression (2026-07 herdr false-surface incident, now solved semantically): +# herdr's agent.get reports generation state ("working" only while the model is +# actively streaming - docs/herdr-backend.md "Busy state"), not "this crew's +# turn is still in progress". A crew blocked on its own long-running foreground +# `no-mistakes axi run` (no --yes; blocks until a gate or outcome) is not +# generating for that whole span, so agent.get reads idle. The crew's own +# semantic lifecycle record still says busy for the whole turn, and it outranks +# the narrower native verdict - so the crew is no longer misread as not-working. +test_no_run_herdr_idle_agent_status_outranked_by_record() { command -v jq >/dev/null 2>&1 || { pass "herdr idle corroboration skipped without jq"; return; } reset_fakes - local d; d=$(new_case herdr-idle-busy-pane) + local d; d=$(new_case herdr-idle-busy-record) make_repo_on_branch "$d/wt" fm/feat-herdr-idle make_fakebin "$d" >/dev/null - fm_write_meta "$d/state/feat-herdr-idle.meta" "window=default:w1:p3" "worktree=$d/wt" "kind=ship" "backend=herdr" + fm_write_meta "$d/state/feat-herdr-idle.meta" "window=default:w1:p3" "worktree=$d/wt" "kind=ship" \ + "backend=herdr" "harness=claude" # No run attributable (mirrors a no-mistakes run-step lookup that found no - # matching row within the configured runs-list window): the pane fallback is - # the only remaining signal. + # matching row within the configured runs-list window): the crew's semantic + # busy state is the only remaining signal. FM_FAKE_AXI_STATUS="" FM_FAKE_RUNS_LIST="" FM_FAKE_TMUX_MISSING=1 FM_FAKE_HERDR_AGENT_STATUS=idle - FM_FAKE_HERDR_BUSY=1 + FM_FAKE_HERDR_BUSY=0 + local gen; gen=$("$ROOT/bin/fm-busy-event.sh" arm "$d/state" feat-herdr-idle) + "$ROOT/bin/fm-busy-event.sh" apply "$d/state" feat-herdr-idle busy --gen "$gen" \ + --source claude-hook --event user-prompt-submit local out; out=$(run_crew_state "$d" feat-herdr-idle) - assert_contains "$out" "state: working" "herdr idle agent_status with a busy-banner pane -> working" - assert_contains "$out" "source: pane" "herdr idle agent_status with a busy-banner pane -> pane source" - pass "herdr idle agent_status is corroborated by the pane text, not trusted outright" + assert_contains "$out" "state: working" "a busy record with herdr idle agent_status -> working" + assert_contains "$out" "claude-hook" "the record's source outranks herdr's narrower native verdict" + pass "a mid-tool-call crew stays working because its record outranks herdr's generation state" } -# The corroboration must not mask a genuinely idle/human-blocked agent: idle -# agent_status AND an idle-looking pane (no busy banner) still reads not-busy. -test_no_run_herdr_idle_agent_status_and_idle_pane_stays_idle() { - command -v jq >/dev/null 2>&1 || { pass "herdr idle+idle-pane skipped without jq"; return; } +# The record must not mask a genuinely idle or human-blocked agent: an idle +# record with idle agent_status still reads not-busy. +test_no_run_herdr_idle_agent_status_and_idle_record_stays_idle() { + command -v jq >/dev/null 2>&1 || { pass "herdr idle+idle-record skipped without jq"; return; } reset_fakes - local d; d=$(new_case herdr-idle-idle-pane) + local d; d=$(new_case herdr-idle-idle-record) make_repo_on_branch "$d/wt" fm/feat-herdr-stopped make_fakebin "$d" >/dev/null - fm_write_meta "$d/state/feat-herdr-stopped.meta" "window=default:w1:p4" "worktree=$d/wt" "kind=ship" "backend=herdr" + fm_write_meta "$d/state/feat-herdr-stopped.meta" "window=default:w1:p4" "worktree=$d/wt" "kind=ship" \ + "backend=herdr" "harness=claude" printf 'working: implementing\n' > "$d/state/feat-herdr-stopped.status" FM_FAKE_AXI_STATUS="" FM_FAKE_RUNS_LIST="" FM_FAKE_TMUX_MISSING=1 FM_FAKE_HERDR_AGENT_STATUS=idle FM_FAKE_HERDR_BUSY=0 + local gen; gen=$("$ROOT/bin/fm-busy-event.sh" arm "$d/state" feat-herdr-stopped) + "$ROOT/bin/fm-busy-event.sh" apply "$d/state" feat-herdr-stopped idle --gen "$gen" \ + --source claude-hook --event stop local out; out=$(run_crew_state "$d" feat-herdr-stopped) - assert_not_contains "$out" "source: pane" "herdr idle agent_status with an idle pane must not read as busy from the pane" - assert_contains "$out" "source: status-log" "herdr idle agent_status with an idle pane falls to the status log" - pass "herdr idle agent_status with a genuinely idle pane stays not-busy (no regression for a human-blocked agent)" + assert_not_contains "$out" "source: pane" "an idle record must not read as busy" + assert_contains "$out" "source: status-log" "an idle record falls to the status log" + pass "an idle record with idle agent_status stays not-busy (no regression for a human-blocked agent)" } # (g) no run + idle pane -> the status-log verb, as-is @@ -867,10 +929,11 @@ test_no_run_idle_pane_uses_log() { local d; d=$(new_case idle) make_repo_on_branch "$d/wt" fm/feat-i make_fakebin "$d" >/dev/null - fm_write_meta "$d/state/feat-i.meta" "window=fm:fm-feat-i" "worktree=$d/wt" "kind=ship" + fm_write_meta "$d/state/feat-i.meta" "window=fm:fm-feat-i" "worktree=$d/wt" "kind=ship" "harness=claude" printf 'needs-decision: which database?\n' > "$d/state/feat-i.status" FM_FAKE_AXI_STATUS="" FM_FAKE_BUSY=0 + arm_idle_record "$d/state" feat-i local out; out=$(run_crew_state "$d" feat-i) assert_contains "$out" "state: parked" "needs-decision log -> parked" assert_contains "$out" "source: status-log" "idle pane -> status-log source" @@ -882,10 +945,11 @@ test_no_run_idle_pane_uses_keyed_log() { local d; d=$(new_case keyed-idle) make_repo_on_branch "$d/wt" fm/feat-keyed make_fakebin "$d" >/dev/null - fm_write_meta "$d/state/feat-keyed.meta" "window=fm:fm-feat-keyed" "worktree=$d/wt" "kind=ship" + fm_write_meta "$d/state/feat-keyed.meta" "window=fm:fm-feat-keyed" "worktree=$d/wt" "kind=ship" "harness=claude" printf 'needs-decision [key=q1]: which database?\n' > "$d/state/feat-keyed.status" FM_FAKE_AXI_STATUS="" FM_FAKE_BUSY=0 + arm_idle_record "$d/state" feat-keyed local out; out=$(run_crew_state "$d" feat-keyed) assert_contains "$out" "state: parked" "keyed needs-decision log -> parked" assert_contains "$out" "which database?" "key token is excluded from status detail" @@ -900,10 +964,11 @@ test_no_run_idle_pane_paused() { local d; d=$(new_case paused) make_repo_on_branch "$d/wt" fm/feat-pause make_fakebin "$d" >/dev/null - fm_write_meta "$d/state/feat-pause.meta" "window=fm:fm-feat-pause" "worktree=$d/wt" "kind=ship" + fm_write_meta "$d/state/feat-pause.meta" "window=fm:fm-feat-pause" "worktree=$d/wt" "kind=ship" "harness=claude" printf 'paused: holding for the upstream tool release\n' > "$d/state/feat-pause.status" FM_FAKE_AXI_STATUS="" FM_FAKE_BUSY=0 + arm_idle_record "$d/state" feat-pause local out; out=$(run_crew_state "$d" feat-pause) assert_contains "$out" "state: paused" "paused log -> paused" assert_contains "$out" "source: status-log" "idle pause -> status-log source" @@ -916,10 +981,11 @@ test_no_run_idle_pane_custom_paused_verb() { local d; d=$(new_case custom-paused) make_repo_on_branch "$d/wt" fm/feat-custom-pause make_fakebin "$d" >/dev/null - fm_write_meta "$d/state/feat-custom-pause.meta" "window=fm:fm-feat-custom-pause" "worktree=$d/wt" "kind=ship" + fm_write_meta "$d/state/feat-custom-pause.meta" "window=fm:fm-feat-custom-pause" "worktree=$d/wt" "kind=ship" "harness=claude" printf 'awaiting: vendor maintenance window\n' > "$d/state/feat-custom-pause.status" FM_FAKE_AXI_STATUS="" FM_FAKE_BUSY=0 + arm_idle_record "$d/state" feat-custom-pause local out; out=$(FM_CLASSIFY_PAUSED_VERB=awaiting run_crew_state "$d" feat-custom-pause) assert_contains "$out" "state: paused" "custom paused verb -> paused" assert_contains "$out" "source: status-log" "custom paused verb -> status-log source" @@ -1033,8 +1099,12 @@ while :; do :; done SH chmod +x "$d/fakebin/no-mistakes" toolbin=$(make_no_timeout_toolbin "$d") - fm_write_meta "$d/state/feat-timeout.meta" "window=fm:fm-feat-timeout" "worktree=$d/wt" "kind=ship" + fm_write_meta "$d/state/feat-timeout.meta" "window=fm:fm-feat-timeout" "worktree=$d/wt" "kind=ship" \ + "harness=claude" FM_FAKE_BUSY=1 + local gen; gen=$("$ROOT/bin/fm-busy-event.sh" arm "$d/state" feat-timeout) + "$ROOT/bin/fm-busy-event.sh" apply "$d/state" feat-timeout busy --gen "$gen" \ + --source claude-hook --event user-prompt-submit start=$SECONDS out=$(FM_FAKE_NM_CALLS="$calls_file" PATH="$d/fakebin:$toolbin" FM_STATE_OVERRIDE="$d/state" FM_CREW_STATE_NM_TIMEOUT=1 "$CREW_STATE" feat-timeout) elapsed=$((SECONDS - start)) @@ -1052,13 +1122,17 @@ test_scout_skips_run_lookup() { local d; d=$(new_case scout) make_repo_on_branch "$d/wt" fm/scout-j make_fakebin "$d" >/dev/null - fm_write_meta "$d/state/scout-j.meta" "window=fm:fm-scout-j" "worktree=$d/wt" "kind=scout" + fm_write_meta "$d/state/scout-j.meta" "window=fm:fm-scout-j" "worktree=$d/wt" "kind=scout" \ + "harness=claude" # Even if a run existed on this branch, a scout must not read it. FM_FAKE_AXI_STATUS="$(run_running fm/scout-j)" FM_FAKE_BUSY=1 + local gen; gen=$("$ROOT/bin/fm-busy-event.sh" arm "$d/state" scout-j) + "$ROOT/bin/fm-busy-event.sh" apply "$d/state" scout-j busy --gen "$gen" \ + --source claude-hook --event user-prompt-submit local out; out=$(run_crew_state "$d" scout-j) assert_not_contains "$out" "source: run-step" "scout ignores no-mistakes run-step" - assert_contains "$out" "source: pane" "scout reads pane busy-signature" + assert_contains "$out" "source: pane" "scout reads its semantic busy state" pass "scout skips the run lookup" } @@ -1157,12 +1231,13 @@ test_historical_same_branch_rewritten_head_not_current() { new_head=$(git -C "$d/wt" rev-parse HEAD) [ "$old_head" != "$new_head" ] || fail "rewrite did not produce a new head" make_fakebin "$d" >/dev/null - fm_write_meta "$d/state/wishlist.meta" "window=fm:fm-wishlist" "worktree=$d/wt" "kind=ship" + fm_write_meta "$d/state/wishlist.meta" "window=fm:fm-wishlist" "worktree=$d/wt" "kind=ship" "harness=claude" printf 'working: stage 2 setup complete rebased onto merged #76\n' > "$d/state/wishlist.status" # Historical run still reports the pre-rewrite head on the reused branch. FM_FAKE_RUN_HEAD="$old_head" FM_FAKE_AXI_STATUS="$(run_parked fm/todo-flag)" FM_FAKE_BUSY=0 + arm_idle_record "$d/state" wishlist out=$(run_crew_state "$d" wishlist) assert_not_contains "$out" "source: run-step" "historical rewritten head must not use run-step" assert_not_contains "$out" "parked at" "historical parked run must not mask current state" @@ -1202,11 +1277,12 @@ test_local_advanced_past_run_head_invalidates() { run_head=$(git -C "$d/wt" rev-parse HEAD) git -C "$d/wt" commit -q --allow-empty -m 'local stage-2 work after prior run' make_fakebin "$d" >/dev/null - fm_write_meta "$d/state/adv.meta" "window=fm:fm-adv" "worktree=$d/wt" "kind=ship" + fm_write_meta "$d/state/adv.meta" "window=fm:fm-adv" "worktree=$d/wt" "kind=ship" "harness=claude" printf 'working: stage 2 implementation in progress\n' > "$d/state/adv.status" FM_FAKE_RUN_HEAD="$run_head" FM_FAKE_AXI_STATUS="$(run_parked fm/feat-adv)" FM_FAKE_BUSY=0 + arm_idle_record "$d/state" adv out=$(run_crew_state "$d" adv) assert_not_contains "$out" "source: run-step" "local-advanced tip must not use historical run" assert_contains "$out" "source: status-log" "falls back after local advanced past run" @@ -1220,11 +1296,12 @@ test_missing_run_head_falls_back_to_current_state() { d=$(new_case missing-run-head) make_repo_on_branch "$d/wt" fm/feat-no-head make_fakebin "$d" >/dev/null - fm_write_meta "$d/state/no-head.meta" "window=fm:fm-no-head" "worktree=$d/wt" "kind=ship" + fm_write_meta "$d/state/no-head.meta" "window=fm:fm-no-head" "worktree=$d/wt" "kind=ship" "harness=claude" printf 'working: current stage still in progress\n' > "$d/state/no-head.status" FM_FAKE_AXI_STATUS=$(run_parked fm/feat-no-head | grep -v '^ head:') FM_FAKE_RUNS_LIST="" FM_FAKE_BUSY=0 + arm_idle_record "$d/state" no-head out=$(run_crew_state "$d" no-head) assert_not_contains "$out" "source: run-step" "missing run head must not permit branch-only attribution" assert_contains "$out" "source: status-log" "missing run head falls back to current state sources" @@ -1257,9 +1334,11 @@ test_cross_branch_attribution_picks_most_recent_row test_coarse_run_does_not_probe_other_branch_ci_log_for_ready_status test_other_branch_run_ignored test_no_run_busy_pane +test_no_run_footer_text_alone_is_not_working +test_no_run_grok_uses_isolated_fallback test_no_run_herdr_unknown_uses_backend_capture -test_no_run_herdr_idle_agent_status_corroborated_by_busy_pane -test_no_run_herdr_idle_agent_status_and_idle_pane_stays_idle +test_no_run_herdr_idle_agent_status_outranked_by_record +test_no_run_herdr_idle_agent_status_and_idle_record_stays_idle test_no_run_idle_pane_uses_log test_no_run_idle_pane_uses_keyed_log test_no_run_idle_pane_paused diff --git a/tests/fm-daemon.test.sh b/tests/fm-daemon.test.sh index 90b32a6ef9..0cadb5af1f 100755 --- a/tests/fm-daemon.test.sh +++ b/tests/fm-daemon.test.sh @@ -22,6 +22,8 @@ if [ -z "${FM_TEST_DAEMON_SOURCED:-}" ]; then fi TMP_ROOT=$(fm_test_tmproot fm-daemon-tests) +FM_DAEMON_PRIMARY_HARNESS=claude +export FM_DAEMON_PRIMARY_HARNESS test_afk_start_refuses_when_flag_cannot_be_written() { local dir state out status @@ -138,6 +140,57 @@ test_stale_transient_self_records_marker() { pass "transient stale self-handles and records a persistence marker" } +test_stale_diagnostic_wedge_survives_busy_housekeeping() { + local case_name dir state fakebin key task win pane reason status_line action_log + for case_name in working prior-terminal paused; do + dir=$(make_supercase "stale-diagnostic-$case_name") + state="$dir/state" + fakebin="$dir/fakebin" + task="suffix-$case_name" + win="sess:fm-$task" + pane="$dir/pane.txt" + action_log="$dir/actions.log" + reason="stale: $win (idle 500s, possible wedge, escalation 3, demand-deep-inspection: same pane has wedge-escalated 3 times in a row - do not re-absorb on the run-step/pane state alone)" + fm_write_meta "$state/$task.meta" "window=$win" "backend=tmux" + case "$case_name" in + working) status_line='working: building' ;; + prior-terminal) status_line='done: already surfaced' ;; + paused) status_line='paused: awaiting an external dependency' ;; + esac + printf '%s\n' "$status_line" > "$state/$task.status" + printf 'Working...\n' > "$pane" + key=$(printf '%s' "$task" | tr ':/.' '___') + echo $(( $(date +%s) - 500 )) > "$state/.subsuper-stale-$key" + [ "$case_name" = prior-terminal ] \ + && printf '%s' "$status_line" > "$state/.subsuper-seen-status-$key" + [ "$case_name" = paused ] \ + && echo $(( $(date +%s) - 500 )) > "$state/.subsuper-paused-$key" + + ( + kill() { printf 'kill %s\n' "$*" >> "$action_log"; } + fm_backend_send_text_submit() { printf 'interrupt %s\n' "$*" >> "$action_log"; } + LOG="$dir/daemon.log" FM_STATE_OVERRIDE="$state" handle_wake "$reason" "$state" + PATH="$fakebin:$PATH" FM_FAKE_TMUX_WINDOW="$win" FM_FAKE_TMUX_CAPTURE="$pane" \ + FM_STATE_OVERRIDE="$state" FM_ESCALATE_BATCH_SECS=999999 housekeeping "$state" + ) + [ "$(wc -l < "$state/.subsuper-escalations" | tr -d ' ')" = 1 ] \ + || fail "$case_name enriched wedge did not produce exactly one escalation" + grep -F "${reason#stale: }" "$state/.subsuper-escalations" >/dev/null \ + || fail "$case_name enriched wedge lost its demand-deep-inspection detail" + [ ! -e "$state/.subsuper-stale-$key" ] \ + || fail "$case_name enriched wedge retained ordinary stale tracking" + case "$case_name" in + paused) [ -e "$state/.subsuper-paused-$key" ] \ + || fail "paused enriched wedge erased ordinary pause tracking" ;; + *) [ ! -e "$state/.subsuper-paused-$key" ] \ + || fail "$case_name enriched wedge created pause tracking" ;; + esac + [ ! -s "$action_log" ] \ + || fail "$case_name enriched wedge interrupted or killed the busy worker" + done + pass "enriched stale wedges bypass status absorption without disturbing busy workers" +} + test_stale_terminal_escalates() { local dir state out dir=$(make_supercase stale-terminal) @@ -305,6 +358,10 @@ test_housekeeping_paused_resumed_cleared() { win="sess:fm-held-w12"; pane="$dir/pane.txt" printf 'paused: holding for the upstream tool release\n' > "$state/held-w12.status" printf 'Working...\n' > "$pane" + fm_write_meta "$state/held-w12.meta" "window=$win" "worktree=$dir/wt" "kind=ship" "harness=pi" + local gen; gen=$("$ROOT/bin/fm-busy-event.sh" arm "$state" held-w12) + "$ROOT/bin/fm-busy-event.sh" apply "$state" held-w12 busy --gen "$gen" \ + --source pi-ext --event agent-start key=$(printf '%s' "held-w12" | tr ':/.' '___') echo $(( $(date +%s) - 5000 )) > "$state/.subsuper-paused-$key" PATH="$fakebin:$PATH" FM_FAKE_TMUX_WINDOW="$win" FM_FAKE_TMUX_CAPTURE="$pane" \ @@ -392,6 +449,12 @@ test_housekeeping_resumed_stale_cleared() { pane="$dir/pane.txt" printf 'working\n' > "$state/res-w6.status" printf 'Working...\n' > "$pane" + # A resumed crew proves it is working through its own semantic busy-state + # record (bin/fm-busy-lib.sh), not through the pane's rendered footer. + fm_write_meta "$state/res-w6.meta" "window=$win" "worktree=$dir/wt" "kind=ship" "harness=pi" + local gen; gen=$("$ROOT/bin/fm-busy-event.sh" arm "$state" res-w6) + "$ROOT/bin/fm-busy-event.sh" apply "$state" res-w6 busy --gen "$gen" \ + --source pi-ext --event agent-start key=$(printf '%s' "res-w6" | tr ':/.' '___') echo $(( $(date +%s) - 500 )) > "$state/.subsuper-stale-$key" PATH="$fakebin:$PATH" FM_FAKE_TMUX_WINDOW="$win" FM_FAKE_TMUX_CAPTURE="$pane" \ @@ -429,19 +492,26 @@ test_housekeeping_herdr_persistent_stale_resolves_meta() { pass "persistent herdr stale resolves the target from metadata and escalates" } -test_housekeeping_herdr_idle_busy_footer_clears_stale() { - local dir state key - dir=$(make_supercase stale-herdr-idle-busy-footer) +# A herdr crew whose native agent.get reads idle (generation state) but whose +# own semantic busy-state record says busy is still working, so its stale +# marker clears without escalating. The record - not the pane's rendered +# footer - is what proves it. +test_housekeeping_herdr_idle_busy_record_clears_stale() { + local dir state key gen + dir=$(make_supercase stale-herdr-idle-busy-record) state="$dir/state" - fm_write_meta "$state/herdr-footer.meta" "window=default:w1:p4" "backend=herdr" + fm_write_meta "$state/herdr-footer.meta" "window=default:w1:p4" "backend=herdr" "harness=claude" printf 'working\n' > "$state/herdr-footer.status" + gen=$("$ROOT/bin/fm-busy-event.sh" arm "$state" herdr-footer) + "$ROOT/bin/fm-busy-event.sh" apply "$state" herdr-footer busy --gen "$gen" \ + --source claude-hook --event user-prompt-submit key=$(printf '%s' "herdr-footer" | tr ':/.' '___') echo $(( $(date +%s) - 500 )) > "$state/.subsuper-stale-$key" ( fm_backend_capture() { [ "$1" = herdr ] || fail "expected herdr capture backend, got $1" [ "$2" = "default:w1:p4" ] || fail "expected herdr window target, got $2" - printf 'esc to interrupt\n' + printf 'quiet\n' } fm_backend_busy_state() { [ "$1" = herdr ] || fail "expected herdr busy backend, got $1" @@ -452,8 +522,8 @@ test_housekeeping_herdr_idle_busy_footer_clears_stale() { [ "$(fm_backend_busy_state herdr default:w1:p4)" = idle ] || fail "herdr busy stub did not report idle" FM_STATE_OVERRIDE="$state" FM_STALE_ESCALATE_SECS=240 housekeeping "$state" ) || fail "herdr idle busy-footer housekeeping failed" - [ ! -e "$state/.subsuper-stale-$key" ] || fail "idle+busy-footer herdr stale marker was not cleared" - [ ! -s "$state/.subsuper-escalations" ] || fail "idle+busy-footer herdr stale was escalated" + [ ! -e "$state/.subsuper-stale-$key" ] || fail "idle-native busy-record herdr stale marker was not cleared" + [ ! -s "$state/.subsuper-escalations" ] || fail "idle-native busy-record herdr stale was escalated" pass "herdr idle busy-footer stale clears through capture corroboration" } @@ -681,7 +751,6 @@ test_busy_guard_defers_when_supervisor_busy() { fakebin="$dir/fakebin" sent="$dir/sent.log"; : > "$sent" capture="$dir/pane.txt" - # pane shows a busy signature (firstmate mid-turn) printf 'esc to interrupt\n' > "$capture" escalate_add "$state" "done: PR 1" afk_enter "$state" @@ -1596,40 +1665,38 @@ test_discover_supervisor_target_herdr() { } test_pane_is_busy_herdr_native_busy_state() { + local dir + dir=$(make_supercase primary-herdr-busy) ( fm_backend_busy_state() { [ "$1" = herdr ] && [ "$2" = "default:w1:p2" ] || fail "unexpected busy_state args: $1 $2"; printf 'busy'; } fm_backend_capture() { fail "capture should not be consulted when busy_state is conclusive"; } - pane_is_busy "default:w1:p2" herdr || fail "pane_is_busy should report busy from herdr's native busy_state" + FM_STATE_OVERRIDE="$dir/state" FM_DAEMON_PRIMARY_HARNESS=claude pane_is_busy "default:w1:p2" herdr \ + || fail "pane_is_busy should report busy from herdr's native busy_state" ) || fail "herdr native-busy pane_is_busy subshell failed" pass "pane_is_busy: herdr native busy_state='busy' short-circuits without a capture fallback" } -test_pane_is_busy_herdr_falls_back_to_capture_regex() { +test_primary_busy_guard_is_harness_scoped() { ( fm_backend_busy_state() { printf 'unknown'; } - fm_backend_capture() { [ "$1" = herdr ] && [ "$2" = "default:w1:p2" ] || fail "unexpected capture args: $1 $2"; printf 'esc to interrupt\n'; } - pane_is_busy "default:w1:p2" herdr || fail "pane_is_busy should fall back to the regex-over-capture reader when busy_state is unknown" - ) || fail "herdr capture-fallback pane_is_busy subshell failed" - pass "pane_is_busy: herdr falls back to the shared regex-over-capture reader when native busy_state is unknown" -} - -test_pane_is_busy_herdr_idle_falls_back_to_capture_regex() { - ( - fm_backend_busy_state() { printf 'idle'; } - fm_backend_capture() { [ "$1" = herdr ] && [ "$2" = "default:w1:p2" ] || fail "unexpected capture args: $1 $2"; printf 'esc to interrupt\n'; } - pane_is_busy "default:w1:p2" herdr || fail "pane_is_busy should fall back to the regex-over-capture reader when busy_state is idle" - ) || fail "herdr idle capture-fallback pane_is_busy subshell failed" - pass "pane_is_busy: herdr corroborates native idle with the shared regex-over-capture reader" + fm_backend_capture() { printf 'esc interrupt\n'; } + if FM_DAEMON_PRIMARY_HARNESS=claude pane_is_busy "default:w1:p2" herdr; then + fail "OpenCode's rendered signature must not classify a Claude primary busy" + fi + FM_DAEMON_PRIMARY_HARNESS=opencode pane_is_busy "default:w1:p2" herdr \ + || fail "OpenCode's rendered signature should classify an OpenCode primary busy" + ) || fail "harness-scoped primary busy guard subshell failed" + pass "primary busy guard isolates rendered signatures by detected harness" } test_pane_is_busy_defaults_to_tmux_when_backend_omitted() { local dir fakebin capture dir=$(make_supercase busy-default-backend) fakebin="$dir/fakebin"; capture="$dir/pane.txt" - printf 'esc to interrupt\n' > "$capture" - PATH="$fakebin:$PATH" FM_FAKE_TMUX_CAPTURE="$capture" pane_is_busy "fakepane" \ + printf 'Ctrl+c:cancel\n' > "$capture" + PATH="$fakebin:$PATH" FM_FAKE_TMUX_CAPTURE="$capture" FM_STATE_OVERRIDE="$dir/state" FM_DAEMON_PRIMARY_HARNESS=grok pane_is_busy "fakepane" \ || fail "pane_is_busy with no backend arg should still default to tmux" - pass "pane_is_busy: omitted backend arg defaults to tmux (pre-existing callers unaffected)" + pass "pane_is_busy: omitted backend defaults to tmux for Grok's isolated fallback" } test_pane_input_pending_herdr_dispatch() { @@ -1658,8 +1725,7 @@ test_inject_msg_herdr_busy_guard_defers() { afk_enter "$state" ( fm_backend_target_exists() { [ "$1" = herdr ] && [ "$2" = "default:w1:p2" ] || fail "unexpected target_exists args: $1 $2"; return 0; } - fm_backend_busy_state() { printf 'busy'; } - fm_backend_capture() { fail "capture should not run when busy_state is conclusive"; } + pane_is_busy() { return 0; } fm_backend_composer_state() { fail "composer_state should not be consulted once the busy-guard already deferred"; } fm_backend_send_text_submit() { fail "send_text_submit should not run when the busy-guard defers"; } if FM_SUPERVISOR_BACKEND=herdr FM_SUPERVISOR_TARGET="default:w1:p2" inject_msg "hello" "$state"; then @@ -1676,8 +1742,7 @@ test_inject_msg_herdr_composer_guard_defers() { afk_enter "$state" ( fm_backend_target_exists() { return 0; } - fm_backend_busy_state() { printf 'idle'; } - fm_backend_capture() { printf 'idle prompt\n'; } + pane_is_busy() { return 1; } fm_backend_composer_state() { [ "$1" = herdr ] && [ "$2" = "default:w1:p2" ] || fail "unexpected composer_state args: $1 $2"; printf 'pending'; } fm_backend_send_text_submit() { fail "send_text_submit should not run when the composer-guard defers"; } if FM_SUPERVISOR_BACKEND=herdr FM_SUPERVISOR_TARGET="default:w1:p2" inject_msg "hello" "$state"; then @@ -1694,7 +1759,7 @@ test_inject_msg_herdr_pane_gone_defers() { afk_enter "$state" ( fm_backend_target_exists() { return 1; } - fm_backend_busy_state() { fail "busy_state should not be consulted once the pane-exists check already failed"; } + pane_is_busy() { fail "busy guard should not be consulted once the pane-exists check already failed"; } fm_backend_send_text_submit() { fail "send_text_submit should not run when the pane does not exist"; } if FM_SUPERVISOR_BACKEND=herdr FM_SUPERVISOR_TARGET="default:w1:gone" inject_msg "hello" "$state"; then fail "inject_msg should defer when the herdr target does not exist" @@ -1710,8 +1775,7 @@ test_inject_msg_herdr_submits_through_backend_dispatch() { afk_enter "$state" ( fm_backend_target_exists() { return 0; } - fm_backend_busy_state() { printf 'idle'; } - fm_backend_capture() { printf 'idle prompt\n'; } + pane_is_busy() { return 1; } fm_backend_composer_state() { printf 'empty'; } fm_backend_send_text_submit() { [ "$1" = herdr ] && [ "$2" = "default:w1:p2" ] || fail "unexpected send_text_submit args: $1 $2" @@ -1736,8 +1800,7 @@ test_inject_msg_defers_on_dead_shell_unknown() { afk_enter "$state" ( fm_backend_target_exists() { return 0; } - fm_backend_busy_state() { printf 'idle'; } - fm_backend_capture() { printf '$ \n'; } + pane_is_busy() { return 1; } fm_backend_composer_state() { printf 'unknown'; } fm_backend_send_text_submit() { fail "send_text_submit must NOT run when the composer is a dead shell (unknown)"; } if FM_SUPERVISOR_BACKEND=herdr FM_SUPERVISOR_TARGET="default:w1:p2" inject_msg "hello" "$state"; then @@ -1754,8 +1817,7 @@ test_inject_msg_defers_on_unrecognized_composer_state() { afk_enter "$state" ( fm_backend_target_exists() { return 0; } - fm_backend_busy_state() { printf 'idle'; } - fm_backend_capture() { printf 'idle prompt\n'; } + pane_is_busy() { return 1; } fm_backend_composer_state() { printf 'future-state'; } fm_backend_send_text_submit() { fail "send_text_submit must not run for an unrecognized composer state"; } if FM_SUPERVISOR_BACKEND=herdr FM_SUPERVISOR_TARGET="default:w1:p2" inject_msg "hello" "$state"; then @@ -1773,6 +1835,7 @@ test_classify_routine_signal_self test_classify_terminal_signal_escalates test_classify_check_and_unknown_escalate test_stale_transient_self_records_marker +test_stale_diagnostic_wedge_survives_busy_housekeeping test_stale_terminal_escalates test_stale_paused_classifies_pause test_handle_wake_paused_records_pause_marker @@ -1789,7 +1852,7 @@ test_housekeeping_paused_unpaused_cleared test_housekeeping_stale_marker_transitions_to_pause test_housekeeping_pause_marker_transitions_to_clear test_housekeeping_herdr_persistent_stale_resolves_meta -test_housekeeping_herdr_idle_busy_footer_clears_stale +test_housekeeping_herdr_idle_busy_record_clears_stale test_housekeeping_herdr_resumed_stale_cleared test_housekeeping_orca_persistent_stale_resolves_terminal test_escalate_batches_into_one_digest @@ -1854,8 +1917,7 @@ test_fm_send_exits_nonzero_on_unproven_submit test_discover_supervisor_backend_precedence test_discover_supervisor_target_herdr test_pane_is_busy_herdr_native_busy_state -test_pane_is_busy_herdr_falls_back_to_capture_regex -test_pane_is_busy_herdr_idle_falls_back_to_capture_regex +test_primary_busy_guard_is_harness_scoped test_pane_is_busy_defaults_to_tmux_when_backend_omitted test_pane_input_pending_herdr_dispatch test_inject_msg_herdr_busy_guard_defers diff --git a/tests/fm-documentation-audiences.test.sh b/tests/fm-documentation-audiences.test.sh index 11854594af..90222802f6 100755 --- a/tests/fm-documentation-audiences.test.sh +++ b/tests/fm-documentation-audiences.test.sh @@ -135,26 +135,7 @@ MD pass "local links resolve while dates, versions, commands, and incident prose remain semantically reviewed" } -test_no_mistakes_document_schema() { - local config="$ROOT/.no-mistakes.yaml" - assert_grep 'document:' "$config" "trusted Document config is missing" - assert_grep ' instructions: |' "$config" "Document instructions use an unsupported shape" - assert_grep 'docs/documentation-audiences.json' "$config" \ - "Document instructions do not point to the audience inventory" - assert_grep 'complete' "$config" \ - "Document instructions do not require a complete branch-diff review" - if command -v ruby >/dev/null 2>&1; then - ruby -e ' - require "yaml" - data = YAML.safe_load(File.read(ARGV.fetch(0))) - abort unless data.dig("document", "instructions").is_a?(String) - ' "$config" || fail ".no-mistakes.yaml did not parse document.instructions" - fi - pass "no-mistakes uses the supported trusted document.instructions schema" -} - test_repository_inventory_passes test_duplicate_and_setup_classification_fail test_required_pointer_fails test_local_links_and_no_keyword_heuristic -test_no_mistakes_document_schema diff --git a/tests/fm-fleet-snapshot-view.test.sh b/tests/fm-fleet-snapshot-view.test.sh index 56af41e11c..f47c70f2fa 100755 --- a/tests/fm-fleet-snapshot-view.test.sh +++ b/tests/fm-fleet-snapshot-view.test.sh @@ -62,8 +62,15 @@ make_home() { # <name> printf '%s\n' "$home" } +record_claude_idle() { # <state-dir> <id> + local state=$1 id=$2 gen + gen=$("$ROOT/bin/fm-busy-event.sh" arm "$state" "$id") + "$ROOT/bin/fm-busy-event.sh" apply "$state" "$id" idle --gen "$gen" \ + --source claude-hook --event stop +} + write_fixture() { # <home> - local home=$1 + local home=$1 fixture_gen mkdir -p "$home/projects/alpha-worktree" "$home/projects/scout-worktree" "$home/secondmate-home" cat > "$home/data/backlog.md" <<EOF ## In flight @@ -84,12 +91,18 @@ EOF "window=firstmate:fm-ship-task" \ "worktree=$home/projects/alpha-worktree" \ "project=alpha" \ - "harness=codex" \ + "harness=claude" \ "kind=ship" \ "mode=ship" \ "yolo=off" \ "pr=https://github.com/kunchenguid/firstmate/pull/9" printf 'needs-decision: choose an API shape\n' > "$home/state/ship-task.status" + # A working ship task proves it through its own semantic busy-state record + # (bin/fm-busy-lib.sh), which is what the snapshot's current-state read + # consults; rendered pane text is no longer a state source. + fixture_gen=$("$ROOT/bin/fm-busy-event.sh" arm "$home/state" ship-task) + "$ROOT/bin/fm-busy-event.sh" apply "$home/state" ship-task busy --gen "$fixture_gen" \ + --source claude-hook --event user-prompt-submit fm_write_meta "$home/state/scout-task.meta" \ "window=firstmate:fm-scout-task" \ "worktree=$home/projects/scout-worktree" \ @@ -343,7 +356,7 @@ EOF } test_event_hints_follow_reconciled_current_state() { - local home fakebin out + local home fakebin out hint_gen home=$(make_home event-hints) mkdir -p \ "$home/projects/active-decision" \ @@ -354,33 +367,41 @@ test_event_hints_follow_reconciled_current_state() { "window=firstmate:fm-active-decision" \ "worktree=$home/projects/active-decision" \ "project=alpha" \ - "harness=codex" \ + "harness=claude" \ "kind=ship" \ "mode=ship" + record_claude_idle "$home/state" active-decision printf 'needs-decision: choose an API shape\n' > "$home/state/active-decision.status" fm_write_meta "$home/state/active-blocked.meta" \ "window=firstmate:fm-active-blocked" \ "worktree=$home/projects/active-blocked" \ "project=alpha" \ - "harness=codex" \ + "harness=claude" \ "kind=ship" \ "mode=ship" + record_claude_idle "$home/state" active-blocked printf 'blocked: waiting on access\n' > "$home/state/active-blocked.status" fm_write_meta "$home/state/stale-decision.meta" \ "window=firstmate:fm-stale-decision-ship-task" \ "worktree=$home/projects/stale-decision" \ "project=alpha" \ - "harness=codex" \ + "harness=claude" \ "kind=ship" \ "mode=ship" + hint_gen=$("$ROOT/bin/fm-busy-event.sh" arm "$home/state" stale-decision) + "$ROOT/bin/fm-busy-event.sh" apply "$home/state" stale-decision busy --gen "$hint_gen" \ + --source claude-hook --event user-prompt-submit printf 'needs-decision: already answered\n' > "$home/state/stale-decision.status" fm_write_meta "$home/state/stale-blocked.meta" \ "window=firstmate:fm-stale-blocked-ship-task" \ "worktree=$home/projects/stale-blocked" \ "project=alpha" \ - "harness=codex" \ + "harness=claude" \ "kind=ship" \ "mode=ship" + hint_gen=$("$ROOT/bin/fm-busy-event.sh" arm "$home/state" stale-blocked) + "$ROOT/bin/fm-busy-event.sh" apply "$home/state" stale-blocked busy --gen "$hint_gen" \ + --source claude-hook --event user-prompt-submit printf 'blocked: old failure\n' > "$home/state/stale-blocked.status" fakebin=$(make_fakebin "$home") out=$(PATH="$fakebin:$PATH" FM_HOME="$home" "$SNAPSHOT" --json) @@ -447,9 +468,10 @@ EOF "window=firstmate:fm-bold-task" \ "worktree=$projects/bold-worktree" \ "project=alpha" \ - "harness=codex" \ + "harness=claude" \ "kind=scout" \ "mode=scout" + record_claude_idle "$home/state" bold-task printf 'done: report ready\n' > "$home/state/bold-task.status" fakebin=$(make_fakebin "$home") out=$(PATH="$fakebin:$PATH" FM_HOME="$home" FM_DATA_OVERRIDE="$data" FM_PROJECTS_OVERRIDE="$projects" "$SNAPSHOT" --json) @@ -709,9 +731,10 @@ test_completed_scout_report_is_pointer_not_pending() { "window=firstmate:fm-lavish-103" \ "worktree=$home/projects/scout-wt" \ "project=firstmate" \ - "harness=codex" \ + "harness=claude" \ "kind=scout" \ "mode=scout" + record_claude_idle "$home/state" lavish-103 # Stale needs-decision, then the scout finished (done). No keyed resolution. printf 'needs-decision: adopt approach A or B for Lavish issue 103\n' > "$home/state/lavish-103.status" printf 'done: report ready at data/lavish-103/report.md\n' >> "$home/state/lavish-103.status" @@ -740,9 +763,10 @@ test_parked_scout_decision_stays_pending() { "window=firstmate:fm-parked-scout" \ "worktree=$home/projects/scout-wt2" \ "project=firstmate" \ - "harness=codex" \ + "harness=claude" \ "kind=scout" \ "mode=scout" + record_claude_idle "$home/state" parked-scout printf 'needs-decision [key=q1]: adopt approach A or B\n' > "$home/state/parked-scout.status" fakebin=$(make_fakebin "$home") out=$(PATH="$fakebin:$PATH" FM_HOME="$home" "$SNAPSHOT" --json) diff --git a/tests/fm-gate-refuse.test.sh b/tests/fm-gate-refuse.test.sh index e788eb1f21..aff57ab18e 100755 --- a/tests/fm-gate-refuse.test.sh +++ b/tests/fm-gate-refuse.test.sh @@ -318,7 +318,8 @@ SH git -C "$case_dir/wt" push -q origin fm/task-x1 git -C "$case_dir/project" fetch -q origin fm_write_meta "$case_dir/state/task-x1.meta" \ - "window=fm-task-x1" "worktree=$case_dir/wt" "project=$case_dir/project" \ + "window=firstmate:fm-task-x1" "endpoint_task_id=task-x1" \ + "worktree=$case_dir/wt" "project=$case_dir/project" \ "kind=ship" "mode=no-mistakes" touch "$case_dir/state/.last-watcher-beat" printf '%s\n' "$case_dir" @@ -360,36 +361,6 @@ test_teardown_refuses_and_admits() { pass "fm-teardown: refuses on marker and gate-worktree backstop; a normal teardown is unaffected" } -# --- tracked .no-mistakes.yaml ---------------------------------------------- - -test_no_mistakes_yaml_disables_project_settings() { - local file="$ROOT/.no-mistakes.yaml" val tab - assert_present "$file" "tracked .no-mistakes.yaml is missing" - git -C "$ROOT" ls-files --error-unmatch .no-mistakes.yaml >/dev/null 2>&1 \ - || fail ".no-mistakes.yaml is not tracked by git" - - # Parse with a real YAML loader and assert the field is boolean true, so a - # malformed file or a stringy "true" fails where a naive grep would pass. - if command -v python3 >/dev/null 2>&1 && python3 -c 'import yaml' >/dev/null 2>&1; then - val=$(python3 -c 'import yaml,sys; print(yaml.safe_load(open(sys.argv[1])).get("disable_project_settings"))' "$file") \ - || fail ".no-mistakes.yaml did not parse as YAML (python3)" - [ "$val" = "True" ] || fail "disable_project_settings is not boolean true (python3 read: $val)" - elif command -v ruby >/dev/null 2>&1; then - ruby -ryaml -e 'exit((YAML.safe_load(File.read(ARGV[0]))["disable_project_settings"] == true) ? 0 : 1)' "$file" \ - || fail ".no-mistakes.yaml did not parse or disable_project_settings != true (ruby)" - else - # No YAML loader: fall back to a strict structural check - no tab indentation - # (YAML forbids it) and the top-level key mapped to the bare boolean true. - tab=$(printf '\t') - case "$(cat "$file")" in - *"$tab"*) fail ".no-mistakes.yaml uses a tab (invalid YAML indentation)" ;; - esac - grep -qxE 'disable_project_settings:[[:space:]]+true' "$file" \ - || fail "top-level 'disable_project_settings: true' not found in .no-mistakes.yaml" - fi - pass ".no-mistakes.yaml parses and sets disable_project_settings: true (trusted-only gate opt-out)" -} - test_helper_env_marker_refuses test_helper_empty_env_marker_refuses test_helper_path_backstop_refuses @@ -397,4 +368,3 @@ test_helper_normal_is_noop test_spawn_refuses_and_admits test_send_refuses_and_admits test_teardown_refuses_and_admits -test_no_mistakes_yaml_disables_project_settings diff --git a/tests/fm-gitignore-config.test.sh b/tests/fm-gitignore-config.test.sh new file mode 100755 index 0000000000..5b864dd646 --- /dev/null +++ b/tests/fm-gitignore-config.test.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +# .gitignore must ignore config/ as a directory, not by exact filename. +# +# A name-by-name list silently stops ignoring any new or home-local file under +# config/ (fm-gitignore-config-name-by-name): an unrecognized file there makes +# the working tree read as dirty, which then blocks guarded sync paths that +# refuse to touch a dirty home. +set -u + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +fail() { + printf 'not ok - %s\n' "$1" >&2 + exit 1 +} + +pass() { + printf 'ok - %s\n' "$1" +} + +random_leaf() { + printf '%s-%s' "$1" "$$-$RANDOM-$RANDOM" +} + +test_config_dir_ignored_as_category() { + local direct nested sample + direct="$(random_leaf config/unlisted-key)" + nested="config/$(random_leaf nested-dir)/$(random_leaf deep-file)" + for sample in "$direct" "$nested" config/some-new-key.admin; do + git -C "$ROOT" check-ignore -q "$sample" \ + || fail "git does not ignore $sample (config/ must be ignored as a directory)" + done + pass "config/ is ignored as a directory, covering unlisted and nested paths" +} + +test_unrelated_path_stays_visible() { + # Control: a path outside config/ must remain visible to Git, so the + # coverage above is proven by contrast rather than an always-ignoring rule. + local sibling + sibling="$(random_leaf not-config)" + git -C "$ROOT" check-ignore -q "$sibling" \ + && fail "git unexpectedly ignores $sibling (outside config/)" + pass "an unrelated path outside config/ remains visible to git" +} + +test_config_dir_ignored_as_category +test_unrelated_path_stays_visible diff --git a/tests/fm-gotmp.test.sh b/tests/fm-gotmp.test.sh index 03f0afafa7..1dd23bd250 100755 --- a/tests/fm-gotmp.test.sh +++ b/tests/fm-gotmp.test.sh @@ -5,10 +5,10 @@ # gotmp/, exports GOTMPDIR into the crewmate pane, and records tasktmp= in the task's # meta. fm-teardown reads tasktmp= and removes the whole root on cleanup. # -# These tests exercise behavior directly: fm-teardown is run as a subprocess against a -# fake FM_HOME/FM_ROOT (built so the real script resolves into it), with stub helper scripts. -# Nothing is sourced. The fm-spawn side is verified both structurally (the source has -# the contract lines) and behaviorally (the mkdir + meta-write pattern it uses). +# These tests exercise fm-teardown directly as a subprocess against a fake FM_HOME/FM_ROOT +# built so the real script resolves into it, with stub helper scripts. +# The isolated fm-spawn subprocess in fm-kimi-harness.test.sh covers temp-root creation, +# metadata publication, and the pane environment export. set -u # This suite does not source tests/lib.sh, so exempt its teardown subprocess from @@ -18,7 +18,6 @@ set -u export FM_GATE_REFUSE_BYPASS=1 ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -SPAWN="$ROOT/bin/fm-spawn.sh" TEARDOWN="$ROOT/bin/fm-teardown.sh" fail() { @@ -65,6 +64,13 @@ make_fake_root() { ln -s "$ROOT/bin/fm-gate-refuse-lib.sh" "$fake/bin/fm-gate-refuse-lib.sh" # fm-pr-lib.sh: teardown uses its canonical task-ID validator for poll cleanup. ln -s "$ROOT/bin/fm-pr-lib.sh" "$fake/bin/fm-pr-lib.sh" + # fm-public-followup-lib.sh (and the fm-x-lib.sh it sources): teardown sources + # it for the relay-activation gate on the promised-public-reply check. Neither + # does anything in this fixture, which has no .env, but both are real siblings + # teardown now requires. + ln -s "$ROOT/bin/fm-public-followup-lib.sh" "$fake/bin/fm-public-followup-lib.sh" + ln -s "$ROOT/bin/fm-x-lib.sh" "$fake/bin/fm-x-lib.sh" + ln -s "$ROOT/bin/fm-secondmate-registry-lib.sh" "$fake/bin/fm-secondmate-registry-lib.sh" # fm-guard.sh: stub (teardown calls it with `|| true`). cat > "$fake/bin/fm-guard.sh" <<'SH' #!/usr/bin/env bash @@ -96,40 +102,6 @@ META printf '%s' "$fake" } -# --- fm-spawn side --- - -test_spawn_contract_and_mkdir_pattern() { - # Structural: fm-spawn must create the gotmp dir, record tasktmp in meta, and export - # GOTMPDIR into the pane. Assert the contract lines are present in the source. - # shellcheck disable=SC2016 # single quotes are deliberate: these are literal source strings - grep -F 'mkdir -p "$TASK_TMP/gotmp"' "$SPAWN" >/dev/null \ - || fail "fm-spawn missing: mkdir of gotmp under TASK_TMP" - # shellcheck disable=SC2016 # single quotes are deliberate: literal source string - grep -F 'echo "tasktmp=$TASK_TMP"' "$SPAWN" >/dev/null \ - || fail "fm-spawn missing: tasktmp= line in meta write" - grep -F 'export GOTMPDIR=' "$SPAWN" >/dev/null \ - || fail "fm-spawn missing: GOTMPDIR export into pane" - # Behavioral: the mkdir + meta-write pattern spawn uses must produce a gotmp dir and - # a meta line whose value the teardown grep (tasktmp=, cut -d= -f2-) reads back whole. - local id=spawn-sim-z1 - local sim_root="$TMP_ROOT/$id-root" - local task_tmp="$sim_root/tmp/fm-$id" - mkdir -p "$sim_root/state" - # Replicate spawn's exact mkdir + meta-write lines. - TASK_TMP="$task_tmp" - mkdir -p "$TASK_TMP/gotmp" - { - echo "tasktmp=$TASK_TMP" - } > "$sim_root/state/$id.meta" - [ -d "$task_tmp/gotmp" ] || fail "simulated spawn did not create gotmp dir" - # Teardown reads tasktmp= with `grep '^tasktmp=' | cut -d= -f2-`; round-trip it. - local read_back - read_back=$(grep '^tasktmp=' "$sim_root/state/$id.meta" | cut -d= -f2-) - [ "$read_back" = "$task_tmp" ] \ - || fail "tasktmp value not round-tripped by teardown's grep|cut (got '$read_back')" - pass "fm-spawn creates gotmp dir and records tasktmp in meta" -} - # --- fm-teardown side (real subprocess) --- test_teardown_removes_tasktmp_dir() { @@ -165,6 +137,13 @@ test_teardown_skips_gracefully_without_tasktmp() { ln -s "$ROOT/bin/fm-gate-refuse-lib.sh" "$fake/bin/fm-gate-refuse-lib.sh" # fm-pr-lib.sh: teardown uses its canonical task-ID validator for poll cleanup. ln -s "$ROOT/bin/fm-pr-lib.sh" "$fake/bin/fm-pr-lib.sh" + # fm-public-followup-lib.sh (and the fm-x-lib.sh it sources): teardown sources + # it for the relay-activation gate on the promised-public-reply check. Neither + # does anything in this fixture, which has no .env, but both are real siblings + # teardown now requires. + ln -s "$ROOT/bin/fm-public-followup-lib.sh" "$fake/bin/fm-public-followup-lib.sh" + ln -s "$ROOT/bin/fm-x-lib.sh" "$fake/bin/fm-x-lib.sh" + ln -s "$ROOT/bin/fm-secondmate-registry-lib.sh" "$fake/bin/fm-secondmate-registry-lib.sh" cat > "$fake/bin/fm-guard.sh" <<'SH' #!/usr/bin/env bash exit 0 @@ -207,7 +186,6 @@ test_teardown_skips_gracefully_when_dir_missing() { pass "fm-teardown skips gracefully when tasktmp= points to a nonexistent dir" } -test_spawn_contract_and_mkdir_pattern test_teardown_removes_tasktmp_dir test_teardown_skips_gracefully_without_tasktmp test_teardown_skips_gracefully_when_dir_missing diff --git a/tests/fm-grok-stop-live-e2e.test.sh b/tests/fm-grok-stop-live-e2e.test.sh new file mode 100755 index 0000000000..c9d4bcc9a6 --- /dev/null +++ b/tests/fm-grok-stop-live-e2e.test.sh @@ -0,0 +1,219 @@ +#!/usr/bin/env bash +# Opt-in real-process Grok Stop compatibility matrix. +# +# Requires exact official binary paths for one native-capable build and one +# genuine pre-native build. Each cell gets a unique scratch repo/home, dedicated +# tmux socket, socket-bound wrapper, target window, and independent control +# window. Cleanup uses only creation-time pane/process identities. +set -u + +if [ "${FM_GROK_STOP_LIVE_E2E:-0}" != 1 ]; then + echo "skip: set FM_GROK_STOP_LIVE_E2E=1 with FM_GROK_NATIVE_BIN and FM_GROK_LEGACY_BIN" + exit 0 +fi + +# shellcheck source=tests/lib.sh +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" + +NATIVE_BIN=${FM_GROK_NATIVE_BIN:-} +LEGACY_BIN=${FM_GROK_LEGACY_BIN:-} +AUTH=${FM_GROK_AUTH_FILE:-$HOME/.grok/auth.json} +REAL_TMUX=$(command -v tmux || true) +ACTIVE_LAB= + +[ -x "$NATIVE_BIN" ] || fail "FM_GROK_NATIVE_BIN must be an exact executable path" +[ -x "$LEGACY_BIN" ] || fail "FM_GROK_LEGACY_BIN must be an exact executable path" +[ -f "$AUTH" ] || fail "FM_GROK_AUTH_FILE must name the already-managed auth artifact" +[ -n "$REAL_TMUX" ] || fail "tmux not found" +command -v jq >/dev/null 2>&1 || fail "jq not found" + +NATIVE_VERSION=$($NATIVE_BIN --version) +LEGACY_VERSION=$($LEGACY_BIN --version) + +cleanup_exact_cell() { + local lab=$1 session=$2 target=$3 pane_expected pid_expected pane_live pid_live windows + pane_expected=$(sed -n 's/^target-pane=//p' "$lab/target.identity") + pid_expected=$(sed -n 's/^target-pid=//p' "$lab/target.identity") + [ -n "$pane_expected" ] && [ -n "$pid_expected" ] || { + echo "blocked: missing recorded target identity; preserving $lab" >&2 + return 1 + } + pane_live=$(cd "$lab" && env -u TMUX -u TMUX_PANE "$REAL_TMUX" -S dedicated.sock \ + display-message -p -t "$session:$target" '#{pane_id}' 2>/dev/null) || { + echo "blocked: cannot verify target pane identity; preserving $lab" >&2 + return 1 + } + pid_live=$(cd "$lab" && env -u TMUX -u TMUX_PANE "$REAL_TMUX" -S dedicated.sock \ + display-message -p -t "$session:$target" '#{pane_pid}' 2>/dev/null) || { + echo "blocked: cannot verify target process identity; preserving $lab" >&2 + return 1 + } + [ "$pane_live" = "$pane_expected" ] && [ "$pid_live" = "$pid_expected" ] || { + echo "blocked: live target identity differs from creation record; preserving $lab" >&2 + return 1 + } + FM_E2E_TMUX_SOCKET_ID="$lab/dedicated.sock" PATH="$lab/bin:$PATH" \ + env -u TMUX -u TMUX_PANE "$lab/bin/tmux" kill-window -t "$session:$target" || return 1 + windows=$(cd "$lab" && env -u TMUX -u TMUX_PANE "$REAL_TMUX" -S dedicated.sock \ + list-windows -t "$session" -F '#{window_name}') || return 1 + printf '%s\n' "$windows" | grep -Fqx control || { + echo "blocked: control window did not survive exact target cleanup; preserving $lab" >&2 + return 1 + } + printf '%s\n' "$windows" | grep -Fqx "$target" && { + echo "blocked: exact target survived cleanup; preserving $lab" >&2 + return 1 + } + FM_E2E_TMUX_SOCKET_ID="$lab/dedicated.sock" PATH="$lab/bin:$PATH" \ + env -u TMUX -u TMUX_PANE "$lab/bin/tmux" kill-server || return 1 + rm -rf -- "$lab" + ACTIVE_LAB= +} + +preserve_on_failure() { + local rc=$? + if [ "$rc" -ne 0 ] && [ -n "$ACTIVE_LAB" ]; then + echo "blocked: preserving failed isolated Grok cell at $ACTIVE_LAB" >&2 + fi + exit "$rc" +} +trap preserve_on_failure EXIT + +run_cell() { # <native|legacy> <exact-binary> + local kind=$1 binary=$2 lab tmp_base session target prompt i child_count payload_count unique_sessions + local stop_values outer_turns outer_text + tmp_base=${TMPDIR:-/tmp} + tmp_base=${tmp_base%/} + lab=$(mktemp -d "$tmp_base/fm-grok-stop-$kind.XXXXXX") || return 1 + session="$kind-e2e" + target="fm-$kind" + ACTIVE_LAB=$lab + umask 077 + mkdir -p "$lab"/{home,grok-home,bin,fmhome/state,fmhome/config} + git clone -q --no-hardlinks "$ROOT" "$lab/project" || return 1 + # Before the candidate is committed, clone sees HEAD only. Apply the current + # tracked diff so this opt-in gate always exercises the code under review. + git -C "$ROOT" diff --binary HEAD -- > "$lab/candidate.patch" || return 1 + [ ! -s "$lab/candidate.patch" ] \ + || git -C "$lab/project" apply --whitespace=nowarn "$lab/candidate.patch" || return 1 + ln -s "$AUTH" "$lab/grok-home/auth.json" + printf '%s\n' "$lab/dedicated.sock" > "$lab/tmux.socket-identity" + + cat > "$lab/bin/tmux" <<EOF +#!/usr/bin/env bash +set -eu +expected='$lab/dedicated.sock' +[ -z "\${TMUX:-}" ] && [ -z "\${TMUX_PANE:-}" ] || exit 91 +[ "\${FM_E2E_TMUX_SOCKET_ID:-}" = "\$expected" ] || exit 92 +[ "\$(cat '$lab/tmux.socket-identity')" = "\$expected" ] || exit 93 +for arg in "\$@"; do case "\$arg" in -S|-L) exit 94 ;; esac; done +printf 'tmux' >> '$lab/tmux-wrapper.log' +printf ' <%s>' "\$@" >> '$lab/tmux-wrapper.log' +printf '\n' >> '$lab/tmux-wrapper.log' +cd '$lab' +exec '$REAL_TMUX' -S dedicated.sock "\$@" +EOF + cat > "$lab/bin/grok" <<'EOF' +#!/usr/bin/env bash +printf 'active=%s' "${GROK_TURNEND_GUARD_ACTIVE:-}" >> "${FM_GROK_E2E_ROOT:?}/resume-invocations.log" +printf ' <%s>' "$@" >> "${FM_GROK_E2E_ROOT:?}/resume-invocations.log" +printf '\n' >> "${FM_GROK_E2E_ROOT:?}/resume-invocations.log" +exec "${FM_GROK_E2E_BIN:?}" "$@" +EOF + chmod +x "$lab/bin/tmux" "$lab/bin/grok" + : > "$lab/tmux-wrapper.log" + : > "$lab/resume-invocations.log" + + if [ "$kind" = native ]; then + cat > "$lab/project/.grok/hooks/00-fm-stop-live-e2e.json" <<EOF +{"hooks":{"Stop":[{"hooks":[{"type":"command","command":"bash -lc 'cat >> \"$lab/payloads.jsonl\"'","timeout":30}]}]}} +EOF + prompt='This is an isolated regression test. Reply exactly NATIVE_BASE. If Stop-hook feedback arrives, do not use tools; acknowledge it by replying exactly NATIVE_CONTINUED, then stop.' + else + mkdir -p "$lab/grok-home/hooks" + cp "$ROOT/.grok/hooks/fm-primary-turnend-guard.json" "$lab/grok-home/hooks/fm-primary-turnend-guard.json" + rm -f "$lab/project/.grok/hooks/fm-primary-turnend-guard.json" + cat > "$lab/grok-home/hooks/00-fm-stop-live-e2e.json" <<EOF +{"hooks":{"Stop":[{"hooks":[{"type":"command","command":"bash -lc 'cat >> \"$lab/payloads.jsonl\"'","timeout":30}]}]}} +EOF + prompt='This is an isolated regression test. Reply exactly LEGACY_BASE. If a resumed turn receives guard feedback, do not use tools; acknowledge it by replying exactly LEGACY_RESUMED, then stop.' + fi + + cat > "$lab/fmhome/state/$kind.meta" <<EOF +window=$session:$target +endpoint_task_id=$kind +worktree=$lab/project +project=$lab/project +harness=grok +kind=scout +mode=no-mistakes +EOF + cat > "$lab/run.sh" <<EOF +#!/usr/bin/env bash +set -u +cd '$lab/project' || exit 70 +env -u TMUX -u TMUX_PANE HOME='$lab/home' GROK_HOME='$lab/grok-home' GROK_AGENT=1 \ + FM_HOME='$lab/fmhome' FM_ROOT_OVERRIDE='$lab/project' FM_GROK_E2E_ROOT='$lab' \ + FM_GROK_E2E_BIN='$binary' FM_E2E_TMUX_SOCKET_ID='$lab/dedicated.sock' PATH='$lab/bin':"\$PATH" \ + '$binary' $([ "$kind" = native ] && printf '%s' '--trust ')--always-approve --reasoning-effort low \ + --output-format json --leader-socket '$lab/leader.sock' -p $(printf '%q' "$prompt") \ + > '$lab/outer.json' 2> '$lab/outer.err' & +child=\$! +printf 'child_pid=%s\n' "\$child" > '$lab/process.identity' +pgid=\$(ps -o pgid= -p "\$child" 2>/dev/null | tr -d ' ') +printf 'child_pgid=%s\n' "\$pgid" >> '$lab/process.identity' +wait "\$child"; rc=\$? +printf 'rc=%s\n' "\$rc" > '$lab/done' +sleep 1800 +EOF + chmod +x "$lab/run.sh" + + ( cd "$lab" && env -u TMUX -u TMUX_PANE "$REAL_TMUX" -S dedicated.sock \ + new-session -d -s "$session" -n control 'sleep 1800' ) || return 1 + ( cd "$lab" && env -u TMUX -u TMUX_PANE "$REAL_TMUX" -S dedicated.sock \ + new-window -d -t "$session:" -n "$target" "$lab/run.sh" ) || return 1 + printf 'target-pane=%s\ntarget-pid=%s\n' \ + "$(cd "$lab" && env -u TMUX -u TMUX_PANE "$REAL_TMUX" -S dedicated.sock display-message -p -t "$session:$target" '#{pane_id}')" \ + "$(cd "$lab" && env -u TMUX -u TMUX_PANE "$REAL_TMUX" -S dedicated.sock display-message -p -t "$session:$target" '#{pane_pid}')" \ + > "$lab/target.identity" + + i=0 + while [ "$i" -lt "${FM_GROK_STOP_LIVE_TIMEOUT:-600}" ] && [ ! -f "$lab/done" ]; do + sleep 1 + i=$((i + 1)) + done + [ -f "$lab/done" ] || fail "$kind Grok cell timed out" + grep -qx 'rc=0' "$lab/done" || fail "$kind Grok process returned nonzero" + [ ! -s "$lab/tmux-wrapper.log" ] || fail "$kind model path invoked tmux unexpectedly" + payload_count=$(jq -s 'length' "$lab/payloads.jsonl") + unique_sessions=$(jq -s '[.[].sessionId] | unique | length' "$lab/payloads.jsonl") + [ "$unique_sessions" -eq 1 ] || fail "$kind Stop payloads changed session identity" + child_count=$(grep -c '^active=' "$lab/resume-invocations.log" || true) + outer_text=$(jq -r '.text // empty' "$lab/outer.json") + + if [ "$kind" = native ]; then + [ "$payload_count" -eq 2 ] || fail "native path expected two Stop payloads, got $payload_count" + stop_values=$(jq -sc '[.[].stopHookActive]' "$lab/payloads.jsonl") + [ "$stop_values" = '[false,true]' ] || fail "native capability sequence was $stop_values" + outer_turns=$(jq -r '.num_turns // 0' "$lab/outer.json") + [ "$outer_turns" -eq 2 ] || fail "native path expected two model turns, got $outer_turns" + [ "$child_count" -eq 0 ] || fail "native path started grok --resume" + case "$outer_text" in *NATIVE_BASE*NATIVE_CONTINUED*) ;; *) fail "native model did not receive guard feedback" ;; esac + printf 'ok - %s native Stop kept one session across false->true, two model turns, and zero resume processes\n' "$NATIVE_VERSION" + else + [ "$payload_count" -eq 2 ] || fail "legacy path expected two Stop payloads, got $payload_count" + jq -se 'all(.[]; (has("stopHookActive") | not) and (has("stop_hook_active") | not))' "$lab/payloads.jsonl" >/dev/null \ + || fail "legacy payload unexpectedly exposed native capability" + [ "$child_count" -eq 1 ] || fail "legacy path expected exactly one grok --resume, got $child_count" + grep -q '^active=1 ' "$lab/resume-invocations.log" || fail "legacy resume lacked the recursion guard" + case "$outer_text" in *LEGACY_BASE*) ;; *) fail "legacy outer turn did not finish normally" ;; esac + printf 'ok - %s legacy Stop omitted capability, resumed exactly once, and stopped normally\n' "$LEGACY_VERSION" + fi + + cleanup_exact_cell "$lab" "$session" "$target" || return 1 +} + +run_cell native "$NATIVE_BIN" +run_cell legacy "$LEGACY_BIN" +trap - EXIT +echo "ok - Grok adaptive Stop real-process matrix passed with exact target cleanup and control-window survival" diff --git a/tests/fm-herdr-session-cleanup-e2e.test.sh b/tests/fm-herdr-session-cleanup-e2e.test.sh index 7a4a49aa00..e45c101834 100755 --- a/tests/fm-herdr-session-cleanup-e2e.test.sh +++ b/tests/fm-herdr-session-cleanup-e2e.test.sh @@ -64,7 +64,7 @@ lab() { env PATH="$HERDR_ORIGINAL_PATH" "$HERDR_LAB_HELPER" run "$HERDR_LAB_SESS production_process_proof() { FM_HOME="$HOME_DIR" FM_BACKEND=herdr HERDR_SESSION="$HERDR_LAB_SESSION" \ FM_HERDR_SESSION_CLEANUP_SOURCE_ONLY=1 PATH="$FAKEBIN:$HERDR_ORIGINAL_PATH" \ - bash -c '. "$1"; fm_herdr_cleanup_process_is_idle_shell "$2" "$3"' \ + bash -c '. "$1"; fm_backend_herdr_pane_idle_shell_pid "$2" "$3" >/dev/null' \ _ "$ROOT/bin/fm-herdr-session-cleanup.sh" "$HERDR_LAB_SESSION" "$PANE" } focus_snapshot() { diff --git a/tests/fm-herdr-session-cleanup.test.sh b/tests/fm-herdr-session-cleanup.test.sh index f4c1df153b..99cf4b49a1 100755 --- a/tests/fm-herdr-session-cleanup.test.sh +++ b/tests/fm-herdr-session-cleanup.test.sh @@ -25,12 +25,32 @@ export FM_HERDR_SESSION_CLEANUP_SOURCE_ONLY=1 . "$ROOT/bin/fm-herdr-session-cleanup.sh" unset FM_HERDR_SESSION_CLEANUP_SOURCE_ONLY -LINUX_PROCESS_INFO='{"result":{"process_info":{"foreground_processes":[{"argv":["/bin/sh"],"name":"sh","pid":67}]}}}' -[ "$(fm_herdr_cleanup_process_argv0 "$LINUX_PROCESS_INFO")" = /bin/sh ] \ - || fail "Linux Herdr process argv array was not accepted" -if fm_herdr_cleanup_process_argv0 \ - '{"result":{"process_info":{"foreground_processes":[{"argv":[67],"name":"sh","pid":67}]}}}' \ - >/dev/null 2>&1; then +# The idle-shell proof now lives in the backend as +# fm_backend_herdr_pane_idle_shell_pid; prove it still reads Linux argv +# arrays (no argv0 field) and rejects malformed executable identities. +FAKE_PS="$TMP_ROOT/fake-ps" +cat > "$FAKE_PS" <<'SH' +#!/usr/bin/env bash +case "$*" in + "-axo pid=,ppid=") printf '1 0\n67 1\n' ;; + "-p 67 -o stat=") printf 'Ss\n' ;; + *) exit 1 ;; +esac +SH +chmod +x "$FAKE_PS" +LINUX_PROCESS_INFO='{"result":{"type":"pane_process_info","process_info":{"pane_id":"w2:p1","shell_pid":67,"foreground_process_group_id":67,"foreground_processes":[{"argv":["/bin/sh"],"name":"sh","pid":67}]}}}' +argv_pid=$( + # shellcheck disable=SC2329 # invoked indirectly by the idle-shell proof. + fm_backend_herdr_cli() { printf '%s\n' "$LINUX_PROCESS_INFO"; } + FM_HERDR_PS_BIN="$FAKE_PS" fm_backend_herdr_pane_idle_shell_pid test w2:p1 +) || fail "Linux Herdr process argv array was not accepted" +[ "$argv_pid" = 67 ] || fail "idle-shell proof printed the wrong shell pid: $argv_pid" +if ( + # shellcheck disable=SC2329 # invoked indirectly by the idle-shell proof. + fm_backend_herdr_cli() { printf '%s\n' '{"result":{"type":"pane_process_info","process_info":{"pane_id":"w2:p1","shell_pid":67,"foreground_process_group_id":67,"foreground_processes":[{"argv":[67],"name":"sh","pid":67}]}}}'; } + FM_HERDR_PS_BIN="$FAKE_PS" FM_BACKEND_HERDR_IDLE_SHELL_PROOF_POLLS=1 \ + fm_backend_herdr_pane_idle_shell_pid test w2:p1 +) >/dev/null 2>&1; then fail "non-string Herdr process argv was accepted" fi pass "process proof reads Linux Herdr argv arrays and rejects malformed executable identities" @@ -54,7 +74,7 @@ fm_lock_try_acquire() { mkdir "$1" 2>/dev/null } fm_lock_release() { rm -rf -- "$1"; } -fm_herdr_cleanup_process_is_idle_shell() { [ ! -e "$FIXTURE_DIR/process-unsafe" ]; } +fm_backend_herdr_pane_idle_shell_pid() { [ ! -e "$FIXTURE_DIR/process-unsafe" ] && printf '67\n'; } fm_backend_herdr_projection_focus_snapshot() { [ ! -e "$FIXTURE_DIR/focus-unreadable" ] || return 1 printf 'w1\t%s' "$(cat "$FIXTURE_DIR/active-tab")" diff --git a/tests/fm-install-herdr.test.sh b/tests/fm-install-herdr.test.sh deleted file mode 100755 index cc5a70ada8..0000000000 --- a/tests/fm-install-herdr.test.sh +++ /dev/null @@ -1,106 +0,0 @@ -#!/usr/bin/env bash -# Contract tests for the pinned Herdr / Treehouse CI installers and the -# bounded Herdr lab cleanup helper. These tests do not download release assets -# and never start or stop the captain's default Herdr session. -set -u - -# shellcheck source=tests/lib.sh -. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" - -HERDR_INSTALL="$ROOT/bin/fm-install-herdr.sh" -TREEHOUSE_INSTALL="$ROOT/bin/fm-install-treehouse.sh" -CLEANUP="$ROOT/bin/fm-herdr-ci-cleanup.sh" -CI="$ROOT/.github/workflows/ci.yml" - -assert_present "$HERDR_INSTALL" "bin/fm-install-herdr.sh is missing" -assert_present "$TREEHOUSE_INSTALL" "bin/fm-install-treehouse.sh is missing" -assert_present "$CLEANUP" "bin/fm-herdr-ci-cleanup.sh is missing" -[ -x "$HERDR_INSTALL" ] || fail "fm-install-herdr.sh must be executable" -[ -x "$TREEHOUSE_INSTALL" ] || fail "fm-install-treehouse.sh must be executable" -[ -x "$CLEANUP" ] || fail "fm-herdr-ci-cleanup.sh must be executable" - -test_herdr_installer_pins_exact_version_and_checksums() { - assert_grep 'FM_HERDR_CI_VERSION=0.7.4' "$HERDR_INSTALL" \ - "Herdr installer must pin suite-verified 0.7.4" - assert_grep 'FM_HERDR_CI_MIN_PROTOCOL=16' "$HERDR_INSTALL" \ - "Herdr installer must require protocol floor 16" - assert_grep 'ogulcancelik/herdr' "$HERDR_INSTALL" \ - "Herdr installer must use the official GitHub release source" - assert_grep 'herdr-linux-x86_64' "$HERDR_INSTALL" \ - "Herdr installer must name the Linux x86_64 release asset" - assert_grep 'bc0fc02d4ba500f9cac2353a43e67fe036785ecca6eb55378e050fac3c103059' "$HERDR_INSTALL" \ - "Herdr installer must pin the Linux x86_64 SHA-256" - assert_grep 'sha256sum' "$HERDR_INSTALL" \ - "Herdr installer must verify a SHA-256 checksum" - assert_grep '--max-filesize' "$HERDR_INSTALL" \ - "Herdr installer must bound the download size" - assert_no_grep 'brew install' "$HERDR_INSTALL" \ - "Herdr installer must not use a floating package-manager install" - assert_no_grep 'apt-get install' "$HERDR_INSTALL" \ - "Herdr installer must not use a floating package-manager install" - pass "Herdr installer pins exact version, asset, checksum, and protocol floor" -} - -test_treehouse_installer_pins_exact_version_and_checksums() { - assert_grep 'FM_TREEHOUSE_CI_VERSION=2.0.1' "$TREEHOUSE_INSTALL" \ - "Treehouse installer must pin the suite-verified 2.0.1 release" - assert_grep 'kunchenguid/treehouse' "$TREEHOUSE_INSTALL" \ - "Treehouse installer must use the official GitHub release source" - assert_grep 'linux-amd64.tar.gz' "$TREEHOUSE_INSTALL" \ - "Treehouse installer must name the Linux amd64 archive" - assert_grep '1d5a32751ab921670103fd201ddb2b91b47338cb13976f45642b827cf8976af2' "$TREEHOUSE_INSTALL" \ - "Treehouse installer must pin the Linux amd64 SHA-256" - assert_grep '--max-filesize' "$TREEHOUSE_INSTALL" \ - "Treehouse installer must bound the download size" - assert_no_grep 'brew install' "$TREEHOUSE_INSTALL" \ - "Treehouse installer must not use a floating package-manager install" - pass "Treehouse installer pins exact version, asset, and checksum" -} - -test_cleanup_only_targets_job_owned_lab_sessions() { - assert_grep 'fm-lab-' "$CLEANUP" \ - "cleanup must only consider fm-lab-* session names" - assert_grep 'default == false' "$CLEANUP" \ - "cleanup must refuse default sessions" - assert_grep 'snapshot' "$CLEANUP" \ - "cleanup must support a pre-suite snapshot" - assert_grep 'teardown' "$CLEANUP" \ - "cleanup must support post-suite teardown of the delta" - # Must not call ambient server stop. - assert_no_grep 'server stop' "$CLEANUP" \ - "cleanup must never call ambient herdr server stop" - pass "cleanup is bounded to job-owned fm-lab-* sessions" -} - -test_ci_wires_installers_and_required_lane() { - assert_grep 'tests-herdr:' "$CI" "CI must define the required Herdr Behavior job" - assert_grep 'fm-install-herdr.sh' "$CI" "CI must call the Herdr installer" - assert_grep 'fm-install-treehouse.sh' "$CI" "CI must call the Treehouse installer" - assert_grep 'fm-herdr-ci-cleanup.sh snapshot' "$CI" "CI must snapshot sessions before the suite" - assert_grep 'fm-herdr-ci-cleanup.sh teardown' "$CI" "CI must teardown job-owned sessions after" - assert_grep "fail-on-gate-skip 'herdr not found'" "$CI" \ - "CI Herdr lane must fail on herdr-not-found" - assert_grep 'family real-herdr-gated' "$CI" \ - "CI Herdr lane must run only the real-herdr-gated family" - assert_grep 'lane portable-parallel-1' "$CI" \ - "portable CI must run parallel shard 1" - assert_grep 'lane portable-parallel-2' "$CI" \ - "portable CI must run parallel shard 2" - assert_grep 'lane portable-serial' "$CI" \ - "portable CI must run the serial remainder" - assert_grep 'fm-test-run.sh --check-coverage' "$CI" \ - "CI must prove portable lanes and Herdr partition the complete inventory" - # Live harness credential tests must stay out of the default Herdr lane. - assert_no_grep 'live-harness-optin' "$CI" \ - "CI must not run live-harness-optin in the required Herdr lane" - assert_no_grep 'FM_AFK_PI_HERDR_E2E' "$CI" \ - "CI must not enable live Pi/Herdr credential tests" - assert_no_grep 'FM_SEND_MARKER_HERDR_E2E' "$CI" \ - "CI must not enable live marker Herdr credential tests" - pass "CI wires pinned installers into a required serial Herdr lane" -} - -test_herdr_installer_pins_exact_version_and_checksums -test_treehouse_installer_pins_exact_version_and_checksums -test_cleanup_only_targets_job_owned_lab_sessions -test_ci_wires_installers_and_required_lane diff --git a/tests/fm-instruction-owners.test.sh b/tests/fm-instruction-owners.test.sh deleted file mode 100755 index 754e00ddc8..0000000000 --- a/tests/fm-instruction-owners.test.sh +++ /dev/null @@ -1,305 +0,0 @@ -#!/usr/bin/env bash -# Static contract tests for conditional instruction owners introduced before the -# AGENTS.md reduction pass. -# shellcheck disable=SC2016 -set -u - -# shellcheck source=tests/lib.sh -. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" - -DIAG="$ROOT/.agents/skills/diagnostic-reasoning/SKILL.md" -PROJECT="$ROOT/.agents/skills/project-management/SKILL.md" -HARNESS="$ROOT/.agents/skills/harness-adapters/SKILL.md" -CODING="$ROOT/.agents/skills/firstmate-coding-guidelines/SKILL.md" -RECOVERY="$ROOT/.agents/skills/stuck-crewmate-recovery/SKILL.md" -SECONDMATE="$ROOT/.agents/skills/secondmate-provisioning/SKILL.md" -CONFIG="$ROOT/docs/configuration.md" -AGENTS="$ROOT/AGENTS.md" -BRIEF="$ROOT/bin/fm-brief.sh" -BOOTSTRAP="$ROOT/bin/fm-bootstrap.sh" - -test_new_skill_metadata_and_triggers() { - local skill name count - for pair in "diagnostic-reasoning:$DIAG" "project-management:$PROJECT"; do - name=${pair%%:*} - skill=${pair#*:} - assert_present "$skill" "$name skill is missing" - assert_grep "name: $name" "$skill" "$name skill metadata has the wrong name" - assert_grep "user-invocable: false" "$skill" "$name skill must not be user-invocable" - assert_grep " internal: true" "$skill" "$name skill must be internal" - count=$(grep -Fc -- "- \`$name\` -" "$ROOT/AGENTS.md") - [ "$count" -eq 1 ] || fail "$name must have exactly one AGENTS.md trigger entry, found $count" - done - assert_grep 'Use before scoping a reported bug and before acting on a diagnostic report.' "$DIAG" \ - "diagnostic skill metadata lost its precise load trigger" - assert_grep '`diagnostic-reasoning` - load before scoping a reported bug and before acting on a diagnostic report.' "$ROOT/AGENTS.md" \ - "AGENTS.md lost the diagnostic-reasoning trigger" - assert_grep 'Use before adding, creating, removing, or initializing a project.' "$PROJECT" \ - "project-management skill metadata lost its precise load trigger" - assert_grep '`project-management` - load before adding, creating, removing, or initializing a project.' "$ROOT/AGENTS.md" \ - "AGENTS.md lost the project-management trigger" - pass "new internal skills have one precise AGENTS.md trigger each" -} - -test_diagnostic_owner_covers_causal_procedure() { - assert_grep "single owner of Firstmate's bug-diagnosis reasoning procedure" "$DIAG" \ - "diagnostic skill does not declare ownership" - for phrase in \ - "end-to-end reproduction aligned with the real user path" \ - "initiating trigger" \ - "masking condition" \ - "visible symptom" \ - "proven path" \ - "relevant history" \ - "smallest counterfactual" \ - "disconfirming evidence"; do - assert_grep "$phrase" "$DIAG" "diagnostic owner is missing '$phrase'" - done - assert_grep "evidence, not authorization to change code" "$DIAG" \ - "diagnostic owner lost the diagnosis-only authority boundary" - pass "diagnostic-reasoning owns the approved evidence procedure" -} - -test_project_management_owner_covers_guarded_operations() { - assert_grep "single owner of Firstmate's project-management procedure" "$PROJECT" \ - "project-management skill does not declare ownership" - for phrase in \ - 'bin/fm-project-mode.sh' \ - '`no-mistakes`' \ - '`direct-PR`' \ - '`local-only`' \ - 'Default it off' \ - 'Creating a GitHub repository is outward-facing.' \ - "captain's explicit consent" \ - 'Never issue a raw removal command from Firstmate.' \ - 'no-mistakes init && no-mistakes doctor'; do - assert_grep "$phrase" "$PROJECT" "project-management owner is missing '$phrase'" - done - pass "project-management owns registry, delivery posture, consent, initialization, and removal safety" -} - -test_generic_effort_fallback_respects_precedence() { - local section - section=$(awk ' - /^Effort precedence is / { found = 1 } - found && /^The supported launch-profile flags / { exit } - found { print } - ' "$HARNESS") - assert_contains "$section" "explicit per-task captain instruction first" \ - "effort rubric lost per-task captain precedence" - assert_contains "$section" "standing dispatch profile or secondmate pin" \ - "effort rubric lost standing configuration precedence" - assert_contains "$section" 'Use `low` for well-understood work' \ - "effort rubric lost its low fallback" - assert_contains "$section" '`xhigh` for ambiguous investigation or design' \ - "effort rubric lost its xhigh fallback" - assert_contains "$section" "Choose intermediate levels proportionally" \ - "effort rubric lost proportional intermediate levels" - assert_contains "$section" 'Never select `max` from this fallback' \ - "effort rubric permits max without an explicit captain preference" - if printf '%s\n' "$section" | grep -qi sol; then - fail "generic effort fallback must not contain Sol-specific policy" - fi - pass "generic effort fallback applies only below captain and standing configuration" -} - -test_agent_owned_quota_array_dispatch_contract() { - local phrase - for phrase in \ - 'Firstmate alone resolves a matched profile array' \ - 'run `quota-axi --json` at that intake' \ - 'evaluate every configured candidate against that current output' \ - 'inspectable real headroom including quota-window pace' \ - 'if any harness/model/provider relationship, applicable quota data, or interpretation cannot be established, stop and report that candidate' \ - 'instead of omitting it, guessing, falling back, or calling the result quota-informed' \ - 'Preserve malformed profile configuration as an actionable error' \ - "preserve the captain's strongest-reasoning class rather than silently downgrading it" \ - 'Break genuine headroom ties without array-order or harness bias' \ - '`quota-axi` owns how model or product windows relate to bounding account windows' \ - 'remains data-only' \ - 'Load `quota-array-dispatch` before choosing among a matched profile array'; do - assert_grep "$phrase" "$AGENTS" "array-dispatch contract lost '$phrase'" - done - - for phrase in \ - '| claude | Open the current interactive session' \ - '| codex | Open the current interactive session' \ - '| opencode | Run `opencode models [provider]`' \ - '| pi / pi-signed | Run the selected executable as `<executable> --list-models [search]`' \ - '| grok | Run `grok models`' \ - "For an unfamiliar harness or model namespace, establish support and provider identity from that harness's authoritative CLI help, model listing, or current documentation rather than guessing" \ - 'If those sources do not establish the relationship needed for dispatch, fail loudly and report the unresolved candidate.'; do - assert_grep "$phrase" "$HARNESS" "model discovery guidance lost '$phrase'" - done - assert_grep 'not as a permanent namespace or provider mapping' "$HARNESS" \ - "model discovery guidance permits a fixed provider table" - assert_grep 'load `quota-array-dispatch` for the pace-aware candidate choice' "$HARNESS" \ - "harness-adapters lost the quota-array-dispatch handoff" - assert_grep '`quota-array-dispatch` owns the pace-aware profile-array selection procedure' "$CONFIG" \ - "configuration docs do not point to quota-array-dispatch" - assert_grep 'quota-axi is required for the' "$BOOTSTRAP" \ - "bootstrap docs lost the quota-axi dependency pointer" - assert_grep 'agent-owned dispatch-profile array procedure in AGENTS.md section 4' "$BOOTSTRAP" \ - "bootstrap docs do not point to the agent-owned array procedure" - assert_grep 'quota-array-dispatch/SKILL.md' "$BOOTSTRAP" \ - "bootstrap docs do not point to quota-array-dispatch" - pass "firstmate directly compares every quota candidate with authoritative model discovery" -} - -test_shared_authoring_requirements_are_owned() { - assert_grep "review every affected supported primary harness and runtime backend" "$CODING" \ - "coding guidance lost the supported compatibility matrix review" - assert_grep "prefer deterministic and idempotent enforcement over relying on agent memory alone" "$CODING" \ - "coding guidance lost deterministic idempotent enforcement" - assert_grep "critical safety, routing, startup, and supervision infrastructure" "$CODING" \ - "coding guidance lost the critical infrastructure scope" - pass "firstmate-coding-guidelines owns compatibility review and deterministic enforcement" -} - -test_secondmate_registry_contract_stays_concise() { - local guidance routing_section schema_line - routing_section=$(awk ' - /^## Routing table$/ { found = 1 } - found && /^## Charter and seed$/ { exit } - found { print } - ' "$SECONDMATE") - guidance=$(awk ' - /^## Routing table$/ { found = 1 } - found && /^## Backlog handoff$/ { exit } - found { print } - ' "$SECONDMATE") - schema_line="- <id> - <one-sentence charter summary> (home: <absolute-home-path>; scope: <natural-language responsibility>; projects: <project-a>, <project-b>; added <date>)" - assert_contains "$routing_section" "$schema_line" \ - "secondmate routing table lost the parser-compatible single-line schema" - assert_contains "$routing_section" "Each registry entry stays concise and single-line" \ - "secondmate routing table no longer requires concise single-line entries" - assert_contains "$routing_section" "genuinely domain-specific hard rules" \ - "secondmate routing table no longer limits extra prose to domain-specific hard rules" - assert_contains "$routing_section" "The home-seeded \`data/charter.md\` is the sole owner of boilerplate idle-by-default behavior, the normal delegation lifecycle, and standard escalation contracts" \ - "secondmate routing table lost the explicit charter ownership pointer" - assert_contains "$routing_section" "no extra registry pointer field is needed" \ - "secondmate routing table no longer explains why the existing home field is the charter pointer" - for phrase in \ - "go idle and wait silently" \ - "Act only on tasks" \ - "never spawn a survey" \ - "run normal firstmate bootstrap" \ - "escalation back to the main firstmate status file" \ - "requests-from-main-firstmate contract" \ - "waits for routed tasks, never self-initiating a survey or audit" \ - "marked supervisor requests return through status" \ - "unmarked captain messages stay conversational"; do - if printf '%s\n' "$guidance" | grep -F "$phrase" >/dev/null; then - fail "secondmate provisioning guidance restated charter boilerplate: $phrase" - fi - done - pass "secondmate registry guidance keeps concise routes and points to the charter" -} - -test_state_startup_and_ordinary_recovery_placement() { - assert_grep "single owner of the top-level operational-home layout" "$CONFIG" \ - "configuration docs do not own the operational state layout" - assert_grep "header is the single owner of session-start ordering" "$CONFIG" \ - "session-start mechanism is not assigned to the script header" - assert_grep "Ordinary dead-direct-report recovery is owned by \`stuck-crewmate-recovery\`" "$CONFIG" \ - "D05 ordinary recovery placement is missing" - assert_grep "## Session-start reconciliation for a dead ordinary direct report" "$RECOVERY" \ - "stuck-crewmate-recovery lacks the dead ordinary direct-report procedure" - assert_grep "treehouse status" "$RECOVERY" \ - "ordinary recovery lost treehouse inventory inspection" - assert_grep "recorded \`orca_worktree_id=\` and \`terminal=\`" "$RECOVERY" \ - "ordinary recovery lost Orca inventory inspection" - assert_grep "session-start digest reports an ordinary direct report's endpoint dead or its metadata has no window" "$AGENTS" \ - "AGENTS.md does not trigger ordinary dead-report recovery" - pass "state, startup, and ordinary recovery have focused owners and triggers" -} - -test_compressed_agents_owner_map() { - assert_grep '`docs/configuration.md` is the single owner of the top-level operational-home layout' "$AGENTS" \ - "AGENTS.md lost the state-layout owner pointer" - assert_grep 'header is the single owner of composed commands, ordering, and digest contents' "$AGENTS" \ - "AGENTS.md lost the session-start owner pointer" - assert_grep '`docs/configuration.md` owns dispatch-profile and runtime-backend schemas' "$AGENTS" \ - "AGENTS.md lost the dispatch-schema owner pointer" - assert_grep 'That skill owns registry syntax, delivery-mode selection' "$AGENTS" \ - "AGENTS.md lost the project-management owner pointer" - assert_grep 'The delivery lifecycle is an always-loaded operational contract' "$AGENTS" \ - "AGENTS.md no longer owns the delivery lifecycle" - assert_grep 'Fleet supervision is an always-loaded operational contract' "$AGENTS" \ - "AGENTS.md no longer owns fleet supervision" - assert_grep '`.tasks.toml`, `docs/configuration.md`, and current `tasks-axi --help` own the backlog schema' "$AGENTS" \ - "AGENTS.md lost the backlog-mechanics owner pointer" - assert_grep '`bin/fm-brief.sh` and its help own scaffold syntax' "$AGENTS" \ - "AGENTS.md lost the brief-mechanics owner pointer" - assert_grep '`docs/configuration.md` owns activation, generated state, cadence, wire protocol' "$AGENTS" \ - "AGENTS.md lost the X-mode mechanics owner pointer" - pass "compressed AGENTS.md records the approved one-owner map" -} - -test_intake_reuses_evidence_and_parallelizes_safe_work() { - for phrase in \ - 'consult existing reports and established evidence' \ - 'remaining bounded research inside it' \ - 'unresolved uncertainty could materially change whether or what to build' \ - 'relay it without a design-only scout' \ - 'ask one concise implementation question when useful' \ - 'Never both present a likely-enough solution' \ - 'overlap as a risk signal rather than an automatic reason to wait' \ - 'independently implemented and validated' \ - 'selected delivery path can reconcile ordinary rebases or conflicts' \ - 'Serialize only for a true semantic dependency' \ - 'shared mutable external state' \ - 'incompatible concurrent migration' \ - 'same-file editing alone is insufficient' \ - 'genuine blockers remain durable'; do - assert_grep "$phrase" "$AGENTS" "intake contract lost '$phrase'" - done - assert_grep 'dispatch isolated work immediately with no concurrency cap' "$AGENTS" \ - "intake contract lost unbounded safe parallel dispatch" - assert_grep 'captain explicitly requests a separate knowledge or design deliverable' "$AGENTS" \ - "intake contract lost captain-requested separate scouts" - assert_grep 'When implementation is separately authorized, promote the existing scout' "$AGENTS" \ - "intake contract lost genuine scout promotion" - pass "intake reuses evidence, reserves scouts for uncertainty, and parallelizes safe work" -} - -test_compressed_agents_retains_authority_and_supervision_safety() { - for phrase in \ - 'A lock-refused session must not spawn, steer, merge, drain the wake queue' \ - 'A diagnostic request, report, recommendation, or implementation-ready finding is evidence, not authorization to change code.' \ - 'The selected delivery path owns its own rigor.' \ - 'When no-mistakes is selected, no-mistakes alone owns review, fixes, tests, documentation, push, PR, and CI; otherwise follow the faster path without adding an independent reviewer.' \ - 'Never hold work outside no-mistakes for a manual clean verdict, stack serial manual reviews, or infer authority for one from security, architecture, or risk alone.' \ - 'A separate review or audit is allowed only when the captain explicitly requests that deliverable or the authorized task is a knowledge-only review; one named question remains scoped to that question.' \ - 'If fast-path risk needs more rigor, escalate whether to use no-mistakes instead of inventing a manual gate.' \ - '**local-only** has the worker stop with a clean ready branch, then waits for the configured merge authority' \ - 'A status line is a wake event, not current state' \ - 'keep exactly one live supervision cycle' \ - 'Never broadly kill watchers' \ - 'While `state/.afk` exists, the daemon owns supervision' \ - 'post the final completion follow-up before teardown'; do - assert_grep "$phrase" "$AGENTS" "compressed AGENTS.md lost safety phrase '$phrase'" - done - assert_no_grep 'Firstmate does not personally review code or deliverables' "$AGENTS" \ - "AGENTS.md retained the weaker duplicate review prohibition" - assert_no_grep 'firstmate reviews your branch' "$AGENTS" \ - "AGENTS.md retained a personal branch-review requirement" - assert_no_grep 'firstmate reviews, captain approves' "$BRIEF" \ - "generated brief retained a stacked personal-review requirement" - if grep -q "$(printf '\342\200\224')" "$AGENTS"; then - fail "AGENTS.md contains an em dash" - fi - pass "compressed AGENTS.md retains authority, supervision, AFK, and X safety" -} - -test_new_skill_metadata_and_triggers -test_diagnostic_owner_covers_causal_procedure -test_project_management_owner_covers_guarded_operations -test_generic_effort_fallback_respects_precedence -test_agent_owned_quota_array_dispatch_contract -test_shared_authoring_requirements_are_owned -test_secondmate_registry_contract_stays_concise -test_state_startup_and_ordinary_recovery_placement -test_compressed_agents_owner_map -test_intake_reuses_evidence_and_parallelizes_safe_work -test_compressed_agents_retains_authority_and_supervision_safety diff --git a/tests/fm-kimi-harness.test.sh b/tests/fm-kimi-harness.test.sh index 8ac5922ec5..b9f8480345 100755 --- a/tests/fm-kimi-harness.test.sh +++ b/tests/fm-kimi-harness.test.sh @@ -9,33 +9,17 @@ SPAWN="$ROOT/bin/fm-spawn.sh" TEARDOWN="$ROOT/bin/fm-teardown.sh" KIMI_HOOK="$ROOT/bin/fm-kimi-turnend-hook.sh" TMP_ROOT=$(fm_test_tmproot fm-kimi-harness) +KIMI_RUNTIME_TASK_TMP= PYTHON_BIN=$(command -v python3) || fail "test needs python3" PYTHON_BIN_DIR=$(dirname "$PYTHON_BIN") JQ_BIN=$(command -v jq) || fail "test needs jq" BASE_PATH=${FM_TEST_BASE_PATH:-$PYTHON_BIN_DIR:/usr/bin:/bin:/usr/sbin:/sbin} -assert_source_line() { - local line=$1 - grep -Fqx -- "$line" "$SPAWN" || fail "existing launch template changed: $line" -} - -test_existing_launch_templates_are_byte_pinned() { - assert_source_line " claude) printf '%s' 'CLAUDE_CODE_ENABLE_PROMPT_SUGGESTION=false claude --dangerously-skip-permissions __MODELFLAG____EFFORTFLAG__\"\$(__OPINPUT__ encode launch-brief < __BRIEF__)\"' ;;" - assert_source_line " printf '%s' 'codex __MODELFLAG____EFFORTFLAG__--dangerously-bypass-approvals-and-sandbox \"\$(__OPINPUT__ encode launch-brief < __BRIEF__)\"'" - assert_source_line " printf '%s' 'codex __MODELFLAG____EFFORTFLAG__--dangerously-bypass-approvals-and-sandbox -c \"notify=[\\\"bash\\\",\\\"-c\\\",\\\"touch __TURNEND__\\\"]\" \"\$(__OPINPUT__ encode launch-brief < __BRIEF__)\"'" - assert_source_line " opencode) printf '%s' 'OPENCODE_CONFIG_CONTENT='\\''{\"permission\":{\"*\":\"allow\"}}'\\'' opencode __MODELFLAG__--prompt \"\$(__OPINPUT__ encode launch-brief < __BRIEF__)\"' ;;" - assert_source_line " printf '%s%s' \"\$harness\" ' __MODELFLAG____EFFORTFLAG__-e __PITURNEND__ -e __PIWATCH__ \"\$(__OPINPUT__ encode launch-brief < __BRIEF__)\"'" - assert_source_line " printf '%s%s' \"\$harness\" ' __MODELFLAG____EFFORTFLAG__-e __PIEXT__ \"\$(__OPINPUT__ encode launch-brief < __BRIEF__)\"'" - assert_source_line " grok) printf '%s' 'grok --always-approve __MODELFLAG____EFFORTFLAG__\"\$(__OPINPUT__ encode launch-brief < __BRIEF__)\"' ;;" - pass "fm-spawn: the five pre-existing adapters' launch templates stay byte-pinned" -} - -test_tracked_files_have_no_user_absolute_paths() { - local pattern="/""Users/" matches - matches=$(git -C "$ROOT" grep -n -F "$pattern" -- . || true) - [ -z "$matches" ] || fail "tracked files contain user-specific absolute paths: $matches" - pass "repository: tracked files contain no user-specific absolute paths" +cleanup_kimi_harness() { + [ -z "$KIMI_RUNTIME_TASK_TMP" ] || rm -rf "$KIMI_RUNTIME_TASK_TMP" + rm -rf "$TMP_ROOT" } +trap cleanup_kimi_harness EXIT make_spawn_fakebin() { local dir=$1 fakebin @@ -193,8 +177,11 @@ EOF } test_kimi_launch_then_send_is_verified() { - local id rec out rc launch pointer brief_real meta - id=kimi-success-z1 + local id rec out rc launch pointer brief_real meta task_tmp + id="kimi-success-z1-$$" + task_tmp="/tmp/fm-$id" + KIMI_RUNTIME_TASK_TMP=$task_tmp + rm -rf "$task_tmp" rec=$(make_spawn_case success "$id") read_spawn_record "$rec" out=$(FM_FAKE_KIMI_SWALLOW_FIRST=yes run_spawn \ @@ -218,6 +205,10 @@ test_kimi_launch_then_send_is_verified() { meta="$HOME_DIR/state/$id.meta" assert_grep 'model=kimi-code/k3' "$meta" "kimi meta lost the requested model" assert_grep 'effort=high' "$meta" "kimi meta did not retain the unsupported effort axis" + assert_grep "tasktmp=$task_tmp" "$meta" "kimi meta did not record its task temp root" + assert_present "$task_tmp/gotmp" "kimi spawn did not create its Go temp directory" + assert_grep "export GOTMPDIR=$task_tmp/gotmp" "$CASE_DIR/tmux-calls.log" \ + "kimi spawn did not export its Go temp directory into the pane" assert_grep 'BEGIN FIRSTMATE KIMI TURN-END HOOK' "$HOME_DIR/.kimi-code/config.toml" \ "kimi spawn did not install its guarded global hook region" assert_grep 'token=' "$WT_DIR/.fm-kimi-turnend" "kimi spawn did not write its token pointer" @@ -575,7 +566,7 @@ SH } test_kimi_busy_signature_is_scoped_to_spinner_lines() { - local capture phase kimi_regex_lines + local capture # shellcheck source=/dev/null . "$ROOT/bin/fm-tmux-lib.sh" unset FM_BUSY_REGEX @@ -589,10 +580,11 @@ test_kimi_busy_signature_is_scoped_to_spinner_lines() { # These fixtures reproduce the observed spinner shape rather than byte-exact # transcriptions. Leading whitespace is deliberately varied; separator whitespace # follows the captured contract. - printf ' 🌑 · Tip: ask Kimi to schedule tasks, e.g. "remind me at 5pm"\n│ > │\n' > "$capture" - fm_pane_is_busy fake kimi || fail "the first real Kimi spinner shape was not recognized as busy" - printf ' 🌗 · Tip: /plugins: manage plugins ...\n│ > │\n' > "$capture" - fm_pane_is_busy fake kimi || fail "the tool-execution Kimi spinner shape was not recognized as busy" + local phase + for phase in 🌑 🌒 🌓 🌔 🌕 🌖 🌗 🌘; do + printf ' %s · Tip: Kimi is working\n│ > │\n' "$phase" > "$capture" + fm_pane_is_busy fake kimi || fail "Kimi spinner phase $phase was not recognized as busy" + done printf 'ordinary response ending with 🌕\n│ > │\n' > "$capture" if fm_pane_is_busy fake kimi; then fail "a moon outside Kimi's spinner-line shape was misread as busy" @@ -617,18 +609,10 @@ test_kimi_busy_signature_is_scoped_to_spinner_lines() { if fm_pane_is_busy fake kimi; then fail "Kimi's idle thinking-effort status label was misread as busy" fi - kimi_regex_lines=$(grep 'KIMI_BUSY_REGEX' "$ROOT/bin/fm-tmux-lib.sh" "$ROOT/bin/fm-watch.sh") - if printf '%s\n' "$kimi_regex_lines" | grep -qi thinking; then - fail "Kimi busy regex still depends on a Thinking or thinking token" - fi - for phase in 🌑 🌒 🌓 🌔 🌕 🌖 🌗 🌘; do - grep -Fq "$phase" "$ROOT/bin/fm-tmux-lib.sh" \ - || fail "shared Kimi matcher is missing moon phase $phase" - done pass "busy detection: real Kimi moon-plus-middot captures require its harness while idle labels stay idle" } -test_watcher_scopes_moon_spinner_to_recorded_kimi_task() ( +test_watcher_never_classifies_kimi_from_its_spinner() ( local state="$TMP_ROOT/watch-state" busy_capture=' 🌑 · Tip: ask Kimi to schedule tasks, e.g. "remind me at 5pm"' mkdir -p "$state" printf 'window=fake\nharness=kimi\n' > "$state/kimi-watch.meta" @@ -640,26 +624,26 @@ test_watcher_scopes_moon_spinner_to_recorded_kimi_task() ( . "$ROOT/bin/fm-watch.sh" # shellcheck disable=SC2329 # Runtime override called by the sourced watcher. fm_backend_busy_state() { printf 'unknown'; } - window_is_busy fake "$busy_capture" \ - || fail "fm-watch did not recognize the real Kimi spinner-line shape" - printf 'window=fake\nharness=codex\n' > "$state/kimi-watch.meta" + # Standalone Kimi has no verified semantic busy source, so it classifies + # unknown - and unknown is never working. Its moon-phase spinner is + # deliberately not a state source: the approved redesign forbids inventing a + # Kimi UI signature, and that glyph set is locale- and emoji-font-sensitive. if window_is_busy fake "$busy_capture"; then - fail "fm-watch applied Kimi's real spinner signature to a recorded Codex task" - fi - printf 'window=fake\nharness=kimi\n' > "$state/kimi-watch.meta" - if window_is_busy fake 'ordinary response ending with 🌕'; then - fail "fm-watch treated an ordinary Kimi moon as a spinner line" + fail "fm-watch classified a Kimi task busy from its spinner instead of unknown" fi - if window_is_busy fake '🌕 Full moon details'; then - fail "fm-watch treated moon-led Kimi output without the middot separator as busy" - fi - if window_is_busy fake 'auto K2.7 Coding thinking /some/path'; then - fail "fm-watch treated Kimi's idle thinking-effort status label as busy" + [ "$(fm_busy_classify tmux fake kimi kimi-watch "$state" "$busy_capture")" = "unknown kimi-unverified" ] \ + || fail "a Kimi task must classify unknown kimi-unverified" + printf 'window=fake\nharness=codex\n' > "$state/kimi-watch.meta" + if window_is_busy fake "$busy_capture"; then + fail "fm-watch applied Kimi's spinner to a recorded Codex task" fi - if window_is_busy fake 'Ctrl+c:cancel'; then - fail "fm-watch let Grok's exact busy token classify a recorded Kimi task busy" + printf 'window=fake\nharness=grok\n' > "$state/kimi-watch.meta" + if window_is_busy fake "$busy_capture"; then + fail "Kimi's spinner classified a recorded Grok task through its isolated fallback" fi - pass "fm-watch: Kimi spinner matching is metadata-scoped and ignores Grok's busy token" + window_is_busy fake 'Ctrl+c:cancel' \ + || fail "Grok's own verified token must still classify a recorded Grok task busy" + pass "fm-watch classifies Kimi as unknown rather than from its spinner, and Grok's fallback stays isolated" ) test_kimi_bordered_prompt_needs_no_override() { @@ -673,8 +657,6 @@ test_kimi_bordered_prompt_needs_no_override() { pass "composer classifier: kimi's existing bordered > shape is already safe without an override" } -test_tracked_files_have_no_user_absolute_paths -test_existing_launch_templates_are_byte_pinned test_kimi_hook_install_is_surgical_idempotent_and_removable test_kimi_hook_remove_preserves_owned_newline_boundary test_kimi_hook_fails_closed_on_missing_malformed_or_partial_config @@ -690,5 +672,5 @@ test_kimi_readiness_gate_precedes_pointer test_kimi_detection_uses_ancestry_after_markers test_kimi_session_lock_identity test_kimi_busy_signature_is_scoped_to_spinner_lines -test_watcher_scopes_moon_spinner_to_recorded_kimi_task +test_watcher_never_classifies_kimi_from_its_spinner test_kimi_bordered_prompt_needs_no_override diff --git a/tests/fm-lint.test.sh b/tests/fm-lint.test.sh index 4a1b18d7dc..17fb097f75 100755 --- a/tests/fm-lint.test.sh +++ b/tests/fm-lint.test.sh @@ -18,11 +18,7 @@ set -u . "$(dirname "${BASH_SOURCE[0]}")/lib.sh" LINT="$ROOT/bin/fm-lint.sh" -CI="$ROOT/.github/workflows/ci.yml" -NM="$ROOT/.no-mistakes.yaml" INSTALLER="$ROOT/bin/fm-install-shellcheck.sh" -# The authoritative file set the one owner must run. -CANON='ROOTS=(bin/*.sh bin/backends/*.sh tests/*.sh)' # The pinned version, read from the single source (the one owner itself). REQUIRED=$("$LINT" --required-version) @@ -33,34 +29,13 @@ pinned_ready() { [ "$(shellcheck --version | awk '/^version:/ {print $2; exit}')" = "$REQUIRED" ] } -test_owner_exists_and_executable() { - assert_present "$LINT" "bin/fm-lint.sh is missing" - [ -x "$LINT" ] || fail "bin/fm-lint.sh must be executable so CI/gate can run it directly" - pass "one-owner lint script exists and is executable" -} - -test_owner_defines_canonical_set() { - assert_grep "$CANON" "$LINT" "fm-lint.sh must run the canonical shellcheck file set" - # It must not weaken CI: no severity downgrade and no blanket disable/exclude - # that would hide findings CI fails on. - assert_no_grep '--severity' "$LINT" "fm-lint.sh must not lower severity below the CI default" - assert_no_grep '--exclude' "$LINT" "fm-lint.sh must not blanket-exclude checks CI enforces" - assert_grep "\"\$FM_LINT_SHELLCHECK\" --norc --external-sources -- \"\${roots[@]}\"" "$LINT" "every bounded worker must ignore ambient config and preserve annotated production sources" - [ "$(grep -Fc -- '--norc --external-sources' "$LINT")" -eq 1 ] || fail "the one worker command must own ShellCheck configuration" - assert_grep "JOBS=\${FM_LINT_JOBS:-2}" "$LINT" "canonical lint must default to two bounded workers" - pass "fm-lint.sh is the sole authoritative definition at CI-default severity" -} - -test_ci_invokes_the_owner() { - grep -Eq '^ - run: bin/fm-lint\.sh$' "$CI" || fail "CI lint job must invoke the one-owner script as a run step" - # Guard against regression to an inline re-spelling of the command. - assert_no_grep 'run: shellcheck' "$CI" "CI must call fm-lint.sh, not re-spell shellcheck inline" - pass "CI lint job calls the one-owner script, not an inline command" -} - -test_nomistakes_invokes_the_owner() { - grep -Fqx " lint: 'bin/fm-lint.sh'" "$NM" || fail "no-mistakes commands.lint must map exactly to the one-owner script" - pass "no-mistakes pre-push lint calls the one-owner script" +test_list_files_reports_the_shell_inventory() { + local listed expected + listed=$("$LINT" --list-files) + expected=$(find bin bin/backends tests -maxdepth 1 -type f -name '*.sh' -print | LC_ALL=C sort) + [ "$(printf '%s\n' "$listed" | LC_ALL=C sort)" = "$expected" ] \ + || fail "fm-lint.sh --list-files did not return the complete shell inventory" + pass "fm-lint.sh --list-files reports the complete shell inventory" } test_pins_an_explicit_version() { @@ -71,17 +46,6 @@ test_pins_an_explicit_version() { pass "fm-lint.sh pins an explicit ShellCheck version ($REQUIRED)" } -test_ci_installs_and_logs_the_pinned_version() { - # CI must derive the version from the one owner (never hardcode a divergent - # number) and log the resolved version as parity evidence. - assert_grep "VERSION=\"\$(\"\$ROOT/bin/fm-lint.sh\" --required-version)\"" "$INSTALLER" "installer must read the version fm-lint.sh pins" - [ "$(grep -Fc "bin/fm-install-shellcheck.sh \"\$RUNNER_TEMP/bin\"" "$CI")" -eq 4 ] || fail "lint and all three portable behavior jobs must use the shared ShellCheck installer" - assert_grep "ACTUAL_SHA256=\$(sha256sum" "$INSTALLER" "installer must calculate the ShellCheck archive checksum" - assert_grep "[ \"\$ACTUAL_SHA256\" = \"\$SHA256\" ]" "$INSTALLER" "installer must verify the ShellCheck archive checksum" - assert_grep "\"\$DESTINATION/shellcheck\" --version" "$INSTALLER" "installer must log the resolved ShellCheck version as evidence" - pass "CI installs and logs the pinned ShellCheck version from the one owner" -} - test_installer_retries_transient_download_failure() { local tmp fakebin destination out tmp=$(fm_test_tmproot fm-shellcheck-download) @@ -238,26 +202,6 @@ SH pass "fm-lint.sh passes a clean fixture" } -test_source_graph_boundaries_keep_every_owner() { - local adapter file production_context_tests="" - [ "$(grep -Fc '# shellcheck source=/dev/null' "$ROOT/bin/fm-backend.sh")" -eq 5 ] \ - || fail "the dispatcher must stop static source following at all five dynamic adapters" - for adapter in tmux herdr zellij orca cmux; do - assert_present "$ROOT/bin/backends/$adapter.sh" "canonical adapter root is missing: $adapter" - done - assert_present "$ROOT/bin/fm-push-transition-lib.sh" "narrow push-transition owner is missing" - assert_grep '# shellcheck source=bin/fm-push-transition-lib.sh' "$ROOT/bin/fm-watch.sh" "the watcher must consume the narrow push-transition owner" - assert_grep ". \"\$ROOT/bin/fm-push-transition-lib.sh\"" "$ROOT/tests/fm-backend-herdr-eventwait-smoke.test.sh" "the Herdr event-wait smoke must consume the narrow production owner" - assert_no_grep '# shellcheck source=bin/fm-watch.sh' "$ROOT/tests/fm-backend-herdr-eventwait-smoke.test.sh" "the event-wait smoke must not re-import the whole watcher graph" - for file in "$ROOT"/tests/*.sh; do - grep -q '^[[:space:]]*# shellcheck source=bin/' "$file" || continue - production_context_tests="${production_context_tests}$(basename "$file")|" - done - [ "$production_context_tests" = 'fm-backend-herdr.test.sh|fm-daemon.test.sh|fm-pending-reply.test.sh|fm-secondmate-sync.test.sh|' ] \ - || fail "only callback/variable interop tests may retain production source context: $production_context_tests" - pass "dispatcher, adapters, production owner, and tests have explicit lint boundaries" -} - test_jobs_are_deterministic_and_complete() { if ! pinned_ready; then pass "SKIP (ShellCheck $REQUIRED not resolved): deterministic bounded jobs check" @@ -482,18 +426,13 @@ SH pass "seeded dispatcher, adapter, production-owner, and test-local diagnostics preserve parity" } -test_owner_exists_and_executable -test_owner_defines_canonical_set -test_ci_invokes_the_owner -test_nomistakes_invokes_the_owner +test_list_files_reports_the_shell_inventory test_pins_an_explicit_version -test_ci_installs_and_logs_the_pinned_version test_installer_retries_transient_download_failure test_rejects_wrong_shellcheck_version test_catches_a_real_lint_defect test_ignores_ambient_shellcheck_opts test_clean_fixture_passes -test_source_graph_boundaries_keep_every_owner test_jobs_are_deterministic_and_complete test_worker_trees_stop_on_signal test_seeded_module_boundary_parity diff --git a/tests/fm-nm-test-contract.test.sh b/tests/fm-nm-test-contract.test.sh deleted file mode 100755 index 54c19eab2f..0000000000 --- a/tests/fm-nm-test-contract.test.sh +++ /dev/null @@ -1,127 +0,0 @@ -#!/usr/bin/env bash -# Contract: local no-mistakes Test is intent-targeted; CI owns broad regression. -# -# Firstmate must not configure commands.test as a complete tests/*.test.sh walk -# (that duplicated CI and burned local pipeline time). Lint stays pinned to -# bin/fm-lint.sh. Remote CI owns broad regression through separate portable and -# required real-Herdr Behavior lanes composed around bin/fm-test-run.sh. -set -u - -# shellcheck source=tests/lib.sh -. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" - -NM="$ROOT/.no-mistakes.yaml" -CI="$ROOT/.github/workflows/ci.yml" - -test_nm_yaml_tracked() { - assert_present "$NM" "tracked .no-mistakes.yaml is missing" - git -C "$ROOT" ls-files --error-unmatch .no-mistakes.yaml >/dev/null 2>&1 \ - || fail ".no-mistakes.yaml is not tracked by git" - pass ".no-mistakes.yaml is present and tracked" -} - -test_nm_keeps_lint_pin() { - grep -Fqx " lint: 'bin/fm-lint.sh'" "$NM" \ - || fail "commands.lint must remain exactly bin/fm-lint.sh" - pass "commands.lint stays pinned to bin/fm-lint.sh" -} - -# True when the YAML maps a non-empty commands.test (string or mapping value). -# Empty / null / absent is the intended targeted-Test posture. -nm_commands_test_value() { - if command -v python3 >/dev/null 2>&1 && python3 -c 'import yaml' >/dev/null 2>&1; then - python3 -c ' -import yaml, sys -doc = yaml.safe_load(open(sys.argv[1])) or {} -cmds = doc.get("commands") or {} -val = cmds.get("test") if isinstance(cmds, dict) else None -if val is None or val is False: - print("") -elif isinstance(val, str): - print(val) -else: - print(repr(val)) -' "$NM" - return - fi - if command -v ruby >/dev/null 2>&1; then - ruby -ryaml -e ' -doc = YAML.safe_load(File.read(ARGV[0])) || {} -cmds = doc["commands"] || {} -val = cmds.is_a?(Hash) ? cmds["test"] : nil -if val.nil? || val == false - puts "" -elsif val.is_a?(String) - puts val -else - puts val.inspect -end -' "$NM" - return - fi - # Structural fallback: any commands.test line under the commands block. - awk ' - /^commands:[[:space:]]*$/ { in_cmds=1; next } - in_cmds && /^[^[:space:]#]/ { in_cmds=0 } - in_cmds && /^[[:space:]]+test:[[:space:]]*/ { - sub(/^[[:space:]]+test:[[:space:]]*/, "") - gsub(/^['\''"]|['\''"]$/, "") - print - exit - } - ' "$NM" -} - -test_nm_has_no_complete_local_test_command() { - local val - val=$(nm_commands_test_value) || fail "failed to read commands.test from .no-mistakes.yaml" - if [ -n "$val" ]; then - case "$val" in - *'tests/*.test.sh'*|*'tests/'*'.test.sh'*) - fail "commands.test must not walk the complete tests/*.test.sh suite; got: $val" - ;; - *) - # Any non-empty override still steers Test away from intent-targeted default. - fail "commands.test must be absent or empty so Test stays intent-targeted; got: $val" - ;; - esac - fi - # Also refuse a commented-out full-suite remnant that could be re-enabled by habit. - if grep -E '^[[:space:]]*#?[[:space:]]*test:[[:space:]].*tests/\*\.test\.sh' "$NM" >/dev/null 2>&1; then - fail ".no-mistakes.yaml still documents a full-suite commands.test line (active or comment)" - fi - pass "no-mistakes does not configure a complete local Test command" -} - -test_ci_still_runs_broad_behavior_suite() { - assert_present "$CI" "ci.yml is missing" - # Portable shards and the serial remainder cover every portable behavior - # script through the one owner, with a deterministic inventory guard. - grep -Fq 'bin/fm-test-run.sh --lane portable-parallel-1' "$CI" \ - || fail "CI must invoke portable parallel shard 1 through fm-test-run.sh" - grep -Fq 'bin/fm-test-run.sh --lane portable-parallel-2' "$CI" \ - || fail "CI must invoke portable parallel shard 2 through fm-test-run.sh" - grep -Fq 'bin/fm-test-run.sh --lane portable-serial' "$CI" \ - || fail "CI must invoke the portable serial remainder through fm-test-run.sh" - grep -Fq 'bin/fm-test-run.sh --check-coverage' "$CI" \ - || fail "CI must prove complete lane coverage through fm-test-run.sh" - # Guard against regression to an uninstrumented inline loop that drops timing. - if grep -Eq 'for test_script in tests/\*\.test\.sh' "$CI"; then - fail "CI Behavior must not re-spell an inline tests/*.test.sh loop; use fm-test-run.sh" - fi - # Preserve other CI lanes this task must not shrink. - grep -Eq 'name:[[:space:]]*Lint shell scripts' "$CI" \ - || fail "CI must retain the lint job" - grep -Eq 'name:[[:space:]]*Stock macOS Bash snapshot compatibility' "$CI" \ - || fail "CI must retain the macOS stock Bash compatibility job" - grep -Eq 'name:[[:space:]]*Repo invariants' "$CI" \ - || fail "CI must retain the repo invariants job" - grep -Fq 'tests-herdr:' "$CI" \ - || fail "CI must retain the required Herdr Behavior job" - pass "CI still owns partitioned broad behavior coverage and companion jobs" -} - -test_nm_yaml_tracked -test_nm_keeps_lint_pin -test_nm_has_no_complete_local_test_command -test_ci_still_runs_broad_behavior_suite diff --git a/tests/fm-no-mistakes-ownership.test.sh b/tests/fm-no-mistakes-ownership.test.sh deleted file mode 100755 index b7e7fc2a6f..0000000000 --- a/tests/fm-no-mistakes-ownership.test.sh +++ /dev/null @@ -1,39 +0,0 @@ -#!/usr/bin/env bash -# Static contract tests for crew-owned no-mistakes validation runs. -set -u - -# shellcheck source=tests/lib.sh disable=SC1091 -. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" - -validate_contract() { - awk ' - /^### Validate$/ { found = 1; next } - found && /^### / { exit } - found { print } - ' "$ROOT/AGENTS.md" -} - -test_worker_owns_synchronous_driver() { - local contract - contract=$(validate_contract) - - assert_contains "$contract" 'The task worker that starts a no-mistakes run drives the pipeline' \ - "Validate contract does not assign the run to its initiating task worker" - assert_contains "$contract" "owns every \`no-mistakes axi run\` and \`no-mistakes axi respond\` call through the next gate or outcome" \ - "Validate contract does not assign every synchronous driver call to the task worker" - assert_contains "$contract" 'process every synchronous return until completion or a genuinely new escalation' \ - "Validate contract does not require the task worker to process every synchronous return" - pass "Validate contract assigns the complete synchronous driver loop to the initiating task worker" -} - -test_firstmate_never_responds_for_crew_run() { - local contract - contract=$(validate_contract) - - assert_contains "$contract" "Firstmate never invokes \`no-mistakes axi respond\` for a crew-owned run." \ - "Validate contract permits Firstmate to respond directly for a crew-owned run" - pass "Validate contract forbids Firstmate from responding directly for a crew-owned run" -} - -test_worker_owns_synchronous_driver -test_firstmate_never_responds_for_crew_run diff --git a/tests/fm-pi-primary-live-e2e.test.sh b/tests/fm-pi-primary-live-e2e.test.sh index a365f06b9c..63f3cb8abb 100755 --- a/tests/fm-pi-primary-live-e2e.test.sh +++ b/tests/fm-pi-primary-live-e2e.test.sh @@ -254,6 +254,7 @@ cp "$ROOT/.pi/extensions/fm-primary-pi-watch.ts" "$PROJECT/.pi/extensions/fm-pri cp "$ROOT/.pi/extensions/lib/fm-calm-assistant-layout.ts" "$PROJECT/.pi/extensions/lib/fm-calm-assistant-layout.ts" cp "$ROOT/.pi/extensions/lib/fm-calm-operational-user-layout.ts" "$PROJECT/.pi/extensions/lib/fm-calm-operational-user-layout.ts" cp "$ROOT/.pi/extensions/lib/fm-calm-visibility.ts" "$PROJECT/.pi/extensions/lib/fm-calm-visibility.ts" +cp "$ROOT/.pi/extensions/lib/fm-calm-working-ship.ts" "$PROJECT/.pi/extensions/lib/fm-calm-working-ship.ts" cp "$ROOT/.pi/extensions/lib/fm-operational-input.ts" "$PROJECT/.pi/extensions/lib/fm-operational-input.ts" cp "$ROOT/.pi/extensions/fm-primary-turnend-guard.ts" "$PROJECT/.pi/extensions/fm-primary-turnend-guard.ts" cp "$ROOT/bin/fm-watch-arm.sh" "$PROJECT/bin/fm-watch-arm.sh" @@ -282,17 +283,21 @@ send_prompt "Reply exactly CALM_LIVE_WORKING_VISIBLE" i=0 while [ "$i" -lt 240 ]; do pane=$(capture) - if printf '%s\n' "$pane" | grep -Fq "Working..."; then + if printf '%s\n' "$pane" | grep -Fq '\__/'; then break fi sleep 0.05 i=$((i + 1)) done +printf '%s\n' "$pane" | grep -Fq '\__/' \ + || fail "Calm did not show the working ship on the credentialed provider path" printf '%s\n' "$pane" | grep -Fq "Working..." \ - || fail "Calm hid Pi's built-in Working row on the credentialed provider path" + && fail "Calm left Pi's stock working row visible on the credentialed provider path" wait_for_exact_line "CALM_LIVE_WORKING_VISIBLE" 120 \ - || fail "Pi did not settle the Calm Working-row provider probe" + || fail "Pi did not settle the Calm working-ship provider probe" pane=$(capture) +printf '%s\n' "$pane" | grep -Fq '\__/' \ + && fail "Calm left the working ship on screen after the run settled" printf '%s\n' "$pane" | grep -Fq "calm transcript" \ && fail "Calm added a persistent Calm status row on the credentialed provider path" send_prompt "/calm" @@ -336,4 +341,4 @@ wait_for_text "PI_EXIT=0" 60 || fail "Pi did not exit cleanly" wait_pid_dead "$watcher_pid" || fail "watcher child survived clean Pi exit" wait_pid_dead "$arm_pid" || fail "arm child survived clean Pi exit" -printf 'ok - Pi %s live E2E covered native Calm Working visibility, Ahoy first/later messages, legacy transcripts, near misses, and watcher continuity\n' "$PI_VERSION" +printf 'ok - Pi %s live E2E covered the Calm working ship, Ahoy first/later messages, legacy transcripts, near misses, and watcher continuity\n' "$PI_VERSION" diff --git a/tests/fm-pi-primary-types.test.sh b/tests/fm-pi-primary-types.test.sh index 3ff81f63ef..68d20afd49 100755 --- a/tests/fm-pi-primary-types.test.sh +++ b/tests/fm-pi-primary-types.test.sh @@ -32,6 +32,7 @@ cp "$ROOT/.pi/extensions/fm-primary-turnend-guard.ts" "$TMP_ROOT/fm-primary-turn cp "$ROOT/.pi/extensions/lib/fm-calm-assistant-layout.ts" "$TMP_ROOT/lib/fm-calm-assistant-layout.ts" cp "$ROOT/.pi/extensions/lib/fm-calm-operational-user-layout.ts" "$TMP_ROOT/lib/fm-calm-operational-user-layout.ts" cp "$ROOT/.pi/extensions/lib/fm-calm-visibility.ts" "$TMP_ROOT/lib/fm-calm-visibility.ts" +cp "$ROOT/.pi/extensions/lib/fm-calm-working-ship.ts" "$TMP_ROOT/lib/fm-calm-working-ship.ts" cp "$ROOT/.pi/extensions/lib/fm-operational-input.ts" "$TMP_ROOT/lib/fm-operational-input.ts" ln -s "$PI_PACKAGE_DIR" "$TMP_ROOT/node_modules/@earendil-works/pi-coding-agent" ln -s "$PI_PACKAGE_DIR/node_modules/@earendil-works/pi-tui" "$TMP_ROOT/node_modules/@earendil-works/pi-tui" diff --git a/tests/fm-pi-watch-extension.test.sh b/tests/fm-pi-watch-extension.test.sh index 518df0e874..f888319489 100755 --- a/tests/fm-pi-watch-extension.test.sh +++ b/tests/fm-pi-watch-extension.test.sh @@ -59,64 +59,6 @@ export const Type = { JS } -test_tracked_extension_present_and_self_hashing() { - local text expected_config_source - expected_config_source="config_dir=\\\"\${FM_CONFIG_OVERRIDE:-\$FM_HOME/config}\\\"" - assert_present "$EXT" "tracked Pi primary watcher extension is missing" - text=$(cat "$EXT") - assert_contains "$text" "fm_watch_arm_pi" "tracked extension missing tool name" - assert_contains "$text" "fm-watch-arm-pi" "tracked extension missing command name" - assert_contains "$text" "fm-watch-arm.sh" "tracked extension missing watcher arm" - assert_contains "$text" "sendUserMessage" "tracked extension missing Pi wake API" - assert_contains "$text" 'encodeFirstmateOperationalInput' "tracked extension does not construct typed synthetic user-role wakes" - assert_contains "$text" "deliverAs: \"followUp\"" "tracked extension missing followUp delivery" - assert_contains "$text" ".pi-watch-extension-loaded" "tracked extension missing loaded marker" - assert_contains "$text" 'createHash("sha256").update(readFileSync(extensionFile)).digest("hex")' "tracked extension does not self-hash its own content for extensionVersion" - assert_contains "$text" 'fileURLToPath(import.meta.url)' "tracked extension does not self-locate via import.meta.url" - assert_contains "$text" 'type LockOwnership = "owned" | "missing" | "other"' "tracked extension does not distinguish missing lock from another owner" - assert_contains "$text" "readFileSync(\`\${state}/.lock\`" "tracked extension does not read the effective session lock" - assert_contains "$text" 'return pidAlive(lockPid) ? "other" : "missing"' "tracked extension does not allow a pre-lock load marker" - assert_contains "$text" 'if (lockOwnership() === "other") return' "tracked extension overwrites another live session marker" - assert_contains "$text" 'const ownership = lockOwnership()' "tracked extension arm does not inspect the distinct lock ownership state" - assert_contains "$text" 'if (ownership === "other") return { ok: false' "tracked extension arm does not preserve the live-other read-only refusal" - assert_contains "$text" 'if (ownership === "missing")' "tracked extension arm collapses a stale or absent lock into the live-other refusal" - assert_contains "$text" "no live session holds the lock" "tracked extension arm missing stale-lock recovery guidance" - assert_contains "$text" "run bin/fm-session-start.sh to reclaim it" "tracked extension arm does not direct stale-lock reclamation" - assert_contains "$text" "call fm_watch_arm_pi to re-arm" "tracked extension arm does not direct supervision re-arm" - assert_contains "$text" "writeFileSync(marker, \`\${extensionVersion}\\n\${process.pid}\\n\`)" "tracked extension does not write the content version and process marker" - assert_contains "$text" "const config = process.env.FM_CONFIG_OVERRIDE" "tracked extension missing effective config resolution" - assert_contains "$text" "FM_CONFIG_OVERRIDE: config" "tracked extension does not pass the effective config to the watcher arm" - assert_contains "$text" "FM_WATCH_ARM_SCRIPT: armScript" "tracked extension does not pass the effective watcher arm script" - assert_contains "$text" "$expected_config_source" "tracked extension does not source the effective x-mode config" - assert_contains "$text" "exec \\\"\$FM_WATCH_ARM_SCRIPT\\\" --restart" "tracked extension does not restart into a Pi-owned watcher child" - assert_contains "$text" 'label: "Arm firstmate watcher"' "tracked extension tool is missing its human-readable label" - assert_not_contains "$text" "Always use this tool" "tracked extension kept broad tool-selection guidance" - assert_contains "$text" "only for the first required cycle or after a notification says the cycle is missing, failed, or unhealthy" "tracked extension tool metadata is missing the Pi first-cycle or explicit-repair rule" - assert_contains "$text" "Do not call it after ordinary work, turn completion, or ordinary signal, stale, check, or heartbeat handling" "tracked extension prompt guidance does not prevent redundant ordinary-notification calls" - assert_contains "$text" 'parameters: Type.Object({})' "tracked extension tool is not using Pi's canonical TypeBox schema" - assert_contains "$text" 'content: [{ type: "text", text: result.message }]' "tracked extension tool is missing Pi text content" - assert_contains "$text" 'details: result' "tracked extension tool is missing structured result details" - assert_contains "$text" 'ctx.ui.notify' "tracked extension command does not notify through Pi's UI" - assert_contains "$text" 'process.once("exit", cleanupOnProcessExit)' "tracked extension lacks clean-process-exit cleanup" - assert_contains "$text" "type SessionGeneration" "tracked extension lacks an explicit session-generation owner" - assert_contains "$text" "function activateGeneration" "tracked extension does not activate a live generation for replacement sessions" - assert_contains "$text" "function generationIsLive" "tracked extension does not gate arm mutations on the live generation" - assert_contains "$text" "watcher: not armed - Pi session is shutting down" "tracked extension missing the terminal shutdown refusal" - assert_not_contains "$text" "[ -f config/x-mode.env ]" "tracked extension kept a repo-relative x-mode config path" - pass "Pi primary watcher extension is tracked, self-hashing, and self-locating" -} - -test_spawn_template_mentions_pi_watch_placeholder() { - local text - text=$(cat "$ROOT/bin/fm-spawn.sh") - assert_contains "$text" "-e __PITURNEND__ -e __PIWATCH__" "Pi secondmate launch template does not include both primary extensions" - assert_contains "$text" "\$PROJ_ABS/.pi/extensions/fm-primary-pi-watch.ts" "fm-spawn does not point the Pi secondmate watch placeholder at the tracked extension" - assert_not_contains "$text" "fm-pi-watch-extension.sh" "fm-spawn should no longer generate the Pi watch extension before launch" - assert_contains "$text" "__PITURNEND__" "fm-spawn does not replace the Pi turn-end guard extension placeholder" - assert_contains "$text" "__PIWATCH__" "fm-spawn does not replace the Pi watch extension placeholder" - pass "Pi secondmate launch wiring includes both tracked primary extensions" -} - test_pi_extension_reports_external_healthy_watcher() { local repo home plugin out status repo="$TMP_ROOT/pi-external-healthy-root" @@ -1234,26 +1176,6 @@ EOF pass "Pi process-exit cleanup stops the attached arm child" } -test_opencode_primary_watch_plugin_static_wiring() { - local plugin module_boundary text - plugin="$ROOT/.opencode/plugins/fm-primary-watch-arm.js" - module_boundary="$ROOT/.opencode/plugins/package.json" - assert_present "$plugin" "OpenCode primary watch plugin missing" - assert_present "$module_boundary" "OpenCode plugin ESM package boundary missing" - assert_contains "$(cat "$module_boundary")" '"type": "module"' "OpenCode plugin package boundary is not explicitly ESM" - text=$(cat "$plugin") - assert_contains "$text" "session.idle" "OpenCode plugin does not listen for session.idle" - assert_contains "$text" "fm-watch-arm.sh" "OpenCode plugin does not spawn the watcher arm" - assert_contains "$text" "promptAsync" "OpenCode plugin does not wake with promptAsync" - assert_contains "$text" 'encodeFirstmateOperationalInput' "OpenCode plugin does not construct typed synthetic user-role wakes" - assert_contains "$text" ".fm-secondmate-home" "OpenCode plugin does not scope out secondmate homes" - assert_contains "$text" "rev-parse\", \"--git-dir" "OpenCode plugin does not check linked worktree scope" - assert_contains "$text" "sessionOwnsLock" "OpenCode plugin does not gate arm attempts on the session lock" - assert_contains "$text" 'fm-watch-arm.sh" --restart' "OpenCode plugin does not restart into its own watcher child" - assert_contains "$text" 'setArmStatus("external")' "OpenCode plugin still treats an external healthy watcher as armed" - pass "OpenCode primary watcher plugin has the verified TUI wake wiring" -} - test_opencode_plugin_package_boundary_is_explicit_esm() { local fixture plugin out status fixture="$TMP_ROOT/opencode-esm-boundary/.opencode" @@ -2202,8 +2124,6 @@ EOF pass "OpenCode healthy arm output does not suppress the turn-end guard" } -test_tracked_extension_present_and_self_hashing -test_spawn_template_mentions_pi_watch_placeholder test_pi_extension_reports_external_healthy_watcher test_pi_tool_returns_agent_tool_result test_pi_redundant_tool_call_is_owned_noop @@ -2219,7 +2139,6 @@ test_pi_arm_distinguishes_session_lock_ownership test_pi_session_transition_generation_owner test_pi_process_exit_cleanup_listener_lifecycle test_pi_process_exit_cleanup_stops_arm_child -test_opencode_primary_watch_plugin_static_wiring test_opencode_plugin_package_boundary_is_explicit_esm test_opencode_primary_watch_plugin_uses_effective_state_home test_opencode_primary_watch_plugin_sources_effective_config diff --git a/tests/fm-pr-check-security.test.sh b/tests/fm-pr-check-security.test.sh index f30e4cf964..1b21e5d6a8 100755 --- a/tests/fm-pr-check-security.test.sh +++ b/tests/fm-pr-check-security.test.sh @@ -98,7 +98,8 @@ SH write_task_meta() { local dir=$1 id=${2:-task-a} fm_write_meta "$dir/home/state/$id.meta" \ - "window=fm-$id" \ + "window=firstmate:fm-$id" \ + "endpoint_task_id=$id" \ "worktree=$dir/wt" \ "project=$dir/project" \ "kind=ship" \ @@ -592,7 +593,8 @@ SH for id in _noncanonical aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa; do dir=$(make_case "legacy-teardown-${id:0:12}") fm_write_meta "$dir/home/state/$id.meta" \ - "window=fm-$id" \ + "window=firstmate:fm-$id" \ + "endpoint_task_id=$id" \ "worktree=$dir/missing-worktree" \ "project=$dir/project" \ 'kind=ship' \ @@ -1676,7 +1678,8 @@ test_complete_single_link_validation() { state="$dir/home/state" fakebin="$dir/fakebin" fm_write_meta "$state/task-a.meta" \ - 'window=fm-task-a' \ + 'window=firstmate:fm-task-a' \ + 'endpoint_task_id=task-a' \ "worktree=$dir/missing-worktree" \ "project=$dir/project" \ 'kind=ship' \ @@ -1836,7 +1839,8 @@ test_obligation_namespace_compatibility() { > "$state/.pr-check-quarantine/_noncanonical.check.abc123" chmod 0600 "$state/.pr-check-quarantine/"* fm_write_meta "$state/_noncanonical.meta" \ - 'window=fm-_noncanonical' \ + 'window=firstmate:fm-_noncanonical' \ + 'endpoint_task_id=_noncanonical' \ "worktree=$dir/missing-worktree" \ "project=$dir/project" \ 'kind=ship' \ @@ -2603,7 +2607,8 @@ test_teardown_removes_poll_artifacts() { dir=$(make_case teardown-cleanup) fakebin="$dir/fakebin" fm_write_meta "$dir/home/state/task-a.meta" \ - 'window=fm-task-a' \ + 'window=firstmate:fm-task-a' \ + 'endpoint_task_id=task-a' \ "worktree=$dir/missing-worktree" \ "project=$dir/project" \ 'kind=ship' \ @@ -2636,7 +2641,8 @@ SH dir=$(make_case teardown-retirement-receipt) fakebin="$dir/fakebin" fm_write_meta "$dir/home/state/task-a.meta" \ - 'window=fm-task-a' \ + 'window=firstmate:fm-task-a' \ + 'endpoint_task_id=task-a' \ "worktree=$dir/missing-worktree" \ "project=$dir/project" \ 'kind=ship' \ @@ -2663,7 +2669,8 @@ SH dir=$(make_case teardown-reserved-quarantine) fakebin="$dir/fakebin" fm_write_meta "$dir/home/state/invalid.meta" \ - 'window=fm-invalid' \ + 'window=firstmate:fm-invalid' \ + 'endpoint_task_id=invalid' \ "worktree=$dir/missing-worktree" \ "project=$dir/project" \ 'kind=ship' \ @@ -2693,7 +2700,8 @@ SH dir=$(make_case "teardown-final-directory-${artifact//./-}") fakebin="$dir/fakebin" fm_write_meta "$dir/home/state/task-a.meta" \ - 'window=fm-task-a' \ + 'window=firstmate:fm-task-a' \ + 'endpoint_task_id=task-a' \ "worktree=$dir/missing-worktree" \ "project=$dir/project" \ 'kind=ship' \ @@ -2733,7 +2741,8 @@ SH dir=$(make_case "teardown-quarantine-link-$kind") fakebin="$dir/fakebin" fm_write_meta "$dir/home/state/task-a.meta" \ - 'window=fm-task-a' \ + 'window=firstmate:fm-task-a' \ + 'endpoint_task_id=task-a' \ "worktree=$dir/missing-worktree" \ "project=$dir/project" \ 'kind=ship' \ @@ -2873,11 +2882,6 @@ EOF [ "$rc" -eq 2 ] || fail "merge wrapper did not refuse a GitLab merge request URL" [ ! -s "$dir/gh-axi.log" ] || fail "merge wrapper reached the GitHub CLI for a GitLab URL" - # The instance is data, never a constant, so self-hosted instances work. - ! grep -qF gitlab.com "$ROOT/bin/fm-pr-lib.sh" \ - || fail "the shared PR library hardcodes a GitLab host" - ! grep -qF gitlab.com "$ROOT/bin/fm-pr-poll.sh" \ - || fail "the static poll hardcodes a GitLab host" pass "GitLab merge requests are followed on any instance and never wake falsely" } diff --git a/tests/fm-public-followup.test.sh b/tests/fm-public-followup.test.sh new file mode 100755 index 0000000000..4c0571c466 --- /dev/null +++ b/tests/fm-public-followup.test.sh @@ -0,0 +1,1042 @@ +#!/usr/bin/env bash +# End-to-end and regression tests for the deterministic public-followup consumer. +# +# The failure this suite pins: firstmate promises a public final reply in an X or +# Discord thread, routes the work out, and then the session compacts or restarts. +# Nothing in memory survives. The promise is only kept if a terminal work result +# reconciles the typed obligation from DISK and the final reply lands in the +# ORIGINAL thread exactly once. +# +# Everything here is hermetic: the relay is a fakebin `curl`, so no port, no +# server, and no public post. tasks-axi and jq are the real tools, because +# tasks-axi owns the obligation state machine and stubbing it would test nothing. +set -u + +# shellcheck source=tests/lib.sh +# shellcheck disable=SC1091 +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" + +PF="$ROOT/bin/fm-public-followup.sh" +EMIT="$ROOT/bin/fm-public-followup-emit.sh" +POLL="$ROOT/bin/fm-x-poll.sh" +TEARDOWN="$ROOT/bin/fm-teardown.sh" +SESSION_START="$ROOT/bin/fm-session-start.sh" +TMP_ROOT=$(fm_test_tmproot fm-public-followup) + +command -v jq >/dev/null 2>&1 || { echo "skip: jq not found"; exit 0; } +command -v tasks-axi >/dev/null 2>&1 || { echo "skip: tasks-axi not found"; exit 0; } + +# A fakebin `curl` standing in for the relay. It logs every call so a test can +# prove exactly how many public posts happened, and honours FAKE_FOLLOWUP_CODE so +# a transport failure can be simulated. +make_fake_curl() { # <home> + local fakebin + fakebin=$(fm_fakebin "$1") + cat > "$fakebin/curl" <<'SH' +#!/usr/bin/env bash +ofile="" url="" data="" +while [ $# -gt 0 ]; do + case "$1" in + -o) ofile=$2; shift 2 ;; + --data-binary) + case "$2" in + @-) data=$(cat) ;; + @*) data=$(cat -- "${2#@}") ;; + *) data=$2 ;; + esac + shift 2 + ;; + -H|-m|-w|-X) shift 2 ;; + -s) shift ;; + http://*|https://*) url=$1; shift ;; + *) shift ;; + esac +done +if [ -n "${FAKE_CURL_LOG:-}" ]; then + { echo "url=$url"; echo "data=$data"; } >> "$FAKE_CURL_LOG" +fi +case "$url" in + */connector/followup) printf '%s' "${FAKE_FOLLOWUP_CODE:-200}" ;; + */connector/answer) printf '200' ;; + */connector/request-context) + [ -n "$ofile" ] && printf '%s' "${FAKE_REQCTX_BODY:-}" > "$ofile" + printf '%s' "${FAKE_REQCTX_CODE:-404}" + ;; + */connector/poll) printf '204' ;; + *) printf '204' ;; +esac +exit 0 +SH + chmod +x "$fakebin/curl" + printf '%s\n' "$fakebin" +} + +# make_home <name> [relay-on|relay-off]: a firstmate home with its own backlog. +# relay-off omits .env entirely, which is exactly what a home that never opted +# into the myfirstmate relay looks like. +make_home() { # <name> [relay-on|relay-off] + local home="$TMP_ROOT/$1" relay=${2:-relay-on} + mkdir -p "$home/data" "$home/state" "$home/config" "$home/projects" + cp "$ROOT/.tasks.toml" "$home/.tasks.toml" + cat > "$home/data/backlog.md" <<'EOF' +## In flight + +## Queued + +## Done +EOF + [ "$relay" = relay-off ] || printf 'FMX_PAIRING_TOKEN=test-token\n' > "$home/.env" + make_fake_curl "$home" >/dev/null + fm_fake_exit0 "$home/fakebin" tmux treehouse no-mistakes gh gh-axi + printf '%s\n' "$home" +} + +run_pf() { # <home> <args...> + local home=$1 + shift + PATH="$home/fakebin:$PATH" FM_ROOT_OVERRIDE="$ROOT" FM_HOME="$home" \ + FM_STATE_OVERRIDE="$home/state" FAKE_CURL_LOG="${FAKE_CURL_LOG:-}" \ + FAKE_FOLLOWUP_CODE="${FAKE_FOLLOWUP_CODE:-200}" "$PF" "$@" +} + +tasks_in() { # <home> <tasks-axi args...> + local home=$1 + shift + (cd "$home" && tasks-axi "$@") +} + +# seed_commitment <home> <obligation> <request> <platform> <work-home> <work-id> +# Simulates the intake half that already works today: the relay mention arrives, +# the typed obligation is created with its opaque thread binding, the work is +# bound, and the private request context is retained. +seed_commitment() { + local home=$1 obligation=$2 request=$3 platform=$4 work_home=$5 work_id=$6 + jq -n --arg r "$request" --arg p "$platform" \ + '{request_id:$r, platform:$p, + context_binding:{version:"ctx1", value:("ctx1_" + $r)}, + public_safe_summary:"fix worker placement when two spaces share a name", + received_at:"2026-07-30T10:00:00Z", + followup_expires_at:"2026-08-06T10:00:00Z", + reservation_expires_at:"2026-08-06T10:00:00Z"}' > "$home/request.json" + jq -n '{type:"pr-merged", project:"firstmate", + required_deliverables:["pr_url"], completion_policy:"all-required"}' \ + > "$home/expected.json" + jq -n --arg h "$work_home" --arg w "$work_id" \ + '{relation_id:"rel-code", work_ref:{home_id:$h, task_id:$w}, + role:"fulfills", required:true, generation:1}' > "$home/relation.json" + + tasks_in "$home" public-followup add "$obligation" \ + --request-context-file "$home/request.json" --purpose promised-final \ + --expected-final-file "$home/expected.json" --expires-at 2026-10-01T00:00:00Z >/dev/null \ + || fail "could not create the public commitment" + tasks_in "$home" public-followup bind-work "$obligation" \ + --relation-file "$home/relation.json" >/dev/null \ + || fail "could not bind work to the public commitment" + + # The mention payload and the durable per-request context, exactly as the relay + # poll records them at intake. + mkdir -p "$home/state/x-inbox" + jq -n --arg r "$request" --arg p "$platform" \ + '{request_id:$r, platform:$p, text:"please fix worker placement"}' \ + > "$home/state/x-inbox/$request.json" + chmod 700 "$home/state/x-inbox" + chmod 600 "$home/state/x-inbox/$request.json" + FM_HOME="$home" bash -c \ + ". '$ROOT/bin/fm-x-lib.sh'; fmx_context_registry_set '$home/state' '$request' '$platform' 1900" \ + || fail "could not retain the private request context" + + run_pf "$home" register "$obligation" --relation rel-code \ + --work-home "$work_home" --work-id "$work_id" --generation 1 >/dev/null \ + || fail "could not register the public commitment" +} + +emit_terminal() { # <child-run-dir> <owning-home> <obligation> <work-home> <work-id> [pr-url] [outcome] + local owning=$2 obligation=$3 work_home=$4 work_id=$5 + local pr=${6:-https://github.com/example/repo/pull/7} outcome=${7:-pr-merged} + "$EMIT" --home "$owning" --obligation "$obligation" --relation rel-code \ + --source-home "$work_home" --work-id "$work_id" --generation 1 \ + --outcome "$outcome" --deliverable "pr_url=$pr" \ + --outcome-text 'Fixed: workers now land in the launching workspace even when two spaces share a name.' +} + +delivery_state() { # <home> <obligation> + tasks_in "$1" public-followup list --json 2>/dev/null \ + | jq -r --arg id "$2" '(.public_followups // []) + | map(select(.id == $id)) | .[0].public_followup.delivery.state // "absent"' +} + +task_state() { # <home> <obligation> + tasks_in "$1" public-followup list --json 2>/dev/null \ + | jq -r --arg id "$2" '(.public_followups // []) + | map(select(.id == $id)) | .[0].state // "absent"' +} + +followup_posts() { # <log> + local n + n=$(grep -c 'connector/followup' "$1" 2>/dev/null) || n=0 + printf '%s' "${n:-0}" +} + +# expect_failure <label> <command...>: run <command>, require a non-zero exit, and +# leave its combined output in EXPECT_OUT for the assertions that follow. Keeps +# refusal tests readable without toggling errexit around every case. +EXPECT_OUT= +expect_failure() { + local label=$1 + shift + if EXPECT_OUT=$("$@" 2>&1); then + fail "$label (unexpectedly succeeded)"$'\n'"--- output ---"$'\n'"$EXPECT_OUT" + fi +} + +# --- 0. bounded, single-line, character-safe outcome text ----------------------- + +# The outcome sentence becomes a public reply, so bounding it must not mangle +# non-ASCII characters, and control characters must never survive into the typed +# event or the thread. +test_outcome_text_is_bounded_without_corrupting_characters() { + local home event text long + home=$(make_home outcome-text) + seed_commitment "$home" pf-text req-text discord main work-text + + "$EMIT" --home "$home" --obligation pf-text --relation rel-code \ + --source-home main --work-id work-text --generation 1 --outcome pr-merged \ + --deliverable pr_url=https://github.com/example/repo/pull/3 \ + --outcome-text "$(printf 'Shipped\tthe caf\xc3\xa9 fix \xe2\x80\x94 \xf0\x9f\x9a\xa2\nsecond line')" >/dev/null \ + || fail "emit failed for non-ASCII outcome text" + event=$(find "$home/state/public-followup/events" -name '*.json' | head -1) + text=$(jq -r '.public_safe_outcome' "$event") \ + || fail "the typed event must remain valid JSON with non-ASCII text" + [ "$text" = 'Shipped the café fix — 🚢 second line' ] \ + || fail "non-ASCII outcome text was corrupted or not collapsed: '$text'" + + # A very long sentence is capped by codepoint, so the JSON stays valid. + rm -f "$event" + long=$(python3 -c 'print("é" * 5000, end="")') + "$EMIT" --home "$home" --obligation pf-text --relation rel-code \ + --source-home main --work-id work-text --generation 1 --outcome pr-merged \ + --deliverable pr_url=https://github.com/example/repo/pull/4 \ + --outcome-text "$long" >/dev/null \ + || fail "emit failed for an over-long outcome text" + event=$(find "$home/state/public-followup/events" -name '*.json' | head -1) + text=$(jq -r '.public_safe_outcome' "$event") \ + || fail "an over-long outcome must still produce valid JSON" + [ "${#text}" -le 600 ] || fail "the outcome text was not bounded, got ${#text} characters" + case "$text" in + *[!é]*) fail "codepoint bounding split a multi-byte character" ;; + esac + pass "outcome text is collapsed to one line, bounded by codepoint, and never corrupts characters" +} + +# --- 1. the restart end-to-end ------------------------------------------------- + +# The whole reported failure, start to finish, with no conversation memory +# anywhere: a Discord request becomes a typed commitment, a secondmate child +# lands the work and reports a TYPED terminal result, the session ends, and a +# cold reconciliation from disk delivers exactly one final reply into the +# original thread and closes the obligation. +test_restart_e2e_delivers_exactly_once() { + local home child log out posts receipt + home=$(make_home restart-e2e) + child=$(make_home restart-child relay-off) + log="$home/curl.log"; : > "$log" + seed_commitment "$home" pf-restart req-restart discord secondmate:fmdev work-code-q1 + printf '%s\n' fmdev > "$child/.fm-secondmate-home" + fm_write_meta "$home/state/fmdev.meta" "kind=secondmate" "home=$child" + fm_write_meta "$child/state/work-code-q1.meta" \ + "x_request=req-restart" "x_request_ts=1700000000" "x_followups=1" + + # The reported failure, reproduced: with the work bound but no reconciled + # terminal result, the commitment is stranded at pending-work and nothing can + # be delivered - which is exactly how a promised final reply went unsent. + [ "$(delivery_state "$home" pf-restart)" = pending-work ] \ + || fail "a freshly bound commitment must sit at pending-work" + FAKE_CURL_LOG="$log" expect_failure "a commitment still waiting on its work must not be deliverable" \ + run_pf "$home" deliver pf-restart + assert_contains "$EXPECT_OUT" "still waiting on its bound work" \ + "the stranded state must be reported, not silently skipped" + [ "$(followup_posts "$log")" -eq 0 ] || fail "the stranded state must post nothing" + + # The child home reports its terminal result as typed data. This is the step + # whose absence left the obligation stranded at pending-work. + emit_terminal "$home" "$home" pf-restart secondmate:fmdev work-code-q1 >/dev/null \ + || fail "the child could not report its typed terminal result" + + # Simulate compaction/restart: nothing but disk survives, and the drained inbox + # is gone. The durable private request context is what keeps the thread binding + # resolvable. + rm -f "$home/state/x-inbox/req-restart.json" + + out=$(FAKE_CURL_LOG="$log" run_pf "$home" consume) \ + || fail "cold reconciliation failed" + assert_contains "$out" "ready pf-restart req-restart discord" \ + "reconciliation must report the commitment as delivery-ready" + [ "$(delivery_state "$home" pf-restart)" = ready ] \ + || fail "the typed terminal result must move the commitment to ready" + [ "$(followup_posts "$log")" -eq 0 ] \ + || fail "reconciliation must not post anything by itself" + + out=$(FAKE_CURL_LOG="$log" run_pf "$home" deliver pf-restart) \ + || fail "delivery failed" + assert_contains "$out" "delivered pf-restart request=req-restart platform=discord" \ + "delivery must report the original request binding" + + posts=$(followup_posts "$log") + [ "$posts" -eq 1 ] || fail "expected exactly one public reply, got $posts" + assert_grep 'connector/followup' "$log" "the reply must use the follow-up endpoint" + assert_grep '"request_id":"req-restart"' "$log" \ + "the reply must target the ORIGINAL request binding" + assert_grep 'workers now land in the launching workspace' "$log" \ + "the reply must reuse the accepted terminal outcome verbatim" + + receipt=$(tasks_in "$home" public-followup list --json \ + | jq -r '(.public_followups // []) + | map(select(.id == "pf-restart")) | .[0].public_followup.delivery.receipt.state // "none"') + [ "$receipt" = posted ] || fail "a validated posted receipt must be recorded, got '$receipt'" + [ "$(task_state "$home" pf-restart)" = 'done' ] \ + || fail "the commitment must be Done only after the receipt" + assert_no_grep '^x_request=' "$child/state/work-code-q1.meta" \ + "typed delivery must clear the secondmate's legacy X link" + pass "restart end-to-end: typed result reconciles from disk and delivers one reply to the original thread" +} + +# --- 2. idempotency ------------------------------------------------------------ + +test_duplicate_event_and_replay_are_noops() { + local home log first second out posts + home=$(make_home idempotent) + log="$home/curl.log"; : > "$log" + seed_commitment "$home" pf-dup req-dup discord main work-dup + + first=$(emit_terminal "$home" "$home" pf-dup main work-dup) || fail "first emit failed" + second=$(emit_terminal "$home" "$home" pf-dup main work-dup) || fail "second emit failed" + [ "$first" = "$second" ] \ + || fail "the same terminal result must derive the same event identity" + [ "$(find "$home/state/public-followup/events" -name '*.json' | wc -l | tr -d ' ')" -eq 1 ] \ + || fail "a duplicate emit must not create a second event file" + + FAKE_CURL_LOG="$log" run_pf "$home" consume >/dev/null || fail "first consume failed" + # Replay the identical event after the fact, exactly as a restarted child would. + emit_terminal "$home" "$home" pf-dup main work-dup >/dev/null || fail "replay emit failed" + out=$(FAKE_CURL_LOG="$log" run_pf "$home" consume) || fail "replay consume failed" + [ -z "$out" ] || fail "replaying an accepted event must be silent, got: $out" + [ "$(delivery_state "$home" pf-dup)" = ready ] \ + || fail "replay must not disturb the delivery state" + + FAKE_CURL_LOG="$log" run_pf "$home" deliver pf-dup >/dev/null || fail "delivery failed" + out=$(FAKE_CURL_LOG="$log" run_pf "$home" deliver pf-dup) || fail "second deliver must succeed silently" + assert_contains "$out" "already delivered" "a second delivery must report the existing receipt" + posts=$(followup_posts "$log") + [ "$posts" -eq 1 ] || fail "a repeated delivery must never double-post, got $posts posts" + pass "duplicate terminal results, restart replay, and repeated delivery are all no-ops" +} + +# --- 3. refusals --------------------------------------------------------------- + +# Everything tasks-axi is the authority on - source home, work id, generation, +# schema, and permitted deliverables - must be refused rather than half-applied, +# and quarantined rather than retried forever. +test_invalid_events_are_refused_and_quarantined() { + local home out events rejected + home=$(make_home refusals) + seed_commitment "$home" pf-refuse req-refuse discord secondmate:fmdev work-real + + # Wrong source home and wrong work id are caught at the edge by the emitter, + # because the owning home's own registration disagrees. + expect_failure "a wrong source home must be refused" \ + "$EMIT" --home "$home" --obligation pf-refuse --relation rel-code \ + --source-home secondmate:other --work-id work-real --generation 1 \ + --outcome pr-merged --deliverable pr_url=https://example.invalid/1 \ + --outcome-text 'x' + assert_contains "$EXPECT_OUT" "does not match this home's registration" \ + "the refusal must name the mismatch" + + expect_failure "a wrong work id must be refused" \ + "$EMIT" --home "$home" --obligation pf-refuse --relation rel-code \ + --source-home secondmate:fmdev --work-id work-other --generation 1 \ + --outcome pr-merged --deliverable pr_url=https://example.invalid/1 \ + --outcome-text 'x' + expect_failure "a stale generation must be refused" \ + "$EMIT" --home "$home" --obligation pf-refuse --relation rel-code \ + --source-home secondmate:fmdev --work-id work-real --generation 0 \ + --outcome pr-merged --deliverable pr_url=https://example.invalid/1 \ + --outcome-text 'x' + + events="$home/state/public-followup/events" + rejected="$home/state/public-followup/rejected" + + # A malformed event that bypassed the emitter entirely. + printf 'not json at all\n' > "$events/deadbeef.json" + out=$(run_pf "$home" consume) || fail "consume must survive a malformed event" + assert_contains "$out" "rejected deadbeef" "a malformed event must be refused" + assert_absent "$events/deadbeef.json" "a refused event must leave the pending inbox" + assert_present "$rejected/deadbeef.reason" "a refusal must keep an inspectable reason" + + # A deliverable the expected-final type does not permit. The emitter accepts the + # shape; tasks-axi is the authority that refuses the semantics. + "$EMIT" --home "$home" --obligation pf-refuse --relation rel-code \ + --source-home secondmate:fmdev --work-id work-real --generation 1 \ + --outcome pr-merged --deliverable report_path=data/x/report.md \ + --outcome-text 'wrong deliverable for a merged PR' >/dev/null \ + || fail "the emitter should publish a shape-valid event" + out=$(run_pf "$home" consume) || fail "consume must survive an unsupported deliverable" + assert_contains "$out" "rejected " "an unsupported deliverable must be refused by tasks-axi" + [ "$(delivery_state "$home" pf-refuse)" = pending-work ] \ + || fail "a refused event must leave the commitment untouched" + + # A hand-edited event whose id no longer matches its own identity fields. + jq -n '{schema_version:1, event_id:"forged", obligation_id:"pf-refuse", + relation_id:"rel-code", work_id:"work-real", generation:1, + source_home_id:"secondmate:fmdev", outcome_type:"pr-merged", + deliverables:{pr_url:"https://example.invalid/9"}, + public_safe_outcome:"forged", occurred_at:"2026-07-30T12:00:00Z", + successor:null}' > "$events/forged.json" + out=$(run_pf "$home" consume) || fail "consume must survive a forged event" + assert_contains "$out" "rejected forged" "a forged event identity must be refused" + pass "wrong source, wrong work id, stale generation, malformed, unsupported deliverable, and forged identity are all refused" +} + +# --- 4. transport failure and late receipt ------------------------------------- + +test_relay_failure_holds_without_false_completion() { + local home log out posts + home=$(make_home retryable) + log="$home/curl.log"; : > "$log" + seed_commitment "$home" pf-retry req-retry discord main work-retry + emit_terminal "$home" "$home" pf-retry main work-retry >/dev/null || fail "emit failed" + FAKE_CURL_LOG="$log" run_pf "$home" consume >/dev/null || fail "consume failed" + + FAKE_CURL_LOG="$log" FAKE_FOLLOWUP_CODE=500 \ + expect_failure "a failed relay post must not report success" \ + run_pf "$home" deliver pf-retry + assert_contains "$EXPECT_OUT" "recorded as retryable" "the failure must be typed as retryable" + [ "$(delivery_state "$home" pf-retry)" = retry-due ] \ + || fail "a failed post must leave a retryable state, got $(delivery_state "$home" pf-retry)" + [ "$(task_state "$home" pf-retry)" != 'done' ] \ + || fail "a failed post must never close the commitment" + + # The retry succeeds and closes it, with exactly one successful post. + : > "$log" + FAKE_CURL_LOG="$log" run_pf "$home" deliver pf-retry >/dev/null || fail "the retry should deliver" + posts=$(followup_posts "$log") + [ "$posts" -eq 1 ] || fail "the retry must post exactly once, got $posts" + [ "$(task_state "$home" pf-retry)" = 'done' ] || fail "a successful retry must close the commitment" + pass "a relay transport failure is held as retryable with no false completion, and the retry posts once" +} + +test_dry_run_does_not_close_commitment() { + local home log out posts + home=$(make_home dry-run) + log="$home/curl.log"; : > "$log" + seed_commitment "$home" pf-dry req-dry discord main work-dry + emit_terminal "$home" "$home" pf-dry main work-dry >/dev/null || fail "emit failed" + run_pf "$home" consume >/dev/null || fail "consume failed" + + FMX_DRY_RUN=1 FAKE_CURL_LOG="$log" expect_failure \ + "a dry-run must not close a public commitment" run_pf "$home" deliver pf-dry + assert_contains "$EXPECT_OUT" "recorded as retryable" \ + "a dry-run must leave a retryable typed state" + [ "$(delivery_state "$home" pf-dry)" = retry-due ] \ + || fail "a dry-run must leave the obligation retryable, got $(delivery_state "$home" pf-dry)" + [ "$(task_state "$home" pf-dry)" != 'done' ] \ + || fail "a dry-run must never close the commitment" + posts=$(followup_posts "$log") + [ "$posts" -eq 0 ] || fail "a dry-run must not post to the relay, got $posts posts" + pass "a dry-run records no public delivery and leaves the commitment retryable" +} + +test_late_receipt_closes_the_exact_attempt_without_reposting() { + local home log out posts attempt + home=$(make_home late-receipt) + log="$home/curl.log"; : > "$log" + seed_commitment "$home" pf-late req-late x main work-late + fm_write_meta "$home/state/work-late.meta" \ + "x_request=req-late" "x_request_ts=1700000000" "x_followups=1" + emit_terminal "$home" "$home" pf-late main work-late >/dev/null || fail "emit failed" + FAKE_CURL_LOG="$log" run_pf "$home" consume >/dev/null || fail "consume failed" + + FAKE_CURL_LOG="$log" FAKE_FOLLOWUP_CODE=503 run_pf "$home" deliver pf-late >/dev/null 2>&1 || true + attempt=$(tasks_in "$home" public-followup list --json \ + | jq -r '(.public_followups // []) | map(select(.id == "pf-late")) + | .[0].public_followup.delivery.attempt_count') + [ "$attempt" = 1 ] || fail "the failed attempt must be recorded as attempt 1, got '$attempt'" + + expect_failure "a late receipt must include its exact message count" \ + run_pf "$home" record-posted pf-late --attempt 1 + assert_contains "$EXPECT_OUT" "--chunks <n> is required" \ + "a late receipt without a message count must be refused" + + # The post actually landed; its receipt was simply lost. Close the exact attempt + # without sending anything else. + : > "$log" + out=$(FAKE_CURL_LOG="$log" run_pf "$home" record-posted pf-late --attempt 1 --chunks 1) \ + || fail "recording a late receipt for the exact attempt must succeed" + assert_contains "$out" "recorded pf-late attempt=1" "the late receipt must name its attempt" + posts=$(followup_posts "$log") + [ "$posts" -eq 0 ] || fail "recording a late receipt must post nothing, got $posts posts" + [ "$(task_state "$home" pf-late)" = 'done' ] || fail "a validated late receipt must close the commitment" + assert_no_grep '^x_request=' "$home/state/work-late.meta" \ + "a late receipt must clear the legacy X link" + + FAKE_CURL_LOG="$log" expect_failure "a receipt for a different attempt must be refused" \ + run_pf "$home" record-posted pf-late --attempt 9 --chunks 1 + pass "a late success receipt closes the exact attempt with no second post, and a mismatched attempt is refused" +} + +test_typed_terminal_clear_only_removes_legacy_link() { + local home meta out + home=$(make_home typed-clear) + meta="$home/state/work-clear.meta" + printf '%s\n' 'status=working' 'x_request=req-clear' 'x_request_ts=1700000000' \ + 'x_followups=2' 'x_platform=discord' 'x_reply_max_chars=1900' > "$meta" + + out=$(PATH="$home/fakebin:$PATH" FM_ROOT_OVERRIDE="$ROOT" FM_HOME="$home" \ + FM_STATE_OVERRIDE="$home/state" "$ROOT/bin/fm-x-followup.sh" --clear work-clear) \ + || fail "the typed terminal clear transition must succeed" + [ "$out" = work-clear ] || fail "the clear-only transition must identify the task" + assert_grep 'status=working' "$meta" "clear-only transition must preserve unrelated task metadata" + assert_no_grep '^x_request=' "$meta" "clear-only transition must remove the request link" + assert_no_grep '^x_followups=' "$meta" "clear-only transition must remove the follow-up counter" + assert_no_grep '^x_platform=' "$meta" "clear-only transition must remove platform metadata" + pass "typed terminal cleanup clears the legacy link without posting" +} + +# A crash between the post and its receipt is the one case where we cannot know +# whether the thread already got a reply. Delivery must refuse rather than guess. +test_interrupted_delivery_refuses_to_repost() { + local home log out posts + home=$(make_home interrupted) + log="$home/curl.log"; : > "$log" + seed_commitment "$home" pf-crash req-crash discord main work-crash + emit_terminal "$home" "$home" pf-crash main work-crash >/dev/null || fail "emit failed" + run_pf "$home" consume >/dev/null || fail "consume failed" + + # Reproduce the crash window directly through the state machine. + tasks_in "$home" public-followup begin-delivery pf-crash \ + --payload-hash 0000000000000000000000000000000000000000000000000000000000000000 >/dev/null \ + || fail "could not stage the interrupted attempt" + + FAKE_CURL_LOG="$log" expect_failure "an interrupted delivery must not silently post again" \ + run_pf "$home" deliver pf-crash + assert_contains "$EXPECT_OUT" "mid-delivery" "the refusal must name the interrupted attempt" + posts=$(followup_posts "$log") + [ "$posts" -eq 0 ] || fail "an interrupted delivery must post nothing, got $posts posts" + pass "a delivery interrupted between post and receipt refuses to repost" +} + +# --- 5. ownership -------------------------------------------------------------- + +# The outward post belongs to the home holding the relay consent and the thread +# binding. A child home has neither, and must not be able to acquire them. +test_outward_delivery_stays_with_the_owning_home() { + local owner child log out + owner=$(make_home owner) + child=$(make_home child relay-off) + log="$owner/curl.log"; : > "$log" + seed_commitment "$owner" pf-own req-own discord secondmate:child work-child + printf '%s\n' child > "$child/.fm-secondmate-home" + fm_write_meta "$owner/state/child.meta" "kind=secondmate" "home=$child" + fm_write_meta "$child/state/work-child.meta" \ + "x_request=req-own" "x_request_ts=1700000000" "x_followups=1" + + FAKE_CURL_LOG="$log" emit_terminal "$owner" "$owner" pf-own secondmate:child work-child >/dev/null \ + || fail "the child could not report its typed result" + [ "$(followup_posts "$log")" -eq 0 ] \ + || fail "reporting a terminal result must never post publicly" + run_pf "$owner" consume >/dev/null || fail "the owning home could not consume the child's typed result" + + # The child home has no commitment of its own and no relay consent, so it can + # neither deliver nor even see one. + PATH="$child/fakebin:$PATH" FM_ROOT_OVERRIDE="$ROOT" FM_HOME="$child" \ + FM_STATE_OVERRIDE="$child/state" FAKE_CURL_LOG="$log" \ + expect_failure "a home without relay consent must not deliver a public reply" \ + "$PF" deliver pf-own + assert_contains "$EXPECT_OUT" "has not opted into the myfirstmate relay" \ + "the refusal must name the missing relay consent" + [ "$(followup_posts "$log")" -eq 0 ] || fail "the refused delivery must post nothing" + FAKE_CURL_LOG="$log" run_pf "$owner" deliver pf-own >/dev/null \ + || fail "the owning home must deliver the typed public reply" + assert_no_grep '^x_request=' "$child/state/work-child.meta" \ + "typed delivery must clear the child task's legacy X link" + pass "a child home reports typed results but can never become the outward-post owner" +} + +test_delivery_requires_registration_before_posting() { + local home log out + home=$(make_home missing-registration) + log="$home/curl.log"; : > "$log" + seed_commitment "$home" pf-missing req-missing x main work-missing + fm_write_meta "$home/state/work-missing.meta" \ + "x_request=req-missing" "x_request_ts=1700000000" "x_followups=1" + emit_terminal "$home" "$home" pf-missing main work-missing >/dev/null || fail "emit failed" + run_pf "$home" consume >/dev/null || fail "consume failed" + rm -f "$home/state/public-followup/registry/pf-missing" + + FAKE_CURL_LOG="$log" expect_failure "delivery without a registration must refuse" \ + run_pf "$home" deliver pf-missing + assert_contains "$EXPECT_OUT" "registration for 'pf-missing' is missing or invalid" \ + "missing registration must be an actionable delivery refusal" + [ "$(followup_posts "$log")" -eq 0 ] || fail "missing registration must prevent any public post" + [ "$(task_state "$home" pf-missing)" != 'done' ] \ + || fail "missing registration must not close the obligation" + assert_grep 'x_request=req-missing' "$home/state/work-missing.meta" \ + "missing registration must leave the legacy link for reconciliation" + pass "typed delivery refuses to post when its cleanup registration is missing" +} + +test_secondmate_teardown_requires_parent_binding() { + local parent child registry_before marker_before + parent=$(make_home teardown-parent) + child=$(make_home teardown-child) + printf '%s\n' mate > "$child/.fm-secondmate-home" + seed_commitment "$parent" pf-teardown req-teardown x secondmate:mate work-child + fm_write_meta "$parent/state/mate.meta" "kind=secondmate" "home=$child" + fm_write_meta "$child/state/work-child.meta" \ + "window=firstmate:fm-work-child" "endpoint_task_id=work-child" \ + "worktree=$child" "project=$child" "kind=ship" "mode=local-only" + + PATH="$child/fakebin:$PATH" FM_ROOT_OVERRIDE="$ROOT" FM_HOME="$child" \ + FM_STATE_OVERRIDE="$child/state" FM_DATA_OVERRIDE="$child/data" \ + expect_failure "marked child teardown without a parent must refuse cleanup" \ + "$TEARDOWN" work-child + assert_contains "$EXPECT_OUT" "cannot resolve the primary home for marked secondmate mate" \ + "missing parent binding must be an actionable teardown refusal" + assert_present "$child/state/work-child.meta" \ + "missing parent binding must preserve the child work metadata" + + parent=$(make_home teardown-valid-parent) + child=$(make_home teardown-valid-child) + printf '%s\n' mate > "$child/.fm-secondmate-home" + printf -- '- mate - synthetic (id is legacy); preserve this (home: %s; scope: synthetic (child); semicolon remains meaningful; projects: ; added 2026-07-30)\n' \ + "$child" > "$parent/data/secondmates.md" + FM_HOME="$parent" "$ROOT/bin/fm-home-seed.sh" validate >/dev/null \ + || fail "home-seed validation rejected a punctuation-bearing operational registry record" + registry_before=$(cat "$parent/data/secondmates.md") + marker_before=$(cat "$child/.fm-secondmate-home") + seed_commitment "$parent" pf-teardown-valid req-teardown-valid x secondmate:mate work-child + fm_write_meta "$parent/state/mate.meta" "kind=secondmate" "home=$child" + fm_write_meta "$child/state/work-child.meta" \ + "window=firstmate:fm-work-child" "endpoint_task_id=work-child" \ + "worktree=$child" "project=$child" "kind=ship" "mode=local-only" + + PATH="$child/fakebin:$PATH" FM_ROOT_OVERRIDE="$ROOT" FM_HOME="$child" \ + FM_STATE_OVERRIDE="$child/state" FM_DATA_OVERRIDE="$child/data" \ + FM_PUBLIC_FOLLOWUP_PRIMARY_HOME="$parent" \ + expect_failure "marked child teardown with a valid parent must enforce the parent commitment" \ + "$TEARDOWN" work-child + assert_contains "$EXPECT_OUT" "still owes a public reply" \ + "valid parent binding must route cleanup through the parent commitment" + case "$EXPECT_OUT" in + *"cannot resolve the primary home"*) fail "valid parent binding was reported as unresolved" ;; + esac + assert_present "$child/state/work-child.meta" \ + "an owed parent commitment must preserve the child work metadata" + [ "$registry_before" = "$(cat "$parent/data/secondmates.md")" ] \ + || fail "guarded cleanup refusal changed the parent registry" + [ "$marker_before" = "$(cat "$child/.fm-secondmate-home")" ] \ + || fail "guarded cleanup refusal changed the child identity marker" + pass "marked secondmate teardown resolves its parent and fails closed when unavailable" +} + +test_relay_disabled_unmarked_teardown_skips_public_path() { + local home tasks_log out rc + home=$(make_home teardown-disabled-unmarked relay-off) + fm_git_init_commit "$home/projects/worktree" + tasks_log="$home/tasks-axi.log"; : > "$tasks_log" + printf 'manual\n' > "$home/config/backlog-backend" + cat > "$home/fakebin/tasks-axi" <<'SH' +#!/usr/bin/env bash +echo "$*" >> "$FAKE_TASKS_AXI_LOG" +exit 99 +SH + chmod +x "$home/fakebin/tasks-axi" + fm_write_meta "$home/state/work-disabled.meta" \ + "window=firstmate:fm-work-disabled" "endpoint_task_id=work-disabled" \ + "worktree=$home/projects/worktree" "project=$home/projects/worktree" \ + "kind=ship" "mode=local-only" + + rc=0 + out=$(PATH="$home/fakebin:$PATH" FM_ROOT_OVERRIDE="$ROOT" FM_HOME="$home" \ + FM_STATE_OVERRIDE="$home/state" FM_DATA_OVERRIDE="$home/data" \ + FM_CONFIG_OVERRIDE="$home/config" FAKE_TASKS_AXI_LOG="$tasks_log" \ + "$TEARDOWN" work-disabled 2>&1) || rc=$? + [ "$rc" -eq 0 ] || fail "relay-disabled unmarked teardown must not refuse public-followup cleanup (rc=$rc): $out" + [ ! -s "$tasks_log" ] || fail "relay-disabled unmarked teardown must not invoke tasks-axi: $(tr '\n' ';' < "$tasks_log")" + assert_not_contains "$out" "still owes a public reply" \ + "relay-disabled unmarked teardown must not run the public commitment guard" + assert_absent "$home/state/public-followup" \ + "relay-disabled unmarked teardown must not create a public-followup artifact" + pass "relay-disabled unmarked teardown runs no public-followup work" +} + +test_relay_disabled_parent_allows_marked_child_teardown() { + local parent child tasks_log out rc + parent=$(make_home teardown-disabled-parent relay-off) + child=$(make_home teardown-disabled-child relay-off) + fm_git_init_commit "$child/projects/worktree" + printf '%s\n' disabled-mate > "$child/.fm-secondmate-home" + printf -- '- disabled-mate - synthetic (home: %s; scope: synthetic; projects: ; added 2026-07-30)\n' \ + "$child" > "$parent/data/secondmates.md" + fm_write_meta "$parent/state/disabled-mate.meta" "kind=secondmate" "home=$child" + tasks_log="$child/tasks-axi.log"; : > "$tasks_log" + printf 'manual\n' > "$child/config/backlog-backend" + cat > "$child/fakebin/tasks-axi" <<'SH' +#!/usr/bin/env bash +echo "$*" >> "$FAKE_TASKS_AXI_LOG" +exit 99 +SH + chmod +x "$child/fakebin/tasks-axi" + fm_write_meta "$child/state/work-disabled.meta" \ + "window=firstmate:fm-work-disabled" "endpoint_task_id=work-disabled" \ + "worktree=$child/projects/worktree" "project=$child/projects/worktree" \ + "kind=ship" "mode=local-only" + + rc=0 + out=$(PATH="$child/fakebin:$PATH" FM_ROOT_OVERRIDE="$ROOT" FM_HOME="$child" \ + FM_STATE_OVERRIDE="$child/state" FM_DATA_OVERRIDE="$child/data" \ + FM_CONFIG_OVERRIDE="$child/config" \ + FM_PUBLIC_FOLLOWUP_PRIMARY_HOME="$parent" FAKE_TASKS_AXI_LOG="$tasks_log" \ + "$TEARDOWN" work-disabled 2>&1) || rc=$? + [ "$rc" -eq 0 ] || fail "relay-disabled parent must allow marked-child teardown (rc=$rc): $out" + [ ! -s "$tasks_log" ] || fail "relay-disabled parent must not invoke tasks-axi for a marked child" + assert_not_contains "$out" "still owes a public reply" \ + "relay-disabled parent must not run the public commitment guard" + assert_absent "$child/state/public-followup" \ + "relay-disabled parent must not create a public-followup artifact" + pass "a marked child proceeds without tasks-axi when its parent relay is disabled" +} + +test_secondmate_parent_binding_matches_literal_id() { + local parent child + parent=$(make_home teardown-literal-parent) + child=$(make_home teardown-literal-child) + printf '%s\n' 'mate.id' > "$child/.fm-secondmate-home" + printf -- '- mateXid - synthetic (home: %s; scope: synthetic; projects: ; added 2026-07-30)\n' \ + "$child" > "$parent/data/secondmates.md" + seed_commitment "$parent" pf-teardown-literal req-teardown-literal x secondmate:mate.id work-literal + fm_write_meta "$parent/state/mate.id.meta" "kind=secondmate" "home=$child" + fm_write_meta "$child/state/work-literal.meta" \ + "window=firstmate:fm-work-literal" "endpoint_task_id=work-literal" \ + "worktree=$child" "project=$child" "kind=ship" "mode=local-only" + + PATH="$child/fakebin:$PATH" FM_ROOT_OVERRIDE="$ROOT" FM_HOME="$child" \ + FM_STATE_OVERRIDE="$child/state" FM_DATA_OVERRIDE="$child/data" \ + FM_CONFIG_OVERRIDE="$child/config" FM_PUBLIC_FOLLOWUP_PRIMARY_HOME="$parent" \ + expect_failure "a near-match registry id must not satisfy a dotted parent binding" \ + "$TEARDOWN" work-literal + assert_contains "$EXPECT_OUT" "cannot resolve the primary home for marked secondmate mate.id" \ + "a dotted id must be matched as an exact registry field" + assert_present "$child/state/work-literal.meta" \ + "a near-match parent binding must preserve the child work metadata" + pass "secondmate parent resolution matches the durable registry id literally" +} + +test_traversal_registration_is_refused_before_delivery() { + local home log out + home=$(make_home traversal-registration) + log="$home/curl.log"; : > "$log" + seed_commitment "$home" pf-traversal req-traversal x main work-traversal + emit_terminal "$home" "$home" pf-traversal main work-traversal >/dev/null \ + || fail "emit failed for traversal registration" + sed -i.bak 's/^work_home=.*/work_home=secondmate:..\/..\/x/' \ + "$home/state/public-followup/registry/pf-traversal" + rm -f "$home/state/public-followup/registry/pf-traversal.bak" + run_pf "$home" consume >/dev/null || fail "consume failed for traversal registration" + + out=$(FAKE_CURL_LOG="$log" run_pf "$home" deliver pf-traversal 2>&1) && \ + fail "a traversal-shaped registration must not be deliverable" + assert_contains "$out" "registration for 'pf-traversal' is missing or invalid" \ + "a traversal-shaped work home must be rejected before delivery" + [ "$(followup_posts "$log")" -eq 0 ] || fail "an invalid work home must not post publicly" + assert_present "$home/state/public-followup/registry/pf-traversal" \ + "an invalid work home must retain its registration for reconciliation" + [ "$(task_state "$home" pf-traversal)" != 'done' ] \ + || fail "an invalid work home must not close the obligation" + pass "traversal-shaped registrations are rejected before path construction or posting" +} + +test_pending_rejects_malformed_listing() { + local home out + home=$(make_home pending-malformed) + seed_commitment "$home" pf-malformed req-malformed discord main work-malformed + cat > "$home/fakebin/tasks-axi" <<'SH' +#!/usr/bin/env bash +printf '%s' '{"public_followups":[' +SH + chmod +x "$home/fakebin/tasks-axi" + + out=$(run_pf "$home" pending) || fail "pending must survive malformed tasks-axi output" + assert_contains "$out" "cannot read this home's public commitments through tasks-axi" \ + "malformed backlog output must use the loud fallback" + assert_present "$home/state/public-followup/registry/pf-malformed" \ + "malformed backlog output must retain the registration" + pass "pending keeps registrations when tasks-axi returns malformed JSON" +} + +test_private_context_survives_inbox_cleanup() { + local home log posts + home=$(make_home context-retention) + log="$home/curl.log"; : > "$log" + seed_commitment "$home" pf-ctx req-ctx discord main work-ctx + emit_terminal "$home" "$home" pf-ctx main work-ctx >/dev/null || fail "emit failed" + run_pf "$home" consume >/dev/null || fail "consume failed" + + # Drain the inbox exactly as answering the original mention does, and make any + # relay fallback fail, so only the retained private context can resolve the + # thread's platform and size budget. + rm -f "$home/state/x-inbox/req-ctx.json" + assert_present "$home/state/x-context/req-ctx.json" \ + "the private request context must outlive the inbox payload" + + FAKE_CURL_LOG="$log" FAKE_REQCTX_CODE=500 run_pf "$home" deliver pf-ctx >/dev/null \ + || fail "delivery must still resolve the thread from retained private context" + posts=$(followup_posts "$log") + [ "$posts" -eq 1 ] || fail "expected one reply after inbox cleanup, got $posts" + assert_grep '"request_id":"req-ctx"' "$log" "the reply must still target the original thread" + pass "the retained private request context keeps the original thread deliverable after inbox cleanup" +} + +# --- 6. completion semantics --------------------------------------------------- + +test_cleanup_refuses_while_a_public_reply_is_owed() { + local home rc + home=$(make_home cleanup-guard) + seed_commitment "$home" pf-guard req-guard discord main ship-task + fm_write_meta "$home/state/ship-task.meta" \ + "window=firstmate:fm-ship-task" \ + "worktree=$home/projects/gone" \ + "project=$home/projects/sample" \ + "harness=codex" \ + "kind=ship" \ + "mode=no-mistakes" + + rc=0 + PATH="$home/fakebin:$PATH" FM_ROOT_OVERRIDE="$ROOT" FM_HOME="$home" \ + FM_STATE_OVERRIDE="$home/state" FM_DATA_OVERRIDE="$home/data" \ + FM_CONFIG_OVERRIDE="$home/config" "$TEARDOWN" ship-task \ + > "$home/teardown.out" 2> "$home/teardown.err" || rc=$? + [ "$rc" -ne 0 ] || fail "cleanup must refuse while a public reply is still owed" + assert_grep "still owes a public reply" "$home/teardown.err" "the refusal must be explicit" + assert_present "$home/state/ship-task.meta" "a refused cleanup must preserve the task record" + + # Once the reply has landed, the same cleanup is allowed to proceed. + emit_terminal "$home" "$home" pf-guard main ship-task >/dev/null || fail "emit failed" + run_pf "$home" consume >/dev/null || fail "consume failed" + FAKE_CURL_LOG="$home/curl.log" run_pf "$home" deliver pf-guard >/dev/null || fail "delivery failed" + rc=0 + PATH="$home/fakebin:$PATH" FM_ROOT_OVERRIDE="$ROOT" FM_HOME="$home" \ + FM_STATE_OVERRIDE="$home/state" FM_DATA_OVERRIDE="$home/data" \ + FM_CONFIG_OVERRIDE="$home/config" "$TEARDOWN" ship-task >/dev/null 2>&1 || rc=$? + [ "$rc" -eq 0 ] || fail "cleanup must proceed once the public reply has landed (rc=$rc)" + pass "cleanup refuses while a public reply is owed and proceeds once it has landed" +} + +# --- 7. zero overhead for homes that do not use the relay ---------------------- + +# The hard acceptance criterion. A home that never opted into the myfirstmate +# relay must see no process, no tasks-axi call, no scan, no output, and no file. +test_relay_disabled_home_pays_nothing() { + local home tasks_log out rc before after cmd + home=$(make_home relay-disabled relay-off) + tasks_log="$home/tasks-axi.log"; : > "$tasks_log" + # Any tasks-axi invocation at all is a failure here, so make it loud. + cat > "$home/fakebin/tasks-axi" <<'SH' +#!/usr/bin/env bash +echo "$*" >> "$FAKE_TASKS_AXI_LOG" +exit 0 +SH + chmod +x "$home/fakebin/tasks-axi" + + before=$(find "$home/state" | LC_ALL=C sort) + for cmd in "consume" "pending" "guard-work main any-task" "retire anything"; do + rc=0 + # shellcheck disable=SC2086 # each cmd is a deliberate argument list + out=$(PATH="$home/fakebin:$PATH" FM_ROOT_OVERRIDE="$ROOT" FM_HOME="$home" \ + FM_STATE_OVERRIDE="$home/state" FAKE_TASKS_AXI_LOG="$tasks_log" "$PF" $cmd 2>&1) || rc=$? + [ "$rc" -eq 0 ] || fail "'$cmd' must be a silent success in a relay-disabled home (rc=$rc)" + [ -z "$out" ] || fail "'$cmd' must print nothing in a relay-disabled home, got: $out" + done + + rc=0 + PATH="$home/fakebin:$PATH" FM_ROOT_OVERRIDE="$ROOT" FM_HOME="$home" \ + FM_STATE_OVERRIDE="$home/state" FAKE_TASKS_AXI_LOG="$tasks_log" "$PF" active || rc=$? + [ "$rc" -eq 1 ] || fail "'active' must report inactive in a relay-disabled home" + + [ ! -s "$tasks_log" ] \ + || fail "a relay-disabled home must never invoke tasks-axi: $(cat "$tasks_log")" + after=$(find "$home/state" | LC_ALL=C sort) + [ "$before" = "$after" ] \ + || fail "a relay-disabled home must gain no public-followup artifact" + assert_absent "$home/state/public-followup" \ + "a relay-disabled home must never get a public-followup directory" + + # A child cannot force artifacts into a home that never opted in either. + rc=0 + out=$("$EMIT" --home "$home" --obligation pf-x --relation rel-code \ + --source-home main --work-id w --generation 1 --outcome pr-merged \ + --outcome-text 'x' 2>&1) || rc=$? + [ "$rc" -eq 0 ] || fail "emitting into a relay-disabled home must be a silent no-op (rc=$rc)" + [ -z "$out" ] || fail "emitting into a relay-disabled home must produce no output: $out" + assert_absent "$home/state/public-followup" \ + "a refused emit must not create a public-followup directory" + pass "a relay-disabled home runs no tasks-axi call, prints nothing, and gains no artifact" +} + +# An opted-in home that has never made a public commitment must not start paying +# either: the second gate is a directory presence check, not a backlog scan. +test_relay_enabled_empty_state_makes_no_calls() { + local home tasks_log out rc cmd + home=$(make_home relay-enabled-empty) + tasks_log="$home/tasks-axi.log"; : > "$tasks_log" + cat > "$home/fakebin/tasks-axi" <<'SH' +#!/usr/bin/env bash +echo "$*" >> "$FAKE_TASKS_AXI_LOG" +exit 0 +SH + chmod +x "$home/fakebin/tasks-axi" + + for cmd in "consume" "pending" "guard-work main any-task"; do + rc=0 + # shellcheck disable=SC2086 # each cmd is a deliberate argument list + out=$(PATH="$home/fakebin:$PATH" FM_ROOT_OVERRIDE="$ROOT" FM_HOME="$home" \ + FM_STATE_OVERRIDE="$home/state" FAKE_TASKS_AXI_LOG="$tasks_log" "$PF" $cmd 2>&1) || rc=$? + [ "$rc" -eq 0 ] || fail "'$cmd' must be a silent success with no commitments (rc=$rc)" + [ -z "$out" ] || fail "'$cmd' must print nothing with no commitments, got: $out" + done + [ ! -s "$tasks_log" ] \ + || fail "an empty relay home must not query the backlog: $(cat "$tasks_log")" + pass "a relay-enabled home with no commitments makes no backlog call and stays silent" +} + +# The relay's own refusal of an exhausted follow-up binding is a captain +# decision, not something to retry into a public thread. +test_exhausted_binding_is_not_retried() { + local home log out posts + home=$(make_home exhausted) + log="$home/curl.log"; : > "$log" + seed_commitment "$home" pf-gone req-gone x main work-gone + emit_terminal "$home" "$home" pf-gone main work-gone >/dev/null || fail "emit failed" + run_pf "$home" consume >/dev/null || fail "consume failed" + + FAKE_CURL_LOG="$log" FAKE_FOLLOWUP_CODE=409 \ + expect_failure "an exhausted binding must not be reported as delivered" \ + run_pf "$home" deliver pf-gone + assert_contains "$EXPECT_OUT" "captain decision" "an exhausted binding must be escalated, not retried" + [ "$(delivery_state "$home" pf-gone)" = expired-action-required ] \ + || fail "an exhausted binding must be recorded as needing action, got $(delivery_state "$home" pf-gone)" + [ "$(task_state "$home" pf-gone)" != 'done' ] \ + || fail "an exhausted binding must never close the commitment" + posts=$(followup_posts "$log") + [ "$posts" -eq 1 ] || fail "the refused attempt is one relay call, got $posts" + pass "a relay-exhausted follow-up binding is escalated rather than retried into the thread" +} + +# The relay poll is the only thing that runs on a cadence in an opted-in home, so +# it must stay a hard no-op without a token, and must not start scanning when a +# relay-enabled home has no public commitments at all. +test_relay_poll_stays_inert_and_surfaces_once() { + local off on out first second + off=$(make_home poll-off relay-off) + out=$(PATH="$off/fakebin:$PATH" FM_ROOT_OVERRIDE="$ROOT" FM_HOME="$off" \ + FM_STATE_OVERRIDE="$off/state" "$POLL" 2>&1) + [ -z "$out" ] || fail "the relay poll must stay silent without a token, got: $out" + assert_absent "$off/state/public-followup" "an inert poll must create nothing" + + on=$(make_home poll-on) + out=$(PATH="$on/fakebin:$PATH" FM_ROOT_OVERRIDE="$ROOT" FM_HOME="$on" \ + FM_STATE_OVERRIDE="$on/state" "$POLL" 2>&1) + assert_not_contains "$out" "public-followup" \ + "a relay home with no public commitments must not mention public follow-ups" + + seed_commitment "$on" pf-poll req-poll discord main work-poll + out=$(PATH="$on/fakebin:$PATH" FM_ROOT_OVERRIDE="$ROOT" FM_HOME="$on" \ + FM_STATE_OVERRIDE="$on/state" "$POLL" 2>&1) + assert_not_contains "$out" "public-followup" \ + "a registered commitment with no terminal result yet must not wake the poll" + + emit_terminal "$on" "$on" pf-poll main work-poll >/dev/null || fail "emit failed" + first=$(PATH="$on/fakebin:$PATH" FM_ROOT_OVERRIDE="$ROOT" FM_HOME="$on" \ + FM_STATE_OVERRIDE="$on/state" "$POLL" 2>&1) + assert_contains "$first" "public-followup terminal results are waiting" \ + "a new terminal result must surface through the existing relay poll" + second=$(PATH="$on/fakebin:$PATH" FM_ROOT_OVERRIDE="$ROOT" FM_HOME="$on" \ + FM_STATE_OVERRIDE="$on/state" "$POLL" 2>&1) + assert_not_contains "$second" "public-followup" \ + "an unchanged pending set must not wake firstmate again every cycle" + pass "the relay poll stays inert without a token, silent with no commitments, and surfaces a new result once" +} + +# --- 8. startup surfacing ------------------------------------------------------ + +test_session_start_surfaces_only_when_owed() { + local off on out + off=$(make_home startup-off relay-off) + out=$(PATH="$off/fakebin:$PATH" FM_ROOT_OVERRIDE="$ROOT" FM_HOME="$off" \ + FM_STATE_OVERRIDE="$off/state" FM_DATA_OVERRIDE="$off/data" \ + FM_CONFIG_OVERRIDE="$off/config" "$SESSION_START" 2>&1) + assert_not_contains "$out" "Public commitments" \ + "a relay-disabled home must not gain a public-commitments section at startup" + + on=$(make_home startup-on) + seed_commitment "$on" pf-start req-start discord main work-start + out=$(PATH="$on/fakebin:$PATH" FM_ROOT_OVERRIDE="$ROOT" FM_HOME="$on" \ + FM_STATE_OVERRIDE="$on/state" FM_DATA_OVERRIDE="$on/data" \ + FM_CONFIG_OVERRIDE="$on/config" "$SESSION_START" 2>&1) + assert_contains "$out" "Public commitments awaiting delivery" \ + "an unresolved commitment must be surfaced at startup" + assert_contains "$out" "unresolved pf-start state=pending-work platform=discord" \ + "the startup summary must be typed and actionable" + assert_contains "$out" "fix worker placement when two spaces share a name" \ + "the startup summary must carry the public-safe summary" + assert_not_contains "$out" "please fix worker placement" \ + "the startup summary must not carry raw request text" + pass "startup surfaces unresolved public commitments only in a relay home that owes one" +} + +# --- 9. typed records stay public-safe ---------------------------------------- + +test_typed_records_exclude_raw_public_material() { + local home backlog event + home=$(make_home privacy) + seed_commitment "$home" pf-priv req-priv discord main work-priv + emit_terminal "$home" "$home" pf-priv main work-priv >/dev/null || fail "emit failed" + event=$(find "$home/state/public-followup/events" -name '*.json' | head -1) + assert_no_grep 'please fix worker placement' "$event" \ + "a terminal event must not carry raw request text" + run_pf "$home" consume >/dev/null || fail "consume failed" + + backlog="$home/data/backlog.md" + assert_no_grep 'please fix worker placement' "$backlog" \ + "the backlog must never carry raw public message text" + # The typed record is base64url canonical JSON, so check the decoded payload too. + tasks_in "$home" public-followup list --json > "$home/typed.json" + assert_no_grep 'please fix worker placement' "$home/typed.json" \ + "the typed obligation must never carry raw public message text" + pass "typed public-followup records carry only public-safe summaries and deliverables" +} + +test_outcome_text_is_bounded_without_corrupting_characters +test_restart_e2e_delivers_exactly_once +test_duplicate_event_and_replay_are_noops +test_invalid_events_are_refused_and_quarantined +test_relay_failure_holds_without_false_completion +test_dry_run_does_not_close_commitment +test_late_receipt_closes_the_exact_attempt_without_reposting +test_typed_terminal_clear_only_removes_legacy_link +test_interrupted_delivery_refuses_to_repost +test_outward_delivery_stays_with_the_owning_home +test_delivery_requires_registration_before_posting +test_secondmate_teardown_requires_parent_binding +test_relay_disabled_unmarked_teardown_skips_public_path +test_relay_disabled_parent_allows_marked_child_teardown +test_secondmate_parent_binding_matches_literal_id +test_traversal_registration_is_refused_before_delivery +test_pending_rejects_malformed_listing +test_private_context_survives_inbox_cleanup +test_cleanup_refuses_while_a_public_reply_is_owed +test_relay_disabled_home_pays_nothing +test_relay_enabled_empty_state_makes_no_calls +test_exhausted_binding_is_not_retried +test_relay_poll_stays_inert_and_surfaces_once +test_session_start_surfaces_only_when_owed +test_typed_records_exclude_raw_public_material diff --git a/tests/fm-quota-array-dispatch-live-e2e.test.sh b/tests/fm-quota-array-dispatch-live-e2e.test.sh new file mode 100755 index 0000000000..0b7f1102ab --- /dev/null +++ b/tests/fm-quota-array-dispatch-live-e2e.test.sh @@ -0,0 +1,109 @@ +#!/usr/bin/env bash +# Credentialed behavior regression for the agent-owned quota-array-dispatch skill. +# +# This drives the public Pi skill-loading interface against a fake quota-axi +# executable rather than parsing instruction source bytes or recreating the +# selector in test code. +set -u + +if [ "${FM_QUOTA_ARRAY_DISPATCH_LIVE_E2E:-0}" != 1 ]; then + echo "skip: set FM_QUOTA_ARRAY_DISPATCH_LIVE_E2E=1 to run the credentialed Pi dispatch-selection regression" + exit 0 +fi + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +OWNER="$ROOT/.agents/skills/quota-array-dispatch/SKILL.md" + +fail() { + printf 'not ok - %s\n' "$1" >&2 + exit 1 +} + +command -v pi >/dev/null 2>&1 || fail "pi not found" +[ -f "$OWNER" ] || fail "quota-array-dispatch skill not found" + +LAB=$(mktemp -d "${TMPDIR:-/tmp}/fm-quota-array-dispatch-live.XXXXXX") +PROJECT="$LAB/project" +FAKEBIN="$LAB/fakebin" +FIXTURE="$LAB/quota.json" +CALLS="$LAB/quota-axi.calls" + +cleanup() { + rm -rf "$LAB" +} +trap cleanup EXIT + +mkdir -p "$PROJECT/.agents/skills/quota-array-dispatch" "$FAKEBIN" +cp "$OWNER" "$PROJECT/.agents/skills/quota-array-dispatch/SKILL.md" + +cat > "$FAKEBIN/quota-axi" <<'SH' +#!/usr/bin/env bash +set -u +if [ "${1:-}" != --json ] || [ "$#" -ne 1 ]; then + printf 'unexpected quota-axi invocation: %s\n' "$*" >&2 + exit 64 +fi +printf '%s\n' "$*" >> "${QUOTA_AXI_CALLS:?}" +cat "${QUOTA_AXI_FIXTURE:?}" +SH +chmod +x "$FAKEBIN/quota-axi" + +write_fixture() { + cat > "$FIXTURE" +} + +run_case() { + local label=$1 expected=$2 prompt=$3 out calls required + shift 3 + : > "$CALLS" + out=$( + cd "$PROJECT" && + PATH="$FAKEBIN:$PATH" QUOTA_AXI_CALLS="$CALLS" QUOTA_AXI_FIXTURE="$FIXTURE" \ + pi --print --approve --no-session --no-context-files --no-extensions \ + --no-skills --skill .agents/skills --tools bash \ + --model openai-codex/gpt-5.6-sol --thinking high \ + "$prompt" + ) || fail "$label: Pi skill run failed: $out" + calls=$(cat "$CALLS") + [ "$calls" = "--json" ] || fail "$label: skill did not use one quota-axi --json snapshot: $calls" + printf '%s\n' "$out" | grep -Fxq "$expected" \ + || fail "$label: expected final line $expected, got: $out" + for required in "$@"; do + printf '%s\n' "$out" | grep -Fxq "$required" \ + || fail "$label: expected accounting line $required, got: $out" + done + printf '%s\n' "$out" + printf 'ok - %s\n' "$label" +} + +write_fixture <<'JSON' +{"schemaVersion":3,"providers":[{"provider":"claude","quotaSemantics":{"description":"The all_models scope bounds every Claude model.","effectiveAvailability":[{"scope":"all_models","status":"known","effectivePercentRemaining":1,"boundedBy":["weekly"],"runway":{"status":"projected_exhaustion","usableRunwaySeconds":600,"projectedExhaustedAt":"2030-01-01T00:10:00Z","limitingWindowId":"weekly","projectionConfidence":"established","projectionBasis":"cycle_average"}}]},"effectivePace":[{"scope":"all_models","pace":"ahead","worstReservePercentPoints":-1}]},{"provider":"codex","quotaSemantics":{"description":"The all_models scope bounds every Codex model.","effectiveAvailability":[{"scope":"all_models","status":"known","effectivePercentRemaining":55,"boundedBy":["weekly"],"runway":{"status":"projected_exhaustion","usableRunwaySeconds":14400,"projectedExhaustedAt":"2030-01-01T04:00:00Z","limitingWindowId":"weekly","projectionConfidence":"established","projectionBasis":"cycle_average"}}]},"effectivePace":[{"scope":"all_models","pace":"ahead","worstReservePercentPoints":-40}]}]} +JSON +run_case \ + "higher headroom and viable runway beat a less-negative reserve" \ + "SELECTED=codex" \ + "Resolve this matched dispatch profile array now. Load quota-array-dispatch and run quota-axi --json exactly once. Both profiles have comparable required task fit and the same strongest reasoning class. The authoritative catalogs already prove Claude/Sonnet and Codex/GPT models supported in their stated provider families, and their selected authentication surfaces are usable. The likely task-completion horizon is two hours with established confidence. Return exact lines FACT=claude|headroom=1|runway_seconds=600|reserve=-1 and FACT=codex|headroom=55|runway_seconds=14400|reserve=-40 to preserve candidate accounting, then an exact final line SELECTED=<claude|codex>. Do not use other vendor or model commands and do not modify files." \ + "FACT=claude|headroom=1|runway_seconds=600|reserve=-1" \ + "FACT=codex|headroom=55|runway_seconds=14400|reserve=-40" + +write_fixture <<'JSON' +{"schemaVersion":3,"providers":[{"provider":"claude","quotaSemantics":{"description":"The all_models scope bounds every Claude model.","effectiveAvailability":[{"scope":"all_models","status":"known","effectivePercentRemaining":55,"boundedBy":["weekly"],"runway":{"status":"unknown","unmeasurableWindowIds":["weekly"]}}]}},{"provider":"codex","quotaSemantics":{"description":"The all_models scope bounds every Codex model.","effectiveAvailability":[{"scope":"all_models","status":"known","effectivePercentRemaining":45,"boundedBy":["weekly"],"runway":{"status":"projected_exhaustion","usableRunwaySeconds":14400,"projectedExhaustedAt":"2030-01-01T04:00:00Z","limitingWindowId":"weekly","projectionConfidence":"established","projectionBasis":"cycle_average"}}]}}]} +JSON +run_case \ + "unmeasurable runway stays eligible and is accounted for explicitly" \ + "DECISION=CODEX" \ + "Resolve this matched dispatch profile array now. Load quota-array-dispatch and run quota-axi --json exactly once. Both profiles have comparable required task fit and the same strongest reasoning class. The authoritative catalogs already prove both models supported in their stated provider families, and their selected authentication surfaces are usable. The likely task-completion horizon is two hours with established confidence. Claude has higher known headroom but explicitly unmeasurable runway, while Codex has lower known headroom and established runway that supports completion. The snapshot cannot prove Pareto dominance in either direction, but the known completion-supporting runway justifies Codex while Claude remains eligible and its uncertainty must be disclosed. Return exact lines FACT=claude|eligible=yes|headroom=55|runway=unknown|unmeasurable=weekly and FACT=codex|eligible=yes|headroom=45|runway_seconds=14400|supports_horizon=yes, then an exact final line DECISION=CODEX. Do not use other vendor or model commands and do not modify files." \ + "FACT=claude|eligible=yes|headroom=55|runway=unknown|unmeasurable=weekly" \ + "FACT=codex|eligible=yes|headroom=45|runway_seconds=14400|supports_horizon=yes" + +write_fixture <<'JSON' +{"schemaVersion":3,"providers":[{"provider":"claude","quotaSemantics":{"description":"The all_models scope bounds every Claude model.","effectiveAvailability":[{"scope":"all_models","status":"known","effectivePercentRemaining":1,"boundedBy":["weekly"],"runway":{"status":"projected_exhaustion","usableRunwaySeconds":10800,"projectedExhaustedAt":"2030-01-01T03:00:00Z","limitingWindowId":"weekly","projectionConfidence":"established","projectionBasis":"cycle_average"}}]}},{"provider":"codex","quotaSemantics":{"description":"The all_models scope bounds every Codex model.","effectiveAvailability":[{"scope":"all_models","status":"known","effectivePercentRemaining":80,"boundedBy":["weekly"],"runway":{"status":"projected_exhaustion","usableRunwaySeconds":28800,"projectedExhaustedAt":"2030-01-01T08:00:00Z","limitingWindowId":"weekly","projectionConfidence":"established","projectionBasis":"cycle_average"}}]}}]} +JSON +run_case \ + "required strongest reasoning class is not downgraded for quota" \ + "SELECTED=claude" \ + "Resolve this matched dispatch profile array now. Load quota-array-dispatch and run quota-axi --json exactly once. The likely task-completion horizon is two hours with established confidence. Claude/Sonnet is catalog-supported with usable authentication and is the only profile that meets the task's required strongest reasoning class. Codex/GPT is catalog-supported with usable authentication but is a weaker reasoning class and cannot meet the requirement. Return exact lines FACT=claude|reasoning=required|headroom=1|runway_seconds=10800 and FACT=codex|reasoning=weaker|headroom=80|runway_seconds=28800, then an exact final line SELECTED=<claude|codex>. Do not use other vendor or model commands and do not modify files." \ + "FACT=claude|reasoning=required|headroom=1|runway_seconds=10800" \ + "FACT=codex|reasoning=weaker|headroom=80|runway_seconds=28800" + +echo "# all quota-array-dispatch live behavior tests passed" diff --git a/tests/fm-quota-array-dispatch.test.sh b/tests/fm-quota-array-dispatch.test.sh deleted file mode 100755 index a958e56c30..0000000000 --- a/tests/fm-quota-array-dispatch.test.sh +++ /dev/null @@ -1,278 +0,0 @@ -#!/usr/bin/env bash -# Contract and deterministic fixture tests for quota-array-dispatch. -# -# The skill owns the agent-facing decision procedure. -# This test encodes the same inspectable comparison rules against sanitized -# fixtures so acceptance cases stay deterministic without introducing a -# production routing wrapper. -# shellcheck disable=SC2016 -set -u - -# shellcheck source=tests/lib.sh -. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" - -AGENTS="$ROOT/AGENTS.md" -OWNER="$ROOT/.agents/skills/quota-array-dispatch/SKILL.md" -HARNESS="$ROOT/.agents/skills/harness-adapters/SKILL.md" -CONFIG="$ROOT/docs/configuration.md" -ARCHITECTURE="$ROOT/docs/architecture.md" -BOOTSTRAP="$ROOT/bin/fm-bootstrap.sh" -AUDIENCES="$ROOT/docs/documentation-audiences.json" -CASES="$ROOT/tests/fixtures/quota-array-dispatch/cases.json" -SHAPE="$ROOT/tests/fixtures/quota-array-dispatch/schema-v3-shape.json" - -intake_boundary() { - awk ' - /^## 4\. Harness and runtime dispatch$/ { found = 1; next } - found && /^## 5\. Recovery$/ { exit } - found { print } - ' "$AGENTS" -} - -select_candidate_py() { - python3 - "$@" <<'PY' -import json, sys - -def conservation_pressure(c): - if not c.get("paceAvailable", True): - return False - status = c.get("paceStatus") - ahead_ids = c.get("aheadWindowIds") or [] - bounding_windows = c.get("boundingWindows") or [] - if status == "ahead": - return True - if status == "mixed" and ahead_ids: - return True - if any(window.get("paceStatus") == "ahead" for window in bounding_windows): - return True - return False - -def select(case): - required = case.get("requiredReasoningClass") - cands = list(case["candidates"]) - if required: - matching = [c for c in cands if c.get("reasoningClass") == required] - if not matching: - return {"error": "required reasoning class unavailable"} - # Strongest-reasoning rule: never drop to a weaker class for quota. - cands = matching - - # Fit filter: fixtures mark comparable; keep only comparable for these cases. - cands = [c for c in cands if c.get("fit") == "comparable"] - if not cands: - return {"error": "no comparable candidates"} - - def sort_key(c): - pressured = conservation_pressure(c) - unknown = bool(c.get("unknownPace")) or c.get("paceStatus") == "unknown" - pace_available = bool(c.get("paceAvailable", True)) - reserve = c.get("worstReserve") - if reserve is None: - reserve_key = float("-inf") - else: - reserve_key = float(reserve) - raw = float(c.get("rawHeadroom") or 0) - # Sort ascending by preference rank components that python min understands - # via a tuple where lower is better only for pressure/unknown flags. - return ( - 1 if pressured else 0, - 1 if (unknown and pace_available) else 0, - 0 if pace_available else 1, # when pace absent, still comparable via raw only - # Among pressured: least-negative reserve => higher reserve first => negate - (-reserve_key if pressured else 0), - # Among sustainable with pace: prefer higher reserve then higher raw - (-reserve_key if (not pressured and pace_available and not unknown) else 0), - -raw, - ) - - # Special-case all-tight already constrained to required class above. - best_key = min(sort_key(c) for c in cands) - winners = [c for c in cands if sort_key(c) == best_key] - if len(winners) > 1: - return { - "error": "genuine tie requires captain choice", - "candidates": sorted(c["id"] for c in winners), - } - winner = winners[0] - return { - "id": winner["id"], - "pressured": conservation_pressure(winner), - } - -case = json.loads(sys.argv[1]) -print(json.dumps(select(case))) -PY -} - -test_owner_and_always_loaded_boundary() { - local boundary trigger_count - boundary=$(intake_boundary) - - assert_present "$OWNER" "quota-array-dispatch owner is missing" - assert_grep 'name: quota-array-dispatch' "$OWNER" "quota-array-dispatch skill has the wrong name" - assert_grep 'user-invocable: false' "$OWNER" "quota-array-dispatch skill must be agent-only" - assert_grep 'single owner of the pace-aware profile-array selection procedure' "$OWNER" \ - "quota-array-dispatch skill does not declare ownership" - - assert_contains "$boundary" 'Firstmate alone resolves a matched profile array' \ - "intake boundary lost agent-owned array resolution" - assert_contains "$boundary" 'run `quota-axi --json` at that intake' \ - "intake boundary lost quota-axi intake read" - assert_contains "$boundary" 'evaluate every configured candidate against that current output' \ - "intake boundary lost full-candidate accounting" - assert_contains "$boundary" 'inspectable real headroom including quota-window pace' \ - "intake boundary lost pace-aware headroom wording" - assert_contains "$boundary" 'if any harness/model/provider relationship, applicable quota data, or interpretation cannot be established, stop and report that candidate' \ - "intake boundary lost unresolved-candidate refusal" - assert_contains "$boundary" 'instead of omitting it, guessing, falling back, or calling the result quota-informed' \ - "intake boundary lost no-guess wording" - assert_contains "$boundary" 'Preserve malformed profile configuration as an actionable error' \ - "intake boundary lost malformed-config refusal" - assert_contains "$boundary" "preserve the captain's strongest-reasoning class rather than silently downgrading it" \ - "intake boundary lost strongest-reasoning rule" - assert_contains "$boundary" 'Break genuine headroom ties without array-order or harness bias' \ - "intake boundary lost genuine-tie rule" - assert_contains "$boundary" '`quota-axi` owns how model or product windows relate to bounding account windows' \ - "intake boundary lost quota-axi window ownership" - assert_contains "$boundary" 'remains data-only' \ - "intake boundary lost data-only producer boundary" - assert_contains "$boundary" 'Load `quota-array-dispatch` before choosing among a matched profile array' \ - "intake boundary lost quota-array-dispatch load trigger" - - trigger_count=$(grep -Fc -- '- `quota-array-dispatch` -' "$AGENTS") - [ "$trigger_count" -eq 1 ] || fail "quota-array-dispatch must have exactly one section 13 trigger, found $trigger_count" - - # Full pace procedure stays out of AGENTS.md. - if printf '%s\n' "$boundary" | grep -q 'reservePercentPoints'; then - fail "AGENTS.md intake boundary duplicated pace formula detail" - fi - if printf '%s\n' "$boundary" | grep -q 'aheadWindowIds'; then - fail "AGENTS.md intake boundary duplicated aheadWindowIds detail" - fi - - pass "quota-array-dispatch has one conditional owner and a concise always-loaded boundary" -} - -test_owner_contains_acceptance_procedure() { - local phrase - for phrase in \ - 'reservePercentPoints = percentRemaining - timeRemainingPercent' \ - 'Negative reserve means usage is ahead of reset pace and creates conservation pressure' \ - 'Positive reserve means usage is behind reset pace' \ - '`on_pace` is neutral' \ - 'effective pace status is `mixed` and any `aheadWindowIds` remain' \ - 'prefer a candidate without ahead-of-reset conservation pressure over one with conservation pressure' \ - 'even when the pressured candidate has somewhat higher raw remaining percentage' \ - 'Prefer the least-negative worst applicable reserve' \ - 'Use known behind/on-pace evidence plus raw headroom transparently' \ - 'Do not collapse those facts into an opaque composite score' \ - '`unknown` is valid explicit uncertainty from quota-axi' \ - 'Prefer known sustainable evidence over `unknown` pace when otherwise comparable' \ - 'If the dispatch choice materially hinges on unresolved pace, report the uncertainty' \ - 'Do not crash, fabricate pace, or silently reinterpret absence as healthy' \ - 'stop and report every tied candidate for captain choice' \ - 'Do not select by array order, harness name, or another arbitrary identity ordering' \ - 'Do not add a daemon, opaque composite score, routing wrapper, hard-coded model-specific policy'; do - assert_grep "$phrase" "$OWNER" "quota-array-dispatch procedure lost '$phrase'" - done - - for phrase in \ - 'Higher raw quota but materially ahead vs lower raw quota on/behind pace' \ - 'Mixed effective pace with an ahead bound' \ - 'Both candidates ahead with different worst reserves' \ - 'Known sustainable versus unknown' \ - 'Every candidate tight while strongest-reasoning applies' \ - 'Genuine tie without array-order or harness bias' \ - 'schemaVersion 2 or absent-pace compatibility'; do - assert_grep "$phrase" "$OWNER" "acceptance scenario missing: $phrase" - done - pass "quota-array-dispatch owns the full pace procedure and acceptance scenarios" -} - -test_cross_references_stay_pointers() { - assert_grep '`quota-array-dispatch` owns the pace-aware profile-array selection procedure' "$CONFIG" \ - "configuration docs do not point to quota-array-dispatch" - assert_no_grep '`AGENTS.md` section 4 owns the dispatch and array-selection procedure.' "$CONFIG" \ - "configuration docs still claim AGENTS.md owns the full array-selection procedure" - assert_grep 'quota-array-dispatch' "$ARCHITECTURE" \ - "architecture docs lost the quota-array-dispatch pointer" - assert_grep 'quota-array-dispatch' "$BOOTSTRAP" \ - "bootstrap header lost the quota-array-dispatch pointer" - assert_grep 'load `quota-array-dispatch` for the pace-aware candidate choice' "$HARNESS" \ - "harness-adapters lost the array-selection handoff" - assert_grep '.agents/skills/quota-array-dispatch/SKILL.md' "$AUDIENCES" \ - "documentation audience inventory missing quota-array-dispatch" - pass "cross-references point at the single procedure owner" -} - -test_schema_v3_shape_fixture() { - python3 - "$SHAPE" <<'PY' || fail "schema v3 shape fixture is invalid" -import json, sys -path = sys.argv[1] -data = json.load(open(path)) -assert data.get("schemaVersion") == 3, data.get("schemaVersion") -assert isinstance(data.get("providers"), list) and data["providers"], "providers" -provider = data["providers"][0] -assert "windows" in provider and provider["windows"], "windows" -window = provider["windows"][0] -assert "pace" in window and "status" in window["pace"], window -eff = provider["quotaSemantics"]["effectiveAvailability"][0] -assert "pace" in eff and "status" in eff["pace"], eff -assert "effectivePercentRemaining" in eff -# Privacy: no live account residue markers. -blob = json.dumps(data) -for bad in ("sk-", "@", "Bearer ", "accountId", "organizationId"): - assert bad not in blob, bad -PY - pass "sanitized schemaVersion 3 fixture preserves producer pace shape without private details" -} - -test_deterministic_acceptance_cases() { - local raw case_json case_id expect expect_error got reason - raw=$(cat "$CASES") - while IFS= read -r case_json; do - case_id=$(python3 -c 'import json,sys; print(json.loads(sys.argv[1])["id"])' "$case_json") - expect=$(python3 -c 'import json,sys; print(json.loads(sys.argv[1]).get("expect", ""))' "$case_json") - expect_error=$(python3 -c 'import json,sys; print(json.loads(sys.argv[1]).get("expectError", ""))' "$case_json") - reason=$(python3 -c 'import json,sys; print(json.loads(sys.argv[1])["reason"])' "$case_json") - got=$(select_candidate_py "$case_json") - python3 -c ' -import json,sys -got=json.loads(sys.argv[1]) -expect=sys.argv[2] -expect_error=sys.argv[3] -case_id=sys.argv[4] -err=got.get("error") -if expect_error: - if err != expect_error: - raise SystemExit("%s: expected error %s, got %s" % (case_id, expect_error, got)) -elif err: - raise SystemExit("%s: selector error: %s" % (case_id, err)) -elif got.get("id") != expect: - raise SystemExit("%s: expected %s, got %s" % (case_id, expect, got)) -' "$got" "$expect" "$expect_error" "$case_id" \ - || fail "case $case_id failed ($reason); selector returned $got" - if [ -n "$expect_error" ]; then - pass "case $case_id -> $expect_error ($reason)" - else - pass "case $case_id -> $expect ($reason)" - fi - done < <(python3 -c 'import json,sys; data=json.load(sys.stdin); [print(json.dumps(c, separators=(",", ":"))) for c in data["cases"]]' <<<"$raw") -} - -test_no_duplicate_procedure_in_agents() { - # Guard against re-expanding the full procedure into AGENTS.md. - local count - count=$(grep -c 'conservation pressure' "$AGENTS" || true) - [ "$count" -eq 0 ] || fail "AGENTS.md should not restate conservation-pressure procedure detail" - count=$(grep -c 'worst applicable reserve' "$AGENTS" || true) - [ "$count" -eq 0 ] || fail "AGENTS.md should not restate worst-reserve procedure detail" - pass "AGENTS.md does not duplicate the pace procedure body" -} - -test_owner_and_always_loaded_boundary -test_owner_contains_acceptance_procedure -test_cross_references_stay_pointers -test_schema_v3_shape_fixture -test_deterministic_acceptance_cases -test_no_duplicate_procedure_in_agents diff --git a/tests/fm-secondmate-harness.test.sh b/tests/fm-secondmate-harness.test.sh index 39ca2021bf..f8cd7f8077 100755 --- a/tests/fm-secondmate-harness.test.sh +++ b/tests/fm-secondmate-harness.test.sh @@ -14,11 +14,13 @@ # explicit per-spawn harness arg still wins. # B) Inheritance. The primary pushes a declared, extensible set of LOCAL # (gitignored) config items - config/crew-dispatch.json, config/crew-harness, -# config/backlog-backend, and config/herdr-presentation-spaces - down into -# each secondmate home's config/, so the secondmate's OWN crewmates, -# dispatch profiles, backlog backend, and Herdr presentation opt-in inherit -# the primary's settings. It is primary-authoritative (re-pushed at -# secondmate spawn, on the bootstrap secondmate sweep, and by config push). +# config/backlog-backend, config/backend, config/herdr-presentation-spaces, and +# config/startup-memory-budget - +# down into each secondmate home's config/, so the secondmate's OWN crewmates, +# dispatch profiles, backlog backend, runtime-backend default, and Herdr +# presentation opt-in inherit the primary's settings. It is primary-authoritative +# (re-pushed at secondmate spawn, on the bootstrap secondmate sweep, and by +# config push). # config/secondmate-harness is deliberately NOT inherited (secondmates do # not spawn secondmates). After a successful push that changes allowlisted # config under an already-running home, a literal-content reread instruction @@ -43,6 +45,15 @@ set -u # shellcheck source=/dev/null . "$ROOT/bin/fm-config-inherit-lib.sh" +# The harness-detection cases below fake `ps` so process ancestry is fully +# controlled, but bin/fm-harness.sh checks verified ENV markers before ancestry. +# A suite run from inside one of those harnesses inherits its marker, and the +# highest-precedence one wins over everything these cases set up: with an +# ambient CLAUDECODE=1, the pi-signed ancestry case resolves "claude". Drop the +# ambient markers so what this suite asserts does not depend on which harness it +# was launched from; every case states the marker it means to test. +unset CLAUDECODE PI_CODING_AGENT FM_PI_HARNESS GROK_AGENT + BASE_PATH=${FM_TEST_BASE_PATH:-/usr/bin:/bin:/usr/sbin:/sbin} fm_git_identity fmtest fmtest@example.com TMP_ROOT=$(fm_test_tmproot fm-secondmate-harness) @@ -158,19 +169,19 @@ esac SH chmod +x "$fakebin/ps" - got=$(PATH="$fakebin:$BASE_PATH" PI_CODING_AGENT=true "$ROOT/bin/fm-harness.sh") + got=$(env -u CLAUDECODE -u GROK_AGENT PATH="$fakebin:$BASE_PATH" PI_CODING_AGENT=true "$ROOT/bin/fm-harness.sh") [ "$got" = pi ] || fail "unmarked shared signed-wrapper ancestry resolved '$got', expected pi" - got=$(PATH="$fakebin:$BASE_PATH" PI_CODING_AGENT=true FM_PI_HARNESS=pi-signed "$ROOT/bin/fm-harness.sh") + got=$(env -u CLAUDECODE -u GROK_AGENT PATH="$fakebin:$BASE_PATH" PI_CODING_AGENT=true FM_PI_HARNESS=pi-signed "$ROOT/bin/fm-harness.sh") [ "$got" = pi-signed ] || fail "selected signed wrapper resolved '$got', expected pi-signed" - got=$(PATH="$fakebin:$BASE_PATH" PI_CODING_AGENT=true FM_PI_HARNESS=pi "$ROOT/bin/fm-harness.sh") + got=$(env -u CLAUDECODE -u GROK_AGENT PATH="$fakebin:$BASE_PATH" PI_CODING_AGENT=true FM_PI_HARNESS=pi "$ROOT/bin/fm-harness.sh") [ "$got" = pi ] || fail "selected plain Pi resolved '$got', expected pi" - got=$(PATH="$fakebin:$BASE_PATH" PI_CODING_AGENT=true FM_PI_HARNESS=pi-signed-helper "$ROOT/bin/fm-harness.sh") + got=$(env -u CLAUDECODE -u GROK_AGENT PATH="$fakebin:$BASE_PATH" PI_CODING_AGENT=true FM_PI_HARNESS=pi-signed-helper "$ROOT/bin/fm-harness.sh") [ "$got" = pi ] || fail "inexact signed selection marker resolved '$got', expected pi" - got=$(PATH="$fakebin:$BASE_PATH" FM_PI_HARNESS=pi-signed "$ROOT/bin/fm-harness.sh") + got=$(env -u CLAUDECODE -u GROK_AGENT -u PI_CODING_AGENT PATH="$fakebin:$BASE_PATH" FM_PI_HARNESS=pi-signed "$ROOT/bin/fm-harness.sh") [ "$got" = pi ] || fail "signed selection marker without Pi's family marker resolved '$got', expected pi" - got=$(PATH="$fakebin:$BASE_PATH" PI_CODING_AGENT=true FM_TEST_SIGNED_SHAPE=plain "$ROOT/bin/fm-harness.sh") + got=$(env -u CLAUDECODE -u GROK_AGENT PATH="$fakebin:$BASE_PATH" PI_CODING_AGENT=true FM_TEST_SIGNED_SHAPE=plain "$ROOT/bin/fm-harness.sh") [ "$got" = pi ] || fail "plain Pi marker resolved '$got', expected pi" - got=$(PATH="$fakebin:$BASE_PATH" PI_CODING_AGENT=true FM_TEST_SIGNED_SHAPE=helper "$ROOT/bin/fm-harness.sh") + got=$(env -u CLAUDECODE -u GROK_AGENT PATH="$fakebin:$BASE_PATH" PI_CODING_AGENT=true FM_TEST_SIGNED_SHAPE=helper "$ROOT/bin/fm-harness.sh") [ "$got" = pi ] || fail "unrelated pi-signed-helper ancestry resolved '$got', expected pi" got=$(PATH="$fakebin:$BASE_PATH" bash -c \ @@ -187,20 +198,73 @@ SH pass "pi-signed identity: authoritative launch selection distinguishes shared wrapper ancestry" } +test_dash_leading_process_names_are_basename_operands() { + local dir fakebin got err status + dir="$TMP_ROOT/dash-leading-process-names" + fakebin=$(fm_fakebin "$dir") + cat > "$fakebin/ps" <<'SH' +#!/usr/bin/env bash +set -u +field= pid= +while [ "$#" -gt 0 ]; do + case "$1" in + -o) field=$2; shift 2 ;; + -p) pid=$2; shift 2 ;; + *) shift ;; + esac +done +case "$pid:$field" in + 4242:comm=) printf '%s\n' '/opt/test/bin/codex' ;; + 4242:args=) printf '%s\n' 'codex' ;; + 4242:ppid=) printf '%s\n' 1 ;; + 5252:comm=) printf '%s\n' '-codex' ;; + 5252:args=) printf '%s\n' '-codex' ;; + 5252:ppid=) printf '%s\n' 1 ;; + *:comm=) printf '%s\n' '-zsh' ;; + *:args=) printf '%s\n' '-zsh' ;; + *:ppid=) printf '%s\n' 4242 ;; +esac +SH + chmod +x "$fakebin/ps" + + err="$dir/fm-harness.err" + got=$(env -u CLAUDECODE -u PI_CODING_AGENT -u GROK_AGENT \ + PATH="$fakebin:$BASE_PATH" "$ROOT/bin/fm-harness.sh" 2>"$err") + [ "$got" = codex ] || fail "dash-leading shell ancestry resolved '$got', expected codex" + [ ! -s "$err" ] || fail "fm-harness wrote basename option noise for literal -zsh: $(cat "$err")" + + err="$dir/fm-session-lock-ancestry.err" + got=$(PATH="$fakebin:$BASE_PATH" bash -c \ + '. "$0/bin/fm-session-lock-lib.sh"; fm_harness_ancestry_pid' "$ROOT" 2>"$err") + [ "$got" = 4242 ] || fail "session-lock dash-leading ancestry selected '$got', expected pid 4242" + [ ! -s "$err" ] || fail "session-lock ancestry wrote basename option noise for literal -zsh: $(cat "$err")" + + err="$dir/fm-session-lock-alive.err" + PATH="$fakebin:$BASE_PATH" bash -c \ + '. "$0/bin/fm-session-lock-lib.sh"; kill() { return 0; }; fm_harness_pid_alive 5252' \ + "$ROOT" 2>"$err"; status=$? + expect_code 0 "$status" "session-lock liveness should accept literal -codex as a harness process name" + [ ! -s "$err" ] || fail "session-lock liveness wrote basename option noise for literal -codex: $(cat "$err")" + + pass "harness identity: dash-leading ps command names are basename operands, not options" +} + # =========================================================================== # B) propagate_inheritable_config unit behavior # =========================================================================== test_propagate_lib() { - local d src dest m1 m2 outside stdout stderr guard_repo err_text + local d src dest home m1 m2 outside stdout stderr guard_repo err_text d="$TMP_ROOT/prop-lib" src="$d/src" - dest="$d/dest" - mkdir -p "$src" "$dest" + home="$d/home1" + dest="$home/config" + mkdir -p "$src" "$dest" "$home/state" # 1. present source is copied printf '{"default":{"harness":"codex"}}\n' > "$src/crew-dispatch.json" printf 'codex\n' > "$src/crew-harness" printf 'manual\n' > "$src/backlog-backend" + printf 'tmux\n' > "$src/backend" : > "$src/herdr-presentation-spaces" stdout="$d/clean-copy.out" stderr="$d/clean-copy.err" @@ -210,7 +274,11 @@ test_propagate_lib() { [ "$(cat "$dest/crew-dispatch.json")" = '{"default":{"harness":"codex"}}' ] || fail "crew-dispatch.json not propagated" [ "$(cat "$dest/crew-harness")" = codex ] || fail "crew-harness not propagated" [ "$(cat "$dest/backlog-backend")" = manual ] || fail "backlog-backend not propagated" + [ "$(cat "$dest/backend")" = tmux ] || fail "backend not propagated" [ -f "$dest/herdr-presentation-spaces" ] || fail "herdr-presentation-spaces not propagated" + printf 'herdr\n' > "$dest/backend" + propagate_inheritable_config "$src" "$dest" + [ "$(cat "$dest/backend")" = tmux ] || fail "primary backend did not overwrite a divergent destination" # 2. idempotent: an unchanged re-run does not churn the mtime m1=$(date -r "$dest/crew-harness" +%s 2>/dev/null || stat -c %Y "$dest/crew-harness") @@ -227,10 +295,12 @@ test_propagate_lib() { printf '{"default":{"harness":"claude"}}\n' > "$src/crew-dispatch.json" printf 'claude\n' > "$src/crew-harness" printf 'tasks-axi\n' > "$src/backlog-backend" + printf 'zellij\n' > "$src/backend" propagate_inheritable_config "$src" "$dest" [ "$(cat "$dest/crew-dispatch.json")" = '{"default":{"harness":"claude"}}' ] || fail "changed dispatch profile did not converge" [ "$(cat "$dest/crew-harness")" = claude ] || fail "changed value did not converge" [ "$(cat "$dest/backlog-backend")" = tasks-axi ] || fail "changed backlog backend did not converge" + [ "$(cat "$dest/backend")" = zellij ] || fail "changed backend did not converge" outside="$d/outside-target" rm -f "$dest/crew-harness" "$outside" @@ -243,11 +313,14 @@ test_propagate_lib() { [ "$(cat "$outside")" = outside ] || fail "destination symlink target was overwritten" # 4. removing the source mirrors absence downstream (primary-authoritative) - rm -f "$src/crew-dispatch.json" "$src/crew-harness" "$src/backlog-backend" "$src/herdr-presentation-spaces" + printf 'herdr\n' > "$dest/backend" + rm -f "$src/crew-dispatch.json" "$src/crew-harness" "$src/backlog-backend" \ + "$src/backend" "$src/herdr-presentation-spaces" propagate_inheritable_config "$src" "$dest" [ -e "$dest/crew-dispatch.json" ] && fail "dispatch profile absence not mirrored downstream" [ -e "$dest/crew-harness" ] && fail "absence not mirrored downstream" [ -e "$dest/backlog-backend" ] && fail "backlog-backend absence not mirrored downstream" + [ -e "$dest/backend" ] && fail "backend absence not mirrored downstream" [ -e "$dest/herdr-presentation-spaces" ] && fail "herdr-presentation-spaces absence not mirrored downstream" rm -f "$dest/crew-harness" @@ -265,22 +338,25 @@ test_propagate_lib() { [ -d "$dest/crew-harness" ] || fail "failed absence mirror removed the wrong path" rm -rf "$dest/crew-harness" - # 5. secondmate-harness is never inherited + # 5. secondmate-harness is never inherited; backend still is printf 'grok\n' > "$src/secondmate-harness" printf '{"default":{"harness":"codex"}}\n' > "$src/crew-dispatch.json" printf 'codex\n' > "$src/crew-harness" printf 'manual\n' > "$src/backlog-backend" - rm -rf "$d/dest2" - mkdir -p "$d/dest2" - propagate_inheritable_config "$src" "$d/dest2" - [ -e "$d/dest2/secondmate-harness" ] && fail "secondmate-harness was inherited (must not be)" - [ "$(cat "$d/dest2/crew-dispatch.json")" = '{"default":{"harness":"codex"}}' ] || fail "crew-dispatch.json not propagated alongside" - [ "$(cat "$d/dest2/crew-harness")" = codex ] || fail "crew-harness not propagated alongside" - [ "$(cat "$d/dest2/backlog-backend")" = manual ] || fail "backlog-backend not propagated alongside" + printf 'herdr\n' > "$src/backend" + rm -rf "$d/home2" + mkdir -p "$d/home2/config" "$d/home2/state" + propagate_inheritable_config "$src" "$d/home2/config" + [ -e "$d/home2/config/secondmate-harness" ] && fail "secondmate-harness was inherited (must not be)" + [ "$(cat "$d/home2/config/crew-dispatch.json")" = '{"default":{"harness":"codex"}}' ] || fail "crew-dispatch.json not propagated alongside" + [ "$(cat "$d/home2/config/crew-harness")" = codex ] || fail "crew-harness not propagated alongside" + [ "$(cat "$d/home2/config/backlog-backend")" = manual ] || fail "backlog-backend not propagated alongside" + [ "$(cat "$d/home2/config/backend")" = herdr ] || fail "backend not propagated alongside" # 6. nothing to propagate -> destination dir is never created (a true no-op) rm -rf "$d/src3" "$d/dest3" mkdir -p "$d/src3" + # Keep backend out of the empty-source case by clearing it from src3 only. propagate_inheritable_config "$d/src3" "$d/dest3/config" [ -e "$d/dest3/config" ] && fail "empty-source propagation created a destination dir" @@ -371,6 +447,7 @@ test_spawn_split_and_inherit() { printf 'claude\n' > "$w/home/config/crew-harness" printf 'codex\n' > "$w/home/config/secondmate-harness" printf 'manual\n' > "$w/home/config/backlog-backend" + printf 'zellij\n' > "$w/home/config/backend" make_seeded_home "$sm" sm spawn_secondmate "$w" sm "$sm" @@ -385,6 +462,8 @@ test_spawn_split_and_inherit() { || fail "split: home crew-dispatch.json not inherited" [ "$(cat "$sm/config/backlog-backend" 2>/dev/null)" = manual ] \ || fail "split: home backlog-backend not inherited as manual" + [ "$(cat "$sm/config/backend" 2>/dev/null)" = zellij ] \ + || fail "split: home backend not inherited as zellij" [ -e "$sm/config/secondmate-harness" ] \ && fail "split: secondmate-harness leaked into the secondmate home" pass "B2 spawn: secondmate runs the secondmate harness; its home inherits declared config" @@ -537,6 +616,50 @@ spawn_secondmate_capture() { "$ROOT/bin/fm-spawn.sh" "$id" "$home" "$@" --secondmate } +test_spawn_backend_precedence_over_inherited_config() { + local w sm meta launchlog out status + w="$TMP_ROOT/spawn-backend-env-precedence" + sm="$w/sm" + launchlog="$w/launch.log" + mkdir -p "$w/home/config" + printf 'herdr\n' > "$w/home/config/backend" + make_seeded_home "$sm" sm + + out=$(FM_BACKEND=tmux spawn_secondmate_capture \ + "$w" sm "$sm" "$launchlog" 2>&1); status=$? + expect_code 0 "$status" \ + "FM_BACKEND=tmux should beat inherited config/backend=herdr"$'\n'"$out" + + meta="$w/home/state/sm.meta" + [ "$(cat "$sm/config/backend")" = herdr ] \ + || fail "backend precedence fixture did not inherit config/backend=herdr" + assert_no_grep '^backend=' "$meta" \ + "FM_BACKEND=tmux did not beat inherited config/backend=herdr" + pass "B5b spawn: FM_BACKEND wins over inherited config/backend" +} + +test_spawn_explicit_backend_precedence_over_env_and_inherited_config() { + local w sm meta launchlog out status + w="$TMP_ROOT/spawn-backend-flag-precedence" + sm="$w/sm" + launchlog="$w/launch.log" + mkdir -p "$w/home/config" + printf 'herdr\n' > "$w/home/config/backend" + make_seeded_home "$sm" sm + + out=$(FM_BACKEND=zellij spawn_secondmate_capture \ + "$w" sm "$sm" "$launchlog" --backend tmux 2>&1); status=$? + expect_code 0 "$status" \ + "explicit --backend tmux should beat FM_BACKEND=zellij and inherited config/backend=herdr"$'\n'"$out" + + meta="$w/home/state/sm.meta" + [ "$(cat "$sm/config/backend")" = herdr ] \ + || fail "explicit backend precedence fixture did not inherit config/backend=herdr" + assert_no_grep '^backend=' "$meta" \ + "explicit --backend tmux did not beat FM_BACKEND=zellij and inherited config/backend=herdr" + pass "B5c spawn: explicit --backend wins over FM_BACKEND and inherited config/backend" +} + # A bare "<harness>" secondmate-harness file (today's format) must launch with # NO --model/--effort flag at all, and meta must keep recording model=default, # effort=default - the core backward-compat requirement of the new format. @@ -770,6 +893,7 @@ new_world() { printf 'projects/\nstate/\ndata/\n.no-mistakes/\n' [ "$dispatch_ignore" = no ] || printf 'config/crew-dispatch.json\n' printf 'config/crew-harness\nconfig/secondmate-harness\nconfig/backlog-backend\n' + printf 'config/backend\nconfig/herdr-presentation-spaces\nconfig/startup-memory-budget\n' } > "$w/main/.gitignore" printf 'v1\n' > "$w/main/AGENTS.md" printf 'r1\n' > "$w/main/README.md" @@ -965,6 +1089,7 @@ test_bootstrap_sweep_propagates_and_reconverges() { printf '{"default":{"harness":"codex"}}\n' > "$w/home/config/crew-dispatch.json" printf 'codex\n' > "$w/home/config/crew-harness" printf 'manual\n' > "$w/home/config/backlog-backend" + printf 'tmux\n' > "$w/home/config/backend" printf 'grok\n' > "$w/home/config/secondmate-harness" run_bootstrap "$w" >/dev/null [ "$(cat "$w/sm/config/crew-harness" 2>/dev/null)" = codex ] \ @@ -973,6 +1098,8 @@ test_bootstrap_sweep_propagates_and_reconverges() { || fail "sweep: crew-dispatch.json not pushed into the live home" [ "$(cat "$w/sm/config/backlog-backend" 2>/dev/null)" = manual ] \ || fail "sweep: backlog-backend not pushed into the live home" + [ "$(cat "$w/sm/config/backend" 2>/dev/null)" = tmux ] \ + || fail "sweep: backend not pushed into the live home" [ -e "$w/sm/config/secondmate-harness" ] \ && fail "sweep: secondmate-harness was inherited (must not be)" @@ -980,6 +1107,7 @@ test_bootstrap_sweep_propagates_and_reconverges() { printf '{"default":{"harness":"claude"}}\n' > "$w/home/config/crew-dispatch.json" printf 'claude\n' > "$w/home/config/crew-harness" printf 'tasks-axi\n' > "$w/home/config/backlog-backend" + printf 'zellij\n' > "$w/home/config/backend" run_bootstrap "$w" >/dev/null [ "$(cat "$w/sm/config/crew-harness" 2>/dev/null)" = claude ] \ || fail "sweep: home did not re-converge to the primary's new crew-harness" @@ -987,9 +1115,12 @@ test_bootstrap_sweep_propagates_and_reconverges() { || fail "sweep: home did not re-converge to the primary's new crew-dispatch.json" [ "$(cat "$w/sm/config/backlog-backend" 2>/dev/null)" = tasks-axi ] \ || fail "sweep: home did not re-converge to the primary's new backlog-backend" + [ "$(cat "$w/sm/config/backend" 2>/dev/null)" = zellij ] \ + || fail "sweep: home did not re-converge to the primary's new backend" # Mirror absence: primary clears inherited config; the home's copies are removed. - rm -f "$w/home/config/crew-dispatch.json" "$w/home/config/crew-harness" "$w/home/config/backlog-backend" + rm -f "$w/home/config/crew-dispatch.json" "$w/home/config/crew-harness" \ + "$w/home/config/backlog-backend" "$w/home/config/backend" run_bootstrap "$w" >/dev/null [ -e "$w/sm/config/crew-dispatch.json" ] \ && fail "sweep: home crew-dispatch.json not removed after the primary cleared it" @@ -997,6 +1128,8 @@ test_bootstrap_sweep_propagates_and_reconverges() { && fail "sweep: home crew-harness not removed after the primary cleared it" [ -e "$w/sm/config/backlog-backend" ] \ && fail "sweep: home backlog-backend not removed after the primary cleared it" + [ -e "$w/sm/config/backend" ] \ + && fail "sweep: home backend not removed after the primary cleared it" pass "B7 bootstrap sweep pushes, re-converges, and mirrors absence; never inherits secondmate-harness" } @@ -1011,6 +1144,7 @@ test_bootstrap_sweep_propagates_when_tracked_current() { printf '{"default":{"harness":"codex"}}\n' > "$w/home/config/crew-dispatch.json" printf 'codex\n' > "$w/home/config/crew-harness" printf 'manual\n' > "$w/home/config/backlog-backend" + printf 'tmux\n' > "$w/home/config/backend" run_bootstrap "$w" >/dev/null [ "$(cat "$w/sm/config/crew-dispatch.json" 2>/dev/null)" = '{"default":{"harness":"codex"}}' ] \ || fail "crew-dispatch.json did not propagate to a tracked-current home" @@ -1018,6 +1152,8 @@ test_bootstrap_sweep_propagates_when_tracked_current() { || fail "config did not propagate to a tracked-current home" [ "$(cat "$w/sm/config/backlog-backend" 2>/dev/null)" = manual ] \ || fail "backlog-backend did not propagate to a tracked-current home" + [ "$(cat "$w/sm/config/backend" 2>/dev/null)" = tmux ] \ + || fail "backend did not propagate to a tracked-current home" pass "B8 bootstrap sweep propagates config even when the home's tracked files are already current" } @@ -1050,10 +1186,10 @@ test_bootstrap_sweep_defers_dispatch_on_stale_unignored_home() { pass "B9 bootstrap sweep defers new inherited config until the home ignores it" } -# Backward-compat: with no inherited config set, the sweep is a no-op for the -# home's config/ - exactly as before this feature - and ordinary sweep behavior -# (fast-forward) is unaffected. -test_bootstrap_sweep_no_inheritance_is_noop() { +# The primary bootstrap always materializes the startup-memory default, so an +# otherwise empty inherited surface converges that one visible value while +# ordinary tracked-file fast-forward behavior remains unchanged. +test_bootstrap_sweep_materializes_and_inherits_memory_default() { local w c1 w=$(new_world boot-noop) c1=$(git -C "$w/main" rev-parse HEAD) @@ -1067,12 +1203,52 @@ test_bootstrap_sweep_no_inheritance_is_noop() { run_bootstrap "$w" >/dev/null - [ -e "$w/sm/config/crew-dispatch.json" ] && fail "no-inheritance sweep created a home crew-dispatch.json" - [ -e "$w/sm/config/crew-harness" ] && fail "no-inheritance sweep created a home crew-harness" - [ -e "$w/sm/config" ] && fail "no-inheritance sweep created a home config/ dir" + [ -e "$w/sm/config/crew-dispatch.json" ] && fail "default-only sweep created a home crew-dispatch.json" + [ -e "$w/sm/config/crew-harness" ] && fail "default-only sweep created a home crew-harness" + [ -e "$w/sm/config/backend" ] && fail "default-only sweep created a home backend" + [ "$(cat "$w/home/config/startup-memory-budget")" = 7500 ] \ + || fail "primary bootstrap did not materialize the startup-memory default" + [ "$(cat "$w/sm/config/startup-memory-budget")" = 7500 ] \ + || fail "default-only sweep did not converge startup-memory-budget" [ "$(git -C "$w/sm" rev-parse HEAD)" = "$head" ] \ - || fail "no-inheritance sweep did not still fast-forward the tracked files" - pass "B10 bootstrap sweep with no inherited config is a config no-op and still fast-forwards" + || fail "default-only sweep did not still fast-forward the tracked files" + pass "B10 bootstrap sweep materializes and inherits the startup-memory default while fast-forwarding" +} + +# config/backend: present and absent primary state converges exactly. +test_backend_inheritance_present_and_absent() { + local w head out err status instruction + w=$(new_world backend-inherit) + head=$(git -C "$w/main" rev-parse HEAD) + add_sm_worktree "$w" sm "$head" + + printf 'tmux\n' > "$w/home/config/backend" + err="$w/backend-inherit.err" + out=$(run_config_push "$w" 2>"$err"); status=$? + expect_code 0 "$status" "backend present push should succeed" + assert_contains "$out" "backend: pushed" "backend present value should report pushed" + [ "$(cat "$w/sm/config/backend")" = tmux ] || fail "backend present value not pushed" + instruction=$(reread_instruction_path "$w/sm") || fail "backend present reread instruction missing" + assert_contains "$(cat "$instruction")" $'-----BEGIN config/backend-----\ntmux\n-----END config/backend-----' \ + "backend present reread must include exact bytes" + + printf 'herdr\n' > "$w/sm/config/backend" + printf 'zellij\n' > "$w/home/config/backend" + out=$(run_config_push "$w" 2>"$err"); status=$? + expect_code 0 "$status" "backend changed push should succeed" + assert_contains "$out" "backend: pushed" "backend changed value should report pushed" + [ "$(cat "$w/sm/config/backend")" = zellij ] \ + || fail "primary backend did not overwrite the divergent destination" + + rm -f "$w/home/config/backend" + out=$(run_config_push "$w" 2>"$err"); status=$? + expect_code 0 "$status" "backend absence push should succeed" + assert_contains "$out" "backend: pushed - mirrored primary absence" "backend should mirror primary absence" + [ -e "$w/sm/config/backend" ] && fail "backend not removed on primary absence" + instruction=$(reread_instruction_path "$w/sm") || fail "backend absence reread instruction missing" + assert_contains "$(cat "$instruction")" $'-----BEGIN config/backend-----\nABSENT\n-----END config/backend-----' \ + "backend absence reread must use ABSENT token" + pass "B12b backend inheritance: present values and primary absence converge exactly" } test_bootstrap_sweep_surfaces_config_propagation_failure() { @@ -1113,7 +1289,7 @@ test_bootstrap_rereads_after_partial_propagation() { } test_config_push_propagates_reports_without_ff_or_nudge() { - local w c1 sm_real old_head out err status out2 tmp log + local w c1 sm_real old_head out err status out2 tmp log instruction w=$(new_world config-push-basic) c1=$(git -C "$w/main" rev-parse HEAD) add_sm_worktree "$w" sm "$c1" @@ -1131,6 +1307,7 @@ test_config_push_propagates_reports_without_ff_or_nudge() { printf '{"default":{"harness":"codex"}}\n' > "$w/home/config/crew-dispatch.json" printf 'codex\n' > "$w/home/config/crew-harness" printf 'manual\n' > "$w/home/config/backlog-backend" + printf 'tmux\n' > "$w/home/config/backend" err="$w/config-push-basic.err" log="$w/config-push-basic.tmux.log" out=$(run_config_push "$w" "$log" 2>"$err"); status=$? @@ -1146,12 +1323,18 @@ test_config_push_propagates_reports_without_ff_or_nudge() { "config push did not report crew-harness as pushed" assert_contains "$out" "backlog-backend: pushed" \ "config push did not report backlog-backend as pushed" + assert_contains "$out" "backend: pushed" \ + "config push did not report backend as pushed" assert_contains "$out" "config-reread: sent" \ "config push with changed config must send a literal reread instruction" assert_not_contains "$out" "NUDGE_SECONDMATES" \ "config push must not use the AGENTS.md instruction-surface nudge channel" [ "$(git -C "$w/sm" rev-parse HEAD)" = "$old_head" ] \ || fail "config push fast-forwarded tracked files" + [ "$(cat "$w/sm/config/backend")" = tmux ] || fail "config push did not write backend" + instruction=$(reread_instruction_path "$w/sm") || fail "config-push reread instruction missing" + assert_contains "$(cat "$instruction")" $'-----BEGIN config/backend-----\ntmux\n-----END config/backend-----' \ + "config-push reread must include exact backend bytes" [ ! -s "$err" ] || fail "clean config push wrote unexpected stderr: $(cat "$err")" assert_contains "$(cat "$log")" "[fm-from-firstmate]" \ "config reread must use the marked routed secondmate path" @@ -1165,6 +1348,8 @@ test_config_push_propagates_reports_without_ff_or_nudge() { "idempotent config push did not report crew-harness as unchanged" assert_contains "$out2" "backlog-backend: unchanged" \ "idempotent config push did not report backlog-backend as unchanged" + assert_contains "$out2" "backend: unchanged" \ + "idempotent config push did not report backend as unchanged" assert_not_contains "$out2" "config-reread: sent" \ "unchanged config must not send a reread message" [ ! -s "$log" ] || fail "unchanged config push still invoked tmux send: $(cat "$log")" @@ -1301,6 +1486,7 @@ test_config_reread_per_home_changed_sets_and_exact_bytes() { printf '%s' "$multiline_json" > "$w/home/config/crew-dispatch.json" printf 'codex\n' > "$w/home/config/crew-harness" printf 'manual\n' > "$w/home/config/backlog-backend" + printf 'tmux\n' > "$w/home/config/backend" { shared_captain_header_for_tests printf '%s\n' "shared secret preference body that must never appear in a config reread" @@ -1319,6 +1505,7 @@ test_config_reread_per_home_changed_sets_and_exact_bytes() { || fail "beta did not receive multiline dispatch" [ "$(cat "$w/alpha/config/crew-harness")" = codex ] || fail "alpha harness not updated" [ "$(cat "$w/alpha/config/backlog-backend")" = manual ] || fail "alpha backlog-backend not updated" + [ "$(cat "$w/alpha/config/backend")" = tmux ] || fail "alpha backend not updated" instr_a=$(reread_instruction_path "$w/alpha") || fail "alpha instruction missing after config push" instr_b=$(reread_instruction_path "$w/beta") || fail "beta instruction missing after config push" @@ -1328,19 +1515,21 @@ test_config_reread_per_home_changed_sets_and_exact_bytes() { [ "$(reread_mode "$instr_b")" = 600 ] || fail "beta instruction is not private" # Deterministic allowlist path order and exact destination bytes for alpha - # (all three config items were missing/stale and therefore pushed). + # (allowlisted config items were missing/stale and therefore pushed). assert_grep "These inherited config files changed" "$instr_a" "alpha framing missing" assert_grep "defaults/rules" "$instr_a" "alpha must preserve agent judgment framing" assert_contains "$(cat "$instr_a")" "config/crew-dispatch.json" "alpha missing dispatch path" assert_contains "$(cat "$instr_a")" "config/crew-harness" "alpha missing harness path" assert_contains "$(cat "$instr_a")" "config/backlog-backend" "alpha missing backlog path" + assert_contains "$(cat "$instr_a")" "config/backend" "alpha missing backend path" # Path order follows FM_INHERITABLE_CONFIG. awk ' /config\/crew-dispatch\.json/ { d=NR } /config\/crew-harness/ { h=NR } /config\/backlog-backend/ { b=NR } + /config\/backend/ && !/backlog-backend/ { k=NR } END { - if (!(d && h && b && d < h && h < b)) exit 1 + if (!(d && h && b && k && d < h && h < b && b < k)) exit 1 } ' "$instr_a" || fail "alpha instruction path order is not deterministic allowlist order" @@ -1351,6 +1540,8 @@ test_config_reread_per_home_changed_sets_and_exact_bytes() { "alpha instruction must include exact harness scalar bytes" assert_contains "$(cat "$instr_a")" $'-----BEGIN config/backlog-backend-----\nmanual\n-----END config/backlog-backend-----' \ "alpha instruction must include exact backlog-backend scalar bytes" + assert_contains "$(cat "$instr_a")" $'-----BEGIN config/backend-----\ntmux\n-----END config/backend-----' \ + "alpha instruction must include exact backend scalar bytes" # No parsed/effective summary, no SHA, no captain-shared dump. assert_not_contains "$(cat "$instr_a")" "Default worker" "must not emit parsed worker summary" @@ -1434,6 +1625,7 @@ test_config_reread_isolation_and_absent_and_send_failure() { printf '%s\n' $'crew-dispatch.json\tpushed\tmirrored primary absence' printf '%s\n' $'crew-harness\tunchanged\t' printf '%s\n' $'backlog-backend\tunchanged\t' + printf '%s\n' $'backend\tunchanged\t' printf '%s\n' $'data/captain-shared.md\tpushed\t' } > "$report" rm -f "$w/beta/config/crew-dispatch.json" @@ -2014,6 +2206,7 @@ cat > "$w/main/bin/fm-spawn.sh" <<SH . '$w/main/bin/fm-config-inherit-lib.sh' printf '%s' spawn >> '$log' printf '%s' codex > '$w/sm/config/crew-harness' +printf '%s\n' 7500 > '$w/sm/config/startup-memory-budget' SH chmod +x "$w/main/bin/fm-spawn.sh" fakebin=$(make_fake_toolchain "$w") @@ -2118,12 +2311,15 @@ SH test_harness_resolution test_secondmate_model_effort_tokens test_pi_signed_detection_and_session_lock_identity +test_dash_leading_process_names_are_basename_operands test_propagate_lib test_spawn_split_and_inherit test_spawn_backward_compat_crew_fallback test_spawn_bare_backward_compat test_spawn_explicit_harness_wins test_spawn_unverified_secondmate_harness_refused +test_spawn_backend_precedence_over_inherited_config +test_spawn_explicit_backend_precedence_over_env_and_inherited_config test_spawn_bare_harness_no_model_effort_flag test_spawn_secondmate_harness_model_token test_spawn_secondmate_harness_model_and_effort_tokens @@ -2135,7 +2331,8 @@ test_spawn_fallback_chain_and_crew_scout_unaffected test_bootstrap_sweep_propagates_and_reconverges test_bootstrap_sweep_propagates_when_tracked_current test_bootstrap_sweep_defers_dispatch_on_stale_unignored_home -test_bootstrap_sweep_no_inheritance_is_noop +test_bootstrap_sweep_materializes_and_inherits_memory_default +test_backend_inheritance_present_and_absent test_bootstrap_sweep_surfaces_config_propagation_failure test_bootstrap_rereads_after_partial_propagation test_config_push_propagates_reports_without_ff_or_nudge diff --git a/tests/fm-secondmate-liveness.test.sh b/tests/fm-secondmate-liveness.test.sh index ff5c07a948..ed35663896 100755 --- a/tests/fm-secondmate-liveness.test.sh +++ b/tests/fm-secondmate-liveness.test.sh @@ -349,7 +349,7 @@ test_sweep_respawns_confirmed_dead_secondmate() { assert_not_contains "$out" "SECONDMATE_LIVENESS: secondmate sm1: respawned" \ "a successfully respawned secondmate should be handled silently" - assert_contains "$(cat "$log")" "kill-window -t firstmate:fm-sm1" \ + assert_contains "$(cat "$log")" "kill-window -t =firstmate:=fm-sm1" \ "the stale endpoint must be killed before respawn (tmux refuses a same-named window over a live one)" assert_contains "$(cat "$log")" "new-window" \ "a confirmed-dead secondmate should actually be relaunched" diff --git a/tests/fm-secondmate-safety.test.sh b/tests/fm-secondmate-safety.test.sh index bf65516053..8295c21d71 100755 --- a/tests/fm-secondmate-safety.test.sh +++ b/tests/fm-secondmate-safety.test.sh @@ -109,6 +109,72 @@ EOF pass "seed allows overlapping project clone lists and drops the owns/owner routing" } +test_home_seed_validate_rejects_unparseable_registry_entry() { + local home err + home="$TMP_ROOT/unparseable-registry-home" + err="$TMP_ROOT/unparseable-registry.err" + mkdir -p "$home/data" + printf '%s\n' '- broken - prose (home: /tmp/child; scope: missing projects and date)' > "$home/data/secondmates.md" + if FM_HOME="$home" "$ROOT/bin/fm-home-seed.sh" validate >/dev/null 2>"$err"; then + fail "home-seed validation accepted an operationally unparseable registry record" + fi + grep -F 'malformed secondmate registry entry' "$err" >/dev/null \ + || fail "home-seed validation did not explain the malformed registry record" + pass "home-seed validation rejects registry records no operational parser can consume" +} + +test_home_seed_refuses_broken_registry_symlink() { + local home sub err target + home="$TMP_ROOT/broken-registry-symlink-home" + sub="$TMP_ROOT/broken-registry-symlink-subhome" + err="$TMP_ROOT/broken-registry-symlink.err" + target="$home/data/missing-secondmates.md" + mkdir -p "$home/data" "$home/state" "$home/projects" + ln -s "$target" "$home/data/secondmates.md" + if FM_HOME="$home" "$ROOT/bin/fm-home-seed.sh" validate >/dev/null 2>"$err"; then + fail "home-seed validation accepted a broken registry symlink" + fi + grep -F 'secondmate registry is unavailable or unsafe' "$err" >/dev/null \ + || fail "home-seed validation did not explain the broken registry symlink" + if FM_HOME="$home" FM_SECONDMATE_CHARTER='design domain' \ + "$ROOT/bin/fm-home-seed.sh" design "$sub" alpha >/dev/null 2>"$err"; then + fail "home seeding accepted a broken registry symlink" + fi + [ -L "$home/data/secondmates.md" ] || fail "home seeding replaced the broken registry symlink" + [ ! -e "$target" ] || fail "home seeding wrote through the broken registry symlink" + [ ! -e "$sub" ] || fail "home seeding provisioned a home before broken registry refusal" + [ ! -e "$home/data/design" ] || fail "home seeding created a brief before broken registry refusal" + pass "home seeding refuses broken registry symlinks before provisioning" +} + +test_home_seed_refuses_unreadable_registry() { + local home sub err registry + home="$TMP_ROOT/unreadable-registry-home" + sub="$TMP_ROOT/unreadable-registry-subhome" + err="$TMP_ROOT/unreadable-registry.err" + registry="$home/data/secondmates.md" + mkdir -p "$home/data" "$home/state" "$home/projects" + printf '%s\n' '- design - design domain (home: /tmp/design; scope: design; projects: alpha; added 2026-07-30)' > "$registry" + chmod 000 "$registry" + if FM_HOME="$home" "$ROOT/bin/fm-home-seed.sh" validate >/dev/null 2>"$err"; then + chmod 600 "$registry" + fail "home-seed validation accepted an unreadable registry" + fi + grep -F 'secondmate registry is unavailable or unsafe' "$err" >/dev/null || { + chmod 600 "$registry" + fail "home-seed validation did not explain the unreadable registry" + } + if FM_HOME="$home" FM_SECONDMATE_CHARTER='design domain' \ + "$ROOT/bin/fm-home-seed.sh" design "$sub" alpha >/dev/null 2>"$err"; then + chmod 600 "$registry" + fail "home seeding accepted an unreadable registry" + fi + chmod 600 "$registry" + [ ! -e "$sub" ] || fail "home seeding provisioned a home before unreadable registry refusal" + [ ! -e "$home/data/design" ] || fail "home seeding created a brief before unreadable registry refusal" + pass "home seeding refuses unreadable registries before provisioning" +} + test_home_seed_validate_rejects_duplicate_homes() { local home subhome subhome_abs err home="$TMP_ROOT/duplicate-home" @@ -444,6 +510,94 @@ test_home_seed_no_projects_end_to_end() { pass "home seeding scaffolds, registers, and spawns a project-less home end to end" } +test_secondmate_spawn_resolves_punctuated_registry_projects() { + local home sub sub_abs fakebin log meta projects + home="$TMP_ROOT/punctuated-spawn-home" + sub="$TMP_ROOT/punctuated-spawn-subhome" + mkdir -p "$home/data" "$home/state" "$home/config" "$home/projects" + mkdir -p "$sub/data" "$sub/state" "$sub/config" "$sub/projects" + mark_firstmate_home "$sub" + printf 'punctuated\n' > "$sub/.fm-secondmate-home" + printf '# Charter\n\nHandled work.\n' > "$sub/data/charter.md" + sub_abs=$(cd "$sub" && pwd -P) + printf -- '- punctuated - launch notes (parenthetical) (home: %s; scope: launch (child); semicolon is valid; projects: alpha, beta; added 2026-07-30)' \ + "$sub_abs" > "$home/data/secondmates.md" + FM_HOME="$home" "$ROOT/bin/fm-home-seed.sh" validate >/dev/null \ + || fail "home-seed validation rejected punctuated registry fields before spawn" + fakebin=$(make_fake_tmux "$TMP_ROOT/punctuated-spawn-fake") + log="$TMP_ROOT/punctuated-spawn-fake/tmux.log" + PATH="$fakebin:$PATH" FM_HOME="$home" FM_FAKE_TMUX_LOG="$log" \ + FM_FAKE_TMUX_CAPTURE="$TMP_ROOT/punctuated-spawn-fake/pane.txt" \ + "$ROOT/bin/fm-spawn.sh" punctuated codex --secondmate >/dev/null 2>&1 \ + || fail "secondmate spawn failed for punctuated registry fields" + meta="$home/state/punctuated.meta" + projects=$(grep '^projects=' "$meta" | cut -d= -f2-) + [ "$projects" = 'alpha, beta' ] \ + || fail "secondmate spawn resolved the wrong projects field: '$projects'" + pass "secondmate spawn resolves home validation and projects from punctuated registry fields" +} + +test_secondmate_spawn_refuses_ambiguous_and_mismatched_registry_bindings() { + local row case_name home sub other fakebin log err meta_before + for row in duplicate-id unterminated-duplicate-id duplicate-home supplied-mismatch metadata-mismatch; do + case_name=${row%%|*} + home="$TMP_ROOT/spawn-binding-$case_name-home" + sub="$TMP_ROOT/spawn-binding-$case_name-sub" + other="$TMP_ROOT/spawn-binding-$case_name-other" + mkdir -p "$home/data" "$home/state" "$home/config" "$home/projects" + mark_firstmate_home "$sub" + mark_firstmate_home "$other" + printf 'domain\n' > "$sub/.fm-secondmate-home" + printf 'domain\n' > "$other/.fm-secondmate-home" + case "$case_name" in + duplicate-id) + cat > "$home/data/secondmates.md" <<EOF +- domain - primary route (home: $sub; scope: valid (scope); punctuation; projects: alpha; added 2026-07-30) +- domain - duplicate route (home: $other; scope: duplicate; projects: beta; added 2026-07-30) +EOF + ;; + unterminated-duplicate-id) + printf -- '- domain - primary route (home: %s; scope: valid (scope); punctuation; projects: alpha; added 2026-07-30)\n- domain - duplicate route (home: %s; scope: duplicate; projects: beta; added 2026-07-30)' \ + "$sub" "$other" > "$home/data/secondmates.md" + ;; + duplicate-home) + cat > "$home/data/secondmates.md" <<EOF +- domain - primary route (home: $sub; scope: valid (scope); punctuation; projects: alpha; added 2026-07-30) +- other - duplicate home route (home: $sub; scope: duplicate; projects: beta; added 2026-07-30) +EOF + ;; + supplied-mismatch|metadata-mismatch) + printf -- '- domain - mismatched route (home: %s; scope: valid (scope); punctuation; projects: alpha; added 2026-07-30)\n' \ + "$other" > "$home/data/secondmates.md" + ;; + esac + fakebin=$(make_fake_tmux "$TMP_ROOT/spawn-binding-$case_name-fake") + log="$TMP_ROOT/spawn-binding-$case_name-fake/tmux.log" + err="$TMP_ROOT/spawn-binding-$case_name.err" + if [ "$case_name" = metadata-mismatch ]; then + fm_write_secondmate_meta "$home/state/domain.meta" "$sub" + meta_before="$TMP_ROOT/spawn-binding-$case_name.meta.before" + cp "$home/state/domain.meta" "$meta_before" + if PATH="$fakebin:$PATH" FM_HOME="$home" FM_FAKE_TMUX_LOG="$log" \ + FM_FAKE_TMUX_CAPTURE="$TMP_ROOT/spawn-binding-$case_name-fake/pane.txt" \ + "$ROOT/bin/fm-spawn.sh" domain codex --secondmate >/dev/null 2>"$err"; then + fail "secondmate spawn accepted $case_name registry binding" + fi + cmp -s "$meta_before" "$home/state/domain.meta" || fail "secondmate spawn changed metadata after $case_name refusal" + else + if PATH="$fakebin:$PATH" FM_HOME="$home" FM_FAKE_TMUX_LOG="$log" \ + FM_FAKE_TMUX_CAPTURE="$TMP_ROOT/spawn-binding-$case_name-fake/pane.txt" \ + "$ROOT/bin/fm-spawn.sh" domain "$sub" codex --secondmate >/dev/null 2>"$err"; then + fail "secondmate spawn accepted $case_name registry binding" + fi + [ ! -e "$home/state/domain.meta" ] || fail "secondmate spawn wrote metadata after $case_name refusal" + fi + [ ! -e "$home/state/.spawn-domain.lock" ] || fail "secondmate spawn left a lock after $case_name refusal" + grep -F 'new-window' "$log" >/dev/null && fail "secondmate spawn created an endpoint before $case_name refusal" + done + pass "secondmate spawn refuses ambiguous, supplied-home, and metadata-home registry bindings" +} + test_home_seed_refuses_projectful_reused_charter_for_projectless_home() { local home reusable_sub stale_sub stale_brief stale_brief_before err home="$TMP_ROOT/no-projects-reused-charter-home" @@ -1316,6 +1470,53 @@ EOF pass "secondmate teardown retires empty homes and releases routing" } +test_secondmate_teardown_refuses_ambiguous_and_mismatched_registry_bindings() { + local case_name home sub other fakebin log err meta_before registry_before + for case_name in duplicate-id duplicate-home home-mismatch; do + home="$TMP_ROOT/teardown-binding-$case_name-home" + sub="$TMP_ROOT/teardown-binding-$case_name-sub" + other="$TMP_ROOT/teardown-binding-$case_name-other" + mkdir -p "$home/state" "$home/data" "$sub/state" "$sub/data" "$sub/config" "$sub/projects" "$other" + printf 'domain\n' > "$sub/.fm-secondmate-home" + fm_write_secondmate_meta "$home/state/domain.meta" "$sub" + case "$case_name" in + duplicate-id) + cat > "$home/data/secondmates.md" <<EOF +- domain - primary route (home: $sub; scope: valid (scope); punctuation; projects: alpha; added 2026-07-30) +- domain - duplicate route (home: $other; scope: duplicate; projects: beta; added 2026-07-30) +EOF + ;; + duplicate-home) + cat > "$home/data/secondmates.md" <<EOF +- domain - primary route (home: $sub; scope: valid (scope); punctuation; projects: alpha; added 2026-07-30) +- other - duplicate home route (home: $sub; scope: duplicate; projects: beta; added 2026-07-30) +EOF + ;; + home-mismatch) + printf -- '- domain - mismatched route (home: %s; scope: valid (scope); punctuation; projects: alpha; added 2026-07-30)\n' \ + "$other" > "$home/data/secondmates.md" + ;; + esac + meta_before="$TMP_ROOT/teardown-binding-$case_name.meta.before" + registry_before="$TMP_ROOT/teardown-binding-$case_name.registry.before" + cp "$home/state/domain.meta" "$meta_before" + cp "$home/data/secondmates.md" "$registry_before" + fakebin=$(make_fake_tmux "$TMP_ROOT/teardown-binding-$case_name-fake") + log="$TMP_ROOT/teardown-binding-$case_name-fake/tmux.log" + err="$TMP_ROOT/teardown-binding-$case_name.err" + if PATH="$fakebin:$PATH" FM_HOME="$home" FM_FAKE_TMUX_LOG="$log" \ + FM_FAKE_TMUX_CAPTURE="$TMP_ROOT/teardown-binding-$case_name-fake/pane.txt" \ + "$ROOT/bin/fm-teardown.sh" domain --force >/dev/null 2>"$err"; then + fail "secondmate teardown accepted $case_name registry binding" + fi + [ -d "$sub" ] || fail "secondmate teardown removed the home after $case_name refusal" + cmp -s "$meta_before" "$home/state/domain.meta" || fail "secondmate teardown changed metadata after $case_name refusal" + cmp -s "$registry_before" "$home/data/secondmates.md" || fail "secondmate teardown changed registry after $case_name refusal" + grep -F 'kill-window' "$log" >/dev/null && fail "secondmate teardown killed an endpoint before $case_name refusal" + done + pass "secondmate teardown refuses ambiguous and identity-mismatched registry bindings" +} + test_secondmate_teardown_refuses_failed_leased_home_return() { local home subhome subhome_abs fakebin log fmroot err rc home="$TMP_ROOT/teardown-return-fail-home" @@ -1435,8 +1636,8 @@ EOF [ ! -d "$childwt" ] || fail "force teardown did not remove child worktree" [ ! -e "$home/state/domain.meta" ] || fail "teardown did not clear parent meta" grep -F -- '- domain ' "$home/data/secondmates.md" >/dev/null && fail "force teardown did not remove secondmate registry route" - grep -F 'kill-window -t firstmate:fm-child' "$log" >/dev/null || fail "force teardown did not kill child window" - grep -F 'kill-window -t firstmate:fm-domain' "$log" >/dev/null || fail "force teardown did not kill parent window" + grep -F 'kill-window -t =firstmate:=fm-child' "$log" >/dev/null || fail "force teardown did not kill child window" + grep -F 'kill-window -t =firstmate:=fm-domain' "$log" >/dev/null || fail "force teardown did not kill parent window" pass "secondmate force teardown discards child work" } @@ -1614,7 +1815,7 @@ EOF || fail "force teardown refused $opdir symlinked inside the secondmate home" [ ! -e "$subhome" ] || fail "force teardown did not remove subhome with inside $opdir symlink" [ ! -e "$home/state/domain.meta" ] || fail "force teardown did not clear parent meta for inside $opdir symlink" - grep -F 'kill-window -t firstmate:fm-domain' "$log" >/dev/null || fail "force teardown did not kill parent window for inside $opdir symlink" + grep -F 'kill-window -t =firstmate:=fm-domain' "$log" >/dev/null || fail "force teardown did not kill parent window for inside $opdir symlink" done pass "force teardown allows operational directory symlinks inside the subhome" } @@ -2167,6 +2368,9 @@ EOF test_fm_home_parameterization test_lock_status_is_per_home test_seed_allows_overlapping_clones_and_drops_owner +test_home_seed_validate_rejects_unparseable_registry_entry +test_home_seed_refuses_broken_registry_symlink +test_home_seed_refuses_unreadable_registry test_home_seed_validate_rejects_duplicate_homes test_home_seed_validate_rejects_duplicate_ids test_home_seed_validate_rejects_nested_homes @@ -2179,6 +2383,8 @@ test_home_seed_refuses_missing_filled_charter test_home_seed_refuses_placeholder_charter test_home_seed_refuses_empty_charter_fields test_home_seed_no_projects_end_to_end +test_secondmate_spawn_resolves_punctuated_registry_projects +test_secondmate_spawn_refuses_ambiguous_and_mismatched_registry_bindings test_home_seed_refuses_projectful_reused_charter_for_projectless_home test_home_seed_refuses_projectless_conversion_of_populated_home test_home_seed_refuses_projectless_home_with_uninspectable_projects @@ -2205,6 +2411,7 @@ test_secondmate_spawn_requires_seeded_matching_home test_secondmate_spawn_refuses_operational_dirs_outside_subhome test_fm_send_refuses_bare_window_without_home_meta test_secondmate_teardown_retires_empty_home +test_secondmate_teardown_refuses_ambiguous_and_mismatched_registry_bindings test_secondmate_teardown_refuses_failed_leased_home_return test_secondmate_teardown_removes_plain_clone_home_without_treehouse_return test_secondmate_force_teardown_discards_child_work diff --git a/tests/fm-secondmate-sync.test.sh b/tests/fm-secondmate-sync.test.sh index 79781e28ad..d67350a1e7 100755 --- a/tests/fm-secondmate-sync.test.sh +++ b/tests/fm-secondmate-sync.test.sh @@ -347,6 +347,9 @@ SH chmod +x "$fakebin/tasks-axi" cat > "$fakebin/quota-axi" <<'SH' #!/usr/bin/env bash +if [ "${1:-}" = --version ]; then + printf '%s\n' 'quota-axi 0.1.16 (fake)' +fi exit 0 SH chmod +x "$fakebin/quota-axi" @@ -837,15 +840,6 @@ test_seed_marker_does_not_mask_real_dirt() { pass "T14 marker tolerance does not mask a genuinely dirty home" } -# --- T15: the shipped firstmate repo gitignores the seed marker ----------------- -# Pins the actual fix so it cannot silently regress: without this .gitignore entry -# every seeded home would read dirty again the moment it lands on this repo's HEAD. -test_repo_gitignores_seed_marker() { - grep -qxF '.fm-secondmate-home' "$ROOT/.gitignore" \ - || fail "the firstmate repo .gitignore must ignore the seed marker (.fm-secondmate-home)" - pass "T15 the firstmate repo gitignores the secondmate seed marker" -} - test_ff_updated test_ff_current test_ff_dirty @@ -866,6 +860,5 @@ test_spawn_warns_when_sync_skipped_before_launch test_seed_marker_clean_when_gitignored test_seed_marker_converges_existing_home test_seed_marker_does_not_mask_real_dirt -test_repo_gitignores_seed_marker echo "# all fm-secondmate-sync tests passed" diff --git a/tests/fm-send-secondmate-marker-herdr-e2e.test.sh b/tests/fm-send-secondmate-marker-herdr-e2e.test.sh index 528ea49f22..5e28b9aaf8 100755 --- a/tests/fm-send-secondmate-marker-herdr-e2e.test.sh +++ b/tests/fm-send-secondmate-marker-herdr-e2e.test.sh @@ -27,7 +27,7 @@ if [ "${FM_SEND_MARKER_HERDR_E2E:-0}" != 1 ]; then exit 0 fi -for tool in git herdr jq pi python3; do +for tool in git herdr jq pi; do command -v "$tool" >/dev/null 2>&1 || { echo "skip: $tool not found"; exit 0; } done @@ -39,6 +39,7 @@ SECOND_HOME="$TMP_ROOT/secondmate-home" CAPTURE="$TMP_ROOT/pi-before-agent.jsonl" FAKEBIN="$TMP_ROOT/fakebin" ORIGINAL_PATH=$PATH +REAL_PI=$(command -v pi) ID='marker-pi-sm' REQUEST='FM_MARKER_HERDR_E2E exact-id request' DIRECT='FM_MARKER_HERDR_DIRECT captain input' @@ -93,38 +94,25 @@ You are a task-local secondmate used only for the marker transport regression. Stay idle and do not initiate work. EOF -# The extension is already an explicit Pi -e resource in the real secondmate -# launch template, so its project_trust hook can grant session-only trust before -# project resources load. before_agent_start records the exact prompt bytes and -# aborts before any provider request, keeping this transport regression local. +# A separate explicit Pi extension grants session-only project trust, records +# before_agent_start prompt bytes, and aborts before any provider request. +# The PATH wrapper adds only that test resource while preserving the production +# secondmate launch and its own extension arguments unchanged. CAPTURE_JSON=$(printf '%s' "$CAPTURE" | jq -Rs .) -python3 - "$SECOND_HOME/.pi/extensions/fm-primary-turnend-guard.ts" "$CAPTURE_JSON" <<'PY' -from pathlib import Path -import sys - -path = Path(sys.argv[1]) -capture_json = sys.argv[2] -source = path.read_text() -import_anchor = 'import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";\n' -source = source.replace( - import_anchor, - import_anchor - + 'import { appendFileSync as fmAppendFileSync } from "node:fs";\n' - + f'const fmCapturePath = {capture_json};\n', - 1, -) -factory_anchor = 'export default function (pi: ExtensionAPI) {\n' -replacement = '''export default function (pi: ExtensionAPI) { +CAPTURE_EXTENSION="$TMP_ROOT/fm-send-marker-capture.ts" +cat > "$CAPTURE_EXTENSION" <<EOF +import { appendFileSync } from "node:fs"; +const capturePath = $CAPTURE_JSON; +export default function (pi: any) { pi.on("project_trust", () => ({ trusted: "yes", remember: false })); pi.on("before_agent_start", (event, ctx) => { - fmAppendFileSync(fmCapturePath, `${JSON.stringify({ prompt: event.prompt, hex: Buffer.from(event.prompt, "utf8").toString("hex") })}\\n`); + appendFileSync(capturePath, \`\${JSON.stringify({ prompt: event.prompt, hex: Buffer.from(event.prompt, "utf8").toString("hex") })}\\n\`); ctx.abort(); }); -''' -if import_anchor not in source or factory_anchor not in source: - raise SystemExit("Pi extension insertion point missing") -path.write_text(source.replace(factory_anchor, replacement, 1)) -PY +} +EOF +printf '#!/usr/bin/env bash\nexec %q -e %q "$@"\n' "$REAL_PI" "$CAPTURE_EXTENSION" > "$FAKEBIN/pi" +chmod +x "$FAKEBIN/pi" "$LAB_HELPER" provision "$SESSION" PATH="$FAKEBIN:$ORIGINAL_PATH" FM_GATE_REFUSE_BYPASS=1 FM_HOME="$SENDER_HOME" HERDR_SESSION="$SESSION" \ diff --git a/tests/fm-send-settle.test.sh b/tests/fm-send-settle.test.sh index 01d2d427e7..3961bfe85a 100755 --- a/tests/fm-send-settle.test.sh +++ b/tests/fm-send-settle.test.sh @@ -16,6 +16,8 @@ set -u # shellcheck source=tests/lib.sh . "$(dirname "${BASH_SOURCE[0]}")/lib.sh" +# shellcheck source=/dev/null +. "$ROOT/bin/fm-busy-lib.sh" SEND="$ROOT/bin/fm-send.sh" @@ -116,7 +118,29 @@ test_key_path_never_pauses() { pass "fm-send: the --key path never pauses (settle scoped to text submit)" } +test_claude_escape_records_interrupt_idle() { + local dir fb log rc home gen out + dir="$TMP_ROOT/claude-interrupt"; mkdir -p "$dir" + fb=$(make_stubs "$dir"); log="$dir/sleep.log" + home="$dir/home"; mkdir -p "$home/state" + fm_write_meta "$home/state/task.meta" \ + "window=sess:win" "worktree=$home/wt" "project=$home/project" \ + "harness=claude" "kind=ship" "mode=no-mistakes" "yolo=off" + gen=$("$ROOT/bin/fm-busy-event.sh" arm "$home/state" task) + printf 'busy_gen=%s\n' "$gen" >> "$home/state/task.meta" + : > "$log" + + env PATH="$fb:$PATH" FM_HOME="$home" FM_SLEEP_LOG="$log" \ + "$SEND" task --key Escape 2>/dev/null; rc=$? + expect_code 0 "$rc" "Claude Escape send should succeed" + out=$(fm_busy_classify tmux sess:win claude task "$home/state") + [ "$out" = "idle fm-interrupt" ] \ + || fail "Claude Escape must classify idle/fm-interrupt, got '$out'" + pass "fm-send: a successful Claude Escape records the interrupt lifecycle edge" +} + test_default_send_pauses_one_second test_zero_disables_pause test_pause_is_tunable test_key_path_never_pauses +test_claude_escape_records_interrupt_idle diff --git a/tests/fm-session-start.test.sh b/tests/fm-session-start.test.sh index 69eb5a24bd..68c926162a 100755 --- a/tests/fm-session-start.test.sh +++ b/tests/fm-session-start.test.sh @@ -313,6 +313,9 @@ SH make_fake_herdr_secondmate_recovery() { local fakebin=$1 + # The recovery kill now requires the shared named-session lock and an exact + # focus snapshot. Keep a focused sibling tab so this test's husk close is + # provably non-workspace-emptying and never needs to signal a fake shell pid. cat > "$fakebin/herdr" <<'SH' #!/usr/bin/env bash set -u @@ -326,16 +329,19 @@ case "${1:-} ${2:-}" in "status --json") printf '%s\n' '{"client":{"protocol":14,"version":"test"},"server":{"running":true}}' ;; + "session list") + printf '{"sessions":[{"name":"default","running":true,"socket_path":"%s.sock"}]}\n' "$state" + ;; "workspace list") - printf '{"result":{"workspaces":[{"workspace_id":"ws1","label":"2ndmate-%s"}]}}\n' "$mate_id" + printf '{"result":{"workspaces":[{"workspace_id":"ws1","label":"2ndmate-%s","focused":true,"active_tab_id":"t-focus"}]}}\n' "$mate_id" ;; "tab list") if [ -e "$spawned" ]; then - printf '{"result":{"tabs":[{"tab_id":"t-new","workspace_id":"ws1","label":"fm-%s"}]}}\n' "$mate_id" + printf '{"result":{"tabs":[{"tab_id":"t-focus","workspace_id":"ws1","label":"captain","focused":true},{"tab_id":"t-new","workspace_id":"ws1","label":"fm-%s","focused":false}]}}\n' "$mate_id" elif [ -e "$killed" ]; then - printf '%s\n' '{"result":{"tabs":[]}}' + printf '%s\n' '{"result":{"tabs":[{"tab_id":"t-focus","workspace_id":"ws1","label":"captain","focused":true}]}}' else - printf '{"result":{"tabs":[{"tab_id":"t-old","workspace_id":"ws1","label":"fm-%s"}]}}\n' "$mate_id" + printf '{"result":{"tabs":[{"tab_id":"t-focus","workspace_id":"ws1","label":"captain","focused":true},{"tab_id":"t-old","workspace_id":"ws1","label":"fm-%s","focused":false}]}}\n' "$mate_id" fi ;; "tab create") @@ -354,9 +360,9 @@ case "${1:-} ${2:-}" in "pane get") pane=${3:-} if [ "$pane" = p-new ] && [ -e "$spawned" ]; then - printf '%s\n' '{"result":{"pane":{"pane_id":"p-new"}}}' + printf '%s\n' '{"result":{"pane":{"pane_id":"p-new","tab_id":"t-new","workspace_id":"ws1"}}}' elif [ "$pane" = p-old ] && [ ! -e "$killed" ]; then - printf '%s\n' '{"result":{"pane":{"pane_id":"p-old"}}}' + printf '%s\n' '{"result":{"pane":{"pane_id":"p-old","tab_id":"t-old","workspace_id":"ws1"}}}' else printf '%s\n' '{"error":{"code":"pane_not_found"}}' >&2 exit 1 @@ -965,7 +971,7 @@ EOF out=$(run_session_start_secondmate "$root" "$home" "$fakebin" "$mate" "$log" "$spawned" shell) assert_not_contains "$out" "SECONDMATE_LIVENESS:" "successful bare-shell recovery should stay non-actionable" - assert_contains "$(cat "$log")" "kill-window -t firstmate:fm-$SESSION_START_SECOND_MATE_ID" \ + assert_contains "$(cat "$log")" "kill-window -t =firstmate:=fm-$SESSION_START_SECOND_MATE_ID" \ "the proven bare-shell path did not remove its existing dead endpoint" assert_contains "$(cat "$log")" "new-window" "the proven bare-shell path did not relaunch" assert_contains "$out" "endpoint: alive (backend=tmux window=firstmate:fm-$SESSION_START_SECOND_MATE_ID)" \ diff --git a/tests/fm-sessionstart-nudge.test.sh b/tests/fm-sessionstart-nudge.test.sh index 28bb3d18b5..878295cba5 100755 --- a/tests/fm-sessionstart-nudge.test.sh +++ b/tests/fm-sessionstart-nudge.test.sh @@ -148,44 +148,6 @@ EOF pass "OpenCode session.created delivers the exact wrapper nudge once per session" } -test_tracked_harness_registration() { - local command pi_plugin opencode_plugin - jq -e '.hooks.SessionStart | length == 1' "$ROOT/.claude/settings.json" >/dev/null \ - || fail "Claude SessionStart hook is not registered exactly once" - jq -e '.hooks.SessionStart[0].matcher == "startup|resume|clear"' "$ROOT/.claude/settings.json" >/dev/null \ - || fail "Claude SessionStart matcher must include startup/resume/clear and exclude compact" - jq -e 'any(.hooks.SessionStart[]?.hooks[]?.command?; contains("fm-sessionstart-nudge.sh"))' \ - "$ROOT/.claude/settings.json" >/dev/null || fail "Claude SessionStart hook does not invoke the wrapper" - - command=$(jq -r '.hooks.SessionStart[0].hooks[0].command' "$ROOT/.codex/hooks.json") - # shellcheck disable=SC2016 - assert_contains "$command" 'payload=$(cat' "Codex SessionStart hook does not read its payload" - # shellcheck disable=SC2016 - assert_contains "$command" 'root=$(pwd -P)' "Codex SessionStart hook is not pwd-anchored" - assert_contains "$command" 'fm-sessionstart-nudge.sh' "Codex SessionStart hook does not invoke the wrapper" - - command=$(jq -r '.hooks.SessionStart[0].hooks[0].command' "$ROOT/.grok/hooks/fm-primary-sessionstart-nudge.json") - # shellcheck disable=SC2016 - assert_contains "$command" '${GROK_WORKSPACE_ROOT:-}' "Grok SessionStart hook lacks an inline-default workspace root" - # shellcheck disable=SC2016 - assert_not_contains "$command" '${GROK_WORKSPACE_ROOT}' "Grok SessionStart hook contains a bare variable expansion" - assert_contains "$command" 'fm-sessionstart-nudge.sh' "Grok SessionStart hook does not invoke the wrapper" - - pi_plugin=$(cat "$ROOT/.pi/extensions/fm-primary-turnend-guard.ts") - assert_contains "$pi_plugin" '["startup", "new", "resume"]' "Pi SessionStart handler has the wrong reason allowlist" - assert_contains "$pi_plugin" 'fm-sessionstart-nudge.sh' "Pi SessionStart handler does not invoke the wrapper" - assert_contains "$pi_plugin" 'firstmate-sessionstart-nudge' "Pi SessionStart handler does not inject a custom context message" - assert_contains "$pi_plugin" 'details: { kind: "session-start" }' "Pi SessionStart context does not retain its exact structured kind" - assert_contains "$pi_plugin" 'pi.sendMessage' "Pi SessionStart handler does not use the context-safe message API" - - opencode_plugin=$(cat "$ROOT/.opencode/plugins/fm-primary-sessionstart-nudge.js") - assert_contains "$opencode_plugin" 'session.created' "OpenCode plugin does not listen for session.created" - assert_contains "$opencode_plugin" 'fm-sessionstart-nudge.sh' "OpenCode plugin does not invoke the wrapper" - assert_contains "$opencode_plugin" 'promptAsync' "OpenCode plugin does not prompt the nudge turn" - - pass "all five verified harnesses register the shared session-start nudge" -} - test_genuine_primary_nudges test_gate_env_is_silent test_gate_common_dir_is_silent @@ -194,4 +156,3 @@ test_linked_secondmate_primary_nudges test_missing_state_is_silent test_owned_lock_is_silent test_opencode_plugin_delivers_exact_nudge_once -test_tracked_harness_registration diff --git a/tests/fm-spawn-dispatch-profile.test.sh b/tests/fm-spawn-dispatch-profile.test.sh index 3a8dfb3a4e..b19d25379f 100755 --- a/tests/fm-spawn-dispatch-profile.test.sh +++ b/tests/fm-spawn-dispatch-profile.test.sh @@ -84,10 +84,15 @@ run_spawn() { local home=$1 wt=$2 fakebin=$3 launchlog=$4 shift 4 : > "$launchlog" + # CLAUDE_CONFIG_DIR is forwarded onto claude launches by fm-spawn, so pin it + # explicitly (empty by default) instead of leaking the invoking shell's value, + # which would make launch assertions depend on the developer's environment. + # A test opts in to the set case via FM_TEST_CLAUDE_CONFIG_DIR. FM_ROOT_OVERRIDE='' FM_HOME="$home" \ FM_STATE_OVERRIDE="$home/state" FM_DATA_OVERRIDE="$home/data" \ FM_PROJECTS_OVERRIDE="$home/projects" FM_CONFIG_OVERRIDE="$home/config" \ FM_SPAWN_NO_GUARD=1 FM_FAKE_PANE_PATH="$wt" TMUX="fake,1,0" \ + CLAUDE_CONFIG_DIR="${FM_TEST_CLAUDE_CONFIG_DIR:-}" \ FM_FAKE_LAUNCH_LOG="$launchlog" GROK_HOME="$home/grok-home" PATH="$fakebin:$PATH" \ "$SPAWN" "$@" 2>&1 } @@ -123,6 +128,153 @@ test_no_profile_keeps_claude_profile_defaults() { pass "no --model/--effort records defaults and types the claude launch instructions" } +test_relative_home_overrides_launch_with_absolute_cross_process_paths() { + local rec id out status launch home_real + id=profile-relative-paths-z1b + rec=$(make_spawn_case profile-relative-paths pi "$id") + read_case_record "$rec" + home_real=$(cd "$HOME_DIR" && pwd -P) + mkdir -p "$CASE_DIR/cdpath/home/state" "$CASE_DIR/cdpath/home/data" + : > "$LAUNCH_LOG" + + out=$( + cd "$CASE_DIR" || exit 1 + CDPATH="$CASE_DIR/cdpath" FM_ROOT_OVERRIDE='' FM_HOME=home \ + FM_STATE_OVERRIDE=home/state FM_DATA_OVERRIDE=home/data \ + FM_PROJECTS_OVERRIDE=home/projects FM_CONFIG_OVERRIDE=home/config \ + FM_SPAWN_NO_GUARD=1 FM_FAKE_PANE_PATH="$WT_DIR" TMUX="fake,1,0" \ + CLAUDE_CONFIG_DIR='' FM_FAKE_LAUNCH_LOG="$LAUNCH_LOG" \ + GROK_HOME=home/grok-home PATH="$FAKEBIN_DIR:$PATH" \ + "$SPAWN" "$id" "$PROJ_DIR" 2>&1 + ) + status=$? + expect_code 0 "$status" "spawn with relative home overrides should succeed" + launch=$(cat "$LAUNCH_LOG") + assert_contains "$launch" "-e '$home_real/state/$id.pi-ext.ts'" \ + "relative FM_STATE_OVERRIDE leaked into Pi's cross-process extension path" + assert_contains "$launch" "< '$home_real/data/$id/brief.md'" \ + "relative FM_DATA_OVERRIDE leaked into the cross-process brief path" + pass "relative home overrides ignore CDPATH and become absolute before spawn launch construction" +} + +test_home_defaults_preserve_absolute_or_resolve_relative_paths() { + local rec relative_id absolute_id out status launch home_real linked_home + relative_id=profile-relative-home-defaults-z1c + absolute_id=profile-absolute-home-defaults-z1d + rec=$(make_spawn_case profile-home-defaults pi "$relative_id" "$absolute_id") + read_case_record "$rec" + home_real=$(cd "$HOME_DIR" && pwd -P) + + : > "$LAUNCH_LOG" + out=$( + cd "$CASE_DIR" || exit 1 + FM_ROOT_OVERRIDE='' FM_HOME=home \ + FM_STATE_OVERRIDE='' FM_DATA_OVERRIDE='' \ + FM_PROJECTS_OVERRIDE=home/projects FM_CONFIG_OVERRIDE=home/config \ + FM_SPAWN_NO_GUARD=1 FM_FAKE_PANE_PATH="$WT_DIR" TMUX="fake,1,0" \ + CLAUDE_CONFIG_DIR='' FM_FAKE_LAUNCH_LOG="$LAUNCH_LOG" \ + GROK_HOME=home/grok-home PATH="$FAKEBIN_DIR:$PATH" \ + "$SPAWN" "$relative_id" "$PROJ_DIR" 2>&1 + ) + status=$? + expect_code 0 "$status" "spawn with relative FM_HOME defaults should succeed" + launch=$(cat "$LAUNCH_LOG") + assert_contains "$launch" "-e '$home_real/state/$relative_id.pi-ext.ts'" \ + "relative FM_HOME leaked into Pi's default cross-process extension path" + assert_contains "$launch" "< '$home_real/data/$relative_id/brief.md'" \ + "relative FM_HOME leaked into the default cross-process brief path" + + linked_home="$CASE_DIR/home-link" + ln -s "$HOME_DIR" "$linked_home" + : > "$LAUNCH_LOG" + out=$( + FM_ROOT_OVERRIDE='' FM_HOME="$linked_home" \ + FM_STATE_OVERRIDE='' FM_DATA_OVERRIDE='' \ + FM_PROJECTS_OVERRIDE="$linked_home/projects" FM_CONFIG_OVERRIDE="$linked_home/config" \ + FM_SPAWN_NO_GUARD=1 FM_FAKE_PANE_PATH="$WT_DIR" TMUX="fake,1,0" \ + CLAUDE_CONFIG_DIR='' FM_FAKE_LAUNCH_LOG="$LAUNCH_LOG" \ + GROK_HOME="$linked_home/grok-home" PATH="$FAKEBIN_DIR:$PATH" \ + "$SPAWN" "$absolute_id" "$PROJ_DIR" 2>&1 + ) + status=$? + expect_code 0 "$status" "spawn with absolute symlink-spelled FM_HOME defaults should succeed" + launch=$(cat "$LAUNCH_LOG") + assert_contains "$launch" "-e '$linked_home/state/$absolute_id.pi-ext.ts'" \ + "absolute FM_HOME spelling changed in Pi's default cross-process extension path" + assert_contains "$launch" "< '$linked_home/data/$absolute_id/brief.md'" \ + "absolute FM_HOME spelling changed in the default cross-process brief path" + pass "FM_HOME defaults resolve relative paths and preserve absolute spellings" +} + +test_absolute_override_spelling_is_preserved_in_launch_paths() { + local rec id out status launch linked_home + id=profile-absolute-paths-z1c + rec=$(make_spawn_case profile-absolute-paths pi "$id") + read_case_record "$rec" + linked_home="$CASE_DIR/home-link" + ln -s "$HOME_DIR" "$linked_home" + : > "$LAUNCH_LOG" + + out=$( + FM_ROOT_OVERRIDE='' FM_HOME="$linked_home" \ + FM_STATE_OVERRIDE="$linked_home/state" FM_DATA_OVERRIDE="$linked_home/data" \ + FM_PROJECTS_OVERRIDE="$linked_home/projects" FM_CONFIG_OVERRIDE="$linked_home/config" \ + FM_SPAWN_NO_GUARD=1 FM_FAKE_PANE_PATH="$WT_DIR" TMUX="fake,1,0" \ + CLAUDE_CONFIG_DIR='' FM_FAKE_LAUNCH_LOG="$LAUNCH_LOG" \ + GROK_HOME="$linked_home/grok-home" PATH="$FAKEBIN_DIR:$PATH" \ + "$SPAWN" "$id" "$PROJ_DIR" 2>&1 + ) + status=$? + expect_code 0 "$status" "spawn with absolute symlink-spelled overrides should succeed" + launch=$(cat "$LAUNCH_LOG") + assert_contains "$launch" "-e '$linked_home/state/$id.pi-ext.ts'" \ + "absolute FM_STATE_OVERRIDE spelling changed in Pi's cross-process extension path" + assert_contains "$launch" "< '$linked_home/data/$id/brief.md'" \ + "absolute FM_DATA_OVERRIDE spelling changed in the cross-process brief path" + pass "absolute override spellings are preserved in spawn launch paths" +} + +test_unresolvable_relative_overrides_fail_loudly() { + local rec id out status + id=profile-unresolvable-paths-z1d + rec=$(make_spawn_case profile-unresolvable-paths pi "$id") + read_case_record "$rec" + + out=$( + cd "$CASE_DIR" || exit 1 + FM_ROOT_OVERRIDE='' FM_HOME=missing-home \ + FM_STATE_OVERRIDE='' FM_DATA_OVERRIDE='' \ + "$SPAWN" "$id" "$PROJ_DIR" 2>&1 + ) + status=$? + expect_code 1 "$status" "spawn with an unresolvable relative home should fail" + assert_contains "$out" "FM_HOME directory cannot be resolved: missing-home" \ + "spawn did not name the unresolvable FM_HOME" + + out=$( + cd "$CASE_DIR" || exit 1 + FM_ROOT_OVERRIDE='' FM_HOME=home \ + FM_STATE_OVERRIDE=missing-state FM_DATA_OVERRIDE=home/data \ + "$SPAWN" "$id" "$PROJ_DIR" 2>&1 + ) + status=$? + expect_code 1 "$status" "spawn with an unresolvable relative state override should fail" + assert_contains "$out" "FM_STATE_OVERRIDE directory cannot be resolved: missing-state" \ + "spawn did not name the unresolvable FM_STATE_OVERRIDE" + + out=$( + cd "$CASE_DIR" || exit 1 + FM_ROOT_OVERRIDE='' FM_HOME=home \ + FM_STATE_OVERRIDE=home/state FM_DATA_OVERRIDE=missing-data \ + "$SPAWN" "$id" "$PROJ_DIR" 2>&1 + ) + status=$? + expect_code 1 "$status" "spawn with an unresolvable relative data override should fail" + assert_contains "$out" "FM_DATA_OVERRIDE directory cannot be resolved: missing-data" \ + "spawn did not name the unresolvable FM_DATA_OVERRIDE" + pass "unresolvable relative spawn overrides fail with named diagnostics" +} + test_active_dispatch_profile_requires_explicit_harness_for_ship() { local rec id out status id=profile-required-ship-z11 @@ -369,6 +521,18 @@ test_pi_signed_threads_shared_pi_profile_and_preserves_identity() { assert_contains "$launch" "fm-operational-input.sh' encode launch-brief" \ "pi-signed launch lost the canonical typed launch-brief envelope" assert_present "$HOME_DIR/state/$id.pi-ext.ts" "pi-signed launch did not install Pi's turn-end extension" + assert_present "$HOME_DIR/state/$id.busy-gen" "pi-signed spawn did not arm the busy-state contract" + assert_contains "$(cat "$HOME_DIR/state/$id.busy-state")" "state=busy source=fm-spawn" \ + "pi-signed spawn did not seed the busy-state record from the launch brief" + local ext gen + ext=$(cat "$HOME_DIR/state/$id.pi-ext.ts") + gen=$(cat "$HOME_DIR/state/$id.busy-gen") + assert_contains "$ext" 'pi.on("agent_start"' "pi extension lost the semantic agent_start busy edge" + assert_contains "$ext" 'pi.on("agent_settled"' "pi extension lost the semantic agent_settled idle edge" + assert_contains "$ext" 'ctx.isIdle()' "pi extension no longer confirms idle with ctx.isIdle()" + assert_contains "$ext" "\"--gen\", \"$gen\"" "pi extension does not carry the armed incarnation gen" + assert_contains "$ext" '"--source", "pi-ext"' "pi extension does not attribute its semantic source" + assert_contains "$ext" 'pi.on("turn_end"' "pi extension lost the turn-end notification touch" pass "pi-signed shares Pi launch semantics while preserving its configured and recorded identity" } @@ -436,6 +600,55 @@ test_batch_forwards_shared_profile_flags() { pass "batch dispatch forwards shared --harness, --model, and --effort to every pair" } +test_claude_forwards_firstmate_config_dir_when_set() { + local rec id out status launch + id=profile-claude-cfgdir-z17 + rec=$(make_spawn_case profile-claude-cfgdir claude "$id") + read_case_record "$rec" + + out=$(FM_TEST_CLAUDE_CONFIG_DIR="/opt/test/claude-work" \ + run_spawn "$HOME_DIR" "$WT_DIR" "$FAKEBIN_DIR" "$LAUNCH_LOG" "$id" "$PROJ_DIR") + status=$? + expect_code 0 "$status" "claude spawn with CLAUDE_CONFIG_DIR set should succeed" + launch=$(cat "$LAUNCH_LOG") + assert_contains "$launch" "CLAUDE_CONFIG_DIR='/opt/test/claude-work' CLAUDE_CODE_ENABLE_PROMPT_SUGGESTION=false claude" \ + "claude launch did not forward firstmate's CLAUDE_CONFIG_DIR to the crewmate pane" + pass "claude forwards firstmate's CLAUDE_CONFIG_DIR so the crewmate uses the same credential store" +} + +test_claude_omits_config_dir_prefix_when_unset() { + local rec id out status launch + id=profile-claude-nocfgdir-z18 + rec=$(make_spawn_case profile-claude-nocfgdir claude "$id") + read_case_record "$rec" + + # run_spawn pins CLAUDE_CONFIG_DIR empty by default, exercising the single-store + # default path where fm-spawn adds no prefix. + out=$(run_spawn "$HOME_DIR" "$WT_DIR" "$FAKEBIN_DIR" "$LAUNCH_LOG" "$id" "$PROJ_DIR") + status=$? + expect_code 0 "$status" "claude spawn without CLAUDE_CONFIG_DIR should succeed" + launch=$(cat "$LAUNCH_LOG") + assert_not_contains "$launch" "CLAUDE_CONFIG_DIR=" \ + "claude launch must not add a config-dir prefix when firstmate has no CLAUDE_CONFIG_DIR set" + pass "claude omits the config-dir prefix when firstmate runs with the single-store default" +} + +test_non_claude_harness_ignores_config_dir() { + local rec id out status launch + id=profile-codex-nocfgdir-z19 + rec=$(make_spawn_case profile-codex-nocfgdir codex "$id") + read_case_record "$rec" + + out=$(FM_TEST_CLAUDE_CONFIG_DIR="/opt/test/claude-work" \ + run_spawn "$HOME_DIR" "$WT_DIR" "$FAKEBIN_DIR" "$LAUNCH_LOG" "$id" "$PROJ_DIR") + status=$? + expect_code 0 "$status" "codex spawn with CLAUDE_CONFIG_DIR set should succeed" + launch=$(cat "$LAUNCH_LOG") + assert_not_contains "$launch" "CLAUDE_CONFIG_DIR=" \ + "non-claude harness launch must not receive the claude-specific config-dir prefix" + pass "non-claude harnesses do not receive the claude CLAUDE_CONFIG_DIR prefix" +} + test_active_dispatch_profile_does_not_block_secondmate_launch() { local rec id sm out status id=profile-secondmate-z16 @@ -455,6 +668,10 @@ test_active_dispatch_profile_does_not_block_secondmate_launch() { } test_no_profile_keeps_claude_profile_defaults +test_relative_home_overrides_launch_with_absolute_cross_process_paths +test_home_defaults_preserve_absolute_or_resolve_relative_paths +test_absolute_override_spelling_is_preserved_in_launch_paths +test_unresolvable_relative_overrides_fail_loudly test_active_dispatch_profile_requires_explicit_harness_for_ship test_active_dispatch_profile_requires_explicit_harness_for_scout test_active_dispatch_profile_allows_explicit_harness @@ -472,6 +689,9 @@ test_pi_signed_threads_shared_pi_profile_and_preserves_identity test_pi_signed_missing_binary_refuses_before_endpoint_or_metadata test_pi_signed_persistent_secondmate_uses_pi_extensions_and_identity test_batch_forwards_shared_profile_flags +test_claude_forwards_firstmate_config_dir_when_set +test_claude_omits_config_dir_prefix_when_unset +test_non_claude_harness_ignores_config_dir test_active_dispatch_profile_does_not_block_secondmate_launch echo "# all fm-spawn-dispatch-profile tests passed" diff --git a/tests/fm-startup-memory-budget.test.sh b/tests/fm-startup-memory-budget.test.sh new file mode 100755 index 0000000000..59eeb0649b --- /dev/null +++ b/tests/fm-startup-memory-budget.test.sh @@ -0,0 +1,317 @@ +#!/usr/bin/env bash +# Behavioral coverage for the visible startup-memory budget, its safe parser, +# accounting command, primary-to-secondmate convergence, and exact reread bytes. +set -u + +# shellcheck source=tests/lib.sh +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" + +BASE_PATH=${FM_TEST_BASE_PATH:-/usr/bin:/bin:/usr/sbin:/sbin} +TMP_ROOT=$(fm_test_tmproot fm-startup-memory-budget) +BUDGET="$ROOT/bin/fm-startup-memory-budget.sh" +BOOTSTRAP="$ROOT/bin/fm-bootstrap.sh" +CONFIG_PUSH="$ROOT/bin/fm-config-push.sh" + +make_fake_toolchain() { + local dir=$1 fakebin + fakebin=$(fm_fakebin "$dir") + fm_fake_exit0 "$fakebin" node gh-axi chrome-devtools-axi lavish-axi quota-axi + cat > "$fakebin/quota-axi" <<'SH' +#!/usr/bin/env bash +if [ "${1:-}" = --version ]; then + printf '%s\n' 'quota-axi 0.1.16 (fake)' +fi +exit 0 +SH + cat > "$fakebin/gh" <<'SH' +#!/usr/bin/env bash +exit 0 +SH + cat > "$fakebin/treehouse" <<'SH' +#!/usr/bin/env bash +if [ "${1:-}" = get ] && [ "${2:-}" = --help ]; then + printf '%s\n' 'Usage: treehouse get [--lease]' +fi +SH + cat > "$fakebin/no-mistakes" <<'SH' +#!/usr/bin/env bash +if [ "${1:-}" = --version ]; then + printf '%s\n' 'no-mistakes version v1.31.2 (fake)' +fi +SH + cat > "$fakebin/tasks-axi" <<'SH' +#!/usr/bin/env bash +case "${1:-}:${2:-}" in + --version:*) printf '%s\n' '0.2.3' ;; + update:--help) printf '%s\n' '--archive-body' ;; + mv:--help) printf '%s\n' 'usage: tasks-axi mv <id> [<id>...]' ;; +esac +SH + cat > "$fakebin/tmux" <<'SH' +#!/usr/bin/env bash +[ -z "${FM_FAKE_TMUX_LOG:-}" ] || printf '%s\n' "$*" >> "$FM_FAKE_TMUX_LOG" +case "$*" in + *display-message*'#{pane_current_command}'*) printf '%s\n' codex ;; + *display-message*'#{pane_id}'*) printf '%s\n' '%1' ;; + *display-message*'#{cursor_y}'*) printf '%s\n' 0 ;; + *capture-pane*) printf '\n' ;; +esac +exit 0 +SH + chmod +x "$fakebin"/* + printf '%s\n' "$fakebin" +} + +new_bootstrap_world() { + local name=$1 world root home + world="$TMP_ROOT/$name" + root="$world/root" + home="$world/home" + mkdir -p "$home/config" "$home/data" "$home/state" "$root/bin" + git init -q -b main "$root" + printf '%s\n' 'config/' > "$root/.gitignore" + printf '%s\n' '# Firstmate test root' > "$root/AGENTS.md" + printf '%s\n' '#!/usr/bin/env bash' 'exit 0' > "$root/bin/placeholder.sh" + chmod +x "$root/bin/placeholder.sh" + git -C "$root" add -A + git -C "$root" -c user.name=fmtest -c user.email=fmtest@example.invalid commit -qm initial + printf '%s|%s\n' "$root" "$home" +} + +run_bootstrap() { + local root=$1 home=$2 fakebin=$3 + PATH="$fakebin:$BASE_PATH" FM_BACKEND=tmux FM_HOME="$home" FM_ROOT_OVERRIDE="$root" \ + "$BOOTSTRAP" +} + +test_primary_bootstrap_materializes_visible_default() { + local rec root home fakebin out second + rec=$(new_bootstrap_world materialize) + root=${rec%%|*} + home=${rec#*|} + fakebin=$(make_fake_toolchain "$TMP_ROOT/materialize") + + out=$(run_bootstrap "$root" "$home" "$fakebin") + [ -z "$out" ] || fail "default materialization should stay quiet, got: $out" + [ "$(<"$home/config/startup-memory-budget")" = 7500 ] \ + || fail "bootstrap did not materialize the visible 7500 default" + [ "$(FM_HOME="$home" "$BUDGET" read)" = 7500 ] \ + || fail "read command did not expose the generated default" + + printf '321\n' > "$home/config/startup-memory-budget" + run_bootstrap "$root" "$home" "$fakebin" >/dev/null + [ "$(<"$home/config/startup-memory-budget")" = 321 ] \ + || fail "bootstrap replaced a valid captain-selected budget" + + second="$TMP_ROOT/materialize/secondmate" + mkdir -p "$second/config" "$second/data" "$second/state" + printf '%s\n' sm > "$second/.fm-secondmate-home" + run_bootstrap "$root" "$second" "$fakebin" >/dev/null + [ ! -e "$second/config/startup-memory-budget" ] \ + || fail "secondmate bootstrap created an independent budget instead of awaiting inheritance" + pass "primary bootstrap materializes only the visible default and preserves valid captain choices" +} + +expect_rejected_read() { + local home=$1 expected=$2 out rc + set +e + out=$(FM_HOME="$home" "$BUDGET" read 2>&1) + rc=$? + set -e + [ "$rc" -ne 0 ] || fail "unsafe budget unexpectedly parsed: $expected" + assert_contains "$out" "$expected" "unsafe budget rejection was not specific" +} + +test_safe_parser_rejects_ambiguous_and_unsafe_values() { + local home outside + home="$TMP_ROOT/parser-home" + mkdir -p "$home/config" "$home/data" + printf '42\n' > "$home/config/startup-memory-budget" + [ "$(FM_HOME="$home" "$BUDGET" read)" = 42 ] || fail "valid positive decimal budget was rejected" + + printf '0\n' > "$home/config/startup-memory-budget" + expect_rejected_read "$home" 'value must be one positive decimal integer' + printf '42\nextra\n' > "$home/config/startup-memory-budget" + expect_rejected_read "$home" 'value must be one positive decimal integer' + printf '+42\n' > "$home/config/startup-memory-budget" + expect_rejected_read "$home" 'value must be one positive decimal integer' + + outside="$TMP_ROOT/parser-outside" + printf '77\n' > "$outside" + rm -f "$home/config/startup-memory-budget" + ln -s "$outside" "$home/config/startup-memory-budget" + expect_rejected_read "$home" 'file is symlinked' + [ "$(<"$outside")" = 77 ] || fail "symlink rejection changed its external target" + + rm -f "$home/config/startup-memory-budget" + ln "$outside" "$home/config/startup-memory-budget" + expect_rejected_read "$home" 'file is hardlinked' + [ "$(<"$outside")" = 77 ] || fail "hardlink rejection changed its external source" + + rm -f "$home/config/startup-memory-budget" + rm -rf "$home/config" + ln -s "$TMP_ROOT/parser-config-target" "$home/config" + mkdir -p "$TMP_ROOT/parser-config-target" + printf '88\n' > "$TMP_ROOT/parser-config-target/startup-memory-budget" + expect_rejected_read "$home" 'config directory is symlinked' + pass "budget parser accepts one exact positive value and rejects malformed or unsafe inputs" +} + +test_budget_accounting_reports_all_three_files_and_safe_failure() { + local home out rc outside + home="$TMP_ROOT/accounting-home" + mkdir -p "$home/config" "$home/data" + printf '10\n' > "$home/config/startup-memory-budget" + printf 'abc\n' > "$home/data/captain.md" + printf 'abcdef\n' > "$home/data/captain-shared.md" + + out=$(FM_HOME="$home" "$BUDGET" report) + assert_contains "$out" 'estimator=ceil(UTF-8 bytes / 3) conservative-local-estimate' \ + "report did not name the stable estimator" + assert_contains "$out" 'file=data/captain.md bytes=4 estimated_tokens=2 status=present' \ + "report did not account for captain memory" + assert_contains "$out" 'file=data/captain-shared.md bytes=7 estimated_tokens=3 status=present' \ + "report did not account for shared memory" + assert_contains "$out" 'file=data/learnings.md bytes=0 estimated_tokens=0 status=absent' \ + "report did not account for absent learnings" + assert_contains "$out" 'total_estimated_tokens=5' "report total was not the sum of all three files" + assert_contains "$out" 'budget_status=within-budget' "report did not classify the initial total" + + printf 'abcdefabcdefabcdefabcdef\n' > "$home/data/learnings.md" + out=$(FM_HOME="$home" "$BUDGET" report) + assert_contains "$out" 'budget_status=over-budget' "report did not surface an over-budget total" + + outside="$TMP_ROOT/accounting-outside" + printf 'outside\n' > "$outside" + rm -f "$home/data/captain.md" + ln -s "$outside" "$home/data/captain.md" + set +e + out=$(FM_HOME="$home" "$BUDGET" report 2>&1) + rc=$? + set -e + expect_code 2 "$rc" "unsafe memory input should fail the accounting command" + assert_contains "$out" 'memory file is not an ordinary regular file' \ + "accounting failure did not identify the unsafe memory file" + [ "$(<"$outside")" = outside ] || fail "accounting failure changed a symlink target" + pass "budget accounting sums the three startup files and reports safe failures" +} + +new_propagation_world() { + local world=$1 root="$1/root" home="$1/home" sm="$1/sm" head + mkdir -p "$home/config" "$home/data" "$home/state" "$root/bin" + touch "$home/state/.last-watcher-beat" + git init -q -b main "$root" + printf '%s\n' 'config/' > "$root/.gitignore" + printf '%s\n' '# Firstmate test root' > "$root/AGENTS.md" + printf '%s\n' '#!/usr/bin/env bash' 'exit 0' > "$root/bin/placeholder.sh" + chmod +x "$root/bin/placeholder.sh" + git -C "$root" add -A + git -C "$root" -c user.name=fmtest -c user.email=fmtest@example.invalid commit -qm initial + head=$(git -C "$root" rev-parse HEAD) + git -C "$root" worktree add -q --detach "$sm" "$head" + printf '%s\n' sm > "$sm/.fm-secondmate-home" + mkdir -p "$sm/config" "$sm/data" "$sm/state" "$sm/projects" + { + printf 'window=firstmate:fm-sm\n' + printf 'kind=secondmate\n' + printf 'harness=codex\n' + printf 'home=%s\n' "$sm" + } > "$home/state/sm.meta" + printf '%s|%s|%s\n' "$root" "$home" "$sm" +} + +latest_reread_instruction() { + local home=$1 state path latest= + state=$(cd "$home/state" && pwd -P) || return 1 + for path in "$state"/.fm-inherited-config-reread.*; do + case "$path" in *.pending) continue ;; esac + [ -f "$path" ] && [ ! -L "$path" ] || continue + latest=$path + done + [ -n "$latest" ] || return 1 + printf '%s\n' "$latest" +} + +run_config_push() { + local root=$1 home=$2 fakebin=$3 log=$4 + PATH="$fakebin:$BASE_PATH" FM_HOME="$home" FM_ROOT_OVERRIDE="$root" FM_SEND_SETTLE=0 \ + FM_FAKE_TMUX_LOG="$log" "$CONFIG_PUSH" +} + +test_primary_budget_converges_with_exact_reread_and_safe_failures() { + local world="$TMP_ROOT/propagation" rec root home sm fakebin log out rc instruction expected outside + mkdir -p "$world" + rec=$(new_propagation_world "$world") + root=${rec%%|*} + rec=${rec#*|} + home=${rec%%|*} + sm=${rec#*|} + fakebin=$(make_fake_toolchain "$world") + log="$world/tmux.log" + + printf '321\n' > "$home/config/startup-memory-budget" + out=$(run_config_push "$root" "$home" "$fakebin" "$log") + assert_contains "$out" 'startup-memory-budget: pushed' \ + "config push did not report the new budget as inherited" + [ "$(<"$sm/config/startup-memory-budget")" = 321 ] \ + || fail "secondmate did not receive the primary budget bytes" + instruction=$(latest_reread_instruction "$sm") || fail "budget propagation did not publish a reread instruction" + expected=$(printf '%s\n\n%s\n%s\n321\n%s' \ + 'These inherited config files changed. Re-read and apply their exact contents at every future intake. They are defaults/rules and do not remove your judgment to choose differently when warranted.' \ + 'config/startup-memory-budget' \ + '-----BEGIN config/startup-memory-budget-----' \ + '-----END config/startup-memory-budget-----') + [ "$(<"$instruction")" = "$expected" ] \ + || fail "budget reread payload was not the exact destination bytes" + assert_contains "$(<"$log")" "CONFIG_REREAD: $instruction" \ + "budget propagation did not send the pointer to its exact reread generation" + + outside="$world/unsafe-budget" + printf '555\n' > "$outside" + rm -f "$sm/config/startup-memory-budget" + ln "$outside" "$sm/config/startup-memory-budget" + set +e + out=$(run_config_push "$root" "$home" "$fakebin" "$log" 2>&1) + rc=$? + set -e + expect_code 1 "$rc" "unsafe inherited destination should stop propagation" + assert_contains "$out" 'startup-memory-budget: error - unsafe or invalid destination: file is hardlinked' \ + "unsafe inherited destination did not produce a concrete propagation error" + [ "$(<"$outside")" = 555 ] || fail "unsafe destination handling changed its hardlinked source" + rm -f "$sm/config/startup-memory-budget" + run_config_push "$root" "$home" "$fakebin" "$log" >/dev/null + [ "$(<"$sm/config/startup-memory-budget")" = 321 ] \ + || fail "safe retry did not restore the converged primary budget" + + rm -f "$home/config/startup-memory-budget" + out=$(run_config_push "$root" "$home" "$fakebin" "$log") + assert_contains "$out" 'startup-memory-budget: pushed - mirrored primary absence' \ + "primary absence was not reported as a converging removal" + [ ! -e "$sm/config/startup-memory-budget" ] \ + || fail "primary absence did not remove the inherited budget" + instruction=$(latest_reread_instruction "$sm") || fail "budget absence did not publish a reread instruction" + assert_contains "$(<"$instruction")" $'-----BEGIN config/startup-memory-budget-----\nABSENT\n-----END config/startup-memory-budget-----' \ + "budget absence reread did not use the explicit ABSENT payload" + + rm -f "$sm/config/startup-memory-budget" + printf '555\n' > "$outside" + ln -s "$outside" "$home/config/startup-memory-budget" + set +e + out=$(run_config_push "$root" "$home" "$fakebin" "$log" 2>&1) + rc=$? + set -e + expect_code 1 "$rc" "unsafe primary budget should stop propagation" + assert_contains "$out" 'startup-memory-budget: error - unsafe or invalid primary source: file is symlinked' \ + "unsafe primary budget did not produce a concrete propagation error" + [ ! -e "$sm/config/startup-memory-budget" ] \ + || fail "unsafe primary budget changed the converged secondmate copy" + [ "$(<"$outside")" = 555 ] || fail "unsafe primary budget handling changed its symlink target" + pass "budget propagation converges through config push with exact rereads, absence, and safe rejection" +} + +test_primary_bootstrap_materializes_visible_default +test_safe_parser_rejects_ambiguous_and_unsafe_values +test_budget_accounting_reports_all_three_files_and_safe_failure +test_primary_budget_converges_with_exact_reread_and_safe_failures + +echo '# all fm-startup-memory-budget tests passed' diff --git a/tests/fm-stow-contract.test.sh b/tests/fm-stow-contract.test.sh deleted file mode 100755 index c43f47c4db..0000000000 --- a/tests/fm-stow-contract.test.sh +++ /dev/null @@ -1,37 +0,0 @@ -#!/usr/bin/env bash -# Behavior tests for /stow's inspect-then-update memory contract. -set -u - -# shellcheck source=tests/lib.sh disable=SC1091 -. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" - -test_stow_skill_task_note_contract() { - local stow="$ROOT/.agents/skills/stow/SKILL.md" - - assert_grep 'tasks-axi show <id> --full' "$stow" "stow skill does not require inspecting task notes first" - assert_grep 'tasks-axi update <id> --body-file <path>' "$stow" "stow skill does not require task body replacement" - assert_grep '--archive-body' "$stow" "stow skill does not document recoverable task body archival" - assert_grep 'Never append.' "$stow" "stow skill does not forbid append-first task notes" - assert_no_grep 'carry that context into the replacement body' "$stow" "stow skill still preserves archive-only context in the replacement body" - pass "stow skill task-note contract includes recoverable body archival" -} - -test_agents_backlog_task_note_contract() { - local agents="$ROOT/AGENTS.md" - - # shellcheck disable=SC2016 # Literal backticks must remain unexpanded. - assert_grep 'current `tasks-axi --help` own the backlog schema' "$agents" \ - "AGENTS.md does not point exact task-note mechanics to the command owner" - assert_grep 'Inspect the current task note before replacing its considered body' "$agents" \ - "AGENTS.md does not require inspecting task notes before replacement" - assert_grep 'archive the superseded body when recoverability matters rather than appending by default' "$agents" \ - "AGENTS.md lost recoverable replacement and no-append semantics" - assert_no_grep 'tasks-axi show <id> --full' "$agents" \ - "AGENTS.md duplicates exact task-note read syntax from its conditional owner" - assert_no_grep 'tasks-axi update <id> --body-file <path>' "$agents" \ - "AGENTS.md duplicates exact task-note update syntax from its conditional owner" - pass "AGENTS.md keeps task-note hygiene inline and points exact mechanics to their owner" -} - -test_stow_skill_task_note_contract -test_agents_backlog_task_note_contract diff --git a/tests/fm-subagent-pretool-check.test.sh b/tests/fm-subagent-pretool-check.test.sh index 6b4868b1d6..c1a2115897 100755 --- a/tests/fm-subagent-pretool-check.test.sh +++ b/tests/fm-subagent-pretool-check.test.sh @@ -7,7 +7,6 @@ set -u . "$(dirname "${BASH_SOURCE[0]}")/lib.sh" CHECK="$ROOT/bin/fm-subagent-pretool-check.sh" -SETTINGS="$ROOT/.claude/settings.json" TMP_ROOT=$(fm_test_tmproot fm-subagent-pretool-tests) PRIMARY="$TMP_ROOT/primary" STATE="$PRIMARY/state" @@ -31,6 +30,17 @@ DELEGATION_TOOLS='Task Agent Workflow RemoteTrigger Monitor ScheduleWakeup SendM # Tools that must stay available: denying these would break ordinary work. PRESERVED_TOOLS='Bash Edit Read Write Skill ToolSearch WebFetch WebSearch NotebookEdit ReportFindings DesignSync PushNotification' +# Session-local todo-list tools. They match a delegation stem but create no +# runnable work, so the guard's plan-only exclusion must allow them. +PLAN_ONLY_TOOLS='TaskCreate TaskUpdate' + +# Names the plan-only exclusion must NOT release. Five of them contain a +# plan-only name as a substring and would be let through by a substring rather +# than exact-name match; bare Task is what a shortened entry of "task" would +# release. Together they make the exact-name contract testable instead of +# assumed. +PLAN_ONLY_NEAR_MISSES='TaskCreateAgent TaskCreateWorktree TaskUpdateAgent RemoteTaskCreate Task TaskCreator' + run_tool() { local tool=$1 rc=0 shift @@ -62,20 +72,15 @@ expect_deny() { } # --------------------------------------------------------------------------- -# Tracked settings boundary and delegation-shape PreToolUse guard. +# Delegation-shape PreToolUse guard. # --------------------------------------------------------------------------- -test_tracked_settings_do_not_ship_permissions_deny() { - jq -e 'keys == ["hooks"] and (has("permissions") | not)' "$SETTINGS" >/dev/null \ - || fail "tracked Claude settings must contain only hooks and no permissions key" - pass "tracked Claude settings do not ship permissions.deny" -} - test_guard_denies_every_currently_known_delegation_tool() { local tool for tool in $DELEGATION_TOOLS; do case "$tool" in TaskOutput|TaskStop|TaskGet|TaskList|CronList) continue ;; + TaskCreate|TaskUpdate) continue ;; esac expect_deny "known delegation tool" "$tool" done @@ -107,6 +112,28 @@ test_guard_allows_ordinary_and_observe_only_tools() { pass "the guard leaves ordinary tools and observe-or-stop operations alone" } +test_guard_allows_session_local_todo_tools() { + # These write, so they are not observe-or-stop, but what they write is the + # harness's session-local todo list: no executor, no agent, no worktree, no + # schedule, nothing that outlives the session. Denying them stops the primary + # tracking its own plan and grants no delegation power in exchange. + local tool + for tool in $PLAN_ONLY_TOOLS; do + expect_allow "session-local todo tool" "$tool" + done + pass "the guard leaves the session-local todo list alone" +} + +test_plan_only_exclusion_is_exact_name() { + # The plan-only exclusion must never widen by substring or by a shorter stem. + # Every name here would be released by such a widening and must stay denied. + local tool + for tool in $PLAN_ONLY_NEAR_MISSES; do + expect_deny "plan-only near miss" "$tool" + done + pass "the plan-only exclusion releases exactly two names and nothing that merely contains them" +} + test_guard_never_classifies_mcp_tools() { # An MCP server names its own tools; a task or agent noun there is common and # has nothing to do with fleet dispatch. @@ -249,34 +276,11 @@ test_missing_jq_stdin_transport_fails_open() { pass "missing jq for stdin transport fails open rather than denying every tool call" } -test_claude_hook_registration_preserves_bash_seatbelts() { - jq -e ' - [.hooks.PreToolUse[] | .hooks[].command] - | any(contains("fm-subagent-pretool-check.sh --claude")) - ' "$SETTINGS" >/dev/null || fail "Claude settings omit the delegation-shape PreToolUse guard" - # A stem-enumerating matcher repeats the fail-open-by-enumeration defect the - # script exists to remove. Match all tools and let the script be the single - # owner of classification. - jq -e ' - [.hooks.PreToolUse[] | select(.hooks[].command | contains("fm-subagent-pretool-check.sh")) | .matcher] | .[0] - | . == ".*" - ' "$SETTINGS" >/dev/null || fail "the guard matcher must match all tools" - jq -e ' - [.hooks.PreToolUse[] | select(.matcher == "Bash") | .hooks[].command] - == [ - "\"$CLAUDE_PROJECT_DIR\"/bin/fm-arm-pretool-check.sh --claude", - "\"$CLAUDE_PROJECT_DIR\"/bin/fm-cd-pretool-check.sh --claude" - ] - ' "$SETTINGS" >/dev/null || fail "Claude Bash PreToolUse must retain only the arm-shape and persistent-cd seatbelts" - jq -e '.hooks.Stop[0].hooks[0].command | contains("fm-turnend-guard.sh")' "$SETTINGS" >/dev/null \ - || fail "the Stop turn-end guard changed" - pass "Claude wires the delegation guard, retains only non-status Bash seatbelts, and preserves the Stop guard" -} - -test_tracked_settings_do_not_ship_permissions_deny test_guard_denies_every_currently_known_delegation_tool test_guard_denies_hypothetical_future_tools test_guard_allows_ordinary_and_observe_only_tools +test_guard_allows_session_local_todo_tools +test_plan_only_exclusion_is_exact_name test_guard_never_classifies_mcp_tools test_deny_message_defers_to_intake_classification test_escape_hatch_allows_deliberate_use @@ -285,4 +289,3 @@ test_secondmate_home_is_in_scope test_stdin_transports_and_output_shapes test_malformed_transport_fails_open test_missing_jq_stdin_transport_fails_open -test_claude_hook_registration_preserves_bash_seatbelts diff --git a/tests/fm-teardown-endpoint-safety.test.sh b/tests/fm-teardown-endpoint-safety.test.sh new file mode 100755 index 0000000000..5786102cd3 --- /dev/null +++ b/tests/fm-teardown-endpoint-safety.test.sh @@ -0,0 +1,275 @@ +#!/usr/bin/env bash +# Regression tests for cleanup endpoint identity validation. +set -u + +# shellcheck source=tests/lib.sh +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" + +TEARDOWN="$ROOT/bin/fm-teardown.sh" +TMP_ROOT=$(fm_test_tmproot fm-teardown-endpoint-safety) +REAL_TMUX=$(command -v tmux || true) + +make_case() { # <name> + local dir=$1 + mkdir -p "$TMP_ROOT/$dir/home/state" "$TMP_ROOT/$dir/home/data" \ + "$TMP_ROOT/$dir/home/config" "$TMP_ROOT/$dir/fakebin" \ + "$TMP_ROOT/$dir/worktree" "$TMP_ROOT/$dir/project" + : > "$TMP_ROOT/$dir/worktree/sentinel" + : > "$TMP_ROOT/$dir/runtime.log" + cat > "$TMP_ROOT/$dir/fakebin/tmux" <<'SH' +#!/usr/bin/env bash +printf 'tmux' >> "${FM_RUNTIME_LOG:?}" +printf ' <%s>' "$@" >> "${FM_RUNTIME_LOG:?}" +printf '\n' >> "${FM_RUNTIME_LOG:?}" +exit 0 +SH + cat > "$TMP_ROOT/$dir/fakebin/treehouse" <<'SH' +#!/usr/bin/env bash +printf 'treehouse' >> "${FM_RUNTIME_LOG:?}" +printf ' <%s>' "$@" >> "${FM_RUNTIME_LOG:?}" +printf '\n' >> "${FM_RUNTIME_LOG:?}" +exit 0 +SH + chmod +x "$TMP_ROOT/$dir/fakebin/tmux" "$TMP_ROOT/$dir/fakebin/treehouse" + printf '%s\n' "$TMP_ROOT/$dir" +} + +run_case() { # <case> <id> + local dir=$1 id=$2 + FM_HOME="$dir/home" FM_ROOT_OVERRIDE="$ROOT" \ + FM_RUNTIME_LOG="$dir/runtime.log" PATH="$dir/fakebin:$PATH" \ + "$TEARDOWN" "$id" --force +} + +assert_refused_without_mutation() { # <case> <id> <description> + local dir=$1 id=$2 description=$3 rc + set +e + run_case "$dir" "$id" > "$dir/stdout" 2> "$dir/stderr" + rc=$? + set -e + [ "$rc" -ne 0 ] || fail "$description: teardown unexpectedly succeeded" + assert_present "$dir/home/state/$id.meta" "$description: metadata changed before refusal" + assert_present "$dir/worktree/sentinel" "$description: worktree changed before refusal" + [ ! -s "$dir/runtime.log" ] || fail "$description: runtime command ran before refusal: $(cat "$dir/runtime.log")" +} + +test_invalid_endpoint_records_refuse_before_mutation() { + local dir id=endpoint-a + + dir=$(make_case missing) + fm_write_meta "$dir/home/state/$id.meta" \ + "worktree=$dir/worktree" "project=$dir/project" "kind=scout" + assert_refused_without_mutation "$dir" "$id" "missing endpoint" + + dir=$(make_case empty) + fm_write_meta "$dir/home/state/$id.meta" \ + "window=" "worktree=$dir/worktree" "project=$dir/project" "kind=scout" + assert_refused_without_mutation "$dir" "$id" "empty endpoint" + + dir=$(make_case malformed) + fm_write_meta "$dir/home/state/$id.meta" \ + "window=ambient-current-window" "worktree=$dir/worktree" \ + "project=$dir/project" "kind=scout" + assert_refused_without_mutation "$dir" "$id" "malformed endpoint" + + dir=$(make_case mismatched) + fm_write_meta "$dir/home/state/$id.meta" \ + "window=isolated:fm-other-task" "endpoint_task_id=other-task" \ + "worktree=$dir/worktree" "project=$dir/project" "kind=scout" + assert_refused_without_mutation "$dir" "$id" "task-mismatched endpoint" + + dir=$(make_case empty-binding) + fm_write_meta "$dir/home/state/$id.meta" \ + "window=isolated:fm-$id" "endpoint_task_id=" \ + "worktree=$dir/worktree" "project=$dir/project" "kind=scout" + assert_refused_without_mutation "$dir" "$id" "empty task binding" + + dir=$(make_case duplicate-binding) + fm_write_meta "$dir/home/state/$id.meta" \ + "window=isolated:fm-$id" "endpoint_task_id=$id" "endpoint_task_id=$id" \ + "worktree=$dir/worktree" "project=$dir/project" "kind=scout" + assert_refused_without_mutation "$dir" "$id" "duplicate task binding" + + pass "fm-teardown: missing, empty, malformed, ambiguous, and task-mismatched endpoints refuse before every mutation or runtime call" +} + +test_supported_backend_endpoint_records_validate() { + local dir id backend target + dir=$(make_case valid-backends) + # shellcheck source=/dev/null + . "$ROOT/bin/fm-backend.sh" + + id=tmux-task + fm_write_meta "$dir/home/state/$id.meta" \ + "window=firstmate:fm-$id" "worktree=$dir/worktree" "project=$dir/project" + fm_backend_validate_task_endpoint "$dir/home/state/$id.meta" "$id" || fail "valid tmux endpoint refused" + [ "$FM_BACKEND_VALIDATED_BACKEND:$FM_BACKEND_VALIDATED_TARGET" = "tmux:firstmate:fm-$id" ] || fail "tmux endpoint validation returned wrong identity" + + id=tmux-spaced-session + fm_write_meta "$dir/home/state/$id.meta" \ + "window=team work:fm-$id" "worktree=$dir/worktree" "project=$dir/project" + fm_backend_validate_task_endpoint "$dir/home/state/$id.meta" "$id" || fail "valid tmux endpoint with a spaced session name refused" + [ "$FM_BACKEND_VALIDATED_TARGET" = "team work:fm-$id" ] || fail "tmux validation changed the spaced session identity" + + id=herdr-task + fm_write_meta "$dir/home/state/$id.meta" \ + "window=lab:w1:p2" "endpoint_task_id=$id" "worktree=$dir/worktree" "project=$dir/project" \ + "backend=herdr" "herdr_session=lab" "herdr_workspace_id=w1" "herdr_tab_id=w1:t2" "herdr_pane_id=w1:p2" + fm_backend_validate_task_endpoint "$dir/home/state/$id.meta" "$id" || fail "valid Herdr endpoint refused" + + id=zellij-task + fm_write_meta "$dir/home/state/$id.meta" \ + "window=lab:7" "endpoint_task_id=$id" "worktree=$dir/worktree" "project=$dir/project" \ + "backend=zellij" "zellij_session=lab" "zellij_tab_id=3" "zellij_pane_id=7" + fm_backend_validate_task_endpoint "$dir/home/state/$id.meta" "$id" || fail "valid Zellij endpoint refused" + + id=orca-task + fm_write_meta "$dir/home/state/$id.meta" \ + "window=fm-$id" "endpoint_task_id=$id" "terminal=term-7" \ + "worktree=$dir/worktree" "project=$dir/project" "backend=orca" "orca_worktree_id=worktree-9" + fm_backend_validate_task_endpoint "$dir/home/state/$id.meta" "$id" || fail "valid Orca endpoint refused" + [ "$FM_BACKEND_VALIDATED_TARGET" = term-7 ] || fail "Orca validation did not select its terminal" + + id=cmux-task + fm_write_meta "$dir/home/state/$id.meta" \ + "window=workspace-1:surface-2" "endpoint_task_id=$id" "worktree=$dir/worktree" "project=$dir/project" \ + "backend=cmux" "cmux_workspace_id=workspace-1" "cmux_surface_id=surface-2" + fm_backend_validate_task_endpoint "$dir/home/state/$id.meta" "$id" || fail "valid cmux endpoint refused" + + for backend in tmux herdr zellij orca cmux; do + set +e + fm_backend_kill "$backend" "" >/dev/null 2>&1 + target=$? + set -e + [ "$target" -ne 0 ] || fail "$backend generic kill accepted an empty target" + done + pass "cleanup identity: valid tmux, Herdr, Zellij, Orca, and cmux records validate while every empty backend target refuses" +} + +test_tmux_empty_target_refuses_without_invocation() { + local dir rc + dir=$(make_case direct-empty) + set +e + FM_RUNTIME_LOG="$dir/runtime.log" PATH="$dir/fakebin:$PATH" \ + bash -c '. "$1/bin/fm-backend.sh"; fm_backend_source tmux; fm_backend_tmux_kill ""' _ "$ROOT" \ + > "$dir/stdout" 2> "$dir/stderr" + rc=$? + set -e + [ "$rc" -ne 0 ] || fail "direct empty tmux target unexpectedly succeeded" + [ ! -s "$dir/runtime.log" ] || fail "direct empty tmux target invoked tmux" + pass "tmux backend: direct empty target returns nonzero without invoking tmux" +} + +test_recorded_process_identity_cleanup_is_exact() { + local dir target_pid control_pid target_record control_record live_command + dir=$(make_case recorded-process) + sleep 30 & + control_pid=$! + sleep 30 & + target_pid=$! + printf '%s\n' "$control_pid" > "$dir/control.pid" + printf '%s\n' "$target_pid" > "$dir/target.pid" + target_record=$(cat "$dir/target.pid") + control_record=$(cat "$dir/control.pid") + [ "$target_record" = "$target_pid" ] && [ "$control_record" = "$control_pid" ] \ + || fail "recorded process identity changed before cleanup" + live_command=$(ps -p "$target_record" -o comm= 2>/dev/null | tr -d '[:space:]') + case "$live_command" in sleep) ;; *) fail "recorded target pid no longer belongs to the expected child" ;; esac + kill -TERM "$target_record" + wait "$target_record" 2>/dev/null || true + kill -0 "$target_record" 2>/dev/null && fail "exact target pid survived cleanup" + kill -0 "$control_record" 2>/dev/null || fail "independent control process was disturbed" + kill -TERM "$control_record" + wait "$control_record" 2>/dev/null || true + pass "process cleanup: creation-time PID identity removes only the exact child and preserves the control child" +} + +isolated_tmux_window_exists() { # <dir> <socket> <session> <window> + ( cd "$1" && "$REAL_TMUX" -S "$2" list-windows -t "$3" -F '#{window_name}' 2>/dev/null ) \ + | grep -Fqx "$4" +} + +test_isolated_tmux_invalid_and_valid_cleanup() { + local dir socket socket_id session='endpoint safety' target_id=target control=control target=fm-target + local prefix_target=fm-prefix prefix_survivor=fm-prefix2 rc + [ -n "$REAL_TMUX" ] || { echo "skip - tmux not installed"; return 0; } + dir=$(make_case isolated-real) + socket=dedicated.sock + socket_id="$dir/$socket" + ( cd "$dir" && env -u TMUX -u TMUX_PANE "$REAL_TMUX" -S "$socket" new-session -d -s "$session" -n "$control" ) + ( cd "$dir" && env -u TMUX -u TMUX_PANE "$REAL_TMUX" -S "$socket" new-window -d -t "$session:" -n "$target" ) + printf '%s\n' "$socket_id" > "$dir/socket.identity" + cat > "$dir/fakebin/tmux" <<SH +#!/usr/bin/env bash +set -eu +[ -z "\${TMUX:-}" ] && [ -z "\${TMUX_PANE:-}" ] || exit 91 +[ "\${FM_TEST_TMUX_SOCKET:-}" = '$socket_id' ] || exit 92 +[ "\$(cat '$dir/socket.identity')" = '$socket_id' ] || exit 93 +printf 'tmux' >> "\${FM_RUNTIME_LOG:?}" +printf ' <%s>' "\$@" >> "\${FM_RUNTIME_LOG:?}" +printf '\n' >> "\${FM_RUNTIME_LOG:?}" +cd '$dir' +exec '$REAL_TMUX' -S '$socket' "\$@" +SH + chmod +x "$dir/fakebin/tmux" + + fm_write_meta "$dir/home/state/invalid.meta" \ + "window=" "worktree=$dir/worktree" "project=$dir/project" "kind=scout" + set +e + env -u TMUX -u TMUX_PANE FM_TEST_TMUX_SOCKET="$socket_id" \ + FM_HOME="$dir/home" FM_ROOT_OVERRIDE="$ROOT" FM_RUNTIME_LOG="$dir/runtime.log" \ + PATH="$dir/fakebin:$PATH" "$TEARDOWN" invalid --force \ + > "$dir/invalid.out" 2> "$dir/invalid.err" + rc=$? + set -e + [ "$rc" -ne 0 ] || fail "isolated invalid endpoint unexpectedly succeeded" + [ ! -s "$dir/runtime.log" ] || fail "isolated invalid endpoint reached tmux" + isolated_tmux_window_exists "$dir" "$socket" "$session" "$control" || fail "invalid cleanup removed control window" + isolated_tmux_window_exists "$dir" "$socket" "$session" "$target" || fail "invalid cleanup removed target window" + + set +e + # shellcheck disable=SC2016 # $1 expands inside the isolated child shell. + env -u TMUX -u TMUX_PANE FM_TEST_TMUX_SOCKET="$socket_id" FM_RUNTIME_LOG="$dir/runtime.log" \ + PATH="$dir/fakebin:$PATH" bash -c \ + '. "$1/bin/fm-backend.sh"; fm_backend_source tmux; fm_backend_tmux_kill ""' _ "$ROOT" \ + > "$dir/empty.out" 2> "$dir/empty.err" + rc=$? + set -e + [ "$rc" -ne 0 ] || fail "isolated direct empty target unexpectedly succeeded" + [ ! -s "$dir/runtime.log" ] || fail "isolated direct empty target reached tmux" + isolated_tmux_window_exists "$dir" "$socket" "$session" "$control" || fail "direct empty cleanup removed control window" + isolated_tmux_window_exists "$dir" "$socket" "$session" "$target" || fail "direct empty cleanup removed target window" + + ( cd "$dir" && env -u TMUX -u TMUX_PANE "$REAL_TMUX" -S "$socket" new-window -d -t "=$session:" -n "$prefix_survivor" ) + # shellcheck disable=SC2016 # $1 and $2 expand inside the isolated child shell. + env -u TMUX -u TMUX_PANE FM_TEST_TMUX_SOCKET="$socket_id" FM_RUNTIME_LOG="$dir/runtime.log" \ + PATH="$dir/fakebin:$PATH" bash -c \ + '. "$1/bin/fm-backend.sh"; fm_backend_source tmux; fm_backend_tmux_kill "$2"' _ "$ROOT" "$session:$prefix_target" + isolated_tmux_window_exists "$dir" "$socket" "$session" "$prefix_survivor" \ + || fail "missing exact target cleanup removed its prefix-matched neighbor" + + fm_write_meta "$dir/home/state/$target_id.meta" \ + "window=$session:$target" "endpoint_task_id=$target_id" \ + "worktree=$dir/nonexistent-worktree" "project=$dir/nonexistent-project" \ + "kind=scout" "mode=no-mistakes" + env -u TMUX -u TMUX_PANE FM_TEST_TMUX_SOCKET="$socket_id" \ + FM_HOME="$dir/home" FM_ROOT_OVERRIDE="$ROOT" FM_RUNTIME_LOG="$dir/runtime.log" \ + PATH="$dir/fakebin:$PATH" "$TEARDOWN" "$target_id" --force \ + > "$dir/valid.out" 2> "$dir/valid.err" \ + || fail "isolated valid endpoint teardown failed: $(cat "$dir/valid.err")" + isolated_tmux_window_exists "$dir" "$socket" "$session" "$target" \ + && fail "valid cleanup did not remove the exact target window" + isolated_tmux_window_exists "$dir" "$socket" "$session" "$control" \ + || fail "valid cleanup removed the independent control window" + grep -Fqx "tmux <kill-window> <-t> <=$session:=$target>" "$dir/runtime.log" \ + || fail "valid cleanup did not invoke exactly the recorded target: $(cat "$dir/runtime.log")" + + ( cd "$dir" && env -u TMUX -u TMUX_PANE "$REAL_TMUX" -S "$socket" kill-server 2>/dev/null ) || true + pass "fm-teardown: exact tmux cleanup preserves invalid and prefix-matched neighbors while removing only the recorded target" +} + +test_invalid_endpoint_records_refuse_before_mutation +test_supported_backend_endpoint_records_validate +test_tmux_empty_target_refuses_without_invocation +test_recorded_process_identity_cleanup_is_exact +test_isolated_tmux_invalid_and_valid_cleanup diff --git a/tests/fm-teardown.test.sh b/tests/fm-teardown.test.sh index c3fa616ac8..23ef6b8af2 100755 --- a/tests/fm-teardown.test.sh +++ b/tests/fm-teardown.test.sh @@ -155,7 +155,8 @@ SH write_meta() { local case_dir=$1 mode=$2 kind=$3 fm_write_meta "$case_dir/state/task-x1.meta" \ - "window=fm-task-x1" \ + "window=firstmate:fm-task-x1" \ + "endpoint_task_id=task-x1" \ "worktree=$case_dir/wt" \ "project=$case_dir/project" \ "kind=$kind" \ @@ -1242,27 +1243,467 @@ test_local_only_force_overrides_unpushed() { pass "local-only worktree with unpushed work is torn down under --force (escape hatch)" } +test_teardown_missing_busy_sidecar_completes() { + local case_dir gen rc + case_dir=$(make_case missing-busy-sidecar) + write_meta "$case_dir" local-only ship + gen=$("$ROOT/bin/fm-busy-event.sh" arm "$case_dir/state" task-x1) + printf 'busy_gen=%s\n' "$gen" >> "$case_dir/state/task-x1.meta" + rm -f "$case_dir/state/task-x1.busy-gen" + + set +e + run_teardown "$case_dir" --force > "$case_dir/stdout" 2> "$case_dir/stderr" + rc=$? + set -e + + expect_code 0 "$rc" "missing-busy-sidecar: teardown should treat the incarnation as already retired" + assert_absent "$case_dir/state/task-x1.busy-state" \ + "missing-busy-sidecar: teardown left the orphan busy record" + assert_absent "$case_dir/state/task-x1.meta" \ + "missing-busy-sidecar: teardown remained incomplete" + pass "teardown completes when an exact busy-state sidecar is already absent" +} + test_herdr_teardown_clears_escalation_marker() { local case_dir marker case_dir=$(make_case herdr-marker-cleanup) write_meta "$case_dir" local-only ship sed -i.bak 's/^window=.*/window=default:wG:pQ/' "$case_dir/state/task-x1.meta" rm -f "$case_dir/state/task-x1.meta.bak" - printf '%s\n' 'backend=herdr' >> "$case_dir/state/task-x1.meta" - cat > "$case_dir/fakebin/herdr" <<'SH' + printf '%s\n' \ + 'backend=herdr' \ + 'herdr_session=default' \ + 'herdr_workspace_id=wG' \ + 'herdr_tab_id=wG:tQ' \ + 'herdr_pane_id=wG:pQ' >> "$case_dir/state/task-x1.meta" + # A reachable session whose exact pane is already structurally gone: the + # locked close is a no-op and the record gate sees a confirmed-gone pane. + cat > "$case_dir/fakebin/herdr" <<SH #!/usr/bin/env bash -exit 0 +case "\${1:-} \${2:-}" in + "session list") printf '%s\n' '{"sessions":[{"name":"default","running":true,"socket_path":"$case_dir/herdr.sock"}]}' ;; + "status --json") printf '%s\n' '{"server":{"running":true}}' ;; + "pane get") printf '%s\n' '{"error":{"code":"pane_not_found"}}'; exit 1 ;; + *) exit 0 ;; +esac SH chmod +x "$case_dir/fakebin/herdr" marker="$case_dir/state/.herdr-escalated-default_wG_pQ" : > "$marker" run_teardown "$case_dir" --force > "$case_dir/stdout" 2> "$case_dir/stderr" \ - || fail "herdr-marker-cleanup: forced teardown failed" + || fail "herdr-marker-cleanup: forced teardown failed: $(cat "$case_dir/stderr")" [ ! -e "$marker" ] || fail "herdr-marker-cleanup: teardown left the pane's escalation marker behind" pass "herdr teardown removes pane-owned escalation dedupe state" } +# Flat (non-projected) Herdr endpoint whose fake pane exists until a locked +# close removes it. The socket path is case-local so the derived presentation +# lock never collides with another test or a real fleet session. +configure_flat_herdr_teardown_case() { # <case-dir> + local case_dir=$1 + sed -i.bak 's/^window=.*/window=default:wG:pQ/' "$case_dir/state/task-x1.meta" + rm -f "$case_dir/state/task-x1.meta.bak" + printf '%s\n' \ + 'backend=herdr' \ + 'herdr_session=default' \ + 'herdr_workspace_id=wG' \ + 'herdr_tab_id=wG:tQ' \ + 'herdr_pane_id=wG:pQ' >> "$case_dir/state/task-x1.meta" + cat > "$case_dir/fakebin/herdr" <<SH +#!/usr/bin/env bash +set -u +printf '%s\n' "\$*" >> "\${FM_FAKE_HERDR_LOG:?}" +case "\${1:-} \${2:-}" in + "workspace list") + printf '%s\n' '{"result":{"workspaces":[{"workspace_id":"wH","active_tab_id":"wH:t1","focused":true},{"workspace_id":"wG","active_tab_id":"wG:tQ","focused":false}]}}' + ;; + "tab list") + case "\$*" in + *"--workspace wH"*) printf '%s\n' '{"result":{"tabs":[{"tab_id":"wH:t1","focused":true}]}}' ;; + *"--workspace wG"*) printf '%s\n' '{"result":{"tabs":[{"tab_id":"wG:tQ","workspace_id":"wG"}]}}' ;; + *) printf '%s\n' '{"result":{"tabs":[]}}' ;; + esac + ;; + "pane list") + printf '%s\n' '{"result":{"panes":[{"pane_id":"wG:pQ","tab_id":"wG:tQ"}]}}' + ;; + "status --json") + printf '%s\n' '{"server":{"running":true}}' + ;; + "session list") + if [ "\${FM_FAKE_HERDR_SESSION_LIST_GARBAGE:-0}" = 1 ]; then + printf '%s\n' 'not-json' + else + printf '%s\n' '{"sessions":[{"name":"default","running":true,"socket_path":"$case_dir/herdr.sock"}]}' + fi + ;; + "pane close") + : > "\${FM_FAKE_HERDR_CLOSED:?}" + ;; + "pane get") + if [ "\${FM_FAKE_HERDR_PANE_GET_GARBAGE:-0}" = 1 ]; then + printf '%s\n' 'not-json' + exit 0 + fi + if [ -e "\${FM_FAKE_HERDR_CLOSED:?}" ]; then + printf '%s\n' '{"error":{"code":"pane_not_found"}}' >&2 + exit 1 + fi + printf '%s\n' '{"result":{"pane":{"pane_id":"wG:pQ","tab_id":"wG:tQ","workspace_id":"wG"}}}' + ;; + "agent get") + printf '%s\n' '{"error":{"code":"agent_not_found"}}' >&2 + exit 1 + ;; +esac +SH + chmod +x "$case_dir/fakebin/herdr" +} + +test_herdr_flat_teardown_refuses_orphaning_records_then_retry_completes() { + local case_dir log closed lock ready release holder_pid rc thlog + case_dir=$(make_case herdr-orphan-refusal) + write_meta "$case_dir" local-only ship + configure_flat_herdr_teardown_case "$case_dir" + log="$case_dir/herdr.log"; : > "$log" + closed="$case_dir/closed" + : > "$case_dir/state/task-x1.status" + : > "$case_dir/state/task-x1.turn-ended" + # Record every treehouse invocation: the contended-lock refusal must fire + # BEFORE the isolated copy is returned, so phase 1 may not invoke it at all. + thlog="$case_dir/treehouse.log"; : > "$thlog" + cat > "$case_dir/fakebin/treehouse" <<SH +#!/usr/bin/env bash +printf '%s\n' "\$*" >> "$thlog" +exit 0 +SH + chmod +x "$case_dir/fakebin/treehouse" + + lock=$(FM_FAKE_HERDR_LOG="$log" FM_FAKE_HERDR_CLOSED="$closed" PATH="$case_dir/fakebin:$PATH" \ + bash -c '. "$0/bin/backends/herdr.sh"; fm_backend_herdr_presentation_session_lock_path default' "$ROOT") \ + || fail "herdr-orphan-refusal: could not resolve the fixture presentation lock path" + ready="$case_dir/lock-ready"; release="$case_dir/lock-release" + ROOT="$ROOT" LOCK="$lock" READY="$ready" RELEASE="$release" bash -c ' + . "$ROOT/bin/fm-wake-lib.sh" + fm_lock_try_acquire "$LOCK" || exit 1 + : > "$READY" + while [ ! -e "$RELEASE" ]; do sleep 0.1; done + fm_lock_release "$LOCK" + ' & + holder_pid=$! + local waited=0 + while [ ! -e "$ready" ] && [ "$waited" -lt 50 ]; do sleep 0.1; waited=$((waited + 1)); done + [ -e "$ready" ] || fail "herdr-orphan-refusal: the contending lock holder never started" + + rc=0 + FM_FAKE_HERDR_LOG="$log" FM_FAKE_HERDR_CLOSED="$closed" \ + run_teardown "$case_dir" --force > "$case_dir/stdout" 2> "$case_dir/stderr" || rc=$? + if [ "$rc" -eq 0 ]; then + : > "$release"; wait "$holder_pid" 2>/dev/null || true + fail "herdr-orphan-refusal: teardown reported success while the exact pane still existed under lock contention" + fi + [ -e "$case_dir/state/task-x1.meta" ] || { : > "$release"; fail "herdr-orphan-refusal: refusal erased the durable endpoint metadata"; } + [ -e "$case_dir/state/task-x1.status" ] || { : > "$release"; fail "herdr-orphan-refusal: refusal erased the task status record"; } + [ -e "$case_dir/state/task-x1.turn-ended" ] || { : > "$release"; fail "herdr-orphan-refusal: refusal erased the turn-end record"; } + assert_grep "presentation lock is contended" "$case_dir/stderr" \ + "herdr-orphan-refusal: the pre-return refusal was not explained visibly" + if [ -s "$thlog" ]; then + : > "$release"; fail "herdr-orphan-refusal: the contended refusal still returned the isolated copy: $(cat "$thlog")" + fi + [ -d "$case_dir/wt" ] || { : > "$release"; fail "herdr-orphan-refusal: the contended refusal removed the isolated copy"; } + if [ "$(git -C "$case_dir/wt" rev-parse --abbrev-ref HEAD 2>/dev/null)" != "fm/task-x1" ]; then + : > "$release"; fail "herdr-orphan-refusal: the contended refusal dropped the task branch before refusing" + fi + if grep -q "teardown task-x1 complete" "$case_dir/stdout"; then + : > "$release"; fail "herdr-orphan-refusal: refusal still reported cleanup complete" + fi + if grep -q "^pane close" "$log"; then + : > "$release"; fail "herdr-orphan-refusal: an unlocked pane close was attempted under contention" + fi + + : > "$release" + wait "$holder_pid" 2>/dev/null || true + FM_FAKE_HERDR_LOG="$log" FM_FAKE_HERDR_CLOSED="$closed" FM_BACKEND_HERDR_IDLE_SHELL_PROOF_POLLS=1 \ + run_teardown "$case_dir" --force > "$case_dir/stdout2" 2> "$case_dir/stderr2" \ + || fail "herdr-orphan-refusal: the retry after lock release failed: $(cat "$case_dir/stderr2")" + [ -e "$closed" ] || fail "herdr-orphan-refusal: the retry never closed the pane under the lock" + [ -s "$thlog" ] || fail "herdr-orphan-refusal: the successful retry never returned the isolated copy" + [ ! -e "$case_dir/state/task-x1.meta" ] || fail "herdr-orphan-refusal: the successful retry left the metadata behind" + [ ! -e "$case_dir/state/task-x1.status" ] || fail "herdr-orphan-refusal: the successful retry left the status record behind" + grep -q "teardown task-x1 complete" "$case_dir/stdout2" \ + || fail "herdr-orphan-refusal: the successful retry did not report completion" + pass "herdr flat teardown refuses before returning the isolated copy under lock contention and the retry completes cleanly" +} + +test_herdr_flat_teardown_refuses_records_on_unparseable_presence() { + local case_dir log closed rc + case_dir=$(make_case herdr-garbage-presence) + write_meta "$case_dir" local-only ship + configure_flat_herdr_teardown_case "$case_dir" + log="$case_dir/herdr.log"; : > "$log" + closed="$case_dir/closed" + : > "$case_dir/state/task-x1.status" + rc=0 + FM_FAKE_HERDR_LOG="$log" FM_FAKE_HERDR_CLOSED="$closed" FM_FAKE_HERDR_PANE_GET_GARBAGE=1 \ + FM_BACKEND_HERDR_IDLE_SHELL_PROOF_POLLS=1 \ + run_teardown "$case_dir" --force > "$case_dir/stdout" 2> "$case_dir/stderr" || rc=$? + [ "$rc" -ne 0 ] \ + || fail "herdr-garbage-presence: teardown erased records on an unparseable pane presence" + [ -e "$case_dir/state/task-x1.meta" ] \ + || fail "herdr-garbage-presence: ambiguous presence erased the durable endpoint metadata" + [ -e "$case_dir/state/task-x1.status" ] \ + || fail "herdr-garbage-presence: ambiguous presence erased the task status record" + assert_grep "ambiguous structured presence" "$case_dir/stderr" \ + "herdr-garbage-presence: the ambiguity refusal was not explained visibly" + pass "herdr flat teardown never erases records when pane presence is unparseable" +} + +assert_herdr_teardown_preflight_refuses_before_changes() { + local mode=$1 case_dir log closed rc thlog teardown_bin + case_dir=$(make_case "herdr-preflight-$mode") + write_meta "$case_dir" local-only ship + configure_flat_herdr_teardown_case "$case_dir" + log="$case_dir/herdr.log"; : > "$log" + closed="$case_dir/closed" + : > "$case_dir/state/task-x1.status" + : > "$case_dir/state/task-x1.turn-ended" + thlog="$case_dir/treehouse.log"; : > "$thlog" + cat > "$case_dir/fakebin/treehouse" <<SH +#!/usr/bin/env bash +printf '%s\n' "\$*" >> "$thlog" +exit 0 +SH + chmod +x "$case_dir/fakebin/treehouse" + + teardown_bin=$TEARDOWN + case "$mode" in + missing-adapter|missing-parser|missing-explicit-close-helper) + mkdir -p "$case_dir/test-root" + cp -R "$ROOT/bin" "$case_dir/test-root/bin" + if [ "$mode" = missing-adapter ]; then + rm -f "$case_dir/test-root/bin/backends/herdr.sh" + elif [ "$mode" = missing-explicit-close-helper ]; then + sed -i.bak 's/^fm_backend_herdr_explicit_close_pane_confirmed()/fm_backend_herdr_explicit_close_pane_confirmed_unavailable()/' \ + "$case_dir/test-root/bin/backends/herdr.sh" + rm -f "$case_dir/test-root/bin/backends/herdr.sh.bak" + else + sed -i.bak 's/^fm_backend_herdr_parse_target()/fm_backend_herdr_parse_target_unavailable()/' \ + "$case_dir/test-root/bin/backends/herdr.sh" + rm -f "$case_dir/test-root/bin/backends/herdr.sh.bak" + fi + teardown_bin="$case_dir/test-root/bin/fm-teardown.sh" + ;; + esac + rc=0 + FM_ROOT_OVERRIDE="$ROOT" FM_STATE_OVERRIDE="$case_dir/state" FM_CONFIG_OVERRIDE="$case_dir/config" \ + FM_FAKE_HERDR_LOG="$log" FM_FAKE_HERDR_CLOSED="$closed" \ + FM_FAKE_HERDR_SESSION_LIST_GARBAGE="$([ "$mode" = unresolvable-lock ] && printf 1 || printf 0)" \ + PATH="$case_dir/fakebin:$PATH" \ + "$teardown_bin" task-x1 --force > "$case_dir/stdout" 2> "$case_dir/stderr" || rc=$? + [ "$rc" -ne 0 ] || fail "herdr-preflight-$mode: teardown continued without its required preflight" + assert_grep "nothing was changed" "$case_dir/stderr" \ + "herdr-preflight-$mode: the retryable pre-return refusal was not explained visibly" + [ -d "$case_dir/wt" ] || fail "herdr-preflight-$mode: refusal removed the isolated copy" + [ "$(git -C "$case_dir/wt" rev-parse --abbrev-ref HEAD 2>/dev/null)" = "fm/task-x1" ] \ + || fail "herdr-preflight-$mode: refusal dropped the task branch" + [ -e "$case_dir/state/task-x1.meta" ] \ + || fail "herdr-preflight-$mode: refusal erased the durable endpoint metadata" + [ -e "$case_dir/state/task-x1.status" ] \ + || fail "herdr-preflight-$mode: refusal erased the task status record" + [ -e "$case_dir/state/task-x1.turn-ended" ] \ + || fail "herdr-preflight-$mode: refusal erased the turn-end record" + [ ! -s "$thlog" ] || fail "herdr-preflight-$mode: refusal returned the isolated copy" + [ ! -e "$closed" ] || fail "herdr-preflight-$mode: refusal attempted an unlocked pane close" +} + +test_herdr_flat_teardown_preflight_refuses_before_changes() { + assert_herdr_teardown_preflight_refuses_before_changes unresolvable-lock + assert_herdr_teardown_preflight_refuses_before_changes missing-adapter + assert_herdr_teardown_preflight_refuses_before_changes missing-parser + assert_herdr_teardown_preflight_refuses_before_changes missing-explicit-close-helper + pass "herdr flat teardown preflight refuses before every destructive change" +} + +configure_secondmate_with_herdr_child() { # <case-dir> + local case_dir=$1 home="$1/secondmate-home" + mkdir -p "$home/state" "$home/data" "$home/config" "$home/projects" + printf '%s\n' task-x1 > "$home/.fm-secondmate-home" + printf '%s\n' "home=$home" >> "$case_dir/state/task-x1.meta" + fm_write_meta "$home/state/child-herdr.meta" \ + "window=childsession:wC:p1" \ + "endpoint_task_id=child-herdr" \ + "worktree=$case_dir/wt" \ + "project=$case_dir/project" \ + "kind=ship" \ + "mode=local-only" \ + "backend=herdr" \ + "herdr_session=childsession" \ + "herdr_workspace_id=wC" \ + "herdr_tab_id=wC:t1" \ + "herdr_pane_id=wC:p1" + : > "$home/state/child-herdr.status" + : > "$home/state/child-herdr.turn-ended" + cat > "$case_dir/fakebin/herdr" <<SH +#!/usr/bin/env bash +set -u +printf '%s\n' "\$*" >> "\${FM_FAKE_HERDR_LOG:?}" +case "\${1:-} \${2:-}" in + "session list") + if [ "\${FM_FAKE_HERDR_SESSION_LIST_GARBAGE:-0}" = 1 ]; then + printf '%s\n' 'not-json' + else + printf '%s\n' '{"sessions":[{"name":"childsession","running":true,"socket_path":"$case_dir/child.sock"}]}' + fi + ;; + "workspace list") exit 1 ;; + "pane get") + if [ -e "\${FM_FAKE_HERDR_CLOSED:?}" ]; then + if [ "\${FM_FAKE_HERDR_PRESENCE_UNKNOWN:-0}" = 1 ]; then + printf '%s\n' 'not-json' + else + printf '%s\n' '{"error":{"code":"pane_not_found"}}' >&2 + exit 1 + fi + else + printf '%s\n' '{"result":{"pane":{"pane_id":"wC:p1","tab_id":"wC:t1","workspace_id":"wC"}}}' + fi + ;; + "pane close") : > "\${FM_FAKE_HERDR_CLOSED:?}" ;; +esac +SH + chmod +x "$case_dir/fakebin/herdr" +} + +test_forced_secondmate_herdr_child_preflight_refuses_before_changes() { + local case_dir home log closed rc thlog + case_dir=$(make_case herdr-child-preflight) + write_meta "$case_dir" local-only secondmate + configure_secondmate_with_herdr_child "$case_dir" + home="$case_dir/secondmate-home" + log="$case_dir/herdr.log"; closed="$case_dir/closed"; thlog="$case_dir/treehouse.log" + : > "$log"; : > "$thlog" + cat > "$case_dir/fakebin/treehouse" <<SH +#!/usr/bin/env bash +printf '%s\n' "\$*" >> "$thlog" +exit 0 +SH + chmod +x "$case_dir/fakebin/treehouse" + rc=0 + FM_FAKE_HERDR_LOG="$log" FM_FAKE_HERDR_CLOSED="$closed" \ + FM_FAKE_HERDR_SESSION_LIST_GARBAGE=1 \ + run_teardown "$case_dir" --force > "$case_dir/stdout" 2> "$case_dir/stderr" || rc=$? + [ "$rc" -ne 0 ] || fail "herdr-child-preflight: teardown continued through an unresolvable child lock" + [ -e "$case_dir/state/task-x1.meta" ] || fail "herdr-child-preflight: refusal erased the parent record" + [ -e "$home/state/child-herdr.meta" ] || fail "herdr-child-preflight: refusal erased the child record" + [ -e "$home/state/child-herdr.status" ] || fail "herdr-child-preflight: refusal erased child status" + [ -d "$home" ] || fail "herdr-child-preflight: refusal removed the secondmate home" + [ ! -s "$thlog" ] || fail "herdr-child-preflight: refusal returned work before child preflight" + [ ! -e "$closed" ] || fail "herdr-child-preflight: refusal attempted a child close" + assert_grep "nothing was changed" "$case_dir/stderr" \ + "herdr-child-preflight: refusal did not explain its non-mutating boundary" + pass "forced secondmate teardown preflights every Herdr child before cleanup mutation" +} + +test_forced_secondmate_herdr_child_retains_records_when_close_unconfirmed() { + local case_dir home log closed rc + case_dir=$(make_case herdr-child-unconfirmed-close) + write_meta "$case_dir" local-only secondmate + configure_secondmate_with_herdr_child "$case_dir" + home="$case_dir/secondmate-home" + log="$case_dir/herdr.log"; closed="$case_dir/closed"; : > "$log" + rc=0 + FM_FAKE_HERDR_LOG="$log" FM_FAKE_HERDR_CLOSED="$closed" FM_FAKE_HERDR_PRESENCE_UNKNOWN=1 \ + run_teardown "$case_dir" --force > "$case_dir/stdout" 2> "$case_dir/stderr" || rc=$? + [ "$rc" -ne 0 ] || fail "herdr-child-unconfirmed-close: teardown erased records after an ambiguous close" + [ -e "$closed" ] || fail "herdr-child-unconfirmed-close: fixture did not attempt the child close" + [ -e "$home/state/child-herdr.meta" ] || fail "herdr-child-unconfirmed-close: ambiguous close erased child metadata" + [ -e "$home/state/child-herdr.status" ] || fail "herdr-child-unconfirmed-close: ambiguous close erased child status" + [ -e "$case_dir/state/task-x1.meta" ] || fail "herdr-child-unconfirmed-close: failed child cleanup erased parent metadata" + [ -d "$home" ] || fail "herdr-child-unconfirmed-close: failed child cleanup removed the secondmate home" + assert_grep "retaining that child's durable identity records" "$case_dir/stderr" \ + "herdr-child-unconfirmed-close: refusal did not explain child record retention" + pass "forced secondmate teardown retains Herdr child identity until exact pane disappearance" +} + +configure_nested_secondmate_with_herdr_grandchild() { # <case-dir> + local case_dir=$1 home="$1/secondmate-home" nested_home="$1/secondmate-home/nested-home" + mkdir -p "$home/state" "$home/data" "$home/config" "$home/projects" + mkdir -p "$nested_home/state" "$nested_home/data" "$nested_home/config" "$nested_home/projects" + printf '%s\n' task-x1 > "$home/.fm-secondmate-home" + printf '%s\n' nested-sm > "$nested_home/.fm-secondmate-home" + printf '%s\n' "home=$home" >> "$case_dir/state/task-x1.meta" + fm_write_meta "$home/state/nested-sm.meta" \ + "window=firstmate:fm-nested-sm" \ + "endpoint_task_id=nested-sm" \ + "worktree=$case_dir/wt" \ + "project=$case_dir/project" \ + "kind=secondmate" \ + "mode=local-only" \ + "home=$nested_home" + fm_write_meta "$nested_home/state/grandchild-herdr.meta" \ + "window=grandchildsession:wG:p1" \ + "endpoint_task_id=grandchild-herdr" \ + "worktree=$case_dir/wt" \ + "project=$case_dir/project" \ + "kind=ship" \ + "mode=local-only" \ + "backend=herdr" \ + "herdr_session=grandchildsession" \ + "herdr_workspace_id=wG" \ + "herdr_tab_id=wG:t1" \ + "herdr_pane_id=wG:p1" + : > "$nested_home/state/grandchild-herdr.status" + : > "$nested_home/state/grandchild-herdr.turn-ended" + cat > "$case_dir/fakebin/herdr" <<SH +#!/usr/bin/env bash +set -u +printf '%s\n' "\$*" >> "\${FM_FAKE_HERDR_LOG:?}" +case "\${1:-} \${2:-}" in + "session list") + printf '%s\n' '{"sessions":[{"name":"grandchildsession","running":true,"socket_path":"$case_dir/grandchild.sock"}]}' + ;; + "workspace list") exit 1 ;; + "pane get") + if [ -e "\${FM_FAKE_HERDR_CLOSED:?}" ]; then + printf '%s\n' 'not-json' + else + printf '%s\n' '{"result":{"pane":{"pane_id":"wG:p1","tab_id":"wG:t1","workspace_id":"wG"}}}' + fi + ;; + "pane close") : > "\${FM_FAKE_HERDR_CLOSED:?}" ;; +esac +SH + chmod +x "$case_dir/fakebin/herdr" +} + +test_forced_teardown_retains_nested_secondmate_home_when_grandchild_close_unconfirmed() { + local case_dir home nested_home log closed rc + case_dir=$(make_case herdr-grandchild-unconfirmed-close) + write_meta "$case_dir" local-only secondmate + configure_nested_secondmate_with_herdr_grandchild "$case_dir" + home="$case_dir/secondmate-home"; nested_home="$home/nested-home" + log="$case_dir/herdr.log"; closed="$case_dir/closed"; : > "$log" + rc=0 + FM_FAKE_HERDR_LOG="$log" FM_FAKE_HERDR_CLOSED="$closed" \ + run_teardown "$case_dir" --force > "$case_dir/stdout" 2> "$case_dir/stderr" || rc=$? + [ "$rc" -ne 0 ] \ + || fail "herdr-grandchild-unconfirmed-close: teardown erased records after an ambiguous grandchild close" + [ -e "$closed" ] \ + || fail "herdr-grandchild-unconfirmed-close: fixture did not attempt the grandchild close" + [ -d "$nested_home" ] \ + || fail "herdr-grandchild-unconfirmed-close: the recursive failure still removed the nested secondmate home" + [ -e "$nested_home/state/grandchild-herdr.meta" ] \ + || fail "herdr-grandchild-unconfirmed-close: ambiguous close erased the grandchild's metadata" + [ -e "$nested_home/state/grandchild-herdr.status" ] \ + || fail "herdr-grandchild-unconfirmed-close: ambiguous close erased the grandchild's status record" + [ -e "$home/state/nested-sm.meta" ] \ + || fail "herdr-grandchild-unconfirmed-close: the recursive failure erased the nested secondmate's own record" + [ -e "$case_dir/state/task-x1.meta" ] \ + || fail "herdr-grandchild-unconfirmed-close: the recursive failure erased the top-level secondmate's record" + pass "forced teardown retains a nested secondmate home and its grandchild's Herdr identity when the grandchild close is unconfirmed" +} + configure_herdr_projection_teardown_case() { # <case-dir> local case_dir=$1 token=AbCdEfGhIjKlMnOpQrStUv sed -i.bak 's/^window=.*/window=fmtest:w1:p2/' "$case_dir/state/task-x1.meta" @@ -1312,6 +1753,10 @@ case "${1:-} ${2:-}" in ;; "pane get") if [ -e "${FM_FAKE_HERDR_CLOSED:?}" ]; then + if [ "${FM_FAKE_HERDR_PRESENCE_UNKNOWN:-0}" = 1 ]; then + printf '%s\n' '{"error":{"code":"internal"}}' >&2 + exit 1 + fi printf '%s\n' '{"error":{"code":"pane_not_found"}}' >&2 exit 1 fi @@ -1359,16 +1804,24 @@ test_herdr_projection_teardown_retains_journal_when_close_unconfirmed() { configure_herdr_projection_teardown_case "$case_dir" log="$case_dir/herdr.log"; closed="$case_dir/closed"; restored="$case_dir/restored"; : > "$log" - FM_FAKE_HERDR_LOG="$log" FM_FAKE_HERDR_CLOSED="$closed" FM_FAKE_HERDR_RESTORED="$restored" FM_FAKE_HERDR_CLOSE_FAIL=1 \ - run_teardown "$case_dir" --force > "$case_dir/stdout" 2> "$case_dir/stderr" \ - || fail "herdr-projection-unconfirmed-close: teardown should preserve best-effort endpoint semantics" + local rc=0 + FM_FAKE_HERDR_LOG="$log" FM_FAKE_HERDR_CLOSED="$closed" FM_FAKE_HERDR_RESTORED="$restored" FM_FAKE_HERDR_PRESENCE_UNKNOWN=1 \ + run_teardown "$case_dir" --force > "$case_dir/stdout" 2> "$case_dir/stderr" || rc=$? + [ "$rc" -ne 0 ] \ + || fail "herdr-projection-unconfirmed-close: teardown reported success after an unknown post-close presence read" + [ -e "$closed" ] \ + || fail "herdr-projection-unconfirmed-close: regression did not exercise an attempted close" [ -e "$case_dir/state/task-x1.herdr-presentation" ] \ || fail "unconfirmed task-pane close incorrectly retired the presentation journal" + [ -e "$case_dir/state/task-x1.meta" ] \ + || fail "unconfirmed task-pane close erased the durable endpoint metadata" assert_grep "close could not be confirmed" "$case_dir/stderr" \ "unconfirmed projected close did not explain why the journal was retained" + assert_grep "not confirmed gone" "$case_dir/stderr" \ + "unconfirmed projected close did not explain why the records were retained" assert_not_contains "$(cat "$log")" "workspace close" \ "unconfirmed projected close must not escalate to workspace cleanup" - pass "herdr projection teardown retains the stale journal and attempts no workspace cleanup when exact-pane close is unconfirmed" + pass "herdr projection teardown retains every record when post-close presence is unknown" } test_local_only_fork_remote_allows @@ -1379,7 +1832,14 @@ test_local_only_merged_to_local_main_allows test_no_mistakes_origin_remote_allows test_no_mistakes_truly_unpushed_refuses test_local_only_force_overrides_unpushed +test_teardown_missing_busy_sidecar_completes test_herdr_teardown_clears_escalation_marker +test_herdr_flat_teardown_refuses_orphaning_records_then_retry_completes +test_herdr_flat_teardown_refuses_records_on_unparseable_presence +test_herdr_flat_teardown_preflight_refuses_before_changes +test_forced_secondmate_herdr_child_preflight_refuses_before_changes +test_forced_secondmate_herdr_child_retains_records_when_close_unconfirmed +test_forced_teardown_retains_nested_secondmate_home_when_grandchild_close_unconfirmed test_herdr_projection_teardown_retires_journal_only_after_confirmed_close test_herdr_projection_teardown_retains_journal_when_close_unconfirmed test_squash_merged_branch_deleted_allows diff --git a/tests/fm-test-isolation-proof.test.sh b/tests/fm-test-isolation-proof.test.sh index 6a11def0ea..1847338e8c 100755 --- a/tests/fm-test-isolation-proof.test.sh +++ b/tests/fm-test-isolation-proof.test.sh @@ -1,24 +1,12 @@ #!/usr/bin/env bash -# Contract tests for bin/fm-test-isolation-proof.sh - the Phase 2 pre-shard -# isolation proof harness. -# -# These tests assert the candidate-set contract, serial exclusions, aggregate -# failure reporting, and that Phase 4 production shards consume this exact set. -# They deliberately do NOT re-run the full concurrent candidate matrix on every -# invocation (that matrix is owned by the harness itself and archived under -# docs/fm-test-isolation-proof.md after a deliberate proof run). +# Behavioral tests for the isolation-proof and test-run public interfaces. set -u -# shellcheck disable=SC1091 # shellcheck source=tests/lib.sh . "$(dirname "${BASH_SOURCE[0]}")/lib.sh" PROOF="$ROOT/bin/fm-test-isolation-proof.sh" RUNNER="$ROOT/bin/fm-test-run.sh" -CI="$ROOT/.github/workflows/ci.yml" -CONTRIB="$ROOT/CONTRIBUTING.md" -PROOF_DOC="$ROOT/docs/fm-test-isolation-proof.md" -PROOF_JSON="$ROOT/docs/fm-test-isolation-proof.json" assert_present "$PROOF" "bin/fm-test-isolation-proof.sh is missing" [ -x "$PROOF" ] || fail "bin/fm-test-isolation-proof.sh must be executable" @@ -31,7 +19,6 @@ test_list_candidates_nonempty_and_stable() { [ "$count" -ge 10 ] || fail "expected a bounded non-trivial candidate set, got $count" sorted=$(printf '%s\n' "$listed" | LC_ALL=C sort) [ "$listed" = "$sorted" ] || fail "--list must be sorted for a stable matrix" - # No duplicates. [ "$(printf '%s\n' "$listed" | uniq | wc -l | tr -d ' ')" = "$count" ] \ || fail "--list must not duplicate candidates" while IFS= read -r line; do @@ -47,11 +34,8 @@ test_list_candidates_nonempty_and_stable() { test_candidates_exclude_serial_classes() { local listed listed=$("$PROOF" --list) - # Self must never re-enter the concurrent matrix. - printf '%s\n' "$listed" | grep -Fq 'tests/fm-test-isolation-proof.test.sh' \ - && fail "isolation-proof test must not be a parallel candidate" - # Real tmux smoke, watcher lock, real herdr, AFK, live harnesses stay serial. for banned in \ + tests/fm-test-isolation-proof.test.sh \ tests/fm-backend-tmux-smoke.test.sh \ tests/fm-watcher-lock.test.sh \ tests/fm-wake-queue.test.sh \ @@ -66,16 +50,6 @@ test_candidates_exclude_serial_classes() { pass "serial classes remain excluded from the parallel candidate set" } -test_candidates_match_archived_proof() { - local listed archived - assert_present "$PROOF_JSON" "docs/fm-test-isolation-proof.json missing" - listed=$("$PROOF" --list) - archived=$(jq -r '.scripts[].path' "$PROOF_JSON" | LC_ALL=C sort) - [ "$listed" = "$archived" ] \ - || fail "candidate set must exactly match the archived isolation proof" - pass "candidate set exactly matches the archived isolation proof" -} - test_extra_hermetic_candidates_present() { local listed listed=$("$PROOF" --list) @@ -89,7 +63,7 @@ test_extra_hermetic_candidates_present() { printf '%s\n' "$listed" | grep -Fxq "$want" \ || fail "extra hermetic candidate missing: $want" done - pass "audited fake-backend / stub-network extras are candidates" + pass "audited fake-backend and stub-network extras are candidates" } test_list_exclusions_documents_reasons() { @@ -111,82 +85,7 @@ test_family_map_labels_this_contract() { pass "isolation-proof contract test is family-mapped" } -test_aggregate_failure_under_concurrency() { - local tmp pass_f fail_f harness rc out - tmp=$(mktemp -d "${TMPDIR:-/tmp}/fm-isolation-agg.XXXXXX") - pass_f="$tmp/pass.test.sh" - fail_f="$tmp/fail.test.sh" - cat >"$pass_f" <<'SH' -#!/usr/bin/env bash -echo "ok - pass" -exit 0 -SH - cat >"$fail_f" <<'SH' -#!/usr/bin/env bash -echo "not ok - fail" -exit 1 -SH - chmod +x "$pass_f" "$fail_f" - # Minimal fixture harness mirroring aggregate + concurrent wait semantics. - harness="$tmp/harness.sh" - cat >"$harness" <<'SH' -#!/usr/bin/env bash -set -eu -jobs=$1 -shift -pids=() -rcs=() -paths=() -idx=0 -for s in "$@"; do - idx=$((idx + 1)) - ( - bash "$s" - echo $? >"${TMPDIR:-/tmp}/iso-rc-$idx" - ) & - pids+=("$!") - paths+=("$s") - while [ "${#pids[@]}" -ge "$jobs" ]; do - wait "${pids[0]}" || true - pids=("${pids[@]:1}") - done -done -while [ "${#pids[@]}" -gt 0 ]; do - wait "${pids[0]}" || true - pids=("${pids[@]:1}") -done -failed=0 -for i in $(seq 1 "$idx"); do - rc=$(cat "${TMPDIR:-/tmp}/iso-rc-$i" 2>/dev/null || echo 1) - [ "$rc" -eq 0 ] || failed=$((failed + 1)) - rm -f "${TMPDIR:-/tmp}/iso-rc-$i" -done -echo "FM_ISOLATION_SUMMARY total=$idx failed=$failed" -[ "$failed" -eq 0 ] -SH - chmod +x "$harness" - set +e - out=$(TMPDIR="$tmp" bash "$harness" 2 "$pass_f" "$fail_f" 2>&1) - rc=$? - set -e - [ "$rc" -ne 0 ] || fail "concurrent aggregate must fail when any candidate fails" - printf '%s\n' "$out" | grep -Fq 'FM_ISOLATION_SUMMARY total=2 failed=1' \ - || fail "aggregate summary must report total=2 failed=1: $out" - rm -rf "$tmp" - pass "aggregate failure reporting survives concurrency" -} - -test_phase4_consumes_proven_set_only() { - assert_present "$CI" "ci.yml missing" - assert_present "$RUNNER" "fm-test-run.sh missing" - # Phase 4 portable parallel lanes must exist and use lane selection, not --all. - grep -Fq 'bin/fm-test-run.sh --lane portable-parallel-1' "$CI" \ - || fail "CI portable parallel 1 must use --lane portable-parallel-1" - grep -Fq 'bin/fm-test-run.sh --lane portable-parallel-2' "$CI" \ - || fail "CI portable parallel 2 must use --lane portable-parallel-2" - grep -Fq 'bin/fm-test-run.sh --lane portable-serial' "$CI" \ - || fail "CI portable serial must use --lane portable-serial" - # Shard union must equal this harness's proven list. +test_parallel_shards_consume_the_proven_set() { local proven shards proven=$("$PROOF" --list | LC_ALL=C sort -u) shards=$( @@ -197,76 +96,12 @@ test_phase4_consumes_proven_set_only() { ) [ "$proven" = "$shards" ] \ || fail "portable parallel shards must equal isolation-proof --list exactly" - # Local --jobs is bounded to this proven set (refuse is contract-tested in - # fm-test-run.test.sh); the option must exist. - grep -E '^[[:space:]]*--jobs\)' "$RUNNER" >/dev/null 2>&1 \ - || fail "fm-test-run.sh must expose bounded --jobs after Phase 4" - pass "Phase 4 portable shards consume the proven-isolated set only" -} - -test_docs_record_proof_owner() { - assert_present "$PROOF_DOC" "docs/fm-test-isolation-proof.md missing" - grep -Fq 'bin/fm-test-isolation-proof.sh' "$PROOF_DOC" \ - || fail "proof doc must name the harness owner" - grep -Fq 'production_sharding_enabled' "$PROOF_DOC" \ - || fail "proof doc must record the archived proof-time sharding flag" - grep -Fq 'concurrency' "$PROOF_DOC" \ - || fail "proof doc must record concurrency" - assert_present "$CONTRIB" "CONTRIBUTING.md missing" - grep -Fq 'fm-test-isolation-proof' "$CONTRIB" \ - || fail "CONTRIBUTING must document the isolation-proof entry point" - pass "docs archive the isolation-proof owner and posture" -} - -test_docs_match_archived_proof() { - python3 - "$PROOF_DOC" "$PROOF_JSON" <<'PY' \ - || fail "proof Markdown must match the archived proof JSON" -import json -import re -import sys - -markdown = open(sys.argv[1], encoding="utf-8").read() -with open(sys.argv[2], encoding="utf-8") as stream: - proof = json.load(stream) - -summary = proof["summary"] -posture = [ - f'| `run_id` | `{proof["run_id"]}` |', - f'| `started_at` | `{proof["started_at"]}` |', - f'| `finished_at` | `{proof["finished_at"]}` |', - f'| concurrency | **{proof["concurrency"]}** |', - f'| candidates | **{summary["total"]}** |', - f'| failed | **{summary["failed"]}** |', - f'| wall duration_ms | **{summary["duration_ms"]}** (~{summary["duration_ms"] / 1000:.1f}s) |', - f'| `production_sharding_enabled` | `{str(proof["production_sharding_enabled"]).capitalize()}` |', - f'| `fm_test_run_jobs_enabled` | `{str(proof["fm_test_run_jobs_enabled"]).capitalize()}` |', - f'| host proof date | {proof["finished_at"][:10]} (UTC day of archive write) |', -] -assert all(line in markdown for line in posture) -section = markdown.split("## Per-candidate durations (concurrent run)", 1)[1] -section = section.split("## Audit notes (why this set)", 1)[0] -actual = [ - (int(duration), int(exit_code), int(worker), path) - for duration, exit_code, worker, path in re.findall( - r"^\| (\d+) \| (\d+) \| (\d+) \| `([^`]+)` \|$", section, re.MULTILINE - ) -] -expected = [ - (row["duration_ms"], row["exit"], row["worker"], row["path"]) - for row in sorted(proof["scripts"], key=lambda row: row["duration_ms"], reverse=True) -] -assert actual == expected -PY - pass "proof Markdown matches archived JSON posture and durations" + pass "parallel shards consume the proven-isolated set only" } test_list_candidates_nonempty_and_stable test_candidates_exclude_serial_classes -test_candidates_match_archived_proof test_extra_hermetic_candidates_present test_list_exclusions_documents_reasons test_family_map_labels_this_contract -test_aggregate_failure_under_concurrency -test_phase4_consumes_proven_set_only -test_docs_record_proof_owner -test_docs_match_archived_proof +test_parallel_shards_consume_the_proven_set diff --git a/tests/fm-test-run.test.sh b/tests/fm-test-run.test.sh index 7c7dbc5d1b..cfb1578fc4 100755 --- a/tests/fm-test-run.test.sh +++ b/tests/fm-test-run.test.sh @@ -11,9 +11,6 @@ set -u . "$(dirname "${BASH_SOURCE[0]}")/lib.sh" RUNNER="$ROOT/bin/fm-test-run.sh" -CI="$ROOT/.github/workflows/ci.yml" -CONTRIB="$ROOT/CONTRIBUTING.md" -SHARD_DOC="$ROOT/docs/fm-test-portable-shards.md" assert_present "$RUNNER" "bin/fm-test-run.sh is missing" [ -x "$RUNNER" ] || fail "bin/fm-test-run.sh must be executable" @@ -98,7 +95,7 @@ init_changed_fixture_repo() { chmod +x "$repo/bin/fm-test-run.sh" for script in \ fm-brief.test.sh \ - fm-captain-translation-contract.test.sh \ + fm-ask-user-authority.test.sh \ fm-cd-pretool-check.test.sh \ fm-daemon.test.sh \ fm-backend-herdr-smoke.test.sh \ @@ -167,7 +164,7 @@ test_changed_dependency_selection_and_unmapped_failure() { printf '\n' >>"$repo/.pi/extensions/fm-primary-pi-watch.ts" printf '\n' >>"$repo/.pi/extensions/fm-primary-turnend-guard.ts" listed=$(cd "$repo" && bin/fm-test-run.sh --list --changed --base HEAD) - assert_contains "$listed" "tests/fm-captain-translation-contract.test.sh" "skill source selects pure contract coverage" + assert_contains "$listed" "tests/fm-ask-user-authority.test.sh" "skill source selects pure contract coverage" assert_contains "$listed" "tests/fm-cd-pretool-check.test.sh" "Claude and Pi source selects hook coverage" assert_contains "$listed" "tests/fm-pi-watch-extension.test.sh" "Pi source selects watcher coverage" git -C "$repo" add .agents .claude .pi @@ -353,84 +350,6 @@ test_exclude_family() { pass "exclude-family drops the named primary family after selection" } -test_ci_and_docs_call_the_owner() { - assert_present "$CI" "ci.yml missing" - assert_present "$CONTRIB" "CONTRIBUTING.md missing" - grep -Fq 'tests-portable-parallel-1:' "$CI" \ - || fail "CI must define portable parallel shard 1" - grep -Fq 'tests-portable-parallel-2:' "$CI" \ - || fail "CI must define portable parallel shard 2" - grep -Fq 'tests-portable-serial:' "$CI" \ - || fail "CI must define the portable serial lane" - grep -Fq 'bin/fm-test-run.sh --lane portable-parallel-1' "$CI" \ - || fail "CI shard 1 must invoke --lane portable-parallel-1" - grep -Fq 'bin/fm-test-run.sh --lane portable-parallel-2' "$CI" \ - || fail "CI shard 2 must invoke --lane portable-parallel-2" - local shard job_body - for shard in 1 2; do - job_body=$(awk -v job=" tests-portable-parallel-$shard:" ' - $0 == job { in_job=1; next } - in_job && /^ [a-zA-Z0-9_-]+:/ { exit } - in_job { print } - ' "$CI") - printf '%s\n' "$job_body" | grep -Fq 'npm install -g tasks-axi' \ - || fail "CI portable parallel shard $shard must install tasks-axi" - printf '%s\n' "$job_body" | grep -Fq 'tasks-axi --version' \ - || fail "CI portable parallel shard $shard must verify tasks-axi" - done - grep -Fq 'bin/fm-test-run.sh --lane portable-serial' "$CI" \ - || fail "CI portable serial must invoke --lane portable-serial" - grep -Fq 'bin/fm-test-run.sh --check-coverage' "$CI" \ - || fail "CI must run the coverage guard" - grep -Fq 'tests-herdr:' "$CI" \ - || fail "CI must define the required tests-herdr job" - grep -Fq 'bin/fm-test-run.sh --family real-herdr-gated' "$CI" \ - || fail "Herdr CI job must run the real-herdr-gated family via fm-test-run" - grep -Fq -- "--fail-on-gate-skip 'herdr not found'" "$CI" \ - || fail "Herdr CI job must fail on herdr-not-found skips" - grep -Fq 'bin/fm-install-herdr.sh' "$CI" \ - || fail "Herdr CI job must install via bin/fm-install-herdr.sh" - grep -Fq 'bin/fm-install-treehouse.sh' "$CI" \ - || fail "Herdr CI job must install via bin/fm-install-treehouse.sh" - grep -Fq 'bin/fm-herdr-ci-cleanup.sh' "$CI" \ - || fail "Herdr CI job must use bounded lab cleanup" - grep -Fq 'tests-timing-aggregate:' "$CI" \ - || fail "CI must aggregate per-lane timing artifacts" - grep -Fq 'timeout-minutes: 20' "$CI" \ - || fail "portable serial hang tripwire must be timeout-minutes: 20" - grep -Fq 'timeout-minutes: 10' "$CI" \ - || fail "portable parallel shards must keep a hang tripwire (10m)" - # Interim full-suite 25m portable timeout must not remain after sharding. - if grep -Eq 'timeout-minutes: 25' "$CI"; then - fail "CI still has interim timeout-minutes: 25 after portable sharding" - fi - # Stale "~2-3 minutes" claim must not remain. - if grep -Eq '2-3 minutes' "$CI"; then - fail "CI workflow still claims the suite finishes in ~2-3 minutes" - fi - # No retry-green strategy on Behavior lanes. - if grep -Eqi 'retry:|max-attempts:|continue-on-error:\s*true' "$CI"; then - fail "CI must not use retries or continue-on-error as a green strategy" - fi - grep -Fq 'fm-test-timing' "$CI" \ - || fail "CI must upload timing artifacts" - grep -Fq 'bin/fm-test-run.sh --all' "$CONTRIB" \ - || fail "CONTRIBUTING must document bin/fm-test-run.sh --all" - grep -Fq 'bin/fm-test-run.sh --family' "$CONTRIB" \ - || fail "CONTRIBUTING must document family selection" - grep -Fq 'bin/fm-test-run.sh --changed' "$CONTRIB" \ - || fail "CONTRIBUTING must document changed-file selection" - grep -Fq 'bin/fm-test-run.sh --proven-isolated --jobs' "$CONTRIB" \ - || fail "CONTRIBUTING must document proven-isolated --jobs" - grep -Fq 'intent-targeted' "$CONTRIB" \ - || fail "CONTRIBUTING must document intent-targeted no-mistakes Test" - # Do not restore a complete-suite commands.test. - if grep -E '^[[:space:]]*test:[[:space:]].*tests/\*\.test\.sh' "$ROOT/.no-mistakes.yaml" >/dev/null 2>&1; then - fail ".no-mistakes.yaml must not set a full-suite commands.test" - fi - pass "CI and CONTRIBUTING call the one-owner runner; no full-suite local Test" -} - test_portable_shard_union_and_coverage_guard() { local s1 s2 proven serial herdr all_count union_count overlap out first s1=$("$RUNNER" --list --lane portable-parallel-1) @@ -462,40 +381,11 @@ test_portable_shard_union_and_coverage_guard() { || fail "lanes must not duplicate scripts" # LPT order: first script of shard 1 is the longest proven script. first=$(printf '%s\n' "$s1" | head -n 1) - [ "$first" = "tests/fm-arm-pretool-check.test.sh" ] \ - || fail "shard 1 must start with longest proven script, got $first" + [ "$first" = "tests/fm-x-mode.test.sh" ] \ + || fail "shard 1 must start with the longest proven script, got $first" pass "portable shard union, disjointness, and coverage guard hold" } -test_portable_shard_docs_match_lanes() { - python3 - "$RUNNER" "$SHARD_DOC" <<'PY' \ - || fail "portable shard documentation must match lane counts and timing sums" -import re -import subprocess -import sys - -runner, doc_path = sys.argv[1:3] -markdown = open(doc_path, encoding="utf-8").read() -averages = { - path: int(duration) - for duration, path in re.findall(r"^\| (\d+) \| `([^`]+)` \|$", markdown, re.MULTILINE) -} -totals = {} -for lane in ("portable-parallel-1", "portable-parallel-2"): - scripts = subprocess.check_output( - [runner, "--list", "--lane", lane], text=True - ).splitlines() - totals[lane] = (len(scripts), sum(averages[path] for path in scripts)) - -for lane, (count, duration) in totals.items(): - expected = f"| `{lane}` | {count} | {duration} ms (~{duration / 1000:.1f} s) |" - assert expected in markdown -imbalance = abs(totals["portable-parallel-1"][1] - totals["portable-parallel-2"][1]) -assert f"| imbalance | | {imbalance} ms |" in markdown -PY - pass "portable shard documentation matches lane counts and timing sums" -} - test_jobs_requires_proven_isolated() { local tmp rc tmp=$(mktemp -d "${TMPDIR:-/tmp}/fm-test-run-jobs.XXXXXX") @@ -522,8 +412,8 @@ test_jobs_parallel_scheduler_and_failure_propagation() { runner="$repo/bin/fm-test-run.sh" evidence="$tmp/evidence" fake_bin="$tmp/fake-bin" - a=tests/fm-no-mistakes-ownership.test.sh - b=tests/fm-stow-contract.test.sh + a=tests/fm-brief.test.sh + b=tests/fm-composer-lib.test.sh c=tests/fm-lint.test.sh d=tests/fm-supervision-instructions.test.sh mkdir -p "$repo/bin" "$repo/tests" "$evidence" "$fake_bin" @@ -688,9 +578,7 @@ test_aggregate_exit_behavior test_gate_skip_accounting test_fail_on_gate_skip_token test_exclude_family -test_ci_and_docs_call_the_owner test_portable_shard_union_and_coverage_guard -test_portable_shard_docs_match_lanes test_jobs_requires_proven_isolated test_jobs_parallel_scheduler_and_failure_propagation test_aggregate_json diff --git a/tests/fm-turnend-guard.test.sh b/tests/fm-turnend-guard.test.sh index 813709d73a..242407c1a3 100755 --- a/tests/fm-turnend-guard.test.sh +++ b/tests/fm-turnend-guard.test.sh @@ -604,36 +604,106 @@ EOF expect_code 0 "$status" "grok adapter must allow its own forced resume turn to end" [ -z "$out" ] || fail "grok adapter printed output while loop-guarded: $out" [ ! -e "$log" ] || fail "grok adapter spawned another resume while loop-guarded: $(cat "$log")" - pass "fm-turnend-guard-grok: loop guard prevents a nested resume loop" + pass "fm-turnend-guard-grok: legacy environment loop guard prevents a nested resume loop" } -test_settings_hook_uses_claude_project_dir() { - local settings command - settings="$ROOT/.claude/settings.json" - [ -f "$settings" ] || fail "tracked .claude/settings.json is missing" - command=$(jq -r '.hooks.Stop[0].hooks[0].command // empty' "$settings") - [ -n "$command" ] || fail "Stop hook command is missing from .claude/settings.json" - assert_contains "$command" 'CLAUDE_PROJECT_DIR' "Stop hook must resolve via CLAUDE_PROJECT_DIR, not a cwd-relative path" - assert_contains "$command" 'fm-turnend-guard.sh --claude' "Stop hook must invoke fm-turnend-guard.sh in cooperative --claude mode" - case "$command" in - bin/fm-turnend-guard.sh|./bin/fm-turnend-guard.sh) - fail "Stop hook must not use a bare relative path (cwd-dependent): $command" - ;; - esac - pass ".claude/settings.json: Stop hook uses CLAUDE_PROJECT_DIR-anchored --claude guard command" -} - -test_codex_hook_invokes_shared_guard() { - local settings command - settings="$ROOT/.codex/hooks.json" - [ -f "$settings" ] || fail "tracked .codex/hooks.json is missing" - command=$(jq -r '.hooks.Stop[0].hooks[0].command // empty' "$settings") - [ -n "$command" ] || fail "Stop hook command is missing from .codex/hooks.json" - assert_contains "$command" 'pwd -P' "codex hook must anchor from the hook process working directory" - assert_contains "$command" '.codex/hooks.json' "codex hook must verify the hook-loaded firstmate root" - assert_contains "$command" 'fm-turnend-guard.sh' "codex hook must invoke the shared guard" - assert_not_contains "$command" '.cwd' "codex hook must not use payload cwd to select the guard executable" - pass ".codex/hooks.json: Stop hook invokes the shared primary guard" +test_grok_adapter_native_false_blocks_without_resume() { + local dir fakebin log out status + dir=$(make_primary_dir "$TMP_ROOT/grok-native-false") + : > "$dir/state/task1.meta" + fakebin=$(fm_fakebin "$TMP_ROOT/grok-native-false-bin") + log="$TMP_ROOT/grok-native-false.log" + printf '#!/usr/bin/env bash\nprintf called >> %q\n' "$log" > "$fakebin/grok" + chmod +x "$fakebin/grok" + out=$(printf '%s' '{"sessionId":"native","stopHookActive":false}' | PATH="$fakebin:$PATH" GROK_WORKSPACE_ROOT="$dir" bash "$dir/bin/fm-turnend-guard-grok.sh" 2>&1); status=$? + expect_code 2 "$status" "native stopHookActive=false must return the shared blocking status" + assert_contains "$out" 'TURN WOULD END BLIND' "native block must pass shared guard feedback to Grok" + [ ! -e "$log" ] || fail "native path started grok --resume" + pass "fm-turnend-guard-grok: native false delegates blocking feedback with zero resume processes" +} + +test_grok_adapter_native_true_allows_without_resume() { + local dir fakebin log out status + dir=$(make_primary_dir "$TMP_ROOT/grok-native-true") + : > "$dir/state/task1.meta" + fakebin=$(fm_fakebin "$TMP_ROOT/grok-native-true-bin") + log="$TMP_ROOT/grok-native-true.log" + printf '#!/usr/bin/env bash\nprintf called >> %q\n' "$log" > "$fakebin/grok" + chmod +x "$fakebin/grok" + out=$(printf '%s' '{"sessionId":"native","stopHookActive":true}' | PATH="$fakebin:$PATH" GROK_WORKSPACE_ROOT="$dir" bash "$dir/bin/fm-turnend-guard-grok.sh" 2>&1); status=$? + expect_code 0 "$status" "native stopHookActive=true must allow the bounded continuation to stop" + [ -z "$out" ] || fail "native true produced output: $out" + [ ! -e "$log" ] || fail "native true started grok --resume" + pass "fm-turnend-guard-grok: native true remains bounded and starts no resume process" +} + +test_grok_adapter_snake_case_native_and_camel_precedence() { + local dir out status + dir=$(make_primary_dir "$TMP_ROOT/grok-native-spellings") + : > "$dir/state/task1.meta" + out=$(printf '%s' '{"sessionId":"native","stop_hook_active":false}' | GROK_WORKSPACE_ROOT="$dir" bash "$dir/bin/fm-turnend-guard-grok.sh" 2>&1); status=$? + expect_code 2 "$status" "typed snake_case false must select native blocking" + assert_contains "$out" 'TURN WOULD END BLIND' "snake_case native block lost feedback" + out=$(printf '%s' '{"sessionId":"native","stopHookActive":true,"stop_hook_active":false}' | GROK_WORKSPACE_ROOT="$dir" bash "$dir/bin/fm-turnend-guard-grok.sh" 2>&1); status=$? + expect_code 0 "$status" "camelCase true must win over snake_case false" + out=$(printf '%s' '{"sessionId":"native","stopHookActive":false,"stop_hook_active":true}' | GROK_WORKSPACE_ROOT="$dir" bash "$dir/bin/fm-turnend-guard-grok.sh" 2>&1); status=$? + expect_code 2 "$status" "camelCase false must win over snake_case true" + pass "fm-turnend-guard-grok: both spellings are typed and camelCase has deterministic precedence" +} + +test_grok_adapter_invalid_inputs_start_neither_path() { + local dir fakebin log payload out status + dir=$(make_primary_dir "$TMP_ROOT/grok-invalid-inputs") + : > "$dir/state/task1.meta" + fakebin=$(fm_fakebin "$TMP_ROOT/grok-invalid-bin") + log="$TMP_ROOT/grok-invalid.log" + printf '#!/usr/bin/env bash\nprintf called >> %q\n' "$log" > "$fakebin/grok" + chmod +x "$fakebin/grok" + for payload in \ + ' ' \ + '{' \ + '{"sessionId":"x","stopHookActive":"false"}' \ + '{"sessionId":"x","stop_hook_active":1}' \ + '{"sessionId":"x"}{"sessionId":"y"}' \ + '{"sessionId":"x","stopHookActive":false}{"sessionId":"y","stopHookActive":false}' \ + '{"sessionId":"x","stopHookActive":"bad","stopHookActive":false}' \ + '{"sessionId":"x","stop_hook_active":false,"stop_hook_active":false}' \ + '{"sessionId":"x","sessionId":"y"}' + do + out=$(printf '%s' "$payload" | PATH="$fakebin:$PATH" GROK_WORKSPACE_ROOT="$dir" bash "$dir/bin/fm-turnend-guard-grok.sh" 2>&1); status=$? + expect_code 0 "$status" "invalid Grok payload must conservatively allow without choosing a path" + [ -z "$out" ] || fail "invalid Grok payload produced output: $out" + done + [ ! -e "$log" ] || fail "invalid Grok payload started a resume process" + out=$(printf '%s' '{"sessionId":"x","stopHookActive":false}' | PATH="$fakebin:$PATH" GROK_WORKSPACE_ROOT="$TMP_ROOT/missing-grok-root" bash "$dir/bin/fm-turnend-guard-grok.sh" 2>&1); status=$? + expect_code 0 "$status" "missing shared-guard prerequisite must conservatively allow" + [ -z "$out" ] || fail "missing prerequisite produced output: $out" + [ ! -e "$log" ] || fail "missing prerequisite started a resume process" + pass "fm-turnend-guard-grok: malformed, invalidly typed, and missing-prerequisite payloads start neither path" +} + +test_grok_adapter_missing_jq_and_no_supervision_allow() { + local dir fakebin log out status tool tool_path + dir=$(make_primary_dir "$TMP_ROOT/grok-nojq") + : > "$dir/state/task1.meta" + fakebin=$(fm_fakebin "$TMP_ROOT/grok-nojq-bin") + log="$TMP_ROOT/grok-nojq.log" + for tool in bash cat printf; do + tool_path=$(command -v "$tool") || fail "test host must provide $tool" + ln -s "$tool_path" "$fakebin/$tool" + done + printf '#!/usr/bin/env bash\nprintf called >> %q\n' "$log" > "$fakebin/grok" + chmod +x "$fakebin/grok" + out=$(printf '%s' '{"sessionId":"x","stopHookActive":false}' | PATH="$fakebin" GROK_WORKSPACE_ROOT="$dir" bash "$dir/bin/fm-turnend-guard-grok.sh" 2>&1); status=$? + expect_code 0 "$status" "missing jq must conservatively allow" + [ -z "$out" ] || fail "missing jq produced output: $out" + [ ! -e "$log" ] || fail "missing jq started a resume process" + + dir=$(make_primary_dir "$TMP_ROOT/grok-native-no-work") + out=$(printf '%s' '{"sessionId":"x","stopHookActive":false}' | GROK_WORKSPACE_ROOT="$dir" bash "$dir/bin/fm-turnend-guard-grok.sh" 2>&1); status=$? + expect_code 0 "$status" "healthy no-supervision-needed native stop must allow" + [ -z "$out" ] || fail "no-supervision-needed native stop produced output: $out" + pass "fm-turnend-guard-grok: missing jq and no-supervision-needed stops stay silent and bounded" } test_codex_hook_uses_process_pwd_when_payload_cwd_is_outside_root() { @@ -699,23 +769,6 @@ EOF pass ".codex/hooks.json: Stop hook ignores nested git root guard scripts" } -test_opencode_plugin_forces_followup() { - local plugin content - plugin="$ROOT/.opencode/plugins/fm-primary-turnend-guard.js" - [ -f "$plugin" ] || fail "tracked OpenCode primary plugin is missing" - content=$(cat "$plugin") - assert_contains "$content" 'session.idle' "OpenCode plugin must run on session.idle" - assert_contains "$content" 'fm-turnend-guard.sh' "OpenCode plugin must invoke the shared guard" - assert_contains "$content" 'promptAsync' "OpenCode plugin must force a follow-up turn" - assert_contains "$content" 'encodeFirstmateOperationalInput' "OpenCode plugin must use the typed operational-input constructor" - assert_contains "$content" 'skipNextIdle' "OpenCode plugin must carry a loop guard" - assert_contains "$content" 'worktree' "OpenCode plugin must anchor the guard from the git worktree path" - assert_contains "$content" 'watcher cycle is missing, failed, or unhealthy' "OpenCode plugin must identify a blind turn as watcher recovery" - assert_contains "$content" 'harness recovery instruction below' "OpenCode plugin must delegate recovery action to the shared guard line" - assert_not_contains "$content" 'Resume supervision according to the session-start operating block' "OpenCode plugin must not route a blind turn through ordinary continuity" - pass ".opencode primary plugin: session.idle forces one follow-up through the shared guard" -} - test_opencode_plugin_anchors_guard_to_worktree() { local plugin parent worktree_dir wrong_dir out status plugin="$ROOT/.opencode/plugins/fm-primary-turnend-guard.js" @@ -775,30 +828,6 @@ EOF pass ".opencode primary plugin: guard path is anchored to worktree, not directory" } -test_pi_extension_forces_followup() { - local ext content - ext="$ROOT/.pi/extensions/fm-primary-turnend-guard.ts" - [ -f "$ext" ] || fail "tracked pi primary extension is missing" - content=$(cat "$ext") - assert_contains "$content" 'agent_settled' "pi extension must run after one logical agent run settles" - assert_contains "$content" 'fm-turnend-guard.sh' "pi extension must invoke the shared guard" - assert_contains "$content" 'sendUserMessage' "pi extension must force a follow-up turn" - assert_contains "$content" 'encodeFirstmateOperationalInput' "pi extension must use the typed operational-input constructor" - assert_contains "$content" 'deliverAs: "followUp"' "pi extension must queue the follow-up safely" - assert_contains "$content" 'guardFollowupActive' "pi extension must carry a logical-run loop guard" - assert_not_contains "$content" 'skipNextTurnEnd' "pi extension kept the internal-turn loop guard" - assert_contains "$content" 'watcher cycle is missing, failed, or unhealthy' "pi extension must identify a blind turn as watcher recovery" - assert_contains "$content" 'harness recovery instruction below' "pi extension must delegate recovery action to the shared guard line" - assert_not_contains "$content" 'Resume supervision according to the session-start operating block' "pi extension must not route a blind turn through ordinary continuity" - assert_contains "$content" '.pi-turnend-extension-loaded' "pi extension must write its loaded marker for session-start diagnostics" - assert_contains "$content" 'lockOwnership' "pi extension loaded marker must respect the session lock" - assert_contains "$content" 'const command = String((event.input as { command?: unknown })?.command ?? "")' "pi extension changed bash command extraction for the PreToolUse contract" - assert_contains "$content" 'runPretoolCheck(command)' "pi extension changed the PreToolUse checker invocation" - assert_contains "$content" 'return { block: true, reason:' "pi extension changed the checker exit-2 block result" - assert_not_contains "$content" 'Run bin/fm-watch-arm.sh as a background task' "pi extension must not hardcode the old watcher-arm instruction" - pass ".pi primary extension: agent_settled forces one follow-up through the shared guard" -} - test_pi_extension_injects_once_per_logical_agent_run() { local repo home ext log out status repo="$TMP_ROOT/pi-logical-run-root" @@ -1075,17 +1104,6 @@ test_hook_claude_mode_secondmate_reblocks_like_primary() { pass "fm-turnend-guard --claude: secondmate home re-blocks unclaimed and allows auto-arm-claimed stops" } -test_grok_hook_invokes_adapter() { - local settings command - settings="$ROOT/.grok/hooks/fm-primary-turnend-guard.json" - [ -f "$settings" ] || fail "tracked grok primary hook config is missing" - command=$(jq -r '.hooks.Stop[0].hooks[0].command // empty' "$settings") - [ -n "$command" ] || fail "Stop hook command is missing from grok primary hook config" - assert_contains "$command" 'GROK_WORKSPACE_ROOT' "grok hook must anchor from GROK_WORKSPACE_ROOT" - assert_contains "$command" 'fm-turnend-guard-grok.sh' "grok hook must invoke the adapter" - pass ".grok primary hook: Stop hook invokes the grok adapter" -} - test_predicate_healthy_no_inflight test_predicate_unhealthy_no_beacon test_predicate_unhealthy_stale_beacon @@ -1117,16 +1135,16 @@ test_hook_silent_without_stdin test_hook_runs_fast test_grok_adapter_forces_one_resume_when_unhealthy test_grok_adapter_loop_guard_skips_resume -test_settings_hook_uses_claude_project_dir -test_codex_hook_invokes_shared_guard +test_grok_adapter_native_false_blocks_without_resume +test_grok_adapter_native_true_allows_without_resume +test_grok_adapter_snake_case_native_and_camel_precedence +test_grok_adapter_invalid_inputs_start_neither_path +test_grok_adapter_missing_jq_and_no_supervision_allow test_codex_hook_uses_process_pwd_when_payload_cwd_is_outside_root test_codex_hook_ignores_nested_git_root_guard -test_opencode_plugin_forces_followup test_opencode_plugin_anchors_guard_to_worktree -test_pi_extension_forces_followup test_pi_extension_injects_once_per_logical_agent_run test_pi_extension_retries_after_followup_delivery_failure -test_grok_hook_invokes_adapter test_hook_claude_mode_reblocks_stop_hook_active_when_unhealthy test_hook_claude_mode_reblocks_x_mode_without_tasks test_hook_claude_mode_allows_when_autoarm_owner_alive diff --git a/tests/fm-vendor-auth-probe.test.sh b/tests/fm-vendor-auth-probe.test.sh new file mode 100755 index 0000000000..47367a3268 --- /dev/null +++ b/tests/fm-vendor-auth-probe.test.sh @@ -0,0 +1,395 @@ +#!/usr/bin/env bash +# Behavior tests for fm-vendor-auth-probe.sh - the one hard-bounded, +# non-destructive authentication probe of a named vendor CLI. +# +# Two defects this suite pins: +# +# 1. The script must render no dispatch verdict and hold no routing knowledge. +# Its predecessor resolved a candidate's credential surface from a hard-coded +# harness-to-provider table plus a `pi:<model-prefix>` source-id matcher, and +# emitted `eligible=`. A supported Pi model in a provider family with no such +# prefixed source was therefore dropped as unresolved while the family's own +# quota and credentials were healthy. The tests below prove the script now has +# no harness, model, or provider input surface at all, so no such mapping can +# influence it, and that both probe outcomes exit alike because neither is a +# verdict. +# +# 2. The captain-approved probe envelope must not depend on agent memory: fixed +# argv, stdin closed, a hard positive bound, and raw vendor output never +# printed. The fake grok records every invocation's argv and anything it can +# read from stdin, so "argv is fixed to `models`", "no login or logout", and +# "stdin stays closed" are observable facts rather than comments. +set -u + +# shellcheck source=tests/lib.sh disable=SC1091 +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" + +BASE_PATH=${FM_TEST_BASE_PATH:-/usr/bin:/bin:/usr/sbin:/sbin} +TMP_ROOT=$(fm_test_tmproot fm-vendor-auth-probe-tests) +SCRIPT="$ROOT/bin/fm-vendor-auth-probe.sh" + +# A stdin payload the script must never leak into a probed vendor CLI. +STDIN_SENTINEL='SENTINEL-STDIN-MUST-NOT-REACH-VENDOR-CLI' + +# --- fake toolchain --------------------------------------------------------- +# +# quota-axi is present on PATH and logs every invocation. The script must never +# call it: reading quota is the dispatch owner's job against one intake snapshot, +# and a probe that re-read it would reintroduce the retired coupling. +make_fakebin() { + local dir=$1 fakebin + fakebin=$(fm_fakebin "$dir") + cat > "$fakebin/quota-axi" <<'SH' +#!/usr/bin/env bash +printf '%s\n' "$*" >> "$FM_FAKE_QUOTA_LOG" +exit 0 +SH + chmod +x "$fakebin/quota-axi" + + cat > "$fakebin/grok" <<'SH' +#!/usr/bin/env bash +printf '%s\n' "$*" >> "$FM_FAKE_GROK_LOG" +# Record whatever is readable on stdin. With stdin correctly closed by the +# caller this reads EOF immediately and records nothing. +if IFS= read -r -t 2 leaked; then + printf '%s\n' "$leaked" >> "$FM_FAKE_GROK_STDIN" +fi +if [ "${1:-}" = --version ]; then + printf 'grok %s (fakebuild) [stable]\n' "${FM_FAKE_GROK_VERSION:-0.2.117}" + exit 0 +fi +case "${FM_FAKE_GROK_MODE:-authenticated}" in + authenticated) + printf '%s\n' 'You are logged in with grok.com.' + printf '\n%s\n' 'Default model: grok-4.5' + ;; + unauthenticated) + printf '%s\n' 'You are not authenticated.' + ;; + garbage) + printf '%s\n' 'Session status: unknown (0.9.0 rewrote this line)' + ;; + leading-blank) + printf '\n%s\n' 'You are logged in with grok.com.' + ;; + empty) : ;; + hang) sleep 30 ;; +esac +# grok 0.2.117 exits 0 whether or not the session authenticates; the fake keeps +# that property so a regression to exit-status reading fails here. +exit 0 +SH + chmod +x "$fakebin/grok" + printf '%s\n' "$fakebin" +} + +# run_probe <case> [args...] -- [env assignments...] +# Sets RUN_LINE, RUN_RC, RUN_GROK_LOG, RUN_GROK_STDIN, RUN_QUOTA_LOG in the +# caller's shell, so it must not be invoked in a command substitution. +RUN_LINE= +RUN_RC=0 +RUN_GROK_LOG= +RUN_GROK_STDIN= +RUN_QUOTA_LOG= +run_probe() { + local case_name=$1 + shift + local case_dir fakebin out rc=0 arg + local -a script_args=() env_pairs=() + case_dir="$TMP_ROOT/$case_name" + mkdir -p "$case_dir" + fakebin=$(make_fakebin "$case_dir") + RUN_GROK_LOG="$case_dir/grok.log" + RUN_GROK_STDIN="$case_dir/grok.stdin" + RUN_QUOTA_LOG="$case_dir/quota.log" + : > "$RUN_GROK_LOG" + : > "$RUN_GROK_STDIN" + : > "$RUN_QUOTA_LOG" + local seen_separator=0 + for arg in "$@"; do + if [ "$seen_separator" -eq 0 ] && [ "$arg" = -- ]; then + seen_separator=1 + continue + fi + if [ "$seen_separator" -eq 0 ]; then + script_args+=("$arg") + else + env_pairs+=("$arg") + fi + done + out=$(env "PATH=$fakebin:$BASE_PATH" \ + "FM_FAKE_GROK_LOG=$RUN_GROK_LOG" \ + "FM_FAKE_GROK_STDIN=$RUN_GROK_STDIN" \ + "FM_FAKE_QUOTA_LOG=$RUN_QUOTA_LOG" \ + "${env_pairs[@]+"${env_pairs[@]}"}" \ + "$SCRIPT" "${script_args[@]+"${script_args[@]}"}" \ + <<<"$STDIN_SENTINEL" 2>/dev/null) || rc=$? + RUN_RC=$rc + RUN_LINE=$out +} + +field() { # <line> <key> + printf '%s\n' "$1" | tr ' ' '\n' | sed -n "s/^$2=//p" +} + +assert_field() { # <line> <key> <expected> <label> + local got + got=$(field "$1" "$2") + [ "$got" = "$3" ] || fail "$4: expected $2=$3, got $2=${got:-<absent>}"$'\n'"--- line ---"$'\n'"$1" +} + +# Every recorded grok invocation must be one of the two fixed, non-destructive +# argv forms. A login, logout, or bare interactive launch fails here. +assert_grok_argv_safe() { # <label> + local line + while IFS= read -r line; do + [ -n "$line" ] || continue + case "$line" in + models|--version) : ;; + *) fail "$1: unexpected Grok CLI invocation 'grok $line'" ;; + esac + done < "$RUN_GROK_LOG" +} + +assert_grok_never_ran() { # <label> + [ ! -s "$RUN_GROK_LOG" ] \ + || fail "$1: no vendor CLI may run, but grok was invoked with: $(tr '\n' '|' < "$RUN_GROK_LOG")" +} + +assert_quota_never_read() { # <label> + [ ! -s "$RUN_QUOTA_LOG" ] \ + || fail "$1: the probe must never call quota-axi, but it ran: $(tr '\n' '|' < "$RUN_QUOTA_LOG")" +} + +# --- the retired dispatch coupling ------------------------------------------ + +# The core retirement: the probe carries no candidate identity, so no +# harness-to-provider table, model prefix matcher, or provider-family mapping can +# exist behind it. Every shape of candidate input is a usage error, and none of +# them reaches a vendor CLI. +test_probe_accepts_no_candidate_identity() { + local label + local -a args + # Both shapes matter. Without a probe name, candidate identity must not stand + # in for one. WITH a valid probe name, candidate identity must still be + # refused rather than quietly accepted and ignored - a silently tolerated + # `--model` is exactly the seam a routing mapping would grow back through. + for label in harness-flag model-flag provider-flag tuple positional-model \ + probe-with-harness probe-with-model probe-with-tuple probe-with-provider; do + case "$label" in + harness-flag) args=(--harness pi) ;; + model-flag) args=(--model openai-codex/gpt-5.6-terra) ;; + provider-flag) args=(--provider codex) ;; + tuple) args=(--harness pi --model openai-codex/gpt-5.6-terra) ;; + positional-model) args=(grok openai-codex/gpt-5.6-terra) ;; + probe-with-harness) args=(grok --harness pi) ;; + probe-with-model) args=(grok --model openai-codex/gpt-5.6-terra) ;; + probe-with-tuple) args=(grok --harness pi --model openai-codex/gpt-5.6-terra) ;; + probe-with-provider) args=(grok --provider codex) ;; + esac + run_probe "identity-$label" "${args[@]}" + expect_code 2 "$RUN_RC" "$label must be a usage error, not a candidate verdict" + [ -z "$RUN_LINE" ] || fail "$label must not emit a fact line: $RUN_LINE" + assert_grok_never_ran "identity-$label" + assert_quota_never_read "identity-$label" + done + pass "the probe accepts no harness, model, or provider and so can hold no routing mapping" +} + +# The retired script read quota to decide eligibility. This one must not, so an +# intake keeps exactly one snapshot and the probe cannot re-derive a route. +test_probe_never_reads_quota() { + local mode + for mode in authenticated unauthenticated; do + run_probe "no-quota-$mode" grok -- "FM_FAKE_GROK_MODE=$mode" + assert_quota_never_read "no-quota-$mode" + done + pass "the probe never reads quota, leaving one intake snapshot to the dispatch owner" +} + +# Neither outcome is a verdict, so neither may be encoded in the exit status. A +# caller that branched on the exit status would be reinventing the eligibility +# gate this script was narrowed to remove. +test_probe_result_is_never_an_exit_status_verdict() { + local mode + for mode in authenticated unauthenticated garbage empty; do + run_probe "rc-$mode" grok -- "FM_FAKE_GROK_MODE=$mode" + expect_code 0 "$RUN_RC" "probe result '$mode' must not be encoded in the exit status" + [ -n "$RUN_LINE" ] || fail "probe result '$mode' must still print its fact line" + done + pass "every probe result exits alike because the script renders no verdict" +} + +test_unregistered_probe_is_a_usage_error() { + local name + for name in openai codex claude pi ''; do + if [ -z "$name" ]; then + run_probe "unregistered-empty" + else + run_probe "unregistered-$name" "$name" + fi + expect_code 2 "$RUN_RC" "an unregistered probe name must be a usage error" + assert_grok_never_ran "unregistered-${name:-empty}" + done + pass "only a registered probe name runs, and an unregistered one is a usage error" +} + +# --- probe classification --------------------------------------------------- + +test_authenticated_session_is_reported() { + run_probe authenticated grok -- "FM_FAKE_GROK_MODE=authenticated" + expect_code 0 "$RUN_RC" "a completed probe prints its fact" + assert_field "$RUN_LINE" probe grok "the probe name must be echoed" + assert_field "$RUN_LINE" status authenticated "an authenticated first line must be recognized" + assert_grok_argv_safe "authenticated case" + pass "an authenticated vendor session is reported as ground truth" +} + +test_unauthenticated_session_is_reported() { + run_probe unauthenticated grok -- "FM_FAKE_GROK_MODE=unauthenticated" + assert_field "$RUN_LINE" status unauthenticated "an unauthenticated first line must be recognized" + assert_grok_argv_safe "unauthenticated case" + pass "an unauthenticated vendor session is reported as ground truth" +} + +# The exit status is deliberately not the verdict, so a rewritten status line +# must read as indeterminate rather than as a successful authentication. +test_unrecognized_output_is_indeterminate() { + local mode + for mode in garbage leading-blank empty; do + run_probe "indeterminate-$mode" grok -- "FM_FAKE_GROK_MODE=$mode" + assert_field "$RUN_LINE" status indeterminate "'$mode' output must never read as authenticated" + done + pass "unrecognized, blank-led, and silent probe output is indeterminate, never authenticated" +} + +test_missing_vendor_cli_is_reported_not_assumed() { + local case_dir fakebin line rc=0 + case_dir="$TMP_ROOT/grok-absent" + mkdir -p "$case_dir" + fakebin=$(make_fakebin "$case_dir") + rm -f "$fakebin/grok" + line=$(env "PATH=$fakebin:$BASE_PATH" \ + "FM_FAKE_QUOTA_LOG=$case_dir/quota.log" \ + "$SCRIPT" grok </dev/null 2>/dev/null) || rc=$? + expect_code 0 "$rc" "an absent vendor CLI is a fact, not a usage error" + assert_field "$line" status unavailable "an absent probe command must be reported" + assert_field "$line" version none "an absent CLI has no version to report" + assert_field "$line" versionVerified none "an absent CLI cannot be version-verified" + pass "an absent vendor CLI is reported rather than assumed authenticated" +} + +# --- the bounded, non-destructive envelope ---------------------------------- + +test_hanging_probe_is_bounded_and_reported() { + local started finished + started=$(date +%s) + run_probe grok-hang grok -- "FM_FAKE_GROK_MODE=hang" "FM_VENDOR_AUTH_PROBE_TIMEOUT=2" + finished=$(date +%s) + assert_field "$RUN_LINE" status timeout "a hit bound must be reported as a timeout" + [ $((finished - started)) -lt 25 ] \ + || fail "the probe was not bounded: took $((finished - started))s against a 2s bound" + pass "a hanging vendor CLI is hard-bounded, reported, and cannot wedge an intake" +} + +# `timeout 0` and the Perl fallback's `alarm 0` both mean "no deadline", so a +# zero bound passed through would silently remove the hard bound entirely. The +# fake hangs for 30s, longer than the 20s default it must fall back to, so the +# two outcomes are distinguishable. +test_zero_bound_falls_back_to_a_real_bound() { + local started finished value + for value in 0 00; do + started=$(date +%s) + run_probe "bound-zero-$value" grok -- "FM_FAKE_GROK_MODE=hang" "FM_VENDOR_AUTH_PROBE_TIMEOUT=$value" + finished=$(date +%s) + assert_field "$RUN_LINE" status timeout "a zero bound must fall back to the default bound, not to no bound" + [ $((finished - started)) -lt 28 ] \ + || fail "a zero bound removed the hard bound: took $((finished - started))s" + done + pass "zero and all-zero bounds fall back to the default instead of removing the hard bound" +} + +# A bogus bound must be replaced, not forwarded: `timeout abc` and `timeout -1` +# fail outright, which would turn a healthy probe into a false indeterminate. +test_malformed_bound_is_replaced_not_forwarded() { + local value + for value in -1 abc 1.5 ' '; do + run_probe "bound-${value// /space}" grok -- "FM_FAKE_GROK_MODE=authenticated" "FM_VENDOR_AUTH_PROBE_TIMEOUT=$value" + assert_field "$RUN_LINE" status authenticated "bound '$value' must be replaced, not forwarded to the bounding command" + done + pass "a malformed bound is replaced by the default rather than forwarded" +} + +test_probe_never_inherits_caller_stdin() { + run_probe grok-stdin grok -- "FM_FAKE_GROK_MODE=authenticated" + [ -n "$RUN_LINE" ] || fail "expected a fact line" + [ ! -s "$RUN_GROK_STDIN" ] \ + || fail "the probe inherited caller stdin: $(cat "$RUN_GROK_STDIN")" + pass "the bounded probe runs with stdin closed and cannot read caller input" +} + +test_probe_argv_is_fixed_and_non_destructive() { + local mode + for mode in authenticated unauthenticated garbage; do + run_probe "argv-$mode" grok -- "FM_FAKE_GROK_MODE=$mode" + assert_grok_argv_safe "argv-$mode" + [ "$(grep -c . "$RUN_GROK_LOG")" -eq 2 ] \ + || fail "argv-$mode: expected exactly one --version and one models call, got: $(tr '\n' '|' < "$RUN_GROK_LOG")" + done + pass "the vendor CLI is invoked only through its two fixed, non-destructive argv forms" +} + +test_fact_line_carries_no_vendor_output_or_credential_material() { + run_probe sanitized grok -- "FM_FAKE_GROK_MODE=authenticated" + assert_not_contains "$RUN_LINE" "You are logged in" "the fact line must not echo raw vendor output" + assert_not_contains "$RUN_LINE" "grok.com" "the fact line must not echo raw vendor output" + assert_not_contains "$RUN_LINE" "auth.json" "the fact line must not name a credential path" + assert_not_contains "$RUN_LINE" "$STDIN_SENTINEL" "the fact line must not echo caller stdin" + case "$RUN_LINE" in + *$'\n'*) fail "the fact line must be exactly one line" ;; + esac + pass "the fact line is one sanitized line with no raw vendor output or credential material" +} + +# --- version disclosure ----------------------------------------------------- + +# The discriminator strings are un-owned vendor UI text. A version change does +# not silently invalidate the fact, but it is disclosed so it can be re-verified. +test_probe_version_change_is_disclosed() { + run_probe version-drift grok -- "FM_FAKE_GROK_MODE=authenticated" "FM_FAKE_GROK_VERSION=0.9.0" + assert_field "$RUN_LINE" version 0.9.0 "the probed CLI version must be recorded" + assert_field "$RUN_LINE" versionVerified no "an unverified version must be disclosed" + pass "a vendor CLI version change is recorded and disclosed for re-verification" +} + +test_probe_version_match_is_recorded() { + run_probe version-pinned grok -- "FM_FAKE_GROK_MODE=authenticated" + assert_field "$RUN_LINE" versionVerified yes "the pinned verified version must be recognized" + pass "the pinned verified vendor version is recognized" +} + +test_help_succeeds_and_names_the_registered_probes() { + local out rc=0 + out=$("$SCRIPT" --help 2>&1) || rc=$? + expect_code 0 "$rc" "--help must succeed" + assert_contains "$out" "grok" "--help must name the registered probes" + pass "--help succeeds and names the registered probes" +} + +test_probe_accepts_no_candidate_identity +test_probe_never_reads_quota +test_probe_result_is_never_an_exit_status_verdict +test_unregistered_probe_is_a_usage_error +test_authenticated_session_is_reported +test_unauthenticated_session_is_reported +test_unrecognized_output_is_indeterminate +test_missing_vendor_cli_is_reported_not_assumed +test_hanging_probe_is_bounded_and_reported +test_zero_bound_falls_back_to_a_real_bound +test_malformed_bound_is_replaced_not_forwarded +test_probe_never_inherits_caller_stdin +test_probe_argv_is_fixed_and_non_destructive +test_fact_line_carries_no_vendor_output_or_credential_material +test_probe_version_change_is_disclosed +test_probe_version_match_is_recorded +test_help_succeeds_and_names_the_registered_probes diff --git a/tests/fm-wake-daemon-lifecycle-e2e.test.sh b/tests/fm-wake-daemon-lifecycle-e2e.test.sh index 639db67b5a..42f879080b 100755 --- a/tests/fm-wake-daemon-lifecycle-e2e.test.sh +++ b/tests/fm-wake-daemon-lifecycle-e2e.test.sh @@ -106,7 +106,7 @@ test_routine_then_terminal_after_restart() { # --- Phase 2: stale working-pane transient -> persistent -> resumed ---------- test_stale_pane_transient_persistent_resume() { - local dir state fakebin win key + local dir state fakebin win key resumed_gen dir=$(make_supercase wd-stale) state="$dir/state" fakebin="$dir/fakebin" @@ -128,15 +128,23 @@ test_stale_pane_transient_persistent_resume() { echo $(( $(date +%s) - 500 )) > "$state/.subsuper-stale-$key" : > "$state/.subsuper-escalations" 2>/dev/null || true PATH="$fakebin:$PATH" FM_FAKE_TMUX_WINDOW="$win" FM_FAKE_TMUX_CAPTURE="$dir/pane.txt" \ - FM_STATE_OVERRIDE="$state" FM_STALE_ESCALATE_SECS=240 housekeeping "$state" + FM_STATE_OVERRIDE="$state" FM_STALE_ESCALATE_SECS=240 housekeeping "$state" \ + 2>"$dir/housekeeping.err" + [ ! -s "$dir/housekeeping.err" ] \ + || fail "missing task metadata leaked a raw read error: $(cat "$dir/housekeeping.err")" [ -s "$state/.subsuper-escalations" ] || fail "persistent stale did not escalate" [ ! -e "$state/.subsuper-stale-$key" ] || fail "stale marker not cleared after escalation" - # Resumed: a fresh transient marker but the pane is now busy -> housekeeping - # clears the marker without escalating. + # Resumed: a fresh transient marker but the crew is provably working again -> + # housekeeping clears the marker without escalating. The proof is the crew's + # own semantic busy-state record (bin/fm-busy-lib.sh), not rendered pane text. stale_marker_record "$win" "$state" echo $(( $(date +%s) - 500 )) > "$state/.subsuper-stale-$key" printf 'Working...\n' > "$dir/pane.txt" + fm_write_meta "$state/stale-w2.meta" "window=$win" "worktree=$dir/wt" "kind=ship" "harness=pi" + resumed_gen=$("$ROOT/bin/fm-busy-event.sh" arm "$state" stale-w2) + "$ROOT/bin/fm-busy-event.sh" apply "$state" stale-w2 busy --gen "$resumed_gen" \ + --source pi-ext --event agent-start : > "$state/.subsuper-escalations" PATH="$fakebin:$PATH" FM_FAKE_TMUX_WINDOW="$win" FM_FAKE_TMUX_CAPTURE="$dir/pane.txt" \ FM_STATE_OVERRIDE="$state" FM_STALE_ESCALATE_SECS=240 housekeeping "$state" diff --git a/tests/fm-watch-triage.test.sh b/tests/fm-watch-triage.test.sh index 19dae9bba3..c6d7928bb7 100755 --- a/tests/fm-watch-triage.test.sh +++ b/tests/fm-watch-triage.test.sh @@ -70,12 +70,45 @@ file_mtime() { if [ "$(uname)" = Darwin ]; then stat -f %m "$1" 2>/dev/null; else stat -c %Y "$1" 2>/dev/null; fi } +# Set <file>'s mtime to exactly <epoch> seconds, for aging a busy-turn marker by +# a precise amount (touch -t takes a local-time stamp, not an epoch, on both +# platforms, so convert via BSD `date -r` or GNU `date -d @`). +set_mtime() { # <epoch> <file> + local epoch=$1 f=$2 stamp + if stamp=$(date -r "$epoch" +%Y%m%d%H%M.%S 2>/dev/null); then + touch -t "$stamp" "$f" + else + stamp=$(date -d "@$epoch" +%Y%m%d%H%M.%S) + touch -t "$stamp" "$f" + fi +} + # Signature a primed .seen-* marker must hold so the per-poll signal scan does not # fire on a pre-existing status (mirrors fm-watch.sh's stat_sig exactly). seen_sig() { if [ "$(uname)" = Darwin ]; then stat -f '%z:%Fm' "$1" 2>/dev/null; else stat -c '%s:%Y' "$1" 2>/dev/null; fi } +# Prime <file>'s .seen-* suppressor to its CURRENT signature, so the per-poll +# no-verb signal scan (which watches every *.turn-ended for a size:mtime change) +# treats a just-created or just-backdated turn-ended marker as already seen. +# Busy-turn-age fixtures create/backdate turn-ended directly (there is no real +# harness touching it), so without this the marker's own first sighting would +# fire an unrelated "signal:" wake and mask the busy-turn-age assertion under +# test. Call again after any further touch/set_mtime on the same file. +prime_turnend_seen() { # <file> + local f=$1 base + base=$(basename "$f" | tr '.' '_') + printf '%s' "$(seen_sig "$f")" > "$(dirname "$f")/.seen-$base" +} + +record_pi_busy() { # <state-dir> <id> + local state=$1 id=$2 gen + gen=$("$ROOT/bin/fm-busy-event.sh" arm "$state" "$id") + "$ROOT/bin/fm-busy-event.sh" apply "$state" "$id" busy --gen "$gen" \ + --source pi-ext --event agent-start +} + reap() { kill "$1" 2>/dev/null || true; wait "$1" 2>/dev/null || true; } # --- pure classifier predicates (fm-classify-lib.sh) ------------------------ @@ -1055,6 +1088,255 @@ test_wedge_escalation_resets_when_pane_becomes_active() { pass "a pane becoming active again resets the consecutive wedge-escalation counter" } +# --- busy pane duration bound: a completed-turn age gate on top of busy ----- +# 2026-07 hibit-agent-focus-nonsteal-r1 incident: a busy pane (herdr "working" +# and/or the harness's rendered busy footer) is unconditional, unbounded proof +# of liveness in every existing classifier, so a genuinely hung foreground tool +# call behind a busy signature ran undetected for 25h. BUSY_TURN_MAX_SECS bounds +# how long a busy pane may run with no completed turn (state/<id>.turn-ended, or +# the task's spawn record before any turn completes); past the bound the SAME +# wedge_timer_check already used for a provably-working non-busy stale takes +# over, so escalation reuses the identical stale reason, escalation counter, and +# demand-deep-inspection marker - never an automatic interrupt or restart. + +test_busy_pane_below_turn_age_bound_is_absorbed() { + local dir state fakebin out capture_file window key sig pid + dir=$(make_case busy-below-turn-age); state="$dir/state"; fakebin="$dir/fakebin" + out="$dir/watch.out"; capture_file="$dir/pane.txt"; window="test:fm-busy-fresh" + printf 'Working... (12.3s)' > "$capture_file" + printf 'window=%s\nkind=ship\nharness=pi\n' "$window" > "$state/busy-fresh.meta" + record_pi_busy "$state" busy-fresh + printf 'working: setup complete\n' > "$state/busy-fresh.status" + sig=$(seen_sig "$state/busy-fresh.status"); printf '%s' "$sig" > "$state/.seen-busy-fresh_status" + key=$(printf '%s' "$window" | tr ':/.' '___') + touch "$state/busy-fresh.turn-ended" + prime_turnend_seen "$state/busy-fresh.turn-ended" + + PATH="$fakebin:$PATH" FM_FAKE_TMUX_WINDOW="$window" FM_FAKE_TMUX_CAPTURE="$capture_file" \ + FM_STATE_OVERRIDE="$state" FM_BUSY_TURN_MAX_SECS=999 FM_STALE_ESCALATE_SECS=999 FM_POLL=1 FM_SIGNAL_GRACE=1 \ + FM_CHECK_INTERVAL=999999 FM_HEARTBEAT=999999 "$WATCH" > "$out" & + pid=$! + if ! wait_live "$pid" 30; then + reap "$pid"; fail "a busy pane below the turn-age bound was escalated: $(cat "$out")" + fi + [ ! -s "$out" ] || fail "a busy pane below the turn-age bound printed a wake reason" + [ ! -e "$state/.stale-since-$key" ] || fail "a busy pane below the turn-age bound started a wedge timer" + reap "$pid" + pass "a busy worker below the turn-age bound remains working with no escalation" +} + +test_busy_pane_stable_hash_escalates_past_turn_age_bound() { + local dir state fakebin out capture_file window key pane_hash sig pid + dir=$(make_case busy-stable-hash-turn-age); state="$dir/state"; fakebin="$dir/fakebin" + out="$dir/watch.out"; capture_file="$dir/pane.txt"; window="test:fm-busy-stable" + printf 'Working...' > "$capture_file" + printf 'window=%s\nkind=ship\nharness=pi\n' "$window" > "$state/busy-stable.meta" + record_pi_busy "$state" busy-stable + printf 'working: setup complete\n' > "$state/busy-stable.status" + sig=$(seen_sig "$state/busy-stable.status"); printf '%s' "$sig" > "$state/.seen-busy-stable_status" + key=$(printf '%s' "$window" | tr ':/.' '___') + pane_hash=$(hash_text "Working...") + printf '%s' "$pane_hash" > "$state/.hash-$key" + printf '1\n' > "$state/.count-$key" + # No completed turn ever recorded for this task: age the spawn record itself. + touch -t 200001010000 "$state/busy-stable.meta" + + # Phase A: past the bound, the stable-hash busy pane is absorbed but starts + # the wedge timer (mirrors the existing provably-working-stale Phase A/B). + PATH="$fakebin:$PATH" FM_FAKE_TMUX_WINDOW="$window" FM_FAKE_TMUX_CAPTURE="$capture_file" \ + FM_STATE_OVERRIDE="$state" FM_BUSY_TURN_MAX_SECS=1 FM_STALE_ESCALATE_SECS=999 FM_POLL=1 FM_SIGNAL_GRACE=1 \ + FM_CHECK_INTERVAL=999999 FM_HEARTBEAT=999999 "$WATCH" > "$out" & + pid=$! + if ! wait_live "$pid" 30; then + reap "$pid"; fail "a stable-hash busy pane past the turn-age bound escalated before the wedge threshold: $(cat "$out")" + fi + [ -s "$state/.stale-since-$key" ] || fail "a stable-hash busy pane past the turn-age bound did not start a wedge timer" + reap "$pid" + + # Phase B: backdate the wedge timer past the threshold; the next poll escalates. + echo $(( $(date +%s) - 500 )) > "$state/.stale-since-$key" + : > "$out" + PATH="$fakebin:$PATH" FM_FAKE_TMUX_WINDOW="$window" FM_FAKE_TMUX_CAPTURE="$capture_file" \ + FM_STATE_OVERRIDE="$state" FM_BUSY_TURN_MAX_SECS=1 FM_STALE_ESCALATE_SECS=240 FM_POLL=1 FM_SIGNAL_GRACE=1 \ + FM_CHECK_INTERVAL=999999 FM_HEARTBEAT=999999 "$WATCH" > "$out" & + pid=$! + wait_for_exit "$pid" 40 || fail "a stable-hash busy pane did not wedge-escalate past the turn-age bound" + grep -F "stale: $window" "$out" >/dev/null || fail "busy turn-age escalation did not print the stale wake" + grep -F "possible wedge" "$out" >/dev/null || fail "busy turn-age escalation did not flag a possible wedge" + pass "a busy worker with a stable pane hash still escalates once its completed-turn age reaches the bound" +} + +# Regression fixture for the incident's actual masking condition: Pi's rendered +# elapsed-time footer changes every poll, so the pane hash never repeats and the +# watcher always takes the "new hash" branch, never the stable-hash one above. +test_busy_pane_changing_hash_escalates_past_turn_age_bound() { + local dir state fakebin out capture_file window key pid + dir=$(make_case busy-changing-hash-turn-age); state="$dir/state"; fakebin="$dir/fakebin" + out="$dir/watch.out"; capture_file="$dir/pane.txt"; window="test:fm-busy-ticking" + printf 'Working... (3600.1s)' > "$capture_file" + printf 'window=%s\nkind=ship\nharness=pi\n' "$window" > "$state/busy-ticking.meta" + record_pi_busy "$state" busy-ticking + printf 'working: setup complete\n' > "$state/busy-ticking.status" + sig=$(seen_sig "$state/busy-ticking.status"); printf '%s' "$sig" > "$state/.seen-busy-ticking_status" + key=$(printf '%s' "$window" | tr ':/.' '___') + touch -t 200001010000 "$state/busy-ticking.meta" + # No pre-seeded .hash-<key>: with a real ticking elapsed footer, every poll + # lands here (h != prev) - the reproduction's actual masking condition. + + # Phase A: first sight past the bound absorbs and starts the wedge timer, + # without ever needing the "genuinely stale" hash-match path. + PATH="$fakebin:$PATH" FM_FAKE_TMUX_WINDOW="$window" FM_FAKE_TMUX_CAPTURE="$capture_file" \ + FM_STATE_OVERRIDE="$state" FM_BUSY_TURN_MAX_SECS=1 FM_STALE_ESCALATE_SECS=999 FM_POLL=1 FM_SIGNAL_GRACE=1 \ + FM_CHECK_INTERVAL=999999 FM_HEARTBEAT=999999 "$WATCH" > "$out" & + pid=$! + if ! wait_live "$pid" 30; then + reap "$pid"; fail "a changing-hash busy pane past the turn-age bound escalated before the wedge threshold: $(cat "$out")" + fi + [ -s "$state/.stale-since-$key" ] || fail "a changing-hash busy pane past the turn-age bound did not start a wedge timer" + reap "$pid" + + # Phase B: another tick (still a fresh, never-before-seen hash) plus a + # backdated wedge timer escalates exactly as the stable-hash case does. + printf 'Working... (3601.2s)' > "$capture_file" + echo $(( $(date +%s) - 500 )) > "$state/.stale-since-$key" + : > "$out" + PATH="$fakebin:$PATH" FM_FAKE_TMUX_WINDOW="$window" FM_FAKE_TMUX_CAPTURE="$capture_file" \ + FM_STATE_OVERRIDE="$state" FM_BUSY_TURN_MAX_SECS=1 FM_STALE_ESCALATE_SECS=240 FM_POLL=1 FM_SIGNAL_GRACE=1 \ + FM_CHECK_INTERVAL=999999 FM_HEARTBEAT=999999 "$WATCH" > "$out" & + pid=$! + wait_for_exit "$pid" 40 || fail "a changing-hash busy pane did not wedge-escalate past the turn-age bound" + grep -F "stale: $window" "$out" >/dev/null || fail "busy turn-age escalation (changing hash) did not print the stale wake" + grep -F "possible wedge" "$out" >/dev/null || fail "busy turn-age escalation (changing hash) did not flag a possible wedge" + pass "a busy worker whose pane hash changes every poll still escalates once its completed-turn age reaches the bound" +} + +test_busy_pane_turn_end_touch_resets_age() { + local dir state fakebin out capture_file window key pane_hash sig pid + dir=$(make_case busy-turn-end-resets-age); state="$dir/state"; fakebin="$dir/fakebin" + out="$dir/watch.out"; capture_file="$dir/pane.txt"; window="test:fm-busy-reset" + printf 'Working...' > "$capture_file" + printf 'window=%s\nkind=ship\nharness=pi\n' "$window" > "$state/busy-reset.meta" + record_pi_busy "$state" busy-reset + printf 'working: setup complete\n' > "$state/busy-reset.status" + sig=$(seen_sig "$state/busy-reset.status"); printf '%s' "$sig" > "$state/.seen-busy-reset_status" + key=$(printf '%s' "$window" | tr ':/.' '___') + pane_hash=$(hash_text "Working...") + printf '%s' "$pane_hash" > "$state/.hash-$key" + printf '1\n' > "$state/.count-$key" + # A wedge is already mid-escalation, as if several over-age polls already ran. + echo $(( $(date +%s) - 500 )) > "$state/.stale-since-$key" + printf '1\n' > "$state/.wedge-escalations-$key" + # The worker's most recent turn just completed: touching turn-ended resets age. + touch "$state/busy-reset.turn-ended" + prime_turnend_seen "$state/busy-reset.turn-ended" + + PATH="$fakebin:$PATH" FM_FAKE_TMUX_WINDOW="$window" FM_FAKE_TMUX_CAPTURE="$capture_file" \ + FM_STATE_OVERRIDE="$state" FM_BUSY_TURN_MAX_SECS=3600 FM_STALE_ESCALATE_SECS=240 FM_POLL=1 FM_SIGNAL_GRACE=1 \ + FM_CHECK_INTERVAL=999999 FM_HEARTBEAT=999999 "$WATCH" > "$out" & + pid=$! + if ! wait_live "$pid" 30; then + reap "$pid"; fail "a freshly completed turn on a busy pane was still escalated: $(cat "$out")" + fi + [ ! -s "$out" ] || fail "a freshly completed turn on a busy pane printed a wake reason" + [ ! -e "$state/.stale-since-$key" ] || fail "a freshly completed turn did not clear the wedge timer" + [ ! -e "$state/.wedge-escalations-$key" ] || fail "a freshly completed turn did not clear the escalation counter" + reap "$pid" + pass "touching a busy worker's completed-turn marker resets the age and prevents an old-age escalation" +} + +test_busy_pane_repeated_escalation_reaches_demand_deep_inspection() { + local dir state fakebin out capture_file window key pane_hash sig pid n + dir=$(make_case busy-turn-age-demand-inspect); state="$dir/state"; fakebin="$dir/fakebin" + out="$dir/watch.out"; capture_file="$dir/pane.txt"; window="test:fm-busy-demand-inspect" + printf 'Working...' > "$capture_file" + printf 'window=%s\nkind=ship\nharness=pi\n' "$window" > "$state/busy-demand.meta" + record_pi_busy "$state" busy-demand + printf 'working: setup complete\n' > "$state/busy-demand.status" + sig=$(seen_sig "$state/busy-demand.status"); printf '%s' "$sig" > "$state/.seen-busy-demand_status" + key=$(printf '%s' "$window" | tr ':/.' '___') + pane_hash=$(hash_text "Working...") + printf '%s' "$pane_hash" > "$state/.hash-$key" + printf '1\n' > "$state/.count-$key" + touch -t 200001010000 "$state/busy-demand.turn-ended" + prime_turnend_seen "$state/busy-demand.turn-ended" + + # Priming round: first sighting past the turn-age bound absorbs and starts + # the wedge timer, mirroring the existing provably-working wedge tests. + PATH="$fakebin:$PATH" FM_FAKE_TMUX_WINDOW="$window" FM_FAKE_TMUX_CAPTURE="$capture_file" \ + FM_STATE_OVERRIDE="$state" FM_BUSY_TURN_MAX_SECS=1 FM_STALE_ESCALATE_SECS=999 FM_POLL=1 FM_SIGNAL_GRACE=1 \ + FM_CHECK_INTERVAL=999999 FM_HEARTBEAT=999999 "$WATCH" > "$out" & + pid=$! + if ! wait_live "$pid" 30; then + reap "$pid"; fail "priming round for busy turn-age escalation was not absorbed: $(cat "$out")" + fi + reap "$pid" + + n=1 + while [ "$n" -le 3 ]; do + echo $(( $(date +%s) - 500 )) > "$state/.stale-since-$key" + : > "$out" + PATH="$fakebin:$PATH" FM_FAKE_TMUX_WINDOW="$window" FM_FAKE_TMUX_CAPTURE="$capture_file" \ + FM_STATE_OVERRIDE="$state" FM_BUSY_TURN_MAX_SECS=1 FM_STALE_ESCALATE_SECS=240 FM_POLL=1 FM_SIGNAL_GRACE=1 \ + FM_CHECK_INTERVAL=999999 FM_HEARTBEAT=999999 "$WATCH" > "$out" & + pid=$! + wait_for_exit "$pid" 40 || fail "busy turn-age escalation round $n did not escalate: $(cat "$out")" + grep -F "escalation $n" "$out" >/dev/null || fail "busy turn-age round $n did not report escalation count $n: $(cat "$out")" + if [ "$n" -lt 3 ]; then + grep -F "demand-deep-inspection" "$out" >/dev/null && fail "busy turn-age round $n escalated to demand-deep-inspection before the threshold: $(cat "$out")" + else + grep -F "demand-deep-inspection" "$out" >/dev/null || fail "busy turn-age round $n (threshold) did not demand deep inspection: $(cat "$out")" + fi + n=$((n + 1)) + done + [ "$(cat "$state/.wedge-escalations-$key" 2>/dev/null || echo 0)" = 3 ] || fail "busy turn-age escalation counter did not persist across consecutive rounds" + pass "repeated busy turn-age escalations reuse the existing escalation counter and demand deep inspection at the threshold" +} + +# Behavioral proof that the production default (no FM_BUSY_TURN_MAX_SECS override +# anywhere in this env) is 3600s: a completed turn 5 minutes old must not start a +# wedge timer, while one 66 minutes old must - bracketing the default around 3600 +# without waiting a literal hour. +test_busy_pane_default_turn_age_bound_is_3600s() { + local dir state fakebin out capture_file window key pane_hash sig pid + dir=$(make_case busy-default-turn-age); state="$dir/state"; fakebin="$dir/fakebin" + out="$dir/watch.out"; capture_file="$dir/pane.txt"; window="test:fm-busy-default" + printf 'Working...' > "$capture_file" + printf 'window=%s\nkind=ship\nharness=pi\n' "$window" > "$state/busy-default.meta" + record_pi_busy "$state" busy-default + printf 'working: setup complete\n' > "$state/busy-default.status" + sig=$(seen_sig "$state/busy-default.status"); printf '%s' "$sig" > "$state/.seen-busy-default_status" + key=$(printf '%s' "$window" | tr ':/.' '___') + pane_hash=$(hash_text "Working...") + printf '%s' "$pane_hash" > "$state/.hash-$key" + printf '1\n' > "$state/.count-$key" + + set_mtime $(( $(date +%s) - 300 )) "$state/busy-default.turn-ended" + prime_turnend_seen "$state/busy-default.turn-ended" + PATH="$fakebin:$PATH" FM_FAKE_TMUX_WINDOW="$window" FM_FAKE_TMUX_CAPTURE="$capture_file" \ + FM_STATE_OVERRIDE="$state" FM_STALE_ESCALATE_SECS=999 FM_POLL=1 FM_SIGNAL_GRACE=1 \ + FM_CHECK_INTERVAL=999999 FM_HEARTBEAT=999999 "$WATCH" > "$out" & + pid=$! + if ! wait_live "$pid" 30; then + reap "$pid"; fail "a 5-minute-old completed turn tripped the default busy-turn-age bound: $(cat "$out")" + fi + [ ! -e "$state/.stale-since-$key" ] || fail "a 5-minute-old completed turn started a wedge timer under the default bound" + reap "$pid" + + set_mtime $(( $(date +%s) - 4000 )) "$state/busy-default.turn-ended" + prime_turnend_seen "$state/busy-default.turn-ended" + : > "$out" + PATH="$fakebin:$PATH" FM_FAKE_TMUX_WINDOW="$window" FM_FAKE_TMUX_CAPTURE="$capture_file" \ + FM_STATE_OVERRIDE="$state" FM_STALE_ESCALATE_SECS=999 FM_POLL=1 FM_SIGNAL_GRACE=1 \ + FM_CHECK_INTERVAL=999999 FM_HEARTBEAT=999999 "$WATCH" > "$out" & + pid=$! + if ! wait_live "$pid" 30; then + reap "$pid"; fail "a 66-minute-old completed turn escalated before the wedge threshold under the default bound: $(cat "$out")" + fi + [ -s "$state/.stale-since-$key" ] || fail "a 66-minute-old completed turn did not start a wedge timer under the default bound (default is not 3600s)" + reap "$pid" + pass "the production default busy-turn-age bound is 3600s (5min under does not wedge, 66min over does)" +} + test_nonterminal_stale_repairs_missing_or_corrupt_timer() { local dir state fakebin out capture_file window key pane_hash sig pid since dir=$(make_case nonterminal-stale-timer-repair); state="$dir/state"; fakebin="$dir/fakebin" @@ -1288,6 +1570,12 @@ test_stale_terminal_status_overridden_by_active_run test_nonterminal_stale_provably_working_absorbed_then_escalated test_wedge_escalation_marks_demand_deep_inspection_after_threshold test_wedge_escalation_resets_when_pane_becomes_active +test_busy_pane_below_turn_age_bound_is_absorbed +test_busy_pane_stable_hash_escalates_past_turn_age_bound +test_busy_pane_changing_hash_escalates_past_turn_age_bound +test_busy_pane_turn_end_touch_resets_age +test_busy_pane_repeated_escalation_reaches_demand_deep_inspection +test_busy_pane_default_turn_age_bound_is_3600s test_nonterminal_stale_not_working_surfaced test_nonterminal_stale_paused_absorbed_then_resurfaced test_exited_declared_pause_is_bounded_but_live_gate_surfaces diff --git a/tests/fm-watcher-lock.test.sh b/tests/fm-watcher-lock.test.sh index 640e913318..e741ec21e8 100755 --- a/tests/fm-watcher-lock.test.sh +++ b/tests/fm-watcher-lock.test.sh @@ -901,18 +901,54 @@ test_pid_identity_is_locale_invariant() { # fm_pid_identity, so its output must be byte-identical regardless of the caller's # exported LC_ALL/LC_TIME. This stays deterministic on CI even where an alternate # locale like ko_KR.UTF-8 is not installed (the equality then holds trivially). - local live no_proc baseline via_lc_all via_lc_time + local live no_proc fakebin locale_log baseline via_lc_all via_lc_time + local real_first real_second observed sleep 300 & live=$! no_proc="$TMP_ROOT/no-proc" - baseline=$(FM_PROC_ROOT_OVERRIDE="$no_proc" LC_ALL=C bash -c '. "$1"; fm_pid_identity "$2"' _ "$LIB" "$live" 2>/dev/null) - via_lc_all=$(FM_PROC_ROOT_OVERRIDE="$no_proc" LC_ALL=ko_KR.UTF-8 bash -c '. "$1"; fm_pid_identity "$2"' _ "$LIB" "$live" 2>/dev/null) - via_lc_time=$(FM_PROC_ROOT_OVERRIDE="$no_proc" LC_TIME=ko_KR.UTF-8 bash -c 'unset LC_ALL; . "$1"; fm_pid_identity "$2"' _ "$LIB" "$live" 2>/dev/null) + fakebin="$TMP_ROOT/locale-ps" + locale_log="$TMP_ROOT/locale-ps.observed" + mkdir -p "$fakebin" + : > "$locale_log" + # The stub renders lstart through date under whatever locale it inherits, so its + # output really does change when the caller's locale leaks through. Dropping the + # LC_ALL=C pin in fm_pid_identity therefore breaks the equality assertions below + # on any host with a second locale installed, and the recorded LC_ALL below keeps + # the pin asserted even where ko_KR.UTF-8 is missing and date falls back to C. + cat > "$fakebin/ps" <<'SH' +#!/usr/bin/env bash +printf '%s\n' "${LC_ALL-<unset>}" >> "$FAKE_PS_LOCALE_LOG" +stamp=$(date -d @1784094040 '+%a %b %e %H:%M:%S %Y' 2>/dev/null) \ + || stamp=$(date -r 1784094040 '+%a %b %e %H:%M:%S %Y' 2>/dev/null) \ + || stamp='Mon Jul 28 20:00:00 2026' +printf '%s sleep 300\n' "$stamp" +SH + chmod +x "$fakebin/ps" + baseline=$(PATH="$fakebin:$PATH" FAKE_PS_LOCALE_LOG="$locale_log" FM_PROC_ROOT_OVERRIDE="$no_proc" LC_ALL=C bash -c '. "$1"; fm_pid_identity "$2"' _ "$LIB" "$live" 2>/dev/null) + via_lc_all=$(PATH="$fakebin:$PATH" FAKE_PS_LOCALE_LOG="$locale_log" FM_PROC_ROOT_OVERRIDE="$no_proc" LC_ALL=ko_KR.UTF-8 bash -c '. "$1"; fm_pid_identity "$2"' _ "$LIB" "$live" 2>/dev/null) + via_lc_time=$(PATH="$fakebin:$PATH" FAKE_PS_LOCALE_LOG="$locale_log" FM_PROC_ROOT_OVERRIDE="$no_proc" LC_TIME=ko_KR.UTF-8 bash -c 'unset LC_ALL; . "$1"; fm_pid_identity "$2"' _ "$LIB" "$live" 2>/dev/null) + # Keep the real ps fallback exercised wherever it supports the portable -o fields. + real_first= + real_second= + if LC_ALL=C ps -p "$live" -o lstart= -o command= >/dev/null 2>&1; then + real_first=$(FM_PROC_ROOT_OVERRIDE="$no_proc" LC_ALL=C bash -c '. "$1"; fm_pid_identity "$2"' _ "$LIB" "$live" 2>/dev/null) + real_second=$(FM_PROC_ROOT_OVERRIDE="$no_proc" LC_TIME=ko_KR.UTF-8 bash -c 'unset LC_ALL; . "$1"; fm_pid_identity "$2"' _ "$LIB" "$live" 2>/dev/null) + fi kill "$live" 2>/dev/null || true wait "$live" 2>/dev/null || true [ -n "$baseline" ] || fail "fm_pid_identity produced no baseline identity under LC_ALL=C" [ "$via_lc_all" = "$baseline" ] || fail "fm_pid_identity varied with exported LC_ALL (got '$via_lc_all', want '$baseline')" [ "$via_lc_time" = "$baseline" ] || fail "fm_pid_identity varied with exported LC_TIME (got '$via_lc_time', want '$baseline')" + while read -r observed; do + [ "$observed" = C ] || fail "fm_pid_identity invoked ps without pinning LC_ALL=C (saw '$observed')" + done < "$locale_log" + if [ -n "$real_first" ]; then + [ "$real_second" = "$real_first" ] \ + || fail "real ps fallback varied with exported LC_TIME (got '$real_second', want '$real_first')" + pass "fm_pid_identity real ps fallback is locale-invariant" + else + pass "real ps fallback locale check skipped where ps -o lstart= is unsupported" + fi pass "fm_pid_identity is locale-invariant across LC_ALL/LC_TIME" } @@ -923,16 +959,14 @@ write_fake_proc_identity() { printf 'bash\0/path with spaces/fm-watch.sh\0--flag\0' > "$proc_root/$pid/cmdline" } -test_linux_pid_identity_ignores_wall_clock_and_detects_pid_reuse() { - local dir state proc_root pid before after_time_jump after_pid_reuse - [ "$(uname)" = Linux ] || { - pass "Linux process identity clock-step regression skipped on non-Linux host" - return - } - dir=$(make_case linux-pid-identity) +test_proc_pid_identity_ignores_wall_clock_and_detects_pid_reuse() { + local dir state proc_root pid identity_key before after_time_jump after_pid_reuse + dir=$(make_case proc-pid-identity) state="$dir/state" proc_root="$dir/proc" pid=4242 + identity_key=proc-starttime + [ "$(uname)" != Linux ] || identity_key=linux-starttime mkdir -p "$proc_root" printf 'btime 1784094040\n' > "$proc_root/stat" write_fake_proc_identity "$proc_root" "$pid" 987654 @@ -944,21 +978,43 @@ test_linux_pid_identity_ignores_wall_clock_and_detects_pid_reuse() { || fail "could not re-read fake Linux process identity after btime change" [ "$after_time_jump" = "$before" ] \ - || fail "Linux process identity changed with btime (before '$before', after '$after_time_jump')" - [ "$before" = 'linux-starttime=987654 cmdline-hex=62617368002f706174682077697468207370616365732f666d2d77617463682e7368002d2d666c616700' ] \ - || fail "Linux process identity did not combine parsed starttime field 22 with the full cmdline ('$before')" - pass "Linux process identity ignores simulated btime changes" + || fail "/proc process identity changed with btime (before '$before', after '$after_time_jump')" + [ "$before" = "$identity_key=987654 cmdline-hex=62617368002f706174682077697468207370616365732f666d2d77617463682e7368002d2d666c616700" ] \ + || fail "/proc process identity did not combine parsed starttime field 22 with the full cmdline ('$before')" + pass "/proc process identity ignores simulated btime changes" write_fake_proc_identity "$proc_root" "$pid" 987655 after_pid_reuse=$(FM_PROC_ROOT_OVERRIDE="$proc_root" FM_STATE_OVERRIDE="$state" bash -c '. "$1"; fm_pid_identity "$2"' _ "$LIB" "$pid") \ - || fail "could not read reused fake Linux pid identity" - [ "$after_pid_reuse" != "$before" ] || fail "Linux process identity missed changed starttime for reused pid" - pass "Linux process identity detects pid reuse" + || fail "could not read reused fake /proc pid identity" + [ "$after_pid_reuse" != "$before" ] || fail "/proc process identity missed changed starttime for reused pid" + pass "/proc process identity detects pid reuse" +} + +test_msys_pid_identity_uses_proc() { + local live identity + case "$(uname)" in + MSYS*|MINGW*|CYGWIN*) ;; + *) + pass "MSYS /proc process identity regression skipped on non-Windows host" + return + ;; + esac + sleep 300 & + live=$! + identity=$(bash -c '. "$1"; fm_pid_identity "$2"' _ "$LIB" "$live" 2>/dev/null) + kill "$live" 2>/dev/null || true + wait "$live" 2>/dev/null || true + case "$identity" in + proc-starttime=*" cmdline-hex="*) ;; + *) fail "MSYS process identity did not use compatible /proc fields ('$identity')" ;; + esac + pass "MSYS process identity uses compatible /proc fields" } test_singleton_start test_pid_identity_is_locale_invariant -test_linux_pid_identity_ignores_wall_clock_and_detects_pid_reuse +test_proc_pid_identity_ignores_wall_clock_and_detects_pid_reuse +test_msys_pid_identity_uses_proc test_stale_watch_lock_reclaimed test_live_stale_watch_lock_is_actionable test_guard_warnings diff --git a/tests/fm-x-mode.test.sh b/tests/fm-x-mode.test.sh index 505860689d..baed0b28d4 100755 --- a/tests/fm-x-mode.test.sh +++ b/tests/fm-x-mode.test.sh @@ -700,6 +700,23 @@ test_bootstrap_activates_on_env_token() { pass "bootstrap activates X mode from an .env token, idempotently" } +test_bootstrap_relative_home_writes_absolute_poll_shim() { + local root home out quoted_home + root="$TMP_ROOT/boot-relative-home" + mkdir -p "$root/home" "$root/cdpath/home" + home=$(cd "$root/home" && pwd -P) + printf 'FMX_PAIRING_TOKEN=tok-relative\n' > "$home/.env" + out=$( + cd "$root" || exit 1 + CDPATH="$root/cdpath" FM_HOME=home "$ROOT/bin/fm-bootstrap.sh" 2>/dev/null + ) + assert_contains "$out" "FMX: X mode on" "relative-home bootstrap must announce X mode" + quoted_home=$(printf '%q' "$home") + assert_grep "export FM_HOME=$quoted_home" "$home/state/x-watch.check.sh" \ + "relative FM_HOME leaked into the durable X-mode poll shim" + pass "bootstrap ignores CDPATH when writing absolute FM_HOME into the durable X-mode poll shim" +} + test_bootstrap_reports_missing_x_dependency() { local home fakebin out tool tool_path home="$TMP_ROOT/boot-missing-x"; mkdir -p "$home" @@ -2862,6 +2879,7 @@ test_followup_post_dry_run_increments_counter_keeps_link test_followup_post_dry_run_final_clears_link test_followup_usage_errors test_bootstrap_activates_on_env_token +test_bootstrap_relative_home_writes_absolute_poll_shim test_bootstrap_reports_missing_x_dependency test_bootstrap_does_not_announce_when_arm_fails test_bootstrap_does_not_follow_x_artifact_symlinks diff --git a/tests/herdr-test-safety.sh b/tests/herdr-test-safety.sh index 984acd0213..59a2bb46cc 100644 --- a/tests/herdr-test-safety.sh +++ b/tests/herdr-test-safety.sh @@ -14,6 +14,25 @@ HERDR_TEST_SAFETY_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" # shellcheck source=/dev/null . "$HERDR_TEST_SAFETY_DIR/bin/fm-herdr-lab.sh" +# herdr_forget_inherited_pane: drop the Herdr PANE identity this test process +# inherited from whatever terminal it was started in. +# +# Herdr injects HERDR_ENV, HERDR_PANE_ID, HERDR_TAB_ID, HERDR_WORKSPACE_ID, +# HERDR_SOCKET_PATH, and HERDR_SESSION into every process it manages a pane for +# (verified 0.7.5 - docs/verification/runtime-backends.md), and a test run from +# inside a Herdr pane inherits all of them. Spawn now treats that pane as the +# authoritative parent to place workers next to, so a leaked identity from the +# developer's own session would follow the test into its isolated lab session +# and be refused there as a cross-session parent - a result that depends on +# where the suite was launched from, not on what it asserts. +# +# Call this before exporting the lab HERDR_SESSION in any suite whose subject is +# the per-home container path. A suite that means to exercise a launcher-bound +# spawn sets HERDR_PANE_ID itself, to a pane it created in its own lab session. +herdr_forget_inherited_pane() { + unset HERDR_ENV HERDR_PANE_ID HERDR_TAB_ID HERDR_WORKSPACE_ID HERDR_SOCKET_PATH HERDR_SESSION +} + herdr_refuse_if_default() { # <session> fm_herdr_lab_refuse_if_default "$1" } diff --git a/tests/lib.sh b/tests/lib.sh index d33062915f..ee3b1d1476 100644 --- a/tests/lib.sh +++ b/tests/lib.sh @@ -152,13 +152,16 @@ fm_write_meta() { } # fm_write_secondmate_meta <file> <home> [window] [projects] [harness]: write the -# standard kind=secondmate meta block used across the secondmate suites. window -# is explicit and defaults to firstmate:fm-domain, projects defaults to alpha, -# and harness defaults to echo to match the common case. +# standard kind=secondmate meta block used across the secondmate suites. Window +# defaults to firstmate:fm-<id>, projects defaults to alpha, and harness defaults +# to echo to match the common case. fm_write_secondmate_meta() { - local file=$1 home=$2 window=${3:-firstmate:fm-domain} projects=${4:-alpha} harness=${5:-echo} + local file=$1 home=$2 id window projects=${4:-alpha} harness=${5:-echo} + id=$(basename "$file" .meta) + window=${3:-firstmate:fm-$id} fm_write_meta "$file" \ "window=$window" \ + "endpoint_task_id=$id" \ "worktree=$home" \ "project=$home" \ "harness=$harness" \ diff --git a/tests/no-mistakes-required-workflow.test.sh b/tests/no-mistakes-required-workflow.test.sh deleted file mode 100755 index dc87c9970f..0000000000 --- a/tests/no-mistakes-required-workflow.test.sh +++ /dev/null @@ -1,96 +0,0 @@ -#!/usr/bin/env bash -# Contract and synthetic event replay for the PR body compliance workflow. -# shellcheck disable=SC2016 -set -u - -# shellcheck source=tests/lib.sh -. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" - -WORKFLOW="$ROOT/.github/workflows/no-mistakes-required.yml" -MARKER='Updates from [git push no-mistakes](https://github.com/kunchenguid/no-mistakes)' - -extract_signature_script() { - awk ' - /^ run: \|$/ { capture=1; next } - capture && /^ / { sub(/^ /, ""); print; next } - capture { exit } - ' "$WORKFLOW" -} - -signature_result() { - local body=$1 script - script=$(extract_signature_script) - PR_NUMBER=418 PR_AUTHOR=synthetic-fork-contributor PR_BODY="$body" bash -c "$script" >/dev/null 2>&1 -} - -render_group() { - local action=$1 run_id=$2 - case "$action" in - opened|edited) printf 'no-mistakes-required-418-%s\n' "$run_id" ;; - synchronize|reopened) printf 'no-mistakes-required-418-head-change\n' ;; - esac -} - -render_run_name() { - local action=$1 run_number=$2 run_id=$3 - printf 'PR #418 body compliance - %s - event %s (run %s)\n' "$action" "$run_number" "$run_id" -} - -test_signature_sequence_at_fixed_head() { - signature_result "Synthetic body\n$MARKER" || fail "signed opened event must succeed" - if signature_result 'Synthetic unsigned edit'; then - fail "unsigned edited event must fail" - fi - signature_result "Synthetic signed edit\n$MARKER" || fail "signed edited event must succeed" - pass "fixed-head signed opened, unsigned edited, signed edited yields 0/1/0" -} - -test_event_identity_contract() { - local opened edited_one edited_two synchronize reopened - opened=$(render_group opened 9001) - edited_one=$(render_group edited 9002) - edited_two=$(render_group edited 9003) - synchronize=$(render_group synchronize 9004) - reopened=$(render_group reopened 9005) - [ "$opened" != "$edited_one" ] && [ "$opened" != "$edited_two" ] && [ "$edited_one" != "$edited_two" ] || \ - fail "body events must have distinct immutable groups" - [ "$synchronize" = "$reopened" ] || fail "synchronize and reopened must share head-change" - case "$opened $edited_one $edited_two" in *head-change*) fail "body event reused head-change" ;; esac - - assert_grep "group: no-mistakes-required-\${{ github.event.pull_request.number }}-\${{ (github.event.action == 'opened' || github.event.action == 'edited') && github.run_id || 'head-change' }}" "$WORKFLOW" \ - "workflow does not implement immutable body-event groups" - assert_grep 'cancel-in-progress: true' "$WORKFLOW" "workflow lost cancellation for coalesced head changes" - pass "body event groups are distinct while head changes remain coalesced" -} - -test_run_names_are_ordered_and_unique() { - local first second - first=$(render_run_name edited 73 9002) - second=$(render_run_name edited 74 9003) - [ "$first" = 'PR #418 body compliance - edited - event 73 (run 9002)' ] || fail "first synthetic run name is incomplete" - [ "$second" = 'PR #418 body compliance - edited - event 74 (run 9003)' ] || fail "second synthetic run name is incomplete" - [ "$first" != "$second" ] || fail "distinct events must have unique run names" - assert_grep 'run-name: "PR #${{ github.event.pull_request.number }} body compliance - ${{ github.event.action }} - event ${{ github.run_number }} (run ${{ github.run_id }})"' "$WORKFLOW" \ - "workflow run name does not expose PR, action, monotonic run number, and immutable run ID" - pass "run names expose monotonic numbers and immutable IDs" -} - -test_security_and_signature_contract_is_preserved() { - assert_grep ' pull_request:' "$WORKFLOW" "workflow must use pull_request" - assert_no_grep 'pull_request_target' "$WORKFLOW" "workflow must not use pull_request_target" - assert_grep ' contents: read' "$WORKFLOW" "contents permission must remain read-only" - assert_no_grep 'contents: write' "$WORKFLOW" "workflow must not gain contents write permission" - assert_no_grep 'secrets.' "$WORKFLOW" "workflow must not read secrets" - assert_no_grep 'actions/checkout' "$WORKFLOW" "workflow must not check out fork code" - assert_grep 'name: PR must be raised via no-mistakes' "$WORKFLOW" "stable required check name changed" - assert_grep "$MARKER" "$WORKFLOW" "signature marker changed" - assert_grep "github.event.pull_request.user.login != 'github-actions[bot]'" "$WORKFLOW" "github-actions bot exemption changed" - assert_grep "github.event.pull_request.user.login != 'dependabot[bot]'" "$WORKFLOW" "dependabot bot exemption changed" - assert_no_grep 'release-please[bot]' "$WORKFLOW" "Firstmate must not exempt release-please" - pass "fork, permission, check-name, marker, and bot-exemption contracts are preserved" -} - -test_signature_sequence_at_fixed_head -test_event_identity_contract -test_run_names_are_ordered_and_unique -test_security_and_signature_contract_is_preserved