pi-crew 0.9.12 → 0.9.14
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 +86 -0
- package/README.md +1 -135
- package/docs/bugs/bug-021-notification-badge-counter-misleading.md +293 -0
- package/docs/bugs/bug-023-chain-windows-path-resolution.md +106 -0
- package/package.json +1 -1
- package/src/config/defaults.ts +3 -1
- package/src/extension/knowledge-injection.ts +236 -15
- package/src/extension/pi-api.ts +1 -1
- 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 +119 -0
- package/src/runtime/goal-achievement.ts +131 -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/recovery-recipes.ts +35 -0
- 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 +13 -0
- package/src/runtime/team-runner.ts +47 -2
- package/src/schema/team-tool-schema.ts +9 -0
- package/src/state/types.ts +3 -0
- package/src/ui/widget/index.ts +1 -1
- package/src/ui/widget/widget-formatters.ts +14 -1
- package/src/ui/widget/widget-renderer.ts +13 -2
- package/src/utils/redaction.ts +20 -0
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,11 +219,24 @@ 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;
|
|
224
232
|
/** True if the agent was steered to wrap up (hit soft turn limit) but finished in time. */
|
|
225
233
|
steered?: boolean;
|
|
234
|
+
/** #7 hardening: bounded digest of intermediate findings (last N tool results or
|
|
235
|
+
* assistant text lines) from the run. Populated by ChildPiLineObserver so that
|
|
236
|
+
* workers that exhaust their budget on tool calls (never emit final assistant
|
|
237
|
+
* text) still produce a non-empty result. Consumers should prefer rawFinalText
|
|
238
|
+
* first — this is a last-resort fallback. */
|
|
239
|
+
intermediateFindings?: string;
|
|
226
240
|
}
|
|
227
241
|
|
|
228
242
|
export function buildChildPiSpawnOptions(cwd: string, env: NodeJS.ProcessEnv): SpawnOptions {
|
|
@@ -512,6 +526,18 @@ function compactChildPiLine(line: string): { persistedLine: string; event?: unkn
|
|
|
512
526
|
export class ChildPiLineObserver {
|
|
513
527
|
private buffer = "";
|
|
514
528
|
private readonly input: ChildPiRunInput;
|
|
529
|
+
/** RAW (uncapped) assistant-text fragments, accumulated in arrival order.
|
|
530
|
+
* Mirrors {@link parsePiJsonOutput}'s textEvents/finalText extraction but
|
|
531
|
+
* operates on the RAW event stream instead of the 16K-compacted transcript.
|
|
532
|
+
* This is the source of the AUTHORITATIVE result.txt; the transcript stays
|
|
533
|
+
* compacted (memory bound). */
|
|
534
|
+
private readonly rawTextEvents: string[] = [];
|
|
535
|
+
/** #7 hardening: bounded digest of intermediate findings. When a worker spends
|
|
536
|
+
* its entire budget on tool calls (never emits a final assistant text),
|
|
537
|
+
* getRawFinalText() returns undefined but this digest captures the last
|
|
538
|
+
* display lines (tool results, stdout fragments) before budget exhaustion.
|
|
539
|
+
* Capped at MAX_INTERMEDIATE_DIGEST_LINES so result artifacts stay bounded. */
|
|
540
|
+
private readonly intermediateFindings: string[] = [];
|
|
515
541
|
|
|
516
542
|
constructor(input: ChildPiRunInput) {
|
|
517
543
|
this.input = input;
|
|
@@ -531,8 +557,50 @@ export class ChildPiLineObserver {
|
|
|
531
557
|
this.emitLine(line);
|
|
532
558
|
}
|
|
533
559
|
|
|
560
|
+
/** Last non-empty RAW assistant text (mirrors {@link parsePiJsonOutput}'s
|
|
561
|
+
* finalText semantics but uncapped). Undefined when no assistant text was
|
|
562
|
+
* seen by this observer. {@link extractText} already drops empty fragments,
|
|
563
|
+
* so the last entry is the final assistant utterance. */
|
|
564
|
+
getRawFinalText(): string | undefined {
|
|
565
|
+
return this.rawTextEvents.length > 0 ? this.rawTextEvents[this.rawTextEvents.length - 1] : undefined;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
/** #7 hardening: returns a bounded digest of intermediate findings accumulated
|
|
569
|
+
* during the run. This is NOT the final answer — it is a best-effort capture
|
|
570
|
+
* of the last assistant text or tool-result display lines before budget
|
|
571
|
+
* exhaustion. Only populated when getRawFinalText() would return undefined.
|
|
572
|
+
* @param maxChars - maximum total characters to return (default 500). */
|
|
573
|
+
getIntermediateFindings(maxChars = 500): string {
|
|
574
|
+
const MAX_INTERMEDIATE_DIGEST_LINES = 20;
|
|
575
|
+
if (this.intermediateFindings.length === 0) return "";
|
|
576
|
+
// Take the last N lines and join, then cap.
|
|
577
|
+
const lines = this.intermediateFindings.slice(-MAX_INTERMEDIATE_DIGEST_LINES);
|
|
578
|
+
const joined = lines.join("\n");
|
|
579
|
+
if (joined.length <= maxChars) return joined;
|
|
580
|
+
// Return the tail within the budget.
|
|
581
|
+
return joined.slice(-maxChars);
|
|
582
|
+
}
|
|
583
|
+
|
|
534
584
|
private emitLine(line: string): void {
|
|
535
585
|
if (!line.trim()) return;
|
|
586
|
+
// Parse the RAW line once so we can BOTH compact it (telemetry transcript,
|
|
587
|
+
// 16K-capped memory bound) AND capture the uncapped assistant text for the
|
|
588
|
+
// authoritative result. Non-JSON lines contribute no assistant text.
|
|
589
|
+
try {
|
|
590
|
+
const rawParsed = JSON.parse(line);
|
|
591
|
+
const rawTexts = extractText(rawParsed);
|
|
592
|
+
if (rawTexts.length > 0) {
|
|
593
|
+
this.rawTextEvents.push(...rawTexts);
|
|
594
|
+
// Also capture raw assistant text as intermediate findings — the last raw
|
|
595
|
+
// text may be a partial answer before the worker ran out of budget.
|
|
596
|
+
const last = rawTexts[rawTexts.length - 1];
|
|
597
|
+
if (last.trim().length > 0) {
|
|
598
|
+
this.intermediateFindings.push(last.trim());
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
} catch {
|
|
602
|
+
// Not valid JSON — compactChildPiLine handles the raw-text fallback below.
|
|
603
|
+
}
|
|
536
604
|
const compact = compactChildPiLine(line);
|
|
537
605
|
if (compact.event !== undefined) {
|
|
538
606
|
try {
|
|
@@ -548,6 +616,10 @@ export class ChildPiLineObserver {
|
|
|
548
616
|
} catch (error) {
|
|
549
617
|
logInternalError("child-pi.on-stdout-line", error, `line=${compact.displayLine}`);
|
|
550
618
|
}
|
|
619
|
+
// #7 hardening: capture display lines (tool results, stdout) as intermediate
|
|
620
|
+
// findings. This ensures we capture tool output even when no assistant text
|
|
621
|
+
// is emitted (budget exhausted on tool calls).
|
|
622
|
+
this.intermediateFindings.push(compact.displayLine!.trim());
|
|
551
623
|
}
|
|
552
624
|
}
|
|
553
625
|
}
|
|
@@ -795,6 +867,51 @@ export async function runChildPi(input: ChildPiRunInput): Promise<ChildPiRunResu
|
|
|
795
867
|
} catch (error) {
|
|
796
868
|
logInternalError("child-pi.response-timeout-term", error, `pid=${child.pid}`);
|
|
797
869
|
}
|
|
870
|
+
// #3 hardening: if the child never exits (zombie) and neither the
|
|
871
|
+
// 'exit' nor 'close' event ever fires, the promise would hang forever.
|
|
872
|
+
// SIGKILL fires ~3s after SIGTERM via hardKillTimer in killProcessPid,
|
|
873
|
+
// but on platforms where SIGKILL also fails (e.g. permission issues),
|
|
874
|
+
// add a bounded safety settle so the promise always resolves. Using
|
|
875
|
+
// hardKillMs + 2s as the safety window: enough for SIGKILL to work
|
|
876
|
+
// normally, but forces settle if the process is truly immortal.
|
|
877
|
+
// NOTE: we do NOT clear hardKillTimer here (that would defeat its purpose);
|
|
878
|
+
// we intentionally add a parallel safety path.
|
|
879
|
+
const SAFETY_SETTLE_MS = HARD_KILL_MS + 2000;
|
|
880
|
+
const safetyTimer = setTimeout(() => {
|
|
881
|
+
if (settled || childExited) return;
|
|
882
|
+
logInternalError(
|
|
883
|
+
"child-pi.settle-safety-fired",
|
|
884
|
+
new Error(`Child did not exit within ${SAFETY_SETTLE_MS}ms of kill; forcing settle`),
|
|
885
|
+
`pid=${child.pid}, responseTimeoutMs=${responseTimeoutMs}`,
|
|
886
|
+
);
|
|
887
|
+
// Verify the child is still alive before forcing settle.
|
|
888
|
+
// If it somehow exited between childExited=false and here, the
|
|
889
|
+
// settled/childExited guard prevents double-settle (harmless but noisy).
|
|
890
|
+
try {
|
|
891
|
+
process.kill(child.pid!, 0);
|
|
892
|
+
// Child still alive — force settle with timeout error.
|
|
893
|
+
const timeoutErr = `Child Pi produced no new output for ${responseTimeoutMs}ms; killed but did not exit within ${SAFETY_SETTLE_MS}ms (possible zombie).`;
|
|
894
|
+
settle({
|
|
895
|
+
exitCode: null,
|
|
896
|
+
stdout,
|
|
897
|
+
stderr,
|
|
898
|
+
error: timeoutErr,
|
|
899
|
+
exitStatus: {
|
|
900
|
+
exitCode: null,
|
|
901
|
+
cancelled: abortRequested,
|
|
902
|
+
timedOut: true,
|
|
903
|
+
killed: hardKilled,
|
|
904
|
+
cleanupErrors,
|
|
905
|
+
finalDrainMs,
|
|
906
|
+
crashClass: "timeout",
|
|
907
|
+
},
|
|
908
|
+
});
|
|
909
|
+
} catch {
|
|
910
|
+
// ESRCH / EPERM — child is already gone. The 'exit'/'close' handler
|
|
911
|
+
// will fire shortly (or already fired in a race). Let it settle normally.
|
|
912
|
+
}
|
|
913
|
+
}, SAFETY_SETTLE_MS);
|
|
914
|
+
safetyTimer.unref();
|
|
798
915
|
}, responseTimeoutMs);
|
|
799
916
|
noResponseTimer.unref();
|
|
800
917
|
};
|
|
@@ -919,6 +1036,8 @@ export async function runChildPi(input: ChildPiRunInput): Promise<ChildPiRunResu
|
|
|
919
1036
|
try {
|
|
920
1037
|
resolve({
|
|
921
1038
|
...result,
|
|
1039
|
+
rawFinalText: lineObserver.getRawFinalText(),
|
|
1040
|
+
intermediateFindings: lineObserver.getIntermediateFindings(),
|
|
922
1041
|
exitStatus: result.exitStatus ?? {
|
|
923
1042
|
exitCode: result.exitCode,
|
|
924
1043
|
cancelled: abortRequested,
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #2 (assessment): goal-achievement detection — kills the "false-green" lie.
|
|
3
|
+
*
|
|
4
|
+
* The v0.9.13 assessment found run team_20260626170635 reported terminal-success
|
|
5
|
+
* ("completed") while its verifier wrote: "did NOT apply ANY of the three
|
|
6
|
+
* security fixes. git diff --stat for all 6 target files is empty… Tests are
|
|
7
|
+
* green only because nothing was changed." The run status LIED.
|
|
8
|
+
*
|
|
9
|
+
* This module assesses whether a completed run actually achieved its goal, so
|
|
10
|
+
* the false-green is no longer SILENT. It is deliberately conservative:
|
|
11
|
+
* - read-only / doc-only workflows are never accused (they legitimately make
|
|
12
|
+
* no project-code edits; their outputs land in gitignored .crew/),
|
|
13
|
+
* - a false-green is only flagged for CODE-MUTATING runs (executor /
|
|
14
|
+
* test-engineer steps) in a git repo whose working tree is empty,
|
|
15
|
+
* - status is downgraded to "failed" ONLY when a corroborating signal (a
|
|
16
|
+
* task that actually failed) confirms it; otherwise the suspicion is
|
|
17
|
+
* exposed via `goalAchieved=false` + a prominent event + manifest note,
|
|
18
|
+
* without breaking a legitimately-no-op run.
|
|
19
|
+
*/
|
|
20
|
+
import { spawnSync } from "node:child_process";
|
|
21
|
+
import type { TeamRunManifest, TeamTaskState } from "../state/types.ts";
|
|
22
|
+
import type { WorkflowConfig } from "../workflows/workflow-config.ts";
|
|
23
|
+
import { findRepoRoot } from "../utils/paths.ts";
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Roles whose job is to EDIT project source/test files (changes that appear in
|
|
27
|
+
* `git status`). Deliberately NARROW: writer/verifier/security-reviewer write
|
|
28
|
+
* reports (often to gitignored .crew/), so an empty diff is NOT a failure
|
|
29
|
+
* signal for them. Only executor/test-engineer MUST touch project code.
|
|
30
|
+
*/
|
|
31
|
+
export const MUTATING_ROLES = new Set<string>(["executor", "test-engineer"]);
|
|
32
|
+
|
|
33
|
+
export type GoalAchieved = boolean | "unknown";
|
|
34
|
+
|
|
35
|
+
export interface GoalAchievementAssessment {
|
|
36
|
+
achieved: GoalAchieved;
|
|
37
|
+
reason: string;
|
|
38
|
+
/** human-readable corroborating signals (for events/manifest) */
|
|
39
|
+
signals: string[];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Does this workflow contain any code-mutating role step? */
|
|
43
|
+
export function workflowIsMutating(workflow: WorkflowConfig | undefined): boolean {
|
|
44
|
+
const steps = workflow?.steps ?? [];
|
|
45
|
+
return steps.some((step) => typeof step?.role === "string" && MUTATING_ROLES.has(step.role));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Is the working tree of the repo containing `cwd` clean (no changes)? Throws-safe. */
|
|
49
|
+
export function isGitWorkingTreeClean(cwd: string): { clean: boolean; repoRoot: string } | { clean: "unknown"; repoRoot: undefined } {
|
|
50
|
+
try {
|
|
51
|
+
const repoRoot = findRepoRoot(cwd);
|
|
52
|
+
if (!repoRoot) return { clean: "unknown", repoRoot: undefined };
|
|
53
|
+
// `git status --porcelain` → empty stdout means a clean working tree.
|
|
54
|
+
const res = spawnSync("git", ["-C", repoRoot, "status", "--porcelain"], { encoding: "utf8", timeout: 5_000 });
|
|
55
|
+
if (res.error || res.status !== 0) return { clean: "unknown", repoRoot: undefined };
|
|
56
|
+
return { clean: res.stdout.trim().length === 0, repoRoot };
|
|
57
|
+
} catch {
|
|
58
|
+
return { clean: "unknown", repoRoot: undefined };
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Assess whether a completed run achieved its goal. Pure function over manifest
|
|
64
|
+
* + tasks + workflow — no side effects, easy to unit-test.
|
|
65
|
+
*/
|
|
66
|
+
export function assessGoalAchievement(
|
|
67
|
+
manifest: TeamRunManifest,
|
|
68
|
+
tasks: TeamTaskState[],
|
|
69
|
+
workflow: WorkflowConfig | undefined,
|
|
70
|
+
): GoalAchievementAssessment {
|
|
71
|
+
// (1) Only code-mutating workflows can be false-green. Read-only/doc runs
|
|
72
|
+
// legitimately make no project edits — never accuse them.
|
|
73
|
+
if (!workflowIsMutating(workflow)) {
|
|
74
|
+
return { achieved: "unknown", reason: "read-only / doc-only workflow (no executor/test-engineer steps)", signals: [] };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// (2) Need a git repo to inspect the working tree.
|
|
78
|
+
const tree = isGitWorkingTreeClean(manifest.cwd);
|
|
79
|
+
if (tree.clean === "unknown" || !tree.repoRoot) {
|
|
80
|
+
return { achieved: "unknown", reason: "not a git repo or git unavailable", signals: [] };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// (3) Non-empty working tree → the mutating run made edits. Achieved.
|
|
84
|
+
if (!tree.clean) {
|
|
85
|
+
return { achieved: true, reason: "git working tree has changes (mutating run edited project files)", signals: [`repoRoot=${tree.repoRoot}`] };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// (4) Mutating run + CLEAN working tree → suspicious. The executor's job was
|
|
89
|
+
// to edit code and it left zero diff. This is the false-green signature.
|
|
90
|
+
const failedTask = tasks.find((t) => t.status === "failed");
|
|
91
|
+
const signals = ["git working tree clean despite mutating workflow"];
|
|
92
|
+
if (failedTask) signals.push(`corroborating failed task: ${failedTask.id} (${failedTask.role})`);
|
|
93
|
+
|
|
94
|
+
return {
|
|
95
|
+
// `false` = false-green detected. Whether to downgrade status is decided
|
|
96
|
+
// by the caller based on the corroborating failed-task signal.
|
|
97
|
+
achieved: false,
|
|
98
|
+
reason: "code-mutating run completed but made no project edits (false-green)",
|
|
99
|
+
signals,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Apply an assessment to a run result: set manifest.goalAchieved + note, and
|
|
105
|
+
* decide whether to downgrade status. Returns the (possibly mutated) manifest.
|
|
106
|
+
*
|
|
107
|
+
* Downgrade rule (conservative): only flip "completed" → "failed" when a
|
|
108
|
+
* corroborating failed-task signal is present. Otherwise expose goalAchieved
|
|
109
|
+
* = false + a manifest note + let the caller emit an event, but leave status
|
|
110
|
+
* intact so a legitimately-no-op mutating run is not broken.
|
|
111
|
+
*/
|
|
112
|
+
export function applyGoalAchievement(
|
|
113
|
+
manifest: TeamRunManifest,
|
|
114
|
+
assessment: GoalAchievementAssessment,
|
|
115
|
+
): { manifest: TeamRunManifest; downgraded: boolean } {
|
|
116
|
+
const note = assessment.achieved === false
|
|
117
|
+
? `goal-achievement: FALSE-GREEN — ${assessment.reason}. ${assessment.signals.join("; ")}`
|
|
118
|
+
: assessment.achieved === true
|
|
119
|
+
? `goal-achievement: OK — ${assessment.reason}`
|
|
120
|
+
: `goal-achievement: unknown — ${assessment.reason}`;
|
|
121
|
+
|
|
122
|
+
const updated: TeamRunManifest = { ...manifest, goalAchieved: assessment.achieved, goalAchievementNote: note };
|
|
123
|
+
|
|
124
|
+
// Downgrade only on the highest-confidence false-green: a failed task
|
|
125
|
+
// corroborates that the run genuinely did not succeed.
|
|
126
|
+
const hasCorroboratingFailure = assessment.signals.some((s) => s.startsWith("corroborating failed task"));
|
|
127
|
+
if (assessment.achieved === false && hasCorroboratingFailure && updated.status === "completed") {
|
|
128
|
+
return { manifest: { ...updated, status: "failed" }, downgraded: true };
|
|
129
|
+
}
|
|
130
|
+
return { manifest: updated, downgraded: false };
|
|
131
|
+
}
|
|
@@ -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);
|
|
@@ -72,3 +72,38 @@ export function buildRecoveryLedger(decisions: PolicyDecision[], previous: Recov
|
|
|
72
72
|
}
|
|
73
73
|
return { entries };
|
|
74
74
|
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* #4 (assessment): decide whether a FAILED task should be re-queued for a
|
|
78
|
+
* whole-task rerun, honoring limits.maxRetriesPerTask.
|
|
79
|
+
*
|
|
80
|
+
* Before #4, buildRecoveryLedger recorded `rerun_task` entries with
|
|
81
|
+
* state:"planned" but NOTHING ever executed them — the recovery ledger was
|
|
82
|
+
* decorative. This pure function drives the actual re-queue decision used in
|
|
83
|
+
* the run loop: when a task returns a failed STATUS (not a retryable throw,
|
|
84
|
+
* which #1's autoRetry/executeWithRetry already handles), re-queue it for a
|
|
85
|
+
* bounded whole-task rerun instead of immediately aborting the run.
|
|
86
|
+
*
|
|
87
|
+
* Default-off: maxRetriesPerTask defaults to 0 → never rerun (preserves prior
|
|
88
|
+
* behavior unless explicitly opted in). Bounded by retryCount < maxRetries.
|
|
89
|
+
*/
|
|
90
|
+
export interface RerunDecision {
|
|
91
|
+
rerun: boolean;
|
|
92
|
+
newRetryCount: number;
|
|
93
|
+
reason: string;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function shouldRerunFailedTask(
|
|
97
|
+
task: { policy?: { retryCount?: number } },
|
|
98
|
+
limits?: { maxRetriesPerTask?: number },
|
|
99
|
+
): RerunDecision {
|
|
100
|
+
const maxRetries = limits?.maxRetriesPerTask ?? 0;
|
|
101
|
+
const retryCount = task.policy?.retryCount ?? 0;
|
|
102
|
+
if (maxRetries <= 0) {
|
|
103
|
+
return { rerun: false, newRetryCount: retryCount, reason: "maxRetriesPerTask not set (opt-in) — no whole-task rerun" };
|
|
104
|
+
}
|
|
105
|
+
if (retryCount >= maxRetries) {
|
|
106
|
+
return { rerun: false, newRetryCount: retryCount, reason: `retryCount ${retryCount} >= maxRetriesPerTask ${maxRetries} — rerun budget exhausted` };
|
|
107
|
+
}
|
|
108
|
+
return { rerun: true, newRetryCount: retryCount + 1, reason: `whole-task rerun ${retryCount + 1}/${maxRetries}` };
|
|
109
|
+
}
|
|
@@ -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,8 @@ 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;
|
|
346
|
+
let intermediateFindings: string | undefined;
|
|
345
347
|
let finalStdout = "";
|
|
346
348
|
let transcriptPath: string | undefined;
|
|
347
349
|
let terminalEvidence: OperationTerminalEvidence[] = [];
|
|
@@ -717,6 +719,8 @@ export async function runTeamTask(
|
|
|
717
719
|
childResult.stdout,
|
|
718
720
|
);
|
|
719
721
|
parsedOutput = parsePiJsonOutput(transcriptText);
|
|
722
|
+
rawFinalText = childResult.rawFinalText;
|
|
723
|
+
intermediateFindings = childResult.intermediateFindings;
|
|
720
724
|
error =
|
|
721
725
|
childResult.error ||
|
|
722
726
|
(childResult.exitCode && childResult.exitCode !== 0
|
|
@@ -836,9 +840,18 @@ export async function runTeamTask(
|
|
|
836
840
|
kind: "result",
|
|
837
841
|
relativePath: `results/${task.id}.txt`,
|
|
838
842
|
content:
|
|
843
|
+
// Prefer the RAW (uncapped) final assistant text captured before the
|
|
844
|
+
// transcript's 16K compaction — this is the authoritative worker output.
|
|
845
|
+
// Fall back to transcript-derived finalText, then stdout/stderr, so a
|
|
846
|
+
// missing raw capture (mock/error path) never yields empty/garbage.
|
|
847
|
+
cleanResultText(rawFinalText) ??
|
|
839
848
|
cleanResultText(parsedOutput?.finalText) ??
|
|
840
849
|
cleanResultText(finalStdout) ??
|
|
841
850
|
cleanResultText(finalStderr) ??
|
|
851
|
+
// #7 hardening: if all real output paths are empty (worker exhausted
|
|
852
|
+
// budget on tool calls, no assistant text), use intermediate findings.
|
|
853
|
+
// intermediateFindings captures the last N tool-result display lines.
|
|
854
|
+
cleanResultText(intermediateFindings) ??
|
|
842
855
|
"(no output)",
|
|
843
856
|
producer: task.id,
|
|
844
857
|
});
|
|
@@ -15,7 +15,8 @@ import { withRunLock } from "../state/locks.ts";
|
|
|
15
15
|
import { aggregateUsage, formatUsage } from "../state/usage.ts";
|
|
16
16
|
import type { WorkflowConfig, WorkflowStep } from "../workflows/workflow-config.ts";
|
|
17
17
|
import { evaluateCrewPolicy, summarizePolicyDecisions } from "./policy-engine.ts";
|
|
18
|
-
import { buildRecoveryLedger } from "./recovery-recipes.ts";
|
|
18
|
+
import { buildRecoveryLedger, shouldRerunFailedTask } from "./recovery-recipes.ts";
|
|
19
|
+
import { assessGoalAchievement, applyGoalAchievement } from "./goal-achievement.ts";
|
|
19
20
|
import { buildTaskGraphIndex, refreshTaskGraphQueues, taskGraphSnapshot } from "./task-graph-scheduler.ts";
|
|
20
21
|
import { buildExecutionPlan as buildDagExecutionPlan, getReadyTasks as getDagReadyTasks, type TaskNode } from "./task-graph.ts";
|
|
21
22
|
import { checkBranchFreshness } from "../worktree/branch-freshness.ts";
|
|
@@ -343,6 +344,16 @@ function retryPolicyFromConfig(config: CrewReliabilityConfig | undefined): Retry
|
|
|
343
344
|
return { ...DEFAULT_RETRY_POLICY, ...(config?.retryPolicy ?? {}) };
|
|
344
345
|
}
|
|
345
346
|
|
|
347
|
+
/**
|
|
348
|
+
* #1 (assessment): decide whether the per-task retry path (executeWithRetry) is used.
|
|
349
|
+
* Defaults to TRUE (opt-out) so transient worker hangs (ChildTimeout) are retried
|
|
350
|
+
* automatically. Previously opt-in, which left the entire retry+recovery stack dormant.
|
|
351
|
+
* Exported for unit testing.
|
|
352
|
+
*/
|
|
353
|
+
export function shouldUseRetry(reliability: CrewReliabilityConfig | undefined): boolean {
|
|
354
|
+
return reliability?.autoRetry !== false;
|
|
355
|
+
}
|
|
356
|
+
|
|
346
357
|
function failedTaskFrom(result: { tasks: TeamTaskState[] }, taskId: string): TeamTaskState | undefined {
|
|
347
358
|
return result.tasks.find((item) => item.id === taskId && item.status === "failed");
|
|
348
359
|
}
|
|
@@ -452,6 +463,23 @@ export async function executeTeamRun(input: ExecuteTeamRunInput): Promise<{ mani
|
|
|
452
463
|
|
|
453
464
|
try {
|
|
454
465
|
const result = await executeTeamRunCore(input, manifest, workflow);
|
|
466
|
+
// #2 (assessment): goal-achievement detection — kill the silent false-green.
|
|
467
|
+
// A code-mutating run that "completed" but left the git working tree clean
|
|
468
|
+
// (and/or had a failed task) is a false-green. We expose goalAchieved on the
|
|
469
|
+
// manifest + emit an event so the lie is never silent, and downgrade status
|
|
470
|
+
// to "failed" only when a failed task corroborates it (conservative).
|
|
471
|
+
const gaAssessment = assessGoalAchievement(result.manifest, result.tasks, workflow);
|
|
472
|
+
const gaApplied = applyGoalAchievement(result.manifest, gaAssessment);
|
|
473
|
+
if (gaApplied.manifest !== result.manifest) {
|
|
474
|
+
result.manifest = gaApplied.manifest;
|
|
475
|
+
try {
|
|
476
|
+
saveRunManifest(result.manifest);
|
|
477
|
+
} catch (persistError) {
|
|
478
|
+
logInternalError("team-runner.goalAchievement.persist", persistError instanceof Error ? persistError : new Error(String(persistError)), `runId=${manifest.runId}`);
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
appendEvent(manifest.eventsPath, { type: "run.goal_achievement", runId: manifest.runId, message: gaApplied.manifest.goalAchievementNote ?? "", data: { achieved: gaAssessment.achieved, downgraded: gaApplied.downgraded, reason: gaAssessment.reason, signals: gaAssessment.signals } });
|
|
482
|
+
if (gaApplied.downgraded) logInternalError("team-runner.goalAchievement.falseGreen", new Error(gaApplied.manifest.goalAchievementNote ?? "false-green detected"), `runId=${manifest.runId}`);
|
|
455
483
|
stopTeamHeartbeat();
|
|
456
484
|
resolveRunPromise(manifest.runId, result);
|
|
457
485
|
cleanupUsage();
|
|
@@ -602,6 +630,18 @@ async function executeTeamRunCore(
|
|
|
602
630
|
|
|
603
631
|
const failed = tasks.find((task) => task.status === "failed");
|
|
604
632
|
if (failed) {
|
|
633
|
+
// #4 (assessment): honor limits.maxRetriesPerTask — re-queue an eligible
|
|
634
|
+
// failed task for a bounded whole-task rerun instead of immediately
|
|
635
|
+
// aborting the run. Before #4, the recovery ledger recorded `rerun_task`
|
|
636
|
+
// entries with state:"planned" but never executed them (decorative).
|
|
637
|
+
// Default-off: maxRetriesPerTask=0 → original abort behavior preserved.
|
|
638
|
+
const rerun = shouldRerunFailedTask(failed, input.limits);
|
|
639
|
+
if (rerun.rerun) {
|
|
640
|
+
tasks = tasks.map((item) => item.id === failed.id ? { ...item, status: "queued" as const, policy: { ...(item.policy ?? {}), retryCount: rerun.newRetryCount }, error: undefined, finishedAt: undefined } : item);
|
|
641
|
+
await saveRunTasksAsync(manifest, tasks);
|
|
642
|
+
await appendEventAsync(manifest.eventsPath, { type: "recovery.rerun_task", runId: manifest.runId, taskId: failed.id, message: `Re-queuing failed task for whole-task rerun: ${rerun.reason}`, data: { attempt: rerun.newRetryCount, maxRetries: input.limits?.maxRetriesPerTask ?? 0, scenario: "task_failed" } });
|
|
643
|
+
continue; // loop re-processes the re-queued task
|
|
644
|
+
}
|
|
605
645
|
tasks = markBlocked(tasks, `Blocked by failed task '${failed.id}'.`);
|
|
606
646
|
await saveRunTasksAsync(manifest, tasks);
|
|
607
647
|
saveCrewAgents(manifest, recordsForMaterializedTasks(manifest, tasks, runtimeKind));
|
|
@@ -685,7 +725,12 @@ async function executeTeamRunCore(
|
|
|
685
725
|
const teamRole = input.team.roles.find((role) => role.name === task.role);
|
|
686
726
|
const perTaskRuntime = resolveTaskRuntimeKind(runtimeKind, task.role, input.runtimeConfig?.isolationPolicy);
|
|
687
727
|
const baseInput = { manifest, tasks, task, step, agent, signal: input.signal, executeWorkers: input.executeWorkers, runtimeKind: runtimeKind, taskRuntimeOverride: perTaskRuntime !== runtimeKind ? perTaskRuntime : undefined, runtimeConfig: input.runtimeConfig, parentContext: input.parentContext, parentModel: input.parentModel, modelRegistry: input.modelRegistry, modelOverride: input.modelOverride, teamRoleModel: teamRole?.model, teamRoleSkills: teamRole?.skills, skillOverride: input.skillOverride, limits: input.limits, onJsonEvent: input.onJsonEvent, workspaceId: input.workspaceId };
|
|
688
|
-
|
|
728
|
+
// #1 (assessment): autoRetry now defaults ON (opt-out via reliability.autoRetry=false).
|
|
729
|
+
// The dominant v0.9.13 failure was ChildTimeout ("worker became unresponsive") with
|
|
730
|
+
// ZERO retries because this gate was opt-in. isRetryable() defaults to true when
|
|
731
|
+
// retryableErrors is empty, so transient hangs now retry up to maxAttempts (3) with
|
|
732
|
+
// exponential backoff. Set reliability.autoRetry=false to restore old single-shot behavior.
|
|
733
|
+
if (!shouldUseRetry(input.reliability)) return withCorrelation(childCorrelation(manifest.runId, task.id), () => runTeamTask(baseInput));
|
|
689
734
|
let lastFailed: { manifest: TeamRunManifest; tasks: TeamTaskState[] } | undefined;
|
|
690
735
|
let lastAttemptId: string | undefined;
|
|
691
736
|
const attemptsSoFar: TaskAttemptState[] = [...(task.attempts ?? [])];
|