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
6 changes: 6 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,12 @@ A silent bootstrap section needs no action; for any printed actionable diagnosti
`BOOTSTRAP_INFO:` lines are completed no-action facts and do not require loading a skill.
`secondmate-provisioning` owns startup secondmate sync, liveness, and inherited-config convergence.

If the digest's lock step could not acquire the lock, it prints a loud, bordered read-only banner instead of silently continuing: every mutating step was skipped, and the rest of the digest is the read-only-safe subset described above.
The banner distinguishes two causes, because they need different responses.
When another live session genuinely holds the lock (`FM_LOCK_REASON=lock-held`), the banner says another session holds the fleet lock; tell the captain another active session is already managing the work and operate read-only until it resolves.
When this session cannot inspect processes to identify its own harness (`FM_LOCK_REASON=ps-unavailable`, as a Codex sandbox that denies `ps` produces, or `harness-detect-failed`), the banner instead reports that firstmate is unable to verify this session's identity and explicitly does NOT claim another session holds the lock; firstmate still refuses to mutate fleet state, and the banner points at `bin/fm-lock.sh status` plus session/process discovery commands to determine the true state.
In both cases, operate read-only - do not spawn, steer, merge, or otherwise mutate fleet state from this session until it resolves.

## 4. Harness and runtime dispatch

Load `harness-adapters` before every spawn or recovery and before trust handling, skill invocation, interrupt, exit, resume, or adapter verification.
Expand Down
68 changes: 60 additions & 8 deletions bin/fm-lock.sh
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,18 @@
# Writes the harness (agent) process PID found by walking the shell's ancestry,
# which lives as long as the firstmate session - unlike the transient subshell
# PID of any one tool call, which is dead moments after it is written.
# Usage: fm-lock.sh acquire; exit 1 if another live session holds it
# Usage: fm-lock.sh acquire; always exits 1 on any failure. On failure
# it also prints a stable machine-readable line
# FM_LOCK_REASON=<reason> to stderr, alongside the
# human-readable error. The three reasons are:
# lock-held another live session holds it
# ps-unavailable a ps call failed or was denied
# (e.g. a Codex sandbox), so this
# session cannot identify its harness
# harness-detect-failed ps worked but no harness was
# found while walking the ancestry
# Callers must NOT treat ps-unavailable/harness-detect-failed
# as another session holding the lock.
# fm-lock.sh status print holder and liveness; always exits 0
set -u

Expand All @@ -17,21 +28,44 @@ mkdir -p "$STATE"
# Known harness command names; extend when a new adapter is verified.
HARNESS_RE='claude|codex|opencode|grok|^pi$'

# harness_pid() reports through globals rather than stdout so its failure
# classification survives: a caller must NOT wrap it in $(...) (a command
# substitution subshell would discard HARNESS_FAIL_REASON). On success it sets
# HARNESS_PID; on failure it sets HARNESS_FAIL_REASON so the caller can tell a
# denied ps (ps-unavailable) apart from a clean ancestry walk that simply found
# no harness (harness-detect-failed). These fail for entirely different reasons
# and must not be conflated with another session holding the lock.
HARNESS_PID=""
HARNESS_FAIL_REASON=""

harness_pid() {
local pid=$$ comm args
local pid=$$ comm args ppid
HARNESS_PID=""
HARNESS_FAIL_REASON=""
for _ in 1 2 3 4 5 6 7 8; do
comm=$(ps -o comm= -p "$pid" 2>/dev/null) || return 1
if ! comm=$(ps -o comm= -p "$pid" 2>/dev/null); then
HARNESS_FAIL_REASON=ps-unavailable
return 1
fi
args=$(ps -o args= -p "$pid" 2>/dev/null)
if printf '%s' "$(basename "$comm")" | grep -qE "$HARNESS_RE"; then
echo "$pid"; return 0
HARNESS_PID=$pid; return 0
fi
# Bare interpreter (e.g. node): match the harness name in its script path.
case "$comm" in
*node*|*python*) printf '%s' "$args" | grep -qE "$HARNESS_RE" && { echo "$pid"; return 0; } ;;
*node*|*python*) printf '%s' "$args" | grep -qE "$HARNESS_RE" && { HARNESS_PID=$pid; return 0; } ;;
esac
pid=$(ps -o ppid= -p "$pid" 2>/dev/null | tr -d ' ')
[ -n "$pid" ] && [ "$pid" -gt 1 ] || return 1
if ! ppid=$(ps -o ppid= -p "$pid" 2>/dev/null); then
HARNESS_FAIL_REASON=ps-unavailable
return 1
fi
pid=$(printf '%s' "$ppid" | tr -d ' ')
if [ -z "$pid" ] || [ "$pid" -le 1 ]; then
HARNESS_FAIL_REASON=harness-detect-failed
return 1
fi
done
HARNESS_FAIL_REASON=harness-detect-failed
return 1
}

Expand All @@ -49,10 +83,28 @@ if [ "${1:-}" = "status" ]; then
exit 0
fi

