@ngockhoale/ukit 3.1.6 → 3.1.7
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 +4 -0
- package/package.json +1 -1
- package/src/core/agentRuntime/planCompiler.js +93 -3
- package/src/core/agentRuntime/planLibrary.js +73 -0
- package/src/core/agentRuntime/plans/bugfix-loop.json +107 -0
- package/src/core/agentRuntime/plans/handoff-review-batch.json +116 -0
- package/src/core/agentRuntime/plans/release-check.json +113 -0
- package/src/core/agentRuntime/vmEngine.js +239 -3
- package/src/core/runtimeConfig.js +2 -2
- package/template_project/ukit/storage/config.json +6 -3
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to UKit are documented here.
|
|
4
4
|
|
|
5
|
+
## 3.1.7 - 2026-09-26
|
|
6
|
+
|
|
7
|
+
- **Observability + memory recording enabled by default on install.** Shipped `config.json` now sets `observability.stage: 'default'` (recorder + analytics + projector + evaluation — the full Data Foundation pipeline) and `learning.episodes.autoWrite: true` + `learning.candidates.stage: 'shadow'` (ukit-memory records evidence without auto-applying anything). Privacy gates (`sanitizeObserved`, `sanitizeForSupport` default-deny) are unchanged. Code defaults in `runtimeConfig.js` updated to match. New: `planLibrary.js` + 3 versioned reference plans (`bugfix-loop`, `handoff-review-batch`, `release-check`) as the V4 artifact seed for the Agent VM phase-2 plan.
|
|
8
|
+
|
|
5
9
|
## 3.1.6 - 2026-09-26
|
|
6
10
|
|
|
7
11
|
- **Decision endpoint URL normalization**: a `UKIT_DECISION_BASE_URL` that already ends in `/v1` (OpenAI convention) no longer produces `…/v1/v1/chat/completions`; the client strips a trailing `/v1` before joining. Applied to `src/decision/client.js` and mirrored in `unic-decision.mjs`.
|
package/package.json
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* agentRuntime/planCompiler.js — decision-first-runtime G4 (DR-06), IR v1.
|
|
2
|
+
* agentRuntime/planCompiler.js — decision-first-runtime G4 (DR-06), IR v1+v2.
|
|
3
3
|
*
|
|
4
4
|
* Pure plan compiler: validates a small DAG IR (frozen opcode set: RUN,
|
|
5
|
-
* WAIT_EVENT, BRANCH, COMPLETE, ESCALATE
|
|
5
|
+
* WAIT_EVENT, BRANCH, COMPLETE, ESCALATE — plus IR v2 wrappers PARALLEL
|
|
6
|
+
* and TIMEOUT) and emits an immutable
|
|
6
7
|
* ValidatedPlan { planVersion, nodes, entryNodes, order }.
|
|
7
8
|
*
|
|
8
9
|
* No I/O, no runtimeConfig reads, no imports of vmEngine. The only
|
|
@@ -21,6 +22,8 @@ export const PLAN_OPS = Object.freeze([
|
|
|
21
22
|
'BRANCH',
|
|
22
23
|
'COMPLETE',
|
|
23
24
|
'ESCALATE',
|
|
25
|
+
'PARALLEL',
|
|
26
|
+
'TIMEOUT',
|
|
24
27
|
]);
|
|
25
28
|
|
|
26
29
|
const OP_SET = new Set(PLAN_OPS);
|
|
@@ -34,6 +37,10 @@ const DEFAULT_MAX_ATTEMPTS = 1;
|
|
|
34
37
|
const MAX_ATTEMPTS_CEILING = 3;
|
|
35
38
|
const SELECTOR_OPS = new Set(['eq', 'neq', 'in', 'exists']);
|
|
36
39
|
const PATH_TRAVERSAL = /(^|\/)\.\.(\/|$)/;
|
|
40
|
+
/** Ceiling for TIMEOUT.deadlineMs — bounded deadlines only (IR v2). */
|
|
41
|
+
const MAX_DEADLINE_MS = 1_800_000;
|
|
42
|
+
const TIMEOUT_POLICIES = new Set(['fail', 'escalate']);
|
|
43
|
+
const WRAPPABLE_OPS = new Set(['RUN', 'WAIT_EVENT']);
|
|
37
44
|
|
|
38
45
|
function err(errors, code, nodeId, detail) {
|
|
39
46
|
errors.push({ code, nodeId, detail });
|
|
@@ -254,6 +261,54 @@ export function compilePlan(ir, opts = {}) {
|
|
|
254
261
|
}
|
|
255
262
|
}
|
|
256
263
|
|
|
264
|
+
let children;
|
|
265
|
+
if (op === 'PARALLEL') {
|
|
266
|
+
children = node.children;
|
|
267
|
+
if (!Array.isArray(children) || children.length === 0) {
|
|
268
|
+
err(errors, 'invalid_children', nodeId, 'PARALLEL requires a non-empty children array');
|
|
269
|
+
} else if (children.some((c) => !isNonEmptyString(c)) || new Set(children).size !== children.length) {
|
|
270
|
+
err(errors, 'invalid_children', nodeId, 'PARALLEL children must be unique node ids');
|
|
271
|
+
} else {
|
|
272
|
+
for (const child of children) {
|
|
273
|
+
if (!ids.has(child)) {
|
|
274
|
+
err(errors, 'missing_ref', nodeId, `child ${JSON.stringify(child)} not in plan`);
|
|
275
|
+
}
|
|
276
|
+
if (child === nodeId) {
|
|
277
|
+
err(errors, 'cycle', nodeId, 'PARALLEL self-reference');
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
const join = node.join === undefined ? 'all' : node.join;
|
|
282
|
+
if (join !== 'all') {
|
|
283
|
+
err(errors, 'invalid_children', nodeId, `join ${JSON.stringify(node.join)} unsupported (only 'all')`);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
let wrappedId;
|
|
288
|
+
if (op === 'TIMEOUT') {
|
|
289
|
+
wrappedId = node.node;
|
|
290
|
+
if (!isNonEmptyString(wrappedId)) {
|
|
291
|
+
err(errors, 'missing_ref', nodeId, 'TIMEOUT requires node: <nodeId>');
|
|
292
|
+
} else {
|
|
293
|
+
if (!ids.has(wrappedId)) {
|
|
294
|
+
err(errors, 'missing_ref', nodeId, `timeout target ${JSON.stringify(wrappedId)} not in plan`);
|
|
295
|
+
}
|
|
296
|
+
if (wrappedId === nodeId) {
|
|
297
|
+
err(errors, 'cycle', nodeId, 'TIMEOUT self-reference');
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
const deadline = node.deadlineMs;
|
|
301
|
+
if (!Number.isInteger(deadline) || deadline <= 0 || deadline > MAX_DEADLINE_MS) {
|
|
302
|
+
err(errors, 'unbounded_timeout', nodeId,
|
|
303
|
+
`deadlineMs must be an integer in 1..${MAX_DEADLINE_MS} (MAX_DEADLINE_MS)`);
|
|
304
|
+
}
|
|
305
|
+
const onTimeout = node.onTimeout === undefined ? 'fail' : node.onTimeout;
|
|
306
|
+
if (!TIMEOUT_POLICIES.has(onTimeout)) {
|
|
307
|
+
err(errors, 'invalid_timeout', nodeId, `onTimeout ${JSON.stringify(node.onTimeout)} unsupported`);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
|
|
257
312
|
const retry = validateRetryPolicy(node.retry, nodeId, declaredEffects, errors);
|
|
258
313
|
|
|
259
314
|
const compiledNode = {
|
|
@@ -264,6 +319,11 @@ export function compilePlan(ir, opts = {}) {
|
|
|
264
319
|
...(node.selector !== undefined ? { selector: deepFreeze({ ...node.selector }) } : {}),
|
|
265
320
|
...(node.timeoutMs !== undefined ? { timeoutMs: node.timeoutMs } : {}),
|
|
266
321
|
...(node.spec !== undefined ? { spec: deepFreeze({ ...node.spec }) } : {}),
|
|
322
|
+
...(Array.isArray(children) ? { children: deepFreeze([...children]) } : {}),
|
|
323
|
+
...(wrappedId !== undefined ? { node: wrappedId } : {}),
|
|
324
|
+
...(node.deadlineMs !== undefined ? { deadlineMs: node.deadlineMs } : {}),
|
|
325
|
+
...(op === 'TIMEOUT' ? { onTimeout: node.onTimeout === undefined ? 'fail' : node.onTimeout } : {}),
|
|
326
|
+
...(op === 'PARALLEL' ? { join: node.join === undefined ? 'all' : node.join } : {}),
|
|
267
327
|
...(branches !== undefined ? { branches: deepFreeze({ ...branches }) } : {}),
|
|
268
328
|
};
|
|
269
329
|
compiled.set(nodeId, compiledNode);
|
|
@@ -281,6 +341,30 @@ export function compilePlan(ir, opts = {}) {
|
|
|
281
341
|
}
|
|
282
342
|
}
|
|
283
343
|
|
|
344
|
+
// IR v2 wrapper containment (checked after all nodes are compiled):
|
|
345
|
+
// every PARALLEL child / TIMEOUT wrapped node is a RUN/WAIT_EVENT that
|
|
346
|
+
// declares exactly the wrapper as its dep — it can never be an entry
|
|
347
|
+
// node, never activate outside its wrapper, and the deps edge is what
|
|
348
|
+
// keeps the wrapper before it in `order`.
|
|
349
|
+
for (const node of compiled.values()) {
|
|
350
|
+
let ids = null;
|
|
351
|
+
if (node.op === 'PARALLEL') ids = node.children;
|
|
352
|
+
if (node.op === 'TIMEOUT' && isNonEmptyString(node.node)) ids = [node.node];
|
|
353
|
+
if (!ids) continue;
|
|
354
|
+
for (const childId of ids) {
|
|
355
|
+
const child = compiled.get(childId);
|
|
356
|
+
if (!child) continue; // missing_ref already reported
|
|
357
|
+
if (!WRAPPABLE_OPS.has(child.op)) {
|
|
358
|
+
err(errors, 'invalid_children', node.id,
|
|
359
|
+
`${node.op} may only wrap RUN/WAIT_EVENT nodes; ${childId} is ${child.op}`);
|
|
360
|
+
}
|
|
361
|
+
if (!(child.deps.length === 1 && child.deps[0] === node.id)) {
|
|
362
|
+
err(errors, 'missing_ref', childId,
|
|
363
|
+
`wrapped node must declare exactly deps:['${node.id}']`);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
284
368
|
const order = [];
|
|
285
369
|
const queue = rawNodes
|
|
286
370
|
.map((n) => n?.id)
|
|
@@ -298,12 +382,18 @@ export function compilePlan(ir, opts = {}) {
|
|
|
298
382
|
err(errors, 'cycle', undefined, 'dependency cycle detected');
|
|
299
383
|
}
|
|
300
384
|
|
|
385
|
+
// v1 plans hash exactly as before; IR v2 usage switches the canonical
|
|
386
|
+
// input to a 'v2:'-prefixed serialization so v2 planVersions can never
|
|
387
|
+
// collide with (or silently change) v1 hashes.
|
|
388
|
+
const usesV2 = [...compiled.values()].some((n) => n.op === 'PARALLEL' || n.op === 'TIMEOUT');
|
|
389
|
+
|
|
301
390
|
if (errors.length > 0) {
|
|
302
391
|
return { ok: false, errors };
|
|
303
392
|
}
|
|
304
393
|
|
|
394
|
+
const canonical = canonicalize(order.map((id) => compiled.get(id)));
|
|
305
395
|
const planVersion = createHash('sha256')
|
|
306
|
-
.update(
|
|
396
|
+
.update(usesV2 ? `v2:${canonical}` : canonical, 'utf8')
|
|
307
397
|
.digest('hex')
|
|
308
398
|
.slice(0, 16);
|
|
309
399
|
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* agentRuntime/planLibrary.js — V4 (phase-2 plan): versioned reference-plan
|
|
3
|
+
* artifacts.
|
|
4
|
+
*
|
|
5
|
+
* Loads the IR documents under `src/core/agentRuntime/plans/<planId>.json`,
|
|
6
|
+
* compiles each through `compilePlan`, and returns an immutable catalog:
|
|
7
|
+
* { planId -> { planId, description, irHash, plan } }. `irHash` is a sha256
|
|
8
|
+
* over the canonical IR bytes — changing a plan bumps its version artifact
|
|
9
|
+
* (the compiled `planVersion` is also content-derived inside compilePlan).
|
|
10
|
+
*
|
|
11
|
+
* Read-only: no writes, no host calls. Compile errors surface as
|
|
12
|
+
* { ok:false, errors } entries — they never throw so a bad draft plan cannot
|
|
13
|
+
* break the caller.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { createHash } from 'node:crypto';
|
|
17
|
+
import fs from 'node:fs';
|
|
18
|
+
import path from 'node:path';
|
|
19
|
+
import { fileURLToPath } from 'node:url';
|
|
20
|
+
|
|
21
|
+
import { compilePlan } from './planCompiler.js';
|
|
22
|
+
|
|
23
|
+
const PLANS_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), 'plans');
|
|
24
|
+
|
|
25
|
+
export function listPlanIds({ plansDir = PLANS_DIR } = {}) {
|
|
26
|
+
let entries;
|
|
27
|
+
try {
|
|
28
|
+
entries = fs.readdirSync(plansDir, { withFileTypes: true });
|
|
29
|
+
} catch {
|
|
30
|
+
return [];
|
|
31
|
+
}
|
|
32
|
+
return entries
|
|
33
|
+
.filter((e) => e.isFile() && e.name.endsWith('.json'))
|
|
34
|
+
.map((e) => e.name.slice(0, -'.json'.length))
|
|
35
|
+
.sort();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function loadPlan(planId, { plansDir = PLANS_DIR } = {}) {
|
|
39
|
+
if (typeof planId !== 'string' || !/^[a-z0-9][a-z0-9-]*$/.test(planId)) {
|
|
40
|
+
return { ok: false, errors: [{ code: 'invalid_plan_id', planId }] };
|
|
41
|
+
}
|
|
42
|
+
const file = path.join(plansDir, `${planId}.json`);
|
|
43
|
+
let raw;
|
|
44
|
+
try {
|
|
45
|
+
raw = fs.readFileSync(file, 'utf8');
|
|
46
|
+
} catch {
|
|
47
|
+
return { ok: false, errors: [{ code: 'plan_not_found', planId }] };
|
|
48
|
+
}
|
|
49
|
+
let ir;
|
|
50
|
+
try {
|
|
51
|
+
ir = JSON.parse(raw);
|
|
52
|
+
} catch {
|
|
53
|
+
return { ok: false, errors: [{ code: 'plan_malformed', planId }] };
|
|
54
|
+
}
|
|
55
|
+
const result = compilePlan(ir);
|
|
56
|
+
if (!result.ok) return result;
|
|
57
|
+
const irHash = createHash('sha256').update(raw, 'utf8').digest('hex').slice(0, 16);
|
|
58
|
+
return {
|
|
59
|
+
ok: true,
|
|
60
|
+
planId,
|
|
61
|
+
description: typeof ir?.description === 'string' ? ir.description : null,
|
|
62
|
+
irHash,
|
|
63
|
+
plan: result.plan,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function loadPlanLibrary({ plansDir = PLANS_DIR } = {}) {
|
|
68
|
+
const catalog = {};
|
|
69
|
+
for (const planId of listPlanIds({ plansDir })) {
|
|
70
|
+
catalog[planId] = loadPlan(planId, { plansDir });
|
|
71
|
+
}
|
|
72
|
+
return Object.freeze(catalog);
|
|
73
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
{
|
|
2
|
+
"planId": "bugfix-loop",
|
|
3
|
+
"description": "Reproduce a failing test, locate the cause via index, patch the smallest surface, re-run the test to green, then summarize the fix. Reference plan for the language-compiled runtime (V4 seed).",
|
|
4
|
+
"nodes": [
|
|
5
|
+
{
|
|
6
|
+
"id": "reproduce",
|
|
7
|
+
"op": "RUN",
|
|
8
|
+
"deps": [],
|
|
9
|
+
"timeoutMs": 300000,
|
|
10
|
+
"retry": {
|
|
11
|
+
"sideEffectClass": "read_only",
|
|
12
|
+
"maxAttempts": 1,
|
|
13
|
+
"backoffMs": 0
|
|
14
|
+
},
|
|
15
|
+
"selector": {
|
|
16
|
+
"eventType": "operation.completed"
|
|
17
|
+
},
|
|
18
|
+
"spec": {
|
|
19
|
+
"cmd": "run the reported failing test/command and capture output",
|
|
20
|
+
"sideEffects": [
|
|
21
|
+
"read_only"
|
|
22
|
+
],
|
|
23
|
+
"writes": [],
|
|
24
|
+
"allowedPaths": []
|
|
25
|
+
}
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
"id": "locate-cause",
|
|
29
|
+
"op": "RUN",
|
|
30
|
+
"deps": [
|
|
31
|
+
"reproduce"
|
|
32
|
+
],
|
|
33
|
+
"timeoutMs": 300000,
|
|
34
|
+
"retry": {
|
|
35
|
+
"sideEffectClass": "read_only",
|
|
36
|
+
"maxAttempts": 2,
|
|
37
|
+
"backoffMs": 500
|
|
38
|
+
},
|
|
39
|
+
"selector": {
|
|
40
|
+
"eventType": "operation.completed"
|
|
41
|
+
},
|
|
42
|
+
"spec": {
|
|
43
|
+
"cmd": "query-index on error/symbol, open top suspects, trace backward to the source line",
|
|
44
|
+
"sideEffects": [
|
|
45
|
+
"read_only"
|
|
46
|
+
],
|
|
47
|
+
"writes": [],
|
|
48
|
+
"allowedPaths": []
|
|
49
|
+
}
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
"id": "patch",
|
|
53
|
+
"op": "RUN",
|
|
54
|
+
"deps": [
|
|
55
|
+
"locate-cause"
|
|
56
|
+
],
|
|
57
|
+
"timeoutMs": 300000,
|
|
58
|
+
"retry": {
|
|
59
|
+
"sideEffectClass": "write",
|
|
60
|
+
"maxAttempts": 1,
|
|
61
|
+
"backoffMs": 0
|
|
62
|
+
},
|
|
63
|
+
"selector": {
|
|
64
|
+
"eventType": "operation.completed"
|
|
65
|
+
},
|
|
66
|
+
"spec": {
|
|
67
|
+
"cmd": "apply the minimal source fix at the located cause",
|
|
68
|
+
"sideEffects": [
|
|
69
|
+
"write"
|
|
70
|
+
],
|
|
71
|
+
"writes": [],
|
|
72
|
+
"allowedPaths": []
|
|
73
|
+
}
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
"id": "verify",
|
|
77
|
+
"op": "RUN",
|
|
78
|
+
"deps": [
|
|
79
|
+
"patch"
|
|
80
|
+
],
|
|
81
|
+
"timeoutMs": 600000,
|
|
82
|
+
"retry": {
|
|
83
|
+
"sideEffectClass": "read_only",
|
|
84
|
+
"maxAttempts": 1,
|
|
85
|
+
"backoffMs": 0
|
|
86
|
+
},
|
|
87
|
+
"selector": {
|
|
88
|
+
"eventType": "operation.completed"
|
|
89
|
+
},
|
|
90
|
+
"spec": {
|
|
91
|
+
"cmd": "re-run the failing test plus the affected suite slice; require green",
|
|
92
|
+
"sideEffects": [
|
|
93
|
+
"read_only"
|
|
94
|
+
],
|
|
95
|
+
"writes": [],
|
|
96
|
+
"allowedPaths": []
|
|
97
|
+
}
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
"id": "done",
|
|
101
|
+
"op": "COMPLETE",
|
|
102
|
+
"deps": [
|
|
103
|
+
"verify"
|
|
104
|
+
]
|
|
105
|
+
}
|
|
106
|
+
]
|
|
107
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
{
|
|
2
|
+
"planId": "handoff-review-batch",
|
|
3
|
+
"description": "Collect pending_review tasks, fan out per-panel-member reviews, aggregate verdicts, and escalate on CHANGES-REQUESTED/CRITICAL. Reference plan for the language-compiled runtime (V4 seed).",
|
|
4
|
+
"nodes": [
|
|
5
|
+
{
|
|
6
|
+
"id": "collect-tasks",
|
|
7
|
+
"op": "RUN",
|
|
8
|
+
"deps": [],
|
|
9
|
+
"timeoutMs": 60000,
|
|
10
|
+
"retry": {
|
|
11
|
+
"sideEffectClass": "read_only",
|
|
12
|
+
"maxAttempts": 2,
|
|
13
|
+
"backoffMs": 500
|
|
14
|
+
},
|
|
15
|
+
"selector": {
|
|
16
|
+
"eventType": "operation.completed"
|
|
17
|
+
},
|
|
18
|
+
"spec": {
|
|
19
|
+
"cmd": "list pending_review task files from docs/AI_HANDOFF/tasks",
|
|
20
|
+
"sideEffects": [
|
|
21
|
+
"read_only"
|
|
22
|
+
],
|
|
23
|
+
"writes": [],
|
|
24
|
+
"allowedPaths": [
|
|
25
|
+
"docs/AI_HANDOFF/tasks"
|
|
26
|
+
]
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
"id": "wait-reviews",
|
|
31
|
+
"op": "WAIT_EVENT",
|
|
32
|
+
"deps": [
|
|
33
|
+
"collect-tasks"
|
|
34
|
+
],
|
|
35
|
+
"timeoutMs": 3600000,
|
|
36
|
+
"retry": {
|
|
37
|
+
"sideEffectClass": "read_only",
|
|
38
|
+
"maxAttempts": 1,
|
|
39
|
+
"backoffMs": 0
|
|
40
|
+
},
|
|
41
|
+
"selector": {
|
|
42
|
+
"eventType": "review.verdicts",
|
|
43
|
+
"boundary": "task-review"
|
|
44
|
+
},
|
|
45
|
+
"spec": {
|
|
46
|
+
"cmd": "wait for panel verdict rows (per member, per task)",
|
|
47
|
+
"sideEffects": [
|
|
48
|
+
"read_only"
|
|
49
|
+
],
|
|
50
|
+
"writes": [],
|
|
51
|
+
"allowedPaths": []
|
|
52
|
+
}
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
"id": "aggregate",
|
|
56
|
+
"op": "RUN",
|
|
57
|
+
"deps": [
|
|
58
|
+
"wait-reviews"
|
|
59
|
+
],
|
|
60
|
+
"timeoutMs": 120000,
|
|
61
|
+
"retry": {
|
|
62
|
+
"sideEffectClass": "read_only",
|
|
63
|
+
"maxAttempts": 1,
|
|
64
|
+
"backoffMs": 0
|
|
65
|
+
},
|
|
66
|
+
"selector": {
|
|
67
|
+
"eventType": "operation.completed"
|
|
68
|
+
},
|
|
69
|
+
"spec": {
|
|
70
|
+
"cmd": "node .claude/ukit/index/review-panel-aggregate.mjs <tasks> \u2014 lead member takes precedence",
|
|
71
|
+
"sideEffects": [
|
|
72
|
+
"read_only",
|
|
73
|
+
"idempotent_write"
|
|
74
|
+
],
|
|
75
|
+
"writes": [
|
|
76
|
+
"docs/AI_HANDOFF/tasks"
|
|
77
|
+
],
|
|
78
|
+
"allowedPaths": [
|
|
79
|
+
"docs/AI_HANDOFF"
|
|
80
|
+
]
|
|
81
|
+
}
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
"id": "gate",
|
|
85
|
+
"op": "BRANCH",
|
|
86
|
+
"deps": [
|
|
87
|
+
"aggregate"
|
|
88
|
+
],
|
|
89
|
+
"selector": {
|
|
90
|
+
"eventType": "branch.selected",
|
|
91
|
+
"field": "verdict"
|
|
92
|
+
},
|
|
93
|
+
"branches": {
|
|
94
|
+
"approved": "done",
|
|
95
|
+
"blocked": "escalate"
|
|
96
|
+
}
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
"id": "done",
|
|
100
|
+
"op": "COMPLETE",
|
|
101
|
+
"deps": [
|
|
102
|
+
"gate"
|
|
103
|
+
]
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
"id": "escalate",
|
|
107
|
+
"op": "ESCALATE",
|
|
108
|
+
"deps": [
|
|
109
|
+
"gate"
|
|
110
|
+
],
|
|
111
|
+
"spec": {
|
|
112
|
+
"reason": "changes-requested-or-critical \u2014 deterministic gate stays authoritative"
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
]
|
|
116
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
{
|
|
2
|
+
"planId": "release-check",
|
|
3
|
+
"description": "Version parity gate before ship: bump \u2192 targeted tests + twin parity \u2192 commit/push/tag \u2192 npm publish \u2192 parity verify. Reference plan for the language-compiled runtime (V4 seed).",
|
|
4
|
+
"nodes": [
|
|
5
|
+
{
|
|
6
|
+
"id": "version-bump",
|
|
7
|
+
"op": "RUN",
|
|
8
|
+
"deps": [],
|
|
9
|
+
"timeoutMs": 60000,
|
|
10
|
+
"retry": {
|
|
11
|
+
"sideEffectClass": "idempotent_write",
|
|
12
|
+
"maxAttempts": 2,
|
|
13
|
+
"backoffMs": 500
|
|
14
|
+
},
|
|
15
|
+
"selector": {
|
|
16
|
+
"eventType": "operation.completed"
|
|
17
|
+
},
|
|
18
|
+
"spec": {
|
|
19
|
+
"cmd": "npm version <v> --no-git-tag-version; sync baseline-reconciliation version",
|
|
20
|
+
"sideEffects": [
|
|
21
|
+
"idempotent_write"
|
|
22
|
+
],
|
|
23
|
+
"writes": [
|
|
24
|
+
"package.json",
|
|
25
|
+
"docs/AI_HANDOFF/inventory/baseline-reconciliation.md"
|
|
26
|
+
],
|
|
27
|
+
"allowedPaths": [
|
|
28
|
+
"package.json",
|
|
29
|
+
"docs/AI_HANDOFF"
|
|
30
|
+
]
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
"id": "verify-tests",
|
|
35
|
+
"op": "RUN",
|
|
36
|
+
"deps": [
|
|
37
|
+
"version-bump"
|
|
38
|
+
],
|
|
39
|
+
"timeoutMs": 600000,
|
|
40
|
+
"retry": {
|
|
41
|
+
"sideEffectClass": "read_only",
|
|
42
|
+
"maxAttempts": 1,
|
|
43
|
+
"backoffMs": 0
|
|
44
|
+
},
|
|
45
|
+
"selector": {
|
|
46
|
+
"eventType": "operation.completed"
|
|
47
|
+
},
|
|
48
|
+
"spec": {
|
|
49
|
+
"cmd": "targeted vitest slice + templateParity + docs render check",
|
|
50
|
+
"sideEffects": [
|
|
51
|
+
"read_only"
|
|
52
|
+
],
|
|
53
|
+
"writes": [],
|
|
54
|
+
"allowedPaths": []
|
|
55
|
+
}
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
"id": "ship",
|
|
59
|
+
"op": "RUN",
|
|
60
|
+
"deps": [
|
|
61
|
+
"verify-tests"
|
|
62
|
+
],
|
|
63
|
+
"timeoutMs": 300000,
|
|
64
|
+
"retry": {
|
|
65
|
+
"sideEffectClass": "network",
|
|
66
|
+
"maxAttempts": 1,
|
|
67
|
+
"backoffMs": 0
|
|
68
|
+
},
|
|
69
|
+
"selector": {
|
|
70
|
+
"eventType": "operation.completed"
|
|
71
|
+
},
|
|
72
|
+
"spec": {
|
|
73
|
+
"cmd": "commit \u2192 push \u2192 tag \u2192 GitHub release \u2192 npm publish",
|
|
74
|
+
"sideEffects": [
|
|
75
|
+
"network"
|
|
76
|
+
],
|
|
77
|
+
"writes": [],
|
|
78
|
+
"allowedPaths": []
|
|
79
|
+
}
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
"id": "parity",
|
|
83
|
+
"op": "RUN",
|
|
84
|
+
"deps": [
|
|
85
|
+
"ship"
|
|
86
|
+
],
|
|
87
|
+
"timeoutMs": 180000,
|
|
88
|
+
"retry": {
|
|
89
|
+
"sideEffectClass": "network",
|
|
90
|
+
"maxAttempts": 1,
|
|
91
|
+
"backoffMs": 0
|
|
92
|
+
},
|
|
93
|
+
"selector": {
|
|
94
|
+
"eventType": "operation.completed"
|
|
95
|
+
},
|
|
96
|
+
"spec": {
|
|
97
|
+
"cmd": "npm view dist-tags.latest must equal package.json version (poll until published)",
|
|
98
|
+
"sideEffects": [
|
|
99
|
+
"network"
|
|
100
|
+
],
|
|
101
|
+
"writes": [],
|
|
102
|
+
"allowedPaths": []
|
|
103
|
+
}
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
"id": "done",
|
|
107
|
+
"op": "COMPLETE",
|
|
108
|
+
"deps": [
|
|
109
|
+
"parity"
|
|
110
|
+
]
|
|
111
|
+
}
|
|
112
|
+
]
|
|
113
|
+
}
|
|
@@ -4,7 +4,11 @@
|
|
|
4
4
|
* flag; this module reads no config itself).
|
|
5
5
|
*
|
|
6
6
|
* Executes a compiled ValidatedPlan (SPEC §4 frozen shape — literal nodes
|
|
7
|
-
* Map, entryNodes, topological order) purely against G1 durable
|
|
7
|
+
* Map, entryNodes, topological order) purely against G1 durable
|
|
8
|
+
* primitives. IR v2 adds wrapper opcodes: PARALLEL fans out to RUN/
|
|
9
|
+
* WAIT_EVENT children through the same activation path and folds when
|
|
10
|
+
* every child is terminal; TIMEOUT wraps one node and resolves on child
|
|
11
|
+
* terminal state or an elapsed deadline swept by deliver()/resume():
|
|
8
12
|
*
|
|
9
13
|
* - cursor + continuations are registered BEFORE the first node activation
|
|
10
14
|
* (register-before-start is structural; no "start without register" path)
|
|
@@ -206,6 +210,15 @@ export function createVmEngine({ dir, now, classifyFn, startFn, hooks } = {}) {
|
|
|
206
210
|
inst.nodes[id].state = 'cancelled';
|
|
207
211
|
}
|
|
208
212
|
}
|
|
213
|
+
// a wrapped child needing recovery is an ambiguous window for the
|
|
214
|
+
// wrapper too — propagate so the wrapper never joins over a child
|
|
215
|
+
// whose side effects cannot be proven
|
|
216
|
+
const wrapperId = nodeId != null ? wrapperOf(inst, nodeId) : null;
|
|
217
|
+
if (wrapperId != null
|
|
218
|
+
&& inst.nodes[wrapperId].state !== 'recovery_required'
|
|
219
|
+
&& !isTerminal(inst.nodes[wrapperId].state)) {
|
|
220
|
+
await moveNode(inst, wrapperId, 'recovery_required', { reason });
|
|
221
|
+
}
|
|
209
222
|
}
|
|
210
223
|
|
|
211
224
|
async function cancelDependents(inst, nodeId) {
|
|
@@ -264,6 +277,27 @@ export function createVmEngine({ dir, now, classifyFn, startFn, hooks } = {}) {
|
|
|
264
277
|
inst.forcedStatus = 'recovery_required';
|
|
265
278
|
break;
|
|
266
279
|
}
|
|
280
|
+
case 'PARALLEL': {
|
|
281
|
+
setNodeState(inst, nodeId, 'running');
|
|
282
|
+
await appendTransition(inst, nodeId, 'running');
|
|
283
|
+
// bounded fan-out: children are contained RUN/WAIT_EVENT nodes
|
|
284
|
+
// (deps === [this node] per compilePlan) — each takes the normal
|
|
285
|
+
// register-before-start activation path, deterministically in
|
|
286
|
+
// declared order, so a crash mid fan-out replays child-by-child
|
|
287
|
+
for (const childId of node.children ?? []) {
|
|
288
|
+
await activate(inst, childId, { suppressStart });
|
|
289
|
+
}
|
|
290
|
+
await resolveParallel(inst, nodeId);
|
|
291
|
+
break;
|
|
292
|
+
}
|
|
293
|
+
case 'TIMEOUT': {
|
|
294
|
+
setNodeState(inst, nodeId, 'running');
|
|
295
|
+
inst.nodes[nodeId].startedAt = clock().getTime();
|
|
296
|
+
await appendTransition(inst, nodeId, 'running');
|
|
297
|
+
await activate(inst, node.node, { suppressStart });
|
|
298
|
+
await resolveTimeout(inst, nodeId);
|
|
299
|
+
break;
|
|
300
|
+
}
|
|
267
301
|
default:
|
|
268
302
|
throw new VmEngineError('unknown_opcode', `${nodeId}: ${node.op}`);
|
|
269
303
|
}
|
|
@@ -277,6 +311,142 @@ export function createVmEngine({ dir, now, classifyFn, startFn, hooks } = {}) {
|
|
|
277
311
|
}
|
|
278
312
|
}
|
|
279
313
|
|
|
314
|
+
/** The IR v2 wrapper (PARALLEL parent or TIMEOUT owner) containing nodeId. */
|
|
315
|
+
function wrapperOf(inst, nodeId) {
|
|
316
|
+
for (const [id, node] of inst.nodeMap) {
|
|
317
|
+
if (node.op === 'PARALLEL' && node.children?.includes(nodeId)) return id;
|
|
318
|
+
if (node.op === 'TIMEOUT' && node.node === nodeId) return id;
|
|
319
|
+
}
|
|
320
|
+
return null;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Fold a PARALLEL wrapper once every child is terminal (join: 'all').
|
|
325
|
+
* All-completed -> completed (and dependents activate); any failure wins
|
|
326
|
+
* over cancellation. Deterministic: the first failure/cancellation in
|
|
327
|
+
* declared child order decides the propagated reason.
|
|
328
|
+
*/
|
|
329
|
+
async function resolveParallel(inst, nodeId, { suppressStart = false } = {}) {
|
|
330
|
+
const node = inst.nodeMap.get(nodeId);
|
|
331
|
+
if (!node || node.op !== 'PARALLEL') return null;
|
|
332
|
+
if (inst.nodes[nodeId].state !== 'running') return null;
|
|
333
|
+
const children = node.children ?? [];
|
|
334
|
+
if (!children.every((c) => isTerminal(inst.nodes[c]?.state))) return null;
|
|
335
|
+
|
|
336
|
+
const failed = children.find((c) => inst.nodes[c].state === 'failed');
|
|
337
|
+
const cancelled = children.find((c) => inst.nodes[c].state === 'cancelled');
|
|
338
|
+
if (failed) {
|
|
339
|
+
await moveNode(inst, nodeId, 'failed', { reason: `child_failed:${failed}` });
|
|
340
|
+
inst.recoveryReason = inst.recoveryReason ?? `parallel_child_failed:${failed}`;
|
|
341
|
+
return { node: nodeId, to: 'failed' };
|
|
342
|
+
}
|
|
343
|
+
if (cancelled) {
|
|
344
|
+
// mirror the RUN cancel path: running -> cancel_pending -> cancelled
|
|
345
|
+
setNodeState(inst, nodeId, 'cancel_pending');
|
|
346
|
+
await appendTransition(inst, nodeId, 'cancel_pending');
|
|
347
|
+
await moveNode(inst, nodeId, 'cancelled', { reason: `child_cancelled:${cancelled}` });
|
|
348
|
+
await cancelDependents(inst, nodeId);
|
|
349
|
+
return { node: nodeId, to: 'cancelled' };
|
|
350
|
+
}
|
|
351
|
+
await moveNode(inst, nodeId, 'completed');
|
|
352
|
+
await activateDependents(inst, nodeId, { suppressStart });
|
|
353
|
+
return { node: nodeId, to: 'completed' };
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Resolve a TIMEOUT wrapper when its wrapped node is terminal — the
|
|
358
|
+
* wrapper mirrors the child's terminal state, never inventing an outcome.
|
|
359
|
+
*/
|
|
360
|
+
async function resolveTimeout(inst, nodeId, { suppressStart = false } = {}) {
|
|
361
|
+
const node = inst.nodeMap.get(nodeId);
|
|
362
|
+
if (!node || node.op !== 'TIMEOUT') return null;
|
|
363
|
+
const wstate = inst.nodes[nodeId].state;
|
|
364
|
+
if (wstate !== 'running') return null;
|
|
365
|
+
const childId = node.node;
|
|
366
|
+
const cstate = inst.nodes[childId]?.state;
|
|
367
|
+
if (!isTerminal(cstate)) return null;
|
|
368
|
+
|
|
369
|
+
if (cstate === 'completed') {
|
|
370
|
+
await moveNode(inst, nodeId, 'completed');
|
|
371
|
+
await activateDependents(inst, nodeId, { suppressStart });
|
|
372
|
+
return { node: nodeId, to: 'completed' };
|
|
373
|
+
}
|
|
374
|
+
if (cstate === 'cancelled') {
|
|
375
|
+
setNodeState(inst, nodeId, 'cancel_pending');
|
|
376
|
+
await appendTransition(inst, nodeId, 'cancel_pending');
|
|
377
|
+
await moveNode(inst, nodeId, 'cancelled', { reason: `child_cancelled:${childId}` });
|
|
378
|
+
await cancelDependents(inst, nodeId);
|
|
379
|
+
return { node: nodeId, to: 'cancelled' };
|
|
380
|
+
}
|
|
381
|
+
await moveNode(inst, nodeId, 'failed', { reason: `child_failed:${childId}` });
|
|
382
|
+
inst.recoveryReason = inst.recoveryReason ?? `timeout_child_failed:${childId}`;
|
|
383
|
+
return { node: nodeId, to: 'failed' };
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* Fire a TIMEOUT whose deadline elapsed while the wrapped node is still
|
|
388
|
+
* non-terminal. 'fail' marks the wrapped node failed with
|
|
389
|
+
* 'deadline_exceeded' (a non-terminal, non-running wrapped node fails
|
|
390
|
+
* closed to recovery_required — the activation window is ambiguous);
|
|
391
|
+
* 'escalate' routes to ESCALATE semantics (wrapper completes escalated,
|
|
392
|
+
* plan forced recovery_required, wrapped node parked recovery_required).
|
|
393
|
+
*/
|
|
394
|
+
async function fireTimeout(inst, nodeId) {
|
|
395
|
+
const node = inst.nodeMap.get(nodeId);
|
|
396
|
+
const childId = node.node;
|
|
397
|
+
const cstate = inst.nodes[childId]?.state;
|
|
398
|
+
|
|
399
|
+
if (node.onTimeout === 'escalate') {
|
|
400
|
+
if (!isTerminal(cstate)) {
|
|
401
|
+
await moveNode(inst, childId, 'recovery_required', { reason: 'deadline_exceeded' });
|
|
402
|
+
}
|
|
403
|
+
await moveNode(inst, nodeId, 'completed', { escalated: true, reason: 'deadline_exceeded' });
|
|
404
|
+
inst.recoveryReason = 'escalated';
|
|
405
|
+
inst.forcedStatus = 'recovery_required';
|
|
406
|
+
return { node: nodeId, to: 'completed', escalated: true };
|
|
407
|
+
}
|
|
408
|
+
if (cstate === 'running') {
|
|
409
|
+
await moveNode(inst, childId, 'failed', { reason: 'deadline_exceeded' });
|
|
410
|
+
} else if (!isTerminal(cstate)) {
|
|
411
|
+
await moveNode(inst, childId, 'recovery_required', { reason: 'deadline_exceeded' });
|
|
412
|
+
}
|
|
413
|
+
await moveNode(inst, nodeId, 'failed', { reason: 'deadline_exceeded' });
|
|
414
|
+
inst.recoveryReason = inst.recoveryReason ?? `deadline_exceeded:${childId}`;
|
|
415
|
+
return { node: nodeId, to: 'failed' };
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* Every TIMEOUT whose deadline elapsed while it is still running fires
|
|
420
|
+
* now — deterministic against the engine clock, journal-first like any
|
|
421
|
+
* other transition. Called from deliver() (before the delivered event is
|
|
422
|
+
* evaluated, so a deadline wins a same-instant race) and resume().
|
|
423
|
+
*/
|
|
424
|
+
async function sweepTimeouts(inst, { suppressStart = false } = {}) {
|
|
425
|
+
const nowMs = clock().getTime();
|
|
426
|
+
const results = [];
|
|
427
|
+
for (const [id, node] of inst.nodeMap) {
|
|
428
|
+
if (node.op !== 'TIMEOUT') continue;
|
|
429
|
+
const rec = inst.nodes[id];
|
|
430
|
+
if (rec.state !== 'running' || rec.startedAt == null) continue;
|
|
431
|
+
if (nowMs - rec.startedAt < node.deadlineMs) continue;
|
|
432
|
+
const childState = inst.nodes[node.node]?.state;
|
|
433
|
+
results.push(isTerminal(childState)
|
|
434
|
+
? await resolveTimeout(inst, id, { suppressStart })
|
|
435
|
+
: await fireTimeout(inst, id));
|
|
436
|
+
}
|
|
437
|
+
return results.filter(Boolean);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/** A wrapped child resolved — fold its wrapper (no-op when none exists). */
|
|
441
|
+
async function onChildResolved(inst, nodeId, { suppressStart = false } = {}) {
|
|
442
|
+
const wrapperId = wrapperOf(inst, nodeId);
|
|
443
|
+
if (wrapperId == null) return null;
|
|
444
|
+
const wrapper = inst.nodeMap.get(wrapperId);
|
|
445
|
+
return wrapper.op === 'TIMEOUT'
|
|
446
|
+
? resolveTimeout(inst, wrapperId, { suppressStart })
|
|
447
|
+
: resolveParallel(inst, wrapperId, { suppressStart });
|
|
448
|
+
}
|
|
449
|
+
|
|
280
450
|
function findWaiting(inst, event) {
|
|
281
451
|
const nodeId = event.operationId.slice(inst.planInstanceId.length + 1);
|
|
282
452
|
const node = inst.nodeMap.get(nodeId);
|
|
@@ -312,6 +482,14 @@ export function createVmEngine({ dir, now, classifyFn, startFn, hooks } = {}) {
|
|
|
312
482
|
return { node: nodeId, to: 'completed', branch: value };
|
|
313
483
|
}
|
|
314
484
|
|
|
485
|
+
// PARALLEL/TIMEOUT consume no external events — a classified route to a
|
|
486
|
+
// wrapper fails closed instead of silently completing it (wrappers only
|
|
487
|
+
// resolve from their contained nodes' states)
|
|
488
|
+
if (node.op === 'PARALLEL' || node.op === 'TIMEOUT') {
|
|
489
|
+
await markRecoveryRequired(inst, nodeId, `unroutable_event:${node.op}`);
|
|
490
|
+
return { node: nodeId, to: 'recovery_required' };
|
|
491
|
+
}
|
|
492
|
+
|
|
315
493
|
// RUN / WAIT_EVENT
|
|
316
494
|
const to = NODE_OUTCOMES[outcome];
|
|
317
495
|
if (to == null) {
|
|
@@ -325,11 +503,13 @@ export function createVmEngine({ dir, now, classifyFn, startFn, hooks } = {}) {
|
|
|
325
503
|
await appendTransition(inst, nodeId, 'cancel_pending');
|
|
326
504
|
await moveNode(inst, nodeId, 'cancelled');
|
|
327
505
|
await cancelDependents(inst, nodeId);
|
|
506
|
+
await onChildResolved(inst, nodeId, { suppressStart });
|
|
328
507
|
return { node: nodeId, to: 'cancelled' };
|
|
329
508
|
}
|
|
330
509
|
if (to === 'completed') {
|
|
331
510
|
await moveNode(inst, nodeId, 'completed');
|
|
332
511
|
await activateDependents(inst, nodeId, { suppressStart });
|
|
512
|
+
await onChildResolved(inst, nodeId, { suppressStart });
|
|
333
513
|
} else {
|
|
334
514
|
// 'failed': bounded auto-retry only for AUTO_RETRYABLE classes per
|
|
335
515
|
// validateRetry — the retry edge is running->retry_pending->running,
|
|
@@ -348,6 +528,7 @@ export function createVmEngine({ dir, now, classifyFn, startFn, hooks } = {}) {
|
|
|
348
528
|
}
|
|
349
529
|
await moveNode(inst, nodeId, 'failed', { reason: retry.code });
|
|
350
530
|
inst.recoveryReason = inst.recoveryReason ?? `node_failed:${nodeId}:${retry.code}`;
|
|
531
|
+
await onChildResolved(inst, nodeId, { suppressStart });
|
|
351
532
|
}
|
|
352
533
|
return { node: nodeId, to };
|
|
353
534
|
}
|
|
@@ -390,7 +571,7 @@ export function createVmEngine({ dir, now, classifyFn, startFn, hooks } = {}) {
|
|
|
390
571
|
forcedStatus: null,
|
|
391
572
|
};
|
|
392
573
|
for (const [id] of nodes) {
|
|
393
|
-
inst.nodes[id] = { state: 'queued', attempt: 0, lastEventSeq: 0 };
|
|
574
|
+
inst.nodes[id] = { state: 'queued', attempt: 0, lastEventSeq: 0, startedAt: null };
|
|
394
575
|
inst.cursors[id] = { lastSeq: 0, seenEventIds: new Set() };
|
|
395
576
|
inst.journalSeq[id] = 0;
|
|
396
577
|
}
|
|
@@ -459,6 +640,7 @@ export function createVmEngine({ dir, now, classifyFn, startFn, hooks } = {}) {
|
|
|
459
640
|
state: seed.state ?? 'queued',
|
|
460
641
|
attempt: seed.attempt ?? 0,
|
|
461
642
|
lastEventSeq: seed.lastEventSeq ?? 0,
|
|
643
|
+
startedAt: null, // journal-derived below; never trusted from the cache
|
|
462
644
|
};
|
|
463
645
|
inst.cursors[id] = { lastSeq: 0, seenEventIds: new Set() };
|
|
464
646
|
inst.journalSeq[id] = 0;
|
|
@@ -471,6 +653,12 @@ export function createVmEngine({ dir, now, classifyFn, startFn, hooks } = {}) {
|
|
|
471
653
|
for (const [id] of nodeMap) {
|
|
472
654
|
for await (const ev of readJournal(dir, opId(planInstanceId, id))) {
|
|
473
655
|
inst.journalSeq[id] = ev.seq;
|
|
656
|
+
// a TIMEOUT deadline is anchored to the FIRST activation record's
|
|
657
|
+
// journal timestamp — replay must land on the same deadline
|
|
658
|
+
if (ev.eventType === 'operation.transition' && ev.safePayload?.to === 'starting'
|
|
659
|
+
&& inst.nodes[id].startedAt == null) {
|
|
660
|
+
inst.nodes[id].startedAt = Date.parse(ev.observedAt) || null;
|
|
661
|
+
}
|
|
474
662
|
}
|
|
475
663
|
}
|
|
476
664
|
// continuation registration survived the crash on disk — recover the ids
|
|
@@ -505,6 +693,11 @@ export function createVmEngine({ dir, now, classifyFn, startFn, hooks } = {}) {
|
|
|
505
693
|
const pi = sep > 0 ? event.operationId.slice(0, sep) : null;
|
|
506
694
|
const inst = pi ? await ensureInst(pi) : null;
|
|
507
695
|
if (!inst) return { consumed: false, code: 'unroutable' };
|
|
696
|
+
|
|
697
|
+
// fire elapsed TIMEOUT deadlines before the delivered event is
|
|
698
|
+
// evaluated: a deadline wins the same-instant race (deterministic),
|
|
699
|
+
// and journaled wrapper folds replay identically on resume
|
|
700
|
+
const swept = await sweepTimeouts(inst);
|
|
508
701
|
const { nodeId, node, matched } = findWaiting(inst, event);
|
|
509
702
|
const cursor = inst.cursors[nodeId] ?? { lastSeq: 0, seenEventIds: new Set() };
|
|
510
703
|
|
|
@@ -559,7 +752,7 @@ export function createVmEngine({ dir, now, classifyFn, startFn, hooks } = {}) {
|
|
|
559
752
|
return { consumed: false, code: 'duplicate' };
|
|
560
753
|
}
|
|
561
754
|
|
|
562
|
-
const transitions = [await applyOutcome(inst, nodeId, node, outcome, event)];
|
|
755
|
+
const transitions = [...swept, await applyOutcome(inst, nodeId, node, outcome, event)];
|
|
563
756
|
await persist(inst);
|
|
564
757
|
return { consumed: true, transitions };
|
|
565
758
|
}
|
|
@@ -584,6 +777,11 @@ export function createVmEngine({ dir, now, classifyFn, startFn, hooks } = {}) {
|
|
|
584
777
|
cursor.seenEventIds.add(ev.eventId);
|
|
585
778
|
if (ev.eventType === 'operation.transition' && ev.safePayload?.to) {
|
|
586
779
|
derived = ev.safePayload.to;
|
|
780
|
+
// deadline anchor for TIMEOUT wrappers: the journaled activation
|
|
781
|
+
// timestamp, identical across replay (never the resume clock)
|
|
782
|
+
if (ev.safePayload.to === 'starting' && inst.nodes[nodeId].startedAt == null) {
|
|
783
|
+
inst.nodes[nodeId].startedAt = Date.parse(ev.observedAt) || null;
|
|
784
|
+
}
|
|
587
785
|
} else {
|
|
588
786
|
delivered.push(ev);
|
|
589
787
|
}
|
|
@@ -614,6 +812,44 @@ export function createVmEngine({ dir, now, classifyFn, startFn, hooks } = {}) {
|
|
|
614
812
|
await activate(inst, nodeId, { suppressStart: true });
|
|
615
813
|
}
|
|
616
814
|
}
|
|
815
|
+
|
|
816
|
+
// IR v2 wrapper post-pass: wrappers replay no journal of their own
|
|
817
|
+
// outcome — they join/resolve deterministically from child states.
|
|
818
|
+
// A crash between fan-out and join is folded here: completed children
|
|
819
|
+
// are never re-activated (their journals are terminal), queued children
|
|
820
|
+
// of an already-started wrapper re-arm via the suppressed activation
|
|
821
|
+
// path, and a non-idempotent child still mid-flight already resolved
|
|
822
|
+
// to recovery_required in the main loop above (propagated to the
|
|
823
|
+
// wrapper by markRecoveryRequired).
|
|
824
|
+
for (const [nodeId, node] of inst.nodeMap) {
|
|
825
|
+
const wstate = inst.nodes[nodeId].state;
|
|
826
|
+
if (node.op === 'PARALLEL' && (wstate === 'starting' || wstate === 'running')) {
|
|
827
|
+
if (wstate === 'starting') await moveNode(inst, nodeId, 'running');
|
|
828
|
+
// a crash between the child's recovery record and the wrapper
|
|
829
|
+
// propagation leaves the join hanging — propagate on replay
|
|
830
|
+
if ((node.children ?? []).some((c) => inst.nodes[c]?.state === 'recovery_required')) {
|
|
831
|
+
await moveNode(inst, nodeId, 'recovery_required', { reason: 'child_recovery' });
|
|
832
|
+
continue;
|
|
833
|
+
}
|
|
834
|
+
for (const childId of node.children ?? []) {
|
|
835
|
+
if (inst.nodes[childId].state === 'queued') {
|
|
836
|
+
await activate(inst, childId, { suppressStart: true });
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
const folded = await resolveParallel(inst, nodeId);
|
|
840
|
+
if (folded) report.recovered.push(nodeId);
|
|
841
|
+
}
|
|
842
|
+
if (node.op === 'TIMEOUT' && (wstate === 'starting' || wstate === 'running')) {
|
|
843
|
+
if (wstate === 'starting') await moveNode(inst, nodeId, 'running');
|
|
844
|
+
if (inst.nodes[node.node]?.state === 'queued') {
|
|
845
|
+
await activate(inst, node.node, { suppressStart: true });
|
|
846
|
+
}
|
|
847
|
+
const resolved = isTerminal(inst.nodes[node.node]?.state)
|
|
848
|
+
? await resolveTimeout(inst, nodeId)
|
|
849
|
+
: (await sweepTimeouts(inst)).length > 0;
|
|
850
|
+
if (resolved) report.recovered.push(nodeId);
|
|
851
|
+
}
|
|
852
|
+
}
|
|
617
853
|
await persist(inst);
|
|
618
854
|
report.status = inst.forcedStatus ?? planStatus(inst);
|
|
619
855
|
return report;
|
|
@@ -431,14 +431,14 @@ export function buildDefaultRuntimeConfig(overrides = {}) {
|
|
|
431
431
|
learning: {
|
|
432
432
|
feedback: { enabled: true, maxEvents: 200 },
|
|
433
433
|
proposals: { minCount: 3, minSessions: 2 },
|
|
434
|
-
episodes: { autoWrite:
|
|
434
|
+
episodes: { autoWrite: true },
|
|
435
435
|
tuning: { enabled: true, applyMode: 'manual' },
|
|
436
436
|
// C52 M04.2 stage keys (SPEC §5 FR-016–FR-018). candidates: repeated
|
|
437
437
|
// corrections/suppressions/escalations promote to a LearningCandidate
|
|
438
438
|
// only after minOccurrences + cross-session evidence; report-only at
|
|
439
439
|
// 'shadow'. overlays: delta overlays on policy fields; base-version
|
|
440
440
|
// mismatch → conflict, never silent apply. Promotion stays manual.
|
|
441
|
-
candidates: { stage: '
|
|
441
|
+
candidates: { stage: 'shadow', minOccurrences: 3 },
|
|
442
442
|
overlays: { stage: 'off' },
|
|
443
443
|
},
|
|
444
444
|
// C52 M04.1 compact resumable state (SPEC §5 FR-012–FR-015). Stage 'off'
|
|
@@ -342,14 +342,14 @@
|
|
|
342
342
|
"minSessions": 2
|
|
343
343
|
},
|
|
344
344
|
"episodes": {
|
|
345
|
-
"autoWrite":
|
|
345
|
+
"autoWrite": true
|
|
346
346
|
},
|
|
347
347
|
"tuning": {
|
|
348
348
|
"enabled": true,
|
|
349
349
|
"applyMode": "manual"
|
|
350
350
|
},
|
|
351
351
|
"candidates": {
|
|
352
|
-
"stage": "
|
|
352
|
+
"stage": "shadow",
|
|
353
353
|
"minOccurrences": 3
|
|
354
354
|
},
|
|
355
355
|
"overlays": {
|
|
@@ -772,5 +772,8 @@
|
|
|
772
772
|
"specRequired": "true = handoff-create b\u1eaft bu\u1ed9c vi\u1ebft docs/AI_HANDOFF/SPEC.md chi ti\u1ebft tr\u01b0\u1edbc khi t\u1ea1o task."
|
|
773
773
|
}
|
|
774
774
|
}
|
|
775
|
+
},
|
|
776
|
+
"observability": {
|
|
777
|
+
"stage": "default"
|
|
775
778
|
}
|
|
776
|
-
}
|
|
779
|
+
}
|