pi-agent-replicate 0.0.0-stage → 1.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +27 -0
- package/CHANGELOG.md +152 -0
- package/CONTEXT.md +63 -0
- package/LICENSE +21 -0
- package/README.md +68 -2
- package/RELEASING.md +132 -0
- package/assets/banner.png +0 -0
- package/config.json.example +6 -0
- package/docs/README.md +23 -0
- package/docs/adr/0003-installable-role-packs.md +29 -0
- package/docs/adr/0008-adopt-pi-only-subagent-execution.md +22 -0
- package/docs/adr/0009-durable-recursive-subagent-ownership.md +23 -0
- package/docs/adr/0010-public-subagent-tree-registry.md +16 -0
- package/docs/configuration.md +14 -0
- package/docs/development.md +27 -0
- package/docs/public-subagent-tree-api.md +58 -0
- package/docs/retained-roles.md +71 -0
- package/docs/settled-lifecycle-contract.md +71 -0
- package/docs/worktree-subagents.md +58 -0
- package/examples/role-pack/extension.ts +18 -0
- package/examples/role-pack/package.json +16 -0
- package/examples/role-pack/roles/example-reviewer.md +12 -0
- package/index.d.ts +116 -0
- package/index.js +1064 -0
- package/index.ts +155 -0
- package/package.json +70 -4
- package/pi-extension/subagents/activity.js +575 -0
- package/pi-extension/subagents/activity.ts +980 -0
- package/pi-extension/subagents/completion.js +184 -0
- package/pi-extension/subagents/completion.ts +229 -0
- package/pi-extension/subagents/delivery-drain.js +15 -0
- package/pi-extension/subagents/delivery-drain.ts +20 -0
- package/pi-extension/subagents/delivery-files.js +86 -0
- package/pi-extension/subagents/delivery-files.ts +105 -0
- package/pi-extension/subagents/delivery-log.js +51 -0
- package/pi-extension/subagents/delivery-log.ts +51 -0
- package/pi-extension/subagents/delivery-payload.ts +55 -0
- package/pi-extension/subagents/delivery.ts +219 -0
- package/pi-extension/subagents/execution-owner.js +30 -0
- package/pi-extension/subagents/execution-owner.ts +38 -0
- package/pi-extension/subagents/herdr.js +645 -0
- package/pi-extension/subagents/herdr.ts +927 -0
- package/pi-extension/subagents/index.ts +3400 -0
- package/pi-extension/subagents/launch.js +774 -0
- package/pi-extension/subagents/launch.ts +1124 -0
- package/pi-extension/subagents/lifecycle.js +477 -0
- package/pi-extension/subagents/lifecycle.ts +582 -0
- package/pi-extension/subagents/lineage.js +806 -0
- package/pi-extension/subagents/lineage.ts +841 -0
- package/pi-extension/subagents/replica-background.js +32 -0
- package/pi-extension/subagents/replica-background.ts +38 -0
- package/pi-extension/subagents/replica-branch.ts +45 -0
- package/pi-extension/subagents/replica-directory-settings.ts +51 -0
- package/pi-extension/subagents/replica-history.js +22 -0
- package/pi-extension/subagents/replica-history.ts +21 -0
- package/pi-extension/subagents/replica-work.js +326 -0
- package/pi-extension/subagents/replica-work.ts +336 -0
- package/pi-extension/subagents/replica-worker.js +74 -0
- package/pi-extension/subagents/replica-worker.ts +71 -0
- package/pi-extension/subagents/replication.ts +99 -0
- package/pi-extension/subagents/result-delivery-queue.ts +33 -0
- package/pi-extension/subagents/role-catalog.js +123 -0
- package/pi-extension/subagents/role-catalog.ts +24 -0
- package/pi-extension/subagents/runtime-routing.js +154 -0
- package/pi-extension/subagents/runtime-routing.ts +239 -0
- package/pi-extension/subagents/session-binding.js +346 -0
- package/pi-extension/subagents/session-binding.ts +339 -0
- package/pi-extension/subagents/session-work.js +219 -0
- package/pi-extension/subagents/session-work.ts +167 -0
- package/pi-extension/subagents/session.js +318 -0
- package/pi-extension/subagents/session.ts +448 -0
- package/pi-extension/subagents/settled-contract.js +47 -0
- package/pi-extension/subagents/settled-contract.ts +175 -0
- package/pi-extension/subagents/source-state.js +180 -0
- package/pi-extension/subagents/source-state.ts +184 -0
- package/pi-extension/subagents/status.ts +493 -0
- package/pi-extension/subagents/subagent-done.js +805 -0
- package/pi-extension/subagents/subagent-done.ts +878 -0
- package/pi-extension/subagents/terminal.js +111 -0
- package/pi-extension/subagents/terminal.ts +208 -0
- package/pi-extension/subagents/watchdog.js +122 -0
- package/pi-extension/subagents/watchdog.ts +171 -0
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { fork } from "node:child_process";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
/** Await costly durable host work without blocking Pi's input or model loop. */
|
|
4
|
+
export function runReplicaBackground(request) {
|
|
5
|
+
return new Promise((resolve, reject) => {
|
|
6
|
+
const child = fork(fileURLToPath(new URL("./replica-worker.js", import.meta.url)), [], {
|
|
7
|
+
// A pane handoff must not signal the worker through the old terminal process group.
|
|
8
|
+
detached: true, execArgv: [], stdio: ["ignore", "ignore", "pipe", "ipc"],
|
|
9
|
+
});
|
|
10
|
+
let stderr = "";
|
|
11
|
+
let reply;
|
|
12
|
+
let failure;
|
|
13
|
+
child.stderr?.on("data", (chunk) => { stderr = (stderr + String(chunk)).slice(-8192); });
|
|
14
|
+
child.on("error", reject);
|
|
15
|
+
child.on("message", (message) => {
|
|
16
|
+
if (message.error)
|
|
17
|
+
failure = new Error(message.error);
|
|
18
|
+
else
|
|
19
|
+
reply = message.result;
|
|
20
|
+
});
|
|
21
|
+
child.on("exit", (code, signal) => {
|
|
22
|
+
if (failure)
|
|
23
|
+
reject(failure);
|
|
24
|
+
else if (code === 0 && reply)
|
|
25
|
+
resolve(reply);
|
|
26
|
+
else
|
|
27
|
+
reject(new Error(`Replica host worker exited (${signal ?? code}): ${stderr}`));
|
|
28
|
+
});
|
|
29
|
+
child.send(request, (error) => { if (error)
|
|
30
|
+
reject(error); });
|
|
31
|
+
});
|
|
32
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { fork } from "node:child_process";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
import type { ReplicaCompletion, ReplicaWork } from "./replica-work.ts";
|
|
4
|
+
import type { SessionLocation } from "./session-binding.ts";
|
|
5
|
+
|
|
6
|
+
/** One host operation. No model or Pi runtime is loaded in the worker. */
|
|
7
|
+
export type ReplicaBackgroundRequest =
|
|
8
|
+
| { operation: "complete"; file: string; cwd: string; sessionFile: string; completion: ReplicaCompletion; parent?: SessionLocation }
|
|
9
|
+
| { operation: "cleanup"; file: string; parentCwd: string; surface: string; child?: { rootDir: string; nodeId: string } };
|
|
10
|
+
export interface ReplicaBackgroundResult {
|
|
11
|
+
work?: ReplicaWork;
|
|
12
|
+
cleaned?: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Await costly durable host work without blocking Pi's input or model loop. */
|
|
16
|
+
export function runReplicaBackground(request: ReplicaBackgroundRequest): Promise<ReplicaBackgroundResult> {
|
|
17
|
+
return new Promise<ReplicaBackgroundResult>((resolve, reject) => {
|
|
18
|
+
const child = fork(fileURLToPath(new URL("./replica-worker.js", import.meta.url)), [], {
|
|
19
|
+
// A pane handoff must not signal the worker through the old terminal process group.
|
|
20
|
+
detached: true, execArgv: [], stdio: ["ignore", "ignore", "pipe", "ipc"],
|
|
21
|
+
});
|
|
22
|
+
let stderr = "";
|
|
23
|
+
let reply: ReplicaBackgroundResult | undefined;
|
|
24
|
+
let failure: Error | undefined;
|
|
25
|
+
child.stderr?.on("data", (chunk) => { stderr = (stderr + String(chunk)).slice(-8192); });
|
|
26
|
+
child.on("error", reject);
|
|
27
|
+
child.on("message", (message: { result?: ReplicaBackgroundResult; error?: string }) => {
|
|
28
|
+
if (message.error) failure = new Error(message.error);
|
|
29
|
+
else reply = message.result;
|
|
30
|
+
});
|
|
31
|
+
child.on("exit", (code, signal) => {
|
|
32
|
+
if (failure) reject(failure);
|
|
33
|
+
else if (code === 0 && reply) resolve(reply);
|
|
34
|
+
else reject(new Error(`Replica host worker exited (${signal ?? code}): ${stderr}`));
|
|
35
|
+
});
|
|
36
|
+
child.send(request, (error) => { if (error) reject(error); });
|
|
37
|
+
});
|
|
38
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { execFileSync, spawnSync } from "node:child_process";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { withLineageMutationGate } from "./lineage.ts";
|
|
4
|
+
|
|
5
|
+
/** Reserve a readable, unique branch and checkout name without moving parent HEAD or staging. */
|
|
6
|
+
export async function reserveReplicaBranch(cwd: string, title: string, base: string): Promise<string> {
|
|
7
|
+
const [task, ...origins] = title.split(" ← ");
|
|
8
|
+
const cyrillic: Record<string, string> = {
|
|
9
|
+
а: "a", б: "b", в: "v", г: "g", д: "d", е: "e", ё: "yo", ж: "zh", з: "z", и: "i", й: "i",
|
|
10
|
+
к: "k", л: "l", м: "m", н: "n", о: "o", п: "p", р: "r", с: "s", т: "t", у: "u", ф: "f",
|
|
11
|
+
х: "kh", ц: "ts", ч: "ch", ш: "sh", щ: "shch", ъ: "", ы: "y", ь: "", э: "e", ю: "yu", я: "ya",
|
|
12
|
+
};
|
|
13
|
+
// Herdr drops non-ASCII characters from its default checkout directory.
|
|
14
|
+
const origin = origins.join("-").normalize("NFKC").toLowerCase()
|
|
15
|
+
.replace(/[а-яё]/g, character => cyrillic[character])
|
|
16
|
+
.normalize("NFKD").replace(/\p{M}/gu, "")
|
|
17
|
+
.replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "session";
|
|
18
|
+
const stem = `replica/${origin}/${task}`;
|
|
19
|
+
const git = (args: string[]) => execFileSync("git", args, { cwd, encoding: "utf8", timeout: 10_000 });
|
|
20
|
+
const gate = join(git(["rev-parse", "--path-format=absolute", "--git-common-dir"]).trim(), "replica-branches");
|
|
21
|
+
const deadline = Date.now() + 5_000;
|
|
22
|
+
for (;;) {
|
|
23
|
+
try {
|
|
24
|
+
return withLineageMutationGate(gate, () => {
|
|
25
|
+
// Different origin/task pairs can flatten to the same Herdr directory.
|
|
26
|
+
// Serialize both checks across all linked checkouts of this repository.
|
|
27
|
+
const directory = (branch: string) => branch.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
28
|
+
const occupied = new Set(git(["for-each-ref", "--format=%(refname:strip=2)", "refs/heads"]).trim().split("\n").map(directory));
|
|
29
|
+
for (let number = 1; ; number++) {
|
|
30
|
+
const branch = number === 1 ? stem : `${stem}-${number}`;
|
|
31
|
+
if (occupied.has(directory(branch))) continue;
|
|
32
|
+
const ref = `refs/heads/${branch}`;
|
|
33
|
+
// Empty expected old value also guards against external Git writers.
|
|
34
|
+
const created = spawnSync("git", ["update-ref", ref, base, ""], { cwd, encoding: "utf8", timeout: 10_000 });
|
|
35
|
+
if (created.error) throw created.error;
|
|
36
|
+
if (created.status === 0) return branch;
|
|
37
|
+
if (!git(["for-each-ref", "--format=%(refname)", ref]).split("\n").includes(ref)) throw new Error(`Cannot reserve replica branch ${branch}: ${created.stderr.trim() || created.signal || "Git failed"}`);
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
} catch (error) {
|
|
41
|
+
if (!(error instanceof Error) || error.message !== "Durable state has an active mutation." || Date.now() >= deadline) throw error;
|
|
42
|
+
await new Promise(resolve => setTimeout(resolve, 25));
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, writeFileSync, renameSync, rmSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
4
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
|
|
6
|
+
export type ReplicaDirectoryMode = "worktree" | "shared";
|
|
7
|
+
const fileFor = (cwd: string) => join(cwd, ".pi", "replicate.json");
|
|
8
|
+
|
|
9
|
+
export function readReplicaDirectoryMode(cwd: string): ReplicaDirectoryMode {
|
|
10
|
+
let raw: string;
|
|
11
|
+
try { raw = readFileSync(fileFor(cwd), "utf8"); }
|
|
12
|
+
catch (error) {
|
|
13
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return "worktree";
|
|
14
|
+
throw error;
|
|
15
|
+
}
|
|
16
|
+
const value = JSON.parse(raw);
|
|
17
|
+
if (!value || (value.directoryMode !== "worktree" && value.directoryMode !== "shared")) {
|
|
18
|
+
throw new Error("Invalid replica directory mode in " + fileFor(cwd));
|
|
19
|
+
}
|
|
20
|
+
return value.directoryMode;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function saveMode(cwd: string, mode: ReplicaDirectoryMode): void {
|
|
24
|
+
const dir = join(cwd, ".pi");
|
|
25
|
+
mkdirSync(dir, { recursive: true });
|
|
26
|
+
const file = fileFor(cwd), temp = file + "." + randomUUID() + ".tmp";
|
|
27
|
+
try {
|
|
28
|
+
writeFileSync(temp, JSON.stringify({ directoryMode: mode }, null, 2) + "\n", { flag: "wx" });
|
|
29
|
+
renameSync(temp, file);
|
|
30
|
+
} finally { rmSync(temp, { force: true }); }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function registerReplicaDirectoryCommand(pi: ExtensionAPI): void {
|
|
34
|
+
pi.registerCommand("replica-directory", {
|
|
35
|
+
description: "Choose where new replicas work for this project",
|
|
36
|
+
handler: async (args, ctx) => {
|
|
37
|
+
let mode = args.trim();
|
|
38
|
+
if (!mode && ctx.hasUI) {
|
|
39
|
+
const current = readReplicaDirectoryMode(ctx.cwd);
|
|
40
|
+
mode = await ctx.ui.select(`Replica directory (current: ${current})`, ["worktree", "shared"]) ?? "";
|
|
41
|
+
}
|
|
42
|
+
if (!mode) return;
|
|
43
|
+
if (mode !== "shared" && mode !== "worktree") {
|
|
44
|
+
ctx.ui.notify("Use /replica-directory shared or /replica-directory worktree.", "error");
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
saveMode(ctx.cwd, mode);
|
|
48
|
+
ctx.ui.notify(`New replicas will use ${mode === "shared" ? "the shared directory" : "a separate worktree"}.`, "info");
|
|
49
|
+
},
|
|
50
|
+
});
|
|
51
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { dirname } from "node:path";
|
|
3
|
+
import { materializeResultFile } from "./delivery-files.js";
|
|
4
|
+
import { readSessionBinding } from "./session-binding.js";
|
|
5
|
+
/** Save post-fork answer text without inherited answers, tools or hidden reasoning. */
|
|
6
|
+
export function materializeReplicationHistory(sessionFile, childId, name) {
|
|
7
|
+
const baseline = readSessionBinding(sessionFile)?.child?.environment.PI_SUBAGENT_REPLICATION_ASSISTANTS;
|
|
8
|
+
if (!baseline)
|
|
9
|
+
throw new Error("Replica answer history has no inherited-answer boundary.");
|
|
10
|
+
const inherited = new Set(JSON.parse(baseline));
|
|
11
|
+
const answers = readFileSync(sessionFile, "utf8").split("\n").filter(Boolean).flatMap((line) => {
|
|
12
|
+
const entry = JSON.parse(line);
|
|
13
|
+
if (entry.type !== "message" || entry.message?.role !== "assistant" || inherited.has(entry.id))
|
|
14
|
+
return [];
|
|
15
|
+
const text = entry.message.content.filter((part) => part.type === "text" && typeof part.text === "string").map((part) => part.text).join("\n");
|
|
16
|
+
return text ? [`## Answer ${entry.id}\n\n${text}`] : [];
|
|
17
|
+
});
|
|
18
|
+
return materializeResultFile({
|
|
19
|
+
sessionsDir: dirname(sessionFile), childSessionId: childId, deliveryId: `history:${childId}`,
|
|
20
|
+
status: "error", agentName: name, answer: answers.join("\n\n") || "No textual answers were produced after replication.", now: Date.now(),
|
|
21
|
+
}).path;
|
|
22
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { dirname } from "node:path";
|
|
3
|
+
import { materializeResultFile } from "./delivery-files.ts";
|
|
4
|
+
import { readSessionBinding } from "./session-binding.ts";
|
|
5
|
+
|
|
6
|
+
/** Save post-fork answer text without inherited answers, tools or hidden reasoning. */
|
|
7
|
+
export function materializeReplicationHistory(sessionFile: string, childId: string, name: string): string {
|
|
8
|
+
const baseline = readSessionBinding(sessionFile)?.child?.environment.PI_SUBAGENT_REPLICATION_ASSISTANTS;
|
|
9
|
+
if (!baseline) throw new Error("Replica answer history has no inherited-answer boundary.");
|
|
10
|
+
const inherited = new Set<string>(JSON.parse(baseline));
|
|
11
|
+
const answers = readFileSync(sessionFile, "utf8").split("\n").filter(Boolean).flatMap((line) => {
|
|
12
|
+
const entry = JSON.parse(line);
|
|
13
|
+
if (entry.type !== "message" || entry.message?.role !== "assistant" || inherited.has(entry.id)) return [];
|
|
14
|
+
const text = entry.message.content.filter((part: any) => part.type === "text" && typeof part.text === "string").map((part: any) => part.text).join("\n");
|
|
15
|
+
return text ? [`## Answer ${entry.id}\n\n${text}`] : [];
|
|
16
|
+
});
|
|
17
|
+
return materializeResultFile({
|
|
18
|
+
sessionsDir: dirname(sessionFile), childSessionId: childId, deliveryId: `history:${childId}`,
|
|
19
|
+
status: "error", agentName: name, answer: answers.join("\n\n") || "No textual answers were produced after replication.", now: Date.now(),
|
|
20
|
+
}).path;
|
|
21
|
+
}
|
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
import { closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, readlinkSync, readdirSync, realpathSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { execFileSync } from "node:child_process";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { writeBindingFile, readSessionBinding, childSessionLocation, sessionLocationAlive, sessionOwnsPane, launchOwnsPane } from "./session-binding.js";
|
|
5
|
+
import { closeHerdrSurface, getHerdrPaneProcessInfo, listHerdrWorktrees } from "./herdr.js";
|
|
6
|
+
import { applySourceJoin, planSourceJoin, snapshotSource } from "./source-state.js";
|
|
7
|
+
import { processIncarnationAlive, withLineageMutationGate } from "./lineage.js";
|
|
8
|
+
/** Resolve an attempt only inside the session's durable binding store. */
|
|
9
|
+
export function replicaWorkFile(storeDir, id) {
|
|
10
|
+
if (!/^[a-zA-Z0-9-]+$/.test(id))
|
|
11
|
+
throw new Error("Invalid replica continuation ID.");
|
|
12
|
+
return join(storeDir, "replicas", `${id}.json`);
|
|
13
|
+
}
|
|
14
|
+
/** Load known ownership; missing or malformed records are never guessed recoverable. */
|
|
15
|
+
export function readReplicaWork(file) {
|
|
16
|
+
const value = JSON.parse(readFileSync(file, "utf8"));
|
|
17
|
+
if (value.version !== 1 || !value.id || !value.parentSessionId || !value.integration || (value.mode === "shared" ? !value.sharedCwd : ((value.mode != null && value.mode !== "worktree") || !value.fork?.commit || !value.joinBase)) ||
|
|
18
|
+
(value.continuationOwner && (!Number.isInteger(value.continuationOwner.pid) || value.continuationOwner.pid <= 0 || (value.continuationOwner.startTime != null && typeof value.continuationOwner.startTime !== "string"))))
|
|
19
|
+
throw new Error("Invalid replica work record.");
|
|
20
|
+
return value;
|
|
21
|
+
}
|
|
22
|
+
/** Record the source basis before Herdr allocates any resources. */
|
|
23
|
+
export function createReplicaWork(file, id, parentSessionId, fork, joinBase = fork.commit) {
|
|
24
|
+
if (existsSync(file))
|
|
25
|
+
throw new Error("Replica work identity already exists.");
|
|
26
|
+
writeBindingFile(file, { version: 1, id, parentSessionId, fork, joinBase, integration: { status: "pending" } });
|
|
27
|
+
}
|
|
28
|
+
/** Record a shared attempt without creating a Git snapshot or branch. */
|
|
29
|
+
export function createSharedReplicaWork(file, id, parentSessionId, cwd) {
|
|
30
|
+
if (existsSync(file)) throw new Error("Replica work identity already exists.");
|
|
31
|
+
writeBindingFile(file, { version: 1, id, parentSessionId, mode: "shared", sharedCwd: cwd, integration: { status: "pending" } });
|
|
32
|
+
}
|
|
33
|
+
/** Persist the allocated shared pane before dispatch. */
|
|
34
|
+
export function attachSharedReplicaPane(file, pane, sessionFile) {
|
|
35
|
+
const record = readReplicaWork(file);
|
|
36
|
+
if (record.mode !== "shared" || (record.sharedPane && record.sharedPane !== pane)) throw new Error("Invalid shared replica pane.");
|
|
37
|
+
writeBindingFile(file, { ...record, sharedPane: pane, ...(sessionFile ? { sessionFile } : {}) });
|
|
38
|
+
}
|
|
39
|
+
/** Reserve the latest settled attempt for its exact immediate parent. */
|
|
40
|
+
export function reserveReplicaContinuation(file, parentSessionId, attemptId) {
|
|
41
|
+
let current = file;
|
|
42
|
+
const seen = new Set();
|
|
43
|
+
for (;;) {
|
|
44
|
+
const result = withLineageMutationGate(current + ".artifacts", (owner) => {
|
|
45
|
+
const record = readReplicaWork(current);
|
|
46
|
+
if (record.parentSessionId !== parentSessionId)
|
|
47
|
+
throw new Error("Continuation is not owned by this session.");
|
|
48
|
+
if (seen.has(record.id))
|
|
49
|
+
throw new Error("Invalid continuation cycle.");
|
|
50
|
+
seen.add(record.id);
|
|
51
|
+
if (!record.completion && !record.launchError)
|
|
52
|
+
throw new Error("Continuation is still active.");
|
|
53
|
+
if (record.integration.status === "pending")
|
|
54
|
+
throw new Error("Continuation has an active integration.");
|
|
55
|
+
const binding = record.sessionFile && existsSync(record.sessionFile) ? readSessionBinding(record.sessionFile) : undefined;
|
|
56
|
+
const location = binding?.child ? childSessionLocation(binding.child.lineage.rootDir, binding.child.lineage.nodeId) : record.worktreeLocation;
|
|
57
|
+
if (location && sessionLocationAlive(location))
|
|
58
|
+
throw new Error("Continuation session is still active.");
|
|
59
|
+
if (record.continuationAttempt) {
|
|
60
|
+
const next = join(dirname(current), record.continuationAttempt + ".json");
|
|
61
|
+
if (!existsSync(next)) {
|
|
62
|
+
if (!record.continuationOwner || processIncarnationAlive(record.continuationOwner, owner))
|
|
63
|
+
throw new Error("Continuation has an active launch reservation.");
|
|
64
|
+
}
|
|
65
|
+
else {
|
|
66
|
+
const child = readReplicaWork(next);
|
|
67
|
+
if (!child.completion && !child.launchError)
|
|
68
|
+
throw new Error("Continuation is still active.");
|
|
69
|
+
if (!child.launchError)
|
|
70
|
+
return { next };
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
if (!record.checkpoint || !record.sessionCheckpoint)
|
|
74
|
+
throw new Error("Continuation has no usable saved source/session checkpoint.");
|
|
75
|
+
writeBindingFile(current, { ...record, continuationAttempt: attemptId, continuationOwner: owner });
|
|
76
|
+
return { record };
|
|
77
|
+
});
|
|
78
|
+
if ("record" in result)
|
|
79
|
+
return result.record;
|
|
80
|
+
current = result.next;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
/** Release only this unpublished launch; never clear another attempt's reservation. */
|
|
84
|
+
export function releaseReplicaContinuation(file, attemptId) {
|
|
85
|
+
withLineageMutationGate(file + ".artifacts", () => {
|
|
86
|
+
const record = readReplicaWork(file);
|
|
87
|
+
if (record.continuationAttempt !== attemptId)
|
|
88
|
+
return;
|
|
89
|
+
const { continuationAttempt: _attempt, continuationOwner: _owner, ...rest } = record;
|
|
90
|
+
writeBindingFile(file, rest);
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
/** Persist runtime before launch, so recovery never silently adopts another configuration. */
|
|
94
|
+
export function saveReplicaRuntime(file, runtime) {
|
|
95
|
+
writeBindingFile(file, { ...readReplicaWork(file), runtime });
|
|
96
|
+
}
|
|
97
|
+
/** Preserve failed allocation facts and release a reservation without discarding evidence. */
|
|
98
|
+
export function failReplicaLaunch(file, error, toolCallId) {
|
|
99
|
+
const record = readReplicaWork(file);
|
|
100
|
+
writeBindingFile(file, { ...record, launchError: String(error), ...(toolCallId ? { launchErrorToolCallId: toolCallId } : {}), integration: { status: "not-attempted", details: record.mode === "shared" ? "Launch failed; changes may already be present in the shared directory." : "Launch failed; saved work was not applied." } });
|
|
101
|
+
}
|
|
102
|
+
/** A persisted native tool error already delivered this pre-acknowledgement failure. */
|
|
103
|
+
export function replicaLaunchFailureReported(record, entries, owner) {
|
|
104
|
+
if (!record.launchError)
|
|
105
|
+
return false;
|
|
106
|
+
const ids = new Set([record.launchErrorToolCallId, owner?.toolCallId].filter(Boolean));
|
|
107
|
+
const diagnostic = record.launchError.replace(/^(?:Error:\s*)+/, "");
|
|
108
|
+
return entries.some(entry => {
|
|
109
|
+
const row = entry;
|
|
110
|
+
const message = row?.message;
|
|
111
|
+
if (row?.type !== "message" || message?.role !== "toolResult" || message.isError !== true)
|
|
112
|
+
return false;
|
|
113
|
+
if (ids.has(message.toolCallId))
|
|
114
|
+
return true;
|
|
115
|
+
// An outer script may fail for another call. Require this launch's actual facts.
|
|
116
|
+
return !!owner?.parentToolCallId && message.toolCallId === owner.parentToolCallId && !!diagnostic && Array.isArray(message.content) &&
|
|
117
|
+
message.content.some(block => typeof block.text === "string" &&
|
|
118
|
+
(block.text.includes(diagnostic) || block.text.includes(JSON.stringify(diagnostic).slice(1, -1))));
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
/** Bind allocated worktree and session before the child process can start. */
|
|
122
|
+
export function attachReplicaWork(file, worktree, sessionFile) {
|
|
123
|
+
const record = readReplicaWork(file);
|
|
124
|
+
writeBindingFile(file, { ...record, worktree, ...(sessionFile ? { sessionFile } : {}) });
|
|
125
|
+
}
|
|
126
|
+
/** Save launch ownership before the child can fail during extension loading. */
|
|
127
|
+
export function captureReplicaLaunchPane(file, owner) {
|
|
128
|
+
const record = readReplicaWork(file);
|
|
129
|
+
if ((record.mode === "shared" ? record.sharedPane : record.worktree?.paneId) !== owner.paneId)
|
|
130
|
+
throw new Error("Launch ownership does not match the replica pane.");
|
|
131
|
+
if (record.launchPane) {
|
|
132
|
+
if (!launchOwnsPane(record.launchPane, owner.paneId, owner.shellPid))
|
|
133
|
+
throw new Error("Replica launch shell incarnation changed.");
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
writeBindingFile(file, { ...record, launchPane: owner });
|
|
137
|
+
}
|
|
138
|
+
/** Preserve the launch workspace's process incarnation even if the session later teleports. */
|
|
139
|
+
export function captureReplicaWorkLocation(file, location) {
|
|
140
|
+
const record = readReplicaWork(file);
|
|
141
|
+
const pane = record.mode === "shared" ? record.sharedPane : record.worktree?.paneId;
|
|
142
|
+
const cwd = record.mode === "shared" ? record.sharedCwd : record.worktree?.path;
|
|
143
|
+
if (record.worktreeLocation || !pane || !cwd || location.paneId !== pane) return;
|
|
144
|
+
if (realpathSync(location.cwd) !== realpathSync(cwd))
|
|
145
|
+
return;
|
|
146
|
+
writeBindingFile(file, { ...record, worktreeLocation: location });
|
|
147
|
+
}
|
|
148
|
+
/** Save task result and latest files before join or pane/worktree cleanup. */
|
|
149
|
+
export function checkpointReplicaCompletion(file, cwd, sessionFile, completion) {
|
|
150
|
+
let record = readReplicaWork(file);
|
|
151
|
+
if (record.completion)
|
|
152
|
+
return record;
|
|
153
|
+
const sessionDir = file + ".artifacts";
|
|
154
|
+
mkdirSync(sessionDir, { recursive: true });
|
|
155
|
+
const sessionCheckpoint = join(sessionDir, "session.jsonl");
|
|
156
|
+
try {
|
|
157
|
+
writeFileSync(sessionCheckpoint, readFileSync(sessionFile), { mode: 0o600 });
|
|
158
|
+
for (const path of [sessionCheckpoint, sessionDir]) {
|
|
159
|
+
const fd = openSync(path, "r");
|
|
160
|
+
try {
|
|
161
|
+
fsyncSync(fd);
|
|
162
|
+
}
|
|
163
|
+
finally {
|
|
164
|
+
closeSync(fd);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
record = { ...record, sessionCheckpoint };
|
|
168
|
+
}
|
|
169
|
+
catch (error) {
|
|
170
|
+
record = { ...record, sessionCheckpointError: String(error) };
|
|
171
|
+
}
|
|
172
|
+
if (record.mode === "shared") {
|
|
173
|
+
const failed = completion.exitCode !== 0 || !!completion.error || !!completion.errorMessage || !completion.summary.trim();
|
|
174
|
+
record = { ...record, sessionFile, completion, integration: failed
|
|
175
|
+
? { status: "not-attempted", details: "The task failed; changes may already be present in the shared directory." }
|
|
176
|
+
: { status: "shared" } };
|
|
177
|
+
writeBindingFile(file, record);
|
|
178
|
+
return record;
|
|
179
|
+
}
|
|
180
|
+
try {
|
|
181
|
+
const checkpoint = snapshotSource(cwd, join(file + ".artifacts", "source"));
|
|
182
|
+
if (checkpoint.commonDir !== record.fork.commonDir)
|
|
183
|
+
throw new Error("Replica moved outside its source repository.");
|
|
184
|
+
record = { ...record, checkpoint };
|
|
185
|
+
}
|
|
186
|
+
catch (error) {
|
|
187
|
+
record = { ...record, checkpointError: String(error) };
|
|
188
|
+
}
|
|
189
|
+
const failed = completion.exitCode !== 0 || !!completion.error || !!completion.errorMessage || !completion.summary.trim();
|
|
190
|
+
record = { ...record, sessionFile, completion, integration: failed
|
|
191
|
+
? { status: "not-attempted", details: "The task failed; partial work was not applied." }
|
|
192
|
+
: record.sessionCheckpointError
|
|
193
|
+
? { status: "blocked", details: `The session could not be saved: ${record.sessionCheckpointError}` }
|
|
194
|
+
: record.integration };
|
|
195
|
+
writeBindingFile(file, record);
|
|
196
|
+
return record;
|
|
197
|
+
}
|
|
198
|
+
/** Attempt successful work; the optional receiver check can refuse application after a session move. */
|
|
199
|
+
export function joinReplicaWork(file, parentCwd, parentActive = () => true) {
|
|
200
|
+
let record = readReplicaWork(file);
|
|
201
|
+
if (!record.completion)
|
|
202
|
+
throw new Error("Replica has no saved task result.");
|
|
203
|
+
if (record.integration.status !== "pending")
|
|
204
|
+
return record;
|
|
205
|
+
if (!record.checkpoint) {
|
|
206
|
+
record = { ...record, integration: { status: "blocked", details: record.checkpointError ?? "No saved source checkpoint." } };
|
|
207
|
+
writeBindingFile(file, record);
|
|
208
|
+
return record;
|
|
209
|
+
}
|
|
210
|
+
try {
|
|
211
|
+
const parent = snapshotSource(parentCwd, join(file + ".artifacts", "parent"));
|
|
212
|
+
if (parent.commonDir !== record.fork.commonDir)
|
|
213
|
+
throw new Error("The parent is now in a different repository; saved work was not applied there.");
|
|
214
|
+
if (record.joinPlan && record.joinPlan.before.repoDir !== parent.repoDir)
|
|
215
|
+
throw new Error("An interrupted join belongs to another parent checkout; its saved plan is retained.");
|
|
216
|
+
if (!record.joinPlan) {
|
|
217
|
+
const plan = planSourceJoin(parent.repoDir, record.joinBase, record.checkpoint.commit, join(file + ".artifacts", "join"));
|
|
218
|
+
if (plan.status === "conflict") {
|
|
219
|
+
record = { ...record, integration: { status: "conflict", details: plan.details } };
|
|
220
|
+
writeBindingFile(file, record);
|
|
221
|
+
return record;
|
|
222
|
+
}
|
|
223
|
+
record = { ...record, joinPlan: plan };
|
|
224
|
+
writeBindingFile(file, record);
|
|
225
|
+
}
|
|
226
|
+
applySourceJoin(record.joinPlan, () => {
|
|
227
|
+
if (!parentActive())
|
|
228
|
+
throw new Error("The parent session moved or closed during integration; saved work was retained.");
|
|
229
|
+
});
|
|
230
|
+
record = { ...record, integration: { status: "applied" } };
|
|
231
|
+
}
|
|
232
|
+
catch (error) {
|
|
233
|
+
record = { ...record, integration: { status: "blocked", details: String(error) } };
|
|
234
|
+
}
|
|
235
|
+
writeBindingFile(file, record);
|
|
236
|
+
return record;
|
|
237
|
+
}
|
|
238
|
+
/** Close an owned settled pane; remove its checkout only after safe integration. */
|
|
239
|
+
export function cleanupReplicaWork(file, parentCwd) {
|
|
240
|
+
const record = readReplicaWork(file), worktree = record.worktree;
|
|
241
|
+
if (record.cleaned) return true;
|
|
242
|
+
if (record.mode === "shared") {
|
|
243
|
+
if (!record.sharedPane) return true;
|
|
244
|
+
if (record.worktreeLocation && sessionLocationAlive(record.worktreeLocation)) return false;
|
|
245
|
+
let info;
|
|
246
|
+
try { info = getHerdrPaneProcessInfo(record.sharedPane); }
|
|
247
|
+
catch (error) {
|
|
248
|
+
if (/pane_not_found|pane.*not found/i.test(String(error))) { writeBindingFile(file, { ...record, cleaned: true }); return true; }
|
|
249
|
+
throw error;
|
|
250
|
+
}
|
|
251
|
+
const owned = record.worktreeLocation
|
|
252
|
+
? sessionOwnsPane(record.worktreeLocation, record.sharedPane, info.shellPid)
|
|
253
|
+
: !!record.launchPane && launchOwnsPane(record.launchPane, record.sharedPane, info.shellPid);
|
|
254
|
+
if (!owned || info.pids.some(pid => pid !== info.shellPid)) return false;
|
|
255
|
+
closeHerdrSurface(record.sharedPane);
|
|
256
|
+
writeBindingFile(file, { ...record, cleaned: true });
|
|
257
|
+
return true;
|
|
258
|
+
}
|
|
259
|
+
if (!worktree) return true;
|
|
260
|
+
const removeWorktree = record.integration.status === "applied";
|
|
261
|
+
const matches = listHerdrWorktrees(parentCwd).filter((entry) => entry.path === worktree.path && entry.branch === worktree.branch && entry.isLinkedWorktree);
|
|
262
|
+
if (matches.length > 1)
|
|
263
|
+
throw new Error("Ambiguous replica worktree ownership.");
|
|
264
|
+
if (!matches.length) {
|
|
265
|
+
if (!removeWorktree)
|
|
266
|
+
return true;
|
|
267
|
+
if (existsSync(worktree.path))
|
|
268
|
+
return false;
|
|
269
|
+
writeBindingFile(file, { ...record, cleaned: true });
|
|
270
|
+
return true;
|
|
271
|
+
}
|
|
272
|
+
const observed = matches[0];
|
|
273
|
+
if (record.worktreeLocation && sessionLocationAlive(record.worktreeLocation))
|
|
274
|
+
return false;
|
|
275
|
+
if (removeWorktree) {
|
|
276
|
+
if (!record.checkpoint || !record.sessionCheckpoint || !existsSync(record.sessionCheckpoint))
|
|
277
|
+
return false;
|
|
278
|
+
const checkpoint = snapshotSource(worktree.path, join(file + ".artifacts", "cleanup"));
|
|
279
|
+
if (checkpoint.tree !== record.checkpoint.tree || checkpoint.head !== record.checkpoint.head || checkpoint.indexHash !== record.checkpoint.indexHash)
|
|
280
|
+
return false; // Never discard edits made after settlement.
|
|
281
|
+
}
|
|
282
|
+
if (observed.workspaceId) {
|
|
283
|
+
if (observed.workspaceId !== worktree.workspaceId)
|
|
284
|
+
return false;
|
|
285
|
+
const failedLaunch = !record.worktreeLocation && !!record.launchPane &&
|
|
286
|
+
(!!record.launchError || (record.completion != null && record.completion.exitCode !== 0));
|
|
287
|
+
if (!record.worktreeLocation && !failedLaunch)
|
|
288
|
+
return false;
|
|
289
|
+
const response = JSON.parse(execFileSync("herdr", ["pane", "list", "--workspace", observed.workspaceId], { encoding: "utf8" }));
|
|
290
|
+
const panes = response?.result?.panes;
|
|
291
|
+
if (!Array.isArray(panes) || panes.length !== 1 || panes[0].pane_id !== worktree.paneId)
|
|
292
|
+
return false;
|
|
293
|
+
const info = getHerdrPaneProcessInfo(worktree.paneId);
|
|
294
|
+
const owned = record.worktreeLocation
|
|
295
|
+
? sessionOwnsPane(record.worktreeLocation, worktree.paneId, info.shellPid)
|
|
296
|
+
: launchOwnsPane(record.launchPane, worktree.paneId, info.shellPid);
|
|
297
|
+
if (!owned || info.pids.some((pid) => pid !== info.shellPid))
|
|
298
|
+
return false;
|
|
299
|
+
if (!removeWorktree) {
|
|
300
|
+
closeHerdrSurface(worktree.paneId);
|
|
301
|
+
return true;
|
|
302
|
+
}
|
|
303
|
+
execFileSync("herdr", ["worktree", "remove", "--workspace", observed.workspaceId, "--force", "--trust-repository"], { encoding: "utf8" });
|
|
304
|
+
}
|
|
305
|
+
else {
|
|
306
|
+
if (!removeWorktree)
|
|
307
|
+
return true;
|
|
308
|
+
// A restarted Herdr may no longer have its workspace. Keep any surviving cwd users.
|
|
309
|
+
for (const pid of readdirSync("/proc").filter((entry) => /^\d+$/.test(entry))) {
|
|
310
|
+
let cwd;
|
|
311
|
+
try {
|
|
312
|
+
cwd = readlinkSync(`/proc/${pid}/cwd`);
|
|
313
|
+
}
|
|
314
|
+
catch {
|
|
315
|
+
continue;
|
|
316
|
+
}
|
|
317
|
+
if (cwd === worktree.path || cwd === `${worktree.path} (deleted)` || cwd.startsWith(worktree.path + "/"))
|
|
318
|
+
return false;
|
|
319
|
+
}
|
|
320
|
+
execFileSync("git", ["worktree", "remove", "--force", worktree.path], { cwd: parentCwd, encoding: "utf8" });
|
|
321
|
+
}
|
|
322
|
+
if (existsSync(worktree.path))
|
|
323
|
+
throw new Error("Replica worktree cleanup could not be verified.");
|
|
324
|
+
writeBindingFile(file, { ...record, cleaned: true });
|
|
325
|
+
return true;
|
|
326
|
+
}
|