Skip to content

feat(bin): count task attempts against a durable retry budget (CFVC-13) - #63

Merged
sbracewell64 merged 6 commits into
mainfrom
fm/land-cfvc-13-onto-fork
Aug 9, 2026
Merged

feat(bin): count task attempts against a durable retry budget (CFVC-13)#63
sbracewell64 merged 6 commits into
mainfrom
fm/land-cfvc-13-onto-fork

Conversation

@sbracewell64

Copy link
Copy Markdown
Owner

What this does

CFVC-13: "should this be retried?" becomes arithmetic over a durable count instead of a model judgment.

Nothing counted attempts before this - no state field, no meta field - and generated brief rule 5 ("if you hit the same obstacle twice... stop") made the worker the arbiter of its own retry budget, evaluated from a context that resets on every relaunch.

  • bin/fm-attempt.sh (new) owns state/<id>.attempt (attempt=, attempt_budget=, terminal=) and the whole contract: record format, migration rule, refusal, exhaustion. Verbs: show, check, open, retire.
  • bin/fm-spawn.sh checks the budget before it creates anything (a refused retry allocates no worktree and no endpoint) and commits the increment when it publishes task metadata (a spawn that never launched costs nothing). The count is published as attempt=/attempt_budget=; --attempt-budget <n> raises it deliberately and the raised value is recorded.
  • bin/fm-teardown.sh retires the count on an ordinary release and keeps it under --force, so discarding between attempts cannot make the budget unbounded.
  • bin/fm-brief.sh deletes rule 5's prose, renumbers the rules, and updates the one numbered cross-reference.

An absent attempt= reads as attempt 1, so a task dispatched before this retries at 2 rather than restarting its budget. Secondmates are exempt: their relaunch is unattended liveness recovery, not a retry. There is deliberately no reset verb - raising the budget is the only way past an exhausted count, and it is recorded.

Dependency compatibility

Both dependencies are delivered on branches and are not on the trunk yet. Neither producer is duplicated.

  • CFVC-11 (fm/cfvc-11-terminal-vocabulary, loopspecs/terminal-states.json): exhaustion terminates in that unified vocabulary's budget_exhausted rather than a third name. The regression asserts membership whenever that file is present and says plainly when it is not - a silent skip would read as a pass - with a discriminating negative control.
  • CFVC-12 (fm/cfvc-12-task-outcome-failure): exhaustion appends the failed: verb its terminal-outcome derivation reads, so it books outcome=failed through that owner instead of a second ledger producer. The three-member v1 outcome enum is untouched.

Check state - please read before merging

The pipeline reported checks-passed on an empty check rollup. Its CI step logged no CI checks reported - still monitoring until merged or closed. No checks examined this head, so it is unverified, not green. Treat it accordingly.

Why this PR exists alongside upstream kunchenguid#1979-series PR 2010

The pipeline pushed the branch to this fork but opened its pull request at the upstream venue: kunchenguid#2010.

That upstream PR is incomplete. Rebasing onto the upstream trunk dropped part of the fix content the pipeline itself had produced for the accepted review finding: the exact-premise correction in bin/fm-attempt.sh's header, bin/fm-teardown.sh's retire rationale, and docs/architecture.md. Those hunks are present here and absent there.

This PR is the complete change, based on the fork trunk, which is the deliberate base for this lane.

Evidence

  • Every new assertion was witnessed red first: the whole tests/fm-attempt.test.sh suite against a tree with bin/fm-attempt.sh removed; the spawn case against the unmodified bin/fm-spawn.sh; the teardown retire case against the unmodified bin/fm-teardown.sh; the brief case against the unmodified bin/fm-brief.sh.
  • Green on this head: tests/fm-attempt.test.sh, tests/fm-brief.test.sh, tests/fm-teardown.test.sh, bin/fm-lint.sh, bin/fm-doc-audience-check.sh, bin/fm-test-run.sh --check-coverage.
  • tests/fm-session-start.test.sh and tests/fm-launch.test.sh fail identically on the untouched base in this environment, so they are pre-existing and unrelated.

Nothing counted attempts before this: no state field, no metadata field, and
brief rule 5's "if you hit the same obstacle twice, stop" made the worker the
arbiter of its own retry budget, evaluated from a context that resets on every
relaunch. "Should this be retried?" is now arithmetic over a number on disk.

