Skip to content
3 changes: 2 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ state/ runtime records and signals; gitignored
<id>.gemini-settings.json firstmate-owned per-task Gemini settings carrying the busy-state and turn-end hooks, reached through GEMINI_CLI_SYSTEM_SETTINGS_PATH so nothing is written into the project's own .gemini/; removed by teardown
<id>.muse-session muse busy-source binding (sessions root plus task worktree) written by fm-spawn; removed by teardown
<id>.cursor-session cursor busy-source binding (projects root, task worktree, prior conversations) written by fm-spawn; removed by teardown
<id>.voluntary-exit durable record that an explicit exit stopped the agent while a PR merge poll is still armed; removed by relaunch, teardown, and when the poll sidecar is gone (bin/fm-control.sh)
<id>.reconcile-nudged epoch second of the last inventory-reconcile nudge sent to this secondmate; bin/fm-secondmate-reconcile.sh owns its per-home cooldown window
<id>.backlog-close the exact backlog transition a teardown recorded before removing the task's record, so an interrupted cleanup can still be finished at the next session start; bin/fm-backlog-transition-lib.sh owns its format and replay, and a landed transition removes it
<id>.inbox/ durable steering inbox: sequenced firstmate instruction records the worker acknowledges by moving them into its handled/ subdirectory; written by fm-send, with ordinary records re-rung and escalated by the watcher while explicit fire-and-forget records are excluded from that ladder, and removed by teardown (bin/fm-task-inbox-lib.sh)
Expand Down Expand Up @@ -135,7 +136,7 @@ state/ runtime records and signals; gitignored
.wake-queue durable queued wakes retained until post-handling acknowledgement: epoch<TAB>seq<TAB>kind<TAB>key<TAB>payload
.watcher-down private generation-bound recovery state coupling watcher downtime, durable wake presentation, and post-handling acknowledgement; never touch
.<id>.open-decisions-cursor per-task byte cursor and folded open-decision set bounding the OPEN DECISIONS scan's cost to new status-log appends; written only by fm-classify-lib.sh's status_open_decisions_incremental, removed by teardown, safe to delete (forces one full re-fold)
.status-presentation-cursor .status-presentation-lock fleet-wide per-task status identity plus independent annotation and outcome-backstop byte offsets, with a serialization lock preventing already-presented lines from replaying while preserving delayed signal annotations; owned by fm-classify-lib.sh, with each task's row retired by teardown
.status-presentation-cursor .status-presentation-lock .status-outcome-identity fleet-wide per-task status identity plus independent annotation and outcome-backstop byte offsets, a serialization lock preventing already-presented lines from replaying while preserving delayed signal annotations, and a stable terminal-result identity so a rewritten log does not re-announce the same outcome; owned by fm-classify-lib.sh, with each task's row retired by teardown
.afk durable away-mode flag; present = sub-supervisor may inject escalations (set by /afk, cleared on user return)
.watch.lock .wake-queue.lock watcher singleton and queue serialization locks
.claude-autoarm.lock .claude-autoarm-epoch .claude-autoarm-failure-notified .claude-autoarm-failure-alarmed .turnend-claude-blocks .turnend-claude-blocks.lock Claude Stop auto-arm single-flight, epoch, failure-episode, attended-alarm, guard-budget, and budget-lock records; never touch
Expand Down
50 changes: 35 additions & 15 deletions bin/fm-busy-lib.sh
Original file line number Diff line number Diff line change
Expand Up @@ -42,21 +42,25 @@
# fm-interrupt the legacy Claude fm-send --key Escape idle event
# fm-recovery a documented recovery reset after relaunch
# Classifier-only sources (never written into a record):
# endpoint-gone, herdr-native, grok-regex, rovo-regex, muse-session-log,
# cursor-transcript, missing, malformed, gen-mismatch, source-mismatch,
# kimi-unverified, codex-unverified, capture-failed, no-target
# endpoint-gone, shell-no-agent, herdr-native, grok-regex, rovo-regex,
# muse-session-log, cursor-transcript, missing, malformed, gen-mismatch,
# source-mismatch, kimi-unverified, codex-unverified, capture-failed,
# no-target
#
# Classification (fm_busy_classify): busy | idle | unknown | dead, always
# with the producing source as the second token. Precedence:
# 1. dead endpoint (fm_busy_classify_live only) -> dead endpoint-gone
# 2. standalone Kimi before verification -> unknown kimi-unverified
# 3. a valid, gen-matching, source-trusted record -> its state and source
# 4. no record at all: herdr's native busy verdict is trusted as busy
# 2. pane that exists with no agent, recovery-grade `dead` (live only)
# -> dead shell-no-agent; this structural verdict wins regardless of
# Cursor, Grok, Muse, Codex, or Kimi classifier order
# 3. standalone Kimi before verification -> unknown kimi-unverified
# 4. a valid, gen-matching, source-trusted record -> its state and source
# 5. no record at all: herdr's native busy verdict is trusted as busy
# (generation state is sufficient for busy, not for idle), then the
# muse session-log and cursor transcript pull sources, then the Grok/Rovo
# temporary regex fallbacks classify a grok or rovo task from its
# rendered tail, then unknown missing
# 5. malformed, stale, or untrusted records -> unknown, never a fallback
# 6. malformed, stale, or untrusted records -> unknown, never a fallback
# Grok and Rovo are the ONLY rendered-text classifications that survive the
# redesign, because neither's structured lifecycle was credited-live-verified
# in the approved audit (Rovo's clean ACP stopReason lives outside the TUI
Expand Down Expand Up @@ -987,11 +991,17 @@ fm_busy_classify() { # <backend> <target> <harness> <id> <state-dir> [tail40]
printf 'unknown missing'
}

