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.
- package/CHANGELOG.md +114 -0
- package/README.md +66 -0
- 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/config/types.ts +5 -0
- package/src/extension/pi-api.ts +1 -1
- package/src/extension/registration/team-tool.ts +145 -35
- package/src/extension/team-tool/run.ts +646 -150
- package/src/runtime/child-pi.ts +87 -1
- package/src/runtime/goal-achievement.ts +131 -0
- package/src/runtime/recovery-recipes.ts +35 -0
- package/src/runtime/task-runner.ts +6 -0
- package/src/runtime/team-runner.ts +1482 -310
- 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/workflows/discover-workflows.ts +144 -29
- package/src/workflows/preflight-validator.ts +195 -0
- package/src/workflows/topology-analyzer.ts +219 -0
- package/src/workflows/workflow-config.ts +11 -0
- package/workflows/chain.workflow.md +6 -0
- package/workflows/default.workflow.md +1 -0
- package/workflows/fast-fix.workflow.md +1 -0
- package/workflows/implementation.workflow.md +1 -0
- package/workflows/parallel-research.workflow.md +1 -0
- package/workflows/pipeline.workflow.md +1 -0
- package/workflows/research.workflow.md +1 -0
- package/workflows/review.workflow.md +1 -0
package/src/runtime/child-pi.ts
CHANGED
|
@@ -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)
|
|
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
|
});
|