Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ config/checkout-refresh optional extra checkout and shallow scan-root directive
config/worktree-provision task-worktree dependency provisioning switch; LOCAL, gitignored; absent or "on" provisions each acquired worktree's declared project dependencies before launch, "off" disables it for this home; see docs/configuration.md "Worktree provisioning"
config/secondmate-harness PRIMARY launch `<harness> [<model>] [<effort>]`; LOCAL, gitignored, fallback config/crew-harness then firstmate, not inherited (section 4)
config/account-routing-mode `off|observe|enforce`; direct account directories for new observe/enforce launches, legacy recovery for existing managed metadata; LOCAL, gitignored, default off, inherited (docs/configuration.md "Agent Fleet account routing")
config/provision/<project>.json optional per-project worktree provisioning manifest that overrides declaration-driven detection for that project; LOCAL, gitignored; absent uses the declaration-driven provisioner; see docs/configuration.md "Worktree provisioning"
config/secondmate-account-pool optional Agent Fleet pool the PRIMARY uses for SECONDMATE launches when routing is enabled; LOCAL, gitignored; selection-only and NOT inherited
Direct account-directory launch covers ship/scout crewmates and secondmate launches; a secondmate binds only the selected account, never the ship/scout worktree-identity contract.
config/backlog-backend backlog backend override; LOCAL, gitignored; absent or "tasks-axi" = default tasks-axi backend, "manual" = force routine backlog updates to hand-editing; inherited by secondmate homes (section 10)
Expand Down Expand Up @@ -398,11 +399,12 @@ bin/fm-spawn.sh <id> --resume-account # sticky legacy managed recove
bin/fm-spawn.sh <id> --continue-account # fresh legacy managed session from verified task-owned continuation state
bin/fm-spawn.sh <id> projects/<repo> --backend <tmux|herdr|zellij|cmux> # explicit new-task runtime backend (docs/configuration.md "Runtime backend")
bin/fm-spawn.sh <id> projects/<repo> --scout # scout task; records kind=scout in meta
bin/fm-spawn.sh <id> projects/<repo> --provision|--no-provision # force or skip worktree provisioning for this spawn
bin/fm-spawn.sh <id> [<firstmate-home>] --secondmate # launch a persistent secondmate in its home
bin/fm-spawn.sh <id1>=projects/<repo1> <id2>=projects/<repo2> [--scout] # batch: one call, several tasks
```

Batch dispatch spawns each `id=repo` pair through the same single-task path, with shared `--scout`, `--harness`, `--model`, `--effort`, `--backend`, `--account-pool`, `--account-profile`, and `--no-account-routing` flags applying to all; one failed pair does not stop the rest, and the batch exits non-zero.
Batch dispatch spawns each `id=repo` pair through the same single-task path, with shared `--scout`, `--harness`, `--model`, `--effort`, `--backend`, `--account-pool`, `--account-profile`, `--no-account-routing`, and `--provision`/`--no-provision` flags applying to all; one failed pair does not stop the rest, and the batch exits non-zero.
When `config/crew-dispatch.json` exists, include an explicit resolved harness for every crewmate or scout spawn or batch after consulting the dispatch rules (section 4).
`bin/fm-spawn.sh`'s header owns harness and runtime-backend resolution, spawn-capable backends and `codex-app` rejection, launch templates, delivery-mode resolution, recorded meta fields, and turn-end hooks.
A backend spawn refusal - a missing dependency, an unauthenticated socket, or a version gate - must be surfaced to the captain as a blocker; never silently retry the spawn on a different backend to work around it.
Expand All @@ -414,6 +416,7 @@ An unignored install directory is a pre-installer FAILURE; an unignored `.fm-pro
A successful non-zero `uv pip check` NOTE records `<manager>:<dir>=installed+<note>` or `<manager>:<dir>=cached+<note>`; `inconsistent-dependency-metadata` means the check found inconsistency, while `unverified-dependency-metadata` means it did not run and must never be phrased as a finding.
Never convert a capability limit into a spawn refusal: route it through `fm_provision_gap`; `docs/configuration.md` "Worktree provisioning" points operators to the exhaustive header-owned set instead of owning a synchronized copy.
A provisioning refusal is a blocker to surface, not something to work around by retrying with `--no-provision`: launching anyway produces a lane that cannot prove its own work.
An optional `config/provision/<project>.json` overrides declaration-driven detection for one project with explicit runtime checks, probes, and failure policy; `bin/fm-provision.sh` owns its readiness and exit contracts, and `docs/configuration.md` "Worktree provisioning" owns the schema and precedence.
For `kind=secondmate`, it launches in the registered or explicit firstmate home with the charter brief as the launch prompt, after the guarded home sync and inheritable-config propagation owned by `secondmate-provisioning`.
Project worktrees start at detached HEAD on a clean default branch; ship briefs tell the crewmate to create its branch, while scout briefs keep the worktree scratch.
For a genuinely new ship or scout task, `bin/fm-spawn.sh` asserts an In flight or Queued backlog row before endpoint creation.
Expand Down
2 changes: 1 addition & 1 deletion bin/backends/herdr.sh
Original file line number Diff line number Diff line change
Expand Up @@ -3530,7 +3530,7 @@ fm_backend_herdr_wait_transition() { # <session> <timeout_secs> <state_dir> <pa
fm_backend_herdr_control_exec rm -rf "$fifo_dir" 2>/dev/null || true
return 2
fi
fm_backend_herdr_scrubbed_exec "${reader[@]}" "$sock" "$timeout" "${pane_ids[@]}" > "$fifo" 2>/dev/null &
fm_backend_herdr_scrubbed_exec exec "${reader[@]}" "$sock" "$timeout" "${pane_ids[@]}" > "$fifo" 2>/dev/null &
reader_pid=$!
if ! exec 9< "$fifo"; then
kill "$reader_pid" 2>/dev/null || true
Expand Down
34 changes: 28 additions & 6 deletions bin/fm-afk-launch.sh
Original file line number Diff line number Diff line change
Expand Up @@ -258,7 +258,7 @@ fm_afk_launch_namespace_guard_release() {
return "$result"
}

fm_afk_launch_namespace_guard_acquire() {
fm_afk_launch_namespace_guard_acquire() { # 0 acquired, 2 busy
local ready response helper_status=0
[ -z "$FM_AFK_LAUNCH_NAMESPACE_GUARD_PID" ] \
&& [ "$FM_AFK_LAUNCH_NAMESPACE_GUARD_HELD" -eq 0 ] || return 1
Expand Down Expand Up @@ -296,7 +296,11 @@ if not stat.S_ISREG(held.st_mode) or not stat.S_ISREG(named.st_mode):
raise RuntimeError("namespace guard is not a regular file")
if (held.st_dev, held.st_ino) != (named.st_dev, named.st_ino):
raise RuntimeError("namespace guard pathname changed before flock")
fcntl.flock(17, fcntl.LOCK_EX)
try:
fcntl.flock(17, fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError:
os.write(1, b"busy\n")
raise SystemExit(75)
confirmed = os.lstat(sys.argv[1])
if (held.st_dev, held.st_ino) != (confirmed.st_dev, confirmed.st_ino):
raise RuntimeError("namespace guard pathname changed while acquiring flock")
Expand All @@ -315,7 +319,7 @@ finally:
' "$FM_AFK_LAUNCH_NAMESPACE_GUARD" "$FM_AFK_LAUNCH_NAMESPACE_GUARD_DIR" >&18 18>&- \
2> "$FM_AFK_LAUNCH_NAMESPACE_GUARD_DIR/error" &
FM_AFK_LAUNCH_NAMESPACE_GUARD_PID=$!
if ! IFS= read -r -t 5 -u 18 response || [ "$response" != ready ]; then
if ! IFS= read -r -t 5 -u 18 response; then
fm_afk_launch_namespace_guard_release >/dev/null 2>&1 || true
return 1
fi
Expand All @@ -324,6 +328,11 @@ finally:
{ exec 18>&-; } 2>/dev/null || true
rm -rf "$FM_AFK_LAUNCH_NAMESPACE_GUARD_DIR" 2>/dev/null || helper_status=1
FM_AFK_LAUNCH_NAMESPACE_GUARD_DIR=
if [ "$response" = busy ] && [ "$helper_status" -eq 75 ]; then
{ exec 17>&-; } 2>/dev/null || true
return 2
fi
[ "$response" = ready ] || helper_status=1
if [ "$helper_status" -ne 0 ]; then
{ exec 17>&-; } 2>/dev/null || true
return 1
Expand Down Expand Up @@ -590,7 +599,14 @@ fm_afk_launch_lock_acquire() {
FM_AFK_LAUNCH_LOCK_INCOMPLETE=0
FM_AFK_LAUNCH_LOCK_LAST_IDENTITY=
for i in $(seq 1 200); do
fm_afk_launch_namespace_guard_acquire || return 1
fm_afk_launch_namespace_guard_acquire
result=$?
if [ "$result" -eq 2 ]; then
sleep 0.05
continue
elif [ "$result" -ne 0 ]; then
return 1
fi
fm_afk_launch_lock_try_guarded "$i" "$ownerless_grace"
result=$?
fm_afk_launch_namespace_guard_release || return 1
Expand Down Expand Up @@ -618,7 +634,7 @@ fm_afk_launch_lock_release_guarded() {
}

fm_afk_launch_lock_release() {
local result=0
local i acquire_result result=0
# EXIT/TERM may land after publication but before the guarded attempt returns.
# In that case the current shell already owns the sibling guard; reacquiring
# it would self-deadlock, so clean the exact token while fd 17 still retains
Expand All @@ -628,7 +644,13 @@ fm_afk_launch_lock_release() {
fm_afk_launch_namespace_guard_release || result=1
return "$result"
fi
fm_afk_launch_namespace_guard_acquire || return 1
for i in $(seq 1 200); do
fm_afk_launch_namespace_guard_acquire
acquire_result=$?
[ "$acquire_result" -ne 2 ] && break
sleep 0.05
done
[ "$acquire_result" -eq 0 ] || return 1
fm_afk_launch_lock_release_guarded || result=1
fm_afk_launch_namespace_guard_release || result=1
return "$result"
Expand Down
7 changes: 4 additions & 3 deletions bin/fm-gate-refuse-lib.sh
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@
# fm-checkout-refresh.sh, fm-ensure-agents-md.sh, fm-fleet-sync.sh,
# fm-home-seed.sh, fm-lock.sh,
# fm-crosscheck.sh, fm-merge-local.sh, fm-pr-check.sh, fm-pr-merge.sh, fm-promote.sh,
# fm-provision.sh,
# fm-report-retention.sh, fm-report-stack.mjs, fm-review-diff.sh, fm-send.sh,
# fm-session-start.sh, fm-spawn.sh, fm-supervise-daemon.sh,
# fm-task-file-append.mjs, fm-teardown.sh, fm-update.sh, fm-wake-drain.sh,
Expand All @@ -84,9 +85,9 @@
# and fm-x-lib.sh.
# Excluded pure helpers are not entrypoints: backends/herdr-eventwait.py,
# fm-contained-read.cjs, fm-contained-read.py, fm-file-transaction.cjs,
# fm-markdown-structure.cjs, and fm-prompt-exec.py carry no fleet dispatch of
# their own and are reached only through the entrypoints and libraries above,
# so they inherit the caller's refusal.
# fm-launch-pinned.sh, fm-markdown-structure.cjs, and fm-prompt-exec.py carry no
# fleet dispatch of their own and are reached only through the entrypoints and
# libraries above, so they inherit the caller's refusal.
# fm-herdr-lab.sh is excluded because it accepts only isolated fm-lab-* sessions
# and protects the live default session with its own tripwire.
# fm-install-shellcheck.sh and fm-lint.sh are excluded developer verification
Expand Down
92 changes: 92 additions & 0 deletions bin/fm-launch-pinned.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
#!/usr/bin/env bash
# fm-launch-pinned.sh - start a crewmate's launch command with a project's proven
# runtime pin ahead of PATH, WITHOUT that pin ever reaching the PATH that
# resolved the command.
#
# Usage:
# fm-launch-pinned.sh <path-prepend> [NAME=VALUE ...] <command> [arg ...]
#
# WHY THIS EXISTS
# Worktree provisioning can prove a project's runtime and hand back the
# directory that must lead PATH for the project's own tools. Putting that
# directory on the PATH the pane shell uses to evaluate firstmate's typed
# launch line would let a manifest decide which binary EVERY bare word in that
# line means: the harness name, a wrapper, that wrapper's target, an
# interpreter carrying a continuation prompt. Those were found and pinned one
# word at a time across five review rounds, which is a search that does not
# converge - the next launch shape adds the next word.
#
# This closes it as a class instead. The crewmate PATH firstmate exports
# carries no manifest-supplied entry, so the whole launch line resolves from
# firstmate's own resolution order whatever words it happens to contain, and
# the pin is applied HERE, after the command has been resolved, to the
# environment the agent and every one of its children inherit. Moving WHERE the
# pin applies is the point: the project's own tools still resolve it first.
#
# CONTRACT
# - <path-prepend> is a colon-joined list of directories, already validated by
# fm-provision.sh (each exists and carries no space, quote, or colon).
# - Leading NAME=VALUE arguments are the launch line's own environment prefix.
# They are consumed here rather than by the pane shell so that a launch line
# can be handed over whole, and they are applied to the command only.
# - The command word is resolved with `type -P`, a PATH-only lookup, against
# the PATH this process INHERITED - the un-pinned crewmate PATH. A shell
# function or alias of the same name cannot answer for it, and neither can
# the pin, which is not exported until after the lookup.
# - A command word that already carries a slash is not PATH-resolved at all.
# - An unresolvable command exits 127 with a reason, the same way a shell
# reports a command it cannot find, rather than launching something else.
set -u

usage() {
sed -n '2,38p' "$0" | sed 's/^# \{0,1\}//'
}

case "${1:-}" in
-h|--help) usage; exit 0 ;;
esac

die() {
printf 'fm-launch-pinned: %s\n' "$*" >&2
exit 2
}

[ "$#" -ge 2 ] || die "usage: fm-launch-pinned.sh <path-prepend> [NAME=VALUE ...] <command> [arg ...]"

PREPEND=$1
shift
[ -n "$PREPEND" ] || die "the path prefix to apply is empty"

ASSIGNMENTS=()
while [ "$#" -gt 0 ]; do
case "$1" in
[A-Za-z_]*=*)
ASSIGNMENTS[${#ASSIGNMENTS[@]}]=$1
shift
;;
*) break ;;
esac
done

[ "$#" -gt 0 ] || die "no command to run after the environment prefix"

TARGET=$1
shift

case "$TARGET" in
*/*) RESOLVED=$TARGET ;;
*) RESOLVED=$(type -P -- "$TARGET" 2>/dev/null) || RESOLVED= ;;
esac

if [ -z "$RESOLVED" ] || [ ! -x "$RESOLVED" ]; then
printf 'fm-launch-pinned: %s\n' \
"'$TARGET' is not an executable on this PATH, so there is nothing to launch" >&2
exit 127
fi

# Only now, with the command already resolved, does the manifest's directory
# reach PATH. Everything below inherits it; nothing above it ever saw it.
PATH="$PREPEND:$PATH"
export PATH

exec /usr/bin/env ${ASSIGNMENTS[@]+"${ASSIGNMENTS[@]}"} "$RESOLVED" "$@"
Loading
Loading