From 9faf5068be07a11a636d530edeceffa387873391 Mon Sep 17 00:00:00 2001 From: "Robert E. Lee" Date: Wed, 29 Jul 2026 06:20:26 -0700 Subject: [PATCH 1/2] =?UTF-8?q?fix(execution):=20opencode=20serve=20protoc?= =?UTF-8?q?ol=20=E2=80=94=20object-shaped=20model=20post=20+=20settled=20t?= =?UTF-8?q?erminal=20stream?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found by the #76 three-host live smoke, where every opencode worker failed and the CLI then crashed: 1. prompt_async 400s: the adapter posted the configured model as a bare string, but opencode serve's schema expects {providerID, modelID} or null ("Expected object | null, got \"opencode/kimi-k3\" at [\"model\"]" — verified against serve 1.18.8). provider/model strings are now split on the first slash into the schema object (model ids may contain slashes); a model with no provider prefix is omitted so the server's own configured default applies — never a guessed provider. 2. Process crash after the failure verdict: when the prompt post threw, launch() stopped the owned server but left the terminal SSE promise unconsumed; its socket-close rejection surfaced as an unhandled rejection (TypeError: terminated, UND_ERR_SOCKET) that killed node AFTER the run verdict had printed. A no-op second consumer is attached at creation — observe() still sees genuine terminal rejections. Regression tests: serve-shaped model posts (incl. slash-containing model ids and the bare-id omission), and a 400-prompt teardown asserting no unhandled rejection while the owned server still receives SIGTERM. Verified end-to-end: with this fix, the opencode leg of the #76 smoke completes — ak run feature with all four workers routed to opencode (kimi-k3) succeeded, produced the sandboxed repo mutation, and emitted normalized terminal evidence (observed model, provider, durations). --- src/lib/execution/opencode.mjs | 22 ++++++++- tests/kit/opencode-execution.test.mjs | 69 +++++++++++++++++++++++++++ 2 files changed, 90 insertions(+), 1 deletion(-) diff --git a/src/lib/execution/opencode.mjs b/src/lib/execution/opencode.mjs index 9f78593..ec9f809 100644 --- a/src/lib/execution/opencode.mjs +++ b/src/lib/execution/opencode.mjs @@ -104,6 +104,19 @@ async function requestNoContent(fetchFn, endpoint, password, pathname, if (!response?.ok) throw new Error(`${method} ${pathname} failed with HTTP ${response?.status ?? 'unknown'}`); } +/** opencode serve's prompt_async schema takes `model` as an OBJECT + * `{providerID, modelID}` or null — a bare string is a 400 ("Expected object + * | null, got …"). The runner's configured model travels as one + * `provider/model` string (routing.mjs), so split on the FIRST slash here + * (provider ids never contain '/'; model ids may, e.g. openrouter). A model + * with no provider prefix falls back to the server's own configured default + * (model omitted) rather than guessing a provider. */ +function serveModelFor(configuredModel) { + if (typeof configuredModel !== 'string' || !configuredModel.includes('/')) return null; + const slash = configuredModel.indexOf('/'); + return { providerID: configuredModel.slice(0, slash), modelID: configuredModel.slice(slash + 1) }; +} + async function requestWithin(fetchFn, endpoint, password, pathname, options, timeoutMs) { const controller = new AbortController(); let timer; @@ -270,8 +283,15 @@ export function createOpenCodeExecutionAdapter({ const eventAbort = new AbortController(); const eventResponse = await fetchFn(`${endpoint}/global/event`, { headers, signal: eventAbort.signal }); const terminal = waitForTerminalEvent(eventResponse, session.id, { signal: eventAbort.signal }); + // Every path that abandons this promise without observe() consuming it + // (a prompt post that throws, cancel/cleanup teardown) would otherwise + // leave its socket-close rejection unhandled — Node's default turns + // that into a process crash AFTER the run verdict. A no-op second + // consumer keeps teardown honest; observe() still sees the rejection. + terminal.catch(() => {}); + const model = serveModelFor(state.worker.configuredModel); await requestWithin(fetchFn, endpoint, password, `/session/${encodeURIComponent(session.id)}/prompt_async`, { - body: { agent: 'build', ...(state.worker.configuredModel ? { model: state.worker.configuredModel } : {}), parts: [{ type: 'text', text: state.prompt }] }, + body: { agent: 'build', ...(model ? { model } : {}), parts: [{ type: 'text', text: state.prompt }] }, }, timeoutMs); return { ...state, endpoint, password, child, sessionId: session.id, terminal, eventAbort }; } catch (error) { diff --git a/tests/kit/opencode-execution.test.mjs b/tests/kit/opencode-execution.test.mjs index 39a291a..d505a7a 100644 --- a/tests/kit/opencode-execution.test.mjs +++ b/tests/kit/opencode-execution.test.mjs @@ -136,6 +136,75 @@ test('a server that ignores TERM receives one bounded KILL fallback and reports assert.equal(result.exitCategory, 'orphaned'); }); +// Regression (#76 smoke): the adapter posted `model` as a bare string, but +// opencode serve's prompt_async schema expects {providerID, modelID} or null — +// every configured-model worker 400'd ("Expected object | null, got …"). +test('configured models post as a serve-shaped {providerID, modelID} object (or are omitted)', async () => { + const posts = []; + const child = { kill: () => true }; + const fetchFn = async (url, init = {}) => { + if (url.endsWith('/global/health')) return response({ healthy: true }); + if (url.endsWith('/session') && init.method === 'POST') return response({ id: 'ses-m' }); + if (url.endsWith('/global/event')) return response(null, { body: sse('data: {"payload":{"type":"session.idle","properties":{"sessionID":"ses-m"}}}\n\n') }); + if (url.endsWith('/prompt_async')) { posts.push(JSON.parse(init.body)); return response(null, { status: 204 }); } + if (url.endsWith('/message')) return response([{ info: { role: 'assistant' } }]); + if (url.endsWith('/instance/dispose')) return response(null, { status: 204 }); + throw new Error(`unexpected URL ${url}`); + }; + const adapter = createOpenCodeExecutionAdapter({ + fetchFn, spawnFn: () => child, reservePort: async () => 43129, secret: () => 'ephemeral', clock: () => '2026-07-29T00:00:00.000Z', + }); + await adapter.cleanup(await adapter.observe(await adapter.launch(await adapter.prepare({ worker, cwd: process.cwd() })))); + assert.deepEqual(posts[0].model, { providerID: 'openrouter', modelID: 'example' }, + 'provider/model strings must arrive as the serve schema object'); + // model ids may themselves contain slashes (openrouter paths): split once. + await adapter.cleanup(await adapter.observe(await adapter.launch(await adapter.prepare({ + worker: { ...worker, configuredModel: 'openrouter/z-ai/glm-5.2' }, cwd: process.cwd(), + })))); + assert.deepEqual(posts[1].model, { providerID: 'openrouter', modelID: 'z-ai/glm-5.2' }, 'split on the FIRST slash only'); + // no provider prefix → the server's own configured default (model omitted, never a guessed provider) + await adapter.cleanup(await adapter.observe(await adapter.launch(await adapter.prepare({ + worker: { ...worker, configuredModel: 'kimi-k3' }, cwd: process.cwd(), + })))); + assert.ok(!('model' in posts[2]), 'a bare model id must not be sent as a mangled object'); +}); + +// Regression (#76 smoke): a prompt post that threw (e.g. the 400 above) left +// the terminal SSE promise unconsumed — its socket-close rejection crashed the +// process as an unhandled rejection AFTER the failure verdict. +test('a prompt post failure tears down without an unhandled terminal rejection', async () => { + const child = { signals: [], kill(signal) { this.signals.push(signal); return true; } }; + const fetchFn = async (url, init = {}) => { + if (url.endsWith('/global/health')) return response({ healthy: true }); + if (url.endsWith('/session') && init.method === 'POST') return response({ id: 'ses-t' }); + if (url.endsWith('/global/event')) { + return response(null, { + body: new ReadableStream({ + start() {}, // never closes on its own — the teardown closes the socket + cancel() { throw Object.assign(new TypeError('terminated'), { code: 'UND_ERR_SOCKET' }); }, + }), + }); + } + if (url.endsWith('/prompt_async')) return response({ name: 'BadRequest' }, { status: 400 }); + throw new Error(`unexpected URL ${url}`); + }; + const adapter = createOpenCodeExecutionAdapter({ + fetchFn, spawnFn: () => child, reservePort: async () => 43130, secret: () => 'ephemeral', + }); + let unhandled = null; + const onUnhandled = (reason) => { unhandled = reason; }; + process.on('unhandledRejection', onUnhandled); + try { + await assert.rejects(adapter.launch(await adapter.prepare({ worker, cwd: process.cwd() })), /HTTP 400/); + // Flush several turns so any dangling rejection would surface. + for (let i = 0; i < 10; i++) await new Promise((r) => setImmediate(r)); + assert.equal(unhandled, null, `teardown must not leak an unhandled rejection, got: ${unhandled}`); + } finally { + process.off('unhandledRejection', onUnhandled); + } + assert.deepEqual(child.signals, ['SIGTERM'], 'the owned server is still terminated on the failure path'); +}); + test('a stalled prompt submission becomes a timeout and tears down its owned server', async () => { const child = { signals: [], kill(signal) { this.signals.push(signal); return true; } }; const fetchFn = async (url, init = {}) => { From 0defce11d2dd4bf173f80266023fb301f2f72813 Mon Sep 17 00:00:00 2001 From: "Robert E. Lee" Date: Wed, 29 Jul 2026 07:27:39 -0700 Subject: [PATCH 2/2] docs(execution): drop stale 'no command invokes this adapter yet' contract comment The adapter has been the ak run path for opencode workers since #82/#84; the comment was false in a security-sensitive adapter (swarm architecture finding). --- src/lib/execution/opencode.mjs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/lib/execution/opencode.mjs b/src/lib/execution/opencode.mjs index ec9f809..00e72a0 100644 --- a/src/lib/execution/opencode.mjs +++ b/src/lib/execution/opencode.mjs @@ -243,8 +243,9 @@ function terminalResult(state, observation, clock) { } /** - * Create a fully injectable OpenCode worker adapter. No command invokes this - * adapter yet; its presence does not change OpenCode's routing capability. + * Create a fully injectable OpenCode worker adapter. Invoked by `ak run` + * (the canonical executor) for opencode-routed workers; routing capability + * itself is gated by the host registry (canRouteActivities, #82). */ export function createOpenCodeExecutionAdapter({ fetchFn = globalThis.fetch, spawnFn = nodeSpawn, haveFn = have, reservePort = defaultReservePort,