Skip to content
67 changes: 65 additions & 2 deletions bin/fm-captain-hold.sh
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
# and secondmate-home ownership aligned with the work that discovered the call.
#
# Usage:
# fm-captain-hold.sh park <task-id> --reason <reason>
# fm-captain-hold.sh hold <task-id> --reason <reason> \
# [--title <title>] [--repo <repo>] [--origin <origin-id>] [--until YYYY-MM-DD]
# fm-captain-hold.sh answer <task-id> --decision-file <path> [--release]
Expand All @@ -30,7 +31,7 @@
# fm-captain-hold.sh binding <source-id>
# fm-captain-hold.sh complete <origin-id> (--none | <task-id>...)
# fm-captain-hold.sh verify <origin-id>
# fm-captain-hold.sh open <task-id> [--identity] [--distinguish-absent]
# fm-captain-hold.sh open <task-id> [--identity] [--distinguish-absent] [--include-parked]
# fm-captain-hold.sh diverged
# fm-captain-hold.sh reconcile list
# fm-captain-hold.sh reconcile close <task-id> --evidence-file <path>
Expand Down Expand Up @@ -181,6 +182,12 @@
# crew task reaches a due stale alarm - its open backlog hold need not appear in
# the task's last status line - and on a 0 bounds repeated alarms from new pane
# hashes for the decision.
# `--include-parked` widens the positive verdict to a not-Done row held with
# hold kind `parked`, a desk disposition that is not a captain call. Only the
# watcher's stale bound asks for it; every closer keeps the captain-only meaning
# above. A raw `tasks-axi unhold` followed by `tasks-axi hold --kind parked`
# bypasses the occurrence tracking provided by `park`, so the re-park's first
# stale sight may be absorbed within the four-hour re-surface window.
#
# `diverged` is the read-only guard over the seam between the two records of
# one captain call. See "record divergence" beside command_diverged below.
Expand Down Expand Up @@ -1857,12 +1864,53 @@ EOF
# exist holds nothing. Every read failure over a record that DOES exist is a 2,
# printed to stderr, because a mechanical closer must never read "cannot tell"
# as permission to close.
command_park() { # <task-id> --reason <reason>
local id=${1:-} reason='' show body stamp tmp
[ "$#" -gt 0 ] || { usage >&2; exit 2; }
shift
[ "${1:-}" = --reason ] && [ "$#" -eq 2 ] || { usage >&2; exit 2; }
reason=$2
validate_slug task-id "$id"
validate_one_line reason "$reason"
acquire_task_control_lock "$id"
require_tasks_axi
task_show_or_fail "$id" "task $id is absent from this home's backlog"
show=$TASK_SHOW_OUTPUT
[ "$(show_field "$show" state)" != "done" ] || fail "task $id is already closed"
if [ "$(show_field_value "$show" held)" = yes ]; then
[ "$(show_field_value "$show" hold_kind)" = parked ] || fail "task $id has another hold"
else
body=$(show_field_value "$show" body)
if [[ $body =~ ^Parked\ hold\ occurrence:\ [0-9a-f]{32}($|$'\n\n') ]]; then
if [[ $body == *$'\n\n'* ]]; then
body=${body#*$'\n\n'}
else
body=''
fi
fi
stamp=$(od -An -N16 -tx1 /dev/urandom | tr -d ' \n') || fail "cannot create parked hold identity"
tmp=$(umask 077; mktemp "${TMPDIR:-/tmp}/fm-park-stamp.XXXXXX") || fail "cannot stage parked hold identity"
if ! printf 'Parked hold occurrence: %s\n\n%s\n' "$stamp" "$body" > "$tmp"; then
rm -f -- "$tmp"
fail "cannot stage parked hold identity"
fi
if ! tasks_axi update "$id" --body-file "$tmp" >/dev/null; then
rm -f -- "$tmp"
fail "cannot record parked hold identity"
fi
rm -f -- "$tmp"
fi
tasks_axi hold "$id" --reason "$reason" --kind parked >/dev/null \
|| fail "could not park task $id"
}

command_open() { # <task-id> [--identity] [--distinguish-absent]
local id='' identity=0 distinguish_absent=0 data state root file backend show shown_body
local id='' identity=0 distinguish_absent=0 include_parked=0 data state root file backend show shown_body
while [ "$#" -gt 0 ]; do
case "$1" in
--identity) identity=1 ;;
--distinguish-absent) distinguish_absent=1 ;;
--include-parked) include_parked=1 ;;
-*) usage >&2; exit 2 ;;
*)
[ -z "$id" ] || { usage >&2; exit 2; }
Expand Down Expand Up @@ -1913,6 +1961,20 @@ command_open() { # <task-id> [--identity] [--distinguish-absent]
fi
return 0
fi
if [ "$include_parked" -eq 1 ] && [ "$state" != "done" ] \
&& [ "$FM_BACKLOG_ROW_HOLD_KIND" = parked ]; then
if [ "$identity" -eq 1 ]; then
task_show "$id" || {
printf 'fm-captain-hold: parked hold %s is open but its record could not be read\n' "$id" >&2
exit 2
}
shown_body=$(show_field_value "$TASK_SHOW_OUTPUT" body)
printf 'parked:%s:%s\n' \
"$(show_field_value "$TASK_SHOW_OUTPUT" hold_reason | cksum | cut -d' ' -f1)" \
"$(printf '%s\n' "$shown_body" | sed -n '1s/^Parked hold occurrence: \([0-9a-f]*\)$/\1/p')"
fi
return 0
fi
return 1
fi
if [ "$FM_BACKLOG_ROW_RESULT" = not_found ]; then
Expand All @@ -1924,6 +1986,7 @@ command_open() { # <task-id> [--identity] [--distinguish-absent]
}

case "${1:-}" in
park) shift; command_park "$@" ;;
hold) shift; command_hold "$@" ;;
answer) shift; command_answer "$@" ;;
answers) shift; command_answers "$@" ;;
Expand Down
36 changes: 28 additions & 8 deletions bin/fm-watch.sh
Original file line number Diff line number Diff line change
Expand Up @@ -1624,7 +1624,14 @@ task_captain_call_open() { # <task>
CAPTAIN_CALL_IDENTITY=
[ -n "$task" ] || return 1
CAPTAIN_CALL_IDENTITY=$(FM_HOME="$FM_HOME" "$SCRIPT_DIR/fm-captain-hold.sh" \
open "$task" --identity 2>/dev/null) || return 1
open "$task" --identity --include-parked 2>/dev/null) || return 1
case "$CAPTAIN_CALL_IDENTITY" in
parked:*)
case "$("$FM_CREW_STATE_BIN" "$task" 2>/dev/null)" in
'state: parked '*'source: run-step'*) CAPTAIN_CALL_IDENTITY=; return 1 ;;
esac
;;
esac
return 0
}

