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
3 changes: 2 additions & 1 deletion .agents/skills/afk/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,7 @@ The daemon still clears its buffer only on the backend's `empty` success verdict

The daemon wraps `fm-watch.sh`, runs the watcher as a child, presents every durable wake after each actionable watcher close, classifies each presented record in bash, and acknowledges the presented generation only after routing completes.
It self-handles the routine majority without consuming a firstmate turn.
Captain-relevant events, plus a bounded recheck of a declared external wait that is still declared, escalate to firstmate's context as one pre-read, single-line, batched digest.
Captain-relevant events, plus a bounded recheck of a declared external wait that is still declared or of a deliberately stopped parked task, escalate to firstmate's context as one pre-read, single-line, batched digest.
The captain-relevant verb set, declared-wait vocabulary, status-span classifier, and presentation-marker contract live in shared `bin/fm-classify-lib.sh`, while each supervisor owns its routing and fleet scan as a consumer of that policy.
While `state/.afk` exists the daemon owns the watcher, so the watcher reverts to one-shot and lets the daemon do the triage - the two never run their triage at the same time.

Expand All @@ -163,6 +163,7 @@ Classify each wake this way:
With no unreported actionable event, the wake self-handles, and the current declaration outranks an enriched possible-wedge reason so it never escalates on the `FM_STALE_ESCALATE_SECS` cadence.
If a declared external wait is still declared past `FM_PAUSE_RESURFACE_SECS` (default four hours), housekeeping sends one recheck and resets the pause window; a captain-held transfer is never rechecked while the posture record exists.
The window ages against the crew's own latest status line, so only a status append that stops declaring the wait ends this routing and restores wedge detection.
A `stale` whose task carries the durable deliberate-stop marker (`state/<id>.deliberate-stop`, written by `bin/fm-control.sh exit`) self-handles the same way and is never wedge-escalated, whatever its last status line says; its bounded recheck is the same `FM_PAUSE_RESURFACE_SECS` pause window, re-surfaced by the watcher while it triages and by housekeeping in away mode.
- `check` -> always escalate. Check scripts print only when firstmate should wake.
- `stale` with a terminal status or bare legacy captain-relevant line -> escalate.
Nonterminal progress remains transient even when its prose contains a legacy free-text token or its seen-status marker already matches, so record a marker and self-handle.
Expand Down
3 changes: 2 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ state/ runtime records and signals; gitignored
<id>.turn-ended touched by turn-end hooks
<id>.progress touched for observed native-harness activity inside one Pi turn; bin/fm-busy-event.sh owns its generation binding and bin/fm-watch.sh reads it beside turn-ended for the busy-age bound only, never as a completed turn
<id>.busy-state <id>.busy-gen semantic busy-state record (one line, atomically replaced) and its per-incarnation gen sidecar; bin/fm-busy-event.sh is the only writer and bin/fm-busy-lib.sh owns the record format and classification; arming again replaces the previous incarnation so late events carrying its gen are rejected as stale; removed by retire and teardown
<id>.deliberate-stop durable marker (one epoch second) written by bin/fm-control.sh's exit verb for a verified stop, cleared by a successful relaunch (bin/fm-spawn.sh) and by teardown, and read by bin/fm-watch.sh and the away-mode daemon so the stopped task is parked on the declared-pause recheck cadence instead of escalated as a wedge
<id>.grok-turnend-token firstmate-owned grok hook registry token for the task; removed by teardown
<id>.kimi-turnend-token firstmate-owned Kimi hook registry token for the task; removed by teardown
<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
Expand Down Expand Up @@ -152,7 +153,7 @@ state/ runtime records and signals; gitignored
.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
.cursor-park-owner .cursor-park-owner.lock .turnend-cursor-blocks Cursor stop-hook owner record, publication and commit lock, and bounded repair-nag budget; never touch
.hash-* .count-* .stale-* .stale-since-* .churn-since-* .paused-* .wedge-escalations-* .dead-reported-* .writing-* .waiting-* .seen-* .hb-surfaced-* .last-* .heartbeat-streak watcher internals; never touch
.hash-* .count-* .stale-* .stale-since-* .churn-since-* .deliberate-stop-* .paused-* .wedge-escalations-* .dead-reported-* .writing-* .waiting-* .seen-* .hb-surfaced-* .last-* .heartbeat-streak watcher internals; never touch
.watch-triage.log watcher's absorbed-wake debug log (size-capped); never relied on, safe to delete
.last-watcher-beat watcher liveness beacon, touched every poll (including while absorbing benign wakes); guard scripts read it
.subsuper-* .supervise-daemon.* sub-supervisor internals; never touch
Expand Down
40 changes: 35 additions & 5 deletions bin/fm-control-lib.sh
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,13 @@
# here rather than improvised per harness in agent prose.
#
# This file owns three capability tables plus their pure artifact-path tables,
# and ONE named exception to that purity - fm_control_endpoint_absence_verdict,
# the single owner of the per-backend endpoint-absence proof, which does run
# backend reads. Everything else has no side effects, runs no backend command,
# and reads no state, so sourcing this file is still free and the tables can be
# read by a test as a pure contract:
# and TWO named exceptions to that purity. fm_control_endpoint_absence_verdict
# is the single owner of the per-backend endpoint-absence proof and does run
# backend reads; the fm_control_deliberate_stop_* helpers own the durable
# deliberate-stop marker and read or write exactly one per-task state file
# (state/<id>.deliberate-stop) with no backend command. Everything else has no
# side effects, runs no backend command, and reads no state, so sourcing this
# file is still free and the tables can be read by a test as a pure contract:
#
# 1. Verb allowlist. There is no arbitrary-text and no generic raw-key entry
# point on the control plane; a caller either names an allowlisted verb or
Expand Down Expand Up @@ -348,3 +350,31 @@ fm_control_harness_turnend_auth_path() { # <harness> <token>
*) return 0 ;;
esac
}

