Skip to content

Gate a playbook on a human decision - #106

Open
wseaton wants to merge 11 commits into
mainfrom
approval-gates
Open

wseaton wants to merge 11 commits into
mainfrom
approval-gates

Conversation

@wseaton

@wseaton wseaton commented Sep 4, 2026 •

Copy link
Copy Markdown
Contributor

A pack can now write approve() as a task: run these tasks, wait for someone to approve, then deploy. Today the only way to ask a human for something is the scored loop's provisioning ask, which is one global wait that only an operator at a socket can end. A gate is a node with dependents, a verdict contribution, and a name.

A gate names what may resolve it. An operator on the run always can, over the control bridge; a gate may additionally name a pull request or a Jira issue, and whichever arrives first wins. Resolution is keyed by a trace id derived from the run and the task and is idempotent under it, so a retrying resolver, a second source, and a resumed process replaying a decision all converge on one recorded outcome. Granted settles the gate passing, denied and timeout settle it failing, and the lane's existing verdict rule does the rest — no new task status.

A run at a gate parks or suspends. Suspending writes the workspace and state dir to the ingest drop-box, reports outcome=suspended and exits zero; a later process restores them through a resume-restore init container and is handed the resolutions that were settled meanwhile. Parked time does not count against the wall clock.

Getting there needed the loop's state to be one thing. Three folds of the session log had grown up independently and a gate adds events all of them would have to learn, so the fold moves into the contract as one exhaustive apply, and the loop's decisions move out of the driver into a state machine with no I/O.

Reading order

The commits are meant to be read one at a time:

  1. a5243f6 contract fold — additive wire, no behavior change, parity-tested against the old folds
  2. 509c98d retire the wide tournament — pure deletion, −985 lines, nothing uses [search]
  3. 166b7c4 machine + host — the loop's decisions, testable with no fakes
  4. 2575038 dead-turn streak across a resume — a bound that had been counting per process
  5. dba1c74 the gate itself, plus the pod render and the governance
  6. 7b83eac an unrelated proposal (container isolation for deterministic tasks), proposed only, droppable
  7. c341800 draw the loop's state machine from the machine's own vocabulary
  8. 00d5f9f dispatch the head from a list the machine owns, so the chart's head is that list

The last two are why docs/loop-machine.md exists and why it cannot drift: machine::HEAD is the order the host walks, each check names its own edges in an exhaustive match, and the page regenerates from crucible loop-reference under a pre-commit hook. Adding a LoopExit, an IterStep, or a HeadCheck fails to compile until it is described, and then fails a test until it reaches the chart.

Scope

This is the engine side only. A suspended run needs a consumer that understands outcome=suspended, holds the pending decisions, and redispatches with --resume; until that exists, a run can suspend but nothing brings it back. Consumers should not adopt this contract version before they handle that outcome explicitly — the shutdown vocabulary is additive, and a consumer that falls through to its success path would mark a run that is merely waiting as finished.

Verification

cargo fmt --check, cargo clippy --all --benches --tests --examples --all-features, cargo test --workspace, and govctl check are clean. The gate has a three-node functional test (open a PR, gate on it, deploy) covering granted, denied, timeout, and an approve over a real control bridge.

Every consumer of state/session.jsonl scanned it its own way: ResumeFold for
counters, the recovery tail scan for crash classification, and the controller's
ingest enum. Add crucible_contract::LoopState with one exhaustive apply, a
classify() that reproduces the recovery precedence, and a resume_view() that
matches ResumeFold, so a new event kind is taught to one place.

Additive wire changes, WIRE_VERSION unchanged: an Unknown catch-all so a newer
writer never makes an older reader refuse a log, an optional envelope ts, task,
source and park on approval_wait, trace_id, by and source on approval_resolved,
a suspended shutdown outcome, and the run-workspace and approval-waits artifact
kinds. CONTRACT_VERSION 1.3.0.

The old folds stay in place for now. A parity module folds every recovery and
resume test log, and the run6 fixture, through both and asserts they agree.

Assisted-by: Claude
No domain pack sets [search], and every piece the wide round adds is a
second copy of something the deep loop already has: a second runner over
Row, a second phase to classify, a second filter in every fold, and a third
TaskKind dispatch site. The event-sourced loop refactor would have to carry
all of it.

Delete WideConfig, WideOutcome, run_wide_tournament, wide_template,
WideRunner, wide_propose, render_wide_prompt, the [search] manifest table
and its validation, --wide and --wide-keep, and the engine.measure_diff
operation. The died_in_wide_round classification arm goes with them; a plan
admitted before any iteration phase is an open plan like any other.

The wire stays readable: phase "wide" and died_in_wide_round remain legal
tokens because old logs carry them, and readers keep such rows out of the
deep loop's baseline and best as the resume fold always did.

ADR-0024 records the decision and supersedes ADR-0010; RFC-0001 C-SEARCH is
deprecated and the [search] rule leaves C-MANIFEST.

Assisted-by: Claude
The loop's decisions were threaded through one 800-line function together
with every effect they cause, and a resume rebuilt state through two more
scanners of the same log. Add crucible::machine: LoopCfg, RunState, Segment,
LoopExit and a Machine whose methods answer the head-of-iteration checks,
fold an iteration's outcome into a row, and do the keep/discard and budget
bookkeeping from plain inputs, with no I/O. loop_driver keeps the host: it
performs every effect in the order it always has and hands the results to
the machine, so the session log a run writes is unchanged.

Resume and crash classification now read the contract's LoopState fold;
ResumeFold and the recovery tail scan are gone, and the parity oracle that
proved the fold matched them goes with them.

