@llblab/pi-actors 0.43.0 → 0.44.0

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.
Files changed (109) hide show
  1. package/AGENTS.md +16 -10
  2. package/CHANGELOG.md +425 -536
  3. package/README.md +13 -11
  4. package/dist/index.js +1 -1
  5. package/dist/lib/async-runs.d.ts +2 -1
  6. package/dist/lib/async-runs.js +24 -34
  7. package/dist/lib/automatic-review-runtime.d.ts +1 -1
  8. package/dist/lib/automatic-review-runtime.js +5 -5
  9. package/dist/lib/command-templates.d.ts +2 -0
  10. package/dist/lib/command-templates.js +38 -4
  11. package/dist/lib/control-projection.d.ts +20 -0
  12. package/dist/lib/control-projection.js +66 -0
  13. package/dist/lib/control.d.ts +3 -0
  14. package/dist/lib/control.js +27 -14
  15. package/dist/lib/draft-sleep.js +3 -3
  16. package/dist/lib/file-state.d.ts +4 -1
  17. package/dist/lib/file-state.js +118 -44
  18. package/dist/lib/inspector-overlay.d.ts +2 -0
  19. package/dist/lib/inspector-overlay.js +124 -69
  20. package/dist/lib/limits.d.ts +15 -3
  21. package/dist/lib/limits.js +15 -3
  22. package/dist/lib/observability.d.ts +4 -2
  23. package/dist/lib/observability.js +43 -36
  24. package/dist/lib/prompts.d.ts +1 -1
  25. package/dist/lib/prompts.js +1 -1
  26. package/dist/lib/recipe-control.js +6 -2
  27. package/dist/lib/review-control.d.ts +1 -1
  28. package/dist/lib/review-control.js +4 -5
  29. package/dist/lib/run-evidence-policy.d.ts +95 -0
  30. package/dist/lib/run-evidence-policy.js +177 -0
  31. package/dist/lib/run-ui-runtime.js +2 -0
  32. package/dist/lib/runs-control-delivery.d.ts +8 -1
  33. package/dist/lib/runs-control-delivery.js +38 -15
  34. package/dist/lib/runs-controls.d.ts +8 -4
  35. package/dist/lib/runs-controls.js +189 -50
  36. package/dist/lib/runs-retention.js +27 -14
  37. package/dist/lib/runs-trace.d.ts +26 -2
  38. package/dist/lib/runs-trace.js +411 -18
  39. package/dist/lib/runtime-identity.d.ts +7 -0
  40. package/dist/lib/runtime-identity.js +35 -0
  41. package/dist/lib/runtime-triage.d.ts +29 -0
  42. package/dist/lib/runtime-triage.js +60 -0
  43. package/dist/lib/tool-review-scheduler.js +7 -7
  44. package/dist/lib/tools-inspect.js +91 -18
  45. package/dist/lib/tools-message.d.ts +1 -2
  46. package/dist/lib/tools-message.js +6 -6
  47. package/dist/lib/tools-response.d.ts +0 -1
  48. package/dist/lib/tools-response.js +0 -9
  49. package/dist/lib/tools.d.ts +1 -1
  50. package/dist/lib/tools.js +1 -1
  51. package/dist/lib/trace-projection.js +107 -41
  52. package/dist/scripts/conformance.mjs +5 -0
  53. package/dist/scripts/locker.mjs +40 -90
  54. package/dist/scripts/music-player.mjs +48 -142
  55. package/dist/scripts/release-gates.mjs +56 -3
  56. package/dist/scripts/validate-recipe.mjs +5 -4
  57. package/dist/skills/actors/SKILL.md +17 -11
  58. package/dist/skills/swarm/SKILL.md +2 -4
  59. package/docs/README.md +1 -4
  60. package/docs/actor-inspector.md +6 -5
  61. package/docs/async-runs.md +11 -9
  62. package/docs/command-templates.md +6 -116
  63. package/docs/recipe-library.md +4 -6
  64. package/docs/releasing.md +28 -0
  65. package/docs/template-recipes.md +1 -1
  66. package/docs/tool-registry.md +2 -2
  67. package/index.ts +1 -1
  68. package/lib/async-runs.ts +26 -50
  69. package/lib/automatic-review-runtime.ts +7 -7
  70. package/lib/command-templates.ts +44 -4
  71. package/lib/control-projection.ts +105 -0
  72. package/lib/control.ts +33 -18
  73. package/lib/draft-sleep.ts +3 -3
  74. package/lib/file-state.ts +91 -63
  75. package/lib/inspector-overlay.ts +108 -61
  76. package/lib/limits.ts +15 -3
  77. package/lib/observability.ts +55 -57
  78. package/lib/prompts.ts +1 -1
  79. package/lib/recipe-control.ts +9 -2
  80. package/lib/review-control.ts +4 -5
  81. package/lib/run-evidence-policy.ts +242 -0
  82. package/lib/run-ui-runtime.ts +2 -0
  83. package/lib/runs-control-delivery.ts +45 -17
  84. package/lib/runs-controls.ts +180 -102
  85. package/lib/runs-retention.ts +28 -20
  86. package/lib/runs-trace.ts +499 -20
  87. package/lib/runtime-identity.ts +39 -0
  88. package/lib/runtime-triage.ts +106 -0
  89. package/lib/tool-review-scheduler.ts +7 -7
  90. package/lib/tools-inspect.ts +94 -20
  91. package/lib/tools-message.ts +7 -8
  92. package/lib/tools-response.ts +0 -12
  93. package/lib/tools.ts +4 -4
  94. package/lib/trace-projection.ts +156 -71
  95. package/package.json +1 -1
  96. package/scripts/conformance.mjs +5 -0
  97. package/scripts/locker.mjs +40 -90
  98. package/scripts/music-player.mjs +48 -142
  99. package/scripts/release-gates.mjs +56 -3
  100. package/scripts/validate-recipe.mjs +5 -4
  101. package/skills/actors/SKILL.md +17 -11
  102. package/skills/swarm/SKILL.md +2 -4
  103. package/dist/lib/runtime-notifier.d.ts +0 -48
  104. package/dist/lib/runtime-notifier.js +0 -138
  105. package/docs/0.43-baseline.md +0 -44
  106. package/docs/actors-deep-reference.md +0 -108
  107. package/docs/component-recipes.md +0 -45
  108. package/docs/task-first-recipes.md +0 -261
  109. package/lib/runtime-notifier.ts +0 -211
