pi-crew 0.9.32 → 0.9.33
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 +61 -1
- package/docs/perf/performance-audit-report-2026-07.md +948 -0
- package/package.json +1 -1
- package/src/extension/run-maintenance.ts +29 -1
- package/src/extension/team-tool/lifecycle-actions.ts +22 -0
- package/src/extension/team-tool.ts +22 -1
- package/src/runtime/skill-instructions.ts +7 -2
- package/src/runtime/task-runner/prompt-builder.ts +66 -19
- package/src/runtime/task-runner.ts +20 -7
- package/src/runtime/team-runner.ts +87 -7
- package/src/state/atomic-write.ts +23 -0
- package/src/state/event-log.ts +71 -25
package/src/state/event-log.ts
CHANGED
|
@@ -225,6 +225,18 @@ export function __test__clearSequenceCache(): void {
|
|
|
225
225
|
sequenceCache.clear();
|
|
226
226
|
}
|
|
227
227
|
|
|
228
|
+
/** @internal — clear the in-process seqCounters Map so nextSequence seeds
|
|
229
|
+
* fresh from the sidecar/file (simulates a process restart for testing). */
|
|
230
|
+
export function __test__clearSeqCounters(): void {
|
|
231
|
+
seqCounters.clear();
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** @internal — the raw nextSequence for testing (forces re-seed from disk
|
|
235
|
+
* by requiring the caller to have already cleared both caches). */
|
|
236
|
+
export function __test__nextSequence(eventsPath: string): number {
|
|
237
|
+
return nextSequence(eventsPath);
|
|
238
|
+
}
|
|
239
|
+
|
|
228
240
|
/** @internal — the max sequence cache entries bound. */
|
|
229
241
|
export const MAX_SEQUENCE_CACHE_ENTRIES_VALUE = MAX_SEQUENCE_CACHE_ENTRIES;
|
|
230
242
|
|
|
@@ -277,13 +289,22 @@ function nextSequence(eventsPath: string): number {
|
|
|
277
289
|
const stored = readStoredSequence(eventsPath);
|
|
278
290
|
const fileShrunk = cached && stat.size < cached.size;
|
|
279
291
|
if (stored !== undefined && !fileShrunk) {
|
|
292
|
+
// EL-1: the sidecar can regress via sync/async interleave —
|
|
293
|
+
// appendEventAsync reserves a seq, yields during appendFile/fsync, a
|
|
294
|
+
// concurrent sync appendEvent reserves+persists a higher seq, then the
|
|
295
|
+
// async path persists its lower seq — regressing the sidecar below the
|
|
296
|
+
// file's true max. Trusting the regressed sidecar alone would return a
|
|
297
|
+
// duplicate seq on the next append. Take max with scanSequence so a
|
|
298
|
+
// regressed sidecar cannot cause duplicate sequence numbers.
|
|
299
|
+
const fileMax = scanSequence(eventsPath);
|
|
300
|
+
const safeSeq = Math.max(stored, fileMax);
|
|
280
301
|
sequenceCache.set(eventsPath, {
|
|
281
302
|
size: stat.size,
|
|
282
303
|
mtimeMs: stat.mtimeMs,
|
|
283
|
-
seq:
|
|
304
|
+
seq: safeSeq,
|
|
284
305
|
lastAccessMs: Date.now(),
|
|
285
306
|
});
|
|
286
|
-
return
|
|
307
|
+
return safeSeq + 1;
|
|
287
308
|
}
|
|
288
309
|
const current = scanSequence(eventsPath);
|
|
289
310
|
sequenceCache.set(eventsPath, {
|
|
@@ -1040,6 +1061,40 @@ export async function flushEventLogBuffer(): Promise<void> {
|
|
|
1040
1061
|
for (const eventsPath of [...bufferedQueues.keys()]) await flushOneEventLogBuffer(eventsPath);
|
|
1041
1062
|
}
|
|
1042
1063
|
|
|
1064
|
+
/**
|
|
1065
|
+
* EL-2: Synchronously flush every queued buffered event across all paths.
|
|
1066
|
+
* Used by the `exit` / `uncaughtException` / `SIGTERM` / `SIGINT` handlers,
|
|
1067
|
+
* which CANNOT await async work (process is terminating). The async
|
|
1068
|
+
* flushEventLogBuffer() previously called from these handlers created
|
|
1069
|
+
* floating promises that never resolved, and the process exited before
|
|
1070
|
+
* any buffered events were written — losing all events buffered via
|
|
1071
|
+
* appendEventBuffered (task.progress etc.). This sync variant writes the
|
|
1072
|
+
* buffered batches using the sync event-log lock + appendFileSync + fsync
|
|
1073
|
+
* + persistSequence, recovering buffered events before termination.
|
|
1074
|
+
* In-flight asyncQueues (appendEventAsync writes already dispatched to
|
|
1075
|
+
* the thread pool) remain best-effort and cannot be awaited on `exit` —
|
|
1076
|
+
* we clear them to drop stale state. SIGKILL cannot be intercepted.
|
|
1077
|
+
*/
|
|
1078
|
+
export function flushBufferedQueuesSync(): void {
|
|
1079
|
+
for (const eventsPath of [...bufferedQueues.keys()]) {
|
|
1080
|
+
const queue = bufferedQueues.get(eventsPath);
|
|
1081
|
+
bufferedQueues.delete(eventsPath);
|
|
1082
|
+
if (!queue || queue.length === 0) continue;
|
|
1083
|
+
try {
|
|
1084
|
+
withEventLogLockSync(eventsPath, () => {
|
|
1085
|
+
// appendEventBatchInsideLock is declared async but its body is fully
|
|
1086
|
+
// synchronous (fs.appendFileSync + fs.fsyncSync + persistSequence,
|
|
1087
|
+
// no awaits). Invoking without await runs the body synchronously and
|
|
1088
|
+
// returns a resolved Promise that we discard.
|
|
1089
|
+
void appendEventBatchInsideLock(eventsPath, queue);
|
|
1090
|
+
});
|
|
1091
|
+
} catch (error) {
|
|
1092
|
+
logInternalError("event-log.sync-flush", error, eventsPath);
|
|
1093
|
+
}
|
|
1094
|
+
}
|
|
1095
|
+
for (const eventsPath of [...bufferedTimers.keys()]) bufferedTimers.delete(eventsPath);
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1043
1098
|
/**
|
|
1044
1099
|
* Schedule an async event append without waiting for the result.
|
|
1045
1100
|
* Uses the non-blocking async queue to avoid blocking the event loop.
|
|
@@ -1059,20 +1114,13 @@ export function appendEventFireAndForget(eventsPath: string, event: AppendTeamEv
|
|
|
1059
1114
|
// failing tests with "Promise resolution is still pending but the event loop
|
|
1060
1115
|
// has already resolved" even when the test body completed cleanly.
|
|
1061
1116
|
process.on("exit", () => {
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
//
|
|
1068
|
-
|
|
1069
|
-
// the map is cleared. This reduces but does not eliminate event loss
|
|
1070
|
-
// on crash — SIGKILL (kill -9) cannot be intercepted.
|
|
1071
|
-
if (asyncQueues.size > 0) {
|
|
1072
|
-
drainAsyncQueues().catch(() => {
|
|
1073
|
-
/* best-effort drain on exit */
|
|
1074
|
-
});
|
|
1075
|
-
}
|
|
1117
|
+
// EL-2: synchronously flush buffered events before the process terminates.
|
|
1118
|
+
// `exit` is sync-only and cannot await async work; the previous async
|
|
1119
|
+
// flushEventLogBuffer()/drainAsyncQueues() created floating promises that
|
|
1120
|
+
// never resolved, and the process exited before any buffered events were
|
|
1121
|
+
// written. flushBufferedQueuesSync uses the sync lock + appendFileSync +
|
|
1122
|
+
// fsync + persistSequence to recover buffered events.
|
|
1123
|
+
flushBufferedQueuesSync();
|
|
1076
1124
|
asyncQueues.clear();
|
|
1077
1125
|
});
|
|
1078
1126
|
|
|
@@ -1099,20 +1147,18 @@ process.on("beforeExit", async () => {
|
|
|
1099
1147
|
}
|
|
1100
1148
|
}
|
|
1101
1149
|
});
|
|
1102
|
-
process.on("SIGTERM", () => setImmediate(() =>
|
|
1103
|
-
process.on("SIGINT", () => setImmediate(() =>
|
|
1150
|
+
process.on("SIGTERM", () => setImmediate(() => flushBufferedQueuesSync()));
|
|
1151
|
+
process.on("SIGINT", () => setImmediate(() => flushBufferedQueuesSync()));
|
|
1104
1152
|
// FIX (Issue 1): Handle uncaught exceptions to flush buffered events before
|
|
1105
1153
|
// the process terminates. The async queues use promise chains that will be
|
|
1106
1154
|
// abandoned on crash; clearing the map prevents memory leaks and stale state.
|
|
1107
1155
|
// Note: SIGKILL (kill -9) cannot be intercepted and is not handled.
|
|
1108
1156
|
process.on("uncaughtException", (error) => {
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
// FIX (Issue 1): Drain asyncQueues before clearing to minimize event loss.
|
|
1115
|
-
drainAsyncQueues();
|
|
1157
|
+
// EL-2: synchronously flush buffered events before re-throwing (which
|
|
1158
|
+
// terminates the process). The previous async flushEventLogBuffer() +
|
|
1159
|
+
// drainAsyncQueues() couldn't complete before process exit; use the sync
|
|
1160
|
+
// variant to recover buffered events.
|
|
1161
|
+
flushBufferedQueuesSync();
|
|
1116
1162
|
asyncQueues.clear();
|
|
1117
1163
|
// Re-throw to preserve default uncaught exception behavior (process exit)
|
|
1118
1164
|
throw error;
|