Ship the /ci skill: odu documents itself the way justci did - #2
Merged
Conversation
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
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`)._
This was referenced Jun 10, 2026
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)
This was referenced Jun 11, 2026
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.
This was referenced Aug 5, 2026
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)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
odu becomes an exporting apm package: justci shipped its
/ciskill 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.ymlis alreadytype: hybrid, so this PR only adds the skill source.Consumers wire it with one line in their
apm.yml:…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-bandstatus/logs/monitorover.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/cito this dependency in juspay/kolu#1252 once this merges — closing the loop: odu documents itself, downstream.Generated by Claude Code (model
claude-fable-5).