@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.
@@ -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
+ }