@sema-agent/core 7.8.0 → 7.9.0
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/CHANGELOG.md +52 -1
- package/dist/agents/subagent.d.ts +1 -1
- package/dist/core/ask-origin.d.ts +12 -1
- package/dist/core/ask-origin.js +5 -1
- package/dist/core/checkpoint-store.d.ts +34 -28
- package/dist/core/gate-lanes.js +49 -23
- package/dist/core/gate-outcome.d.ts +8 -4
- package/dist/core/gate-outcome.js +2 -1
- package/dist/core/hooks.d.ts +56 -31
- package/dist/core/permission-rule-consent.d.ts +29 -36
- package/dist/core/permission-rule-consent.js +110 -60
- package/dist/core/permission-rule-model.d.ts +254 -55
- package/dist/core/permission-rule-model.js +323 -43
- package/dist/core/permission-rule-org.d.ts +9 -8
- package/dist/core/permission-rule-org.js +6 -15
- package/dist/core/permission-rule-provider.d.ts +10 -6
- package/dist/core/permission-rule-provider.js +12 -8
- package/dist/core/permission-rule-session.d.ts +7 -6
- package/dist/core/permission-rule-session.js +29 -10
- package/dist/core/permission-rule-store.d.ts +40 -18
- package/dist/core/permission-rule-store.js +68 -42
- package/dist/core/permission-rule-sync.d.ts +9 -3
- package/dist/core/permission-rule-sync.js +29 -25
- package/dist/core/permission-rule-syntax.d.ts +30 -0
- package/dist/core/permission-rule-syntax.js +44 -0
- package/dist/core/permission-rules.d.ts +55 -33
- package/dist/core/permission-rules.js +65 -55
- package/dist/core/persisted-rule-arms.d.ts +56 -0
- package/dist/core/persisted-rule-arms.js +48 -0
- package/dist/core/runner/contracts.d.ts +21 -2
- package/dist/core/runner/permission-rule-lanes.d.ts +33 -26
- package/dist/core/runner/permission-rule-lanes.js +27 -21
- package/dist/core/runner/prepare-gate-stations.js +1 -1
- package/dist/core/runner/prepare-safety-scan.js +8 -2
- package/dist/core/runner/prepare-task.js +1 -1
- package/dist/core/runner/resume-admission.d.ts +53 -0
- package/dist/core/runner/resume-admission.js +83 -0
- package/dist/core/runner/resume-apply.d.ts +50 -0
- package/dist/core/runner/resume-apply.js +184 -0
- package/dist/core/runner/resume-checkpoint-screen.d.ts +18 -0
- package/dist/core/runner/resume-checkpoint-screen.js +108 -0
- package/dist/core/runner/resume-claim.d.ts +32 -0
- package/dist/core/runner/resume-claim.js +27 -0
- package/dist/core/runner/resume-internals-and-config.d.ts +33 -0
- package/dist/core/runner/resume-internals-and-config.js +50 -0
- package/dist/core/runner/resume-policy-outcome.d.ts +31 -0
- package/dist/core/runner/resume-policy-outcome.js +127 -0
- package/dist/core/runner/resume-preflight.d.ts +40 -0
- package/dist/core/runner/resume-preflight.js +122 -0
- package/dist/core/runner/resume-review-outcome.d.ts +30 -0
- package/dist/core/runner/resume-review-outcome.js +88 -0
- package/dist/core/runner/run-harness-handlers.js +1 -1
- package/dist/core/runner/runtask.d.ts +20 -0
- package/dist/core/runner/runtask.js +85 -734
- package/dist/core/runner/tool-end-body.d.ts +12 -5
- package/dist/core/runner/tool-end-body.js +5 -5
- package/dist/core/runner/tool-output-projection.d.ts +9 -6
- package/dist/core/runner/tool-output-projection.js +1 -18
- package/dist/core/runner/turn-attachments.d.ts +2 -2
- package/dist/core/store-contracts/permission-rule-sync-contract.js +29 -10
- package/dist/core/tool-roster.d.ts +9 -0
- package/dist/core/tool-roster.js +15 -5
- package/dist/index.d.ts +2 -2
- package/dist/index.js +2 -2
- package/dist/stores/file/checkpoint-store.js +1 -1
- package/dist/stores/file/permission-rule-store.d.ts +28 -23
- package/dist/stores/file/permission-rule-store.js +64 -16
- package/dist/tools/fs/bash-readonly-classifier.d.ts +1 -1
- package/dist/tools/fs/fs-pdf.d.ts +1 -1
- package/dist/tools/fs/notebook.d.ts +1 -1
- package/dist/tools/fs/safety.d.ts +1 -1
- package/package.json +1 -1
- package/test/export-surface.snapshot.json +43 -11
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { persistedReadDenyEntryProblem } from "../../tools/fs/read-deny.js";
|
|
2
|
+
import { isAskOrigin } from "../ask-origin.js";
|
|
3
|
+
import { CheckpointError, checkpointVersionOf, F012_CHECKPOINT_VERSION, FACE_CHECKPOINT_VERSION, MAX_SUPPORTED_CHECKPOINT_VERSION, REAL_APPROVAL_CHECKPOINT_VERSION, realApprovalOrgFact } from "../checkpoint-store.js";
|
|
4
|
+
import { constraintChainDigest, constraintChainEntryOfSuppliedLayer } from "../tool-policy.js";
|
|
5
|
+
export function resumeCheckpointScreen(input) {
|
|
6
|
+
const { cp, resumeSignal, internals, runner } = input;
|
|
7
|
+
if (checkpointVersionOf(cp) > MAX_SUPPORTED_CHECKPOINT_VERSION) {
|
|
8
|
+
throw new CheckpointError("checkpoint.unsupported_version", `checkpoint version ${checkpointVersionOf(cp)} is newer than this worker supports (max ${MAX_SUPPORTED_CHECKPOINT_VERSION})`, { reason: "version_newer" });
|
|
9
|
+
}
|
|
10
|
+
const preCasGateBit = cp.gate.kind === "irreversible_ask" ? cp.gate.realApproval : undefined;
|
|
11
|
+
const preCasBitWellFormed = preCasGateBit !== undefined && typeof preCasGateBit === "object" && isAskOrigin(preCasGateBit.origin);
|
|
12
|
+
const faceSaysGoverned = (cp.state.readFace?.realApproval) === true;
|
|
13
|
+
const preCasBitViolation = checkpointVersionOf(cp) === REAL_APPROVAL_CHECKPOINT_VERSION
|
|
14
|
+
? !preCasBitWellFormed
|
|
15
|
+
: checkpointVersionOf(cp) < REAL_APPROVAL_CHECKPOINT_VERSION
|
|
16
|
+
? preCasGateBit !== undefined
|
|
17
|
+
: faceSaysGoverned
|
|
18
|
+
? !preCasBitWellFormed
|
|
19
|
+
: preCasGateBit !== undefined && !preCasBitWellFormed;
|
|
20
|
+
if (preCasBitViolation) {
|
|
21
|
+
throw new CheckpointError("checkpoint.invalid_outcome", checkpointVersionOf(cp) >= REAL_APPROVAL_CHECKPOINT_VERSION
|
|
22
|
+
? `a v${checkpointVersionOf(cp)} checkpoint's realApproval gate bit must be well-formed (origin a member of the closed ask-origin set)${checkpointVersionOf(cp) === REAL_APPROVAL_CHECKPOINT_VERSION ? " and present on an irreversible_ask gate" : ""} — this row's is not; refusing to resume a damaged real-approval row (corruption / downgrade guard), the checkpoint stays pending`
|
|
23
|
+
: `a v${checkpointVersionOf(cp)} checkpoint carries a realApproval gate bit no release of that version ever minted — refusing to honor a fabricated origin (corruption / forgery guard), the checkpoint stays pending`, { reason: checkpointVersionOf(cp) >= REAL_APPROVAL_CHECKPOINT_VERSION ? "real_approval_damaged" : "real_approval_forged" });
|
|
24
|
+
}
|
|
25
|
+
{
|
|
26
|
+
const faceSectionRaw = cp.state.readFace;
|
|
27
|
+
const faceCandidate = faceSectionRaw !== undefined && typeof faceSectionRaw === "object" && faceSectionRaw !== null
|
|
28
|
+
? faceSectionRaw
|
|
29
|
+
: undefined;
|
|
30
|
+
const faceWellFormed = faceCandidate !== undefined &&
|
|
31
|
+
(faceCandidate.face === "open" || faceCandidate.face === "roots") &&
|
|
32
|
+
(faceCandidate.realApproval === undefined || faceCandidate.realApproval === true) &&
|
|
33
|
+
(faceCandidate.denyEntries === undefined ||
|
|
34
|
+
(Array.isArray(faceCandidate.denyEntries) &&
|
|
35
|
+
faceCandidate.denyEntries.every((e) => persistedReadDenyEntryProblem(e) === null)));
|
|
36
|
+
if (checkpointVersionOf(cp) >= FACE_CHECKPOINT_VERSION ? !faceWellFormed : faceSectionRaw !== undefined) {
|
|
37
|
+
throw new CheckpointError("checkpoint.invalid_outcome", checkpointVersionOf(cp) >= FACE_CHECKPOINT_VERSION
|
|
38
|
+
? `a v${checkpointVersionOf(cp)} checkpoint must carry a well-formed read-face section (state.readFace) — this row's is missing or malformed; refusing to resume a damaged read-face row (corruption / downgrade guard), the checkpoint stays pending`
|
|
39
|
+
: `a v${checkpointVersionOf(cp)} checkpoint carries a read-face section no release of that version ever minted — refusing to honor a fabricated read-face state (corruption / forgery guard), the checkpoint stays pending`, { reason: checkpointVersionOf(cp) >= FACE_CHECKPOINT_VERSION ? "read_face_damaged" : "read_face_forged" });
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
if (realApprovalOrgFact(preCasGateBit) !== undefined && runner.deps.permissionRuleStore?.partitions.org !== true) {
|
|
43
|
+
throw new CheckpointError("checkpoint.unsupported_version", `this checkpoint's approval was minted under organization governance (org ${String(realApprovalOrgFact(preCasGateBit))}, routed as ${String(preCasGateBit?.origin)}) and this worker has no org adjudication wiring (its permission-rule store carries no org partition) — a governed approval may only be redeemed where governance can be enforced; the checkpoint stays pending, resume it on an org-wired worker`, { reason: "governed_unwired" });
|
|
44
|
+
}
|
|
45
|
+
const retiredWalltimeTotal = cp.resourceLedger?.totalWalltimeSec;
|
|
46
|
+
if (retiredWalltimeTotal !== undefined) {
|
|
47
|
+
throw new CheckpointError("checkpoint.walltime_axis_retired", "this checkpoint's ledger carries a cross-slice WALL-CLOCK allocation (resourceLedger.totalWalltimeSec), an axis this engine version retired — " +
|
|
48
|
+
"resuming here would run the leg with that ceiling unenforced. Rejected pre-CAS (the checkpoint stays pending): finish it on a worker of the " +
|
|
49
|
+
"previous release, or start a fresh task bounded by resourceSuspend.totalTokens / totalBudgetUsd.");
|
|
50
|
+
}
|
|
51
|
+
if (resumeSignal?.aborted === true && cp.status === "pending") {
|
|
52
|
+
throw new CheckpointError("checkpoint.resume_aborted", "the resume was handed an ALREADY-ABORTED signal — refusing to consume the approval on a leg that cannot run it (the checkpoint stays pending and is resumable with a live signal)");
|
|
53
|
+
}
|
|
54
|
+
if (cp.state.workspaceHandle !== undefined && runner.deps.executionEnvFactory === undefined) {
|
|
55
|
+
throw new CheckpointError("checkpoint.unsupported_version", "checkpoint has a remote workspaceHandle but no RunnerDeps.executionEnvFactory is wired to rebuild the env", { reason: "env_factory_missing" });
|
|
56
|
+
}
|
|
57
|
+
if (cp.state.workspaceHandle !== undefined && internals?.requestedCwd !== undefined) {
|
|
58
|
+
throw new CheckpointError("checkpoint.cwd_conflicts_restore", `internals.requestedCwd ("${internals.requestedCwd}") cannot be combined with a checkpoint workspace restore — ` +
|
|
59
|
+
"the restored workspace's own mount path is authoritative for the task root. Drop requestedCwd on resume legs " +
|
|
60
|
+
"(the checkpoint stays pending and is resumable without it).");
|
|
61
|
+
}
|
|
62
|
+
if (cp.state.inheritedGate?.requiresParentConstraint === true) {
|
|
63
|
+
const supplied = internals?.inheritedGate?.parentConstraints?.length ?? 0;
|
|
64
|
+
if (supplied === 0) {
|
|
65
|
+
throw new CheckpointError("resume.parent_constraint_missing", "this checkpoint's task ran under inherited parent-policy constraints (state.inheritedGate.requiresParentConstraint) " +
|
|
66
|
+
"— the live policy/onAsk closures cannot be persisted, so the resume must re-supply them via " +
|
|
67
|
+
"resumeStream(token, outcome, taskConfig, internals) with internals.inheritedGate.parentConstraints; " +
|
|
68
|
+
"rejected pre-CAS (the checkpoint stays pending) rather than silently dropping the ancestors' gate");
|
|
69
|
+
}
|
|
70
|
+
const expected = cp.state.inheritedGate.parentConstraintCount;
|
|
71
|
+
if (expected !== undefined && supplied !== expected) {
|
|
72
|
+
throw new CheckpointError("resume.parent_constraint_mismatch", `re-supplied ${supplied} parent constraint(s) but the checkpoint recorded ${expected} — a partial/mismatched ` +
|
|
73
|
+
"re-supply would run the resumed leg under a different ancestor chain than it suspended with; " +
|
|
74
|
+
"rejected pre-CAS (the checkpoint stays pending) — re-resume with the full original chain");
|
|
75
|
+
}
|
|
76
|
+
if (checkpointVersionOf(cp) >= F012_CHECKPOINT_VERSION &&
|
|
77
|
+
(cp.state.inheritedGate.constraintChain === undefined || cp.state.inheritedGate.constraintDigest === undefined)) {
|
|
78
|
+
throw new CheckpointError("checkpoint.invalid_outcome", `a v${checkpointVersionOf(cp)} checkpoint that requires parent constraints must carry BOTH the frozen constraintChain and its constraintDigest (they are minted in one write with the version stamp) — this row carries neither or only one; refusing to fall back to the count-only contract on a damaged row (corruption / downgrade guard), the checkpoint stays pending`, { reason: "constraint_chain_missing" });
|
|
79
|
+
}
|
|
80
|
+
const expectedDigest = cp.state.inheritedGate.constraintDigest;
|
|
81
|
+
if (expectedDigest !== undefined) {
|
|
82
|
+
const persistedChain = cp.state.inheritedGate.constraintChain;
|
|
83
|
+
if (persistedChain === undefined || constraintChainDigest(persistedChain) !== expectedDigest) {
|
|
84
|
+
throw new CheckpointError("resume.parent_constraint_mismatch", "the checkpoint's OWN recorded constraint chain does not match its recorded digest " +
|
|
85
|
+
"(corrupt / inconsistently-minted row) — the frozen deny data the resume would execute is not the data the " +
|
|
86
|
+
"digest authenticates; rejected pre-CAS (the checkpoint stays pending)");
|
|
87
|
+
}
|
|
88
|
+
const suppliedDigest = constraintChainDigest((internals?.inheritedGate?.parentConstraints ?? []).map((pc, i) => constraintChainEntryOfSuppliedLayer(pc, persistedChain[i])));
|
|
89
|
+
if (suppliedDigest !== expectedDigest) {
|
|
90
|
+
throw new CheckpointError("resume.parent_constraint_mismatch", "the re-supplied parent-constraint chain's projection content does not match what the checkpoint recorded " +
|
|
91
|
+
`(recorded ${expectedDigest}, re-supplied ${suppliedDigest}) — a same-shape chain with different frozen ` +
|
|
92
|
+
"deny data would run the resumed leg under a different ancestor gate than it suspended with; " +
|
|
93
|
+
"rejected pre-CAS (the checkpoint stays pending) — re-resume with the original chain's policies " +
|
|
94
|
+
"(including, when the row recorded one, the ancestor's auto-mode arming recipe verbatim)");
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
const resumeInternals = internals?.inheritedGate?.parentConstraints !== undefined
|
|
99
|
+
? {
|
|
100
|
+
...internals,
|
|
101
|
+
inheritedGate: {
|
|
102
|
+
...internals.inheritedGate,
|
|
103
|
+
parentConstraints: Object.freeze(internals.inheritedGate.parentConstraints.map((e) => ({ ...e }))),
|
|
104
|
+
},
|
|
105
|
+
}
|
|
106
|
+
: internals;
|
|
107
|
+
return { resumeInternals };
|
|
108
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* design/393 S3 — the resume ladder's CLAIM rung (E10), verbatim from `Runner.resumeStream`: the atomic
|
|
3
|
+
* CAS (`claimTerminal` when the store provides it — one round trip carrying the loser's truth — else the
|
|
4
|
+
* two-step `resolve` + diagnostic read), the same-Runner claim mark around it, and the loss classification
|
|
5
|
+
* (`reopened_concurrently` for a live row whose rev moved, `already_resolved` for a consumed one, with the
|
|
6
|
+
* dead parked-constraint entry evicted). Winning is the SOLE trigger to execute the pending action.
|
|
7
|
+
*
|
|
8
|
+
* Async (the CAS). It ends by handing off to the driver's post-claim rung (`next`) in its LAST continuation
|
|
9
|
+
* — the post-consume hook fires on the tick the win was read — and returns what that rung returns.
|
|
10
|
+
*/
|
|
11
|
+
import { type Checkpoint, type CheckpointStore, type CheckpointToken } from "../checkpoint-store.js";
|
|
12
|
+
import type { InheritedGate, ResumeRun } from "./contracts.js";
|
|
13
|
+
export interface ResumeClaimInput<R> {
|
|
14
|
+
/** borrowed-readonly — the checkpoint token being claimed. */
|
|
15
|
+
token: CheckpointToken;
|
|
16
|
+
/** borrowed-readonly — the pending row (scope and the rev the ladder validated against). */
|
|
17
|
+
cp: Checkpoint;
|
|
18
|
+
/** borrowed-readonly — the store the CAS runs against. */
|
|
19
|
+
store: CheckpointStore;
|
|
20
|
+
/** borrowed-readonly — the twin the store persists as the resolved outcome. */
|
|
21
|
+
outcomeForStore: ResumeRun["outcome"];
|
|
22
|
+
/** borrowed-mutable — the Runner's same-Runner claim marks: opened with the resolve attempt, released on a loss. */
|
|
23
|
+
locallyClaimedTokens: Set<CheckpointToken>;
|
|
24
|
+
/** borrowed-mutable — the Runner's parked-constraint registry: a terminally consumed token's entry is evicted. */
|
|
25
|
+
parentConstraintRegistry: Map<CheckpointToken, NonNullable<InheritedGate["parentConstraints"]>>;
|
|
26
|
+
/** borrowed-readonly — the driver's post-claim rung, entered in this rung's last continuation. */
|
|
27
|
+
next: (claimed: ResumeClaimResult) => Promise<R>;
|
|
28
|
+
}
|
|
29
|
+
/** The claim's completion is its whole result: a lost CAS throws, so reaching `next` IS the win. */
|
|
30
|
+
export interface ResumeClaimResult {
|
|
31
|
+
}
|
|
32
|
+
export declare function resumeClaim<R>(input: ResumeClaimInput<R>): Promise<R>;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { CheckpointError } from "../checkpoint-store.js";
|
|
2
|
+
export async function resumeClaim(input) {
|
|
3
|
+
const { token, cp, store, outcomeForStore, locallyClaimedTokens, parentConstraintRegistry } = input;
|
|
4
|
+
locallyClaimedTokens.add(token);
|
|
5
|
+
let won;
|
|
6
|
+
let claimLossStatus;
|
|
7
|
+
if (store.claimTerminal !== undefined) {
|
|
8
|
+
const claim = await store.claimTerminal(token, cp.scope, { kind: "resolve", outcome: outcomeForStore, expect: { rev: cp.rev ?? 0 } });
|
|
9
|
+
won = claim.claimed;
|
|
10
|
+
if (!claim.claimed)
|
|
11
|
+
claimLossStatus = claim.current.status;
|
|
12
|
+
}
|
|
13
|
+
else {
|
|
14
|
+
won = await store.resolve(token, cp.scope, outcomeForStore, { rev: cp.rev ?? 0 });
|
|
15
|
+
}
|
|
16
|
+
if (!won)
|
|
17
|
+
locallyClaimedTokens.delete(token);
|
|
18
|
+
if (!won) {
|
|
19
|
+
const stillPending = claimLossStatus !== undefined ? claimLossStatus === "pending" : (await store.get(token))?.status === "pending";
|
|
20
|
+
if (stillPending) {
|
|
21
|
+
throw new CheckpointError("checkpoint.reopened_concurrently", "checkpoint changed concurrently (its revision advanced via a resolve/reopen cycle since this resume validated) — not executed; re-resume against the current state");
|
|
22
|
+
}
|
|
23
|
+
parentConstraintRegistry.delete(token);
|
|
24
|
+
throw new CheckpointError("checkpoint.already_resolved", "checkpoint already resolved or expired — not re-executed (idempotent)");
|
|
25
|
+
}
|
|
26
|
+
return await input.next({});
|
|
27
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* design/393 S3 — the resume ladder's INTERNALS-AND-CONFIG rung (E7), verbatim from `Runner.resumeStream`:
|
|
3
|
+
* the locked-policy guard on the resume-edit seat, the principal and placement identity-continuity rungs,
|
|
4
|
+
* and the governance-window ENTRANCE check for a row that still owes a delivery.
|
|
5
|
+
*
|
|
6
|
+
* Synchronous by construction, with the ladder's ONE await at this rung handed back instead of taken: the
|
|
7
|
+
* window read is STARTED here (the same expression, on the same tick) and returned as `windowEntrance` for the
|
|
8
|
+
* driver to await exactly where the ladder always awaited it — an `async` wrapper would add a suspension point
|
|
9
|
+
* the ladder never had, on the path where no window is armed most of all. Same reason the forwarding drain is a
|
|
10
|
+
* synchronous predicate plus a Promise factory (prepare-run-refs.ts).
|
|
11
|
+
*/
|
|
12
|
+
import { type Checkpoint, type ResumeOutcome } from "../checkpoint-store.js";
|
|
13
|
+
import type { ResumeTaskConfig, RunInternals, RunnerDepsSeat } from "./contracts.js";
|
|
14
|
+
export interface ResumeInternalsAndConfigInput {
|
|
15
|
+
/** borrowed-readonly — the pending row (recorded principal, placement root, parked steers, background tasks). */
|
|
16
|
+
cp: Checkpoint;
|
|
17
|
+
/** borrowed-readonly — the config snapshot (`basePolicyForResumeEdit`, `principal`). */
|
|
18
|
+
config: ResumeTaskConfig;
|
|
19
|
+
/** borrowed-readonly — the internals snapshot (`placementRoot`). */
|
|
20
|
+
internals: RunInternals | undefined;
|
|
21
|
+
/** borrowed-readonly — the ONE read of the outcome discriminant (a bare resource_limit continue owes nothing). */
|
|
22
|
+
outcomeGate: ResumeOutcome["gate"];
|
|
23
|
+
/** borrowed-readonly — the Runner's deployment deps, read LIVE through the Runner (reads `lockedConfig`, `usageWindowStore`, `usageWindows`); the one home of why it is a
|
|
24
|
+
* seat and not a captured object is {@link RunnerDepsSeat}. */
|
|
25
|
+
runner: RunnerDepsSeat;
|
|
26
|
+
}
|
|
27
|
+
export interface ResumeInternalsAndConfigResult {
|
|
28
|
+
/** The window verdict as a re-assertable closure (armed only when governance is active), for the rungs whose awaits sit inside the read→CAS window. */
|
|
29
|
+
recheckGovernanceWindow: (() => Promise<void>) | undefined;
|
|
30
|
+
/** The entrance check already in flight (started on this tick) — the driver awaits it where the ladder did; absent when governance is unarmed. */
|
|
31
|
+
windowEntrance: Promise<void> | undefined;
|
|
32
|
+
}
|
|
33
|
+
export declare function resumeInternalsAndConfig(input: ResumeInternalsAndConfigInput): ResumeInternalsAndConfigResult;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { CheckpointError, readPendingSteerQueue } from "../checkpoint-store.js";
|
|
2
|
+
import { GLOBAL_USAGE_KEY, resolveUsageWindows, usageRetryAfterMs } from "../usage-window-store.js";
|
|
3
|
+
import { placementValueOrAbsent } from "./checkpoint-scope.js";
|
|
4
|
+
export function resumeInternalsAndConfig(input) {
|
|
5
|
+
const { cp, config, internals, outcomeGate, runner } = input;
|
|
6
|
+
let windowEntrance;
|
|
7
|
+
if (runner.deps.lockedConfig?.keys?.includes("toolPolicy") === true && config.basePolicyForResumeEdit !== undefined) {
|
|
8
|
+
throw new CheckpointError("checkpoint.invalid_outcome", "TaskSpec.basePolicyForResumeEdit is administratively locked by this deployment (locked key \"toolPolicy\") — a task-supplied " +
|
|
9
|
+
"resume-edit policy is refused pre-CAS (the checkpoint stays pending); remove the field or change the deployment's lock configuration");
|
|
10
|
+
}
|
|
11
|
+
let recheckGovernanceWindow;
|
|
12
|
+
{
|
|
13
|
+
const rowPrincipal = cp.principal || undefined;
|
|
14
|
+
const suppliedPrincipal = config.principal || undefined;
|
|
15
|
+
if (rowPrincipal !== undefined && suppliedPrincipal !== undefined && suppliedPrincipal !== rowPrincipal) {
|
|
16
|
+
throw new CheckpointError("resume.principal_mismatch", `the resume config carries principal ${JSON.stringify(suppliedPrincipal)} but this checkpoint was suspended under principal ${JSON.stringify(rowPrincipal)} — ` +
|
|
17
|
+
"running the resumed leg under a different identity would move its usage-ledger bucket, scope derivation and attribution; " +
|
|
18
|
+
"refused pre-CAS (the checkpoint stays pending): re-resume with the original principal, or omit the field to inherit the recorded one");
|
|
19
|
+
}
|
|
20
|
+
{
|
|
21
|
+
const recordedPlacementRoot = placementValueOrAbsent(cp.state.placementRootSessionId);
|
|
22
|
+
const suppliedPlacementRoot = placementValueOrAbsent(internals?.placementRoot);
|
|
23
|
+
if (recordedPlacementRoot !== undefined && suppliedPlacementRoot !== undefined && suppliedPlacementRoot !== recordedPlacementRoot) {
|
|
24
|
+
throw new CheckpointError("resume.placement_mismatch", `the resume supplied explicit placement root ${JSON.stringify(suppliedPlacementRoot)} but this checkpoint recorded placement root ${JSON.stringify(recordedPlacementRoot)} at suspend — ` +
|
|
25
|
+
"resuming under a different placement fixed point would re-place the leg (and its descendants) on another target; " +
|
|
26
|
+
"refused pre-CAS (the checkpoint stays pending): re-resume with the recorded root, or omit internals.placementRoot to inherit it");
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
const owesDelivery = outcomeGate !== "resource_limit" ||
|
|
30
|
+
readPendingSteerQueue(cp.state).length > 0 ||
|
|
31
|
+
(cp.state.runningBackgroundTasks?.length ?? 0) > 0;
|
|
32
|
+
const usageWindowStore = runner.deps.usageWindowStore;
|
|
33
|
+
if (owesDelivery && usageWindowStore !== undefined) {
|
|
34
|
+
const preCasWindows = resolveUsageWindows(runner.deps.usageWindows);
|
|
35
|
+
if (preCasWindows !== undefined && preCasWindows.length > 0) {
|
|
36
|
+
const ledgerKey = (suppliedPrincipal ?? rowPrincipal) || GLOBAL_USAGE_KEY;
|
|
37
|
+
const assertWindowOpen = async () => {
|
|
38
|
+
const wait = usageRetryAfterMs(await usageWindowStore.read(ledgerKey, preCasWindows, Date.now()), preCasWindows);
|
|
39
|
+
if (wait !== undefined) {
|
|
40
|
+
throw new CheckpointError("resume.usage_window_exhausted", `a deployment usage window for ledger key ${JSON.stringify(ledgerKey)} is exhausted, and this checkpoint still owes a delivery a re-mint cannot carry — ` +
|
|
41
|
+
`refused pre-CAS (nothing consumed, nothing unpinned): the same token and the same decision are redeemable once the window frees, in ${String(wait)}ms`, { retryAfterMs: wait });
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
recheckGovernanceWindow = assertWindowOpen;
|
|
45
|
+
windowEntrance = assertWindowOpen();
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return { recheckGovernanceWindow, windowEntrance };
|
|
50
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* design/393 S3 — the resume ladder's APPROVAL-LANE capture and binding rung (E4), verbatim from
|
|
3
|
+
* `Runner.resumeStream`: on a human / irreversible_ask row every deployment-supplied field of the
|
|
4
|
+
* `policy_ask` decide is read exactly once into a plain twin (answer canonicalized, updatedInput cloned to the
|
|
5
|
+
* JSON domain, the decision word, the refused keys, the host's decision facts and attribution, the deny note),
|
|
6
|
+
* then the decision-action binding (boundCallId, boundInputHash by version), the env_failed replay guard and
|
|
7
|
+
* the content-ask answer binding. Synchronous; every refusal throws pre-CAS.
|
|
8
|
+
*/
|
|
9
|
+
import { type QuestionAnswer } from "../ask-question.js";
|
|
10
|
+
import { type Checkpoint, type ResumeOutcome } from "../checkpoint-store.js";
|
|
11
|
+
import type { ResumeTaskConfig, RunnerDepsSeat } from "./contracts.js";
|
|
12
|
+
export interface ResumePolicyOutcomeInput {
|
|
13
|
+
/** borrowed-readonly — the pending row (the lane is keyed on the STORED gate kind; the binding reads its pending call). */
|
|
14
|
+
cp: Checkpoint;
|
|
15
|
+
/** borrowed-readonly — the caller's outcome; each policy field is read exactly once, into the twin. */
|
|
16
|
+
outcome: ResumeOutcome;
|
|
17
|
+
/** borrowed-readonly — the config snapshot; the answer binding reads its `onQuestion` face. */
|
|
18
|
+
config: ResumeTaskConfig;
|
|
19
|
+
/** borrowed-readonly — the Runner's deployment deps, read LIVE through the Runner (the answer binding falls back to `onQuestion`); the one home of why it is a
|
|
20
|
+
* seat and not a captured object is {@link RunnerDepsSeat}. */
|
|
21
|
+
runner: RunnerDepsSeat;
|
|
22
|
+
}
|
|
23
|
+
export interface ResumePolicyOutcomeResult {
|
|
24
|
+
/** The approval lane's plain twin (a human / irreversible_ask row), else undefined. */
|
|
25
|
+
plainPolicyOutcome: Extract<ResumeOutcome, {
|
|
26
|
+
gate: "policy_ask";
|
|
27
|
+
}> | undefined;
|
|
28
|
+
/** The decide's content-ask answer in canonical form (present only when one was carried). */
|
|
29
|
+
redeemedAnswer: QuestionAnswer | undefined;
|
|
30
|
+
}
|
|
31
|
+
export declare function resumePolicyOutcome(input: ResumePolicyOutcomeInput): ResumePolicyOutcomeResult;
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { canonicalizeCapturedPlainData, classifyQuestionOutcome, isLiveQuestionFace } from "../ask-question.js";
|
|
2
|
+
import { BINDING_CHECKPOINT_VERSION, CheckpointError, checkpointVersionOf, winnerFromOutcome } from "../checkpoint-store.js";
|
|
3
|
+
import { screenApproverAttribution } from "../tool-policy.js";
|
|
4
|
+
import { sanitizeUntrustedText } from "../untrusted-text.js";
|
|
5
|
+
import { assertOutcomeText, describeSuppliedValue, pendingContentAskCallId, sameWinner } from "./decide-continuation.js";
|
|
6
|
+
const REFUSED_DECIDE_KEYS = [
|
|
7
|
+
["gateOutcome", "the settlement record is minted by core from the host's decision facts and the row's origin, never supplied"],
|
|
8
|
+
["settledBy", 'a retired key — the host supplies decision FACTS as hostDecision{decidedBy: "person" | "sla_timeout", approver?} and core mints the settlement word from them'],
|
|
9
|
+
["approver", "a retired key — the attribution rides the facts it belongs to, as hostDecision.approver"],
|
|
10
|
+
];
|
|
11
|
+
export function resumePolicyOutcome(input) {
|
|
12
|
+
const { cp, outcome, config, runner } = input;
|
|
13
|
+
let suppliedAnswer;
|
|
14
|
+
let redeemedAnswer;
|
|
15
|
+
let plainPolicyOutcome;
|
|
16
|
+
if (cp.gate.kind === "human" || cp.gate.kind === "irreversible_ask") {
|
|
17
|
+
const decide = outcome;
|
|
18
|
+
suppliedAnswer = decide.answer;
|
|
19
|
+
if (suppliedAnswer !== undefined) {
|
|
20
|
+
const reading = classifyQuestionOutcome(suppliedAnswer);
|
|
21
|
+
if (reading.shape !== "answered") {
|
|
22
|
+
throw new CheckpointError("checkpoint.invalid_outcome", "resume carried a content-ask `answer` that does not read as an answer (malformed, sparse, or not capturable as plain data) — " +
|
|
23
|
+
"refusing pre-CAS rather than fabricating an empty human response; the checkpoint stays pending", { field: "answer" });
|
|
24
|
+
}
|
|
25
|
+
redeemedAnswer = reading.answer;
|
|
26
|
+
}
|
|
27
|
+
const updatedInputRaw = decide.updatedInput;
|
|
28
|
+
let updatedInput;
|
|
29
|
+
try {
|
|
30
|
+
updatedInput = updatedInputRaw === undefined ? undefined : structuredClone(updatedInputRaw);
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
throw new CheckpointError("checkpoint.invalid_outcome", "resume carried an `updatedInput` that could not be captured as plain data — refusing pre-CAS; the checkpoint stays pending");
|
|
34
|
+
}
|
|
35
|
+
if (Object.is(updatedInput, -0))
|
|
36
|
+
updatedInput = 0;
|
|
37
|
+
if (updatedInput !== undefined && !canonicalizeCapturedPlainData(updatedInput)) {
|
|
38
|
+
throw new CheckpointError("checkpoint.invalid_outcome", "resume carried an `updatedInput` outside the plain-JSON value domain (a Map/Set/Date/buffer, a sparse or expando-carrying array, a non-finite number, or a cycle) — " +
|
|
39
|
+
"such a value reads differently on the two sides of the durable store; refusing pre-CAS, the checkpoint stays pending");
|
|
40
|
+
}
|
|
41
|
+
const decision = decide.decision;
|
|
42
|
+
if (decision !== "allow" && decision !== "deny") {
|
|
43
|
+
throw new CheckpointError("checkpoint.invalid_outcome", `resume decision "${describeSuppliedValue(decision)}" is outside the policy_ask domain — a decide is exactly "allow" or "deny"; refusing pre-CAS, the checkpoint stays pending`);
|
|
44
|
+
}
|
|
45
|
+
for (const [key, why] of REFUSED_DECIDE_KEYS) {
|
|
46
|
+
if (Reflect.get(decide, key) !== undefined) {
|
|
47
|
+
throw new CheckpointError("checkpoint.invalid_outcome", `resume carries \`${key}\` — ${why}; refusing pre-CAS, the checkpoint stays pending`, { field: "hostDecision" });
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
const hostDecisionRaw = decide.hostDecision;
|
|
51
|
+
if (typeof hostDecisionRaw !== "object" || hostDecisionRaw === null) {
|
|
52
|
+
throw new CheckpointError("checkpoint.invalid_outcome", `resume carries no hostDecision — a policy_ask decide names who decided it ({ decidedBy: "person" | "sla_timeout", approver? }); refusing pre-CAS, the checkpoint stays pending`, { field: "hostDecision" });
|
|
53
|
+
}
|
|
54
|
+
const decidedBy = hostDecisionRaw.decidedBy;
|
|
55
|
+
if (decidedBy !== "person" && decidedBy !== "sla_timeout") {
|
|
56
|
+
throw new CheckpointError("checkpoint.invalid_outcome", `resume hostDecision.decidedBy "${describeSuppliedValue(decidedBy)}" is outside the closed set — it is exactly "person" or "sla_timeout"; refusing pre-CAS, the checkpoint stays pending`, { field: "hostDecision" });
|
|
57
|
+
}
|
|
58
|
+
if (decidedBy === "sla_timeout" && decision === "allow") {
|
|
59
|
+
throw new CheckpointError("checkpoint.invalid_outcome", `resume carries decision "allow" decided by "sla_timeout" — an elapsed SLA window cannot be what approved an action that EXECUTES; refusing pre-CAS, the checkpoint stays pending`, { field: "hostDecision" });
|
|
60
|
+
}
|
|
61
|
+
const attribution = screenApproverAttribution(hostDecisionRaw.approver);
|
|
62
|
+
if (attribution.defect !== undefined) {
|
|
63
|
+
throw new CheckpointError("checkpoint.invalid_outcome", `resume carries an approver attribution this engine refuses: ${attribution.defect}; refusing pre-CAS, the checkpoint stays pending`, { field: "approver" });
|
|
64
|
+
}
|
|
65
|
+
const reason = decide.reason;
|
|
66
|
+
assertOutcomeText(reason, "reason");
|
|
67
|
+
const approverCell = attribution.approver !== undefined ? { approver: attribution.approver } : {};
|
|
68
|
+
plainPolicyOutcome = {
|
|
69
|
+
gate: "policy_ask",
|
|
70
|
+
boundCallId: decide.boundCallId,
|
|
71
|
+
boundInputHash: decide.boundInputHash,
|
|
72
|
+
decision,
|
|
73
|
+
...(updatedInput !== undefined ? { updatedInput } : {}),
|
|
74
|
+
...(reason !== undefined ? { reason } : {}),
|
|
75
|
+
...(redeemedAnswer !== undefined ? { answer: redeemedAnswer } : {}),
|
|
76
|
+
hostDecision: { decidedBy, ...approverCell },
|
|
77
|
+
};
|
|
78
|
+
if (plainPolicyOutcome.decision === "deny" && plainPolicyOutcome.reason && sanitizeUntrustedText(plainPolicyOutcome.reason) !== plainPolicyOutcome.reason) {
|
|
79
|
+
throw new CheckpointError("checkpoint.invalid_outcome", "resume deny/reject reason must not contain a </system-reminder> tag");
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
if (plainPolicyOutcome !== undefined) {
|
|
83
|
+
const boundTo = cp.pendingAction.kind === "tool_approval" ? cp.pendingAction.toolCallId : undefined;
|
|
84
|
+
if (plainPolicyOutcome.boundCallId !== boundTo) {
|
|
85
|
+
throw new CheckpointError("checkpoint.invalid_outcome", `resume boundCallId "${plainPolicyOutcome.boundCallId}" does not match the checkpoint's pending tool call "${boundTo ?? "(none)"}" — the decision-action binding (design/80 D-1) failed; refusing to apply a decision bound to a different action`, { field: "boundCallId" });
|
|
86
|
+
}
|
|
87
|
+
const boundHash = cp.pendingAction.kind === "tool_approval" ? cp.pendingAction.boundInputHash : undefined;
|
|
88
|
+
if (boundHash !== undefined) {
|
|
89
|
+
if (plainPolicyOutcome.boundInputHash !== boundHash) {
|
|
90
|
+
throw new CheckpointError("checkpoint.invalid_outcome", "resume boundInputHash does not match the checkpoint's pending tool call input — the decision-action input binding (design/80 D-1 §2) failed; refusing to apply an approval bound to a different input (TOCTOU re-mint guard)", { field: "boundInputHash" });
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
else if (checkpointVersionOf(cp) >= BINDING_CHECKPOINT_VERSION) {
|
|
94
|
+
throw new CheckpointError("checkpoint.invalid_outcome", `resume of a binding-version (v${checkpointVersionOf(cp)}) checkpoint whose pending tool call has no persisted boundInputHash — refusing to resume an unbound binding-version checkpoint (corruption / downgrade guard)`);
|
|
95
|
+
}
|
|
96
|
+
if (cp.reopenReason === "env_failed") {
|
|
97
|
+
if (cp.resolvedOutcome === undefined) {
|
|
98
|
+
throw new CheckpointError("checkpoint.reopen_revote", "checkpoint was reopened after an env-restore failure (env_failed) but carries no persisted winner to replay — refusing to resume (inconsistent row, fail-closed)");
|
|
99
|
+
}
|
|
100
|
+
const incoming = winnerFromOutcome(plainPolicyOutcome);
|
|
101
|
+
const persistedWinner = (() => {
|
|
102
|
+
const w = cp.resolvedOutcome;
|
|
103
|
+
if (w?.answer === undefined)
|
|
104
|
+
return w;
|
|
105
|
+
const reading = classifyQuestionOutcome(w.answer);
|
|
106
|
+
return reading.shape === "answered" ? { ...w, answer: reading.answer } : w;
|
|
107
|
+
})();
|
|
108
|
+
if (incoming === undefined || !sameWinner(incoming, persistedWinner)) {
|
|
109
|
+
throw new CheckpointError("checkpoint.reopen_revote", `this checkpoint was reopened after an env-restore failure (env_failed) — a re-resume is a system retry of the already-approved action, not a re-vote; the supplied decision must replay the persisted winner (boundCallId "${cp.resolvedOutcome.boundCallId}", decision "${cp.resolvedOutcome.decision}"), refusing a different decision`);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
const contentAskCallId = pendingContentAskCallId(cp);
|
|
113
|
+
if (suppliedAnswer !== undefined && (contentAskCallId === undefined || plainPolicyOutcome.decision !== "allow")) {
|
|
114
|
+
throw new CheckpointError("checkpoint.invalid_outcome", contentAskCallId === undefined
|
|
115
|
+
? "resume carried a content-ask `answer` but the checkpoint's pending call is not the reserved question tool — an answer has no consumer on a side-effecting tool's approval; refusing rather than dropping it silently"
|
|
116
|
+
: "resume carried a content-ask `answer` on a `deny` — a denial injects a refusal, never an answer; refusing rather than dropping it silently", { field: "answer" });
|
|
117
|
+
}
|
|
118
|
+
if (contentAskCallId !== undefined && plainPolicyOutcome.decision === "allow" && suppliedAnswer === undefined) {
|
|
119
|
+
if (!isLiveQuestionFace(config.onQuestion ?? runner.deps.onQuestion)) {
|
|
120
|
+
throw new CheckpointError("checkpoint.invalid_outcome", "resume approved a content-ask (the reserved question tool) without an `answer`, and this resume has no live answering face — " +
|
|
121
|
+
'executing the question against nothing would hand the model a fabricated "no human is available" default while consuming the approval; ' +
|
|
122
|
+
"re-resume with the operator's answer on the outcome (or deny it), the checkpoint stays pending", { field: "answer" });
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return { plainPolicyOutcome, redeemedAnswer };
|
|
127
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* design/393 S3 — the resume ladder's deployment PREFLIGHT rung (E9), verbatim from `Runner.resumeStream`:
|
|
3
|
+
* the LAST rung before the CAS. The deployment's `resumePreflight` screens the resume off the persisted row's
|
|
4
|
+
* facts under its own validated deadline; every non-answer arm refuses retry-later (the row stays pending);
|
|
5
|
+
* only an explicit, fully readable `terminal` verdict settles the row through the single-shot expire CAS
|
|
6
|
+
* (with the reap duty that win carries), and the window verdict is re-asserted after the hook's await.
|
|
7
|
+
*
|
|
8
|
+
* Async (the seat, the expire, the loss re-read, the bounded destroy). It ends by handing off to the rung
|
|
9
|
+
* after it (`next`) in its LAST continuation — the claim opens on the tick the preflight cleared — and
|
|
10
|
+
* returns what that rung returns.
|
|
11
|
+
*/
|
|
12
|
+
import { type Checkpoint, type CheckpointStore, type CheckpointToken } from "../checkpoint-store.js";
|
|
13
|
+
import type { InheritedGate, RunnerDepsSeat, SuspendReap } from "./contracts.js";
|
|
14
|
+
export interface ResumePreflightInput<R> {
|
|
15
|
+
/** borrowed-readonly — the checkpoint token (handed to the deployment and to the settling CAS). */
|
|
16
|
+
token: CheckpointToken;
|
|
17
|
+
/** borrowed-readonly — the pending row; the deployment reads its persisted facts, never the caller's bag. */
|
|
18
|
+
cp: Checkpoint;
|
|
19
|
+
/** borrowed-readonly — the ONE signal read from admission; the seat's wait is bounded by it. */
|
|
20
|
+
resumeSignal: AbortSignal | undefined;
|
|
21
|
+
/** borrowed-readonly — the window verdict to re-assert after the seat's await (undefined when unarmed). */
|
|
22
|
+
recheckGovernanceWindow: (() => Promise<void>) | undefined;
|
|
23
|
+
/** borrowed-readonly — the store the row lives in (the terminal arm's expire and its loss re-read). */
|
|
24
|
+
store: CheckpointStore;
|
|
25
|
+
/** borrowed-readonly — the Runner's deployment deps, read LIVE through the Runner (reads `resumePreflight`, `resumePreflightTimeoutMs`, `onError` (after the seat's await too) and is the seat's owner); the one home of why it is a
|
|
26
|
+
* seat and not a captured object is {@link RunnerDepsSeat}. */
|
|
27
|
+
runner: RunnerDepsSeat;
|
|
28
|
+
/** borrowed-mutable — the Runner's same-Runner claim marks: opened with the expire attempt, released at the duty decision. */
|
|
29
|
+
locallyClaimedTokens: Set<CheckpointToken>;
|
|
30
|
+
/** borrowed-mutable — the Runner's parked-constraint registry: the token's entry is evicted when the terminal arm wins. */
|
|
31
|
+
parentConstraintRegistry: Map<CheckpointToken, NonNullable<InheritedGate["parentConstraints"]>>;
|
|
32
|
+
/** borrowed-mutable — the Runner's suspended-env reap handles: the winning terminal arm takes and destroys this token's. */
|
|
33
|
+
suspendedEnvReaps: Map<CheckpointToken, SuspendReap>;
|
|
34
|
+
/** borrowed-readonly — the rung after the preflight, entered in this rung's last continuation. */
|
|
35
|
+
next: (screened: ResumePreflightResult) => Promise<R>;
|
|
36
|
+
}
|
|
37
|
+
/** The preflight's completion is its whole result: every refusal throws, so reaching `next` IS the fact. */
|
|
38
|
+
export interface ResumePreflightResult {
|
|
39
|
+
}
|
|
40
|
+
export declare function resumePreflight<R>(input: ResumePreflightInput<R>): Promise<R>;
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { CheckpointError } from "../checkpoint-store.js";
|
|
2
|
+
import { MAX_HOOK_TIMEOUT_MS, runHookSeat } from "../hooks.js";
|
|
3
|
+
import { hasDestroy } from "../remote-env.js";
|
|
4
|
+
import { inlineUntrusted } from "../untrusted-text.js";
|
|
5
|
+
import { describeSuppliedValue } from "./decide-continuation.js";
|
|
6
|
+
import { settleTeardownLeg } from "./teardown-bounded.js";
|
|
7
|
+
const RESUME_PREFLIGHT_DEFAULT_TIMEOUT_MS = 10_000;
|
|
8
|
+
export async function resumePreflight(input) {
|
|
9
|
+
const { token, cp, resumeSignal, recheckGovernanceWindow, store, runner, locallyClaimedTokens, parentConstraintRegistry, suspendedEnvReaps } = input;
|
|
10
|
+
if (runner.deps.resumePreflight !== undefined) {
|
|
11
|
+
const preflight = runner.deps.resumePreflight;
|
|
12
|
+
const rawDeadline = runner.deps.resumePreflightTimeoutMs;
|
|
13
|
+
let preflightTimeoutMs = RESUME_PREFLIGHT_DEFAULT_TIMEOUT_MS;
|
|
14
|
+
if (rawDeadline !== undefined) {
|
|
15
|
+
if (typeof rawDeadline === "number" && Number.isFinite(rawDeadline) && rawDeadline > 0 && rawDeadline <= MAX_HOOK_TIMEOUT_MS) {
|
|
16
|
+
preflightTimeoutMs = rawDeadline;
|
|
17
|
+
}
|
|
18
|
+
else {
|
|
19
|
+
try {
|
|
20
|
+
runner.deps.onError?.(new Error(`RunnerDeps.resumePreflightTimeoutMs is not a positive finite number within the seat ceiling of ${MAX_HOOK_TIMEOUT_MS}ms (got ${describeSuppliedValue(rawDeadline)}) — the resume preflight falls back to its own ${RESUME_PREFLIGHT_DEFAULT_TIMEOUT_MS}ms default`), { phase: "config", sessionId: cp.sessionId });
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
const info = {
|
|
27
|
+
token,
|
|
28
|
+
sessionId: cp.sessionId,
|
|
29
|
+
...(cp.state.placementRootSessionId !== undefined ? { placementRootSessionId: cp.state.placementRootSessionId } : {}),
|
|
30
|
+
...(cp.principal ? { principal: cp.principal } : {}),
|
|
31
|
+
...(cp.state.workspaceHandle !== undefined ? { workspaceHandle: structuredClone(cp.state.workspaceHandle) } : {}),
|
|
32
|
+
gateKind: cp.gate.kind,
|
|
33
|
+
};
|
|
34
|
+
const refusalTail = "; the checkpoint stays pending and the same token is redeemable once the obstacle clears";
|
|
35
|
+
let verdict;
|
|
36
|
+
try {
|
|
37
|
+
const seat = await runHookSeat("resumePreflight", {
|
|
38
|
+
timeoutMs: preflightTimeoutMs,
|
|
39
|
+
...(resumeSignal !== undefined ? { signal: resumeSignal } : {}),
|
|
40
|
+
abortEnds: true,
|
|
41
|
+
onBadTimeout: (badErr) => {
|
|
42
|
+
try {
|
|
43
|
+
runner.deps.onError?.(badErr instanceof Error ? badErr : new Error(String(badErr)), { phase: "hook", sessionId: cp.sessionId });
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
owner: runner.deps,
|
|
49
|
+
}, (sig) => preflight(info, sig));
|
|
50
|
+
if (seat.expired) {
|
|
51
|
+
throw new CheckpointError("resume.preflight_rejected", seat.cause === "timeout"
|
|
52
|
+
? `the deployment's resumePreflight did not answer within its ${preflightTimeoutMs}ms deadline while screening this resume — refused fail-closed (retry-later arm)${refusalTail}`
|
|
53
|
+
: `the resume was cancelled while the deployment's resumePreflight was still screening it — refused (retry-later arm)${refusalTail}`);
|
|
54
|
+
}
|
|
55
|
+
verdict = seat.value;
|
|
56
|
+
}
|
|
57
|
+
catch (preflightErr) {
|
|
58
|
+
if (preflightErr instanceof CheckpointError)
|
|
59
|
+
throw preflightErr;
|
|
60
|
+
const err = preflightErr instanceof Error ? preflightErr : new Error(String(preflightErr));
|
|
61
|
+
try {
|
|
62
|
+
runner.deps.onError?.(err, { phase: "hook", sessionId: cp.sessionId });
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
}
|
|
66
|
+
throw new CheckpointError("resume.preflight_rejected", `the deployment's resumePreflight crashed while screening this resume (${inlineUntrusted(err.message)}) — refused fail-closed (retry-later arm)${refusalTail}`);
|
|
67
|
+
}
|
|
68
|
+
const verdictOk = verdict !== null && typeof verdict === "object" ? verdict.ok : undefined;
|
|
69
|
+
if (verdictOk !== true) {
|
|
70
|
+
const bag = verdict !== null && typeof verdict === "object" ? verdict : undefined;
|
|
71
|
+
const disposition = bag?.disposition;
|
|
72
|
+
const suppliedMessage = bag?.message;
|
|
73
|
+
const retryAfterMs = bag?.retryAfterMs;
|
|
74
|
+
const readableMessage = typeof suppliedMessage === "string" && suppliedMessage !== "" ? suppliedMessage : undefined;
|
|
75
|
+
const said = readableMessage !== undefined ? `: ${inlineUntrusted(readableMessage)}` : " (no readable verdict — fail-closed)";
|
|
76
|
+
if (disposition === "terminal" && readableMessage !== undefined) {
|
|
77
|
+
locallyClaimedTokens.add(token);
|
|
78
|
+
let settled;
|
|
79
|
+
try {
|
|
80
|
+
settled = await store.expire(token, cp.scope);
|
|
81
|
+
}
|
|
82
|
+
catch (expireErr) {
|
|
83
|
+
locallyClaimedTokens.delete(token);
|
|
84
|
+
throw expireErr;
|
|
85
|
+
}
|
|
86
|
+
if (!settled) {
|
|
87
|
+
locallyClaimedTokens.delete(token);
|
|
88
|
+
const live = await store.get(token);
|
|
89
|
+
if (live?.status === "pending") {
|
|
90
|
+
throw new CheckpointError("resume.preflight_rejected", `the deployment's resumePreflight ruled this resume PERMANENTLY blocked${said}, but the row moved under this refusal (a concurrent resolve/reopen cycle) — nothing was settled here; the row is pending again, re-resume against the current state (the preflight screens every attempt)`);
|
|
91
|
+
}
|
|
92
|
+
throw new CheckpointError("resume.preflight_rejected", `the deployment's resumePreflight ruled this resume PERMANENTLY blocked${said} — a concurrent actor had already settled the row (${describeSuppliedValue(live?.status ?? "consumed")}); nothing was settled by this refusal`);
|
|
93
|
+
}
|
|
94
|
+
parentConstraintRegistry.delete(token);
|
|
95
|
+
const terminalReap = suspendedEnvReaps.get(token);
|
|
96
|
+
suspendedEnvReaps.delete(token);
|
|
97
|
+
locallyClaimedTokens.delete(token);
|
|
98
|
+
if (terminalReap?.env !== undefined && hasDestroy(terminalReap.env)) {
|
|
99
|
+
const terminalEnv = terminalReap.env;
|
|
100
|
+
await settleTeardownLeg(() => terminalEnv.destroy(), "terminal-preflight env destroy", (envErr) => {
|
|
101
|
+
try {
|
|
102
|
+
runner.deps.onError?.(envErr, { phase: "config", sessionId: terminalReap.sessionId });
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
try {
|
|
109
|
+
runner.deps.onError?.(new Error(`resumePreflight ruled this row PERMANENTLY blocked${said} — the row was settled terminally (expired) by this refusal's single-shot CAS (token no longer redeemable)`), { phase: "hook", sessionId: cp.sessionId });
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
}
|
|
113
|
+
throw new CheckpointError("resume.preflight_rejected", `the deployment's resumePreflight ruled this resume PERMANENTLY blocked${said} — the row was settled terminally (expired by this refusal's single-shot CAS); the token is not redeemable`);
|
|
114
|
+
}
|
|
115
|
+
const waitHint = typeof retryAfterMs === "number" && Number.isFinite(retryAfterMs) && retryAfterMs >= 0 ? retryAfterMs : undefined;
|
|
116
|
+
throw new CheckpointError("resume.preflight_rejected", `the deployment's resumePreflight refused this resume${said}${refusalTail}${waitHint !== undefined ? ` (deployment wait hint: ${String(waitHint)}ms)` : ""}`, waitHint !== undefined ? { retryAfterMs: waitHint } : undefined);
|
|
117
|
+
}
|
|
118
|
+
if (recheckGovernanceWindow !== undefined)
|
|
119
|
+
await recheckGovernanceWindow();
|
|
120
|
+
}
|
|
121
|
+
return await input.next({});
|
|
122
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* design/393 S3 — the resume ladder's gate-match rung and the review / park TWINS (E3), verbatim from
|
|
3
|
+
* `Runner.resumeStream`: the outcome arm must match the persisted gate kind; a review-gate outcome
|
|
4
|
+
* (plan_review / dry_run_review) is captured into a plain twin with its domain and text-payload checks, the
|
|
5
|
+
* break-out-tag hygiene and the env_failed replay guard; the two tool-less park lanes (wake / resource_limit)
|
|
6
|
+
* get their twins too, so no lane hands the caller's object downstream. Synchronous; every refusal throws
|
|
7
|
+
* pre-CAS.
|
|
8
|
+
*/
|
|
9
|
+
import { type Checkpoint, type PendingSteerEntry, type ResumeOutcome } from "../checkpoint-store.js";
|
|
10
|
+
export interface ResumeReviewOutcomeInput {
|
|
11
|
+
/** borrowed-readonly — the pending row (the lane is keyed on the STORED gate kind). */
|
|
12
|
+
cp: Checkpoint;
|
|
13
|
+
/** borrowed-readonly — the caller's outcome; each review field is read exactly once, into the twin. */
|
|
14
|
+
outcome: ResumeOutcome;
|
|
15
|
+
/** borrowed-readonly — the ONE read of the outcome discriminant, taken at admission. */
|
|
16
|
+
outcomeGate: ResumeOutcome["gate"];
|
|
17
|
+
/** borrowed-readonly — the validated wake message, carried into the wake twin. */
|
|
18
|
+
wakeMessage: Omit<PendingSteerEntry, "seq"> | undefined;
|
|
19
|
+
}
|
|
20
|
+
export interface ResumeReviewOutcomeResult {
|
|
21
|
+
/** The review lane's plain twin (a plan_review / needs_review row), else undefined. */
|
|
22
|
+
plainReviewOutcome: Extract<ResumeOutcome, {
|
|
23
|
+
gate: "plan_review" | "dry_run_review";
|
|
24
|
+
}> | undefined;
|
|
25
|
+
/** The tool-less park lanes' plain twin (a wake or a resource_limit continue), else undefined. */
|
|
26
|
+
plainParkOutcome: Extract<ResumeOutcome, {
|
|
27
|
+
gate: "wake" | "resource_limit";
|
|
28
|
+
}> | undefined;
|
|
29
|
+
}
|
|
30
|
+
export declare function resumeReviewOutcome(input: ResumeReviewOutcomeInput): ResumeReviewOutcomeResult;
|