@marcoscale98/pi-extensible-workflows 5.14.1-fork.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.
Files changed (204) hide show
  1. package/CHANGELOG.md +536 -0
  2. package/README.md +64 -0
  3. package/dist/src/agent-execution.d.ts +338 -0
  4. package/dist/src/agent-execution.js +1633 -0
  5. package/dist/src/background-widget.d.ts +74 -0
  6. package/dist/src/background-widget.js +1340 -0
  7. package/dist/src/budget.d.ts +38 -0
  8. package/dist/src/budget.js +188 -0
  9. package/dist/src/changelog.d.ts +6 -0
  10. package/dist/src/changelog.js +112 -0
  11. package/dist/src/decoders.d.ts +96 -0
  12. package/dist/src/decoders.js +629 -0
  13. package/dist/src/execution.d.ts +19 -0
  14. package/dist/src/execution.js +749 -0
  15. package/dist/src/herdr.d.ts +53 -0
  16. package/dist/src/herdr.js +270 -0
  17. package/dist/src/host-delivery.d.ts +95 -0
  18. package/dist/src/host-delivery.js +376 -0
  19. package/dist/src/host-navigator.d.ts +83 -0
  20. package/dist/src/host-navigator.js +1087 -0
  21. package/dist/src/host-phases.d.ts +89 -0
  22. package/dist/src/host-phases.js +320 -0
  23. package/dist/src/host-recovery.d.ts +141 -0
  24. package/dist/src/host-recovery.js +356 -0
  25. package/dist/src/host-runtime.d.ts +78 -0
  26. package/dist/src/host-runtime.js +378 -0
  27. package/dist/src/host-view.d.ts +116 -0
  28. package/dist/src/host-view.js +868 -0
  29. package/dist/src/host.d.ts +40 -0
  30. package/dist/src/host.js +1641 -0
  31. package/dist/src/index.d.ts +29 -0
  32. package/dist/src/index.js +13725 -0
  33. package/dist/src/index.js.map +6 -0
  34. package/dist/src/io.d.ts +14 -0
  35. package/dist/src/io.js +82 -0
  36. package/dist/src/package-identity.d.ts +4 -0
  37. package/dist/src/package-identity.js +6 -0
  38. package/dist/src/paths.d.ts +14 -0
  39. package/dist/src/paths.js +51 -0
  40. package/dist/src/persistence.d.ts +5 -0
  41. package/dist/src/persistence.js +5 -0
  42. package/dist/src/pi-runtime-adapter.d.ts +27 -0
  43. package/dist/src/pi-runtime-adapter.js +243 -0
  44. package/dist/src/pi-runtime-runner.d.ts +42 -0
  45. package/dist/src/pi-runtime-runner.js +505 -0
  46. package/dist/src/registry.d.ts +48 -0
  47. package/dist/src/registry.js +379 -0
  48. package/dist/src/retention.d.ts +17 -0
  49. package/dist/src/retention.js +195 -0
  50. package/dist/src/runtime/agent-runner.d.ts +143 -0
  51. package/dist/src/runtime/agent-runner.js +12 -0
  52. package/dist/src/runtime/index.d.ts +2 -0
  53. package/dist/src/runtime/index.js +2 -0
  54. package/dist/src/runtime/namespace.d.ts +14 -0
  55. package/dist/src/runtime/namespace.js +1 -0
  56. package/dist/src/runtime/workflow-result.d.ts +2 -0
  57. package/dist/src/runtime/workflow-result.js +6 -0
  58. package/dist/src/session-handoff.d.ts +2 -0
  59. package/dist/src/session-handoff.js +82 -0
  60. package/dist/src/session-lease.d.ts +12 -0
  61. package/dist/src/session-lease.js +180 -0
  62. package/dist/src/store.d.ts +95 -0
  63. package/dist/src/store.js +951 -0
  64. package/dist/src/subagent-manager-handle.d.ts +4 -0
  65. package/dist/src/subagent-manager-handle.js +6 -0
  66. package/dist/src/tool-timing.d.ts +12 -0
  67. package/dist/src/tool-timing.js +33 -0
  68. package/dist/src/trajectory-contracts.d.ts +8 -0
  69. package/dist/src/trajectory-contracts.js +9 -0
  70. package/dist/src/trajectory-host-handle.d.ts +12 -0
  71. package/dist/src/trajectory-host-handle.js +6 -0
  72. package/dist/src/trajectory.d.ts +128 -0
  73. package/dist/src/trajectory.js +594 -0
  74. package/dist/src/types.d.ts +960 -0
  75. package/dist/src/types.js +60 -0
  76. package/dist/src/utils.d.ts +57 -0
  77. package/dist/src/utils.js +225 -0
  78. package/dist/src/validation.d.ts +29 -0
  79. package/dist/src/validation.js +1100 -0
  80. package/dist/src/workflow-artifacts.d.ts +14 -0
  81. package/dist/src/workflow-artifacts.js +40 -0
  82. package/dist/starter/index.d.ts +1 -0
  83. package/dist/starter/index.js +103 -0
  84. package/dist/starter/index.js.map +6 -0
  85. package/dist/starter/review-loop.d.ts +72 -0
  86. package/dist/starter/review-loop.js +43 -0
  87. package/dist/starter/roles/developer.md +6 -0
  88. package/dist/starter/roles/oracle.md +18 -0
  89. package/dist/starter/roles/researcher.md +20 -0
  90. package/dist/starter/roles/reviewer.md +19 -0
  91. package/dist/starter/roles/scout.md +18 -0
  92. package/dist/subagents/index.d.ts +8 -0
  93. package/dist/subagents/index.js +8021 -0
  94. package/dist/subagents/index.js.map +6 -0
  95. package/dist/subagents/src/contracts.d.ts +173 -0
  96. package/dist/subagents/src/contracts.js +68 -0
  97. package/dist/subagents/src/decode.d.ts +15 -0
  98. package/dist/subagents/src/decode.js +182 -0
  99. package/dist/subagents/src/index.d.ts +12 -0
  100. package/dist/subagents/src/index.js +180 -0
  101. package/dist/subagents/src/manager.d.ts +3 -0
  102. package/dist/subagents/src/manager.js +1636 -0
  103. package/dist/subagents/src/navigator.d.ts +5 -0
  104. package/dist/subagents/src/navigator.js +627 -0
  105. package/dist/subagents/src/view.d.ts +70 -0
  106. package/dist/subagents/src/view.js +387 -0
  107. package/dist/subagents/src/worktree.d.ts +34 -0
  108. package/dist/subagents/src/worktree.js +68 -0
  109. package/dist/trajectory/assets/favicon.png +0 -0
  110. package/dist/trajectory/assets/index.html +772 -0
  111. package/dist/trajectory/assets/marked.min.js +6 -0
  112. package/dist/trajectory/assets/morphdom.min.js +1 -0
  113. package/dist/trajectory/index.d.ts +5 -0
  114. package/dist/trajectory/index.js +3459 -0
  115. package/dist/trajectory/index.js.map +6 -0
  116. package/dist/trajectory/src/assets/favicon.png +0 -0
  117. package/dist/trajectory/src/assets/index.html +772 -0
  118. package/dist/trajectory/src/assets/marked.min.js +6 -0
  119. package/dist/trajectory/src/assets/morphdom.min.js +1 -0
  120. package/dist/trajectory/src/export.d.ts +25 -0
  121. package/dist/trajectory/src/export.js +80 -0
  122. package/dist/trajectory/src/index.d.ts +26 -0
  123. package/dist/trajectory/src/index.js +732 -0
  124. package/dist/trajectory/src/server.d.ts +7 -0
  125. package/dist/trajectory/src/server.js +2534 -0
  126. package/dist/trajectory/src/server.js.map +6 -0
  127. package/examples/advanced-patterns/auto-model-router.mjs +78 -0
  128. package/examples/workflow-extension-template/README.md +37 -0
  129. package/examples/workflow-extension-template/extension.test.mjs +59 -0
  130. package/examples/workflow-extension-template/index.js +51 -0
  131. package/examples/workflow-extension-template/roles/reviewer.md +4 -0
  132. package/package.json +105 -0
  133. package/skills/pi-extensible-workflows/SKILL.md +160 -0
  134. package/src/agent-execution.ts +1533 -0
  135. package/src/background-widget.ts +1496 -0
  136. package/src/budget.ts +109 -0
  137. package/src/changelog.ts +114 -0
  138. package/src/decoders.ts +567 -0
  139. package/src/execution.ts +652 -0
  140. package/src/herdr.ts +240 -0
  141. package/src/host-delivery.ts +346 -0
  142. package/src/host-navigator.ts +871 -0
  143. package/src/host-phases.ts +264 -0
  144. package/src/host-recovery.ts +333 -0
  145. package/src/host-runtime.ts +373 -0
  146. package/src/host-view.ts +828 -0
  147. package/src/host.ts +1502 -0
  148. package/src/index.ts +29 -0
  149. package/src/io.ts +73 -0
  150. package/src/package-identity.ts +7 -0
  151. package/src/paths.ts +49 -0
  152. package/src/persistence.ts +11 -0
  153. package/src/pi-runtime-adapter.ts +247 -0
  154. package/src/pi-runtime-runner.ts +436 -0
  155. package/src/registry.ts +321 -0
  156. package/src/retention.ts +178 -0
  157. package/src/runtime/agent-runner.ts +158 -0
  158. package/src/runtime/index.ts +2 -0
  159. package/src/runtime/namespace.ts +16 -0
  160. package/src/runtime/workflow-result.ts +8 -0
  161. package/src/session-handoff.ts +78 -0
  162. package/src/session-lease.ts +125 -0
  163. package/src/store.ts +832 -0
  164. package/src/subagent-manager-handle.ts +8 -0
  165. package/src/tool-timing.ts +47 -0
  166. package/src/trajectory-contracts.ts +13 -0
  167. package/src/trajectory-host-handle.ts +16 -0
  168. package/src/trajectory.ts +543 -0
  169. package/src/types.ts +374 -0
  170. package/src/utils.ts +192 -0
  171. package/src/validation.ts +910 -0
  172. package/src/workflow-artifacts.ts +35 -0
  173. package/starter/index.ts +35 -0
  174. package/starter/prompts/council.md +16 -0
  175. package/starter/prompts/deep-research.md +16 -0
  176. package/starter/prompts/oracle.md +14 -0
  177. package/starter/prompts/parallel-review.md +22 -0
  178. package/starter/prompts/parallel-scout.md +16 -0
  179. package/starter/prompts/review-loop.md +14 -0
  180. package/starter/prompts/review.md +14 -0
  181. package/starter/prompts/scout.md +14 -0
  182. package/starter/review-loop.ts +73 -0
  183. package/starter/roles/developer.md +6 -0
  184. package/starter/roles/oracle.md +18 -0
  185. package/starter/roles/researcher.md +20 -0
  186. package/starter/roles/reviewer.md +19 -0
  187. package/starter/roles/scout.md +18 -0
  188. package/subagents/README.md +145 -0
  189. package/subagents/index.ts +8 -0
  190. package/subagents/src/contracts.ts +176 -0
  191. package/subagents/src/decode.ts +158 -0
  192. package/subagents/src/index.ts +194 -0
  193. package/subagents/src/manager.ts +1560 -0
  194. package/subagents/src/navigator.ts +488 -0
  195. package/subagents/src/view.ts +376 -0
  196. package/subagents/src/worktree.ts +99 -0
  197. package/trajectory/index.ts +5 -0
  198. package/trajectory/src/assets/favicon.png +0 -0
  199. package/trajectory/src/assets/index.html +772 -0
  200. package/trajectory/src/assets/marked.min.js +6 -0
  201. package/trajectory/src/assets/morphdom.min.js +1 -0
  202. package/trajectory/src/export.ts +85 -0
  203. package/trajectory/src/index.ts +604 -0
  204. package/trajectory/src/server.ts +447 -0
