@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
@@ -0,0 +1,459 @@
1
+ /**
2
+ * Pi adapter for the host-neutral main-session execution seam (#526 / S1b-2).
3
+ * Owns argv construction, spawn/SIGTERM/close, and session codec helpers.
4
+ * public-cli runners project RoleTurnRequest; this module is the sole argv owner.
5
+ */
6
+ import { execFile, spawn } from "node:child_process";
7
+ import { constants } from "node:fs";
8
+ import { access, realpath } from "node:fs/promises";
9
+ import { delimiter, isAbsolute, join, resolve } from "node:path";
10
+ import { platform } from "node:process";
11
+ import { promisify } from "node:util";
12
+ import { randomUUID } from "node:crypto";
13
+ import { appendFile, readFile } from "node:fs/promises";
14
+
15
+ import type {
16
+ DurablePrincipal,
17
+ DurablePrincipalAuthority,
18
+ MethodBinding,
19
+ RoleTurnActivation,
20
+ RoleTurnHost,
21
+ RoleTurnKnownFailure,
22
+ RoleTurnModelConfig,
23
+ RoleTurnRequest,
24
+ RoleTurnResult,
25
+ } from "../host-contracts.ts";
26
+ import { ExplicitInternalActivationError } from "../host-contracts.ts";
27
+ import { applyEngineChildEnv } from "../engine-detour.ts";
28
+ import { decodePiDurablePrincipal } from "./durable-principal.ts";
29
+
30
+ /** Package-relative Internal role entrypoint (ADR 0052; same path as public-cli registry). */
31
+ const INTERNAL_ROLE_ENTRYPOINT_RELATIVE = "extensions/role-runtime.ts";
32
+
33
+ export function resolveInternalRoleEntrypoint(packageRoot: string): string {
34
+ return join(packageRoot, INTERNAL_ROLE_ENTRYPOINT_RELATIVE);
35
+ }
36
+
37
+ /** Non-dispatch args: load Internal once and exit via Pi help (no model turn). */
38
+ export const EXPLICIT_INTERNAL_LOAD_PROBE_ARGS = [
39
+ "--no-skills",
40
+ "--no-prompt-templates",
41
+ "--no-themes",
42
+ "--no-context-files",
43
+ "--no-session",
44
+ "--help",
45
+ ] as const;
46
+
47
+ export function buildExplicitInternalActivationArgs(
48
+ selectedRoleEntry: string,
49
+ extraArgs: readonly string[] = [],
50
+ ): string[] {
51
+ return ["--no-extensions", "-e", selectedRoleEntry, ...extraArgs];
52
+ }
53
+
54
+ function buildSeatModelCliArgs(model: RoleTurnModelConfig | undefined): string[] {
55
+ if (model === undefined) return [];
56
+ return [
57
+ "--provider",
58
+ model.provider,
59
+ "--model",
60
+ model.model,
61
+ ...(model.thinking === undefined ? [] : ["--thinking", model.thinking]),
62
+ ];
63
+ }
64
+
65
+ function buildActivationFlagArgs(activation: RoleTurnActivation): string[] {
66
+ switch (activation.role) {
67
+ case "judge":
68
+ return ["--ak-role", "judge"];
69
+ case "coder":
70
+ return [
71
+ "--ak-role",
72
+ "coder",
73
+ "--ak-coder-phase",
74
+ activation.phase,
75
+ "--ak-coder-task",
76
+ activation.taskPath,
77
+ ];
78
+ case "fixer":
79
+ return [
80
+ "--ak-role",
81
+ "fixer",
82
+ "--ak-fixer-phase",
83
+ activation.phase,
84
+ "--ak-fix-packet",
85
+ activation.packetPath,
86
+ ...(activation.prerequisitesPath === undefined
87
+ ? []
88
+ : ["--ak-fixer-prerequisites", activation.prerequisitesPath]),
89
+ ];
90
+ case "reviewer":
91
+ return [
92
+ "--ak-role",
93
+ "reviewer",
94
+ "--ak-review-base",
95
+ activation.baseRevision,
96
+ ...(activation.authorityRefs.length === 0
97
+ ? []
98
+ : ["--ak-review-authority-refs", JSON.stringify([...activation.authorityRefs])]),
99
+ ...(activation.ticketNumber === undefined
100
+ ? []
101
+ : ["--ak-review-ticket-number", String(activation.ticketNumber)]),
102
+ ];
103
+ case "merger":
104
+ return ["--ak-role", "merger", "--ak-merger-input", activation.inputPath];
105
+ case "collector":
106
+ return [
107
+ "--ak-role",
108
+ "collector",
109
+ "--ak-collector-repo",
110
+ activation.repo,
111
+ "--ak-collector-pr",
112
+ activation.pr,
113
+ ...(activation.requestManifestPath === undefined
114
+ ? []
115
+ : ["--ak-collector-request-manifest", activation.requestManifestPath]),
116
+ ];
117
+ case "doctor":
118
+ return ["--ak-role", "doctor", "--ak-doctor-case", activation.casePath];
119
+ case "notary":
120
+ return ["--ak-role", "notary", "--ak-notary-source-run", activation.sourceRun];
121
+ default: {
122
+ const _exhaustive: never = activation;
123
+ return _exhaustive;
124
+ }
125
+ }
126
+ }
127
+
128
+ function buildMethodArgs(methods: readonly MethodBinding[]): string[] {
129
+ const skillArgs: string[] = [];
130
+ for (const method of methods) {
131
+ if (method.kind === "skill") {
132
+ skillArgs.push("--skill", method.path);
133
+ }
134
+ }
135
+ return skillArgs;
136
+ }
137
+
138
+ /**
139
+ * Translate a closed RoleTurnRequest into Pi argv after `--no-extensions -e entry`.
140
+ * Controlled session constants and session coordinates are adapter-internal.
141
+ */
142
+ export function buildPiTurnExtraArgs(
143
+ request: RoleTurnRequest,
144
+ authority: DurablePrincipalAuthority,
145
+ extraPiArgs: readonly string[] = [],
146
+ ): string[] {
147
+ const { sessionFile, sessionDirectory } = decodePiDurablePrincipal(
148
+ authority,
149
+ request.principal,
150
+ );
151
+ const prompt =
152
+ request.continuation.kind === "initial" || request.continuation.kind === "resume"
153
+ ? request.continuation.prompt
154
+ : (() => {
155
+ const _exhaustive: never = request.continuation;
156
+ return _exhaustive;
157
+ })();
158
+ return [
159
+ "--no-skills",
160
+ ...buildMethodArgs(request.methods),
161
+ "--no-prompt-templates",
162
+ "--no-themes",
163
+ "--no-context-files",
164
+ "--session",
165
+ sessionFile,
166
+ "--session-dir",
167
+ sessionDirectory,
168
+ ...extraPiArgs,
169
+ ...buildActivationFlagArgs(request.activation),
170
+ "--mode",
171
+ "json",
172
+ ...buildSeatModelCliArgs(request.model),
173
+ prompt,
174
+ ];
175
+ }
176
+
177
+ export type PiSpawnRunner = (
178
+ args: readonly string[],
179
+ options: {
180
+ cwd: string;
181
+ env: NodeJS.ProcessEnv;
182
+ timeoutMs?: number;
183
+ },
184
+ ) => Promise<{
185
+ code: number | null;
186
+ stderr: string;
187
+ timedOut: boolean;
188
+ knownFailure?: RoleTurnKnownFailure;
189
+ }>;
190
+
191
+ export type LaunchedPiIdentity = {
192
+ readonly executable: string;
193
+ readonly version: string;
194
+ };
195
+
196
+ export type LaunchedRolePackageIdentity = {
197
+ readonly roleEntry: string;
198
+ readonly rolePackageRoot: string;
199
+ readonly rolePackageVersion: string;
200
+ readonly entryMode: "public-cli";
201
+ };
202
+
203
+ export type PiRoleTurnHostConfig = {
204
+ readonly packageRoot: string;
205
+ readonly principalAuthority: DurablePrincipalAuthority;
206
+ /** Test / seat-specific extra Pi args (faux provider etc.). */
207
+ readonly extraPiArgs?: readonly string[];
208
+ readonly timeoutMs?: number;
209
+ /** Low-level spawn seam (tests inject faux children). */
210
+ readonly spawnRunner?: PiSpawnRunner;
211
+ readonly recordLaunchedPiIdentity?: (
212
+ runDirectory: string,
213
+ identity: LaunchedPiIdentity,
214
+ ) => Promise<void>;
215
+ readonly recordLaunchedRolePackageIdentity?: (
216
+ runDirectory: string,
217
+ identity: LaunchedRolePackageIdentity,
218
+ ) => Promise<void>;
219
+ readonly observeLaunchedRolePackageIdentity?: (
220
+ packageRoot: string,
221
+ roleEntrypoint: string,
222
+ ) => Promise<LaunchedRolePackageIdentity>;
223
+ };
224
+
225
+ const execFileAsync = promisify(execFile);
226
+
227
+ async function resolveSelectedPi(
228
+ command: string,
229
+ cwd: string,
230
+ env: NodeJS.ProcessEnv,
231
+ ): Promise<string> {
232
+ const searchPath =
233
+ env.PATH ?? (platform === "win32" ? (process.env.PATH ?? "") : "/usr/bin:/bin");
234
+ const candidates =
235
+ isAbsolute(command) || command.includes("/")
236
+ ? [resolve(cwd, command)]
237
+ : searchPath.split(delimiter).map((dir) => resolve(cwd, dir, command));
238
+ for (const candidate of candidates) {
239
+ try {
240
+ await access(candidate, constants.X_OK);
241
+ } catch (error) {
242
+ const code = (error as NodeJS.ErrnoException).code;
243
+ if (code === "ENOENT" || code === "ENOTDIR" || code === "EACCES") continue;
244
+ throw new ExplicitInternalActivationError(
245
+ `Pi executable resolution failed: ${String((error as Error).message)}`,
246
+ { knownCause: "activation", cause: error },
247
+ );
248
+ }
249
+ return await realpath(candidate);
250
+ }
251
+ throw new ExplicitInternalActivationError(`Pi executable not found: ${command}`, {
252
+ knownCause: "activation",
253
+ });
254
+ }
255
+
256
+ async function selectedPiIdentity(
257
+ command: string,
258
+ cwd: string,
259
+ env: NodeJS.ProcessEnv,
260
+ ): Promise<LaunchedPiIdentity> {
261
+ const executable = await resolveSelectedPi(command, cwd, env);
262
+ const { stdout } = await execFileAsync(executable, ["--version"], {
263
+ cwd,
264
+ env,
265
+ encoding: "utf8",
266
+ });
267
+ const version = stdout.trim();
268
+ if (version === "") throw new Error(`Pi executable returned an empty version: ${executable}`);
269
+ return { executable, version };
270
+ }
271
+
272
+ /**
273
+ * Default child runner: canonically select `pi` on PATH (or PI_BINARY) and launch
274
+ * that exact file. Close settles exactly once for natural return / error / SIGTERM.
275
+ */
276
+ type SpawnedPiChild = ReturnType<typeof spawn> & {
277
+ stderr: NonNullable<ReturnType<typeof spawn>["stderr"]>;
278
+ };
279
+
280
+ export function createDefaultPiSpawnRunner(options: {
281
+ recordLaunchedPiIdentity?: (
282
+ runDirectory: string,
283
+ identity: LaunchedPiIdentity,
284
+ ) => Promise<void>;
285
+ }): PiSpawnRunner {
286
+ return async (args, spawnOptions) => {
287
+ const command = spawnOptions.env.PI_BINARY ?? "pi";
288
+ const piIdentity = await selectedPiIdentity(command, spawnOptions.cwd, spawnOptions.env);
289
+ return await new Promise((resolveResult, reject) => {
290
+ // Child stdout is discarded at the stdio seam (CLAUDE.md Role invocation
291
+ // evidence). Do not pipe or accumulate it. stderr stays piped for diagnostics.
292
+ const child: SpawnedPiChild = spawn(piIdentity.executable, [...args], {
293
+ cwd: spawnOptions.cwd,
294
+ env: spawnOptions.env,
295
+ stdio: ["ignore", "ignore", "pipe"],
296
+ });
297
+ let stderr = "";
298
+ let timedOut = false;
299
+ // No default wall clock. Only an explicit caller budget arms a timer (ADR 0010).
300
+ // SIGKILL is unconditionally forbidden — graceful SIGTERM only.
301
+ let timer: ReturnType<typeof setTimeout> | undefined;
302
+ let settled = false;
303
+ // `error` fires for pre-spawn failures (ENOENT after identity check is a
304
+ // true activation failure) or kill/dispatch errors. Retain it so `close`
305
+ // remains the SOLE settlement point (spec-B: child close once). Only
306
+ // fall back to rejecting on `error` if `close` never fires (e.g. spawn
307
+ // never succeeded so no `close` event will arrive).
308
+ let hasSpawned = false;
309
+ let executionError: Error | undefined;
310
+ const armTimeoutAfterChildReady = (): void => {
311
+ if (spawnOptions.timeoutMs === undefined) return;
312
+ timer = setTimeout(() => {
313
+ timedOut = true;
314
+ child.kill("SIGTERM");
315
+ }, spawnOptions.timeoutMs);
316
+ };
317
+ let identityRecorded: Promise<void> = Promise.resolve();
318
+ child.once("spawn", () => {
319
+ hasSpawned = true;
320
+ armTimeoutAfterChildReady();
321
+ const runDirectory = spawnOptions.env.AK_ROLE_RUN_DIR;
322
+ if (
323
+ typeof runDirectory === "string" &&
324
+ runDirectory !== "" &&
325
+ options.recordLaunchedPiIdentity !== undefined
326
+ ) {
327
+ identityRecorded = options.recordLaunchedPiIdentity(runDirectory, piIdentity);
328
+ }
329
+ });
330
+ child.stderr.setEncoding("utf8").on("data", (chunk) => {
331
+ stderr += chunk;
332
+ });
333
+ child.on("error", (error) => {
334
+ // A pre-spawn error has no child lifecycle to close. After spawn,
335
+ // retain the execution error and let the mandatory close event own
336
+ // cleanup and the single settlement.
337
+ if (settled || hasSpawned) {
338
+ executionError = error;
339
+ return;
340
+ }
341
+ if (timer !== undefined) clearTimeout(timer);
342
+ settled = true;
343
+ reject(error);
344
+ });
345
+ child.on("close", (code) => {
346
+ if (timer !== undefined) clearTimeout(timer);
347
+ void identityRecorded.then(
348
+ () => {
349
+ if (settled) return;
350
+ settled = true;
351
+ if (executionError !== undefined) {
352
+ reject(executionError);
353
+ return;
354
+ }
355
+ resolveResult({
356
+ code,
357
+ stderr,
358
+ timedOut,
359
+ });
360
+ },
361
+ (error) => {
362
+ if (settled) return;
363
+ settled = true;
364
+ reject(error);
365
+ },
366
+ );
367
+ });
368
+ });
369
+ };
370
+ }
371
+
372
+ /** Create the production Pi RoleTurnHost (composition-root assembly). */
373
+ export function createPiRoleTurnHost(config: PiRoleTurnHostConfig): RoleTurnHost {
374
+ const spawnRunner =
375
+ config.spawnRunner ??
376
+ createDefaultPiSpawnRunner({
377
+ ...(config.recordLaunchedPiIdentity === undefined
378
+ ? {}
379
+ : { recordLaunchedPiIdentity: config.recordLaunchedPiIdentity }),
380
+ });
381
+
382
+ return {
383
+ async executeTurn(request: RoleTurnRequest): Promise<RoleTurnResult> {
384
+ const roleEntry = await realpath(resolveInternalRoleEntrypoint(config.packageRoot));
385
+ const extraArgs = buildPiTurnExtraArgs(
386
+ request,
387
+ config.principalAuthority,
388
+ config.extraPiArgs ?? [],
389
+ );
390
+ const args = buildExplicitInternalActivationArgs(roleEntry, extraArgs);
391
+ const env: NodeJS.ProcessEnv = {
392
+ ...process.env,
393
+ HOME: request.home,
394
+ PI_CODING_AGENT_DIR: request.agentDir,
395
+ AK_ROLE_RUN_DIR: request.runDirectory,
396
+ };
397
+ applyEngineChildEnv(env, request.engine);
398
+ if (request.correlationId !== undefined && request.correlationId.trim() !== "") {
399
+ env.AK_CORRELATION_ID = request.correlationId;
400
+ }
401
+ // Package provenance is known before Pi starts.
402
+ if (
403
+ config.recordLaunchedRolePackageIdentity !== undefined &&
404
+ config.observeLaunchedRolePackageIdentity !== undefined
405
+ ) {
406
+ await config.recordLaunchedRolePackageIdentity(
407
+ request.runDirectory,
408
+ await config.observeLaunchedRolePackageIdentity(config.packageRoot, roleEntry),
409
+ );
410
+ }
411
+ const timeoutMs = request.timeoutMs ?? config.timeoutMs;
412
+ return await spawnRunner(args, {
413
+ cwd: request.cwd,
414
+ env,
415
+ ...(timeoutMs === undefined ? {} : { timeoutMs }),
416
+ });
417
+ },
418
+ };
419
+ }
420
+
421
+ import { sitianReport } from "../sitian-facade.ts";
422
+
423
+ /**
424
+ * Append one custom JSONL entry to the durable principal's session file.
425
+ * Pi session codec only — AK artifact O_EXCL retention stays in public-cli.
426
+ */
427
+ export async function appendPiSessionCustomEntry(
428
+ authority: DurablePrincipalAuthority,
429
+ principal: DurablePrincipal,
430
+ customType: string,
431
+ data: unknown,
432
+ ): Promise<void> {
433
+ const { sessionFile } = decodePiDurablePrincipal(authority, principal);
434
+ const text = await readFile(sessionFile, "utf8");
435
+ let parentId: string | null = null;
436
+ for (const line of text.trim().split("\n").filter(Boolean)) {
437
+ const entry = JSON.parse(line) as { id?: unknown; type?: unknown };
438
+ if (typeof entry.id === "string" && entry.type !== "session") parentId = entry.id;
439
+ }
440
+ const timestamp = new Date().toISOString();
441
+ const pointerLine = `${JSON.stringify({
442
+ type: "custom",
443
+ customType,
444
+ data,
445
+ id: randomUUID(),
446
+ parentId,
447
+ timestamp,
448
+ })}\n`;
449
+ await appendFile(sessionFile, pointerLine, "utf8");
450
+ try {
451
+ sitianReport({
452
+ level: "event",
453
+ kind: "dispatch-error",
454
+ sessionParent: sessionFile,
455
+ payload: { customType, data },
456
+ source: "pi-role-turn-host",
457
+ });
458
+ } catch {}
459
+ }
@@ -13,13 +13,17 @@
13
13
  */
