Skip to content

kernel: dispatch runnable steps in parallel - #137

Merged
kjgbot merged 14 commits into
mainfrom
feat/v2-parallel-dispatch
Sep 3, 2026
Merged

kernel: dispatch runnable steps in parallel#137
kjgbot merged 14 commits into
mainfrom
feat/v2-parallel-dispatch

Conversation

@kjgbot

@kjgbot kjgbot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Closes the issue #132 item 5 kernel slice.

Protocol change — a terminal run refuses event.emit and stream.append

Added by the rebase onto 512723c. A reviewer should meet this here rather than
discover it in a test diff. The call was made by the lane lead; the signoff is
asked to re-derive it rather than inherit it.

This is not a new rule. main already holds it, and simply fails to apply it
consistently.
A worker holds an llm lease, the run is cancelled out from under
it, and the worker does what a live worker does. On main, today:

late step.complete  -> THROW [lease_conflict]   <- main already refuses this
late stream.append  -> OK    {"offset":0}       <- and corrupts the journal
late event.emit     -> OK    {"matched":0}
>>> entries AFTER terminal run.completed: ["stream.appended"]

The late completion is refused and the late append is accepted, from the same
worker in the same moment
. main states the rule for step completions at
engine/remote.rs:44 (#142); this PR extends main's own rule to the remaining
mutating verbs.

It matters because the accepted append corrupts the run. stream.append journals
stream.appended after run.completed, producing a journal the daemon can no
longer fold on resume — same data directory, both binaries:

MAIN   resume -> exit=0  {"status":"completed","completion_reason":"success"}
BRANCH resume -> exit=1  Error: fold run journal
                         Caused by: journal entry 5 appears after terminal run.completed

So this does not remove a working capability; it removes a way to corrupt a
journal that main currently reports as success. The wire-conformance test was
asserting a bug.

kernel/DESIGN.md:384-385 agrees: event.emit "satisfies wait.event", which a
terminal run has none of — so main's matched: 0 is a no-op wearing a success
shape — and stream.append "journals stream.appended", which is the corruption
itself.

Callers that appended to a finished run now receive run_terminal. No in-repo
product code did so; the one live in-repo use appends against a parked run and
is unaffected.

Two test commits carry this, each isolated and each stating that the call was the
lead's:

  • 269fcc6 — a late step.complete after run.cancel reports run_terminal
    rather than lease_conflict. Error-code taxonomy only; the refusal and the
    journal assertions are unchanged.
  • 4dec6af — a terminal run refuses event.emit and stream.append. Behaviour,
    not taxonomy. Both verbs keep full wire coverage: their success paths move to a
    parked run, and the terminal case now asserts the refusal, agreeing with
    crash_resume/protocol_admission.rs — which is left untouched, because it
    pinned the invariant correctly.

Full diagnosis, the four-way check that no legitimate late append exists, and the
rebase audit: ops/reviews/20260903-pr137-repair-0903.md.

What changed

  • next_actions now emits journal-first start/action pairs for every step already Runnable in one folded state snapshot.
  • Emission order is the authored RunSpec.steps order, so the same journal state and simulated time produce the same batch.
  • Dependency barriers remain fold-driven: a join is not considered until every dependency completion is journaled and folded.
  • Crash/resume coverage pins partial-batch recovery and deterministic recovery of every durable in-flight lease.
  • The implementation stays inside relayflowd-core; no provider, product, tenant, network, filesystem, or wall-clock logic enters the scheduler. v1 remains supported/default.

Red-first evidence

Command on the pre-change scheduler after adding the fan-out test:

$ cargo test -p relayflowd-core machine_starts_every_runnable_step_in_authored_order
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.21s
     Running unittests src/lib.rs (target/debug/deps/relayflowd_core-eb215c870b5b8c71)

running 1 test
test machine::tests::machine_starts_every_runnable_step_in_authored_order ... FAILED

failures:

---- machine::tests::machine_starts_every_runnable_step_in_authored_order stdout ----

thread 'machine::tests::machine_starts_every_runnable_step_in_authored_order' (2153048) panicked at relayflowd-core/src/machine/tests.rs:61:5:
assertion `left == right` failed: both independent lanes must start
  left: 2
 right: 4
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace


failures:
    machine::tests::machine_starts_every_runnable_step_in_authored_order

test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 28 filtered out; finished in 0.00s

error: test failed, to rerun pass `-p relayflowd-core --lib`

Final verification

$ cargo test --workspace --quiet

running 22 tests
......................
test result: ok. 22 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.80s

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

running 19 tests
...................
test result: ok. 19 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 4.80s

running 1 test
.
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.07s

running 1 test
.
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.11s

running 3 tests
...
test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.21s

running 29 tests
.............................
test result: ok. 29 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.32s

running 5 tests
.....
test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

running 17 tests
.................
test result: ok. 17 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.06s

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

$ cargo clippy --workspace --quiet -- -D warnings
(no output; exit 0)

$ rustfmt --edition 2024 --check relayflowd-core/src/machine.rs relayflowd-core/src/machine/tests.rs relayflowd-core/src/machine/parallel_tests.rs
(no output; exit 0)

The repository wrapper was also attempted literally and could not start in this local environment:

$ ../ops/cargo.sh clippy --workspace -- -D warnings
mise ERROR cargo is not a valid shim. This likely means you uninstalled a tool and the shim does not point to anything. Run `mise use <TOOL>` to reinstall the tool.
mise ERROR Run with --verbose or MISE_VERBOSE=1 for more information

No merge performed; human review and merge remain required.

@coderabbitai

coderabbitai Bot commented Sep 2, 2026

Copy link
Copy Markdown

Review Change Stack

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Free

Run ID: 8ee20638-afc2-4ae6-9fae-d07c27987ab4

📥 Commits

Reviewing files that changed from the base of the PR and between 512723c and 7f6a30f.

📒 Files selected for processing (42)
  • kernel/relayflowd-core/src/machine.rs
  • kernel/relayflowd-core/src/machine/parallel.rs
  • kernel/relayflowd-core/src/machine/parallel_tests.rs
  • kernel/relayflowd-core/src/spec.rs
  • kernel/relayflowd-core/src/spec/tests.rs
  • kernel/relayflowd-core/src/state.rs
  • kernel/relayflowd-core/src/state/pins.rs
  • kernel/relayflowd-journal/src/append.rs
  • kernel/relayflowd-journal/src/lib.rs
  • kernel/relayflowd/src/engine.rs
  • kernel/relayflowd/src/engine/drive.rs
  • kernel/relayflowd/src/engine/effects.rs
  • kernel/relayflowd/src/engine/remote.rs
  • kernel/relayflowd/src/server.rs
  • kernel/relayflowd/src/server/protocol.rs
  • kernel/relayflowd/src/server/session.rs
  • kernel/relayflowd/src/server/session/assignments.rs
  • kernel/relayflowd/src/server/session/matching.rs
  • kernel/relayflowd/src/server/tests.rs
  • kernel/relayflowd/src/server/wire.rs
  • kernel/relayflowd/src/worker.rs
  • kernel/relayflowd/tests/crash_resume.rs
  • kernel/relayflowd/tests/crash_resume/concurrency.rs
  • kernel/relayflowd/tests/crash_resume/llm_support.rs
  • kernel/relayflowd/tests/crash_resume/parallel_lifecycle.rs
  • kernel/relayflowd/tests/crash_resume/pin_projection.rs
  • kernel/relayflowd/tests/crash_resume/protocol_admission.rs
  • kernel/relayflowd/tests/crash_resume/surface_identity.rs
  • kernel/relayflowd/tests/crash_resume/worker_capacity.rs
  • kernel/relayflowd/tests/crash_resume/workspace_identity.rs
  • kernel/relayflowd/tests/parallel_driver.rs
  • ops/reviews/20260903-pr137-repair-0903.md
  • sdk/src/journal-client.ts
  • sdk/src/protocol.ts
  • sdk/src/validate.ts
  • sdk/src/worker.ts
  • sdk/tests/deterministic-llm.test.ts
  • sdk/tests/live-kernel.test.ts
  • sdk/tests/validate.test.ts
  • testdata/hello-ladder.flow.yaml
  • testdata/hello-ladder.spec.canonical.json
  • testdata/hello-ladder.spec.sha256

Included review availability: Your plan provides up to 1 included review per hour; 0 remain after this review.


📝 Walkthrough

Walkthrough

The scheduler now starts compatible steps in deterministic batches, coordinates worker reservations and leases, validates canonical surfaces, and prevents mutations after run completion. Journal, protocol, SDK, and integration tests cover parallel execution, recovery, capacity, pin handling, and terminal admission.

Changes

Parallel execution and scheduler state

Layer / File(s) Summary
Canonical surface contracts
kernel/relayflowd-core/src/spec.rs, sdk/src/validate.ts, kernel/relayflowd-core/src/state/pins.rs, kernel/relayflowd/src/engine/effects.rs
Workspace and external surfaces require canonical identities. Matching and containment use semantic path rules.
Deterministic parallel scheduling
kernel/relayflowd-core/src/machine.rs, kernel/relayflowd-core/src/machine/parallel.rs, kernel/relayflowd-core/src/machine/parallel_tests.rs
Runnable steps start in authored-order batches. Retry timers wake together. Agent steps serialize on conflicting workspace, external, and stream surfaces.
Capacity-aware worker dispatch
kernel/relayflowd/src/engine/drive.rs, kernel/relayflowd/src/server/session/*, kernel/relayflowd/src/worker.rs, sdk/src/worker.ts
Worker capacity is reserved before dispatch. Worker selection accounts for reservations and assignments. Lease and assignment state updates follow durable completion entries.
Terminal-run mutation enforcement
kernel/relayflowd-journal/src/*, kernel/relayflowd/src/engine.rs, kernel/relayflowd/src/server.rs, kernel/relayflowd/src/server/protocol.rs, kernel/relayflowd/src/engine/remote.rs
Journal appends and mutating protocol operations reject completed runs. Rejected agent completions do not persist end pins.
Integration and verification coverage
kernel/relayflowd/tests/*, sdk/tests/*, testdata/*, ops/reviews/*
Tests cover parallel recovery, worker capacity, surface identity, pin projection, terminal admission, SDK validation, and protocol behavior.

Estimated code review effort: 5 (Critical) | ~120 minutes

Sequence Diagram(s)

sequenceDiagram
  participant RunState
  participant Engine
  participant ProtocolHub
  participant Worker
  participant Journal
  RunState->>Engine: emit runnable step batch
  Engine->>ProtocolHub: reserve worker capacity
  ProtocolHub->>Worker: send step.dispatch
  Engine->>Journal: persist attempt and dispatch state
  Worker->>Engine: submit heartbeat or completion
  Engine->>Journal: persist durable completion
  Journal->>ProtocolHub: release assignment and advance pins
Loading

Poem

A rabbit reads each line,
The patch grows clear beneath the moon,
Small changes hop in place,
Tests guard the garden path,
Reviews bloom before the dawn.

Warning

Some tools did not complete. Review the errors below.

🔧 Clippy (1.97.1)

Clippy execution failed


Note

🎁 Summarized by CodeRabbit Free

Your organization is on the Free plan. CodeRabbit will generate a high-level summary and a walkthrough for each pull request. For a comprehensive line-by-line review, please upgrade your subscription to CodeRabbit Essentials by visiting https://app.coderabbit.ai/settings/billing.

Comment @coderabbitai help to get the list of available commands.

kjgbot pushed a commit that referenced this pull request Sep 3, 2026
Second P1 in PR #137 review: `/mount/repo` and `/mount/./repo` were
admitted concurrently. `parallel::SurfaceIdentity::Opaque("workspace:...")`
compared the raw string; workspace surfaces bypassed the canonical path
identity used for external surfaces, letting alias forms conflict-check
as disjoint.

Repair:

- `SurfaceIdentity::External` → `SurfaceIdentity::Path{kind, namespace,
  components}` with `PathSurfaceKind::{Workspace,External}`. Workspace and
  external surfaces now share the canonical path identity but do not
  cross-collide.
- Rename `spec::external_surface_identity` → `spec::path_surface_identity`;
  callers of the workspace surface use the same canonicalizer.
- SDK `isCanonicalExternalSurface` → `isCanonicalPathSurface`; workspace
  entries now reject empty/./.. components with the same error class as
  external surfaces.
- New `kernel/relayflowd/tests/crash_resume/workspace_identity.rs` covers
  alias refusal and canonical subtree serialization over a real socket.
- 33 new SDK validate cases pin workspace canonicalization; 9 kernel
  parallel_tests cases (ancestor/descendant/sibling for both surface
  kinds) still green.

Evidence:

  $ cargo test -p relayflowd-core --lib machine::parallel
  test result: ok. 9 passed; 0 failed; ...

  $ cargo test -p relayflowd --test crash_resume workspace_aliases
  test result: ok. 1 passed; 0 failed; ... finished in 0.89s

  $ ./node_modules/.bin/vitest run tests/validate.test.ts
  Test Files  1 passed (1)
  Tests  48 passed (48)

Session-Id: 6cae47a0-1263-4c8b-bfaa-bd5ffc72e08e

@kjgbot kjgbot left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FINDINGS — structural signoff at bdd598c05d3172fd2fab6e3ec7009135ebdc2543

This is not a verdict on the current PR head (83db98bd1c6f39c187bb342fba23df2b022b53f9); I have not re-reviewed the commits pushed after bdd598c.

P1 — rejected agent completion can forge the next inspect attempt\x27s starting pins. step.complete advances the live worker pin projection from caller-supplied end_pins before the completion is validated or durably accepted (kernel/relayflowd/src/server.rs:269-270). A mismatched started_pins is then rejected by kernel/relayflowd/src/engine/remote.rs:275-289, but inspect recovery selects last_end_pins (kernel/relayflowd-core/src/machine.rs:232-237).

I reproduced this through the Unix socket: worker attached at repo@rev-0; attempt 1 supplied mismatched started_pins and end_pins: repo@forged-revision; the server journaled worker_error and dispatched attempt 2 at repo@forged-revision. This violates RFC-0001 Appendix A rules 2, 4 (inspect starting state), and 6, and fails closed incorrectly.

The focused capacity, terminal-admission, and canonical-surface suites passed; the full literal evidence and required regression are in the committed report that follows.

@kjgbot

kjgbot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor Author

Durable report committed and pushed: f13a51a — docs(review): record PR 137 structure findings.

Correction to the prior review wording: this is the next inspect attempt's starting pins.

kjgbot pushed a commit that referenced this pull request Sep 3, 2026
Records the six exact-head verdicts, the shared failure mode (tests that
prove the mechanism fires rather than that the bound holds, under a CI that
runs neither suite), my wrong design call on #137's canonicalization, the
silent-merge trap that nearly shipped a regression on #136, and the verified
state of cloud #3264 against its owner's DONE claim.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FtQSAcGDta5VH9xiZFT4sR

Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
kjgbot added 14 commits September 3, 2026 14:26
Session-Id: 01a062cc-f525-7d01-932e-a634815114c1

Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1

Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: 01a062cc-f525-7d01-932e-a634815114c1

Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1

Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: 01a062cc-f525-7d01-932e-a634815114c1

Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1

Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: 01a062cc-f525-7d01-932e-a634815114c1

Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1

Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: 01a062cc-f525-7d01-932e-a634815114c1

Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1

Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Second P1 in PR #137 review: `/mount/repo` and `/mount/./repo` were
admitted concurrently. `parallel::SurfaceIdentity::Opaque("workspace:...")`
compared the raw string; workspace surfaces bypassed the canonical path
identity used for external surfaces, letting alias forms conflict-check
as disjoint.

Repair:

- `SurfaceIdentity::External` → `SurfaceIdentity::Path{kind, namespace,
  components}` with `PathSurfaceKind::{Workspace,External}`. Workspace and
  external surfaces now share the canonical path identity but do not
  cross-collide.
- Rename `spec::external_surface_identity` → `spec::path_surface_identity`;
  callers of the workspace surface use the same canonicalizer.
- SDK `isCanonicalExternalSurface` → `isCanonicalPathSurface`; workspace
  entries now reject empty/./.. components with the same error class as
  external surfaces.
- New `kernel/relayflowd/tests/crash_resume/workspace_identity.rs` covers
  alias refusal and canonical subtree serialization over a real socket.
- 33 new SDK validate cases pin workspace canonicalization; 9 kernel
  parallel_tests cases (ancestor/descendant/sibling for both surface
  kinds) still green.

Evidence:

  $ cargo test -p relayflowd-core --lib machine::parallel
  test result: ok. 9 passed; 0 failed; ...

  $ cargo test -p relayflowd --test crash_resume workspace_aliases
  test result: ok. 1 passed; 0 failed; ... finished in 0.89s

  $ ./node_modules/.bin/vitest run tests/validate.test.ts
  Test Files  1 passed (1)
  Tests  48 passed (48)

Session-Id: 6cae47a0-1263-4c8b-bfaa-bd5ffc72e08e

Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1

Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Session-Id: 01a0667b-bd7e-73c1-8e14-e3e9d13d136e

Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1

Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Reverts 83db98b's accept-and-normalize and restores 53bfee0's strict rule
for BOTH workspace and external surfaces. An independent signoff at 83db98b
found a P0: exactly-once effects can double-fire.

83db98b widened the *external* accept set the same way it widened workspace
-- its own test diff moved "/provider/item/" from reject to accept -- but
added `workspace_surfaces_equal` only. The exactly-once ledger key is a raw
SQL string:

  PRIMARY KEY (step_id, idempotency_key, surface_path)
  relayflowd-journal/src/lib.rs:48, append.rs:162

`idempotency_key = sha256(run_id || step_id)` (machine.rs:396) and `step_id`
are both constant across attempts, so `surface_path` is the only variable in
that key -- and it had two legal spellings. Executed against the real
SqliteJournal at 83db98b:

  attempt1 '/provider/item'   deduped = false
  attempt2 '/provider/item/'  deduped = false
  effect_count = 2 ; confirmed_effect_count = 1

`deduped=false` means "you owe the provider call" (engine/effects.rs:17-24),
so one logical effect fires twice. At the parent commit it failed closed at
effects.rs:127.

Accept-and-normalize only holds if EVERY identity comparison routes through
the same normalization. 83db98b reached fifteen workspace comparison sites
and got all fifteen right; it missed the sixteenth, which happens to be the
one guarding exactly-once. Uniform reject needs no such completeness: one
surface has exactly one spelling, and a non-canonical one never enters the
system.

Two facts make the strict rule the house rule rather than a new constraint:
testdata/hello-agent.flow.yaml already authored `surface: repo`, so the
ladder fixture was the outlier; and 53bfee0's own contract test
`workspace_mounts_and_worktrees_must_have_one_canonical_spelling` already
asserted `/mount/repo/` is refused, which accept-and-normalize contradicted.

RED (before this change, with the tests restored to the strict contract):

  $ cargo test -p relayflowd-core --lib spec::tests::external_surface
  ---- spec::tests::external_surface_paths_must_have_one_canonical_spelling stdout ----
  panicked at relayflowd-core/src/spec/tests.rs:146:9:
  accepted non-canonical surface "/provider/item/"
  test result: FAILED. 0 passed; 1 failed

GREEN:

  $ cargo test --workspace
  22 + 31 + 1 + 1 + 4 + 3 + 37 + 5 + 18 passed; 0 failed

  $ ./node_modules/.bin/vitest run
  259 passed (260); 1 pre-existing live-kernel failure, unchanged

Fixtures: testdata/hello-ladder.flow.yaml authors `surface: repo`, and its
canonical JSON and sha256 are regenerated through the SDK compiler rather
than hand-edited. The canonical diff is one character; the hash moves
ecccd7b2..de095a29 -> 57cac294..f6d57944, and spec_parity confirms kernel and
SDK still agree byte-for-byte.

`workspace_surfaces_equal` is kept across its 18 call sites. Under a single
spelling it is equivalent to string equality for valid surfaces, but it still
compares parsed identities and so fails closed when either side does not
parse -- defense in depth at the pin/declaration seam for exactly the bug
class above. Removing it would be an 18-site change for no safety gain.

A non-canonical spelling is no longer a surface at all, so
`external_surface_contains("/provider/item/", "/provider/item")` now fails
closed rather than resolving to the canonical form, and the two
ancestor/descendant conflict cases that exercised the terminal slash are
dropped as unreachable states rather than restated.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FtQSAcGDta5VH9xiZFT4sR

Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1

Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1

Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
…ease_conflict

This edits a test that judges this branch's own behaviour, which AGENTS.md
rails against. It is therefore its own commit, touching nothing else, and the
taxonomy call was made by the lead (relayflow-lead-0903), not by this branch.
Flagging it for the independent signoff to re-derive rather than inherit.

The test is #142's own — "cancels over the real socket and rejects the lease
holder after closure", added by main in feat(kernel): add durable run
cancellation. It cancels a run over the socket, then has the lease holder
complete the step, and asserted the refusal carried `lease_conflict`.

What this branch changed is which of two refusals fires first, not whether the
completion is refused. `step.complete` now runs the `ensure_mutable` admission
gate before `completion_worker`:

  ensure_mutable(&engine, &params.run_id)?;              // -> run_terminal
  let worker_id = hub
      .completion_worker(connection_id, &key)
      .map_err(protocol_conflict)?;                      // -> lease_conflict

Each code has exactly one producer in the tree (server/protocol.rs:58 and :46),
so the ordering fully determines which is returned.

Unchanged by this commit, and still asserted by the same test: the completion is
refused, exactly one run.cancel.requested entry exists, and exactly one
run.completed entry exists carrying completionReason "canceled". Only the error
code moved.

The lead's reasoning for preferring run_terminal: lease_conflict tells a worker
"someone else holds your lease", which is false here -- nobody holds it, the run
is over -- and it invites a retry that terminality does not. Checking "can this
run accept mutations at all?" before "who holds this lease?" is also the correct
precedence: the cheaper, more general, fail-closed question first.

The rejected alternative was weakening ensure_mutable so lease_conflict still
won. That trades a correct guard for a stale expectation.

Mutation-verified, both directions, on the rebased tree at 512723c.

RED (before this commit):

  $ ./node_modules/.bin/vitest run
   FAIL  tests/live-kernel.test.ts > ... > cancels over the real socket and rejects the lease holder after closure
  AssertionError: expected JournalProtocolError: run_terminal: run 0... { code: '...' } to match object { code: 'lease_conflict' }
  - Object {
  -   "code": "lease_conflict",
  + JournalProtocolError {
  +   "code": "run_terminal",
        Tests  2 failed | 409 passed | 3 skipped (414)

GREEN (after):

  $ ./node_modules/.bin/vitest run
        Tests  1 failed | 410 passed | 3 skipped (414)

The one remaining failure is the pre-existing `JournalClient wire conformance`
failure, which is independent: it survives moving ensure_mutable after
completion_worker, whereas this test does not.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FtQSAcGDta5VH9xiZFT4sR

Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
Supersedes an unmerged first pass of this report that targeted 990093b. Every
command is pinned to a literal SHA rather than the origin/main ref, which moved
twice during the task.

This rebase produced ZERO conflicts, which is the risk rather than the result:
on #139's rebase a line that reverted a lowering auto-merged silently. Every
hunk was therefore audited by reading.

512723c adds #138, which touches four files this branch also edits (spec.rs,
spec/tests.rs, validate.ts, validate.test.ts) and, critically, moves timeoutMs
to deterministic-only in TWO independent places: the step-fields allowlist and
compileStep's base spread. Getting one right and missing the other yields a spec
that validates but lowers wrong, and validateSpec cannot see it. Both halves are
byte-identical to 512723c and both were re-proved behaviourally through
compileYaml + toKernelSpec: a deterministic step lowers to timeout_ms, llm and
agent are refused at the allowlist. #136's `output` line survives in both verb
lists.

Artifact survival, both directions. All 15 of #138's blobs hashed before and
after: 11 identical including compile.ts and step-fields.ts; the 4 that moved
are the 4 this branch edits and each is a pure addition. Every line of #138
content absent afterwards was enumerated: a first pass with plain diff reported
14, of which 7 were false positives from re-indentation and one rustfmt
attribute rewrap; whitespace-insensitively 7 remain, all attributed and none
authored by #138. In the other direction, a whole-tree set-diff of the branch's
own change set before against after reports exactly three deltas across 41
files, the same three deliberate resolutions as the first pass and nothing else.

The branch's own gate is proved where it lives rather than where it is
convenient: a canonical spec compiled through the SDK, its lowered kernel spec
then mutated and submitted over a real socket with the SDK out of the path. The
kernel refuses all five non-canonical forms across both surface kinds, and
accepts the canonical control.

Gates: tsc --noEmit, tsc -p tsconfig.type-tests.json (a gate #138 added that the
brief's list predates), and tsc -p tsconfig.tests.json all pass; cargo test
--workspace is 130 passed, 0 failed; vitest is 410 passed with one failure, the
pre-existing wire-conformance one. Rust test names set-difference to exactly the
union of both parents, 130 executed against 130 expected.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FtQSAcGDta5VH9xiZFT4sR

Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
…branch's

An earlier version of this report called the vitest wire-conformance failure
"pre-existing". That was wrong. It was observed at 83db98b, which is #137's own
head and already carries bdd598c; "pre-existing at the branch parent" is not
"pre-existing on main", and I repeated the stronger claim without testing it.

Determined empirically instead. A throwaway worktree at 512723c with its own
kernel build and RELAYFLOWD_BIN pinned to it runs tests/live-kernel.test.ts at
21 passed (21), wire conformance included. The same file fails on this branch.
The failure is the branch's.

It is also not an error-code taxonomy change, which is why no assertion was
touched. bdd598c gates six verbs with ensure_mutable, two of them event.emit and
stream.append. The wire-conformance test starts a deterministic run, which
completes immediately, then calls both on it and asserts they SUCCEED
(matched === 0, offset === 0). Main accepts them; this branch refuses them. The
observable protocol behaviour changed from accepted to refused.

The refusal is load-bearing rather than gratuitous, and the obvious fix is the
wrong one. On main, stream.append against a terminal run writes a
stream.appended entry after run.completed. The branch's state.rs fold guard
rejects exactly that, so a journal main produces is one this branch cannot load:
the same data directory resumes clean under the main binary and fails under the
branch binary with "journal entry 5 appears after terminal run.completed".
Relaxing ensure_mutable to turn the test green would let the daemon write
journals it then cannot resume, which is a durability bug and worse than a red
test.

The rebase also surfaced a direct contradiction: the branch's own
protocol_admission test asserts stream.append and event.emit return run_terminal
and leave the journal unchanged, while main's wire-conformance test asserts they
succeed. Both are in the tree, both were written deliberately, and they cannot
both pass. Because protocol_admission pins the error code, an accept-and-ignore
compromise would require editing that gate too.

Three options are laid out in the report. None is implemented; the branch is
unchanged and the push is on hold pending the lead's call.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FtQSAcGDta5VH9xiZFT4sR

Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
PROTOCOL CHANGE. This edits main's test to match a deliberate behaviour change
in this branch, so it is its own commit, touching nothing else. The call was the
lead's (relayflow-lead-0903), not this branch's. The signoff should re-derive it
rather than inherit it.

This is main's test -- "JournalClient wire conformance against live relayflowd".
It started a deterministic run, which completes immediately, then called
event.emit and stream.append on that terminal run and asserted both SUCCEED:

  expect((await client.eventEmit(run_id, 'unmatched', {ok:true})).matched).toBe(0);
  expect((await client.streamAppend(run_id, 'results', {answer:4})).offset).toBe(0);

bdd598c admits every mutating verb through `ensure_mutable`, so this branch
refuses both with run_terminal. Unlike the step.complete change in 269fcc6, this
is not an error-code taxonomy move: observable behaviour on a shipped verb pair
goes from accepted to refused.

It does not remove a working capability. It removes a way to corrupt a journal
that main reports as success. On main, stream.append against a terminal run
journals stream.appended AFTER run.completed, and this branch's state.rs fold
guard rejects exactly that -- so main produces journals the daemon cannot fold
on resume. Same data directory, both binaries:

  MAIN   resume -> exit=0  {"status":"completed","completion_reason":"success"}
  BRANCH resume -> exit=1  Error: fold run journal
                           Caused by: journal entry 5 appears after terminal run.completed

The realistic shape is worse than that synthetic one, and shows main is already
self-inconsistent. A worker holds an llm lease; the run is cancelled out from
under it; the worker then does what a live worker does:

  late step.complete  -> THROW [lease_conflict]      <- main already refuses this
  late stream.append  -> OK    {"offset":0}          <- and corrupts the journal
  late event.emit     -> OK    {"matched":0}
  >>> entries AFTER terminal run.completed: ["stream.appended"]

Main already holds "a terminal run accepts no step completions"
(engine/remote.rs:44, from #142). This branch extends the same rule to the other
mutating verbs, which is what the words already meant.

Checked before changing anything, rather than assuming: no product code calls
either verb (only the JournalClient method definitions); no doc sanctions a
post-terminal append -- kernel/DESIGN.md:384-385 says event.emit "satisfies
wait.event", which a terminal run has none of, and stream.append "journals
stream.appended", which is the corruption; and the one in-repo live use
(crash_resume/llm.rs) is against a parked run and is unaffected.

The test keeps full wire coverage of both verbs. Their success paths move to the
parked llm run, which is the only state in which appending to a run's journal is
meaningful, and the terminal case now asserts the refusal -- matching
crash_resume/protocol_admission.rs, which is left alone because it pins the
invariant correctly.

  $ ./node_modules/.bin/tsc -p tsconfig.tests.json
  (clean)
  $ RELAYFLOWD_BIN=<this worktree's own build> ./node_modules/.bin/vitest run
   Test Files  23 passed | 1 skipped (24)
        Tests  411 passed | 3 skipped (414)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FtQSAcGDta5VH9xiZFT4sR

Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
… behind it

The lead chose option 1 and asked that the assumption behind it be tested rather
than inherited: is there any legitimate late stream.append or event.emit on a
terminal run, out-of-band async completion in particular. Checked four ways and
the assumption holds.

No product code calls either verb — only the JournalClient definitions, a
loopback mock, and tests. No doc sanctions a post-terminal append: DESIGN.md says
event.emit satisfies a wait, which a terminal run has none of, and stream.append
journals stream.appended, which is the corruption itself. The one live in-repo
use appends against a parked run and is unaffected.

The async shape was executed rather than reasoned about, and it cuts against main:
a worker holding an llm lease on a run cancelled out from under it has its late
step.complete refused with lease_conflict while its late stream.append is
accepted in the same moment, journalling after run.completed and producing a
journal the branch cannot fold. Main already holds the rule for step completions
at engine/remote.rs:44; this branch extends it to the remaining mutating verbs.

Adds the PR-body Protocol change section, and records a CI finding: the two
contradicting tests both passed because neither runs in flows CI. cargo test
never runs at all, and CI's vitest names four files, of which live-kernel.test.ts
is not one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FtQSAcGDta5VH9xiZFT4sR

Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
…nsistency

The strongest argument for the change is not the resume demonstration, it is
that main already holds the rule and fails to apply it uniformly: a worker whose
run was cancelled has its late step.complete refused with lease_conflict and its
late stream.append accepted, in the same moment. This PR extends main's own rule
rather than imposing a new one. Reordered so a reviewer meets that first, with
the resume corruption as the reason it matters and DESIGN.md:384-385 as
corroboration.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FtQSAcGDta5VH9xiZFT4sR

Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1
@kjgbot
kjgbot force-pushed the feat/v2-parallel-dispatch branch from 83db98b to 7f6a30f Compare September 3, 2026 12:52
@kjgbot

kjgbot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor Author

Merging — basis at 7f6a30fd, with one correction to the Protocol change section

Independent signoff REVIEW_PASSED, no P0, no P1, at this exact SHA. I wrote the Protocol change framing above and part of it overstates. Correcting it here rather than quietly editing.

Correction: only one of the two verbs corrupts a journal

The section says the change "removes a way to corrupt a journal", of both verbs. On main, event.emit on a terminal run writes no journal entry at all — only stream.append does. So the corruption argument is true of stream.append and not of event.emit.

Gating event.emit is still right, but on a different argument: a terminal run has no wait.event to satisfy, so main’s OK {"matched": 0} is a success shape for a guaranteed non-delivery, and uniformity across mutating verbs is worth more than a response that tells a caller nothing happened by returning success. That is the argument, and the body should have made it.

Second, smaller: "corrupt" overstates even for stream.append. The reviewer showed the message remains readable on main (stream.read → {"messages":[{"answer":4}]}). It is an ordering-invariant violation — an entry after run.completed that the daemon cannot fold on resume — not data loss.

The argument that carries the change is the one the final commit leads with, and it holds entirely inside main: from one worker in one moment, a late step.complete throws lease_conflict while a late stream.append returns OK {"offset":0}. engine/remote.rs:44 already holds this rule for step completions; this extends it to the rest.

Both lead calls were re-derived, not inherited

I made two calls the owner escalated. The reviewer agreed with both and sharpened each.

On 269fcc6 it did not judge on which word reads better — it asked whether ensure_mutable-first swallows lease_conflict where that is true, and proved it does not: worker-b completing worker-a’s step on a live run still throws lease_conflict: completion came from a worker that does not hold the lease, on both binaries. And my premise was weaker than the truth — on main’s terminal path the message is lease_conflict: attempt has no active worker lease, so the code and its own message already disagreed.

On 4dec6af, the out-of-band worry I raised is answered textually rather than by judgement: kernel/DESIGN.md:383 names step.complete as the out-of-band path, and main already refuses that when terminal — so RFC-0001’s async worker creates no legitimate late append. It also checked two shapes I had not named — wait.human and durable channels (stream.append takes a run_id, so streams are per-run) — and both hold.

The P0 — closed, and verified against the commit that introduced it

Reviewing against main shows nothing here, because main never had this defect. The reviewer built c21f039 outside the repo and fired one effect twice, changing only the spelling: /provider/itemdeduped=false, /provider/item/deduped=false. Both fire. At this head the second is refused. Corpus 26/26 at the kernel boundary with the SDK bypassed and 26/26 in TS — Rust and TS agree on every row across both surface kinds. spec_parity 5/5 with canonical JSON recompiled through the SDK matching the pinned 57cac294…. workspace_surfaces_equal across its 18 call sites cannot reintroduce equivalence: repo/ is refused at worker.attach here and accepted at c21f039.

The load-bearing fixture is right for the right reason — the unknown-key test now fails on unknown field 'workspaces' rather than on the surface spelling.

Gates

tsc exit 0; cargo 130/0; vitest 411 passed / 3 skipped / 0 failed. Verified by test-name set-diff against a re-run of pure 512723c with its own pinned binary: zero names lost in either suite (102+28=130, 388+23=411, exact). compile.ts and step-fields.ts byte-identical to 512723c. Whitespace-insensitive attribution reduced 13 apparent deletions to 7 true ones, none authored by #138.

Carried forward, non-blocking

  • The CI gap deserves its own issue. protocol_admission and the wire-conformance test encoded opposite contracts and both passed, because neither runs in CI — one workflow, cargo build but never cargo test, and exactly four named SDK files. The tree can hold a contradiction indefinitely and stay green.
  • workspace_surfaces_equal’s doc comment at spec.rs:230-232 still describes the behaviour 93c1a3c reverted — code right, comment now inverted.
  • A bounded reservation-leak window at engine/drive.rs:153 where ? returns before release; self-heals on detach.
  • Caller errors surfaced as internal, including the P0’s own refusal message.
  • The PR body undersells the PR. It describes only the first of eight kernel commits. 8edba06 (forged pins) — which stops the hub advancing worker pins from unvalidated params.end_pins before verification — is arguably the strongest commit here and goes unmentioned.

Squash-merging under the checklist: independent signoff PASSED at the exact head, CI green there, base clean, no unresolved feedback, no unpushed WIP.

🤖 Generated with Claude Code

https://claude.ai/code/session_01FtQSAcGDta5VH9xiZFT4sR

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