akemon 0.2.24 → 0.2.26

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/context.js CHANGED
@@ -41,12 +41,14 @@ function parseConversation(content) {
41
41
  if (recentMatch) {
42
42
  const lines = recentMatch[1].split("\n");
43
43
  for (const line of lines) {
44
- const m = line.match(/^\[(.+?)\] (User|Agent): (.*)$/);
44
+ // Format: [ts] [kind] Role: text OR (legacy) [ts] Role: text
45
+ const m = line.match(/^\[(.+?)\] (?:\[(order)\] )?(User|Agent): (.*)$/);
45
46
  if (m) {
46
47
  rounds.push({
47
48
  ts: m[1],
48
- role: m[2].toLowerCase(),
49
- content: m[3],
49
+ kind: m[2] ?? "chat",
50
+ role: m[3].toLowerCase(),
51
+ content: m[4],
50
52
  });
51
53
  }
52
54
  else if (rounds.length > 0 && line !== "") {
@@ -67,8 +69,10 @@ export async function loadConversation(workdir, agentName, convId) {
67
69
  return { summary: "", rounds: [] };
68
70
  }
69
71
  }
70
- /** Append a single message to a conversation file. Creates file if needed. */
71
- export async function appendMessage(workdir, agentName, convId, role, message) {
72
+ /** Append a single message to a conversation file. Creates file if needed.
73
+ * kind="order" writes an explicit [order] tag so the UI can filter/style it.
74
+ * kind="chat" (default) omits the tag for backward-compatibility with old files. */
75
+ export async function appendMessage(workdir, agentName, convId, role, message, kind = "chat") {
72
76
  const dir = conversationsDir(workdir, agentName);
73
77
  await mkdir(dir, { recursive: true });
74
78
  const p = conversationPath(workdir, agentName, convId);
@@ -81,7 +85,8 @@ export async function appendMessage(workdir, agentName, convId, role, message) {
81
85
  content = "## Summary\n\n\n## Recent\n";
82
86
  }
83
87
  const ts = localNow();
84
- content = content.trimEnd() + "\n" + `[${ts}] ${role}: ${message}` + "\n";
88
+ const kindTag = kind === "order" ? "[order] " : "";
89
+ content = content.trimEnd() + "\n" + `[${ts}] ${kindTag}${role}: ${message}` + "\n";
85
90
  await writeFile(p, content);
86
91
  }
87
92
  /** Append a user+agent round to a conversation file. Creates file if needed. */
@@ -0,0 +1,90 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it, before, after } from "node:test";
3
+ import { mkdtemp, rm } from "node:fs/promises";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ // We import the functions we need to test. appendMessage and loadConversation
7
+ // need a workdir on disk; parseConversation is internal — tested via loadConversation.
8
+ import { appendMessage, loadConversation } from "./context.js";
9
+ const agentName = "test-agent";
10
+ // ---------------------------------------------------------------------------
11
+ // appendMessage + loadConversation round-trip
12
+ // ---------------------------------------------------------------------------
13
+ describe("appendMessage / loadConversation", () => {
14
+ let workdir = "";
15
+ before(async () => {
16
+ workdir = await mkdtemp(join(tmpdir(), "ctx-test-"));
17
+ });
18
+ after(async () => {
19
+ await rm(workdir, { recursive: true, force: true });
20
+ });
21
+ it("writes a chat message and reads it back with kind='chat'", async () => {
22
+ const convId = "test-chat";
23
+ await appendMessage(workdir, agentName, convId, "User", "hello", "chat");
24
+ const conv = await loadConversation(workdir, agentName, convId);
25
+ assert.equal(conv.rounds.length, 1);
26
+ assert.equal(conv.rounds[0].role, "user");
27
+ assert.equal(conv.rounds[0].content, "hello");
28
+ assert.equal(conv.rounds[0].kind, "chat");
29
+ });
30
+ it("writes an order message with [order] tag and reads kind='order'", async () => {
31
+ const convId = "test-order";
32
+ await appendMessage(workdir, agentName, convId, "User", "order request", "order");
33
+ await appendMessage(workdir, agentName, convId, "Agent", "order delivered", "order");
34
+ const conv = await loadConversation(workdir, agentName, convId);
35
+ assert.equal(conv.rounds.length, 2);
36
+ assert.equal(conv.rounds[0].kind, "order");
37
+ assert.equal(conv.rounds[0].role, "user");
38
+ assert.equal(conv.rounds[1].kind, "order");
39
+ assert.equal(conv.rounds[1].role, "agent");
40
+ });
41
+ it("defaults to kind='chat' when kind arg is omitted", async () => {
42
+ const convId = "test-default-kind";
43
+ await appendMessage(workdir, agentName, convId, "Agent", "hi there");
44
+ const conv = await loadConversation(workdir, agentName, convId);
45
+ assert.equal(conv.rounds[0].kind, "chat");
46
+ });
47
+ it("parses mixed chat + order rounds correctly", async () => {
48
+ const convId = "test-mixed";
49
+ await appendMessage(workdir, agentName, convId, "User", "chat message", "chat");
50
+ await appendMessage(workdir, agentName, convId, "User", "order task", "order");
51
+ await appendMessage(workdir, agentName, convId, "Agent", "chat reply", "chat");
52
+ await appendMessage(workdir, agentName, convId, "Agent", "order result", "order");
53
+ const conv = await loadConversation(workdir, agentName, convId);
54
+ assert.equal(conv.rounds.length, 4);
55
+ assert.equal(conv.rounds[0].kind, "chat");
56
+ assert.equal(conv.rounds[1].kind, "order");
57
+ assert.equal(conv.rounds[2].kind, "chat");
58
+ assert.equal(conv.rounds[3].kind, "order");
59
+ });
60
+ it("parses legacy file (no [kind] tag) as kind='chat' for backward-compat", async () => {
61
+ const convId = "test-legacy";
62
+ // Write a legacy-style file manually (no [order] tag)
63
+ const { mkdir, writeFile } = await import("node:fs/promises");
64
+ const dir = join(workdir, ".akemon", "agents", agentName, "conversations");
65
+ await mkdir(dir, { recursive: true });
66
+ const legacyContent = "## Summary\n\n\n## Recent\n[2026-04-23 10:00] User: old message\n[2026-04-23 10:01] Agent: old reply\n";
67
+ await writeFile(join(dir, `${convId}.md`), legacyContent);
68
+ const conv = await loadConversation(workdir, agentName, convId);
69
+ assert.equal(conv.rounds.length, 2);
70
+ assert.equal(conv.rounds[0].kind, "chat");
71
+ assert.equal(conv.rounds[1].kind, "chat");
72
+ });
73
+ it("[order] tag appears in the raw file content", async () => {
74
+ const convId = "test-tag-on-disk";
75
+ await appendMessage(workdir, agentName, convId, "User", "buy something", "order");
76
+ const { readFile: rf } = await import("node:fs/promises");
77
+ const dir = join(workdir, ".akemon", "agents", agentName, "conversations");
78
+ const raw = await rf(join(dir, `${convId}.md`), "utf-8");
79
+ assert.ok(raw.includes("[order] User: buy something"), `raw file should contain [order] tag: ${raw}`);
80
+ });
81
+ it("chat message does NOT have [order] tag in raw file", async () => {
82
+ const convId = "test-no-tag-on-disk";
83
+ await appendMessage(workdir, agentName, convId, "User", "just chatting", "chat");
84
+ const { readFile: rf } = await import("node:fs/promises");
85
+ const dir = join(workdir, ".akemon", "agents", agentName, "conversations");
86
+ const raw = await rf(join(dir, `${convId}.md`), "utf-8");
87
+ assert.ok(!raw.includes("[order]"), `chat line should NOT contain [order] tag: ${raw}`);
88
+ assert.ok(raw.includes("User: just chatting"));
89
+ });
90
+ });
@@ -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
@@ -26,6 +29,27 @@ export class EnginePeripheral {
26
29
  bus = null;
27
30
  /** Last execution trace (for error reporting) */
28
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
+ }
29
53
  constructor(config) {
30
54
  this.config = config;
31
55
  this.id = `engine:${config.engine}`;
@@ -69,22 +93,32 @@ export class EnginePeripheral {
69
93
  // ---------------------------------------------------------------------------
70
94
  // Unified engine runner
71
95
  // ---------------------------------------------------------------------------
72
- async runEngine(task, allowAll, extraAllowedTools, signal) {
73
- const { engine, model, workdir } = this.config;
74
- if (engine === "raw") {
75
- return this.runRawEngine(task);
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);
76
112
  }
77
- const cmd = buildEngineCommand(engine, model, allowAll ?? this.config.allowAll, extraAllowedTools);
78
- return runCommand(cmd.cmd, cmd.args, task, workdir, cmd.stdinMode, signal);
79
113
  }
80
114
  // ---------------------------------------------------------------------------
81
115
  // Raw engine: OpenAI-compatible API with tool call loop
82
116
  // ---------------------------------------------------------------------------
83
- async runRawEngine(task) {
84
- const apiUrl = (this.config.rawApiUrl || "http://localhost:11434/v1") + "/chat/completions";
85
- const modelName = this.config.model || "gemma4:4b";
86
- const maxRounds = this.config.rawMaxRounds || 20;
87
- const apiKey = this.config.rawApiKey || "";
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 || "";
88
122
  console.log(`[raw] Task:\n${task}`);
89
123
  const trace = [];
90
124
  this.lastTrace = trace;
@@ -332,6 +366,28 @@ export const RAW_TOOLS = [
332
366
  // ---------------------------------------------------------------------------
333
367
  // CLI engine helpers (shared, non-class)
334
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
+ // rawApiKeyEnv takes precedence over rawApiKey (env vars preferred for secrets)
385
+ if (entry.rawApiKeyEnv)
386
+ override.rawApiKey = process.env[entry.rawApiKeyEnv] ?? "";
387
+ else if (entry.rawApiKey !== undefined)
388
+ override.rawApiKey = entry.rawApiKey;
389
+ return { ...base, ...override };
390
+ }
335
391
  function buildEngineCommand(engine, model, allowAll, extraAllowedTools) {
336
392
  switch (engine) {
337
393
  case "claude": {
@@ -364,7 +420,7 @@ function buildEngineCommand(engine, model, allowAll, extraAllowedTools) {
364
420
  return { cmd: engine, args: [], stdinMode: true };
365
421
  }
366
422
  }
367
- function runCommand(cmd, args, task, cwd, stdinMode = true, signal) {
423
+ function runCommand(cmd, args, task, cwd, stdinMode = true, signal, activeChildren) {
368
424
  return new Promise((resolve, reject) => {
369
425
  const { CLAUDECODE, ...cleanEnv } = process.env;
370
426
  const finalArgs = stdinMode ? args : [...args, task];
@@ -373,23 +429,32 @@ function runCommand(cmd, args, task, cwd, stdinMode = true, signal) {
373
429
  cwd,
374
430
  env: cleanEnv,
375
431
  stdio: [stdinMode ? "pipe" : "ignore", "pipe", "pipe"],
432
+ detached: true, // child becomes process-group leader; enables pgid kill
376
433
  });
377
- // Abort → SIGTERM, then SIGKILL after a grace period so a hung engine can't
378
- // hold the slot past the caller's deadline.
434
+ if (activeChildren) {
435
+ activeChildren.add(child);
436
+ updateMetrics({ engine_children_active: activeChildren.size });
437
+ }
438
+ // Abort → SIGTERM to process group, then SIGKILL after grace period.
439
+ // Using -pid (negative) sends the signal to the entire process group,
440
+ // so any sub-forks spawned by the CLI are also terminated.
379
441
  let aborted = false;
380
442
  const onAbort = () => {
381
- if (aborted)
443
+ if (aborted || !child.pid)
382
444
  return;
383
445
  aborted = true;
384
- console.log(`[${cmd}] aborted, killing pid=${child.pid}`);
446
+ console.log(`[${cmd}] aborted, killing pgid=-${child.pid}`);
447
+ sendFailureEvent("engine_abort", cmd, "engine subprocess aborted via signal");
385
448
  try {
386
- child.kill("SIGTERM");
449
+ process.kill(-child.pid, "SIGTERM");
387
450
  }
388
451
  catch { }
389
- setTimeout(() => { try {
390
- child.kill("SIGKILL");
391
- }
392
- catch { } }, 3000).unref();
452
+ setTimeout(() => {
453
+ try {
454
+ process.kill(-child.pid, "SIGKILL");
455
+ }
456
+ catch { }
457
+ }, 3000).unref();
393
458
  };
394
459
  if (signal) {
395
460
  if (signal.aborted)
@@ -412,6 +477,11 @@ function runCommand(cmd, args, task, cwd, stdinMode = true, signal) {
412
477
  });
413
478
  child.on("close", (code, killSignal) => {
414
479
  signal?.removeEventListener("abort", onAbort);
480
+ if (activeChildren) {
481
+ activeChildren.delete(child);
482
+ updateMetrics({ engine_children_active: activeChildren.size });
483
+ }
484
+ child.unref();
415
485
  console.log(`[${cmd}] exit=${code}${killSignal ? ` signal=${killSignal}` : ""} stdout=${stdout.length}b stderr=${stderr.length}b`);
416
486
  if (stderr)
417
487
  console.log(`[${cmd}] stderr:\n${stderr}`);
@@ -431,6 +501,11 @@ function runCommand(cmd, args, task, cwd, stdinMode = true, signal) {
431
501
  });
432
502
  child.on("error", (err) => {
433
503
  signal?.removeEventListener("abort", onAbort);
504
+ if (activeChildren) {
505
+ activeChildren.delete(child);
506
+ updateMetrics({ engine_children_active: activeChildren.size });
507
+ }
508
+ child.unref();
434
509
  reject(err);
435
510
  });
436
511
  });
@@ -19,11 +19,55 @@
19
19
  * - low — background enrichment (platform tasks, script activities,
20
20
  * long-term, identity compression)
21
21
  */
22
+ import { updateMetrics } from "./metrics.js";
22
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;
23
27
  export class EngineQueue {
24
28
  busy = false;
25
29
  busySince = 0;
26
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
+ }
27
71
  /** Wait up to `deadlineMs` for the slot, then take it. */
28
72
  acquire(priority, deadlineMs) {
29
73
  if (!this.busy) {
@@ -39,12 +83,15 @@ export class EngineQueue {
39
83
  reject,
40
84
  timer: setTimeout(() => {
41
85
  const idx = this.waiters.indexOf(waiter);
42
- if (idx >= 0)
86
+ if (idx >= 0) {
43
87
  this.waiters.splice(idx, 1);
88
+ updateMetrics({ engine_queue_depth: this.waiters.length });
89
+ }
44
90
  reject(new Error(`Engine busy timeout (${Math.round(deadlineMs / 60000)} min)`));
45
91
  }, deadlineMs),
46
92
  };
47
93
  this.waiters.push(waiter);
94
+ updateMetrics({ engine_queue_depth: this.waiters.length });
48
95
  });
49
96
  }
50
97
  /** Release the slot and hand it to the best waiter, if any. */
@@ -58,6 +105,7 @@ export class EngineQueue {
58
105
  this.waiters.splice(this.waiters.indexOf(next), 1);
59
106
  clearTimeout(next.timer);
60
107
  this.busySince = Date.now();
108
+ updateMetrics({ engine_queue_depth: this.waiters.length });
61
109
  next.resolve();
62
110
  }
63
111
  /** Take the slot synchronously (used by MCP fast-path when !isBusy). */
@@ -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
+ });