@akagilnc/pi-workflow-roles 0.1.3452 → 0.1.3471

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 (36) hide show
  1. package/CLAUDE.md +2 -0
  2. package/dist/evidence-child-executor.js +2 -11
  3. package/dist/grok/production-host.js +196 -205
  4. package/dist/pi/in-process-session.js +4 -0
  5. package/dist/public-cli/main.js +998 -920
  6. package/package.json +1 -1
  7. package/scripts/build-package.mjs +1 -3
  8. package/src/analyst-gate-cycles-read.ts +3 -21
  9. package/src/analyst-ledger.ts +11 -42
  10. package/src/archivist-record-entry.ts +5 -6
  11. package/src/evidence-child-executor.ts +5 -15
  12. package/src/grok/production-host.ts +3 -32
  13. package/src/grok/role-envelope.ts +10 -4
  14. package/src/host-contracts.ts +4 -11
  15. package/src/host-transition-prior-native.ts +3 -7
  16. package/src/ledger-session-read.ts +1 -17
  17. package/src/pi/in-process-session.ts +6 -2
  18. package/src/pi/role-turn-host.ts +9 -0
  19. package/src/public-cli/collector-run.ts +4 -4
  20. package/src/public-cli/countersign-run.ts +90 -20
  21. package/src/public-cli/doctor-run.ts +4 -4
  22. package/src/public-cli/gleaner-left-run.ts +9 -6
  23. package/src/public-cli/inspector-run.ts +95 -32
  24. package/src/public-cli/instruction-seat-run.ts +7 -6
  25. package/src/public-cli/invocation.ts +14 -8
  26. package/src/public-cli/judge-run.ts +4 -4
  27. package/src/public-cli/notary-run.ts +91 -30
  28. package/src/public-cli/post-admission.ts +424 -360
  29. package/src/public-cli/run-lifecycle.ts +205 -0
  30. package/src/public-cli/seat-ticket-binding.ts +136 -16
  31. package/src/public-cli/settlement.ts +74 -52
  32. package/src/run-ticket-number.ts +53 -0
  33. package/src/submission-ledger.ts +86 -18
  34. package/dist/ledger-session-read.js +0 -241
  35. package/dist/ticket-seat-memory.js +0 -244
  36. package/src/ticket-seat-memory.ts +0 -454
@@ -5,15 +5,23 @@
5
5
  * initial role facades before entering; manual resume never re-admits.
6
6
  * Role runners supply only turn request projection and narrow settlement adapters.
7
7
  */
8
+ import { randomUUID } from "node:crypto";
8
9
  import { readFile, writeFile } from "node:fs/promises";
9
- import { join } from "node:path";
10
+ import { isAbsolute, join, resolve } from "node:path";
10
11
 
11
12
  import {
12
13
  buildResumeContinuationPrompt,
13
14
  type PublicResumeRequest,
15
+ type SameTicketSummonsMaterials,
14
16
  } from "./run-lifecycle.ts";
15
17
  import { CliUsageError } from "./cli-errors.ts";
16
18
  import type { RoleTurnRequestProjectionOptions } from "./turn-request.ts";
19
+ import {
20
+ buildInstructionTransportPrompt,
21
+ freezeAttachmentsIntoRun,
22
+ } from "./invocation.ts";
23
+ import { pathContainedIn } from "../activation-ledger-topology.ts";
24
+ import { engineSessionMaterialFromOptions } from "../package-resources/engine-material.ts";
17
25
 
