pi-crew 0.6.1 → 0.6.4

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 (119) hide show
  1. package/CHANGELOG.md +194 -0
  2. package/README.md +81 -33
  3. package/docs/issue-29-analysis.md +189 -0
  4. package/docs/superpowers/plans/2026-06-09-fallow-patterns-adoption.md +1268 -0
  5. package/docs/ui-optimization-plan.md +447 -0
  6. package/package.json +2 -2
  7. package/src/config/config.ts +106 -15
  8. package/src/errors.ts +107 -0
  9. package/src/extension/async-notifier.ts +6 -2
  10. package/src/extension/crew-cleanup.ts +8 -5
  11. package/src/extension/management.ts +464 -109
  12. package/src/extension/register.ts +213 -35
  13. package/src/extension/registration/brief-tool-overrides.ts +400 -0
  14. package/src/extension/registration/commands.ts +27 -2
  15. package/src/extension/registration/subagent-helpers.ts +2 -2
  16. package/src/extension/registration/subagent-tools.ts +9 -4
  17. package/src/extension/registration/team-tool.ts +17 -10
  18. package/src/extension/registration/viewers.ts +2 -2
  19. package/src/extension/team-tool/api.ts +3 -3
  20. package/src/extension/team-tool/cancel.ts +3 -3
  21. package/src/extension/team-tool/explain.ts +1 -1
  22. package/src/extension/team-tool/handle-schedule.ts +40 -0
  23. package/src/extension/team-tool/inspect.ts +3 -3
  24. package/src/extension/team-tool/lifecycle-actions.ts +4 -4
  25. package/src/extension/team-tool/respond.ts +2 -2
  26. package/src/extension/team-tool/run.ts +64 -14
  27. package/src/extension/team-tool/status.ts +1 -1
  28. package/src/extension/team-tool-types.ts +2 -0
  29. package/src/extension/team-tool.ts +173 -46
  30. package/src/hooks/registry.ts +77 -13
  31. package/src/hooks/types.ts +9 -0
  32. package/src/plugins/plugin-define.ts +6 -0
  33. package/src/plugins/plugin-registry.ts +32 -0
  34. package/src/plugins/plugins/index.ts +3 -0
  35. package/src/plugins/plugins/nextjs.ts +19 -0
  36. package/src/plugins/plugins/vite.ts +14 -0
  37. package/src/plugins/plugins/vitest.ts +14 -0
  38. package/src/runtime/async-runner.ts +35 -7
  39. package/src/runtime/background-runner.ts +529 -144
  40. package/src/runtime/chain-parser.ts +5 -1
  41. package/src/runtime/chain-runner.ts +9 -3
  42. package/src/runtime/checkpoint.ts +262 -180
  43. package/src/runtime/child-pi.ts +164 -37
  44. package/src/runtime/crash-recovery.ts +25 -14
  45. package/src/runtime/crew-agent-records.ts +2 -0
  46. package/src/runtime/diagnostic-export.ts +1 -1
  47. package/src/runtime/dynamic-script-runner.ts +10 -7
  48. package/src/runtime/foreground-watchdog.ts +1 -1
  49. package/src/runtime/heartbeat-watcher.ts +19 -2
  50. package/src/runtime/intercom-bridge.ts +7 -0
  51. package/src/runtime/iteration-hooks.ts +1 -1
  52. package/src/runtime/manifest-cache.ts +4 -0
  53. package/src/runtime/orphan-worker-registry.ts +444 -0
  54. package/src/runtime/parent-guard.ts +70 -16
  55. package/src/runtime/pi-args.ts +437 -14
  56. package/src/runtime/pi-spawn.ts +1 -0
  57. package/src/runtime/post-checks.ts +1 -1
  58. package/src/runtime/retry-runner.ts +9 -3
  59. package/src/runtime/run-tracker.ts +38 -10
  60. package/src/runtime/sandbox.ts +17 -2
  61. package/src/runtime/scheduler.ts +25 -2
  62. package/src/runtime/skill-effectiveness.ts +105 -62
  63. package/src/runtime/skill-instructions.ts +110 -32
  64. package/src/runtime/stale-reconciler.ts +310 -69
  65. package/src/runtime/subagent-manager.ts +251 -57
  66. package/src/runtime/task-health.ts +76 -0
  67. package/src/runtime/task-id.ts +20 -0
  68. package/src/runtime/task-runner/live-executor.ts +1 -1
  69. package/src/runtime/task-runner/progress.ts +1 -1
  70. package/src/runtime/task-runner/state-helpers.ts +110 -6
  71. package/src/runtime/task-runner.ts +92 -70
  72. package/src/runtime/team-runner.ts +186 -20
  73. package/src/schema/team-tool-schema.ts +27 -9
  74. package/src/skills/discover-skills.ts +9 -3
  75. package/src/state/active-run-registry.ts +170 -38
  76. package/src/state/artifact-store.ts +25 -13
  77. package/src/state/atomic-write-v2.ts +86 -0
  78. package/src/state/atomic-write.ts +346 -55
  79. package/src/state/blob-store.ts +178 -10
  80. package/src/state/crew-init.ts +161 -28
  81. package/src/state/decision-ledger.ts +172 -111
  82. package/src/state/event-log-rotation.ts +82 -52
  83. package/src/state/event-log.ts +254 -70
  84. package/src/state/health-store.ts +71 -0
  85. package/src/state/locks.ts +102 -20
  86. package/src/state/mailbox.ts +45 -7
  87. package/src/state/observation-store.ts +4 -1
  88. package/src/state/run-graph.ts +141 -130
  89. package/src/state/run-metrics.ts +24 -8
  90. package/src/state/session-state-map.ts +51 -0
  91. package/src/state/state-store.ts +330 -43
  92. package/src/ui/live-run-sidebar.ts +1 -1
  93. package/src/ui/loaders.ts +4 -0
  94. package/src/ui/overlays/agent-picker-overlay.ts +1 -1
  95. package/src/ui/overlays/mailbox-detail-overlay.ts +1 -1
  96. package/src/ui/powerbar-publisher.ts +1 -1
  97. package/src/ui/run-action-dispatcher.ts +2 -2
  98. package/src/ui/run-snapshot-cache.ts +1 -1
  99. package/src/ui/status-colors.ts +5 -1
  100. package/src/ui/theme-adapter.ts +80 -1
  101. package/src/ui/tool-progress-formatter.ts +9 -5
  102. package/src/ui/tool-render.ts +4 -0
  103. package/src/ui/tool-renderers/brief-mode.ts +207 -0
  104. package/src/ui/tool-renderers/index.ts +627 -0
  105. package/src/ui/widget/index.ts +224 -0
  106. package/src/ui/widget/widget-formatters.ts +148 -0
  107. package/src/ui/widget/widget-model.ts +90 -0
  108. package/src/ui/widget/widget-renderer.ts +130 -0
  109. package/src/ui/widget/widget-types.ts +37 -0
  110. package/src/utils/env-filter.ts +66 -0
  111. package/src/utils/file-coalescer.ts +9 -1
  112. package/src/utils/guards.ts +110 -0
  113. package/src/utils/paths.ts +80 -5
  114. package/src/utils/redaction.ts +6 -1
  115. package/src/utils/safe-paths.ts +281 -10
  116. package/src/worktree/cleanup.ts +112 -24
  117. package/src/worktree/worktree-manager.ts +128 -15
  118. package/test-bugs-all.mjs +10 -6
  119. package/test-integration-check.ts +4 -0
