pi-crew 0.9.34 → 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.
@@ -151,8 +151,20 @@ type LiveSessionLike = {
151
151
 
152
152
  function appendTranscript(filePath: string | undefined, event: unknown): void {
153
153
  if (!filePath) return;
154
- fs.mkdirSync(path.dirname(filePath), { recursive: true });
155
- fs.appendFileSync(filePath, `${JSON.stringify(redactSecrets(event))}\n`, "utf-8");
154
+ // Fire-and-forget async write to avoid blocking the event loop.
155
+ // Transcript writes are best-effort telemetry — callers do not need to await.
156
+ void appendTranscriptAsync(filePath, event);
157
+ }
158
+
159
+ /** Async version of appendTranscript — fire-and-forget for non-blocking writes. */
160
+ async function appendTranscriptAsync(filePath: string, event: unknown): Promise<void> {
161
+ try {
162
+ await fs.promises.mkdir(path.dirname(filePath), { recursive: true });
163
+ const content = `${JSON.stringify(redactSecrets(event))}\n`;
164
+ await fs.promises.appendFile(filePath, content, "utf-8");
165
+ } catch (error) {
166
+ logInternalError("live-session.transcript-write-failed", error as Error, `path=${filePath}`);
167
+ }
156
168
  }
157
169
 
158
170
  function asRecord(value: unknown): Record<string, unknown> | undefined {
@@ -588,13 +600,20 @@ export async function runLiveSessionTask(input: LiveSessionSpawnInput): Promise<
588
600
  jsonEvents: 0,
589
601
  error: "createAgentSession export is unavailable.",
590
602
  };
603
+ // OPT-03: compute yield-enabled flag early to conditionally allocate
604
+ // collectedJsonEvents — avoids ~1KB array allocation per task when yield
605
+ // detection is disabled. Pattern mirrors task-runner.ts:165. Uses the same
606
+ // `enabled !== false` semantics as the late yield-detection block below so
607
+ // the array is always allocated when (and only when) it will be read.
608
+ const yieldConfig = input.runtimeConfig?.yield ?? { enabled: DEFAULT_YIELD_CONFIG.enabled };
609
+ const yieldEnabled = yieldConfig.enabled !== false;
591
610
  let session: LiveSessionLike | undefined;
592
611
  let unsubscribe: (() => void) | undefined;
593
612
  let unsubscribeControlRealtime: (() => void) | undefined;
594
613
  let controlTimer: ReturnType<typeof setInterval> | undefined;
595
614
  let stdout = "";
596
615
  let jsonEvents = 0;
597
- const collectedJsonEvents: Record<string, unknown>[] = [];
616
+ const collectedJsonEvents: Record<string, unknown>[] | undefined = yieldEnabled ? [] : undefined;
598
617
  const maxCollectedJsonEvents = 1000;
599
618
  let yieldResult: YieldResult | undefined;
600
619
 
@@ -854,7 +873,7 @@ export async function runLiveSessionTask(input: LiveSessionSpawnInput): Promise<
854
873
  trackLiveAgentToolEnd(agentId, toolName);
855
874
  }
856
875
  // Phase 1: collect events for yield detection
