agent-accelerator 0.5.2 → 0.5.3

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.
@@ -32,6 +32,7 @@ import { getModelFromCatalog } from "../models/catalog.ts";
32
32
  import type { SubAgentStep } from "../types/response.ts";
33
33
  import { SessionLoadError } from "../types/errors.ts";
34
34
  import { createSessionId } from "../utils/session.ts";
35
+ import { writeFileDurable, readWithBackup } from "../utils/durable.ts";
35
36
 
36
37
  /** Cumulative per-session token + cost totals (JSON-safe). */
37
38
  export interface SessionTotals {
@@ -61,6 +62,40 @@ export interface PersistedSubAgentTrace {
61
62
  error?: string;
62
63
  }
63
64
 
65
+ /** How a run ended; a run with no ending was cut off (a crash). `abandoned` = a newer run started instead of resuming it. */
66
+ export type SessionRunEnd = "done" | "failed" | "aborted" | "stopped" | "abandoned";
67
+
68
+ /** Where one run began: `i` is the index of its user message in `messages` (turn boundaries for later phases). */
69
+ export interface SessionRunMark {
70
+ id: string;
71
+ i: number;
72
+ at: number;
73
+ /** Unset while the run is active or when it was cut off. */
74
+ end?: SessionRunEnd;
75
+ }
76
+
77
+ /** The result of a tool call that finished, saved the moment it finished so a crash never repeats it. */
78
+ export interface SessionToolRecord {
79
+ id: string;
80
+ name: string;
81
+ result: unknown;
82
+ isError?: boolean;
83
+ /** The tool ended the run with `ctx.stop`. */
84
+ stopped?: boolean;
85
+ }
86
+
87
+ /** A prompt that was waiting when the process stopped (its options other than `additionalContext` are not saved). */
88
+ export interface SessionPendingPrompt {
89
+ prompt: string | ContentPart[];
90
+ additionalContext?: string;
91
+ }
92
+
93
+ /** Steers not yet injected into the active run, and queued follow-ups, as last journaled. */
94
+ export interface SessionPending {
95
+ steer: SessionPendingPrompt[];
96
+ queue: SessionPendingPrompt[];
97
+ }
98
+
64
99
  /** Single-file session snapshot (v1). Written as one JSON object. */
65
100
  export interface PersistedAgentSession {
66
101
  version: 1;
@@ -79,6 +114,14 @@ export interface PersistedAgentSession {
79
114
  parentSessionId?: string;
80
115
  /** Dedicated sub-agent traces, keyed by worker TrackingID (32-char hex). */
81
116
  subagents?: Record<string, PersistedSubAgentTrace>;
117
+ /** Written with a journal (dir mode): only a `journal.ndjson` whose header names this id belongs to this snapshot. */
118
+ epoch?: string;
119
+ /** Where each run began, oldest first. */
120
+ runs?: SessionRunMark[];
121
+ /** Read from the journal only (never in `session.json`): prompts that were still waiting. */
122
+ pending?: SessionPending;
123
+ /** Read from the journal only: results of tool calls finished in the current run (used to resume without repeating them). */
124
+ tools?: SessionToolRecord[];
82
125
  }
83
126
 
84
127
  export function emptyTotals(): SessionTotals {
@@ -286,6 +329,12 @@ export interface SessionAgentLike {
286
329
  instructions?: string;
287
330
  parentSessionId?: unknown;
288
331
  subagentTraces?: Record<string, PersistedSubAgentTrace>;
332
+ /** Run boundaries (see {@link SessionRunMark}). */
333
+ runMarks?: SessionRunMark[];
334
+ /** Prompts waiting in the steer inbox / follow-up queue; called by the journal on every sync. */
335
+ pendingPrompts?: () => SessionPending;
336
+ /** Tool calls finished in the current run (cleared when the next run starts). */
337
+ toolRecords?: SessionToolRecord[];
289
338
  context: { systemPrompt?: string; messages: Message[] };
290
339
  }
291
340
 
@@ -354,7 +403,7 @@ function stableStringify(value: unknown): string {
354
403
  * stored `arguments` (pure duplicate, the common case). Divergent or
355
404
  * unparseable wire strings are kept — they carry info parsing lost.
356
405
  */
357
- function stripRedundantRawArguments(messages: Message[]): Message[] {
406
+ export function stripRedundantRawArguments(messages: Message[]): Message[] {
358
407
  return messages.map((m) => {
359
408
  if (!Array.isArray((m as Message).content)) return m;
360
409
  let changed = false;
@@ -432,6 +481,7 @@ export function buildSessionData(agent: SessionAgentLike, telemetry?: SessionTel
432
481
  messages,
433
482
  ...(parentSessionId ? { parentSessionId } : {}),
434
483
  ...(subagents ? { subagents } : {}),
484
+ ...(agent.runMarks && agent.runMarks.length > 0 ? { runs: agent.runMarks.map((r) => ({ ...r })) } : {}),
435
485
  };
436
486
  }
437
487
 
@@ -619,6 +669,8 @@ function parseSession(raw: string, limits?: SessionLimits): PersistedAgentSessio
619
669
  totals: obj.totals,
620
670
  parentSessionId: obj.parentSessionId,
621
671
  subagents: obj.subagents,
672
+ ...(typeof obj.epoch === "string" ? { epoch: obj.epoch } : {}),
673
+ ...(Array.isArray(obj.runs) ? { runs: obj.runs as SessionRunMark[] } : {}),
622
674
  };
623
675
  } catch {
624
676
  return null;
@@ -654,14 +706,14 @@ export function inspectSessionPayload(
654
706
  /** Loads a session file (pretty `.json` or legacy `.jsonl`). Returns null when missing/corrupt. */
655
707
  export function loadSessionFile(filePath: string, limits?: SessionLimits): PersistedAgentSession | null {
656
708
  const resolved = path.resolve(filePath);
657
- let raw: string;
658
- try {
659
- if (!fs.existsSync(resolved)) return null;
660
- raw = fs.readFileSync(resolved, "utf8");
661
- } catch {
662
- return null;
663
- }
664
- return sessionFromPayload(raw, resolved, limits);
709
+ const read = readWithBackup(resolved, isLoadableSession);
710
+ return read ? sessionFromPayload(read.text, resolved, limits) : null;
711
+ }
712
+
713
+ // True when a stored payload is a usable snapshot (a valid one over the load limits counts); in: raw file text, out: readWithBackup's fallback decision.
714
+ function isLoadableSession(raw: string): boolean {
715
+ const status = inspectSessionPayload(raw).status;
716
+ return status === "ok" || status === "over_limit";
665
717
  }
666
718
 
667
719
  /**
@@ -680,25 +732,8 @@ function sessionFromPayload(raw: string, file: string, limits?: SessionLimits):
680
732
  return res.session ?? null;
681
733
  }
682
734
 
683
- /** Atomic file write (temp + rename) so a crash never leaves half-written JSON. */
684
- export function writeFileAtomic(filePath: string, content: string): void {
685
- const dir = path.dirname(filePath);
686
- try {
687
- fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
688
- } catch {}
689
- let tag = "tmp";
690
- try {
691
- const pid = (globalThis as any)?.process?.pid;
692
- const rand = (globalThis as any)?.crypto?.randomUUID?.().slice(0, 8) ?? Math.random().toString(36).slice(2, 8);
693
- tag = `${typeof pid === "number" ? pid : "np"}-${rand}`;
694
- } catch {
695
- tag = Math.random().toString(36).slice(2, 8);
696
- }
697
- const tmp = `${filePath}.tmp-${tag}`;
698
- fs.writeFileSync(tmp, content, { encoding: "utf8", mode: 0o600 });
699
- try { fs.chmodSync(tmp, 0o600); } catch {}
700
- fs.renameSync(tmp, filePath);
701
- }
735
+ /** Public name of the durable writer (temp file, fsync, `.bak`, atomic rename): a crash or power cut never leaves half-written JSON. */
736
+ export const writeFileAtomic = writeFileDurable;
702
737
 
703
738
  /** Session id grammar: no traversal, no slashes, no hidden files. */
704
739
  export function assertValidSessionId(sessionId: string): void {
@@ -707,6 +742,15 @@ export function assertValidSessionId(sessionId: string): void {
707
742
  }
708
743
  }
709
744
 
745
+ /** Renames a genuinely corrupt snapshot to `*.corrupt-<ts>` before it is overwritten (a healthy one over the load limits is our own prior save and is just rewritten). */
746
+ export function quarantineIfCorrupt(file: string): void {
747
+ try {
748
+ if (inspectSessionPayload(fs.readFileSync(file, "utf8")).status === "corrupt") fs.renameSync(file, `${file}.corrupt-${Date.now()}`);
749
+ } catch {
750
+ // why: no file yet, or it cannot be read: nothing to quarantine.
751
+ }
752
+ }
753
+
710
754
  /** Saves an agent + telemetry snapshot as a pretty-printed `.json` document (2-space indent). */
711
755
  export function saveSessionFile(
712
756
  filePath: string,
@@ -714,20 +758,7 @@ export function saveSessionFile(
714
758
  telemetry?: SessionTelemetry | SessionTotals | null
715
759
  ): void {
716
760
  const resolved = path.resolve(filePath);
717
- // Q-28-partial: never overwrite a corrupt file in place — quarantine it
718
- // as `*.corrupt-<ts>` first (load returns null for corrupt payloads).
719
- try {
720
- if (fs.existsSync(resolved)) {
721
- try {
722
- const raw = fs.readFileSync(resolved, "utf8");
723
- // Only genuinely corrupt payloads are quarantined; a healthy snapshot
724
- // over the load limits is simply rewritten (it is our own prior save).
725
- if (inspectSessionPayload(raw).status === "corrupt") {
726
- try { fs.renameSync(resolved, `${resolved}.corrupt-${Date.now()}`); } catch {}
727
- }
728
- } catch {}
729
- }
730
- } catch {}
761
+ quarantineIfCorrupt(resolved);
731
762
  const data = buildSessionData(agent, telemetry);
732
763
  try {
733
764
  writeFileAtomic(resolved, serializeSession(data) + "\n");
@@ -790,6 +821,28 @@ function extForMedia(mimeType?: string, filename?: string): string {
790
821
  return "bin";
791
822
  }
792
823
 
824
+ // Finds embedded media bytes in a part (binary, `data:` URL or raw base64); remote URLs and local paths are references and give undefined; in: any content part, out: extractMediaToDir and the session journal.
825
+ export function embeddedMedia(part: ContentPart): { key: (typeof MEDIA_PART_KEYS)[number]; bytes: Uint8Array; mimeType?: string; ext: string } | undefined {
826
+ const p = part as unknown as Record<string, unknown> | null;
827
+ if (!p || typeof p["type"] !== "string" || !(MEDIA_PART_KEYS as readonly string[]).includes(p["type"])) return undefined;
828
+ const key = p["type"] as (typeof MEDIA_PART_KEYS)[number];
829
+ const raw = p[key];
830
+ let bytes: Uint8Array;
831
+ let mimeType = typeof p["mimeType"] === "string" ? (p["mimeType"] as string) : undefined;
832
+ if (raw instanceof Uint8Array) bytes = raw;
833
+ else if (raw instanceof ArrayBuffer) bytes = new Uint8Array(raw);
834
+ else if (typeof raw === "string") {
835
+ if (isHttpRef(raw)) return undefined; // remote reference: the provider fetches it per turn
836
+ const dataUrl = raw.startsWith("data:") ? parseDataUrl(raw) : undefined;
837
+ if (dataUrl) {
838
+ bytes = base64ToBytes(dataUrl.base64);
839
+ mimeType = mimeType ?? dataUrl.mimeType;
840
+ } else if (looksLikeEmbeddedBase64(raw)) bytes = base64ToBytes(raw.replace(/\s/g, ""));
841
+ else return undefined; // local path or short label: keep as reference
842
+ } else return undefined;
843
+ return { key, bytes, ...(mimeType ? { mimeType } : {}), ext: extForMedia(mimeType, typeof p["filename"] === "string" ? (p["filename"] as string) : undefined) };
844
+ }
845
+
793
846
  function cloneMessages(messages: Message[]): Message[] {
794
847
  try {
795
848
  const g: any = globalThis as any;
@@ -830,35 +883,15 @@ export function extractMediaToDir(
830
883
  } catch {}
831
884
  const wrote: string[] = [];
832
885
  const counters: Record<string, number> = {};
833
- let hasBinary = false;
834
886
 
835
887
  for (const msg of cloned) {
836
888
  if (!Array.isArray((msg as Message).content)) continue;
837
889
  for (const part of (msg as Message).content as ContentPart[]) {
838
- const p: any = part as any;
839
- if (!p || typeof p.type !== "string") continue;
840
- if (!(MEDIA_PART_KEYS as readonly string[]).includes(p.type)) continue;
841
- const key = p.type as (typeof MEDIA_PART_KEYS)[number];
842
- const raw = p[key] as unknown;
843
- let bytes: Uint8Array | undefined;
844
- let mimeType: string | undefined = typeof p.mimeType === "string" ? p.mimeType : undefined;
845
- if (raw instanceof Uint8Array) bytes = raw;
846
- else if (raw instanceof ArrayBuffer) bytes = new Uint8Array(raw);
847
- else if (typeof raw === "string") {
848
- if (isHttpRef(raw)) continue; // remote reference — provider fetches per turn
849
- const dataUrl = raw.startsWith("data:") ? parseDataUrl(raw) : undefined;
850
- if (dataUrl) {
851
- bytes = base64ToBytes(dataUrl.base64);
852
- mimeType = mimeType ?? dataUrl.mimeType;
853
- } else if (looksLikeEmbeddedBase64(raw)) {
854
- bytes = base64ToBytes(raw.replace(/\s/g, ""));
855
- } else continue; // local path or short label — keep as reference
856
- } else continue;
857
-
858
- hasBinary = true;
890
+ const found = embeddedMedia(part);
891
+ if (!found) continue;
892
+ const { key, bytes, mimeType } = found;
859
893
  const n = (counters[key] = (counters[key] ?? 0) + 1);
860
- const ext = extForMedia(mimeType, typeof p.filename === "string" ? p.filename : undefined);
861
- const file = `${key}-${String(n).padStart(3, "0")}.${ext}`;
894
+ const file = `${key}-${String(n).padStart(3, "0")}.${found.ext}`;
862
895
  try {
863
896
  fs.mkdirSync(mediaDir, { recursive: true, mode: 0o700 });
864
897
  const dest = path.resolve(mediaDir, file);
@@ -868,8 +901,9 @@ export function extractMediaToDir(
868
901
  } catch {
869
902
  continue;
870
903
  }
904
+ const p = part as unknown as Record<string, unknown>;
871
905
  p[key] = `media/${file}`;
872
- if (mimeType) p.mimeType = mimeType;
906
+ if (mimeType) p["mimeType"] = mimeType;
873
907
  wrote.push(`media/${file}`);
874
908
  }
875
909
  }
@@ -937,17 +971,7 @@ export function saveSessionDir(
937
971
  );
938
972
  try {
939
973
  const target = path.join(sessionDir, "session.json");
940
- // Q-28-partial: quarantine corrupt targets instead of overwriting.
941
- try {
942
- if (fs.existsSync(target)) {
943
- try {
944
- const raw = fs.readFileSync(target, "utf8");
945
- if (inspectSessionPayload(raw).status === "corrupt") {
946
- try { fs.renameSync(target, `${target}.corrupt-${Date.now()}`); } catch {}
947
- }
948
- } catch {}
949
- }
950
- } catch {}
974
+ quarantineIfCorrupt(target);
951
975
  writeFileAtomic(target, serializeSession(data) + "\n");
952
976
  } catch {
953
977
  // persistence must never crash a chat turn
@@ -955,6 +979,67 @@ export function saveSessionDir(
955
979
  return sessionDir;
956
980
  }
957
981
 
982
+ /** Name of the append-only step log next to `session.json` (see `session/journal.ts`). */
983
+ export const JOURNAL_FILE = "journal.ndjson";
984
+
985
+ // Snapshot fields a `meta` journal record replaces (everything except messages, identity and totals).
986
+ const JOURNAL_META_KEYS = ["model", "subagentModel", "thinkingLevel", "cache", "systemPrompt", "parentSessionId", "subagents"] as const;
987
+
988
+ // Applies a journal to its snapshot: records of the snapshot's own epoch only, `msg` by index (already-present ones skipped), `run` adds a run boundary, `end` closes one, `tool` keeps a finished tool result, `pending` replaces the waiting prompts, `meta` replaces config fields, unknown record types ignored; a torn last line is dropped, damage earlier stops the replay with a bus warning; in: the parsed snapshot + journal text, out: loadSessionDir.
989
+ function replayJournal(snapshot: PersistedAgentSession, text: string, file: string): PersistedAgentSession {
990
+ if (!snapshot.epoch) return snapshot;
991
+ const lines = text.split("\n");
992
+ const session: PersistedAgentSession = { ...snapshot, messages: [...snapshot.messages] };
993
+ let sawHeader = false;
994
+ const stop = (reason: string, line: number): void => {
995
+ telemetryBus.emitWarning("session.journal_corrupt", `[Agent Accelerator] ${path.basename(file)} line ${line + 1}: ${reason}; the rest of the journal is ignored.`, { file, line: line + 1 });
996
+ };
997
+ for (let k = 0; k < lines.length; k++) {
998
+ const line = lines[k]!.trim();
999
+ if (!line) continue;
1000
+ let rec: { t?: unknown; e?: unknown; i?: unknown; m?: unknown; p?: unknown; id?: unknown; at?: unknown; status?: unknown; call?: unknown; name?: unknown; result?: unknown; isError?: unknown; stopped?: unknown };
1001
+ try {
1002
+ rec = JSON.parse(line);
1003
+ } catch {
1004
+ if (lines.slice(k + 1).some((l) => l.trim())) stop("not valid JSON", k); // a torn LAST line is just a cut-off write
1005
+ break;
1006
+ }
1007
+ if (!sawHeader) {
1008
+ sawHeader = true;
1009
+ if (rec.t !== "base" || rec.e !== snapshot.epoch) return snapshot; // a journal from before this snapshot
1010
+ continue;
1011
+ }
1012
+ if (rec.t === "msg" && typeof rec.i === "number") {
1013
+ if (rec.i < session.messages.length) continue; // already in the snapshot
1014
+ if (rec.i > session.messages.length || findInvalidMessage([rec.m]) !== -1) {
1015
+ stop(rec.i > session.messages.length ? "a message is missing before this one" : "invalid message", k);
1016
+ break;
1017
+ }
1018
+ session.messages.push(rec.m as Message);
1019
+ } else if (rec.t === "run" && typeof rec.id === "string" && typeof rec.i === "number" && typeof rec.at === "number") {
1020
+ if (!session.runs?.some((r) => r.id === rec.id)) session.runs = [...(session.runs ?? []), { id: rec.id, i: rec.i, at: rec.at }];
1021
+ } else if (rec.t === "end" && typeof rec.id === "string" && typeof rec.status === "string") {
1022
+ const mark = session.runs?.find((r) => r.id === rec.id);
1023
+ if (mark) session.runs = session.runs!.map((r) => (r === mark ? { ...r, end: rec.status as SessionRunEnd } : r));
1024
+ } else if (rec.t === "tool" && typeof rec.call === "string" && typeof rec.name === "string") {
1025
+ session.tools = [...(session.tools ?? []).filter((x) => x.id !== rec.call), { id: rec.call, name: rec.name, result: rec.result, ...(rec.isError ? { isError: true } : {}), ...(rec.stopped ? { stopped: true } : {}) }];
1026
+ } else if (rec.t === "pending" && rec.p && typeof rec.p === "object") {
1027
+ const p = rec.p as SessionPending; // latest record wins
1028
+ if (Array.isArray(p.steer) && Array.isArray(p.queue)) {
1029
+ if (p.steer.length + p.queue.length > 0) session.pending = p;
1030
+ else delete session.pending;
1031
+ }
1032
+ } else if (rec.t === "meta" && rec.m && typeof rec.m === "object") {
1033
+ const meta = rec.m as Record<string, unknown>;
1034
+ for (const key of JOURNAL_META_KEYS) {
1035
+ if (key in meta) (session as unknown as Record<string, unknown>)[key] = meta[key];
1036
+ else delete session[key];
1037
+ }
1038
+ }
1039
+ }
1040
+ return session;
1041
+ }
1042
+
958
1043
  /** A loaded session folder: snapshot (media paths resolved) + folder location. */
959
1044
  export interface LoadedSessionDir {
960
1045
  session: PersistedAgentSession;
@@ -985,9 +1070,17 @@ export function loadSessionDir(sessionPath: string, limits?: SessionLimits): Loa
985
1070
  // path does not exist yet — fall through to session.json lookup
986
1071
  }
987
1072
  const file = path.join(dir, "session.json");
988
- if (!fs.existsSync(file)) return null;
989
- const data = sessionFromPayload(fs.readFileSync(file, "utf8"), file, limits);
990
- if (!data) return null;
1073
+ const read = readWithBackup(file, isLoadableSession);
1074
+ if (!read) return null;
1075
+ const snapshot = sessionFromPayload(read.text, file, limits);
1076
+ if (!snapshot) return null;
1077
+ let journal: string | undefined;
1078
+ try {
1079
+ journal = fs.readFileSync(path.join(dir, JOURNAL_FILE), "utf8");
1080
+ } catch {
1081
+ journal = undefined; // no journal: the snapshot is the whole session
1082
+ }
1083
+ const data = journal ? replayJournal(snapshot, journal, path.join(dir, JOURNAL_FILE)) : snapshot;
991
1084
  data.messages = resolveMediaPaths(data.messages, dir);
992
1085
  return { session: data, dir };
993
1086
  } catch (err) {
@@ -1007,7 +1100,13 @@ export function findLatestSessionDir(rootDir: string): string | null {
1007
1100
  try {
1008
1101
  const st = fs.statSync(file);
1009
1102
  if (!st.isFile()) continue;
1010
- if (!best || st.mtimeMs > best.mtime) best = { dir: path.join(root, entry), mtime: st.mtimeMs };
1103
+ let mtime = st.mtimeMs;
1104
+ try {
1105
+ mtime = Math.max(mtime, fs.statSync(path.join(root, entry, JOURNAL_FILE)).mtimeMs); // steps land in the journal
1106
+ } catch {
1107
+ // why: no journal yet.
1108
+ }
1109
+ if (!best || mtime > best.mtime) best = { dir: path.join(root, entry), mtime };
1011
1110
  } catch {
1012
1111
  continue;
1013
1112
  }
@@ -0,0 +1,62 @@
1
+ /**
2
+ * One log file per tool call, inside the session folder: `tool-logs/<n>-<tool>.ndjson`. The executor writes its own
3
+ * steps into it (input, attempts, retries, result or error, duration) and a tool adds its own with `ctx.log(...)`.
4
+ * Every line is flushed to disk as it is written, so after a crash the file shows how far the call got. The file is for
5
+ * developers and harnesses: it is never sent to the model, and nothing in the SDK reads it back.
6
+ */
7
+ import * as fs from "node:fs";
8
+ import * as path from "node:path";
9
+ import { fsyncFolder } from "../utils/durable.ts";
10
+
11
+ /** The log of one tool call, as the executor sees it. */
12
+ export interface ToolLogWriter {
13
+ /** Path relative to the session folder (`tool-logs/003-send_email.ndjson`); saved on the call's result. */
14
+ readonly file: string;
15
+ /** Appends one line (`timestamp` is added) and flushes it. Never throws. */
16
+ write(entry: Record<string, unknown>): void;
17
+ /** Closes the file; later writes (a tool still running after a timeout) are ignored. */
18
+ close(): void;
19
+ }
20
+
21
+ /** Hands out the log files of one session folder, numbering them in the order the calls start. */
22
+ export class ToolLogs {
23
+ private next: number | undefined;
24
+
25
+ constructor(private readonly sessionDir: string) {}
26
+
27
+ // Opens `tool-logs/<n>-<tool>.ndjson` for one call; the numbering continues after files from earlier runs and restarts; in: a tool call as the executor starts it, out: the writer the executor and `ctx.log` write through.
28
+ open(call: { name: string }): ToolLogWriter | undefined {
29
+ const dir = path.join(this.sessionDir, "tool-logs");
30
+ try {
31
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
32
+ this.next ??= fs.readdirSync(dir).reduce((max, f) => Math.max(max, Number(/^(\d+)-/.exec(f)?.[1] ?? 0)), 0) + 1;
33
+ const name = `${String(this.next++).padStart(3, "0")}-${call.name.replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 64)}.ndjson`;
34
+ const fd = fs.openSync(path.join(dir, name), "a", 0o600);
35
+ fsyncFolder(dir);
36
+ let open = true;
37
+ return {
38
+ file: `tool-logs/${name}`,
39
+ write(entry) {
40
+ if (!open) return;
41
+ try {
42
+ fs.writeSync(fd, JSON.stringify({ timestamp: Date.now(), ...entry }) + "\n");
43
+ fs.fdatasyncSync(fd);
44
+ } catch {
45
+ // why: a full disk must never fail the tool call being logged.
46
+ }
47
+ },
48
+ close() {
49
+ if (!open) return;
50
+ open = false;
51
+ try {
52
+ fs.closeSync(fd);
53
+ } catch {
54
+ // why: nothing left to flush.
55
+ }
56
+ },
57
+ };
58
+ } catch {
59
+ return undefined; // logging is best effort: the call runs without a log
60
+ }
61
+ }
62
+ }
@@ -4,12 +4,14 @@
4
4
  * Each event is a single synchronous append, so a process crash keeps every
5
5
  * event already emitted: a `tool.call_started` with no ending event was in
6
6
  * flight, and the last `tool.progress` shows how far it got. Power loss is not
7
- * covered (no fsync). `turn.thinking` (full reasoning text) is never written.
7
+ * covered (no fsync, by design: this is observability, not state). `turn.thinking`
8
+ * (full reasoning text) is never written. Takes the home's one-process lock.
8
9
  */
9
10
  import * as fs from "node:fs";
10
11
  import * as path from "node:path";
11
12
  import { telemetryBus } from "./telemetry.ts";
12
13
  import { safeStringify } from "./utils/serialization.ts";
14
+ import { lockFolder } from "./utils/durable.ts";
13
15
 
14
16
  const sinks = new Set<string>();
15
17
 
@@ -17,6 +19,7 @@ const sinks = new Set<string>();
17
19
  export function persistTelemetry(home: string): void {
18
20
  const dir = path.join(home, "telemetry");
19
21
  if (sinks.has(dir)) return;
22
+ lockFolder(home);
20
23
  sinks.add(dir);
21
24
  telemetryBus.onEvent((event) => {
22
25
  if (event.code === "turn.thinking") return;