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.md
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,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-* .paused-* .wedge-escalations-* .dead-reported-* .writing-* .waiting-* .seen-* .hb-surfaced-* .last-* .heartbeat-streak .ready-work* 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
13 changes: 10 additions & 3 deletions bin/fm-captain-hold.sh
Original file line number Diff line number Diff line change
Expand Up @@ -338,16 +338,23 @@ load_decision() { # <path>; sets DECISION_TEXT and DECISION_DIGEST
# the root's own tasks-axi configuration, exactly like the transition library's
# mutate path.
tasks_axi() {
local data file root backend
local data file root backend status=0
data=$(fm_backlog_data_absolute "$DATA") || fail "data directory cannot be resolved: $DATA"
root=$(fm_backlog_root "$data") || fail "$FM_BACKLOG_TRANSITION_ERROR"
backend=$(fm_tasks_axi_backend "$root") || return 2
if [ "$backend" = markdown ]; then
file=$(fm_backlog_file "$data") || fail "$FM_BACKLOG_TRANSITION_ERROR"
(cd "$root" && tasks-axi "$@" --file "$file")
(cd "$root" && tasks-axi "$@" --file "$file") || status=$?
else
(cd "$root" && tasks-axi "$@")
(cd "$root" && tasks-axi "$@") || status=$?
fi
[ "$status" -eq 0 ] || return "$status"
case "$1" in
done|unhold|hold|block|unblock|start)
FM_HOME="$FM_HOME" FM_STATE_OVERRIDE="$STATE" FM_DATA_OVERRIDE="$DATA" \
"$SCRIPT_DIR/fm-ready-work.sh" wake || true
;;
esac
}

