mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-07 05:56:21 -10:00
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:
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);
|
||||
}
|
||||
|
||||
@@ -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;",
|
||||
};
|
||||
@@ -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; }
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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" });
|
||||
|
||||
Reference in new issue
Block a user