Skip to content
Merged
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
2 changes: 1 addition & 1 deletion .agents/skills/afk/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ No `/back` is needed. The first genuine message is the return signal:

- A message **without** the current operational prefix or a legacy bare marker, and **not** starting with `/afk` -> the captain is back.
Run `bin/fm-afk-return.sh` before acting on the message that brought the captain back.
That script owns correct-ordered daemon shutdown, durable wake draining, escalation and wedge evidence, and the return-catch-up gate.
That script owns correct-ordered daemon shutdown, durable wake draining, escalation, wedge, and staleness auto-close reclaim evidence, and the return-catch-up gate.
If it reports a firstmate-actionable `blocked:` event, remediate it immediately through the normal lifecycle, or explicitly reclassify it with a durable reason and close its decision key with `resolved [key=...]`, then run `bin/fm-afk-return.sh check`.
Once the daemon stops, resume full per-wake responsiveness through the emitted primary-harness supervision protocol while blocker handling proceeds, so the gate never creates a blind wait.
Do not answer a Bearings request or perform any other ordinary captain work until the check exits successfully.
Expand Down
9 changes: 8 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ state/ volatile runtime signals; gitignored
<id>.status appended by crewmates: "<state>: <note>" wake-event lines, not current-state truth
<id>.turn-ended touched by turn-end hooks
<id>.grok-turnend-token firstmate-owned grok hook registry token for the task; removed by teardown
<id>.staleness-unfiled written by fm-teardown.sh's --staleness-autoclose reclaim only when filing the triage bead failed, before <id>.meta is removed; records the preserved worktree, branch, and project so the location is never lost
<id>.kimi-turnend-token firstmate-owned Kimi hook registry token for the task; removed by teardown
<id>.parlay-listen-pid background `parlay listen` pid from a best-effort Parlay chat-panel enrollment at spawn (bin/fm-spawn.sh header); killed and removed by teardown
<id>.meta written by fm-spawn: window=, endpoint_task_id=, worktree=, project=, harness=, model=, effort=, kind=, mode=, yolo=, tasktmp=; kind=secondmate also records home= and projects=; a non-default runtime backend records further backend-specific fields (docs/configuration.md "Runtime backend"; bin/fm-backend.sh, section 8); optional label= is recorded only when --label was passed at spawn; optional beads_id= is recorded only when --beads was passed at spawn, driving the claim-first/close-last bead lifecycle (bin/fm-brief-hooks.d/beads.sh, bin/fm-teardown.sh header, bin/fm-ledger.sh) under section 7's bead-linked dispatch; fm-pr-check, including through fm-pr-merge, records one canonical pr= and the forge's pr_head= when available (GitHub pull requests and GitLab merge requests; docs/gitlab-merge-watch.md); fm-x-link appends x_request=, x_request_ts=, x_followups=, and optional x_platform=/x_reply_max_chars= for an X-mode-originated task (section 14)
Expand All @@ -113,7 +114,9 @@ state/ volatile runtime signals; gitignored
.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 .turnend-claude-blocks Claude Stop auto-arm single-flight, epoch, and guard-budget records; never touch
.hash-* .count-* .stale-* .stale-since-* .paused-* .wedge-escalations-* .seen-* .hb-surfaced-* .last-* .heartbeat-streak watcher internals; never touch
.hash-* .count-* .stale-* .stale-since-* .paused-* .wedge-escalations-* .seen-* .hb-surfaced-* .last-* .heartbeat-streak .staleness-fails-* .staleness-next-* .staleness-working-* watcher internals; never touch
.staleness-autoclose.log append-only log of the watcher's idle>2h staleness auto-close reclaim attempts against a ship task (bin/fm-teardown.sh --staleness-autoclose); never relied on, safe to delete
.staleness-autoclose-afk.log durable evidence of staleness auto-close reclaims made while away; surfaced by /afk return as catch-up evidence, then cleared
.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 Expand Up @@ -330,6 +333,10 @@ A teardown refusal for uncommitted or unlanded work is a stop-and-investigate re
Never force teardown without explicit discard authority.
After successful teardown, record completion, retain only the configured recent Done history, and re-evaluate queued work whose blockers and time gates have cleared.

The watcher also reclaims a ship task's live process on its own once its window has sat idle past the configured staleness threshold (default two hours) and the crew is not provably working or parked at a captain-relevant gate.
Landed work gets the ordinary full teardown above; unlanded work only has its runtime endpoint reclaimed and a triage record filed, leaving the worktree, branch, and every uncommitted change untouched for later deliberate triage.
Treat a task whose endpoint has gone quiet with no captain-facing wake as a possible automatic reclaim rather than a crash; the filed staleness bead (or, if filing failed, `state/<id>.staleness-unfiled`), the preserved worktree and branch, and `state/.staleness-autoclose.log` still hold the evidence.