Expand Down Expand Up @@ -1678,15 +1685,22 @@ stale_wait_record() { # <window-key>
# Bound a due stale alarm for an ordinary crew task held for the captain.
# Backlog-only secondmate holds are outside this guard because the earlier gate
# preserves their no-backlog-read hot path.
# While the away-posture record exists the bound is absolute: an open captain
# call is never rechecked, whatever the throttle says, because nobody is there
# to answer it and the return brief lists it.
# A `parked` backlog hold (a desk disposition owed by the supervisor, not the
# captain) takes the same first-sight-then-cadence bound, so an idle parked pane
# stops re-alarming on every display tick.
# While the away-posture record exists the bound is absolute for a captain call
# only: it is never rechecked, whatever the throttle says, because nobody is
# there to answer it and the return brief lists it. A parked hold keeps its
# cadence, because it is not a captain call.
captain_call_stale_bound() { # <window-key> <task>
local key=$1 task=$2
STALE_WAIT_DECLARATION=
task_captain_call_open "$task" || return 1
STALE_WAIT_DECLARATION=$(captain_call_declaration "$task" "$CAPTAIN_CALL_IDENTITY")
afk_record_present && return 0
case "$CAPTAIN_CALL_IDENTITY" in
parked:*) ;;
*) afk_record_present && return 0 ;;
esac
stale_wait_throttled "$key" "$STALE_WAIT_DECLARATION"
}