The plan lane gains its resume fold: execute_from seeds a graph with the
results an earlier process settled, and a run that died inside a plan hands
the resumed iteration the passing pack tasks under the same plan, so they are
reported rather than re-dispatched (RFC-0002 C-PLAYBOOK-RESUME). Engine
operations always run again, since the world they produced did not survive.

Assisted-by: Claude
MAX_DEAD_TURN_ATTEMPTS bounds the attempts an iteration gets when a turn
never starts, but the counter lived in Machine and began at zero on every
construction, so it bounded attempts per process rather than per iteration.
A pod that burned two attempts and died handed its successor three more, and
a harness that cannot start never reaches the stall.

LoopState::dead_turns reads the streak off the log: trailing rows in the
infra phase, skipping a distressed row, which annotates the turn before it
rather than reporting one, so it neither ends the streak nor counts toward
it. The count travels on ResumeView into RunState, beside the rest of the
state a resume restores.

No wire change; those rows already carry phase "infra".

Assisted-by: Claude
A pack can now say "run these tasks, then wait for someone to approve, then
deploy". approve() is a task kind with dependents, a verdict contribution and a
name, rather than the scored loop's one global provisioning wait that only an
operator at a socket could end.

A gate names what may resolve it. An operator on the run always can, over the
control bridge or the controller; a gate may additionally name a pull request
or a Jira issue, and whichever arrives first wins. Resolution is keyed by a
trace id derived from the run and the task and is idempotent under it, so a
retrying resolver, a second source, and a resumed process replaying a decision
converge on one recorded outcome. Granted settles the gate passing, denied and
timeout settle it failing; the lane's existing verdict rule does the rest, so
there is no new task status.

A run at a gate parks or suspends. Suspending writes the workspace and state
dir to the controller's drop-box, reports outcome=suspended and exits zero; a
later process restores them through a resume-restore init container and is
handed the resolutions the controller settled meanwhile. Parked time does not
count against the wall clock. The pod gets a preStop that writes the stop file
and a 300s grace period, because ctrlc handles SIGINT only: without it a delete
SIGKILLs a parked run mid-gate.

ParkPolicy moves to the lib and CRUCIBLE_RESUME_OF to the contract so the
renderer can name what the engine parses. The wire stays at version 1: every
addition is an optional field or a new token.

ADR-0025 records the decision; RFC-0002 gains C-PLAYBOOK-APPROVAL, and
C-PLAYBOOK-LANE, C-PLAYBOOK-RESUME, C-PLAYBOOK-SHAPE and RFC-0001 C-WIRE are
amended to match.

Assisted-by: Claude
A command task runs sh -c in the loop pod with whatever toolchain the loop
image baked, so a pack needing another one either grows the shared image or
vendors a build.

Nothing new is needed to fix that. Isolation is a one-variant enum, the
sandbox module already moves a workdir in and named files out for agent
turns, ComputeDriver already picks nested podman or a sibling pod, and
emits/emits_files already fix the only channel out of a task, which is what
makes where it ran a substrate detail. The proposal is an optional image on
command and evaluate, dispatched as a second isolation variant.

Proposed, not decided, and sequenced after the gate stack: it wants the
pod-dispatch path the controller half is about to change.

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 00d5f9f.

CI runs clippy from a cold cache with -D warnings; a warm local cache had
been replaying stale clean results, so four disallowed anyhow::bail sites, a
needless clone, and a large_enum_variant went unseen.

The gate's failures become thiserror variants like every other module's:
NoBridgeReply, SuspendNeedsManifest, StoppedWhileParked, and the escaping
snapshot entry, the failed bundle, and the failed git run on SuspendError.

The approve task kind grew Task past the point where holding one inline in
the Starlark value enum was reasonable, so that variant is boxed, the same
way the loop's decided step already carries its payload.

Assisted-by: Claude
The loop's decisions became named types when they moved out of the driver, so
the state chart is now derivable rather than something to keep redrawing by
hand. `crucible loop-reference` prints it, scripts/loop-docs.sh writes
docs/loop-machine.md, and a pre-commit hook regenerates the page when
machine.rs changes, the same shape the DSL reference already uses.

Drift is a compile error, not a stale diagram. Adding a LoopExit breaks
shutdown_reason, adding an IterStep breaks step_label, and the coverage tests
then hold the new variant to reaching the chart. A third test walks the edges
from Head so a state cannot be drawn without a way to reach it.

The exits on the chart are the shutdown tokens the session log carries, so a
reader can match a node to a run's last line.

Assisted-by: Claude
Assisted-by: Claude
Move gate events, suspension, resume-state recovery, and compression onto
Host so callers invoke behavior through the type that owns gate-host state.
Document the domain-method convention and remove the attribution requirement.
The head's order of operations existed only as the reading order of
run_loop_body: park on a pending approval, drain a re-scope, drain a denial,
park on distress, check the interrupt, check the caps. Nothing named that
sequence, so the state chart had to assert it by hand and a reordering would
have falsified the page without failing anything.

machine::HEAD is now that sequence, as data. The host walks it and dispatches
each HeadCheck through one function that returns HeadFlow rather than breaking
a loop, which is what lets the order live outside the code that runs it. Each
check names its own chart edges in an exhaustive match, so the head of the
diagram is generated from the same list the host walks, and a new check
compiles only once it is described and dispatched.

run_loop_body goes from 814 lines to 689. The head's thirteen threaded
arguments become one HeadCtx. No behavior change: the checks run in the same
order, do the same things, and the 616 engine tests are untouched.

The iteration half of the chart is still a written table. IterStep and Settle
already name those transitions, but the host performs them inline; moving them
behind the same dispatch is the remaining half of this.

Assisted-by: Claude

This branch has not been deployed

No deployments
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