require_tasks_axi() {
Expand Down
3 changes: 3 additions & 0 deletions bin/fm-guard.sh
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,7 @@ fm_supervision_status "$STATE" "$GRACE"
in_flight=$FM_SUP_IN_FLIGHT
sources=$FM_SUP_SOURCES
checks=$FM_SUP_CHECKS
gated=$FM_SUP_GATED
needed=$FM_SUP_NEEDED
beacon_desc=$FM_SUP_BEACON_DESC
fm_watcher_supervision_verdict "$STATE" "$WATCH" "$GRACE" "$FM_HOME" "$FM_ROOT"
Expand Down Expand Up @@ -240,6 +241,8 @@ if [ "$watcher_healthy" = false ]; then
printf '● %s process-event source(s) registered, but %s.\n' "$sources" "$watcher_cause"
elif [ "$checks" -gt 0 ]; then
printf '● %s registered custom check(s), but %s.\n' "$checks" "$watcher_cause"
elif [ "$gated" -gt 0 ]; then
printf '● %s queued backlog item(s) wait on a date or blocker, but %s.\n' "$gated" "$watcher_cause"
else
printf '● X-mode relay polling needs supervision, but %s.\n' "$watcher_cause"
fi
Expand Down
263 changes: 263 additions & 0 deletions bin/fm-ready-work.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,263 @@
#!/usr/bin/env bash
# fm-ready-work.sh - the ready-work backstop: surface queued backlog work that
# became dispatchable without this home acting, and report whether gated queued
# work still needs a watcher to notice it.
#
# Usage: fm-ready-work.sh wake
# Append queued task ids released by a date or blocker gate to the durable
# wake queue before recording them as surfaced.
# Sourced (. bin/fm-ready-work.sh): fm_ready_work_scan, fm_ready_work_commit,
# fm_ready_work_release (bin/fm-watch.sh), and fm_ready_work_live_gates
# (bin/fm-supervision-lib.sh).
#
# WHY. Queued work gated on a date (`tasks-axi hold --until`, including captain
# holds deferred with bin/fm-captain-hold.sh --until) or on blockers can become
# ready without any turn in this home: a date passes, or a blocker is closed by a
# captain answer or a supported backlog close. A bare tasks-axi mutation bypasses
# bin/fm-tasks-axi.sh and is unsupported; home-local blockers missed that way are
# re-evaluated at session start. An undated captain hold or queued blocker alone
# does not keep a watcher running.
#
# READINESS is tasks-axi's own derivation, read from one `tasks-axi list` through
# bin/fm-tasks-axi.sh: its derived `blocked` and `held` fields already apply
# dependency state and compare hold dates to the local date. Ready means queued,
# not blocked, not held, and not a public-followup obligation, which is never
# dispatchable - the same set `tasks-axi ready` lists.
#
# ONCE PER TRANSITION. state/.ready-work-surfaced lists gated ready ids already
# surfaced. A scan reports gated ready ids missing from it; the caller commits
# the current gated ready set only after delivery. An id leaves the record when
# it is dispatched, closed, or re-held, so its next readiness is new again.
# state/.ready-work.lock serializes scan-to-commit across callers, so one
# transition is reported by exactly one of them.
#
# LIVE GATES (supervision need). A queued item's gate is live when it can clear
# without this home acting: a hold with a future date, or a blocker that is in
# flight or itself live-gated. An undated hold - or a blocker chain that ends in
# one, or in queued ready work this home has not dispatched - waits on this
# home's own next turn, so it never keeps a watcher alive; a dated gate keeps one
# only until its date, when the scan surfaces the item and the gate stops
# counting.
#
# STEPPING ASIDE. tasks-axi missing from PATH, config/backlog-backend=manual, a
# missing data directory, a markdown home with no backlog file, or a listing that
# fails, times out after 10 seconds, or cannot be
# parsed all mean nothing to surface and no need: this backstop never blocks a
# turn or a teardown on its own failure. The home's data and config directories
# come from FM_HOME unless FM_DATA_OVERRIDE / FM_CONFIG_OVERRIDE name them.

FM_READY_WORK_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
FM_READY_WORK_ELIGIBLE=
FM_READY_WORK_LIVE=0
FM_READY_WORK_NEW=
FM_READY_WORK_LOCK=

# Classify one `tasks-axi list` listing. Prints `eligible <id>` per gated ready item and
# a final `live <count>`; exits 2 when the listing lacks the expected table.
fm_ready_work_classify() {
LC_ALL=C awk '
function split_row(line, out, n, i, c, field, inq, esc) {
n = 0; field = ""; inq = 0; esc = 0
for (i = 1; i <= length(line); i++) {
c = substr(line, i, 1)
if (esc) { field = field c; esc = 0; continue }
if (inq) {
if (c == "\\") { esc = 1; continue }
if (c == "\"") { inq = 0; continue }
field = field c
continue
}
if (c == "\"") { inq = 1; continue }
if (c == ",") { out[++n] = field; field = ""; continue }
field = field c
}
out[++n] = field
return n
}
/^tasks: / { table = 1; next }
/^tasks\[[0-9]+\]\{/ {
header = $0
sub(/^[^{]*\{/, "", header)
sub(/\}:.*$/, "", header)
ncol = split(header, cols, ",")
for (i = 1; i <= ncol; i++) col[cols[i]] = i
table = 1
rows = 1
next
}
rows && /^ / {
line = substr($0, 3)
split_row(line, f)
id = f[col["id"]]
n++
ids[n] = id
state[id] = f[col["state"]]
kind[id] = f[col["kind"]]
blocked[id] = f[col["blocked"]]
held[id] = f[col["held"]]
until[id] = f[col["hold_until"]]
blockers[id] = f[col["blocked_by"]]
deps[id] = f[col["deps"]]
next
}
{ rows = 0 }
END {
if (!table) exit 2
if (n > 0 && !("id" in col && "state" in col && "kind" in col && "blocked" in col \
&& "blocked_by" in col && "deps" in col && "held" in col && "hold_until" in col)) exit 2
for (i = 1; i <= n; i++) {
id = ids[i]
if (state[id] != "queued" || kind[id] == "public-followup") continue
if (blocked[id] == "no" && held[id] == "no") {
if (deps[id] != "none" && deps[id] != "-" && deps[id] != "" \
|| until[id] != "-" && until[id] != "") print "eligible " id
}
if (held[id] == "yes" && until[id] != "-" && until[id] != "") live[id] = 1
}
# A blocked item is live when any open blocker is in flight or live itself;
# iterate to the fixpoint so a chain inherits its root gate. An undated
# hold stays not live even when blocked, since only this home releases it.
do {
changed = 0
for (i = 1; i <= n; i++) {
id = ids[i]
if (state[id] != "queued" || live[id] || blocked[id] != "yes") continue
if (held[id] == "yes" && (until[id] == "-" || until[id] == "")) continue
m = split(blockers[id], bs, ",")
for (j = 1; j <= m; j++) {
b = bs[j]
if (state[b] == "in_flight" || live[b]) { live[id] = 1; changed = 1; break }
}
}
} while (changed)
count = 0
for (id in live) if (live[id]) count++
print "live " count
}
'
}

# fm_ready_work_read <state-dir>
# Sets FM_READY_WORK_ELIGIBLE (sorted gated ready ids, one per line) and
# FM_READY_WORK_LIVE (live-gated queued count). Returns 0 on a good read, 1 when
# this home has no readable tasks-axi backlog (see STEPPING ASIDE).
fm_ready_work_read() {
local state=$1 home data config root backend listing classified
FM_READY_WORK_ELIGIBLE=
FM_READY_WORK_LIVE=0
home=${FM_HOME:-${FM_ROOT_OVERRIDE:-$(cd "$FM_READY_WORK_DIR/.." && pwd)}}
data=${FM_DATA_OVERRIDE:-$home/data}
config=${FM_CONFIG_OVERRIDE:-$home/config}
[ -d "$data" ] || return 1
command -v tasks-axi >/dev/null 2>&1 || return 1
# shellcheck source=bin/fm-tasks-axi-lib.sh
command -v fm_tasks_axi_backend >/dev/null 2>&1 \
|| . "$FM_READY_WORK_DIR/fm-tasks-axi-lib.sh" || return 1
fm_backlog_backend_manual "$config" && return 1
root=$(CDPATH='' cd -- "$data/.." 2>/dev/null && pwd -P) || return 1
backend=$(fm_tasks_axi_backend "$root" 2>/dev/null) || return 1
if [ "$backend" = markdown ] && [ ! -e "$data/backlog.md" ]; then
return 1
fi
# shellcheck source=bin/fm-timeout-lib.sh
command -v fm_run_timed >/dev/null 2>&1 \
|| . "$FM_READY_WORK_DIR/fm-timeout-lib.sh" || return 1
listing=$(FM_HOME="$home" FM_DATA_OVERRIDE="$data" \
fm_run_timed 10 \
"$FM_READY_WORK_DIR/fm-tasks-axi.sh" list --fields blocked,blocked_by,deps,held,hold_until \
2>/dev/null </dev/null) || return 1
classified=$(printf '%s\n' "$listing" | fm_ready_work_classify) || return 1
FM_READY_WORK_ELIGIBLE=$(printf '%s\n' "$classified" | sed -n 's/^eligible //p' | LC_ALL=C sort -u)
FM_READY_WORK_LIVE=$(printf '%s\n' "$classified" | sed -n 's/^live //p')
case "$FM_READY_WORK_LIVE" in ''|*[!0-9]*) FM_READY_WORK_LIVE=0; return 1 ;; esac
return 0
}

# fm_ready_work_live_gates <state-dir>: print the live-gated queued count.
fm_ready_work_live_gates() {
if fm_ready_work_read "$1"; then
printf '%s\n' "$FM_READY_WORK_LIVE"
else
printf '0\n'
fi
}

# fm_ready_work_scan <state-dir>
# Takes state/.ready-work.lock, reads the backlog, and sets FM_READY_WORK_NEW to
# the space-separated gated ready ids not yet surfaced. Returns 0 with the lock held, to be finished by
# fm_ready_work_commit; returns 1 with no lock held when there is nothing to
# read or the lock stays contended.
fm_ready_work_scan() {
local state=$1 record
FM_READY_WORK_NEW=
# shellcheck source=bin/fm-wake-lib.sh
command -v fm_lock_acquire_wait_bounded >/dev/null 2>&1 \
|| . "$FM_READY_WORK_DIR/fm-wake-lib.sh" || return 1
FM_READY_WORK_LOCK="$state/.ready-work.lock"
fm_lock_acquire_wait_bounded "$FM_READY_WORK_LOCK" 10 || return 1
if ! fm_ready_work_read "$state"; then
fm_ready_work_release
return 1
fi
record="$state/.ready-work-surfaced"
[ -e "$record" ] || record=/dev/null
FM_READY_WORK_NEW=$(printf '%s\n' "$FM_READY_WORK_ELIGIBLE" | LC_ALL=C awk '
FILENAME == ARGV[1] { if ($0 != "") seen[$0] = 1; next }
$0 != "" && !($0 in seen) { printf "%s%s", sep, $0; sep = " " }
' "$record" -)
return 0
}

# fm_ready_work_commit <state-dir>: record the scanned gated ready set as surfaced and
# release the scan lock.
fm_ready_work_commit() {
local state=$1 record tmp status=0
record="$state/.ready-work-surfaced"
if tmp=$(mktemp "$state/.ready-work-surfaced.XXXXXX"); then
if [ -n "$FM_READY_WORK_ELIGIBLE" ]; then
printf '%s\n' "$FM_READY_WORK_ELIGIBLE" > "$tmp" || status=1
fi
if [ "$status" -eq 0 ]; then
mv -f -- "$tmp" "$record" || status=1
fi
[ "$status" -eq 0 ] || rm -f -- "$tmp"
else
status=1
fi
fm_ready_work_release
return "$status"
}

fm_ready_work_release() {
[ -n "$FM_READY_WORK_LOCK" ] || return 0
fm_lock_release "$FM_READY_WORK_LOCK" || true
FM_READY_WORK_LOCK=
}

fm_ready_work_main() {
local state
case "${1:-}" in
wake) ;;
-h|--help)
awk 'NR == 1 { next } /^#/ { sub(/^# ?/, ""); print; next } { exit }' "$0"
return 0
;;
*)
printf 'usage: fm-ready-work.sh wake\n' >&2
return 2
;;
esac
state=${FM_STATE_OVERRIDE:-${FM_HOME:-$(cd "$FM_READY_WORK_DIR/.." && pwd)}/state}
fm_ready_work_scan "$state" || return 0
if [ -n "$FM_READY_WORK_NEW" ]; then
fm_wake_append check ready-work "check: ready-work: $FM_READY_WORK_NEW" || {
fm_ready_work_release
return 1
}
fi
fm_ready_work_commit "$state"
}

