onepipeline 0.6.2

Execute a task DAG over oneagentgraph and onevcs, merging their event streams into one.
Documentation
# onepipeline contract

Composes oneagentgraph + onevcs, owns the task DAG, merges the three event streams.

Plan schema v2 = ai-orchestrator tracked-graph schema v7 node shapes (`agent` direct, lifecycle with `repo`, `kind: human`, nested `steps` on one branch, `expects_no_diff`, `context`, cross-DAG `run:<id>#<node>` refs, What/Why/Acceptance-criteria task prose, per-node `max_turns`), with: `repo` resolved through onevcs; new optional per-node `executor: NAME`; new optional `agent_graph: REF` overriding the default node-scope graph config; and **no `done_when`** — v7's judge-only bar is retired here, because onejudge hands that field to the judge verbatim and the criterion belongs in the onejudge document the node-scope graph's worker already points at, written once. A per-node bar is the task's own `## Acceptance criteria`, which the judge reads as the first message of the transcript it is given. A plan still carrying `done_when` is refused **by name**, and the refusal says where the bar goes instead.

`schema_version: 2` is that change: v1 declared a `done_when` and a `max_turns` that no dispatch ever received, so the two versions do not describe the same document and a v1 plan is **refused deliberately**, naming what moved and what to set. A v1 plan that still carries `done_when` is answered with the field's own refusal rather than the version's, because the field is the thing its author has to move. Every optional field is omitted from what this crate writes when it is empty, so a plan round-trips as the file wrote it.

Per-node **controls** — what a node declares about how its dispatch runs, as against what it is asked to do — reach that dispatch as node-scope overrides on its agent-graph launch, applied after the run-wide `--node-set`s: `max_turns` is the worker member's own turn ceiling. A control this build accepts and cannot apply refuses the plan at validation, and refuses the launch again where it is composed; neither path may fall back to a default. A control a dispatch could not run under is not representable on the dispatch at all — `max_turns` is a `NonZeroU32` there, and the checked conversion from the plan's own `Option<u32>` happens at the trust boundary, so `max_turns: 0` is refused where the plan is read rather than by the member that could not start. A node with `steps` declares its persona, task, and turn budget on them, and a `kind: human` or `expects_no_diff` node — which has no dispatch — declares none of the three.

`resume` continues a node on the branch its previous attempt preserved: `{branch, checkpoint?, completed_steps?}`. `branch` is the preserved branch. `completed_steps` names the steps that branch already carries, and a continuation skips exactly those and re-runs the rest — an absent or empty list re-runs the whole workstream, which repeats work but never skips it. `checkpoint` must be a commit reachable on the remote; a local-only revision is not a checkpoint, because the machine that continues the node is not the machine that made it.

Cross-DAG edges: a `run:<id>#<node>` dep resolves by reading the referenced run's ledger, and only a `node-settled` of `done` satisfies it. An unknown run, a node that has not settled, and one that settled `failed` or `skipped` all leave the consumer **blocked, never failed** — the upstream may still arrive. Resolution is re-read on **every reconcile pass**, so an upstream that arrives after its consumer was blocked starts that consumer on the pass that observed it rather than parking the run. On first resolution the consumer records how far the upstream had got (`cross-dag-satisfied`, `{dependency, last_seq}`); if the upstream passes that point afterwards the consumer reports it once (`upstream-modified`, `{dependency, captured_last_seq, observed_last_seq}`) and is **reported, not re-run** — the work was correct when it was done, and repeating it is the planner's judgement. `last_seq` is the count of records in the upstream's merged store, because a run is written by several processes and no single stream's `seq` describes it.

Execution is **continuous and deterministic**. There are no rounds: `onepipeline start` runs the engine's reconcile loop itself, and a node — and each step within a lifecycle node — dispatches the moment its dependencies settle `done`, with settlement triggering integration and publication immediately. The loop drains the planner's durable command queue, re-resolves cross-DAG edges, and re-derives the frontier on every pass. **No agent is required to use onepipeline**: deterministic code drives the whole lifecycle.

The only thing that pauses anything is a **decision point**, and it pauses only the subtree that depends on it. A blocking surface — a ready `kind: human` node's attestation, a completion request, any surface declared blocking — holds its dependents back while independent branches proceed, and clearing it (`attest`, `reply`) auto-resumes the paused subtree inside the running loop, with no external driver action. Non-blocking surfaces never pause anything. `decision-pending` is emitted when a blocking surface begins holding dependents back and `decision-cleared` when it releases them.

