gsd-pi 2.23.0 → 2.24.0
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/dist/cli.js +12 -3
- package/dist/headless.d.ts +4 -0
- package/dist/headless.js +118 -10
- package/dist/help-text.js +22 -7
- package/dist/resource-loader.js +64 -9
- package/dist/resources/extensions/gsd/auto-dispatch.ts +51 -2
- package/dist/resources/extensions/gsd/auto-prompts.ts +73 -0
- package/dist/resources/extensions/gsd/auto-recovery.ts +41 -2
- package/dist/resources/extensions/gsd/auto-worktree.ts +15 -3
- package/dist/resources/extensions/gsd/auto.ts +123 -41
- package/dist/resources/extensions/gsd/commands.ts +176 -10
- package/dist/resources/extensions/gsd/complexity.ts +1 -0
- package/dist/resources/extensions/gsd/dashboard-overlay.ts +38 -0
- package/dist/resources/extensions/gsd/doctor.ts +56 -11
- package/dist/resources/extensions/gsd/exit-command.ts +2 -2
- package/dist/resources/extensions/gsd/gitignore.ts +1 -0
- package/dist/resources/extensions/gsd/guided-flow.ts +75 -0
- package/dist/resources/extensions/gsd/index.ts +34 -1
- package/dist/resources/extensions/gsd/parallel-eligibility.ts +233 -0
- package/dist/resources/extensions/gsd/parallel-merge.ts +156 -0
- package/dist/resources/extensions/gsd/parallel-orchestrator.ts +496 -0
- package/dist/resources/extensions/gsd/preferences.ts +65 -1
- package/dist/resources/extensions/gsd/prompts/discuss-headless.md +86 -0
- package/dist/resources/extensions/gsd/prompts/research-slice.md +1 -1
- package/dist/resources/extensions/gsd/prompts/validate-milestone.md +40 -61
- package/dist/resources/extensions/gsd/provider-error-pause.ts +29 -2
- package/dist/resources/extensions/gsd/session-status-io.ts +197 -0
- package/dist/resources/extensions/gsd/state.ts +72 -30
- package/dist/resources/extensions/gsd/tests/agent-end-provider-error.test.ts +81 -0
- package/dist/resources/extensions/gsd/tests/auto-budget-alerts.test.ts +20 -3
- package/dist/resources/extensions/gsd/tests/auto-preflight.test.ts +1 -0
- package/dist/resources/extensions/gsd/tests/auto-recovery.test.ts +202 -2
- package/dist/resources/extensions/gsd/tests/auto-worktree-milestone-merge.test.ts +34 -0
- package/dist/resources/extensions/gsd/tests/complete-milestone.test.ts +8 -1
- package/dist/resources/extensions/gsd/tests/derive-state-db.test.ts +9 -15
- package/dist/resources/extensions/gsd/tests/derive-state-deps.test.ts +9 -0
- package/dist/resources/extensions/gsd/tests/derive-state-draft.test.ts +8 -0
- package/dist/resources/extensions/gsd/tests/derive-state.test.ts +14 -0
- package/dist/resources/extensions/gsd/tests/integration-mixed-milestones.test.ts +8 -0
- package/dist/resources/extensions/gsd/tests/migrate-writer-integration.test.ts +5 -5
- package/dist/resources/extensions/gsd/tests/parallel-orchestration.test.ts +656 -0
- package/dist/resources/extensions/gsd/tests/parallel-workers-multi-milestone-e2e.test.ts +354 -0
- package/dist/resources/extensions/gsd/tests/queue-reorder-e2e.test.ts +1 -0
- package/dist/resources/extensions/gsd/tests/validate-milestone.test.ts +316 -0
- package/dist/resources/extensions/gsd/tests/worker-registry.test.ts +148 -0
- package/dist/resources/extensions/gsd/types.ts +15 -1
- package/dist/resources/extensions/subagent/index.ts +5 -0
- package/dist/resources/extensions/subagent/worker-registry.ts +99 -0
- package/dist/update-check.d.ts +9 -0
- package/dist/update-check.js +97 -0
- package/package.json +6 -1
- package/packages/pi-ai/dist/providers/anthropic.d.ts.map +1 -1
- package/packages/pi-ai/dist/providers/anthropic.js +16 -7
- package/packages/pi-ai/dist/providers/anthropic.js.map +1 -1
- package/packages/pi-ai/dist/providers/azure-openai-responses.d.ts.map +1 -1
- package/packages/pi-ai/dist/providers/azure-openai-responses.js +12 -4
- package/packages/pi-ai/dist/providers/azure-openai-responses.js.map +1 -1
- package/packages/pi-ai/dist/providers/google-vertex.d.ts.map +1 -1
- package/packages/pi-ai/dist/providers/google-vertex.js +21 -9
- package/packages/pi-ai/dist/providers/google-vertex.js.map +1 -1
- package/packages/pi-ai/dist/providers/openai-completions.d.ts.map +1 -1
- package/packages/pi-ai/dist/providers/openai-completions.js +12 -4
- package/packages/pi-ai/dist/providers/openai-completions.js.map +1 -1
- package/packages/pi-ai/dist/providers/openai-responses.d.ts.map +1 -1
- package/packages/pi-ai/dist/providers/openai-responses.js +12 -4
- package/packages/pi-ai/dist/providers/openai-responses.js.map +1 -1
- package/packages/pi-ai/src/providers/anthropic.ts +21 -8
- package/packages/pi-ai/src/providers/azure-openai-responses.ts +16 -4
- package/packages/pi-ai/src/providers/google-vertex.ts +32 -17
- package/packages/pi-ai/src/providers/openai-completions.ts +16 -4
- package/packages/pi-ai/src/providers/openai-responses.ts +16 -4
- package/packages/pi-coding-agent/dist/core/agent-session.js +1 -1
- package/packages/pi-coding-agent/dist/core/agent-session.js.map +1 -1
- package/packages/pi-coding-agent/dist/core/settings-manager.js +1 -1
- package/packages/pi-coding-agent/dist/core/settings-manager.js.map +1 -1
- package/packages/pi-coding-agent/src/core/agent-session.ts +1 -1
- package/packages/pi-coding-agent/src/core/settings-manager.ts +2 -2
- package/scripts/postinstall.js +7 -109
- package/src/resources/extensions/gsd/auto-dispatch.ts +51 -2
- package/src/resources/extensions/gsd/auto-prompts.ts +73 -0
- package/src/resources/extensions/gsd/auto-recovery.ts +41 -2
- package/src/resources/extensions/gsd/auto-worktree.ts +15 -3
- package/src/resources/extensions/gsd/auto.ts +123 -41
- package/src/resources/extensions/gsd/commands.ts +176 -10
- package/src/resources/extensions/gsd/complexity.ts +1 -0
- package/src/resources/extensions/gsd/dashboard-overlay.ts +38 -0
- package/src/resources/extensions/gsd/doctor.ts +56 -11
- package/src/resources/extensions/gsd/exit-command.ts +2 -2
- package/src/resources/extensions/gsd/gitignore.ts +1 -0
- package/src/resources/extensions/gsd/guided-flow.ts +75 -0
- package/src/resources/extensions/gsd/index.ts +34 -1
- package/src/resources/extensions/gsd/parallel-eligibility.ts +233 -0
- package/src/resources/extensions/gsd/parallel-merge.ts +156 -0
- package/src/resources/extensions/gsd/parallel-orchestrator.ts +496 -0
- package/src/resources/extensions/gsd/preferences.ts +65 -1
- package/src/resources/extensions/gsd/prompts/discuss-headless.md +86 -0
- package/src/resources/extensions/gsd/prompts/research-slice.md +1 -1
- package/src/resources/extensions/gsd/prompts/validate-milestone.md +40 -61
- package/src/resources/extensions/gsd/provider-error-pause.ts +29 -2
- package/src/resources/extensions/gsd/session-status-io.ts +197 -0
- package/src/resources/extensions/gsd/state.ts +72 -30
- package/src/resources/extensions/gsd/tests/agent-end-provider-error.test.ts +81 -0
- package/src/resources/extensions/gsd/tests/auto-budget-alerts.test.ts +20 -3
- package/src/resources/extensions/gsd/tests/auto-preflight.test.ts +1 -0
- package/src/resources/extensions/gsd/tests/auto-recovery.test.ts +202 -2
- package/src/resources/extensions/gsd/tests/auto-worktree-milestone-merge.test.ts +34 -0
- package/src/resources/extensions/gsd/tests/complete-milestone.test.ts +8 -1
- package/src/resources/extensions/gsd/tests/derive-state-db.test.ts +9 -15
- package/src/resources/extensions/gsd/tests/derive-state-deps.test.ts +9 -0
- package/src/resources/extensions/gsd/tests/derive-state-draft.test.ts +8 -0
- package/src/resources/extensions/gsd/tests/derive-state.test.ts +14 -0
- package/src/resources/extensions/gsd/tests/integration-mixed-milestones.test.ts +8 -0
- package/src/resources/extensions/gsd/tests/migrate-writer-integration.test.ts +5 -5
- package/src/resources/extensions/gsd/tests/parallel-orchestration.test.ts +656 -0
- package/src/resources/extensions/gsd/tests/parallel-workers-multi-milestone-e2e.test.ts +354 -0
- package/src/resources/extensions/gsd/tests/queue-reorder-e2e.test.ts +1 -0
- package/src/resources/extensions/gsd/tests/validate-milestone.test.ts +316 -0
- package/src/resources/extensions/gsd/tests/worker-registry.test.ts +148 -0
- package/src/resources/extensions/gsd/types.ts +15 -1
- package/src/resources/extensions/subagent/index.ts +5 -0
- package/src/resources/extensions/subagent/worker-registry.ts +99 -0
|
@@ -0,0 +1,656 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for parallel milestone orchestration modules:
|
|
3
|
+
* - session-status-io.ts (file-based IPC)
|
|
4
|
+
* - parallel-eligibility.ts (eligibility formatting)
|
|
5
|
+
* - parallel-orchestrator.ts (orchestrator lifecycle)
|
|
6
|
+
* - preferences.ts (parallel config validation)
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { describe, it, beforeEach, afterEach } from "node:test";
|
|
10
|
+
import assert from "node:assert/strict";
|
|
11
|
+
import { mkdtempSync, mkdirSync, rmSync } from "node:fs";
|
|
12
|
+
import { tmpdir } from "node:os";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
|
|
15
|
+
import {
|
|
16
|
+
writeSessionStatus,
|
|
17
|
+
readSessionStatus,
|
|
18
|
+
readAllSessionStatuses,
|
|
19
|
+
removeSessionStatus,
|
|
20
|
+
sendSignal,
|
|
21
|
+
consumeSignal,
|
|
22
|
+
isSessionStale,
|
|
23
|
+
cleanupStaleSessions,
|
|
24
|
+
type SessionStatus,
|
|
25
|
+
} from "../session-status-io.js";
|
|
26
|
+
|
|
27
|
+
import {
|
|
28
|
+
formatEligibilityReport,
|
|
29
|
+
type ParallelCandidates,
|
|
30
|
+
} from "../parallel-eligibility.js";
|
|
31
|
+
|
|
32
|
+
import {
|
|
33
|
+
isParallelActive,
|
|
34
|
+
getOrchestratorState,
|
|
35
|
+
getWorkerStatuses,
|
|
36
|
+
startParallel,
|
|
37
|
+
stopParallel,
|
|
38
|
+
pauseWorker,
|
|
39
|
+
resumeWorker,
|
|
40
|
+
getAggregateCost,
|
|
41
|
+
isBudgetExceeded,
|
|
42
|
+
resetOrchestrator,
|
|
43
|
+
} from "../parallel-orchestrator.js";
|
|
44
|
+
|
|
45
|
+
import { validatePreferences, resolveParallelConfig } from "../preferences.js";
|
|
46
|
+
|
|
47
|
+
import { determineMergeOrder, formatMergeResults, type MergeResult } from "../parallel-merge.js";
|
|
48
|
+
import type { WorkerInfo } from "../parallel-orchestrator.js";
|
|
49
|
+
|
|
50
|
+
// ─── Test Helpers ────────────────────────────────────────────────────────────
|
|
51
|
+
|
|
52
|
+
function makeTmpBase(): string {
|
|
53
|
+
const base = mkdtempSync(join(tmpdir(), "gsd-parallel-test-"));
|
|
54
|
+
mkdirSync(join(base, ".gsd"), { recursive: true });
|
|
55
|
+
return base;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function makeStatus(overrides: Partial<SessionStatus> = {}): SessionStatus {
|
|
59
|
+
return {
|
|
60
|
+
milestoneId: "M001",
|
|
61
|
+
pid: process.pid,
|
|
62
|
+
state: "running",
|
|
63
|
+
currentUnit: { type: "execute-task", id: "M001/S01/T01", startedAt: Date.now() },
|
|
64
|
+
completedUnits: 3,
|
|
65
|
+
cost: 1.50,
|
|
66
|
+
lastHeartbeat: Date.now(),
|
|
67
|
+
startedAt: Date.now() - 60_000,
|
|
68
|
+
worktreePath: "/tmp/test-worktree",
|
|
69
|
+
...overrides,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ─── session-status-io ───────────────────────────────────────────────────────
|
|
74
|
+
|
|
75
|
+
describe("session-status-io: status roundtrip", () => {
|
|
76
|
+
let base: string;
|
|
77
|
+
beforeEach(() => { base = makeTmpBase(); });
|
|
78
|
+
afterEach(() => { rmSync(base, { recursive: true, force: true }); });
|
|
79
|
+
|
|
80
|
+
it("write then read returns identical status", () => {
|
|
81
|
+
const status = makeStatus();
|
|
82
|
+
writeSessionStatus(base, status);
|
|
83
|
+
const read = readSessionStatus(base, "M001");
|
|
84
|
+
assert.ok(read);
|
|
85
|
+
assert.equal(read.milestoneId, "M001");
|
|
86
|
+
assert.equal(read.pid, process.pid);
|
|
87
|
+
assert.equal(read.state, "running");
|
|
88
|
+
assert.equal(read.completedUnits, 3);
|
|
89
|
+
assert.equal(read.cost, 1.50);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("readSessionStatus returns null for missing milestone", () => {
|
|
93
|
+
const read = readSessionStatus(base, "M999");
|
|
94
|
+
assert.equal(read, null);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("readAllSessionStatuses returns all written statuses", () => {
|
|
98
|
+
writeSessionStatus(base, makeStatus({ milestoneId: "M001" }));
|
|
99
|
+
writeSessionStatus(base, makeStatus({ milestoneId: "M002" }));
|
|
100
|
+
writeSessionStatus(base, makeStatus({ milestoneId: "M003" }));
|
|
101
|
+
const all = readAllSessionStatuses(base);
|
|
102
|
+
assert.equal(all.length, 3);
|
|
103
|
+
const ids = all.map(s => s.milestoneId).sort();
|
|
104
|
+
assert.deepEqual(ids, ["M001", "M002", "M003"]);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("readAllSessionStatuses returns empty array when no parallel dir", () => {
|
|
108
|
+
const all = readAllSessionStatuses(base);
|
|
109
|
+
assert.equal(all.length, 0);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("removeSessionStatus deletes the file", () => {
|
|
113
|
+
writeSessionStatus(base, makeStatus());
|
|
114
|
+
assert.ok(readSessionStatus(base, "M001"));
|
|
115
|
+
removeSessionStatus(base, "M001");
|
|
116
|
+
assert.equal(readSessionStatus(base, "M001"), null);
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
describe("session-status-io: signal roundtrip", () => {
|
|
121
|
+
let base: string;
|
|
122
|
+
beforeEach(() => { base = makeTmpBase(); });
|
|
123
|
+
afterEach(() => { rmSync(base, { recursive: true, force: true }); });
|
|
124
|
+
|
|
125
|
+
it("sendSignal then consumeSignal returns the signal", () => {
|
|
126
|
+
sendSignal(base, "M001", "pause");
|
|
127
|
+
const signal = consumeSignal(base, "M001");
|
|
128
|
+
assert.ok(signal);
|
|
129
|
+
assert.equal(signal.signal, "pause");
|
|
130
|
+
assert.equal(signal.from, "coordinator");
|
|
131
|
+
assert.ok(signal.sentAt > 0);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("consumeSignal removes the signal file", () => {
|
|
135
|
+
sendSignal(base, "M001", "stop");
|
|
136
|
+
consumeSignal(base, "M001");
|
|
137
|
+
const second = consumeSignal(base, "M001");
|
|
138
|
+
assert.equal(second, null);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("consumeSignal returns null when no signal pending", () => {
|
|
142
|
+
assert.equal(consumeSignal(base, "M001"), null);
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
describe("session-status-io: stale detection", () => {
|
|
147
|
+
it("isSessionStale returns false for current process PID", () => {
|
|
148
|
+
const status = makeStatus({ pid: process.pid, lastHeartbeat: Date.now() });
|
|
149
|
+
assert.equal(isSessionStale(status), false);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("isSessionStale returns true for dead PID", () => {
|
|
153
|
+
// PID 2147483647 is extremely unlikely to be alive
|
|
154
|
+
const status = makeStatus({ pid: 2147483647, lastHeartbeat: Date.now() });
|
|
155
|
+
assert.equal(isSessionStale(status), true);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("isSessionStale returns true for expired heartbeat", () => {
|
|
159
|
+
const status = makeStatus({
|
|
160
|
+
pid: process.pid,
|
|
161
|
+
lastHeartbeat: Date.now() - 60_000,
|
|
162
|
+
});
|
|
163
|
+
assert.equal(isSessionStale(status, 5_000), true);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it("isSessionStale returns false for recent heartbeat with alive PID", () => {
|
|
167
|
+
const status = makeStatus({
|
|
168
|
+
pid: process.pid,
|
|
169
|
+
lastHeartbeat: Date.now(),
|
|
170
|
+
});
|
|
171
|
+
assert.equal(isSessionStale(status, 30_000), false);
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
describe("session-status-io: cleanupStaleSessions", () => {
|
|
176
|
+
let base: string;
|
|
177
|
+
beforeEach(() => { base = makeTmpBase(); });
|
|
178
|
+
afterEach(() => { rmSync(base, { recursive: true, force: true }); });
|
|
179
|
+
|
|
180
|
+
it("removes stale sessions and returns their IDs", () => {
|
|
181
|
+
// Write a stale session (dead PID)
|
|
182
|
+
writeSessionStatus(base, makeStatus({
|
|
183
|
+
milestoneId: "M001",
|
|
184
|
+
pid: 2147483647,
|
|
185
|
+
}));
|
|
186
|
+
// Write a live session
|
|
187
|
+
writeSessionStatus(base, makeStatus({
|
|
188
|
+
milestoneId: "M002",
|
|
189
|
+
pid: process.pid,
|
|
190
|
+
lastHeartbeat: Date.now(),
|
|
191
|
+
}));
|
|
192
|
+
|
|
193
|
+
const removed = cleanupStaleSessions(base);
|
|
194
|
+
assert.deepEqual(removed, ["M001"]);
|
|
195
|
+
assert.equal(readSessionStatus(base, "M001"), null);
|
|
196
|
+
assert.ok(readSessionStatus(base, "M002"));
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
// ─── parallel-eligibility ────────────────────────────────────────────────────
|
|
201
|
+
|
|
202
|
+
describe("parallel-eligibility: formatEligibilityReport", () => {
|
|
203
|
+
it("formats empty candidates", () => {
|
|
204
|
+
const candidates: ParallelCandidates = {
|
|
205
|
+
eligible: [],
|
|
206
|
+
ineligible: [],
|
|
207
|
+
fileOverlaps: [],
|
|
208
|
+
};
|
|
209
|
+
const report = formatEligibilityReport(candidates);
|
|
210
|
+
assert.ok(report.includes("Eligible for Parallel Execution (0)"));
|
|
211
|
+
assert.ok(report.includes("No milestones are currently eligible"));
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it("formats eligible milestones", () => {
|
|
215
|
+
const candidates: ParallelCandidates = {
|
|
216
|
+
eligible: [
|
|
217
|
+
{ milestoneId: "M001", title: "Auth System", eligible: true, reason: "All dependencies satisfied." },
|
|
218
|
+
{ milestoneId: "M002", title: "Dashboard", eligible: true, reason: "All dependencies satisfied." },
|
|
219
|
+
],
|
|
220
|
+
ineligible: [],
|
|
221
|
+
fileOverlaps: [],
|
|
222
|
+
};
|
|
223
|
+
const report = formatEligibilityReport(candidates);
|
|
224
|
+
assert.ok(report.includes("Eligible for Parallel Execution (2)"));
|
|
225
|
+
assert.ok(report.includes("**M001** — Auth System"));
|
|
226
|
+
assert.ok(report.includes("**M002** — Dashboard"));
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it("formats ineligible milestones with reasons", () => {
|
|
230
|
+
const candidates: ParallelCandidates = {
|
|
231
|
+
eligible: [],
|
|
232
|
+
ineligible: [
|
|
233
|
+
{ milestoneId: "M003", title: "API", eligible: false, reason: "Blocked by incomplete dependencies: M001." },
|
|
234
|
+
],
|
|
235
|
+
fileOverlaps: [],
|
|
236
|
+
};
|
|
237
|
+
const report = formatEligibilityReport(candidates);
|
|
238
|
+
assert.ok(report.includes("Ineligible (1)"));
|
|
239
|
+
assert.ok(report.includes("Blocked by incomplete dependencies"));
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it("formats file overlap warnings", () => {
|
|
243
|
+
const candidates: ParallelCandidates = {
|
|
244
|
+
eligible: [
|
|
245
|
+
{ milestoneId: "M001", title: "Auth", eligible: true, reason: "OK" },
|
|
246
|
+
{ milestoneId: "M002", title: "API", eligible: true, reason: "OK" },
|
|
247
|
+
],
|
|
248
|
+
ineligible: [],
|
|
249
|
+
fileOverlaps: [
|
|
250
|
+
{ mid1: "M001", mid2: "M002", files: ["src/types.ts", "src/utils.ts"] },
|
|
251
|
+
],
|
|
252
|
+
};
|
|
253
|
+
const report = formatEligibilityReport(candidates);
|
|
254
|
+
assert.ok(report.includes("File Overlap Warnings (1)"));
|
|
255
|
+
assert.ok(report.includes("`src/types.ts`"));
|
|
256
|
+
assert.ok(report.includes("`src/utils.ts`"));
|
|
257
|
+
});
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
// ─── parallel-orchestrator ───────────────────────────────────────────────────
|
|
261
|
+
|
|
262
|
+
describe("parallel-orchestrator: lifecycle", () => {
|
|
263
|
+
let base: string;
|
|
264
|
+
beforeEach(() => {
|
|
265
|
+
base = makeTmpBase();
|
|
266
|
+
resetOrchestrator();
|
|
267
|
+
});
|
|
268
|
+
afterEach(() => {
|
|
269
|
+
resetOrchestrator();
|
|
270
|
+
rmSync(base, { recursive: true, force: true });
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
it("isParallelActive returns false initially", () => {
|
|
274
|
+
assert.equal(isParallelActive(), false);
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it("getOrchestratorState returns null initially", () => {
|
|
278
|
+
assert.equal(getOrchestratorState(), null);
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
it("startParallel initializes orchestrator state", async () => {
|
|
282
|
+
const result = await startParallel(base, ["M001", "M002"], {
|
|
283
|
+
parallel: { enabled: true, max_workers: 4, merge_strategy: "per-milestone", auto_merge: "confirm" },
|
|
284
|
+
});
|
|
285
|
+
assert.deepEqual(result.started, ["M001", "M002"]);
|
|
286
|
+
assert.equal(result.errors.length, 0);
|
|
287
|
+
assert.equal(isParallelActive(), true);
|
|
288
|
+
assert.equal(getWorkerStatuses().length, 2);
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
it("startParallel caps to max_workers", async () => {
|
|
292
|
+
const result = await startParallel(base, ["M001", "M002", "M003", "M004"], {
|
|
293
|
+
parallel: { enabled: true, max_workers: 2, merge_strategy: "per-milestone", auto_merge: "confirm" },
|
|
294
|
+
});
|
|
295
|
+
assert.deepEqual(result.started, ["M001", "M002"]);
|
|
296
|
+
assert.equal(getWorkerStatuses().length, 2);
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
it("startParallel writes session status files", async () => {
|
|
300
|
+
await startParallel(base, ["M001"], undefined);
|
|
301
|
+
const status = readSessionStatus(base, "M001");
|
|
302
|
+
assert.ok(status);
|
|
303
|
+
assert.equal(status.milestoneId, "M001");
|
|
304
|
+
assert.equal(status.state, "running");
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
it("stopParallel stops all workers", async () => {
|
|
308
|
+
await startParallel(base, ["M001", "M002"], undefined);
|
|
309
|
+
await stopParallel(base);
|
|
310
|
+
assert.equal(isParallelActive(), false);
|
|
311
|
+
const workers = getWorkerStatuses();
|
|
312
|
+
assert.ok(workers.every(w => w.state === "stopped"));
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
it("stopParallel stops a specific worker", async () => {
|
|
316
|
+
await startParallel(base, ["M001", "M002"], undefined);
|
|
317
|
+
await stopParallel(base, "M001");
|
|
318
|
+
const workers = getWorkerStatuses();
|
|
319
|
+
const m1 = workers.find(w => w.milestoneId === "M001");
|
|
320
|
+
const m2 = workers.find(w => w.milestoneId === "M002");
|
|
321
|
+
assert.equal(m1?.state, "stopped");
|
|
322
|
+
assert.equal(m2?.state, "running");
|
|
323
|
+
assert.equal(isParallelActive(), true);
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
it("pauseWorker and resumeWorker toggle worker state", async () => {
|
|
327
|
+
await startParallel(base, ["M001"], undefined);
|
|
328
|
+
pauseWorker(base, "M001");
|
|
329
|
+
assert.equal(getWorkerStatuses()[0].state, "paused");
|
|
330
|
+
resumeWorker(base, "M001");
|
|
331
|
+
assert.equal(getWorkerStatuses()[0].state, "running");
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
it("pauseWorker sends pause signal", async () => {
|
|
335
|
+
await startParallel(base, ["M001"], undefined);
|
|
336
|
+
pauseWorker(base, "M001");
|
|
337
|
+
const signal = consumeSignal(base, "M001");
|
|
338
|
+
assert.ok(signal);
|
|
339
|
+
assert.equal(signal.signal, "pause");
|
|
340
|
+
});
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
describe("parallel-orchestrator: budget", () => {
|
|
344
|
+
beforeEach(() => { resetOrchestrator(); });
|
|
345
|
+
afterEach(() => { resetOrchestrator(); });
|
|
346
|
+
|
|
347
|
+
it("getAggregateCost returns 0 when not active", () => {
|
|
348
|
+
assert.equal(getAggregateCost(), 0);
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
it("isBudgetExceeded returns false when not active", () => {
|
|
352
|
+
assert.equal(isBudgetExceeded(), false);
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
it("isBudgetExceeded returns false when no ceiling set", async () => {
|
|
356
|
+
const base = makeTmpBase();
|
|
357
|
+
await startParallel(base, ["M001"], undefined);
|
|
358
|
+
assert.equal(isBudgetExceeded(), false);
|
|
359
|
+
resetOrchestrator();
|
|
360
|
+
rmSync(base, { recursive: true, force: true });
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
it("isBudgetExceeded returns true when ceiling reached", async () => {
|
|
364
|
+
const base = makeTmpBase();
|
|
365
|
+
await startParallel(base, ["M001"], {
|
|
366
|
+
parallel: { enabled: true, max_workers: 2, budget_ceiling: 1.00, merge_strategy: "per-milestone", auto_merge: "confirm" },
|
|
367
|
+
});
|
|
368
|
+
// Manually set totalCost to test budget check
|
|
369
|
+
const orchState = getOrchestratorState();
|
|
370
|
+
if (orchState) orchState.totalCost = 1.50;
|
|
371
|
+
assert.equal(isBudgetExceeded(), true);
|
|
372
|
+
resetOrchestrator();
|
|
373
|
+
rmSync(base, { recursive: true, force: true });
|
|
374
|
+
});
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
// ─── preferences: parallel config ────────────────────────────────────────────
|
|
378
|
+
|
|
379
|
+
describe("preferences: resolveParallelConfig", () => {
|
|
380
|
+
it("returns defaults when prefs is undefined", () => {
|
|
381
|
+
const config = resolveParallelConfig(undefined);
|
|
382
|
+
assert.equal(config.enabled, false);
|
|
383
|
+
assert.equal(config.max_workers, 2);
|
|
384
|
+
assert.equal(config.budget_ceiling, undefined);
|
|
385
|
+
assert.equal(config.merge_strategy, "per-milestone");
|
|
386
|
+
assert.equal(config.auto_merge, "confirm");
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
it("returns defaults when parallel is undefined", () => {
|
|
390
|
+
const config = resolveParallelConfig({});
|
|
391
|
+
assert.equal(config.enabled, false);
|
|
392
|
+
assert.equal(config.max_workers, 2);
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
it("fills in missing fields with defaults", () => {
|
|
396
|
+
const config = resolveParallelConfig({
|
|
397
|
+
parallel: { enabled: true } as any,
|
|
398
|
+
});
|
|
399
|
+
assert.equal(config.enabled, true);
|
|
400
|
+
assert.equal(config.max_workers, 2);
|
|
401
|
+
assert.equal(config.merge_strategy, "per-milestone");
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
it("clamps max_workers to 1-4 range", () => {
|
|
405
|
+
assert.equal(resolveParallelConfig({
|
|
406
|
+
parallel: { enabled: true, max_workers: 0, merge_strategy: "per-milestone", auto_merge: "confirm" },
|
|
407
|
+
}).max_workers, 1);
|
|
408
|
+
assert.equal(resolveParallelConfig({
|
|
409
|
+
parallel: { enabled: true, max_workers: 10, merge_strategy: "per-milestone", auto_merge: "confirm" },
|
|
410
|
+
}).max_workers, 4);
|
|
411
|
+
});
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
describe("preferences: validatePreferences parallel config", () => {
|
|
415
|
+
it("validates valid parallel config without errors", () => {
|
|
416
|
+
const result = validatePreferences({
|
|
417
|
+
parallel: {
|
|
418
|
+
enabled: true,
|
|
419
|
+
max_workers: 3,
|
|
420
|
+
budget_ceiling: 50.00,
|
|
421
|
+
merge_strategy: "per-slice",
|
|
422
|
+
auto_merge: "manual",
|
|
423
|
+
},
|
|
424
|
+
});
|
|
425
|
+
assert.equal(result.errors.length, 0);
|
|
426
|
+
assert.ok(result.preferences.parallel);
|
|
427
|
+
assert.equal(result.preferences.parallel?.enabled, true);
|
|
428
|
+
assert.equal(result.preferences.parallel?.max_workers, 3);
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
it("rejects invalid max_workers", () => {
|
|
432
|
+
const result = validatePreferences({
|
|
433
|
+
parallel: { max_workers: 10 } as any,
|
|
434
|
+
});
|
|
435
|
+
assert.ok(result.errors.some(e => e.includes("max_workers")));
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
it("rejects negative budget_ceiling", () => {
|
|
439
|
+
const result = validatePreferences({
|
|
440
|
+
parallel: { budget_ceiling: -5 } as any,
|
|
441
|
+
});
|
|
442
|
+
assert.ok(result.errors.some(e => e.includes("budget_ceiling")));
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
it("rejects invalid merge_strategy", () => {
|
|
446
|
+
const result = validatePreferences({
|
|
447
|
+
parallel: { merge_strategy: "invalid" } as any,
|
|
448
|
+
});
|
|
449
|
+
assert.ok(result.errors.some(e => e.includes("merge_strategy")));
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
it("rejects invalid auto_merge", () => {
|
|
453
|
+
const result = validatePreferences({
|
|
454
|
+
parallel: { auto_merge: "yolo" } as any,
|
|
455
|
+
});
|
|
456
|
+
assert.ok(result.errors.some(e => e.includes("auto_merge")));
|
|
457
|
+
});
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
// ─── Test Helpers (parallel-merge) ───────────────────────────────────────────
|
|
461
|
+
|
|
462
|
+
function makeWorker(overrides: Partial<WorkerInfo> = {}): WorkerInfo {
|
|
463
|
+
return {
|
|
464
|
+
milestoneId: "M001",
|
|
465
|
+
title: "Test Milestone",
|
|
466
|
+
pid: process.pid,
|
|
467
|
+
process: null,
|
|
468
|
+
worktreePath: "/tmp/test-worktree",
|
|
469
|
+
startedAt: Date.now() - 60_000,
|
|
470
|
+
state: "stopped",
|
|
471
|
+
completedUnits: 5,
|
|
472
|
+
cost: 2.50,
|
|
473
|
+
...overrides,
|
|
474
|
+
};
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
// ─── parallel-merge: determineMergeOrder ─────────────────────────────────────
|
|
478
|
+
|
|
479
|
+
describe("parallel-merge: determineMergeOrder sequential", () => {
|
|
480
|
+
it("returns milestone IDs sorted alphabetically by default", () => {
|
|
481
|
+
const workers = [
|
|
482
|
+
makeWorker({ milestoneId: "M003", state: "stopped", completedUnits: 1 }),
|
|
483
|
+
makeWorker({ milestoneId: "M001", state: "stopped", completedUnits: 2 }),
|
|
484
|
+
makeWorker({ milestoneId: "M002", state: "stopped", completedUnits: 3 }),
|
|
485
|
+
];
|
|
486
|
+
const order = determineMergeOrder(workers, "sequential");
|
|
487
|
+
assert.deepEqual(order, ["M001", "M002", "M003"]);
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
it("excludes workers that are still running", () => {
|
|
491
|
+
const workers = [
|
|
492
|
+
makeWorker({ milestoneId: "M001", state: "stopped", completedUnits: 5 }),
|
|
493
|
+
makeWorker({ milestoneId: "M002", state: "running", completedUnits: 0 }),
|
|
494
|
+
makeWorker({ milestoneId: "M003", state: "stopped", completedUnits: 2 }),
|
|
495
|
+
];
|
|
496
|
+
const order = determineMergeOrder(workers, "sequential");
|
|
497
|
+
assert.deepEqual(order, ["M001", "M003"]);
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
it("excludes workers with zero completedUnits even if stopped", () => {
|
|
501
|
+
const workers = [
|
|
502
|
+
makeWorker({ milestoneId: "M001", state: "stopped", completedUnits: 0 }),
|
|
503
|
+
makeWorker({ milestoneId: "M002", state: "stopped", completedUnits: 3 }),
|
|
504
|
+
];
|
|
505
|
+
const order = determineMergeOrder(workers, "sequential");
|
|
506
|
+
assert.deepEqual(order, ["M002"]);
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
it("returns empty array when no workers are completed", () => {
|
|
510
|
+
const workers = [
|
|
511
|
+
makeWorker({ milestoneId: "M001", state: "running", completedUnits: 0 }),
|
|
512
|
+
makeWorker({ milestoneId: "M002", state: "paused", completedUnits: 0 }),
|
|
513
|
+
];
|
|
514
|
+
const order = determineMergeOrder(workers);
|
|
515
|
+
assert.deepEqual(order, []);
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
it("uses sequential order as the default when no order arg provided", () => {
|
|
519
|
+
const workers = [
|
|
520
|
+
makeWorker({ milestoneId: "M002", state: "stopped", completedUnits: 1 }),
|
|
521
|
+
makeWorker({ milestoneId: "M001", state: "stopped", completedUnits: 1 }),
|
|
522
|
+
];
|
|
523
|
+
// Call with no second argument — should default to "sequential"
|
|
524
|
+
const order = determineMergeOrder(workers);
|
|
525
|
+
assert.deepEqual(order, ["M001", "M002"]);
|
|
526
|
+
});
|
|
527
|
+
});
|
|
528
|
+
|
|
529
|
+
describe("parallel-merge: determineMergeOrder by-completion", () => {
|
|
530
|
+
it("returns milestones sorted by startedAt (earliest first)", () => {
|
|
531
|
+
const now = Date.now();
|
|
532
|
+
const workers = [
|
|
533
|
+
makeWorker({ milestoneId: "M003", state: "stopped", completedUnits: 1, startedAt: now - 30_000 }),
|
|
534
|
+
makeWorker({ milestoneId: "M001", state: "stopped", completedUnits: 1, startedAt: now - 90_000 }),
|
|
535
|
+
makeWorker({ milestoneId: "M002", state: "stopped", completedUnits: 1, startedAt: now - 60_000 }),
|
|
536
|
+
];
|
|
537
|
+
const order = determineMergeOrder(workers, "by-completion");
|
|
538
|
+
assert.deepEqual(order, ["M001", "M002", "M003"]);
|
|
539
|
+
});
|
|
540
|
+
|
|
541
|
+
it("excludes paused workers from by-completion order", () => {
|
|
542
|
+
const now = Date.now();
|
|
543
|
+
const workers = [
|
|
544
|
+
makeWorker({ milestoneId: "M001", state: "stopped", completedUnits: 2, startedAt: now - 90_000 }),
|
|
545
|
+
makeWorker({ milestoneId: "M002", state: "paused", completedUnits: 1, startedAt: now - 60_000 }),
|
|
546
|
+
makeWorker({ milestoneId: "M003", state: "stopped", completedUnits: 3, startedAt: now - 30_000 }),
|
|
547
|
+
];
|
|
548
|
+
const order = determineMergeOrder(workers, "by-completion");
|
|
549
|
+
assert.deepEqual(order, ["M001", "M003"]);
|
|
550
|
+
});
|
|
551
|
+
});
|
|
552
|
+
|
|
553
|
+
// ─── parallel-merge: formatMergeResults ──────────────────────────────────────
|
|
554
|
+
|
|
555
|
+
describe("parallel-merge: formatMergeResults", () => {
|
|
556
|
+
it("returns a no-op message for an empty results array", () => {
|
|
557
|
+
const output = formatMergeResults([]);
|
|
558
|
+
assert.equal(output, "No completed milestones to merge.");
|
|
559
|
+
});
|
|
560
|
+
|
|
561
|
+
it("formats a single successful merge without push", () => {
|
|
562
|
+
const results: MergeResult[] = [
|
|
563
|
+
{ milestoneId: "M001", success: true, commitMessage: "feat: auth system", pushed: false },
|
|
564
|
+
];
|
|
565
|
+
const output = formatMergeResults(results);
|
|
566
|
+
assert.ok(output.includes("# Merge Results"));
|
|
567
|
+
assert.ok(output.includes("**M001**"));
|
|
568
|
+
assert.ok(output.includes("merged successfully"));
|
|
569
|
+
assert.ok(!output.includes("(pushed)"));
|
|
570
|
+
});
|
|
571
|
+
|
|
572
|
+
it("includes (pushed) suffix when result.pushed is true", () => {
|
|
573
|
+
const results: MergeResult[] = [
|
|
574
|
+
{ milestoneId: "M002", success: true, commitMessage: "feat: dashboard", pushed: true },
|
|
575
|
+
];
|
|
576
|
+
const output = formatMergeResults(results);
|
|
577
|
+
assert.ok(output.includes("(pushed)"));
|
|
578
|
+
});
|
|
579
|
+
|
|
580
|
+
it("formats a conflict result with file list and retry instructions", () => {
|
|
581
|
+
const results: MergeResult[] = [
|
|
582
|
+
{
|
|
583
|
+
milestoneId: "M003",
|
|
584
|
+
success: false,
|
|
585
|
+
conflictFiles: ["src/types.ts", "src/utils.ts"],
|
|
586
|
+
error: "Merge conflict: 2 conflicting file(s)",
|
|
587
|
+
},
|
|
588
|
+
];
|
|
589
|
+
const output = formatMergeResults(results);
|
|
590
|
+
assert.ok(output.includes("**M003**"));
|
|
591
|
+
assert.ok(output.includes("CONFLICT (2 file(s))"));
|
|
592
|
+
assert.ok(output.includes("`src/types.ts`"));
|
|
593
|
+
assert.ok(output.includes("`src/utils.ts`"));
|
|
594
|
+
assert.ok(output.includes("/gsd parallel merge M003"));
|
|
595
|
+
});
|
|
596
|
+
|
|
597
|
+
it("formats a generic error (no conflict files) with the error message", () => {
|
|
598
|
+
const results: MergeResult[] = [
|
|
599
|
+
{ milestoneId: "M004", success: false, error: "No roadmap found for M004" },
|
|
600
|
+
];
|
|
601
|
+
const output = formatMergeResults(results);
|
|
602
|
+
assert.ok(output.includes("**M004**"));
|
|
603
|
+
assert.ok(output.includes("failed: No roadmap found for M004"));
|
|
604
|
+
assert.ok(!output.includes("CONFLICT"));
|
|
605
|
+
});
|
|
606
|
+
|
|
607
|
+
it("formats multiple results in the order provided", () => {
|
|
608
|
+
const results: MergeResult[] = [
|
|
609
|
+
{ milestoneId: "M001", success: true, pushed: false },
|
|
610
|
+
{ milestoneId: "M002", success: false, error: "branch not found" },
|
|
611
|
+
{ milestoneId: "M003", success: true, pushed: true },
|
|
612
|
+
];
|
|
613
|
+
const output = formatMergeResults(results);
|
|
614
|
+
const m1Pos = output.indexOf("M001");
|
|
615
|
+
const m2Pos = output.indexOf("M002");
|
|
616
|
+
const m3Pos = output.indexOf("M003");
|
|
617
|
+
assert.ok(m1Pos < m2Pos, "M001 should appear before M002");
|
|
618
|
+
assert.ok(m2Pos < m3Pos, "M002 should appear before M003");
|
|
619
|
+
});
|
|
620
|
+
});
|
|
621
|
+
|
|
622
|
+
// ─── doctor: stale_parallel_session issue code ───────────────────────────────
|
|
623
|
+
|
|
624
|
+
describe("doctor: stale_parallel_session issue code exists", () => {
|
|
625
|
+
it("DoctorIssueCode union includes stale_parallel_session", async () => {
|
|
626
|
+
// Import doctor.ts and verify the type is real by constructing a DoctorIssue
|
|
627
|
+
// with code "stale_parallel_session" — TypeScript will reject it at compile
|
|
628
|
+
// time if the code is not in the union; the runtime assertion confirms the
|
|
629
|
+
// string value round-trips through the typed object correctly.
|
|
630
|
+
const { } = await import("../doctor.js");
|
|
631
|
+
// Construct a value that satisfies DoctorIssue using the code under test
|
|
632
|
+
const issue: import("../doctor.js").DoctorIssue = {
|
|
633
|
+
severity: "warning",
|
|
634
|
+
code: "stale_parallel_session",
|
|
635
|
+
scope: "project",
|
|
636
|
+
unitId: "M001",
|
|
637
|
+
message: "Stale parallel session detected",
|
|
638
|
+
fixable: true,
|
|
639
|
+
};
|
|
640
|
+
assert.equal(issue.code, "stale_parallel_session");
|
|
641
|
+
});
|
|
642
|
+
|
|
643
|
+
it("DoctorIssue with stale_parallel_session has warning severity", () => {
|
|
644
|
+
const issue: import("../doctor.js").DoctorIssue = {
|
|
645
|
+
severity: "warning",
|
|
646
|
+
code: "stale_parallel_session",
|
|
647
|
+
scope: "project",
|
|
648
|
+
unitId: "M002",
|
|
649
|
+
message: "Stale parallel session for M002",
|
|
650
|
+
fixable: true,
|
|
651
|
+
};
|
|
652
|
+
assert.equal(issue.severity, "warning");
|
|
653
|
+
assert.equal(issue.fixable, true);
|
|
654
|
+
assert.equal(issue.scope, "project");
|
|
655
|
+
});
|
|
656
|
+
});
|