@ngockhoale/ukit 3.0.8 → 3.0.10
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/CHANGELOG.md +18 -1
- package/manifests/documentation.yaml +11 -0
- package/package.json +1 -1
- package/scripts/audit/decision-coverage.mjs +29 -2
- package/scripts/bench/data-foundation.mjs +52 -3
- package/scripts/bench/decision-runtime-baseline.mjs +427 -0
- package/scripts/bench/decision-runtime-metrics.mjs +67 -0
- package/scripts/bench/decision-runtime-variant.mjs +626 -0
- package/scripts/bench/memory-ablation.mjs +495 -0
- package/scripts/bench/memory-baseline.mjs +596 -0
- package/scripts/bench/memory-bench.mjs +661 -0
- package/scripts/bench/memory-canary.mjs +321 -0
- package/scripts/bench/memory-corpus.mjs +354 -0
- package/scripts/bench/memory-gate.mjs +389 -0
- package/scripts/bench/memory-metrics.mjs +179 -0
- package/scripts/bench/parallel-agents.mjs +33 -11
- package/scripts/bench/recorder-overhead.mjs +204 -0
- package/scripts/bench/sqlite-spike.mjs +451 -0
- package/scripts/measure-decision-gateway.mjs +306 -0
- package/scripts/perf/audit-perf.mjs +35 -17
- package/src/bug/triageBug.js +4 -3
- package/src/cli/commands/memory.js +357 -63
- package/src/context/detectProjectContext.js +11 -1
- package/src/core/agentRuntime/adapters.js +254 -0
- package/src/core/agentRuntime/artifacts.js +192 -0
- package/src/core/agentRuntime/completionGate.js +176 -0
- package/src/core/agentRuntime/context.js +149 -0
- package/src/core/agentRuntime/contract.js +247 -0
- package/src/core/agentRuntime/diagnostics.js +244 -0
- package/src/core/agentRuntime/evaluation.js +163 -0
- package/src/core/agentRuntime/eventStore.js +404 -0
- package/src/core/agentRuntime/liveness.js +60 -0
- package/src/core/agentRuntime/planCompiler.js +322 -0
- package/src/core/agentRuntime/promotion.js +53 -0
- package/src/core/agentRuntime/qualityComparison.js +112 -0
- package/src/core/agentRuntime/recovery.js +266 -0
- package/src/core/agentRuntime/resourcePolicy.js +78 -0
- package/src/core/agentRuntime/runtimeSupport.js +237 -0
- package/src/core/agentRuntime/supervisor.js +565 -0
- package/src/core/agentRuntime/vmEngine.js +621 -0
- package/src/core/codeintel/analogy.js +3 -2
- package/src/core/experiments/dynamicWorkflow.js +17 -2
- package/src/core/fileOps.js +21 -3
- package/src/core/memory/deltaOverlays.js +75 -30
- package/src/core/memory/learningCandidates.js +93 -48
- package/src/core/memory/memoryFlags.js +83 -0
- package/src/core/memory/memoryFreshness.js +190 -0
- package/src/core/memory/memoryHit.js +144 -0
- package/src/core/memory/migrate.js +69 -189
- package/src/core/memory/migrateMapping.js +232 -0
- package/src/core/memory/mutateMemory.js +323 -0
- package/src/core/memory/policy.js +96 -0
- package/src/core/memory/projectIdentity.js +266 -0
- package/src/core/memory/recordIndex.js +178 -0
- package/src/core/memory/recordStore.js +133 -20
- package/src/core/memory/records.js +144 -6
- package/src/core/memory/retrieval.js +259 -125
- package/src/core/memory/store.js +16 -5
- package/src/core/memory/storeBackup.js +226 -0
- package/src/core/memory/storeV2.js +63 -26
- package/src/core/memory/storeV2Loader.js +30 -12
- package/src/core/memory/userMemory.js +38 -20
- package/src/core/memory/writeClassification.js +161 -0
- package/src/core/memory/writeGuard.js +129 -0
- package/src/core/observability/adapters/hookTelemetryAdapter.js +90 -0
- package/src/core/observability/analytics/cohorts.js +148 -0
- package/src/core/observability/analytics/storeDigest.js +163 -0
- package/src/core/observability/evaluation/experimentPlan.js +95 -0
- package/src/core/observability/evaluation/findings.js +99 -0
- package/src/core/observability/evaluation/optimizationKnowledge.js +10 -1
- package/src/core/observability/evaluation/perturbation.js +273 -0
- package/src/core/observability/evaluation/replay.js +7 -1
- package/src/core/observability/evaluation/scorecard.js +23 -3
- package/src/core/observability/rollout.js +11 -7
- package/src/core/observability/schema/compatibility.js +135 -0
- package/src/core/observability/schema/registry.js +99 -0
- package/src/core/observability/schema/validate.js +7 -0
- package/src/core/observability/support/import.js +53 -9
- package/src/core/observability/support/paths.js +13 -3
- package/src/core/observability/support/projector.js +148 -12
- package/src/core/output/index.js +12 -2
- package/src/core/runtimeConfig.js +83 -0
- package/src/core/runtimePaths.js +3 -0
- package/src/core/sensitiveValueScanner.js +40 -0
- package/src/core/token/index.js +40 -3
- package/src/decision/client.js +37 -13
- package/src/decision/protocol.js +1 -1
- package/src/decision/registry.js +5 -3
- package/src/decision/runtimeDecide.js +242 -0
- package/src/decision/runtimeFilter.js +150 -0
- package/src/decision/runtimeScheduler.js +239 -0
- package/src/index/buildIndex.js +13 -12
- package/src/index/queryIndex.js +35 -14
- package/src/index/relatedTests.js +50 -8
- package/src/index/resolveContext.js +9 -4
- package/src/manifest/selectItems.js +7 -3
- package/src/render/instructionRenderer.js +17 -5
- package/template_project/.claude/ukit/index/lib/index-core.mjs +94 -39
- package/template_project/.claude/ukit/index/route-task.mjs +121 -19
- package/template_project/.claude/ukit/index/unic-decision.mjs +28 -13
- package/template_project/.claude/ukit/runtime/memory-flags.mjs +51 -0
- package/template_project/.claude/ukit/runtime/memory-freshness.mjs +155 -0
- package/template_project/.claude/ukit/runtime/memory-policy.mjs +286 -0
- package/template_project/.claude/ukit/runtime/output-compression.mjs +3 -0
- package/template_project/.claude/ukit/runtime/reinject-context.mjs +145 -14
|
@@ -0,0 +1,621 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* agentRuntime/vmEngine.js — decision-first-runtime G4 (DR-06), VM vertical
|
|
3
|
+
* slice, omp-scoped, off-rollout (gated by host-side `decisionRuntime.vm`
|
|
4
|
+
* flag; this module reads no config itself).
|
|
5
|
+
*
|
|
6
|
+
* Executes a compiled ValidatedPlan (SPEC §4 frozen shape — literal nodes
|
|
7
|
+
* Map, entryNodes, topological order) purely against G1 durable primitives:
|
|
8
|
+
*
|
|
9
|
+
* - cursor + continuations are registered BEFORE the first node activation
|
|
10
|
+
* (register-before-start is structural; no "start without register" path)
|
|
11
|
+
* - `deliver` consumes continuations idempotently via `consumptionKey` and
|
|
12
|
+
* applies a deterministic per-node transition table — routine transitions
|
|
13
|
+
* for known nodes need ZERO classifyFn calls (no external poll)
|
|
14
|
+
* - `resume` replays journals to rebuild state (journal is source of truth;
|
|
15
|
+
* the plan state file is a rebuildable cache) and marks ambiguous crash
|
|
16
|
+
* windows `recovery_required` — never auto-replays non-idempotent nodes
|
|
17
|
+
*
|
|
18
|
+
* Journal layout (per node, operationId = `<planInstanceId>:<nodeId>`):
|
|
19
|
+
* seq1 'operation.transition' {to:'starting'} — activation record
|
|
20
|
+
* seq2 'operation.transition' {to:'running'}
|
|
21
|
+
* seqN delivered SemanticEvent (producer seq, kept verbatim)
|
|
22
|
+
* seqN+1.. 'operation.transition' records {to:'completed'|...}
|
|
23
|
+
* Engine transition records use strictly increasing seq after the delivered
|
|
24
|
+
* event, so the journal stays gap-free and the activation record suppresses
|
|
25
|
+
* any second `startFn` side-effect on replay.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { promises as fs } from 'node:fs';
|
|
29
|
+
import path from 'node:path';
|
|
30
|
+
|
|
31
|
+
import {
|
|
32
|
+
CONTRACT_VERSION,
|
|
33
|
+
OPERATION_STATES,
|
|
34
|
+
isTerminal,
|
|
35
|
+
validateTransition,
|
|
36
|
+
validateSemanticEvent,
|
|
37
|
+
validateRetry,
|
|
38
|
+
} from './contract.js';
|
|
39
|
+
import {
|
|
40
|
+
registerContinuation,
|
|
41
|
+
consumeContinuation,
|
|
42
|
+
appendEvent,
|
|
43
|
+
readJournal,
|
|
44
|
+
} from './eventStore.js';
|
|
45
|
+
|
|
46
|
+
const STATE_SET = new Set(OPERATION_STATES);
|
|
47
|
+
const PLANS_DIR = 'plans';
|
|
48
|
+
const FENCING = { fencingEpoch: 0 };
|
|
49
|
+
|
|
50
|
+
/** Outcomes that resolve a RUN/WAIT_EVENT node. Undeclared -> recovery_required. */
|
|
51
|
+
const NODE_OUTCOMES = Object.freeze({
|
|
52
|
+
completed: 'completed',
|
|
53
|
+
failed: 'failed',
|
|
54
|
+
cancelled: 'cancelled',
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
export class VmEngineError extends Error {
|
|
58
|
+
constructor(code, message) {
|
|
59
|
+
super(message ?? code);
|
|
60
|
+
this.name = 'VmEngineError';
|
|
61
|
+
this.code = code;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function pathExists(p) {
|
|
66
|
+
try {
|
|
67
|
+
await fs.stat(p);
|
|
68
|
+
return true;
|
|
69
|
+
} catch {
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function writeFileAtomic(target, data) {
|
|
75
|
+
await fs.mkdir(path.dirname(target), { recursive: true });
|
|
76
|
+
const tmp = `${target}.tmp-${process.pid}-${Math.random().toString(36).slice(2)}`;
|
|
77
|
+
const fh = await fs.open(tmp, 'w');
|
|
78
|
+
try {
|
|
79
|
+
await fh.writeFile(data, 'utf8');
|
|
80
|
+
await fh.sync();
|
|
81
|
+
} finally {
|
|
82
|
+
await fh.close();
|
|
83
|
+
}
|
|
84
|
+
await fs.rename(tmp, target);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* @param {{dir:string, now?:()=>Date, classifyFn?:Function, startFn?:Function, hooks?:object}} opts
|
|
89
|
+
*/
|
|
90
|
+
export function createVmEngine({ dir, now, classifyFn, startFn, hooks } = {}) {
|
|
91
|
+
if (typeof dir !== 'string' || dir === '') {
|
|
92
|
+
throw new VmEngineError('malformed_opts', 'dir required');
|
|
93
|
+
}
|
|
94
|
+
const clock = typeof now === 'function' ? now : () => new Date();
|
|
95
|
+
const fire = typeof startFn === 'function' ? startFn : async () => {};
|
|
96
|
+
const instances = new Map(); // planInstanceId -> instance
|
|
97
|
+
let closed = false;
|
|
98
|
+
|
|
99
|
+
const planPath = (pi) => path.join(dir, PLANS_DIR, `${pi}.json`);
|
|
100
|
+
const opId = (pi, nodeId) => `${pi}:${nodeId}`;
|
|
101
|
+
const obs = () => clock().toISOString();
|
|
102
|
+
|
|
103
|
+
function transitionRecord(nodeId, to, seq, extra = {}) {
|
|
104
|
+
return {
|
|
105
|
+
eventId: `vm-${nodeId}-${to}-${seq}-${Math.random().toString(36).slice(2, 10)}`,
|
|
106
|
+
operationId: opId(extra.pi ?? '', nodeId),
|
|
107
|
+
seq,
|
|
108
|
+
eventType: 'operation.transition',
|
|
109
|
+
observedAt: obs(),
|
|
110
|
+
producerVersion: 'ukit-vm',
|
|
111
|
+
contractVersion: CONTRACT_VERSION,
|
|
112
|
+
privacyClass: 'internal',
|
|
113
|
+
artifactRefs: [],
|
|
114
|
+
safePayload: { to, ...(extra.payload ?? {}) },
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function appendTransition(inst, nodeId, to, payload) {
|
|
119
|
+
const seq = ++inst.cursors[nodeId].lastSeq;
|
|
120
|
+
const event = transitionRecord(nodeId, to, seq, { pi: inst.planInstanceId, payload });
|
|
121
|
+
await appendEvent(dir, event, { hooks: { afterAppend: hooks?.afterEventAppend } });
|
|
122
|
+
return event;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function setNodeState(inst, nodeId, next) {
|
|
126
|
+
const prev = inst.nodes[nodeId].state;
|
|
127
|
+
if (prev === next) return;
|
|
128
|
+
const check = validateTransition(prev, next, FENCING);
|
|
129
|
+
if (!check.ok) {
|
|
130
|
+
throw new VmEngineError(check.code, `${nodeId}: ${prev} -> ${next} rejected (${check.code})`);
|
|
131
|
+
}
|
|
132
|
+
inst.nodes[nodeId].state = next;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function moveNode(inst, nodeId, to, payload) {
|
|
136
|
+
setNodeState(inst, nodeId, to);
|
|
137
|
+
await appendTransition(inst, nodeId, to, payload);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function planStatus(inst) {
|
|
141
|
+
const states = Object.values(inst.nodes).map((n) => n.state);
|
|
142
|
+
if (states.some((s) => s === 'recovery_required')) return 'recovery_required';
|
|
143
|
+
if (!states.every((s) => isTerminal(s))) {
|
|
144
|
+
return states.some((s) => s === 'failed') ? 'failed' : 'running';
|
|
145
|
+
}
|
|
146
|
+
if (states.some((s) => s === 'failed')) return 'failed';
|
|
147
|
+
if (states.some((s) => s === 'cancelled')) return 'cancelled';
|
|
148
|
+
return 'completed';
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async function persist(inst) {
|
|
152
|
+
const nodes = {};
|
|
153
|
+
for (const [id, n] of Object.entries(inst.nodes)) {
|
|
154
|
+
nodes[id] = { state: n.state, attempt: n.attempt, lastEventSeq: n.lastEventSeq };
|
|
155
|
+
}
|
|
156
|
+
const cursor = {};
|
|
157
|
+
for (const [id, c] of Object.entries(inst.cursors)) cursor[id] = c.lastSeq;
|
|
158
|
+
// compiled nodes are persisted so a fresh engine (post-crash process)
|
|
159
|
+
// can rehydrate the instance from disk and replay journals — the plan
|
|
160
|
+
// file is still a cache: journal replay remains authoritative.
|
|
161
|
+
const payload = {
|
|
162
|
+
planInstanceId: inst.planInstanceId,
|
|
163
|
+
planVersion: inst.planVersion,
|
|
164
|
+
status: planStatus(inst),
|
|
165
|
+
nodes,
|
|
166
|
+
cursor,
|
|
167
|
+
recoveryReason: inst.recoveryReason ?? null,
|
|
168
|
+
plan: { nodes: Object.fromEntries(inst.nodeMap), entryNodes: inst.entryNodes },
|
|
169
|
+
contractVersion: CONTRACT_VERSION,
|
|
170
|
+
};
|
|
171
|
+
await hooks?.beforePlanWrite?.(inst.planInstanceId);
|
|
172
|
+
await writeFileAtomic(planPath(inst.planInstanceId), `${JSON.stringify(payload)}\n`);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function nodeMap(plan) {
|
|
176
|
+
if (plan?.nodes instanceof Map) return plan.nodes;
|
|
177
|
+
if (plan?.nodes && typeof plan.nodes === 'object') return new Map(Object.entries(plan.nodes));
|
|
178
|
+
return new Map();
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function depsSatisfied(inst, node) {
|
|
182
|
+
return (node.deps ?? []).every((d) => inst.nodes[d]?.state === 'completed');
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async function markRecoveryRequired(inst, nodeId, reason) {
|
|
186
|
+
if (nodeId != null && inst.nodes[nodeId] && !isTerminal(inst.nodes[nodeId].state)) {
|
|
187
|
+
await moveNode(inst, nodeId, 'recovery_required', { reason });
|
|
188
|
+
}
|
|
189
|
+
inst.recoveryReason = reason;
|
|
190
|
+
inst.forcedStatus = 'recovery_required';
|
|
191
|
+
// fail closed: dependents never start
|
|
192
|
+
for (const [id, node] of inst.nodeMap) {
|
|
193
|
+
if (id !== nodeId && inst.nodes[id].state === 'queued'
|
|
194
|
+
&& (node.deps ?? []).includes(nodeId)) {
|
|
195
|
+
inst.nodes[id].state = 'cancelled';
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
async function cancelDependents(inst, nodeId) {
|
|
201
|
+
for (const [id, node] of inst.nodeMap) {
|
|
202
|
+
if (inst.nodes[id].state === 'queued' && (node.deps ?? []).includes(nodeId)) {
|
|
203
|
+
inst.nodes[id].state = 'cancelled';
|
|
204
|
+
await cancelDependents(inst, id);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Activate a queued node whose deps are satisfied. */
|
|
210
|
+
async function activate(inst, nodeId, { suppressStart = false } = {}) {
|
|
211
|
+
const node = inst.nodeMap.get(nodeId);
|
|
212
|
+
if (!node || !STATE_SET.has(inst.nodes[nodeId].state)) return;
|
|
213
|
+
if (inst.nodes[nodeId].state !== 'queued') return;
|
|
214
|
+
|
|
215
|
+
// activation record lands in the journal BEFORE any side effect so that
|
|
216
|
+
// journal replay never fires startFn twice
|
|
217
|
+
inst.nodes[nodeId].attempt += 1;
|
|
218
|
+
setNodeState(inst, nodeId, 'starting');
|
|
219
|
+
await appendTransition(inst, nodeId, 'starting');
|
|
220
|
+
|
|
221
|
+
switch (node.op) {
|
|
222
|
+
case 'RUN': {
|
|
223
|
+
setNodeState(inst, nodeId, 'running');
|
|
224
|
+
await appendTransition(inst, nodeId, 'running');
|
|
225
|
+
if (!suppressStart) {
|
|
226
|
+
await fire(node.spec ?? {}, { planInstanceId: inst.planInstanceId, nodeId, node });
|
|
227
|
+
await hooks?.afterStart?.(nodeId);
|
|
228
|
+
}
|
|
229
|
+
break;
|
|
230
|
+
}
|
|
231
|
+
case 'WAIT_EVENT': {
|
|
232
|
+
setNodeState(inst, nodeId, 'running');
|
|
233
|
+
await appendTransition(inst, nodeId, 'running');
|
|
234
|
+
break;
|
|
235
|
+
}
|
|
236
|
+
case 'BRANCH': {
|
|
237
|
+
setNodeState(inst, nodeId, 'running');
|
|
238
|
+
await appendTransition(inst, nodeId, 'running');
|
|
239
|
+
break;
|
|
240
|
+
}
|
|
241
|
+
case 'COMPLETE': {
|
|
242
|
+
setNodeState(inst, nodeId, 'running');
|
|
243
|
+
await appendTransition(inst, nodeId, 'running');
|
|
244
|
+
await moveNode(inst, nodeId, 'completed');
|
|
245
|
+
await activateDependents(inst, nodeId);
|
|
246
|
+
break;
|
|
247
|
+
}
|
|
248
|
+
case 'ESCALATE': {
|
|
249
|
+
setNodeState(inst, nodeId, 'running');
|
|
250
|
+
await appendTransition(inst, nodeId, 'running');
|
|
251
|
+
await moveNode(inst, nodeId, 'completed', { escalated: true });
|
|
252
|
+
inst.recoveryReason = 'escalated';
|
|
253
|
+
inst.forcedStatus = 'recovery_required';
|
|
254
|
+
break;
|
|
255
|
+
}
|
|
256
|
+
default:
|
|
257
|
+
throw new VmEngineError('unknown_opcode', `${nodeId}: ${node.op}`);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
async function activateDependents(inst, nodeId, { suppressStart = false } = {}) {
|
|
262
|
+
for (const [id, node] of inst.nodeMap) {
|
|
263
|
+
if (inst.nodes[id].state === 'queued' && depsSatisfied(inst, inst.nodeMap.get(id))) {
|
|
264
|
+
await activate(inst, id, { suppressStart });
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function findWaiting(inst, event) {
|
|
270
|
+
const nodeId = event.operationId.slice(inst.planInstanceId.length + 1);
|
|
271
|
+
const node = inst.nodeMap.get(nodeId);
|
|
272
|
+
if (!node) return { nodeId, node: null, matched: false };
|
|
273
|
+
const sel = node.selector;
|
|
274
|
+
const matched = inst.nodes[nodeId]?.state === 'running'
|
|
275
|
+
&& sel && sel.eventType === event.eventType
|
|
276
|
+
&& (!sel.fields || Object.entries(sel.fields).every(([k, v]) => event.safePayload?.[k] === v));
|
|
277
|
+
return { nodeId, node, matched };
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** Apply the deterministic per-node transition table for a consumed event. */
|
|
281
|
+
async function applyOutcome(inst, nodeId, node, outcome, event, { suppressStart = false } = {}) {
|
|
282
|
+
const rec = inst.nodes[nodeId];
|
|
283
|
+
rec.lastEventSeq = event.seq;
|
|
284
|
+
|
|
285
|
+
if (node.op === 'BRANCH') {
|
|
286
|
+
const field = node.selector?.field ?? 'branch';
|
|
287
|
+
const value = event.safePayload?.[field];
|
|
288
|
+
const target = node.branches?.[value];
|
|
289
|
+
if (target == null || !inst.nodeMap.has(target)) {
|
|
290
|
+
await markRecoveryRequired(inst, nodeId, `unknown_branch:${String(value)}`);
|
|
291
|
+
return { node: nodeId, to: 'recovery_required' };
|
|
292
|
+
}
|
|
293
|
+
await moveNode(inst, nodeId, 'completed', { branch: value, target });
|
|
294
|
+
for (const other of Object.values(node.branches ?? {})) {
|
|
295
|
+
if (other !== target && inst.nodes[other]?.state === 'queued') {
|
|
296
|
+
inst.nodes[other].state = 'cancelled';
|
|
297
|
+
await cancelDependents(inst, other);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
await activate(inst, target, { suppressStart });
|
|
301
|
+
return { node: nodeId, to: 'completed', branch: value };
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// RUN / WAIT_EVENT
|
|
305
|
+
const to = NODE_OUTCOMES[outcome];
|
|
306
|
+
if (to == null) {
|
|
307
|
+
await markRecoveryRequired(inst, nodeId, `undeclared_outcome:${String(outcome)}`);
|
|
308
|
+
return { node: nodeId, to: 'recovery_required' };
|
|
309
|
+
}
|
|
310
|
+
if (to === 'cancelled') {
|
|
311
|
+
// node can't self-transition to cancelled from running per G1 table;
|
|
312
|
+
// route through cancel_pending
|
|
313
|
+
setNodeState(inst, nodeId, 'cancel_pending');
|
|
314
|
+
await appendTransition(inst, nodeId, 'cancel_pending');
|
|
315
|
+
await moveNode(inst, nodeId, 'cancelled');
|
|
316
|
+
await cancelDependents(inst, nodeId);
|
|
317
|
+
return { node: nodeId, to: 'cancelled' };
|
|
318
|
+
}
|
|
319
|
+
if (to === 'completed') {
|
|
320
|
+
await moveNode(inst, nodeId, 'completed');
|
|
321
|
+
await activateDependents(inst, nodeId, { suppressStart });
|
|
322
|
+
} else {
|
|
323
|
+
// 'failed': bounded auto-retry only for AUTO_RETRYABLE classes per
|
|
324
|
+
// validateRetry — the retry edge is running->retry_pending->running,
|
|
325
|
+
// so it must be attempted BEFORE the terminal 'failed' transition
|
|
326
|
+
const cls = node.retry?.sideEffectClass ?? 'write';
|
|
327
|
+
const policy = { maxAttempts: node.retry?.maxAttempts ?? 1 };
|
|
328
|
+
const retry = validateRetry(cls, rec.attempt + 1, policy);
|
|
329
|
+
if (retry.ok) {
|
|
330
|
+
setNodeState(inst, nodeId, 'retry_pending');
|
|
331
|
+
await appendTransition(inst, nodeId, 'retry_pending');
|
|
332
|
+
setNodeState(inst, nodeId, 'running');
|
|
333
|
+
await appendTransition(inst, nodeId, 'running');
|
|
334
|
+
rec.attempt += 1;
|
|
335
|
+
if (!suppressStart) await fire(node.spec ?? {}, { planInstanceId: inst.planInstanceId, nodeId, node });
|
|
336
|
+
return { node: nodeId, to: 'running' };
|
|
337
|
+
}
|
|
338
|
+
await moveNode(inst, nodeId, 'failed', { reason: retry.code });
|
|
339
|
+
inst.recoveryReason = inst.recoveryReason ?? `node_failed:${nodeId}:${retry.code}`;
|
|
340
|
+
}
|
|
341
|
+
return { node: nodeId, to };
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
async function escalateUnclassified(inst, event) {
|
|
345
|
+
inst.recoveryReason = `unclassifiable:${event.eventId}`;
|
|
346
|
+
inst.forcedStatus = 'recovery_required';
|
|
347
|
+
// terminal-record in the plan's own operation journal space: mark every
|
|
348
|
+
// still-waiting node recovery_required so resume folds identically
|
|
349
|
+
for (const [id] of inst.nodeMap) {
|
|
350
|
+
if (!isTerminal(inst.nodes[id].state) && inst.nodes[id].state !== 'queued') {
|
|
351
|
+
await moveNode(inst, id, 'recovery_required', { reason: 'unclassifiable_event' });
|
|
352
|
+
break;
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
await persist(inst);
|
|
356
|
+
return { consumed: false, code: 'escalated' };
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// ----------------------------------------------------------------- API
|
|
360
|
+
|
|
361
|
+
async function start(plan, opts = {}) {
|
|
362
|
+
if (closed) throw new VmEngineError('engine_closed', 'close() called');
|
|
363
|
+
const nodes = nodeMap(plan);
|
|
364
|
+
if (nodes.size === 0 || !Array.isArray(plan?.entryNodes) || plan.entryNodes.length === 0) {
|
|
365
|
+
return { unsupported: true, code: 'malformed_plan' };
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
const planInstanceId = `pi-${clock().getTime().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
|
|
369
|
+
const inst = {
|
|
370
|
+
planInstanceId,
|
|
371
|
+
planVersion: plan.planVersion,
|
|
372
|
+
nodeMap: nodes,
|
|
373
|
+
entryNodes: plan.entryNodes,
|
|
374
|
+
nodes: {},
|
|
375
|
+
cursors: {},
|
|
376
|
+
continuations: {},
|
|
377
|
+
recoveryReason: null,
|
|
378
|
+
forcedStatus: null,
|
|
379
|
+
};
|
|
380
|
+
for (const [id] of nodes) {
|
|
381
|
+
inst.nodes[id] = { state: 'queued', attempt: 0, lastEventSeq: 0 };
|
|
382
|
+
inst.cursors[id] = { lastSeq: 0, seenEventIds: new Set() };
|
|
383
|
+
}
|
|
384
|
+
instances.set(planInstanceId, inst);
|
|
385
|
+
|
|
386
|
+
// 1) cursor + continuations registered BEFORE any node activation
|
|
387
|
+
for (const [id, node] of nodes) {
|
|
388
|
+
if (node.selector) {
|
|
389
|
+
const rec = await registerContinuation(dir, {
|
|
390
|
+
continuationId: `cont-${planInstanceId}-${id}`,
|
|
391
|
+
planVersion: plan.planVersion,
|
|
392
|
+
nodeId: id,
|
|
393
|
+
operationId: opId(planInstanceId, id),
|
|
394
|
+
afterSeq: 0,
|
|
395
|
+
selector: node.selector,
|
|
396
|
+
consumptionKey: `${planInstanceId}:${id}`,
|
|
397
|
+
});
|
|
398
|
+
inst.continuations[id] = rec.continuationId;
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
await persist(inst);
|
|
402
|
+
|
|
403
|
+
// 2) activate entry nodes (first startFn call happens only now)
|
|
404
|
+
for (const id of plan.entryNodes) {
|
|
405
|
+
await activate(inst, id);
|
|
406
|
+
}
|
|
407
|
+
await persist(inst);
|
|
408
|
+
return { planInstanceId };
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* Rehydrate an instance from the persisted plan file after the owning
|
|
413
|
+
* process dropped (real crash). The file seeds nodeMap/continuations/
|
|
414
|
+
* bookkeeping only — journal replay in `resume` stays authoritative.
|
|
415
|
+
*/
|
|
416
|
+
async function ensureInst(planInstanceId) {
|
|
417
|
+
let inst = instances.get(planInstanceId);
|
|
418
|
+
if (inst) return inst;
|
|
419
|
+
const file = planPath(planInstanceId);
|
|
420
|
+
if (!(await pathExists(file))) return null;
|
|
421
|
+
let raw;
|
|
422
|
+
try {
|
|
423
|
+
raw = JSON.parse(await fs.readFile(file, 'utf8'));
|
|
424
|
+
} catch {
|
|
425
|
+
// corrupt plan file — rehydration impossible; deliver degrades to
|
|
426
|
+
// 'unroutable' and resume/state surface 'unknown_plan'
|
|
427
|
+
return null;
|
|
428
|
+
}
|
|
429
|
+
const nodeMap = new Map(Object.entries(raw.plan?.nodes ?? {}));
|
|
430
|
+
if (nodeMap.size === 0) return null;
|
|
431
|
+
inst = {
|
|
432
|
+
planInstanceId,
|
|
433
|
+
planVersion: raw.planVersion,
|
|
434
|
+
nodeMap,
|
|
435
|
+
entryNodes: raw.plan?.entryNodes ?? [],
|
|
436
|
+
nodes: {},
|
|
437
|
+
cursors: {},
|
|
438
|
+
continuations: {},
|
|
439
|
+
recoveryReason: raw.recoveryReason ?? null,
|
|
440
|
+
forcedStatus: null,
|
|
441
|
+
};
|
|
442
|
+
for (const [id] of nodeMap) {
|
|
443
|
+
const seed = raw.nodes?.[id] ?? {};
|
|
444
|
+
inst.nodes[id] = {
|
|
445
|
+
state: seed.state ?? 'queued',
|
|
446
|
+
attempt: seed.attempt ?? 0,
|
|
447
|
+
lastEventSeq: seed.lastEventSeq ?? 0,
|
|
448
|
+
};
|
|
449
|
+
inst.cursors[id] = { lastSeq: 0, seenEventIds: new Set() };
|
|
450
|
+
}
|
|
451
|
+
// continuation registration survived the crash on disk — recover the ids
|
|
452
|
+
try {
|
|
453
|
+
const lines = (await fs.readFile(path.join(dir, 'continuations.jsonl'), 'utf8'))
|
|
454
|
+
.split('\n').filter(Boolean);
|
|
455
|
+
for (const line of lines) {
|
|
456
|
+
let c;
|
|
457
|
+
// one torn line must not drop every recovered continuation
|
|
458
|
+
try { c = JSON.parse(line); } catch { continue; }
|
|
459
|
+
const nid = c.operationId?.startsWith(`${planInstanceId}:`)
|
|
460
|
+
? c.operationId.slice(planInstanceId.length + 1) : null;
|
|
461
|
+
if (nid && inst.nodeMap.has(nid)) inst.continuations[nid] = c.continuationId;
|
|
462
|
+
}
|
|
463
|
+
} catch { /* no continuations file — nothing registered yet */ }
|
|
464
|
+
instances.set(planInstanceId, inst);
|
|
465
|
+
return inst;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
async function deliver(event) {
|
|
469
|
+
if (closed) throw new VmEngineError('engine_closed', 'close() called');
|
|
470
|
+
const shape = validateSemanticEvent(event);
|
|
471
|
+
if (!shape.ok) return { consumed: false, code: shape.code };
|
|
472
|
+
|
|
473
|
+
const sep = event.operationId.indexOf(':');
|
|
474
|
+
const pi = sep > 0 ? event.operationId.slice(0, sep) : null;
|
|
475
|
+
const inst = pi ? await ensureInst(pi) : null;
|
|
476
|
+
if (!inst) return { consumed: false, code: 'unroutable' };
|
|
477
|
+
const { nodeId, node, matched } = findWaiting(inst, event);
|
|
478
|
+
const cursor = inst.cursors[nodeId] ?? { lastSeq: 0, seenEventIds: new Set() };
|
|
479
|
+
|
|
480
|
+
// ordering: known eventId -> duplicate; seq <= cursor -> out_of_order;
|
|
481
|
+
// anything else is checked for causality vs the journal on append
|
|
482
|
+
if (cursor.seenEventIds.has(event.eventId)) {
|
|
483
|
+
return { consumed: false, code: 'duplicate' };
|
|
484
|
+
}
|
|
485
|
+
if (event.seq <= cursor.lastSeq) {
|
|
486
|
+
return { consumed: false, code: 'out_of_order' };
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
// classify only when the IR itself cannot route the event (G4-FR05)
|
|
490
|
+
let outcome = event.safePayload?.outcome;
|
|
491
|
+
let targetMatched = matched;
|
|
492
|
+
if (!targetMatched) {
|
|
493
|
+
if (typeof classifyFn !== 'function') {
|
|
494
|
+
return escalateUnclassified(inst, event);
|
|
495
|
+
}
|
|
496
|
+
const verdict = await classifyFn(event, { planInstanceId: pi, nodeId, node });
|
|
497
|
+
if (verdict?.action === 'escalate' || verdict == null) {
|
|
498
|
+
return escalateUnclassified(inst, event);
|
|
499
|
+
}
|
|
500
|
+
if (verdict?.action === 'route' && typeof verdict.outcome === 'string') {
|
|
501
|
+
outcome = verdict.outcome;
|
|
502
|
+
targetMatched = node != null && inst.nodes[nodeId]?.state === 'running';
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
if (!targetMatched) return escalateUnclassified(inst, event);
|
|
506
|
+
|
|
507
|
+
// journal the delivered event BEFORE consumption record: a crash between
|
|
508
|
+
// the two leaves a journal event replay will re-apply idempotently;
|
|
509
|
+
// a second delivery of the same eventId is still a consume no-op
|
|
510
|
+
try {
|
|
511
|
+
await appendEvent(dir, event, { hooks: { afterAppend: hooks?.afterEventAppend } });
|
|
512
|
+
} catch (err) {
|
|
513
|
+
if (err?.code === 'out_of_order') return { consumed: false, code: 'out_of_order' };
|
|
514
|
+
throw err;
|
|
515
|
+
}
|
|
516
|
+
cursor.lastSeq = event.seq;
|
|
517
|
+
cursor.seenEventIds.add(event.eventId);
|
|
518
|
+
|
|
519
|
+
const cont = await consumeContinuation(dir, inst.continuations[nodeId], event);
|
|
520
|
+
if (!cont.consumed && cont.duplicate) {
|
|
521
|
+
return { consumed: false, code: 'duplicate' };
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
const transitions = [await applyOutcome(inst, nodeId, node, outcome, event)];
|
|
525
|
+
await persist(inst);
|
|
526
|
+
return { consumed: true, transitions };
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
/**
|
|
530
|
+
* Rebuild instance state by replaying journals (journal is truth). Already-
|
|
531
|
+
* journaled activations suppress startFn; non-idempotent nodes caught in a
|
|
532
|
+
* crash window resolve to recovery_required — never auto-replayed.
|
|
533
|
+
*/
|
|
534
|
+
async function resume(planInstanceId) {
|
|
535
|
+
const inst = await ensureInst(planInstanceId);
|
|
536
|
+
if (!inst) throw new VmEngineError('unknown_plan', planInstanceId);
|
|
537
|
+
const report = { planInstanceId, replayedEvents: 0, recovered: [], recoveryRequired: [] };
|
|
538
|
+
|
|
539
|
+
for (const [nodeId, node] of inst.nodeMap) {
|
|
540
|
+
const cursor = inst.cursors[nodeId];
|
|
541
|
+
let derived = null;
|
|
542
|
+
let delivered = [];
|
|
543
|
+
for await (const ev of readJournal(dir, opId(planInstanceId, nodeId))) {
|
|
544
|
+
report.replayedEvents += 1;
|
|
545
|
+
cursor.lastSeq = ev.seq;
|
|
546
|
+
cursor.seenEventIds.add(ev.eventId);
|
|
547
|
+
if (ev.eventType === 'operation.transition' && ev.safePayload?.to) {
|
|
548
|
+
derived = ev.safePayload.to;
|
|
549
|
+
} else {
|
|
550
|
+
delivered.push(ev);
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
if (derived != null) inst.nodes[nodeId].state = derived;
|
|
554
|
+
|
|
555
|
+
const state = inst.nodes[nodeId].state;
|
|
556
|
+
if (isTerminal(state)) continue;
|
|
557
|
+
|
|
558
|
+
// crash window: running/starting node with non-idempotent side effects
|
|
559
|
+
// cannot prove whether its side effect landed
|
|
560
|
+
const cls = node.retry?.sideEffectClass ?? 'write';
|
|
561
|
+
const retryable = validateRetry(cls, 2, { maxAttempts: node.retry?.maxAttempts ?? 1 }).ok;
|
|
562
|
+
if (!retryable && (state === 'starting' || state === 'running') && delivered.length === 0 && node.op === 'RUN') {
|
|
563
|
+
// only ambiguous if its activation record exists but no outcome yet —
|
|
564
|
+
// that is exactly "side effect may have fired"
|
|
565
|
+
await markRecoveryRequired(inst, nodeId, `crash_window:${cls}`);
|
|
566
|
+
report.recoveryRequired.push(nodeId);
|
|
567
|
+
continue;
|
|
568
|
+
}
|
|
569
|
+
// re-apply pending delivered events not yet folded into a transition
|
|
570
|
+
for (const ev of delivered) {
|
|
571
|
+
const outcome = ev.safePayload?.outcome;
|
|
572
|
+
await applyOutcome(inst, nodeId, node, outcome, ev, { suppressStart: true });
|
|
573
|
+
report.recovered.push(nodeId);
|
|
574
|
+
}
|
|
575
|
+
if (inst.nodes[nodeId].state === 'queued' && depsSatisfied(inst, node)) {
|
|
576
|
+
await activate(inst, nodeId, { suppressStart: true });
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
await persist(inst);
|
|
580
|
+
report.status = inst.forcedStatus ?? planStatus(inst);
|
|
581
|
+
return report;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
async function state(planInstanceId) {
|
|
585
|
+
const inst = instances.get(planInstanceId);
|
|
586
|
+
if (!inst) {
|
|
587
|
+
const file = planPath(planInstanceId);
|
|
588
|
+
if (await pathExists(file)) {
|
|
589
|
+
try {
|
|
590
|
+
const raw = JSON.parse(await fs.readFile(file, 'utf8'));
|
|
591
|
+
if (raw !== null && typeof raw === 'object' && !Array.isArray(raw)) return raw;
|
|
592
|
+
} catch { /* corrupt plan file — same surface as missing file */ }
|
|
593
|
+
}
|
|
594
|
+
throw new VmEngineError('unknown_plan', planInstanceId);
|
|
595
|
+
}
|
|
596
|
+
const nodes = {};
|
|
597
|
+
for (const [id, n] of Object.entries(inst.nodes)) {
|
|
598
|
+
nodes[id] = { state: n.state, attempt: n.attempt, lastEventSeq: n.lastEventSeq };
|
|
599
|
+
}
|
|
600
|
+
const cursor = {};
|
|
601
|
+
for (const [id, c] of Object.entries(inst.cursors)) cursor[id] = c.lastSeq;
|
|
602
|
+
const status = inst.forcedStatus ?? planStatus(inst);
|
|
603
|
+
return {
|
|
604
|
+
planInstanceId,
|
|
605
|
+
planVersion: inst.planVersion,
|
|
606
|
+
status,
|
|
607
|
+
nodes,
|
|
608
|
+
cursor,
|
|
609
|
+
...(inst.recoveryReason ? { recoveryReason: inst.recoveryReason } : {}),
|
|
610
|
+
};
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
async function close() {
|
|
614
|
+
closed = true;
|
|
615
|
+
for (const inst of instances.values()) {
|
|
616
|
+
await persist(inst);
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
return { start, deliver, resume, state, close };
|
|
621
|
+
}
|
|
@@ -99,10 +99,11 @@ async function loadProcedureCandidates(projectRoot, config) {
|
|
|
99
99
|
return { records: null, why: 'memory-unavailable' };
|
|
100
100
|
}
|
|
101
101
|
const loaded = await loader.loadV2Records(projectRoot, { type: 'procedure' });
|
|
102
|
-
|
|
102
|
+
const loadedRecords = Array.isArray(loaded?.records) ? loaded.records : [];
|
|
103
|
+
if (loaded?.state !== 'ok' || loadedRecords.length === 0) {
|
|
103
104
|
return { records: [], why: 'memory-unavailable' };
|
|
104
105
|
}
|
|
105
|
-
return { records:
|
|
106
|
+
return { records: loadedRecords.filter((r) => records.isRecordUsable(r)), why: null };
|
|
106
107
|
} catch {
|
|
107
108
|
return { records: null, why: 'memory-unavailable' };
|
|
108
109
|
}
|
|
@@ -412,7 +412,14 @@ export function runProgram({
|
|
|
412
412
|
while (!done && blockedReason === null) {
|
|
413
413
|
attempt += 1;
|
|
414
414
|
const strategy = strategies.length > 0 ? strategies[attempt - 1] : undefined;
|
|
415
|
-
|
|
415
|
+
// A throwing runner degrades to a failure result — it must never escape
|
|
416
|
+
// runProgram as an exception (degrade contract, SPEC §FR-008).
|
|
417
|
+
let result;
|
|
418
|
+
try {
|
|
419
|
+
result = runner({ task, attempt, strategy, state }) ?? {};
|
|
420
|
+
} catch (err) {
|
|
421
|
+
result = { status: 'runner-threw', failure: String(err?.message ?? err) };
|
|
422
|
+
}
|
|
416
423
|
state.budget.consumed += 1;
|
|
417
424
|
if (completion(result)) {
|
|
418
425
|
done = true;
|
|
@@ -482,7 +489,15 @@ export function runProgram({
|
|
|
482
489
|
|
|
483
490
|
state.workflow.phase = 'blocked';
|
|
484
491
|
if (typeof fallback === 'function') {
|
|
485
|
-
|
|
492
|
+
// A throwing fallback is still a degrade contract — record the throw as a
|
|
493
|
+
// blocker and report 'blocked', never let the exception escape (FR-008).
|
|
494
|
+
let fallbackResult = null;
|
|
495
|
+
try {
|
|
496
|
+
fallbackResult = fallback({ state, blockers }) ?? {};
|
|
497
|
+
} catch {
|
|
498
|
+
blockers.push({ taskId: null, reason: 'fallback-threw' });
|
|
499
|
+
return { status: 'blocked', ...result, blockers };
|
|
500
|
+
}
|
|
486
501
|
if (fallbackResult.status === 'ok') {
|
|
487
502
|
return { status: 'degraded', fallback: fallbackResult, ...result };
|
|
488
503
|
}
|