me=$(harness_pid) || { echo "error: cannot locate harness process in ancestry" >&2; exit 1; }
# Call harness_pid directly, never as $(harness_pid): its result travels through
# HARNESS_PID/HARNESS_FAIL_REASON, which a command-substitution subshell would
# strand.
if harness_pid; then
me=$HARNESS_PID
else
case "$HARNESS_FAIL_REASON" in
ps-unavailable)
echo "FM_LOCK_REASON=ps-unavailable" >&2
echo "error: cannot inspect processes to identify this session's harness (ps failed or was denied)" >&2
;;
*)
echo "FM_LOCK_REASON=harness-detect-failed" >&2
echo "error: cannot locate harness process in ancestry" >&2
;;
esac
exit 1
fi
if [ -f "$LOCK" ]; then
old=$(cat "$LOCK")
if [ "$old" != "$me" ] && holder_alive "$old"; then
echo "FM_LOCK_REASON=lock-held" >&2
echo "error: another live firstmate session holds the lock (pid $old); operate read-only until resolved" >&2
exit 1
fi
Expand Down
59 changes: 50 additions & 9 deletions bin/fm-session-start.sh
Original file line number Diff line number Diff line change
Expand Up @@ -242,20 +242,50 @@ section "SESSION START - $FM_HOME"
subsection "LOCK"
LOCK_OUT=$("$SCRIPT_DIR/fm-lock.sh" 2>&1)
LOCK_RC=$?
printf '%s\n' "$LOCK_OUT"
# fm-lock.sh emits a stable machine-readable FM_LOCK_REASON=<reason> line to
# stderr on failure. Parse it out for banner branching, and strip it from the
# human-facing echo so only the readable message is shown.
LOCK_REASON=$(printf '%s\n' "$LOCK_OUT" | sed -n 's/^FM_LOCK_REASON=//p' | head -1)
LOCK_MSG=$(printf '%s\n' "$LOCK_OUT" | grep -v '^FM_LOCK_REASON=')
printf '%s\n' "$LOCK_MSG"
READ_ONLY=0
if [ "$LOCK_RC" -ne 0 ]; then
READ_ONLY=1
BAR='●━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'
{
printf '%s\n' "$BAR"
printf '● READ-ONLY SESSION - ANOTHER LIVE FIRSTMATE SESSION HOLDS THE FLEET LOCK\n'
printf '● %s\n' "$LOCK_OUT"
printf '● Skipping every mutating step: PR-check migration, secondmate sync,\n'
printf '● X-mode artifacts, fleet sync, and wake-queue drain. Detect-only bootstrap\n'
printf '● diagnostics and the rest of this read-only-safe digest still ran below.\n'
printf '● Operate read-only until this resolves - do not spawn, steer, merge, or\n'
printf '● otherwise mutate fleet state from this session.\n'
# Only an explicit lock-held reason produces the "another session" banner.
# ps-unavailable, harness-detect-failed, and any unrecognized/absent reason
# all default to the honest identity-unverifiable banner: firstmate still
# refuses to mutate fleet state (it cannot verify its own session identity),
# but it must NOT falsely claim another session holds the lock.
if [ "$LOCK_REASON" = lock-held ]; then
printf '● READ-ONLY SESSION - ANOTHER LIVE FIRSTMATE SESSION HOLDS THE FLEET LOCK\n'
printf '● %s\n' "$LOCK_MSG"
printf '● Skipping every mutating step: PR-check migration, secondmate sync,\n'
printf '● X-mode artifacts, fleet sync, and wake-queue drain. Detect-only bootstrap diagnostics and\n'
printf '● the rest of this read-only-safe digest still ran below.\n'
printf '● Operate read-only until this resolves - do not spawn, steer, merge, or\n'
printf '● otherwise mutate fleet state from this session.\n'
else
printf "● READ-ONLY SESSION - UNABLE TO VERIFY THIS SESSION'S IDENTITY\n"
printf '● Process inspection failed or was denied, so this session cannot confirm\n'
printf '● which harness it is running under. This is NOT a claim that another\n'
printf '● session holds the fleet lock - run the discovery commands below to find\n'
printf '● the true state.\n'
printf '● %s\n' "$LOCK_MSG"
printf '● Skipping every mutating step: PR-check migration, secondmate sync,\n'
printf '● X-mode artifacts, fleet sync, and wake-queue drain, because firstmate must not mutate\n'
printf '● fleet state when it cannot verify its own session identity. Detect-only\n'
printf '● bootstrap diagnostics and the rest of this read-only-safe digest still\n'
printf '● ran below.\n'
printf '● Determine the true state before acting:\n'
printf '● bin/fm-lock.sh status\n'
printf '● tmux list-sessions\n'
printf '● tmux list-panes -a\n'
printf '● ps -axo pid,ppid,stat,lstart,command\n'
printf '● A Codex sandbox may require approval for process inspection.\n'
fi
printf '%s\n' "$BAR"
}
fi
Expand Down Expand Up @@ -386,12 +416,23 @@ fi
# --- 6. closing reminder -----------------------------------------------
section "NEXT STEP"
if [ "$READ_ONLY" -eq 1 ]; then
cat <<'EOF'
if [ "$LOCK_REASON" = lock-held ]; then
cat <<'EOF'
This session did not acquire the fleet lock. Stay read-only: do not arm,
drain, spawn, steer, merge, or repair fleet state from here. The session
holding the lock owns mutable follow-up.

