Skip to content
Open
Show file tree
Hide file tree
Changes from 4 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: 2 additions & 0 deletions bin/fm-brief.sh
Original file line number Diff line number Diff line change
Expand Up @@ -330,6 +330,7 @@ The report is the only thing that survives, so anything worth keeping must be in
5. If you hit the same obstacle twice, append \`blocked: {why}\` and stop; firstmate will help.
6. If a decision belongs to a human (product choices, destructive actions),
append \`needs-decision: {summary of options}\` and stop. Firstmate will reply with the decision.
Give it a key when more than one decision or blocker can be open at once: \`needs-decision [key=<slug>]: {summary}\`.
A decision or blocker you opened stays open until a \`resolved\` line carrying its exact key lands; a later \`done:\` or \`working:\` line never closes it, even when the answer is what started that work.
Firstmate's reply normally writes that closing line at answer time; when a blocker or wait clears WITHOUT a firstmate reply, append \`resolved: {how it cleared}\` yourself (same \`[key=<slug>]\` if you opened it with one) as you resume.
7. Never stop, restart, or update the shared \`no-mistakes\` daemon - it is one instance serving
Expand Down Expand Up @@ -447,6 +448,7 @@ $RULE1
5. If you hit the same obstacle twice, append \`blocked: {why}\` and stop; firstmate will help.
6. If a decision belongs above the implementation worker (product choices, destructive actions, ask-user findings),
append \`needs-decision: {summary of options}\` and stop. Firstmate will reply with the decision.
Give it a key when more than one decision or blocker can be open at once: \`needs-decision [key=<slug>]: {summary}\`.
A decision or blocker you opened stays open until a \`resolved\` line carrying its exact key lands; a later \`done:\` or \`working:\` line never closes it, even when the answer is what started that work.
Firstmate's reply normally writes that closing line at answer time; when a blocker or wait clears WITHOUT a firstmate reply, append \`resolved: {how it cleared}\` yourself (same \`[key=<slug>]\` if you opened it with one) as you resume.
7. Never stop, restart, or update the shared \`no-mistakes\` daemon - it is one instance serving
Expand Down
81 changes: 57 additions & 24 deletions bin/fm-send.sh
Original file line number Diff line number Diff line change
Expand Up @@ -83,19 +83,32 @@
# transferred from the live status log to its durable captain-held task, which
# the status ledger alone can no longer close.
#
# Each named key must therefore currently be open in ONE of the two ledgers: open
# in this home's status log per status_open_decisions (bin/fm-classify-lib.sh), or
# a still-open captain-held task resolved as above. A key in neither is refused
# before sending, so a mistyped key cannot deliver an answer while silently
# orphaning the decision. A failed or unconfirmed send never closes a key (a remote
# A named key is closed only when it is currently open in ONE of the two ledgers:
# this home's status log per status_open_decisions (bin/fm-classify-lib.sh), or
# a still-open captain-held task resolved as above. A key in neither closes
# nothing, and fm-send says so loudly on stderr and exits nonzero - but it still
# DELIVERS the answer, and still closes whichever other named keys are open.
# Delivering there is deliberate. Cancelling the whole send did not avoid the
# mis-state this flag exists to prevent - a decision that looks answered while
# its record stays open - it produced the worse version of it: the decision
# stayed open anyway AND no worker ever received the answer the captain
# believed was given. Delivering the text is never more dangerous than the
# plain steer that is always allowed; the flag's power is the CLOSE, and that
# is exactly what an unmatched key withholds. "Delivered, not closed" is also
# what the post-delivery append-failure path below already accepts as the safe
# direction, because an open decision re-surfaces in every later OPEN DECISIONS
# listing until it is really answered. The diagnostic stays loud so the key gets
# reconciled instead of assumed closed, and says not to resend.
# A failed or unconfirmed send never closes a key (a remote
# delivered-with-pending-confirmation outcome counts as delivered - see the
# remote paragraph above); a
# delivered answer whose closing append fails exits nonzero with the exact
# manual close command, leaving the decision open to re-surface (the safe
# direction). A send without the flag never closes anything: a routine steer,
# working:, or done: event still cannot clear a captain decision. The flag is
# refused with --key, with an explicit backend target (no task ledger in this
# home), and with an empty message.
# remote paragraph above); a delivered answer whose closing append fails exits
# nonzero with the exact manual close command, leaving the decision open to
# re-surface (the safe direction). A send without the flag never closes anything:
# a routine steer, working:, or done: event still cannot clear a captain
# decision. Malformed usage IS refused before anything is sent - a key outside
# the slug charset, a duplicate key, --key, an explicit backend target (no task
# ledger in this home), and an empty message - because those are argument errors
# with no answer to deliver, not ledger-state mismatches.
#
# After a successful text submit fm-send pauses FM_SEND_SETTLE seconds (default 1,
# 0 disables) before returning: submit confirmation only proves the text was
Expand Down Expand Up @@ -382,20 +395,25 @@ if [ -n "$TARGET_SELECTOR" ] && [ -n "$TARGET_META" ] && [ "$(fm_meta_get "$TARG
fi

# Validate the answerer-closes request before any durable mutation or send: the
# target must have a task ledger in THIS home, the send must carry an answer
# message, and every named key must be open right now in that ledger per the
# ONE authoritative fold (status_open_decisions). Refusing here, before the
# send, is what keeps a mistyped key loud instead of delivering an answer that
# silently leaves its decision open.
# target must have a task ledger in THIS home and the send must carry an answer
# message. Those are argument errors, refused outright.
# Each named key is then checked against the ONE authoritative fold
# (status_open_decisions) and split into the keys this send may close and the
# keys it may not. An unmatched key is announced here, before the send, so the
# reason is on the record even if delivery later fails - but it does not cancel
# the answer (see the header contract).
RESOLVE_STATUS_FILE=
# Which ledger each answered key belongs to. A key still open in the status log
# is owned by the status log: fm-captain-hold's `complete` closes that live copy
# at the moment it transfers a decision to its durable captain-held task, so
# "still open in status" and "already held" are the two sides of one transfer,
# never both at once. Checking the backlog only for keys the status log no
# longer owns also keeps the common path free of any backlog read.
# longer owns also keeps the common path free of any backlog read. A key in
# neither ledger is unmatched: the answer is still delivered, and nothing is
# closed for that key.
RESOLVE_STATUS_KEYS=
RESOLVE_HOLD_KEYS=
RESOLVE_UNMATCHED_KEYS=

# Resolve a --resolve-key key that the status log no longer owns to the
# captain-held task that carries it: the key as a task id itself (the collapsed
Expand Down Expand Up @@ -443,19 +461,20 @@ if [ -n "$RESOLVE_KEYS" ]; then
esac
# Not open in the status log. A decision already transferred to its durable
# captain-held task is exactly this case, and it is answerable - just
# through the other ledger - so check there before refusing.
# through the other ledger - so check there before treating it as unmatched.
if resolved_hold_id=$(fm_send_hold_resolved_id "$RESOLVE_TASK_ID" "$k"); then
RESOLVE_HOLD_KEYS="${RESOLVE_HOLD_KEYS}${RESOLVE_HOLD_KEYS:+ }$resolved_hold_id"
continue
fi
echo "error: --resolve-key '$k': no open decision or blocker with that key in $RESOLVE_STATUS_FILE, and no captain-held task '$k' or '$RESOLVE_TASK_ID-decision-$k' still open (already closed or mistyped). Re-check the OPEN DECISIONS listing, then resend without that key or with the right one; nothing was sent." >&2
exit 1
RESOLVE_UNMATCHED_KEYS="${RESOLVE_UNMATCHED_KEYS}${RESOLVE_UNMATCHED_KEYS:+ }$k"
echo "warning: --resolve-key '$k': no open decision or blocker with that key in $RESOLVE_STATUS_FILE, and no captain-held task '$k' or '$RESOLVE_TASK_ID-decision-$k' still open (already closed, mistyped, or transferred). The answer is still being delivered; nothing will be closed for that key." >&2
done
fi

# Close each answered decision in this home's ledger, only after delivery is
# fully confirmed. An append failure exits nonzero with the manual close
# command; the decision then stays open and re-surfaces, never silently lost.
# fully confirmed, and only for the keys that were actually open. An append
# failure exits nonzero with the manual close command; the decision then stays
# open and re-surfaces, never silently lost.
# The close is this home's own bookkeeping, written by the very turn that
# answered the decision, so it goes through the guarded self-announced append
# (bin/fm-wake-lib.sh) and does not wake this same session again; any
Expand Down Expand Up @@ -666,7 +685,7 @@ else
fi
# Delivery is fully confirmed: close each answered decision in this home's
# ledger (answerer-closes; see the header contract).
if [ -n "$RESOLVE_KEYS" ]; then
if [ -n "$RESOLVE_STATUS_KEYS" ] || [ -n "$RESOLVE_HOLD_KEYS" ]; then
fm_send_close_resolved_keys "$RESOLVE_ANSWER_TEXT" || exit 1
fm_send_feed_resolved_holds "$RESOLVE_ANSWER_TEXT" || exit 1
fi
Expand All @@ -683,3 +702,17 @@ else
# disables it. Scoped to this path only, never the shared submit core.
[ "${FM_SEND_SETTLE:-1}" = 0 ] || sleep "${FM_SEND_SETTLE:-1}"
fi

# The answer landed but at least one named key was not open, so the ledger is
# not in the state the caller expected: exit nonzero rather than reporting a
# clean success. The decision, if it exists under another key, stays open and
# re-surfaces in the next OPEN DECISIONS listing - which is also where the key
# that actually closes it is printed, next to its own close command.
if [ -n "$RESOLVE_UNMATCHED_KEYS" ]; then
RESOLVE_UNMATCHED_RENDERED=
for k in $RESOLVE_UNMATCHED_KEYS; do
RESOLVE_UNMATCHED_RENDERED="${RESOLVE_UNMATCHED_RENDERED}${RESOLVE_UNMATCHED_RENDERED:+ }--resolve-key '$k'"
done
echo "error: the answer was delivered to $T, but no decision was closed for $RESOLVE_UNMATCHED_RENDERED (not open in $RESOLVE_STATUS_FILE). Do not resend the answer. Re-check the OPEN DECISIONS listing and close the real key with its printed command, or leave it open if it is already closed." >&2
exit 1
fi
Comment thread
greptile-apps[bot] marked this conversation as resolved.
35 changes: 29 additions & 6 deletions bin/fm-wake-drain.sh
Original file line number Diff line number Diff line change
Expand Up @@ -130,8 +130,18 @@ EOF
# fm-classify-lib.sh's "incremental (cursor-backed) open-decisions fold").
# Bounded and silent: prints nothing when no decision is open, which is the
# common case.
#
# Printed key == answerable key. Every entry renders the key the fold actually
# decided - INCLUDING the "default" bucket a keyless line opens - and carries
# its own ready-to-run close command underneath, because the two must never
# disagree: an entry that showed no key while its note text still contained a
# "[key=...]" token advertised that token as the key, and fm-send closes only a
# key this same fold reports open. Rendering the fold's key and spelling the
# exact command for it removes the inference that mismatch needed. A "[key=...]"
# inside a note is worker prose, quoted verbatim rather than rewritten - the
# command line under the note is what says which key is real.
print_open_decisions_section() {
local snapshot=${1:-} open task key verb note line item_bytes=220 global_bytes=4000
local snapshot=${1:-} open task key verb note line cmd item_bytes=220 global_bytes=4000
local output='' used=0 shown=0 omitted=0 bytes

if [ -n "$snapshot" ]; then
Expand All @@ -143,20 +153,33 @@ print_open_decisions_section() {

while IFS=$(printf '\t') read -r task key verb note; do
[ -n "$task" ] || continue
line="$task"
[ "$key" = default ] || line="$line [key=$key]"
line="$line $verb: $note"
line="$task [key=$key] $verb: $note"
# The shared cut counts the item's own characters; the trailing newline this
# section's global budget also pays for is this caller's, so the per-item
# allowance passed down is one short of the cap.
fm_cap_line_var "$line" $((item_bytes - 1))
line=$FM_LINE_CAP_LINE
bytes=$(( ${#line} + 1 ))
# Never capped: a truncated command would be a command that does not run,
# and both operands are bounded already (a task id and a slug-charset key).
# The key is slug-charset by the fold's own check, but the task id is just a
# state-dir filename, and this line is printed to be RUN: an id outside the
# plain-slug charset gets a pointer instead of a command rather than a
# string an agent would paste into a shell.
case "$task" in
*[!A-Za-z0-9._-]*)
cmd=" close it: answer that task with bin/fm-send.sh --resolve-key $key (its id is not a plain slug; check $STATE)"
;;
*) cmd=" close it: bin/fm-send.sh $task --resolve-key $key '<answer>'" ;;
esac
Comment thread
greptile-apps[bot] marked this conversation as resolved.
# An entry is shown only when its note AND its command both fit, so no
# decision is ever listed without the command that closes it.
bytes=$(( ${#line} + 1 + ${#cmd} + 1 ))
if [ $((used + bytes)) -gt "$global_bytes" ]; then
omitted=$((omitted + 1))
continue
fi
output="$output$line
$cmd
"
used=$((used + bytes))
shown=$((shown + 1))
Expand All @@ -174,7 +197,7 @@ EOF
# the send that answers a listed decision also closes it, so closure never
# depends on the busy worker writing a matching resolved line (contract:
# bin/fm-send.sh header).
printf "OPEN DECISIONS: close one by answering it: bin/fm-send.sh <task> --resolve-key <key> '<answer>'\n" || return 1
printf 'OPEN DECISIONS: answer each one with the close command printed under it; the key that closes a decision is the bracketed key before its verb, never a [key=...] inside the note text.\n' || return 1
}

# Print the RECORD DIVERGENCE section: every captain call whose two records
Expand Down
52 changes: 29 additions & 23 deletions bin/fm-watch.sh
Original file line number Diff line number Diff line change
Expand Up @@ -487,9 +487,21 @@ clear_pause_tracking() { # <window-key>
}

# Reconcile a declared pause or captain-held status with authoritative crew state.
# After fm-crew-state has fallen back to stopped or unknown, paused classification is
# recovered only for a confidently dead ordinary crew, or for a secondmate, whose
# endpoint liveness this function deliberately never reads.
# The authoritative read decides first, and agent liveness never overrules it: a
# crew that DECLARED its own external wait is normally still alive holding
# something (a dev server, a session), so a live agent is that pause's expected
# shape rather than evidence against it. Liveness only breaks the remaining tie -
# a paused or captain-held status whose crew fm-crew-state can no longer confirm
# (stopped or unknown), where paused classification is recovered only for a
# confidently dead ordinary crew, or for a secondmate, whose endpoint liveness
# this function deliberately never reads, and a live or unreadable ordinary crew
# still surfaces, so a captain hold firstmate wrote over a crew that never
# declared the wait itself cannot silence a live decision gate.
# Once a decision grants the bounded cadence, the cheap .paused-<key> path holds
# it without re-reading anything until the recheck marker ages past
# STALE_ESCALATE_SECS. Re-deciding it per stale hash was the 2026-08-12 churn bug:
# a held dev server repaints, so every repaint was a fresh first sighting that
# re-read liveness and queued another contentless stale wake.
pause_state_class() { # <window> <task>
local win=$1 task=$2 key last recheck_file class agent_alive kind
key=$(window_key "$win")
Expand All @@ -500,29 +512,21 @@ pause_state_class() { # <window> <task>
crew_absorb_class "$task"
return
fi
# Read once past the declared-wait gate and reused by both liveness gates below,
# so a mate's stale poll costs one metadata scan rather than one per gate, and the
# far more common no-declaration path above still costs none.
kind=$(window_kind "$win")
if [ -e "$STATE/.paused-$key" ] && [ "$(age_of "$recheck_file")" -lt "$STALE_ESCALATE_SECS" ]; then
if [ "$kind" != secondmate ]; then
agent_alive=$(fm_backend_agent_alive "$(window_backend "$win")" "$win" 2>/dev/null) || agent_alive=unknown
if [ "$agent_alive" != dead ]; then
rm -f "$recheck_file"
printf 'none'
return
fi
fi
printf 'paused'
return
fi
# Read once past both cheap gates, so only the poll that actually re-decides a
# declared wait pays the metadata scan the liveness gate below needs; the far
# more common no-declaration and held-cadence paths above cost none.
kind=$(window_kind "$win")
class=$(crew_absorb_class "$task")
if [ "$class" = working ]; then
rm -f "$recheck_file"
printf 'working'
return
fi
if [ "$kind" != secondmate ]; then
if [ "$class" != paused ] && [ "$kind" != secondmate ]; then
agent_alive=$(fm_backend_agent_alive "$(window_backend "$win")" "$win" 2>/dev/null) || agent_alive=unknown
if [ "$agent_alive" != dead ]; then
rm -f "$recheck_file"
Expand All @@ -531,13 +535,15 @@ pause_state_class() { # <window> <task>
fi
fi
# Recover paused classification for a declared wait that authoritative crew state
# could not name. Reaching here already proves the only two admissible cases: an
# ordinary crew whose agent the gate above confirmed dead, so no live decision gate
# is being silenced, or a secondmate, whose endpoint liveness is deliberately never
# read and so cannot supply that confirmation. Without the mate case a mate's
# captain hold - which has no current-state mapping and so arrives as `none` -
# would be silenced by every caller rather than taking the bounded re-surface
# cadence, and a forgotten hold would rot invisibly.
# could not name. A crew the authoritative read already named paused skipped the
# gate above and needs no recovery, so reaching here still `none` proves the only
# two admissible cases: an ordinary crew whose agent the gate above confirmed
# dead, so no live decision gate is being silenced, or a secondmate, whose
# endpoint liveness is deliberately never read and so cannot supply that
# confirmation. Without the mate case a mate's captain hold - which has no
# current-state mapping and so arrives as `none` - would be silenced by every
# caller rather than taking the bounded re-surface cadence, and a forgotten hold
# would rot invisibly.
[ "$class" = none ] && class=paused
case "$class" in
paused) date +%s > "$recheck_file" ;;
Expand Down
Loading