@@ -1,24 +1,25 @@
1
1
  /**
2
2
  * Run Control journal.
3
- * Zones: durable Control records, claim locks, status transitions, bounded compaction
4
- * Owns Run-local Control persistence; owner/generation authorization and transport delivery stay in lifecycle adapters.
3
+ * Zones: durable records, atomic admission, claims, transitions, compaction
4
+ * Owns Run-local Control persistence; lifecycle authorization and transport stay in adapters.
5
5
  */
6
6
 
7
7
  import { randomUUID } from "node:crypto";
8
- import { writeFileSync } from "node:fs";
8
+ import { closeSync, fstatSync, openSync, readSync, statSync } from "node:fs";
9
9
  import { join } from "node:path";
10
10
 
11
+ import * as Control from "./control.ts";
11
12
  import { acquireFileMutationLock, writeTextAtomic } from "./file-state.ts";
12
- import { readJsonlFileResilient } from "./state-readers.ts";
13
- export const RUN_CONTROL_TERMINAL_LIMIT = 128;
14
-
15
- export type RunControlStatus =
16
- | "queued"
17
- | "delivered"
18
- | "claimed"
19
- | "handled"
20
- | "failed";
13
+ import * as Limits from "./limits.ts";
14
+ import {
15
+ classifyRunControlRecord, computeRunControlCapacity, decideRunControlAdmission,
16
+ runControlStatusTimestamp, type RunControlAdmissionErrorDetails,
17
+ type RunControlAdmissionIntegrity, type RunControlStatus,
18
+ } from "./run-evidence-policy.ts";
19
+ import type { JsonlReadResult, StateReadDiagnostic } from "./state-readers.ts";
21
20
 
