pi-crew 0.9.17 → 0.9.18

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.
@@ -66,6 +66,108 @@ import type {
66
66
  UpdateConfigOptions,
67
67
  } from "./types.ts";
68
68
 
69
+ // (F16) loadConfig was called 1 Hz idle / 6 Hz active with 0 cache — added 2s TTL+mtime cache following the manifestCache pattern in state-store.ts:75-130.
70
+ const CONFIG_CACHE_TTL_MS = 2000;
71
+
72
+ interface ConfigCacheEntry {
73
+ value: LoadedPiTeamsConfig;
74
+ mtimes: Record<string, number>;
75
+ cachedAt: number;
76
+ }
77
+
78
+ interface ConfigCacheKeyParts {
79
+ filePath: string;
80
+ legacyPath: string;
81
+ projectPath: string;
82
+ projectPiCrewJsonPath: string;
83
+ cwd: string | null;
84
+ }
85
+
86
+ const configCache = new Map<string, ConfigCacheEntry>();
87
+
88
+ /** @internal — TTL override for unit tests (matches __test__setManifestCache pattern in state-store.ts:82). */
89
+ export function __test__setConfigCacheTtlMs(ttlMs: number): void {
90
+ configCacheTtlMsOverride = ttlMs;
91
+ }
92
+
93
+ /** @internal — read the effective TTL in use by the cache. */
94
+ export function __test__getConfigCacheTtlMs(): number {
95
+ return configCacheTtlMsOverride ?? CONFIG_CACHE_TTL_MS;
96
+ }
97
+
98
+ /** @internal — peek at the cached entry for a given key shape. */
99
+ export function __test__getConfigCacheEntry(parts: ConfigCacheKeyParts): ConfigCacheEntry | undefined {
100
+ return configCache.get(buildConfigCacheKey(parts));
101
+ }
102
+
103
+ /** @internal — number of cached entries (for tests/diagnostics). */
104
+ export function __test__configCacheSize(): number {
105
+ return configCache.size;
106
+ }
107
+
108
+ let configCacheTtlMsOverride: number | null = null;
109
+
110
+ function buildConfigCacheKey(parts: ConfigCacheKeyParts): string {
111
+ return JSON.stringify([parts.filePath, parts.legacyPath, parts.projectPath, parts.projectPiCrewJsonPath, parts.cwd]);
112
+ }
113
+
114
+ function statMtimeMs(filePath: string): number | undefined {
115
+ try {
116
+ return fs.statSync(filePath).mtimeMs;
117
+ } catch (error) {
118
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
119
+ throw error;
120
+ }
121
+ }
122
+
123
+ function readCachedConfigParts(filePath: string, legacyPath: string, cwd: string | undefined): ConfigCacheKeyParts {
124
+ const projectPath = cwd ? projectConfigPath(cwd) : "";
125
+ const piCrewJsonPath = cwd ? projectPiCrewJsonPath(cwd) : "";
126
+ return {
127
+ filePath,
128
+ legacyPath,
129
+ projectPath,
130
+ projectPiCrewJsonPath: piCrewJsonPath,
131
+ cwd: cwd ?? null,
132
+ };
133
+ }
134
+
135
+ function readCacheMtimes(parts: ConfigCacheKeyParts): Record<string, number> {
136
+ const mtimes: Record<string, number> = {};
137
+ for (const p of [parts.filePath, parts.legacyPath, parts.projectPath, parts.projectPiCrewJsonPath]) {
138
+ if (!p) continue; // skip empty (cwd-undefined project paths)
139
+ const m = statMtimeMs(p);
140
+ if (m !== undefined) mtimes[p] = m;
141
+ }
142
+ return mtimes;
143
+ }
144
+
145
+ function matchesCachedMtimes(cached: Record<string, number>, current: Record<string, number>): boolean {
146
+ const cachedKeys = Object.keys(cached);
147
+ const currentKeys = Object.keys(current);
148
+ if (cachedKeys.length !== currentKeys.length) return false;
149
+ for (const key of cachedKeys) {
150
+ if (current[key] !== cached[key]) return false;
151
+ }
152
+ return true;
153
+ }
154
+
155
+ function setConfigCache(key: string, value: LoadedPiTeamsConfig, mtimes: Record<string, number>): void {
156
+ if (configCache.has(key)) configCache.delete(key);
157
+ configCache.set(key, { value, mtimes, cachedAt: Date.now() });
158
+ const ttlMs = configCacheTtlMsOverride ?? CONFIG_CACHE_TTL_MS;
159
+ // TTL eviction on insert (mirrors manifestCache eviction in state-store.ts:108-117)
160
+ const now = Date.now();
161
+ for (const [k, entry] of configCache.entries()) {
162
+ if (now - entry.cachedAt > ttlMs) configCache.delete(k);
163
+ }
164
+ }
165
+
166
+ /** Drop all cached loadConfig results. Call after config writes or from tests. */
167
+ export function invalidateConfigCache(): void {
168
+ configCache.clear();
169
+ }
170
+
69
171
  function resolveHomeDir(): string {
70
172
  const envValue = process.env.PI_TEAMS_HOME?.trim();
71
173
  const defaultHome = os.homedir();
@@ -972,6 +1074,27 @@ function readOptionalConfig(filePath: string): {
972
1074
  export function loadConfig(cwd?: string): LoadedPiTeamsConfig {
973
1075
  const filePath = configPath();
974
1076
  const legacyPath = legacyConfigPath();
1077
+
1078
+ // (F16) Quick-win cache: skip the expensive full-reparse on hot paths
1079
+ // (preload tick 1 Hz, per-write validation, subagent completion) when the
1080
+ // on-disk config files are unchanged. See CONFIG_CACHE_TTL_MS at the
1081
+ // top of this file for the rationale and the manifestCache pattern in
1082
+ // state-store.ts:75-130 for the canonical implementation we mirror.
1083
+ const cacheParts = readCachedConfigParts(filePath, legacyPath, cwd);
1084
+ const cacheKey = buildConfigCacheKey(cacheParts);
1085
+ const cached = configCache.get(cacheKey);
1086
+ const ttlMs = configCacheTtlMsOverride ?? CONFIG_CACHE_TTL_MS;
1087
+ if (cached && Date.now() - cached.cachedAt <= ttlMs) {
1088
+ const currentMtimes = readCacheMtimes(cacheParts);
1089
+ if (matchesCachedMtimes(cached.mtimes, currentMtimes)) {
1090
+ // Refresh insertion order so a frequently-accessed key isn't
1091
+ // evicted from the Map on the next eviction sweep.
1092
+ configCache.delete(cacheKey);
1093
+ configCache.set(cacheKey, cached);
1094
+ return cached.value;
1095
+ }
1096
+ }
1097
+
975
1098
  const paths = cwd ? [filePath, projectConfigPath(cwd)] : [filePath];
976
1099
  const warnings: string[] = [];
977
1100
  const legacyConfig = readOptionalConfig(legacyPath);
@@ -1015,12 +1138,20 @@ export function loadConfig(cwd?: string): LoadedPiTeamsConfig {
1015
1138
  paths.push(piCrewJsonPath);
1016
1139
  }
1017
1140
  }
1018
- return {
1141
+ const result: LoadedPiTeamsConfig = {
1019
1142
  path: filePath,
1020
1143
  paths,
1021
1144
  config,
1022
1145
  warnings: warnings.length > 0 ? warnings : undefined,
1023
1146
  };
1147
+ // Only cache when at least one of the watched paths exists — this avoids
1148
+ // pinning stale empty results when a user later creates one of these files
1149
+ // in the cache window. mtime stat below picks up the new file (it appears
1150
+ // in currentMtimes but not in cached.mtimes) and triggers a re-parse.
1151
+ if (Object.keys(readCacheMtimes(cacheParts)).length > 0) {
1152
+ setConfigCache(cacheKey, result, readCacheMtimes(cacheParts));
1153
+ }
1154
+ return result;
1024
1155
  }
1025
1156
 
1026
1157
  export function updateConfig(patch: PiTeamsConfig, options: UpdateConfigOptions = {}): SavedPiTeamsConfig {
@@ -1042,6 +1173,9 @@ export function updateConfig(patch: PiTeamsConfig, options: UpdateConfigOptions
1042
1173
  }
1043
1174
  fs.mkdirSync(path.dirname(filePath), { recursive: true });
1044
1175
  atomicWriteFile(filePath, `${JSON.stringify(merged, null, 2)}\n`);
1176
+ // (F16) Invalidate the loadConfig cache after a write — the next
1177
+ // caller must see the new value, not a 0-2s stale snapshot.
1178
+ invalidateConfigCache();
1045
1179
  return { path: filePath, config: merged };
1046
1180
  });
1047
1181
  }
