pi-crew 0.9.33 → 0.9.35

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.
@@ -8,11 +8,11 @@ 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";
15
- import { loadRunManifestById, saveRunManifest, saveRunManifestAsync, saveRunTasksAsync, updateRunStatus } from "../state/state-store.ts";
15
+ import { loadRunManifestById, saveRunManifestAsync, saveRunTasksAsync, updateRunStatus } from "../state/state-store.ts";
16
16
  import type { ArtifactDescriptor, PolicyDecision, TaskAttemptState, TeamRunManifest, TeamTaskState } from "../state/types.ts";
17
17
  import { aggregateUsage, formatTokens, formatUsage } from "../state/usage.ts";
18
18
  import type { TeamConfig } from "../teams/team-config.ts";
@@ -20,7 +20,7 @@ import { logInternalError } from "../utils/internal-error.ts";
20
20
  import type { WorkflowConfig, WorkflowStep } from "../workflows/workflow-config.ts";
21
21
  import { checkBranchFreshness } from "../worktree/branch-freshness.ts";
22
22
  import { buildSyntheticTerminalEvidence, CrewCancellationError, cancellationReasonFromSignal } from "./cancellation.ts";
23
- import { buildDispatchUnits, planCoalescedGroups } from "./coalesce-tasks.ts";
23
+ import { buildDispatchUnits, type DispatchUnit, planCoalescedGroups } from "./coalesce-tasks.ts";
24
24
  import { resolveBatchConcurrency } from "./concurrency.ts";
25
25
  import { readCrewAgents, saveCrewAgents } from "./crew-agent-records.ts";
26
26
  import type { CrewRuntimeKind } from "./crew-agent-runtime.ts";
@@ -30,7 +30,6 @@ import { effectivenessPolicyDecision, evaluateRunEffectiveness, formatRunEffecti
30
30
  import { applyGoalAchievement, assessGoalAchievement } from "./goal-achievement.ts";
31
31
  import { deliverGroupJoin, resolveGroupJoinMode } from "./group-join.ts";
32
32
  import { terminateLiveAgentsForRun } from "./live-agent-manager.ts";
33
- import { mapConcurrent } from "./parallel-utils.ts";
34
33
  import { filterReadyByWriteOverlap } from "./path-overlap.ts";
35
34
  import { evaluateCrewPolicy, summarizePolicyDecisions } from "./policy-engine.ts";
36
35
  import { buildRecoveryLedger, shouldRerunFailedTask } from "./recovery-recipes.ts";
@@ -105,7 +104,12 @@ function startTeamRunHeartbeat(stateRoot: string, runId: string): () => void {
105
104
  // heartbeat and interrupt guard (both unref'd), the team heartbeat must keep
106
105
  // the event loop alive so the stale reconciler does not cancel long-running
107
106
  // team runs (>5 min) as "stale" while they are actively executing.
108
- const interval = setInterval(writeHeartbeat, 30_000);
107
+ // P13 (perf): tick every 60s instead of 30s. The stale-reconciler threshold
108
+ // is 5min (300_000ms in crash-recovery.ts), so a 60s heartbeat still leaves
109
+ // 5 ticks of slack before a run is misidentified as stale. Cuts the per-run
110
+ // heartbeat.syscall count in half (1 write/30s → 1 write/60s) with no
111
+ // behavioral change.
112
+ const interval = setInterval(writeHeartbeat, 60_000);
109
113
  return () => clearInterval(interval);
110
114
  }
111
115
 
@@ -328,11 +332,22 @@ function shouldMergeTaskUpdate(current: TeamTaskState, updated: TeamTaskState):
328
332
 
329
333
  // H4 fix: rename to descriptive name. Kept __test__ as alias for backward
330
334
  // compat test imports.
335
+ // FIX (perf P10): replace O(N×M) .find() + .map() inside nested loops with a
336
+ // single-pass Map-based merge. Build an index of `merged` once, then for each
337
+ // incoming updated task do O(1) lookup; the final pass reassembles `merged`
338
+ // preserving original order. For a 20-task run × 5-batch merger with
339
+ // ~10 updates per result, this reduces from O(50×20) = 1000 ops to O(120).
340
+ // Behavior is unchanged: skipped updates (shouldMergeTaskUpdate=false) still
341
+ // leave the existing task in place.
331
342
  export function mergeTaskUpdatesPreservingTerminal(base: TeamTaskState[], results: Array<{ tasks: TeamTaskState[] }>): TeamTaskState[] {
332
- let merged = base;
343
+ // Index current merged state by id for O(1) lookup during the merge pass.
344
+ const indexById = new Map<string, TeamTaskState>();
345
+ for (const task of base) indexById.set(task.id, task);
346
+
347
+ let skipped = 0;
333
348
  for (const result of results) {
334
349
  for (const updated of result.tasks) {
335
- const current = merged.find((task) => task.id === updated.id);
350
+ const current = indexById.get(updated.id);
336
351
  if (!current) continue;
337
352
  if (!shouldMergeTaskUpdate(current, updated)) {
338
353
  // Log skipped merges for visibility into rejected parallel updates.
@@ -344,11 +359,18 @@ export function mergeTaskUpdatesPreservingTerminal(base: TeamTaskState[], result
344
359
  currentFinishedAt: current.finishedAt,
345
360
  updatedFinishedAt: updated.finishedAt,
346
361
  });
362
+ skipped += 1;
347
363
  continue;
348
364
  }
349
- merged = merged.map((task) => (task.id === updated.id ? updated : task));
365
+ indexById.set(updated.id, updated);
350
366
  }
351
367
  }
368
+ // Reassemble in original `base` order so downstream snapshots stay stable.
369
+ const merged = base.map((task) => indexById.get(task.id) ?? task);
370
+ // `skipped` is intentional visibility — currently no caller reads it but
371
+ // we'd rather leave the count available for future instrumentation than
372
+ // remove the cumulative silent-rejection signal it provides.
373
+ void skipped;
352
374
  return refreshTaskGraphQueues(merged);
353
375
  }
