arisa 5.2.7 → 5.2.17

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.
Files changed (45) hide show
  1. package/README.md +21 -2
  2. package/package.json +1 -1
  3. package/src/core/agent/agent-manager.js +79 -4
  4. package/src/core/agent/agent-session-lifecycle.js +29 -6
  5. package/src/core/agent/agent-turn-coordinator.js +143 -0
  6. package/src/core/agent/pi-capability-tools.js +7 -4
  7. package/src/core/agent/session-history-reader.js +168 -0
  8. package/src/core/agent/session-preload-migration.js +162 -0
  9. package/src/core/agent/session-rotation.js +29 -0
  10. package/src/core/agent/worker-tool-fanout.js +117 -0
  11. package/src/core/capabilities/capability-service.js +3 -2
  12. package/src/core/config/config-defaults.js +29 -0
  13. package/src/core/tasks/task-runner.js +8 -4
  14. package/src/core/tasks/task-store.js +47 -4
  15. package/src/core/tools/daemon-processes.js +7 -0
  16. package/src/core/tools/memory-pressure.js +2 -2
  17. package/src/core/tools/tool-registry.js +35 -7
  18. package/src/core/tools/weighted-resource-governor.js +7 -6
  19. package/src/official-tools.lock.json +121 -47
  20. package/src/runtime/doctor.js +20 -73
  21. package/src/runtime/obsolete-daemon-reaper.js +43 -0
  22. package/src/runtime/process-inspection.js +78 -0
  23. package/src/runtime/tool-process-supervisor.js +35 -8
  24. package/src/runtime/tui.js +1 -1
  25. package/src/transport/telegram/bot.js +3 -1
  26. package/src/transport/telegram/chat-queue.js +6 -2
  27. package/src/transport/telegram/task-dispatcher.js +36 -11
  28. package/src/transport/telegram/telegram-prompt-controller.js +17 -4
  29. package/test/agent-turn-coordinator.test.js +45 -0
  30. package/test/context-and-task-bounds.test.js +2 -1
  31. package/test/daemon-runtime.test.js +2 -4
  32. package/test/doctor.test.js +19 -0
  33. package/test/memory-pressure.test.js +7 -2
  34. package/test/obsolete-daemon-reaper.test.js +61 -0
  35. package/test/official-tool-dependencies.test.js +7 -2
  36. package/test/pi-compaction.test.js +21 -0
  37. package/test/session-history-reader.test.js +84 -0
  38. package/test/session-preload-migration.test.js +120 -0
  39. package/test/session-rotation.test.js +110 -0
  40. package/test/task-store.test.js +32 -0
  41. package/test/telegram-prompt-controller.test.js +2 -1
  42. package/test/telegram-task-dispatcher.test.js +66 -5
  43. package/test/tool-registry-run.test.js +10 -1
  44. package/test/weighted-resource-governor.test.js +28 -0
  45. package/test/worker-tool-fanout.test.js +79 -0
@@ -38,6 +38,14 @@ function failureDestination(task) {
38
38
  }
39
39
 
40
40
  function buildFailureNotice({ task, result, error }) {
41
+ if (result?.authBlockedNew === true) {
42
+ return [
43
+ "⚠️ Arisa automation paused for authentication",
44
+ `Tool: ${task.payload?.toolName || "unknown"}`,
45
+ `Reason: ${safeErrorSummary(error)}`,
46
+ `Next authentication check: ${result.runAt}`
47
+ ].join("\n");
48
+ }
41
49
  const uncertain = result?.status === "outcome_uncertain" || result?.lastOutcome === "outcome_uncertain";
42
50
  const recurring = result?.terminalFailure === true && result?.status === "pending";
43
51
  const lines = [
@@ -72,7 +80,8 @@ export function createTelegramTaskDispatcher({
72
80
  prompt: await buildAsyncTaskPrompt({ task, artifactStore, toolRegistry, resourceNotes, logger }),
73
81
  label: `scheduled task ${task.id}`,
74
82
  route: task.route,
75
- timeoutMs: agentTimeoutMs
83
+ timeoutMs: agentTimeoutMs,
84
+ priority: task.source?.toolName === "whatsapp-web" ? "interactive" : "background"
76
85
  });
77
86
  }
78
87
 
@@ -92,7 +101,8 @@ export function createTelegramTaskDispatcher({
92
101
  prompt: await buildAsyncEventPrompt(task, resourceNotes),
93
102
  label: `agent event ${task.id}`,
94
103
  route: task.route,
95
- timeoutMs: eventTimeoutMs
104
+ timeoutMs: eventTimeoutMs,
105
+ priority: task.source?.toolName === "process-retrospective" ? "background" : "interactive"
96
106
  });
97
107
  }
