diff --git a/docs/reference/agent-startup-guide.md b/docs/reference/agent-startup-guide.md index 97ba05012..c63f3fdd2 100644 --- a/docs/reference/agent-startup-guide.md +++ b/docs/reference/agent-startup-guide.md @@ -369,6 +369,8 @@ Each startup file and action specifies when it applies: **An exact resume delivers neither.** When a restore reopens the seat's saved conversation (`rig up --existing` with a resume token), OpenRig types no startup actions and delivers no startup files, `restore` ones included: the conversation already has them, and typing them again would add turns to it. The one exception: a managed guidance block missing from the guidance file is put back before the harness starts. +**A seat handover does the same put-back.** Before a fresh, fork or rebuild successor starts, the handover puts the seat's missing managed guidance blocks back in its guidance file from the saved startup context. That's the rig's Claude file (`CLAUDE.md`, or `CLAUDE.local.md` when the rig uses it) for Claude, and `AGENTS.md` for Codex, Pi and omp. So a seat rebuilt into a new working directory keeps its guidance. Blocks already in the file are left as they are. + Use this to avoid re-sending context that a fresh conversation doesn't need twice. For example, a one-time project briefing might only apply on `fresh_start`, while identity grounding should apply on both, so a seat that comes back fresh gets it again. --- diff --git a/packages/daemon/src/domain/restore-guidance.ts b/packages/daemon/src/domain/restore-guidance.ts index 6ce338842..85809e3c2 100644 --- a/packages/daemon/src/domain/restore-guidance.ts +++ b/packages/daemon/src/domain/restore-guidance.ts @@ -276,8 +276,8 @@ export function readNodeStartupSnapshot(db: Pick, /** #875: put a seat's missing managed blocks back in its guidance file from its saved startup context, as an exact resume * does: the seat-skills line only with the person's yes, and nothing through a recorded file that is now a link. A - * successor launch carries no guidance of its own, so a Codex seat whose launch sent it as developer instructions needs - * this before its successor starts. Never throws; returns what it did and what it couldn't. */ + * successor launch carries no guidance of its own, so every seat's handover needs this before its successor starts + * (#2456). Never throws; returns what it did and what it couldn't. */ export function putBackSeatGuidance(input: { startupCtx: NodeStartupSnapshot | null; cwd: string | null | undefined; diff --git a/packages/daemon/src/domain/seat-handover-service.ts b/packages/daemon/src/domain/seat-handover-service.ts index 569db829d..0eb73b909 100644 --- a/packages/daemon/src/domain/seat-handover-service.ts +++ b/packages/daemon/src/domain/seat-handover-service.ts @@ -538,9 +538,10 @@ export class SeatHandoverService { const launchAdditionNotes = [ ...seatAdditions.missing.map((file) => `seat_launch_addition_missing: ${file} is not in this seat's storage; the successor launched without it`), ...seatAdditions.refused.map((reason) => `seat_launch_addition_refused: ${reason}; the successor launched without it`), - // #875: a Codex seat whose launch sent its guidance as developer instructions left none in its repository AGENTS.md, - // and a successor's launch carries none, so the missing blocks go back first, as an exact resume puts them back. - ...(node.runtime === "codex" ? this.putBackGuidance(node.id, node.cwd) : []).map((note) => `guidance_put_back: ${note}`), + // #875, #2456: a successor's launch carries no guidance of its own, whatever the runtime, so the seat's missing blocks + // go back first, as an exact resume puts them back: a Codex seat whose launch sent them as developer instructions, + // or any seat rebuilt into a working directory that doesn't hold them. + ...this.putBackGuidance(node.id, node.cwd, statusResult.status.rig_id).map((note) => `guidance_put_back: ${note}`), ]; const codexCorePluginDir = seatCodexCorePluginDir(node, this.db, undefined, this.pluginsDir); const launch = await this.successorLauncher.createSuccessor({ @@ -722,14 +723,14 @@ export class SeatHandoverService { } /** #875: the seat's missing guidance blocks put back from its saved startup context, as an exact resume does. Returns notes. */ - private putBackGuidance(nodeId: string, cwd: string | null | undefined): string[] { + private putBackGuidance(nodeId: string, cwd: string | null | undefined, rigId: string): string[] { let seatSkillsLineYes = false; try { seatSkillsLineYes = readFootprintAnswer(new SettingsStore()).state === "yes"; } catch { /* no recorded yes */ } let startupCtx; try { startupCtx = readNodeStartupSnapshot(this.db, nodeId); } catch (err) { return [`could not read the saved startup context: ${(err as Error).message}`]; } return putBackSeatGuidance({ - startupCtx, cwd, claudeManagedBlockFile: null, seatSkillsLineYes, linkInfo: lstatLinkInfo, + startupCtx, cwd, claudeManagedBlockFile: this.rigRepo.getRigClaudeManagedBlockFile(rigId), seatSkillsLineYes, linkInfo: lstatLinkInfo, fs: { exists: existsSync, readFile: (file) => readFileSync(file, "utf-8"), writeFile: (file, content) => writeFileSync(file, content, "utf-8"), mkdirp: (dir) => mkdirSync(dir, { recursive: true }) }, }); diff --git a/packages/daemon/test/seat-handover-service.test.ts b/packages/daemon/test/seat-handover-service.test.ts index 182d36724..417cbfe73 100644 --- a/packages/daemon/test/seat-handover-service.test.ts +++ b/packages/daemon/test/seat-handover-service.test.ts @@ -214,6 +214,42 @@ describe("SeatHandoverService", () => { } finally { fs.rmSync(root, { recursive: true, force: true }); } }); + // #2456: a Claude successor's launch carries no guidance either, so a rebuild into a working directory without the seat's + // blocks (after `rig seat set-cwd`, say) started without them. The handover puts them back in the rig's Claude file first. + it.each([ + ["fresh", "CLAUDE.md"], + ["rebuild", "CLAUDE.md"], + ["fresh", "CLAUDE.local.md"], + ["rebuild", "CLAUDE.local.md"], + ] as const)("a %s Claude successor starts with the seat's guidance in the rig's %s (#2456)", async (source, file) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "handover-guidance-claude-")); + try { + const { rig, node } = seedSeat({ runtime: "claude-code" }); + if (file !== "CLAUDE.md") rigRepo.setRigClaudeManagedBlockFile(rig.id, file); + db.prepare("UPDATE nodes SET cwd = ? WHERE id = ?").run(root, node.id); + const culture = path.join(root, "spec", "culture.md"); + fs.mkdirSync(path.dirname(culture), { recursive: true }); + fs.writeFileSync(culture, "Team culture.\n"); + db.prepare("INSERT INTO node_startup_context (node_id, projection_entries_json, resolved_files_json, startup_actions_json, runtime) VALUES (?, ?, '[]', '[]', 'claude-code')") + .run(node.id, JSON.stringify([{ category: "guidance", effectiveId: "culture.md", sourceSpec: "base", sourcePath: path.dirname(culture), + resourcePath: "culture.md", absolutePath: culture, mergeStrategy: "managed_block", target: "CLAUDE.md" }])); + const guidance = path.join(root, file); + capturePaneScreen.mockResolvedValue("Claude Code v2.1.303\n❯ \n────────────────────\n ⏵⏵ accept edits on (shift+tab to cycle)"); + let atLaunch: string | null = null; + launchHarness.mockImplementation(async () => { + atLaunch = fs.existsSync(guidance) ? fs.readFileSync(guidance, "utf8") : null; + return { ok: true, resumeToken: "claude-successor", resumeType: "claude_id" }; + }); + const claude = new SeatHandoverService({ db, rigRepo, sessionRegistry, discoveryRepo, eventBus, + tmuxAdapter: tmux(), runtimeAdapters: { "claude-code": { runtime: "claude-code", launchHarness, checkReady } as unknown as RuntimeAdapter }, + predecessorRecapResolver: resolvePredecessorRecap as never, readinessTimeoutMs: 50, sleep: async () => {} }); + const result = await claude.handover({ seatRef: "dev-impl@seat-rig", reason: "replace occupant", source }); + expect(result, JSON.stringify(result)).toMatchObject({ ok: true }); + expect(atLaunch).toContain("Team culture."); + if (file !== "CLAUDE.md") expect(fs.existsSync(path.join(root, "CLAUDE.md"))).toBe(false); + } finally { fs.rmSync(root, { recursive: true, force: true }); } + }); + it.each(["stop", "restart", "clean"] as const)("allows %s for a launched handover but refuses discovered and directly adopted occupants", async operation => { for (const provenance of ["adopted", "discovered", "launched"] as const) { const { node, sessionId } = seedSeat({ rigName: `${operation}-${provenance}` });