akemon 0.2.23 → 0.2.25
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/dist/engine-peripheral.js +118 -42
- package/dist/engine-queue.js +143 -0
- package/dist/engine-queue.test.js +99 -0
- package/dist/engine-routing.js +52 -0
- package/dist/engine-routing.test.js +122 -0
- package/dist/mcp-server.js +18 -23
- package/dist/memory-module.js +2 -0
- package/dist/metrics.js +30 -0
- package/dist/orphan-scan.js +79 -0
- package/dist/orphan-scan.test.js +81 -0
- package/dist/reflection-module.integration.test.js +180 -0
- package/dist/reflection-module.js +27 -29
- package/dist/reflection-module.test.js +66 -0
- package/dist/relay-client.js +17 -1
- package/dist/role-module.js +2 -2
- package/dist/role-module.test.js +208 -0
- package/dist/script-module.js +1 -0
- package/dist/server.js +68 -38
- package/dist/task-helpers.js +26 -0
- package/dist/task-helpers.test.js +88 -0
- package/dist/task-module.js +38 -25
- package/package.json +3 -2
|
@@ -13,6 +13,9 @@ import { readFile, writeFile, mkdir } from "fs/promises";
|
|
|
13
13
|
import { join, dirname, isAbsolute } from "path";
|
|
14
14
|
import { callAgent } from "./relay-client.js";
|
|
15
15
|
import { SIG, sig } from "./types.js";
|
|
16
|
+
import { updateMetrics, pushExecMs } from "./metrics.js";
|
|
17
|
+
import { sendFailureEvent } from "./relay-client.js";
|
|
18
|
+
import { resolveEngineConfig, } from "./engine-routing.js";
|
|
16
19
|
export const LLM_ENGINES = new Set(["claude", "codex", "opencode", "gemini", "raw"]);
|
|
17
20
|
// ---------------------------------------------------------------------------
|
|
18
21
|
// EnginePeripheral
|
|
@@ -24,11 +27,29 @@ export class EnginePeripheral {
|
|
|
24
27
|
tags = ["engine", "llm"];
|
|
25
28
|
config;
|
|
26
29
|
bus = null;
|
|
27
|
-
/** Engine mutual exclusion — only one engine process at a time */
|
|
28
|
-
busy = false;
|
|
29
|
-
busySince = 0;
|
|
30
30
|
/** Last execution trace (for error reporting) */
|
|
31
31
|
lastTrace = [];
|
|
32
|
+
/** Active CLI child processes — tracked so SIGTERM handler can kill them. */
|
|
33
|
+
activeChildren = new Set();
|
|
34
|
+
/**
|
|
35
|
+
* Send SIGKILL to all active child process groups. Called during daemon shutdown.
|
|
36
|
+
*
|
|
37
|
+
* NOTE: sends SIGKILL directly (no SIGTERM grace) — safe for stateless
|
|
38
|
+
* request/response CLIs. Must change to SIGTERM+3s+SIGKILL when Batch 5.1
|
|
39
|
+
* persistent-session mode lands (sessions need graceful teardown).
|
|
40
|
+
*/
|
|
41
|
+
killAllChildren() {
|
|
42
|
+
for (const child of this.activeChildren) {
|
|
43
|
+
if (!child.pid)
|
|
44
|
+
continue;
|
|
45
|
+
console.log(`[engine] shutdown: killing pgid=-${child.pid}`);
|
|
46
|
+
try {
|
|
47
|
+
process.kill(-child.pid, "SIGKILL");
|
|
48
|
+
}
|
|
49
|
+
catch { }
|
|
50
|
+
}
|
|
51
|
+
this.activeChildren.clear();
|
|
52
|
+
}
|
|
32
53
|
constructor(config) {
|
|
33
54
|
this.config = config;
|
|
34
55
|
this.id = `engine:${config.engine}`;
|
|
@@ -70,47 +91,34 @@ export class EnginePeripheral {
|
|
|
70
91
|
}, this.id);
|
|
71
92
|
}
|
|
72
93
|
// ---------------------------------------------------------------------------
|
|
73
|
-
// Engine mutex helpers
|
|
74
|
-
// ---------------------------------------------------------------------------
|
|
75
|
-
acquire() {
|
|
76
|
-
if (this.busy)
|
|
77
|
-
return false;
|
|
78
|
-
this.busy = true;
|
|
79
|
-
this.busySince = Date.now();
|
|
80
|
-
return true;
|
|
81
|
-
}
|
|
82
|
-
release() {
|
|
83
|
-
this.busy = false;
|
|
84
|
-
this.busySince = 0;
|
|
85
|
-
}
|
|
86
|
-
/** Watchdog: reset if stuck for > 10 min */
|
|
87
|
-
checkStuck() {
|
|
88
|
-
if (this.busy && this.busySince > 0 && Date.now() - this.busySince > 10 * 60 * 1000) {
|
|
89
|
-
console.log(`[watchdog] engine stuck for ${Math.round((Date.now() - this.busySince) / 1000)}s, force-resetting`);
|
|
90
|
-
this.release();
|
|
91
|
-
return true;
|
|
92
|
-
}
|
|
93
|
-
return false;
|
|
94
|
-
}
|
|
95
|
-
// ---------------------------------------------------------------------------
|
|
96
94
|
// Unified engine runner
|
|
97
95
|
// ---------------------------------------------------------------------------
|
|
98
|
-
async runEngine(task, allowAll, extraAllowedTools) {
|
|
99
|
-
const
|
|
100
|
-
|
|
101
|
-
|
|
96
|
+
async runEngine(task, allowAll, extraAllowedTools, signal, origin, routing) {
|
|
97
|
+
const entry = resolveEngineConfig(routing, origin);
|
|
98
|
+
const cfg = entry ? applyRoutingEntry(this.config, entry) : this.config;
|
|
99
|
+
if (origin && entry) {
|
|
100
|
+
console.log(`[engine] using ${cfg.engine}${cfg.model ? `/${cfg.model}` : ""} (origin=${origin})`);
|
|
101
|
+
}
|
|
102
|
+
const t0 = Date.now();
|
|
103
|
+
try {
|
|
104
|
+
if (cfg.engine === "raw") {
|
|
105
|
+
return await this.runRawEngine(task, cfg);
|
|
106
|
+
}
|
|
107
|
+
const cmd = buildEngineCommand(cfg.engine, cfg.model, allowAll ?? cfg.allowAll, extraAllowedTools);
|
|
108
|
+
return await runCommand(cmd.cmd, cmd.args, task, cfg.workdir, cmd.stdinMode, signal, this.activeChildren);
|
|
109
|
+
}
|
|
110
|
+
finally {
|
|
111
|
+
pushExecMs(Date.now() - t0);
|
|
102
112
|
}
|
|
103
|
-
const cmd = buildEngineCommand(engine, model, allowAll ?? this.config.allowAll, extraAllowedTools);
|
|
104
|
-
return runCommand(cmd.cmd, cmd.args, task, workdir, cmd.stdinMode);
|
|
105
113
|
}
|
|
106
114
|
// ---------------------------------------------------------------------------
|
|
107
115
|
// Raw engine: OpenAI-compatible API with tool call loop
|
|
108
116
|
// ---------------------------------------------------------------------------
|
|
109
|
-
async runRawEngine(task) {
|
|
110
|
-
const apiUrl = (
|
|
111
|
-
const modelName =
|
|
112
|
-
const maxRounds =
|
|
113
|
-
const apiKey =
|
|
117
|
+
async runRawEngine(task, cfg = this.config) {
|
|
118
|
+
const apiUrl = (cfg.rawApiUrl || "http://localhost:11434/v1") + "/chat/completions";
|
|
119
|
+
const modelName = cfg.model || "gemma4:4b";
|
|
120
|
+
const maxRounds = cfg.rawMaxRounds || 20;
|
|
121
|
+
const apiKey = cfg.rawApiKey || "";
|
|
114
122
|
console.log(`[raw] Task:\n${task}`);
|
|
115
123
|
const trace = [];
|
|
116
124
|
this.lastTrace = trace;
|
|
@@ -358,6 +366,25 @@ export const RAW_TOOLS = [
|
|
|
358
366
|
// ---------------------------------------------------------------------------
|
|
359
367
|
// CLI engine helpers (shared, non-class)
|
|
360
368
|
// ---------------------------------------------------------------------------
|
|
369
|
+
/**
|
|
370
|
+
* Build a local EngineConfig copy that merges in a routing entry's overrides.
|
|
371
|
+
* Resolves rawApiKeyEnv → rawApiKey from environment at call time.
|
|
372
|
+
* Never mutates the base config.
|
|
373
|
+
*/
|
|
374
|
+
function applyRoutingEntry(base, entry) {
|
|
375
|
+
const override = { engine: entry.engine };
|
|
376
|
+
if (entry.model !== undefined)
|
|
377
|
+
override.model = entry.model ?? undefined;
|
|
378
|
+
if (entry.rawApiUrl !== undefined)
|
|
379
|
+
override.rawApiUrl = entry.rawApiUrl;
|
|
380
|
+
if (entry.rawMaxRounds !== undefined)
|
|
381
|
+
override.rawMaxRounds = entry.rawMaxRounds;
|
|
382
|
+
if (entry.allowAll !== undefined)
|
|
383
|
+
override.allowAll = entry.allowAll;
|
|
384
|
+
if (entry.rawApiKeyEnv)
|
|
385
|
+
override.rawApiKey = process.env[entry.rawApiKeyEnv] ?? "";
|
|
386
|
+
return { ...base, ...override };
|
|
387
|
+
}
|
|
361
388
|
function buildEngineCommand(engine, model, allowAll, extraAllowedTools) {
|
|
362
389
|
switch (engine) {
|
|
363
390
|
case "claude": {
|
|
@@ -390,7 +417,7 @@ function buildEngineCommand(engine, model, allowAll, extraAllowedTools) {
|
|
|
390
417
|
return { cmd: engine, args: [], stdinMode: true };
|
|
391
418
|
}
|
|
392
419
|
}
|
|
393
|
-
function runCommand(cmd, args, task, cwd, stdinMode = true) {
|
|
420
|
+
function runCommand(cmd, args, task, cwd, stdinMode = true, signal, activeChildren) {
|
|
394
421
|
return new Promise((resolve, reject) => {
|
|
395
422
|
const { CLAUDECODE, ...cleanEnv } = process.env;
|
|
396
423
|
const finalArgs = stdinMode ? args : [...args, task];
|
|
@@ -399,8 +426,39 @@ function runCommand(cmd, args, task, cwd, stdinMode = true) {
|
|
|
399
426
|
cwd,
|
|
400
427
|
env: cleanEnv,
|
|
401
428
|
stdio: [stdinMode ? "pipe" : "ignore", "pipe", "pipe"],
|
|
402
|
-
|
|
429
|
+
detached: true, // child becomes process-group leader; enables pgid kill
|
|
403
430
|
});
|
|
431
|
+
if (activeChildren) {
|
|
432
|
+
activeChildren.add(child);
|
|
433
|
+
updateMetrics({ engine_children_active: activeChildren.size });
|
|
434
|
+
}
|
|
435
|
+
// Abort → SIGTERM to process group, then SIGKILL after grace period.
|
|
436
|
+
// Using -pid (negative) sends the signal to the entire process group,
|
|
437
|
+
// so any sub-forks spawned by the CLI are also terminated.
|
|
438
|
+
let aborted = false;
|
|
439
|
+
const onAbort = () => {
|
|
440
|
+
if (aborted || !child.pid)
|
|
441
|
+
return;
|
|
442
|
+
aborted = true;
|
|
443
|
+
console.log(`[${cmd}] aborted, killing pgid=-${child.pid}`);
|
|
444
|
+
sendFailureEvent("engine_abort", cmd, "engine subprocess aborted via signal");
|
|
445
|
+
try {
|
|
446
|
+
process.kill(-child.pid, "SIGTERM");
|
|
447
|
+
}
|
|
448
|
+
catch { }
|
|
449
|
+
setTimeout(() => {
|
|
450
|
+
try {
|
|
451
|
+
process.kill(-child.pid, "SIGKILL");
|
|
452
|
+
}
|
|
453
|
+
catch { }
|
|
454
|
+
}, 3000).unref();
|
|
455
|
+
};
|
|
456
|
+
if (signal) {
|
|
457
|
+
if (signal.aborted)
|
|
458
|
+
onAbort();
|
|
459
|
+
else
|
|
460
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
461
|
+
}
|
|
404
462
|
if (stdinMode && child.stdin) {
|
|
405
463
|
child.stdin.on("error", () => { });
|
|
406
464
|
child.stdin.write(task);
|
|
@@ -414,12 +472,22 @@ function runCommand(cmd, args, task, cwd, stdinMode = true) {
|
|
|
414
472
|
child.stderr?.on("data", (chunk) => {
|
|
415
473
|
stderr += chunk.toString();
|
|
416
474
|
});
|
|
417
|
-
child.on("close", (code) => {
|
|
418
|
-
|
|
475
|
+
child.on("close", (code, killSignal) => {
|
|
476
|
+
signal?.removeEventListener("abort", onAbort);
|
|
477
|
+
if (activeChildren) {
|
|
478
|
+
activeChildren.delete(child);
|
|
479
|
+
updateMetrics({ engine_children_active: activeChildren.size });
|
|
480
|
+
}
|
|
481
|
+
child.unref();
|
|
482
|
+
console.log(`[${cmd}] exit=${code}${killSignal ? ` signal=${killSignal}` : ""} stdout=${stdout.length}b stderr=${stderr.length}b`);
|
|
419
483
|
if (stderr)
|
|
420
484
|
console.log(`[${cmd}] stderr:\n${stderr}`);
|
|
421
485
|
if (stdout)
|
|
422
486
|
console.log(`[${cmd}] stdout:\n${stdout}`);
|
|
487
|
+
if (aborted) {
|
|
488
|
+
reject(new Error(`${cmd} aborted`));
|
|
489
|
+
return;
|
|
490
|
+
}
|
|
423
491
|
const output = stdout.trim();
|
|
424
492
|
if (output) {
|
|
425
493
|
resolve(output);
|
|
@@ -428,6 +496,14 @@ function runCommand(cmd, args, task, cwd, stdinMode = true) {
|
|
|
428
496
|
reject(new Error(`${cmd} exited with code ${code}, no stdout`));
|
|
429
497
|
}
|
|
430
498
|
});
|
|
431
|
-
child.on("error",
|
|
499
|
+
child.on("error", (err) => {
|
|
500
|
+
signal?.removeEventListener("abort", onAbort);
|
|
501
|
+
if (activeChildren) {
|
|
502
|
+
activeChildren.delete(child);
|
|
503
|
+
updateMetrics({ engine_children_active: activeChildren.size });
|
|
504
|
+
}
|
|
505
|
+
child.unref();
|
|
506
|
+
reject(err);
|
|
507
|
+
});
|
|
432
508
|
});
|
|
433
509
|
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* EngineQueue — priority-aware single-slot scheduler for the local engine.
|
|
3
|
+
*
|
|
4
|
+
* Only one engine subprocess may run at a time. All modules (orders, digestion,
|
|
5
|
+
* reflection, script activities, MCP ad-hoc calls, …) go through this queue so
|
|
6
|
+
* that higher-priority work does not starve behind background busywork.
|
|
7
|
+
*
|
|
8
|
+
* Ordering:
|
|
9
|
+
* - When the slot is free, acquire() returns immediately.
|
|
10
|
+
* - Otherwise callers wait; when the running job releases the slot, the
|
|
11
|
+
* highest-priority waiter wins (FIFO within the same priority).
|
|
12
|
+
* - If the caller's deadline elapses first, acquire() rejects with
|
|
13
|
+
* "Engine busy timeout" and the caller is removed from the queue.
|
|
14
|
+
*
|
|
15
|
+
* Priority semantics (keep in sync with types.ts ComputeRequest.priority):
|
|
16
|
+
* - high — user-waiting work (orders, user tasks, direct MCP calls)
|
|
17
|
+
* - normal — periodic self-maintenance that must not starve (digestion,
|
|
18
|
+
* reflection)
|
|
19
|
+
* - low — background enrichment (platform tasks, script activities,
|
|
20
|
+
* long-term, identity compression)
|
|
21
|
+
*/
|
|
22
|
+
import { updateMetrics } from "./metrics.js";
|
|
23
|
+
const PRIORITY_RANK = { high: 3, normal: 2, low: 1 };
|
|
24
|
+
/** Max simultaneous user_manual tasks allowed to hold or wait for a slot.
|
|
25
|
+
* Prevents more than this many claude CLI processes from queuing up. */
|
|
26
|
+
const DEFAULT_MAX_USER_MANUAL = 2;
|
|
27
|
+
export class EngineQueue {
|
|
28
|
+
busy = false;
|
|
29
|
+
busySince = 0;
|
|
30
|
+
waiters = [];
|
|
31
|
+
// User-manual concurrency gate
|
|
32
|
+
maxUserManualSlots;
|
|
33
|
+
userManualActive = 0;
|
|
34
|
+
userManualQueue = [];
|
|
35
|
+
constructor(maxUserManualSlots = DEFAULT_MAX_USER_MANUAL) {
|
|
36
|
+
this.maxUserManualSlots = maxUserManualSlots;
|
|
37
|
+
}
|
|
38
|
+
/** Acquire a user_manual slot before joining the engine queue.
|
|
39
|
+
* Callers MUST call releaseUserManualSlot() in a finally block. */
|
|
40
|
+
acquireUserManualSlot(deadlineMs) {
|
|
41
|
+
if (this.userManualActive < this.maxUserManualSlots) {
|
|
42
|
+
this.userManualActive++;
|
|
43
|
+
return Promise.resolve();
|
|
44
|
+
}
|
|
45
|
+
return new Promise((resolve, reject) => {
|
|
46
|
+
let timerRef;
|
|
47
|
+
const entry = {
|
|
48
|
+
resolve: () => { clearTimeout(timerRef); resolve(); },
|
|
49
|
+
reject: (err) => { clearTimeout(timerRef); reject(err); },
|
|
50
|
+
};
|
|
51
|
+
timerRef = setTimeout(() => {
|
|
52
|
+
const idx = this.userManualQueue.indexOf(entry);
|
|
53
|
+
if (idx >= 0)
|
|
54
|
+
this.userManualQueue.splice(idx, 1);
|
|
55
|
+
entry.reject(new Error("User manual slot timeout"));
|
|
56
|
+
}, deadlineMs);
|
|
57
|
+
this.userManualQueue.push(entry);
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
/** Release a user_manual slot and wake the next waiter. */
|
|
61
|
+
releaseUserManualSlot() {
|
|
62
|
+
const next = this.userManualQueue.shift();
|
|
63
|
+
if (next) {
|
|
64
|
+
// Transfer slot to the next waiter (active count unchanged)
|
|
65
|
+
next.resolve();
|
|
66
|
+
}
|
|
67
|
+
else {
|
|
68
|
+
this.userManualActive = Math.max(0, this.userManualActive - 1);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
/** Wait up to `deadlineMs` for the slot, then take it. */
|
|
72
|
+
acquire(priority, deadlineMs) {
|
|
73
|
+
if (!this.busy) {
|
|
74
|
+
this.busy = true;
|
|
75
|
+
this.busySince = Date.now();
|
|
76
|
+
return Promise.resolve();
|
|
77
|
+
}
|
|
78
|
+
return new Promise((resolve, reject) => {
|
|
79
|
+
const waiter = {
|
|
80
|
+
priority,
|
|
81
|
+
enqueuedAt: Date.now(),
|
|
82
|
+
resolve,
|
|
83
|
+
reject,
|
|
84
|
+
timer: setTimeout(() => {
|
|
85
|
+
const idx = this.waiters.indexOf(waiter);
|
|
86
|
+
if (idx >= 0) {
|
|
87
|
+
this.waiters.splice(idx, 1);
|
|
88
|
+
updateMetrics({ engine_queue_depth: this.waiters.length });
|
|
89
|
+
}
|
|
90
|
+
reject(new Error(`Engine busy timeout (${Math.round(deadlineMs / 60000)} min)`));
|
|
91
|
+
}, deadlineMs),
|
|
92
|
+
};
|
|
93
|
+
this.waiters.push(waiter);
|
|
94
|
+
updateMetrics({ engine_queue_depth: this.waiters.length });
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
/** Release the slot and hand it to the best waiter, if any. */
|
|
98
|
+
release() {
|
|
99
|
+
const next = this.pickNext();
|
|
100
|
+
if (!next) {
|
|
101
|
+
this.busy = false;
|
|
102
|
+
this.busySince = 0;
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
this.waiters.splice(this.waiters.indexOf(next), 1);
|
|
106
|
+
clearTimeout(next.timer);
|
|
107
|
+
this.busySince = Date.now();
|
|
108
|
+
updateMetrics({ engine_queue_depth: this.waiters.length });
|
|
109
|
+
next.resolve();
|
|
110
|
+
}
|
|
111
|
+
/** Take the slot synchronously (used by MCP fast-path when !isBusy). */
|
|
112
|
+
tryAcquire() {
|
|
113
|
+
if (this.busy)
|
|
114
|
+
return false;
|
|
115
|
+
this.busy = true;
|
|
116
|
+
this.busySince = Date.now();
|
|
117
|
+
return true;
|
|
118
|
+
}
|
|
119
|
+
isBusy() {
|
|
120
|
+
return this.busy;
|
|
121
|
+
}
|
|
122
|
+
queueDepth() {
|
|
123
|
+
return this.waiters.length;
|
|
124
|
+
}
|
|
125
|
+
/** How long has the current holder held the slot, in ms. 0 if free. */
|
|
126
|
+
heldMs() {
|
|
127
|
+
return this.busy && this.busySince ? Date.now() - this.busySince : 0;
|
|
128
|
+
}
|
|
129
|
+
pickNext() {
|
|
130
|
+
if (this.waiters.length === 0)
|
|
131
|
+
return null;
|
|
132
|
+
let best = this.waiters[0];
|
|
133
|
+
for (let i = 1; i < this.waiters.length; i++) {
|
|
134
|
+
const w = this.waiters[i];
|
|
135
|
+
const wr = PRIORITY_RANK[w.priority];
|
|
136
|
+
const br = PRIORITY_RANK[best.priority];
|
|
137
|
+
if (wr > br || (wr === br && w.enqueuedAt < best.enqueuedAt)) {
|
|
138
|
+
best = w;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return best;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { describe, it } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { EngineQueue } from "./engine-queue.js";
|
|
4
|
+
// Helpers
|
|
5
|
+
const tick = () => new Promise((r) => setImmediate(r));
|
|
6
|
+
async function sleep(ms) {
|
|
7
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
8
|
+
}
|
|
9
|
+
describe("EngineQueue", () => {
|
|
10
|
+
it("free slot: acquire resolves immediately and isBusy becomes true", async () => {
|
|
11
|
+
const q = new EngineQueue();
|
|
12
|
+
assert.equal(q.isBusy(), false);
|
|
13
|
+
await q.acquire("high", 1000);
|
|
14
|
+
assert.equal(q.isBusy(), true);
|
|
15
|
+
q.release();
|
|
16
|
+
assert.equal(q.isBusy(), false);
|
|
17
|
+
});
|
|
18
|
+
it("tryAcquire: succeeds when free, returns false when busy", () => {
|
|
19
|
+
const q = new EngineQueue();
|
|
20
|
+
assert.equal(q.tryAcquire(), true);
|
|
21
|
+
assert.equal(q.isBusy(), true);
|
|
22
|
+
assert.equal(q.tryAcquire(), false);
|
|
23
|
+
q.release();
|
|
24
|
+
});
|
|
25
|
+
it("priority ordering: high waiter beats normal when slot is released", async () => {
|
|
26
|
+
const q = new EngineQueue();
|
|
27
|
+
await q.acquire("high", 1000); // take the slot
|
|
28
|
+
const order = [];
|
|
29
|
+
const p1 = q.acquire("normal", 2000).then(() => { order.push("normal"); q.release(); });
|
|
30
|
+
await tick();
|
|
31
|
+
const p2 = q.acquire("high", 2000).then(() => { order.push("high"); q.release(); });
|
|
32
|
+
await tick();
|
|
33
|
+
assert.equal(q.queueDepth(), 2);
|
|
34
|
+
q.release(); // hand off to highest-priority waiter
|
|
35
|
+
await Promise.all([p1, p2]);
|
|
36
|
+
assert.deepEqual(order, ["high", "normal"]);
|
|
37
|
+
});
|
|
38
|
+
it("FIFO within same priority: earlier enqueuer wins", async () => {
|
|
39
|
+
const q = new EngineQueue();
|
|
40
|
+
await q.acquire("high", 1000);
|
|
41
|
+
const order = [];
|
|
42
|
+
const p1 = q.acquire("normal", 2000).then(() => { order.push("first"); q.release(); });
|
|
43
|
+
await sleep(5); // ensure different enqueuedAt timestamps
|
|
44
|
+
const p2 = q.acquire("normal", 2000).then(() => { order.push("second"); q.release(); });
|
|
45
|
+
await tick();
|
|
46
|
+
q.release();
|
|
47
|
+
await Promise.all([p1, p2]);
|
|
48
|
+
assert.deepEqual(order, ["first", "second"]);
|
|
49
|
+
});
|
|
50
|
+
it("deadline timeout: waiter is removed and rejects with busy-timeout error", async () => {
|
|
51
|
+
const q = new EngineQueue();
|
|
52
|
+
await q.acquire("high", 1000); // hold the slot
|
|
53
|
+
let caught = null;
|
|
54
|
+
const p = q.acquire("low", 30).catch((e) => { caught = e; });
|
|
55
|
+
await sleep(60); // let the 30ms deadline fire
|
|
56
|
+
assert.equal(q.queueDepth(), 0, "waiter must be removed after timeout");
|
|
57
|
+
await p;
|
|
58
|
+
assert.ok(caught !== null && typeof caught === "object", "should have rejected with an Error");
|
|
59
|
+
const msg = caught.message;
|
|
60
|
+
assert.ok(msg.includes("Engine busy timeout"), msg);
|
|
61
|
+
q.release();
|
|
62
|
+
});
|
|
63
|
+
it("release with no waiters makes slot free", () => {
|
|
64
|
+
const q = new EngineQueue();
|
|
65
|
+
assert.equal(q.tryAcquire(), true);
|
|
66
|
+
q.release();
|
|
67
|
+
assert.equal(q.isBusy(), false);
|
|
68
|
+
assert.equal(q.heldMs(), 0);
|
|
69
|
+
});
|
|
70
|
+
it("queueDepth tracks waiters correctly", async () => {
|
|
71
|
+
const q = new EngineQueue();
|
|
72
|
+
await q.acquire("high", 1000);
|
|
73
|
+
assert.equal(q.queueDepth(), 0);
|
|
74
|
+
const p1 = q.acquire("normal", 2000);
|
|
75
|
+
await tick();
|
|
76
|
+
assert.equal(q.queueDepth(), 1);
|
|
77
|
+
const p2 = q.acquire("low", 2000);
|
|
78
|
+
await tick();
|
|
79
|
+
assert.equal(q.queueDepth(), 2);
|
|
80
|
+
q.release(); // hand to normal (higher priority)
|
|
81
|
+
await tick();
|
|
82
|
+
assert.equal(q.queueDepth(), 1);
|
|
83
|
+
const holder = await p1; // p1 resolved — release it
|
|
84
|
+
void holder; // suppress unused warning
|
|
85
|
+
q.release();
|
|
86
|
+
await p2;
|
|
87
|
+
q.release();
|
|
88
|
+
assert.equal(q.queueDepth(), 0);
|
|
89
|
+
});
|
|
90
|
+
it("heldMs: returns 0 when free, positive when busy", async () => {
|
|
91
|
+
const q = new EngineQueue();
|
|
92
|
+
assert.equal(q.heldMs(), 0);
|
|
93
|
+
await q.acquire("high", 1000);
|
|
94
|
+
await sleep(10);
|
|
95
|
+
assert.ok(q.heldMs() >= 10, `heldMs should be >= 10, got ${q.heldMs()}`);
|
|
96
|
+
q.release();
|
|
97
|
+
assert.equal(q.heldMs(), 0);
|
|
98
|
+
});
|
|
99
|
+
});
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* engine-routing.ts — pure helpers for origin-based engine selection.
|
|
3
|
+
*
|
|
4
|
+
* Three exported pure functions (each independently unit-tested):
|
|
5
|
+
* resolveEngineConfig — picks which engine/model to use for a given origin
|
|
6
|
+
* deriveChildOrigin — returns the origin a child/sub-task should carry
|
|
7
|
+
* downgradeForRetry — downgrades any origin to "retry" when a task retries
|
|
8
|
+
*/
|
|
9
|
+
/**
|
|
10
|
+
* Resolve which engine routing entry to use for a given origin.
|
|
11
|
+
*
|
|
12
|
+
* Lookup order:
|
|
13
|
+
* 1. routing[origin] (exact match)
|
|
14
|
+
* 2. routing.default (fallback)
|
|
15
|
+
* 3. null (no routing configured → caller uses base engine config)
|
|
16
|
+
*
|
|
17
|
+
* Backward-compatible: if routing is undefined/null, returns null, meaning the
|
|
18
|
+
* caller should use whatever engine is already in the base EngineConfig.
|
|
19
|
+
*/
|
|
20
|
+
export function resolveEngineConfig(routing, origin) {
|
|
21
|
+
if (!routing)
|
|
22
|
+
return null;
|
|
23
|
+
if (origin) {
|
|
24
|
+
const exact = routing[origin];
|
|
25
|
+
if (exact)
|
|
26
|
+
return exact;
|
|
27
|
+
}
|
|
28
|
+
return routing.default ?? null;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Derive the origin that a child task should carry.
|
|
32
|
+
*
|
|
33
|
+
* "Human contamination" rule: human intent does NOT cross agent boundaries.
|
|
34
|
+
* Regardless of what the parent's origin is, any task spawned for/from another
|
|
35
|
+
* agent is always "platform" on the receiving side.
|
|
36
|
+
*
|
|
37
|
+
* Example: user_manual order → agent A calls agent B via MCP →
|
|
38
|
+
* agent B's resulting order has origin "platform", not "user_manual".
|
|
39
|
+
*/
|
|
40
|
+
export function deriveChildOrigin(_parentOrigin) {
|
|
41
|
+
return "platform";
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Downgrade the origin when a task enters the retry path.
|
|
45
|
+
*
|
|
46
|
+
* Retries must not consume the subscription CLI budget even if the original
|
|
47
|
+
* task was user_manual. Downgrading to "retry" lets the routing table send
|
|
48
|
+
* them to a cheaper API engine.
|
|
49
|
+
*/
|
|
50
|
+
export function downgradeForRetry(_origin) {
|
|
51
|
+
return "retry";
|
|
52
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import { resolveEngineConfig, deriveChildOrigin, downgradeForRetry, } from "./engine-routing.js";
|
|
4
|
+
// ---------------------------------------------------------------------------
|
|
5
|
+
// resolveEngineConfig
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
describe("resolveEngineConfig", () => {
|
|
8
|
+
const claudeEntry = { engine: "claude", model: "claude-opus-4-5" };
|
|
9
|
+
const rawEntry = { engine: "raw", rawApiUrl: "https://api.deepseek.com/v1", model: "deepseek-chat", rawApiKeyEnv: "DEEPSEEK_API_KEY" };
|
|
10
|
+
const defaultEntry = { engine: "raw", rawApiUrl: "https://api.anthropic.com/v1", model: "claude-haiku-4-5", rawApiKeyEnv: "ANTHROPIC_API_KEY" };
|
|
11
|
+
it("returns exact origin entry when routing has that origin", () => {
|
|
12
|
+
const routing = {
|
|
13
|
+
user_manual: claudeEntry,
|
|
14
|
+
platform: rawEntry,
|
|
15
|
+
default: defaultEntry,
|
|
16
|
+
};
|
|
17
|
+
const result = resolveEngineConfig(routing, "user_manual");
|
|
18
|
+
assert.deepEqual(result, claudeEntry);
|
|
19
|
+
});
|
|
20
|
+
it("returns platform entry for platform origin", () => {
|
|
21
|
+
const routing = {
|
|
22
|
+
user_manual: claudeEntry,
|
|
23
|
+
platform: rawEntry,
|
|
24
|
+
default: defaultEntry,
|
|
25
|
+
};
|
|
26
|
+
const result = resolveEngineConfig(routing, "platform");
|
|
27
|
+
assert.deepEqual(result, rawEntry);
|
|
28
|
+
});
|
|
29
|
+
it("falls back to default when origin not in routing", () => {
|
|
30
|
+
const routing = {
|
|
31
|
+
user_manual: claudeEntry,
|
|
32
|
+
default: defaultEntry,
|
|
33
|
+
};
|
|
34
|
+
// self_cycle not in routing → fallback to default
|
|
35
|
+
const result = resolveEngineConfig(routing, "self_cycle");
|
|
36
|
+
assert.deepEqual(result, defaultEntry);
|
|
37
|
+
});
|
|
38
|
+
it("falls back to default when origin is undefined", () => {
|
|
39
|
+
const routing = { default: defaultEntry };
|
|
40
|
+
const result = resolveEngineConfig(routing, undefined);
|
|
41
|
+
assert.deepEqual(result, defaultEntry);
|
|
42
|
+
});
|
|
43
|
+
it("returns null when routing is undefined (backward-compat: use base config)", () => {
|
|
44
|
+
const result = resolveEngineConfig(undefined, "user_manual");
|
|
45
|
+
assert.equal(result, null);
|
|
46
|
+
});
|
|
47
|
+
it("returns null when routing is null", () => {
|
|
48
|
+
const result = resolveEngineConfig(null, "user_manual");
|
|
49
|
+
assert.equal(result, null);
|
|
50
|
+
});
|
|
51
|
+
it("returns null when routing has no matching entry and no default", () => {
|
|
52
|
+
const routing = { user_manual: claudeEntry };
|
|
53
|
+
// self_cycle not in routing, no default
|
|
54
|
+
const result = resolveEngineConfig(routing, "self_cycle");
|
|
55
|
+
assert.equal(result, null);
|
|
56
|
+
});
|
|
57
|
+
it("returns null when routing is empty object and origin is undefined", () => {
|
|
58
|
+
const result = resolveEngineConfig({}, undefined);
|
|
59
|
+
assert.equal(result, null);
|
|
60
|
+
});
|
|
61
|
+
it("retry origin resolves to its own routing entry when configured", () => {
|
|
62
|
+
const retryEntry = { engine: "raw", rawApiUrl: "https://api.deepseek.com/v1", model: "deepseek-chat" };
|
|
63
|
+
const routing = {
|
|
64
|
+
user_manual: claudeEntry,
|
|
65
|
+
retry: retryEntry,
|
|
66
|
+
default: defaultEntry,
|
|
67
|
+
};
|
|
68
|
+
const result = resolveEngineConfig(routing, "retry");
|
|
69
|
+
assert.deepEqual(result, retryEntry);
|
|
70
|
+
});
|
|
71
|
+
it("retry origin falls back to default when no retry entry configured", () => {
|
|
72
|
+
const routing = {
|
|
73
|
+
user_manual: claudeEntry,
|
|
74
|
+
default: defaultEntry,
|
|
75
|
+
};
|
|
76
|
+
const result = resolveEngineConfig(routing, "retry");
|
|
77
|
+
assert.deepEqual(result, defaultEntry);
|
|
78
|
+
});
|
|
79
|
+
it("reflection origin resolves correctly", () => {
|
|
80
|
+
const reflEntry = { engine: "raw", model: "gemma3:4b" };
|
|
81
|
+
const routing = { reflection: reflEntry, default: defaultEntry };
|
|
82
|
+
const result = resolveEngineConfig(routing, "reflection");
|
|
83
|
+
assert.deepEqual(result, reflEntry);
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
// ---------------------------------------------------------------------------
|
|
87
|
+
// downgradeForRetry
|
|
88
|
+
// ---------------------------------------------------------------------------
|
|
89
|
+
describe("downgradeForRetry", () => {
|
|
90
|
+
const origins = ["user_manual", "self_cycle", "platform", "retry", "reflection"];
|
|
91
|
+
it("always returns 'retry' regardless of input", () => {
|
|
92
|
+
for (const origin of origins) {
|
|
93
|
+
assert.equal(downgradeForRetry(origin), "retry", `downgradeForRetry(${origin}) should be 'retry'`);
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
it("user_manual + isRetry=true → 'retry' (not user_manual)", () => {
|
|
97
|
+
// This is the spec's explicit test case for the downgrade rule
|
|
98
|
+
const original = "user_manual";
|
|
99
|
+
const downgraded = downgradeForRetry(original);
|
|
100
|
+
assert.equal(downgraded, "retry");
|
|
101
|
+
assert.notEqual(downgraded, "user_manual");
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
// ---------------------------------------------------------------------------
|
|
105
|
+
// deriveChildOrigin
|
|
106
|
+
// ---------------------------------------------------------------------------
|
|
107
|
+
describe("deriveChildOrigin", () => {
|
|
108
|
+
const origins = ["user_manual", "self_cycle", "platform", "retry", "reflection"];
|
|
109
|
+
it("always returns 'platform' regardless of parent", () => {
|
|
110
|
+
for (const origin of origins) {
|
|
111
|
+
assert.equal(deriveChildOrigin(origin), "platform", `deriveChildOrigin(${origin}) should be 'platform'`);
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
it("user_manual parent does NOT propagate to child (anti-contamination rule)", () => {
|
|
115
|
+
const child = deriveChildOrigin("user_manual");
|
|
116
|
+
assert.equal(child, "platform");
|
|
117
|
+
assert.notEqual(child, "user_manual");
|
|
118
|
+
});
|
|
119
|
+
it("self_cycle parent → child is platform, not self_cycle", () => {
|
|
120
|
+
assert.equal(deriveChildOrigin("self_cycle"), "platform");
|
|
121
|
+
});
|
|
122
|
+
});
|