diff --git a/docs/adr/0018-generalized-host-worker-execution.md b/docs/adr/0018-generalized-host-worker-execution.md new file mode 100644 index 00000000..1e9d8327 --- /dev/null +++ b/docs/adr/0018-generalized-host-worker-execution.md @@ -0,0 +1,95 @@ +# ADR-0018 — Generalized host-worker execution, preserving `ak dual` + +- **Status:** Proposed +- **Date:** 2026-07-29 +- **Deciders:** agentic-kit maintainers + +## Context + +`ak` now manages Claude, Codex, and OpenCode, but only Claude and Codex are routing +hosts. The existing `ak dual run` implementation materializes a Claude/Codex-specific +configuration and delegates execution to `claude-flow-codex`. It has no execution +adapter, result normalization, cancellation contract, or permission policy for OpenCode. +Changing OpenCode's routing capability alone would therefore create an invalid plan. + +OpenCode does provide a headless HTTP server intended for programmatic use, with an +OpenAPI endpoint, health/version endpoint, sessions, asynchronous prompts, SSE events, +session abort, and permission responses. It defaults to loopback and can be protected +with HTTP basic authentication. [OpenCode server documentation](https://opencode.ai/docs/server/) + +`opencode run` supports non-interactive commands and a `provider/model` selector, but +its documented CLI surface does not establish the supervised session, cancellation, and +permission-response contract required for a routable worker. [OpenCode CLI documentation](https://opencode.ai/docs/cli/) + +## Decision + +1. Preserve OpenCode's managed-host parity throughout this work. An enabled but absent + OpenCode CLI is installed as `opencode-ai`; its npm-managed version participates in + the shared drift/update path; and external installs are detected but never shadowed + or overwritten. `ak host pick`, setup, sync, status, and teardown remain the + canonical management surfaces, exactly as for Claude and Codex. Routing capability + is additive to this lifecycle, not a replacement for it. + +2. Introduce an agentic-kit-owned, host-neutral execution contract: + + ```text + readiness → prepare → launch → observe → interpret → cancel → cleanup + ``` + + Every terminal result contains host, activity, configured selector, correlation, + timing, status, exit category, and only independently grounded provider/model/usage + facts. A host or its `provider/model` selector never proves inference-provider, + billing, cost, or QE vendor diversity. + +3. Preserve `ak dual` as the Claude/Codex compatibility projection throughout this + migration. A later explicit generalized command will own a host-neutral plan; it + must not silently reinterpret existing `dual` configuration, templates, primary-host + mirroring, or escalation behavior. + +4. Select OpenCode's short-lived, loopback-only `opencode serve` HTTP/OpenAPI surface + as the candidate execution transport. Each owned server uses an allocated loopback + port and an ephemeral `OPENCODE_SERVER_PASSWORD`, neither logged nor persisted. The + adapter uses Node's built-in `fetch`, keeping the package free of runtime dependencies. + ACP is not selected: it is an editor-oriented JSON-RPC transport and adds a client + protocol obligation without improving this worker contract. + +5. An OpenCode worker creates an isolated session, observes the event stream, submits an + asynchronous prompt, and on cancellation or timeout calls the documented abort + endpoint before terminating its owned server. A permission request becomes the + deterministic `permission_required` result and is aborted; the runner never passes + `--auto` or weakens user-owned OpenCode permission configuration. + + The worker instruction is a versioned, invocation-only template in the package. It + is sent with the worker prompt, never deployed as an OpenCode agent, command, or + configuration override. This avoids an agent-specific permission override (which + would take precedence over global user policy) and makes the template side-effect + free, inspectable, and testable. + +6. OpenCode remains `canRouteActivities:false` until the adapter has conformance, + sandbox-mutation, cancellation, timeout, permission, malformed-event, and cleanup + evidence. The capability flip happens in the same change as the runnable adapter. + +## Consequences + +- A host-neutral plan and result schema can be added without changing existing command + behavior; Claude/Codex parity is proved before OpenCode routing is enabled. +- Managed installation is testable independently of execution readiness: a missing CLI + is installable, but wiring and routing never claim it is runnable until post-install + detection succeeds. +- OpenCode automation needs a local server supervisor and sanitized event fixtures. +- The `providers.dualRouting` compatibility representation remains readable during the + migration. AQE projection remains distinct: an OpenCode execution route without + grounded provider evidence is diagnosed, never projected as an invented provider. +- Automatic seeding remains Claude/Codex subscription-only. No OpenCode route is seeded + from unknown or metered provider/billing facts. + +## Acceptance evidence before status becomes Accepted + +- Exact-final-head fixtures and sandbox tests establish OpenCode server readiness, + isolated session mutation, structured terminal results, explicit deny, no-prompt-hang, + timeout/cancel abort, and owned-server cleanup. +- Claude/Codex materialization remains behaviorally identical for `ak dual`. +- OpenCode has parity tests for enabled-absent installation, npm-managed update drift, + externally managed installs, post-install wiring, and marker-precise teardown. +- Cross-host escalation, migration, routing-disable behavior, provenance, and cost safety + are covered; #73 remains the immediate post-#76 disable-semantics follow-on. diff --git a/docs/adr/README.md b/docs/adr/README.md index 7534a969..7162e904 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -26,6 +26,7 @@ Consequences**, and cites the grounded source it rests on where relevant. | [0015](0015-managed-codex-native-statusline.md) | Manage Codex's native user-wide status line without claiming rich-renderer parity | Accepted | | [0016](0016-capability-driven-integration-adapters.md) | Capability-driven host, provider, binding, projection, and observability adapters | Accepted | | [0017](0017-opencode-host.md) | OpenCode as a managed, observable, non-routable host through native surfaces | Accepted | +| [0018](0018-generalized-host-worker-execution.md) | Generalized host-worker execution, preserving `ak dual` | Proposed | Theme: ADRs **0001–0006** define **dual-host LLM routing and leadership** — how `ak` lets ruflo route each development activity (architecture, implementation, testing, review, …) to the right host (Claude @@ -86,3 +87,8 @@ stack through OpenCode's native JSON configuration, plugin, converted-agent, ski machine-guidance surfaces; preserves user values through ownership receipts and guarded teardown; and keeps primary/activity routing limited to capability-qualified Claude/Codex hosts. Generalized multi-host routing, including an OpenCode execution-worker contract, remains follow-on issue #76. + +**0018** defines that follow-on's execution boundary: host-neutral worker lifecycle and normalized +terminal evidence, a compatibility-preserving `ak dual` migration, and the candidate OpenCode +loopback HTTP/OpenAPI transport. It remains Proposed until real adapter conformance and sandbox +evidence prove routing can be enabled safely. diff --git a/src/lib/execution/opencode.mjs b/src/lib/execution/opencode.mjs new file mode 100644 index 00000000..0f791aa8 --- /dev/null +++ b/src/lib/execution/opencode.mjs @@ -0,0 +1,260 @@ +// Supervised OpenCode execution candidate (ADR-0018). This is intentionally +// separate from opencode.mjs, which owns managed configuration lifecycle. It +// never writes user configuration or passes the CLI's dangerous --auto flag. +import { spawn as nodeSpawn } from 'node:child_process'; +import { randomBytes } from 'node:crypto'; +import fs from 'node:fs'; +import net from 'node:net'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { have } from '../exec.mjs'; +import { validateExecutionAdapter, validateWorkerResult } from './schema.mjs'; + +const TEMPLATE_PATH = fileURLToPath(new URL('../../templates/opencode-worker-prompt.md', import.meta.url)); +const LOOPBACK = '127.0.0.1'; +const USERNAME = 'opencode'; + +const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); +const nowIso = () => new Date().toISOString(); +const defaultSecret = () => randomBytes(24).toString('base64url'); + +function defaultReservePort() { + return new Promise((resolve, reject) => { + const server = net.createServer(); + server.once('error', reject); + server.listen(0, LOOPBACK, () => { + const { port } = /** @type {{port:number}} */ (server.address()); + server.close((error) => error ? reject(error) : resolve(port)); + }); + }); +} + +function templateText(readFileSync = fs.readFileSync) { + return readFileSync(TEMPLATE_PATH, 'utf8'); +} + +/** Render the template used only in this request; it is not copied into a + * user's OpenCode config, agents, commands, or project files. */ +export function renderOpenCodeWorkerPrompt(worker, { template = templateText() } = {}) { + if (!worker || typeof worker !== 'object') throw new TypeError('worker is required'); + if (typeof worker.prompt !== 'string' || !worker.prompt.trim()) throw new TypeError('worker.prompt is required'); + const metadata = [ + `worker: ${worker.id ?? 'unknown'}`, + `activity: ${worker.activity ?? 'unknown'}`, + `role: ${worker.role ?? 'unknown'}`, + `configured model: ${worker.configuredModel ?? 'default'}`, + ].join('\n'); + return template.replace('{{task}}', worker.prompt).replace('{{metadata}}', metadata); +} + +function basicHeaders(password) { + return { + authorization: `Basic ${Buffer.from(`${USERNAME}:${password}`).toString('base64')}`, + accept: 'application/json, text/event-stream', + }; +} + +async function responseJson(response, operation) { + if (!response?.ok) throw new Error(`${operation} failed with HTTP ${response?.status ?? 'unknown'}`); + try { return await response.json(); } catch { throw new Error(`${operation} returned invalid JSON`); } +} + +async function requestJson(fetchFn, endpoint, password, pathname, + { method = 'GET', body } = /** @type {{method?:string, body?:any}} */ ({})) { + const headers = basicHeaders(password); + if (body !== undefined) headers['content-type'] = 'application/json'; + const response = await fetchFn(`${endpoint}${pathname}`, { + method, headers, ...(body === undefined ? {} : { body: JSON.stringify(body) }), + }); + return responseJson(response, `${method} ${pathname}`); +} + +async function requestNoContent(fetchFn, endpoint, password, pathname, + { method = 'POST', body } = /** @type {{method?:string, body?:any}} */ ({})) { + const headers = basicHeaders(password); + if (body !== undefined) headers['content-type'] = 'application/json'; + const response = await fetchFn(`${endpoint}${pathname}`, { + method, headers, ...(body === undefined ? {} : { body: JSON.stringify(body) }), + }); + if (!response?.ok) throw new Error(`${method} ${pathname} failed with HTTP ${response?.status ?? 'unknown'}`); +} + +async function waitForHealth(fetchFn, endpoint, password, { attempts = 40, wait = delay } = {}) { + let lastError = null; + for (let attempt = 0; attempt < attempts; attempt++) { + try { + const health = await requestJson(fetchFn, endpoint, password, '/global/health'); + if (health?.healthy === true) return health; + lastError = new Error('health response was not healthy'); + } catch (error) { lastError = error; } + await wait(50); + } + throw new Error(`OpenCode server did not become healthy: ${lastError?.message ?? 'unknown error'}`); +} + +function normalizeEvent(value) { + return value?.payload ?? value; +} + +/** Read only the first terminal session event. The SSE parsing is deliberately + * tolerant of chunk boundaries but rejects malformed data rather than inventing + * completion. */ +async function waitForTerminalEvent(response, sessionId) { + if (!response?.ok || !response.body?.getReader) throw new Error('GET /global/event did not return an SSE body'); + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + let data = []; + const consume = async (line) => { + if (line.startsWith('data:')) { data.push(line.slice(5).trimStart()); return null; } + if (line !== '' || data.length === 0) return null; + const raw = data.join('\n'); + data = []; + let event; + try { event = normalizeEvent(JSON.parse(raw)); } catch { throw new Error('OpenCode SSE event was malformed'); } + const properties = event?.properties ?? {}; + if (event?.type === 'permission.updated' && properties.sessionID === sessionId) return { type: 'permission', permission: properties }; + if (event?.type === 'session.error' && (!properties.sessionID || properties.sessionID === sessionId)) return { type: 'error', error: properties.error ?? null }; + if (event?.type === 'session.idle' && properties.sessionID === sessionId) return { type: 'idle' }; + if (event?.type === 'session.status' && properties.sessionID === sessionId && properties.status?.type === 'idle') return { type: 'idle' }; + return null; + }; + for (;;) { + const { done, value } = await reader.read(); + buffer += decoder.decode(value ?? new Uint8Array(), { stream: !done }); + const lines = buffer.split(/\r?\n/); + buffer = lines.pop() ?? ''; + for (const line of lines) { + const terminal = await consume(line); + if (terminal) { await reader.cancel(); return terminal; } + } + if (done) break; + } + throw new Error('OpenCode SSE stream ended before the session became terminal'); +} + +function assistantFrom(messages) { + const candidates = Array.isArray(messages) ? messages : []; + for (let i = candidates.length - 1; i >= 0; i--) { + const info = candidates[i]?.info; + if (info?.role === 'assistant') return info; + } + return null; +} + +function errorCategory(error) { + if (error?.name === 'ProviderAuthError') return 'auth_required'; + return 'worker_error'; +} + +function terminalResult(state, observation, clock) { + const endedAt = clock(); + const startedAt = state.startedAt; + const durationMs = Math.max(0, Date.parse(endedAt) - Date.parse(startedAt)); + const assistant = observation.assistant ?? null; + let status = 'succeeded'; + let exitCategory = 'success'; + let failure = null; + if (observation.type === 'permission') { + status = 'blocked'; exitCategory = 'permission_required'; failure = { permission: observation.permission?.id ?? null }; + } else if (observation.type === 'timeout') { + status = 'timed_out'; exitCategory = 'timeout'; failure = { reason: 'timeout' }; + } else if (observation.type === 'cancelled') { + status = 'cancelled'; exitCategory = 'cancelled'; failure = { reason: 'cancelled' }; + } else if (observation.type === 'error' || assistant?.error) { + const error = observation.error ?? assistant?.error ?? null; + status = 'failed'; exitCategory = errorCategory(error); failure = error ?? { reason: 'OpenCode session failed' }; + } else if (observation.type !== 'idle') { + status = 'failed'; exitCategory = 'protocol_error'; failure = { reason: 'unknown terminal event' }; + } + return validateWorkerResult({ + workerId: state.worker.id, activity: state.worker.activity, role: state.worker.role, host: 'opencode', + status, exitCategory, startedAt, endedAt, durationMs, + provider: assistant?.providerID ?? null, + providerProvenance: assistant?.providerID ? 'observed' : 'unknown', + configuredModel: state.worker.configuredModel ?? null, + observedModel: assistant?.modelID ?? null, + sessionId: state.sessionId ?? null, + transcriptRefs: state.sessionId ? [`opencode://session/${state.sessionId}`] : [], + failure, usage: assistant?.tokens ? { tokens: assistant.tokens, cost: assistant.cost ?? null } : null, + }); +} + +/** + * Create a fully injectable OpenCode worker adapter. No command invokes this + * adapter yet; its presence does not change OpenCode's routing capability. + */ +export function createOpenCodeExecutionAdapter({ + fetchFn = globalThis.fetch, spawnFn = nodeSpawn, haveFn = have, reservePort = defaultReservePort, + secret = defaultSecret, wait = delay, clock = nowIso, +} = {}) { + if (typeof fetchFn !== 'function') throw new TypeError('fetchFn is required'); + const adapter = { + id: 'opencode-server', + async readiness() { + const installed = await haveFn('opencode'); + return installed ? { ready: true } : { ready: false, exitCategory: 'cli_unavailable' }; + }, + async prepare({ worker, cwd = process.cwd() } = /** @type {{worker?:any,cwd?:string}} */ ({})) { + if (worker?.host !== 'opencode') throw new TypeError('OpenCode adapter requires an opencode worker'); + if (!path.isAbsolute(cwd)) throw new TypeError('OpenCode worker cwd must be absolute'); + return { worker, cwd, prompt: renderOpenCodeWorkerPrompt(worker), startedAt: clock() }; + }, + async launch(state) { + const port = await reservePort(); + const password = secret(); + const endpoint = `http://${LOOPBACK}:${port}`; + const child = spawnFn('opencode', ['serve', '--hostname', LOOPBACK, '--port', String(port)], { + cwd: state.cwd, + env: { ...process.env, OPENCODE_SERVER_USERNAME: USERNAME, OPENCODE_SERVER_PASSWORD: password }, + stdio: 'ignore', + }); + try { + await waitForHealth(fetchFn, endpoint, password, { wait }); + const session = await requestJson(fetchFn, endpoint, password, '/session', { + method: 'POST', body: { title: `agentic-kit ${state.worker.id}` }, + }); + if (typeof session?.id !== 'string' || !session.id) throw new Error('OpenCode created a session without an id'); + const headers = basicHeaders(password); + const eventResponse = await fetchFn(`${endpoint}/global/event`, { headers }); + const terminal = waitForTerminalEvent(eventResponse, session.id); + await requestNoContent(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 }] }, + }); + return { ...state, endpoint, password, child, sessionId: session.id, terminal }; + } catch (error) { + try { child.kill('SIGTERM'); } catch { /* cleanup is best-effort */ } + throw error; + } + }, + async observe(state) { + try { + const observation = await state.terminal; + if (observation.type === 'permission') { + await requestNoContent(fetchFn, state.endpoint, state.password, `/session/${encodeURIComponent(state.sessionId)}/abort`); + } + if (observation.type !== 'idle') return observation; + const messages = await requestJson(fetchFn, state.endpoint, state.password, `/session/${encodeURIComponent(state.sessionId)}/message`); + return { ...observation, assistant: assistantFrom(messages) }; + } catch (error) { + return { type: 'error', error: { name: 'ProtocolError', data: { message: error.message } } }; + } + }, + interpret(state, observation) { return terminalResult(state, observation, clock); }, + async cancel(state) { + if (state?.sessionId) { + try { await requestNoContent(fetchFn, state.endpoint, state.password, `/session/${encodeURIComponent(state.sessionId)}/abort`); } catch { /* cleanup records the final truth */ } + } + try { state?.child?.kill?.('SIGTERM'); return { type: 'cancelled' }; } catch { return { type: 'cancelled', orphaned: true }; } + }, + async cleanup(state) { + if (state?.endpoint) { + try { await requestNoContent(fetchFn, state.endpoint, state.password, '/instance/dispose'); } catch { /* child termination remains the fallback */ } + } + try { state?.child?.kill?.('SIGTERM'); return { cleaned: true }; } catch { return { cleaned: false, orphaned: true }; } + }, + }; + return validateExecutionAdapter(adapter); +} + +export const OPENCODE_EXECUTION_ADAPTER = createOpenCodeExecutionAdapter(); diff --git a/src/lib/execution/schema.mjs b/src/lib/execution/schema.mjs new file mode 100644 index 00000000..e33e5c24 --- /dev/null +++ b/src/lib/execution/schema.mjs @@ -0,0 +1,63 @@ +// Host-worker execution contract — deliberately separate from host configuration +// lifecycle. This first slice defines the capability boundary; it does not make a +// host runnable or change routing eligibility. +import { assertEnum, assertId, assertRecord, immutable } from '../adapters/schema.mjs'; + +export const WORKER_STATUSES = Object.freeze([ + 'succeeded', 'failed', 'cancelled', 'timed_out', 'blocked', +]); + +export const EXIT_CATEGORIES = Object.freeze([ + 'success', 'cancelled', 'timeout', 'permission_required', 'auth_required', + 'model_unavailable', 'cli_unavailable', 'protocol_error', 'worker_error', + 'orphaned', 'unknown', +]); + +const REQUIRED_METHODS = Object.freeze([ + 'readiness', 'prepare', 'launch', 'observe', 'interpret', 'cancel', 'cleanup', +]); + +/** Validate the host-neutral execution-adapter shape without invoking it. */ +export function validateExecutionAdapter(value) { + assertRecord(value, 'executionAdapter'); + assertId(value.id, 'executionAdapter.id'); + for (const method of REQUIRED_METHODS) { + if (typeof value[method] !== 'function') { + throw new TypeError(`executionAdapter.${method} must be a function`); + } + } + return immutable({ id: value.id, ...Object.fromEntries(REQUIRED_METHODS.map((method) => [method, value[method]])) }); +} + +/** Validate the privacy-safe, normalized terminal result every adapter returns. */ +export function validateWorkerResult(value) { + assertRecord(value, 'workerResult'); + assertId(value.workerId, 'workerResult.workerId'); + assertId(value.activity, 'workerResult.activity'); + assertId(value.role, 'workerResult.role'); + assertId(value.host, 'workerResult.host'); + assertEnum(value.status, WORKER_STATUSES, 'workerResult.status'); + assertEnum(value.exitCategory, EXIT_CATEGORIES, 'workerResult.exitCategory'); + if (typeof value.startedAt !== 'string' || typeof value.endedAt !== 'string' + || !Number.isFinite(value.durationMs) || value.durationMs < 0) { + throw new TypeError('workerResult requires ISO timestamps and a non-negative durationMs'); + } + if (value.provider !== null && typeof value.provider !== 'string') throw new TypeError('workerResult.provider must be string|null'); + if (value.configuredModel !== null && typeof value.configuredModel !== 'string') throw new TypeError('workerResult.configuredModel must be string|null'); + if (value.observedModel !== null && typeof value.observedModel !== 'string') throw new TypeError('workerResult.observedModel must be string|null'); + if (value.sessionId !== null && typeof value.sessionId !== 'string') throw new TypeError('workerResult.sessionId must be string|null'); + if (!Array.isArray(value.transcriptRefs) || value.transcriptRefs.some((ref) => typeof ref !== 'string')) { + throw new TypeError('workerResult.transcriptRefs must be an array of strings'); + } + if (value.failure !== null && (typeof value.failure !== 'object' || Array.isArray(value.failure))) { + throw new TypeError('workerResult.failure must be object|null'); + } + if (value.usage !== null && (typeof value.usage !== 'object' || Array.isArray(value.usage))) { + throw new TypeError('workerResult.usage must be object|null'); + } + assertEnum(value.providerProvenance, ['observed', 'configured', 'inferred', 'unknown'], 'workerResult.providerProvenance'); + if (value.provider === null && value.providerProvenance !== 'unknown') { + throw new TypeError('workerResult.providerProvenance must be unknown without a provider'); + } + return immutable(structuredClone(value)); +} diff --git a/src/lib/routing.mjs b/src/lib/routing.mjs index 51b33707..74460904 100644 --- a/src/lib/routing.mjs +++ b/src/lib/routing.mjs @@ -6,7 +6,7 @@ // (no I/O) so the projectors and defaults are unit-testable in isolation; the // writers/UX that consume it live in providers.mjs / the commands. import { vendorOf } from './qeCourt.mjs'; -import { routableHostIds, primaryHostIds } from './adapters/index.mjs'; +import { routableHostIds, primaryHostIds, validateActivityHost } from './adapters/index.mjs'; // ── Vocabulary ─────────────────────────────────────────────────────────────── // Canonical development activities ak routes (ADR-0002). Array order = display order. @@ -380,23 +380,49 @@ export const DUAL_RUN_TEMPLATE_NAMES = Object.keys(DUAL_RUN_TEMPLATES); * becomes a worker whose platform + model come from the policy's effective route * for that node's activity. Throws on an unknown template. */ -export function policyToDualRunConfig(policy = {}, { template = 'feature', task = '' } = {}) { +export function materializeRunPlan(policy = {}, { template = 'feature', task = '' } = {}) { const nodes = DUAL_RUN_TEMPLATES[template]; if (!nodes) throw new Error(`unknown template "${template}" (expected: ${DUAL_RUN_TEMPLATE_NAMES.join(', ')})`); const routes = resolveRoutes(policy); - const workers = nodes.map((n) => { + return { + template, + workers: nodes.map((n) => { const r = routes[n.activity]; + const eligibility = validateActivityHost(r.host); + if (!eligibility.ok) { + throw new Error(`route for "${n.activity}" cannot materialize: host "${r.host}" requires canRouteActivities`); + } return { id: n.id, - platform: r.host, // 'claude' | 'codex' — matches DualModeOrchestrator's worker.platform + activity: n.activity, + host: r.host, role: n.role, - model: r.model, + configuredModel: r.model ?? null, prompt: n.prompt(task), ...(n.dependsOn ? { dependsOn: n.dependsOn } : {}), ...(n.maxTurns ? { maxTurns: n.maxTurns } : {}), }; - }); - return { workers }; + }), + }; +} + +/** + * Compatibility projection to `claude-flow-codex dual run --config`. It deliberately + * remains Claude/Codex-specific while the generalized runner is introduced. + */ +export function policyToDualRunConfig(policy = {}, opts = {}) { + const plan = materializeRunPlan(policy, opts); + return { + workers: plan.workers.map((worker) => ({ + id: worker.id, + platform: worker.host, + role: worker.role, + model: worker.configuredModel ?? undefined, + prompt: worker.prompt, + ...(worker.dependsOn ? { dependsOn: worker.dependsOn } : {}), + ...(worker.maxTurns ? { maxTurns: worker.maxTurns } : {}), + })), + }; } /** diff --git a/src/templates/opencode-worker-prompt.md b/src/templates/opencode-worker-prompt.md new file mode 100644 index 00000000..752b063e --- /dev/null +++ b/src/templates/opencode-worker-prompt.md @@ -0,0 +1,20 @@ + + +You are an agentic-kit managed OpenCode execution worker. + +Work only in the server's current project worktree. Read and follow the project's +applicable AGENTS.md and other project instructions before changing files. Keep the +requested change focused, run the relevant verification when practical, and report +what you changed and verified. + +Use the existing OpenCode permission policy exactly as configured by the user. Do +not change OpenCode configuration, weaken permissions, or use an approval bypass. +If an operation needs approval, stop and let the supervisor handle it. + +## Assigned work + +{{task}} + +## Worker metadata + +{{metadata}} diff --git a/tests/kit/execution-schema.test.mjs b/tests/kit/execution-schema.test.mjs new file mode 100644 index 00000000..7be93ef1 --- /dev/null +++ b/tests/kit/execution-schema.test.mjs @@ -0,0 +1,35 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { validateExecutionAdapter, validateWorkerResult } from '../../src/lib/execution/schema.mjs'; + +const adapter = () => ({ + id: 'test-host', + readiness() {}, prepare() {}, launch() {}, observe() {}, interpret() {}, cancel() {}, cleanup() {}, +}); + +const result = () => ({ + workerId: 'worker-1', activity: 'implementation', role: 'coder', host: 'opencode', + status: 'blocked', exitCategory: 'permission_required', + startedAt: '2026-07-29T00:00:00.000Z', endedAt: '2026-07-29T00:00:01.000Z', durationMs: 1000, + provider: null, providerProvenance: 'unknown', configuredModel: 'openrouter/example', observedModel: null, + sessionId: null, transcriptRefs: [], failure: null, usage: null, +}); + +test('execution adapters must implement the complete host-neutral lifecycle', () => { + assert.equal(validateExecutionAdapter(adapter()).id, 'test-host'); + const incomplete = adapter(); + delete incomplete.cancel; + assert.throws(() => validateExecutionAdapter(incomplete), /executionAdapter.cancel/); +}); + +test('worker results preserve unknown provider facts instead of inferring from the host', () => { + const out = validateWorkerResult(result()); + assert.equal(out.provider, null); + assert.equal(out.providerProvenance, 'unknown'); + assert.throws(() => validateWorkerResult({ ...result(), providerProvenance: 'inferred' }), /must be unknown/); +}); + +test('worker results require a bounded terminal category and timing evidence', () => { + assert.throws(() => validateWorkerResult({ ...result(), exitCategory: 'made-up' }), /exitCategory/); + assert.throws(() => validateWorkerResult({ ...result(), durationMs: -1 }), /non-negative/); +}); diff --git a/tests/kit/opencode-execution.test.mjs b/tests/kit/opencode-execution.test.mjs new file mode 100644 index 00000000..47bcd3eb --- /dev/null +++ b/tests/kit/opencode-execution.test.mjs @@ -0,0 +1,72 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { createOpenCodeExecutionAdapter, renderOpenCodeWorkerPrompt } from '../../src/lib/execution/opencode.mjs'; + +const worker = { + id: 'worker-1', activity: 'implementation', role: 'coder', host: 'opencode', + configuredModel: 'openrouter/example', prompt: 'Add a safe server adapter.', +}; + +const response = (json, { status = 200, body = null } = {}) => ({ ok: status >= 200 && status < 300, status, json: async () => json, body }); +const sse = (events) => new ReadableStream({ + start(controller) { controller.enqueue(new TextEncoder().encode(events)); controller.close(); }, +}); + +test('worker prompt is invocation-only and preserves the user permission boundary', () => { + const prompt = renderOpenCodeWorkerPrompt(worker, { template: 'Task={{task}}\nMeta={{metadata}}\nNo bypass.' }); + assert.match(prompt, /Add a safe server adapter/); + assert.match(prompt, /configured model: openrouter\/example/); + assert.doesNotMatch(prompt, /--auto/); +}); + +test('server adapter launches loopback-only with ephemeral basic auth and normalizes observed facts', async () => { + const calls = []; + const child = { signals: [], kill(signal) { this.signals.push(signal); return true; } }; + const fetchFn = async (url, init = {}) => { + calls.push({ url, init }); + if (url.endsWith('/global/health')) return response({ healthy: true, version: '1.18.9' }); + if (url.endsWith('/session') && init.method === 'POST') return response({ id: 'ses-1' }); + if (url.endsWith('/global/event')) return response(null, { body: sse('data: {"payload":{"type":"session.idle","properties":{"sessionID":"ses-1"}}}\n\n') }); + if (url.endsWith('/prompt_async')) return response(null, { status: 204 }); + if (url.endsWith('/message')) return response([{ info: { role: 'assistant', providerID: 'openrouter', modelID: 'example', tokens: { input: 1, output: 2 }, cost: 0.01 } }]); + if (url.endsWith('/instance/dispose')) return response(null, { status: 204 }); + throw new Error(`unexpected URL ${url}`); + }; + const adapter = createOpenCodeExecutionAdapter({ + fetchFn, spawnFn: (_cmd, args, opts) => { assert.deepEqual(args, ['serve', '--hostname', '127.0.0.1', '--port', '43123']); assert.equal(opts.stdio, 'ignore'); assert.equal(opts.env.OPENCODE_SERVER_PASSWORD, 'ephemeral'); return child; }, + reservePort: async () => 43123, secret: () => 'ephemeral', clock: () => '2026-07-29T00:00:00.000Z', + }); + const state = await adapter.prepare({ worker, cwd: process.cwd() }); + const launched = await adapter.launch(state); + const observed = await adapter.observe(launched); + const result = adapter.interpret(launched, observed); + assert.equal(result.status, 'succeeded'); + assert.equal(result.provider, 'openrouter'); + assert.equal(result.providerProvenance, 'observed'); + assert.equal(result.observedModel, 'example'); + assert.ok(calls.every(({ init }) => init.headers.authorization.startsWith('Basic '))); + assert.ok(calls.some(({ url }) => url.endsWith('/prompt_async'))); + await adapter.cleanup(launched); + assert.deepEqual(child.signals, ['SIGTERM']); +}); + +test('permission events are deterministically aborted and never converted into implicit approval', async () => { + const paths = []; + const fetchFn = async (url, init = {}) => { + paths.push(`${init.method ?? 'GET'} ${new URL(url).pathname}`); + if (url.endsWith('/global/health')) return response({ healthy: true }); + if (url.endsWith('/session') && init.method === 'POST') return response({ id: 'ses-2' }); + if (url.endsWith('/global/event')) return response(null, { body: sse('data: {"payload":{"type":"permission.updated","properties":{"id":"perm-1","sessionID":"ses-2"}}}\n\n') }); + if (url.endsWith('/prompt_async') || url.endsWith('/abort')) return response(null, { status: 204 }); + throw new Error(`unexpected URL ${url}`); + }; + const adapter = createOpenCodeExecutionAdapter({ + fetchFn, spawnFn: () => ({ kill: () => true }), reservePort: async () => 43124, secret: () => 'ephemeral', clock: () => '2026-07-29T00:00:00.000Z', + }); + const launched = await adapter.launch(await adapter.prepare({ worker, cwd: process.cwd() })); + const result = adapter.interpret(launched, await adapter.observe(launched)); + assert.equal(result.status, 'blocked'); + assert.equal(result.exitCategory, 'permission_required'); + assert.ok(paths.includes('POST /session/ses-2/abort')); + assert.ok(!paths.some((p) => p.includes('/permissions/perm-1'))); +}); diff --git a/tests/kit/routing.test.mjs b/tests/kit/routing.test.mjs index eb928170..39f0a596 100644 --- a/tests/kit/routing.test.mjs +++ b/tests/kit/routing.test.mjs @@ -4,6 +4,7 @@ import { ACTIVITIES, AK_ORIGINATED, DEFAULT_ROUTES, HOST_PROVIDER, SUBSCRIPTION_PROVIDERS, AQE_CONSTRUCTIBLE_PROVIDERS, MODEL_CATALOG, MODEL_CATALOG_VERIFIED, modelChoices, formatModelHelp, resolveRoutes, seedDualRouting, policyToAgentOverrides, routedVendors, routingSummary, + materializeRunPlan, validateRoute, parseRouteSpecs, DUAL_RUN_TEMPLATE_NAMES, policyToDualRunConfig, escalatePolicy, } from '../../src/lib/routing.mjs'; @@ -161,6 +162,23 @@ test('every dual-run template projects to platforms of claude|codex only', () => } }); +test('host-neutral run plan preserves every legacy dual worker assignment', () => { + const policy = seedDualRouting(); + const plan = materializeRunPlan(policy, { template: 'feature', task: 'add auth' }); + const dual = policyToDualRunConfig(policy, { template: 'feature', task: 'add auth' }); + assert.equal(plan.template, 'feature'); + assert.deepEqual(plan.workers.map(({ host, configuredModel, activity: _activity, ...worker }) => ({ + ...worker, platform: host, model: configuredModel ?? undefined, + })), dual.workers); + assert.ok(plan.workers.every((worker) => worker.activity && worker.host && !('platform' in worker))); +}); + +test('a managed but non-routable host cannot materialize a runnable plan', () => { + assert.throws(() => materializeRunPlan({ implementation: { + host: 'opencode', model: 'openrouter/example', source: 'user', + } }, { template: 'feature', task: 'x' }), /implementation.*opencode.*canRouteActivities/); +}); + test('policyToDualRunConfig throws on an unknown template', () => { assert.throws(() => policyToDualRunConfig({}, { template: 'nope' }), /unknown template/); });