From 51b0de1875bbfca2b5a6daaf2a868ba82110337e Mon Sep 17 00:00:00 2001 From: Declan Carroll Date: Fri, 14 Aug 2026 11:50:32 +0000 Subject: [PATCH] fix: Tell each codespace agent-worker turn that a turn is atomic (no-changelog) (#36304) Co-authored-by: Claude Opus 5 (1M context) --- .devcontainer/codespaces/README.md | 19 +++++++++- .devcontainer/codespaces/agent-worker.mjs | 46 ++++++++++++++++++++++- 2 files changed, 61 insertions(+), 4 deletions(-) diff --git a/.devcontainer/codespaces/README.md b/.devcontainer/codespaces/README.md index f7afd9d7098..fe6e5a21e18 100644 --- a/.devcontainer/codespaces/README.md +++ b/.devcontainer/codespaces/README.md @@ -71,6 +71,19 @@ A turn stops after about 25 minutes (`TURN_TIMEOUT_MS`). This limit is below the n8n Wait limit. So the worker reports a clear message before n8n reports a generic timeout. Keep the worker limit below the n8n limit if you change either. +**A turn is atomic, and the worker tells the session so.** The turn ends on the +session's final message, and its children end with it: a background `Bash` task +is killed, `Monitor` events never arrive, `PushNotification` has nowhere to go, +and `ScheduleWakeup` never fires. The session also gets no turn of its own to +report back in — the turn's resume URL continues one waiting n8n execution and is +then spent, so nothing on the box can post to the thread unprompted. A session +that backgrounds a build and signs off with "I'll verify once it finishes" is +therefore describing something that cannot happen. The worker states this in +`--append-system-prompt` on every turn (`turnContract`), together with a pointer +to this file for the box-specific parts. This is only the n8n/Slack path: an +interactive session (`pnpm session`, tmux) is long-lived, so background work, +monitors and notifications behave normally there. + ### Build and run the app in a session The prebuild already installed the dependencies and warmed the build. So a @@ -100,8 +113,10 @@ session rarely needs a cold `pnpm install` or a full `pnpm build`. Both are slow turbo cache and is fast when warm. - To clear stale build outputs after a branch switch, run `pnpm reset`. Add `--full` if that does not clear it. -- Give a long build its own turn. Do not chain an install and a full build - behind other work in one turn. +- Run a long build in the foreground and give it its own turn. Backgrounding it + does not help: it is killed when the turn ends (see above). Do not chain an + install and a full build behind other work in one turn either — that is what + runs into the 25-minute limit. ## Flaky tools (MCP) diff --git a/.devcontainer/codespaces/agent-worker.mjs b/.devcontainer/codespaces/agent-worker.mjs index e99e3858015..b5acceede94 100644 --- a/.devcontainer/codespaces/agent-worker.mjs +++ b/.devcontainer/codespaces/agent-worker.mjs @@ -67,11 +67,53 @@ const TURN_ENV = { ...process.env }; if (BOX_ID) TURN_ENV.CODESPACE_NAME = BOX_ID; if (GITHUB_USER) TURN_ENV.GITHUB_USER = GITHUB_USER; -function runClaude({ message, sessionId, cwd }) { +const CODESPACE_DOCS = '.devcontainer/codespaces/README.md'; + +// A session cannot be told any of this after its final message, and a system +// prompt is not part of the resumed transcript, so send it on every turn. State +// the turn's hard limits inline: a session that has to read a file to learn them +// can reply before it gets there. Point at the box docs for the rest — they +// already cover dev:up, ports, and build cost, and AGENTS.md does not. +function turnContract(author) { + return [ + '# Your runtime', + 'You are one turn of a Slack thread, driven by an n8n workflow that runs you as a headless', + '`claude -p` on a GitHub codespace. Your final message is the reply that reaches Slack, so keep', + 'it short and skip heavy markdown.', + author ? `You are replying to ${author}.` : '', + '', + '# A turn is atomic', + 'The turn ends when you emit your final message, and everything you started ends with it:', + 'background Bash tasks are killed, Monitor events never arrive, PushNotification has nowhere to', + 'go, and ScheduleWakeup never fires. You get no turn of your own afterwards — you cannot speak', + 'again until a human writes again. So run long work (builds, test suites, restarts) in the', + 'foreground of this turn and wait for it, or do not start it at all. Never end a turn promising', + `to verify, check back, or follow up. Work that will not fit the turn limit of ~${Math.round( + TURN_TIMEOUT_MS / 60_000, + )} minutes`, + 'should be split: do the part that fits, then say what to ask for next.', + '', + '# This box', + `You are on codespace ${BOX_ID ?? '(unknown)'}, not a laptop. Before you build, start, or expose`, + `the app, read ${CODESPACE_DOCS} ("Build and run the app in a session"). It is box-specific and`, + 'the repo AGENTS.md does not cover it.', + ] + .filter(Boolean) + .join('\n'); +} + +function runClaude({ message, sessionId, cwd, author }) { const safeCwd = resolvePath(typeof cwd === 'string' && cwd ? cwd : `${ROOT}/n8n`); if (safeCwd !== ROOT && !safeCwd.startsWith(ROOT + sep)) throw new Error(`cwd must be under ${ROOT}`); - const args = ['-p', '--output-format', 'json', '--dangerously-skip-permissions']; + const args = [ + '-p', + '--output-format', + 'json', + '--dangerously-skip-permissions', + '--append-system-prompt', + turnContract(typeof author === 'string' ? author : ''), + ]; if (sessionId) args.push('--resume', sessionId); args.push(message); return new Promise((res, rej) => {