diff --git a/AGENTS.md b/AGENTS.md index 8b5403096d..272f5b368b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/bin/fm-supervise-daemon.sh b/bin/fm-supervise-daemon.sh index 86bad52b44..fc0ec951ce 100755 --- a/bin/fm-supervise-daemon.sh +++ b/bin/fm-supervise-daemon.sh @@ -477,7 +477,8 @@ clear_pause_tracking() { # 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() { # diff --git a/bin/fm-watch.sh b/bin/fm-watch.sh index f5a714b4c7..0751aa82ad 100755 --- a/bin/fm-watch.sh +++ b/bin/fm-watch.sh @@ -46,7 +46,9 @@ # (state/.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 @@ -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. @@ -300,6 +303,79 @@ window_key() { # 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() { # + 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 + } + } + } + ') + 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() { # + 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 @@ -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 @@ -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" @@ -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" diff --git a/docs/architecture.md b/docs/architecture.md index cd0318c097..5480f2a83a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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/.turn-ended` marker reaches `FM_BUSY_TURN_MAX_SECS`, or its `state/.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. diff --git a/docs/configuration.md b/docs/configuration.md index a861b40678..9ee51f394c 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -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/.turn-ended marker, or its state/.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/.turn-ended marker, or its state/.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 diff --git a/docs/verification/runtime-backends.md b/docs/verification/runtime-backends.md index 0e918ad5c4..6a694e195a 100644 --- a/docs/verification/runtime-backends.md +++ b/docs/verification/runtime-backends.md @@ -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 diff --git a/tests/fm-daemon.test.sh b/tests/fm-daemon.test.sh index 962f143464..4c9ff22a83 100755 --- a/tests/fm-daemon.test.sh +++ b/tests/fm-daemon.test.sh @@ -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" diff --git a/tests/fm-watch-triage.test.sh b/tests/fm-watch-triage.test.sh index 1bee5cab9b..55428eebb9 100755 --- a/tests/fm-watch-triage.test.sh +++ b/tests/fm-watch-triage.test.sh @@ -163,6 +163,26 @@ record_pi_busy() { # --source pi-ext --event agent-start } +record_cursor_busy() { # + local dir=$1 id=$2 state projects workspace project conversation + state="$dir/state" + projects="$dir/cursor-projects" + workspace="$dir/worktree" + project="$projects/project" + conversation='conversation-1' + mkdir -p "$workspace" "$project/agent-transcripts/$conversation" + printf '{"workspacePath":"%s"}\n' "$workspace" > "$project/.workspace-trusted" + printf '{"role":"user","message":{"content":"work"}}\n' \ + > "$project/agent-transcripts/$conversation/$conversation.jsonl" + printf 'projects_root=%s\nworkspace_root=%s\n' "$projects" "$workspace" \ + > "$state/$id.cursor-session" +} + +write_cursor_progress_pane() { # + printf '%s\n ▄▄▄▄▄▄▄▄▄▄\n → Add a follow-up ctrl+c to stop\n ▀▀▀▀▀▀▀▀▀▀\n 1 task\n Cursor Grok 4.5 High · %s%% Run Everything\n ~/.treehouse/cursor-task · 39418af\n' \ + "$2" "$3" > "$1" +} + reap() { kill "$1" 2>/dev/null || true; wait "$1" 2>/dev/null || true; } # --- pure classifier predicates (fm-classify-lib.sh) ------------------------ @@ -1579,6 +1599,190 @@ test_busy_pane_changing_hash_escalates_past_turn_age_bound() { pass "a busy worker whose pane hash changes every poll still escalates once its completed-turn age reaches the bound" } +# Cursor's progress counters are stronger than generic pane churn. Once a +# long turn crosses the completed-turn bound, advancing either numeric value +# resets its wedge timer, while a spinner-only redraw with unchanged values +# still escalates. +test_cursor_progress_resets_busy_turn_wedge_timer() { + local dir state fakebin out capture_file window key sig pid sample verb tokens + local -a progress_samples=( + 'Working|5.12k' + 'Thinking|66.76k' + 'Reading|60.16k' + 'Running|61.16k' + ) + dir=$(make_case cursor-progress-turn-age); state="$dir/state"; fakebin="$dir/fakebin" + out="$dir/watch.out"; capture_file="$dir/pane.txt"; window="test:fm-cursor-progress" + write_cursor_progress_pane "$capture_file" ' ⠁⠆ Processing 1.00k tokens' 7 + printf 'window=%s\nkind=ship\nharness=cursor\n' "$window" > "$state/cursor-progress.meta" + record_cursor_busy "$dir" cursor-progress + printf 'working: setup complete\n' > "$state/cursor-progress.status" + sig=$(seen_sig "$state/cursor-progress.status"); printf '%s' "$sig" > "$state/.seen-cursor-progress_status" + key=$(printf '%s' "$window" | tr ':/.' '___') + touch -t 200001010000 "$state/cursor-progress.meta" + + # Establish the first numeric sample and the over-age wedge timer. + PATH="$fakebin:$PATH" FM_FAKE_TMUX_WINDOW="$window" FM_FAKE_TMUX_CAPTURE="$capture_file" \ + FM_STATE_OVERRIDE="$state" FM_BUSY_TURN_MAX_SECS=1 FM_STALE_ESCALATE_SECS=999 FM_POLL=1 FM_SIGNAL_GRACE=1 \ + FM_CHECK_INTERVAL=999999 FM_HEARTBEAT=999999 "$WATCH" > "$out" & + pid=$! + if ! wait_poll_cycle "$state" "$pid"; then + reap "$pid"; fail "Cursor's first progress sample escalated before the wedge threshold: $(cat "$out")" + fi + [ -s "$state/.stale-since-$key" ] || fail "Cursor's first progress sample did not preserve the busy-turn bound" + reap "$pid" + ack_stopped_cycle "$state" || fail "could not acknowledge the Cursor baseline watcher stop" + + # A changed token count is current liveness even though the turn is over-age. + for sample in "${progress_samples[@]}"; do + IFS='|' read -r verb tokens <<< "$sample" + write_cursor_progress_pane "$capture_file" " ⠋⠆ $verb $tokens tokens" 7 + echo $(( $(date +%s) - 500 )) > "$state/.stale-since-$key" + : > "$out" + PATH="$fakebin:$PATH" FM_FAKE_TMUX_WINDOW="$window" FM_FAKE_TMUX_CAPTURE="$capture_file" \ + FM_STATE_OVERRIDE="$state" FM_BUSY_TURN_MAX_SECS=1 FM_STALE_ESCALATE_SECS=1 FM_POLL=1 FM_SIGNAL_GRACE=1 \ + FM_CHECK_INTERVAL=999999 FM_HEARTBEAT=999999 "$WATCH" > "$out" & + pid=$! + if ! wait_poll_cycle "$state" "$pid"; then + reap "$pid"; fail "Cursor's $verb token progress was reported as a possible wedge: $(cat "$out")" + fi + [ ! -e "$state/.stale-since-$key" ] || fail "Cursor's $verb token progress did not reset the wedge timer" + reap "$pid" + ack_stopped_cycle "$state" || fail "could not acknowledge the Cursor $verb token-progress watcher stop" + done + + # Context growth carries the same liveness verdict. + write_cursor_progress_pane "$capture_file" ' ⠙⠆ Running 61.16k tokens' 8 + echo $(( $(date +%s) - 500 )) > "$state/.stale-since-$key" + : > "$out" + PATH="$fakebin:$PATH" FM_FAKE_TMUX_WINDOW="$window" FM_FAKE_TMUX_CAPTURE="$capture_file" \ + FM_STATE_OVERRIDE="$state" FM_BUSY_TURN_MAX_SECS=1 FM_STALE_ESCALATE_SECS=1 FM_POLL=1 FM_SIGNAL_GRACE=1 \ + FM_CHECK_INTERVAL=999999 FM_HEARTBEAT=999999 "$WATCH" > "$out" & + pid=$! + if ! wait_poll_cycle "$state" "$pid"; then + reap "$pid"; fail "Cursor's advancing context percentage was reported as a possible wedge: $(cat "$out")" + fi + [ ! -e "$state/.stale-since-$key" ] || fail "Cursor's advancing context percentage did not reset the wedge timer" + reap "$pid" + ack_stopped_cycle "$state" || fail "could not acknowledge the Cursor context-progress watcher stop" + + # Rotating only the spinner is generic pane churn and must not hide a wedge. + write_cursor_progress_pane "$capture_file" ' ⠸⠆ Running 61.16k tokens' 8 + echo $(( $(date +%s) - 500 )) > "$state/.stale-since-$key" + : > "$out" + PATH="$fakebin:$PATH" FM_FAKE_TMUX_WINDOW="$window" FM_FAKE_TMUX_CAPTURE="$capture_file" \ + FM_STATE_OVERRIDE="$state" FM_BUSY_TURN_MAX_SECS=1 FM_STALE_ESCALATE_SECS=1 FM_POLL=1 FM_SIGNAL_GRACE=1 \ + FM_CHECK_INTERVAL=999999 FM_HEARTBEAT=999999 "$WATCH" > "$out" & + pid=$! + wait_for_exit "$pid" 100 || fail "Cursor's spinner-only redraw hid an over-age busy-turn wedge" + grep -F "possible wedge" "$out" >/dev/null || fail "Cursor's spinner-only redraw did not preserve wedge escalation" + pass "Cursor token or context progress resets the busy-turn wedge timer; spinner-only redraws do not" +} + +test_cursor_progress_marker_write_failure_fails_closed() { + local dir state fakebin out err capture_file window key sig pid marker + dir=$(make_case cursor-progress-marker-unwritable); state="$dir/state"; fakebin="$dir/fakebin" + out="$dir/watch.out"; err="$dir/watch.err"; capture_file="$dir/pane.txt" + window="test:fm-cursor-progress-unwritable" + write_cursor_progress_pane "$capture_file" ' ⠋⠆ Working 60 tokens' 7 + printf 'window=%s\nkind=ship\nharness=cursor\n' "$window" > "$state/cursor-progress-unwritable.meta" + record_cursor_busy "$dir" cursor-progress-unwritable + printf 'working: setup complete\n' > "$state/cursor-progress-unwritable.status" + sig=$(seen_sig "$state/cursor-progress-unwritable.status") + printf '%s' "$sig" > "$state/.seen-cursor-progress-unwritable_status" + key=$(printf '%s' "$window" | tr ':/.' '___') + marker="$state/.cursor-progress-$key" + printf 'token=59tokens context=7%%' > "$marker" + chmod u-w "$marker" + touch -t 200001010000 "$state/cursor-progress-unwritable.meta" + echo $(( $(date +%s) - 500 )) > "$state/.stale-since-$key" + + PATH="$fakebin:$PATH" FM_FAKE_TMUX_WINDOW="$window" FM_FAKE_TMUX_CAPTURE="$capture_file" \ + FM_STATE_OVERRIDE="$state" FM_BUSY_TURN_MAX_SECS=1 FM_STALE_ESCALATE_SECS=1 FM_POLL=1 FM_SIGNAL_GRACE=1 \ + FM_CHECK_INTERVAL=999999 FM_HEARTBEAT=999999 "$WATCH" > "$out" 2> "$err" & + pid=$! + wait_for_exit "$pid" 100 || fail "an unwritable Cursor progress marker kept stale numeric progress live" + chmod u+w "$marker" + grep -F "possible wedge" "$out" >/dev/null \ + || fail "an unwritable Cursor progress marker did not preserve wedge escalation: $(cat "$out")" + pass "an unwritable Cursor progress marker fails closed" +} + +test_cursor_progress_requires_shared_numeric_field() { + local fixture name previous id dir state fakebin out capture_file window key sig pid marker + local -a fixtures=( + 'field-disappearance|token=60tokens context=7%' + 'empty-intersection|token=60tokens context=' + ) + for fixture in "${fixtures[@]}"; do + IFS='|' read -r name previous <<< "$fixture" + id="cursor-$name" + dir=$(make_case "$id"); state="$dir/state"; fakebin="$dir/fakebin" + out="$dir/watch.out"; capture_file="$dir/pane.txt"; window="test:fm-$id" + write_cursor_progress_pane "$capture_file" 'ordinary response output' 7 + printf 'window=%s\nkind=ship\nharness=cursor\n' "$window" > "$state/$id.meta" + record_cursor_busy "$dir" "$id" + printf 'working: setup complete\n' > "$state/$id.status" + sig=$(seen_sig "$state/$id.status") + printf '%s' "$sig" > "$state/.seen-${id}_status" + key=$(printf '%s' "$window" | tr ':/.' '___') + marker="$state/.cursor-progress-$key" + printf '%s' "$previous" > "$marker" + touch -t 200001010000 "$state/$id.meta" + echo $(( $(date +%s) - 500 )) > "$state/.stale-since-$key" + + PATH="$fakebin:$PATH" FM_FAKE_TMUX_WINDOW="$window" FM_FAKE_TMUX_CAPTURE="$capture_file" \ + FM_STATE_OVERRIDE="$state" FM_BUSY_TURN_MAX_SECS=1 FM_STALE_ESCALATE_SECS=1 FM_POLL=1 FM_SIGNAL_GRACE=1 \ + FM_CHECK_INTERVAL=999999 FM_HEARTBEAT=999999 "$WATCH" > "$out" & + pid=$! + wait_for_exit "$pid" 100 || fail "Cursor $name counted field visibility as numeric progress" + grep -F "possible wedge" "$out" >/dev/null \ + || fail "Cursor $name did not preserve wedge escalation: $(cat "$out")" + done + pass "Cursor progress requires a changed numeric field shared by consecutive samples" +} + +test_cursor_progress_rejects_content_masquerade() { + local dir state fakebin out capture_file window key sig pid marker + dir=$(make_case cursor-progress-content); state="$dir/state"; fakebin="$dir/fakebin" + out="$dir/watch.out"; capture_file="$dir/pane.txt"; window="test:fm-cursor-progress-content" + printf 'ordinary tool output processed 61 tokens\ncontext report reached 8%%\n' > "$capture_file" + printf ' ▄▄▄▄▄▄▄▄▄▄\n → Add a follow-up ctrl+c to stop\n ▀▀▀▀▀▀▀▀▀▀\n 1 task\n Cursor Grok 4.5 High · 7%% Run Everything\n ~/.treehouse/cursor-task · 39418af\n' >> "$capture_file" + printf 'window=%s\nkind=ship\nharness=cursor\n' "$window" > "$state/cursor-progress-content.meta" + record_cursor_busy "$dir" cursor-progress-content + printf 'working: setup complete\n' > "$state/cursor-progress-content.status" + sig=$(seen_sig "$state/cursor-progress-content.status") + printf '%s' "$sig" > "$state/.seen-cursor-progress-content_status" + key=$(printf '%s' "$window" | tr ':/.' '___') + marker="$state/.cursor-progress-$key" + printf 'token=60tokens context=7%%' > "$marker" + touch -t 200001010000 "$state/cursor-progress-content.meta" + echo $(( $(date +%s) - 500 )) > "$state/.stale-since-$key" + + PATH="$fakebin:$PATH" FM_FAKE_TMUX_WINDOW="$window" FM_FAKE_TMUX_CAPTURE="$capture_file" \ + FM_STATE_OVERRIDE="$state" FM_BUSY_TURN_MAX_SECS=1 FM_STALE_ESCALATE_SECS=1 FM_POLL=1 FM_SIGNAL_GRACE=1 \ + FM_CHECK_INTERVAL=999999 FM_HEARTBEAT=999999 "$WATCH" > "$out" & + pid=$! + wait_for_exit "$pid" 100 || fail "ordinary Cursor pane content masqueraded as numeric progress" + grep -F "possible wedge" "$out" >/dev/null \ + || fail "ordinary Cursor pane content suppressed wedge escalation: $(cat "$out")" + [ "$(cat "$marker")" = 'token= context=7%' ] \ + || fail "ordinary Cursor pane content entered the progress marker: $(cat "$marker")" + pass "Cursor progress ignores convincing numeric text outside its reserved rows" +} + +test_cursor_progress_unrecognized_structure_is_unknown() { + local dir state out + dir=$(make_case cursor-progress-unknown); state="$dir/state" + out=$(FM_STATE_OVERRIDE="$state" bash -c ' + . "$1/bin/fm-watch.sh" + cursor_progress_signature "ordinary output says Working 61 tokens and context reached 8%" + ' _ "$ROOT") + [ "$out" = 'unknown=cursor-progress-structure-unrecognized' ] \ + || fail "unrecognized Cursor structure did not return an explicit reasoned unknown: $out" + pass "unrecognized Cursor progress structure returns a reasoned unknown" +} + test_busy_pane_turn_end_touch_resets_age() { local dir state fakebin out capture_file window key pane_hash sig pid dir=$(make_case busy-turn-end-resets-age); state="$dir/state"; fakebin="$dir/fakebin" @@ -2634,6 +2838,11 @@ test_wedge_escalation_resets_when_pane_becomes_active test_busy_pane_below_turn_age_bound_is_absorbed test_busy_pane_stable_hash_escalates_past_turn_age_bound test_busy_pane_changing_hash_escalates_past_turn_age_bound +test_cursor_progress_resets_busy_turn_wedge_timer +test_cursor_progress_marker_write_failure_fails_closed +test_cursor_progress_requires_shared_numeric_field +test_cursor_progress_rejects_content_masquerade +test_cursor_progress_unrecognized_structure_is_unknown test_busy_pane_turn_end_touch_resets_age test_busy_pane_repeated_escalation_reaches_demand_deep_inspection test_busy_pane_default_turn_age_bound_is_3600s