Imported from nickderobertis/oneagentgraph (
src/AGENTS.md). Install upstream withnpx skills add nickderobertis/oneagentgraph --skill src. Copyright stays with the author.
The crate
Rules that hold as this grows:
- Add no public item the contract does not name without a reason a reader can
check. A convenience method, a
Resultalias, a builder: each is interface drift, and a consumer that pins to it gets a breaking change later. - Optionality is a decision, not a default. Where the contract states a
default (
stream: true) or shows a field asnull/[], that reading is encoded. Where it neither states a default nor marks a field optional, the field is required — do not quietly relax one to make a config parse. #![warn(missing_docs)]withclippy -D warningsmeans undocumented public items fail the gate. Say what a thing is for, not what it is.
Where each part of the contract lives
| module | what it owns |
|---|---|
config |
the graph YAML schema, and what validate can check without launching |
resolve |
a ConfigRef → bytes, content-addressed for the run record |
persona |
reading a persona as a onejudge config fragment (docs/persona-format.md), the merge onto a onejudge base, the shipped catalog |
invoke |
one member's launch, its generated configs, and the model pairing rule |
member |
a single-sided member's child process: its stream, its watchdogs, its death |
judge |
a two-party member, driven through onejudge's library in this process |
control / note |
where an in-flight turn is addressed, and the transport that carries a role-addressed note into the conversation's own inbox |
run |
dependency order, cron members, the merged stream, the exit code |
scratch |
owner.lock ownership, proven descendant reaping |
event / render |
the wire envelope, the shared filter over it (docs/event-filter-notes.md), and the text rendering of the same events |
history / health / smoke |
the read-only verbs |
The seams that are easy to get wrong
Each conversation side is pinned without a wrapper script, and without a cd.
onejudge gives a harness judge oneharness run --config <judge_config> and the
agent side none, so the agent side is pinned by placing its resolved config at
<member scratch>/oneharness.toml and naming that directory as the
conversation's worktree — oneharness discovers a project config upward from
--cwd. Naming rather than entering, because every member of a graph now shares
one process: a member that cd-ed would pin its siblings too.
The judge side is a list, composed by one path. OnejudgeMember::judge is
Vec<JudgeSide> whichever spelling the graph used, and invoke::provider_block
is the only place it becomes onejudge's provider: a list of exactly one harness
side is the kind: oneharness block it always was, at oneharness.judge.toml;
anything else is kind: split with judges: in list order — each harness side
at its own judge-<label>.toml, the label being the graph's or the one onejudge
defaults, an llmlint side's config anchored to the graph's directory and
handed over absolute (never copied: an llmlint config resolves its plugins
relative to itself), a command side as written. Do not add a second composition
path, and do not restate onejudge's own rules for a panel here — label
uniqueness once defaulted, how a panel decides, what an llmlint run's exit codes
mean are onejudge's (docs/judges.md at the pinned release); what this crate
checks is only what it needs for its own files: a label is a file-name-safe
component, and two harness sides may not claim one scratch file.
Nothing per-member is exported. Same reason. A member's mode and its
ONEAGENTGRAPH_SCRATCH_DIR ownership stamp go into that member's own resolved
oneharness configs (mode, and [env], which oneharness gives to every harness
process it starts), so the stamp still reaches the harness fixed at exec. Only
the graph's own env: block is exported, once, before any member starts. The
stamp also goes on each command as it is spawned, by Group::prepare — on POSIX
the stamp is the group, so a command that reached the kernel without it would
be outside its group whatever else was recorded.
A thread cannot be killed. A watchdog on a two-party member therefore
escalates the way cancel --kill does — set the abort flag so the sink answers
the engine's next event with ControlFlow::Break, reap everything stamped for
the member, and after TEARDOWN_GRACE report the member dead and abandon the
thread. Never wait forever: a run that hangs on a member it already condemned is
the failure the watchdog exists to prevent.
The two sides read their outcomes differently. onejudge settles 1 for a
task it drove but did not complete; oneharness exits non-zero when it could not
run the turn at all, which is a death. member::Kind is the one place that
distinction lives.
A test chain names bare identities, never variants. ONEHARNESS_BIN_* keys
on a harness id and, at the pinned CLI, no spelling of it reaches a variant, so
a chain naming claude-code:alternate spawns the real paid provider with the
double sitting unused beside it. That is a money hazard, not a style point. The
one sanctioned exception is a variant that names its bin in its own config —
[harness.claude-code.variant.alternate] bin = … — which is the layer every
core falls to when no override names the variant; tests/e2e/dispatch.rs's
env-file journey and tests/e2e/selection.rs's exhausted-chain journey are the
two that do it, and each says so at the site. src/bin/ holds the two doubles,
behind the non-default test-doubles feature; keep them deterministic and free
of anything the crate does not already depend on.
Provisioning installs one CLI. just bootstrap pins oneharness, and the
version lives at the top of the justfile. onejudge has no entry: it is a
cargo dependency from crates.io, pinned by Cargo.lock, so there is nothing to
install and nothing on PATH to shadow it.
The e2e suite drives the pinned oneharness and refuses every other.
tests/e2e/support.rs reads the pin out of the justfile and takes the first
candidate — PATH, then $CARGO_HOME/bin — whose --version is exactly it. It
used to select on capability (the first candidate answering interrupt --help),
which let a newer CLI on PATH win over the pin and drive an oneharness CI
never runs, so a green check here could sit against a red gate. The version
matters twice over. A two-party member's turns run through this CLI, so the
oneharness-core it links is the one those journeys prove, and the
model-mismatch journeys in tests/e2e/selection.rs exist only where that core
refuses a codex turn the server would run under another model. And smoke and
interrupt pass --format json, which the pin is the first release to take at
all — an older CLI refuses it as an unknown argument, so a host below the pin
does not merely behave differently, it answers those two verbs nothing. The
smoke and interrupt journeys are what hold that: they drive exactly the pin with
the flag in the argv. A host whose PATH and
cargo bin both hold another version is refused by name with the instruction
(just bootstrap); ONEAGENTGRAPH_TEST_ONEHARNESS names an install outright,
and is held to the same pin.
scratch is the one module a Linux check never compiles all of. Its
cfg(windows) half is the whole liveness layer again in job objects, and the
first thing that reads a line of it is cross (windows-latest) — a required
check a CI round-trip away. just lint-windows runs the same clippy against it
here; it asks for a target and a cross compiler just bootstrap deliberately
does not install, and says which when either is missing.
To prove a Windows journey red, compile the layer out rather than revert it —
cfg(all(windows, not(windows))) on the cfg(windows) module, widening the
fallback to cfg(not(unix)). tests/e2e/liveness.rs records which journeys that
turns red and which cannot, and why.
A two-party member is grouped through onejudge, not around it. Only the
caller of CreateProcess can put a child in a job object, and since onejudge
became a library that caller is onejudge — so nothing this crate spawns is left
to group. onejudge's Plan::with_spawn_hook is the seam — and the reason the
floor in Cargo.toml is where it is: judge::run opens the member's Group and
hands it over,
and onejudge installs it on both backends of a split, so the worker's
harness and the judge's land in the same group. Group::prepare and
Group::adopt exist for that hook — its two methods are the before-the-fork and
after-the-process moments the two platforms need, and Group::spawn is the same
pair for the commands this crate does spawn. Never reach for a shim binary that
re-spawns itself into the job, or a local copy of onejudge's execute.
A note is routed by the conversation, not here. control::interrupt
addresses the agent side and nothing else, so a note sent that way reaches the
worker and never the judge. control::note hands the note to the member, and
judge::run gives the engine the inbox end of an onejudge::note::Notes
channel through Plan::with_notes: only the engine knows which side is live. Do
not re-derive that decision here. interrupt stays the lever for a member with
no conversation at all.
provider.control: true asks for a controllable turn on both parties. One
flag, since onejudge 0.7.0 — and oneharness run --control takes a chain of
exactly one candidate, so a one-candidate judge chain reports with no fallback
block at all. A refusal there is then the candidate the invocation is
attributed to rather than a fell_through, which is not a side that ran:
judge::conversed is where the two are read back apart, and the fake harness's
agents_controlled_turn is where the sentinels stop firing on the wrong side.
crate::note re-exports onejudge's shapes; it does not declare them.
Addressee, Note, Accepted and Undelivered live in onejudge::note, which
is where the approved contract puts them. A second declaration drifts, and a note
that satisfies the copy is still refused by the conversation it was written for.
The onejudge floor in Cargo.toml is the compile floor for that module.
The transport is this crate's, and the courier gets its own thread. A note is
offered by a different process, so it arrives through a Spool in the member's
scratch and a Courier carries it into Notes::send. Never service that spool
from the supervision loop: send blocks until the conversation disposes of the
note — for the supervisor, until its re-taken decision returns — which would put
a judge invocation between two heartbeats. Ending::end records the terminal
refusal so a later note is refused rather than spooled to nobody.
judge's hold_between_turns is a fixture, not a second fake seam. It
pauses real code at the one conversation boundary nothing outside the process can
hold — a harness runs inside a turn, so it cannot hold the gap between two. A
journey asks for it with a marker in the conversation's own task text, which is
the lever every other steer of a conversation already uses, and it is compiled
only under test-doubles. Reach for it nowhere else.
The spool mirrors Accepted/Undelivered, which are not Serialize. Map
every variant in both directions so one added upstream fails this build instead
of vanishing in transit. It is a wire format, not a second contract.
The endpoint is a spool directory, not a socket. The approved contract says
socket; a member of this crate runs on Windows too, where a unix domain socket is
already why control reports no controllable turn, and a note seam that existed
on one platform only would be a delivery an operator could not rely on. What a
consumer sees is unchanged — control.json names the endpoint by path, and the
two ends meet nowhere else.
Two things that bite
A sentinel in a prompt matches prose. The fake harness is steered by markers
in the prompt it is given, and that prompt is the whole rendered system prompt —
persona included. Every marker therefore carries a fake: prefix, because hang
is a substring of change, and a persona telling an agent to state a change's
blast radius once parked every turn of the suite. Both doubles ask through one
steers function that applies the prefix, so a marker added later cannot arrive
bare — which is how complete-now and should-fail stayed unprefixed for a
while after the rule was written.
writeln! reaches a writer as two calls. The body, then the newline. Any
writer that treats one call as one line — the text renderer did — emits a blank
line after every event. Buffer to the newline instead.