@akagilnc/pi-workflow-roles 0.1.3572 → 0.1.3621

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 (129) hide show
  1. package/dist/acp-host/production-host.js +20239 -10556
  2. package/dist/analyst-gate-cycles-read.js +387 -0
  3. package/dist/archivist-record-entry.js +22 -1
  4. package/dist/audit-escalation.js +6 -0
  5. package/dist/auditor-soul.js +74 -0
  6. package/dist/collector-config.js +86 -0
  7. package/dist/collector-evidence.js +316 -0
  8. package/dist/collector-github.js +527 -0
  9. package/dist/collector-ledger.js +853 -0
  10. package/dist/collector-tool-schemas.js +33 -0
  11. package/dist/compliance-transport.js +104 -52
  12. package/dist/diarist-mechanical.js +411 -0
  13. package/dist/diarist-ticket-resolution.js +177 -0
  14. package/dist/diarist.js +311 -0
  15. package/dist/doctor-auditor.js +25 -0
  16. package/dist/doctor-contracts.js +2 -0
  17. package/dist/doctor-evidence.js +142 -0
  18. package/dist/gatekeeper-role.js +104 -81
  19. package/dist/host-transition-prior-native.js +78 -0
  20. package/dist/institutional-resolution.js +1 -95
  21. package/dist/judge-auditor.js +26 -0
  22. package/dist/ledger-session-read.js +227 -0
  23. package/dist/merger-git-state.js +78 -0
  24. package/dist/navigator-attendance.js +15 -7
  25. package/dist/navigator-public-session.js +170 -0
  26. package/dist/navigator-session-contracts.js +36 -40
  27. package/dist/notary-source-run.js +122 -0
  28. package/dist/package-contracts/auditor-output.js +66 -0
  29. package/dist/package-contracts/evidence-child-output.js +34 -0
  30. package/dist/package-contracts/judge-output.js +2 -0
  31. package/dist/package-contracts/terminating-tools.js +25 -2
  32. package/dist/package-resources/method-skill.js +254 -0
  33. package/dist/packaged-role-registry.js +36 -0
  34. package/dist/pi/durable-principal.js +61 -0
  35. package/dist/pi/in-process-session.js +25 -6
  36. package/dist/pi/known-failure.js +52 -0
  37. package/dist/pi/role-turn-host.js +430 -0
  38. package/dist/public-cli/auto-resume.js +414 -0
  39. package/dist/public-cli/cli-errors.js +8 -0
  40. package/dist/public-cli/cli-io.js +1 -0
  41. package/dist/public-cli/command-renderer.js +5 -0
  42. package/dist/public-cli/doctor-run.js +84 -0
  43. package/dist/public-cli/inspector-run.js +136 -0
  44. package/dist/public-cli/instruction-seat-run.js +256 -0
  45. package/dist/public-cli/invocation.js +2225 -0
  46. package/dist/public-cli/judge-run.js +118 -0
  47. package/dist/public-cli/load-production-acp-host.js +40 -0
  48. package/dist/public-cli/main.js +1140 -652
  49. package/dist/public-cli/notary-run.js +161 -0
  50. package/dist/public-cli/option-definitions.js +1192 -0
  51. package/dist/public-cli/post-admission.js +667 -0
  52. package/dist/public-cli/public-run-credentials.js +49 -0
  53. package/dist/public-cli/registry.js +4 -1
  54. package/dist/public-cli/reviewer-dispatch-rejection.js +77 -0
  55. package/dist/public-cli/run-lifecycle.js +1344 -0
  56. package/dist/public-cli/seat-ticket-binding.js +113 -0
  57. package/dist/public-cli/settlement.js +3441 -0
  58. package/dist/public-cli/terminal.js +135 -0
  59. package/dist/public-cli/turn-request.js +25 -0
  60. package/dist/public-role-summons.js +301 -0
  61. package/dist/receipt-delivery-policy.js +10 -0
  62. package/dist/reviewer-child-executor.js +80 -8
  63. package/dist/reviewer-execution-ledger.js +2 -1
  64. package/dist/run-terminal-artifacts.js +195 -0
  65. package/dist/run-ticket-number.js +40 -0
  66. package/dist/session-assistant-usage.js +107 -0
  67. package/dist/shape-unreadable-failure.js +34 -0
  68. package/dist/submission-errors.js +3 -0
  69. package/dist/submission-ledger.js +388 -0
  70. package/dist/ticket-provenance-contracts.js +115 -0
  71. package/dist/ticket-provenance.js +340 -0
  72. package/extensions/role-runtime.ts +7 -0
  73. package/package.json +1 -1
  74. package/scripts/build-package.mjs +2 -1
  75. package/souls/doctor-auditor.md +1 -0
  76. package/souls/evidence-child.md +1 -0
  77. package/src/acp-host/production-host.ts +3 -0
  78. package/src/acp-host/role-envelope.ts +2 -2
  79. package/src/acp-host/role-turn-host.ts +9 -1
  80. package/src/analyst-gate-cycles-read.ts +37 -2
  81. package/src/archivist-record-entry.ts +45 -1
  82. package/src/audit-escalation.ts +17 -0
  83. package/src/auditor-role.ts +22 -0
  84. package/src/auditor-soul.ts +42 -0
  85. package/src/compliance-transport.ts +177 -66
  86. package/src/doctor-auditor.ts +10 -14
  87. package/src/doctor-contracts.ts +1 -0
  88. package/src/doctor-role.ts +2 -2
  89. package/src/evidence-child-role.ts +22 -0
  90. package/src/gatekeeper-pass-envelope.ts +109 -0
  91. package/src/gatekeeper-role.ts +145 -104
  92. package/src/host-contracts.ts +9 -1
  93. package/src/institutional-resolution.ts +8 -163
  94. package/src/judge-auditor.ts +10 -15
  95. package/src/judge-role.ts +8 -0
  96. package/src/navigator-attendance.ts +25 -9
  97. package/src/navigator-public-session.ts +217 -0
  98. package/src/navigator-session-contracts.ts +61 -54
  99. package/src/notary-source-run.ts +3 -1
  100. package/src/package-contracts/auditor-output.ts +82 -0
  101. package/src/package-contracts/evidence-child-output.ts +51 -0
  102. package/src/package-contracts/judge-output.ts +1 -0
  103. package/src/package-contracts/reviewer-output.ts +2 -2
  104. package/src/package-contracts/terminating-tools.ts +31 -1
  105. package/src/packaged-role-registry.ts +43 -0
  106. package/src/pi/adapter.ts +2 -1
  107. package/src/pi/in-process-session.ts +38 -12
  108. package/src/pi/role-turn-host.ts +34 -0
  109. package/src/public-cli/cli.ts +14 -6
  110. package/src/public-cli/instruction-seat-run.ts +289 -50
  111. package/src/public-cli/invocation.ts +66 -25
  112. package/src/public-cli/option-definitions.ts +61 -0
  113. package/src/public-cli/post-admission.ts +7 -1
  114. package/src/public-cli/registry.ts +4 -1
  115. package/src/public-cli/run-lifecycle.ts +22 -3
  116. package/src/public-cli/settlement.ts +120 -5
  117. package/src/public-cli/terminal.ts +2 -0
  118. package/src/public-role-summons.ts +431 -0
  119. package/src/receipt-delivery-policy.ts +10 -0
  120. package/src/reviewer-agent.ts +1 -1
  121. package/src/reviewer-child-executor.ts +114 -12
  122. package/src/reviewer-execution-ledger.ts +3 -2
  123. package/src/role-runtime.ts +158 -17
  124. package/src/session-assistant-usage.ts +119 -0
  125. package/src/session-opening-materials.ts +1 -1
  126. package/src/shape-unreadable-failure.ts +49 -0
  127. package/src/submission-errors.ts +3 -0
  128. package/dist/evidence-child-executor.js +0 -829
  129. package/src/evidence-child-executor.ts +0 -1140
