pi-crew 0.2.20 → 0.2.22
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 +4 -2
- 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 +8 -3
- 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/live-executor.ts +15 -0
- 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
package/docs/usage.md
CHANGED
|
@@ -48,6 +48,20 @@ Supported fields:
|
|
|
48
48
|
"showModel": true,
|
|
49
49
|
"showTokens": true,
|
|
50
50
|
"showTools": true
|
|
51
|
+
},
|
|
52
|
+
"limits": {
|
|
53
|
+
"maxConcurrentWorkers": 4
|
|
54
|
+
},
|
|
55
|
+
"reliability": {
|
|
56
|
+
"autoRetry": false,
|
|
57
|
+
"autoRecover": false,
|
|
58
|
+
"deadletterThreshold": 3,
|
|
59
|
+
"retryPolicy": {
|
|
60
|
+
"maxAttempts": 3,
|
|
61
|
+
"initialDelayMs": 1000,
|
|
62
|
+
"backoffMultiplier": 2,
|
|
63
|
+
"maxDelayMs": 30000
|
|
64
|
+
}
|
|
51
65
|
}
|
|
52
66
|
}
|
|
53
67
|
```
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-crew",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.22",
|
|
4
4
|
"description": "Pi extension for coordinated AI teams, workflows, worktrees, and async task orchestration",
|
|
5
5
|
"author": "baphuongna",
|
|
6
6
|
"license": "MIT",
|
|
@@ -80,9 +80,11 @@
|
|
|
80
80
|
"@sinclair/typebox": "^0.34.49",
|
|
81
81
|
"cli-highlight": "^2.1.11",
|
|
82
82
|
"diff": "^5.2.0",
|
|
83
|
-
"jiti": "^2.6.1"
|
|
83
|
+
"jiti": "^2.6.1",
|
|
84
|
+
"typebox": "^1.1.38"
|
|
84
85
|
},
|
|
85
86
|
"devDependencies": {
|
|
87
|
+
"@biomejs/biome": "^2.4.15",
|
|
86
88
|
"@mariozechner/pi-agent-core": "^0.65.0",
|
|
87
89
|
"@mariozechner/pi-ai": "^0.65.0",
|
|
88
90
|
"@mariozechner/pi-coding-agent": "^0.65.0",
|
|
@@ -80,18 +80,24 @@ export type ExportableResource =
|
|
|
80
80
|
| { kind: "skill"; config: SkillDescriptor };
|
|
81
81
|
|
|
82
82
|
export function resourcesToExportContent(resources: ExportableResource[]): ExportContent[] {
|
|
83
|
-
|
|
83
|
+
const results: ExportContent[] = [];
|
|
84
|
+
for (const resource of resources) {
|
|
84
85
|
switch (resource.kind) {
|
|
85
86
|
case "agent":
|
|
86
|
-
|
|
87
|
+
results.push(agentToExportContent(resource.config));
|
|
88
|
+
break;
|
|
87
89
|
case "team":
|
|
88
|
-
|
|
90
|
+
results.push(teamToExportContent(resource.config));
|
|
91
|
+
break;
|
|
89
92
|
case "workflow":
|
|
90
|
-
|
|
93
|
+
results.push(workflowToExportContent(resource.config));
|
|
94
|
+
break;
|
|
91
95
|
case "skill":
|
|
92
|
-
|
|
96
|
+
results.push(skillToExportContent(resource.config));
|
|
97
|
+
break;
|
|
93
98
|
}
|
|
94
|
-
}
|
|
99
|
+
}
|
|
100
|
+
return results;
|
|
95
101
|
}
|
|
96
102
|
|
|
97
103
|
export interface ToolExportResult {
|
|
@@ -33,6 +33,8 @@ export interface AgentConfig {
|
|
|
33
33
|
contextMode?: "fresh" | "fork";
|
|
34
34
|
/** Maximum turns for this agent. Overrides runtime config if set. */
|
|
35
35
|
maxTurns?: number;
|
|
36
|
+
/** Tools to explicitly forbid for this agent. Takes precedence over allowedTools. */
|
|
37
|
+
disallowedTools?: string[];
|
|
36
38
|
disabled?: boolean;
|
|
37
39
|
override?: { source: "config"; path: string };
|
|
38
40
|
}
|
package/src/config/defaults.ts
CHANGED
|
@@ -40,7 +40,7 @@ export const DEFAULT_CONCURRENCY = {
|
|
|
40
40
|
};
|
|
41
41
|
|
|
42
42
|
export const DEFAULT_EVENT_LOG = {
|
|
43
|
-
terminalEventTypes: ["run.blocked", "run.completed", "run.failed", "run.cancelled", "task.completed", "task.failed", "task.skipped", "task.cancelled"],
|
|
43
|
+
terminalEventTypes: ["run.blocked", "run.completed", "run.failed", "run.cancelled", "task.completed", "task.failed", "task.skipped", "task.cancelled", "task.needs_attention"],
|
|
44
44
|
};
|
|
45
45
|
|
|
46
46
|
export const DEFAULT_ARTIFACT_CLEANUP = {
|
package/src/config/markers.ts
CHANGED
|
@@ -85,29 +85,34 @@ function renderBlocks(blocks: GuidanceBlock[]): string {
|
|
|
85
85
|
* Parse the inner marker content and return the set of block IDs present.
|
|
86
86
|
*/
|
|
87
87
|
export function extractGuidanceIds(content: string): string[] {
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
88
|
+
const ids: string[] = [];
|
|
89
|
+
const regex = /<!-- PI-CREW:BLOCK:([^\s>]+) -->/g;
|
|
90
|
+
let match: RegExpExecArray | null = regex.exec(content);
|
|
91
|
+
while (match !== null) {
|
|
92
|
+
const captured = match[1];
|
|
93
|
+
if (captured !== undefined) ids.push(captured);
|
|
94
|
+
match = regex.exec(content);
|
|
95
|
+
}
|
|
96
|
+
return ids;
|
|
95
97
|
}
|
|
96
98
|
|
|
97
99
|
/**
|
|
98
100
|
* Parse existing blocks from marker content into a map of id → GuidanceBlock.
|
|
99
101
|
*/
|
|
100
102
|
function parseExistingBlocks(inner: string): Map<string, GuidanceBlock> {
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
103
|
+
const map = new Map<string, GuidanceBlock>();
|
|
104
|
+
const regex =
|
|
105
|
+
/<!-- PI-CREW:BLOCK:([^\s>]+) -->\n([\s\S]*?)<!-- PI-CREW:\/BLOCK:\1 -->/g;
|
|
106
|
+
let match: RegExpExecArray | null = regex.exec(inner);
|
|
107
|
+
while (match !== null) {
|
|
108
|
+
const id = match[1];
|
|
109
|
+
const blockContent = match[2];
|
|
110
|
+
if (id !== undefined && blockContent !== undefined) {
|
|
111
|
+
map.set(id, { id, content: blockContent.replace(/\n$/, "") });
|
|
112
|
+
}
|
|
113
|
+
match = regex.exec(inner);
|
|
114
|
+
}
|
|
115
|
+
return map;
|
|
111
116
|
}
|
|
112
117
|
|
|
113
118
|
/**
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import { PiTeamsConfigSchema } from "../schema/config-schema.ts";
|
|
6
|
-
import {
|
|
6
|
+
import type { TSchema } from "@sinclair/typebox";
|
|
7
7
|
import { Value } from "@sinclair/typebox/value";
|
|
8
8
|
import { suggestConfigKey } from "./suggestions.ts";
|
|
9
9
|
import {
|
|
@@ -106,8 +106,10 @@ export function startAsyncRunNotifier(ctx: ExtensionContext, state: AsyncNotifie
|
|
|
106
106
|
} catch (error) {
|
|
107
107
|
const message = error instanceof Error ? error.message : String(error);
|
|
108
108
|
if (message.includes("stale") || message.includes("session replacement") || message.includes("old ctx")) {
|
|
109
|
-
|
|
110
|
-
|
|
109
|
+
// Don't stop the interval — session_start will create a new notifier
|
|
110
|
+
// with the refreshed ctx. The isCurrent guard will make this old
|
|
111
|
+
// notifier dormant once sessionGeneration increments.
|
|
112
|
+
// Stopping here creates a race: old notifier dies before new one starts.
|
|
111
113
|
return;
|
|
112
114
|
}
|
|
113
115
|
console.error(`[pi-crew] async notifier error: ${message}`);
|
|
@@ -170,6 +170,24 @@ function findReferences(ctx: ManagementContext, resource: "agent" | "team" | "wo
|
|
|
170
170
|
return refs;
|
|
171
171
|
}
|
|
172
172
|
|
|
173
|
+
function escapeRegex(str: string): string {
|
|
174
|
+
return str.replace(/[.*+?^${}()|[\\]\\]/g, "\\$&");
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function walkTsFiles(dir: string): string[] {
|
|
178
|
+
const results: string[] = [];
|
|
179
|
+
if (!fs.existsSync(dir)) return results;
|
|
180
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
181
|
+
const fullPath = path.join(dir, entry.name);
|
|
182
|
+
if (entry.isDirectory()) {
|
|
183
|
+
results.push(...walkTsFiles(fullPath));
|
|
184
|
+
} else if (entry.name.endsWith(".ts") || entry.name.endsWith(".md")) {
|
|
185
|
+
results.push(fullPath);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
return results;
|
|
189
|
+
}
|
|
190
|
+
|
|
173
191
|
function updateReferencesForRename(ctx: ManagementContext, resource: "agent" | "team" | "workflow", oldName: string, newName: string, scope: MutableSource, dryRun: boolean): string[] {
|
|
174
192
|
if (oldName === newName) return [];
|
|
175
193
|
if (resource !== "agent" && resource !== "workflow") return [];
|
|
@@ -193,6 +211,40 @@ function updateReferencesForRename(ctx: ManagementContext, resource: "agent" | "
|
|
|
193
211
|
fs.writeFileSync(team.filePath, serializeTeam(nextTeam), "utf-8");
|
|
194
212
|
}
|
|
195
213
|
}
|
|
214
|
+
// L12 fix: also update workflow step role references when renaming agents.
|
|
215
|
+
// Workflow files use `role:` to reference agent roles, not agent names.
|
|
216
|
+
for (const workflow of allWorkflows(discoverWorkflows(ctx.cwd)).filter((w) => w.source === scope)) {
|
|
217
|
+
let updated = false;
|
|
218
|
+
const newSteps = workflow.steps.map((step) => {
|
|
219
|
+
if (step.role === oldName) {
|
|
220
|
+
updated = true;
|
|
221
|
+
return { ...step, role: newName };
|
|
222
|
+
}
|
|
223
|
+
return step;
|
|
224
|
+
});
|
|
225
|
+
if (!updated) continue;
|
|
226
|
+
changed.push(workflow.filePath);
|
|
227
|
+
if (!dryRun) {
|
|
228
|
+
backupFile(workflow.filePath);
|
|
229
|
+
fs.writeFileSync(workflow.filePath, serializeWorkflow({ ...workflow, steps: newSteps }), "utf-8");
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
// L12 fix: update agent references in test fixtures.
|
|
233
|
+
const testDir = scope === "user" ? path.join(ctx.cwd, ".crew", "test") : path.join(ctx.cwd, "test", "fixtures");
|
|
234
|
+
if (fs.existsSync(testDir)) {
|
|
235
|
+
for (const fixture of walkTsFiles(testDir)) {
|
|
236
|
+
const content = fs.readFileSync(fixture, "utf-8");
|
|
237
|
+
if (!content.includes(oldName)) continue;
|
|
238
|
+
const agentPattern = new RegExp('(["\'\\`]agent[="\':\\s]*)' + escapeRegex(oldName) + '(["\'\\`]|\\s)', 'g');
|
|
239
|
+
const newContent = content.replace(agentPattern, `$1${newName}$2`);
|
|
240
|
+
if (newContent !== content) {
|
|
241
|
+
changed.push(fixture);
|
|
242
|
+
if (!dryRun) {
|
|
243
|
+
fs.writeFileSync(fixture, newContent, "utf-8");
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
196
248
|
return changed;
|
|
197
249
|
}
|
|
198
250
|
|
|
@@ -441,6 +441,39 @@ export function registerPiTeams(pi: ExtensionAPI): void {
|
|
|
441
441
|
return undefined;
|
|
442
442
|
}
|
|
443
443
|
rpcHandle = registerPiCrewRpc(getPiEvents(), () => currentCtx);
|
|
444
|
+
time("register.globalRegistry");
|
|
445
|
+
// Register global RPC registry for cross-extension access (mirrors pi-subagents3's Symbol.for pattern)
|
|
446
|
+
// Uses lazy import to avoid pulling team-tool.ts into module load.
|
|
447
|
+
// Other extensions access via: const reg = globalThis[Symbol.for("pi-crew:registry")];
|
|
448
|
+
void import("./team-tool.ts").then(({ registerCrewGlobalRegistry }) => {
|
|
449
|
+
const manifestCacheForRegistry = getManifestCache(currentCtx?.cwd ?? process.cwd());
|
|
450
|
+
registerCrewGlobalRegistry({
|
|
451
|
+
version: 1,
|
|
452
|
+
getRecord: (runId) => manifestCacheForRegistry.get(runId),
|
|
453
|
+
listRuns: () => manifestCacheForRegistry.list(100).map((m) => ({ runId: m.runId, status: m.status, goal: m.goal })),
|
|
454
|
+
appendEvent: (runId, event) => {
|
|
455
|
+
const manifest = manifestCacheForRegistry.get(runId);
|
|
456
|
+
if (manifest) void import("../state/event-log.ts").then(({ appendEventFireAndForget }) => appendEventFireAndForget(manifest.eventsPath, event as Parameters<typeof appendEventFireAndForget>[1]));
|
|
457
|
+
},
|
|
458
|
+
waitForAll: async (runId) => {
|
|
459
|
+
const { loadRunManifestById } = await import("../state/state-store.ts");
|
|
460
|
+
const check = (): boolean => {
|
|
461
|
+
const loaded = loadRunManifestById(currentCtx?.cwd ?? process.cwd(), runId);
|
|
462
|
+
if (!loaded) return true;
|
|
463
|
+
return !loaded.tasks.some((t: { status: string }) => t.status === "running" || t.status === "queued");
|
|
464
|
+
};
|
|
465
|
+
while (!check()) await new Promise((resolve) => setTimeout(resolve, 500));
|
|
466
|
+
},
|
|
467
|
+
hasRunning: (runId) => {
|
|
468
|
+
const manifest = manifestCacheForRegistry.get(runId);
|
|
469
|
+
if (!manifest) return false;
|
|
470
|
+
const { loadRunManifestById } = require("../state/state-store.ts");
|
|
471
|
+
const loaded = loadRunManifestById(currentCtx?.cwd ?? process.cwd(), runId);
|
|
472
|
+
if (!loaded) return false;
|
|
473
|
+
return loaded.tasks.some((t: { status: string }) => t.status === "running" || t.status === "queued");
|
|
474
|
+
},
|
|
475
|
+
});
|
|
476
|
+
});
|
|
444
477
|
|
|
445
478
|
const cleanupRuntime = (): void => {
|
|
446
479
|
if (cleanedUp) return;
|
|
@@ -456,16 +489,20 @@ export function registerPiTeams(pi: ExtensionAPI): void {
|
|
|
456
489
|
stopAsyncRunNotifier(notifierState);
|
|
457
490
|
|
|
458
491
|
// Best-effort: kill any async background runners that are still alive.
|
|
459
|
-
//
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
}
|
|
492
|
+
// NOTE: Background runners are designed to outlive the Pi session.
|
|
493
|
+
// Do NOT kill them on session_shutdown — they manage their own lifecycle.
|
|
494
|
+
// Only kill foreground child processes (handled above via abort controllers).
|
|
495
|
+
// See Bug #17: killing async runners on session_shutdown was the root cause
|
|
496
|
+
// of the "background runner dies at ~35s" bug.
|
|
497
|
+
// try {
|
|
498
|
+
// for (const manifest of manifestCache.list(50)) {
|
|
499
|
+
// if (manifest.async?.pid !== undefined && checkProcessLiveness(manifest.async.pid).alive) {
|
|
500
|
+
// killProcessPid(manifest.async.pid);
|
|
501
|
+
// }
|
|
502
|
+
// }
|
|
503
|
+
// } catch (error) {
|
|
504
|
+
// logInternalError("register.cleanupRuntime.killAsync", error);
|
|
505
|
+
// }
|
|
469
506
|
|
|
470
507
|
// P0: Purge all stale active-run-index entries on session cleanup.
|
|
471
508
|
// This handles: normal exit, SIGTERM, Ctrl+C — any case where cleanupRuntime fires.
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import * as fs from "node:fs";
|
|
2
|
+
import * as os from "node:os";
|
|
2
3
|
import * as path from "node:path";
|
|
3
4
|
import type { TeamRunManifest } from "../state/types.ts";
|
|
4
5
|
import { DEFAULT_PATHS } from "../config/defaults.ts";
|
|
@@ -22,11 +23,16 @@ function collectRuns(root: string, maxEntries?: number, signal?: AbortSignal): T
|
|
|
22
23
|
const runsRoot = path.join(root, DEFAULT_PATHS.state.runsSubdir);
|
|
23
24
|
if (!fs.existsSync(runsRoot)) return [];
|
|
24
25
|
if (signal?.aborted) return [];
|
|
26
|
+
// 2.19 — skip temp directories to avoid phantom runs from test/isolated workspaces.
|
|
27
|
+
// Runs in /tmp/ are typically from tests or isolated worktrees and should not
|
|
28
|
+
// appear in production dashboards unless the process is actually alive.
|
|
29
|
+
const tempDirs = [os.tmpdir(), "/var/tmp", "/tmp"];
|
|
30
|
+
const isTempRoot = tempDirs.some((t) => root.startsWith(t + path.sep));
|
|
25
31
|
const token = createCancellationToken({ signal });
|
|
26
32
|
const entries = fs.readdirSync(runsRoot, { withFileTypes: true })
|
|
27
33
|
.filter((entry) => entry.isDirectory() && isSafePathId(entry.name))
|
|
28
34
|
.map((entry) => entry.name)
|
|
29
|
-
.sort((a, b) => b.localeCompare(a));
|
|
35
|
+
.sort((a, b) => (b ?? "").localeCompare(a ?? ""));
|
|
30
36
|
const selected = maxEntries !== undefined ? entries.slice(0, Math.max(0, maxEntries)) : entries;
|
|
31
37
|
const results: TeamRunManifest[] = [];
|
|
32
38
|
for (let i = 0; i < selected.length; i++) {
|
|
@@ -37,6 +43,18 @@ function collectRuns(root: string, maxEntries?: number, signal?: AbortSignal): T
|
|
|
37
43
|
// Filter out ghost runs: active status but CWD no longer exists.
|
|
38
44
|
// These are deadletter/replay/temp runs whose temp dirs were cleaned up.
|
|
39
45
|
if ((manifest.status === "queued" || manifest.status === "running" || manifest.status === "planning") && manifest.cwd && !fs.existsSync(manifest.cwd)) continue;
|
|
46
|
+
// 2.19 — for runs in temp directories, verify the background process is alive.
|
|
47
|
+
// Without this, runs that completed/crashed but left stale manifests will still show.
|
|
48
|
+
if (isTempRoot && (manifest.status === "running" || manifest.status === "queued" || manifest.status === "planning")) {
|
|
49
|
+
const asyncPidPath = path.join(path.dirname(manifest.stateRoot), "async.pid");
|
|
50
|
+
try {
|
|
51
|
+
const pidData = JSON.parse(fs.readFileSync(asyncPidPath, "utf-8"));
|
|
52
|
+
const pid = pidData?.pid;
|
|
53
|
+
if (pid && typeof pid === "number") {
|
|
54
|
+
try { process.kill(pid, 0); } catch { continue; } // PID dead, skip this run
|
|
55
|
+
}
|
|
56
|
+
} catch { /* no async.pid or parse error — keep the run */ }
|
|
57
|
+
}
|
|
40
58
|
results.push(manifest);
|
|
41
59
|
} catch { /* skip unreadable manifests */ }
|
|
42
60
|
}
|
|
@@ -46,7 +64,7 @@ function collectRuns(root: string, maxEntries?: number, signal?: AbortSignal): T
|
|
|
46
64
|
function mergeRuns(runSets: TeamRunManifest[][], max?: number): TeamRunManifest[] {
|
|
47
65
|
const byId = new Map<string, TeamRunManifest>();
|
|
48
66
|
for (const runs of runSets) for (const run of runs) byId.set(run.runId, run);
|
|
49
|
-
const sorted = [...byId.values()].sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
|
67
|
+
const sorted = [...byId.values()].sort((a, b) => (b.createdAt ?? "").localeCompare(a.createdAt ?? ""));
|
|
50
68
|
return max !== undefined ? sorted.slice(0, Math.max(0, max)) : sorted;
|
|
51
69
|
}
|
|
52
70
|
|
|
@@ -50,7 +50,7 @@ function appendPruneAudit(cwd: string, payload: Record<string, unknown>): string
|
|
|
50
50
|
|
|
51
51
|
export function pruneFinishedRuns(cwd: string, keep: number, options: PruneRunsOptions = {}): PruneRunsResult {
|
|
52
52
|
const token = createCancellationToken({ signal: options.signal });
|
|
53
|
-
const finished = listRuns(cwd, options.signal).filter((run) => run.cwd === cwd && isFinished(run)).sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
|
53
|
+
const finished = listRuns(cwd, options.signal).filter((run) => run.cwd === cwd && isFinished(run)).sort((a, b) => (b.updatedAt ?? "").localeCompare(a.updatedAt ?? ""));
|
|
54
54
|
const kept = finished.slice(0, keep).map((run) => run.runId);
|
|
55
55
|
const removed: string[] = [];
|
|
56
56
|
const toRemove = finished.slice(keep);
|
|
@@ -129,7 +129,7 @@ export function pruneUserLevelRuns(keep: number): PruneRunsResult {
|
|
|
129
129
|
}
|
|
130
130
|
|
|
131
131
|
// Sort newest first, keep top N, remove the rest
|
|
132
|
-
finished.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
|
132
|
+
finished.sort((a, b) => (b.updatedAt ?? "").localeCompare(a.updatedAt ?? ""));
|
|
133
133
|
const kept = finished.slice(0, keep).map((r) => r.runId);
|
|
134
134
|
const removed: string[] = [];
|
|
135
135
|
for (const run of finished.slice(keep)) {
|
|
@@ -145,7 +145,7 @@ async function spawnSingleTask(
|
|
|
145
145
|
});
|
|
146
146
|
|
|
147
147
|
if (runtime.available && runtime.kind === "child-process") {
|
|
148
|
-
spawnBackgroundTeamRun(created.manifest);
|
|
148
|
+
await spawnBackgroundTeamRun(created.manifest);
|
|
149
149
|
}
|
|
150
150
|
|
|
151
151
|
return { ok: true, value: { runId: created.manifest.runId, goal, agent: agentName } };
|
|
@@ -132,13 +132,10 @@ export async function handleRun(params: TeamToolParamsValue, ctx: TeamContext):
|
|
|
132
132
|
const executionManifest = { ...updatedManifest, runtimeResolution, runConfig: executedConfig, updatedAt: new Date().toISOString() };
|
|
133
133
|
atomicWriteJson(paths.manifestPath, executionManifest);
|
|
134
134
|
appendEvent(executionManifest.eventsPath, { type: "runtime.resolved", runId: executionManifest.runId, message: `Runtime resolved: ${runtime.kind} safety=${runtime.safety}`, data: { runtimeResolution } });
|
|
135
|
-
const runAsync = params.async ??
|
|
136
|
-
// Background runners are standalone Node processes — live-session (in-process Pi SDK)
|
|
137
|
-
// is only valid when tasks run inside the parent Pi agent session. Override to
|
|
138
|
-
// child-process for async runs so the background runner spawns child Pi workers.
|
|
135
|
+
const runAsync = params.async ?? executedConfig.asyncByDefault ?? false;
|
|
139
136
|
let effectiveRuntime = runtime;
|
|
140
137
|
if (runAsync && runtime.kind === "live-session") {
|
|
141
|
-
effectiveRuntime = { ...runtime, kind: "child-process", steer:
|
|
138
|
+
effectiveRuntime = { ...runtime, kind: "child-process", steer: true, resume: false, liveToolActivity: false, fallback: "child-process", reason: "Background runner cannot use live-session; falling back to child-process." };
|
|
142
139
|
}
|
|
143
140
|
const effectiveRuntimeResolution = effectiveRuntime !== runtime ? runtimeResolutionState(effectiveRuntime) : runtimeResolution;
|
|
144
141
|
const effectiveManifest = effectiveRuntime !== runtime ? { ...executionManifest, runtimeResolution: effectiveRuntimeResolution, updatedAt: new Date().toISOString() } : executionManifest;
|
|
@@ -160,7 +157,7 @@ export async function handleRun(params: TeamToolParamsValue, ctx: TeamContext):
|
|
|
160
157
|
`Env: PI_CREW_EXECUTE_WORKERS=${process.env.PI_CREW_EXECUTE_WORKERS ?? "<unset>"}, PI_TEAMS_EXECUTE_WORKERS=${process.env.PI_TEAMS_EXECUTE_WORKERS ?? "<unset>"}`,
|
|
161
158
|
].join("\n"), { action: "run", status: "error", runId: blocked.runId, artifactsRoot: blocked.artifactsRoot }, true);
|
|
162
159
|
}
|
|
163
|
-
const spawned = spawnBackgroundTeamRun(effectiveManifest);
|
|
160
|
+
const spawned = await spawnBackgroundTeamRun(effectiveManifest);
|
|
164
161
|
const asyncManifest = { ...effectiveManifest, async: { pid: spawned.pid, logPath: spawned.logPath, spawnedAt: new Date().toISOString() } };
|
|
165
162
|
atomicWriteJson(paths.manifestPath, asyncManifest);
|
|
166
163
|
appendEvent(effectiveManifest.eventsPath, { type: "async.spawned", runId: effectiveManifest.runId, data: { pid: spawned.pid, logPath: spawned.logPath } });
|
|
@@ -28,10 +28,11 @@ import type { PiTeamsToolResult } from "./tool-result.ts";
|
|
|
28
28
|
import type { ArtifactDescriptor, TeamRunManifest, TeamTaskState } from "../state/types.ts";
|
|
29
29
|
// Heavy runtime — lazy-loaded to avoid 1.4s import cost at extension registration.
|
|
30
30
|
// executeTeamRun is only called when a team run actually executes.
|
|
31
|
-
import type { executeTeamRun as
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
31
|
+
import type { executeTeamRun as _executeTeamRunFn } from "../runtime/team-runner.ts";
|
|
32
|
+
type ExecuteTeamRunFn = typeof _executeTeamRunFn;
|
|
33
|
+
let _cachedExecuteTeamRun: ExecuteTeamRunFn | undefined = undefined;
|
|
34
|
+
async function executeTeamRun(...args: Parameters<ExecuteTeamRunFn>): Promise<Awaited<ReturnType<ExecuteTeamRunFn>>> {
|
|
35
|
+
if (_cachedExecuteTeamRun === undefined) {
|
|
35
36
|
// LAZY: heavy runtime — defer 1.4s import cost until team run actually executes.
|
|
36
37
|
const mod = await import("../runtime/team-runner.ts");
|
|
37
38
|
_cachedExecuteTeamRun = mod.executeTeamRun;
|
|
@@ -51,10 +52,11 @@ import { autonomousPatchFromConfig, configPatchFromConfig, effectiveRunConfig, f
|
|
|
51
52
|
import { handleApi } from "./team-tool/api.ts";
|
|
52
53
|
// Lazy-loaded: run.ts pulls in spawnBackgroundTeamRun, resolveCrewRuntime, etc.
|
|
53
54
|
// Static import fails silently in some jiti contexts (child-process), leaving handleRun undefined.
|
|
54
|
-
import type { handleRun as
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
55
|
+
import type { handleRun as _handleRunFn } from "./team-tool/run.ts";
|
|
56
|
+
type HandleRunFn = typeof _handleRunFn;
|
|
57
|
+
let _cachedHandleRun: HandleRunFn | undefined = undefined;
|
|
58
|
+
async function handleRun(...args: Parameters<HandleRunFn>): Promise<Awaited<ReturnType<HandleRunFn>>> {
|
|
59
|
+
if (_cachedHandleRun === undefined) {
|
|
58
60
|
// LAZY: run.ts pulls in spawnBackgroundTeamRun + resolveCrewRuntime; also avoids jiti import race in child-process contexts.
|
|
59
61
|
const mod = await import("./team-tool/run.ts");
|
|
60
62
|
_cachedHandleRun = mod.handleRun;
|
|
@@ -156,15 +158,29 @@ function artifactKey(artifact: ArtifactDescriptor): string {
|
|
|
156
158
|
function recoverCheckpointedTasks(manifest: TeamRunManifest, tasks: TeamTaskState[]): { manifest: TeamRunManifest; tasks: TeamTaskState[]; recovered: string[] } {
|
|
157
159
|
const recovered: string[] = [];
|
|
158
160
|
let nextManifest = manifest;
|
|
159
|
-
|
|
161
|
+
const nextTasks = tasks.map((task) => {
|
|
160
162
|
if (task.status !== "running" || !task.checkpoint) return task;
|
|
161
163
|
if (task.checkpoint.phase === "artifact-written" && task.resultArtifact) {
|
|
162
164
|
recovered.push(task.id);
|
|
163
165
|
return { ...task, status: "completed" as const, finishedAt: task.finishedAt ?? task.checkpoint.updatedAt, error: undefined, claim: undefined };
|
|
164
166
|
}
|
|
165
167
|
if (task.checkpoint.phase === "child-stdout-final") {
|
|
166
|
-
|
|
167
|
-
|
|
168
|
+
// transcripts are written with .attempt-${i}.jsonl suffix; find the most recent one
|
|
169
|
+
const transcriptsDir = path.join(manifest.artifactsRoot, "transcripts");
|
|
170
|
+
let transcriptPath: string | undefined;
|
|
171
|
+
if (fs.existsSync(transcriptsDir)) {
|
|
172
|
+
const files = fs.readdirSync(transcriptsDir).filter((f) => f.startsWith(`${task.id}.attempt-`) && f.endsWith(".jsonl"));
|
|
173
|
+
if (files.length > 0) {
|
|
174
|
+
// Sort by attempt index descending to get the most recent
|
|
175
|
+
files.sort((a, b) => {
|
|
176
|
+
const idxA = parseInt(a.match(/\.attempt-(\d+)\./)?.[1] ?? "0");
|
|
177
|
+
const idxB = parseInt(b.match(/\.attempt-(\d+)\./)?.[1] ?? "0");
|
|
178
|
+
return idxB - idxA;
|
|
179
|
+
});
|
|
180
|
+
transcriptPath = path.join(transcriptsDir, files[0]);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
if (!transcriptPath) return task;
|
|
168
184
|
const transcript = fs.readFileSync(transcriptPath, "utf-8");
|
|
169
185
|
const parsed = parsePiJsonOutput(transcript);
|
|
170
186
|
if (!parsed.finalText && !parsed.usage) return task;
|
|
@@ -242,6 +258,22 @@ export async function handleResume(params: TeamToolParamsValue, ctx: TeamContext
|
|
|
242
258
|
});
|
|
243
259
|
}
|
|
244
260
|
|
|
261
|
+
export function handleSteer(params: TeamToolParamsValue, ctx: TeamContext): PiTeamsToolResult {
|
|
262
|
+
const { runId, taskId, message } = params;
|
|
263
|
+
if (!runId || !taskId || !message) {
|
|
264
|
+
return result("steer requires runId, taskId, and message", { action: "steer", status: "error" }, true);
|
|
265
|
+
}
|
|
266
|
+
const loaded = loadRunManifestById(ctx.cwd, runId);
|
|
267
|
+
if (!loaded) return result(`Run '${runId}' not found`, { action: "steer", status: "error" }, true);
|
|
268
|
+
const task = loaded.tasks.find(t => t.id === taskId);
|
|
269
|
+
if (!task) return result(`Task '${taskId}' not found`, { action: "steer", status: "error" }, true);
|
|
270
|
+
if (!task.pendingSteers) task.pendingSteers = [];
|
|
271
|
+
task.pendingSteers.push(message);
|
|
272
|
+
saveRunTasks(loaded.manifest, loaded.tasks);
|
|
273
|
+
appendEvent(loaded.manifest.eventsPath, { type: "task.steer_queued", runId, taskId, data: { message } });
|
|
274
|
+
return result(`Steer queued for task '${taskId}'. It will be delivered when the task's session is ready.`, { action: "steer", status: "ok" });
|
|
275
|
+
}
|
|
276
|
+
|
|
245
277
|
export async function handleTeamTool(params: TeamToolParamsValue, ctx: TeamContext): Promise<PiTeamsToolResult> {
|
|
246
278
|
const action = params.action ?? "list";
|
|
247
279
|
switch (action) {
|
|
@@ -339,6 +371,30 @@ export async function handleTeamTool(params: TeamToolParamsValue, ctx: TeamConte
|
|
|
339
371
|
case "create": return handleCreate(params, ctx);
|
|
340
372
|
case "update": return handleUpdate(params, ctx);
|
|
341
373
|
case "delete": return handleDelete(params, ctx);
|
|
374
|
+
case "steer": return handleSteer(params, ctx);
|
|
342
375
|
default: return result(`Unknown action: ${action}`, { action: "unknown", status: "error" }, true);
|
|
343
376
|
}
|
|
344
377
|
}
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* Global RPC registry for cross-extension access to pi-crew's team orchestrator.
|
|
381
|
+
* Uses Symbol.for() for cross-package singleton pattern (same as OpenTelemetry).
|
|
382
|
+
* Extensions can access via: const reg = globalThis[Symbol.for("pi-crew:registry")];
|
|
383
|
+
*/
|
|
384
|
+
const CREW_REGISTRY_KEY = Symbol.for("pi-crew:registry");
|
|
385
|
+
interface CrewRegistry {
|
|
386
|
+
version: 1;
|
|
387
|
+
getRecord: (runId: string) => TeamRunManifest | undefined;
|
|
388
|
+
listRuns: () => Array<{ runId: string; status: string; goal: string }>;
|
|
389
|
+
appendEvent: (runId: string, event: Record<string, unknown>) => void;
|
|
390
|
+
waitForAll: (runId: string) => Promise<void>;
|
|
391
|
+
hasRunning: (runId: string) => boolean;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
export function registerCrewGlobalRegistry(registry: CrewRegistry): void {
|
|
395
|
+
(globalThis as Record<symbol | string, unknown>)[CREW_REGISTRY_KEY] = registry;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
export function getCrewGlobalRegistry(): CrewRegistry | undefined {
|
|
399
|
+
return (globalThis as Record<symbol | string, unknown>)[CREW_REGISTRY_KEY] as CrewRegistry | undefined;
|
|
400
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
2
|
-
import { MetricRegistry } from "./metric-registry.ts";
|
|
2
|
+
import type { MetricRegistry } from "./metric-registry.ts";
|
|
3
3
|
|
|
4
4
|
function recordValue(value: unknown): Record<string, unknown> {
|
|
5
5
|
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
|
@@ -46,6 +46,7 @@ export function wireEventToMetrics(events: ExtensionAPI["events"] | undefined, r
|
|
|
46
46
|
["crew.run.cancelled", (data) => { const item = recordValue(data); runCount.inc({ status: "cancelled", reason: cancellationReasonLabel(item.reason) }); }],
|
|
47
47
|
["crew.task.completed", (data) => { const item = recordValue(data); taskCount.inc({ status: "completed" }); taskDuration.observe({ role: stringValue(item.role, "unknown") }, numberValue(item.durationMs)); tokenUsage.observe({ role: stringValue(item.role, "unknown") }, numberValue(item.tokens)); }],
|
|
48
48
|
["crew.task.failed", () => taskCount.inc({ status: "failed" })],
|
|
49
|
+
["crew.task.needs_attention", () => taskCount.inc({ status: "needs_attention" })],
|
|
49
50
|
["crew.task.retry_attempt", (data) => { const item = recordValue(data); taskCount.inc({ status: "retry" }); retryAttemptCount.inc({ runId: stringValue(item.runId, "unknown"), taskId: stringValue(item.taskId, "unknown") }); }],
|
|
50
51
|
["crew.task.deadletter", (data) => { const item = recordValue(data); deadletterCount.inc({ reason: stringValue(item.reason, "unknown") }); }],
|
|
51
52
|
["crew.task.overflow", (data) => { const item = recordValue(data); overflowCount.inc({ phase: stringValue(item.phase, "unknown"), previous_phase: stringValue(item.previousPhase, "none") }); }],
|