A secondmate is persistent and an empty queue is healthy.
Retire one only on an explicit captain or main-firstmate decision, after loading `secondmate-provisioning`; its home must contain no work under way, and forced discard still requires explicit captain authority.

Expand Down
19 changes: 13 additions & 6 deletions bin/fm-afk-return.sh
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,12 @@
#
# The durable state/.afk-return-catchup file is written BEFORE daemon shutdown,
# so a crash between stopping, draining, and blocker handling fails closed. It
# retains the drained wake, buffered-escalation, and wedge-marker evidence until
# every live open blocker is closed and `check` succeeds. Repeated begin/check
# calls are idempotent. `guard` never mutates state and is suitable for ordinary
# read entrypoints such as fm-bearings-snapshot.sh.
# retains the drained wake, buffered-escalation, wedge-marker, and
# staleness-autoclose-reclaim evidence (bin/fm-watch.sh's idle>2h backstop,
# which also runs while away) until every live open blocker is closed and
# `check` succeeds. Repeated begin/check calls are idempotent. `guard` never
# mutates state and is suitable for ordinary read entrypoints such as
# fm-bearings-snapshot.sh.
set -u

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
Expand Down Expand Up @@ -124,7 +126,8 @@ clear_delivery_artifacts() {
rm -f \
"$STATE/.subsuper-escalations" \
"$STATE/.subsuper-escalations.since" \
"$STATE/.subsuper-inject-wedged"
"$STATE/.subsuper-inject-wedged" \
"$STATE/.staleness-autoclose-afk.log"
}

return_guard() {
Expand All @@ -141,7 +144,7 @@ return_guard() {
}

return_reconcile() {
local evidence blockers drained wedge escalations lifecycle_ok=1
local evidence blockers drained wedge escalations staleness lifecycle_ok=1
evidence=$(mktemp "$STATE/.afk-return-evidence.XXXXXX") || return 1
blockers=$(mktemp "$STATE/.afk-return-blockers.XXXXXX") || { rm -f "$evidence"; return 1; }
preserve_evidence "$evidence"
Expand All @@ -168,6 +171,10 @@ return_reconcile() {
escalations=$(cat "$STATE/.subsuper-escalations" 2>/dev/null || true)
append_evidence escalation "$escalations" "$evidence"
fi
if [ -s "$STATE/.staleness-autoclose-afk.log" ]; then
staleness=$(cat "$STATE/.staleness-autoclose-afk.log" 2>/dev/null || true)
append_evidence staleness "$staleness" "$evidence"
fi

scan_open_blockers > "$blockers"
if [ "$lifecycle_ok" -ne 1 ] || [ -s "$blockers" ]; then
Expand Down
48 changes: 48 additions & 0 deletions bin/fm-staleness-file.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
#!/usr/bin/env bash
# File a triage bead into the `staleness` federated store for a task whose
# expensive live process was just reclaimed by bin/fm-watch.sh's idle>2h
# backstop because its worktree still holds unlanded work. Called only from
# bin/fm-teardown.sh's staleness_chat_only_teardown, after work_is_landed has
# already returned false for the task's branch.
# Fail-open by design, matching bin/fm-bead-stamp.sh: a missing `staleness` CLI
# or a create call it rejects warns on stderr and exits 0 so a triage-filing
# problem never blocks or fails the reclaim that is already under way.
# Usage: fm-staleness-file.sh <task-id> <purpose> <worktree> <branch> <project> <harness> <idle-since-epoch> <change-summary>
set -u

ID=${1-}
PURPOSE=${2-}
WORKTREE=${3-}
BRANCH=${4-}
PROJECT=${5-}
HARNESS=${6-unknown}
IDLE_SINCE=${7-}
SUMMARY=${8-}

if [ -z "$ID" ] || [ -z "$WORKTREE" ]; then
echo "warning: fm-staleness-file.sh needs at least a task id and worktree, skipping" >&2
exit 0
fi

if ! command -v staleness >/dev/null 2>&1; then
echo "warning: staleness CLI not found on PATH, could not file $ID for triage" >&2
exit 0
fi

idle_human=$IDLE_SINCE
case "$IDLE_SINCE" in
'') idle_human=unknown ;;
*[!0-9]*) ;;
*) idle_human=$(date -r "$IDLE_SINCE" 2>/dev/null || date -d "@$IDLE_SINCE" 2>/dev/null || echo "$IDLE_SINCE") ;;
esac

