feat(spawn): bootstrap project task environments before worker launch - #6
Conversation
…validated worktrees
|
Warning Review limit reached
Next review available in: 56 minutes You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository. How can I continue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews. How do review limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (8)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Pull request overview
Captain, this PR adds a project-opt-in, toolchain-agnostic “task bootstrap” step that runs after fm-spawn.sh has proven an isolated worktree and before any worker is launched, ensuring prerequisite failures stop spawns cleanly and deterministically (with a fingerprint-based skip for preserved environments).
Changes:
- Introduces
bin/fm-task-bootstrap.shto execute a tracked.firstmate/bootstrapdeclaration in bounded, noninteractive modes (fingerprint,run, optionaltimeout), persisting a per-worktree success fingerprint in the worktree’s Git dir. - Updates
bin/fm-spawn.shto run the task bootstrap before worker launch and to abort-cleanup tmux/zellij/cmux endpoints + return only a validated leased Treehouse worktree when spawn fails pre-meta. - Adds a dedicated regression suite
tests/fm-task-bootstrap.test.sh, wires it intobin/fm-test-run.sh, and documents the protocol and backend implications.
Reviewed changes
Copilot reviewed 5 out of 8 changed files in this pull request and generated no comments.
Show a summary per file
| File | Description |
|---|---|
bin/fm-task-bootstrap.sh |
New bounded, noninteractive runner for project-declared prerequisites with fingerprint skip/rerun and strict declaration validation. |
bin/fm-spawn.sh |
Runs task bootstrap before worker launch; adds abort cleanup for pre-meta failures and returns only validated leased worktrees. |
tests/fm-task-bootstrap.test.sh |
Regression coverage for declaration contract, bounds enforcement, fingerprint lifecycle, and fail-closed spawn behavior. |
bin/fm-test-run.sh |
Routes the new test and bootstrap script into the backend-dispatch test family selection. |
docs/configuration.md |
Documents .firstmate/bootstrap protocol, safety constraints, bound enforcement, and backend cleanup behavior. |
docs/architecture.md |
Notes the bootstrap step as part of the spawn lifecycle after isolated-worktree proof. |
docs/scripts.md |
Adds fm-task-bootstrap.sh to the scripts catalog. |
docs/herdr-backend.md |
Records the documented limitation that failed pre-meta Herdr spawns may leave residue requiring manual cleanup. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
* docs: define captain instruction precedence (#1362)
* docs: add captain-authorized inherent red-check merge exception
Keep the default red-PR ban and own one always-loaded exception in the
merge-authority section: captain-explicit PR or bounded batch plus exact
check, only when the failure is inherent to the selected delivery path.
Yolo cannot activate it; final head and the full current check suite must
be verified; other substantive failures remain non-waivable.
* docs: replace narrow red-check exception with captain precedence
Supersede the inherent failing-check merge exception with one always-loaded
Firstmate-local rule: a current explicit concrete captain instruction
overrides a conflicting Firstmate-written standing rule only within exact
scope, never above platform/system/developer instructions. Keep the ordinary
red-PR default and yolo boundary; point section 7 at the section 1 owner.
* docs: define validation supersession sequence (#1407)
* fix: give validation-time captain overrides a supersession sequence
The Validate section let a captain instruction that completely
invalidates the work being validated keep the same task and worker, but
never said how: the adjacent rule flatly bans hand-editing, committing,
aborting, or restarting during an active run with no carve-out, so a
worker facing full invalidation had no sanctioned path forward.
Add the missing sequence: cancel through no-mistakes axi's abort
command, confirm the run has stopped through axi status, recover branch
ownership through axi sync's guarded recovery, only then replace the
obsolete work, and validate once against the final head. The existing
ban on hand-editing an active run now cross-references this sequence
instead of contradicting it.
* no-mistakes(review): Make validation custody recovery conditional
* no-mistakes(document): Clarify validation supersession abort exception
* fix: keep obsolete pipeline commits out of the superseded deliverable
The review-applied fix made custody recovery conditional on
branch_sync.next_action.code, but left an open gap: recovering custody
settles who owns the branch, not what content ships. As written, a
worker could recover an obsolete run's branch and build the
replacement on top of its now-irrelevant commits instead of from the
correct pre-invalidation base, carrying obsolete content into the
final deliverable.
Make that explicit: custody recovery settles ownership, not content,
so the worker replaces obsolete work from the correct base and keeps
the obsolete run's commits out of what gets validated and shipped.
* no-mistakes(test): Restore minimal pre-invalidation replacement instruction
* fix: dedupe redundant "replace the obsolete work" restatement
Line 309 already says the worker replaces the obsolete work from the
correct pre-invalidation base, excluding the obsolete commits. The
closing sentence restated "replace the obsolete work" again before
gating the final validation run, layering the same fact twice instead
of stating it once.
Trim the closing sentence to just the ownership gate and the
single-run-against-final-head requirement it uniquely adds.
* fix: bind backend overrides to exact-task authority (#1413)
* fix: bind explicit --backend to exact-task authority
A Herdr-backed second mate carried a prior one-task --backend tmux
exception forward by analogy, so its child landed in tmux and never
appeared under the second mate in Herdr. Runtime detection was correct;
the authority surface was not.
docs/configuration.md now owns that an explicit --backend is authorized
only for that exact task. AGENTS.md and fm-spawn help point there.
* no-mistakes(document): Consolidate backend selection authorization documentation
* fix(herdr): prevent focus flashes during projected workspace cleanup (#1229)
* fix: remove projected workspaces through Herdr's focus-preserving pane-death path
Herdr 0.7.5's explicit close of a workspace-emptying last pane moves the
attached client's focus to a neighbor workspace, flashing the captain's
whole window and routing in-flight keystrokes to the wrong pane until
Firstmate's exact-tab restore masks it 56-197 ms later.
Teardown and cleanup now plan a workspace-emptying close as a focus-safe
removal: verify the close empties the workspace, reposition the doomed
workspace behind the focused one through the verified workspace.move
transport when it sits before a non-last focused workspace, prove the pane
holds one lone idle shell, and end that shell so Herdr removes the emptied
workspace through its focus-preserving pane-death path. Any ambiguity or
failure falls back to the plain close behind the existing restore backstop,
and fm_backend_herdr_kill applies the same plan for non-projected removals.
Two conditions proven on real hardware are encoded in the adapter: BSD ps
reports a login shell's comm as "-zsh", and an idle shell transiently
hosts a prompt helper right after a workspace.move relayout, absorbed by a
bounded strict-sample settle window in the idle-shell proof, now the single
owner shared with session-start cleanup.
An isolated-lab regression reproduces the raw steal on 0.7.5 and proves the
plan removes a doomed workspace with zero wrong-focus samples and no
corrective focus; unit fixtures cover the position, edge, ambiguity, move
and kill failure, escalation, and transient-helper cases. Upstream fixes
(#1877 explicit close, #1912 pane death) are merged but unreleased; once
released the plan degrades to a harmless reorder-then-remove.
* no-mistakes(review): Confirm pane death from structured not-found responses
* no-mistakes(review): Serialize Herdr kills and sample focus continuously
* no-mistakes(review): Synchronize Herdr focus evidence output
* no-mistakes(review): Refuse unlocked Herdr pane closes
* no-mistakes(document): Correct Herdr focus-safety documentation
* no-mistakes: apply CI fixes
* fix: never erase a Herdr task's records while its pane survives a refused close
A transient presentation-lock contention could produce a completed teardown
while the exact Herdr pane stayed alive as an unowned restored shell: the
kill refused the unlocked close (correctly), returned success, the warning
was suppressed, and cleanup erased the task's status, turn-end, and
metadata records after the isolated copy had already been returned.
Teardown now acquires the named-session presentation lock before anything
destructive: a contended lock refuses up front while the isolated copy, the
task branch, every durable record, and the endpoint are all intact for a
plain rerun, and the projected and flat close paths both run under that one
held lock instead of acquiring their own. Durable records are erased only
once the exact pane is confirmed gone through its structured presence; a
refused, skipped, or failed close retains every record with a visible,
retryable error, and after a skipped close (unresolvable lock path) only a
structured pane_not_found counts as gone - unknown never does.
The teardown regression drives a live contending lock holder end to end:
the refusal touches nothing (no worktree return, no branch drop, no close
attempt), and the retry after release returns the copy, closes the pane
under the lock, and removes the records. The unconfirmed projected close
now refuses with records retained, and the structured-presence gate has a
strict/default unit matrix.
* no-mistakes(review): Require structured pane-not-found before Herdr record removal
* no-mistakes(document): Correct Herdr record-retention verification date
* fix: refuse ambiguity, revalidate SIGKILL ownership, and roll back failed removals
Three accepted-contract corrections from the post-CI personal review of the
Herdr keep-spaces focus-flash mitigation.
Ambiguous endpoint identity no longer counts as a confirmed-gone pane: a
missing or malformed target refuses record removal in the structured
presence gate, and teardown treats missing confirmation machinery as a
refusal instead of skipping the gate, so only an exact structured
pane_not_found ever erases durable task records.
The pane-death SIGKILL escalation re-reads the exact pane's process
information and refuses to signal unless the same shell pid still passes
the strict bare-idle ownership proof, so a pid that exited and was reused
by an unrelated process is never signaled; the refused escalation falls
back to the plain close with the unrelated process untouched.
A reposition whose removal is not confirmed no longer outlives the attempt:
the emptying-close plan records the verified pre-move order and original
index whenever it invokes the mover, and both close owners restore the
exact original workspace order through a second verified move, under the
same held session lock, before reporting the close as failed.
Each defect was reproduced first: the unit matrix documented malformed
identity as gone, the PID-reuse regression showed SIGKILL reaching a
disowned pid, and the rollback regression showed a single unrestored move.
Teardown-level regressions cover unparseable presence retention alongside
the strict identity matrix.
* no-mistakes(review): Require confirmed Herdr removal and resolvable teardown locks
* no-mistakes(review): Enforce structured Herdr closes and teardown preflight
* no-mistakes(review): Preflight explicit Herdr close confirmation helper
* no-mistakes(document): Document Herdr rollback failure semantics
* no-mistakes(review): Captain, harden recursive Herdr teardown safety
* no-mistakes(document): Document recursive Herdr teardown evidence
* fix: retain nested secondmate home when a recursive child cleanup fails
Captain-decided Option A correction for nm-askuser-flash-r6, found during
complete-diff rereview of the merged head.
cleanup_firstmate_home_children's recursive secondmate branch called
itself for a nested child's home without checking the result, then
unconditionally removed that home right after. remove_firstmate_home
ends in an unconditional recursive delete with no check for leftover
records, so a nested secondmate whose own Herdr grandchild failed its
confirmed-gone check would have its entire home - retained grandchild
records included - erased by the very next line.
Guard the recursive call the same way every other fallible call in this
function already is: || return 1, skipping remove_firstmate_home and
leaving the nested home and its records for a safe rerun.
Empirically, fm-teardown.sh's set -eu already halted the script on the
prior unguarded call before reaching removal (verified by hand with the
guard reverted, under both this session's bash and stock macOS bash
3.2) - the reachable behavior was already correct. The explicit guard
is still applied exactly as decided: it matches every sibling call site
in the function, and it stops the correctness of this path depending on
errexit's well-known fragility under refactors (a wrapping if/&&, or a
future subshell) rather than on an explicit check.
Adds a teardown-level regression building on the existing direct-child
Herdr fixtures: a top-level secondmate contains a nested secondmate,
whose own Herdr child's close goes unconfirmed. Proves through the
public fm-teardown.sh interface that the nested home, the nested
secondmate's own record, and the grandchild's metadata and status all
survive, and that the top-level secondmate's record survives too.
* no-mistakes(document): Document nested Herdr teardown retention
* fix: prioritize completion runway in quota-aware dispatch (#1431)
* fix(dispatch): prioritize quota completion runway
* no-mistakes(document): Document completion-aware quota runway selection
* fix(bin): preserve full task contract in no-mistakes intent (#1447)
* Preserve task contract in no-mistakes intent
* no-mistakes(review): Preserve complete current task contract in no-mistakes intent
* fix(bin): parse punctuated secondmate registry entries safely (#1452)
* fix: centralize secondmate registry parsing
* no-mistakes(review): Centralize secondmate registry binding validation
* no-mistakes(review): Harden registry EOF and symlink validation
* no-mistakes(review): Reject unreadable registries before parsing
* no-mistakes(document): Document punctuation-safe secondmate registry validation
* no-mistakes: apply CI fixes
* feat(bin): add durable process-event supervision (#1483)
* feat(procevent): supervise long-polling sources into durable events
Firstmate had no way to wait on a blocking external process without holding
a conversational turn. Add a domain-neutral process-to-event runner plus a
thin adapter around the currently published `lavish-axi poll` interface:
canonical physical source identity, one machine-wide owner per source, direct
argv execution, and durable 0600 result capture before any event referencing
it is published on the existing wake queue. No second notifier, no polling
control plane, and no retry machinery.
A captured result with no durable handled acknowledgement stays eligible for
bounded re-announcement across any number of drains and restarts. Draining a
wake before acting on it and then starting a replacement session resurfaces
the same exact source and sequence, and never puts result payload text in an
event line. `fm-procevent.sh handled <source-id> <sequence>` is the only thing
that stops re-announcement: generation-keyed, private, path-safe, durable, and
atomically idempotent, so a paired external effect gated on its first-time
versus repeat report is never authorized twice.
An acknowledgement is refused unless matching captured result and adapter
records already exist, so a premature or mistyped call cannot suppress a
future result.
The source side is unchanged and still lossy: the published poll clears
feedback destructively before returning it, so a result lost in that window
is unrecoverable. This is never at-least-once, no-loss, or lossless, and the
handled acknowledgement is not a generic exactly-once effect either - a crash
between an external effect and its acknowledgement can still repeat that
effect on replay.
Integrate registered sources with watcher supervision, the guards, and
recoverable secondmate teardown across nested homes, and cover source
identity, lifecycle races, supervision, restart handling, and cleanup safety
with regressions.
* no-mistakes(review): Prevent Lavish prompt text from spoofing missing sessions
* no-mistakes(review): Serialize publication and secure handled acknowledgements
* no-mistakes(document): Document hardened process-event acknowledgement guarantees
* fix(procevent): never reclaim a source whose owned group still runs
A runner is its own process group leader and starts the blocking source in
that group, but the claim records only the leader PID and its identity. If the
leader died while the source child kept running, the missing PID was
classified stale: reconciliation released the claim and started a second
runner while the old blocking source was still consuming the same canonical
source. For the Lavish adapter that means two destructive long polls racing on
one review session, so it is not harmless process litter. It also contradicted
the documented promise that ownership is never released until the whole group
is gone.
Ownership state now distinguishes a generation that is really gone from one
whose leader crashed with its group still alive. Reconcile stops that
surviving group and releases its exact generation before starting any
replacement, and keeps the claim for a later cycle when it cannot prove the
group stopped or another home owns it. Acquisition and `start` treat the same
state as held rather than reclaimable.
Signalling that group is safe precisely because only an absent leader reaches
this state. A reused PID leaves the leader alive, so the identity comparison
still classifies it stale or uncertain and no group signal follows, which
keeps the existing PID-reuse refusal intact.
Add a public-interface regression for the exact crash cut - SIGKILL only the
leader, prove the child group survives, reconcile, and prove the old group is
gone with no second source running - plus its counterexample that a generation
with no leader and no surviving group is still reclaimed. Update the runner
help, operating documentation, skill, and verification record where they
described reclaim in terms of the leader alone.
* no-mistakes(review): Enforce runner group ownership and detect poller overlap
* no-mistakes(review): Isolate runner groups from unrelated caller processes
* no-mistakes(document): Document isolated process-event runner launch
* no-mistakes(lint): Suppress Perl literal ShellCheck false positive
* fix(bin): retire terminal process events and surface queued wakes (#1500)
* fix(bin): deliver process-event results and retire ended sources
Two defects reproduced during a real Lavish adapter session.
One human `Send & End` produced four captured results: the real feedback,
then recurring empty ended sessions. The generic runner had no way to learn
a source was finished, so every reconcile restarted a poll that returned
immediately. The runner now asks the source's own adapter -
`fm-procevent-<adapter>.sh terminal <result-file>` - and on exit 0 alone
re-proves ownership, drops the registration, and releases its own claim
under one source boundary. Terminal knowledge stays adapter-owned: for
Lavish that is an ended session, a missing session, and the final feedback
delivery the published poll marks with `session_ended`. An adapter with no
terminal command keeps its source armed exactly as before. Capture before
publication, captured-result durability, queued wake durability, bounded
re-announcement, handled deduplication, one-owner ownership, and explicit
idempotent retirement are all unchanged.
A captured result queued its `check` wake durably, but a healthy watcher
with a fresh beacon never delivered it; the result surfaced only after a
manual drain. Publication happens outside the watcher (in the runner) or
unconditionally (in reconcile), so the watcher had no newly actionable
signal to report and never reached its rewake path. It now reports a
queued-but-unsurfaced process-event record through the same actionable exit
every other wake uses, deduplicated by the same `.seen-*` marker discipline
the signal scan uses, so the record is always durable before it is
suppressed. The durable queue remains the authority and no second notifier,
poller, timer, queue, or adapter-specific wake path is added.
Regressions cover both, driven end to end: an armed Lavish source against a
stand-in for the published poll polls once, captures once, publishes one
distinct event, and retires itself; two fixture adapters prove the terminal
decision follows the adapter alone; and a real capture plus a real watcher
prove one proactive wake before any drain, with no duplicate wake while the
record stays queued or after it is acknowledged.
* no-mistakes(review): Harden process-event retirement and proactive delivery
* no-mistakes(review): Route process-event delivery through shared wake owner
* no-mistakes(document): Clarify process-event delivery and retirement documentation
* no-mistakes(lint): Fix ShellCheck control-flow warnings
* no-mistakes(lint): Fix wake output status lint warning
* perf: shard portable serial tests across CI runners (#1544)
* perf(ci): shard the portable serial behavior lane across runners
The Behavior portable serial job ran all 69 scripts of the serial
remainder on one runner. The measured serial sum on run 30725985757 was
1143762 ms (19m04s) against a 20-minute timeout, so the job intermittently
reached the cap and was cancelled with every step passing. Setup is only
about 7s, so the cost is entirely test wall time.
Split the lane into four separate-runner shards. Each shard is still
strictly serial, and separate runners mean no two of these stateful
scripts ever share a machine, so the split needs no concurrency isolation
proof. Assignment is longest-processing-time bin packing over measured
per-script duration hints, balancing every shard to 285941 ms (~4m46s) of
expected work, and the timeout tightens from 20 to 15 minutes.
bin/fm-test-run.sh owns the shard count and refuses a lane whose "ofN"
disagrees with it, while ci.yml derives the same count from
strategy.job-total rather than a literal, so changing it in either file
alone fails the lane loudly instead of leaving part of the required suite
unrun. --check-coverage additionally proves the shards are non-empty,
disjoint, and exactly equal to the serial lane. No test is weakened,
skipped, or removed.
Also replace the wall-clock sleeps in the --jobs scheduler test fixture
with an explicit signal handshake between the fixtures. The old
0.5s-versus-0.05s race failed on a loaded machine; the handshake passes
under sustained CPU saturation.
* no-mistakes(review): Correct portable serial shard balance evidence
* no-mistakes(document): Document portable serial shard evidence accurately
* fix(bin): correct session lock and attached watcher supervision (#1545)
* fix(bin): identify harness sessions by path and report delivered wakes
Two supervision faults, both reported by a contributor and both open on the
default branch.
Fault 1: the Stop auto-arm never claims the home. fm_harness_ancestry_pid()
matched only the basename of `ps -o comm=`, and Claude Code's native installer
names the per-session executable by its version (.../share/claude/versions/
2.1.220), so that basename identifies nothing. Three real failure shapes follow:
a version-named session is missed entirely and the hook exits 0 with the epoch
never written (unconditional on Linux, where procps reports the kernel exec name
and ignores argv[0]); a claude-named daemon that directly parents sessions wins
the outermost-contiguous-claude rule ahead of the session itself; and a session
that is both version-named and daemon-parented has its live lock reclaimed as
stale and rewritten to the shared daemon pid, corrupting the home's ownership
record.
Harness identity now also reads whole components of the executable path and of
argv[0], which is what both platforms still carry. Matching whole components
only keeps that widening safe: bin/fm-claude-stop-autoarm.sh and ~/.claude/hooks
scripts have no "claude" component. Ownership is then decided against the
session's whole contiguous harness ancestry rather than one chosen pid, which is
the honest form of the question the library already documents ("does the current
process descend from that same harness?"). That subsumes the outermost-pid rule
for Claude's nested bg-spare worker chain instead of reverting it, and lets a
daemon-parented session recognize its own lock. Lock acquisition still writes the
outermost pid of the run, the only pid that lives as long as the session.
Fault 2: an attached arm reports a delivered cycle as FAILED. The watcher prints
its one reason line to its own stdout, so only the arm that forked it can read
that line; an arm that attached observes nothing but a released lock and called a
completely successful cycle "cycle ended without an actionable reason". No
supervision event was lost - the durable queue held it - but every harness
protocol reads that line as "supervision is down" and directs a manual re-arm.
The arm now resolves an unobservable close against the durable wake queue, which
records every wake before the watcher prints it and whose sequence counter never
rewinds, not even across a drain. A cycle the queue proves delivered a wake
reports that wake and exits 0; a cycle whose records a handling turn already
drained reports the delivery without inventing a reason line; only a cycle that
delivered nothing is still the typed nonzero failure. Fixing it in the arm covers
codex, opencode, pi, grok and kimi, not just the Claude Stop path.
Regressions: tests/fm-session-lock-ancestry.test.sh pins both platforms' ps
semantics behind a deterministic process table and runs the real Stop auto-arm in
version-named, daemon-parented, and combined real process trees, each orphaned so
the walk cannot escape the fixture. tests/fm-watch-arm.test.sh drives a real
watcher and a real attached arm through a real wake. Every fault case fails on
the previous code.
* no-mistakes(review): Bind watcher delivery records to process identity
* no-mistakes(review): Return validated watcher identity atomically
* no-mistakes(review): Track watcher successors by PID and identity
* no-mistakes(document): Consolidate watcher arm-cycle documentation ownership
* fix(bin): harden Claude supervision auto-arm recovery (#1495)
* fix(supervision): harden Claude auto-arm failure handling
* no-mistakes(review): Guarantee automatic retry after Claude auto-arm failures
* no-mistakes(review): Gate attended fail-open on verified supervision failure
* no-mistakes(document): Document Claude auto-arm retry and guard scope
* no-mistakes: apply CI fixes
* fix(supervision): make Claude fail-open progression monotonic
* no-mistakes(review): Preserve auto-arm failure episodes until verified watcher recovery
* no-mistakes(review): Linearize auto-arm failure progression across existing locks
* no-mistakes(review): Linearize positive recovery across shared failure episode lock
* no-mistakes(review): Scope Claude recovery contention to Claude guard mode
* no-mistakes(document): Align supervision auto-arm documentation
* no-mistakes(review): Preserve actionable wakes despite healthy successors
* no-mistakes(document): Refresh supervision auto-arm documentation
* feat(bin): require an explicit per-task delivery contract (#1563)
* feat(bin): require an explicit ship delivery mode in fm-brief
A ship brief's definition of done was shaped by a silent per-project registry
lookup, so an adjusted brief and the task's recorded delivery could disagree and
no one had to decide anything per task.
fm-brief now requires --mode on ship scaffolds, validates it against the closed
set, refuses the conditional no-mistakes-prod-only registry policy as a task
mode, and records the choice as a fixed machine-readable "Delivery contract:
mode=<mode>" line that fm-spawn can check. --mode is refused on scout and
secondmate scaffolds, and --yolo is refused outright because the worker never
owns approval decisions.
* feat(bin): require an explicit ship delivery contract at spawn and promotion
fm-spawn resolved every ship and scout task's mode and yolo from the project
registry, so the delivery posture was never a per-task decision and could
contradict the brief the worker was about to follow.
fm-spawn now requires --mode and --yolo on ship spawns, validates both against
their closed sets, and reads the brief's recorded delivery contract line and
refuses a mismatch before any endpoint exists; a brief scaffolded before that
line existed warns once and launches on the flag. A batch carries one shared
contract that each pair still checks against its own brief. Scout and secondmate
spawns refuse the flags, and a scout now records no mode or yolo at all, which
teardown and the snapshot already tolerate. When the explicit mode carries less
rigor than the project's standing posture, a deviation notice is printed and the
spawn continues, so the registry stays advisory rather than an enforced default.
fm-promote requires the same two flags, because a scout carries no posture to
inherit, and writes them into the task record with the kind flip.
fm-project-mode keeps its one registry parser for the mechanical consumers that
have no task in hand, accepts the conditional no-mistakes-prod-only annotation
and maps it to its most rigorous leg for them, and grows --raw so the deviation
notice can tell a conditional policy apart from a flat mode.
* docs: record the explicit per-task delivery contract
AGENTS.md section 7 now owns how each ship task's mode and yolo are resolved at
intake, including the surface classification for a no-mistakes-prod-only project
and the unregistered-project fallback, and the project-management skill defines
that conditional policy as a registration-time posture with its defaults and
initialization consequences. The registry blurb, script table, and architecture
section follow: the registry records the captain's standing posture, and task
delivery is decided per task and passed explicitly.
* test: pass ship delivery flags per call site in the Herdr launcher e2e
The shared spawn helper also launches a secondmate, which refuses the flags, so
the contract belongs at each ship call site rather than inside the helper.
* test: pass the ship delivery contract in the secondmate suites
Both suites scaffold or spawn an ordinary ship task as the control case for a
secondmate assertion, so each needs the explicit contract the ship path now
requires.
* feat(bin): support remote secondmate homes (#1576)
* Add generic remote secondmate transport
* Add routed remote secondmate replies
* Add remote outbox backlog handoff
* Integrate remote secondmate lifecycle
* no-mistakes(review): Fix remote snapshot and handoff races
* no-mistakes(review): Serialize remote home provisioning transactions
* no-mistakes(review): Harden remote lifecycle transaction boundaries
* no-mistakes(review): Serialize remote lifecycle mutations and fail closed
* no-mistakes(review): Close remote lifecycle and file race windows
* no-mistakes(review): Serialize remote reply retirement and inheritance
* no-mistakes(review): Harden remote transfer integrity and recovery
* no-mistakes(review): Serialize remote respawn with registry retirement
* no-mistakes(document): Document remote bootstrap convergence accurately
* no-mistakes(document): Clarify skipped remote secondmate mutations
* no-mistakes(lint): Resolve remote script ShellCheck warnings
* no-mistakes: apply CI fixes
* feat: add per-task trace context propagation (#995)
* feat(spawn): propagate a native W3C traceparent to spawned agents
Add a default-off capability that resolves one W3C traceparent for a task,
injects it into the agent's pane shell as the TRACEPARENT environment
variable immediately before launch, and records the identical value as
traceparent= in state/<id>.meta, so an external observer that explicitly
reads that env value or meta field can correlate a worker, a Secondmate, and
their nested children into one trace with no collector, storage, UI, or
vendor coupling.
TRACEPARENT as an environment variable is a firstmate convention carrying a
W3C-formatted value: W3C Trace Context standardizes the header, not an env
var, and OpenTelemetry SDKs do not read it automatically, so a downstream
must consume it deliberately; this feature parents no SDK span by itself.
Identity is per task, not per spawn: the carrier is minted with random ids on
the first spawn, adopted as a child (fresh span, same trace) for a nested
spawn whose parent already holds one, and reused verbatim from the meta on
relaunch, so a task keeps one stable logical identity across restarts. A
malformed or all-zero inherited value is treated as absent and roots a fresh
trace. A new root is sampled (01) - a sampling decision a downstream
parent-based sampler honors, not a guarantee that any collector stores a
span, and firstmate emits no spans; a child preserves the inherited flag.
Trust boundary: a firstmate-minted root is random and reads no prompt, path,
task prose, credential, or arbitrary environment key. An inherited
TRACEPARENT is opaque caller-controlled data - up to 24 bytes of id passed
through after syntax validation - so whoever set it controls those bytes, a
bounded fixed-width channel rather than a general content or secret channel.
The feature adds no OTEL_* variable, no tracestate, and no arbitrary
environment injection; it runs no configurable or arbitrary command, only the
fixed local od and tr (resolved from PATH) to read a few bytes of entropy - a
small local pipeline with no network or watchdog and no hard latency
guarantee. Any entropy or validation failure that returns omits the carrier
without aborting the spawn. A default-off spawn leaves the generated meta and
launch environment unchanged.
Enablement is default-off (config/trace-context, or FM_TRACE_CONTEXT where a
non-empty value overrides and unset or empty defers to the file) and is
propagated into secondmate homes, taking effect at each agent's next launch:
a Secondmate launched or relaunched after enablement carries the primary
trace into its nested workers, while an already-running Secondmate roots new
traces for its own workers until relaunched. Injection reuses the existing
GOTMPDIR channel, so all spawn backends and harnesses and the ship, scout,
and secondmate paths are covered.
Covered by a pure-library suite and a spawn-path integration test (fake tmux
plus a real worktree, hermetic against ambient FM_TRACE_CONTEXT) proving the
recorded and injected carriers are identical and sent before launch, that
default-off writes and injects neither, that a relaunch reuses the recorded
carrier, and that an explicit FM_TRACE_CONTEXT overrides the file both ways;
plus a source-owner inheritance test proving trace-context propagates and
absence-mirrors through propagate_inheritable_config.
Documentation follows the repository documentation-audiences contract:
docs/trace-context.md is maintainer-architecture rationale, the configuration
schema lives in docs/configuration.md, and the repeatable test evidence is
separated into docs/verification/trace-context.md (maintainer-verification),
registered in docs/documentation-audiences.json.
* fix(spawn): propagate the effective trace-context decision to secondmates
FM_TRACE_CONTEXT overrode trace context only in the process that read it. A
newly launched secondmate decided enablement from the inherited
config/trace-context file alone, so the override did not cross the
primary-to-secondmate boundary: FM_TRACE_CONTEXT=off with the file present left
the secondmate's nested workers traced (a broken kill switch), and
FM_TRACE_CONTEXT=on with the file absent left them untraced despite the
inherited carrier.
Deliver the primary's effective decision to a newly launched secondmate as a
normalized on/off FM_TRACE_CONTEXT in the launch prefix, so a FM_TRACE_CONTEXT
override governs the nested primary -> secondmate -> worker chain both ways, not
just the copied file. The value is bounded to the literal on/off and does not
broaden environment injection; the already-running secondmate boundary is
unchanged.
Add a genuine two-level spawn regression that drives fm-spawn twice with the
exact environment the primary injects into the secondmate and proves both
divergent directions end to end. Correct the documentation that implied
secondmate coverage on every backend, since orca and cmux reject secondmate
spawns, and refresh the verification evidence for the new assertion count.
* no-mistakes(review): Clarify Secondmate trace-context launch snapshots
* no-mistakes(document): Correct trace-context documentation ownership and relaunch semantics
* fix(spawn): resolve the trace-context decision once for carrier and snapshot
The effective trace-context decision was read twice per spawn: once inside
fm_trace_context_resolve for the recorded carrier, and again for the secondmate
FM_TRACE_CONTEXT launch snapshot. A config-file change between the two reads
could pair a carrier with the opposite enable state - an injected carrier with
an off snapshot, or no carrier with an on snapshot.
Freeze the effective on/off decision once, drive the carrier resolution under
that frozen FM_TRACE_CONTEXT so it cannot independently re-read the file, and
reuse the same frozen decision for the secondmate launch snapshot. Add a
spawn-path regression that drives the file-decided path and proves the recorded
carrier and the delivered snapshot always agree, and refresh the verification
evidence for the new assertion count.
* no-mistakes(review): Preserve legacy Secondmate trace boundary
* no-mistakes(document): Correct trace-context verification comparison base
* no-mistakes(review): Captain, prevent failed trace delivery metadata claims
* no-mistakes(review): Captain, align trace-context tests and verification evidence
* no-mistakes(document): Correct trace-context verification evidence
* no-mistakes(lint): Suppress intentional ShellCheck literal-dollar warnings
* no-mistakes(review): Captain: freeze trace context at session start
* no-mistakes(test): Captain: stabilize scheduler test and document Kimi trace coverage
* no-mistakes(document): Document trace-context safety boundaries
* fix(trace): fail off on stale session snapshots
Publish each home session decision atomically through a same-directory temporary file and bind it to the current session lock. A replacement failure can no longer leave an earlier on decision active in a later session; missing, stale, malformed, or unpublishable state defaults safely to off.
Add regressions for read-only replacement and failed publication, update spawn and session-start fixtures for the lock-bound format, and refresh the architecture and verification records.
* no-mistakes(review): Fix trace spawn failure independence and duplicate safety
* no-mistakes(document): Refresh trace-context documentation and verification
* no-mistakes(review): Clear partial backend input after failed trace submission
* no-mistakes(review): Stop unsafe trace delivery before launch append
* no-mistakes(document): Document unsafe trace delivery handling
* fix(trace): bound each trace to one routed task, never the routing agent
A persistent Secondmate holds its launch-time TRACEPARENT in the process
environment for its whole life, and routed requests never replace it, so
resolving new-task carriers from the ambient environment chained every
routed task into one ever-growing trace per Secondmate with distinct
parent ids. Resolve now reuses the task's recorded carrier or mints a
fresh sampled root, never reading ambient TRACEPARENT, so each routed
task is its own trace boundary while relaunch, recovery, and
scout-to-ship promotion keep one stable per-task identity.
The spawn regression models the reviewed scenario exactly: two unrelated
tasks spawned sequentially through one persistent Secondmate environment
record and inject distinct trace ids, adopt nothing from the Secondmate's
carrier, and a relaunch of the first task reuses its original carrier
verbatim.
* docs(trace): define the per-task trace boundary
The design contract is one task per trace: a persistent Secondmate is
routing infrastructure with its own agent identity, never a shared trace
root for the unrelated tasks routed through it. Root/recovery semantics
replace the removed child-inheritance path, the sampling and safety
sections drop inherited-carrier language because ambient TRACEPARENT is
never read, and the verification page records the refreshed suite
inventories including the two-task Secondmate boundary regression.
* test(trace): adopt the explicit per-task delivery contract in spawn fixtures
Rebasing onto current main brings the explicit per-task delivery contract:
ship spawns now require --mode and --yolo instead of resolving them from the
project registry. The trace spawn fixtures pass the same explicit contract
canonical spawn tests use, preserving the per-task trace boundary coverage
unchanged, and the verification page records the refreshed comparison base.
* fix(bin): harden tmux agent liveness across harnesses (#1577)
* fix(bin): classify tmux agent liveness independent of process titles
`fm_backend_tmux_agent_state` attributed a pane solely from
`#{pane_current_command}`, which is a process TITLE a harness can rewrite,
not a structural fact. Claude Code 2.1.220 reports its version string there,
so a live Claude endpoint classified `ambiguous`: the session-start secondmate
liveness sweep could no longer see it, and any consumer that gates on a
positive classification refuses outright.
Read a second, independent name source: the kernel `comm` of every process in
the pane tty's foreground process group. Either source naming a verified
harness yields `alive`, because a false `dead` is the one verdict that can
start a duplicate agent on a live worktree. Scoping to the foreground process
group rather than the pane's descendants keeps a harness-named background
process from faking an agent, and covers multi-process launchers (the Pi
Launcher path) without a special case.
Verified on 2026-08-03 against all seven adapters running for real on tmux
3.6a / macOS 26.5.2 arm64: claude 2.1.220, codex-cli 0.146.0, opencode
1.18.11, pi 0.82.0, pi-signed 0.82.0, grok 0.2.118, kimi 0.31.1 all classify
`alive`, each attributed by a source independent of its title.
Two tests, because they fail for different reasons:
- tests/fm-tmux-agent-liveness.test.sh pins the logic with real processes and
no harness, so it runs everywhere CI runs tmux. It drives the two name
sources apart on purpose and asserts the divergence, so no case can go
quietly vacuous.
- tests/fm-harness-liveness-drift-live-e2e.test.sh relaunches every installed
harness and fails naming the harness and version when one stops being
attributed by a title-independent source.
AGENTS.md section 4 carries the resulting standing rule, and
firstmate-coding-guidelines owns how to satisfy it.
* no-mistakes: apply CI fixes
* docs: move the harness-dependent-check policy out of AGENTS.md
The standing rule was stated in AGENTS.md section 4 with the mechanics in
firstmate-coding-guidelines, which split one contract across two owners and
charged every session for a rule that only fires when firstmate's own
harness-dependent code is being changed.
firstmate-coding-guidelines is now the single owner of both the rule and how
to satisfy it: real-harness proof required, that proof authorized to spend
tokens, structural signals preferred over vendor-rendered surfaces, and a
guard that fails loudly naming the harness and version where a surface signal
is unavoidable. No inline stub is left behind, because AGENTS.md already
carries the load trigger for that skill in sections 7 and 13, so it is read
before any change to firstmate's shared tracked material.
Also records the cross-platform lesson the pipeline caught in the portable
regression, and corrects that file's header: the divergence assertion lives
on the version-string case, which diverges on both supported platforms,
rather than on every case.
* no-mistakes(review): Harden tmux liveness identity and drift validation
* no-mistakes(document): Clarify cross-platform tmux liveness documentation
* feat(bin): propagate trace context to remote secondmates (#1609)
* feat(bin): trace remote secondmate routes and unify the inherit allowlist
Per-task W3C trace context (#995) resolved and injected its carrier only at
the local spawn path. A remote secondmate is routed through
spawn_remote_secondmate, which returns long before that site and wrote its own
metadata block, so a remote secondmate stayed silently untraced even with the
capability enabled.
The parent home still owns that task's identity, because it holds the metadata
an observer reads. It now resolves the carrier against the task's own meta
under its own frozen decision - reused verbatim on relaunch, freshly rooted
otherwise, never adopting the parent process's ambient TRACEPARENT - and hands
it to the configured host through a new fm-spawn --traceparent argument,
accepted only for a secondmate launch and only as a strict W3C value. The
remote host exports it at the same unconditional pre-launch site and reports
back the carrier its endpoint actually holds, which the parent records, so an
already-alive endpoint reports the identity its agent really received rather
than one the parent merely intended. Disabled remains byte-identical and off.
The remote inherit path also carried its own hardcoded copy of the inheritable
config set, already drifted from FM_INHERITABLE_CONFIG by trace-context. Both
remote ends now derive from that one declaration, so a future item cannot be
sent by one side and refused by the other, and session-scoped enablement items
are skipped on live convergence exactly as the local path skips them.
Also fixes a latent stderr leak: an absent session lock printed a raw redirect
failure, which the new remote resolve site made visible.
Adds tests/fm-remote-secondmate-trace-context.test.sh, driving the real
parent -> fm-on -> remote entrypoint -> control -> remote fm-spawn chain over
the deterministic SSH boundary and reading the carrier back from the remote
pane's own log.
* no-mistakes(document): Clarify remote trace and allowlist contracts
* feat(bin): preflight remote runtime tool paths (#1623)
* feat(bin): widen the remote runtime PATH and add a remote doctor preflight
The fixed remote entrypoint hard-coded a four-directory PATH, so a remote
account whose tools live under nix or a per-user profile could not run basic
Firstmate work without a login shell. The entrypoint now composes its child
PATH from the code root's bin, the account's ~/.local/bin, the common
package-manager directories that actually exist on the host, and the portable
system tail, deduplicated and in a fixed order, still under env -i with the
same variable allowlist and no shell command string.
fm-remote-doctor.sh reports that exact PATH by inheriting it from its own
entrypoint launch rather than recomposing it, so the ordering keeps one owner.
It is read-only, reports where each required and optional tool resolved, and
exits non-zero naming every required tool that did not. Remote seeding runs it
as a preflight before anything is created on the host and restores the registry
when it fails.
* no-mistakes(review): Harden remote git authorization and missing-tool diagnostics
* no-mistakes(document): Document remote PATH doctor and safe shims
* no-mistakes(lint): Fix ShellCheck findings in remote path tests
* no-mistakes(lint): Suppress exported fixture's false-positive ShellCheck warning
* feat: gate remote second mates on Herdr readiness (#1639)
* feat(bin): gate remote second mates on herdr readiness
A remote second mate now always runs on the Herdr backend, whose server
belongs to the host's GUI login session and therefore outlives the SSH
connections that supervise it. fm-spawn's remote route forces that backend
and the host-local control script refuses any other, so the requirement
cannot be dropped from either side.
fm-remote-doctor.sh becomes the single owner of what "ready" means. It keeps
its PATH and tool reporting from #1623 and adds the Herdr, Aqua LaunchAgent,
GUI-session, server-reachability, and entrypoint-symlink checks, tagging each
gap fixable: or human: with the exact operator step. --fix closes only the
automatable gaps - writing and loading the Aqua-scoped dev.firstmate.herdr
launch agent, starting the server where no launch agent applies, and
recreating the entrypoint symlink - then re-derives every check from the host,
so a human gap is never presented as fixed. It never creates a login session,
writes an auto-login password, or touches FileVault.
Remote seed, remote spawn, and the startup liveness relaunch all run the same
check, repair, re-check sequence through one shared library and fail closed
with the doctor's own gap text. Recovery inherits the gate because it respawns
through the same route.
Tests drive the real doctor against a controlled account fixture with a
private HOME, a state-backed launchctl, and a fake herdr, and prove the
dangerous actions are never attempted. The remote lifecycle suites gain a
stateful Herdr CLI fixture and answer the readiness gate at the SSH boundary,
so they never inspect or repair the runner's own account.
* no-mistakes(review): Validate launch-agent contract and confirm Herdr startup
* no-mistakes(review): Validate loaded launch-agent contract before readiness
* no-mistakes(review): Refuse legacy remote backends without altering routes
* no-mistakes(review): Clarify conditional remote readiness repair sequence
* no-mistakes(review): Repair remote readiness before liveness probing
* no-mistakes(review): Preserve unknown seeds and reject legacy liveness
* no-mistakes(document): docs: clarify remote Herdr backend ownership
* fix: isolate remote secondmates in shared Herdr session (#1659)
* Pin remote secondmates to fm-remote
* no-mistakes(review): Fail closed on legacy remote Herdr endpoints
* no-mistakes(review): Isolate fm-remote launch agent from interactive default
* no-mistakes(document): Document shared remote Herdr retirement safety
* feat: route remote commands through an Aqua job worker (#1660)
* feat: run remote commands through Aqua job worker
* no-mistakes(review): Enforce remote job deadlines and safe worker shutdown
* no-mistakes(review): Refresh stale workers and harden dependency-free supervision
* no-mistakes(review): Harden worker ownership recovery and shutdown quarantine
* no-mistakes(review): Fix doctor bootstrap, harness repair, and output draining
* no-mistakes(review): Probe doctor tools through authenticated worker bootstrap
* no-mistakes(review): Refresh stale workers before doctor tool probes
* no-mistakes(review): Recover stopped quarantines and extend job deadlines
* no-mistakes(review): Separate queue and execution timeout windows
* no-mistakes(review): Supervise Linux worker crashes and bind root identity
* no-mistakes(review): Resolve authorized Nix profile bin links
* no-mistakes(review): Clarify Nix path resolution documentation
* no-mistakes(review): Harden PATH safety and nvm selection
* no-mistakes(review): Honor nvm system defaults and refresh doctor digest
* no-mistakes(review): Keep workers ready during active jobs
* no-mistakes(review): Bound pre-execution validation by job timeout
* no-mistakes(document): Clarify remote worker documentation
* no-mistakes(lint): Fix remote worker ShellCheck diagnostics
* no-mistakes: apply CI fixes
* no-mistakes: apply CI fixes
* no-mistakes: apply CI fixes
* fix: clarify remote doctor bootstrap path (#1691)
* fix(bin): gate delivery reports on a real PR and genuinely-run checks (#2)
* fix(bin): require a real PR and genuinely run checks before done
Workers on claude, codex and opencode/GLM reported "done: committed <sha>"
with no PR at all, and "checks green" on a fork PR whose workflow runs sat
at action_required so nothing had run. Both read the absence of a visible
failure as success, and the generated definition of done invited it: the
direct-PR contract opened by declaring the task complete once committed,
and no mode said what a green check set has to look like.
The PR-based modes now carry a short evidence gate: a real PR URL must
exist, and "checks green" may describe only checks that actually ran and
passed, naming the exact shape that fooled two workers - `gh pr checks`
printing nothing, or listing only skipped, queued or pending runs. A
missing PR is a blocker, not a done report, so the gate adds no new way to
declare success. The no-mistakes first report is now labelled a handoff
rather than completion, and local-only is untouched because it has no PR
by definition.
Delivery-mode semantics, the worktree-isolation assertion, the Herdr lab
gate and every other safety clause are unchanged, with behavioural tests
over the generated brief for each mode.
* no-mistakes(review): scope delivery evidence gate to PR-naming completion claim
---------
Co-authored-by: Claus (AI Assistant) <claus@providenceit.nl>
* feat(teardown): verify a merged PR's issues actually closed (#3)
* feat(teardown): verify a merged PR's issues actually closed
GitHub silently ignores some closing keywords, so a merged PR can leave its
issue open with no error or signal; the work lands on the default branch
while the board still misrepresents the state. Separately, many PRs carry no
parseable closing reference at all.
Add bin/fm-issue-closure.sh: a best-effort post-merge check that re-derives
the candidate issues a merged GitHub PR was meant to close - from the PR
body's closing-keyword grammar, GitHub's own closingIssuesReferences
(commit-message refs included), and optionally the task brief - checks each
candidate's state, and reports any GitHub left OPEN. It never auto-closes
(an outward-facing human decision), never blocks its caller (always exits 0),
reports lookup failures separately, and stays silent for PRs that close
nothing. GitLab merge requests are out of scope.
Invoke it from bin/fm-teardown.sh's post-merge path (alongside fleet-sync,
guarded by `|| true`), so firstmate sees the report in teardown output and
can surface the discrepancy.
* no-mistakes(review): skip PR-numbered closure candidates via gh api; drop dead helper
* no-mistakes(review): migrate teardown test gh mock to gh api issue lookup
* no-mistakes(document): add fm-issue-closure.sh to bin toolbelt inventory
* no-mistakes(review): accept colon closing-keyword form; fix doc wording
* no-mistakes(document): document teardown's merged-PR issue-closure step in script header
---------
Co-authored-by: Claus (AI Assistant) <claus@providenceit.nl>
* fix(teardown): stop refusing teardown of work that already landed (#4)
* fix(teardown): stop reporting landed commits as unpushed
Teardown refused two demonstrably-safe worktrees in one day, both from
comparisons that could not answer the question they were asked.
A worktree can hold ZERO remote-tracking refs for its own branch - a fresh
clone, a pruned ref, a worktree that never fetched. `git log HEAD --not
--remotes` then has nothing to compare against and reads every commit as
unpushed. That is absence of evidence, not evidence of absence, so the
remotes are now asked directly with `git ls-remote`, a read-only lookup that
writes nothing to the worktree. Teardown still never fetches to answer this:
a fetch would make a network failure indistinguishable from a properly
pushed branch, turning a safe refusal into an unsafe proceed.
A squash merge rewrites the branch into one commit, so no local hash exists
on the base even though every change does. When hash and per-commit patch-id
comparisons come up empty, the FILES at stake are now compared against the PR
head and against the PR's own merge commit - the merge commit rather than the
current default branch, because the base branch keeps moving and that motion
is not the task's work. A 3-way merge against the default branch that cannot
run falls through to the same comparison instead of being read as unlanded.
The file comparison covers what the ref itself contributed, not only what the
branch changed: an unpushed local commit that reverts merged work touches no
file relative to the branch's own base, and comparing only the branch's files
would call that revert landed and discard it.
Nothing weakens the guard. Every check returns "landed" only on positive
proof; an unreachable remote, a failed PR lookup, an unavailable merge
commit, a quoted filename, and a comparison that fails to run are all
recorded as unestablished and still refuse. Refusals now list exactly which
evidence source could not settle the question, so an operator can verify the
named gap instead of reaching for --force.
* no-mistakes(review): close rename-detection false allow and clarify refusal evidence notes
* no-mistakes(document): document FM_LS_REMOTE_TIMEOUT_SECS in configuration env inventory
---------
Co-authored-by: Claus (AI Assistant) <claus@providenceit.nl>
* feat(spawn): bootstrap project task environments before worker launch (#6)
* feat(spawn): bootstrap project task environments
* no-mistakes(review): bound and isolate task bootstrap, release aborted spawns
* no-mistakes(review): enforce bootstrap bound everywhere, return only validated worktrees
* no-mistakes(document): scope failed-spawn cleanup claims to their actual backends
* no-mistakes(lint): use env for bootstrap runner override in test
---------
Co-authored-by: Claus (AI Assistant) <claus@providenceit.nl>
* fix(herdr): confirm fast Herdr deliveries with a bounded submit postcondition (#7)
* fix(send): confirm fast Herdr deliveries
* no-mistakes(review): widen Herdr transcript postcondition window; restore wait fixtures
* no-mistakes(review): bite tall-turn regression; strip capture ANSI once
* no-mistakes(document): document Herdr fast-turn submit postcondition and pending diagnostics
---------
Co-authored-by: Claus (AI Assistant) <claus@providenceit.nl>
---------
Co-authored-by: Kun Chen <3233006+kunchenguid@users.noreply.github.com>
Co-authored-by: Gavin <51008413+allstargg@users.noreply.github.com>
Co-authored-by: Claus (AI Assistant) <claus@providenceit.nl>
Intent
Close the gap between a fresh Git worktree and a trustworthy execution environment for Firstmate tasks. Add a project-declared, toolchain-agnostic prerequisite bootstrap that runs after isolated worktree acquisition and before worker launch, uses a lockfile/toolchain/readiness fingerprint so a matching preserved environment skips cheaply, reruns when stale, and fails loudly without launching the worker or publishing success when a prerequisite fails. Do not add any pre-spawn git fetch, do not modify projects/, and do not hardcode Prisma, pnpm, or any specific project's toolchain into Firstmate. Cover the behavior with a biting regression that failed on the pre-change code; the exact pre-fix result was: 'not ok - spawn continued after its project-declared bootstrap failed'. In the PR body, state that no project declaration is added by this change and that each project remains responsible for declaring complete fingerprint inputs and prerequisite commands; Firstmate validates protocol execution, not project-specific correctness.
What Changed
bin/fm-task-bootstrap.shruns a project's tracked, executable.firstmate/bootstrapdeclaration through Firstmate-ownedfingerprint,run, and optionaltimeoutmodes. It hashes the declaration's own bytes together with the project's declared fingerprint text, stores the last successful value in the worktree-specific Git directory (so it survives Treehouse's reset andgit clean -fd), skipsrunon a match, reruns when stale, and recomputes the fingerprint after a successful run. Projects with no declaration exit 0 with no output. The declaration is refused if it is untracked, not a regular executable file, or reached through a symlinked.firstmate; every mode runs with stdin from/dev/nulland under a wall-clock bound (default 900s, project-overridable) enforced bytimeout,gtimeout, or an inline Node executor that signals the whole process group withSIGTERMthenSIGKILL— there is no unbounded fallback, and a host with none of the three refuses to run the declaration.bin/fm-spawn.shinvokes the bootstrap for every non-secondmate spawn after the isolated-worktree proof and before worker launch, exiting nonzero without launching the worker or publishing task metadata when it fails. To make that ordinary failure safe, tmux/zellij/cmux spawns gained an abort path (ENDPOINT_ABORT_CLEANUP) that kills the endpoint this process created and returns the leased Treehouse worktree — but only the acquisitionvalidate_spawn_worktreepositively accepted, never an unvalidated pane reading. The cleanup is disarmed oncestate/<id>.metais published and teardown owns the release.tests/fm-task-bootstrap.test.sh(11 regressions covering the declaration contract, fingerprint skip/rerun lifecycle, bound enforcement, and fail-closed spawn) and routedfm-task-bootstrap.{sh,test.sh}into thebackend-dispatchfamily inbin/fm-test-run.sh. Docs updated indocs/configuration.md(new "Project task bootstrap" section),docs/architecture.md, anddocs/scripts.md;docs/herdr-backend.mdrecords under Active limits that a failed Herdr spawn still leaves its task container and leased worktree behind, unlike the other backends.No project bootstrap declaration is added by this change — no
projects/path is touched, no.firstmate/bootstrapis created, and no pre-spawngit fetchis introduced. Firstmate carries no Prisma, pnpm, or other toolchain-specific branches: each project remains responsible for declaring complete fingerprint inputs and correct prerequisite commands. Firstmate validates protocol execution — that the declaration is reviewable, noninteractive, bounded, and fails closed — not project-specific correctness. An incomplete project fingerprint yields a stale skip that only that project can fix.Risk Assessment
✅ Low: Both round-2 findings are durably closed at the right boundary — the bound now has no unbounded path on any host and its regression fails rather than skips where no runtime exists, and the Treehouse return is armed solely from the validated acquisition — leaving an opt-in, tracked-only, symlink-guarded, noninteractive, fail-closed addition with behavioral coverage for every corrected invariant.
Testing
Ran the targetedtests/fm-task-bootstrap.test.shsuite (11 checks, all passing) plus the four adjacent spawn and teardown suites touched by the new endpoint abort-cleanup path, and proved the regression bites by reverting onlybin/fm-spawn.shto the base commit, which reproduced the stated pre-fix failurenot ok - spawn continued after its project-declared bootstrap failedverbatim before I restored the change. For product-level evidence I wrote and ran a CLI demo that drives the realfm-spawn.shagainst a fictionalwidgetc/deps.lockproject: it captures a failed prerequisite refusing worker launch with nostate/<id>.metapublished and both the tmux endpoint and leased treehouse worktree released, a successful bootstrap followed by an actual worker launch, and the fingerprint lifecycle skipping cheaply on an unchanged environment while rerunning on a changed lockfile, a changed toolchain version, and a missing generated artifact — four prerequisite runs across six spawns. I also confirmed the intent's forbidden constraints in the diff (noprojects/changes, no declaration added, no pre-spawngit fetch, no project-toolchain hardcoding). This is a Bash CLI orchestration change with no rendered UI surface, so a command transcript is the artifact an end user would actually experience; no screenshot applies. Everything passed and the working tree was left clean with no transient artifacts.Evidence: End-to-end fm-spawn task bootstrap transcript (fail-closed + fingerprint lifecycle)
=== Scene A: a declared prerequisite fails === $ fm-spawn.sh fix-widget-a1 acme-widgets task bootstrap: environment is stale; running .../wt-broken/.firstmate/bootstrap (bounded at 900s) [acme] installing declared dependencies from deps.lock ... cp: cannot stat 'deps.lock': No such file or directory task bootstrap: task bootstrap failed: .../wt-broken/.firstmate/bootstrap run error: task bootstrap failed for fix-widget-a1; refusing to launch worker exit status: 1 $ ls fm-home/state/fix-widget-a1.meta # was the worker's task published? ls: cannot access .../fix-widget-a1.meta: No such file or directory -> no task metadata, so no worker was launched and no success published $ cat calls.log # what the aborted spawn released tmux send-keys -t firstmate:fm-fix-widget-a1 treehouse get Enter tmux kill-window -t =firstmate:=fm-fix-widget-a1 treehouse return --force .../wt-broken $ grep send-keys calls.log | grep -v 'treehouse get' # harness launch keystrokes (no matches) -> only the worktree acquisition was sent; the codex worker itself was never launched === Scene B: fingerprint lifecycle on a preserved worktree === $ fm-spawn.sh add-widget-b1 acme-widgets # fresh worktree: nothing is prepared yet task bootstrap: environment is stale; running .../wt-ok/.firstmate/bootstrap (bounded at 900s) [acme] installing declared dependencies from deps.lock ... [acme] generating the widget client ... [acme] environment ready task bootstrap: prerequisites complete; recorded environment fingerprint spawned add-widget-b1 harness=codex kind=ship mode=no-mistakes yolo=off window=firstmate:fm-add-widget-b1 worktree=.../wt-ok $ ls fm-home/state/add-widget-b1.meta # worker task published? add-widget-b1.meta -> worker launched after prerequisites completed $ ls wt-ok/vendor wt-ok/build # what the project's prerequisites produced .../wt-ok/build: generated-client .../wt-ok/vendor: installed.lock $ fm-spawn.sh add-widget-b2 acme-widgets # same environment, nothing changed task bootstrap: environment fingerprint matches; skipping .../wt-ok/.firstmate/bootstrap $ echo "widget-core 1.5.0" >> wt-ok/deps.lock # lockfile moves $ fm-spawn.sh add-widget-b3 acme-widgets # lockfile changed task bootstrap: environment is stale; running ... (bounded at 900s) task bootstrap: prerequisites complete; recorded environment fingerprint $ echo widgetc-4.0 > wt-ok/toolchain.version # toolchain moves $ fm-spawn.sh add-widget-b4 acme-widgets # toolchain version changed task bootstrap: environment is stale; running ... (bounded at 900s) task bootstrap: prerequisites complete; recorded environment fingerprint $ rm wt-ok/build/generated-client # generated output goes missing $ fm-spawn.sh add-widget-b5 acme-widgets # readiness check fails task bootstrap: environment is stale; running ... (bounded at 900s) task bootstrap: prerequisites complete; recorded environment fingerprint $ fm-spawn.sh add-widget-b6 acme-widgets # environment is ready again task bootstrap: environment fingerprint matches; skipping .../wt-ok/.firstmate/bootstrap $ wc -l run.log # how many times the project's prerequisites actually ran 4 prerequisite runs across 6 spawns on the same preserved worktreeEvidence: Regression bites on pre-change code (bin/fm-spawn.sh reverted to base cbd5d67)
$ git checkout cbd5d67 -- bin/fm-spawn.sh $ bash tests/fm-task-bootstrap.test.sh not ok - spawn continued after its project-declared bootstrap failed exit=1 $ git checkout HEAD -- bin/fm-spawn.sh # change restored, worktree cleanEvidence: Targeted task-bootstrap suite on the target commit
$ bash tests/fm-task-bootstrap.test.sh ok - a failed project bootstrap loudly stops spawn before worker launch ok - stale fingerprints run bootstrap while matching fingerprints skip it ok - a failed prerequisite stays loud and cannot publish a success marker ok - only a tracked declaration counts as opt-in ok - a symlinked .firstmate parent cannot smuggle in a declaration ok - every declared mode runs with stdin from /dev/null ok - every available bounded runner stops a hanging run and kills its process group ok - a host with no bounded runtime refuses the declaration instead of running it unbounded ok - only timeout, gtimeout, or node may be pinned as the bounded runner ok - declarations without a timeout mode run under the documented 900s default ok - an unvalidated pane path is never returned to the treehouse pool # all task-bootstrap tests passedEvidence: Reproducible end-user demo script used to produce the transcript
Pipeline
Updates from git push no-mistakes
✅ **intent** - passed
✅ No issues found.
✅ **Rebase** - passed
✅ No issues found.
🔧 **Review** - 4 issues found → auto-fixed (2) ✅
bin/fm-task-bootstrap.sh:103- The project-declaredrunis executed with no timeout and with fm-spawn's stdin (the primary session's tty) inherited. A prerequisite that stalls on the network, or one that prompts for input, blocks fm-spawn indefinitely: the only other unbounded step in this region is bounded (thefor _ in $(seq 1 60)settle loop at bin/fm-spawn.sh:1322 gives up after 60s), and in batch dispatch (bin/fm-spawn.sh:408-410) eachid=repopair is re-exec'd serially, so one hung bootstrap stalls every remaining spawn in the batch. The intent requires the bootstrap to 'fail loudly'; a silent hang is neither loud nor a failure. Consider a bounded wrapper (configurable, e.g. FM_TASK_BOOTSTRAP_TIMEOUT) and running the declaration with stdin redirected from /dev/null so a prompt fails instead of blocking.bin/fm-spawn.sh:1351- When the bootstrap fails on the tmux, zellij, or cmux backends, spawn exits at :1353 after the task window/pane was created andtreehouse getalready leased a worktree, but beforestate/$ID.metais written at :1658. fm-teardown refuses without that file (bin/fm-teardown.sh:146 'error: no meta for task'), so the window and the leased worktree are orphaned with no supported cleanup, and a retry of the same id creates a second window. The orca path is covered (ORCA_ABORT_CLEANUP is set at :1195 and consumed by the EXIT trap) and herdr is covered (HERDR_PROJECTION_ABORT_CLEANUP is only cleared at :1700), so only these three backends leak. The gap pre-dates this change (the settle-timeout exit at :1341 has it too), but a failed project install is a routine outcome unlike the isolation guard, so it now fires on ordinary failures.bin/fm-task-bootstrap.sh:63-[ ! -L "$declaration" ]only tests the final path component, so the deliberate 'declaration must not be a symbolic link' guard is bypassed when.firstmateitself is a symlink:$worktree_real/.firstmate/bootstrapthen resolves and executes a file outside the worktree, whose contents never appear in the repo's diff. Resolving the declaration withpwd -Pon its directory and requiring the result to stay under$worktree_realcloses it consistently with the guard already written.bin/fm-task-bootstrap.sh:59- The script header (:5) and docs/configuration.md both define opt-in as 'tracking' an executable.firstmate/bootstrap, but the checks at :63-68 only require a non-symlink executable regular file. Any executable that lands at that path in a preserved/pooled worktree and survives Treehouse'sgit clean -fd(i.e. an ignored path) is executed by the primary session before the next worker launches, with no reviewed-content guarantee. Agit -C "$worktree_real" ls-files --error-unmatch .firstmate/bootstrapcheck would make the implementation match the documented contract.🔧 Fix: bound and isolate task bootstrap, release aborted spawns
2 warnings still open:
bin/fm-task-bootstrap.sh:103- The timeout ladder istimeout->gtimeout-> run unbounded with a stderr warning. macOS ships neither GNUtimeoutnorgtimeout(coreutils is not in the base system) and README.md advertisesplatform-macOS | Linux, so on a stock macOS primary session the wall-clock bound this commit exists to add simply does not apply and the authorized failure is still fully reachable: a stalled projectrunblocks fm-spawn forever and, via the serialized batch re-exec at bin/fm-spawn.sh:408-410, every remaining pair. Three places assert the bound unconditionally while it is absent: docs/configuration.md:201 ("every mode runs with stdin from /dev/null and under a wall-clock bound"), the header at bin/fm-task-bootstrap.sh:28, and the runtime line at :184 which still prints "bounded at 900s". The regression at tests/fm-task-bootstrap.test.sh:281-283pass-skips when no timeout command exists, so CI stays green on exactly the platform where the invariant is missing. The earliest supported boundary already exists in this repo: bin/fm-bearings-snapshot.sh:192-202 uses the same ladder with aperl -e 'alarm ...'fallback that forks,setpgrps, sends TERM then KILL to the group, and exits 124 - matching this script's 124 contract, requiring no watchdog beyond whattimeoutitself is, and available on stock macOS. Adopting it also closes the secondary gap that neither thetimeoutnorgtimeoutbranch passes--kill-after, so today a declaration that ignores SIGTERM still hangs past its bound.bin/fm-spawn.sh:326- The new abort-path guard is[ -n "${WT:-}" ] && [ "$WT" != "$PROJ_ABS" ] && [ -d "$WT" ], but its comment claims it returns "only a worktree this spawn actually leased". It does not: the settle loop at :1355-1370 assignsWT="$p"as soon as two consecutive pane reads agree on any path whose physical form differs from PROJ_ABS_REAL, andvalidate_spawn_worktreethen exits 1 for a path that is not a git worktree root. Concrete path:treehouse getdoes not take effect and the pane's shell settles in$HOME(or any non-repo directory) -> the loop accepts it -> validate_spawn_worktree exits 1 -> the EXIT trap runs( cd "$PROJ_ABS" && treehouse return --force "$HOME" ), handing an arbitrary user directory to a pool manager with --force, with all output discarded so only a generic warning surfaces. The comparison is also logical where the rest of the file is deliberately physical: PROJ_ABS_REAL exists at :869 and the comment at :1341-1345 explains that a symlinked project prefix defeats the string form. Gate the return on the worktree having passed validate_spawn_worktree (set a flag immediately after :1375), or re-derivegit -C "$WT" rev-parse --show-toplevelin the trap and require it to resolve to$WTand to differ from$PROJ_ABS_REAL.🔧 Fix: enforce bootstrap bound everywhere, return only validated worktrees
✅ Re-checked - no issues remain.
✅ **Test** - passed
✅ No issues found.
bash tests/fm-task-bootstrap.test.sh— all 11 targeted bootstrap/spawn regressions pass on the target commitBiting-regression proof:git checkout cbd5d67 -- bin/fm-spawn.sh && bash tests/fm-task-bootstrap.test.shreproduced the stated pre-fix result verbatim, thengit checkout HEAD -- bin/fm-spawn.shrestored the change (worktree verified clean)End-to-end manual demodemo-task-bootstrap.shdriving the realbin/fm-spawn.shwith a fake tmux/treehouse backend: fail-closed spawn, worker launch after successful bootstrap, and the full fingerprint skip/rerun lifecycle over 6 spawns on one preserved worktreebash tests/fm-spawn-batch.test.sh,bash tests/fm-spawn-worktree-settle.test.sh,bash tests/fm-spawn-dispatch-profile.test.sh,bash tests/fm-teardown-endpoint-safety.test.sh— spawn/teardown suites adjacent to the new endpoint abort-cleanup pathbin/fm-task-bootstrap.sh --helprenders the declaration contract the docs point users toOpt-out path:bin/fm-task-bootstrap.sh <repo-with-no-declaration>exits 0 with no outputForbidden-constraint checks overgit diff cbd5d67..bd454ab: noprojects/paths, no.firstmate/bootstrapdeclaration added, no addedgit fetch, no prisma/pnpm/yarn/cargo/lockfile tokens in changedbin/codebin/fm-spawn.sh:1231- Documented (not fixed) asymmetry: a Herdr spawn that fails before publishing task metadata — now reachable via a failed project task bootstrap — cleans up only projected panes and never returns the leased Treehouse worktree, unlike tmux/zellij/cmux (ENDPOINT_ABORT_CLEANUP) and Orca. I narrowed the docs claim and recorded the gap under Herdr's Active limits; closing the gap itself is a code change outside this documentation phase.🔧 **Lint** - 1 issue found → auto-fixed ✅
🔧 Fix: use env for bootstrap runner override in test
✅ Re-checked - no issues remain.
✅ **Push** - passed
✅ No issues found.