@@ -1063,6 +1197,8 @@ export function updateAutonomousConfig(patch: PiTeamsAutonomousConfig): SavedPiT
1063
1197
  : {};
1064
1198
  current.autonomous = { ...currentAutonomous, ...patch };
1065
1199
  atomicWriteFile(filePath, `${JSON.stringify(current, null, 2)}\n`);
1200
+ // (F16) Invalidate the loadConfig cache after a write — see updateConfig.
1201
+ invalidateConfigCache();
1066
1202
  return { path: filePath, config: parseConfig(current) };
1067
1203
  });
1068
1204
  }
@@ -67,7 +67,7 @@ import { registerCrewShortcuts } from "./crew-shortcuts.ts";
67
67
  import { type PiCrewRpcHandle, registerPiCrewRpc } from "./cross-extension-rpc.ts";
68
68
  import { registerKnowledgeInjection } from "./knowledge-injection.ts";
69
69
  import { registerCrewMessageRenderers } from "./message-renderers.ts";
70
- import { type NotificationDescriptor } from "./notification-router.ts";
70
+ import type { NotificationDescriptor } from "./notification-router.ts";
71
71
  import { runArtifactCleanup } from "./registration/artifact-cleanup.ts";
72
72
  import { registerTeamCommands } from "./registration/commands.ts";
