pi-crew 0.9.13 → 0.9.15

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.
@@ -231,6 +231,12 @@ export interface ChildPiRunResult {
231
231
  aborted?: boolean;
232
232
  /** True if the agent was steered to wrap up (hit soft turn limit) but finished in time. */
233
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;
234
240
  }
235
241
 
236
242
  export function buildChildPiSpawnOptions(cwd: string, env: NodeJS.ProcessEnv): SpawnOptions {
@@ -526,6 +532,12 @@ export class ChildPiLineObserver {
526
532
  * This is the source of the AUTHORITATIVE result.txt; the transcript stays
527
533
  * compacted (memory bound). */
528
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[] = [];
529
541
 
530
542
  constructor(input: ChildPiRunInput) {
531
543
  this.input = input;
@@ -553,6 +565,22 @@ export class ChildPiLineObserver {
553
565
  return this.rawTextEvents.length > 0 ? this.rawTextEvents[this.rawTextEvents.length - 1] : undefined;
554
566
  }
555
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
+
556
584
  private emitLine(line: string): void {
557
585
  if (!line.trim()) return;
558
586
  // Parse the RAW line once so we can BOTH compact it (telemetry transcript,
@@ -561,7 +589,15 @@ export class ChildPiLineObserver {
561
589
  try {
562
590
  const rawParsed = JSON.parse(line);
563
591
  const rawTexts = extractText(rawParsed);
564
- if (rawTexts.length > 0) this.rawTextEvents.push(...rawTexts);
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
+ }
565
601
  } catch {
566
602
  // Not valid JSON — compactChildPiLine handles the raw-text fallback below.
567
603
  }
@@ -580,6 +616,10 @@ export class ChildPiLineObserver {
580
616
  } catch (error) {
581
617
  logInternalError("child-pi.on-stdout-line", error, `line=${compact.displayLine}`);
582
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());
583
623
  }
584
624
  }
585
625
  }
@@ -827,6 +867,51 @@ export async function runChildPi(input: ChildPiRunInput): Promise<ChildPiRunResu
827
867
  } catch (error) {
828
868
  logInternalError("child-pi.response-timeout-term", error, `pid=${child.pid}`);
829
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();
830
915
  }, responseTimeoutMs);
831
916
  noResponseTimer.unref();
832
917
  };
@@ -952,6 +1037,7 @@ export async function runChildPi(input: ChildPiRunInput): Promise<ChildPiRunResu
952
1037
  resolve({
953
1038
  ...result,
954
1039
  rawFinalText: lineObserver.getRawFinalText(),
1040
+ intermediateFindings: lineObserver.getIntermediateFindings(),
955
1041
  exitStatus: result.exitStatus ?? {
956
1042
  exitCode: result.exitCode,
957
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
+ }
@@ -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
+ }
@@ -343,6 +343,7 @@ export async function runTeamTask(
343
343
  let modelAttempts: ModelAttemptSummary[] | undefined;
344
344
  let parsedOutput: ParsedPiJsonOutput | undefined;
345
345
  let rawFinalText: string | undefined;
346
+ let intermediateFindings: string | undefined;
346
347
  let finalStdout = "";
347
348
  let transcriptPath: string | undefined;
348
349
  let terminalEvidence: OperationTerminalEvidence[] = [];
@@ -719,6 +720,7 @@ export async function runTeamTask(
719
720
  );
720
721
  parsedOutput = parsePiJsonOutput(transcriptText);
721
722
  rawFinalText = childResult.rawFinalText;
723
+ intermediateFindings = childResult.intermediateFindings;
722
724
  error =
723
725
  childResult.error ||
724
726
  (childResult.exitCode && childResult.exitCode !== 0
@@ -846,6 +848,10 @@ export async function runTeamTask(
846
848
  cleanResultText(parsedOutput?.finalText) ??
847
849
  cleanResultText(finalStdout) ??
848
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) ??
849
855
  "(no output)",
850
856
  producer: task.id,
851
857
  });