fix: Connect cloud OpenCode sessions to the latest conversation (no-changelog) (#39449)

This commit is contained in:
Svetoslav Dekov
2026-09-24 09:28:34 +00:00
committed by GitHub
parent 3b3ac393ba
commit dfeb93cdc7
5 changed files with 169 additions and 77 deletions
+12 -10
View File
@@ -72,8 +72,8 @@ OpenCode server, repository, tools, and builds run in the Codespace. Browser
mode opens the remote web interface through a local connection.
```bash
pnpm session:opencode fix-flaky # resume the saved conversation
pnpm session:opencode fix-flaky --web # open that conversation in a browser
pnpm session:opencode fix-flaky # resume that workspace's latest conversation
pnpm session:opencode fix-flaky --web # open it in a browser
pnpm session:opencode fix-flaky --new # start a new conversation in that worktree
pnpm session:opencode --web --port 4100 # override the default browser port
pnpm session:opencode --help
@@ -98,11 +98,12 @@ post-start command installs. The local harness setup above is not required for
these commands.
The command prepares the worktree, starts or reuses one server, opens an SSH
tunnel, and connects the client. Each workspace name has a saved conversation
ID on the Codespace. TUI and browser modes open the same saved conversation.
`--new` replaces that saved ID. It preserves the worktree and the old conversation.
If you switch conversations inside a client, the next launcher run still opens
the ID saved for that workspace name.
tunnel, and connects the client. Each workspace opens its own conversation: the
most recently updated one in that worktree. A conversation moves to the front
when it receives a message. Switching to a conversation without sending a
message does not move it. Switching to another workspace's conversation does
not change what this workspace opens next. `--new` starts a new conversation
instead. It preserves the worktree and the old conversation.
- **Exit the TUI** with `/exit` or its quit shortcut. The launcher closes its
tunnel. The remote server stays running.
@@ -110,7 +111,8 @@ the ID saved for that workspace name.
the browser tab does not close the tunnel. Keep the launcher running while
you use the browser.
- **Reconnect** with the same command after a network interruption. After a
Codespace stop, the command restarts the server and opens the saved conversation.
Codespace stop, the command restarts the server and opens the workspace's
most recently updated conversation.
A stop terminates running tools. It does not resume interrupted commands.
- **Browser mode uses local port 4096 by default.** Use `--port` to override it.
TUI mode selects an available port unless you specify one.
@@ -129,14 +131,14 @@ your laptop can access the local browser proxy while it runs. Do not forward
this proxy or the OpenCode server port to other machines.
The server enables only OpenRouter. It reads `OPENROUTER_API_KEY` when it starts.
Browser mode opens the selected conversation directly.
Browser mode opens the workspace's most recently updated conversation directly.
The web UI stores opened projects in browser storage. If a new-session page
shows **New project**, open `/workspaces/n8n` there once. Keep the same browser
port when you reconnect to preserve this selection.
The server runs in the detached tmux session `n8n-opencode-server`. Its log is
`/workspaces/.n8n-opencode/server.log`. That directory also holds the server
credentials and saved conversation IDs. It is readable only by its owner.
credentials. It is readable only by its owner.
An unhealthy server produces an error without stopping active work. Inspect
the log through `pnpm session:shell`. To restart it after checking active work,
run `tmux kill-session -t '=n8n-opencode-server'` in that shell. Then reconnect.
@@ -1,6 +1,6 @@
import assert from 'node:assert/strict';
import { spawn } from 'node:child_process';
import { rmSync } from 'node:fs';
import { rmSync, readFileSync } from 'node:fs';
import { test } from 'node:test';
import { setTimeout as delay } from 'node:timers/promises';
import { fileURLToPath } from 'node:url';
@@ -43,8 +43,12 @@ if (args[0] === 'codespace' && args[1] === 'list') {
process.stdin.on('end', () => {
log({ event: 'bootstrap', hasSource: source.includes('prepareOpenCode') });
if (process.env.TEST_BOOTSTRAP_FAIL) process.exit(1);
const name = args.at(-1).split(' ').at(-2);
console.log(JSON.stringify({ port: 4242, password: '${secret}', sessionID: 'ses_saved',
const parts = args.at(-1).split(' ');
const name = parts.at(-3);
const fresh = parts.at(-2) === 'true';
const web = parts.at(-1) === 'true';
console.log(JSON.stringify({ port: 4242, password: '${secret}',
...(fresh && !web ? {} : { sessionID: 'ses_saved' }),
directory: name === 'agent' ? '/workspaces/n8n' : '/workspaces/wt-' + name }));
});
}
@@ -93,6 +97,22 @@ async function waitFor(check) {
throw new Error('Fixture did not become ready.');
}
// An orphaned fixture process can linger as a zombie where PID 1 does not reap
// it, and kill(pid, 0) still succeeds for one. Read the process state where the
// kernel exposes it; without /proc only the signal error can tell.
function stopped(pid) {
try {
process.kill(pid, 0);
} catch {
return true;
}
try {
return readFileSync(`/proc/${pid}/stat`, 'utf8').split(' ')[2] === 'Z';
} catch {
return false;
}
}
test('parses options before or after the workspace and rejects unsupported flags', () => {
assert.equal(parseOpenCodeArgs(['--web']).port, 4096);
assert.equal(parseOpenCodeArgs([]).port, 0);
@@ -142,20 +162,21 @@ test(
assert.ok(!result.output.includes(secret));
assert.ok(calls.some((call) => call.event === 'tunnel-stopped'));
const pid = calls.find((call) => call.event === 'ssh-child').pid;
await waitFor(() => {
try {
process.kill(pid, 0);
return false;
} catch {
return true;
}
});
await waitFor(() => stopped(pid));
},
);
test('starts a new conversation for --new instead of continuing', { timeout: 15000 }, async (t) => {
const f = fixture(t);
const result = await f.start(['fix-flaky', '--new']).done;
assert.equal(result.code, 0, result.output);
const client = f.calls().find((call) => call.command === 'opencode');
assert.deepEqual(client.args.slice(2), ['--dir', '/workspaces/wt-fix-flaky']);
});
for (const signal of ['SIGINT', 'SIGHUP']) {
test(
`opens the saved web conversation and cleans up on ${signal}`,
`opens the newest web conversation and cleans up on ${signal}`,
{ timeout: 15000 },
async (t) => {
const f = fixture(t, { TEST_NO_CLIENT: '1' });
+30 -26
View File
@@ -162,35 +162,35 @@ function prepareWorkspace({ name, directory, mainDirectory, stateDir }) {
}
}
async function ensureSession({ name, fresh, directory, stateDir, server }) {
const sessionFile = join(stateDir, `${name}.session.json`);
const saved = fresh ? undefined : readJson(sessionFile);
let session;
if (saved) {
const response = await request(server, `/session/${encodeURIComponent(saved.id)}`, directory);
if (response.ok) session = await response.json();
else if (response.status !== 404)
throw new Error(`Cannot resume OpenCode session (${response.status}).`);
if (session && session.directory !== directory)
throw new Error('The saved OpenCode session belongs to another directory. Use --new.');
}
if (!session) {
const response = await request(server, '/session', directory, {
method: 'POST',
body: JSON.stringify({ title: `n8n: ${name}` }),
});
if (!response.ok) throw new Error(`Cannot create OpenCode session (${response.status}).`);
session = await response.json();
if (typeof session.id !== 'string' || session.directory !== directory)
throw new Error('OpenCode returned an invalid session.');
saveJson(sessionFile, { id: session.id });
// OpenCode's own resume is scoped to the repository project, so all worktrees
// share one pool. Scope the selection to the workspace directory instead, so
// each worktree resumes its own most recently updated root conversation.
async function resolveSession({ name, fresh, directory, server }) {
if (!fresh) {
const query = new URLSearchParams({ directory, roots: 'true' });
const response = await request(server, `/session?${query}`, directory);
if (!response.ok) throw new Error(`Cannot list OpenCode sessions (${response.status}).`);
const sessions = await response.json();
const latest = Array.isArray(sessions)
? sessions.find((entry) => typeof entry?.id === 'string' && entry.id.length > 0)
: undefined;
if (latest) return latest.id;
}
const response = await request(server, '/session', directory, {
method: 'POST',
body: JSON.stringify({ title: `n8n: ${name}` }),
});
if (!response.ok) throw new Error(`Cannot create OpenCode session (${response.status}).`);
const session = await response.json();
if (typeof session.id !== 'string' || session.directory !== directory)
throw new Error('OpenCode returned an invalid session.');
return session.id;
}
export async function prepareOpenCode({
name = 'agent',
fresh = false,
web = false,
workspaces = '/workspaces',
} = {}) {
const stateDir = join(workspaces, '.n8n-opencode');
@@ -203,15 +203,19 @@ export async function prepareOpenCode({
if (!server) execFileSync('opencode', ['--version'], { stdio: ['ignore', 'pipe', 'inherit'] });
prepareWorkspace({ name, directory, mainDirectory, stateDir });
server ??= await startServer({ stateDir, mainDirectory, workspaces });
const sessionID = await ensureSession({ name, fresh, directory, stateDir, server });
const state = { ...server, directory };
// A fresh TUI conversation needs no record. The TUI creates it on the first message.
if (fresh && !web) return state;
// Only the parent process reads stdout. Never send this record to terminal output.
return { ...server, directory, sessionID };
return { ...state, sessionID: await resolveSession({ name, fresh, directory, server }) };
}
if (process.argv[1] === '-') {
try {
const [name, fresh] = process.argv.slice(2);
console.log(JSON.stringify(await prepareOpenCode({ name, fresh: fresh === 'true' })));
const [name, fresh, web] = process.argv.slice(2);
console.log(
JSON.stringify(await prepareOpenCode({ name, fresh: fresh === 'true', web: web === 'true' })),
);
} catch (error) {
console.error(error.message);
process.exitCode = 1;
@@ -7,6 +7,22 @@ import { setTimeout as delay } from 'node:timers/promises';
import { fakeBinaries } from './fake-bin.mjs';
import { prepareOpenCode } from './opencode-server.mjs';
// An orphaned fixture process can linger as a zombie where PID 1 does not reap
// it, and kill(pid, 0) still succeeds for one. Read the process state where the
// kernel exposes it; without /proc only the signal error can tell.
function stopped(pid) {
try {
process.kill(pid, 0);
} catch {
return true;
}
try {
return readFileSync(`/proc/${pid}/stat`, 'utf8').split(' ')[2] === 'Z';
} catch {
return false;
}
}
function fixture(t) {
const fake = fakeBinaries('opencode-server-');
const dir = fake.root;
@@ -38,7 +54,13 @@ if (process.env.TEST_INSTALL_FAIL) process.exit(1);
'tmux',
`
if (args[0] === 'has-session') {
try { process.kill(+fs.readFileSync(file('pid'), 'utf8'), 0); } catch { process.exit(1); }
const pid = +fs.readFileSync(file('pid'), 'utf8');
let gone = false;
try { process.kill(pid, 0); } catch { gone = true; }
if (!gone) {
try { gone = fs.readFileSync('/proc/' + pid + '/stat', 'utf8').split(' ')[2] === 'Z'; } catch {}
}
if (gone) process.exit(1);
} else {
const child = require('node:child_process').spawn('bash', ['-c', args.at(-1)], { detached:true, stdio:'ignore' });
fs.writeFileSync(file('pid'), String(child.pid)); child.unref();
@@ -59,15 +81,35 @@ const server = require('node:http').createServer(async (req, res) => {
res.setHeader('content-type', 'application/json');
const expected = 'Basic ' + Buffer.from('opencode:' + process.env.OPENCODE_SERVER_PASSWORD).toString('base64');
if (req.headers.authorization !== expected) { res.writeHead(401).end('{}'); return; }
const directory = decodeURIComponent(req.headers['x-opencode-directory']);
if (req.url === '/global/health') { res.end(JSON.stringify({ healthy:true, version:'1.2.3' })); return; }
if (req.method === 'POST' && req.url === '/session') {
for await (const chunk of req) {}
if (fs.existsSync(file('reject-create'))) { res.writeHead(503).end('{}'); return; }
let body = '';
for await (const chunk of req) body += chunk;
const id = 'ses_' + (Object.keys(sessions).length + 1);
sessions[id] = { id, directory:decodeURIComponent(req.headers['x-opencode-directory']) };
sessions[id] = { id, directory, title: JSON.parse(body).title };
fs.writeFileSync(file('sessions.json'), JSON.stringify(sessions));
res.end(JSON.stringify(sessions[id])); return;
}
if (fs.existsSync(file('reject-session'))) { res.writeHead(503).end('{}'); return; }
const url = new URL(req.url, 'http://localhost');
if (url.pathname === '/session') {
// Newest first, like the real server. Filter on the query, not the header,
// so the test fails when the launcher omits either parameter. The
// child-session file models a subagent conversation that the roots query
// flag must exclude.
const directory = url.searchParams.get('directory');
const roots = url.searchParams.get('roots') === 'true';
const stored = Object.values(sessions);
if (fs.existsSync(file('child-session')))
stored.push({ id: 'ses_child', directory, title: 'child', parentID: 'ses_1' });
const list = stored
.filter(s => s.directory === directory && (!roots || !s.parentID))
.reverse();
if (fs.existsSync(file('extra-session'))) list.unshift({ id: 'ses_extra', directory, title: 'TUI session' });
res.end(JSON.stringify(list)); return;
}
const session = sessions[req.url.split('/').at(-1)];
if (!session) { res.writeHead(404).end('{}'); return; }
res.end(JSON.stringify(session));
@@ -80,11 +122,7 @@ server.listen(+args[args.indexOf('--port') + 1], '127.0.0.1');
const pid = +readFileSync(join(dir, 'pid'), 'utf8');
process.kill(-pid, 'SIGTERM');
for (let count = 0; count < 100; count++) {
try {
process.kill(pid, 0);
} catch {
return;
}
if (stopped(pid)) return;
await delay(20);
}
throw new Error('Fixture server did not stop.');
@@ -107,19 +145,33 @@ server.listen(+args[args.indexOf('--port') + 1], '127.0.0.1');
}
test(
'reuses the server and saved conversation, creates separate workspaces, and resumes after restart',
'reuses the server, serves each workspace its newest conversation, and resumes after restart',
{ timeout: 15000 },
async (t) => {
const f = fixture(t);
const first = await f.prepare({ name: 'fix-flaky' });
assert.equal(first.directory, join(f.dir, 'wt-fix-flaky'));
assert.equal(first.sessionID, 'ses_1');
assert.deepEqual(await f.prepare({ name: 'fix-flaky' }), first);
const second = await f.prepare({ name: 'another-task' });
assert.equal(second.port, first.port);
assert.notEqual(second.sessionID, first.sessionID);
const fresh = await f.prepare({ name: 'fix-flaky', fresh: true });
assert.notEqual(fresh.sessionID, first.sessionID);
assert.equal((await f.prepare({ name: 'fix-flaky' })).sessionID, fresh.sessionID);
assert.equal(second.sessionID, 'ses_2');
const webbed = await f.prepare({ name: 'fix-flaky', web: true });
assert.equal(webbed.sessionID, 'ses_1');
const created = JSON.parse(readFileSync(join(f.dir, 'sessions.json'), 'utf8'));
assert.equal(created.ses_1.title, 'n8n: fix-flaky');
assert.equal(created.ses_1.directory, join(f.dir, 'wt-fix-flaky'));
assert.equal((await f.prepare({ name: 'fix-flaky', web: true })).sessionID, 'ses_1');
assert.equal((await f.prepare({ name: 'another-task', web: true })).sessionID, 'ses_2');
writeFileSync(join(f.dir, 'extra-session'), '');
assert.equal((await f.prepare({ name: 'fix-flaky', web: true })).sessionID, 'ses_extra');
rmSync(join(f.dir, 'extra-session'));
writeFileSync(join(f.dir, 'child-session'), '');
assert.equal((await f.prepare({ name: 'fix-flaky', web: true })).sessionID, 'ses_1');
rmSync(join(f.dir, 'child-session'));
const fresh = await f.prepare({ name: 'fix-flaky', web: true, fresh: true });
assert.notEqual(fresh.sessionID, 'ses_1');
assert.equal((await f.prepare({ name: 'fix-flaky', web: true })).sessionID, fresh.sessionID);
const env = JSON.parse(readFileSync(join(f.dir, 'server-env.json'), 'utf8'));
assert.deepEqual([env.worker, env.queue, env.slack], [false, false, false]);
assert.equal(env.cache, join(f.dir, '.turbo-cache'));
@@ -127,18 +179,18 @@ test(
assert.deepEqual(env.config.enabled_providers, ['openrouter']);
assert.deepEqual([env.runtime, env.profile], ['sandbox', null]);
assert.equal(statSync(join(f.dir, '.n8n-opencode')).mode & 0o777, 0o700);
for (const file of ['serve.sh', 'server.json', 'fix-flaky.session.json']) {
for (const file of ['serve.sh', 'server.json']) {
assert.equal(statSync(join(f.dir, '.n8n-opencode', file)).mode & 0o777, 0o600);
}
assert.ok(!f.commands().some((entry) => JSON.stringify(entry.args).includes(first.password)));
assert.equal(f.commands().filter((entry) => entry.command === 'pnpm').length, 2);
await f.stop();
const restarted = await f.prepare({ name: 'fix-flaky' });
const restarted = await f.prepare({ name: 'fix-flaky', web: true });
assert.equal(restarted.sessionID, fresh.sessionID);
assert.notEqual(restarted.password, first.password);
rmSync(first.directory, { recursive: true });
writeFileSync(join(f.dir, 'branch-exists'), '');
assert.equal((await f.prepare({ name: 'fix-flaky' })).sessionID, fresh.sessionID);
assert.equal((await f.prepare({ name: 'fix-flaky' })).directory, join(f.dir, 'wt-fix-flaky'));
const worktree = f
.commands()
.filter((entry) => entry.command === 'git' && entry.args.includes('add'))
@@ -183,19 +235,23 @@ test(
);
test(
'preserves saved sessions on API errors and replaces only missing sessions',
'reports session API errors and creates conversations only when none exist',
{ timeout: 10000 },
async (t) => {
const f = fixture(t);
const first = await f.prepare();
const first = await f.prepare({ web: true });
assert.equal(first.sessionID, 'ses_1');
writeFileSync(join(f.dir, 'reject-session'), '');
await assert.rejects(f.prepare(), /Cannot resume OpenCode session \(503\)/);
const saved = join(f.dir, '.n8n-opencode', 'agent.session.json');
assert.equal(JSON.parse(readFileSync(saved, 'utf8')).id, first.sessionID);
await assert.rejects(f.prepare({ web: true }), /Cannot list OpenCode sessions \(503\)/);
assert.ok(JSON.parse(readFileSync(join(f.dir, 'sessions.json'), 'utf8')).ses_1);
rmSync(join(f.dir, 'reject-session'));
writeFileSync(saved, JSON.stringify({ id: 'ses_missing' }));
assert.notEqual((await f.prepare()).sessionID, first.sessionID);
writeFileSync(saved, '{');
await assert.rejects(f.prepare(), /Cannot read/);
assert.equal((await f.prepare({ web: true })).sessionID, first.sessionID);
writeFileSync(join(f.dir, 'reject-create'), '');
await assert.rejects(
f.prepare({ name: 'fresh-task', web: true }),
/Cannot create OpenCode session \(503\)/,
);
rmSync(join(f.dir, 'reject-create'));
assert.equal((await f.prepare({ name: 'fresh-task', web: true })).sessionID, 'ses_2');
},
);
+13 -4
View File
@@ -94,7 +94,7 @@ function startChild(command, args, options = {}) {
async function bootstrap(codespace, options, signal) {
signal.throwIfAborted();
console.log(`Preparing OpenCode workspace '${options.name}' on ${codespace}…`);
const command = `umask 077; mkdir -p /workspaces/.n8n-opencode && flock --close -w 600 /workspaces/.n8n-opencode/launch.lock node --input-type=module - ${options.name} ${options.fresh}`;
const command = `umask 077; mkdir -p /workspaces/.n8n-opencode && flock --close -w 600 /workspaces/.n8n-opencode/launch.lock node --input-type=module - ${options.name} ${options.fresh} ${options.web}`;
const remote = startChild('gh', ['codespace', 'ssh', '-c', codespace, '--', command], {
stdio: ['pipe', 'pipe', 'inherit'],
detached: true,
@@ -129,8 +129,8 @@ async function bootstrap(codespace, options, signal) {
!state ||
!Number.isInteger(state.port) ||
!text(state.password) ||
!text(state.sessionID) ||
!text(state.directory)
!text(state.directory) ||
(options.web && !text(state.sessionID))
) {
throw new Error('Invalid OpenCode server response.');
}
@@ -236,9 +236,18 @@ export async function connectOpenCode(options, ensureCodespace) {
openBrowser(webUrl);
} else {
console.log(`Connecting to OpenCode ${health.version} in ${state.directory}…`);
// OpenCode's own resume spans all worktrees of the repository. The
// server resolved the workspace's latest conversation instead, so
// target it directly. A fresh TUI conversation needs no target.
client = startChild(
'opencode',
['attach', url, '--dir', state.directory, '--session', state.sessionID],
[
'attach',
url,
'--dir',
state.directory,
...(state.sessionID ? ['--session', state.sessionID] : []),
],
{
stdio: 'inherit',
env: {