73
73
  import { registerCompactionGuard } from "./registration/compaction-guard.ts";
@@ -67,9 +67,7 @@ function loadRunSummaries(cwd: string, options: OnboardingOptions = {}): RunSumm
67
67
  completedAt: raw.completedAt ?? raw.updatedAt,
68
68
  taskCount: 0, // tasks stored separately, not in manifest
69
69
  });
70
- } catch {
71
- continue;
72
- }
70
+ } catch {}
73
71
  }
74
72
 
75
73
  return summaries;
@@ -3,7 +3,7 @@
3
3
  * Provides set/clear/status commands for anchor points.
4
4
  */
5
5
 
6
- import { AnchorManager, AnchorNotFoundError, createAnchorManager, NoHandoffsError } from "../../runtime/anchor-manager.ts";
6
+ import { type AnchorManager, AnchorNotFoundError, createAnchorManager, NoHandoffsError } from "../../runtime/anchor-manager.ts";
7
7
  import type { HandoffSummary } from "../../runtime/handoff-manager.ts";
8
8
  import type { TeamToolParamsValue } from "../../schema/team-tool-schema.ts";
9
9
  import type { PiTeamsToolResult } from "../tool-result.ts";
@@ -3,7 +3,7 @@
3
3
  * Provides on/off/status commands for auto-summarization.
4
4
  */
5
5
 
6
- import { AutoSummarizeService, createAutoSummarizeService, DEFAULT_AUTO_SUMMARIZE_CONFIG } from "../../runtime/auto-summarize.ts";
6
+ import { type AutoSummarizeService, createAutoSummarizeService, DEFAULT_AUTO_SUMMARIZE_CONFIG } from "../../runtime/auto-summarize.ts";
7
7
  import type { TeamToolParamsValue } from "../../schema/team-tool-schema.ts";
