Skip to content

Commit bfe45c0

Browse files
authored
perf(test): speed up workspace test suite (#172)
1 parent a7bc41e commit bfe45c0

7 files changed

Lines changed: 103 additions & 145 deletions

File tree

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@
3838
"scripts": {
3939
"build": "npm run build --workspace @henryqw/pi-herdr --workspace @henryqw/pi-task-models --workspace @henryqw/pi-subagent",
4040
"postinstall": "npm run build",
41-
"test": "npm test --workspaces --if-present",
41+
"test": "node scripts/test-workspaces.mjs",
4242
"test:live": "npm run test:live --workspace @henryqw/pi-auto-compact",
4343
"typecheck": "npm run typecheck --workspaces --if-present",
4444
"pack:check": "npm run pack:check --workspaces --if-present"

packages/pi-auto-dag/test/execution.test.ts

Lines changed: 19 additions & 85 deletions
Original file line numberDiff line numberDiff line change
@@ -521,44 +521,6 @@ test("process interruption after claiming leaves inspectable execution state and
521521
assert.deepEqual((await readdir(stateRoot(project.root))).filter((name) => name.startsWith(".lifecycle")), []);
522522
});
523523

524-
test("abort retains an undelivered blocked notification until acknowledgement", async (t) => {
525-
const project = await setupProject(t);
526-
const graph = parseDeliveryGraph({ ...graphInput, issues: [graphInput.issues[2]] });
527-
const initial = createInitialRunState({
528-
run_id: RUN_ID,
529-
graph,
530-
source_commit: await git(project.root, "rev-parse", "HEAD"),
531-
main_worktree: project.root,
532-
integration_branch: "integration",
533-
default_branch: "main",
534-
created_at: "2026-08-09T00:00:00.000Z",
535-
main_pane: "main-pane",
536-
workspace_id: "main-workspace",
537-
});
538-
await createRun(project.root, initial, () => "create");
539-
await writeRunState(project.root, {
540-
...initial,
541-
phase: "blocked",
542-
block_reason: "API needs a decision.",
543-
tasks: {
544-
...initial.tasks,
545-
api: { status: "blocked", attempts: 1, block_reason: "Choose a protocol.", blocked_role: "implementer" },
546-
},
547-
}, () => "blocked");
548-
const pending = (await readRunState(project.root, RUN_ID))!.notifications[0];
549-
const lifecycle = createCoreLifecycle({ uuid: () => "lifecycle", now: () => "2026-08-09T01:00:00.000Z" });
550-
const aborted = await lifecycle.abort(project.root, "Cancelled by user");
551-
assert.equal(aborted.phase, "aborted");
552-
assert.equal(aborted.abort_cleanup_complete, true);
553-
assert.equal(await readActiveRunId(project.root), RUN_ID);
554-
const resumed = await lifecycle.resume(project.root);
555-
assert.equal(resumed.phase, "aborted");
556-
assert.equal(resumed.notifications.find(({ event_id }) => event_id === pending.event_id)?.delivered_at, undefined);
557-
assert.equal(await readActiveRunId(project.root), RUN_ID);
558-
await lifecycle.acknowledgeNotification(project.root, pending.event_id);
559-
assert.equal(await readActiveRunId(project.root), undefined);
560-
});
561-
562524
test("terminal settlement retains an aborted lock without cleanup proof", async (t) => {
563525
const project = await setupProject(t);
564526
const graph = parseDeliveryGraph({ ...graphInput, issues: [graphInput.issues[2]] });
@@ -579,49 +541,7 @@ test("terminal settlement retains an aborted lock without cleanup proof", async
579541
assert.equal(await readActiveRunId(project.root), RUN_ID);
580542
});
581543

582-
test("terminal settlement releases a completed lock after acknowledgement persistence", async (t) => {
583-
const project = await setupProject(t);
584-
const graph = parseDeliveryGraph({ ...graphInput, issues: [graphInput.issues[2]] });
585-
const initial = createInitialRunState({
586-
run_id: RUN_ID,
587-
graph,
588-
source_commit: await git(project.root, "rev-parse", "HEAD"),
589-
main_worktree: project.root,
590-
integration_branch: "integration",
591-
default_branch: "main",
592-
created_at: "2026-08-09T00:00:00.000Z",
593-
main_pane: "main-pane",
594-
workspace_id: "main-workspace",
595-
});
596-
await createRun(project.root, initial, () => "create");
597-
await writeRunState(project.root, {
598-
...initial,
599-
phase: "completed",
600-
tasks: Object.fromEntries(Object.entries(initial.tasks).map(([id, task]) => [id, { status: "completed", attempts: task.attempts }])),
601-
pr: {
602-
number: 42,
603-
url: "https://example.test/pull/42",
604-
head_ref: "integration",
605-
base_ref: "main",
606-
head_oid: initial.integration_head,
607-
},
608-
}, () => "completed");
609-
const completed = (await readRunState(project.root, RUN_ID))!;
610-
await assert.rejects(createCoreLifecycle().abort(project.root, "Too late"), /Cannot abort a completed run/);
611-
assert.equal((await readRunState(project.root, RUN_ID))!.phase, "completed");
612-
await writeRunState(project.root, {
613-
...completed,
614-
notifications: completed.notifications.map((notification) => ({
615-
...notification,
616-
delivered_at: "2026-08-09T01:00:00.000Z",
617-
})),
618-
}, () => "delivered");
619-
assert.equal(await readActiveRunId(project.root), RUN_ID);
620-
await createCoreLifecycle().settleTerminal(project.root);
621-
assert.equal(await readActiveRunId(project.root), undefined);
622-
});
623-
624-
test("durable blocked/completed notifications retain stable IDs and release completion only after ack", async (t) => {
544+
test("durable blocked/completed notifications retain stable IDs and settle delivered completion", async (t) => {
625545
const project = await setupProject(t);
626546
const graph = parseDeliveryGraph({ ...graphInput, issues: [graphInput.issues[2]] });
627547
const initial = createInitialRunState({
@@ -703,10 +623,17 @@ test("durable blocked/completed notifications retain stable IDs and release comp
703623
const completedRoundTrip = (await readRunState(project.root, RUN_ID))!;
704624
assert.equal(completedRoundTrip.notifications.find(({ kind }) => kind === "completed")!.event_id, completedId);
705625
assert.equal(completedRoundTrip.notifications.length, 3);
706-
707-
const delivered = await lifecycle.acknowledgeNotification(project.root, completedId);
626+
await assert.rejects(lifecycle.abort(project.root, "Too late"), /Cannot abort a completed run/);
627+
assert.deepEqual(await readRunState(project.root, RUN_ID), completedRoundTrip);
628+
await writeRunState(project.root, {
629+
...completedRoundTrip,
630+
notifications: completedRoundTrip.notifications.map((notification) => notification.kind === "completed"
631+
? { ...notification, delivered_at: "2026-08-09T01:00:00.000Z" }
632+
: notification),
633+
}, () => "completed-delivered");
634+
assert.equal(await readActiveRunId(project.root), RUN_ID);
635+
await lifecycle.settleTerminal(project.root);
708636
assert.equal(await readActiveRunId(project.root), undefined);
709-
assert.deepEqual(await lifecycle.acknowledgeNotification(project.root, completedId), delivered);
710637
});
711638

712639
test("followUp delivery is fire-and-forget: failed or unacknowledged dispatch stays pending until explicit acknowledgement", async (t) => {
@@ -731,8 +658,15 @@ test("followUp delivery is fire-and-forget: failed or unacknowledged dispatch st
731658
tasks: { ...initial.tasks, api: { status: "blocked", attempts: 1, block_reason: "Choose a protocol.", blocked_role: "implementer" } },
732659
}, () => "blocked");
733660
const lifecycle = createCoreLifecycle({ uuid: () => "lifecycle", now: () => "2026-08-09T01:00:00.000Z" });
734-
await lifecycle.abort(project.root, "Cancelled by user");
735661
const eventId = (await readRunState(project.root, RUN_ID))!.notifications[0].event_id;
662+
const aborted = await lifecycle.abort(project.root, "Cancelled by user");
663+
assert.equal(aborted.phase, "aborted");
664+
assert.equal(aborted.abort_cleanup_complete, true);
665+
assert.equal(await readActiveRunId(project.root), RUN_ID);
666+
assert.equal((await lifecycle.resume(project.root)).phase, "aborted");
667+
const pending = (await readRunState(project.root, RUN_ID))!.notifications[0];
668+
assert.equal(pending.event_id, eventId);
669+
assert.equal(pending.delivered_at, undefined);
736670

737671
let failSend = true;
738672
const sent: string[] = [];

packages/pi-auto-dag/test/handoff.test.ts

Lines changed: 1 addition & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,7 @@ test("failed terminal delivery releases the prompted-turn latch for retry", asyn
177177
const tool = tools.find((candidate) => candidate.name === WORKER_TOOLS.request_review)!;
178178

179179
await assert.rejects(tool.execute("first", { summary: "finished" }), /acceptance receipt/);
180+
await assert.rejects(readFile(action.value.receipt_path), /ENOENT/);
180181
const result = await tool.execute("retry", { summary: "finished" });
181182

182183
assert.equal(result.details.status, "accepted");
@@ -337,27 +338,6 @@ test("worker redelivers an active rejected ticket for lifecycle recovery", async
337338
assert.deepEqual(deliveries, [action.value.event_id, replacement.event_id]);
338339
});
339340

340-
test("delivery without lifecycle acceptance does not report Sent", async (t) => {
341-
const root = await mkdtemp(join(tmpdir(), "pi-auto-dag-handoff-"));
342-
t.after(async () => await rm(root, { recursive: true, force: true }));
343-
const action = await ticket(root);
344-
const tools: Array<{ name: string; execute: Function }> = [];
345-
createWorkerExtension({
346-
environment: environment(action.path),
347-
deliveryAttempts: 1,
348-
delay: async () => {},
349-
runner: async (command) => command === "git"
350-
? { code: 0, stdout: `${HEAD}\n`, stderr: "" }
351-
: { code: 0, stdout: "", stderr: "" },
352-
})({ on() {}, registerTool(tool: { name: string; execute: Function }) { tools.push(tool); } } as never);
353-
354-
await assert.rejects(
355-
tools.find((tool) => tool.name === WORKER_TOOLS.request_review)!.execute("call", { summary: "finished" }),
356-
/acceptance receipt/,
357-
);
358-
await assert.rejects(readFile(action.value.receipt_path), /ENOENT/);
359-
});
360-
361341
test("worker requests compaction before high-context submission", async (t) => {
362342
const root = await mkdtemp(join(tmpdir(), "pi-auto-dag-handoff-"));
363343
t.after(async () => await rm(root, { recursive: true, force: true }));

packages/pi-auto-dag/test/pr-lifecycle.test.ts

Lines changed: 8 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,11 @@ test("the frozen final check prepares its disposable checkout and opens one exac
4343

4444
assert.equal(state.phase, "completed");
4545
assert.equal(state.tasks["final-check"].status, "completed");
46+
const notification = state.notifications[0];
47+
assert.equal(state.notifications.length, 1);
48+
assert.ok(notification?.kind === "completed");
49+
assert.equal(notification.delivered_at, undefined);
50+
assert.equal(notification.payload.pr.url, "https://example.test/pr/42");
4651
assert.equal(await readActiveRunId(project.root), RUN_ID);
4752
assert.equal(gh.pr?.headRefName, "dag");
4853
assert.equal(gh.pr?.baseRefName, "main");
@@ -60,43 +65,11 @@ test("the frozen final check prepares its disposable checkout and opens one exac
6065
.map((call) => JSON.parse(call.args[3]))
6166
.find((value) => value.type === "auto_dag_review" && value.kind === "final_check");
6267
await assertReviewPatch(finalPacket, project.root, state.source_commit, state.integration_head, { type: "integration_head" });
63-
});
64-
65-
test("completion notification retains the active lock until idempotent acknowledgement", async (t) => {
66-
const project = await makeProject(t);
67-
const lifecycle = makeLifecycle(combinedRunner(fakeHerdr(), fakeGh(project.root)));
68-
await finishInitialRun(project.root, lifecycle);
69-
const state = (await lifecycle.status(project.root, RUN_ID))!;
70-
const notification = state.notifications[0];
71-
72-
assert.equal(state.notifications.length, 1);
73-
assert.ok(notification?.kind === "completed");
74-
assert.equal(notification.delivered_at, undefined);
75-
assert.equal(notification.payload.pr.url, "https://example.test/pr/42");
76-
assert.equal(await readActiveRunId(project.root), RUN_ID);
7768
const acknowledged = await lifecycle.acknowledgeNotification(project.root, notification.event_id);
7869
assert.equal(await readActiveRunId(project.root), undefined);
7970
assert.deepEqual(await lifecycle.acknowledgeNotification(project.root, notification.event_id), acknowledged);
8071
});
8172

82-
test("a retained completed run resumes unfinished terminal cleanup without revalidating its PR", async (t) => {
83-
const project = await makeProject(t);
84-
const gh = fakeGh(project.root);
85-
const lifecycle = makeLifecycle(combinedRunner(fakeHerdr(), gh));
86-
await finishInitialRun(project.root, lifecycle);
87-
const completed = (await lifecycle.status(project.root, RUN_ID))!;
88-
89-
// Simulate an interrupted cleanup save plus a PR merged after completion.
90-
await writeRunState(project.root, replaceTask(completed, "alpha", { ...completed.tasks.alpha, branch_cleanup_done: undefined }), () => "interrupted");
91-
gh.state = "MERGED";
92-
const ghCalls = gh.calls.length;
93-
94-
const resumed = await lifecycle.resume(project.root);
95-
assert.equal(resumed.phase, "completed");
96-
assert.equal(resumed.tasks.alpha.branch_cleanup_done, true);
97-
assert.equal(gh.calls.length, ghCalls);
98-
});
99-
10073
test("a failed terminal cleanup keeps completion durable and retries after the PR merges", async (t) => {
10174
const project = await makeProject(t);
10275
const gh = fakeGh(project.root);
@@ -117,12 +90,14 @@ test("a failed terminal cleanup keeps completion durable and retries after the P
11790
await git(project.root, "branch", completed.tasks.alpha.branch!, completed.tasks.alpha.commit!);
11891
failBranchDelete = true;
11992
gh.state = "MERGED";
93+
let ghCalls = gh.calls.length;
12094
let resumed = await lifecycle.resume(project.root);
12195
assert.equal(resumed.phase, "completed");
12296
assert.equal(resumed.cleanup_blocks?.[0]?.operation, "branch");
97+
assert.equal(gh.calls.length, ghCalls);
12398

12499
failBranchDelete = false;
125-
const ghCalls = gh.calls.length;
100+
ghCalls = gh.calls.length;
126101
resumed = await lifecycle.resume(project.root);
127102
assert.equal(resumed.phase, "completed");
128103
assert.equal(resumed.cleanup_blocks, undefined);

packages/pi-auto-dag/test/run.mjs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ if (!files.length || files.some((file) => !existsSync(file))) {
1111
throw new Error(`Unknown pi-auto-dag test suite: ${suite ?? "(none)"}`);
1212
}
1313

14-
const child = spawn(process.execPath, ["--test", ...files], { stdio: "inherit" });
14+
const child = spawn(process.execPath, ["--test", "--test-concurrency=2", ...files], { stdio: "inherit" });
1515
child.on("exit", (code) => {
1616
process.exitCode = code ?? 1;
1717
});

packages/pi-subagent/test/subagent.test.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1843,16 +1843,20 @@ Do bounded work.
18431843
`);
18441844
const runner = join(agentDir, "fake-pi.mjs");
18451845
await writeFile(runner, `const event = (value) => console.log(JSON.stringify(value));
1846-
setTimeout(() => event({ type: "tool_execution_start", toolCallId: "tool-1", toolName: "bash", args: {} }), 80);
1846+
setTimeout(() => event({ type: "tool_execution_start", toolCallId: "tool-1", toolName: "bash", args: {} }), 200);
1847+
setTimeout(() => event({ type: "message_update", usage: { totalTokens: 1 } }), 600);
18471848
setTimeout(() => {
18481849
event({ type: "tool_execution_end", toolCallId: "tool-1", toolName: "bash", result: {}, isError: false });
18491850
event({ type: "message_end", message: { role: "assistant", content: [{ type: "text", text: "done" }], stopReason: "end" } });
1850-
}, 180);
1851+
}, 1_000);
18511852
`);
18521853
process.argv[1] = runner;
1853-
const app = harness({ timeoutPolicy: { idleMs: 120, maxMs: 270 } });
1854+
const timeoutPolicy = { idleMs: 800, maxMs: 1_500 };
1855+
const app = harness({ timeoutPolicy });
1856+
const startedAt = Date.now();
18541857
const result = await app.tool.execute("call-1", { role: "worker", task: "work" }, undefined, undefined, app.ctx);
18551858
assert.equal(singleOutput(result), "done");
1859+
assert.ok(Date.now() - startedAt > timeoutPolicy.idleMs, "completion did not outlast the original idle deadline");
18561860
});
18571861
});
18581862

@@ -1866,7 +1870,7 @@ test("workflow transport retains executor rejection Usage when no child result e
18661870
const runner = join(agentDir, "fake-pi.mjs");
18671871
await writeFile(runner, `console.log(JSON.stringify({ type: "message_update", usage: ${JSON.stringify(observedUsage)} })); setInterval(() => {}, 1_000);`);
18681872
process.argv[1] = runner;
1869-
const app = harness({ timeoutPolicy: { idleMs: 40, maxMs: 100 } });
1873+
const app = harness({ timeoutPolicy: { idleMs: 500, maxMs: 800 } });
18701874
const error = await app.tool.execute("usage-rejection", { role: "worker", task: "work" }, undefined, undefined, app.ctx).then(
18711875
() => assert.fail("expected workflow failure"),
18721876
(reason) => reason,

scripts/test-workspaces.mjs

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import { spawn, spawnSync } from "node:child_process";
2+
3+
const npmExecPath = process.env.npm_execpath;
4+
if (!npmExecPath) throw new Error("npm_execpath is required to run workspace tests.");
5+
6+
const concurrency = 2;
7+
const testArgs = process.argv.slice(2);
8+
9+
const discovery = spawnSync(process.execPath, [npmExecPath, "pkg", "get", "name", "scripts.test", "--workspaces", "--json"], {
10+
encoding: "utf8",
11+
stdio: ["ignore", "pipe", "inherit"],
12+
});
13+
14+
if (discovery.error) throw discovery.error;
15+
if (discovery.status !== 0) process.exit(discovery.status ?? 1);
16+
17+
const manifests = JSON.parse(discovery.stdout);
18+
if (!manifests || typeof manifests !== "object" || Array.isArray(manifests)) {
19+
throw new Error("npm did not return workspace package metadata.");
20+
}
21+
22+
const workspaces = Object.entries(manifests)
23+
.filter(([, manifest]) => manifest && typeof manifest === "object" && Object.hasOwn(manifest, "scripts.test"))
24+
.map(([workspace]) => workspace);
25+
26+
if (!workspaces.length) throw new Error("No workspace test scripts found.");
27+
28+
const prioritizedWorkspaces = ["@henryqw/pi-auto-dag", "@henryqw/pi-subagent"];
29+
const scheduledWorkspaces = [
30+
...prioritizedWorkspaces.filter((workspace) => workspaces.includes(workspace)),
31+
...workspaces.filter((workspace) => !prioritizedWorkspaces.includes(workspace)),
32+
];
33+
const failures = [];
34+
35+
function run(workspace) {
36+
return new Promise((resolve) => {
37+
const args = ["run", "test", "--workspace", workspace];
38+
if (workspace === "@henryqw/pi-subagent") args.push("--", "--test-concurrency=2", ...testArgs);
39+
else if (testArgs.length) args.push("--", ...testArgs);
40+
const child = spawn(process.execPath, [npmExecPath, ...args], { stdio: "inherit" });
41+
child.once("error", (error) => {
42+
console.error(`Failed to start tests for ${workspace}: ${error.message}`);
43+
resolve(1);
44+
});
45+
child.once("exit", (code) => resolve(code ?? 1));
46+
});
47+
}
48+
49+
let next = 0;
50+
async function worker() {
51+
while (next < scheduledWorkspaces.length) {
52+
const workspace = scheduledWorkspaces[next++];
53+
const code = await run(workspace);
54+
if (code !== 0) failures.push({ workspace, code });
55+
}
56+
}
57+
58+
await Promise.all(Array.from({ length: Math.min(concurrency, scheduledWorkspaces.length) }, worker));
59+
60+
if (failures.length) {
61+
for (const { workspace, code } of failures) {
62+
console.error(`Workspace test failed: ${workspace} (exit ${code})`);
63+
}
64+
process.exitCode = 1;
65+
}

0 commit comments

Comments
 (0)