fix: Codespace agent session fixes and thread→box turn routing (no-changelog) (#36173)

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Declan Carroll 2026-08-13 10:35:19 +00:00 committed by GitHub
parent ddd5494809
commit 0904287eaf
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 61 additions and 41 deletions

View File

@ -121,9 +121,15 @@ The private marketplace uses the codespace's own GitHub auth — no extra token.
prompts for it, then remembers). Both repos are in the same org, which is what
lets this work.
If a user does not authorize it, the clone fails and the skills step is skipped
(the worker still starts). Existing codespaces created before this change need a
recreate to get the prompt. The log is at `/tmp/post-start.log`.
The codespace authenticates git over HTTPS and has no SSH key, so `post-start.mjs`
sets `CLAUDE_CODE_PLUGIN_PREFER_HTTPS=1` — Claude Code's plugin loader otherwise
clones `owner/repo` shorthand over SSH and the private clone fails.
If a user does not authorize the grant, the clone fails and the skills step is
skipped (the worker still starts). Existing codespaces created before this change
need a recreate to get the prompt. The log is at `/tmp/post-start.log`: a failed
`skills repo reachable` line means the grant was not authorized; a failed
`marketplace add` after a reachable repo means a loader-auth problem.
## Viewing the dev UI locally
@ -203,7 +209,7 @@ After a stop, `pnpm session <name>` restarts the codespace (~30–60 s); run
## Costs
Compute bills only while the codespace runs: ~$0.36/hr (4-core) / ~$0.72/hr
(8-core), ~nothing stopped. Usage draws from a shared monthly org budget, so
Compute bills only while the codespace runs: ~$0.72/hr for the 8-core box,
~nothing stopped. Usage draws from a shared monthly org budget, so
`pnpm session stop` when you leave; the idle timeout is the backstop, not the
plan.

View File