8
8
  import type { PiTeamsToolResult } from "../tool-result.ts";
9
9
  import { result, type TeamContext } from "./context.ts";
@@ -8,6 +8,7 @@ import type { TeamRunManifest } from "../state/types.ts";
8
8
  import { WINDOWS_ESSENTIAL_ENV_VARS } from "../utils/env-allowlist.ts";
9
9
  import { sanitizeEnvSecrets } from "../utils/env-filter.ts";
10
10
  import { logInternalError } from "../utils/internal-error.ts";
11
+ import { packageRoot } from "../utils/paths.ts";
11
12
  import { registerWorker, unregisterWorker } from "./orphan-worker-registry.ts";
12
13
  import { PEER_DEP_DIR_ENV, resolvePeerDepDir } from "./peer-dep.ts";
13
14
 
@@ -223,7 +224,17 @@ export const BACKGROUND_RUNNER_ENV_ALLOWLIST: string[] = [
223
224
  ];
224
225
 
225
226
  export async function spawnBackgroundTeamRun(manifest: TeamRunManifest): Promise<SpawnBackgroundTeamRunResult> {
226
- const runnerPath = path.join(path.dirname(fileURLToPath(import.meta.url)), "background-runner.ts");
227
+ // FIX (2026-07-02, perf review F-critical): use packageRoot() instead of
228
+ // import.meta.url-relative path. The previous path.resolve walks
229
+ // <bundleDir>/background-runner.ts, which is correct in src/ but BROKEN
230
+ // in the bundle: esbuild's __esm helper does not preserve per-module
231
+ // import.meta.url, so the resolve lands at <pi-crew>/background-runner.ts
232
+ // (missing `src/runtime/`). The background-runner then ENOENTs at spawn
233
+ // and 3 explorer agents failed during the verification run. packageRoot()
234
+ // walks up to find pi-crew's package.json and works correctly from both
235
+ // src/ and dist/ entry points. Mirrors the fix in pi-args.ts:10 (commit
236
+ // 0dd93e0).
237
+ const runnerPath = path.join(packageRoot(), "src", "runtime", "background-runner.ts");
227
238
  const logPath = path.join(manifest.stateRoot, "background.log");
228
239
  fs.mkdirSync(manifest.stateRoot, { recursive: true });
229
240
 
@@ -993,7 +993,7 @@ export async function runChildPi(input: ChildPiRunInput): Promise<ChildPiRunResu
993
993
  };
994
994
 
995
995
  let softLimitReached = false;
996
- let steerInjectionFailed = false;
996
+ const steerInjectionFailed = false;
997
997
  const maxTurns = input.maxTurns;
998
998
  // FIX (Issue #1): Bound graceTurns to prevent the hard abort condition from
999
999
  // never triggering when an arbitrarily large value is passed.
@@ -27,9 +27,9 @@
27
27
  * grouping many micro-tasks into one worker prompt.
28
28
  */
29
29
 
30
- import { permissionForRole } from "./role-permission.ts";
31
30
  import type { TeamTaskState } from "../state/types.ts";
32
31
  import type { WorkflowConfig, WorkflowStep } from "../workflows/workflow-config.ts";
32
+ import { permissionForRole } from "./role-permission.ts";
33
33
 
34
34
  /** Default cap on group size to prevent context-budget overflow. */
35
35
  export const DEFAULT_MAX_GROUP_SIZE = 5;