857
- if (event && typeof event === "object" && !Array.isArray(event)) {
876
+ if (collectedJsonEvents && event && typeof event === "object" && !Array.isArray(event)) {
858
877
  collectedJsonEvents.push(event as Record<string, unknown>);
859
878
  if (collectedJsonEvents.length > maxCollectedJsonEvents)
860
879
  collectedJsonEvents.splice(0, collectedJsonEvents.length - maxCollectedJsonEvents);
@@ -936,16 +955,12 @@ export async function runLiveSessionTask(input: LiveSessionSpawnInput): Promise<
936
955
  // --- Phase 1: Yield enforcement loop ---
937
956
  // After the initial prompt completes, check if the worker called submit_result.
938
957
  // Priority: 1) custom tool callback (G1), 2) JSON event detection (legacy).
939
- const yieldConfig = input.runtimeConfig?.yield ?? {
940
- enabled: DEFAULT_YIELD_CONFIG.enabled,
941
- };
942
- const yieldEnabled = yieldConfig.enabled !== false;
943
958
  if (yieldEnabled && session) {
944
959
  // Check custom tool callback first (G1)
945
960
  if (customToolYieldResolved && customToolYieldResult) {
946
961
  yieldResult = customToolYieldResult;
947
- } else {
948
- // Legacy: detect from JSON events
962
+ } else if (collectedJsonEvents) {
963
+ // Legacy: detect from JSON events (only when collection is enabled)
949
964
  const alreadyYielded = hasYieldInOutput(collectedJsonEvents);
950
965
  if (alreadyYielded) {
951
966
  const yieldEvent = collectedJsonEvents.find((e) => isYieldEvent(e));
@@ -976,7 +991,7 @@ export async function runLiveSessionTask(input: LiveSessionSpawnInput): Promise<
976
991
  // Check again after schema reminder
977
992
  if (customToolYieldResolved && customToolYieldResult) {
978
993
  yieldResult = customToolYieldResult;
979
- } else {
994
+ } else if (collectedJsonEvents) {
980
995
  const newEvents = collectedJsonEvents.slice(-10);
981
996
  if (hasYieldInOutput(newEvents)) {
982
997
  const yieldEvent = newEvents.find((e) => isYieldEvent(e));
@@ -1027,7 +1042,7 @@ export async function runLiveSessionTask(input: LiveSessionSpawnInput): Promise<
1027
1042
  break;
1028
1043
  }
1029
1044
  // Legacy: check JSON events
1030
- if (hasYieldInOutput(collectedJsonEvents.slice(-10))) {
1045
+ if (collectedJsonEvents && hasYieldInOutput(collectedJsonEvents.slice(-10))) {
1031
1046
  const yieldEvent = collectedJsonEvents.slice(-10).find((e) => isYieldEvent(e));
1032
1047
  if (yieldEvent) yieldResult = extractYieldResult(yieldEvent);
1033
1048
  break;
@@ -20,8 +20,35 @@ const MAX_SKILL_CHARS = 1500;
20
20
  const MAX_TOTAL_CHARS = 6000;
21
21
  const MAX_SKILL_NAME_CHARS = 80;
22
22
  const MAX_SELECTED_SKILLS = 32;
23
+
24
+ // ── Cache sizing rationale (OPT-05 analysis, 2026-07-13) ───────────────────
25
+ // The package ships 30 SKILL.md files; 11 are referenced by DEFAULT_ROLE_SKILLS.
26
+ // Cache key = `${cwd}:${skillName}`, so a single-cwd run needs ≤30 entries.
27
+ // Multi-cwd scenarios (worktree isolation) multiply this, but rarely exceed 90.
28
+ // 128 provides 4× headroom over the skill count, ~1.4× over a 3-cwd scenario.
29
+ // Memory per entry ≈ 5KB (raw + compacted body), so 128 entries ≈ 640KB — negligible.
30
+ // Conclusion: 128 is well-tuned. Evictions are near-zero in practice.
23
31
  const SKILL_CACHE_MAX_ENTRIES = 128;
24
32
 
33
+ export interface SkillCacheStats {
34
+ hits: number;
35
+ misses: number;
36
+ evictions: number;
37
+ currentSize: number;
38
+ maxEntries: number;
39
+ /** Computed hit-rate: hits / (hits + misses), 0 when no lookups yet. */
40
+ hitRate: number;
41
+ }
42
+
43
+ const skillCacheStats: SkillCacheStats = {
44
+ hits: 0,
45
+ misses: 0,
46
+ evictions: 0,
47
+ currentSize: 0,
48
+ maxEntries: SKILL_CACHE_MAX_ENTRIES,
49
+ hitRate: 0,
50
+ };
51
+
25
52
  const DEFAULT_ROLE_SKILLS: Record<string, string[]> = {
26
53
  explorer: ["read-only-explorer", "context-artifact-hygiene"],
27
54
  analyst: ["read-only-explorer", "requirements-to-task-packet"],
@@ -146,16 +173,43 @@ const skillReadCache = new Map<string, CachedSkillMarkdown>();
146
173
  function rememberSkill(key: string, value: CachedSkillMarkdown): CachedSkillMarkdown {
147
174
  if (skillReadCache.has(key)) skillReadCache.delete(key);
148
175
  skillReadCache.set(key, value);
149
- while (skillReadCache.size > SKILL_CACHE_MAX_ENTRIES) {
176
+ while (skillReadCache.size > skillCacheStats.maxEntries) {
150
177
  const oldest = skillReadCache.keys().next().value;
151
178
  if (!oldest) break;
152
179
  skillReadCache.delete(oldest);
180
+ skillCacheStats.evictions++;
153
181
  }
182
+ skillCacheStats.currentSize = skillReadCache.size;
154
183
  return value;
155
184
  }
156
185
 
157
186
  export function clearSkillInstructionCache(): void {
158
187
  skillReadCache.clear();
188
+ skillCacheStats.currentSize = 0;
189
+ }
190
+
191
+ export function getSkillCacheStats(): SkillCacheStats {
192
+ const total = skillCacheStats.hits + skillCacheStats.misses;
193
+ return {
194
+ ...skillCacheStats,
195
+ currentSize: skillReadCache.size,
196
+ hitRate: total > 0 ? skillCacheStats.hits / total : 0,
197
+ };
198
+ }
199
+
200
+ export function resetSkillCacheStats(): void {
201
+ skillCacheStats.hits = 0;
202
+ skillCacheStats.misses = 0;
203
+ skillCacheStats.evictions = 0;
204
+ skillCacheStats.currentSize = skillReadCache.size;
205
+ }
206
+
207
+ /**
208
+ * Test-only: temporarily override the cache capacity to exercise eviction logic.
209
+ * Always restore to `SKILL_CACHE_MAX_ENTRIES` after the test.
210
+ */
211
+ export function _setSkillCacheMaxEntriesForTesting(max: number): void {
212
+ skillCacheStats.maxEntries = max;
159
213
  }
160
214
 
161
215
  function cachedSkillFresh(value: CachedSkillMarkdown): boolean {
@@ -181,8 +235,13 @@ function readSkillMarkdown(
181
235
  if (!isValidSkillName(name)) return undefined;
182
236
  const cacheKey = `${path.resolve(cwd)}:${name}`;
183
237
  const cached = skillReadCache.get(cacheKey);
184
- if (cached && cachedSkillFresh(cached)) return cached;
238
+ if (cached && cachedSkillFresh(cached)) {
239
+ skillCacheStats.hits++;
240
+ return cached;
241
+ }
185
242
  if (cached) skillReadCache.delete(cacheKey);
243
+ skillCacheStats.misses++;
244
+ skillCacheStats.currentSize = skillReadCache.size;
186
245
  for (const entry of candidateSkillDirs(cwd)) {
187
246
  try {
188
247
  const relative = path.join(name, "SKILL.md");
@@ -74,6 +74,9 @@ function checkResultFile(manifest: TeamRunManifest, tasks: TeamTaskState[]): { f
74
74
  const onlyCancelledOrSkipped = tasks.every((t) => t.status === "cancelled" || t.status === "skipped");
75
75
  manifest.status = hasFailed ? "failed" : onlyCancelledOrSkipped ? "cancelled" : "completed";
76
76
  // Persist manifest status change immediately to make checkResultFile self-contained.
77
+ // NOTE (OPT-02): Remains sync — reconcileStaleRun → checkResultFile is called from
78
+ // withRunLockSync in crash-recovery.ts and from sync reconcileOrphanedTempWorkspaces.
79
+ // Converting to async would require cascading through all callers including sync UI hooks.
77
80
  saveRunManifest(manifest);
78
81
  // Sync agent records even when tasks are already terminal
79
82
  // (e.g., a previous reconcile fixed tasks but crashed before updating agents)
@@ -329,6 +332,8 @@ export function reconcileStaleRun(manifest: TeamRunManifest, tasks: TeamTaskStat
329
332
  // checkResultFile sets manifest.status='completed' and saves it,
330
333
  // but we re-save to ensure the completed status is persisted
331
334
  // before returning (avoids TOCTOU where caller might re-read stale data)
335
+ // NOTE (OPT-02): Remains sync — reconcileStaleRun is called from withRunLockSync in
336
+ // crash-recovery.ts and from sync reconcileOrphanedTempWorkspaces; cannot await here.
332
337
  saveRunManifest(manifest);
333
338
  return {
334
339
  runId,
@@ -4,10 +4,22 @@ import * as fs from "node:fs";
4
4
  * Read the tail of a file, capped at maxBytes.
5
5
  * If the file exceeds maxBytes, reads only the last maxBytes and snaps
6
6
  * to the nearest newline boundary to avoid partial JSONL lines.
7
+ *
8
+ * Falls back to `fallbackContent` when the file is missing OR empty. The
9
+ * empty-file case matters because `appendTranscript` is fire-and-forget
10
+ * async in the mock path (commit e316a36) — the file is created on disk
11
+ * (existsSync returns true) before the actual write completes, so a
12
+ * subsequent tail-read can observe size=0. Returning "" in that case would
13
+ * silently drop the mock's stdout, breaking downstream parsers that depend
14
+ * on a non-empty transcript (e.g. adaptive-plan JSON extraction in
15
+ * implementation-fanout.test.ts). Falling back to `fallbackContent` keeps
16
+ * the existing fallback semantics: "if the file isn't usable, use what
17
+ * the caller already has."
7
18
  */
8
19
  export function tailReadWithLineSnap(filePath: string, maxBytes: number, fallbackContent: string): string {
9
20
  if (!fs.existsSync(filePath)) return fallbackContent;
10
21
  const stat = fs.statSync(filePath);
22
+ if (stat.size === 0) return fallbackContent;
11
23
  if (stat.size <= maxBytes) return fs.readFileSync(filePath, "utf-8");
12
24
  const fd = fs.openSync(filePath, "r");
13
25
  try {
@@ -7,8 +7,8 @@ import { errors } from "../errors.ts";
7
7
  import { appendHookEvent, executeHook } from "../hooks/registry.ts";
8
8
  import { writeArtifact } from "../state/artifact-store.ts";
9
9
  import { appendEventAsync, appendEventBuffered, appendEventFireAndForget } from "../state/event-log.ts";
10
- import { withRunLockSync } from "../state/locks.ts";
11
- import { saveRunManifest } from "../state/state-store.ts";
10
+ import { withRunLock } from "../state/locks.ts";
11
+ import { saveRunManifestAsync } from "../state/state-store.ts";
12
12
  import { createTaskClaim } from "../state/task-claims.ts";
13
13
  import type {
14
14
  ArtifactDescriptor,
@@ -83,6 +83,36 @@ import {
83
83
  // Register the submit_result tool handler so subprocess events can extract yield data.
84
84
  registerYieldTool();
85
85
 
86
+ /** Async helper for writing steering events — fire-and-forget for non-blocking writes. */
87
+ async function appendSteeringAsync(steeringDir: string, taskId: string, steers: string[]): Promise<void> {
88
+ try {
89
+ await fs.promises.mkdir(steeringDir, { recursive: true });
90
+ const steeringPath = `${steeringDir}/${taskId}.jsonl`;
91
+ const lines = steers
92
+ .map(
93
+ (msg) =>
94
+ JSON.stringify({
95
+ type: "steer",
96
+ message: msg,
97
+ ts: new Date().toISOString(),
98
+ }) + "\n",
99
+ )
100
+ .join("");
101
+ await fs.promises.appendFile(steeringPath, lines, "utf-8");
102
+ } catch (error) {
103
+ logInternalError("task-runner.steering-write-failed", error as Error, `taskId=${taskId}`);
104
+ }
105
+ }
106
+
107
+ /** Async helper for writing background logs — fire-and-forget for non-blocking writes. */
108
+ async function appendBackgroundLogAsync(bgLogPath: string, eventLine: string): Promise<void> {
109
+ try {
110
+ await fs.promises.appendFile(bgLogPath, `${eventLine}\n`, "utf-8");
111
+ } catch (error) {
112
+ logInternalError("task-runner.background-log-write-failed", error as Error, `path=${bgLogPath}`);
113
+ }
114
+ }
115
+
86
116
  export interface TaskRunnerInput {
87
117
  manifest: TeamRunManifest;
88
118
  tasks: TeamTaskState[];
@@ -413,7 +443,7 @@ export async function runTeamTask(input: TaskRunnerInput): Promise<{ manifest: T
413
443
  // Ensure transcripts/ subdirectory exists before child-pi appends
414
444
  // to it. appendTranscript uses O_APPEND (no mkdir) for security,
415
445
  // so the caller must create the directory.
416
- fs.mkdirSync(path.join(manifest.artifactsRoot, "transcripts"), {
446
+ await fs.promises.mkdir(path.join(manifest.artifactsRoot, "transcripts"), {
417
447
  recursive: true,
418
448
  });
419
449
  const model = attemptModels[i];
@@ -460,18 +490,8 @@ export async function runTeamTask(input: TaskRunnerInput): Promise<{ manifest: T
460
490
  ({ task, tasks } = checkpointTask(manifest, tasks, task, "child-spawned", pid));
461
491
  if (task.pendingSteers?.length) {
462
492
  const steeringDir = `${manifest.artifactsRoot}/steering`;
463
- fs.mkdirSync(steeringDir, { recursive: true });
464
- const steeringPath = `${steeringDir}/${task.id}.jsonl`;
465
- for (const msg of task.pendingSteers) {
466
- fs.appendFileSync(
467
- steeringPath,
468
- JSON.stringify({
469
- type: "steer",
470
- message: msg,
471
- ts: new Date().toISOString(),
472
- }) + "\n",
473
- );
474
- }
493
+ // Fire-and-forget async write for steering events
494
+ void appendSteeringAsync(steeringDir, task.id, task.pendingSteers);
475
495
  task.pendingSteers = [];
476
496
  tasks = persistSingleTaskUpdate(manifest, tasks, task);
477
497
  }
@@ -533,7 +553,8 @@ export async function runTeamTask(input: TaskRunnerInput): Promise<{ manifest: T
533
553
  const bgLogPath = `${manifest.stateRoot}/background.log`;
534
554
  const eventLine =
535
555
  typeof event === "object" && !Array.isArray(event) ? JSON.stringify(event) : String(event);
536
- fs.appendFileSync(bgLogPath, `${eventLine}\n`);
556
+ // Fire-and-forget async write for background log
557
+ void appendBackgroundLogAsync(bgLogPath, eventLine);
537
558
  }
538
559
  // Always keep in-memory agentProgress fresh (cheap) so the UI/events see
539
560
  // the latest progress, but THROTTLE the disk persist. Previously this
@@ -1201,9 +1222,9 @@ export async function runTeamTask(input: TaskRunnerInput): Promise<{ manifest: T
1201
1222
  // stale manifest and overwrite this task's freshly-written artifacts, silently
1202
1223
  // losing them. persistSingleTaskUpdate is re-entrance-safe (runLockHeldByUs guard),
1203
1224
  // so nesting it inside this lock is a no-op re-acquire, not a deadlock.
1204
- withRunLockSync(manifest, () => {
1205
- saveRunManifest(manifest);
1206
- tasks = persistSingleTaskUpdate(manifest, tasks, task);
1225
+ tasks = await withRunLock(manifest, async () => {
1226
+ await saveRunManifestAsync(manifest);
1227
+ return persistSingleTaskUpdate(manifest, tasks, task);
1207
1228
  });
1208
1229
  upsertCrewAgent(manifest, recordFromTask(manifest, task, runtimeKind));
1209
1230
  // Execute task_result hook before emitting terminal event
@@ -12,7 +12,7 @@ import { hashArtifactContent as hashContent, writeArtifact } from "../state/arti
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";
@@ -618,7 +617,7 @@ function isMutatingTask(task: TeamTaskState): boolean {
618
617
  return permissionForRole(task.role) !== "read_only";
619
618
  }
620
619
 
621
- function ensurePlanApprovalRequested(manifest: TeamRunManifest, tasks: TeamTaskState[]): TeamRunManifest {
620
+ async function ensurePlanApprovalRequested(manifest: TeamRunManifest, tasks: TeamTaskState[]): Promise<TeamRunManifest> {
622
621
  if (manifest.planApproval) return manifest;
623
622
  const assessTask = tasks.find((task) => task.stepId === "assess" && task.status === "completed");
624
623
  // ROADMAP T1.2: for non-adaptive workflows, fall back to the most recent
@@ -637,7 +636,7 @@ function ensurePlanApprovalRequested(manifest: TeamRunManifest, tasks: TeamTaskS
637
636
  planArtifactPath: planTask?.resultArtifact?.path,
638
637
  },
639
638
  };
640
- saveRunManifest(updated);
639
+ await saveRunManifestAsync(updated);
641
640
  appendEvent(updated.eventsPath, {
642
641
  type: "plan.approval_required",
643
642
  runId: updated.runId,
@@ -745,7 +744,7 @@ export async function executeTeamRun(input: ExecuteTeamRunInput): Promise<{ mani
745
744
  if (input.budgetAbort !== undefined) manifest.budgetAbort = input.budgetAbort;
746
745
  if (input.budgetUnlimited !== undefined) manifest.budgetUnlimited = input.budgetUnlimited;
747
746
  if (manifest.budgetTotal !== undefined && manifest.budgetTotal > 0 && !manifest.budgetUnlimited) {
748
- saveRunManifest(manifest);
747
+ await saveRunManifestAsync(manifest);
749
748
  }
750
749
 
751
750
  void registerRunPromise(manifest.runId);
@@ -773,7 +772,7 @@ export async function executeTeamRun(input: ExecuteTeamRunInput): Promise<{ mani
773
772
  if (gaApplied.manifest !== result.manifest) {
774
773
  result.manifest = gaApplied.manifest;
775
774
  try {
776
- saveRunManifest(result.manifest);
775
+ await saveRunManifestAsync(result.manifest);
777
776
  } catch (persistError) {
778
777
  logInternalError(
779
778
  "team-runner.goalAchievement.persist",
@@ -948,9 +947,9 @@ async function executeTeamRunCore(
948
947
  const canInjectAdaptivePlan = workflow.name === "implementation";
949
948
  let adaptivePlanInjected = false;
950
949
  let adaptivePlanMissing = false;
951
- const attemptAdaptivePlan = () => {
950
+ const attemptAdaptivePlan = async () => {
952
951
  if (!canInjectAdaptivePlan || adaptivePlanInjected || adaptivePlanMissing) return { injected: false, missing: false };
953
- const adaptivePlan = injectAdaptivePlanIfReady({
952
+ const adaptivePlan = await injectAdaptivePlanIfReady({
954
953
  manifest,
955
954
  tasks,
956
955
  workflow,
@@ -965,7 +964,7 @@ async function executeTeamRunCore(
965
964
  missing: adaptivePlan.missingPlan,
966
965
  };
967
966
  };
968
- const initialAdaptive = attemptAdaptivePlan();
967
+ const initialAdaptive = await attemptAdaptivePlan();
969
968
  if (initialAdaptive.missing) {
970
969
  tasks = markBlocked(tasks, "Adaptive planner did not produce a valid subagent plan.");
971
970
  await saveRunTasksAsync(manifest, tasks);
@@ -973,13 +972,13 @@ async function executeTeamRunCore(
973
972
  return { manifest, tasks };
974
973
  }
975
974
  if (initialAdaptive.injected) {
976
- manifest = requiresPlanApproval(workflow, input.runtimeConfig) ? ensurePlanApprovalRequested(manifest, tasks) : manifest;
975
+ manifest = requiresPlanApproval(workflow, input.runtimeConfig) ? await ensurePlanApprovalRequested(manifest, tasks) : manifest;
977
976
  queueIndex = buildTaskGraphIndex(tasks);
978
977
  } else if (
979
978
  requiresPlanApproval(workflow, input.runtimeConfig) &&
980
979
  (hasPendingMutatingAdaptiveTask(tasks) || hasPendingMutatingTaskAtBoundary(tasks))
981
980
  ) {
982
- manifest = ensurePlanApprovalRequested(manifest, tasks);
981
+ manifest = await ensurePlanApprovalRequested(manifest, tasks);
983
982
  }
984
983
  if (manifest.planApproval?.status === "cancelled") {
985
984
  tasks = cancelPlanTasks(tasks, "Plan approval was cancelled.");
@@ -1003,7 +1002,13 @@ async function executeTeamRunCore(
1003
1002
  );
1004
1003
  let wfMachine = createWorkflowStateMachine(workflowPhases);
1005
1004
 
1006
- 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) {
1007
1012
  if (input.signal?.aborted) {
1008
1013
  const cancelReason = cancellationReasonFromSignal(input.signal);
1009
1014
  const message = `${cancelReason.message} (${cancelReason.code})`;
@@ -1202,26 +1207,39 @@ async function executeTeamRunCore(
1202
1207
  },
1203
1208
  });
1204
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);
1205
1217
  const approvalPending = isPlanApprovalPending(manifest);
1206
- 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);
1207
1220
  const candidateBatch = readyIds
1208
1221
  .map((id) => tasks.find((task) => task.id === id))
1209
1222
  .filter((task): task is TeamTaskState => Boolean(task));
1210
1223
  const readyBatch = approvalPending
1211
- ? candidateBatch.filter((task) => !isMutatingTask(task)).slice(0, concurrency.selectedCount)
1224
+ ? candidateBatch.filter((task) => !isMutatingTask(task)).slice(0, slotsAvailable)
1212
1225
  : candidateBatch;
1213
1226
  if (readyBatch.length === 0) {
1214
- 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)) {
1215
1232
  await saveRunTasksAsync(manifest, tasks);
1216
1233
  saveCrewAgents(manifest, recordsForMaterializedTasks(manifest, tasks, runtimeKind));
1217
1234
  manifest = updateRunStatus(manifest, "blocked", "Plan approval required before mutating implementation tasks run.");
1218
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 };
1219
1242
  }
1220
- tasks = markBlocked(tasks, "No ready queued task; dependency graph may be invalid.");
1221
- await saveRunTasksAsync(manifest, tasks);
1222
- saveCrewAgents(manifest, recordsForMaterializedTasks(manifest, tasks, runtimeKind));
1223
- manifest = updateRunStatus(manifest, "blocked", "No ready queued task.");
1224
- return { manifest, tasks };
1225
1243
  }
1226
1244
 
1227
1245
  // 2.2 caller migration: batch progress is high-frequency informational (M7 wire).
@@ -1311,7 +1329,10 @@ async function executeTeamRunCore(
1311
1329
  );
1312
1330
  }
1313
1331
 
1314
- 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[] }> => {
1315
1336
  // M6 real dispatch path: single worker for N tasks.
1316
1337
  if (unit.kind === "group") {
1317
1338
  const groupTasks = unit.group.tasks;
@@ -1533,30 +1554,52 @@ async function executeTeamRunCore(
1533
1554
  }),
1534
1555
  );
1535
1556
  }
1536
- });
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
- }
1549
- // FIX: Filter out undefined entries from partial results when error occurred
1550
- // during parallel execution. Other workers may have written partial results
1551
- // before one threw. Results may be partial - some tasks in-flight at error
1552
- // time will not have entries in the results array.
1553
- const validResults = results.filter((item): item is NonNullable<typeof item> => item !== undefined);
1554
- // Guard: if ALL parallel workers threw before returning, validResults is empty.
1555
- // at(-1)! would crash. Mark the run failed rather than crashing.
1556
- if (validResults.length === 0) {
1557
- manifest = updateRunStatus(manifest, "failed", "All parallel tasks failed catastrophically.");
1558
- 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
+ });
1559
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];
1560
1603
  // Reconstruct manifest from the last worker's snapshot. The .artifacts field
1561
1604
  // is re-merged from both the team-runner's in-memory state and all workers'
1562
1605
  // snapshots, so artifact writes by task-runner (which individually save manifest
@@ -1722,7 +1765,7 @@ async function executeTeamRunCore(
1722
1765
  }
1723
1766
  }
1724
1767
 
1725
- const cancelledResult = results.find((item) => item.manifest.status === "cancelled");
1768
+ const cancelledResult = resultToMerge.manifest.status === "cancelled" ? resultToMerge : undefined;
1726
1769
  if (cancelledResult || input.signal?.aborted) {
1727
1770
  const reason = input.signal?.aborted ? cancellationReasonFromSignal(input.signal) : undefined;
1728
1771
  const message = reason?.message ?? cancelledResult?.manifest.summary ?? "Run cancelled during task execution.";
@@ -1761,7 +1804,7 @@ async function executeTeamRunCore(
1761
1804
  return { manifest, tasks: reCancelledTasks };
1762
1805
  }
1763
1806
  queueIndex = buildTaskGraphIndex(tasks);
1764
- const injectedAfterBatch = attemptAdaptivePlan();
1807
+ const injectedAfterBatch = await attemptAdaptivePlan();
1765
1808
  if (injectedAfterBatch.missing) {
1766
1809
  tasks = markBlocked(tasks, "Adaptive planner did not produce a valid subagent plan.");
1767
1810
  await saveRunTasksAsync(manifest, tasks);
@@ -1770,13 +1813,13 @@ async function executeTeamRunCore(
1770
1813
  return { manifest, tasks };
1771
1814
  }
1772
1815
  if (injectedAfterBatch.injected) {
1773
- manifest = requiresPlanApproval(workflow, input.runtimeConfig) ? ensurePlanApprovalRequested(manifest, tasks) : manifest;
1816
+ manifest = requiresPlanApproval(workflow, input.runtimeConfig) ? await ensurePlanApprovalRequested(manifest, tasks) : manifest;
1774
1817
  queueIndex = buildTaskGraphIndex(tasks);
1775
1818
  } else if (
1776
1819
  requiresPlanApproval(workflow, input.runtimeConfig) &&
1777
1820
  (hasPendingMutatingAdaptiveTask(tasks) || hasPendingMutatingTaskAtBoundary(tasks))
1778
1821
  ) {
1779
- manifest = ensurePlanApprovalRequested(manifest, tasks);
1822
+ manifest = await ensurePlanApprovalRequested(manifest, tasks);
1780
1823
  }
1781
1824
  if (manifest.planApproval?.status === "cancelled") {
1782
1825
  tasks = cancelPlanTasks(tasks, "Plan approval was cancelled.");
@@ -1787,17 +1830,17 @@ async function executeTeamRunCore(
1787
1830
  }
1788
1831
  await saveRunTasksAsync(manifest, tasks);
1789
1832
  saveCrewAgents(manifest, recordsForMaterializedTasks(manifest, tasks, runtimeKind));
1790
- const completedBatch = tasks.filter((t) => batchTasks.some((bt) => bt.id === t.id));
1833
+ const completedBatch = tasks.filter((t) => completedUnit.taskIds.includes(t.id));
1791
1834
  const batchArtifact = writeArtifact(manifest.artifactsRoot, {
1792
1835
  kind: "summary",
1793
- relativePath: `batches/${batchTasks.map((task) => task.id).join("+")}.md`,
1836
+ relativePath: `batches/${completedUnit.taskIds.join("+")}.md`,
1794
1837
  producer: "team-runner",
1795
1838
  content: aggregateTaskOutputs(completedBatch, manifest),
1796
1839
  });
1797
1840
  const groupDelivery = deliverGroupJoin({
1798
1841
  manifest,
1799
1842
  mode: resolveGroupJoinMode(input.runtimeConfig),
1800
- batch: batchTasks,
1843
+ batch: completedBatch,
1801
1844
  allTasks: tasks,
1802
1845
  });
1803
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). */
@@ -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).