@akagilnc/pi-workflow-roles 0.1.5483 → 0.1.5571

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 (112) hide show
  1. package/README.md +1 -1
  2. package/README.zh-CN.md +1 -1
  3. package/dist/acp-host/production-host.js +6312 -6376
  4. package/dist/analyst-gate-cycles-read.js +28 -78
  5. package/dist/analyst-ledger.js +26 -42
  6. package/dist/analyst-metric-families/acceptance-success-rework.js +25 -1
  7. package/dist/analyst-metric-families/round-timeline.js +2 -5
  8. package/dist/archivist-record-entry.js +4 -3
  9. package/dist/archivist-record-pointer.js +71 -36
  10. package/dist/atomic-write.js +2 -2
  11. package/dist/auditor-dossier-tool.js +11 -6
  12. package/dist/auditor-soul.js +5 -12
  13. package/dist/collector-config.js +6 -5
  14. package/dist/doctor-evidence.js +8 -4
  15. package/dist/engine-detour-usage.js +8 -22
  16. package/dist/headless-host/production-host.js +5969 -6011
  17. package/dist/host-descriptions.js +0 -4
  18. package/dist/host-session-record.js +266 -267
  19. package/dist/ledger-session-read.js +5 -0
  20. package/dist/migrate-book-topology.js +1369 -722
  21. package/dist/navigator-public-session.js +13 -17
  22. package/dist/notary-source-run.js +8 -16
  23. package/dist/packaged-role-registry.js +5 -56
  24. package/dist/pi/durable-principal.js +2 -3
  25. package/dist/pi/role-turn-host.js +79 -15
  26. package/dist/public-cli/auto-resume.js +8 -47
  27. package/dist/public-cli/instruction-seat-run.js +9 -7
  28. package/dist/public-cli/invocation.js +67 -100
  29. package/dist/public-cli/main.js +2689 -2532
  30. package/dist/public-cli/post-admission.js +96 -55
  31. package/dist/public-cli/run-lifecycle.js +44 -100
  32. package/dist/public-cli/settlement.js +184 -429
  33. package/dist/public-cli/terminal.js +0 -12
  34. package/dist/role-run-placement.js +53 -16
  35. package/dist/role-run-relocation.js +111 -52
  36. package/dist/run-dossier-files.js +30 -0
  37. package/dist/run-dossier.js +322 -0
  38. package/dist/run-terminal-artifacts.js +69 -202
  39. package/dist/run-ticket-number.js +49 -7
  40. package/dist/session-assistant-usage.js +9 -18
  41. package/dist/session-identity.js +49 -19
  42. package/dist/sitian-appender.js +29 -18
  43. package/dist/sitian-facade.js +11 -0
  44. package/dist/sitian-reader.js +44 -29
  45. package/dist/submission-gate.js +1 -1
  46. package/dist/submission-ledger.js +4 -4
  47. package/dist/ticket-provenance-contracts.js +2 -8
  48. package/dist/ticket-provenance.js +25 -9
  49. package/package.json +1 -1
  50. package/scripts/build-package.mjs +2 -0
  51. package/src/acp-host/production-host.ts +1 -1
  52. package/src/acp-host/role-turn-host.ts +11 -4
  53. package/src/analyst-gate-cycles-read.ts +31 -88
  54. package/src/analyst-ledger.ts +36 -51
  55. package/src/analyst-metric-families/acceptance-success-rework.ts +24 -1
  56. package/src/analyst-metric-families/gate-cycles.ts +1 -1
  57. package/src/analyst-metric-families/round-timeline.ts +2 -5
  58. package/src/analyst-page.ts +3 -3
  59. package/src/archivist-record-entry.ts +6 -5
  60. package/src/archivist-record-pointer.ts +92 -42
  61. package/src/atomic-write.ts +3 -1
  62. package/src/auditor-dossier-tool.ts +14 -8
  63. package/src/auditor-soul.ts +5 -10
  64. package/src/book-topology-migration-placement.ts +16 -3
  65. package/src/book-topology-record-class-migrators.ts +4 -2
  66. package/src/book-topology-runs-migrator.ts +6 -3
  67. package/src/collector-config.ts +5 -3
  68. package/src/doctor-contracts.ts +1 -1
  69. package/src/doctor-evidence.ts +7 -3
  70. package/src/engine-detour-tool.ts +1 -1
  71. package/src/engine-detour-usage.ts +12 -25
  72. package/src/external-host-turn-loop.ts +25 -0
  73. package/src/factory-board.ts +16 -81
  74. package/src/headless-host/production-host.ts +1 -4
  75. package/src/headless-host/role-turn-host.ts +56 -21
  76. package/src/host-descriptions.ts +0 -6
  77. package/src/host-session-record.ts +224 -140
  78. package/src/ledger-session-read.ts +6 -0
  79. package/src/navigator-public-session.ts +13 -18
  80. package/src/navigator-work-context.ts +3 -1
  81. package/src/notary-role.ts +2 -1
  82. package/src/notary-source-run.ts +8 -18
  83. package/src/packaged-role-registry.ts +1 -65
  84. package/src/pi/adapter.ts +50 -7
  85. package/src/pi/durable-principal.ts +2 -4
  86. package/src/pi/role-turn-host.ts +85 -14
  87. package/src/public-cli/auto-resume.ts +7 -43
  88. package/src/public-cli/instruction-seat-run.ts +13 -6
  89. package/src/public-cli/invocation.ts +90 -144
  90. package/src/public-cli/post-admission.ts +111 -72
  91. package/src/public-cli/run-lifecycle.ts +47 -115
  92. package/src/public-cli/settlement.ts +221 -589
  93. package/src/public-cli/terminal.ts +1 -61
  94. package/src/role-envelope.ts +4 -2
  95. package/src/role-run-placement.ts +65 -17
  96. package/src/role-run-relocation.ts +110 -70
  97. package/src/role-runtime.ts +46 -69
  98. package/src/run-dossier-files.ts +33 -0
  99. package/src/run-dossier.ts +357 -0
  100. package/src/run-terminal-artifacts.ts +84 -240
  101. package/src/run-ticket-number.ts +82 -14
  102. package/src/session-assistant-usage.ts +8 -20
  103. package/src/session-identity.ts +52 -21
  104. package/src/sitian-appender.ts +33 -26
  105. package/src/sitian-facade.ts +22 -0
  106. package/src/sitian-reader.ts +62 -32
  107. package/src/submission-gate.ts +1 -1
  108. package/src/submission-ledger.ts +4 -4
  109. package/src/ticket-provenance-contracts.ts +3 -6
  110. package/src/ticket-provenance.ts +23 -11
  111. package/src/ticket-snapshot.ts +3 -2
  112. package/src/ticket-trajectory.ts +95 -109
