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 @@ -207,7 +207,7 @@ When the configured tasks-axi backlog gate applies, the spawn itself moves the w
After spawning, confirm the worker is processing the brief and handle any trust dialog through `harness-adapters`.
A persistent secondmate is recorded in the secondmate registry and runtime state, never as a backlog work item.

Steer a worker with ordinary text through fail-closed `fm-send`: the message becomes a durable record in the task's steering inbox (multi-line text is legal, local and remote alike) and the worker's terminal receives only a constant doorbell line, with the watcher re-ringing an unacknowledged local message and escalating a stuck one (`bin/fm-task-inbox-lib.sh`; `bin/fm-send.sh` owns the typed-plane carve-outs).
Steer a worker with ordinary text through fail-closed `fm-send`: the message becomes a durable record in the task's steering inbox (multi-line text is legal, local and remote alike) and the worker's terminal receives only a constant doorbell line, with the watcher re-ringing an unacknowledged local message and escalating a stuck one (`bin/fm-task-inbox-lib.sh` owns the inbox and ladder policy, while `bin/fm-task-inbox-ladder.sh` owns durable ladder state; `bin/fm-send.sh` owns the typed-plane carve-outs).
A remote secondmate steer rides the same durable-inbox model through the remote transport; after an unconfirmed delivery, only the exact `FM_PENDING_REPLY_EXISTING_CORR=<id>` resend command printed by `fm-send` is safe because it preserves the request body for remote enqueue deduplication (`bin/fm-send.sh` header).
When a steer answers an open keyed decision or blocker, pass `fm-send`'s `--resolve-key` so the answer itself closes that decision record at answer time, identically for local and remote workers (contract: `bin/fm-send.sh` header).
`fm-send` is the data plane for text the worker should read; never use its key or text paths for interrupt, exit, or other lifecycle control, because routing-marked lifecycle text becomes chat the worker reasons about instead of executing.
Expand Down
8 changes: 5 additions & 3 deletions bin/fm-brief.sh
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,8 @@
# scaffold never emits a substitution a file-write tool would copy through.
# Every scaffold also carries the steering-inbox receive-and-ack section:
# process state/<id>.inbox/*.msg in order and acknowledge each by moving it to
# handled/ (record, doorbell, and ladder owned by bin/fm-task-inbox-lib.sh).
# handled/ (record, doorbell, and ladder policy owned by bin/fm-task-inbox-lib.sh;
# durable ladder state owned by bin/fm-task-inbox-ladder.sh).
# Ship tasks include a project-memory section bounding crewmate edits to a
# project's AGENTS.md/CLAUDE.md: only corrections of factually wrong
# information, including wrong information the task itself introduced - never
Expand Down Expand Up @@ -347,8 +348,9 @@ STATUS_APPEND="echo \"{state} [at=<epoch>]: {one short line}\" >> $STATUS_FILE &
INBOX_DIR=$(shell_quote "$STATE/$ID.inbox")