Expand Down Expand Up @@ -2776,9 +2790,15 @@ EOF
printf '%s' "$h" > "$sf"
triage_log "absorbed stale (captain-held, never rechecked while the away-posture record exists): $w"
elif [ "$(cat "$sf" 2>/dev/null || true)" != "$h" ]; then
fm_wake_append stale "$w" "stale: $w" || exit 1
printf '%s' "$h" > "$sf"
wake "stale: $w"
STALE_WAIT_DECLARATION=
if captain_call_stale_bound "$key" "$task" && [[ "$CAPTAIN_CALL_IDENTITY" = parked:* ]]; then
printf '%s' "$h" > "$sf"
else
fm_wake_append stale "$w" "stale: $w" || exit 1
stale_wait_record "$key"
printf '%s' "$h" > "$sf"
wake "stale: $w"
fi
fi
elif stale_is_terminal "$w" "$STATE"; then
# The log's latest status event is captain-relevant - but that alone is not
Expand Down
7 changes: 4 additions & 3 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,11 @@ firstmate's supervisor contract and routing index for conditional procedures is

A zero-token bash watcher (`bin/fm-watch.sh`) sleeps on the fleet, classifies detected wakes in bash, and wakes the first mate only when something is actionable.
Actionable wakes include captain-relevant status signals, no-verb signals without positive evidence that their crew is still executing, authenticated check output such as PR merge polling or a Relay mention, stale panes whose crew is not provably working whether their status log looks terminal or non-terminal, provably-working stale panes that persist past `FM_STALE_ESCALATE_SECS` with no wait their own worker declared, no writes to their own task worktree, and - in a home that armed `config/wedge-defer-parked-gate` - no validation gate of their own awaiting an unanswered supervisor decision, declared external waits and attended captain-held transfers that remain declared past `FM_PAUSE_RESURFACE_SECS`, and heartbeat backstop hits.
For an ordinary crew task, a wait is read from both of its records: the status line a worker declared, and the backlog hold `bin/fm-captain-hold.sh` recorded once firstmate handed the work to the captain.
So a delivered ordinary crew task whose last line stays a `done` PR-ready line bounds repeated alarms from new pane hashes to the `FM_PAUSE_RESURFACE_SECS` cadence for the length of the captain's decision.
For an ordinary crew task, a wait is read from both of its records: the status line a worker declared, and a backlog hold recorded through `bin/fm-captain-hold.sh` for either a captain decision or a desk-parked disposition.
So a delivered ordinary crew task whose last line stays a `done` PR-ready line bounds repeated alarms from new pane hashes to the `FM_PAUSE_RESURFACE_SECS` cadence while its backlog hold remains open.
The first hash still alarms, each new hash inside that window is absorbed, and a new hash after the window re-surfaces the hold; a terminal pane hash that never changes stays inert after its first alarm exactly as it did before this bound.
The throttle is scoped to both the current captain-call lifecycle and the status-log state, so releasing and re-holding the same task without a status append starts a fresh window whose first new hash alarms.
The throttle is scoped to the hold declaration and status-log state, so releasing and re-holding through the wrapper without a status append starts a fresh window whose first new hash alarms.
A live parked validation-gate worker stays on its own wedge path rather than inheriting the desk-parked backlog bound.
A secondmate reaches the stale path only for a wait declared in its status line, so a hold recorded only in the backlog while its last line is `working:` or `done:` is outside this guard.
Reaching that case would require consulting the backlog for windows the secondmate gate deliberately skips, putting backlog reads on the ordinary poll hot path this design preserves.
Repeated provably-working stale escalations on the same unchanged pane add an escalation count to the wake reason and, at `FM_WEDGE_DEMAND_INSPECT_COUNT`, a `demand-deep-inspection` marker.
Expand Down
2 changes: 1 addition & 1 deletion docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -1261,7 +1261,7 @@ FM_CAPTAIN_RE='done:|needs-decision:|blocked:|failed:|PR ready|checks green|read
FM_CLASSIFY_PAUSED_VERB=paused # leading status verb for a declared external wait; excluded from FM_CAPTAIN_RE and distinct from blocked
FM_STALE_ESCALATE_SECS=240 # idle seconds before a provably-working stale pane escalates, unless that pane's own worker declared a wait that has not elapsed, or, where config/wedge-defer-parked-gate arms it, that pane's crew is parked at a validation gate awaiting the supervisor's decision on it that the crew raised under that run's key and nobody has answered yet, either of which takes the FM_PAUSE_RESURFACE_SECS recheck below instead; stale panes whose crew is not provably working surface immediately unless admitted directly to the declared-wait cadence, while a live idle declared wait still surfaces once before that cadence bounds repeats; at that same escalation moment a recovery-grade agent-state probe (docs/architecture.md owns that dead-record contract) reports a pane whose endpoint is proven `dead` or `missing` once and stops re-escalating it while it stays that way
FM_BUSY_TURN_MAX_SECS=3600 # maximum age without a completed turn or explicit native-harness progress (bin/fm-watch.sh owns marker selection), before the same wedge escalation used for a provably-working non-busy stale takes over; inspection-only, never an automatic interrupt or restart; a declared external wait, an attended verified captain-held transfer, or - where config/wedge-defer-parked-gate arms it - a validation gate of the crew's own awaiting the supervisor's still-unanswered decision takes the FM_PAUSE_RESURFACE_SECS recheck below instead
FM_PAUSE_RESURFACE_SECS=14400 # four hours between bounded rechecks of a declared external wait or verified captain-held transfer, and between repeated new-hash stale alarms for an ordinary crew task with an open backlog captain call; a structured until time can make an external-wait recheck occur sooner but cannot extend this bound; this includes a live idle pane after its first inconclusive stale wake, a provably-working pane whose own unelapsed declared wait or, where config/wedge-defer-parked-gate arms it, unanswered supervisor-owed validation gate defers its FM_STALE_ESCALATE_SECS escalation, and a live busy pane past FM_BUSY_TURN_MAX_SECS, while the away-mode daemon uses the same setting and ages its window against the crew's own latest status line rather than pane busy state; a captain-held transfer is never rechecked while the away-posture record exists, while an armed validation gate awaiting the supervisor's decision keeps this recheck in either posture
FM_PAUSE_RESURFACE_SECS=14400 # four hours between bounded rechecks of a declared external wait or verified captain-held transfer, and between repeated new-hash stale alarms for an ordinary crew task with an open backlog captain call or desk-parked backlog hold; the first sight of each hold declaration alarms, while unheld lanes still alarm on each new hash and a live parked validation-gate worker stays on its own wedge path; a structured until time can make an external-wait recheck occur sooner but cannot extend this bound; this includes a live idle pane after its first inconclusive stale wake, a provably-working pane whose own unelapsed declared wait or, where config/wedge-defer-parked-gate arms it, unanswered supervisor-owed validation gate defers its FM_STALE_ESCALATE_SECS escalation, and a live busy pane past FM_BUSY_TURN_MAX_SECS, while the away-mode daemon uses the same setting and ages its window against the crew's own latest status line rather than pane busy state; a captain-held transfer is never rechecked while the away-posture record exists, but a desk-parked backlog hold keeps its cadence there, as does an armed validation gate awaiting the supervisor's decision
FM_SECONDMATE_WAKE_STALL_SECS=180 # minimum interval with no change of the oldest actionable foreign wake-queue row (it advances as the mate drains, and a queue reprovisioned under the same task id starts a fresh interval at whatever sequence it restarts) before an endpoint-recorded local secondmate produces one durable parent wake-loop-stall notification for that no-progress episode; a mate that is provably inside an active turn (an exact busy verdict) does not escalate until that same no-progress interval reaches FM_BUSY_TURN_MAX_SECS above; a mate whose busy class is exactly idle, whose agent is alive, and whose composer is not pending is rung once so its own home can drain, and the parent notification is withheld until that same row stays frozen for another stall interval; unknown or ring-unsafe panes keep the parent alarm; declared external-wait pause rows are excluded, and zero or invalid values use 180
FM_WEDGE_DEMAND_INSPECT_COUNT=3 # consecutive provably-working stale escalations on the same unchanged pane before demand-deep-inspection is added
FM_WORKTREE_WRITE_PRUNE='.git node_modules .venv venv __pycache__ .mypy_cache .pytest_cache .ruff_cache .tox target dist build .next .cache vendor' # directory names the wedge detector's task-worktree write probe skips; the default keeps .git out so a supervisor's own read-only git command can never look like crew progress; set it to the empty string to prune nothing, which widens the probe to the whole depth-bounded tree rather than disabling it
Expand Down
44 changes: 44 additions & 0 deletions tests/fm-captain-hold-lifecycle.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -791,6 +791,48 @@ EOF
pass "the completion gate attests captain-held inventory and transfers open status decisions"
}