@@ -5,16 +5,14 @@
5
5
  * Writes native-session-copy (or native-session-warning on retry failure) after CLI exit.
6
6
  * Log line write failures declare to stderr and never abort the leg.
7
7
  */
8
+ import { randomBytes } from "node:crypto";
8
9
  import { spawnSync } from "node:child_process";
9
10
  import {
10
- appendFileSync,
11
11
  copyFileSync,
12
- closeSync,
13
12
  existsSync,
14
- openSync,
15
- readFileSync,
16
13
  mkdirSync,
17
14
  readdirSync,
15
+ renameSync,
18
16
  rmSync,
19
17
  statSync,
20
18
  type Dirent,
@@ -22,11 +20,10 @@ import {
22
20
  import { homedir } from "node:os";
23
21
  import { dirname, join } from "node:path";
24
22
 
25
- import type { RoleTurnContinuation } from "./host-contracts.ts";
26
23
  import { sitianReport } from "./sitian-facade.ts";
27
24
  import type { SitianRecordInput } from "./sitian-contracts.ts";
28
25
 
29
- import { errorText } from "./unknown-value.ts";
26
+ import { errorText, isEnoent } from "./unknown-value.ts";
30
27
 
31
28
  /** Volume category under `<run>/session/<kind>/records.jsonl`. */
32
29
  export const HOST_SESSION_RECORD_KIND = "host-session" as const;
@@ -47,31 +44,41 @@ export function sitianReportSafe(input: SitianRecordInput): void {
47
44
 
48
45
  /** Recursively scan for Codex rollout file matching sessionId under sessionsDir. */
49
46
  function findCodexRollout(sessionsDir: string, sessionId: string): string | undefined {
50
- if (!existsSync(sessionsDir)) return undefined;
47
+ // Direct readdir: only ENOENT is absence. EACCES/EPERM keep their cause (#1161 C3-io).
51
48
  const matches: Array<{ path: string; mtime: number }> = [];
52
49
  function scan(dir: string, depth = 0): void {
53
50
  if (depth > 6) return;
54
51
  let entries: Dirent[];
55
52
  try {
56
53
  entries = readdirSync(dir, { withFileTypes: true });
57
- } catch {
58
- return;
54
+ } catch (error) {
55
+ if (isEnoent(error)) return;
56
+ throw error;
59
57
  }
60
58
  for (const entry of entries) {
61
59
  const fullPath = join(dir, entry.name);
62
60
  if (entry.isDirectory()) {
63
61
  scan(fullPath, depth + 1);
64
62
  } else if (entry.isFile() && entry.name.endsWith(".jsonl") && entry.name.includes(sessionId)) {
63
+ // Ranking needs a real mtime. Silent mtime:0 would sort unreadable
64
+ // matches last and let an older readable sibling win (#1161 C3-io).
65
+ // ENOENT = vanished between readdir and stat (skip); other IO keeps cause.
65
66
  try {
66
67
  const stat = statSync(fullPath);
67
68
  matches.push({ path: fullPath, mtime: stat.mtimeMs });
68
- } catch {
69
- matches.push({ path: fullPath, mtime: 0 });
69
+ } catch (error) {
70
+ if (isEnoent(error)) continue;
71
+ throw error;
70
72
  }
71
73
  }
72
74
  }
73
75
  }
74
- scan(sessionsDir);
76
+ try {
77
+ scan(sessionsDir);
78
+ } catch (error) {
79
+ if (isEnoent(error)) return undefined;
80
+ throw error;
81
+ }
75
82
  if (matches.length === 0 || matches[0] === undefined) return undefined;
76
83
  matches.sort((a, b) => b.mtime - a.mtime);
77
84
  return matches[0]?.path;
@@ -118,55 +125,18 @@ function codexSessionsDirectory(home?: string): string {
118
125
  return join(process.env.CODEX_HOME || join(home || homedir(), ".codex"), "sessions");
119
126
  }
120
127
 
121
- /** Count each host activation, including Pi turns without an external copy. */
122
- export function recordRunStart(runDirectory: string): void {
123
- try { appendFileSync(join(runDirectory, ".run-starts"), "\n"); }
124
- catch (error) { declareHostSessionFailure(error); }
125
- }
126
-
127
128
  /**
128
- * Resolve the host dossier landing destination under `<run>/session/`.
129
- * Format: `<run>/session/<host>-<model>-<n>` (single file adds .jsonl; model slashes replaced with -).
130
- * Ordinal n is the start/resume count in the run (initial 1, each resume +1).
129
+ * Host original landing under `<run>/session/`: one file per host, never
130
+ * numbered — every exit overwrites the same name (#1161).
131
+ * codex / claude: `<host>.jsonl`; grok-build: directory `<host>`.
131
132
  */
132
133
  export function resolveHostDossierLandingPath(options: {
133
134
  readonly host: string;
134
- readonly model?: { readonly model?: string; readonly thinking?: string } | undefined;
135
135
  readonly sessionDirectory: string;
136
- readonly continuation: RoleTurnContinuation;
137
- }): { readonly landingPath: string; readonly ordinal: number; readonly sanitizedModel: string } {
138
- const sanitizedModel = (options.model?.model ?? "default").replace(/[\/\\]+/g, "-");
139
- let maxOrdinal = 0;
140
-
141
- if (existsSync(options.sessionDirectory)) {
142
- try {
143
- const entries = readdirSync(options.sessionDirectory);
144
- for (const entry of entries) {
145
- const match = entry.match(/-(\d+)(?:\.jsonl|\.failed)?$/);
146
- const first = match?.[1];
147
- if (first !== undefined) {
148
- const val = parseInt(first, 10);
149
- if (!isNaN(val) && val > maxOrdinal) {
150
- maxOrdinal = val;
151
- }
152
- }
153
- }
154
- } catch {
155
- // Ignore directory read errors
156
- }
157
- }
158
-
159
- const startsPath = join(dirname(options.sessionDirectory), ".run-starts");
160
- let starts = 0;
161
- if (existsSync(startsPath)) starts = readFileSync(startsPath, "utf8").length;
162
- const ordinal = Math.max(maxOrdinal + 1, starts, options.continuation.kind === "resume" ? 2 : 1);
163
-
164
- const baseName = `${options.host}-${sanitizedModel}-${ordinal}`;
165
- const landingPath = options.host === "grok-build"
166
- ? join(options.sessionDirectory, baseName)
167
- : join(options.sessionDirectory, `${baseName}.jsonl`);
168
-
169
- return { landingPath, ordinal, sanitizedModel };
136
+ }): string {
137
+ return options.host === "grok-build"
138
+ ? join(options.sessionDirectory, options.host)
139
+ : join(options.sessionDirectory, `${options.host}.jsonl`);
170
140
  }
171
141
 
172
142
  /** Record native session pointer line to Sitian as soon as session ID is known. */
@@ -212,29 +182,24 @@ function copyFileCloneOrFallback(src: string, dest: string): void {
212
182
  copyFileSync(src, dest);
213
183
  }
214
184
 
215
- /** Copy both grok-build originals: chat_history.jsonl and usage.json into destDir. */
185
+ /**
186
+ * Copy both grok-build originals: chat_history.jsonl and usage.json into destDir.
187
+ * Direct copy — no existsSync/access preflight. Real ENOENT/EACCES surface from
188
+ * the copy itself (Node fs: do not exists/access before open — #1161 C3-io).
189
+ */
216
190
  function copyGrokDossier(srcDir: string, destDir: string): void {
217
- const chatHistorySrc = join(srcDir, "chat_history.jsonl");
218
- if (!existsSync(chatHistorySrc)) {
219
- throw new Error(`Grok native chat_history.jsonl missing at ${chatHistorySrc}`);
220
- }
221
191
  mkdirSync(destDir, { recursive: true });
222
- const chatHistoryDest = join(destDir, "chat_history.jsonl");
223
- copyFileCloneOrFallback(chatHistorySrc, chatHistoryDest);
224
-
225
- const usageSrc = join(srcDir, "usage.json");
226
- if (!existsSync(usageSrc)) {
227
- throw new Error(`Grok native usage.json missing at ${usageSrc}`);
228
- }
229
- copyFileCloneOrFallback(usageSrc, join(destDir, "usage.json"));
192
+ copyFileCloneOrFallback(join(srcDir, "chat_history.jsonl"), join(destDir, "chat_history.jsonl"));
193
+ copyFileCloneOrFallback(join(srcDir, "usage.json"), join(destDir, "usage.json"));
230
194
  }
231
195
 
232
196
  /**
233
- * Copy native host session original to run session dossier after child process exits.
234
- * If copy fails, retries once.
235
- * On success, appends native-session-copy line to Sitian.
236
- * On repeated failure, appends native-session-warning line to Sitian.
237
- * Does not throw; turn outcome is never altered by copy success or failure.
197
+ * Copy the native host session original over the run's single landing path
198
+ * after the child process exits. Copies beside the landing path and swaps in
199
+ * on success, so a failed copy never destroys the previous good original.
200
+ * Retries once. Success appends a native-session-copy line, repeated failure a
201
+ * native-session-warning line. Does not throw; the turn outcome is never
202
+ * altered by copy success or failure.
238
203
  */
239
204
  export function copyAndRecordHostDossier(options: {
240
205
  readonly host: string;
@@ -242,102 +207,221 @@ export function copyAndRecordHostDossier(options: {
242
207
  readonly cwd: string;
243
208
  readonly sessionDirectory: string;
244
209
  readonly sessionParent: string;
245
- readonly continuation: RoleTurnContinuation;
246
- readonly model?: { readonly model?: string; readonly thinking?: string } | undefined;
247
210
  readonly home?: string | undefined;
248
211
  }): void {
249
- if (options.host === "pi" || options.host === "hermes") {
212
+ if (options.host === "hermes") {
250
213
  return;
251
214
  }
252
- const nativePath = resolveNativeSessionPath({
215
+ const report = (payload: Record<string, unknown>): void => sitianReportSafe({
216
+ level: "event",
217
+ kind: HOST_SESSION_RECORD_KIND,
253
218
  host: options.host,
254
- sessionId: options.sessionId,
255
219
  cwd: options.cwd,
256
- ...(options.home !== undefined ? { home: options.home } : {}),
220
+ sessionParent: options.sessionParent,
221
+ source: `${options.host}-dossier`,
222
+ payload,
257
223
  });
258
- if (nativePath === undefined && options.host !== "codex") return;
259
-
260
- let destination: ReturnType<typeof resolveHostDossierLandingPath>;
261
- try {
262
- destination = resolveHostDossierLandingPath(options);
263
- } catch (error) {
264
- sitianReportSafe({
265
- level: "event",
266
- kind: HOST_SESSION_RECORD_KIND,
267
- host: options.host,
268
- cwd: options.cwd,
269
- sessionParent: options.sessionParent,
270
- source: `${options.host}-dossier`,
271
- payload: {
272
- type: "native-session-warning",
273
- sessionId: options.sessionId,
274
- error: errorText(error),
275
- },
224
+ // Pi already lands at session/session.jsonl; record that landing through the
225
+ // same native-session-copy row current.json projects — no copy, no second
226
+ // original parser.
227
+ if (options.host === "pi") {
228
+ report({
229
+ type: "native-session-copy",
230
+ nativePath: options.sessionParent,
231
+ landingPath: options.sessionParent,
232
+ sessionId: options.sessionId,
276
233
  });
277
234
  return;
278
235
  }
279
- const { landingPath, ordinal, sanitizedModel } = destination;
236
+
237
+ const landingPath = resolveHostDossierLandingPath(options);
238
+ // Per-attempt staging/previous names: concurrent copies of the same landing
239
+ // must not share one `.copying` / `.previous` path (#1161 C2). Node's fs
240
+ // copy is not atomic; unique sibling + rename is the smallest ownership fix.
241
+ // Swap-failure restores that attempt's own `.previous.<token>` inline — no
242
+ // next-call orphan scan (J6: do not invent a second recovery mechanism).
243
+ //
244
+ // Native-path discovery (incl. Codex rollout scan) stays inside this attempt
245
+ // try: ADR 0086 one-retry + native-session-warning covers discover/copy
246
+ // failure alike. A throw outside would abort the host leg (#1161 C3-io).
280
247
 
281
248
  let lastError: unknown;
282
249
  let copySuccess = false;
250
+ let stagingForCleanup: string | undefined;
251
+ let nativePath: string | undefined;
283
252
  for (let attempt = 1; attempt <= 2; attempt++) {
253
+ const attemptToken = randomBytes(6).toString("hex");
254
+ const staging = `${landingPath}.copying.${attemptToken}`;
255
+ stagingForCleanup = staging;
284
256
  try {
257
+ nativePath = resolveNativeSessionPath({
258
+ host: options.host,
259
+ sessionId: options.sessionId,
260
+ cwd: options.cwd,
261
+ ...(options.home !== undefined ? { home: options.home } : {}),
262
+ });
263
+ if (nativePath === undefined && options.host !== "codex") {
264
+ stagingForCleanup = undefined;
265
+ return;
266
+ }
285
267
  if (options.host === "grok-build") {
286
- copyGrokDossier(nativePath!, landingPath);
268
+ copyGrokDossier(nativePath!, staging);
287
269
  } else {
288
- if (nativePath === undefined || !existsSync(nativePath)) {
270
+ // Undefined path = discovery found nothing (true absence). Present path:
271
+ // copy directly so EACCES/EPERM keep their cause — no existsSync wash.
272
+ if (nativePath === undefined) {
289
273
  throw new Error(`Native session file missing for ${options.host} session ${options.sessionId}`);
290
274
  }
291
275
  mkdirSync(dirname(landingPath), { recursive: true });
292
- copyFileCloneOrFallback(nativePath, landingPath);
276
+ copyFileCloneOrFallback(nativePath, staging);
277
+ }
278
+ if (options.host === "grok-build") {
279
+ // A directory cannot be renamed over: park the previous original aside,
280
+ // move the new one in, and put the old one back only if the landing is
281
+ // still absent. This attempt owns its `.previous.<token>` through
282
+ // success or failure — restore-if-absent, then clear the token when it
283
+ // is no longer the unique original (peer may have published landing).
284
+ // Never delete previous when restore failed and landing is still absent.
285
+ // Cleanup failure must not wash into native-session-copy success
286
+ // (#1161 C2 / 失败诚实). No orphan scan, no cross-call recovery, no queue.
287
+ const previous = `${landingPath}.previous.${attemptToken}`;
288
+ const hadPrevious = existsSync(landingPath);
289
+ let parked = false;
290
+ let keepUniquePrevious = false;
291
+ let cleanupError: unknown;
292
+ let swapFailure: unknown;
293
+ let restoreError: unknown;
294
+ try {
295
+ if (hadPrevious) {
296
+ renameSync(landingPath, previous);
297
+ parked = true;
298
+ }
299
+ try {
300
+ renameSync(staging, landingPath);
301
+ } catch (swapError) {
302
+ if (parked && existsSync(previous) && !existsSync(landingPath)) {
303
+ try {
304
+ renameSync(previous, landingPath);
305
+ } catch (error) {
306
+ restoreError = error;
307
+ // Restore failed; previous may still be the only old original.
308
+ if (existsSync(previous) && !existsSync(landingPath)) {
309
+ keepUniquePrevious = true;
310
+ }
311
+ }
312
+ }
313
+ swapFailure = swapError;
314
+ }
315
+ } finally {
316
+ if (parked && existsSync(previous) && !keepUniquePrevious) {
317
+ try {
318
+ rmSync(previous, { recursive: true, force: true });
319
+ } catch (error) {
320
+ cleanupError = error;
321
+ }
322
+ }
323
+ }
324
+ if (swapFailure !== undefined) {
325
+ // Keep every residual cause; previous cleanup names its path explicitly
326
+ // (Node errno usually embeds path — still pass residual so AggregateError
327
+ // never silently omits which leftover remains; #1161 C2).
328
+ lastError = combineCopyFailureCauses(
329
+ swapFailure,
330
+ restoreError,
331
+ cleanupError,
332
+ cleanupError === undefined ? undefined : previous,
333
+ );
334
+ // Ordinary swap fail with restore+cleanup success → ADR 0086 retry once.
335
+ // Restore or cleanup incomplete → keep residual paths; do not retry
336
+ // (retry would re-park / wash residual-previous causes).
337
+ if (restoreError !== undefined || cleanupError !== undefined) {
338
+ break;
339
+ }
340
+ try {
341
+ rmSync(staging, { recursive: true, force: true });
342
+ stagingForCleanup = undefined;
343
+ } catch (stagingCleanupError) {
344
+ lastError = combineCopyFailureCauses(
345
+ swapFailure,
346
+ undefined,
347
+ stagingCleanupError,
348
+ staging,
349
+ );
350
+ // Residual already recorded; clear so the final pass does not re-rm / duplicate.
351
+ stagingForCleanup = undefined;
352
+ break;
353
+ }
354
+ continue;
355
+ }
356
+ if (cleanupError !== undefined) {
357
+ // Swap landed but cleanup ownership is incomplete — leave the true
358
+ // cause with residual previous path; do not retry or claim copy.
359
+ lastError = combineCopyFailureCauses(cleanupError, undefined, cleanupError, previous);
360
+ stagingForCleanup = undefined;
361
+ break;
362
+ }
363
+ } else {
364
+ renameSync(staging, landingPath); // atomically replaces the previous file
293
365
  }
294
366
  copySuccess = true;
367
+ stagingForCleanup = undefined;
295
368
  break;
296
369
  } catch (error) {
297
370
  lastError = error;
371
+ try {
372
+ rmSync(staging, { recursive: true, force: true });
373
+ stagingForCleanup = undefined;
374
+ } catch (cleanupError) {
375
+ lastError = combineCopyFailureCauses(error, undefined, cleanupError, staging);
376
+ // Residual already recorded; clear so the final pass does not re-rm / duplicate.
377
+ stagingForCleanup = undefined;
378
+ break;
379
+ }
298
380
  }
299
381
  }
300
382
 
301
383
  if (copySuccess) {
302
- sitianReportSafe({
303
- level: "event",
304
- kind: HOST_SESSION_RECORD_KIND,
305
- host: options.host,
306
- cwd: options.cwd,
307
- sessionParent: options.sessionParent,
308
- source: `${options.host}-dossier`,
309
- payload: {
310
- type: "native-session-copy",
311
- nativePath,
312
- landingPath,
313
- ordinal,
314
- sessionId: options.sessionId,
315
- },
316
- });
384
+ report({ type: "native-session-copy", nativePath, landingPath, sessionId: options.sessionId });
317
385
  } else {
318
- // A partial first attempt must not masquerade as a complete native original.
319
- try { rmSync(landingPath, { recursive: options.host === "grok-build", force: true }); }
320
- catch { /* warning below reports the original copy failure */ }
321
- // Reserve the failed ordinal outside the native dossier landing namespace.
322
- try {
323
- mkdirSync(options.sessionDirectory, { recursive: true });
324
- closeSync(openSync(join(options.sessionDirectory, `${options.host}-${sanitizedModel}-${ordinal}.failed`), "a"));
325
- } catch { /* the warning below retains the original copy failure */ }
326
- sitianReportSafe({
327
- level: "event",
328
- kind: HOST_SESSION_RECORD_KIND,
329
- host: options.host,
330
- cwd: options.cwd,
331
- sessionParent: options.sessionParent,
332
- source: `${options.host}-dossier`,
333
- payload: {
334
- type: "native-session-warning",
335
- nativePath,
336
- landingPath,
337
- ordinal,
338
- sessionId: options.sessionId,
339
- error: errorText(lastError),
340
- },
386
+ // A partial attempt must not masquerade as a complete native original.
387
+ if (stagingForCleanup !== undefined) {
388
+ try {
389
+ rmSync(stagingForCleanup, { recursive: true, force: true });
390
+ } catch (cleanupError) {
391
+ lastError = combineCopyFailureCauses(lastError, undefined, cleanupError, stagingForCleanup);
392
+ }
393
+ }
394
+ report({
395
+ type: "native-session-warning",
396
+ nativePath,
397
+ landingPath,
398
+ sessionId: options.sessionId,
399
+ error: errorText(lastError),
341
400
  });
342
401
  }
343
402
  }
403
+
404
+ /** Fold swap / restore / staging-or-previous cleanup causes without washing any. */
405
+ function combineCopyFailureCauses(
406
+ primary: unknown,
407
+ restoreError: unknown,
408
+ cleanupError: unknown,
409
+ residualPath?: string,
410
+ ): unknown {
411
+ const errors: unknown[] = [primary];
412
+ const parts = [errorText(primary)];
413
+ if (restoreError !== undefined) {
414
+ errors.push(restoreError);
415
+ parts.push(`restore failed: ${errorText(restoreError)}`);
416
+ }
417
+ if (cleanupError !== undefined) {
418
+ if (cleanupError !== primary) errors.push(cleanupError);
419
+ parts.push(
420
+ residualPath === undefined
421
+ ? `cleanup failed: ${errorText(cleanupError)}`
422
+ : `cleanup failed: ${errorText(cleanupError)} (residual: ${residualPath})`,
423
+ );
424
+ }
425
+ if (errors.length === 1 && parts.length === 1) return primary;
426
+ return new AggregateError(errors, parts.join("; "), { cause: primary });
427
+ }
@@ -8,6 +8,12 @@ import { isRecord } from "./unknown-value.ts";
8
8
 
9
9
  export type LedgerSessionRow = Record<string, unknown>;
10
10
 
11
+ /** Complete Pi volume decoding: malformed EOF is an error, with no row-shape filtering. */
12
+ export async function readStrictPiSessionJsonl(path: string): Promise<unknown[]> {
13
+ const text = await readFile(path, "utf8");
14
+ return text.trim().split("\n").filter(Boolean).map((line) => JSON.parse(line) as unknown);
15
+ }
16
+
11
17
  /**
12
18
  * Loud JSONL failure that still retains rows parsed before the bad line.
13
19
  * Callers that only need the throw keep catching Error; owners that must
@@ -9,7 +9,7 @@
9
9
  import { existsSync } from "node:fs";
10
10
  import { basename } from "node:path";
11
11
 
12
- import { sitianReport } from "./sitian-facade.ts";
12
+ import { sitianReportSafe } from "./host-session-record.ts";
13
13
  import {
14
14
  NavigatorUnavailableError,
15
15
  navigatorProviderFailureFromPublicTerminal,
@@ -21,6 +21,7 @@ import {
21
21
  } from "./navigator-session-contracts.ts";
22
22
  import type { NoReceiptLifecycleFacts } from "./receipt-delivery-policy.ts";
23
23
  import { runDirectoryFromHostContext, type HostContext } from "./host-contracts.ts";
24
+ import { parseRunLeaf } from "./role-run-placement.ts";
24
25
  import type { PublicSummonResult } from "./public-role-summons.ts";
25
26
  import { CliUsageError } from "./public-cli/cli-errors.ts";
26
27
  import { isNavigatorSeat } from "./packaged-role-registry.ts";
@@ -57,11 +58,9 @@ async function resolveNavigatorLedgerHome(context: HostContext): Promise<string
57
58
  export const NAVIGATOR_HOST_RUN_POINTER_ENTRY = "ak-navigator-host-run";
58
59
 
59
60
  export function runIdFromNavigatorDirectory(runDirectory: string): string | undefined {
60
- const entry = basename(runDirectory);
61
- const suffix = "@navigator";
62
- if (!entry.endsWith(suffix)) return undefined;
63
- const runId = entry.slice(0, entry.length - suffix.length);
64
- return runId.length > 0 ? runId : undefined;
61
+ const parsed = parseRunLeaf(basename(runDirectory));
62
+ if (parsed === undefined || parsed.role !== "navigator") return undefined;
63
+ return parsed.runId;
65
64
  }
66
65
 
67
66
  export function readNavigatorHostRunPointer(entries: readonly unknown[]): string | undefined {
@@ -285,18 +284,14 @@ export function createNativeNavigatorSessionFactory(deps?: {
285
284
  routePlaybookReadFailure: () => routePlaybookReadFailure,
286
285
  appendEntry: (customType, data) => {
287
286
  sessionManager.appendCustomEntry(customType, data);
288
- try {
289
- sitianReport({
290
- level: "event",
291
- kind: "attendance",
292
- cwd: context.cwd,
293
- sessionParent: sessionManager.getSessionFile(),
294
- payload: { customType, data },
295
- source: "navigator-public-session",
296
- });
297
- } catch {
298
- // best-effort
299
- }
287
+ sitianReportSafe({
288
+ level: "event",
289
+ kind: "attendance",
290
+ cwd: context.cwd,
291
+ sessionParent: sessionManager.getSessionFile(),
292
+ payload: { customType, data },
293
+ source: "navigator-public-session",
294
+ });
300
295
  },
301
296
  entries: () => sessionManager.getEntries(),
302
297
  setModel: async (next, nextThinking) => {
@@ -5,6 +5,8 @@
5
5
  import { readFile } from "node:fs/promises";
6
6
  import { resolve } from "node:path";
7
7
 
8
+ import { sessionDirectoryOf } from "./role-run-placement.ts";
9
+
8
10
  import { loadDoctorCase } from "./doctor-evidence.ts";
9
11
  import { isEnoent } from "./unknown-value.ts";
10
12
  import { runDirectoryFromHostContext, type HostContext } from "./host-contracts.ts";
@@ -69,7 +71,7 @@ export async function loadNavigatorWorkContext(
69
71
  const publicRunDir = runDirectoryFromHostContext(options.context);
70
72
  const currentSessionDir = options.context.sessionManager.getSessionDir();
71
73
  const isBoundPublicRun = publicRunDir !== undefined
72
- && resolve(currentSessionDir) === resolve(publicRunDir, "session");
74
+ && resolve(currentSessionDir) === resolve(sessionDirectoryOf(publicRunDir));
73
75
  if (subjectMode === "public-instruction" && isBoundPublicRun) {
74
76
  let admitted;
75
77
  try {
@@ -1,6 +1,7 @@
1
1
  import type { RoleHost, HostContext } from "./host-contracts.ts";
2
2
  import { registerFiledSubmissionTool } from "./filed-submission.ts";
3
3
  import { roleSubmissionDeclaration } from "./role-submission-declarations.ts";
4
+ import { isSafePositiveTicketNumber } from "./run-ticket-number.ts";
4
5
  /**
5
6
  * Public Notary role runtime — direct officer seat (not through Gatekeeper province).
6
7
  * Caller supplies only a source-run locator; Notary self-fetches authoritative materials.
@@ -44,7 +45,7 @@ export function readNotaryTicketFlag(flag: unknown): number | undefined {
44
45
  }
45
46
  if (flag.trim() === "") return undefined;
46
47
  const n = Number(flag);
47
- if (!Number.isSafeInteger(n) || n < 1) {
48
+ if (!isSafePositiveTicketNumber(n)) {
48
49
  throw new Error(
49
50
  "Notary ak-notary-ticket-number is present but not a safe positive integer string",
50
51
  );
@@ -14,6 +14,7 @@ import {
14
14
  } from "./activation-ledger-topology.ts";
15
15
  import type { NotarySourceRunLocator } from "./notary-contracts.ts";
16
16
  import { findRunDirectoryById, readRoleRunIdentity } from "./public-cli/run-lifecycle.ts";
17
+ import { formatRunLeaf, parseRunLeaf } from "./role-run-placement.ts";
17
18
 
18
19
  async function readRetainedIdentity(runDirectory: string) {
19
20
  try {
@@ -26,11 +27,6 @@ async function readRetainedIdentity(runDirectory: string) {
26
27
  }
27
28
  }
28
29
 
29
- // Run directory leaf: <runId>@<role>. Production uses uuidv7 runIds; offline tracers
30
- // may use deterministic non-uuid leaves. Role token stays identifier-shaped.
31
- const RUN_DIR_NAME =
32
- /^([A-Za-z0-9][A-Za-z0-9._-]*)@([A-Za-z][A-Za-z0-9_-]*)$/;
33
-
34
30
  export class NotarySourceRunError extends Error {
35
31
  constructor(message: string, options?: ErrorOptions) {
36
32
  super(message, options);
@@ -38,12 +34,6 @@ export class NotarySourceRunError extends Error {
38
34
  }
39
35
  }
40
36
 
41
- function parseRunDirectoryName(name: string): { runId: string; role: string } | undefined {
42
- const match = RUN_DIR_NAME.exec(name);
43
- if (match === null) return undefined;
44
- return { runId: match[1]!, role: match[2]! };
45
- }
46
-
47
37
  async function requireRunDirectory(candidate: string, display: string): Promise<string> {
48
38
  let real: string;
49
39
  try {
@@ -68,7 +58,7 @@ async function requireRunDirectory(candidate: string, display: string): Promise<
68
58
  `notary --source-run must be a run directory: ${display}`,
69
59
  );
70
60
  }
71
- const identity = parseRunDirectoryName(basename(real));
61
+ const identity = parseRunLeaf(basename(real));
72
62
  if (identity === undefined) {
73
63
  throw new NotarySourceRunError(
74
64
  `notary --source-run must be named <runId>@<role>: ${basename(real)}`,
@@ -84,7 +74,7 @@ async function requireRunDirectory(candidate: string, display: string): Promise<
84
74
  * - absolute/relative path that realpath's to that same book runs slot
85
75
  *
86
76
  * Rejects project-tree projections and any path outside the project's ledger book runs.
87
- * Retained `run-state.json` must match basename identity and book binding.
77
+ * Retained state.jsonl run-state must match basename identity and book binding.
88
78
  */
89
79
  export async function resolveNotarySourceRunLocator(options: {
90
80
  readonly projectRoot: string;
@@ -102,13 +92,13 @@ export async function resolveNotarySourceRunLocator(options: {
102
92
  const bookRunsRoot = join(bookDirectory, "runs");
103
93
 
104
94
  let candidate: string;
105
- const bare = parseRunDirectoryName(raw);
95
+ const bare = parseRunLeaf(raw);
106
96
  if (bare !== undefined && !raw.includes("/") && !raw.includes("\\")) {
107
97
  candidate = (await findRunDirectoryById(options.home, bare.runId, bookKey, bare.role))
108
- ?? join(bookRunsRoot, `${bare.runId}@${bare.role}`);
98
+ ?? join(bookRunsRoot, formatRunLeaf(bare.runId, bare.role));
109
99
  } else {
110
100
  candidate = isAbsolute(raw) ? raw : resolve(options.projectRoot, raw);
111
- const identity = parseRunDirectoryName(basename(candidate));
101
+ const identity = parseRunLeaf(basename(candidate));
112
102
  const subjectDirectory = dirname(dirname(candidate));
113
103
  const isLegacyUnboundLocator =
114
104
  identity !== undefined &&
@@ -126,7 +116,7 @@ export async function resolveNotarySourceRunLocator(options: {
126
116
  }
127
117
 
128
118
  const real = await requireRunDirectory(candidate, raw);
129
- const identity = parseRunDirectoryName(basename(real))!;
119
+ const identity = parseRunLeaf(basename(real))!;
130
120
 
131
121
  const bookIdentity = physicalPathIdentity(bookDirectory);
132
122
  const parentIdentity = physicalPathIdentity(dirname(real));
@@ -176,7 +166,7 @@ export async function loadNotarySourceRunLocator(
176
166
  path: string,
177
167
  ): Promise<NotarySourceRunLocator> {
178
168
  const real = await requireRunDirectory(path, path);
179
- const identity = parseRunDirectoryName(basename(real))!;
169
+ const identity = parseRunLeaf(basename(real))!;
180
170
  const runState = await readRetainedIdentity(real);
181
171
  if (runState === undefined) {
182
172
  throw new NotarySourceRunError(