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

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/8253-a-turn-of-the-forge-s-agent-runs-in-an-e → main 1 commit fork

  • Merged. scout/8253-a-turn-of-the-forge-s-agent-runs-in-an-e was merged into main as ed10b5c.

Review

Sign in to review this change.
2 files changed +71 −33
src/crew/conversation.ts +52 −31
@@ -13,6 +13,8 @@
1313 * resumed turn is told to check what is done before doing anything.
1414 */
1515
16 import { mkdirSync } from "node:fs";
17 import { join } from "node:path";
1618 import type { App } from "../app.ts";
1719 import { dailyLeft } from "../core/account_ceiling.ts";
1820 import { appendTurn, openConversation, removeTurn, setClaudeSession, type Conversation } from "../core/conversations.ts";
@@ -77,6 +79,16 @@ const RESUME_PROMPT =
7779 /** Turns with a session listening, by conversation id: the run id a message is steered to. */
7880 const live = new Map<string, string>();
7981
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
8092 /** The `--mcp-config` JSON: this forge's `/mcp`, the token read from the child's environment, never from its argv. */
8193 export function mcpConfig(localUrl: string): string {
8294 return JSON.stringify({
@@ -194,37 +206,46 @@ function begin(
194206 let outcome: ClaudeOutcome | undefined;
195207 let unlisten = (): void => {};
196208 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 }
228249 } catch (error) {
229250 app.log.warn("conversation turn failed", { error: String(error) });
230251 } finally {
test/conversation-turn.test.ts +19 −2
@@ -6,7 +6,7 @@
66 */
77
88 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";
1010 import { mkdtemp, rm } from "node:fs/promises";
1111 import { tmpdir } from "node:os";
1212 import { join } from "node:path";
@@ -33,7 +33,7 @@ const options = (binary: string, signal = new AbortController().signal): Convers
3333 });
3434
3535 /** 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 {
3737 const path = join(dir, name);
3838 writeFileSync(
3939 path,
@@ -41,6 +41,8 @@ function stub(name: string, sid: string, mode: "answer" | "hang" | "steer" | "fa
4141 if [ "$1" = "--version" ]; then echo "2.1.0 (fake)"; exit 0; fi
4242 IFS= read -r first
4343 printf '%s\\n' "$@" > "${dir}/${name}.argv"
44 pwd > "${dir}/${name}.cwd"
45 case " $* " in *" --resume "*) ${gone ? "exit 1" : ":"} ;; esac
4446 printf '%s' "$FORGE_MCP_TOKEN" > "${dir}/${name}.token"
4547 printf '%s' "$first" > "${dir}/${name}.first"
4648 echo '{"type":"system","subtype":"init","session_id":"${sid}","model":"fake"}'
@@ -117,6 +119,21 @@ describe("a conversation turn", () => {
117119 expect(args[args.indexOf("--resume") + 1]).toBe("sess-a");
118120 });
119121
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
120137 test("a message while a turn runs is steered into it, not started beside it", async () => {
121138 const bin = stub("steered", "sess-a", "steer");
122139 const a = say(app, options(bin), alice.id, "one");

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs