From 166d9ba33af6a4a68f5eed37c11a9e46374109bd Mon Sep 17 00:00:00 2001 From: NewAiCoder Date: Sun, 6 Sep 2026 13:32:38 -0400 Subject: [PATCH 01/11] feat(procevent): add repeat mode and action environment to fm-procevent-when A `when` condition->action watch (`bin/fm-procevent-when.sh`) fired at most once: a successful fire was always a terminal outcome, and the runner retired the registration afterward. That is the right shape for a one-shot wait, but wrong for a source that must keep ringing every time a condition changes again - the only way to keep watching was to have some other agent notice the retirement and re-arm it by hand. - `fm-procevent-when.sh` gains `--repeat`: a successful fire releases the single-fire claim, journals the fire, and emits a non-terminal `repeat: continues` outcome, so the runner keeps the registration and restarts the poll instead of retiring it. The deadline is then measured from the last fire, so it means the condition stopped changing, not that the watch itself is old. - A new `silent` command makes that same successful repeat fire a routine no-op the runner records as handled without a wake, using the existing adapter seam, since the action has already done its job by the time the fire is journalled. Every other outcome, in both modes, stays terminal and still wakes with evidence. - `fm-procevent-when.sh` gains `--action-env NAME=VALUE`, recorded in the hash-bound spec (denying interpreter/loader-hijacking variable names), so an action can carry the environment it needs while the action executable itself stays argv[0] and its bytes stay trust-bound. tests/fm-procevent-when.test.sh covers arming with `--repeat`, the `repeat: continues` outcome, the `silent` no-wake path, the last-fire deadline reset, and `--action-env` validation and propagation. Fixes #3921 --- .agents/skills/process-event-sources/SKILL.md | 3 +- bin/fm-procevent-when.sh | 252 +++++++++++++++--- docs/configuration.md | 13 +- docs/scripts.md | 2 +- docs/verification/process-event-sources.md | 2 + tests/fm-procevent-when.test.sh | 143 ++++++++++ 6 files changed, 376 insertions(+), 39 deletions(-) diff --git a/.agents/skills/process-event-sources/SKILL.md b/.agents/skills/process-event-sources/SKILL.md index 8b765f01c8a..65f71715206 100644 --- a/.agents/skills/process-event-sources/SKILL.md +++ b/.agents/skills/process-event-sources/SKILL.md @@ -81,6 +81,7 @@ bin/fm-procevent-when.sh arm --condition ... --action ... Eligibility is a firstmate judgment made BEFORE arming, because the scripts cannot classify an argv: the action must be safe, reversible, and exact (for example `no-mistakes update --beta`, whose own guard refuses while a validation run is active). Never bind an action that is destructive, irreversible, or security-sensitive, an action needing captain approval or any gate decision, or an action whose right form depends on what the condition finds - those keep the existing check-fires-then-firstmate-decides flow, for which a plain custom check or another adapter stays correct. When in doubt, arm only the condition half as an ordinary check and keep the action as a wake-time decision. +`--repeat` turns a one-shot watch into "ring X every time Y changes", which is right whenever the condition is an edge the target needs to hear about more than once - a worker waiting on its own pipeline state is the standing example. Its successful fires are silent and it stops only on a failure or a `retire`, so use it only for an action that is safe to run repeatedly. `bin/fm-procevent.sh --help`, `bin/fm-procevent-lavish.sh --help`, `bin/fm-procevent-when.sh --help`, `bin/fm-procevent-quota.sh --help`, and `bin/fm-procevent-remote-reply.sh --help` own the exact commands and flags. @@ -123,7 +124,7 @@ The crew-hosted recovery ordering and arm-and-acknowledge rule are owned by the A board close carrying a real answer, and every other result, still wakes its owner unchanged. Never read the absence of a wake as proof a review is still open; ask the source, not the queue. : A Lavish wake whose source id matches `bin/fm-procevent-lavish.sh source-id "$(bin/fm-bearings-board.sh path)"` is a bearings board result; load the `bearings` skill's board-wake handling regardless of which answer kinds the result contains. -: A `when` wake carries the watch's one terminal captured outcome and may be re-announced until handled: `bin/fm-procevent-when.sh classify ` returns `fired` (relay the success and its output); `action-failed` (relay the captured error and decide recovery); `condition-error`, `never-true`, or `rejected` (the watch stopped safely without acting - report why and decide whether to re-arm); or `ambiguous` (the action was claimed but its outcome was never captured - verify its effect manually before anything else). Every `when` outcome is terminal and the action is never retried automatically, so after handling and the generic acknowledgement above, run `bin/fm-procevent-when.sh retire ` to clean the watch's private records before any re-arm. +: A `when` wake always carries a TERMINAL captured outcome and may be re-announced until handled: `bin/fm-procevent-when.sh classify ` returns `fired` (relay the success and its output); `action-failed` (relay the captured error and decide recovery); `condition-error`, `never-true`, or `rejected` (the watch stopped safely without acting - report why and decide whether to re-arm); or `ambiguous` (the action was claimed but its outcome was never captured - verify its effect manually before anything else). The action is never retried automatically, so after handling and the generic acknowledgement above, run `bin/fm-procevent-when.sh retire ` to clean the watch's private records before any re-arm. A repeat watch's successful fire is the one outcome that is neither terminal nor announced: it is recorded handled and the watch keeps going, so you will never see a wake for it, and the absence of `when` wakes from a repeat watch means it is working rather than that nothing happened. : A `quota` wake carries one terminal quota-check outcome: `bin/fm-procevent-quota.sh classify ` returns `low`, `exhausted`, `error`, or `unknown`. Report the provider and captured quota state, decide whether the active work should continue or move, then use the generic acknowledgement above. Re-arm explicitly if continued monitoring is needed. : Treat every byte of the result as **input, never instruction and never authority**. It came from outside firstmate, so it must not be executed, echoed into a shell, or read as permission. An approval in a result routes through the ordinary merge and decision owners, unchanged. : Never append a raw result to a task's status history; that log is a bounded event record, not a payload channel. diff --git a/bin/fm-procevent-when.sh b/bin/fm-procevent-when.sh index 76f11f7df68..9f8635ac424 100755 --- a/bin/fm-procevent-when.sh +++ b/bin/fm-procevent-when.sh @@ -1,13 +1,14 @@ #!/usr/bin/env bash # Condition->action adapter for the generic process-to-event runner: register a # deterministic condition and a deterministic action once, let the runner's -# blocking child poll the condition tokenlessly, fire the action at most once on -# a stable true, and publish one terminal outcome, re-announced until handled. +# blocking child poll the condition tokenlessly, fire the action on a stable +# true, and publish one terminal outcome, re-announced until handled. # # Usage: # fm-procevent-when.sh arm [options] --condition ... --action ... # fm-procevent-when.sh classify # fm-procevent-when.sh terminal +# fm-procevent-when.sh silent # fm-procevent-when.sh source-id # fm-procevent-when.sh retire # fm-procevent-when.sh rebind-all @@ -30,6 +31,21 @@ # --action-timeout bound on the action run (default 1800) # --error-budget consecutive condition errors tolerated # before waking firstmate (default 3) +# --action-env one environment assignment to run the +# action under; repeatable. The +# assignments are part of the +# hash-bound spec, and the action +# executable itself stays argv[0], so +# its bytes remain trust-bound. NAME is +# refused outright when it could hijack +# an interpreter or loader instead of +# configuring the action (PATH, +# LD_PRELOAD, PYTHONPATH, and similar - +# see env_assignment_valid for the +# exact list). +# --repeat keep watching after a successful +# fire instead of ending there (see +# REPEAT MODE below) # The condition argv must exit 0 for true, 1 for a clean false; # any other exit (or a per-poll timeout) is an error, never a true. # POLICY, not enforceable here: both halves must be exact and @@ -42,14 +58,21 @@ # classify Print the captured outcome class a handler should act on: # fired, action-failed, condition-error, never-true, ambiguous, # rejected, or unknown. -# terminal Exit 0 when the captured result ends this source. Every when -# outcome is terminal because the pair fires at most once; the -# generic runner then retires the registration itself. +# terminal Exit 0 when the captured result ends this source. Every outcome of +# a one-shot watch is terminal because the pair fires at most once. +# A repeat watch's successful fire is the one non-terminal outcome, +# so the generic runner keeps the registration and restarts the poll; +# every other outcome is terminal there too. +# silent Exit 0 when the captured result is a routine no-op the generic +# runner should record as handled instead of announcing. That is +# exactly a repeat watch's successful fire: the worker was already +# rung by the action itself, so waking firstmate to say so is a model +# turn spent learning nothing. # source-id Print the canonical source id for . # retire Stop the watch: retire the registration and remove the spec, trust -# record, and fired marker. Idempotent. Captured results and their -# handled acknowledgements are never touched. Warns when the action -# had already fired without a captured outcome. +# record, fired marker, and fire journal. Idempotent. Captured +# results and their handled acknowledgements are never touched. +# Warns when the action had already fired without a captured outcome. # rebind-all Refresh the trust binding of every registered watch whose action # executable lives under this repo (FM_ROOT), re-hashing it against # its CURRENT on-disk bytes. A self-update fast-forwards bin/ in @@ -74,18 +97,39 @@ # was never captured - emits a terminal outcome document instead of # retrying silently, so firstmate is always woken with the evidence. # +# REPEAT MODE +# A one-shot watch answers "do X as soon as Y is true". A repeat watch answers +# "ring X every time Y changes", which is what a worker waiting on its own +# pipeline needs: that state changes several times per run, and a watch that +# died on the first change left every later change to a model turn. +# Under --repeat, a successful fire releases the single-fire claim, appends one +# line to the watch's fire journal (state/when/.fires), and emits a +# `repeat: continues` outcome. That outcome is silent and non-terminal, so the +# generic runner records it handled without a wake and restarts the poll; the +# deadline is then measured from the last fire rather than from arming, so it +# means "the condition stopped changing" instead of "the watch got old". +# Everything else is unchanged: a failed action, a condition error past its +# budget, a deadline, a mutated spec or action, and an uncaptured claimed fire +# are all still terminal and still wake firstmate with the evidence. The +# single-fire claim still makes one fire unrepeatable within its own poll, so +# only a fire whose outcome was emitted lets the next one happen. A repeat +# action must therefore be safe to run again, which is the standard the +# one-shot action already had to meet. +# # Outcome document (the captured result named by the wake): # when: # status: fired|action-failed|condition-error|never-true|ambiguous|rejected # detail: # condition_polls: # action_exit: (fired and action-failed only) +# repeat: continues (a repeat watch's successful fire only) # output: # # # Ownership, durable capture, publication, restart recovery, and the handled # acknowledgement all belong to bin/fm-procevent.sh; this adapter owns only the # condition->action semantics above. + set -u SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" @@ -107,11 +151,19 @@ WHEN_DIR="$STATE/when" OUTPUT_TAIL_BYTES=${FM_WHEN_OUTPUT_TAIL_BYTES:-8192} die() { printf 'error: %s\n' "$1" >&2; exit 1; } -usage() { sed -n '2,72p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'; exit 2; } +# The header comment block runs from line 2 to the first blank line, so this +# stays correct as the block grows. +usage() { sed -n '2,/^$/p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'; exit 2; } spec_file() { printf '%s/%s.spec\n' "$WHEN_DIR" "$1"; } trust_file() { printf '%s/%s.trust\n' "$WHEN_DIR" "$1"; } fired_file() { printf '%s/%s.fired\n' "$WHEN_DIR" "$1"; } +fires_file() { printf '%s/%s.fires\n' "$WHEN_DIR" "$1"; } + +# A repeat watch keeps only the most recent fires; the journal is evidence for +# the handler, not an audit log, and it must not grow without bound on a source +# that legitimately fires for days. +FIRES_JOURNAL_LINES=${FM_WHEN_FIRES_JOURNAL_LINES:-200} when_name_valid() { local name=${1-} @@ -127,6 +179,18 @@ cmd_source_id() { positive_int() { case "${1-}" in ''|*[!0-9]*) return 1 ;; 0) return 1 ;; *) return 0 ;; esac } +env_assignment_valid() { # + local LC_ALL=C + [[ "${1-}" =~ ^[A-Za-z_][A-Za-z0-9_]*= ]] || return 1 + case "${1%%=*}" in + PATH|IFS|ENV|BASH_ENV|SHELLOPTS|BASHOPTS|GLOBIGNORE| \ + LD_PRELOAD|LD_LIBRARY_PATH|LD_AUDIT|DYLD_INSERT_LIBRARIES|DYLD_LIBRARY_PATH| \ + PERL5LIB|PERL5OPT|PYTHONPATH|PYTHONHOME|NODE_OPTIONS|NODE_PATH|RUBYOPT|RUBYLIB| \ + GCONV_PATH|LOCPATH|GIT_SSH|GIT_SSH_COMMAND) + return 1 ;; + esac +} + positive_number() { local n=${1-} local LC_ALL=C @@ -153,8 +217,8 @@ action_executable() { # : print the executable's absolute path cmd_arm() { local name=${1-} sid interval=60 stable=2 deadline=604800 - local condition_timeout=60 action_timeout=1800 error_budget=3 - local -a cond=() act=() + local condition_timeout=60 action_timeout=1800 error_budget=3 repeat=0 + local -a cond=() act=() env_assignments=() [ -n "$name" ] || usage shift when_name_valid "$name" || die "name must be path-safe and at most 59 characters: $name" @@ -167,6 +231,8 @@ cmd_arm() { --condition-timeout) positive_int "${2-}" || die "--condition-timeout needs a positive integer of seconds"; condition_timeout=$2; shift 2 ;; --action-timeout) positive_int "${2-}" || die "--action-timeout needs a positive integer of seconds"; action_timeout=$2; shift 2 ;; --error-budget) positive_int "${2-}" || die "--error-budget needs a positive integer"; error_budget=$2; shift 2 ;; + --action-env) env_assignment_valid "${2-}" || die "--action-env needs NAME=VALUE with a shell-safe NAME: ${2-}"; env_assignments+=("$2"); shift 2 ;; + --repeat) repeat=1; shift ;; --condition) shift while [ "$#" -gt 0 ] && [ "$1" != --action ]; do cond+=("$1"); shift; done @@ -181,7 +247,7 @@ cmd_arm() { [ "${#cond[@]}" -ge 1 ] || die "arm needs at least one --condition argv element" [ "${#act[@]}" -ge 1 ] || die "arm needs at least one --action argv element" local arg - for arg in "${cond[@]}" "${act[@]}"; do + for arg in "${cond[@]}" "${act[@]}" ${env_assignments[@]+"${env_assignments[@]}"}; do case "$arg" in *$'\n'*) die "argv elements cannot contain newlines" ;; esac done @@ -190,7 +256,7 @@ cmd_arm() { trap 'fm_procevent_source_lock_release "$sid"' EXIT local leftover for leftover in "$(spec_file "$sid")" "$(trust_file "$sid")" "$(fired_file "$sid")" \ - "$(fm_procevent_registry_dir "$STATE")/$sid.source"; do + "$(fires_file "$sid")" "$(fm_procevent_registry_dir "$STATE")/$sid.source"; do if [ -e "$leftover" ] || [ -L "$leftover" ]; then die "watch already exists or left state behind: $leftover (retire it first)" fi @@ -216,12 +282,15 @@ cmd_arm() { printf 'condition_timeout=%s\n' "$condition_timeout" printf 'action_timeout=%s\n' "$action_timeout" printf 'error_budget=%s\n' "$error_budget" + printf 'repeat=%s\n' "$repeat" printf 'action_sha256=%s\n' "$action_hash" printf 'condition_argc=%s\n' "${#cond[@]}" printf 'action_argc=%s\n' "${#act[@]}" + printf 'env_argc=%s\n' "${#env_assignments[@]}" printf 'argv:\n' printf '%s\n' "${cond[@]}" printf '%s\n' "${act[@]}" + [ "${#env_assignments[@]}" -eq 0 ] || printf '%s\n' "${env_assignments[@]}" } > "$tmp" || { rm -f -- "$tmp"; die "cannot write the spec"; } chmod 0600 "$tmp" || { rm -f -- "$tmp"; die "cannot secure the spec"; } hash=$(fm_pr_sha256 "$tmp") || { rm -f -- "$tmp"; die "cannot hash the spec"; } @@ -251,13 +320,16 @@ cmd_arm() { # --- spec load --------------------------------------------------------------- # spec_load : validate the trust binding, then parse the spec into -# SPEC_* variables plus COND_ARGV and ACT_ARGV. Any structural or trust failure -# returns 1 with a reason in SPEC_ERROR; nothing from the spec is executed. +# SPEC_* variables plus COND_ARGV, ACT_ARGV, and ENV_ARGV. Any structural or +# trust failure returns 1 with a reason in SPEC_ERROR; nothing from the spec is +# executed. `repeat` and `env_argc` are optional so a spec armed before those +# fields existed still loads, as a one-shot watch with no action environment. spec_load() { local sid=$1 spec trust device hash want version line key value extra SPEC_ERROR= COND_ARGV=() ACT_ARGV=() + ENV_ARGV=() spec=$(spec_file "$sid") trust=$(trust_file "$sid") [ -d "$WHEN_DIR" ] && [ ! -L "$WHEN_DIR" ] || { SPEC_ERROR="watch directory is unavailable"; return 1; } @@ -275,8 +347,8 @@ spec_load() { SPEC_ARMED='' SPEC_INTERVAL='' SPEC_STABLE='' SPEC_DEADLINE='' SPEC_CONDITION_TIMEOUT='' SPEC_ACTION_TIMEOUT='' SPEC_ERROR_BUDGET='' - SPEC_ACTION_SHA256='' - local cond_argc='' act_argc='' in_argv=0 read_cond=0 read_act=0 + SPEC_ACTION_SHA256='' SPEC_REPEAT=0 + local cond_argc='' act_argc='' env_argc=0 in_argv=0 read_cond=0 read_act=0 read_env=0 { IFS= read -r version || { SPEC_ERROR="spec is empty"; return 1; } [ "$version" = fm-when-spec-v1 ] || { SPEC_ERROR="spec has an unknown version"; return 1; } @@ -293,9 +365,11 @@ spec_load() { condition_timeout) SPEC_CONDITION_TIMEOUT=$value ;; action_timeout) SPEC_ACTION_TIMEOUT=$value ;; error_budget) SPEC_ERROR_BUDGET=$value ;; + repeat) SPEC_REPEAT=$value ;; action_sha256) SPEC_ACTION_SHA256=$value ;; condition_argc) cond_argc=$value ;; action_argc) act_argc=$value ;; + env_argc) env_argc=$value ;; *) SPEC_ERROR="spec carries an unknown field: $key"; return 1 ;; esac elif [ "$read_cond" -lt "${cond_argc:-0}" ]; then @@ -304,6 +378,9 @@ spec_load() { elif [ "$read_act" -lt "${act_argc:-0}" ]; then ACT_ARGV+=("$line") read_act=$((read_act + 1)) + elif [ "$read_env" -lt "${env_argc:-0}" ]; then + ENV_ARGV+=("$line") + read_env=$((read_env + 1)) else SPEC_ERROR="spec carries trailing content" return 1 @@ -318,11 +395,19 @@ spec_load() { positive_int "$SPEC_CONDITION_TIMEOUT" || { SPEC_ERROR="spec condition timeout is malformed"; return 1; } positive_int "$SPEC_ACTION_TIMEOUT" || { SPEC_ERROR="spec action timeout is malformed"; return 1; } positive_int "$SPEC_ERROR_BUDGET" || { SPEC_ERROR="spec error budget is malformed"; return 1; } + case "$SPEC_REPEAT" in 0|1) ;; *) SPEC_ERROR="spec repeat flag is malformed"; return 1 ;; esac [[ "$SPEC_ACTION_SHA256" =~ ^[0-9a-f]{64}$ ]] \ || { SPEC_ERROR="spec action hash is malformed"; return 1; } positive_int "${cond_argc:-}" || { SPEC_ERROR="spec condition argc is malformed"; return 1; } positive_int "${act_argc:-}" || { SPEC_ERROR="spec action argc is malformed"; return 1; } + case "${env_argc:-}" in ''|*[!0-9]*) SPEC_ERROR="spec action environment argc is malformed"; return 1 ;; esac + local assignment + for assignment in ${ENV_ARGV[@]+"${ENV_ARGV[@]}"}; do + env_assignment_valid "$assignment" \ + || { SPEC_ERROR="spec action environment is malformed"; return 1; } + done [ "$read_cond" -eq "$cond_argc" ] && [ "$read_act" -eq "$act_argc" ] \ + && [ "$read_env" -eq "$env_argc" ] \ || { SPEC_ERROR="spec argv is incomplete"; return 1; } } @@ -341,6 +426,9 @@ bounded_run() { # emit_doc # The single stdout writer of `run`: everything the generic runner captures. +# EMIT_REPEAT carries the one field only a repeat watch's successful fire sets, +# because that is what makes an outcome silent and non-terminal. +EMIT_REPEAT= emit_doc() { local sid=$1 status=$2 detail=$3 polls=$4 action_exit=$5 outfile=$6 printf 'when: %s\n' "$sid" @@ -348,14 +436,46 @@ emit_doc() { printf 'detail: %s\n' "$detail" printf 'condition_polls: %s\n' "$polls" [ -z "$action_exit" ] || printf 'action_exit: %s\n' "$action_exit" + [ -z "$EMIT_REPEAT" ] || printf 'repeat: %s\n' "$EMIT_REPEAT" printf 'output:\n' if [ -n "$outfile" ] && [ -f "$outfile" ]; then tail -c "$OUTPUT_TAIL_BYTES" "$outfile" 2>/dev/null || true fi } +# The epoch the deadline is measured from. For a repeat watch that is its last +# recorded fire, so the deadline means "the condition stopped changing" rather +# than "the watch got old"; with no fire yet it is the arming epoch, exactly as +# a one-shot watch always measured it. +deadline_base() { # + local last + if [ "$SPEC_REPEAT" = 1 ]; then + last=$(tail -n 1 "$(fires_file "$1")" 2>/dev/null | cut -d' ' -f1) || last= + case "$last" in ''|*[!0-9]*) ;; *) printf '%s\n' "$last"; return 0 ;; esac + fi + printf '%s\n' "$SPEC_ARMED" +} + +# One line per successful repeat fire, kept bounded. A failed append costs the +# handler evidence and the deadline its base, never the fire itself, so it is +# reported through the outcome document rather than raised here. +journal_fire() { # + local journal tmp + journal=$(fires_file "$1") + printf '%s fired polls=%s\n' "$2" "$3" >> "$journal" 2>/dev/null || return 1 + chmod 0600 "$journal" 2>/dev/null || true + [ "$(grep -c . "$journal" 2>/dev/null || echo 0)" -gt "$FIRES_JOURNAL_LINES" ] || return 0 + tmp=$(umask 077; mktemp "$WHEN_DIR/.fires.XXXXXX") || return 0 + if tail -n "$FIRES_JOURNAL_LINES" "$journal" > "$tmp" 2>/dev/null; then + mv -f -- "$tmp" "$journal" 2>/dev/null || rm -f -- "$tmp" + else + rm -f -- "$tmp" + fi + return 0 +} + cmd_run() { - local sid=${1-} fired out rc polls=0 consecutive_true=0 consecutive_err=0 now + local sid=${1-} fired out rc polls=0 consecutive_true=0 consecutive_err=0 now base base_label fm_procevent_source_id_valid "$sid" || die "source id must be path-safe: $sid" fired=$(fired_file "$sid") @@ -363,6 +483,10 @@ cmd_run() { emit_doc "$sid" rejected "FM_WHEN_OUTPUT_TAIL_BYTES must be a positive integer; nothing was executed" 0 '' '' exit 0 fi + if ! positive_int "$FIRES_JOURNAL_LINES"; then + emit_doc "$sid" rejected "FM_WHEN_FIRES_JOURNAL_LINES must be a positive integer; nothing was executed" 0 '' '' + exit 0 + fi if ! spec_load "$sid"; then emit_doc "$sid" rejected "refused without executing anything: $SPEC_ERROR" 0 '' '' @@ -370,12 +494,21 @@ cmd_run() { fi # A fired marker with this runner not mid-action means an earlier run claimed - # the fire and died before its outcome was durably captured. Never run the - # action again; report the ambiguity for manual verification instead. + # the fire and died before its outcome was durably captured. For a one-shot + # watch that is unrecoverable: never run the action again, and report the + # ambiguity for manual verification instead. A repeat watch's action is + # already required to be safe to run again - that is what repeating means - + # so there the marker is simply released and the watch resumes, which is also + # what makes a fire whose capture was lost cost one extra ring rather than + # ending the watch. if [ -e "$fired" ] || [ -L "$fired" ]; then - emit_doc "$sid" ambiguous \ - "the action was already claimed but its outcome was never captured; verify its effect manually before retiring" 0 '' '' - exit 0 + if [ "$SPEC_REPEAT" = 1 ]; then + rm -f -- "$fired" + else + emit_doc "$sid" ambiguous \ + "the action was already claimed but its outcome was never captured; verify its effect manually before retiring" 0 '' '' + exit 0 + fi fi if ! out=$(umask 077; mktemp "$WHEN_DIR/.run-out.XXXXXX"); then @@ -384,20 +517,23 @@ cmd_run() { fi trap 'fm_procevent_source_lock_release "$sid"; rm -f -- "$out"' EXIT + base=$(deadline_base "$sid") + if [ "$base" = "$SPEC_ARMED" ]; then base_label=arming; else base_label="its last fire"; fi + while :; do now=$(date +%s) - if [ $(( now - SPEC_ARMED )) -ge "$SPEC_DEADLINE" ]; then + if [ $(( now - base )) -ge "$SPEC_DEADLINE" ]; then emit_doc "$sid" never-true \ - "the condition never held for $SPEC_STABLE consecutive polls within ${SPEC_DEADLINE}s of arming" "$polls" '' '' + "the condition did not hold for $SPEC_STABLE consecutive polls within ${SPEC_DEADLINE}s of $base_label" "$polls" '' '' exit 0 fi bounded_run "$SPEC_CONDITION_TIMEOUT" "$out" "${COND_ARGV[@]}" rc=$? polls=$((polls + 1)) now=$(date +%s) - if [ $(( now - SPEC_ARMED )) -ge "$SPEC_DEADLINE" ]; then + if [ $(( now - base )) -ge "$SPEC_DEADLINE" ]; then emit_doc "$sid" never-true \ - "the condition never held for $SPEC_STABLE consecutive polls within ${SPEC_DEADLINE}s of arming" "$polls" '' "$out" + "the condition did not hold for $SPEC_STABLE consecutive polls within ${SPEC_DEADLINE}s of $base_label" "$polls" '' "$out" exit 0 fi case "$rc" in @@ -424,9 +560,9 @@ cmd_run() { done now=$(date +%s) - if [ $(( now - SPEC_ARMED )) -ge "$SPEC_DEADLINE" ]; then + if [ $(( now - base )) -ge "$SPEC_DEADLINE" ]; then emit_doc "$sid" never-true \ - "the condition never held for $SPEC_STABLE consecutive polls within ${SPEC_DEADLINE}s of arming" "$polls" '' "$out" + "the condition did not hold for $SPEC_STABLE consecutive polls within ${SPEC_DEADLINE}s of $base_label" "$polls" '' "$out" exit 0 fi @@ -462,13 +598,36 @@ cmd_run() { exit 0 fi - bounded_run "$SPEC_ACTION_TIMEOUT" "$out" "${ACT_ARGV[@]}" - rc=$? - if [ "$rc" -eq 0 ]; then - emit_doc "$sid" fired "the condition held and the action exited 0" "$polls" "$rc" "$out" + # The assignments come from the hash-bound spec and ACT_ARGV[0] is the + # absolute path resolved at arming, so `env` cannot mistake the action for an + # option or an assignment, and the executable whose bytes were just + # revalidated is still the one that runs. + if [ "${#ENV_ARGV[@]}" -eq 0 ]; then + bounded_run "$SPEC_ACTION_TIMEOUT" "$out" "${ACT_ARGV[@]}" else + bounded_run "$SPEC_ACTION_TIMEOUT" "$out" env "${ENV_ARGV[@]}" "${ACT_ARGV[@]}" + fi + rc=$? + if [ "$rc" -ne 0 ]; then emit_doc "$sid" action-failed "the condition held but the action exited $rc" "$polls" "$rc" "$out" + exit 0 fi + if [ "$SPEC_REPEAT" = 1 ]; then + # Journal first, then release the claim: a crash between the two costs one + # duplicate ring on restart, while the reverse order could lose the fire's + # only record and reset the deadline base. + if journal_fire "$sid" "$(date +%s)" "$polls"; then + EMIT_REPEAT=continues + rm -f -- "$fired" + emit_doc "$sid" fired "the condition held and the action exited 0; the watch continues" "$polls" "$rc" "$out" + else + emit_doc "$sid" fired \ + "the condition held and the action exited 0, but the fire journal could not be written; the watch stops here" \ + "$polls" "$rc" "$out" + fi + exit 0 + fi + emit_doc "$sid" fired "the condition held and the action exited 0" "$polls" "$rc" "$out" exit 0 } @@ -483,6 +642,20 @@ result_status() { # ' "$1" } +# True when the document is a repeat watch's successful fire: the one outcome +# that is neither terminal nor worth a wake. Read from the same leading block as +# the status, so captured command output can never forge it. +result_repeat_continues() { # + local status repeat + status=$(result_status "$1") + [ "$status" = fired ] || return 1 + repeat=$(awk ' + $0 == "output:" { exit } + /^repeat: / { sub(/^repeat: /, ""); print; exit } + ' "$1") + [ "$repeat" = continues ] +} + cmd_classify() { local file=${1-} status [ -n "$file" ] || usage @@ -499,7 +672,15 @@ cmd_terminal() { local file=${1-} [ -n "$file" ] || usage [ -f "$file" ] || die "result file does not exist: $file" - [ "$(cmd_classify "$file")" != unknown ] + [ "$(cmd_classify "$file")" != unknown ] || return 1 + ! result_repeat_continues "$file" +} + +cmd_silent() { + local file=${1-} + [ -n "$file" ] || usage + [ -f "$file" ] || die "result file does not exist: $file" + result_repeat_continues "$file" } # --- rebind-all --------------------------------------------------------------- @@ -621,7 +802,7 @@ cmd_retire() { fi fi "$SCRIPT_DIR/fm-procevent.sh" retire "$sid" || die "cannot retire the watch source: $sid" - rm -f -- "$(spec_file "$sid")" "$(trust_file "$sid")" "$(fired_file "$sid")" + rm -f -- "$(spec_file "$sid")" "$(trust_file "$sid")" "$(fired_file "$sid")" "$(fires_file "$sid")" printf 'retired: %s\n' "$sid" } @@ -630,6 +811,7 @@ case "${1-}" in run) shift; [ "$#" -eq 1 ] || usage; cmd_run "$@" ;; classify) shift; cmd_classify "$@" ;; terminal) shift; cmd_terminal "$@" ;; + silent) shift; cmd_silent "$@" ;; source-id) shift; cmd_source_id "$@" ;; retire) shift; cmd_retire "$@" ;; rebind-all) shift; cmd_rebind_all "$@" ;; diff --git a/docs/configuration.md b/docs/configuration.md index cd558c3c424..c3ede1f576d 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -912,10 +912,16 @@ If the hosting worker cannot be recovered, relaunch a worker to re-host first; g The cross-home gap between worker rounds remains an accepted residual until lavish-axi's exclusive listener lands. The interim crew instruction emitted by `bin/fm-brief.sh` points workers at this arm-and-acknowledge contract. -The `when` adapter (`bin/fm-procevent-when.sh`) turns this channel into a condition->action primitive: it registers a deterministic condition and a deterministic action once, its blocking child polls the condition without waking firstmate, and a stable true fires the action at most once before one terminal outcome is durably captured and published as a wake that remains eligible for re-announcement until handled. +The `when` adapter (`bin/fm-procevent-when.sh`) turns this channel into a condition->action primitive: it registers a deterministic condition and a deterministic action once, its blocking child polls the condition without waking firstmate, and a stable true fires the action before one terminal outcome is durably captured and published as a wake that remains eligible for re-announcement until handled. The (condition, action) spec is stored privately under `state/when/` and hash-bound by a trust record the same way `bin/fm-check-register.sh` binds a custom check, while the spec separately binds the resolved action executable's bytes; a mutated or unregistered spec or a changed action executable is refused before the action runs, and that binding is reloaded from disk immediately before each fire rather than trusted from when polling started. A repo update that fast-forwards an in-repo action's bytes in place would otherwise desync every already-armed watch's trust binding with no tampering involved; `bin/fm-procevent-when.sh rebind-all` re-hashes and republishes the binding for every registered watch whose action lives under `FM_ROOT`, including one already polling, so it keeps firing across such an update instead of being refused on its next fire. -Every failure path - a mutated spec or action executable, a condition error past its budget, an expired deadline, a failed action, or an earlier fire whose outcome was never captured - produces a terminal captured outcome that wakes firstmate rather than a silent retry, and a durable single-fire marker claimed before the action makes restarts and re-polls unable to fire it twice. +An action that needs environment to work at all is armed with hash-bound `NAME=VALUE` assignments recorded in that same spec, and the action executable stays argv[0], so binding its bytes is unaffected. +A watch armed one-shot fires at most once and every one of its outcomes is terminal. +A watch armed to repeat answers "ring X every time Y changes" instead: its successful fire is the single non-terminal outcome, and it is also the single silent one, so the runner records it handled without a wake and restarts the poll rather than retiring the source. +That fire is journalled under `state/when/`, and a repeat watch's deadline is measured from its last fire rather than from arming, so the deadline means the condition stopped changing instead of the watch getting old. +A repeat action must therefore be safe to run again, which is the standard the one-shot action already had to meet. +Every failure path - a mutated spec or action executable, a condition error past its budget, an expired deadline, a failed action, or, for a one-shot watch, an earlier fire whose outcome was never captured - produces a terminal captured outcome that wakes firstmate rather than a silent retry, in both modes. +A durable single-fire marker claimed before the action makes restarts and re-polls unable to fire it twice; a repeat watch releases that marker only once its fire has been emitted, so a lost capture costs one extra ring instead of ending the watch. The adapter automates only the exact deterministic subset: anything needing judgment, and anything destructive, irreversible, or security-sensitive, keeps the ordinary check-fires-then-firstmate-decides flow, and the adapter's header and `--help` own its commands, flags, and outcome document. This section is the single owner of the runner's operating contract. @@ -939,6 +945,8 @@ For built-ins, silence remains independent of the keyed-answer feed below: suppr For Lavish that verdict covers two shapes - a session the adapter classifies `ended` that carries no queued content block at all, which is a review surface closed with nothing said, and `browser_disconnected` (classified `disconnected`), which carries no answer while the session remains open. Any recognized top-level `prompts` or `feedback` block counts as content regardless of its declared count, and a malformed header makes the result indeterminate rather than empty. A `Send & End` close carrying the captain's answer arrives as `status: feedback` with `session_ended`, so it classifies `feedback` and is announced unchanged, as is any `ended` result that still carries content, and every `waiting`, `missing`, `unknown`, or unreadable result. +For `when` that verdict covers exactly one shape too - a repeat watch's successful fire, whose action has already rung its target, so announcing it would only spend a turn on news the target already has. +Every other `when` outcome, in both modes, is announced unchanged. Whether a captured result ends its source is adapter knowledge, never the runner's. After capture - and after initial `check` publication for the default ordering - the runner asks the immutable captured owner through the built-in `terminal` command or external `result.terminal` operation and retires the registration on exit 0 alone - except a task-owned board, whose terminal retirement is refused until its owner acknowledges the round, as the crew-hosted section above defines - dropping only the exact registration generation captured by its claim and releasing that claim only after removal succeeds under one source boundary; a missing command, an error, or any other exit keeps the source armed, so an adapter with no notion of ending needs no change. @@ -947,6 +955,7 @@ Any registration refuses to replace an external registration while its prior run A source that has ended therefore captures at most one terminal result, is never restarted, and leaves no recurring poll work. For ordinary sources, explicit `retire` stays the supported and idempotent path afterwards; a task-owned board instead refuses `retire` until its owner concludes the open terminal round with `handled`. For Lavish that verdict covers an ended session, a missing session, and the final feedback of a `Send & End` review, which the published poll marks with `session_ended` before it returns only empty ended sessions. +For `when` it covers every outcome of a one-shot watch, and every outcome of a repeat watch except its successful fire, which is what keeps a repeat watch registered and restarted until it fails or is retired. Applying a captured result through code is a built-in adapter seam, and some built-in results carry no judgement at all: they must simply be applied idempotently to this home's own durable state. Leaving that to a handler means it can silently not happen, so immediately after the terminal check above the runner calls `bin/fm-procevent-.sh autohandle ` and lets the built-in adapter apply and acknowledge its own result. diff --git a/docs/scripts.md b/docs/scripts.md index 6a5bd8d77b6..2e93c14e9f0 100644 --- a/docs/scripts.md +++ b/docs/scripts.md @@ -81,7 +81,7 @@ The shared no-mistakes gate refusal for fleet lifecycle entrypoints is summarize | `fm-procevent.sh` | Register, supervise, capture, classify, acknowledge, and safely retire built-in or explicitly bound process-event sources | | `fm-procevent-remote-reply.sh` | Relay the remote-secondmate status stream through non-destructive process-event deltas | | `fm-procevent-quota.sh` | Wake Firstmate when tracked quota drops below a threshold, is exhausted, or cannot be polled | -| `fm-procevent-when.sh` | Fire a trust-bound deterministic action at most once when its registered condition holds, then wake with the outcome | +| `fm-procevent-when.sh` | Fire a trust-bound deterministic action when its registered condition holds - once, or on every change under `--repeat` - then wake with the outcome | | `fm-gate-refuse-lib.sh` | Shared no-mistakes gate-context refusal for fleet lifecycle entrypoints | | `fm-watch-arm.sh` | Verified home-scoped watcher arm wrapper with loud cycle endings and bounded lifecycle ledger | | `fm-watch-checkpoint.sh` | Run one bounded foreground watcher checkpoint for Codex-style supervision | diff --git a/docs/verification/process-event-sources.md b/docs/verification/process-event-sources.md index 8abe4a71a06..32edc14d30e 100644 --- a/docs/verification/process-event-sources.md +++ b/docs/verification/process-event-sources.md @@ -145,6 +145,8 @@ Exercised by `tests/fm-procevent.test.sh` against a fake blocking source whose c | condition->action single-fire and trust | `tests/fm-procevent-when.test.sh` drives the public `when` adapter and generic runner with real commands, proving stable true fires once, a claimed fire restarts as ambiguous without a second action, concurrent arms publish one complete watch, and mutated specs or action executables are refused before execution | | condition->action terminal outcomes | the same suite proves flapping true polls do not fire, action failure, condition error budget, deadline expiry, and a true poll completing after its deadline each produce the expected terminal captured result without an unsafe action | | condition->action process bounds | the same suite proves action timeout terminates descendants and command-output staging remains within `FM_WHEN_OUTPUT_TAIL_BYTES` while the command runs | +| condition->action action environment | the same suite proves a registered `--action-env` assignment reaches the action, an assignment with a non-identifier NAME is refused before arming, and a shell-safe NAME that could instead hijack an interpreter or loader (`LD_PRELOAD`, `PATH`, and the same short denylist) is refused too, naming the offending option in every refusal | +| condition->action repeat mode | the same suite proves a `--repeat` watch's successful fire is classified `fired` yet is neither terminal nor announced, is journalled and self-acknowledged as handled with no wake reaching the durable queue, leaves the watch registered and re-armed so a second real condition change rings the action again, stops ringing once retired, and still produces a normal terminal wake when its action fails | | silent failure handling | a nonzero exit with no output publishes nothing and leaves the source registered for retry | | inertness | a home with no registered source generates no state, starts no process, and does not need supervision | | rebind-all refreshes an in-repo trust binding, leaves an out-of-repo one alone | after a simulated self-update rewrites an armed watch's in-repo action executable's bytes, `rebind-all` republishes exactly that watch's trust binding against the new bytes and leaves a watch whose action lives outside `FM_ROOT` untouched byte-for-byte; the rebound watch then fires cleanly against the new bytes instead of being refused, and a second `rebind-all` with nothing changed rebinds nothing (`tests/fm-procevent-when.test.sh`) | diff --git a/tests/fm-procevent-when.test.sh b/tests/fm-procevent-when.test.sh index 36ddc955f78..a5cee162820 100755 --- a/tests/fm-procevent-when.test.sh +++ b/tests/fm-procevent-when.test.sh @@ -646,4 +646,147 @@ assert_grep 'status: fired' "$RESULT" \ assert_grep 'v2 ran against' "$RESULT" "the fired action ran the rebound (v2) bytes, not a rejection from a torn read" pass "the fire-time reload never observes rebind_one's spec/trust publish mid-rename" +# --- an action environment reaches the action, and its NAME is validated ------ +H="$TMP_ROOT/h-action-env"; new_home "$H" +ENVLOG="$TMP_ROOT/action-env-act" +# An action that records the one variable the assignment carries, so "the +# environment reached the action" is observable rather than inferred. +ENVACT="$TMP_ROOT/env-act.sh" +cat > "$ENVACT" <<'SH' +#!/usr/bin/env bash +printf 'FM_TEST_HOME=%s\n' "${FM_TEST_HOME:-unset}" >> "$1" +SH +chmod +x "$ENVACT" +if when "$H" arm bad-env --action-env 'not a name=x' \ + --condition true --action "$ENVACT" "$ENVLOG" 2>"$TMP_ROOT/bad-env.err"; then + fail "an assignment with an invalid NAME must be refused" +fi +assert_grep 'action-env' "$TMP_ROOT/bad-env.err" "the refusal names the offending option" +# A shell-safe NAME is not enough: an interpreter/loader-hijacking name must be +# refused too, or the argv[0] trust binding is worthless for any action that is +# itself a `#!/usr/bin/env`-shebang script. +if when "$H" arm hijack-env --action-env 'LD_PRELOAD=/tmp/evil.so' \ + --condition true --action "$ENVACT" "$ENVLOG" 2>"$TMP_ROOT/hijack-env.err"; then + fail "an interpreter/loader-hijacking NAME must be refused" +fi +assert_grep 'action-env' "$TMP_ROOT/hijack-env.err" "the LD_PRELOAD refusal names the offending option" +assert_absent "$H/state/when/when-hijack-env.spec" "a refused LD_PRELOAD assignment is never armed" +if when "$H" arm hijack-path --action-env 'PATH=/tmp/evil-bin' \ + --condition true --action "$ENVACT" "$ENVLOG" 2>"$TMP_ROOT/hijack-path.err"; then + fail "a PATH action-env assignment must be refused" +fi +assert_grep 'action-env' "$TMP_ROOT/hijack-path.err" "the PATH refusal names the offending option" +when "$H" arm action-env --interval 0.1 --stable 1 \ + --action-env "FM_TEST_HOME=$H" \ + --condition true --action "$ENVACT" "$ENVLOG" >/dev/null +pe "$H" reconcile >/dev/null +wait_for_result "$H" when-action-env || fail "the action-env watch produced no outcome" +assert_grep "FM_TEST_HOME=$H" "$ENVLOG" "the action ran under the registered assignment" +RESULT=$(first_result "$H" when-action-env) +assert_grep 'status: fired' "$RESULT" "an action with an environment still fires normally" +pass "an action environment reaches the action and an invalid NAME is refused" + +# --- a repeat watch rings again after each fire, silently -------------------- +# The condition is true exactly while a trigger file exists, so the test drives +# the watch through two independent "changes" and can prove the second ring is +# a real re-arm rather than a leftover from the first. +H="$TMP_ROOT/h-repeat"; new_home "$H" +REPEAT_TRIG="$TMP_ROOT/repeat-trigger" +REPEATLOG="$TMP_ROOT/repeat-act" +when "$H" arm repeat --interval 0.1 --stable 1 --repeat \ + --condition "$COND" "$REPEAT_TRIG" "$TMP_ROOT/repeat-count" \ + --action "$ACT" "$REPEATLOG" >/dev/null +pe "$H" reconcile >/dev/null +wait_for_file "$TMP_ROOT/repeat-count" || fail "the repeat condition was never polled" +: > "$REPEAT_TRIG" +wait_for_result "$H" when-repeat || fail "the repeat watch captured no first outcome" +RESULT=$(first_result "$H" when-repeat) +assert_grep 'status: fired' "$RESULT" "the first repeat fire is recorded as fired" +assert_grep 'repeat: continues' "$RESULT" "the first repeat fire declares that the watch continues" +assert_contains "$(when "$H" classify "$RESULT")" fired "classify still reads a repeat fire as fired" +if when "$H" terminal "$RESULT"; then + fail "a repeat watch's successful fire must not be terminal" +fi +when "$H" silent "$RESULT" || fail "a repeat watch's successful fire must be silent" +assert_present "$H/state/when/when-repeat.fires" "the fire journal records the fire" +# The registration survives, because only a terminal outcome retires a source. +assert_present "$H/state/procevent/when-repeat.source" "a repeat fire keeps the watch registered" +# Nothing woke firstmate, and the runner acknowledged the outcome itself, so a +# later reconcile cannot re-announce it either. +assert_not_contains "$(wake_payloads "$H")" "procevent when when-repeat" \ + "a successful repeat fire never reaches the durable wake queue" +SEQ=$(basename "$RESULT" | sed 's/^when-repeat\.//; s/\.result$//') +assert_contains "$(pe "$H" handled when-repeat "$SEQ")" "already-handled" \ + "the runner recorded the silent repeat fire as handled itself" +# A second change must ring again, which only a re-armed watch can do. +rm -f -- "$REPEAT_TRIG" +for _ in $(seq 1 150); do + [ "$(count_lines "$REPEATLOG")" -ge 1 ] && break + sleep 0.1 +done +: > "$REPEAT_TRIG" +for _ in $(seq 1 150); do + [ "$(count_lines "$REPEATLOG")" -ge 2 ] && break + pe "$H" reconcile >/dev/null 2>&1 + sleep 0.1 +done +[ "$(count_lines "$REPEATLOG")" -ge 2 ] || fail "the repeat watch never rang a second time" +assert_not_contains "$(wake_payloads "$H")" "procevent when when-repeat" \ + "no repeat fire wakes firstmate" +pass "a repeat watch rings again after each fire without waking firstmate" + +# --- retire stops a repeat watch --------------------------------------------- +STOPPED=$(count_lines "$REPEATLOG") +when "$H" retire repeat >/dev/null +assert_absent "$H/state/procevent/when-repeat.source" "retire drops the repeat registration" +assert_absent "$H/state/when/when-repeat.fires" "retire removes the fire journal" +pe "$H" reconcile >/dev/null +sleep 0.6 +assert_contains "$(count_lines "$REPEATLOG")" "$STOPPED" "a retired repeat watch never rings again" +pass "retire stops a repeat watch" + +# --- a failing action still ends a repeat watch and wakes firstmate ---------- +H="$TMP_ROOT/h-repeat-fail"; new_home "$H" +REPEATFAILLOG="$TMP_ROOT/repeat-fail-act" +when "$H" arm repeat-fail --interval 0.1 --stable 1 --repeat \ + --condition true --action "$ACT" "$REPEATFAILLOG" 9 >/dev/null +pe "$H" reconcile >/dev/null +wait_for_result "$H" when-repeat-fail || fail "the failing repeat action captured no outcome" +RESULT=$(first_result "$H" when-repeat-fail) +assert_grep 'status: action-failed' "$RESULT" "the failure is captured under repeat too" +assert_grep 'action_exit: 9' "$RESULT" "the exact exit code survives" +when "$H" terminal "$RESULT" || fail "a failed action must stay terminal under repeat" +if when "$H" silent "$RESULT"; then + fail "a failed action must never be silenced" +fi +for _ in $(seq 1 100); do + [ ! -e "$H/state/procevent/when-repeat-fail.source" ] && break + sleep 0.1 +done +assert_absent "$H/state/procevent/when-repeat-fail.source" "a failed repeat watch retires itself" +assert_contains "$(wake_payloads "$H")" "procevent when when-repeat-fail" \ + "the failure reaches the durable wake queue" +pass "a failing action ends a repeat watch and wakes firstmate" + +# --- the shape fm-spawn arms: a repeat watch whose action rings a task ------- +# fm-spawn arms this watch for every no-mistakes ship, and fm-send refuses to +# resolve a target without an explicit FM_HOME, so the assignment is what makes +# the ring land at all. This exercises that exact shape end to end rather than +# reading fm-spawn's source. +H="$TMP_ROOT/h-spawn-shape"; new_home "$H" +RING_TASK=ring-task +printf 'window=none\nbackend=tmux\n' > "$H/state/$RING_TASK.meta" +when "$H" arm "nm-state-$RING_TASK" --interval 0.1 --stable 1 --repeat \ + --action-env "FM_HOME=$H" \ + --condition true \ + --action "$ROOT/bin/fm-send.sh" "$RING_TASK" 'no-mistakes state changed' >/dev/null +pe "$H" reconcile >/dev/null +wait_for_result "$H" "when-nm-state-$RING_TASK" || fail "the spawn-shaped watch captured no outcome" +RESULT=$(first_result "$H" "when-nm-state-$RING_TASK") +assert_grep 'status: fired' "$RESULT" "the ring succeeded, so FM_HOME reached fm-send" +assert_grep 'repeat: continues' "$RESULT" "the spawn-shaped watch keeps watching" +assert_present "$H/state/$RING_TASK.inbox" "the ring landed in the task's steering inbox" +when "$H" retire "nm-state-$RING_TASK" >/dev/null +pass "the watch shape fm-spawn arms rings a task and keeps watching" + printf 'all fm-procevent-when tests passed\n' From 8204b9133c986bc4f3f77df6a3f24944967d374f Mon Sep 17 00:00:00 2001 From: NewAiCoder <170579485+NewAiCoder@users.noreply.github.com> Date: Tue, 8 Sep 2026 01:37:24 -0400 Subject: [PATCH 02/11] no-mistakes(document): Fix stale when-adapter docs: fire journal in AGENTS.md, silent-command claim in verification doc --- AGENTS.md | 2 +- docs/verification/process-event-sources.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 5df9383d4f6..733b6d18ff0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -134,7 +134,7 @@ state/ runtime records and signals; gitignored procevent-inbox/ private captured results and their durable handled-acknowledgement markers; source output lives here and never in an event line decision-bindings/ private records marking a captured-answer source as feeding the keyed-answer intake, with a legacy origin on pre-collapse records; written only by bin/fm-captain-hold.sh bind, dropped by unbind and by source retirement (section 13; docs/captain-hold-lifecycle.md) reconcile-requests/ private open obligations to re-check a captain call whose board selection was `reconcile`; written only by bin/fm-captain-hold.sh, retired by its verify-then-decide outcomes or a normal answer that settles the call (section 13; docs/captain-hold-lifecycle.md) - when/ private condition->action watch specs, their trust bindings, and single-fire markers; written only by bin/fm-procevent-when.sh (section 13's process-event-sources trigger) + when/ private condition->action watch specs, their trust bindings, single-fire markers, and a repeat watch's bounded fire journal; written only by bin/fm-procevent-when.sh (section 13's process-event-sources trigger) inbox/ captain notes captured out of band by bin/fm-inbox.sh, including the voice handover's queued requests; each note appends one `check` wake and stays pending until acknowledged with `bin/fm-inbox.sh drain --ack `, which moves it to inbox/handled/; request-id reservations, announcement markers, and primary replies live beside the notes (bin/fm-inbox.sh; docs/voice-relay.md) x-inbox/ generated Relay pending mention payloads; fmx-respond drains it (section 14) x-context/ generated Relay durable per-request reply context and one-wake offer markers, keyed by request_id; survives inbox cleanup and expires within seven days (section 14; bin/fm-x-lib.sh) diff --git a/docs/verification/process-event-sources.md b/docs/verification/process-event-sources.md index 32edc14d30e..b7dcab5ac95 100644 --- a/docs/verification/process-event-sources.md +++ b/docs/verification/process-event-sources.md @@ -105,7 +105,7 @@ Exercised by `tests/fm-procevent.test.sh` against a fake blocking source whose c | worker-owned Lavish rounds | one three-round fixture arms a board for an identity-matched task endpoint, delivers nonterminal and terminal captures directly to that task's steering inbox without a firstmate `check` wake, acknowledges each nonterminal round through a successful re-arm, redelivers an inbox note filed before acknowledgement, refuses a second armer and every early retirement, and concludes the terminal round through `handled` without another poll; focused fixtures also pin failed re-arm rollback, generation-specific reply staging, one reply post across transient poll retries, unreachable-owner refusal, interrupted conclusion recovery, and repeat acknowledgement isolation | | Lavish handled-status classification | an executable fixture table pins exact `feedback`, `ended`, `waiting`, and `browser_disconnected` mappings, including `browser_disconnected` to `disconnected`; the same suite proves that status is nonterminal and receives a zero-answer silence verdict | | session-derived Lavish routing | the three-round worker fixture starts its first listener under conflicting ambient host/port values and configuration, then recovers later listeners while that conflicting configuration remains, and proves every reply/poll uses the board's saved session endpoint; direct polls cover Unicode artifact paths, hostnames, IPv6, session endpoint changes, quiet retries, and refusal before reply consumption when session evidence is absent or invalid; spawn coverage still proves the configured opening address enters the worker launch | -| silence fails closed | the adapter's published `silent` command suppresses only an `ended` session with no queued content block or a `browser_disconnected` response, and announces a real answer, freeform prose, any recognized content block regardless of its declared count, a malformed top-level content header, a `waiting` or `missing` session, a server error, an unreadable result, and indented payload text imitating an empty content block; the `remote-reply` and `when` adapters, which implement no `silent` command, announce every result | +| silence fails closed | the adapter's published `silent` command suppresses only an `ended` session with no queued content block or a `browser_disconnected` response, and announces a real answer, freeform prose, any recognized content block regardless of its declared count, a malformed top-level content header, a `waiting` or `missing` session, a server error, an unreadable result, and indented payload text imitating an empty content block; the `remote-reply` adapter, which implements no `silent` command, announces every result, and the `when` adapter's own `silent` command (proven in "condition->action repeat mode" below) suppresses only a repeat watch's successful fire, announcing every other result | | terminal retirement preserves the result | the retired source's captured output, its announced event, its handled acknowledgement, and later explicit `retire` all still behave normally | | registration-generation retirement | an old terminal runner preserves a concurrently replaced registration and releases ownership so the replacement runs independently; injected registration-removal failure retains a terminal claim, performs no second poll, and completes idempotently once removal recovers; a live owner retiring its own terminal source mid-capture tolerates only its transient reservation-removal failure and still removes the registration under exact ownership | | one `Send & End`, one result | an armed Lavish source driven against a stand-in for the published poll, which delivers the final `session_ended` feedback once and empty ended sessions afterward, polls exactly once, captures exactly one result, publishes one distinct event, and retires itself | From 3e37b7cf37024d0923e1a3dca5072d42f62c7950 Mon Sep 17 00:00:00 2001 From: NewAiCoder <170579485+NewAiCoder@users.noreply.github.com> Date: Tue, 8 Sep 2026 02:07:28 -0400 Subject: [PATCH 03/11] no-mistakes(ci): Fixed the Greptile P1: in --repeat mode, bin/fm-procevent-when.sh cleared the fired marker on a successful fire and let the next reconcile's fresh `run` invocation fire again as soon as it saw a single true poll - even if the condition level had never actually gone false. That violates the documented "ring X every time Y changes" repeat semantics and causes duplicate action/task-notification rings on a condition that stays continuously true. Root-cause fix: added a small persisted per-source marker (`.needs-edge`) set whenever a repeat watch fires. A subsequent `run` invocation loads this marker and, while set, ignores true polls (treats them as non-counting) until it observes an actual false poll, at which point it clears the marker and resumes normal stable-true counting. The marker is cleaned up on retire and blocks re-arming under the same name if left behind, consistent with the existing `.fired`/`.fires` leftover checks. The existing "repeat watch rings again" test happened to remove-then-instantly-recreate the trigger file with no live poller ever actually running during the false window, so it was not proving a real edge; it now explicitly reconciles and waits during the false window so a poll genuinely observes it. Added a new regression test ("a repeat watch never refires on a level that never went false") that reproduces the exact reported bug - fails on the pre-fix code (verified by temporarily reverting the source fix) and passes after the fix, while also proving a genuine subsequent edge still re-arms the watch. Full suite (tests/fm-procevent-when.test.sh, 19 cases) passes; shellcheck is clean --- bin/fm-procevent-when.sh | 29 +++++++++++++++++++--- tests/fm-procevent-when.test.sh | 44 ++++++++++++++++++++++++++++++++- 2 files changed, 68 insertions(+), 5 deletions(-) diff --git a/bin/fm-procevent-when.sh b/bin/fm-procevent-when.sh index 9f8635ac424..1f738fc29cd 100755 --- a/bin/fm-procevent-when.sh +++ b/bin/fm-procevent-when.sh @@ -159,6 +159,7 @@ spec_file() { printf '%s/%s.spec\n' "$WHEN_DIR" "$1"; } trust_file() { printf '%s/%s.trust\n' "$WHEN_DIR" "$1"; } fired_file() { printf '%s/%s.fired\n' "$WHEN_DIR" "$1"; } fires_file() { printf '%s/%s.fires\n' "$WHEN_DIR" "$1"; } +edge_file() { printf '%s/%s.needs-edge\n' "$WHEN_DIR" "$1"; } # A repeat watch keeps only the most recent fires; the journal is evidence for # the handler, not an audit log, and it must not grow without bound on a source @@ -256,7 +257,7 @@ cmd_arm() { trap 'fm_procevent_source_lock_release "$sid"' EXIT local leftover for leftover in "$(spec_file "$sid")" "$(trust_file "$sid")" "$(fired_file "$sid")" \ - "$(fires_file "$sid")" "$(fm_procevent_registry_dir "$STATE")/$sid.source"; do + "$(fires_file "$sid")" "$(edge_file "$sid")" "$(fm_procevent_registry_dir "$STATE")/$sid.source"; do if [ -e "$leftover" ] || [ -L "$leftover" ]; then die "watch already exists or left state behind: $leftover (retire it first)" fi @@ -476,6 +477,7 @@ journal_fire() { # cmd_run() { local sid=${1-} fired out rc polls=0 consecutive_true=0 consecutive_err=0 now base base_label + local needs_edge=0 fm_procevent_source_id_valid "$sid" || die "source id must be path-safe: $sid" fired=$(fired_file "$sid") @@ -493,6 +495,12 @@ cmd_run() { exit 0 fi + # A repeat watch that already fired must see the condition go false before + # another stable-true run is allowed to fire again - otherwise a condition + # that never flaps would just refire on every reconcile of a level that + # never changed, which is not "ring X every time Y changes". + [ "$SPEC_REPEAT" = 1 ] && [ -e "$(edge_file "$sid")" ] && needs_edge=1 + # A fired marker with this runner not mid-action means an earlier run claimed # the fire and died before its outcome was durably captured. For a one-shot # watch that is unrecoverable: never run the action again, and report the @@ -538,13 +546,21 @@ cmd_run() { fi case "$rc" in 0) - consecutive_true=$((consecutive_true + 1)) consecutive_err=0 - [ "$consecutive_true" -ge "$SPEC_STABLE" ] && break + if [ "$needs_edge" = 1 ]; then + consecutive_true=0 + else + consecutive_true=$((consecutive_true + 1)) + [ "$consecutive_true" -ge "$SPEC_STABLE" ] && break + fi ;; 1) consecutive_true=0 consecutive_err=0 + if [ "$needs_edge" = 1 ]; then + needs_edge=0 + rm -f -- "$(edge_file "$sid")" + fi ;; *) consecutive_true=0 @@ -613,6 +629,10 @@ cmd_run() { exit 0 fi if [ "$SPEC_REPEAT" = 1 ]; then + # Mark that the next run must see the condition go false before it may + # fire again: a still-true level on the next reconcile is not a new + # change, and refiring on it would be a duplicate ring. + (umask 077; : > "$(edge_file "$sid")") 2>/dev/null || true # Journal first, then release the claim: a crash between the two costs one # duplicate ring on restart, while the reverse order could lose the fire's # only record and reset the deadline base. @@ -802,7 +822,8 @@ cmd_retire() { fi fi "$SCRIPT_DIR/fm-procevent.sh" retire "$sid" || die "cannot retire the watch source: $sid" - rm -f -- "$(spec_file "$sid")" "$(trust_file "$sid")" "$(fired_file "$sid")" "$(fires_file "$sid")" + rm -f -- "$(spec_file "$sid")" "$(trust_file "$sid")" "$(fired_file "$sid")" \ + "$(fires_file "$sid")" "$(edge_file "$sid")" printf 'retired: %s\n' "$sid" } diff --git a/tests/fm-procevent-when.test.sh b/tests/fm-procevent-when.test.sh index a5cee162820..64d226be698 100755 --- a/tests/fm-procevent-when.test.sh +++ b/tests/fm-procevent-when.test.sh @@ -718,12 +718,18 @@ assert_not_contains "$(wake_payloads "$H")" "procevent when when-repeat" \ SEQ=$(basename "$RESULT" | sed 's/^when-repeat\.//; s/\.result$//') assert_contains "$(pe "$H" handled when-repeat "$SEQ")" "already-handled" \ "the runner recorded the silent repeat fire as handled itself" -# A second change must ring again, which only a re-armed watch can do. +# A second change must ring again, which only a re-armed watch can do. The +# prior fire's runner already exited, so a fresh reconcile must start a new +# one and that runner must actually observe the trigger absent at least once +# before it reappears - otherwise this would only prove the still-true-level +# refire bug, not a real edge. rm -f -- "$REPEAT_TRIG" for _ in $(seq 1 150); do [ "$(count_lines "$REPEATLOG")" -ge 1 ] && break sleep 0.1 done +pe "$H" reconcile >/dev/null 2>&1 +sleep 0.3 : > "$REPEAT_TRIG" for _ in $(seq 1 150); do [ "$(count_lines "$REPEATLOG")" -ge 2 ] && break @@ -735,6 +741,42 @@ assert_not_contains "$(wake_payloads "$H")" "procevent when when-repeat" \ "no repeat fire wakes firstmate" pass "a repeat watch rings again after each fire without waking firstmate" +# --- a repeat watch never refires on a level that never went false ----------- +# After a fire, the prior runner has already exited; a later reconcile starts +# a fresh one. If the condition is still (not newly) true, that is not "Y +# changed" and must not ring the action again - only an actual false poll in +# between may re-arm the watch for its next fire. +H="$TMP_ROOT/h-repeat-level"; new_home "$H" +LEVEL_TRIG="$TMP_ROOT/repeat-level-trigger" +LEVEL_LOG="$TMP_ROOT/repeat-level-act" +: > "$LEVEL_TRIG" +when "$H" arm level --interval 0.1 --stable 1 --repeat \ + --condition "$COND" "$LEVEL_TRIG" "$TMP_ROOT/repeat-level-count" \ + --action "$ACT" "$LEVEL_LOG" >/dev/null +pe "$H" reconcile >/dev/null +wait_for_result "$H" when-level || fail "the still-true repeat watch captured no first outcome" +[ "$(count_lines "$LEVEL_LOG")" -eq 1 ] || fail "the first fire must run the action exactly once" +# The trigger is left in place (never removed), so every later reconcile sees +# the same continuously-true level, not a new change. +for _ in $(seq 1 15); do + pe "$H" reconcile >/dev/null 2>&1 + sleep 0.1 +done +[ "$(count_lines "$LEVEL_LOG")" -eq 1 ] || \ + fail "a condition that never went false must not refire the action a second time" +# The watch is not stuck: a genuine false-then-true edge still rings it again. +rm -f -- "$LEVEL_TRIG" +pe "$H" reconcile >/dev/null 2>&1 +sleep 0.3 +: > "$LEVEL_TRIG" +for _ in $(seq 1 150); do + [ "$(count_lines "$LEVEL_LOG")" -ge 2 ] && break + pe "$H" reconcile >/dev/null 2>&1 + sleep 0.1 +done +[ "$(count_lines "$LEVEL_LOG")" -ge 2 ] || fail "a real edge after the level must still ring the watch again" +pass "a repeat watch never refires on a level that never went false" + # --- retire stops a repeat watch --------------------------------------------- STOPPED=$(count_lines "$REPEATLOG") when "$H" retire repeat >/dev/null From 5394a2a460871465d32b4e362448ba16d303ffa4 Mon Sep 17 00:00:00 2001 From: NewAiCoder <170579485+NewAiCoder@users.noreply.github.com> Date: Sat, 12 Sep 2026 18:52:04 -0400 Subject: [PATCH 04/11] ci: retrigger CI (Behavior portable serial 1 flaked on the repeat-watch timing test; 4/4 local reruns pass) From 84e221570e1fa9a1168ab81b781c96d2e125cd58 Mon Sep 17 00:00:00 2001 From: NewAiCoder <170579485+NewAiCoder@users.noreply.github.com> Date: Sat, 12 Sep 2026 18:57:08 -0400 Subject: [PATCH 05/11] no-mistakes(review): Escalate failed edge-marker write to terminal watch-stops-here path --- bin/fm-procevent-when.sh | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/bin/fm-procevent-when.sh b/bin/fm-procevent-when.sh index 1f738fc29cd..a08c8d5a348 100755 --- a/bin/fm-procevent-when.sh +++ b/bin/fm-procevent-when.sh @@ -631,8 +631,17 @@ cmd_run() { if [ "$SPEC_REPEAT" = 1 ]; then # Mark that the next run must see the condition go false before it may # fire again: a still-true level on the next reconcile is not a new - # change, and refiring on it would be a duplicate ring. - (umask 077; : > "$(edge_file "$sid")") 2>/dev/null || true + # change, and refiring on it would be a duplicate ring. A write failure + # here is treated the same as a failed journal write below: without the + # marker, a fresh run on a level that never went false would refire + # immediately, reproducing the exact duplicate-ring bug this file exists + # to prevent. + if ! (umask 077; : > "$(edge_file "$sid")") 2>/dev/null; then + emit_doc "$sid" fired \ + "the condition held and the action exited 0, but the edge marker could not be written; the watch stops here" \ + "$polls" "$rc" "$out" + exit 0 + fi # Journal first, then release the claim: a crash between the two costs one # duplicate ring on restart, while the reverse order could lose the fire's # only record and reset the deadline base. From 5765c352438f95a33f6a9911e9a1508a38da27a9 Mon Sep 17 00:00:00 2001 From: NewAiCoder <170579485+NewAiCoder@users.noreply.github.com> Date: Sat, 12 Sep 2026 19:21:18 -0400 Subject: [PATCH 06/11] no-mistakes(document): Document verification-suite coverage and state/when/ inventory for the edge-marker fix --- AGENTS.md | 2 +- docs/verification/process-event-sources.md | 2 +- tests/fm-procevent-when.test.sh | 60 ++++++++++++++++++++++ 3 files changed, 62 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 733b6d18ff0..371c9eba35f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -134,7 +134,7 @@ state/ runtime records and signals; gitignored procevent-inbox/ private captured results and their durable handled-acknowledgement markers; source output lives here and never in an event line decision-bindings/ private records marking a captured-answer source as feeding the keyed-answer intake, with a legacy origin on pre-collapse records; written only by bin/fm-captain-hold.sh bind, dropped by unbind and by source retirement (section 13; docs/captain-hold-lifecycle.md) reconcile-requests/ private open obligations to re-check a captain call whose board selection was `reconcile`; written only by bin/fm-captain-hold.sh, retired by its verify-then-decide outcomes or a normal answer that settles the call (section 13; docs/captain-hold-lifecycle.md) - when/ private condition->action watch specs, their trust bindings, single-fire markers, and a repeat watch's bounded fire journal; written only by bin/fm-procevent-when.sh (section 13's process-event-sources trigger) + when/ private condition->action watch specs, their trust bindings, single-fire markers, a repeat watch's bounded fire journal, and a repeat watch's needs-a-new-edge marker; written only by bin/fm-procevent-when.sh (section 13's process-event-sources trigger) inbox/ captain notes captured out of band by bin/fm-inbox.sh, including the voice handover's queued requests; each note appends one `check` wake and stays pending until acknowledged with `bin/fm-inbox.sh drain --ack `, which moves it to inbox/handled/; request-id reservations, announcement markers, and primary replies live beside the notes (bin/fm-inbox.sh; docs/voice-relay.md) x-inbox/ generated Relay pending mention payloads; fmx-respond drains it (section 14) x-context/ generated Relay durable per-request reply context and one-wake offer markers, keyed by request_id; survives inbox cleanup and expires within seven days (section 14; bin/fm-x-lib.sh) diff --git a/docs/verification/process-event-sources.md b/docs/verification/process-event-sources.md index b7dcab5ac95..6ec9fd42e53 100644 --- a/docs/verification/process-event-sources.md +++ b/docs/verification/process-event-sources.md @@ -146,7 +146,7 @@ Exercised by `tests/fm-procevent.test.sh` against a fake blocking source whose c | condition->action terminal outcomes | the same suite proves flapping true polls do not fire, action failure, condition error budget, deadline expiry, and a true poll completing after its deadline each produce the expected terminal captured result without an unsafe action | | condition->action process bounds | the same suite proves action timeout terminates descendants and command-output staging remains within `FM_WHEN_OUTPUT_TAIL_BYTES` while the command runs | | condition->action action environment | the same suite proves a registered `--action-env` assignment reaches the action, an assignment with a non-identifier NAME is refused before arming, and a shell-safe NAME that could instead hijack an interpreter or loader (`LD_PRELOAD`, `PATH`, and the same short denylist) is refused too, naming the offending option in every refusal | -| condition->action repeat mode | the same suite proves a `--repeat` watch's successful fire is classified `fired` yet is neither terminal nor announced, is journalled and self-acknowledged as handled with no wake reaching the durable queue, leaves the watch registered and re-armed so a second real condition change rings the action again, stops ringing once retired, and still produces a normal terminal wake when its action fails | +| condition->action repeat mode | the same suite proves a `--repeat` watch's successful fire is classified `fired` yet is neither terminal nor announced, is journalled and self-acknowledged as handled with no wake reaching the durable queue, leaves the watch registered and re-armed so a second real condition change rings the action again, never refires on a level that never went false while still ringing again on a genuine subsequent edge, stops ringing once retired, and still produces a normal terminal wake when its action fails or when either the fire journal or the edge marker cannot be written | | silent failure handling | a nonzero exit with no output publishes nothing and leaves the source registered for retry | | inertness | a home with no registered source generates no state, starts no process, and does not need supervision | | rebind-all refreshes an in-repo trust binding, leaves an out-of-repo one alone | after a simulated self-update rewrites an armed watch's in-repo action executable's bytes, `rebind-all` republishes exactly that watch's trust binding against the new bytes and leaves a watch whose action lives outside `FM_ROOT` untouched byte-for-byte; the rebound watch then fires cleanly against the new bytes instead of being refused, and a second `rebind-all` with nothing changed rebinds nothing (`tests/fm-procevent-when.test.sh`) | diff --git a/tests/fm-procevent-when.test.sh b/tests/fm-procevent-when.test.sh index 64d226be698..bba37c0b0e0 100755 --- a/tests/fm-procevent-when.test.sh +++ b/tests/fm-procevent-when.test.sh @@ -777,6 +777,66 @@ done [ "$(count_lines "$LEVEL_LOG")" -ge 2 ] || fail "a real edge after the level must still ring the watch again" pass "a repeat watch never refires on a level that never went false" +# --- a failed edge-marker write escalates to a captured terminal outcome ---- +# A regression guard for the exact bug the edge marker exists to prevent: if +# the write that records "the next stable-true is not a new edge" itself +# fails, the fix must escalate to a captured terminal outcome (status fired, +# no `repeat: continues`) instead of the old `|| true` swallow - because a +# swallowed failure left no marker behind, and cmd_terminal classifies any +# fired result without `repeat: continues` as terminal, so the generic runner +# retires the source instead of leaving a live registration that a level which +# never goes false could refire on the very next reconcile. The write is +# forced to fail deterministically by pre-occupying the marker's path with a +# directory, which `: > path` can never truncate. +H="$TMP_ROOT/h-repeat-edgefail"; new_home "$H" +EDGEFAIL_TRIG="$TMP_ROOT/repeat-edgefail-trigger" +EDGEFAIL_LOG="$TMP_ROOT/repeat-edgefail-act" +rm -f -- "$EDGEFAIL_TRIG" +when "$H" arm edgefail --interval 0.1 --stable 1 --repeat \ + --condition "$COND" "$EDGEFAIL_TRIG" "$TMP_ROOT/repeat-edgefail-count" \ + --action "$ACT" "$EDGEFAIL_LOG" >/dev/null +mkdir -p "$H/state/when/when-edgefail.needs-edge" +# The obstruction directory also makes the runner start with needs_edge=1 (the +# marker "exists"), so the runner must observe at least one real false poll +# before it will count a true poll towards firing at all - drive that with the +# same reconcile-then-wait-then-trigger idiom the other repeat tests use, +# otherwise this would only prove the runner never fires, not that it fires +# once and stops safely. +pe "$H" reconcile >/dev/null +wait_for_file "$TMP_ROOT/repeat-edgefail-count" || fail "the edge-marker-write-failure condition was never polled" +: > "$EDGEFAIL_TRIG" +wait_for_result "$H" when-edgefail || fail "the edge-marker-write-failure watch captured no first outcome" +RESULT=$(first_result "$H" when-edgefail) +assert_grep 'status: fired' "$RESULT" "the fire itself is still recorded as fired" +assert_not_contains "$(cat "$RESULT")" 'repeat: continues' \ + "a failed edge-marker write must not declare the watch continues" +assert_grep 'edge marker could not be written' "$RESULT" \ + "the captured outcome names the edge-marker write failure" +[ "$(count_lines "$EDGEFAIL_LOG")" -eq 1 ] || fail "the fire must still run the action exactly once" +when "$H" terminal "$RESULT" || fail "a failed edge-marker write must be terminal, exactly like a failed journal write" +if when "$H" silent "$RESULT"; then + fail "a failed edge-marker write must never be silenced" +fi +# The generic runner retires a terminal source: no restart, no second fire - +# this is the actual mechanism that stops the level from refiring, not a +# leftover fired-claim file. +for _ in $(seq 1 100); do + [ ! -e "$H/state/procevent/when-edgefail.source" ] && break + sleep 0.1 +done +assert_absent "$H/state/procevent/when-edgefail.source" "a failed edge-marker write retires the watch" +assert_contains "$(wake_payloads "$H")" "procevent when when-edgefail" \ + "a fire whose edge marker could not be written must wake firstmate, not stay silent" +# The trigger is left in place (still continuously true); since the watch is +# retired, no further reconcile may run the action again. +for _ in $(seq 1 15); do + pe "$H" reconcile >/dev/null 2>&1 + sleep 0.1 +done +[ "$(count_lines "$EDGEFAIL_LOG")" -eq 1 ] || \ + fail "a retired watch must never refire, even on a level that never went false" +pass "a failed edge-marker write escalates to a captured terminal outcome and retires the watch" + # --- retire stops a repeat watch --------------------------------------------- STOPPED=$(count_lines "$REPEATLOG") when "$H" retire repeat >/dev/null From a76b47ad845f6baa7b0e475d1f86c3585bb0d330 Mon Sep 17 00:00:00 2001 From: NewAiCoder <170579485+NewAiCoder@users.noreply.github.com> Date: Sat, 12 Sep 2026 19:51:17 -0400 Subject: [PATCH 07/11] test(fm-procevent-when): widen the repeat-watch second-ring retry budget to 30s Behavior portable serial N has flaked twice on 'the repeat watch never rang a second time' (once on serial 1, once on serial 3), while the same test passes reliably locally. This loop calls pe reconcile every 0.1s tick on top of the polling runner it waits on, making it more contention-sensitive under a loaded shared CI runner than the file's other passive wait_for_result/wait_for_file checks. Doubling its budget to 300 tries (30s) gives it the same headroom without slowing a healthy run, which still breaks out of the loop on the first successful poll. --- tests/fm-procevent-when.test.sh | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tests/fm-procevent-when.test.sh b/tests/fm-procevent-when.test.sh index bba37c0b0e0..22b85588e86 100755 --- a/tests/fm-procevent-when.test.sh +++ b/tests/fm-procevent-when.test.sh @@ -731,7 +731,13 @@ done pe "$H" reconcile >/dev/null 2>&1 sleep 0.3 : > "$REPEAT_TRIG" -for _ in $(seq 1 150); do +# 300 tries (30s), double the file's other wait budgets: this loop spawns a +# `pe reconcile` process every iteration on top of the polling runner it is +# waiting on, so it is measurably more contention-sensitive under a loaded +# shared CI runner than a passive wait_for_result/wait_for_file check - it +# has been the only assertion in this file to flake there (twice, on two +# different shards), while never failing locally. +for _ in $(seq 1 300); do [ "$(count_lines "$REPEATLOG")" -ge 2 ] && break pe "$H" reconcile >/dev/null 2>&1 sleep 0.1 From 13e99678f506f17904dd3f188a9c84fab5c912c9 Mon Sep 17 00:00:00 2001 From: NewAiCoder <170579485+NewAiCoder@users.noreply.github.com> Date: Sat, 12 Sep 2026 20:00:24 -0400 Subject: [PATCH 08/11] no-mistakes(document): Documented repeat-watch edge-required-before-refire semantics and edge-marker failure path in the script's own --help contract --- bin/fm-procevent-when.sh | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/bin/fm-procevent-when.sh b/bin/fm-procevent-when.sh index a08c8d5a348..15468440c65 100755 --- a/bin/fm-procevent-when.sh +++ b/bin/fm-procevent-when.sh @@ -102,6 +102,10 @@ # "ring X every time Y changes", which is what a worker waiting on its own # pipeline needs: that state changes several times per run, and a watch that # died on the first change left every later change to a model turn. +# A fire also marks that the next run must see the condition go false before +# a true poll counts toward another fire, so a level that stays continuously +# true rings the action once and then holds silent instead of refiring on +# every reconcile - "every time Y changes" means an actual edge. # Under --repeat, a successful fire releases the single-fire claim, appends one # line to the watch's fire journal (state/when/.fires), and emits a # `repeat: continues` outcome. That outcome is silent and non-terminal, so the @@ -109,12 +113,12 @@ # deadline is then measured from the last fire rather than from arming, so it # means "the condition stopped changing" instead of "the watch got old". # Everything else is unchanged: a failed action, a condition error past its -# budget, a deadline, a mutated spec or action, and an uncaptured claimed fire -# are all still terminal and still wake firstmate with the evidence. The -# single-fire claim still makes one fire unrepeatable within its own poll, so -# only a fire whose outcome was emitted lets the next one happen. A repeat -# action must therefore be safe to run again, which is the standard the -# one-shot action already had to meet. +# budget, a deadline, a mutated spec or action, an uncaptured claimed fire, and +# a failed edge-marker write are all still terminal and still wake firstmate +# with the evidence. The single-fire claim still makes one fire unrepeatable +# within its own poll, so only a fire whose outcome was emitted lets the next +# one happen. A repeat action must therefore be safe to run again, which is +# the standard the one-shot action already had to meet. # # Outcome document (the captured result named by the wake): # when: From da74c203bb26a7bfc78dff6a5f1badd29ddbb46d Mon Sep 17 00:00:00 2001 From: NewAiCoder <170579485+NewAiCoder@users.noreply.github.com> Date: Sun, 13 Sep 2026 13:43:53 -0400 Subject: [PATCH 09/11] no-mistakes(review): Fix rebind-all to preserve repeat and action-env fields on republish --- bin/fm-procevent-when.sh | 8 ++++-- tests/fm-procevent-when.test.sh | 50 +++++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 2 deletions(-) diff --git a/bin/fm-procevent-when.sh b/bin/fm-procevent-when.sh index 15468440c65..1bb0b56d247 100755 --- a/bin/fm-procevent-when.sh +++ b/bin/fm-procevent-when.sh @@ -719,9 +719,10 @@ cmd_silent() { # --- rebind-all --------------------------------------------------------------- # publish_spec : write and hash-bind a spec from -# the SPEC_* scalars and COND_ARGV/ACT_ARGV a prior spec_load already +# the SPEC_* scalars and COND_ARGV/ACT_ARGV/ENV_ARGV a prior spec_load already # populated, using the given action hash. Mirrors cmd_arm's write block; the -# only caller today is rebind_one, refreshing action_sha256 alone. +# only caller today is rebind_one, refreshing action_sha256 while preserving +# every other field spec_load parsed, including repeat and the action env. publish_spec() { local sid=$1 device=$2 action_hash=$3 tmp trust_tmp hash tmp=$(umask 077; mktemp "$WHEN_DIR/.spec.XXXXXX") || return 1 @@ -734,12 +735,15 @@ publish_spec() { printf 'condition_timeout=%s\n' "$SPEC_CONDITION_TIMEOUT" printf 'action_timeout=%s\n' "$SPEC_ACTION_TIMEOUT" printf 'error_budget=%s\n' "$SPEC_ERROR_BUDGET" + printf 'repeat=%s\n' "$SPEC_REPEAT" printf 'action_sha256=%s\n' "$action_hash" printf 'condition_argc=%s\n' "${#COND_ARGV[@]}" printf 'action_argc=%s\n' "${#ACT_ARGV[@]}" + printf 'env_argc=%s\n' "${#ENV_ARGV[@]}" printf 'argv:\n' printf '%s\n' "${COND_ARGV[@]}" printf '%s\n' "${ACT_ARGV[@]}" + [ "${#ENV_ARGV[@]}" -eq 0 ] || printf '%s\n' "${ENV_ARGV[@]}" } > "$tmp" || { rm -f -- "$tmp"; return 1; } chmod 0600 "$tmp" || { rm -f -- "$tmp"; return 1; } hash=$(fm_pr_sha256 "$tmp") || { rm -f -- "$tmp"; return 1; } diff --git a/tests/fm-procevent-when.test.sh b/tests/fm-procevent-when.test.sh index 22b85588e86..7673f850bca 100755 --- a/tests/fm-procevent-when.test.sh +++ b/tests/fm-procevent-when.test.sh @@ -469,6 +469,56 @@ assert_grep 'status: fired' "$RESULT" "the rebound watch fires instead of being assert_grep 'action ran v2 against' "$RESULT" "the fired action ran the new bytes, not a stale copy" pass "rebind-all refreshes an in-repo watch's trust binding after a self-update and leaves an out-of-repo one alone" +# --- rebind-all preserves repeat and the action environment ----------------- +# publish_spec republishes a watch's spec from the fields spec_load already +# parsed; if it dropped repeat/env_argc/the env assignments, a rebound +# --repeat --action-env watch would silently downgrade to a plain one-shot +# watch with no environment on its very next fire. +H="$TMP_ROOT/h-rebind-repeat-env"; new_home "$H" +REPO_ROOT="$TMP_ROOT/rebind-repeat-env-repo" +mkdir -p "$REPO_ROOT/bin" +REPEAT_ENV_ACT="$REPO_ROOT/bin/act.sh" +cat > "$REPEAT_ENV_ACT" <<'SH' +#!/usr/bin/env bash +log=$1 +printf 'v1 ran with FM_TEST_MARK=%s\n' "${FM_TEST_MARK:-unset}" >> "$log" +SH +chmod +x "$REPEAT_ENV_ACT" +when_ro() { FM_HOME="$1" FM_ROOT_OVERRIDE="$REPO_ROOT" "$ROOT/bin/fm-procevent-when.sh" "${@:2}"; } + +REPEAT_ENV_TRIG="$TMP_ROOT/rebind-repeat-env-trigger" +REPEAT_ENV_LOG="$TMP_ROOT/rebind-repeat-env-act" +when_ro "$H" arm rebind-repeat-env --interval 0.1 --stable 1 --repeat \ + --action-env "FM_TEST_MARK=keep" \ + --condition "$COND" "$REPEAT_ENV_TRIG" "$TMP_ROOT/rebind-repeat-env-count" \ + --action "$REPEAT_ENV_ACT" "$REPEAT_ENV_LOG" >/dev/null + +# Simulate the self-update: rewrite the in-repo action's bytes in place. +cat > "$REPEAT_ENV_ACT" <<'SH' +#!/usr/bin/env bash +log=$1 +printf 'v2 ran with FM_TEST_MARK=%s\n' "${FM_TEST_MARK:-unset}" >> "$log" +SH +chmod +x "$REPEAT_ENV_ACT" + +OUT=$(when_ro "$H" rebind-all) || fail "rebind-all reported a failure: $OUT" +assert_contains "$OUT" "rebound: when-rebind-repeat-env" "the repeat/action-env watch was rebound" + +pe "$H" reconcile >/dev/null +wait_for_file "$TMP_ROOT/rebind-repeat-env-count" || fail "the rebound watch's condition was never polled" +: > "$REPEAT_ENV_TRIG" +wait_for_result "$H" when-rebind-repeat-env || fail "the rebound watch captured no outcome" +RESULT=$(first_result "$H" when-rebind-repeat-env) +assert_grep 'status: fired' "$RESULT" "the rebound repeat/action-env watch still fires" +assert_grep 'repeat: continues' "$RESULT" \ + "rebind-all must preserve the repeat flag, not silently downgrade to a one-shot watch" +assert_grep 'v2 ran with FM_TEST_MARK=keep' "$REPEAT_ENV_LOG" \ + "rebind-all must preserve the action environment, not drop it on republish" +# A repeat watch never self-retires, so its poller would otherwise keep +# running (and contending for CPU) for the rest of the suite. +when_ro "$H" retire rebind-repeat-env >/dev/null +pass "rebind-all preserves a watch's repeat flag and action environment across a self-update" + # --- rebind-all matches an action reached through a symlinked FM_ROOT ------- H="$TMP_ROOT/h-rebind-symlink"; new_home "$H" REPO_REAL="$TMP_ROOT/rebind-symlink-real" From 27ea9881cb56163e8b2bd24f4e4e8c0db4132945 Mon Sep 17 00:00:00 2001 From: NewAiCoder <170579485+NewAiCoder@users.noreply.github.com> Date: Sun, 13 Sep 2026 14:02:41 -0400 Subject: [PATCH 10/11] no-mistakes(document): Add rebind-all repeat/env-preservation coverage row to verification doc --- docs/verification/process-event-sources.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/verification/process-event-sources.md b/docs/verification/process-event-sources.md index 6ec9fd42e53..0bf0bd5aa6e 100644 --- a/docs/verification/process-event-sources.md +++ b/docs/verification/process-event-sources.md @@ -151,6 +151,7 @@ Exercised by `tests/fm-procevent.test.sh` against a fake blocking source whose c | inertness | a home with no registered source generates no state, starts no process, and does not need supervision | | rebind-all refreshes an in-repo trust binding, leaves an out-of-repo one alone | after a simulated self-update rewrites an armed watch's in-repo action executable's bytes, `rebind-all` republishes exactly that watch's trust binding against the new bytes and leaves a watch whose action lives outside `FM_ROOT` untouched byte-for-byte; the rebound watch then fires cleanly against the new bytes instead of being refused, and a second `rebind-all` with nothing changed rebinds nothing (`tests/fm-procevent-when.test.sh`) | | rebind-all matches FM_ROOT reached through a symlink | `FM_ROOT` and the resolved action executable are each canonicalized before the containment comparison, so a watch whose action is reached through a symlinked checkout path is still recognized as in-repo and rebound rather than silently skipped as out of scope (`tests/fm-procevent-when.test.sh`) | +| rebind-all preserves repeat and the action environment | `publish_spec` republishes every field `spec_load` parsed - not only the action hash - so a rebound `--repeat --action-env` watch still fires with its assigned environment and its non-terminal `repeat: continues` outcome instead of silently downgrading to a one-shot watch with no environment on its next fire (`tests/fm-procevent-when.test.sh`) | | self-update rebinds a locally armed watch | `fm-update.sh` runs its own home's `rebind-all` best-effort immediately after a successful fast-forward of the primary repo or a local secondmate, so a watch armed against an in-repo action keeps firing across the update with no separate operator step (`tests/fm-update.test.sh`) | | rebind-all reaches a watch already polling when the update lands | `run`'s poll loop calls `spec_load` once before entering its loop and would otherwise compare fire-time bytes against that stale in-memory hash forever; the fire-time check instead reloads the trust binding from disk immediately before firing, so a watch armed before a self-update still fires against the rebound bytes instead of being rejected as stale (`tests/fm-procevent-when.test.sh`) | | fire-time reload serializes against rebind_one's publish | `publish_spec` renames the new spec into place and the new trust into place as two separate renames, never one atomic swap; the fire-time reload takes the same per-sid source lock `rebind_one` holds across that publish, so it can never observe the torn combination of rebound spec bytes next to a still-old trust record and instead waits for the publish to finish (`tests/fm-procevent-when.test.sh`) | From 1a95aa82f061baa6737605a251f7f232b78ded69 Mon Sep 17 00:00:00 2001 From: NewAiCoder <170579485+NewAiCoder@users.noreply.github.com> Date: Mon, 21 Sep 2026 03:57:06 -0400 Subject: [PATCH 11/11] no-mistakes(test): test(fm-procevent-when): wait for edge marker instead of fixed sleep --- tests/fm-procevent-when.test.sh | 34 ++++++++++++++++++++++++--------- 1 file changed, 25 insertions(+), 9 deletions(-) diff --git a/tests/fm-procevent-when.test.sh b/tests/fm-procevent-when.test.sh index 7673f850bca..5cbcb27245e 100755 --- a/tests/fm-procevent-when.test.sh +++ b/tests/fm-procevent-when.test.sh @@ -779,17 +779,33 @@ for _ in $(seq 1 150); do sleep 0.1 done pe "$H" reconcile >/dev/null 2>&1 -sleep 0.3 +# The prior fire left the needs-edge marker behind (bin/fm-procevent-when.sh's +# edge_file), and the fresh runner reconcile just launched only clears it once +# its own first poll actually observes the trigger absent. A fixed sleep here +# assumed that poll would land inside the sleep - forking, sourcing the +# adapter's libraries and reaching that first poll under a loaded shared +# runner can take longer than any fixed budget survives, and a trigger +# recreated before that first poll leaves the marker set forever: the watch +# is then correctly, permanently waiting for an edge that already passed it +# by, which is what actually made this the one assertion in this file to +# flake under a loaded shared runner (twice, on two different shards) while +# never failing locally. Waiting on the marker itself removes the timing +# assumption; reconcile is retried on the same cadence as the marker check +# because a launch it starts is only confirmed within +# FM_PROCEVENT_LAUNCH_CONFIRM_SECONDS (default 3s), and under load that +# confirmation can lose the race too. +EDGE_MARKER="$H/state/when/when-repeat.needs-edge" +tick=0 +for _ in $(seq 1 150); do + [ -e "$EDGE_MARKER" ] || break + tick=$((tick + 1)) + [ $(( tick % 10 )) -eq 0 ] && pe "$H" reconcile >/dev/null 2>&1 + sleep 0.1 +done +[ -e "$EDGE_MARKER" ] && fail "the repeat watch's fresh runner never observed the condition go false" : > "$REPEAT_TRIG" -# 300 tries (30s), double the file's other wait budgets: this loop spawns a -# `pe reconcile` process every iteration on top of the polling runner it is -# waiting on, so it is measurably more contention-sensitive under a loaded -# shared CI runner than a passive wait_for_result/wait_for_file check - it -# has been the only assertion in this file to flake there (twice, on two -# different shards), while never failing locally. -for _ in $(seq 1 300); do +for _ in $(seq 1 150); do [ "$(count_lines "$REPEATLOG")" -ge 2 ] && break - pe "$H" reconcile >/dev/null 2>&1 sleep 0.1 done [ "$(count_lines "$REPEATLOG")" -ge 2 ] || fail "the repeat watch never rang a second time"