@@ -254,7 +254,7 @@ export function makeWorkflowCtx(manifest: TeamRunManifest, opts: MakeWorkflowCtx
254
254
  // round-12 P0-1: in-memory phase state, exposed via non-enumerable getter like __finalResult.
255
255
  // The events log is the durable source of truth for phase boundaries.
256
256
  // round-18 P2-3: hydrate phaseState from a resumed checkpoint (backward compatible when unset).
257
- let phaseState: { currentPhase: string | undefined; phases: string[] } = opts.resumedState
257
+ const phaseState: { currentPhase: string | undefined; phases: string[] } = opts.resumedState
258
258
  ? {
259
259
  currentPhase: opts.resumedState.currentPhase,
260
260
  phases: [...opts.resumedState.phases],
@@ -172,7 +172,7 @@ export class HiddenHandoffService {
172
172
 
173
173
  const priority = options.priority ?? this.inferPriority(summary);
174
174
  const content = this.buildContent(summary);
175
- let recipient = options.to ?? this.getParentAgentId();
175
+ const recipient = options.to ?? this.getParentAgentId();
176
176
 
177
177
  if (!recipient) {
178
178
  // No parent to send to, but we still emit the event
@@ -16,8 +16,8 @@
16
16
  * when proxying from the parent.
17
17
  */
18
18
 
19
- import { type ToolDefinition } from "@earendil-works/pi-coding-agent";
20
- import { type Static, type TSchema } from "@sinclair/typebox";
19
+ import type { ToolDefinition } from "@earendil-works/pi-coding-agent";
20
+ import type { Static, TSchema } from "@sinclair/typebox";
21
21
 
22
22
  export interface McpProxyConfig {
23
23
  /** Whether to enable MCP in the child session. */
@@ -152,10 +152,7 @@ function tryScanJson(text: string): unknown | undefined {
152
152
  const candidate = rest.slice(0, end);
153
153
  try {
154
154
  return JSON.parse(candidate);
155
- } catch {
156
- // Not valid JSON at this position; keep scanning for the next '{'/'['.
157
- continue;
158
- }
155
+ } catch {}
159
156
  }
160
157
  return undefined;
161
158
  }
@@ -278,7 +278,7 @@ export class RetryRunner {
278
278
  * Calculate backoff delay with optional capping.
279
279
  */
280
280
  private calculateBackoff(baseMs: number, attempt: number, multiplier: number, maxBackoffMs?: number): number {
281
- let delay = baseMs * Math.pow(multiplier, attempt - 1);
281
+ let delay = baseMs * multiplier ** (attempt - 1);
282
282
  if (maxBackoffMs !== undefined) {
283
283
  delay = Math.min(delay, maxBackoffMs);
284
284
  }
@@ -11,19 +11,19 @@
11
11
 
12
12
  import { writeFile } from "node:fs/promises";
13
13
  import path from "node:path";
14
- import { permissionForRole } from "./role-permission.ts";
15
- import { runChildPi } from "./child-pi.ts";
16
- import { splitCoalescedOutput } from "./task-runner/output-splitter.ts";
17
- import { buildWorkspaceTree } from "./workspace-tree.ts";
18
- import { saveRunTasks, updateRunStatus } from "../state/state-store.ts";
14
+ import type { AgentConfig } from "../agents/agent-config.ts";
19
15
  import { writeArtifact } from "../state/artifact-store.ts";
20
16
  import { appendEventAsync } from "../state/event-log.ts";
21
- import { createWorkerHeartbeat, touchWorkerHeartbeat } from "./worker-heartbeat.ts";
22
- import type { AgentConfig } from "../agents/agent-config.ts";
23
- import type { CrewRuntimeMode } from "./runtime-resolver.ts";
17
+ import { saveRunTasks, updateRunStatus } from "../state/state-store.ts";
24
18
  import type { TeamRunManifest, TeamTaskState } from "../state/types.ts";
25
19
  import type { WorkflowStep } from "../workflows/workflow-config.ts";
20
+ import { runChildPi } from "./child-pi.ts";
21
+ import { permissionForRole } from "./role-permission.ts";
22
+ import type { CrewRuntimeMode } from "./runtime-resolver.ts";
23
+ import { splitCoalescedOutput } from "./task-runner/output-splitter.ts";
26
24
  import { mergeArtifacts } from "./team-runner-artifacts.ts";
25
+ import { createWorkerHeartbeat, touchWorkerHeartbeat } from "./worker-heartbeat.ts";
26
+ import { buildWorkspaceTree } from "./workspace-tree.ts";
27
27
 
28
28
  export interface CoalescedTaskGroupInput {
29
29
  manifest: TeamRunManifest;
@@ -58,7 +58,7 @@ export function hashToBase36(content: string, length: number): string {
58
58
  */
59
59
  export function calculateAdaptiveLength(existingCount: number, config: AdaptiveIDConfig = DEFAULT_CONFIG): number {
60
60
  for (let length = config.minLength; length <= config.maxLength; length++) {
61
- const totalPossibilities = Math.pow(36, length);
61
+ const totalPossibilities = 36 ** length;
62
62
  const probability = 1 - Math.exp(-(existingCount * existingCount) / (2 * totalPossibilities));
63
63
  if (probability <= config.maxCollisionProbability) {
64
64
  return length;
@@ -1,6 +1,7 @@
1
1
  import * as fs from "node:fs";
2
+ import { flushPendingAtomicWrites } from "../../state/atomic-write.ts";
2
3
  import { withRunLockSync } from "../../state/locks.ts";
3
- import { loadRunManifestById, saveRunTasks } from "../../state/state-store.ts";
4
+ import { loadRunManifestById, saveRunTasksCoalesced } from "../../state/state-store.ts";
4
5
  import type { TaskCheckpointState, TeamRunManifest, TeamTaskState } from "../../state/types.ts";
5
6
  import { logInternalError } from "../../utils/internal-error.ts";
6
7
  import { recordFromTask, upsertCrewAgent } from "../crew-agent-records.ts";
@@ -54,7 +55,16 @@ export function persistSingleTaskUpdate(
54
55
 
55
56
  try {
56
57
  return withRunLockSync(manifest, () => {
57
- retryLoop: for (let attempt = 0; attempt < 100; attempt++) {
58
+ for (let attempt = 0; attempt < 100; attempt++) {
59
+ // F4: persistSingleTaskUpdate now uses saveRunTasksCoalesced below
60
+ // (50ms debounce window). Read-modify-write loops are unsafe under
61
+ // coalescing — a parallel writer's buffered write is invisible to
62
+ // loadRunManifestById until it actually lands. Force any pending
63
+ // coalesced writes to flush first so this read sees the latest
64
+ // durable state. Without this guard, a parallel writer could
65
+ // overwrite our buffered write between our load and our (async)
66
+ // fsync, silently losing the intermediate update.
67
+ flushPendingAtomicWrites();
58
68
  const latest = loadRunManifestById(manifest.cwd, manifest.runId)?.tasks ?? fallbackTasks;
59
69
  merged = updateTask(latest, taskWithCheckpoint);
60
70
 
@@ -69,7 +79,7 @@ export function persistSingleTaskUpdate(
69
79
  if (currentMtime !== baseMtime) {
70
80
  // Another writer committed — their update is in latest, re-merge on top
71
81
  baseMtime = currentMtime;
72
- continue retryLoop;
82
+ continue;
73
83
  }
74
84
 
75
85
  // No concurrent writer — check that our merged result is based on the
@@ -83,7 +93,7 @@ export function persistSingleTaskUpdate(
83
93
  }
84
94
  if (recheckMtime !== baseMtime) {
85
95
  baseMtime = recheckMtime;
86
- continue retryLoop;
96
+ continue;
87
97
  }
88
98
 
89
99
  // Final pre-write mtime check to catch any concurrent writer that completed
@@ -97,10 +107,10 @@ export function persistSingleTaskUpdate(
97
107
  if (preWriteMtime !== baseMtime) {
98
108
  // Another writer committed — retry
99
109
  baseMtime = preWriteMtime;
100
- continue retryLoop;
110
+ continue;
101
111
  }
102
112
 
103
- break retryLoop;
113
+ break;
104
114
  }
105
115
 
106
116
  if (merged === undefined) {
@@ -109,7 +119,12 @@ export function persistSingleTaskUpdate(
109
119
  }
110
120
 
111
121
  try {
112
- saveRunTasks(manifest, merged);
122
+ // F4: coalesced write inside the withRunLockSync critical section.
123
+ // The mtime CAS retry loop above still guards against concurrent
124
+ // non-coalesced writers; the flushPendingAtomicWrites() guard at
125
+ // the top of the retry loop ensures reads see any other coalesced
126
+ // writer's flushed-before-this-call state.
127
+ saveRunTasksCoalesced(manifest, merged);
113
128
  } catch (err) {
114
129
  logInternalError("persistSingleTaskUpdate", err);
115
130
  throw err;
@@ -18,10 +18,8 @@ import type { TeamConfig } from "../teams/team-config.ts";
18
18
  import { logInternalError } from "../utils/internal-error.ts";
19
19
  import type { WorkflowConfig, WorkflowStep } from "../workflows/workflow-config.ts";
20
20
  import { checkBranchFreshness } from "../worktree/branch-freshness.ts";
21
- import { mergeArtifacts } from "./team-runner-artifacts.ts";
22
21
  import { buildSyntheticTerminalEvidence, CrewCancellationError, cancellationReasonFromSignal } from "./cancellation.ts";
23
- import { planCoalescedGroups, buildDispatchUnits } from "./coalesce-tasks.ts";
24
- import { runCoalescedTaskGroup } from "./run-coalesced-task-group.ts";
22
+ import { buildDispatchUnits, planCoalescedGroups } from "./coalesce-tasks.ts";
25
23
  import { resolveBatchConcurrency } from "./concurrency.ts";
26
24
  import { readCrewAgents, saveCrewAgents } from "./crew-agent-records.ts";
27
25
  import type { CrewRuntimeKind } from "./crew-agent-runtime.ts";
@@ -37,6 +35,7 @@ import { evaluateCrewPolicy, summarizePolicyDecisions } from "./policy-engine.ts
37
35
  import { buildRecoveryLedger, shouldRerunFailedTask } from "./recovery-recipes.ts";
38
36
  import { DEFAULT_RETRY_POLICY, executeWithRetry, type RetryPolicy } from "./retry-executor.ts";
39
37
  import { permissionForRole } from "./role-permission.ts";
38
+ import { runCoalescedTaskGroup } from "./run-coalesced-task-group.ts";
40
39
  import { registerRunPromise, rejectRunPromise, resolveRunPromise } from "./run-tracker.ts";
41
40
  import { resolveTaskRuntimeKind } from "./runtime-policy.ts";
42
41
  import type { CrewRuntimeCapabilities } from "./runtime-resolver.ts";
@@ -45,6 +44,7 @@ import { buildExecutionPlan as buildDagExecutionPlan, getReadyTasks as getDagRea
45
44
  import { buildTaskGraphIndex, refreshTaskGraphQueues, taskGraphSnapshot } from "./task-graph-scheduler.ts";
46
45
  import { aggregateTaskOutputs } from "./task-output-context.ts";
47
46
  import { runTeamTask } from "./task-runner.ts";
47
+ import { mergeArtifacts } from "./team-runner-artifacts.ts";
48
48
  import { clearTrackedTaskUsage } from "./usage-tracker.ts";
49
49
  import {
50
50
  createWorkflowStateMachine,
@@ -71,9 +71,7 @@ export function snapshotManifests(cwd: string): Record<string, string> {
71
71
  try {
72
72
  const content = fs.readFileSync(abs);
73
73
  snapshot[rel] = createHash("sha256").update(content).digest("hex");
74
- } catch {
75
- continue; // unreadable (permissions/race) — skip gracefully
76
- }
74
+ } catch {}
77
75
  }
78
76
  return snapshot;
79
77
  }
@@ -615,7 +615,7 @@ function flushOnePendingAtomicWrite(filePath: string): void {
615
615
  throw error;
616
616
  }
617
617
  // Exponential backoff: base delay * 2^(retryCount-1), capped at 30 seconds
618
- const backoffMs = Math.min(30000, current.coalesceMs * Math.pow(2, current.retryCount - 1));
618
+ const backoffMs = Math.min(30000, current.coalesceMs * 2 ** (current.retryCount - 1));
619
619
  const timer = setTimeout(() => flushOnePendingAtomicWrite(filePath), backoffMs);
620
620
  timer.unref();
621
621
  current.timer = timer;
@@ -422,10 +422,16 @@ export function saveRunTasks(manifest: TeamRunManifest, tasks: TeamTaskState[]):
422
422
  * see the previous on-disk content while the write is still buffered).
423
423
  * Bulk update paths that fan out into multiple writer call sites are the
424
424
  * intended use case. Single-update + read-update loops (e.g.
425
- * persistSingleTaskUpdate) should keep using saveRunTasks.
425
+ * persistSingleTaskUpdate) should keep using saveRunTasks — OR call
426
+ * `flushPendingAtomicWrites()` immediately before their read to force
427
+ * any pending coalesced writes to land on disk first.
428
+ *
429
+ * (perf review 2026-07 F4) — now used by persistSingleTaskUpdate's
430
+ * non-terminal checkpoint path; that caller calls flushPendingAtomicWrites()
431
+ * before its read-modify-write load to defeat the stale-read window.
426
432
  */
427
433
  /** @internal */
428
- function saveRunTasksCoalesced(manifest: TeamRunManifest, tasks: TeamTaskState[]): void {
434
+ export function saveRunTasksCoalesced(manifest: TeamRunManifest, tasks: TeamTaskState[]): void {
429
435
  // FIX: Invalidate cache BEFORE atomic write to prevent stale cache serving.
430
436
  invalidateRunCache(manifest.stateRoot);
431
437
  try {
@@ -64,9 +64,7 @@ const IDLE_REASSERT_MAX_MS = 5000;
64
64
  const COMPLETE_FLASH_MS = 1500;
65
65
 
66
66
  /** Injected for tests (defaults write to the real /dev/tty). */
67
- export interface GhosttyWriter {
68
- (seq: string): void;
69
- }
67
+ export type GhosttyWriter = (seq: string) => void;
70
68
 
71
69
  let ghosttyWriter: GhosttyWriter | undefined;
72
70
 
@@ -6,7 +6,7 @@
6
6
  * Uses visibleWidth() for ANSI-aware padding so borders align correctly.
7
7
  */
8
8
 
9
- import { Container, Text, visibleWidth } from "@earendil-works/pi-tui";
9
+ import { type Container, Text, visibleWidth } from "@earendil-works/pi-tui";
10
10
  import type { CrewAgentRecord } from "../../runtime/crew-agent-runtime.ts";
11
11
  import { truncateToWidth } from "../../utils/visual.ts";
12
12
  import type { CrewTheme } from "../theme-adapter.ts";
@@ -85,7 +85,7 @@ function resolveWindowsCanonical(p: string): string {
85
85
  // containment checks: base (from cwd, often long-name) and target
86
86
  // (from os.tmpdir()/mkdtempSync, often short-name) must normalize to
87
87
  // the SAME form or a contained target is wrongly rejected as "outside".
88
- let real = fs.realpathSync.native(p);
88
+ const real = fs.realpathSync.native(p);
89
89
  // Guard against NTFS internal paths (e.g. C:\$Extend\$Deleted)
90
90
  if (real.includes("$Extend") || real.includes("$Deleted")) throw new Error("NTFS internal path");
91
91
  return real;
@@ -267,10 +267,6 @@ export function resolveRealContainedPath(baseDir: string, targetPath: string): s
267
267
  if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
268
268
  // For the final component (target itself), ENOENT is expected for non-existent targets.
269
269
  if (i === resolvedParts.length - 1) continue;
270
- // For non-final components (parent directories), ENOENT is also acceptable —
271
- // the caller will create them before the write operation if needed.
272
- // We only need to ensure no existing path component is a symlink.
273
- continue;
274
270
  }
275
271
  }
276
272