Public: anyone can read this; the crew can change it.
A turn of the forge's agent runs in an empty directory of its own, not the data dir #8256
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/8253-a-turn-of-the-forge-s-agent-runs-in-an-e → main 1 commit fork
2 files
changed +71
−33
src/crew/conversation.ts +52 −31
| @@ -13,6 +13,8 @@ | ||
| 13 | 13 | * resumed turn is told to check what is done before doing anything. |
| 14 | 14 | */ |
| 15 | 15 | |
| 16 | import { mkdirSync } from "node:fs"; | |
| 17 | import { join } from "node:path"; | |
| 16 | 18 | import type { App } from "../app.ts"; |
| 17 | 19 | import { dailyLeft } from "../core/account_ceiling.ts"; |
| 18 | 20 | import { appendTurn, openConversation, removeTurn, setClaudeSession, type Conversation } from "../core/conversations.ts"; |
| @@ -77,6 +79,16 @@ const RESUME_PROMPT = | ||
| 77 | 79 | /** Turns with a session listening, by conversation id: the run id a message is steered to. */ |
| 78 | 80 | const live = new Map<string, string>(); |
| 79 | 81 | |
| 82 | /** | |
| 83 | * Where a turn runs: an empty directory of its own under the data dir, made at | |
| 84 | * first use, so nothing a turn could name sits beside forge.db or secret.key. | |
| 85 | */ | |
| 86 | export const turnDir = (dataDir: string): string => { | |
| 87 | const dir = join(dataDir, "conversation-cwd"); | |
| 88 | mkdirSync(dir, { recursive: true, mode: 0o700 }); | |
| 89 | return dir; | |
| 90 | }; | |
| 91 | ||
| 80 | 92 | /** The `--mcp-config` JSON: this forge's `/mcp`, the token read from the child's environment, never from its argv. */ |
| 81 | 93 | export function mcpConfig(localUrl: string): string { |
| 82 | 94 | return JSON.stringify({ |
| @@ -194,37 +206,46 @@ function begin( | ||
| 194 | 206 | let outcome: ClaudeOutcome | undefined; |
| 195 | 207 | let unlisten = (): void => {}; |
| 196 | 208 | try { |
| 197 | outcome = await runClaude({ | |
| 198 | binary: options.binary, | |
| 199 | cwd: app.config.dataDir, | |
| 200 | prompt: resumes > 0 ? RESUME_PROMPT : prompt, | |
| 201 | appendSystemPrompt: CONVERSATION_BRIEF, | |
| 202 | model: options.model, | |
| 203 | budgetUsd: budget, | |
| 204 | resume: resumeSession, | |
| 205 | env: { | |
| 206 | ...credentialEnv(picked), | |
| 207 | ...(options.configDir === undefined ? {} : { CLAUDE_CONFIG_DIR: options.configDir }), | |
| 208 | FORGE_MCP_TOKEN: minted.value.token, | |
| 209 | }, | |
| 210 | sandbox, | |
| 211 | label: "conversation", | |
| 212 | timeoutMs: options.timeoutMs, | |
| 213 | log: (line) => appendRunLog(app.config.dataDir, runId, line), | |
| 214 | signal: options.signal, | |
| 215 | mcp: { config: mcpConfig(options.localUrl), allow: [`mcp__${MCP_SERVER}`] }, | |
| 216 | onSteer: (send) => { | |
| 217 | unlisten = listenForSteer(runId, send); | |
| 218 | }, | |
| 219 | onSession: (sessionId) => { | |
| 220 | app.db.query("UPDATE conversation_run SET session_id = ?2 WHERE id = ?1").run(runId, sessionId); | |
| 221 | setClaudeSession(app.db, userId, conversation.id, sessionId); | |
| 222 | }, | |
| 223 | onSaid: (said) => { | |
| 224 | if (said.kind === "text") lastText = said.text; | |
| 225 | if (said.text.trim() !== "") put(said.kind === "text" ? "agent" : "tool", said.text); | |
| 226 | }, | |
| 227 | }); | |
| 209 | const cwd = turnDir(app.config.dataDir); | |
| 210 | // A stored session made under another cwd cannot be resumed: Claude Code | |
| 211 | // then names no session at all. That one message starts fresh, once. | |
| 212 | for (let session = resumeSession; ; session = undefined) { | |
| 213 | let named = false; | |
| 214 | outcome = await runClaude({ | |
| 215 | binary: options.binary, | |
| 216 | cwd, | |
| 217 | prompt: resumes > 0 && session !== undefined ? RESUME_PROMPT : prompt, | |
| 218 | appendSystemPrompt: CONVERSATION_BRIEF, | |
| 219 | model: options.model, | |
| 220 | budgetUsd: budget, | |
| 221 | resume: session, | |
| 222 | env: { | |
| 223 | ...credentialEnv(picked), | |
| 224 | ...(options.configDir === undefined ? {} : { CLAUDE_CONFIG_DIR: options.configDir }), | |
| 225 | FORGE_MCP_TOKEN: minted.value.token, | |
| 226 | }, | |
| 227 | sandbox, | |
| 228 | label: "conversation", | |
| 229 | timeoutMs: options.timeoutMs, | |
| 230 | log: (line) => appendRunLog(app.config.dataDir, runId, line), | |
| 231 | signal: options.signal, | |
| 232 | mcp: { config: mcpConfig(options.localUrl), allow: [`mcp__${MCP_SERVER}`] }, | |
| 233 | onSteer: (send) => { | |
| 234 | unlisten = listenForSteer(runId, send); | |
| 235 | }, | |
| 236 | onSession: (sessionId) => { | |
| 237 | named = true; | |
| 238 | app.db.query("UPDATE conversation_run SET session_id = ?2 WHERE id = ?1").run(runId, sessionId); | |
| 239 | setClaudeSession(app.db, userId, conversation.id, sessionId); | |
| 240 | }, | |
| 241 | onSaid: (said) => { | |
| 242 | if (said.kind === "text") lastText = said.text; | |
| 243 | if (said.text.trim() !== "") put(said.kind === "text" ? "agent" : "tool", said.text); | |
| 244 | }, | |
| 245 | }); | |
| 246 | if (session === undefined || named || options.signal.aborted) break; | |
| 247 | appendRunLog(app.config.dataDir, runId, "no session to resume; starting this message fresh"); | |
| 248 | } | |
| 228 | 249 | } catch (error) { |
| 229 | 250 | app.log.warn("conversation turn failed", { error: String(error) }); |
| 230 | 251 | } finally { |
test/conversation-turn.test.ts +19 −2
| @@ -6,7 +6,7 @@ | ||
| 6 | 6 | */ |
| 7 | 7 | |
| 8 | 8 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; |
| 9 | import { chmodSync, existsSync, readFileSync, writeFileSync } from "node:fs"; | |
| 9 | import { chmodSync, existsSync, readdirSync, readFileSync, realpathSync, writeFileSync } from "node:fs"; | |
| 10 | 10 | import { mkdtemp, rm } from "node:fs/promises"; |
| 11 | 11 | import { tmpdir } from "node:os"; |
| 12 | 12 | import { join } from "node:path"; |
| @@ -33,7 +33,7 @@ const options = (binary: string, signal = new AbortController().signal): Convers | ||
| 33 | 33 | }); |
| 34 | 34 | |
| 35 | 35 | /** A stand-in `claude`: records what it was given, answers, and holds while told to. */ |
| 36 | function stub(name: string, sid: string, mode: "answer" | "hang" | "steer" | "fail"): string { | |
| 36 | function stub(name: string, sid: string, mode: "answer" | "hang" | "steer" | "fail", gone = false): string { | |
| 37 | 37 | const path = join(dir, name); |
| 38 | 38 | writeFileSync( |
| 39 | 39 | path, |
| @@ -41,6 +41,8 @@ function stub(name: string, sid: string, mode: "answer" | "hang" | "steer" | "fa | ||
| 41 | 41 | if [ "$1" = "--version" ]; then echo "2.1.0 (fake)"; exit 0; fi |
| 42 | 42 | IFS= read -r first |
| 43 | 43 | printf '%s\\n' "$@" > "${dir}/${name}.argv" |
| 44 | pwd > "${dir}/${name}.cwd" | |
| 45 | case " $* " in *" --resume "*) ${gone ? "exit 1" : ":"} ;; esac | |
| 44 | 46 | printf '%s' "$FORGE_MCP_TOKEN" > "${dir}/${name}.token" |
| 45 | 47 | printf '%s' "$first" > "${dir}/${name}.first" |
| 46 | 48 | echo '{"type":"system","subtype":"init","session_id":"${sid}","model":"fake"}' |
| @@ -117,6 +119,21 @@ describe("a conversation turn", () => { | ||
| 117 | 119 | expect(args[args.indexOf("--resume") + 1]).toBe("sess-a"); |
| 118 | 120 | }); |
| 119 | 121 | |
| 122 | test("the turn runs in an empty directory of its own, not the data dir", () => { | |
| 123 | const cwd = readFileSync(join(dir, "first.cwd"), "utf8").trim(); | |
| 124 | expect(cwd).toBe(realpathSync(join(dir, "conversation-cwd"))); | |
| 125 | expect(readdirSync(cwd)).toEqual([]); | |
| 126 | }); | |
| 127 | ||
| 128 | test("a stored session that cannot be resumed starts that message fresh, once", async () => { | |
| 129 | const bin = stub("gone", "sess-new", "answer", true); | |
| 130 | const said = say(app, options(bin), alice.id, "still there?"); | |
| 131 | if (said.ok) await said.value.done; | |
| 132 | expect(turns(alice).at(-1)).toBe("agent:All quiet."); | |
| 133 | expect(lines("gone")).not.toContain("--resume"); | |
| 134 | expect(openConversation(app.db, alice.id).claudeSessionId).toBe("sess-new"); | |
| 135 | }); | |
| 136 | ||
| 120 | 137 | test("a message while a turn runs is steered into it, not started beside it", async () => { |
| 121 | 138 | const bin = stub("steered", "sess-a", "steer"); |
| 122 | 139 | const a = say(app, options(bin), alice.id, "one"); |
new above
offline