pi-crew 0.11.2 → 0.11.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/CHANGELOG.md +44 -1
- package/dist/index.mjs +491 -493
- package/package.json +5 -2
- package/skills/real-test-pi-crew/SKILL.md +19 -11
- package/src/extension/async-notifier.ts +32 -8
- package/src/extension/crew-input-router.ts +9 -2
- package/src/extension/plan-orchestrate.ts +19 -3
- package/src/extension/registration/commands/shared.ts +11 -10
- package/src/extension/registration/foreground-run-controller.ts +2 -2
- package/src/extension/registration/lazy-configurers.ts +4 -1
- package/src/extension/registration/lifecycle-handlers.ts +16 -10
- package/src/extension/registration/lifecycle.ts +2 -0
- package/src/extension/registration/observability.ts +27 -18
- package/src/extension/registration/team-tool.ts +9 -2
- package/src/extension/registration/wire-cross-extension.ts +2 -0
- package/src/extension/team-manager-command.ts +3 -2
- package/src/extension/team-tool/goal-wrap.ts +7 -0
- package/src/extension/team-tool/goal.ts +12 -0
- package/src/extension/team-tool/parallel-dispatch.ts +16 -1
- package/src/extension/team-tool/run-intent.ts +1 -1
- package/src/extension/team-tool/run.ts +1 -1
- package/src/runtime/async-runner.ts +36 -0
- package/src/runtime/broker/crew-broker.ts +61 -62
- package/src/runtime/broker/delegate/delegate-event.ts +12 -8
- package/src/runtime/broker/protocol/connection-state.ts +10 -0
- package/src/runtime/broker/protocol/lock-busy.ts +76 -0
- package/src/runtime/delegate-spawn.ts +16 -2
- package/src/runtime/deterministic-ast.ts +14 -2
- package/src/runtime/goal-workflow/dynamic-workflow-context.ts +78 -58
- package/src/runtime/goal-workflow/dynamic-workflow-runner.ts +14 -2
- package/src/runtime/goal-workflow/goal-loop-runner.ts +32 -0
- package/src/runtime/group-join.ts +11 -3
- package/src/runtime/live-session/live-session-runtime.ts +1 -1
- package/src/runtime/model/runtime-warmup.ts +2 -0
- package/src/runtime/output/output-validator.ts +10 -5
- package/src/runtime/plan-approval.ts +14 -1
- package/src/runtime/recovery/crash-recovery.ts +75 -52
- package/src/runtime/subagent-manager.ts +43 -13
- package/src/runtime/task-runner/child-executor.ts +17 -6
- package/src/runtime/task-runner/post-execution.ts +44 -14
- package/src/runtime/task-runner/prompt-builder.ts +8 -0
- package/src/runtime/task-runner/retrieval-orchestrator.ts +36 -14
- package/src/runtime/task-runner.ts +7 -0
- package/src/runtime/team-runner.ts +4 -1
- package/src/runtime/verification/verification-worktree.ts +3 -0
- package/src/state/coordination/mailbox.ts +7 -2
- package/src/state/stores/state-store.ts +8 -1
- package/src/ui/dashboard-panes/agents-pane.ts +11 -8
- package/src/ui/inline-panel/index.ts +1 -1
- package/src/ui/render-diff.ts +19 -6
- package/src/ui/run-dashboard.ts +11 -1
- package/src/ui/run-snapshot-cache.ts +9 -3
- package/src/ui/widget/task-list.ts +8 -5
- package/src/ui/widget/widget-model.ts +3 -1
- package/tsconfig.json +2 -0
- package/src/ui/card-colors.ts +0 -168
- package/src/utils/completion-dedupe.ts +0 -63
- package/src/utils/resolve-shell.ts +0 -36
|
@@ -85,8 +85,18 @@ export interface TaskExecutionResult {
|
|
|
85
85
|
transcriptPath: string | undefined;
|
|
86
86
|
terminalEvidence: OperationTerminalEvidence[];
|
|
87
87
|
startupEvidence: import("../heartbeat/worker-startup.ts").WorkerStartupEvidence;
|
|
88
|
+
/** Which child-executor fallback branch produced the result artifact
|
|
89
|
+
* content (rawFinalText → finalText → stdout → stderr → findings →
|
|
90
|
+
* "(no output)"). Finding #2 (2026-09-29 battery): "stderr"/"none" mean
|
|
91
|
+
* every authoritative worker output source was empty — finalizeTaskResult
|
|
92
|
+
* fails those DETERMINISTICALLY; the stderr-noise classifier stays a
|
|
93
|
+
* defense layer, never the verdict oracle. */
|
|
94
|
+
resultSource?: ResultSource;
|
|
88
95
|
}
|
|
89
96
|
|
|
97
|
+
/** Result-artifact provenance tags (see {@link TaskExecutionResult.resultSource}). */
|
|
98
|
+
export type ResultSource = "rawFinalText" | "finalText" | "stdout" | "stderr" | "findings" | "none";
|
|
99
|
+
|
|
90
100
|
/** Final persisted task state returned by finalizeTaskResult. */
|
|
91
101
|
export interface FinalTaskResult {
|
|
92
102
|
manifest: TeamRunManifest;
|
|
@@ -328,24 +338,39 @@ export async function finalizeTaskResult(ctx: TaskExecutionContext, execResult:
|
|
|
328
338
|
// result ALWAYS surfaces in at least one authoritative source);
|
|
329
339
|
// gate 2 — the persisted artifact is empty/'(no output)'/whitespace OR
|
|
330
340
|
// isStderrOnlyResult says every line is strict log noise.
|
|
331
|
-
//
|
|
332
|
-
//
|
|
333
|
-
//
|
|
341
|
+
// Finding #2 (2026-09-29 battery): gate 2 alone is not a verdict oracle —
|
|
342
|
+
// the strict-noise classifier missed colon-bearing/camelCase extension
|
|
343
|
+
// tags (`[pi-crew:crash-recovery.reconcileStaleRuns]`, lock-contention
|
|
344
|
+
// noise about a SIBLING run), so attempts whose stderr carried them
|
|
345
|
+
// completed tasks with 100%-noise artifacts while sibling attempts on the
|
|
346
|
+
// same runs failed — verdict divergence depended on per-attempt stderr
|
|
347
|
+
// composition. The child-executor fallback chain now tags the winning
|
|
348
|
+
// branch (resultSource); "stderr"/"none"-sourced artifacts fail
|
|
349
|
+
// UNCONDITIONALLY (classifier-independent), making empty/stderr-only
|
|
350
|
+
// results never complete a task, uniformly across roles.
|
|
351
|
+
// A read error on the artifact is NOT a failure (conservative) — unless
|
|
352
|
+
// the source tag already proves the artifact is stderr/none-sourced.
|
|
353
|
+
// Mirrors the mutation-guard fail-mode precedent: error marker + exitCode
|
|
354
|
+
// bump + last modelAttempt success:false → status flips to "failed" (retryable).
|
|
334
355
|
if (!error) {
|
|
335
356
|
const finalTextEmpty = !parsedOutput?.finalText?.trim();
|
|
336
357
|
const finalStdoutEmpty = !finalStdout?.trim();
|
|
337
|
-
|
|
358
|
+
const resultSource = execResult.resultSource;
|
|
359
|
+
const degradedResultSource = resultSource === "stderr" || resultSource === "none";
|
|
360
|
+
if (finalTextEmpty && finalStdoutEmpty && (resultArtifact?.path || degradedResultSource)) {
|
|
338
361
|
let artifactContent: string | undefined;
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
362
|
+
if (resultArtifact?.path) {
|
|
363
|
+
try {
|
|
364
|
+
artifactContent = readFileSync(resultArtifact.path, "utf8");
|
|
365
|
+
} catch {
|
|
366
|
+
artifactContent = undefined; // unreadable artifact — do not fail on read errors
|
|
367
|
+
}
|
|
343
368
|
}
|
|
344
|
-
if (artifactContent !== undefined) {
|
|
345
|
-
const trimmedArtifact = artifactContent.trim();
|
|
369
|
+
if (artifactContent !== undefined || degradedResultSource) {
|
|
370
|
+
const trimmedArtifact = (artifactContent ?? "").trim();
|
|
346
371
|
const emptyArtifact = trimmedArtifact === "" || trimmedArtifact === "(no output)";
|
|
347
|
-
const stderrOnlyArtifact = !emptyArtifact && isStderrOnlyResult(artifactContent);
|
|
348
|
-
if (emptyArtifact || stderrOnlyArtifact) {
|
|
372
|
+
const stderrOnlyArtifact = !emptyArtifact && isStderrOnlyResult(artifactContent ?? "");
|
|
373
|
+
if (degradedResultSource || emptyArtifact || stderrOnlyArtifact) {
|
|
349
374
|
error = "Result artifact is empty or stderr-only (failureCause: empty-or-stderr-only-result)";
|
|
350
375
|
exitCode = exitCode === 0 ? 1 : exitCode;
|
|
351
376
|
if (modelAttempts?.length) {
|
|
@@ -359,7 +384,11 @@ export async function finalizeTaskResult(ctx: TaskExecutionContext, execResult:
|
|
|
359
384
|
structurePreserved: false,
|
|
360
385
|
issues: [
|
|
361
386
|
`empty-or-stderr-only-result: ${
|
|
362
|
-
|
|
387
|
+
degradedResultSource && !emptyArtifact && !stderrOnlyArtifact
|
|
388
|
+
? `result artifact was sourced from the ${resultSource} fallback (no authoritative worker output)`
|
|
389
|
+
: emptyArtifact
|
|
390
|
+
? "result artifact is empty"
|
|
391
|
+
: "result artifact contains only stderr/session-log noise"
|
|
363
392
|
}`,
|
|
364
393
|
],
|
|
365
394
|
};
|
|
@@ -373,7 +402,8 @@ export async function finalizeTaskResult(ctx: TaskExecutionContext, execResult:
|
|
|
373
402
|
structurePreserved: false,
|
|
374
403
|
issues: outputValidation.issues,
|
|
375
404
|
failureCause: "empty-or-stderr-only-result",
|
|
376
|
-
|
|
405
|
+
...(resultSource ? { resultSource } : {}),
|
|
406
|
+
resultPath: resultArtifact?.path,
|
|
377
407
|
},
|
|
378
408
|
});
|
|
379
409
|
}
|
|
@@ -248,6 +248,14 @@ export async function computeStablePrefixComponents(
|
|
|
248
248
|
|
|
249
249
|
const components: StableComponents = { treeBlock, suggestedFilesBlock, knowledgeFragment };
|
|
250
250
|
stableComponentCache.set(cacheKey, components);
|
|
251
|
+
// RR-021 WI-4.3f: same insertion-order eviction cap as the stableIOCache
|
|
252
|
+
// sibling below — stableComponentCache had NO cap (unbounded across a
|
|
253
|
+
// long session with many distinct runIds: the key includes runId).
|
|
254
|
+
while (stableComponentCache.size > 256) {
|
|
255
|
+
const oldest = stableComponentCache.keys().next().value;
|
|
256
|
+
if (oldest === undefined) break;
|
|
257
|
+
stableComponentCache.delete(oldest);
|
|
258
|
+
}
|
|
251
259
|
// Populate the cross-run cache. Clamp size to avoid unbounded growth across
|
|
252
260
|
// long sessions with many distinct (cwd, step) combos.
|
|
253
261
|
stableIOCache.set(ioKey, { ...components, at: now });
|
|
@@ -160,6 +160,9 @@ interface RipgrepAvailable {
|
|
|
160
160
|
}
|
|
161
161
|
|
|
162
162
|
let cachedRgCheck: RipgrepAvailable | undefined;
|
|
163
|
+
/** RR-021 WI-1.3: memoize the in-flight detection so concurrent first calls
|
|
164
|
+
* spawn rg at most once instead of one process per caller. */
|
|
165
|
+
let inFlightRgCheck: Promise<RipgrepAvailable> | undefined;
|
|
163
166
|
|
|
164
167
|
/**
|
|
165
168
|
* PERF round 3: per-cwd cache of the rg discovery result (relative paths,
|
|
@@ -196,45 +199,64 @@ function storeDiscovered(cwd: string, files: string[]): void {
|
|
|
196
199
|
* Detect ripgrep availability once per process. Uses `rg --version` and
|
|
197
200
|
* catches ENOENT or non-zero exit. Cached so the cost (one spawn) is
|
|
198
201
|
* paid only on the first retrieval cycle.
|
|
202
|
+
*
|
|
203
|
+
* RR-021 WI-1.3 hardening: a spawned-but-wedged rg no longer hangs detection
|
|
204
|
+
* forever — a 3s unref'd SIGKILL timer defaults to `{ available: false }`.
|
|
199
205
|
*/
|
|
200
206
|
export async function detectRipgrep(): Promise<RipgrepAvailable> {
|
|
201
207
|
if (cachedRgCheck !== undefined) return cachedRgCheck;
|
|
202
|
-
|
|
208
|
+
if (inFlightRgCheck !== undefined) return inFlightRgCheck;
|
|
209
|
+
inFlightRgCheck = new Promise<RipgrepAvailable>((resolve) => {
|
|
203
210
|
let settled = false;
|
|
211
|
+
let killTimer: NodeJS.Timeout | undefined;
|
|
212
|
+
const settle = (result: RipgrepAvailable): void => {
|
|
213
|
+
if (settled) return;
|
|
214
|
+
settled = true;
|
|
215
|
+
if (killTimer !== undefined) clearTimeout(killTimer);
|
|
216
|
+
cachedRgCheck = result;
|
|
217
|
+
resolve(result);
|
|
218
|
+
};
|
|
204
219
|
try {
|
|
205
220
|
const child = spawn("rg", ["--version"], { stdio: ["ignore", "pipe", "pipe"] });
|
|
221
|
+
// 3s hard deadline. unref'd so the timer never holds the event loop open.
|
|
222
|
+
killTimer = setTimeout(() => {
|
|
223
|
+
try {
|
|
224
|
+
child.kill("SIGKILL");
|
|
225
|
+
} catch {
|
|
226
|
+
// already dead — the close handler settles the result
|
|
227
|
+
}
|
|
228
|
+
settle({ available: false });
|
|
229
|
+
}, 3000);
|
|
230
|
+
killTimer.unref();
|
|
206
231
|
let stdout = "";
|
|
207
232
|
child.stdout?.on("data", (chunk) => {
|
|
208
233
|
stdout += chunk.toString("utf-8");
|
|
209
234
|
});
|
|
210
235
|
child.on("error", () => {
|
|
211
|
-
|
|
212
|
-
settled = true;
|
|
213
|
-
cachedRgCheck = { available: false };
|
|
214
|
-
resolve(cachedRgCheck);
|
|
236
|
+
settle({ available: false });
|
|
215
237
|
});
|
|
216
238
|
child.on("close", (code) => {
|
|
217
|
-
if (settled) return;
|
|
218
|
-
settled = true;
|
|
219
239
|
if (code === 0) {
|
|
220
|
-
|
|
240
|
+
settle({ available: true, version: stdout.split("\n")[0] ?? undefined });
|
|
221
241
|
} else {
|
|
222
|
-
|
|
242
|
+
settle({ available: false });
|
|
223
243
|
}
|
|
224
|
-
resolve(cachedRgCheck);
|
|
225
244
|
});
|
|
226
245
|
} catch {
|
|
227
|
-
|
|
228
|
-
settled = true;
|
|
229
|
-
cachedRgCheck = { available: false };
|
|
230
|
-
resolve(cachedRgCheck);
|
|
246
|
+
settle({ available: false });
|
|
231
247
|
}
|
|
232
248
|
});
|
|
249
|
+
try {
|
|
250
|
+
return await inFlightRgCheck;
|
|
251
|
+
} finally {
|
|
252
|
+
inFlightRgCheck = undefined;
|
|
253
|
+
}
|
|
233
254
|
}
|
|
234
255
|
|
|
235
256
|
/** @internal Test-only: reset the ripgrep detection cache. */
|
|
236
257
|
export function __test_resetRipgrepCache(): void {
|
|
237
258
|
cachedRgCheck = undefined;
|
|
259
|
+
inFlightRgCheck = undefined;
|
|
238
260
|
}
|
|
239
261
|
|
|
240
262
|
/** @internal Test-only: reset the discovery cache. */
|
|
@@ -147,6 +147,11 @@ export async function runTeamTask(input: TaskRunnerInput): Promise<{ manifest: T
|
|
|
147
147
|
// branch. Leaving them undefined preserves those branches' behavior.
|
|
148
148
|
let surfaceLost: TaskExecutionResult["surfaceLost"];
|
|
149
149
|
let rawFinalText: string | undefined;
|
|
150
|
+
// Finding #2 (2026-09-29 battery): result-artifact provenance tag from the
|
|
151
|
+
// child-executor fallback chain — MUST be forwarded like surfaceLost/
|
|
152
|
+
// rawFinalText above, or finalizeTaskResult's deterministic
|
|
153
|
+
// stderr/none-source gate silently degrades to classifier-only.
|
|
154
|
+
let resultSource: TaskExecutionResult["resultSource"];
|
|
150
155
|
if (runtimeKind === "child-process") {
|
|
151
156
|
// CORE-5 extraction 4: the entire child-process branch (model routing +
|
|
152
157
|
// model-fallback attempt loop, runWorker callbacks, R3 listener-leak
|
|
@@ -169,6 +174,7 @@ export async function runTeamTask(input: TaskRunnerInput): Promise<{ manifest: T
|
|
|
169
174
|
startupEvidence = child.startupEvidence;
|
|
170
175
|
surfaceLost = child.surfaceLost;
|
|
171
176
|
rawFinalText = child.rawFinalText;
|
|
177
|
+
resultSource = child.resultSource;
|
|
172
178
|
} else if (runtimeKind === "live-session") {
|
|
173
179
|
// LAZY: live-executor is only needed for live-session runtime branches.
|
|
174
180
|
const { runLiveTask } = await import("./task-runner/live-executor.ts");
|
|
@@ -237,6 +243,7 @@ export async function runTeamTask(input: TaskRunnerInput): Promise<{ manifest: T
|
|
|
237
243
|
startupEvidence,
|
|
238
244
|
surfaceLost,
|
|
239
245
|
rawFinalText,
|
|
246
|
+
resultSource,
|
|
240
247
|
};
|
|
241
248
|
return await finalizeTaskResult(ctx, execResult);
|
|
242
249
|
} finally {
|
|
@@ -1170,7 +1170,10 @@ async function executeTeamRunCore(
|
|
|
1170
1170
|
// sync spin-lock (Atomics.wait) doesn't block the event loop every batch.
|
|
1171
1171
|
// The terminal saveCrewAgents at closeout remains durable + flushes this.
|
|
1172
1172
|
saveCrewAgentsCoalesced(manifest, recordsForMaterializedTasks(manifest, tasks, runtimeKind));
|
|
1173
|
-
|
|
1173
|
+
// RR-021 WI-4.3a: Set lookup — settledTaskIds.includes() inside filter is
|
|
1174
|
+
// O(n*m) on every settled batch.
|
|
1175
|
+
const settledSet = new Set(settledTaskIds);
|
|
1176
|
+
const completedBatch = tasks.filter((t) => settledSet.has(t.id));
|
|
1174
1177
|
const batchArtifact = writeArtifact(manifest.artifactsRoot, {
|
|
1175
1178
|
kind: "summary",
|
|
1176
1179
|
relativePath: `batches/${batchSummarySlug(settledTaskIds)}.md`,
|
|
@@ -66,6 +66,7 @@ export function checkWorktreeSandboxAvailable(cwd: string): { available: true; c
|
|
|
66
66
|
cwd,
|
|
67
67
|
stdio: ["ignore", "pipe", "pipe"],
|
|
68
68
|
encoding: "utf-8",
|
|
69
|
+
timeout: 5000,
|
|
69
70
|
}).trim();
|
|
70
71
|
if (!toplevel)
|
|
71
72
|
return {
|
|
@@ -77,6 +78,7 @@ export function checkWorktreeSandboxAvailable(cwd: string): { available: true; c
|
|
|
77
78
|
cwd,
|
|
78
79
|
stdio: ["ignore", "pipe", "pipe"],
|
|
79
80
|
encoding: "utf-8",
|
|
81
|
+
timeout: 5000,
|
|
80
82
|
}).trim();
|
|
81
83
|
if (!commitSha)
|
|
82
84
|
return {
|
|
@@ -92,6 +94,7 @@ export function checkWorktreeSandboxAvailable(cwd: string): { available: true; c
|
|
|
92
94
|
cwd,
|
|
93
95
|
stdio: ["ignore", "pipe", "pipe"],
|
|
94
96
|
encoding: "utf-8",
|
|
97
|
+
timeout: 5000,
|
|
95
98
|
}).trim();
|
|
96
99
|
if (status.length > 0)
|
|
97
100
|
return {
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
1
2
|
import * as fs from "node:fs";
|
|
2
3
|
import * as path from "node:path";
|
|
3
4
|
import { DEFAULT_MAILBOX } from "../../config/defaults.ts";
|
|
@@ -726,7 +727,9 @@ export function appendMailboxMessage(
|
|
|
726
727
|
else ensureRunMailbox(manifest);
|
|
727
728
|
const createdAt = new Date().toISOString();
|
|
728
729
|
const complete: MailboxMessage = {
|
|
729
|
-
|
|
730
|
+
// RR-021 WI-4.3j: randomUUID instead of Date.now()+Math.random() —
|
|
731
|
+
// collision-free under the msg_ prefix, no clock-ordering leakage.
|
|
732
|
+
id: message.id ?? `msg_${randomUUID()}`,
|
|
730
733
|
runId: manifest.runId,
|
|
731
734
|
direction: message.direction,
|
|
732
735
|
from: message.from,
|
|
@@ -854,7 +857,9 @@ export async function appendMailboxMessageAsync(
|
|
|
854
857
|
else ensureRunMailbox(manifest);
|
|
855
858
|
const createdAt = new Date().toISOString();
|
|
856
859
|
const complete: MailboxMessage = {
|
|
857
|
-
|
|
860
|
+
// RR-021 WI-4.3j: randomUUID instead of Date.now()+Math.random() —
|
|
861
|
+
// collision-free under the msg_ prefix, no clock-ordering leakage.
|
|
862
|
+
id: message.id ?? `msg_${randomUUID()}`,
|
|
858
863
|
runId: manifest.runId,
|
|
859
864
|
direction: message.direction,
|
|
860
865
|
from: message.from,
|
|
@@ -21,7 +21,7 @@ import {
|
|
|
21
21
|
import { canTransitionRunStatus } from "../contracts.ts";
|
|
22
22
|
import { withRunLock, withRunLockSync } from "../coordination/locks.ts";
|
|
23
23
|
import { appendEvent } from "../event-log/event-log.ts";
|
|
24
|
-
import type { TeamRunManifest, TeamTaskState } from "../types.ts";
|
|
24
|
+
import type { RunModelContext, TeamRunManifest, TeamTaskState } from "../types.ts";
|
|
25
25
|
import { CURRENT_SCHEMA_VERSION } from "../types.ts";
|
|
26
26
|
import { unregisterActiveRun } from "./active-run-registry.ts";
|
|
27
27
|
import { extractTaskArray, loadTasksWithRecovery, loadTasksWithRecoveryAsync, quarantineCorruptFile } from "./manifest-io.ts";
|
|
@@ -438,6 +438,12 @@ export function createRunManifest(params: {
|
|
|
438
438
|
* Defaults preserve current behavior (generated id, wall clock). */
|
|
439
439
|
runId?: string;
|
|
440
440
|
now?: () => Date;
|
|
441
|
+
/** Model routing snapshot (Finding #1, battery 2026-09-29): detached runs
|
|
442
|
+
* re-enter through background-runner with no ExtensionContext, so the
|
|
443
|
+
* caller's model override / inherited session model / auth-filtered
|
|
444
|
+
* catalogue must be persisted on the manifest at creation time.
|
|
445
|
+
* Omitted when undefined so old manifests stay byte-identical. */
|
|
446
|
+
modelContext?: RunModelContext;
|
|
441
447
|
}): { manifest: TeamRunManifest; tasks: TeamTaskState[]; paths: RunPaths } {
|
|
442
448
|
const paths = createRunPaths(params.cwd, params.runId);
|
|
443
449
|
const now = (params.now ? params.now() : new Date()).toISOString();
|
|
@@ -462,6 +468,7 @@ export function createRunManifest(params: {
|
|
|
462
468
|
...(params.ownerSessionId ? { ownerSessionId: params.ownerSessionId } : {}),
|
|
463
469
|
runKind: params.runKind ?? "team-run",
|
|
464
470
|
...(params.args !== undefined ? { args: params.args } : {}),
|
|
471
|
+
...(params.modelContext ? { modelContext: params.modelContext } : {}),
|
|
465
472
|
};
|
|
466
473
|
fs.mkdirSync(paths.stateRoot, { recursive: true });
|
|
467
474
|
fs.mkdirSync(paths.artifactsRoot, { recursive: true });
|
|
@@ -116,13 +116,16 @@ export function renderAgentsPane(snapshot: RunUiSnapshot | undefined, options: R
|
|
|
116
116
|
// One pinned clock (D6-T4) for the whole pane render.
|
|
117
117
|
const nowMs = options.nowMs ?? Date.now();
|
|
118
118
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
119
|
+
// RR-021 WI-4.3c: first-wins Map keyed by taskId — the two `.find` sites
|
|
120
|
+
// below (real-agent filter + per-agent handle) must keep seeing the SAME
|
|
121
|
+
// (first) matching handle, so the build guards with has() instead of set()
|
|
122
|
+
// overwriting later duplicates.
|
|
123
|
+
const liveByTaskId = new Map<string, (typeof liveForRun)[number]>();
|
|
124
|
+
for (const handle of liveForRun) {
|
|
125
|
+
if (!liveByTaskId.has(handle.taskId)) liveByTaskId.set(handle.taskId, handle);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const realAgents = snapshot.agents.filter((a) => isRealAgent(a, liveByTaskId.get(a.taskId), nowMs));
|
|
126
129
|
const lineCount = Math.min(realAgents.length, 12);
|
|
127
130
|
const label =
|
|
128
131
|
realAgents.length !== snapshot.agents.length
|
|
@@ -135,7 +138,7 @@ export function renderAgentsPane(snapshot: RunUiSnapshot | undefined, options: R
|
|
|
135
138
|
lines.push(`${completed}/${total} tasks · ${label}`);
|
|
136
139
|
|
|
137
140
|
for (const agent of realAgents.slice(0, 12)) {
|
|
138
|
-
const liveHandle =
|
|
141
|
+
const liveHandle = liveByTaskId.get(agent.taskId);
|
|
139
142
|
const icon = agentGlyph(agent.status, agent.taskId);
|
|
140
143
|
const role = `${agent.role ?? "?"}`;
|
|
141
144
|
|
|
@@ -277,7 +277,7 @@ export function installInlinePanel(pi: ExtensionAPI, ctx: ExtensionContext, uiCo
|
|
|
277
277
|
// (lazy import: viewers.ts pulls the state-store chain and must stay
|
|
278
278
|
// off this module's startup path — AGENTS.md lazy boundary).
|
|
279
279
|
onOpenBrowser: () => {
|
|
280
|
-
void import("../../extension/registration/viewers.ts").then((m) => m.openAgentsJobsBrowser(ctx));
|
|
280
|
+
void import("../../extension/registration/viewers.ts").then((m) => m.openAgentsJobsBrowser(ctx)); // LAZY: defer agents-jobs browser until opened
|
|
281
281
|
},
|
|
282
282
|
});
|
|
283
283
|
});
|
package/src/ui/render-diff.ts
CHANGED
|
@@ -1,7 +1,20 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
import type { Change } from "diff";
|
|
2
3
|
import type { CrewTheme } from "./theme-adapter.ts";
|
|
3
4
|
import { asCrewTheme } from "./theme-adapter.ts";
|
|
4
5
|
|
|
6
|
+
// RR-021 WI-2.1: the `diff` package was eagerly imported module-scope with a
|
|
7
|
+
// wildcard (pulling the whole namespace into every UI load). Only diffWords is
|
|
8
|
+
// used at runtime — lazy sync accessor on first use (model: ui/syntax-highlight.ts).
|
|
9
|
+
// `Change` is a type-only import (erased at compile time, zero runtime cost).
|
|
10
|
+
const require = createRequire(import.meta.url);
|
|
11
|
+
type DiffModule = typeof import("diff");
|
|
12
|
+
let cachedDiff: DiffModule | undefined;
|
|
13
|
+
function diffModule(): DiffModule {
|
|
14
|
+
if (!cachedDiff) cachedDiff = require("diff") as DiffModule;
|
|
15
|
+
return cachedDiff;
|
|
16
|
+
}
|
|
17
|
+
|
|
5
18
|
interface ParsedDiffLine {
|
|
6
19
|
prefix: string;
|
|
7
20
|
lineNum: string;
|
|
@@ -36,8 +49,8 @@ const WORD_DIFF_MIN_SIM = 0.15;
|
|
|
36
49
|
* characters relative to the longer of the two strings, using word-level
|
|
37
50
|
* diff to identify the common (unchanged) parts. Returns a value in [0, 1].
|
|
38
51
|
*/
|
|
39
|
-
function computeSimilarity(oldContent: string, newContent: string, wordDiff?:
|
|
40
|
-
const parts = wordDiff ??
|
|
52
|
+
function computeSimilarity(oldContent: string, newContent: string, wordDiff?: Change[]): number {
|
|
53
|
+
const parts = wordDiff ?? diffModule().diffWords(oldContent, newContent);
|
|
41
54
|
let commonChars = 0;
|
|
42
55
|
for (const part of parts) {
|
|
43
56
|
if (!part.removed && !part.added) {
|
|
@@ -53,9 +66,9 @@ function renderIntraLineDiff(
|
|
|
53
66
|
theme: CrewTheme,
|
|
54
67
|
oldContent: string,
|
|
55
68
|
newContent: string,
|
|
56
|
-
wordDiff?:
|
|
69
|
+
wordDiff?: Change[],
|
|
57
70
|
): { removedLine: string; addedLine: string } {
|
|
58
|
-
const parts = wordDiff ??
|
|
71
|
+
const parts = wordDiff ?? diffModule().diffWords(oldContent, newContent);
|
|
59
72
|
let removedLine = "";
|
|
60
73
|
let addedLine = "";
|
|
61
74
|
let isFirstRemoved = true;
|
|
@@ -137,7 +150,7 @@ export function renderDiff(diffText: string, options: RenderDiffOptions = {}): s
|
|
|
137
150
|
const newContent = replaceTabs(addedLines[0]!.content);
|
|
138
151
|
// PERF (2026-08-24): diffWords is the expensive call and was run
|
|
139
152
|
// TWICE on identical inputs (similarity + render). One pass, shared.
|
|
140
|
-
const wordDiff =
|
|
153
|
+
const wordDiff = diffModule().diffWords(oldContent, newContent);
|
|
141
154
|
const similarity = computeSimilarity(oldContent, newContent, wordDiff);
|
|
142
155
|
if (similarity >= WORD_DIFF_MIN_SIM) {
|
|
143
156
|
const { removedLine, addedLine } = renderIntraLineDiff(theme, oldContent, newContent, wordDiff);
|
package/src/ui/run-dashboard.ts
CHANGED
|
@@ -248,7 +248,17 @@ function readProgressPreview(run: TeamRunManifest, maxLines = 5, snapshotCache?:
|
|
|
248
248
|
if (snapshotCache) return ["Progress: (loading…)"];
|
|
249
249
|
// Legacy fallback: tests/dev paths without a snapshot cache still read the
|
|
250
250
|
// progress artifact directly so existing assertions keep working.
|
|
251
|
-
|
|
251
|
+
// RR-021 WI-4.3e: allocation-free reverse scan — the old
|
|
252
|
+
// [...run.artifacts].reverse().find() copied the whole array per render.
|
|
253
|
+
// (findLast needs an ES2023 lib bump, which breaks tsgo's RequestInfo
|
|
254
|
+
// ambient resolution — see WI-4.3 evidence note.)
|
|
255
|
+
let progress: (typeof run.artifacts)[number] | undefined;
|
|
256
|
+
for (let i = run.artifacts.length - 1; i >= 0; i--) {
|
|
257
|
+
if (run.artifacts[i]?.kind === "progress") {
|
|
258
|
+
progress = run.artifacts[i];
|
|
259
|
+
break;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
252
262
|
if (!progress) return ["Progress: (none)"];
|
|
253
263
|
try {
|
|
254
264
|
const progressPath = resolveRealContainedPath(run.artifactsRoot, progress.path);
|
|
@@ -692,9 +692,15 @@ async function mailboxFromAsync(manifest: TeamRunManifest, agents: CrewAgentReco
|
|
|
692
692
|
}
|
|
693
693
|
|
|
694
694
|
function cancellationReasonFromEvents(events: TeamEvent[]): string | undefined {
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
695
|
+
// RR-021 WI-4.3e: allocation-free reverse scan — the old
|
|
696
|
+
// [...events].reverse().find() copied the whole array on every call.
|
|
697
|
+
// (findLast needs an ES2023 lib bump, which breaks tsgo's RequestInfo
|
|
698
|
+
// ambient resolution — see WI-4.3 evidence note.)
|
|
699
|
+
for (let i = events.length - 1; i >= 0; i--) {
|
|
700
|
+
const event = events[i];
|
|
701
|
+
if (event?.type === "run.cancelled" && typeof event.data?.reason === "string") return event.data.reason;
|
|
702
|
+
}
|
|
703
|
+
return undefined;
|
|
698
704
|
}
|
|
699
705
|
|
|
700
706
|
type SliceSignatures = NonNullable<RunUiSnapshot["sliceSignatures"]>;
|
|
@@ -126,10 +126,12 @@ function runningSuffix(task: TeamTaskState): string {
|
|
|
126
126
|
|
|
127
127
|
/** `› blocked by #2, #3` for queued tasks — open (non-completed)
|
|
128
128
|
* dependencies only, named by task number (pi-tasks display rule). */
|
|
129
|
-
function blockedSuffix(task: TeamTaskState,
|
|
129
|
+
function blockedSuffix(task: TeamTaskState, taskById: Map<string, TeamTaskState>, numberById: Map<string, number>): string {
|
|
130
130
|
if (task.status !== "queued") return "";
|
|
131
|
+
// RR-021 WI-4.3b: Map lookup — tasks.find() per dependency was O(deps×tasks)
|
|
132
|
+
// on every queued row render.
|
|
131
133
|
const openDeps = (task.dependsOn ?? [])
|
|
132
|
-
.map((depId) =>
|
|
134
|
+
.map((depId) => taskById.get(depId))
|
|
133
135
|
.filter((dep): dep is TeamTaskState => dep !== undefined && dep.status !== "completed");
|
|
134
136
|
if (openDeps.length === 0) return "";
|
|
135
137
|
const labels = openDeps.map((dep) => `#${numberById.get(dep.id) ?? "?"}`);
|
|
@@ -139,9 +141,9 @@ function blockedSuffix(task: TeamTaskState, tasks: readonly TeamTaskState[], num
|
|
|
139
141
|
/** One line per task: `#n title` behind its status glyph — pi-tasks style.
|
|
140
142
|
* Completed titles are dimmed and struck through. Returns the ROW CONTENT:
|
|
141
143
|
* the rail glyph (`┃ `) is owned by `railLine`. */
|
|
142
|
-
function taskRow(task: TeamTaskState, taskNumber: number,
|
|
144
|
+
function taskRow(task: TeamTaskState, taskNumber: number, taskById: Map<string, TeamTaskState>, numberById: Map<string, number>): string {
|
|
143
145
|
const title = taskTitle(task);
|
|
144
|
-
const suffix = task.status === "running" ? runningSuffix(task) : blockedSuffix(task,
|
|
146
|
+
const suffix = task.status === "running" ? runningSuffix(task) : blockedSuffix(task, taskById, numberById);
|
|
145
147
|
if (task.status === "completed") {
|
|
146
148
|
const struck = `${STRIKE_ON}${DIM_ON}#${taskNumber} ${title}${DIM_OFF}${STRIKE_OFF}`;
|
|
147
149
|
return `${taskStatusIcon(task)} ${struck}`;
|
|
@@ -182,6 +184,7 @@ export function buildTaskListLines(runs: readonly WidgetRun[], width: number, th
|
|
|
182
184
|
|
|
183
185
|
const budget = Math.max(8, width - 2);
|
|
184
186
|
const numberById = new Map(tasks.map((task, index) => [task.id, index + 1]));
|
|
187
|
+
const taskById = new Map(tasks.map((task) => [task.id, task]));
|
|
185
188
|
const done = tasks.filter((task) => task.status === "completed").length;
|
|
186
189
|
const dead = tasks.filter((task) => isDoneStatus(task.status) && task.status !== "completed").length;
|
|
187
190
|
const inProgress = tasks.filter(
|
|
@@ -211,7 +214,7 @@ export function buildTaskListLines(runs: readonly WidgetRun[], width: number, th
|
|
|
211
214
|
|
|
212
215
|
const visible = visibleTasks(tasks);
|
|
213
216
|
for (const task of visible) {
|
|
214
|
-
lines.push(railLine(RAIL.body, "border", taskRow(task, numberById.get(task.id) ?? 0,
|
|
217
|
+
lines.push(railLine(RAIL.body, "border", taskRow(task, numberById.get(task.id) ?? 0, taskById, numberById), theme, budget));
|
|
215
218
|
}
|
|
216
219
|
const hidden = tasks.length - visible.length;
|
|
217
220
|
if (hidden > 0) {
|
|
@@ -46,7 +46,9 @@ export function activeWidgetRuns(
|
|
|
46
46
|
if (now - lastStaleReconcileAt > STALE_RECONCILE_INTERVAL_MS && manifestCache) {
|
|
47
47
|
lastStaleReconcileAt = now;
|
|
48
48
|
try {
|
|
49
|
-
reconcileAllStaleRuns(cwd, manifestCache, Date.now(), workspaceId)
|
|
49
|
+
void reconcileAllStaleRuns(cwd, manifestCache, Date.now(), workspaceId).catch(() => {
|
|
50
|
+
/* non-critical */
|
|
51
|
+
});
|
|
50
52
|
} catch {
|
|
51
53
|
/* non-critical */
|
|
52
54
|
}
|
package/tsconfig.json
CHANGED
|
@@ -9,6 +9,8 @@
|
|
|
9
9
|
"skipLibCheck": true,
|
|
10
10
|
"allowImportingTsExtensions": true,
|
|
11
11
|
"noEmit": true,
|
|
12
|
+
"incremental": true,
|
|
13
|
+
"tsBuildInfoFile": "node_modules/.cache/pi-crew/tsconfig.tsbuildinfo",
|
|
12
14
|
"types": ["node"],
|
|
13
15
|
"verbatimModuleSyntax": true,
|
|
14
16
|
"noUncheckedSideEffectImports": true
|