pi-crew 0.2.19 → 0.2.21
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 +23 -10
- package/README.md +4 -2
- package/docs/PROJECT_REVIEW.md +271 -0
- package/docs/PROJECT_REVIEW_FIXES.md +343 -0
- package/docs/PROJECT_REVIEW_ROUND4.md +156 -0
- package/docs/PROJECT_REVIEW_ROUND5.md +86 -0
- package/docs/fixes/BATCH_A_H1_H2.md +86 -0
- package/docs/fixes/bug-006-foreground-cancel-concurrent.md +78 -0
- package/docs/fixes/bug-007-async-notifier-stale-ctx.md +112 -0
- package/docs/fixes/bug-008-child-process-silent-timeout.md +100 -0
- package/docs/fixes/bug-009-executor-yield-limit-needs-attention.md +75 -0
- package/docs/fixes/bug-010-child-process-api-key-filtered.md +109 -0
- package/docs/fixes/bug-011-spawn-pi-enoent.md +92 -0
- package/docs/fixes/bug-012-essential-env-stripped.md +89 -0
- package/docs/fixes/bug-013-background-runner-death.md +84 -0
- package/docs/fixes/bug-014-infinite-retry-loop-needs-attention.md +82 -0
- package/docs/fixes/bug-015-background-runner-sigterm.md +65 -0
- package/docs/fixes/bug-017-background-runner-session-shutdown.md +66 -0
- package/docs/fixes/bug-017-background-runner-sigkill-double-fork.md +28 -0
- package/docs/fixes/bug-018-child-pi-worker-stdin-hang.md +61 -0
- package/docs/fixes/bug-019-phantom-runs-temp-workspace.md +52 -0
- package/docs/pi-crew-bugs.md +954 -0
- package/docs/pi-crew-investigation-report.md +411 -0
- package/docs/pi-crew-test-final.md +120 -0
- package/docs/pi-crew-test-results.md +260 -0
- package/docs/pi-crew-test-round2.md +136 -0
- package/docs/pi-crew-test-round4.md +100 -0
- package/docs/pi-crew-test-round5.md +70 -0
- package/docs/pi-crew-test-round6.md +110 -0
- package/docs/usage.md +14 -0
- package/package.json +7 -5
- package/src/adapters/export-util.ts +12 -6
- package/src/agents/agent-config.ts +2 -0
- package/src/config/defaults.ts +1 -1
- package/src/config/markers.ts +22 -17
- package/src/config/resilient-parser.ts +1 -1
- package/src/extension/async-notifier.ts +4 -2
- package/src/extension/management.ts +52 -0
- package/src/extension/register.ts +47 -10
- package/src/extension/run-index.ts +20 -2
- package/src/extension/run-maintenance.ts +2 -2
- package/src/extension/team-tool/parallel-dispatch.ts +1 -1
- package/src/extension/team-tool/run.ts +3 -6
- package/src/extension/team-tool.ts +67 -11
- package/src/observability/event-to-metric.ts +2 -1
- package/src/runtime/async-runner.ts +42 -34
- package/src/runtime/background-runner.ts +165 -7
- package/src/runtime/child-pi.ts +111 -18
- package/src/runtime/code-summary.ts +1 -1
- package/src/runtime/crash-recovery.ts +1 -1
- package/src/runtime/crew-agent-runtime.ts +2 -1
- package/src/runtime/heartbeat-watcher.ts +4 -0
- package/src/runtime/live-agent-manager.ts +1 -1
- package/src/runtime/live-session-runtime.ts +2 -1
- package/src/runtime/manifest-cache.ts +2 -2
- package/src/runtime/model-fallback.ts +2 -1
- package/src/runtime/phase-progress.ts +1 -1
- package/src/runtime/pi-args.ts +3 -1
- package/src/runtime/pi-spawn.ts +6 -0
- package/src/runtime/prose-compressor.ts +1 -1
- package/src/runtime/result-extractor.ts +0 -1
- package/src/runtime/retry-executor.ts +1 -1
- package/src/runtime/runtime-resolver.ts +1 -1
- package/src/runtime/skill-instructions.ts +0 -1
- package/src/runtime/stale-reconciler.ts +30 -3
- package/src/runtime/subagent-manager.ts +2 -0
- package/src/runtime/task-display.ts +1 -1
- package/src/runtime/task-graph-scheduler.ts +1 -1
- package/src/runtime/task-runner/tail-read.ts +26 -0
- package/src/runtime/task-runner.ts +1007 -383
- package/src/runtime/team-runner.ts +9 -5
- package/src/runtime/worker-startup.ts +3 -1
- package/src/schema/team-tool-schema.ts +2 -1
- package/src/state/active-run-registry.ts +8 -2
- package/src/state/atomic-write.ts +17 -0
- package/src/state/contracts.ts +5 -2
- package/src/state/event-log-rotation.ts +118 -31
- package/src/state/event-log.ts +33 -5
- package/src/state/event-reconstructor.ts +4 -2
- package/src/state/mailbox.ts +5 -1
- package/src/state/schedule.ts +146 -0
- package/src/state/types.ts +40 -0
- package/src/state/usage.ts +20 -0
- package/src/ui/crew-widget.ts +2 -2
- package/src/ui/run-event-bus.ts +1 -1
- package/src/ui/run-snapshot-cache.ts +2 -1
- package/src/ui/snapshot-types.ts +1 -0
- package/src/utils/gh-protocol.ts +2 -2
- package/src/utils/names.ts +1 -1
- package/src/utils/sse-parser.ts +0 -2
- package/src/worktree/branch-freshness.ts +1 -1
- package/src/worktree/cleanup.ts +54 -14
- package/src/worktree/worktree-manager.ts +19 -9
|
@@ -157,7 +157,7 @@ export function createManifestCache(cwd: string, options: ManifestCacheOptions =
|
|
|
157
157
|
}
|
|
158
158
|
|
|
159
159
|
function loadManifest(runId: string, rootsToCheck: string[]): CachedManifest | undefined {
|
|
160
|
-
|
|
160
|
+
const cached = manifestIndex.get(runId);
|
|
161
161
|
if (!isSafePathId(runId)) return undefined;
|
|
162
162
|
const activeEntry = activeRunEntries().find((entry) => entry.runId === runId);
|
|
163
163
|
if (activeEntry) {
|
|
@@ -211,7 +211,7 @@ export function createManifestCache(cwd: string, options: ManifestCacheOptions =
|
|
|
211
211
|
|
|
212
212
|
|
|
213
213
|
const runs = [...unique.values()].filter((value): value is CachedManifest => value !== undefined).map((value) => value.manifest);
|
|
214
|
-
const sorted = runs.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
|
214
|
+
const sorted = runs.sort((a, b) => (b.createdAt ?? "").localeCompare(a.createdAt ?? ""));
|
|
215
215
|
const limited = sorted.slice(0, Math.max(0, limit));
|
|
216
216
|
if (manifestIndex.size > maxEntries) {
|
|
217
217
|
const removeCount = manifestIndex.size - maxEntries;
|
|
@@ -161,9 +161,10 @@ export function resolveModelCandidate(
|
|
|
161
161
|
}
|
|
162
162
|
|
|
163
163
|
const RETRYABLE_MODEL_FAILURE_PATTERNS = [
|
|
164
|
-
/rate
|
|
164
|
+
/rate.?limit/i,
|
|
165
165
|
/too many requests/i,
|
|
166
166
|
/\b429\b/,
|
|
167
|
+
/rate_limit_error/i,
|
|
167
168
|
/quota/i,
|
|
168
169
|
/provider.*unavailable/i,
|
|
169
170
|
/model.*unavailable/i,
|
|
@@ -48,7 +48,7 @@ export interface RunProgress {
|
|
|
48
48
|
// Helpers
|
|
49
49
|
// ---------------------------------------------------------------------------
|
|
50
50
|
|
|
51
|
-
const TERMINAL_STATUSES = new Set(["completed", "failed", "cancelled", "skipped"]);
|
|
51
|
+
const TERMINAL_STATUSES = new Set(["completed", "failed", "cancelled", "skipped", "needs_attention"]);
|
|
52
52
|
|
|
53
53
|
/**
|
|
54
54
|
* Extract the phase label for a task.
|
package/src/runtime/pi-args.ts
CHANGED
|
@@ -99,8 +99,10 @@ export function buildPiWorkerArgs(input: BuildPiWorkerArgsInput): BuildPiWorkerA
|
|
|
99
99
|
}
|
|
100
100
|
|
|
101
101
|
if (input.agent.tools?.length) args.push("--tools", input.agent.tools.join(","));
|
|
102
|
+
// Always add --no-extensions before --extension to prevent user extensions from being auto-loaded.
|
|
103
|
+
// User extensions in ~/.pi/agent/extensions/ may fail due to missing dependencies.
|
|
104
|
+
args.push("--no-extensions");
|
|
102
105
|
if (input.agent.extensions !== undefined) {
|
|
103
|
-
args.push("--no-extensions");
|
|
104
106
|
for (const extension of [PROMPT_RUNTIME_EXTENSION_PATH, ...input.agent.extensions]) args.push("--extension", extension);
|
|
105
107
|
} else {
|
|
106
108
|
args.push("--extension", PROMPT_RUNTIME_EXTENSION_PATH);
|
package/src/runtime/pi-spawn.ts
CHANGED
|
@@ -160,8 +160,14 @@ export function getPiSpawnCommand(args: string[]): PiSpawnCommand {
|
|
|
160
160
|
}
|
|
161
161
|
}
|
|
162
162
|
if (process.platform === "win32") {
|
|
163
|
+
// Windows: resolve via resolvePiCliScript to find the bundled .js entry point
|
|
163
164
|
const script = resolvePiCliScript();
|
|
164
165
|
if (script) return { command: process.execPath, args: [script, ...args] };
|
|
165
166
|
}
|
|
167
|
+
// Linux/macOS: also resolve the full path so child processes can find 'pi' even if
|
|
168
|
+
// PATH is minimal (e.g. in detached background-runner processes). Fall back to "pi"
|
|
169
|
+
// only if resolution fails.
|
|
170
|
+
const script = resolvePiCliScript();
|
|
171
|
+
if (script) return { command: process.execPath, args: [script, ...args] };
|
|
166
172
|
return { command: "pi", args };
|
|
167
173
|
}
|
|
@@ -17,7 +17,7 @@ const PROTECTED_PATTERNS: readonly RegExp[] = [
|
|
|
17
17
|
/```[\s\S]*?```/g, // fenced code blocks
|
|
18
18
|
/`[^`\n]+`/g, // inline code
|
|
19
19
|
/\bhttps?:\/\/\S+/gi, // URLs
|
|
20
|
-
/\b[\w.-]*[
|
|
20
|
+
/\b[\w.-]*[/\\][\w./\\-]+/g, // paths with / or \
|
|
21
21
|
/\b[A-Z][A-Z0-9]*(?:_[A-Z][A-Z0-9]*)+\b/g, // CONSTANT_CASE
|
|
22
22
|
/\b\w+(?:\.\w+)+\(\)/g, // dotted.method() calls
|
|
23
23
|
/[A-Za-z_][A-Za-z0-9_]*\s*\([^)]*\)/g, // function calls: name(args)
|
|
@@ -39,7 +39,7 @@ function isRetryable(error: Error, policy: RetryPolicy): boolean {
|
|
|
39
39
|
}
|
|
40
40
|
|
|
41
41
|
export function calculateRetryDelay(attempt: number, policy: RetryPolicy = DEFAULT_RETRY_POLICY, random = Math.random): number {
|
|
42
|
-
const base = policy.backoffMs *
|
|
42
|
+
const base = policy.backoffMs * policy.exponentialFactor ** Math.max(0, attempt - 1);
|
|
43
43
|
const jitter = (random() * 2 - 1) * policy.jitterRatio * base;
|
|
44
44
|
return Math.max(0, base + jitter);
|
|
45
45
|
}
|
|
@@ -86,7 +86,7 @@ function scaffoldCaps(requestedMode: CrewRuntimeMode, reason?: string, safety: C
|
|
|
86
86
|
}
|
|
87
87
|
|
|
88
88
|
function childCaps(requestedMode: CrewRuntimeMode, reason?: string): CrewRuntimeCapabilities {
|
|
89
|
-
return { kind: "child-process", requestedMode, available: true, steer:
|
|
89
|
+
return { kind: "child-process", requestedMode, available: true, steer: true, resume: false, liveToolActivity: false, transcript: true, safety: "trusted", ...(reason ? { reason } : {}) };
|
|
90
90
|
}
|
|
91
91
|
|
|
92
92
|
function liveCaps(requestedMode: CrewRuntimeMode): CrewRuntimeCapabilities {
|
|
@@ -142,7 +142,6 @@ function readSkillMarkdown(cwd: string, name: string): { path: string; source: "
|
|
|
142
142
|
const stat = fs.statSync(filePath);
|
|
143
143
|
return rememberSkill(cacheKey, { path: filePath, source: entry.source, content: fs.readFileSync(filePath, "utf-8"), mtimeMs: stat.mtimeMs, size: stat.size });
|
|
144
144
|
} catch {
|
|
145
|
-
continue;
|
|
146
145
|
}
|
|
147
146
|
}
|
|
148
147
|
return undefined;
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
1
3
|
import type { TeamRunManifest, TeamTaskState } from "../state/types.ts";
|
|
2
4
|
import { checkProcessLiveness } from "./process-status.ts";
|
|
3
5
|
import { recordFromTask, upsertCrewAgent } from "./crew-agent-records.ts";
|
|
@@ -30,7 +32,7 @@ function checkResultFile(
|
|
|
30
32
|
): { found: boolean; repaired: boolean } {
|
|
31
33
|
// Check if all tasks already have terminal status (result was written but manifest wasn't updated)
|
|
32
34
|
const allTerminal = tasks.length > 0 && tasks.every(
|
|
33
|
-
(t) => t.status === "completed" || t.status === "failed" || t.status === "cancelled" || t.status === "skipped",
|
|
35
|
+
(t) => t.status === "completed" || t.status === "failed" || t.status === "cancelled" || t.status === "skipped" || t.status === "needs_attention",
|
|
34
36
|
);
|
|
35
37
|
if (allTerminal) {
|
|
36
38
|
// Sync agent records even when tasks are already terminal
|
|
@@ -45,8 +47,12 @@ function checkResultFile(
|
|
|
45
47
|
|
|
46
48
|
/**
|
|
47
49
|
* Phase 2: Check PID liveness.
|
|
50
|
+
* Uses process.kill(pid, 0) for the authoritative check, but also checks
|
|
51
|
+
* the heartbeat file as corroborating evidence. If a heartbeat was recently
|
|
52
|
+
* written, treat the PID as alive even if process.kill returns false
|
|
53
|
+
* (handles SIGKILL race where PID hasn't been recycled yet).
|
|
48
54
|
*/
|
|
49
|
-
function checkPidLiveness(pid: number | undefined): {
|
|
55
|
+
function checkPidLiveness(pid: number | undefined, stateRoot?: string): {
|
|
50
56
|
alive: boolean;
|
|
51
57
|
detail: string;
|
|
52
58
|
} {
|
|
@@ -54,6 +60,27 @@ function checkPidLiveness(pid: number | undefined): {
|
|
|
54
60
|
return { alive: false, detail: "no pid recorded" };
|
|
55
61
|
}
|
|
56
62
|
const liveness = checkProcessLiveness(pid);
|
|
63
|
+
// If process is alive per kill(0), we're done.
|
|
64
|
+
if (liveness.alive) return { alive: true, detail: liveness.detail };
|
|
65
|
+
// Process is dead per kill(0). Check heartbeat as corroborating evidence.
|
|
66
|
+
if (stateRoot) {
|
|
67
|
+
const heartbeatPath = path.join(stateRoot, "heartbeat.json");
|
|
68
|
+
try {
|
|
69
|
+
if (fs.existsSync(heartbeatPath)) {
|
|
70
|
+
const hb = JSON.parse(fs.readFileSync(heartbeatPath, "utf-8")) as { pid?: number; at?: number };
|
|
71
|
+
if (hb?.pid === pid && hb?.at) {
|
|
72
|
+
const ageMs = Date.now() - hb.at;
|
|
73
|
+
// Heartbeat written < 5 min ago → process was alive recently.
|
|
74
|
+
// Don't repair yet; let the next reconciliation cycle catch it.
|
|
75
|
+
if (ageMs < 5 * 60_000) {
|
|
76
|
+
return { alive: true, detail: `process dead but heartbeat ${Math.round(ageMs / 1000)}s old` };
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
} catch {
|
|
81
|
+
/* ignore — best-effort */
|
|
82
|
+
}
|
|
83
|
+
}
|
|
57
84
|
return { alive: liveness.alive, detail: liveness.detail };
|
|
58
85
|
}
|
|
59
86
|
|
|
@@ -143,7 +170,7 @@ export function reconcileStaleRun(
|
|
|
143
170
|
|
|
144
171
|
// Phase 2: Check PID liveness
|
|
145
172
|
const pid = manifest.async?.pid;
|
|
146
|
-
const pidStatus = checkPidLiveness(pid);
|
|
173
|
+
const pidStatus = checkPidLiveness(pid, manifest.stateRoot);
|
|
147
174
|
|
|
148
175
|
if (pidStatus.detail === "no pid recorded") {
|
|
149
176
|
// No async PID may be a foreground/live run. Preserve it if task heartbeat
|
|
@@ -45,6 +45,8 @@ export interface SubagentRecord {
|
|
|
45
45
|
turnCount?: number;
|
|
46
46
|
terminated?: boolean;
|
|
47
47
|
durationMs?: number;
|
|
48
|
+
/** Lifetime token usage accumulated via message_end events. Survives compaction. */
|
|
49
|
+
lifetimeUsage?: { input: number; output: number; cacheWrite: number };
|
|
48
50
|
}
|
|
49
51
|
|
|
50
52
|
type SpawnRunner = (options: SubagentSpawnOptions, signal?: AbortSignal) => Promise<PiTeamsToolResult>;
|
|
@@ -31,7 +31,7 @@ export function waitingReason(task: TeamTaskState, tasks: TeamTaskState[]): stri
|
|
|
31
31
|
export function formatTaskGraphLines(tasks: TeamTaskState[]): string[] {
|
|
32
32
|
if (tasks.length === 0) return ["- (none)"];
|
|
33
33
|
return tasks.map((task) => {
|
|
34
|
-
const icon = task.status === "completed" ? "✓" : task.status === "running" ? "⠋" : task.status === "failed" ? "✗" : task.status === "cancelled" || task.status === "skipped" ? "■" : "◦";
|
|
34
|
+
const icon = task.status === "completed" ? "✓" : task.status === "running" ? "⠋" : task.status === "failed" ? "✗" : task.status === "cancelled" || task.status === "skipped" ? "■" : task.status === "needs_attention" ? "⚠" : "◦";
|
|
35
35
|
const wait = waitingReason(task, tasks);
|
|
36
36
|
return `- ${icon} ${task.id} [${task.status}] ${task.role}->${task.agent}${wait && wait !== "ready" ? ` (${wait})` : ""}`;
|
|
37
37
|
});
|
|
@@ -43,7 +43,7 @@ function withQueue(task: TeamTaskState, index: TaskGraphIndex): TeamTaskState {
|
|
|
43
43
|
if (task.status === "running") {
|
|
44
44
|
return { ...task, graph: task.graph ? { ...task.graph, queue: "running" } : task.graph };
|
|
45
45
|
}
|
|
46
|
-
if (task.status === "completed" || task.status === "skipped") {
|
|
46
|
+
if (task.status === "completed" || task.status === "skipped" || task.status === "needs_attention") {
|
|
47
47
|
return { ...task, graph: task.graph ? { ...task.graph, queue: "done" } : task.graph };
|
|
48
48
|
}
|
|
49
49
|
return { ...task, graph: task.graph ? { ...task.graph, queue: "blocked" } : task.graph };
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Read the tail of a file, capped at maxBytes.
|
|
5
|
+
* If the file exceeds maxBytes, reads only the last maxBytes and snaps
|
|
6
|
+
* to the nearest newline boundary to avoid partial JSONL lines.
|
|
7
|
+
*/
|
|
8
|
+
export function tailReadWithLineSnap(
|
|
9
|
+
filePath: string,
|
|
10
|
+
maxBytes: number,
|
|
11
|
+
fallbackContent: string,
|
|
12
|
+
): string {
|
|
13
|
+
if (!fs.existsSync(filePath)) return fallbackContent;
|
|
14
|
+
const stat = fs.statSync(filePath);
|
|
15
|
+
if (stat.size <= maxBytes) return fs.readFileSync(filePath, "utf-8");
|
|
16
|
+
const fd = fs.openSync(filePath, "r");
|
|
17
|
+
try {
|
|
18
|
+
const buf = Buffer.alloc(maxBytes);
|
|
19
|
+
const bytesRead = fs.readSync(fd, buf, 0, maxBytes, stat.size - maxBytes);
|
|
20
|
+
const raw = buf.slice(0, bytesRead).toString("utf-8");
|
|
21
|
+
const firstNewline = raw.indexOf("\n");
|
|
22
|
+
return firstNewline >= 0 ? raw.slice(firstNewline + 1) : raw;
|
|
23
|
+
} finally {
|
|
24
|
+
fs.closeSync(fd);
|
|
25
|
+
}
|
|
26
|
+
}
|