arisa 5.2.7 → 5.2.19
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 +25 -4
- package/package.json +10 -9
- package/pnpm-workspace.yaml +7 -5
- package/src/core/agent/agent-manager.js +92 -18
- package/src/core/agent/agent-session-lifecycle.js +29 -6
- package/src/core/agent/agent-turn-coordinator.js +143 -0
- package/src/core/agent/auth-flow.js +6 -6
- package/src/core/agent/model-speed.js +3 -1
- package/src/core/agent/pi-auth-login.js +28 -28
- package/src/core/agent/pi-capability-tools.js +8 -4
- package/src/core/agent/pi-runtime.js +14 -21
- 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 +28 -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 +48 -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 +129 -48
- package/src/runtime/bootstrap-cli.js +3 -3
- package/src/runtime/bootstrap-telegram.js +7 -7
- 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/slave-cli.js +20 -10
- package/src/runtime/slave-service.js +299 -7
- package/src/runtime/tool-process-supervisor.js +35 -8
- package/src/runtime/tui.js +6 -7
- package/src/transport/telegram/bot.js +11 -5
- package/src/transport/telegram/chat-queue.js +6 -2
- package/src/transport/telegram/model-controls.js +1 -1
- package/src/transport/telegram/task-dispatcher.js +67 -15
- package/src/transport/telegram/telegram-auth-controller.js +7 -7
- package/src/transport/telegram/telegram-prompt-controller.js +17 -4
- package/src/transport/telegram/telegram-session-bridge.js +2 -1
- package/test/agent-turn-coordinator.test.js +48 -0
- package/test/auth-flow.test.js +2 -2
- package/test/capabilities-security.test.js +36 -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/model-selection.test.js +3 -2
- package/test/obsolete-daemon-reaper.test.js +61 -0
- package/test/official-tool-dependencies.test.js +7 -2
- package/test/official-tool-installer.test.js +13 -0
- package/test/pi-auth-login.test.js +78 -0
- package/test/pi-capability-tools.test.js +3 -0
- package/test/pi-compaction.test.js +21 -0
- package/test/pi-speed-integration.test.js +176 -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/slave-cli.test.js +221 -5
- package/test/task-store.test.js +34 -0
- package/test/telegram-prompt-controller.test.js +2 -1
- package/test/telegram-task-dispatcher.test.js +121 -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: ${result.authBlock?.toolName || 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 = [
|
|
@@ -64,16 +72,62 @@ export function createTelegramTaskDispatcher({
|
|
|
64
72
|
const agentTimeoutMs = boundedTimeout(taskTimeouts.agentTimeoutMs, 15 * 60_000);
|
|
65
73
|
const eventTimeoutMs = boundedTimeout(taskTimeouts.eventTimeoutMs, 5 * 60_000);
|
|
66
74
|
|
|
75
|
+
const runBackgroundTool = (toolName, chatId, args, label) => agentManager.runTurn({
|
|
76
|
+
priority: "background",
|
|
77
|
+
label
|
|
78
|
+
}, () => agentManager.runTool({ name: toolName, request: { args }, chatId }));
|
|
79
|
+
|
|
80
|
+
function throwToolFailure(result, toolName, fallbackResolution) {
|
|
81
|
+
const error = new Error(result?.error || `${toolName} failed`);
|
|
82
|
+
if (result?.status === "blocked_auth" || fallbackResolution) {
|
|
83
|
+
error.retryable = false;
|
|
84
|
+
error.authBlocked = true;
|
|
85
|
+
error.authResolution = {
|
|
86
|
+
...(result?.resolution || fallbackResolution || {}),
|
|
87
|
+
toolName
|
|
88
|
+
};
|
|
89
|
+
} else if (result?.status === "needs_config") {
|
|
90
|
+
error.retryable = false;
|
|
91
|
+
} else if (result?.status === "outcome_uncertain") {
|
|
92
|
+
error.retryable = false;
|
|
93
|
+
error.outcomeUncertain = true;
|
|
94
|
+
}
|
|
95
|
+
throw error;
|
|
96
|
+
}
|
|
97
|
+
|
|
67
98
|
async function dispatchAgentTask(task, chatId) {
|
|
68
99
|
if (!task.payload.prompt) throw new NonRetryableTaskError("agent_task missing prompt");
|
|
100
|
+
if (task.authBlock?.toolName) {
|
|
101
|
+
const probe = await runBackgroundTool(
|
|
102
|
+
task.authBlock.toolName,
|
|
103
|
+
chatId,
|
|
104
|
+
task.authBlock.probeArgs || {},
|
|
105
|
+
`authentication probe ${task.authBlock.toolName}`
|
|
106
|
+
);
|
|
107
|
+
if (probe?.ok === false) throwToolFailure(probe, task.authBlock.toolName, task.authBlock);
|
|
108
|
+
logger?.log("tasks", `authentication restored for ${task.authBlock.toolName} (task ${task.id})`);
|
|
109
|
+
}
|
|
110
|
+
|
|
69
111
|
logger?.log("tasks", `running task ${task.id} for chat ${chatId}`);
|
|
112
|
+
const agentTaskExecution = { blockedAuth: null };
|
|
70
113
|
await enqueueAsyncPrompt({
|
|
71
114
|
chatId,
|
|
72
115
|
prompt: await buildAsyncTaskPrompt({ task, artifactStore, toolRegistry, resourceNotes, logger }),
|
|
73
116
|
label: `scheduled task ${task.id}`,
|
|
74
117
|
route: task.route,
|
|
75
|
-
timeoutMs: agentTimeoutMs
|
|
118
|
+
timeoutMs: agentTimeoutMs,
|
|
119
|
+
priority: task.source?.toolName === "whatsapp-web" ? "interactive" : "background",
|
|
120
|
+
agentTaskExecution
|
|
76
121
|
});
|
|
122
|
+
if (agentTaskExecution.blockedAuth) {
|
|
123
|
+
const blocked = agentTaskExecution.blockedAuth;
|
|
124
|
+
throwToolFailure({
|
|
125
|
+
ok: false,
|
|
126
|
+
status: "blocked_auth",
|
|
127
|
+
error: blocked.error,
|
|
128
|
+
resolution: blocked.resolution
|
|
129
|
+
}, blocked.toolName);
|
|
130
|
+
}
|
|
77
131
|
}
|
|
78
132
|
|
|
79
133
|
async function dispatchAgentEvent(task, chatId) {
|
|
@@ -92,7 +146,8 @@ export function createTelegramTaskDispatcher({
|
|
|
92
146
|
prompt: await buildAsyncEventPrompt(task, resourceNotes),
|
|
93
147
|
label: `agent event ${task.id}`,
|
|
94
148
|
route: task.route,
|
|
95
|
-
timeoutMs: eventTimeoutMs
|
|
149
|
+
timeoutMs: eventTimeoutMs,
|
|
150
|
+
priority: task.source?.toolName === "process-retrospective" ? "background" : "interactive"
|
|
96
151
|
});
|
|
97
152
|
}
|
|
98
153
|
|
|
@@ -100,20 +155,17 @@ export function createTelegramTaskDispatcher({
|
|
|
100
155
|
const toolName = task.payload?.toolName;
|
|
101
156
|
if (!toolName) throw new NonRetryableTaskError("poll_tool missing toolName");
|
|
102
157
|
logger?.log("tasks", `polling tool ${toolName} (task ${task.id}) for chat ${chatId}`);
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
if (result.status === "needs_config") error.retryable = false;
|
|
111
|
-
if (result.status === "outcome_uncertain") {
|
|
112
|
-
error.retryable = false;
|
|
113
|
-
error.outcomeUncertain = true;
|
|
114
|
-
}
|
|
115
|
-
throw error;
|
|
158
|
+
|
|
159
|
+
const runTool = (args) => runBackgroundTool(toolName, chatId, args, `poll tool ${toolName}`);
|
|
160
|
+
|
|
161
|
+
if (task.authBlock) {
|
|
162
|
+
const probe = await runTool(task.authBlock.probeArgs || {});
|
|
163
|
+
if (probe?.ok === false) throwToolFailure(probe, toolName, task.authBlock);
|
|
164
|
+
logger?.log("tasks", `authentication restored for ${toolName} (task ${task.id})`);
|
|
116
165
|
}
|
|
166
|
+
|
|
167
|
+
const result = await runTool(task.payload.args || {});
|
|
168
|
+
if (result?.ok === false) throwToolFailure(result, toolName);
|
|
117
169
|
}
|
|
118
170
|
|
|
119
171
|
async function dispatchTask(task) {
|
|
@@ -50,7 +50,7 @@ export function createTelegramAuthController({
|
|
|
50
50
|
if (!detected) return false;
|
|
51
51
|
|
|
52
52
|
try {
|
|
53
|
-
await api.sendMessage(chatId, buildPiAuthTelegramMessage({ config, chatId, issue: detected }));
|
|
53
|
+
await api.sendMessage(chatId, await buildPiAuthTelegramMessage({ config, chatId, issue: detected }));
|
|
54
54
|
markPromptErrorNotified(error);
|
|
55
55
|
return true;
|
|
56
56
|
} catch (notifyError) {
|
|
@@ -66,11 +66,11 @@ export function createTelegramAuthController({
|
|
|
66
66
|
agentManager.clearSessionCache(chatId);
|
|
67
67
|
issue = null;
|
|
68
68
|
logger?.log("telegram", `Pi auth renewal completed for chat ${chatId}`);
|
|
69
|
-
await api.sendMessage(chatId, buildPiAuthTelegramMessage({ config, chatId, verified: true }));
|
|
69
|
+
await api.sendMessage(chatId, await buildPiAuthTelegramMessage({ config, chatId, verified: true }));
|
|
70
70
|
} catch (error) {
|
|
71
71
|
const detected = rememberValidationFailure(error);
|
|
72
72
|
logger?.error("telegram", `Pi auth renewal failed for chat ${chatId}: ${getErrorMessage(error)}`);
|
|
73
|
-
await api.sendMessage(chatId, buildPiAuthTelegramMessage({ config, chatId, issue: detected })).catch((notifyError) => {
|
|
73
|
+
await api.sendMessage(chatId, await buildPiAuthTelegramMessage({ config, chatId, issue: detected })).catch((notifyError) => {
|
|
74
74
|
logger?.error("telegram", `auth renewal failure notice failed for chat ${chatId}: ${getErrorMessage(notifyError)}`);
|
|
75
75
|
});
|
|
76
76
|
} finally {
|
|
@@ -140,17 +140,17 @@ export function createTelegramAuthController({
|
|
|
140
140
|
const authorization = await authorize(ctx);
|
|
141
141
|
if (!authorization.ok) return;
|
|
142
142
|
|
|
143
|
-
const status = getPiAuthStatus(config, ctx.chat.id);
|
|
143
|
+
const status = await getPiAuthStatus(config, ctx.chat.id);
|
|
144
144
|
if (status.hasApiKey || !status.supportsOAuth) {
|
|
145
145
|
await withTyping(ctx, async () => {
|
|
146
146
|
try {
|
|
147
147
|
await agentManager.validateAgent();
|
|
148
148
|
agentManager.clearSessionCache(ctx.chat.id);
|
|
149
149
|
issue = null;
|
|
150
|
-
await ctx.reply(buildPiAuthTelegramMessage({ config, chatId: ctx.chat.id, verified: true }));
|
|
150
|
+
await ctx.reply(await buildPiAuthTelegramMessage({ config, chatId: ctx.chat.id, verified: true }));
|
|
151
151
|
} catch (error) {
|
|
152
152
|
const detected = rememberValidationFailure(error);
|
|
153
|
-
await ctx.reply(buildPiAuthTelegramMessage({ config, chatId: ctx.chat.id, issue: detected }));
|
|
153
|
+
await ctx.reply(await buildPiAuthTelegramMessage({ config, chatId: ctx.chat.id, issue: detected }));
|
|
154
154
|
}
|
|
155
155
|
});
|
|
156
156
|
return;
|
|
@@ -163,7 +163,7 @@ export function createTelegramAuthController({
|
|
|
163
163
|
: "Pi login is already in progress. Paste the redirect URL or code here when you have it.");
|
|
164
164
|
} catch (error) {
|
|
165
165
|
const detected = rememberValidationFailure(error);
|
|
166
|
-
await ctx.reply(buildPiAuthTelegramMessage({ config, chatId: ctx.chat.id, issue: detected }));
|
|
166
|
+
await ctx.reply(await buildPiAuthTelegramMessage({ config, chatId: ctx.chat.id, issue: detected }));
|
|
167
167
|
}
|
|
168
168
|
}
|
|
169
169
|
|
|
@@ -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,48 @@
|
|
|
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 (t) => {
|
|
29
|
+
t.mock.timers.enable({ apis: ["setTimeout"] });
|
|
30
|
+
const coordinator = new AgentTurnCoordinator();
|
|
31
|
+
const releaseActive = await coordinator.acquire({ priority: "interactive", label: "active" });
|
|
32
|
+
const expired = assert.rejects(
|
|
33
|
+
coordinator.acquire({ priority: "background", label: "stale batch", queueTtlMs: 10 }),
|
|
34
|
+
(error) => error.code === "AGENT_TURN_QUEUE_EXPIRED" && error.retryable === true && error.outcomeUncertain === false
|
|
35
|
+
);
|
|
36
|
+
t.mock.timers.tick(10);
|
|
37
|
+
await expired;
|
|
38
|
+
assert.equal(coordinator.diagnostic().expired, 1);
|
|
39
|
+
releaseActive();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("run releases exclusive admission after failures", async () => {
|
|
43
|
+
const coordinator = new AgentTurnCoordinator();
|
|
44
|
+
await assert.rejects(coordinator.run({ priority: "background" }, async () => { throw new Error("failed"); }), /failed/);
|
|
45
|
+
const result = await coordinator.run({ priority: "interactive" }, async () => "ok");
|
|
46
|
+
assert.equal(result, "ok");
|
|
47
|
+
assert.equal(coordinator.diagnostic().completed, 2);
|
|
48
|
+
});
|
package/test/auth-flow.test.js
CHANGED
|
@@ -42,7 +42,7 @@ test("ignores unrelated Pi errors", () => {
|
|
|
42
42
|
assert.equal(getPiAuthIssue(new Error("")), null);
|
|
43
43
|
});
|
|
44
44
|
|
|
45
|
-
test("reports the active chat model after authentication", () => {
|
|
45
|
+
test("reports the active chat model after authentication", async () => {
|
|
46
46
|
const config = {
|
|
47
47
|
pi: {
|
|
48
48
|
provider: "openai-codex",
|
|
@@ -60,7 +60,7 @@ test("reports the active chat model after authentication", () => {
|
|
|
60
60
|
}
|
|
61
61
|
};
|
|
62
62
|
|
|
63
|
-
const message = buildPiAuthTelegramMessage({ config, chatId: 123, verified: true });
|
|
63
|
+
const message = await buildPiAuthTelegramMessage({ config, chatId: 123, verified: true });
|
|
64
64
|
|
|
65
65
|
assert.match(message, /^Pi authentication is working for openai-codex\/gpt-5\.6\./);
|
|
66
66
|
assert.doesNotMatch(message, /gpt-5\.5/);
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import test from "node:test";
|
|
3
3
|
import { createArisaCapabilities } from "../src/runtime/arisa-capabilities.js";
|
|
4
|
+
import { createCapabilityService } from "../src/core/capabilities/capability-service.js";
|
|
4
5
|
|
|
5
6
|
function createFakeArtifactStore() {
|
|
6
7
|
const stores = new Map();
|
|
@@ -294,6 +295,41 @@ test("normalizes tool run args and rejects arrays", async () => {
|
|
|
294
295
|
);
|
|
295
296
|
});
|
|
296
297
|
|
|
298
|
+
test("records blocked authentication and stops later tools inside an agent task", async () => {
|
|
299
|
+
const execution = { blockedAuth: null };
|
|
300
|
+
const resolution = { retryAfterSeconds: 3600, probeArgs: { action: "status" } };
|
|
301
|
+
let executions = 0;
|
|
302
|
+
const service = createCapabilityService({
|
|
303
|
+
artifactStore: createFakeArtifactStore(),
|
|
304
|
+
toolRegistry: { async load() {} },
|
|
305
|
+
toolExecutor: {
|
|
306
|
+
async runTool() {
|
|
307
|
+
executions += 1;
|
|
308
|
+
return { ok: false, status: "blocked_auth", error: "authentication expired", resolution };
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
});
|
|
312
|
+
const run = (name) => service.execute({
|
|
313
|
+
method: "tools.run",
|
|
314
|
+
actorToolName: "run_tool",
|
|
315
|
+
chatId: "chat-1",
|
|
316
|
+
params: { name, args: {} },
|
|
317
|
+
context: { agentTaskExecution: execution }
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
await run("creator-scout");
|
|
321
|
+
assert.deepEqual(execution.blockedAuth, {
|
|
322
|
+
toolName: "creator-scout",
|
|
323
|
+
error: "authentication expired",
|
|
324
|
+
resolution
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
const skipped = await run("campaign-draft-runner");
|
|
328
|
+
assert.equal(skipped.status, "blocked_prerequisite");
|
|
329
|
+
assert.equal(skipped.resolution.prerequisiteStatus, "blocked_auth");
|
|
330
|
+
assert.equal(executions, 1);
|
|
331
|
+
});
|
|
332
|
+
|
|
297
333
|
test("rejects missing artifact input before running a tool", async () => {
|
|
298
334
|
const calls = [];
|
|
299
335
|
const capabilities = createCapabilities({
|
|
@@ -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
|
});
|
|
@@ -355,17 +355,18 @@ test("maps supported model speeds to provider service tiers", () => {
|
|
|
355
355
|
|
|
356
356
|
test("applies Pi speed to every provider request and updates it in place", async () => {
|
|
357
357
|
const calls = [];
|
|
358
|
+
const model = { provider: "openai-codex", api: "openai-codex-responses", id: "gpt-5.6-sol" };
|
|
358
359
|
const controller = createModelSpeedController((model, context, options) => {
|
|
359
360
|
calls.push({ model, context, options });
|
|
360
361
|
return "stream";
|
|
361
362
|
}, 1);
|
|
362
363
|
|
|
363
|
-
assert.equal(controller.streamFn(
|
|
364
|
+
assert.equal(controller.streamFn(model, "context", {
|
|
364
365
|
signal: "signal",
|
|
365
366
|
onPayload: (payload) => ({ ...payload, preserved: true })
|
|
366
367
|
}), "stream");
|
|
367
368
|
controller.setSpeed(1.5);
|
|
368
|
-
controller.streamFn(
|
|
369
|
+
controller.streamFn(model, "context", { signal: "signal" });
|
|
369
370
|
|
|
370
371
|
assert.equal(calls[0].options.serviceTier, "default");
|
|
371
372
|
assert.equal(calls[1].options.serviceTier, "priority");
|
|
@@ -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" });
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import crypto from "node:crypto";
|
|
3
|
+
import { execFileSync } from "node:child_process";
|
|
3
4
|
import { cp, mkdir, mkdtemp, readFile, readdir, rm, symlink, writeFile } from "node:fs/promises";
|
|
4
5
|
import os from "node:os";
|
|
5
6
|
import path from "node:path";
|
|
@@ -88,9 +89,21 @@ test("every bundled official tool lock matches the catalog source", async () =>
|
|
|
88
89
|
);
|
|
89
90
|
const manifest = JSON.parse(await readFile(path.join(source, "tool.manifest.json"), "utf8"));
|
|
90
91
|
assert.equal(manifest.version ?? null, entry.version ?? null, `${name} version`);
|
|
92
|
+
assert.deepEqual(manifest.toolDependencies || {}, entry.toolDependencies || {}, `${name} tool dependencies`);
|
|
91
93
|
}
|
|
92
94
|
});
|
|
93
95
|
|
|
96
|
+
test("the bundled lock commit contains the exact catalog snapshot", async () => {
|
|
97
|
+
const lock = JSON.parse(await readFile(new URL("../src/official-tools.lock.json", import.meta.url), "utf8"));
|
|
98
|
+
const repositoryRoot = fileURLToPath(new URL("../../", import.meta.url));
|
|
99
|
+
execFileSync("git", ["cat-file", "-e", `${lock.commit}^{commit}`], { cwd: repositoryRoot });
|
|
100
|
+
const changedTools = execFileSync("git", ["diff", "--name-only", lock.commit, "--", "tools"], {
|
|
101
|
+
cwd: repositoryRoot,
|
|
102
|
+
encoding: "utf8"
|
|
103
|
+
}).trim();
|
|
104
|
+
assert.equal(changedTools, "", `Bundled lock commit does not match catalog source:\n${changedTools}`);
|
|
105
|
+
});
|
|
106
|
+
|
|
94
107
|
test("rejects symbolic links before deployment", async (t) => {
|
|
95
108
|
const { source, files } = await fixture(t);
|
|
96
109
|
await symlink(path.join(source, "index.js"), path.join(source, "link.js"));
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { ModelRuntime } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import { createPiOAuthLogin } from "../src/core/agent/pi-auth-login.js";
|
|
5
|
+
import { createPiRuntime, hasProviderAuth, supportsProviderOAuth } from "../src/core/agent/pi-runtime.js";
|
|
6
|
+
import { piAuthFile } from "../src/platform/paths.js";
|
|
7
|
+
|
|
8
|
+
test("creates the async Pi runtime with Arisa credential storage and awaits runtime API keys", async (t) => {
|
|
9
|
+
const calls = [];
|
|
10
|
+
const runtime = {
|
|
11
|
+
async setRuntimeApiKey(provider, apiKey) {
|
|
12
|
+
await Promise.resolve();
|
|
13
|
+
calls.push([provider, apiKey]);
|
|
14
|
+
},
|
|
15
|
+
getProviderAuthStatus: () => ({ configured: true, source: "stored" }),
|
|
16
|
+
getProvider: () => ({ auth: { oauth: {} } })
|
|
17
|
+
};
|
|
18
|
+
t.mock.method(ModelRuntime, "create", async (options) => {
|
|
19
|
+
assert.equal(options.authPath, piAuthFile);
|
|
20
|
+
return runtime;
|
|
21
|
+
});
|
|
22
|
+
assert.equal(await createPiRuntime({ provider: "openai", apiKey: "test-key" }), runtime);
|
|
23
|
+
assert.deepEqual(calls, [["openai", "test-key"]]);
|
|
24
|
+
assert.equal(hasProviderAuth("openai", runtime), true);
|
|
25
|
+
assert.equal(supportsProviderOAuth("openai", runtime), true);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("adapts Pi OAuth notifications, selections and manual codes in order", async (t) => {
|
|
29
|
+
const events = [];
|
|
30
|
+
let controller;
|
|
31
|
+
const credential = { type: "oauth", access: "test-access" };
|
|
32
|
+
t.mock.method(ModelRuntime, "create", async () => ({
|
|
33
|
+
getProvider: () => ({ auth: { oauth: {} } }),
|
|
34
|
+
async login(provider, type, interaction) {
|
|
35
|
+
assert.equal(provider, "openai-codex");
|
|
36
|
+
assert.equal(type, "oauth");
|
|
37
|
+
interaction.notify({ type: "auth_url", url: "https://example.com/login" });
|
|
38
|
+
interaction.notify({ type: "device_code", userCode: "ABCD", verificationUri: "https://example.com/device" });
|
|
39
|
+
interaction.notify({ type: "progress", message: "waiting" });
|
|
40
|
+
assert.equal(await interaction.prompt({ type: "select", options: [{ id: "device", label: "Device" }] }), "device");
|
|
41
|
+
assert.deepEqual(events, ["auth", "device", "waiting", "select"]);
|
|
42
|
+
assert.equal(await interaction.prompt({ type: "text", message: "Value" }), "answer");
|
|
43
|
+
const code = interaction.prompt({ type: "manual_code" });
|
|
44
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
45
|
+
assert.equal(controller.manualInputRequested, true);
|
|
46
|
+
assert.equal(controller.submitManualCode(" callback-code "), true);
|
|
47
|
+
assert.equal(await code, "callback-code");
|
|
48
|
+
return credential;
|
|
49
|
+
}
|
|
50
|
+
}));
|
|
51
|
+
controller = createPiOAuthLogin({
|
|
52
|
+
provider: "openai-codex",
|
|
53
|
+
onAuth: async () => { await Promise.resolve(); events.push("auth"); },
|
|
54
|
+
onDeviceCode: async () => { events.push("device"); },
|
|
55
|
+
onProgress: (message) => events.push(message),
|
|
56
|
+
onSelect: ({ options }) => { events.push("select"); return options[0].id; },
|
|
57
|
+
onPrompt: () => "answer"
|
|
58
|
+
});
|
|
59
|
+
assert.equal(await controller.promise, credential);
|
|
60
|
+
assert.equal(controller.manualInputRequested, false);
|
|
61
|
+
assert.equal(controller.submitManualCode("again"), false);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("OAuth rejects unsupported providers and surfaces notification failures", async (t) => {
|
|
65
|
+
t.mock.method(ModelRuntime, "create", async () => ({ getProvider: () => ({ auth: {} }) }));
|
|
66
|
+
await assert.rejects(createPiOAuthLogin({ provider: "unsupported" }).promise, /No internal OAuth login flow/);
|
|
67
|
+
ModelRuntime.create.mock.restore();
|
|
68
|
+
t.mock.method(ModelRuntime, "create", async () => ({
|
|
69
|
+
getProvider: () => ({ auth: { oauth: {} } }),
|
|
70
|
+
async login(_provider, _type, interaction) {
|
|
71
|
+
interaction.notify({ type: "auth_url", url: "https://example.com/login" });
|
|
72
|
+
await interaction.prompt({ type: "manual_code" });
|
|
73
|
+
}
|
|
74
|
+
}));
|
|
75
|
+
const login = createPiOAuthLogin({ provider: "openai-codex", onAuth: async () => { throw new Error("delivery failed"); } });
|
|
76
|
+
await assert.rejects(login.promise, /delivery failed/);
|
|
77
|
+
assert.equal(login.manualInputRequested, false);
|
|
78
|
+
});
|
|
@@ -5,6 +5,7 @@ import { createPiCapabilityTools } from "../src/core/agent/pi-capability-tools.j
|
|
|
5
5
|
function createHarness() {
|
|
6
6
|
const calls = [];
|
|
7
7
|
let taskContext = { transportChatId: "chat-1", messageThreadId: 10 };
|
|
8
|
+
const agentTaskExecution = { blockedAuth: null };
|
|
8
9
|
const capabilityService = {
|
|
9
10
|
async execute(request) {
|
|
10
11
|
calls.push(request);
|
|
@@ -15,6 +16,7 @@ function createHarness() {
|
|
|
15
16
|
};
|
|
16
17
|
const telegram = {
|
|
17
18
|
getTaskContext: () => taskContext,
|
|
19
|
+
getAgentTaskExecution: () => agentTaskExecution,
|
|
18
20
|
sendMedia: async () => {}
|
|
19
21
|
};
|
|
20
22
|
const tools = createPiCapabilityTools({
|
|
@@ -62,4 +64,5 @@ test("Pi tool execution resolves the current Telegram task context per call", as
|
|
|
62
64
|
|
|
63
65
|
assert.equal(harness.calls[0].context.taskContext.messageThreadId, 10);
|
|
64
66
|
assert.equal(harness.calls[1].context.taskContext.messageThreadId, 20);
|
|
67
|
+
assert.equal(harness.calls[0].context.agentTaskExecution, harness.calls[1].context.agentTaskExecution);
|
|
65
68
|
});
|