test_park_preserves_user_written_occurrence_prefix() {
local home show
home=$(make_home park-user-body)
tasks_in "$home" add parked-prose 'parked prose' --file data/backlog.md \
--body $'Parked hold occurrence: planned work\n\nKeep this plan.' >/dev/null
run_captain "$home" park parked-prose --reason 'desk parked' >/dev/null \
|| fail "could not park task with user-written occurrence prefix"
show=$(tasks_in "$home" show parked-prose --file data/backlog.md) \
|| fail "could not read parked task"
assert_contains "$show" 'Parked hold occurrence: planned work' \
"parking discarded user-written occurrence prefix"
assert_contains "$show" 'Keep this plan.' "parking discarded user-written plan"
pass "parking preserves user-written occurrence prose"
}

# A desk-parked row is not a captain call: every closer's plain `open` keeps
# reading it as not held, and only the watcher's `--include-parked` admits it,
# with an identity bound to the parked reason so re-parking starts a new window.
test_open_admits_parked_rows_only_when_asked() {
local home rc first second
home=$(make_home open-parked)
tasks_in "$home" add parked-lane 'parked lane' --file data/backlog.md >/dev/null
tasks_in "$home" hold parked-lane --reason 'desk parked preserve only' --kind parked \
--file data/backlog.md >/dev/null
rc=0; run_captain "$home" open parked-lane >/dev/null 2>&1 || rc=$?
[ "$rc" -eq 1 ] || fail "plain open read a parked row as a captain call (exit $rc)"
rc=0; run_captain "$home" open parked-lane --distinguish-absent >/dev/null 2>&1 || rc=$?
[ "$rc" -eq 1 ] || fail "open --distinguish-absent read a parked row as held (exit $rc)"
first=$(run_captain "$home" open parked-lane --identity --include-parked) \
|| fail "open --include-parked did not admit an open parked row"
case "$first" in parked:?*) ;; *) fail "parked identity is not parked-scoped: $first" ;; esac
tasks_in "$home" hold parked-lane --reason 'desk parked for a new reason' --kind parked \
--file data/backlog.md >/dev/null
second=$(run_captain "$home" open parked-lane --identity --include-parked) \
|| fail "open --include-parked lost a re-parked row"
[ "$first" != "$second" ] || fail "re-parking with a new reason kept the same identity"
tasks_in "$home" "done" parked-lane --file data/backlog.md >/dev/null
rc=0; run_captain "$home" open parked-lane --include-parked >/dev/null 2>&1 || rc=$?
[ "$rc" -eq 1 ] || fail "open --include-parked admitted a Done row (exit $rc)"
pass "open admits parked rows only under --include-parked, with a reason-bound identity"
}

# The recorded-answer rule: answering closes with the captain's exact words, an
# exact retry is idempotent, a drifted retry is rejected, dependent work routed
# behind the answered task is released by the close, and the completion gate is
Expand Down Expand Up @@ -4031,6 +4073,8 @@ test_uninventoried_report_decision_refuses_completion
test_hold_decodes_a_bare_scalar_body_without_the_nonref_default
test_retained_body_keeps_its_utf8_bytes
test_completion_gate_attests_and_transfers
test_open_admits_parked_rows_only_when_asked
test_park_preserves_user_written_occurrence_prefix
test_answer_records_and_closes
test_release_frees_held_work
test_hold_stamp_precedes_hold_visibility
Expand Down
Loading