# fm_busy_classify_live: fm_busy_classify behind the one process-level
# override - a gone endpoint is dead, never busy. Requires fm-backend.sh to
# be sourced for fm_backend_target_exists.
fm_busy_classify_live() { # <backend> <target> <harness> <id> <state-dir> [expected-label]
local backend=$1 target=$2 harness=$3 id=$4 state=$5 label=${6-}
# fm_busy_classify_live: fm_busy_classify behind the process-level overrides.
# A gone endpoint is dead, never busy. A pane that still exists but whose
# recovery-grade classifier reports `dead` (nothing but a shell) is
# `dead shell-no-agent`, and that structural verdict wins before Cursor,
# Grok, Muse, Codex, or Kimi classifiers run. Ambiguous, unreadable, and
# unverified agent-state results do not override harness classifiers.
# Requires fm-backend.sh to be sourced for fm_backend_target_exists.
# Optional <tail40> is forwarded to the Grok/Rovo arms unchanged.
fm_busy_classify_live() { # <backend> <target> <harness> <id> <state-dir> [expected-label] [tail40]
local backend=$1 target=$2 harness=$3 id=$4 state=$5 label=${6-} tail40=${7-}
local agent_state
if [ -z "$target" ]; then
printf 'unknown no-target'
return 0
Expand All @@ -1000,13 +1010,23 @@ fm_busy_classify_live() { # <backend> <target> <harness> <id> <state-dir> [expe
printf 'dead endpoint-gone'
return 0
fi
fm_busy_classify "$backend" "$target" "$harness" "$id" "$state"
if command -v fm_backend_agent_state >/dev/null 2>&1; then
agent_state=$(fm_backend_agent_state "$backend" "$target" 2>/dev/null || true)
case "$agent_state" in
dead)
printf 'dead shell-no-agent'
return 0
;;
esac
fi
fm_busy_classify "$backend" "$target" "$harness" "$id" "$state" "$tail40"
}

# fm_busy_classify_meta: classify a task from its recorded metadata, so every
# consumer resolves backend, target, and harness the same way instead of
# re-deriving them. Requires fm-backend.sh to be sourced. <tail40> is
# optional pre-captured plain output reused by the Grok arm.
# optional pre-captured plain output reused by the Grok arm. Process-level
# overrides (gone endpoint, shell without agent) run through classify_live.
fm_busy_classify_meta() { # <meta-file> <id> <state-dir> [tail40]
local meta=$1 id=$2 state=$3 tail40=${4-} backend target harness
[ -f "$meta" ] || { printf 'unknown missing'; return 0; }
Expand All @@ -1017,7 +1037,7 @@ fm_busy_classify_meta() { # <meta-file> <id> <state-dir> [tail40]
printf 'unknown no-target'
return 0
fi
fm_busy_classify "$backend" "$target" "$harness" "$id" "$state" "$tail40"
fm_busy_classify_live "$backend" "$target" "$harness" "$id" "$state" "fm-$id" "$tail40"
}

# fm_busy_is_busy: boolean view for callers that only gate on provable
Expand Down
93 changes: 90 additions & 3 deletions bin/fm-classify-lib.sh
Original file line number Diff line number Diff line change
Expand Up @@ -982,6 +982,71 @@ EOF
printf '%s' "$offset"
}

# Stable identity of one terminal status outcome (done, failed), independent of
# the status file's inode or byte offset. A rewritten log that still ends on the
# same terminal result keeps this identity; a genuinely new result does not.
# Only an outcome verb has an identity: a blocked or needs-decision line states a
# live condition that can legitimately recur, so it stays offset-sensitive and
# the same text appended later is a new event, not the one already presented.
# Empty output means "no identity", which every caller reads as "do not dedupe".
status_terminal_event_identity() { # <event-line>
local line=$1
case "$(status_line_verb "$line")" in
done|failed) ;;
*) return 0 ;;
esac
printf '%s' "$line" | LC_ALL=C tr -d '\r' | cksum | awk '{printf "e1:%s-%s", $1, $2}'
}