98
108
 
@@ -100,20 +110,35 @@ export function createTelegramTaskDispatcher({
100
110
  const toolName = task.payload?.toolName;
101
111
  if (!toolName) throw new NonRetryableTaskError("poll_tool missing toolName");
102
112
  logger?.log("tasks", `polling tool ${toolName} (task ${task.id}) for chat ${chatId}`);
103
- const result = await agentManager.runTool({
104
- name: toolName,
105
- request: { args: task.payload.args || {} },
106
- chatId
107
- });
108
- if (result?.ok === false) {
109
- const error = new Error(result.error || `poll_tool ${toolName} failed`);
110
- if (result.status === "needs_config") error.retryable = false;
111
- if (result.status === "outcome_uncertain") {
113
+
114
+ const runTool = (args) => agentManager.runTurn({
115
+ priority: "background",
116
+ label: `poll tool ${toolName}`
117
+ }, () => agentManager.runTool({ name: toolName, request: { args }, chatId }));
118
+
119
+ function throwFailure(result, fallbackResolution) {
120
+ const error = new Error(result?.error || `poll_tool ${toolName} failed`);
121
+ if (result?.status === "blocked_auth" || fallbackResolution) {
122
+ error.retryable = false;
123
+ error.authBlocked = true;
124
+ error.authResolution = result?.resolution || fallbackResolution;
125
+ } else if (result?.status === "needs_config") {
126
+ error.retryable = false;
127
+ } else if (result?.status === "outcome_uncertain") {
112
128
  error.retryable = false;
113
129
  error.outcomeUncertain = true;
114
130
  }
115
131
  throw error;
116
132
  }
133
+
134
+ if (task.authBlock) {
135
+ const probe = await runTool(task.authBlock.probeArgs || {});
136
+ if (probe?.ok === false) throwFailure(probe, task.authBlock);
137
+ logger?.log("tasks", `authentication restored for ${toolName} (task ${task.id})`);
138
+ }
139
+
140
+ const result = await runTool(task.payload.args || {});
141
+ if (result?.ok === false) throwFailure(result);
117
142
  }
118
143
 
119
144
  async function dispatchTask(task) {
@@ -157,8 +157,14 @@ export function createTelegramPromptController({
157
157
  });
158
158
  };
159
159
 
160
- if (ctx) return withTyping(ctx, work);
161
- return work();
160
+ return agentManager.runTurn({
161
+ priority: executionReceipt?.priority || "interactive",
162
+ label: executionReceipt?.label || `interactive prompt for ${sessionId}`,
163
+ queueTtlMs: executionReceipt?.queueTtlMs
164
+ }, () => {
165
+ executionReceipt?.start?.();
166
+ return ctx ? withTyping(ctx, work) : work();
167
+ });
162
168
  }
163
169
 
164
170
  function processChatPromptQueue({ chatId, prompt, label, ctx = null, beforeInitialPrompt, initialReceipt = null }) {
@@ -193,10 +199,17 @@ export function createTelegramPromptController({
193
199
  busyMessageMode = "queue",
194
200
  waitForExecution = false,
195
201
  onExecutionStart = null,
196
- coalesceQueued = false
202
+ coalesceQueued = false,
203
+ turnPriority = "interactive",
204
+ turnQueueTtlMs = undefined
197
205
  }) {
198
206
  const chatState = getChatState(chatId);
199
- const receipt = waitForExecution ? createPromptExecutionReceipt(onExecutionStart) : null;
207
+ const receipt = waitForExecution ? createPromptExecutionReceipt(onExecutionStart, {
208
+ priority: turnPriority,
209
+ label,
210
+ queueTtlMs: turnQueueTtlMs,
211
+ deferStart: true
212
+ }) : null;
200
213
 
201
214
  if (chatState.processing) {
202
215
  const incomingRoute = ctx ? contextRoute(ctx) : null;
@@ -0,0 +1,45 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { AgentTurnCoordinator } from "../src/core/agent/agent-turn-coordinator.js";
4
+
5
+ test("interactive turns run before queued background turns without overlapping", async () => {
6
+ const coordinator = new AgentTurnCoordinator();
7
+ const releaseActive = await coordinator.acquire({ priority: "background", label: "active background" });
8
+ let backgroundStarted = false;
9
+ const background = coordinator.acquire({ priority: "background", label: "queued background" }).then((release) => {
10
+ backgroundStarted = true;
11
+ return release;
12
+ });
13
+ const interactive = coordinator.acquire({ priority: "interactive", label: "interactive" });
14
+
15
+ releaseActive();
16
+ const releaseInteractive = await interactive;
17
+ assert.equal(backgroundStarted, false);
18
+ assert.equal(coordinator.diagnostic().active.priority, "interactive");
19
+ releaseInteractive();
20
+ const releaseBackground = await background;
21
+ assert.equal(backgroundStarted, true);
22
+ releaseBackground();
23
+ await new Promise((resolve) => setImmediate(resolve));
24
+ assert.equal(coordinator.diagnostic().active, null);
25
+ assert.equal(coordinator.diagnostic().completed, 3);
26
+ });
27
+
28
+ test("background turns expire safely before execution when their queue TTL elapses", async () => {
29
+ const coordinator = new AgentTurnCoordinator();
30
+ const releaseActive = await coordinator.acquire({ priority: "interactive", label: "active" });
31
+ await assert.rejects(
32
+ coordinator.acquire({ priority: "background", label: "stale batch", queueTtlMs: 10 }),
33
+ (error) => error.code === "AGENT_TURN_QUEUE_EXPIRED" && error.retryable === true && error.outcomeUncertain === false
34
+ );
35
+ assert.equal(coordinator.diagnostic().expired, 1);
36
+ releaseActive();
37
+ });
38
+
39
+ test("run releases exclusive admission after failures", async () => {
40
+ const coordinator = new AgentTurnCoordinator();
41
+ await assert.rejects(coordinator.run({ priority: "background" }, async () => { throw new Error("failed"); }), /failed/);
42
+ const result = await coordinator.run({ priority: "interactive" }, async () => "ok");
43
+ assert.equal(result, "ok");
44
+ assert.equal(coordinator.diagnostic().completed, 2);
45
+ });
@@ -395,6 +395,7 @@ test("selectScheduledTasks bounds history while keeping active tasks", () => {
395
395
  status: "done"
396
396
  }));
397
397
  tasks[0] = { id: "pending-1", status: "pending" };
398
+ tasks[1] = { id: "blocked-1", status: "blocked_auth" };
398
399
 
399
400
  const result = selectScheduledTasks(tasks);
400
401
 
@@ -402,7 +403,7 @@ test("selectScheduledTasks bounds history while keeping active tasks", () => {
402
403
  assert.equal(result.returned, 50);
403
404
  assert.equal(result.limit, 50);
404
405
  assert.equal(result.truncated, true);
405
- assert.equal(result.tasks[0].id, "pending-1");
406
+ assert.deepEqual(result.tasks.slice(0, 2).map((task) => task.id), ["blocked-1", "pending-1"]);
406
407
  });
407
408
 
408
409
  test("selectScheduledTasks honors an explicit status and limit", () => {
@@ -244,7 +244,7 @@ test("supervisor ignores invalid chat directories and recovers valid daemons", a
244
244
  }
245
245
  });
246
246
 
247
- test("does not restart registrations whose scope no longer matches the tool manifest", async () => {
247
+ test("removes stopped registrations whose scope no longer matches the tool manifest", async () => {
248
248
  const runtime = runtimeFor({ type: "global" }, { autoStart: true });
249
249
  const registry = new ToolRegistry();
250
250
  registry.tools.set("fake-daemon", {
@@ -267,11 +267,9 @@ test("does not restart registrations whose scope no longer matches the tool mani
267
267
  const results = await supervisor.repair();
268
268
  const result = results.find((item) => item.record.toolName === "fake-daemon" && item.record.instanceId === "global");
269
269
 
270
- assert.equal(result.outcome, "stale-registration");
270
+ assert.equal(result.outcome, "obsolete-removed");
271
271
  assert.match(result.reason, /global scope does not match manifest chat scope/);
272
272
  assert.equal(isProcessAlive(await runtime.getPid()), false);
273
-
274
- await unregisterManagedDaemon({ toolName: "fake-daemon", scope: { type: "global" } });
275
273
  assert.deepEqual(await readJson(runtime.paths.metaFile, null), null);
276
274
  });
277
275
 
@@ -121,6 +121,25 @@ test("lists each checked daemon with its scope and state", async () => {
121
121
  assert.ok(formatted.split("\n").every((line) => [...line].length <= 35));
122
122
  });
123
123
 
124
+ test("reports automatic obsolete daemon cleanup and unverifiable leftovers", async () => {
125
+ const { report } = await run({
126
+ repairs: [
127
+ {
128
+ record: { toolName: "removed", instanceId: "global", scope: { type: "global" } },
129
+ outcome: "obsolete-removed",
130
+ reason: "tool is no longer installed"
131
+ },
132
+ {
133
+ record: { toolName: "unknown-pid", instanceId: "global", scope: { type: "global" } },
134
+ outcome: "obsolete-unverified"
135
+ }
136
+ ]
137
+ });
138
+
139
+ assert.match(report.repairs.join("\n"), /Removed obsolete daemon removed/);
140
+ assert.match(report.attention.join("\n"), /unknown-pid.*could not be verified/);
141
+ });
142
+
124
143
  test("reports missing tool dependencies as attention items", async () => {
125
144
  const report = await runDoctor({
126
145
  agentManager: { getRuntimeDiagnostic: async () => runtime() },
@@ -28,9 +28,14 @@ test("classifies each configured memory pressure boundary", () => {
28
28
  swapTotalBytes: 0,
29
29
  swapUsedPercent: 0
30
30
  }, policy), /worker RSS/);
31
+ assert.equal(memoryPressureReason({
32
+ workerRssBytes: 400 * 1024 * 1024,
33
+ swapTotalBytes: 0,
34
+ swapUsedPercent: 0
35
+ }, policy, { ignoreWorkerRss: true }), "");
31
36
  assert.match(memoryPressureReason({
32
- workerRssBytes: 100 * 1024 * 1024,
37
+ workerRssBytes: 400 * 1024 * 1024,
33
38
  swapTotalBytes: 100,
34
39
  swapUsedPercent: 96
35
- }, policy), /swap use/);
40
+ }, policy, { ignoreWorkerRss: true }), /swap use/);
36
41
  });
@@ -0,0 +1,61 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { reapObsoleteDaemon } from "../src/runtime/obsolete-daemon-reaper.js";
4
+
5
+ const record = {
6
+ toolName: "removed-tool",
7
+ instanceId: "global",
8
+ entryPath: "/tools/removed-tool/index.js",
9
+ scope: { type: "global" }
10
+ };
11
+
12
+ test("purges an obsolete daemon with no live process", async () => {
13
+ const purged = [];
14
+ const result = await reapObsoleteDaemon({
15
+ record,
16
+ diagnostic: { pid: null },
17
+ reason: "tool is no longer installed",
18
+ purgeDaemon: async (identity) => purged.push(identity)
19
+ });
20
+
21
+ assert.equal(result.outcome, "obsolete-removed");
22
+ assert.deepEqual(purged, [{ toolName: "removed-tool", scope: { type: "global" } }]);
23
+ });
24
+
25
+ test("terminates only a verified obsolete daemon process before purging", async () => {
26
+ const stopped = [];
27
+ const purged = [];
28
+ const result = await reapObsoleteDaemon({
29
+ record,
30
+ diagnostic: { pid: 321 },
31
+ reason: "tool is no longer installed",
32
+ timeoutMs: 100,
33
+ stopTimeoutMs: 50,
34
+ inspectProcesses: async () => [{ pid: 321, command: `${process.execPath} ${record.entryPath} daemon` }],
35
+ stopProcess: async (pid, options) => stopped.push([pid, options]),
36
+ purgeDaemon: async (identity) => purged.push(identity)
37
+ });
38
+
39
+ assert.equal(result.outcome, "obsolete-removed");
40
+ assert.deepEqual(stopped, [[321, { forceAfterMs: 50 }]]);
41
+ assert.equal(purged.length, 1);
42
+ });
43
+
44
+ test("leaves an unverifiable live PID untouched", async () => {
45
+ let stopped = false;
46
+ let purged = false;
47
+ const result = await reapObsoleteDaemon({
48
+ record,
49
+ diagnostic: { pid: 321 },
50
+ reason: "registered entry does not match the installed tool",
51
+ timeoutMs: 100,
52
+ stopTimeoutMs: 50,
53
+ inspectProcesses: async () => [{ pid: 321, command: "node /some/other/process.js daemon" }],
54
+ stopProcess: async () => { stopped = true; },
55
+ purgeDaemon: async () => { purged = true; }
56
+ });
57
+
58
+ assert.equal(result.outcome, "obsolete-unverified");
59
+ assert.equal(stopped, false);
60
+ assert.equal(purged, false);
61
+ });
@@ -10,9 +10,14 @@ test("official orchestrators declare their hard tool dependencies", async () =>
10
10
  assert.deepEqual((await manifest("magnific-mcp")).toolDependencies, { "mcp-client": "^0.2.0" });
11
11
  assert.deepEqual((await manifest("campaign-draft-runner")).toolDependencies, {
12
12
  "pr-campaign": "^0.1.0",
13
- "gmail-workspace": "^0.1.0"
13
+ "gmail-workspace": "^0.1.0",
14
+ "lightpanda-browser": "^0.10.0"
15
+ });
16
+ assert.deepEqual((await manifest("browser-session-bridge")).toolDependencies, { "lightpanda-browser": "^0.11.5" });
17
+ assert.deepEqual((await manifest("x-campaign-runner")).toolDependencies, {
18
+ "x-dm": "^0.4.0",
19
+ "lightpanda-browser": "^0.11.0"
14
20
  });
15
- assert.deepEqual((await manifest("x-campaign-runner")).toolDependencies, { "x-dm": "^0.4.0" });
16
21
  assert.deepEqual((await manifest("x-dm")).toolDependencies, { "browser-session-bridge": "^0.1.0" });
17
22
  assert.deepEqual((await manifest("x-session-reader")).toolDependencies, { "browser-session-bridge": "^0.1.0" });
18
23
  assert.deepEqual((await manifest("official-tool-sync")).toolDependencies, { trash: "^1.0.0" });
@@ -23,6 +23,27 @@ test("merges partial resident session cache overrides with defaults", () => {
23
23
  });
24
24
  });
25
25
 
26
+ test("merges partial session rotation overrides with defaults", () => {
27
+ const config = applyConfigDefaults({ pi: { sessionRotation: { enabled: false } } });
28
+
29
+ assert.deepEqual(config.pi.sessionRotation, {
30
+ enabled: false,
31
+ compactAtPersistedBytes: 24 * 1024 * 1024,
32
+ maxPersistedBytes: 32 * 1024 * 1024
33
+ });
34
+ });
35
+
36
+ test("merges partial exclusive turn coordinator overrides with defaults", () => {
37
+ const config = applyConfigDefaults({ pi: { turnCoordinator: { backgroundQueueTtlMs: 30_000 } } });
38
+
39
+ assert.deepEqual(config.pi.turnCoordinator, {
40
+ enabled: true,
41
+ backgroundQueueTtlMs: 30_000,
42
+ interactiveQueueTtlMs: 0,
43
+ maxQueued: 100
44
+ });
45
+ });
46
+
26
47
  test("merges partial Pi compaction overrides with defaults", () => {
27
48
  const config = applyConfigDefaults({
28
49
  pi: { compaction: { reserveTokens: 8_192 } }
@@ -0,0 +1,84 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import path from "node:path";
5
+ import test from "node:test";
6
+ import { inspectSessionForPreloadMigration } from "../src/core/agent/session-history-reader.js";
7
+
8
+ function writeSession(entries) {
9
+ const dir = mkdtempSync(path.join(tmpdir(), "arisa-session-reader-"));
10
+ const file = path.join(dir, "session.jsonl");
11
+ writeFileSync(file, `${entries.map((entry) => JSON.stringify(entry)).join("\n")}\n`, "utf8");
12
+ return { dir, file };
13
+ }
14
+
15
+ const header = {
16
+ type: "session",
17
+ version: 3,
18
+ id: "session-id",
19
+ timestamp: "2026-08-28T00:00:00.000Z",
20
+ cwd: "/workspace"
21
+ };
22
+
23
+ function message(id, parentId, text) {
24
+ return {
25
+ type: "message",
26
+ id,
27
+ parentId,
28
+ timestamp: "2026-08-28T00:00:00.000Z",
29
+ message: { role: "user", content: [{ type: "text", text }] }
30
+ };
31
+ }
32
+
33
+ test("discovers the latest valid compaction on the active branch without retaining divergent payloads", () => {
34
+ const entries = [
35
+ header,
36
+ message("kept", null, "kept context"),
37
+ {
38
+ type: "compaction",
39
+ id: "active-compaction",
40
+ parentId: "kept",
41
+ firstKeptEntryId: "kept",
42
+ summary: "active summary"
43
+ },
44
+ message("after", "active-compaction", "recent context"),
45
+ {
46
+ type: "compaction",
47
+ id: "divergent-compaction",
48
+ parentId: "kept",
49
+ firstKeptEntryId: "kept",
50
+ summary: "divergent summary"
51
+ },
52
+ message("leaf", "after", "active leaf")
53
+ ];
54
+ const { dir, file } = writeSession(entries);
55
+ try {
56
+ const result = inspectSessionForPreloadMigration(file, 1);
57
+ assert.equal(result.compactionId, "active-compaction");
58
+ assert.equal(result.summary, "active summary");
59
+ assert.deepEqual(result.contextEntries.map((entry) => entry.id), ["kept", "after", "leaf"]);
60
+ } finally {
61
+ rmSync(dir, { recursive: true, force: true });
62
+ }
63
+ });
64
+
65
+ test("does not migrate a small, unsupported, or structurally invalid session", () => {
66
+ const cases = [
67
+ [header, message("one", null, "small")],
68
+ [{ ...header, version: 2 }, message("kept", null, "old"), {
69
+ type: "compaction", id: "comp", parentId: "kept", firstKeptEntryId: "kept", summary: "summary"
70
+ }],
71
+ [header, message("leaf", "missing-parent", "broken"), {
72
+ type: "compaction", id: "comp", parentId: "leaf", firstKeptEntryId: "leaf", summary: "summary"
73
+ }]
74
+ ];
75
+ for (const entries of cases) {
76
+ const { dir, file } = writeSession(entries);
77
+ try {
78
+ const threshold = entries === cases[0] ? 1024 * 1024 : 1;
79
+ assert.equal(inspectSessionForPreloadMigration(file, threshold), null);
80
+ } finally {
81
+ rmSync(dir, { recursive: true, force: true });
82
+ }
83
+ }
84
+ });
@@ -0,0 +1,120 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import path from "node:path";
5
+ import test from "node:test";
6
+ import { SessionManager } from "@earendil-works/pi-coding-agent";
7
+ import {
8
+ createPreloadMigrationChild,
9
+ migrateRecentSessionBeforeLoad
10
+ } from "../src/core/agent/session-preload-migration.js";
11
+
12
+ function sourceMessage(id, parentId, role, text) {
13
+ return {
14
+ type: "message",
15
+ id,
16
+ parentId,
17
+ timestamp: "2026-08-28T00:00:00.000Z",
18
+ message: { role, content: [{ type: "text", text }] }
19
+ };
20
+ }
21
+
22
+ function writeEntries(file, entries) {
23
+ writeFileSync(file, `${entries.map((entry) => JSON.stringify(entry)).join("\n")}\n`, "utf8");
24
+ }
25
+
26
+ test("atomically creates a valid child session with a durable parent reference", () => {
27
+ const sessionDir = mkdtempSync(path.join(tmpdir(), "arisa-session-migration-"));
28
+ try {
29
+ const result = createPreloadMigrationChild({
30
+ sessionDir,
31
+ cwd: "/workspace",
32
+ migration: {
33
+ sourceFile: path.join(sessionDir, "historical.jsonl"),
34
+ sourceBytes: 80 * 1024 * 1024,
35
+ summary: "compacted history",
36
+ contextEntries: [
37
+ sourceMessage("old-user", null, "user", "recent question"),
38
+ { type: "label", id: "ignored", parentId: "old-user", targetId: "old-user", label: "old" },
39
+ sourceMessage("old-assistant", "old-user", "assistant", "recent answer")
40
+ ]
41
+ },
42
+ operationalNotes: "Durable operating notes:\n- keep history",
43
+ now: new Date("2026-08-28T12:00:00.000Z")
44
+ });
45
+
46
+ assert.equal(result.copiedEntries, 2);
47
+ assert.equal(readdirSync(sessionDir).some((name) => name.endsWith(".tmp")), false);
48
+ const entries = readFileSync(result.targetFile, "utf8").trim().split("\n").map(JSON.parse);
49
+ assert.equal(entries[0].parentSession, result.sourceFile);
50
+ assert.deepEqual(entries.slice(1).map((entry) => entry.parentId), [
51
+ null,
52
+ entries[1].id,
53
+ entries[2].id,
54
+ entries[3].id
55
+ ]);
56
+ assert.equal(entries[2].details.source, "preload-migration");
57
+
58
+ const manager = SessionManager.open(result.targetFile, sessionDir, "/workspace");
59
+ const messages = manager.buildSessionContext().messages;
60
+ assert.deepEqual(messages.map((message) => message.role), ["custom", "custom", "user", "assistant"]);
61
+ assert.match(messages[1].content, /compacted history/);
62
+ } finally {
63
+ rmSync(sessionDir, { recursive: true, force: true });
64
+ }
65
+ });
66
+
67
+ test("pre-load migration commits once and a restart selects the compact child", () => {
68
+ const sessionDir = mkdtempSync(path.join(tmpdir(), "arisa-session-recovery-"));
69
+ const sourceFile = path.join(sessionDir, "historical.jsonl");
70
+ try {
71
+ writeEntries(sourceFile, [
72
+ { type: "session", version: 3, id: "old", timestamp: "2026-08-28T00:00:00.000Z", cwd: "/workspace" },
73
+ sourceMessage("historical", null, "user", "x".repeat(8_000)),
74
+ sourceMessage("kept", "historical", "user", "recent"),
75
+ {
76
+ type: "compaction",
77
+ id: "compaction",
78
+ parentId: "kept",
79
+ firstKeptEntryId: "kept",
80
+ summary: "bounded summary"
81
+ },
82
+ sourceMessage("leaf", "compaction", "assistant", "answer")
83
+ ]);
84
+
85
+ const first = migrateRecentSessionBeforeLoad({
86
+ sessionDir,
87
+ cwd: "/workspace",
88
+ policy: { maxPersistedBytes: 2_000 },
89
+ operationalNotes: "notes"
90
+ });
91
+ assert.equal(first.sourceFile, sourceFile);
92
+ assert.ok(first.targetBytes < 2_000);
93
+ assert.equal(migrateRecentSessionBeforeLoad({
94
+ sessionDir,
95
+ cwd: "/workspace",
96
+ policy: { maxPersistedBytes: 2_000 }
97
+ }), null);
98
+ const resumed = SessionManager.continueRecent("/workspace", sessionDir);
99
+ assert.equal(resumed.getSessionFile(), first.targetFile);
100
+ } finally {
101
+ rmSync(sessionDir, { recursive: true, force: true });
102
+ }
103
+ });
104
+
105
+ test("refuses to load an oversized session when no safe compaction checkpoint exists", () => {
106
+ const sessionDir = mkdtempSync(path.join(tmpdir(), "arisa-session-refusal-"));
107
+ try {
108
+ writeEntries(path.join(sessionDir, "unsafe.jsonl"), [
109
+ { type: "session", version: 3, id: "unsafe", timestamp: "2026-08-28T00:00:00.000Z", cwd: "/workspace" },
110
+ sourceMessage("leaf", null, "user", "x".repeat(4_000))
111
+ ]);
112
+ assert.throws(() => migrateRecentSessionBeforeLoad({
113
+ sessionDir,
114
+ cwd: "/workspace",
115
+ policy: { maxPersistedBytes: 1_000 }
116
+ }), (error) => error.code === "PI_SESSION_PRELOAD_MIGRATION_UNAVAILABLE");
117
+ } finally {
118
+ rmSync(sessionDir, { recursive: true, force: true });
119
+ }
120
+ });
@@ -0,0 +1,110 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { applyConfigDefaults } from "../src/core/config/config-defaults.js";
4
+ import { AgentManager } from "../src/core/agent/agent-manager.js";
5
+ import { compactionRotationRequest, normalizeSessionRotationPolicy } from "../src/core/agent/session-rotation.js";
6
+
7
+ const mebibyte = 1024 * 1024;
8
+
9
+ function compactionEvent(summary = "checkpoint") {
10
+ return {
11
+ type: "compaction_end",
12
+ aborted: false,
13
+ errorMessage: "",
14
+ result: { summary }
15
+ };
16
+ }
17
+
18
+ test("normalizes the automatic session rotation policy", () => {
19
+ assert.deepEqual(normalizeSessionRotationPolicy(), {
20
+ enabled: true,
21
+ compactAtPersistedBytes: 24 * mebibyte,
22
+ maxPersistedBytes: 32 * mebibyte
23
+ });
24
+ assert.deepEqual(normalizeSessionRotationPolicy({
25
+ enabled: false,
26
+ compactAtPersistedBytes: 8,
27
+ maxPersistedBytes: 12
28
+ }), {
29
+ enabled: false,
30
+ compactAtPersistedBytes: 8,
31
+ maxPersistedBytes: 12
32
+ });
33
+ });
34
+
35
+ test("requests rotation only after a successful oversized compaction", () => {
36
+ assert.equal(compactionRotationRequest(compactionEvent(), 24 * mebibyte), null);
37
+ assert.equal(compactionRotationRequest({ ...compactionEvent(), aborted: true }, 25 * mebibyte), null);
38
+ assert.equal(compactionRotationRequest(compactionEvent(), 25 * mebibyte, { enabled: false }), null);
39
+ const request = compactionRotationRequest(compactionEvent("latest summary"), 25 * mebibyte);
40
+ assert.equal(request.persistedBytes, 25 * mebibyte);
41
+ assert.match(request.handoff, /latest summary/);
42
+ });
43
+
44
+ test("compacts at the preventive persisted-size threshold and then rotates", async () => {
45
+ const config = applyConfigDefaults({ telegram: {}, pi: { provider: "test", model: "test" } });
46
+ const manager = new AgentManager({
47
+ config,
48
+ artifactStore: {},
49
+ toolRegistry: {},
50
+ taskStore: {},
51
+ logger: null
52
+ });
53
+ let compactions = 0;
54
+ const context = {
55
+ activeUsers: 1,
56
+ session: {
57
+ sessionFile: "/sessions/preventive.jsonl",
58
+ async compact() {
59
+ compactions += 1;
60
+ manager.scheduleCompactionRotationCheck("chat", context, compactionEvent("preventive summary"));
61
+ },
62
+ async close() {}
63
+ },
64
+ rotationCheckPromise: Promise.resolve(),
65
+ rotationRequest: null
66
+ };
67
+ manager.sessions.set("chat", context);
68
+ manager.estimatePersistedSessionBytes = async () => 25 * mebibyte;
69
+
70
+ await manager.releaseSessionContext("chat", context);
71
+
72
+ assert.equal(compactions, 1);
73
+ assert.equal(manager.sessions.has("chat"), false);
74
+ assert.equal(manager.pendingNewSessions.has("chat"), true);
75
+ });
76
+
77
+ test("rotates after active work releases and preserves the parent session path", async () => {
78
+ const config = applyConfigDefaults({ telegram: {}, pi: { provider: "test", model: "test" } });
79
+ let closes = 0;
80
+ const manager = new AgentManager({
81
+ config,
82
+ artifactStore: {},
83
+ toolRegistry: {},
84
+ taskStore: {},
85
+ logger: null
86
+ });
87
+ const context = {
88
+ activeUsers: 1,
89
+ session: {
90
+ sessionFile: "/sessions/oversized.jsonl",
91
+ async close() { closes += 1; }
92
+ },
93
+ rotationCheckPromise: Promise.resolve(),
94
+ rotationRequest: null
95
+ };
96
+ manager.sessions.set("chat", context);
97
+ manager.estimatePersistedSessionBytes = async () => 65 * mebibyte;
98
+
99
+ manager.scheduleCompactionRotationCheck("chat", context, compactionEvent("handoff summary"));
100
+ await manager.releaseSessionContext("chat", context);
101
+
102
+ assert.equal(closes, 1);
103
+ assert.equal(manager.sessions.has("chat"), false);
104
+ assert.equal(manager.pendingNewSessions.has("chat"), true);
105
+ assert.deepEqual(manager.pendingSessionHandoffs.get("chat"), {
106
+ text: "Automatic session rotation after compaction. Continue from this checkpoint:\n\nhandoff summary",
107
+ parentSession: "/sessions/oversized.jsonl",
108
+ source: "compaction-rotation"
109
+ });
110
+ });