pi-crew 0.9.26 → 0.9.27
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 +87 -0
- package/NOTICE.md +14 -0
- package/assets/crew-vibes.ttf +0 -0
- package/assets/runner-spritesheet.png +0 -0
- package/dist/build-meta.json +255 -40
- package/dist/index.mjs +1564 -241
- package/dist/index.mjs.map +4 -4
- package/docs/perf/optimization-plan-2026-07-verified.md +396 -0
- package/package.json +9 -2
- package/scripts/bench-check.mjs +96 -0
- package/scripts/bench-cold-start.mjs +216 -0
- package/scripts/build-bundle.mjs +84 -0
- package/scripts/build-crew-vibes-font.py +328 -0
- package/scripts/check-all-skills.ts +294 -0
- package/scripts/check-bundle-staleness.mjs +97 -0
- package/scripts/check-conflict-markers.mjs +91 -0
- package/scripts/check-lazy-imports.mjs +28 -0
- package/scripts/install-crew-vibes-font.mjs +91 -0
- package/scripts/postinstall.mjs +47 -0
- package/scripts/profile-startup.mjs +125 -0
- package/scripts/release-smoke.mjs +74 -0
- package/scripts/run-bench.mjs +47 -0
- package/scripts/run-real-chain.ts +41 -0
- package/scripts/test-issue-29-crash.ts +111 -0
- package/scripts/test-issue-29-e2e.ts +183 -0
- package/scripts/test-issue-29-real-runtime.ts +330 -0
- package/scripts/test-issue-29-real-tasks.ts +387 -0
- package/scripts/test-issue-29-team-tool.ts +105 -0
- package/scripts/test-runner.mjs +74 -0
- package/scripts/verify-flicker-fix.ts +109 -0
- package/scripts/verify-skill.ts +550 -0
- package/scripts/watch-bundle.mjs +259 -0
- package/scripts/watchdog-harness.ts +119 -0
- package/src/agents/discover-agents.ts +6 -1
- package/src/config/defaults.ts +6 -0
- package/src/extension/crew-vibes/cat-frames.ts +18 -0
- package/src/extension/crew-vibes/config.ts +195 -0
- package/src/extension/crew-vibes/figures.ts +94 -0
- package/src/extension/crew-vibes/font-detect.ts +58 -0
- package/src/extension/crew-vibes/index.ts +396 -0
- package/src/extension/crew-vibes/provider-usage.ts +330 -0
- package/src/extension/crew-vibes/render.ts +206 -0
- package/src/extension/crew-vibes/speed.ts +286 -0
- package/src/extension/knowledge-injection.ts +12 -3
- package/src/extension/management.ts +23 -3
- package/src/extension/register.ts +7 -0
- package/src/runtime/child-pi.ts +117 -10
- package/src/runtime/crew-agent-records.ts +10 -3
- package/src/runtime/retry-executor.ts +4 -1
- package/src/runtime/task-runner/state-helpers.ts +9 -30
- package/src/runtime/team-runner.ts +5 -3
- package/src/state/atomic-write.ts +153 -49
- package/src/state/event-log.ts +16 -10
- package/src/state/locks.ts +7 -8
- package/src/state/mailbox.ts +15 -4
- package/src/state/state-store.ts +39 -10
- package/src/teams/discover-teams.ts +56 -1
- package/src/utils/safe-paths.ts +2 -1
- package/src/workflows/discover-workflows.ts +72 -1
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Profile pi-crew register cold load.
|
|
4
|
+
*
|
|
5
|
+
* Usage:
|
|
6
|
+
* node scripts/profile-startup.mjs # default 5 iters
|
|
7
|
+
* node scripts/profile-startup.mjs --iters 10
|
|
8
|
+
*
|
|
9
|
+
* Outputs:
|
|
10
|
+
* .profile/startup-<timestamp>.cpuprofile (open in Chrome DevTools)
|
|
11
|
+
* .profile/summary.json (top frames JSON)
|
|
12
|
+
*
|
|
13
|
+
* Implementation note: spawns a child Node with --cpu-prof so the profile
|
|
14
|
+
* captures only the cold-load path (no measurement noise from this driver).
|
|
15
|
+
*/
|
|
16
|
+
import * as fs from "node:fs";
|
|
17
|
+
import * as path from "node:path";
|
|
18
|
+
import { execFileSync, spawnSync } from "node:child_process";
|
|
19
|
+
import { pathToFileURL } from "node:url";
|
|
20
|
+
|
|
21
|
+
const root = path.resolve(import.meta.dirname, "..");
|
|
22
|
+
const profileDir = path.join(root, ".profile");
|
|
23
|
+
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
24
|
+
|
|
25
|
+
const args = process.argv.slice(2);
|
|
26
|
+
let iters = 5;
|
|
27
|
+
for (let i = 0; i < args.length; i++) {
|
|
28
|
+
if (args[i] === "--iters" && args[i + 1]) {
|
|
29
|
+
iters = Number(args[i + 1]);
|
|
30
|
+
i++;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
fs.mkdirSync(profileDir, { recursive: true });
|
|
35
|
+
|
|
36
|
+
const driverPath = path.join(profileDir, `_driver-${timestamp}.mjs`);
|
|
37
|
+
const registerImportSpec = pathToFileURL(path.join(root, "src/extension/register.ts")).href;
|
|
38
|
+
const driverSrc = `
|
|
39
|
+
import { performance } from "node:perf_hooks";
|
|
40
|
+
const start = performance.now();
|
|
41
|
+
const { registerPiTeams } = await import(${JSON.stringify(registerImportSpec)});
|
|
42
|
+
const importMs = performance.now() - start;
|
|
43
|
+
|
|
44
|
+
function createEvents() {
|
|
45
|
+
const handlers = new Map();
|
|
46
|
+
return {
|
|
47
|
+
on(event, handler) {
|
|
48
|
+
const set = handlers.get(event) ?? new Set();
|
|
49
|
+
set.add(handler);
|
|
50
|
+
handlers.set(event, set);
|
|
51
|
+
return () => set.delete(handler);
|
|
52
|
+
},
|
|
53
|
+
emit(event, payload) {
|
|
54
|
+
for (const h of handlers.get(event) ?? []) h(payload);
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
function createPi(events) {
|
|
59
|
+
return {
|
|
60
|
+
events,
|
|
61
|
+
on(_e, _h) {},
|
|
62
|
+
registerCommand() {},
|
|
63
|
+
registerTool() {},
|
|
64
|
+
appendEntry() {},
|
|
65
|
+
getSessionName() { return undefined; },
|
|
66
|
+
setSessionName() {},
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const samples = [];
|
|
71
|
+
for (let i = 0; i < ${iters}; i++) {
|
|
72
|
+
const t0 = performance.now();
|
|
73
|
+
const events = createEvents();
|
|
74
|
+
const pi = createPi(events);
|
|
75
|
+
registerPiTeams(pi);
|
|
76
|
+
samples.push(performance.now() - t0);
|
|
77
|
+
}
|
|
78
|
+
process.stdout.write(JSON.stringify({ importMs, samples }) + "\\n");
|
|
79
|
+
`;
|
|
80
|
+
fs.writeFileSync(driverPath, driverSrc, "utf-8");
|
|
81
|
+
|
|
82
|
+
const cpuProfilePath = path.join(profileDir, `startup-${timestamp}.cpuprofile`);
|
|
83
|
+
console.log(`[profile] Running ${iters} iterations with --cpu-prof...`);
|
|
84
|
+
|
|
85
|
+
const result = spawnSync(process.execPath, [
|
|
86
|
+
"--cpu-prof",
|
|
87
|
+
`--cpu-prof-dir=${profileDir}`,
|
|
88
|
+
`--cpu-prof-name=startup-${timestamp}.cpuprofile`,
|
|
89
|
+
"--experimental-strip-types",
|
|
90
|
+
"--no-warnings",
|
|
91
|
+
driverPath,
|
|
92
|
+
], { encoding: "utf-8", cwd: root });
|
|
93
|
+
|
|
94
|
+
fs.unlinkSync(driverPath);
|
|
95
|
+
|
|
96
|
+
if (result.status !== 0) {
|
|
97
|
+
console.error("[profile] driver failed:", result.stderr || result.stdout);
|
|
98
|
+
process.exit(result.status ?? 1);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
let parsed;
|
|
102
|
+
try {
|
|
103
|
+
parsed = JSON.parse(result.stdout.trim().split("\n").pop());
|
|
104
|
+
} catch (error) {
|
|
105
|
+
console.error("[profile] could not parse driver output:", result.stdout);
|
|
106
|
+
process.exit(2);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const samples = parsed.samples;
|
|
110
|
+
samples.sort((a, b) => a - b);
|
|
111
|
+
const p = (q) => samples[Math.min(samples.length - 1, Math.floor((samples.length - 1) * q))];
|
|
112
|
+
const summary = {
|
|
113
|
+
timestamp,
|
|
114
|
+
iters,
|
|
115
|
+
importMs: round(parsed.importMs),
|
|
116
|
+
registerMs: { min: round(samples[0]), p50: round(p(0.5)), p95: round(p(0.95)), p99: round(p(0.99)), max: round(samples[samples.length - 1]) },
|
|
117
|
+
cpuProfile: path.relative(root, cpuProfilePath).replace(/\\/g, "/"),
|
|
118
|
+
};
|
|
119
|
+
fs.writeFileSync(path.join(profileDir, "summary.json"), JSON.stringify(summary, null, 2) + "\n", "utf-8");
|
|
120
|
+
|
|
121
|
+
console.log("[profile] Summary:", JSON.stringify(summary, null, 2));
|
|
122
|
+
console.log("[profile] CPU profile:", cpuProfilePath);
|
|
123
|
+
console.log("[profile] Open in Chrome DevTools → Performance → Load profile.");
|
|
124
|
+
|
|
125
|
+
function round(n) { return Math.round(n * 100) / 100; }
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Release smoke test — verifies packed tarball loads correctly in a temp project.
|
|
4
|
+
* Run: node scripts/release-smoke.mjs
|
|
5
|
+
*/
|
|
6
|
+
import * as fs from "node:fs";
|
|
7
|
+
import * as os from "node:os";
|
|
8
|
+
import * as path from "node:path";
|
|
9
|
+
import { execSync } from "node:child_process";
|
|
10
|
+
|
|
11
|
+
const root = path.resolve(import.meta.dirname, "..");
|
|
12
|
+
|
|
13
|
+
function log(msg) {
|
|
14
|
+
console.log(`[release-smoke] ${msg}`);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function run(cmd, cwd) {
|
|
18
|
+
log(` $ ${cmd}`);
|
|
19
|
+
execSync(cmd, { cwd, stdio: "pipe", timeout: 60_000 });
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
try {
|
|
23
|
+
// 1. Read version from package.json
|
|
24
|
+
const rootPkg = JSON.parse(fs.readFileSync(path.join(root, "package.json"), "utf-8"));
|
|
25
|
+
const version = rootPkg.version;
|
|
26
|
+
log(`Package version: ${version}`);
|
|
27
|
+
|
|
28
|
+
// 2. Pack tarball
|
|
29
|
+
log("Packing tarball...");
|
|
30
|
+
execSync("npm pack", { cwd: root, stdio: "pipe", timeout: 60_000 });
|
|
31
|
+
const tarballName = `pi-crew-${version}.tgz`;
|
|
32
|
+
const tarballPath = path.join(root, tarballName);
|
|
33
|
+
if (!fs.existsSync(tarballPath)) throw new Error(`Tarball not found: ${tarballPath}`);
|
|
34
|
+
log(` Tarball: ${tarballName}`);
|
|
35
|
+
|
|
36
|
+
// 3. Create temp project
|
|
37
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-crew-smoke-"));
|
|
38
|
+
log(`Temp project: ${tmpDir}`);
|
|
39
|
+
try {
|
|
40
|
+
run("npm init -y", tmpDir);
|
|
41
|
+
|
|
42
|
+
// 4. Install packed tarball
|
|
43
|
+
run(`npm install ${tarballPath}`, tmpDir);
|
|
44
|
+
|
|
45
|
+
// 5. Verify extension loads
|
|
46
|
+
const pkgPath = path.join(tmpDir, "node_modules", "pi-crew", "package.json");
|
|
47
|
+
if (!fs.existsSync(pkgPath)) throw new Error("pi-crew package not found in node_modules");
|
|
48
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
|
|
49
|
+
log(` Installed version: ${pkg.version}`);
|
|
50
|
+
|
|
51
|
+
// 6. Verify key entrypoints exist
|
|
52
|
+
const srcRegister = path.join(tmpDir, "node_modules", "pi-crew", "src", "extension", "register.ts");
|
|
53
|
+
if (fs.existsSync(srcRegister)) {
|
|
54
|
+
log(` Extension register entrypoint found: ${srcRegister}`);
|
|
55
|
+
} else {
|
|
56
|
+
throw new Error("Could not find extension register entrypoint");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// 7. Verify version consistency
|
|
60
|
+
if (pkg.version !== version) {
|
|
61
|
+
throw new Error(`Version mismatch: root=${version} installed=${pkg.version}`);
|
|
62
|
+
}
|
|
63
|
+
log(` Version consistency: OK ${pkg.version}`);
|
|
64
|
+
|
|
65
|
+
log("Release smoke test PASSED!");
|
|
66
|
+
} finally {
|
|
67
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
68
|
+
// Clean up tarball
|
|
69
|
+
try { fs.unlinkSync(tarballPath); } catch {}
|
|
70
|
+
}
|
|
71
|
+
} catch (error) {
|
|
72
|
+
console.error("Release smoke test FAILED:", error instanceof Error ? error.message : String(error));
|
|
73
|
+
process.exit(1);
|
|
74
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Run all benches, collect JSON output, write to test/bench/results.json.
|
|
4
|
+
*
|
|
5
|
+
* Each bench prints a single JSON line on stdout (NDJSON). Earlier lines may
|
|
6
|
+
* be ignored. Failures abort the run.
|
|
7
|
+
*/
|
|
8
|
+
import * as fs from "node:fs";
|
|
9
|
+
import * as path from "node:path";
|
|
10
|
+
import { spawnSync } from "node:child_process";
|
|
11
|
+
|
|
12
|
+
const root = path.resolve(import.meta.dirname, "..");
|
|
13
|
+
const benchDir = path.join(root, "test", "bench");
|
|
14
|
+
const benches = fs.readdirSync(benchDir).filter((f) => f.endsWith(".bench.ts"));
|
|
15
|
+
|
|
16
|
+
const results = {};
|
|
17
|
+
for (const bench of benches) {
|
|
18
|
+
const benchPath = path.join(benchDir, bench);
|
|
19
|
+
console.log(`[bench] running ${bench}...`);
|
|
20
|
+
const t0 = Date.now();
|
|
21
|
+
const result = spawnSync(process.execPath, [
|
|
22
|
+
"--experimental-strip-types",
|
|
23
|
+
"--no-warnings",
|
|
24
|
+
benchPath,
|
|
25
|
+
], { encoding: "utf-8", cwd: root, timeout: 600_000 });
|
|
26
|
+
if (result.status !== 0) {
|
|
27
|
+
console.error(result.stderr || result.stdout);
|
|
28
|
+
process.exit(result.status ?? 1);
|
|
29
|
+
}
|
|
30
|
+
const elapsed = ((Date.now() - t0) / 1000).toFixed(1);
|
|
31
|
+
const lines = result.stdout.trim().split("\n").filter(Boolean);
|
|
32
|
+
let parsed;
|
|
33
|
+
for (const line of lines.reverse()) {
|
|
34
|
+
try { parsed = JSON.parse(line); break; } catch { /* skip non-JSON */ }
|
|
35
|
+
}
|
|
36
|
+
if (!parsed?.name) {
|
|
37
|
+
console.error(`[bench] could not parse JSON output from ${bench}\n${result.stdout}`);
|
|
38
|
+
process.exit(2);
|
|
39
|
+
}
|
|
40
|
+
results[parsed.name] = parsed;
|
|
41
|
+
console.log(`[bench] ${parsed.name} done in ${elapsed}s`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const outPath = path.join(benchDir, "results.json");
|
|
45
|
+
const payload = { capturedAt: new Date().toISOString(), node: process.version, platform: process.platform, results };
|
|
46
|
+
fs.writeFileSync(outPath, JSON.stringify(payload, null, 2) + "\n", "utf-8");
|
|
47
|
+
console.log(`[bench] wrote ${outPath}`);
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Real end-to-end chain execution test.
|
|
3
|
+
*
|
|
4
|
+
* Runs two ACTUAL team runs sequentially via the chain feature, with step 2's
|
|
5
|
+
* worker receiving step 1's handoff context (including output text). Prints
|
|
6
|
+
* both step runIds for inspection.
|
|
7
|
+
*
|
|
8
|
+
* Usage: npx tsx scripts/run-real-chain.ts
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { TeamContext } from "../src/extension/team-tool/context.ts";
|
|
12
|
+
import { handleRun } from "../src/extension/team-tool/run.ts";
|
|
13
|
+
|
|
14
|
+
const chain = '"Say the numbers 1, 2, 3" -> "What was the last number in the previous step? Add 2 to it."';
|
|
15
|
+
const ctx: TeamContext = { cwd: process.cwd() };
|
|
16
|
+
|
|
17
|
+
console.log("[run-real-chain] Executing chain:", chain);
|
|
18
|
+
console.log("[run-real-chain] Working directory:", process.cwd());
|
|
19
|
+
console.log("[run-real-chain] This will spawn REAL team runs. Please wait...\n");
|
|
20
|
+
|
|
21
|
+
const result = await handleRun({ action: "run", chain }, ctx);
|
|
22
|
+
|
|
23
|
+
console.log("\n=== CHAIN RESULT (details) ===");
|
|
24
|
+
console.log(JSON.stringify(result.details, null, 2));
|
|
25
|
+
|
|
26
|
+
const text = result.content?.[0];
|
|
27
|
+
if (text && "text" in text) {
|
|
28
|
+
console.log("\n=== SUMMARY ===");
|
|
29
|
+
console.log(text.text);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Extract runIds for easy inspection.
|
|
33
|
+
const runIds = (result.details as { data?: { runIds?: string[] } }).data?.runIds;
|
|
34
|
+
if (runIds && runIds.length > 0) {
|
|
35
|
+
console.log("\n=== STEP RUN IDS ===");
|
|
36
|
+
for (let i = 0; i < runIds.length; i++) {
|
|
37
|
+
console.log(`Step ${i + 1}: ${runIds[i]}`);
|
|
38
|
+
}
|
|
39
|
+
} else {
|
|
40
|
+
console.log("\n[run-real-chain] WARNING: no runIds found in result.");
|
|
41
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
#!/usr/bin/env -S npx tsx
|
|
2
|
+
/**
|
|
3
|
+
* Crash reproduction test for issue #29: verifies the defense-in-depth fix
|
|
4
|
+
* in subagent-manager.ts:start() prevents unhandled rejections from
|
|
5
|
+
* crashing the host process.
|
|
6
|
+
*
|
|
7
|
+
* Strategy: spawn a subagent whose runner throws, then do NOT await
|
|
8
|
+
* record.promise. Without the fix, this triggers unhandledRejection which
|
|
9
|
+
* Node.js converts to uncaughtException. With the fix, the .catch inside
|
|
10
|
+
* start() absorbs the rejection.
|
|
11
|
+
*
|
|
12
|
+
* Run with: npx tsx scripts/test-issue-29-crash.ts
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import * as fs from "node:fs";
|
|
16
|
+
import * as os from "node:os";
|
|
17
|
+
import * as path from "node:path";
|
|
18
|
+
import { fileURLToPath } from "node:url";
|
|
19
|
+
|
|
20
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-crew-issue-29-crash-"));
|
|
21
|
+
fs.mkdirSync(path.join(tmpDir, ".pi"), { recursive: true });
|
|
22
|
+
|
|
23
|
+
console.log("Issue #29 CRASH reproduction test");
|
|
24
|
+
console.log(`Project: ${tmpDir}`);
|
|
25
|
+
console.log();
|
|
26
|
+
|
|
27
|
+
let crashed = false;
|
|
28
|
+
let crashError: Error | undefined;
|
|
29
|
+
const crashEvents: string[] = [];
|
|
30
|
+
|
|
31
|
+
process.on("uncaughtException", (error) => {
|
|
32
|
+
crashed = true;
|
|
33
|
+
crashError = error;
|
|
34
|
+
crashEvents.push(`uncaughtException: ${error.message}`);
|
|
35
|
+
console.error(`[CRASH] uncaughtException: ${error.message}`);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
process.on("unhandledRejection", (reason) => {
|
|
39
|
+
crashed = true;
|
|
40
|
+
const err = reason instanceof Error ? reason : new Error(String(reason));
|
|
41
|
+
crashError = err;
|
|
42
|
+
crashEvents.push(`unhandledRejection: ${err.message}`);
|
|
43
|
+
console.error(`[CRASH] unhandledRejection: ${err.message}`);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
async function main(): Promise<void> {
|
|
47
|
+
const piCrewRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
48
|
+
const { SubagentManager } = await import(path.join(piCrewRoot, "src/runtime/subagent-manager.ts"));
|
|
49
|
+
|
|
50
|
+
const mgr = new SubagentManager();
|
|
51
|
+
|
|
52
|
+
const runnerThatThrows = async (): Promise<never> => {
|
|
53
|
+
throw new Error("simulated subagent failure (issue #29 crash test)");
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const record = mgr.spawn(
|
|
57
|
+
{
|
|
58
|
+
cwd: tmpDir,
|
|
59
|
+
type: "test",
|
|
60
|
+
description: "issue-29 crash test",
|
|
61
|
+
prompt: "throw",
|
|
62
|
+
background: false,
|
|
63
|
+
},
|
|
64
|
+
runnerThatThrows as Parameters<typeof mgr.spawn>[1],
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
console.log(`Spawned subagent ${record.id}`);
|
|
68
|
+
|
|
69
|
+
// Do NOT await — this is the scenario that crashes pi.
|
|
70
|
+
console.log("Waiting 300ms without awaiting record.promise...");
|
|
71
|
+
await new Promise((resolve) => setTimeout(resolve, 300));
|
|
72
|
+
|
|
73
|
+
console.log();
|
|
74
|
+
console.log(`Results:`);
|
|
75
|
+
console.log(` record.status = ${record.status}`);
|
|
76
|
+
console.log(` process.crashed = ${crashed}`);
|
|
77
|
+
console.log(` crashEvents = ${JSON.stringify(crashEvents)}`);
|
|
78
|
+
console.log();
|
|
79
|
+
|
|
80
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
81
|
+
|
|
82
|
+
if (crashed) {
|
|
83
|
+
console.error("✗ FAIL: process crashed (uncaughtException/unhandledRejection fired)");
|
|
84
|
+
console.error(" The defense-in-depth fix in subagent-manager.ts did NOT prevent the crash.");
|
|
85
|
+
if (crashError?.stack) {
|
|
86
|
+
console.error(" Stack trace:");
|
|
87
|
+
console.error(
|
|
88
|
+
crashError.stack
|
|
89
|
+
.split("\n")
|
|
90
|
+
.slice(0, 10)
|
|
91
|
+
.map((l) => ` ${l}`)
|
|
92
|
+
.join("\n"),
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
process.exit(1);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (record.status !== "error") {
|
|
99
|
+
console.error(`✗ FAIL: record.status should be 'error', got '${record.status}'`);
|
|
100
|
+
process.exit(1);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
console.log("✓ PASS: no crash, record marked 'error'");
|
|
104
|
+
process.exit(0);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
main().catch((error) => {
|
|
108
|
+
console.error("Test script itself crashed:");
|
|
109
|
+
console.error(error);
|
|
110
|
+
process.exit(1);
|
|
111
|
+
});
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
#!/usr/bin/env -S npx tsx
|
|
2
|
+
/**
|
|
3
|
+
* End-to-end test for issue #29: "Hardcoded .crew/state/runs path crashes pi
|
|
4
|
+
* (uncaughtException) in .pi-based projects".
|
|
5
|
+
*
|
|
6
|
+
* This script reproduces the EXACT scenario from the issue:
|
|
7
|
+
* 1. Create a .pi/-only project (no .crew/ directory).
|
|
8
|
+
* 2. Set up an uncaughtException + unhandledRejection detector.
|
|
9
|
+
* 3. Trigger the bug path: start a subagent whose runner throws, then
|
|
10
|
+
* NOT await record.promise (mimics the indirect caller pattern
|
|
11
|
+
* described in the issue).
|
|
12
|
+
* 4. Exit 0 if no crash, exit 1 if crash detected.
|
|
13
|
+
*
|
|
14
|
+
* Run with: npx tsx scripts/test-issue-29-e2e.ts
|
|
15
|
+
*
|
|
16
|
+
* The script must be run via `tsx` so that the dynamic ESM import of
|
|
17
|
+
* subagent-manager resolves correctly.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import * as fs from "node:fs";
|
|
21
|
+
import * as os from "node:os";
|
|
22
|
+
import * as path from "node:path";
|
|
23
|
+
import { fileURLToPath } from "node:url";
|
|
24
|
+
|
|
25
|
+
// ── Step 1: Set up a .pi/-only project (no .crew/) ──────────────────────
|
|
26
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-crew-issue-29-e2e-"));
|
|
27
|
+
fs.mkdirSync(path.join(tmpDir, ".pi"), { recursive: true });
|
|
28
|
+
// CRITICALLY: no .crew/ directory
|
|
29
|
+
const projectRoot = tmpDir;
|
|
30
|
+
|
|
31
|
+
console.log("=".repeat(72));
|
|
32
|
+
console.log("Issue #29 end-to-end reproduction test");
|
|
33
|
+
console.log("=".repeat(72));
|
|
34
|
+
console.log(`Project root: ${projectRoot}`);
|
|
35
|
+
console.log(`Has .pi/: ${fs.existsSync(path.join(projectRoot, ".pi"))}`);
|
|
36
|
+
console.log(`Has .crew/: ${fs.existsSync(path.join(projectRoot, ".crew"))} (should be false)`);
|
|
37
|
+
console.log();
|
|
38
|
+
|
|
39
|
+
// ── Step 2: Install crash detectors ──────────────────────────────────────
|
|
40
|
+
let crashed = false;
|
|
41
|
+
let crashError: Error | undefined;
|
|
42
|
+
|
|
43
|
+
process.on("uncaughtException", (error) => {
|
|
44
|
+
crashed = true;
|
|
45
|
+
crashError = error;
|
|
46
|
+
console.error("✗ FATAL: uncaughtException fired");
|
|
47
|
+
console.error(" This is the bug described in issue #29.");
|
|
48
|
+
console.error(` Error: ${error.message}`);
|
|
49
|
+
if (error.stack) console.error(` Stack: ${error.stack.split("\n").slice(0, 8).join("\n")}`);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
process.on("unhandledRejection", (reason) => {
|
|
53
|
+
crashed = true;
|
|
54
|
+
const reasonErr = reason instanceof Error ? reason : new Error(String(reason));
|
|
55
|
+
crashError = reasonErr;
|
|
56
|
+
console.error("✗ FATAL: unhandledRejection fired");
|
|
57
|
+
console.error(" This is the bug described in issue #29.");
|
|
58
|
+
console.error(` Reason: ${reasonErr.message}`);
|
|
59
|
+
if (reasonErr.stack) console.error(` Stack: ${reasonErr.stack.split("\n").slice(0, 8).join("\n")}`);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
// ── Step 3: Trigger the bug path ─────────────────────────────────────────
|
|
63
|
+
|
|
64
|
+
async function main(): Promise<void> {
|
|
65
|
+
const piCrewRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
66
|
+
|
|
67
|
+
// Dynamic import so the .ts files are loaded through tsx, not require.
|
|
68
|
+
const { SubagentManager } = await import(path.join(piCrewRoot, "src/runtime/subagent-manager.ts"));
|
|
69
|
+
const { waitForRun } = await import(path.join(piCrewRoot, "src/runtime/run-tracker.ts"));
|
|
70
|
+
const { projectCrewRoot } = await import(path.join(piCrewRoot, "src/utils/paths.ts"));
|
|
71
|
+
|
|
72
|
+
// Verify the resolver returns the .pi/teams/ path for our project.
|
|
73
|
+
const resolvedRoot = projectCrewRoot(projectRoot);
|
|
74
|
+
console.log(`projectCrewRoot(projectRoot) = ${resolvedRoot}`);
|
|
75
|
+
const expectedRoot = path.join(projectRoot, ".pi", "teams");
|
|
76
|
+
if (resolvedRoot !== expectedRoot) {
|
|
77
|
+
console.error(`✗ projectCrewRoot returned ${resolvedRoot}, expected ${expectedRoot}`);
|
|
78
|
+
process.exit(1);
|
|
79
|
+
}
|
|
80
|
+
console.log("✓ projectCrewRoot correctly resolves to .pi/teams/");
|
|
81
|
+
console.log();
|
|
82
|
+
|
|
83
|
+
// ── Test 3a: Direct waitForRun() crash path ─────────────────────────
|
|
84
|
+
console.log("Test 3a: Direct waitForRun() with non-existent runId");
|
|
85
|
+
console.log("-".repeat(72));
|
|
86
|
+
const startA = Date.now();
|
|
87
|
+
try {
|
|
88
|
+
await waitForRun("team_e2e_never_exists_xyz", projectRoot, {
|
|
89
|
+
timeoutMs: 500,
|
|
90
|
+
pollIntervalMs: 50,
|
|
91
|
+
});
|
|
92
|
+
console.error("✗ waitForRun should have thrown");
|
|
93
|
+
} catch (error) {
|
|
94
|
+
const msg = (error as Error).message;
|
|
95
|
+
const elapsed = Date.now() - startA;
|
|
96
|
+
console.log(`✓ waitForRun threw after ${elapsed}ms (as expected)`);
|
|
97
|
+
console.log(` Message: ${msg}`);
|
|
98
|
+
if (msg.includes(".pi/teams")) {
|
|
99
|
+
console.log("✓ Error message references .pi/teams/ (resolver applied)");
|
|
100
|
+
} else if (msg.includes(".crew/state/runs")) {
|
|
101
|
+
console.error(`✗ Error message still references .crew/state/runs/ (BUG NOT FIXED)`);
|
|
102
|
+
process.exit(1);
|
|
103
|
+
} else {
|
|
104
|
+
console.error(`✗ Error message doesn't reference expected path: ${msg}`);
|
|
105
|
+
process.exit(1);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
console.log();
|
|
109
|
+
|
|
110
|
+
// ── Test 3b: Indirect crash path via SubagentManager ────────────────
|
|
111
|
+
// This is the ACTUAL crash path: subagent fails → record.promise rejects
|
|
112
|
+
// → no caller awaits → unhandled rejection → pi crashes.
|
|
113
|
+
console.log("Test 3b: SubagentManager with runner that throws (no awaiter)");
|
|
114
|
+
console.log("-".repeat(72));
|
|
115
|
+
const mgr = new SubagentManager();
|
|
116
|
+
|
|
117
|
+
const runnerThatThrows = async (): Promise<never> => {
|
|
118
|
+
// Simulate a failed run lookup inside the subagent — this is what
|
|
119
|
+
// waitForRun() throws when the run directory doesn't exist.
|
|
120
|
+
// The runner returns a synthetic result, and then pollRunToTerminal
|
|
121
|
+
// would call waitForRun internally, but to keep the test focused we
|
|
122
|
+
// throw directly from the runner (mimics any unhandled subagent failure).
|
|
123
|
+
throw new Error(
|
|
124
|
+
`Run team_e2e_simulated not found. No run directory at ${path.join(projectRoot, ".crew", "state", "runs", "team_e2e_simulated")}`,
|
|
125
|
+
);
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
const record = mgr.spawn(
|
|
129
|
+
{
|
|
130
|
+
cwd: projectRoot,
|
|
131
|
+
type: "test",
|
|
132
|
+
description: "issue-29-e2e test",
|
|
133
|
+
prompt: "simulate failure",
|
|
134
|
+
background: false,
|
|
135
|
+
},
|
|
136
|
+
runnerThatThrows as Parameters<typeof mgr.spawn>[1],
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
console.log(`Spawned subagent ${record.id} (status: ${record.status})`);
|
|
140
|
+
|
|
141
|
+
// CRITICALLY: do NOT await record.promise — this is the scenario
|
|
142
|
+
// described in the issue where the throw escapes as an unhandled
|
|
143
|
+
// rejection because no caller awaits.
|
|
144
|
+
console.log("Waiting 500ms without awaiting record.promise...");
|
|
145
|
+
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
146
|
+
|
|
147
|
+
console.log(`After 500ms:`);
|
|
148
|
+
console.log(` record.status = ${record.status}`);
|
|
149
|
+
console.log(` process.crashed = ${crashed}`);
|
|
150
|
+
console.log();
|
|
151
|
+
|
|
152
|
+
// ── Step 4: Final verdict ───────────────────────────────────────────
|
|
153
|
+
if (crashed) {
|
|
154
|
+
console.error("=".repeat(72));
|
|
155
|
+
console.error("✗ TEST FAILED: process crashed (uncaughtException or unhandledRejection)");
|
|
156
|
+
console.error("=".repeat(72));
|
|
157
|
+
console.error("The fix in commit a80fe6c did NOT prevent the crash.");
|
|
158
|
+
process.exit(1);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (record.status !== "error") {
|
|
162
|
+
console.error(`✗ TEST FAILED: record.status should be 'error', got '${record.status}'`);
|
|
163
|
+
process.exit(1);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
console.log("=".repeat(72));
|
|
167
|
+
console.log("✓ ALL TESTS PASSED");
|
|
168
|
+
console.log(" - projectCrewRoot correctly resolves .pi/teams/");
|
|
169
|
+
console.log(" - waitForRun() throws with .pi/teams/ in error message");
|
|
170
|
+
console.log(" - SubagentManager rejection is caught by defense-in-depth .catch");
|
|
171
|
+
console.log(" - No uncaughtException or unhandledRejection fired");
|
|
172
|
+
console.log("=".repeat(72));
|
|
173
|
+
|
|
174
|
+
// Cleanup
|
|
175
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
176
|
+
process.exit(0);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
main().catch((error) => {
|
|
180
|
+
console.error("Test script itself crashed:");
|
|
181
|
+
console.error(error);
|
|
182
|
+
process.exit(1);
|
|
183
|
+
});
|