# The durable deliberate-stop marker: state/<id>.deliberate-stop. Written by
# bin/fm-control.sh's exit verb (the stop path itself, never inferred later
# from status prose), cleared by a relaunch (bin/fm-spawn.sh --relaunch) and by
# teardown, and read by bin/fm-watch.sh. Presence means the task's worker was
# deliberately stopped, so its idle endpoint is a parked task: the watcher gives
# it the declared-pause treatment - a long bounded recheck cadence, never a
# stale or wedge escalation - instead of treating it as a worker that stopped
# responding on its own. The file's mtime, refreshed by each stop so a re-stop
# after a relaunch starts a fresh recheck window, opens that cadence; the body
# records the stop's epoch second. A failed stop attempt
# never writes it, so a refused or unattributed endpoint keeps escalating exactly
# as it always did.
fm_control_deliberate_stop_marker() { # <state-dir> <id>
printf '%s/%s.deliberate-stop' "$1" "$2"
}

fm_control_deliberate_stop_record() { # <state-dir> <id>
printf '%s\n' "$(date +%s)" > "$(fm_control_deliberate_stop_marker "$1" "$2")"
}

fm_control_deliberate_stop_clear() { # <state-dir> <id>
rm -f -- "$(fm_control_deliberate_stop_marker "$1" "$2")"
}

fm_control_deliberate_stop_present() { # <state-dir> <id> -> 0 when the marker exists
[ -e "$(fm_control_deliberate_stop_marker "$1" "$2")" ]
}
33 changes: 23 additions & 10 deletions bin/fm-control.sh
Original file line number Diff line number Diff line change
Expand Up @@ -470,15 +470,32 @@ retire_busy_incarnation() {
fi
}

# finish_stop <outcome>: record the durable deliberate-stop marker and print the
# stop outcome. The marker is the whole point of the exit verb's "deliberate"
# guarantee: it lets the watcher treat this parked task as a deliberate stop (a
# long bounded recheck, never a stale or wedge escalation) instead of a worker
# that went quiet on its own. Written only here, on the verified success paths,
# so a refused or failed stop never records one.
finish_stop() { # <outcome>
# A verified stop ends this busy incarnation even if it was already dead
# before the control command arrived.
retire_busy_incarnation
fm_control_deliberate_stop_record "$STATE" "$ID" \
|| die "could not record task $ID's deliberate stop"
printf '%s' "$1"
return 0
}

# do_exit: stop the running agent, preserving endpoint and worktree. Prints
# `already-stopped`, `endpoint-gone`, or `stopped`.
# `already-stopped`, `endpoint-gone`, or `stopped`, and leaves the durable
# deliberate-stop marker behind on every verified stop (bin/fm-control-lib.sh).
do_exit() {
local state cmd verdict composer_state cancel absence interrupt_result=not-needed
require_state_verified_backend exit
state=$(agent_state)
case "$state" in
dead)
printf 'already-stopped'
finish_stop already-stopped
return 0
;;
alive) ;;
Expand All @@ -496,15 +513,15 @@ do_exit() {
# verb normally preserves did not survive. The worktree and every
# uncommitted change are untouched, and `relaunch` re-creates the
# endpoint from here.
printf 'endpoint-gone'
finish_stop endpoint-gone
return 0
;;
dead)
# The endpoint was only unreachable and is there after all, holding
# no agent - a herdr pane whose session server was merely stopped is
# the common case. Nothing is gone, so this is the ordinary
# already-stopped outcome.
printf 'already-stopped'
finish_stop already-stopped
return 0
;;
alive)
Expand All @@ -525,8 +542,7 @@ do_exit() {
state=$(agent_state)
case "$state" in
dead)
retire_busy_incarnation
printf 'stopped'
finish_stop stopped
return 0
;;
alive) interrupt_result="delivered verified=agent-alive cancel=$cancel" ;;
Expand Down Expand Up @@ -560,10 +576,7 @@ do_exit() {
state=$(wait_agent_state "$EXIT_WAIT" dead) || {
die "exit-delivered $ID interrupt=$interrupt_result exit-command=delivered agent-state=$state exit=unconfirmed; the agent did not stop within ${EXIT_WAIT}s"
}
# The incarnation is over: retire its busy wiring so no stale record or
# orphaned generation survives the agent that produced it.
retire_busy_incarnation
printf 'stopped'
finish_stop stopped
}

# --- transactional relaunch -------------------------------------------------
Expand Down
10 changes: 10 additions & 0 deletions bin/fm-spawn.sh
Original file line number Diff line number Diff line change
Expand Up @@ -4914,6 +4914,16 @@ fi
# This is the commit point: all endpoint and harness delivery that can reject
# the spawn has succeeded. Re-read and transition while holding the same
# per-task lock as metadata publication, then and only then report success.
if [ "$RELAUNCH" -eq 1 ]; then
# All launch delivery and the relaunch record publication now succeeded, so
# the replacement supersedes the deliberately stopped incarnation. Clear its
# parked-task marker only at this commit point: an earlier launch failure
# leaves the stopped task parked rather than reviving the stale/wedge ladder.
fm_control_deliberate_stop_clear "$STATE_REAL" "$ID" || {
echo "error: replacement for $ID was launched, but its deliberate-stop marker could not be cleared" >&2
exit 1
}
fi
if [ "$SPAWN_META_LOCK_HELD" != 1 ]; then
SPAWN_META_LOCK=$(fm_meta_lock_path "$STATE/$ID.meta") || exit 1
fm_lock_acquire_wait "$SPAWN_META_LOCK"
Expand Down
Loading
Loading