14
14
  import { constants as fsConstants } from "node:fs";
15
15
  import { randomUUID } from "node:crypto";
16
- import { appendFile, lstat, mkdir, open, readFile } from "node:fs/promises";
16
+ import { lstat, mkdir, open } from "node:fs/promises";
17
17
  import { join } from "node:path";
18
18
 
19
+ import type {
20
+ DurablePrincipal,
21
+ DurablePrincipalAuthority,
22
+ SessionCustomEntryAppender,
23
+ } from "../host-contracts.ts";
19
24
  import {
20
25
  AUTO_RESUME_LIMIT,
21
26
  describeErrorIdentity,
22
- isSessionPrincipalAvailable,
23
27
  acquireRunWriterLease,
24
28
  markRunTerminal,
25
29
  RunWriterLeaseHeldError,
@@ -192,7 +196,9 @@ function jsonSafeReplacer(): (key: string, value: unknown) => unknown {
192
196
  * attempt can never overwrite an earlier attempt's file.
193
197
  */
194
198
  async function retainDispatchError(
195
- admitted: { sessionFile: string; runDirectory: string },
199
+ admitted: { runDirectory: string; principal: DurablePrincipal },
200
+ principalAuthority: DurablePrincipalAuthority,
201
+ sessionAppender: SessionCustomEntryAppender,
196
202
  attempt: number,
197
203
  error: unknown,
198
204
  ): Promise<{ file: string; pointerError?: unknown }> {
@@ -216,6 +222,7 @@ async function retainDispatchError(
216
222
  // construction; a colliding name fails loudly instead of overwriting.
217
223
  // O_NOFOLLOW when the platform provides it keeps a planted symlink from
218
224
  // being followed; on platforms without it, exclusivity still holds.
225
+ // AK artifact owner stays here; only the session JSONL pointer uses Pi codec.
219
226
  const noFollowFlag =
220
227
  typeof fsConstants.O_NOFOLLOW === "number" ? fsConstants.O_NOFOLLOW : 0;
221
228
  const handle = await open(
@@ -228,13 +235,8 @@ async function retainDispatchError(
228
235
  } finally {
229
236
  await handle.close();
230
237
  }
231
- // Addressable pointer in the dossier (卷宗): reuses the session principal's
232
- // appended custom-entry shape (mirrors pi SessionManager.appendCustomEntry,
233
- // same mechanism as #419 attempt history — no second ledger introduced).
234
- // The one-writer lease is held for the append (#426 review: production
235
- // dispatchers release the lease in their finally before the rejection reaches
236
- // this point); if a concurrent writer already holds it, the append is skipped
237
- // gracefully — the error file itself is already durably retained.
238
+ // Addressable pointer in the dossier (卷宗): Pi session custom-entry codec
239
+ // (appendPiSessionCustomEntry). Lease still owned here with run-writer.
238
240
  let pointerLease: RunWriterLease;
239
241
  try {
240
242
  pointerLease = await acquireRunWriterLease(admitted.runDirectory);
@@ -243,32 +245,17 @@ async function retainDispatchError(
243
245
  throw error;
244
246
  }
245
247
  // Pointer-stage failure (#426 fix_now #5) is separated from the file write:
246
- // once the error file is durably on disk, a failed readFile/JSON.parse/
247
- // appendFile must not reject through here and orphan it — the file path is
248
- // still handed back to the caller (retainedErrorFiles → terminal
249
- // dispatchErrorFiles/artifacts), and the pointer failure is reported
250
- // separately by the caller.
248
+ // once the error file is durably on disk, a failed session append must not
249
+ // reject through here and orphan it — the file path is still handed back.
251
250
  let pointerError: unknown;
252
251
  try {
253
- const text = await readFile(admitted.sessionFile, "utf8");
254
- // Session headers are not branch entries (#419 precedent in
255
- // appendRunAttemptHistory excludes type === "session"); leave parentId null
256
- // until a non-header entry exists so the pointer stays addressable.
257
- let parentId: string | null = null;
258
- for (const line of text.trim().split("\n").filter(Boolean)) {
259
- const entry = JSON.parse(line) as { id?: unknown; type?: unknown };
260
- if (typeof entry.id === "string" && entry.type !== "session") parentId = entry.id;
261
- }
262
252
  const timestamp = new Date().toISOString();
263
- const pointerLine = `${JSON.stringify({
264
- type: "custom",
265
- customType: DISPATCH_ERROR_RETENTION_ENTRY_TYPE,
266
- data: { version: 1, attempt, file: filePath, recordedAt: timestamp },
267
- id: randomUUID(),
268
- parentId,
269
- timestamp,
270
- })}\n`;
271
- await appendFile(admitted.sessionFile, pointerLine, "utf8");
253
+ await sessionAppender(
254
+ principalAuthority,
255
+ admitted.principal,
256
+ DISPATCH_ERROR_RETENTION_ENTRY_TYPE,
257
+ { version: 1, attempt, file: filePath, recordedAt: timestamp },
258
+ );
272
259
  } catch (error) {
273
260
  pointerError = error;
274
261
  } finally {
@@ -331,15 +318,20 @@ function dispatchExceptionFailureTerminal(input: {
331
318
  };
332
319
  }
333
320
 
334
- export async function runWithAutoResumeLoop<T extends AutoResumeDispatchResult>(options: {
321
+ export async function runWithAutoResumeLoop<
322
+ T extends AutoResumeDispatchResult,
323
+ TPayload = unknown,
324
+ >(options: {
335
325
  admitted: {
336
- sessionFile: string;
337
326
  runDirectory: string;
338
327
  /** Identity for the loop-owned typed failure terminal (dispatch-exception exhaustion). */
339
328
  role: TerminalRoleName;
340
329
  runId: string;
330
+ principal: DurablePrincipal;
341
331
  };
332
+ principalAuthority: DurablePrincipalAuthority;
342
333
  io: CliIo;
334
+ sessionAppender: SessionCustomEntryAppender;
343
335
  /**
344
336
  * Effective ceiling (#422), resolved by the caller before the loop; never re-read
345
337
  * per round. undefined = package default (AUTO_RESUME_LIMIT). Domain-validated at
@@ -347,9 +339,9 @@ export async function runWithAutoResumeLoop<T extends AutoResumeDispatchResult>(
347
339
  * before the first dispatch instead of silently bypassing the ceiling comparison.
348
340
  */
349
341
  autoResumeLimit?: number | undefined;
350
- buildInitialArgs: () => string[];
351
- buildResumeArgs: () => string[];
352
- dispatch: (extraArgs: string[], lease: RunWriterLease, isFirst: boolean, attemptIo: CliIo) => Promise<T>;
342
+ buildInitialPayload: () => TPayload;
343
+ buildResumePayload: () => TPayload;
344
+ dispatch: (payload: TPayload, lease: RunWriterLease, isFirst: boolean, attemptIo: CliIo) => Promise<T>;
353
345
  }): Promise<T> {
354
346
  // #422 single-point resolution + domain validation. NaN would bypass every
355
347
  // `attempts >= limit` comparison (always false) — reject here, before any dispatch.
@@ -357,7 +349,7 @@ export async function runWithAutoResumeLoop<T extends AutoResumeDispatchResult>(
357
349
  parseAutoResumeLimit(limit);
358
350
  let autoResumeAttempts = 0;
359
351
  let isFirst = true;
360
- let currentExtraArgs = options.buildInitialArgs();
352
+ let currentPayload = options.buildInitialPayload();
361
353
  let dispatchOrdinal = 0;
362
354
  let lastThrownError: unknown;
363
355
  let everyAttemptThrew = true;
@@ -379,7 +371,7 @@ export async function runWithAutoResumeLoop<T extends AutoResumeDispatchResult>(
379
371
 
380
372
  let result: T | undefined;
381
373
  try {
382
- result = await options.dispatch(currentExtraArgs, lease, isFirst, dummyIo);
374
+ result = await options.dispatch(currentPayload, lease, isFirst, dummyIo);
383
375
  } catch (error) {
384
376
  // Owner 2026-08-23: 「出了异常,就原地记录错误信息,然后重试。」
385
377
  // Retain the whole exception in place (per-attempt full file + dossier
@@ -392,7 +384,13 @@ export async function runWithAutoResumeLoop<T extends AutoResumeDispatchResult>(
392
384
  // Track the file as soon as it is durably written (#426 review):
393
385
  // a pointer-stage failure comes back separately (pointerError) and must
394
386
  // never orphan the retained file (#426 fix_now #5).
395
- const { file, pointerError } = await retainDispatchError(options.admitted, attempt, error);
387
+ const { file, pointerError } = await retainDispatchError(
388
+ options.admitted,
389
+ options.principalAuthority,
390
+ options.sessionAppender,
391
+ attempt,
392
+ error,
393
+ );
396
394
  retainedErrorFiles.push(file);
397
395
  options.io.stderr(
398
396
  `dispatch attempt ${attempt} threw (${describeErrorIdentity(error)}); full error retained at ${file}\n`,
@@ -430,7 +428,7 @@ export async function runWithAutoResumeLoop<T extends AutoResumeDispatchResult>(
430
428
  if (terminal !== undefined) presentTerminal(terminal, options.io);
431
429
  return result;
432
430
  }
433
- if (!(await isSessionPrincipalAvailable(options.admitted.sessionFile))) {
431
+ if (!(await options.principalAuthority.isAvailable(options.admitted.principal))) {
434
432
  if (terminal !== undefined) presentTerminal(terminal, options.io);
435
433
  return result;
436
434
  }
@@ -453,7 +451,7 @@ export async function runWithAutoResumeLoop<T extends AutoResumeDispatchResult>(
453
451
  terminal,
454
452
  } as T;
455
453
  }
456
- if (!(await isSessionPrincipalAvailable(options.admitted.sessionFile))) {
454
+ if (!(await options.principalAuthority.isAvailable(options.admitted.principal))) {
457
455
  const terminal = dispatchExceptionFailureTerminal({
458
456
  role: options.admitted.role,
459
457
  runId: options.admitted.runId,
@@ -473,7 +471,7 @@ export async function runWithAutoResumeLoop<T extends AutoResumeDispatchResult>(
473
471
  }
474
472
 
475
473
  autoResumeAttempts++;
476
- currentExtraArgs = options.buildResumeArgs();
474
+ currentPayload = options.buildResumePayload();
477
475
  isFirst = false;
478
476
  }
479
477
  }