Driver contract: `onepipeline start plan.json [--attach|--detach] [--dag-graph off|REF] [--heartbeat-interval 1800] [--set PATH=VALUE]... [--node-set PATH=VALUE]... [--acknowledge-concurrent] [--launch-config FILE] [--filter-agentgraph SPEC] [--filter-vcs SPEC] [--filter-profile NAME=SPEC]...` executes the plan, holding the run ownership lock (single writer) in the process that drives it — attached, this one; detached, the driver it retains. `--dag-graph` defaults to `off`; given a REF it launches that dag-scope agent graph (shipped: `monitor` member + resettable-cron `check-in` member) as an **observer and channel author** — never as the driver, and no member of it runs an engine verb. Its judge side may still be `onepipeline channel serve RUN` as a command provider. Each repeatable `--set` is forwarded opaquely and in order to that dag-scope launch; each repeatable `--node-set` is forwarded opaquely and in order to every node-scope launch. Both lists are retained in the launch record and replayed by `adopt`. Attach returns when the run settles — the graph completed, a blocking surface waits with nothing else able to move, or exit 3 = nothing is driving the run. `awaiting-planner` means exactly that conjunction: an outstanding decision **and** nothing else able to move. An attached launch is running the loop, so it waits out every decision it can still make progress beside — independent branches keep going and a decision cleared while they do resumes its subtree inside that loop — and returns only when the run cannot advance without something arriving over the channel. A detached launch, and an attached one that has returned, hands the same state to `adopt`. What keeps driving after a launcher returns is **this executable**, never an installed sibling: a detached dag-scope launch retains `onepipeline drive GRAPH --task ... --dir ...`, which composes this build's own `oneagentgraph` so one build decides what a graph document may contain, and the detached run driver is `onepipeline drive-run RUN`, which takes the ownership lock, launches the observer graph the run declares — inside the process tree a `stop` reaps — and runs the same loop an attached launch runs in-process. Both are `hide = true`, absent from `--help` and from the surface list, and both are reached directly by `scripts/smoke-published.sh`, which is required of every hidden verb because a published artifact that cannot reach them cannot launch a detached run at all. `onepipeline adopt RUN` attaches a fresh driver to an intact ledger, resuming mid-decision from the folded state. It refuses a run something is genuinely driving; a run the liveness verdict has already called PARKED — or otherwise undriven — it may take over, ending that parked driver politely, waiting for it to go, and only then taking the lock, which it says on stderr and still has no `--force` for. Ownership: runs belong to the launching session; `runs --mine`; `stop` refuses another session's run and `--force` names the owner.

Before launch, every targeted repository is checked through `onevcs session holders REPO --json`. A live holder refuses the launch unless `--acknowledge-concurrent` is passed; that override remains visible on stderr and emits `concurrent-acknowledged` with the shared identities and runs. A stale holder is reported and does not refuse.

Channel (public contract): `onepipeline next RUN [--filter NAME|SPEC] [--all]`, `reply RUN [FILE]`, `surface RUN --kind check-in --message TEXT`, `attest RUN REF`, `stop RUN`. Surfaces and events are emitted as they occur. Reply envelope: legacy verdicts plus `{"version": 1, "commands": [...]}` with ops `add | drop | reparent | retry | cancel | requeue | attest | complete | context` — required fields and validation semantics exactly as ai-orchestrator's live-edit protocol (docs/orchestration.md#live-graph-edits): applied-or-rejected-with-reason, durable command queue, reply exit 0 = applied, 1 = accepted-not-yet-reconciled, 2 = refused/malformed. A reply carries an `author`, `planner` (the default) or `monitor`, and a **per-author op allowlist is enforced at reply validation**: `planner` may issue every op, `monitor` may issue `retry | requeue | cancel | context | add` only, and `complete`, `attest`, and `drop` are refused for the monitor with a reason — as is a commandless verdict declaring completion, which says what `complete` says in a field rather than an op. Every accepted monitor edit is additionally surfaced non-blocking to the planner. A `retry` naming neither `branch` nor `resume` **inherits both** from the node it supersedes — a node that ran, committed, and stopped leaves work on a branch, and its own settlement pins that branch to it, so the replacement continues it rather than cutting a fresh one beside committed work nothing points at; a `retry` that names either is answered with what it named. The superseded node leaves the graph in the **same edit**, which emits `node-dropped` — a cancelled node left in a continuous graph holds the run in `waiting` for ever — and what became of it stays in the run's record as its own `node-settled` and the `edit-committed` that replaced it. A `context` note carries exactly **one dispatch**: it attaches to the node's next dispatch and is consumed on delivery. It carries one further optional field, `deliver: auto|live|next`, defaulting to `auto`: `auto` delivers the note into the node's running turn when it has a controllable one and otherwise attaches it to the next dispatch, `live` refuses with a reason when it cannot deliver into the running turn, and `next` is the next-dispatch behaviour explicitly. Anything else is refused. Live delivery is `oneagentgraph interrupt RUN MEMBER --input`, addressed by the graph run and member the dispatch's own relayed envelopes stamp; that verb's exit 3 — no controllable turn in flight — is the `auto` fall-through and the `live` refusal and is not an error, while a delivery that was attempted and failed is refused under both. A note the running turn took is not also owed to the next dispatch, and `edit-committed` records which happened as `delivery: live | deferred` on the `context-added` operation it compiled. Surface consumption triggers `oneagentgraph reset-timer RUN check-in` — the whole pacemaker-reset contract.

