@llblab/pi-actors 0.43.1 → 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 (57) hide show
  1. package/AGENTS.md +6 -6
  2. package/BACKLOG.md +1 -189
  3. package/CHANGELOG.md +412 -402
  4. package/README.md +5 -5
  5. package/dist/lib/async-runs.js +8 -30
  6. package/dist/lib/file-state.d.ts +3 -1
  7. package/dist/lib/file-state.js +25 -7
  8. package/dist/lib/inspector-overlay.js +46 -30
  9. package/dist/lib/limits.d.ts +10 -0
  10. package/dist/lib/limits.js +10 -0
  11. package/dist/lib/observability.d.ts +4 -2
  12. package/dist/lib/observability.js +43 -36
  13. package/dist/lib/run-evidence-policy.d.ts +95 -0
  14. package/dist/lib/run-evidence-policy.js +177 -0
  15. package/dist/lib/run-ui-runtime.js +2 -0
  16. package/dist/lib/runs-controls.d.ts +7 -5
  17. package/dist/lib/runs-controls.js +186 -49
  18. package/dist/lib/runs-retention.js +27 -14
  19. package/dist/lib/runs-trace.d.ts +25 -1
  20. package/dist/lib/runs-trace.js +410 -21
  21. package/dist/lib/runtime-triage.js +6 -22
  22. package/dist/lib/tools-inspect.js +48 -14
  23. package/dist/lib/trace-projection.js +90 -44
  24. package/dist/scripts/conformance.mjs +5 -0
  25. package/dist/scripts/locker.mjs +31 -74
  26. package/dist/scripts/music-player.mjs +41 -129
  27. package/dist/scripts/release-gates.mjs +26 -4
  28. package/dist/skills/actors/SKILL.md +7 -6
  29. package/dist/skills/swarm/SKILL.md +2 -2
  30. package/docs/README.md +0 -1
  31. package/docs/actor-inspector.md +3 -2
  32. package/docs/async-runs.md +10 -8
  33. package/docs/recipe-library.md +2 -2
  34. package/lib/async-runs.ts +8 -45
  35. package/lib/file-state.ts +28 -7
  36. package/lib/inspector-overlay.ts +29 -17
  37. package/lib/limits.ts +10 -0
  38. package/lib/observability.ts +55 -57
  39. package/lib/run-evidence-policy.ts +242 -0
  40. package/lib/run-ui-runtime.ts +2 -0
  41. package/lib/runs-controls.ts +177 -108
  42. package/lib/runs-retention.ts +28 -20
  43. package/lib/runs-trace.ts +496 -21
  44. package/lib/runtime-triage.ts +11 -25
  45. package/lib/tools-inspect.ts +45 -15
  46. package/lib/trace-projection.ts +137 -75
  47. package/package.json +1 -1
  48. package/scripts/conformance.mjs +5 -0
  49. package/scripts/locker.mjs +31 -74
  50. package/scripts/music-player.mjs +41 -129
  51. package/scripts/release-gates.mjs +26 -4
  52. package/skills/actors/SKILL.md +7 -6
  53. package/skills/swarm/SKILL.md +2 -2
  54. package/dist/lib/runtime-notifier.d.ts +0 -48
  55. package/dist/lib/runtime-notifier.js +0 -138
  56. package/docs/0.43-baseline.md +0 -39
  57. package/lib/runtime-notifier.ts +0 -211
@@ -3,11 +3,13 @@
3
3
  * Zones: Run Recipe/Trace/Control views and runtime/recipe/tool diagnostics
4
4
  * Owns exact inspect target/view dispatch; source projection stays in domain modules.
5
5
  */
6
+ import { statSync } from "node:fs";
6
7
  import { join } from "node:path";
7
8
  import * as AsyncRuns from "./async-runs.js";
8
9
  import * as ControlProjection from "./control-projection.js";
9
10
  import * as Inspector from "./inspector.js";
10
11
  import * as Limits from "./limits.js";
12
+ import { computeRunControlCapacity } from "./run-evidence-policy.js";
11
13
  import * as Paths from "./paths.js";
12
14
  import * as RecipesDiscovery from "./recipes-discovery.js";
13
15
  import * as ReviewDiagnostics from "./review-diagnostics.js";
