From 8e85902060c725a3f5528a38d7f47a8a33b64e9e Mon Sep 17 00:00:00 2001 From: Mike Schwarz <171890339+mvschwarz@users.noreply.github.com> Date: Tue, 3 Nov 2026 23:30:48 -0800 Subject: [PATCH] fix(daemon): a handover's successor is pointed to its startup files, role first (#2474) A seat handed over on a fresh install now starts with its role, as a normal launch does. A start types each send_text startup file into the new conversation, the role first; a handover ran no startup delivery, so a fresh or rebuild successor of the built-in development, design and conveyor agents and vault-specialist started without its role file, and nothing said so. The first section of the turn each fresh or rebuild successor already gets, the restore packet or the priming packet, now lists the seat's send_text startup files as absolute paths, role first, before the predecessor's context: the files a fresh start delivers, required and optional. A file whose source is missing is listed as MISSING, and a missing required one joins startedWithoutRequired, so the seat reads as needing attention in rig ps with the file named, and the handover still completes. Fork successors, which carry their parent's conversation, and discovered successors get no list. The runtime switch is unchanged, since its successor's first turn already carries these files' text. Limits: a pointer asks the successor to read the file, rather than pasting its text as a start does; send_text startup actions, such as an after_ready preload, aren't run for a successor. Reported in #2456 by @eisenbergjerry. --- docs/as-built/cli-reference.md | 2 +- docs/reference/agent-state-taxonomy.md | 14 ++++--- .../daemon/src/domain/restore-guidance.ts | 36 ++++++++++++++++ .../src/domain/seat-handover-service.ts | 37 +++++++++++++++- .../daemon/test/seat-handover-service.test.ts | 42 +++++++++++++++++++ 5 files changed, 123 insertions(+), 8 deletions(-) diff --git a/docs/as-built/cli-reference.md b/docs/as-built/cli-reference.md index 70faed6e1..c7ca00e11 100644 --- a/docs/as-built/cli-reference.md +++ b/docs/as-built/cli-reference.md @@ -379,7 +379,7 @@ do not establish connectivity to a live peer. |---|---|---| | `rig ps` | — | `--json`
`--no-cleanup`
`--resources`
`--nodes`
`--full`
`--verbose`
`--limit `
`--fields `
`--summary`
`--filter `
`--active`
`--running`
`-A, --all-rigs`
`--rig `
`--session `
`--include-archived`
`--host `
`--all-hosts`
`--hosts ` | -- `--nodes --json` — each seat carries the daemon's served `needsAttention` flag; the CLI doesn't re-derive it, and it is absent when an older daemon doesn't serve it. `arbitratedNeedsInput` and `needsInputEvidence` (`{ rung, observedAt }` or null) come with `--full`, or by name through `--fields`. `startedWithoutRequired` lists the required guidance files a handover's successor started without, and the compact read keeps it. +- `--nodes --json` — each seat carries the daemon's served `needsAttention` flag; the CLI doesn't re-derive it, and it is absent when an older daemon doesn't serve it. `arbitratedNeedsInput` and `needsInputEvidence` (`{ rung, observedAt }` or null) come with `--full`, or by name through `--fields`. `startedWithoutRequired` lists the required startup files a handover's successor started without, and the compact read keeps it. ### roster diff --git a/docs/reference/agent-state-taxonomy.md b/docs/reference/agent-state-taxonomy.md index f7bdfb2aa..3972a79d8 100644 --- a/docs/reference/agent-state-taxonomy.md +++ b/docs/reference/agent-state-taxonomy.md @@ -59,16 +59,20 @@ the existing live-hook fallback in effect; lifecycle, startup, held and error si can independently raise attention. A false attention flag means no known trigger, not proof that an unobserved input box is clear. -A handover whose successor started without a required file the guidance file should -hold also raises attention. That's a projected guidance file, or a guidance startup file -marked `required`. The seat serves `startedWithoutRequired` with each file's path, the +A handover whose successor started without a required file also raises attention. That's +a projected guidance file or a guidance startup file marked `required` still missing from +the guidance file, or, for a fresh or rebuild successor, a required `send_text` startup +file that's missing. The seat serves `startedWithoutRequired` with each file's path, the table's reason column names each file, and the handover's own output says it's degraded. The mark belongs to that successor's session, so the seat's next launch or handover clears it. Time alone never does. Repairing the file in place doesn't clear it either, because no route delivers guidance into a running conversation. -A required `send_text` startup file, such as an agent's role, isn't delivered to a -handover's successor and isn't checked. +A fresh or rebuild successor's first turn opens with the seat's `send_text` startup files, +the ones a fresh start delivers, as absolute paths with the role first. A pointer asks the +successor to read the file; it isn't the pasted text a start sends. A fork successor +carries its parent's conversation and gets no list. `send_text` startup actions, such as +an `after_ready` preload, aren't run for a successor. The compact human table's ATTN column renders `yes`, `no`, or `?` when the flag is unavailable. Compact JSON retains `needsAttention`; use `--full` or diff --git a/packages/daemon/src/domain/restore-guidance.ts b/packages/daemon/src/domain/restore-guidance.ts index 267713a81..c92124aa9 100644 --- a/packages/daemon/src/domain/restore-guidance.ts +++ b/packages/daemon/src/domain/restore-guidance.ts @@ -152,6 +152,42 @@ export function seatGuidance( return { targetPath, items: [...items.values()] }; } +/** A `send_text` startup file a fresh start delivers to a seat's new conversation. */ +export interface SeatStartupTextFile { + absolutePath: string; + required: boolean; + /** Whether the file is on disk now. */ + present: boolean; + /** The seat's own role. */ + role: boolean; +} + +/** #2456: the `send_text` startup files a fresh start delivers to a seat's new conversation, from its saved startup context: + * each file that applies on `fresh_start` and resolves to `send_text`, as at a start, the seat's role first. A handover's + * fresh or rebuild successor is a new conversation no start delivered to, so its first turn points to these. */ +export function seatStartupTextFiles( + startupCtx: NodeStartupSnapshot | null, + exists: (path: string) => boolean, + readFile: (path: string) => string, +): SeatStartupTextFile[] { + if (!startupCtx) return []; + const files = startupCtx.resolvedStartupFiles.flatMap((saved): SeatStartupTextFile[] => { + if (!saved.appliesOn.includes("fresh_start")) return []; + const f = reanchorBuiltinStartupFile(saved, undefined, undefined, exists); + const present = exists(f.absolutePath); + let hint: string = f.deliveryHint; + if (hint === "auto") { + // Classified from its content, as at a start; one that can't be read is classified from its path alone. + let content = ""; + try { if (present) content = readFile(f.absolutePath); } catch { /* as a start's own read */ } + hint = resolveConcreteHint(f.path, content, f.orientation); + } + if (hint !== "send_text") return []; + return [{ absolutePath: f.absolutePath, required: f.required, present, role: isPerSeatRoleStartupFile(f.path, f.orientation) }]; + }); + return [...files.filter((f) => f.role), ...files.filter((f) => !f.role)]; +} + /** The seat-skills line's own rule, as at a start: its block belongs in the guidance only with the person's yes, and never * through a linked file. Without it, the block is left out (so it's neither put back nor reported missing), and * `excluded` says why. */ diff --git a/packages/daemon/src/domain/seat-handover-service.ts b/packages/daemon/src/domain/seat-handover-service.ts index 051fd10b2..fed14bfe7 100644 --- a/packages/daemon/src/domain/seat-handover-service.ts +++ b/packages/daemon/src/domain/seat-handover-service.ts @@ -17,7 +17,7 @@ import { SeatHandoverPlanner, parseHandoverSource, SEAT_HANDOVER_SOURCE_CAPABILI import { discoverResumeToken } from "./agent-images/resume-token-discovery.js"; import { resolveRebuildArtifacts } from "./session-source-rebuild-resolver.js"; import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; -import { keptGuidanceSeatLine, lstatLinkInfo, putBackSeatGuidance, readNodeStartupSnapshot } from "./restore-guidance.js"; +import { keptGuidanceSeatLine, lstatLinkInfo, putBackSeatGuidance, readNodeStartupSnapshot, seatStartupTextFiles, type SeatStartupTextFile } from "./restore-guidance.js"; import { managedBlockRecords } from "./managed-block-records.js"; import { readFootprintAnswer } from "./footprint-choice.js"; import { SettingsStore } from "./user-settings/settings-store.js"; @@ -545,6 +545,11 @@ export class SeatHandoverService { // refreshed; one left as it is (edited, or possibly stale) is named here and in the successor's packet. const guidancePutBack = this.putBackGuidance(node.id, node.cwd, statusResult.status.rig_id); const guidanceNote = keptGuidanceSeatLine(guidancePutBack.notes); + // #2456: a fresh or rebuild successor is a new conversation no start delivered to, so its first turn points to the seat's + // send_text startup files, role first. A fork carries its parent's conversation; a discovered successor was prepared. + const startupFiles = parsed.source.mode === "fresh" || parsed.source.mode === "rebuild" ? this.startupTextFiles(node.id) : []; + const startedWithoutRequired = [...guidancePutBack.startedWithoutRequired, + ...startupFiles.filter((file) => file.required && !file.present).map((file) => file.absolutePath)]; 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`), @@ -609,6 +614,7 @@ export class SeatHandoverService { recapUnavailableReason: resolved ? undefined : (predecessorRecapResolution as { unavailableReason: string }).unavailableReason, ...(authoredRecapInfo ?? {}), ...(guidanceNote ? { guidanceNote } : {}), + startupFiles, }); if (!delivered.ok) { // Partial: the successor is live in the preserved pane but the context packet never landed — @@ -691,6 +697,7 @@ export class SeatHandoverService { gaps, emptyChainReason, ...(guidanceNote ? { guidanceNote } : {}), + startupFiles, }); if (!delivered.ok) { // Same partial-state contract as the fresh packet: unwind the candidate, @@ -727,11 +734,17 @@ export class SeatHandoverService { appliedLaunch: launch.appliedLaunch ?? null, sourceOutcome, launchAdditionNotes, - startedWithoutRequired: guidancePutBack.startedWithoutRequired, + startedWithoutRequired, cleanup: () => this.successorLauncher.cleanup(launch.tmuxSession, launch.discoveredId), }); } + /** #2456: the seat's send_text startup files a fresh start delivers, role first; none when the saved context can't be read. */ + private startupTextFiles(nodeId: string): SeatStartupTextFile[] { + try { return seatStartupTextFiles(readNodeStartupSnapshot(this.db, nodeId), existsSync, (file) => readFileSync(file, "utf-8")); } + catch { return []; } + } + /** #875: the seat's missing guidance blocks put back from its saved startup context, as an exact resume does. Returns * notes, and the required files the successor starts without (#2456). */ private putBackGuidance(nodeId: string, cwd: string | null | undefined, rigId: string): ReturnType { @@ -998,12 +1011,15 @@ export class SeatHandoverService { emptyChainReason?: string; /** #2456: the line naming managed guidance blocks left as they are. */ guidanceNote?: string; + startupFiles?: SeatStartupTextFile[]; }, ): Promise<{ ok: true } | { ok: false; message: string }> { const lines = [ `# Seat rebuild handover — ${info.seatRef}`, `You are a REBUILT successor for this seat (reason: ${info.reason}, at ${this.now().toISOString()}). Your predecessor session was ${info.departingSession}; its live context was deliberately NOT carried. Prime yourself from the durable artifacts below, highest trust first.`, ]; + const startup = startupFilesSection(info.startupFiles ?? []); + if (startup.length > 0) lines.push("", ...startup); if (info.primedArtifacts.length > 0) { lines.push("", "Priming artifacts (read each, in order):"); for (const artifact of info.primedArtifacts) lines.push(`- ${artifact.address} — ${artifact.label}`); @@ -1048,6 +1064,7 @@ export class SeatHandoverService { authoredRecap?: { address: string; chainLength: number }; authoredRecapAbsentReason?: string; guidanceNote?: string; + startupFiles?: SeatStartupTextFile[]; }, ): Promise { const packet = buildRestorePacket({ ...info, handoverAt: this.now().toISOString() }); @@ -1449,6 +1466,18 @@ export class SeatHandoverService { * respawn-pane owns native scrollback on codex seats. Never called "scrollback": the label must * make replay unmistakable, because passing a replayed recap off as native history is the one * thing the requirement cannot survive. Exported for unit test. */ +/** #2456: the first section of a fresh or rebuild successor's turn: the seat's startup files to read, by path, role first. + * A pointer asks the successor to read; it isn't the pasted text a start sends. */ +function startupFilesSection(files: SeatStartupTextFile[]): string[] { + if (files.length === 0) return []; + return [ + "--- Your seat's startup files: read each before anything else, in order ---", + ...files.map((file) => file.present + ? `- ${file.absolutePath}${file.role ? " (your role)" : ""}` + : `- MISSING: ${file.absolutePath}${file.required ? " (required; OpenRig couldn't find it, so you start without it)" : ""}`), + ]; +} + export function buildRestorePacket(info: { seatRef: string; reason: string; @@ -1470,8 +1499,11 @@ export function buildRestorePacket(info: { authoredRecapAbsentReason?: string; /** #2456: the line naming managed guidance blocks the handover left as they are (edited, or possibly stale). */ guidanceNote?: string; + /** #2456: the seat's send_text startup files, listed first so the successor reads them before the predecessor's context. */ + startupFiles?: SeatStartupTextFile[]; }): string { const captured = info.capturedContext.trim(); + const startup = startupFilesSection(info.startupFiles ?? []); const lines = [ "=== OpenRig seat handover — restore context ===", `Seat: ${info.seatRef}`, @@ -1479,6 +1511,7 @@ export function buildRestorePacket(info: { `Predecessor session: ${info.departingSession}`, `Handover at: ${info.handoverAt}`, "", + ...(startup.length > 0 ? [...startup, ""] : []), "--- Predecessor terminal (captured) ---", captured.length > 0 ? captured : "(no capture available)", ]; diff --git a/packages/daemon/test/seat-handover-service.test.ts b/packages/daemon/test/seat-handover-service.test.ts index 609744bbb..351019ce6 100644 --- a/packages/daemon/test/seat-handover-service.test.ts +++ b/packages/daemon/test/seat-handover-service.test.ts @@ -303,6 +303,48 @@ describe("SeatHandoverService", () => { } finally { fs.rmSync(root, { recursive: true, force: true }); } }); + // A fresh or rebuild successor is a new conversation that no start delivered to, so its one first turn points it to each + // send_text startup file a fresh start delivers, role first. A required one that's missing marks the seat. A fork carries + // its parent's conversation, which already had them, and gets no list. + it.each(["fresh", "rebuild", "fork:dev-impl@seat-rig"])("a %s successor's first turn points to the seat's startup files, role first (#2456)", async (source) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "handover-startup-files-")); + try { + const { node, sessionId } = seedSeat(); + sessionRegistry.updateResumeToken(sessionId!, "codex_id", "codex-thread-1", "hook"); + db.prepare("UPDATE nodes SET cwd = ? WHERE id = ?").run(root, node.id); + const role = path.join(root, "guidance", "role.md"); + const context = path.join(root, "startup", "context.md"); + const missing = path.join(root, "startup", "brief.txt"); + for (const [file, text] of [[role, "You are the implementer.\n"], [context, "Project context.\n"]] as const) { + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, text); + } + const text = (absolutePath: string, extra: Record = {}) => ({ path: path.relative(root, absolutePath), absolutePath, ownerRoot: root, + deliveryHint: "send_text", required: true, appliesOn: ["fresh_start", "restore"], ...extra }); + // Saved in the order a spec might list them; the role comes first in the list all the same. + db.prepare("INSERT INTO node_startup_context (node_id, projection_entries_json, resolved_files_json, startup_actions_json, runtime) VALUES (?, '[]', ?, '[]', 'codex')") + .run(node.id, JSON.stringify([text(context), text(role, { orientation: "role" }), text(missing)])); + const result = await service.handover({ seatRef: "dev-impl@seat-rig", reason: "replace occupant", source }); + expect(result, JSON.stringify(result)).toMatchObject({ ok: true }); + const turns = sendText.mock.calls.map((call) => String(call[1])); + const listed = turns.find((turn) => turn.includes("Your seat's startup files")); + const handed = (result as unknown as { result: { startedWithoutRequired?: string[] } }).result; + if (source.startsWith("fork:")) { + expect(listed).toBeUndefined(); + expect(handed.startedWithoutRequired).toBeUndefined(); + return; + } + expect(listed, JSON.stringify(turns)).toBeDefined(); + const at = (needle: string) => listed!.indexOf(needle); + expect(at(role)).toBeGreaterThan(at("Your seat's startup files")); + expect(at(role)).toBeLessThan(at(context)); + expect(listed).toContain(`MISSING: ${missing}`); + // The list comes before the predecessor's context: its terminal for a fresh successor, its durable chain for a rebuild. + expect(at("Your seat's startup files")).toBeLessThan(at(source === "fresh" ? "--- Predecessor terminal" : "The durable chain is EMPTY")); + expect(handed.startedWithoutRequired).toEqual([missing]); + } finally { fs.rmSync(root, { recursive: true, force: true }); } + }); + // #2456 part 3: a block already in the guidance file. OpenRig keeps a record of what it last wrote there, so a handover // refreshes a block it wrote whose source has changed, keeps a block a person edited and names it, and names a block it // has no record of as possibly stale instead of replacing it.