package/src/store.ts ADDED
@@ -0,0 +1,832 @@
1
+ import { execFile } from "node:child_process";
2
+ import { createHash, randomUUID } from "node:crypto";
3
+ import { access, mkdir, readdir, readFile, realpath, rename, rm, stat, writeFile } from "node:fs/promises";
4
+ import { homedir } from "node:os";
5
+ import { dirname, join, relative, resolve, sep } from "node:path";
6
+ import { promisify } from "node:util";
7
+ import { HARD_TERMINAL_RUN_STATES, WorkflowError, type JsonValue, type LaunchSnapshot, type WorkflowErrorCode, type WorkflowRunEvent } from "./types.js";
8
+ import { coerceWorkflowError, errorText, isNodeError, loadLaunchSnapshot, object, positiveInteger, SerialLane } from "./utils.js";
9
+ import { budgetUsage } from "./budget.js";
10
+ import {
11
+ decodeBooleanCheckpointResult, decodeBorrowedWorktreeBindings, decodeJournal, decodeLaunchSnapshot,
12
+ decodeOwnershipRecords, decodePersistedRun, decodeSummaryProjection, decodeSystemPromptArtifact,
13
+ decodeWorktreeReferences,
14
+ type AwaitingCheckpoint, type BorrowedWorktreeBinding, type CompletedOperation, type EffectiveSystemPrompt,
15
+ type Journal, type PendingWorkflowDecision, type PersistedOwnershipNode,
16
+ type PersistedRun, type RunSummary, type RunSummaryArtifacts, type WorktreeReference,
17
+ } from "./decoders.js";
18
+ import { atomicJson, atomicPrettyJson, atomicWriteFile, git, gitIdentity, json } from "./io.js";
19
+ import { runsDirectory, safePart, sameFilesystemPath, structuralPath, workflowProjectSettingsPath } from "./paths.js";
20
+
21
+ const executeFile = promisify(execFile);
22
+ const WORKTREE_POST_CREATE_TIMEOUT_MS = 60_000;
23
+ const WORKTREE_POST_CREATE_OUTPUT_LIMIT = 4096;
24
+ const SYSTEM_PROMPT_STORAGE = ".system-prompts";
25
+ const SYSTEM_PROMPT_RECORDS = "records";
26
+ const SYSTEM_PROMPT_BODIES = "bodies";
27
+ const SYSTEM_PROMPT_SEQUENCE = "sequence";
28
+ const SYSTEM_PROMPT_ARTIFACT = { version: 2 as const, format: "append-only" as const, storage: SYSTEM_PROMPT_STORAGE };
29
+ function summaryArtifacts(directory: string): RunSummaryArtifacts { return { runDirectory: directory, statePath: join(directory, "state.json"), journalPath: join(directory, "journal.json"), snapshotPath: join(directory, "snapshot.json"), workflowPath: join(directory, "workflow.js"), resultPath: join(directory, "result.json"), summaryPath: join(directory, "summary.json") }; }
30
+ function summaryFromRun(run: PersistedRun, directory: string, journal: Journal, previous: Partial<RunSummary> | undefined, fallbackCreatedAt: string, now = new Date().toISOString()): RunSummary {
31
+ const createdAt = typeof previous?.createdAt === "string" ? previous.createdAt : fallbackCreatedAt;
32
+ const failedAt = run.failedAt ?? run.error?.failedAt;
33
+ const replayablePaths = [...new Set([...(run.retry?.completedPaths ?? []), ...Object.keys(journal.completed)])];
34
+ const incompletePaths = [...new Set([...(run.retry?.incompletePaths ?? []), ...(failedAt ? [failedAt] : [])])];
35
+ return { schemaVersion: 1, runId: run.id, sessionId: run.sessionId, workflowName: run.workflowName, state: run.state, createdAt, updatedAt: now, ...(previous?.terminalAt || HARD_TERMINAL_RUN_STATES.has(run.state) ? { terminalAt: previous?.terminalAt ?? now } : {}), usage: budgetUsage(run.usage), agents: run.agents.map(({ id, name, label, state, role, attempts }) => ({ id, name, ...(label ? { label } : {}), state, ...(role ? { role } : {}), attempts })), ...(run.error ? { error: run.error } : {}), ...(failedAt ? { failedAt } : {}), replayablePaths, incompletePaths, artifacts: summaryArtifacts(directory) };
36
+ }
37
+ function systemPromptStoragePath(directory: string): string { return join(directory, SYSTEM_PROMPT_STORAGE); }
38
+ function systemPromptRecordsPath(directory: string): string { return join(systemPromptStoragePath(directory), SYSTEM_PROMPT_RECORDS); }
39
+ function systemPromptBodiesPath(directory: string): string { return join(systemPromptStoragePath(directory), SYSTEM_PROMPT_BODIES); }
40
+ function systemPromptSequencePath(directory: string): string { return join(systemPromptStoragePath(directory), SYSTEM_PROMPT_SEQUENCE); }
41
+ function systemPromptRecordName(sequence: number): string { return `${String(sequence).padStart(20, "0")}.json`; }
42
+ async function createSystemPromptStorage(directory: string, writeArtifact: boolean): Promise<void> {
43
+ const storage = systemPromptStoragePath(directory);
44
+ await mkdir(join(storage, SYSTEM_PROMPT_RECORDS), { recursive: true, mode: 0o700 });
45
+ await mkdir(join(storage, SYSTEM_PROMPT_BODIES), { recursive: true, mode: 0o700 });
46
+ await atomicWriteFile(systemPromptSequencePath(directory), "0\n");
47
+ if (writeArtifact) await atomicJson(join(directory, "system-prompts.json"), SYSTEM_PROMPT_ARTIFACT);
48
+ }
49
+
50
+ export class RunStore {
51
+ readonly directory: string;
52
+ private journalLane = new SerialLane();
53
+ // ponytail: serializes one RunStore instance; cross-process run sharing remains unsupported.
54
+ private stateLane = new SerialLane();
55
+ private summaryLane = new SerialLane();
56
+ private worktreeLane = new SerialLane();
57
+ private borrowedWorktreeLane = new SerialLane();
58
+ private snapshotLane = new SerialLane();
59
+ private launchSnapshotLane = new SerialLane();
60
+ // ponytail: the session lease prevents concurrent RunStore writers for one run.
61
+ private systemPromptLane = new SerialLane();
62
+ constructor(readonly cwd: string, readonly sessionId: string, readonly runId: string, readonly home = homedir()) {
63
+ this.cwd = resolve(cwd);
64
+ this.directory = join(runsDirectory(this.cwd, sessionId, home), safePart(runId));
65
+ }
66
+ #assertRunIdentity(run: PersistedRun, code: WorkflowErrorCode, message: string): void {
67
+ if (resolve(run.cwd) !== this.cwd || run.sessionId !== this.sessionId || run.id !== this.runId) throw new WorkflowError(code, message);
68
+ }
69
+
70
+ async create(run: PersistedRun, snapshot: Readonly<LaunchSnapshot>): Promise<void> {
71
+ this.#assertRunIdentity(run, "INTERNAL_ERROR", "Run identity does not match its session-scoped store");
72
+ const temporary = join(dirname(this.directory), `.${safePart(this.runId)}.${String(process.pid)}.${randomUUID()}.tmp`);
73
+ await mkdir(dirname(this.directory), { recursive: true, mode: 0o700 });
74
+ await mkdir(temporary, { mode: 0o700 });
75
+ try {
76
+ await writeFile(join(temporary, "workflow.js"), snapshot.script, { encoding: "utf8", mode: 0o600 });
77
+ await atomicJson(join(temporary, "snapshot.json"), snapshot);
78
+ await atomicJson(join(temporary, "journal.json"), { completed: {}, awaiting: {}, decisions: {} });
79
+ await atomicJson(join(temporary, "ownership.json"), []);
80
+ await atomicJson(join(temporary, "worktrees.json"), []);
81
+ await atomicJson(join(temporary, "borrowed-worktrees.json"), []);
82
+ await atomicJson(join(temporary, "state.json"), run);
83
+ await createSystemPromptStorage(temporary, true);
84
+ await atomicJson(join(temporary, "summary.json"), summaryFromRun(run, this.directory, { completed: {} }, undefined, new Date().toISOString()));
85
+ await rename(temporary, this.directory);
86
+ } catch (error) {
87
+ await rm(temporary, { recursive: true, force: true });
88
+ throw error;
89
+ }
90
+ }
91
+ private async refreshSummary(): Promise<void> {
92
+ const write = this.summaryLane.run(async () => {
93
+ const run = decodePersistedRun(await json(join(this.directory, "state.json")), true);
94
+ const journal = decodeJournal(await json(join(this.directory, "journal.json")));
95
+ const previous = await json(join(this.directory, "summary.json")).then(decodeSummaryProjection).catch(() => undefined);
96
+ if (!run || !journal) throw new Error("Persisted run or journal is invalid");
97
+ const fallbackCreatedAt = await stat(join(this.directory, "state.json")).then((value) => new Date(value.mtimeMs).toISOString());
98
+ await atomicJson(join(this.directory, "summary.json"), summaryFromRun(run, this.directory, journal, previous, fallbackCreatedAt));
99
+ });
100
+ await write;
101
+ }
102
+ //NOTE: summary.json is an optional derived cache (see OPTIONAL_RUN_FILES); a missed refresh self-heals because every reader (loadSummary, CLI inspector) recomputes from state/journal. Keep this best-effort so a cache hiccup never fails the primary write.
103
+ private refreshSummaryBestEffort(): void { void this.refreshSummary().catch(() => undefined); }
104
+
105
+ async isComplete(): Promise<boolean> {
106
+ try { await Promise.all([access(join(this.directory, "snapshot.json")), access(join(this.directory, "journal.json")), access(join(this.directory, "ownership.json")), access(join(this.directory, "state.json"))]); return true; }
107
+ catch { return false; }
108
+ }
109
+
110
+ async load(): Promise<{ run: PersistedRun; snapshot: Readonly<LaunchSnapshot> }> {
111
+ await this.stateLane.run(async () => undefined);
112
+ const rawRun = await json(join(this.directory, "state.json"));
113
+ const run = decodePersistedRun(rawRun, true);
114
+ if (!run) throw new WorkflowError("RESUME_INCOMPATIBLE", "Persisted run is invalid");
115
+ this.#assertRunIdentity(run, "RESUME_INCOMPATIBLE", "Persisted run belongs to another cwd or Pi session");
116
+ if (!object(rawRun) || !Array.isArray(rawRun.agentSessions) || Object.hasOwn(rawRun, "nativeSessions")) throw new WorkflowError("RESUME_INCOMPATIBLE", "Persisted run uses an unsupported agent session format");
117
+ const snapshot = decodeLaunchSnapshot(await json(join(this.directory, "snapshot.json")));
118
+ if (!snapshot) throw new WorkflowError("RESUME_INCOMPATIBLE", "Persisted launch snapshot is invalid");
119
+ return { run, snapshot: loadLaunchSnapshot(snapshot) };
120
+ }
121
+ async loadStatus(): Promise<PersistedRun> {
122
+ await this.stateLane.run(async () => undefined);
123
+ const run = decodePersistedRun(await json(join(this.directory, "state.json")), true);
124
+ if (!run) throw new WorkflowError("RUN_NOT_FOUND", "Persisted run is invalid");
125
+ this.#assertRunIdentity(run, "RUN_NOT_FOUND", "Persisted run does not belong to this project");
126
+ return run;
127
+ }
128
+ async loadSummary(): Promise<RunSummary> {
129
+ await this.stateLane.run(async () => undefined);
130
+ await this.journalLane.run(async () => undefined);
131
+ await this.summaryLane.run(async () => undefined);
132
+ const run = decodePersistedRun(await json(join(this.directory, "state.json")), true);
133
+ const journal = decodeJournal(await json(join(this.directory, "journal.json")));
134
+ const previous = await json(join(this.directory, "summary.json")).then(decodeSummaryProjection).catch(() => undefined);
135
+ if (!run || !journal) throw new WorkflowError("RESUME_INCOMPATIBLE", "Persisted run or journal is invalid");
136
+ const [stateStat, journalStat] = await Promise.all([stat(join(this.directory, "state.json")), stat(join(this.directory, "journal.json"))]);
137
+ const fallbackCreatedAt = new Date(stateStat.mtimeMs).toISOString();
138
+ const previousUpdatedAt = previous?.updatedAt === undefined ? Number.NaN : Date.parse(previous.updatedAt);
139
+ const updatedAt = new Date(Math.max(stateStat.mtimeMs, journalStat.mtimeMs, Number.isNaN(previousUpdatedAt) ? 0 : previousUpdatedAt)).toISOString();
140
+ return summaryFromRun(run, this.directory, journal, previous, fallbackCreatedAt, updatedAt);
141
+ }
142
+
143
+ async saveState(run: PersistedRun): Promise<void> {
144
+ const write = this.stateLane.run(async () => {
145
+ this.#assertRunIdentity(run, "INTERNAL_ERROR", "Run identity does not match its session-scoped store");
146
+ await atomicJson(join(this.directory, "state.json"), run);
147
+ this.refreshSummaryBestEffort();
148
+ });
149
+ await write;
150
+ }
151
+
152
+ async updateState(update: (run: PersistedRun) => PersistedRun | Promise<PersistedRun>): Promise<PersistedRun> {
153
+ const write = this.stateLane.run(async () => {
154
+ const current = decodePersistedRun(await json(join(this.directory, "state.json")));
155
+ if (!current) throw new WorkflowError("RESUME_INCOMPATIBLE", "Persisted run is invalid");
156
+ this.#assertRunIdentity(current, "RESUME_INCOMPATIBLE", "Persisted run belongs to another cwd or Pi session");
157
+ const result = await update(current);
158
+ this.#assertRunIdentity(result, "INTERNAL_ERROR", "Run identity does not match its session-scoped store");
159
+ await atomicJson(join(this.directory, "state.json"), result);
160
+ this.refreshSummaryBestEffort();
161
+ return result;
162
+ });
163
+ return write;
164
+ }
165
+
166
+ async saveSnapshot(snapshot: Readonly<LaunchSnapshot>): Promise<void> {
167
+ const write = this.launchSnapshotLane.run(() => atomicJson(join(this.directory, "snapshot.json"), snapshot));
168
+ await write;
169
+ }
170
+
171
+ async appendEvent(event: WorkflowRunEvent): Promise<void> {
172
+ await this.updateState((run) => ({ ...run, events: [...(run.events ?? []), ...(event.type !== "log" && run.events?.some((current) => current.type === event.type && current.message === event.message) ? [] : [event])] }));
173
+ }
174
+
175
+ async saveOwnership(nodes: readonly PersistedOwnershipNode[]): Promise<void> {
176
+ await atomicJson(join(this.directory, "ownership.json"), nodes);
177
+ }
178
+
179
+ async loadOwnership(): Promise<readonly PersistedOwnershipNode[]> {
180
+ const nodes = decodeOwnershipRecords(await json(join(this.directory, "ownership.json")));
181
+ if (!nodes) throw new WorkflowError("RESUME_INCOMPATIBLE", "Persisted ownership records are invalid");
182
+ return nodes;
183
+ }
184
+
185
+ systemPromptPath(): string { return join(this.directory, "system-prompts.json"); }
186
+ private async readSystemPromptArtifact(): Promise<{ version: number; format?: unknown; storage?: unknown; entries?: EffectiveSystemPrompt[] } | undefined> {
187
+ try {
188
+ const artifact = decodeSystemPromptArtifact(await json(this.systemPromptPath()));
189
+ if (!artifact) throw new Error("Persisted system prompts are invalid");
190
+ return artifact;
191
+ } catch (error: unknown) {
192
+ if (isNodeError(error, "ENOENT")) return undefined;
193
+ throw error;
194
+ }
195
+ }
196
+
197
+ private async appendSystemPromptV2(entry: Omit<EffectiveSystemPrompt, "sha256">): Promise<void> {
198
+ const sha256 = createHash("sha256").update(entry.prompt).digest("hex");
199
+ const bodyPath = join(systemPromptBodiesPath(this.directory), sha256);
200
+ try { await access(bodyPath); }
201
+ catch (error) { if (!isNodeError(error, "ENOENT")) throw error; await atomicWriteFile(bodyPath, entry.prompt); }
202
+ const previous = Number(await readFile(systemPromptSequencePath(this.directory), "utf8"));
203
+ if (!Number.isSafeInteger(previous) || previous < 0 || previous >= Number.MAX_SAFE_INTEGER) throw new Error("Persisted system-prompt sequence is invalid");
204
+ const sequence = previous + 1;
205
+ await atomicWriteFile(systemPromptSequencePath(this.directory), `${String(sequence)}\n`);
206
+ await atomicJson(join(systemPromptRecordsPath(this.directory), systemPromptRecordName(sequence)), { sessionId: entry.sessionId, attempt: entry.attempt, turn: entry.turn, sha256 });
207
+ }
208
+ private async migrateSystemPrompts(legacy: { version: number; entries?: EffectiveSystemPrompt[] }): Promise<void> {
209
+ await rm(systemPromptStoragePath(this.directory), { recursive: true, force: true });
210
+ await createSystemPromptStorage(this.directory, false);
211
+ for (const entry of legacy.entries ?? []) await this.appendSystemPromptV2(entry);
212
+ await atomicJson(this.systemPromptPath(), SYSTEM_PROMPT_ARTIFACT);
213
+ }
214
+ private async prepareSystemPromptStorage(): Promise<void> {
215
+ const artifact = await this.readSystemPromptArtifact();
216
+ if (artifact === undefined) {
217
+ await rm(systemPromptStoragePath(this.directory), { recursive: true, force: true });
218
+ await createSystemPromptStorage(this.directory, true);
219
+ } else if (artifact.version === 1) {
220
+ if (!Array.isArray(artifact.entries)) throw new Error("Persisted system prompts are invalid");
221
+ await this.migrateSystemPrompts(artifact);
222
+ } else if (artifact.version !== SYSTEM_PROMPT_ARTIFACT.version || artifact.format !== SYSTEM_PROMPT_ARTIFACT.format || artifact.storage !== SYSTEM_PROMPT_ARTIFACT.storage) throw new Error("Persisted system prompts are invalid");
223
+ }
224
+ private async readSystemPromptsV2(): Promise<readonly EffectiveSystemPrompt[]> {
225
+ const artifact = await this.readSystemPromptArtifact();
226
+ if (!artifact || artifact.version !== SYSTEM_PROMPT_ARTIFACT.version || artifact.format !== SYSTEM_PROMPT_ARTIFACT.format || artifact.storage !== SYSTEM_PROMPT_ARTIFACT.storage) throw new Error("Persisted system prompts are invalid");
227
+ const sequence = Number(await readFile(systemPromptSequencePath(this.directory), "utf8"));
228
+ if (!Number.isSafeInteger(sequence) || sequence < 0) throw new Error("Persisted system-prompt sequence is invalid");
229
+ const names = (await readdir(systemPromptRecordsPath(this.directory))).filter((name) => !name.endsWith(".tmp")).sort();
230
+ const entries: EffectiveSystemPrompt[] = [];
231
+ let highest = 0;
232
+ for (const name of names) {
233
+ const match = /^(\d{20})\.json$/.exec(name);
234
+ if (!match) throw new Error("Persisted system-prompt records are invalid");
235
+ const recordSequence = Number(match[1]);
236
+ if (!Number.isSafeInteger(recordSequence) || recordSequence < 1) throw new Error("Persisted system-prompt records are invalid");
237
+ highest = Math.max(highest, recordSequence);
238
+ const record = await json(join(systemPromptRecordsPath(this.directory), name));
239
+ if (!object(record)) throw new Error("Persisted system-prompt record is invalid");
240
+ const sessionId = record.sessionId;
241
+ const attempt = record.attempt;
242
+ const turn = record.turn;
243
+ const sha256 = record.sha256;
244
+ if (typeof sessionId !== "string" || !sessionId || !positiveInteger(attempt) || !positiveInteger(turn) || typeof sha256 !== "string" || !/^[0-9a-f]{64}$/.test(sha256)) throw new Error("Persisted system-prompt record is invalid");
245
+ const prompt = await readFile(join(systemPromptBodiesPath(this.directory), sha256), "utf8");
246
+ if (createHash("sha256").update(prompt).digest("hex") !== sha256) throw new Error("Persisted system-prompt body is invalid");
247
+ entries.push({ sessionId, attempt, turn, sha256, prompt });
248
+ }
249
+ if (sequence < highest) throw new Error("Persisted system-prompt sequence is invalid");
250
+ return entries;
251
+ }
252
+ async recordSystemPrompt(entry: Omit<EffectiveSystemPrompt, "sha256">): Promise<void> {
253
+ const write = this.systemPromptLane.run(async () => {
254
+ await this.prepareSystemPromptStorage();
255
+ await this.appendSystemPromptV2(entry);
256
+ });
257
+ await write;
258
+ }
259
+ async systemPrompts(): Promise<readonly EffectiveSystemPrompt[]> {
260
+ await this.systemPromptLane.run(async () => undefined);
261
+ const artifact = await this.readSystemPromptArtifact();
262
+ if (artifact === undefined) return [];
263
+ if (artifact.version === 1) return artifact.entries ?? [];
264
+ if (artifact.version === SYSTEM_PROMPT_ARTIFACT.version) return this.readSystemPromptsV2();
265
+ throw new Error("Persisted system prompts are invalid");
266
+ }
267
+
268
+ async #decodeJournal(): Promise<Journal> {
269
+ const journal = decodeJournal(await json(join(this.directory, "journal.json")));
270
+ if (!journal) throw new WorkflowError("RESUME_INCOMPATIBLE", "Persisted journal is invalid");
271
+ return journal;
272
+ }
273
+ /** Reads the journal after every queued journal write has settled. */
274
+ async #readJournal(): Promise<Journal> {
275
+ await this.journalLane.run(async () => undefined);
276
+ return this.#decodeJournal();
277
+ }
278
+
279
+ private async updateJournal<T>(update: (journal: Journal) => T | Promise<T>): Promise<T> {
280
+ const write = this.journalLane.run(async () => {
281
+ const journal = await this.#decodeJournal();
282
+ journal.awaiting ??= {};
283
+ const result = await update(journal);
284
+ await atomicJson(join(this.directory, "journal.json"), journal);
285
+ this.refreshSummaryBestEffort();
286
+ return result;
287
+ });
288
+ return write;
289
+ }
290
+
291
+ async complete(path: string, value: JsonValue): Promise<void> {
292
+ await this.updateJournal((journal) => {
293
+ if (journal.completed[path]) throw new WorkflowError("DUPLICATE_NAME", `Completed structural path already exists: ${path}`);
294
+ journal.completed[path] = { path, value };
295
+ });
296
+ }
297
+
298
+ async replay(path: string): Promise<CompletedOperation | undefined> {
299
+ const [operation] = await this.replayableOperationsFrom(new Set(), path);
300
+ return operation;
301
+ }
302
+
303
+ async replayableOperations(): Promise<readonly CompletedOperation[]> {
304
+ return this.replayableOperationsFrom(new Set());
305
+ }
306
+
307
+ /** Every journaled agent operation of the retry lineage, mapped to its session file when one was recorded. */
308
+ async agentSessionFiles(): Promise<ReadonlyMap<string, string | undefined>> {
309
+ return this.agentSessionFilesFrom(new Set());
310
+ }
311
+
312
+ private async agentSessionFilesFrom(seen: Set<string>): Promise<ReadonlyMap<string, string | undefined>> {
313
+ if (seen.has(this.runId)) throw new WorkflowError("RESUME_INCOMPATIBLE", "Retry provenance contains a cycle");
314
+ const nextSeen = new Set(seen);
315
+ nextSeen.add(this.runId);
316
+ const loaded = await this.load();
317
+ const files = new Map<string, string | undefined>();
318
+ if (loaded.run.retry?.sourceRunId) {
319
+ const source = await this.sourceRun(loaded.run.retry.sourceRunId);
320
+ for (const [path, file] of await source.agentSessionFilesFrom(nextSeen)) files.set(path, file);
321
+ }
322
+ const journal = await this.#readJournal();
323
+ for (const agent of loaded.run.agents) {
324
+ // Only a journaled operation completed: an attempt a dying host left running records no error either.
325
+ if (!agent.resultPath || !journal.completed[agent.resultPath]) continue;
326
+ // Failed attempts of that operation also record a session; their partial transcript must never be the source.
327
+ const locator = agent.attemptDetails?.filter((detail) => !detail.error).at(-1)?.session?.locator;
328
+ files.set(agent.resultPath, object(locator) && typeof locator.sessionFile === "string" ? locator.sessionFile : undefined);
329
+ }
330
+ return files;
331
+ }
332
+
333
+ /** Source lineage first, own journal last (it overrides); `path` narrows to one operation without cloning the rest. */
334
+ private async replayableOperationsFrom(seen: Set<string>, path?: string): Promise<readonly CompletedOperation[]> {
335
+ if (seen.has(this.runId)) throw new WorkflowError("RESUME_INCOMPATIBLE", "Retry provenance contains a cycle");
336
+ const nextSeen = new Set(seen);
337
+ nextSeen.add(this.runId);
338
+ const loaded = await this.load();
339
+ const operations = new Map<string, CompletedOperation>();
340
+ if (loaded.run.retry?.sourceRunId) {
341
+ const source = await this.sourceRun(loaded.run.retry.sourceRunId);
342
+ for (const operation of await source.replayableOperationsFrom(nextSeen, path)) operations.set(operation.path, operation);
343
+ }
344
+ const journal = await this.#readJournal();
345
+ for (const operation of Object.values(journal.completed)) if (path === undefined || operation.path === path) operations.set(operation.path, operation);
346
+ return [...operations.values()].map((operation) => structuredClone(operation));
347
+ }
348
+
349
+ async awaitCheckpoint(checkpoint: AwaitingCheckpoint): Promise<boolean | undefined> {
350
+ const replayed = await this.replay(checkpoint.path);
351
+ if (replayed) {
352
+ const result = decodeBooleanCheckpointResult(replayed.value);
353
+ if (result === undefined) throw new WorkflowError("RESUME_INCOMPATIBLE", "Persisted checkpoint result is invalid");
354
+ return result;
355
+ }
356
+ return this.updateJournal((journal) => {
357
+ const completed = journal.completed[checkpoint.path];
358
+ if (completed) {
359
+ const result = decodeBooleanCheckpointResult(completed.value);
360
+ if (result === undefined) throw new WorkflowError("RESUME_INCOMPATIBLE", "Persisted checkpoint result is invalid");
361
+ return result;
362
+ }
363
+ const awaiting = journal.awaiting ?? (journal.awaiting = {});
364
+ awaiting[checkpoint.path] = checkpoint;
365
+ return undefined;
366
+ });
367
+ }
368
+
369
+ async awaitingCheckpoints(): Promise<readonly AwaitingCheckpoint[]> {
370
+ return Object.values((await this.#readJournal()).awaiting ?? {});
371
+ }
372
+ async requestWorkflowDecision(request: PendingWorkflowDecision): Promise<void> {
373
+ await this.updateJournal((journal) => { journal.decisions ??= {}; journal.decisions[request.proposalId] = request; });
374
+ }
375
+ async pendingWorkflowDecisions(): Promise<readonly PendingWorkflowDecision[]> {
376
+ return Object.values((await this.#readJournal()).decisions ?? {});
377
+ }
378
+ async answerWorkflowDecision(proposalId: string, approved: boolean): Promise<PendingWorkflowDecision | undefined> {
379
+ return this.updateJournal((journal) => {
380
+ const request = journal.decisions?.[proposalId];
381
+ if (!request) return undefined;
382
+ journal.completed[`decision/${proposalId}`] = { path: `decision/${proposalId}`, value: approved };
383
+ delete journal.decisions?.[proposalId];
384
+ return request;
385
+ });
386
+ }
387
+
388
+ async answerCheckpoint(name: string, approved: boolean): Promise<AwaitingCheckpoint | undefined> {
389
+ return this.updateJournal((journal) => {
390
+ const checkpoint = Object.values(journal.awaiting ?? {}).find((item) => item.name === name);
391
+ if (!checkpoint || journal.completed[checkpoint.path]) return undefined;
392
+ journal.completed[checkpoint.path] = { path: checkpoint.path, value: approved };
393
+ journal.awaiting = Object.fromEntries(Object.entries(journal.awaiting ?? {}).filter(([path]) => path !== checkpoint.path));
394
+ return checkpoint;
395
+ });
396
+ }
397
+
398
+ #worktreeKey(owner: string): string { return createHash("sha256").update(`${this.sessionId}\0${this.runId}\0${owner}`).digest("hex").slice(0, 16); }
399
+
400
+ private expectedWorktree(owner: string): Pick<WorktreeReference, "path" | "branch"> {
401
+ const key = this.#worktreeKey(owner);
402
+ return { path: join(this.directory, "worktrees", key), branch: `pi-extensible-workflows/${safePart(this.runId)}/${key}` };
403
+ }
404
+
405
+ private markerPath(owner: string): string { return join(this.directory, `worktree-${this.#worktreeKey(owner)}.creating`); }
406
+
407
+ private namedWorktreeOwner(name: string): string {
408
+ if (!name.trim()) throw new WorkflowError("WORKTREE_FAILED", "Named worktree names must be non-empty");
409
+ return structuralPath("worktree", "named", name.trim());
410
+ }
411
+
412
+ private worktreeName(owner: string): string | undefined {
413
+ const prefix = `${structuralPath("worktree", "named")}/`;
414
+ if (!owner.startsWith(prefix)) return undefined;
415
+ const encoded = owner.slice(prefix.length);
416
+ if (!encoded || encoded.includes("/")) return undefined;
417
+ try {
418
+ const name = decodeURIComponent(encoded);
419
+ return name.trim() ? name : undefined;
420
+ } catch {
421
+ return undefined;
422
+ }
423
+ }
424
+
425
+ private async runWorktreePostCreateCommand(command: readonly string[], cwd: string, source?: string): Promise<void> {
426
+ const executable = command[0];
427
+ if (!executable) return;
428
+ const display = command.map((argument) => JSON.stringify(argument)).join(" ");
429
+ try {
430
+ await executeFile(executable, [...command.slice(1)], { cwd, env: process.env, encoding: "utf8", timeout: WORKTREE_POST_CREATE_TIMEOUT_MS, maxBuffer: 1024 * 1024 });
431
+ } catch (error: unknown) {
432
+ const record = typeof error === "object" && error !== null ? error as Record<string, unknown> : {};
433
+ const code = record.code;
434
+ const signal = typeof record.signal === "string" ? record.signal : undefined;
435
+ const stderr = typeof record.stderr === "string" ? record.stderr.trim() : "";
436
+ const stdout = typeof record.stdout === "string" ? record.stdout.trim() : "";
437
+ const output = [stderr, stdout].filter(Boolean).join("\n").slice(0, WORKTREE_POST_CREATE_OUTPUT_LIMIT);
438
+ const provenance = source !== undefined && sameFilesystemPath(source, workflowProjectSettingsPath(this.cwd)) ? "trusted project settings" : "global settings";
439
+ let category: string;
440
+ if (code === "ENOENT") category = `executable ${JSON.stringify(executable)} was not found`;
441
+ else if (code === "ETIMEDOUT" || record.killed === true && signal === "SIGTERM") category = `timed out after ${String(WORKTREE_POST_CREATE_TIMEOUT_MS / 1000)} seconds`;
442
+ else if (typeof code === "number") category = `exited with status ${String(code)}`;
443
+ else if (signal) category = `was terminated by ${signal}`;
444
+ else category = `could not be started${typeof code === "string" ? ` (${code})` : ""}`;
445
+ throw new Error(`worktree post-create command ${display} from ${provenance} failed in ${cwd}: ${category}${output ? `; output: ${output}` : ""}`, { cause: error });
446
+ }
447
+ }
448
+ private structuralWorktree(owner: string, record: WorktreeReference): WorktreeReference {
449
+ const expected = this.expectedWorktree(owner);
450
+ const relativePath = relative(this.directory, record.path);
451
+ const relativeCwd = relative(record.path, record.cwd);
452
+ if (record.owner !== owner || resolve(record.path) !== expected.path || record.branch !== expected.branch || relativePath === ".." || relativePath.startsWith(`..${sep}`) || relativeCwd === ".." || relativeCwd.startsWith(`..${sep}`)) throw new Error(`Invalid worktree record for ${owner}`);
453
+ return record;
454
+ }
455
+
456
+ async #loadWorktreeRecords(missingOk = true): Promise<WorktreeReference[]> {
457
+ const rawRecords = await json(join(this.directory, "worktrees.json")).catch((error: unknown) => { if (missingOk && isNodeError(error, "ENOENT")) return []; throw error; });
458
+ const records = decodeWorktreeReferences(rawRecords);
459
+ if (!records) throw new WorkflowError("WORKTREE_FAILED", "Worktree records are invalid");
460
+ return records;
461
+ }
462
+
463
+ private async borrowedWorktreeRecords(wait = true): Promise<readonly BorrowedWorktreeBinding[]> {
464
+ if (wait) await this.borrowedWorktreeLane.run(async () => undefined);
465
+ const rawRecords = await json(join(this.directory, "borrowed-worktrees.json")).catch((error: unknown) => { if (isNodeError(error, "ENOENT")) return []; throw error; });
466
+ const records = decodeBorrowedWorktreeBindings(rawRecords);
467
+ if (!records) throw new WorkflowError("WORKTREE_FAILED", "Borrowed worktree bindings are invalid");
468
+ const seen = new Set<string>();
469
+ return records.map((candidate) => {
470
+ if (!candidate.name.trim() || candidate.name !== candidate.name.trim() || !candidate.sourceRunId || candidate.owner !== this.namedWorktreeOwner(candidate.name)) throw new WorkflowError("WORKTREE_FAILED", "Borrowed worktree binding is invalid");
471
+ if (seen.has(candidate.name)) throw new WorkflowError("WORKTREE_FAILED", `Duplicate borrowed worktree binding for ${candidate.name}`);
472
+ seen.add(candidate.name);
473
+ return candidate;
474
+ });
475
+ }
476
+ async borrowedWorktrees(): Promise<readonly BorrowedWorktreeBinding[]> { return this.borrowedWorktreeRecords(); }
477
+
478
+ private async borrowedWorktree(name: string): Promise<BorrowedWorktreeBinding | undefined> {
479
+ return (await this.borrowedWorktreeRecords()).find((binding) => binding.name === name);
480
+ }
481
+
482
+ private async sourceRun(sourceRunId: string): Promise<RunStore> {
483
+ if (!sourceRunId || sourceRunId === this.runId) throw new WorkflowError("WORKTREE_FAILED", "Borrowed worktree source run is invalid");
484
+ const source = new RunStore(this.cwd, this.sessionId, sourceRunId, this.home);
485
+ try {
486
+ const loaded = await source.load();
487
+ if (!HARD_TERMINAL_RUN_STATES.has(loaded.run.state)) throw new Error(`Source run ${sourceRunId} is not terminal`);
488
+ return source;
489
+ } catch (error) {
490
+ throw coerceWorkflowError("WORKTREE_FAILED", error);
491
+ }
492
+ }
493
+
494
+ async validateParentRun(parentRunId: string): Promise<void> { await this.sourceRun(parentRunId); }
495
+ async validateRetrySource(): Promise<void> {
496
+ const validate = async (current: RunStore, seen: Set<string>): Promise<void> => {
497
+ if (seen.has(current.runId)) throw new WorkflowError("RESUME_INCOMPATIBLE", "Retry provenance contains a cycle");
498
+ const nextSeen = new Set(seen);
499
+ nextSeen.add(current.runId);
500
+ const loaded = await current.load();
501
+ const retry = loaded.run.retry;
502
+ if (!retry) return;
503
+ // decodeRetry() already guarantees the field shapes; only the semantic constraints remain to check here.
504
+ if (!retry.sourceRunId || retry.sourceRunId === current.runId || !retry.lineageRootRunId) throw new WorkflowError("RESUME_INCOMPATIBLE", "Retry provenance is incomplete");
505
+ const source = await current.sourceRun(retry.sourceRunId);
506
+ const sourceRun = (await source.load()).run;
507
+ if (loaded.run.parentRunId !== retry.sourceRunId) throw new WorkflowError("RESUME_INCOMPATIBLE", "Retry parent run does not match its source run");
508
+ if (sourceRun.state !== "failed") throw new WorkflowError("RESUME_INCOMPATIBLE", `Retry source run ${retry.sourceRunId} is not failed`);
509
+ const expectedLineageRoot = sourceRun.retry?.lineageRootRunId ?? sourceRun.id;
510
+ if (retry.lineageRootRunId !== expectedLineageRoot) throw new WorkflowError("RESUME_INCOMPATIBLE", "Retry lineage root does not match its source run");
511
+ await validate(source, nextSeen);
512
+ };
513
+ try { await validate(this, new Set()); }
514
+ catch (error) { throw coerceWorkflowError("RESUME_INCOMPATIBLE", error); }
515
+ }
516
+
517
+ private async ownedWorktree(owner: string, cwd?: string): Promise<WorktreeReference> {
518
+ const records = await this.#loadWorktreeRecords(false);
519
+ const matches = records.filter((candidate) => candidate.owner === owner);
520
+ if (matches.length !== 1) throw new Error(`Missing or duplicate worktree record for ${owner}`);
521
+ const record = matches[0];
522
+ if (!record) throw new Error(`Missing or duplicate worktree record for ${owner}`);
523
+ const validated = this.structuralWorktree(owner, record);
524
+ if (cwd !== undefined && resolve(cwd) !== resolve(validated.cwd)) throw new Error(`Invalid worktree record for ${owner}`);
525
+ await access(validated.cwd);
526
+ return validated;
527
+ }
528
+
529
+ private async resolveBorrowedWorktree(binding: BorrowedWorktreeBinding, seen: Set<string>): Promise<{ reference: WorktreeReference; sourceRunId: string; owner: string }> {
530
+ try {
531
+ const source = await this.sourceRun(binding.sourceRunId);
532
+ const resolved = await source.findNamedWorktree(binding.name, seen);
533
+ if (!resolved) throw new Error(`Missing named worktree ${binding.name} in source run ${binding.sourceRunId}`);
534
+ if (resolved.owner !== binding.owner) throw new Error(`Borrowed worktree binding does not match source owner for ${binding.name}`);
535
+ return resolved;
536
+ } catch (error) {
537
+ throw coerceWorkflowError("WORKTREE_FAILED", error);
538
+ }
539
+ }
540
+
541
+ private async findNamedWorktree(name: string, seen: Set<string> = new Set()): Promise<{ reference: WorktreeReference; sourceRunId: string; owner: string } | undefined> {
542
+ const owner = this.namedWorktreeOwner(name);
543
+ if (seen.has(this.runId)) throw new WorkflowError("WORKTREE_FAILED", "Borrowed worktree bindings contain a cycle");
544
+ const nextSeen = new Set(seen);
545
+ nextSeen.add(this.runId);
546
+ const binding = await this.borrowedWorktree(name);
547
+ if (binding) {
548
+ const loaded = await this.load();
549
+ if (loaded.run.parentRunId === undefined) throw new WorkflowError("WORKTREE_FAILED", `Borrowed worktree ${name} has no parent run`);
550
+ const parent = await this.sourceRun(loaded.run.parentRunId);
551
+ const resolved = await parent.findNamedWorktree(name, nextSeen);
552
+ if (!resolved || resolved.sourceRunId !== binding.sourceRunId || resolved.owner !== binding.owner) throw new WorkflowError("WORKTREE_FAILED", `Borrowed worktree binding for ${name} is not inherited from its parent run`);
553
+ return resolved;
554
+ }
555
+ const records = await this.#loadWorktreeRecords(false);
556
+ const matches = records.filter((candidate) => candidate.owner === owner);
557
+ if (matches.length === 0) {
558
+ const loaded = await this.load();
559
+ if (loaded.run.parentRunId === undefined) return undefined;
560
+ const parent = await this.sourceRun(loaded.run.parentRunId);
561
+ return parent.findNamedWorktree(name, nextSeen);
562
+ }
563
+ try {
564
+ const reference = await this.ownedWorktree(owner);
565
+ return { reference, sourceRunId: this.runId, owner };
566
+ } catch (error) {
567
+ throw coerceWorkflowError("WORKTREE_FAILED", error);
568
+ }
569
+ }
570
+
571
+ async resolveNamedWorktree(name: string, seen: Set<string> = new Set()): Promise<{ reference: WorktreeReference; sourceRunId: string; owner: string }> {
572
+ const resolved = await this.findNamedWorktree(name, seen);
573
+ if (!resolved) throw new WorkflowError("WORKTREE_FAILED", `Missing named worktree ${name}`);
574
+ return resolved;
575
+ }
576
+ async validateDeletionWorktrees(): Promise<void> {
577
+ try {
578
+ const records = await this.#loadWorktreeRecords(false);
579
+ const owners = new Set<string>();
580
+ const paths = new Set<string>();
581
+ for (const record of records) {
582
+ const owner = record.owner;
583
+ if (owners.has(owner)) throw new Error(`Duplicate worktree record for ${owner}`);
584
+ owners.add(owner);
585
+ const reference = this.structuralWorktree(owner, record);
586
+ paths.add(resolve(reference.path));
587
+ }
588
+ const entries = await readdir(join(this.directory, "worktrees"), { withFileTypes: true }).catch((error: unknown) => { if (isNodeError(error, "ENOENT")) return [] as import("node:fs").Dirent[]; throw error; });
589
+ for (const entry of entries) if (!entry.isDirectory() || entry.isSymbolicLink() || !paths.has(resolve(join(this.directory, "worktrees", entry.name)))) throw new Error(`Unrecorded worktree artifact: ${join(this.directory, "worktrees", entry.name)}`);
590
+ } catch (error) {
591
+ throw coerceWorkflowError("WORKTREE_FAILED", error);
592
+ }
593
+ }
594
+
595
+
596
+ async validateBorrowedWorktrees(): Promise<void> {
597
+ try {
598
+ const loaded = await this.load();
599
+ if (loaded.run.parentRunId !== undefined) await this.validateParentRun(loaded.run.parentRunId);
600
+ for (const binding of await this.borrowedWorktreeRecords()) await this.resolveBorrowedWorktree(binding, new Set([this.runId]));
601
+ } catch (error) {
602
+ throw coerceWorkflowError("WORKTREE_FAILED", error);
603
+ }
604
+ }
605
+ async validateNamedWorktrees(): Promise<void> {
606
+ try {
607
+ const records = await this.#loadWorktreeRecords(false);
608
+ for (const record of records) {
609
+ const owner = record.owner;
610
+ if (this.worktreeName(owner)) await this.validateWorktree(owner);
611
+ }
612
+ } catch (error) {
613
+ throw coerceWorkflowError("WORKTREE_FAILED", error);
614
+ }
615
+ }
616
+
617
+ async ownsWorktree(owner: string): Promise<boolean> {
618
+ const records = await this.#loadWorktreeRecords(false);
619
+ return records.filter((candidate) => candidate.owner === owner).length === 1;
620
+ }
621
+
622
+ private async cleanupMarker(markerPath: string): Promise<void> {
623
+ let marker: Record<string, unknown>;
624
+ try {
625
+ const parsed = await json(markerPath);
626
+ if (!object(parsed)) return;
627
+ marker = parsed;
628
+ } catch { return; }
629
+ if (typeof marker.owner !== "string" || typeof marker.base !== "string") return;
630
+ const expected = this.expectedWorktree(marker.owner);
631
+ if (marker.path !== expected.path || marker.branch !== expected.branch) return;
632
+ const root = await git(this.cwd, ["rev-parse", "--show-toplevel"]).then((value) => value.trim()).catch(() => "");
633
+ if (!root) return;
634
+ const branchBase = await git(root, ["rev-parse", "--verify", `${expected.branch}^{commit}`]).then((value) => value.trim()).catch(() => "");
635
+ if (branchBase !== marker.base) return;
636
+ await git(root, ["worktree", "remove", "--force", expected.path]).catch(() => undefined);
637
+ await git(root, ["branch", "-D", expected.branch]).catch(() => undefined);
638
+ await rm(expected.path, { recursive: true, force: true });
639
+ await rm(markerPath, { force: true });
640
+ }
641
+
642
+ private async cleanupOrphanWorktrees(): Promise<void> {
643
+ const entries = await readdir(this.directory).catch(() => [] as string[]);
644
+ for (const entry of entries.filter((name) => name.endsWith(".creating"))) await this.cleanupMarker(join(this.directory, entry));
645
+ }
646
+
647
+ async validateWorktree(owner: string, cwd?: string): Promise<WorktreeReference> {
648
+ try {
649
+ await this.load();
650
+ const name = this.worktreeName(owner);
651
+ const binding = name ? await this.borrowedWorktree(name) : undefined;
652
+ if (binding) {
653
+ const resolved = await this.resolveBorrowedWorktree(binding, new Set([this.runId]));
654
+ if (cwd !== undefined && resolve(cwd) !== resolve(resolved.reference.cwd)) throw new Error(`Invalid worktree record for ${owner}`);
655
+ return resolved.reference;
656
+ }
657
+ return await this.ownedWorktree(owner, cwd);
658
+ } catch (error) {
659
+ throw coerceWorkflowError("WORKTREE_FAILED", error);
660
+ }
661
+ }
662
+
663
+ async worktree(owner: string): Promise<WorktreeReference> {
664
+ const write = this.worktreeLane.run(async () => {
665
+ const loaded = await this.load();
666
+ const recordsPath = resolve(this.directory, "worktrees.json");
667
+ let records = await this.#loadWorktreeRecords();
668
+ const name = this.worktreeName(owner);
669
+ const binding = name ? await this.borrowedWorktree(name) : undefined;
670
+ if (binding) return (await this.resolveBorrowedWorktree(binding, new Set([this.runId]))).reference;
671
+ if (name && loaded.run.parentRunId !== undefined) {
672
+ const resolved = await this.resolveNamedWorktreeFromParent(name, loaded.run.parentRunId);
673
+ if (resolved) {
674
+ await this.bindBorrowedWorktree({ name, sourceRunId: resolved.sourceRunId, owner: resolved.owner });
675
+ return resolved.reference;
676
+ }
677
+ }
678
+ if (name && Array.isArray(loaded.run.retry?.namedWorktrees) && loaded.run.retry.namedWorktrees.includes(name)) throw new WorkflowError("WORKTREE_FAILED", `Missing inherited named worktree ${name}`);
679
+ const existing = records.find((record) => record.owner === owner);
680
+ if (existing) return this.validateWorktree(owner);
681
+ const { path, branch } = this.expectedWorktree(owner);
682
+ const markerPath = this.markerPath(owner);
683
+ let branchCreated = false;
684
+ let worktreeCreated = false;
685
+ let markerWritten = false;
686
+ try {
687
+ const root = (await git(this.cwd, ["rev-parse", "--show-toplevel"])).trim();
688
+ const [canonicalRoot, canonicalCwd] = await Promise.all([realpath(root), realpath(this.cwd)]);
689
+ const launchRelative = relative(canonicalRoot, canonicalCwd);
690
+ if (launchRelative === ".." || launchRelative.startsWith(`..${sep}`)) throw new Error("launch cwd is outside the repository");
691
+ await this.cleanupMarker(markerPath);
692
+ const dirty = (await git(root, ["status", "--porcelain", "--untracked-files=normal"])).trim();
693
+ if (dirty) {
694
+ const dirtyPaths = dirty.split("\n").filter(Boolean).map((entry) => entry.slice(3));
695
+ const shownPaths = dirtyPaths.slice(0, 10);
696
+ const more = dirtyPaths.length > shownPaths.length ? `, and ${String(dirtyPaths.length - shownPaths.length)} more` : "";
697
+ throw new Error(`repository ${root} has uncommitted changes at worktree creation; commit or stash them first: ${shownPaths.join(", ")}${more}`);
698
+ }
699
+ let commit: string;
700
+ try {
701
+ commit = (await git(root, ["rev-parse", "--verify", "HEAD^{commit}"])).trim();
702
+ } catch {
703
+ throw new Error("repository has no commits");
704
+ }
705
+ await mkdir(dirname(path), { recursive: true, mode: 0o700 });
706
+ const record = { owner, path, branch, cwd: join(path, launchRelative), base: commit };
707
+ await atomicJson(markerPath, { owner, path, branch, base: commit });
708
+ markerWritten = true;
709
+ await git(root, ["branch", branch, commit]);
710
+ branchCreated = true;
711
+ await git(root, ["worktree", "add", "--no-checkout", path, branch]);
712
+ worktreeCreated = true;
713
+ await git(path, ["checkout", "--force", branch]);
714
+ if (loaded.snapshot.settings.worktreePostCreateCommand) await this.runWorktreePostCreateCommand(loaded.snapshot.settings.worktreePostCreateCommand, record.cwd, loaded.snapshot.settingsSources?.worktreePostCreateCommand);
715
+ await atomicJson(recordsPath, [...records, record]);
716
+ await rm(markerPath, { force: true });
717
+ return record;
718
+ } catch (error) {
719
+ if (worktreeCreated) await git(this.cwd, ["worktree", "remove", "--force", path]).catch(() => undefined);
720
+ if (branchCreated) await git(this.cwd, ["branch", "-D", branch]).catch(() => undefined);
721
+ if (markerWritten) await rm(markerPath, { force: true });
722
+ try {
723
+ const persisted = await this.#loadWorktreeRecords();
724
+ const match = persisted.filter((candidate) => candidate.owner === owner);
725
+ const candidate = match.length === 1 ? match[0] : undefined;
726
+ if (candidate) { this.structuralWorktree(owner, candidate); records = persisted.filter((current) => current !== candidate); await atomicJson(recordsPath, records); }
727
+ } catch { /* Ownership changed or disappeared: do not delete anything. */ }
728
+ throw new WorkflowError("WORKTREE_FAILED", errorText(error));
729
+ }
730
+ });
731
+ return write;
732
+ }
733
+
734
+ private async resolveNamedWorktreeFromParent(name: string, parentRunId: string): Promise<{ reference: WorktreeReference; sourceRunId: string; owner: string } | undefined> {
735
+ const source = await this.sourceRun(parentRunId);
736
+ return source.findNamedWorktree(name, new Set([this.runId]));
737
+ }
738
+
739
+ private async bindBorrowedWorktree(binding: BorrowedWorktreeBinding): Promise<void> {
740
+ const write = this.borrowedWorktreeLane.run(async () => {
741
+ const records = [...await this.borrowedWorktreeRecords(false)];
742
+ const existing = records.find((candidate) => candidate.name === binding.name);
743
+ if (existing) {
744
+ if (JSON.stringify(existing) !== JSON.stringify(binding)) throw new WorkflowError("WORKTREE_FAILED", `Borrowed worktree binding for ${binding.name} changed`);
745
+ return;
746
+ }
747
+ records.push(binding);
748
+ await atomicJson(join(this.directory, "borrowed-worktrees.json"), records);
749
+ });
750
+ await write;
751
+ }
752
+ async snapshotWorktree(owner: string): Promise<string> {
753
+ try {
754
+ const write = this.snapshotLane.run(async () => {
755
+ const record = await this.worktree(owner);
756
+ for (let attempt = 0; attempt < 3; attempt += 1) {
757
+ await git(record.path, ["add", "-A"]);
758
+ if (!(await git(record.path, ["status", "--porcelain"])).trim()) break;
759
+ try {
760
+ await git(record.path, ["commit", "-m", "pi-extensible-workflows runtime snapshot"], gitIdentity);
761
+ break;
762
+ } catch (error) {
763
+ if (attempt === 2) throw error;
764
+ }
765
+ }
766
+ return (await git(record.path, ["rev-parse", "HEAD"])).trim();
767
+ });
768
+ return await write;
769
+ } catch (error) {
770
+ throw coerceWorkflowError("WORKTREE_FAILED", error);
771
+ }
772
+ }
773
+ async worktrees(): Promise<readonly WorktreeReference[]> {
774
+ const records = await this.#loadWorktreeRecords();
775
+ const bindings = await this.borrowedWorktreeRecords();
776
+ const boundOwners = new Set(bindings.map((binding) => binding.owner));
777
+ const owned = await Promise.all(records.filter((record) => !boundOwners.has(record.owner)).map(async (record) => { try { return await this.validateWorktree(record.owner); } catch { return undefined; } }));
778
+ const borrowed = await Promise.all(bindings.map(async (binding) => (await this.resolveBorrowedWorktree(binding, new Set([this.runId]))).reference));
779
+ return [...owned.filter((record): record is WorktreeReference => record !== undefined), ...borrowed];
780
+ }
781
+ async validNamedWorktrees(): Promise<readonly string[]> {
782
+ const names = new Set<string>();
783
+ const records = await this.#loadWorktreeRecords();
784
+ let bindings: readonly BorrowedWorktreeBinding[];
785
+ try { bindings = await this.borrowedWorktreeRecords(); }
786
+ catch (error) { if (error instanceof WorkflowError && error.code === "WORKTREE_FAILED") return []; throw error; }
787
+ const boundOwners = new Set(bindings.map((binding) => binding.owner));
788
+ for (const record of records) {
789
+ const owner = record.owner;
790
+ const name = this.worktreeName(owner);
791
+ if (!name || owner !== this.namedWorktreeOwner(name) || boundOwners.has(owner)) continue;
792
+ try { await this.ownedWorktree(owner); names.add(name); } catch { /* Do not advertise stale or invalid records. */ }
793
+ }
794
+ for (const binding of bindings) {
795
+ try { await this.resolveBorrowedWorktree(binding, new Set([this.runId])); names.add(binding.name); } catch { /* Do not advertise stale inherited records. */ }
796
+ }
797
+ return [...names];
798
+ }
799
+ async changedWorktrees(): Promise<readonly WorktreeReference[]> {
800
+ const changed: WorktreeReference[] = [];
801
+ for (const valid of await this.worktrees()) {
802
+ try { await git(valid.path, ["diff", "--quiet", valid.base, "HEAD"]); }
803
+ catch { changed.push(valid); }
804
+ }
805
+ return changed;
806
+ }
807
+
808
+ async saveResult(value: JsonValue): Promise<string> {
809
+ const path = join(this.directory, "result.json");
810
+ await atomicPrettyJson(path, value);
811
+ return path;
812
+ }
813
+
814
+ async resultBytes(): Promise<number> {
815
+ return (await stat(join(this.directory, "result.json"))).size;
816
+ }
817
+
818
+ async delete(confirmed: boolean): Promise<void> {
819
+ if (!confirmed) throw new WorkflowError("CANCELLED", "Run deletion requires confirmation");
820
+ const records = await this.#loadWorktreeRecords();
821
+ const validated = records.map((record) => {
822
+ try { return this.structuralWorktree(record.owner, record); }
823
+ catch (error) { throw new WorkflowError("WORKTREE_FAILED", errorText(error)); }
824
+ });
825
+ await this.cleanupOrphanWorktrees();
826
+ for (const record of validated) {
827
+ await git(this.cwd, ["worktree", "remove", "--force", record.path]).catch(() => undefined);
828
+ await git(this.cwd, ["branch", "-D", record.branch]).catch(() => undefined);
829
+ }
830
+ await rm(this.directory, { recursive: true, force: true });
831
+ }
832
+ }