bin/fm-attempt.sh owns state/<id>.attempt (attempt=, attempt_budget=,
terminal=). Every ship or scout spawn checks the budget before it creates
anything and commits the increment when it publishes task metadata, which also
carries the count as attempt=/attempt_budget=. An absent field reads as attempt
1, so a task dispatched before this retries at 2 rather than restarting its
budget. Secondmates are exempt: their relaunch is unattended liveness recovery,
not a retry.

Exhaustion is a named stop, not a silent one. It records the terminal state and
declares the failure on the task's own status log. Teardown retires the count on
an ordinary release, which is only reachable once the work landed, and keeps it
under --force, so discarding between attempts cannot make the budget unbounded.

Compatibility with the two delivered dependencies, neither of which is on the
trunk yet, and neither of whose producers this duplicates:

- CFVC-11 (fm/cfvc-11-terminal-vocabulary, loopspecs/terminal-states.json):
  exhaustion terminates in that unified vocabulary's budget_exhausted rather
  than a third name. The regression asserts membership whenever that file is
  present and says plainly when it is not.
- CFVC-12 (fm/cfvc-12-task-outcome-failure): the status declaration uses the
  failed: verb that its terminal-outcome derivation reads, so exhaustion books
  outcome=failed through that owner instead of a second ledger producer. The
  three-member v1 outcome enum is untouched.

Brief rule 5's prose is deleted in the same change and the rule list renumbered,
with its numbered cross-reference updated.
…ta parity check

The projection parity case spawns ONE task id twice - opted out, then
projected - and byte-compares the two metadata files after normalizing the
fields that legitimately differ per run. The durable attempt counter adds
another such field: the second spawn of that id is attempt 2 by construction,
so the comparison failed on a real per-run fact rather than on a projection
difference.

Only attempt= is normalized. attempt_budget= stays compared, because the budget
is a property of the task rather than of the run, and projection must not
change it - normalizing both would have retired real coverage to silence one
line.

Caught by fork CI, not locally: this suite is real-herdr-gated and the task
brief carries no Herdr lab guard, so CI is its verification path here.
CI proved the increment semantics wrong. The Herdr suite drives a same-identity
reclaim: a task whose session dies leaves an agent-free husk, and fm-spawn is
re-run with the same id to replace the dead endpoint while the worktree, branch
and work are preserved. Counting every launch charged that recovery against the
retry budget, so a task could not be brought back after two session restarts -
"fm-hibit-resume-r1 has spent its retry budget: 2 of 2 attempts used". That is
the same harm already avoided for secondmates and not applied to crewmates.

The contract now reads the spec literally: an attempt increments ONLY when the
prior attempt has a recorded FAILED terminal outcome. A spawn following no
recorded failure - dead runtime, husk, freeze, any recovery reclaim - is a
CONTINUATION: the count persists, does not move, and is never refused. The
outcome record draws the line, not the spawn event, which is exactly why this
increment depends on the terminal outcome that can say the prior attempt failed.
A wedged worker is no exception; relaunching one continues unless the wedge was
recorded as a failure first.

The failure signal is the `failed:` verb on the task's own status log - the same
declaration the ledger derives outcome=failed from, so this reads CFVC-12's
evidence rather than a private second signal. failures= records how many were
seen when the count last moved, so only a failure newer than the last counted
attempt spends the next one. This script's own budget-exhaustion declaration is
excluded from that tally, or one refusal would manufacture its own successor. A
refusal preserves the tally it found: it opens nothing, so it must not consume
the pending failure.

A forced teardown needs its own record. The discard ends the attempt AND deletes
the status log carrying the declaration, so teardown --force now calls
`fm-attempt.sh end`, which marks the attempt ended (ended=1). Without it a
re-dispatch after a discard would read as a continuation and discarding between
attempts would make the budget unbounded - the hole keeping the record closed.

Witnessed red first, as instructed: tests/fm-backend-herdr-presentation-e2e.sh
reproduces the exact CI refusal against the pre-correction tree and passes
after, 23 cases, with the default-session tripwire intact. The suite drives
Herdr only through the guarded lab helper, which refuses the default session by
construction.
Trunk began requiring --reason-code on every ship and scout spawn after this
branch was cut, so the attempt suite's six ship spawns were refused before they
could publish a count. Each now passes NL_RULE_CLASSIFICATION, matching the
other spawn-driving suites.

The secondmate case is deliberately left alone: the reason code is refused on
--secondmate, which provisions a standing home rather than dispatching a task.
@sbracewell64
sbracewell64 force-pushed the fm/land-cfvc-13-onto-fork branch from af60d09 to 3594339 Compare August 9, 2026 17:56
@sbracewell64