# The receive-and-ack half of the steering-inbox contract, included in every
# scaffold kind. The record format, doorbell line, and re-ring ladder are
# owned by bin/fm-task-inbox-lib.sh. The doorbell names the inbox as
# scaffold kind. The inbox record format, doorbell line, and re-ring policy are
# owned by bin/fm-task-inbox-lib.sh; durable ladder state is owned by
# bin/fm-task-inbox-ladder.sh. The doorbell names the inbox as
# "$FM_TASK_INBOX", which bin/fm-spawn.sh exports into every launch; the full
# path here remains the fallback for a worker launched without that export.
# The doorbell itself is self-describing, so this section is reinforcement
Expand Down
5 changes: 3 additions & 2 deletions bin/fm-send.sh
Original file line number Diff line number Diff line change
Expand Up @@ -54,8 +54,9 @@
# fire-and-forget record is excluded from that ladder; when config/wait-no-turns
# is present and its ring here was skipped or failed, the watcher rings it
# exactly once more.
# bin/fm-task-inbox-lib.sh owns the record format, the doorbell line, and the
# re-ring ladder. The composer pre-check before the ring is ADVISORY only: when
# bin/fm-task-inbox-lib.sh owns the inbox record format, doorbell line, and
# re-ring policy; bin/fm-task-inbox-ladder.sh owns the durable ladder state.
# The composer pre-check before the ring is ADVISORY only: when
# the composer visibly holds pending text the ring is skipped with a notice and
# the watcher re-rings an ordinary record later; no composer verdict is
# delivery proof on this plane, and a failed ring never fails the send.
Expand Down
139 changes: 139 additions & 0 deletions bin/fm-task-inbox-ladder.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
#!/usr/bin/env bash
# fm-task-inbox-ladder.sh - the one durable state record per steering-inbox
# message (fork issue #48: re-ring on state transition, not on a timer alone).
#
# Fork-owned seam sourced by bin/fm-task-inbox-lib.sh, which keeps owning the
# inbox contract and the ladder decision (fm_task_inbox_due_action). This file
# owns only the record: its format, its atomic write, and the transitions that
# change it. Nothing else in the inbox keeps ladder state.
#
# One record, <task>.inbox/.ring-state, one line, tab separated:
# <msg>\t<state>\t<count>\t<ring_at>\t<seen_at>
# msg basename of the oldest unhandled message the record describes
# state ringing | escalated
# count delivery attempts spent on msg
# ring_at epoch of the last delivery attempt
# seen_at epoch the worker was last seen busy while msg was ringing, else 0
#
# The record is valid only for the message it names. A message with no record,
# or whose name differs from the record's, is in the implicit `delivered`
# state, so a new oldest message is a fresh ladder by construction rather than
# by a reset someone must remember. `handled` is implicit too: an
# acknowledged message leaves the inbox root, and the record is removed once no
# unhandled message remains.
#
# Transitions, each driven by exactly one event and each ONE atomic rewrite:
# delivered -> ringing the watcher attempted delivery (record_ring)
# ringing -> ringing a re-ring (record_ring: count + 1) or a busy
# sighting after a ring (note_inflight: seen_at)
# ringing/delivered -> escalated the stale wake was queued (record_escalated)
# any -> handled the worker moved the message (record dropped)
# A busy sighting never acks, never touches the message, and only moves the
# re-arm point (see fm_task_inbox_due_action), so suppression cannot consume a
# row or change the idempotent re-handling semantics.
#
# Writes are temp-then-rename inside the inbox directory, so a reader never
# sees a partial record. A write failure returns 1 while the inbox exists and
# is a quiet no-op for a concurrently removed inbox; the watcher reports the
# failure through its one stale-wake path.
#
# Sourced by bin/fm-task-inbox-lib.sh; no side effects on source.

fm_task_inbox_ladder_path() { # <state-dir> <task-id>
printf '%s/%s.inbox/.ring-state' "$1" "$2"
}

# Print "<state> <count> <ring_at> <seen_at>" for <msg>: the record's own
# values when it names <msg>, else the implicit delivered state.
fm_task_inbox_ladder_read() { # <state-dir> <task-id> <msg-basename>
local path rec_msg="" state="" count="" ring_at="" seen_at=""
path=$(fm_task_inbox_ladder_path "$1" "$2")
IFS=$(printf '\t') read -r rec_msg state count ring_at seen_at 2>/dev/null < "$path" || true
case "$state" in
ringing|escalated) ;;
*) rec_msg= ;;
esac
case "$count" in ''|*[!0-9]*) rec_msg= ;; esac
case "$ring_at" in ''|*[!0-9]*) rec_msg= ;; esac
case "$seen_at" in ''|*[!0-9]*) rec_msg= ;; esac
if [ -n "$rec_msg" ] && [ "$rec_msg" = "$3" ]; then
printf '%s %s %s %s' "$state" "$count" "$ring_at" "$seen_at"
else
printf 'delivered 0 0 0'
fi
}

