Public: anyone can read this; the crew can change it.

A /talk conversation resumes its own session across a real forge restart #8780

Merged opened this

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

Merged

torque/forge:torque/8776-a-talk-conversation-resumes-its-own-sess → main 1 commit fork 1 thread · 1 unresolved

  • Merged. torque/8776-a-talk-conversation-resumes-its-own-sess was merged into main as 9fa3a56.

Review

Sign in to review this change.
2 files changed +118 −0
src/crew/conversation.ts +1
@@ -300,6 +300,7 @@ function begin(
300300 let fresh = prompt;
301301 for (let session = resumeSession; ; session = undefined) {
302302 let named = false;
303 if (session !== undefined) appendRunLog(app.config.dataDir, runId, `resuming session ${session}`);
303304 outcome = await runClaude({
304305 binary: options.binary,
305306 cwd,
test/conversation-restart.test.ts added +117
@@ -0,0 +1,117 @@
1 /**
2 * A /talk conversation resumes its own session across a real forge restart
3 * (#8776): the first message goes through the real `claude`, the forge is
4 * stopped and started again on the same data directory, and the second
5 * message resumes the same `claude_session_id` instead of taking the
6 * fresh-start fallback. The session files are under the data directory, and
7 * nothing is written under HOME.
8 *
9 * Skipped, with the reason on the console, where there is no `claude` binary
10 * or no credential in the environment (`ANTHROPIC_API_KEY` or
11 * `CLAUDE_CODE_OAUTH_TOKEN`): the check's cells have both. Two tiny turns on
12 * the smallest model, each capped at $0.25.
13 */
14 import { afterAll, describe, expect, test } from "bun:test";
15 import { existsSync, mkdirSync, readdirSync, readFileSync, statSync } from "node:fs";
16 import { mkdtemp, rm } from "node:fs/promises";
17 import { tmpdir } from "node:os";
18 import { join } from "node:path";
19 import { createApp, type App } from "../src/app.ts";
20 import { listTurns, openConversation } from "../src/core/conversations.ts";
21 import { createUser } from "../src/core/users.ts";
22 import { say, sessionsDir, type ConversationOptions } from "../src/crew/conversation.ts";
23 import { serve } from "../src/http/server.ts";
24 import { runLogPath } from "../src/runs/log.ts";
25
26 const binary = Bun.which("claude");
27 const credential = process.env.ANTHROPIC_API_KEY || process.env.CLAUDE_CODE_OAUTH_TOKEN;
28 const why = binary === null ? "no `claude` binary on PATH" : !credential ? "no ANTHROPIC_API_KEY or CLAUDE_CODE_OAUTH_TOKEN in the environment" : undefined;
29 if (why !== undefined) console.warn(`skipped: conversation-restart needs the real claude: ${why}`);
30
31 /** Every file under `dir`, relative, depth-first. */
32 const files = (dir: string): string[] =>
33 !existsSync(dir)
34 ? []
35 : readdirSync(dir, { withFileTypes: true }).flatMap((e) =>
36 e.isDirectory() ? files(join(dir, e.name)).map((f) => join(e.name, f)) : [e.name],
37 );
38
39 const realHome = process.env.HOME;
40 let dataDir = "";
41 let home = "";
42
43 afterAll(async () => {
44 if (realHome === undefined) delete process.env.HOME;
45 else process.env.HOME = realHome;
46 if (dataDir !== "") await rm(dataDir, { recursive: true, force: true });
47 if (home !== "") await rm(home, { recursive: true, force: true });
48 });
49
50 /** One forge process's life: boot on the data directory, say one message, stop. */
51 async function lifetime(userId: string | undefined, message: string) {
52 const app: App = createApp({ dataDir, port: 0 });
53 const servers = await serve(app);
54 try {
55 let id = userId;
56 if (id === undefined) {
57 const u = await createUser(app.db, { origin: "seed", trusted: true, username: "alice", email: "[email protected]", password: "a-good-passphrase", adminIfFirst: true });
58 if (!u.ok) throw new Error(u.error);
59 id = u.value.id;
60 }
61 const options: ConversationOptions = {
62 binary: binary!,
63 localUrl: servers.url,
64 budgetUsd: 0.25,
65 timeoutMs: 120_000,
66 model: "haiku",
67 signal: new AbortController().signal,
68 };
69 const said = say(app, options, id, message);
70 if (!said.ok) throw new Error(said.error);
71 await said.value.done;
72 const conversation = openConversation(app.db, id);
73 const run = app.db.query<{ id: string; state: string }, [string]>("SELECT id, state FROM conversation_run WHERE conversation_id = ?1 ORDER BY id DESC LIMIT 1").get(conversation.id)!;
74 const log = existsSync(runLogPath(dataDir, run.id)) ? readFileSync(runLogPath(dataDir, run.id), "utf8") : "";
75 const t = listTurns(app.db, id, conversation.id);
76 const agent = t.ok ? t.value.filter((x) => x.role === "agent").map((x) => x.text).join(" ") : "";
77 return { userId: id, session: conversation.claudeSessionId, state: run.state, log, agent };
78 } finally {
79 servers.stop();
80 app.close();
81 }
82 }
83
84 describe("a conversation across a forge restart", () => {
85 test.skipIf(why !== undefined)(
86 "the second message resumes the first's session, kept under the data directory",
87 async () => {
88 dataDir = await mkdtemp(join(tmpdir(), "forge-conv-restart-"));
89 home = await mkdtemp(join(tmpdir(), "forge-conv-home-"));
90 mkdirSync(join(home, ".claude"), { recursive: true });
91 process.env.HOME = home; // a session written there would show
92
93 const first = await lifetime(undefined, "Remember the word PELICAN. Reply with only: ok");
94 console.log(`first turn: state=${first.state} session=${first.session}\n${first.log}`);
95 expect(first.state).toBe("ok");
96 expect(first.session).not.toBe("");
97 expect(first.log).not.toContain("resuming session");
98
99 const second = await lifetime(first.userId, "What word did I ask you to remember? Reply with only that word.");
100 console.log(`second turn (after restart): state=${second.state} session=${second.session}\n${second.log}`);
101 expect(second.state).toBe("ok");
102 expect(second.log).toContain(`resuming session ${first.session}`);
103 expect(second.log).not.toContain("no session to resume");
104 expect(second.session).toBe(first.session);

This assertion passes trivially if claude never sees the temp HOME. Also assert that the session files exist under the data dir (done above) and, ideally, that home/.claude holds no projects dir.

105 expect(second.agent.toLowerCase()).toContain("pelican");
106
107 // The session files are under the data directory...
108 const kept = files(sessionsDir(dataDir)).filter((f) => f.endsWith(`${first.session}.jsonl`));
109 console.log(`session files under data dir: ${kept.join(", ")}`);
110 expect(kept.length).toBeGreaterThan(0);
111 expect(statSync(join(dataDir, "claude-config")).mode & 0o077).toBe(0);
112 // ...and none under HOME.
113 expect(files(home).filter((f) => f.endsWith(".jsonl"))).toEqual([]);
114 },
115 300_000,
116 );
117 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs