@vincemakes/kiso-runtime 0.1.36 → 0.1.38
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/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/lock-adapter.d.ts +92 -0
- package/dist/lock-adapter.js +339 -0
- package/dist/recovery-plan.d.ts +74 -0
- package/dist/recovery-plan.js +209 -0
- package/dist/run.js +361 -321
- package/dist/store.d.ts +41 -33
- package/dist/store.js +69 -236
- package/package.json +1 -1
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
|