body=$(printf 'task: %s\npurpose: %s\nworktree: %s\nbranch: %s\nproject: %s\nharness: %s\nidle since: %s\n\n%s\n' \
"$ID" "${PURPOSE:-unknown}" "$WORKTREE" "${BRANCH:-unknown}" "${PROJECT:-unknown}" "$HARNESS" \
"$idle_human" "${SUMMARY:-<no change summary>}")

bead_id=$(staleness create "Stale task $ID: ${BRANCH:-unknown branch}" \
--description "$body" --labels staleness-autoclose --silent) || {
echo "warning: could not file staleness bead for $ID (see stderr above)" >&2
exit 0
}
echo "filed staleness bead $bead_id for $ID"
Comment thread
coderabbitai[bot] marked this conversation as resolved.
90 changes: 89 additions & 1 deletion bin/fm-teardown.sh
Original file line number Diff line number Diff line change
Expand Up @@ -63,10 +63,18 @@
# like fm-bead-stamp.sh: a missing task CLI or a close the CLI rejects warns on
# stderr and never blocks or fails an already-confirmed teardown. bin/fm-ledger.sh
# is the safety net for a bead that was claimed but never reaches this path.
# Usage: fm-teardown.sh <task-id> [--force]
# Usage: fm-teardown.sh <task-id> [--force | --staleness-autoclose [<idle-since-epoch>]]
# --force skips ordinary-task dirty and landed-work checks, skips scout report
# checks, and discards secondmate child work for kind=secondmate. Only use it
# when the captain has explicitly said to discard the work.
# --staleness-autoclose is bin/fm-watch.sh's idle>2h backstop for kind=ship
# tasks only: work_is_landed decides the branch. Landed falls through to the
# ordinary teardown above unchanged. Unlanded reclaims only the runtime
# endpoint and firstmate's own tracking state (never the worktree, its
# branch, or any uncommitted change) and files a bin/fm-staleness-file.sh bead
# recording the worktree, branch, harness, and idle-since timestamp for later
# triage - deletion happens only during that deliberate triage, still under
# the never-discard-unlanded-work guard.
#
# Transient / stale worktree git lock recovery (teardown-lock-race): a crew process
# killed mid-git-operation can leave a .git/worktrees/<wt>/index.lock (or, for a
Expand Down Expand Up @@ -125,6 +133,18 @@ if [ "$#" -lt 1 ] || ! fm_task_id_path_safe "$1"; then
fi
ID=$1
FORCE=${2:-}
# --staleness-autoclose (bin/fm-watch.sh's idle>2h backstop): resolve landed vs
# unlanded via work_is_landed and either fall through to the ordinary teardown
# below (landed - FORCE reset to "" so it behaves exactly like a plain call) or
# take the staleness_chat_only_teardown branch (unlanded - kill only the runtime
# endpoint, file a staleness-store bead, and never touch the worktree). The
# optional third argument is the idle-since epoch, recorded on the filed bead.
STALENESS_AUTOCLOSE=0
STALENESS_IDLE_SINCE=${3:-}
if [ "$FORCE" = "--staleness-autoclose" ]; then
STALENESS_AUTOCLOSE=1
FORCE=""
fi
# Fail closed before any fleet mutation: a no-mistakes gate agent must never tear
# down a worktree (see bin/fm-gate-refuse-lib.sh).
fm_refuse_if_gate_agent
Expand Down Expand Up @@ -159,6 +179,10 @@ KIND=$(grep '^kind=' "$META" | cut -d= -f2- || true)
[ -n "$KIND" ] || KIND=ship
MODE=$(grep '^mode=' "$META" | cut -d= -f2- || true)
[ -n "$MODE" ] || MODE=no-mistakes
if [ "$STALENESS_AUTOCLOSE" = 1 ] && [ "$KIND" != ship ]; then
echo "error: staleness auto-close only supports kind=ship tasks (task $ID is kind=$KIND)" >&2
exit 1
fi
BEADS_ID=$(fm_meta_get "$META" beads_id)
PUBLIC_FOLLOWUP_HOME=$FM_HOME
PUBLIC_FOLLOWUP_STATE=$STATE
Expand Down Expand Up @@ -417,6 +441,53 @@ remove_pr_poll_artifacts() {
fi
}

# Best-effort uncommitted/unpushed summary for a staleness-store bead. Read-only
# (git status/log against $1); never writes into the worktree.
staleness_worktree_summary() {
local wt=$1 dirty unpushed
dirty=$(git -C "$wt" status --porcelain 2>/dev/null | head -20)
unpushed=$(git -C "$wt" log --oneline HEAD --not --remotes -- 2>/dev/null | head -20)
printf 'uncommitted changes:\n%s\nunpushed commits:\n%s' "${dirty:-<none>}" "${unpushed:-<none>}"
}

