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.
@@ -1,18 +1,21 @@
1
1
  /**
2
2
  * Disk layer for scheduled actions. Everything lives under one home folder:
3
3
  *
4
- * <home>/scheduled_actions/tasks.json every action, one file
5
- * <home>/scheduled_actions/deliveries.json saved results waiting for their delivery time
6
- * <home>/scheduled_actions/<id>/<date>.md one file per run
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
- * The files are the source of truth: every write re-reads first (never writes
9
- * from a stale copy) and goes through `writeFileDurable` (temp file, fsync, `.bak`, rename).
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 `deliveries.json`). */
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 `scheduled_actions/`. */
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 `scheduled_actions` folder of a home; in: the path helpers, out: its path.
76
- const baseDir = (home: string): string => path.join(home, "scheduled_actions");
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
- /** Absolute path of a run file given its `scheduled_actions/`-relative path. */
83
- export function runPath(home: string, relativeFile: string): string {
84
- return path.resolve(baseDir(home), relativeFile);
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
- /** Renames the pre-0.5.0 `scheduled_tasks/` folder to `scheduled_actions/` (once, only when the new one does not exist). */
88
- export function migrateLegacyFolder(home: string): void {
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
- if (fs.existsSync(legacy) && !fs.existsSync(baseDir(home))) fs.renameSync(legacy, baseDir(home));
138
+ fs.rmSync(heartbeatFile(taskDir(home, id)), { force: true });
92
139
  } catch {
93
- // why: best effort; the old folder simply stays where it is.
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 a `{ version, <key>: [...] }` file into its object entries; undefined when the text is not that shape; in: raw file text, out: readList.
100
- function parseList(text: string, key: "tasks" | "pending"): Raw[] | undefined {
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 list = (JSON.parse(text) as Raw | null)?.[key];
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 a `{ version, <key>: [...] }` file. Missing = empty. A damaged file is renamed `*.corrupt-<ts>`; if its `.bak`
112
- * (the last good version) is valid it is restored, else the list starts empty.
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 readList(file: string, key: "tasks" | "pending"): Raw[] {
115
- const read = readWithBackup(file, (t) => parseList(t, key) !== undefined);
116
- if (!read) return [];
117
- const list = parseList(read.text, key);
118
- if (list && !read.fromBackup) return list;
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 (list) {
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.basename(file)} is not valid; moved to ${path.basename(moved)} and ${list ? "restored from its backup" : "starting empty"}.`,
135
- { file: moved, restored: !!list }
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 list ?? [];
211
+ return value;
138
212
  }
139
213
 
140
- // Writes `{ version, <key>: [...] }` durably (with a .bak); in: updateTasks/updateDeliveries, out: the file.
141
- function writeList(file: string, key: "tasks" | "pending", list: Raw[]): void {
142
- writeFileDurable(file, JSON.stringify({ version: 1, [key]: list }, null, 2) + "\n");
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
- /** Every record as stored. Records may be invalid: check with {@link validateRecord} before acting on one. */
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
- return readList(tasksFile(home), "tasks") as unknown as ScheduledActionRecord[];
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
- /** Re-reads `tasks.json`, applies `fn` (mutating the array/records in place) and writes it back. */
151
- export function updateTasks<T>(home: string, fn: (tasks: ScheduledActionRecord[]) => T): T {
152
- const tasks = readTasks(home);
153
- const result = fn(tasks);
154
- writeList(tasksFile(home), "tasks", tasks as unknown as Raw[]);
155
- return result;
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
- /** Every saved delivery as stored (entries may be invalid: check with validateDelivery); in: the scheduler, out: PendingDelivery[]. */
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
- return readList(deliveriesFile(home), "pending") as unknown as PendingDelivery[];
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
- /** Re-reads `deliveries.json`, applies `fn` (mutating in place) and writes it back. */
164
- export function updateDeliveries<T>(home: string, fn: (pending: PendingDelivery[]) => T): T {
165
- const pending = readDeliveries(home);
166
- const result = fn(pending);
167
- writeList(deliveriesFile(home), "pending", pending as unknown as Raw[]);
168
- return result;
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 `deliveries.json` entry is usable, else a one-line reason. Never throws. */
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 `scheduled_actions/`.
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 `scheduled_actions/`. */
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.
@@ -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 (Q-34) Kept for compat; new code should use
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
- // Q-10: oversized payloads are rejected, but flagged as over-limit (NOT corrupt).
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
- // Q-10: oversize guard — flagged as over-limit, not corrupt.
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
- // Q-10: version check — reject unknown versions, accept legacy shapes
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
- // Q-28-partial: sessionDirFor already enforces assertValidSessionId.
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
- // Q-10: session validation (append-only; deserializeSession above only gained
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 (Q-10, no new deps).
1159
+ * Manual Zod-like validation for session snapshots (no new deps).
1160
1160
  * Checks `version===1`, string `sessionId`, array `messages`, and oversize
1161
- * (>5000 messages or >10MB JSON). Throws a `SessionLoadError`-named Error
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
- // Q-34: context-window lookup without the legacy 1M fallback.
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
- * (Q-34). Prefer this over `getSessionContextWindow` for new code.
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
- // Q-41: telemetry bus lives in `../telemetry.ts` (dependency-free, so low-level
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 (Q-35). Never logs. */
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
- // Q-35: canonical usage invariants.
1236
+ // Canonical usage invariants.
1237
1237
  // ---------------------------------------------------------------------------
1238
1238
  /**
1239
- * Canonical usage invariants (provider-agnostic, Q-35):
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
- // Q-36: one cost model — integer micro-USD accumulation.
1290
+ // One cost model — integer micro-USD accumulation.
1291
1291
  // ---------------------------------------------------------------------------
1292
1292
 
1293
- /** Where a priced cost came from (Q-36). Unknown stays unknown, never 0. */
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 (Q-36). */
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 (Q-36). Aligned with pi
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 (Q-36). When
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 (Q-36).
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 (Q-36).
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
  */