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.
Files changed (59) hide show
  1. package/CHANGELOG.md +87 -0
  2. package/NOTICE.md +14 -0
  3. package/assets/crew-vibes.ttf +0 -0
  4. package/assets/runner-spritesheet.png +0 -0
  5. package/dist/build-meta.json +255 -40
  6. package/dist/index.mjs +1564 -241
  7. package/dist/index.mjs.map +4 -4
  8. package/docs/perf/optimization-plan-2026-07-verified.md +396 -0
  9. package/package.json +9 -2
  10. package/scripts/bench-check.mjs +96 -0
  11. package/scripts/bench-cold-start.mjs +216 -0
  12. package/scripts/build-bundle.mjs +84 -0
  13. package/scripts/build-crew-vibes-font.py +328 -0
  14. package/scripts/check-all-skills.ts +294 -0
  15. package/scripts/check-bundle-staleness.mjs +97 -0
  16. package/scripts/check-conflict-markers.mjs +91 -0
  17. package/scripts/check-lazy-imports.mjs +28 -0
  18. package/scripts/install-crew-vibes-font.mjs +91 -0
  19. package/scripts/postinstall.mjs +47 -0
  20. package/scripts/profile-startup.mjs +125 -0
  21. package/scripts/release-smoke.mjs +74 -0
  22. package/scripts/run-bench.mjs +47 -0
  23. package/scripts/run-real-chain.ts +41 -0
  24. package/scripts/test-issue-29-crash.ts +111 -0
  25. package/scripts/test-issue-29-e2e.ts +183 -0
  26. package/scripts/test-issue-29-real-runtime.ts +330 -0
  27. package/scripts/test-issue-29-real-tasks.ts +387 -0
  28. package/scripts/test-issue-29-team-tool.ts +105 -0
  29. package/scripts/test-runner.mjs +74 -0
  30. package/scripts/verify-flicker-fix.ts +109 -0
  31. package/scripts/verify-skill.ts +550 -0
  32. package/scripts/watch-bundle.mjs +259 -0
  33. package/scripts/watchdog-harness.ts +119 -0
  34. package/src/agents/discover-agents.ts +6 -1
  35. package/src/config/defaults.ts +6 -0
  36. package/src/extension/crew-vibes/cat-frames.ts +18 -0
  37. package/src/extension/crew-vibes/config.ts +195 -0
  38. package/src/extension/crew-vibes/figures.ts +94 -0
  39. package/src/extension/crew-vibes/font-detect.ts +58 -0
  40. package/src/extension/crew-vibes/index.ts +396 -0
  41. package/src/extension/crew-vibes/provider-usage.ts +330 -0
  42. package/src/extension/crew-vibes/render.ts +206 -0
  43. package/src/extension/crew-vibes/speed.ts +286 -0
  44. package/src/extension/knowledge-injection.ts +12 -3
  45. package/src/extension/management.ts +23 -3
  46. package/src/extension/register.ts +7 -0
  47. package/src/runtime/child-pi.ts +117 -10
  48. package/src/runtime/crew-agent-records.ts +10 -3
  49. package/src/runtime/retry-executor.ts +4 -1
  50. package/src/runtime/task-runner/state-helpers.ts +9 -30
  51. package/src/runtime/team-runner.ts +5 -3
  52. package/src/state/atomic-write.ts +153 -49
  53. package/src/state/event-log.ts +16 -10
  54. package/src/state/locks.ts +7 -8
  55. package/src/state/mailbox.ts +15 -4
  56. package/src/state/state-store.ts +39 -10
  57. package/src/teams/discover-teams.ts +56 -1
  58. package/src/utils/safe-paths.ts +2 -1
  59. package/src/workflows/discover-workflows.ts +72 -1
