@vincemakes/kiso-runtime 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,920 @@
1
+ /**
2
+ * AgentSession + Run — the durable multi-turn conversation (Phase C/D).
3
+ *
4
+ * A session owns ONE EventLog, seeded from disk on load and continued in
5
+ * memory. Each `run(input)`:
6
+ *
7
+ * 1. appends the user input to the log AND the store (durable first);
8
+ * 2. drives the kernel loop against the session's log — every adapter
9
+ * call is a pure projection of that log (ADR-0002), so multi-turn
10
+ * context is free;
11
+ * 3. writes every event to the store BEFORE yielding it (write-ahead);
12
+ * 4. yields the stream; the run's `runId` and `abort()` ride on the Run
13
+ * handle, not on the event union.
14
+ *
15
+ * Phase D adds the human-in-the-loop surface:
16
+ * - `pendingApprovals()` — pauses that still await a decision
17
+ * (permission_requested without permission_decided);
18
+ * - `approve(decisionId, allow)` — resumes a paused run in-process, or
19
+ * persists the decision directly when the run is gone;
20
+ * - `uncertainExecutions()` / `resolveUncertain(...)` — the ledger of
21
+ * interrupted side effects and the human's rerun/abandon verdict.
22
+ *
23
+ * Restart recovery is the same code path as a second run: rebuild the log
24
+ * from the JSONL, continue numbering where the file ended.
25
+ */
26
+ import { EventLog, executionLedger, loop, projectMessages, } from "@vincemakes/kiso-core";
27
+ import { denialResult } from "@vincemakes/kiso-core";
28
+ import { StaleWriterError } from "./store.js";
29
+ /** A session whose disk write was rejected (stale handle) is PERMANENTLY
30
+ * poisoned: its in-memory log no longer matches the disk, so no further
31
+ * run may proceed — reload the session (一). */
32
+ export class PoisonedSessionError extends Error {
33
+ constructor(reason) {
34
+ super(`session is poisoned: ${reason} — reload it; the in-memory log no longer matches the disk`);
35
+ this.name = "PoisonedSessionError";
36
+ }
37
+ }
38
+ export class ResumeBlockedError extends Error {
39
+ uncertain;
40
+ constructor(uncertain) {
41
+ super(`resume is blocked by ${uncertain.length} uncertain execution(s): ` +
42
+ uncertain.map((u) => `${u.name}(${u.executionId})`).join(", ") +
43
+ " — resolve each with resolveUncertain(executionId, 'rerun'|'abandoned') first");
44
+ this.name = "ResumeBlockedError";
45
+ this.uncertain = uncertain;
46
+ }
47
+ }
48
+ export class AgentSession {
49
+ id;
50
+ log;
51
+ #store;
52
+ #adapter;
53
+ #config;
54
+ #pendingResolvers = new Map();
55
+ #uncertaintyResolvers = new Map();
56
+ #answered = new Set();
57
+ /** 七: verdicts already passed to a live resolver — the resolution event
58
+ * lands in the log asynchronously (the loop owns it), so the ledger
59
+ * alone cannot make resolveUncertain idempotent across the same tick. */
60
+ #uncertaintyAnswered = new Set();
61
+ /** 第四轮(对抗): verdicts the human GAVE, recorded when passed to a live
62
+ * resolver. If an abort races the verdict, the loop / recovery queries
63
+ * these and records the decision (exactly once) instead of losing it. */
64
+ #approvalVerdicts = new Map();
65
+ #uncertaintyVerdicts = new Map();
66
+ /** 第五轮(P1-5): verdicts submitted to a LIVE resolver but not yet known
67
+ * durable. An async generator only advances on next(), so approve()/
68
+ * resolveUncertain() CANNOT wait for the loop to persist — that would
69
+ * deadlock (the consumer waits while the generator needs a next()).
70
+ * Instead the verdict is recorded here, and the Run's iterator FINALLY
71
+ * flushes every not-yet-durable verdict to disk — an abandoned generator
72
+ * can never lose a verdict the human gave. */
73
+ #pendingDurableApprovals = new Map();
74
+ #pendingDurableUncertainties = new Map();
75
+ #poisoned = null;
76
+ /** Permanently invalidate the session after a rejected disk write (一). */
77
+ poison(reason) {
78
+ if (this.#poisoned === null)
79
+ this.#poisoned = reason;
80
+ }
81
+ ensureHealthy() {
82
+ if (this.#poisoned !== null)
83
+ throw new PoisonedSessionError(this.#poisoned);
84
+ }
85
+ #activeRuns = new Set();
86
+ constructor(id, log, store, adapter, config) {
87
+ this.id = id;
88
+ this.log = log;
89
+ this.#store = store;
90
+ this.#adapter = adapter;
91
+ this.#config = config;
92
+ }
93
+ /** Write-ahead through the store; a rejected write POISONS the session
94
+ * (一/第四轮): the in-memory log no longer matches the disk — whatever
95
+ * the cause (stale handle, corruption, a live external writer, an I/O
96
+ * fault) — so no further run, resume, or log mutation may proceed.
97
+ * The health check runs BEFORE every write, on every path. */
98
+ async persist(runId, event) {
99
+ this.ensureHealthy();
100
+ try {
101
+ await this.#store.append(this.id, runId, event);
102
+ }
103
+ catch (err) {
104
+ // 第四轮: ANY rejected write poisons — not only the typed
105
+ // stale/corruption errors. A live external writer's lock error
106
+ // is the realistic case; the in-memory log is ahead of the disk
107
+ // in all of them.
108
+ this.poison(err.message);
109
+ throw err;
110
+ }
111
+ }
112
+ // ── one active run per session (Area 1) ──────────────────────────────
113
+ beginRun(run) {
114
+ if (this.#activeRuns.size > 0) {
115
+ throw new Error("this session already has an active run — one run at a time");
116
+ }
117
+ this.#activeRuns.add(run);
118
+ }
119
+ endRun(run) {
120
+ this.#activeRuns.delete(run);
121
+ }
122
+ /** The conversation so far, as the model sees it. */
123
+ projected() {
124
+ return projectMessages(this.log.all);
125
+ }
126
+ /** Run one user turn. Iterate to consume; `run.abort()` cancels. */
127
+ run(input, options) {
128
+ this.ensureHealthy();
129
+ return new Run(this.#store, this.#adapter, this.#config, this, input, options?.signal, false);
130
+ }
131
+ /**
132
+ * Continue the interrupted run (Area 2): apply durable decisions,
133
+ * fill missing receipts, resume the pause, and drive the original
134
+ * trajectory to its terminal — WITHOUT inventing a new user turn.
135
+ * Yields nothing when the session already completed.
136
+ */
137
+ resume() {
138
+ this.ensureHealthy();
139
+ return new Run(this.#store, this.#adapter, this.#config, this, undefined, undefined, true);
140
+ }
141
+ // ── Phase D: approvals ───────────────────────────────────────────────
142
+ /**
143
+ * Pauses that still await a human decision (durable, survives restart).
144
+ * B 组: a request whose RUN has terminated is DEAD — it is neither
145
+ * re-presented here nor recoverable; expired requests are excluded too.
146
+ */
147
+ pendingApprovals() {
148
+ const records = this.#store.load(this.id);
149
+ const terminatedRuns = new Set();
150
+ for (const r of records) {
151
+ if (r.event.type === "terminal")
152
+ terminatedRuns.add(r.runId);
153
+ }
154
+ const requestRun = new Map();
155
+ for (const r of records) {
156
+ if (r.event.type === "permission_requested")
157
+ requestRun.set(r.event.decisionId, r.runId);
158
+ }
159
+ const decided = new Set(this.log.all.filter((e) => e.type === "permission_decided").map((e) => e.decisionId));
160
+ const expired = new Set(this.log.all.filter((e) => e.type === "permission_expired").map((e) => e.decisionId));
161
+ return this.log.all
162
+ .filter((e) => {
163
+ if (e.type !== "permission_requested")
164
+ return false;
165
+ if (decided.has(e.decisionId) || expired.has(e.decisionId))
166
+ return false;
167
+ const runId = requestRun.get(e.decisionId);
168
+ return runId === undefined || !terminatedRuns.has(runId);
169
+ })
170
+ .map((e) => ({
171
+ decisionId: e.decisionId,
172
+ callId: e.callId,
173
+ name: e.name,
174
+ input: e.input,
175
+ }));
176
+ }
177
+ /**
178
+ * Answer a pending approval (Area 2). With a live run, the decision is
179
+ * RESOLVED into the run's frame — the loop (or the resume recovery)
180
+ * writes `permission_decided` itself, so there is exactly one writer per
181
+ * event and seq never duplicates. With no live run, the decision is
182
+ * persisted directly (durable, attributed to the original run) and the
183
+ * next resume applies it without re-asking. The crash window between a
184
+ * resolve and the run's write is benign: nothing has executed yet, so a
185
+ * lost decision only re-presents the request.
186
+ */
187
+ async approve(decisionId, allow) {
188
+ // 第四轮: a poisoned session may not mutate the log — checked before
189
+ // anything is recorded.
190
+ this.ensureHealthy();
191
+ // Idempotent: one decision per request (review finding 7). The
192
+ // in-memory answered-set covers the same-tick double answer — the
193
+ // loop writes the durable record asynchronously after the resolver
194
+ // wakes, so the log cannot be consulted yet. The durable check below
195
+ // covers answers arriving after the record landed.
196
+ if (this.#answered.has(decisionId))
197
+ return;
198
+ this.#answered.add(decisionId);
199
+ if (this.log.all.some((e) => e.type === "permission_decided" && e.decisionId === decisionId))
200
+ return;
201
+ // B 组: a late approve() on a TERMINATED run writes nothing and
202
+ // executes nothing — a dead run's approval cannot resurrect it.
203
+ const records = this.#store.load(this.id);
204
+ const request = records.find((r) => r.event.type === "permission_requested" && r.event.decisionId === decisionId);
205
+ if (request) {
206
+ const runTerminated = records.some((r) => r.runId === request.runId && r.event.type === "terminal");
207
+ if (runTerminated)
208
+ return;
209
+ }
210
+ const resolver = this.#pendingResolvers.get(decisionId);
211
+ if (resolver !== undefined) {
212
+ // 第四轮(对抗): recorded so an abort racing the verdict cannot
213
+ // lose it — the loop's abort path consults approvalVerdict.
214
+ this.#approvalVerdicts.set(decisionId, allow);
215
+ // 第五轮(P1-5): the verdict is SUBMITTED — the Run's finally
216
+ // flushes it to disk if the generator never gets to persist it.
217
+ // (Waiting here for durability would deadlock: the generator
218
+ // only advances on the consumer's next(), which the consumer
219
+ // cannot issue while awaiting approve().)
220
+ this.#pendingDurableApprovals.set(decisionId, allow);
221
+ this.#pendingResolvers.delete(decisionId);
222
+ resolver(allow ? { action: "allow" } : { action: "deny", reason: "denied by user" });
223
+ return;
224
+ }
225
+ const runId = request?.runId ?? "approval";
226
+ const decided = this.log.append({
227
+ type: "permission_decided",
228
+ decisionId,
229
+ ...(request !== undefined ? { callId: request.event.callId } : {}),
230
+ decision: allow ? "approved" : "denied",
231
+ ...(allow ? {} : { reason: "denied by user" }),
232
+ });
233
+ await this.persist(runId, decided);
234
+ }
235
+ // ── Phase D: the uncertain-execution ledger ──────────────────────────
236
+ /** Executions that started but never reported a result (crash window). */
237
+ uncertainExecutions() {
238
+ return [...executionLedger(this.log.all).values()].filter((r) => r.status === "uncertain");
239
+ }
240
+ /**
241
+ * The human's verdict on an interrupted execution, keyed by EXECUTION ID
242
+ * (B 组): "rerun" (the human says the side effect did NOT happen — the
243
+ * attempt is completed with a recorded failure so the model may re-issue
244
+ * it as a new logical call) or "abandoned" (treated as failed forever).
245
+ * Only uncertain → rerun/abandoned is legal; a resolved or successful
246
+ * execution is left untouched (idempotent, irreversible). Both fill a
247
+ * model-facing result — a dangling tool_use with NO result would be
248
+ * rejected by real providers (review finding 1).
249
+ */
250
+ async resolveUncertain(executionId, resolution) {
251
+ // 第四轮: a poisoned session may not mutate the log.
252
+ this.ensureHealthy();
253
+ const record = executionLedger(this.log.all).get(executionId);
254
+ if (!record)
255
+ throw new Error(`no execution record for ${executionId}`);
256
+ if (record.status !== "uncertain")
257
+ return; // idempotent + irreversible
258
+ // 七: a verdict already passed to a live resolver is FINAL — the
259
+ // loop's resolution event lands asynchronously, so the ledger alone
260
+ // cannot make this idempotent across the same tick.
261
+ if (this.#uncertaintyAnswered.has(executionId))
262
+ return;
263
+ this.#uncertaintyAnswered.add(executionId);
264
+ // 七: with a LIVE resolver, the active loop / recovery generator
265
+ // OWNS the resolution event — it appends, yields, and persists it
266
+ // through the Run, so the consumer's stream and the durable log
267
+ // stay identical. We only pass the verdict; a hidden append here
268
+ // would leave a seq gap. 第四轮(对抗): the verdict is recorded so an
269
+ // abort racing it cannot lose it.
270
+ const resolver = this.#uncertaintyResolvers.get(executionId);
271
+ if (resolver !== undefined) {
272
+ this.#uncertaintyVerdicts.set(executionId, resolution);
273
+ // 第五轮(P1-5): submitted — flushed to disk by the Run's finally
274
+ // if the generator never persists it.
275
+ this.#pendingDurableUncertainties.set(executionId, { resolution, callId: record.callId });
276
+ this.#uncertaintyResolvers.delete(executionId);
277
+ resolver(resolution);
278
+ return;
279
+ }
280
+ // 七: OFFLINE verdict — no live resolver: persist directly.
281
+ // 四: the verdict is attributed to the ORIGINAL run of the execution
282
+ // — never the fake runId "resolution".
283
+ const runId = this.runIdFor(executionId);
284
+ const resolved = this.log.append({
285
+ type: "tool_execution_resolved",
286
+ executionId,
287
+ callId: record.callId,
288
+ resolution,
289
+ });
290
+ await this.persist(runId, resolved);
291
+ // 四: the fill is keyed by THIS execution — a tool_result belonging to
292
+ // a different (same-callId) execution must not suppress the verdict's
293
+ // model-facing result, and the fill itself carries the executionId.
294
+ // 八(对抗): the fill also carries the tags from the durable RECEIPT —
295
+ // the normal live path emits the result with tags before the pause,
296
+ // so a crash-window repair reproduces them.
297
+ if (!this.log.all.some((e) => e.type === "tool_result" && e.executionId === record.executionId)) {
298
+ const denial = denialResult(resolution === "rerun"
299
+ ? "interrupted execution — rerun approved: the attempt is treated as NOT applied; the model may retry"
300
+ : "abandoned by human decision — the interrupted attempt must not be treated as applied");
301
+ const receipt = [...this.log.all]
302
+ .reverse()
303
+ .find((e) => (e.type === "tool_execution_failed" || e.type === "tool_execution_succeeded") &&
304
+ e.executionId === executionId);
305
+ const result = this.log.append({
306
+ type: "tool_result",
307
+ callId: record.callId,
308
+ content: denial.content,
309
+ isError: true,
310
+ errorKind: denial.errorKind,
311
+ ...(receipt?.tags !== undefined ? { tags: receipt.tags } : {}),
312
+ executionId: record.executionId,
313
+ });
314
+ await this.persist(runId, result);
315
+ }
316
+ }
317
+ /** The runId that owns an execution — from its durable started record. */
318
+ runIdFor(executionId) {
319
+ const rec = this.#store
320
+ .load(this.id)
321
+ .find((r) => r.event.type === "tool_execution_started" &&
322
+ r.event.executionId === executionId);
323
+ if (!rec)
324
+ throw new Error(`no durable execution record for ${executionId}`);
325
+ return rec.runId;
326
+ }
327
+ registerUncertaintyResolver(executionId, resolve) {
328
+ this.#uncertaintyResolvers.set(executionId, resolve);
329
+ }
330
+ dropUncertaintyResolver(executionId) {
331
+ this.#uncertaintyResolvers.delete(executionId);
332
+ }
333
+ // ── internal: the resolver registry ──────────────────────────────────
334
+ registerResolver(decisionId, resolve) {
335
+ this.#pendingResolvers.set(decisionId, resolve);
336
+ }
337
+ /** 第四轮(对抗): a verdict the human already gave for a live decision. */
338
+ approvalVerdict(decisionId) {
339
+ return this.#approvalVerdicts.get(decisionId);
340
+ }
341
+ /** 第四轮(对抗): a verdict the human already gave for a live execution. */
342
+ uncertaintyVerdict(executionId) {
343
+ return this.#uncertaintyVerdicts.get(executionId);
344
+ }
345
+ /**
346
+ * 第五轮(P1-5): flush every verdict submitted to a live resolver that is
347
+ * not yet durable. Called from the Run iterator's FINALLY — whether the
348
+ * run completed, aborted, or was abandoned by the consumer. An event the
349
+ * loop already appended is left alone (its persist precedes its yield);
350
+ * a missing event is appended here and persisted, attributed to the run.
351
+ */
352
+ async flushPendingVerdicts(runId, log) {
353
+ for (const [decisionId, allow] of this.#pendingDurableApprovals) {
354
+ const decided = log.all.find((e) => e.type === "permission_decided" && e.decisionId === decisionId);
355
+ if (decided === undefined) {
356
+ const app = log.append({
357
+ type: "permission_decided",
358
+ decisionId,
359
+ decision: allow ? "approved" : "denied",
360
+ ...(allow ? {} : { reason: "denied by user" }),
361
+ });
362
+ await this.persist(runId, app);
363
+ }
364
+ this.#pendingDurableApprovals.delete(decisionId);
365
+ }
366
+ for (const [executionId, pending] of this.#pendingDurableUncertainties) {
367
+ const resolved = log.all.find((e) => e.type === "tool_execution_resolved" && e.executionId === executionId);
368
+ if (resolved === undefined) {
369
+ const app = log.append({
370
+ type: "tool_execution_resolved",
371
+ executionId,
372
+ callId: pending.callId,
373
+ resolution: pending.resolution,
374
+ });
375
+ await this.persist(runId, app);
376
+ }
377
+ this.#pendingDurableUncertainties.delete(executionId);
378
+ }
379
+ }
380
+ dropResolver(decisionId) {
381
+ this.#pendingResolvers.delete(decisionId);
382
+ }
383
+ }
384
+ /**
385
+ * A single turn. Async-iterable, so `for await (const ev of session.run(x))`
386
+ * is the natural shape; the handle also carries the runId and the abort.
387
+ */
388
+ export class Run {
389
+ runId;
390
+ #store;
391
+ #adapter;
392
+ #config;
393
+ #session;
394
+ #input;
395
+ #resume;
396
+ #abort = new AbortController();
397
+ #externalSignal;
398
+ #decisionIds = [];
399
+ #uncertaintyIds = [];
400
+ #started = false;
401
+ constructor(store, adapter, config, session, input, externalSignal, resume) {
402
+ this.#store = store;
403
+ this.#adapter = adapter;
404
+ this.#config = config;
405
+ this.#session = session;
406
+ this.#input = input;
407
+ this.#externalSignal = externalSignal;
408
+ this.#resume = resume;
409
+ this.runId = crypto.randomUUID();
410
+ }
411
+ /** Cancel the run: propagates to the adapter (SDK) and future executions. */
412
+ abort() {
413
+ this.#abort.abort();
414
+ }
415
+ async *[Symbol.asyncIterator]() {
416
+ if (this.#started)
417
+ throw new Error("a run may only be consumed once");
418
+ this.#started = true;
419
+ // The WHOLE body is one try/finally: a consumer that abandons the
420
+ // run at ANY yield (even the user_input one) must release the
421
+ // session's single-run slot and its approval resolvers.
422
+ try {
423
+ // 第四轮: health is re-checked when the iterator ACTUALLY starts —
424
+ // a run constructed before the session was poisoned must fail
425
+ // here, before any log or disk mutation.
426
+ this.#session.ensureHealthy();
427
+ this.#session.beginRun(this);
428
+ const log = this.#session.log;
429
+ const signal = this.#externalSignal ? new MergedSignal(this.#abort.signal, this.#externalSignal) : this.#abort.signal;
430
+ const loopConfig = () => ({
431
+ adapter: this.#adapter,
432
+ model: this.#config.model,
433
+ ...(this.#config.systemPrompt !== undefined ? { systemPrompt: this.#config.systemPrompt } : {}),
434
+ registry: this.#config.registry,
435
+ ...(this.#config.hooks !== undefined ? { hooks: this.#config.hooks } : {}),
436
+ ...(this.#config.maxTurns !== undefined ? { maxTurns: this.#config.maxTurns } : {}),
437
+ ...(this.#config.maxTokens !== undefined ? { maxTokens: this.#config.maxTokens } : {}),
438
+ ...(this.#config.temperature !== undefined ? { temperature: this.#config.temperature } : {}),
439
+ ...(this.#config.compaction !== undefined ? { compaction: this.#config.compaction } : {}),
440
+ ...(this.#config.maxRetries !== undefined ? { maxRetries: this.#config.maxRetries } : {}),
441
+ log,
442
+ signal,
443
+ resolveApproval: (decisionId) => new Promise((resolve) => {
444
+ this.#decisionIds.push(decisionId);
445
+ this.#session.registerResolver(decisionId, resolve);
446
+ }),
447
+ // 第四轮(对抗): the abort paths consult these so a verdict
448
+ // the human gave in the same instant as the abort is
449
+ // recorded, exactly once.
450
+ approvalVerdict: (decisionId) => this.#session.approvalVerdict(decisionId),
451
+ uncertaintyVerdict: (executionId) => this.#session.uncertaintyVerdict(executionId),
452
+ resolveUncertainty: (executionId) => new Promise((resolve) => {
453
+ this.#uncertaintyIds.push(executionId);
454
+ this.#session.registerUncertaintyResolver(executionId, resolve);
455
+ }),
456
+ });
457
+ const self = this;
458
+ const runLoop = async function* () {
459
+ for await (const ev of loop(loopConfig())) {
460
+ await self.#session.persist(self.runId, ev);
461
+ yield ev;
462
+ }
463
+ };
464
+ if (this.#resume) {
465
+ // ── B 组: recovery is PER-RUN, keyed by StoreRecord.runId ──
466
+ // Rebuild run boundaries; only the LAST unterminated run is
467
+ // recovered. Earlier runs that DID terminate have their
468
+ // dangling approvals closed (permission_expired) — a dead
469
+ // run's approval is never re-presented or resurrected.
470
+ const records = this.#store.load(this.#session.id);
471
+ const runs = new Map();
472
+ const order = [];
473
+ for (const r of records) {
474
+ if (!runs.has(r.runId)) {
475
+ runs.set(r.runId, []);
476
+ order.push(r.runId);
477
+ }
478
+ runs.get(r.runId).push(r.event);
479
+ }
480
+ let lastOpen;
481
+ for (const runId of order) {
482
+ const events = runs.get(runId);
483
+ if (!events.some((e) => e.type === "terminal"))
484
+ lastOpen = { runId, events };
485
+ }
486
+ if (!lastOpen)
487
+ return; // everything terminated — nothing to resume
488
+ // Adopt the ORIGINAL runId so the whole trajectory stays one
489
+ // run in the audit.
490
+ this.runId = lastOpen.runId;
491
+ // Close dangling approvals of TERMINATED runs.
492
+ for (const [runId, events] of runs) {
493
+ if (runId === lastOpen.runId)
494
+ continue;
495
+ if (!events.some((e) => e.type === "terminal"))
496
+ continue; // an open earlier run? impossible — lastOpen is the LAST
497
+ for (const ev of events) {
498
+ if (ev.type !== "permission_requested")
499
+ continue;
500
+ const dead = this.#session.log.all.some((e) => (e.type === "permission_decided" || e.type === "permission_expired") &&
501
+ e.decisionId === ev.decisionId);
502
+ if (dead)
503
+ continue;
504
+ const expired = this.#session.log.append({
505
+ type: "permission_expired",
506
+ decisionId: ev.decisionId,
507
+ reason: `run ${runId} terminated before the request was answered`,
508
+ });
509
+ await this.#session.persist(runId, expired);
510
+ }
511
+ }
512
+ // Uncertain executions block until a human decides.
513
+ const uncertain = this.#session.uncertainExecutions();
514
+ if (uncertain.length > 0) {
515
+ throw new ResumeBlockedError(uncertain.map((u) => ({ executionId: u.executionId, callId: u.callId, name: u.name })));
516
+ }
517
+ // 1. Recovery scoped to the LAST OPEN RUN's events. The recover
518
+ // phase re-announces ALREADY-PERSISTED events (the stored
519
+ // permission_requested) for the consumer to re-prompt on —
520
+ // those must never be written to the store again, or seq
521
+ // would duplicate. Only events newer than the base log
522
+ // entry are durable.
523
+ const baseSeq = log.lastSeq;
524
+ const persist = async (ev) => {
525
+ if (ev.seq > baseSeq)
526
+ await this.#session.persist(this.runId, ev);
527
+ };
528
+ for await (const ev of this.#recover(log, signal, lastOpen.events)) {
529
+ await persist(ev);
530
+ yield ev;
531
+ }
532
+ // 2. Continuation: drive the LAST OPEN run to its terminal.
533
+ // The guard is scoped to that run — an earlier run's
534
+ // terminal must not suppress it (B 组).
535
+ if (!lastOpen.events.some((e) => e.type === "terminal")) {
536
+ for await (const ev of runLoop())
537
+ yield ev;
538
+ }
539
+ return;
540
+ }
541
+ // 四: a session with an open run REFUSES new runs at the
542
+ // persistence layer — a second open run would be permanently
543
+ // orphaned (recovery only ever recovers the last one). The
544
+ // open run is continued via resume(), never by starting another.
545
+ const openRun = openRunId(this.#store.load(this.#session.id));
546
+ if (openRun !== undefined) {
547
+ throw new Error(`session ${this.#session.id} still has an open run (${openRun}) — resume() it instead of starting a new run`);
548
+ }
549
+ // 1. Durable first: the prompt enters the log and the store
550
+ // before any model call — a crash here leaves a restorable
551
+ // session. The prompt is also the first event the consumer
552
+ // sees, so what was asked and what happened live in the same
553
+ // stream.
554
+ const inputEvent = log.append({ type: "user_input", content: this.#input });
555
+ await this.#session.persist(this.runId, inputEvent);
556
+ yield inputEvent;
557
+ // 2. The loop projects from the session log — multi-turn context
558
+ // is the projection, not a second copy.
559
+ for await (const ev of runLoop())
560
+ yield ev;
561
+ }
562
+ finally {
563
+ // 第五轮(P1-5): flush verdicts the consumer submitted before the
564
+ // generator was abandoned — an approve()/resolveUncertain() whose
565
+ // durable event the loop never got to persist must STILL land on
566
+ // disk, exactly once.
567
+ try {
568
+ await this.#session.flushPendingVerdicts(this.runId, this.#session.log);
569
+ }
570
+ catch {
571
+ // the flush itself failed (poisoned session) — the error
572
+ // already poisoned everything; nothing more can be done.
573
+ }
574
+ // The run is over (or abandoned): its unanswered approvals must
575
+ // fall back to the direct-persist path, so a late approve() is
576
+ // still durable.
577
+ for (const decisionId of this.#decisionIds) {
578
+ this.#session.dropResolver(decisionId);
579
+ }
580
+ for (const executionId of this.#uncertaintyIds) {
581
+ this.#session.dropUncertaintyResolver(executionId);
582
+ }
583
+ this.#session.endRun(this);
584
+ }
585
+ }
586
+ // ── Area 2: the durable recovery state machine ───────────────────────
587
+ /**
588
+ * Apply every durable decision and fill every missing receipt, in log
589
+ * order. A decision with no execution yet EXECUTES the persisted call
590
+ * (its original name/input/callId — never re-asked of the model, never
591
+ * re-approved); a denial writes its tool result; a succeeded/failed
592
+ * execution whose tool_result never landed is completed from the
593
+ * receipt. Undecided requests pause and await approve().
594
+ */
595
+ async *#recover(log, signal, scope) {
596
+ const requests = scope.filter((e) => e.type === "permission_requested");
597
+ for (const pending of requests) {
598
+ const decided = log.all.find((e) => e.type === "permission_decided" && e.decisionId === pending.decisionId);
599
+ // 四: paired by events NEWER than the request — a historical
600
+ // same-callId execution from an earlier run must not count as THIS
601
+ // request's execution (the provider callId may repeat across runs).
602
+ const hasExecution = log.all.some((e) => e.type === "tool_execution_started" && e.callId === pending.callId && e.seq > pending.seq);
603
+ const hasResult = log.all.some((e) => e.type === "tool_result" && e.callId === pending.callId && e.seq > pending.seq);
604
+ if (decided === undefined) {
605
+ // Pause: announce the stored request, await the human.
606
+ const pendingDecision = new Promise((resolve) => {
607
+ this.#decisionIds.push(pending.decisionId);
608
+ this.#session.registerResolver(pending.decisionId, resolve);
609
+ });
610
+ yield pending;
611
+ // Area 4: an abort during the resumed approval wait ends the
612
+ // run; the request stays durable and pending.
613
+ if (signal.aborted) {
614
+ // 第五轮(P1-6): a verdict given in the same instant as the
615
+ // abort is still recorded — the abort must not bypass the
616
+ // durable fallback (aligned with the loop's abort path).
617
+ const verdict = this.#session.approvalVerdict(pending.decisionId);
618
+ if (verdict !== undefined) {
619
+ yield log.append({
620
+ type: "permission_decided",
621
+ decisionId: pending.decisionId,
622
+ callId: pending.callId,
623
+ decision: verdict ? "approved" : "denied",
624
+ ...(verdict ? {} : { reason: "denied by user" }),
625
+ });
626
+ }
627
+ return;
628
+ }
629
+ const final = await abortable(pendingDecision, signal);
630
+ if (final === ABORTED) {
631
+ // 第四轮(对抗): a verdict given in the same instant as the
632
+ // abort is recorded (exactly once), never lost.
633
+ const verdict = this.#session.approvalVerdict(pending.decisionId);
634
+ if (verdict !== undefined) {
635
+ yield log.append({
636
+ type: "permission_decided",
637
+ decisionId: pending.decisionId,
638
+ callId: pending.callId,
639
+ decision: verdict ? "approved" : "denied",
640
+ ...(verdict ? {} : { reason: "denied by user" }),
641
+ });
642
+ }
643
+ return;
644
+ }
645
+ // The decision is written here — exactly one writer per event.
646
+ yield log.append({
647
+ type: "permission_decided",
648
+ decisionId: pending.decisionId,
649
+ callId: pending.callId, // binds the decision to the invocation (B 组)
650
+ decision: final.action === "allow" ? "approved" : "denied",
651
+ ...(final.action === "deny" && final.reason !== undefined ? { reason: final.reason } : {}),
652
+ });
653
+ if (final.action === "allow") {
654
+ if (!hasExecution)
655
+ yield* this.#executePersisted(pending.callId, pending.name, pending.input, signal);
656
+ }
657
+ else if (!hasResult) {
658
+ yield* this.#denialResult(pending.callId, final.reason ?? "denied by user");
659
+ }
660
+ }
661
+ else if (decided.decision === "approved") {
662
+ // Decided while no process was running: apply without pausing.
663
+ // An abort during recovery must stop the pending executions,
664
+ // exactly like the live loop's sibling guard (finding 3).
665
+ if (signal.aborted)
666
+ return;
667
+ if (!hasExecution)
668
+ yield* this.#executePersisted(pending.callId, pending.name, pending.input, signal);
669
+ }
670
+ else if (!hasResult) {
671
+ yield* this.#denialResult(pending.callId, decided.reason ?? "denied by user");
672
+ }
673
+ }
674
+ // Receipt repair: an execution that reached a terminal state but
675
+ // whose model-facing result never landed is completed FROM THE
676
+ // RECEIPT — never re-executed. Snapshot the scope first: this phase
677
+ // appends the repaired results, and iterating a growing array would
678
+ // re-visit them. 四: pairing is by executionId — a same-callId result
679
+ // from a different execution never suppresses the repair.
680
+ for (const ev of [...scope]) {
681
+ if (ev.type !== "tool_execution_succeeded" && ev.type !== "tool_execution_failed")
682
+ continue;
683
+ const hasResult = log.all.some((e) => e.type === "tool_result" && e.executionId === ev.executionId);
684
+ if (hasResult)
685
+ continue;
686
+ yield log.append(ev.type === "tool_execution_succeeded"
687
+ ? {
688
+ type: "tool_result",
689
+ callId: ev.callId,
690
+ content: ev.result.content,
691
+ isError: false,
692
+ // 八: the repaired result reproduces the normal path
693
+ // losslessly — the tags ride on the durable receipt.
694
+ ...(ev.tags !== undefined ? { tags: ev.tags } : {}),
695
+ executionId: ev.executionId,
696
+ }
697
+ : {
698
+ type: "tool_result",
699
+ callId: ev.callId,
700
+ content: ev.error,
701
+ isError: true,
702
+ ...(ev.errorKind !== undefined ? { errorKind: ev.errorKind } : {}),
703
+ ...(ev.tags !== undefined ? { tags: ev.tags } : {}),
704
+ executionId: ev.executionId,
705
+ });
706
+ }
707
+ // B 组 crash window: a resolution was persisted but its tool_result
708
+ // fill never landed — complete it so the model is never left staring
709
+ // at a dangling tool_use. 四: keyed by executionId, and the fill
710
+ // carries it, so a same-callId result from another execution is never
711
+ // confused with this one.
712
+ for (const ev of [...scope]) {
713
+ if (ev.type !== "tool_execution_resolved")
714
+ continue;
715
+ const hasResult = log.all.some((e) => e.type === "tool_result" && e.executionId === ev.executionId);
716
+ if (hasResult)
717
+ continue;
718
+ const denial = denialResult(ev.resolution === "rerun"
719
+ ? "interrupted execution — rerun approved: the attempt is treated as NOT applied; the model may retry"
720
+ : "abandoned by human decision — the interrupted attempt must not be treated as applied");
721
+ yield log.append({
722
+ type: "tool_result",
723
+ callId: ev.callId,
724
+ content: denial.content,
725
+ isError: true,
726
+ errorKind: denial.errorKind,
727
+ executionId: ev.executionId,
728
+ });
729
+ }
730
+ }
731
+ /**
732
+ * Execute a call whose approval is already durable: the original
733
+ * name/input/callId from the persisted permission_requested, bypassing
734
+ * the permission hook (it was decided) and the model (it was never
735
+ * asked to re-issue). Full ledgered lifecycle.
736
+ */
737
+ async *#executePersisted(callId, name, input, signal) {
738
+ const log = this.#session.log;
739
+ const tool = this.#config.registry.get(name);
740
+ const executionId = `ex-${log.lastSeq + 1}`;
741
+ // An abort that landed while the decision was being applied must
742
+ // not start the side effect (finding 3).
743
+ if (signal.aborted)
744
+ return;
745
+ yield log.append({ type: "tool_execution_started", executionId, callId, name, input });
746
+ let result;
747
+ if (tool === undefined) {
748
+ result = { content: `Unknown tool: ${name}`, isError: true, errorKind: "invalid_input" };
749
+ }
750
+ else {
751
+ try {
752
+ if (signal.aborted) {
753
+ result = { content: "aborted before execution", isError: true, errorKind: "fatal" };
754
+ }
755
+ else {
756
+ result = await tool.execute(input, { signal });
757
+ }
758
+ }
759
+ catch (err) {
760
+ result = {
761
+ content: err instanceof Error ? err.message : String(err),
762
+ isError: true,
763
+ errorKind: "fatal",
764
+ };
765
+ }
766
+ if (this.#config.hooks?.onPostTool) {
767
+ result = await this.#config.hooks.onPostTool({ callId, name, input }, result, { sessionId: this.#session.id });
768
+ }
769
+ }
770
+ if (result.isError) {
771
+ yield log.append({
772
+ type: "tool_execution_failed",
773
+ executionId,
774
+ callId,
775
+ error: result.content,
776
+ // P1-9: errorKind only exists on errors — runtime-guarded too.
777
+ ...(result.isError && result.errorKind !== undefined ? { errorKind: result.errorKind } : {}),
778
+ safeToRetry: tool?.idempotent === true,
779
+ ...(result.tags !== undefined ? { tags: result.tags } : {}),
780
+ });
781
+ }
782
+ else {
783
+ yield log.append({
784
+ type: "tool_execution_succeeded",
785
+ executionId,
786
+ callId,
787
+ result: { content: result.content, isError: false },
788
+ ...(result.tags !== undefined ? { tags: result.tags } : {}),
789
+ });
790
+ }
791
+ yield log.append({
792
+ type: "tool_result",
793
+ callId,
794
+ content: result.content,
795
+ isError: result.isError,
796
+ // P1-9: errorKind only exists on errors — runtime-guarded too.
797
+ ...(result.isError && result.errorKind !== undefined ? { errorKind: result.errorKind } : {}),
798
+ // 五: live tags survive the resumed path too.
799
+ ...(result.tags !== undefined ? { tags: result.tags } : {}),
800
+ executionId,
801
+ });
802
+ // 四: a failed NON-idempotent execution after a cross-process approval
803
+ // is a durable uncertain PAUSE, exactly like the live loop's — the
804
+ // provider and any sibling executions stop until a human decides.
805
+ // An abort during the wait leaves the execution uncertain; the next
806
+ // resume blocks on it (ResumeBlockedError) instead of continuing.
807
+ if (result.isError && tool !== undefined && tool.idempotent !== true) {
808
+ const pendingResolution = new Promise((resolve) => {
809
+ this.#uncertaintyIds.push(executionId);
810
+ this.#session.registerUncertaintyResolver(executionId, resolve);
811
+ });
812
+ const pendingUncertain = log.append({
813
+ type: "uncertain_pending",
814
+ executionId,
815
+ callId,
816
+ name,
817
+ error: result.content,
818
+ });
819
+ yield pendingUncertain;
820
+ const verdict = await abortable(pendingResolution, signal);
821
+ if (verdict === ABORTED) {
822
+ // 第四轮(对抗): a verdict given in the same instant as the
823
+ // abort is recorded (exactly once), never lost.
824
+ const given = this.#session.uncertaintyVerdict(executionId);
825
+ if (given !== undefined) {
826
+ yield log.append({
827
+ type: "tool_execution_resolved",
828
+ executionId,
829
+ callId,
830
+ resolution: given,
831
+ });
832
+ }
833
+ return;
834
+ }
835
+ // 七: the recovery generator owns the resolution event — appended
836
+ // and yielded here, persisted by the Run's wrapper (a live
837
+ // resolveUncertain() only passed the verdict).
838
+ yield log.append({
839
+ type: "tool_execution_resolved",
840
+ executionId,
841
+ callId,
842
+ resolution: verdict,
843
+ });
844
+ }
845
+ }
846
+ /** The model-facing result of a durable denial — no execution happened. */
847
+ async *#denialResult(callId, reason) {
848
+ const denial = denialResult(reason);
849
+ yield this.#session.log.append({
850
+ type: "tool_result",
851
+ callId,
852
+ content: denial.content,
853
+ isError: true,
854
+ errorKind: denial.errorKind,
855
+ });
856
+ }
857
+ }
858
+ /**
859
+ * The most recent run WITHOUT a terminal, or undefined when every recorded
860
+ * run terminated. Recovery can only drive ONE run to its terminal, so an
861
+ * open run must be the exclusive reason a session refuses new runs (四).
862
+ */
863
+ function openRunId(records) {
864
+ const terminated = new Set(records.filter((r) => r.event.type === "terminal").map((r) => r.runId));
865
+ for (let i = records.length - 1; i >= 0; i--) {
866
+ const runId = records[i].runId;
867
+ if (!terminated.has(runId))
868
+ return runId;
869
+ }
870
+ return undefined;
871
+ }
872
+ /** Sentinel: the signal aborted while the recovery awaited a decision. */
873
+ const ABORTED = Symbol("kiso-resume-aborted");
874
+ /** Resolve with the decision, or ABORTED when the signal fires first. */
875
+ async function abortable(promise, signal) {
876
+ if (signal.aborted)
877
+ return ABORTED;
878
+ return new Promise((resolve) => {
879
+ const onAbort = () => {
880
+ signal.removeEventListener("abort", onAbort);
881
+ resolve(ABORTED);
882
+ };
883
+ signal.addEventListener("abort", onAbort, { once: true });
884
+ promise.then((value) => {
885
+ signal.removeEventListener("abort", onAbort);
886
+ resolve(value);
887
+ }, (err) => {
888
+ signal.removeEventListener("abort", onAbort);
889
+ throw err;
890
+ });
891
+ });
892
+ }
893
+ /**
894
+ * A signal that fires when ANY source fires — the run's own controller and
895
+ * an optional external signal (the CLI's Ctrl+C, a fixture's flip).
896
+ */
897
+ class MergedSignal {
898
+ #sources;
899
+ #listeners = new Set();
900
+ constructor(...sources) {
901
+ this.#sources = sources;
902
+ for (const source of sources) {
903
+ if (source.aborted)
904
+ continue;
905
+ source.addEventListener("abort", () => {
906
+ for (const listener of this.#listeners)
907
+ listener();
908
+ });
909
+ }
910
+ }
911
+ get aborted() {
912
+ return this.#sources.some((s) => s.aborted);
913
+ }
914
+ addEventListener(_type, listener) {
915
+ this.#listeners.add(() => listener.call(this, undefined));
916
+ }
917
+ removeEventListener(_type, listener) {
918
+ this.#listeners.delete(listener);
919
+ }
920
+ }