status_outcome_identity_path() { # <state>
printf '%s/.status-outcome-identity' "$1"
}

status_outcome_identity_get() { # <state> <task>
local state=$1 task=$2 path row_task hash extra
path=$(status_outcome_identity_path "$state")
[ -f "$path" ] && [ -r "$path" ] && [ ! -L "$path" ] || return 1
while IFS=$(printf '\t') read -r row_task hash extra; do
[ -n "$row_task" ] || continue
[ -z "$extra" ] || continue
[ -n "$hash" ] || continue
if [ "$row_task" = "$task" ]; then
printf '%s' "$hash"
return 0
fi
done < "$path"
return 1
}

status_outcome_identity_commit() { # <state> <task-hash-snapshot>
local state=$1 snapshot=$2 path tmp row_task hash extra seen='' line task
path=$(status_outcome_identity_path "$state")
tmp="$path.tmp.$$"
: > "$tmp" || return 1
if [ -f "$path" ] && [ -r "$path" ] && [ ! -L "$path" ]; then
while IFS=$(printf '\t') read -r row_task hash extra; do
[ -n "$row_task" ] || continue
[ -z "$extra" ] || continue
[ -n "$hash" ] || continue
case "
$snapshot
" in *$'\n'"$row_task"$'\t'*) continue ;; esac
printf '%s\t%s\n' "$row_task" "$hash" >> "$tmp" || { rm -f "$tmp"; return 1; }
done < "$path"
elif [ -e "$path" ] || [ -L "$path" ]; then
rm -f "$tmp"
return 1
fi
while IFS=$(printf '\t') read -r task hash; do
[ -n "$task" ] || continue
[ -n "$hash" ] || continue
printf '%s\t%s\n' "$task" "$hash" >> "$tmp" || { rm -f "$tmp"; return 1; }
done <<EOF
$snapshot
EOF
mv -f "$tmp" "$path" || { rm -f "$tmp"; return 1; }
}

