decide the phase 2 fork: turns stay on opencode run

The serve has a second, newer API surface nobody here had looked at, and it publishes
exactly what the parity doc calls impossible under stdin ignore: delivery steer and queue
on POST /prompt, an interrupt that does not tear down, and a per-session event stream with
an after cursor — the durable-replay machinery officer hand-built for claude, as a
primitive. That would have made migrating obvious.

It does not execute. A prompt is accepted with an admittedSeq, stored, emits
prompt.admitted and prompted, and then never steps. Ruled out separately: the model, the
permissions (build is *:allow, no pending requests), the per-request location (the surface
is location-scoped via header or a deepObject query, supplied everywhere, no change), and a
config gate. The legacy POST /session/id/message?directory= generates fine in 17s, so the
serve itself works — only the new pipeline is inert. session.next.* is the tell.

And not a version problem, which is the part everything here had backwards: this Mac runs
1.18.11 and alpha runs 1.17.9, measured. The dead pipeline was tested on the NEWER binary.
The original "this server runs 1.17.9" meant alpha and was copied to a machine where it was
false; corrected in runner.ts and the test.

So building against it now would produce code that looks finished and does nothing, which
is the failure mode this project keeps rediscovering. One request reopens the question
after any upgrade, and the doc names it.

Also de-flakes the lifecycle tests: they spawn real processes, and a fixed sleep(750) went
red once on a machine busy running these probes. Presence assertions poll now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-10 14:15:34 +01:00
co-authored by Claude Opus 5
parent 9118a9f76c
commit 41663bc207
4 changed files with 148 additions and 11 deletions
+25 -5
View File
@@ -15,7 +15,8 @@ import {
//
// This covers the NDJSON → ChatEvent mapping from `opencode run --format json`, which is the piece most
// likely to break against a new release: the event shape is not a documented contract, and this project
// already runs two different opencode versions across two machines (1.17.9 here, 1.18.11 elsewhere).
// already runs two different opencode versions across two machines (1.18.11 on the Mac, 1.17.9 on alpha
// — measured 2026-08-10; this file previously had them the wrong way round).
// Before this, a shape change would have surfaced as a silently empty or malformed turn.
//
// The fixtures below are the shapes the live 1.17.9 binary emits. If one of these tests fails after an
@@ -148,6 +149,22 @@ afterAll(() => {
rmSync(stubDir, { recursive: true, force: true });
});
/**
* Wait for something to BECOME true, rather than sleeping a guessed interval and hoping.
*
* These tests spawn real processes, so every "has it happened yet" is at the mercy of machine load —
* and a fixed `sleep(750)` duly failed once on a box that was busy running opencode probes. Polling
* makes a slow machine slow instead of red. Absence assertions still need a fixed wait, since there is
* no event to wait for; those are marked where they appear.
*/
async function waitFor(what: () => boolean, timeoutMs = 8000): Promise<void> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (what()) return;
await Bun.sleep(25);
}
}
describe('runOpenCodeTurn — a second turn on a live session', () => {
it('lets the replacement keep the session: no error, still listed, still killable', async () => {
const sessionKey = 'sess-supersede';
@@ -157,8 +174,11 @@ describe('runOpenCodeTurn — a second turn on a live session', () => {
runOpenCodeTurn({ sessionKey, prompt: 'first', cwd: stubDir }, CONFIG, emit);
runOpenCodeTurn({ sessionKey, prompt: 'second', cwd: stubDir }, CONFIG, emit);
// Let the superseded child actually die. Its exit handler is what used to reach across.
await Bun.sleep(750);
// Wait for the superseded child to actually die — its exit handler is what used to reach across.
// Absence assertion, so there is no event to wait for: give it a generous fixed window instead, and
// wait on something observable (the kill landing) rather than purely on the clock.
await waitFor(() => listRunningOpenCodeTurns().length === 1);
await Bun.sleep(1500);
// 1. Nothing is emitted for a turn the system replaced on purpose. This one mattered most: the emit
// is committed to chat_session_events by the sidecar, so a false "OpenCode exited" became history.
@@ -169,7 +189,7 @@ describe('runOpenCodeTurn — a second turn on a live session', () => {
// 3. And still reachable by the stop button, rather than orphaned with no handle.
killOpenCodeTurn(sessionKey);
await Bun.sleep(250);
await waitFor(() => messages.some((m) => m.type === 'opencode:event' && m.event.type === 'stopped'));
expect(listRunningOpenCodeTurns()).not.toContainEqual({ sessionKey });
expect(messages.some((m) => m.type === 'opencode:event' && m.event.type === 'stopped')).toBe(true);
});
@@ -207,7 +227,7 @@ describe('runOpenCodeTurn — a second turn on a live session', () => {
runOpenCodeTurn({ sessionKey, prompt: 'only', cwd: stubDir }, { ...CONFIG, bin: FAILING_BIN }, (m) =>
messages.push(m),
);
await Bun.sleep(750);
await waitFor(() => messages.some((m) => m.type === 'opencode:event' && m.event.type === 'error'));
expect(messages.some((m) => m.type === 'opencode:event' && m.event.type === 'error')).toBe(true);
expect(listRunningOpenCodeTurns()).not.toContainEqual({ sessionKey });
+7 -4
View File
@@ -60,10 +60,13 @@ const running = new Map<string, RunHandle>();
// Shape of `opencode run --format json` events.
//
// Verified live against opencode 1.17.9 (this server) and reported working on 1.18.11 elsewhere. Nothing
// enforces either — the binary is whatever is installed on the machine, and two machines in this project
// already differ. `runner.test.ts` pins the mapping itself so a shape change fails a test rather than a
// turn; if it starts failing, re-read the NDJSON from the installed binary before editing the test.
// Verified live against opencode 1.18.11 (this Mac) and 1.17.9 (alpha) — MEASURED on 2026-08-10, having
// previously been recorded the other way round here: the "this server" in the original note meant alpha,
// and the comment was copied to a machine where it was false. Nothing enforces a version anyway; the
// binary is whatever is installed, and the two machines in this project already differ.
//
// `runner.test.ts` pins the mapping so a shape change fails a test rather than a turn; if it starts
// failing, re-read the NDJSON from the installed binary before editing the test.
type RunPart = {
type?: string;
text?: string;