fm_task_inbox_ladder_write() { # <state-dir> <task-id> <msg> <state> <count> <ring_at> <seen_at>
local dir path tmp
dir="$1/$2.inbox"
path=$(fm_task_inbox_ladder_path "$1" "$2")
[ -d "$dir" ] || return 0
if [ ! -d "$path" ] \
&& tmp=$(mktemp "$dir/.ring-state.XXXXXX" 2>/dev/null) \
&& { printf '%s\t%s\t%s\t%s\t%s\n' "$3" "$4" "$5" "$6" "$7" > "$tmp"; } 2>/dev/null \
&& mv "$tmp" "$path" 2>/dev/null; then
return 0
fi
[ -z "${tmp:-}" ] || rm -f "$tmp" 2>/dev/null || true
[ -d "$dir" ] || return 0
return 1
}

fm_task_inbox_ladder_drop() { # <state-dir> <task-id>
rm -f "$(fm_task_inbox_ladder_path "$1" "$2")" 2>/dev/null || true
}

# A delivery attempt was made: a failed ring or a composer-protected skip still
# consumes budget, so neither an unreadable pane nor a permanently blocked
# composer can retry silently forever.
fm_task_inbox_ladder_record_ring() { # <state-dir> <task-id> <record-path>
local base=${3##*/} state count ring_at seen_at
IFS=" " read -r state count ring_at seen_at <<EOF
$(fm_task_inbox_ladder_read "$1" "$2" "$base")
EOF
fm_task_inbox_ladder_write "$1" "$2" "$base" ringing "$((count + 1))" "$(date +%s)" 0
}

# The stale wake for <msg> is durably queued. Wake-before-record ordering
# favors at-least-once recovery: a crash or write failure can cause a rare
# duplicate wake, never a lost one. A dead-pane escalation never rang, so it
# records count 0.
fm_task_inbox_ladder_record_escalated() { # <state-dir> <task-id> <record-path>
local base=${3##*/} state count ring_at seen_at
IFS=" " read -r state count ring_at seen_at <<EOF
$(fm_task_inbox_ladder_read "$1" "$2" "$base")
EOF
fm_task_inbox_ladder_write "$1" "$2" "$base" escalated "$count" "$ring_at" "$seen_at"
}

# True while the oldest message is ringing and its busy sighting may be
# refreshed. A recent sighting gates the expensive pane capture and rewrite;
# after half a grace the watcher may refresh it again.
fm_task_inbox_ladder_sighting_due() { # <ring-at> <seen-at> <grace-secs>
local ring_at=$1 seen_at=$2 grace=$3 half now
[ "$seen_at" = 0 ] && return 0
half=$(( (grace + 1) / 2 ))
now=$(date +%s)
[ "$((now - seen_at))" -ge "$half" ]
}

fm_task_inbox_ladder_probe_due() { # <state-dir> <task-id> <msg-basename> <grace-secs>
local state count ring_at seen_at
IFS=" " read -r state count ring_at seen_at <<EOF
$(fm_task_inbox_ladder_read "$1" "$2" "$3")
EOF
[ "$state" = ringing ] || return 1
fm_task_inbox_ladder_sighting_due "$ring_at" "$seen_at" "${4:-0}"
}

# The worker was busy while <msg> is ringing: re-arm the next ring from this
# sighting. Anything but a ringing record for <msg> is left untouched.
fm_task_inbox_ladder_note_inflight() { # <state-dir> <task-id> <msg-basename> <grace-secs>
local state count ring_at seen_at
IFS=" " read -r state count ring_at seen_at <<EOF
$(fm_task_inbox_ladder_read "$1" "$2" "$3")
EOF
[ "$state" = ringing ] || return 0
fm_task_inbox_ladder_sighting_due "$ring_at" "$seen_at" "${4:-0}" || return 0
fm_task_inbox_ladder_write "$1" "$2" "$3" ringing "$count" "$ring_at" "$(date +%s)"
}
Loading
Loading