diff --git a/packages/web/server/lib/openchamber-control/service.js b/packages/web/server/lib/openchamber-control/service.js index ebdd0d72..25160bd4 100644 --- a/packages/web/server/lib/openchamber-control/service.js +++ b/packages/web/server/lib/openchamber-control/service.js @@ -240,10 +240,32 @@ export const createOpenChamberControlService = (dependencies) => { } }; + // session.send/fork default the directory to the caller's context directory, + // which is wrong for sessions living in other worktrees: prompt_async then + // targets an instance that does not hold the session and the run dies with + // UnknownError. Resolve the target session's directory from the global + // session list when the caller did not scope explicitly. + const resolveSessionDirectory = async (sessionID) => { + try { + const client = await getClient(); + const response = await client.experimental?.session?.list?.({}); + const sessions = Array.isArray(response?.data) ? response.data : []; + const session = sessions.find((item) => item?.id === sessionID); + return asNonEmptyString(session?.directory) || null; + } catch { + return null; + } + }; + const executeSessionAction = async (action, input, contextDirectory, signal) => { if (input.timeout !== undefined && input.wait !== true) throw new OpenChamberControlError('timeout requires wait', 400); if (input.lastAssistant === true && input.wait !== true) throw new OpenChamberControlError('lastAssistant requires wait', 400); - const directory = asNonEmptyString(input.directory) || (!input.projectId ? asNonEmptyString(contextDirectory) : null); + const sessionID = asNonEmptyString(input.sessionId); + let directory = asNonEmptyString(input.directory) || (!input.projectId ? asNonEmptyString(contextDirectory) : null); + if (sessionID && action !== 'session.create' && !asNonEmptyString(input.directory) && !input.projectId) { + const resolvedSessionDirectory = await resolveSessionDirectory(sessionID); + if (resolvedSessionDirectory) directory = resolvedSessionDirectory; + } const payload = { ...(directory ? { directory } : {}), ...(asNonEmptyString(input.projectId) ? { projectId: input.projectId.trim() } : {}), @@ -262,7 +284,6 @@ export const createOpenChamberControlService = (dependencies) => { ...(typeof input.setUpstream === 'boolean' ? { setUpstream: input.setUpstream } : {}), ...(asNonEmptyString(input.messageId) ? { messageId: input.messageId.trim() } : {}), }; - const sessionID = asNonEmptyString(input.sessionId); const startedAt = now(); let result; if (action === 'session.create') { diff --git a/packages/web/server/lib/openchamber-control/service.test.js b/packages/web/server/lib/openchamber-control/service.test.js index a9a1be47..5cede73c 100644 --- a/packages/web/server/lib/openchamber-control/service.test.js +++ b/packages/web/server/lib/openchamber-control/service.test.js @@ -132,6 +132,43 @@ describe('OpenChamber control service', () => { expect(sessionService[method]).toHaveBeenCalledWith('ses_1', { directory: '/repo', prompt: 'Continue' }); }); + it('resolves the target session directory from the global session list when send omits it', async () => { + const { service, sessionService, client } = createService({ + createClient: () => ({ + ...client, + experimental: { + session: { + list: vi.fn(async () => ({ + data: [ + { id: 'ses_other', directory: '/repo/worktrees/other' }, + { id: 'ses_target', directory: '/repo/worktrees/target' }, + ], + })), + }, + }, + }), + }); + sessionService.send.mockResolvedValue({ sessionId: 'ses_target', directory: '/repo/worktrees/target', promptDispatched: true }); + + await service.execute('session.send', { sessionId: 'ses_target', prompt: 'Continue' }, '/repo'); + + expect(sessionService.send).toHaveBeenCalledWith('ses_target', { directory: '/repo/worktrees/target', prompt: 'Continue' }); + }); + + it('falls back to the context directory when the session is not in the global list', async () => { + const { service, sessionService, client } = createService({ + createClient: () => ({ + ...client, + experimental: { session: { list: vi.fn(async () => ({ data: [] })) } }, + }), + }); + sessionService.send.mockResolvedValue({ sessionId: 'ses_unknown', directory: '/repo', promptDispatched: true }); + + await service.execute('session.send', { sessionId: 'ses_unknown', prompt: 'Continue' }, '/repo'); + + expect(sessionService.send).toHaveBeenCalledWith('ses_unknown', { directory: '/repo', prompt: 'Continue' }); + }); + it('waits past initial idle until a completed assistant result appears', async () => { let timestamp = 1000; const { service, client, sessionService } = createService({ diff --git a/packages/web/server/lib/openchamber-sessions/routes.js b/packages/web/server/lib/openchamber-sessions/routes.js index 0c82b1e0..f7ca1304 100644 --- a/packages/web/server/lib/openchamber-sessions/routes.js +++ b/packages/web/server/lib/openchamber-sessions/routes.js @@ -1,6 +1,6 @@ import express from 'express'; import { createOpencodeClient } from '@opencode-ai/sdk/v2'; -import { createWorktree } from '../git/index.js'; +import { createWorktree, getWorktreeBootstrapStatus } from '../git/index.js'; import { expandSnippets } from '../opencode/snippets.js'; import { expandCommandGoalObjective, parseScheduledCommandPrompt } from '../scheduled-tasks/runtime.js'; import { buildGoalIntroText, createSessionGoal } from '../session-goal/create.js'; @@ -275,6 +275,31 @@ const resolveRequestedDirectory = async ({ payload, readSettingsFromDiskMigrated const PROMPT_LANDED_TIMEOUT_MS = 5_000; const PROMPT_LANDED_POLL_MS = 150; +// createWorktree returns while the worktree is still being populated in the +// background (git reset --hard after a --no-checkout add). Dispatching a +// prompt into a half-populated directory makes opencode's run die with +// UnknownError (agent and config files are not there yet), so wait until the +// bootstrap reaches git-ready (population done) or fails before creating the +// session and dispatching. +const WORKTREE_BOOTSTRAP_TIMEOUT_MS = 60_000; +const WORKTREE_BOOTSTRAP_POLL_MS = 150; + +const waitForWorktreeBootstrapReady = async ({ directory }) => { + const deadline = Date.now() + WORKTREE_BOOTSTRAP_TIMEOUT_MS; + for (;;) { + const status = await getWorktreeBootstrapStatus(directory); + if (status?.status === 'failed') { + throw new OpenChamberControlError(`Worktree bootstrap failed: ${status.error || 'unknown error'}`, 500); + } + const phase = status?.phase; + if (status?.status === 'ready' || phase === 'git-ready' || phase === 'setup-ready') return; + if (Date.now() >= deadline) { + throw new OpenChamberControlError('Timed out waiting for the worktree bootstrap', 500); + } + await new Promise((resolve) => setTimeout(resolve, WORKTREE_BOOTSTRAP_POLL_MS)); + } +}; + const latestUserMessageID = async ({ client, sessionID, directory }) => { let response; try { @@ -579,6 +604,7 @@ export const createOpenChamberSessionService = (dependencies) => { if (worktreeInput) { worktree = await createWorktree(resolvedDirectory.directory, worktreeInput); sessionDirectory = worktree.path; + await waitForWorktreeBootstrapReady({ directory: sessionDirectory }); } const baseUrl = buildOpenCodeUrl('/', '').replace(/\/$/, ''); diff --git a/packages/web/server/lib/openchamber-sessions/routes.test.js b/packages/web/server/lib/openchamber-sessions/routes.test.js index 8b964fb6..3f6fbe5b 100644 --- a/packages/web/server/lib/openchamber-sessions/routes.test.js +++ b/packages/web/server/lib/openchamber-sessions/routes.test.js @@ -8,6 +8,12 @@ const createWorktreeMock = vi.fn(async () => ({ branch: 'openchamber/side-task', path: '/repo/worktrees/side-task', })); +const getWorktreeBootstrapStatusMock = vi.fn(async () => ({ + status: 'ready', + phase: 'setup-ready', + error: null, + updatedAt: Date.now(), +})); const sessionCreateMock = vi.fn(async () => ({ data: { id: 'ses_123' } })); const sessionForkMock = vi.fn(async () => ({ data: { id: 'ses_fork', title: 'Forked session' } })); const sessionMessagesMock = vi.fn(async () => ({ data: [] })); @@ -61,6 +67,7 @@ const selectionInputResponse = (url) => { const sessionCommandMock = vi.fn(async () => ({ data: {} })); const commandListMock = vi.fn(async () => ({ data: [] })); globalThis.__openchamberCreateWorktreeMock = createWorktreeMock; +globalThis.__openchamberGetWorktreeBootstrapStatusMock = getWorktreeBootstrapStatusMock; let registerOpenChamberSessionRoutes; @@ -80,6 +87,7 @@ vi.mock('@opencode-ai/sdk/v2', () => ({ vi.mock('../git/index.js', () => ({ createWorktree: (...args) => globalThis.__openchamberCreateWorktreeMock(...args), + getWorktreeBootstrapStatus: (...args) => globalThis.__openchamberGetWorktreeBootstrapStatusMock(...args), })); const createApp = (overrides = {}, options = {}) => { @@ -107,6 +115,13 @@ describe('openchamber session routes', () => { beforeEach(() => { createWorktreeMock.mockClear(); + getWorktreeBootstrapStatusMock.mockClear(); + getWorktreeBootstrapStatusMock.mockImplementation(async () => ({ + status: 'ready', + phase: 'setup-ready', + error: null, + updatedAt: Date.now(), + })); sessionCreateMock.mockClear(); sessionForkMock.mockClear(); existingSessionMessages = []; @@ -366,6 +381,74 @@ describe('openchamber session routes', () => { } }); + it('waits for the worktree bootstrap to complete before creating the session', async () => { + const statuses = [ + { status: 'pending', phase: 'directory-created', error: null, updatedAt: 1 }, + { status: 'pending', phase: 'git-ready', error: null, updatedAt: 2 }, + { status: 'ready', phase: 'setup-ready', error: null, updatedAt: 3 }, + ]; + getWorktreeBootstrapStatusMock.mockImplementation(async () => statuses.shift() || statuses[statuses.length - 1]); + const originalFetch = globalThis.fetch; + globalThis.fetch = vi.fn(async (url) => { + if (String(url).includes('/prompt_async')) { + return { ok: true, text: async () => '' }; + } + return { ok: true, json: async () => ({ id: 'ses_123' }) }; + }); + try { + const { app } = createApp(); + const response = await request(app) + .post('/api/openchamber/sessions') + .send({ + directory: '/repo/app', + worktree: { name: 'side-task' }, + prompt: 'Run this', + model: 'openai/gpt-5.5', + }) + .expect(200); + + expect(response.body.promptDispatched).toBe(true); + const sessionCreateCalls = globalThis.fetch.mock.calls.filter(([url]) => String(url).includes('/session?directory')); + const promptCalls = globalThis.fetch.mock.calls.filter(([url]) => String(url).includes('/prompt_async')); + expect(sessionCreateCalls.length).toBeGreaterThanOrEqual(1); + expect(promptCalls.length).toBeGreaterThanOrEqual(1); + const createIndex = globalThis.fetch.mock.calls.indexOf(sessionCreateCalls[0]); + const promptIndex = globalThis.fetch.mock.calls.indexOf(promptCalls[0]); + expect(getWorktreeBootstrapStatusMock).toHaveBeenCalled(); + expect(createIndex).toBeGreaterThan(-1); + expect(promptIndex).toBeGreaterThan(createIndex); + } finally { + globalThis.fetch = originalFetch; + } + }); + + it('fails the create when the worktree bootstrap failed', async () => { + getWorktreeBootstrapStatusMock.mockImplementation(async () => ({ + status: 'failed', + phase: 'directory-created', + error: 'branch already exists', + updatedAt: Date.now(), + })); + const originalFetch = globalThis.fetch; + globalThis.fetch = vi.fn(async (url) => ({ ok: true, json: async () => ({ id: 'ses_123' }) })); + try { + const { app } = createApp(); + await request(app) + .post('/api/openchamber/sessions') + .send({ + directory: '/repo/app', + worktree: { name: 'side-task' }, + prompt: 'Run this', + model: 'openai/gpt-5.5', + }) + .expect(500, { error: 'Worktree bootstrap failed: branch already exists' }); + const promptCalls = globalThis.fetch.mock.calls.filter(([url]) => String(url).includes('/prompt_async')); + expect(promptCalls.length).toBe(0); + } finally { + globalThis.fetch = originalFetch; + } + }); + it('sends a goal prompt to an existing session after creating goal metadata', async () => { const originalFetch = globalThis.fetch; const fetchMock = vi.fn(async (url) => selectionInputResponse(url) || { ok: true, text: async () => '' });