@akagilnc/pi-workflow-roles 0.1.3902 → 0.1.3936

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 (34) hide show
  1. package/README.md +3 -3
  2. package/README.zh-CN.md +4 -4
  3. package/dist/acp-host/production-host.js +2540 -1994
  4. package/dist/compliance-transport.js +4 -1
  5. package/dist/headless-host/production-host.js +2274 -1728
  6. package/dist/prepared-role-turn.js +5 -0
  7. package/dist/public-cli/auto-resume.js +115 -13
  8. package/dist/public-cli/diarist-run.js +154 -0
  9. package/dist/public-cli/main.js +7082 -5854
  10. package/dist/public-cli/option-definitions.js +1 -1
  11. package/dist/public-cli/post-admission.js +455 -188
  12. package/dist/public-cli/role-turn-host-resolution.js +59 -0
  13. package/dist/public-cli/run-lifecycle.js +11 -6
  14. package/dist/public-cli/turn-request.js +1 -0
  15. package/dist/public-role-summons.js +48 -42
  16. package/dist/role-activation-flags.js +3 -0
  17. package/dist/role-runtime.js +14 -3
  18. package/dist/session-identity.js +58 -0
  19. package/package.json +1 -1
  20. package/src/compliance-transport.ts +6 -1
  21. package/src/host-contracts.ts +2 -0
  22. package/src/public-cli/auto-resume.ts +157 -11
  23. package/src/public-cli/cli.ts +25 -85
  24. package/src/public-cli/countersign-run.ts +26 -33
  25. package/src/public-cli/option-definitions.ts +1 -1
  26. package/src/public-cli/post-admission.ts +514 -159
  27. package/src/public-cli/reviewer-run.ts +0 -2
  28. package/src/public-cli/role-turn-host-resolution.ts +122 -0
  29. package/src/public-cli/run-lifecycle.ts +15 -10
  30. package/src/public-cli/turn-request.ts +3 -0
  31. package/src/public-role-summons.ts +71 -54
  32. package/src/role-activation-flags.ts +3 -0
  33. package/src/role-runtime.ts +14 -2
  34. package/src/session-identity.ts +34 -0