# --staleness-autoclose, unlanded branch: reclaim the expensive live process for
# a task whose worktree has not landed (work_is_landed already returned false),
# file it to the staleness triage store, and leave the worktree, its branch, and
# every uncommitted change on disk exactly as-is - deletion happens only later,
# during deliberate triage. Mirrors the fail-open external-CLI pattern used by
# close_linked_bead(): a missing/failing staleness CLI never blocks reclaim.
staleness_chat_only_teardown() {
local harness purpose summary bead_out
fm_backend_kill "$BACKEND" "$T" "$(meta_value "$META" zellij_tab_id)" "fm-$ID" 2>/dev/null || true
remove_grok_turnend_auth "$STATE" "$ID"
remove_kimi_turnend_auth "$STATE" "$ID"
deregister_parlay_agent "$STATE" "$ID"
fm_backend_clear_transition "$BACKEND" "$STATE" "$T" || true
remove_pr_poll_artifacts "$STATE" "$ID" || true
retire_busy_state "$STATE" "$ID" "$BUSY_GEN" || true
harness=$(meta_value "$META" harness)
purpose=""
if [ -f "$DATA/$ID/brief.md" ]; then
purpose=$(awk '/^# Task/{f=1;next} f && NF {print; exit}' "$DATA/$ID/brief.md" 2>/dev/null || true)
fi
summary=$(staleness_worktree_summary "$WT")
bead_out=$("$SCRIPT_DIR/fm-staleness-file.sh" "$ID" "$purpose" "$WT" "$STALENESS_BRANCH" "$PROJ" \
"${harness:-unknown}" "$STALENESS_IDLE_SINCE" "$summary" 2>&1) || true
[ -z "$bead_out" ] || printf '%s\n' "$bead_out"
# fm-staleness-file.sh is fail-open (always exits 0), so a failed filing is
# only visible by the absence of its success line. $ID.meta is about to be
# removed below, so on failure write a durable fallback record first -
# otherwise a preserved unlanded worktree loses its only location pointer.
if ! printf '%s\n' "$bead_out" | grep -q '^filed staleness bead '; then
printf 'task: %s\npurpose: %s\nworktree: %s\nbranch: %s\nproject: %s\nharness: %s\nidle since: %s\n' \
"$ID" "${purpose:-unknown}" "$WT" "${STALENESS_BRANCH:-unknown}" "${PROJ:-unknown}" \
"${harness:-unknown}" "$STALENESS_IDLE_SINCE" > "$STATE/$ID.staleness-unfiled"
fi
rm -f "$STATE/$ID.status" "$STATE/$ID.turn-ended" "$STATE/$ID.meta" \
"$STATE/$ID.grok-turnend-token" "$STATE/$ID.kimi-turnend-token"
echo "staleness auto-close $ID: chat reclaimed, worktree $WT preserved for triage"
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

# Resolve the PR number for a worktree branch via gh-axi. Echoes the number on a
# single match and returns 0; returns non-zero on no match or any lookup failure,
# so the caller treats it as "no PR found" (fail-safe).
Expand Down Expand Up @@ -1203,6 +1274,23 @@ remove_secondmate_registry_entry() {

validate_pr_poll_cleanup "$STATE" "$ID" || exit 1

if [ "$STALENESS_AUTOCLOSE" = 1 ]; then
if [ ! -d "$WT" ]; then
# Nothing to preserve or file a triage bead about - report the actual
# state instead of claiming a worktree that is already gone.
echo "staleness auto-close $ID: worktree $WT is already gone, skipping triage filing"
rm -f "$STATE/$ID.status" "$STATE/$ID.turn-ended" "$STATE/$ID.meta" \
"$STATE/$ID.grok-turnend-token" "$STATE/$ID.kimi-turnend-token"
exit 0
fi
STALENESS_BRANCH=$(git -C "$WT" rev-parse --abbrev-ref HEAD 2>/dev/null || echo HEAD)
if ! work_is_landed "$STALENESS_BRANCH"; then
staleness_chat_only_teardown
exit 0
fi
# Landed: fall through to the ordinary full teardown below (FORCE is "").
fi

if [ "$KIND" = secondmate ]; then
[ -n "$HOME_PATH" ] || HOME_PATH=$WT
validate_firstmate_home_for_removal "$HOME_PATH" "secondmate home" "$ID" >/dev/null || exit 1
Expand Down
Loading
Loading