@ -10,9 +10,9 @@
// Env:
// N8N_DEQUEUE_URL n8n webhook that hands back one pending turn (required)
// AGENT_WORKER_TOKEN shared bearer sent on every dequeue (required)
// GITHUB_USER box owner's login; turns are addressed to it (codespaces set this)
// AGENT_WORKER_ROOT confine turn cwd under here (default /workspaces)
// POLL_INTERVAL_MS delay between empty polls (default 3000)
// GITHUB_USER box owner's login; the bootstrap route for a new thread (codespaces set this)
// CODESPACE_NAME stable box id; routes a thread back to the box holding its session (codespaces set this)
// TURN_TIMEOUT_MS per-turn limit; keep below the n8n Wait limit (default 25 min)
import { execFile } from 'node:child_process';
import { resolve as resolvePath, sep } from 'node:path';
import { setTimeout as sleep } from 'node:timers/promises';
@ -20,10 +20,13 @@ import { setTimeout as sleep } from 'node:timers/promises';
const DEQUEUE_URL = process.env.N8N_DEQUEUE_URL;
const TOKEN = process.env.AGENT_WORKER_TOKEN;
const GITHUB_USER = process.env.GITHUB_USER;
const ROOT = resolvePath(process.env.AGENT_WORKER_ROOT ?? '/workspaces');
const BOX_ID = process.env.CODESPACE_NAME;
const ROOT = '/workspaces';
// Read a positive-number env var. Fall back to the default (and warn) on a bad
// value, so a config mistake cannot change the poll rate or the turn limit.
const POLL_INTERVAL_MS = 3000;
// Warn and use the default on a bad value, so a config mistake cannot silently
// disable the turn limit.
function posNum(name, fallback) {
const raw = process.env[name];
if (raw === undefined) return fallback;
@ -33,7 +36,6 @@ function posNum(name, fallback) {
return fallback;
}
const POLL_INTERVAL_MS = posNum('POLL_INTERVAL_MS', 3000);
// Keep this below the n8n Wait-node limit. Then the worker reports a slow turn
// before n8n's Wait ends with a generic message.
const TURN_TIMEOUT_MS = posNum('TURN_TIMEOUT_MS', 25 * 60_000);
@ -88,7 +90,7 @@ async function post(url, body) {
}
async function dequeue() {
const res = await post(DEQUEUE_URL, { githubUser: GITHUB_USER, token: TOKEN });
const res = await post(DEQUEUE_URL, { githubUser: GITHUB_USER, boxId: BOX_ID, token: TOKEN });
if (!res.ok) throw new Error(`dequeue HTTP ${res.status}`);
const text = await res.text();
if (!text.trim()) return null; // no pending turn
@ -105,6 +107,7 @@ async function handle(turn) {
status: 'done',
output: r.result ?? '',
sessionId: r.session_id ?? turn.sessionId ?? '',
boxId: BOX_ID,
};
} catch (error) {
result = {
@ -112,6 +115,7 @@ async function handle(turn) {
status: 'error',
output: error.message,
sessionId: turn.sessionId ?? '',
boxId: BOX_ID,
};
}
// Send the result to the turn's resume URL. This continues the waiting n8n
@ -137,7 +141,9 @@ for (;;) {
try {
const turn = await dequeue();
if (turn) {
console.log(`turn ${turn.turnId}: ${turn.sessionId ? 'resume' : 'new'}`);
console.log(
`${new Date().toISOString()} turn ${turn.turnId} by ${turn.author ?? 'unknown'}: ${turn.sessionId ? 'resume' : 'new'}`,
);
await handle(turn);
continue; // get the next turn now, with no delay
}

View File

@ -1,18 +1,15 @@
#!/usr/bin/env node
// Runs on each codespace start. It installs the private skills marketplace into
// Claude Code (best-effort), then starts the agent worker.
//
// The codespace's own GitHub token is granted read access to the marketplace
// repo via customizations.codespaces.repositories in devcontainer.json (each
// user authorizes it once at codespace creation). So git — and Claude Code's
// clone — read it with the codespace's normal auth, no extra token. If a user
// did not authorize it, the clone fails and the skills step is skipped. The
// worker always starts.
// Runs on each codespace start. Installs the skills marketplace, starts the worker.
import { execFileSync } from 'node:child_process';
const MARKETPLACE = 'n8n-io/n8n-claude-skills';
const PLUGIN = 'quality@n8n-claude-skills';
// The codespace clones over HTTPS and has no SSH key. The loader defaults to SSH
// for "owner/repo", so it must prefer HTTPS or the private clone fails.
process.env.CLAUDE_CODE_PLUGIN_PREFER_HTTPS = '1';
process.env.CLAUDE_CODE_PLUGIN_KEEP_MARKETPLACE_ON_FAILURE = '1';
function tryRun(label, cmd, args) {
try {
execFileSync(cmd, args, { stdio: 'inherit' });
@ -23,14 +20,15 @@ function tryRun(label, cmd, args) {
}
}
tryRun('skills repo reachable', 'git', ['ls-remote', `https://github.com/${MARKETPLACE}`, 'HEAD']);
if (tryRun('marketplace add', 'claude', ['plugin', 'marketplace', 'add', MARKETPLACE]))
tryRun('plugin install', 'claude', ['plugin', 'install', PLUGIN]);
// Start the poll worker, detached, so it survives this script exiting.
tryRun('worker start', 'tmux', [
'new-session',
'-d',
'-s',
'agent-worker',
'bash -lc "node /workspaces/n8n/.devcontainer/codespaces/agent-worker.mjs >> /tmp/agent-worker.log 2>&1"',
'bash -lc "export CLAUDE_CODE_PLUGIN_PREFER_HTTPS=1 CLAUDE_CODE_PLUGIN_KEEP_MARKETPLACE_ON_FAILURE=1; node /workspaces/n8n/.devcontainer/codespaces/agent-worker.mjs >> /tmp/agent-worker.log 2>&1"',
]);

View File

@ -13,8 +13,7 @@ import { execFileSync, spawnSync } from 'node:child_process';
const REPO = 'n8n-io/n8n';
const DEVCONTAINER = '.devcontainer/codespaces/devcontainer.json';
const MACHINE = process.env.CODESPACE_MACHINE ?? 'premiumLinux'; // 8-core/32GB
const FALLBACK_MACHINE = 'standardLinux32gb'; // 4-core/16GB, until org policy allows bigger
const MACHINE = 'premiumLinux'; // 8-core/32GB — the only size we use
const gh = (...args) => execFileSync('gh', args, { encoding: 'utf8' }).trim();
const ghTty = (...args) => spawnSync('gh', args, { stdio: 'inherit' });
@ -39,21 +38,32 @@ function requestCodespaceScope() {
}
function ensureCodespace() {
let cs = findCodespace();
if (!cs) {
console.log(`No codespace on ${REPO} — creating one (first build takes a while)…`);
const create = (machine) =>
gh('codespace', 'create', '-R', REPO, '--devcontainer-path', DEVCONTAINER, '-m', machine);
let name;
try {
name = create(MACHINE);
} catch {
console.log(`Machine type ${MACHINE} unavailable — falling back to ${FALLBACK_MACHINE}`);
name = create(FALLBACK_MACHINE);
}
cs = { name };
const existing = findCodespace();
if (existing) return existing.name;
console.log(`No codespace on ${REPO} — creating one (first build takes a while)…`);
// Run interactive. The devcontainer requests access to the private skills repo,
// so gh prints an authorization URL and waits. A captured run cannot answer it.
const { status } = ghTty(
'codespace',
'create',
'-R',
REPO,
'--devcontainer-path',
DEVCONTAINER,
'-m',
MACHINE,
);
if (status !== 0) {
console.error('Codespace create failed. Authorize the permissions prompt above, then retry.');
process.exit(1);
}
return cs.name;
const created = findCodespace();
if (!created) {
console.error('Created a codespace but could not find it — run `pnpm session ls`.');
process.exit(1);
}
return created.name;
}
// The preludes go inside tmux's '…' argument: do not use single quotes in them.