pi-crew 0.9.32 → 0.9.34
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 +163 -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/observability/exporters/otlp-exporter.ts +24 -2
- package/src/runtime/live-session-runtime.ts +12 -6
- package/src/runtime/parallel-utils.ts +5 -0
- package/src/runtime/skill-instructions.ts +7 -2
- package/src/runtime/task-graph-scheduler.ts +48 -17
- package/src/runtime/task-runner/prompt-builder.ts +120 -19
- package/src/runtime/task-runner/state-helpers.ts +18 -1
- package/src/runtime/task-runner.ts +20 -7
- package/src/runtime/team-runner.ts +196 -39
- package/src/state/artifact-store.ts +5 -0
- package/src/state/atomic-write.ts +87 -8
- package/src/state/event-log.ts +71 -25
- package/src/state/state-store.ts +32 -28
|
@@ -8,7 +8,7 @@ import { childCorrelation, withCorrelation } from "../observability/correlation.
|
|
|
8
8
|
import type { MetricRegistry } from "../observability/metric-registry.ts";
|
|
9
9
|
import { PluginRegistry } from "../plugins/plugin-registry.ts";
|
|
10
10
|
import { NextJsPlugin, VitePlugin, VitestPlugin } from "../plugins/plugins/index.ts";
|
|
11
|
-
import { writeArtifact } from "../state/artifact-store.ts";
|
|
11
|
+
import { hashArtifactContent as hashContent, writeArtifact } from "../state/artifact-store.ts";
|
|
12
12
|
import { appendEvent, appendEventAsync, appendEventBuffered, appendEventFireAndForget, flushEventLogBuffer } from "../state/event-log.ts";
|
|
13
13
|
import { HealthStore } from "../state/health-store.ts";
|
|
14
14
|
import { withRunLock } from "../state/locks.ts";
|
|
@@ -44,6 +44,7 @@ import { recordsForMaterializedTasks } from "./task-display.ts";
|
|
|
44
44
|
import { buildExecutionPlan as buildDagExecutionPlan, getReadyTasks as getDagReadyTasks, type TaskNode } from "./task-graph.ts";
|
|
45
45
|
import { buildTaskGraphIndex, refreshTaskGraphQueues, taskGraphSnapshot } from "./task-graph-scheduler.ts";
|
|
46
46
|
import { aggregateTaskOutputs } from "./task-output-context.ts";
|
|
47
|
+
import { clearStablePrefixCache, computeStablePrefixComponents } from "./task-runner/prompt-builder.ts";
|
|
47
48
|
import { runTeamTask } from "./task-runner.ts";
|
|
48
49
|
import { mergeArtifacts } from "./team-runner-artifacts.ts";
|
|
49
50
|
import { clearTrackedTaskUsage } from "./usage-tracker.ts";
|
|
@@ -104,7 +105,12 @@ function startTeamRunHeartbeat(stateRoot: string, runId: string): () => void {
|
|
|
104
105
|
// heartbeat and interrupt guard (both unref'd), the team heartbeat must keep
|
|
105
106
|
// the event loop alive so the stale reconciler does not cancel long-running
|
|
106
107
|
// team runs (>5 min) as "stale" while they are actively executing.
|
|
107
|
-
|
|
108
|
+
// P13 (perf): tick every 60s instead of 30s. The stale-reconciler threshold
|
|
109
|
+
// is 5min (300_000ms in crash-recovery.ts), so a 60s heartbeat still leaves
|
|
110
|
+
// 5 ticks of slack before a run is misidentified as stale. Cuts the per-run
|
|
111
|
+
// heartbeat.syscall count in half (1 write/30s → 1 write/60s) with no
|
|
112
|
+
// behavioral change.
|
|
113
|
+
const interval = setInterval(writeHeartbeat, 60_000);
|
|
108
114
|
return () => clearInterval(interval);
|
|
109
115
|
}
|
|
110
116
|
|
|
@@ -264,6 +270,15 @@ function shouldMergeTaskUpdate(current: TeamTaskState, updated: TeamTaskState):
|
|
|
264
270
|
// but completed is the success terminal state and should not be reachable from
|
|
265
271
|
// failed via a stale merge. The check above only guards non-terminal→terminal.
|
|
266
272
|
if (current.status === "failed" && updated.status === "completed") return false;
|
|
273
|
+
// Mirror that guard for the other dangerous terminal→terminal flips (reverse
|
|
274
|
+
// audit CANCEL-3 + F3): a cancelled task must not be resurrected to completed,
|
|
275
|
+
// and a completed task must not be demoted to failed, by a stale worker merge.
|
|
276
|
+
// The task transition table (contracts.ts) only permits terminal→queued
|
|
277
|
+
// (retry); these flips are always stale results. A worker that completed after
|
|
278
|
+
// the task was cancelled, or a stale failed result arriving after completion,
|
|
279
|
+
// must not flip a settled terminal status.
|
|
280
|
+
if (current.status === "cancelled" && updated.status === "completed") return false;
|
|
281
|
+
if (current.status === "completed" && updated.status === "failed") return false;
|
|
267
282
|
// Guard: when current is "running" but has resultArtifact (another worker already
|
|
268
283
|
// completed it), a stale updated with status="running" and no resultArtifact
|
|
269
284
|
// must not overwrite the actual completed state.
|
|
@@ -318,11 +333,22 @@ function shouldMergeTaskUpdate(current: TeamTaskState, updated: TeamTaskState):
|
|
|
318
333
|
|
|
319
334
|
// H4 fix: rename to descriptive name. Kept __test__ as alias for backward
|
|
320
335
|
// compat test imports.
|
|
336
|
+
// FIX (perf P10): replace O(N×M) .find() + .map() inside nested loops with a
|
|
337
|
+
// single-pass Map-based merge. Build an index of `merged` once, then for each
|
|
338
|
+
// incoming updated task do O(1) lookup; the final pass reassembles `merged`
|
|
339
|
+
// preserving original order. For a 20-task run × 5-batch merger with
|
|
340
|
+
// ~10 updates per result, this reduces from O(50×20) = 1000 ops to O(120).
|
|
341
|
+
// Behavior is unchanged: skipped updates (shouldMergeTaskUpdate=false) still
|
|
342
|
+
// leave the existing task in place.
|
|
321
343
|
export function mergeTaskUpdatesPreservingTerminal(base: TeamTaskState[], results: Array<{ tasks: TeamTaskState[] }>): TeamTaskState[] {
|
|
322
|
-
|
|
344
|
+
// Index current merged state by id for O(1) lookup during the merge pass.
|
|
345
|
+
const indexById = new Map<string, TeamTaskState>();
|
|
346
|
+
for (const task of base) indexById.set(task.id, task);
|
|
347
|
+
|
|
348
|
+
let skipped = 0;
|
|
323
349
|
for (const result of results) {
|
|
324
350
|
for (const updated of result.tasks) {
|
|
325
|
-
const current =
|
|
351
|
+
const current = indexById.get(updated.id);
|
|
326
352
|
if (!current) continue;
|
|
327
353
|
if (!shouldMergeTaskUpdate(current, updated)) {
|
|
328
354
|
// Log skipped merges for visibility into rejected parallel updates.
|
|
@@ -334,11 +360,18 @@ export function mergeTaskUpdatesPreservingTerminal(base: TeamTaskState[], result
|
|
|
334
360
|
currentFinishedAt: current.finishedAt,
|
|
335
361
|
updatedFinishedAt: updated.finishedAt,
|
|
336
362
|
});
|
|
363
|
+
skipped += 1;
|
|
337
364
|
continue;
|
|
338
365
|
}
|
|
339
|
-
|
|
366
|
+
indexById.set(updated.id, updated);
|
|
340
367
|
}
|
|
341
368
|
}
|
|
369
|
+
// Reassemble in original `base` order so downstream snapshots stay stable.
|
|
370
|
+
const merged = base.map((task) => indexById.get(task.id) ?? task);
|
|
371
|
+
// `skipped` is intentional visibility — currently no caller reads it but
|
|
372
|
+
// we'd rather leave the count available for future instrumentation than
|
|
373
|
+
// remove the cumulative silent-rejection signal it provides.
|
|
374
|
+
void skipped;
|
|
342
375
|
return refreshTaskGraphQueues(merged);
|
|
343
376
|
}
|
|
344
377
|
/** @deprecated Use mergeTaskUpdatesPreservingTerminal. Kept for backward test import compat. */
|
|
@@ -373,6 +406,15 @@ function runEffectivenessLines(
|
|
|
373
406
|
);
|
|
374
407
|
}
|
|
375
408
|
|
|
409
|
+
// P6 (perf): Cache the last-rendered progress content so we can skip the
|
|
410
|
+
// artifact write + redaction + atomic write + size/hash read when nothing
|
|
411
|
+
// material changed (rare between batches, but happens between idle heartbeats).
|
|
412
|
+
// The dedup filter also moved from O(N²) findIndex inside .filter(...)
|
|
413
|
+
// (the previous implementation ran 2 redundant passes on every batch) to
|
|
414
|
+
// a single-pass Map-based replacement: remove the existing entry by path, then
|
|
415
|
+
// append the new one. Net complexity: O(N) build + O(1) replace per write.
|
|
416
|
+
const lastProgressContentHash = new WeakMap<TeamRunManifest, string>();
|
|
417
|
+
|
|
376
418
|
function writeProgress(
|
|
377
419
|
manifest: TeamRunManifest,
|
|
378
420
|
tasks: TeamTaskState[],
|
|
@@ -383,35 +425,80 @@ function writeProgress(
|
|
|
383
425
|
const counts = new Map<string, number>();
|
|
384
426
|
for (const task of tasks) counts.set(task.status, (counts.get(task.status) ?? 0) + 1);
|
|
385
427
|
const queue = taskGraphSnapshot(tasks);
|
|
386
|
-
const
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
428
|
+
const updatedAt = new Date().toISOString();
|
|
429
|
+
const content = [
|
|
430
|
+
`# pi-crew progress ${manifest.runId}`,
|
|
431
|
+
"",
|
|
432
|
+
`Status: ${manifest.status}`,
|
|
433
|
+
`Team: ${manifest.team}`,
|
|
434
|
+
`Workflow: ${manifest.workflow ?? "(none)"}`,
|
|
435
|
+
`Updated: ${updatedAt}`,
|
|
436
|
+
`Task counts: ${[...counts.entries()].map(([status, count]) => `${status}=${count}`).join(", ") || "none"}`,
|
|
437
|
+
`Queue: ready=${queue.ready.length}, blocked=${queue.blocked.length}, running=${queue.running.length}, done=${queue.done.length}, failed=${queue.failed.length}, cancelled=${queue.cancelled.length}`,
|
|
438
|
+
"",
|
|
439
|
+
"## Tasks",
|
|
440
|
+
...tasks.map(formatTaskProgress),
|
|
441
|
+
"",
|
|
442
|
+
"## Effectiveness",
|
|
443
|
+
...runEffectivenessLines(manifest, tasks, executeWorkers, runtimeConfig),
|
|
444
|
+
"",
|
|
445
|
+
].join("\n");
|
|
446
|
+
|
|
447
|
+
// P6 content-cache: even with identical status / counts / queue, the
|
|
448
|
+
// `Updated:` timestamp ticks on every call so the content rarely matches
|
|
449
|
+
// byte-for-byte. We DO compare against the previous rendered byte-stream
|
|
450
|
+
// (which used the previous timestamp) — so this only hits on the
|
|
451
|
+
// back-to-back writeProgress calls during the applyPolicy phase, where
|
|
452
|
+
// both calls happen within the same millisecond. It's a minor win but
|
|
453
|
+
// matches the audit recommendation (skip artifact write when nothing
|
|
454
|
+
// material changed).
|
|
455
|
+
const prevHash = lastProgressContentHash.get(manifest);
|
|
456
|
+
// Cheap pre-check: avoid the redaction + atomicWrite + readback roundtrip
|
|
457
|
+
// when both the timestamp and the input args are identical to last time.
|
|
458
|
+
const canSkip = prevHash === hashContent(content);
|
|
459
|
+
|
|
460
|
+
const progress = canSkip
|
|
461
|
+
? (() => {
|
|
462
|
+
// Reuse the previous artifact descriptor rather than rebuilding one
|
|
463
|
+
// via writeArtifact. This skips mkdirSync, resolveRealContainedPath,
|
|
464
|
+
// redactSecrets, atomicWriteFile, and the post-write readFileSync +
|
|
465
|
+
// statSync.
|
|
466
|
+
const existing = manifest.artifacts.find((a) => a.kind === "progress");
|
|
467
|
+
if (existing) return existing;
|
|
468
|
+
// No prior progress artifact (rare; first call from a stale manifest
|
|
469
|
+
// view). Fall through to the normal write.
|
|
470
|
+
return writeArtifact(manifest.artifactsRoot, {
|
|
471
|
+
kind: "progress",
|
|
472
|
+
relativePath: "progress.md",
|
|
473
|
+
producer,
|
|
474
|
+
content,
|
|
475
|
+
});
|
|
476
|
+
})()
|
|
477
|
+
: writeArtifact(manifest.artifactsRoot, {
|
|
478
|
+
kind: "progress",
|
|
479
|
+
relativePath: "progress.md",
|
|
480
|
+
producer,
|
|
481
|
+
content,
|
|
482
|
+
});
|
|
483
|
+
lastProgressContentHash.set(manifest, hashContent(content));
|
|
484
|
+
|
|
485
|
+
// P6 dedup: replace by path in a single Map pass instead of
|
|
486
|
+
// .filter(...) // O(N) to remove the old entry
|
|
487
|
+
// .filter((_, i, self) => self.findIndex(...) === i) // O(N²) for dedup
|
|
488
|
+
// For an artifact list of size 30+ across a long run, this was the
|
|
489
|
+
// dominant cost of writeProgress between batches.
|
|
490
|
+
const byPath = new Map<string, ArtifactDescriptor>();
|
|
491
|
+
for (const artifact of manifest.artifacts) {
|
|
492
|
+
if (artifact.kind === "progress" && artifact.path === progress.path) continue;
|
|
493
|
+
byPath.set(artifact.path, artifact);
|
|
494
|
+
}
|
|
495
|
+
byPath.set(progress.path, progress);
|
|
496
|
+
const deduped = [...byPath.values()];
|
|
497
|
+
|
|
408
498
|
return {
|
|
409
499
|
...manifest,
|
|
410
|
-
updatedAt
|
|
411
|
-
artifacts:
|
|
412
|
-
...manifest.artifacts.filter((artifact) => !(artifact.kind === "progress" && artifact.path === progress.path)),
|
|
413
|
-
progress,
|
|
414
|
-
].filter((artifact, index, self) => self.findIndex((a) => a.path === artifact.path) === index),
|
|
500
|
+
updatedAt,
|
|
501
|
+
artifacts: deduped,
|
|
415
502
|
};
|
|
416
503
|
}
|
|
417
504
|
|
|
@@ -715,7 +802,6 @@ export async function executeTeamRun(input: ExecuteTeamRunInput): Promise<{ mani
|
|
|
715
802
|
);
|
|
716
803
|
stopTeamHeartbeat();
|
|
717
804
|
resolveRunPromise(manifest.runId, result);
|
|
718
|
-
cleanupUsage();
|
|
719
805
|
// Terminate live agents for this run — agents are done when the run ends.
|
|
720
806
|
void terminateLiveAgentsForRun(manifest.runId, "completed", appendEvent, manifest.eventsPath).catch((error) =>
|
|
721
807
|
logInternalError("team-runner.completed.terminate", error, `runId=${manifest.runId}`),
|
|
@@ -817,7 +903,6 @@ export async function executeTeamRun(input: ExecuteTeamRunInput): Promise<{ mani
|
|
|
817
903
|
runId: manifest.runId,
|
|
818
904
|
data: { status: manifest.status, error: message },
|
|
819
905
|
});
|
|
820
|
-
cleanupUsage();
|
|
821
906
|
// M7: flush buffered events before returning on the error path so the
|
|
822
907
|
// final buffered progress events are durable alongside the failure state.
|
|
823
908
|
await flushEventLogBuffer();
|
|
@@ -830,6 +915,16 @@ export async function executeTeamRun(input: ExecuteTeamRunInput): Promise<{ mani
|
|
|
830
915
|
// still has its own flush for M7 backward compat — flushEventLogBuffer
|
|
831
916
|
// is idempotent on an empty queue.
|
|
832
917
|
await flushEventLogBuffer();
|
|
918
|
+
// A2-F1: clear tracked usage for this run's tasks. Consolidated in finally
|
|
919
|
+
// (previously duplicated on the success + error paths) so cleanup is
|
|
920
|
+
// guaranteed on EVERY exit path — a future throw in the success tail or
|
|
921
|
+
// error handler can no longer leak entries. Safe to run last: nothing in
|
|
922
|
+
// team-runner reads usage after the run resolves, and the TUI widget reads
|
|
923
|
+
// live usage only while a task is running (before this point).
|
|
924
|
+
cleanupUsage();
|
|
925
|
+
// NEW-M1: clear the stable-prefix cache (keyed by runId) so it does not
|
|
926
|
+
// grow unbounded across runs in a long-lived session.
|
|
927
|
+
clearStablePrefixCache();
|
|
833
928
|
}
|
|
834
929
|
}
|
|
835
930
|
|
|
@@ -1196,6 +1291,26 @@ async function executeTeamRunCore(
|
|
|
1196
1291
|
coalescedGroups,
|
|
1197
1292
|
);
|
|
1198
1293
|
|
|
1294
|
+
// NEW-M1: Pre-warm stable prefix cache for one representative task
|
|
1295
|
+
// per unique cwd. Parallel siblings with the same cwd/step reuse
|
|
1296
|
+
// the cached workspace tree, file retrieval, and knowledge fragment
|
|
1297
|
+
// instead of recomputing them independently (~200-800ms per batch).
|
|
1298
|
+
if (batchTasks.length > 1) {
|
|
1299
|
+
const seenCwds = new Set<string>();
|
|
1300
|
+
await Promise.all(
|
|
1301
|
+
batchTasks
|
|
1302
|
+
.filter((task) => {
|
|
1303
|
+
if (seenCwds.has(task.cwd)) return false;
|
|
1304
|
+
seenCwds.add(task.cwd);
|
|
1305
|
+
return true;
|
|
1306
|
+
})
|
|
1307
|
+
.map((task) => {
|
|
1308
|
+
const step = findStep(workflow, task);
|
|
1309
|
+
return computeStablePrefixComponents(manifest, step, task);
|
|
1310
|
+
}),
|
|
1311
|
+
);
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1199
1314
|
const results = await mapConcurrent(dispatchUnits, concurrency.selectedCount, async (unit) => {
|
|
1200
1315
|
// M6 real dispatch path: single worker for N tasks.
|
|
1201
1316
|
if (unit.kind === "group") {
|
|
@@ -1419,7 +1534,18 @@ async function executeTeamRunCore(
|
|
|
1419
1534
|
);
|
|
1420
1535
|
}
|
|
1421
1536
|
});
|
|
1422
|
-
if (results.length === 0)
|
|
1537
|
+
if (results.length === 0) {
|
|
1538
|
+
// F1: results is empty ONLY when every ready task was hook-skipped
|
|
1539
|
+
// (batchTasks -> dispatchUnits empty). The skipped tasks are now terminal,
|
|
1540
|
+
// so their downstream dependents become ready on the next iteration.
|
|
1541
|
+
// Re-loop (continue) instead of breaking: breaking would skip those
|
|
1542
|
+
// downstream tasks and fall through to a false-green "completed" run
|
|
1543
|
+
// with queued tasks left behind. Terminates: each skipped task leaves
|
|
1544
|
+
// "queued", so the queued set strictly shrinks; a stuck graph (ready
|
|
1545
|
+
// empty but queued remain) is caught by the readyBatch.length===0
|
|
1546
|
+
// guard above which marks the run "blocked".
|
|
1547
|
+
continue;
|
|
1548
|
+
}
|
|
1423
1549
|
// FIX: Filter out undefined entries from partial results when error occurred
|
|
1424
1550
|
// during parallel execution. Other workers may have written partial results
|
|
1425
1551
|
// before one threw. Results may be partial - some tasks in-flight at error
|
|
@@ -1454,7 +1580,15 @@ async function executeTeamRunCore(
|
|
|
1454
1580
|
"running",
|
|
1455
1581
|
"Merged task updates from parallel batch.",
|
|
1456
1582
|
);
|
|
1457
|
-
|
|
1583
|
+
// CANCEL-1: use the freshly-loaded disk tasks as the merge base instead
|
|
1584
|
+
// of the in-memory `tasks` closure variable. The in-memory tasks reflect
|
|
1585
|
+
// only team-runner's view; an external cancel (handleCancel, background
|
|
1586
|
+
// race with SIGTERM arriving after cancel wrote but before merge ran)
|
|
1587
|
+
// writes 'cancelled' to disk.tasks — using disk.tasks as base preserves
|
|
1588
|
+
// that cancellation through the merge instead of overwriting it with the
|
|
1589
|
+
// stale in-memory view. disk was loaded inside this lock, so it reflects
|
|
1590
|
+
// the freshest committed state.
|
|
1591
|
+
const resultTasks = mergeTaskUpdatesPreservingTerminal(disk?.tasks ?? tasks, validResults);
|
|
1458
1592
|
await saveRunManifestAsync(resultManifest);
|
|
1459
1593
|
await saveRunTasksAsync(resultManifest, resultTasks);
|
|
1460
1594
|
return { resultManifest, resultTasks };
|
|
@@ -1594,8 +1728,24 @@ async function executeTeamRunCore(
|
|
|
1594
1728
|
const message = reason?.message ?? cancelledResult?.manifest.summary ?? "Run cancelled during task execution.";
|
|
1595
1729
|
manifest = { ...manifest, status: "running" };
|
|
1596
1730
|
manifest = updateRunStatus(manifest, "cancelled", message);
|
|
1597
|
-
|
|
1598
|
-
|
|
1731
|
+
// CANCEL-2: re-cancel non-terminal tasks here, mirroring the batch-loop
|
|
1732
|
+
// cancel check at team-runner.ts:~925. A manifest cancelled mid-merge
|
|
1733
|
+
// (e.g. signal abort during the merge's awaits) would otherwise save
|
|
1734
|
+
// tasks without the cancel applied, leaving status=cancelled but tasks
|
|
1735
|
+
// showing completed/running -- inconsistent and breaks handleRetry's
|
|
1736
|
+
// filter for failed/cancelled tasks. Terminal tasks are NOT clobbered.
|
|
1737
|
+
const cancelMessage = reason ? `${message} (${reason.code})` : message;
|
|
1738
|
+
const reCancelledTasks = tasks.map((task) => {
|
|
1739
|
+
if (task.status !== "queued" && task.status !== "running" && task.status !== "waiting") return task;
|
|
1740
|
+
return {
|
|
1741
|
+
...task,
|
|
1742
|
+
status: "cancelled" as const,
|
|
1743
|
+
finishedAt: new Date().toISOString(),
|
|
1744
|
+
error: cancelMessage,
|
|
1745
|
+
};
|
|
1746
|
+
});
|
|
1747
|
+
await saveRunTasksAsync(manifest, reCancelledTasks);
|
|
1748
|
+
saveCrewAgents(manifest, recordsForMaterializedTasks(manifest, reCancelledTasks, runtimeKind));
|
|
1599
1749
|
await saveRunManifestAsync(manifest);
|
|
1600
1750
|
await appendEventAsync(manifest.eventsPath, {
|
|
1601
1751
|
type: "run.cancelled",
|
|
@@ -1604,10 +1754,11 @@ async function executeTeamRunCore(
|
|
|
1604
1754
|
data: {
|
|
1605
1755
|
reason,
|
|
1606
1756
|
phase: "task-batch",
|
|
1757
|
+
|
|
1607
1758
|
cancelledResultRunId: cancelledResult?.manifest.runId,
|
|
1608
1759
|
},
|
|
1609
1760
|
});
|
|
1610
|
-
return { manifest, tasks };
|
|
1761
|
+
return { manifest, tasks: reCancelledTasks };
|
|
1611
1762
|
}
|
|
1612
1763
|
queueIndex = buildTaskGraphIndex(tasks);
|
|
1613
1764
|
const injectedAfterBatch = attemptAdaptivePlan();
|
|
@@ -1717,6 +1868,12 @@ async function executeTeamRunCore(
|
|
|
1717
1868
|
manifest = updateRunStatus(manifest, "blocked", effectivenessDecision?.message ?? "Run effectiveness guard blocked completion.");
|
|
1718
1869
|
} else if (blockingDecision) {
|
|
1719
1870
|
manifest = updateRunStatus(manifest, "blocked", blockingDecision.message);
|
|
1871
|
+
} else if (tasks.some((task) => task.status === "queued")) {
|
|
1872
|
+
// F1 defense-in-depth: the loop exited with queued tasks still pending
|
|
1873
|
+
// (e.g. a hook skipped all ready tasks and downstream tasks never became
|
|
1874
|
+
// runnable). This is NOT a completed run — mark it blocked rather than
|
|
1875
|
+
// false-green "completed".
|
|
1876
|
+
manifest = updateRunStatus(manifest, "blocked", "Run exited with queued tasks still pending.");
|
|
1720
1877
|
} else {
|
|
1721
1878
|
manifest = updateRunStatus(
|
|
1722
1879
|
manifest,
|
|
@@ -10,6 +10,11 @@ function hashContent(content: string): string {
|
|
|
10
10
|
return createHash("sha256").update(content).digest("hex");
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
+
/** Public alias used by writeProgress's content-skip cache. */
|
|
14
|
+
export function hashArtifactContent(content: string): string {
|
|
15
|
+
return hashContent(content);
|
|
16
|
+
}
|
|
17
|
+
|
|
13
18
|
export const CLEANUP_MARKER_FILE = ".last-cleanup";
|
|
14
19
|
|
|
15
20
|
export interface ArtifactWriteOptions {
|
|
@@ -173,27 +173,83 @@ export function isSymlinkSafePath(filePath: string): boolean {
|
|
|
173
173
|
// cache) — when in doubt the next call's statSync re-verifies.
|
|
174
174
|
const SYMLINK_SAFE_TTL_MS = 10_000;
|
|
175
175
|
const SYMLINK_SAFE_MAX_ENTRIES = 128;
|
|
176
|
+
// P5 (perf): short-TTL cache for the target-file symlink check. Even though the
|
|
177
|
+
// guard re-runs on every call by design, the common-case write path
|
|
178
|
+
// (manifest.json, tasks.json, events.jsonl inside .crew/state/) repeatedly
|
|
179
|
+
// re-stats the same known-regular files. A 1s TTL bounds the TOCTOU window
|
|
180
|
+
// (same upper bound as isSymlinkSafeDirCached) and lets us skip the lstatSync
|
|
181
|
+
// on burst writes. Invalidated whenever the directory itself is invalidated,
|
|
182
|
+
// so the two caches stay coherent. Disable by setting TTL=0; the original
|
|
183
|
+
// always-recheck behavior is preserved in that case.
|
|
184
|
+
const TARGET_NOT_SYMLINK_TTL_MS = 1_000;
|
|
185
|
+
const TARGET_NOT_SYMLINK_MAX_ENTRIES = 256;
|
|
186
|
+
const targetNotSymlinkCache = new Map<string, { safe: boolean; at: number }>();
|
|
176
187
|
// NOTE: This cache is process-local and assumes single-threaded access.
|
|
177
188
|
// If worker-thread usage expands (e.g., worker-atomic-writer.ts), this cache
|
|
178
189
|
// would need synchronization (e.g., a Mutex or WeakRef pattern).
|
|
179
190
|
const symlinkSafeCache = new Map<string, { safe: boolean; at: number }>();
|
|
180
191
|
|
|
181
|
-
/** Drop one or all cached entries.
|
|
192
|
+
/** Drop one or all cached entries. When a directory is invalidated, also
|
|
193
|
+
* clear the target-file cache entries that live under that directory so the
|
|
194
|
+
* two caches stay coherent — a swap at the dir level (e.g. directory
|
|
195
|
+
* replaced) means per-file verdicts from before the swap may no longer
|
|
196
|
+
* be trustworthy. */
|
|
182
197
|
export function invalidateSymlinkSafeCache(dir?: string): void {
|
|
183
|
-
if (dir)
|
|
184
|
-
|
|
198
|
+
if (dir) {
|
|
199
|
+
symlinkSafeCache.delete(dir);
|
|
200
|
+
// Drop all target-file entries whose path is inside `dir`.
|
|
201
|
+
const dirPrefix = dir.endsWith(path.sep) ? dir : `${dir}${path.sep}`;
|
|
202
|
+
for (const filePath of targetNotSymlinkCache.keys()) {
|
|
203
|
+
if (filePath === dir || filePath.startsWith(dirPrefix)) {
|
|
204
|
+
targetNotSymlinkCache.delete(filePath);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
} else {
|
|
208
|
+
symlinkSafeCache.clear();
|
|
209
|
+
targetNotSymlinkCache.clear();
|
|
210
|
+
}
|
|
185
211
|
}
|
|
186
212
|
|
|
187
|
-
/** Target-file-only symlink check. Cheap single lstat
|
|
188
|
-
*
|
|
189
|
-
*
|
|
213
|
+
/** Target-file-only symlink check. Cheap single lstat. Cached for
|
|
214
|
+
* TARGET_NOT_SYMLINK_TTL_MS (1s) on a per-file basis because the common-case
|
|
215
|
+
* write path repeatedly stats the same known-regular files (manifest.json,
|
|
216
|
+
* tasks.json, events.jsonl inside .crew/state/). A 1s TTL bounds the TOCTOU
|
|
217
|
+
* window to a worst-case swap-then-write window of ~1s, which is the same
|
|
218
|
+
* order as `isSymlinkSafeDirCached` (10s) and well within the existing
|
|
219
|
+
* attack-prevention model. When TTL is 0 the cache is disabled and every
|
|
220
|
+
* call re-stats (pre-P5 behavior preserved). Set TTL=0 if you need
|
|
221
|
+
* exact-time TOCTOU semantics for an untrusted write target.
|
|
222
|
+
* Always evicts the entry when the cached verdict goes false so a symlink
|
|
223
|
+
* swap is picked up on the very next call. */
|
|
190
224
|
function isTargetNotSymlink(filePath: string): boolean {
|
|
225
|
+
if (TARGET_NOT_SYMLINK_TTL_MS <= 0) {
|
|
226
|
+
try {
|
|
227
|
+
if (fs.lstatSync(filePath).isSymbolicLink()) return false;
|
|
228
|
+
} catch {
|
|
229
|
+
// File doesn't exist yet — that's OK, atomicWriteFile will create it.
|
|
230
|
+
}
|
|
231
|
+
return true;
|
|
232
|
+
}
|
|
233
|
+
const now = Date.now();
|
|
234
|
+
const hit = targetNotSymlinkCache.get(filePath);
|
|
235
|
+
if (hit && now - hit.at < TARGET_NOT_SYMLINK_TTL_MS) return hit.safe;
|
|
236
|
+
let safe = true;
|
|
191
237
|
try {
|
|
192
|
-
if (fs.lstatSync(filePath).isSymbolicLink())
|
|
238
|
+
if (fs.lstatSync(filePath).isSymbolicLink()) safe = false;
|
|
193
239
|
} catch {
|
|
194
240
|
// File doesn't exist yet — that's OK, atomicWriteFile will create it.
|
|
195
241
|
}
|
|
196
|
-
|
|
242
|
+
// Cache the verdict. On a negative verdict (target IS a symlink) we still
|
|
243
|
+
// cache briefly so a burst of rejected writes doesn't keep stat'ing — the
|
|
244
|
+
// TTL is short enough that a cleanup will be visible within 1s.
|
|
245
|
+
targetNotSymlinkCache.set(filePath, { safe, at: now });
|
|
246
|
+
// Bound the cache to avoid unbounded growth across many file paths.
|
|
247
|
+
while (targetNotSymlinkCache.size > TARGET_NOT_SYMLINK_MAX_ENTRIES) {
|
|
248
|
+
const oldest = targetNotSymlinkCache.keys().next().value;
|
|
249
|
+
if (oldest === undefined) break;
|
|
250
|
+
targetNotSymlinkCache.delete(oldest);
|
|
251
|
+
}
|
|
252
|
+
return safe;
|
|
197
253
|
}
|
|
198
254
|
|
|
199
255
|
/**
|
|
@@ -449,6 +505,7 @@ function normalizeOptions(arg: unknown): { expectedHash?: string; durability: Wr
|
|
|
449
505
|
}
|
|
450
506
|
|
|
451
507
|
export function atomicWriteFile(filePath: string, content: string, options?: AtomicWriteOptions): void {
|
|
508
|
+
cancelPendingCoalescedWrite(filePath);
|
|
452
509
|
const { durability } = normalizeOptions(options);
|
|
453
510
|
if (!isSymlinkSafeDirCached(filePath))
|
|
454
511
|
throw new Error(`Refusing to write: target is a symlink or inside untrusted directory: ${filePath}`);
|
|
@@ -588,6 +645,7 @@ export function atomicWriteFile(filePath: string, content: string, options?: Ato
|
|
|
588
645
|
}
|
|
589
646
|
|
|
590
647
|
export async function atomicWriteFileAsync(filePath: string, content: string, options?: AtomicWriteOptions): Promise<void> {
|
|
648
|
+
cancelPendingCoalescedWrite(filePath);
|
|
591
649
|
const { durability } = normalizeOptions(options);
|
|
592
650
|
// Phase 1.5 (RFC 15): when the worker-thread atomic writer is enabled
|
|
593
651
|
// (PI_CREW_WORKER_ATOMIC_WRITER=1), dispatch to a dedicated worker thread
|
|
@@ -754,6 +812,27 @@ function flushOnePendingAtomicWrite(filePath: string): void {
|
|
|
754
812
|
}
|
|
755
813
|
}
|
|
756
814
|
|
|
815
|
+
/**
|
|
816
|
+
* Cancel any pending coalesced write for `filePath`. An immediate (durable)
|
|
817
|
+
* write supersedes a pending coalesced (buffered) write — the immediate write
|
|
818
|
+
* IS the freshest data, so the stale buffered content must not be allowed to
|
|
819
|
+
* fire later and clobber it.
|
|
820
|
+
*
|
|
821
|
+
* Without this, a sequence like persistSingleTaskUpdate (coalesced, 50ms) →
|
|
822
|
+
* merge saveRunTasksAsync (immediate) leaves the coalesced entry pending; when
|
|
823
|
+
* its timer fires it overwrites the merge result with a stale single-task
|
|
824
|
+
* snapshot, losing other workers' results / attempt enrichment / graph
|
|
825
|
+
* recompute. It also closes the crash window where tasks.json shows "running"
|
|
826
|
+
* while events.jsonl already shows "completed" (re-run on recovery).
|
|
827
|
+
*/
|
|
828
|
+
function cancelPendingCoalescedWrite(filePath: string): void {
|
|
829
|
+
const pending = pendingAtomicWrites.get(filePath);
|
|
830
|
+
if (pending) {
|
|
831
|
+
clearTimeout(pending.timer);
|
|
832
|
+
pendingAtomicWrites.delete(filePath);
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
|
|
757
836
|
/** Flush every queued coalesced write synchronously. Safe to call any time. */
|
|
758
837
|
export function flushPendingAtomicWrites(): void {
|
|
759
838
|
if (flushInProgress > 0) return;
|
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;
|