@akagilnc/pi-workflow-roles 0.1.4620 → 0.1.4631

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.
@@ -22,6 +22,7 @@ import {
22
22
  resolveActivationLedgerHome,
23
23
  } from "./activation-ledger-topology.ts";
24
24
  import { listBookRunDirectories } from "./role-run-placement.ts";
25
+ import { autopsyWriterLock } from "./public-cli/run-lifecycle.ts";
25
26
  import { readRunTicketNumber } from "./run-ticket-number.ts";
26
27
  import {
27
28
  extractSessionModelSequence,
@@ -87,6 +88,64 @@ async function readExistingRunLifecycleState(
87
88
  }
88
89
  }
89
90
 
91
+ /**
92
+ * #855 lease check projection for ghost legs — index facts only (ADR 0049).
93
+ * Does not rename autopsy verdicts into stronger claims than observed.
94
+ */
95
+ export type AnalystGhostLeaseCheck =
96
+ | { readonly kind: "holder-dead"; readonly pid: number }
97
+ | { readonly kind: "no-lease" }
98
+ | { readonly kind: "unverifiable"; readonly reason: "unreadable" | "unparseable" };
99
+
100
+ export type AnalystGhostLeg = {
101
+ readonly runId: string;
102
+ readonly runState: "admitted" | "running";
103
+ readonly leaseCheck: AnalystGhostLeaseCheck;
104
+ };
105
+
106
+ function ghostLeaseCheckFromAutopsy(
107
+ autopsy: Awaited<ReturnType<typeof autopsyWriterLock>>,
108
+ ): AnalystGhostLeaseCheck | undefined {
109
+ switch (autopsy.verdict) {
110
+ case "alive":
111
+ return undefined;
112
+ case "dead":
113
+ return { kind: "holder-dead", pid: autopsy.pid };
114
+ case "absent":
115
+ return { kind: "no-lease" };
116
+ case "unknown":
117
+ return {
118
+ kind: "unverifiable",
119
+ reason: autopsy.reason === "unreadable" ? "unreadable" : "unparseable",
120
+ };
121
+ }
122
+ }
123
+
124
+ /**
125
+ * Classify one admitted|running run against writer lease liveness (#855).
126
+ * Alive holder → still in-flight (omit). Otherwise → ghost report fact only.
127
+ */
128
+ async function classifyGhostCandidate(input: {
129
+ readonly runId: string;
130
+ readonly runState: "admitted" | "running";
131
+ readonly runDirectory: string;
132
+ }): Promise<
133
+ | { readonly kind: "live" }
134
+ | { readonly kind: "ghost"; readonly leg: AnalystGhostLeg }
135
+ > {
136
+ const autopsy = await autopsyWriterLock(join(input.runDirectory, "writer.lock"));
137
+ const leaseCheck = ghostLeaseCheckFromAutopsy(autopsy);
138
+ if (leaseCheck === undefined) return { kind: "live" };
139
+ return {
140
+ kind: "ghost",
141
+ leg: {
142
+ runId: input.runId,
143
+ runState: input.runState,
144
+ leaseCheck,
145
+ },
146
+ };
147
+ }
148
+
90
149
  function parseRunDirectoryName(
91
150
  name: string,
92
151
  ): { runId: string; role: string } | undefined {
@@ -335,6 +394,8 @@ export type AnalystScopedRunScan = {
335
394
  readonly unreadable: readonly AnalystUnreadableRun[];
336
395
  /** C4: typed-ticket admits whose projectRoot mechanical key conflicted. */
337
396
  readonly scopeConflicts: readonly AnalystScopeConflict[];
397
+ /** #855: admitted|running runs whose writer lease cannot prove a live holder. */
398
+ readonly ghostLegs: readonly AnalystGhostLeg[];
338
399
  };
339
400
 
340
401
  async function classifyScopedRun(input: {
@@ -343,9 +404,20 @@ async function classifyScopedRun(input: {
343
404
  readonly role: string;
344
405
  readonly runDirectory: string;
345
406
  }): Promise<
346
- | { readonly kind: "readable"; readonly facts: AnalystReadableRunFacts }
347
- | { readonly kind: "unreadable"; readonly entry: AnalystUnreadableRun }
407
+ | {
408
+ readonly kind: "readable";
409
+ readonly facts: AnalystReadableRunFacts;
410
+ /** #855: present terminal still lists ghost when lease cannot prove live. */
411
+ readonly ghostLeg?: AnalystGhostLeg;
412
+ }
413
+ | {
414
+ readonly kind: "unreadable";
415
+ readonly entry: AnalystUnreadableRun;
416
+ /** #855: unreadable terminal still lists ghost when lease cannot prove live. */
417
+ readonly ghostLeg?: AnalystGhostLeg;
418
+ }
348
419
  | { readonly kind: "live" }
420
+ | { readonly kind: "ghost"; readonly leg: AnalystGhostLeg }
349
421
  > {
350
422
  const missingSources: AnalystMissingSource[] = [];
351
423
  const reasons: string[] = [];
@@ -434,13 +506,31 @@ async function classifyScopedRun(input: {
434
506
 
435
507
  // 3) typed terminal artifact — absence is no-receipt only for terminal runs.
436
508
  // Live admitted/running/resumable runs (existing run-state) are not dead legs.
509
+ // #855: writer-lease ghost check is independent of terminal artifact status.
510
+ // present → still emit metrics; unreadable → still enter missingSources;
511
+ // admitted|running + non-live lease → still list in ghostLegs (ticket #4).
512
+ const lifecycle = await readExistingRunLifecycleState(input.runDirectory);
513
+ let ghostLeg: AnalystGhostLeg | undefined;
514
+ if (lifecycle === "admitted" || lifecycle === "running") {
515
+ const ghost = await classifyGhostCandidate({
516
+ runId: input.runId,
517
+ runState: lifecycle,
518
+ runDirectory: input.runDirectory,
519
+ });
520
+ if (ghost.kind === "ghost") ghostLeg = ghost.leg;
521
+ }
522
+
437
523
  try {
438
524
  const artifact = await readRunTerminalArtifact(input.runDirectory);
439
525
  if (artifact.status === "unreadable") {
440
526
  missingSources.push("terminal-artifact");
441
527
  reasons.push(`${artifact.file}: ${artifact.reason}`);
442
528
  } else if (artifact.status === "absent") {
443
- const lifecycle = await readExistingRunLifecycleState(input.runDirectory);
529
+ if (lifecycle === "admitted" || lifecycle === "running") {
530
+ // No terminal yet: ghost report only, or live in-flight omit.
531
+ if (ghostLeg !== undefined) return { kind: "ghost", leg: ghostLeg };
532
+ return { kind: "live" };
533
+ }
444
534
  if (lifecycle !== undefined && LIVE_RUN_STATES.has(lifecycle)) {
445
535
  return { kind: "live" };
446
536
  }
@@ -479,6 +569,7 @@ async function classifyScopedRun(input: {
479
569
  firstFrameAt,
480
570
  lastFrameAt,
481
571
  },
572
+ ...(ghostLeg !== undefined ? { ghostLeg } : {}),
482
573
  };
483
574
  }
484
575
 
@@ -510,6 +601,7 @@ async function classifyScopedRun(input: {
510
601
  firstFrameAt: { status: "present", at: frameSpan.startedAt },
511
602
  lastFrameAt: { status: "present", at: frameSpan.endedAt },
512
603
  },
604
+ ...(ghostLeg !== undefined ? { ghostLeg } : {}),
513
605
  };
514
606
  }
515
607
 
@@ -525,6 +617,7 @@ async function classifyScopedRun(input: {
525
617
  models,
526
618
  gateCycles,
527
619
  },
620
+ ...(ghostLeg !== undefined ? { ghostLeg } : {}),
528
621
  };
529
622
  }
530
623
 
@@ -588,11 +681,12 @@ export async function scanAnalystIssueRuns(input: {
588
681
  }
589
682
 
590
683
  if (bookNames.length === 0) {
591
- return { runs: [], unreadable: [], scopeConflicts: [] };
684
+ return { runs: [], unreadable: [], scopeConflicts: [], ghostLegs: [] };
592
685
  }
593
686
 
594
687
  const runs: AnalystReadableRunFacts[] = [];
595
688
  const unreadable: AnalystUnreadableRun[] = [];
689
+ const ghostLegs: AnalystGhostLeg[] = [];
596
690
  // scopeConflicts retained on the scan face for page envelope compat; book×ticket
597
691
  // scope no longer emits projectRoot dual-key conflicts on the CLI path.
598
692
  const scopeConflicts: AnalystScopeConflict[] = [];
@@ -638,9 +732,19 @@ export async function scanAnalystIssueRuns(input: {
638
732
  role: parsed.role,
639
733
  runDirectory,
640
734
  });
641
- if (classified.kind === "readable") runs.push(classified.facts);
642
- else if (classified.kind === "unreadable") unreadable.push(classified.entry);
735
+ if (classified.kind === "readable") {
736
+ runs.push(classified.facts);
737
+ if (classified.ghostLeg !== undefined) ghostLegs.push(classified.ghostLeg);
738
+ } else if (classified.kind === "unreadable") {
739
+ unreadable.push(classified.entry);
740
+ if (classified.ghostLeg !== undefined) ghostLegs.push(classified.ghostLeg);
741
+ } else if (classified.kind === "ghost") {
742
+ ghostLegs.push(classified.leg);
743
+ }
643
744
  // live in-flight runs are omitted from legs and unreadable (not failure/death).
745
+ // #855 ghost legs are reported separately — not counted as in-flight.
746
+ // Lease check is independent of terminal artifact: present/unreadable may
747
+ // still carry a ghostLeg alongside metrics or missingSources.
644
748
  }
645
749
  }
646
750
 
@@ -648,5 +752,6 @@ export async function scanAnalystIssueRuns(input: {
648
752
  runs,
649
753
  unreadable,
650
754
  scopeConflicts,
755
+ ghostLegs,
651
756
  };
652
757
  }
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * 司天台唯一 Pi session 记录落盘入口(ADR 0065)。
3
3
  * 调用方只声明自己是谁的什么;落点由候簿拓扑算出,签名不含任何落点/路径参数。
4
- * 「谁调了谁」复用 Pi parentSession + ADR 0047 correlation,不新增 caller 字段。
4
+ * 「谁调了谁」复用 Pi parentSession,不新增 caller 字段(#855 两面对账 correlation 键已删)。
5
5
  */
6
6
  import {
7
7
  closeSync,
@@ -470,9 +470,6 @@ export function createPiRoleTurnHost(config: PiRoleTurnHostConfig): RoleTurnHost
470
470
  ) {
471
471
  env.AK_ROLE_AUDITOR_SUBJECT = process.env.AK_ROLE_AUDITOR_SUBJECT;
472
472
  }
473
- if (request.correlationId !== undefined && request.correlationId.trim() !== "") {
474
- env.AK_CORRELATION_ID = request.correlationId;
475
- }
476
473
  if (
477
474
  config.recordLaunchedRolePackageIdentity !== undefined &&
478
475
  config.observeLaunchedRolePackageIdentity !== undefined
@@ -32,6 +32,7 @@ import {
32
32
  type RunWriterLease,
33
33
  } from "./run-lifecycle.ts";
34
34
  import { parseAutoResumeLimit } from "./config.ts";
35
+ import { processCancelSignalName } from "./process-cancel.ts";
35
36
  import { isLawfulTypedTerminalOutcome, formatTerminalResult, type TerminalArtifactRef, type TerminalResult, type TerminalRoleName } from "./terminal.ts";
36
37
  import {
37
38
  attachRecordedSubmissions,
@@ -473,6 +474,11 @@ export async function runWithAutoResumeLoop<
473
474
  * before the first dispatch instead of silently bypassing the ceiling comparison.
474
475
  */
475
476
  autoResumeLimit?: number | undefined;
477
+ /**
478
+ * #855: process-cancel signal. Once aborted by a catchable process signal,
479
+ * the loop must not dispatch another turn or spawn another child.
480
+ */
481
+ signal?: AbortSignal;
476
482
  buildInitialPayload: () => TPayload;
477
483
  buildResumePayload: () => TPayload;
478
484
  dispatch: (payload: TPayload, lease: RunWriterLease, isFirst: boolean, attemptIo: CliIo) => Promise<T>;
@@ -578,6 +584,11 @@ export async function runWithAutoResumeLoop<
578
584
  if (terminal !== undefined) presentTerminal(terminal, options.io);
579
585
  return result;
580
586
  }
587
+ // #855: process cancel — stop even if dispatch forgot skipAutoResume.
588
+ if (processCancelSignalName(options.signal) !== undefined) {
589
+ if (terminal !== undefined) presentTerminal(terminal, options.io);
590
+ return result;
591
+ }
581
592
  }
582
593
 
583
594
  if (result !== undefined) {
@@ -616,6 +627,29 @@ export async function runWithAutoResumeLoop<
616
627
  terminal,
617
628
  } as T;
618
629
  }
630
+ // #855: process cancel on the throw path — do not re-dispatch; name the signal.
631
+ const cancelName = processCancelSignalName(options.signal);
632
+ if (cancelName !== undefined) {
633
+ const terminal = await attachDispatchExceptionTerminal(
634
+ options.admitted,
635
+ dispatchExceptionFailureTerminal({
636
+ role: options.admitted.role,
637
+ runId: options.admitted.runId,
638
+ causeError: lastThrownError,
639
+ errorFiles: retainedErrorFiles,
640
+ autoResumeAttempts,
641
+ endReason: `ak-role terminated by ${cancelName}`,
642
+ everyAttemptThrew,
643
+ }),
644
+ options.io,
645
+ );
646
+ await finalizeExceptionRunBestEffort(options.admitted.runDirectory, options.io);
647
+ presentTerminal(terminal, options.io);
648
+ return {
649
+ exitCode: 1,
650
+ terminal,
651
+ } as T;
652
+ }
619
653
  if (!(await isPrincipalAvailable(options.admitted.principal))) {
620
654
  const terminal = await attachDispatchExceptionTerminal(
621
655
  options.admitted,
@@ -305,6 +305,11 @@ export type CliEnv = {
305
305
  * skip same-ticket auto-resume. Not a public flag — the verb is the choice.
306
306
  */
307
307
  freshSummons?: true;
308
+ /**
309
+ * #855: process-level cancel (SIGTERM/SIGINT/SIGHUP). Nested turns receive
310
+ * the same signal so hosts can gracefully stop children.
311
+ */
312
+ signal?: AbortSignal;
308
313
  };
309
314
 
310
315
 
@@ -413,6 +418,7 @@ function createRoleEnvironment(
413
418
  ? {}
414
419
  : { autoResumeLimit: options.config.autoResumeLimit }),
415
420
  ...(env.freshSummons === true ? { freshSummons: true as const } : {}),
421
+ ...(env.signal === undefined ? {} : { signal: env.signal }),
416
422
  };
417
423
  }
418
424
 
@@ -1168,6 +1174,30 @@ async function runConfigCommand(
1168
1174
  throw new CliUsageError(`unknown config subcommand: ${args[0]}`);
1169
1175
  }
1170
1176
 
1177
+ /**
1178
+ * #855: process-cancel handlers only for commands that run a role turn
1179
+ * (callable roles / resume / new). analyst/roles/config/help keep Node's
1180
+ * default SIGINT/SIGTERM termination — installing handlers would swallow Ctrl+C.
1181
+ */
1182
+ export function commandNeedsProcessCancel(argv: readonly string[]): boolean {
1183
+ // Decision-time parse must not escape the CLI usage boundary. Malformed
1184
+ // global flags throw CliUsageError here; return false and let runAkRole
1185
+ // present the same structured usage reject (exit 2). Handlers are moot
1186
+ // when parse fails — install-or-not has no subsequent effect.
1187
+ let parsed: ParsedGlobal;
1188
+ try {
1189
+ parsed = parseArgv(argv);
1190
+ } catch (error) {
1191
+ if (error instanceof CliUsageError) return false;
1192
+ throw error;
1193
+ }
1194
+ if (parsed.help || parsed.command === undefined || parsed.command === "help") {
1195
+ return false;
1196
+ }
1197
+ if (parsed.command === "resume" || parsed.command === "new") return true;
1198
+ return isPublicCallableRole(parsed.command);
1199
+ }
1200
+
1171
1201
  export async function runAkRole(
1172
1202
  argv: readonly string[],
1173
1203
  env: CliEnv,
@@ -34,6 +34,28 @@ process.env.AK_ROLE_PACKAGE_ROOT = packageRoot;
34
34
  // The host-provided runtime must be resolvable before the CLI module graph loads it.
35
35
  ensureHostPiRuntimeResolvable(packageRoot);
36
36
 
37
- const { runAkRole } = await import("./cli.ts");
38
- const result = await runAkRole(process.argv.slice(2), { packageRoot });
39
- process.exitCode = result.exitCode;
37
+ const { runAkRole, commandNeedsProcessCancel } = await import("./cli.ts");
38
+ const { installProcessCancelHandlers } = await import("./process-cancel.ts");
39
+
40
+ // #855: catchable signals only for role-turn commands. analyst/roles/config/help
41
+ // keep Node default termination — global handlers would swallow Ctrl+C.
42
+ const argv = process.argv.slice(2);
43
+ const processCancel = commandNeedsProcessCancel(argv)
44
+ ? installProcessCancelHandlers()
45
+ : undefined;
46
+ try {
47
+ const result = await runAkRole(argv, {
48
+ packageRoot,
49
+ ...(processCancel === undefined ? {} : { signal: processCancel.signal }),
50
+ });
51
+ // Signal termination is never a successful public exit, even if a seat
52
+ // somehow returned zero before settlement folded the abort.
53
+ process.exitCode =
54
+ processCancel !== undefined &&
55
+ processCancel.receivedSignal() !== undefined &&
56
+ result.exitCode === 0
57
+ ? 1
58
+ : result.exitCode;
59
+ } finally {
60
+ processCancel?.dispose();
61
+ }
@@ -87,6 +87,11 @@ import {
87
87
  type TypedProviderHttpObservation,
88
88
  type WriterLeaseDiagnosticKind,
89
89
  } from "./run-lifecycle.ts";
90
+ import {
91
+ processCancelDiagnostic,
92
+ processCancelSignalName,
93
+ type CatchableProcessSignal,
94
+ } from "./process-cancel.ts";
90
95
  import { homeFromRunDirectory } from "../activation-ledger-topology.ts";
91
96
  import {
92
97
  attemptProducedFreshSubmission,
@@ -119,6 +124,63 @@ import {
119
124
  TurnDispatchedFailure,
120
125
  } from "./auto-resume.ts";
121
126
 
127
+ /** #855: process-cancel settlement never re-enters auto-resume. */
128
+ function withProcessCancelSkipAutoResume<T extends object>(
129
+ result: T,
130
+ signal: AbortSignal | undefined,
131
+ ): T & { skipAutoResume?: true } {
132
+ if (processCancelSignalName(signal) === undefined) {
133
+ return result as T & { skipAutoResume?: true };
134
+ }
135
+ return { ...result, skipAutoResume: true as const };
136
+ }
137
+
138
+ /**
139
+ * #855: turn already started, then a catchable process signal arrived before
140
+ * lawful seal — settle as named cancel failure and skip auto-resume.
141
+ */
142
+ async function settleProcessCancelAfterTurn<A extends AdmittedRoleInvocation, T extends TerminalResult>(input: {
143
+ admitted: A;
144
+ cancelName: CatchableProcessSignal;
145
+ result: RoleTurnResult;
146
+ adapters: PostAdmissionAdapters<A, T>;
147
+ env: PostAdmissionEnv;
148
+ io: CliIo;
149
+ persistRunState: boolean;
150
+ deferredPersist: { needsPersist?: true } | Record<string, never>;
151
+ invocationScopeId: string | undefined;
152
+ }): Promise<{
153
+ exitCode: number;
154
+ admitted: A;
155
+ terminal?: T;
156
+ skipAutoResume?: true;
157
+ turnDispatched?: true;
158
+ needsPersist?: true;
159
+ }> {
160
+ const failed = await settleAfterTurnStarted(
161
+ input.admitted,
162
+ withEngineDetourInvocationScope(
163
+ {
164
+ timedOut: input.result.timedOut,
165
+ code: input.result.code,
166
+ stderr: input.result.stderr,
167
+ knownDiagnostic: processCancelDiagnostic(input.cancelName),
168
+ },
169
+ input.invocationScopeId,
170
+ ),
171
+ input.adapters,
172
+ input.env.principalAuthority,
173
+ input.io,
174
+ input.persistRunState,
175
+ );
176
+ return {
177
+ ...failed,
178
+ turnDispatched: true as const,
179
+ skipAutoResume: true as const,
180
+ ...input.deferredPersist,
181
+ };
182
+ }
183
+
122
184
  /**
123
185
  * Nested station-child role already finished its own call-local loop.
124
186
  * Parent records that failure once and must not auto-resume into a re-summon
@@ -587,6 +649,38 @@ export async function dispatchPostAdmissionTurn<
587
649
  const finishAfterTurn = async (result: DispatchOutcome): Promise<DispatchOutcome> => {
588
650
  if (afterDispatchApplied) return result;
589
651
  afterDispatchApplied = true;
652
+ // #855: re-read cancel before afterDispatch finalization — never present
653
+ // lawful success after a catchable process signal. skipRunStateWrite: run-state
654
+ // may already be terminal from a prior lawful persist; do not reopen as resumable.
655
+ const cancelBeforeFinish = processCancelSignalName(env.signal);
656
+ if (
657
+ cancelBeforeFinish !== undefined &&
658
+ result.terminal !== undefined &&
659
+ isLawfulTypedTerminalOutcome(result.terminal.roleOutcome)
660
+ ) {
661
+ result = {
662
+ ...(await settleAfterTurnStarted(
663
+ admitted,
664
+ withEngineDetourInvocationScope(
665
+ {
666
+ timedOut: false,
667
+ code: null,
668
+ stderr: "",
669
+ knownDiagnostic: processCancelDiagnostic(cancelBeforeFinish),
670
+ skipRunStateWrite: true,
671
+ },
672
+ request.invocationScopeId,
673
+ ),
674
+ adapters,
675
+ env.principalAuthority,
676
+ io,
677
+ persistRunState,
678
+ )),
679
+ turnDispatched: true as const,
680
+ skipAutoResume: true as const,
681
+ ...deferredPersist,
682
+ };
683
+ }
590
684
  try {
591
685
  // #858: an unbound seat may assert its ticket on the existing receipt.
592
686
  // Read the original accepted payload; do not rewrite it, infer from prose,
@@ -788,20 +882,29 @@ export async function dispatchPostAdmissionTurn<
788
882
  try {
789
883
  result = await env.roleTurnHost.executeTurn(turnRequest);
790
884
  } catch (error) {
885
+ const processCancelName = processCancelSignalName(env.signal);
791
886
  const settled = await settleAfterTurnStarted(
792
887
  admitted,
793
888
  withEngineDetourInvocationScope({
794
889
  timedOut: false,
795
890
  code: null,
796
891
  stderr: "",
797
- thrown: error,
892
+ thrown:
893
+ processCancelName === undefined
894
+ ? error
895
+ : new Error(processCancelDiagnostic(processCancelName), { cause: error }),
798
896
  }, request.invocationScopeId),
799
897
  adapters,
800
898
  env.principalAuthority,
801
899
  io,
802
900
  persistRunState,
803
901
  );
804
- return await finishAfterTurn({ ...settled, turnDispatched: true as const, ...deferredPersist });
902
+ return await finishAfterTurn(
903
+ withProcessCancelSkipAutoResume(
904
+ { ...settled, turnDispatched: true as const, ...deferredPersist },
905
+ env.signal,
906
+ ),
907
+ );
805
908
  }
806
909
 
807
910
  // `result.stderr` stays live in memory for the real classification below
@@ -917,11 +1020,13 @@ export async function dispatchPostAdmissionTurn<
917
1020
  // retroactively invalidate an acceptance that already sealed this turn
918
1021
  // — a resolved 429 observed earlier in the same session is exactly
919
1022
  // that.
1023
+ const processCancelName = processCancelSignalName(env.signal);
920
1024
  const directHostFailureSignal =
921
1025
  result.timedOut
922
1026
  || result.knownFailure !== undefined
923
1027
  || runnerKnownFailure !== undefined
924
- || credentialFailure !== undefined;
1028
+ || credentialFailure !== undefined
1029
+ || processCancelName !== undefined;
925
1030
  hostSignalFailed =
926
1031
  directHostFailureSignal
927
1032
  || (result.code !== null && result.code !== 0)
@@ -959,12 +1064,16 @@ export async function dispatchPostAdmissionTurn<
959
1064
  // where a bare nonzero exit / resolution failure must not be outranked
960
1065
  // by someone else's earlier success (#836 r12 class 2).
961
1066
  const staleAcceptanceOutranksRealFailure = !settledIsFreshThisAttempt && hostSignalFailed;
1067
+ // #855: re-read cancel after trySettle/attach awaits — a signal in this
1068
+ // window must not land as lawful accepted.
1069
+ const cancelAfterSettle = processCancelSignalName(env.signal);
962
1070
  if (
963
1071
  settled !== undefined
964
1072
  && shouldPresent(settled)
965
1073
  && !directHostFailureSignal
966
1074
  && !staleAcceptanceOutranksRealFailure
967
1075
  && stderrLogWriteFailure === undefined
1076
+ && cancelAfterSettle === undefined
968
1077
  ) {
969
1078
  // This court sealed — drop open-court pointer (bare resume no longer continues it).
970
1079
  if (
@@ -1017,6 +1126,24 @@ export async function dispatchPostAdmissionTurn<
1017
1126
  return await finishAfterTurn({ ...settledFailure, turnDispatched: true as const, ...deferredPersist });
1018
1127
  }
1019
1128
  if (settledOutcome !== undefined) {
1129
+ // #855: final cancel re-read before lawful seal — settlement window may
1130
+ // have received SIGTERM/SIGINT/SIGHUP after the earlier snapshot.
1131
+ const lateCancel = processCancelSignalName(env.signal);
1132
+ if (lateCancel !== undefined) {
1133
+ return await finishAfterTurn(
1134
+ await settleProcessCancelAfterTurn({
1135
+ admitted,
1136
+ cancelName: lateCancel,
1137
+ result,
1138
+ adapters,
1139
+ env,
1140
+ io,
1141
+ persistRunState,
1142
+ deferredPersist,
1143
+ invocationScopeId: request.invocationScopeId,
1144
+ }),
1145
+ );
1146
+ }
1020
1147
  if (persistRunState) {
1021
1148
  try {
1022
1149
  await persistReturnedRunState(admitted, env.principalAuthority, { lawful: true });
@@ -1045,17 +1172,24 @@ export async function dispatchPostAdmissionTurn<
1045
1172
  io,
1046
1173
  persistRunState,
1047
1174
  );
1048
- return await finishAfterTurn({ ...failed, turnDispatched: true as const, ...deferredPersist });
1175
+ return await finishAfterTurn(
1176
+ withProcessCancelSkipAutoResume(
1177
+ { ...failed, turnDispatched: true as const, ...deferredPersist },
1178
+ env.signal,
1179
+ ),
1180
+ );
1049
1181
  }
1050
1182
  }
1051
1183
  // Lawful persist + present is the caller's stop seam (auto-resume loop /
1052
1184
  // manual resume), not this retried host-turn function.
1185
+ // #855 cancel re-read lives in finishAfterTurn (before afterDispatch).
1053
1186
  return await finishAfterTurn({ ...settledOutcome, ...deferredPersist });
1054
1187
  }
1055
1188
 
1056
1189
  // Host/runner true failure coexists with already-recorded payloads — never wash as accepted.
1057
1190
  if (hostSignalFailed) {
1058
1191
  const resolutionInput = controlledFailureInputFromResolution(resolution!);
1192
+ const processCancelName = processCancelSignalName(env.signal);
1059
1193
  const stderrLogWriteDetails =
1060
1194
  stderrLogWriteFailure === undefined
1061
1195
  ? undefined
@@ -1067,6 +1201,9 @@ export async function dispatchPostAdmissionTurn<
1067
1201
  code: result.code,
1068
1202
  stderr: result.stderr,
1069
1203
  ...resolutionInput,
1204
+ ...(processCancelName === undefined
1205
+ ? {}
1206
+ : { knownDiagnostic: processCancelDiagnostic(processCancelName) }),
1070
1207
  // Secondary fact only — never the cause. Rides on whichever channel
1071
1208
  // classification actually reads (knownFailure.details owns it when
1072
1209
  // a knownFailure exists; the top-level knownDetails otherwise).
@@ -1086,7 +1223,12 @@ export async function dispatchPostAdmissionTurn<
1086
1223
  io,
1087
1224
  persistRunState,
1088
1225
  );
1089
- return await finishAfterTurn({ ...failed, turnDispatched: true as const, ...deferredPersist });
1226
+ return await finishAfterTurn(
1227
+ withProcessCancelSkipAutoResume(
1228
+ { ...failed, turnDispatched: true as const, ...deferredPersist },
1229
+ env.signal,
1230
+ ),
1231
+ );
1090
1232
  }
1091
1233
 
1092
1234
  // Nothing else was wrong, but the durable stderr mirror itself failed to
@@ -1114,6 +1256,23 @@ export async function dispatchPostAdmissionTurn<
1114
1256
  await settleHostEndedNoReceipt(admitted, env.principalAuthority, courtScope) as T,
1115
1257
  courtScope,
1116
1258
  );
1259
+ // #855: cancel during no_receipt settlement window is not lawful success.
1260
+ const noReceiptCancel = processCancelSignalName(env.signal);
1261
+ if (noReceiptCancel !== undefined) {
1262
+ return await finishAfterTurn(
1263
+ await settleProcessCancelAfterTurn({
1264
+ admitted,
1265
+ cancelName: noReceiptCancel,
1266
+ result,
1267
+ adapters,
1268
+ env,
1269
+ io,
1270
+ persistRunState,
1271
+ deferredPersist,
1272
+ invocationScopeId: request.invocationScopeId,
1273
+ }),
1274
+ );
1275
+ }
1117
1276
  if (persistRunState) {
1118
1277
  try {
1119
1278
  await persistReturnedRunState(admitted, env.principalAuthority, { lawful: true });
@@ -1137,9 +1296,15 @@ export async function dispatchPostAdmissionTurn<
1137
1296
  io,
1138
1297
  persistRunState,
1139
1298
  );
1140
- return await finishAfterTurn({ ...failed, turnDispatched: true as const, ...deferredPersist });
1299
+ return await finishAfterTurn(
1300
+ withProcessCancelSkipAutoResume(
1301
+ { ...failed, turnDispatched: true as const, ...deferredPersist },
1302
+ env.signal,
1303
+ ),
1304
+ );
1141
1305
  }
1142
1306
  }
1307
+ // #855 cancel re-read lives in finishAfterTurn (before afterDispatch).
1143
1308
  return await finishAfterTurn({
1144
1309
  exitCode: exitCodeForTerminalOutcome(noReceipt.roleOutcome),
1145
1310
  admitted,
@@ -1512,6 +1677,7 @@ export async function runPostAdmissionSeatResume<
1512
1677
  io: input.io,
1513
1678
  sessionAppender: env.sessionAppender,
1514
1679
  autoResumeLimit: env.autoResumeLimit,
1680
+ ...(env.signal === undefined ? {} : { signal: env.signal }),
1515
1681
  buildInitialPayload: (): StationChildAttempt => ({ resumeTurn: false }),
1516
1682
  buildResumePayload: (): StationChildAttempt => ({ resumeTurn: true }),
1517
1683
  // Same as public manual resume: prior-court sealed acceptance is not a
@@ -1678,6 +1844,7 @@ export async function runPostAdmissionResumable<
1678
1844
  io,
1679
1845
  sessionAppender: env.sessionAppender,
1680
1846
  autoResumeLimit: env.autoResumeLimit,
1847
+ ...(env.signal === undefined ? {} : { signal: env.signal }),
1681
1848
  buildInitialPayload: buildScopedInitial,
1682
1849
  buildResumePayload: buildScopedResume,
1683
1850
  dispatch: async (request, lease, _isFirst, attemptIo) => {