@@ -0,0 +1,387 @@
1
+ #!/usr/bin/env -S npx tsx
2
+ /**
3
+ * REAL-TASK E2E test for issue #29.
4
+ *
5
+ * This test runs ACTUAL pi-crew operations in a .pi/-only project (no
6
+ * .crew/ directory) to verify the fix works end-to-end through the
7
+ * public API — not just isolated unit tests.
8
+ *
9
+ * Operations exercised:
10
+ * 1. spawnBackgroundTeamRun() — exercises the background-runner path
11
+ * (which writes to background.log and exit-code.txt using paths
12
+ * that previously hardcoded .crew/state/runs/).
13
+ * 2. waitForRun() — the CRASH site. With fix, the slow-path early-exit
14
+ * uses projectCrewRoot() so the error message references .pi/teams/.
15
+ * 3. loadRunManifestById() — the loader used by waitForRun's fast path,
16
+ * which was always correct but is exercised here for completeness.
17
+ * 4. saveCheckpoint() / loadCheckpoint() — round-trip through .pi/teams/.
18
+ * 5. recordSkillActivation() / getSkillActivations() — paths land in
19
+ * .pi/teams/state/runs/, not .crew/.
20
+ * 6. Full executeTeamRun() with executeWorkers=false — runs the entire
21
+ * team-runner pipeline (with placeholder results) which internally
22
+ * creates manifests, tasks, events.jsonl, and exercises the full
23
+ * projectCrewRoot() path.
24
+ * 7. initLedger() / appendEntry() — decision-ledger uses projectCrewRoot()
25
+ * when stateRoot is omitted.
26
+ *
27
+ * Crash detection: registers uncaughtException + unhandledRejection
28
+ * listeners. Any crash during the operations causes exit 1.
29
+ *
30
+ * Run with: npx tsx scripts/test-issue-29-real-tasks.ts
31
+ */
32
+
33
+ import * as fs from "node:fs";
34
+ import * as os from "node:os";
35
+ import * as path from "node:path";
36
+ import { fileURLToPath } from "node:url";
37
+
38
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-crew-issue-29-real-"));
39
+ fs.mkdirSync(path.join(tmpDir, ".pi"), { recursive: true });
40
+ // CRITICALLY: no .crew/
41
+
42
+ console.log("=".repeat(72));
43
+ console.log("Issue #29 REAL-TASK E2E test (exercises public API)");
44
+ console.log("=".repeat(72));
45
+ console.log(`Project: ${tmpDir}`);
46
+ console.log(` Has .pi/ : ${fs.existsSync(path.join(tmpDir, ".pi"))}`);
47
+ console.log(` Has .crew/: ${fs.existsSync(path.join(tmpDir, ".crew"))} (should be false)`);
48
+ console.log();
49
+
50
+ // ── Crash detection ──────────────────────────────────────────────────────
51
+ let crashed = false;
52
+ let crashError: Error | undefined;
53
+ process.on("uncaughtException", (error) => {
54
+ crashed = true;
55
+ crashError = error;
56
+ console.error(`[CRASH] uncaughtException: ${error.message}`);
57
+ });
58
+ process.on("unhandledRejection", (reason) => {
59
+ crashed = true;
60
+ const err = reason instanceof Error ? reason : new Error(String(reason));
61
+ crashError = err;
62
+ console.error(`[CRASH] unhandledRejection: ${err.message}`);
63
+ });
64
+
65
+ // ── Helpers ──────────────────────────────────────────────────────────────
66
+ const piCrewRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
67
+ let pass = 0;
68
+ let fail = 0;
69
+ const failures: string[] = [];
70
+
71
+ function check(label: string, condition: boolean, detail = ""): void {
72
+ if (condition) {
73
+ pass++;
74
+ console.log(` ✓ ${label}`);
75
+ } else {
76
+ fail++;
77
+ failures.push(label);
78
+ console.error(` ✗ ${label}${detail ? ` — ${detail}` : ""}`);
79
+ }
80
+ }
81
+
82
+ async function main(): Promise<void> {
83
+ // Force placeholder-result mode (no child process spawns needed for the
84
+ // full executeTeamRun pipeline).
85
+ process.env.PI_CREW_EXECUTE_WORKERS = "false";
86
+
87
+ // ── Test 1: projectCrewRoot returns the right path ─────────────────
88
+ console.log();
89
+ console.log("Test 1: projectCrewRoot resolver");
90
+ console.log("-".repeat(72));
91
+ const { projectCrewRoot, clearProjectRootCache } = await import(path.join(piCrewRoot, "src/utils/paths.ts"));
92
+ clearProjectRootCache();
93
+ const root = projectCrewRoot(tmpDir);
94
+ check(`projectCrewRoot returns .pi/teams/ for .pi-only project (got: ${root})`, root === path.join(tmpDir, ".pi", "teams"));
95
+
96
+ // ── Test 2: Full executeTeamRun() pipeline ──────────────────────────
97
+ // This runs the actual team-runner (with executeWorkers=false so it
98
+ // doesn't spawn child Pi processes). It internally creates the manifest,
99
+ // tasks.json, events.jsonl, and exercises every projectCrewRoot() site.
100
+ console.log();
101
+ console.log("Test 2: Full executeTeamRun() pipeline (with placeholder workers)");
102
+ console.log("-".repeat(72));
103
+
104
+ const { createRunManifest, createTasksFromWorkflow, loadRunManifestById } = await import(
105
+ path.join(piCrewRoot, "src/state/state-store.ts")
106
+ );
107
+ const { discoverTeams, allTeams } = await import(path.join(piCrewRoot, "src/teams/discover-teams.ts"));
108
+ const { discoverAgents, allAgents } = await import(path.join(piCrewRoot, "src/agents/discover-agents.ts"));
109
+ const { discoverWorkflows, allWorkflows } = await import(path.join(piCrewRoot, "src/workflows/discover-workflows.ts"));
110
+ const { executeTeamRun } = await import(path.join(piCrewRoot, "src/runtime/team-runner.ts"));
111
+
112
+ const teams = allTeams(discoverTeams(tmpDir));
113
+ const agents = allAgents(discoverAgents(tmpDir));
114
+ const workflows = allWorkflows(discoverWorkflows(tmpDir));
115
+ console.log(` Discovered ${teams.length} teams, ${agents.length} agents, ${workflows.length} workflows`);
116
+
117
+ const team = teams.find((t) => t.name === "fast-fix");
118
+ const workflow = workflows.find((w) => w.name === "fast-fix");
119
+ check("fast-fix team found", Boolean(team));
120
+ check("fast-fix workflow found", Boolean(workflow));
121
+
122
+ if (team && workflow) {
123
+ const {
124
+ manifest,
125
+ tasks: initialTasks,
126
+ paths,
127
+ } = createRunManifest({
128
+ cwd: tmpDir,
129
+ team,
130
+ workflow,
131
+ goal: "E2E test for issue #29: run real team-runner pipeline in .pi-only project",
132
+ workspaceMode: "single",
133
+ });
134
+ const tasks = initialTasks;
135
+
136
+ console.log(` Created run: ${manifest.runId}`);
137
+ console.log(` Tasks: ${tasks.length}`);
138
+
139
+ const start = Date.now();
140
+ try {
141
+ const result = await executeTeamRun({
142
+ manifest,
143
+ tasks,
144
+ team,
145
+ workflow,
146
+ agents,
147
+ executeWorkers: false,
148
+ workspaceId: "e2e-test-29",
149
+ });
150
+ const elapsed = Date.now() - start;
151
+ console.log(` executeTeamRun completed in ${elapsed}ms`);
152
+ console.log(` Final status: ${result.manifest.status}`);
153
+
154
+ // Verify the manifest is at the right path
155
+ const expectedStateRoot = path.join(tmpDir, ".pi", "teams", "state", "runs", manifest.runId);
156
+ check(
157
+ `manifest.stateRoot is under .pi/teams/state/runs/`,
158
+ result.manifest.stateRoot === expectedStateRoot,
159
+ `got: ${result.manifest.stateRoot}`,
160
+ );
161
+
162
+ // Verify the manifest can be reloaded
163
+ const reloaded = loadRunManifestById(tmpDir, manifest.runId);
164
+ check("manifest can be reloaded by loadRunManifestById()", Boolean(reloaded));
165
+ if (reloaded) {
166
+ check("reloaded manifest has same runId", reloaded.manifest.runId === manifest.runId);
167
+ }
168
+
169
+ // Verify .pi/teams/state/runs/<runId>/ directory exists
170
+ check(`.pi/teams/state/runs/${manifest.runId}/ exists`, fs.existsSync(expectedStateRoot));
171
+
172
+ // Verify NO .crew/ directory was created
173
+ check(`no .crew/ directory was created (would indicate fallback)`, !fs.existsSync(path.join(tmpDir, ".crew")));
174
+
175
+ // Verify all expected files are under .pi/teams/
176
+ const runDir = expectedStateRoot;
177
+ const expectedFiles = ["manifest.json", "tasks.json", "events.jsonl"];
178
+ for (const f of expectedFiles) {
179
+ check(`${f} exists at .pi/teams/state/runs/<runId>/${f}`, fs.existsSync(path.join(runDir, f)));
180
+ }
181
+ } catch (error) {
182
+ fail++;
183
+ failures.push("executeTeamRun");
184
+ console.error(` ✗ executeTeamRun FAILED: ${(error as Error).message}`);
185
+ }
186
+ }
187
+
188
+ // ── Test 3: waitForRun() with real runId ────────────────────────────
189
+ console.log();
190
+ console.log("Test 3: waitForRun() against the just-created run");
191
+ console.log("-".repeat(72));
192
+ const { waitForRun } = await import(path.join(piCrewRoot, "src/runtime/run-tracker.ts"));
193
+ if (team && workflow) {
194
+ const { manifest, tasks } = createRunManifest({
195
+ cwd: tmpDir,
196
+ team,
197
+ workflow,
198
+ goal: "waitForRun test",
199
+ workspaceMode: "single",
200
+ });
201
+ // Manually write a completed manifest (avoid running the full pipeline again)
202
+ const completedManifest = {
203
+ ...manifest,
204
+ status: "completed" as const,
205
+ summary: "Test completed",
206
+ updatedAt: new Date().toISOString(),
207
+ completedAt: new Date().toISOString(),
208
+ };
209
+ fs.mkdirSync(completedManifest.stateRoot, { recursive: true });
210
+ fs.writeFileSync(path.join(completedManifest.stateRoot, "manifest.json"), JSON.stringify(completedManifest, null, 2));
211
+ fs.writeFileSync(path.join(completedManifest.stateRoot, "tasks.json"), JSON.stringify(tasks, null, 2));
212
+ fs.writeFileSync(path.join(completedManifest.stateRoot, "events.jsonl"), "");
213
+
214
+ const start = Date.now();
215
+ try {
216
+ const result = await waitForRun(manifest.runId, tmpDir, {
217
+ timeoutMs: 2000,
218
+ pollIntervalMs: 50,
219
+ });
220
+ const elapsed = Date.now() - start;
221
+ check(
222
+ `waitForRun() succeeded in ${elapsed}ms (manifest.status=${result.manifest.status})`,
223
+ result.manifest.status === "completed",
224
+ );
225
+ } catch (error) {
226
+ fail++;
227
+ failures.push("waitForRun");
228
+ console.error(` ✗ waitForRun FAILED: ${(error as Error).message}`);
229
+ }
230
+ }
231
+
232
+ // ── Test 4: waitForRun() with non-existent runId (slow path) ────────
233
+ console.log();
234
+ console.log("Test 4: waitForRun() with non-existent runId (slow path early-exit)");
235
+ console.log("-".repeat(72));
236
+ try {
237
+ await waitForRun("team_definitely_does_not_exist_xyz_29", tmpDir, {
238
+ timeoutMs: 200,
239
+ pollIntervalMs: 50,
240
+ });
241
+ fail++;
242
+ failures.push("waitForRun(non-existent)");
243
+ console.error(" ✗ waitForRun should have thrown");
244
+ } catch (error) {
245
+ const msg = (error as Error).message;
246
+ check(
247
+ "error message references .pi/teams/ (not .crew/state/runs/)",
248
+ msg.includes(".pi/teams") && !msg.includes(".crew/state/runs"),
249
+ `got: ${msg}`,
250
+ );
251
+ }
252
+
253
+ // ── Test 5: saveCheckpoint / loadCheckpoint round-trip ──────────────
254
+ console.log();
255
+ console.log("Test 5: saveCheckpoint / loadCheckpoint round-trip");
256
+ console.log("-".repeat(72));
257
+ const { saveCheckpoint, loadCheckpoint, listCheckpoints, clearCheckpointStores } = await import(
258
+ path.join(piCrewRoot, "src/runtime/checkpoint.ts")
259
+ );
260
+ const ckRunId = `ck_real_${Date.now().toString(36)}`;
261
+ const ckTaskId = "task-real-1";
262
+ saveCheckpoint(ckRunId, ckTaskId, 1, "context", "progress", "agent-real", "model-real", tmpDir);
263
+ const loaded = loadCheckpoint(ckRunId, ckTaskId, tmpDir);
264
+ check("saveCheckpoint → loadCheckpoint round-trip", Boolean(loaded));
265
+ if (loaded) {
266
+ check("loaded checkpoint has correct taskId", loaded.taskId === ckTaskId);
267
+ check("loaded checkpoint has correct progress", loaded.progress === "progress");
268
+ }
269
+ const ckPath = path.join(tmpDir, ".pi", "teams", "state", "runs", ckRunId, "checkpoints", `${ckTaskId}.json`);
270
+ check(`checkpoint file at .pi/teams/state/runs/${ckRunId}/checkpoints/${ckTaskId}.json`, fs.existsSync(ckPath));
271
+ const ckList = listCheckpoints(ckRunId, tmpDir);
272
+ check(`listCheckpoints returns 1 entry`, ckList.length === 1);
273
+ clearCheckpointStores();
274
+
275
+ // ── Test 6: recordSkillActivation / getSkillActivations ─────────────
276
+ console.log();
277
+ console.log("Test 6: recordSkillActivation / getSkillActivations");
278
+ console.log("-".repeat(72));
279
+ const { recordSkillActivation, getSkillActivations } = await import(path.join(piCrewRoot, "src/runtime/skill-effectiveness.ts"));
280
+ const seRunId = `se_real_${Date.now().toString(36)}`;
281
+ recordSkillActivation(tmpDir, {
282
+ id: "act-real-1",
283
+ skillId: "verification-before-done",
284
+ role: "executor",
285
+ runId: seRunId,
286
+ taskId: "task-real-1",
287
+ timestamp: new Date().toISOString(),
288
+ passed: true,
289
+ confidence: 0.7,
290
+ });
291
+ const seList = getSkillActivations(tmpDir, seRunId);
292
+ check("recordSkillActivation → getSkillActivations round-trip", seList.length === 1);
293
+ if (seList.length === 1) {
294
+ check("recorded skill has correct skillId", seList[0].skillId === "verification-before-done");
295
+ }
296
+ const sePath = path.join(tmpDir, ".pi", "teams", "state", "runs", seRunId, "skill-activations.jsonl");
297
+ check(`skill-activations file at .pi/teams/state/runs/${seRunId}/skill-activations.jsonl`, fs.existsSync(sePath));
298
+
299
+ // ── Test 7: initLedger / appendEntry / getLedger ────────────────────
300
+ console.log();
301
+ console.log("Test 7: initLedger / appendEntry / getLedger");
302
+ console.log("-".repeat(72));
303
+ const { initLedger, appendEntry, getLedger } = await import(path.join(piCrewRoot, "src/state/decision-ledger.ts"));
304
+ const ledgerRunId = `ledger_real_${Date.now().toString(36)}`;
305
+ initLedger(ledgerRunId);
306
+ appendEntry(ledgerRunId, {
307
+ rolloutId: "rollout-1",
308
+ timestamp: new Date().toISOString(),
309
+ decision: "chose option A",
310
+ confidence: 0.8,
311
+ reasoning: "lowest cost",
312
+ });
313
+ const ledger = getLedger(ledgerRunId);
314
+ check(`initLedger + appendEntry round-trip (${ledger.length} entries)`, ledger.length === 1);
315
+
316
+ // ── Test 8: background-runner.ts path computation (offline) ──────────
317
+ // We can't actually spawn the background worker (it requires a real
318
+ // child Pi process), but we can import the path functions and verify
319
+ // they would resolve to the right location.
320
+ console.log();
321
+ console.log("Test 8: background-runner path resolution (offline check)");
322
+ console.log("-".repeat(72));
323
+ // The background-runner.ts uses projectCrewRoot(_cwd) for the log path.
324
+ // We verify the resolver gives the right path:
325
+ const logExpectedDir = path.join(projectCrewRoot(tmpDir), "state", "runs");
326
+ check(`background log dir resolves to .pi/teams/state/runs/`, logExpectedDir === path.join(tmpDir, ".pi", "teams", "state", "runs"));
327
+
328
+ // ── Test 9: subagent-manager crash safety ───────────────────────────
329
+ console.log();
330
+ console.log("Test 9: subagent-manager crash safety (defense in depth)");
331
+ console.log("-".repeat(72));
332
+ const { SubagentManager } = await import(path.join(piCrewRoot, "src/runtime/subagent-manager.ts"));
333
+ const mgr = new SubagentManager();
334
+ const throwingRunner = async (): Promise<never> => {
335
+ throw new Error("simulated failure");
336
+ };
337
+ const record = mgr.spawn(
338
+ {
339
+ cwd: tmpDir,
340
+ type: "test",
341
+ description: "issue-29 real test",
342
+ prompt: "throw",
343
+ background: false,
344
+ },
345
+ throwingRunner as Parameters<typeof mgr.spawn>[1],
346
+ );
347
+ // Do NOT await record.promise — this is the scenario that crashes pi.
348
+ await new Promise((resolve) => setTimeout(resolve, 300));
349
+ check(`record.status is 'error' after runner throws`, record.status === "error");
350
+ check(`no uncaughtException/unhandledRejection fired`, !crashed, crashError?.message);
351
+
352
+ // ── Final verdict ───────────────────────────────────────────────────
353
+ console.log();
354
+ console.log("=".repeat(72));
355
+ console.log(`Results: ${pass} passed, ${fail} failed, ${crashed ? "1" : "0"} crashed`);
356
+ if (failures.length > 0) {
357
+ console.error("Failures:");
358
+ for (const f of failures) console.error(` - ${f}`);
359
+ }
360
+ if (crashed) {
361
+ console.error(`Crashed: ${crashError?.message}`);
362
+ }
363
+ console.log("=".repeat(72));
364
+
365
+ // Cleanup
366
+ try {
367
+ fs.rmSync(tmpDir, { recursive: true, force: true });
368
+ } catch {
369
+ /* ignore */
370
+ }
371
+
372
+ if (crashed || fail > 0) {
373
+ process.exit(1);
374
+ }
375
+ process.exit(0);
376
+ }
377
+
378
+ main().catch((error) => {
379
+ console.error("Test script itself crashed:");
380
+ console.error(error);
381
+ try {
382
+ fs.rmSync(tmpDir, { recursive: true, force: true });
383
+ } catch {
384
+ /* ignore */
385
+ }
386
+ process.exit(1);
387
+ });
@@ -0,0 +1,105 @@
1
+ #!/usr/bin/env -S npx tsx
2
+ /**
3
+ * Real-world reproduction of issue #29.
4
+ *
5
+ * Scenario (from the issue report):
6
+ * 1. User has a .pi/-only project (no .crew/ directory).
7
+ * 2. A background team run is in progress; the run directory does NOT
8
+ * exist on disk yet (the worker is still starting up).
9
+ * 3. waitForRun() is called to wait for the run to complete.
10
+ * 4. waitForRun() takes the slow path and on attempt 0 hits the
11
+ * early-exit "Run not found" check.
12
+ *
13
+ * On the BUGGY version (pre-fix a80fe6c):
14
+ * - The early-exit check uses `path.join(cwd, ".crew", "state", "runs", runId)`
15
+ * - That directory does not exist in a .pi/-only project
16
+ * - Throws "Run not found. No run directory at <cwd>/.crew/state/runs/..."
17
+ * - The throw escapes as an unhandled rejection → pi crashes
18
+ *
19
+ * On the FIXED version (a80fe6c):
20
+ * - The early-exit check uses `path.join(projectCrewRoot(cwd), "state", "runs", runId)`
21
+ * - In a .pi/-only project, projectCrewRoot returns .pi/teams/
22
+ * - The check still throws (the directory doesn't exist anywhere), but the
23
+ * error message correctly points at .pi/teams/state/runs/...
24
+ *
25
+ * The test is "passes on fixed, fails on buggy" — verified by toggling the
26
+ * fix in src/runtime/run-tracker.ts.
27
+ *
28
+ * Run with: npx tsx scripts/test-issue-29-team-tool.ts
29
+ */
30
+
31
+ import * as fs from "node:fs";
32
+ import * as os from "node:os";
33
+ import * as path from "node:path";
34
+ import { fileURLToPath } from "node:url";
35
+
36
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-crew-issue-29-real-"));
37
+ fs.mkdirSync(path.join(tmpDir, ".pi"), { recursive: true });
38
+ // CRITICALLY: no .crew/
39
+
40
+ console.log("Issue #29 REAL-WORLD reproduction test");
41
+ console.log(`Project: ${tmpDir}`);
42
+ console.log(` Has .pi/: ${fs.existsSync(path.join(tmpDir, ".pi"))}`);
43
+ console.log(` Has .crew/: ${fs.existsSync(path.join(tmpDir, ".crew"))} (should be false)`);
44
+ console.log();
45
+
46
+ async function main(): Promise<void> {
47
+ const piCrewRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
48
+
49
+ // Realistic scenario: the run manifest does NOT exist yet (the background
50
+ // worker is still starting up). waitForRun() takes the slow path and
51
+ // hits the early-exit check on attempt 0 — that's where the bug fires.
52
+ const runId = `team_real_${Date.now().toString(36)}`;
53
+ console.log(`Looking for run ${runId}`);
54
+ console.log(` Expected (correct) path: ${path.join(tmpDir, ".pi", "teams", "state", "runs", runId)}`);
55
+ console.log(` Buggy path: ${path.join(tmpDir, ".crew", "state", "runs", runId)}`);
56
+ console.log();
57
+
58
+ const { waitForRun } = await import(path.join(piCrewRoot, "src/runtime/run-tracker.ts"));
59
+
60
+ console.log("Calling waitForRun()...");
61
+ const start = Date.now();
62
+ try {
63
+ const result = await waitForRun(runId, tmpDir, {
64
+ timeoutMs: 500,
65
+ pollIntervalMs: 100,
66
+ });
67
+ const elapsed = Date.now() - start;
68
+ console.error(`✗ waitForRun unexpectedly SUCCEEDED in ${elapsed}ms (should have thrown)`);
69
+ console.error(` manifest.status = ${result.manifest.status}`);
70
+ process.exit(1);
71
+ } catch (error) {
72
+ const elapsed = Date.now() - start;
73
+ const msg = (error as Error).message;
74
+ console.log(`waitForRun threw after ${elapsed}ms:`);
75
+ console.log(` Message: ${msg}`);
76
+ console.log();
77
+
78
+ // The error message must reference the CORRECT path (.pi/teams/...),
79
+ // not the old hardcoded .crew/state/runs/...
80
+ if (msg.includes(".pi/teams")) {
81
+ console.log("✓ Error message references .pi/teams/ (resolver applied)");
82
+ console.log("✓ TEST PASSED: waitForRun correctly resolves .pi/teams/state/runs/");
83
+ fs.rmSync(tmpDir, { recursive: true, force: true });
84
+ process.exit(0);
85
+ }
86
+ if (msg.includes(".crew/state/runs")) {
87
+ console.error("✗ TEST FAILED: Error message references .crew/state/runs/");
88
+ console.error(" This is the bug described in issue #29:");
89
+ console.error(" waitForRun() looked in .crew/state/runs/ instead of .pi/teams/state/runs/");
90
+ fs.rmSync(tmpDir, { recursive: true, force: true });
91
+ process.exit(1);
92
+ }
93
+ console.error(`✗ TEST FAILED: Error message doesn't reference expected path`);
94
+ console.error(` Got: ${msg}`);
95
+ fs.rmSync(tmpDir, { recursive: true, force: true });
96
+ process.exit(1);
97
+ }
98
+ }
99
+
100
+ main().catch((error) => {
101
+ console.error("Test script itself crashed:");
102
+ console.error(error);
103
+ fs.rmSync(tmpDir, { recursive: true, force: true });
104
+ process.exit(1);
105
+ });
@@ -0,0 +1,74 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Test runner wrapper that enforces non-zero exit code on failures.
4
+ *
5
+ * Problem: `tsx --test` always exits 0 even when tests fail.
6
+ * Fix: Use stdio: 'inherit' so output streams directly (avoids pipe buffer
7
+ * deadlocks on large test suites), and rely on the child's exit code.
8
+ *
9
+ * Always passes --test-force-exit so the child process cannot hang the
10
+ * parent (pi) on shutdown. Defensive: prevents the "pi froze" failure
11
+ * mode where a long-running test keeps file handles/timers open and
12
+ * blocks the agent's wait-for-exit.
13
+ *
14
+ * Usage: node scripts/test-runner.mjs [tsx test args...]
15
+ */
16
+ import { spawnSync } from "node:child_process";
17
+ import process from "node:process";
18
+
19
+ const args = process.argv.slice(2);
20
+ if (args.length === 0) {
21
+ // When run by Node's test runner (no args), exit 0 gracefully.
22
+ // This script needs test file arguments to do anything useful.
23
+ console.log("skip: no test files specified");
24
+ process.exit(0);
25
+ }
26
+
27
+ // Always inject --test-force-exit to guarantee child exits (prevents pi hang).
28
+ const hasForceExit = args.includes("--test-force-exit");
29
+ let finalArgs = hasForceExit ? args : ["--test-force-exit", ...args];
30
+
31
+ // CI reliability: node:test runs test FILES concurrently in one process
32
+ // (--test-concurrency=N). On shared CI runners (GitHub Actions), high
33
+ // concurrency causes cross-file filesystem contention that makes write-then-
34
+ // stat tests (notably state-store's createRunManifest assertions) flake:
35
+ // - windows-latest: Windows Defender real-time scanning locks freshly-
36
+ // created temp files → transient EPERM/EBUSY on rename inside
37
+ // atomicWriteFile (exhausts the ~1.6s rename retries).
38
+ // - macos-latest: /var/folders tmp contention under load → occasional
39
+ // 4ms instant write failures.
40
+ // The flake only surfaced after the Round 13/14 test additions pushed the
41
+ // runners past their timing threshold. Capping cross-file concurrency at 2
42
+ // across ALL platforms gives the FS room to flush and eliminates the storm.
43
+ // Local dev is unaffected (developers pass --test-concurrency=4 explicitly
44
+ // and run on idle machines). This only clamps the CI-requested value.
45
+ finalArgs = finalArgs.map((arg) => {
46
+ const m = /^(--test-concurrency)=(\d+)$/.exec(arg);
47
+ return m && Number(m[2]) > 2 ? `${m[1]}=2` : arg;
48
+ });
49
+
50
+ const result = spawnSync(
51
+ process.execPath,
52
+ ["--import", "tsx/esm", "--test", ...finalArgs],
53
+ {
54
+ stdio: "inherit",
55
+ env: { ...process.env, NODE_ENV: "test", PI_CREW_SKIP_HOME_CHECK: "1" },
56
+ // 2026-07-01: bumped from 600s → 900s after atomic-write.ts added
57
+ // fs.fsyncSync for the mailbox-replay flake fix. fsync adds ~5-10ms
58
+ // per atomic-write, which compounded across 5800 tests pushed
59
+ // Windows CI just over the 10-minute budget. 15 minutes gives
60
+ // comfortable headroom on Windows (slowest) without masking real
61
+ // test bugs.
62
+ timeout: 900_000,
63
+ },
64
+ );
65
+
66
+ if (result.error) {
67
+ console.error("Test runner error:", result.error.message);
68
+ process.exit(1);
69
+ }
70
+
71
+ // The Node.js test runner exits with non-zero when tests fail.
72
+ // With --test-force-exit, it may exit with code 1 if force-exited
73
+ // while tests were still running (which shouldn't happen normally).
74
+ process.exit(result.status ?? 0);
@@ -0,0 +1,109 @@
1
+ // E2E verification for the UI flicker fix.
2
+ // Loads the fixed snapshot-cache, builds a real snapshot against a temp
3
+ // run directory, fires a burst of run:state + worker:lifecycle events
4
+ // (the exact trigger the fix handles), and verifies the cache entry
5
+ // survives the burst synchronously. Pre-fix: every event deleted the
6
+ // cache entry → widget alternated between snapshot path and disk-read
7
+ // fallback every render tick. Post-fix: the entry is preserved (or
8
+ // replaced via coalesced refresh) so the widget sees a stable snapshot.
9
+
10
+ import * as fs from "node:fs";
11
+ import * as os from "node:os";
12
+ import * as path from "node:path";
13
+ import { saveCrewAgents } from "../src/runtime/crew-agent-records.ts";
14
+ import { createRunManifest, saveRunManifest } from "../src/state/state-store.ts";
15
+ import { runEventBus } from "../src/ui/run-event-bus.ts";
16
+ import { createRunSnapshotCache } from "../src/ui/run-snapshot-cache.ts";
17
+
18
+ const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "verify-flicker-"));
19
+ fs.mkdirSync(path.join(cwd, ".crew"), { recursive: true });
20
+
21
+ const team = {
22
+ name: "verify",
23
+ description: "",
24
+ roles: [{ name: "explorer", agent: "explorer" }],
25
+ source: "test",
26
+ filePath: "builtin",
27
+ };
28
+ const workflow = {
29
+ name: "verify",
30
+ description: "",
31
+ steps: [{ id: "explore", role: "explorer" }],
32
+ source: "test",
33
+ filePath: "builtin",
34
+ };
35
+ const created = createRunManifest({
36
+ cwd,
37
+ team,
38
+ workflow,
39
+ goal: "verify-flicker-fix",
40
+ });
41
+ saveRunManifest({ ...created.manifest, status: "running" });
42
+ saveCrewAgents(created.manifest, [
43
+ {
44
+ id: `${created.manifest.runId}:01`,
45
+ runId: created.manifest.runId,
46
+ taskId: created.tasks[0]?.id ?? "explore",
47
+ agent: "explorer",
48
+ role: "explorer",
49
+ runtime: "child-process",
50
+ status: "running",
51
+ startedAt: created.manifest.createdAt,
52
+ progress: {
53
+ recentTools: [],
54
+ recentOutput: ["first"],
55
+ toolCount: 1,
56
+ currentTool: "read",
57
+ tokens: 10,
58
+ },
59
+ },
60
+ ]);
61
+
62
+ const cache = createRunSnapshotCache(cwd, { ttlMs: 60_000 });
63
+ const initial = cache.refresh(created.manifest.runId);
64
+ console.log(`INITIAL: signature=${initial.signature} tasks=${initial.tasks.length}`);
65
+
66
+ const runId = created.manifest.runId;
67
+ // Burst simulating the exact event sequence that fired the OLD flicker:
68
+ // every run:state and worker:lifecycle event in this list would have
69
+ // deleted the cache entry pre-fix.
70
+ const burst: {
71
+ type: string;
72
+ channel: "run:state" | "worker:lifecycle";
73
+ taskId?: string;
74
+ }[] = [
75
+ { type: "run.started", channel: "worker:lifecycle" },
76
+ { type: "task.started", channel: "worker:lifecycle" },
77
+ { type: "manifest.saved", channel: "run:state" },
78
+ { type: "task.claimed", channel: "run:state" },
79
+ { type: "manifest.saved", channel: "run:state" },
80
+ { type: "task.unclaimed", channel: "run:state" },
81
+ { type: "manifest.saved", channel: "run:state" },
82
+ { type: "task.claimed", channel: "run:state" },
83
+ { type: "worker.status", channel: "worker:lifecycle", taskId: "t1" },
84
+ { type: "worker.status", channel: "worker:lifecycle", taskId: "t1" },
85
+ { type: "task.completed", channel: "worker:lifecycle" },
86
+ { type: "manifest.saved", channel: "run:state" },
87
+ ];
88
+ let survivalCount = 0;
89
+ for (const e of burst) {
90
+ runEventBus.emit({ ...e, runId });
91
+ const after = cache.get(runId);
92
+ if (after) survivalCount++;
93
+ else console.log(`FAILED at ${e.type} — cache entry deleted`);
94
+ }
95
+ console.log(`SURVIVED: ${survivalCount}/${burst.length} burst events`);
96
+
97
+ await new Promise((r) => setTimeout(r, 200));
98
+ const after = cache.get(runId);
99
+ console.log(`AFTER COALESCE: ${after ? `present (signature=${after.signature})` : "MISSING"}`);
100
+
101
+ fs.rmSync(cwd, { recursive: true, force: true });
102
+ console.log("\n=== RESULT ===");
103
+ if (survivalCount === burst.length && after) {
104
+ console.log("PASS — cache survives all burst events (no flicker window)");
105
+ process.exit(0);
106
+ } else {
107
+ console.log("FAIL — cache was deleted by burst events");
108
+ process.exit(1);
109
+ }