@sema-agent/core 7.8.0 → 7.9.1
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 +75 -1
- package/dist/agents/subagent.d.ts +1 -1
- package/dist/core/ask-origin.d.ts +16 -1
- package/dist/core/ask-origin.js +5 -1
- package/dist/core/checkpoint-store.d.ts +36 -35
- package/dist/core/effective-path-target.d.ts +43 -0
- package/dist/core/effective-path-target.js +56 -0
- package/dist/core/engine-notice.d.ts +8 -0
- package/dist/core/fs-write-gate-policy.js +2 -1
- package/dist/core/gate-lanes.d.ts +1 -0
- package/dist/core/gate-lanes.js +60 -28
- package/dist/core/gate-outcome.d.ts +8 -4
- package/dist/core/gate-outcome.js +2 -1
- package/dist/core/governance-codes.d.ts +1 -1
- package/dist/core/governance-codes.js +2 -0
- package/dist/core/hooks.d.ts +66 -32
- package/dist/core/mcp-injection-drop.d.ts +74 -0
- package/dist/core/mcp-injection-drop.js +27 -0
- 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 +311 -96
- package/dist/core/permission-rule-model.js +309 -50
- package/dist/core/permission-rule-org.d.ts +19 -12
- package/dist/core/permission-rule-org.js +13 -16
- 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 +56 -33
- package/dist/core/permission-rules.js +88 -55
- package/dist/core/persisted-rule-arms.d.ts +61 -0
- package/dist/core/persisted-rule-arms.js +50 -0
- package/dist/core/runner/active-skill-scope.js +2 -1
- package/dist/core/runner/contracts.d.ts +21 -2
- package/dist/core/runner/permission-rule-lanes.d.ts +42 -27
- package/dist/core/runner/permission-rule-lanes.js +25 -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/session-rule-policy.js +2 -1
- 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-face-overlay.js +22 -3
- 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/sensitive-path-policy.js +5 -3
- package/dist/core/shell-lexer.d.ts +47 -0
- package/dist/core/shell-lexer.js +478 -0
- package/dist/core/shell-scan.d.ts +60 -0
- package/dist/core/shell-scan.js +183 -0
- package/dist/core/shell-wrapper-table.d.ts +297 -0
- package/dist/core/shell-wrapper-table.js +58 -0
- package/dist/core/store-contracts/permission-rule-sync-contract.js +29 -10
- package/dist/core/tool-catalog-entries.js +6 -6
- package/dist/core/tool-face.d.ts +80 -4
- package/dist/core/tool-face.js +10 -0
- package/dist/core/tool-policy.d.ts +1 -6
- package/dist/core/tool-registry.d.ts +8 -11
- package/dist/core/tool-registry.js +5 -2
- package/dist/core/tool-roster.d.ts +20 -2
- package/dist/core/tool-roster.js +36 -8
- package/dist/index.d.ts +7 -4
- package/dist/index.js +6 -3
- 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/fs-search-tools.d.ts +3 -2
- package/dist/tools/fs/fs-search-tools.js +17 -9
- package/dist/tools/fs/index.js +1 -1
- package/dist/tools/fs/notebook.d.ts +1 -1
- package/dist/tools/fs/safety.d.ts +1 -1
- package/dist/tools/fs/search.d.ts +0 -8
- package/dist/tools/fs/search.js +0 -23
- package/package.json +1 -1
- package/test/export-surface.snapshot.json +99 -11
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import { snapshotActorAssertion } from "../../internal/llm.js";
|
|
2
|
+
import { isAskOrigin } from "../ask-origin.js";
|
|
3
|
+
import { ASK_USER_QUESTION_TOOL_NAME } from "../ask-question.js";
|
|
4
|
+
import { CheckpointError, remainingBudgetMicroUsd } from "../checkpoint-store.js";
|
|
5
|
+
import { cloneObserverInput, mintHookInvocationIdentity, resolveHookTimeoutMs, runHookSeat } from "../hooks.js";
|
|
6
|
+
import { checkToolPolicyProjection, refuseOutOfContractDecision } from "../tool-policy.js";
|
|
7
|
+
import { callFaceSeat, parkedCallFaceOf } from "../tool-roster.js";
|
|
8
|
+
import { inlineUntrusted } from "../untrusted-text.js";
|
|
9
|
+
import { deepJsonEqual, describeSuppliedValue } from "./decide-continuation.js";
|
|
10
|
+
import { mintGateOutcome } from "./gate-exit.js";
|
|
11
|
+
export async function resumeApply(input) {
|
|
12
|
+
const { cp, config, internals, outcomeGate, wakeMessage, resumeSignal, recheckGovernanceWindow, plainReviewOutcome, plainParkOutcome, runner, effectiveDelegationFacts } = input;
|
|
13
|
+
let plainPolicyOutcome = input.plainPolicyOutcome;
|
|
14
|
+
let wakeHookContext;
|
|
15
|
+
if (outcomeGate === "wake" && wakeMessage !== undefined) {
|
|
16
|
+
const resumeScreenHooks = config.hooks ?? runner.deps.hooks;
|
|
17
|
+
const screen = resumeScreenHooks?.userPromptSubmit;
|
|
18
|
+
if (screen !== undefined && resumeSignal?.aborted !== true) {
|
|
19
|
+
const screenedMessage = wakeMessage;
|
|
20
|
+
const identity = mintHookInvocationIdentity({
|
|
21
|
+
sessionId: cp.sessionId,
|
|
22
|
+
taskId: config.taskId ?? cp.sessionId,
|
|
23
|
+
legKind: "resume",
|
|
24
|
+
isDelegatedChild: effectiveDelegationFacts(internals, cp.state.isDelegatedChild).isDelegatedChild,
|
|
25
|
+
...(internals?.insideFork === true ? { insideFork: true } : {}),
|
|
26
|
+
...(internals?.agentName !== undefined ? { agentName: internals.agentName } : {}),
|
|
27
|
+
...(internals?.parentToolCallId !== undefined ? { parentToolCallId: internals.parentToolCallId } : {}),
|
|
28
|
+
});
|
|
29
|
+
const screenTimeoutMs = resolveHookTimeoutMs(resumeScreenHooks?.timeoutMs, (badErr) => {
|
|
30
|
+
try {
|
|
31
|
+
runner.deps.onError?.(badErr instanceof Error ? badErr : new Error(String(badErr)), { phase: "hook", sessionId: cp.sessionId });
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
}
|
|
35
|
+
}, resumeScreenHooks);
|
|
36
|
+
let decision;
|
|
37
|
+
try {
|
|
38
|
+
const seat = await runHookSeat("userPromptSubmit", { timeoutMs: screenTimeoutMs, ...(resumeSignal !== undefined ? { signal: resumeSignal } : {}), abortEnds: true }, (sig) => screen(screenedMessage.text, {
|
|
39
|
+
identity,
|
|
40
|
+
signal: sig,
|
|
41
|
+
source: "resume_message",
|
|
42
|
+
...(screenedMessage.inputId !== undefined ? { inputId: screenedMessage.inputId } : {}),
|
|
43
|
+
...(screenedMessage.actor !== undefined ? { actor: snapshotActorAssertion(screenedMessage.actor) } : {}),
|
|
44
|
+
}));
|
|
45
|
+
if (seat.expired) {
|
|
46
|
+
throw new CheckpointError("steering.blocked_by_hook", seat.cause === "timeout"
|
|
47
|
+
? `the deployment's userPromptSubmit hook did not answer within its ${screenTimeoutMs}ms bound while screening this wake message; the resume was refused (fail-closed) and the checkpoint stays pending`
|
|
48
|
+
: `the resume was cancelled while the deployment's userPromptSubmit hook was still screening this wake message; the checkpoint stays pending`);
|
|
49
|
+
}
|
|
50
|
+
decision = seat.value;
|
|
51
|
+
}
|
|
52
|
+
catch (hookErr) {
|
|
53
|
+
if (hookErr instanceof CheckpointError)
|
|
54
|
+
throw hookErr;
|
|
55
|
+
const err = hookErr instanceof Error ? hookErr : new Error(String(hookErr));
|
|
56
|
+
try {
|
|
57
|
+
runner.deps.onError?.(err, { phase: "hook", sessionId: cp.sessionId });
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
}
|
|
61
|
+
throw new CheckpointError("steering.blocked_by_hook", `the deployment's userPromptSubmit hook crashed while screening this wake message (${inlineUntrusted(err.message)}); the resume was refused (fail-closed) and the checkpoint stays pending`);
|
|
62
|
+
}
|
|
63
|
+
if (decision?.block !== undefined && decision.block !== "") {
|
|
64
|
+
throw new CheckpointError("steering.blocked_by_hook", `the deployment's userPromptSubmit hook blocked this wake message: ${inlineUntrusted(decision.block)}`);
|
|
65
|
+
}
|
|
66
|
+
if (decision?.additionalContext !== undefined && decision.additionalContext !== "") {
|
|
67
|
+
wakeHookContext = decision.additionalContext;
|
|
68
|
+
}
|
|
69
|
+
if (recheckGovernanceWindow !== undefined)
|
|
70
|
+
await recheckGovernanceWindow();
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
if (plainPolicyOutcome !== undefined &&
|
|
74
|
+
plainPolicyOutcome.decision === "allow" &&
|
|
75
|
+
plainPolicyOutcome.updatedInput !== undefined &&
|
|
76
|
+
cp.pendingAction.kind === "tool_approval" &&
|
|
77
|
+
cp.pendingAction.toolName !== ASK_USER_QUESTION_TOOL_NAME) {
|
|
78
|
+
const editedArgs = plainPolicyOutcome.updatedInput;
|
|
79
|
+
const editBudget = {
|
|
80
|
+
resourceRemainingMicroUsd: remainingBudgetMicroUsd(cp.resourceLedger),
|
|
81
|
+
resourceSpentMicroUsd: cp.resourceLedger?.spentMicroUsd ?? 0,
|
|
82
|
+
suspendCount: cp.suspendCount ?? 0,
|
|
83
|
+
};
|
|
84
|
+
const editedReq = {
|
|
85
|
+
toolName: cp.pendingAction.toolName,
|
|
86
|
+
args: editedArgs,
|
|
87
|
+
toolCallId: cp.pendingAction.toolCallId,
|
|
88
|
+
budget: editBudget,
|
|
89
|
+
...callFaceSeat(parkedCallFaceOf(cp.pendingAction)),
|
|
90
|
+
...(cp.state.handsCwd !== undefined ? { cwd: cp.state.handsCwd } : {}),
|
|
91
|
+
};
|
|
92
|
+
const editSignal = config.signal;
|
|
93
|
+
const raceEditAbort = async (p) => {
|
|
94
|
+
if (editSignal === undefined)
|
|
95
|
+
return p;
|
|
96
|
+
if (editSignal.aborted) {
|
|
97
|
+
throw new CheckpointError("checkpoint.resume_aborted", "the resume was aborted while re-adjudicating the approver's edit — the checkpoint stays pending and is resumable with a live signal");
|
|
98
|
+
}
|
|
99
|
+
return new Promise((resolve, reject) => {
|
|
100
|
+
const onAbort = () => reject(new CheckpointError("checkpoint.resume_aborted", "the resume was aborted while re-adjudicating the approver's edit — the checkpoint stays pending and is resumable with a live signal"));
|
|
101
|
+
editSignal.addEventListener("abort", onAbort, { once: true });
|
|
102
|
+
p.then((v) => { editSignal.removeEventListener("abort", onAbort); resolve(v); }, (e) => { editSignal.removeEventListener("abort", onAbort); reject(e); });
|
|
103
|
+
});
|
|
104
|
+
};
|
|
105
|
+
const ig = cp.state.inheritedGate;
|
|
106
|
+
if (ig?.requiresParentConstraint === true) {
|
|
107
|
+
const chain = ig.constraintChain;
|
|
108
|
+
const unprojectable = chain === undefined
|
|
109
|
+
? "the checkpoint predates the frozen projection chain (no constraintChain recorded)"
|
|
110
|
+
: chain.some((e) => "opaque" in e)
|
|
111
|
+
? "the inherited chain carries an OPAQUE layer (no projection exported)"
|
|
112
|
+
: chain.some((e) => !("opaque" in e) && e.requiresLiveRemainder)
|
|
113
|
+
? "an inherited chain layer declares a live remainder beyond its projection"
|
|
114
|
+
: undefined;
|
|
115
|
+
if (unprojectable !== undefined) {
|
|
116
|
+
throw new CheckpointError("resume.constraint_unprojectable", `the approver EDITED this call's input, but the inherited ancestor chain cannot re-adjudicate the edit statically: ${unprojectable}. ` +
|
|
117
|
+
"Refusing the edited resume pre-CAS (the checkpoint stays pending and remains decidable) — approve the shown input as-is, deny it, " +
|
|
118
|
+
"or re-issue the edited action as a fresh call so the full live chain adjudicates it");
|
|
119
|
+
}
|
|
120
|
+
for (const entry of chain) {
|
|
121
|
+
const denied = checkToolPolicyProjection(entry, editedReq);
|
|
122
|
+
if (denied !== undefined) {
|
|
123
|
+
throw new CheckpointError("resume.constraint_rejected", `the approver EDITED this call's input and a FROZEN inherited ancestor constraint denies the edited args${denied.message ? `: ${denied.message}` : ""}. ` +
|
|
124
|
+
"Refused pre-CAS (fresh-redecision semantics: the checkpoint stays pending and the same token remains decidable — approve as shown, deny, or edit differently)");
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
const toolPolicyLocked = runner.deps.lockedConfig?.keys?.includes("toolPolicy") === true;
|
|
129
|
+
const editBasePolicy = toolPolicyLocked
|
|
130
|
+
? (runner.deps.basePolicyForResumeEdit ?? runner.deps.toolPolicy)
|
|
131
|
+
: (config.basePolicyForResumeEdit ?? runner.deps.basePolicyForResumeEdit ?? config.toolPolicy ?? runner.deps.toolPolicy);
|
|
132
|
+
if (editBasePolicy !== undefined) {
|
|
133
|
+
let rechecked;
|
|
134
|
+
try {
|
|
135
|
+
rechecked = refuseOutOfContractDecision(await raceEditAbort(Promise.resolve(editBasePolicy.check(editedReq, editSignal))));
|
|
136
|
+
}
|
|
137
|
+
catch (err) {
|
|
138
|
+
if (err instanceof CheckpointError)
|
|
139
|
+
throw err;
|
|
140
|
+
rechecked = { action: "deny", message: `the resume-edit policy re-check errored: ${err instanceof Error ? err.message : String(err)}` };
|
|
141
|
+
}
|
|
142
|
+
if (rechecked.action === "deny") {
|
|
143
|
+
throw new CheckpointError("resume.constraint_rejected", `the approver EDITED this call's input and the deployment's resume-edit policy denies the edited args${rechecked.message ? `: ${rechecked.message}` : ""}. ` +
|
|
144
|
+
"Refused pre-CAS (the checkpoint stays pending and remains decidable)");
|
|
145
|
+
}
|
|
146
|
+
if (rechecked.updatedInput !== undefined && !deepJsonEqual(rechecked.updatedInput, editedArgs)) {
|
|
147
|
+
throw new CheckpointError("resume.constraint_rejected", `the deployment's resume-edit policy REWROTE the approver's edited args to a different value than was approved — the durable resume cannot execute either the un-adjudicated edit or an unapproved rewrite; ` +
|
|
148
|
+
`refused pre-CAS (the checkpoint stays pending); re-submit the edited action so the rewrite is adjudicated and approved synchronously`);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
const resumeHooks = config.hooks ?? runner.deps.hooks;
|
|
152
|
+
if (resumeHooks?.preToolUse !== undefined && resumeHooks.preToolUseObservational === true) {
|
|
153
|
+
try {
|
|
154
|
+
await raceEditAbort(Promise.resolve(resumeHooks.preToolUse(cp.pendingAction.toolName, cloneObserverInput(editedArgs), {
|
|
155
|
+
toolCallId: cp.pendingAction.toolCallId,
|
|
156
|
+
toolName: cp.pendingAction.toolName,
|
|
157
|
+
})));
|
|
158
|
+
}
|
|
159
|
+
catch (err) {
|
|
160
|
+
if (err instanceof CheckpointError)
|
|
161
|
+
throw err;
|
|
162
|
+
throw new CheckpointError("resume.constraint_rejected", `the declared-observational PreToolUse face threw while observing the approver's edited args (${err instanceof Error ? err.message : String(err)}) — ` +
|
|
163
|
+
"refused fail-closed pre-CAS (the checkpoint stays pending and remains decidable)");
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
if (plainPolicyOutcome !== undefined) {
|
|
168
|
+
const parkedOrigin = cp.pendingAction.kind === "tool_approval" ? cp.pendingAction.origin : undefined;
|
|
169
|
+
if (!isAskOrigin(parkedOrigin)) {
|
|
170
|
+
throw new CheckpointError("checkpoint.unsupported_version", "this checkpoint's pending approval carries no origin word (a row minted before the ask's origin was persisted on the park) — its settlement cannot be recorded; the checkpoint stays pending", { reason: "origin_missing" });
|
|
171
|
+
}
|
|
172
|
+
const facts = plainPolicyOutcome.hostDecision;
|
|
173
|
+
const approverCell = facts.approver !== undefined ? { approver: facts.approver } : {};
|
|
174
|
+
const settlement = facts.decidedBy === "sla_timeout"
|
|
175
|
+
? { kind: "park_sla_expired", who: { party: "host", ...approverCell }, when: Date.now() }
|
|
176
|
+
: { kind: plainPolicyOutcome.decision === "allow" ? "human_allowed" : "human_refused", who: { party: "person", ...approverCell }, when: Date.now(), ...(plainPolicyOutcome.decision === "deny" && plainPolicyOutcome.reason !== undefined ? { note: plainPolicyOutcome.reason } : {}) };
|
|
177
|
+
plainPolicyOutcome = { ...plainPolicyOutcome, gateOutcome: mintGateOutcome({ ...(plainPolicyOutcome.decision === "deny" ? { deniedBy: "ask_resolution" } : {}), settled: { settlement, origin: parkedOrigin } }) };
|
|
178
|
+
}
|
|
179
|
+
const outcomeForStore = plainPolicyOutcome ?? plainReviewOutcome ?? plainParkOutcome;
|
|
180
|
+
if (outcomeForStore === undefined) {
|
|
181
|
+
throw new CheckpointError("checkpoint.invalid_outcome", `resume outcome (gate "${describeSuppliedValue(outcomeGate)}") matched the checkpoint gate but no lane captured it — refusing pre-CAS rather than passing the caller's live object to the store and the resumed run`);
|
|
182
|
+
}
|
|
183
|
+
return await input.next({ wakeHookContext, plainPolicyOutcome, outcomeForStore });
|
|
184
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { type Checkpoint } from "../checkpoint-store.js";
|
|
2
|
+
import type { RunInternals, RunnerDepsSeat } from "./contracts.js";
|
|
3
|
+
export interface ResumeCheckpointScreenInput {
|
|
4
|
+
/** borrowed-readonly — the pending row; every invariant is judged on its persisted fields. */
|
|
5
|
+
cp: Checkpoint;
|
|
6
|
+
/** borrowed-readonly — the ONE signal read from admission; `.aborted` is read live here. */
|
|
7
|
+
resumeSignal: AbortSignal | undefined;
|
|
8
|
+
/** borrowed-readonly — the internals snapshot (the re-supplied chain, the requested cwd). */
|
|
9
|
+
internals: RunInternals | undefined;
|
|
10
|
+
/** borrowed-readonly — the Runner's deployment deps, read LIVE through the Runner (reads `permissionRuleStore` and `executionEnvFactory`); the one home of why it is a
|
|
11
|
+
* seat and not a captured object is {@link RunnerDepsSeat}. */
|
|
12
|
+
runner: RunnerDepsSeat;
|
|
13
|
+
}
|
|
14
|
+
export interface ResumeCheckpointScreenResult {
|
|
15
|
+
/** The internals the resumed leg is handed: the validated chain frozen at the array level (entries by reference). */
|
|
16
|
+
resumeInternals: RunInternals | undefined;
|
|
17
|
+
}
|
|
18
|
+
export declare function resumeCheckpointScreen(input: ResumeCheckpointScreenInput): ResumeCheckpointScreenResult;
|
|
@@ -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
|
+
}
|