Compare commits

...
Author SHA1 Message Date
dev60-review aaa683a8e1 test(codex): a fresh Codex seat's managed guidance arrives as launch developer instructions
On a fresh launch of Codex 0.164.0 or later with no developer_instructions
configured, OpenRig's managed guidance should go in through
-c developer_instructions, leaving the tracked repository AGENTS.md and the
Codex home alone. A configured or unreadable layer, an older or unknown
version, and a resume keep the repository AGENTS.md. Roles and the
seat-skills line stay where they are today.
2026-11-03 06:13:26 +00:00
dev60-review 8f30607a64 fix(pi): a restore records the guidance file it resumes on, and writes nothing through a later link
An exact resume keeps a seat's saved startup selection, so the recorded
guidance file stayed at a newer launch's agent file while an older snapshot's
seat resumed on its repository file: inventory named the wrong file, and
another rig's down in the same cwd could take this live seat's guidance. The
restore now records the snapshot's choice, and nothing else in the selection
changes.

The link check that keeps a fresh launch off a linked agent file now also runs
where a recorded file is written: the exact-resume put-back and the Pi
adapter's guidance writes. A file that became a link after its launch gets
nothing written through it, nothing falls back to the repository file, and the
missing guidance is reported as a gap. The conversation still resumes.
2026-11-03 05:21:11 +00:00
dev60-review a5ff0fcda0 test(pi): a restore records the guidance file it resumes on, and writes nothing through a later link
An older snapshot restored exactly after a newer launch recorded the agent file
resumes on the repository file, so the record must name that file for
inventory and for another rig's down in the same cwd. A recorded agent file
that became a link after its launch gets nothing put back through it, and the
missing guidance is reported.
2026-11-03 05:18:58 +00:00
dev60-review bcdfd7a39c fix(pi): a Pi seat's managed guidance goes to its agent folder, never the tracked AGENTS.md
Pi loads its agent folder's AGENTS.md (PI_CODING_AGENT_DIR) before the
repository's, so a Pi seat's managed guidance now goes there and the person's
tracked repository AGENTS.md is no longer written at launch or restore.

A fresh launch asks the adapter once where its guidance goes and records the
answer with the startup context (node_startup_context.guidance_file, new and
nullable). Restore, rig down and node inventory read the recorded file, so one
target is used everywhere. A row or snapshot from before this records nothing
and keeps the repository file, so an existing seat isn't repointed by a restore.

The Pi adapter keeps the repository file when the seat's agent folder or its
AGENTS.md is a symlink or a hard link, since other sessions could read it there,
and says so in the launch warnings. OMP, Codex and Claude are unchanged.