@@ -25,6 +25,7 @@ import {
25
25
  AUTO_RESUME_LIMIT,
26
26
  describeErrorIdentity,
27
27
  acquireRunWriterLease,
28
+ markRunResumable,
28
29
  markRunTerminal,
29
30
  RunWriterLeaseHeldError,
30
31
  type RunWriterLease,
@@ -34,12 +35,42 @@ import { isLawfulTypedTerminalOutcome, formatTerminalResult, type TerminalArtifa
34
35
  import {
35
36
  presentFailureTerminal,
36
37
  presentStructuralRejection,
38
+ resolveControlledFailureResumeObservation,
37
39
  type SealedAcceptanceRedispatchDisposition,
38
40
  } from "./settlement.ts";
39
41
  import type { CliIo } from "./cli-io.ts";
40
42
 
41
43
  const dummyIo: CliIo = { stdout: () => {}, stderr: () => {} };
42
44
 
45
+ /**
46
+ * Persist run-state after a host-turn result, outside the retried dispatch try.
47
+ * Lawful settlement always seals terminal — a typed 429 observation must not
48
+ * win (#416 成功即停). 429 only marks resumable on the controlled-failure path.
49
+ * Write failure must escape the retried dispatch try instead of becoming a
50
+ * synthetic host-turn terminal.
51
+ */
52
+ export async function persistReturnedRunState(
53
+ admitted: { runDirectory: string; principal?: DurablePrincipal },
54
+ authority: Pick<DurablePrincipalAuthority, "isAvailable">,
55
+ options?: { readonly lawful?: boolean },
56
+ ): Promise<void> {
57
+ if (options?.lawful === true) {
58
+ await markRunTerminal(admitted.runDirectory);
59
+ return;
60
+ }
61
+ const resumeObservation = await resolveControlledFailureResumeObservation({
62
+ runDirectory: admitted.runDirectory,
63
+ });
64
+ const typedHttp429 = resumeObservation.typedHttp429;
65
+ if (admitted.principal !== undefined && typedHttp429 !== undefined) {
66
+ if (await authority.isAvailable(admitted.principal)) {
67
+ await markRunResumable(admitted.runDirectory, typedHttp429);
68
+ return;
69
+ }
70
+ }
71
+ await markRunTerminal(admitted.runDirectory);
72
+ }
73
+
43
74
  function presentTerminal(terminal: TerminalResult, io: CliIo): void {
44
75
  if (terminal.roleOutcome.kind === "failure" || terminal.roleOutcome.kind === "no_receipt") {
45
76
  presentFailureTerminal(terminal, io);
@@ -67,8 +98,48 @@ async function finalizeExceptionRunBestEffort(runDirectory: string, io: CliIo):
67
98
  export type AutoResumeDispatchResult = {
68
99
  exitCode: number;
69
100
  terminal?: TerminalResult;
101
+ /**
102
+ * Pre-turn settlement under the writer lease (station child exhausted).
103
+ * Loop presents this result and must not redispatch (#840 父子不层叠).
104
+ */
105
+ skipAutoResume?: true;
106
+ /**
107
+ * Host turn actually started. Absent on beforeDispatch / pre-turn settlement
108
+ * so the loop retries the initial payload instead of a session resume.
109
+ */
110
+ turnDispatched?: true;
111
+ /**
112
+ * Dispatch skipped run-state persist so this loop seam owns it.
113
+ * Absent when dispatch already persisted or never produced a terminal.
114
+ */
115
+ needsPersist?: true;
70
116
  };
71
117
 
118
+ /**
119
+ * Thrown by dispatchPostAdmissionTurn when a failure happens inside its own
120
+ * settlement authority (presentControlledFailure) after the host turn
121
+ * genuinely started — never to fabricate a replacement terminal (ADR 0080
122
+ * single-settlement-disposition: settlement stays exactly presentControlledFailure
123
+ * / settleFailureTerminalResult, or — once the retry budget is exhausted —
124
+ * this loop's own dispatchExceptionFailureTerminal). Its only job is to carry
125
+ * the "turn already started" fact across the throw boundary so this loop
126
+ * selects a resume payload on the next attempt instead of replaying the
127
+ * initial one (#840 r9 判词 class 1 boundary — 覆盖 executeTurn 已启动后至
128
+ * 返回带 turnDispatched 结果前的全部异常). `cause` is the true failure;
129
+ * retention below serializes it whole via the standard Error.cause chain.
130
+ */
131
+ export class TurnDispatchedFailure extends Error {
132
+ override readonly name = "TurnDispatchedFailure";
133
+ constructor(cause: unknown) {
134
+ super(
135
+ `settlement failed after the host turn genuinely started: ${
136
+ cause instanceof Error ? cause.message : String(cause)
137
+ }`,
138
+ { cause },
139
+ );
140
+ }
141
+ }
142
+
72
143
  /** Session custom-entry type carrying the pointer to one dispatch error file. */
73
144
  export const DISPATCH_ERROR_RETENTION_ENTRY_TYPE = "ak_run_dispatch_error_retention" as const;
74
145
 
@@ -80,27 +151,30 @@ function runArtifactsDirectory(runDirectory: string): string {
80
151
  /**
81
152
  * #182-A hardened path identity, mirrored from settlement.ts's
82
153
  * ensureAuditEvidenceDirectory: a planted symlink at the run directory or the
83
- * artifacts path must not receive the dispatch error dump (O_NOFOLLOW only
154
+ * artifacts path must not receive a durable run artifact (O_NOFOLLOW only
84
155
  * protects the final file name; recursive mkdir would accept a symlinked
85
- * parent). Fails loudly with the true cause instead.
156
+ * parent). Fails loudly with the true cause instead. Shared by every caller
157
+ * that needs a durable run-artifacts write independent of session/dossier
158
+ * health — dispatch-error retention here, and post-admission's best-effort
159
+ * cleanup diagnostic (#840 bounce class 2).
86
160
  */
87
- async function ensureRealArtifactsDirectory(runDirectory: string): Promise<string> {
161
+ export async function ensureRealArtifactsDirectory(runDirectory: string): Promise<string> {
88
162
  const runStat = await lstat(runDirectory);
89
163
  if (runStat.isSymbolicLink() || !runStat.isDirectory()) {
90
- throw new Error("dispatch error retention: run directory is not a real directory");
164
+ throw new Error("run artifact retention: run directory is not a real directory");
91
165
  }
92
166
  const artifactsDir = runArtifactsDirectory(runDirectory);
93
167
  try {
94
168
  const existing = await lstat(artifactsDir);
95
169
  if (existing.isSymbolicLink() || !existing.isDirectory()) {
96
- throw new Error("dispatch error retention: artifacts path is not a real directory");
170
+ throw new Error("run artifact retention: artifacts path is not a real directory");
97
171
  }
98
172
  } catch (error) {
99
173
  if (!isMissingPathError(error)) throw error;
100
174
  await mkdir(artifactsDir, { recursive: true });
101
175
  const created = await lstat(artifactsDir);
102
176
  if (created.isSymbolicLink() || !created.isDirectory()) {
103
- throw new Error("dispatch error retention: artifacts directory is not a real directory");
177
+ throw new Error("run artifact retention: artifacts directory is not a real directory");
104
178
  }
105
179
  }
106
180
  return artifactsDir;
@@ -268,6 +342,23 @@ async function retainDispatchError(
268
342
  return pointerError === undefined ? { file: filePath } : { file: filePath, pointerError };
269
343
  }
270
344
 
345
+ /**
346
+ * Unwrap TurnDispatchedFailure before this loop's own final presentation
347
+ * (#840 r9 判词 class 1 — the auto-resume.ts final presentation boundary).
348
+ * The wrapper only carries the "turn already genuinely started" signal
349
+ * across the throw boundary so the loop above selects a resume payload; the
350
+ * typed decisiveFacts and diagnostic text below must name the real cause it
351
+ * wraps (接住可以,洗白不行 — 未识别异常不得冒用具体标签,真因必须落痕), not
352
+ * the internal signal's own identity.
353
+ */
354
+ function unwrapTurnDispatchedFailure(error: unknown): unknown {
355
+ let current = error;
356
+ while (current instanceof TurnDispatchedFailure) {
357
+ current = current.cause;
358
+ }
359
+ return current;
360
+ }
361
+
271
362
  /**
272
363
  * Typed failure terminal for a retry path that ended with only exceptions:
273
364
  * loud, non-lawful, carrying the last true cause and the pointers to the
@@ -283,12 +374,13 @@ function dispatchExceptionFailureTerminal(input: {
283
374
  /** True only when every attempt threw; otherwise describe just the final attempt. */
284
375
  everyAttemptThrew: boolean;
285
376
  }): TerminalResult {
377
+ const causeError = unwrapTurnDispatchedFailure(input.causeError);
286
378
  // #426 review: this terminal fires whenever the FINAL dispatch throws, not
287
379
  // only when every attempt threw — do not misrepresent a mixed retry history.
288
380
  const history = input.everyAttemptThrew
289
381
  ? "dispatch threw an exception on every attempt"
290
382
  : "the final dispatch threw an exception";
291
- const diagnostic = `${history} (${input.endReason}; resumes used ${input.autoResumeAttempts}); last cause: ${describeErrorIdentity(input.causeError)}`;
383
+ const diagnostic = `${history} (${input.endReason}; resumes used ${input.autoResumeAttempts}); last cause: ${describeErrorIdentity(causeError)}`;
292
384
  const decisiveFacts: Record<string, unknown> = {
293
385
  cause: "unrecognized",
294
386
  diagnostic,
@@ -298,7 +390,7 @@ function dispatchExceptionFailureTerminal(input: {
298
390
  if (input.errorFiles.length > 0) {
299
391
  decisiveFacts.lastDispatchErrorFile = input.errorFiles[input.errorFiles.length - 1];
300
392
  }
301
- const candidate = input.causeError as { name?: unknown; code?: unknown };
393
+ const candidate = causeError as { name?: unknown; code?: unknown };
302
394
  if (typeof candidate?.name === "string") decisiveFacts.errorName = candidate.name;
303
395
  if (typeof candidate?.code === "string" || typeof candidate?.code === "number") {
304
396
  decisiveFacts.errorCode = candidate.code;
@@ -334,6 +426,16 @@ export async function runWithAutoResumeLoop<
334
426
  principal: DurablePrincipal;
335
427
  };
336
428
  principalAuthority: DurablePrincipalAuthority;
429
+ /**
430
+ * Host-aware "can this principal's turn still be resumed" probe (#840 P1).
431
+ * Callers that know the selected host pass
432
+ * session-identity.ts's resolveHostAwareSessionAvailability(host,
433
+ * principalAuthority) so an ACP/headless turn's own binding file — not
434
+ * pi's session.jsonl — decides continuation for that host. Defaults to
435
+ * principalAuthority.isAvailable (pi-only check) when omitted, so a
436
+ * caller that never leaves the pi host keeps identical behavior.
437
+ */
438
+ isPrincipalAvailable?: (principal: DurablePrincipal) => Promise<boolean>;
337
439
  io: CliIo;
338
440
  sessionAppender: SessionCustomEntryAppender;
339
441
  /**
@@ -358,6 +460,9 @@ export async function runWithAutoResumeLoop<
358
460
  // `attempts >= limit` comparison (always false) — reject here, before any dispatch.
359
461
  const limit = options.autoResumeLimit ?? AUTO_RESUME_LIMIT;
360
462
  parseAutoResumeLimit(limit);
463
+ const isPrincipalAvailable =
464
+ options.isPrincipalAvailable ??
465
+ ((principal: DurablePrincipal) => options.principalAuthority.isAvailable(principal));
361
466
  let autoResumeAttempts = 0;
362
467
  let isFirst = true;
363
468
  let currentPayload = options.buildInitialPayload();
@@ -381,6 +486,11 @@ export async function runWithAutoResumeLoop<
381
486
  }
382
487
 
383
488
  let result: T | undefined;
489
+ // Set only when the caught throw is a TurnDispatchedFailure (#840 r9 判词
490
+ // class 1): the host turn genuinely started this attempt even though
491
+ // dispatch produced no result — the next payload must still be a resume,
492
+ // not a replay of the initial one.
493
+ let turnStartedBeforeThrow = false;
384
494
  try {
385
495
  result = await options.dispatch(currentPayload, lease, isFirst, dummyIo);
386
496
  } catch (error) {
@@ -390,6 +500,7 @@ export async function runWithAutoResumeLoop<
390
500
  // diagnostic-sink-isolation precedent). The dispatcher owns lease release
391
501
  // in its own finally, so the retry round starts with the lock free.
392
502
  lastThrownError = error;
503
+ turnStartedBeforeThrow = error instanceof TurnDispatchedFailure;
393
504
  const attempt = dispatchOrdinal;
394
505
  try {
395
506
  // Track the file as soon as it is durably written (#426 review):
@@ -419,6 +530,7 @@ export async function runWithAutoResumeLoop<
419
530
  }
420
531
  dispatchOrdinal += 1;
421
532
 
533
+ let persistFailure: { readonly error: unknown } | undefined;
422
534
  if (result !== undefined) {
423
535
  everyAttemptThrew = false;
424
536
  const terminal = (result as { terminal?: TerminalResult }).terminal;
@@ -427,6 +539,23 @@ export async function runWithAutoResumeLoop<
427
539
  }
428
540
 
429
541
  const lawful = terminal !== undefined && isLawfulTypedTerminalOutcome(terminal.roleOutcome);
542
+ if (result.needsPersist === true) {
543
+ // Persist on the loop seam, not inside retried dispatch.
544
+ try {
545
+ await persistReturnedRunState(
546
+ options.admitted,
547
+ { isAvailable: isPrincipalAvailable },
548
+ lawful ? { lawful: true } : undefined,
549
+ );
550
+ } catch (persistError) {
551
+ // Lawful persist stays loud (no fake terminal). Non-lawful persist is
552
+ // not a host-turn failure — do not auto-resume it — but sealed stop
553
+ // must still see the already-settled result (#648).
554
+ if (lawful) throw persistError;
555
+ persistFailure = { error: persistError };
556
+ lastThrownError = persistError;
557
+ }
558
+ }
430
559
  if (lawful) {
431
560
  if (terminal !== undefined) {
432
561
  // Present lawful terminal once to real io (dummy was used inside dispatch)
@@ -434,6 +563,10 @@ export async function runWithAutoResumeLoop<
434
563
  }
435
564
  return result;
436
565
  }
566
+ if (result.skipAutoResume === true) {
567
+ if (terminal !== undefined) presentTerminal(terminal, options.io);
568
+ return result;
569
+ }
437
570
  }
438
571
 
439
572
  // Settlement-owned sealed disposition before any redispatch (#648 / #672):
@@ -471,13 +604,20 @@ export async function runWithAutoResumeLoop<
471
604
  }
472
605
  }
473
606
 
607
+ if (persistFailure !== undefined) {
608
+ throw persistFailure.error;
609
+ }
610
+
474
611
  if (result !== undefined) {
475
612
  const terminal = (result as { terminal?: TerminalResult }).terminal;
476
613
  if (autoResumeAttempts >= limit) {
477
614
  if (terminal !== undefined) presentTerminal(terminal, options.io);
478
615
  return result;
479
616
  }
480
- if (!(await options.principalAuthority.isAvailable(options.admitted.principal))) {
617
+ if (
618
+ result.turnDispatched === true
619
+ && !(await isPrincipalAvailable(options.admitted.principal))
620
+ ) {
481
621
  if (terminal !== undefined) presentTerminal(terminal, options.io);
482
622
  return result;
483
623
  }
@@ -500,7 +640,7 @@ export async function runWithAutoResumeLoop<
500
640
  terminal,
501
641
  } as T;
502
642
  }
503
- if (!(await options.principalAuthority.isAvailable(options.admitted.principal))) {
643
+ if (!(await isPrincipalAvailable(options.admitted.principal))) {
504
644
  const terminal = dispatchExceptionFailureTerminal({
505
645
  role: options.admitted.role,
506
646
  runId: options.admitted.runId,
@@ -520,7 +660,13 @@ export async function runWithAutoResumeLoop<
520
660
  }
521
661
 
522
662
  autoResumeAttempts++;
523
- currentPayload = options.buildResumePayload();
663
+ // Resume payload only after a host turn actually started — either a
664
+ // returned result says so, or the attempt threw a TurnDispatchedFailure
665
+ // after genuinely starting the turn (#840 r9 判词 class 1). Pre-turn
666
+ // throws and beforeDispatch failures retry the initial payload (#840 / #416).
667
+ if (result?.turnDispatched === true || turnStartedBeforeThrow) {
668
+ currentPayload = options.buildResumePayload();
669
+ }
524
670
  isFirst = false;
525
671
  }
526
672
  }
@@ -44,14 +44,16 @@ import { CliUsageError } from "./cli-errors.ts";
44
44
  import type { CliIo } from "./cli-io.ts";
45
45
  import type { PostAdmissionEnv } from "./post-admission.ts";
46
46
  import type { RoleTurnHost } from "../host-contracts.ts";
47
- import { packagedExternalHostNames } from "../host-descriptions.ts";
48
- import { createLazyProductionExternalHost } from "./load-production-external-host.ts";
47
+ import { appendPiSessionCustomEntry } from "../pi/role-turn-host.ts";
49
48
  import {
50
- createPiRoleTurnHost,
51
- appendPiSessionCustomEntry,
52
- } from "../pi/role-turn-host.ts";
49
+ composeRoleTurnHostAdapters,
50
+ formatHostSelectionFailure,
51
+ HostSelectionError,
52
+ selectRoleTurnHost,
53
+ type HostSelectionFailure,
54
+ type NamedRoleTurnHostAdapter,
55
+ } from "./role-turn-host-resolution.ts";
53
56
  import {
54
- observeLaunchedRolePackageIdentity,
55
57
  parseAnalystArgv,
56
58
  parseCoderArgv,
57
59
  parseCollectorArgv,
@@ -68,8 +70,6 @@ import {
68
70
  parseAuditorArgv,
69
71
  parseNotaryArgv,
70
72
  parseReviewerArgv,
71
- recordLaunchedPiIdentity,
72
- recordLaunchedRolePackageIdentity,
73
73
  } from "./invocation.ts";
74
74
  import {
75
75
  createTypedOptionConsumer,
@@ -237,24 +237,7 @@ function takePublicGlobalFlag(
237
237
  return undefined;
238
238
  }
239
239
 
240
- export type HostSelectionFailure = {
241
- readonly kind: "host-unregistered" | "host-model-mismatch";
242
- readonly host: string;
243
- readonly seat: PublicCallableRole;
244
- readonly model: string;
245
- readonly registeredHosts: readonly string[];
246
- };
247
-
248
- export type NamedRoleTurnHostAdapter = {
249
- readonly name: string;
250
- readonly create: (input: { role: PublicCallableRole; model: EffectiveSeat["selection"] }) =>
251
- | { readonly ok: true; readonly host: RoleTurnHost }
252
- | { readonly ok: false };
253
- };
254
-
255
- class HostSelectionError extends Error {
256
- constructor(readonly failure: HostSelectionFailure) { super(failure.kind); }
257
- }
240
+ export type { HostSelectionFailure, NamedRoleTurnHostAdapter };
258
241
 
259
242
  export type CliEnv = {
260
243
  home?: string;
@@ -315,59 +298,7 @@ export type CliEnv = {
315
298
  freshSummons?: true;
316
299
  };
317
300
 
318
- /** Compose the Pi turn host for one role dispatch (sole public-cli → pi contact). */
319
- function resolveRoleTurnHost(
320
- env: CliEnv,
321
- options: {
322
- role: PublicCallableRole;
323
- seat: EffectiveSeat;
324
- principalAuthority: DurablePrincipalAuthority;
325
- extraPiArgs?: readonly string[];
326
- timeoutMs?: number;
327
- },
328
- ): RoleTurnHost {
329
- const piHost = env.roleTurnHost ?? createPiRoleTurnHost({
330
- packageRoot: env.packageRoot,
331
- principalAuthority: options.principalAuthority,
332
- ...(options.extraPiArgs === undefined ? {} : { extraPiArgs: options.extraPiArgs }),
333
- ...(options.timeoutMs === undefined ? {} : { timeoutMs: options.timeoutMs }),
334
- recordLaunchedPiIdentity,
335
- recordLaunchedRolePackageIdentity,
336
- observeLaunchedRolePackageIdentity,
337
- });
338
- // Composition-root adapter table: pi (in-process default) + one lazy external host per description-table key (#820).
339
- const adapters: readonly NamedRoleTurnHostAdapter[] = env.hostAdapters ?? [
340
- { name: "pi", create: () => ({ ok: true as const, host: piHost }) },
341
- ...packagedExternalHostNames().map((name) => ({
342
- name,
343
- create: () => ({
344
- ok: true as const,
345
- // Factory loads outside the public bin static graph (ADR 0052 peer-free discovery).
346
- host: createLazyProductionExternalHost({
347
- packageRoot: env.packageRoot,
348
- hostName: name,
349
- principalAuthority: options.principalAuthority,
350
- }),
351
- }),
352
- })),
353
- ];
354
- const hostName = options.seat.host;
355
- const adapter = adapters.find((candidate) => candidate.name === hostName);
356
- const model = options.seat.selection === undefined ? "unconfigured" : `${options.seat.selection.provider}/${options.seat.selection.model}`;
357
- const registeredHosts = adapters.map(({ name }) => name);
358
- if (adapter === undefined) {
359
- throw new HostSelectionError(
360
- { kind: "host-unregistered", host: hostName, seat: options.role, model, registeredHosts },
361
- );
362
- }
363
- const selected = adapter.create({ role: options.role, model: options.seat.selection });
364
- if (!selected.ok) {
365
- throw new HostSelectionError(
366
- { kind: "host-model-mismatch", host: hostName, seat: options.role, model, registeredHosts },
367
- );
368
- }
369
- return selected.host;
370
- }
301
+
371
302
 
372
303
  type RoleEnvironmentOptions = {
373
304
  role: PublicCallableRole;
@@ -426,12 +357,21 @@ function createRoleEnvironment(
426
357
  // #788 expectation 2: select a registered host first; only then project the
427
358
  // host-facing provider (table > unique directory > fail). Bad host never
428
359
  // reaches model resolution.
429
- const roleTurnHost = resolveRoleTurnHost(env, {
360
+ // Nested summons reuse this table and select by the child seat — never the
361
+ // already-selected parent adapter (#840 / ADR 0082 host-flag-two-channels).
362
+ const hostAdapters = composeRoleTurnHostAdapters(
363
+ {
364
+ packageRoot: env.packageRoot,
365
+ ...(env.roleTurnHost === undefined ? {} : { roleTurnHost: env.roleTurnHost }),
366
+ ...(env.hostAdapters === undefined ? {} : { hostAdapters: env.hostAdapters }),
367
+ ...(extraPiArgs === undefined ? {} : { extraPiArgs }),
368
+ ...(timeoutMs === undefined ? {} : { timeoutMs }),
369
+ },
370
+ env.principalAuthority!,
371
+ );
372
+ const roleTurnHost = selectRoleTurnHost(hostAdapters, {
430
373
  role,
431
374
  seat: options.seat,
432
- principalAuthority: env.principalAuthority!,
433
- ...(extraPiArgs === undefined ? {} : { extraPiArgs }),
434
- ...(timeoutMs === undefined ? {} : { timeoutMs }),
435
375
  });
436
376
  const hostFacingSelection =
437
377
  options.seat.selection === undefined
@@ -449,6 +389,7 @@ function createRoleEnvironment(
449
389
  sessionAppender: appendPiSessionCustomEntry,
450
390
  packageRoot: env.packageRoot,
451
391
  roleTurnHost,
392
+ hostAdapters,
452
393
  cwd: options.cwd,
453
394
  ...(options.credentials === undefined ? {} : { credentials: options.credentials }),
454
395
  ...(env.correlationId === undefined ? {} : { correlationId: env.correlationId }),
@@ -1606,8 +1547,7 @@ export async function runAkRole(
1606
1547
  throw new CliUsageError(`unknown command: ${parsed.command}`);
1607
1548
  } catch (error) {
1608
1549
  if (error instanceof HostSelectionError) {
1609
- const registered = error.failure.registeredHosts.join(", ");
1610
- io.stderr(formatCliDiagnostic(`${error.failure.kind}: ${error.failure.host}; registered: ${registered}`));
1550
+ io.stderr(formatCliDiagnostic(formatHostSelectionFailure(error.failure)));
1611
1551
  return { exitCode: 1, hostFailure: error.failure };
1612
1552
  }
1613
1553
  if (error instanceof CliUsageError) {
@@ -29,7 +29,6 @@ import {
29
29
  admitCountersignInvocation,
30
30
  bindAdmittedTicketNumber,
31
31
  buildCountersignTransportPrompt,
32
- parseDiaristArgv,
33
32
  type AdmittedCountersignInvocation,
34
33
  type ParseCountersignArgvResult,
35
34
  } from "./invocation.ts";
@@ -40,6 +39,7 @@ import {
40
39
  type PostAdmissionEnv,
41
40
  runPostAdmissionSeatResume,
42
41
  resumeTurnRequestProjectionOptions,
42
+ StationChildExhaustedError,
43
43
  } from "./post-admission.ts";
44
44
  import {
45
45
  loadResumableCountersignRun,
@@ -125,32 +125,24 @@ async function invokeCourtDiarist(input: {
125
125
  },
126
126
  };
127
127
 
128
- const { runPublicDiarist } = await import("./diarist-run.ts");
129
- const result = await runPublicDiarist(
130
- ["--project", input.projectRoot, input.instruction],
131
- {
132
- home: env.home,
133
- agentDir: env.agentDir,
134
- packageRoot: env.packageRoot,
135
- cwd: env.cwd,
136
- principalAuthority: env.principalAuthority,
137
- roleTurnHost: env.roleTurnHost,
138
- sessionAppender: env.sessionAppender,
139
- ...(env.model === undefined ? {} : { model: env.model }),
140
- ...(env.engine === undefined ? {} : { engine: env.engine }),
141
- ...(env.host === undefined ? {} : { host: env.host }),
142
- ...(env.timeoutMs === undefined ? {} : { timeoutMs: env.timeoutMs }),
143
- ...(env.credentials === undefined ? {} : { credentials: env.credentials }),
144
- ...(env.correlationId === undefined ? {} : { correlationId: env.correlationId }),
145
- ...(env.signal === undefined ? {} : { signal: env.signal }),
146
- // Typed handoff only — never derived from summons prose.
147
- ...(input.boundTicketNumber === undefined
148
- ? {}
149
- : { boundTicketNumber: input.boundTicketNumber }),
150
- },
151
- quietIo,
152
- parseDiaristArgv,
153
- );
128
+ const { summonPublicRole } = await import("../public-role-summons.ts");
129
+ const result = await summonPublicRole({
130
+ role: "diarist",
131
+ argv: ["--project", input.projectRoot, input.instruction],
132
+ cwd: env.cwd,
133
+ home: env.home,
134
+ agentDir: env.agentDir,
135
+ packageRoot: env.packageRoot,
136
+ io: quietIo,
137
+ ...(env.credentials === undefined ? {} : { credentials: env.credentials }),
138
+ ...(env.signal === undefined ? {} : { signal: env.signal }),
139
+ ...(input.boundTicketNumber === undefined
140
+ ? {}
141
+ : { boundTicketNumber: input.boundTicketNumber }),
142
+ // Child seat selects from the composition-root table. Do not pass the
143
+ // already-selected parent adapter (#840 / ADR 0082 host-flag-two-channels).
144
+ ...(env.hostAdapters === undefined ? {} : { hostAdapters: env.hostAdapters }),
145
+ });
154
146
 
155
147
  const roleOutcome = result.terminal?.roleOutcome;
156
148
  // Discriminated union: only non-failure arms carry status (failure uses cause).
@@ -168,7 +160,7 @@ async function invokeCourtDiarist(input: {
168
160
  const diagnostic =
169
161
  roleOutcome?.kind === "failure"
170
162
  ? roleOutcome.diagnostic
171
- : `exit ${result.exitCode}`;
163
+ : result.stderr?.trim() || `exit ${result.exitCode}`;
172
164
  return {
173
165
  identity: { kind: "unbound" },
174
166
  failedWithoutEscalate: {
@@ -177,7 +169,7 @@ async function invokeCourtDiarist(input: {
177
169
  };
178
170
  }
179
171
 
180
- const asserted = result.admitted?.ticketNumber;
172
+ const asserted = (result.admitted as { ticketNumber?: number } | undefined)?.ticketNumber;
181
173
  if (
182
174
  typeof asserted === "number" &&
183
175
  Number.isSafeInteger(asserted) &&
@@ -225,12 +217,12 @@ export async function runCountersignCourtDiaristStation(
225
217
  );
226
218
 
227
219
  if (outcome.identity.kind === "escalate") {
228
- throw new Error(
220
+ throw new StationChildExhaustedError(
229
221
  `court diarist station escalated (cannot identify court target): ${outcome.identity.reason}`,
230
222
  );
231
223
  }
232
224
  if (outcome.failedWithoutEscalate !== undefined) {
233
- throw new Error(outcome.failedWithoutEscalate.diagnostic);
225
+ throw new StationChildExhaustedError(outcome.failedWithoutEscalate.diagnostic);
234
226
  }
235
227
  }
236
228
 
@@ -282,8 +274,9 @@ export async function runPublicCountersign(
282
274
  // (never mechanical matching of summons text). Resolve that typed key after
283
275
  // admit so the countersign run page exists first; same-ticket re-summons
284
276
  // resume the prior run on the typed key (unused mint is abandoned). The test
285
- // seam `runCourtDiaristStation` defers identity to beforeDispatch and
286
- // therefore skips auto-resume under the seam alone.
277
+ // seam `runCourtDiaristStation` defers identity to beforeDispatch; generic
278
+ // hook failures stay on the parent call-local budget, exhausted nested
279
+ // station children still skip parent auto-resume (#840 父子不层叠).
287
280
  let typedTicket: number | undefined;
288
281
  let identityDiaristRan = false;
289
282
 
@@ -1141,7 +1141,7 @@ export type PublicCommandHelpFacts = {
1141
1141
  * the role list like every other role).
1142
1142
  */
1143
1143
  export const PUBLIC_NAVIGATOR_HELP_NOTE =
1144
- "Navigator also attends automatically on every run; that sidecar is unchanged by direct `ak-role navigator` calls." as const;
1144
+ "Navigator also attends automatically on top-level public entry legs; that sidecar is unchanged by direct `ak-role navigator` calls." as const;
1145
1145
 
1146
1146
  const TOP_LEVEL_HELP = {
1147
1147
  command: "top",