@akagilnc/pi-workflow-roles 0.1.2521 → 0.1.2653-next.71811ca

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 (109) hide show
  1. package/README.md +129 -39
  2. package/README.zh-CN.md +128 -39
  3. package/dist/activation-ledger-topology.js +1 -1
  4. package/dist/archivist-record-entry.js +47 -26
  5. package/dist/auditor-dossier-tool.js +3 -1
  6. package/dist/compliance-transport.js +17 -26
  7. package/dist/doctor-contracts.js +1 -2
  8. package/dist/dossier-resolution.js +4 -8
  9. package/dist/engine-detour-tool.js +33 -8
  10. package/dist/engine-detour.js +1 -0
  11. package/dist/evidence-child-executor.js +277 -424
  12. package/dist/host-contracts.js +22 -0
  13. package/dist/institutional-resolution.js +88 -0
  14. package/dist/merger-contracts.js +1 -2
  15. package/dist/navigator-attendance.js +16 -0
  16. package/dist/notary-contracts.js +2 -3
  17. package/dist/package-contracts/fixer-output.js +1 -2
  18. package/dist/packaged-role-registry.js +102 -9
  19. package/dist/pi/in-process-session.js +671 -0
  20. package/dist/public-cli/config.js +7 -5
  21. package/dist/public-cli/main.js +3767 -4113
  22. package/dist/reviewer-child-executor.js +6 -1
  23. package/dist/session-opening-materials.js +10 -38
  24. package/dist/sitian-appender.js +146 -0
  25. package/dist/sitian-contracts.js +21 -0
  26. package/dist/sitian-facade.js +15 -0
  27. package/dist/sitian-reader.js +52 -0
  28. package/extensions/role-runtime.ts +9 -7
  29. package/package.json +5 -5
  30. package/resources/engines/cursor.md +12 -26
  31. package/resources/engines/hermes.md +2 -23
  32. package/resources/engines/opus.md +1 -16
  33. package/resources/navigator-route-playbook.md +2 -2
  34. package/scripts/build-package.mjs +0 -1
  35. package/souls/quality-law.md +1 -1
  36. package/src/activation-ledger-topology.ts +1 -1
  37. package/src/archivist-record-entry.ts +57 -45
  38. package/src/auditor-dossier-tool.ts +4 -2
  39. package/src/canonical-skill-binding.ts +37 -27
  40. package/src/collector-role.ts +11 -20
  41. package/src/collector-tool-schemas.ts +1 -4
  42. package/src/compliance-transport.ts +19 -44
  43. package/src/doctor-auditor.ts +1 -5
  44. package/src/doctor-contracts.ts +1 -4
  45. package/src/doctor-role.ts +31 -8
  46. package/src/dossier-resolution.ts +8 -8
  47. package/src/engine-detour-tool.ts +48 -19
  48. package/src/engine-detour.ts +3 -0
  49. package/src/evidence-child-executor.ts +277 -485
  50. package/src/factory-board.ts +3 -3
  51. package/src/gatekeeper-role.ts +21 -10
  52. package/src/host-contracts.ts +386 -0
  53. package/src/in-process-session.ts +1 -115
  54. package/src/institutional-resolution.ts +135 -0
  55. package/src/judge-auditor.ts +1 -5
  56. package/src/judge-role.ts +37 -51
  57. package/src/merger-contracts.ts +1 -4
  58. package/src/merger-role.ts +7 -11
  59. package/src/navigator-attendance.ts +20 -1
  60. package/src/notary-contracts.ts +8 -11
  61. package/src/notary-role.ts +7 -23
  62. package/src/notary-source-run.ts +4 -3
  63. package/src/package-contracts/fixer-output.ts +1 -4
  64. package/src/package-resources/method-skill-binding.ts +10 -34
  65. package/src/packaged-role-registry.ts +122 -12
  66. package/src/pi/adapter.ts +192 -0
  67. package/src/pi/durable-principal.ts +98 -0
  68. package/src/pi/in-process-session.ts +819 -0
  69. package/src/pi/known-failure.ts +77 -0
  70. package/src/pi/pi-normalization.ts +284 -0
  71. package/src/pi/role-turn-host.ts +459 -0
  72. package/src/public-cli/auto-resume.ts +42 -44
  73. package/src/public-cli/cli.ts +171 -257
  74. package/src/public-cli/coder-run.ts +115 -433
  75. package/src/public-cli/collector-run.ts +62 -314
  76. package/src/public-cli/config.ts +7 -5
  77. package/src/public-cli/doctor-run.ts +41 -52
  78. package/src/public-cli/fixer-run.ts +122 -427
  79. package/src/public-cli/invocation.ts +287 -96
  80. package/src/public-cli/judge-run.ts +92 -422
  81. package/src/public-cli/merger-run.ts +167 -429
  82. package/src/public-cli/notary-run.ts +41 -47
  83. package/src/public-cli/option-definitions.ts +136 -3
  84. package/src/public-cli/post-admission.ts +497 -0
  85. package/src/public-cli/public-run-credentials.ts +4 -4
  86. package/src/public-cli/reviewer-dispatch-rejection.ts +106 -0
  87. package/src/public-cli/reviewer-run.ts +132 -449
  88. package/src/public-cli/run-lifecycle.ts +339 -327
  89. package/src/public-cli/settlement.ts +251 -134
  90. package/src/public-cli/terminal.ts +3 -9
  91. package/src/public-cli/turn-request.ts +87 -0
  92. package/src/reviewer-child-executor.ts +9 -1
  93. package/src/reviewer-role.ts +11 -20
  94. package/src/role-runtime.ts +129 -78
  95. package/src/session-opening-materials.ts +25 -39
  96. package/src/sitian-appender.ts +171 -0
  97. package/src/sitian-contracts.ts +106 -0
  98. package/src/sitian-facade.ts +18 -0
  99. package/src/sitian-reader.ts +62 -0
  100. package/src/worker-role.ts +42 -40
  101. package/src/worker-submission-gates.ts +33 -0
  102. package/dist/archivist-role-run-coordinates.js +0 -28
  103. package/dist/package-contracts/terminating-infrastructure.js +0 -90
  104. package/resources/engines/sonnet.md +0 -27
  105. package/resources/engines/zcode.md +0 -63
  106. package/src/archivist-role-run-coordinates.ts +0 -45
  107. package/src/package-contracts/terminating-infrastructure.ts +0 -132
  108. package/src/public-cli/explicit-internal.ts +0 -427
  109. package/src/public-cli/one-shot-dispatch.ts +0 -300
