Add join = "settled": dispatch on any terminal status with the failure's evidence - #105
Merged
Merged
Conversation
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
|
Docs preview for this PR is built and attached as the Download Rebuilt for 22bcb8b. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
_okevaluate 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}.allandpassedare 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:
all/passedconsumers and fan-out sources see no change;outcomeinput C-PLAYBOOK-LANE has required: the shutdown token and one status entry per main-graph task;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_failureasks 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 scenariodeliveris blocked,reportpasses reading the failed review instance's captured evidence, andverdictends the run invalid. The migrated pack is on branchbackport-settledin crucible-domains.Refs: WI-2026-09-04-001