Skip to content

Ship the /ci skill: odu documents itself the way justci did - #2

Merged
srid merged 1 commit into
masterfrom
apm-ci-skill
Jun 9, 2026
Merged

Ship the /ci skill: odu documents itself the way justci did#2
srid merged 1 commit into
masterfrom
apm-ci-skill

Conversation

@srid

@srid srid commented Jun 9, 2026

Copy link
Copy Markdown
Member

odu becomes an exporting apm package: justci shipped its /ci skill via .apm/skills/ci, and consumers got the runner reference deployed into their agent runtimes by declaring the dependency. odu now does the same — apm.yml is already type: hybrid, so this PR only adds the skill source.

Consumers wire it with one line in their apm.yml:

dependencies:
  apm:
    - juspay/odu

…and just ai::apm (or however the repo runs apm) deploys it as .claude/skills/ci / .agents/skills/ci. The content is the consumer-generic runner reference: invocation (nix run github:juspay/odu, or a consumer's re-exported .#odu), the strict-mode flag table, selectors, --progress json's NDJSON contract, in-band status/logs/monitor over .ci/odu.sock, hosts config with the justci-fallback migration path, and the one-shot-lane / skipped-posts-nothing / runner-drv-resolution semantics a consumer must know.

kolu will switch from its repo-local .apm/skills/ci to this dependency in juspay/kolu#1252 once this merges — closing the loop: odu documents itself, downstream.

Generated by Claude Code (model claude-fable-5).

apm.yml is already type:hybrid, so a repo-local .apm/skills/ci makes odu
an exporting apm package: consumers add 'juspay/odu' to their apm.yml
dependencies and 'just ai::apm' deploys this reference as their /ci
skill (exactly the wiring justci provided, now consumer-generic).
@srid
srid merged commit a9cdbc1 into master Jun 9, 2026
4 of 6 checks passed
@srid
srid deleted the apm-ci-skill branch June 9, 2026 23:50
srid added a commit to juspay/kolu that referenced this pull request Jun 9, 2026
…nts itself

juspay/odu#2 made odu an exporting apm package (the wiring justci had);
kolu's repo-local .apm/skills/ci is deleted and apm.yml depends on
juspay/odu instead, closing the graduation loop: kolu consumes both the
binary (npins, re-exported flake outputs) and its documentation (apm)
from upstream. odu pin freshened to the same commit.
srid added a commit to juspay/kolu that referenced this pull request Jun 10, 2026
**kolu's CI is now run by odu (Tamil ஓடு — "run") — the live-service CI
runner grown from the `mini-ci` example — replacing
`github.com/juspay/justci`.** This PR carries the whole Phase-1 arc of
the accepted Atlas plan
([mini-ci-vs-justci](https://github.com/juspay/kolu/blob/odu/docs/atlas/dist/mini-ci-vs-justci.html)):
odu was **built here, dogfooded on this very PR, and then graduated to
[juspay/odu](https://github.com/juspay/odu)** — so the final diff
*deletes* the runner's source from kolu and consumes it upstream, the
way remote-process-monitor became drishti. Same status contexts, same
per-SHA log layout, same strict-mode flag table as justci, so branch
protection and the `/do` muscle memory carry over unchanged — but the
run is **state you attach to**, not a batch process you scrape.

> **Dogfooded on this PR throughout**: the full pipeline (26/26 nodes,
linux on leased pool box `kolu-ci-5`, darwin on `rasam`) went green with
every required check posted by odu itself; the final head re-verifies
the linux lane (e2e deliberately skipped — maintainer's call). The `##
Evidence` comment has a live `odu monitor` dashboard captured mid-run.

### How a run works

```
odu run  (coordinator — one process, parented by ci/pu/run.sh's lease)
 ├─ strict gate: dirty-tree refuse · HEAD pin via git worktree
 ├─ just --dump → [metadata("ci")] DAG  (odu dump / graph to inspect)
 ├─ per platform lane (hosts.json):
 │    nix copy odu-runner.drv → realise on host → ssh host odu-runner --stdio
 │    └─ run.configure over the typed surface → host git-fetches the pushed
 │       SHA into a writable per-SHA workspace → just --no-deps <recipe> each
 ├─ fan-in: lane state cells merge → served on .ci/odu.sock
 │    (odu status / logs -f / monitor — a live TUI dashboard — attach here)
 ├─ .ci/<sha7>/<plat>/<recipe>.log + timings.jsonl  (durable past runner death)
 └─ gh: ci::<recipe>@<platform> statuses on transitions read from the cell
```

On a TTY, `odu run` paints a live colour recipes × lanes matrix —
spinners, per-cell ticking elapsed times, a log-tail footer, the commit
as a clickable OSC-8 link (`@ 3cbac86`, or `@ 3cbac86+dirty` in
live-tree mode) — justci-UX mimicry deliberately dropped. Piped, it
emits quiet transition lines with a once-a-minute heartbeat; `--progress
json` is the byte-stable NDJSON contract `/do` consumes.

### What kolu ends up with

| kolu consumes | via |
| --- | --- |
| the `odu` / `odu-runner` binaries | npins pin of
[juspay/odu](https://github.com/juspay/odu), **re-exported through
kolu's flake** — `nix run .#odu`, `ci/pu/run.sh` (lease machinery
untouched), and the coordinator's runner-drv eval all unchanged; `npins
update odu` to bump |
| the `/ci` skill | `juspay/odu` as an apm dependency
([juspay/odu#2](juspay/odu#2)) — the runner
documents itself, like justci did |
| `@kolu/surface` libraries (inside odu) | odu's own npins pin of kolu —
two deliberate pins, never a build-time loop |

The justci excision is total in live paths: `apm.yml` drops the
dependency, `.agency/do.md` / `ci/mod.just` / the README CI section
describe odu, the justci-era ralph reports carry historical notes, and
the workflow rule justci incidentally shipped (`Keep README.md in
sync…`) is re-authored repo-locally so it survives the dependency
removal. odu's 40-test suite now runs in juspay/odu's own GitHub Actions
(odu-on-odu, per push).

### The road here (review + production)

Codex ⇄ Claude debate (consensus, 2 rounds): NUL-byte escapes,
per-invocation worktree suffix, dirty-live-tree remote-lane guard, the
`timings.jsonl` sidecar `report.sh` now prefers. Lowy ⇄ Hickey lens
debate (consensus, 11 applied): one `nodeId` owner, one `STATUS_META`
projection, shared log-tail store, dead `ODU_RUNNER_DRV` wire deleted.
Code-police: 3 polish fixes. Production then caught what review
couldn't: **node's `'pipe'` stdio is an AF_UNIX socketpair Linux can't
`open()` by path** (cucumber's `pretty:/dev/stderr` died where
process-compose's real pipes worked → recipes now run behind a `| cat`
interposition, regression-tested), and **a pinned nix client older than
the host daemon corrupts CA-derivation handling** (the wrappers no
longer pin nix — the host that provides the daemon provides the client).

### Byte-parity, verified against live data

| justci posted | odu posts |
| --- | --- |
| `ci::e2e@x86_64-linux` contexts (+ `_ci-setup@<plat>`) | identical
strings |
| `Running:` / `Succeeded (3m26s):` / `Failed (8s):` + log path |
identical, incl. Go's `1m0s` zero-pad |
| logs at `.ci/<sha7>/<plat>/ci::e2e.log` | identical layout |
| skipped nodes: no post (absent context blocks merge) | identical |

*odu adds one state justci never defined: infrastructure death posts
`error` / `Errored (<dur>)` — a lane's ssh link dying mid-run fails the
run instead of wedging it.*

> **Deliberate divergences** (documented in the odu README/skill):
remote lanes `git fetch` the **pushed** SHA (no git-bundle transport);
live-tree mode is localhost-only on a dirty tree; lanes are one-shot;
`dump` replaces `dump-yaml`; `--tui` is absorbed by `odu monitor`;
idle-attach and the (vacuous — contexts are byte-identical) protection
flip are follow-ups. No website changelog entry — contributor tooling,
invisible to kolu app users.

### Try it

```sh
nix run github:juspay/odu -- --help          # the graduated tool itself
nix run github:juspay/kolu/odu#odu -- --help # kolu's re-export of the pin
```

_Generated by [`/be`](https://github.com/srid/agency) on Claude Code
(model `claude-fable-5`)._
srid added a commit that referenced this pull request Jun 10, 2026
## `run` and `monitor`: one progress contract — fixes #4

`odu run` and `odu monitor` rendered the *same* fan-in state through two
separate, hand-rolled output paths that had drifted
([#4](#4)).
This routes `monitor`'s non-interactive stream through `run`'s own
renderer, so
there is now a single source of truth for the json/plain contract.

### The seam
- **Shared `progressEvent(sha7, id, node)`** in `display.ts` — the one
projection
of a node's state into a `ProgressEvent`. Both `run` (its own run
`sha7`) and
`monitor` (the surface's `sha7`) build transitions through it, so the
two faces
  emit byte-identical output by construction instead of re-deriving it.
- **`monitorStream` now drives `createDisplay(json ? "json" :
"plain")`** — the
same `JsonDisplay`/`PlainDisplay` `run` uses. Collapses three
divergences at
  once:
- **#3 (json shape):** monitor's NDJSON regains `recipe` / `platform` /
`log`
and uses the `--progress json` wording (`success`, not the raw `ok`) —
so a
    consumer of `run`'s contract no longer breaks against `monitor`.
- **#2 (plain format):** glyph + `ProgressStatus` + duration + a `→
logpath`
    on failures, matching `run` (was a bare `status  id`).
  - **#4 (heartbeat):** the 60s `… still running:` line now appears in
    monitor's stream too (it was silent between transitions).

### Carry `sha7`/`dirty` on the surface
`monitor` needs the run's commit to name the durable log path
(`.ci/<sha7>/…`). Rather than re-read git HEAD (and risk drift if HEAD
moved
mid-run), `PipelineState` now carries `sha7`/`dirty`: the coordinator
stamps the
authoritative values onto the fan-in surface (`.ci/odu.sock`). The
lane's copy
is advisory — documented in `runner.ts` — since commit identity is a
fan-in
concern.

### The one deliberate difference (#1)
The interactive threshold stays `stdin && stdout` for the dashboard (it
reads
keystrokes) vs `run`'s `stdout`-only live matrix (output only). That
difference
is inherent to interactivity, now commented as deliberate. The win: when
monitor
*does* fall back to the stream, that stream is now `run`-quality rather
than the
old poor cousin.

### Tests
- `progressEvent` unit tests: recipe/platform/log, the `NodeStatus →
ProgressStatus`
  mapping, `exit_code` only when present, `null` for `pending`.
- `introspect.test.ts`: `monitorStream` over a real served surface — the
json
carries the full contract; the plain lines use `run`'s glyph + wording +
log
  ref. `pnpm typecheck` clean, 68 unit tests green.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
srid added a commit that referenced this pull request Jun 12, 2026
Reproducible terminal evidence for the run ledger (PR #28), in the house
style of cancel-demo.sh: drives the nix-built binary against a throwaway
pass/fail fixture pinned to a localhost lane, narrating empty ledger →
two passed runs of one sha (#1/#2 seq) → a failed run on a new commit →
odu runs + -o json. Re-run to regenerate the gif.
srid added a commit that referenced this pull request Jun 12, 2026
**Every terminal run now writes a durable record to
`.ci/<sha7>/runs/<seq>.json`** — the run identity `(repo, sha, seq)`,
the outcome, timing, the lane→host map, and a terminal snapshot of every
node. Until now a run's state lived only in the coordinator's in-memory
`nodes` cell on `.ci/odu.sock`; once that process exited, *"what runs
happened here, and how did each end?"* had no answer — `odu status` with
no live socket exits 1, and the agent face's own header said it plainly:
*"no durable PipelineState manifest exists on disk."* This is that
manifest.

Both faces read it. **`odu runs`** lists the ledger off disk — the first
CLI command that works against an idle checkout — and the agent face
gains a **`runs` MCP tool** so a coding agent can ask "how did the last
run end?" after the coordinator is gone.

```
$ odu runs
7d89868#2  7d89868+dirty  ✔ passed  1 lane  just now
7d89868#1  7d89868+dirty  ✔ passed  1 lane  3m ago
```

### The seam

```
PipelineState (live cell) ──buildRunRecord──▶ RunRecord ──writeRunRecord──▶ .ci/<sha7>/runs/<seq>.json
                              (pure)                                                    │
                                       odu runs (CLI) / runs (MCP) ◀──readLedger────────┘
```

- **`src/common/runRecord.ts`** — the `RunRecord` schema +
`buildRunRecord` (pure: `PipelineState` + run env → record), and the
shared `projectNodes` both the record and the timing sidecar derive
their per-node fields from. `outcome` is one tri-state enum — `passed`
(completed, no red node), `failed` (completed, red node), `incomplete`
(a node still pending/running when finalized) — so the illegal "passed
but incomplete" state is unrepresentable. `sha7` is *derived* from
`sha`, never stored.
- **`src/coordinator/ledger.ts`** — the on-disk layout beside the
existing per-SHA logs. `allocateSeq` (next ordinal for a commit),
`writeRunRecord`, and a deliberately *forgiving* `readLedger` — a record
it can't parse (future format, torn write) is skipped, never thrown, so
one bad file never blinds the rest of the history.
- **`src/coordinator/run.ts`** — `finalizeRunRecord` wired into *every*
terminal path: natural completion, each `--linger` drain, and the shared
`shutdown` teardown (so cancel / interrupt / idle record too, marked
`incomplete`). The record is written **before** the socket lock
releases, and `shutdown` snapshots state at interrupt-time, so a
superseding run can't collide on `seq` and a cancelled run can't be
mislabelled `passed`.
- **`src/cli/runs.ts` + `src/mcp/runsTool.ts`** — `odu runs [-o json]`
and the read-only `runs` bespoke MCP tool, both reading the same ledger
off disk (the ledger is consulted precisely when no coordinator is live,
like the `logs` durable fallback).

### Why `seq`

The same commit runs more than once — a rerun after an infra flake is a
*new run*, not a mutation of the prior one's history. `seq` makes each a
distinct, append-only record (`7d89868#1`, `#2`, …) instead of an
overwrite. A run started by `odu run` and one spawned by the MCP `run`
tool land in the same ledger, since both flow through the one
coordinator.

> **Scope.** This is the run-identity half of the runner's idle/serve
direction (odu-web's Phase 0). What's deliberately *not* here, because
it needs the long-lived serve/socket work: attaching the *live*
`nodes`/`nodeLog` surface to a finished run, and a runner you reach
before any run starts. `odu status` with no live run still exits 1; `odu
runs` (and the `runs` tool) read what already happened. A `SIGKILL`
mid-run leaves no record — only a graceful terminal path writes one.

### Review

Verified live: two localhost runs of one commit produced `7d89868#1`
then `#2` (seq increments, no overwrite); `odu runs` and `-o json` both
render; 134 unit tests green, typecheck + nix fmt-check clean. Then
through the full gauntlet:
- **lens-debate** (lowy ⇄ hickey) — consensus, 6 structural fixes (the
tri-state `outcome`, `shortSha` derivation, shared `projectNodes`,
`repoSlug`/`gitTopLevel` reuse, sidecar symmetry).
- **codex-debate** (`xhigh`) — consensus in 2 rounds; caught two real
teardown-ordering bugs (a seq-collision race and a
cancelled-run-mislabelled-`passed` window), both fixed.
- **simplify** + **code-police** (inline; the skill was rate-limited) —
one cleanup + one fact-check fix each.

See the three review comments below for the per-finding trails.

### Try it locally

```sh
nix run github:juspay/odu/feat/run-identity -- runs
```

*Generated by [`/be`](https://github.com/srid/agency) on Claude Code
(model `claude-opus-4-8`).*
srid added a commit that referenced this pull request Aug 5, 2026
kolu's `effect` branch moved thirteen commits past `f010fcdaf`: the G-round of
juspay/kolu#2101, chasing the deploy-#2 production freeze (all hosts stale at
once, writes accepted, ZERO log lines, cured only by a restart).

Two of odu's six hydrated packages moved — `@kolu/surface` (client, server,
reactor, peer-server, unix-socket, links/{wire,websocket}, mirrorRemoteSurface,
plus new `containThrow` and `frameLimit` leaves) and `@kolu/surface-remote`
(hostFanout, reServeSurface).

VERDICT: none — no odu source change required.
=========================================
Typecheck is clean and the 368-test unit suite passes, 0 failures across 29
files. Each delta the impact brief named, with its receipt:

- **`mirrorRemoteSurface` / `MirrorRemoteSurfaceOptions`, `log` → `onFault`
  (BREAKING for direct callers).** Zero hits in odu's `src/` and `tests/`. odu
  imports no `@kolu/surface/mirror` — its full surface import set is `client`,
  `define`, `errors`, `link`, `links/{direct,readiness,stdio,unix-socket}`,
  `loopback`, `peer-server`, `project`, `server`, `unix-socket`. The only
  `mirrorRemoteSurface` caller in the tree is the hydrated
  `surface-remote/hostFanout.ts`, and kolu moved that call itself in the same
  commit.
- **`pumpRemoteSurface` / `reServeSurface` `onFault`.** Zero hits, and additive
  regardless — `onFault?` is optional on both option types.
- **`fenceStream` / `rawStream` `onRetry`.** Zero hits. odu's only `retry` code
  is its own GitHub status-post backoff (`src/coordinator/statuses.ts`).
- **`websocketLink`.** Zero hits — odu has no websocket leg at all. Repo-wide,
  `websocket|ws://|wss://` returns nothing over `src/` + `tests/`. odu's
  transports are stdio (`serveOverStdio`, `stdioLink`) and unix socket
  (`serveOverUnixSocket`, `unixSocketLink`) only.
- **`frameLimit` / `rpcSerializationLayer` (new `./frame-limit` export).** Zero
  direct hits; the export is additive. odu inherits the explicit cap
  transitively, since all three of its legs serialize inside the hydrated
  package. Behaviourally this is a NO-OP today: `RPC_MAX_FRAME_BYTES` is set to
  16 MiB, which IS Effect beta.103's own default — passing it explicitly stops a
  future Effect bump moving the wire silently, it does not move it now.

  The exposure question was asked properly rather than waved off, because odu is
  a CI runner and CI logs are unbounded user content. odu's ONLY member whose
  payload scales with recipe output is the `nodeLog` stream
  (`src/common/surface.ts:369-373`), and it is already bounded two orders of
  magnitude under the cap: snapshots are clamped to `MAX_LOG_CHARS` = 64 KiB
  (`src/common/surface.ts:303-308`, applied at `src/common/logTail.ts:64,69-70`
  and on the stream's first frame at `:78-80`), `append` frames carry one child
  pipe chunk (Node's 64 KiB highWaterMark), and the MCP face re-clamps on
  accumulation (`src/mcp/agentSurface.ts:364-369`). No other member carries user
  content — `nodes`/`header` are ids, status and timings; procedure I/O is
  `{id}` / `{ok}` / lease holder structs.
- **Reactor poll-cell seed semantics (now cell-local at T+0).** Zero hits — odu
  uses no kolu reactor or poll-cell primitive. Every `poll` in odu is its own
  hand-rolled sleep loop (`checkoutLock.ts`, `cancel.ts`, `lease.ts`,
  `cli/leaseCmd.ts`).
- **`runtime.done` observers (log-and-continue now contractually wrong).** odu
  attaches NO observer: all three `implementSurface` sites
  (`src/runner/runner.ts:80`, `src/coordinator/run.ts:855`,
  `src/mcp/serveForTest.ts:73`) destructure only `group`, `handlers` and `ctx`.
  That is not a break, and today it is not even reachable: `done` is supervised
  for OWNED SOURCES only — the connectors of derived cells and derived
  collections — and odu declares none. Its cells are plain `store`-backed and its
  one stream takes a plain `source`. Recorded as a latent obligation on odu#43:
  the day odu declares a derived member, it must observe `done` and treat a
  rejection as fatal.
- **`@kolu/surface-daemon` additions.** odu grafts no daemon package — verified
  repo-wide (zero hits for `surface-daemon` outside `node_modules`), and the
  hydration list in `justfile:14-20` is exactly the six.

Effect is untouched at 4.0.0-beta.103; `bun install --frozen-lockfile` reports
the same 153 installs across 164 packages with no changes, so `bun.lock` and
`bun.nix` are byte-identical.

The pin hash was produced as in the last three bumps — an in-place
`nix-prefetch-url --unpack` edit with the method re-validated against the
OUTGOING revision first (prefetching `f010fcdaf` reproduced its committed
`sha256-3QGxNHLy…` exactly) before it was trusted for the new one.
srid added a commit that referenced this pull request Aug 7, 2026
…s, and Effect Schema (#74)

kolu's surface stack went Effect-native (juspay/kolu#2101), and odu
consumes it
by source. This is odu taking the same migration: the pin, the
dependency swap,
zod → Effect Schema across odu's own vocabulary, and every consumption
site
re-cut against the new shapes.

**Pairs with juspay/kolu#2101.**

## The shape of the change

Three things moved underneath odu at once, and each one has a distinct
feel at
the call sites:

| | before | now |
| --- | --- | --- |
| serving | `implementSurface(...).router` → `serveOverStdio({ router
})` | `{ group, handlers }`, route-set-checked against each other at
boot |
| dialing | `stdioLink<Contract>({…})`, sync, returns a typed client |
`await stdioLink({ group, … })` → `{ dispatch, dispose }`, then a
spec-derived face over it |
| reading | `await client.surface.nodes.get({})` → `AsyncIterable`, `{
signal }` to cancel | `client.surface.nodes.get()` → a lazy `Stream`,
cancelled by closing the subscription |

The middle column is what makes this a rewrite in three files and a
mechanical
edit in twenty. The interesting work was in `lane.ts`, `agentSurface.ts`
and
`common/surface.ts`; everything else followed from them.

## Two real bugs, found by the port

Neither was a migration artifact. Both were live defects the stricter
schema
exposed.

**The coordinator could publish a run nobody could observe.**
`orchestrate`
stamped the reserved ordinal onto the fan-in cell as
`{ ...state, seq: seq ?? undefined }`. zod's `.optional()` tolerated the
present-but-undefined key; `Schema.optionalKey` refuses it on **encode**
as
well as decode. So on the rare path where no ordinal could be reserved,
the
whole `nodes` cell became un-encodable — a run that executes fine, that
no
`attach`, `status` or agent read can see. The key is spread in only when
there
is one, and there is now an encode-side byte fixture for the shape.

**Both lease dials sent `lockPath: undefined`.** With no override in
hand, that
is a decode failure now, not the "use the agent default" request the
caller
means — the same shape that bit kolu's own history pager during its
migration.
The default lock path is the *agent's* to choose (`ODU_LEASE_LOCK`), so
the key
has to be absent, and a small `lockPathKey` helper makes that the only
spelling.

Every other `optionalKey` producer in the tree was audited the way kolu
audited
its own: `TaskSpec.name`/`os`/`needs` (built totally by `just/ingest.ts`
—
`osAttributes` always returns an array), `PipelineState.posting`,
`UnpostedEntry.attempts`, `RunRecord.unposted` (already a conditional
spread),
and the five MCP tool inputs (decoded from host JSON, where `undefined`
is
unrepresentable). No others needed changing.

## Byte compatibility, proven rather than asserted

`RunRecord` is odu's only durable manifest, and the ledger reader
**deliberately
skips records it cannot parse**. A silent encode drift would have made
every
pre-migration run vanish from `odu runs` with no error at all — the
failure
mode a decode-equality test is structurally blind to.

So `src/common/schemaBytes.test.ts` asserts encoded JSON **strings**,
not decode
equality: 21 fixtures over `PipelineState` (both wires),
`NodeLogMessage`,
`TaskSpec`/`ConfigureInput`, both lease unions, and `RunRecord`. Each
was
cross-checked against the pre-migration **zod encoder** in a scratch
harness —
all 18 formats emit identical bytes, field order included. The
"unposted entry written before `attempts` existed" fixture is the
specific
regression that would otherwise have eaten history.


And then the same property was checked the other way round, on
production data
rather than a fixture. The record this PR's own green two-platform CI
run wrote
(`.ci/8d442f0/runs/1.json`, 1930 bytes, both lanes, 16 nodes) was fed
back
through the **pre-migration zod reader**:

```
pre-migration zod reader accepts it : true
post-migration reader accepts it    : true
both re-encode to IDENTICAL bytes    : true
```

So a reader from before this change can read what this change writes,
and a
reader from after can read what came before. On odu's only durable
manifest,
that is the whole ballgame.
## What the migration let us delete

- **Three `any`-typed `router` fields and their biome-ignores.** A tag
carries
its own route, so there is nothing opaque left to smuggle past the
compiler.
- **The TS2590 dodge in `agentSurface.ts`** — a hand-transcribed
four-member
mirror of surface A plus an `as never` cast on `deps`. `SurfaceClientOf`
now
resolves to the narrow read face, so the expensive half of the union is
never
built. Re-measured on this surface rather than assumed; the cast that
could
  have been hiding a genuine error alongside the overflow is gone.
- **The `NodeIdKeySchema` relaxation**, and its reason with it. It
existed
because surface-mcp's old URI decoder probed `keySchema.safeParse("")`
to
classify a key as string-typed, which a `.min(1)` string rejects. The
Effect
decoder tries the id verbatim first, so there is **one** node-id
contract
  again instead of two literals free to drift.
- **`lane.ts`'s `AbortController[]`.** A subscription is a fiber and
unsubscribing is closing it, so the per-stream array had nothing left to
distinguish — teardown wants them all gone at once, which is one abort.
- **`redialingAClient`'s dishonest teardown.** Its `finally { close() }`
ran
  only if the consumer resumed the generator, and `close()` was sync and
  couldn't have been awaited there anyway. It is `Stream.unwrap` over an
`acquireRelease` now: the dial is a scoped resource, an interruption
runs the
release, and the release is an `Effect`, so the now-async close is
genuinely
  awaited.

## The ledger — #43

| # | item | state |
| --- | --- | --- |
| 1 | **kolu#1858** — `serveOverStdio` framework-owns the process exit |
✅ **stays drained.** The contract is preserved verbatim upstream (kolu
S4 §3); re-verified here by `odu.test.ts`'s override arm and
`processTeardown.test.ts`'s real child agent. |
| 2 | **kolu#1865** — typed `isError` tool dispatch; new
`@kolu/surface/wait` | ✅ dispatch half **inherited** at the bump.
`runWait` adoption **deferred, with reason** — see below. |
| 3 | **kolu#1876** — declared procedure error union (now
`ProcedureSpec.error?`) | ⏭ **deferred, with reason** — see below. |
| 4 | **kolu#1884** — `sshConnector` requires `localEnv` | ✅ **stays
drained.** Still required on the effect branch;
`surfaceRemoteOpts.localhostSpawnEnv()` still supplies it at all three
dial sites. |
| 5 | **kolu#2018** — `directAgentDerivation(drvPath, binaryCache)` | ✅
**DRAINED.** See below. |
| 6 | **kolu#2101** — the Effect 4 migration | ✅ **DRAINED.** This PR. |

### #2018 — the binary cache, without a second source of truth

`directAgentDerivation` now requires an `AgentBinaryCache` so that no
consumer
can assemble a cache-blind provisioning path. odu's flake already
declares the
pair it trusts, so rather than hand-writing those literals a second time
in
TypeScript, `nix/binary-cache.nix` owns them and `default.nix` bakes
them onto
the wrapper exactly the way `ODU_RUNNER_FLAKE` is baked.

Nix forbids the flake's own copy from importing anything — `nixConfig`
must be
a literal set, and a computed one fails eval outright. So the
duplication is
*forced by the tool*, which makes it the kind that must be **checked**
rather
than trusted: `nix/binary-cache.nix` asserts its values appear verbatim
in
`flake.nix` and fails `nix build .#odu` with a directed message the
moment they
drift. Falsified by mutating one and watching the build refuse.

No fallback and no override knob, matching `ODU_RUNNER_FLAKE`'s existing
doctrine: a coordinator built without the pair is misbuilt, and the
honest
response to a misbuilt binary is to refuse rather than provision
cache-blind.

### The two deferrals, and why

**#1865's `runWait`.** It returns a `WaitOutcome` discriminated union
(`met`/`gone`/`timeout`/`interrupted`/`closed`). odu's `wait_for_settle`
returns a `SettleVerdict` — always an object, with `timed_out` as a
*field*
rather than an arm, plus the `{sha7, seq}` identity contract
#49
added and the loud `NoLiveRunError` refusal that has no `WaitOutcome`
counterpart. Adopting the scaffold would reshape a **published
agent-facing
result**, which the e2e suite pins on purpose. That is a deliberate
product
decision, not migration cleanup, and it does not belong in the same
change as
a transport rewrite.

**#1876's declared errors.** `run.configure`'s `{ ok, error: string |
null }`
is a hand-rolled result type a `Schema.TaggedErrorClass` would replace
properly
— but doing so changes the bytes on the lane wire, and this PR's whole
credibility rests on *not* moving wire bytes it did not have to. Worth
its own
change.


## The `odu` ↔ `odu-runner` flag day — a stated decision, not an
omission

kolu spent a whole architecture decision (its D6) on its upgrade window,
because
its daemons outlive a deploy. odu's equivalent question is: what happens
to an
operator whose venue box or `~/.cache` still holds a **pre-migration**
`odu-runner` closure, now that the coordinator speaks Effect RPC?

**Nothing, by construction** — and the reason is worth writing down
rather than
assuming.

The coordinator resolves the lane runner from its OWN baked
`ODU_RUNNER_FLAKE`
(`self.outPath`, baked in `default.nix`), evaluates
`packages.<platform>.odu-runner.drvPath` from it, and provisions exactly
that
derivation. Store paths are content-addressed over the source, so the
two
epochs are simply different paths:

```
this branch:  /nix/store/ifk5jvrs0h2f461pssbrsd7zg8bdg61c-odu-runner.drv
master:       /nix/store/8zyvwlkf8vh33g8c9kpbk2jnplfjhydm-odu-runner.drv
```

A coordinator built from this commit can only ever ask for the first. No
warm
cache, no GC root, and no "already provisioned — skipped copy" fast path
can
substitute the old closure under the new name, because the name IS the
hash of
the new source. That is the same property `runnerFlake.ts`'s header
already
leans on when it refuses to offer an override: "use a different runner"
means
"run a different odu".

The venue **lease** is not a counterexample either. A pre-migration
`odu-runner` still holding a flock on some box is never spoken to — each
`tryClaim` spawns its own agent over ssh, and the stale holder is
observed
through the filesystem lock (`busy`), never through the wire.

And if someone forces a mismatch anyway, it is loud: the stdio link
fails to
decode the first frame, the link dies, `lane.ts` reports `onDead`, and
the
coordinator marks the lane's unfinished nodes `errored`. odu's lanes are
one-shot by design, so "the agent could not speak" and "the agent died"
are
already the same observable — there is no silent-degradation path to
close.

So odu needs no protocol-epoch machinery, and this PR adds none.
Recorded here
because the recon asked for the decision to be visible, and an unstated
non-problem reads exactly like an overlooked one.

## Depth bound, enforced rather than reviewed

`src/common/effectEdges.test.ts` enumerates the sanctioned `Effect.run*`
call
sites. There is exactly **one**: `src/common/effectEdge.ts`, odu's
bridge back
to the pull-a-frame-at-a-time shape the CLI and MCP tools are written
in.

A lint rule cannot cover this — Biome's Promise rules are blind to an
Effect
that was never run, and an `Effect.runPromise` dropped into a leaf
typechecks
perfectly while quietly making that leaf a boundary. The test also fails
on a
**stale** allowance, since an entry left behind after its call site
moved reads
as a boundary that no longer exists and would quietly re-admit one
later.
Falsified in both directions.

Round 3 widened both scans against the rename dodge — see **Round 3**
below.

## Contracts that did NOT move

Worth stating explicitly, because it is the strongest evidence the port
is
faithful:

- `src/mcp/server.test.ts` passes with its gating assertions
**unmodified** —
  `tools/list` is still exactly
  `[cancel, lane_cancel, node_cancel, node_rerun, run, wait_for_settle]`
  (default-deny), `resources` still carries `surface://streams/nodes`,
templates still carry `surface://collections/logs/{id}`, `header` is
still
  absent.
- `tests/e2e/harness.ts`'s deliberately re-declared `ProgressEvent` is
  untouched — odu's NDJSON byte contract with `/do` and with kolu's CI.
- `serveOverUnixSocket`'s six bind outcomes, the
`already-served`-is-the-lock
doctrine, and the framework-owned exit on the stdio arm are all
preserved
  upstream and re-verified here.

The one deliberate external change: MCP-facing numerics (`exit_code`,
`duration_ms`, `seq`, `limit`, `timeout_ms`) advertise
`{"type":"integer"}`
instead of `{"type":"number"}`. `Schema.Number` is a codec tolerant of
Infinity/NaN whose JSON Schema would offer a host the literal string
`"NaN"` as
a valid value (kolu PLAN D8, divergence 2). These are counts and
durations; the
faithful spelling is also the safer one.

## Compiler note

kolu requires `typescript@7.0.2` for its own build. The recon flagged
odu-compiles-kolu's-sources-under-5.x as an untested combination and a
real
risk. It is a non-issue: the hydrated `@kolu/*` sources typecheck with
**zero
errors** under odu's `tsc 5.9.3`. No bump needed.

## The pin

`npins/sources.json` targets kolu s **unmerged `effect` branch** at
**`72da67ec6`** — the current head of that branch (see **Rounds 3–7**
below) —
with the real tarball hash of that revision. **It must be
re-pinned to the merge commit on `master` once juspay/kolu#2101 lands**
— this
PR should not merge before that one.

Dependency delta: the five `@orpc/*` packages and `zod` are out (zod
survives in
the lockfile only as a transitive of the MCP SDK); `effect` and
`@effect/platform-node` are in at the literal `4.0.0-beta.103`, plus
`dequal`
for `@kolu/surface-map`'s server. An `overrides` block keeps `effect` a
singleton — the hydrated sources resolve it by walking up to the root
`node_modules` (bunfig.toml's isolated-linker invariant), and a second
copy
would give the `_tag`-based error narrowing two class realms to disagree
about,
silently.


## Campaign 2 — the face becomes Effect-only

kolu's `effect` branch ran a second campaign after this PR was first
opened, and
the pin moved with it: **`b0db04a4c` → `3c631446d`**. A unary member
call is now
an `Effect` and **there is no other spelling** — every transitional
Promise twin
kolu staged is deleted (`UnaryProcedure`, `BoundProcedure`,
`ProcedureResult`,
`safe`, `isDefinedError`), `SurfaceReadFace`'s procedure ladder returns
an
Effect, and `BespokeTool.handler` returns one too.

Five of the thirteen impact rows touch odu — the unary face, the
spec-derived
client types, the six MCP bespoke handlers, the projection's forwarders,
and the
sweep below. The other eight are supervisor / surface-app / first-frame
/ mirror
/ `dialAgentOnce` surface odu does not consume, and the `isAbortReason`
un-export is a no-op here (odu never imported it).

### A silent bug this PR had already shipped

`introspect.ts`'s attach dashboard spelled:

```ts
rerun: (id) => void client.surface.node.rerun({ id })
```

An Effect is a *description* of a call, not the call. `void` on one
discards it
without ever dispatching — so **pressing `r` in an attached session
silently did
nothing**. The file had *zero* type errors at the new pin, because
discarding a
result is exactly what `void` is for. `await` is the same shape: an
Effect is
not a thenable, so `await` resolves to the Effect object.

This is the hazard kolu hit five times in a single wave — once quietly
disabling
the very drain a daemon acceptance test existed to prove — and then
banned
repo-side. `effectEdges.test.ts` bans it here too, deliberately
**narrow**:
`await`/`void` applied directly to a face call, nothing between but a
reference
path, so the sanctioned `await runUnary(<call>)` does not match. A check
that
condemned the one right spelling would be switched off within a week,
and then
the real ones ride back in. Comment lines are skipped so a doc can still
*teach*
the wrong shape. Falsified by reintroducing the exact line above.

### The edge, named for what it is

`common/stream.ts` → **`common/effectEdge.ts`**, gaining `runUnary`. It
is still
odu's only sanctioned `Effect.run*` site, which is precisely why a
module named
after the first shape it happened to carry is how a second edge gets
added
unnoticed.

Promise-shaped callers (the CLI, the lane and lease dials, `cancel`) go
through
it. Effect-shaped ones deliberately do **not**: the six bespoke handlers
compose
instead, and so inherit interruption — a cancelled `tools/call` now
tears down
whatever the handler opened, which a Promise edge cannot offer.

The projection's forwarders got simpler exactly as the impact row
predicted:
`Effect.promise(() => a.surface.node.rerun(x))` collapses to
`a.surface.node.rerun(x)` — and that lift was the same hazard in
miniature,
since `Effect.promise` over something already an Effect succeeds *with
the
Effect*. They now carry `Effect.orDie`: B's spec declares no procedure
error, so
a `SurfaceCallFailure` from A is undeclared, and laundering a dropped
link into
`{ ok: false }` would tell an agent the rerun was refused when nobody
was there
to refuse it.

### A regression the e2e caught

`wait_for_settle` first went to a bare `Effect.tryPromise`, which wraps
a
rejection in `Cause.UnknownError` — swapping #49's loud "no
run in
progress in this checkout" refusal for a generic string. Two black-box
MCP e2e
tests failed on the message, which *is* the contract. Fixed with an
explicit
`catch` that passes the error through. Worth noting that only the
black-box
suite could have caught this.

### runWait, re-checked

Still **deferred**, and now for a firmer reason: campaign 2 did not
convert it.
`runWait` is still `async` + `AbortSignal` — surface-mcp's own doc names
it as
the reason `BespokeTool.handler` keeps its `signal` parameter. The
original
argument is unchanged: its `WaitOutcome` union is not odu's published
`SettleVerdict`, and adopting it would reshape an agent-facing contract
the e2e
pins on purpose.

### One thing fixed in passing

`test:unit` / `test:e2e` are scoped to `./src` and `./tests/e2e`. The
bare `src`
filter also matched `apm_modules/juspay/odu/src` — a gitignored vendored
copy
`just apm` installs — so a developer who ran apm and then tests got 24
failures
out of a stale tree. CI never saw it (`apm_modules` is not in a fresh
checkout),
but it cost a real debugging cycle here.

## Round 3 — the review round, and the rename dodge

kolu's review of juspay/kolu#2101 moved its `effect` branch seven
commits, and
the pin moved with it: **`3c631446d` → `8f0ce9780`**. Nothing odu
consumes
changed shape, so this round is a pin bump plus one deliberate import.

### Consumption verdicts

| kolu change | odu impact |
| --- | --- |
| `@kolu/surface-remote`: `pumpRemoteSurface` gains a loud exit when a
mirror ends while the link still answers `system.live` (no API delta) |
**none** — odu imports exactly `makeSession`, `sshConnector`,
`isLocalHost`, `agentBinaryCache`, `directAgentDerivation` and five
types from that package. It never touches `pumpRemoteSurface`,
`hostFanout` or `mirrorRemoteSurface`: odu dials one agent per lane, so
there is no fanout to pump. |
| `@kolu/surface`: comment-only | **none** |
| preferences ladder · typing-echo bench · client restore race |
**none** — kolu-internal |

So the odu#43 ledger is unchanged: row 6 (**kolu#2101**) stays ✅
**DRAINED**,
and no other row moves. `bun.lock` and `bun.nix` are byte-identical at
the new
pin, which is the mechanical confirmation that nothing in the hydrated
sources
moved a dependency.

### The scanners, mirrored

odu's `effectEdges` test caught the `r`-key bug last round; kolu then
widened
its own two scanners against the dodges the narrow version misses
(`packages/tests/governance/awaitedFace.ts`, `runEdges.ts`). Those
widenings are
now mirrored here, in odu's own style.

The narrow check bans `await`/`void` applied **directly** to a face
call, and
that restraint is still right — it is what keeps the sanctioned
`await runUnary(<call>)` from matching. But the narrowness has a seam: a
**rename**. Bind the face to a name first and the path is gone from the
awaiting
line while the silence is identical:

```ts
const verb = client.surface.node.rerun;    await verb(x);           // alias
const p    = client.surface.node.rerun(x); await p;                 // stored description
const s    = client.surface;               await s.node.rerun(x);   // face handle
```

All three compiled and dispatched nothing. Every name bound to something
that
**starts** as a face path is now marked, and awaiting or voiding a
marked name
is a hit. Marking rather than banning the binding, because a face bound
and then
CALLED or composed is legitimate — and a rule that is merely loud gets
switched
off as fast as one that is wrong.

The same seam exists on the run-edge side, and it is worse there,
because the
allowlist's whole claim is that it can **see** every edge. `const run =
Effect.runPromise` makes a run call the scan cannot count, and so do
`.then(Effect.runPromise)`, `const { runFork } = Effect`, and a bare
named
import (`import { runPromise } from "effect/Effect"`) that drops the
namespace
the scan keys on. All four now **fail outright** rather than being
counted: an
alias travels, so there is no one file to hang an allowance on.

Two supporting changes the widening needs:

- **Comments and string literals are blanked by a character scan**
rather than
the old line-oriented comment skip. `//` inside a string literal (every
URL
here) and a quote inside a comment both defeat the line version, and the
new
  patterns are spelled in prose all over `effectEdges.test.ts` and
`effectEdge.ts`. The blanking is not a loophole — it is what lets the
ban stay
teachable. The bare-import check alone reads *through* strings, because
the
  module specifier it must see **is** one.
- **Scope is `src/` and `tests/`** — the two trees `tsconfig` compiles.
`tests/e2e/harness.ts` is production-shaped harness code that was
invisible to
both scans. `*.test.ts` stays out of the run-edge budget and *only* that
budget: a test IS a process edge, but a test that silently never
dispatches is
  the bug that hides the others, so the face ban covers it.

**The sweep: zero real hits.** odu's tree carries no face-path binding
at all,
no uncalled `Effect.run*` reference, and no bare run import; the single
sanctioned edge is still `src/common/effectEdge.ts`. Falsified in both
directions with a scratch probe — each of the six dodge shapes was
reported with
its citing line, and the two sanctioned spellings (`await
runUnary(<call>)`, and
a face bound then CALLED inside one) stayed clean.

## Round 4 — one commit, and nothing to import

kolu's `effect` branch moved exactly one commit past round 3's pin:
**`8f0ce9780` → `41d517754`**, `fix(client): the restore seed's wait for
the
answered tile is bounded` (juspay/kolu#2101 F2). It touches three files,
all in
`packages/client` — `hostScope/createSessionRestore.ts`,
`terminal/useSessionRestore.ts` and that file's seam test.

odu hydrates six kolu packages: `@kolu/surface`, `@kolu/surface-mcp`,
`@kolu/surface-remote`, `@kolu/shell-quote`, `@kolu/surface-map` and
`@kolu/log`. `packages/client` is not one of them and has no path into
them, so
the consumption verdict is **none** — with nothing to mirror this round,
unlike
round 3's scanner import.

So the diff is three lines of `npins/sources.json` (revision, url, hash)
and
nothing else. `bun install --frozen-lockfile` reports no changes and
`bun.lock`
/ `bun.nix` are byte-identical to the round-3 pin, the mechanical
confirmation
that nothing in the hydrated sources moved a dependency. The odu#43
ledger is
unchanged: row 6 (**kolu#2101**) stays ✅ **DRAINED**.

The venue run was green on both platforms first try. The GitHub-hosted
Actions
lane took three attempts, both failures on `ubuntu-latest` and neither
about
this diff: attempt 1 was the `ci::unit` CPU-starvation flake diagnosed
below,
and attempt 2 stalled inside `nix develop` — `ci::install`, `ci::fmt`
and
`ci::bun-nix-fresh` sat at zero progress for 43 minutes (the log's last
useful
line is `warning: download buffer is full`) until the job's 45-minute
timeout
cancelled it. `macos-latest` passed on the first attempt and was never
rerun.

## Round 5 — the osfacts fold, and Effect beta.103

kolu's `effect` branch moved five commits: **`41d517754` → `beb2f7418`**
(still
the head of juspay/kolu#2101). Two of them are visible from outside
kolu; the
other three are an atlas rebuild, a governance pass policing the graft's
Effect
pin, and a re-measurement of kolu's own beta-behaviour assumptions.

### The osfacts adoption — verdict **`none`**, re-grounded rather than
restated

`feat(osfacts)!` (**juspay/kolu#2103**, folded into the branch) takes
the
Effect-native osfacts client: `surface-daemon-supervisor`'s last two
Promise
seams — `ReadSocketHolders` and `ReadProcessIdentityAsync` — become
Effects,
the two `tryPromise` lifts that existed only because the old client
declared no
`effect` dependency are deleted, and two padi readers
(`samplePadiMemory`,
`scanSubtreePorts`) go Effect-native with them. `EndpointSpec` now has
zero
Promise-typed members.

**None of that is odu's.** odu hydrates exactly six packages —
`@kolu/surface`,
`@kolu/surface-mcp`, `@kolu/surface-remote`, `@kolu/surface-map`,
`@kolu/shell-quote` and `@kolu/log` — and `surface-daemon-supervisor`,
`surface-daemon` and `padi` are none of them.

The verdict is the same one this PR gave the change while it was still
an
unmerged kolu PR, and it was **re-derived at the new pin** rather than
carried
forward on trust. Diffing the whole pin range restricted to the hydrated
six
returns seven files: three `package.json`s (version literals only), two
under
`packages/surface/example/` — odu imports no example — and exactly one
source
file, `packages/surface/src/mirrorRemoteSurface.ts`, whose only hunk is
a doc
comment re-stamping its `BETA-ASSUMPTION` marker from `beta.102` to
`beta.103`.
odu does not import `@kolu/surface/mirror` either; its surface imports
are
`client`, `define`, `errors`, `link`,
`links/{direct,stdio,unix-socket}`,
`loopback`, `peer-server`, `project`, `server` and `unix-socket`.

### Effect 4.0.0-beta.103

The other commit that matters moves kolu's whole stack off beta.102 —
including
the seven vendored `@kolu/surface*` version literals odu hydrates. odu
resolves
**one** `effect` instance by design: the hydrated sources walk up to
this
repo's root `node_modules` (the `overrides` note and `bunfig.toml`'s
isolated-linker invariant), so odu's own literal is not free to lag
kolu's.
Four sites move in lockstep — the `@effect/platform-node` and `effect`
dependencies, and the two `overrides` that enforce the single realm.
`@effect/platform-node-shared` follows transitively.

**Lockfiles move this round**, unlike rounds 3 and 4 where they were
byte-identical: three new tarballs (`effect`, `@effect/platform-node`,
`@effect/platform-node-shared`) with new integrity hashes, and nothing
else.
The fetched package **set** is unchanged — `bun.nix` is a pure
version-and-hash
delta over the same 153 installs, regenerated by `just
regenerate-bun-nix` and
re-verified by the pipeline's own `ci::bun-nix-fresh` node on both
platforms.

**The removed-API sweep: zero hits.** Checked against odu's `src/` and
`tests/`
*and* against the hydrated `@kolu/*` sources `tsc` actually follows —
the
tsconfig includes only the two roots, but `skipLibCheck` does not exempt
raw
`.ts` imports, so a removed API used inside surface would surface here.
No
`SchemaIssue` `actual` read, no `getActual` or `.redact`, no
`Schema.UnknownFromJsonString`, no `SchemaMultiDocument`, no
`Context.mutate`
or `Context.getReferenceUnsafe`, and no `Schema.Record`
`keyValueCombiner`.
The Clock split lands on nobody: odu never touches Effect's `Clock`
service, so
`currentTimeNanos`' re-anchoring and the new `monotonicTimeNanos` reach
no odu
code path (`surface-remote`'s `clockProbe` is kolu's own name for a
liveness
poll, not that service). `ConfigProvider` and `Optic` are unused in both
trees.

The two changes that could bite **silently** rather than at compile time
are the
cancellable-microtask dispatch for yielded work and the
`Atom.withReactivity`
refresh fix — and they land on exactly the async and reactive machinery
odu's
loopback suite exercises. So the 368-test suite was run four consecutive
times
locally at the new pin, identical every time, before it ran again on
each venue
lane.

### The venue run, and the Actions lane

Both green, and — unlike round 4 — the GitHub-hosted lanes needed **no
reruns**:
`ubuntu-latest` and `macos-latest` each passed on the first attempt, so
the
CPU-starvation `ci::unit` flake diagnosed below did not fire this round.

One operational note on the venue run: the first `odu run` attempt
exited
without dispatching a node because the darwin venue was held by another
repo's
run — it reserved ordinal `562a51f#1` and stopped there. The run that
produced
the receipts is `562a51f#2`, dispatched after taking an agent-held `odu
lease`
on both platforms first. That is the same reserved-but-unused-ordinal
shape
round 4 recorded, and the reason a run's identity is checked rather than
assumed.

## Round 6 — the epoch gate, and a pin coupling that now fails fast

kolu's `effect` branch moved **`beb2f7418` → `994fd1ff9`** (still the
head of
juspay/kolu#2101) and this time it moved *through* odu rather than past
it.
Verdict **`breaks-at-bump`** — the first non-`none` round since the
migration
itself, and the first that changed odu source rather than only its pin.

### What the gate is, and why the stdio leg needed one

A stdio SERVER must now greet before it serves: exactly one
newline-terminated
line on stdout, ahead of its first RPC frame —

```
{"surfaceStdioGate":{"v":1,"verdict":"ready"}}
```

and `stdioLink` correspondingly **requires** a `StdioReadinessProof`,
minted
only by `awaitStdioReadiness` (new subpath
`@kolu/surface/links/readiness`)
after it has read that banner off the very stream the proof names.

The reason is a production incident, not a tidiness urge. Building a
link builds
an `RpcClient`, and building one starts Effect RPC's pinger. A peer from
a
*previous* protocol epoch accepts the pipe and then says nothing — it is
waiting
for a greeting in a protocol nobody speaks any more — so the pinger
kills the
link ~10s later with a generic transport error that reads exactly like
an
unreachable host. And an unreachable host retries forever, by design.
Every
remote host wedged in a permanent connect loop, with a log line that
blamed the
network.

What makes the fix worth consuming rather than merely absorbing is that
it is
**structural**. The brand behind the proof is a module-private
`WeakSet`, so a
hand-rolled look-alike does not construct — `stdioLink` throws with the
incident
in the message. The blind attach is not a discipline anyone has to
remember; it
does not typecheck. odu had two sites that were exactly that blind
attach, and
`tsc` named both before any test ran:

```
src/odu.test.ts(63,32): error TS2345: … Property 'readiness' is missing …
src/runner/processTeardown.test.ts(66,34): error TS2345: … Property 'readiness' is missing …
```

### Site 1 — the loopback suite greets, because it is the server

`src/odu.test.ts` serves over an **explicit** transport (`transport:
pair.server`). `serveOverStdio` writes the banner by itself only when
the
PROCESS is the agent — and the construction-time discriminant for that
is
precisely the `transport` argument. Over a loopback the caller *is* the
server,
so the caller greets, exactly as a daemon front does once it has
converged.
kolu ships `greetLoopback` for this:

```ts
const readiness = await greetLoopback(pair);
const link = await stdioLink({
  group: laneSurface.group,
  read: pair.client.read,
  write: pair.client.write,
  readiness,
});
```

This is the real protocol, not a shortcut around it: `greetLoopback`
writes the
banner on the server half and reads it back off the client half. That
matters
for what this suite is *for* — a loopback round-trip is only honest
evidence
about the ssh leg if it performs the same handshake the ssh leg
performs.

### Site 2 — the real child, and a budget that is stated

`src/runner/processTeardown.test.ts` drives a genuine `odu-runner
--stdio`
child. `src/runner/main.ts` hands `serveOverStdio` no explicit
transport, so
**that process IS the agent** and now emits the banner on stdout before
its
first frame. (Nothing else shares that channel: the runner's `log` has
always
written `process.stderr`.) So the suite reads it for real:

```ts
const readiness = await awaitStdioReadiness({
  read: runner.stdout,
  deadlineMs: RUNNER_GREET_BUDGET_MS,
  describe: "odu-runner --stdio",
});
const link = await stdioLink({
  group: laneSurface.group,
  read: runner.stdout,
  write: runner.stdin,
  readiness,
});
```

`deadlineMs` is required with no default, and deliberately so — expiry
is a
terminal verdict, and a number nobody chose is not a budget. This one is
`RUNNER_GREET_BUDGET_MS = 15_000`, and it is **not** the ssh leg's
`180_000`.
That larger figure is the sum of a *remote* daemon's convergence
ceilings —
cross-epoch reap 125s, socket rebind 30s, probe silence 8s, front-daemon
wait
10s — plus network round-trips. None of those exist here: the whole wait
is one
Bun start, one module graph, one `createLaneRunner()`. 15s is what this
suite
already gives every other local liveness fact (`until`'s default) and
half the
test's own 30s ceiling, so a runner that never greets fails as a
**classified**
readiness verdict with the peer's prelude quoted — not as an opaque
"test timed
out" thirty seconds later.

### What did not move

odu's `sshConnector` call sites need no change at all. The gate lives
*inside*
kolu's connector, which reads the banner and races it against the
child's own
death before handing back a `Connection` — and keeps the old
classification for
a host that is merely off (an ssh spawn failure is still `"network"`,
still
retried; a sleeping laptop is not condemned by this change).

Effect stays at **4.0.0-beta.103**, so unlike round 5 the lockfiles are
untouched: `bun install --frozen-lockfile` reports the same 153 installs
across
164 packages, no changes, and `ci::bun-nix-fresh` regenerates `bun.nix`
identically on both platforms.

### The same-pin coupling, now a fast failure instead of a hang

This is the operational half of the round, and it is worth stating
plainly
because it changes what a mismatched deployment *looks like*.

odu's coordinator and its lane runners are built from the same kolu pin,
so in
practice nothing changes: both ends greet, every dial proceeds. The
venue
lease probes show it working end to end at the new pin — `odu-runner:
idle —
waiting for run.configure over stdio`, then `first RPC received —
coordinator
attached`, on every pool host.

What changes is the *failure*. A coordinator on this pin dialing a
runner built
from an **older** pin used to hang forever: the old runner never greets,
the
pinger died at ~10s, the session read that as `"network"`, and
`"network"`
retries eternally. Now `awaitStdioReadiness` expires `silent` within the
180s
budget and the session classifies it **`"remote"`** — bounded, named,
and
reported. The coupling was always real; it is now *legible*. The
standing
ledger entry (#43) records it as the rule: coordinator and
runner
come from the same kolu pin, and a mismatch is a fast named failure
rather than
a wedged host.

### The venue run, and the Actions lane

Both green, both on the first attempt — the CPU-starvation `ci::unit`
flake
diagnosed below did not fire on either GitHub-hosted lane.

The venue run repeated round 5's operational shape exactly, which is
worth one
line because it is now a pattern rather than an anecdote: the **first**
`odu
run` attempt exited `0` having dispatched nothing, because kolu's own CI
held
`petit` — the pool's only darwin host — for its run of the very pin
being taken
here (`994fd1f#1`). It reserved ordinal `17f3229#1` and stopped. The run
that
produced the receipts is **`17f3229#2`**, dispatched after taking
agent-held
`odu lease`s on both platforms and waiting kolu's run out. An exit code
of `0`
is not evidence a run happened; the recorded ordinal is.

Worth noting what those lease probes showed on the way in, since it is
the
gate working over real ssh rather than in a test: every pool host
greeted —
`odu-runner: idle — waiting for run.configure over stdio`, then `first
RPC
received — coordinator attached`.

## Round 7 — the G-round, and a verdict that stayed `none` on purpose

Four commits here, taking the pin `994fd1ff9` → `f010fcdaf` → `8403697`
→
`8513645` → **`72da67ec6`**. Three are bookkeeping; the G-round is the
interesting one.

**`f010fcdaf` — one kaval commit, verdict `none`.** kolu's branch moved
a single
commit (`test(kaval): pin the --stdio front's converge → greet → relay
order`,
the daemon-front half of the epoch gate Round 6 adopted). odu does not
graft
kaval. Grounded rather than asserted: the whole range touches
`packages/kaval/src/stdioBridge{,.test}.ts` and one governance file, and
diffing
that same range restricted to odu's six hydrated packages returns
**empty**.

**`8403697` — the G-round, thirteen commits, verdict `none`.** This is
kolu
chasing the deploy-#2 production freeze: every host stale at once,
writes
accepted, **zero log lines**, cured only by a restart. Two of odu's six
hydrated
packages moved — `@kolu/surface` (client, server, reactor, peer-server,
unix-socket, `links/{wire,websocket}`, `mirrorRemoteSurface`, plus new
`containThrow` and `frameLimit` leaves) and `@kolu/surface-remote`
(`hostFanout`,
`reServeSurface`).

A round that large landing as `none` is a claim that deserves receipts
rather
than a shrug, so here is each delta against odu's actual tree. Typecheck
is
clean and the 368-test suite passes, 0 failures across 29 files.

| delta | kind | odu |
| --- | --- | --- |
| `mirrorRemoteSurface`, `MirrorRemoteSurfaceOptions` `log` → `onFault`
| **breaking** for direct callers | **zero hits** — odu imports no
`@kolu/surface/mirror` |
| `pumpRemoteSurface` / `reServeSurface` `onFault` | additive | zero
hits |
| `fenceStream` / `rawStream` `onRetry` | behaviour (throws contained) |
zero hits |
| `websocketLink` throwing thunk → `SocketOpenError` + re-dial |
behaviour | zero hits — **odu has no websocket leg at all** |
| `frameLimit` / `rpcSerializationLayer` | new export | inherited
transitively; a no-op today (see below) |
| reactor poll-cell seed now cell-local at T+0 | behaviour | zero hits —
odu uses no kolu poll cell |
| `runtime.done` observers | contract hardened | no observer — currently
unreachable, recorded as an obligation |
| `@kolu/surface-daemon` additions | new | not among the six hydrated
packages |

odu's full surface import set, which is what makes most of those rows
short:
`client`, `define`, `errors`, `link`,
`links/{direct,readiness,stdio,unix-socket}`,
`loopback`, `peer-server`, `project`, `server`, `unix-socket` — plus
`@kolu/surface-remote`, `@kolu/surface-mcp`, `@kolu/log` and
`@kolu/shell-quote`.
No `mirror`, no `websocket`, no reactor.

### The frame cap, asked properly

`frameLimit` is the one delta where "zero hits" would have been a lazy
answer.
odu is a CI runner; CI logs are unbounded user content; and the incident
behind
the constant is a 26 MB payload riding one frame and killing the whole
connection. So the real question is not whether odu *imports* it but
whether any
odu payload can scale into it.

It cannot, by two orders of magnitude. First, the constant is a **no-op
today**:
`RPC_MAX_FRAME_BYTES` is 16 MiB, which *is* Effect beta.103's own
default —
passing it explicitly stops a future Effect bump moving kolu's wire
silently, it
does not move it now. Second, odu's only member whose payload scales
with recipe
output is the `nodeLog` stream, and it was already clamped long before
this
round: `MAX_LOG_CHARS` = 64 KiB (`src/common/surface.ts:303-308`),
applied on
every mutation (`src/common/logTail.ts:64,69-70`) and on the stream's
first frame
(`:78-80`); `append` frames carry a single child pipe chunk (Node's 64
KiB
highWaterMark); and the MCP face re-clamps on accumulation
(`src/mcp/agentSurface.ts:364-369`). Nothing else on the surface carries
user
content — `nodes` and `header` are ids, status and timings, and
procedure I/O is
`{id}` / `{ok}` / lease-holder structs.

The standing rule that falls out, now on #43: **any new odu
member
whose payload scales with recipe output carries its own clamp.** The 16
MiB cap
is the backstop, not the budget.

### The one thing this round leaves owed

The G-round hardened `runtime.done`: a rejection means structural wiring
death,
and a "log and keep serving" observer is now *contractually wrong* — it
produces
precisely the zombie the incident diagnosed (process alive, socket
answering,
`done` already settled, every future fault unobservable).

odu attaches **no** `done` observer. All three `implementSurface` sites
(`src/runner/runner.ts:80`, `src/coordinator/run.ts:855`,
`src/mcp/serveForTest.ts:73`) destructure only `group`, `handlers` and
`ctx`.

That is not a break, and it is worth being precise about why rather than
promising a fix this PR does not contain: today it is **unreachable**,
because
`done` is supervised for owned sources only — the connectors of
*derived* cells
and *derived* collections — and odu declares none. Its cells are plain
`store`-backed and its one stream takes a plain `source`, which is not
supervised. So there is nothing here to observe yet.

The day odu declares its first derived member, that changes silently:
nothing
would fail to compile, and the fault would become unobservable at
exactly the
moment it became possible. That is why it is written down as an
obligation on
#43 instead of being quietly satisfied with an observer that
has
nothing to watch.

### `8513645` — one padi commit, verdict `none`

The branch settled with one last G-round follow-up, `fix(padi): one
canonical
spelling for a scratch path, create and append (G9a)` — a
darwin-specific fix in
padi's terminal scratch handling. odu does not graft padi. Grounded the
same way
as the kaval commit above: the range touches exactly two files, both
under
`packages/padi/src/`, and diffing it restricted to odu's hydrated six
returns
**empty**. It adds no surface delta, so the G-round verdicts above stand
unchanged rather than needing re-derivation.

### `72da67ec6` — verdict `none`, and the first range whose restricted
diff is *not* empty

Three commits: a kolu-server middleware fix (a response delivered
through the
failure channel is no longer reported as a 500), a surface-remote test
de-flake, and an e2e step-definition tweak.

This one deserves its own paragraph because the usual shortcut —
"restricted
diff is empty, therefore `none`" — does **not** apply. Diffing the range
against
odu's six hydrated packages returns two files:

```
packages/surface-remote/src/reServeSurface.test.ts | 178 ++++++++++++-----
packages/surface-remote/src/relayStream.test.ts    |  64 +++++--
```

Both are `.test.ts` — kolu's own tests, swapping a stopwatch-based wait
for a
convergence-based one. Filtering that same file list for non-test
sources
returns nothing, so no shipped `surface-remote` code moved.

So it is still `none`, but for a structural reason worth stating rather
than a
coincidence. Hydration copies **whole package trees** into
`node_modules/@kolu/*`,
tests included — but odu's tsconfig includes only odu's own roots and
`tsc`
follows the *import graph*. odu imports `@kolu/surface-remote`
exclusively
through its package entry: no `@kolu/surface-remote/src/…` deep path
anywhere,
and no kolu `.test` module in `src/` or `tests/`. kolu's test files are
therefore hydrated but never compiled, and odu does not run kolu's
suites. The
other two commits land outside the six entirely — `packages/server`,
`packages/tests` and a `nix/` VM scaffold.

## Gates

| gate | result |
| --- | --- |
| `tsc --noEmit` (repo-wide, incl. hydrated `@kolu/*`) | clean |
| `bun run test:unit` | **368 pass**, 0 fail, 29 files |
| `bun run test:e2e` | **16 pass**, 0 fail, 4 files — real nix-built
binaries, real `odu mcp` over a real stdio transport |
| `bun install --frozen-lockfile` | clean |
| `nix build .#odu .#odu-runner` | green |
| `nixpkgs-fmt --check` | clean |
| bun.nix freshness | regenerates identical |
| `grep -rn "zod\|@orpc" src/ tests/` | no hits |
| **odu runs odu**, two platforms | **16 ok · 0 failed · 0 errored** —
run **`c904d77#1`** (round 7, final pin `72da67ec6`), `x86_64-linux` on
`kolu-ci-2`, `aarch64-darwin` on `ci@petit`; every node green on its
first attempt, and all 16 `<recipe>@<platform>` contexts read `success`
on the commit (aggregate commit state `success`). Green the same way at
each intermediate pin: `a9e6084#1` (`8513645`), `af69720#1` (`8403697`),
`17f3229#2` (round 6), `562a51f#2` (round 5), `dabc992#4` (round 4),
`c35d009#1` (round 3), `5849078#1` (campaign 2) and `8d442f0#1`
(campaign 1). |
| **GitHub Actions**, two lanes | green on the **first attempt** on both
`ubuntu-latest` (4m56s) and `macos-latest` (8m42s) ([run
30979428410](https://github.com/juspay/odu/actions/runs/30979428410)) —
no rerun needed this round. |


### The Actions-lane `ci::unit` flake, now diagnosed

Round 2 recorded one unexplained `ci::unit@x86_64-linux` failure on
`ubuntu-latest`. Round 3 hit it again — `ci::unit` red on **both**
GitHub-hosted runners on the first attempt, green on rerun — so it got
chased
properly this time. It is **CPU starvation on the hosted runners, and it
is
pre-existing.**

The shape gives it away. On a GitHub-hosted runner `ci::unit` runs
*concurrently* with `ci::e2e` (a nix build) and `ci::bun-nix-fresh`
(2m53s of
`bun2nix`) on 3–4 shared cores; on the venue boxes and locally the same
three
nodes have room. Reproduced deliberately: the suite pinned to 2 cores
with every
core saturated fails, repeatably, in three timing-sensitive suites —

- `odu lane runner over stdio (loopback)` — the skip-cascade /
terminal-node arms
- `LiveView — focus and the log subscription` (its snapshot shows a
nonsense
  `496059h` duration, i.e. a clock read that lost its race)
- `wait_for_settle` — the settle/posting-debt arms

**And it is not this round's doing.** The same stress on the round-2
head
`5849078` fails in the same suites at the same rate (3 runs each: 3/0/2
failures
at `5849078`, 3/1/1 at `c35d009`). Unstressed, both are 368/368 green —
six
consecutive clean runs locally, a clean fresh clone, a clean run with an
empty
`$HOME` and `CI=true`, a clean localhost-lane run of the whole pipeline
(the exact workflow shape), and the venue run `c35d009#1` green on both
platforms first try. The new governance test never failed in any of it.

So: **diagnosed, not fixed here.** De-flaking three timing-sensitive
suites is a
real piece of work and it has nothing to do with a pin bump — smuggling
it into
this PR is exactly the sort of thing this PR has refused to do
elsewhere. The
second gap round 2 flagged still stands too: odu's Actions workflow runs
with
`--no-post`, so a failed node's log dies with the runner and the lane
cannot say
*why* it went red. Both belong in their own PR.

## One thing deliberately left out

`just apm` also wanted to register an upstream `srid/agency` Stop hook a
second
time (apm-cli 0.26 → 0.27 packaging drift, for a script already
committed
here). That is unrelated to this migration and would have duplicated a
live
hook, so the two hook files stay at HEAD; the next intentional apm run
can take
it on purpose.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
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