@@ -55,7 +57,9 @@ function runtimeTriage(ctx, deps) {
55
57
  const failed = runs.filter((run) => run.status === "failed");
56
58
  const pendingControls = [];
57
59
  const staleControls = [];
60
+ const backpressuredRuns = [];
58
61
  const controlDiagnostics = [];
62
+ const traceDiagnostics = [];
59
63
  const nowMs = Date.now();
60
64
  for (const run of runs) {
61
65
  const stateDir = typeof run.state_dir === "string" ? run.state_dir : undefined;
@@ -63,13 +67,19 @@ function runtimeTriage(ctx, deps) {
63
67
  continue;
64
68
  const runId = String(run.run ?? "unknown");
65
69
  const journal = RunsControls.readRunControlJournalFromStateDir(stateDir);
70
+ const capacity = computeRunControlCapacity(journal.records);
66
71
  controlDiagnostics.push(...journal.diagnostics.map((diagnostic) => ({
67
72
  ...(diagnostic.line === undefined ? {} : { line: diagnostic.line }),
68
- reason: diagnostic.line === undefined
69
- ? "unreadable_control_journal"
70
- : "invalid_control_json",
71
- run: runId,
73
+ reason: diagnostic.line === undefined ? "unreadable_control_journal" : "invalid_control_json", run: runId,
72
74
  })));
75
+ if (capacity.backpressured)
76
+ backpressuredRuns.push({
77
+ run: runId, pending: capacity.pending, pending_limit: capacity.limit,
78
+ journal_bytes: statSync(RunsControls.runControlsFile(stateDir)).size,
79
+ });
80
+ const traceSummary = RunsTrace.summarizeRunTraceJournal(RunsTrace.readRunTraceJournal(stateDir));
81
+ if (!traceSummary.history_complete)
82
+ traceDiagnostics.push({ run: runId, ...traceSummary });
73
83
  for (const control of journal.records) {
74
84
  const classified = RuntimeTriage.classifyRuntimeControl({
75
85
  run: runId,
@@ -114,12 +124,13 @@ function runtimeTriage(ctx, deps) {
114
124
  pending_controls: boundedPendingControls,
115
125
  stale_control_count: staleControls.length,
116
126
  stale_controls: boundedStaleControls,
117
- control_diagnostic_count: controlDiagnostics.length,
118
- control_diagnostics: controlDiagnostics.slice(0, 40),
127
+ backpressured_run_count: backpressuredRuns.length, backpressured_runs: backpressuredRuns.slice(0, 40),
128
+ control_diagnostic_count: controlDiagnostics.length, control_diagnostics: controlDiagnostics.slice(0, 40),
129
+ trace_diagnostic_count: traceDiagnostics.length, trace_diagnostics: traceDiagnostics.slice(0, 40),
119
130
  attention_events: attentionEvents.slice(-40).reverse(),
120
131
  next_actions: [
121
132
  ...(failed.length ? ["inspect target=runtime view=runs status=failed"] : []),
122
- ...staleControls.slice(0, 3).map((entry) => `inspect target=run:${entry.run} view=control`),
133
+ ...new Set([...staleControls, ...backpressuredRuns].slice(0, 3).map((entry) => `inspect target=run:${entry.run} view=control`)),
123
134
  ...(attentionEvents.length
124
135
  ? attentionEvents.slice(-3).map((entry) => `inspect target=run:${String(entry.run)} view=trace source=runtime`)
125
136
  : []),
@@ -131,7 +142,7 @@ function compactRuntime(view, details) {
131
142
  return `\nruntime runs=${details.runs?.length ?? 0}`;
132
143
  }
133
144
  if (view === "triage") {
134
- return `\nruntime failed=${details.failed_runs?.length ?? 0} pending_controls=${Number(details.pending_control_count ?? 0)} stale_controls=${Number(details.stale_control_count ?? 0)} attention=${details.attention_events?.length ?? 0}`;
145
+ return `\nruntime failed=${details.failed_runs?.length ?? 0} pending_controls=${Number(details.pending_control_count ?? 0)} stale_controls=${Number(details.stale_control_count ?? 0)} backpressured=${Number(details.backpressured_run_count ?? 0)} incomplete_trace=${Number(details.trace_diagnostic_count ?? 0)} attention=${details.attention_events?.length ?? 0}`;
135
146
  }
136
147
  return `\nruntime version=${String(details.version)} state_schema=${String(details.state_schema)} automatic_review=${String(details.automatic_review)}`;
137
148
  }
@@ -199,8 +210,8 @@ function inspectRun(run, view, input, ctx, deps) {
199
210
  throw new Error(`unsupported Trace source: ${source}`);
200
211
  }
201
212
  return {
202
- run,
203
- source,
213
+ run, source,
214
+ summary: RunsTrace.summarizeRunTraceJournal(RunsTrace.readRunTraceJournal(stateDir)),
204
215
  items: TraceProjection.projectRunTrace(stateDir, {
205
216
  artifacts: status.artifacts,
206
217
  limit: Number(input.lines || Limits.DEFAULT_INSPECT_LINES),
@@ -209,19 +220,42 @@ function inspectRun(run, view, input, ctx, deps) {
209
220
  };
210
221
  }
211
222
  const runInstanceId = String(status.run_instance_id ?? "");
223
+ const journal = RunsControls.readRunControlJournalFromStateDir(stateDir);
224
+ const capacity = computeRunControlCapacity(journal.records);
225
+ const structuralDiagnostics = journal.records.flatMap((value) => {
226
+ const classified = RuntimeTriage.classifyRuntimeControl({ run,
227
+ runInstanceId: runInstanceId || undefined, status: String(status.status ?? "unknown") }, value, Date.now());
228
+ return classified.diagnostic ? [classified.diagnostic.reason] : [];
229
+ });
230
+ let journalBytes = 0;
231
+ try {
232
+ journalBytes = statSync(RunsControls.runControlsFile(stateDir)).size;
233
+ }
234
+ catch { }
235
+ const diagnostics = [...journal.diagnostics.map((item) => ({
236
+ ...(item.line === undefined ? {} : { line: item.line }),
237
+ reason: item.line === undefined ? "unreadable_control_journal" : "invalid_control_json",
238
+ })), ...structuralDiagnostics.map((reason) => ({ reason }))].slice(0, 40);
239
+ const nowMs = Date.now();
240
+ const stalePending = journal.records.reduce((count, control) => count + Number(Boolean(RuntimeTriage.classifyRuntimeControl({ run, runInstanceId: runInstanceId || undefined,
241
+ status: String(status.status ?? "unknown") }, control, nowMs).stale)), 0);
212
242
  return {
213
243
  run,
214
244
  run_instance_id: runInstanceId,
215
245
  status: status.status,
246
+ pending: capacity.pending, pending_limit: capacity.limit,
247
+ available: capacity.available, backpressured: capacity.backpressured,
248
+ journal_bytes: journalBytes, journal_limit: Limits.RUN_CONTROL_JOURNAL_MAX_BYTES,
249
+ ...(capacity.oldest_pending_at ? { oldest_pending_at: capacity.oldest_pending_at } : {}),
250
+ stale_pending: stalePending,
251
+ diagnostics,
216
252
  actor_actions: Array.isArray(status.control) ? status.control : [],
217
253
  runtime_actions: status.status === "running" ? ["kill"] : ["archive", "prune"],
218
254
  endpoint: runInstanceId
219
255
  ? RunControlDelivery.readRunControlEndpoint(stateDir, runInstanceId)
220
256
  : undefined,
221
- recent_controls: RunsControls.readRunControlsFromStateDir(stateDir)
222
- .slice(-Math.max(1, Number(input.lines || 20)))
223
- .reverse()
224
- .map(ControlProjection.projectRunControl),
257
+ recent_controls: journal.records.slice(-Math.max(1, Number(input.lines || 20))).reverse()
258
+ .map((control) => ControlProjection.projectRunControl(control)),
225
259
  };
226
260
  }
227
261
  function inspectTool(name, view, deps) {
@@ -9,11 +9,21 @@ import * as ControlProjection from "./control-projection.js";
9
9
  import * as ExecutionSessions from "./execution-sessions.js";
10
10
  import * as Limits from "./limits.js";
11
11
  import { resolveArtifactManifest, } from "./runs-artifacts.js";
12
+ import * as RunsControls from "./runs-controls.js";
12
13
  import { tailFile } from "./runs-status.js";
14
+ import * as RunsTrace from "./runs-trace.js";
13
15
  import * as SessionEvidence from "./session-evidence.js";
14
- import { readJsonFileResilient, readJsonlFileResilient, } from "./state-readers.js";
16
+ import { readJsonFileResilient, } from "./state-readers.js";
15
17
  const LIFECYCLE_PREFIXES = ["run.", "command."];
16
18
  const SOURCE_LIMIT = 100;
19
+ const SOURCE_RANK = {
20
+ lifecycle: 6,
21
+ control: 5,
22
+ process: 4,
23
+ agent: 3,
24
+ artifact: 2,
25
+ runtime: 1,
26
+ };
17
27
  function redact(value) {
18
28
  return SessionEvidence.redactSessionEvidenceValue(value);
19
29
  }
@@ -22,6 +32,21 @@ function sourceForEvent(event) {
22
32
  ? "lifecycle"
23
33
  : "runtime";
24
34
  }
35
+ function ordered(item, sourceKey, ordinal) {
36
+ return { item, ordinal, sourceKey };
37
+ }
38
+ function compareTraceItems(left, right) {
39
+ const timestamp = right.item.ts.localeCompare(left.item.ts);
40
+ if (timestamp)
41
+ return timestamp;
42
+ if (left.sourceKey === right.sourceKey) {
43
+ const ordinal = right.ordinal - left.ordinal;
44
+ if (ordinal)
45
+ return ordinal;
46
+ }
47
+ return SOURCE_RANK[right.item.source] - SOURCE_RANK[left.item.source] ||
48
+ right.item.id.localeCompare(left.item.id);
49
+ }
25
50
  function latestControlTimestamp(control) {
26
51
  return (control.failed_at ??
27
52
  control.handled_at ??
@@ -57,39 +82,57 @@ function controlDiagnosticItems(diagnostics) {
57
82
  }));
58
83
  }
59
84
  function traceEventItems(stateDir) {
60
- const read = readJsonlFileResilient(join(stateDir, "trace.jsonl"));
61
- return [
62
- ...read.records.slice(-SOURCE_LIMIT).map((event) => ({
63
- id: event.id,
64
- ts: event.ts,
65
- source: sourceForEvent(event),
66
- kind: event.kind,
67
- summary: event.summary ?? event.kind,
68
- ...(event.level ? { level: event.level } : {}),
69
- detail: redact({
70
- ...(event.attention ? { attention: event.attention } : {}),
71
- ...(event.data !== undefined ? { data: event.data } : {}),
72
- }),
73
- })),
74
- ...diagnosticItems(read.diagnostics),
75
- ];
85
+ const read = RunsTrace.readRunTraceJournal(stateDir);
86
+ const retained = read.events.slice(-SOURCE_LIMIT);
87
+ const projected = retained.map(({ event, ordinal }) => ordered({
88
+ id: event.id,
89
+ ts: event.ts,
90
+ source: sourceForEvent(event),
91
+ kind: event.kind,
92
+ summary: event.summary ?? event.kind,
93
+ ...(event.level ? { level: event.level } : {}),
94
+ detail: redact({
95
+ ...(event.attention ? { attention: event.attention } : {}),
96
+ ...(event.data !== undefined ? { data: event.data } : {}),
97
+ }),
98
+ }, "trace", ordinal));
99
+ const diagnostics = diagnosticItems(read.diagnostics).map((item, index) => ordered(item, "trace", read.events.length + index + 1));
100
+ const hasMarker = read.events.some(({ event }) => event.kind === "runtime.trace_compacted");
101
+ if (read.omittedPrefixBytes > 0 && !hasMarker) {
102
+ diagnostics.push(ordered({
103
+ id: "runtime:trace-history-incomplete",
104
+ ts: new Date(0).toISOString(),
105
+ source: "runtime",
106
+ kind: "runtime.trace_history_incomplete",
107
+ summary: "Trace history is incomplete",
108
+ level: "warning",
109
+ detail: {
110
+ omitted_prefix_bytes: read.omittedPrefixBytes,
111
+ read_bytes: read.readBytes,
112
+ retained_events: read.events.length,
113
+ },
114
+ }, "trace", 0));
115
+ }
116
+ return [...projected, ...diagnostics];
76
117
  }
77
118
  function controlItems(stateDir) {
78
- const read = readJsonlFileResilient(join(stateDir, "controls.jsonl"));
119
+ const read = RunsControls.readRunControlJournalFromStateDir(stateDir);
120
+ const start = Math.max(0, read.records.length - SOURCE_LIMIT);
121
+ const projected = read.records.slice(start).map((control, index) => {
122
+ const value = ControlProjection.projectRunControl(control);
123
+ return ordered({
124
+ id: `control:${value.id}`,
125
+ ts: latestControlTimestamp(value),
126
+ source: "control",
127
+ kind: `control.${value.status}`,
128
+ summary: `${value.action} ${value.status}`,
129
+ ...(value.status === "failed" ? { level: "error" } : {}),
130
+ detail: value,
131
+ }, "control", start + index + 1);
132
+ });
79
133
  return [
80
- ...read.records.slice(-SOURCE_LIMIT).map((control) => {
81
- const projected = ControlProjection.projectRunControl(control);
82
- return {
83
- id: `control:${projected.id}`,
84
- ts: latestControlTimestamp(projected),
85
- source: "control",
86
- kind: `control.${projected.status}`,
87
- summary: `${projected.action} ${projected.status}`,
88
- ...(projected.status === "failed" ? { level: "error" } : {}),
89
- detail: projected,
90
- };
91
- }),
92
- ...controlDiagnosticItems(read.diagnostics),
134
+ ...projected,
135
+ ...controlDiagnosticItems(read.diagnostics).map((item, index) => ordered(item, "control", read.records.length + index + 1)),
93
136
  ];
94
137
  }
95
138
  function agentItems(stateDir) {
@@ -98,7 +141,7 @@ function agentItems(stateDir) {
98
141
  .map((turn) => {
99
142
  const text = turn.assistantText ?? turn.userText ?? "Agent turn";
100
143
  const summary = text.replaceAll(/\s+/g, " ").trim().slice(0, 160);
101
- return {
144
+ return ordered({
102
145
  id: `agent:${turn.commandId}:${turn.sessionFile}:${turn.index}`,
103
146
  ts: turn.timestamp ?? new Date(0).toISOString(),
104
147
  source: "agent",
@@ -106,7 +149,7 @@ function agentItems(stateDir) {
106
149
  summary: summary || "Agent turn",
107
150
  ...(turn.error ? { level: "error" } : {}),
108
151
  detail: redact(turn),
109
- };
152
+ }, "agent", turn.index);
110
153
  });
111
154
  }
112
155
  function processItems(stateDir) {
@@ -118,7 +161,7 @@ function processItems(stateDir) {
118
161
  const tail = tailFile(path, Limits.DEFAULT_INSPECT_LINES);
119
162
  if (!tail && stat.size === 0)
120
163
  return [];
121
- return [{
164
+ return [ordered({
122
165
  id: `process:${stream}`,
123
166
  ts: stat.mtime.toISOString(),
124
167
  source: "process",
@@ -126,17 +169,18 @@ function processItems(stateDir) {
126
169
  summary: `${stream} (${stat.size} bytes)`,
127
170
  ...(stream === "stderr" ? { level: "warning" } : {}),
128
171
  detail: redact({ bytes: stat.size, path, tail }),
129
- }];
172
+ }, `process:${stream}`, 1)];
130
173
  });
131
174
  }
132
175
  function resultItems(stateDir) {
133
176
  const path = join(stateDir, "result.json");
134
177
  const read = readJsonFileResilient(path, {});
135
- if (Object.keys(read.value).length === 0)
136
- return diagnosticItems(read.diagnostics);
178
+ if (Object.keys(read.value).length === 0) {
179
+ return diagnosticItems(read.diagnostics).map((item, index) => ordered(item, "process:result", index));
180
+ }
137
181
  const stat = statSync(path);
138
182
  const failed = Number(read.value.code ?? 0) !== 0;
139
- return [{
183
+ return [ordered({
140
184
  id: "process:result",
141
185
  ts: typeof read.value.completedAt === "string"
142
186
  ? read.value.completedAt
@@ -146,13 +190,13 @@ function resultItems(stateDir) {
146
190
  summary: failed ? "Run process failed" : "Run process completed",
147
191
  ...(failed ? { level: "error" } : {}),
148
192
  detail: redact(read.value),
149
- }, ...diagnosticItems(read.diagnostics)];
193
+ }, "process:result", 1), ...diagnosticItems(read.diagnostics).map((item, index) => ordered(item, "process:result", index + 2))];
150
194
  }
151
195
  function artifactItems(stateDir, declarations) {
152
196
  const manifest = resolveArtifactManifest(declarations);
153
197
  if (!manifest)
154
198
  return [];
155
- return Object.entries(manifest).slice(0, SOURCE_LIMIT).map(([name, artifact]) => ({
199
+ return Object.entries(manifest).slice(0, SOURCE_LIMIT).map(([name, artifact], index) => ordered({
156
200
  id: `artifact:${name}`,
157
201
  ts: artifact.exists && existsSync(artifact.path)
158
202
  ? statSync(artifact.path).mtime.toISOString()
@@ -167,7 +211,7 @@ function artifactItems(stateDir, declarations) {
167
211
  required: artifact.required,
168
212
  ...(artifact.size !== undefined ? { size: artifact.size } : {}),
169
213
  }),
170
- }));
214
+ }, "artifact", index + 1));
171
215
  }
172
216
  export function projectRunTrace(stateDir, options = {}) {
173
217
  const source = options.source ?? "all";
@@ -179,7 +223,9 @@ export function projectRunTrace(stateDir, options = {}) {
179
223
  ...resultItems(stateDir),
180
224
  ...artifactItems(stateDir, options.artifacts),
181
225
  ]
182
- .filter((item) => source === "all" || item.source === source)
183
- .sort((left, right) => right.ts.localeCompare(left.ts) || right.id.localeCompare(left.id));
184
- return items.slice(0, Math.max(1, Math.min(options.limit ?? 100, 200)));
226
+ .filter(({ item }) => source === "all" || item.source === source)
227
+ .sort(compareTraceItems);
228
+ return items
229
+ .slice(0, Math.max(1, Math.min(options.limit ?? 100, 200)))
230
+ .map(({ item }) => item);
185
231
  }
@@ -15,6 +15,11 @@ const conformanceSuites = [
15
15
  "tests/control.test.ts",
16
16
  "tests/runs-controls.test.ts",
17
17
  "tests/runs-trace.test.ts",
18
+ "tests/observability.test.ts",
19
+ "tests/async-run-control.test.ts",
20
+ "tests/run-kernel-dogfood.test.ts",
21
+ "tests/installed.scripts.test.ts",
22
+ "tests/inspector-overlay.test.ts",
18
23
  "tests/tools-inspect-kernel.test.ts",
19
24
  "tests/recipes-discovery.test.ts",
20
25
  "tests/review-swarm-dogfood.test.ts",
@@ -11,7 +11,6 @@
11
11
  import { spawnSync } from "node:child_process";
12
12
  import { createHash } from "node:crypto";
13
13
  import {
14
- appendFileSync,
15
14
  existsSync,
16
15
  mkdirSync,
17
16
  readFileSync,
@@ -32,8 +31,12 @@ async function importRuntimeModule(name) {
32
31
  return await import(pathToFileURL(existsSync(compiled) ? compiled : source).href);
33
32
  }
34
33
 
35
- const { acquireFileMutationLock, writeJsonAtomic, writeTextAtomic } =
36
- await importRuntimeModule("file-state");
34
+ const { acquireFileMutationLock, writeJsonAtomic, writeTextAtomic } = await importRuntimeModule("file-state");
35
+ const { readJsonlFileResilient } = await importRuntimeModule("state-readers");
36
+ const LOCKER_JOURNAL_MAX_RECORDS = 512;
37
+ const LOCKER_JOURNAL_MAX_BYTES = 1024 * 1024;
38
+ const { claimRunControlByIdInStateDir, updateRunControlStatusInStateDir } =
39
+ await importRuntimeModule("runs-controls");
37
40
  const { appendRunTraceEvent } = await importRuntimeModule("runs-trace");
38
41
 
39
42
  function parseArgs(argv) {
@@ -59,7 +62,6 @@ async function runLocker(argv = process.argv.slice(2)) {
59
62
  const queuePath = join(stateDir, "queue.json");
60
63
  const locksPath = join(stateDir, "locks.json");
61
64
  const journalPath = join(stateDir, "journal.jsonl");
62
- const controlsPath = join(stateDir, "controls.jsonl");
63
65
  const controlPath = join(stateDir, "control.fifo");
64
66
  let runInstanceId;
65
67
  mkdirSync(stateDir, { recursive: true });
@@ -96,10 +98,22 @@ async function runLocker(argv = process.argv.slice(2)) {
96
98
  }
97
99
 
98
100
  function journal(event, data = {}) {
99
- appendFileSync(
100
- journalPath,
101
- `${JSON.stringify({ event, ts: new Date().toISOString(), ...data })}\n`,
102
- );
101
+ const release = acquireFileMutationLock(journalPath);
102
+ try {
103
+ const records = [...readJsonlFileResilient(journalPath).records,
104
+ { event, ts: new Date().toISOString(), ...data }]
105
+ .slice(-LOCKER_JOURNAL_MAX_RECORDS);
106
+ let content = encodeJournal(records);
107
+ while (records.length && Buffer.byteLength(content) > LOCKER_JOURNAL_MAX_BYTES) {
108
+ records.shift();
109
+ content = encodeJournal(records);
110
+ }
111
+ writeTextAtomic(journalPath, content);
112
+ } finally { release(); }
113
+ }
114
+
115
+ function encodeJournal(records) {
116
+ return records.length ? `${records.map((record) => JSON.stringify(record)).join("\n")}\n` : "";
103
117
  }
104
118
 
105
119
  function emitTrace(kind, summary, data = {}, level = "info") {
@@ -147,69 +161,6 @@ async function runLocker(argv = process.argv.slice(2)) {
147
161
  }
148
162
  }
149
163
 
150
- function acquireControlsLock() {
151
- return acquireFileMutationLock(controlsPath);
152
- }
153
-
154
- function readControls() {
155
- if (!existsSync(controlsPath)) return [];
156
- return readFileSync(controlsPath, "utf8")
157
- .split("\n")
158
- .filter((line) => line.trim())
159
- .map((line) => {
160
- try {
161
- return JSON.parse(line);
162
- } catch {
163
- return undefined;
164
- }
165
- })
166
- .filter(Boolean);
167
- }
168
-
169
- function writeControls(controls) {
170
- writeTextAtomic(
171
- controlsPath,
172
- controls.length
173
- ? `${controls.map((control) => JSON.stringify(control)).join("\n")}\n`
174
- : "",
175
- );
176
- }
177
-
178
- function claimControl(id) {
179
- const release = acquireControlsLock();
180
- try {
181
- const controls = readControls();
182
- const control = controls.find((item) => item.id === id);
183
- if (
184
- !control ||
185
- control.run_instance_id !== runInstanceId ||
186
- (control.status !== "queued" && control.status !== "delivered")
187
- ) {
188
- return undefined;
189
- }
190
- control.claimed_at = new Date().toISOString();
191
- control.status = "claimed";
192
- writeControls(controls);
193
- return { ...control };
194
- } finally {
195
- release();
196
- }
197
- }
198
-
199
- function finalizeControl(id, status, error) {
200
- const release = acquireControlsLock();
201
- try {
202
- const controls = readControls();
203
- const control = controls.find((item) => item.id === id);
204
- if (!control || control.status !== "claimed") return;
205
- control.status = status;
206
- control[`${status}_at`] = new Date().toISOString();
207
- if (error) control.error = error;
208
- writeControls(controls);
209
- } finally {
210
- release();
211
- }
212
- }
213
164
 
214
165
  function tailJournal(count) {
215
166
  if (!existsSync(journalPath)) return [];
@@ -397,7 +348,9 @@ async function runLocker(argv = process.argv.slice(2)) {
397
348
  function handleLine(line) {
398
349
  const control = normalizeControl(line);
399
350
  if (!control) return false;
400
- const claimed = claimControl(control.id);
351
+ const claimed = claimRunControlByIdInStateDir(
352
+ stateDir, runInstanceId, control.id,
353
+ );
401
354
  if (!claimed) {
402
355
  emitTrace(
403
356
  "lock.control_rejected",
@@ -409,11 +362,15 @@ async function runLocker(argv = process.argv.slice(2)) {
409
362
  }
410
363
  try {
411
364
  const stopping = handle(claimed) === true;
412
- finalizeControl(claimed.id, "handled");
365
+ updateRunControlStatusInStateDir(
366
+ stateDir, claimed.id, "handled", {}, ["claimed"],
367
+ );
413
368
  return stopping;
414
369
  } catch (error) {
415
370
  const text = error instanceof Error ? error.message : String(error);
416
- finalizeControl(claimed.id, "failed", text);
371
+ updateRunControlStatusInStateDir(
372
+ stateDir, claimed.id, "failed", { error: text }, ["claimed"],
373
+ );
417
374
  journal("lock.error", { error: text });
418
375
  emitTrace("lock.error", text, { error: text }, "error");
419
376
  return false;