mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-07 05:56:21 -10:00
fix(daemon): put a seat's guidance back before any runtime's handover successor launches (#2458)
A handover now puts a seat's missing managed guidance blocks back for every runtime before its successor launches, as it already did for Codex and as exact resume does. It passes the rig's Claude file, so a Claude seat on a rig that uses CLAUDE.local.md gets its blocks there, not in CLAUDE.md, and the file each seat gets still follows the runtime saved at its last launch. That covers a rebuild into a working directory without the seat's blocks, for example after rig seat set-cwd. Blocks already present are left alone, and a block that can't be put back is named in the result's notes while the handover completes. Not covered: comparing blocks already in the file with their current source, on any route, is not part of this change; a required startup file that can't be delivered fails an ordinary start but is only a note on a handover; Pi, omp and a Claude fork take the same call, and the new cells cover Claude fresh and rebuild. Reported in #2456 by @eisenbergjerry.
This commit is contained in:
1 parent
ab858f691b
commit
fe29a92e44
4 files changed
+46
-7
No files matched your search
@@ -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 <rig> --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.
|
||||
|
||||
---
|
||||
|
||||
@@ -276,8 +276,8 @@ export function readNodeStartupSnapshot(db: Pick<Database.Database, "prepare">,
|
||||
|
||||
/** #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;
|
||||
|
||||
@@ -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 }) },
|
||||
});
|
||||
|
||||
@@ -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}` });
|
||||
|
||||
Reference in new issue
Block a user