fix(pi): a Pi seat's managed guidance goes to its agent folder, never the tracked AGENTS.md (#2432)

A Pi seat's managed guidance now goes to its own agent folder (`PI_CODING_AGENT_DIR`'s `AGENTS.md`), so a person's tracked repository `AGENTS.md` stays byte-identical across launch, `rig down`, restore and exact resume. A fresh launch asks the adapter once and records the answer in a new nullable `node_startup_context.guidance_file` (migration 146); restore, the exact-resume put-back, teardown, inventory and snapshots all read that one target through `seatGuidanceFile`. Rows and snapshots recorded before this keep the repository file. An exact restore of an older snapshot records that snapshot's choice, and a symlink or hard link introduced after launch is reported as a gap rather than written through. Codex, OMP and Claude are unchanged; this does not stop Codex's tracked-file writes, and #875 stays open.
This commit is contained in:
Mike Schwarz authored and GitHub committed 2026-11-02 23:19:22 -08:00
1 parent 7b858418f3
commit 9d2d0d47d8
18 files changed
+331 -29

No files matched your search

@@ -13,4 +13,9 @@ export class OmpRuntimeAdapter extends PiRuntimeAdapter {
if (!tmuxSession) return null;
return nodePath.join(piSeatPaths(this.stateRoot, tmuxSession).agentDir, "skills", effectiveId, "SKILL.md");
}
/** #875: OMP keeps its guidance in the repository AGENTS.md. Pi's agent-folder loading was shown natively; OMP's wasn't. */
override guidanceFileFor(): { file: string | null; note?: string } {
return { file: null };
}
}
@@ -23,6 +23,7 @@ import { resolveConcreteHint } from "../domain/runtime-adapter.js";
import type { ProjectionPlan, ProjectionEntry } from "../domain/projection-planner.js";
import { validateResumeToken } from "../domain/resume-token-validation.js";
import { isPerSeatRoleGuidance, mergeManagedBlock, perSeatRoleSkipWarning } from "../domain/managed-blocks.js";
import { guidanceFileSharedByLink } from "../domain/restore-guidance.js";
import { observePiResourceTrust, observeOmpApprovalMode } from "../domain/permission-drift.js";
import {
piSeatPaths, parsePiRunnerState, buildPiRunnerCommand, buildPendingRunnerState,
@@ -40,6 +41,8 @@ export interface PiAdapterFsOps {
listFiles?(dirPath: string): string[];
statMode?(path: string): number;
chmod?(path: string, mode: number): void;
/** Whether a path is a symlink and its hard-link count, or null when it doesn't exist (an lstat). */
linkInfo?(path: string): { link: boolean; nlink: number } | null;
}
export interface PiRuntimeAdapterDeps {
@@ -182,9 +185,10 @@ export class PiRuntimeAdapter implements RuntimeAdapter {
switch (hint) {
case "guidance_merge": {
// ADDITIVE context-file merge (Omnigent best-practice: never
// replace Pi's default system prompt). Pi reads AGENTS.md from
// the managed cwd as a project context file.
const targetPath = nodePath.join(binding.cwd, "AGENTS.md");
// replace Pi's default system prompt). Pi reads AGENTS.md from its
// agent folder and from the managed cwd as context files.
const targetPath = this.guidanceTarget(binding, warnings);
if (!targetPath) continue; // not written through a link: not counted as delivered
const merged = this.mergeGuidance(targetPath, file.path, content, warnings);
if (!merged) continue; // rig-role skip: do not count as delivered
break;
@@ -440,9 +444,32 @@ export class PiRuntimeAdapter implements RuntimeAdapter {
};
}
/** #875: a Pi seat's managed guidance goes into its own agent folder's AGENTS.md, which Pi loads before the repository's
* (PI_CODING_AGENT_DIR is that folder), so the person's tracked repository AGENTS.md is never written. The repository
* file stays when there's no session yet, or when the folder or its AGENTS.md is a link: through a link, other sessions
* (a person's own Pi home, a shared file) would read this rig's guidance. */
guidanceFileFor(binding: NodeBinding): { file: string | null; note?: string } {
if (!binding.tmuxSession) return { file: null };
const file = nodePath.join(piSeatPaths(this.stateRoot, binding.tmuxSession).agentDir, "AGENTS.md");
const linked = guidanceFileSharedByLink(file, (path) => this.fs.linkInfo?.(path) ?? null);
return linked ? { file: null, note: `${this.runtime}: ${linked} is a link, so this seat's managed guidance stays in its repository AGENTS.md` } : { file };
}
/** The file this launch merges guidance into: the one its startup chose (guidanceFileFor, or a restore's saved choice),
* else the repository AGENTS.md in the managed cwd. Null when a chosen file has since become a link (#2432): nothing is
* written through it, and nothing falls back to the repository file. */
private guidanceTarget(binding: NodeBinding, warnings: string[]): string | null {
if (!binding.guidanceFile) return nodePath.join(binding.cwd, "AGENTS.md");
const linked = guidanceFileSharedByLink(binding.guidanceFile, (path) => this.fs.linkInfo?.(path) ?? null);
if (!linked) return binding.guidanceFile;
warnings.push(`${this.runtime}: ${linked} is now a link, so this seat's managed guidance wasn't written to ${binding.guidanceFile}, which other sessions could read`);
return null;
}
private projectEntry(entry: ProjectionEntry, binding: NodeBinding, warnings: string[]): boolean {
if (entry.category === "guidance" && entry.mergeStrategy === "managed_block") {
const targetPath = nodePath.join(binding.cwd, "AGENTS.md");
const targetPath = this.guidanceTarget(binding, warnings);
if (!targetPath) return false;
const content = this.fs.readFile(entry.absolutePath);
return this.mergeGuidance(targetPath, entry.effectiveId, content, warnings, entry.absolutePath);
}
+2
View File
@@ -1,6 +1,7 @@
import { activityGrantsSchema } from "./migrations/143_activity_grants.js";
import { environmentBindingsSchema } from "./migrations/144_environment_bindings.js";
import { nodePermissionsToSchema } from "./migrations/145_node_permissions_to.js";
import { nodeStartupGuidanceFileSchema } from "./migrations/146_node_startup_guidance_file.js";
import { nodeAccountHomeSchema } from "./migrations/141_node_account_home.js";
import { escalationTargetRefsSchema } from "./migrations/126_escalation_target_refs.js";
import { claudeTokenReadingsSchema } from "./migrations/134_claude_token_readings.js";
@@ -293,4 +294,5 @@ export const ALL_MIGRATIONS: Migration[] = [
activityGrantsSchema,
environmentBindingsSchema,
nodePermissionsToSchema,
nodeStartupGuidanceFileSchema,
];
@@ -0,0 +1,9 @@
import type { Migration } from "../migrate.js";
/** The file a seat's launch chose for its managed guidance blocks, when that isn't the rule's file in its working directory
* (a Pi seat's own agent folder AGENTS.md, #875). Null (the default, and every row written before this) means the rule's
* file, so restore, teardown and inventory keep reading exactly what they read before. */
export const nodeStartupGuidanceFileSchema: Migration = {
name: "146_node_startup_guidance_file.sql",
sql: "ALTER TABLE node_startup_context ADD COLUMN guidance_file TEXT;",
};
+2 -1
View File
@@ -994,7 +994,8 @@ export function getNodeDetail(
// The seat's guidance file as its runtime resolves it now, and what it holds, read once for the guidance entries.
const guidanceEvidence = (): { file: string | null; content: string | null } => {
const runtime = (ctxRow as { runtime?: string | null } | undefined)?.runtime ?? entry.runtime;
const file = entry.cwd ? seatGuidanceFile(runtime, entry.cwd, new RigRepository(db).getRigClaudeManagedBlockFile(rigId)) : null;
const recorded = (ctxRow as { guidance_file?: string | null } | undefined)?.guidance_file;
const file = entry.cwd ? seatGuidanceFile(runtime, entry.cwd, new RigRepository(db).getRigClaudeManagedBlockFile(rigId), recorded) : null;
let content: string | null = null;
if (file) {
try { content = fs.existsSync(file) ? fs.readFileSync(file, "utf8") : null; } catch { content = null; }
+37 -4
View File
@@ -13,6 +13,8 @@
// Guidance problems never block the exact resume. What can't be put back is reported.
import nodePath from "node:path";
import { lstatSync } from "node:fs";
import type Database from "better-sqlite3";
import {
MANAGED_BLOCK_START,
MANAGED_BLOCK_END,
@@ -46,20 +48,51 @@ export interface SeatGuidance {
items: GuidanceItem[];
}
/** The file a seat's runtime merges its managed guidance blocks into, whatever a spec's `target` declares: the rig's
* Claude file for Claude, `AGENTS.md` for Codex, Pi and omp, or null for a runtime this doesn't know. Teardown, its
* live-file protection, exact resume and node inventory all read the target from here. */
/** The file a seat's runtime merges its managed guidance blocks into, whatever a spec's `target` declares: the file its
* launch recorded (`recorded`, a Pi seat's agent folder AGENTS.md, #875), else the rig's Claude file for Claude,
* `AGENTS.md` for Codex, Pi and omp, or null for a runtime this doesn't know. Teardown, its live-file protection, exact
* resume and node inventory all read the target from here. */
export function seatGuidanceFile(
runtime: string | null | undefined,
cwd: string,
claudeManagedBlockFile: string | null | undefined,
recorded?: string | null,
): string | null {
if (recorded) return recorded;
const fileName = runtime === "claude-code"
? (claudeManagedBlockFile ?? DEFAULT_CLAUDE_MANAGED_BLOCK_FILE)
: runtime === "codex" || runtime === "pi" || runtime === "omp" ? "AGENTS.md" : null;
return fileName ? nodePath.join(cwd, fileName) : null;
}
export type LinkInfo = (path: string) => { link: boolean; nlink: number } | null;
/** An lstat of a path: whether it's a symlink and its hard-link count, or null when it doesn't exist. */
export const lstatLinkInfo: LinkInfo = (path) => {
try { const st = lstatSync(path); return { link: st.isSymbolicLink(), nlink: st.nlink }; } catch { return null; }
};
/** #875: a seat's own guidance file (a Pi seat's `<seat>/agent/AGENTS.md`) is its alone only while neither the file, its
* folder nor that folder's parent is a symlink and the file has one name. Otherwise other sessions (a person's own Pi
* home, a shared file) could read what's written there. Returns the linked path, or null. Checked when a launch chooses
* the file and again wherever a recorded file is written, since a link can be made after the launch recorded it. */
export function guidanceFileSharedByLink(file: string, linkInfo: LinkInfo): string | null {
for (const path of [nodePath.dirname(nodePath.dirname(file)), nodePath.dirname(file), file]) {
const info = linkInfo(path);
if (info && (info.link || (path === file && info.nlink > 1))) return path;
}
return null;
}
/** The guidance file a node's last launch recorded (#875), or null: none recorded, or a database from before the column. */
export function recordedGuidanceFile(db: Pick<Database.Database, "prepare">, nodeId: string | null | undefined): string | null {
if (!nodeId) return null;
try {
const row = db.prepare("SELECT guidance_file FROM node_startup_context WHERE node_id = ?").get(nodeId) as { guidance_file: string | null } | undefined;
return row?.guidance_file || null;
} catch { return null; }
}
/** Whether guidance text holds this id's block as it is written: a BEGIN marker followed later by its END, in the
* current or legacy form. For reporting an installed file; restore's own `blockState` is unchanged. */
export function hasCompleteGuidanceBlock(content: string, blockId: string): boolean {
@@ -80,7 +113,7 @@ export function seatGuidance(
readFile: (path: string) => string,
): SeatGuidance | null {
if (!startupCtx || !cwd) return null;
const targetPath = seatGuidanceFile(startupCtx.runtime, cwd, claudeManagedBlockFile);
const targetPath = seatGuidanceFile(startupCtx.runtime, cwd, claudeManagedBlockFile, startupCtx.guidanceFile);
if (!targetPath) return null;
const items = new Map<string, GuidanceItem>();
for (const saved of startupCtx.projectionEntries) {
@@ -33,7 +33,7 @@ import { resumeProbeEvidence, type NativeResumeProbeEvidence } from "./native-re
import { observeClaudeDelivery, observeClaudeResumeLaunch, processArgv, verifyClaudePaneProcess, verifyClaudePaneRuntime, verifyPaneIdleShell, type ClaudeLaunchedProcess } from "./native-process-lineage.js";
import { readNativeExecutablePaths } from "./native-process-executable.js";
import { reanchorBuiltinStartupFile, reanchorShippedProjectionEntry } from "./builtin-startup-files.js";
import { applySeatSkillsLineRule, seatGuidance, restoreMissingGuidance, missingGuidanceBlocks } from "./restore-guidance.js";
import { applySeatSkillsLineRule, seatGuidance, restoreMissingGuidance, missingGuidanceBlocks, guidanceFileSharedByLink, lstatLinkInfo } from "./restore-guidance.js";
import type { DesiredClaudeActivityHooks } from "./desired-claude-activity-hooks.js";
import { readFootprintAnswer } from "./footprint-choice.js";
import { SettingsStore } from "./user-settings/settings-store.js";
@@ -1497,6 +1497,12 @@ export class RestoreOrchestrator {
try { return readFootprintAnswer(this.routerLineSettings ?? new SettingsStore()).state === "yes"; } catch { return false; }
}
/** The recorded guidance file follows the snapshot a seat was exactly resumed from (#2432); nothing else in its saved
* startup selection changes. A database from before the column has nothing to record. */
private recordRestoredGuidanceFile(nodeId: string, file: string | null): void {
try { this.db.prepare("UPDATE node_startup_context SET guidance_file = ? WHERE node_id = ?").run(file, nodeId); } catch { /* no column */ }
}
private checkRestoredGuidance(
result: RestoreNodeResult,
node: SnapshotData["nodes"][number],
@@ -1780,6 +1786,16 @@ export class RestoreOrchestrator {
guidance = rule.guidance;
if (rule.excluded === "linked") warnings?.push(`Restore guidance: ${node.logicalId}: the seat-skills line wasn't put back, because ${guidance.targetPath} is a link.`);
}
// #2432: the record names the file this resumed seat uses, the snapshot's choice, which can be older than the
// database's: an older snapshot restored exactly after a newer launch resumes on its repository file.
if (startupCtx) this.recordRestoredGuidanceFile(node.id, startupCtx.guidanceFile ?? null);
// #2432: a recorded file that became a link after its launch gets nothing put back through it; the check after the
// launch reports the blocks it lacks.
const linked = startupCtx?.guidanceFile && guidance ? guidanceFileSharedByLink(guidance.targetPath, lstatLinkInfo) : null;
if (linked && guidance) {
warnings?.push(`Restore guidance: ${node.logicalId}: ${linked} is now a link, so nothing was put back in ${guidance.targetPath}, which other sessions could read.`);
guidance = null;
}
if (guidance && guidance.items.length > 0) {
const repair = restoreMissingGuidance(guidance, {
exists: existsSync,
@@ -2036,6 +2052,8 @@ export class RestoreOrchestrator {
const binding = {
...launchResult.binding,
// #875: the guidance file this seat's launch recorded; an older snapshot recorded none, so its file stays the rule's.
guidanceFile: startupCtx.guidanceFile ?? null,
...(data.environmentBindingsByNode?.[node.id] ? { environmentRestore: data.environmentBindingsByNode[node.id] } : {}),
...(opts?.claudeExecutable ? { claudeExecutable: opts.claudeExecutable } : {}),
cwd: launchCwd,
+21 -15
View File
@@ -9,7 +9,7 @@ import type { ResumeMetadataRefresher } from "./resume-metadata-refresher.js";
import fs from "node:fs";
import nodePath from "node:path";
import { removeManagedBlocksFromFile } from "./managed-blocks.js";
import { seatGuidanceFile } from "./restore-guidance.js";
import { recordedGuidanceFile, seatGuidanceFile } from "./restore-guidance.js";
import { cleanupOwnedClaudeCwd } from "./claude-owned-settings.js";
import { stopTranscriptRotation } from "./transcript-rotation.js";
import { findOtherSessionOwner } from "./session-owner.js";
@@ -227,7 +227,7 @@ export class RigTeardownOrchestrator {
if (!ownedByAnotherRig) stopTranscriptRotation(session.sessionName);
// Success or already gone — update DB atomically
this.atomicNodeCleanup(session);
this.cleanupManagedGuidanceFileForNode(rigId, session.runtime, session.cwd, currentLiveGuidanceTargets(), result);
this.cleanupManagedGuidanceFileForNode(rigId, session.runtime, session.cwd, currentLiveGuidanceTargets(), result, session.nodeId);
if (killResult.ok) result.sessionsKilled++;
if (killResult.ok && communityHarnessOf(session.communityHarness, session.runtime)) (result.paneOnly ??= []).push(session.sessionName);
} else {
@@ -398,19 +398,19 @@ export class RigTeardownOrchestrator {
private liveGuidanceTargets(rigId: string): Set<string> {
const rows = this.db.prepare(`
SELECT n.rig_id, n.runtime, n.cwd FROM nodes n
SELECT n.id, n.rig_id, n.runtime, n.cwd FROM nodes n
JOIN rigs r ON r.id = n.rig_id
WHERE r.archived_at IS NULL AND r.id <> ? AND n.cwd IS NOT NULL
`).all(rigId) as Array<{ rig_id: string; runtime: string | null; cwd: string }>;
`).all(rigId) as Array<{ id: string; rig_id: string; runtime: string | null; cwd: string }>;
const targets = new Set<string>();
for (const row of rows) {
const target = this.guidanceTargetPath(row.rig_id, row.runtime, row.cwd);
const target = this.guidanceTargetPath(row.rig_id, row.runtime, row.cwd, row.id);
if (target) targets.add(this.guidancePathKey(target));
}
// A failed kill or inconclusive probe leaves this rig's session live.
// Its file may also be shared with a sibling that stopped successfully.
for (const session of this.getLatestLiveSessions(rigId)) {
const target = this.guidanceTargetPath(rigId, session.runtime, session.cwd);
const target = this.guidanceTargetPath(rigId, session.runtime, session.cwd, session.nodeId);
if (target) targets.add(this.guidancePathKey(target));
}
return targets;
@@ -418,12 +418,12 @@ export class RigTeardownOrchestrator {
private cleanupManagedGuidanceFiles(rigId: string, liveGuidanceTargets: ReadonlySet<string>, result: TeardownResult): void {
const rows = this.db.prepare(`
SELECT DISTINCT runtime, cwd
SELECT id, runtime, cwd
FROM nodes
WHERE rig_id = ?
`).all(rigId) as Array<{ runtime: string | null; cwd: string | null }>;
`).all(rigId) as Array<{ id: string; runtime: string | null; cwd: string | null }>;
for (const row of rows) {
this.cleanupManagedGuidanceFileForNode(rigId, row.runtime, row.cwd, liveGuidanceTargets, result);
this.cleanupManagedGuidanceFileForNode(rigId, row.runtime, row.cwd, liveGuidanceTargets, result, row.id);
}
}
@@ -474,12 +474,13 @@ export class RigTeardownOrchestrator {
return keys;
}
private guidanceTargetPath(rigId: string, runtime: string | null, cwd: string | null): string | null {
/** The file a seat reads its managed guidance from: the one its last launch recorded (#875), else the rule's. */
private guidanceTargetPath(rigId: string, runtime: string | null, cwd: string | null, nodeId?: string | null): string | null {
if (!runtime || !cwd) {
return null;
}
// #25: clean only the rig's selected Claude file; the other file is never touched.
return seatGuidanceFile(runtime, cwd, this.deps.rigRepo.getRigClaudeManagedBlockFile(rigId));
return seatGuidanceFile(runtime, cwd, this.deps.rigRepo.getRigClaudeManagedBlockFile(rigId), recordedGuidanceFile(this.db, nodeId));
}
private guidancePathKey(path: string): string {
@@ -497,11 +498,16 @@ export class RigTeardownOrchestrator {
}
}
private cleanupManagedGuidanceFileForNode(rigId: string, runtime: string | null, cwd: string | null, liveGuidanceTargets: ReadonlySet<string>, result: TeardownResult): void {
const targetPath = this.guidanceTargetPath(rigId, runtime, cwd);
if (!targetPath) {
return;
/** Takes OpenRig's unedited blocks out of the file the seat's launch recorded and out of the rule's file, where an
* older launch of the same seat wrote them (#875): a recorded file never leaves blocks behind in the repository. */
private cleanupManagedGuidanceFileForNode(rigId: string, runtime: string | null, cwd: string | null, liveGuidanceTargets: ReadonlySet<string>, result: TeardownResult, nodeId?: string | null): void {
const targets = new Set([this.guidanceTargetPath(rigId, runtime, cwd, nodeId), this.guidanceTargetPath(rigId, runtime, cwd)]);
for (const targetPath of targets) {
if (targetPath) this.cleanupManagedGuidanceFile(targetPath, liveGuidanceTargets, result);
}
}
private cleanupManagedGuidanceFile(targetPath: string, liveGuidanceTargets: ReadonlySet<string>, result: TeardownResult): void {
if (liveGuidanceTargets.has(this.guidancePathKey(targetPath))) return;
// The seat-skills line's block is removed only while it still holds the line; a person's edit is kept and reported.
const { keptEdited } = removeManagedBlocksFromFile({
@@ -17,6 +17,10 @@ export interface NodeBinding extends Binding {
/** Claude-only, session-scoped Advisor model. */
advisor?: string;
codexConfigProfile?: string;
/** #875: the file this launch merges its managed guidance blocks into, chosen once at a fresh launch
* (`guidanceFileFor`) or carried from a restore's saved startup context. Null means the rule's file in `cwd`;
* undefined means not chosen yet. */
guidanceFile?: string | null;
/** A staged account-home selection; null explicitly returns to configured defaults before commit. */
accountHome?: string | null;
accountToken?: import("./claude-seat-token.js").ClaudeSeatTokenRef | null;
@@ -234,6 +238,9 @@ export interface RuntimeAdapter {
* the Claude project tree, for "already in place" detection. Absent = the
* instantiator's default target. */
skillTargetPath?(tmuxSession: string | null, effectiveId: string): string | null;
/** #875: where this seat's managed guidance should go, decided at a fresh launch and recorded with its startup context.
* Absent, or a null file, keeps the rule's file in the working directory; a note says why the seat's own file wasn't used. */
guidanceFileFor?(binding: NodeBinding): { file: string | null; note?: string };
/** List currently installed/projected resources for a node. */
listInstalled(binding: NodeBinding): Promise<InstalledResource[]>;
@@ -107,12 +107,13 @@ export class SnapshotCapture {
const nodeStartupContext: Record<string, import("./types.js").NodeStartupSnapshot | null> = {};
for (const node of rig.nodes) {
const ctx = this.db.prepare("SELECT * FROM node_startup_context WHERE node_id = ?")
.get(node.id) as { projection_entries_json: string; resolved_files_json: string; startup_actions_json: string; runtime: string } | undefined;
.get(node.id) as { projection_entries_json: string; resolved_files_json: string; startup_actions_json: string; runtime: string; guidance_file?: string | null } | undefined;
nodeStartupContext[node.id] = ctx ? {
projectionEntries: JSON.parse(ctx.projection_entries_json),
resolvedStartupFiles: JSON.parse(ctx.resolved_files_json),
startupActions: JSON.parse(ctx.startup_actions_json),
runtime: ctx.runtime,
...(ctx.guidance_file ? { guidanceFile: ctx.guidance_file } : {}),
} : null;
}
@@ -409,6 +409,13 @@ export class StartupOrchestrator {
? { parentToken: input.forkSource.value.trim(), isRestore: input.isRestore } : undefined;
const launchGeneration = this.sessionRegistry.currentOccupantTenure(input.nodeId)?.generationUuid;
if (input.adapter.environment) input = { ...input, binding: { ...input.binding, launchGeneration: input.binding.launchGeneration ?? launchGeneration } };
// #875: a fresh launch decides once where its managed guidance goes; a restore carries its saved choice (null for an
// older snapshot), so the adapter isn't asked again and an existing seat isn't silently repointed.
if (input.binding.guidanceFile === undefined && input.adapter.guidanceFileFor) {
const chosen = input.adapter.guidanceFileFor(input.binding);
if (chosen.note) warnings.push(chosen.note);
input = { ...input, binding: { ...input.binding, guidanceFile: chosen.file } };
}
// 1. Mark pending
this.sessionRegistry.updateStartupStatus(input.sessionId, "pending");
@@ -565,6 +572,10 @@ export class StartupOrchestrator {
JSON.stringify(input.startupActions),
input.adapter.runtime,
);
// The chosen guidance file rides with the context it was chosen for; the row above leaves it null otherwise.
if (input.binding.guidanceFile) {
this.db.prepare("UPDATE node_startup_context SET guidance_file = ? WHERE node_id = ?").run(input.binding.guidanceFile, input.nodeId);
}
} catch (error) {
return this.fail(input, "failed", [`Startup context persistence failed: ${String(error)}`], undefined, false, undefined, false, "persistence", true);
}
+2
View File
@@ -389,6 +389,8 @@ export interface NodeStartupSnapshot {
resolvedStartupFiles: import("./runtime-adapter.js").ResolvedStartupFile[];
startupActions: StartupAction[];
runtime: string;
/** #875: the guidance file this seat's launch chose and recorded. Absent (every older snapshot) means the rule's file. */
guidanceFile?: string;
}
export interface SnapshotData {
+2 -1
View File
@@ -204,6 +204,7 @@ import { withCommunityHarnessResolver, withCommunityHarnesses } from "./adapters
import { ensureActivityHookToken, writeActivityEndpointFile, deriveActivityUrl, readActivityEndpointFile } from "./domain/activity-endpoint.js";
import { recoverSeatSkillAttempts } from "./domain/seat-skill-apply.js";
import { restoreExistingRigUnattended } from "./domain/existing-rig-restore.js";
import { lstatLinkInfo } from "./domain/restore-guidance.js";
import {
getCompatibleOpenRigPath,
getDefaultOpenRigPath,
@@ -972,7 +973,7 @@ export async function createDaemon(opts?: DaemonOptions): Promise<DaemonResult>
} });
// OPR.0.4.6.PI1 — the RPC-first Pi adapter (runner-in-a-pane). Same fsOps
// shape as the Codex adapter; seat isolation roots under piStateRoot.
const piAdapter = new PiRuntimeAdapter({ tmux: tmuxAdapter, seatLaunchEnvironment, fsOps: { readFile: (p: string) => fs.readFileSync(p, "utf-8"), writeFile: (p: string, c: string) => fs.writeFileSync(p, c, "utf-8"), exists: (p: string) => fs.existsSync(p), mkdirp: (p: string) => fs.mkdirSync(p, { recursive: true }), listFiles: (dir: string) => { const r: string[] = []; /** Collect relative file paths recursively. */ function w(d: string, pre: string) { for (const e of fs.readdirSync(d, { withFileTypes: true })) { if (e.isDirectory()) w(nodePath.join(d, e.name), nodePath.join(pre, e.name)); else r.push(pre ? nodePath.join(pre, e.name) : e.name); } } w(dir, ""); return r; }, statMode: (p: string) => fs.statSync(p).mode, chmod: (p: string, m: number) => fs.chmodSync(p, m) }, stateRoot: piStateRoot, runnerEntryPath: piRunnerEntryPath, usageFactsEnabled: piUsageFactsEnabled, openRouterChargeEnabled: piOpenRouterChargeEnabled });
const piAdapter = new PiRuntimeAdapter({ tmux: tmuxAdapter, seatLaunchEnvironment, fsOps: { readFile: (p: string) => fs.readFileSync(p, "utf-8"), writeFile: (p: string, c: string) => fs.writeFileSync(p, c, "utf-8"), exists: (p: string) => fs.existsSync(p), mkdirp: (p: string) => fs.mkdirSync(p, { recursive: true }), listFiles: (dir: string) => { const r: string[] = []; /** Collect relative file paths recursively. */ function w(d: string, pre: string) { for (const e of fs.readdirSync(d, { withFileTypes: true })) { if (e.isDirectory()) w(nodePath.join(d, e.name), nodePath.join(pre, e.name)); else r.push(pre ? nodePath.join(pre, e.name) : e.name); } } w(dir, ""); return r; }, statMode: (p: string) => fs.statSync(p).mode, chmod: (p: string, m: number) => fs.chmodSync(p, m), linkInfo: lstatLinkInfo }, stateRoot: piStateRoot, runnerEntryPath: piRunnerEntryPath, usageFactsEnabled: piUsageFactsEnabled, openRouterChargeEnabled: piOpenRouterChargeEnabled });
const ompAdapter = new OmpRuntimeAdapter({ tmux: tmuxAdapter, fsOps: { readFile: (p: string) => fs.readFileSync(p, "utf-8"), writeFile: (p: string, c: string) => fs.writeFileSync(p, c, "utf-8"), exists: (p: string) => fs.existsSync(p), mkdirp: (p: string) => fs.mkdirSync(p, { recursive: true }), listFiles: (dir: string) => { const r: string[] = []; /** Collect relative file paths recursively. */ function w(d: string, pre: string) { for (const e of fs.readdirSync(d, { withFileTypes: true })) { if (e.isDirectory()) w(nodePath.join(d, e.name), nodePath.join(pre, e.name)); else r.push(pre ? nodePath.join(pre, e.name) : e.name); } } w(dir, ""); return r; } }, stateRoot: ompStateRoot, runnerEntryPath: piRunnerEntryPath });
// OPR.0.5.1.1 — the stub runtime adapter (Pi-shaped node-script runner in a pane).
// Same fsOps shape as Pi; the compiled runner entry lives in the daemon dist.
+2 -1
View File
@@ -1,6 +1,7 @@
import { activityGrantsSchema } from "../../src/db/migrations/143_activity_grants.js";
import { environmentBindingsSchema } from "../../src/db/migrations/144_environment_bindings.js";
import { nodePermissionsToSchema } from "../../src/db/migrations/145_node_permissions_to.js";
import { nodeStartupGuidanceFileSchema } from "../../src/db/migrations/146_node_startup_guidance_file.js";
import { nodeAccountHomeSchema } from "../../src/db/migrations/141_node_account_home.js";
import { escalationTargetRefsSchema } from "../../src/db/migrations/126_escalation_target_refs.js";
import { nodeClaudePermissionsSchema } from "../../src/db/migrations/139_node_claude_permissions.js";
@@ -156,7 +157,7 @@ import fs from "node:fs";
/** Seam B R6: the canonical full-fixture migration list, exported so file-backed
* DB-reopen tests migrate IDENTICALLY to createFullTestDb. */
export const migrationsForFullTestDb = [coreSchema, bindingsSessionsSchema, eventsSchema, snapshotsSchema, checkpointsSchema, resumeMetadataSchema, nodeSpecFieldsSchema, packagesSchema, installJournalSchema, journalSeqSchema, bootstrapSchema, discoverySchema, discoveryFkFix, agentspecRebootSchema, startupContextSchema, chatMessagesSchema, podNamespaceSchema, contextUsageSchema, externalCliAttachmentSchema, rigServicesSchema, seatHandoverObservabilitySchema, nodeCodexConfigProfileSchema, nodePermissionPolicySchema, rigPermissionPolicySchema, nodePolicyProvenanceSchema, rigPolicyProvenanceSchema, streamItemsSchema, queueItemsSchema, queueTransitionsSchema, rigPolicySchema, rigArchiveSchema, resumeProvenanceSchema, resumeVerificationSchema, seatIdentityVerdictsSchema, selfHostIdentitySchema, occupantTenuresSchema, daemonLifecycleSchema, watchdogJobsSchema, occupantGenerationStampsSchema, projectionManifestSchema, watchdogTargetGenerationSchema, appliedLaunchObservationsSchema, appliedLaunchObservationInvalidationsSchema, threadSeatMapSchema, queueTransitionWakesSchema, nodeSessionSourceSchema, scopedOperatingPostureSchema, humanNotificationIntentSchema, reviewReadIndexesSchema, inventoryEventIndexesSchema, rigClaudeManagedBlockFileSchema, nodePermissionSelectionsSchema, humanReplyToSchema, humanQuestionsSchema, nodeEffortSchema, rigNonInterruptiveSchema, rigInstallRootSchema, threadPartMapSchema, queueTransitionsQitemIdOrderSchema, queueItemsRecoveryTagsSchema, resumeRotationSchema, sessionStopIntentSchema, nodeEffortChoiceSchema, modePostureCoexistSchema, seatEnvSchema, appliedLaunchDaemonOptOutSchema, sessionCommunityHarnessSchema, threadSeatMapChannelKeySchema, queueItemWakeInputBoxSchema, providerAccountReadingsSchema, humanQuietWindowsSchema, seatIdentityPanePidSchema, seatIdentityProcessEvidenceSchema, rigStartupLayersSchema, humanOperatingModesSchema, queueDelegatedDecisionSchema, nodeAdvisorSchema, operationCorrelationSchema, terminalPolicySchema, seatRuntimeSwitchesSchema, claudeTokenReadingsSchema, nodeLaunchRecipeSchema, nodeClaudeTokenRefSchema, nodeClaudePermissionsSchema, nodeCodexPermissionsSchema, escalationTargetRefsSchema, sessionAgentRunnerSchema, nodeAccountHomeSchema, nodeClaudeMcpSchema, activityGrantsSchema, environmentBindingsSchema, nodePermissionsToSchema];
export const migrationsForFullTestDb = [coreSchema, bindingsSessionsSchema, eventsSchema, snapshotsSchema, checkpointsSchema, resumeMetadataSchema, nodeSpecFieldsSchema, packagesSchema, installJournalSchema, journalSeqSchema, bootstrapSchema, discoverySchema, discoveryFkFix, agentspecRebootSchema, startupContextSchema, chatMessagesSchema, podNamespaceSchema, contextUsageSchema, externalCliAttachmentSchema, rigServicesSchema, seatHandoverObservabilitySchema, nodeCodexConfigProfileSchema, nodePermissionPolicySchema, rigPermissionPolicySchema, nodePolicyProvenanceSchema, rigPolicyProvenanceSchema, streamItemsSchema, queueItemsSchema, queueTransitionsSchema, rigPolicySchema, rigArchiveSchema, resumeProvenanceSchema, resumeVerificationSchema, seatIdentityVerdictsSchema, selfHostIdentitySchema, occupantTenuresSchema, daemonLifecycleSchema, watchdogJobsSchema, occupantGenerationStampsSchema, projectionManifestSchema, watchdogTargetGenerationSchema, appliedLaunchObservationsSchema, appliedLaunchObservationInvalidationsSchema, threadSeatMapSchema, queueTransitionWakesSchema, nodeSessionSourceSchema, scopedOperatingPostureSchema, humanNotificationIntentSchema, reviewReadIndexesSchema, inventoryEventIndexesSchema, rigClaudeManagedBlockFileSchema, nodePermissionSelectionsSchema, humanReplyToSchema, humanQuestionsSchema, nodeEffortSchema, rigNonInterruptiveSchema, rigInstallRootSchema, threadPartMapSchema, queueTransitionsQitemIdOrderSchema, queueItemsRecoveryTagsSchema, resumeRotationSchema, sessionStopIntentSchema, nodeEffortChoiceSchema, modePostureCoexistSchema, seatEnvSchema, appliedLaunchDaemonOptOutSchema, sessionCommunityHarnessSchema, threadSeatMapChannelKeySchema, queueItemWakeInputBoxSchema, providerAccountReadingsSchema, humanQuietWindowsSchema, seatIdentityPanePidSchema, seatIdentityProcessEvidenceSchema, rigStartupLayersSchema, humanOperatingModesSchema, queueDelegatedDecisionSchema, nodeAdvisorSchema, operationCorrelationSchema, terminalPolicySchema, seatRuntimeSwitchesSchema, claudeTokenReadingsSchema, nodeLaunchRecipeSchema, nodeClaudeTokenRefSchema, nodeClaudePermissionsSchema, nodeCodexPermissionsSchema, escalationTargetRefsSchema, sessionAgentRunnerSchema, nodeAccountHomeSchema, nodeClaudeMcpSchema, activityGrantsSchema, environmentBindingsSchema, nodePermissionsToSchema, nodeStartupGuidanceFileSchema];
/**
* P24 — the DECLARED exclusions for {@link migrationsForFullTestDb}. That list is deliberately a
@@ -1171,6 +1171,16 @@ describe("node detail reports a guidance block's installed file only from eviden
declaredTarget: "CLAUDE.md", effectiveTarget: path.join(cwd, "AGENTS.md") });
});
it("follows a Pi seat's recorded guidance file in its agent folder, not the repository AGENTS.md (#875)", () => {
const agentFile = path.join(cwd, "pi-state", "dev-impl@test-rig", "agent", "AGENTS.md");
fs.mkdirSync(path.dirname(agentFile), { recursive: true });
fs.writeFileSync(agentFile, block("role"));
fs.writeFileSync(path.join(cwd, "AGENTS.md"), block("role")); // a stale repository copy isn't evidence for this seat
seedStartupContext(db, "node-1", { runtime: "pi", projectionEntries: [guidance("AGENTS.md")] });
db.prepare("UPDATE node_startup_context SET guidance_file = ? WHERE node_id = 'node-1'").run(agentFile);
expect(getNodeDetail(db, "rig-1", "dev.impl")!.installedResources[0]).toMatchObject({ targetPath: agentFile, effectiveTarget: agentFile });
});
it("claims nothing for a runtime with no known guidance file, a merge that writes nothing, or a half-written block", () => {
fs.writeFileSync(path.join(cwd, "AGENTS.md"), block("role"));
expect(installed("synth")[0]).toEqual({ id: "role", category: "guidance", targetPath: "", declaredTarget: "AGENTS.md" });
@@ -765,3 +765,70 @@ describe("durable catch-up cursor survives launch-attempt resets", () => {
}
});
});
// #875 direction 2: Pi loads its agent folder's AGENTS.md before the repository's (Quality's native run on Pi 1.0.1), so a
// Pi seat's managed guidance goes there and the person's tracked repository AGENTS.md is never written.
describe("a Pi seat's managed guidance goes to its own agent folder (#875)", () => {
const AGENT_FILE = nodePath.join(piSeatPaths(STATE_ROOT, SESSION).agentDir, "AGENTS.md");
const seat = { tmuxSession: SESSION, cwd: "/work" } as never;
it("chooses the seat's agent-folder AGENTS.md, and nothing without a session", () => {
const adapter = adapterWith(memFs(), mockTmux());
expect(adapter.guidanceFileFor(seat)).toEqual({ file: AGENT_FILE });
expect(adapter.guidanceFileFor({ tmuxSession: null, cwd: "/work" } as never)).toEqual({ file: null });
});
it.each([
["the agent AGENTS.md is a symlink", AGENT_FILE, { link: true, nlink: 1 }],
["the agent AGENTS.md is a hard link", AGENT_FILE, { link: false, nlink: 2 }],
["the agent folder is a symlink", piSeatPaths(STATE_ROOT, SESSION).agentDir, { link: true, nlink: 1 }],
])("keeps the repository file when %s, which other sessions could read, and says so", (_case, linked, info) => {
const fs = { ...memFs(), linkInfo: (p: string) => (p === linked ? info : null) };
const chosen = adapterWith(fs, mockTmux()).guidanceFileFor(seat);
expect(chosen.file).toBeNull();
expect(chosen.note).toMatch(/repository AGENTS\.md/);
});
it("merges startup guidance and projected guidance into the chosen file, creating its folder, and never the repository AGENTS.md", async () => {
const fs = memFs({ "/spec/SOP.md": "How work flows here.", "/spec/culture.md": "Team culture." });
const adapter = adapterWith(fs, mockTmux());
const binding = { tmuxSession: SESSION, cwd: "/work", guidanceFile: AGENT_FILE } as never;
const delivered = await adapter.deliverStartup([{ path: "SOP.md", absolutePath: "/spec/SOP.md", ownerRoot: "/spec",
deliveryHint: "guidance_merge", required: true, appliesOn: ["fresh_start"] }], binding);
expect(delivered.failed).toEqual([]);
const projected = await adapter.project({ runtime: "pi", cwd: "/work", entries: [{ effectiveId: "culture", category: "guidance",
mergeStrategy: "managed_block", absolutePath: "/spec/culture.md", classification: "safe_projection" }],
startup: { files: [], actions: [] }, conflicts: [], noOps: [], diagnostics: [] } as never, binding);
expect(projected.failed).toEqual([]);
expect(fs.files[AGENT_FILE]).toContain("How work flows here.");
expect(fs.files[AGENT_FILE]).toContain("Team culture.");
expect(fs.dirs.has(nodePath.dirname(AGENT_FILE))).toBe(true);
expect("/work/AGENTS.md" in fs.files).toBe(false);
});
it("a binding with no chosen file (a restore from an older snapshot) still merges into the repository AGENTS.md", async () => {
const fs = memFs({ "/spec/SOP.md": "How work flows here." });
await adapterWith(fs, mockTmux()).deliverStartup([{ path: "SOP.md", absolutePath: "/spec/SOP.md", ownerRoot: "/spec",
deliveryHint: "guidance_merge", required: true, appliesOn: ["fresh_start"] }], { tmuxSession: SESSION, cwd: "/work", guidanceFile: null } as never);
expect(fs.files["/work/AGENTS.md"]).toContain("How work flows here.");
expect(AGENT_FILE in fs.files).toBe(false);
});
it("an OMP seat keeps the repository file: nothing has shown OMP loads its agent folder's AGENTS.md", () => {
const omp = new OmpRuntimeAdapter({ tmux: mockTmux(), fsOps: memFs(), stateRoot: STATE_ROOT, runnerEntryPath: RUNNER });
expect(omp.guidanceFileFor(seat)).toEqual({ file: null });
});
});
// #2432 X-F2: a restore's recorded agent file that became a link after its launch gets nothing written through it.
describe("a Pi seat's recorded guidance file that has since become a link (#2432)", () => {
const AGENT_FILE = nodePath.join(piSeatPaths(STATE_ROOT, SESSION).agentDir, "AGENTS.md");
it("writes no guidance through it or into the repository AGENTS.md, and says why", async () => {
const fs = { ...memFs({ "/spec/SOP.md": "How work flows here." }), linkInfo: (p: string) => (p === AGENT_FILE ? { link: true, nlink: 1 } : null) };
const result = await adapterWith(fs, mockTmux()).deliverStartup([{ path: "SOP.md", absolutePath: "/spec/SOP.md", ownerRoot: "/spec",
deliveryHint: "guidance_merge", required: true, appliesOn: ["restore"] }], { tmuxSession: SESSION, cwd: "/work", guidanceFile: AGENT_FILE } as never);
expect(AGENT_FILE in fs.files).toBe(false);
expect("/work/AGENTS.md" in fs.files).toBe(false);
expect(result.warnings?.join("\n")).toMatch(/now a link/);
});
});
@@ -33,6 +33,7 @@ import type { TmuxAdapter } from "../src/adapters/tmux.js";
import type { ClaudeResumeAdapter } from "../src/adapters/claude-resume.js";
import type { CodexResumeAdapter } from "../src/adapters/codex-resume.js";
import { createFullTestDb } from "./helpers/test-app.js";
import { getNodeDetail } from "../src/domain/node-inventory.js";
const TOKEN = "tok-lead";
@@ -72,6 +73,8 @@ interface SeatOptions {
autoGuidanceFile?: boolean;
/** The saved selection carries the seat-skills bootstrap file, as for every seat with skill storage. */
seatSkillsLine?: boolean;
/** #875: a Pi launch that chose its own agent folder's AGENTS.md, recorded with the startup context. */
piAgentFile?: boolean;
}
/** The resume type each runtime records for its native session. */
@@ -169,7 +172,12 @@ describe("a seat's guidance across rig down then rig up --existing (exact resume
const actions = [{ type: "send_text", value: "STARTUP-PROMPT-MUST-NOT-REPLAY", phase: "after_ready", appliesOn: ["fresh_start", "restore"], idempotent: true }];
db.prepare("INSERT INTO node_startup_context (node_id, projection_entries_json, resolved_files_json, startup_actions_json, runtime) VALUES (?, ?, ?, ?, ?)")
.run(node.id, JSON.stringify(entries), JSON.stringify(files), JSON.stringify(actions), runtime);
const guidanceFile = path.join(cwd, runtime === "claude-code" ? (opts.claudeManagedBlockFile ?? "CLAUDE.md") : "AGENTS.md");
const guidanceFile = opts.piAgentFile ? path.join(tmp, "pi-state", sessionName, "agent", "AGENTS.md")
: path.join(cwd, runtime === "claude-code" ? (opts.claudeManagedBlockFile ?? "CLAUDE.md") : "AGENTS.md");
if (opts.piAgentFile) {
fs.mkdirSync(path.dirname(guidanceFile), { recursive: true });
db.prepare("UPDATE node_startup_context SET guidance_file = ? WHERE node_id = ?").run(guidanceFile, node.id);
}
if (opts.userText) fs.writeFileSync(guidanceFile, opts.userText);
mergeManagedBlock(nodeFs, guidanceFile, "lead-role", "You lead this team.");
mergeManagedBlock(nodeFs, guidanceFile, "SOP.md", "How work flows here.");
@@ -290,6 +298,80 @@ describe("a seat's guidance across rig down then rig up --existing (exact resume
for (const other of ["CLAUDE.md", "CLAUDE.local.md"]) expect(fs.existsSync(path.join(cwd, other)), other).toBe(false);
});
// #875 direction 2: this Pi seat's launch put its blocks in its own agent folder's AGENTS.md. Down takes them out of that
// file, the exact resume puts them back there, and the person's tracked repository AGENTS.md is never written.
it("a Pi seat whose launch chose its agent folder keeps the tracked repository AGENTS.md byte-identical across down and the exact resume", async () => {
const tracked = "# Project guidance (tracked in git)\nRun the tests before pushing.\n";
const repoFile = path.join(cwd, "AGENTS.md");
fs.writeFileSync(repoFile, tracked);
const { rigId, guidanceFile } = launchedSeat({ podAware: true, runtime: "pi", piAgentFile: true, userText: "My own Pi notes\n" });
expect(guidanceFile).not.toBe(repoFile);
let afterDown: string | null = null;
const { result, adapter, fileAtHarnessStart } = await downThenUpExisting(rigId, guidanceFile, { podAware: true, runtime: "pi",
betweenDownAndUp: () => {
afterDown = fs.readFileSync(guidanceFile, "utf-8");
expect(fs.readFileSync(repoFile, "utf-8"), "the tracked AGENTS.md after rig down").toBe(tracked);
} });
expect(afterDown, "the agent AGENTS.md after rig down keeps only the person's lines").toBe("My own Pi notes\n");
expect(fileAtHarnessStart).toContain("My own Pi notes\n");
expect(fileAtHarnessStart).toContain(MANAGED_BLOCK_START("lead-role"));
expect(fileAtHarnessStart).toContain(MANAGED_BLOCK_START("SOP.md"));
expect(fs.readFileSync(repoFile, "utf-8"), "the tracked AGENTS.md after the exact resume").toBe(tracked);
expect(result.nodes[0]?.guidanceGaps).toBeUndefined();
// The restore hands the launch's recorded choice to the adapter, so a replayed start would use the same file.
expect((adapter.project.mock.calls[0] as unknown[] | undefined)?.[1]).toMatchObject({ guidanceFile });
});
// #2432 X-F1: an older snapshot (no recorded choice) restored exactly after a newer launch recorded the agent file. The
// seat resumes on the repository file, so the record must say so: inventory names it, and another rig's down in the same
// cwd leaves the guidance this live seat reads.
it("an older snapshot restored exactly after a newer recorded launch records the repository file it resumes on", async () => {
const { rigId, guidanceFile } = launchedSeat({ podAware: true, runtime: "pi" });
const nodeId = (db.prepare("SELECT id FROM nodes WHERE rig_id = ?").get(rigId) as { id: string }).id;
const newer = path.join(tmp, "pi-state", "orch-lead@poc", "agent", "AGENTS.md");
const recorded = () => (db.prepare("SELECT guidance_file FROM node_startup_context WHERE node_id = ?").get(nodeId) as { guidance_file: string | null }).guidance_file;
await downThenUpExisting(rigId, guidanceFile, { podAware: true, runtime: "pi",
// After the snapshot was taken, a newer fresh launch recorded the seat's agent file.
betweenDownAndUp: () => { db.prepare("UPDATE node_startup_context SET guidance_file = ? WHERE node_id = ?").run(newer, nodeId); } });
expect(recorded(), "the record names the file the resumed seat uses").toBeNull();
expect(getNodeDetail(db, rigId, "orch.lead")!.installedResources.find((r) => r.id === "lead-role")).toMatchObject({ targetPath: guidanceFile });
// Another rig in the same cwd goes down: the resumed seat's live repository guidance stays.
const other = rigRepo.createRig("other");
const peer = rigRepo.addNode(other.id, "peer", { role: "peer", runtime: "codex", cwd });
sessionRegistry.updateStatus(sessionRegistry.registerSession(peer.id, "r02-peer").id, "running");
await new RigTeardownOrchestrator({ db, rigRepo, sessionRegistry, tmuxAdapter: mockTmux(), snapshotCapture, eventBus }).teardown(other.id);
expect(fs.readFileSync(guidanceFile, "utf-8")).toContain(MANAGED_BLOCK_START("lead-role"));
expect(fs.readFileSync(guidanceFile, "utf-8")).toContain(MANAGED_BLOCK_START("SOP.md"));
});
// #2432 X-F2: after the launch recorded the agent file, the person links it to a shared Pi home's file. The exact resume
// writes nothing through that link: the conversation resumes, the gap is reported, and nothing falls back to the
// repository file.
it("a recorded agent file that became a link after its launch gets nothing put back through it, and the gap is reported", async () => {
const tracked = "# Project guidance (tracked in git)\n";
const repoFile = path.join(cwd, "AGENTS.md");
fs.writeFileSync(repoFile, tracked);
const { rigId, guidanceFile } = launchedSeat({ podAware: true, runtime: "pi", piAgentFile: true });
const shared = path.join(tmp, "shared-pi-home", "AGENTS.md");
fs.mkdirSync(path.dirname(shared), { recursive: true });
fs.writeFileSync(shared, "The person's own Pi home guidance\n");
const { result } = await downThenUpExisting(rigId, guidanceFile, { podAware: true, runtime: "pi",
betweenDownAndUp: () => { fs.rmSync(guidanceFile, { force: true }); fs.symlinkSync(shared, guidanceFile); } });
expect(fs.readFileSync(shared, "utf-8"), "the shared file").toBe("The person's own Pi home guidance\n");
expect(fs.readFileSync(repoFile, "utf-8"), "no fallback into the tracked file").toBe(tracked);
expect(result.nodes[0]?.status).toBe("resumed");
expect(result.nodes[0]?.guidanceGaps).toEqual(expect.arrayContaining(["lead-role", "SOP.md"]));
expect(result.rigResult).not.toBe("fully_restored");
});
it("a Pi seat restored from a snapshot with no recorded choice stays on the repository AGENTS.md (no silent repoint)", async () => {
const { rigId, guidanceFile } = launchedSeat({ podAware: true, runtime: "pi" });
expect(guidanceFile).toBe(path.join(cwd, "AGENTS.md"));
const { adapter, fileAtHarnessStart } = await downThenUpExisting(rigId, guidanceFile, { podAware: true, runtime: "pi" });
expect(fileAtHarnessStart).toContain(MANAGED_BLOCK_START("SOP.md"));
expect((adapter.project.mock.calls[0] as unknown[] | undefined)?.[1]).toMatchObject({ guidanceFile: null });
});
it("only a missing block is put back: an existing block keeps its content, and user text is preserved", async () => {
const { rigId, guidanceFile } = launchedSeat({ userText: "# My project notes\nKeep tests fast.\n" });
const { fileAtHarnessStart } = await downThenUpExisting(rigId, guidanceFile, {
@@ -776,6 +776,25 @@ describe("StartupOrchestrator", () => {
expect(read()).toEqual(before);
});
// #875 direction 2: a launch decides once where its managed guidance goes and records it; a restore passes the saved
// choice (null for an older snapshot), so the adapter isn't asked again and an old seat isn't silently repointed.
it("records the adapter's chosen guidance file at launch, and uses a restore's saved choice without asking again", async () => {
const seed = seedSession(); const orch = createOrchestrator();
const chosen = "/openrig-home/state/pi/r01-impl/agent/AGENTS.md";
const guidanceFileFor = vi.fn(() => ({ file: chosen }));
const adapter = mockAdapter({ runtime: "pi", guidanceFileFor } as never);
const recorded = () => (db.prepare("SELECT guidance_file FROM node_startup_context WHERE node_id=?").get(seed.nodeId) as { guidance_file: string | null }).guidance_file;
expect((await orch.startNode(makeInput(seed, { adapter }))).ok).toBe(true);
expect(vi.mocked(adapter.project).mock.calls[0]?.[1]).toMatchObject({ guidanceFile: chosen });
expect(vi.mocked(adapter.deliverStartup).mock.calls[0]?.[1]).toMatchObject({ guidanceFile: chosen });
expect(recorded()).toBe(chosen);
guidanceFileFor.mockClear();
const restored = await orch.startNode(makeInput(seed, { adapter, isRestore: true, binding: { ...makeBinding(), guidanceFile: null } }));
expect(restored.ok).toBe(true);
expect(guidanceFileFor).not.toHaveBeenCalled();
expect(recorded()).toBeNull();
});
it("exact resume retains configured fresh context while sending no replay", async () => {
const seed = seedSession(); const orch = createOrchestrator();
const action = makeAction({ type: "send_text", value: "configured context" });