@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.
- package/README.zh-CN.md +1 -1
- package/dist/acp-host/production-host.js +231 -218
- package/dist/activation-ledger-topology.js +0 -4
- package/dist/headless-host/production-host.js +215 -202
- package/dist/pi/role-turn-host.js +0 -3
- package/dist/public-cli/auto-resume.js +26 -0
- package/dist/public-cli/main.js +1054 -780
- package/dist/public-cli/post-admission.js +101 -7
- package/dist/public-cli/process-cancel.js +57 -0
- package/package.json +1 -1
- package/scripts/build-package.mjs +0 -1
- package/src/activation-ledger-topology.ts +1 -6
- package/src/activation-ledger.ts +5 -236
- package/src/analyst-entry.ts +19 -2
- package/src/analyst-ledger.ts +111 -6
- package/src/archivist-record-entry.ts +1 -1
- package/src/pi/role-turn-host.ts +0 -3
- package/src/public-cli/auto-resume.ts +34 -0
- package/src/public-cli/cli.ts +30 -0
- package/src/public-cli/main.ts +25 -3
- package/src/public-cli/post-admission.ts +173 -6
- package/src/public-cli/process-cancel.ts +75 -0
- package/src/role-runtime.ts +4 -51
- package/dist/activation-reconciliation.js +0 -61
- package/src/activation-reconciliation.ts +0 -163
package/src/analyst-ledger.ts
CHANGED
|
@@ -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
|
-
| {
|
|
347
|
-
|
|
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
|
-
|
|
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")
|
|
642
|
-
|
|
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
|
}
|
package/src/pi/role-turn-host.ts
CHANGED
|
@@ -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,
|
package/src/public-cli/cli.ts
CHANGED
|
@@ -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,
|
package/src/public-cli/main.ts
CHANGED
|
@@ -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
|
|
39
|
-
|
|
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:
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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) => {
|