Skip to content
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,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-* .paused-* .wedge-escalations-* .writing-* .seen-* .hb-surfaced-* .last-* .heartbeat-streak watcher internals; never touch
.hash-* .count-* .stale-* .stale-since-* .paused-* .wedge-escalations-* .writing-* .cursor-progress-* .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
3 changes: 2 additions & 1 deletion bin/fm-supervise-daemon.sh
Original file line number Diff line number Diff line change
Expand Up @@ -477,7 +477,8 @@ clear_pause_tracking() { # <window> <state>
rm -f "$state/.subsuper-paused-$key" "$state/.subsuper-stale-$key" \
"$state/.paused-$watcher_key" "$state/.paused-rechecked-$watcher_key" "$state/.paused-resurfaced-$watcher_key" \
"$state/.stale-$watcher_key" "$state/.stale-since-$watcher_key" "$state/.wedge-escalations-$watcher_key" \
"$state/.writing-since-$watcher_key" "$state/.writing-resurfaced-$watcher_key"
"$state/.writing-since-$watcher_key" "$state/.writing-resurfaced-$watcher_key" \
"$state/.cursor-progress-$watcher_key"
}

reconcile_pause_tracking() { # <window> <state> <last-status-line>
Expand Down
95 changes: 89 additions & 6 deletions bin/fm-watch.sh
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,9 @@
# (state/<id>.turn-ended, or the spawn record before any
# turn completes). Past that bound, a declared external
# wait or verified captain-held transfer uses the long
# pause recheck cadence; every other pane goes through
# pause recheck cadence; changed Cursor token or context
# counters are positive liveness and reset the timer;
# every other pane goes through
# the same wedge timer and surfaces with the identical
# "stale: ..." reason, escalation count, and
# demand-deep-inspection marker, for human inspection
Expand Down Expand Up @@ -289,8 +291,9 @@ window_label() {
# The ONE derivation of a window's per-window marker key: `:`, `/` and `.` become
# `_` so a window name is usable as a filename suffix. Every per-window file the
# watcher keeps is named by it (.hash-, .count-, .stale-, .stale-since-,
# .wedge-escalations-, .paused-*, .writing-*), and live homes hold those markers on
# disk under the current format, so the format lives here alone: a second copy is
# .wedge-escalations-, .paused-*, .writing-*, .cursor-progress-), and live homes
# hold those markers on disk under the current format, so the format lives here
# alone: a second copy is
# how a future change to it silently orphans a window's markers instead of clearing
# them. The helpers below take the derived key rather than re-deriving it, so one
# poll of one window derives it once.
Expand All @@ -300,6 +303,79 @@ window_key() { # <window>
printf '%s' "${key//./_}"
}

# Cursor updates one in-place status line during long reasoning turns. Numeric
# progress is read only from its reserved status and model-footer positions in
# the structurally anchored composer block. Unrecognized structure is explicitly
# unknown, so arbitrary pane content can never become progress evidence.
cursor_progress_signature() { # <tail40>
local tail40=$1 block status footer token context
block=$(printf '%s\n' "$tail40" | awk '
{ line[NR] = $0 }
END {
for (i = NR - 1; i >= 5; i--) {
if (line[i - 4] ~ /^[[:space:]]*[^[:alnum:][:space:]]+[[:space:]]*$/ &&
line[i - 3] ~ /^[[:space:]]*.*Add a follow-up[[:space:]]+ctrl\+c to stop[[:space:]]*$/ &&
line[i - 2] ~ /^[[:space:]]*[^[:alnum:][:space:]]+[[:space:]]*$/ &&
line[i - 1] ~ /^[[:space:]]*[0-9]+ tasks?[[:space:]]*$/ &&
line[i] ~ /^[[:space:]]*Cursor .+ · [0-9]+([.][0-9]+)?[[:space:]]*%[[:space:]]+Run Everything[[:space:]]*$/ &&
line[i + 1] ~ /^[[:space:]]*[^[:space:]].* · [[:xdigit:]]+[[:space:]]*$/) {
printf "%s\t%s\n", line[i - 5], line[i]
exit
}
}
}
')
Comment on lines +312 to +327

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Rigid Cursor layout coupling

Progress recognition now depends on fixed row positions and presentation literals such as Add a follow-up and Run Everything. This conflicts with the repository requirement to bind harness adapters to exposed semantics rather than rendered pixels, and makes presentation-only Cursor changes restore false wedge alarms and require repeated parser updates.

Context Used: If there is a VISION.md file at the root of the re... (source)

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

case "$block" in
*$'\t'*) ;;
*) printf 'unknown=cursor-progress-structure-unrecognized'; return 0 ;;
esac
status=${block%%$'\t'*}
footer=${block#*$'\t'}
token=$(printf '%s\n' "$status" \
| grep -E '^[[:space:]]*[^[:space:]]*⠆[[:space:]]+.+[[:space:]][0-9]+([.][0-9]+)?[kKmM]?[[:space:]]+tokens[[:space:]]*$' \
| grep -Eo '[0-9]+([.][0-9]+)?[kKmM]?[[:space:]]+tokens' \
| tail -1 | tr -d '[:space:]' || true)
context=$(printf '%s\n' "$footer" \
| grep -Eo '[0-9]+([.][0-9]+)?[[:space:]]*%' \
| tail -1 | tr -d '[:space:]' || true)
printf 'token=%s context=%s' "$token" "$context"
}

# Record Cursor's current numeric progress and return success only when a numeric
# field is present in both consecutive samples and its value changed. First sight,
# field appearance, and field disappearance are not proof of movement.
cursor_progress_changed() { # <window> <tail40> <window-key>
local win=$1 tail40=$2 key=$3 marker sig prev harness token context prev_token prev_context
marker="$STATE/.cursor-progress-$key"
harness=$(window_harness "$win")
case "$harness" in
cursor*) ;;
*) rm -f "$marker"; return 1 ;;
esac
sig=$(cursor_progress_signature "$tail40") || return 1
prev=$(cat "$marker" 2>/dev/null || true)
printf '%s' "$sig" > "$marker" || return 1
case "$sig" in
token=*' context='*) ;;
*) return 1 ;;
esac
[ -n "$prev" ] || return 1
case "$prev" in
token=*' context='*) ;;
*) return 1 ;;
esac
token=${sig#token=}
context=${token#* context=}
token=${token%% context=*}
prev_token=${prev#token=}
prev_context=${prev_token#* context=}
prev_token=${prev_token%% context=*}
if [ -n "$token" ] && [ -n "$prev_token" ] && [ "$token" != "$prev_token" ]; then
return 0
fi
[ -n "$context" ] && [ -n "$prev_context" ] && [ "$context" != "$prev_context" ]
}

# Steering-inbox loss detection, one cheap check per recorded window per poll.
# Quiet when healthy: an absent, empty, or handled inbox costs one directory
# glob and produces nothing. When the ladder (fm_task_inbox_due_action, the
Expand Down Expand Up @@ -1335,10 +1411,11 @@ EOF
# content cannot suppress stale detection. Read once per window per poll and
# reused below so a busy verdict is consistent within one cycle.
if window_is_busy "$w" "$tail40"; then busy_now=0; else busy_now=1; fi
if cursor_progress_changed "$w" "$tail40" "$key"; then cursor_progress_now=0; else cursor_progress_now=1; fi
if [ "$h" = "$prev" ]; then
n=$(( $(cat "$cf" 2>/dev/null || echo 0) + 1 ))
echo "$n" > "$cf"
if [ "$n" -ge 2 ] && [ "$busy_now" -ne 0 ]; then
if [ "$n" -ge 2 ] && [ "$busy_now" -ne 0 ] && [ "$cursor_progress_now" -ne 0 ]; then
# The pane is idle/stale at hash $h. Triage decides whether this wakes
# firstmate. Detection itself is unchanged from above.
if [ "$kind" = secondmate ]; then
Expand Down Expand Up @@ -1445,7 +1522,10 @@ EOF
# then route it through busy_turn_bound_check, which hands the crossed
# bound to the same wedge timer unless the crew declared the wait itself.
paused_bound=1
if [ "$busy_now" -eq 0 ] && busy_turn_over_age "$task"; then
if [ "$cursor_progress_now" -eq 0 ]; then
rm -f "$ssf" "$ewf"
clear_write_tracking "$key"
elif [ "$busy_now" -eq 0 ] && busy_turn_over_age "$task"; then
busy_turn_bound_check "$w" "$task" "$h" "$ssf" "$ewf" && paused_bound=0
else
rm -f "$ssf" "$ewf"
Expand All @@ -1463,7 +1543,10 @@ EOF
printf '%s' "$h" > "$hf"
echo 0 > "$cf"
paused_bound=1
if [ "$busy_now" -eq 0 ] && busy_turn_over_age "$task"; then
if [ "$cursor_progress_now" -eq 0 ]; then
rm -f "$ssf" "$ewf"
clear_write_tracking "$key"
elif [ "$busy_now" -eq 0 ] && busy_turn_over_age "$task"; then
busy_turn_bound_check "$w" "$task" "$h" "$ssf" "$ewf" && paused_bound=0
else
rm -f "$ssf" "$ewf"
Expand Down
1 change: 1 addition & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ That deferral re-surfaces on the same `FM_PAUSE_RESURFACE_SECS` cadence as a dec
Every absence of write evidence, including a missing worktree record, a torn-down worktree, a walk that outlives its wall-clock bound on a hung mount, and a failed walk, leaves the existing escalation schedule untouched, so a crew that writes nothing still escalates exactly as before.
A secondmate's recorded worktree is never probed for write activity, because it is a provisioned firstmate home whose own supervision keeps writing inside it whether or not the mate produces anything, so its panes keep escalating on the unchanged schedule.
A busy pane is otherwise exempt from staleness, but only until its latest `state/<id>.turn-ended` marker reaches `FM_BUSY_TURN_MAX_SECS`, or its `state/<id>.meta` spawn record reaches that age before any turn completes; past that bound it is routed through the same wedge escalation, with the identical reason, escalation count, worktree-write deferral, and `demand-deep-inspection` marker, for inspection only - never an automatic interrupt, signal, or restart.
For Cursor only, a numeric token count or context percentage that changes between two recognized Cursor-owned status/footer samples is positive liveness and resets that wedge timer; a spinner-only redraw, a field that is absent from either sample, an unrecognized layout, or progress-shaped ordinary pane content is not liveness evidence.
A crew that declared an external wait (`paused:`) or a verified captain-held transfer is the one exception to that bound: its busy verdict supplies liveness while identifying the long-running foreground call as the declared wait, so it takes the bounded `FM_PAUSE_RESURFACE_SECS` recheck instead of a wedge escalation.
Lifting the declaration restores the unchanged busy-pane wedge path, while a pane that is no longer busy returns to the existing idle declared-wait classification.
Those actionable wakes are written to a durable local queue (`state/.wake-queue`) only after generation-bound recovery evidence is published, so an interrupted watcher or handling turn can be recovered without losing the queue record.
Expand Down
2 changes: 1 addition & 1 deletion docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -703,7 +703,7 @@ FM_SIGNAL_GRACE=30 # seconds to coalesce nearby status and turn-end signals
FM_CAPTAIN_RE='done:|needs-decision:|blocked:|failed:|PR ready|checks green|ready in branch|merged' # captain-relevant status regex; nonterminal progress verbs remain excluded even when their prose matches
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; stale panes whose crew is not provably working surface immediately unless they declare the pause verb
FM_BUSY_TURN_MAX_SECS=3600 # maximum age of a busy pane's latest state/<id>.turn-ended marker, or its state/<id>.meta spawn record before any turn completes, 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 or verified captain-held transfer takes the FM_PAUSE_RESURFACE_SECS recheck below instead
FM_BUSY_TURN_MAX_SECS=3600 # maximum age of a busy pane's latest state/<id>.turn-ended marker, or its state/<id>.meta spawn record before any turn completes, 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 or verified captain-held transfer takes the FM_PAUSE_RESURFACE_SECS recheck below instead; a changed Cursor token count or context percentage present in consecutive recognized status/footer samples resets the wedge timer as positive liveness
FM_PAUSE_RESURFACE_SECS=3600 # seconds before the watcher re-surfaces a declared external wait or verified captain-held transfer for a recheck, including a live busy pane past FM_BUSY_TURN_MAX_SECS; the away-mode daemon uses the same setting for a declared external wait or verified captain-held transfer
FM_SECONDMATE_WAKE_STALL_SECS=60 # minimum age of the oldest valid foreign wake-queue row before an endpoint-recorded local secondmate produces one durable parent wake-loop-stall notification; zero or invalid values use 60
FM_WEDGE_DEMAND_INSPECT_COUNT=3 # consecutive provably-working stale escalations on the same unchanged pane before demand-deep-inspection is added
Expand Down
5 changes: 3 additions & 2 deletions docs/verification/runtime-backends.md
Original file line number Diff line number Diff line change
Expand Up @@ -868,11 +868,12 @@ After an interrupt the aborted close was observed within seconds in some runs an
Binding never reconstructs cursor's workspace-slug directory name, which collapses path separators.
Cursor records the exact absolute workspace path in each project directory's `.workspace-trusted`, and the binding matches on that value.

### Rendered busy token, delivery only
### Rendered busy progress

Mid-turn the pane showed a braille spinner plus a verb, and `ctrl+c to stop` on the composer row; both the verb line and that token were absent the instant the turn ended.
The same version rendered `Working` in one turn and `Running` in the next, so the TOKEN is matched and the verb is not.
This row is a delivery guard for submit acknowledgement only; recorded worker state comes from the transcript fold.
For submit acknowledgement this row remains a delivery guard, and recorded worker state still comes from the transcript fold.
The watcher separately treats a changed numeric token count or context percentage in consecutive recognized Cursor-owned status/footer samples as positive liveness after the busy-turn age bound; `tests/fm-watch-triage.test.sh` pins the known Working, Thinking, Reading, and Running forms, context-only progress, static-counter spinner redraw, field disappearance, unrecognized structure, ordinary-content rejection, and marker-write failure.

### Launch, lifecycle, and skills

Expand Down
2 changes: 2 additions & 0 deletions tests/fm-daemon.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -283,12 +283,14 @@ test_handle_wake_terminal_signal_clears_pause_tracking() {
: > "$state/.paused-$watcher_key"
: > "$state/.stale-$watcher_key"
: > "$state/.wedge-escalations-$watcher_key"
: > "$state/.cursor-progress-$watcher_key"
FM_STATE_OVERRIDE="$state" handle_wake "signal: $state/held-w10-terminal.status" "$state"
[ ! -e "$state/.subsuper-paused-$key" ] || fail "terminal signal retained the daemon pause marker"
[ ! -e "$state/.subsuper-stale-$key" ] || fail "terminal signal retained daemon stale tracking"
[ ! -e "$state/.paused-$watcher_key" ] || fail "terminal signal retained watcher pause tracking"
[ ! -e "$state/.stale-$watcher_key" ] || fail "terminal signal retained watcher stale tracking"
[ ! -e "$state/.wedge-escalations-$watcher_key" ] || fail "terminal signal retained watcher wedge tracking"
[ ! -e "$state/.cursor-progress-$watcher_key" ] || fail "terminal signal retained Cursor progress tracking"
FM_STATE_OVERRIDE="$state" handle_wake "stale: $win" "$state"
[ ! -e "$state/.subsuper-stale-$key" ] || fail "terminal stale dedupe restored daemon stale tracking"
pass "a terminal signal clears pause and stale tracking across both supervisors"
Expand Down
Loading