@@ -1,6 +1,9 @@
1
- import { readFileSync, writeFileSync, existsSync, mkdirSync } from "fs";
2
- import { dirname } from "path";
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
2
+ import { dirname, join } from "path";
3
+ import { projectCrewRoot } from "../utils/paths.ts";
4
+ import { assertSafePathId } from "../utils/safe-paths.ts";
3
5
  import { atomicWriteFile } from "./atomic-write.ts";
6
+ import { withFileLockSync } from "./locks.ts";
4
7
 
5
8
  export interface CoherenceMark {
6
9
  matchesPrior: boolean;
@@ -24,16 +27,27 @@ export interface RolloutEntry {
24
27
  * Get the ledger file path for a given run ID.
25
28
  * SECURITY: Accept stateRoot param to use it for path computation
26
29
  * instead of hardcoded path, ensuring stateRoot containment.
30
+ * Uses projectCrewRoot() to honour the `.pi/teams/` fallback for `.pi`-based
31
+ * projects (see issue #29).
27
32
  */
28
- function getLedgerPath(runId: string, stateRoot?: string): string {
29
- const base = stateRoot ?? `.crew/state/runs/${runId}`;
33
+ function getLedgerPath(
34
+ runId: string,
35
+ stateRoot?: string,
36
+ cwd?: string,
37
+ ): string {
38
+ const base =
39
+ stateRoot ??
40
+ join(projectCrewRoot(cwd ?? process.cwd()), "state", "runs", runId);
30
41
  return `${base}/decision-ledger.jsonl`;
31
42
  }
32
43
 
33
44
  /**
34
45
  * Compute coherence marks based on existing ledger entries.
35
46
  */
36
- function computeCoherence(entry: RolloutEntry, ledger: RolloutEntry[]): CoherenceMark {
47
+ function computeCoherence(
48
+ entry: RolloutEntry,
49
+ ledger: RolloutEntry[],
50
+ ): CoherenceMark {
37
51
  if (ledger.length === 0) {
38
52
  return {
39
53
  matchesPrior: false,
@@ -46,15 +60,21 @@ function computeCoherence(entry: RolloutEntry, ledger: RolloutEntry[]): Coherenc
46
60
  const previousEntry = ledger[ledger.length - 1];
47
61
  const matchesPrior: boolean =
48
62
  entry.decisionMark === previousEntry.decisionMark ||
49
- Boolean(entry.priorWinner && entry.topCandidates.includes(entry.priorWinner));
63
+ Boolean(
64
+ entry.priorWinner &&
65
+ entry.topCandidates.includes(entry.priorWinner),
66
+ );
50
67
 
51
68
  // Check last 10 entries for recursive pattern
52
69
  const recentEntries = ledger.slice(-10);
53
70
  const recentDecisions = recentEntries.map((e) => e.decisionMark);
54
71
  const currentDecision = entry.decisionMark;
55
72
 
56
- const recursiveMatches = recentDecisions.filter((d) => d === currentDecision).length;
57
- const matchesRecursive = recursiveMatches >= Math.ceil(recentDecisions.length / 2); // At least half match
73
+ const recursiveMatches = recentDecisions.filter(
74
+ (d) => d === currentDecision,
75
+ ).length;
76
+ const matchesRecursive =
77
+ recursiveMatches >= Math.ceil(recentDecisions.length / 2); // At least half match
58
78
 
59
79
  const promotionAllowed = matchesPrior || matchesRecursive;
60
80
 
@@ -82,6 +102,7 @@ function computeCoherence(entry: RolloutEntry, ledger: RolloutEntry[]): Coherenc
82
102
  * Creates the directory and ledger file if they don't exist.
83
103
  */
84
104
  export function initLedger(runId: string): void {
105
+ assertSafePathId("runId", runId);
85
106
  const ledgerPath = getLedgerPath(runId);
86
107
  const dir = dirname(ledgerPath);
87
108
 
@@ -99,8 +120,11 @@ export function initLedger(runId: string): void {
99
120
  * Append a new entry to the decision ledger.
100
121
  * Automatically computes and adds coherence marks.
101
122
  * FIX: Uses atomic write to prevent partial writes on crash.
123
+ * FIX: Uses withFileLockSync to prevent concurrent appendEntry calls from
124
+ * losing entries (classic read-check-write race).
102
125
  */
103
126
  export function appendEntry(runId: string, entry: RolloutEntry): RolloutEntry {
127
+ assertSafePathId("runId", runId);
104
128
  // Ensure directory exists
105
129
  const ledgerPath = getLedgerPath(runId);
106
130
  const dir = dirname(ledgerPath);
@@ -108,28 +132,35 @@ export function appendEntry(runId: string, entry: RolloutEntry): RolloutEntry {
108
132
  mkdirSync(dir, { recursive: true });
109
133
  }
110
134
 
111
- // Get existing entries to compute coherence (and use same result for write)
112
- const ledger = getLedger(runId);
113
-
114
- // Compute coherence
115
- const coherenceMark = computeCoherence(entry, ledger);
116
- const entryWithCoherence: RolloutEntry = {
117
- ...entry,
118
- coherenceMark,
119
- };
120
-
121
- // Append to JSONL file using atomic write to prevent corruption
122
- // Use the already-loaded ledger content (no double-read)
123
- const line = JSON.stringify(entryWithCoherence) + "\n";
124
- const existingContent = ledger.length > 0 ? ledger.map((e) => JSON.stringify(e)).join("\n") + "\n" : "";
125
- atomicWriteFile(ledgerPath, existingContent + line);
126
- return entryWithCoherence;
135
+ // FIX: Wrap read+write in file lock to prevent concurrent writers from
136
+ // reading stale state and overwriting each other's entries.
137
+ let entryWithCoherence: RolloutEntry;
138
+ withFileLockSync(ledgerPath, () => {
139
+ // Get existing entries to compute coherence (and use same result for write)
140
+ const ledger = getLedger(runId);
141
+
142
+ // Compute coherence
143
+ const coherenceMark = computeCoherence(entry, ledger);
144
+ entryWithCoherence = { ...entry, coherenceMark };
145
+
146
+ // Append to JSONL file using atomic write to prevent corruption
147
+ // Use the already-loaded ledger content (no double-read)
148
+ const line = JSON.stringify(entryWithCoherence) + "\n";
149
+ const existingContent =
150
+ ledger.length > 0
151
+ ? ledger.map((e) => JSON.stringify(e)).join("\n") + "\n"
152
+ : "";
153
+ atomicWriteFile(ledgerPath, existingContent + line);
154
+ });
155
+
156
+ return entryWithCoherence!;
127
157
  }
128
158
 
129
159
  /**
130
160
  * Read all entries from the decision ledger.
131
161
  */
132
162
  export function getLedger(runId: string): RolloutEntry[] {
163
+ assertSafePathId("runId", runId);
133
164
  const ledgerPath = getLedgerPath(runId);
134
165
 
135
166
  if (!existsSync(ledgerPath)) {
@@ -151,6 +182,7 @@ export function getLedger(runId: string): RolloutEntry[] {
151
182
  * Get the most recent entry from the decision ledger.
152
183
  */
153
184
  export function getLatestDecision(runId: string): RolloutEntry | null {
185
+ assertSafePathId("runId", runId);
154
186
  const ledger = getLedger(runId);
155
187
  if (ledger.length === 0) {
156
188
  return null;
@@ -162,6 +194,7 @@ export function getLatestDecision(runId: string): RolloutEntry | null {
162
194
  * Generate a human-readable markdown summary of the ledger.
163
195
  */
164
196
  export function summarizeLedger(runId: string): string {
197
+ assertSafePathId("runId", runId);
165
198
  const ledger = getLedger(runId);
166
199
 
167
200
  if (ledger.length === 0) {
@@ -191,12 +224,20 @@ export function summarizeLedger(runId: string): string {
191
224
  lines.push(`- **Prior Winner**: ${entry.priorWinner}`);
192
225
  }
193
226
 
194
- lines.push(`- **Top Candidates**: ${entry.topCandidates.join(", ") || "(none)"}`);
227
+ lines.push(
228
+ `- **Top Candidates**: ${entry.topCandidates.join(", ") || "(none)"}`,
229
+ );
195
230
  lines.push("");
196
231
  lines.push("#### Coherence");
197
- lines.push(`- **Matches Prior**: ${entry.coherenceMark.matchesPrior ? "✓" : "✗"}`);
198
- lines.push(`- **Matches Recursive**: ${entry.coherenceMark.matchesRecursive ? "✓" : "✗"}`);
199
- lines.push(`- **Promotion Allowed**: ${entry.coherenceMark.promotionAllowed ? "✓" : "✗"}`);
232
+ lines.push(
233
+ `- **Matches Prior**: ${entry.coherenceMark.matchesPrior ? "✓" : "✗"}`,
234
+ );
235
+ lines.push(
236
+ `- **Matches Recursive**: ${entry.coherenceMark.matchesRecursive ? "✓" : "✗"}`,
237
+ );
238
+ lines.push(
239
+ `- **Promotion Allowed**: ${entry.coherenceMark.promotionAllowed ? "✓" : "✗"}`,
240
+ );
200
241
  lines.push(`- **Reason**: ${entry.coherenceMark.reason}`);
201
242
  lines.push("");
202
243
  }
@@ -218,8 +259,12 @@ export function summarizeLedger(runId: string): string {
218
259
  lines.push(`| Decay | ${decayCount} |`);
219
260
  lines.push("");
220
261
 
221
- const promotedCount = ledger.filter((e) => e.coherenceMark.promotionAllowed).length;
222
- lines.push(`**Promotion Rate**: ${promotedCount}/${ledger.length} (${((promotedCount / ledger.length) * 100).toFixed(1)}%)`);
262
+ const promotedCount = ledger.filter(
263
+ (e) => e.coherenceMark.promotionAllowed,
264
+ ).length;
265
+ lines.push(
266
+ `**Promotion Rate**: ${promotedCount}/${ledger.length} (${((promotedCount / ledger.length) * 100).toFixed(1)}%)`,
267
+ );
223
268
 
224
269
  return lines.join("\n");
225
270
  }
@@ -229,110 +274,126 @@ export function summarizeLedger(runId: string): string {
229
274
  * FIX: This preserves all previous entries while updating just the last one.
230
275
  * Previously this would truncate the entire ledger!
231
276
  */
232
- function overrideLastEntry(runId: string, coherenceMark: import("./types.js").CoherenceMark): RolloutEntry {
233
- const ledger = getLedger(runId);
234
- if (ledger.length === 0) {
235
- throw new Error(`No ledger entries found for run ${runId}`);
236
- }
237
- // Update the last entry with the new coherence mark
238
- const lastIndex = ledger.length - 1;
239
- ledger[lastIndex] = { ...ledger[lastIndex], coherenceMark };
240
- // Rewrite entire ledger to preserve all entries
241
- const ledgerPath = getLedgerPath(runId);
242
- atomicWriteFile(ledgerPath, ledger.map((e) => JSON.stringify(e)).join("\n") + "\n");
243
- return ledger[lastIndex];
244
- }
277
+ // NOTE: overrideLastEntry was dead code (never called). Removed in post-v0.6.2 review.
278
+ // If needed in the future, re-implement with assertSafePathId guard.
245
279
 
246
280
  /**
247
281
  * Promote a candidate by marking it as accepted with proper coherence.
282
+ * FIX: Wrap read+write in file lock to prevent concurrent promotion/decay
283
+ * calls from losing entries.
248
284
  */
249
- export function promoteCandidate(runId: string, candidate: string): RolloutEntry {
250
- const latestDecision = getLatestDecision(runId);
285
+ export function promoteCandidate(
286
+ runId: string,
287
+ candidate: string,
288
+ ): RolloutEntry {
289
+ assertSafePathId("runId", runId);
290
+ assertSafePathId("candidate", candidate);
251
291
 
252
- // Get existing entries to compute proper coherence
253
- const ledger = getLedger(runId);
292
+ const ledgerPath = getLedgerPath(runId);
293
+ const dir = dirname(ledgerPath);
294
+ if (!existsSync(dir)) {
295
+ mkdirSync(dir, { recursive: true });
296
+ }
254
297
 
255
- // Create entry without coherence first
256
- const entryWithoutCoherence = {
257
- rolloutId: `promote-${Date.now()}`,
258
- timestamp: new Date().toISOString(),
259
- priorWinner: latestDecision?.topCandidates[0],
260
- searchSpace: latestDecision?.searchSpace || "unknown",
261
- trialCount: (latestDecision?.trialCount || 0) + 1,
262
- topCandidates: [candidate],
263
- decisionMark: "accept" as const,
264
- };
298
+ let entry: RolloutEntry;
299
+ withFileLockSync(ledgerPath, () => {
300
+ const latestDecision = getLatestDecision(runId);
301
+
302
+ // Get existing entries to compute proper coherence
303
+ const ledger = getLedger(runId);
304
+
305
+ // Create entry without coherence first
306
+ const entryWithoutCoherence = {
307
+ rolloutId: `promote-${Date.now()}`,
308
+ timestamp: new Date().toISOString(),
309
+ priorWinner: latestDecision?.topCandidates[0],
310
+ searchSpace: latestDecision?.searchSpace || "unknown",
311
+ trialCount: (latestDecision?.trialCount || 0) + 1,
312
+ topCandidates: [candidate],
313
+ decisionMark: "accept" as const,
314
+ };
265
315
 
266
- // Compute coherence (empty ledger = no matches)
267
- const coherenceMark = computeCoherence(entryWithoutCoherence as RolloutEntry, ledger);
316
+ // Compute coherence (empty ledger = no matches)
317
+ const coherenceMark = computeCoherence(
318
+ entryWithoutCoherence as RolloutEntry,
319
+ ledger,
320
+ );
268
321
 
269
- // Manual promotion always allows further promotion
270
- coherenceMark.promotionAllowed = true;
271
- coherenceMark.reason = "Manual promotion - promotion allowed";
322
+ // Manual promotion always allows further promotion
323
+ coherenceMark.promotionAllowed = true;
324
+ coherenceMark.reason = "Manual promotion - promotion allowed";
272
325
 
273
- // Create full entry with coherence
274
- const entry: RolloutEntry = {
275
- ...entryWithoutCoherence,
276
- coherenceMark,
277
- };
326
+ // Create full entry with coherence
327
+ entry = { ...entryWithoutCoherence, coherenceMark };
278
328
 
279
- // Always push new entry (append-only pattern)
280
- ledger.push(entry);
329
+ // Always push new entry (append-only pattern)
330
+ ledger.push(entry);
281
331
 
282
- // Rewrite entire ledger atomically to preserve all entries
283
- const ledgerPath = getLedgerPath(runId);
284
- const dir = dirname(ledgerPath);
285
- if (!existsSync(dir)) {
286
- mkdirSync(dir, { recursive: true });
287
- }
288
- atomicWriteFile(ledgerPath, ledger.map((e) => JSON.stringify(e)).join("\n") + "\n");
332
+ // Rewrite entire ledger atomically to preserve all entries
333
+ atomicWriteFile(
334
+ ledgerPath,
335
+ ledger.map((e) => JSON.stringify(e)).join("\n") + "\n",
336
+ );
337
+ });
289
338
 
290
- return entry;
339
+ return entry!;
291
340
  }
292
341
 
293
342
  /**
294
343
  * Decay a candidate by marking it as accepted with proper coherence.
344
+ * FIX: Wrap read+write in file lock to prevent concurrent promotion/decay
345
+ * calls from losing entries.
295
346
  */
296
347
  export function decayCandidate(runId: string, candidate: string): RolloutEntry {
297
- const latestDecision = getLatestDecision(runId);
348
+ assertSafePathId("runId", runId);
349
+ assertSafePathId("candidate", candidate);
298
350
 
299
- // Get existing entries to compute proper coherence
300
- const ledger = getLedger(runId);
351
+ const ledgerPath = getLedgerPath(runId);
352
+ const dir = dirname(ledgerPath);
353
+ if (!existsSync(dir)) {
354
+ mkdirSync(dir, { recursive: true });
355
+ }
301
356
 
302
- // Create entry without coherence first
303
- const entryWithoutCoherence = {
304
- rolloutId: `decay-${Date.now()}`,
305
- timestamp: new Date().toISOString(),
306
- priorWinner: latestDecision?.topCandidates[0],
307
- searchSpace: latestDecision?.searchSpace || "unknown",
308
- trialCount: (latestDecision?.trialCount || 0) + 1,
309
- topCandidates: [candidate],
310
- decisionMark: "decay" as const,
311
- };
357
+ let entry: RolloutEntry;
358
+ withFileLockSync(ledgerPath, () => {
359
+ const latestDecision = getLatestDecision(runId);
360
+
361
+ // Get existing entries to compute proper coherence
362
+ const ledger = getLedger(runId);
363
+
364
+ // Create entry without coherence first
365
+ const entryWithoutCoherence = {
366
+ rolloutId: `decay-${Date.now()}`,
367
+ timestamp: new Date().toISOString(),
368
+ priorWinner: latestDecision?.topCandidates[0],
369
+ searchSpace: latestDecision?.searchSpace || "unknown",
370
+ trialCount: (latestDecision?.trialCount || 0) + 1,
371
+ topCandidates: [candidate],
372
+ decisionMark: "decay" as const,
373
+ };
312
374
 
313
- // Compute coherence (empty ledger = no matches)
314
- const coherenceMark = computeCoherence(entryWithoutCoherence as RolloutEntry, ledger);
375
+ // Compute coherence (empty ledger = no matches)
376
+ const coherenceMark = computeCoherence(
377
+ entryWithoutCoherence as RolloutEntry,
378
+ ledger,
379
+ );
315
380
 
316
- // Manual decay never allows promotion
317
- coherenceMark.promotionAllowed = false;
318
- coherenceMark.reason = "Manual decay - promotion not allowed";
381
+ // Manual decay never allows promotion
382
+ coherenceMark.promotionAllowed = false;
383
+ coherenceMark.reason = "Manual decay - promotion not allowed";
319
384
 
320
- // Create full entry with coherence
321
- const entry: RolloutEntry = {
322
- ...entryWithoutCoherence,
323
- coherenceMark,
324
- };
385
+ // Create full entry with coherence
386
+ entry = { ...entryWithoutCoherence, coherenceMark };
325
387
 
326
- // Always push new entry (append-only pattern)
327
- ledger.push(entry);
388
+ // Always push new entry (append-only pattern)
389
+ ledger.push(entry);
328
390
 
329
- // Rewrite entire ledger to preserve all entries
330
- const ledgerPath = getLedgerPath(runId);
331
- const dir = dirname(ledgerPath);
332
- if (!existsSync(dir)) {
333
- mkdirSync(dir, { recursive: true });
334
- }
335
- atomicWriteFile(ledgerPath, ledger.map((e) => JSON.stringify(e)).join("\n") + "\n");
391
+ // Rewrite entire ledger to preserve all entries
392
+ atomicWriteFile(
393
+ ledgerPath,
394
+ ledger.map((e) => JSON.stringify(e)).join("\n") + "\n",
395
+ );
396
+ });
336
397
 
337
- return entry;
338
- }
398
+ return entry!;
399
+ }
@@ -2,6 +2,7 @@ import * as fs from "node:fs";
2
2
  import { readEvents } from "./event-log.ts";
3
3
  import { atomicWriteFile } from "./atomic-write.ts";
4
4
  import { logInternalError } from "../utils/internal-error.ts";
5
+ import { withEventLogLockSync } from "./event-log.ts";
5
6
 
6
7
  export interface RotationConfig {
7
8
  maxFileSizeBytes: number;
@@ -47,6 +48,7 @@ export interface CompactionResult {
47
48
  compactedSize: number;
48
49
  eventsRemoved: number;
49
50
  eventsKept: number;
51
+ recoveryFailed?: boolean;
50
52
  }
51
53
 
52
54
  /**
@@ -72,54 +74,74 @@ export function compactEventLog(eventsPath: string, config?: Partial<RotationCon
72
74
  if (originalCount <= cfg.compactToCount) return undefined;
73
75
  const kept = allEvents.slice(-cfg.compactToCount);
74
76
  const lines = kept.map((e) => JSON.stringify(e)).join("\n") + "\n";
75
- try {
76
- atomicWriteFile(eventsPath, lines);
77
- } catch {
78
- // Concurrent write conflict — skip compaction this cycle
79
- return undefined;
80
- }
81
- // C2: Re-read to recover any events appended during the compaction window.
82
- // If events were appended and then overwritten by atomicWriteFile, they are LOST.
83
- // Detect this and re-append any missing events.
84
- try {
85
- const afterWrite = readEvents(eventsPath);
86
- const appendedDuringWindow = afterWrite.length - kept.length;
87
- if (appendedDuringWindow >= 0) {
88
- // No data loss either events were appended and kept, or nothing happened.
77
+
78
+ // FIX: Wrap entire read-compact-write-recover sequence in lock to prevent
79
+ // event loss during compaction. Without lock, events can be appended between
80
+ // read and write, lost silently.
81
+ return withEventLogLockSync(eventsPath, () => {
82
+ try {
83
+ atomicWriteFile(eventsPath, lines);
84
+ } catch (err) {
85
+ // Concurrent write conflict skip compaction this cycle
86
+ logInternalError("event-log-rotation.compact", err, `eventsPath=${eventsPath}`);
87
+ return undefined;
88
+ }
89
+ // C2: Re-read to recover any events appended during the compaction window.
90
+ // Events appended during the compaction window are preserved because they
91
+ // appear in afterWrite and the condition afterWrite.length >= kept.length is
92
+ // true, so they are included in the return stats without entering the
93
+ // recovery branch.
94
+ try {
95
+ const afterWrite = readEvents(eventsPath);
96
+ // FIX: Check if events were actually lost (afterWrite.length < kept.length)
97
+ // rather than using appendedDuringWindow >= 0 which is always true.
98
+ // Also use sequence numbers for comparison instead of JSON.stringify
99
+ // which is fragile due to key ordering and floating point differences.
100
+ if (afterWrite.length >= kept.length) {
101
+ // No data loss — either events were appended and kept, or nothing happened.
102
+ return {
103
+ originalSize,
104
+ compactedSize: fs.statSync(eventsPath).size,
105
+ eventsRemoved: originalCount - kept.length,
106
+ eventsKept: kept.length + Math.max(0, afterWrite.length - kept.length),
107
+ };
108
+ }
109
+ // afterWrite.length < kept.length — events were lost during compaction window.
110
+ // Find missing events and re-append them.
111
+ // FIX: Use sequence numbers for comparison instead of JSON.stringify.
112
+ const afterSeqs = new Set(afterWrite.map((e) => e.metadata?.seq).filter((s): s is number => s !== undefined));
113
+ const missingEvents = kept.filter((e) => e.metadata?.seq === undefined || !afterSeqs.has(e.metadata.seq));
114
+ let recoveredCount = 0;
115
+ let recoveryFailed = false;
116
+ for (const event of missingEvents) {
117
+ try {
118
+ // Use atomicWriteFile for recovery append too — safer than plain appendFileSync
119
+ atomicWriteFile(eventsPath, JSON.stringify(event) + "\n");
120
+ recoveredCount++;
121
+ } catch (err) {
122
+ recoveryFailed = true;
123
+ // FIX: Log when recovery append fails to avoid silent event loss
124
+ logInternalError("event-log-rotation.recovery", err, `eventsPath=${eventsPath} lostEvent=${JSON.stringify(event).slice(0, 100)}`);
125
+ }
126
+ }
89
127
  return {
90
128
  originalSize,
91
129
  compactedSize: fs.statSync(eventsPath).size,
92
130
  eventsRemoved: originalCount - kept.length,
93
- eventsKept: kept.length + Math.max(0, appendedDuringWindow),
131
+ eventsKept: kept.length + recoveredCount,
132
+ recoveryFailed,
133
+ };
134
+ } catch {
135
+ // Post-write verification failed — compaction likely succeeded.
136
+ const compactedSize = fs.statSync(eventsPath).size;
137
+ return {
138
+ originalSize,
139
+ compactedSize,
140
+ eventsRemoved: originalCount - kept.length,
141
+ eventsKept: kept.length,
94
142
  };
95
143
  }
96
- // afterWrite.length < kept.length — events were lost during compaction window.
97
- // Find missing events and re-append them.
98
- const afterSet = new Set(afterWrite.map((e) => JSON.stringify(e)));
99
- const missingEvents = kept.filter((e) => !afterSet.has(JSON.stringify(e)));
100
- for (const event of missingEvents) {
101
- try {
102
- fs.appendFileSync(eventsPath, JSON.stringify(event) + "\n", "utf-8");
103
- } catch {
104
- // Append failed — log but don't throw.
105
- }
106
- }
107
- return {
108
- originalSize,
109
- compactedSize: fs.statSync(eventsPath).size,
110
- eventsRemoved: originalCount - kept.length,
111
- eventsKept: kept.length,
112
- };
113
- } catch {
114
- // Post-write verification failed — compaction likely succeeded.
115
- const compactedSize = fs.statSync(eventsPath).size;
116
- return {
117
- originalSize,
118
- compactedSize,
119
- eventsRemoved: originalCount - kept.length,
120
- eventsKept: kept.length,
121
- };
122
- }
144
+ });
123
145
  }
124
146
 
125
147
  /**
@@ -131,16 +153,24 @@ export function compactEventLog(eventsPath: string, config?: Partial<RotationCon
131
153
  */
132
154
  export function rotateEventLog(eventsPath: string): boolean {
133
155
  if (!fs.existsSync(eventsPath)) return false;
134
- try {
135
- const ts = new Date().toISOString().replace(/[:.]/g, "-");
136
- const archivePath = `${eventsPath}.${ts}.archive.jsonl`;
137
- fs.renameSync(eventsPath, archivePath);
138
- fs.writeFileSync(eventsPath, "", "utf-8");
139
- return true;
140
- } catch (error) {
141
- logInternalError("event-log.rotate", error, `eventsPath=${eventsPath}`);
142
- return false;
143
- }
156
+ // FIX: Wrap rotation in lock to prevent race conditions with concurrent readers.
157
+ // Order of operations: (1) create new empty file, (2) rename old file to archive.
158
+ // This ensures eventsPath always exists — a reader never sees a missing file.
159
+ return withEventLogLockSync(eventsPath, () => {
160
+ try {
161
+ const ts = new Date().toISOString().replace(/[:.]/g, "-");
162
+ const archivePath = `${eventsPath}.${ts}.archive.jsonl`;
163
+ // Step 1: create new empty file at eventsPath FIRST
164
+ // This ensures eventsPath always exists for readers
165
+ atomicWriteFile(eventsPath, "");
166
+ // Step 2: rename old content to archive (after new file is in place)
167
+ fs.renameSync(eventsPath, archivePath);
168
+ return true;
169
+ } catch (error) {
170
+ logInternalError("event-log.rotate", error, `eventsPath=${eventsPath}`);
171
+ return false;
172
+ }
173
+ });
144
174
  }
145
175
 
146
176
  export interface EventLogStats {