@@ -0,0 +1,1344 @@
1
+ /**
2
+ * Durable Role run lifecycle for public CLI (ADR 0052 / #11 / #108 / #416).
3
+ * States: admitted → running → resumable | terminal.
4
+ * #416 (owner 2026-08-22): resume no longer gates on terminal/resumable or typed 429 —
5
+ * any existing run with an available Pi session principal may be resumed; caller decides.
6
+ * Prose is never regex-classified as quota evidence.
7
+ */
8
+ import { chmod, open, readdir, readFile, unlink, writeFile } from "node:fs/promises";
9
+ import { join } from "node:path";
10
+ import { activationBookDirectory, resolveActivationLedgerHome, } from "../activation-ledger-topology.js";
11
+ import { readRunTicketNumber } from "../run-ticket-number.js";
12
+ import { CliUsageError } from "./cli-errors.js";
13
+ import { readLatestTypedProviderHttpObservation, } from "../typed-provider-http.js";
14
+ export { clearTypedProviderHttpObservation, recordTypedProviderHttpStatus, readLatestTypedProviderHttpObservation, } from "../typed-provider-http.js";
15
+ import { parseCollectorRepository } from "../collector-config.js";
16
+ import { appendEngineSessionMaterial, engineSessionMaterialFromOptions, } from "../package-resources/engine-material.js";
17
+ import { recordEffectiveInvocationModel, requireAuthorityRef, } from "./invocation.js";
18
+ /** Providers eligible for v1 typed-429 resume (Codex / xAI only).
19
+ * @deprecated v1 429-only gate removed by #416 (owner 2026-08-22: "根本不要有限制"). Kept for compatibility; do not use for new branching.
20
+ */
21
+ export const V1_RESUMABLE_PROVIDERS = ["openai-codex", "xai"];
22
+ /** Default for the #422 configurable single-call auto-resume ceiling
23
+ * (public-cli.json top-level `autoResumeLimit`). No longer the runtime truth
24
+ * source: runWithAutoResumeLoop receives the effective value once per call.
25
+ */
26
+ export const AUTO_RESUME_LIMIT = 2;
27
+ /** Package-owned turn trigger for resume. Not caller instruction and not semantic task content. */
28
+ export const RESUME_TRANSPORT_ENVELOPE = "[ak-role:resume-continue]";
29
+ /**
30
+ * Unique continuation-prompt selector for manual/auto resume (#471 / #600).
31
+ * Message present → base bytes unchanged; absent → package transport envelope.
32
+ * When engine material is present, append structured engine coordinates (same
33
+ * delivery as initial transport prompts). Zero parse, zero classify, zero narrow.
34
+ */
35
+ export function selectResumeContinuationPrompt(message, engineMaterial) {
36
+ const base = message !== undefined ? message : RESUME_TRANSPORT_ENVELOPE;
37
+ return appendEngineSessionMaterial([base], engineMaterial).join("\n");
38
+ }
39
+ /**
40
+ * Resume continuation with engine material resolved from the seat env (#600).
41
+ * Seat table / invocation engine axis rides the same prompt seam as initial runs.
42
+ */
43
+ export function buildResumeContinuationPrompt(options) {
44
+ return selectResumeContinuationPrompt(options.message, engineSessionMaterialFromOptions({
45
+ ...(options.engine === undefined ? {} : { engine: options.engine }),
46
+ packageRoot: options.packageRoot,
47
+ }));
48
+ }
49
+ const RUN_STATE_FILE = "run-state.json";
50
+ const WRITER_LOCK_FILE = "writer.lock";
51
+ /** @deprecated #416: 429-only classification removed; kept for compatibility. */
52
+ export function isV1ResumableProvider(provider) {
53
+ return V1_RESUMABLE_PROVIDERS.includes(provider);
54
+ }
55
+ /** @deprecated #416: 429-only observation no longer gates resume; kept for historical runs. */
56
+ export async function readTypedHttp429Observation(runDirectory) {
57
+ const observation = await readLatestTypedProviderHttpObservation(runDirectory);
58
+ if (observation === undefined)
59
+ return undefined;
60
+ if (observation.httpStatus !== 429)
61
+ return undefined;
62
+ if (!isV1ResumableProvider(observation.provider))
63
+ return undefined;
64
+ return { httpStatus: 429, provider: observation.provider };
65
+ }
66
+ /** Complete public resume command. Run ID is revealed only through this command text. */
67
+ export function renderResumeCommand(runId) {
68
+ return `ak-role resume ${runId}`;
69
+ }
70
+ export async function writeRoleRunState(runDirectory, record) {
71
+ const payload = { ...record, runDirectory };
72
+ await writeFile(join(runDirectory, RUN_STATE_FILE), `${JSON.stringify(payload, null, 2)}\n`, "utf8");
73
+ }
74
+ function parseSameTicketSummonsMaterials(raw) {
75
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw))
76
+ return undefined;
77
+ const record = raw;
78
+ const instruction = typeof record.instruction === "string" ? record.instruction : undefined;
79
+ const instructionEmpty = typeof record.instructionEmpty === "boolean" ? record.instructionEmpty : undefined;
80
+ const attachmentPaths = Array.isArray(record.attachmentPaths)
81
+ ? record.attachmentPaths.filter((p) => typeof p === "string" && p.length > 0)
82
+ : undefined;
83
+ const sourceRunPath = typeof record.sourceRunPath === "string" && record.sourceRunPath.trim() !== ""
84
+ ? record.sourceRunPath
85
+ : undefined;
86
+ let sourceRun;
87
+ if (record.sourceRun !== null &&
88
+ typeof record.sourceRun === "object" &&
89
+ !Array.isArray(record.sourceRun)) {
90
+ const sr = record.sourceRun;
91
+ if (typeof sr.runId === "string" &&
92
+ sr.runId.trim() !== "" &&
93
+ typeof sr.role === "string" &&
94
+ sr.role.trim() !== "" &&
95
+ typeof sr.runDirectory === "string" &&
96
+ sr.runDirectory.trim() !== "") {
97
+ sourceRun = {
98
+ runId: sr.runId,
99
+ role: sr.role,
100
+ runDirectory: sr.runDirectory,
101
+ };
102
+ }
103
+ }
104
+ if (instruction === undefined &&
105
+ instructionEmpty === undefined &&
106
+ (attachmentPaths === undefined || attachmentPaths.length === 0) &&
107
+ sourceRunPath === undefined &&
108
+ sourceRun === undefined) {
109
+ return undefined;
110
+ }
111
+ return {
112
+ ...(instruction === undefined ? {} : { instruction }),
113
+ ...(instructionEmpty === undefined ? {} : { instructionEmpty }),
114
+ ...(attachmentPaths === undefined || attachmentPaths.length === 0
115
+ ? {}
116
+ : { attachmentPaths }),
117
+ ...(sourceRunPath === undefined ? {} : { sourceRunPath }),
118
+ ...(sourceRun === undefined ? {} : { sourceRun }),
119
+ };
120
+ }
121
+ function parseCurrentCourtState(raw) {
122
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw))
123
+ return undefined;
124
+ const record = raw;
125
+ if (typeof record.courtAttemptId !== "string" || record.courtAttemptId.length === 0) {
126
+ return undefined;
127
+ }
128
+ const summons = parseSameTicketSummonsMaterials(record.summons);
129
+ return {
130
+ courtAttemptId: record.courtAttemptId,
131
+ ...(summons === undefined ? {} : { summons }),
132
+ };
133
+ }
134
+ async function readRoleRunStateDisk(runDirectory) {
135
+ let raw;
136
+ try {
137
+ raw = JSON.parse(await readFile(join(runDirectory, RUN_STATE_FILE), "utf8"));
138
+ }
139
+ catch {
140
+ return undefined;
141
+ }
142
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
143
+ return undefined;
144
+ }
145
+ const record = raw;
146
+ if (typeof record.runId !== "string" || record.runId.trim() === "") {
147
+ return undefined;
148
+ }
149
+ if (record.role !== "judge" &&
150
+ record.role !== "coder" &&
151
+ record.role !== "fixer" &&
152
+ record.role !== "collector" &&
153
+ record.role !== "doctor" &&
154
+ record.role !== "reviewer" &&
155
+ record.role !== "merger" &&
156
+ record.role !== "notary" &&
157
+ record.role !== "countersign" &&
158
+ record.role !== "gleaner-left" &&
159
+ record.role !== "inspector" &&
160
+ record.role !== "gatekeeper" &&
161
+ record.role !== "navigator" &&
162
+ record.role !== "auditor" &&
163
+ record.role !== "evidence-child" &&
164
+ record.role !== "diarist") {
165
+ return undefined;
166
+ }
167
+ if (record.state !== "admitted" &&
168
+ record.state !== "running" &&
169
+ record.state !== "resumable" &&
170
+ record.state !== "terminal") {
171
+ return undefined;
172
+ }
173
+ if (typeof record.bookKey !== "string")
174
+ return undefined;
175
+ if (typeof record.projectRoot !== "string")
176
+ return undefined;
177
+ if (typeof record.sessionDirectory !== "string")
178
+ return undefined;
179
+ if (typeof record.admittedRequestPath !== "string")
180
+ return undefined;
181
+ const runDir = typeof record.runDirectory === "string" && record.runDirectory.trim() !== ""
182
+ ? record.runDirectory
183
+ : runDirectory;
184
+ // Principal wire stays uninterpreted — authority owns legacy sessionFile fallback.
185
+ const principalWire = {
186
+ sessionDirectory: record.sessionDirectory,
187
+ ...(typeof record.sessionFile === "string"
188
+ ? { sessionFile: record.sessionFile }
189
+ : {}),
190
+ };
191
+ let resumable;
192
+ if (record.resumable !== undefined && record.resumable !== null) {
193
+ if (typeof record.resumable === "object" &&
194
+ !Array.isArray(record.resumable)) {
195
+ const r = record.resumable;
196
+ if (r.httpStatus === 429 &&
197
+ typeof r.provider === "string" &&
198
+ isV1ResumableProvider(r.provider)) {
199
+ resumable = { httpStatus: 429, provider: r.provider };
200
+ }
201
+ }
202
+ }
203
+ const phase = record.phase === "plan" || record.phase === "apply"
204
+ ? record.phase
205
+ : undefined;
206
+ const currentCourt = parseCurrentCourtState(record.currentCourt);
207
+ return {
208
+ runId: record.runId,
209
+ role: record.role,
210
+ state: record.state,
211
+ bookKey: record.bookKey,
212
+ projectRoot: record.projectRoot,
213
+ runDirectory: runDir,
214
+ admittedRequestPath: record.admittedRequestPath,
215
+ principalWire,
216
+ ...(phase === undefined ? {} : { phase }),
217
+ ...(resumable === undefined ? {} : { resumable }),
218
+ ...(currentCourt === undefined ? {} : { currentCourt }),
219
+ };
220
+ }
221
+ async function writeRoleRunStateDisk(runDirectory, disk) {
222
+ const payload = {
223
+ runId: disk.runId,
224
+ role: disk.role,
225
+ state: disk.state,
226
+ bookKey: disk.bookKey,
227
+ projectRoot: disk.projectRoot,
228
+ runDirectory: disk.runDirectory,
229
+ sessionDirectory: disk.principalWire.sessionDirectory,
230
+ ...(disk.principalWire.sessionFile === undefined
231
+ ? {}
232
+ : { sessionFile: disk.principalWire.sessionFile }),
233
+ admittedRequestPath: disk.admittedRequestPath,
234
+ ...(disk.phase === undefined ? {} : { phase: disk.phase }),
235
+ ...(disk.resumable === undefined ? {} : { resumable: disk.resumable }),
236
+ ...(disk.currentCourt === undefined ? {} : { currentCourt: disk.currentCourt }),
237
+ };
238
+ await writeFile(join(runDirectory, RUN_STATE_FILE), `${JSON.stringify(payload, null, 2)}\n`, "utf8");
239
+ }
240
+ /** One authority.decode of the uninterpreted wire → record + opaque principal (frozen wire itself). */
241
+ function materializeRoleRunFromDisk(disk, authority) {
242
+ try {
243
+ const coordinates = authority.decode(disk.principalWire);
244
+ return {
245
+ principal: disk.principalWire,
246
+ run: {
247
+ runId: disk.runId,
248
+ role: disk.role,
249
+ state: disk.state,
250
+ bookKey: disk.bookKey,
251
+ projectRoot: disk.projectRoot,
252
+ sessionDirectory: coordinates.sessionDirectory,
253
+ sessionFile: coordinates.sessionFile,
254
+ runDirectory: disk.runDirectory,
255
+ admittedRequestPath: disk.admittedRequestPath,
256
+ ...(disk.phase === undefined ? {} : { phase: disk.phase }),
257
+ ...(disk.resumable === undefined ? {} : { resumable: disk.resumable }),
258
+ },
259
+ };
260
+ }
261
+ catch {
262
+ return undefined;
263
+ }
264
+ }
265
+ /**
266
+ * Read durable run-state and materialize principal coordinates through the
267
+ * injected host authority (legacy sessionFile fallback lives only in the codec).
268
+ */
269
+ export async function readRoleRunState(runDirectory, authority) {
270
+ const disk = await readRoleRunStateDisk(runDirectory);
271
+ if (disk === undefined)
272
+ return undefined;
273
+ return materializeRoleRunFromDisk(disk, authority)?.run;
274
+ }
275
+ /**
276
+ * Envelope identity only — no principal payload interpretation.
277
+ * Used by notary locator / role peek that never consume session coordinates.
278
+ */
279
+ export async function readRoleRunIdentity(runDirectory) {
280
+ const disk = await readRoleRunStateDisk(runDirectory);
281
+ if (disk === undefined)
282
+ return undefined;
283
+ return {
284
+ runId: disk.runId,
285
+ role: disk.role,
286
+ bookKey: disk.bookKey,
287
+ runDirectory: disk.runDirectory,
288
+ state: disk.state,
289
+ };
290
+ }
291
+ export async function markRunAdmitted(admitted, authority) {
292
+ const { sessionDirectory, sessionFile } = authority.decode(admitted.principal);
293
+ await writeRoleRunState(admitted.runDirectory, {
294
+ runId: admitted.runId,
295
+ role: admitted.role,
296
+ state: "admitted",
297
+ bookKey: admitted.bookKey,
298
+ projectRoot: admitted.projectRoot,
299
+ sessionDirectory,
300
+ sessionFile,
301
+ admittedRequestPath: admitted.admittedRequestPath,
302
+ ...(admitted.role === "coder" || admitted.role === "fixer"
303
+ ? { phase: admitted.phase }
304
+ : {}),
305
+ });
306
+ }
307
+ /**
308
+ * Shared dispatch execution seam: record the effective launch model (initial or
309
+ * resume override) and the authoritative seat engine/host onto invocation.json,
310
+ * then transition to running.
311
+ * Role runners must not coordinate lifecycle ledger writes themselves.
312
+ * Engine axis is authoritative here (#617): present string is written; omit/undefined
313
+ * clears any prior engine key so unset-engine + resume does not keep a stale value.
314
+ * (Non-authoritative partial updates still use recordEffectiveInvocationModel directly
315
+ * with `engine: undefined` to preserve.)
316
+ */
317
+ export async function markRunRunning(runDirectory, effectiveModel, effectiveEngine, effectiveHost) {
318
+ await recordEffectiveInvocationModel(runDirectory, effectiveModel,
319
+ // Authoritative seat projection: absent engine ⇒ null (delete).
320
+ effectiveEngine === undefined ? null : effectiveEngine, effectiveHost);
321
+ const current = await readRoleRunStateDisk(runDirectory);
322
+ if (current === undefined) {
323
+ throw new Error("cannot mark running: run state missing");
324
+ }
325
+ // Omit resumable while a writer is active. Principal wire is passed through uninterpreted.
326
+ // Preserve open currentCourt across running transitions (#637).
327
+ await writeRoleRunStateDisk(runDirectory, {
328
+ runId: current.runId,
329
+ role: current.role,
330
+ state: "running",
331
+ bookKey: current.bookKey,
332
+ projectRoot: current.projectRoot,
333
+ runDirectory: current.runDirectory,
334
+ admittedRequestPath: current.admittedRequestPath,
335
+ principalWire: current.principalWire,
336
+ ...(current.phase === undefined ? {} : { phase: current.phase }),
337
+ ...(current.currentCourt === undefined ? {} : { currentCourt: current.currentCourt }),
338
+ });
339
+ }
340
+ /** @deprecated #416: 429-only resumable marker; kept for historical runs. */
341
+ export async function markRunResumable(runDirectory, observation) {
342
+ const current = await readRoleRunStateDisk(runDirectory);
343
+ if (current === undefined) {
344
+ throw new Error("cannot mark resumable: run state missing");
345
+ }
346
+ await writeRoleRunStateDisk(runDirectory, {
347
+ ...current,
348
+ state: "resumable",
349
+ resumable: observation,
350
+ });
351
+ }
352
+ export async function markRunTerminal(runDirectory) {
353
+ const current = await readRoleRunStateDisk(runDirectory);
354
+ if (current === undefined) {
355
+ throw new Error("cannot mark terminal: run state missing");
356
+ }
357
+ // Preserve open currentCourt: terminal after a failed/incomplete court must still
358
+ // let bare resume continue that court (#637).
359
+ await writeRoleRunStateDisk(runDirectory, {
360
+ runId: current.runId,
361
+ role: current.role,
362
+ state: "terminal",
363
+ bookKey: current.bookKey,
364
+ projectRoot: current.projectRoot,
365
+ runDirectory: current.runDirectory,
366
+ admittedRequestPath: current.admittedRequestPath,
367
+ principalWire: current.principalWire,
368
+ ...(current.phase === undefined ? {} : { phase: current.phase }),
369
+ ...(current.currentCourt === undefined ? {} : { currentCourt: current.currentCourt }),
370
+ });
371
+ }
372
+ /** Read the open court turn on a retained run, if any (#637). */
373
+ export async function readCurrentCourt(runDirectory) {
374
+ const current = await readRoleRunStateDisk(runDirectory);
375
+ return current?.currentCourt;
376
+ }
377
+ /** Persist the open court turn identity + materials (#637). */
378
+ export async function recordCurrentCourt(runDirectory, court) {
379
+ const current = await readRoleRunStateDisk(runDirectory);
380
+ if (current === undefined) {
381
+ throw new Error("cannot record current court: run state missing");
382
+ }
383
+ await writeRoleRunStateDisk(runDirectory, {
384
+ ...current,
385
+ currentCourt: court,
386
+ });
387
+ }
388
+ /**
389
+ * Clear open court after this courtAttemptId seals, or when the open court is
390
+ * already sealed and bare resume returns to run-scoped idempotence (#637).
391
+ * When expectedCourtAttemptId is set, clear only if it still matches — never
392
+ * drop a different court recorded under the writer lease after our judgment.
393
+ */
394
+ export async function clearCurrentCourt(runDirectory, expectedCourtAttemptId) {
395
+ const current = await readRoleRunStateDisk(runDirectory);
396
+ if (current === undefined || current.currentCourt === undefined)
397
+ return;
398
+ if (expectedCourtAttemptId !== undefined &&
399
+ current.currentCourt.courtAttemptId !== expectedCourtAttemptId) {
400
+ return;
401
+ }
402
+ await writeRoleRunStateDisk(runDirectory, {
403
+ runId: current.runId,
404
+ role: current.role,
405
+ state: current.state,
406
+ bookKey: current.bookKey,
407
+ projectRoot: current.projectRoot,
408
+ runDirectory: current.runDirectory,
409
+ admittedRequestPath: current.admittedRequestPath,
410
+ principalWire: current.principalWire,
411
+ ...(current.phase === undefined ? {} : { phase: current.phase }),
412
+ ...(current.resumable === undefined ? {} : { resumable: current.resumable }),
413
+ });
414
+ }
415
+ /**
416
+ * True when the host authority reports the durable principal available.
417
+ * Resume must reopen this exact principal; directory-latest is not identity.
418
+ */
419
+ export async function isDurablePrincipalAvailable(principal, authority) {
420
+ return authority.isAvailable(principal);
421
+ }
422
+ export class RunWriterLeaseHeldError extends Error {
423
+ code = "AK_RUN_WRITER_LEASE_HELD";
424
+ constructor(message = "role run writer lease is already held") {
425
+ super(message);
426
+ this.name = "RunWriterLeaseHeldError";
427
+ }
428
+ }
429
+ /**
430
+ * True error identity for diagnostics — name/code/message as-is, never a
431
+ * guessed label (failure-honesty constitution).
432
+ */
433
+ export function describeErrorIdentity(error) {
434
+ const candidate = error;
435
+ const name = typeof candidate?.name === "string" && candidate.name !== ""
436
+ ? candidate.name
437
+ : typeof error;
438
+ const code = typeof candidate?.code === "string" || typeof candidate?.code === "number"
439
+ ? ` code=${String(candidate.code)}`
440
+ : "";
441
+ const message = typeof candidate?.message === "string" && candidate.message !== ""
442
+ ? `: ${candidate.message}`
443
+ : "";
444
+ return `${name}${code}${message}`;
445
+ }
446
+ function errorCodeOf(error) {
447
+ return error.code;
448
+ }
449
+ /**
450
+ * Signal-0 liveness probe. Only ESRCH proves absence; any other refusal
451
+ * (e.g. EPERM) means the holder process exists.
452
+ */
453
+ function isProcessAlive(pid) {
454
+ try {
455
+ process.kill(pid, 0);
456
+ return true;
457
+ }
458
+ catch (error) {
459
+ return errorCodeOf(error) !== "ESRCH";
460
+ }
461
+ }
462
+ /**
463
+ * Holder autopsy for an existing writer.lock (#552). "absent" covers no file,
464
+ * no parseable pid (a live creator mid-acquisition reads as empty, and so does
465
+ * the crash-window leftover), and unreadable files — absent alone never
466
+ * authorizes reclaim; only a "dead" verdict does. A non-ENOENT read failure
467
+ * still decides "absent" but rides along as readFailure so the true cause can
468
+ * land in the cleanup sink instead of being laundered away.
469
+ */
470
+ async function autopsyWriterLock(lockPath) {
471
+ let content;
472
+ try {
473
+ content = await readFile(lockPath, "utf8");
474
+ }
475
+ catch (error) {
476
+ if (errorCodeOf(error) === "ENOENT")
477
+ return { verdict: "absent" };
478
+ return { verdict: "absent", readFailure: error };
479
+ }
480
+ const normalized = content.trim();
481
+ if (!/^[1-9]\d*$/.test(normalized))
482
+ return { verdict: "absent" };
483
+ const pid = Number.parseInt(normalized, 10);
484
+ if (!Number.isSafeInteger(pid) || pid <= 0)
485
+ return { verdict: "absent" };
486
+ return isProcessAlive(pid) ? { verdict: "alive", pid } : { verdict: "dead", pid };
487
+ }
488
+ function describeAutopsy(autopsy) {
489
+ switch (autopsy.verdict) {
490
+ case "alive":
491
+ return `live pid ${autopsy.pid}`;
492
+ case "dead":
493
+ return `dead pid ${autopsy.pid}`;
494
+ case "absent":
495
+ return autopsy.readFailure !== undefined
496
+ ? "unreadable lock"
497
+ : "absent or unparseable holder";
498
+ }
499
+ }
500
+ /**
501
+ * Remove one lock whose re-read autopsy is still a verified-dead holder, or
502
+ * leave it for the next round otherwise. The pre-unlink re-read guard means a
503
+ * concurrent writer that re-locked between the autopsy and the unlink cannot
504
+ * have its live lock stolen. Residual race: a writer can still re-lock between
505
+ * the re-read and the unlink itself; POSIX offers no compare-and-delete, and
506
+ * this narrows the window to a single syscall pair.
507
+ *
508
+ * An EACCES unlink (non-writable run directory) is recovered by restoring the
509
+ * directory permissions only — never by a blind retrying unlink. After the
510
+ * chmod the caller loop re-runs the create/autopsy cycle, so any unlink still
511
+ * follows a fresh verified-dead verdict on the current pathname content; a
512
+ * contender that installed its live lock inside the recovery window reads as
513
+ * alive and is left alone (#629).
514
+ *
515
+ * Returns whether the lock was actually deleted.
516
+ */
517
+ async function reclaimStaleWriterLock(lockPath, runDirectory) {
518
+ const current = await autopsyWriterLock(lockPath);
519
+ if (current.verdict !== "dead")
520
+ return { reclaimed: false };
521
+ try {
522
+ await unlink(lockPath);
523
+ return { reclaimed: true };
524
+ }
525
+ catch (error) {
526
+ if (errorCodeOf(error) === "ENOENT")
527
+ return { reclaimed: false };
528
+ if (errorCodeOf(error) !== "EACCES")
529
+ throw error;
530
+ // Restore directory permissions and drop the round: re-unlinking here
531
+ // without a fresh autopsy could delete a contender's live lock that was
532
+ // installed while the directory was unwritable (#629 TOCTOU).
533
+ await chmod(runDirectory, 0o755);
534
+ // A chmod-proof EACCES (e.g. a deny-delete ACE the mode change cannot
535
+ // clear) recurs every round; hand the identity to the caller so the final
536
+ // stayed-contested refusal can still name the true cause (#629).
537
+ return { reclaimed: false, eaccesFailure: error };
538
+ }
539
+ }
540
+ async function createWriterLease(lockPath, runDirectory, reportCleanupFailure) {
541
+ const handle = await open(lockPath, "wx");
542
+ try {
543
+ await handle.writeFile(`${process.pid}\n`, "utf8");
544
+ }
545
+ catch (error) {
546
+ await handle.close().catch(() => undefined);
547
+ await unlink(lockPath).catch(() => undefined);
548
+ throw error;
549
+ }
550
+ let released = false;
551
+ return {
552
+ lockPath,
553
+ async release() {
554
+ if (released)
555
+ return;
556
+ released = true;
557
+ await handle.close().catch(() => undefined);
558
+ try {
559
+ await unlink(lockPath);
560
+ }
561
+ catch (error) {
562
+ if (errorCodeOf(error) === "EACCES") {
563
+ try {
564
+ await chmod(runDirectory, 0o755);
565
+ await unlink(lockPath);
566
+ }
567
+ catch (retryError) {
568
+ reportCleanupFailure(retryError);
569
+ }
570
+ }
571
+ else {
572
+ reportCleanupFailure(error);
573
+ }
574
+ }
575
+ },
576
+ };
577
+ }
578
+ const WRITER_LEASE_RECLAIM_ROUNDS = 3;
579
+ /**
580
+ * Acquire the one-writer lease for a Role run. Exclusive create — no second
581
+ * writer; a concurrent acquire rejects without dispatch.
582
+ *
583
+ * A contested lock gets a holder autopsy before rejection (#552): only a
584
+ * verified-dead holder pid — parseable pid, signal-0 ESRCH, and still dead on
585
+ * the pre-unlink re-read — authorizes reclaim, because no writer is left to
586
+ * release the lock; acquire then retries the create. An empty, unparseable, or
587
+ * unreadable lock proves no dead holder (a live creator is mid-acquisition
588
+ * between the exclusive create and its pid write), so it rejects as
589
+ * RunWriterLeaseHeldError naming the path and the lock stays on disk — a
590
+ * crash-window empty lock blocking a resume is that refusal's known residue,
591
+ * not safely fixable by unlink here. A live holder rejects the same typed
592
+ * error naming the pid and path. A pid recycled by an unrelated process reads
593
+ * as alive — that degrades to the same typed rejection, never worse than the
594
+ * pre-#552 behavior. Reclaim rounds are bounded by
595
+ * WRITER_LEASE_RECLAIM_ROUNDS; a lock that stays contested (e.g. a reclaim
596
+ * race repeatedly lost) surfaces the same typed error instead of spinning.
597
+ * When every round's unlink fails with a chmod-proof EACCES, the final
598
+ * refusal additionally carries the last reclaim failure's error identity so
599
+ * the true cause stays observable (#629).
600
+ *
601
+ * `onCleanupFailure` receives a non-terminal diagnostic line when release-time
602
+ * lock cleanup fails, a contested lock cannot be read, or a stale lock is
603
+ * reclaimed (the #556 orphan-pi residual declaration). Release stays
604
+ * best-effort, but no diagnostic promises that the next acquire reclaims a
605
+ * residual lock: a release-failed residual carries this process's live pid
606
+ * (the next acquire rejects it as held), and an unreadable contested lock is
607
+ * left in place. The true error identity must still land somewhere observable
608
+ * — silent swallowing is forbidden.
609
+ */
610
+ export async function acquireRunWriterLease(runDirectory, onCleanupFailure) {
611
+ const reportDiagnostic = (diagnostic, kind) => {
612
+ const line = diagnostic.endsWith("\n") ? diagnostic : `${diagnostic}\n`;
613
+ try {
614
+ onCleanupFailure?.(line, kind);
615
+ }
616
+ catch {
617
+ // diagnostic-sink failure is itself best-effort; never break acquire()/release().
618
+ }
619
+ };
620
+ /**
621
+ * Release-time cleanup failure: the release could not remove the lock, so
622
+ * the residual lock (carrying this process's pid) stays on disk — the next
623
+ * acquire reads it as live and rejects; nothing here may promise that the
624
+ * next acquire reclaims it.
625
+ */
626
+ const reportCleanupFailure = (error) => {
627
+ reportDiagnostic(`writer lease lock cleanup failed (release is best-effort; residual lock left in place) at ${join(runDirectory, WRITER_LOCK_FILE)}: ${describeErrorIdentity(error)}`);
628
+ };
629
+ /** Contested-lock read failure: nothing was cleaned up; the lock stays exactly where it is. */
630
+ const reportReadFailure = (error) => {
631
+ reportDiagnostic(`writer lease lock read failed (holder liveness unverifiable; lock left in place) at ${join(runDirectory, WRITER_LOCK_FILE)}: ${describeErrorIdentity(error)}`);
632
+ };
633
+ const lockPath = join(runDirectory, WRITER_LOCK_FILE);
634
+ let lastAutopsy = { verdict: "absent" };
635
+ let lastReclaimFailure;
636
+ for (let reclaimsLeft = WRITER_LEASE_RECLAIM_ROUNDS;; reclaimsLeft -= 1) {
637
+ try {
638
+ return await createWriterLease(lockPath, runDirectory, reportCleanupFailure);
639
+ }
640
+ catch (error) {
641
+ if (errorCodeOf(error) !== "EEXIST")
642
+ throw error;
643
+ }
644
+ lastAutopsy = await autopsyWriterLock(lockPath);
645
+ if (lastAutopsy.verdict === "absent" && lastAutopsy.readFailure !== undefined) {
646
+ reportReadFailure(lastAutopsy.readFailure);
647
+ }
648
+ if (lastAutopsy.verdict === "alive") {
649
+ throw new RunWriterLeaseHeldError(`role run writer lease is already held by live pid ${lastAutopsy.pid} at ${lockPath}`);
650
+ }
651
+ if (lastAutopsy.verdict === "absent") {
652
+ throw new RunWriterLeaseHeldError(lastAutopsy.readFailure !== undefined
653
+ ? `role run writer lease lock is unreadable at ${lockPath}: ${describeErrorIdentity(lastAutopsy.readFailure)}; holder liveness unverifiable, lock left in place`
654
+ : `role run writer lease lock at ${lockPath} has no verifiable holder pid (empty or unparseable); holder liveness unverifiable, lock left in place`);
655
+ }
656
+ if (reclaimsLeft <= 0)
657
+ break;
658
+ let reclaimed = false;
659
+ try {
660
+ const outcome = await reclaimStaleWriterLock(lockPath, runDirectory);
661
+ reclaimed = outcome.reclaimed;
662
+ if (!reclaimed && outcome.eaccesFailure !== undefined) {
663
+ lastReclaimFailure = outcome.eaccesFailure;
664
+ }
665
+ }
666
+ catch (reclaimError) {
667
+ throw new RunWriterLeaseHeldError(`stale writer lease reclaim failed at ${lockPath} (autopsy: ${describeAutopsy(lastAutopsy)}): ${describeErrorIdentity(reclaimError)}`);
668
+ }
669
+ if (reclaimed) {
670
+ reportDiagnostic(`stale writer lease reclaimed at ${lockPath} (holder pid ${lastAutopsy.pid} verified dead): the killed holder may have left an orphaned pi child still writing this run — check for a surviving pi process on this run before continuing`, "stale-reclaimed");
671
+ }
672
+ }
673
+ throw new RunWriterLeaseHeldError(lastReclaimFailure !== undefined
674
+ ? `role run writer lease stayed contested at ${lockPath} after ${WRITER_LEASE_RECLAIM_ROUNDS} reclaims (last autopsy: ${describeAutopsy(lastAutopsy)}; last reclaim failure: ${describeErrorIdentity(lastReclaimFailure)})`
675
+ : `role run writer lease stayed contested at ${lockPath} after ${WRITER_LEASE_RECLAIM_ROUNDS} reclaims (last autopsy: ${describeAutopsy(lastAutopsy)})`);
676
+ }
677
+ /**
678
+ * Locate a Role run directory by run ID under the ledger books home.
679
+ * Returns undefined when the ID is unknown.
680
+ */
681
+ export async function findRunDirectoryById(home, runId) {
682
+ if (runId.trim() === "")
683
+ return undefined;
684
+ const ledgerHome = resolveActivationLedgerHome(home);
685
+ const booksRoot = join(ledgerHome, "books");
686
+ let bookKeys;
687
+ try {
688
+ bookKeys = await readdir(booksRoot);
689
+ }
690
+ catch {
691
+ return undefined;
692
+ }
693
+ for (const bookKey of bookKeys) {
694
+ const runsDir = join(activationBookDirectory(ledgerHome, bookKey), "runs");
695
+ let entries;
696
+ try {
697
+ entries = await readdir(runsDir);
698
+ }
699
+ catch {
700
+ continue;
701
+ }
702
+ for (const entry of entries) {
703
+ if (entry === `${runId}@judge` || entry.startsWith(`${runId}@`)) {
704
+ return join(runsDir, entry);
705
+ }
706
+ }
707
+ }
708
+ return undefined;
709
+ }
710
+ /**
711
+ * Locate the latest retained run for one seat+ticket under a book (#637).
712
+ * Same walk surface as findRunDirectoryById; ticket identity from durable pages.
713
+ * runId is UUIDv7 — lexicographic max is latest. No parallel index.
714
+ * Only a truly missing runs directory means no history; damage/permission errors propagate.
715
+ */
716
+ export async function findLatestRunIdForSeatTicket(input) {
717
+ const ledgerHome = resolveActivationLedgerHome(input.home);
718
+ const runsDir = join(activationBookDirectory(ledgerHome, input.bookKey), "runs");
719
+ let entries;
720
+ try {
721
+ entries = await readdir(runsDir);
722
+ }
723
+ catch (error) {
724
+ if (errorCodeOf(error) === "ENOENT")
725
+ return undefined;
726
+ throw error;
727
+ }
728
+ const suffix = `@${input.role}`;
729
+ let best;
730
+ for (const entry of entries) {
731
+ if (!entry.endsWith(suffix))
732
+ continue;
733
+ const runId = entry.slice(0, entry.length - suffix.length);
734
+ if (runId.length === 0)
735
+ continue;
736
+ const ticketNumber = await readRunTicketNumber(join(runsDir, entry));
737
+ if (ticketNumber !== input.ticketNumber)
738
+ continue;
739
+ if (best === undefined || runId > best)
740
+ best = runId;
741
+ }
742
+ return best;
743
+ }
744
+ /** Restore optional correlation + typed ticket identity from a durable admitted page. */
745
+ function parsePersistedTicketIdentity(record) {
746
+ const correlationId = typeof record.correlationId === "string" && record.correlationId.trim() !== ""
747
+ ? record.correlationId
748
+ : undefined;
749
+ const ticketNumber = typeof record.ticketNumber === "number" &&
750
+ Number.isSafeInteger(record.ticketNumber) &&
751
+ record.ticketNumber >= 1
752
+ ? record.ticketNumber
753
+ : undefined;
754
+ const ticketResolution = record.ticketResolution === "true-unbound" ? "true-unbound" : undefined;
755
+ return {
756
+ ...(correlationId === undefined ? {} : { correlationId }),
757
+ ...(ticketNumber === undefined ? {} : { ticketNumber }),
758
+ ...(ticketResolution === undefined ? {} : { ticketResolution }),
759
+ };
760
+ }
761
+ function restoredTicketFields(fields) {
762
+ return {
763
+ ...(fields.correlationId === undefined ? {} : { correlationId: fields.correlationId }),
764
+ ...(fields.ticketNumber === undefined ? {} : { ticketNumber: fields.ticketNumber }),
765
+ ...(fields.ticketResolution === undefined
766
+ ? {}
767
+ : { ticketResolution: fields.ticketResolution }),
768
+ };
769
+ }
770
+ async function loadResumableRunRecord(home, runId, authority) {
771
+ const runDirectory = await findRunDirectoryById(home, runId);
772
+ if (runDirectory === undefined) {
773
+ throw new CliUsageError(`unknown role run id: ${runId}`);
774
+ }
775
+ const disk = await readRoleRunStateDisk(runDirectory);
776
+ if (disk === undefined) {
777
+ throw new CliUsageError(`unknown role run id: ${runId}`);
778
+ }
779
+ // #416: removed terminal/resumable gates per owner decision "根本不要有限制" (2026-08-22).
780
+ // Only the exact Pi session principal check remains as honest failure.
781
+ // One authority.decode of the uninterpreted wire yields both principal and record.
782
+ const materialized = materializeRoleRunFromDisk(disk, authority);
783
+ if (materialized === undefined) {
784
+ throw new CliUsageError(`unknown role run id: ${runId}`);
785
+ }
786
+ const { run, principal } = materialized;
787
+ if (!(await isDurablePrincipalAvailable(principal, authority))) {
788
+ throw new CliUsageError(`role run Pi session principal is unavailable: ${runId}`);
789
+ }
790
+ // Reconstruct admitted identity from durable run record + admitted-request.json.
791
+ let instruction = "";
792
+ let instructionEmpty = true;
793
+ let attachments = [];
794
+ let phase;
795
+ let taskPath;
796
+ let packetPath;
797
+ let prerequisitesPath;
798
+ let prerequisites;
799
+ let baseRevision;
800
+ let authorityRefs;
801
+ let mergerInputPath;
802
+ let derived;
803
+ let correlationId;
804
+ let ticketNumber;
805
+ let ticketResolution;
806
+ let prNumber;
807
+ let repository;
808
+ let repositoryDisplay;
809
+ let requestManifestPath;
810
+ let manifestDigest;
811
+ let issueNumber;
812
+ let caseRunsPath;
813
+ let caseIdentity;
814
+ let sourceRunPath;
815
+ let sourceRun;
816
+ try {
817
+ const raw = JSON.parse(await readFile(run.admittedRequestPath, "utf8"));
818
+ if (raw !== null && typeof raw === "object" && !Array.isArray(raw)) {
819
+ const record = raw;
820
+ if (typeof record.instruction === "string") {
821
+ instruction = record.instruction;
822
+ }
823
+ if (typeof record.instructionEmpty === "boolean") {
824
+ instructionEmpty = record.instructionEmpty;
825
+ }
826
+ if (Array.isArray(record.attachments)) {
827
+ attachments = record.attachments;
828
+ }
829
+ if (record.phase === "plan" || record.phase === "apply") {
830
+ phase = record.phase;
831
+ }
832
+ if (typeof record.taskPath === "string" && record.taskPath.trim() !== "") {
833
+ taskPath = record.taskPath;
834
+ }
835
+ if (typeof record.packetPath === "string" && record.packetPath.trim() !== "") {
836
+ packetPath = record.packetPath;
837
+ }
838
+ if (typeof record.prerequisitesPath === "string" &&
839
+ record.prerequisitesPath.trim() !== "") {
840
+ prerequisitesPath = record.prerequisitesPath;
841
+ }
842
+ if (Array.isArray(record.prerequisites)) {
843
+ prerequisites = record.prerequisites;
844
+ }
845
+ if (typeof record.baseRevision === "string" &&
846
+ record.baseRevision.trim() !== "") {
847
+ baseRevision = record.baseRevision;
848
+ }
849
+ // Collector — admitted repository/PR identity (#633 resume).
850
+ if (typeof record.prNumber === "number" && Number.isSafeInteger(record.prNumber) && record.prNumber >= 1) {
851
+ prNumber = record.prNumber;
852
+ }
853
+ if (typeof record.repository === "string" && record.repository.trim() !== "") {
854
+ repository = record.repository;
855
+ }
856
+ if (typeof record.repositoryDisplay === "string" && record.repositoryDisplay.trim() !== "") {
857
+ repositoryDisplay = record.repositoryDisplay;
858
+ }
859
+ if (typeof record.requestManifestPath === "string" && record.requestManifestPath.trim() !== "") {
860
+ requestManifestPath = record.requestManifestPath;
861
+ }
862
+ if (typeof record.manifestDigest === "string" && record.manifestDigest.trim() !== "") {
863
+ manifestDigest = record.manifestDigest;
864
+ }
865
+ // Doctor — admitted single-case identity (#633 resume).
866
+ if (typeof record.issueNumber === "number" && Number.isSafeInteger(record.issueNumber) && record.issueNumber >= 1) {
867
+ issueNumber = record.issueNumber;
868
+ }
869
+ if (typeof record.caseRunsPath === "string" && record.caseRunsPath.trim() !== "") {
870
+ caseRunsPath = record.caseRunsPath;
871
+ }
872
+ if (record.caseIdentity !== null &&
873
+ typeof record.caseIdentity === "object" &&
874
+ !Array.isArray(record.caseIdentity)) {
875
+ const ci = record.caseIdentity;
876
+ if (typeof ci.issueNumber === "number" &&
877
+ Number.isSafeInteger(ci.issueNumber) &&
878
+ ci.issueNumber >= 1 &&
879
+ typeof ci.runsPath === "string" &&
880
+ ci.runsPath.trim() !== "") {
881
+ caseIdentity = { issueNumber: ci.issueNumber, runsPath: ci.runsPath };
882
+ }
883
+ }
884
+ // Notary — admitted source-run locator identity (#633 resume).
885
+ if (typeof record.sourceRunPath === "string" && record.sourceRunPath.trim() !== "") {
886
+ sourceRunPath = record.sourceRunPath;
887
+ }
888
+ if (record.sourceRun !== null &&
889
+ typeof record.sourceRun === "object" &&
890
+ !Array.isArray(record.sourceRun)) {
891
+ const sr = record.sourceRun;
892
+ if (typeof sr.runId === "string" &&
893
+ sr.runId.trim() !== "" &&
894
+ typeof sr.role === "string" &&
895
+ sr.role.trim() !== "" &&
896
+ typeof sr.runDirectory === "string" &&
897
+ sr.runDirectory.trim() !== "") {
898
+ sourceRun = {
899
+ runId: sr.runId,
900
+ role: sr.role,
901
+ runDirectory: sr.runDirectory,
902
+ };
903
+ }
904
+ }
905
+ if (Array.isArray(record.authorityRefs)) {
906
+ // Reuse unique --authority-ref grammar; blank/inline prose must not resume as authority.
907
+ authorityRefs = Object.freeze(record.authorityRefs.map((ref) => {
908
+ if (typeof ref !== "string") {
909
+ throw new CliUsageError("role run admitted authority refs must be durable reference strings");
910
+ }
911
+ return requireAuthorityRef(ref);
912
+ }));
913
+ }
914
+ if (typeof record.mergerInputPath === "string" &&
915
+ record.mergerInputPath.trim() !== "") {
916
+ mergerInputPath = record.mergerInputPath;
917
+ }
918
+ if (record.derived !== null &&
919
+ typeof record.derived === "object" &&
920
+ !Array.isArray(record.derived)) {
921
+ const d = record.derived;
922
+ if (typeof d.targetObjectId === "string" &&
923
+ typeof d.sourceObjectId === "string" &&
924
+ typeof d.automaticMergeTreeId === "string" &&
925
+ Array.isArray(d.expectedConflictPaths) &&
926
+ Array.isArray(d.resolutionScope) &&
927
+ d.expectedConflictPaths.every((p) => typeof p === "string") &&
928
+ d.resolutionScope.every((p) => typeof p === "string")) {
929
+ derived = {
930
+ targetObjectId: d.targetObjectId,
931
+ sourceObjectId: d.sourceObjectId,
932
+ automaticMergeTreeId: d.automaticMergeTreeId,
933
+ expectedConflictPaths: d.expectedConflictPaths,
934
+ resolutionScope: d.resolutionScope,
935
+ };
936
+ }
937
+ }
938
+ const fromAdmitted = parsePersistedTicketIdentity(record);
939
+ correlationId = fromAdmitted.correlationId;
940
+ ticketNumber = fromAdmitted.ticketNumber;
941
+ ticketResolution = fromAdmitted.ticketResolution;
942
+ }
943
+ }
944
+ catch (error) {
945
+ // Preserve unique --authority-ref grammar failures; do not collapse to unreadable.
946
+ if (error instanceof CliUsageError)
947
+ throw error;
948
+ throw new CliUsageError(`role run admitted request is unreadable: ${runId}`, { cause: error });
949
+ }
950
+ let model;
951
+ try {
952
+ const invocationRaw = JSON.parse(await readFile(join(run.runDirectory, "invocation.json"), "utf8"));
953
+ if (invocationRaw !== null &&
954
+ typeof invocationRaw === "object" &&
955
+ !Array.isArray(invocationRaw)) {
956
+ const rec = invocationRaw;
957
+ if (typeof rec.provider === "string" && typeof rec.model === "string") {
958
+ model = {
959
+ provider: rec.provider,
960
+ model: rec.model,
961
+ ...(typeof rec.thinking === "string"
962
+ ? { thinking: rec.thinking }
963
+ : {}),
964
+ };
965
+ }
966
+ if (correlationId === undefined ||
967
+ ticketNumber === undefined ||
968
+ ticketResolution === undefined) {
969
+ const fromInvocation = parsePersistedTicketIdentity(rec);
970
+ if (correlationId === undefined)
971
+ correlationId = fromInvocation.correlationId;
972
+ if (ticketNumber === undefined)
973
+ ticketNumber = fromInvocation.ticketNumber;
974
+ if (ticketResolution === undefined) {
975
+ ticketResolution = fromInvocation.ticketResolution;
976
+ }
977
+ }
978
+ }
979
+ }
980
+ catch (error) {
981
+ if (!(error instanceof Error &&
982
+ "code" in error &&
983
+ error.code === "ENOENT")) {
984
+ throw new CliUsageError(`role run invocation identity is unreadable: ${runId}`, { cause: error });
985
+ }
986
+ }
987
+ return {
988
+ run,
989
+ principal,
990
+ ...(run.resumable === undefined ? {} : { observation: run.resumable }),
991
+ admittedFields: {
992
+ instruction,
993
+ instructionEmpty,
994
+ attachments,
995
+ ...(phase === undefined ? {} : { phase }),
996
+ ...(taskPath === undefined ? {} : { taskPath }),
997
+ ...(packetPath === undefined ? {} : { packetPath }),
998
+ ...(prerequisitesPath === undefined ? {} : { prerequisitesPath }),
999
+ ...(prerequisites === undefined ? {} : { prerequisites }),
1000
+ ...(baseRevision === undefined ? {} : { baseRevision }),
1001
+ ...(authorityRefs === undefined ? {} : { authorityRefs }),
1002
+ ...(mergerInputPath === undefined ? {} : { mergerInputPath }),
1003
+ ...(derived === undefined ? {} : { derived }),
1004
+ ...(correlationId === undefined ? {} : { correlationId }),
1005
+ ...(ticketNumber === undefined ? {} : { ticketNumber }),
1006
+ ...(ticketResolution === undefined ? {} : { ticketResolution }),
1007
+ ...(prNumber === undefined ? {} : { prNumber }),
1008
+ ...(repository === undefined ? {} : { repository }),
1009
+ ...(repositoryDisplay === undefined ? {} : { repositoryDisplay }),
1010
+ ...(requestManifestPath === undefined ? {} : { requestManifestPath }),
1011
+ ...(manifestDigest === undefined ? {} : { manifestDigest }),
1012
+ ...(issueNumber === undefined ? {} : { issueNumber }),
1013
+ ...(caseRunsPath === undefined ? {} : { caseRunsPath }),
1014
+ ...(caseIdentity === undefined ? {} : { caseIdentity }),
1015
+ ...(sourceRunPath === undefined ? {} : { sourceRunPath }),
1016
+ ...(sourceRun === undefined ? {} : { sourceRun }),
1017
+ ...(model === undefined ? {} : { model }),
1018
+ },
1019
+ };
1020
+ }
1021
+ /**
1022
+ * Base admitted projection shared by every seat loader: durable identity +
1023
+ * instruction/attachments + principal + restored ticket identity + model (#633).
1024
+ * Seat loaders add only their seat-specific fields on top.
1025
+ */
1026
+ function resumedBaseAdmitted(loaded) {
1027
+ return {
1028
+ runId: loaded.run.runId,
1029
+ bookKey: loaded.run.bookKey,
1030
+ projectRoot: loaded.run.projectRoot,
1031
+ instruction: loaded.admittedFields.instruction,
1032
+ instructionEmpty: loaded.admittedFields.instructionEmpty,
1033
+ attachments: loaded.admittedFields.attachments,
1034
+ runDirectory: loaded.run.runDirectory,
1035
+ principal: loaded.principal,
1036
+ admittedRequestPath: loaded.run.admittedRequestPath,
1037
+ ...(loaded.admittedFields.model === undefined ? {} : { model: loaded.admittedFields.model }),
1038
+ ...restoredTicketFields(loaded.admittedFields),
1039
+ };
1040
+ }
1041
+ /** Loaded-run envelope projection; seat loaders add only their seat fields. */
1042
+ function seatLoadedResult(loaded, admitted) {
1043
+ return {
1044
+ admitted,
1045
+ run: loaded.run,
1046
+ ...(loaded.observation === undefined ? {} : { observation: loaded.observation }),
1047
+ };
1048
+ }
1049
+ /**
1050
+ * Load a resumable Judge run for resume. Rejects unknown, terminal,
1051
+ * non-resumable, and non-Judge IDs without replaying dispatch.
1052
+ */
1053
+ export async function loadResumableJudgeRun(home, runId, authority) {
1054
+ const loaded = await loadResumableRunRecord(home, runId, authority);
1055
+ if (loaded.run.role !== "judge") {
1056
+ throw new CliUsageError(`role run ${runId} belongs to ${loaded.run.role}, not judge`);
1057
+ }
1058
+ const admitted = {
1059
+ role: "judge",
1060
+ ...resumedBaseAdmitted(loaded),
1061
+ };
1062
+ return seatLoadedResult(loaded, admitted);
1063
+ }
1064
+ /**
1065
+ * Load a resumable Coder run for resume. Phase and task path are restored from
1066
+ * the admitted request so continuation stays role-correct (#109).
1067
+ */
1068
+ export async function loadResumableCoderRun(home, runId, authority) {
1069
+ const loaded = await loadResumableRunRecord(home, runId, authority);
1070
+ if (loaded.run.role !== "coder") {
1071
+ throw new CliUsageError(`role run ${runId} belongs to ${loaded.run.role}, not coder`);
1072
+ }
1073
+ const phase = loaded.admittedFields.phase ?? loaded.run.phase;
1074
+ if (phase !== "plan" && phase !== "apply") {
1075
+ throw new CliUsageError(`role run admitted coder phase is missing: ${runId}`);
1076
+ }
1077
+ const taskPath = loaded.admittedFields.taskPath;
1078
+ if (taskPath === undefined) {
1079
+ throw new CliUsageError(`role run admitted coder task path is missing: ${runId}`);
1080
+ }
1081
+ if (loaded.admittedFields.instruction.trim() === "") {
1082
+ throw new CliUsageError(`role run admitted coder task is blank: ${runId}`);
1083
+ }
1084
+ const admitted = {
1085
+ role: "coder",
1086
+ phase,
1087
+ ...resumedBaseAdmitted(loaded),
1088
+ instructionEmpty: false,
1089
+ taskPath,
1090
+ };
1091
+ return seatLoadedResult(loaded, admitted);
1092
+ }
1093
+ /**
1094
+ * Load a resumable Fixer run for resume. Phase, packet, and prerequisites are
1095
+ * restored from the admitted request so continuation stays role-correct (#110).
1096
+ */
1097
+ export async function loadResumableFixerRun(home, runId, authority) {
1098
+ const loaded = await loadResumableRunRecord(home, runId, authority);
1099
+ if (loaded.run.role !== "fixer") {
1100
+ throw new CliUsageError(`role run ${runId} belongs to ${loaded.run.role}, not fixer`);
1101
+ }
1102
+ const phase = loaded.admittedFields.phase ?? loaded.run.phase;
1103
+ if (phase !== "plan" && phase !== "apply") {
1104
+ throw new CliUsageError(`role run admitted fixer phase is missing: ${runId}`);
1105
+ }
1106
+ const packetPath = loaded.admittedFields.packetPath;
1107
+ if (packetPath === undefined) {
1108
+ throw new CliUsageError(`role run admitted fixer packet path is missing: ${runId}`);
1109
+ }
1110
+ if (loaded.admittedFields.instruction.trim() === "") {
1111
+ throw new CliUsageError(`role run admitted fixer instruction is blank: ${runId}`);
1112
+ }
1113
+ const prerequisites = loaded.admittedFields.prerequisites ?? Object.freeze([]);
1114
+ const admitted = {
1115
+ role: "fixer",
1116
+ phase,
1117
+ ...resumedBaseAdmitted(loaded),
1118
+ instructionEmpty: false,
1119
+ packetPath,
1120
+ ...(loaded.admittedFields.prerequisitesPath === undefined
1121
+ ? {}
1122
+ : { prerequisitesPath: loaded.admittedFields.prerequisitesPath }),
1123
+ prerequisites,
1124
+ };
1125
+ return seatLoadedResult(loaded, admitted);
1126
+ }
1127
+ /**
1128
+ * Peek the durable role of a run id without enforcing resumable state.
1129
+ * Used by public resume dispatch to pick the role-correct seat and path.
1130
+ */
1131
+ /**
1132
+ * Load a resumable Reviewer run for resume. Fixed base is restored from the
1133
+ * admitted request; caller instruction remains optional provenance only.
1134
+ */
1135
+ export async function loadResumableReviewerRun(home, runId, authority) {
1136
+ const loaded = await loadResumableRunRecord(home, runId, authority);
1137
+ if (loaded.run.role !== "reviewer") {
1138
+ throw new CliUsageError(`role run ${runId} belongs to ${loaded.run.role}, not reviewer`);
1139
+ }
1140
+ const baseRevision = loaded.admittedFields.baseRevision;
1141
+ if (baseRevision === undefined || baseRevision.trim() === "") {
1142
+ throw new CliUsageError(`role run admitted reviewer base revision is missing: ${runId}`);
1143
+ }
1144
+ const admitted = {
1145
+ role: "reviewer",
1146
+ ...resumedBaseAdmitted(loaded),
1147
+ baseRevision,
1148
+ authorityRefs: Object.freeze([...(loaded.admittedFields.authorityRefs ?? [])]),
1149
+ };
1150
+ return seatLoadedResult(loaded, admitted);
1151
+ }
1152
+ /**
1153
+ * Load a resumable Countersign run for resume (#599). Ticket binding and
1154
+ * attachments restore from the admitted request.
1155
+ */
1156
+ export async function loadResumableCountersignRun(home, runId, authority) {
1157
+ const loaded = await loadResumableRunRecord(home, runId, authority);
1158
+ if (loaded.run.role !== "countersign") {
1159
+ throw new CliUsageError(`role run ${runId} belongs to ${loaded.run.role}, not countersign`);
1160
+ }
1161
+ const admitted = {
1162
+ role: "countersign",
1163
+ ...resumedBaseAdmitted(loaded),
1164
+ };
1165
+ return seatLoadedResult(loaded, admitted);
1166
+ }
1167
+ /**
1168
+ * Load a resumable Gleaner-Left run for resume (#599). Fixed base restores from
1169
+ * the admitted request so continuation stays comparison-correct.
1170
+ */
1171
+ export async function loadResumableInstructionSeatRun(home, runId, authority) {
1172
+ const loaded = await loadResumableRunRecord(home, runId, authority);
1173
+ if (loaded.run.role !== "gatekeeper"
1174
+ && loaded.run.role !== "navigator"
1175
+ && loaded.run.role !== "auditor"
1176
+ && loaded.run.role !== "evidence-child") {
1177
+ throw new CliUsageError(`role run ${runId} belongs to ${loaded.run.role}, not an instruction seat`);
1178
+ }
1179
+ const admitted = {
1180
+ role: loaded.run.role,
1181
+ ...resumedBaseAdmitted(loaded),
1182
+ };
1183
+ return seatLoadedResult(loaded, admitted);
1184
+ }
1185
+ export async function loadResumableGleanerLeftRun(home, runId, authority) {
1186
+ const loaded = await loadResumableRunRecord(home, runId, authority);
1187
+ if (loaded.run.role !== "gleaner-left") {
1188
+ throw new CliUsageError(`role run ${runId} belongs to ${loaded.run.role}, not gleaner-left`);
1189
+ }
1190
+ const baseRevision = loaded.admittedFields.baseRevision;
1191
+ if (baseRevision === undefined || baseRevision.trim() === "") {
1192
+ throw new CliUsageError(`role run admitted gleaner-left base revision is missing: ${runId}`);
1193
+ }
1194
+ const admitted = {
1195
+ role: "gleaner-left",
1196
+ ...resumedBaseAdmitted(loaded),
1197
+ baseRevision,
1198
+ };
1199
+ return seatLoadedResult(loaded, admitted);
1200
+ }
1201
+ export async function loadResumableDiaristRun(home, runId, authority) {
1202
+ const loaded = await loadResumableRunRecord(home, runId, authority);
1203
+ if (loaded.run.role !== "diarist") {
1204
+ throw new CliUsageError(`role run ${runId} belongs to ${loaded.run.role}, not diarist`);
1205
+ }
1206
+ const admitted = {
1207
+ role: "diarist",
1208
+ ...resumedBaseAdmitted(loaded),
1209
+ };
1210
+ return seatLoadedResult(loaded, admitted);
1211
+ }
1212
+ /**
1213
+ * Load a resumable Merger run for resume. Derived envelope + internal input path
1214
+ * are restored from the admitted request (#114).
1215
+ */
1216
+ export async function loadResumableMergerRun(home, runId, authority) {
1217
+ const loaded = await loadResumableRunRecord(home, runId, authority);
1218
+ if (loaded.run.role !== "merger") {
1219
+ throw new CliUsageError(`role run ${runId} belongs to ${loaded.run.role}, not merger`);
1220
+ }
1221
+ const mergerInputPath = loaded.admittedFields.mergerInputPath;
1222
+ if (mergerInputPath === undefined) {
1223
+ throw new CliUsageError(`role run admitted merger input path is missing: ${runId}`);
1224
+ }
1225
+ const derived = loaded.admittedFields.derived;
1226
+ if (derived === undefined) {
1227
+ throw new CliUsageError(`role run admitted merger envelope is missing: ${runId}`);
1228
+ }
1229
+ if (loaded.admittedFields.instruction.trim() === "") {
1230
+ throw new CliUsageError(`role run admitted merger task is blank: ${runId}`);
1231
+ }
1232
+ const admitted = {
1233
+ role: "merger",
1234
+ ...resumedBaseAdmitted(loaded),
1235
+ instructionEmpty: false,
1236
+ mergerInputPath,
1237
+ derived,
1238
+ };
1239
+ return seatLoadedResult(loaded, admitted);
1240
+ }
1241
+ /**
1242
+ * Load a resumable Collector run for resume (#633). Admitted repository/PR
1243
+ * identity restores from the durable admitted request so the continuation
1244
+ * stays binding-correct.
1245
+ */
1246
+ export async function loadResumableCollectorRun(home, runId, authority) {
1247
+ const loaded = await loadResumableRunRecord(home, runId, authority);
1248
+ if (loaded.run.role !== "collector") {
1249
+ throw new CliUsageError(`role run ${runId} belongs to ${loaded.run.role}, not collector`);
1250
+ }
1251
+ const { prNumber, repository, repositoryDisplay, manifestDigest } = loaded.admittedFields;
1252
+ if (prNumber === undefined ||
1253
+ repository === undefined ||
1254
+ repositoryDisplay === undefined ||
1255
+ manifestDigest === undefined) {
1256
+ throw new CliUsageError(`role run admitted collector repository identity is missing: ${runId}`);
1257
+ }
1258
+ let parsedRepository;
1259
+ try {
1260
+ parsedRepository = parseCollectorRepository(repositoryDisplay);
1261
+ }
1262
+ catch (error) {
1263
+ const detail = error instanceof Error ? error.message : String(error);
1264
+ throw new CliUsageError(detail, { cause: error });
1265
+ }
1266
+ if (parsedRepository.canonical !== repository) {
1267
+ throw new CliUsageError(`role run admitted collector repository does not match its canonical form: ${runId}`);
1268
+ }
1269
+ const admitted = {
1270
+ role: "collector",
1271
+ ...resumedBaseAdmitted(loaded),
1272
+ prNumber,
1273
+ repository: parsedRepository,
1274
+ ...(loaded.admittedFields.requestManifestPath === undefined
1275
+ ? {}
1276
+ : { requestManifestPath: loaded.admittedFields.requestManifestPath }),
1277
+ manifestDigest,
1278
+ };
1279
+ return seatLoadedResult(loaded, admitted);
1280
+ }
1281
+ /**
1282
+ * Load a resumable Doctor run for resume (#633). Issue + retained case
1283
+ * identity restore from the durable admitted request.
1284
+ */
1285
+ export async function loadResumableDoctorRun(home, runId, authority) {
1286
+ const loaded = await loadResumableRunRecord(home, runId, authority);
1287
+ if (loaded.run.role !== "doctor") {
1288
+ throw new CliUsageError(`role run ${runId} belongs to ${loaded.run.role}, not doctor`);
1289
+ }
1290
+ const { issueNumber, caseRunsPath, caseIdentity } = loaded.admittedFields;
1291
+ if (issueNumber === undefined ||
1292
+ caseRunsPath === undefined ||
1293
+ caseIdentity === undefined) {
1294
+ throw new CliUsageError(`role run admitted doctor case identity is missing: ${runId}`);
1295
+ }
1296
+ const admitted = {
1297
+ role: "doctor",
1298
+ ...resumedBaseAdmitted(loaded),
1299
+ issueNumber,
1300
+ caseRunsPath,
1301
+ caseIdentity,
1302
+ };
1303
+ return seatLoadedResult(loaded, admitted);
1304
+ }
1305
+ /**
1306
+ * Load a resumable Notary run for resume (#633). Source-run locator restores
1307
+ * from the durable admitted request; the locator is not re-resolved.
1308
+ */
1309
+ export async function loadResumableNotaryRun(home, runId, authority) {
1310
+ const loaded = await loadResumableRunRecord(home, runId, authority);
1311
+ if (loaded.run.role !== "notary") {
1312
+ throw new CliUsageError(`role run ${runId} belongs to ${loaded.run.role}, not notary`);
1313
+ }
1314
+ const { sourceRunPath, sourceRun } = loaded.admittedFields;
1315
+ if (sourceRunPath === undefined || sourceRun === undefined) {
1316
+ throw new CliUsageError(`role run admitted notary source-run locator is missing: ${runId}`);
1317
+ }
1318
+ const admitted = {
1319
+ role: "notary",
1320
+ ...resumedBaseAdmitted(loaded),
1321
+ sourceRunPath,
1322
+ sourceRun,
1323
+ };
1324
+ return seatLoadedResult(loaded, admitted);
1325
+ }
1326
+ /** Load a resumable Inspector run for resume (#633); base admitted identity only. */
1327
+ export async function loadResumableInspectorRun(home, runId, authority) {
1328
+ const loaded = await loadResumableRunRecord(home, runId, authority);
1329
+ if (loaded.run.role !== "inspector") {
1330
+ throw new CliUsageError(`role run ${runId} belongs to ${loaded.run.role}, not inspector`);
1331
+ }
1332
+ const admitted = {
1333
+ role: "inspector",
1334
+ ...resumedBaseAdmitted(loaded),
1335
+ };
1336
+ return seatLoadedResult(loaded, admitted);
1337
+ }
1338
+ export async function peekRoleRunRole(home, runId) {
1339
+ const runDirectory = await findRunDirectoryById(home, runId);
1340
+ if (runDirectory === undefined)
1341
+ return undefined;
1342
+ const run = await readRoleRunIdentity(runDirectory);
1343
+ return run?.role;
1344
+ }