pi-crew 0.9.21 → 0.9.23
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 +64 -0
- package/dist/build-meta.json +4687 -4687
- package/dist/index.mjs +1583 -1492
- package/dist/index.mjs.map +3 -3
- package/package.json +1 -1
- package/src/extension/cross-extension-rpc.ts +1 -1
- package/src/runtime/pipeline-runner.ts +116 -107
- package/src/runtime/tool-output-pruner.ts +1 -1
- package/src/state/event-log.ts +182 -20
- package/src/worktree/worktree-manager.ts +1 -3
package/package.json
CHANGED
|
@@ -137,7 +137,7 @@ function isAllowedRpcRunParams(params: TeamToolParamsValue): {
|
|
|
137
137
|
return { ok: true };
|
|
138
138
|
}
|
|
139
139
|
|
|
140
|
-
function on(events: EventBusLike, channel: string, handler: (raw:
|
|
140
|
+
function on(events: EventBusLike, channel: string, handler: (raw: any) => unknown): () => void {
|
|
141
141
|
const unsub = events.on(channel, handler);
|
|
142
142
|
return typeof unsub === "function" ? unsub : () => {};
|
|
143
143
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { AgentConfig } from "../agents/agent-config.ts";
|
|
2
2
|
import { errors } from "../errors.ts";
|
|
3
|
-
import { appendEventAsync } from "../state/event-log.ts";
|
|
3
|
+
import { appendEventAsync, flushEventLogBuffer } from "../state/event-log.ts";
|
|
4
4
|
import type { TeamTaskState } from "../state/types.ts";
|
|
5
5
|
import type { TeamConfig } from "../teams/team-config.ts";
|
|
6
6
|
import type { WorkflowConfig, WorkflowStep } from "../workflows/workflow-config.ts";
|
|
@@ -107,142 +107,151 @@ export class PipelineRunner {
|
|
|
107
107
|
let previousResults: unknown[] = [];
|
|
108
108
|
const startTime = Date.now();
|
|
109
109
|
|
|
110
|
-
|
|
111
|
-
type: "pipeline:started",
|
|
112
|
-
runId,
|
|
113
|
-
message: `Pipeline '${workflow.name}' started`,
|
|
114
|
-
data: { stages: workflow.stages.map((s) => s.name) },
|
|
115
|
-
});
|
|
116
|
-
|
|
117
|
-
for (let i = 0; i < workflow.stages.length; i++) {
|
|
118
|
-
const stage = workflow.stages[i];
|
|
119
|
-
const stageStartTime = Date.now();
|
|
120
|
-
|
|
121
|
-
// Determine stop behavior for this stage
|
|
122
|
-
const effectiveStopOnError = stage.stopOnError ?? workflow.stopOnError ?? this.stopOnError;
|
|
123
|
-
|
|
110
|
+
try {
|
|
124
111
|
await appendEventAsync(eventsPath, {
|
|
125
|
-
type: "pipeline:
|
|
112
|
+
type: "pipeline:started",
|
|
126
113
|
runId,
|
|
127
|
-
message: `
|
|
128
|
-
data: {
|
|
114
|
+
message: `Pipeline '${workflow.name}' started`,
|
|
115
|
+
data: { stages: workflow.stages.map((s) => s.name) },
|
|
129
116
|
});
|
|
130
117
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
const
|
|
134
|
-
stageIndex: i,
|
|
135
|
-
stageName: stage.name,
|
|
136
|
-
previousResults,
|
|
137
|
-
totalStages: workflow.stages.length,
|
|
138
|
-
runId,
|
|
139
|
-
};
|
|
140
|
-
|
|
141
|
-
// Resolve inputs
|
|
142
|
-
const inputs = this.resolveInputs(stage.inputs, previousResults, context);
|
|
143
|
-
|
|
144
|
-
// Execute stage (handle fan-out if enabled)
|
|
145
|
-
const results = await this.executeStageInternal(stage, inputs, stageContext, executeStage);
|
|
118
|
+
for (let i = 0; i < workflow.stages.length; i++) {
|
|
119
|
+
const stage = workflow.stages[i];
|
|
120
|
+
const stageStartTime = Date.now();
|
|
146
121
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
name: stage.name,
|
|
150
|
-
status: "completed",
|
|
151
|
-
results,
|
|
152
|
-
duration,
|
|
153
|
-
fanOutItems: Array.isArray(inputs) ? inputs.length : undefined,
|
|
154
|
-
});
|
|
155
|
-
|
|
156
|
-
previousResults = results;
|
|
122
|
+
// Determine stop behavior for this stage
|
|
123
|
+
const effectiveStopOnError = stage.stopOnError ?? workflow.stopOnError ?? this.stopOnError;
|
|
157
124
|
|
|
158
125
|
await appendEventAsync(eventsPath, {
|
|
159
|
-
type: "pipeline:
|
|
126
|
+
type: "pipeline:stage_started",
|
|
160
127
|
runId,
|
|
161
|
-
message: `Stage '${stage.name}'
|
|
162
|
-
data: {
|
|
163
|
-
stageIndex: i,
|
|
164
|
-
stageName: stage.name,
|
|
165
|
-
duration,
|
|
166
|
-
resultCount: results.length,
|
|
167
|
-
},
|
|
128
|
+
message: `Stage '${stage.name}' started`,
|
|
129
|
+
data: { stageIndex: i, stageName: stage.name },
|
|
168
130
|
});
|
|
169
|
-
} catch (error) {
|
|
170
|
-
const duration = Date.now() - stageStartTime;
|
|
171
|
-
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
172
131
|
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
});
|
|
181
|
-
|
|
182
|
-
await appendEventAsync(eventsPath, {
|
|
183
|
-
type: "pipeline:stage_failed",
|
|
132
|
+
try {
|
|
133
|
+
// Build stage context
|
|
134
|
+
const stageContext: PipelineContext = {
|
|
135
|
+
stageIndex: i,
|
|
136
|
+
stageName: stage.name,
|
|
137
|
+
previousResults,
|
|
138
|
+
totalStages: workflow.stages.length,
|
|
184
139
|
runId,
|
|
185
|
-
|
|
186
|
-
data: {
|
|
187
|
-
stageIndex: i,
|
|
188
|
-
stageName: stage.name,
|
|
189
|
-
duration,
|
|
190
|
-
error: errorMessage,
|
|
191
|
-
},
|
|
192
|
-
});
|
|
140
|
+
};
|
|
193
141
|
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
runId,
|
|
197
|
-
message: `Pipeline '${workflow.name}' failed at stage '${stage.name}'`,
|
|
198
|
-
data: { failedStage: stage.name, error: errorMessage },
|
|
199
|
-
});
|
|
142
|
+
// Resolve inputs
|
|
143
|
+
const inputs = this.resolveInputs(stage.inputs, previousResults, context);
|
|
200
144
|
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
status: "failed",
|
|
206
|
-
};
|
|
207
|
-
} else {
|
|
145
|
+
// Execute stage (handle fan-out if enabled)
|
|
146
|
+
const results = await this.executeStageInternal(stage, inputs, stageContext, executeStage);
|
|
147
|
+
|
|
148
|
+
const duration = Date.now() - stageStartTime;
|
|
208
149
|
stages.push({
|
|
209
150
|
name: stage.name,
|
|
210
|
-
status: "
|
|
211
|
-
results
|
|
212
|
-
error: errorMessage,
|
|
151
|
+
status: "completed",
|
|
152
|
+
results,
|
|
213
153
|
duration,
|
|
154
|
+
fanOutItems: Array.isArray(inputs) ? inputs.length : undefined,
|
|
214
155
|
});
|
|
215
156
|
|
|
157
|
+
previousResults = results;
|
|
158
|
+
|
|
216
159
|
await appendEventAsync(eventsPath, {
|
|
217
|
-
type: "pipeline:
|
|
160
|
+
type: "pipeline:stage_completed",
|
|
218
161
|
runId,
|
|
219
|
-
message: `Stage '${stage.name}'
|
|
162
|
+
message: `Stage '${stage.name}' completed`,
|
|
220
163
|
data: {
|
|
221
164
|
stageIndex: i,
|
|
222
165
|
stageName: stage.name,
|
|
223
166
|
duration,
|
|
224
|
-
|
|
167
|
+
resultCount: results.length,
|
|
225
168
|
},
|
|
226
169
|
});
|
|
170
|
+
} catch (error) {
|
|
171
|
+
const duration = Date.now() - stageStartTime;
|
|
172
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
173
|
+
|
|
174
|
+
if (effectiveStopOnError) {
|
|
175
|
+
stages.push({
|
|
176
|
+
name: stage.name,
|
|
177
|
+
status: "failed",
|
|
178
|
+
results: [],
|
|
179
|
+
error: errorMessage,
|
|
180
|
+
duration,
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
await appendEventAsync(eventsPath, {
|
|
184
|
+
type: "pipeline:stage_failed",
|
|
185
|
+
runId,
|
|
186
|
+
message: `Stage '${stage.name}' failed: ${errorMessage}`,
|
|
187
|
+
data: {
|
|
188
|
+
stageIndex: i,
|
|
189
|
+
stageName: stage.name,
|
|
190
|
+
duration,
|
|
191
|
+
error: errorMessage,
|
|
192
|
+
},
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
await appendEventAsync(eventsPath, {
|
|
196
|
+
type: "pipeline:failed",
|
|
197
|
+
runId,
|
|
198
|
+
message: `Pipeline '${workflow.name}' failed at stage '${stage.name}'`,
|
|
199
|
+
data: { failedStage: stage.name, error: errorMessage },
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
return {
|
|
203
|
+
stages,
|
|
204
|
+
totalDuration: Date.now() - startTime,
|
|
205
|
+
finalResults: previousResults,
|
|
206
|
+
status: "failed",
|
|
207
|
+
};
|
|
208
|
+
} else {
|
|
209
|
+
stages.push({
|
|
210
|
+
name: stage.name,
|
|
211
|
+
status: "failed",
|
|
212
|
+
results: [],
|
|
213
|
+
error: errorMessage,
|
|
214
|
+
duration,
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
await appendEventAsync(eventsPath, {
|
|
218
|
+
type: "pipeline:stage_skipped",
|
|
219
|
+
runId,
|
|
220
|
+
message: `Stage '${stage.name}' skipped due to error`,
|
|
221
|
+
data: {
|
|
222
|
+
stageIndex: i,
|
|
223
|
+
stageName: stage.name,
|
|
224
|
+
duration,
|
|
225
|
+
error: errorMessage,
|
|
226
|
+
},
|
|
227
|
+
});
|
|
228
|
+
}
|
|
227
229
|
}
|
|
228
230
|
}
|
|
229
|
-
}
|
|
230
231
|
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
232
|
+
await appendEventAsync(eventsPath, {
|
|
233
|
+
type: "pipeline:completed",
|
|
234
|
+
runId,
|
|
235
|
+
message: `Pipeline '${workflow.name}' completed`,
|
|
236
|
+
data: {
|
|
237
|
+
stages: stages.map((s) => ({ name: s.name, status: s.status })),
|
|
238
|
+
},
|
|
239
|
+
});
|
|
239
240
|
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
241
|
+
return {
|
|
242
|
+
stages,
|
|
243
|
+
totalDuration: Date.now() - startTime,
|
|
244
|
+
finalResults: previousResults,
|
|
245
|
+
status: stages.some((s) => s.status === "failed") ? "partial" : "completed",
|
|
246
|
+
};
|
|
247
|
+
} finally {
|
|
248
|
+
// FIX (P0 follow-up): Drain the event-log buffer before returning so
|
|
249
|
+
// --test-force-exit does not detect "Promise resolution is still
|
|
250
|
+
// pending". The buffered flush uses a 20ms ref'd timer that fires
|
|
251
|
+
// asynchronously; without this explicit drain, the test runner sees
|
|
252
|
+
// pending appendEventAsync promises and cancels the test file.
|
|
253
|
+
await flushEventLogBuffer(eventsPath);
|
|
254
|
+
}
|
|
246
255
|
}
|
|
247
256
|
|
|
248
257
|
/**
|
|
@@ -90,7 +90,7 @@ export interface PruneResult {
|
|
|
90
90
|
// Token estimation (rough char/4 heuristic, matching gajae-code)
|
|
91
91
|
// ---------------------------------------------------------------------------
|
|
92
92
|
|
|
93
|
-
import { countTokens } from "../utils/token-counter";
|
|
93
|
+
import { countTokens } from "../utils/token-counter.ts";
|
|
94
94
|
|
|
95
95
|
function estimateTokens(text: string): number {
|
|
96
96
|
return countTokens(text);
|
package/src/state/event-log.ts
CHANGED
|
@@ -412,15 +412,11 @@ export function resetEventLogMode(): void {
|
|
|
412
412
|
export async function appendEventAsync(eventsPath: string, event: AppendTeamEvent): Promise<TeamEvent> {
|
|
413
413
|
// Non-terminal events: route through buffer for coalesced writes (20ms default).
|
|
414
414
|
// This eliminates per-event fsync + persistSequence overhead for high-frequency events.
|
|
415
|
+
// The buffer timer is kept alive (ref'd) so standalone contexts (tests, short-lived
|
|
416
|
+
// scripts) get their events flushed before the loop drains — no per-event keepAlive
|
|
417
|
+
// intervals needed (which previously starved the event loop at high fan-out).
|
|
415
418
|
if (!TERMINAL_EVENT_TYPES.has(event.type)) {
|
|
416
|
-
|
|
417
|
-
// FIX: appendEventBuffered uses unref()'d timers. Keep the event loop alive
|
|
418
|
-
// so the promise resolves in standalone/test contexts where nothing else
|
|
419
|
-
// keeps the loop running. Without this, the event loop drains before the
|
|
420
|
-
// timer fires and the promise never resolves.
|
|
421
|
-
const keepAlive = setInterval(() => {}, 200);
|
|
422
|
-
void result.finally(() => clearInterval(keepAlive));
|
|
423
|
-
return result;
|
|
419
|
+
return appendEventBuffered(eventsPath, event);
|
|
424
420
|
}
|
|
425
421
|
// Terminal events: direct async path for immediate durability guarantee
|
|
426
422
|
const queueKey = eventsPath;
|
|
@@ -618,6 +614,131 @@ export async function appendEventAsync(eventsPath: string, event: AppendTeamEven
|
|
|
618
614
|
* `withEventLogLockSync` for `eventsPath`. Used by `appendEventBuffered` to
|
|
619
615
|
* write a whole batch of pending events under a single lock acquire.
|
|
620
616
|
*/
|
|
617
|
+
/**
|
|
618
|
+
* Batch variant used by the buffered flush path. Computes metadata for each
|
|
619
|
+
* event, writes the whole batch in a single appendFileSync + fsync, persists
|
|
620
|
+
* the sequence sidecar once with the last seq, and updates the sequence cache
|
|
621
|
+
* once. Resolves each item with its finalized event (carrying the assigned
|
|
622
|
+
* seq). This collapses N fsyncs into 1 for the buffered write path, which is
|
|
623
|
+
* the entire point of buffering — the previous per-event fsync made buffer
|
|
624
|
+
* coalescing useless and added ~30ms/event on tmpfs.
|
|
625
|
+
*/
|
|
626
|
+
async function appendEventBatchInsideLock(eventsPath: string, queue: BufferedAppend[]): Promise<void> {
|
|
627
|
+
if (queue.length === 0) return;
|
|
628
|
+
fs.mkdirSync(path.dirname(eventsPath), { recursive: true });
|
|
629
|
+
|
|
630
|
+
// Pre-flight size check (mirrors appendEventInsideLock). We do it once for
|
|
631
|
+
// the batch instead of once per event.
|
|
632
|
+
try {
|
|
633
|
+
if (fs.existsSync(eventsPath)) {
|
|
634
|
+
const stat = fs.statSync(eventsPath);
|
|
635
|
+
if (stat.size > MAX_EVENTS_BYTES) {
|
|
636
|
+
try {
|
|
637
|
+
const prepared = prepareCompaction(eventsPath);
|
|
638
|
+
if (prepared) applyCompactionUnlocked(eventsPath, prepared);
|
|
639
|
+
} catch (error) {
|
|
640
|
+
logInternalError("event-log.batch-immediate-compact", error, `eventsPath=${eventsPath}`);
|
|
641
|
+
}
|
|
642
|
+
if (fs.existsSync(eventsPath) && fs.statSync(eventsPath).size > MAX_EVENTS_BYTES) {
|
|
643
|
+
rotateEventLogUnlocked(eventsPath);
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
} catch (error) {
|
|
648
|
+
logInternalError("event-log.batch-size-check", error, `eventsPath=${eventsPath}`);
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
// Phase 1: compute metadata + JSON lines for every event in the batch.
|
|
652
|
+
// Initialize nextSeq ONCE from nextSequence (or the first event's baseMetadata.seq),
|
|
653
|
+
// then increment locally for each subsequent event in the batch. Calling
|
|
654
|
+
// nextSequence() per-event would re-read file stat/sidecar with no writes
|
|
655
|
+
// in between — every call would see the same file state and return the same
|
|
656
|
+
// seq, breaking the "unique monotonic seq" contract. The cache update +
|
|
657
|
+
// persistSequence at the end refreshes the sidecar to the last assigned seq.
|
|
658
|
+
const startingSeq = queue[0]?.event.metadata?.seq ?? nextSequence(eventsPath);
|
|
659
|
+
let nextSeq = startingSeq;
|
|
660
|
+
const finalized: { item: BufferedAppend; line: string; fullEvent: TeamEvent }[] = [];
|
|
661
|
+
let lastSeq = 0;
|
|
662
|
+
for (const item of queue) {
|
|
663
|
+
const baseMetadata = item.event.metadata;
|
|
664
|
+
const seq = baseMetadata?.seq ?? nextSeq++;
|
|
665
|
+
let metadata: TeamEventMetadata = {
|
|
666
|
+
seq,
|
|
667
|
+
provenance: baseMetadata?.provenance ?? "team_runner",
|
|
668
|
+
...(baseMetadata?.parentEventId ? { parentEventId: baseMetadata.parentEventId } : {}),
|
|
669
|
+
...(baseMetadata?.attemptId ? { attemptId: baseMetadata.attemptId } : {}),
|
|
670
|
+
...(baseMetadata?.branchId ? { branchId: baseMetadata.branchId } : {}),
|
|
671
|
+
...(baseMetadata?.causationId ? { causationId: baseMetadata.causationId } : {}),
|
|
672
|
+
...(baseMetadata?.correlationId ? { correlationId: baseMetadata.correlationId } : {}),
|
|
673
|
+
...(baseMetadata?.sessionIdentity ? { sessionIdentity: baseMetadata.sessionIdentity } : {}),
|
|
674
|
+
...(baseMetadata?.ownership ? { ownership: baseMetadata.ownership } : {}),
|
|
675
|
+
...(baseMetadata?.nudgeId ? { nudgeId: baseMetadata.nudgeId } : {}),
|
|
676
|
+
...(baseMetadata?.confidence ? { confidence: baseMetadata.confidence } : {}),
|
|
677
|
+
};
|
|
678
|
+
const fullEvent: TeamEvent = {
|
|
679
|
+
time: new Date().toISOString(),
|
|
680
|
+
...item.event,
|
|
681
|
+
metadata,
|
|
682
|
+
};
|
|
683
|
+
if (baseMetadata?.fingerprint || TERMINAL_EVENT_TYPES.has(fullEvent.type)) {
|
|
684
|
+
metadata = {
|
|
685
|
+
...metadata,
|
|
686
|
+
fingerprint: baseMetadata?.fingerprint ?? computeEventFingerprint(fullEvent),
|
|
687
|
+
};
|
|
688
|
+
fullEvent.metadata = metadata;
|
|
689
|
+
}
|
|
690
|
+
finalized.push({ item, line: `${JSON.stringify(redactSecrets(fullEvent))}\n`, fullEvent });
|
|
691
|
+
lastSeq = seq;
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
// Phase 2: single appendFileSync + single fsync + single persistSequence.
|
|
695
|
+
// Before this fix, each event in the batch triggered its own fsync, which
|
|
696
|
+
// was the dominant cost on tmpfs and CI runners.
|
|
697
|
+
try {
|
|
698
|
+
if (fs.existsSync(eventsPath) && fs.statSync(eventsPath).size > MAX_EVENTS_BYTES) {
|
|
699
|
+
logInternalError(
|
|
700
|
+
"event-log.size-limit",
|
|
701
|
+
new Error(`events file ${eventsPath} exceeds ${MAX_EVENTS_BYTES} bytes after compaction`),
|
|
702
|
+
`eventsPath=${eventsPath}`,
|
|
703
|
+
);
|
|
704
|
+
// Reject the batch — caller will surface the error per item.
|
|
705
|
+
for (const { item } of finalized) item.reject(new Error("event log size limit exceeded"));
|
|
706
|
+
return;
|
|
707
|
+
}
|
|
708
|
+
} catch (error) {
|
|
709
|
+
logInternalError("event-log.batch-size-check-post", error, `eventsPath=${eventsPath}`);
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
fs.appendFileSync(eventsPath, finalized.map((f) => f.line).join(""), "utf-8");
|
|
713
|
+
const fd = fs.openSync(eventsPath, "r+");
|
|
714
|
+
try {
|
|
715
|
+
fs.fsyncSync(fd);
|
|
716
|
+
} catch {
|
|
717
|
+
// EPERM on Windows CI: best-effort flush
|
|
718
|
+
} finally {
|
|
719
|
+
fs.closeSync(fd);
|
|
720
|
+
}
|
|
721
|
+
persistSequence(eventsPath, lastSeq);
|
|
722
|
+
|
|
723
|
+
// Phase 3: cache update + resolve all promises.
|
|
724
|
+
try {
|
|
725
|
+
const stat = fs.statSync(eventsPath);
|
|
726
|
+
if (sequenceCache.size >= MAX_SEQUENCE_CACHE_ENTRIES) {
|
|
727
|
+
evictOldestSequenceCacheEntries();
|
|
728
|
+
}
|
|
729
|
+
sequenceCache.set(eventsPath, {
|
|
730
|
+
size: stat.size,
|
|
731
|
+
mtimeMs: stat.mtimeMs,
|
|
732
|
+
seq: lastSeq,
|
|
733
|
+
lastAccessMs: Date.now(),
|
|
734
|
+
});
|
|
735
|
+
} catch (error) {
|
|
736
|
+
logInternalError("event-log.batch-cache-update", error, `eventsPath=${eventsPath}`);
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
for (const { item, fullEvent } of finalized) item.resolve(fullEvent);
|
|
740
|
+
}
|
|
741
|
+
|
|
621
742
|
function appendEventInsideLock(eventsPath: string, event: AppendTeamEvent): TeamEvent {
|
|
622
743
|
fs.mkdirSync(path.dirname(eventsPath), { recursive: true });
|
|
623
744
|
const baseMetadata = event.metadata;
|
|
@@ -784,8 +905,15 @@ export function appendEventBuffered(eventsPath: string, event: AppendTeamEvent,
|
|
|
784
905
|
queue.push({ event, resolve, reject });
|
|
785
906
|
bufferedQueues.set(eventsPath, queue);
|
|
786
907
|
if (!bufferedTimers.has(eventsPath)) {
|
|
787
|
-
|
|
788
|
-
|
|
908
|
+
// Wrap flush in async IIFE so the returned Promise is awaited (avoids
|
|
909
|
+
// "floating promise" warnings under --test-force-exit and prevents
|
|
910
|
+
// the timer from being treated as done before the flush actually
|
|
911
|
+
// completes its async work).
|
|
912
|
+
const timer = setTimeout(() => {
|
|
913
|
+
flushOneEventLogBuffer(eventsPath).catch((error) => {
|
|
914
|
+
logInternalError("event-log.buffered-flush", error, `eventsPath=${eventsPath}`);
|
|
915
|
+
});
|
|
916
|
+
}, bufferMs);
|
|
789
917
|
bufferedTimers.set(eventsPath, timer);
|
|
790
918
|
}
|
|
791
919
|
});
|
|
@@ -830,15 +958,12 @@ async function flushOneEventLogBuffer(eventsPath: string): Promise<void> {
|
|
|
830
958
|
// FIX (Issue 2): Use async lock instead of withEventLogLockSync to avoid
|
|
831
959
|
// blocking the event loop. The sync lock uses sleepSync which blocks for
|
|
832
960
|
// up to 5s and prevents AbortSignal handlers from firing.
|
|
961
|
+
// FIX (P0 follow-up): Batch the file write + fsync + persistSequence across
|
|
962
|
+
// the whole queue. Previously each event triggered its own fsyncSync,
|
|
963
|
+
// turning 100 buffered events into 100 fsyncs (~3s on tmpfs). Now we do
|
|
964
|
+
// 1 appendFileSync + 1 fsync + 1 persistSequence for the whole batch.
|
|
833
965
|
await withEventLogLockAsync(eventsPath, async () => {
|
|
834
|
-
|
|
835
|
-
try {
|
|
836
|
-
const ev = appendEventInsideLock(eventsPath, item.event);
|
|
837
|
-
item.resolve(ev);
|
|
838
|
-
} catch (error) {
|
|
839
|
-
item.reject(error);
|
|
840
|
-
}
|
|
841
|
-
}
|
|
966
|
+
await appendEventBatchInsideLock(eventsPath, queue);
|
|
842
967
|
});
|
|
843
968
|
} catch (error) {
|
|
844
969
|
// Lock acquire failed — fail every queued item so callers can fall back.
|
|
@@ -866,15 +991,52 @@ export function appendEventFireAndForget(eventsPath: string, event: AppendTeamEv
|
|
|
866
991
|
// Auto-flush on process exit so buffered events do not silently leak.
|
|
867
992
|
// Defense-in-depth: SIGTERM/SIGINT use setImmediate so the handler returns
|
|
868
993
|
// immediately and the main thread is not blocked by sync I/O.
|
|
994
|
+
// FIX (P0 follow-up): Only call flushEventLogBuffer() / drainAsyncQueues() if
|
|
995
|
+
// there is actually pending work. Calling them unconditionally creates a new
|
|
996
|
+
// floating Promise that the test runner detects under --test-force-exit,
|
|
997
|
+
// failing tests with "Promise resolution is still pending but the event loop
|
|
998
|
+
// has already resolved" even when the test body completed cleanly.
|
|
869
999
|
process.on("exit", () => {
|
|
870
|
-
|
|
1000
|
+
if (bufferedQueues.size > 0) {
|
|
1001
|
+
flushEventLogBuffer().catch(() => {
|
|
1002
|
+
/* best-effort flush on exit */
|
|
1003
|
+
});
|
|
1004
|
+
}
|
|
871
1005
|
// FIX (Issue 1): Drain asyncQueues on exit to minimize event loss.
|
|
872
1006
|
// In-flight async writes are awaited (via Promise.allSettled) before
|
|
873
1007
|
// the map is cleared. This reduces but does not eliminate event loss
|
|
874
1008
|
// on crash — SIGKILL (kill -9) cannot be intercepted.
|
|
875
|
-
|
|
1009
|
+
if (asyncQueues.size > 0) {
|
|
1010
|
+
drainAsyncQueues().catch(() => {
|
|
1011
|
+
/* best-effort drain on exit */
|
|
1012
|
+
});
|
|
1013
|
+
}
|
|
876
1014
|
asyncQueues.clear();
|
|
877
1015
|
});
|
|
1016
|
+
|
|
1017
|
+
// FIX (P0 follow-up): Drain buffered events on `beforeExit` (async-aware).
|
|
1018
|
+
// The `exit` handler above is sync-only and cannot await the flush, leaving
|
|
1019
|
+
// pending promises that the test runner detects under --test-force-exit as
|
|
1020
|
+
// "Promise resolution is still pending but the event loop has already
|
|
1021
|
+
// resolved". `beforeExit` fires when the event loop drains naturally and
|
|
1022
|
+
// supports async handlers, so we can await the flush here and let the loop
|
|
1023
|
+
// drain cleanly before process.exit() is called.
|
|
1024
|
+
process.on("beforeExit", async () => {
|
|
1025
|
+
if (bufferedQueues.size > 0) {
|
|
1026
|
+
try {
|
|
1027
|
+
await flushEventLogBuffer();
|
|
1028
|
+
} catch {
|
|
1029
|
+
/* best-effort */
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
if (asyncQueues.size > 0) {
|
|
1033
|
+
try {
|
|
1034
|
+
await drainAsyncQueues();
|
|
1035
|
+
} catch {
|
|
1036
|
+
/* best-effort */
|
|
1037
|
+
}
|
|
1038
|
+
}
|
|
1039
|
+
});
|
|
878
1040
|
process.on("SIGTERM", () => setImmediate(() => flushEventLogBuffer()));
|
|
879
1041
|
process.on("SIGINT", () => setImmediate(() => flushEventLogBuffer()));
|
|
880
1042
|
// FIX (Issue 1): Handle uncaught exceptions to flush buffered events before
|
|
@@ -111,7 +111,6 @@ async function gitAsync(cwd: string, args: string[]): Promise<string> {
|
|
|
111
111
|
const { stdout } = await execFileAsync("git", args, {
|
|
112
112
|
cwd,
|
|
113
113
|
encoding: "utf-8",
|
|
114
|
-
stdio: ["ignore", "pipe", "pipe"],
|
|
115
114
|
env: gitEnv(),
|
|
116
115
|
windowsHide: true,
|
|
117
116
|
});
|
|
@@ -445,7 +444,6 @@ async function branchExistsAsync(repoRoot: string, branch: string): Promise<{ lo
|
|
|
445
444
|
await execFileAsync("git", ["for-each-ref", "--format=%(refname)", `refs/remotes/*/${branch}`], {
|
|
446
445
|
cwd: repoRoot,
|
|
447
446
|
encoding: "utf-8",
|
|
448
|
-
stdio: ["ignore", "pipe", "pipe"],
|
|
449
447
|
windowsHide: true,
|
|
450
448
|
})
|
|
451
449
|
).stdout.trim();
|
|
@@ -459,7 +457,7 @@ async function pruneStaleWorktreesAsync(repoRoot: string): Promise<void> {
|
|
|
459
457
|
try {
|
|
460
458
|
await execFileAsync("git", ["worktree", "prune"], {
|
|
461
459
|
cwd: repoRoot,
|
|
462
|
-
|
|
460
|
+
windowsHide: true,
|
|
463
461
|
});
|
|
464
462
|
} catch {
|
|
465
463
|
/* best-effort */
|