EOF
else
cat <<'EOF'
This session could not verify its own identity, so it did not take the fleet
lock. Stay read-only: do not arm, drain, spawn, steer, merge, or repair fleet
state from here. Run bin/fm-lock.sh status and the session/process discovery
commands in the banner above to determine the true state before acting; a
Codex sandbox may require approval for process inspection.

EOF
fi
elif [ "$AFK_PRESENT" -eq 1 ]; then
cat <<'EOF'
Away mode is active. Follow the supervision operating instructions block above:
Expand Down
2 changes: 1 addition & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ Only a named non-default branch checked out in `FM_ROOT` is a worktree tangle.

`fm-tangle-lib.sh` resolves the default branch from `origin/HEAD`, then local `main` or `master`, and classifies that named non-default primary branch as the tangle.
`fm-guard.sh` prints the repair command on the next mutable fleet action, while `bin/fm-session-start.sh` reports the same condition through bootstrap as a `TANGLE:` line at session start.
If another live session holds the fleet lock, both surfaces keep the alarm but switch to read-only wording with no repair command.
If this session did not acquire the fleet lock, both surfaces keep the alarm but switch to read-only wording with no repair command.
Ship briefs also tell the crewmate to verify `pwd -P` and `git rev-parse --show-toplevel` before creating `fm/<id>`, then stop with a blocked status if it landed in the primary checkout.

## No-mistakes gate authority boundary
Expand Down
25 changes: 25 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,31 @@ It uses the same live secondmate discovery and propagation helper as bootstrap,
That live discovery starts from `state/*.meta` records with `kind=secondmate`; `data/secondmates.md` only backfills `home=` for older or incomplete meta records.
Skipped items, such as a destination checkout that does not yet gitignore the item, are visible warnings but not hard failures.

## Session lock and read-only sessions

Each firstmate home takes a per-home session lock at session start (`bin/fm-lock.sh`), keyed to the harness process it finds by walking its own process ancestry with `ps`.
A session that cannot take the lock stays read-only and skips every mutating step, and `bin/fm-session-start.sh` prints a loud banner explaining why.
The banner distinguishes two very different failures, because they need different responses.

When another live firstmate session genuinely holds the lock, the banner says another session holds the fleet lock; operate read-only and let that session own mutable follow-up.

When this session cannot inspect processes to identify its own harness, the banner instead says it is unable to verify this session's identity, and explicitly does not claim another session holds the lock.
This is the case a Codex macOS sandbox produces: `workspace-write` with `on-request` approvals can deny `ps`, so `bin/fm-lock.sh` cannot walk the ancestry and cannot confirm the harness.
A detached background job whose ancestry is not walkable hits the same path.
Firstmate stays read-only here on purpose, because it must not mutate fleet state when it cannot verify its own identity.
To determine the true state, run the diagnostics the banner prints:

```sh
bin/fm-lock.sh status
tmux list-sessions
tmux list-panes -a
ps -axo pid,ppid,stat,lstart,command
```

If `bin/fm-lock.sh status` reports `lock: free` and no other firstmate session is running, the read-only state was caused by denied process inspection, not a real lock holder.
Under a Codex sandbox, granting the approval that lets `ps` run (or launching the harness where its ancestry is inspectable) lets the next session start acquire the lock normally.
`bin/fm-lock.sh` emits a stable `FM_LOCK_REASON=<lock-held|ps-unavailable|harness-detect-failed>` line to stderr on any acquire failure so this classification is scriptable; every acquire failure still exits 1.

## X mode (.env)

X mode lets a firstmate instance answer public `@myfirstmate` mentions and act on normal reversible mention requests through firstmate's normal lifecycle.
Expand Down
2 changes: 1 addition & 1 deletion docs/scripts.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ The shared no-mistakes gate refusal used by `fm-spawn.sh`, `fm-send.sh`, and `fm
| `fm-promote.sh` | Promote a scout task in place to a protected ship task |
| `fm-teardown.sh` | Fail-closed teardown: return landed ship worktrees, require completed scout deliverables, retire secondmate homes |
| `fm-harness.sh` | Detect the running harness and resolve crew or secondmate harness, model, and effort |
| `fm-lock.sh` | Per-home firstmate session lock |
| `fm-lock.sh` | Per-home firstmate session lock; classifies acquire failures with a stable `FM_LOCK_REASON` line |
| `fm-x-lib.sh` | Shared X-mode config, relay, and reply-threading helpers |
| `fm-x-poll.sh` | One bounded X relay poll: stash pending mentions, print `x-mention <request_id>` |
| `fm-x-reply.sh` | Post or dry-run preview a composed X-mode reply or follow-up |
Expand Down
Loading