@vincemakes/kiso-runtime 0.1.36 → 0.1.37
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/dist/recovery-plan.d.ts +74 -0
- package/dist/recovery-plan.js +209 -0
- package/dist/run.js +361 -321
- package/package.json +1 -1
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* R-F 0.1.46 — the recovery plan: recovery as pure projection. From the
|
|
3
|
+
* durable event prefix the plan derives THE unique safe next step — never
|
|
4
|
+
* the adjudication itself (the approval pipeline stays in the runtime driver
|
|
5
|
+
* layer, run.ts). Purity: no I/O, no ID generation, no time — the same
|
|
6
|
+
* prefix always derives the same plan. The driver consumes one action at a
|
|
7
|
+
* time and re-derives after every append: the recovery is a loop over this
|
|
8
|
+
* projection, not a second state machine (the R-F thesis — fresh execution
|
|
9
|
+
* and resume walk the same ordinary program).
|
|
10
|
+
*
|
|
11
|
+
* The action vocabulary (the R-F directive):
|
|
12
|
+
* COMPLETED — no open run: nothing to recover
|
|
13
|
+
* TERMINAL — the open run reached its terminal
|
|
14
|
+
* WAIT_PERMISSION(seq) — a stored request awaits the human
|
|
15
|
+
* DECIDE_PERMISSION(seq) — a committed call re-enters the approval pipeline
|
|
16
|
+
* EXECUTE(seq) — a durable approval authorizes the persisted call
|
|
17
|
+
* RESOLVE_UNCERTAIN(id) — a started execution with no receipt: the crash
|
|
18
|
+
* window — the human decides (never auto-rerun)
|
|
19
|
+
* REPAIR_RESULT(id|seq) — the model-facing result is missing: complete it
|
|
20
|
+
* from the durable fact (the receipt or the denial)
|
|
21
|
+
* FILL_RESOLUTION(id) — a resolution's model-facing fill is missing
|
|
22
|
+
* ABANDON_DRAFT(from) — a text-bearing no-stop suffix: void it (the
|
|
23
|
+
* driver appends the marker AND expires the voided
|
|
24
|
+
* requests — one deterministic step, sentence 3)
|
|
25
|
+
* CONTINUE_MODEL — nothing left to repair: drive the loop
|
|
26
|
+
*
|
|
27
|
+
* The derivation order is the R-E recovery's phase order (the zero-behavior
|
|
28
|
+
* proof: the prefix-table gate and the healing fixtures run unchanged):
|
|
29
|
+
* terminal > completed > uncertain > draft > invocations (the Gap A calls,
|
|
30
|
+
* then the stored requests) > receipt repairs > resolution fills > continue.
|
|
31
|
+
*
|
|
32
|
+
* Inputs: `events` — the session's full event prefix (the log); `scope` —
|
|
33
|
+
* the open run's stored events at resume start (the run boundaries). Both
|
|
34
|
+
* are pure inputs; the caller loads them.
|
|
35
|
+
*/
|
|
36
|
+
import type { Event } from "@vincemakes/kiso-core";
|
|
37
|
+
export type RecoveryAction = {
|
|
38
|
+
readonly kind: "COMPLETED";
|
|
39
|
+
} | {
|
|
40
|
+
readonly kind: "TERMINAL";
|
|
41
|
+
} | {
|
|
42
|
+
readonly kind: "WAIT_PERMISSION";
|
|
43
|
+
readonly invocationSeq: number;
|
|
44
|
+
} | {
|
|
45
|
+
readonly kind: "DECIDE_PERMISSION";
|
|
46
|
+
readonly invocationSeq: number;
|
|
47
|
+
} | {
|
|
48
|
+
readonly kind: "EXECUTE";
|
|
49
|
+
readonly invocationSeq: number;
|
|
50
|
+
}
|
|
51
|
+
/** The receipt repair (executionId) or the durable-denial repair (invocationSeq). */
|
|
52
|
+
| {
|
|
53
|
+
readonly kind: "REPAIR_RESULT";
|
|
54
|
+
readonly executionId?: string;
|
|
55
|
+
readonly invocationSeq?: number;
|
|
56
|
+
} | {
|
|
57
|
+
readonly kind: "RESOLVE_UNCERTAIN";
|
|
58
|
+
readonly executionId: string;
|
|
59
|
+
} | {
|
|
60
|
+
readonly kind: "FILL_RESOLUTION";
|
|
61
|
+
readonly executionId: string;
|
|
62
|
+
} | {
|
|
63
|
+
readonly kind: "ABANDON_DRAFT";
|
|
64
|
+
readonly voidFromSeq: number;
|
|
65
|
+
} | {
|
|
66
|
+
readonly kind: "CONTINUE_MODEL";
|
|
67
|
+
};
|
|
68
|
+
/** A request's framework identity: its own invocationSeq, or the old-log
|
|
69
|
+
* fallback (the last same-callId call before the request, in the scope). */
|
|
70
|
+
export declare function invocationSeqOf(request: Event & {
|
|
71
|
+
type: "permission_requested";
|
|
72
|
+
}, scope: readonly Event[]): number | undefined;
|
|
73
|
+
/** The one safe next step for the durable prefix (derivation order above). */
|
|
74
|
+
export declare function deriveRecoveryPlan(events: readonly Event[], scope: readonly Event[]): RecoveryAction;
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* R-F 0.1.46 — the recovery plan: recovery as pure projection. From the
|
|
3
|
+
* durable event prefix the plan derives THE unique safe next step — never
|
|
4
|
+
* the adjudication itself (the approval pipeline stays in the runtime driver
|
|
5
|
+
* layer, run.ts). Purity: no I/O, no ID generation, no time — the same
|
|
6
|
+
* prefix always derives the same plan. The driver consumes one action at a
|
|
7
|
+
* time and re-derives after every append: the recovery is a loop over this
|
|
8
|
+
* projection, not a second state machine (the R-F thesis — fresh execution
|
|
9
|
+
* and resume walk the same ordinary program).
|
|
10
|
+
*
|
|
11
|
+
* The action vocabulary (the R-F directive):
|
|
12
|
+
* COMPLETED — no open run: nothing to recover
|
|
13
|
+
* TERMINAL — the open run reached its terminal
|
|
14
|
+
* WAIT_PERMISSION(seq) — a stored request awaits the human
|
|
15
|
+
* DECIDE_PERMISSION(seq) — a committed call re-enters the approval pipeline
|
|
16
|
+
* EXECUTE(seq) — a durable approval authorizes the persisted call
|
|
17
|
+
* RESOLVE_UNCERTAIN(id) — a started execution with no receipt: the crash
|
|
18
|
+
* window — the human decides (never auto-rerun)
|
|
19
|
+
* REPAIR_RESULT(id|seq) — the model-facing result is missing: complete it
|
|
20
|
+
* from the durable fact (the receipt or the denial)
|
|
21
|
+
* FILL_RESOLUTION(id) — a resolution's model-facing fill is missing
|
|
22
|
+
* ABANDON_DRAFT(from) — a text-bearing no-stop suffix: void it (the
|
|
23
|
+
* driver appends the marker AND expires the voided
|
|
24
|
+
* requests — one deterministic step, sentence 3)
|
|
25
|
+
* CONTINUE_MODEL — nothing left to repair: drive the loop
|
|
26
|
+
*
|
|
27
|
+
* The derivation order is the R-E recovery's phase order (the zero-behavior
|
|
28
|
+
* proof: the prefix-table gate and the healing fixtures run unchanged):
|
|
29
|
+
* terminal > completed > uncertain > draft > invocations (the Gap A calls,
|
|
30
|
+
* then the stored requests) > receipt repairs > resolution fills > continue.
|
|
31
|
+
*
|
|
32
|
+
* Inputs: `events` — the session's full event prefix (the log); `scope` —
|
|
33
|
+
* the open run's stored events at resume start (the run boundaries). Both
|
|
34
|
+
* are pure inputs; the caller loads them.
|
|
35
|
+
*/
|
|
36
|
+
import { executionLedger } from "./ledger.js";
|
|
37
|
+
/** The committed boundaries of a run's events (Gap B's boundary list). */
|
|
38
|
+
const isBoundary = (e) => e.type === "stop" ||
|
|
39
|
+
e.type === "user_input" ||
|
|
40
|
+
e.type === "terminal" ||
|
|
41
|
+
e.type === "microcompacted" ||
|
|
42
|
+
e.type === "compacted" ||
|
|
43
|
+
e.type === "summarized" ||
|
|
44
|
+
e.type === "model_output_abandoned";
|
|
45
|
+
/** A request's framework identity: its own invocationSeq, or the old-log
|
|
46
|
+
* fallback (the last same-callId call before the request, in the scope). */
|
|
47
|
+
export function invocationSeqOf(request, scope) {
|
|
48
|
+
if (request.invocationSeq !== undefined)
|
|
49
|
+
return request.invocationSeq;
|
|
50
|
+
let seq;
|
|
51
|
+
for (const e of scope) {
|
|
52
|
+
if (e.type === "tool_call_end" && e.callId === request.callId && e.seq < request.seq)
|
|
53
|
+
seq = e.seq;
|
|
54
|
+
}
|
|
55
|
+
return seq;
|
|
56
|
+
}
|
|
57
|
+
/** The events of the open run INCLUDING the recovery's own appends — the
|
|
58
|
+
* scope plus everything the driver appended after it (seq is global and
|
|
59
|
+
* monotonic, so the tail is exactly the appends). */
|
|
60
|
+
function openRunEvents(events, scope) {
|
|
61
|
+
if (scope.length === 0)
|
|
62
|
+
return events;
|
|
63
|
+
const lastScopeSeq = scope[scope.length - 1].seq;
|
|
64
|
+
const tail = events.filter((e) => e.seq > lastScopeSeq);
|
|
65
|
+
return [...scope, ...tail];
|
|
66
|
+
}
|
|
67
|
+
/** The one safe next step for the durable prefix (derivation order above). */
|
|
68
|
+
export function deriveRecoveryPlan(events, scope) {
|
|
69
|
+
// 1. the open run reached its terminal → done. The terminal may be in the
|
|
70
|
+
// scope (a resume adopted a run the loop completed in a previous
|
|
71
|
+
// process) or in the driver's own tail (the continuation's terminal).
|
|
72
|
+
if (openRunEvents(events, scope).some((e) => e.type === "terminal"))
|
|
73
|
+
return { kind: "TERMINAL" };
|
|
74
|
+
// 2. nothing open → nothing to recover.
|
|
75
|
+
if (scope.length === 0)
|
|
76
|
+
return { kind: "COMPLETED" };
|
|
77
|
+
// 3. the crash window: a started execution with no receipt is the human's
|
|
78
|
+
// (never auto-rerun — the prefix-table gate's row 7). The FIRST in log
|
|
79
|
+
// order blocks; the driver throws with the full list. A receipted
|
|
80
|
+
// execution is an outcome (ruling #12 / the α ruling: the audit keeps
|
|
81
|
+
// it) — never uncertain.
|
|
82
|
+
const uncertain = [...executionLedger(events).values()].filter((r) => r.status === "uncertain");
|
|
83
|
+
if (uncertain.length > 0)
|
|
84
|
+
return { kind: "RESOLVE_UNCERTAIN", executionId: uncertain[0].executionId };
|
|
85
|
+
// 4. Gap B: a text-bearing no-stop suffix is an abandoned draft — void it.
|
|
86
|
+
// Text-only detection (the 0.1.44 verification): a bare tool-call
|
|
87
|
+
// suffix is the legal approval-panel pause, never a draft. The
|
|
88
|
+
// boundary/draft scans run over the OPEN RUN's events INCLUDING the
|
|
89
|
+
// driver's own appends — the driver re-derives after every append,
|
|
90
|
+
// and the marker IT appended must already be the last boundary (the
|
|
91
|
+
// old one-pass Gap B never needed this: it ran before any append).
|
|
92
|
+
const openEvents = openRunEvents(events, scope);
|
|
93
|
+
const boundary = [...openEvents].reverse().find(isBoundary);
|
|
94
|
+
if (boundary !== undefined) {
|
|
95
|
+
const afterBoundary = openEvents.some((e) => (e.type === "text_delta" || e.type === "thinking") && e.seq > boundary.seq);
|
|
96
|
+
// The approval-panel pause: a suffix that carries a pending ask of the
|
|
97
|
+
// LIVE turn — the last boundary is the user_input, no stop since — is
|
|
98
|
+
// the human's pause, never a draft: the call was extracted and asked,
|
|
99
|
+
// the request is durable, the WAIT_PERMISSION step re-announces it.
|
|
100
|
+
// (The loop persists the stream's tail AFTER the pause resolves, so a
|
|
101
|
+
// crash mid-pause leaves exactly this shape.) A request AFTER a STOP
|
|
102
|
+
// is different: it is the draft's own ask (0143's shape) — the marker
|
|
103
|
+
// voids it and the request expires with the draft.
|
|
104
|
+
const liveAsk = afterBoundary &&
|
|
105
|
+
boundary.type === "user_input" &&
|
|
106
|
+
openEvents.some((e) => e.type === "permission_requested" && e.seq > boundary.seq);
|
|
107
|
+
if (afterBoundary && !liveAsk)
|
|
108
|
+
return { kind: "ABANDON_DRAFT", voidFromSeq: boundary.seq };
|
|
109
|
+
}
|
|
110
|
+
// 5. the invocations: the Gap A calls first (scope order), then the
|
|
111
|
+
// stored requests (scope order, then this recovery's own asks). Each:
|
|
112
|
+
// undecided → the pipeline must decide; decided-approved without an
|
|
113
|
+
// execution → execute the persisted call; decided-denied without a
|
|
114
|
+
// model-facing result → repair it from the denial.
|
|
115
|
+
const hasRequest = (callId, after) => events.some((e) => e.type === "permission_requested" && e.callId === callId && e.seq > after);
|
|
116
|
+
const hasExecution = (callId, after) => events.some((e) => e.type === "tool_execution_started" && e.callId === callId && e.seq > after);
|
|
117
|
+
const hasResult = (callId, after) => events.some((e) => e.type === "tool_result" && e.callId === callId && e.seq > after);
|
|
118
|
+
const decidedForCall = (callId, after) => {
|
|
119
|
+
for (const e of events) {
|
|
120
|
+
if (e.type !== "permission_decided")
|
|
121
|
+
continue;
|
|
122
|
+
// a durable POLICY verdict binds the call (E1); a human verdict
|
|
123
|
+
// binds its request, never the call (the requests pass owns it)
|
|
124
|
+
if (e.decidedBy !== undefined && e.callId === callId && e.seq > after)
|
|
125
|
+
return e;
|
|
126
|
+
}
|
|
127
|
+
return undefined;
|
|
128
|
+
};
|
|
129
|
+
const decidedForRequest = (decisionId) => {
|
|
130
|
+
for (const e of events) {
|
|
131
|
+
if (e.type === "permission_decided" && e.decisionId === decisionId)
|
|
132
|
+
return e;
|
|
133
|
+
}
|
|
134
|
+
return undefined;
|
|
135
|
+
};
|
|
136
|
+
for (const call of scope) {
|
|
137
|
+
if (call.type !== "tool_call_end")
|
|
138
|
+
continue;
|
|
139
|
+
// the boundary clause: a call whose turn has no legal stop is a
|
|
140
|
+
// DRAFT's call — Gap B voids it; this pass never touches it.
|
|
141
|
+
const turnEnd = scope.find((e) => e.type === "user_input" && e.seq > call.seq)?.seq ?? Number.POSITIVE_INFINITY;
|
|
142
|
+
if (!scope.some((e) => e.type === "stop" && e.seq > call.seq && e.seq < turnEnd))
|
|
143
|
+
continue;
|
|
144
|
+
// a stored request owns the invocation (the requests pass below) —
|
|
145
|
+
// "only a durable permission_decided authorizes an effect"; Gap A
|
|
146
|
+
// must never re-decide over a stored request.
|
|
147
|
+
if (hasRequest(call.callId, call.seq))
|
|
148
|
+
continue;
|
|
149
|
+
if (hasResult(call.callId, call.seq))
|
|
150
|
+
continue; // closed — nothing to fill
|
|
151
|
+
const decided = decidedForCall(call.callId, call.seq);
|
|
152
|
+
if (decided === undefined)
|
|
153
|
+
return { kind: "DECIDE_PERMISSION", invocationSeq: call.seq };
|
|
154
|
+
if (decided.decision === "approved") {
|
|
155
|
+
if (!hasExecution(call.callId, call.seq))
|
|
156
|
+
return { kind: "EXECUTE", invocationSeq: call.seq };
|
|
157
|
+
}
|
|
158
|
+
else if (!hasResult(call.callId, call.seq)) {
|
|
159
|
+
return { kind: "REPAIR_RESULT", invocationSeq: call.seq };
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
const lastScopeSeq = scope.length > 0 ? scope[scope.length - 1].seq : -1;
|
|
163
|
+
const requests = [
|
|
164
|
+
...scope.filter((e) => e.type === "permission_requested"),
|
|
165
|
+
// this recovery's own asks (the Gap A ask appends) — the log tail
|
|
166
|
+
...events.filter((e) => e.type === "permission_requested" && e.seq > lastScopeSeq),
|
|
167
|
+
];
|
|
168
|
+
for (const pending of requests) {
|
|
169
|
+
const invocationSeq = invocationSeqOf(pending, scope);
|
|
170
|
+
// a voided request was expired by the ABANDON_DRAFT step (sentence 3:
|
|
171
|
+
// never re-presented, never executed) — skip it here.
|
|
172
|
+
if (invocationSeq !== undefined &&
|
|
173
|
+
events.some((e) => e.type === "model_output_abandoned" && invocationSeq > e.voidFromSeq && invocationSeq <= e.seq)) {
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
const decided = decidedForRequest(pending.decisionId);
|
|
177
|
+
if (decided === undefined) {
|
|
178
|
+
return { kind: "WAIT_PERMISSION", invocationSeq: invocationSeq ?? pending.seq };
|
|
179
|
+
}
|
|
180
|
+
if (decided.decision === "approved") {
|
|
181
|
+
if (!hasExecution(pending.callId, pending.seq))
|
|
182
|
+
return { kind: "EXECUTE", invocationSeq: invocationSeq ?? pending.seq };
|
|
183
|
+
}
|
|
184
|
+
else if (!hasResult(pending.callId, pending.seq)) {
|
|
185
|
+
return { kind: "REPAIR_RESULT", invocationSeq: invocationSeq ?? pending.seq };
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
// 6. the receipt repairs: an execution that reached a terminal state
|
|
189
|
+
// whose model-facing result never landed — complete it FROM THE
|
|
190
|
+
// RECEIPT, never re-executed.
|
|
191
|
+
for (const ev of scope) {
|
|
192
|
+
if (ev.type !== "tool_execution_succeeded" && ev.type !== "tool_execution_failed")
|
|
193
|
+
continue;
|
|
194
|
+
if (!hasResult(ev.callId, ev.seq) && !events.some((e) => e.type === "tool_result" && e.executionId === ev.executionId)) {
|
|
195
|
+
return { kind: "REPAIR_RESULT", executionId: ev.executionId };
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
// 7. the resolution fills: a persisted resolution whose model-facing fill
|
|
199
|
+
// never landed — the model must never stare at a dangling tool_use.
|
|
200
|
+
for (const ev of scope) {
|
|
201
|
+
if (ev.type !== "tool_execution_resolved")
|
|
202
|
+
continue;
|
|
203
|
+
if (!events.some((e) => e.type === "tool_result" && e.executionId === ev.executionId)) {
|
|
204
|
+
return { kind: "FILL_RESOLUTION", executionId: ev.executionId };
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
// 8. nothing left to repair — the loop drives from here.
|
|
208
|
+
return { kind: "CONTINUE_MODEL" };
|
|
209
|
+
}
|
package/dist/run.js
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import { denialResult, loop } from "@vincemakes/kiso-core";
|
|
7
7
|
import { ABORTED, MergedSignal, abortable, openRunId } from "./recovery.js";
|
|
8
|
+
import { deriveRecoveryPlan, invocationSeqOf } from "./recovery-plan.js";
|
|
8
9
|
import { composeApprovalChain, composeSystemPrompt, composeToolTable, microcompactFor } from "./compose.js";
|
|
9
10
|
import { truncationGuard } from "./truncation-guard.js";
|
|
10
11
|
import { ResumeBlockedError } from "./session.js";
|
|
@@ -160,11 +161,9 @@ export class Run {
|
|
|
160
161
|
await this.#session.persist(runId, expired);
|
|
161
162
|
}
|
|
162
163
|
}
|
|
163
|
-
// Uncertain executions block until a human decides
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
throw new ResumeBlockedError(uncertain.map((u) => ({ executionId: u.executionId, callId: u.callId, name: u.name })));
|
|
167
|
-
}
|
|
164
|
+
// Uncertain executions block until a human decides — the
|
|
165
|
+
// recovery plan derives RESOLVE_UNCERTAIN and the driver
|
|
166
|
+
// throws below (same list, same order; R-F 0.1.46).
|
|
168
167
|
// 1. Recovery scoped to the LAST OPEN RUN's events. The recover
|
|
169
168
|
// phase re-announces ALREADY-PERSISTED events (the stored
|
|
170
169
|
// permission_requested) for the consumer to re-prompt on —
|
|
@@ -234,351 +233,392 @@ export class Run {
|
|
|
234
233
|
this.#session.endRun(this);
|
|
235
234
|
}
|
|
236
235
|
}
|
|
237
|
-
// ── Area 2: the
|
|
236
|
+
// ── Area 2: the recovery — a thin driver over the recovery plan ───────
|
|
237
|
+
// R-F 0.1.46: recovery is a PURE PROJECTION (recovery-plan.ts) consumed
|
|
238
|
+
// by this driver. The durable prefix derives THE one safe next step;
|
|
239
|
+
// the driver executes it and re-derives. The old state machine's phases
|
|
240
|
+
// are now action cases — the prefix-table gate and the healing fixtures
|
|
241
|
+
// run unchanged (the zero-behavior proof), and each phase's semantics
|
|
242
|
+
// live in the plan's derivation or in the step below.
|
|
238
243
|
/**
|
|
239
|
-
*
|
|
240
|
-
*
|
|
241
|
-
*
|
|
242
|
-
*
|
|
243
|
-
*
|
|
244
|
-
*
|
|
245
|
-
*
|
|
246
|
-
*
|
|
247
|
-
* decision and no execution is UNDECIDED — recovery re-enters it into
|
|
248
|
-
* the approval pipeline before anything else (only a durable
|
|
249
|
-
* permission_decided authorizes an effect).
|
|
244
|
+
* Consume the recovery plan one action at a time: execute the derived
|
|
245
|
+
* step, re-derive, stop when nothing is left (CONTINUE_MODEL — the
|
|
246
|
+
* resume's continuation drives the loop from there). The steps write
|
|
247
|
+
* only what the plan derived; the NEXT derive applies it (EXECUTE /
|
|
248
|
+
* REPAIR_RESULT), so the recovery is a loop over the projection, never
|
|
249
|
+
* a second state machine. An abort that lands mid-step ends the
|
|
250
|
+
* recovery: re-deriving would re-present the same action forever (the
|
|
251
|
+
* steps' pre-execution abort guards rely on this).
|
|
250
252
|
*/
|
|
251
253
|
async *#recover(log, signal, scope, approvalChain, hooks) {
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
254
|
+
for (;;) {
|
|
255
|
+
const action = deriveRecoveryPlan(log.all, scope);
|
|
256
|
+
switch (action.kind) {
|
|
257
|
+
case "COMPLETED":
|
|
258
|
+
case "TERMINAL":
|
|
259
|
+
return;
|
|
260
|
+
case "CONTINUE_MODEL":
|
|
261
|
+
// Upgrade-path housekeeping (the old requests pass did it
|
|
262
|
+
// at the first resume): a request voided by an EARLIER
|
|
263
|
+
// marker whose expiry never landed — the pre-0.1.44 logs,
|
|
264
|
+
// where the marker existed but the expiry did not — is
|
|
265
|
+
// expired here. Idempotent: only the missing expiry is
|
|
266
|
+
// written; the re-derive then skips the expired request.
|
|
267
|
+
if (yield* this.#expireStaleVoidedRequests(scope, log))
|
|
268
|
+
break;
|
|
269
|
+
return;
|
|
270
|
+
case "RESOLVE_UNCERTAIN": {
|
|
271
|
+
// The crash window: uncertain executions block until a
|
|
272
|
+
// human decides (never auto-rerun). The full list goes to
|
|
273
|
+
// the throw, in log order — the first in the plan's
|
|
274
|
+
// derivation is the first in this list (the ledger's
|
|
275
|
+
// insertion order).
|
|
276
|
+
const uncertain = this.#session.uncertainExecutions();
|
|
277
|
+
throw new ResumeBlockedError(uncertain.map((u) => ({ executionId: u.executionId, callId: u.callId, name: u.name })));
|
|
278
|
+
}
|
|
279
|
+
case "ABANDON_DRAFT":
|
|
280
|
+
yield* this.#abandonDraft(action.voidFromSeq, scope, log);
|
|
281
|
+
break;
|
|
282
|
+
case "DECIDE_PERMISSION":
|
|
283
|
+
yield* this.#decidePermission(action.invocationSeq, scope, log, signal, approvalChain, hooks);
|
|
284
|
+
break;
|
|
285
|
+
case "WAIT_PERMISSION":
|
|
286
|
+
yield* this.#waitPermission(action.invocationSeq, scope, log, signal);
|
|
287
|
+
break;
|
|
288
|
+
case "EXECUTE":
|
|
289
|
+
yield* this.#executeInvocation(action.invocationSeq, scope, log, signal);
|
|
290
|
+
break;
|
|
291
|
+
case "REPAIR_RESULT":
|
|
292
|
+
if (action.executionId !== undefined)
|
|
293
|
+
yield* this.#repairReceipt(action.executionId, scope, log);
|
|
294
|
+
else
|
|
295
|
+
yield* this.#repairDenial(action.invocationSeq, scope, log);
|
|
296
|
+
break;
|
|
297
|
+
case "FILL_RESOLUTION":
|
|
298
|
+
yield* this.#fillResolution(action.executionId, scope, log);
|
|
299
|
+
break;
|
|
287
300
|
}
|
|
301
|
+
if (signal.aborted)
|
|
302
|
+
return;
|
|
288
303
|
}
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
304
|
+
}
|
|
305
|
+
/** The open run's stored requests PLUS this recovery's own asks — the
|
|
306
|
+
* same list the plan's request pass derives over (scope order, then
|
|
307
|
+
* the log tail in append order). */
|
|
308
|
+
#storedRequests(scope, log) {
|
|
309
|
+
const lastScopeSeq = scope.length > 0 ? scope[scope.length - 1].seq : -1;
|
|
310
|
+
return [
|
|
311
|
+
...scope.filter((e) => e.type === "permission_requested"),
|
|
312
|
+
...log.all.filter((e) => e.type === "permission_requested" && e.seq > lastScopeSeq),
|
|
313
|
+
];
|
|
314
|
+
}
|
|
315
|
+
/** The invocation the plan keyed by seq — a committed call in the scope,
|
|
316
|
+
* or a stored request (the old-log identity fallback included). */
|
|
317
|
+
#invocationFor(invocationSeq, scope, log) {
|
|
318
|
+
const call = scope.find((e) => e.type === "tool_call_end" && e.seq === invocationSeq);
|
|
319
|
+
if (call !== undefined)
|
|
320
|
+
return call;
|
|
321
|
+
return this.#storedRequests(scope, log).find((e) => (invocationSeqOf(e, scope) ?? e.seq) === invocationSeq);
|
|
322
|
+
}
|
|
323
|
+
/**
|
|
324
|
+
* The ABANDON_DRAFT step (Gap B): the marker voids the draft's range —
|
|
325
|
+
* model output only, the audit bytes stay, a call inside the range is
|
|
326
|
+
* never executed (the kernel's type filter keeps the framework's
|
|
327
|
+
* facts). The SAME step expires every stored request whose invocation
|
|
328
|
+
* falls in the voided range (sentence 3: never re-presented, never
|
|
329
|
+
* executed) — the old requests pass appended these later; the fold
|
|
330
|
+
* makes the void and its expiry ONE deterministic step. Idempotent: an
|
|
331
|
+
* already-expired request is never re-expired, and an already-voided
|
|
332
|
+
* draft derives no ABANDON_DRAFT (the marker is the last boundary).
|
|
333
|
+
*/
|
|
334
|
+
async *#abandonDraft(voidFromSeq, scope, log) {
|
|
335
|
+
const marker = log.append({
|
|
336
|
+
type: "model_output_abandoned",
|
|
337
|
+
voidFromSeq,
|
|
338
|
+
reason: "a model output suffix without a committed stop — abandoned on resume",
|
|
339
|
+
});
|
|
340
|
+
yield marker;
|
|
341
|
+
for (const pending of scope) {
|
|
342
|
+
if (pending.type !== "permission_requested")
|
|
313
343
|
continue;
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
// never in the provider projection); this stage never touches it.
|
|
317
|
-
// The two Gaps divide at the stop; no mixing.
|
|
318
|
-
const turnEnd = scope.find((e) => e.type === "user_input" && e.seq > call.seq)?.seq ?? Number.POSITIVE_INFINITY;
|
|
319
|
-
const turnStop = scope.some((e) => e.type === "stop" && e.seq > call.seq && e.seq < turnEnd);
|
|
320
|
-
if (!turnStop)
|
|
344
|
+
const invocationSeq = pending.invocationSeq ?? invocationSeqOf(pending, scope);
|
|
345
|
+
if (invocationSeq === undefined)
|
|
321
346
|
continue;
|
|
322
|
-
|
|
323
|
-
// binds the stored request by decisionId, or pauses for the
|
|
324
|
-
// human) — Gap A must never re-decide over a stored
|
|
325
|
-
// permission_requested. "Only a durable permission_decided
|
|
326
|
-
// authorizes an effect": a pending request is not a decision.
|
|
327
|
-
const hasRequest = log.all.some((e) => e.type === "permission_requested" && e.callId === call.callId && e.seq > call.seq);
|
|
328
|
-
if (hasRequest)
|
|
347
|
+
if (!(invocationSeq > voidFromSeq && invocationSeq <= marker.seq))
|
|
329
348
|
continue;
|
|
330
|
-
|
|
331
|
-
const hasExecution = log.all.some((e) => e.type === "tool_execution_started" && e.callId === call.callId && e.seq > call.seq);
|
|
332
|
-
const hasResult = log.all.some((e) => e.type === "tool_result" && e.callId === call.callId && e.seq > call.seq);
|
|
333
|
-
if (hasResult)
|
|
334
|
-
continue; // closed — nothing to fill
|
|
335
|
-
if (decided !== undefined) {
|
|
336
|
-
// E1: the durable verdict speaks for the call — apply it
|
|
337
|
-
// without re-running the chain.
|
|
338
|
-
if (signal.aborted)
|
|
339
|
-
return;
|
|
340
|
-
if (decided.decision === "approved" && !hasExecution) {
|
|
341
|
-
yield* this.#executePersisted(call.callId, call.name, call.input ?? {}, call.seq, signal);
|
|
342
|
-
}
|
|
343
|
-
else if (!hasResult) {
|
|
344
|
-
yield* this.#denialResult(call.callId, decided.reason ?? "denied by user", call.seq);
|
|
345
|
-
}
|
|
349
|
+
if (log.all.some((e) => e.type === "permission_expired" && e.decisionId === pending.decisionId))
|
|
346
350
|
continue;
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
verdict = { action: "deny", reason: decision.reason ?? "denied" };
|
|
380
|
-
else
|
|
381
|
-
verdict = { action: "allow" };
|
|
382
|
-
}
|
|
383
|
-
if (verdict === undefined)
|
|
384
|
-
verdict = { action: "allow" }; // no policies — the kernel's default allow
|
|
385
|
-
const decisionId = `d-${log.all.length + 1}`;
|
|
386
|
-
if (verdict.action === "allow") {
|
|
387
|
-
yield log.append({
|
|
388
|
-
type: "permission_decided",
|
|
389
|
-
decisionId,
|
|
390
|
-
callId: call.callId,
|
|
391
|
-
invocationSeq: call.seq,
|
|
392
|
-
decision: "approved",
|
|
393
|
-
...("decidedBy" in verdict ? { decidedBy: verdict.decidedBy } : {}),
|
|
394
|
-
});
|
|
395
|
-
if (signal.aborted)
|
|
351
|
+
yield log.append({
|
|
352
|
+
type: "permission_expired",
|
|
353
|
+
decisionId: pending.decisionId,
|
|
354
|
+
reason: "the invocation was abandoned with an incomplete draft — never re-presented, never executed",
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
/**
|
|
359
|
+
* The DECIDE_PERMISSION step (Gap A, undecided): the committed call
|
|
360
|
+
* re-enters the approval pipeline with the LIVE decision order
|
|
361
|
+
* (loop.ts decideCall) — the composed chain first; only when no chain
|
|
362
|
+
* exists do the hooks' onPreTool speak (defer → ask, deny → deny,
|
|
363
|
+
* allow → allow); no policies at all → the kernel's default allow. A
|
|
364
|
+
* throwing chain counts as ask: it speaks, never silently (the live
|
|
365
|
+
* parity). Only the DECISION is written here — the EXECUTE /
|
|
366
|
+
* REPAIR_RESULT steps apply it on the next derive. The ask's request
|
|
367
|
+
* is announced immediately (the consumer may answer; the
|
|
368
|
+
* WAIT_PERMISSION step re-announces and pauses when it did not).
|
|
369
|
+
*/
|
|
370
|
+
async *#decidePermission(invocationSeq, scope, log, signal, approvalChain, hooks) {
|
|
371
|
+
const call = scope.find((e) => e.type === "tool_call_end" && e.seq === invocationSeq);
|
|
372
|
+
if (call === undefined)
|
|
373
|
+
throw new Error(`the recovery plan derived a call outside the scope (seq ${invocationSeq})`);
|
|
374
|
+
// The chain's PolicyCall carries name+input only — callId is the
|
|
375
|
+
// framework's, the hook's is the provider-facing ToolCallPayload.
|
|
376
|
+
const payload = { callId: call.callId, name: call.name, input: call.input ?? {} };
|
|
377
|
+
const policyCall = { name: payload.name, input: payload.input };
|
|
378
|
+
let verdict;
|
|
379
|
+
try {
|
|
380
|
+
if (approvalChain !== undefined) {
|
|
381
|
+
const chainVerdict = await abortable(Promise.resolve(approvalChain.decide(policyCall, { signal, sessionId: this.#session.id })), signal);
|
|
382
|
+
if (chainVerdict === ABORTED)
|
|
396
383
|
return;
|
|
397
|
-
|
|
398
|
-
}
|
|
399
|
-
else if (verdict.action === "deny") {
|
|
400
|
-
yield log.append({
|
|
401
|
-
type: "permission_decided",
|
|
402
|
-
decisionId,
|
|
403
|
-
callId: call.callId,
|
|
404
|
-
invocationSeq: call.seq,
|
|
405
|
-
decision: "denied",
|
|
406
|
-
...("reason" in verdict && verdict.reason !== undefined ? { reason: verdict.reason } : {}),
|
|
407
|
-
...("decidedBy" in verdict ? { decidedBy: verdict.decidedBy } : {}),
|
|
408
|
-
});
|
|
409
|
-
yield* this.#denialResult(call.callId, ("reason" in verdict && verdict.reason) || "denied", call.seq);
|
|
410
|
-
}
|
|
411
|
-
else {
|
|
412
|
-
// ask / all-abstain — the requests pass below announces the
|
|
413
|
-
// stored request and waits for the human.
|
|
414
|
-
const appended = log.append({
|
|
415
|
-
type: "permission_requested",
|
|
416
|
-
decisionId,
|
|
417
|
-
callId: call.callId,
|
|
418
|
-
invocationSeq: call.seq,
|
|
419
|
-
name: call.name,
|
|
420
|
-
input: call.input ?? {},
|
|
421
|
-
});
|
|
422
|
-
gapAsks.push(appended);
|
|
423
|
-
yield appended;
|
|
384
|
+
verdict = chainVerdict;
|
|
424
385
|
}
|
|
425
386
|
}
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
387
|
+
catch {
|
|
388
|
+
verdict = { action: "ask" };
|
|
389
|
+
}
|
|
390
|
+
if (verdict === undefined && hooks?.onPreTool !== undefined) {
|
|
391
|
+
const decision = await abortable(Promise.resolve(hooks.onPreTool(payload, { sessionId: this.#session.id })), signal);
|
|
392
|
+
if (decision === ABORTED)
|
|
393
|
+
return;
|
|
394
|
+
if (decision.action === "defer")
|
|
395
|
+
verdict = { action: "ask" };
|
|
396
|
+
else if (decision.action !== "allow")
|
|
397
|
+
verdict = { action: "deny", reason: decision.reason ?? "denied" };
|
|
398
|
+
else
|
|
399
|
+
verdict = { action: "allow" };
|
|
400
|
+
}
|
|
401
|
+
if (verdict === undefined)
|
|
402
|
+
verdict = { action: "allow" }; // no policies — the kernel's default allow
|
|
403
|
+
// The recovery's own decision is a POLICY verdict (the plan's E1
|
|
404
|
+
// rule: only a decidedBy-carrying decision binds the call — a human
|
|
405
|
+
// verdict binds its request, never the call). The driver's write
|
|
406
|
+
// must satisfy its own plan: no decidedBy → the re-derive would
|
|
407
|
+
// derive DECIDE_PERMISSION again forever (the plan cannot tell the
|
|
408
|
+
// driver's write from a human's). Stamped "mode:default" when the
|
|
409
|
+
// verdict carries none — the gates' seeded convention.
|
|
410
|
+
const decisionId = `d-${log.all.length + 1}`;
|
|
411
|
+
if (verdict.action === "allow") {
|
|
412
|
+
yield log.append({
|
|
413
|
+
type: "permission_decided",
|
|
414
|
+
decisionId,
|
|
415
|
+
callId: call.callId,
|
|
416
|
+
invocationSeq: call.seq,
|
|
417
|
+
decision: "approved",
|
|
418
|
+
decidedBy: "decidedBy" in verdict ? verdict.decidedBy : "mode:default",
|
|
419
|
+
});
|
|
420
|
+
}
|
|
421
|
+
else if (verdict.action === "deny") {
|
|
422
|
+
yield log.append({
|
|
423
|
+
type: "permission_decided",
|
|
424
|
+
decisionId,
|
|
425
|
+
callId: call.callId,
|
|
426
|
+
invocationSeq: call.seq,
|
|
427
|
+
decision: "denied",
|
|
428
|
+
...("reason" in verdict && verdict.reason !== undefined ? { reason: verdict.reason } : {}),
|
|
429
|
+
decidedBy: "decidedBy" in verdict ? verdict.decidedBy : "mode:default",
|
|
430
|
+
});
|
|
431
|
+
}
|
|
432
|
+
else {
|
|
433
|
+
// ask / all-abstain — the WAIT_PERMISSION step announces the
|
|
434
|
+
// stored request and awaits the human.
|
|
435
|
+
yield log.append({
|
|
436
|
+
type: "permission_requested",
|
|
437
|
+
decisionId,
|
|
438
|
+
callId: call.callId,
|
|
439
|
+
invocationSeq: call.seq,
|
|
440
|
+
name: call.name,
|
|
441
|
+
input: call.input ?? {},
|
|
442
|
+
});
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
/**
|
|
446
|
+
* The WAIT_PERMISSION step (a stored request, undecided): the resolver
|
|
447
|
+
* is registered, the stored request announced, and the human's decision
|
|
448
|
+
* awaited. An abort during the wait ends the run — the request stays
|
|
449
|
+
* durable and pending, and a verdict given in the same instant as the
|
|
450
|
+
* abort is recorded exactly once (the round-4 adversarial path). Only
|
|
451
|
+
* the DECISION is written here — the EXECUTE / REPAIR_RESULT steps
|
|
452
|
+
* apply it on the next derive.
|
|
453
|
+
*/
|
|
454
|
+
async *#waitPermission(invocationSeq, scope, log, signal) {
|
|
455
|
+
const pending = this.#storedRequests(scope, log).find((e) => (invocationSeqOf(e, scope) ?? e.seq) === invocationSeq);
|
|
456
|
+
if (pending === undefined)
|
|
457
|
+
throw new Error(`the recovery plan derived a request outside the log (seq ${invocationSeq})`);
|
|
458
|
+
const pendingDecision = new Promise((resolve) => {
|
|
459
|
+
this.#decisionIds.push(pending.decisionId);
|
|
460
|
+
this.#session.registerResolver(pending.decisionId, resolve);
|
|
461
|
+
});
|
|
462
|
+
yield pending;
|
|
463
|
+
// Area 4: an abort during the resumed approval wait ends the run;
|
|
464
|
+
// the request stays durable and pending.
|
|
465
|
+
if (signal.aborted) {
|
|
466
|
+
// round 5(P1-6): a verdict given in the same instant as the
|
|
467
|
+
// abort is still recorded — the abort must not bypass the
|
|
468
|
+
// durable fallback (aligned with the loop's abort path).
|
|
469
|
+
const verdict = this.#session.approvalVerdict(pending.decisionId);
|
|
470
|
+
if (verdict !== undefined) {
|
|
443
471
|
yield log.append({
|
|
444
|
-
type: "
|
|
472
|
+
type: "permission_decided",
|
|
445
473
|
decisionId: pending.decisionId,
|
|
446
|
-
|
|
474
|
+
callId: pending.callId,
|
|
475
|
+
decision: verdict ? "approved" : "denied",
|
|
476
|
+
...(verdict ? {} : { reason: "denied by user" }),
|
|
447
477
|
});
|
|
448
|
-
continue;
|
|
449
478
|
}
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
const pendingDecision = new Promise((resolve) => {
|
|
459
|
-
this.#decisionIds.push(pending.decisionId);
|
|
460
|
-
this.#session.registerResolver(pending.decisionId, resolve);
|
|
461
|
-
});
|
|
462
|
-
yield pending;
|
|
463
|
-
// Area 4: an abort during the resumed approval wait ends the
|
|
464
|
-
// run; the request stays durable and pending.
|
|
465
|
-
if (signal.aborted) {
|
|
466
|
-
// round 5(P1-6): a verdict given in the same instant as the
|
|
467
|
-
// abort is still recorded — the abort must not bypass the
|
|
468
|
-
// durable fallback (aligned with the loop's abort path).
|
|
469
|
-
const verdict = this.#session.approvalVerdict(pending.decisionId);
|
|
470
|
-
if (verdict !== undefined) {
|
|
471
|
-
yield log.append({
|
|
472
|
-
type: "permission_decided",
|
|
473
|
-
decisionId: pending.decisionId,
|
|
474
|
-
callId: pending.callId,
|
|
475
|
-
decision: verdict ? "approved" : "denied",
|
|
476
|
-
...(verdict ? {} : { reason: "denied by user" }),
|
|
477
|
-
});
|
|
478
|
-
}
|
|
479
|
-
return;
|
|
480
|
-
}
|
|
481
|
-
const final = await abortable(pendingDecision, signal);
|
|
482
|
-
if (final === ABORTED) {
|
|
483
|
-
// round 4 (adversarial): a verdict given in the same instant as the
|
|
484
|
-
// abort is recorded (exactly once), never lost.
|
|
485
|
-
const verdict = this.#session.approvalVerdict(pending.decisionId);
|
|
486
|
-
if (verdict !== undefined) {
|
|
487
|
-
yield log.append({
|
|
488
|
-
type: "permission_decided",
|
|
489
|
-
decisionId: pending.decisionId,
|
|
490
|
-
callId: pending.callId,
|
|
491
|
-
decision: verdict ? "approved" : "denied",
|
|
492
|
-
...(verdict ? {} : { reason: "denied by user" }),
|
|
493
|
-
});
|
|
494
|
-
}
|
|
495
|
-
return;
|
|
496
|
-
}
|
|
497
|
-
// The decision is written here — exactly one writer per event.
|
|
479
|
+
return;
|
|
480
|
+
}
|
|
481
|
+
const final = await abortable(pendingDecision, signal);
|
|
482
|
+
if (final === ABORTED) {
|
|
483
|
+
// round 4 (adversarial): a verdict given in the same instant as
|
|
484
|
+
// the abort is recorded (exactly once), never lost.
|
|
485
|
+
const verdict = this.#session.approvalVerdict(pending.decisionId);
|
|
486
|
+
if (verdict !== undefined) {
|
|
498
487
|
yield log.append({
|
|
499
488
|
type: "permission_decided",
|
|
500
489
|
decisionId: pending.decisionId,
|
|
501
|
-
callId: pending.callId,
|
|
502
|
-
decision:
|
|
503
|
-
...(
|
|
490
|
+
callId: pending.callId,
|
|
491
|
+
decision: verdict ? "approved" : "denied",
|
|
492
|
+
...(verdict ? {} : { reason: "denied by user" }),
|
|
504
493
|
});
|
|
505
|
-
if (final.action === "allow") {
|
|
506
|
-
if (!hasExecution)
|
|
507
|
-
yield* this.#executePersisted(pending.callId, pending.name, pending.input, invocationSeq, signal);
|
|
508
|
-
}
|
|
509
|
-
else if (!hasResult) {
|
|
510
|
-
yield* this.#denialResult(pending.callId, final.reason ?? "denied by user", invocationSeq);
|
|
511
|
-
}
|
|
512
|
-
}
|
|
513
|
-
else if (decided.decision === "approved") {
|
|
514
|
-
// Decided while no process was running: apply without pausing.
|
|
515
|
-
// An abort during recovery must stop the pending executions,
|
|
516
|
-
// exactly like the live loop's sibling guard (finding 3).
|
|
517
|
-
if (signal.aborted)
|
|
518
|
-
return;
|
|
519
|
-
if (!hasExecution)
|
|
520
|
-
yield* this.#executePersisted(pending.callId, pending.name, pending.input, invocationSeq, signal);
|
|
521
494
|
}
|
|
522
|
-
|
|
523
|
-
|
|
495
|
+
return;
|
|
496
|
+
}
|
|
497
|
+
// The decision is written here — exactly one writer per event.
|
|
498
|
+
yield log.append({
|
|
499
|
+
type: "permission_decided",
|
|
500
|
+
decisionId: pending.decisionId,
|
|
501
|
+
callId: pending.callId, // binds the decision to the invocation (B group)
|
|
502
|
+
decision: final.action === "allow" ? "approved" : "denied",
|
|
503
|
+
...(final.action === "deny" && final.reason !== undefined ? { reason: final.reason } : {}),
|
|
504
|
+
});
|
|
505
|
+
}
|
|
506
|
+
/**
|
|
507
|
+
* The EXECUTE step: a durable approval authorizes the persisted call —
|
|
508
|
+
* the original name/input/callId (never re-asked of the model, never
|
|
509
|
+
* re-approved), the full ledgered lifecycle, and the ruling-#12
|
|
510
|
+
* receipt-as-outcome semantics.
|
|
511
|
+
*/
|
|
512
|
+
async *#executeInvocation(invocationSeq, scope, log, signal) {
|
|
513
|
+
const invocation = this.#invocationFor(invocationSeq, scope, log);
|
|
514
|
+
if (invocation === undefined)
|
|
515
|
+
throw new Error(`the recovery plan derived an invocation outside the log (seq ${invocationSeq})`);
|
|
516
|
+
yield* this.#executePersisted(invocation.callId, invocation.name, invocation.input ?? {}, invocationSeq, signal);
|
|
517
|
+
}
|
|
518
|
+
/**
|
|
519
|
+
* The REPAIR_RESULT step for a receipt (executionId): an execution that
|
|
520
|
+
* reached a terminal state but whose model-facing result never landed
|
|
521
|
+
* is completed FROM THE RECEIPT — never re-executed. Pairing is by
|
|
522
|
+
* executionId (round 4) — a same-callId result from a different
|
|
523
|
+
* execution never suppresses the repair.
|
|
524
|
+
*/
|
|
525
|
+
async *#repairReceipt(executionId, scope, log) {
|
|
526
|
+
const ev = scope.find((e) => (e.type === "tool_execution_succeeded" || e.type === "tool_execution_failed") && e.executionId === executionId);
|
|
527
|
+
if (ev === undefined)
|
|
528
|
+
throw new Error(`the recovery plan derived a receipt outside the scope (${executionId})`);
|
|
529
|
+
yield log.append(ev.type === "tool_execution_succeeded"
|
|
530
|
+
? {
|
|
531
|
+
type: "tool_result",
|
|
532
|
+
callId: ev.callId,
|
|
533
|
+
content: ev.result.content,
|
|
534
|
+
isError: false,
|
|
535
|
+
// round 8: the repaired result reproduces the normal path
|
|
536
|
+
// losslessly — the tags ride on the durable receipt.
|
|
537
|
+
...(ev.tags !== undefined ? { tags: ev.tags } : {}),
|
|
538
|
+
executionId: ev.executionId,
|
|
524
539
|
}
|
|
540
|
+
: {
|
|
541
|
+
type: "tool_result",
|
|
542
|
+
callId: ev.callId,
|
|
543
|
+
content: ev.error,
|
|
544
|
+
isError: true,
|
|
545
|
+
...(ev.errorKind !== undefined ? { errorKind: ev.errorKind } : {}),
|
|
546
|
+
...(ev.tags !== undefined ? { tags: ev.tags } : {}),
|
|
547
|
+
executionId: ev.executionId,
|
|
548
|
+
});
|
|
549
|
+
}
|
|
550
|
+
/**
|
|
551
|
+
* The REPAIR_RESULT step for a durable denial (invocationSeq): the
|
|
552
|
+
* model-facing result of the denied invocation is completed from its
|
|
553
|
+
* durable permission_decided — no execution happened. The REQUEST is
|
|
554
|
+
* looked up first (its decision binds by decisionId — the human's
|
|
555
|
+
* verdict carries no decidedBy); the call fallback covers the Gap-A
|
|
556
|
+
* policy-verdict shape, which can only derive when NO request exists
|
|
557
|
+
* (Gap A never re-decides over a stored request).
|
|
558
|
+
*/
|
|
559
|
+
async *#repairDenial(invocationSeq, scope, log) {
|
|
560
|
+
const request = this.#storedRequests(scope, log).find((e) => (invocationSeqOf(e, scope) ?? e.seq) === invocationSeq);
|
|
561
|
+
const call = scope.find((e) => e.type === "tool_call_end" && e.seq === invocationSeq);
|
|
562
|
+
const decided = log.all.find((e) => e.type === "permission_decided" &&
|
|
563
|
+
(request !== undefined
|
|
564
|
+
? e.decisionId === request.decisionId
|
|
565
|
+
: call !== undefined && e.callId === call.callId && e.seq > call.seq && e.decidedBy !== undefined));
|
|
566
|
+
if (decided === undefined || (request?.callId ?? call?.callId) === undefined) {
|
|
567
|
+
throw new Error(`the recovery plan derived a denial without its decision (seq ${invocationSeq})`);
|
|
525
568
|
}
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
569
|
+
yield* this.#denialResult(request?.callId ?? call.callId, decided.reason ?? "denied by user", invocationSeq);
|
|
570
|
+
}
|
|
571
|
+
/**
|
|
572
|
+
* The FILL_RESOLUTION step: a resolution was persisted but its
|
|
573
|
+
* model-facing fill never landed — complete it so the model is never
|
|
574
|
+
* left staring at a dangling tool_use. Keyed by executionId; the fill
|
|
575
|
+
* carries it, so a same-callId result from another execution is never
|
|
576
|
+
* confused with this one (round 4).
|
|
577
|
+
*/
|
|
578
|
+
async *#fillResolution(executionId, scope, log) {
|
|
579
|
+
const ev = scope.find((e) => e.type === "tool_execution_resolved" && e.executionId === executionId);
|
|
580
|
+
if (ev === undefined)
|
|
581
|
+
throw new Error(`the recovery plan derived a resolution outside the scope (${executionId})`);
|
|
582
|
+
const denial = denialResult(ev.resolution === "rerun"
|
|
583
|
+
? "interrupted execution — rerun approved: the attempt is treated as NOT applied; the model may retry"
|
|
584
|
+
: "abandoned by human decision — the interrupted attempt must not be treated as applied");
|
|
585
|
+
yield log.append({
|
|
586
|
+
type: "tool_result",
|
|
587
|
+
callId: ev.callId,
|
|
588
|
+
content: denial.content,
|
|
589
|
+
isError: true,
|
|
590
|
+
errorKind: denial.errorKind,
|
|
591
|
+
executionId: ev.executionId,
|
|
592
|
+
});
|
|
593
|
+
}
|
|
594
|
+
/**
|
|
595
|
+
* The CONTINUE_MODEL housekeeping (called by the driver before the
|
|
596
|
+
* continuation): a request voided by an EARLIER marker whose expiry
|
|
597
|
+
* never landed — the pre-0.1.44 upgrade path, where the marker existed
|
|
598
|
+
* but the expiry did not — is expired here, exactly as the old requests
|
|
599
|
+
* pass did at the first resume. Returns whether anything was appended
|
|
600
|
+
* (the driver re-derives once; the plan then skips the expired
|
|
601
|
+
* request).
|
|
602
|
+
*/
|
|
603
|
+
async *#expireStaleVoidedRequests(scope, log) {
|
|
604
|
+
let expired = false;
|
|
605
|
+
for (const pending of this.#storedRequests(scope, log)) {
|
|
606
|
+
const invocationSeq = invocationSeqOf(pending, scope);
|
|
607
|
+
if (invocationSeq === undefined)
|
|
537
608
|
continue;
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
type: "tool_result",
|
|
541
|
-
callId: ev.callId,
|
|
542
|
-
content: ev.result.content,
|
|
543
|
-
isError: false,
|
|
544
|
-
// round 8: the repaired result reproduces the normal path
|
|
545
|
-
// losslessly — the tags ride on the durable receipt.
|
|
546
|
-
...(ev.tags !== undefined ? { tags: ev.tags } : {}),
|
|
547
|
-
executionId: ev.executionId,
|
|
548
|
-
}
|
|
549
|
-
: {
|
|
550
|
-
type: "tool_result",
|
|
551
|
-
callId: ev.callId,
|
|
552
|
-
content: ev.error,
|
|
553
|
-
isError: true,
|
|
554
|
-
...(ev.errorKind !== undefined ? { errorKind: ev.errorKind } : {}),
|
|
555
|
-
...(ev.tags !== undefined ? { tags: ev.tags } : {}),
|
|
556
|
-
executionId: ev.executionId,
|
|
557
|
-
});
|
|
558
|
-
}
|
|
559
|
-
// B group crash window: a resolution was persisted but its tool_result
|
|
560
|
-
// fill never landed — complete it so the model is never left staring
|
|
561
|
-
// at a dangling tool_use. round 4: keyed by executionId, and the fill
|
|
562
|
-
// carries it, so a same-callId result from another execution is never
|
|
563
|
-
// confused with this one.
|
|
564
|
-
for (const ev of [...scope]) {
|
|
565
|
-
if (ev.type !== "tool_execution_resolved")
|
|
609
|
+
const voided = log.all.some((e) => e.type === "model_output_abandoned" && invocationSeq > e.voidFromSeq && invocationSeq <= e.seq);
|
|
610
|
+
if (!voided)
|
|
566
611
|
continue;
|
|
567
|
-
|
|
568
|
-
if (hasResult)
|
|
612
|
+
if (log.all.some((e) => e.type === "permission_expired" && e.decisionId === pending.decisionId))
|
|
569
613
|
continue;
|
|
570
|
-
const denial = denialResult(ev.resolution === "rerun"
|
|
571
|
-
? "interrupted execution — rerun approved: the attempt is treated as NOT applied; the model may retry"
|
|
572
|
-
: "abandoned by human decision — the interrupted attempt must not be treated as applied");
|
|
573
614
|
yield log.append({
|
|
574
|
-
type: "
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
isError: true,
|
|
578
|
-
errorKind: denial.errorKind,
|
|
579
|
-
executionId: ev.executionId,
|
|
615
|
+
type: "permission_expired",
|
|
616
|
+
decisionId: pending.decisionId,
|
|
617
|
+
reason: "the invocation was abandoned with an incomplete draft — never re-presented, never executed",
|
|
580
618
|
});
|
|
619
|
+
expired = true;
|
|
581
620
|
}
|
|
621
|
+
return expired;
|
|
582
622
|
}
|
|
583
623
|
/**
|
|
584
624
|
* Execute a call whose approval is already durable: the original
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vincemakes/kiso-runtime",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.37",
|
|
4
4
|
"description": "kiso runtime — durable multi-turn agent sessions: AgentDefinition, AgentRuntime, AgentSession, Run, append-only JSONL store.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|