@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
@@ -12,6 +12,7 @@ import { buildResumeContinuationPrompt, RESUME_TRANSPORT_ENVELOPE, } from "./run
12
12
  import { CliUsageError } from "./cli-errors.js";
13
13
  import { buildInstructionTransportPrompt, freezeAttachmentsIntoRun, } from "./invocation.js";
14
14
  import { pathContainedIn } from "../activation-ledger-topology.js";
15
+ import { resolveHostAwareSessionAvailability } from "../session-identity.js";
15
16
  import { projectCaseDossierPointerSection } from "./case-dossier-delivery.js";
16
17
  /** Append one system section to a continuation prompt, keeping its kind. */
17
18
  function appendContinuationSection(continuation, section) {
@@ -22,12 +23,72 @@ function appendContinuationSection(continuation, section) {
22
23
  }
23
24
  import { projectHostTransitionPriorNative } from "../host-transition-prior-native.js";
24
25
  import { missingCredentialPreDispatchFailure, postRunMissingCredentialFailure, } from "./public-run-credentials.js";
25
- import { acquireRunWriterLease, clearCurrentCourt, clearTypedProviderHttpObservation, markRunResumable, markRunRunning, markRunTerminal, readCurrentCourt, recordCurrentCourt, renderResumeCommand, RunWriterLeaseHeldError, } from "./run-lifecycle.js";
26
+ import { acquireRunWriterLease, clearCurrentCourt, clearTypedProviderHttpObservation, describeErrorIdentity, markRunRunning, readCurrentCourt, recordCurrentCourt, renderResumeCommand, RunWriterLeaseHeldError, } from "./run-lifecycle.js";
26
27
  import { homeFromRunDirectory } from "../activation-ledger-topology.js";
27
28
  import { readSealedSubmission } from "../submission-ledger.js";
29
+ import { clearReviewerDispatchRejection } from "./reviewer-dispatch-rejection.js";
28
30
  import { classifyPostAdmissionFailure, controlledFailureInputFromResolution, exitCodeForTerminalOutcome, explicitInternalKnownFailureClassificationInput, formatCliDiagnostic, formatTerminalResult, inspectJudgeSession, isLawfulTypedTerminalOutcome, presentFailureTerminal, presentStructuralRejection, resolveAuditedRunnerFailureResolution, resolveControlledFailureResumeObservation, settleFailureTerminalResult, sealedAcceptanceRedispatchDisposition, } from "./settlement.js";
29
31
  import {} from "./terminal.js";
30
- import { runWithAutoResumeLoop } from "./auto-resume.js";
32
+ import { ensureRealArtifactsDirectory, persistReturnedRunState, runWithAutoResumeLoop, TurnDispatchedFailure, } from "./auto-resume.js";
33
+ /**
34
+ * Nested station-child role already finished its own call-local loop.
35
+ * Parent records that failure once and must not auto-resume into a re-summon
36
+ * (#840 父子不层叠). Other beforeDispatch failures keep the shared #416 budget.
37
+ */
38
+ export class StationChildExhaustedError extends Error {
39
+ name = "StationChildExhaustedError";
40
+ }
41
+ function withOnceSuccessfulBeforeDispatch(adapters) {
42
+ const hook = adapters.beforeDispatch;
43
+ if (hook === undefined)
44
+ return adapters;
45
+ let succeeded = false;
46
+ return {
47
+ ...adapters,
48
+ beforeDispatch: async (admitted) => {
49
+ if (succeeded)
50
+ return;
51
+ await hook(admitted);
52
+ succeeded = true;
53
+ },
54
+ };
55
+ }
56
+ /**
57
+ * Session custom-entry type for a best-effort post-dispatch cleanup
58
+ * diagnostic (#840 r9 判词 class 1). Every auto-resume attempt dispatches
59
+ * with dummyIo (src/public-cli/auto-resume.ts), so an io.stderr-only
60
+ * diagnostic never reaches a real caller — the dossier is the durable
61
+ * channel that does (失败诚实宪法 真因必须落痕).
62
+ */
63
+ export const POST_ADMISSION_CLEANUP_DIAGNOSTIC_ENTRY_TYPE = "ak_post_admission_cleanup_diagnostic";
64
+ /**
65
+ * Best-effort post-dispatch diagnostic that must still leave a real trace
66
+ * even though the attempt's own io may be dummyIo (#840 r9 判词 class 1).
67
+ * Single authoritative durable channel — a dossier custom entry via
68
+ * sessionAppender — not a standing duplicate. Only when that one write
69
+ * itself fails does this fall back to a plain run-artifacts file (reusing
70
+ * auto-resume.ts's existing dispatch-error retention helper — no new
71
+ * mechanism), so an unhealthy session never leaves this diagnostic with
72
+ * zero durable trace (#840 bounce class 2); a healthy session never gets a
73
+ * redundant second copy (#840 bounce class 1: 同一业务规则只保留一个权威实现).
74
+ */
75
+ async function recordBestEffortPostDispatchDiagnostic(admitted, env, diagnostic, io) {
76
+ io.stderr(formatCliDiagnostic(diagnostic));
77
+ const payload = { diagnostic, recordedAt: new Date().toISOString() };
78
+ try {
79
+ await env.sessionAppender(env.principalAuthority, admitted.principal, POST_ADMISSION_CLEANUP_DIAGNOSTIC_ENTRY_TYPE, payload);
80
+ return;
81
+ }
82
+ catch (appendError) {
83
+ try {
84
+ const artifactsDir = await ensureRealArtifactsDirectory(admitted.runDirectory);
85
+ await writeFile(join(artifactsDir, `post-admission-diagnostic-${randomUUID()}.json`), `${JSON.stringify({ version: 1, ...payload }, null, 2)}\n`, { encoding: "utf8", flag: "wx" });
86
+ }
87
+ catch (artifactError) {
88
+ io.stderr(formatCliDiagnostic(`post-dispatch diagnostic durable retention failed on both channels (best-effort continue): dossier=${describeErrorIdentity(appendError)}; artifact=${describeErrorIdentity(artifactError)}`));
89
+ }
90
+ }
91
+ }
31
92
  /** Previous main-session host recorded on invocation.json, if any. */
32
93
  async function readInvocationHost(runDirectory) {
33
94
  try {
@@ -64,7 +125,7 @@ export async function resolveResumeMethodMaterialAdapters(input) {
64
125
  return { kind: "terminal", ...terminal };
65
126
  }
66
127
  }
67
- export async function presentControlledFailure(admitted, failureInput, adapters, authority, io) {
128
+ export async function presentControlledFailure(admitted, failureInput, adapters, authority, io, persistRunState = true) {
68
129
  const hasThrown = Object.hasOwn(failureInput, "thrown");
69
130
  const resumeObservation = await resolveControlledFailureResumeObservation({
70
131
  runDirectory: admitted.runDirectory,
@@ -115,11 +176,8 @@ export async function presentControlledFailure(admitted, failureInput, adapters,
115
176
  const sessionPrincipalAvailable = await authority.isAvailable(admitted.principal);
116
177
  resumable = sessionPrincipalAvailable && typedHttp429 !== undefined;
117
178
  }
118
- if (resumable && typedHttp429 !== undefined) {
119
- await markRunResumable(admitted.runDirectory, typedHttp429);
120
- }
121
- else {
122
- await markRunTerminal(admitted.runDirectory);
179
+ if (persistRunState) {
180
+ await persistReturnedRunState(admitted, authority);
123
181
  }
124
182
  const terminal = await settleFailureTerminalResult(admitted, failure, authority, resumable
125
183
  ? { resume: { command: renderResumeCommand(admitted.runId) } }
@@ -131,13 +189,37 @@ export async function presentControlledFailure(admitted, failureInput, adapters,
131
189
  terminal,
132
190
  };
133
191
  }
192
+ /**
193
+ * presentControlledFailure, called only where the host turn has already
194
+ * genuinely started (#840 r9 判词 class 1 boundary). If presentControlledFailure
195
+ * itself fails, this never fabricates a replacement terminal (ADR 0080
196
+ * single-settlement-disposition — presentControlledFailure /
197
+ * settleFailureTerminalResult stays the one authority); it re-throws a
198
+ * TurnDispatchedFailure so runWithAutoResumeLoop still learns the turn
199
+ * started and selects a resume payload on the next attempt, while settling
200
+ * the true cause through its own existing dispatch-exception machinery once
201
+ * the retry budget is exhausted.
202
+ */
203
+ async function settleAfterTurnStarted(admitted, failureInput, adapters, authority, io, persistRunState) {
204
+ try {
205
+ return (await presentControlledFailure(admitted, failureInput, adapters, authority, io, persistRunState));
206
+ }
207
+ catch (error) {
208
+ throw new TurnDispatchedFailure(error);
209
+ }
210
+ }
134
211
  export async function dispatchPostAdmissionTurn(input) {
135
212
  const { admitted, env, io, request, lease, adapters, effectiveEngine } = input;
213
+ const persistRunState = input.persistRunState !== false;
214
+ const deferredPersist = persistRunState ? {} : { needsPersist: true };
136
215
  const shouldPresent = adapters.shouldPresentSettled ?? ((terminal) => isLawfulTypedTerminalOutcome(terminal.roleOutcome));
137
216
  try {
138
217
  const missingCredential = missingCredentialPreDispatchFailure(env.model, env.credentials);
139
218
  if (missingCredential !== undefined) {
140
- return (await presentControlledFailure(admitted, missingCredential, adapters, env.principalAuthority, io));
219
+ return {
220
+ ...(await presentControlledFailure(admitted, missingCredential, adapters, env.principalAuthority, io, persistRunState)),
221
+ ...deferredPersist,
222
+ };
141
223
  }
142
224
  // #617 DK-4: capture previous invocation host before markRunRunning overwrites it.
143
225
  // Single authority projectHostTransitionPriorNative classifies the prior native volume.
@@ -161,28 +243,45 @@ export async function dispatchPostAdmissionTurn(input) {
161
243
  }
162
244
  catch (error) {
163
245
  // prior-native IO is on the public one-shot path — controlled failure, not bare throw.
164
- return (await presentControlledFailure(admitted, {
165
- timedOut: false,
166
- code: null,
167
- stderr: "",
168
- thrown: error,
169
- }, adapters, env.principalAuthority, io));
246
+ return {
247
+ ...(await presentControlledFailure(admitted, {
248
+ timedOut: false,
249
+ code: null,
250
+ stderr: "",
251
+ thrown: error,
252
+ }, adapters, env.principalAuthority, io, persistRunState)),
253
+ ...deferredPersist,
254
+ };
170
255
  }
171
- await markRunRunning(admitted.runDirectory, env.model, effectiveEngine, env.host);
172
256
  await clearTypedProviderHttpObservation(admitted.runDirectory);
173
- // beforeDispatch (seat-owned pre-turn work) runs after running is marked —
174
- // its failures must settle the run, not leave it permanently running.
257
+ // Per-attempt hygiene: stale Reviewer rejection pages must not ride into
258
+ // auto-resume. ENOENT-safe for every seat.
259
+ await clearReviewerDispatchRejection(admitted.runDirectory);
260
+ // The authoritative host write (markRunRunning) is delayed to just before
261
+ // executeTurn — not merely past beforeDispatch (#840 r9 判词 class 2). Any
262
+ // pre-turn retry (beforeDispatch, dossier projection, continuation
263
+ // assembly) must still see the prior invocation host on its next attempt;
264
+ // committing env.host earlier would make readInvocationHost read back the
265
+ // new host on the retry and silently drop hostTransition.
266
+ // Failures settle the run (presentControlledFailure); they must not leave it
267
+ // permanently running. Call-local auto-resume retries this hook until it
268
+ // succeeds; only an exhausted nested station child skips the parent loop
269
+ // (#840 父子不层叠).
175
270
  if (adapters.beforeDispatch !== undefined) {
176
271
  try {
177
272
  await adapters.beforeDispatch(admitted);
178
273
  }
179
274
  catch (error) {
180
- return (await presentControlledFailure(admitted, {
275
+ const settled = (await presentControlledFailure(admitted, {
181
276
  timedOut: false,
182
277
  code: null,
183
278
  stderr: "",
184
279
  thrown: error,
185
- }, adapters, env.principalAuthority, io));
280
+ }, adapters, env.principalAuthority, io, persistRunState));
281
+ if (error instanceof StationChildExhaustedError) {
282
+ return { ...settled, skipAutoResume: true, ...deferredPersist };
283
+ }
284
+ return { ...settled, ...deferredPersist };
186
285
  }
187
286
  }
188
287
  // Turn request is assembled after beforeDispatch so this turn sees whatever it
@@ -192,6 +291,9 @@ export async function dispatchPostAdmissionTurn(input) {
192
291
  // alike. System refs append their own neutral section; caller frozen
193
292
  // attachments and the seat's own prompt bytes are never rewritten.
194
293
  let turnRequest = env.signal === undefined ? request : { ...request, signal: env.signal };
294
+ if (env.stationChild !== undefined) {
295
+ turnRequest = { ...turnRequest, stationChild: env.stationChild };
296
+ }
195
297
  if (hostTransition !== undefined) {
196
298
  turnRequest = { ...turnRequest, hostTransition };
197
299
  }
@@ -206,23 +308,47 @@ export async function dispatchPostAdmissionTurn(input) {
206
308
  continuation: appendContinuationSection(turnRequest.continuation, dossierSection),
207
309
  };
208
310
  }
311
+ // Authoritative host write happens here, at the real dispatch boundary —
312
+ // immediately before the turn actually starts, after every retryable
313
+ // pre-turn step above has succeeded on this attempt (#840 r9 判词 class 2).
314
+ await markRunRunning(admitted.runDirectory, env.model, effectiveEngine, env.host);
209
315
  let result;
210
316
  try {
211
317
  result = await env.roleTurnHost.executeTurn(turnRequest);
212
318
  }
213
319
  catch (error) {
214
- return (await presentControlledFailure(admitted, {
320
+ const settled = await settleAfterTurnStarted(admitted, {
215
321
  timedOut: false,
216
322
  code: null,
217
323
  stderr: "",
218
324
  thrown: error,
219
- }, adapters, env.principalAuthority, io));
325
+ }, adapters, env.principalAuthority, io, persistRunState);
326
+ return { ...settled, turnDispatched: true, ...deferredPersist };
220
327
  }
328
+ // Everything below runs after the host turn genuinely started (#840 r9
329
+ // 判词 class 1 boundary — 覆盖 executeTurn 已启动后至返回带 turnDispatched
330
+ // 结果前的全部异常). Each fallible step routes any failure through the
331
+ // single existing settlement authority (presentControlledFailure /
332
+ // settleFailureTerminalResult) rather than a second one (ADR 0080
333
+ // single-settlement-disposition) — never by fabricating a terminal here.
334
+ // A cleanup step that runs only after a real settlement (clearCurrentCourt)
335
+ // is protected by logging and keeping that already-obtained result, never
336
+ // by discarding it (#840 已交劳动只整理终局不重做). A failure inside the
337
+ // settlement authority itself (presentControlledFailure's own reads) goes
338
+ // through settleAfterTurnStarted: still no fabricated terminal, but the
339
+ // re-thrown TurnDispatchedFailure still tells runWithAutoResumeLoop the
340
+ // turn genuinely started, so the next retry sends a resume payload — the
341
+ // true cause settles through the loop's own existing dispatch-exception
342
+ // machinery once the retry budget is exhausted.
221
343
  try {
222
344
  await writeFile(join(admitted.runDirectory, "stderr.log"), result.stderr, "utf8");
223
345
  }
224
- catch {
225
- // continue to lawful / controlled-failure settlement
346
+ catch (error) {
347
+ // Best-effort: the turn's own stderr capture is secondary to lawful /
348
+ // controlled-failure settlement below, but the failure itself must
349
+ // still leave a real trace (失败诚实宪法 真因必须落痕) — durably, since
350
+ // every auto-resume attempt's io is dummyIo (#840 r9 判词 class 1).
351
+ await recordBestEffortPostDispatchDiagnostic(admitted, env, `stderr.log write failed (best-effort continue): ${describeErrorIdentity(error)}`, io);
226
352
  }
227
353
  let settled;
228
354
  // Same-ticket re-summons carry courtAttemptId — settle only that attempt so a
@@ -230,55 +356,111 @@ export async function dispatchPostAdmissionTurn(input) {
230
356
  const courtScope = request.courtAttemptId === undefined || request.courtAttemptId.length === 0
231
357
  ? undefined
232
358
  : { courtAttemptId: request.courtAttemptId };
359
+ // Single complete boundary (#840 r9 判词 class 1): trySettle, its
360
+ // shouldPresent gate, and the accepted-settlement cleanup all settle
361
+ // through this one catch. shouldPresent used to sit outside every
362
+ // TurnDispatchedFailure wrap, so a throw from it looked like an ordinary
363
+ // pre-turn throw to the caller (turnStartedBeforeThrow stayed false) and
364
+ // replayed the initial payload even though the host turn had already
365
+ // genuinely run.
366
+ let settledOutcome;
233
367
  try {
234
368
  settled = await adapters.trySettle(admitted, env.principalAuthority, courtScope);
369
+ if (settled !== undefined && shouldPresent(settled)) {
370
+ // This court sealed — drop open-court pointer (bare resume no longer continues it).
371
+ if (settled.roleOutcome.kind === "accepted" &&
372
+ request.courtAttemptId !== undefined &&
373
+ request.courtAttemptId.length > 0) {
374
+ try {
375
+ await clearCurrentCourt(admitted.runDirectory, request.courtAttemptId);
376
+ }
377
+ catch (error) {
378
+ // Settlement already sealed accepted — a cleanup failure here must
379
+ // not erase that fact or make the caller replay this court's
380
+ // summons over already-delivered work (#840 已交劳动只整理终局不重做).
381
+ // A later bare resume self-heals: buildRequestAfterLease finds the
382
+ // open court already sealed and clears it then (documented
383
+ // continue-under-failure contract, not a swallow — 失败诚实宪法 真因
384
+ // 必须落痕) — durably, since every auto-resume attempt's io here is
385
+ // dummyIo (#840 r9 判词 class 1).
386
+ await recordBestEffortPostDispatchDiagnostic(admitted, env, `current-court cleanup failed after accepted settlement (best-effort continue, self-heals on next resume): ${describeErrorIdentity(error)}`, io);
387
+ }
388
+ }
389
+ settledOutcome = {
390
+ exitCode: exitCodeForTerminalOutcome(settled.roleOutcome),
391
+ admitted,
392
+ terminal: settled,
393
+ turnDispatched: true,
394
+ };
395
+ }
235
396
  }
236
397
  catch (error) {
237
- // Settle throw is a real failure fact — never swallow into undefined.
238
- return (await presentControlledFailure(admitted, {
398
+ // Settle (or its shouldPresent gate) throw is a real failure fact —
399
+ // never swallow into undefined.
400
+ const settledFailure = await settleAfterTurnStarted(admitted, {
239
401
  timedOut: false,
240
402
  code: result.code,
241
403
  stderr: result.stderr,
242
404
  thrown: error,
243
- }, adapters, env.principalAuthority, io));
244
- }
245
- if (settled !== undefined && shouldPresent(settled)) {
246
- // This court sealed — drop open-court pointer (bare resume no longer continues it).
247
- if (settled.roleOutcome.kind === "accepted" &&
248
- request.courtAttemptId !== undefined &&
249
- request.courtAttemptId.length > 0) {
250
- await clearCurrentCourt(admitted.runDirectory, request.courtAttemptId);
251
- }
252
- await markRunTerminal(admitted.runDirectory);
253
- io.stdout(formatTerminalResult(settled));
254
- return {
255
- exitCode: exitCodeForTerminalOutcome(settled.roleOutcome),
256
- admitted,
257
- terminal: settled,
405
+ }, adapters, env.principalAuthority, io, persistRunState);
406
+ return { ...settledFailure, turnDispatched: true, ...deferredPersist };
407
+ }
408
+ if (settledOutcome !== undefined) {
409
+ // Lawful persist + present is the caller's stop seam (auto-resume loop /
410
+ // manual resume), not this retried host-turn function.
411
+ return { ...settledOutcome, ...deferredPersist };
412
+ }
413
+ // Any exception while resolving the failure facts below — including the
414
+ // session-file decode itself — still happened after the host turn
415
+ // genuinely started (#840 r9 判词 class 1). Fold it into the same single
416
+ // controlled-failure settlement instead of losing turnDispatched to an
417
+ // uncaught throw.
418
+ let failureInput;
419
+ try {
420
+ const sessionFile = admitted.principal !== undefined
421
+ ? env.principalAuthority.decode(admitted.principal).sessionFile
422
+ : "";
423
+ const runnerKnownFailure = adapters.resolveRunnerKnownFailure !== undefined && sessionFile !== ""
424
+ ? await adapters.resolveRunnerKnownFailure({ result, sessionFile })
425
+ : result.knownFailure;
426
+ const credentialFailure = postRunMissingCredentialFailure(result, env.model, env.credentials);
427
+ const resolution = await resolveAuditedRunnerFailureResolution({
428
+ runner: runnerKnownFailure,
429
+ sessionFile,
430
+ credential: credentialFailure,
431
+ runDirectory: admitted.runDirectory,
432
+ });
433
+ failureInput = {
434
+ timedOut: result.timedOut,
435
+ code: result.code,
436
+ stderr: result.stderr,
437
+ ...controlledFailureInputFromResolution(resolution),
258
438
  };
259
439
  }
260
- const sessionFile = admitted.principal !== undefined
261
- ? env.principalAuthority.decode(admitted.principal).sessionFile
262
- : "";
263
- const runnerKnownFailure = adapters.resolveRunnerKnownFailure !== undefined && sessionFile !== ""
264
- ? await adapters.resolveRunnerKnownFailure({ result, sessionFile })
265
- : result.knownFailure;
266
- const credentialFailure = postRunMissingCredentialFailure(result, env.model, env.credentials);
267
- const resolution = await resolveAuditedRunnerFailureResolution({
268
- runner: runnerKnownFailure,
269
- sessionFile,
270
- credential: credentialFailure,
271
- runDirectory: admitted.runDirectory,
272
- });
273
- return (await presentControlledFailure(admitted, {
274
- timedOut: result.timedOut,
275
- code: result.code,
276
- stderr: result.stderr,
277
- ...controlledFailureInputFromResolution(resolution),
278
- }, adapters, env.principalAuthority, io));
440
+ catch (error) {
441
+ failureInput = {
442
+ timedOut: false,
443
+ code: result.code,
444
+ stderr: result.stderr,
445
+ thrown: error,
446
+ };
447
+ }
448
+ const failed = await settleAfterTurnStarted(admitted, failureInput, adapters, env.principalAuthority, io, persistRunState);
449
+ return { ...failed, turnDispatched: true, ...deferredPersist };
279
450
  }
280
451
  finally {
281
- await lease.release();
452
+ try {
453
+ await lease.release();
454
+ }
455
+ catch (error) {
456
+ // lease.release() is documented best-effort and never rejects in the
457
+ // production acquireRunWriterLease implementation (createWriterLease
458
+ // reports cleanup failures via callback, never throws) — this guard is
459
+ // structural only: an uncaught throw from a finally block silently
460
+ // replaces whatever the try already returned, including a properly
461
+ // tagged turnDispatched:true result (#840 r9 判词 class 1 boundary).
462
+ io.stderr(formatCliDiagnostic(`writer lease release failed unexpectedly (best-effort continue): ${describeErrorIdentity(error)}`));
463
+ }
282
464
  }
283
465
  }
284
466
  /**
@@ -351,8 +533,27 @@ export function resumeTurnRequestProjectionOptions(admitted, request, env, summo
351
533
  prompt,
352
534
  },
353
535
  ...(request.message === undefined ? {} : { courtAttemptId: randomUUID() }),
536
+ ...(env.stationChild === undefined ? {} : { stationChild: env.stationChild }),
354
537
  };
355
538
  }
539
+ /**
540
+ * Hold the writer lease through after-lease build, then hand off to dispatch.
541
+ * Builder (or any throw before dispatch) must release here — dispatch's finally
542
+ * only runs after this handoff (manual resume and station-child auto-resume).
543
+ */
544
+ async function dispatchAfterWriterLease(input) {
545
+ let handedOffToDispatch = false;
546
+ try {
547
+ const request = await input.build();
548
+ handedOffToDispatch = true;
549
+ return await input.dispatch(request);
550
+ }
551
+ finally {
552
+ if (!handedOffToDispatch) {
553
+ await input.lease.release();
554
+ }
555
+ }
556
+ }
356
557
  function isAlreadyFrozenSummonsAttachment(runDirectory, attachmentPath) {
357
558
  const absolute = isAbsolute(attachmentPath)
358
559
  ? attachmentPath
@@ -387,7 +588,8 @@ export async function prepareSummonsResumeMaterials(runDirectory, summons) {
387
588
  * Shared manual-resume orchestration for seats whose continuation is the
388
589
  * package resume envelope (#599 / #633): load once → structural rejection →
389
590
  * optional seat afterAdmittedLoad (method material / controlled failure) →
390
- * seat turn projection → runPostAdmissionManualResume. Seat-owned loader
591
+ * seat turn projection → station-child auto-resume or public manual resume.
592
+ * Seat-owned loader
391
593
  * validation, turn builder, and adapters stay on the seat.
392
594
  *
393
595
  * Court open/recovery transaction (#637): under the existing writer lease,
@@ -421,8 +623,146 @@ export async function runPostAdmissionSeatResume(input) {
421
623
  }
422
624
  adapters = prepared.adapters;
423
625
  }
424
- // Court recovery / open under lease, then always dispatch (resume is pass-through).
626
+ const buildRequestAfterLease = async () => {
627
+ let openCourtAttemptId;
628
+ // Build uses the admitted judged under this lease (rehydrated when open
629
+ // court materials ride). Settlement identity stays on the outer admitted.
630
+ let admittedForBuild = loaded.admitted;
631
+ // Bare resume: read + seal-judge + bound clear only under the held lease.
632
+ if (request.summons === undefined) {
633
+ const openCourt = await readCurrentCourt(admittedForBuild.runDirectory);
634
+ if (openCourt !== undefined) {
635
+ const sealedForOpen = await readSealedSubmission(admittedForBuild.projectRoot, admittedForBuild.runId, {
636
+ home: homeFromRunDirectory(admittedForBuild.runDirectory),
637
+ attemptId: openCourt.courtAttemptId,
638
+ });
639
+ if (sealedForOpen === undefined) {
640
+ // Continue open court: same summons materials + existing courtAttemptId.
641
+ // Caller message (if any) stays on the request — projection keeps it.
642
+ openCourtAttemptId = openCourt.courtAttemptId;
643
+ request = {
644
+ runId: request.runId,
645
+ ...(request.message === undefined
646
+ ? {}
647
+ : { message: request.message }),
648
+ ...(openCourt.summons === undefined
649
+ ? {}
650
+ : { summons: openCourt.summons }),
651
+ };
652
+ if (openCourt.summons !== undefined) {
653
+ const reloaded = await input.load(request);
654
+ admittedForBuild = reloaded.admitted;
655
+ }
656
+ }
657
+ else {
658
+ // Open court already sealed — clear only the court id just judged.
659
+ await clearCurrentCourt(admittedForBuild.runDirectory, openCourt.courtAttemptId);
660
+ }
661
+ }
662
+ }
663
+ // Freeze external paths once; rewrite summons to the frozen identity so
664
+ // currentCourt + later bare resume reuse the accepted snapshot.
665
+ if (request.summons !== undefined) {
666
+ const prepared = await prepareSummonsResumeMaterials(admittedForBuild.runDirectory, request.summons);
667
+ if (prepared !== undefined &&
668
+ (request.summons.attachmentPaths?.length ?? 0) > 0) {
669
+ request = {
670
+ ...request,
671
+ summons: {
672
+ ...request.summons,
673
+ attachmentPaths: prepared.attachments.map((attachment) => attachment.frozenPath),
674
+ },
675
+ };
676
+ }
677
+ }
678
+ let turnRequest = await input.buildTurnRequest(admittedForBuild, request);
679
+ // Open court continue, or new court for summons / message re-review
680
+ // (clause 0 新庭可再交卷; #833). Bare resume without open court omits id.
681
+ if (openCourtAttemptId !== undefined ||
682
+ request.summons !== undefined ||
683
+ request.message !== undefined) {
684
+ const courtAttemptId = openCourtAttemptId ??
685
+ (turnRequest.courtAttemptId !== undefined &&
686
+ turnRequest.courtAttemptId.length > 0
687
+ ? turnRequest.courtAttemptId
688
+ : randomUUID());
689
+ turnRequest = { ...turnRequest, courtAttemptId };
690
+ if (openCourtAttemptId === undefined) {
691
+ const court = {
692
+ courtAttemptId,
693
+ ...(request.summons === undefined
694
+ ? {}
695
+ : { summons: request.summons }),
696
+ };
697
+ await recordCurrentCourt(admittedForBuild.runDirectory, court);
698
+ }
699
+ }
700
+ return turnRequest;
701
+ };
702
+ // Court recovery / open under lease, then dispatch.
703
+ // Station-child same-ticket/same-parent resume is call-local auto-resume
704
+ // (#840 / #416). Public `ak-role resume` stays one-shot (ADR 0080).
425
705
  try {
706
+ if (input.env.stationChild === true) {
707
+ let firstTurn;
708
+ const stationAdapters = withOnceSuccessfulBeforeDispatch(adapters);
709
+ return await runWithAutoResumeLoop({
710
+ admitted: loaded.admitted,
711
+ principalAuthority: input.env.principalAuthority,
712
+ isPrincipalAvailable: resolveHostAwareSessionAvailability(input.env.host, input.env.principalAuthority),
713
+ io: input.io,
714
+ sessionAppender: input.env.sessionAppender,
715
+ autoResumeLimit: input.env.autoResumeLimit,
716
+ buildInitialPayload: () => ({ resumeTurn: false }),
717
+ buildResumePayload: () => ({ resumeTurn: true }),
718
+ // Same as public manual resume: prior-court sealed acceptance is not a
719
+ // redispatch brake (#833). New-court station-child turns still auto-resume.
720
+ dispatch: async (payload, lease, _isFirst, attemptIo) => dispatchAfterWriterLease({
721
+ lease,
722
+ build: async () => {
723
+ // #840 r8 判词 class 2: this call-local retry must keep this
724
+ // court's frozen summons / 交卷 body / attachments verbatim
725
+ // (same object as firstTurn) and project only the minimal
726
+ // host-needed resume trigger — never the manual-resume engine
727
+ // handbook (buildResumeContinuationPrompt), which would replace
728
+ // a 审核循环 same-ticket continuation with a bare outsourcing
729
+ // 「重新读」 envelope (#755 contract, resumeTurnRequestProjectionOptions
730
+ // above). RESUME_TRANSPORT_ENVELOPE is the same package-owned,
731
+ // non-semantic trigger that projection already uses for a
732
+ // same-ticket summons carrying no instruction/attachments.
733
+ if (payload.resumeTurn && firstTurn !== undefined) {
734
+ return {
735
+ ...firstTurn,
736
+ continuation: {
737
+ kind: "resume",
738
+ prompt: RESUME_TRANSPORT_ENVELOPE,
739
+ },
740
+ };
741
+ }
742
+ const turnRequest = await buildRequestAfterLease();
743
+ firstTurn = turnRequest;
744
+ return turnRequest;
745
+ },
746
+ dispatch: (turnRequest) => dispatchPostAdmissionTurn({
747
+ admitted: loaded.admitted,
748
+ env: {
749
+ ...input.env,
750
+ ...(loaded.admitted.correlationId === undefined
751
+ ? {}
752
+ : { correlationId: loaded.admitted.correlationId }),
753
+ },
754
+ io: attemptIo,
755
+ request: turnRequest,
756
+ lease,
757
+ adapters: stationAdapters,
758
+ persistRunState: false,
759
+ ...(input.effectiveEngine === undefined
760
+ ? {}
761
+ : { effectiveEngine: input.effectiveEngine }),
762
+ }),
763
+ }),
764
+ });
765
+ }
426
766
  return await runPostAdmissionManualResume({
427
767
  admitted: loaded.admitted,
428
768
  env: input.env,
@@ -431,82 +771,7 @@ export async function runPostAdmissionSeatResume(input) {
431
771
  ...(input.effectiveEngine === undefined
432
772
  ? {}
433
773
  : { effectiveEngine: input.effectiveEngine }),
434
- buildRequestAfterLease: async () => {
435
- let openCourtAttemptId;
436
- // Build uses the admitted judged under this lease (rehydrated when open
437
- // court materials ride). Settlement identity stays on the outer admitted.
438
- let admittedForBuild = loaded.admitted;
439
- // Bare resume: read + seal-judge + bound clear only under the held lease.
440
- if (request.summons === undefined) {
441
- const openCourt = await readCurrentCourt(admittedForBuild.runDirectory);
442
- if (openCourt !== undefined) {
443
- const sealedForOpen = await readSealedSubmission(admittedForBuild.projectRoot, admittedForBuild.runId, {
444
- home: homeFromRunDirectory(admittedForBuild.runDirectory),
445
- attemptId: openCourt.courtAttemptId,
446
- });
447
- if (sealedForOpen === undefined) {
448
- // Continue open court: same summons materials + existing courtAttemptId.
449
- // Caller message (if any) stays on the request — projection keeps it.
450
- openCourtAttemptId = openCourt.courtAttemptId;
451
- request = {
452
- runId: request.runId,
453
- ...(request.message === undefined
454
- ? {}
455
- : { message: request.message }),
456
- ...(openCourt.summons === undefined
457
- ? {}
458
- : { summons: openCourt.summons }),
459
- };
460
- if (openCourt.summons !== undefined) {
461
- const reloaded = await input.load(request);
462
- admittedForBuild = reloaded.admitted;
463
- }
464
- }
465
- else {
466
- // Open court already sealed — clear only the court id just judged.
467
- await clearCurrentCourt(admittedForBuild.runDirectory, openCourt.courtAttemptId);
468
- }
469
- }
470
- }
471
- // Freeze external paths once; rewrite summons to the frozen identity so
472
- // currentCourt + later bare resume reuse the accepted snapshot.
473
- if (request.summons !== undefined) {
474
- const prepared = await prepareSummonsResumeMaterials(admittedForBuild.runDirectory, request.summons);
475
- if (prepared !== undefined &&
476
- (request.summons.attachmentPaths?.length ?? 0) > 0) {
477
- request = {
478
- ...request,
479
- summons: {
480
- ...request.summons,
481
- attachmentPaths: prepared.attachments.map((attachment) => attachment.frozenPath),
482
- },
483
- };
484
- }
485
- }
486
- let turnRequest = await input.buildTurnRequest(admittedForBuild, request);
487
- // Open court continue, or new court for summons / message re-review
488
- // (clause 0 新庭可再交卷; #833). Bare resume without open court omits id.
489
- if (openCourtAttemptId !== undefined ||
490
- request.summons !== undefined ||
491
- request.message !== undefined) {
492
- const courtAttemptId = openCourtAttemptId ??
493
- (turnRequest.courtAttemptId !== undefined &&
494
- turnRequest.courtAttemptId.length > 0
495
- ? turnRequest.courtAttemptId
496
- : randomUUID());
497
- turnRequest = { ...turnRequest, courtAttemptId };
498
- if (openCourtAttemptId === undefined) {
499
- const court = {
500
- courtAttemptId,
501
- ...(request.summons === undefined
502
- ? {}
503
- : { summons: request.summons }),
504
- };
505
- await recordCurrentCourt(admittedForBuild.runDirectory, court);
506
- }
507
- }
508
- return turnRequest;
509
- },
774
+ buildRequestAfterLease,
510
775
  });
511
776
  }
512
777
  catch (error) {
@@ -520,31 +785,30 @@ export async function runPostAdmissionSeatResume(input) {
520
785
  }
521
786
  }
522
787
  /**
523
- * Shared post-admission one-shot path: writer lease, then turn dispatch.
788
+ * Shared post-admission one-shot path: folds into runPostAdmissionResumable (#840 / #416).
789
+ * All callable roles share the single auto-resume loop.
524
790
  * Initial facades own the durable admitted mark (markRunAdmitted) before
525
791
  * entering; manual resume never re-admits.
526
792
  */
527
793
  export async function runPostAdmissionOneShot(input) {
528
- const { admitted, env, io, request, adapters, effectiveEngine } = input;
529
- let lease;
530
- try {
531
- lease = await acquireRunWriterLease(admitted.runDirectory, (diagnostic) => io.stderr(diagnostic));
532
- }
533
- catch (error) {
534
- if (error instanceof RunWriterLeaseHeldError) {
535
- presentStructuralRejection(error, io);
536
- return { exitCode: 2, admitted };
537
- }
538
- throw error;
539
- }
540
- return await dispatchPostAdmissionTurn({
541
- admitted,
542
- env,
543
- io,
544
- request,
545
- lease,
546
- adapters,
547
- ...(effectiveEngine === undefined ? {} : { effectiveEngine }),
794
+ const engine = input.effectiveEngine ?? input.env.engine;
795
+ return await runPostAdmissionResumable({
796
+ admitted: input.admitted,
797
+ env: input.env,
798
+ io: input.io,
799
+ buildInitialRequest: () => input.request,
800
+ buildResumeRequest: () => ({
801
+ ...input.request,
802
+ continuation: {
803
+ kind: "resume",
804
+ prompt: buildResumeContinuationPrompt({
805
+ packageRoot: input.env.packageRoot,
806
+ ...(engine === undefined ? {} : { engine }),
807
+ }),
808
+ },
809
+ }),
810
+ adapters: input.adapters,
811
+ ...(input.effectiveEngine === undefined ? {} : { effectiveEngine: input.effectiveEngine }),
548
812
  });
549
813
  }
550
814
  /**
@@ -552,10 +816,12 @@ export async function runPostAdmissionOneShot(input) {
552
816
  * Initial facades own the durable admitted mark before entering.
553
817
  */
554
818
  export async function runPostAdmissionResumable(input) {
555
- const { admitted, env, io, buildInitialRequest, buildResumeRequest, adapters, effectiveEngine } = input;
819
+ const { admitted, env, io, buildInitialRequest, buildResumeRequest, effectiveEngine } = input;
820
+ const adapters = withOnceSuccessfulBeforeDispatch(input.adapters);
556
821
  return runWithAutoResumeLoop({
557
822
  admitted,
558
823
  principalAuthority: env.principalAuthority,
824
+ isPrincipalAvailable: resolveHostAwareSessionAvailability(env.host, env.principalAuthority),
559
825
  io,
560
826
  sessionAppender: env.sessionAppender,
561
827
  autoResumeLimit: env.autoResumeLimit,
@@ -572,6 +838,7 @@ export async function runPostAdmissionResumable(input) {
572
838
  request,
573
839
  lease,
574
840
  adapters,
841
+ persistRunState: false,
575
842
  // #600: every attempt (initial + auto-resume) writes seat engine when present.
576
843
  ...(effectiveEngine === undefined ? {} : { effectiveEngine }),
577
844
  }),
@@ -581,6 +848,7 @@ export async function runPostAdmissionResumable(input) {
581
848
  * Manual resume: lease + dispatch. Pass-through to the host — no sealed-accepted
582
849
  * short-circuit (#833 / #416). Court open (summons / message / open court) is
583
850
  * built under lease when using buildRequestAfterLease; sole-final stays per-attempt.
851
+ * After-lease build shares dispatchAfterWriterLease with station-child auto-resume.
584
852
  */
585
853
  export async function runPostAdmissionManualResume(input) {
586
854
  const { admitted, env, io, adapters, effectiveEngine, buildRequestAfterLease, } = input;
@@ -614,19 +882,18 @@ export async function runPostAdmissionManualResume(input) {
614
882
  }
615
883
  throw error;
616
884
  }
617
- // Court open/recovery under held lease until dispatch owns release (finally
618
- // below). Builder and any throw on this seam must release here — dispatch's
619
- // finally only runs after handoff.
620
- let handedOffToDispatch = false;
621
- try {
622
- if (request === undefined) {
623
- if (buildRequestAfterLease === undefined) {
624
- throw new Error("runPostAdmissionManualResume requires request or buildRequestAfterLease");
885
+ const result = await dispatchAfterWriterLease({
886
+ lease,
887
+ build: async () => {
888
+ if (request === undefined) {
889
+ if (buildRequestAfterLease === undefined) {
890
+ throw new Error("runPostAdmissionManualResume requires request or buildRequestAfterLease");
891
+ }
892
+ request = await buildRequestAfterLease();
625
893
  }
626
- request = await buildRequestAfterLease();
627
- }
628
- handedOffToDispatch = true;
629
- const result = await dispatchPostAdmissionTurn({
894
+ return request;
895
+ },
896
+ dispatch: (turnRequest) => dispatchPostAdmissionTurn({
630
897
  admitted,
631
898
  env: {
632
899
  ...env,
@@ -636,24 +903,24 @@ export async function runPostAdmissionManualResume(input) {
636
903
  : { correlationId: admitted.correlationId }),
637
904
  },
638
905
  io,
639
- request,
906
+ request: turnRequest,
640
907
  lease,
641
908
  adapters,
642
909
  ...(effectiveEngine === undefined ? {} : { effectiveEngine }),
643
- });
644
- if (result.terminal !== undefined) {
645
- result.terminal.autoResumeCount = 0;
646
- }
647
- return {
648
- ...result,
649
- ...(staleWriterLeaseReclaimed === true
650
- ? { staleWriterLeaseReclaimed: true }
651
- : {}),
652
- };
910
+ }),
911
+ });
912
+ if (result.terminal !== undefined &&
913
+ isLawfulTypedTerminalOutcome(result.terminal.roleOutcome)) {
914
+ await persistReturnedRunState(admitted, env.principalAuthority, { lawful: true });
915
+ io.stdout(formatTerminalResult(result.terminal));
653
916
  }
654
- finally {
655
- if (!handedOffToDispatch) {
656
- await lease.release();
657
- }
917
+ if (result.terminal !== undefined) {
918
+ result.terminal.autoResumeCount = 0;
658
919
  }
920
+ return {
921
+ ...result,
922
+ ...(staleWriterLeaseReclaimed === true
923
+ ? { staleWriterLeaseReclaimed: true }
924
+ : {}),
925
+ };
659
926
  }