18
26
  import type {
19
27
  ControlledFailureCause,
@@ -26,11 +34,6 @@ import type {
26
34
  SessionCustomEntryAppender,
27
35
  } from "../host-contracts.ts";
28
36
  import { projectHostTransitionPriorNative } from "../host-transition-prior-native.ts";
29
- import {
30
- isTicketSeatMemoryBound,
31
- readTicketSeatMemoryLastHost,
32
- writeTicketSeatMemoryLastHost,
33
- } from "../ticket-seat-memory.ts";
34
37
  import type { CredentialProviders, SeatModelConfig } from "./config.ts";
35
38
  import {
36
39
  missingCredentialPreDispatchFailure,
@@ -38,16 +41,22 @@ import {
38
41
  } from "./public-run-credentials.ts";
39
42
  import {
40
43
  acquireRunWriterLease,
44
+ clearCurrentCourt,
41
45
  clearTypedProviderHttpObservation,
42
46
  markRunResumable,
43
47
  markRunRunning,
44
48
  markRunTerminal,
49
+ readCurrentCourt,
50
+ recordCurrentCourt,
45
51
  renderResumeCommand,
46
52
  RunWriterLeaseHeldError,
53
+ type CurrentCourtState,
47
54
  type RunWriterLease,
48
55
  type TypedProviderHttpObservation,
49
56
  type WriterLeaseDiagnosticKind,
50
57
  } from "./run-lifecycle.ts";
58
+ import { homeFromRunDirectory } from "../activation-ledger-topology.ts";
59
+ import { readSealedSubmission } from "../submission-ledger.ts";
51
60
  import {
52
61
  classifyPostAdmissionFailure,
53
62
  controlledFailureInputFromResolution,
@@ -59,12 +68,10 @@ import {
59
68
  isLawfulTypedTerminalOutcome,
60
69
  presentFailureTerminal,
61
70
  presentStructuralRejection,
62
- projectThrownFailureLeaf,
63
71
  resolveAuditedRunnerFailureResolution,
64
72
  resolveControlledFailureResumeObservation,
65
73
  settleFailureTerminalResult,
66
74
  sealedAcceptanceRedispatchDisposition,
67
- type ControlledFailure,
68
75
  } from "./settlement.ts";
69
76
  import type { CliIo } from "./cli-io.ts";
70
77
  import type { AdmittedRoleInvocation } from "./invocation.ts";
@@ -113,7 +120,12 @@ export type PostAdmissionAdapters<
113
120
  A extends AdmittedRoleInvocation = AdmittedRoleInvocation,
114
121
  T extends TerminalResult = TerminalResult,
115
122
  > = {
116
- trySettle: (admitted: A, authority: DurablePrincipalAuthority) => Promise<T | undefined>;
123
+ trySettle: (
124
+ admitted: A,
125
+ authority: DurablePrincipalAuthority,
126
+ /** Current court turn scope (#637); omit for run-scoped sealed reads. */
127
+ scope?: { readonly courtAttemptId?: string },
128
+ ) => Promise<T | undefined>;
117
129
  /** Default: isLawfulTypedTerminalOutcome(terminal.roleOutcome). */
118
130
  shouldPresentSettled?: (terminal: T) => boolean;
119
131
  resolveRunnerKnownFailure?: (input: {
@@ -177,8 +189,7 @@ export async function presentControlledFailure<
177
189
  admitted.principal !== undefined
178
190
  ? await inspectJudgeSession(authority.decode(admitted.principal).sessionFile)
179
191
  : undefined;
180
- // knownFailure channel owns details when present; otherwise caller knownDetails
181
- // (e.g. last-host write concurrent leaf on timeout/activation dual failure).
192
+ // knownFailure channel owns details when present; otherwise caller knownDetails.
182
193
  const fromKnownFailure =
183
194
  explicitInternalKnownFailureClassificationInput(knownFailure);
184
195
  const failure = classifyPostAdmissionFailure({
@@ -233,127 +244,6 @@ export async function presentControlledFailure<
233
244
  };
234
245
  }
235
246
 
236
- /** Serialize one failure leaf for details.concurrentFailures (sole leaf owner = settlement). */
237
- function concurrentFailureLeafRecord(leaf: ControlledFailure): {
238
- readonly cause: ControlledFailureCause;
239
- readonly diagnostic: string;
240
- readonly identity?: { readonly name?: string; readonly code?: string | number };
241
- readonly details?: Readonly<Record<string, unknown>>;
242
- } {
243
- return {
244
- cause: leaf.cause,
245
- diagnostic: leaf.diagnostic,
246
- ...(leaf.identity === undefined ? {} : { identity: leaf.identity }),
247
- ...(leaf.details === undefined ? {} : { details: leaf.details }),
248
- };
249
- }
250
-
251
- function existingConcurrentFailureLeaves(
252
- details: Readonly<Record<string, unknown>> | undefined,
253
- ): unknown[] {
254
- const value = details?.concurrentFailures;
255
- return Array.isArray(value) ? [...value] : [];
256
- }
257
-
258
- /**
259
- * Attach last-host write failure as concurrent secondary evidence on an existing
260
- * returned-path ControlledFailureInput — append, never replace prior leaves.
261
- */
262
- function withLastHostWriteConcurrentFailure(
263
- input: ControlledFailureInput,
264
- writeError: unknown,
265
- ): ControlledFailureInput {
266
- const writeLeaf = concurrentFailureLeafRecord(projectThrownFailureLeaf(writeError));
267
- if (input.knownFailure !== undefined) {
268
- const failure = input.knownFailure;
269
- return {
270
- ...input,
271
- knownFailure: {
272
- cause: failure.cause,
273
- ...(failure.identity === undefined ? {} : { identity: failure.identity }),
274
- ...(failure.diagnostic === undefined
275
- ? {}
276
- : { diagnostic: failure.diagnostic }),
277
- details: {
278
- ...(failure.details ?? {}),
279
- concurrentFailures: [
280
- ...existingConcurrentFailureLeaves(failure.details),
281
- writeLeaf,
282
- ],
283
- },
284
- },
285
- };
286
- }
287
- return {
288
- ...input,
289
- knownDetails: {
290
- ...(input.knownDetails ?? {}),
291
- concurrentFailures: [
292
- ...existingConcurrentFailureLeaves(input.knownDetails),
293
- writeLeaf,
294
- ],
295
- },
296
- };
297
- }
298
-
299
- /** Settled failure roleOutcome already narrowed by kind at the sole call site. */
300
- type SettledFailureOutcome = Extract<
301
- TerminalResult["roleOutcome"],
302
- { kind: "failure" }
303
- >;
304
-
305
- /**
306
- * Rebuild the settled failure as knownFailure so concurrent last-host write can
307
- * append without replacing cause/identity/diagnostic/details.
308
- * Caller narrows by kind; no second runtime re-check.
309
- */
310
- function knownFailureFromSettledFailureOutcome(
311
- outcome: SettledFailureOutcome,
312
- ): RoleTurnKnownFailure {
313
- const facts = outcome.decisiveFacts;
314
- const secondary = facts.secondaryEvidence;
315
- const details =
316
- secondary !== undefined &&
317
- typeof secondary === "object" &&
318
- secondary !== null &&
319
- !Array.isArray(secondary)
320
- ? (secondary as Readonly<Record<string, unknown>>)
321
- : undefined;
322
- const identity: { name?: string; code?: string | number } = {};
323
- if (typeof facts.errorName === "string") identity.name = facts.errorName;
324
- if (
325
- typeof facts.errorCode === "string" ||
326
- typeof facts.errorCode === "number"
327
- ) {
328
- identity.code = facts.errorCode;
329
- }
330
- return {
331
- cause: outcome.cause,
332
- diagnostic: outcome.diagnostic,
333
- ...(Object.keys(identity).length > 0 ? { identity } : {}),
334
- ...(details === undefined ? {} : { details }),
335
- };
336
- }
337
-
338
- /**
339
- * Presence envelope for a caught last-host write failure.
340
- * Own-key presence is distinct from the caught value: `throw undefined` must stay a
341
- * real failure fact (same rule as settlement thrown own-key), never a missing-write sentinel.
342
- */
343
- type LastHostWriteFailure = { readonly error: unknown };
344
-
345
- /** Keep primary throw identity; attach last-host write as a real AggregateError leaf. */
346
- function withOptionalLastHostWriteThrow(
347
- primary: unknown,
348
- writeFailure: LastHostWriteFailure | undefined,
349
- message: string,
350
- ): unknown {
351
- if (writeFailure === undefined) return primary;
352
- return new AggregateError([primary, writeFailure.error], message, {
353
- cause: primary,
354
- });
355
- }
356
-
357
247
  export async function dispatchPostAdmissionTurn<
358
248
  A extends AdmittedRoleInvocation,
359
249
  T extends TerminalResult = TerminalResult,
@@ -389,54 +279,16 @@ export async function dispatchPostAdmissionTurn<
389
279
  }
390
280
  // #617 DK-4: capture previous invocation host before markRunRunning overwrites it.
391
281
  // Single authority projectHostTransitionPriorNative owns known-host prior native paths.
392
- // #636 ticket-seat memory: one last-host page owns both last host and Grok native-home run.
393
- // Open is not turn return/throw: production Grok notes the open fact at bind success;
394
- // last-host records when the host returned a result OR when native home actually opened.
395
- // Pre-open throws claim nothing; post-open throws retain the opened run.
396
- // Side effects are gated by explicit ticket+seat binding — never by directory-outside-run guessing.
282
+ // Same-run resume (#637) keeps host identity on the run's invocation page.
397
283
  let previousHost: string | undefined;
398
- let previousRunDirectory: string | undefined;
399
- let nativeHomeRunDirectory: string | undefined;
400
- let openedNativeHomeRunDirectory: string | undefined;
401
284
  const liveHost = env.host;
402
285
  const principalCoordinates =
403
286
  admitted.principal === undefined
404
287
  ? undefined
405
288
  : env.principalAuthority.decode(admitted.principal);
406
- const ticketSeatMemoryBound = isTicketSeatMemoryBound({
407
- role: admitted.role,
408
- ...(admitted.ticketNumber === undefined ? {} : { ticketNumber: admitted.ticketNumber }),
409
- });
410
289
  let turnRequest: RoleTurnRequest;
411
290
  try {
412
- // Ticket-seat host authority lives on the nest last-host page only.
413
- // Per-run invocation.host must not override: old-run retry after a cross-host
414
- // intermediate would otherwise keep the stale run host and drop the transition.
415
- // Non-ticket-seat paths still read the per-run invocation mark (#617).
416
- if (ticketSeatMemoryBound && principalCoordinates !== undefined) {
417
- const lastHost = await readTicketSeatMemoryLastHost(
418
- principalCoordinates.sessionDirectory,
419
- );
420
- if (lastHost !== undefined) {
421
- previousHost = lastHost.host;
422
- // runDirectory on last-host is the established Grok native isolation run when present
423
- // (preserved across non-Grok hosts so return-to-Grok can reopen it).
424
- if (
425
- typeof lastHost.runDirectory === "string" &&
426
- lastHost.runDirectory.length > 0
427
- ) {
428
- previousRunDirectory = lastHost.runDirectory;
429
- if (
430
- liveHost === "grok-build" &&
431
- request.continuation.kind === "resume"
432
- ) {
433
- nativeHomeRunDirectory = lastHost.runDirectory;
434
- }
435
- }
436
- }
437
- } else {
438
- previousHost = await readInvocationHost(admitted.runDirectory);
439
- }
291
+ previousHost = await readInvocationHost(admitted.runDirectory);
440
292
  const hostTransition =
441
293
  previousHost !== undefined && liveHost !== undefined && principalCoordinates !== undefined
442
294
  ? await projectHostTransitionPriorNative({
@@ -444,27 +296,14 @@ export async function dispatchPostAdmissionTurn<
444
296
  liveHost,
445
297
  runDirectory: admitted.runDirectory,
446
298
  piSessionFile: principalCoordinates.sessionFile,
447
- ...(previousRunDirectory === undefined ? {} : { previousRunDirectory }),
448
299
  })
449
300
  : undefined;
450
301
  turnRequest = request;
451
302
  if (hostTransition !== undefined) {
452
303
  turnRequest = { ...turnRequest, hostTransition };
453
304
  }
454
- if (nativeHomeRunDirectory !== undefined) {
455
- turnRequest = { ...turnRequest, nativeHomeRunDirectory };
456
- }
457
- // Capture the actual Grok open event from production isolation (bind success).
458
- if (ticketSeatMemoryBound) {
459
- turnRequest = {
460
- ...turnRequest,
461
- noteNativeHomeOpened: (runDirectory) => {
462
- openedNativeHomeRunDirectory = runDirectory;
463
- },
464
- };
465
- }
466
305
  } catch (error) {
467
- // last-host / prior-native IO is on the public one-shot path — controlled failure, not bare throw.
306
+ // prior-native IO is on the public one-shot path — controlled failure, not bare throw.
468
307
  return (await presentControlledFailure(
469
308
  admitted,
470
309
  {
@@ -502,66 +341,17 @@ export async function dispatchPostAdmissionTurn<
502
341
  }
503
342
  }
504
343
 
505
- // Turn outcome and open fact are separate events. Discriminate throw value undefined.
506
- let turnOutcome:
507
- | { readonly kind: "returned"; readonly result: RoleTurnResult }
508
- | { readonly kind: "thrown"; readonly error: unknown };
344
+ let result: RoleTurnResult;
509
345
  try {
510
- turnOutcome = {
511
- kind: "returned",
512
- result: await env.roleTurnHost.executeTurn(turnRequest),
513
- };
346
+ result = await env.roleTurnHost.executeTurn(turnRequest);
514
347
  } catch (error) {
515
- turnOutcome = { kind: "thrown", error };
516
- }
517
-
518
- // last-host authority: host returned a turn result OR native home actually opened.
519
- // Grok open fact comes from production bind (noteNativeHomeOpened), not from return/throw.
520
- // Mock hosts that return without opening still record via the returned path.
521
- // Pre-open throws: neither → no claim. Post-open throws: open fact → retain.
522
- // Spans returned failure / same-run retry / return-to-Grok / post-open body+cleanup throws.
523
- // last-host write failure is pending secondary evidence only — never forks settlement.
524
- // Thrown dual: AggregateError leaves. Returned dual: same shared settle/resolve chain,
525
- // write leaf attached at the single present boundary (ADR 0080 single-settlement-disposition).
526
- // Presence is the envelope, not the caught value — `throw undefined` stays a failure fact.
527
- let lastHostWriteFailure: LastHostWriteFailure | undefined;
528
- const hostEngaged =
529
- turnOutcome.kind === "returned" || openedNativeHomeRunDirectory !== undefined;
530
- if (
531
- hostEngaged &&
532
- liveHost !== undefined &&
533
- principalCoordinates !== undefined &&
534
- ticketSeatMemoryBound
535
- ) {
536
- try {
537
- await writeTicketSeatMemoryLastHost(
538
- principalCoordinates.sessionDirectory,
539
- liveHost,
540
- liveHost === "grok-build"
541
- ? (openedNativeHomeRunDirectory ??
542
- nativeHomeRunDirectory ??
543
- admitted.runDirectory)
544
- : previousRunDirectory,
545
- );
546
- } catch (error) {
547
- lastHostWriteFailure = { error };
548
- }
549
- }
550
-
551
- if (turnOutcome.kind === "thrown") {
552
- // Preserve original throw/cause (pre-open or post-open). last-host already
553
- // retained open fact above when bind had succeeded; write fail stays concurrent.
554
348
  return (await presentControlledFailure(
555
349
  admitted,
556
350
  {
557
351
  timedOut: false,
558
352
  code: null,
559
353
  stderr: "",
560
- thrown: withOptionalLastHostWriteThrow(
561
- turnOutcome.error,
562
- lastHostWriteFailure,
563
- "host turn and ticket-seat last-host write failed",
564
- ),
354
+ thrown: error,
565
355
  },
566
356
  adapters,
567
357
  env.principalAuthority,
@@ -569,8 +359,6 @@ export async function dispatchPostAdmissionTurn<
569
359
  )) as { exitCode: number; admitted: A; terminal: T };
570
360
  }
571
361
 
572
- const result = turnOutcome.result;
573
-
574
362
  try {
575
363
  await writeFile(
576
364
  join(admitted.runDirectory, "stderr.log"),
@@ -582,8 +370,14 @@ export async function dispatchPostAdmissionTurn<
582
370
  }
583
371
 
584
372
  let settled: T | undefined;
373
+ // Same-ticket re-summons carry courtAttemptId — settle only that attempt so a
374
+ // prior sealed pass cannot wash this turn's missing/escalated/failed result.
375
+ const courtScope =
376
+ request.courtAttemptId === undefined || request.courtAttemptId.length === 0
377
+ ? undefined
378
+ : { courtAttemptId: request.courtAttemptId };
585
379
  try {
586
- settled = await adapters.trySettle(admitted, env.principalAuthority);
380
+ settled = await adapters.trySettle(admitted, env.principalAuthority, courtScope);
587
381
  } catch (error) {
588
382
  // Settle throw is a real failure fact — never swallow into undefined.
589
383
  return (await presentControlledFailure(
@@ -592,11 +386,7 @@ export async function dispatchPostAdmissionTurn<
592
386
  timedOut: false,
593
387
  code: result.code,
594
388
  stderr: result.stderr,
595
- thrown: withOptionalLastHostWriteThrow(
596
- error,
597
- lastHostWriteFailure,
598
- "settlement and ticket-seat last-host write failed",
599
- ),
389
+ thrown: error,
600
390
  },
601
391
  adapters,
602
392
  env.principalAuthority,
@@ -604,44 +394,14 @@ export async function dispatchPostAdmissionTurn<
604
394
  )) as { exitCode: number; admitted: A; terminal: T };
605
395
  }
606
396
  if (settled !== undefined && shouldPresent(settled)) {
607
- if (lastHostWriteFailure !== undefined) {
608
- if (settled.roleOutcome.kind === "failure") {
609
- // Existing failure terminal stays primary; last-host write is concurrent only.
610
- // shouldPresentSettled must not be read as "no existing failure".
611
- return (await presentControlledFailure(
612
- admitted,
613
- withLastHostWriteConcurrentFailure(
614
- {
615
- timedOut: result.timedOut,
616
- code: result.code,
617
- stderr: result.stderr,
618
- knownFailure: knownFailureFromSettledFailureOutcome(
619
- settled.roleOutcome,
620
- ),
621
- },
622
- lastHostWriteFailure.error,
623
- ),
624
- adapters,
625
- env.principalAuthority,
626
- io,
627
- )) as { exitCode: number; admitted: A; terminal: T };
628
- }
629
- // No existing failure terminal — last-host write is the sole public failure.
630
- // thrown key is always set so `throw undefined` stays own-key present.
631
- return (await presentControlledFailure(
632
- admitted,
633
- {
634
- timedOut: result.timedOut,
635
- code: result.code,
636
- stderr: result.stderr,
637
- thrown: lastHostWriteFailure.error,
638
- },
639
- adapters,
640
- env.principalAuthority,
641
- io,
642
- )) as { exitCode: number; admitted: A; terminal: T };
397
+ // This court sealed — drop open-court pointer so bare resume is run-scoped idempotent.
398
+ if (
399
+ settled.roleOutcome.kind === "accepted" &&
400
+ request.courtAttemptId !== undefined &&
401
+ request.courtAttemptId.length > 0
402
+ ) {
403
+ await clearCurrentCourt(admitted.runDirectory, request.courtAttemptId);
643
404
  }
644
- // last-host recorded after host returned a turn result (shared failure/retry/return fact).
645
405
  await markRunTerminal(admitted.runDirectory);
646
406
  io.stdout(formatTerminalResult(settled));
647
407
  return {
@@ -670,20 +430,14 @@ export async function dispatchPostAdmissionTurn<
670
430
  credential: credentialFailure,
671
431
  runDirectory: admitted.runDirectory,
672
432
  });
673
- const hostFailureInput: ControlledFailureInput = {
674
- timedOut: result.timedOut,
675
- code: result.code,
676
- stderr: result.stderr,
677
- ...controlledFailureInputFromResolution(resolution),
678
- };
679
433
  return (await presentControlledFailure(
680
434
  admitted,
681
- lastHostWriteFailure === undefined
682
- ? hostFailureInput
683
- : withLastHostWriteConcurrentFailure(
684
- hostFailureInput,
685
- lastHostWriteFailure.error,
686
- ),
435
+ {
436
+ timedOut: result.timedOut,
437
+ code: result.code,
438
+ stderr: result.stderr,
439
+ ...controlledFailureInputFromResolution(resolution),
440
+ },
687
441
  adapters,
688
442
  env.principalAuthority,
689
443
  io,
@@ -694,15 +448,63 @@ export async function dispatchPostAdmissionTurn<
694
448
  }
695
449
 
696
450
  /**
697
- * Shared resume continuation projection (#471 / #600 / #633): seat-table
698
- * model/engine/timeout axes, restored correlation, and the package resume
699
- * envelope (message optional). Seats add only their activation projection.
451
+ * Shared resume continuation projection (#471 / #600 / #633 / #637): seat-table
452
+ * model/engine/timeout axes, restored correlation, and either
453
+ * - manual resume: package envelope / optional caller message (unchanged), or
454
+ * - same-ticket summons: this turn's instruction + frozen attachment paths.
455
+ * Caller message and summons materials each keep their place: a resume message
456
+ * keeps manual-resume prompt semantics while open-court frozen attachments still
457
+ * ride; summons instruction is only the prompt base when no caller message.
458
+ * Seats add only their activation projection. Call prepareSummonsResumeMaterials
459
+ * first when request.summons carries attachment paths.
700
460
  */
701
461
  export function resumeTurnRequestProjectionOptions(
702
462
  admitted: AdmittedRoleInvocation,
703
463
  request: PublicResumeRequest,
704
464
  env: PostAdmissionEnv,
465
+ summonsPrepared?: {
466
+ readonly instruction: string;
467
+ readonly instructionEmpty: boolean;
468
+ readonly attachments: readonly { frozenPath: string }[];
469
+ },
705
470
  ): RoleTurnRequestProjectionOptions {
471
+ const engineMaterial = engineSessionMaterialFromOptions({
472
+ ...(env.engine === undefined ? {} : { engine: env.engine }),
473
+ packageRoot: env.packageRoot,
474
+ });
475
+ let prompt: string;
476
+ if (request.message !== undefined) {
477
+ // Manual resume caller-message semantics stay authoritative for the prompt:
478
+ // message present → base bytes unchanged (including blank/whitespace).
479
+ // Open-court frozen attachments still continue when present; attachment
480
+ // projection must not re-interpret the caller message as instructionEmpty.
481
+ if (
482
+ summonsPrepared !== undefined &&
483
+ summonsPrepared.attachments.length > 0
484
+ ) {
485
+ prompt = buildInstructionTransportPrompt(
486
+ {
487
+ instruction: request.message,
488
+ instructionEmpty: false,
489
+ attachments: summonsPrepared.attachments,
490
+ },
491
+ engineMaterial,
492
+ );
493
+ } else {
494
+ prompt = buildResumeContinuationPrompt({
495
+ packageRoot: env.packageRoot,
496
+ ...(env.engine === undefined ? {} : { engine: env.engine }),
497
+ message: request.message,
498
+ });
499
+ }
500
+ } else if (summonsPrepared !== undefined) {
501
+ prompt = buildInstructionTransportPrompt(summonsPrepared, engineMaterial);
502
+ } else {
503
+ prompt = buildResumeContinuationPrompt({
504
+ packageRoot: env.packageRoot,
505
+ ...(env.engine === undefined ? {} : { engine: env.engine }),
506
+ });
507
+ }
706
508
  return {
707
509
  packageRoot: env.packageRoot,
708
510
  home: env.home,
@@ -715,20 +517,67 @@ export function resumeTurnRequestProjectionOptions(
715
517
  : { correlationId: admitted.correlationId ?? env.correlationId }),
716
518
  continuation: {
717
519
  kind: "resume",
718
- prompt: buildResumeContinuationPrompt({
719
- packageRoot: env.packageRoot,
720
- ...(env.engine === undefined ? {} : { engine: env.engine }),
721
- ...(request.message === undefined ? {} : { message: request.message }),
722
- }),
520
+ prompt,
723
521
  },
724
522
  };
725
523
  }
726
524
 
525
+ function isAlreadyFrozenSummonsAttachment(
526
+ runDirectory: string,
527
+ attachmentPath: string,
528
+ ): boolean {
529
+ const absolute = isAbsolute(attachmentPath)
530
+ ? attachmentPath
531
+ : resolve(attachmentPath);
532
+ return pathContainedIn(join(runDirectory, "attachments"), absolute);
533
+ }
534
+
535
+ /**
536
+ * Freeze same-ticket summons attachments into the retained run directory (#637).
537
+ * No-op materials (no paths / instruction-only) skip the freeze.
538
+ * Paths already under this run's attachments/ are the accepted freeze identity —
539
+ * reuse them; do not re-freeze from external originals on bare resume.
540
+ * Manual resume never calls this — old attachment semantics stay intact.
541
+ */
542
+ export async function prepareSummonsResumeMaterials(
543
+ runDirectory: string,
544
+ summons: SameTicketSummonsMaterials | undefined,
545
+ ): Promise<
546
+ | {
547
+ readonly instruction: string;
548
+ readonly instructionEmpty: boolean;
549
+ readonly attachments: readonly { frozenPath: string }[];
550
+ }
551
+ | undefined
552
+ > {
553
+ if (summons === undefined) return undefined;
554
+ if (summons.instruction === undefined && (summons.attachmentPaths?.length ?? 0) === 0) {
555
+ return undefined;
556
+ }
557
+ const instruction = summons.instruction ?? "";
558
+ const instructionEmpty =
559
+ summons.instructionEmpty ?? instruction.trim() === "";
560
+ let attachments: readonly { frozenPath: string }[] = [];
561
+ if (summons.attachmentPaths !== undefined && summons.attachmentPaths.length > 0) {
562
+ const alreadyFrozen = summons.attachmentPaths.every((path) =>
563
+ isAlreadyFrozenSummonsAttachment(runDirectory, path),
564
+ );
565
+ attachments = alreadyFrozen
566
+ ? summons.attachmentPaths.map((frozenPath) => ({ frozenPath }))
567
+ : await freezeAttachmentsIntoRun(summons.attachmentPaths, runDirectory);
568
+ }
569
+ return { instruction, instructionEmpty, attachments };
570
+ }
571
+
727
572
  /**
728
573
  * Shared manual-resume orchestration for seats whose continuation is the
729
574
  * package resume envelope (#599 / #633): load → structural rejection → seat
730
575
  * turn projection → runPostAdmissionManualResume. Seat-owned loader validation,
731
576
  * turn builder, and adapters stay on the seat.
577
+ *
578
+ * Court open/recovery transaction (#637): under the existing writer lease,
579
+ * read currentCourt, judge seal, clear (bound to the judged court id), freeze,
580
+ * and record. No pre-lease clear or stale court-snapshot consumption.
732
581
  */
733
582
  export async function runPostAdmissionSeatResume<
734
583
  A extends AdmittedRoleInvocation,
@@ -737,14 +586,23 @@ export async function runPostAdmissionSeatResume<
737
586
  request: PublicResumeRequest;
738
587
  env: PostAdmissionEnv;
739
588
  io: CliIo;
740
- load: () => Promise<{ admitted: A }>;
741
- buildTurnRequest: (admitted: A) => RoleTurnRequest;
589
+ /** Load admitted state; receives the effective resume request (may carry rehydrated summons). */
590
+ load: (request: PublicResumeRequest) => Promise<{ admitted: A }>;
591
+ /** Build turn from admitted + effective request (summons ride existing projection). */
592
+ buildTurnRequest: (
593
+ admitted: A,
594
+ request: PublicResumeRequest,
595
+ ) => RoleTurnRequest | Promise<RoleTurnRequest>;
742
596
  adapters: PostAdmissionAdapters<A, T>;
743
597
  effectiveEngine?: string;
744
598
  }): Promise<{ exitCode: number; admitted?: A; terminal?: T }> {
599
+ let request = input.request;
600
+
601
+ // Load once for runDirectory / structural rejection; court identity is judged
602
+ // only after the writer lease is held (below).
745
603
  let loaded;
746
604
  try {
747
- loaded = await input.load();
605
+ loaded = await input.load(request);
748
606
  } catch (error) {
749
607
  if (error instanceof CliUsageError) {
750
608
  presentStructuralRejection(error, input.io);
@@ -752,14 +610,124 @@ export async function runPostAdmissionSeatResume<
752
610
  }
753
611
  throw error;
754
612
  }
755
- return await runPostAdmissionManualResume({
756
- admitted: loaded.admitted,
757
- env: input.env,
758
- io: input.io,
759
- request: input.buildTurnRequest(loaded.admitted),
760
- adapters: input.adapters,
761
- ...(input.effectiveEngine === undefined ? {} : { effectiveEngine: input.effectiveEngine }),
762
- });
613
+
614
+ // Entire court recovery / open path runs after lease. forceContinuation skips
615
+ // the pre-lease sealed short-circuit; when the after-lease builder leaves no
616
+ // courtAttemptId, manual resume still presents run-scoped sealed idempotence
617
+ // under the same held lease.
618
+ try {
619
+ return await runPostAdmissionManualResume({
620
+ admitted: loaded.admitted,
621
+ env: input.env,
622
+ io: input.io,
623
+ adapters: input.adapters,
624
+ ...(input.effectiveEngine === undefined
625
+ ? {}
626
+ : { effectiveEngine: input.effectiveEngine }),
627
+ forceContinuation: true,
628
+ buildRequestAfterLease: async () => {
629
+ let openCourtAttemptId: string | undefined;
630
+ // Build uses the admitted judged under this lease (rehydrated when open
631
+ // court materials ride). Settlement identity stays on the outer admitted.
632
+ let admittedForBuild = loaded.admitted;
633
+
634
+ // Bare resume: read + seal-judge + bound clear only under the held lease.
635
+ if (request.summons === undefined) {
636
+ const openCourt = await readCurrentCourt(admittedForBuild.runDirectory);
637
+ if (openCourt !== undefined) {
638
+ const sealedForOpen = await readSealedSubmission(
639
+ admittedForBuild.projectRoot,
640
+ admittedForBuild.runId,
641
+ {
642
+ home: homeFromRunDirectory(admittedForBuild.runDirectory),
643
+ attemptId: openCourt.courtAttemptId,
644
+ },
645
+ );
646
+ if (sealedForOpen === undefined) {
647
+ // Continue open court: same summons materials + existing courtAttemptId.
648
+ // Caller message (if any) stays on the request — projection keeps it.
649
+ openCourtAttemptId = openCourt.courtAttemptId;
650
+ request = {
651
+ runId: request.runId,
652
+ ...(request.message === undefined
653
+ ? {}
654
+ : { message: request.message }),
655
+ ...(openCourt.summons === undefined
656
+ ? {}
657
+ : { summons: openCourt.summons }),
658
+ };
659
+ if (openCourt.summons !== undefined) {
660
+ const reloaded = await input.load(request);
661
+ admittedForBuild = reloaded.admitted;
662
+ }
663
+ } else {
664
+ // Open court already sealed — clear only the court id just judged.
665
+ await clearCurrentCourt(
666
+ admittedForBuild.runDirectory,
667
+ openCourt.courtAttemptId,
668
+ );
669
+ }
670
+ }
671
+ }
672
+
673
+ // Freeze external paths once; rewrite summons to the frozen identity so
674
+ // currentCourt + later bare resume reuse the accepted snapshot.
675
+ if (request.summons !== undefined) {
676
+ const prepared = await prepareSummonsResumeMaterials(
677
+ admittedForBuild.runDirectory,
678
+ request.summons,
679
+ );
680
+ if (
681
+ prepared !== undefined &&
682
+ (request.summons.attachmentPaths?.length ?? 0) > 0
683
+ ) {
684
+ request = {
685
+ ...request,
686
+ summons: {
687
+ ...request.summons,
688
+ attachmentPaths: prepared.attachments.map(
689
+ (attachment) => attachment.frozenPath,
690
+ ),
691
+ },
692
+ };
693
+ }
694
+ }
695
+
696
+ let turnRequest = await input.buildTurnRequest(admittedForBuild, request);
697
+
698
+ // Court path only when continuing an open court or opening a new summons court.
699
+ // Bare resume with no open court (or cleared sealed pointer) keeps no
700
+ // courtAttemptId so run-scoped sealed idempotence can present under lease.
701
+ if (openCourtAttemptId !== undefined || request.summons !== undefined) {
702
+ const courtAttemptId =
703
+ openCourtAttemptId ??
704
+ (turnRequest.courtAttemptId !== undefined &&
705
+ turnRequest.courtAttemptId.length > 0
706
+ ? turnRequest.courtAttemptId
707
+ : randomUUID());
708
+ turnRequest = { ...turnRequest, courtAttemptId };
709
+ if (openCourtAttemptId === undefined) {
710
+ const court: CurrentCourtState = {
711
+ courtAttemptId,
712
+ ...(request.summons === undefined
713
+ ? {}
714
+ : { summons: request.summons }),
715
+ };
716
+ await recordCurrentCourt(admittedForBuild.runDirectory, court);
717
+ }
718
+ }
719
+ return turnRequest;
720
+ },
721
+ });
722
+ } catch (error) {
723
+ // Open-court rehydrate load under lease may still surface seat structural
724
+ // rejection (e.g. notary rejects caller message) — same exit face as pre-lease.
725
+ if (error instanceof CliUsageError) {
726
+ presentStructuralRejection(error, input.io);
727
+ return { exitCode: 2 };
728
+ }
729
+ throw error;
730
+ }
763
731
  }
764
732
 
765
733
  /**
@@ -858,37 +826,22 @@ export async function runPostAdmissionResumable<
858
826
  }
859
827
 
860
828
  /**
861
- * Shared post-admission manual resume path: acquire writer lease and dispatch turn.
862
- * When the submission ledger is already sealed, project that accepted terminal
863
- * idempotently — do not dispatch a doomed turn that would append
864
- * post-seal-anomaly and erase the sealed read (#599; keep #416 open load).
829
+ * Single authority for manual-resume run-scoped sealed-accepted presentation
830
+ * (#599 / #648 / #672 / #637). Used both before lease (eager request path) and
831
+ * after court-recovery builder under lease when no courtAttemptId remains.
832
+ * Returns undefined to fall through to dispatch; never rebuilds the gate.
865
833
  */
866
- export async function runPostAdmissionManualResume<
834
+ async function presentSealedAcceptedManualResumeIfAny<
867
835
  A extends AdmittedRoleInvocation,
868
- T extends TerminalResult = TerminalResult,
836
+ T extends TerminalResult,
869
837
  >(input: {
870
838
  admitted: A;
871
839
  env: PostAdmissionEnv;
872
840
  io: CliIo;
873
- request: RoleTurnRequest;
874
841
  adapters: PostAdmissionAdapters<A, T>;
875
- /** Seat-table engine axis on resume (#600); written onto invocation.json when present. */
876
- effectiveEngine?: string;
877
- }): Promise<{
878
- exitCode: number;
879
- admitted?: A;
880
- terminal?: T;
881
- staleWriterLeaseReclaimed?: true;
882
- }> {
883
- const { admitted, env, io, request, adapters, effectiveEngine } = input;
884
- // #617 DK-3: manual resume writes the live seat/env model (same as new legs).
885
- const effectiveModel = env.model;
886
- const shouldPresent =
887
- adapters.shouldPresentSettled ??
888
- ((terminal: T) => isLawfulTypedTerminalOutcome(terminal.roleOutcome));
889
-
890
- // Sealed accepted receipt only — audit_escalation / residual failure must not
891
- // short-circuit; those still need a real continuation turn.
842
+ shouldPresent: (terminal: T) => boolean;
843
+ }): Promise<{ exitCode: number; admitted: A; terminal?: T } | undefined> {
844
+ const { admitted, env, io, adapters, shouldPresent } = input;
892
845
  try {
893
846
  const existing = await adapters.trySettle(admitted, env.principalAuthority);
894
847
  if (
@@ -905,9 +858,8 @@ export async function runPostAdmissionManualResume<
905
858
  };
906
859
  }
907
860
  } catch (error) {
908
- // Settlement-owned sealed disposition (#648 / #599 / #672): sealed accepted +
909
- // publication/settle throw fail closed without redispatch; authority failure
910
- // preserves true cause. Manual resume only presents — does not rebuild the gate.
861
+ // Settlement-owned sealed disposition: sealed accepted + publication/settle
862
+ // throw fail closed without redispatch; authority failure preserves cause.
911
863
  const disposition = await sealedAcceptanceRedispatchDisposition(admitted);
912
864
  if (disposition.kind === "block") {
913
865
  return (await presentControlledFailure(
@@ -930,6 +882,77 @@ export async function runPostAdmissionManualResume<
930
882
  // proof of seal; fall through to dispatch so the attempt path can settle
931
883
  // or fail honestly.
932
884
  }
885
+ return undefined;
886
+ }
887
+
888
+ /**
889
+ * Shared post-admission manual resume path: acquire writer lease and dispatch turn.
890
+ * When the submission ledger is already sealed, project that accepted terminal
891
+ * idempotently — do not dispatch a doomed turn that would append
892
+ * post-seal-anomaly and erase the sealed read (#599; keep #416 open load).
893
+ * Same-ticket re-summons (#637) pass forceContinuation + courtAttemptId so a new
894
+ * court turn still runs with this summons' materials despite a prior sealed
895
+ * acceptance, while submission-ledger sole-final stays per-attempt.
896
+ */
897
+ export async function runPostAdmissionManualResume<
898
+ A extends AdmittedRoleInvocation,
899
+ T extends TerminalResult = TerminalResult,
900
+ >(input: {
901
+ admitted: A;
902
+ env: PostAdmissionEnv;
903
+ io: CliIo;
904
+ /** Eager turn request (non-court path / seats that build before lease). */
905
+ request?: RoleTurnRequest;
906
+ /**
907
+ * Court-opening path (#637): build turn + freeze + record currentCourt only
908
+ * after the writer lease is held. Mutually exclusive with a prebuilt request
909
+ * when forceContinuation is set.
910
+ */
911
+ buildRequestAfterLease?: () => Promise<RoleTurnRequest>;
912
+ adapters: PostAdmissionAdapters<A, T>;
913
+ /** Seat-table engine axis on resume (#600); written onto invocation.json when present. */
914
+ effectiveEngine?: string;
915
+ /** Same-ticket re-summons: skip sealed-accepted short-circuit and dispatch. */
916
+ forceContinuation?: boolean;
917
+ }): Promise<{
918
+ exitCode: number;
919
+ admitted?: A;
920
+ terminal?: T;
921
+ staleWriterLeaseReclaimed?: true;
922
+ }> {
923
+ const {
924
+ admitted,
925
+ env,
926
+ io,
927
+ adapters,
928
+ effectiveEngine,
929
+ forceContinuation,
930
+ buildRequestAfterLease,
931
+ } = input;
932
+ let request = input.request;
933
+ // #617 DK-3: manual resume writes the live seat/env model (same as new legs).
934
+ const effectiveModel = env.model;
935
+ const shouldPresent =
936
+ adapters.shouldPresentSettled ??
937
+ ((terminal: T) => isLawfulTypedTerminalOutcome(terminal.roleOutcome));
938
+ const sealedIdempotenceInput = {
939
+ admitted,
940
+ env,
941
+ io,
942
+ adapters,
943
+ shouldPresent,
944
+ } as const;
945
+
946
+ // Sealed accepted receipt only — audit_escalation / residual failure must not
947
+ // short-circuit; those still need a real continuation turn.
948
+ // Same-ticket re-summons / court recovery forceContinuation skips the pre-lease
949
+ // face; post-builder reuses the same presenter when no courtAttemptId remains.
950
+ if (forceContinuation !== true && request !== undefined) {
951
+ const presented = await presentSealedAcceptedManualResumeIfAny(
952
+ sealedIdempotenceInput,
953
+ );
954
+ if (presented !== undefined) return presented;
955
+ }
933
956
 
934
957
  let lease: RunWriterLease;
935
958
  let staleWriterLeaseReclaimed: true | undefined;
@@ -957,24 +980,65 @@ export async function runPostAdmissionManualResume<
957
980
  throw error;
958
981
  }
959
982
 
960
- const result = await dispatchPostAdmissionTurn({
961
- admitted,
962
- env: {
963
- ...env,
964
- ...(effectiveModel === undefined ? {} : { model: effectiveModel }),
965
- ...(admitted.correlationId === undefined ? {} : { correlationId: admitted.correlationId }),
966
- },
967
- io,
968
- request,
969
- lease,
970
- adapters,
971
- ...(effectiveEngine === undefined ? {} : { effectiveEngine }),
972
- });
973
- if (result.terminal !== undefined) {
974
- (result.terminal as { autoResumeCount?: number }).autoResumeCount = 0;
983
+ // Court open/recovery under held lease until dispatch owns release (finally
984
+ // below). Builder, sealed presenter, and any throw on this seam must release
985
+ // here — dispatch's finally only runs after handoff.
986
+ let handedOffToDispatch = false;
987
+ try {
988
+ if (request === undefined) {
989
+ if (buildRequestAfterLease === undefined) {
990
+ throw new Error(
991
+ "runPostAdmissionManualResume requires request or buildRequestAfterLease",
992
+ );
993
+ }
994
+ request = await buildRequestAfterLease();
995
+
996
+ if (
997
+ request.courtAttemptId === undefined ||
998
+ request.courtAttemptId.length === 0
999
+ ) {
1000
+ const presented = await presentSealedAcceptedManualResumeIfAny(
1001
+ sealedIdempotenceInput,
1002
+ );
1003
+ if (presented !== undefined) {
1004
+ return {
1005
+ ...presented,
1006
+ ...(staleWriterLeaseReclaimed === true
1007
+ ? { staleWriterLeaseReclaimed: true as const }
1008
+ : {}),
1009
+ };
1010
+ }
1011
+ }
1012
+ }
1013
+
1014
+ handedOffToDispatch = true;
1015
+ const result = await dispatchPostAdmissionTurn({
1016
+ admitted,
1017
+ env: {
1018
+ ...env,
1019
+ ...(effectiveModel === undefined ? {} : { model: effectiveModel }),
1020
+ ...(admitted.correlationId === undefined
1021
+ ? {}
1022
+ : { correlationId: admitted.correlationId }),
1023
+ },
1024
+ io,
1025
+ request,
1026
+ lease,
1027
+ adapters,
1028
+ ...(effectiveEngine === undefined ? {} : { effectiveEngine }),
1029
+ });
1030
+ if (result.terminal !== undefined) {
1031
+ (result.terminal as { autoResumeCount?: number }).autoResumeCount = 0;
1032
+ }
1033
+ return {
1034
+ ...result,
1035
+ ...(staleWriterLeaseReclaimed === true
1036
+ ? { staleWriterLeaseReclaimed: true as const }
1037
+ : {}),
1038
+ };
1039
+ } finally {
1040
+ if (!handedOffToDispatch) {
1041
+ await lease.release();
1042
+ }
975
1043
  }
976
- return {
977
- ...result,
978
- ...(staleWriterLeaseReclaimed === true ? { staleWriterLeaseReclaimed: true as const } : {}),
979
- };
980
1044
  }