@@ -1,3 +1,8 @@
1
+ import type {
2
+ DurablePrincipal,
3
+ DurablePrincipalAuthority,
4
+ } from "../host-contracts.ts";
5
+ import { decodePiDurablePrincipal } from "../pi/durable-principal.ts";
1
6
  /**
2
7
  * Durable Role run lifecycle for public CLI (ADR 0052 / #11 / #108 / #416).
3
8
  * States: admitted → running → resumable | terminal.
@@ -5,7 +10,7 @@
5
10
  * any existing run with an available Pi session principal may be resumed; caller decides.
6
11
  * Prose is never regex-classified as quota evidence.
7
12
  */
8
- import { chmod, lstat, open, readdir, readFile, unlink, writeFile } from "node:fs/promises";
13
+ import { chmod, open, readdir, readFile, unlink, writeFile } from "node:fs/promises";
9
14
  import { join } from "node:path";
10
15
 
11
16
  import {
@@ -24,6 +29,8 @@ export {
24
29
  } from "../typed-provider-http.ts";
25
30
  import type { FixerPhase } from "../package-contracts/fixer-output.ts";
26
31
  import type { FixerPrerequisite } from "../package-contracts/fixer-packet.ts";
32
+ import { THINKING_LEVELS } from "./config.ts";
33
+ import type { PublicThinkingLevel } from "./registry.ts";
27
34
  import {
28
35
  recordEffectiveInvocationModel,
29
36
  requireAuthorityRef,
@@ -155,102 +162,224 @@ export async function writeRoleRunState(
155
162
  );
156
163
  }
157
164
 
158
- export async function readRoleRunState(
165
+ /**
166
+ * Uninterpreted principal wire as stored on run-state.json.
167
+ * Legacy rows may omit sessionFile; only DurablePrincipalAuthority decodes it.
168
+ */
169
+ type RoleRunPrincipalWire = {
170
+ readonly sessionDirectory: string;
171
+ readonly sessionFile?: string;
172
+ };
173
+
174
+ /** Envelope I/O only — principal payload is carried uninterpreted. */
175
+ type RoleRunStateDisk = {
176
+ readonly runId: string;
177
+ readonly role: RoleRunRecord["role"];
178
+ readonly state: RoleRunState;
179
+ readonly bookKey: string;
180
+ readonly projectRoot: string;
181
+ readonly runDirectory: string;
182
+ readonly admittedRequestPath: string;
183
+ readonly principalWire: RoleRunPrincipalWire;
184
+ readonly phase?: CoderPhase | FixerPhase;
185
+ readonly resumable?: TypedHttp429Observation;
186
+ };
187
+
188
+ async function readRoleRunStateDisk(
159
189
  runDirectory: string,
160
- ): Promise<RoleRunRecord | undefined> {
190
+ ): Promise<RoleRunStateDisk | undefined> {
161
191
  let raw: unknown;
162
192
  try {
163
193
  raw = JSON.parse(await readFile(join(runDirectory, RUN_STATE_FILE), "utf8"));
164
194
  } catch {
165
195
  return undefined;
166
196
  }
167
- if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
168
- return undefined;
169
- }
170
- const record = raw as Record<string, unknown>;
171
- if (typeof record.runId !== "string" || record.runId.trim() === "") {
172
- return undefined;
173
- }
174
- if (
175
- record.role !== "judge" &&
176
- record.role !== "coder" &&
177
- record.role !== "fixer" &&
178
- record.role !== "collector" &&
179
- record.role !== "doctor" &&
180
- record.role !== "reviewer" &&
181
- record.role !== "merger" &&
182
- record.role !== "notary"
183
- ) {
184
- return undefined;
185
- }
197
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
198
+ return undefined;
199
+ }
200
+ const record = raw as Record<string, unknown>;
201
+ if (typeof record.runId !== "string" || record.runId.trim() === "") {
202
+ return undefined;
203
+ }
204
+ if (
205
+ record.role !== "judge" &&
206
+ record.role !== "coder" &&
207
+ record.role !== "fixer" &&
208
+ record.role !== "collector" &&
209
+ record.role !== "doctor" &&
210
+ record.role !== "reviewer" &&
211
+ record.role !== "merger" &&
212
+ record.role !== "notary"
213
+ ) {
214
+ return undefined;
215
+ }
216
+ if (
217
+ record.state !== "admitted" &&
218
+ record.state !== "running" &&
219
+ record.state !== "resumable" &&
220
+ record.state !== "terminal"
221
+ ) {
222
+ return undefined;
223
+ }
224
+ if (typeof record.bookKey !== "string") return undefined;
225
+ if (typeof record.projectRoot !== "string") return undefined;
226
+ if (typeof record.sessionDirectory !== "string") return undefined;
227
+ if (typeof record.admittedRequestPath !== "string") return undefined;
228
+ const runDir =
229
+ typeof record.runDirectory === "string" && record.runDirectory.trim() !== ""
230
+ ? record.runDirectory
231
+ : runDirectory;
232
+ // Principal wire stays uninterpreted — authority owns legacy sessionFile fallback.
233
+ const principalWire: RoleRunPrincipalWire = {
234
+ sessionDirectory: record.sessionDirectory,
235
+ ...(typeof record.sessionFile === "string"
236
+ ? { sessionFile: record.sessionFile }
237
+ : {}),
238
+ };
239
+ let resumable: TypedHttp429Observation | undefined;
240
+ if (record.resumable !== undefined && record.resumable !== null) {
186
241
  if (
187
- record.state !== "admitted" &&
188
- record.state !== "running" &&
189
- record.state !== "resumable" &&
190
- record.state !== "terminal"
242
+ typeof record.resumable === "object" &&
243
+ !Array.isArray(record.resumable)
191
244
  ) {
192
- return undefined;
193
- }
194
- if (typeof record.bookKey !== "string") return undefined;
195
- if (typeof record.projectRoot !== "string") return undefined;
196
- if (typeof record.sessionDirectory !== "string") return undefined;
197
- if (typeof record.admittedRequestPath !== "string") return undefined;
198
- const runDir =
199
- typeof record.runDirectory === "string" && record.runDirectory.trim() !== ""
200
- ? record.runDirectory
201
- : runDirectory;
202
- // Legacy states predate sessionFile. Their persisted session directory is
203
- // the authority for the historical principal, even if project topology moved.
204
- const sessionFile =
205
- typeof record.sessionFile === "string" && record.sessionFile.trim() !== ""
206
- ? record.sessionFile
207
- : join(record.sessionDirectory, "session.jsonl");
208
- let resumable: TypedHttp429Observation | undefined;
209
- if (record.resumable !== undefined && record.resumable !== null) {
245
+ const r = record.resumable as Record<string, unknown>;
210
246
  if (
211
- typeof record.resumable === "object" &&
212
- !Array.isArray(record.resumable)
247
+ r.httpStatus === 429 &&
248
+ typeof r.provider === "string" &&
249
+ isV1ResumableProvider(r.provider)
213
250
  ) {
214
- const r = record.resumable as Record<string, unknown>;
215
- if (
216
- r.httpStatus === 429 &&
217
- typeof r.provider === "string" &&
218
- isV1ResumableProvider(r.provider)
219
- ) {
220
- resumable = { httpStatus: 429, provider: r.provider };
221
- }
251
+ resumable = { httpStatus: 429, provider: r.provider };
222
252
  }
223
253
  }
224
- const phase =
225
- record.phase === "plan" || record.phase === "apply"
226
- ? record.phase
227
- : undefined;
254
+ }
255
+ const phase =
256
+ record.phase === "plan" || record.phase === "apply"
257
+ ? record.phase
258
+ : undefined;
259
+ return {
260
+ runId: record.runId,
261
+ role: record.role,
262
+ state: record.state,
263
+ bookKey: record.bookKey,
264
+ projectRoot: record.projectRoot,
265
+ runDirectory: runDir,
266
+ admittedRequestPath: record.admittedRequestPath,
267
+ principalWire,
268
+ ...(phase === undefined ? {} : { phase }),
269
+ ...(resumable === undefined ? {} : { resumable }),
270
+ };
271
+ }
272
+
273
+ async function writeRoleRunStateDisk(
274
+ runDirectory: string,
275
+ disk: RoleRunStateDisk,
276
+ ): Promise<void> {
277
+ const payload = {
278
+ runId: disk.runId,
279
+ role: disk.role,
280
+ state: disk.state,
281
+ bookKey: disk.bookKey,
282
+ projectRoot: disk.projectRoot,
283
+ runDirectory: disk.runDirectory,
284
+ sessionDirectory: disk.principalWire.sessionDirectory,
285
+ ...(disk.principalWire.sessionFile === undefined
286
+ ? {}
287
+ : { sessionFile: disk.principalWire.sessionFile }),
288
+ admittedRequestPath: disk.admittedRequestPath,
289
+ ...(disk.phase === undefined ? {} : { phase: disk.phase }),
290
+ ...(disk.resumable === undefined ? {} : { resumable: disk.resumable }),
291
+ };
292
+ await writeFile(
293
+ join(runDirectory, RUN_STATE_FILE),
294
+ `${JSON.stringify(payload, null, 2)}\n`,
295
+ "utf8",
296
+ );
297
+ }
298
+
299
+ /** One authority.decode of the uninterpreted wire → record + opaque principal (frozen wire itself). */
300
+ function materializeRoleRunFromDisk(
301
+ disk: RoleRunStateDisk,
302
+ authority: DurablePrincipalAuthority,
303
+ ): { readonly run: RoleRunRecord; readonly principal: DurablePrincipal } | undefined {
304
+ try {
305
+ const coordinates = authority.decode(disk.principalWire);
228
306
  return {
229
- runId: record.runId,
230
- role: record.role,
231
- state: record.state,
232
- bookKey: record.bookKey,
233
- projectRoot: record.projectRoot,
234
- sessionDirectory: record.sessionDirectory,
235
- sessionFile,
236
- runDirectory: runDir,
237
- admittedRequestPath: record.admittedRequestPath,
238
- ...(phase === undefined ? {} : { phase }),
239
- ...(resumable === undefined ? {} : { resumable }),
307
+ principal: disk.principalWire as unknown as DurablePrincipal,
308
+ run: {
309
+ runId: disk.runId,
310
+ role: disk.role,
311
+ state: disk.state,
312
+ bookKey: disk.bookKey,
313
+ projectRoot: disk.projectRoot,
314
+ sessionDirectory: coordinates.sessionDirectory,
315
+ sessionFile: coordinates.sessionFile,
316
+ runDirectory: disk.runDirectory,
317
+ admittedRequestPath: disk.admittedRequestPath,
318
+ ...(disk.phase === undefined ? {} : { phase: disk.phase }),
319
+ ...(disk.resumable === undefined ? {} : { resumable: disk.resumable }),
320
+ },
240
321
  };
322
+ } catch {
323
+ return undefined;
324
+ }
325
+ }
326
+
327
+ /**
328
+ * Read durable run-state and materialize principal coordinates through the
329
+ * injected host authority (legacy sessionFile fallback lives only in the codec).
330
+ */
331
+ export async function readRoleRunState(
332
+ runDirectory: string,
333
+ authority: DurablePrincipalAuthority,
334
+ ): Promise<RoleRunRecord | undefined> {
335
+ const disk = await readRoleRunStateDisk(runDirectory);
336
+ if (disk === undefined) return undefined;
337
+ return materializeRoleRunFromDisk(disk, authority)?.run;
338
+ }
339
+
340
+ /**
341
+ * Envelope identity only — no principal payload interpretation.
342
+ * Used by notary locator / role peek that never consume session coordinates.
343
+ */
344
+ export async function readRoleRunIdentity(
345
+ runDirectory: string,
346
+ ): Promise<
347
+ | {
348
+ readonly runId: string;
349
+ readonly role: RoleRunRecord["role"];
350
+ readonly bookKey: string;
351
+ readonly runDirectory: string;
352
+ readonly state: RoleRunState;
353
+ }
354
+ | undefined
355
+ > {
356
+ const disk = await readRoleRunStateDisk(runDirectory);
357
+ if (disk === undefined) return undefined;
358
+ return {
359
+ runId: disk.runId,
360
+ role: disk.role,
361
+ bookKey: disk.bookKey,
362
+ runDirectory: disk.runDirectory,
363
+ state: disk.state,
364
+ };
241
365
  }
