pi-crew 0.10.2 → 0.10.4
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/AGENTS.md +2 -1
- package/CHANGELOG.md +249 -0
- package/README.md +5 -1
- package/dist/index.mjs +10844 -7250
- package/docs/architecture.md +4 -4
- package/docs/commands-reference.md +3 -0
- package/docs/publishing.md +15 -3
- package/install.mjs +90 -39
- package/package.json +9 -3
- package/schema.json +11 -0
- package/scripts/README.md +4 -3
- package/skills/real-test-pi-crew/REPORT-TEMPLATE.md +7 -2
- package/skills/real-test-pi-crew/SKILL.md +428 -82
- package/src/config/config-merge.ts +11 -1
- package/src/config/config-validation.ts +40 -1
- package/src/config/config.ts +28 -6
- package/src/config/defaults.ts +35 -10
- package/src/config/env-vars.ts +27 -2
- package/src/config/migration-validator.ts +113 -0
- package/src/config/types.ts +36 -0
- package/src/extension/cross-extension-rpc.ts +3 -7
- package/src/extension/register.ts +13 -0
- package/src/extension/registration/lifecycle-handlers.ts +40 -9
- package/src/extension/registration/observability.ts +3 -7
- package/src/extension/registration/subagent-tools.ts +3 -7
- package/src/extension/registration/team-tool.ts +56 -12
- package/src/extension/registration/ui.ts +3 -8
- package/src/extension/registration/viewers.ts +3 -10
- package/src/extension/team-manager-command.ts +3 -7
- package/src/extension/team-tool/api/agent-control.ts +17 -10
- package/src/extension/team-tool/api/heartbeat.ts +4 -3
- package/src/extension/team-tool/api/mailbox.ts +33 -20
- package/src/extension/team-tool/api/plan-approval.ts +5 -5
- package/src/extension/team-tool/api/task-claims.ts +8 -7
- package/src/extension/team-tool/cancel.ts +6 -0
- package/src/extension/team-tool/doctor.ts +364 -7
- package/src/extension/team-tool/handle-settings.ts +23 -1
- package/src/extension/team-tool/inspect.ts +10 -2
- package/src/extension/team-tool/run.ts +3 -7
- package/src/extension/team-tool/status.ts +12 -0
- package/src/extension/team-tool.ts +41 -16
- package/src/hooks/registry.ts +62 -56
- package/src/prompt/inbox-poll.ts +90 -0
- package/src/prompt/message-tool.ts +166 -0
- package/src/prompt/prompt-runtime.ts +201 -18
- package/src/prompt/scratchpad-lifecycle.ts +3 -3
- package/src/prompt/surface-worker.ts +720 -0
- package/src/prompt/worker-events-channel.ts +49 -3
- package/src/runtime/async-runner.ts +29 -1
- package/src/runtime/background-runner.ts +43 -42
- package/src/runtime/broker/broker-issuer.ts +27 -2
- package/src/runtime/broker/crew-broker-tokens.ts +56 -4
- package/src/runtime/broker/crew-broker.ts +334 -443
- package/src/runtime/broker/delegate/delegate-event.ts +37 -0
- package/src/runtime/broker/mailbox-observer/mailbox-fanout.ts +59 -0
- package/src/runtime/broker/protocol/connection-state.ts +103 -0
- package/src/runtime/broker/protocol/events-replay.ts +68 -0
- package/src/runtime/broker/protocol/manifest-loader.ts +20 -0
- package/src/runtime/broker/protocol/msg-inbox.ts +69 -0
- package/src/runtime/broker/protocol/request-parsers.ts +175 -0
- package/src/runtime/broker/protocol/wait-auth.ts +46 -0
- package/src/runtime/child-pi/child-pi-spawn.ts +23 -9
- package/src/runtime/child-pi/child-pi-streams.ts +9 -1
- package/src/runtime/child-pi/child-pi.ts +368 -5
- package/src/runtime/crew-agent-records.ts +13 -1
- package/src/runtime/dispatch-batch.ts +12 -1
- package/src/runtime/event-log-tail-source.ts +374 -0
- package/src/runtime/finalize-run.ts +19 -7
- package/src/runtime/foreground-control.ts +19 -6
- package/src/runtime/goal-workflow/dynamic-workflow-context.ts +6 -0
- package/src/runtime/goal-workflow/dynamic-workflow-runner.ts +3 -0
- package/src/runtime/goal-workflow/goal-loop-runner.ts +29 -27
- package/src/runtime/goal-workflow/goal-state-store.ts +3 -0
- package/src/runtime/heartbeat/heartbeat-watcher.ts +3 -3
- package/src/runtime/live-session/live-agent-manager.ts +34 -1
- package/src/runtime/live-session/live-control-realtime.ts +10 -0
- package/src/runtime/live-session/live-session-runtime.ts +47 -27
- package/src/runtime/manifest-cache.ts +128 -17
- package/src/runtime/model/pi-args.ts +59 -65
- package/src/runtime/output/sidechain-output.ts +61 -6
- package/src/runtime/plan-replan.ts +3 -0
- package/src/runtime/process/proc-stat.ts +46 -0
- package/src/runtime/process/zombie-scanner.ts +32 -19
- package/src/runtime/spawn-policy.ts +27 -41
- package/src/runtime/stale-reconciler.ts +28 -3
- package/src/runtime/supervisor-contact.ts +3 -0
- package/src/runtime/surface/degrade.ts +776 -0
- package/src/runtime/surface/herdr-provider.ts +546 -0
- package/src/runtime/surface/launch-script.ts +172 -0
- package/src/runtime/surface/resolve-surface.ts +274 -0
- package/src/runtime/surface/surface-provider.ts +129 -0
- package/src/runtime/surface/surface-spawn.ts +475 -0
- package/src/runtime/surface/tmux-provider.ts +400 -0
- package/src/runtime/task-runner/child-executor.ts +80 -0
- package/src/runtime/task-runner/post-execution.ts +57 -2
- package/src/runtime/task-runner/prompt-builder.ts +1 -0
- package/src/runtime/task-runner/retrieval-orchestrator.ts +191 -56
- package/src/runtime/task-runner/state-helpers.ts +54 -30
- package/src/runtime/task-runner.ts +4 -2
- package/src/runtime/team-runner.ts +104 -3
- package/src/schema/config-schema.ts +24 -0
- package/src/state/atomic-write.ts +219 -40
- package/src/state/coordination/locks.ts +7 -5
- package/src/state/coordination/mailbox.ts +56 -10
- package/src/state/event-log/cursor.ts +413 -23
- package/src/state/event-log/event-log.ts +120 -113
- package/src/state/event-log/sequence-cache.ts +21 -3
- package/src/state/stores/ownership-map.ts +5 -4
- package/src/state/stores/plan-store.ts +12 -0
- package/src/state/stores/state-store.ts +103 -6
- package/src/state/types.ts +51 -0
- package/src/ui/inline-panel/agent-pane.ts +3 -0
- package/src/ui/powerbar-publisher.ts +3 -7
- package/src/ui/render-diff.ts +16 -8
- package/src/ui/run-action-dispatcher.ts +7 -10
- package/src/ui/run-dashboard.ts +87 -42
- package/src/ui/run-event-bus.ts +10 -1
- package/src/ui/run-snapshot-cache.ts +83 -35
- package/src/ui/settings-overlay.ts +4 -1
- package/src/ui/transcript-cache.ts +101 -13
- package/src/ui/transcript-viewer.ts +92 -24
- package/src/ui/widget/index.ts +32 -8
- package/src/utils/visual.ts +43 -0
- package/src/worktree/worktree-manager.ts +65 -4
package/src/ui/widget/index.ts
CHANGED
|
@@ -177,6 +177,14 @@ class CrewWidgetComponent implements WidgetComponent {
|
|
|
177
177
|
private cachedWidth = 0;
|
|
178
178
|
private cachedLines: string[] = [];
|
|
179
179
|
private cachedBaseLines: string[] = [];
|
|
180
|
+
/**
|
|
181
|
+
* PERF (2026-08-24): truncate at build time, re-truncate only on width
|
|
182
|
+
* change — cache hits returned a fresh array + ANSI-aware width measurement
|
|
183
|
+
* per line on every frame. `cachedLines` stays raw (unclipped) so a width
|
|
184
|
+
* change can re-clip without a full rebuild; -1 marks "not truncated yet".
|
|
185
|
+
*/
|
|
186
|
+
private truncatedLines: string[] = [];
|
|
187
|
+
private lastTruncateWidth = -1;
|
|
180
188
|
private cachedTheme: CrewTheme;
|
|
181
189
|
private readonly tui: unknown;
|
|
182
190
|
private readonly unsubscribeTheme: () => void;
|
|
@@ -274,10 +282,23 @@ class CrewWidgetComponent implements WidgetComponent {
|
|
|
274
282
|
this.cacheSignature = "";
|
|
275
283
|
this.cachedBaseLines = [];
|
|
276
284
|
this.cachedLines = [];
|
|
285
|
+
this.truncatedLines = [];
|
|
286
|
+
this.lastTruncateWidth = -1;
|
|
277
287
|
this.cachedBuildSignature = "";
|
|
278
288
|
this.cachedBuildSignatureAt = 0;
|
|
279
289
|
}
|
|
280
290
|
|
|
291
|
+
/**
|
|
292
|
+
* PERF (2026-08-24): clip `cachedLines` to `width` only when the width
|
|
293
|
+
* changed since the last clip — the raw lines are stored unclipped so a
|
|
294
|
+
* resize re-clips them without rebuilding the whole cache.
|
|
295
|
+
*/
|
|
296
|
+
private ensureTruncated(width: number): void {
|
|
297
|
+
if (width === this.lastTruncateWidth) return;
|
|
298
|
+
this.truncatedLines = this.cachedLines.map((line) => truncate(line, width));
|
|
299
|
+
this.lastTruncateWidth = width;
|
|
300
|
+
}
|
|
301
|
+
|
|
281
302
|
/** Poke the host TUI to repaint immediately (defensive: no-op if unavailable). */
|
|
282
303
|
requestRepaint(): void {
|
|
283
304
|
requestRenderTarget(this.tui);
|
|
@@ -326,12 +347,14 @@ class CrewWidgetComponent implements WidgetComponent {
|
|
|
326
347
|
this.cachedWidth = width;
|
|
327
348
|
this.cachedTheme = this.theme;
|
|
328
349
|
this.cacheSignature = signature;
|
|
350
|
+
this.lastTruncateWidth = -1;
|
|
329
351
|
}
|
|
330
352
|
if (runs.length === 0) {
|
|
331
353
|
this.invalidate();
|
|
332
354
|
return [];
|
|
333
355
|
}
|
|
334
|
-
|
|
356
|
+
this.ensureTruncated(width);
|
|
357
|
+
return this.truncatedLines;
|
|
335
358
|
}
|
|
336
359
|
|
|
337
360
|
// Panel cursor/pane state is part of the rendered output, so it belongs in
|
|
@@ -361,6 +384,7 @@ class CrewWidgetComponent implements WidgetComponent {
|
|
|
361
384
|
this.cachedWidth = width;
|
|
362
385
|
this.cachedTheme = this.theme;
|
|
363
386
|
this.cacheSignature = signatureWithPanel;
|
|
387
|
+
this.lastTruncateWidth = -1;
|
|
364
388
|
}
|
|
365
389
|
|
|
366
390
|
if (runs.length === 0) {
|
|
@@ -373,11 +397,13 @@ class CrewWidgetComponent implements WidgetComponent {
|
|
|
373
397
|
return [];
|
|
374
398
|
}
|
|
375
399
|
|
|
400
|
+
this.ensureTruncated(width);
|
|
376
401
|
if (!compactDock) {
|
|
377
402
|
const updatedHeader = `${runningGlyph}${this.cachedBaseLines[0]?.slice(1) ?? ""}`;
|
|
378
403
|
this.cachedLines[0] = truncate(colorWidgetLine(updatedHeader, 0, this.theme), width);
|
|
404
|
+
this.truncatedLines[0] = truncate(this.cachedLines[0], width);
|
|
379
405
|
}
|
|
380
|
-
return this.
|
|
406
|
+
return this.truncatedLines;
|
|
381
407
|
}
|
|
382
408
|
}
|
|
383
409
|
|
|
@@ -441,10 +467,6 @@ export function updateCrewWidget(
|
|
|
441
467
|
// the only place already holding the manifest/snapshot caches — so the row
|
|
442
468
|
// projection is registered here instead of re-reading state on every keypress.
|
|
443
469
|
setPanelRowsProvider(() => panelRowsFromRuns(activeWidgetRuns(ctx.cwd, manifestCache, snapshotCache, preloadedManifests, workspaceId)));
|
|
444
|
-
const lines = buildWidgetLines(ctx.cwd, state.frame, maxLines, runs, state.notificationCount ?? 0, getRenderWidth(), {
|
|
445
|
-
rowStyle,
|
|
446
|
-
...panelDisplayState(),
|
|
447
|
-
});
|
|
448
470
|
const placement = config?.widgetPlacement ?? DEFAULT_UI.widgetPlacement;
|
|
449
471
|
// `bottom` is not a pi widget slot: the dock then renders inside the
|
|
450
472
|
// crew-vibes footer (dock-footer registry). pi's slot calls always use a
|
|
@@ -453,7 +475,9 @@ export function updateCrewWidget(
|
|
|
453
475
|
const dockInFooter = bottomMode && isFooterDockSinkActive();
|
|
454
476
|
const piPlacement: "aboveEditor" | "belowEditor" = bottomMode ? "belowEditor" : placement;
|
|
455
477
|
|
|
456
|
-
|
|
478
|
+
// PERF (2026-08-24): the persistent CrewWidgetComponent renders itself from
|
|
479
|
+
// state.frame; the built line set was only ever used for its .length.
|
|
480
|
+
ctx.ui.setStatus(STATUS_KEY, runs.length > 0 ? statusSummary(runs) : undefined);
|
|
457
481
|
|
|
458
482
|
const shouldClearLegacy = state.legacyCleared !== true || state.lastPlacement !== placement;
|
|
459
483
|
if (shouldClearLegacy) {
|
|
@@ -461,7 +485,7 @@ export function updateCrewWidget(
|
|
|
461
485
|
state.legacyCleared = true;
|
|
462
486
|
}
|
|
463
487
|
|
|
464
|
-
if (
|
|
488
|
+
if (runs.length === 0) {
|
|
465
489
|
if (state.lastVisibility !== "hidden" || state.lastPlacement !== placement) {
|
|
466
490
|
setExtensionWidget(ctx, WIDGET_KEY, undefined, { placement: piPlacement });
|
|
467
491
|
setExtensionWidget(ctx, TASKS_WIDGET_KEY, undefined, { placement: "aboveEditor" });
|
package/src/utils/visual.ts
CHANGED
|
@@ -4,6 +4,12 @@ export const ANSI_PATTERN = /\u001b\[[0-?]*[ -/]*[@-~]/g;
|
|
|
4
4
|
|
|
5
5
|
const WIDTH_CACHE_LIMIT = 256;
|
|
6
6
|
const widthCache = new Map<string, number>();
|
|
7
|
+
// PERF (2026-08-24): truncateToWidth/wrapHard measure ONE codepoint at a time;
|
|
8
|
+
// those 1-2 char keys thrashed the 256-entry LRU, evicting every long-string
|
|
9
|
+
// entry that could actually hit. Short keys get their own cache (bounded
|
|
10
|
+
// alphabet — hit rate ~100%) so the LRU serves only whole lines/segments.
|
|
11
|
+
const SHORT_WIDTH_CACHE_LIMIT = 1024;
|
|
12
|
+
const shortWidthCache = new Map<string, number>();
|
|
7
13
|
|
|
8
14
|
// NOTE: width measurement is delegated to pi-tui's `visibleWidth` (see below).
|
|
9
15
|
// pi-tui is the renderer that HARD-ABORTS the session if any line exceeds the
|
|
@@ -20,6 +26,17 @@ export function visibleWidth(value: string): number {
|
|
|
20
26
|
// LRU cache on top (pi-tui caches too, but this avoids repeated calls for the
|
|
21
27
|
// hot render path).
|
|
22
28
|
if (value.length > 4096) return tuiVisibleWidth(value);
|
|
29
|
+
if (value.length <= 2) {
|
|
30
|
+
const s = shortWidthCache.get(value);
|
|
31
|
+
if (s !== undefined) return s;
|
|
32
|
+
const w = tuiVisibleWidth(value);
|
|
33
|
+
if (shortWidthCache.size >= SHORT_WIDTH_CACHE_LIMIT) {
|
|
34
|
+
const first = shortWidthCache.keys().next().value;
|
|
35
|
+
if (first !== undefined) shortWidthCache.delete(first);
|
|
36
|
+
}
|
|
37
|
+
shortWidthCache.set(value, w);
|
|
38
|
+
return w;
|
|
39
|
+
}
|
|
23
40
|
const cached = widthCache.get(value);
|
|
24
41
|
if (cached !== undefined) return cached;
|
|
25
42
|
const length = tuiVisibleWidth(value);
|
|
@@ -33,6 +50,7 @@ export function visibleWidth(value: string): number {
|
|
|
33
50
|
|
|
34
51
|
export function __test__clearVisibleWidthCache(): void {
|
|
35
52
|
widthCache.clear();
|
|
53
|
+
shortWidthCache.clear();
|
|
36
54
|
}
|
|
37
55
|
|
|
38
56
|
export function __test__visibleWidthCacheSize(): number {
|
|
@@ -205,3 +223,28 @@ export function truncateToVisualLines(text: string, maxVisualLines: number, widt
|
|
|
205
223
|
const truncated = visualLines.slice(-limit);
|
|
206
224
|
return { visualLines: truncated, skippedCount: visualLines.length - limit };
|
|
207
225
|
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* PERF (2026-08-24): tail-windowed twin of truncateToVisualLines. Wraps lines
|
|
229
|
+
* from the END backwards and stops as soon as `maxVisualLines` visual lines
|
|
230
|
+
* exist — O(visible window) instead of O(whole transcript). `skippedCount`
|
|
231
|
+
* counts skipped SOURCE lines (a lower bound on skipped visual lines; the
|
|
232
|
+
* caller's total display therefore reads "≥ N lines" when scrolled to bottom).
|
|
233
|
+
*/
|
|
234
|
+
export function truncateToVisualLinesTail(text: string, maxVisualLines: number, width: number, paddingX = 0): VisualTruncateResult {
|
|
235
|
+
if (!text) return { visualLines: [], skippedCount: 0 };
|
|
236
|
+
const effectiveWidth = Math.max(1, width - paddingX * 2);
|
|
237
|
+
const limit = Math.max(1, maxVisualLines);
|
|
238
|
+
const sourceLines = text.split("\n");
|
|
239
|
+
const wrapped: string[][] = [];
|
|
240
|
+
let collected = 0;
|
|
241
|
+
let index = sourceLines.length - 1;
|
|
242
|
+
while (index >= 0 && collected < limit) {
|
|
243
|
+
const w = wrapHard(pad(sourceLines[index]!, Math.max(0, effectiveWidth)).trimEnd(), effectiveWidth);
|
|
244
|
+
wrapped.push(w);
|
|
245
|
+
collected += w.length;
|
|
246
|
+
index -= 1;
|
|
247
|
+
}
|
|
248
|
+
const visualLines = wrapped.reverse().flat().slice(-limit);
|
|
249
|
+
return { visualLines, skippedCount: index + 1 };
|
|
250
|
+
}
|
|
@@ -129,19 +129,59 @@ function sanitizeBranchPart(value: string): string {
|
|
|
129
129
|
);
|
|
130
130
|
}
|
|
131
131
|
|
|
132
|
+
// PERF (2026-08-24): the sync path (prepareTaskWorkspace — per task!) ran
|
|
133
|
+
// findGitRoot (rev-parse spawn) + assertCleanLeader (status --porcelain spawn)
|
|
134
|
+
// on every call, and the reuse path ran assertCleanLeader twice. Mirror the
|
|
135
|
+
// async caches below (_gitRootCache/_cleanLeaderCache): same repoRoot → same
|
|
136
|
+
// answer within a run.
|
|
137
|
+
//
|
|
138
|
+
// Mirrored invalidation semantics (from the async caches below):
|
|
139
|
+
// - gitRoot: plain Map, NO TTL — the async _gitRootCache is process-lifetime with
|
|
140
|
+
// a FIFO cap; a cwd's repo root cannot change without moving .git itself.
|
|
141
|
+
// - cleanLeader: ONLY clean verdicts are cached — the async _cleanLeaderCache is
|
|
142
|
+
// a Set of verified-clean repoRoots and a dirty verdict throws BEFORE caching,
|
|
143
|
+
// so every call while dirty re-probes (mirrored here). The async cache has NO
|
|
144
|
+
// TTL (a clean verdict is trusted for the whole process), so the 30s TTL below
|
|
145
|
+
// is strictly MORE conservative than the async path: a leader dirtied after a
|
|
146
|
+
// cached clean verdict can slip through for at most 30s here vs. forever on
|
|
147
|
+
// the async path. The reuse-path re-check in prepareTaskWorkspace deletes the
|
|
148
|
+
// entry first, mirroring `_cleanLeaderCache.delete(repoRoot)` in the async variant.
|
|
149
|
+
const SYNC_WT_CACHE_TTL_MS = 30_000;
|
|
150
|
+
const MAX_SYNC_GIT_ROOT_CACHE = 256;
|
|
151
|
+
const MAX_SYNC_CLEAN_LEADER_CACHE = 256;
|
|
152
|
+
const syncGitRootCache = new Map<string, string>();
|
|
153
|
+
const syncCleanLeaderCache = new Map<string, { clean: boolean; expiresAt: number }>();
|
|
154
|
+
|
|
132
155
|
export function findGitRoot(cwd: string): string {
|
|
133
|
-
|
|
156
|
+
const cached = syncGitRootCache.get(cwd);
|
|
157
|
+
if (cached) return cached;
|
|
158
|
+
const root = git(cwd, ["rev-parse", "--show-toplevel"]);
|
|
159
|
+
syncGitRootCache.set(cwd, root);
|
|
160
|
+
if (syncGitRootCache.size > MAX_SYNC_GIT_ROOT_CACHE) {
|
|
161
|
+
const oldest = syncGitRootCache.keys().next().value;
|
|
162
|
+
if (oldest !== undefined) syncGitRootCache.delete(oldest);
|
|
163
|
+
}
|
|
164
|
+
return root;
|
|
134
165
|
}
|
|
135
166
|
|
|
136
167
|
export function assertCleanLeader(repoRoot: string): void {
|
|
168
|
+
const cached = syncCleanLeaderCache.get(repoRoot);
|
|
169
|
+
if (cached?.clean && Date.now() < cached.expiresAt) return;
|
|
137
170
|
// H-7 follow-up: --untracked-files=no so pi-crew's own auto-created .gitignore
|
|
138
171
|
// (and any other untracked files the user hasn't staged) doesn't block worktree mode.
|
|
139
172
|
// The worktree contract is "no tracked changes" — untracked files are safe since
|
|
140
173
|
// they're either in .gitignore or the user can decide later.
|
|
141
174
|
const status = git(repoRoot, ["status", "--porcelain", "--untracked-files=no"]);
|
|
142
175
|
if (status.trim()) {
|
|
176
|
+
// Dirty verdicts are never cached (async mirror): the user may commit/stash
|
|
177
|
+
// seconds later, so the next call must re-probe.
|
|
143
178
|
throw new Error("Worktree mode requires a clean leader repository. Commit/stash changes or use workspaceMode: 'single'.");
|
|
144
179
|
}
|
|
180
|
+
syncCleanLeaderCache.set(repoRoot, { clean: true, expiresAt: Date.now() + SYNC_WT_CACHE_TTL_MS });
|
|
181
|
+
if (syncCleanLeaderCache.size > MAX_SYNC_CLEAN_LEADER_CACHE) {
|
|
182
|
+
const oldest = syncCleanLeaderCache.keys().next().value;
|
|
183
|
+
if (oldest !== undefined) syncCleanLeaderCache.delete(oldest);
|
|
184
|
+
}
|
|
145
185
|
}
|
|
146
186
|
|
|
147
187
|
// --- Async versions ---
|
|
@@ -453,6 +493,25 @@ function pruneStaleWorktrees(repoRoot: string): void {
|
|
|
453
493
|
}
|
|
454
494
|
}
|
|
455
495
|
|
|
496
|
+
// PERF (2026-08-24): worktree prune is a repo-level write; running it per
|
|
497
|
+
// task in a 50-task run is 50 prunes. At most once per repoRoot per minute.
|
|
498
|
+
// (The async variant memoizes at-most-once per process — see P1-11 above —
|
|
499
|
+
// so a 60s window prunes strictly more often than the async path already does.)
|
|
500
|
+
// R5-L2 mirror: FIFO-cap the key set so the map cannot grow unbounded.
|
|
501
|
+
const PRUNE_MIN_INTERVAL_MS = 60_000;
|
|
502
|
+
const MAX_PRUNE_THROTTLE_KEYS = 128;
|
|
503
|
+
const lastPruneAt = new Map<string, number>();
|
|
504
|
+
function pruneStaleWorktreesThrottled(repoRoot: string): void {
|
|
505
|
+
const last = lastPruneAt.get(repoRoot) ?? 0;
|
|
506
|
+
if (Date.now() - last < PRUNE_MIN_INTERVAL_MS) return;
|
|
507
|
+
lastPruneAt.set(repoRoot, Date.now());
|
|
508
|
+
if (lastPruneAt.size > MAX_PRUNE_THROTTLE_KEYS) {
|
|
509
|
+
const oldest = lastPruneAt.keys().next().value;
|
|
510
|
+
if (oldest !== undefined) lastPruneAt.delete(oldest);
|
|
511
|
+
}
|
|
512
|
+
pruneStaleWorktrees(repoRoot); // original body — best-effort, failures swallowed
|
|
513
|
+
}
|
|
514
|
+
|
|
456
515
|
async function branchExistsAsync(repoRoot: string, branch: string): Promise<{ local: boolean; remoteOnly: boolean }> {
|
|
457
516
|
let local = false;
|
|
458
517
|
try {
|
|
@@ -818,11 +877,13 @@ export function prepareTaskWorkspace(manifest: TeamRunManifest, task: TeamTaskSt
|
|
|
818
877
|
if (mergedReused.length > 0) {
|
|
819
878
|
overlaySeedPaths(repoRoot, worktreePath, mergedReused);
|
|
820
879
|
}
|
|
821
|
-
// Re-validate leader is still clean before reusing — leader state may have changed since first preparation
|
|
880
|
+
// Re-validate leader is still clean before reusing — leader state may have changed since first preparation.
|
|
881
|
+
// Mirrors prepareTaskWorkspaceAsync: delete the cached verdict so this re-check probes live state.
|
|
882
|
+
syncCleanLeaderCache.delete(repoRoot);
|
|
822
883
|
assertCleanLeader(repoRoot);
|
|
823
884
|
return { cwd: worktreePath, worktreePath, branch, reused: true };
|
|
824
885
|
}
|
|
825
|
-
|
|
886
|
+
pruneStaleWorktreesThrottled(repoRoot);
|
|
826
887
|
const exists = branchExists(repoRoot, branch);
|
|
827
888
|
let worktreeCreated = false;
|
|
828
889
|
try {
|
|
@@ -1141,7 +1202,7 @@ export function prepareAgentWorktree(manifest: TeamRunManifest, agentId: string)
|
|
|
1141
1202
|
const stamp = `${Date.now()}-${randomBytes(4).toString("hex")}`;
|
|
1142
1203
|
const worktreePath = path.join(worktreeRoot, `${sanitizedAgentId}-${stamp}`);
|
|
1143
1204
|
const branch = `pi-crew/${sanitizedRunId}/${sanitizedAgentId}-${stamp}`;
|
|
1144
|
-
|
|
1205
|
+
pruneStaleWorktreesThrottled(repoRoot);
|
|
1145
1206
|
git(repoRoot, ["worktree", "add", "-b", branch, worktreePath, "HEAD"]);
|
|
1146
1207
|
const nodeModulesLinked =
|
|
1147
1208
|
loadedConfig.config.worktree?.linkNodeModules === true ? linkNodeModulesIfPresent(repoRoot, worktreePath) : false;
|