Merged stream: envelope NDJSON, one store per run, interleaving the three sources `pipeline`, `agentgraph`, `vcs`. A relayed envelope keeps its producer's own `stream`, `seq`, `source`, and kind, so a sibling's kind is a wire string this library never rejects. A lifecycle node's `onevcs` session is **followed** — `onevcs events TOKEN --follow` — from the moment there is a token until the session closes, so the gate run, the push, the change request, the check polling, and the merge reach the store while they happen rather than in one batch at settlement; each session envelope is stamped with the node it belongs to, which its producer cannot know, and an enricher never rewrites a key the producer stamped. A follow that never started, or that neither ended cleanly nor relayed a record, falls back to reading the stream once. This library's **own** kinds are a closed set — the `PipelineKind` enum, which is what emits them — and exactly these: `run-started`, `node-ready`, `node-dispatched`, `node-settled`, `edit-committed`, `edit-rejected`, `planner-surface-queued`, `planner-surfaced`, `planner-replied`, `human-attested`, `driver-adopted`, `run-stopped`, `quiet-worker`, `decision-pending`, `decision-cleared`, `cross-dag-satisfied`, `upstream-modified`, `completion-requested`, `concurrent-acknowledged`. `edit-committed` carries the `author` that submitted the edit. A dispatch that produced **nothing** and failed is re-asked, and only that one — an attempt that answered has already answered — and each attempt is recorded as its own `node-dispatched` carrying `attempt`, `attempts`, and the bounded reason the previous attempt gave, so a reader counts dispatches per node to see a retry while the settlement still distinguishes `no-agent-progress` from `task-failed`. The envelope's reserved `round` label is **deprecated and no longer stamped by anything this library writes**; it survives on the duplicated cross-repo envelope type because the sibling libraries still declare it.

<!-- llmlint: ignore[contracts_have_one_source_or_a_drift_gate] the duplication is the
approved contract's own mechanism rather than a missing gate, and it cannot be closed
from inside one of the three repositories. The envelope this paragraph names is under the
same decision and for the same reason — three independently-released tools would have to
co-version to share a crate — and the mechanism that holds them together is the one named
here: the grammar committed in *this* document is the source, and `tests/contract.rs`
drives the `filters:` fixture below through `src/filter.rs`'s own types, extracted from
this file rather than restated beside it, so a copy that stops matching this text fails
`just check`. `oneagentgraph` and `onevcs` carry the same text and each run the same gate
against it; the cross-repository half is the contract owner reading one committed
grammar. A shared artifact here would be exactly the shared source the contract refuses. -->

