agent-accelerator 0.5.6 → 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 +10 -17
- package/package.json +1 -1
- package/src/agent/agent.ts +92 -57
- package/src/agent/architecture.md +25 -0
- package/src/agent/context.ts +7 -3
- package/src/agent/delegation.ts +30 -96
- package/src/agent/loop.ts +54 -56
- package/src/agent/subagent.ts +4 -4
- package/src/agent/worker.ts +1 -3
- package/src/architecture.md +28 -0
- package/src/index.ts +13 -80
- package/src/models/architecture.md +14 -0
- package/src/models/catalog-cache.ts +10 -7
- package/src/models/catalog.ts +20 -13
- package/src/providers/architecture.md +24 -0
- package/src/providers/google.ts +24 -4
- package/src/providers/openai-compat.ts +29 -7
- package/src/providers/openai.ts +22 -6
- package/src/providers/openrouter.ts +14 -2
- package/src/providers/registry.ts +1 -0
- package/src/providers/shared.ts +4 -4
- package/src/providers.ts +9 -53
- package/src/scheduler/architecture.md +22 -0
- package/src/scheduler/scheduler.ts +131 -56
- package/src/scheduler/store.ts +274 -54
- package/src/scheduler/time.ts +2 -0
- package/src/session/architecture.md +19 -0
- package/src/session/store.ts +37 -22
- package/src/session/usage.ts +1 -0
- package/src/streaming/architecture.md +13 -0
- package/src/streaming/event-stream.ts +16 -11
- package/src/telemetry-file.ts +74 -24
- package/src/telemetry.ts +3 -2
- package/src/tools/architecture.md +19 -0
- package/src/tools/executor.ts +14 -1
- package/src/tools/schema.ts +1 -0
- package/src/tools/subagent-result.ts +1 -0
- package/src/tools/tool.ts +8 -8
- package/src/types/agent.ts +22 -20
- package/src/types/architecture.md +20 -0
- 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 +25 -0
- package/src/utils/base64.ts +2 -0
- package/src/utils/cache.ts +6 -4
- package/src/utils/durable.ts +78 -3
- package/src/utils/env.ts +1 -1
- package/src/utils/errors.ts +4 -0
- package/src/utils/headers.ts +7 -4
- package/src/utils/home.ts +1 -1
- package/src/utils/media.ts +1 -0
- package/src/utils/raw-capture.ts +3 -0
- package/src/utils/retry.ts +2 -1
- package/src/utils/safe-io.ts +4 -0
- package/src/utils/session.ts +10 -166
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,31 +75,105 @@ export interface RunHeader {
|
|
|
72
75
|
error?: string;
|
|
73
76
|
}
|
|
74
77
|
|
|
75
|
-
|
|
76
|
-
const
|
|
77
|
-
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");
|
|
78
80
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
+
}
|
|
82
133
|
}
|
|
83
134
|
|
|
84
|
-
/**
|
|
85
|
-
export function
|
|
86
|
-
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 {
|
|
87
137
|
try {
|
|
88
|
-
|
|
138
|
+
fs.rmSync(heartbeatFile(taskDir(home, id)), { force: true });
|
|
89
139
|
} catch {
|
|
90
|
-
// why:
|
|
140
|
+
// why: a leftover heartbeat of a finished run is harmless (it is only read for actions that are cooking).
|
|
91
141
|
}
|
|
92
142
|
}
|
|
93
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
|
+
|
|
94
161
|
type Raw = Record<string, unknown>;
|
|
95
162
|
|
|
96
|
-
// Parses
|
|
97
|
-
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 {
|
|
98
165
|
try {
|
|
99
|
-
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"];
|
|
100
177
|
if (!Array.isArray(list)) return undefined;
|
|
101
178
|
return list.filter((x): x is Raw => !!x && typeof x === "object" && !Array.isArray(x));
|
|
102
179
|
} catch {
|
|
@@ -105,21 +182,21 @@ function parseList(text: string, key: "tasks" | "pending"): Raw[] | undefined {
|
|
|
105
182
|
}
|
|
106
183
|
|
|
107
184
|
/**
|
|
108
|
-
* Reads
|
|
109
|
-
* (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).
|
|
110
187
|
*/
|
|
111
|
-
function
|
|
112
|
-
const read = readWithBackup(file, (t) =>
|
|
113
|
-
if (!read) return
|
|
114
|
-
const
|
|
115
|
-
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;
|
|
116
193
|
const moved = `${file}.corrupt-${Date.now()}`;
|
|
117
194
|
try {
|
|
118
195
|
fs.renameSync(file, moved);
|
|
119
196
|
} catch {
|
|
120
197
|
// why: quarantine is best-effort; the file is ignored either way.
|
|
121
198
|
}
|
|
122
|
-
if (
|
|
199
|
+
if (value !== undefined) {
|
|
123
200
|
try {
|
|
124
201
|
writeFileDurable(file, read.text, { backup: false }); // keep the good .bak where it is
|
|
125
202
|
} catch {
|
|
@@ -128,39 +205,181 @@ function readList(file: string, key: "tasks" | "pending"): Raw[] {
|
|
|
128
205
|
}
|
|
129
206
|
telemetryBus.emitWarning(
|
|
130
207
|
"scheduled_action.store_corrupt",
|
|
131
|
-
`[Agent Accelerator] ${path.
|
|
132
|
-
{ 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 }
|
|
133
210
|
);
|
|
134
|
-
return
|
|
211
|
+
return value;
|
|
135
212
|
}
|
|
136
213
|
|
|
137
|
-
|
|
138
|
-
|
|
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
|
+
}
|
|
139
225
|
}
|
|
140
226
|
|
|
141
|
-
/**
|
|
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. */
|
|
142
262
|
export function readTasks(home: string): ScheduledActionRecord[] {
|
|
143
|
-
|
|
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
|
+
}
|
|
144
298
|
}
|
|
145
299
|
|
|
146
|
-
/**
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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.");
|
|
152
323
|
}
|
|
153
324
|
|
|
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). */
|
|
154
350
|
export function readDeliveries(home: string): PendingDelivery[] {
|
|
155
|
-
|
|
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)));
|
|
156
358
|
}
|
|
157
359
|
|
|
158
|
-
/**
|
|
159
|
-
export function updateDeliveries<T>(home: string, fn: (pending: PendingDelivery[]) => T): T {
|
|
160
|
-
const
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
+
}
|
|
164
383
|
}
|
|
165
384
|
|
|
166
385
|
// ---------------------------------------------------------------------------
|
|
@@ -219,7 +438,7 @@ const deliverySchema = z.object({
|
|
|
219
438
|
late: z.boolean().optional(),
|
|
220
439
|
});
|
|
221
440
|
|
|
222
|
-
/** `undefined` when a `
|
|
441
|
+
/** `undefined` when a `delivery.json` entry is usable, else a one-line reason. Never throws. */
|
|
223
442
|
export function validateDelivery(raw: unknown): string | undefined {
|
|
224
443
|
const parsed = deliverySchema.safeParse(raw);
|
|
225
444
|
return parsed.success ? undefined : parsed.error.issues.map((i) => `${i.path.join(".") || "entry"}: ${i.message}`).join("; ");
|
|
@@ -229,6 +448,7 @@ export function validateDelivery(raw: unknown): string | undefined {
|
|
|
229
448
|
// Run files
|
|
230
449
|
// ---------------------------------------------------------------------------
|
|
231
450
|
|
|
451
|
+
// Renders a run file: the `---` header lines, a blank line, the result text; in: writeRunFile, out: the file text.
|
|
232
452
|
function renderRun(header: RunHeader, body: string): string {
|
|
233
453
|
const lines = ["---"];
|
|
234
454
|
for (const [key, value] of Object.entries(header)) {
|
|
@@ -240,7 +460,7 @@ function renderRun(header: RunHeader, body: string): string {
|
|
|
240
460
|
|
|
241
461
|
/**
|
|
242
462
|
* Writes one run under its final name (`27-october-2026.md`, `-2`, `-3` on a
|
|
243
|
-
* same-day clash) and returns its path relative to `
|
|
463
|
+
* same-day clash) and returns its path relative to `scheduled/`.
|
|
244
464
|
*/
|
|
245
465
|
export function writeRunFile(home: string, id: string, deliverAt: Date, header: RunHeader, body: string): string {
|
|
246
466
|
const dir = path.join(baseDir(home), id);
|
|
@@ -287,7 +507,7 @@ export function findRunFile(home: string, id: string, deliverAt: Date, startedAt
|
|
|
287
507
|
return undefined;
|
|
288
508
|
}
|
|
289
509
|
|
|
290
|
-
/** Deletes an action's folder (`purge`). Only ever inside `
|
|
510
|
+
/** Deletes an action's folder (`purge`). Only ever inside `scheduled/`. */
|
|
291
511
|
export function removeTaskFolder(home: string, id: string): void {
|
|
292
512
|
const root = baseDir(home);
|
|
293
513
|
const dir = path.resolve(root, id);
|
package/src/scheduler/time.ts
CHANGED
|
@@ -15,7 +15,9 @@ const DAYS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday",
|
|
|
15
15
|
const DDHHMM = /^(\d{2,}):(\d{2}):(\d{2})$/;
|
|
16
16
|
const LOCAL_ISO = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})$/;
|
|
17
17
|
|
|
18
|
+
// Two-digit zero padding; in: date formatting here, out: a string.
|
|
18
19
|
const pad2 = (n: number): string => String(n).padStart(2, "0");
|
|
20
|
+
// JSON text of a value for error messages; in: the validators, out: a string.
|
|
19
21
|
const show = (v: unknown): string => JSON.stringify(v) ?? String(v);
|
|
20
22
|
|
|
21
23
|
/** Parses `DD:HH:MM` (DD >= 2 digits, MM < 60). HH is range-checked by the caller. */
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# src/session/
|
|
2
|
+
|
|
3
|
+
Saving a conversation, its spending and its tool logs.
|
|
4
|
+
|
|
5
|
+
| File | What it is |
|
|
6
|
+
| :-- | :-- |
|
|
7
|
+
| `store.ts` | Snapshot format (`session.json`, one line per field and per turn), load validation, `SessionTelemetry`, media extraction, journal replay (`replayJournal`), and `priceUsage` (the one cost model, integer micro-USD). |
|
|
8
|
+
| `journal.ts` | `SessionJournal`: after each step appends one fsynced line to `journal.ndjson`; writes a fresh snapshot (new `epoch`) when history changed any other way or the journal passed 500 lines / 4 MB. |
|
|
9
|
+
| `usage.ts` | Pure spending accounting: `UsageTotals`, `usageLine`, `buildUsageReport`. No Agent import. |
|
|
10
|
+
| `tool-log.ts` | `ToolLogs`: one fsynced NDJSON file per tool call under `<session>/tool-logs/`. |
|
|
11
|
+
|
|
12
|
+
## Invariants
|
|
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
|
+
- A journal whose header epoch differs from the snapshot's is ignored on load.
|
|
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
|
+
- Media is content-addressed (`media/<kind>-h<hash>.<ext>`), written once, never deleted.
|
|
17
|
+
- Valid sessions over the load limits throw `SessionLoadError` and are never quarantined; only corrupt files are renamed `*.corrupt-<ts>`.
|
|
18
|
+
- Pricing: a provider-reported cost wins and is never rescaled; unknown pricing stays unknown, never $0; cache reads and writes are subsets of input.
|
|
19
|
+
- Never call `saveSessionDir` on a folder an Agent persists to.
|