21
+ export { RUN_CONTROL_TERMINAL_LIMIT } from "./limits.ts";
22
+ export type { RunControlStatus } from "./run-evidence-policy.ts";
22
23
  export interface RunControlRecord {
23
24
  id: string;
24
25
  run_instance_id: string;
@@ -32,110 +33,196 @@ export interface RunControlRecord {
32
33
  failed_at?: string;
33
34
  error?: string;
34
35
  }
35
-
36
36
  export interface ProcessRunControlsResult {
37
- claimed: number;
38
- failed: number;
39
- handled: number;
37
+ claimed: number; failed: number; handled: number;
38
+ }
39
+ function admissionError(details: RunControlAdmissionErrorDetails): Error {
40
+ return Object.assign(new Error(details.reason === "control_backpressure"
41
+ ? `Run Control admission backpressured: pending=${details.pending}/${details.limit}`
42
+ : `Run Control journal integrity failed: ${details.integrity_reason ?? "unknown"}`), details);
40
43
  }
41
44
 
42
- const TERMINAL_STATUSES = new Set<RunControlStatus>(["handled", "failed"]);
43
-
45
+ const STATUSES = new Set<RunControlStatus>([
46
+ "queued", "delivered", "claimed", "handled", "failed",
47
+ ]);
44
48
  export function runControlsFile(stateDir: string): string {
45
49
  return join(stateDir, "controls.jsonl");
46
50
  }
47
-
51
+ export function readRunControlJournalFromStateDir(stateDir: string): JsonlReadResult<unknown> {
52
+ const path = runControlsFile(stateDir);
53
+ let fd: number;
54
+ try { fd = openSync(path, "r"); }
55
+ catch (error) {
56
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return { diagnostics: [], records: [] };
57
+ return { diagnostics: [{ message: String(error), path }], records: [] };
58
+ }
59
+ try {
60
+ const bytes = fstatSync(fd).size;
61
+ if (!Number.isSafeInteger(bytes) || bytes < 0 || bytes > Limits.RUN_CONTROL_JOURNAL_MAX_BYTES) {
62
+ return { diagnostics: [{ message: "Run Control journal exceeds bounded read limit", path }], records: [] };
63
+ }
64
+ const buffer = Buffer.allocUnsafe(bytes);
65
+ let consumed = 0;
66
+ while (consumed < bytes) {
67
+ const count = readSync(fd, buffer, consumed, bytes - consumed, consumed);
68
+ if (count === 0) return { diagnostics: [{ message: "Run Control journal changed during bounded read", path }], records: [] };
69
+ consumed += count;
70
+ }
71
+ if (fstatSync(fd).size !== bytes) return { diagnostics: [{ message: "Run Control journal changed during bounded read", path }], records: [] };
72
+ const records: unknown[] = [];
73
+ const diagnostics: StateReadDiagnostic[] = [];
74
+ for (const [index, line] of buffer.toString("utf8").split("\n").entries()) {
75
+ if (!line.trim()) continue;
76
+ try { records.push(JSON.parse(line)); }
77
+ catch (error) { diagnostics.push({ line: index + 1, message: String(error), path }); }
78
+ }
79
+ return { diagnostics, records };
80
+ } catch (error) {
81
+ return { diagnostics: [{ message: String(error), path }], records: [] };
82
+ } finally { closeSync(fd); }
83
+ }
48
84
  export function readRunControlsFromStateDir(stateDir: string): RunControlRecord[] {
49
- return readJsonlFileResilient<RunControlRecord>(runControlsFile(stateDir))
50
- .records;
85
+ return readRunControlJournalFromStateDir(stateDir).records as RunControlRecord[];
51
86
  }
52
-
53
87
  function acquireRunControlsLock(stateDir: string): () => void {
54
88
  return acquireFileMutationLock(runControlsFile(stateDir));
55
89
  }
56
90
 
57
91
  function compactRunControls(records: RunControlRecord[]): RunControlRecord[] {
58
- const active = records.filter((record) => !TERMINAL_STATUSES.has(record.status));
59
- const terminal = records
60
- .filter((record) => TERMINAL_STATUSES.has(record.status))
61
- .slice(-RUN_CONTROL_TERMINAL_LIMIT);
92
+ const active = records.filter((record) => classifyRunControlRecord(record) !== "terminal");
93
+ const terminal = records.filter((record) => classifyRunControlRecord(record) === "terminal")
94
+ .slice(-Limits.RUN_CONTROL_TERMINAL_LIMIT);
62
95
  const retained = new Set([...active, ...terminal]);
63
- return records.filter((record) => retained.has(record));
96
+ return records.filter((record) => retained.has(record)).map((record) =>
97
+ typeof record.error === "string" && Buffer.byteLength(record.error) > Limits.RUN_CONTROL_ERROR_MAX_BYTES
98
+ ? { ...record, error: boundedControlError(record.error) } : record);
99
+ }
100
+ function encodeRunControls(records: RunControlRecord[]): string {
101
+ return records.length ? `${records.map((record) => JSON.stringify(record)).join("\n")}\n` : "";
102
+ }
103
+ function boundedControlError(value: string): string {
104
+ if (Buffer.byteLength(value) <= Limits.RUN_CONTROL_ERROR_MAX_BYTES) return value;
105
+ const suffix = "… [truncated]";
106
+ const limit = Limits.RUN_CONTROL_ERROR_MAX_BYTES - Buffer.byteLength(suffix);
107
+ const bytes = Buffer.from(value);
108
+ let end = limit;
109
+ while (end > 0 && (bytes[end] & 0xc0) === 0x80) end -= 1;
110
+ return `${bytes.subarray(0, end).toString("utf8")}${suffix}`;
64
111
  }
65
-
66
112
  function writeRunControls(stateDir: string, records: RunControlRecord[]): void {
67
- const compacted = compactRunControls(records);
68
- writeTextAtomic(
69
- runControlsFile(stateDir),
70
- compacted.length
71
- ? `${compacted.map((record) => JSON.stringify(record)).join("\n")}\n`
72
- : "",
73
- );
113
+ const encoded = encodeRunControls(compactRunControls(records));
114
+ if (Buffer.byteLength(encoded) > Limits.RUN_CONTROL_JOURNAL_MAX_BYTES) {
115
+ throw new Error("Run Control journal transition exceeds bounded byte limit");
116
+ }
117
+ writeTextAtomic(runControlsFile(stateDir), encoded);
118
+ }
119
+ function readMutableRunControls(
120
+ stateDir: string, runInstanceId?: string,
121
+ ): RunControlRecord[] | undefined {
122
+ const journal = readRunControlJournalFromStateDir(stateDir);
123
+ if (journal.diagnostics.length) return undefined;
124
+ const records = journal.records as RunControlRecord[];
125
+ if (runInstanceId && records.some((record) => controlIntegrity(record, runInstanceId) !== "valid"))
126
+ return undefined;
127
+ return records;
128
+ }
129
+ function controlIntegrity(value: unknown, runInstanceId: string): RunControlAdmissionIntegrity {
130
+ if (!value || typeof value !== "object" || Array.isArray(value)) return "invalid_record";
131
+ const record = value as Record<string, unknown>;
132
+ if (typeof record.id !== "string" || !record.id ||
133
+ typeof record.run_instance_id !== "string" || !record.run_instance_id ||
134
+ typeof record.status !== "string" || !STATUSES.has(record.status as RunControlStatus) ||
135
+ (record.error !== undefined && typeof record.error !== "string")) return "invalid_record";
136
+ try {
137
+ Control.normalizeControlAction(record.action);
138
+ if (record.input !== undefined) Control.normalizeControlInput(record.input);
139
+ } catch { return "invalid_record"; }
140
+ const timestamp = runControlStatusTimestamp(record, record.status as RunControlStatus);
141
+ if (typeof timestamp !== "string" || !Number.isFinite(Date.parse(timestamp)))
142
+ return "invalid_status_timestamp";
143
+ if (classifyRunControlRecord(record) === "pending" &&
144
+ record.run_instance_id !== runInstanceId) return "generation_mismatch";
145
+ return "valid";
74
146
  }
75
-
76
147
  export function appendRunControlInStateDir(
77
- stateDir: string,
78
- request: { run_instance_id: string; action: string; input?: unknown },
148
+ stateDir: string, request: { run_instance_id: string; action: string; input?: unknown },
79
149
  ): RunControlRecord {
150
+ const action = Control.normalizeControlAction(request.action);
151
+ const input = request.input === undefined ? undefined : Control.normalizeControlInput(request.input);
80
152
  const record: RunControlRecord = {
81
153
  id: randomUUID(),
82
154
  run_instance_id: request.run_instance_id,
83
- action: request.action,
84
- ...(request.input !== undefined ? { input: request.input } : {}),
155
+ action,
156
+ ...(input !== undefined ? { input } : {}),
85
157
  status: "queued",
86
158
  queued_at: new Date().toISOString(),
87
159
  };
160
+ const encodedRecordBytes = Buffer.byteLength(`${JSON.stringify(record)}\n`);
88
161
  const releaseLock = acquireRunControlsLock(stateDir);
89
162
  try {
90
- writeFileSync(runControlsFile(stateDir), `${JSON.stringify(record)}\n`, {
91
- flag: "a",
163
+ const path = runControlsFile(stateDir);
164
+ let bytes = 0;
165
+ try { bytes = statSync(path).size; }
166
+ catch (error) {
167
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") bytes = Number.MAX_SAFE_INTEGER;
168
+ }
169
+ const journal = bytes > Limits.RUN_CONTROL_JOURNAL_MAX_BYTES
170
+ ? { diagnostics: [], records: [] } : readRunControlJournalFromStateDir(stateDir);
171
+ let integrity: RunControlAdmissionIntegrity = bytes > Limits.RUN_CONTROL_JOURNAL_MAX_BYTES
172
+ ? "journal_bytes"
173
+ : journal.diagnostics.some((item) => item.line !== undefined)
174
+ ? "malformed"
175
+ : journal.diagnostics.length ? "unreadable" : "valid";
176
+ for (const value of journal.records) {
177
+ if (integrity !== "valid") break;
178
+ integrity = controlIntegrity(value, request.run_instance_id);
179
+ }
180
+ const records = journal.records as RunControlRecord[];
181
+ const retained = encodeRunControls(compactRunControls(records));
182
+ const decision = decideRunControlAdmission({
183
+ integrity,
184
+ journalBytes: bytes,
185
+ newRecordBytes: encodedRecordBytes,
186
+ records,
187
+ retainedJournalBytes: Buffer.byteLength(retained),
188
+ runInstanceId: request.run_instance_id,
189
+ capacity: computeRunControlCapacity(records),
92
190
  });
191
+ if (!decision.admitted) throw admissionError(decision.error);
192
+ writeTextAtomic(path, `${retained}${JSON.stringify(record)}\n`);
193
+ return record;
93
194
  } finally {
94
195
  releaseLock();
95
196
  }
96
- return record;
97
197
  }
98
-
99
198
  export function updateRunControlStatusInStateDir(
100
- stateDir: string,
101
- id: string,
102
- nextStatus: RunControlStatus,
199
+ stateDir: string, id: string, nextStatus: RunControlStatus,
103
200
  metadata: Pick<RunControlRecord, "error"> = {},
104
201
  expectedStatuses?: readonly RunControlStatus[],
105
202
  ): boolean {
106
203
  const releaseLock = acquireRunControlsLock(stateDir);
107
204
  try {
108
- const records = readRunControlsFromStateDir(stateDir);
205
+ const records = readMutableRunControls(stateDir);
206
+ if (!records) return false;
109
207
  const index = records.findIndex((record) => record.id === id);
110
208
  if (index < 0) return false;
111
209
  const record = records[index]!;
210
+ if (records.some((value) => controlIntegrity(value, record.run_instance_id) !== "valid")) return false;
112
211
  if (expectedStatuses && !expectedStatuses.includes(record.status)) return false;
113
212
  if (record.status === nextStatus) return false;
114
213
  const timestamp = new Date().toISOString();
115
214
  if (nextStatus === "delivered") {
116
215
  if (record.delivered_at) return false;
117
- records[index] = {
118
- ...record,
119
- delivered_at: timestamp,
120
- ...(record.status === "queued" ? { status: "delivered" as const } : {}),
121
- };
216
+ records[index] = { ...record, delivered_at: timestamp,
217
+ ...(record.status === "queued" ? { status: "delivered" as const } : {}) };
122
218
  } else {
123
- if (TERMINAL_STATUSES.has(record.status) || nextStatus === "queued") return false;
124
- if (
125
- (nextStatus === "claimed" &&
126
- record.status !== "queued" &&
219
+ if (classifyRunControlRecord(record) === "terminal" || nextStatus === "queued") return false;
220
+ if ((nextStatus === "claimed" && record.status !== "queued" &&
127
221
  record.status !== "delivered") ||
128
- (nextStatus === "handled" && record.status !== "claimed")
129
- ) {
130
- return false;
131
- }
132
- const timestampKey = `${nextStatus}_at` as const;
133
- records[index] = {
134
- ...record,
135
- ...metadata,
136
- [timestampKey]: timestamp,
137
- status: nextStatus,
138
- };
222
+ (nextStatus === "handled" && record.status !== "claimed")) return false;
223
+ records[index] = { ...record,
224
+ ...(metadata.error !== undefined ? { error: boundedControlError(metadata.error) } : {}),
225
+ [`${nextStatus}_at`]: timestamp, status: nextStatus };
139
226
  }
140
227
  writeRunControls(stateDir, records);
141
228
  return true;
@@ -143,38 +230,38 @@ export function updateRunControlStatusInStateDir(
143
230
  releaseLock();
144
231
  }
145
232
  }
146
-
147
- export function claimRunControlInStateDir(
148
- stateDir: string,
149
- runInstanceId: string,
233
+ function claimRunControl(
234
+ stateDir: string, runInstanceId: string, controlId?: string,
150
235
  ): RunControlRecord | undefined {
151
236
  const releaseLock = acquireRunControlsLock(stateDir);
152
237
  try {
153
- const records = readRunControlsFromStateDir(stateDir);
154
- const index = records.findIndex(
155
- (record) =>
156
- record.run_instance_id === runInstanceId &&
157
- (record.status === "queued" || record.status === "delivered"),
158
- );
238
+ const records = readMutableRunControls(stateDir, runInstanceId);
239
+ if (!records) return undefined;
240
+ const index = records.findIndex((record) =>
241
+ (controlId === undefined || record.id === controlId) &&
242
+ record.run_instance_id === runInstanceId &&
243
+ (record.status === "queued" || record.status === "delivered"));
159
244
  if (index < 0) return undefined;
160
245
  const claimed: RunControlRecord = {
161
- ...records[index]!,
162
- claimed_at: new Date().toISOString(),
163
- status: "claimed",
164
- };
246
+ ...records[index]!, claimed_at: new Date().toISOString(), status: "claimed" };
165
247
  records[index] = claimed;
166
248
  writeRunControls(stateDir, records);
167
249
  return claimed;
168
- } finally {
169
- releaseLock();
170
- }
250
+ } finally { releaseLock(); }
251
+ }
252
+ export function claimRunControlInStateDir(
253
+ stateDir: string, runInstanceId: string,
254
+ ): RunControlRecord | undefined {
255
+ return claimRunControl(stateDir, runInstanceId);
256
+ }
257
+ export function claimRunControlByIdInStateDir(
258
+ stateDir: string, runInstanceId: string, controlId: string,
259
+ ): RunControlRecord | undefined {
260
+ return claimRunControl(stateDir, runInstanceId, controlId);
171
261
  }
172
-
173
262
  export async function processRunControlsInStateDir(
174
- stateDir: string,
175
- runInstanceId: string,
176
- handler: (control: RunControlRecord) => Promise<void> | void,
177
- limit = 1,
263
+ stateDir: string, runInstanceId: string,
264
+ handler: (control: RunControlRecord) => Promise<void> | void, limit = 1,
178
265
  ): Promise<ProcessRunControlsResult> {
179
266
  const result: ProcessRunControlsResult = { claimed: 0, failed: 0, handled: 0 };
180
267
  for (let index = 0; index < Math.max(1, limit); index += 1) {
@@ -183,21 +270,12 @@ export async function processRunControlsInStateDir(
183
270
  result.claimed += 1;
184
271
  try {
185
272
  await handler(control);
186
- if (updateRunControlStatusInStateDir(stateDir, control.id, "handled", {}, ["claimed"])) {
273
+ if (updateRunControlStatusInStateDir(stateDir, control.id, "handled", {}, ["claimed"]))
187
274
  result.handled += 1;
188
- }
189
275
  } catch (error) {
190
- if (
191
- updateRunControlStatusInStateDir(
192
- stateDir,
193
- control.id,
194
- "failed",
195
- { error: error instanceof Error ? error.message : String(error) },
196
- ["claimed"],
197
- )
198
- ) {
276
+ if (updateRunControlStatusInStateDir(stateDir, control.id, "failed",
277
+ { error: error instanceof Error ? error.message : String(error) }, ["claimed"]))
199
278
  result.failed += 1;
200
- }
201
279
  }
202
280
  }
203
281
  return result;
@@ -4,10 +4,12 @@
4
4
  */
5
5
 
6
6
  import { createHash, randomUUID } from "node:crypto";
7
- import { appendFileSync, cpSync, mkdirSync, renameSync, rmSync } from "node:fs";
7
+ import { cpSync, mkdirSync, renameSync, rmSync } from "node:fs";
8
8
  import { basename, dirname, join } from "node:path";
9
9
 
10
- import { acquireFileMutationLock, writeJsonAtomic } from "./file-state.ts";
10
+ import { acquireFileMutationLock, writeJsonAtomic, writeTextAtomic } from "./file-state.ts";
11
+ import * as Limits from "./limits.ts";
12
+ import { readJsonlFileResilient } from "./state-readers.ts";
11
13
  import {
12
14
  resolveArtifactManifest,
13
15
  type RunArtifactDeclaration,
@@ -27,29 +29,35 @@ export function appendRunRetentionEvidence(
27
29
  const stateDir = String(status.state_dir);
28
30
  const path = join(dirname(stateDir), "retention.jsonl");
29
31
  const id = options.id ?? randomUUID();
32
+ const record = {
33
+ action,
34
+ ...(options.error ? { error: options.error } : {}),
35
+ id,
36
+ outcome,
37
+ ...(options.result ? { result: options.result } : {}),
38
+ run: String(status.run ?? basename(stateDir)),
39
+ ...(typeof status.run_instance_id === "string" ? { run_instance_id: status.run_instance_id } : {}),
40
+ ts: new Date().toISOString(),
41
+ };
30
42
  const release = acquireFileMutationLock(path);
31
43
  try {
32
- appendFileSync(
33
- path,
34
- `${JSON.stringify({
35
- action,
36
- ...(options.error ? { error: options.error } : {}),
37
- id,
38
- outcome,
39
- ...(options.result ? { result: options.result } : {}),
40
- run: String(status.run ?? basename(stateDir)),
41
- ...(typeof status.run_instance_id === "string"
42
- ? { run_instance_id: status.run_instance_id }
43
- : {}),
44
- ts: new Date().toISOString(),
45
- })}\n`,
46
- );
47
- } finally {
48
- release();
49
- }
44
+ const records = [...readJsonlFileResilient<Record<string, unknown>>(path).records, record]
45
+ .slice(-Limits.RUN_RETENTION_MAX_RECORDS);
46
+ let content = encodeRecords(records);
47
+ while (records.length && Buffer.byteLength(content) > Limits.RUN_RETENTION_MAX_BYTES) {
48
+ records.shift();
49
+ content = encodeRecords(records);
50
+ }
51
+ if (!records.includes(record)) throw new Error("Run retention record exceeds journal byte limit");
52
+ writeTextAtomic(path, content);
53
+ } finally { release(); }
50
54
  return id;
51
55
  }
52
56
 
57
+ function encodeRecords(records: Record<string, unknown>[]): string {
58
+ return records.length ? `${records.map((value) => JSON.stringify(value)).join("\n")}\n` : "";
59
+ }
60
+
53
61
  function retainedArtifactFilename(name: string, path: string): string {
54
62
  const readableName = name.replace(/[^A-Za-z0-9_.-]+/g, "_") || "artifact";
55
63
  const identity = createHash("sha256")