From 058dcd63e50abf3e3b46f3753e09561757c1f3f0 Mon Sep 17 00:00:00 2001 From: Jason Kneen Date: Mon, 15 Jun 2026 23:54:25 +0100 Subject: [PATCH 1/2] fix(daemon): bind Omnigent sessions to a runner host_id The daemon's Omnigent create-session payload omitted host_id, so the backend never bound/launched a runner for the session and every turn failed with runner_unavailable ("No runner bound for session"). Mirror the CLI provider: resolve a host_id before POST /v1/sessions. settings.omnigent.hostId wins; otherwise GET /v1/hosts and auto-pick the first online host (first host if none report status). Fail closed with a clear error when zero hosts are registered, instead of creating a session guaranteed to fail at turn time. - omnigent-provider.mjs: pure parseOmnigentHosts / chooseOmnigentHost / buildOmnigentSessionBody helpers (SDK-free, unit-testable) - chat-jobs.mjs: resolveOmnigentHostId + include host_id in the body - omnigent-settings.mjs: optional hostId field (+ CODESURF_OMNIGENT_HOST_ID) - tests: host parse/pick + host_id-in-payload + settings coverage --- packages/codesurf-daemon/bin/chat-jobs.mjs | 36 +++++++++++-- .../codesurf-daemon/bin/omnigent-provider.mjs | 51 +++++++++++++++++++ .../codesurf-daemon/bin/omnigent-settings.mjs | 8 ++- .../codesurf-daemon/test/omnigent.test.mjs | 43 +++++++++++++++- 4 files changed, 132 insertions(+), 6 deletions(-) diff --git a/packages/codesurf-daemon/bin/chat-jobs.mjs b/packages/codesurf-daemon/bin/chat-jobs.mjs index f77dcfda..ecbfead1 100644 --- a/packages/codesurf-daemon/bin/chat-jobs.mjs +++ b/packages/codesurf-daemon/bin/chat-jobs.mjs @@ -27,12 +27,15 @@ import { resolveAuthoritativeAgentMode } from './agent-mode-resolver.mjs' import { OMNIGENT_DEFAULT_BASE_URL, OMNIGENT_DEFAULT_CLI, + buildOmnigentSessionBody, + chooseOmnigentHost, decodeOmnigentModelId, extractOmnigentSessionId, mapOmnigentStreamEvent, normalizeOmnigentServerRoot, omnigentAuthHeaders, omnigentEndpointUrl, + parseOmnigentHosts, parseOmnigentServerUrl, parseOmnigentSseChunk, parseOmnigentStatusJson, @@ -678,6 +681,25 @@ async function resolveOmnigentAgentId(modelId, settings, baseUrl, apiKey, signal return first.id.trim() } +// Resolve the runner host_id to bind a new Omnigent session to. Without it the +// backend never launches a runner and every turn fails with runner_unavailable +// ("No runner bound for session"). settings.hostId wins (operator pin); else GET +// /v1/hosts and auto-pick the first online host (first host if none report +// status), mirroring the CLI provider. FAIL CLOSED on zero hosts: throw a clear +// error rather than create a session that is guaranteed to fail at turn time. +async function resolveOmnigentHostId(settings, baseUrl, apiKey, signal) { + const configured = String(settings?.hostId ?? '').trim() + if (configured) return configured + const payload = await omnigentFetchJson(baseUrl, '/v1/hosts', apiKey, signal ? { signal } : {}) + const hosts = parseOmnigentHosts(payload) + if (hosts.length === 0) { + throw new Error('Omnigent returned no runner hosts from /v1/hosts; no runner host is registered. Start a runner host (or set settings.omnigent.hostId) before creating a session.') + } + const chosen = chooseOmnigentHost(hosts) + if (!chosen?.id) throw new Error('Omnigent /v1/hosts returned hosts without a usable id.') + return chosen.id +} + function omnigentTitleFromPrompt(text) { const oneLine = String(text ?? '').replace(/\s+/g, ' ').trim() if (!oneLine) return 'CodeSurf session' @@ -2195,11 +2217,17 @@ export function createChatJobManager({ homeDir, checkpointStore = null, claudeQu let sessionId = typeof request.sessionId === 'string' && request.sessionId.trim() ? request.sessionId.trim() : null if (!sessionId) { const agentId = await resolveOmnigentAgentId(request.model, settings, baseUrl, apiKey, abortController.signal) - const body = { - agent_id: agentId, + // Bind the session to a runner host. Required: a session created without + // host_id never gets a runner bound, so its turns fail with + // runner_unavailable. Resolved before create so a missing host fails the + // job cleanly here instead of mid-turn. + const hostId = await resolveOmnigentHostId(settings, baseUrl, apiKey, abortController.signal) + const body = buildOmnigentSessionBody({ + agentId, + hostId, title: omnigentTitleFromPrompt(lastUserMsg.content ?? ''), - ...(workspaceDir ? { workspace: workspaceDir } : {}), - } + workspace: workspaceDir, + }) const created = await omnigentFetchJson(baseUrl, '/v1/sessions', apiKey, { method: 'POST', body: JSON.stringify(body), diff --git a/packages/codesurf-daemon/bin/omnigent-provider.mjs b/packages/codesurf-daemon/bin/omnigent-provider.mjs index 12d7f431..b2383557 100644 --- a/packages/codesurf-daemon/bin/omnigent-provider.mjs +++ b/packages/codesurf-daemon/bin/omnigent-provider.mjs @@ -63,6 +63,57 @@ export function extractOmnigentSessionId(payload) { return typeof nestedId === 'string' && nestedId.trim() ? nestedId.trim() : null } +// Normalize the /v1/hosts response into `{ id, name, status }` rows. Mirrors the +// CLI provider's parseOmnigentHosts: accept a bare array, `{ hosts: [...] }`, or +// `{ data: [...] }`, and prefer `host_id` over `id` for the runner id (the +// backend keys runner registration on host_id). Rows without an id are dropped. +export function parseOmnigentHosts(payload) { + const record = payload && typeof payload === 'object' && !Array.isArray(payload) ? payload : {} + const rows = Array.isArray(payload) + ? payload + : Array.isArray(record.hosts) + ? record.hosts + : Array.isArray(record.data) + ? record.data + : [] + return rows + .map(item => { + const row = item && typeof item === 'object' ? item : null + const id = typeof row?.host_id === 'string' && row.host_id.trim() + ? row.host_id.trim() + : typeof row?.id === 'string' && row.id.trim() + ? row.id.trim() + : '' + if (!id) return null + const name = typeof row?.name === 'string' && row.name.trim() ? row.name.trim() : undefined + const status = typeof row?.status === 'string' && row.status.trim() ? row.status.trim() : undefined + return { id, name, status } + }) + .filter(Boolean) +} + +// Pick the runner host to bind a new session to: the first host reporting status +// `online`, else the first host (covers backends that omit status). Mirrors the +// CLI provider's chooseOmnigentHost. Returns the chosen row or null when empty. +export function chooseOmnigentHost(hosts) { + const rows = Array.isArray(hosts) ? hosts : [] + return rows.find(host => host?.status === 'online') ?? rows[0] ?? null +} + +// Build the POST /v1/sessions request body. Pure + exported (mirrors +// buildCodexExecArgs in chat-jobs.mjs) so the daemon test can assert host_id is +// included alongside agent_id without importing the Claude SDK that chat-jobs.mjs +// pulls in. host_id is the load-bearing field: without it the Omnigent backend +// never binds a runner and every turn fails with runner_unavailable. +export function buildOmnigentSessionBody({ agentId, hostId, title, workspace } = {}) { + return { + agent_id: agentId, + ...(hostId ? { host_id: hostId } : {}), + title, + ...(workspace ? { workspace } : {}), + } +} + export function parseOmnigentServerUrl(output) { const match = String(output ?? '').match(/https?:\/\/[^\s)]+/i) return match ? normalizeOmnigentServerRoot(match[0]) : null diff --git a/packages/codesurf-daemon/bin/omnigent-settings.mjs b/packages/codesurf-daemon/bin/omnigent-settings.mjs index a083b6b2..6dd89ed2 100644 --- a/packages/codesurf-daemon/bin/omnigent-settings.mjs +++ b/packages/codesurf-daemon/bin/omnigent-settings.mjs @@ -10,6 +10,7 @@ // "baseUrl": "http://127.0.0.1:6767", // "apiKey": "", // "agentId": "", +// "hostId": "", // "autoStart": true // } } } // @@ -46,6 +47,11 @@ export function resolveOmnigentSettings({ settings, env = process.env } = {}) { const agentId = envString(env?.CODESURF_OMNIGENT_AGENT_ID) ?? (typeof cfg.agentId === 'string' ? cfg.agentId.trim() : '') + // Optional runner host pin. Blank (default) means auto-pick the first online + // host from /v1/hosts at session-create time (see resolveOmnigentHostId). + const hostId = envString(env?.CODESURF_OMNIGENT_HOST_ID) + ?? (typeof cfg.hostId === 'string' ? cfg.hostId.trim() : '') + const autoStart = envFlag(env?.CODESURF_OMNIGENT_AUTO_START, cfg.autoStart !== false) const enabled = envFlag( @@ -53,5 +59,5 @@ export function resolveOmnigentSettings({ settings, env = process.env } = {}) { cfg.enabled !== false, ) - return { enabled, baseUrl, apiKey, agentId, autoStart } + return { enabled, baseUrl, apiKey, agentId, hostId, autoStart } } diff --git a/packages/codesurf-daemon/test/omnigent.test.mjs b/packages/codesurf-daemon/test/omnigent.test.mjs index 3d995a99..d85b8e21 100644 --- a/packages/codesurf-daemon/test/omnigent.test.mjs +++ b/packages/codesurf-daemon/test/omnigent.test.mjs @@ -4,12 +4,15 @@ import { test } from 'node:test' import { resolveOmnigentSettings } from '../bin/omnigent-settings.mjs' import { OMNIGENT_DEFAULT_BASE_URL, + buildOmnigentSessionBody, + chooseOmnigentHost, decodeOmnigentModelId, extractOmnigentSessionId, mapOmnigentStreamEvent, normalizeOmnigentServerRoot, omnigentAuthHeaders, omnigentEndpointUrl, + parseOmnigentHosts, parseOmnigentSseChunk, } from '../bin/omnigent-provider.mjs' @@ -20,18 +23,20 @@ test('resolveOmnigentSettings defaults are sensible and provider works out of th baseUrl: OMNIGENT_DEFAULT_BASE_URL, apiKey: '', agentId: '', + hostId: '', autoStart: true, }) }) test('resolveOmnigentSettings reads settings.omnigent and honors env overrides', () => { const settings = { - omnigent: { enabled: true, baseUrl: 'http://host:9000/v1', apiKey: 'cfg', agentId: 'agent-cfg', autoStart: false }, + omnigent: { enabled: true, baseUrl: 'http://host:9000/v1', apiKey: 'cfg', agentId: 'agent-cfg', hostId: 'host-cfg', autoStart: false }, } const fromCfg = resolveOmnigentSettings({ settings, env: {} }) assert.equal(fromCfg.baseUrl, 'http://host:9000/v1') assert.equal(fromCfg.apiKey, 'cfg') assert.equal(fromCfg.agentId, 'agent-cfg') + assert.equal(fromCfg.hostId, 'host-cfg') assert.equal(fromCfg.autoStart, false) const overridden = resolveOmnigentSettings({ @@ -40,6 +45,7 @@ test('resolveOmnigentSettings reads settings.omnigent and honors env overrides', CODESURF_OMNIGENT_BASE_URL: 'http://override:1234', CODESURF_OMNIGENT_API_KEY: 'env-key', CODESURF_OMNIGENT_AGENT_ID: 'env-agent', + CODESURF_OMNIGENT_HOST_ID: 'env-host', CODESURF_OMNIGENT_AUTO_START: 'true', CODESURF_OMNIGENT_ENABLED: 'false', }, @@ -47,10 +53,45 @@ test('resolveOmnigentSettings reads settings.omnigent and honors env overrides', assert.equal(overridden.baseUrl, 'http://override:1234') assert.equal(overridden.apiKey, 'env-key') assert.equal(overridden.agentId, 'env-agent') + assert.equal(overridden.hostId, 'env-host') assert.equal(overridden.autoStart, true) assert.equal(overridden.enabled, false) }) +test('parseOmnigentHosts normalizes array / {hosts} / {data} shapes and prefers host_id', () => { + // host_id wins over id; rows without an id are dropped. + assert.deepEqual(parseOmnigentHosts([{ host_id: 'h1', name: 'A', status: 'online' }, { id: 'h2' }, { name: 'no-id' }]), [ + { id: 'h1', name: 'A', status: 'online' }, + { id: 'h2', name: undefined, status: undefined }, + ]) + assert.deepEqual(parseOmnigentHosts({ hosts: [{ host_id: 'hh' }] }), [{ id: 'hh', name: undefined, status: undefined }]) + assert.deepEqual(parseOmnigentHosts({ data: [{ id: 'hd', status: 'offline' }] }), [{ id: 'hd', name: undefined, status: 'offline' }]) + assert.deepEqual(parseOmnigentHosts(null), []) +}) + +test('chooseOmnigentHost auto-picks the first online host, else the first host', () => { + // Auto-pick: the online host wins even when a non-online host comes first. + assert.equal(chooseOmnigentHost([{ id: 'a', status: 'offline' }, { id: 'b', status: 'online' }])?.id, 'b') + // Fall back to the first host when none report status. + assert.equal(chooseOmnigentHost([{ id: 'a' }, { id: 'b' }])?.id, 'a') + assert.equal(chooseOmnigentHost([]), null) +}) + +test('buildOmnigentSessionBody includes host_id alongside agent_id (the runner-binding fix)', () => { + const onlineHost = chooseOmnigentHost(parseOmnigentHosts({ hosts: [{ host_id: 'runner-1', status: 'online' }] })) + const body = buildOmnigentSessionBody({ + agentId: 'agent-x', + hostId: onlineHost.id, + title: 'hello', + workspace: '/tmp/ws', + }) + assert.deepEqual(body, { agent_id: 'agent-x', host_id: 'runner-1', title: 'hello', workspace: '/tmp/ws' }) + // host_id is the load-bearing field: it must be present so the backend binds a runner. + assert.equal(body.host_id, 'runner-1') + // workspace is omitted when not provided; a blank hostId drops host_id entirely. + assert.deepEqual(buildOmnigentSessionBody({ agentId: 'a', title: 't' }), { agent_id: 'a', title: 't' }) +}) + test('normalizeOmnigentServerRoot strips trailing slash and /v1', () => { assert.equal(normalizeOmnigentServerRoot('http://x:6767/'), 'http://x:6767') assert.equal(normalizeOmnigentServerRoot('http://x:6767/v1'), 'http://x:6767') From c972b8385a959e94a3c225ecf7807a4edb44ae74 Mon Sep 17 00:00:00 2001 From: Jason Kneen Date: Tue, 16 Jun 2026 00:00:28 +0100 Subject: [PATCH 2/2] fix(daemon): match Omnigent host status case-insensitively Backends/CLIs report ONLINE/Online/online; chooseOmnigentHost compared status === 'online' case-sensitively, so an uppercase ONLINE host was missed and a stale first (offline) host was wrongly picked. Normalize to lowercase before comparing. (cross-review blocking fix on PR #20) --- packages/codesurf-daemon/bin/omnigent-provider.mjs | 8 +++++--- packages/codesurf-daemon/test/omnigent.test.mjs | 3 +++ 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/packages/codesurf-daemon/bin/omnigent-provider.mjs b/packages/codesurf-daemon/bin/omnigent-provider.mjs index b2383557..96c44a09 100644 --- a/packages/codesurf-daemon/bin/omnigent-provider.mjs +++ b/packages/codesurf-daemon/bin/omnigent-provider.mjs @@ -93,11 +93,13 @@ export function parseOmnigentHosts(payload) { } // Pick the runner host to bind a new session to: the first host reporting status -// `online`, else the first host (covers backends that omit status). Mirrors the -// CLI provider's chooseOmnigentHost. Returns the chosen row or null when empty. +// `online`, else the first host (covers backends that omit status). Status is +// compared case-insensitively — backends/CLIs report ONLINE/Online/online — so a +// non-lowercase online host isn't missed in favor of a stale first host. Mirrors +// the CLI provider's chooseOmnigentHost. Returns the chosen row or null when empty. export function chooseOmnigentHost(hosts) { const rows = Array.isArray(hosts) ? hosts : [] - return rows.find(host => host?.status === 'online') ?? rows[0] ?? null + return rows.find(host => String(host?.status ?? '').toLowerCase() === 'online') ?? rows[0] ?? null } // Build the POST /v1/sessions request body. Pure + exported (mirrors diff --git a/packages/codesurf-daemon/test/omnigent.test.mjs b/packages/codesurf-daemon/test/omnigent.test.mjs index d85b8e21..d325d492 100644 --- a/packages/codesurf-daemon/test/omnigent.test.mjs +++ b/packages/codesurf-daemon/test/omnigent.test.mjs @@ -72,6 +72,9 @@ test('parseOmnigentHosts normalizes array / {hosts} / {data} shapes and prefers test('chooseOmnigentHost auto-picks the first online host, else the first host', () => { // Auto-pick: the online host wins even when a non-online host comes first. assert.equal(chooseOmnigentHost([{ id: 'a', status: 'offline' }, { id: 'b', status: 'online' }])?.id, 'b') + // Status match is case-insensitive: an uppercase ONLINE host is picked over a + // non-online first host (backends/CLIs report ONLINE/Online/online). + assert.equal(chooseOmnigentHost([{ id: 'a', status: 'OFFLINE' }, { id: 'b', status: 'ONLINE' }])?.id, 'b') // Fall back to the first host when none report status. assert.equal(chooseOmnigentHost([{ id: 'a' }, { id: 'b' }])?.id, 'a') assert.equal(chooseOmnigentHost([]), null)