status_outcome_backstop_cursor_offset() { # <status-file>
local f=$1 state task manifest data row_task ident presented row_backstop backstop extra current size
[ -f "$f" ] && [ -r "$f" ] && [ ! -L "$f" ] || return 1
Expand Down Expand Up @@ -1148,7 +1213,7 @@ status_presentation_marker_commit() {

status_retire_presentation_task() { # <state> <task-id>
local state=$1 task=$2 lock manifest tmp data row_task ident offset backstop extra rc=0 found=0
local signal_marker heartbeat_marker daemon_marker
local signal_marker heartbeat_marker daemon_marker identity_path identity_tmp identity_row identity_hash identity_extra
lock="$state/.status-presentation-lock"
manifest="$state/.status-presentation-cursor"
tmp="$manifest.tmp.$$"
Expand Down Expand Up @@ -1212,6 +1277,25 @@ EOF
fi
fi
if [ "$rc" -eq 0 ]; then
identity_path=$(status_outcome_identity_path "$state")
if [ -f "$identity_path" ] && [ -r "$identity_path" ] && [ ! -L "$identity_path" ]; then
identity_tmp="$identity_path.tmp.$$"
if : > "$identity_tmp"; then
while IFS=$(printf '\t') read -r identity_row identity_hash identity_extra; do
[ -n "$identity_row" ] || continue
[ "$identity_row" = "$task" ] && continue
[ -z "$identity_extra" ] || continue
[ -n "$identity_hash" ] || continue
printf '%s\t%s\n' "$identity_row" "$identity_hash" >> "$identity_tmp" || rc=1
done < "$identity_path"
if [ "$rc" -eq 0 ]; then
mv -f "$identity_tmp" "$identity_path" || rc=1
fi
[ "$rc" -eq 0 ] || rm -f "$identity_tmp"
else
rc=1
fi
fi
rm -f -- "$state/$task.status" "$state/.$task.open-decisions-cursor" \
"$signal_marker" "$heartbeat_marker" "$daemon_marker" || rc=1
fi
Expand Down Expand Up @@ -1254,7 +1338,7 @@ EOF
}

status_commit_presentation_snapshot() { # <state> <snapshot>
local state=$1 snapshot=$2 task endpoint ident f cur_ident size tmp backstop acknowledged_task acknowledged_endpoint
local state=$1 snapshot=$2 task endpoint ident f cur_ident size tmp backstop acknowledged_task acknowledged_endpoint acknowledged_hash
tmp="$state/.status-presentation-cursor.tmp.$$"
: > "$tmp" || return 1
while IFS=$(printf '\t') read -r task endpoint ident; do
Expand All @@ -1270,7 +1354,7 @@ status_commit_presentation_snapshot() { # <state> <snapshot>
[ "$cur_ident" = "$ident" ] && [ "$endpoint" -le "$size" ] \
|| { rm -f "$tmp"; return 1; }
backstop=$(status_outcome_backstop_cursor_offset "$f") || { rm -f "$tmp"; return 1; }
while IFS=$(printf '\t') read -r acknowledged_task acknowledged_endpoint; do
while IFS=$(printf '\t') read -r acknowledged_task acknowledged_endpoint acknowledged_hash; do
if [ "$acknowledged_task" = "$task" ]; then backstop=$acknowledged_endpoint; fi
done <<EOF
${STATUS_OUTCOME_BACKSTOP_ACKNOWLEDGED:-}
Expand All @@ -1283,6 +1367,9 @@ EOF
$snapshot
EOF
mv -f "$tmp" "$state/.status-presentation-cursor" || { rm -f "$tmp"; return 1; }
if [ -n "${STATUS_OUTCOME_IDENTITY_ACK:-}" ]; then
status_outcome_identity_commit "$state" "$STATUS_OUTCOME_IDENTITY_ACK" || return 1
fi
}

scan_open_decisions_snapshot() { # <state> <task-and-endpoint-snapshot>
Expand Down
59 changes: 55 additions & 4 deletions bin/fm-control.sh
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,10 @@
# every uncommitted change. Interrupts first when the task reads
# busy, then submits the harness's exit command. Postcondition:
# the backend's recovery-grade classifier reports the agent gone.
# Already-stopped is success (idempotent).
# Already-stopped is success (idempotent). When the task still has
# an armed PR merge poll, exit records state/<id>.voluntary-exit so
# supervision treats the dead pane as an expected external wait
# instead of a repeating stale alarm, without dropping the poll.
# relaunch Transactionally replace the running agent with a new one, in the
# SAME endpoint and SAME worktree, on the same or a newly chosen
# harness/model/effort - so switching harness is one ordinary use
Expand Down Expand Up @@ -444,14 +447,56 @@ retire_busy_incarnation() {
fi
}

# state/<id>.voluntary-exit is the durable record that an explicit exit verb
# stopped the agent while an external wait (an armed PR merge poll) still
# stands. fm_voluntary_exit_record_valid in fm-pr-lib.sh owns its grammar.
# Relaunch removes it. Teardown removes it. The watcher ignores it once the
# poll sidecar is gone, so a later genuine death is not hidden.
record_voluntary_exit_wait() {
local rec tmp exited_at
[ -f "$STATE/$ID.pr-poll" ] && [ ! -L "$STATE/$ID.pr-poll" ] || {
rm -f "$STATE/$ID.voluntary-exit"
return 0
}
rec="$STATE/$ID.voluntary-exit"
tmp="$rec.tmp.$$"
exited_at=$(date +%s) || return 1
case "$exited_at" in ''|*[!0-9]*) return 1 ;; esac
{
printf 'schema=fm-voluntary-exit.v1\n'
printf 'reason=external-wait\n'
printf 'wait=pr-poll\n'
printf 'exited_at=%s\n' "$exited_at"
} > "$tmp" || { rm -f "$tmp"; return 1; }
chmod 600 "$tmp" 2>/dev/null || true
mv -f "$tmp" "$rec"
}

clear_voluntary_exit_wait() {
rm -f "$STATE/$ID.voluntary-exit"
}

# do_exit: stop the running agent, preserving endpoint and worktree. Prints
# `already-stopped` or `stopped`.
# `already-stopped` or `stopped`. Pass `record-wait` from the exit verb so an
# armed PR poll is remembered as an expected external wait. Relaunch omits
# that token and clears any prior record.
do_exit() {
local state cmd verdict cancel interrupt_result=not-needed
local state cmd verdict cancel interrupt_result=not-needed record_wait=${1:-}
require_state_verified_backend exit
state=$(agent_state)
case "$state" in
dead)
if [ "$record_wait" = record-wait ]; then
# Idempotence may preserve a record written by an earlier successful
# exit, but an agent already found dead was not stopped by this call.
# Never mint a voluntary-wait record that could hide that true death.
if ! { [ -f "$STATE/$ID.pr-poll" ] && [ ! -L "$STATE/$ID.pr-poll" ] \
&& fm_voluntary_exit_record_valid "$STATE" "$ID"; }; then
clear_voluntary_exit_wait
fi
else
clear_voluntary_exit_wait
fi
printf 'already-stopped'
return 0
;;
Expand Down Expand Up @@ -493,6 +538,12 @@ do_exit() {
# The incarnation is over: retire its busy wiring so no stale record or
# orphaned generation survives the agent that produced it.
retire_busy_incarnation
if [ "$record_wait" = record-wait ]; then
record_voluntary_exit_wait \
|| die "agent stopped but its external-wait record could not be persisted"
else
clear_voluntary_exit_wait
fi
printf 'stopped'
}

Expand Down Expand Up @@ -871,7 +922,7 @@ case "$VERB" in
echo "interrupt-delivered $ID harness=$HARNESS backend=$BACKEND verified=$proof"
;;
exit)
result=$(do_exit)
result=$(do_exit record-wait)
echo "$result $ID harness=$HARNESS backend=$BACKEND endpoint=$T worktree=$WT"
;;
relaunch)
Expand Down
Loading
Loading