Skip to content

Add join = "settled": dispatch on any terminal status with the failure's evidence - #105

Merged
wseaton merged 12 commits into
mainfrom
settled-join
Sep 4, 2026
Merged

wseaton merged 12 commits into
mainfrom
settled-join

Conversation

@wseaton

@wseaton wseaton commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

A task's status was one bit driving four things at once: whether dependents run, whether its declared output and files flow anywhere, whether its workspace writes commit, and whether the run's verdict fails. A probe that ran correctly and measured a negative result could not both fail and keep its evidence, so packs paired every producer with an _ok evaluate gate. The backport pack in crucible-domains#479 has fourteen of them.

This adds join = "settled": a task dispatches once every dependency is terminal, whatever it settled as, unless the run has halted, and receives each dependency as {status, note, output, files}. all and passed are unchanged. A task may also settle itself failing with "status": "fail" in its result, so an agent turn can veto without an exit code.

Behind that, in order:

  • the epilogue stage actually schedules after the main graph and survives a short-circuit, and the first halt reason wins (from plan: preserve declared evidence from measured failures #101);
  • a failing attempt keeps its structured output, with guards so all/passed consumers and fan-out sources see no change;
  • the join itself: ir, DSL, executor envelope, capture of declared files on a measured failure by authorship (each declared path's content compared before and after the attempt, so nothing an earlier task left behind is misattributed), staging only into a settled direct dependent;
  • every epilogue task receives the outcome input C-PLAYBOOK-LANE has required: the shutdown token and one status entry per main-graph task;
  • validation refuses cross-stage dependencies in plan files and dependencies named after reserved inputs.

RFC-0002 gains C-SETTLED-JOIN; C-PLAYBOOK-LANE, C-TASK-FILES and RFC-0001 C-WORKFLOW are amended. Both RFCs are in spec.

This subsumes #101: capture_on_failure asks the producer a question that belongs to the edge, and it could not remove a single gate because it only fed epilogue tasks. The epilogue scheduling and halt-ordering halves of #101 are here as the first commit; the flag, its validation, its wire field and C-FAILURE-EVIDENCE are not.

Verified with the executor, harness, ir and DSL tests added per commit, cargo test --workspace (1666 passed), clippy on all targets, govctl check, the regenerated DSL reference, and the migrated backport pack: 29 tasks become 17 with no _ok, its 143 pack tests pass, and in the fixture-mode objection scenario deliver is blocked, report passes reading the failed review instance's captured evidence, and verdict ends the run invalid. The migrated pack is on branch backport-settled in crucible-domains.

Refs: WI-2026-09-04-001

The executor never read Stage, so an epilogue task was an ordinary
dependency-free task: it could dispatch ahead of the main graph on
declaration order alone, a required failure blocked it, a failure of its
own short-circuited the run, and it counted toward validity. It now waits
until every main-graph task holds a result, runs after a short-circuit,
never sets the halt reason itself, and is left out of the validity
computation. A cost or wall-clock ceiling still blocks it.

The pre-dispatch truncation gate was the last path by which an epilogue
task decided the verdict: a required epilogue task whose needs the
substrate could not satisfy truncated the whole plan, so nothing ran and
the run was invalid. The gate now looks at the main graph only, and an
unrunnable epilogue task settles skipped like any other. In the scored
lane, whose epilogue subplan holds no main-graph task, that turns a
whole-subplan truncation into per-task skipped rows.

Both ceiling checks overwrote whatever halt reason was already recorded,
so an epilogue crossing the budget after a required failure reported
budget-exceeded instead of the short-circuit. The first reason now wins.

Assisted-by: Claude
AttemptOutcome::Fail carries the object the attempt produced rather than a
bare note: an evaluate task's graded record with `pass` normalized to false,
a nonzero exit's last stdout line where it parses as a JSON object, and the
object a task emitted when it missed a declared field. Transport failures
keep no output. TaskResult.output is now populated on failure, and cli.rs
already copies it, so a failing task's reading reaches task_result.

Two guards ship in the same commit, because a populated failure output is a
silent regression without them. inputs_for selects on status before cloning
an output, with a mapped node exempt: fold_instances sets an output
unconditionally, so a fold that settled Fail is what join = "passed" over a
lossy fan-out reduces over, while a plain task's retained failure output
must not reach a consumer that cannot tell a reading from a verdict.
fanout_items requires its `over` source to have passed, or a mapped node
fans out over a list read off a discovery that failed; join = "passed" with
a passing sibling makes that reachable without any new join.

Assisted-by: Claude
A reporting task could not be reached on the path it reports on. `all`
blocks it behind the failure and `passed` forwards only what survived, so
a pack that wants a verdict written after a negative reading routes every
producer through a paired gate task that re-emits the reading as a pass.

`join = "settled"` dispatches once every declared dependency holds a
terminal result, whatever it is, and forwards each one as
{status, note, output, files} rather than as the output itself, so a
script switched from `passed` to `settled` fails at its first read
instead of consuming a negative reading as a positive one. A mapped
dependency carries a per_instance map keyed by item, present and empty
where the node produced none. It is a statement about dependency status,
not a scheduling exemption: a required failure, either ceiling, or a
truncated graph still blocks it.

Declared files follow the same edge. A failing attempt's set is captured
before the workspace rollback, published whole or not at all, and staged
only into a task that joins settled on it directly; staging a failed
grandparent's evidence into a consumer with no entry for it would read as
the grandparent having passed. On the failure path a declared path counts
only when that attempt wrote it, tested against the workspace's committed
state, so serial instances sharing a workspace and a declared path cannot
publish each other's readings. A task that settles skipped,
transport-failed, blocked, or truncated drops the set an earlier run left
under its name, so the envelope's `files` flag cannot outlive it.

`"status": "fail"` joins `"skipped"` as an engine-meaningful value of a
task's own reserved status field: an agent turn has no exit code and no
`pass` to grade. A runner settles the declared status before it acts on
the attempt, so a veto is a failure when its files are taken and not only
once the executor reads it, and the declared-field check runs after both.
Both values, with complete/reason, item, and kept, are now in a
reserved-fields table in the generated DSL reference.

`grade()` refuses the join at its call site, and top_k, the engine-owned
report, and engine operations refuse it at validation: their inputs are a
fixed typed context.

Assisted-by: Claude
Cover the settled envelope and the failure-file edge rule where the
existing tests stop: an epilogue task joining settled after a
short-circuit, a failed mapped grandparent's instances, a lossy join over
a failed direct dependency with a captured set, a null note, a settled
mapped node whose `over` source failed, the edge rule end to end through
the real harness, and the whole envelope as a task reads it out of
CRUCIBLE_INPUTS.

Assisted-by: Claude
git status calls an ignored path dirty, so an ignored file an earlier
passing task wrote was published as a later failing task's evidence and
staged into its settled consumer. Fingerprint each declared path in the
root the attempt runs in before it runs, and capture on failure only
where the path exists and its content changed. A passing attempt that a
missing declared file converts to a failure now keeps its own reading,
which the settled consumer needs and enforce_emits already retains.

Assisted-by: Claude
A node that settles blocked or skipped publishes no instance rows, so
drop_captured under the node's own name never reached state/files/<node>
[<key>] and an earlier run's instance evidence stayed on disk after its
producer went silent.

Assisted-by: Claude
The engine writes `item` and `kept` into a task's inputs after the
dependency envelope is built, so a dependency by either name lost the
entry the plan promised it: a mapped task joining settled over a
dependency called `item` saw the bare instance key instead. Refuse it in
Plan::validate, and at the DSL call site so the diagnostic carries the
line that named it.

Assisted-by: Claude
Paragraph 3 asserted a blanket "MUST settle blocked" for anything a
required failure left undispatched, which contradicts C-PLAYBOOK-LANE's
requirement that an epilogue task run when the main graph failed, and
contradicts the executor, which exempts epilogue tasks from a
short-circuit. The code is right; name the exemption and its limits.

Assisted-by: Claude
The reserved-result-fields table published `complete` and `reason` as
engine behaviour. Nothing in crucible, crucible-contract, or the packs
reads either: early completion is normative prose in C-PLAYBOOK-LANE
with no implementation, and the drift test tied only `status` to
DeclaredStatus::ALL, so it could not catch the two invented rows.

Assisted-by: Claude
The isolated-instance batch is the second place a ceiling crossed after
a required failure can overwrite the recorded halt, and it had no test.
An epilogue node may fan out over another epilogue task, which crosses
no stage, so the site is reachable: with the guard reverted the run
reports BudgetExceeded instead of the short-circuit that ended the main
graph.

Assisted-by: Claude
An epilogue task waits for every main-graph task to settle, so an edge
between the stages is a deadlock in either direction: the readiness pass
dispatches nothing, breaks, and the run reports Completed with zero
session rows and no diagnostic. WorkflowCfg::validate_stages catches the
shape on the manifest path, but `plan run --plan <file>` goes through
Plan::validate only and stage deserializes from the plan file.

Assisted-by: Claude
C-PLAYBOOK-LANE forbids an epilogue task from depending on a main-graph
task and requires it to receive the run's outcome instead, but the
executor injected nothing, so an epilogue task in the playbook lane had
no way to learn what it was reporting on. Every epilogue task's inputs
now carry a reserved `outcome` entry, built at dispatch, holding the
shutdown token dispatch stopped on and one {status, note} per settled
main-graph task. The shape is stated once, in C-SETTLED-JOIN, and
referenced from the lane clause; `outcome` joins `item` and `kept` as a
reserved dependency name and an epilogue task name.

Assisted-by: Claude
@github-actions

github-actions Bot commented Sep 4, 2026

Copy link
Copy Markdown

Docs preview for this PR is built and attached as the docs-preview artifact.

Download docs-preview, unzip, and open index.html in a browser.

Rebuilt for 22bcb8b.

@wseaton
wseaton merged commit a2e7984 into main Sep 4, 2026
4 checks passed
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