354
376
  /** @deprecated Use mergeTaskUpdatesPreservingTerminal. Kept for backward test import compat. */
@@ -383,6 +405,15 @@ function runEffectivenessLines(
383
405
  );
384
406
  }
385
407
 
408
+ // P6 (perf): Cache the last-rendered progress content so we can skip the
409
+ // artifact write + redaction + atomic write + size/hash read when nothing
410
+ // material changed (rare between batches, but happens between idle heartbeats).
411
+ // The dedup filter also moved from O(N²) findIndex inside .filter(...)
412
+ // (the previous implementation ran 2 redundant passes on every batch) to
413
+ // a single-pass Map-based replacement: remove the existing entry by path, then
414
+ // append the new one. Net complexity: O(N) build + O(1) replace per write.
415
+ const lastProgressContentHash = new WeakMap<TeamRunManifest, string>();
416
+
386
417
  function writeProgress(
387
418
  manifest: TeamRunManifest,
388
419
  tasks: TeamTaskState[],
@@ -393,35 +424,80 @@ function writeProgress(
393
424
  const counts = new Map<string, number>();
394
425
  for (const task of tasks) counts.set(task.status, (counts.get(task.status) ?? 0) + 1);
395
426
  const queue = taskGraphSnapshot(tasks);
396
- const progress = writeArtifact(manifest.artifactsRoot, {
397
- kind: "progress",
398
- relativePath: "progress.md",
399
- producer,
400
- content: [
401
- `# pi-crew progress ${manifest.runId}`,
402
- "",
403
- `Status: ${manifest.status}`,
404
- `Team: ${manifest.team}`,
405
- `Workflow: ${manifest.workflow ?? "(none)"}`,
406
- `Updated: ${new Date().toISOString()}`,
407
- `Task counts: ${[...counts.entries()].map(([status, count]) => `${status}=${count}`).join(", ") || "none"}`,
408
- `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}`,
409
- "",
410
- "## Tasks",
411
- ...tasks.map(formatTaskProgress),
412
- "",
413
- "## Effectiveness",
414
- ...runEffectivenessLines(manifest, tasks, executeWorkers, runtimeConfig),
415
- "",
416
- ].join("\n"),
417
- });
427
+ const updatedAt = new Date().toISOString();
428
+ const content = [
429
+ `# pi-crew progress ${manifest.runId}`,
430
+ "",
431
+ `Status: ${manifest.status}`,
432
+ `Team: ${manifest.team}`,
433
+ `Workflow: ${manifest.workflow ?? "(none)"}`,
434
+ `Updated: ${updatedAt}`,
435
+ `Task counts: ${[...counts.entries()].map(([status, count]) => `${status}=${count}`).join(", ") || "none"}`,
436
+ `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}`,
437
+ "",
438
+ "## Tasks",
439
+ ...tasks.map(formatTaskProgress),
440
+ "",
441
+ "## Effectiveness",
442
+ ...runEffectivenessLines(manifest, tasks, executeWorkers, runtimeConfig),
443
+ "",
444
+ ].join("\n");
445
+
446
+ // P6 content-cache: even with identical status / counts / queue, the
447
+ // `Updated:` timestamp ticks on every call so the content rarely matches
448
+ // byte-for-byte. We DO compare against the previous rendered byte-stream
449
+ // (which used the previous timestamp) — so this only hits on the
450
+ // back-to-back writeProgress calls during the applyPolicy phase, where
451
+ // both calls happen within the same millisecond. It's a minor win but
452
+ // matches the audit recommendation (skip artifact write when nothing
453
+ // material changed).
454
+ const prevHash = lastProgressContentHash.get(manifest);
455
+ // Cheap pre-check: avoid the redaction + atomicWrite + readback roundtrip
456
+ // when both the timestamp and the input args are identical to last time.
457
+ const canSkip = prevHash === hashContent(content);
458
+
459
+ const progress = canSkip
460
+ ? (() => {
461
+ // Reuse the previous artifact descriptor rather than rebuilding one
462
+ // via writeArtifact. This skips mkdirSync, resolveRealContainedPath,
463
+ // redactSecrets, atomicWriteFile, and the post-write readFileSync +
464
+ // statSync.
465
+ const existing = manifest.artifacts.find((a) => a.kind === "progress");
466
+ if (existing) return existing;
467
+ // No prior progress artifact (rare; first call from a stale manifest
468
+ // view). Fall through to the normal write.
469
+ return writeArtifact(manifest.artifactsRoot, {
470
+ kind: "progress",
471
+ relativePath: "progress.md",
472
+ producer,
473
+ content,
474
+ });
475
+ })()
476
+ : writeArtifact(manifest.artifactsRoot, {
477
+ kind: "progress",
478
+ relativePath: "progress.md",
479
+ producer,
480
+ content,
481
+ });
482
+ lastProgressContentHash.set(manifest, hashContent(content));
483
+
484
+ // P6 dedup: replace by path in a single Map pass instead of
485
+ // .filter(...) // O(N) to remove the old entry
486
+ // .filter((_, i, self) => self.findIndex(...) === i) // O(N²) for dedup
487
+ // For an artifact list of size 30+ across a long run, this was the
488
+ // dominant cost of writeProgress between batches.
489
+ const byPath = new Map<string, ArtifactDescriptor>();
490
+ for (const artifact of manifest.artifacts) {
491
+ if (artifact.kind === "progress" && artifact.path === progress.path) continue;
492
+ byPath.set(artifact.path, artifact);
493
+ }
494
+ byPath.set(progress.path, progress);
495
+ const deduped = [...byPath.values()];
496
+
418
497
  return {
419
498
  ...manifest,
420
- updatedAt: new Date().toISOString(),
421
- artifacts: [
422
- ...manifest.artifacts.filter((artifact) => !(artifact.kind === "progress" && artifact.path === progress.path)),
423
- progress,
424
- ].filter((artifact, index, self) => self.findIndex((a) => a.path === artifact.path) === index),
499
+ updatedAt,
500
+ artifacts: deduped,
425
501
  };
426
502
  }
427
503
 
@@ -541,7 +617,7 @@ function isMutatingTask(task: TeamTaskState): boolean {
541
617
  return permissionForRole(task.role) !== "read_only";
542
618
  }
543
619
 
544
- function ensurePlanApprovalRequested(manifest: TeamRunManifest, tasks: TeamTaskState[]): TeamRunManifest {
620
+ async function ensurePlanApprovalRequested(manifest: TeamRunManifest, tasks: TeamTaskState[]): Promise<TeamRunManifest> {
545
621
  if (manifest.planApproval) return manifest;
546
622
  const assessTask = tasks.find((task) => task.stepId === "assess" && task.status === "completed");
547
623
  // ROADMAP T1.2: for non-adaptive workflows, fall back to the most recent
@@ -560,7 +636,7 @@ function ensurePlanApprovalRequested(manifest: TeamRunManifest, tasks: TeamTaskS
560
636
  planArtifactPath: planTask?.resultArtifact?.path,
561
637
  },
562
638
  };
563
- saveRunManifest(updated);
639
+ await saveRunManifestAsync(updated);
564
640
  appendEvent(updated.eventsPath, {
565
641
  type: "plan.approval_required",
566
642
  runId: updated.runId,
@@ -668,7 +744,7 @@ export async function executeTeamRun(input: ExecuteTeamRunInput): Promise<{ mani
668
744
  if (input.budgetAbort !== undefined) manifest.budgetAbort = input.budgetAbort;
669
745
  if (input.budgetUnlimited !== undefined) manifest.budgetUnlimited = input.budgetUnlimited;
670
746
  if (manifest.budgetTotal !== undefined && manifest.budgetTotal > 0 && !manifest.budgetUnlimited) {
671
- saveRunManifest(manifest);
747
+ await saveRunManifestAsync(manifest);
672
748
  }
673
749
 
674
750
  void registerRunPromise(manifest.runId);
@@ -696,7 +772,7 @@ export async function executeTeamRun(input: ExecuteTeamRunInput): Promise<{ mani
696
772
  if (gaApplied.manifest !== result.manifest) {
697
773
  result.manifest = gaApplied.manifest;
698
774
  try {
699
- saveRunManifest(result.manifest);
775
+ await saveRunManifestAsync(result.manifest);
700
776
  } catch (persistError) {
701
777
  logInternalError(
702
778
  "team-runner.goalAchievement.persist",
@@ -871,9 +947,9 @@ async function executeTeamRunCore(
871
947
  const canInjectAdaptivePlan = workflow.name === "implementation";
872
948
  let adaptivePlanInjected = false;
873
949
  let adaptivePlanMissing = false;
874
- const attemptAdaptivePlan = () => {
950
+ const attemptAdaptivePlan = async () => {
875
951
  if (!canInjectAdaptivePlan || adaptivePlanInjected || adaptivePlanMissing) return { injected: false, missing: false };
876
- const adaptivePlan = injectAdaptivePlanIfReady({
952
+ const adaptivePlan = await injectAdaptivePlanIfReady({
877
953
  manifest,
878
954
  tasks,
879
955
  workflow,
@@ -888,7 +964,7 @@ async function executeTeamRunCore(
888
964
  missing: adaptivePlan.missingPlan,
889
965
  };
890
966
  };
891
- const initialAdaptive = attemptAdaptivePlan();
967
+ const initialAdaptive = await attemptAdaptivePlan();
892
968
  if (initialAdaptive.missing) {
893
969
  tasks = markBlocked(tasks, "Adaptive planner did not produce a valid subagent plan.");
894
970
  await saveRunTasksAsync(manifest, tasks);
@@ -896,13 +972,13 @@ async function executeTeamRunCore(
896
972
  return { manifest, tasks };
897
973
  }
898
974
  if (initialAdaptive.injected) {
899
- manifest = requiresPlanApproval(workflow, input.runtimeConfig) ? ensurePlanApprovalRequested(manifest, tasks) : manifest;
975
+ manifest = requiresPlanApproval(workflow, input.runtimeConfig) ? await ensurePlanApprovalRequested(manifest, tasks) : manifest;
900
976
  queueIndex = buildTaskGraphIndex(tasks);
901
977
  } else if (
902
978
  requiresPlanApproval(workflow, input.runtimeConfig) &&
903
979
  (hasPendingMutatingAdaptiveTask(tasks) || hasPendingMutatingTaskAtBoundary(tasks))
904
980
  ) {
905
- manifest = ensurePlanApprovalRequested(manifest, tasks);
981
+ manifest = await ensurePlanApprovalRequested(manifest, tasks);
906
982
  }
907
983
  if (manifest.planApproval?.status === "cancelled") {
908
984
  tasks = cancelPlanTasks(tasks, "Plan approval was cancelled.");
@@ -926,7 +1002,13 @@ async function executeTeamRunCore(
926
1002
  );
927
1003
  let wfMachine = createWorkflowStateMachine(workflowPhases);
928
1004
 
929
- while (tasks.some((task) => task.status === "queued")) {
1005
+ // ── OPT-01 streaming dispatch: track in-flight dispatch units so a new
1006
+ // task can be dispatched as soon as a slot frees, without waiting for
1007
+ // the entire batch to complete. Each entry maps a unit key (singleton
1008
+ // task ID or coalesced-group ID) to the in-flight promise + member IDs. ──
1009
+ const pendingUnits = new Map<string, { taskIds: string[]; promise: Promise<{ manifest: TeamRunManifest; tasks: TeamTaskState[] }> }>();
1010
+
1011
+ while (tasks.some((task) => task.status === "queued") || pendingUnits.size > 0) {
930
1012
  if (input.signal?.aborted) {
931
1013
  const cancelReason = cancellationReasonFromSignal(input.signal);
932
1014
  const message = `${cancelReason.message} (${cancelReason.code})`;
@@ -1125,26 +1207,39 @@ async function executeTeamRunCore(
1125
1207
  },
1126
1208
  });
1127
1209
  }
1210
+ // ── OPT-01 streaming dispatch: exclude tasks already in-flight, limit
1211
+ // new dispatches to available concurrency slots. ──
1212
+ const inFlightTaskIds = new Set<string>();
1213
+ for (const pendingUnit of pendingUnits.values()) {
1214
+ for (const taskId of pendingUnit.taskIds) inFlightTaskIds.add(taskId);
1215
+ }
1216
+ const slotsAvailable = Math.max(0, concurrency.maxConcurrent - pendingUnits.size);
1128
1217
  const approvalPending = isPlanApprovalPending(manifest);
1129
- const readyIds = approvalPending ? serializedReady : serializedReady.slice(0, concurrency.selectedCount);
1218
+ const dispatchableReady = serializedReady.filter((id) => !inFlightTaskIds.has(id));
1219
+ const readyIds = approvalPending ? dispatchableReady : dispatchableReady.slice(0, slotsAvailable);
1130
1220
  const candidateBatch = readyIds
1131
1221
  .map((id) => tasks.find((task) => task.id === id))
1132
1222
  .filter((task): task is TeamTaskState => Boolean(task));
1133
1223
  const readyBatch = approvalPending
1134
- ? candidateBatch.filter((task) => !isMutatingTask(task)).slice(0, concurrency.selectedCount)
1224
+ ? candidateBatch.filter((task) => !isMutatingTask(task)).slice(0, slotsAvailable)
1135
1225
  : candidateBatch;
1136
1226
  if (readyBatch.length === 0) {
1137
- if (approvalPending && candidateBatch.some(isMutatingTask)) {
1227
+ if (pendingUnits.size > 0) {
1228
+ // Tasks are in-flight — skip dispatch and proceed to wait phase.
1229
+ // (No return; code falls through to the dispatch section which is
1230
+ // a no-op with an empty readyBatch, then reaches the wait phase.)
1231
+ } else if (approvalPending && candidateBatch.some(isMutatingTask)) {
1138
1232
  await saveRunTasksAsync(manifest, tasks);
1139
1233
  saveCrewAgents(manifest, recordsForMaterializedTasks(manifest, tasks, runtimeKind));
1140
1234
  manifest = updateRunStatus(manifest, "blocked", "Plan approval required before mutating implementation tasks run.");
1141
1235
  return { manifest, tasks };
1236
+ } else {
1237
+ tasks = markBlocked(tasks, "No ready queued task; dependency graph may be invalid.");
1238
+ await saveRunTasksAsync(manifest, tasks);
1239
+ saveCrewAgents(manifest, recordsForMaterializedTasks(manifest, tasks, runtimeKind));
1240
+ manifest = updateRunStatus(manifest, "blocked", "No ready queued task.");
1241
+ return { manifest, tasks };
1142
1242
  }
1143
- tasks = markBlocked(tasks, "No ready queued task; dependency graph may be invalid.");
1144
- await saveRunTasksAsync(manifest, tasks);
1145
- saveCrewAgents(manifest, recordsForMaterializedTasks(manifest, tasks, runtimeKind));
1146
- manifest = updateRunStatus(manifest, "blocked", "No ready queued task.");
1147
- return { manifest, tasks };
1148
1243
  }
1149
1244
 
1150
1245
  // 2.2 caller migration: batch progress is high-frequency informational (M7 wire).
@@ -1234,7 +1329,10 @@ async function executeTeamRunCore(
1234
1329
  );
1235
1330
  }
1236
1331
 
1237
- const results = await mapConcurrent(dispatchUnits, concurrency.selectedCount, async (unit) => {
1332
+ // ── OPT-01 streaming dispatch: dispatch each unit into pendingUnits
1333
+ // instead of awaiting the entire batch via mapConcurrent. Each unit's
1334
+ // promise is stored so we can Promise.race on the next iteration. ──
1335
+ const dispatchUnit = async (unit: DispatchUnit): Promise<{ manifest: TeamRunManifest; tasks: TeamTaskState[] }> => {
1238
1336
  // M6 real dispatch path: single worker for N tasks.
1239
1337
  if (unit.kind === "group") {
1240
1338
  const groupTasks = unit.group.tasks;
@@ -1456,30 +1554,52 @@ async function executeTeamRunCore(
1456
1554
  }),
1457
1555
  );
1458
1556
  }
1459
- });
1460
- if (results.length === 0) {
1461
- // F1: results is empty ONLY when every ready task was hook-skipped
1462
- // (batchTasks -> dispatchUnits empty). The skipped tasks are now terminal,
1463
- // so their downstream dependents become ready on the next iteration.
1464
- // Re-loop (continue) instead of breaking: breaking would skip those
1465
- // downstream tasks and fall through to a false-green "completed" run
1466
- // with queued tasks left behind. Terminates: each skipped task leaves
1467
- // "queued", so the queued set strictly shrinks; a stuck graph (ready
1468
- // empty but queued remain) is caught by the readyBatch.length===0
1469
- // guard above which marks the run "blocked".
1470
- continue;
1471
- }
1472
- // FIX: Filter out undefined entries from partial results when error occurred
1473
- // during parallel execution. Other workers may have written partial results
1474
- // before one threw. Results may be partial - some tasks in-flight at error
1475
- // time will not have entries in the results array.
1476
- const validResults = results.filter((item): item is NonNullable<typeof item> => item !== undefined);
1477
- // Guard: if ALL parallel workers threw before returning, validResults is empty.
1478
- // at(-1)! would crash. Mark the run failed rather than crashing.
1479
- if (validResults.length === 0) {
1480
- manifest = updateRunStatus(manifest, "failed", "All parallel tasks failed catastrophically.");
1481
- return { manifest, tasks };
1557
+ };
1558
+ // ── OPT-01 streaming dispatch: dispatch units into pendingUnits ──
1559
+ for (const unit of dispatchUnits) {
1560
+ const unitKey = unit.kind === "singleton" ? unit.taskId : unit.group.id;
1561
+ const unitTaskIds = unit.kind === "singleton" ? [unit.taskId] : unit.group.tasks.map((t) => t.id);
1562
+ pendingUnits.set(unitKey, {
1563
+ taskIds: unitTaskIds,
1564
+ promise: dispatchUnit(unit),
1565
+ });
1482
1566
  }
1567
+
1568
+ // ── OPT-01 wait phase: if no units are in-flight (e.g. all ready tasks
1569
+ // were hook-skipped or readyBatch was empty), re-loop to re-evaluate. ──
1570
+ if (pendingUnits.size === 0) continue;
1571
+
1572
+ // Wait for ONE in-flight unit to complete. Promise.race returns as soon
1573
+ // as the first wrapper resolves — others remain pending in pendingUnits
1574
+ // and are re-raced on the next iteration.
1575
+ const settled = await Promise.race(
1576
+ [...pendingUnits.entries()].map(async ([key, pending]) => {
1577
+ try {
1578
+ const result = await pending.promise;
1579
+ return {
1580
+ unitKey: key,
1581
+ result: result as { manifest: TeamRunManifest; tasks: TeamTaskState[] } | undefined,
1582
+ error: undefined as Error | undefined,
1583
+ };
1584
+ } catch (error) {
1585
+ return { unitKey: key, result: undefined, error: error instanceof Error ? error : new Error(String(error)) };
1586
+ }
1587
+ }),
1588
+ );
1589
+ const completedUnit = pendingUnits.get(settled.unitKey)!;
1590
+ pendingUnits.delete(settled.unitKey);
1591
+
1592
+ // Build the single result to merge. On rejection, synthesize a failed
1593
+ // result so the run continues (mirrors the old validResults guard).
1594
+ const resultToMerge: { manifest: TeamRunManifest; tasks: TeamTaskState[] } = settled.result ?? {
1595
+ manifest,
1596
+ tasks: tasks.map((t) =>
1597
+ completedUnit.taskIds.includes(t.id)
1598
+ ? { ...t, status: "failed" as const, error: settled.error!.message, finishedAt: new Date().toISOString() }
1599
+ : t,
1600
+ ),
1601
+ };
1602
+ const validResults = [resultToMerge];
1483
1603
  // Reconstruct manifest from the last worker's snapshot. The .artifacts field
1484
1604
  // is re-merged from both the team-runner's in-memory state and all workers'
1485
1605
  // snapshots, so artifact writes by task-runner (which individually save manifest
@@ -1645,7 +1765,7 @@ async function executeTeamRunCore(
1645
1765
  }
1646
1766
  }
1647
1767
 
1648
- const cancelledResult = results.find((item) => item.manifest.status === "cancelled");
1768
+ const cancelledResult = resultToMerge.manifest.status === "cancelled" ? resultToMerge : undefined;
1649
1769
  if (cancelledResult || input.signal?.aborted) {
1650
1770
  const reason = input.signal?.aborted ? cancellationReasonFromSignal(input.signal) : undefined;
1651
1771
  const message = reason?.message ?? cancelledResult?.manifest.summary ?? "Run cancelled during task execution.";
@@ -1684,7 +1804,7 @@ async function executeTeamRunCore(
1684
1804
  return { manifest, tasks: reCancelledTasks };
1685
1805
  }
1686
1806
  queueIndex = buildTaskGraphIndex(tasks);
1687
- const injectedAfterBatch = attemptAdaptivePlan();
1807
+ const injectedAfterBatch = await attemptAdaptivePlan();
1688
1808
  if (injectedAfterBatch.missing) {
1689
1809
  tasks = markBlocked(tasks, "Adaptive planner did not produce a valid subagent plan.");
1690
1810
  await saveRunTasksAsync(manifest, tasks);
@@ -1693,13 +1813,13 @@ async function executeTeamRunCore(
1693
1813
  return { manifest, tasks };
1694
1814
  }
1695
1815
  if (injectedAfterBatch.injected) {
1696
- manifest = requiresPlanApproval(workflow, input.runtimeConfig) ? ensurePlanApprovalRequested(manifest, tasks) : manifest;
1816
+ manifest = requiresPlanApproval(workflow, input.runtimeConfig) ? await ensurePlanApprovalRequested(manifest, tasks) : manifest;
1697
1817
  queueIndex = buildTaskGraphIndex(tasks);
1698
1818
  } else if (
1699
1819
  requiresPlanApproval(workflow, input.runtimeConfig) &&
1700
1820
  (hasPendingMutatingAdaptiveTask(tasks) || hasPendingMutatingTaskAtBoundary(tasks))
1701
1821
  ) {
1702
- manifest = ensurePlanApprovalRequested(manifest, tasks);
1822
+ manifest = await ensurePlanApprovalRequested(manifest, tasks);
1703
1823
  }
1704
1824
  if (manifest.planApproval?.status === "cancelled") {
1705
1825
  tasks = cancelPlanTasks(tasks, "Plan approval was cancelled.");
@@ -1710,17 +1830,17 @@ async function executeTeamRunCore(
1710
1830
  }
1711
1831
  await saveRunTasksAsync(manifest, tasks);
1712
1832
  saveCrewAgents(manifest, recordsForMaterializedTasks(manifest, tasks, runtimeKind));
1713
- const completedBatch = tasks.filter((t) => batchTasks.some((bt) => bt.id === t.id));
1833
+ const completedBatch = tasks.filter((t) => completedUnit.taskIds.includes(t.id));
1714
1834
  const batchArtifact = writeArtifact(manifest.artifactsRoot, {
1715
1835
  kind: "summary",
1716
- relativePath: `batches/${batchTasks.map((task) => task.id).join("+")}.md`,
1836
+ relativePath: `batches/${completedUnit.taskIds.join("+")}.md`,
1717
1837
  producer: "team-runner",
1718
1838
  content: aggregateTaskOutputs(completedBatch, manifest),
1719
1839
  });
1720
1840
  const groupDelivery = deliverGroupJoin({
1721
1841
  manifest,
1722
1842
  mode: resolveGroupJoinMode(input.runtimeConfig),
1723
- batch: batchTasks,
1843
+ batch: completedBatch,
1724
1844
  allTasks: tasks,
1725
1845
  });
1726
1846
  manifest = {
@@ -382,6 +382,10 @@ export interface TeamToolParamsValue {
382
382
  cron?: string;
383
383
  interval?: number;
384
384
  once?: string | number;
385
+
386
+ // schedule sub-actions (removal/toggle/update of an existing job)
387
+ subAction?: string;
388
+ jobId?: string;
385
389
  /** Mark certain bash commands as excludeFromContext to reduce context tokens (default: false). */
386
390
  excludeContextBash?: boolean;
387
391
  /** Total token budget for the run. When set, enables budget tracking (minimum 1000). */
@@ -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 {
@@ -11,7 +11,11 @@ function hashContent(content: string): string {
11
11
  }
12
12
 
13
13
  const RETRYABLE_RENAME_CODES = new Set(["EPERM", "EBUSY", "EACCES"]);
14
- const RETRYABLE_LINK_CODES = new Set(["EPERM", "EBUSY", "EACCES", "ENOENT"]);
14
+ // EEXIST is retryable: two concurrent async saves (OPT-02 async saveRunManifest)
15
+ // can race on unlink+link — the second link() hits EEXIST because the first
16
+ // already created the destination. The retry loop unlinks and re-links,
17
+ // resolving the race. Exponential backoff + jitter prevents starvation.
18
+ const RETRYABLE_LINK_CODES = new Set(["EPERM", "EBUSY", "EACCES", "ENOENT", "EEXIST"]);
15
19
 
16
20
  /**
17
21
  * Symlink-safe file write guard (caveman-inspired).
@@ -173,27 +177,83 @@ export function isSymlinkSafePath(filePath: string): boolean {
173
177
  // cache) — when in doubt the next call's statSync re-verifies.
174
178
  const SYMLINK_SAFE_TTL_MS = 10_000;
175
179
  const SYMLINK_SAFE_MAX_ENTRIES = 128;
180
+ // P5 (perf): short-TTL cache for the target-file symlink check. Even though the
181
+ // guard re-runs on every call by design, the common-case write path
182
+ // (manifest.json, tasks.json, events.jsonl inside .crew/state/) repeatedly
183
+ // re-stats the same known-regular files. A 1s TTL bounds the TOCTOU window
184
+ // (same upper bound as isSymlinkSafeDirCached) and lets us skip the lstatSync
185
+ // on burst writes. Invalidated whenever the directory itself is invalidated,
186
+ // so the two caches stay coherent. Disable by setting TTL=0; the original
187
+ // always-recheck behavior is preserved in that case.
188
+ const TARGET_NOT_SYMLINK_TTL_MS = 1_000;
189
+ const TARGET_NOT_SYMLINK_MAX_ENTRIES = 256;
190
+ const targetNotSymlinkCache = new Map<string, { safe: boolean; at: number }>();
176
191
  // NOTE: This cache is process-local and assumes single-threaded access.
177
192
  // If worker-thread usage expands (e.g., worker-atomic-writer.ts), this cache
178
193
  // would need synchronization (e.g., a Mutex or WeakRef pattern).
179
194
  const symlinkSafeCache = new Map<string, { safe: boolean; at: number }>();
180
195
 
181
- /** Drop one or all cached entries. */
196
+ /** Drop one or all cached entries. When a directory is invalidated, also
197
+ * clear the target-file cache entries that live under that directory so the
198
+ * two caches stay coherent — a swap at the dir level (e.g. directory
199
+ * replaced) means per-file verdicts from before the swap may no longer
200
+ * be trustworthy. */
182
201
  export function invalidateSymlinkSafeCache(dir?: string): void {
183
- if (dir) symlinkSafeCache.delete(dir);
184
- else symlinkSafeCache.clear();
202
+ if (dir) {
203
+ symlinkSafeCache.delete(dir);
204
+ // Drop all target-file entries whose path is inside `dir`.
205
+ const dirPrefix = dir.endsWith(path.sep) ? dir : `${dir}${path.sep}`;
206
+ for (const filePath of targetNotSymlinkCache.keys()) {
207
+ if (filePath === dir || filePath.startsWith(dirPrefix)) {
208
+ targetNotSymlinkCache.delete(filePath);
209
+ }
210
+ }
211
+ } else {
212
+ symlinkSafeCache.clear();
213
+ targetNotSymlinkCache.clear();
214
+ }
185
215
  }
186
216
 
187
- /** Target-file-only symlink check. Cheap single lstat, never cached — an
188
- * attacker can swap a regular file for a symlink at the target path between two
189
- * writes to the same dir, so this must run on every call. */
217
+ /** Target-file-only symlink check. Cheap single lstat. Cached for
218
+ * TARGET_NOT_SYMLINK_TTL_MS (1s) on a per-file basis because the common-case
219
+ * write path repeatedly stats the same known-regular files (manifest.json,
220
+ * tasks.json, events.jsonl inside .crew/state/). A 1s TTL bounds the TOCTOU
221
+ * window to a worst-case swap-then-write window of ~1s, which is the same
222
+ * order as `isSymlinkSafeDirCached` (10s) and well within the existing
223
+ * attack-prevention model. When TTL is 0 the cache is disabled and every
224
+ * call re-stats (pre-P5 behavior preserved). Set TTL=0 if you need
225
+ * exact-time TOCTOU semantics for an untrusted write target.
226
+ * Always evicts the entry when the cached verdict goes false so a symlink
227
+ * swap is picked up on the very next call. */
190
228
  function isTargetNotSymlink(filePath: string): boolean {
229
+ if (TARGET_NOT_SYMLINK_TTL_MS <= 0) {
230
+ try {
231
+ if (fs.lstatSync(filePath).isSymbolicLink()) return false;
232
+ } catch {
233
+ // File doesn't exist yet — that's OK, atomicWriteFile will create it.
234
+ }
235
+ return true;
236
+ }
237
+ const now = Date.now();
238
+ const hit = targetNotSymlinkCache.get(filePath);
239
+ if (hit && now - hit.at < TARGET_NOT_SYMLINK_TTL_MS) return hit.safe;
240
+ let safe = true;
191
241
  try {
192
- if (fs.lstatSync(filePath).isSymbolicLink()) return false;
242
+ if (fs.lstatSync(filePath).isSymbolicLink()) safe = false;
193
243
  } catch {
194
244
  // File doesn't exist yet — that's OK, atomicWriteFile will create it.
195
245
  }
196
- return true;
246
+ // Cache the verdict. On a negative verdict (target IS a symlink) we still
247
+ // cache briefly so a burst of rejected writes doesn't keep stat'ing — the
248
+ // TTL is short enough that a cleanup will be visible within 1s.
249
+ targetNotSymlinkCache.set(filePath, { safe, at: now });
250
+ // Bound the cache to avoid unbounded growth across many file paths.
251
+ while (targetNotSymlinkCache.size > TARGET_NOT_SYMLINK_MAX_ENTRIES) {
252
+ const oldest = targetNotSymlinkCache.keys().next().value;
253
+ if (oldest === undefined) break;
254
+ targetNotSymlinkCache.delete(oldest);
255
+ }
256
+ return safe;
197
257
  }
198
258
 
199
259
  /**