@lmzhen/dsh-evolution-core 0.27.3 → 0.29.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.
- package/lib/{http-routes-BP-S3KV-.js → http-routes-w09FdvOp.js} +6 -0
- package/lib/http-routes.js +1 -1
- package/lib/index.js +3339 -2612
- package/lib/subagent/index.js +240 -0
- package/lib/types/http-routes.d.ts +6 -0
- package/lib/types/http-routes.js +6 -0
- package/lib/types/index.d.ts +3 -0
- package/lib/types/index.js +3 -0
- package/lib/types/params.d.ts +1 -1
- package/lib/types/params.js +7 -0
- package/lib/types/platform-services.d.ts +1 -1
- package/lib/types/platform-services.js +2 -1
- package/lib/types/run-registry.d.ts +40 -10
- package/lib/types/run-registry.js +92 -18
- package/lib/types/skill-history.d.ts +1 -1
- package/lib/types/skill-history.js +2 -1
- package/lib/types/skill-store.d.ts +63 -2
- package/lib/types/skill-store.js +246 -25
- package/lib/types/subagent/channel.js +202 -0
- package/lib/types/subagent/types.d.ts +39 -0
- package/lib/types/tree-lock.d.ts +51 -0
- package/lib/types/tree-lock.js +115 -0
- package/lib/types/unit-undo-plan.d.ts +48 -0
- package/lib/types/unit-undo-plan.js +94 -0
- package/lib/types/unit-undo.d.ts +28 -0
- package/lib/types/unit-undo.js +81 -0
- package/package.json +1 -1
- package/persisted-write-inventory.json +36 -0
package/lib/subagent/index.js
CHANGED
|
@@ -56,8 +56,70 @@ function serviceOf(ctx, name) {
|
|
|
56
56
|
function messageOf(error) {
|
|
57
57
|
return error instanceof Error ? error.message : String(error);
|
|
58
58
|
}
|
|
59
|
+
function textOfValue(value) {
|
|
60
|
+
if (typeof value === "string") return value;
|
|
61
|
+
if (!Array.isArray(value)) return "";
|
|
62
|
+
return value.filter((block) => typeof block === "object" && block !== null && block.type === "text").map((block) => {
|
|
63
|
+
const text = block.text;
|
|
64
|
+
return typeof text === "string" ? text : "";
|
|
65
|
+
}).join("");
|
|
66
|
+
}
|
|
67
|
+
function abortLink(signal, timeoutMs) {
|
|
68
|
+
const controller = new AbortController();
|
|
69
|
+
const timer = setTimeout(() => {
|
|
70
|
+
controller.abort(/* @__PURE__ */ new Error("subagent turn timeout"));
|
|
71
|
+
}, timeoutMs);
|
|
72
|
+
const onAbort = () => {
|
|
73
|
+
controller.abort(signal?.reason);
|
|
74
|
+
};
|
|
75
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
76
|
+
return {
|
|
77
|
+
controller,
|
|
78
|
+
cleanup: () => {
|
|
79
|
+
clearTimeout(timer);
|
|
80
|
+
signal?.removeEventListener("abort", onAbort);
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
function waitForSubagentEnd(ctx, childId, signal, timeoutMs) {
|
|
85
|
+
return new Promise((resolve, reject) => {
|
|
86
|
+
let settled = false;
|
|
87
|
+
const finish = (fn) => {
|
|
88
|
+
if (settled) return;
|
|
89
|
+
settled = true;
|
|
90
|
+
cleanup();
|
|
91
|
+
fn();
|
|
92
|
+
};
|
|
93
|
+
const eventCtx = ctx;
|
|
94
|
+
const off = typeof eventCtx.on === "function" ? eventCtx.on("subagent/end", (info) => {
|
|
95
|
+
if (info.id !== childId) return;
|
|
96
|
+
finish(() => {
|
|
97
|
+
resolve(info);
|
|
98
|
+
});
|
|
99
|
+
}) : () => {};
|
|
100
|
+
const timer = setTimeout(() => {
|
|
101
|
+
finish(() => {
|
|
102
|
+
reject(/* @__PURE__ */ new Error("timed out waiting for subagent/end: " + childId));
|
|
103
|
+
});
|
|
104
|
+
}, timeoutMs);
|
|
105
|
+
const onAbort = () => {
|
|
106
|
+
finish(() => {
|
|
107
|
+
reject(signal.reason instanceof Error ? signal.reason : /* @__PURE__ */ new Error("conversation turn aborted"));
|
|
108
|
+
});
|
|
109
|
+
};
|
|
110
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
111
|
+
function cleanup() {
|
|
112
|
+
clearTimeout(timer);
|
|
113
|
+
signal.removeEventListener("abort", onAbort);
|
|
114
|
+
off();
|
|
115
|
+
}
|
|
116
|
+
if (signal.aborted) onAbort();
|
|
117
|
+
});
|
|
118
|
+
}
|
|
59
119
|
function createSubagentChannel(ctx) {
|
|
60
120
|
let lastKind = "reviewer";
|
|
121
|
+
const active = /* @__PURE__ */ new Map();
|
|
122
|
+
const disposed = /* @__PURE__ */ new Set();
|
|
61
123
|
return {
|
|
62
124
|
get state() {
|
|
63
125
|
return probeChannelState(ctx, lastKind);
|
|
@@ -179,6 +241,184 @@ function createSubagentChannel(ctx) {
|
|
|
179
241
|
} catch {}
|
|
180
242
|
}
|
|
181
243
|
}
|
|
244
|
+
},
|
|
245
|
+
async open(spec) {
|
|
246
|
+
lastKind = spec.kind;
|
|
247
|
+
const probe = probeChannelState(ctx, spec.kind);
|
|
248
|
+
if (probe.kind === "unavailable") return probe;
|
|
249
|
+
if (spec.allow.length === 0) return {
|
|
250
|
+
kind: "unavailable",
|
|
251
|
+
code: "E-324",
|
|
252
|
+
reason: "allow set is empty"
|
|
253
|
+
};
|
|
254
|
+
const subagents = serviceOf(ctx, "subagents");
|
|
255
|
+
const presets = serviceOf(ctx, "agentPresets");
|
|
256
|
+
if (subagents === void 0 || presets === void 0) return {
|
|
257
|
+
kind: "unavailable",
|
|
258
|
+
code: "E-323",
|
|
259
|
+
reason: "subagents/agentPresets service unavailable"
|
|
260
|
+
};
|
|
261
|
+
const parentPresetId = presets.composedPreset(spec.parent.ctx);
|
|
262
|
+
if (parentPresetId === void 0) return {
|
|
263
|
+
kind: "unavailable",
|
|
264
|
+
code: "E-323",
|
|
265
|
+
reason: "parent preset could not be resolved"
|
|
266
|
+
};
|
|
267
|
+
const ensured = await ensureRestrictedPreset(ctx, {
|
|
268
|
+
kind: spec.kind,
|
|
269
|
+
parentPresetId,
|
|
270
|
+
allow: spec.allow
|
|
271
|
+
});
|
|
272
|
+
if (ensured.kind === "failed") return {
|
|
273
|
+
kind: "unavailable",
|
|
274
|
+
code: "E-324",
|
|
275
|
+
reason: ensured.reason
|
|
276
|
+
};
|
|
277
|
+
const link = abortLink(spec.signal, spec.timeoutMs);
|
|
278
|
+
let activation;
|
|
279
|
+
try {
|
|
280
|
+
activation = await subagents.startActivation({
|
|
281
|
+
provider: "spawn",
|
|
282
|
+
label: spec.label,
|
|
283
|
+
delivery: "caller",
|
|
284
|
+
request: {
|
|
285
|
+
parent: spec.parent,
|
|
286
|
+
prompt: [{
|
|
287
|
+
type: "text",
|
|
288
|
+
text: spec.promptText
|
|
289
|
+
}],
|
|
290
|
+
agentPreset: ensured.presetId,
|
|
291
|
+
...spec.cwd !== void 0 ? { cwd: spec.cwd } : {},
|
|
292
|
+
...spec.maxDepth !== void 0 ? { maxDepth: spec.maxDepth } : {},
|
|
293
|
+
...spec.agentOptions !== void 0 ? { agentOptions: spec.agentOptions } : {}
|
|
294
|
+
},
|
|
295
|
+
signal: link.controller.signal
|
|
296
|
+
});
|
|
297
|
+
const result = await activation.result;
|
|
298
|
+
if (result.stopReason === "aborted") {
|
|
299
|
+
try {
|
|
300
|
+
await activation.dispose();
|
|
301
|
+
} catch {}
|
|
302
|
+
return { kind: "aborted" };
|
|
303
|
+
}
|
|
304
|
+
if (result.stopReason !== "completed") {
|
|
305
|
+
try {
|
|
306
|
+
await activation.dispose();
|
|
307
|
+
} catch {}
|
|
308
|
+
return {
|
|
309
|
+
kind: "unavailable",
|
|
310
|
+
code: "E-323",
|
|
311
|
+
reason: "round 1 did not complete: " + result.stopReason
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
const turn = {
|
|
315
|
+
childId: activation.childId,
|
|
316
|
+
stopReason: result.stopReason,
|
|
317
|
+
text: textOfValue(result.output),
|
|
318
|
+
...result.structured !== void 0 ? { structured: result.structured } : {},
|
|
319
|
+
...result.diagnostic !== void 0 ? { diagnostic: result.diagnostic } : {}
|
|
320
|
+
};
|
|
321
|
+
active.set(activation.childId, {
|
|
322
|
+
childId: activation.childId,
|
|
323
|
+
parent: spec.parent,
|
|
324
|
+
activation,
|
|
325
|
+
disposed: false
|
|
326
|
+
});
|
|
327
|
+
disposed.delete(activation.childId);
|
|
328
|
+
return {
|
|
329
|
+
kind: "ok",
|
|
330
|
+
childId: activation.childId,
|
|
331
|
+
turn
|
|
332
|
+
};
|
|
333
|
+
} catch (error) {
|
|
334
|
+
if (activation !== void 0) try {
|
|
335
|
+
await activation.dispose();
|
|
336
|
+
} catch {}
|
|
337
|
+
if (link.controller.signal.aborted) return { kind: "aborted" };
|
|
338
|
+
return {
|
|
339
|
+
kind: "unavailable",
|
|
340
|
+
code: "E-323",
|
|
341
|
+
reason: messageOf(error)
|
|
342
|
+
};
|
|
343
|
+
} finally {
|
|
344
|
+
link.cleanup();
|
|
345
|
+
}
|
|
346
|
+
},
|
|
347
|
+
async continueTurn(childId, promptText, options) {
|
|
348
|
+
if (disposed.has(childId)) return {
|
|
349
|
+
kind: "unavailable",
|
|
350
|
+
code: "E-324",
|
|
351
|
+
reason: "conversation is disposed"
|
|
352
|
+
};
|
|
353
|
+
const current = active.get(childId);
|
|
354
|
+
if (current === void 0 || current.disposed) return {
|
|
355
|
+
kind: "unavailable",
|
|
356
|
+
code: "E-323",
|
|
357
|
+
reason: "no open conversation for " + childId
|
|
358
|
+
};
|
|
359
|
+
const subagents = serviceOf(ctx, "subagents");
|
|
360
|
+
if (subagents?.sendMessage === void 0) return {
|
|
361
|
+
kind: "unavailable",
|
|
362
|
+
code: "E-323",
|
|
363
|
+
reason: "subagents.sendMessage unavailable"
|
|
364
|
+
};
|
|
365
|
+
const link = abortLink(options?.signal, options?.timeoutMs ?? 12e4);
|
|
366
|
+
let pending;
|
|
367
|
+
try {
|
|
368
|
+
pending = waitForSubagentEnd(ctx, childId, link.controller.signal, options?.timeoutMs ?? 12e4);
|
|
369
|
+
await subagents.sendMessage(current.parent, childId, [{
|
|
370
|
+
type: "text",
|
|
371
|
+
text: promptText
|
|
372
|
+
}], { signal: link.controller.signal });
|
|
373
|
+
} catch (error) {
|
|
374
|
+
link.cleanup();
|
|
375
|
+
if (options?.signal?.aborted === true) return { kind: "aborted" };
|
|
376
|
+
return {
|
|
377
|
+
kind: "unavailable",
|
|
378
|
+
code: "E-323",
|
|
379
|
+
reason: messageOf(error)
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
try {
|
|
383
|
+
const info = await pending;
|
|
384
|
+
if (info.stopReason === "aborted") return { kind: "aborted" };
|
|
385
|
+
return {
|
|
386
|
+
kind: "ok",
|
|
387
|
+
childId,
|
|
388
|
+
turn: {
|
|
389
|
+
childId,
|
|
390
|
+
stopReason: info.stopReason ?? "completed",
|
|
391
|
+
text: textOfValue(info.lastAssistantMessage),
|
|
392
|
+
...info.diagnostic !== void 0 ? { diagnostic: info.diagnostic } : {}
|
|
393
|
+
}
|
|
394
|
+
};
|
|
395
|
+
} catch (error) {
|
|
396
|
+
if (options?.signal?.aborted === true) return { kind: "aborted" };
|
|
397
|
+
return {
|
|
398
|
+
kind: "unavailable",
|
|
399
|
+
code: "E-323",
|
|
400
|
+
reason: messageOf(error)
|
|
401
|
+
};
|
|
402
|
+
} finally {
|
|
403
|
+
link.cleanup();
|
|
404
|
+
}
|
|
405
|
+
},
|
|
406
|
+
async dispose(childId) {
|
|
407
|
+
const current = active.get(childId);
|
|
408
|
+
if (current === void 0 || current.disposed) {
|
|
409
|
+
disposed.add(childId);
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
current.disposed = true;
|
|
413
|
+
active.delete(childId);
|
|
414
|
+
disposed.add(childId);
|
|
415
|
+
try {
|
|
416
|
+
await current.activation.dispose();
|
|
417
|
+
} catch (error) {
|
|
418
|
+
try {
|
|
419
|
+
ctx.logger.warn("subagent conversation: dispose failed: " + messageOf(error));
|
|
420
|
+
} catch {}
|
|
421
|
+
}
|
|
182
422
|
}
|
|
183
423
|
};
|
|
184
424
|
}
|
|
@@ -53,6 +53,12 @@ export declare const HOST_ROUTES: {
|
|
|
53
53
|
readonly skillHealth: {
|
|
54
54
|
readonly rows: "/api/dsh-evolution/skill-health/rows";
|
|
55
55
|
};
|
|
56
|
+
readonly merge: {
|
|
57
|
+
readonly view: "/api/dsh-evolution/merge/view";
|
|
58
|
+
readonly rows: "/api/dsh-evolution/merge/rows";
|
|
59
|
+
readonly run: "/api/dsh-evolution/merge/run";
|
|
60
|
+
readonly runs: "/api/dsh-evolution/merge/runs";
|
|
61
|
+
};
|
|
56
62
|
readonly diagnostics: {
|
|
57
63
|
readonly view: "/api/dsh-evolution/diagnostics";
|
|
58
64
|
};
|
package/lib/types/http-routes.js
CHANGED
|
@@ -50,6 +50,12 @@ export const HOST_ROUTES = {
|
|
|
50
50
|
skillHealth: {
|
|
51
51
|
rows: '/api/dsh-evolution/skill-health/rows',
|
|
52
52
|
},
|
|
53
|
+
merge: {
|
|
54
|
+
view: '/api/dsh-evolution/merge/view',
|
|
55
|
+
rows: '/api/dsh-evolution/merge/rows',
|
|
56
|
+
run: '/api/dsh-evolution/merge/run',
|
|
57
|
+
runs: '/api/dsh-evolution/merge/runs',
|
|
58
|
+
},
|
|
53
59
|
diagnostics: {
|
|
54
60
|
view: '/api/dsh-evolution/diagnostics',
|
|
55
61
|
},
|
package/lib/types/index.d.ts
CHANGED
|
@@ -58,6 +58,9 @@ export * from './quality.ts';
|
|
|
58
58
|
export * from './redact.ts';
|
|
59
59
|
export * from './review-channel.ts';
|
|
60
60
|
export * from './run-registry.ts';
|
|
61
|
+
export * from './tree-lock.ts';
|
|
62
|
+
export * from './unit-undo-plan.ts';
|
|
63
|
+
export * from './unit-undo.ts';
|
|
61
64
|
export * from './reports.ts';
|
|
62
65
|
export * from './long-runs.ts';
|
|
63
66
|
export * from './serial.ts';
|
package/lib/types/index.js
CHANGED
|
@@ -59,6 +59,9 @@ export * from "./quality.js";
|
|
|
59
59
|
export * from "./redact.js";
|
|
60
60
|
export * from "./review-channel.js";
|
|
61
61
|
export * from "./run-registry.js";
|
|
62
|
+
export * from "./tree-lock.js";
|
|
63
|
+
export * from "./unit-undo-plan.js";
|
|
64
|
+
export * from "./unit-undo.js";
|
|
62
65
|
export * from "./reports.js";
|
|
63
66
|
export * from "./long-runs.js";
|
|
64
67
|
export * from "./serial.js";
|
package/lib/types/params.d.ts
CHANGED
|
@@ -48,7 +48,7 @@ export declare function readParam(carrier: object | undefined, id: string): unkn
|
|
|
48
48
|
* belongs to, the tier says who may change it. `internal` here is a group id with no rows today;
|
|
49
49
|
* the `why: 'internal'` a non-E3 row carries is a different value on the other axis (the reason it
|
|
50
50
|
* has no settings card) — the shared spelling is a coincidence, not a routing rule. */
|
|
51
|
-
export type ParamGroup = 'library' | 'write-caps' | 'review' | 'memory' | 'curator' | 'deployment' | 'internal';
|
|
51
|
+
export type ParamGroup = 'library' | 'write-caps' | 'review' | 'memory' | 'curator' | 'deployment' | 'merge' | 'internal';
|
|
52
52
|
/**
|
|
53
53
|
* Chinese display name per group, one entry per line (the scripts read this block
|
|
54
54
|
* by text, the same way they read PARAM_NAMESPACES). The raw group id is what
|
package/lib/types/params.js
CHANGED
|
@@ -91,6 +91,7 @@ export const PARAM_GROUP_LABELS = Object.freeze({
|
|
|
91
91
|
memory: '记忆',
|
|
92
92
|
curator: '技能整理',
|
|
93
93
|
deployment: '安装配置',
|
|
94
|
+
merge: '技能合并',
|
|
94
95
|
internal: '内部',
|
|
95
96
|
});
|
|
96
97
|
/**
|
|
@@ -203,6 +204,11 @@ export const PARAM_EXPOSURE = Object.freeze([
|
|
|
203
204
|
{ id: 'install.presetRowOverrides', group: 'deployment', tier: 'E4', authority: 'install', owner: 'scripts', applies: 'restart', docAnchor: 'PARAMETERS.md#deployment', summary: 'Preset row overrides the installer injects (row-overrides.json).', why: 'install' },
|
|
204
205
|
{ id: 'timeout.gateDefault', group: 'deployment', tier: 'E2', authority: 'install', owner: 'scripts', applies: 'none', docAnchor: 'PARAMETERS.md#deployment', summary: 'Gate budget: the vitest --testTimeout the gate table and the CI step carry (the number lives in testing-policy.timeouts).', why: 'internal' },
|
|
205
206
|
{ id: 'timeout.specDefault', group: 'deployment', tier: 'E2', authority: 'install', owner: 'scripts', applies: 'none', docAnchor: 'PARAMETERS.md#deployment', summary: 'Local spec budget: the vitest testTimeout the family config generates per leaf tier (the number lives in testing-policy.timeouts).', why: 'internal' },
|
|
207
|
+
{ id: 'mergeApproval', group: 'merge', tier: 'E3', authority: 'cordis', owner: 'evolution-merge', applies: 'live', docAnchor: 'PARAMETERS.md#merge', summary: 'Pause after round 1 and put the pair in the pending queue instead of authoring round 2 automatically.', label: '合并审批', hint: '开启后,第一轮判定完先停下、进入待批准清单;关闭则直接进入第二轮合并。', control: 'switch', unit: '', values: '', card: 'evolution-merge' },
|
|
208
|
+
{ id: 'mergeSkippedTtlDays', group: 'merge', tier: 'E3', authority: 'cordis', owner: 'evolution-merge', applies: 'live', docAnchor: 'PARAMETERS.md#merge', summary: 'Days a skipped pair stays out of screening; 0 means it never expires.', label: '无需合并保留天数', hint: '判定无需合并的对,多少天内不再参与筛选;0 表示一直保留。', control: 'number', unit: '天', values: '', card: 'evolution-merge' },
|
|
209
|
+
{ id: 'mergeRunTimeoutMinutes', group: 'merge', tier: 'E3', authority: 'cordis', owner: 'evolution-merge', applies: 'live', docAnchor: 'PARAMETERS.md#merge', summary: 'Wall-clock budget of one merge run; 0 means no limit.', label: '单次运行上限', hint: '一次合并运行最多持续多少分钟;0 表示不设上限。', control: 'number', unit: '分钟', values: '', card: 'evolution-merge' },
|
|
210
|
+
{ id: 'mergePairTimeoutMinutes', group: 'merge', tier: 'E2', authority: 'cordis', owner: 'evolution-merge', applies: 'none', docAnchor: 'PARAMETERS.md#merge', summary: 'Wall-clock budget of one pair round (a stuck child guard).', why: 'internal' },
|
|
211
|
+
{ id: 'mergeModelsRoot', group: 'merge', tier: 'E2', authority: 'cordis', owner: 'evolution-merge', applies: 'none', docAnchor: 'PARAMETERS.md#merge', summary: 'Local model root containing the int8 and pca64 model directories; empty falls back to SKILL_MERGE_MODELS_ROOT. No auto provisioning.', why: 'path' },
|
|
206
212
|
]);
|
|
207
213
|
/** The settings namespace each OWNER PACKAGE registers (design §7.2).
|
|
208
214
|
*
|
|
@@ -223,6 +229,7 @@ export const PARAM_NAMESPACES = Object.freeze({
|
|
|
223
229
|
// 0.21.0 (G3): the commands row's handoff switch is E3 — same reason as the entry above: the card
|
|
224
230
|
// and `/evolution params set` both resolve their settings namespace through this map.
|
|
225
231
|
'evolution-commands': 'evolution-commands',
|
|
232
|
+
'evolution-merge': 'evolution-merge',
|
|
226
233
|
});
|
|
227
234
|
/**
|
|
228
235
|
* The rows whose settings namespace CHANGED with the platform-line move (G3): the
|
|
@@ -20,7 +20,7 @@ export interface PlatformServiceProbe {
|
|
|
20
20
|
/** Where the probe runs. */
|
|
21
21
|
readonly side: 'host' | 'client';
|
|
22
22
|
}
|
|
23
|
-
/** The
|
|
23
|
+
/** The 26 probes, in service-name order (the guard checks this list against the tree). */
|
|
24
24
|
export declare const PLATFORM_SERVICE_PROBES: readonly PlatformServiceProbe[];
|
|
25
25
|
/**
|
|
26
26
|
* The HOST-side probes that do not resolve in this runtime — what `/evolution doctor` prints as
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* `client` probes run in the browser half, where the host has no view of the seats.
|
|
12
12
|
* @module @lmzhen/dsh-evolution-core
|
|
13
13
|
*/
|
|
14
|
-
/** The
|
|
14
|
+
/** The 26 probes, in service-name order (the guard checks this list against the tree). */
|
|
15
15
|
export const PLATFORM_SERVICE_PROBES = Object.freeze([
|
|
16
16
|
{ service: 'agents', feature: 'review and curator subagent wake-ups', side: 'host' },
|
|
17
17
|
{ service: 'approval', feature: 'the platform approval policy the approval row shadows', side: 'host' },
|
|
@@ -22,6 +22,7 @@ export const PLATFORM_SERVICE_PROBES = Object.freeze([
|
|
|
22
22
|
{ service: 'evolutionIo', feature: 'every durable read and write (the IO seam)', side: 'host' },
|
|
23
23
|
{ service: 'hmr', feature: 'the preset install read-merge-write commit (the platform HMR lock serializes it)', side: 'host' },
|
|
24
24
|
{ service: 'evolutionMemoryBudget', feature: 'the memory-budget divergence section of this report', side: 'host' },
|
|
25
|
+
{ service: 'evolutionMerge', feature: 'the merge command and its read-only panel routes', side: 'host' },
|
|
25
26
|
{ service: 'evolutionPolicy', feature: 'the policy snapshot that shadows row knobs', side: 'host' },
|
|
26
27
|
{ service: 'evolutionReplay', feature: '/evolution replay', side: 'host' },
|
|
27
28
|
{ service: 'evolutionState', feature: 'the state facade behind activity and replay', side: 'host' },
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* 0.19.0 (S2) / 0.19.3 (restructure): the family's run-lifecycle owner.
|
|
3
3
|
*
|
|
4
|
-
* A "run" is work that can outlive one interaction —
|
|
4
|
+
* A "run" is work that can outlive one interaction — the maintenance scan and merge workflows.
|
|
5
5
|
* Before this module the only record of such work was the requesting invocation:
|
|
6
6
|
* `lastMaintainAt` / `lastMaintainRunId` / `maintainInFlightSince` were
|
|
7
7
|
* module-scope globals in evolution-commands, and the plan text existed ONLY in
|
|
@@ -38,8 +38,17 @@
|
|
|
38
38
|
* @module
|
|
39
39
|
*/
|
|
40
40
|
import type { EvolutionIoLike } from './io.ts';
|
|
41
|
-
/** The kinds of long-running work the registry tracks.
|
|
42
|
-
export type RunKind = 'maintain';
|
|
41
|
+
/** The kinds of long-running work the registry tracks. */
|
|
42
|
+
export type RunKind = 'maintain' | 'merge';
|
|
43
|
+
/** Which merge command opened a run. `kind` stays the work family; this is the command. */
|
|
44
|
+
export type RunCommand = 'scan' | 'pair' | 'approve' | 'resume';
|
|
45
|
+
/** Coarse, user-visible stage of a run. */
|
|
46
|
+
export type RunStage = 'screening' | 'decide' | 'author' | 'commit' | 'settle';
|
|
47
|
+
/** Progress of a run's current stage. */
|
|
48
|
+
export interface RunProgress {
|
|
49
|
+
readonly done: number;
|
|
50
|
+
readonly total: number;
|
|
51
|
+
}
|
|
43
52
|
/** The four terminal-or-live states of a run. */
|
|
44
53
|
export type RunState = 'running' | 'succeeded' | 'failed' | 'cancelled';
|
|
45
54
|
/** One tracked run. `resultRef` is a path to the run's report, never its content. */
|
|
@@ -48,6 +57,11 @@ export interface RunRecord {
|
|
|
48
57
|
kind: RunKind;
|
|
49
58
|
state: RunState;
|
|
50
59
|
startedAt: number;
|
|
60
|
+
/** The merge command that opened this run, when the kind is merge. */
|
|
61
|
+
command?: RunCommand | undefined;
|
|
62
|
+
/** The stage the owner last reported. */
|
|
63
|
+
stage?: RunStage | undefined;
|
|
64
|
+
progress?: RunProgress | undefined;
|
|
51
65
|
/** The process that owns a RUNNING record. Required to tell an in-flight run of
|
|
52
66
|
* another plane (both planes share one DSH_HOME) from the orphan of a dead one;
|
|
53
67
|
* absent means the owner cannot be named, which is treated as gone. */
|
|
@@ -57,6 +71,10 @@ export interface RunRecord {
|
|
|
57
71
|
resultRef?: string | undefined;
|
|
58
72
|
/** Why the run did not succeed (an abort reason, a validation failure). */
|
|
59
73
|
failure?: string | undefined;
|
|
74
|
+
/** Set when a tree restore made this run's effects un-undoable. */
|
|
75
|
+
supersededAt?: number | undefined;
|
|
76
|
+
/** The run this run was spawned from (approve/resume after an earlier command). */
|
|
77
|
+
parentRunId?: string | undefined;
|
|
60
78
|
}
|
|
61
79
|
/** What a started run gets: its id and the signal its work must honour. */
|
|
62
80
|
export interface RunHandle {
|
|
@@ -65,6 +83,8 @@ export interface RunHandle {
|
|
|
65
83
|
readonly signal: AbortSignal;
|
|
66
84
|
/** When the run was registered — the same instant the record carries. */
|
|
67
85
|
readonly startedAt: number;
|
|
86
|
+
readonly command?: RunCommand | undefined;
|
|
87
|
+
readonly parentRunId?: string | undefined;
|
|
68
88
|
}
|
|
69
89
|
/** How a run ended, as reported by its owner. */
|
|
70
90
|
export interface RunOutcome {
|
|
@@ -90,15 +110,25 @@ export interface RunRegistry {
|
|
|
90
110
|
* lifetime starts when the caller answers "started", so a plane that reads that answer
|
|
91
111
|
* must find the run. The cost is one local index write on the ack path (milliseconds).
|
|
92
112
|
*/
|
|
93
|
-
begin(kind: RunKind
|
|
113
|
+
begin(kind: RunKind, seed?: {
|
|
114
|
+
command?: RunCommand;
|
|
115
|
+
total?: number;
|
|
116
|
+
parentRunId?: string;
|
|
117
|
+
}): Promise<RunHandle>;
|
|
94
118
|
/** Record a run's terminal state (first writer wins). */
|
|
95
119
|
settle(id: string, outcome: RunOutcome): Promise<void>;
|
|
96
|
-
/**
|
|
97
|
-
|
|
98
|
-
/**
|
|
99
|
-
|
|
120
|
+
/** Record the current stage/progress of a running run. */
|
|
121
|
+
stage(id: string, stage: RunStage, progress?: RunProgress): Promise<void>;
|
|
122
|
+
/** Mark runs as superseded by a tree restore; idempotent. */
|
|
123
|
+
supersede(ids: readonly string[]): Promise<void>;
|
|
124
|
+
/** Mark every run of a kind in the SHARED index (including foreign rows); returns the count. */
|
|
125
|
+
supersedeKind(kind: RunKind): Promise<number>;
|
|
126
|
+
/** The live run, optionally filtered by kind. */
|
|
127
|
+
inFlight(kind?: RunKind): RunRecord | undefined;
|
|
128
|
+
/** When the newest terminal run of a kind ended (`undefined` when none). */
|
|
129
|
+
lastSettledAt(kind?: RunKind): number | undefined;
|
|
100
130
|
/** Every tracked run, newest first (retained history: cap applied). */
|
|
101
|
-
runs(): readonly RunRecord[];
|
|
131
|
+
runs(kind?: RunKind): readonly RunRecord[];
|
|
102
132
|
/** One run by id, converged view included. */
|
|
103
133
|
find(id: string): RunRecord | undefined;
|
|
104
134
|
/** Ask a running run to stop; it settles as `cancelled`. @returns whether it was running. */
|
|
@@ -119,7 +149,7 @@ export interface RunRegistryOptions {
|
|
|
119
149
|
maxRecords?: number;
|
|
120
150
|
}
|
|
121
151
|
/** Current index schema. Bump only for a structural change. */
|
|
122
|
-
export declare const RUNS_SCHEMA_VERSION =
|
|
152
|
+
export declare const RUNS_SCHEMA_VERSION = 2;
|
|
123
153
|
/** Retained run records, newest first. */
|
|
124
154
|
export declare const DEFAULT_RUN_RECORDS = 50;
|
|
125
155
|
/** Index path for a home. */
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* 0.19.0 (S2) / 0.19.3 (restructure): the family's run-lifecycle owner.
|
|
3
3
|
*
|
|
4
|
-
* A "run" is work that can outlive one interaction —
|
|
4
|
+
* A "run" is work that can outlive one interaction — the maintenance scan and merge workflows.
|
|
5
5
|
* Before this module the only record of such work was the requesting invocation:
|
|
6
6
|
* `lastMaintainAt` / `lastMaintainRunId` / `maintainInFlightSince` were
|
|
7
7
|
* module-scope globals in evolution-commands, and the plan text existed ONLY in
|
|
@@ -42,7 +42,7 @@ import { join } from 'node:path';
|
|
|
42
42
|
import { clampedNumber } from "./numeric.js";
|
|
43
43
|
import { isProcessAlive, transactIo } from "./io.js";
|
|
44
44
|
/** Current index schema. Bump only for a structural change. */
|
|
45
|
-
export const RUNS_SCHEMA_VERSION =
|
|
45
|
+
export const RUNS_SCHEMA_VERSION = 2;
|
|
46
46
|
/** Retained run records, newest first. */
|
|
47
47
|
export const DEFAULT_RUN_RECORDS = 50;
|
|
48
48
|
/** Index path for a home. */
|
|
@@ -99,10 +99,17 @@ function parseIndex(raw) {
|
|
|
99
99
|
kind: record.kind,
|
|
100
100
|
state: record.state,
|
|
101
101
|
startedAt: record.startedAt,
|
|
102
|
+
...(record.command === undefined ? {} : { command: record.command }),
|
|
103
|
+
...(record.stage === undefined ? {} : { stage: record.stage }),
|
|
104
|
+
...(record.progress === undefined
|
|
105
|
+
? {}
|
|
106
|
+
: { progress: { done: record.progress.done, total: record.progress.total } }),
|
|
102
107
|
...(typeof record.pid === 'number' ? { pid: record.pid } : {}),
|
|
103
108
|
...(typeof record.endedAt === 'number' ? { endedAt: record.endedAt } : {}),
|
|
104
109
|
...(typeof record.resultRef === 'string' ? { resultRef: record.resultRef } : {}),
|
|
105
110
|
...(typeof record.failure === 'string' ? { failure: record.failure } : {}),
|
|
111
|
+
...(typeof record.supersededAt === 'number' ? { supersededAt: record.supersededAt } : {}),
|
|
112
|
+
...(typeof record.parentRunId === 'string' ? { parentRunId: record.parentRunId } : {}),
|
|
106
113
|
});
|
|
107
114
|
}
|
|
108
115
|
return { ok: true, records, foreign, ...(notes.length > 0 ? { note: notes.join('; ') } : {}) };
|
|
@@ -131,14 +138,20 @@ export function newRunRegistry(options) {
|
|
|
131
138
|
const orphanOf = (row) => ({ ...row, state: 'failed', endedAt: now(), failure: ORPHAN_FAILURE });
|
|
132
139
|
/** Every row this process knows, newest first. ALL reads go through here. */
|
|
133
140
|
const view = () => [...owned.values(), ...foreign.values()].sort((a, b) => b.startedAt - a.startedAt);
|
|
134
|
-
/** Retained history: every running row plus the newest terminal
|
|
141
|
+
/** Retained history: every running row plus the newest terminal rows per kind. */
|
|
135
142
|
const capped = (rows) => {
|
|
136
143
|
const live = rows.filter(row => row.state === 'running');
|
|
137
144
|
const done = rows
|
|
138
145
|
.filter(row => TERMINAL.has(row.state))
|
|
139
|
-
.sort((a, b) => (b.endedAt ?? b.startedAt) - (a.endedAt ?? a.startedAt))
|
|
140
|
-
|
|
141
|
-
|
|
146
|
+
.sort((a, b) => (b.endedAt ?? b.startedAt) - (a.endedAt ?? a.startedAt));
|
|
147
|
+
const perKind = new Map();
|
|
148
|
+
for (const row of done) {
|
|
149
|
+
const kept = perKind.get(row.kind) ?? [];
|
|
150
|
+
if (kept.length < maxRecords)
|
|
151
|
+
kept.push(row);
|
|
152
|
+
perKind.set(row.kind, kept);
|
|
153
|
+
}
|
|
154
|
+
return [...live, ...[...perKind.values()].flat()].sort((a, b) => b.startedAt - a.startedAt);
|
|
142
155
|
};
|
|
143
156
|
/**
|
|
144
157
|
* Update a surviving foreign row in place (identity stable), or add it.
|
|
@@ -159,9 +172,14 @@ export function newRunRegistry(options) {
|
|
|
159
172
|
if (TERMINAL.has(existing.state) && !TERMINAL.has(row.state))
|
|
160
173
|
return true;
|
|
161
174
|
// Clear what the new row dropped, so nothing is inherited from the previous view.
|
|
175
|
+
delete existing.command;
|
|
176
|
+
delete existing.stage;
|
|
177
|
+
delete existing.progress;
|
|
162
178
|
delete existing.endedAt;
|
|
163
179
|
delete existing.resultRef;
|
|
164
180
|
delete existing.failure;
|
|
181
|
+
delete existing.supersededAt;
|
|
182
|
+
delete existing.parentRunId;
|
|
165
183
|
Object.assign(existing, row);
|
|
166
184
|
return false;
|
|
167
185
|
};
|
|
@@ -269,18 +287,77 @@ export function newRunRegistry(options) {
|
|
|
269
287
|
].filter((note) => note !== undefined);
|
|
270
288
|
return { ok: true, ...(notes.length > 0 ? { note: notes.join('; ') } : {}) };
|
|
271
289
|
},
|
|
272
|
-
async begin(kind) {
|
|
290
|
+
async begin(kind, seed) {
|
|
273
291
|
const id = randomUUID();
|
|
274
292
|
const startedAt = now();
|
|
275
293
|
const controller = new AbortController();
|
|
276
294
|
controllers.set(id, controller);
|
|
277
295
|
// The pid is what lets ANOTHER process tell this live run from an orphan.
|
|
278
|
-
owned.set(id, {
|
|
296
|
+
owned.set(id, {
|
|
297
|
+
id,
|
|
298
|
+
kind,
|
|
299
|
+
state: 'running',
|
|
300
|
+
startedAt,
|
|
301
|
+
pid: process.pid,
|
|
302
|
+
...(seed?.command !== undefined ? { command: seed.command } : {}),
|
|
303
|
+
...(seed?.parentRunId !== undefined ? { parentRunId: seed.parentRunId } : {}),
|
|
304
|
+
...(seed?.total !== undefined ? { progress: { done: 0, total: seed.total } } : {}),
|
|
305
|
+
});
|
|
279
306
|
// Write-through (0.19.1, review P2): answering "started" promises a run another
|
|
280
307
|
// plane can read, so the row reaches the index before this resolves. `persist()`
|
|
281
308
|
// warns instead of throwing, so an unwritable home cannot withhold the handle.
|
|
282
309
|
await persist();
|
|
283
|
-
return {
|
|
310
|
+
return {
|
|
311
|
+
id,
|
|
312
|
+
kind,
|
|
313
|
+
signal: controller.signal,
|
|
314
|
+
startedAt,
|
|
315
|
+
...(seed?.command !== undefined ? { command: seed.command } : {}),
|
|
316
|
+
...(seed?.parentRunId !== undefined ? { parentRunId: seed.parentRunId } : {}),
|
|
317
|
+
};
|
|
318
|
+
},
|
|
319
|
+
async stage(id, stage, progress) {
|
|
320
|
+
const row = owned.get(id);
|
|
321
|
+
if (row === undefined || row.state !== 'running')
|
|
322
|
+
return;
|
|
323
|
+
row.stage = stage;
|
|
324
|
+
if (progress !== undefined)
|
|
325
|
+
row.progress = { done: progress.done, total: progress.total };
|
|
326
|
+
await persist();
|
|
327
|
+
},
|
|
328
|
+
async supersede(ids) {
|
|
329
|
+
let changed = false;
|
|
330
|
+
for (const id of new Set(ids)) {
|
|
331
|
+
const row = owned.get(id);
|
|
332
|
+
if (row === undefined || row.supersededAt !== undefined)
|
|
333
|
+
continue;
|
|
334
|
+
row.supersededAt = now();
|
|
335
|
+
changed = true;
|
|
336
|
+
}
|
|
337
|
+
if (changed)
|
|
338
|
+
await persist();
|
|
339
|
+
},
|
|
340
|
+
async supersedeKind(kind) {
|
|
341
|
+
let marked = 0;
|
|
342
|
+
await transactIo(options.io, path, (current) => {
|
|
343
|
+
const onDisk = parseIndex(current);
|
|
344
|
+
const merged = new Map();
|
|
345
|
+
for (const row of onDisk.records)
|
|
346
|
+
merged.set(row.id, row);
|
|
347
|
+
for (const [id, row] of owned)
|
|
348
|
+
merged.set(id, row);
|
|
349
|
+
for (const row of merged.values()) {
|
|
350
|
+
if (row.kind !== kind || row.supersededAt !== undefined)
|
|
351
|
+
continue;
|
|
352
|
+
row.supersededAt = now();
|
|
353
|
+
marked += 1;
|
|
354
|
+
}
|
|
355
|
+
const kept = capped([...merged.values()]);
|
|
356
|
+
const live = kept.filter(row => row.state === 'running');
|
|
357
|
+
const done = kept.filter(row => TERMINAL.has(row.state));
|
|
358
|
+
return JSON.stringify({ schemaVersion: RUNS_SCHEMA_VERSION, runs: [...live, ...done, ...onDisk.foreign] }, null, 2);
|
|
359
|
+
});
|
|
360
|
+
return marked;
|
|
284
361
|
},
|
|
285
362
|
async settle(id, outcome) {
|
|
286
363
|
const row = owned.get(id);
|
|
@@ -297,17 +374,14 @@ export function newRunRegistry(options) {
|
|
|
297
374
|
controllers.delete(id);
|
|
298
375
|
await persist();
|
|
299
376
|
},
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
// parameter (and the filter) together with the second `RunKind` member.
|
|
303
|
-
inFlight() {
|
|
304
|
-
return view().find(row => row.state === 'running');
|
|
377
|
+
inFlight(kind) {
|
|
378
|
+
return view().find(row => row.state === 'running' && (kind === undefined || row.kind === kind));
|
|
305
379
|
},
|
|
306
|
-
lastSettledAt() {
|
|
307
|
-
return view().find(row => TERMINAL.has(row.state))?.endedAt;
|
|
380
|
+
lastSettledAt(kind) {
|
|
381
|
+
return view().find(row => TERMINAL.has(row.state) && (kind === undefined || row.kind === kind))?.endedAt;
|
|
308
382
|
},
|
|
309
|
-
runs() {
|
|
310
|
-
return capped(view());
|
|
383
|
+
runs(kind) {
|
|
384
|
+
return kind === undefined ? capped(view()) : capped(view().filter(row => row.kind === kind));
|
|
311
385
|
},
|
|
312
386
|
find(id) {
|
|
313
387
|
return owned.get(id) ?? foreign.get(id);
|
|
@@ -296,7 +296,7 @@ export declare function loadVersionContent(root: string, io: EvolutionIoLike, na
|
|
|
296
296
|
*/
|
|
297
297
|
export declare function recordVersions(root: string, io: EvolutionIoLike, input: VersionRecordInput, keep: number): Promise<RecordedVersions | null>;
|
|
298
298
|
/** What kind of change one recorded version carries, as a CLOSED set. */
|
|
299
|
-
export type VersionActionKind = 'baseline' | 'create' | 'patch' | 'update' | 'restore' | 'delete' | 'archive' | 'consolidate' | 'restructure' | 'support-write' | 'support-remove' | 'other';
|
|
299
|
+
export type VersionActionKind = 'baseline' | 'create' | 'patch' | 'update' | 'restore' | 'delete' | 'archive' | 'consolidate' | 'restructure' | 'merge' | 'support-write' | 'support-remove' | 'other';
|
|
300
300
|
/**
|
|
301
301
|
* Classify one version action for display. Fail-closed like {@link versionTarget}: an action this
|
|
302
302
|
* module does not know reads as `other` rather than being guessed, and `tests/skill-history.spec.ts`
|