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.
- package/CHANGELOG.md +194 -0
- package/README.md +81 -33
- package/docs/issue-29-analysis.md +189 -0
- package/docs/superpowers/plans/2026-06-09-fallow-patterns-adoption.md +1268 -0
- package/docs/ui-optimization-plan.md +447 -0
- package/package.json +2 -2
- package/src/config/config.ts +106 -15
- package/src/errors.ts +107 -0
- package/src/extension/async-notifier.ts +6 -2
- package/src/extension/crew-cleanup.ts +8 -5
- package/src/extension/management.ts +464 -109
- package/src/extension/register.ts +213 -35
- package/src/extension/registration/brief-tool-overrides.ts +400 -0
- package/src/extension/registration/commands.ts +27 -2
- package/src/extension/registration/subagent-helpers.ts +2 -2
- package/src/extension/registration/subagent-tools.ts +9 -4
- package/src/extension/registration/team-tool.ts +17 -10
- package/src/extension/registration/viewers.ts +2 -2
- package/src/extension/team-tool/api.ts +3 -3
- package/src/extension/team-tool/cancel.ts +3 -3
- package/src/extension/team-tool/explain.ts +1 -1
- package/src/extension/team-tool/handle-schedule.ts +40 -0
- package/src/extension/team-tool/inspect.ts +3 -3
- package/src/extension/team-tool/lifecycle-actions.ts +4 -4
- package/src/extension/team-tool/respond.ts +2 -2
- package/src/extension/team-tool/run.ts +64 -14
- package/src/extension/team-tool/status.ts +1 -1
- package/src/extension/team-tool-types.ts +2 -0
- package/src/extension/team-tool.ts +173 -46
- package/src/hooks/registry.ts +77 -13
- package/src/hooks/types.ts +9 -0
- package/src/plugins/plugin-define.ts +6 -0
- package/src/plugins/plugin-registry.ts +32 -0
- package/src/plugins/plugins/index.ts +3 -0
- package/src/plugins/plugins/nextjs.ts +19 -0
- package/src/plugins/plugins/vite.ts +14 -0
- package/src/plugins/plugins/vitest.ts +14 -0
- package/src/runtime/async-runner.ts +35 -7
- package/src/runtime/background-runner.ts +529 -144
- package/src/runtime/chain-parser.ts +5 -1
- package/src/runtime/chain-runner.ts +9 -3
- package/src/runtime/checkpoint.ts +262 -180
- package/src/runtime/child-pi.ts +164 -37
- package/src/runtime/crash-recovery.ts +25 -14
- package/src/runtime/crew-agent-records.ts +2 -0
- package/src/runtime/diagnostic-export.ts +1 -1
- package/src/runtime/dynamic-script-runner.ts +10 -7
- package/src/runtime/foreground-watchdog.ts +1 -1
- package/src/runtime/heartbeat-watcher.ts +19 -2
- package/src/runtime/intercom-bridge.ts +7 -0
- package/src/runtime/iteration-hooks.ts +1 -1
- package/src/runtime/manifest-cache.ts +4 -0
- package/src/runtime/orphan-worker-registry.ts +444 -0
- package/src/runtime/parent-guard.ts +70 -16
- package/src/runtime/pi-args.ts +437 -14
- package/src/runtime/pi-spawn.ts +1 -0
- package/src/runtime/post-checks.ts +1 -1
- package/src/runtime/retry-runner.ts +9 -3
- package/src/runtime/run-tracker.ts +38 -10
- package/src/runtime/sandbox.ts +17 -2
- package/src/runtime/scheduler.ts +25 -2
- package/src/runtime/skill-effectiveness.ts +105 -62
- package/src/runtime/skill-instructions.ts +110 -32
- package/src/runtime/stale-reconciler.ts +310 -69
- package/src/runtime/subagent-manager.ts +251 -57
- package/src/runtime/task-health.ts +76 -0
- package/src/runtime/task-id.ts +20 -0
- package/src/runtime/task-runner/live-executor.ts +1 -1
- package/src/runtime/task-runner/progress.ts +1 -1
- package/src/runtime/task-runner/state-helpers.ts +110 -6
- package/src/runtime/task-runner.ts +92 -70
- package/src/runtime/team-runner.ts +186 -20
- package/src/schema/team-tool-schema.ts +27 -9
- package/src/skills/discover-skills.ts +9 -3
- package/src/state/active-run-registry.ts +170 -38
- package/src/state/artifact-store.ts +25 -13
- package/src/state/atomic-write-v2.ts +86 -0
- package/src/state/atomic-write.ts +346 -55
- package/src/state/blob-store.ts +178 -10
- package/src/state/crew-init.ts +161 -28
- package/src/state/decision-ledger.ts +172 -111
- package/src/state/event-log-rotation.ts +82 -52
- package/src/state/event-log.ts +254 -70
- package/src/state/health-store.ts +71 -0
- package/src/state/locks.ts +102 -20
- package/src/state/mailbox.ts +45 -7
- package/src/state/observation-store.ts +4 -1
- package/src/state/run-graph.ts +141 -130
- package/src/state/run-metrics.ts +24 -8
- package/src/state/session-state-map.ts +51 -0
- package/src/state/state-store.ts +330 -43
- package/src/ui/live-run-sidebar.ts +1 -1
- package/src/ui/loaders.ts +4 -0
- package/src/ui/overlays/agent-picker-overlay.ts +1 -1
- package/src/ui/overlays/mailbox-detail-overlay.ts +1 -1
- package/src/ui/powerbar-publisher.ts +1 -1
- package/src/ui/run-action-dispatcher.ts +2 -2
- package/src/ui/run-snapshot-cache.ts +1 -1
- package/src/ui/status-colors.ts +5 -1
- package/src/ui/theme-adapter.ts +80 -1
- package/src/ui/tool-progress-formatter.ts +9 -5
- package/src/ui/tool-render.ts +4 -0
- package/src/ui/tool-renderers/brief-mode.ts +207 -0
- package/src/ui/tool-renderers/index.ts +627 -0
- package/src/ui/widget/index.ts +224 -0
- package/src/ui/widget/widget-formatters.ts +148 -0
- package/src/ui/widget/widget-model.ts +90 -0
- package/src/ui/widget/widget-renderer.ts +130 -0
- package/src/ui/widget/widget-types.ts +37 -0
- package/src/utils/env-filter.ts +66 -0
- package/src/utils/file-coalescer.ts +9 -1
- package/src/utils/guards.ts +110 -0
- package/src/utils/paths.ts +80 -5
- package/src/utils/redaction.ts +6 -1
- package/src/utils/safe-paths.ts +281 -10
- package/src/worktree/cleanup.ts +112 -24
- package/src/worktree/worktree-manager.ts +128 -15
- package/test-bugs-all.mjs +10 -6
- package/test-integration-check.ts +4 -0
|
@@ -1,6 +1,9 @@
|
|
|
1
|
-
import {
|
|
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(
|
|
29
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
57
|
-
|
|
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
|
-
//
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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(
|
|
227
|
+
lines.push(
|
|
228
|
+
`- **Top Candidates**: ${entry.topCandidates.join(", ") || "(none)"}`,
|
|
229
|
+
);
|
|
195
230
|
lines.push("");
|
|
196
231
|
lines.push("#### Coherence");
|
|
197
|
-
lines.push(
|
|
198
|
-
|
|
199
|
-
|
|
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(
|
|
222
|
-
|
|
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
|
-
|
|
233
|
-
|
|
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(
|
|
250
|
-
|
|
285
|
+
export function promoteCandidate(
|
|
286
|
+
runId: string,
|
|
287
|
+
candidate: string,
|
|
288
|
+
): RolloutEntry {
|
|
289
|
+
assertSafePathId("runId", runId);
|
|
290
|
+
assertSafePathId("candidate", candidate);
|
|
251
291
|
|
|
252
|
-
|
|
253
|
-
const
|
|
292
|
+
const ledgerPath = getLedgerPath(runId);
|
|
293
|
+
const dir = dirname(ledgerPath);
|
|
294
|
+
if (!existsSync(dir)) {
|
|
295
|
+
mkdirSync(dir, { recursive: true });
|
|
296
|
+
}
|
|
254
297
|
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
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
|
-
|
|
267
|
-
|
|
316
|
+
// Compute coherence (empty ledger = no matches)
|
|
317
|
+
const coherenceMark = computeCoherence(
|
|
318
|
+
entryWithoutCoherence as RolloutEntry,
|
|
319
|
+
ledger,
|
|
320
|
+
);
|
|
268
321
|
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
322
|
+
// Manual promotion always allows further promotion
|
|
323
|
+
coherenceMark.promotionAllowed = true;
|
|
324
|
+
coherenceMark.reason = "Manual promotion - promotion allowed";
|
|
272
325
|
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
...entryWithoutCoherence,
|
|
276
|
-
coherenceMark,
|
|
277
|
-
};
|
|
326
|
+
// Create full entry with coherence
|
|
327
|
+
entry = { ...entryWithoutCoherence, coherenceMark };
|
|
278
328
|
|
|
279
|
-
|
|
280
|
-
|
|
329
|
+
// Always push new entry (append-only pattern)
|
|
330
|
+
ledger.push(entry);
|
|
281
331
|
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
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
|
-
|
|
348
|
+
assertSafePathId("runId", runId);
|
|
349
|
+
assertSafePathId("candidate", candidate);
|
|
298
350
|
|
|
299
|
-
|
|
300
|
-
const
|
|
351
|
+
const ledgerPath = getLedgerPath(runId);
|
|
352
|
+
const dir = dirname(ledgerPath);
|
|
353
|
+
if (!existsSync(dir)) {
|
|
354
|
+
mkdirSync(dir, { recursive: true });
|
|
355
|
+
}
|
|
301
356
|
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
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
|
-
|
|
314
|
-
|
|
375
|
+
// Compute coherence (empty ledger = no matches)
|
|
376
|
+
const coherenceMark = computeCoherence(
|
|
377
|
+
entryWithoutCoherence as RolloutEntry,
|
|
378
|
+
ledger,
|
|
379
|
+
);
|
|
315
380
|
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
381
|
+
// Manual decay never allows promotion
|
|
382
|
+
coherenceMark.promotionAllowed = false;
|
|
383
|
+
coherenceMark.reason = "Manual decay - promotion not allowed";
|
|
319
384
|
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
...entryWithoutCoherence,
|
|
323
|
-
coherenceMark,
|
|
324
|
-
};
|
|
385
|
+
// Create full entry with coherence
|
|
386
|
+
entry = { ...entryWithoutCoherence, coherenceMark };
|
|
325
387
|
|
|
326
|
-
|
|
327
|
-
|
|
388
|
+
// Always push new entry (append-only pattern)
|
|
389
|
+
ledger.push(entry);
|
|
328
390
|
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
//
|
|
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 +
|
|
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
|
-
|
|
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
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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 {
|