pi-crew 0.9.11 → 0.9.13
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 +72 -0
- package/README.md +1 -135
- package/package.json +1 -1
- package/src/extension/crew-shortcuts.ts +29 -2
- package/src/extension/knowledge-injection.ts +236 -15
- package/src/extension/registration/commands.ts +61 -34
- package/src/extension/team-tool/chain-dispatch.ts +103 -0
- package/src/extension/team-tool/chain-executor.ts +400 -0
- package/src/extension/team-tool/run.ts +9 -0
- package/src/runtime/agent-memory.ts +5 -2
- package/src/runtime/background-runner.ts +49 -0
- package/src/runtime/chain-runner.ts +47 -8
- package/src/runtime/child-pi.ts +33 -0
- package/src/runtime/handoff-manager.ts +7 -0
- package/src/runtime/live-session-runtime.ts +10 -4
- package/src/runtime/pi-json-output.ts +5 -1
- package/src/runtime/process-status.ts +7 -2
- package/src/runtime/task-output-context.ts +36 -7
- package/src/runtime/task-runner/prompt-builder.ts +5 -1
- package/src/runtime/task-runner.ts +7 -0
- package/src/schema/team-tool-schema.ts +9 -0
- package/src/ui/crew-footer.ts +3 -3
- package/src/ui/crew-select-list.ts +1 -1
- package/src/ui/dashboard-panes/agents-pane.ts +26 -4
- package/src/ui/dashboard-panes/cancellation-pane.ts +23 -0
- package/src/ui/keybinding-map.ts +7 -3
- package/src/ui/live-conversation-overlay.ts +2 -2
- package/src/ui/live-run-sidebar.ts +18 -10
- package/src/ui/overlays/help-overlay.ts +166 -0
- package/src/ui/run-dashboard.ts +210 -70
- package/src/ui/status-colors.ts +45 -0
- package/src/ui/widget/index.ts +46 -3
- package/src/ui/widget/widget-formatters.ts +22 -7
- package/src/ui/widget/widget-renderer.ts +31 -27
- package/src/utils/visual.ts +3 -1
|
@@ -25,6 +25,19 @@ import type { executeTeamRun as ExecuteTeamRunFn } from "./team-runner.ts";
|
|
|
25
25
|
import type { TeamRunManifest, TeamTaskState } from "../state/types.ts";
|
|
26
26
|
|
|
27
27
|
let _cachedExecuteTeamRun: typeof ExecuteTeamRunFn | undefined;
|
|
28
|
+
|
|
29
|
+
/** Maximum runtime for a single background run before the watchdog force-aborts
|
|
30
|
+
* it. Prevents zombie background-runner processes when a team run hangs forever
|
|
31
|
+
* (e.g. a hung child Pi process, a stuck lock, or a test that spawns a run
|
|
32
|
+
* without cleanup). Default 2h — generous for legitimate long runs (research
|
|
33
|
+
* workflows, goal-loops) but catches true zombies (observed: 10h+ stale test
|
|
34
|
+
* runs). Override via PI_CREW_MAX_RUN_MS env (milliseconds). The watchdog
|
|
35
|
+
* aborts via the shared AbortController, then force-exits after a grace
|
|
36
|
+
* period in case the abort signal does not propagate to all execution paths. */
|
|
37
|
+
const MAX_BACKGROUND_RUN_MS = (() => {
|
|
38
|
+
const env = Number.parseInt(process.env.PI_CREW_MAX_RUN_MS ?? "", 10);
|
|
39
|
+
return Number.isFinite(env) && env > 0 ? env : 2 * 60 * 60 * 1000;
|
|
40
|
+
})();
|
|
28
41
|
async function executeTeamRun(
|
|
29
42
|
...args: Parameters<typeof ExecuteTeamRunFn>
|
|
30
43
|
): Promise<Awaited<ReturnType<typeof ExecuteTeamRunFn>>> {
|
|
@@ -234,6 +247,7 @@ function runCleanup(
|
|
|
234
247
|
stopParentGuard: () => void,
|
|
235
248
|
stopHeartbeat: () => void,
|
|
236
249
|
keepAlive: NodeJS.Timeout,
|
|
250
|
+
watchdogTimer: NodeJS.Timeout,
|
|
237
251
|
exitDueToRejection: boolean,
|
|
238
252
|
eventsPath?: string,
|
|
239
253
|
): void {
|
|
@@ -245,6 +259,9 @@ function runCleanup(
|
|
|
245
259
|
// FIX: clearInterval FIRST, then kill children. This ensures the heartbeat
|
|
246
260
|
// interval is always cleaned up even if terminateActiveChildPiProcesses throws.
|
|
247
261
|
clearInterval(keepAlive);
|
|
262
|
+
// Clear the anti-zombie watchdog so a normally-completing run does not
|
|
263
|
+
// carry a pending force-exit timer into the exit path.
|
|
264
|
+
clearTimeout(watchdogTimer);
|
|
248
265
|
// FIX Issues #1, #2, #4: Wrap child process termination in try/catch so errors
|
|
249
266
|
// don't prevent the cleanup from completing. We log but don't re-throw since
|
|
250
267
|
// we're already exiting.
|
|
@@ -563,6 +580,37 @@ async function main(): Promise<void> {
|
|
|
563
580
|
// bounded by the 5s interval. The event loop exit is deferred at most 5s.
|
|
564
581
|
const keepAlive = setInterval(() => {}, 5000);
|
|
565
582
|
|
|
583
|
+
// WATCHDOG (anti-zombie): if the run exceeds MAX_BACKGROUND_RUN_MS without
|
|
584
|
+
// completing, abort it and force-exit. Without this, a hung team run
|
|
585
|
+
// (stuck child Pi, deadlocked lock, test that never cleans up) leaves the
|
|
586
|
+
// background-runner alive forever because keepAlive holds the event loop.
|
|
587
|
+
// The watchdog fires once; it is cleared in the finally block via runCleanup.
|
|
588
|
+
const watchdogTimer = setTimeout(() => {
|
|
589
|
+
console.error(`[background-runner] WATCHDOG: run ${runId} exceeded ${MAX_BACKGROUND_RUN_MS}ms — aborting (zombie prevention)`);
|
|
590
|
+
try {
|
|
591
|
+
appendEvent(manifest.eventsPath, {
|
|
592
|
+
type: "async.watchdog_fired",
|
|
593
|
+
runId,
|
|
594
|
+
message: `Run exceeded ${MAX_BACKGROUND_RUN_MS}ms and was force-aborted to prevent a zombie background-runner process.`,
|
|
595
|
+
data: { maxRunMs: MAX_BACKGROUND_RUN_MS },
|
|
596
|
+
});
|
|
597
|
+
} catch { /* best-effort event log */ }
|
|
598
|
+
// Signal the finally block to exit(1) after cleanup.
|
|
599
|
+
exitDueToRejection = true;
|
|
600
|
+
// Abort the in-flight team run via the shared signal (propagates to
|
|
601
|
+
// executeTeamRun → child-pi → kills child processes).
|
|
602
|
+
abortController.abort();
|
|
603
|
+
// Hard-exit safety net: if the abort does not propagate within 15s
|
|
604
|
+
// (e.g. a hung native call), force-kill so the process cannot linger.
|
|
605
|
+
const forceExit = setTimeout(() => {
|
|
606
|
+
console.error(`[background-runner] WATCHDOG: abort did not propagate within grace period — force-exiting`);
|
|
607
|
+
stopParentGuard();
|
|
608
|
+
try { terminateActiveChildPiProcesses(); } catch { /* best-effort */ }
|
|
609
|
+
process.exit(1);
|
|
610
|
+
}, 15_000);
|
|
611
|
+
forceExit.unref();
|
|
612
|
+
}, MAX_BACKGROUND_RUN_MS);
|
|
613
|
+
|
|
566
614
|
try {
|
|
567
615
|
debugLog(`[background-runner] about to call discoverAgents`);
|
|
568
616
|
const agents = allAgents(discoverAgents(cwd));
|
|
@@ -798,6 +846,7 @@ async function main(): Promise<void> {
|
|
|
798
846
|
stopParentGuard,
|
|
799
847
|
stopHeartbeat,
|
|
800
848
|
keepAlive,
|
|
849
|
+
watchdogTimer,
|
|
801
850
|
exitDueToRejection,
|
|
802
851
|
manifest.eventsPath,
|
|
803
852
|
);
|
|
@@ -496,25 +496,54 @@ export class ChainRunner {
|
|
|
496
496
|
return context;
|
|
497
497
|
}
|
|
498
498
|
|
|
499
|
-
|
|
500
|
-
|
|
499
|
+
const notes: string[] = [];
|
|
500
|
+
|
|
501
|
+
// Limit history size to prevent memory leak (H2). Emit a marker so a dropped
|
|
502
|
+
// entry does not vanish silently — this was the only silent-loss path in the
|
|
503
|
+
// output-handling machinery (see research-findings/output-handling-deep-dive.md §F).
|
|
504
|
+
let limitedHandoffs = handoffs;
|
|
505
|
+
if (handoffs.length > ChainRunner.MAX_CHAIN_HISTORY_SIZE) {
|
|
506
|
+
const dropped = handoffs.length - ChainRunner.MAX_CHAIN_HISTORY_SIZE;
|
|
507
|
+
notes.push(`[chain history limited to last ${ChainRunner.MAX_CHAIN_HISTORY_SIZE} entries; ${dropped} older entr${dropped === 1 ? "y" : "ies"} omitted]`);
|
|
508
|
+
limitedHandoffs = handoffs.slice(-ChainRunner.MAX_CHAIN_HISTORY_SIZE);
|
|
509
|
+
}
|
|
501
510
|
|
|
502
|
-
// Limit per-entry size to prevent memory issues from large artifacts
|
|
511
|
+
// Limit per-entry size to prevent memory issues from large artifacts.
|
|
503
512
|
const filteredHandoffs = limitedHandoffs.filter(h => {
|
|
504
513
|
const size = JSON.stringify(h).length;
|
|
505
514
|
return size <= ChainRunner.MAX_HANDOFF_ENTRY_SIZE;
|
|
506
515
|
});
|
|
516
|
+
const oversizedDropped = limitedHandoffs.length - filteredHandoffs.length;
|
|
517
|
+
if (oversizedDropped > 0) {
|
|
518
|
+
notes.push(`[${oversizedDropped} handoff entr${oversizedDropped === 1 ? "y" : "ies"} omitted (> ${ChainRunner.MAX_HANDOFF_ENTRY_SIZE} bytes each)]`);
|
|
519
|
+
}
|
|
507
520
|
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
521
|
+
// Track array-cap drops so capped lists do not vanish silently. Each field
|
|
522
|
+
// over its cap records the full count so a reader knows data was elided.
|
|
523
|
+
const arrayCapNotes: string[] = [];
|
|
524
|
+
const mapped = filteredHandoffs.map(h => {
|
|
525
|
+
const over: string[] = [];
|
|
526
|
+
if (h.filesCreated && h.filesCreated.length > 50) over.push(`filesCreated=${h.filesCreated.length}`);
|
|
527
|
+
if (h.filesModified && h.filesModified.length > 50) over.push(`filesModified=${h.filesModified.length}`);
|
|
528
|
+
if (h.decisions && h.decisions.length > 20) over.push(`decisions=${h.decisions.length}`);
|
|
529
|
+
if (h.nextSteps && h.nextSteps.length > 20) over.push(`nextSteps=${h.nextSteps.length}`);
|
|
530
|
+
if (over.length > 0) arrayCapNotes.push(`step ${h.taskId}: ${over.join(", ")} (capped)`);
|
|
531
|
+
return {
|
|
511
532
|
step: h.taskId,
|
|
512
533
|
outcome: h.outcome,
|
|
513
534
|
filesCreated: h.filesCreated?.slice(0, 50), // Limit array size
|
|
514
535
|
filesModified: h.filesModified?.slice(0, 50), // Limit array size
|
|
515
536
|
decisions: h.decisions?.slice(0, 20), // Limit array size
|
|
516
537
|
nextSteps: h.nextSteps?.slice(0, 20), // Limit array size
|
|
517
|
-
|
|
538
|
+
...(h.outputText ? { outputText: h.outputText.slice(0, 5000) } : {}), // Size-capped worker output
|
|
539
|
+
};
|
|
540
|
+
});
|
|
541
|
+
if (arrayCapNotes.length > 0) notes.push(`[list caps applied: ${arrayCapNotes.join("; ")}]`);
|
|
542
|
+
|
|
543
|
+
return {
|
|
544
|
+
...context,
|
|
545
|
+
__chainHistory: mapped,
|
|
546
|
+
...(notes.length > 0 ? { __chainHistoryNotes: notes } : {}),
|
|
518
547
|
};
|
|
519
548
|
}
|
|
520
549
|
|
|
@@ -525,13 +554,23 @@ export class ChainRunner {
|
|
|
525
554
|
config: ChainStep,
|
|
526
555
|
context: Record<string, unknown>
|
|
527
556
|
): Promise<TaskResult> {
|
|
557
|
+
// Pass step config (team/workflow/model) into the packet context so the
|
|
558
|
+
// concrete ChainTaskRunner can resolve which team/workflow to run. Without
|
|
559
|
+
// this, a step parsed to a @teamName reference would lose its team because
|
|
560
|
+
// the only channel from executeStep to runTask is the TaskPacket. Purely
|
|
561
|
+
// additive — adds sibling keys alongside __chainHistory/Notes.
|
|
528
562
|
const packet: TaskPacket = {
|
|
529
563
|
taskId: `chain-${Date.now()}-${config.name}`,
|
|
530
564
|
runId: "chain",
|
|
531
565
|
goal: config.inlineGoal ?? config.name,
|
|
532
566
|
summarizeThreshold: 3000,
|
|
533
567
|
collapseContext: true,
|
|
534
|
-
context
|
|
568
|
+
context: {
|
|
569
|
+
...context,
|
|
570
|
+
__chainStepTeam: config.team,
|
|
571
|
+
__chainStepWorkflow: config.workflow,
|
|
572
|
+
__chainStepModel: config.model,
|
|
573
|
+
},
|
|
535
574
|
};
|
|
536
575
|
|
|
537
576
|
return this.taskRunner.runTask(packet);
|
package/src/runtime/child-pi.ts
CHANGED
|
@@ -17,6 +17,7 @@ import { sanitizeEnvSecrets } from "../utils/env-filter.ts";
|
|
|
17
17
|
import { registerChildProcess, unregisterChildProcess } from "../extension/crew-cleanup.ts";
|
|
18
18
|
import { classifyProcessCrash } from "./crash-classification.ts";
|
|
19
19
|
import { resolveRealContainedPath } from "../utils/safe-paths.ts";
|
|
20
|
+
import { extractText } from "./pi-json-output.ts";
|
|
20
21
|
|
|
21
22
|
const POST_EXIT_STDIO_GUARD_MS = DEFAULT_CHILD_PI.postExitStdioGuardMs;
|
|
22
23
|
const FINAL_DRAIN_MS = DEFAULT_CHILD_PI.finalDrainMs;
|
|
@@ -218,6 +219,13 @@ export interface ChildPiRunResult {
|
|
|
218
219
|
stdout: string;
|
|
219
220
|
stderr: string;
|
|
220
221
|
error?: string;
|
|
222
|
+
/** RAW (uncapped) final assistant text, captured at stream-parse time BEFORE
|
|
223
|
+
* the 16K transcript compaction. This is the AUTHORITATIVE worker output —
|
|
224
|
+
* it becomes results/<id>.txt so downstream dependencies are not bounded by
|
|
225
|
+
* the transcript's telemetry cap. Undefined when no assistant text was seen
|
|
226
|
+
* (mock paths, error paths) — callers MUST fall back to transcript-derived
|
|
227
|
+
* finalText. See research-findings/output-handling-deep-dive.md §A. */
|
|
228
|
+
rawFinalText?: string;
|
|
221
229
|
exitStatus?: WorkerExitStatus;
|
|
222
230
|
/** True if the agent was hard-aborted (max_turns + grace exceeded). */
|
|
223
231
|
aborted?: boolean;
|
|
@@ -512,6 +520,12 @@ function compactChildPiLine(line: string): { persistedLine: string; event?: unkn
|
|
|
512
520
|
export class ChildPiLineObserver {
|
|
513
521
|
private buffer = "";
|
|
514
522
|
private readonly input: ChildPiRunInput;
|
|
523
|
+
/** RAW (uncapped) assistant-text fragments, accumulated in arrival order.
|
|
524
|
+
* Mirrors {@link parsePiJsonOutput}'s textEvents/finalText extraction but
|
|
525
|
+
* operates on the RAW event stream instead of the 16K-compacted transcript.
|
|
526
|
+
* This is the source of the AUTHORITATIVE result.txt; the transcript stays
|
|
527
|
+
* compacted (memory bound). */
|
|
528
|
+
private readonly rawTextEvents: string[] = [];
|
|
515
529
|
|
|
516
530
|
constructor(input: ChildPiRunInput) {
|
|
517
531
|
this.input = input;
|
|
@@ -531,8 +545,26 @@ export class ChildPiLineObserver {
|
|
|
531
545
|
this.emitLine(line);
|
|
532
546
|
}
|
|
533
547
|
|
|
548
|
+
/** Last non-empty RAW assistant text (mirrors {@link parsePiJsonOutput}'s
|
|
549
|
+
* finalText semantics but uncapped). Undefined when no assistant text was
|
|
550
|
+
* seen by this observer. {@link extractText} already drops empty fragments,
|
|
551
|
+
* so the last entry is the final assistant utterance. */
|
|
552
|
+
getRawFinalText(): string | undefined {
|
|
553
|
+
return this.rawTextEvents.length > 0 ? this.rawTextEvents[this.rawTextEvents.length - 1] : undefined;
|
|
554
|
+
}
|
|
555
|
+
|
|
534
556
|
private emitLine(line: string): void {
|
|
535
557
|
if (!line.trim()) return;
|
|
558
|
+
// Parse the RAW line once so we can BOTH compact it (telemetry transcript,
|
|
559
|
+
// 16K-capped memory bound) AND capture the uncapped assistant text for the
|
|
560
|
+
// authoritative result. Non-JSON lines contribute no assistant text.
|
|
561
|
+
try {
|
|
562
|
+
const rawParsed = JSON.parse(line);
|
|
563
|
+
const rawTexts = extractText(rawParsed);
|
|
564
|
+
if (rawTexts.length > 0) this.rawTextEvents.push(...rawTexts);
|
|
565
|
+
} catch {
|
|
566
|
+
// Not valid JSON — compactChildPiLine handles the raw-text fallback below.
|
|
567
|
+
}
|
|
536
568
|
const compact = compactChildPiLine(line);
|
|
537
569
|
if (compact.event !== undefined) {
|
|
538
570
|
try {
|
|
@@ -919,6 +951,7 @@ export async function runChildPi(input: ChildPiRunInput): Promise<ChildPiRunResu
|
|
|
919
951
|
try {
|
|
920
952
|
resolve({
|
|
921
953
|
...result,
|
|
954
|
+
rawFinalText: lineObserver.getRawFinalText(),
|
|
922
955
|
exitStatus: result.exitStatus ?? {
|
|
923
956
|
exitCode: result.exitCode,
|
|
924
957
|
cancelled: abortRequested,
|
|
@@ -99,6 +99,9 @@ export interface HandoffSummary {
|
|
|
99
99
|
|
|
100
100
|
// Context snapshot
|
|
101
101
|
contextSnapshot: string;
|
|
102
|
+
|
|
103
|
+
/** Worker output text propagated through the chain (read from resultArtifact). */
|
|
104
|
+
outputText?: string;
|
|
102
105
|
}
|
|
103
106
|
|
|
104
107
|
/**
|
|
@@ -121,6 +124,9 @@ export interface TaskResult {
|
|
|
121
124
|
filesDeleted?: string[];
|
|
122
125
|
decisions?: Decision[];
|
|
123
126
|
error?: string;
|
|
127
|
+
|
|
128
|
+
/** Worker's textual output (read from resultArtifact during chain execution). */
|
|
129
|
+
outputText?: string;
|
|
124
130
|
}
|
|
125
131
|
|
|
126
132
|
/**
|
|
@@ -443,6 +449,7 @@ export class HandoffManager {
|
|
|
443
449
|
},
|
|
444
450
|
|
|
445
451
|
contextSnapshot,
|
|
452
|
+
...(result.outputText ? { outputText: result.outputText } : {}),
|
|
446
453
|
};
|
|
447
454
|
}
|
|
448
455
|
|
|
@@ -4,8 +4,13 @@ import type { AgentConfig } from "../agents/agent-config.ts";
|
|
|
4
4
|
import type { CrewRuntimeConfig } from "../config/config.ts";
|
|
5
5
|
import type { TeamRunManifest, TeamTaskState, UsageState } from "../state/types.ts";
|
|
6
6
|
import { appendEvent } from "../state/event-log.ts";
|
|
7
|
-
import { buildMemoryBlock } from "./agent-memory.ts";
|
|
8
7
|
import { trackTaskUsage } from "./usage-tracker.ts";
|
|
8
|
+
// NOTE: buildMemoryBlock is intentionally NOT imported here. The agent memory
|
|
9
|
+
// block is injected via renderTaskPrompt().full (the USER prompt), which is
|
|
10
|
+
// shared by both the child-pi path (no system prompt) and the live-session
|
|
11
|
+
// path. Adding it to liveSystemPrompt() too duplicated the entire memory
|
|
12
|
+
// block (up to 200 lines) in both the user and system prompts. Keep memory
|
|
13
|
+
// in a single place: the shared user prompt. See G3 fix.
|
|
9
14
|
import { createStreamingOutput, type StreamingOutputHandle } from "./streaming-output.ts";
|
|
10
15
|
import { registerLiveAgent, disposeLiveAgentSession, terminateLiveAgent, updateLiveAgentStatus, trackLiveAgentToolStart, trackLiveAgentToolEnd, trackLiveAgentTurnEnd, trackLiveAgentResponseText, markLiveAgentCompleted } from "./live-agent-manager.ts";
|
|
11
16
|
import { applyLiveAgentControlRequest, applyLiveAgentControlRequests, type LiveAgentControlCursor } from "./live-agent-control.ts";
|
|
@@ -371,8 +376,10 @@ function compressSessionToolDescriptions(session: LiveSessionLike): void {
|
|
|
371
376
|
// is loaded and tree-shakeable, so adding the actual logic later is trivial.
|
|
372
377
|
}
|
|
373
378
|
|
|
374
|
-
function liveSystemPrompt(input: LiveSessionSpawnInput): string {
|
|
375
|
-
|
|
379
|
+
export function liveSystemPrompt(input: LiveSessionSpawnInput): string {
|
|
380
|
+
// Agent MEMORY is intentionally omitted here — it is already injected via
|
|
381
|
+
// renderTaskPrompt().full (the user prompt, shared with the child-pi path).
|
|
382
|
+
// See the import-block note above and the G3 fix.
|
|
376
383
|
const role = input.task.role;
|
|
377
384
|
const styleBlock = buildCommunicationStyle(role);
|
|
378
385
|
const contractBlock = buildOutputContract(role);
|
|
@@ -390,7 +397,6 @@ function liveSystemPrompt(input: LiveSessionSpawnInput): string {
|
|
|
390
397
|
sensitiveConstraint,
|
|
391
398
|
"",
|
|
392
399
|
input.agent.systemPrompt || "Follow the user task exactly and report verification evidence.",
|
|
393
|
-
memory ? `\n${memory}` : "",
|
|
394
400
|
].filter(Boolean).join("\n");
|
|
395
401
|
}
|
|
396
402
|
|
|
@@ -75,7 +75,11 @@ function textFromContent(content: unknown): string[] {
|
|
|
75
75
|
return text;
|
|
76
76
|
}
|
|
77
77
|
|
|
78
|
-
|
|
78
|
+
/** Extract assistant-text fragments from a parsed Pi JSON event.
|
|
79
|
+
* Exported so {@link ChildPiLineObserver} can capture the RAW (uncapped)
|
|
80
|
+
* assistant text for the authoritative result, mirroring the extraction
|
|
81
|
+
* order this function uses inside {@link parsePiJsonOutput}. */
|
|
82
|
+
export function extractText(value: unknown): string[] {
|
|
79
83
|
const obj = asRecord(value);
|
|
80
84
|
if (!obj) return [];
|
|
81
85
|
const message = asRecord(obj.message);
|
|
@@ -16,7 +16,11 @@ export interface ProcessLiveness {
|
|
|
16
16
|
*/
|
|
17
17
|
const ORPHANED_ACTIVE_RUN_MS = 2 * 60 * 1000;
|
|
18
18
|
/** How long a completed run stays visible in the widget after completion. */
|
|
19
|
-
const COMPLETED_VISIBILITY_GRACE_MS =
|
|
19
|
+
const COMPLETED_VISIBILITY_GRACE_MS = 8_000;
|
|
20
|
+
/** Errors (failed/cancelled) linger far longer so a failed run leaves a visible
|
|
21
|
+
* trace in the crew widget for ~10 min (F-5). Successful completions vanish
|
|
22
|
+
* quickly to keep the widget quiet. */
|
|
23
|
+
const ERROR_VISIBILITY_GRACE_MS = 10 * 60 * 1000;
|
|
20
24
|
/** Maximum age (ms) for an active run before it's considered stale.
|
|
21
25
|
* After this time, PID-only liveness is unreliable due to PID recycling. */
|
|
22
26
|
const STALE_ACTIVE_RUN_MS = 30 * 60 * 1000;
|
|
@@ -120,12 +124,13 @@ export function isDisplayActiveRun(run: TeamRunManifest, agents: CrewAgentRecord
|
|
|
120
124
|
}
|
|
121
125
|
// Grace period: show completed runs for a few seconds so users see the result.
|
|
122
126
|
if (run.status === "completed" || run.status === "failed" || run.status === "cancelled") {
|
|
127
|
+
const grace = run.status === "completed" ? COMPLETED_VISIBILITY_GRACE_MS : ERROR_VISIBILITY_GRACE_MS;
|
|
123
128
|
const lastAgentActivity = agents.reduce<number>((max, agent) => {
|
|
124
129
|
const ts = agent.completedAt ?? agent.startedAt;
|
|
125
130
|
const parsed = ts ? new Date(ts).getTime() : 0;
|
|
126
131
|
return Number.isFinite(parsed) && parsed > max ? parsed : max;
|
|
127
132
|
}, new Date(run.updatedAt).getTime());
|
|
128
|
-
if (Number.isFinite(lastAgentActivity) && now - lastAgentActivity <
|
|
133
|
+
if (Number.isFinite(lastAgentActivity) && now - lastAgentActivity < grace) return true;
|
|
129
134
|
return false;
|
|
130
135
|
}
|
|
131
136
|
if (!isActiveRunStatus(run.status)) return false;
|
|
@@ -14,6 +14,12 @@ export interface DependencyContextEntry {
|
|
|
14
14
|
status: string;
|
|
15
15
|
resultSummary: string;
|
|
16
16
|
resultPath?: string;
|
|
17
|
+
/** Absolute path to the FULL (untruncated) result, teed when the inline
|
|
18
|
+
* resultSummary was materially truncated (>TEE_THRESHOLD_MULTIPLIER). The
|
|
19
|
+
* downstream worker can `read` this to recover the dropped middle. Mirrors
|
|
20
|
+
* the sharedReads recovery path so dependency injection is no longer
|
|
21
|
+
* circular (re-reading resultPath used to yield the same truncated text). */
|
|
22
|
+
fullOutputPath?: string;
|
|
17
23
|
structuredResults?: Record<string, unknown>;
|
|
18
24
|
artifactsProduced?: string[];
|
|
19
25
|
usage?: { inputTokens: number; outputTokens: number; durationMs: number };
|
|
@@ -50,6 +56,16 @@ function containedExists(filePath: string, baseDir?: string): boolean {
|
|
|
50
56
|
*/
|
|
51
57
|
export const MAX_RESULT_INLINE_BYTES = 32_000;
|
|
52
58
|
|
|
59
|
+
/**
|
|
60
|
+
* Tee-recovery multiplier (R2). A shared artifact is teed to disk — so the
|
|
61
|
+
* downstream worker can `read` the dropped middle — when its size exceeds
|
|
62
|
+
* this fraction of {@link MAX_RESULT_INLINE_BYTES}. Lowered from 2.0
|
|
63
|
+
* (64 KB) to 1.25 (40 KB) so the 32–64 KB band, where the head+tail split
|
|
64
|
+
* is already materially lossy for structured content, also gets a recovery
|
|
65
|
+
* path instead of losing the middle forever.
|
|
66
|
+
*/
|
|
67
|
+
export const TEE_THRESHOLD_MULTIPLIER = 1.25;
|
|
68
|
+
|
|
53
69
|
/**
|
|
54
70
|
* Read a file and return its content, truncating to a head+tail slice if it
|
|
55
71
|
* exceeds {@link MAX_RESULT_INLINE_BYTES} characters. Multi-byte UTF-8
|
|
@@ -110,10 +126,11 @@ function writeTeeFile(fullOutputPath: string, content: string): boolean {
|
|
|
110
126
|
* content AND (when tee was actually written) the absolute path to the full
|
|
111
127
|
* file. Returns undefined if the file cannot be read at all.
|
|
112
128
|
*
|
|
113
|
-
* Tee threshold: only when content.length >
|
|
114
|
-
* (
|
|
115
|
-
*
|
|
116
|
-
* with
|
|
129
|
+
* Tee threshold: only when content.length > TEE_THRESHOLD_MULTIPLIER ×
|
|
130
|
+
* MAX_RESULT_INLINE_BYTES (R2: 1.25× = 40 KB; previously 2× = 64 KB). Below
|
|
131
|
+
* the tee threshold the head+tail split is mostly intact and the worker can
|
|
132
|
+
* live with it; at/above the threshold the dropped middle is recoverable via
|
|
133
|
+
* the teed full file. File content is read once and reused for both the
|
|
117
134
|
* pipeline (truncation) and the tee write (full file).
|
|
118
135
|
*
|
|
119
136
|
* Truncation behavior is unchanged from the P0-A pipeline: ANSI strip +
|
|
@@ -131,8 +148,10 @@ export function readIfSmallWithTee(
|
|
|
131
148
|
const content = fs.readFileSync(safePath, "utf-8");
|
|
132
149
|
if (content.length > maxChars) {
|
|
133
150
|
let fullOutputPath: string | undefined;
|
|
134
|
-
// Tee
|
|
135
|
-
|
|
151
|
+
// Tee when truncation is materially lossy (>TEE_THRESHOLD_MULTIPLIER ×
|
|
152
|
+
// threshold). R2: lowered from 2× (64 KB) to 1.25× (40 KB) so the
|
|
153
|
+
// 32–64 KB band also gets a recovery path.
|
|
154
|
+
if (opts.tee && content.length > maxChars * TEE_THRESHOLD_MULTIPLIER) {
|
|
136
155
|
if (writeTeeFile(opts.tee.fullOutputPath, content)) {
|
|
137
156
|
fullOutputPath = opts.tee.fullOutputPath;
|
|
138
157
|
}
|
|
@@ -267,13 +286,18 @@ export function collectDependencyOutputContext(manifest: TeamRunManifest, tasks:
|
|
|
267
286
|
const byStep = new Map(tasks.map((item) => [item.stepId, item]).filter((entry): entry is [string, TeamTaskState] => Boolean(entry[0])));
|
|
268
287
|
const byId = new Map(tasks.map((item) => [item.id, item]));
|
|
269
288
|
const dependencies = task.dependsOn.map((dep) => byStep.get(dep) ?? byId.get(dep)).filter((item): item is TeamTaskState => Boolean(item)).map((item) => {
|
|
270
|
-
const
|
|
289
|
+
const fullOutputPath = item.resultArtifact ? teePathForArtifact(manifest.artifactsRoot, task.id, item.id) : undefined;
|
|
290
|
+
const teeResult = item.resultArtifact
|
|
291
|
+
? readIfSmallWithTee(item.resultArtifact.path, { baseDir: manifest.artifactsRoot, ...(fullOutputPath ? { tee: { fullOutputPath } } : {}) })
|
|
292
|
+
: undefined;
|
|
293
|
+
const resultText = teeResult?.content;
|
|
271
294
|
return {
|
|
272
295
|
taskId: item.id,
|
|
273
296
|
role: item.role,
|
|
274
297
|
status: item.status,
|
|
275
298
|
resultSummary: resultText ?? "",
|
|
276
299
|
resultPath: item.resultArtifact?.path,
|
|
300
|
+
...(teeResult?.fullOutputPath ? { fullOutputPath: teeResult.fullOutputPath } : {}),
|
|
277
301
|
structuredResults: resultText ? tryParseJson(resultText) : undefined,
|
|
278
302
|
artifactsProduced: listTaskArtifacts(manifest, item.id),
|
|
279
303
|
usage: aggregateUsage(item),
|
|
@@ -317,6 +341,11 @@ export function renderDependencyOutputContext(context: DependencyOutputContext):
|
|
|
317
341
|
parts.push("# Dependency Outputs", "");
|
|
318
342
|
for (const dep of context.dependencies) {
|
|
319
343
|
parts.push(`## ${dep.taskId} (${dep.role})`, `Status: ${dep.status}`, dep.resultPath ? `Result artifact: ${dep.resultPath}` : "", "", dep.resultSummary?.trim() || "(no result output)", "");
|
|
344
|
+
// P1-A dependency tee-recovery hint: when the dependency's result was
|
|
345
|
+
// materially truncated (>1.25× MAX_RESULT_INLINE_BYTES) the full RAW
|
|
346
|
+
// content was teed to fullOutputPath. Mirrors the sharedReads hint so the
|
|
347
|
+
// downstream worker can read the dropped middle instead of re-deriving.
|
|
348
|
+
if (dep.fullOutputPath) parts.push(`Full output (if you need the missing middle): ${dep.fullOutputPath}`, "");
|
|
320
349
|
if (dep.structuredResults) parts.push("Structured results:", JSON.stringify(dep.structuredResults, null, 2), "");
|
|
321
350
|
if (dep.artifactsProduced?.length) parts.push(`Artifacts produced: ${dep.artifactsProduced.join(", ")}`, "");
|
|
322
351
|
if (dep.usage) parts.push(`Usage: ${dep.usage.inputTokens} input tokens, ${dep.usage.outputTokens} output tokens, ${dep.usage.durationMs}ms`, "");
|
|
@@ -120,7 +120,11 @@ export async function renderTaskPrompt(manifest: TeamRunManifest, step: Workflow
|
|
|
120
120
|
// O4: project knowledge (.crew/knowledge.md) — workers don't load the
|
|
121
121
|
// pi-crew extension (spawned with --no-extensions), so before_agent_start
|
|
122
122
|
// never fires for them. Inject here so every worker sees project knowledge.
|
|
123
|
-
buildKnowledgeFragment(task.cwd
|
|
123
|
+
buildKnowledgeFragment(task.cwd, {
|
|
124
|
+
goal: manifest.goal,
|
|
125
|
+
taskText: step.task,
|
|
126
|
+
role: step.role,
|
|
127
|
+
}),
|
|
124
128
|
].filter(Boolean).join("\n");
|
|
125
129
|
|
|
126
130
|
// Dynamic suffix: goal, step, skills, task packet, dependency context, memory — changes per task
|
|
@@ -342,6 +342,7 @@ export async function runTeamTask(
|
|
|
342
342
|
let error: string | undefined;
|
|
343
343
|
let modelAttempts: ModelAttemptSummary[] | undefined;
|
|
344
344
|
let parsedOutput: ParsedPiJsonOutput | undefined;
|
|
345
|
+
let rawFinalText: string | undefined;
|
|
345
346
|
let finalStdout = "";
|
|
346
347
|
let transcriptPath: string | undefined;
|
|
347
348
|
let terminalEvidence: OperationTerminalEvidence[] = [];
|
|
@@ -717,6 +718,7 @@ export async function runTeamTask(
|
|
|
717
718
|
childResult.stdout,
|
|
718
719
|
);
|
|
719
720
|
parsedOutput = parsePiJsonOutput(transcriptText);
|
|
721
|
+
rawFinalText = childResult.rawFinalText;
|
|
720
722
|
error =
|
|
721
723
|
childResult.error ||
|
|
722
724
|
(childResult.exitCode && childResult.exitCode !== 0
|
|
@@ -836,6 +838,11 @@ export async function runTeamTask(
|
|
|
836
838
|
kind: "result",
|
|
837
839
|
relativePath: `results/${task.id}.txt`,
|
|
838
840
|
content:
|
|
841
|
+
// Prefer the RAW (uncapped) final assistant text captured before the
|
|
842
|
+
// transcript's 16K compaction — this is the authoritative worker output.
|
|
843
|
+
// Fall back to transcript-derived finalText, then stdout/stderr, so a
|
|
844
|
+
// missing raw capture (mock/error path) never yields empty/garbage.
|
|
845
|
+
cleanResultText(rawFinalText) ??
|
|
839
846
|
cleanResultText(parsedOutput?.finalText) ??
|
|
840
847
|
cleanResultText(finalStdout) ??
|
|
841
848
|
cleanResultText(finalStderr) ??
|
|
@@ -114,6 +114,12 @@ export const TeamToolParams = Type.Object({
|
|
|
114
114
|
goal: Type.Optional(
|
|
115
115
|
Type.String({ description: "High-level objective for a team run." }),
|
|
116
116
|
),
|
|
117
|
+
chain: Type.Optional(
|
|
118
|
+
Type.String({
|
|
119
|
+
description:
|
|
120
|
+
'Chain expression: "step1 -> step2 -> step3". Runs each step as a sequential team run, passing handoff context forward via the goal text. Supports inline goals ("...") and @team references. e.g. chain=\'"Research X" -> "Analyze" -> "Write report"\'.',
|
|
121
|
+
}),
|
|
122
|
+
),
|
|
117
123
|
task: Type.Optional(
|
|
118
124
|
Type.String({
|
|
119
125
|
description: "Concrete task text for direct role/agent execution.",
|
|
@@ -370,6 +376,9 @@ export interface TeamToolParamsValue {
|
|
|
370
376
|
role?: string;
|
|
371
377
|
agent?: string;
|
|
372
378
|
goal?: string;
|
|
379
|
+
/** Chain expression: "step1 -> step2 -> step3". Runs each step as a sequential
|
|
380
|
+
* team run with handoff context passed forward via the goal. */
|
|
381
|
+
chain?: string;
|
|
373
382
|
task?: string;
|
|
374
383
|
singleAgent?: boolean;
|
|
375
384
|
runId?: string;
|
package/src/ui/crew-footer.ts
CHANGED
|
@@ -90,9 +90,9 @@ export class CrewFooter {
|
|
|
90
90
|
].join(" • ");
|
|
91
91
|
const badges = this.data.badges?.length ? this.data.badges.map((badge) => `[${badge}]`).join(" ") : "";
|
|
92
92
|
this.cacheLines = [
|
|
93
|
-
this.theme.fg("dim", pad(truncate(firstParts.join(" • "), lineWidth
|
|
94
|
-
this.theme.fg("dim", pad(truncate(usageLine, lineWidth
|
|
95
|
-
this.theme.fg("dim", pad(truncate(badges, lineWidth
|
|
93
|
+
this.theme.fg("dim", pad(truncate(firstParts.join(" • "), lineWidth), lineWidth)),
|
|
94
|
+
this.theme.fg("dim", pad(truncate(usageLine, lineWidth), lineWidth)),
|
|
95
|
+
this.theme.fg("dim", pad(truncate(badges, lineWidth), lineWidth)),
|
|
96
96
|
];
|
|
97
97
|
this.cacheKey = key;
|
|
98
98
|
this.cacheWidth = width;
|
|
@@ -86,7 +86,7 @@ export class CrewSelectList<T = string> {
|
|
|
86
86
|
const suffix = item.description ? this.theme.fg("dim", ` — ${item.description}`) : "";
|
|
87
87
|
const raw = `${prefix}${item.label}${suffix}`;
|
|
88
88
|
const line = index === this.selectedIndex ? this.theme.inverse?.(raw) ?? raw : raw;
|
|
89
|
-
lines.push(pad(truncate(line, width
|
|
89
|
+
lines.push(pad(truncate(line, width), Math.max(1, width)));
|
|
90
90
|
}
|
|
91
91
|
if (hasBottom) lines.push(this.theme.fg("muted", `↓ ${this.items.length - (this.scrollOffset + slots)} more`));
|
|
92
92
|
return lines.slice(0, maxHeight);
|
|
@@ -6,6 +6,28 @@ import type { CrewAgentRecord } from "../../runtime/crew-agent-runtime.ts";
|
|
|
6
6
|
import { formatCost } from "../../state/usage.ts";
|
|
7
7
|
import { listLiveAgents, listLiveAgentsByWorkspace, type LiveAgentHandle } from "../../runtime/live-agent-manager.ts";
|
|
8
8
|
import { computeLiveDurationMs } from "../live-duration.ts";
|
|
9
|
+
import { visibleWidth } from "../../utils/visual.ts";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Fixed visible widths for the per-agent numeric metrics (finding V-1).
|
|
13
|
+
* Right-aligning each field to a constant width stops the right edge of the
|
|
14
|
+
* stats block from jittering every tick as values change width
|
|
15
|
+
* (e.g. `9.9s`→`10.0s`, `950`→`1.0k`, `$0.9`→`$1.0`).
|
|
16
|
+
*/
|
|
17
|
+
const TOKENS_METRIC_WIDTH = 5; // `1.2k`, `12.3k`, `123`
|
|
18
|
+
const COST_METRIC_WIDTH = 7; // `$0.0500`, `$1.50`
|
|
19
|
+
const DURATION_METRIC_WIDTH = 6; // `3.0s`, `59.9s`, `120.0s`
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Width-aware right-align (padStart) for a numeric metric so its column stays
|
|
23
|
+
* stable across ticks. Uses `visibleWidth` so wide/CJK glyphs never misalign
|
|
24
|
+
* the padding. Values wider than `width` are returned verbatim (no truncation)
|
|
25
|
+
* so outliers overflow gracefully instead of crashing the render.
|
|
26
|
+
*/
|
|
27
|
+
function alignMetric(value: string, width: number): string {
|
|
28
|
+
const gap = width - visibleWidth(value);
|
|
29
|
+
return gap > 0 ? " ".repeat(gap) + value : value;
|
|
30
|
+
}
|
|
9
31
|
|
|
10
32
|
/**
|
|
11
33
|
* Returns true if this agent did real work (LLM call, tool use, or non-trivial duration).
|
|
@@ -89,12 +111,12 @@ export function renderAgentsPane(snapshot: RunUiSnapshot | undefined, options: R
|
|
|
89
111
|
const tokenTotal = (agent.usage?.input ?? 0) + (agent.usage?.output ?? 0) + (agent.usage?.cacheRead ?? 0) + (agent.usage?.cacheWrite ?? 0);
|
|
90
112
|
if (tokenTotal > 0) {
|
|
91
113
|
const tok = tokenTotal >= 1000 ? `${(tokenTotal / 1000).toFixed(1)}k` : `${tokenTotal}`;
|
|
92
|
-
stats.push(tok);
|
|
114
|
+
stats.push(alignMetric(tok, TOKENS_METRIC_WIDTH));
|
|
93
115
|
}
|
|
94
116
|
// Per-agent cost (Round 17 BS-1): the data is already on task.usage.cost;
|
|
95
117
|
// surface it live so the user sees $ burn per agent during a run.
|
|
96
118
|
if (agent.usage?.cost && agent.usage.cost > 0) {
|
|
97
|
-
stats.push(formatCost(agent.usage.cost));
|
|
119
|
+
stats.push(alignMetric(formatCost(agent.usage.cost), COST_METRIC_WIDTH));
|
|
98
120
|
}
|
|
99
121
|
if (liveHandle) {
|
|
100
122
|
// Round 23 (BUG 1): the duration math here was naive —
|
|
@@ -104,13 +126,13 @@ export function renderAgentsPane(snapshot: RunUiSnapshot | undefined, options: R
|
|
|
104
126
|
// fired for EVERY running live agent in the dashboard. Use the shared,
|
|
105
127
|
// validated computeLiveDurationMs (mirrors widget-formatters.ts).
|
|
106
128
|
const ms = computeLiveDurationMs(liveHandle.activity);
|
|
107
|
-
stats.push(`${(ms / 1000).toFixed(1)}s
|
|
129
|
+
stats.push(alignMetric(`${(ms / 1000).toFixed(1)}s`, DURATION_METRIC_WIDTH));
|
|
108
130
|
if (options.showModel !== false && liveHandle.modelName && liveHandle.modelName !== "default") {
|
|
109
131
|
stats.push(liveHandle.modelName);
|
|
110
132
|
}
|
|
111
133
|
} else if (agent.startedAt) {
|
|
112
134
|
const ms = Date.now() - new Date(agent.startedAt).getTime();
|
|
113
|
-
if (Number.isFinite(ms)) stats.push(`${(ms / 1000).toFixed(1)}s
|
|
135
|
+
if (Number.isFinite(ms)) stats.push(alignMetric(`${(ms / 1000).toFixed(1)}s`, DURATION_METRIC_WIDTH));
|
|
114
136
|
}
|
|
115
137
|
|
|
116
138
|
const statsStr = stats.length ? ` · ${stats.join(" ")}` : "";
|
|
@@ -40,4 +40,27 @@ export function renderCancellationPane(manifest: TeamRunManifest, tasks: TeamTas
|
|
|
40
40
|
}
|
|
41
41
|
|
|
42
42
|
return lines;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* D-1 / L-2 — a one-line terminal-run reason for the dashboard detail row.
|
|
47
|
+
*
|
|
48
|
+
* Surfaces *why* a failed/cancelled/stopped run ended without forcing the
|
|
49
|
+
* user to switch panes. This also gives this module a real consumer (it was
|
|
50
|
+
* previously zero-importer dead code per the UI/UX review), wiring it in as
|
|
51
|
+
* the natural home for cancellation/failure reason display.
|
|
52
|
+
*
|
|
53
|
+
* Resolution order: structured `run.cancelled` event reason → first failed
|
|
54
|
+
* task's error → first policy-decision message.
|
|
55
|
+
*/
|
|
56
|
+
export function summarizeTerminalReason(
|
|
57
|
+
manifest: TeamRunManifest,
|
|
58
|
+
tasks: TeamTaskState[],
|
|
59
|
+
cancellationReason?: string,
|
|
60
|
+
): string | undefined {
|
|
61
|
+
if (cancellationReason) return cancellationReason;
|
|
62
|
+
const failed = tasks.find((task) => task.status === "failed" && task.error);
|
|
63
|
+
if (failed?.error) return failed.error;
|
|
64
|
+
if (manifest.policyDecisions?.length) return manifest.policyDecisions[0]?.message;
|
|
65
|
+
return undefined;
|
|
43
66
|
}
|