fix: wait for worktree bootstrap before prompt dispatch; resolve session directory for send/fork (#2708)
This commit is contained in:
@@ -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) => {
|
const executeSessionAction = async (action, input, contextDirectory, signal) => {
|
||||||
if (input.timeout !== undefined && input.wait !== true) throw new OpenChamberControlError('timeout requires wait', 400);
|
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);
|
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 = {
|
const payload = {
|
||||||
...(directory ? { directory } : {}),
|
...(directory ? { directory } : {}),
|
||||||
...(asNonEmptyString(input.projectId) ? { projectId: input.projectId.trim() } : {}),
|
...(asNonEmptyString(input.projectId) ? { projectId: input.projectId.trim() } : {}),
|
||||||
@@ -262,7 +284,6 @@ export const createOpenChamberControlService = (dependencies) => {
|
|||||||
...(typeof input.setUpstream === 'boolean' ? { setUpstream: input.setUpstream } : {}),
|
...(typeof input.setUpstream === 'boolean' ? { setUpstream: input.setUpstream } : {}),
|
||||||
...(asNonEmptyString(input.messageId) ? { messageId: input.messageId.trim() } : {}),
|
...(asNonEmptyString(input.messageId) ? { messageId: input.messageId.trim() } : {}),
|
||||||
};
|
};
|
||||||
const sessionID = asNonEmptyString(input.sessionId);
|
|
||||||
const startedAt = now();
|
const startedAt = now();
|
||||||
let result;
|
let result;
|
||||||
if (action === 'session.create') {
|
if (action === 'session.create') {
|
||||||
|
|||||||
@@ -132,6 +132,43 @@ describe('OpenChamber control service', () => {
|
|||||||
expect(sessionService[method]).toHaveBeenCalledWith('ses_1', { directory: '/repo', prompt: 'Continue' });
|
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 () => {
|
it('waits past initial idle until a completed assistant result appears', async () => {
|
||||||
let timestamp = 1000;
|
let timestamp = 1000;
|
||||||
const { service, client, sessionService } = createService({
|
const { service, client, sessionService } = createService({
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import express from 'express';
|
import express from 'express';
|
||||||
import { createOpencodeClient } from '@opencode-ai/sdk/v2';
|
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 { expandSnippets } from '../opencode/snippets.js';
|
||||||
import { expandCommandGoalObjective, parseScheduledCommandPrompt } from '../scheduled-tasks/runtime.js';
|
import { expandCommandGoalObjective, parseScheduledCommandPrompt } from '../scheduled-tasks/runtime.js';
|
||||||
import { buildGoalIntroText, createSessionGoal } from '../session-goal/create.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_TIMEOUT_MS = 5_000;
|
||||||
const PROMPT_LANDED_POLL_MS = 150;
|
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 }) => {
|
const latestUserMessageID = async ({ client, sessionID, directory }) => {
|
||||||
let response;
|
let response;
|
||||||
try {
|
try {
|
||||||
@@ -579,6 +604,7 @@ export const createOpenChamberSessionService = (dependencies) => {
|
|||||||
if (worktreeInput) {
|
if (worktreeInput) {
|
||||||
worktree = await createWorktree(resolvedDirectory.directory, worktreeInput);
|
worktree = await createWorktree(resolvedDirectory.directory, worktreeInput);
|
||||||
sessionDirectory = worktree.path;
|
sessionDirectory = worktree.path;
|
||||||
|
await waitForWorktreeBootstrapReady({ directory: sessionDirectory });
|
||||||
}
|
}
|
||||||
|
|
||||||
const baseUrl = buildOpenCodeUrl('/', '').replace(/\/$/, '');
|
const baseUrl = buildOpenCodeUrl('/', '').replace(/\/$/, '');
|
||||||
|
|||||||
@@ -8,6 +8,12 @@ const createWorktreeMock = vi.fn(async () => ({
|
|||||||
branch: 'openchamber/side-task',
|
branch: 'openchamber/side-task',
|
||||||
path: '/repo/worktrees/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 sessionCreateMock = vi.fn(async () => ({ data: { id: 'ses_123' } }));
|
||||||
const sessionForkMock = vi.fn(async () => ({ data: { id: 'ses_fork', title: 'Forked session' } }));
|
const sessionForkMock = vi.fn(async () => ({ data: { id: 'ses_fork', title: 'Forked session' } }));
|
||||||
const sessionMessagesMock = vi.fn(async () => ({ data: [] }));
|
const sessionMessagesMock = vi.fn(async () => ({ data: [] }));
|
||||||
@@ -61,6 +67,7 @@ const selectionInputResponse = (url) => {
|
|||||||
const sessionCommandMock = vi.fn(async () => ({ data: {} }));
|
const sessionCommandMock = vi.fn(async () => ({ data: {} }));
|
||||||
const commandListMock = vi.fn(async () => ({ data: [] }));
|
const commandListMock = vi.fn(async () => ({ data: [] }));
|
||||||
globalThis.__openchamberCreateWorktreeMock = createWorktreeMock;
|
globalThis.__openchamberCreateWorktreeMock = createWorktreeMock;
|
||||||
|
globalThis.__openchamberGetWorktreeBootstrapStatusMock = getWorktreeBootstrapStatusMock;
|
||||||
|
|
||||||
let registerOpenChamberSessionRoutes;
|
let registerOpenChamberSessionRoutes;
|
||||||
|
|
||||||
@@ -80,6 +87,7 @@ vi.mock('@opencode-ai/sdk/v2', () => ({
|
|||||||
|
|
||||||
vi.mock('../git/index.js', () => ({
|
vi.mock('../git/index.js', () => ({
|
||||||
createWorktree: (...args) => globalThis.__openchamberCreateWorktreeMock(...args),
|
createWorktree: (...args) => globalThis.__openchamberCreateWorktreeMock(...args),
|
||||||
|
getWorktreeBootstrapStatus: (...args) => globalThis.__openchamberGetWorktreeBootstrapStatusMock(...args),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const createApp = (overrides = {}, options = {}) => {
|
const createApp = (overrides = {}, options = {}) => {
|
||||||
@@ -107,6 +115,13 @@ describe('openchamber session routes', () => {
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
createWorktreeMock.mockClear();
|
createWorktreeMock.mockClear();
|
||||||
|
getWorktreeBootstrapStatusMock.mockClear();
|
||||||
|
getWorktreeBootstrapStatusMock.mockImplementation(async () => ({
|
||||||
|
status: 'ready',
|
||||||
|
phase: 'setup-ready',
|
||||||
|
error: null,
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
}));
|
||||||
sessionCreateMock.mockClear();
|
sessionCreateMock.mockClear();
|
||||||
sessionForkMock.mockClear();
|
sessionForkMock.mockClear();
|
||||||
existingSessionMessages = [];
|
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 () => {
|
it('sends a goal prompt to an existing session after creating goal metadata', async () => {
|
||||||
const originalFetch = globalThis.fetch;
|
const originalFetch = globalThis.fetch;
|
||||||
const fetchMock = vi.fn(async (url) => selectionInputResponse(url) || { ok: true, text: async () => '' });
|
const fetchMock = vi.fn(async (url) => selectionInputResponse(url) || { ok: true, text: async () => '' });
|
||||||
|
|||||||
Reference in New Issue
Block a user