Repository navigation
Conversation
talktejas
pushed a commit
that referenced
this pull request
Sep 17, 2026
…kunchenguid#3578) * fix(bin): let verified harness ancestry outrank retained markers (#3) * fix(bin): let a structural harness ancestor outrank a retained marker bin/fm-harness.sh treated a verified environment marker as unconditionally authoritative, so a Codex session started from an environment that had retained CLAUDECODE=1 detected as claude. Session start then emitted Claude's Stop-owned supervision protocol to a Codex primary, and every turn end was blocked for missing Claude recovery. The defect is the precedence boundary, not any one harness. codex, opencode, kimi, and muse publish no identity marker at all, so with markers winning outright any retained CLAUDECODE renamed them; the Cursor-before-Claude ordering was a point patch on the same class of problem, and the launch-time marker clearing only ever covered sessions fm-spawn started. Markers and ancestry are now separate evidence layers that detect_own arbitrates: - no ancestry match, or no marker: the single available layer answers, unchanged; - same harness family: the marker's finer verdict stands, so a launch-selected pi-signed is not flattened to pi by an ancestry walk that can only see the shared launcher name; - different harness with a structural (command-name) ancestor: ancestry wins, because only ancestry proves who owns the process tree; - different harness with only a bare-interpreter script-path match: the marker wins, since a harness-shaped path in some node process's arguments is weaker evidence than a harness publishing its own identity. The correction is symmetric: a retained CURSOR_AGENT no longer renames a claude worker nested under cursor either. Adds fm-harness.sh ancestry [<pid>], ancestry evidence with no marker layer, so a real harness process can be asked what the walk makes of it. tests/fm-harness-precedence.test.sh is the portable regression, built from real renamed processes with no harness installed. Every case drives the two layers apart and asserts each alone as well as the combination, so no case can pass vacuously; it also pins Codex's real two-process install topology, since the fix depends on the native binary being what a tool subprocess meets first. The opt-in drift guard gains the matching live half: each installed harness's real running process must still be identified by the ancestry walk, and it fails naming the harness and version when a release changes that name. Documentation follows the corrected contract in the script header, the harness-adapters detection section, the codex, opencode, kimi, and cursor references, and a dated verification record. * fix(tests): drop the unused argument pass-through in the shim-topology helper bin/fm-lint.sh refused the branch: run_shim declared a `[ancestry]` argument and forwarded "$@", but every call site that varies the environment or passes the ancestry subcommand invokes the shim entry point directly, so the helper is only ever called with no arguments (ShellCheck SC2120/SC2119). Behavior is unchanged: with no arguments "$@" expanded to nothing. * fix(bin): examine the top of the process chain instead of assuming init harness_ancestry stopped as soon as the next pid was 1, on the assumption that pid 1 is always init and can never be a harness. Inside a PID namespace that assumption inverts: the harness itself is pid 1, so the walk never examined the one process that proves who owns the tree, reported no ancestry at all, and handed the verdict straight back to a retained marker. A real Codex session under `codex sandbox`, holding CLAUDECODE=1 and CLAUDE_CODE_ENTRYPOINT=cli, is exactly that shape: it resolved claude and rendered Claude's Stop-owned supervision protocol even with the marker-vs-ancestry precedence boundary in place. The same probe now resolves codex and renders the Codex foreground checkpoint. A host's real pid 1 (init, systemd, launchd) matches no harness name, so examining it costs one ps call and can introduce no false positive; the walk still stops once that top process has been read, and a non-numeric or zero ppid still ends it. tests/fm-harness-precedence.test.sh pins the namespace shape with a fake ps that reports every process as bash with ppid 1 and pid 1 as the harness. The case asserts the marker still answers alone when pid 1 is host-shaped, so it cannot pass vacuously, and it fails against the previous stop condition. * docs(verification): record the real-Codex retained-marker evidence The existing record proved the precedence boundary with the portable regression and recorded each installed harness's process name behind the ancestry walk, but it had no evidence from a real Codex process actually holding a retained Claude marker, which is the failure the boundary exists for. Adds the dated before/after result from codex-cli 0.152.0 under `codex sandbox`, with the exact command and the decisive verdict and rendered protocol on each side, and records the second boundary that shape exposed: the walk must examine the top of the process chain, because inside a PID namespace the harness is pid 1. Refreshes the portable regression's observed output for the case it gained. * no-mistakes(review): blind ancestry in marker-pinned harness tests * no-mistakes(review): blind ancestry in the Pi guard-routing test * no-mistakes(review): classify precedence suite, dedupe ps stub, soften claims * no-mistakes(review): model the spawn-and-wait Codex shim topology * no-mistakes(document): correct stale muse marker-clearing detection claims * no-mistakes: apply CI fixes * fix(bin): examine the top of the chain in the lock and nudge walks too The pid-1 defect corrected in bin/fm-harness.sh survived unchanged in the two other harness-ancestry walks, on the exact topology the branch verified against a real Codex process. bin/fm-session-lock-lib.sh's fm_harness_ancestry_pids stopped as soon as the next pid was 1, so a firstmate whose harness is pid 1 of its own PID namespace could not find that harness at all and did not recognize its own session lock. bin/fm-sessionstart-nudge.sh carried the same stop plus a blanket rejection of a lock pid of 1, so the same session was told to run session start again on every turn. Both walks now compare the top process before stopping, matching the shape used in bin/fm-harness.sh. For the lock walk this is safe because fm_harness_process_matches rejects a host's real pid 1. For the nudge, `kill -0` still gates the lock pid, and on a host an unprivileged `kill -0 1` fails, so a lock file that wrongly names pid 1 leaves the hook silent rather than acting on init. Each walk gains one regression case. The lock case drives a deterministic process table whose pid 1 is the harness and asserts a host-shaped pid 1 still finds nothing, so it cannot pass vacuously. The nudge case needs a real PID namespace, because the builtin `kill -0` gate cannot be reached through a fake ps, and it first proves the same fixture nudges with no lock present; it skips explicitly where unprivileged namespaces are unavailable. * no-mistakes(review): assert comm-strength detection from subprocess vantage in drift guard * fix(bin): verify the live harness guard at the strength the guarantee needs The marker-versus-ancestry boundary this branch ships is a strength claim: detect_own hands an args-strength verdict straight back to a retained foreign marker, so a harness is only protected where the ancestry walk reaches it at comm strength. The installed-harness drift guard probed the pane process alone. Under an interpreter shim the pane process IS the shim, whose own script path is args strength, while the native binary that carries comm strength is its child. The guard therefore observed args for Codex, passed, and would have kept passing if a release stopped spawning that native child at all, while real sessions silently regressed to the original bug. fm-harness.sh gains `ancestry-subtree`, which asks the walk from the pane process and every descendant of it, the vantage a tool subprocess actually occupies. The guard now requires comm strength somewhere in that set and requires every vantage to name the same harness. This supersedes the preceding commit's in-guard leaf walk, which reached the same vantage but left the logic inside the test file, where CI could not pin it and nothing else could reuse it. A harness-dependent check needs both halves: `tests/fm-harness-precedence.test.sh` now carries a portable case proving the subtree probe reaches a strength the top-of-session probe cannot, mutation checked twice, once against the pre-change script and once by disabling descendant enumeration. The subtree walk also avoids depending on tty and process-group semantics that differ between Linux and macOS. Verified live: codex-cli 0.152.0 reports [args codex;comm codex] and Claude Code 2.1.257 reports [comm claude]. * no-mistakes(review): narrow drift guard to the upward vantage path * no-mistakes(review): judge only comm-strength vantages in drift guard * no-mistakes(document): drop duplicated rationale in detection precedence evidence * no-mistakes(review): fix pid-1 nudge case vacuity and descent no-arg expansion * no-mistakes(document): drop branch-relative phrasing in detection precedence evidence * no-mistakes(review): guard remaining empty positional expansions in fm-harness * no-mistakes(document): scope cursor marker-ordering claim to the marker layer * no-mistakes(review): Prefer comm-strength leaves in equal-depth descent ties * no-mistakes(document): Document comm-strength descent tie-break --------- * no-mistakes(review): Blind ancestry in stale gemini/rovo marker-precedence tests * no-mistakes(document): Add missing equal-depth-tie test line to precedence evidence transcript * no-mistakes(review): Fix stale/vacuous agy precedence test, add agy to precedence suite and docs * no-mistakes(document): Fix stale kimi.md marker doc missed by ancestry-precedence fix --------- Co-authored-by: NewAiCoder <170579485+NewAiCoder@users.noreply.github.com>
talktejas
force-pushed
the
fm/fm-spawn-per-task-base
branch
2 times, most recently
from
September 17, 2026 20:58
bfa22de to
7b8310b
Compare
A worker's base branch could only come from the project's standing base= record in data/projects.md. Keeping an effort on one integration branch therefore meant editing that shared registry at the start and remembering to restore it at the end; a forgotten restore silently bases the next unrelated task on a dead integration branch, and nothing reports it. --base names the base for one ship or scout spawn and is never written back, so the registry keeps holding the project's standing base. Precedence is --base, then the registry base=, then the repository default, unchanged below the flag. An explicit base the project cannot resolve refuses the spawn naming the branch instead of falling back, and an origin-less copy now honours an explicit base rather than silently skipping it. The resolved base is recorded as base= in the task record, so recovery, relaunch, and teardown read the branch the task was created against rather than re-resolving a registry that may have moved on. --relaunch reuses that record and refuses --base for the same reason it refuses --backend; --secondmate has no task worktree and refuses it too.
talktejas
force-pushed
the
fm/fm-spawn-per-task-base
branch
from
September 20, 2026 05:46
7b8310b to
f42df17
Compare
talktejas
added a commit
that referenced
this pull request
Sep 20, 2026
* feat(spawn): add a per-spawn --base branch for ship and scout tasks Supersedes #3, which cannot be revalidated: that branch's head was replaced outside the pipeline, and no-mistakes then refuses to reconcile its private mirror record for the branch. Same content, clean branch from main. A task worktree's base branch comes from the project's own declaration, so honouring the captain's integration-branch ruling means editing shared state at the start of an effort and remembering to restore it at the end - and a forgotten restore bases the next unrelated fix on a dead integration branch with nothing reporting it. bin/fm-spawn.sh now accepts --base <branch>, which governs one spawn only: it takes precedence over bin/fm-project-base.sh's resolution and the repository default when a pooled worktree is freshened, writes nothing back, refuses the spawn naming the source rather than falling back when the branch does not resolve, and is refused alongside --relaunch and --secondmate. The resolved base is recorded as base= in the task's metadata so a relaunch keeps the branch the task was created against. * no-mistakes(review): honour a task's recorded base at landing and teardown * no-mistakes(review): record a task base only when explicitly dispatched * no-mistakes(document): correct local-landing docs for per-task base
Owner
Author
|
Superseded by #8, which carries the same content on a clean branch off main and is merged. This branch could not be taken through the pipeline: its private mirror record could not be reconciled after its head was replaced outside the pipeline. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Intent
The captain ruled on 2026-09-17 that a multi-part effort must accumulate on one integration branch and merge into the active branch only once, checked as a whole. He gave that ruling after a night in which fifteen separate changes were merged into a product's develop branch one at a time, none of them ever compiled, and he found the 35 compile errors himself by failing to sign in to his own demo.
Honouring that ruling with today's tooling means editing a shared registry file at the start of an effort and remembering to restore it at the end. The second mate that carries the rule pointed out, unprompted, that this is exactly how the rule fails quietly: a forgotten restore bases the next unrelated fix on a dead integration branch and nothing reports it.
So the ruling is sound and the tooling makes it fragile. You are closing that gap.
Concretely, in firstmate's own tooling: a task worktree's base branch today comes only from the shared per-project registry record, so honouring the ruling means editing that shared record at the start of an effort and remembering to restore it afterwards. Close the gap by giving a spawn an explicit per-task base branch that governs that one task only, is never written back to the shared registry, refuses rather than silently falling back when the named branch does not resolve, and is recorded with the task so a relaunch keeps the branch the task was created against.
What Changed
bin/fm-spawn.shaccepts--base <branch>, which takes precedence over the project'sdata/projects.mdbase=record and the repository default when a pooled worktree is freshened, without writing anything back to the registry; a base that does not resolve refuses the spawn naming the source instead of falling back, and the flag is refused alongside--relaunchand on--secondmate. Batch dispatch forwards it with the other shared args.base=in the task'sstate/<id>.meta, andpreserve_relaunch_metacarries the existing record forward so a relaunch keeps the branch the task was created against.$MODEalone: since scouts are refused--mode, the spawn falls back to the project's registered posture viafm-project-mode.sh --raw, so a scout on a local-only project resolves the local branch rather than a staleorigin/<branch>. A worktree with no origin configured also honours an explicit base.base=survives. Docs (docs/architecture.md,docs/scripts.md) and the project-management skill describe the per-spawn base and posture lookup.🤖 Generated with Claude Code
Risk Assessment
✅ Low: The flag is well-bounded and fails closed on every unresolvable base, the authorized posture fix is correct and covered by a test that genuinely fails against the old logic, base= is additive metadata with no strict consumer, and the only surviving issues are two inaccurate documentation sentences.
Testing
I stood up throwaway firstmate homes and real git clones (a main-defaulted remote with a three-commit-ahead effort branch and a cold detached pool worktree) and dispatched realbin/fm-spawn.shinvocations against them. The headline user story works:--base integration/multicurrencylands the cold slot on that branch, recordsbase=integration/multicurrencyinstate/<id>.meta, and leavesdata/projects.mdbyte-identical, while the very next unrelated spawn with no flag still lands on the registry's standing base. The three failure modes the intent names all refuse loudly — a typo'd base stops the spawn naming the branch and writes no task record instead of quietly falling back to main,--relaunch --baserefuses and keeps the task's own recorded branch, and--secondmate --baserefuses. Both fixes selected in review round 1 were verified as real regressions: the scout/local-only base resolution fails against the pre-fix script and passes now, and the previously un-isolated secondmate test no longer creates state in the ambient firstmate home. The change is CLI- and git-state-facing with no rendered UI, so the evidence is CLI transcripts and resulting git/meta/registry state rather than screenshots.test_explicit_base_is_not_written_backin fm-spawn-base-branch.logdurable task metadata survives replacement launch publicationin fm-control-relaunch.logtest_scout_accepts_an_explicit_baseandtest_scout_on_a_local_only_project_uses_the_local_branchpass at ffc71d3; the latter fails against the pre-fix script (scout-local-only-before.log)git statusEvidence: End-user walkthrough of --base against the real fm-spawn.sh CLI
Source: End-user walkthrough of --base against the real fm-spawn.sh CLI
Evidence: fm-spawn-base-branch test suite output
Source: fm-spawn-base-branch test suite output
Evidence: fm-control-relaunch test suite output (base= survives relaunch)
Source: fm-control-relaunch test suite output (base= survives relaunch)
Evidence: Scout/local-only base: failure against the pre-fix spawn script
Source: Scout/local-only base: failure against the pre-fix spawn script
Evidence: Secondmate test isolation: ambient-home leak before the fix
Source: Secondmate test isolation: ambient-home leak before the fix
Evidence: Refusal transcript (unresolvable explicit base)
Pipeline
Updates from git push no-mistakes
✅ **intent** - passed
✅ No issues found.
✅ **Rebase** - passed
✅ No issues found.
tests/fm-spawn-base-branch.test.sh:423- test_secondmate_refuses_an_explicit_base invokes "$SPAWN" with none of the env isolation every other test in this file uses (FM_HOME, FM_STATE_OVERRIDE, FM_DATA_OVERRIDE, FM_PROJECTS_OVERRIDE, FM_CONFIG_OVERRIDE, FM_SPAWN_NO_GUARD=1, PATH=$FAKEBIN_DIR:$PATH). bin/fm-spawn.sh:458 therefore runs the real bin/fm-guard.sh against the developer's or CI runner's ambient FM_HOME before the --base refusal at bin/fm-spawn.sh:591, and fm-guard.sh writes the episode marker state/.guard-watcher-stale-banner into that real home. Fix: wrap the invocation in the same env prefix used by test_relaunch_refuses_an_explicit_base.bin/fm-spawn.sh:2523- The local-only branch keys off MODE, but --mode is refused on scout spawns (bin/fm-spawn.sh:586) so MODE is empty for every scout. A scout spawned into a local-only project that still has an origin configured resolves target=origin/<base> instead of the local branch. Concrete sequence: local-only project, effort branch integration/x created and merged locally by bin/fm-merge-local.sh (never pushed);fm-spawn.sh s1 <proj> --scout --base integration/xeither refuses (origin/integration/x does not exist, so the new flag is unusable for scouts on local-only projects) or resets the worktree to a stale origin/integration/x and the scout audits an old tree - the silent-wrong-base failure the flag exists to prevent. The registry-base path has the same hole since 11217bc; the smallest honest remedy is deciding how a scout learns the project's local-only posture (e.g. consulting fm-project-mode.sh for the resolution rule only), which extends the change beyond its stated intent and needs authorization.bin/fm-spawn.sh:2545- The comment states the recorded base= is read by "recovery, relaunch, and teardown", but no consumer reads base= from state/<id>.meta anywhere in bin/ or lib/. A relaunch reuses the recorded worktree and merely carries the line forward through preserve_relaunch_meta (base= is correctly absent from its owned-key list). The recording still satisfies the intent's requirement that a relaunch keep the branch the task was created against; only the comment's claimed consumers are inaccurate.🔧 Fix applied.
3 infos still open:
bin/fm-spawn.sh:80- The usage header says --base "resolves exactly as a registry base= does: origin/<branch> for a shared clone, the local branch in local-only mode or when no origin is configured." Both halves are now inaccurate. (a) With no origin configured, a registry base= is not applied at all: freshen_spawn_worktree_base returns 0 early at bin/fm-spawn.sh:2484 unless BASE_ARG is set, so only --base governs there - the two do not resolve identically. (b) The local-vs-origin decision is no longer "local-only mode": bin/fm-spawn.sh:2528-2529 resolves $posture from $MODE and, when that is empty (every scout), from the project's registered posture. Correct the two sentences to describe the posture-based rule and the no-origin asymmetry; no code change.bin/fm-spawn.sh:85- The header says "--relaunch reuses that record like every other identity axis", and docs/architecture.md:314 says "a relaunch reuses that record". Nothing reads base= from state/<id>.meta anywhere in bin/ (grep-verified); base= is absent from preserve_relaunch_meta's owned-key list, so a relaunch merely copies the line forward. The observable outcome is still right - a relaunch reuses the recorded worktree, so the task keeps its branch - but the stated mechanism is not. This is the same false-consumer claim the prior fix round corrected at bin/fm-spawn.sh:2549; these two copies were missed. Restate both as "the record is carried forward with the reused worktree".tests/fm-spawn-base-branch.test.sh:469- Beyond the three prescribed fixes, this round moved test_scout_also_starts_from_the_recorded_base from after the "# all fm-spawn-base-branch tests passed" banner into the test list. The author's commit invoked it after the banner, so a failure there would have been reported as passing. The repair is correct and in scope for the file; noting it so it is not mistaken for an unreviewed drive-by.✅ **Test** - passed
✅ No issues found.
test_explicit_base_is_not_written_backin fm-spawn-base-branch.logdurable task metadata survives replacement launch publicationin fm-control-relaunch.logtest_scout_accepts_an_explicit_baseandtest_scout_on_a_local_only_project_uses_the_local_branchpass at ffc71d3; the latter fails against the pre-fix script (scout-local-only-before.log)git statusbash tests/fm-spawn-base-branch.test.sh— 16 checks, all passbash tests/fm-control-relaunch.test.sh— includes the newbase=survives-relaunch assertion, all passManual CLI walkthrough: five realfm-spawn.shdispatches against a main-defaulted clone with anintegration/multicurrencybranch (explicit base, no-flag registry fallback, typo'd base refusal,--relaunch --baserefusal,--secondmate --baserefusal)Regression proof: newtests/fm-spawn-base-branch.test.shrun against the pre-fixbin/fm-spawn.shfrom 284be65 —a scout on a local-only projectfails there, passes at ffc71d3Isolation proof: pre-fix un-isolated secondmate invocation createsstate/in the ambient firstmate home; the fixed suite run leaves the ambient home untouched✅ **Document** - passed
✅ No issues found.
⏭️ **Lint** - skipped
✅ **Push** - passed
✅ No issues found.
CI attribution
Checks on head
0d6ea01d. Nothing below needs re-deriving..github/workflows/ci.yml:403asserted-eq 18whiletests/fm-fleet-snapshot-view.test.shemits 20ok -lines. Pin and its message updated to 20.11217bc— see CI run34445426890, job102769078629, byte-identical assertion lines and the sametotal=31 failed=1summary.The operation was canceled; it has no failing assertion of its own. An absent verdict is not treated as a pass.What this PR owns
bin/fm-spawn.shand its two colocated test files, plus thedocs/and skill lines describing base resolution. A scoped diff of11217bc..HEADagainsttests/fm-bearings-board-render.test.sh,tests/assets/board-render-harness.mjs,bin/fm-bearings*andlib/is empty.Serial 4 is a real defect, not a stale assertion
The cause has since been identified independently: a restricted expression in the board-render harness. It is invisible on a developer machine — the suite passes all 20 assertions locally in this worktree — and reproduces only on the CI runner, which is why it reads as environment-specific here. The fix is going through the pipeline on its own branch. This branch will be rebased onto it, after which these checks should go green on their own merits.
Duplicated fix, deliberately
The macOS snapshot pin fix is also on another branch. It is carried here on purpose: without it this branch sits red waiting on a branch that is itself still red. Both branches set the same value, so the duplication conflicts with nothing and costs nothing.
Workflow linting
The workflow half of the lint step did not run locally:
actionlint1.7.12 is not installed on this machine, and installing it was out of scope. ShellCheck passed clean at the pinned 0.11.0. CI runs the workflow lint itself, and its Lint check is green above.