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.
- package/CHANGELOG.md +211 -0
- package/dist/build-meta.json +257 -261
- package/dist/index.mjs +2127 -1937
- package/dist/index.mjs.map +4 -4
- package/docs/performance-optimization-execution-plan.md +520 -0
- package/package.json +1 -1
- package/src/extension/team-tool/api.ts +8 -8
- package/src/extension/team-tool/goal-wrap.ts +2 -2
- package/src/extension/team-tool/goal.ts +2 -2
- package/src/extension/team-tool/handle-schedule.ts +102 -0
- package/src/extension/team-tool/run.ts +0 -42
- package/src/extension/team-tool.ts +13 -8
- package/src/observability/exporters/otlp-exporter.ts +24 -2
- package/src/runtime/adaptive-plan.ts +3 -3
- package/src/runtime/background-runner.ts +4 -4
- package/src/runtime/child-pi.ts +191 -56
- package/src/runtime/live-session-runtime.ts +39 -18
- package/src/runtime/parallel-utils.ts +5 -0
- package/src/runtime/skill-instructions.ts +61 -2
- package/src/runtime/stale-reconciler.ts +5 -0
- package/src/runtime/task-graph-scheduler.ts +48 -17
- package/src/runtime/task-runner/prompt-builder.ts +55 -1
- package/src/runtime/task-runner/state-helpers.ts +18 -1
- package/src/runtime/task-runner/tail-read.ts +12 -0
- package/src/runtime/task-runner.ts +40 -19
- package/src/runtime/team-runner.ts +204 -84
- package/src/schema/team-tool-schema.ts +4 -0
- package/src/state/artifact-store.ts +5 -0
- package/src/state/atomic-write.ts +69 -9
- package/src/state/state-store.ts +107 -30
- package/src/utils/token-counter.ts +168 -35
- package/workflows/pipeline.workflow.md +13 -12
|
@@ -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,
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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,
|
|
1224
|
+
? candidateBatch.filter((task) => !isMutatingTask(task)).slice(0, slotsAvailable)
|
|
1135
1225
|
: candidateBatch;
|
|
1136
1226
|
if (readyBatch.length === 0) {
|
|
1137
|
-
if (
|
|
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
|
-
|
|
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
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
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 =
|
|
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) =>
|
|
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/${
|
|
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:
|
|
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
|
-
|
|
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)
|
|
184
|
-
|
|
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
|
|
188
|
-
*
|
|
189
|
-
*
|
|
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())
|
|
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
|
-
|
|
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
|
/**
|