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
|
@@ -1,13 +1,6 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { piAuthFile } from "../../platform/paths.js";
|
|
1
|
+
import { createPiRuntime, supportsProviderOAuth } from "./pi-runtime.js";
|
|
3
2
|
|
|
4
3
|
export function createPiOAuthLogin({ provider, onAuth, onDeviceCode, onPrompt, onProgress, onSelect } = {}) {
|
|
5
|
-
const authStorage = AuthStorage.create(piAuthFile);
|
|
6
|
-
const oauthProvider = authStorage.getOAuthProviders().find((item) => item.id === provider);
|
|
7
|
-
if (!oauthProvider) {
|
|
8
|
-
throw new Error(`No internal OAuth login flow is available for ${provider}.`);
|
|
9
|
-
}
|
|
10
|
-
|
|
11
4
|
let resolveManualCode;
|
|
12
5
|
const manualCodePromise = new Promise((resolve) => {
|
|
13
6
|
resolveManualCode = resolve;
|
|
@@ -15,7 +8,6 @@ export function createPiOAuthLogin({ provider, onAuth, onDeviceCode, onPrompt, o
|
|
|
15
8
|
|
|
16
9
|
const controller = {
|
|
17
10
|
provider,
|
|
18
|
-
oauthProvider,
|
|
19
11
|
manualInputRequested: false,
|
|
20
12
|
submitManualCode(value) {
|
|
21
13
|
if (!resolveManualCode) return false;
|
|
@@ -31,25 +23,33 @@ export function createPiOAuthLogin({ provider, onAuth, onDeviceCode, onPrompt, o
|
|
|
31
23
|
promise: null
|
|
32
24
|
};
|
|
33
25
|
|
|
34
|
-
controller.promise =
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
26
|
+
controller.promise = createPiRuntime().then(async (runtime) => {
|
|
27
|
+
if (!supportsProviderOAuth(provider, runtime)) {
|
|
28
|
+
throw new Error(`No internal OAuth login flow is available for ${provider}.`);
|
|
29
|
+
}
|
|
30
|
+
let notifications = Promise.resolve();
|
|
31
|
+
let notificationError;
|
|
32
|
+
const credential = await runtime.login(provider, "oauth", {
|
|
33
|
+
notify(event) {
|
|
34
|
+
notifications = notifications.then(async () => {
|
|
35
|
+
if (event.type === "auth_url") await onAuth?.({ ...event, controller });
|
|
36
|
+
else if (event.type === "device_code") await onDeviceCode?.({ ...event, controller });
|
|
37
|
+
else await onProgress?.(event.message);
|
|
38
|
+
}).catch((error) => { notificationError = error; });
|
|
39
|
+
},
|
|
40
|
+
async prompt(params) {
|
|
41
|
+
await notifications;
|
|
42
|
+
if (notificationError) throw notificationError;
|
|
43
|
+
if (params.type === "select") {
|
|
44
|
+
return onSelect ? onSelect({ ...params, controller }) : params.options?.[0]?.id;
|
|
45
|
+
}
|
|
46
|
+
if (params.type === "manual_code") return controller.waitForManualCode();
|
|
47
|
+
return onPrompt ? onPrompt({ ...params, controller }) : "";
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
await notifications;
|
|
51
|
+
if (notificationError) throw notificationError;
|
|
52
|
+
return credential;
|
|
53
53
|
}).finally(() => {
|
|
54
54
|
controller.submitManualCode("");
|
|
55
55
|
});
|
|
@@ -18,7 +18,7 @@ function nativeTools(policy) {
|
|
|
18
18
|
}];
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
-
export function createPiCapabilityTools({ capabilityService, telegram, chatId, policy, logger }) {
|
|
21
|
+
export function createPiCapabilityTools({ capabilityService, telegram, chatId, policy, logger, toolFanout }) {
|
|
22
22
|
if (!capabilityService?.execute) throw new Error("Pi capability tools require CapabilityService");
|
|
23
23
|
|
|
24
24
|
const baseContext = {
|
|
@@ -38,6 +38,7 @@ export function createPiCapabilityTools({ capabilityService, telegram, chatId, p
|
|
|
38
38
|
}
|
|
39
39
|
};
|
|
40
40
|
|
|
41
|
+
const runWithFanout = (work) => toolFanout?.run ? toolFanout.run(work) : work();
|
|
41
42
|
const execute = (actorToolName, method, params = {}, context = {}) => capabilityService.execute({
|
|
42
43
|
method,
|
|
43
44
|
actorToolName,
|
|
@@ -46,6 +47,7 @@ export function createPiCapabilityTools({ capabilityService, telegram, chatId, p
|
|
|
46
47
|
context: {
|
|
47
48
|
...baseContext,
|
|
48
49
|
taskContext: telegram.getTaskContext(),
|
|
50
|
+
agentTaskExecution: telegram.getAgentTaskExecution?.() || null,
|
|
49
51
|
...context
|
|
50
52
|
}
|
|
51
53
|
});
|
|
@@ -113,12 +115,14 @@ export function createPiCapabilityTools({ capabilityService, telegram, chatId, p
|
|
|
113
115
|
args: Type.Optional(Type.Record(Type.String(), Type.String())),
|
|
114
116
|
deliver: Type.Optional(Type.Boolean())
|
|
115
117
|
}),
|
|
116
|
-
execute: async (_id, params) => jsonResult(await
|
|
118
|
+
execute: async (_id, params) => jsonResult(await runWithFanout(
|
|
119
|
+
() => execute("run_tool", "tools.run", params)
|
|
120
|
+
))
|
|
117
121
|
}),
|
|
118
122
|
defineTool({
|
|
119
123
|
name: "list_scheduled_tasks",
|
|
120
124
|
label: "List scheduled tasks",
|
|
121
|
-
description: "List scheduled async tasks for the current Telegram chat. Results default to 50 tasks, always include pending
|
|
125
|
+
description: "List scheduled async tasks for the current Telegram chat. Results default to 50 tasks, always include pending, running, and authentication-blocked tasks, and accept an optional limit up to 100.",
|
|
122
126
|
parameters: Type.Object({
|
|
123
127
|
status: Type.Optional(Type.String()),
|
|
124
128
|
limit: Type.Optional(Type.Integer({ minimum: 1, maximum: maxScheduledTaskListLimit }))
|
|
@@ -135,7 +139,7 @@ export function createPiCapabilityTools({ capabilityService, telegram, chatId, p
|
|
|
135
139
|
defineTool({
|
|
136
140
|
name: "cancel_all_scheduled_tasks",
|
|
137
141
|
label: "Cancel all scheduled tasks",
|
|
138
|
-
description: "Cancel all
|
|
142
|
+
description: "Cancel all active async tasks, including authentication-blocked tasks, for the current Telegram chat.",
|
|
139
143
|
parameters: Type.Object({}),
|
|
140
144
|
execute: async () => jsonResult(await execute("cancel_all_scheduled_tasks", "tasks.cancelAll"))
|
|
141
145
|
}),
|
|
@@ -1,34 +1,29 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { ModelRuntime } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { piAuthFile } from "../../platform/paths.js";
|
|
3
3
|
|
|
4
4
|
function compareText(a, b) {
|
|
5
5
|
return a.localeCompare(b, undefined, { sensitivity: "base", numeric: true });
|
|
6
6
|
}
|
|
7
7
|
|
|
8
|
-
export function createPiRuntime({ provider, apiKey } = {}) {
|
|
9
|
-
const
|
|
8
|
+
export async function createPiRuntime({ provider, apiKey } = {}) {
|
|
9
|
+
const runtime = await ModelRuntime.create({ authPath: piAuthFile });
|
|
10
10
|
if (provider && apiKey) {
|
|
11
|
-
|
|
11
|
+
await runtime.setRuntimeApiKey(provider, apiKey);
|
|
12
12
|
}
|
|
13
|
-
|
|
14
|
-
const oauthProviders = authStorage.getOAuthProviders();
|
|
15
|
-
return { authStorage, modelRegistry, oauthProviders };
|
|
13
|
+
return runtime;
|
|
16
14
|
}
|
|
17
15
|
|
|
18
|
-
export function hasProviderAuth(provider,
|
|
19
|
-
return
|
|
16
|
+
export function hasProviderAuth(provider, runtime) {
|
|
17
|
+
return runtime.getProviderAuthStatus(provider).configured;
|
|
20
18
|
}
|
|
21
19
|
|
|
22
|
-
export function supportsProviderOAuth(provider,
|
|
23
|
-
return
|
|
20
|
+
export function supportsProviderOAuth(provider, runtime) {
|
|
21
|
+
return Boolean(runtime.getProvider(provider)?.auth.oauth);
|
|
24
22
|
}
|
|
25
23
|
|
|
26
|
-
export function listPiProviders(runtime
|
|
27
|
-
const { modelRegistry, oauthProviders } = runtime;
|
|
28
|
-
const allModels = modelRegistry.getAll();
|
|
29
|
-
const oauthIds = new Set(oauthProviders.map((item) => item.id));
|
|
24
|
+
export function listPiProviders(runtime) {
|
|
30
25
|
const counts = new Map();
|
|
31
|
-
for (const model of
|
|
26
|
+
for (const model of runtime.getModels()) {
|
|
32
27
|
counts.set(model.provider, (counts.get(model.provider) || 0) + 1);
|
|
33
28
|
}
|
|
34
29
|
|
|
@@ -36,7 +31,7 @@ export function listPiProviders(runtime = createPiRuntime()) {
|
|
|
36
31
|
.map((provider) => ({
|
|
37
32
|
provider,
|
|
38
33
|
authConfigured: hasProviderAuth(provider, runtime),
|
|
39
|
-
supportsOAuth:
|
|
34
|
+
supportsOAuth: supportsProviderOAuth(provider, runtime),
|
|
40
35
|
modelCount: counts.get(provider) || 0
|
|
41
36
|
}))
|
|
42
37
|
.sort((a, b) => {
|
|
@@ -46,10 +41,8 @@ export function listPiProviders(runtime = createPiRuntime()) {
|
|
|
46
41
|
});
|
|
47
42
|
}
|
|
48
43
|
|
|
49
|
-
export function listProviderModels(provider, runtime
|
|
50
|
-
return runtime.
|
|
51
|
-
.getAll()
|
|
52
|
-
.filter((model) => model.provider === provider)
|
|
44
|
+
export function listProviderModels(provider, runtime) {
|
|
45
|
+
return [...runtime.getModels(provider)]
|
|
53
46
|
.sort((a, b) => compareText(a.name || a.id, b.name || b.id));
|
|
54
47
|
}
|
|
55
48
|
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { closeSync, openSync, readSync, readdirSync, statSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { StringDecoder } from "node:string_decoder";
|
|
4
|
+
|
|
5
|
+
const readBufferBytes = 1024 * 1024;
|
|
6
|
+
const supportedSessionVersion = 3;
|
|
7
|
+
|
|
8
|
+
function parseEntry(line) {
|
|
9
|
+
if (!line.trim()) return null;
|
|
10
|
+
try {
|
|
11
|
+
return JSON.parse(line);
|
|
12
|
+
} catch {
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function readSessionHeader(filePath) {
|
|
18
|
+
const descriptor = openSync(filePath, "r");
|
|
19
|
+
try {
|
|
20
|
+
const buffer = Buffer.alloc(4096);
|
|
21
|
+
const bytesRead = readSync(descriptor, buffer, 0, buffer.length, 0);
|
|
22
|
+
const line = buffer.toString("utf8", 0, bytesRead).split("\n", 1)[0];
|
|
23
|
+
const header = parseEntry(line);
|
|
24
|
+
return header?.type === "session" ? header : null;
|
|
25
|
+
} catch {
|
|
26
|
+
return null;
|
|
27
|
+
} finally {
|
|
28
|
+
closeSync(descriptor);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function findMostRecentSessionFile(sessionDir, cwd) {
|
|
33
|
+
const expectedCwd = path.resolve(cwd);
|
|
34
|
+
try {
|
|
35
|
+
return readdirSync(sessionDir)
|
|
36
|
+
.filter((name) => name.endsWith(".jsonl"))
|
|
37
|
+
.map((name) => path.join(sessionDir, name))
|
|
38
|
+
.map((filePath) => ({ filePath, header: readSessionHeader(filePath) }))
|
|
39
|
+
.filter(({ header }) => header && typeof header.cwd === "string" && path.resolve(header.cwd) === expectedCwd)
|
|
40
|
+
.map(({ filePath }) => ({ filePath, mtimeMs: statSync(filePath).mtimeMs }))
|
|
41
|
+
.sort((left, right) => right.mtimeMs - left.mtimeMs)[0]?.filePath || null;
|
|
42
|
+
} catch {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function streamSessionEntries(filePath, visit) {
|
|
48
|
+
const descriptor = openSync(filePath, "r");
|
|
49
|
+
try {
|
|
50
|
+
const decoder = new StringDecoder("utf8");
|
|
51
|
+
const buffer = Buffer.allocUnsafe(readBufferBytes);
|
|
52
|
+
let pending = "";
|
|
53
|
+
while (true) {
|
|
54
|
+
const bytesRead = readSync(descriptor, buffer, 0, buffer.length, null);
|
|
55
|
+
if (!bytesRead) break;
|
|
56
|
+
pending += decoder.write(buffer.subarray(0, bytesRead));
|
|
57
|
+
let newline = pending.indexOf("\n");
|
|
58
|
+
while (newline !== -1) {
|
|
59
|
+
const entry = parseEntry(pending.slice(0, newline));
|
|
60
|
+
if (entry) visit(entry);
|
|
61
|
+
pending = pending.slice(newline + 1);
|
|
62
|
+
newline = pending.indexOf("\n");
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
pending += decoder.end();
|
|
66
|
+
const entry = parseEntry(pending);
|
|
67
|
+
if (entry) visit(entry);
|
|
68
|
+
} finally {
|
|
69
|
+
closeSync(descriptor);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function traceActivePath(entries, leafId) {
|
|
74
|
+
const reversed = [];
|
|
75
|
+
const seen = new Set();
|
|
76
|
+
let currentId = leafId;
|
|
77
|
+
while (currentId) {
|
|
78
|
+
if (seen.has(currentId)) return null;
|
|
79
|
+
seen.add(currentId);
|
|
80
|
+
const entry = entries.get(currentId);
|
|
81
|
+
if (!entry) return null;
|
|
82
|
+
reversed.push(entry);
|
|
83
|
+
currentId = entry.parentId || null;
|
|
84
|
+
}
|
|
85
|
+
return reversed.reverse();
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function findLatestValidCompaction(path) {
|
|
89
|
+
const pathIndex = new Map(path.map((entry, index) => [entry.id, index]));
|
|
90
|
+
for (let index = path.length - 1; index >= 0; index -= 1) {
|
|
91
|
+
const entry = path[index];
|
|
92
|
+
if (entry.type !== "compaction" || !entry.hasSummary) continue;
|
|
93
|
+
if (!entry.firstKeptEntryId) return { entry, index, firstKeptIndex: index };
|
|
94
|
+
const firstKeptIndex = pathIndex.get(entry.firstKeptEntryId);
|
|
95
|
+
if (firstKeptIndex !== undefined && firstKeptIndex < index) {
|
|
96
|
+
return { entry, index, firstKeptIndex };
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function inspectSessionGraph(filePath) {
|
|
103
|
+
let header = null;
|
|
104
|
+
let firstEntry = true;
|
|
105
|
+
let invalidHeader = false;
|
|
106
|
+
let leafId = null;
|
|
107
|
+
let duplicateId = false;
|
|
108
|
+
const entries = new Map();
|
|
109
|
+
streamSessionEntries(filePath, (entry) => {
|
|
110
|
+
if (firstEntry) {
|
|
111
|
+
firstEntry = false;
|
|
112
|
+
header = entry.type === "session" ? entry : null;
|
|
113
|
+
invalidHeader = !header;
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
if (invalidHeader) return;
|
|
117
|
+
if (!entry.id || entry.type === "session") return;
|
|
118
|
+
if (entries.has(entry.id)) duplicateId = true;
|
|
119
|
+
entries.set(entry.id, {
|
|
120
|
+
id: entry.id,
|
|
121
|
+
parentId: entry.parentId || null,
|
|
122
|
+
type: entry.type,
|
|
123
|
+
firstKeptEntryId: entry.type === "compaction" ? entry.firstKeptEntryId || null : null,
|
|
124
|
+
hasSummary: entry.type === "compaction" && Boolean(String(entry.summary || "").trim())
|
|
125
|
+
});
|
|
126
|
+
leafId = entry.id;
|
|
127
|
+
});
|
|
128
|
+
if (invalidHeader || !header || header.version !== supportedSessionVersion || duplicateId || !leafId) return null;
|
|
129
|
+
const path = traceActivePath(entries, leafId);
|
|
130
|
+
if (!path) return null;
|
|
131
|
+
const compaction = findLatestValidCompaction(path);
|
|
132
|
+
if (!compaction) return null;
|
|
133
|
+
return { header, path, compaction };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function loadMigrationPayload(filePath, path, compaction) {
|
|
137
|
+
const before = path.slice(compaction.firstKeptIndex, compaction.index);
|
|
138
|
+
const after = path.slice(compaction.index + 1);
|
|
139
|
+
const contextIds = [...before, ...after].map((entry) => entry.id);
|
|
140
|
+
const wanted = new Set(contextIds);
|
|
141
|
+
const loaded = new Map();
|
|
142
|
+
let summary = "";
|
|
143
|
+
streamSessionEntries(filePath, (entry) => {
|
|
144
|
+
if (entry.id === compaction.entry.id) summary = String(entry.summary || "").trim();
|
|
145
|
+
if (wanted.has(entry.id)) loaded.set(entry.id, entry);
|
|
146
|
+
});
|
|
147
|
+
if (!summary || loaded.size !== wanted.size) return null;
|
|
148
|
+
return {
|
|
149
|
+
summary,
|
|
150
|
+
contextEntries: contextIds.map((id) => loaded.get(id))
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function inspectSessionForPreloadMigration(filePath, maxPersistedBytes) {
|
|
155
|
+
const sourceBytes = statSync(filePath).size;
|
|
156
|
+
if (sourceBytes <= Math.max(1, Number(maxPersistedBytes) || 1)) return null;
|
|
157
|
+
const graph = inspectSessionGraph(filePath);
|
|
158
|
+
if (!graph) return null;
|
|
159
|
+
const payload = loadMigrationPayload(filePath, graph.path, graph.compaction);
|
|
160
|
+
if (!payload) return null;
|
|
161
|
+
return {
|
|
162
|
+
sourceFile: filePath,
|
|
163
|
+
sourceBytes,
|
|
164
|
+
header: graph.header,
|
|
165
|
+
compactionId: graph.compaction.entry.id,
|
|
166
|
+
...payload
|
|
167
|
+
};
|
|
168
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import {
|
|
2
|
+
closeSync,
|
|
3
|
+
fsyncSync,
|
|
4
|
+
openSync,
|
|
5
|
+
renameSync,
|
|
6
|
+
statSync,
|
|
7
|
+
unlinkSync,
|
|
8
|
+
writeFileSync
|
|
9
|
+
} from "node:fs";
|
|
10
|
+
import path from "node:path";
|
|
11
|
+
import { randomUUID } from "node:crypto";
|
|
12
|
+
import {
|
|
13
|
+
findMostRecentSessionFile,
|
|
14
|
+
inspectSessionForPreloadMigration
|
|
15
|
+
} from "./session-history-reader.js";
|
|
16
|
+
import { normalizeSessionRotationPolicy } from "./session-rotation.js";
|
|
17
|
+
|
|
18
|
+
const contextEntryTypes = new Set([
|
|
19
|
+
"message",
|
|
20
|
+
"custom_message",
|
|
21
|
+
"branch_summary",
|
|
22
|
+
"thinking_level_change",
|
|
23
|
+
"model_change"
|
|
24
|
+
]);
|
|
25
|
+
|
|
26
|
+
function createEntry(type, parentId, fields = {}) {
|
|
27
|
+
return {
|
|
28
|
+
type,
|
|
29
|
+
...fields,
|
|
30
|
+
id: randomUUID(),
|
|
31
|
+
parentId,
|
|
32
|
+
timestamp: fields.timestamp || new Date().toISOString()
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function appendDurably(filePath, entry) {
|
|
37
|
+
writeFileSync(filePath, `${JSON.stringify(entry)}\n`, { encoding: "utf8", flag: "a" });
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function syncFile(filePath) {
|
|
41
|
+
const descriptor = openSync(filePath, "r");
|
|
42
|
+
try {
|
|
43
|
+
fsyncSync(descriptor);
|
|
44
|
+
} finally {
|
|
45
|
+
closeSync(descriptor);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function syncDirectory(directory) {
|
|
50
|
+
const descriptor = openSync(directory, "r");
|
|
51
|
+
try {
|
|
52
|
+
fsyncSync(descriptor);
|
|
53
|
+
} finally {
|
|
54
|
+
closeSync(descriptor);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function migratedEntry(source, parentId) {
|
|
59
|
+
const { id: _id, parentId: _parentId, ...fields } = source;
|
|
60
|
+
return createEntry(source.type, parentId, fields);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function createPreloadMigrationChild({
|
|
64
|
+
sessionDir,
|
|
65
|
+
cwd,
|
|
66
|
+
migration,
|
|
67
|
+
operationalNotes = "",
|
|
68
|
+
now = new Date()
|
|
69
|
+
}) {
|
|
70
|
+
const timestamp = now.toISOString();
|
|
71
|
+
const sessionId = randomUUID();
|
|
72
|
+
const filenameTimestamp = timestamp.replace(/[:.]/g, "-");
|
|
73
|
+
const targetFile = path.join(sessionDir, `${filenameTimestamp}_${sessionId}.jsonl`);
|
|
74
|
+
const temporaryFile = path.join(sessionDir, `.session-migration-${sessionId}.tmp`);
|
|
75
|
+
const header = {
|
|
76
|
+
type: "session",
|
|
77
|
+
version: 3,
|
|
78
|
+
id: sessionId,
|
|
79
|
+
timestamp,
|
|
80
|
+
cwd,
|
|
81
|
+
parentSession: migration.sourceFile
|
|
82
|
+
};
|
|
83
|
+
let parentId = null;
|
|
84
|
+
let copiedEntries = 0;
|
|
85
|
+
|
|
86
|
+
try {
|
|
87
|
+
writeFileSync(temporaryFile, `${JSON.stringify(header)}\n`, { encoding: "utf8", flag: "wx" });
|
|
88
|
+
const notes = String(operationalNotes || "").trim();
|
|
89
|
+
if (notes) {
|
|
90
|
+
const entry = createEntry("custom_message", parentId, {
|
|
91
|
+
customType: "arisa-operational-notes",
|
|
92
|
+
content: notes,
|
|
93
|
+
display: false,
|
|
94
|
+
details: { source: "session-start" }
|
|
95
|
+
});
|
|
96
|
+
appendDurably(temporaryFile, entry);
|
|
97
|
+
parentId = entry.id;
|
|
98
|
+
}
|
|
99
|
+
const handoff = createEntry("custom_message", parentId, {
|
|
100
|
+
customType: "arisa-session-handoff",
|
|
101
|
+
content: [
|
|
102
|
+
"Automatic session migration before loading. Continue from this checkpoint:",
|
|
103
|
+
"",
|
|
104
|
+
migration.summary
|
|
105
|
+
].join("\n"),
|
|
106
|
+
display: false,
|
|
107
|
+
details: { source: "preload-migration" }
|
|
108
|
+
});
|
|
109
|
+
appendDurably(temporaryFile, handoff);
|
|
110
|
+
parentId = handoff.id;
|
|
111
|
+
|
|
112
|
+
for (const sourceEntry of migration.contextEntries) {
|
|
113
|
+
if (!contextEntryTypes.has(sourceEntry.type)) continue;
|
|
114
|
+
const entry = migratedEntry(sourceEntry, parentId);
|
|
115
|
+
appendDurably(temporaryFile, entry);
|
|
116
|
+
parentId = entry.id;
|
|
117
|
+
copiedEntries += 1;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
syncFile(temporaryFile);
|
|
121
|
+
renameSync(temporaryFile, targetFile);
|
|
122
|
+
syncDirectory(sessionDir);
|
|
123
|
+
return {
|
|
124
|
+
sourceFile: migration.sourceFile,
|
|
125
|
+
sourceBytes: migration.sourceBytes,
|
|
126
|
+
targetFile,
|
|
127
|
+
targetBytes: statSync(targetFile).size,
|
|
128
|
+
copiedEntries
|
|
129
|
+
};
|
|
130
|
+
} catch (error) {
|
|
131
|
+
try {
|
|
132
|
+
unlinkSync(temporaryFile);
|
|
133
|
+
} catch {}
|
|
134
|
+
throw error;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function migrateRecentSessionBeforeLoad({
|
|
139
|
+
sessionDir,
|
|
140
|
+
cwd,
|
|
141
|
+
policy,
|
|
142
|
+
operationalNotes = ""
|
|
143
|
+
}) {
|
|
144
|
+
const normalized = normalizeSessionRotationPolicy(policy);
|
|
145
|
+
if (!normalized.enabled) return null;
|
|
146
|
+
const sourceFile = findMostRecentSessionFile(sessionDir, cwd);
|
|
147
|
+
if (!sourceFile) return null;
|
|
148
|
+
const sourceBytes = statSync(sourceFile).size;
|
|
149
|
+
if (sourceBytes <= normalized.maxPersistedBytes) return null;
|
|
150
|
+
const migration = inspectSessionForPreloadMigration(sourceFile, normalized.maxPersistedBytes);
|
|
151
|
+
if (!migration) {
|
|
152
|
+
const error = new Error("Oversized Pi session could not be migrated safely before loading");
|
|
153
|
+
error.code = "PI_SESSION_PRELOAD_MIGRATION_UNAVAILABLE";
|
|
154
|
+
throw error;
|
|
155
|
+
}
|
|
156
|
+
return createPreloadMigrationChild({
|
|
157
|
+
sessionDir,
|
|
158
|
+
cwd,
|
|
159
|
+
migration,
|
|
160
|
+
operationalNotes
|
|
161
|
+
});
|
|
162
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
const defaultCompactAtPersistedBytes = 24 * 1024 * 1024;
|
|
2
|
+
const defaultMaxPersistedBytes = 32 * 1024 * 1024;
|
|
3
|
+
|
|
4
|
+
export function normalizeSessionRotationPolicy(policy = {}) {
|
|
5
|
+
const maxPersistedBytes = Math.max(1, Number(policy?.maxPersistedBytes) || defaultMaxPersistedBytes);
|
|
6
|
+
return {
|
|
7
|
+
enabled: policy?.enabled !== false,
|
|
8
|
+
compactAtPersistedBytes: Math.min(
|
|
9
|
+
maxPersistedBytes,
|
|
10
|
+
Math.max(1, Number(policy?.compactAtPersistedBytes) || defaultCompactAtPersistedBytes)
|
|
11
|
+
),
|
|
12
|
+
maxPersistedBytes
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function compactionRotationRequest(event, persistedBytes, policy = {}) {
|
|
17
|
+
const normalized = normalizeSessionRotationPolicy(policy);
|
|
18
|
+
if (!normalized.enabled || event?.type !== "compaction_end" || event.aborted || event.errorMessage) return null;
|
|
19
|
+
const summary = String(event.result?.summary || "").trim();
|
|
20
|
+
if (!summary || Math.max(0, Number(persistedBytes) || 0) <= normalized.compactAtPersistedBytes) return null;
|
|
21
|
+
return {
|
|
22
|
+
handoff: [
|
|
23
|
+
"Automatic session rotation after compaction. Continue from this checkpoint:",
|
|
24
|
+
"",
|
|
25
|
+
summary
|
|
26
|
+
].join("\n"),
|
|
27
|
+
persistedBytes: Math.max(0, Number(persistedBytes) || 0)
|
|
28
|
+
};
|
|
29
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
const defaults = Object.freeze({
|
|
2
|
+
enabled: true,
|
|
3
|
+
maxConcurrent: 2,
|
|
4
|
+
pressureConcurrent: 1,
|
|
5
|
+
serializePercent: 60
|
|
6
|
+
});
|
|
7
|
+
|
|
8
|
+
function boundedInteger(value, fallback, minimum, maximum) {
|
|
9
|
+
const number = Number(value);
|
|
10
|
+
return Number.isFinite(number) ? Math.min(maximum, Math.max(minimum, Math.floor(number))) : fallback;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function boundedPercent(value, fallback) {
|
|
14
|
+
const number = Number(value);
|
|
15
|
+
return Number.isFinite(number) ? Math.min(99, Math.max(1, number)) : fallback;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function normalizeToolFanoutConfig(config = {}) {
|
|
19
|
+
const maxConcurrent = boundedInteger(config.maxConcurrent, defaults.maxConcurrent, 1, 16);
|
|
20
|
+
return {
|
|
21
|
+
enabled: config.enabled !== false,
|
|
22
|
+
maxConcurrent,
|
|
23
|
+
pressureConcurrent: Math.min(
|
|
24
|
+
maxConcurrent,
|
|
25
|
+
boundedInteger(config.pressureConcurrent, defaults.pressureConcurrent, 1, 16)
|
|
26
|
+
),
|
|
27
|
+
serializePercent: boundedPercent(config.serializePercent, defaults.serializePercent)
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export class WorkerToolFanoutController {
|
|
32
|
+
constructor({ heapCircuitBreaker, logger, config = {} }) {
|
|
33
|
+
this.heapCircuitBreaker = heapCircuitBreaker;
|
|
34
|
+
this.logger = logger;
|
|
35
|
+
this.active = 0;
|
|
36
|
+
this.queue = [];
|
|
37
|
+
this.draining = false;
|
|
38
|
+
this.metrics = {
|
|
39
|
+
peakActive: 0,
|
|
40
|
+
peakQueued: 0,
|
|
41
|
+
pressureSerializations: 0,
|
|
42
|
+
rejectedAdmissions: 0,
|
|
43
|
+
completed: 0
|
|
44
|
+
};
|
|
45
|
+
this.setConfig(config);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
setConfig(config = {}) {
|
|
49
|
+
this.config = normalizeToolFanoutConfig(config);
|
|
50
|
+
this.drain();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
capacity() {
|
|
54
|
+
if (!this.config.enabled) return Number.POSITIVE_INFINITY;
|
|
55
|
+
const pressure = this.heapCircuitBreaker.sample();
|
|
56
|
+
if (pressure.percent >= this.config.serializePercent) {
|
|
57
|
+
this.metrics.pressureSerializations += 1;
|
|
58
|
+
return this.config.pressureConcurrent;
|
|
59
|
+
}
|
|
60
|
+
return this.config.maxConcurrent;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async drain() {
|
|
64
|
+
if (this.draining) return;
|
|
65
|
+
this.draining = true;
|
|
66
|
+
try {
|
|
67
|
+
while (this.queue.length && this.active < this.capacity()) {
|
|
68
|
+
const job = this.queue.shift();
|
|
69
|
+
try {
|
|
70
|
+
await this.heapCircuitBreaker.admit();
|
|
71
|
+
} catch (error) {
|
|
72
|
+
this.metrics.rejectedAdmissions += 1;
|
|
73
|
+
job.reject(error);
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
this.active += 1;
|
|
77
|
+
this.metrics.peakActive = Math.max(this.metrics.peakActive, this.active);
|
|
78
|
+
job.resolve();
|
|
79
|
+
}
|
|
80
|
+
} finally {
|
|
81
|
+
this.draining = false;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
acquire() {
|
|
86
|
+
if (!this.config.enabled) return Promise.resolve();
|
|
87
|
+
return new Promise((resolve, reject) => {
|
|
88
|
+
this.queue.push({ resolve, reject });
|
|
89
|
+
this.metrics.peakQueued = Math.max(this.metrics.peakQueued, this.queue.length);
|
|
90
|
+
this.drain();
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
release() {
|
|
95
|
+
if (this.config.enabled) this.active = Math.max(0, this.active - 1);
|
|
96
|
+
this.metrics.completed += 1;
|
|
97
|
+
this.drain();
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async run(work) {
|
|
101
|
+
await this.acquire();
|
|
102
|
+
try {
|
|
103
|
+
return await work();
|
|
104
|
+
} finally {
|
|
105
|
+
this.release();
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
getDiagnostic() {
|
|
110
|
+
return {
|
|
111
|
+
...this.config,
|
|
112
|
+
active: this.active,
|
|
113
|
+
queued: this.queue.length,
|
|
114
|
+
...this.metrics
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
}
|