@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.
@@ -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
- const uncertain = this.#session.uncertainExecutions();
165
- if (uncertain.length > 0) {
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 durable recovery state machine ───────────────────────
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
- * Apply every durable decision and fill every missing receipt, in log
240
- * order. A decision with no execution yet EXECUTES the persisted call
241
- * (its original name/input/callId never re-asked of the model, never
242
- * re-approved); a denial writes its tool result; a succeeded/failed
243
- * execution whose tool_result never landed is completed from the
244
- * receipt. Undecided requests pause and await approve().
245
- *
246
- * R-E 0.1.43 (Gap A): a committed turn's tool_call_end with no durable
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
- // ── Gap B: the tail draft is abandoned — never committed history ──
253
- // "A model output suffix without a committed stop is an incomplete
254
- // draft and must never become committed provider history." The
255
- // resume appends the abandon marker FIRST: it voids the range after
256
- // the last committed boundary (stop / user_input / terminal /
257
- // compaction / summarized — or an earlier marker), so the projection
258
- // excludes the draft and a call inside it is never executed (the
259
- // boundary clause: the two Gaps divide at the stop). The audit bytes
260
- // stay; a marker is kernel-exclusive (the AdapterEvent whitelist).
261
- // Idempotent: an already-voided draft has a marker as its last
262
- // boundary the detection finds no output after it, and the
263
- // recovered events (decided/started/result) are no draft.
264
- const boundary = [...scope].reverse().find((e) => e.type === "stop" ||
265
- e.type === "user_input" ||
266
- e.type === "terminal" ||
267
- e.type === "microcompacted" ||
268
- e.type === "compacted" ||
269
- e.type === "summarized" ||
270
- e.type === "model_output_abandoned");
271
- if (boundary !== undefined) {
272
- // R-E 0.1.44 (verified against sentence 2): the draft detection
273
- // stays text-only — a bare tool-call suffix [tool_call_end,
274
- // permission_requested] with no text is the legal approval-panel
275
- // pause (the Area 2 contract: the pending request binds and
276
- // executes, pair-closed — the extensions-e2e gate). The finding's
277
- // shapes all carry text, and the VOID SCOPE (what the marker
278
- // covers) is the type filter at project.ts: model output dies
279
- // with the draft, the framework's facts never do.
280
- const draft = scope.some((e) => (e.type === "text_delta" || e.type === "thinking") && e.seq > boundary.seq);
281
- if (draft) {
282
- yield log.append({
283
- type: "model_output_abandoned",
284
- voidFromSeq: boundary.seq,
285
- reason: "a model output suffix without a committed stop — abandoned on resume",
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
- // The invocation's framework identity (R-E 0.1.43): the
290
- // tool_call_end's seq carried by new logs, derived by
291
- // callId+proximity for old ones (the last such call before the seq).
292
- const callSeqOf = (callId, before) => {
293
- let seq;
294
- for (const e of scope) {
295
- if (e.type === "tool_call_end" && e.callId === callId && e.seq < before)
296
- seq = e.seq;
297
- }
298
- return seq;
299
- };
300
- // ── Gap A: a committed turn's UNDECIDED invocation ────────────────
301
- // A durable stop with a bare tool_call_end (no decision, no
302
- // execution) re-enters the approval pipeline: the composed chain
303
- // decides allow durable permission_decided (decidedBy
304
- // faithfully) + the persisted execution; deny → decided + the
305
- // denial result; ask/all-abstain → permission_requested (the
306
- // requests pass below announces it and waits for the human). No
307
- // guessing, no inheriting, no retro-authorization — re-decide.
308
- // A durable POLICY verdict (E1: decidedBy set) newer than the call
309
- // binds it the chain never re-runs for a decided invocation.
310
- const gapAsks = [];
311
- for (const call of scope) {
312
- if (call.type !== "tool_call_end")
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
- // The boundary clause (the directive): a call whose turn has no
315
- // legal stop is a DRAFT's call — Gap B voids it (never executed,
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
- // The requests pass below owns request-tracked invocations (it
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
- const decided = log.all.find((e) => e.type === "permission_decided" && e.callId === call.callId && e.seq > call.seq && e.decidedBy !== undefined);
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
- // UNDECIDED — re-enter the approval pipeline with the LIVE
349
- // decision order (loop.ts decideCall): the composed chain
350
- // first; only when no chain exists do the hooks' onPreTool
351
- // speak (defer → ask, deny → deny, allow → allow); no policies
352
- // at all → the kernel's default allow. Same semantics as the
353
- // live path — a defer policy must not collapse into an
354
- // auto-allow on resume. A throwing chain counts as ask: it
355
- // speaks, never silently (the live parity).
356
- const payload = { callId: call.callId, name: call.name, input: call.input ?? {} };
357
- // The chain's PolicyCall carries name+input only callId is the
358
- // framework's, the hook's is the provider-facing ToolCallPayload.
359
- const policyCall = { name: payload.name, input: payload.input };
360
- let verdict;
361
- try {
362
- if (approvalChain !== undefined) {
363
- const chainVerdict = await abortable(Promise.resolve(approvalChain.decide(policyCall, { signal, sessionId: this.#session.id })), signal);
364
- if (chainVerdict === ABORTED)
365
- return;
366
- verdict = chainVerdict;
367
- }
368
- }
369
- catch {
370
- verdict = { action: "ask" };
371
- }
372
- if (verdict === undefined && hooks?.onPreTool !== undefined) {
373
- const decision = await abortable(Promise.resolve(hooks.onPreTool(payload, { sessionId: this.#session.id })), signal);
374
- if (decision === ABORTED)
375
- return;
376
- if (decision.action === "defer")
377
- verdict = { action: "ask" };
378
- else if (decision.action !== "allow")
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
- yield* this.#executePersisted(call.callId, call.name, call.input ?? {}, call.seq, signal);
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
- const requests = [...scope.filter((e) => e.type === "permission_requested"), ...gapAsks];
427
- for (const pending of requests) {
428
- // R-E 0.1.43: the writes below carry the invocation's framework
429
- // identity the request's own, or the callId+proximity
430
- // fallback for old logs (the compat contract).
431
- const invocationSeq = pending.invocationSeq ?? callSeqOf(pending.callId, pending.seq);
432
- // R-E 0.1.44 (sentence 3): a stored request whose invocation is
433
- // VOIDED is expired — never re-presented, never executed (the
434
- // dead-run expiry precedent above). The void ranges come from
435
- // log.all the marker THIS recovery appended is included. The
436
- // receipt stays in the audit; only the presentation is gone.
437
- // An identity-less request (an old log where neither the field nor
438
- // the callId+proximity fallback yields a seq) is never proven
439
- // voided — it keeps the pre-0.1.44 behavior.
440
- const voided = invocationSeq !== undefined &&
441
- log.all.some((e) => e.type === "model_output_abandoned" && invocationSeq > e.voidFromSeq && invocationSeq <= e.seq);
442
- if (voided) {
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: "permission_expired",
472
+ type: "permission_decided",
445
473
  decisionId: pending.decisionId,
446
- reason: "the invocation was abandoned with an incomplete draft — never re-presented, never executed",
474
+ callId: pending.callId,
475
+ decision: verdict ? "approved" : "denied",
476
+ ...(verdict ? {} : { reason: "denied by user" }),
447
477
  });
448
- continue;
449
478
  }
450
- const decided = log.all.find((e) => e.type === "permission_decided" && e.decisionId === pending.decisionId);
451
- // round 4: paired by events NEWER than the request — a historical
452
- // same-callId execution from an earlier run must not count as THIS
453
- // request's execution (the provider callId may repeat across runs).
454
- const hasExecution = log.all.some((e) => e.type === "tool_execution_started" && e.callId === pending.callId && e.seq > pending.seq);
455
- const hasResult = log.all.some((e) => e.type === "tool_result" && e.callId === pending.callId && e.seq > pending.seq);
456
- if (decided === undefined) {
457
- // Pause: announce the stored request, await the human.
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, // 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 } : {}),
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
- else if (!hasResult) {
523
- yield* this.#denialResult(pending.callId, decided.reason ?? "denied by user", invocationSeq);
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
- // Receipt repair: an execution that reached a terminal state but
527
- // whose model-facing result never landed is completed FROM THE
528
- // RECEIPT — never re-executed. Snapshot the scope first: this phase
529
- // appends the repaired results, and iterating a growing array would
530
- // re-visit them. round 4: pairing is by executionId a same-callId result
531
- // from a different execution never suppresses the repair.
532
- for (const ev of [...scope]) {
533
- if (ev.type !== "tool_execution_succeeded" && ev.type !== "tool_execution_failed")
534
- continue;
535
- const hasResult = log.all.some((e) => e.type === "tool_result" && e.executionId === ev.executionId);
536
- if (hasResult)
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
- yield log.append(ev.type === "tool_execution_succeeded"
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
- const hasResult = log.all.some((e) => e.type === "tool_result" && e.executionId === ev.executionId);
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: "tool_result",
575
- callId: ev.callId,
576
- content: denial.content,
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.36",
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",