arisa 5.2.20 → 5.2.21

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.
@@ -0,0 +1,115 @@
1
+ import { mkdir, readdir, rename, rm, stat } from "node:fs/promises";
2
+ import path from "node:path";
3
+
4
+ const defaultRetentionMs = 24 * 60 * 60_000;
5
+ const defaultMaxCompleted = 2_048;
6
+ const operationBatchSize = 64;
7
+
8
+ function positiveInteger(value, fallback) {
9
+ return Number.isSafeInteger(value) && value > 0 ? value : fallback;
10
+ }
11
+
12
+ function resultId(file) {
13
+ return file.endsWith(".result.json") ? file.slice(0, -".result.json".length) : "";
14
+ }
15
+
16
+ function activeId(file) {
17
+ return file.replace(/\.(?:request|processing)\.json$/, "");
18
+ }
19
+
20
+ async function entries(directory) {
21
+ try {
22
+ return await readdir(directory, { withFileTypes: true });
23
+ } catch (error) {
24
+ if (error?.code === "ENOENT") return [];
25
+ throw error;
26
+ }
27
+ }
28
+
29
+ async function inBatches(items, operation) {
30
+ for (let index = 0; index < items.length; index += operationBatchSize) {
31
+ await Promise.all(items.slice(index, index + operationBatchSize).map(operation));
32
+ }
33
+ }
34
+
35
+ async function completedRecords(directory, location, directoryEntries) {
36
+ const records = directoryEntries
37
+ .filter((entry) => entry.isFile() && entry.name.endsWith(".result.json"))
38
+ .map((entry) => ({
39
+ id: resultId(entry.name),
40
+ name: entry.name,
41
+ file: path.join(directory, entry.name),
42
+ location,
43
+ mtimeMs: 0
44
+ }));
45
+ await inBatches(records, async (record) => {
46
+ record.mtimeMs = (await stat(record.file)).mtimeMs;
47
+ });
48
+ return records;
49
+ }
50
+
51
+ async function moveLegacyResult(record, resultsDir) {
52
+ const destination = path.join(resultsDir, record.name);
53
+ try {
54
+ await rename(record.file, destination);
55
+ } catch (error) {
56
+ if (error?.code !== "EEXIST") throw error;
57
+ await rm(record.file, { force: true });
58
+ }
59
+ }
60
+
61
+ export async function ensureDaemonJournal(paths) {
62
+ await Promise.all([
63
+ mkdir(paths.commandsDir, { recursive: true }),
64
+ mkdir(paths.resultsDir, { recursive: true })
65
+ ]);
66
+ }
67
+
68
+ export async function maintainDaemonJournal(paths, policy = {}, { now = Date.now } = {}) {
69
+ const startedAt = Date.now();
70
+ await ensureDaemonJournal(paths);
71
+ const [commandEntries, resultEntries] = await Promise.all([
72
+ entries(paths.commandsDir),
73
+ entries(paths.resultsDir)
74
+ ]);
75
+ const activeIds = new Set(commandEntries
76
+ .filter((entry) => entry.isFile() && /\.(?:request|processing)\.json$/.test(entry.name))
77
+ .map((entry) => activeId(entry.name)));
78
+ const completed = [
79
+ ...await completedRecords(paths.resultsDir, "results", resultEntries),
80
+ ...await completedRecords(paths.commandsDir, "legacy", commandEntries)
81
+ ].sort((left, right) => right.mtimeMs - left.mtimeMs || left.name.localeCompare(right.name));
82
+ const retentionMs = positiveInteger(policy.journalRetentionMs, defaultRetentionMs);
83
+ const maxCompleted = positiveInteger(policy.journalMaxCompleted, defaultMaxCompleted);
84
+ const cutoff = now() - retentionMs;
85
+ const seen = new Set();
86
+ const retained = [];
87
+ const removed = [];
88
+ let retainedCompleted = 0;
89
+
90
+ for (const record of completed) {
91
+ const duplicate = seen.has(record.id);
92
+ const protectedByActiveJob = activeIds.has(record.id);
93
+ const withinRetention = record.mtimeMs >= cutoff;
94
+ const withinLimit = retainedCompleted < maxCompleted;
95
+ if (!duplicate && (protectedByActiveJob || (withinRetention && withinLimit))) {
96
+ seen.add(record.id);
97
+ retained.push(record);
98
+ if (!protectedByActiveJob) retainedCompleted += 1;
99
+ } else {
100
+ removed.push(record);
101
+ }
102
+ }
103
+
104
+ await inBatches(removed, (record) => rm(record.file, { force: true }));
105
+ const legacy = retained.filter((record) => record.location === "legacy");
106
+ await inBatches(legacy, (record) => moveLegacyResult(record, paths.resultsDir));
107
+
108
+ return {
109
+ active: activeIds.size,
110
+ completed: retained.length,
111
+ migrated: legacy.length,
112
+ pruned: removed.length,
113
+ scanMs: Math.max(0, Date.now() - startedAt)
114
+ };
115
+ }
@@ -56,6 +56,7 @@ export function daemonPaths(toolNameOrOptions, scope) {
56
56
  instanceId: getDaemonInstanceId(identity.scope),
57
57
  root,
58
58
  commandsDir: path.join(root, "commands"),
59
+ resultsDir: path.join(root, "results"),
59
60
  pidFile: path.join(root, "daemon.pid"),
60
61
  metaFile: path.join(root, "daemon.meta.json"),
61
62
  statusFile: path.join(root, "status.json"),
@@ -186,6 +187,13 @@ export async function readDaemonDiagnostic({ toolName, scope, autoStart = false
186
187
  nextAt: status.nextRestartAt || null
187
188
  },
188
189
  disposition: daemonDisposition({ state, alive, autoStart: Boolean(autoStart), restartRequested }),
190
+ journal: status.journal && typeof status.journal === "object" ? {
191
+ active: Number(status.journal.active || 0),
192
+ completed: Number(status.journal.completed || 0),
193
+ migrated: Number(status.journal.migrated || 0),
194
+ pruned: Number(status.journal.pruned || 0),
195
+ scanMs: Number(status.journal.scanMs || 0)
196
+ } : null,
189
197
  updatedAt: status.updatedAt || null,
190
198
  logFile: paths.logFile
191
199
  };
@@ -275,7 +283,8 @@ export async function unregisterManagedDaemon(toolNameOrOptions, { scope } = {})
275
283
  rm(paths.startLockFile, { force: true }),
276
284
  rm(paths.capabilityFile, { force: true }),
277
285
  process.platform === "win32" ? Promise.resolve() : rm(paths.socketFile, { force: true }),
278
- rm(paths.commandsDir, { recursive: true, force: true })
286
+ rm(paths.commandsDir, { recursive: true, force: true }),
287
+ rm(paths.resultsDir, { recursive: true, force: true })
279
288
  ]);