242
366
 
243
367
  export async function markRunAdmitted(
244
368
  admitted: AdmittedRoleInvocation,
369
+ authority: DurablePrincipalAuthority,
245
370
  ): Promise<void> {
371
+ const { sessionDirectory, sessionFile } = decodePiDurablePrincipal(
372
+ authority,
373
+ admitted.principal,
374
+ );
246
375
  await writeRoleRunState(admitted.runDirectory, {
247
376
  runId: admitted.runId,
248
377
  role: admitted.role,
249
378
  state: "admitted",
250
379
  bookKey: admitted.bookKey,
251
380
  projectRoot: admitted.projectRoot,
252
- sessionDirectory: admitted.sessionDirectory,
253
- sessionFile: admitted.sessionFile,
381
+ sessionDirectory,
382
+ sessionFile,
254
383
  admittedRequestPath: admitted.admittedRequestPath,
255
384
  ...(
256
385
  admitted.role === "coder" || admitted.role === "fixer"
@@ -273,27 +402,25 @@ export async function markRunRunning(
273
402
  effectiveModel?: InvocationEffectiveModel,
274
403
  effectiveEngine?: string,
275
404
  ): Promise<void> {
276
- if (effectiveModel !== undefined || effectiveEngine !== undefined) {
277
- await recordEffectiveInvocationModel(
278
- runDirectory,
279
- effectiveModel,
280
- effectiveEngine,
281
- );
282
- }
283
- const current = await readRoleRunState(runDirectory);
405
+ await recordEffectiveInvocationModel(
406
+ runDirectory,
407
+ effectiveModel,
408
+ effectiveEngine,
409
+ );
410
+ const current = await readRoleRunStateDisk(runDirectory);
284
411
  if (current === undefined) {
285
412
  throw new Error("cannot mark running: run state missing");
286
413
  }
287
- // Omit resumable while a writer is active.
288
- await writeRoleRunState(runDirectory, {
414
+ // Omit resumable while a writer is active. Principal wire is passed through uninterpreted.
415
+ await writeRoleRunStateDisk(runDirectory, {
289
416
  runId: current.runId,
290
417
  role: current.role,
291
418
  state: "running",
292
419
  bookKey: current.bookKey,
293
420
  projectRoot: current.projectRoot,
294
- sessionDirectory: current.sessionDirectory,
295
- sessionFile: current.sessionFile,
421
+ runDirectory: current.runDirectory,
296
422
  admittedRequestPath: current.admittedRequestPath,
423
+ principalWire: current.principalWire,
297
424
  ...(current.phase === undefined ? {} : { phase: current.phase }),
298
425
  });
299
426
  }
@@ -303,11 +430,11 @@ export async function markRunResumable(
303
430
  runDirectory: string,
304
431
  observation: TypedHttp429Observation,
305
432
  ): Promise<void> {
306
- const current = await readRoleRunState(runDirectory);
433
+ const current = await readRoleRunStateDisk(runDirectory);
307
434
  if (current === undefined) {
308
435
  throw new Error("cannot mark resumable: run state missing");
309
436
  }
310
- await writeRoleRunState(runDirectory, {
437
+ await writeRoleRunStateDisk(runDirectory, {
311
438
  ...current,
312
439
  state: "resumable",
313
440
  resumable: observation,
@@ -315,37 +442,32 @@ export async function markRunResumable(
315
442
  }
316
443
 
317
444
  export async function markRunTerminal(runDirectory: string): Promise<void> {
318
- const current = await readRoleRunState(runDirectory);
445
+ const current = await readRoleRunStateDisk(runDirectory);
319
446
  if (current === undefined) {
320
447
  throw new Error("cannot mark terminal: run state missing");
321
448
  }
322
- await writeRoleRunState(runDirectory, {
449
+ await writeRoleRunStateDisk(runDirectory, {
323
450
  runId: current.runId,
324
451
  role: current.role,
325
452
  state: "terminal",
326
453
  bookKey: current.bookKey,
327
454
  projectRoot: current.projectRoot,
328
- sessionDirectory: current.sessionDirectory,
329
- sessionFile: current.sessionFile,
455
+ runDirectory: current.runDirectory,
330
456
  admittedRequestPath: current.admittedRequestPath,
457
+ principalWire: current.principalWire,
331
458
  ...(current.phase === undefined ? {} : { phase: current.phase }),
332
459
  });
333
460
  }
334
461
 
335
462
  /**
336
- * True when the durable Pi session file principal exists as a regular file.
463
+ * True when the host authority reports the durable principal available.
337
464
  * Resume must reopen this exact principal; directory-latest is not identity.
338
465
  */
339
- export async function isSessionPrincipalAvailable(
340
- sessionFile: string,
466
+ export async function isDurablePrincipalAvailable(
467
+ principal: DurablePrincipal,
468
+ authority: DurablePrincipalAuthority,
341
469
  ): Promise<boolean> {
342
- if (sessionFile.trim() === "") return false;
343
- try {
344
- const st = await lstat(sessionFile);
345
- return st.isFile() && !st.isSymbolicLink();
346
- } catch {
347
- return false;
348
- }
470
+ return authority.isAvailable(principal);
349
471
  }
350
472
 
351
473
  export class RunWriterLeaseHeldError extends Error {
@@ -382,151 +504,14 @@ export function describeErrorIdentity(error: unknown): string {
382
504
  return `${name}${code}${message}`;
383
505
  }
384
506
 
385
- function errorCodeOf(error: unknown): unknown {
386
- return (error as { code?: unknown }).code;
387
- }
388
-
389
507
  /**
390
- * Signal-0 liveness probe. Only ESRCH proves absence; any other refusal
391
- * (e.g. EPERM) means the holder process exists.
392
- */
393
- function isProcessAlive(pid: number): boolean {
394
- try {
395
- process.kill(pid, 0);
396
- return true;
397
- } catch (error) {
398
- return errorCodeOf(error) !== "ESRCH";
399
- }
400
- }
401
-
402
- type WriterLockAutopsy =
403
- | { verdict: "absent"; readFailure?: unknown }
404
- | { verdict: "dead"; pid: number }
405
- | { verdict: "alive"; pid: number };
406
-
407
- /**
408
- * Holder autopsy for an existing writer.lock (#552). "absent" covers no file,
409
- * no parseable pid (a live creator mid-acquisition reads as empty, and so does
410
- * the crash-window leftover), and unreadable files — absent alone never
411
- * authorizes reclaim; only a "dead" verdict does. A non-ENOENT read failure
412
- * still decides "absent" but rides along as readFailure so the true cause can
413
- * land in the cleanup sink instead of being laundered away.
414
- */
415
- async function autopsyWriterLock(lockPath: string): Promise<WriterLockAutopsy> {
416
- let content: string;
417
- try {
418
- content = await readFile(lockPath, "utf8");
419
- } catch (error) {
420
- if (errorCodeOf(error) === "ENOENT") return { verdict: "absent" };
421
- return { verdict: "absent", readFailure: error };
422
- }
423
- const pid = Number.parseInt(content.trim(), 10);
424
- if (!Number.isInteger(pid) || pid <= 0) return { verdict: "absent" };
425
- return isProcessAlive(pid) ? { verdict: "alive", pid } : { verdict: "dead", pid };
426
- }
427
-
428
- function describeAutopsy(autopsy: WriterLockAutopsy): string {
429
- switch (autopsy.verdict) {
430
- case "alive":
431
- return `live pid ${autopsy.pid}`;
432
- case "dead":
433
- return `dead pid ${autopsy.pid}`;
434
- case "absent":
435
- return autopsy.readFailure !== undefined
436
- ? "unreadable lock"
437
- : "absent or unparseable holder";
438
- }
439
- }
440
-
441
- /**
442
- * Remove one lock whose re-read autopsy is still a verified-dead holder, or
443
- * leave it for the next round otherwise. The pre-unlink re-read guard means a
444
- * concurrent writer that re-locked between the autopsy and the unlink cannot
445
- * have its live lock stolen. Residual race: a writer can still re-lock between
446
- * the re-read and the unlink itself; POSIX offers no compare-and-delete, and
447
- * this narrows the window to a single syscall pair.
448
- */
449
- async function reclaimStaleWriterLock(lockPath: string, runDirectory: string): Promise<void> {
450
- const current = await autopsyWriterLock(lockPath);
451
- if (current.verdict !== "dead") return;
452
- try {
453
- await unlink(lockPath);
454
- } catch (error) {
455
- if (errorCodeOf(error) === "ENOENT") return;
456
- if (errorCodeOf(error) !== "EACCES") throw error;
457
- await chmod(runDirectory, 0o755);
458
- await unlink(lockPath);
459
- }
460
- }
461
-
462
- async function createWriterLease(
463
- lockPath: string,
464
- runDirectory: string,
465
- reportCleanupFailure: (error: unknown) => void,
466
- ): Promise<RunWriterLease> {
467
- const handle = await open(lockPath, "wx");
468
- try {
469
- await handle.writeFile(`${process.pid}\n`, "utf8");
470
- } catch (error) {
471
- await handle.close().catch(() => undefined);
472
- await unlink(lockPath).catch(() => undefined);
473
- throw error;
474
- }
475
- let released = false;
476
- return {
477
- lockPath,
478
- async release() {
479
- if (released) return;
480
- released = true;
481
- await handle.close().catch(() => undefined);
482
- try {
483
- await unlink(lockPath);
484
- } catch (error) {
485
- if (errorCodeOf(error) === "EACCES") {
486
- try {
487
- await chmod(runDirectory, 0o755);
488
- await unlink(lockPath);
489
- } catch (retryError) {
490
- // best-effort cleanup: a stale lock left here is reclaimed by the
491
- // next acquire's holder autopsy, but the true chmod/unlink cause
492
- // must be recorded, not swallowed.
493
- reportCleanupFailure(retryError);
494
- }
495
- } else {
496
- // non-EACCES unlink failure is best-effort settlement cleanup; record true cause.
497
- reportCleanupFailure(error);
498
- }
499
- }
500
- },
501
- };
502
- }
503
-
504
- const WRITER_LEASE_RECLAIM_ROUNDS = 3;
505
-
506
- /**
507
- * Acquire the one-writer lease for a Role run. Exclusive create — no second
508
- * writer; a concurrent acquire rejects without dispatch.
509
- *
510
- * A contested lock gets a holder autopsy before rejection (#552): only a
511
- * verified-dead holder pid — parseable pid, signal-0 ESRCH, and still dead on
512
- * the pre-unlink re-read — authorizes reclaim, because no writer is left to
513
- * release the lock; acquire then retries the create. An empty, unparseable, or
514
- * unreadable lock proves no dead holder (a live creator is mid-acquisition
515
- * between the exclusive create and its pid write), so it rejects as
516
- * RunWriterLeaseHeldError naming the path and the lock stays on disk — a
517
- * crash-window empty lock blocking a resume is that refusal's known residue,
518
- * not safely fixable by unlink here. A live holder rejects the same typed
519
- * error naming the pid and path. A pid recycled by an unrelated process reads
520
- * as alive — that degrades to the same typed rejection, never worse than the
521
- * pre-#552 behavior. Reclaim rounds are bounded by
522
- * WRITER_LEASE_RECLAIM_ROUNDS; a lock that stays contested (e.g. a reclaim
523
- * race repeatedly lost) surfaces the same typed error instead of spinning.
508
+ * Acquire the one-writer lease for a Role run. Concurrent acquire rejects
509
+ * without dispatch. Exclusive create — no second writer.
524
510
  *
525
511
  * `onCleanupFailure` receives a non-terminal diagnostic line when release-time
526
- * lock cleanup fails or a contested lock cannot be read. Release stays
527
- * best-effort (a stale lock is reclaimed by the next acquire's autopsy), but
528
- * the true error identity must still land somewhere observable — silent
529
- * swallowing is forbidden.
512
+ * lock cleanup fails. Release stays best-effort (a stale lock resurfaces as
513
+ * RunWriterLeaseHeldError on next acquire), but the true error identity must
514
+ * still land somewhere observable — silent swallowing is forbidden.
530
515
  */
531
516
  export async function acquireRunWriterLease(
532
517
  runDirectory: string,
@@ -538,57 +523,58 @@ export async function acquireRunWriterLease(
538
523
  // cause has already been handed to the sink as its argument.
539
524
  try {
540
525
  onCleanupFailure?.(
541
- `writer lease lock cleanup failed (best-effort continue; stale lock is reclaimed by the next acquire's holder autopsy) at ${join(runDirectory, WRITER_LOCK_FILE)}: ${describeErrorIdentity(error)}`,
526
+ `writer lease lock cleanup failed (best-effort continue; stale lock resurfaces as lease-held on next acquire) at ${join(runDirectory, WRITER_LOCK_FILE)}: ${describeErrorIdentity(error)}`,
542
527
  );
543
528
  } catch {
544
529
  // diagnostic-sink failure is itself best-effort; never break release().
545
530
  }
546
531
  };
547
532
  const lockPath = join(runDirectory, WRITER_LOCK_FILE);
548
- let lastAutopsy: WriterLockAutopsy = { verdict: "absent" };
549
- // The reclaim budget: every successful reclaim is immediately followed by a
550
- // fresh create attempt, including the budget's last one (the previous shape
551
- // exited after a final-round reclaim with the path already clear).
552
- for (let reclaimsLeft = WRITER_LEASE_RECLAIM_ROUNDS; ; reclaimsLeft -= 1) {
533
+ try {
534
+ const handle = await open(lockPath, "wx");
553
535
  try {
554
- return await createWriterLease(lockPath, runDirectory, reportCleanupFailure);
536
+ await handle.writeFile(`${process.pid}\n`, "utf8");
555
537
  } catch (error) {
556
- if (errorCodeOf(error) !== "EEXIST") throw error;
557
- }
558
- lastAutopsy = await autopsyWriterLock(lockPath);
559
- if (lastAutopsy.verdict === "absent" && lastAutopsy.readFailure !== undefined) {
560
- // 失败诚实: a lock we could not read must land its true read cause
561
- // somewhere observable — recorded, but never treated as proof of death.
562
- reportCleanupFailure(lastAutopsy.readFailure);
563
- }
564
- if (lastAutopsy.verdict === "alive") {
565
- throw new RunWriterLeaseHeldError(
566
- `role run writer lease is already held by live pid ${lastAutopsy.pid} at ${lockPath}`,
567
- );
568
- }
569
- if (lastAutopsy.verdict === "absent") {
570
- // #552 mechanical criterion: stale = holder pid verified dead. An
571
- // empty lock (a live creator is mid-acquisition before its pid write)
572
- // or an unreadable one proves no dead holder — unlinking here could
573
- // steal a live writer's lock, so reject typed and leave the lock.
574
- throw new RunWriterLeaseHeldError(
575
- lastAutopsy.readFailure !== undefined
576
- ? `role run writer lease lock is unreadable at ${lockPath}: ${describeErrorIdentity(lastAutopsy.readFailure)}; holder liveness unverifiable, lock left in place`
577
- : `role run writer lease lock at ${lockPath} has no verifiable holder pid (empty or unparseable); holder liveness unverifiable, lock left in place`,
578
- );
538
+ await handle.close().catch(() => undefined);
539
+ await unlink(lockPath).catch(() => undefined);
540
+ throw error;
579
541
  }
580
- if (reclaimsLeft <= 0) break;
581
- try {
582
- await reclaimStaleWriterLock(lockPath, runDirectory);
583
- } catch (reclaimError) {
584
- throw new RunWriterLeaseHeldError(
585
- `stale writer lease reclaim failed at ${lockPath} (autopsy: ${describeAutopsy(lastAutopsy)}): ${describeErrorIdentity(reclaimError)}`,
586
- );
542
+ let released = false;
543
+ return {
544
+ lockPath,
545
+ async release() {
546
+ if (released) return;
547
+ released = true;
548
+ await handle.close().catch(() => undefined);
549
+ try {
550
+ await unlink(lockPath);
551
+ } catch (error) {
552
+ if ((error as { code?: unknown }).code === "EACCES") {
553
+ try {
554
+ await chmod(runDirectory, 0o755);
555
+ await unlink(lockPath);
556
+ } catch (retryError) {
557
+ // best-effort cleanup: stale lock will surface as lease-held on next acquire (exit 2),
558
+ // but the true chmod/unlink cause must be recorded, not swallowed.
559
+ reportCleanupFailure(retryError);
560
+ }
561
+ } else {
562
+ // non-EACCES unlink failure is best-effort settlement cleanup; record true cause.
563
+ reportCleanupFailure(error);
564
+ }
565
+ }
566
+ },
567
+ };
568
+ } catch (error) {
569
+ if (
570
+ error instanceof Error &&
571
+ "code" in error &&
572
+ (error as { code?: unknown }).code === "EEXIST"
573
+ ) {
574
+ throw new RunWriterLeaseHeldError();
587
575
  }
576
+ throw error;
588
577
  }
589
- throw new RunWriterLeaseHeldError(
590
- `role run writer lease stayed contested at ${lockPath} after ${WRITER_LEASE_RECLAIM_ROUNDS} reclaims (last autopsy: ${describeAutopsy(lastAutopsy)})`,
591
- );
592
578
  }
593
579
 
594
580
  /**
@@ -640,6 +626,8 @@ type LoadedAdmittedRequestFields = {
640
626
  readonly derived?: DerivedMergerEnvelope;
641
627
  readonly correlationId?: string;
642
628
  readonly ticketNumber?: number;
629
+ /** Effective model restored from the invocation identity page on resume. */
630
+ readonly model?: InvocationEffectiveModel;
643
631
  };
644
632
 
645
633
  /** Restore optional correlation + typed ticketNumber from a durable admitted page. */
@@ -675,8 +663,10 @@ function restoredTicketFields(fields: LoadedAdmittedRequestFields): {
675
663
  async function loadResumableRunRecord(
676
664
  home: string,
677
665
  runId: string,
666
+ authority: DurablePrincipalAuthority,
678
667
  ): Promise<{
679
668
  readonly run: RoleRunRecord;
669
+ readonly principal: DurablePrincipal;
680
670
  readonly observation?: TypedHttp429Observation;
681
671
  readonly admittedFields: LoadedAdmittedRequestFields;
682
672
  }> {
@@ -684,14 +674,19 @@ async function loadResumableRunRecord(
684
674
  if (runDirectory === undefined) {
685
675
  throw new CliUsageError(`unknown role run id: ${runId}`);
686
676
  }
687
- const run = await readRoleRunState(runDirectory);
688
- if (run === undefined) {
677
+ const disk = await readRoleRunStateDisk(runDirectory);
678
+ if (disk === undefined) {
689
679
  throw new CliUsageError(`unknown role run id: ${runId}`);
690
680
  }
691
681
  // #416: removed terminal/resumable gates per owner decision "根本不要有限制" (2026-08-22).
692
682
  // Only the exact Pi session principal check remains as honest failure.
693
- // Exact Pi session principal must be present before resume dispatches.
694
- if (!(await isSessionPrincipalAvailable(run.sessionFile))) {
683
+ // One authority.decode of the uninterpreted wire yields both principal and record.
684
+ const materialized = materializeRoleRunFromDisk(disk, authority);
685
+ if (materialized === undefined) {
686
+ throw new CliUsageError(`unknown role run id: ${runId}`);
687
+ }
688
+ const { run, principal } = materialized;
689
+ if (!(await isDurablePrincipalAvailable(principal, authority))) {
695
690
  throw new CliUsageError(
696
691
  `role run Pi session principal is unavailable: ${runId}`,
697
692
  );
@@ -805,39 +800,50 @@ async function loadResumableRunRecord(
805
800
  { cause: error },
806
801
  );
807
802
  }
808
- if (correlationId === undefined || ticketNumber === undefined) {
809
- try {
810
- const invocationRaw: unknown = JSON.parse(
811
- await readFile(join(run.runDirectory, "invocation.json"), "utf8"),
812
- );
813
- if (
814
- invocationRaw !== null &&
815
- typeof invocationRaw === "object" &&
816
- !Array.isArray(invocationRaw)
817
- ) {
818
- const fromInvocation = parsePersistedTicketIdentity(
819
- invocationRaw as Record<string, unknown>,
820
- );
803
+ let model: InvocationEffectiveModel | undefined;
804
+ try {
805
+ const invocationRaw: unknown = JSON.parse(
806
+ await readFile(join(run.runDirectory, "invocation.json"), "utf8"),
807
+ );
808
+ if (
809
+ invocationRaw !== null &&
810
+ typeof invocationRaw === "object" &&
811
+ !Array.isArray(invocationRaw)
812
+ ) {
813
+ const rec = invocationRaw as Record<string, unknown>;
814
+ if (typeof rec.provider === "string" && typeof rec.model === "string") {
815
+ model = {
816
+ provider: rec.provider,
817
+ model: rec.model,
818
+ ...(typeof rec.thinking === "string" &&
819
+ THINKING_LEVELS.has(rec.thinking as PublicThinkingLevel)
820
+ ? { thinking: rec.thinking as PublicThinkingLevel }
821
+ : {}),
822
+ };
823
+ }
824
+ if (correlationId === undefined || ticketNumber === undefined) {
825
+ const fromInvocation = parsePersistedTicketIdentity(rec);
821
826
  if (correlationId === undefined) correlationId = fromInvocation.correlationId;
822
827
  if (ticketNumber === undefined) ticketNumber = fromInvocation.ticketNumber;
823
828
  }
824
- } catch (error) {
825
- if (
826
- !(
827
- error instanceof Error &&
828
- "code" in error &&
829
- (error as { code?: unknown }).code === "ENOENT"
830
- )
831
- ) {
832
- throw new CliUsageError(
833
- `role run invocation identity is unreadable: ${runId}`,
834
- { cause: error },
835
- );
836
- }
829
+ }
830
+ } catch (error) {
831
+ if (
832
+ !(
833
+ error instanceof Error &&
834
+ "code" in error &&
835
+ (error as { code?: unknown }).code === "ENOENT"
836
+ )
837
+ ) {
838
+ throw new CliUsageError(
839
+ `role run invocation identity is unreadable: ${runId}`,
840
+ { cause: error },
841
+ );
837
842
  }
838
843
  }
839
844
  return {
840
845
  run,
846
+ principal,
841
847
  ...(run.resumable === undefined ? {} : { observation: run.resumable }),
842
848
  admittedFields: {
843
849
  instruction,
@@ -854,6 +860,7 @@ async function loadResumableRunRecord(
854
860
  ...(derived === undefined ? {} : { derived }),
855
861
  ...(correlationId === undefined ? {} : { correlationId }),
856
862
  ...(ticketNumber === undefined ? {} : { ticketNumber }),
863
+ ...(model === undefined ? {} : { model }),
857
864
  },
858
865
  };
859
866
  }
@@ -890,8 +897,9 @@ export type LoadedResumableReviewerRun = {
890
897
  export async function loadResumableJudgeRun(
891
898
  home: string,
892
899
  runId: string,
900
+ authority: DurablePrincipalAuthority,
893
901
  ): Promise<LoadedResumableJudgeRun> {
894
- const loaded = await loadResumableRunRecord(home, runId);
902
+ const loaded = await loadResumableRunRecord(home, runId, authority);
895
903
  if (loaded.run.role !== "judge") {
896
904
  throw new CliUsageError(
897
905
  `role run ${runId} belongs to ${loaded.run.role}, not judge`,
@@ -906,9 +914,9 @@ export async function loadResumableJudgeRun(
906
914
  instructionEmpty: loaded.admittedFields.instructionEmpty,
907
915
  attachments: loaded.admittedFields.attachments,
908
916
  runDirectory: loaded.run.runDirectory,
909
- sessionDirectory: loaded.run.sessionDirectory,
910
- sessionFile: loaded.run.sessionFile,
917
+ principal: loaded.principal,
911
918
  admittedRequestPath: loaded.run.admittedRequestPath,
919
+ ...(loaded.admittedFields.model === undefined ? {} : { model: loaded.admittedFields.model }),
912
920
  ...restoredTicketFields(loaded.admittedFields),
913
921
  };
914
922
  return {
@@ -925,8 +933,9 @@ export async function loadResumableJudgeRun(
925
933
  export async function loadResumableCoderRun(
926
934
  home: string,
927
935
  runId: string,
936
+ authority: DurablePrincipalAuthority,
928
937
  ): Promise<LoadedResumableCoderRun> {
929
- const loaded = await loadResumableRunRecord(home, runId);
938
+ const loaded = await loadResumableRunRecord(home, runId, authority);
930
939
  if (loaded.run.role !== "coder") {
931
940
  throw new CliUsageError(
932
941
  `role run ${runId} belongs to ${loaded.run.role}, not coder`,
@@ -959,10 +968,10 @@ export async function loadResumableCoderRun(
959
968
  instructionEmpty: false,
960
969
  attachments: loaded.admittedFields.attachments,
961
970
  runDirectory: loaded.run.runDirectory,
962
- sessionDirectory: loaded.run.sessionDirectory,
963
- sessionFile: loaded.run.sessionFile,
971
+ principal: loaded.principal,
964
972
  admittedRequestPath: loaded.run.admittedRequestPath,
965
973
  taskPath,
974
+ ...(loaded.admittedFields.model === undefined ? {} : { model: loaded.admittedFields.model }),
966
975
  ...restoredTicketFields(loaded.admittedFields),
967
976
  };
968
977
  return {
@@ -979,8 +988,9 @@ export async function loadResumableCoderRun(
979
988
  export async function loadResumableFixerRun(
980
989
  home: string,
981
990
  runId: string,
991
+ authority: DurablePrincipalAuthority,
982
992
  ): Promise<LoadedResumableFixerRun> {
983
- const loaded = await loadResumableRunRecord(home, runId);
993
+ const loaded = await loadResumableRunRecord(home, runId, authority);
984
994
  if (loaded.run.role !== "fixer") {
985
995
  throw new CliUsageError(
986
996
  `role run ${runId} belongs to ${loaded.run.role}, not fixer`,
@@ -1014,14 +1024,14 @@ export async function loadResumableFixerRun(
1014
1024
  instructionEmpty: false,
1015
1025
  attachments: loaded.admittedFields.attachments,
1016
1026
  runDirectory: loaded.run.runDirectory,
1017
- sessionDirectory: loaded.run.sessionDirectory,
1018
- sessionFile: loaded.run.sessionFile,
1027
+ principal: loaded.principal,
1019
1028
  admittedRequestPath: loaded.run.admittedRequestPath,
1020
1029
  packetPath,
1021
1030
  ...(loaded.admittedFields.prerequisitesPath === undefined
1022
1031
  ? {}
1023
1032
  : { prerequisitesPath: loaded.admittedFields.prerequisitesPath }),
1024
1033
  prerequisites,
1034
+ ...(loaded.admittedFields.model === undefined ? {} : { model: loaded.admittedFields.model }),
1025
1035
  ...restoredTicketFields(loaded.admittedFields),
1026
1036
  };
1027
1037
  return {
@@ -1042,8 +1052,9 @@ export async function loadResumableFixerRun(
1042
1052
  export async function loadResumableReviewerRun(
1043
1053
  home: string,
1044
1054
  runId: string,
1055
+ authority: DurablePrincipalAuthority,
1045
1056
  ): Promise<LoadedResumableReviewerRun> {
1046
- const loaded = await loadResumableRunRecord(home, runId);
1057
+ const loaded = await loadResumableRunRecord(home, runId, authority);
1047
1058
  if (loaded.run.role !== "reviewer") {
1048
1059
  throw new CliUsageError(
1049
1060
  `role run ${runId} belongs to ${loaded.run.role}, not reviewer`,
@@ -1064,11 +1075,11 @@ export async function loadResumableReviewerRun(
1064
1075
  instructionEmpty: loaded.admittedFields.instructionEmpty,
1065
1076
  attachments: loaded.admittedFields.attachments,
1066
1077
  runDirectory: loaded.run.runDirectory,
1067
- sessionDirectory: loaded.run.sessionDirectory,
1068
- sessionFile: loaded.run.sessionFile,
1078
+ principal: loaded.principal,
1069
1079
  admittedRequestPath: loaded.run.admittedRequestPath,
1070
1080
  baseRevision,
1071
1081
  authorityRefs: Object.freeze([...(loaded.admittedFields.authorityRefs ?? [])]),
1082
+ ...(loaded.admittedFields.model === undefined ? {} : { model: loaded.admittedFields.model }),
1072
1083
  ...restoredTicketFields(loaded.admittedFields),
1073
1084
  };
1074
1085
  return {
@@ -1091,8 +1102,9 @@ export type LoadedResumableMergerRun = {
1091
1102
  export async function loadResumableMergerRun(
1092
1103
  home: string,
1093
1104
  runId: string,
1105
+ authority: DurablePrincipalAuthority,
1094
1106
  ): Promise<LoadedResumableMergerRun> {
1095
- const loaded = await loadResumableRunRecord(home, runId);
1107
+ const loaded = await loadResumableRunRecord(home, runId, authority);
1096
1108
  if (loaded.run.role !== "merger") {
1097
1109
  throw new CliUsageError(
1098
1110
  `role run ${runId} belongs to ${loaded.run.role}, not merger`,
@@ -1124,11 +1136,11 @@ export async function loadResumableMergerRun(
1124
1136
  instructionEmpty: false,
1125
1137
  attachments: loaded.admittedFields.attachments,
1126
1138
  runDirectory: loaded.run.runDirectory,
1127
- sessionDirectory: loaded.run.sessionDirectory,
1128
- sessionFile: loaded.run.sessionFile,
1139
+ principal: loaded.principal,
1129
1140
  admittedRequestPath: loaded.run.admittedRequestPath,
1130
1141
  mergerInputPath,
1131
1142
  derived,
1143
+ ...(loaded.admittedFields.model === undefined ? {} : { model: loaded.admittedFields.model }),
1132
1144
  ...restoredTicketFields(loaded.admittedFields),
1133
1145
  };
1134
1146
  return {
@@ -1154,6 +1166,6 @@ export async function peekRoleRunRole(
1154
1166
  > {
1155
1167
  const runDirectory = await findRunDirectoryById(home, runId);
1156
1168
  if (runDirectory === undefined) return undefined;
1157
- const run = await readRoleRunState(runDirectory);
1169
+ const run = await readRoleRunIdentity(runDirectory);
1158
1170
  return run?.role;
1159
1171
  }