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.
- package/README.md +21 -2
- package/package.json +1 -1
- package/src/core/agent/agent-manager.js +79 -4
- package/src/core/agent/agent-session-lifecycle.js +29 -6
- package/src/core/agent/agent-turn-coordinator.js +143 -0
- package/src/core/agent/pi-capability-tools.js +7 -4
- package/src/core/agent/session-history-reader.js +168 -0
- package/src/core/agent/session-preload-migration.js +162 -0
- package/src/core/agent/session-rotation.js +29 -0
- package/src/core/agent/worker-tool-fanout.js +117 -0
- package/src/core/capabilities/capability-service.js +3 -2
- package/src/core/config/config-defaults.js +29 -0
- package/src/core/tasks/task-runner.js +8 -4
- package/src/core/tasks/task-store.js +47 -4
- package/src/core/tools/daemon-processes.js +7 -0
- package/src/core/tools/memory-pressure.js +2 -2
- package/src/core/tools/tool-registry.js +35 -7
- package/src/core/tools/weighted-resource-governor.js +7 -6
- package/src/official-tools.lock.json +121 -47
- package/src/runtime/doctor.js +20 -73
- package/src/runtime/obsolete-daemon-reaper.js +43 -0
- package/src/runtime/process-inspection.js +78 -0
- package/src/runtime/tool-process-supervisor.js +35 -8
- package/src/runtime/tui.js +1 -1
- package/src/transport/telegram/bot.js +3 -1
- package/src/transport/telegram/chat-queue.js +6 -2
- package/src/transport/telegram/task-dispatcher.js +36 -11
- package/src/transport/telegram/telegram-prompt-controller.js +17 -4
- package/test/agent-turn-coordinator.test.js +45 -0
- package/test/context-and-task-bounds.test.js +2 -1
- package/test/daemon-runtime.test.js +2 -4
- package/test/doctor.test.js +19 -0
- package/test/memory-pressure.test.js +7 -2
- package/test/obsolete-daemon-reaper.test.js +61 -0
- package/test/official-tool-dependencies.test.js +7 -2
- package/test/pi-compaction.test.js +21 -0
- package/test/session-history-reader.test.js +84 -0
- package/test/session-preload-migration.test.js +120 -0
- package/test/session-rotation.test.js +110 -0
- package/test/task-store.test.js +32 -0
- package/test/telegram-prompt-controller.test.js +2 -1
- package/test/telegram-task-dispatcher.test.js +66 -5
- package/test/tool-registry-run.test.js +10 -1
- package/test/weighted-resource-governor.test.js +28 -0
- 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
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
});
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
if (result
|
|
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
|
-
|
|
161
|
-
|
|
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
|
|
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.
|
|
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("
|
|
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, "
|
|
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
|
|
package/test/doctor.test.js
CHANGED
|
@@ -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:
|
|
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
|
+
});
|