onepipeline
Execute a task DAG over oneagentgraph
and onevcs, merging their event
streams into one.
onepipeline is the composition layer. It executes a plan — a dependency graph
mixing direct agent nodes, repository lifecycle nodes, and explicit human actions
— continuously, dispatching each node the moment its dependencies
settle, through a pluggable executor seam, and keeps a live channel open to
the planner supervising the run. The plan itself lives in
onetaskgraph: a run is
launched by naming a project of whichever backend you already track work in, so
a plan is something you can open, edit and share without this harness in the
loop. The
agents come from oneagentgraph; the clones, worktrees, publications, and
change requests come from onevcs. Nothing here verifies a change: that is the
repository's own merge path — the host's required checks where a change
publishes remotely, and the repository's pre-push hook at the publishing push
where it publishes locally. Dependency direction is one-way: neither sibling
depends on this crate.
The public types, traits, config schemas, and CLI surface are the approved
contract in docs/contract.md, compiled — and implemented
behind it. onevcs is linked and called: sessions, publication, and a
session's event stream are library calls, so what a publication did is a typed
value rather than a line of prose to parse. oneagentgraph is still run as a
CLI, so a build of it that refuses will make the dispatches this crate starts
refuse too; the composition layer itself is complete.
Install
To install a revision that has not been released yet — which today is every revision, since the crate depends on its siblings by git and a git dependency cannot be published — build it from the repository:
The package name is not optional: cargo install --git searches the whole
repository, and this one also carries the onepipeline-testfakes test harness,
so an unqualified command fails with multiple packages with binaries found.
Prebuilt archives for Linux (x86-64, arm64), macOS (Intel, Apple silicon), and
Windows (x86-64) are attached to every release, with sha256 checksums.
How the npm launcher is published
npm install -g onepipeline-cli installs a launcher that carries no binary. The
binary lives in a per-platform package the launcher pins to an exact version and
npm installs as an optional dependency — and an install silently skips an
optional dependency it cannot resolve, leaving an onepipeline on PATH with
nothing to exec.
npm publish exiting 0 means the registry accepted the upload, not that it
serves the version. The gap was measured at 75 seconds on one release and
around eight minutes on another. So the order a release publishes in is not the
order the registry becomes able to answer in, and offering the launcher on the
strength of five exit codes is what put a broken install in front of users for a
window after each release.
scripts/publish-npm.sh therefore asks the registry rather than the exit code:
it does not return until the version it just published is served, and it refuses
to publish any package before the exact versions that package's own manifest
pins are served. verify-npm then installs and starts the launcher on every
platform the manifest declares a package for, so a release that would ship that
window is red rather than green.
Release outcome
Every release attaches release-outcome.json to its GitHub Release. It
answers one question in three states, so a consumer can tell a publication that
was verified from one that was not without opening a job log:
nothing-shipped— no publish job ran, so no artifact can have reached a registry. A publish that failed does not say this: it may have published part of what it was given, so it reads asshipped-unverified.shipped-unverified— artifacts are public, and installing one of them was not proven to work.shipped-verified— every artifact taking part in the release published and installed and ran on every platform it declares a package for.
Ask for it by URL. No credential, and latest needs no version:
Each id is the registry-qualified identifier
release-targets.toml declares and
scripts/release-probe.sh answers for, so the two are asked in the same
vocabulary — the probe says which version a registry serves, and this says
whether anything proved it works. schema_version changes only alongside the
golden records under npm/test/golden/.
The middle state is the one that matters, and it is why verification is not a workflow of its own: a workflow whose green meant only that the publishing steps exited without error would have been green for every release whose npm launcher could not start.
Where a plan lives
A plan is one onetaskgraph project, and a node is one task in it. A run is launched by naming that project's qualified id:
Which backend that store is — a folder of Markdown, Linear, GitHub Projects — is onetaskgraph's own configuration, discovered from the directory you launch in:
# onetaskgraph.yaml
sources:
plans:
plugin: local-md
config:
examples/plan-store/ is a complete store of that shape, holding the two example
plans this repository ships. Nothing here special-cases a remote source, so a
local-md project runs directly — author locally, run it, and copy it up only
when it should become durable.
The mapping is docs/contract.md's, and it is one rule per
field: the plan-level settings (schema_version, goal, name, concurrency)
are reserved onepipeline.<field> metadata keys on the project; a node's id
is onepipeline.id on its task; its prose is the task's content, its title the
task's title, and its repository the first of the task's repositories; its
dependencies are real onetaskgraph dependency edges; and every other node field
is onepipeline.<field> carrying the same JSON value a plan document carried
under that name. One task of the example store:
title: "feat: implement approved release"
project: "tracked-release"
repositories:
- -
onepipeline drives the onetaskgraph binary rather than linking the crate, so
one has to be installed: from ONETASKGRAPH_BIN when that names one, and from
onetaskgraph on the PATH otherwise. Its version is checked before anything is
dispatched, and an absent, unusable, or too-old install refuses the launch —
naming the path, the version, the minimum, and how to install one — rather than
becoming a run that fails on its first node.
A plan is checkable before it is launched, and the check is the engine's own
loader — every refusal start makes before it dispatches anything, and no other
rule — so a consumer never has to re-implement it:
Each repeatable --check <PATH> names an executable, resolved against the
directory the verb ran in. It is handed the loaded plan as one JSON document
on its stdin — every default resolved, each node carrying its task's own metadata
map verbatim — with ONEPIPELINE_PLAN_CHECK_SCHEMA=1 in its environment, and
answers on stdout with {"refusals": [...]} and exit 0, node and field
present on each and null where it is about neither. Engine refusals come first
and carry "source": "engine"; each check's follow in the order its flags were
given, under the path as it was given, and --json prints them as one object
carrying project, accepted, refusals and unrunnable, always all four.
Exit 0 is the loader and every check accepting, 1 is at least one refusal
from either source, and 2 is a project that could not be read or a check that
could not be run — which is reported separately from a refusal and never read as
an accept. A loader refusal short-circuits: there is no loaded plan to hand a
check, so each is reported as not run.
The run's own record does not move: the journal, the ledger, and the graph a run is executing are still this crate's, projected from that journal under the run's ownership lock. Node status, settlement metadata, and accepted live graph edits are projected back onto the onetaskgraph project in the background. A failed write is reported and retried — unless the store refused it, which is reported once and attempted again when the run's graph next changes; it never changes execution or an edit ruling.
What it does
start drives the run itself: a node — and each step within a lifecycle
node — dispatches the moment its dependencies settle, and settlement triggers
integration and publication immediately. No agent is required. The only pauses
are decision points: a ready kind: human node, or any surface declared
blocking, holds back the subtree that depends on it while every other branch
carries on, and clearing it with attest or reply resumes that subtree inside
the running loop.
--dag-graph REF attaches an agent graph as an observer — the shipped one is
a monitor member that watches the stream and raises what does not line up, plus
a resettable-cron check-in member that surfaces a status when nobody has
reported one for a while. It never drives the engine. Attached, start returns
when the run settles; exit 3 means nothing is driving the run, and
onepipeline adopt RUN attaches a fresh driver to the intact ledger, and takes
the same --attach/--detach pair start does: detached, it prints the launch
record and returns once the driver it retained has the run, so recovering one run
does not hold the session supervising the others.
A lifecycle node states the title its change request opens under, and may state
its body too. --pr-author-graph REF names an agent graph that drafts that body
instead, from the branch's own diff, once the branch is verified and before the
change request is published; naming none is the default, and a drafting dispatch that
does not get there costs the change request its body and nothing else. A worker may
open its session's change request itself, as a draft, while it works — onevcs publish $ONEVCS_SESSION --draft, the token every dispatch in a session carries —
and the closeout then finishes that change request rather than opening another:
the drafter is shown it and told how to read the worker's transcript
(onepipeline transcript RUN NODE, which every dispatch's ONEPIPELINE_RUN_ID and
ONEPIPELINE_RUNS_DIR serve), the drafted description is written onto it under the
plan's title, and the same publication lifts the draft and lands under the
resolved policy. The node settles on the publication's own word, and its detail
says the worker opened the change request as a draft and the closeout marked it
ready. A node declared draft: true leaves its change request as a draft for a
person instead — opened as one, or held as one — and settles done with outcome
change-draft; its dependents proceed. It costs no
visibility either: a drafting dispatch that was configured, attempted, and produced
no body is recorded against the node under one of three endings — dispatch-failed
for one that could not be run or ran without succeeding, schema-refused for one
whose every answer the schema rejected, and no-body for one that answered inside
the schema and put nothing in it, which are three different fixes — and the node's
own settlement says the same thing, so results shows it. Naming no graph and
writing the body yourself spend no dispatch and are not reported.
A publication that fails does not always finish the node. onevcs says which
failure it was, and five of them settle under a word of their own — checks-failed
for a required check the host reports concluded red, checks-unsettled for a bound
that elapsed with the change still outstanding, push-rejected for a push the merge
path refused, sync-conflict for a base that moved under the publication, and
pushed-unverified for a push that reached the remote with the merge path unreadable
behind it. The first four leave the rejected tree on the branch the session handed
back, so the node is dispatched again on that branch, with no step
recorded as completed and
with the failure's reason and the id of every artifact its publication recorded
delivered as that dispatch's own context — the worker meets the diagnosis, on the
tree that has to change. pushed-unverified is answered differently, because
nothing about its tree was rejected: the work is already on the origin, so the
merge path is read again — bounded by ONEPIPELINE_MERGE_PATH_READS, three by
default — rather than the agent re-dispatched for a fresh clone and a fresh gate to
re-push what the remote already carries. A verdict that arrives during those reads
settles the node; reads that never get one settle it failed saying where the work
is, what commit it is at, and what stopped the read. Everything else settles
publication-failed as it always
did and is not retried: the repository's own gate, a request refused at a trust
boundary, and a seam with no implementation behind it all answer the same way
however many times they are asked. The loop is bounded by
ONEPIPELINE_PUBLICATION_ATTEMPTS, three by default, and a node that spends it
settles failed under the last failure's word, saying how many attempts were made
and what each one ended with.
A lifecycle node whose branch is level with its base — every commit on it one
the base already carries, and nothing left in the worktree to commit — has no
change-request body drafted and nothing published, and settles on whether this
dispatch wrote a commit rather than on the ahead-count alone. A node that declared
expects_no_diff settles done as no-changes, the success that word is
documented for; a branch the base already carries — a worker that landed its
commit on the base itself — settles the same way under either declaration; and a
worker that was asked for a change and committed nothing settles failed as
empty-branch, naming the branch and what it was compared against, so a manager
decides: a retry carrying expects_no_diff: true accepts it as the declared
success, and an amended task sends it back to produce a diff. Settled done as
no-changes, as it used to be, a plan that omitted the declaration was invisible
in every view. A branch that is ahead and whose tree the base already carries
still reaches onevcs's own NothingToPublish at publication and settles
no-changes as it always did.
A dispatch that ends for a reason that is not the agent's verdict on its task
settles dispatch-died rather than task-failed: a rate limit twenty seconds after
the final report, a harness that lost its credential, a run root deleted underneath
a live turn. The word is chosen by classifying the failure's own detail and never by
inspecting the branch, so a dispatch that died holding finished work and one that
produced nothing at all reach the same word. The settlement carries cause — the
producer's own classification, rate_limit, quota, auth, spawn-error — and
head, the commit the node's branch was left at, and results and status say in
one sentence that the branch may carry finished work and name that commit. It is not
infrastructure-failure, which is the dispatch layer refusing before any work
began and is retried for exactly that reason.
Where the producer's own liveness rule was provider-failure the word narrows to
provider-failed, carrying the same cause, branch and head: a node whose
provider went is a node with nothing wrong with its work, and task-failed sent
the reader looking for what the work got wrong. That classification is reconciled
against the record of the turn it names before anything acts on it — a turn that
opened, closed, and was billed says the dispatch produced what the death says it
did not, and where the two disagree the record wins and the node is not settled as
a provider death. Not in the approved contract yet: open divergence 49.
A provider death the producer could not classify — the provider rule on a
cause of unclassified — is where an identity chain stops: a candidate that
refuses to run is stepped past, and the producer says so, but one that ran and
produced no usable result stops the chain there and the identities behind it are
never tried. That is raised on the planner channel as a non-blocking finding
naming the candidate the chain stopped at, the candidates it stepped past with
each one's reason, and what the death said — for a node's dispatch and for the
run's observer graph alike, whether the run was launched attached or detached.
It fails nothing: a node settles on the death exactly as above, and the observer
is restarted as it always is.
Every node dispatch carries ONEPIPELINE_NODE_SCRATCH_DIR in its own
environment: an absolute path to a directory that exists and is writable before
the dispatch's first turn, unique to that dispatch — a retry, a requeue and a
resumed pin of the same node each get their own — and never removed while that
dispatch runs. Nothing beyond that is promised, and the spelling of the path least
of all: no consumer may derive one path from another. Not in the approved contract
yet either: open divergence 48.
The planner supervises over the channel:
Every edit is applied or rejected with a reason: reply exits 0 when the
reconciler applied it, 0 when it is accepted and still queued — durable,
waiting for something to drive the run, and never an instruction to send again,
which the reply says in words on stderr — and 2 when it was refused. A
non-zero status from this verb is a rejection to correct.
Two of those ops reach a node that is already running, and they are deliberately not the same lever:
A note is delivered into the node's live conversation, to whichever party of
it is speaking, and the other party receives it with that party's response — so a
correction reaches the worker and its judge alike rather than one of them. Whose
task it updates is named rather than inferred, it may bind a criterion the judge of
that conversation then decides against, and a note that would reach nobody is
refused rather than accepted quietly. A note no running turn took is carried to the
node's next dispatch, rendered there under ## Planner context, saying of itself
that it reports observed state and adds no acceptance criteria, and consumed when
that dispatch takes it — so beyond the one conversation it reached, a note does not
change what the node is judged against.
Its field set, each field's default, the four combinations of its two delivery
axes, and the dispositions it answers with are declared on
onepipeline::channel::Command::Note, and nowhere else in this repository — one
declaration a consumer reads rather than a second copy here that could drift from
it.
An amend does change that: its text becomes part of the node's effective
task, rendered under ## Amendment above the task's operational notes and claiming
precedence over them, so the worker and the judge reviewing it read the same ruling
— on the dispatch that follows it and on every later one, until another amend
replaces it. A turn already in flight is not reached: its task was composed before
the ruling existed, and so was the one its judge reads. A node's current amendment
is readable from status and from results before anything replaces it. That is
the lever for a correction that has to reach the live turn and still bind a
re-dispatch, which a note deliberately does not do: reaching a running turn and
being carried to the next dispatch are mutually exclusive.
There is one manager-note op. The context op this crate used to carry was the
same delivery with fewer fields, and shipping both is what let a manager's approval
reach a worker and never its judge; an envelope still naming context is refused
by that name. Both note and amend are the planner's; an observing monitor may
issue neither, because moving a bar — or binding one against the conversation
running now — is a decomposition decision rather than an observation.
A launch may also name a node validator — a command of the host's own, which
every op that introduces or changes a node's task (add, retry, a requeue
whose amendment touches task, and amend) is offered the resulting node to, as
JSON on its stdin. Exit 0 accepts the edit; a non-zero exit refuses it with the
command's own stderr as the reason. It is named by --node-validator COMMAND, by
ONEPIPELINE_NODE_VALIDATOR, or by a launch config's node_validator, in that
order of precedence; naming none is the default and runs no validator at all.
A launch may also name an envelope reviewer — a second command of the host's
own, offered the whole reply envelope once, after every command in it has passed
this crate's own validation and the node validator above and before any of its
edits is committed. One JSON document crosses its stdin: the run's goal, every
node the envelope introduces or changes with the op that produced each, and the
plan they are being edited into, as the envelope leaves it. That is the review no
per-node check can make — two added nodes that duplicate each other, a contract
seam between two nodes of one edit, the dependency edges the edit introduces, or
whether the edited graph still delivers the goal. Exit 0 accepts it; a non-zero
exit refuses the whole envelope, so no command of it half-applies, with the
command's own stderr as the reason and every op and node the envelope carried
named beside it. The refusal also names the node the reviewer objected to,
which is not the same set: the reviewer declares it on a line of its stderr
reading objection: cover — one line per node, repeatable — and a reviewer that
declares none is reported as having declared none rather than as objecting to
everything. A reviewer that cannot be started refuses the envelope rather than
letting it through reviewed by nothing. An accepted envelope is offered
once per envelope — unlike the node validator's two offers, because this hook
exists for a review a host plausibly answers with an agent, and because the
submission check is the only place a refusal is still whole. It is named by
--envelope-reviewer COMMAND, by ONEPIPELINE_ENVELOPE_REVIEWER, or by a launch
config's envelope_reviewer, in that order of precedence; naming none is the
default and runs no reviewer at all.
A launch may also set the write-back item budget — how long the settlement
write-back allows its store's project copy per item it writes. The copy's
deadline is that budget multiplied by the number of items the run projects, and
never below the sixty-second floor every other store command is bounded by, so the
backstop that kills an unreachable store's copy scales with the plan instead of
being outgrown by it; a copy that outlasts its deadline is reported with the
seconds it was allowed, the item count, the per-item budget, and whether the floor
governed. It is named by --writeback-item-budget SECONDS, by
ONEPIPELINE_WRITEBACK_ITEM_BUDGET, or by a launch config's
writeback_item_budget, in that order of precedence, as a positive whole number
of seconds — zero is refused at whichever spelling carried it — and naming none
takes ten seconds per item.
Read-only views — runs, status, host, monitor, results, goals,
transcript, telemetry — report unread surfaces, driver liveness, and
provider health without touching a run. status says what each in-flight node
is doing right now, with an event count and an age; transcript RUN [NODE]
renders a dispatched turn's tools and its words; telemetry reports what each
party spent and where the wall clock went, in eight buckets that sum exactly.
Anything nothing in the stack measures is reported absent, never as a zero.
onepipeline monitor RUN [--filter NAME|SPEC | --all] [--cursor CURSOR] makes one
pass over the merged stream and returns. Every pass — including one that renders
no event — ends its stdout with exactly one resume line after the trailer,
-- cursor 1:<run>:<byte>, whose byte is the end of the last finished record in
the journal at the moment of the read: past records the profile hid, and short of
a record whose writer has not finished it. --cursor renders only the events
recorded after that place, so a caller that keeps the last line and hands it back
reads nothing twice and keeps no file of its own. The cursor is the one watch
prints and reads — either verb resumes from the other's — and a cursor watch
would refuse (malformed, another run's, past the journal's end, or inside a
record) is refused the same way, exiting 2 with the reason on stderr and nothing
on stdout.
onepipeline watch RUN is the bounded wait a supervisor puts in a wake loop, and
it takes the same --filter NAME|SPEC / --all profile selection monitor does.
It blocks, writing one line per event a supervisor acts on — a graph edit whichever
author issued it (edit-committed, command-accepted, edit-rejected), a node settling at any outcome
(node-settled), a surface being raised (planner-surface-queued), a decision
beginning or clearing to hold a subtree (decision-pending, decision-cleared), a
completion being asked for (completion-requested), and the run being stopped
(run-stopped) — and one heartbeat line per --tick-interval SECONDS of silence, so
a quiet run and a dead stream are not the same thing seen from outside. Every
heartbeat says how many planner surfaces are unread and of which kinds, so a caller
matching only on event lines cannot lose the one signal that a question is waiting.
The human lines go to standard error and one NDJSON record per line to standard
output — a watch of event, of heartbeat, and one final return carrying
run_id, condition, exit, node when the condition names one, cursor and
unread — so nothing has to match prose.
It returns on the first of five conditions to fire, each with a status of its own:
exit 0 when the run settled complete, 3 when nothing is driving it — the same
code every other verb here uses for that — 4 when a blocking surface is waiting
to be answered, 5 when the --timeout SECONDS wait elapsed with the run still
live, and 6 when a node the wait was told to return on settled, the return
record's node naming which one. It prints a cursor on exit, and --cursor
resumes from one without re-emitting what the earlier watch already did.
--tick-interval is this stream's clock and is not start's
--heartbeat-interval, which sets the pacemaker agent's cadence; neither verb
accepts the other's flag.
--until is repeatable, and it is how a supervisor says what the wait is for —
so that waiting for something is this verb's job rather than a loop somebody writes
around it. The default, --until surface, returns on a blocking surface;
--until settled waits through one, still reporting it and counting it on every
heartbeat; --until nothing-driving names a condition every wait already returns
on; --until node-settled returns when any node of the run settles; and
--until node=<ID> returns when that node does. The run settling and nothing
driving it end every wait whether they were asked for or not — a wait that could
outlive the run it watches is the silence this verb exists to end — which is why
naming them adds nothing, and why the two conditions that predate this selector
mean exactly what they always did. Every condition is checked when the command is
invoked, before anything is streamed and before anything waits: one this verb does
not offer is refused naming the ones it does; one naming a node the run's graph does
not hold is refused naming that node and the ids the graph holds; and one nothing in
the run's remaining life could satisfy — a node that settled done before this
watch's cursor, which nothing dispatches again — is refused naming that node, rather
than becoming a wait that never returns. A condition the run has already satisfied
is answered rather than refused: a settlement at or past the cursor is read on the
first pass and returns straight away. And --timeout none does not bound the wait at
all, which is a different value from --timeout 0, whose published meaning is
unchanged: read the run once and return.
Where a dispatch runs
The executor seam decides. v1 ships the local executor only; the trait and the rules grammar are shaped so a dispatch-server or Kubernetes executor is a config change rather than a code change.
executors:
-
rules:
- when:
use: local
- use: local
Ordered: the first rule whose when holds decides, and a rule with no when is
the fallback. A when tests an executor's capacity, the node's own labels
(when: {node_label: {persona: reviewer}}), or both — several conditions in one
when all have to hold.
Development
just --list is the full command surface.
docs/contract-divergences.md records every place
the code could not compile the contract exactly as written, and what the planner
who owns the contract ruled on each.
License
MIT.