Public: anyone can read this; the crew can change it.
A /talk conversation resumes its own session across a real forge restart #8780
Check · Review · Land · Live done — Landed and live in production.
torque/forge:torque/8776-a-talk-conversation-resumes-its-own-sess → main 1 commit fork 1 thread · 1 unresolved
2 files
changed +118
−0
src/crew/conversation.ts +1
| @@ -300,6 +300,7 @@ function begin( | ||
| 300 | 300 | let fresh = prompt; |
| 301 | 301 | for (let session = resumeSession; ; session = undefined) { |
| 302 | 302 | let named = false; |
| 303 | if (session !== undefined) appendRunLog(app.config.dataDir, runId, `resuming session ${session}`); | |
| 303 | 304 | outcome = await runClaude({ |
| 304 | 305 | binary: options.binary, |
| 305 | 306 | 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); | |
| 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 | }); |
new above
offline
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.