@akagilnc/pi-workflow-roles 0.1.3902 → 0.1.3932

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
@@ -22,6 +22,7 @@ import {
22
22
  freezeAttachmentsIntoRun,
23
23
  } from "./invocation.ts";
24
24
  import { pathContainedIn } from "../activation-ledger-topology.ts";
25
+ import { resolveHostAwareSessionAvailability } from "../session-identity.ts";
25
26
 
26
27
  import type {
27
28
  ControlledFailureCause,
@@ -56,9 +57,8 @@ import {
56
57
  acquireRunWriterLease,
57
58
  clearCurrentCourt,
58
59
  clearTypedProviderHttpObservation,
59
- markRunResumable,
60
+ describeErrorIdentity,
60
61
  markRunRunning,
61
- markRunTerminal,
62
62
  readCurrentCourt,
63
63
  recordCurrentCourt,
64
64
  renderResumeCommand,
@@ -70,6 +70,7 @@ import {
70
70
  } from "./run-lifecycle.ts";
71
71
  import { homeFromRunDirectory } from "../activation-ledger-topology.ts";
72
72
  import { readSealedSubmission } from "../submission-ledger.ts";
73
+ import { clearReviewerDispatchRejection } from "./reviewer-dispatch-rejection.ts";
73
74
  import {
74
75
  classifyPostAdmissionFailure,
75
76
  controlledFailureInputFromResolution,
@@ -88,10 +89,97 @@ import {
88
89
  } from "./settlement.ts";
89
90
  import type { CliIo } from "./cli-io.ts";
90
91
  import type { AdmittedRoleInvocation } from "./invocation.ts";
92
+ import type { NamedRoleTurnHostAdapter } from "./role-turn-host-resolution.ts";
91
93
  import {
92
94
  type TerminalResult,
93
95
  } from "./terminal.ts";
94
- import { runWithAutoResumeLoop } from "./auto-resume.ts";
96
+ import {
97
+ ensureRealArtifactsDirectory,
98
+ persistReturnedRunState,
99
+ runWithAutoResumeLoop,
100
+ TurnDispatchedFailure,
101
+ } from "./auto-resume.ts";
102
+
103
+ /**
104
+ * Nested station-child role already finished its own call-local loop.
105
+ * Parent records that failure once and must not auto-resume into a re-summon
106
+ * (#840 父子不层叠). Other beforeDispatch failures keep the shared #416 budget.
107
+ */
108
+ export class StationChildExhaustedError extends Error {
109
+ override readonly name = "StationChildExhaustedError";
110
+ }
111
+
112
+ function withOnceSuccessfulBeforeDispatch<
113
+ A extends AdmittedRoleInvocation,
114
+ T extends TerminalResult = TerminalResult,
115
+ >(adapters: PostAdmissionAdapters<A, T>): PostAdmissionAdapters<A, T> {
116
+ const hook = adapters.beforeDispatch;
117
+ if (hook === undefined) return adapters;
118
+ let succeeded = false;
119
+ return {
120
+ ...adapters,
121
+ beforeDispatch: async (admitted) => {
122
+ if (succeeded) return;
123
+ await hook(admitted);
124
+ succeeded = true;
125
+ },
126
+ };
127
+ }
128
+
129
+ /**
130
+ * Session custom-entry type for a best-effort post-dispatch cleanup
131
+ * diagnostic (#840 r9 判词 class 1). Every auto-resume attempt dispatches
132
+ * with dummyIo (src/public-cli/auto-resume.ts), so an io.stderr-only
133
+ * diagnostic never reaches a real caller — the dossier is the durable
134
+ * channel that does (失败诚实宪法 真因必须落痕).
135
+ */
136
+ export const POST_ADMISSION_CLEANUP_DIAGNOSTIC_ENTRY_TYPE =
137
+ "ak_post_admission_cleanup_diagnostic" as const;
138
+
139
+ /**
140
+ * Best-effort post-dispatch diagnostic that must still leave a real trace
141
+ * even though the attempt's own io may be dummyIo (#840 r9 判词 class 1).
142
+ * Single authoritative durable channel — a dossier custom entry via
143
+ * sessionAppender — not a standing duplicate. Only when that one write
144
+ * itself fails does this fall back to a plain run-artifacts file (reusing
145
+ * auto-resume.ts's existing dispatch-error retention helper — no new
146
+ * mechanism), so an unhealthy session never leaves this diagnostic with
147
+ * zero durable trace (#840 bounce class 2); a healthy session never gets a
148
+ * redundant second copy (#840 bounce class 1: 同一业务规则只保留一个权威实现).
149
+ */
150
+ async function recordBestEffortPostDispatchDiagnostic<A extends AdmittedRoleInvocation>(
151
+ admitted: A,
152
+ env: PostAdmissionEnv,
153
+ diagnostic: string,
154
+ io: CliIo,
155
+ ): Promise<void> {
156
+ io.stderr(formatCliDiagnostic(diagnostic));
157
+ const payload = { diagnostic, recordedAt: new Date().toISOString() };
158
+ try {
159
+ await env.sessionAppender(
160
+ env.principalAuthority,
161
+ admitted.principal,
162
+ POST_ADMISSION_CLEANUP_DIAGNOSTIC_ENTRY_TYPE,
163
+ payload,
164
+ );
165
+ return;
166
+ } catch (appendError) {
167
+ try {
168
+ const artifactsDir = await ensureRealArtifactsDirectory(admitted.runDirectory);
169
+ await writeFile(
170
+ join(artifactsDir, `post-admission-diagnostic-${randomUUID()}.json`),
171
+ `${JSON.stringify({ version: 1, ...payload }, null, 2)}\n`,
172
+ { encoding: "utf8", flag: "wx" },
173
+ );
174
+ } catch (artifactError) {
175
+ io.stderr(
176
+ formatCliDiagnostic(
177
+ `post-dispatch diagnostic durable retention failed on both channels (best-effort continue): dossier=${describeErrorIdentity(appendError)}; artifact=${describeErrorIdentity(artifactError)}`,
178
+ ),
179
+ );
180
+ }
181
+ }
182
+ }
95
183
 
96
184
  /** Previous main-session host recorded on invocation.json, if any. */
97
185
  async function readInvocationHost(runDirectory: string): Promise<string | undefined> {
@@ -113,6 +201,11 @@ export type PostAdmissionEnv = {
113
201
  cwd: string;
114
202
  correlationId?: string;
115
203
  roleTurnHost: RoleTurnHost;
204
+ /**
205
+ * Composition-root adapter table. Nested summons select by the child seat
206
+ * from this table — they must not inherit the already-selected parent host.
207
+ */
208
+ hostAdapters?: readonly NamedRoleTurnHostAdapter[];
116
209
  model?: SeatModelConfig;
117
210
  engine?: string;
118
211
  /** Effective main-session host for this run (#595 admission / #617 resume seat). */
@@ -134,6 +227,8 @@ export type PostAdmissionEnv = {
134
227
  * and mint a new run. Absent on ordinary role commands and on `ak-role resume`.
135
228
  */
136
229
  freshSummons?: true;
230
+ /** Station child role run (#840): omit automatic navigator attendance. */
231
+ stationChild?: boolean;
137
232
  };
138
233
 
139
234
  /**
@@ -243,6 +338,7 @@ export async function presentControlledFailure<
243
338
  adapters: PostAdmissionAdapters<A, T>,
244
339
  authority: DurablePrincipalAuthority,
245
340
  io: CliIo,
341
+ persistRunState = true,
246
342
  ): Promise<{
247
343
  exitCode: number;
248
344
  admitted: A;
@@ -303,10 +399,8 @@ export async function presentControlledFailure<
303
399
  resumable = sessionPrincipalAvailable && typedHttp429 !== undefined;
304
400
  }
305
401
 
306
- if (resumable && typedHttp429 !== undefined) {
307
- await markRunResumable(admitted.runDirectory, typedHttp429);
308
- } else {
309
- await markRunTerminal(admitted.runDirectory);
402
+ if (persistRunState) {
403
+ await persistReturnedRunState(admitted, authority);
310
404
  }
311
405
 
312
406
  const terminal = await settleFailureTerminalResult(
@@ -325,6 +419,42 @@ export async function presentControlledFailure<
325
419
  };
326
420
  }
327
421
 
422
+ /**
423
+ * presentControlledFailure, called only where the host turn has already
424
+ * genuinely started (#840 r9 判词 class 1 boundary). If presentControlledFailure
425
+ * itself fails, this never fabricates a replacement terminal (ADR 0080
426
+ * single-settlement-disposition — presentControlledFailure /
427
+ * settleFailureTerminalResult stays the one authority); it re-throws a
428
+ * TurnDispatchedFailure so runWithAutoResumeLoop still learns the turn
429
+ * started and selects a resume payload on the next attempt, while settling
430
+ * the true cause through its own existing dispatch-exception machinery once
431
+ * the retry budget is exhausted.
432
+ */
433
+ async function settleAfterTurnStarted<
434
+ A extends AdmittedRoleInvocation,
435
+ T extends TerminalResult = TerminalResult,
436
+ >(
437
+ admitted: A,
438
+ failureInput: ControlledFailureInput,
439
+ adapters: PostAdmissionAdapters<A, T>,
440
+ authority: DurablePrincipalAuthority,
441
+ io: CliIo,
442
+ persistRunState: boolean,
443
+ ): Promise<{ exitCode: number; admitted: A; terminal: T }> {
444
+ try {
445
+ return (await presentControlledFailure(
446
+ admitted,
447
+ failureInput,
448
+ adapters,
449
+ authority,
450
+ io,
451
+ persistRunState,
452
+ )) as { exitCode: number; admitted: A; terminal: T };
453
+ } catch (error) {
454
+ throw new TurnDispatchedFailure(error);
455
+ }
456
+ }
457
+
328
458
  export async function dispatchPostAdmissionTurn<
329
459
  A extends AdmittedRoleInvocation,
330
460
  T extends TerminalResult = TerminalResult,
@@ -336,12 +466,18 @@ export async function dispatchPostAdmissionTurn<
336
466
  lease: RunWriterLease;
337
467
  adapters: PostAdmissionAdapters<A, T>;
338
468
  effectiveEngine?: string;
469
+ persistRunState?: boolean;
339
470
  }): Promise<{
340
471
  exitCode: number;
341
472
  admitted: A;
342
473
  terminal?: T;
474
+ skipAutoResume?: true;
475
+ turnDispatched?: true;
476
+ needsPersist?: true;
343
477
  }> {
344
478
  const { admitted, env, io, request, lease, adapters, effectiveEngine } = input;
479
+ const persistRunState = input.persistRunState !== false;
480
+ const deferredPersist = persistRunState ? {} : { needsPersist: true as const };
345
481
  const shouldPresent =
346
482
  adapters.shouldPresentSettled ?? ((terminal: T) => isLawfulTypedTerminalOutcome(terminal.roleOutcome));
347
483
  try {
@@ -350,13 +486,17 @@ export async function dispatchPostAdmissionTurn<
350
486
  env.credentials,
351
487
  );
352
488
  if (missingCredential !== undefined) {
353
- return (await presentControlledFailure(
354
- admitted,
355
- missingCredential,
356
- adapters,
357
- env.principalAuthority,
358
- io,
359
- )) as { exitCode: number; admitted: A; terminal: T };
489
+ return {
490
+ ...(await presentControlledFailure(
491
+ admitted,
492
+ missingCredential,
493
+ adapters,
494
+ env.principalAuthority,
495
+ io,
496
+ persistRunState,
497
+ )) as { exitCode: number; admitted: A; terminal: T },
498
+ ...deferredPersist,
499
+ };
360
500
  }
361
501
  // #617 DK-4: capture previous invocation host before markRunRunning overwrites it.
362
502
  // Single authority projectHostTransitionPriorNative classifies the prior native volume.
@@ -380,29 +520,43 @@ export async function dispatchPostAdmissionTurn<
380
520
  : undefined;
381
521
  } catch (error) {
382
522
  // prior-native IO is on the public one-shot path — controlled failure, not bare throw.
383
- return (await presentControlledFailure(
384
- admitted,
385
- {
386
- timedOut: false,
387
- code: null,
388
- stderr: "",
389
- thrown: error,
390
- },
391
- adapters,
392
- env.principalAuthority,
393
- io,
394
- )) as { exitCode: number; admitted: A; terminal: T };
523
+ return {
524
+ ...(await presentControlledFailure(
525
+ admitted,
526
+ {
527
+ timedOut: false,
528
+ code: null,
529
+ stderr: "",
530
+ thrown: error,
531
+ },
532
+ adapters,
533
+ env.principalAuthority,
534
+ io,
535
+ persistRunState,
536
+ )) as { exitCode: number; admitted: A; terminal: T },
537
+ ...deferredPersist,
538
+ };
395
539
  }
396
540
 
397
- await markRunRunning(admitted.runDirectory, env.model, effectiveEngine, env.host);
398
541
  await clearTypedProviderHttpObservation(admitted.runDirectory);
399
- // beforeDispatch (seat-owned pre-turn work) runs after running is marked —
400
- // its failures must settle the run, not leave it permanently running.
542
+ // Per-attempt hygiene: stale Reviewer rejection pages must not ride into
543
+ // auto-resume. ENOENT-safe for every seat.
544
+ await clearReviewerDispatchRejection(admitted.runDirectory);
545
+ // The authoritative host write (markRunRunning) is delayed to just before
546
+ // executeTurn — not merely past beforeDispatch (#840 r9 判词 class 2). Any
547
+ // pre-turn retry (beforeDispatch, dossier projection, continuation
548
+ // assembly) must still see the prior invocation host on its next attempt;
549
+ // committing env.host earlier would make readInvocationHost read back the
550
+ // new host on the retry and silently drop hostTransition.
551
+ // Failures settle the run (presentControlledFailure); they must not leave it
552
+ // permanently running. Call-local auto-resume retries this hook until it
553
+ // succeeds; only an exhausted nested station child skips the parent loop
554
+ // (#840 父子不层叠).
401
555
  if (adapters.beforeDispatch !== undefined) {
402
556
  try {
403
557
  await adapters.beforeDispatch(admitted);
404
558
  } catch (error) {
405
- return (await presentControlledFailure(
559
+ const settled = (await presentControlledFailure(
406
560
  admitted,
407
561
  {
408
562
  timedOut: false,
@@ -413,7 +567,12 @@ export async function dispatchPostAdmissionTurn<
413
567
  adapters,
414
568
  env.principalAuthority,
415
569
  io,
570
+ persistRunState,
416
571
  )) as { exitCode: number; admitted: A; terminal: T };
572
+ if (error instanceof StationChildExhaustedError) {
573
+ return { ...settled, skipAutoResume: true as const, ...deferredPersist };
574
+ }
575
+ return { ...settled, ...deferredPersist };
417
576
  }
418
577
  }
419
578
 
@@ -425,6 +584,9 @@ export async function dispatchPostAdmissionTurn<
425
584
  // attachments and the seat's own prompt bytes are never rewritten.
426
585
  let turnRequest: RoleTurnRequest =
427
586
  env.signal === undefined ? request : { ...request, signal: env.signal };
587
+ if (env.stationChild !== undefined) {
588
+ turnRequest = { ...turnRequest, stationChild: env.stationChild };
589
+ }
428
590
  if (hostTransition !== undefined) {
429
591
  turnRequest = { ...turnRequest, hostTransition };
430
592
  }
@@ -443,11 +605,16 @@ export async function dispatchPostAdmissionTurn<
443
605
  };
444
606
  }
445
607
 
608
+ // Authoritative host write happens here, at the real dispatch boundary —
609
+ // immediately before the turn actually starts, after every retryable
610
+ // pre-turn step above has succeeded on this attempt (#840 r9 判词 class 2).
611
+ await markRunRunning(admitted.runDirectory, env.model, effectiveEngine, env.host);
612
+
446
613
  let result: RoleTurnResult;
447
614
  try {
448
615
  result = await env.roleTurnHost.executeTurn(turnRequest);
449
616
  } catch (error) {
450
- return (await presentControlledFailure(
617
+ const settled = await settleAfterTurnStarted(
451
618
  admitted,
452
619
  {
453
620
  timedOut: false,
@@ -458,17 +625,43 @@ export async function dispatchPostAdmissionTurn<
458
625
  adapters,
459
626
  env.principalAuthority,
460
627
  io,
461
- )) as { exitCode: number; admitted: A; terminal: T };
628
+ persistRunState,
629
+ );
630
+ return { ...settled, turnDispatched: true as const, ...deferredPersist };
462
631
  }
463
632
 
633
+ // Everything below runs after the host turn genuinely started (#840 r9
634
+ // 判词 class 1 boundary — 覆盖 executeTurn 已启动后至返回带 turnDispatched
635
+ // 结果前的全部异常). Each fallible step routes any failure through the
636
+ // single existing settlement authority (presentControlledFailure /
637
+ // settleFailureTerminalResult) rather than a second one (ADR 0080
638
+ // single-settlement-disposition) — never by fabricating a terminal here.
639
+ // A cleanup step that runs only after a real settlement (clearCurrentCourt)
640
+ // is protected by logging and keeping that already-obtained result, never
641
+ // by discarding it (#840 已交劳动只整理终局不重做). A failure inside the
642
+ // settlement authority itself (presentControlledFailure's own reads) goes
643
+ // through settleAfterTurnStarted: still no fabricated terminal, but the
644
+ // re-thrown TurnDispatchedFailure still tells runWithAutoResumeLoop the
645
+ // turn genuinely started, so the next retry sends a resume payload — the
646
+ // true cause settles through the loop's own existing dispatch-exception
647
+ // machinery once the retry budget is exhausted.
464
648
  try {
465
649
  await writeFile(
466
650
  join(admitted.runDirectory, "stderr.log"),
467
651
  result.stderr,
468
652
  "utf8",
469
653
  );
470
- } catch {
471
- // continue to lawful / controlled-failure settlement
654
+ } catch (error) {
655
+ // Best-effort: the turn's own stderr capture is secondary to lawful /
656
+ // controlled-failure settlement below, but the failure itself must
657
+ // still leave a real trace (失败诚实宪法 真因必须落痕) — durably, since
658
+ // every auto-resume attempt's io is dummyIo (#840 r9 判词 class 1).
659
+ await recordBestEffortPostDispatchDiagnostic(
660
+ admitted,
661
+ env,
662
+ `stderr.log write failed (best-effort continue): ${describeErrorIdentity(error)}`,
663
+ io,
664
+ );
472
665
  }
473
666
 
474
667
  let settled: T | undefined;
@@ -478,11 +671,55 @@ export async function dispatchPostAdmissionTurn<
478
671
  request.courtAttemptId === undefined || request.courtAttemptId.length === 0
479
672
  ? undefined
480
673
  : { courtAttemptId: request.courtAttemptId };
674
+ // Single complete boundary (#840 r9 判词 class 1): trySettle, its
675
+ // shouldPresent gate, and the accepted-settlement cleanup all settle
676
+ // through this one catch. shouldPresent used to sit outside every
677
+ // TurnDispatchedFailure wrap, so a throw from it looked like an ordinary
678
+ // pre-turn throw to the caller (turnStartedBeforeThrow stayed false) and
679
+ // replayed the initial payload even though the host turn had already
680
+ // genuinely run.
681
+ let settledOutcome:
682
+ | { exitCode: number; admitted: A; terminal: T; turnDispatched: true }
683
+ | undefined;
481
684
  try {
482
685
  settled = await adapters.trySettle(admitted, env.principalAuthority, courtScope);
686
+ if (settled !== undefined && shouldPresent(settled)) {
687
+ // This court sealed — drop open-court pointer (bare resume no longer continues it).
688
+ if (
689
+ settled.roleOutcome.kind === "accepted" &&
690
+ request.courtAttemptId !== undefined &&
691
+ request.courtAttemptId.length > 0
692
+ ) {
693
+ try {
694
+ await clearCurrentCourt(admitted.runDirectory, request.courtAttemptId);
695
+ } catch (error) {
696
+ // Settlement already sealed accepted — a cleanup failure here must
697
+ // not erase that fact or make the caller replay this court's
698
+ // summons over already-delivered work (#840 已交劳动只整理终局不重做).
699
+ // A later bare resume self-heals: buildRequestAfterLease finds the
700
+ // open court already sealed and clears it then (documented
701
+ // continue-under-failure contract, not a swallow — 失败诚实宪法 真因
702
+ // 必须落痕) — durably, since every auto-resume attempt's io here is
703
+ // dummyIo (#840 r9 判词 class 1).
704
+ await recordBestEffortPostDispatchDiagnostic(
705
+ admitted,
706
+ env,
707
+ `current-court cleanup failed after accepted settlement (best-effort continue, self-heals on next resume): ${describeErrorIdentity(error)}`,
708
+ io,
709
+ );
710
+ }
711
+ }
712
+ settledOutcome = {
713
+ exitCode: exitCodeForTerminalOutcome(settled.roleOutcome),
714
+ admitted,
715
+ terminal: settled,
716
+ turnDispatched: true as const,
717
+ };
718
+ }
483
719
  } catch (error) {
484
- // Settle throw is a real failure fact — never swallow into undefined.
485
- return (await presentControlledFailure(
720
+ // Settle (or its shouldPresent gate) throw is a real failure fact —
721
+ // never swallow into undefined.
722
+ const settledFailure = await settleAfterTurnStarted(
486
723
  admitted,
487
724
  {
488
725
  timedOut: false,
@@ -493,59 +730,81 @@ export async function dispatchPostAdmissionTurn<
493
730
  adapters,
494
731
  env.principalAuthority,
495
732
  io,
496
- )) as { exitCode: number; admitted: A; terminal: T };
733
+ persistRunState,
734
+ );
735
+ return { ...settledFailure, turnDispatched: true as const, ...deferredPersist };
497
736
  }
498
- if (settled !== undefined && shouldPresent(settled)) {
499
- // This court sealed — drop open-court pointer (bare resume no longer continues it).
500
- if (
501
- settled.roleOutcome.kind === "accepted" &&
502
- request.courtAttemptId !== undefined &&
503
- request.courtAttemptId.length > 0
504
- ) {
505
- await clearCurrentCourt(admitted.runDirectory, request.courtAttemptId);
506
- }
507
- await markRunTerminal(admitted.runDirectory);
508
- io.stdout(formatTerminalResult(settled));
509
- return {
510
- exitCode: exitCodeForTerminalOutcome(settled.roleOutcome),
511
- admitted,
512
- terminal: settled,
513
- };
737
+ if (settledOutcome !== undefined) {
738
+ // Lawful persist + present is the caller's stop seam (auto-resume loop /
739
+ // manual resume), not this retried host-turn function.
740
+ return { ...settledOutcome, ...deferredPersist };
514
741
  }
515
742
 
516
- const sessionFile =
517
- admitted.principal !== undefined
518
- ? env.principalAuthority.decode(admitted.principal).sessionFile
519
- : "";
520
- const runnerKnownFailure =
521
- adapters.resolveRunnerKnownFailure !== undefined && sessionFile !== ""
522
- ? await adapters.resolveRunnerKnownFailure({ result, sessionFile })
523
- : result.knownFailure;
524
- const credentialFailure = postRunMissingCredentialFailure(
525
- result,
526
- env.model,
527
- env.credentials,
528
- );
529
- const resolution = await resolveAuditedRunnerFailureResolution({
530
- runner: runnerKnownFailure,
531
- sessionFile,
532
- credential: credentialFailure,
533
- runDirectory: admitted.runDirectory,
534
- });
535
- return (await presentControlledFailure(
536
- admitted,
537
- {
743
+ // Any exception while resolving the failure facts below — including the
744
+ // session-file decode itself — still happened after the host turn
745
+ // genuinely started (#840 r9 判词 class 1). Fold it into the same single
746
+ // controlled-failure settlement instead of losing turnDispatched to an
747
+ // uncaught throw.
748
+ let failureInput: ControlledFailureInput;
749
+ try {
750
+ const sessionFile =
751
+ admitted.principal !== undefined
752
+ ? env.principalAuthority.decode(admitted.principal).sessionFile
753
+ : "";
754
+ const runnerKnownFailure =
755
+ adapters.resolveRunnerKnownFailure !== undefined && sessionFile !== ""
756
+ ? await adapters.resolveRunnerKnownFailure({ result, sessionFile })
757
+ : result.knownFailure;
758
+ const credentialFailure = postRunMissingCredentialFailure(
759
+ result,
760
+ env.model,
761
+ env.credentials,
762
+ );
763
+ const resolution = await resolveAuditedRunnerFailureResolution({
764
+ runner: runnerKnownFailure,
765
+ sessionFile,
766
+ credential: credentialFailure,
767
+ runDirectory: admitted.runDirectory,
768
+ });
769
+ failureInput = {
538
770
  timedOut: result.timedOut,
539
771
  code: result.code,
540
772
  stderr: result.stderr,
541
773
  ...controlledFailureInputFromResolution(resolution),
542
- },
774
+ };
775
+ } catch (error) {
776
+ failureInput = {
777
+ timedOut: false,
778
+ code: result.code,
779
+ stderr: result.stderr,
780
+ thrown: error,
781
+ };
782
+ }
783
+ const failed = await settleAfterTurnStarted(
784
+ admitted,
785
+ failureInput,
543
786
  adapters,
544
787
  env.principalAuthority,
545
788
  io,
546
- )) as { exitCode: number; admitted: A; terminal: T };
789
+ persistRunState,
790
+ );
791
+ return { ...failed, turnDispatched: true as const, ...deferredPersist };
547
792
  } finally {
548
- await lease.release();
793
+ try {
794
+ await lease.release();
795
+ } catch (error) {
796
+ // lease.release() is documented best-effort and never rejects in the
797
+ // production acquireRunWriterLease implementation (createWriterLease
798
+ // reports cleanup failures via callback, never throws) — this guard is
799
+ // structural only: an uncaught throw from a finally block silently
800
+ // replaces whatever the try already returned, including a properly
801
+ // tagged turnDispatched:true result (#840 r9 判词 class 1 boundary).
802
+ io.stderr(
803
+ formatCliDiagnostic(
804
+ `writer lease release failed unexpectedly (best-effort continue): ${describeErrorIdentity(error)}`,
805
+ ),
806
+ );
807
+ }
549
808
  }
550
809
  }
551
810
 
@@ -623,9 +882,32 @@ export function resumeTurnRequestProjectionOptions(
623
882
  prompt,
624
883
  },
625
884
  ...(request.message === undefined ? {} : { courtAttemptId: randomUUID() }),
885
+ ...(env.stationChild === undefined ? {} : { stationChild: env.stationChild }),
626
886
  };
627
887
  }
628
888
 
889
+ /**
890
+ * Hold the writer lease through after-lease build, then hand off to dispatch.
891
+ * Builder (or any throw before dispatch) must release here — dispatch's finally
892
+ * only runs after this handoff (manual resume and station-child auto-resume).
893
+ */
894
+ async function dispatchAfterWriterLease<T>(input: {
895
+ lease: RunWriterLease;
896
+ build: () => Promise<RoleTurnRequest>;
897
+ dispatch: (request: RoleTurnRequest) => Promise<T>;
898
+ }): Promise<T> {
899
+ let handedOffToDispatch = false;
900
+ try {
901
+ const request = await input.build();
902
+ handedOffToDispatch = true;
903
+ return await input.dispatch(request);
904
+ } finally {
905
+ if (!handedOffToDispatch) {
906
+ await input.lease.release();
907
+ }
908
+ }
909
+ }
910
+
629
911
  function isAlreadyFrozenSummonsAttachment(
630
912
  runDirectory: string,
631
913
  attachmentPath: string,
@@ -677,7 +959,8 @@ export async function prepareSummonsResumeMaterials(
677
959
  * Shared manual-resume orchestration for seats whose continuation is the
678
960
  * package resume envelope (#599 / #633): load once → structural rejection →
679
961
  * optional seat afterAdmittedLoad (method material / controlled failure) →
680
- * seat turn projection → runPostAdmissionManualResume. Seat-owned loader
962
+ * seat turn projection → station-child auto-resume or public manual resume.
963
+ * Seat-owned loader
681
964
  * validation, turn builder, and adapters stay on the seat.
682
965
  *
683
966
  * Court open/recovery transaction (#637): under the existing writer lease,
@@ -739,17 +1022,7 @@ export async function runPostAdmissionSeatResume<
739
1022
  adapters = prepared.adapters;
740
1023
  }
741
1024
 
742
- // Court recovery / open under lease, then always dispatch (resume is pass-through).
743
- try {
744
- return await runPostAdmissionManualResume({
745
- admitted: loaded.admitted,
746
- env: input.env,
747
- io: input.io,
748
- adapters,
749
- ...(input.effectiveEngine === undefined
750
- ? {}
751
- : { effectiveEngine: input.effectiveEngine }),
752
- buildRequestAfterLease: async () => {
1025
+ const buildRequestAfterLease = async (): Promise<RoleTurnRequest> => {
753
1026
  let openCourtAttemptId: string | undefined;
754
1027
  // Build uses the admitted judged under this lease (rehydrated when open
755
1028
  // court materials ride). Settlement identity stays on the outer admitted.
@@ -843,8 +1116,88 @@ export async function runPostAdmissionSeatResume<
843
1116
  await recordCurrentCourt(admittedForBuild.runDirectory, court);
844
1117
  }
845
1118
  }
846
- return turnRequest;
847
- },
1119
+ return turnRequest;
1120
+ };
1121
+
1122
+ // Court recovery / open under lease, then dispatch.
1123
+ // Station-child same-ticket/same-parent resume is call-local auto-resume
1124
+ // (#840 / #416). Public `ak-role resume` stays one-shot (ADR 0080).
1125
+ try {
1126
+ if (input.env.stationChild === true) {
1127
+ let firstTurn: RoleTurnRequest | undefined;
1128
+ const stationAdapters = withOnceSuccessfulBeforeDispatch(adapters);
1129
+ type StationChildAttempt = { readonly resumeTurn: boolean };
1130
+ return await runWithAutoResumeLoop({
1131
+ admitted: loaded.admitted,
1132
+ principalAuthority: input.env.principalAuthority,
1133
+ isPrincipalAvailable: resolveHostAwareSessionAvailability(
1134
+ input.env.host,
1135
+ input.env.principalAuthority,
1136
+ ),
1137
+ io: input.io,
1138
+ sessionAppender: input.env.sessionAppender,
1139
+ autoResumeLimit: input.env.autoResumeLimit,
1140
+ buildInitialPayload: (): StationChildAttempt => ({ resumeTurn: false }),
1141
+ buildResumePayload: (): StationChildAttempt => ({ resumeTurn: true }),
1142
+ // Same as public manual resume: prior-court sealed acceptance is not a
1143
+ // redispatch brake (#833). New-court station-child turns still auto-resume.
1144
+ dispatch: async (payload, lease, _isFirst, attemptIo) =>
1145
+ dispatchAfterWriterLease({
1146
+ lease,
1147
+ build: async () => {
1148
+ // #840 r8 判词 class 2: this call-local retry must keep this
1149
+ // court's frozen summons / 交卷 body / attachments verbatim
1150
+ // (same object as firstTurn) and project only the minimal
1151
+ // host-needed resume trigger — never the manual-resume engine
1152
+ // handbook (buildResumeContinuationPrompt), which would replace
1153
+ // a 审核循环 same-ticket continuation with a bare outsourcing
1154
+ // 「重新读」 envelope (#755 contract, resumeTurnRequestProjectionOptions
1155
+ // above). RESUME_TRANSPORT_ENVELOPE is the same package-owned,
1156
+ // non-semantic trigger that projection already uses for a
1157
+ // same-ticket summons carrying no instruction/attachments.
1158
+ if (payload.resumeTurn && firstTurn !== undefined) {
1159
+ return {
1160
+ ...firstTurn,
1161
+ continuation: {
1162
+ kind: "resume",
1163
+ prompt: RESUME_TRANSPORT_ENVELOPE,
1164
+ },
1165
+ };
1166
+ }
1167
+ const turnRequest = await buildRequestAfterLease();
1168
+ firstTurn = turnRequest;
1169
+ return turnRequest;
1170
+ },
1171
+ dispatch: (turnRequest) =>
1172
+ dispatchPostAdmissionTurn({
1173
+ admitted: loaded.admitted,
1174
+ env: {
1175
+ ...input.env,
1176
+ ...(loaded.admitted.correlationId === undefined
1177
+ ? {}
1178
+ : { correlationId: loaded.admitted.correlationId }),
1179
+ },
1180
+ io: attemptIo,
1181
+ request: turnRequest,
1182
+ lease,
1183
+ adapters: stationAdapters,
1184
+ persistRunState: false,
1185
+ ...(input.effectiveEngine === undefined
1186
+ ? {}
1187
+ : { effectiveEngine: input.effectiveEngine }),
1188
+ }),
1189
+ }),
1190
+ });
1191
+ }
1192
+ return await runPostAdmissionManualResume({
1193
+ admitted: loaded.admitted,
1194
+ env: input.env,
1195
+ io: input.io,
1196
+ adapters,
1197
+ ...(input.effectiveEngine === undefined
1198
+ ? {}
1199
+ : { effectiveEngine: input.effectiveEngine }),
1200
+ buildRequestAfterLease,
848
1201
  });
849
1202
  } catch (error) {
850
1203
  // Open-court rehydrate load under lease may still surface seat structural
@@ -858,7 +1211,8 @@ export async function runPostAdmissionSeatResume<
858
1211
  }
859
1212
 
860
1213
  /**
861
- * Shared post-admission one-shot path: writer lease, then turn dispatch.
1214
+ * Shared post-admission one-shot path: folds into runPostAdmissionResumable (#840 / #416).
1215
+ * All callable roles share the single auto-resume loop.
862
1216
  * Initial facades own the durable admitted mark (markRunAdmitted) before
863
1217
  * entering; manual resume never re-admits.
864
1218
  */
@@ -877,29 +1231,24 @@ export async function runPostAdmissionOneShot<
877
1231
  admitted?: A;
878
1232
  terminal?: T;
879
1233
  }> {
880
- const { admitted, env, io, request, adapters, effectiveEngine } = input;
881
-
882
- let lease: RunWriterLease;
883
- try {
884
- lease = await acquireRunWriterLease(admitted.runDirectory, (diagnostic) =>
885
- io.stderr(diagnostic),
886
- );
887
- } catch (error) {
888
- if (error instanceof RunWriterLeaseHeldError) {
889
- presentStructuralRejection(error, io);
890
- return { exitCode: 2, admitted };
891
- }
892
- throw error;
893
- }
894
-
895
- return await dispatchPostAdmissionTurn({
896
- admitted,
897
- env,
898
- io,
899
- request,
900
- lease,
901
- adapters,
902
- ...(effectiveEngine === undefined ? {} : { effectiveEngine }),
1234
+ const engine = input.effectiveEngine ?? input.env.engine;
1235
+ return await runPostAdmissionResumable({
1236
+ admitted: input.admitted,
1237
+ env: input.env,
1238
+ io: input.io,
1239
+ buildInitialRequest: () => input.request,
1240
+ buildResumeRequest: () => ({
1241
+ ...input.request,
1242
+ continuation: {
1243
+ kind: "resume",
1244
+ prompt: buildResumeContinuationPrompt({
1245
+ packageRoot: input.env.packageRoot,
1246
+ ...(engine === undefined ? {} : { engine }),
1247
+ }),
1248
+ },
1249
+ }),
1250
+ adapters: input.adapters,
1251
+ ...(input.effectiveEngine === undefined ? {} : { effectiveEngine: input.effectiveEngine }),
903
1252
  });
904
1253
  }
905
1254
 
@@ -923,11 +1272,13 @@ export async function runPostAdmissionResumable<
923
1272
  admitted?: A;
924
1273
  terminal?: T;
925
1274
  }> {
926
- const { admitted, env, io, buildInitialRequest, buildResumeRequest, adapters, effectiveEngine } = input;
1275
+ const { admitted, env, io, buildInitialRequest, buildResumeRequest, effectiveEngine } = input;
1276
+ const adapters = withOnceSuccessfulBeforeDispatch(input.adapters);
927
1277
 
928
1278
  return runWithAutoResumeLoop({
929
1279
  admitted,
930
1280
  principalAuthority: env.principalAuthority,
1281
+ isPrincipalAvailable: resolveHostAwareSessionAvailability(env.host, env.principalAuthority),
931
1282
  io,
932
1283
  sessionAppender: env.sessionAppender,
933
1284
  autoResumeLimit: env.autoResumeLimit,
@@ -946,6 +1297,7 @@ export async function runPostAdmissionResumable<
946
1297
  request,
947
1298
  lease,
948
1299
  adapters,
1300
+ persistRunState: false,
949
1301
  // #600: every attempt (initial + auto-resume) writes seat engine when present.
950
1302
  ...(effectiveEngine === undefined ? {} : { effectiveEngine }),
951
1303
  }),
@@ -956,6 +1308,7 @@ export async function runPostAdmissionResumable<
956
1308
  * Manual resume: lease + dispatch. Pass-through to the host — no sealed-accepted
957
1309
  * short-circuit (#833 / #416). Court open (summons / message / open court) is
958
1310
  * built under lease when using buildRequestAfterLease; sole-final stays per-attempt.
1311
+ * After-lease build shares dispatchAfterWriterLease with station-child auto-resume.
959
1312
  */
960
1313
  export async function runPostAdmissionManualResume<
961
1314
  A extends AdmittedRoleInvocation,
@@ -1015,48 +1368,50 @@ export async function runPostAdmissionManualResume<
1015
1368
  throw error;
1016
1369
  }
1017
1370
 
1018
- // Court open/recovery under held lease until dispatch owns release (finally
1019
- // below). Builder and any throw on this seam must release here — dispatch's
1020
- // finally only runs after handoff.
1021
- let handedOffToDispatch = false;
1022
- try {
1023
- if (request === undefined) {
1024
- if (buildRequestAfterLease === undefined) {
1025
- throw new Error(
1026
- "runPostAdmissionManualResume requires request or buildRequestAfterLease",
1027
- );
1371
+ const result = await dispatchAfterWriterLease({
1372
+ lease,
1373
+ build: async () => {
1374
+ if (request === undefined) {
1375
+ if (buildRequestAfterLease === undefined) {
1376
+ throw new Error(
1377
+ "runPostAdmissionManualResume requires request or buildRequestAfterLease",
1378
+ );
1379
+ }
1380
+ request = await buildRequestAfterLease();
1028
1381
  }
1029
- request = await buildRequestAfterLease();
1030
- }
1031
-
1032
- handedOffToDispatch = true;
1033
- const result = await dispatchPostAdmissionTurn({
1034
- admitted,
1035
- env: {
1036
- ...env,
1037
- ...(effectiveModel === undefined ? {} : { model: effectiveModel }),
1038
- ...(admitted.correlationId === undefined
1039
- ? {}
1040
- : { correlationId: admitted.correlationId }),
1041
- },
1042
- io,
1043
- request,
1044
- lease,
1045
- adapters,
1046
- ...(effectiveEngine === undefined ? {} : { effectiveEngine }),
1047
- });
1048
- if (result.terminal !== undefined) {
1049
- (result.terminal as { autoResumeCount?: number }).autoResumeCount = 0;
1050
- }
1051
- return {
1052
- ...result,
1053
- ...(staleWriterLeaseReclaimed === true
1054
- ? { staleWriterLeaseReclaimed: true as const }
1055
- : {}),
1056
- };
1057
- } finally {
1058
- if (!handedOffToDispatch) {
1059
- await lease.release();
1060
- }
1382
+ return request;
1383
+ },
1384
+ dispatch: (turnRequest) =>
1385
+ dispatchPostAdmissionTurn({
1386
+ admitted,
1387
+ env: {
1388
+ ...env,
1389
+ ...(effectiveModel === undefined ? {} : { model: effectiveModel }),
1390
+ ...(admitted.correlationId === undefined
1391
+ ? {}
1392
+ : { correlationId: admitted.correlationId }),
1393
+ },
1394
+ io,
1395
+ request: turnRequest,
1396
+ lease,
1397
+ adapters,
1398
+ ...(effectiveEngine === undefined ? {} : { effectiveEngine }),
1399
+ }),
1400
+ });
1401
+ if (
1402
+ result.terminal !== undefined &&
1403
+ isLawfulTypedTerminalOutcome(result.terminal.roleOutcome)
1404
+ ) {
1405
+ await persistReturnedRunState(admitted, env.principalAuthority, { lawful: true });
1406
+ io.stdout(formatTerminalResult(result.terminal));
1407
+ }
1408
+ if (result.terminal !== undefined) {
1409
+ (result.terminal as { autoResumeCount?: number }).autoResumeCount = 0;
1061
1410
  }
1411
+ return {
1412
+ ...result,
1413
+ ...(staleWriterLeaseReclaimed === true
1414
+ ? { staleWriterLeaseReclaimed: true as const }
1415
+ : {}),
1416
+ };
1062
1417
  }