Copy link
Copy Markdown
Owner Author

Rebased onto the current fork trunk

Rebased onto faf131b (fork main, after #60 and #62 merged). Pre-rewrite head af60d09 is preserved at archive/prerebase-2026-08-09/land-cfvc-13-onto-fork; the head branch is fork-only and is not shared with any open upstream PR. All five commits survived, including the three pipeline-fix commits and the later fix(bin): spend an attempt on a recorded failure, not on a launch.

Conflicts and how they were resolved

Six conflicts across two of the five commits. Every one was two independent additions landing at the same point, and every one resolved as a union:

  • bin/fm-spawn.sh - four sites (header, variable init, the two option parsers, and the batch arg pass-through) where trunk added --reason-code / --capability-floor / --tooling-gap-item and this branch added --attempt-budget. Both option families are present and independently wired.
  • bin/fm-teardown.sh - trunk added $ID.terminal-recorded to the state cleanup list; this branch added the retire_attempt_record call. Both are kept.
  • tests/fm-teardown.test.sh - trunk appended three ledger-outcome cases and this branch appended two attempt-record cases, interleaved across a shared fixture body. All five functions and all five runner entries are present.
  • AGENTS.md - both sides edited the <id>.meta inventory line. Kept trunk's line, which carries the reasoning fields from the feat(bin): record why every agent dispatch was necessary at the spawn chokepoint kunchenguid/firstmate#1830 landing, and spliced this branch's attempt=/attempt_budget= clause into it, so neither set of fields is lost. The branch's own <id>.attempt line was then carried in its final form, with failures= and the recorded-failure wording from the last commit rather than the earlier draft.

One integration fix the rebase exposed

Trunk began requiring --reason-code on every ship and scout spawn after this branch was cut, so all six of the attempt suite's ship spawns were refused before they could publish a count. Each now passes NL_RULE_CLASSIFICATION, matching the other spawn-driving suites. The secondmate case is deliberately untouched, because the reason code is refused on --secondmate.

Test results, disclosed in full

bin/fm-test-run.sh --changed --base fork/main selected 85 suites; 6 failed on the first pass.

  • tests/fm-attempt.test.sh - failed for the missing reason code above, and passes after the fix.
  • fm-calm-pi-extension, fm-busy-adapter-wiring, fm-backend-tmux-smoke, fm-launch, fm-tmux-agent-liveness - pre-existing and unrelated. All five fail identically on unmodified fork/main in a separate clean checkout, and are local-environment failures (Node ESM loader, tmux harness) rather than branch defects; the same lanes pass in CI.

bin/fm-lint.sh is clean.

The reporting was not trusted on absence: the runner produced real not ok lines and a non-zero exit, the fixed suite was watched failing and then passing on the same command, and the five unrelated failures were reproduced against a bare-trunk control rather than assumed.

@sbracewell64
sbracewell64 merged commit 0204baa into main Aug 9, 2026
13 of 14 checks passed
sbracewell64 added a commit that referenced this pull request Aug 9, 2026
…3) (#63)

* feat(bin): count task attempts against a durable retry budget

Nothing counted attempts before this: no state field, no metadata field, and
brief rule 5's "if you hit the same obstacle twice, stop" made the worker the
arbiter of its own retry budget, evaluated from a context that resets on every
relaunch. "Should this be retried?" is now arithmetic over a number on disk.

bin/fm-attempt.sh owns state/<id>.attempt (attempt=, attempt_budget=,
terminal=). Every ship or scout spawn checks the budget before it creates
anything and commits the increment when it publishes task metadata, which also
carries the count as attempt=/attempt_budget=. An absent field reads as attempt
1, so a task dispatched before this retries at 2 rather than restarting its
budget. Secondmates are exempt: their relaunch is unattended liveness recovery,
not a retry.

Exhaustion is a named stop, not a silent one. It records the terminal state and
declares the failure on the task's own status log. Teardown retires the count on
an ordinary release, which is only reachable once the work landed, and keeps it
under --force, so discarding between attempts cannot make the budget unbounded.

Compatibility with the two delivered dependencies, neither of which is on the
trunk yet, and neither of whose producers this duplicates:

- CFVC-11 (fm/cfvc-11-terminal-vocabulary, loopspecs/terminal-states.json):
  exhaustion terminates in that unified vocabulary's budget_exhausted rather
  than a third name. The regression asserts membership whenever that file is
  present and says plainly when it is not.
- CFVC-12 (fm/cfvc-12-task-outcome-failure): the status declaration uses the
  failed: verb that its terminal-outcome derivation reads, so exhaustion books
  outcome=failed through that owner instead of a second ledger producer. The
  three-member v1 outcome enum is untouched.

Brief rule 5's prose is deleted in the same change and the rule list renumbered,
with its numbered cross-reference updated.

* no-mistakes(review): validate default budget env, fix retire premise, document fm-attempt.sh

* no-mistakes(document): document tooling status-line producers in AGENTS.md state inventory

* test(herdr): normalize the per-run attempt count in the projection meta parity check

The projection parity case spawns ONE task id twice - opted out, then
projected - and byte-compares the two metadata files after normalizing the
fields that legitimately differ per run. The durable attempt counter adds
another such field: the second spawn of that id is attempt 2 by construction,
so the comparison failed on a real per-run fact rather than on a projection
difference.

Only attempt= is normalized. attempt_budget= stays compared, because the budget
is a property of the task rather than of the run, and projection must not
change it - normalizing both would have retired real coverage to silence one
line.

Caught by fork CI, not locally: this suite is real-herdr-gated and the task
brief carries no Herdr lab guard, so CI is its verification path here.

* fix(bin): spend an attempt on a recorded failure, not on a launch

CI proved the increment semantics wrong. The Herdr suite drives a same-identity
reclaim: a task whose session dies leaves an agent-free husk, and fm-spawn is
re-run with the same id to replace the dead endpoint while the worktree, branch
and work are preserved. Counting every launch charged that recovery against the
retry budget, so a task could not be brought back after two session restarts -
"fm-hibit-resume-r1 has spent its retry budget: 2 of 2 attempts used". That is
the same harm already avoided for secondmates and not applied to crewmates.

The contract now reads the spec literally: an attempt increments ONLY when the
prior attempt has a recorded FAILED terminal outcome. A spawn following no
recorded failure - dead runtime, husk, freeze, any recovery reclaim - is a
CONTINUATION: the count persists, does not move, and is never refused. The
outcome record draws the line, not the spawn event, which is exactly why this
increment depends on the terminal outcome that can say the prior attempt failed.
A wedged worker is no exception; relaunching one continues unless the wedge was
recorded as a failure first.

The failure signal is the `failed:` verb on the task's own status log - the same
declaration the ledger derives outcome=failed from, so this reads CFVC-12's
evidence rather than a private second signal. failures= records how many were
seen when the count last moved, so only a failure newer than the last counted
attempt spends the next one. This script's own budget-exhaustion declaration is
excluded from that tally, or one refusal would manufacture its own successor. A
refusal preserves the tally it found: it opens nothing, so it must not consume
the pending failure.

A forced teardown needs its own record. The discard ends the attempt AND deletes
the status log carrying the declaration, so teardown --force now calls
`fm-attempt.sh end`, which marks the attempt ended (ended=1). Without it a
re-dispatch after a discard would read as a continuation and discarding between
attempts would make the budget unbounded - the hole keeping the record closed.

Witnessed red first, as instructed: tests/fm-backend-herdr-presentation-e2e.sh
reproduces the exact CI refusal against the pre-correction tree and passes
after, 23 cases, with the default-session tripwire intact. The suite drives
Herdr only through the guarded lab helper, which refuses the default session by
construction.

* test(attempt): give the spawn cases the reason code trunk now requires

Trunk began requiring --reason-code on every ship and scout spawn after this
branch was cut, so the attempt suite's six ship spawns were refused before they
could publish a count. Each now passes NL_RULE_CLASSIFICATION, matching the
other spawn-driving suites.

The secondmate case is deliberately left alone: the reason code is refused on
--secondmate, which provisions a standing home rather than dispatching a task.
sbracewell64 added a commit that referenced this pull request Aug 10, 2026
…3) (#63)

* feat(bin): count task attempts against a durable retry budget

Nothing counted attempts before this: no state field, no metadata field, and
brief rule 5's "if you hit the same obstacle twice, stop" made the worker the
arbiter of its own retry budget, evaluated from a context that resets on every
relaunch. "Should this be retried?" is now arithmetic over a number on disk.

bin/fm-attempt.sh owns state/<id>.attempt (attempt=, attempt_budget=,
terminal=). Every ship or scout spawn checks the budget before it creates
anything and commits the increment when it publishes task metadata, which also
carries the count as attempt=/attempt_budget=. An absent field reads as attempt
1, so a task dispatched before this retries at 2 rather than restarting its
budget. Secondmates are exempt: their relaunch is unattended liveness recovery,
not a retry.

Exhaustion is a named stop, not a silent one. It records the terminal state and
declares the failure on the task's own status log. Teardown retires the count on
an ordinary release, which is only reachable once the work landed, and keeps it
under --force, so discarding between attempts cannot make the budget unbounded.

Compatibility with the two delivered dependencies, neither of which is on the
trunk yet, and neither of whose producers this duplicates:

- CFVC-11 (fm/cfvc-11-terminal-vocabulary, loopspecs/terminal-states.json):
  exhaustion terminates in that unified vocabulary's budget_exhausted rather
  than a third name. The regression asserts membership whenever that file is
  present and says plainly when it is not.
- CFVC-12 (fm/cfvc-12-task-outcome-failure): the status declaration uses the
  failed: verb that its terminal-outcome derivation reads, so exhaustion books
  outcome=failed through that owner instead of a second ledger producer. The
  three-member v1 outcome enum is untouched.

Brief rule 5's prose is deleted in the same change and the rule list renumbered,
with its numbered cross-reference updated.

* no-mistakes(review): validate default budget env, fix retire premise, document fm-attempt.sh

* no-mistakes(document): document tooling status-line producers in AGENTS.md state inventory

* test(herdr): normalize the per-run attempt count in the projection meta parity check

The projection parity case spawns ONE task id twice - opted out, then
projected - and byte-compares the two metadata files after normalizing the
fields that legitimately differ per run. The durable attempt counter adds
another such field: the second spawn of that id is attempt 2 by construction,
so the comparison failed on a real per-run fact rather than on a projection
difference.

Only attempt= is normalized. attempt_budget= stays compared, because the budget
is a property of the task rather than of the run, and projection must not
change it - normalizing both would have retired real coverage to silence one
line.

Caught by fork CI, not locally: this suite is real-herdr-gated and the task
brief carries no Herdr lab guard, so CI is its verification path here.

* fix(bin): spend an attempt on a recorded failure, not on a launch

CI proved the increment semantics wrong. The Herdr suite drives a same-identity
reclaim: a task whose session dies leaves an agent-free husk, and fm-spawn is
re-run with the same id to replace the dead endpoint while the worktree, branch
and work are preserved. Counting every launch charged that recovery against the
retry budget, so a task could not be brought back after two session restarts -
"fm-hibit-resume-r1 has spent its retry budget: 2 of 2 attempts used". That is
the same harm already avoided for secondmates and not applied to crewmates.

The contract now reads the spec literally: an attempt increments ONLY when the
prior attempt has a recorded FAILED terminal outcome. A spawn following no
recorded failure - dead runtime, husk, freeze, any recovery reclaim - is a
CONTINUATION: the count persists, does not move, and is never refused. The
outcome record draws the line, not the spawn event, which is exactly why this
increment depends on the terminal outcome that can say the prior attempt failed.
A wedged worker is no exception; relaunching one continues unless the wedge was
recorded as a failure first.

The failure signal is the `failed:` verb on the task's own status log - the same
declaration the ledger derives outcome=failed from, so this reads CFVC-12's
evidence rather than a private second signal. failures= records how many were
seen when the count last moved, so only a failure newer than the last counted
attempt spends the next one. This script's own budget-exhaustion declaration is
excluded from that tally, or one refusal would manufacture its own successor. A
refusal preserves the tally it found: it opens nothing, so it must not consume
the pending failure.

A forced teardown needs its own record. The discard ends the attempt AND deletes
the status log carrying the declaration, so teardown --force now calls
`fm-attempt.sh end`, which marks the attempt ended (ended=1). Without it a
re-dispatch after a discard would read as a continuation and discarding between
attempts would make the budget unbounded - the hole keeping the record closed.

Witnessed red first, as instructed: tests/fm-backend-herdr-presentation-e2e.sh
reproduces the exact CI refusal against the pre-correction tree and passes
after, 23 cases, with the default-session tripwire intact. The suite drives
Herdr only through the guarded lab helper, which refuses the default session by
construction.

* test(attempt): give the spawn cases the reason code trunk now requires

Trunk began requiring --reason-code on every ship and scout spawn after this
branch was cut, so the attempt suite's six ship spawns were refused before they
could publish a count. Each now passes NL_RULE_CLASSIFICATION, matching the
other spawn-driving suites.

The secondmate case is deliberately left alone: the reason code is refused on
--secondmate, which provisions a standing home rather than dispatching a task.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant