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.
@@ -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
- const memory = input.agent.memory ? buildMemoryBlock(input.agent.name, input.agent.memory, input.task.cwd, Boolean(input.agent.tools?.some((tool) => tool === "write" || tool === "edit"))) : "";
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
- function extractText(value: unknown): string[] {
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 > 2 * MAX_RESULT_INLINE_BYTES
114
- * (the head+tail is materially lossy small over-threshold files are not
115
- * teed because the inline content is mostly intact and the worker can live
116
- * with the 75/25 split). File content is read once and reused for both the
129
+ * Tee threshold: only when content.length > TEE_THRESHOLD_MULTIPLIER ×
130
+ * MAX_RESULT_INLINE_BYTES (R2: 1.25× = 40 KB; previously = 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 only when truncation is materially lossy (>2× threshold).
135
- if (opts.tee && content.length > maxChars * 2) {
151
+ // Tee when truncation is materially lossy (>TEE_THRESHOLD_MULTIPLIER ×
152
+ // threshold). R2: lowered from (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 resultText = item.resultArtifact ? readIfSmall(item.resultArtifact.path, manifest.artifactsRoot) : undefined;
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
- if (input.reliability?.autoRetry !== true) return withCorrelation(childCorrelation(manifest.runId, task.id), () => runTeamTask(baseInput));
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 ?? [])];