**Filtering is owned by the stream's source.** Consumers have different attention budgets, so each producer takes a filter and narrows what it emits, rather than every consumer downstream re-filtering the same firehose. One grammar across the stack: `{include: [matcher, ...], exclude: [matcher, ...]}`. A matcher's fields are all optional and conjoin — every field it names must hold — and they are `source` (exact equality), `kind` (a glob over the kind's kebab-case wire string, where `*` stands for any run of characters including none and every other character is itself), and the reserved labels `run_id`, `node`, `step`, `member`, `persona` (exact equality; a matcher naming a label the envelope did not stamp does not match it). An absent or empty `include` admits everything; a match in `exclude` always rejects, whatever `include` said. `stream` is deliberately not matchable — it names a producing process rather than anything a consumer means — and neither are payload fields, nor the deprecated `round`. A **relayed** envelope's `kind` is matched as the wire string it arrived as: a sibling library's kind is not the reading library's own set, and is never rejected for being unknown. Like the envelope, the filter type is duplicated per repository by design and held together the same way: the grammar committed in this document is the one source, and each producer's own contract test drives the example below through its types. A spec is **external input** and is refused where it is read, naming the matcher it is about — which list, and which position in it: a field that is not a matcher field is named in the refusal, as is a matcher naming no field at all (it matches every envelope, so one in `exclude` silences the stream entirely) and one whose field is empty (nothing on the stream carries an empty kind or an empty label).

A launch declares a `filters:` block, in a **launch config** — `onepipeline start plan.json --launch-config FILE`, a YAML or JSON document of its own — and equivalently on the command line, as `--filter-agentgraph SPEC`, `--filter-vcs SPEC`, and repeatable `--filter-profile NAME=SPEC`, each `SPEC` a path to a file holding a filter or the document itself inline as JSON. The config is the base and each flag overrides the part of it that it names: the two source filters wholesale, and a profile **by name**, so a config holding a team's profiles beside a plan can have one replaced for one launch without restating the rest. The resulting block is retained in the launch record and replayed by `adopt`.

Launch config (YAML or JSON, by path):

```yaml
schema_version: 1                     # this build reads 1; another number is refused by it
filters:                              # optional, and omitted from what this crate writes when empty
  agentgraph:                         # forwarded to every oneagentgraph launch this run starts
    exclude: [{kind: turn-activity}]
  vcs:                                # the typed value every followed onevcs session is read through
    include: [{kind: "gate-*"}, {kind: session-closed}]
  profiles:                           # named read-time views; planner and monitor ship and are overridable
    planner:
      include: [{source: pipeline}]
    monitor: {}
```

The config is **external input**: an unknown key is refused by name rather than dropped, a `schema_version` this build does not read is refused by its number, and a filter it carries is refused by the grammar's own rules above — all of it before a run is minted, so a launch that could not be honoured never cuts a session. A document declaring only `schema_version: 1` is a launch that says nothing about its events, which is what a launch naming no config already means; the block is omitted from what this crate writes when it is empty, so a config round-trips as the file wrote it and a launch record from a build predating the block still reads.

The filter block itself has two halves, and they are not the same thing:

**Source filters** decide what enters the merged store, and are passed through to the library that owns each stream: `filters.agentgraph` reaches every `oneagentgraph` launch this run starts — the observer graph and every node-scope dispatch — as that binary's own `--event-filter` / `events.filter`, and `filters.vcs` is the typed value the linked `onevcs` `EventStream` filtered constructor takes for every followed session, and for the read that covers that follow's tail. Declared once at launch, so a run also stops paying to relay events nobody will read. A launch naming no `filters:` block ingests exactly what it always did.

**Read-time profiles** shape what one reader is shown and **never touch the store**: `onepipeline next RUN [--filter NAME|SPEC] [--all]` and `onepipeline monitor RUN [--filter NAME|SPEC] [--all]`, where a bare name selects one of the run's profiles, a spec is read inline or from a file, `--all` is no profile at all, and naming neither is the shipped `planner` profile. `next` reports its shaped event view beside the surface it claimed. A profile that names no profile this run has is refused, naming the ones it does. Shipped: `planner` is `include: [{source: pipeline}]` — every pipeline-level event, with the detailed `agentgraph` and `vcs` activity behind them left out — and `monitor` is unfiltered; both are overridden by declaring a profile of that name. A profile shapes the **event view** and nothing else: which surfaces exist and the unread-surface accounting over them belong to the channel, so a blocking surface is delivered under every profile.

Executor seam:

```rust
pub trait Executor {
    fn name(&self) -> &str;
    fn capabilities(&self) -> Capabilities;      // { vcs_sessions: bool, ... }
    fn capacity(&self) -> CapacityReport;        // { slots_free, load1, mem_free_bytes }
    fn dispatch(&self, req: DispatchRequest) -> Result<Box<dyn DispatchHandle>>;
}
pub struct DispatchRequest {
    pub graph: ConfigRef,                        // content-addressed node-scope agent-graph config (oneagentgraph type)
    pub task: String,
    pub labels: Labels,                          // reserved: run_id, node, step, persona
    pub controls: NodeControls,                  // the node's own dispatch controls: { max_turns: Option<NonZeroU32> }
    pub workspace: WorkspaceSpec,                // Path(PathBuf) | VcsSession(SessionRequest: onevcs type)
    pub cancel: CancellationToken,
}
pub trait DispatchHandle {
    fn events(&mut self) -> EventStream;         // envelope NDJSON relayed from wherever it runs
    fn wait(&mut self) -> Result<DispatchOutcome>;
    fn cancel(&self, mode: CancelMode);          // Cooperative | Kill
}
#[non_exhaustive]
pub struct DispatchOutcome {
    pub succeeded: bool,                         // the settlement itself: a stream of turns does not carry it
    pub detail: String,
    pub session: Option<String>,                 // the executing machine opened it, so it hands the token back
    pub branch: Option<String>,
}
```

`WorkspaceSpec::VcsSession` means the machine running the dispatch opens the onevcs session there — so the request carries `onevcs::SessionRequest`, the *ask*, and never an opened `onevcs::Session`; v1 ships `LocalExecutor` only (supports both variants), the trait + rules grammar are shaped for WS dispatch-server and k8s executors. `DispatchOutcome` is `#[non_exhaustive]`: naming a further field later is additive.

Executor rules (YAML, ordered predicates over capacity + node labels):

```yaml
executors:
  - {name: local, type: local, max_load1: 8.0, min_free_mem: 2GiB}
rules:
  - when: {executor_has_capacity: local}
    use: local
  - use: local
```

`min_free_mem` is carried as the string a rules file wrote it as, so the file round-trips; the units are exactly `B`, `KiB`, `MiB`, `GiB`, `TiB`, and a bare byte count. Any other unit is refused when the rules file loads, naming the executor and the list — read leniently an unreadable limit means *no limit at all*, so the one file written to keep dispatches off an exhausted host would be the file that removed the bound.

`when` is a mapping, and there are exactly two predicate families in it. `executor_has_capacity: NAME` matches on **capacity**: the named executor's `CapacityReport` against the limits its `executors` entry declares. `node_label: {KEY: VALUE, ...}` matches on the **node's labels** by exact string equality, never a glob or a pattern. The keys it may name are the reserved ones that exist when the choice is made — `run_id`, `node`, `persona` — because an executor is chosen once per node, before any of its steps run; `step`, `round`, and a free-form extra are refused at load rather than left as a condition nothing can satisfy. Several conditions in one `when` conjoin: all of them hold or the rule does not fire. A `when` naming neither family is refused at load rather than read as an always-true rule. A rule with no `when` at all is the fallback, and the first rule that holds decides.

Views (CLI): `runs`, `status`, `host`, `monitor RUN [--filter NAME|SPEC] [--all]`, `results`, `goals`, `transcript RUN [NODE]`, `telemetry [--breakdown]` — unread-surface accounting, driver liveness (DRIVER DEAD vs PARKED vs UNDRIVEN), provider-health block sourced from `oneagentgraph health`. No view reports a round: the read model is the continuous graph state folded from the journal, and the ledger records it as one `result.json` per run, rewritten whenever a driver closes out. `status` reports, per in-flight node, **what its dispatch is doing now**, how many events it has recorded, and how long since the last one, read from the `turn-activity` summaries `oneagentgraph` emits from both member kinds. `transcript` renders a dispatched turn's tools from those same summaries and its words from the onejudge report a `member-settled` retained at `report_path`. That report is **copied into the run's own storage as the settlement is ingested** — from a process this library started, refusing anything that is not the producing library's own plain file, of its own name, within a bounded size — and every reader afterwards opens only that copy, at a path derived from the settlement rather than taken from it. A settlement whose copy the run does not hold is named as unretained, and the path it claimed is printed and never opened. `telemetry` carries per-party `usage` — `agent`, `judge`, `llmlint`, `total`, each with `input`, `output`, `cache_read`, `cache_write`, and `cost_usd` — and eight WALL buckets that sum exactly: `agent`, `judge`, `llmlint`, `gate`, `publication_wait`, `lock_wait`, `setup`, `scheduling`. Where two nodes are doing different things across one millisecond it is named by the more specific of the two, which is what keeps gate time and lock waiting separable from agent time. A bucket or a party nothing in the stack measures is served **absent**, never as a zero that reads as measured.

Shipped content: personas `monitor` (at `personas/orchestrator.yaml`, the shipped file the orchestrator persona was rewritten into), `check-in`, `pr-author`; the dag-scope agent-graph config; a default node-scope config (worker+judge); example plans. The `monitor` persona is an **observer**: it reads the detailed stream, compares activity against the run's goal, each node's task prose, and the graph definition, surfaces issues, opportunities, and anomalies non-blocking, self-applies clearly intent-preserving fixes as attributed live edits within the monitor op allowlist, escalates ambiguity rather than acting on it, and never authors target-project content. pr-author composition: one post-verification dispatch drafting the ChangeSpec title/body from the diff; drafting failure falls back deterministic and never blocks publication.