if [ "${BASH_SOURCE[0]}" = "${0}" ]; then
fm_ready_work_main "$@"
fi
16 changes: 15 additions & 1 deletion bin/fm-supervision-lib.sh
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@
# live watcher process means per supervision model. The status fields here retain
# the beacon-age details used in their messages.

# shellcheck source=/dev/null
. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/fm-ready-work.sh"

# Portable mtime; Linux stat lacks -f, macOS stat lacks -c.
fm_sup_stat_mtime() {
if [ "$(uname)" = Darwin ]; then
Expand All @@ -35,10 +38,16 @@ fm_sup_stat_mtime() {
# sweep's call at execution time, and a home whose check
# no longer validates needs the watcher precisely so the
# sweep can report the rejection instead of going quiet.
# FM_SUP_GATED count of live-gated queued backlog items: a future hold
# date, or blockers that can close without this home
# acting (bin/fm-ready-work.sh owns that definition and
# the watcher wake it waits for). Read only when nothing
# above already needs supervision, which keeps the
# backlog read off a busy home's turn boundary; 0 then.
# FM_SUP_NEEDED true/false - in-flight work, an X-mode relay poll, a
# registered event source (a source is a wait on an
# external process, not a task, so it has no metadata),
# or a registered custom check
# a registered custom check, or live-gated queued work
# FM_SUP_WATCHER_FRESH true/false - a watcher beacon within the grace window
# FM_SUP_BEACON_DESC human-readable beacon age, for banners ("never" if absent)
# FM_SUP_QUEUE_PENDING true/false - state/.wake-queue has unread records
Expand Down Expand Up @@ -78,6 +87,11 @@ fm_supervision_status() {
|| [ "$FM_SUP_CHECKS" -gt 0 ]; then
FM_SUP_NEEDED=true
fi
FM_SUP_GATED=0
if [ "$FM_SUP_NEEDED" = false ]; then
FM_SUP_GATED=$(fm_ready_work_live_gates "$state")
[ "$FM_SUP_GATED" -gt 0 ] && FM_SUP_NEEDED=true
fi

beat="$state/.last-watcher-beat"
if [ -e "$beat" ]; then
Expand Down
9 changes: 8 additions & 1 deletion bin/fm-tasks-axi.sh
Original file line number Diff line number Diff line change
Expand Up @@ -124,4 +124,11 @@ else
fi

cd "$FM_BACKLOG_AXI_ROOT" || fail "cannot enter the backlog root $FM_BACKLOG_AXI_ROOT"
exec tasks-axi ${ARGS[@]+"${ARGS[@]}"}
case "${ARGS[0]:-}" in
done|unhold|hold|block|unblock|start)
tasks-axi ${ARGS[@]+"${ARGS[@]}"} || exit $?
FM_HOME="$FM_HOME" FM_DATA_OVERRIDE="$DATA" \
"$SCRIPT_DIR/fm-ready-work.sh" wake || true
;;
*) exec tasks-axi ${ARGS[@]+"${ARGS[@]}"} ;;
esac
3 changes: 3 additions & 0 deletions bin/fm-teardown.sh
Original file line number Diff line number Diff line change
Expand Up @@ -1558,6 +1558,9 @@ backlog_refresh_reminder() {
printf '%s\n' "Backlog: $ID stays open in $backlog_display, still held for the captain with its deliverable recorded. Relay the question and close it only with bin/fm-captain-hold.sh answer."
elif [ "$BACKLOG_CLOSED" = 1 ]; then
printf '%s\n' "Backlog: $ID is closed in $backlog_display. Run bin/fm-tasks-axi.sh ready for dependency-cleared candidates, check date gates, and dispatch only work whose blockers are gone and date is due."
FM_HOME="$FM_HOME" FM_STATE_OVERRIDE="$STATE" FM_DATA_OVERRIDE="$DATA" \
FM_CONFIG_OVERRIDE="$CONFIG" "$SCRIPT_DIR/fm-ready-work.sh" wake \
|| true
else
printf '%s\n' "Backlog: $ID just finished ($BACKLOG_SKIP_REASON). Update $backlog_display - move $ID to Done, keep Done to the 10 most recent, then re-scan Queued and dispatch only work whose blockers are gone and date is due."
fi
Expand Down
3 changes: 2 additions & 1 deletion bin/fm-test-run.sh
Original file line number Diff line number Diff line change
Expand Up @@ -304,7 +304,7 @@ family_for_basename() {
fm-mail.test.sh|fm-mail-check.test.sh|\
fm-turnend-foreign-owner-arm-fix.test.sh|\
fm-wake-queue.test.sh|fm-watch-arm.test.sh|fm-watch-checkpoint.test.sh|fm-watch-recovery-loop.test.sh|\
fm-watch-triage.test.sh|fm-task-inbox.test.sh|\
fm-watch-triage.test.sh|fm-task-inbox.test.sh|fm-ready-work.test.sh|\
fm-watcher-lock.test.sh|fm-inactive-reconcile.test.sh)
printf '%s\n' watcher-wake-lock
;;
Expand Down Expand Up @@ -771,6 +771,7 @@ tests/fm-project-origin.test.sh 136
tests/fm-public-followup.test.sh 153508
tests/fm-quota-array-dispatch-live-e2e.test.sh 71
tests/fm-quota-choose.test.sh 1484
tests/fm-ready-work.test.sh 14839
tests/fm-remote-backlog-handoff.test.sh 73123
tests/fm-remote-doctor.test.sh 13889
tests/fm-remote-entrypoint.test.sh 108
Expand Down
Loading
Loading