agent-accelerator 0.5.7 → 0.5.8
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 +4 -4
- package/package.json +1 -1
- package/src/agent/agent.ts +76 -39
- package/src/agent/context.ts +3 -3
- package/src/agent/delegation.ts +12 -12
- package/src/agent/loop.ts +41 -41
- package/src/agent/subagent.ts +4 -4
- package/src/architecture.md +1 -1
- package/src/index.ts +7 -7
- package/src/models/catalog-cache.ts +7 -7
- package/src/models/catalog.ts +13 -13
- package/src/providers/google.ts +4 -4
- package/src/providers/openai-compat.ts +7 -7
- package/src/providers/openai.ts +5 -5
- package/src/providers/openrouter.ts +2 -2
- package/src/providers/shared.ts +4 -4
- package/src/scheduler/architecture.md +5 -2
- package/src/scheduler/scheduler.ts +111 -57
- package/src/scheduler/store.ts +273 -59
- package/src/session/architecture.md +1 -1
- package/src/session/store.ts +22 -22
- package/src/streaming/event-stream.ts +11 -11
- package/src/telemetry-file.ts +74 -24
- package/src/telemetry.ts +2 -2
- package/src/tools/executor.ts +1 -1
- package/src/tools/tool.ts +8 -8
- package/src/types/agent.ts +22 -20
- package/src/types/response.ts +8 -8
- package/src/types/scheduler.ts +2 -2
- package/src/types/tool.ts +5 -5
- package/src/update-models.ts +2 -2
- package/src/utils/architecture.md +1 -1
- package/src/utils/cache.ts +4 -4
- package/src/utils/durable.ts +78 -3
- package/src/utils/env.ts +1 -1
- package/src/utils/headers.ts +4 -4
- package/src/utils/home.ts +1 -1
- package/src/utils/retry.ts +1 -1
- package/src/utils/session.ts +5 -5
package/src/scheduler/store.ts
CHANGED
|
@@ -1,18 +1,21 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Disk layer for scheduled actions. Everything lives under one home folder:
|
|
3
3
|
*
|
|
4
|
-
* <home>/
|
|
5
|
-
* <home>/
|
|
6
|
-
* <home>/
|
|
4
|
+
* <home>/scheduled/<id>/task.json the action (`{ version: 2, task }`)
|
|
5
|
+
* <home>/scheduled/<id>/delivery.json its saved results waiting for their delivery time (`{ version: 2, pending }`)
|
|
6
|
+
* <home>/scheduled/<id>/<date>.md one file per run
|
|
7
|
+
* <home>/scheduled/<id>/.mutex short lock held while this action's files change
|
|
8
|
+
* <home>/scheduled/<id>/.heartbeat written while a process is running the action (pid, host; its mtime is refreshed)
|
|
7
9
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* Sync `fs`, like the session store.
|
|
10
|
+
* One folder per action, so processes working on different actions never touch the same file. The files are the
|
|
11
|
+
* source of truth: every change takes the action's mutex, re-reads first (never writes from a stale copy) and goes
|
|
12
|
+
* through `writeFileDurable` (temp file, fsync, `.bak`, rename). Sync `fs`, like the session store.
|
|
11
13
|
*/
|
|
12
14
|
import * as fs from "node:fs";
|
|
15
|
+
import * as os from "node:os";
|
|
13
16
|
import * as path from "node:path";
|
|
14
17
|
import { z } from "zod";
|
|
15
|
-
import { readWithBackup, writeFileDurable } from "../utils/durable.ts";
|
|
18
|
+
import { readWithBackup, withFileMutex, writeFileDurable } from "../utils/durable.ts";
|
|
16
19
|
import { telemetryBus } from "../telemetry.ts";
|
|
17
20
|
import { resolveHome } from "../utils/home.ts";
|
|
18
21
|
export { resolveHome };
|
|
@@ -26,10 +29,10 @@ export const TASK_ID_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
|
26
29
|
/** 48-char slug + `-` + 6 random chars. */
|
|
27
30
|
export const MAX_TASK_ID_LENGTH = 55;
|
|
28
31
|
|
|
29
|
-
/** A saved run waiting for its delivery time (one entry of `
|
|
32
|
+
/** A saved run waiting for its delivery time (one entry of an action's `delivery.json`). */
|
|
30
33
|
export interface PendingDelivery {
|
|
31
34
|
id: string;
|
|
32
|
-
/** Run file path, relative to `
|
|
35
|
+
/** Run file path, relative to `scheduled/`. */
|
|
33
36
|
file: string;
|
|
34
37
|
status: ScheduledRunStatus;
|
|
35
38
|
readyAt: string;
|
|
@@ -72,34 +75,105 @@ export interface RunHeader {
|
|
|
72
75
|
error?: string;
|
|
73
76
|
}
|
|
74
77
|
|
|
75
|
-
// The `
|
|
76
|
-
const baseDir = (home: string): string => path.join(home, "
|
|
77
|
-
// Path of tasks.json; in: readTasks/updateTasks, out: its path.
|
|
78
|
-
const tasksFile = (home: string): string => path.join(baseDir(home), "tasks.json");
|
|
79
|
-
// Path of deliveries.json; in: readDeliveries/updateDeliveries, out: its path.
|
|
80
|
-
const deliveriesFile = (home: string): string => path.join(baseDir(home), "deliveries.json");
|
|
78
|
+
// The `scheduled` folder of a home; in: the path helpers, out: its path.
|
|
79
|
+
const baseDir = (home: string): string => path.join(home, "scheduled");
|
|
81
80
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
81
|
+
// The folder of one action; refuses anything that is not an action id, so a hand-edited id can never point outside `scheduled/`; in: every per-action helper, out: its path.
|
|
82
|
+
function taskDir(home: string, id: string): string {
|
|
83
|
+
if (typeof id !== "string" || !TASK_ID_PATTERN.test(id) || id.length > MAX_TASK_ID_LENGTH) {
|
|
84
|
+
throw new ValidationError(`${JSON.stringify(id)} is not a scheduled action id.`);
|
|
85
|
+
}
|
|
86
|
+
return path.join(baseDir(home), id);
|
|
87
|
+
}
|
|
88
|
+
// Path of an action's record; in: the task helpers, out: its path.
|
|
89
|
+
const taskFile = (dir: string): string => path.join(dir, "task.json");
|
|
90
|
+
// Path of an action's saved deliveries; in: the delivery helpers, out: its path.
|
|
91
|
+
const deliveryFile = (dir: string): string => path.join(dir, "delivery.json");
|
|
92
|
+
// Path of the short mutex that serialises every change to one action (record and deliveries); in: updateTask/updateDeliveries/deleteTask, out: its path.
|
|
93
|
+
const mutexFile = (dir: string): string => path.join(dir, ".mutex");
|
|
94
|
+
|
|
95
|
+
// Path of the file a running action's owner keeps fresh; in: the heartbeat helpers, out: its path.
|
|
96
|
+
const heartbeatFile = (dir: string): string => path.join(dir, ".heartbeat");
|
|
97
|
+
|
|
98
|
+
/** Who is running an action and how long ago they last said so. */
|
|
99
|
+
export interface Heartbeat {
|
|
100
|
+
pid: number;
|
|
101
|
+
host: string;
|
|
102
|
+
/** Identifies the scheduler instance inside that process. */
|
|
103
|
+
token: string;
|
|
104
|
+
ageMs: number;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Marks an action as running in this process (pid, host, token); written before the record flips to `cooking`; in: startCook, out: `<id>/.heartbeat`. */
|
|
108
|
+
export function writeHeartbeat(home: string, id: string, token: string): void {
|
|
109
|
+
fs.writeFileSync(heartbeatFile(taskDir(home, id)), JSON.stringify({ pid: process.pid, host: os.hostname(), token }), { mode: 0o600 });
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Refreshes the heartbeat's modification time; false when the file is gone; in: the scheduler tick for each run in flight. */
|
|
113
|
+
export function touchHeartbeat(home: string, id: string): boolean {
|
|
114
|
+
try {
|
|
115
|
+
const now = new Date();
|
|
116
|
+
fs.utimesSync(heartbeatFile(taskDir(home, id)), now, now);
|
|
117
|
+
return true;
|
|
118
|
+
} catch {
|
|
119
|
+
return false;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** The heartbeat of an action, or undefined when it has none or the file is unreadable; in: the dead-owner checks and `delete`. */
|
|
124
|
+
export function readHeartbeat(home: string, id: string): Heartbeat | undefined {
|
|
125
|
+
try {
|
|
126
|
+
const file = heartbeatFile(taskDir(home, id));
|
|
127
|
+
const raw = JSON.parse(fs.readFileSync(file, "utf8")) as Partial<Heartbeat>;
|
|
128
|
+
if (typeof raw.pid !== "number" || typeof raw.host !== "string" || typeof raw.token !== "string") return undefined;
|
|
129
|
+
return { pid: raw.pid, host: raw.host, token: raw.token, ageMs: Date.now() - fs.statSync(file).mtimeMs };
|
|
130
|
+
} catch {
|
|
131
|
+
return undefined;
|
|
132
|
+
}
|
|
85
133
|
}
|
|
86
134
|
|
|
87
|
-
/**
|
|
88
|
-
export function
|
|
89
|
-
const legacy = path.join(home, "scheduled_tasks");
|
|
135
|
+
/** Removes the heartbeat when a run ends; in: the scheduler after a run, out: nothing. */
|
|
136
|
+
export function clearHeartbeat(home: string, id: string): void {
|
|
90
137
|
try {
|
|
91
|
-
|
|
138
|
+
fs.rmSync(heartbeatFile(taskDir(home, id)), { force: true });
|
|
92
139
|
} catch {
|
|
93
|
-
// why:
|
|
140
|
+
// why: a leftover heartbeat of a finished run is harmless (it is only read for actions that are cooking).
|
|
94
141
|
}
|
|
95
142
|
}
|
|
96
143
|
|
|
144
|
+
// True for the error of a folder that was removed while we waited for its lock; in: the catch blocks of the mutating helpers, out: a boolean.
|
|
145
|
+
const isMissingFolder = (err: unknown): boolean => (err as { code?: string } | null)?.code === "ENOENT";
|
|
146
|
+
|
|
147
|
+
// Deletes the leftover temp files of a task.json / delivery.json write that never finished (a killed process); only called while holding the action's mutex, which is what makes it safe (run files are written outside the mutex, so they are never touched); in: the three mutating helpers, out: nothing.
|
|
148
|
+
function sweepShardTemps(dir: string): void {
|
|
149
|
+
try {
|
|
150
|
+
for (const name of fs.readdirSync(dir)) if (/^(task|delivery)\.json\.tmp-\d+-[0-9a-z]+$/.test(name)) fs.rmSync(path.join(dir, name), { force: true });
|
|
151
|
+
} catch {
|
|
152
|
+
// why: an unreadable folder or an undeletable leftover is harmless.
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Absolute path of a run file given its `scheduled/`-relative path. */
|
|
157
|
+
export function runPath(home: string, relativeFile: string): string {
|
|
158
|
+
return path.resolve(baseDir(home), relativeFile);
|
|
159
|
+
}
|
|
160
|
+
|
|
97
161
|
type Raw = Record<string, unknown>;
|
|
98
162
|
|
|
99
|
-
// Parses
|
|
100
|
-
function
|
|
163
|
+
// Parses `{ version, task: {...} }` into the record; undefined when the text is not that shape; in: readShard, out: the record.
|
|
164
|
+
function parseTask(text: string): Raw | undefined {
|
|
101
165
|
try {
|
|
102
|
-
const
|
|
166
|
+
const task = (JSON.parse(text) as Raw | null)?.["task"];
|
|
167
|
+
return task && typeof task === "object" && !Array.isArray(task) ? (task as Raw) : undefined;
|
|
168
|
+
} catch {
|
|
169
|
+
return undefined;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Parses `{ version, pending: [...] }` into its object entries; undefined when the text is not that shape; in: readShard, out: the entries.
|
|
174
|
+
function parsePending(text: string): Raw[] | undefined {
|
|
175
|
+
try {
|
|
176
|
+
const list = (JSON.parse(text) as Raw | null)?.["pending"];
|
|
103
177
|
if (!Array.isArray(list)) return undefined;
|
|
104
178
|
return list.filter((x): x is Raw => !!x && typeof x === "object" && !Array.isArray(x));
|
|
105
179
|
} catch {
|
|
@@ -108,21 +182,21 @@ function parseList(text: string, key: "tasks" | "pending"): Raw[] | undefined {
|
|
|
108
182
|
}
|
|
109
183
|
|
|
110
184
|
/**
|
|
111
|
-
* Reads
|
|
112
|
-
* (the last good version) is valid it is restored, else the
|
|
185
|
+
* Reads one JSON file of an action. Missing = undefined. A damaged file is renamed `*.corrupt-<ts>`; if its `.bak`
|
|
186
|
+
* (the last good version) is valid it is restored and returned, else undefined (the action is treated as absent).
|
|
113
187
|
*/
|
|
114
|
-
function
|
|
115
|
-
const read = readWithBackup(file, (t) =>
|
|
116
|
-
if (!read) return
|
|
117
|
-
const
|
|
118
|
-
if (
|
|
188
|
+
function readShard<T>(file: string, parse: (text: string) => T | undefined): T | undefined {
|
|
189
|
+
const read = readWithBackup(file, (t) => parse(t) !== undefined);
|
|
190
|
+
if (!read) return undefined;
|
|
191
|
+
const value = parse(read.text);
|
|
192
|
+
if (value !== undefined && !read.fromBackup) return value;
|
|
119
193
|
const moved = `${file}.corrupt-${Date.now()}`;
|
|
120
194
|
try {
|
|
121
195
|
fs.renameSync(file, moved);
|
|
122
196
|
} catch {
|
|
123
197
|
// why: quarantine is best-effort; the file is ignored either way.
|
|
124
198
|
}
|
|
125
|
-
if (
|
|
199
|
+
if (value !== undefined) {
|
|
126
200
|
try {
|
|
127
201
|
writeFileDurable(file, read.text, { backup: false }); // keep the good .bak where it is
|
|
128
202
|
} catch {
|
|
@@ -131,41 +205,181 @@ function readList(file: string, key: "tasks" | "pending"): Raw[] {
|
|
|
131
205
|
}
|
|
132
206
|
telemetryBus.emitWarning(
|
|
133
207
|
"scheduled_action.store_corrupt",
|
|
134
|
-
`[Agent Accelerator] ${path.
|
|
135
|
-
{ file: moved, restored:
|
|
208
|
+
`[Agent Accelerator] ${path.relative(baseDir(path.dirname(path.dirname(file))), file)} is not valid; moved to ${path.basename(moved)} and ${value !== undefined ? "restored from its backup" : "ignored"}.`,
|
|
209
|
+
{ file: moved, restored: value !== undefined }
|
|
136
210
|
);
|
|
137
|
-
return
|
|
211
|
+
return value;
|
|
138
212
|
}
|
|
139
213
|
|
|
140
|
-
//
|
|
141
|
-
function
|
|
142
|
-
|
|
214
|
+
// Names of the action folders of a home (every folder not starting with a dot), sorted; in: readTasks/readDeliveries, out: folder names.
|
|
215
|
+
function actionFolders(home: string): string[] {
|
|
216
|
+
try {
|
|
217
|
+
return fs
|
|
218
|
+
.readdirSync(baseDir(home), { withFileTypes: true })
|
|
219
|
+
.filter((e) => e.isDirectory() && !e.name.startsWith("."))
|
|
220
|
+
.map((e) => e.name)
|
|
221
|
+
.sort();
|
|
222
|
+
} catch {
|
|
223
|
+
return [];
|
|
224
|
+
}
|
|
143
225
|
}
|
|
144
226
|
|
|
145
|
-
/**
|
|
227
|
+
/** Folder/id mismatches already reported, so a bad hand edit warns once per process. */
|
|
228
|
+
const reportedMismatch = new Set<string>();
|
|
229
|
+
// True when a record or delivery names the folder it sits in; otherwise warns once and says no; in: readTasks/readDeliveries, out: a boolean.
|
|
230
|
+
function inOwnFolder(file: string, folder: string, id: unknown): boolean {
|
|
231
|
+
if (id === folder) return true;
|
|
232
|
+
const key = `${file}:${String(id)}`;
|
|
233
|
+
if (!reportedMismatch.has(key)) {
|
|
234
|
+
reportedMismatch.add(key);
|
|
235
|
+
telemetryBus.emitWarning(
|
|
236
|
+
"scheduled_action.invalid",
|
|
237
|
+
`[Agent Accelerator] ${folder}/${path.basename(file)} names the id ${JSON.stringify(id)} but sits in the folder "${folder}" and is skipped.`,
|
|
238
|
+
{ id: String(id), problem: "id: does not match its folder", file: path.basename(file) }
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
return false;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Orders records by creation time, then id, so listings are stable; in: readTasks, out: a comparison number.
|
|
245
|
+
function byCreation(a: ScheduledActionRecord, b: ScheduledActionRecord): number {
|
|
246
|
+
return (a.createdAt ?? "").localeCompare(b.createdAt ?? "") || a.id.localeCompare(b.id);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** One action as stored (`task.json`), or undefined when it does not exist or `id` is not an action id. May be invalid: check with {@link validateRecord}. */
|
|
250
|
+
export function readTask(home: string, id: string): ScheduledActionRecord | undefined {
|
|
251
|
+
let dir: string;
|
|
252
|
+
try {
|
|
253
|
+
dir = taskDir(home, id);
|
|
254
|
+
} catch {
|
|
255
|
+
return undefined;
|
|
256
|
+
}
|
|
257
|
+
const raw = readShard(taskFile(dir), parseTask);
|
|
258
|
+
return raw && inOwnFolder(taskFile(dir), id, raw["id"]) ? (raw as unknown as ScheduledActionRecord) : undefined;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** Every record as stored, oldest first. Records may be invalid: check with {@link validateRecord} before acting on one. */
|
|
146
262
|
export function readTasks(home: string): ScheduledActionRecord[] {
|
|
147
|
-
|
|
263
|
+
const out: ScheduledActionRecord[] = [];
|
|
264
|
+
for (const folder of actionFolders(home)) {
|
|
265
|
+
if (!TASK_ID_PATTERN.test(folder) || folder.length > MAX_TASK_ID_LENGTH) continue; // not an action folder
|
|
266
|
+
const rec = readTask(home, folder);
|
|
267
|
+
if (rec) out.push(rec);
|
|
268
|
+
}
|
|
269
|
+
return out.sort(byCreation);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Changes one action under its own short mutex: re-reads `task.json`, calls `fn` (which may mutate the record in place;
|
|
274
|
+
* it gets `undefined` when the action does not exist) and writes the record back if it changed. If `fn` throws nothing
|
|
275
|
+
* is written. Other actions are never locked, so processes working on different actions do not wait for each other.
|
|
276
|
+
*/
|
|
277
|
+
export function updateTask<T>(home: string, id: string, fn: (rec: ScheduledActionRecord | undefined) => T): T {
|
|
278
|
+
let dir: string;
|
|
279
|
+
try {
|
|
280
|
+
dir = taskDir(home, id);
|
|
281
|
+
} catch {
|
|
282
|
+
return fn(undefined); // not an action id: nothing to change
|
|
283
|
+
}
|
|
284
|
+
if (!fs.existsSync(dir)) return fn(undefined);
|
|
285
|
+
try {
|
|
286
|
+
return withFileMutex(mutexFile(dir), () => {
|
|
287
|
+
sweepShardTemps(dir);
|
|
288
|
+
const rec = readTask(home, id);
|
|
289
|
+
const before = rec ? JSON.stringify(rec) : "";
|
|
290
|
+
const result = fn(rec);
|
|
291
|
+
if (rec && JSON.stringify(rec) !== before) writeFileDurable(taskFile(dir), JSON.stringify({ version: 2, task: rec }, null, 2) + "\n");
|
|
292
|
+
return result;
|
|
293
|
+
});
|
|
294
|
+
} catch (err) {
|
|
295
|
+
if (!isMissingFolder(err)) throw err;
|
|
296
|
+
return fn(undefined); // another process deleted the action while we waited for its lock
|
|
297
|
+
}
|
|
148
298
|
}
|
|
149
299
|
|
|
150
|
-
/**
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
300
|
+
/**
|
|
301
|
+
* Creates an action in a new folder. The folder is made with a create-only `mkdir`, so two processes can never take the
|
|
302
|
+
* same id; `nextId` is called again until a free one is found, then `build` makes the record for it.
|
|
303
|
+
*/
|
|
304
|
+
export function createTask(home: string, nextId: () => string, build: (id: string) => ScheduledActionRecord): ScheduledActionRecord {
|
|
305
|
+
fs.mkdirSync(baseDir(home), { recursive: true, mode: 0o700 });
|
|
306
|
+
for (let attempt = 0; attempt < 100; attempt++) {
|
|
307
|
+
const id = nextId();
|
|
308
|
+
const dir = taskDir(home, id);
|
|
309
|
+
try {
|
|
310
|
+
fs.mkdirSync(dir, { mode: 0o700 });
|
|
311
|
+
} catch (err) {
|
|
312
|
+
if ((err as { code?: string }).code === "EEXIST") continue;
|
|
313
|
+
throw err;
|
|
314
|
+
}
|
|
315
|
+
const rec = build(id);
|
|
316
|
+
withFileMutex(mutexFile(dir), () => {
|
|
317
|
+
sweepShardTemps(dir);
|
|
318
|
+
writeFileDurable(taskFile(dir), JSON.stringify({ version: 2, task: rec }, null, 2) + "\n");
|
|
319
|
+
});
|
|
320
|
+
return rec;
|
|
321
|
+
}
|
|
322
|
+
throw new ValidationError("Could not find a free scheduled action id.");
|
|
156
323
|
}
|
|
157
324
|
|
|
158
|
-
/**
|
|
325
|
+
/** Removes an action's record and saved deliveries (run files stay); true when there was something to remove. An emptied folder is removed too. */
|
|
326
|
+
export function deleteTask(home: string, id: string): boolean {
|
|
327
|
+
const dir = taskDir(home, id);
|
|
328
|
+
if (!fs.existsSync(dir)) return false;
|
|
329
|
+
let removed: boolean;
|
|
330
|
+
try {
|
|
331
|
+
removed = withFileMutex(mutexFile(dir), () => {
|
|
332
|
+
const had = fs.existsSync(taskFile(dir)) || fs.existsSync(deliveryFile(dir));
|
|
333
|
+
for (const f of [taskFile(dir), deliveryFile(dir)]) fs.rmSync(f, { force: true }), fs.rmSync(`${f}.bak`, { force: true });
|
|
334
|
+
fs.rmSync(heartbeatFile(dir), { force: true });
|
|
335
|
+
return had;
|
|
336
|
+
});
|
|
337
|
+
} catch (err) {
|
|
338
|
+
if (isMissingFolder(err)) return false; // another process deleted it first
|
|
339
|
+
throw err;
|
|
340
|
+
}
|
|
341
|
+
try {
|
|
342
|
+
fs.rmdirSync(dir); // only succeeds when nothing is left in it
|
|
343
|
+
} catch {
|
|
344
|
+
// why: run files (or another process's new action) keep the folder.
|
|
345
|
+
}
|
|
346
|
+
return removed;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/** Every saved delivery of every action as stored, soonest first (entries may be invalid: check with validateDelivery). */
|
|
159
350
|
export function readDeliveries(home: string): PendingDelivery[] {
|
|
160
|
-
|
|
351
|
+
const out: PendingDelivery[] = [];
|
|
352
|
+
for (const folder of actionFolders(home)) {
|
|
353
|
+
if (!TASK_ID_PATTERN.test(folder) || folder.length > MAX_TASK_ID_LENGTH) continue;
|
|
354
|
+
const file = deliveryFile(taskDir(home, folder));
|
|
355
|
+
for (const entry of readShard(file, parsePending) ?? []) if (inOwnFolder(file, folder, entry["id"])) out.push(entry as unknown as PendingDelivery);
|
|
356
|
+
}
|
|
357
|
+
return out.sort((a, b) => String(a.deliverAt).localeCompare(String(b.deliverAt)));
|
|
161
358
|
}
|
|
162
359
|
|
|
163
|
-
/**
|
|
164
|
-
export function updateDeliveries<T>(home: string, fn: (pending: PendingDelivery[]) => T): T {
|
|
165
|
-
const
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
360
|
+
/** Changes the saved deliveries of ONE action under its short mutex (`fn` mutates the array in place); the file is removed when the list ends up empty. */
|
|
361
|
+
export function updateDeliveries<T>(home: string, id: string, fn: (pending: PendingDelivery[]) => T): T {
|
|
362
|
+
const dir = taskDir(home, id);
|
|
363
|
+
for (let attempt = 1; ; attempt++) {
|
|
364
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
365
|
+
try {
|
|
366
|
+
return withFileMutex(mutexFile(dir), () => {
|
|
367
|
+
sweepShardTemps(dir);
|
|
368
|
+
const file = deliveryFile(dir);
|
|
369
|
+
const pending = (readShard(file, parsePending) ?? []) as unknown as PendingDelivery[];
|
|
370
|
+
const result = fn(pending);
|
|
371
|
+
if (pending.length === 0) {
|
|
372
|
+
fs.rmSync(file, { force: true });
|
|
373
|
+
fs.rmSync(`${file}.bak`, { force: true });
|
|
374
|
+
} else {
|
|
375
|
+
writeFileDurable(file, JSON.stringify({ version: 2, pending }, null, 2) + "\n");
|
|
376
|
+
}
|
|
377
|
+
return result;
|
|
378
|
+
});
|
|
379
|
+
} catch (err) {
|
|
380
|
+
if (!isMissingFolder(err) || attempt >= 3) throw err; // the folder was removed between making it and locking it: make it again
|
|
381
|
+
}
|
|
382
|
+
}
|
|
169
383
|
}
|
|
170
384
|
|
|
171
385
|
// ---------------------------------------------------------------------------
|
|
@@ -224,7 +438,7 @@ const deliverySchema = z.object({
|
|
|
224
438
|
late: z.boolean().optional(),
|
|
225
439
|
});
|
|
226
440
|
|
|
227
|
-
/** `undefined` when a `
|
|
441
|
+
/** `undefined` when a `delivery.json` entry is usable, else a one-line reason. Never throws. */
|
|
228
442
|
export function validateDelivery(raw: unknown): string | undefined {
|
|
229
443
|
const parsed = deliverySchema.safeParse(raw);
|
|
230
444
|
return parsed.success ? undefined : parsed.error.issues.map((i) => `${i.path.join(".") || "entry"}: ${i.message}`).join("; ");
|
|
@@ -246,7 +460,7 @@ function renderRun(header: RunHeader, body: string): string {
|
|
|
246
460
|
|
|
247
461
|
/**
|
|
248
462
|
* Writes one run under its final name (`27-october-2026.md`, `-2`, `-3` on a
|
|
249
|
-
* same-day clash) and returns its path relative to `
|
|
463
|
+
* same-day clash) and returns its path relative to `scheduled/`.
|
|
250
464
|
*/
|
|
251
465
|
export function writeRunFile(home: string, id: string, deliverAt: Date, header: RunHeader, body: string): string {
|
|
252
466
|
const dir = path.join(baseDir(home), id);
|
|
@@ -293,7 +507,7 @@ export function findRunFile(home: string, id: string, deliverAt: Date, startedAt
|
|
|
293
507
|
return undefined;
|
|
294
508
|
}
|
|
295
509
|
|
|
296
|
-
/** Deletes an action's folder (`purge`). Only ever inside `
|
|
510
|
+
/** Deletes an action's folder (`purge`). Only ever inside `scheduled/`. */
|
|
297
511
|
export function removeTaskFolder(home: string, id: string): void {
|
|
298
512
|
const root = baseDir(home);
|
|
299
513
|
const dir = path.resolve(root, id);
|
|
@@ -10,7 +10,7 @@ Saving a conversation, its spending and its tool logs.
|
|
|
10
10
|
| `tool-log.ts` | `ToolLogs`: one fsynced NDJSON file per tool call under `<session>/tool-logs/`. |
|
|
11
11
|
|
|
12
12
|
## Invariants
|
|
13
|
-
- Layout: `<home>/sessions/<id>/{session.json, journal.ndjson, media/, tool-logs/, workers/}`.
|
|
13
|
+
- Layout: `<home>/sessions/<id>/{session.json, journal.ndjson, telemetry.ndjson, media/, tool-logs/, workers/}`. `telemetry.ndjson` is written by `telemetry-file.ts` (opt-in), not by the session code; a folder holding only that file is not a session.
|
|
14
14
|
- A journal whose header epoch differs from the snapshot's is ignored on load.
|
|
15
15
|
- Journal record types: `base`, `msg`, `meta`, `run`, `end`, `tool`, `pending`, `usage`. Replay ignores unknown types, drops a torn last line, and stops at earlier damage with `session.journal_corrupt`.
|
|
16
16
|
- Media is content-addressed (`media/<kind>-h<hash>.<ext>`), written once, never deleted.
|
package/src/session/store.ts
CHANGED
|
@@ -166,7 +166,7 @@ function specFor(modelStr: string): ReturnType<typeof getModelFromCatalog> {
|
|
|
166
166
|
}
|
|
167
167
|
|
|
168
168
|
/** Context window for telemetry display. Falls back to 1M like the chat CLI.
|
|
169
|
-
* @deprecated
|
|
169
|
+
* @deprecated Kept for compat; new code should use
|
|
170
170
|
* `getSessionContextWindowOrUndefined` and handle `undefined` explicitly.
|
|
171
171
|
*/
|
|
172
172
|
export function getSessionContextWindow(modelStr: string): number {
|
|
@@ -575,7 +575,7 @@ function parseSession(raw: string, limits?: SessionLimits): PersistedAgentSessio
|
|
|
575
575
|
const maxBytes = limits?.maxBytes ?? MAX_SESSION_JSON_BYTES;
|
|
576
576
|
const text = (raw ?? "").trim();
|
|
577
577
|
if (!text) return null;
|
|
578
|
-
//
|
|
578
|
+
// Oversized payloads are rejected, but flagged as over-limit (NOT corrupt).
|
|
579
579
|
if (text.length > maxBytes) return OVER_LIMIT;
|
|
580
580
|
try {
|
|
581
581
|
// Legacy JSONL: lines shaped {type: session|mainModel|subagentModel|metrics|message|...}
|
|
@@ -626,7 +626,7 @@ function parseSession(raw: string, limits?: SessionLimits): PersistedAgentSessio
|
|
|
626
626
|
}
|
|
627
627
|
}
|
|
628
628
|
if (!sessionId && messages.length === 0) return null;
|
|
629
|
-
//
|
|
629
|
+
// Oversize guard — flagged as over-limit, not corrupt.
|
|
630
630
|
if (messages.length > maxMessages) return OVER_LIMIT;
|
|
631
631
|
return {
|
|
632
632
|
version: 1,
|
|
@@ -645,7 +645,7 @@ function parseSession(raw: string, limits?: SessionLimits): PersistedAgentSessio
|
|
|
645
645
|
}
|
|
646
646
|
const obj: any = JSON.parse(text);
|
|
647
647
|
if (!obj || typeof obj !== "object") return null;
|
|
648
|
-
//
|
|
648
|
+
// Version check — reject unknown versions, accept legacy shapes
|
|
649
649
|
// that predate versioning (no version field).
|
|
650
650
|
if ("version" in obj && (obj as { version?: unknown }).version !== 1) return null;
|
|
651
651
|
// Very old single-JSON shape without version: {sessionId, model, context:{messages}}
|
|
@@ -978,7 +978,7 @@ export function saveSessionDir(
|
|
|
978
978
|
agent: SessionAgentLike,
|
|
979
979
|
telemetry?: SessionTelemetry | SessionTotals | null
|
|
980
980
|
): string {
|
|
981
|
-
//
|
|
981
|
+
// sessionDirFor already enforces assertValidSessionId.
|
|
982
982
|
const sessionDir = sessionDirFor(rootDir, agent.sessionId);
|
|
983
983
|
try {
|
|
984
984
|
fs.mkdirSync(sessionDir, { recursive: true });
|
|
@@ -1139,7 +1139,7 @@ export function findLatestSessionDir(rootDir: string): string | null {
|
|
|
1139
1139
|
}
|
|
1140
1140
|
|
|
1141
1141
|
// ---------------------------------------------------------------------------
|
|
1142
|
-
//
|
|
1142
|
+
// Session validation (append-only; deserializeSession above only gained
|
|
1143
1143
|
// size/version guards, no behavior change otherwise).
|
|
1144
1144
|
// ---------------------------------------------------------------------------
|
|
1145
1145
|
|
|
@@ -1156,9 +1156,9 @@ function sessionLoadError(message: string): Error {
|
|
|
1156
1156
|
}
|
|
1157
1157
|
|
|
1158
1158
|
/**
|
|
1159
|
-
* Manual Zod-like validation for session snapshots (
|
|
1159
|
+
* Manual Zod-like validation for session snapshots (no new deps).
|
|
1160
1160
|
* Checks `version===1`, string `sessionId`, array `messages`, and oversize
|
|
1161
|
-
* (
|
|
1161
|
+
* (more than `MAX_SESSION_MESSAGES` = 50,000 messages or `MAX_SESSION_JSON_BYTES` = 100 MB of JSON). Throws a `SessionLoadError`-named Error
|
|
1162
1162
|
* on failure; returns the typed session on success.
|
|
1163
1163
|
*/
|
|
1164
1164
|
export function validateSessionData(data: unknown): PersistedAgentSession {
|
|
@@ -1191,12 +1191,12 @@ export function validateSessionData(data: unknown): PersistedAgentSession {
|
|
|
1191
1191
|
}
|
|
1192
1192
|
|
|
1193
1193
|
// ---------------------------------------------------------------------------
|
|
1194
|
-
//
|
|
1194
|
+
// Context-window lookup without the legacy 1M fallback.
|
|
1195
1195
|
// ---------------------------------------------------------------------------
|
|
1196
1196
|
|
|
1197
1197
|
/**
|
|
1198
|
-
* Returns the catalog context window for a model, or `undefined` when unknown
|
|
1199
|
-
*
|
|
1198
|
+
* Returns the catalog context window for a model, or `undefined` when unknown.
|
|
1199
|
+
* Prefer this over `getSessionContextWindow` for new code.
|
|
1200
1200
|
*/
|
|
1201
1201
|
export function getSessionContextWindowOrUndefined(modelStr: string): number | undefined {
|
|
1202
1202
|
try {
|
|
@@ -1210,7 +1210,7 @@ export function getSessionContextWindowOrUndefined(modelStr: string): number | u
|
|
|
1210
1210
|
}
|
|
1211
1211
|
|
|
1212
1212
|
// ---------------------------------------------------------------------------
|
|
1213
|
-
//
|
|
1213
|
+
// Telemetry bus lives in `../telemetry.ts` (dependency-free, so low-level
|
|
1214
1214
|
// modules such as the catalog cache and event stream can emit without import
|
|
1215
1215
|
// cycles). Re-exported here for backward compatibility.
|
|
1216
1216
|
// ---------------------------------------------------------------------------
|
|
@@ -1218,7 +1218,7 @@ import { telemetryBus } from "../telemetry.ts";
|
|
|
1218
1218
|
export { telemetryBus };
|
|
1219
1219
|
export type { TelemetryEvent, TelemetryListener } from "../telemetry.ts";
|
|
1220
1220
|
|
|
1221
|
-
/** Reports a usage/cost anomaly via the global hook + telemetry bus
|
|
1221
|
+
/** Reports a usage/cost anomaly via the global hook + telemetry bus. Never logs. */
|
|
1222
1222
|
export function reportUsageAnomaly(detail: { type: string; [k: string]: unknown }): void {
|
|
1223
1223
|
try {
|
|
1224
1224
|
(globalThis as unknown as { __agentAccelAnomaly?: (d: unknown) => void }).__agentAccelAnomaly?.(detail);
|
|
@@ -1233,10 +1233,10 @@ export function reportUsageAnomaly(detail: { type: string; [k: string]: unknown
|
|
|
1233
1233
|
}
|
|
1234
1234
|
|
|
1235
1235
|
// ---------------------------------------------------------------------------
|
|
1236
|
-
//
|
|
1236
|
+
// Canonical usage invariants.
|
|
1237
1237
|
// ---------------------------------------------------------------------------
|
|
1238
1238
|
/**
|
|
1239
|
-
* Canonical usage invariants (provider-agnostic
|
|
1239
|
+
* Canonical usage invariants (provider-agnostic):
|
|
1240
1240
|
* - all token counts are finite integers >= 0;
|
|
1241
1241
|
* - `cachedTokens` / `cacheReadTokens` are a SUBSET of `inputTokens`
|
|
1242
1242
|
* (cache hits cannot exceed input; aggregates clamp — see `accumulateUsage`
|
|
@@ -1287,13 +1287,13 @@ export function assertUsageInvariants(usage: TokenUsage): void {
|
|
|
1287
1287
|
}
|
|
1288
1288
|
|
|
1289
1289
|
// ---------------------------------------------------------------------------
|
|
1290
|
-
//
|
|
1290
|
+
// One cost model — integer micro-USD accumulation.
|
|
1291
1291
|
// ---------------------------------------------------------------------------
|
|
1292
1292
|
|
|
1293
|
-
/** Where a priced cost came from
|
|
1293
|
+
/** Where a priced cost came from. Unknown stays unknown, never 0. */
|
|
1294
1294
|
export type CostSource = "provider" | "catalog" | "unknown";
|
|
1295
1295
|
|
|
1296
|
-
/** Integer micro-USD breakdown behind a priced cost
|
|
1296
|
+
/** Integer micro-USD breakdown behind a priced cost. */
|
|
1297
1297
|
export interface CostBreakdown {
|
|
1298
1298
|
inputMicroUSD: number;
|
|
1299
1299
|
outputMicroUSD: number;
|
|
@@ -1315,7 +1315,7 @@ export type PricedCost = NonNullable<TokenUsage["cost"]> & {
|
|
|
1315
1315
|
};
|
|
1316
1316
|
|
|
1317
1317
|
/**
|
|
1318
|
-
* Service-tier price multipliers
|
|
1318
|
+
* Service-tier price multipliers. Aligned with pi
|
|
1319
1319
|
* (`api/openai-responses.ts:387`): `flex` trades latency for ~0.5x price,
|
|
1320
1320
|
* `priority`/`fast` reserve capacity at ~2x (`2.5x` for `gpt-5.5`).
|
|
1321
1321
|
* Standard/default is 1.0. Estimates because vendors do not publish exact
|
|
@@ -1347,7 +1347,7 @@ function toDollars(microUSD: number): number {
|
|
|
1347
1347
|
}
|
|
1348
1348
|
|
|
1349
1349
|
/**
|
|
1350
|
-
* Resolves effective per-1M pricing for a usage snapshot
|
|
1350
|
+
* Resolves effective per-1M pricing for a usage snapshot. When
|
|
1351
1351
|
* `usage.inputTokens > 200_000` and the catalog carries `cost.tiers` or
|
|
1352
1352
|
* `cost.context_over_200k`, the over-200k tier prices apply; otherwise base
|
|
1353
1353
|
* `pricing.*` (falling back to legacy `cost.*`) applies.
|
|
@@ -1404,7 +1404,7 @@ function resolveEffectivePricing(
|
|
|
1404
1404
|
}
|
|
1405
1405
|
|
|
1406
1406
|
/**
|
|
1407
|
-
* Prices one usage snapshot with integer micro-USD accumulation
|
|
1407
|
+
* Prices one usage snapshot with integer micro-USD accumulation.
|
|
1408
1408
|
*
|
|
1409
1409
|
* - A provider-reported `usage.cost.totalCost` (a number, 0 included: free or cached) wins (source `provider`).
|
|
1410
1410
|
* - Else catalog pricing via `resolveEffectivePricing` (source `catalog`).
|
|
@@ -1498,7 +1498,7 @@ export function priceUsage(
|
|
|
1498
1498
|
}
|
|
1499
1499
|
|
|
1500
1500
|
/**
|
|
1501
|
-
* Computes and attaches catalog pricing to a usage snapshot
|
|
1501
|
+
* Computes and attaches catalog pricing to a usage snapshot.
|
|
1502
1502
|
* Keeps provider-reported costs untouched; returns the cost (or undefined
|
|
1503
1503
|
* when unknown). Never fabricates a 0 object for unknown pricing.
|
|
1504
1504
|
*/
|