280
289
  return { toolName: paths.toolName, scope: paths.scope, instanceId: paths.instanceId };
281
290
  }
@@ -10,7 +10,7 @@ export function daemonJobPaths(paths, id) {
10
10
  return {
11
11
  request: path.join(paths.commandsDir, `${id}.request.json`),
12
12
  processing: path.join(paths.commandsDir, `${id}.processing.json`),
13
- result: path.join(paths.commandsDir, `${id}.result.json`)
13
+ result: path.join(paths.resultsDir || paths.commandsDir, `${id}.result.json`)
14
14
  };
15
15
  }
16
16
 
@@ -1,5 +1,5 @@
1
1
  import net from "node:net";
2
- import { chmod, mkdir, readdir, rename, rm, unlink } from "node:fs/promises";
2
+ import { chmod, readdir, rename, rm, unlink } from "node:fs/promises";
3
3
  import {
4
4
  ensureDaemonCapability,
5
5
  readJson,
@@ -7,6 +7,7 @@ import {
7
7
  writeJson
8
8
  } from "./daemon-processes.js";
9
9
  import { loadDaemonPolicy } from "./daemon-policy.js";
10
+ import { ensureDaemonJournal, maintainDaemonJournal } from "./daemon-journal.js";
10
11
  import {
11
12
  DAEMON_CONTROL_FIELD,
12
13
  DAEMON_PROTOCOL_VERSION,
@@ -37,7 +38,7 @@ export function createDaemonWorker({ toolName, paths }) {
37
38
  let statusWrite = Promise.resolve();
38
39
 
39
40
  async function ensure() {
40
- await mkdir(paths.commandsDir, { recursive: true });
41
+ await ensureDaemonJournal(paths);
41
42
  }
42
43
 
43
44
  async function getPid() {
@@ -96,6 +97,7 @@ export function createDaemonWorker({ toolName, paths }) {
96
97
  streamBufferBytes: policy.streamBufferBytes || 1_048_576
97
98
  };
98
99
  const subscribers = new Map();
100
+ const deliveredSequences = new WeakMap();
99
101
  const activeJobs = new Map();
100
102
  const cancelledJobs = new Set();
101
103
  let lastActivity = Date.now();
@@ -103,21 +105,42 @@ export function createDaemonWorker({ toolName, paths }) {
103
105
  let exiting = false;
104
106
  let acceptingWork = true;
105
107
  let processRequested = false;
108
+ let journalMaintenance = Promise.resolve();
106
109
 
107
- await ensure();
110
+ function maintainJournal() {
111
+ const operation = journalMaintenance
112
+ .catch(() => {})
113
+ .then(() => maintainDaemonJournal(paths, policy));
114
+ journalMaintenance = operation;
115
+ return operation;
116
+ }
117
+
118
+ const journal = await maintainJournal();
108
119
  const capabilityToken = process.env.ARISA_DAEMON_CAPABILITY || await ensureDaemonCapability(paths);
109
120
  await writeStatus({
110
121
  state: "starting",
111
122
  pid: process.pid,
112
123
  heartbeatAt: new Date().toISOString(),
113
124
  supportsRecovery: typeof recover === "function",
125
+ journal,
114
126
  message: "Daemon work loop started; waiting for health check"
115
127
  });
116
128
 
129
+ async function sendFrame(socket, frame) {
130
+ const delivered = deliveredSequences.get(socket) || new Map();
131
+ if ((delivered.get(frame.jobId) || 0) >= frame.sequence) return true;
132
+ const sent = await writeDaemonSocketFrame(socket, frame, ipcLimits);
133
+ if (sent) {
134
+ delivered.set(frame.jobId, frame.sequence);
135
+ deliveredSequences.set(socket, delivered);
136
+ }
137
+ return sent;
138
+ }
139
+
117
140
  async function publish(frame) {
118
141
  const sockets = [...(subscribers.get(frame.jobId) || [])];
119
142
  for (const socket of sockets) {
120
- if (!(await writeDaemonSocketFrame(socket, frame, ipcLimits))) subscribers.get(frame.jobId)?.delete(socket);
143
+ if (!(await sendFrame(socket, frame))) subscribers.get(frame.jobId)?.delete(socket);
121
144
  }
122
145
  }
123
146
 
@@ -276,7 +299,7 @@ export function createDaemonWorker({ toolName, paths }) {
276
299
  subscribers.get(jobId).add(socket);
277
300
  subscribedJobs.add(jobId);
278
301
  readJson(daemonJobPaths(paths, jobId).result, null).then((result) => {
279
- if (result?.terminal) return writeDaemonSocketFrame(socket, result.terminal, ipcLimits);
302
+ if (result?.terminal) return sendFrame(socket, result.terminal);
280
303
  return processQueue();
281
304
  }).catch(() => socket.destroy());
282
305
  }
@@ -293,10 +316,19 @@ export function createDaemonWorker({ toolName, paths }) {
293
316
  const heartbeatTimer = setInterval(() => {
294
317
  writeStatus({ heartbeatAt: new Date().toISOString() }).catch(() => {});
295
318
  }, policy.heartbeatIntervalMs);
319
+ const journalTimer = setInterval(() => {
320
+ maintainJournal()
321
+ .then((journal) => writeStatus({ journal }))
322
+ .catch((error) => writeStatus({
323
+ lastError: { at: new Date().toISOString(), phase: "journal", message: error?.message || String(error) }
324
+ }));
325
+ }, policy.journalSweepIntervalMs || 5 * 60_000);
326
+ journalTimer.unref?.();
296
327
  const idleTimer = idleTimeoutMs > 0 ? setInterval(async () => {
297
328
  if (processing || exiting || Date.now() - lastActivity <= idleTimeoutMs) return;
298
329
  exiting = true;
299
330
  clearInterval(heartbeatTimer);
331
+ clearInterval(journalTimer);
300
332
  clearInterval(idleTimer);
301
333
  await beforeExit?.();
302
334
  await writeStatus({ state: "stopped", restartRequested: false, nextRestartAt: null, message: "Idle timeout reached" });
package/src/index.js CHANGED
@@ -15,7 +15,7 @@ process.env.ARISA_PACKAGE_DIR = arisaPackageDir;
15
15
 
16
16
  const args = process.argv.slice(2);
17
17
  const cli = parseCliArgs(args);
18
- const command = cli.positionals[0] || "run";
18
+ const command = cli.flags.help ? "help" : cli.positionals[0] || "run";
19
19
  const forceBootstrap = Boolean(cli.flags.bootstrap);
20
20
  const verbose = !cli.flags.silent;
21
21
  const serviceRunner = Boolean(cli.flags["service-runner"]);
@@ -226,7 +226,35 @@ async function runForeground() {
226
226
  }
227
227
  }
228
228
 
229
+ function printHelp() {
230
+ console.log([
231
+ "Usage: arisa [command] [options]",
232
+ "",
233
+ "Commands:",
234
+ " run Run Arisa in the foreground (default)",
235
+ " tui Open the terminal interface",
236
+ " start Start the background service",
237
+ " stop Stop the background service",
238
+ " restart Restart the background service",
239
+ " status Show background service status",
240
+ " log Show background service logs",
241
+ " flush Remove Arisa state while stopped",
242
+ " slave Manage a Slave host",
243
+ " help Show this help",
244
+ "",
245
+ "Options:",
246
+ " --help Show this help",
247
+ " --silent Reduce runtime logging",
248
+ " --bootstrap Reopen interactive setup"
249
+ ].join("\n"));
250
+ }
251
+
229
252
  async function main() {
253
+ if (command === "help") {
254
+ printHelp();
255
+ return;
256
+ }
257
+
230
258
  if (slaveCommand) {
231
259
  const { runSlaveCli } = await import("./runtime/slave-cli.js");
232
260
  const result = await runSlaveCli({
@@ -334,6 +362,12 @@ async function main() {
334
362
  return;
335
363
  }
336
364
 
365
+ if (command !== "run") {
366
+ const error = new Error(`Unknown Arisa command: ${command}`);
367
+ error.code = "ARISA_UNKNOWN_COMMAND";
368
+ throw error;
369
+ }
370
+
337
371
  await runForeground();
338
372
  }
339
373
 
@@ -25,7 +25,8 @@ export function createIpcSocketPath({ homeDir = arisaHomeDir, platform = process
25
25
  }
26
26
 
27
27
  export const arisaIpcSocketFile = createIpcSocketPath();
28
- export const tasksFile = path.join(stateDir, "tasks.json");
28
+ export const tasksFile = path.join(stateDir, "tasks.json"); // Legacy migration source; retained unchanged.
29
+ export const tasksDatabaseFile = path.join(stateDir, "tasks.sqlite");
29
30
  export const toolsDir = path.join(arisaHomeDir, "tools");
30
31
  export const chatsDir = path.join(arisaHomeDir, "chats");
31
32
  export const toolStateDir = path.join(stateDir, "tools");
@@ -2,7 +2,7 @@ import crypto from "node:crypto";
2
2
  import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
3
3
  import path from "node:path";
4
4
  import { readRecentLogLines } from "./log-viewer.js";
5
- import { arisaPackageDir, serviceLogFile, stateDir, tasksFile } from "../platform/paths.js";
5
+ import { arisaPackageDir, serviceLogFile, stateDir } from "../platform/paths.js";
6
6
 
7
7
  export const workerRecoveryReportFile = path.join(stateDir, "worker-recovery-report.json");
8
8
 
@@ -70,9 +70,12 @@ export function summarizeRecoveryEvidence(lines, report) {
70
70
  };
71
71
  }
72
72
 
73
- async function interruptedTaskCount(report, file = tasksFile) {
73
+ async function interruptedTaskCount(report, file) {
74
74
  try {
75
- const document = JSON.parse(await readFile(file, "utf8"));
75
+ // Explicit JSON input is retained for offline reports. Runtime reads the live DB.
76
+ const document = file
77
+ ? JSON.parse(await readFile(file, "utf8"))
78
+ : await new (await import("../core/tasks/task-store.js")).TaskStore().list();
76
79
  const tasks = Array.isArray(document) ? document : document.tasks || [];
77
80
  const occurredAt = new Date(report.occurredAt).getTime();
78
81
  return tasks.filter((task) => {
@@ -98,7 +101,7 @@ async function runtimeVersion() {
98
101
  export async function loadWorkerRecoveryReport({
99
102
  reportFile = workerRecoveryReportFile,
100
103
  logFile = serviceLogFile,
101
- taskFile = tasksFile,
104
+ taskFile,
102
105
  readLines = readRecentLogLines,
103
106
  getVersion = runtimeVersion
104
107
  } = {}) {
@@ -72,10 +72,11 @@ export function createTelegramTaskDispatcher({
72
72
  const agentTimeoutMs = boundedTimeout(taskTimeouts.agentTimeoutMs, 15 * 60_000);
73
73
  const eventTimeoutMs = boundedTimeout(taskTimeouts.eventTimeoutMs, 5 * 60_000);
74
74
 
75
- const runBackgroundTool = (toolName, chatId, args, label) => agentManager.runTurn({
76
- priority: "background",
77
- label
78
- }, () => agentManager.runTool({ name: toolName, request: { args }, chatId }));
75
+ const runHeadlessTool = (toolName, chatId, args) => agentManager.runTool({
76
+ name: toolName,
77
+ request: { args },
78
+ chatId
79
+ });
79
80
 
80
81
  function throwToolFailure(result, toolName, fallbackResolution) {
81
82
  const error = new Error(result?.error || `${toolName} failed`);
@@ -98,11 +99,10 @@ export function createTelegramTaskDispatcher({
98
99
  async function dispatchAgentTask(task, chatId) {
99
100
  if (!task.payload.prompt) throw new NonRetryableTaskError("agent_task missing prompt");
100
101
  if (task.authBlock?.toolName) {
101
- const probe = await runBackgroundTool(
102
+ const probe = await runHeadlessTool(
102
103
  task.authBlock.toolName,
103
104
  chatId,
104
- task.authBlock.probeArgs || {},
105
- `authentication probe ${task.authBlock.toolName}`
105
+ task.authBlock.probeArgs || {}
106
106
  );
107
107
  if (probe?.ok === false) throwToolFailure(probe, task.authBlock.toolName, task.authBlock);
108
108
  logger?.log("tasks", `authentication restored for ${task.authBlock.toolName} (task ${task.id})`);
@@ -156,7 +156,7 @@ export function createTelegramTaskDispatcher({
156
156
  if (!toolName) throw new NonRetryableTaskError("poll_tool missing toolName");
157
157
  logger?.log("tasks", `polling tool ${toolName} (task ${task.id}) for chat ${chatId}`);
158
158
 
159
- const runTool = (args) => runBackgroundTool(toolName, chatId, args, `poll tool ${toolName}`);
159
+ const runTool = (args) => runHeadlessTool(toolName, chatId, args);
160
160
 
161
161
  if (task.authBlock) {
162
162
  const probe = await runTool(task.authBlock.probeArgs || {});
@@ -25,6 +25,54 @@ test("interactive turns run before queued background turns without overlapping",
25
25
  assert.equal(coordinator.diagnostic().completed, 3);
26
26
  });
27
27
 
28
+ test("reserves a quiet window for interactive follow-ups before background work", async (t) => {
29
+ t.mock.timers.enable({ apis: ["setTimeout"] });
30
+ let now = 1_000;
31
+ const coordinator = new AgentTurnCoordinator({
32
+ config: { interactiveQuietMs: 100 },
33
+ now: () => now
34
+ });
35
+ const releaseFirstInteractive = await coordinator.acquire({ priority: "interactive", label: "first" });
36
+ releaseFirstInteractive();
37
+
38
+ let backgroundStarted = false;
39
+ const background = coordinator.acquire({ priority: "background", label: "background" }).then((release) => {
40
+ backgroundStarted = true;
41
+ return release;
42
+ });
43
+ assert.equal(backgroundStarted, false);
44
+
45
+ now = 1_050;
46
+ const releaseFollowUp = await coordinator.acquire({ priority: "interactive", label: "follow-up" });
47
+ assert.equal(coordinator.diagnostic().active.label, "follow-up");
48
+ releaseFollowUp();
49
+ await Promise.resolve();
50
+ assert.equal(backgroundStarted, false);
51
+
52
+ now = 1_150;
53
+ t.mock.timers.tick(100);
54
+ const releaseBackground = await background;
55
+ assert.equal(backgroundStarted, true);
56
+ releaseBackground();
57
+ });
58
+
59
+ test("reports wait metrics separately for interactive and background turns", async () => {
60
+ let now = 1_000;
61
+ const coordinator = new AgentTurnCoordinator({ now: () => now });
62
+ const releaseActive = await coordinator.acquire({ priority: "background" });
63
+ const interactive = coordinator.acquire({ priority: "interactive" });
64
+ now = 1_025;
65
+ releaseActive();
66
+ const releaseInteractive = await interactive;
67
+ releaseInteractive();
68
+
69
+ const diagnostic = coordinator.diagnostic();
70
+ assert.equal(diagnostic.priorities.background.completed, 1);
71
+ assert.equal(diagnostic.priorities.interactive.completed, 1);
72
+ assert.equal(diagnostic.priorities.interactive.maxWaitMs, 25);
73
+ assert.equal(diagnostic.priorities.interactive.averageWaitMs, 25);
74
+ });
75
+
28
76
  test("background turns expire safely before execution when their queue TTL elapses", async (t) => {
29
77
  t.mock.timers.enable({ apis: ["setTimeout"] });
30
78
  const coordinator = new AgentTurnCoordinator();
@@ -0,0 +1,52 @@
1
+ import assert from "node:assert/strict";
2
+ import { execFile } from "node:child_process";
3
+ import { mkdtemp, rm } from "node:fs/promises";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+ import test from "node:test";
7
+ import { promisify } from "node:util";
8
+ import { fileURLToPath } from "node:url";
9
+
10
+ const execFileAsync = promisify(execFile);
11
+ const packageDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
12
+ const entry = path.join(packageDir, "src", "index.js");
13
+
14
+ async function isolatedEnvironment() {
15
+ const home = await mkdtemp(path.join(os.tmpdir(), "arisa-cli-command-"));
16
+ return {
17
+ home,
18
+ env: { ...process.env, ARISA_HOME: home }
19
+ };
20
+ }
21
+
22
+ test("prints CLI help without starting the runtime", async (t) => {
23
+ const isolated = await isolatedEnvironment();
24
+ t.after(() => rm(isolated.home, { recursive: true, force: true }));
25
+
26
+ const { stdout, stderr } = await execFileAsync(process.execPath, [entry, "--help"], {
27
+ cwd: packageDir,
28
+ env: isolated.env
29
+ });
30
+
31
+ assert.match(stdout, /^Usage: arisa/m);
32
+ assert.match(stdout, /status\s+Show background service status/);
33
+ assert.equal(stderr, "");
34
+ });
35
+
36
+ test("rejects unknown CLI commands instead of starting the runtime", async (t) => {
37
+ const isolated = await isolatedEnvironment();
38
+ t.after(() => rm(isolated.home, { recursive: true, force: true }));
39
+
40
+ await assert.rejects(
41
+ () => execFileAsync(process.execPath, [entry, "doctor"], {
42
+ cwd: packageDir,
43
+ env: isolated.env
44
+ }),
45
+ (error) => {
46
+ assert.equal(error.code, 1);
47
+ assert.match(error.stderr, /Unknown Arisa command: doctor/);
48
+ assert.doesNotMatch(error.stderr, /loading config|validating Pi session/);
49
+ return true;
50
+ }
51
+ );
52
+ });
@@ -21,7 +21,10 @@ const policy = {
21
21
  restartBackoffMaxMs: 40,
22
22
  startupTimeoutMs: 2_000,
23
23
  stopTimeoutMs: 300,
24
- queuePollIntervalMs: 10
24
+ queuePollIntervalMs: 10,
25
+ journalRetentionMs: 24 * 60 * 60_000,
26
+ journalMaxCompleted: 128,
27
+ journalSweepIntervalMs: 60_000
25
28
  };
26
29
 
27
30
  await mkdir(path.join(homeDir, "state"), { recursive: true });
@@ -51,8 +54,10 @@ const {
51
54
  const { submitDaemonControl: directSubmitDaemonControl } = await import("../src/core/tools/daemon-client.js");
52
55
  const {
53
56
  DAEMON_EVENT_TYPES: directDaemonEventTypes,
54
- DAEMON_PROTOCOL_VERSION: directDaemonProtocolVersion
57
+ DAEMON_PROTOCOL_VERSION: directDaemonProtocolVersion,
58
+ daemonJobPaths
55
59
  } = await import("../src/core/tools/daemon-protocol.js");
60
+ const { maintainDaemonJournal } = await import("../src/core/tools/daemon-journal.js");
56
61
  const { createToolProcessSupervisor, formatDaemonOutcome } = await import("../src/runtime/tool-process-supervisor.js");
57
62
  const { superviseDaemon } = await import("../src/core/tools/daemon-health.js");
58
63
  const { ToolRegistry } = await import("../src/core/tools/tool-registry.js");
@@ -127,7 +132,7 @@ test("streams ordered daemon events and persists the terminal result", async ()
127
132
  assert.deepEqual(output, { echo: "done" });
128
133
  assert.deepEqual(events.map((event) => event.type), ["accepted", "progress", "chunk", "completed"]);
129
134
  assert.deepEqual(events.map((event) => event.sequence), [1, 2, 3, 4]);
130
- assert.ok((await readdir(runtime.paths.commandsDir)).some((file) => file.endsWith(".result.json")));
135
+ assert.ok((await readdir(runtime.paths.resultsDir)).some((file) => file.endsWith(".result.json")));
131
136
  await runtime.stop();
132
137
  });
133
138
 
@@ -140,7 +145,7 @@ test("cancels a timed-out job without restarting the shared daemon", async () =>
140
145
  );
141
146
 
142
147
  const terminal = await waitFor(async () => {
143
- const result = await readJson(path.join(runtime.paths.commandsDir, `${jobId}.result.json`), null);
148
+ const result = await readJson(daemonJobPaths(runtime.paths, jobId).result, null);
144
149
  return result?.terminal || null;
145
150
  });
146
151
  assert.equal(terminal.type, "failed");
@@ -192,6 +197,41 @@ test("recovers queued and accepted journal records after daemon start", async ()
192
197
  await runtime.stop();
193
198
  });
194
199
 
200
+ test("migrates and bounds completed daemon journal records outside the active queue", async () => {
201
+ const paths = daemonPaths({ toolName: "journal-test", scope: { type: "chat", chatId: "303" } });
202
+ await maintainDaemonJournal(paths);
203
+ for (let index = 0; index < 5; index += 1) {
204
+ await writeJson(path.join(paths.commandsDir, `legacy-${index}.result.json`), {
205
+ id: `legacy-${index}`,
206
+ terminal: { version: 1, jobId: `legacy-${index}`, type: "completed", sequence: 2, payload: { output: { index } } }
207
+ });
208
+ }
209
+ await writeJson(path.join(paths.commandsDir, "legacy-active.request.json"), {
210
+ id: "legacy-active",
211
+ status: "queued",
212
+ payload: { value: "active" }
213
+ });
214
+ await writeJson(path.join(paths.commandsDir, "legacy-active.result.json"), {
215
+ id: "legacy-active",
216
+ terminal: { version: 1, jobId: "legacy-active", type: "completed", sequence: 2, payload: { output: { active: true } } }
217
+ });
218
+
219
+ const journal = await maintainDaemonJournal(paths, {
220
+ journalRetentionMs: 60_000,
221
+ journalMaxCompleted: 2
222
+ });
223
+ const activeFiles = await readdir(paths.commandsDir);
224
+ const resultFiles = await readdir(paths.resultsDir);
225
+
226
+ assert.equal(journal.active, 1);
227
+ assert.equal(journal.completed, 3);
228
+ assert.equal(journal.migrated, 3);
229
+ assert.equal(journal.pruned, 3);
230
+ assert.deepEqual(activeFiles, ["legacy-active.request.json"]);
231
+ assert.equal(resultFiles.includes("legacy-active.result.json"), true);
232
+ assert.equal(resultFiles.length, 3);
233
+ });
234
+
195
235
  test("isolates daemon process files and context by chat scope", async () => {
196
236
  const first = runtimeFor({ type: "chat", chatId: "101" });
197
237
  const second = runtimeFor({ type: "chat", chatId: "202" });
@@ -40,6 +40,7 @@ test("merges partial exclusive turn coordinator overrides with defaults", () =>
40
40
  enabled: true,
41
41
  backgroundQueueTtlMs: 30_000,
42
42
  interactiveQueueTtlMs: 0,
43
+ interactiveQuietMs: 2_000,
43
44
  maxQueued: 100
44
45
  });
45
46
  });