mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-17 05:29:44 +02:00
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:
parent
ddd5494809
commit
0904287eaf
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"',
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user