rig down removes OpenRig's unedited blocks from the recorded file and from the
repository file, where an older launch of the same seat wrote them.
2026-11-03 05:02:06 +00:00
dev60-review e91e308b32 test(pi): a Pi seat's managed guidance goes to its agent folder and the tracked AGENTS.md is never written
A Pi seat loads its agent folder's AGENTS.md before the repository's, so its
managed guidance can live there instead of in the person's tracked file. These
cells pin that: the launch chooses and records the file, every reader uses the
recorded one, rig down and an exact resume leave the tracked AGENTS.md
byte-identical, a linked agent file or an older snapshot keeps the repository
file, and OMP is unchanged.
2026-11-03 04:54:27 +00:00
19 changed files with 427 additions and 29 deletions

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.
@@ -2789,3 +2789,79 @@ describe("a seat's own permission envelope (argv only, not native enforcement)",
expect(command).not.toContain("exclude_slash_tmp");
});
});
// #875 Codex: on a fresh launch of a supported Codex, OpenRig's managed guidance goes in as the launch's developer
// instructions (`-c developer_instructions`, Codex 0.164.0), so the person's tracked repository AGENTS.md and the Codex
// home are left alone. Anything else keeps the repository AGENTS.md, as before.
describe("a fresh Codex seat's managed guidance as launch developer instructions (#875)", () => {
const HOME = "/codex-home";
const adapterFor = (fs: CodexAdapterFsOps, tmux = mockTmux(), codexVersion: string | null = "0.164.0") => new CodexRuntimeAdapter({
tmux, fsOps: fs, codexHome: HOME, listProcesses: () => [], sleep: async () => {},
resolveManagedConfigSettings: async () => ({ enabled: false, ...(codexVersion ? { codexVersion } : {}) }),
});
const plan = (entries: ProjectionEntry[]): ProjectionPlan => ({ runtime: "codex", cwd: "/project", entries,
startup: { files: [], actions: [] }, conflicts: [], noOps: [], diagnostics: [] } as unknown as ProjectionPlan);
const guidance = (effectiveId: string, absolutePath: string) => makeEntry({ category: "guidance", effectiveId, mergeStrategy: "managed_block", absolutePath });
const startupFile = (path: string, absolutePath: string): ResolvedStartupFile => ({ path, absolutePath, ownerRoot: nodePath.dirname(absolutePath),
deliveryHint: "guidance_merge", required: true, appliesOn: ["fresh_start"] });
const instructionsIn = (command: string): string | undefined => {
const arg = /-c 'developer_instructions=([^']*)'/.exec(command)?.[1];
return arg === undefined ? undefined : JSON.parse(arg) as string;
};
it("chooses launch instructions only for a fresh launch of Codex 0.164.0 or later with none configured", async () => {
expect(await adapterFor(mockFs()).guidanceFileFor(makeBinding())).toEqual({ file: null, launchInstructions: true });
expect(await adapterFor(mockFs(), mockTmux(), "0.165.2").guidanceFileFor(makeBinding())).toEqual({ file: null, launchInstructions: true });
expect(await adapterFor(mockFs()).guidanceFileFor(makeBinding(), { resuming: true })).toEqual({ file: null });
expect(await adapterFor(mockFs(), mockTmux(), "0.163.9").guidanceFileFor(makeBinding())).toEqual({ file: null });
expect(await adapterFor(mockFs(), mockTmux(), null).guidanceFileFor(makeBinding())).toEqual({ file: null });
});
it.each([
["the Codex home config", `${HOME}/config.toml`, 'developer_instructions = "Mine."\n'],
["a profile in the Codex home config", `${HOME}/config.toml`, '[profiles.work]\ndeveloper_instructions = "Mine."\n'],
["the project config", "/project/.codex/config.toml", 'developer_instructions = "Mine."\n'],
["an unreadable config layer", `${HOME}/config.toml`, "this is = = not toml"],
])("keeps the repository AGENTS.md when %s sets developer_instructions or can't be read, and says why", async (_case, path, text) => {
const chosen = await adapterFor(mockFs({ [path]: text })).guidanceFileFor(makeBinding());
expect(chosen.launchInstructions).toBeUndefined();
expect(chosen.file).toBeNull();
expect(chosen.note).toMatch(/repository AGENTS\.md/);
});
it("sends the guidance with the fresh launch, writes neither the repository nor the home AGENTS.md, and keeps roles and the seat-skills line out", async () => {
const fs = mockFs({ "/spec/culture.md": "Team culture.", "/spec/SOP.md": "How work flows here.", "/spec/rig-role.md": "Seat role text.",
"/spec/openrig-seat-skills.md": "Seat skills line." }) as CodexAdapterFsOps & { _store: Record<string, string> };
const tmux = mockTmux();
const adapter = adapterFor(fs, tmux);
const binding = { ...makeBinding(), guidanceFile: null, guidanceLaunchInstructions: true } as NodeBinding;
await adapter.project(plan([guidance("culture.md", "/spec/culture.md"), guidance("rig-role", "/spec/rig-role.md")]), binding);
await adapter.deliverStartup([startupFile("SOP.md", "/spec/SOP.md")], binding);
expect("/project/AGENTS.md" in fs._store).toBe(false);
expect(`${HOME}/AGENTS.md` in fs._store).toBe(false);
// The person's yes to the seat-skills line was for its AGENTS.md footprint, so it keeps that file.
await adapter.deliverStartup([startupFile("openrig-seat-skills.md", "/spec/openrig-seat-skills.md")], binding);
expect(fs._store["/project/AGENTS.md"]).toContain("Seat skills line.");
expect((await adapter.launchHarness(binding, { name: "dev-qa@test-rig" })).ok).toBe(true);
const sent = (tmux.sendText as ReturnType<typeof vi.fn>).mock.calls.map((call) => call[1] as string).find((call) => call.includes(" -C '/project'"))!;
const text = instructionsIn(sent);
expect(text).toMatch(/^OpenRig seat guidance for this session follows\. Where it conflicts with AGENTS\.md guidance in the repository or the Codex home, follow AGENTS\.md\./);
expect(text).toContain("Team culture.");
expect(text).toContain("How work flows here.");
expect(text).not.toContain("Seat role text.");
expect(text).not.toContain("Seat skills line.");
// Still read as a fresh launch by the process-lineage readers.
expect(codexCommandResumeToken(sent)).toBeUndefined();
});
it("a launch without the choice (a resume, a restore, an older Codex) sends no developer instructions and merges into AGENTS.md as before", async () => {
const fs = mockFs({ "/spec/SOP.md": "How work flows here." }) as CodexAdapterFsOps & { _store: Record<string, string> };
const tmux = mockTmux();
const adapter = adapterFor(fs, tmux);
await adapter.deliverStartup([startupFile("SOP.md", "/spec/SOP.md")], { ...makeBinding(), guidanceFile: null } as NodeBinding);
expect(fs._store["/project/AGENTS.md"]).toContain("How work flows here.");
await adapter.launchHarness({ ...makeBinding(), guidanceFile: null } as NodeBinding, { name: "dev-qa@test-rig" });
const sent = (tmux.sendText as ReturnType<typeof vi.fn>).mock.calls.map((call) => call[1] as string).find((call) => call.includes(" -C '/project'"))!;
expect(sent).not.toContain("developer_instructions");
});
});
+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,45 @@ 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();
});
// #875 Codex: the adapter may answer asynchronously, a resume is named to it, and a choice of launch instructions rides
// the binding to projection, delivery and the launch; nothing is recorded as a guidance file for it.
it("passes whether the launch resumes, and carries an adapter's launch-instructions choice on the binding", async () => {
const seed = seedSession(); const orch = createOrchestrator();
const guidanceFileFor = vi.fn(async (_binding: NodeBinding, opts?: { resuming?: boolean }) =>
opts?.resuming ? { file: null } : { file: null, launchInstructions: true });
const adapter = mockAdapter({ runtime: "codex", guidanceFileFor } as never);
expect((await orch.startNode(makeInput(seed, { adapter }))).ok).toBe(true);
expect(guidanceFileFor).toHaveBeenLastCalledWith(expect.anything(), { resuming: false });
const chosen = { guidanceFile: null, guidanceLaunchInstructions: true };
expect(vi.mocked(adapter.project).mock.calls[0]?.[1]).toMatchObject(chosen);
expect(vi.mocked(adapter.deliverStartup).mock.calls[0]?.[1]).toMatchObject(chosen);
expect(vi.mocked(adapter.launchHarness).mock.calls[0]?.[0]).toMatchObject(chosen);
expect((db.prepare("SELECT guidance_file FROM node_startup_context WHERE node_id=?").get(seed.nodeId) as { guidance_file: string | null }).guidance_file).toBeNull();
vi.mocked(adapter.project).mockClear();
expect((await orch.startNode(makeInput(seed, { adapter, resumeToken: "native-original" }))).ok).toBe(true);
expect(guidanceFileFor).toHaveBeenLastCalledWith(expect.anything(), { resuming: true });
expect(vi.mocked(adapter.project).mock.calls[0]?.[1]).not.toMatchObject({ guidanceLaunchInstructions: true });
});
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" });