gsd-pi 2.26.0 → 2.27.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/README.md +43 -6
- package/dist/cli.js +4 -2
- package/dist/headless.d.ts +3 -0
- package/dist/headless.js +136 -8
- package/dist/help-text.js +3 -0
- package/dist/loader.js +33 -4
- package/dist/resources/extensions/bg-shell/index.ts +19 -2
- package/dist/resources/extensions/bg-shell/process-manager.ts +45 -0
- package/dist/resources/extensions/bg-shell/types.ts +21 -1
- package/dist/resources/extensions/gsd/auto/session.ts +224 -0
- package/dist/resources/extensions/gsd/auto-budget.ts +32 -0
- package/dist/resources/extensions/gsd/auto-dashboard.ts +63 -10
- package/dist/resources/extensions/gsd/auto-direct-dispatch.ts +229 -0
- package/dist/resources/extensions/gsd/auto-dispatch.ts +23 -10
- package/dist/resources/extensions/gsd/auto-model-selection.ts +179 -0
- package/dist/resources/extensions/gsd/auto-observability.ts +74 -0
- package/dist/resources/extensions/gsd/auto-prompts.ts +0 -1
- package/dist/resources/extensions/gsd/auto-timeout-recovery.ts +262 -0
- package/dist/resources/extensions/gsd/auto-tool-tracking.ts +54 -0
- package/dist/resources/extensions/gsd/auto-unit-closeout.ts +46 -0
- package/dist/resources/extensions/gsd/auto-worktree-sync.ts +207 -0
- package/dist/resources/extensions/gsd/auto.ts +977 -1551
- package/dist/resources/extensions/gsd/commands.ts +3 -3
- package/dist/resources/extensions/gsd/dashboard-overlay.ts +47 -72
- package/dist/resources/extensions/gsd/doctor-proactive.ts +9 -4
- package/dist/resources/extensions/gsd/export-html.ts +1001 -0
- package/dist/resources/extensions/gsd/export.ts +49 -1
- package/dist/resources/extensions/gsd/git-service.ts +6 -0
- package/dist/resources/extensions/gsd/gitignore.ts +4 -1
- package/dist/resources/extensions/gsd/guided-flow.ts +24 -5
- package/dist/resources/extensions/gsd/index.ts +54 -1
- package/dist/resources/extensions/gsd/native-git-bridge.ts +30 -2
- package/dist/resources/extensions/gsd/observability-validator.ts +21 -0
- package/dist/resources/extensions/gsd/parallel-orchestrator.ts +231 -20
- package/dist/resources/extensions/gsd/preferences.ts +62 -1
- package/dist/resources/extensions/gsd/prompts/execute-task.md +4 -3
- package/dist/resources/extensions/gsd/prompts/system.md +1 -1
- package/dist/resources/extensions/gsd/reports.ts +510 -0
- package/dist/resources/extensions/gsd/roadmap-slices.ts +1 -1
- package/dist/resources/extensions/gsd/skills/gsd-headless/SKILL.md +178 -0
- package/dist/resources/extensions/gsd/skills/gsd-headless/references/answer-injection.md +54 -0
- package/dist/resources/extensions/gsd/skills/gsd-headless/references/commands.md +59 -0
- package/dist/resources/extensions/gsd/skills/gsd-headless/references/multi-session.md +185 -0
- package/dist/resources/extensions/gsd/state.ts +30 -0
- package/dist/resources/extensions/gsd/templates/task-summary.md +9 -0
- package/dist/resources/extensions/gsd/tests/auto-dashboard.test.ts +13 -0
- package/dist/resources/extensions/gsd/tests/continue-here.test.ts +81 -0
- package/dist/resources/extensions/gsd/tests/derive-state-db.test.ts +5 -0
- package/dist/resources/extensions/gsd/tests/derive-state-deps.test.ts +1 -0
- package/dist/resources/extensions/gsd/tests/derive-state-draft.test.ts +1 -0
- package/dist/resources/extensions/gsd/tests/derive-state.test.ts +10 -1
- package/dist/resources/extensions/gsd/tests/dispatch-missing-task-plans.test.ts +132 -0
- package/dist/resources/extensions/gsd/tests/doctor-proactive.test.ts +14 -0
- package/dist/resources/extensions/gsd/tests/integration-mixed-milestones.test.ts +1 -0
- package/dist/resources/extensions/gsd/tests/milestone-transition-worktree.test.ts +1 -1
- package/dist/resources/extensions/gsd/tests/native-has-changes-cache.test.ts +61 -0
- package/dist/resources/extensions/gsd/tests/network-error-fallback.test.ts +51 -1
- package/dist/resources/extensions/gsd/tests/parallel-budget-atomicity.test.ts +331 -0
- package/dist/resources/extensions/gsd/tests/parallel-crash-recovery.test.ts +298 -0
- package/dist/resources/extensions/gsd/tests/parallel-merge.test.ts +465 -0
- package/dist/resources/extensions/gsd/tests/parallel-orchestration.test.ts +39 -10
- package/dist/resources/extensions/gsd/tests/plan-slice-prompt.test.ts +71 -0
- package/dist/resources/extensions/gsd/tests/replan-slice.test.ts +42 -0
- package/dist/resources/extensions/gsd/tests/triage-dispatch.test.ts +9 -9
- package/dist/resources/extensions/gsd/tests/verification-evidence.test.ts +743 -0
- package/dist/resources/extensions/gsd/tests/verification-gate.test.ts +965 -0
- package/dist/resources/extensions/gsd/tests/visualizer-data.test.ts +1 -1
- package/dist/resources/extensions/gsd/tests/visualizer-overlay.test.ts +44 -10
- package/dist/resources/extensions/gsd/tests/worktree.test.ts +3 -1
- package/dist/resources/extensions/gsd/types.ts +38 -0
- package/dist/resources/extensions/gsd/verification-evidence.ts +183 -0
- package/dist/resources/extensions/gsd/verification-gate.ts +567 -0
- package/dist/resources/extensions/gsd/visualizer-data.ts +25 -3
- package/dist/resources/extensions/gsd/visualizer-overlay.ts +31 -21
- package/dist/resources/extensions/gsd/visualizer-views.ts +15 -66
- package/dist/resources/extensions/search-the-web/tool-search.ts +26 -0
- package/dist/resources/extensions/shared/format-utils.ts +85 -0
- package/dist/resources/extensions/shared/tests/format-utils.test.ts +153 -0
- package/dist/resources/extensions/subagent/index.ts +46 -1
- package/dist/resources/extensions/subagent/isolation.ts +9 -6
- package/package.json +1 -1
- package/packages/pi-ai/dist/providers/openai-completions.js +7 -4
- package/packages/pi-ai/dist/providers/openai-completions.js.map +1 -1
- package/packages/pi-ai/src/providers/openai-completions.ts +7 -4
- package/packages/pi-coding-agent/dist/core/lsp/client.d.ts.map +1 -1
- package/packages/pi-coding-agent/dist/core/lsp/client.js +7 -0
- package/packages/pi-coding-agent/dist/core/lsp/client.js.map +1 -1
- package/packages/pi-coding-agent/dist/core/lsp/config.d.ts.map +1 -1
- package/packages/pi-coding-agent/dist/core/lsp/config.js +9 -2
- package/packages/pi-coding-agent/dist/core/lsp/config.js.map +1 -1
- package/packages/pi-coding-agent/src/core/lsp/client.ts +8 -0
- package/packages/pi-coding-agent/src/core/lsp/config.ts +9 -2
- package/packages/pi-tui/dist/components/editor.d.ts.map +1 -1
- package/packages/pi-tui/dist/components/editor.js +1 -1
- package/packages/pi-tui/dist/components/editor.js.map +1 -1
- package/packages/pi-tui/src/components/editor.ts +3 -1
- package/scripts/link-workspace-packages.cjs +22 -6
- package/src/resources/extensions/bg-shell/index.ts +19 -2
- package/src/resources/extensions/bg-shell/process-manager.ts +45 -0
- package/src/resources/extensions/bg-shell/types.ts +21 -1
- package/src/resources/extensions/gsd/auto/session.ts +224 -0
- package/src/resources/extensions/gsd/auto-budget.ts +32 -0
- package/src/resources/extensions/gsd/auto-dashboard.ts +63 -10
- package/src/resources/extensions/gsd/auto-direct-dispatch.ts +229 -0
- package/src/resources/extensions/gsd/auto-dispatch.ts +23 -10
- package/src/resources/extensions/gsd/auto-model-selection.ts +179 -0
- package/src/resources/extensions/gsd/auto-observability.ts +74 -0
- package/src/resources/extensions/gsd/auto-prompts.ts +0 -1
- package/src/resources/extensions/gsd/auto-timeout-recovery.ts +262 -0
- package/src/resources/extensions/gsd/auto-tool-tracking.ts +54 -0
- package/src/resources/extensions/gsd/auto-unit-closeout.ts +46 -0
- package/src/resources/extensions/gsd/auto-worktree-sync.ts +207 -0
- package/src/resources/extensions/gsd/auto.ts +977 -1551
- package/src/resources/extensions/gsd/commands.ts +3 -3
- package/src/resources/extensions/gsd/dashboard-overlay.ts +47 -72
- package/src/resources/extensions/gsd/doctor-proactive.ts +9 -4
- package/src/resources/extensions/gsd/export-html.ts +1001 -0
- package/src/resources/extensions/gsd/export.ts +49 -1
- package/src/resources/extensions/gsd/git-service.ts +6 -0
- package/src/resources/extensions/gsd/gitignore.ts +4 -1
- package/src/resources/extensions/gsd/guided-flow.ts +24 -5
- package/src/resources/extensions/gsd/index.ts +54 -1
- package/src/resources/extensions/gsd/native-git-bridge.ts +30 -2
- package/src/resources/extensions/gsd/observability-validator.ts +21 -0
- package/src/resources/extensions/gsd/parallel-orchestrator.ts +231 -20
- package/src/resources/extensions/gsd/preferences.ts +62 -1
- package/src/resources/extensions/gsd/prompts/execute-task.md +4 -3
- package/src/resources/extensions/gsd/prompts/system.md +1 -1
- package/src/resources/extensions/gsd/reports.ts +510 -0
- package/src/resources/extensions/gsd/roadmap-slices.ts +1 -1
- package/src/resources/extensions/gsd/skills/gsd-headless/SKILL.md +178 -0
- package/src/resources/extensions/gsd/skills/gsd-headless/references/answer-injection.md +54 -0
- package/src/resources/extensions/gsd/skills/gsd-headless/references/commands.md +59 -0
- package/src/resources/extensions/gsd/skills/gsd-headless/references/multi-session.md +185 -0
- package/src/resources/extensions/gsd/state.ts +30 -0
- package/src/resources/extensions/gsd/templates/task-summary.md +9 -0
- package/src/resources/extensions/gsd/tests/auto-dashboard.test.ts +13 -0
- package/src/resources/extensions/gsd/tests/continue-here.test.ts +81 -0
- package/src/resources/extensions/gsd/tests/derive-state-db.test.ts +5 -0
- package/src/resources/extensions/gsd/tests/derive-state-deps.test.ts +1 -0
- package/src/resources/extensions/gsd/tests/derive-state-draft.test.ts +1 -0
- package/src/resources/extensions/gsd/tests/derive-state.test.ts +10 -1
- package/src/resources/extensions/gsd/tests/dispatch-missing-task-plans.test.ts +132 -0
- package/src/resources/extensions/gsd/tests/doctor-proactive.test.ts +14 -0
- package/src/resources/extensions/gsd/tests/integration-mixed-milestones.test.ts +1 -0
- package/src/resources/extensions/gsd/tests/milestone-transition-worktree.test.ts +1 -1
- package/src/resources/extensions/gsd/tests/native-has-changes-cache.test.ts +61 -0
- package/src/resources/extensions/gsd/tests/network-error-fallback.test.ts +51 -1
- package/src/resources/extensions/gsd/tests/parallel-budget-atomicity.test.ts +331 -0
- package/src/resources/extensions/gsd/tests/parallel-crash-recovery.test.ts +298 -0
- package/src/resources/extensions/gsd/tests/parallel-merge.test.ts +465 -0
- package/src/resources/extensions/gsd/tests/parallel-orchestration.test.ts +39 -10
- package/src/resources/extensions/gsd/tests/plan-slice-prompt.test.ts +71 -0
- package/src/resources/extensions/gsd/tests/replan-slice.test.ts +42 -0
- package/src/resources/extensions/gsd/tests/triage-dispatch.test.ts +9 -9
- package/src/resources/extensions/gsd/tests/verification-evidence.test.ts +743 -0
- package/src/resources/extensions/gsd/tests/verification-gate.test.ts +965 -0
- package/src/resources/extensions/gsd/tests/visualizer-data.test.ts +1 -1
- package/src/resources/extensions/gsd/tests/visualizer-overlay.test.ts +44 -10
- package/src/resources/extensions/gsd/tests/worktree.test.ts +3 -1
- package/src/resources/extensions/gsd/types.ts +38 -0
- package/src/resources/extensions/gsd/verification-evidence.ts +183 -0
- package/src/resources/extensions/gsd/verification-gate.ts +567 -0
- package/src/resources/extensions/gsd/visualizer-data.ts +25 -3
- package/src/resources/extensions/gsd/visualizer-overlay.ts +31 -21
- package/src/resources/extensions/gsd/visualizer-views.ts +15 -66
- package/src/resources/extensions/search-the-web/tool-search.ts +26 -0
- package/src/resources/extensions/shared/format-utils.ts +85 -0
- package/src/resources/extensions/shared/tests/format-utils.test.ts +153 -0
- package/src/resources/extensions/subagent/index.ts +46 -1
- package/src/resources/extensions/subagent/isolation.ts +9 -6
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* parallel-budget-atomicity.test.ts — Budget enforcement tests for parallel orchestration (G6).
|
|
3
|
+
*
|
|
4
|
+
* Verifies that the budget ceiling cannot be exceeded through race conditions
|
|
5
|
+
* or incorrect cost aggregation. Tests the single-writer architecture:
|
|
6
|
+
* workers emit costs via session status files, the coordinator reads them
|
|
7
|
+
* sequentially via refreshWorkerStatuses().
|
|
8
|
+
*
|
|
9
|
+
* Covers:
|
|
10
|
+
* - Ceiling enforcement: isBudgetExceeded returns true above ceiling
|
|
11
|
+
* - Cost aggregation: sum across all workers is correct
|
|
12
|
+
* - No double-counting: multiple refreshes don't accumulate
|
|
13
|
+
* - Budget reset: totalCost clears after resetOrchestrator
|
|
14
|
+
* - No budget ceiling: isBudgetExceeded returns false when ceiling unset
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import test from "node:test";
|
|
18
|
+
import assert from "node:assert/strict";
|
|
19
|
+
import { mkdirSync, rmSync } from "node:fs";
|
|
20
|
+
import { join } from "node:path";
|
|
21
|
+
import { tmpdir } from "node:os";
|
|
22
|
+
import { randomUUID } from "node:crypto";
|
|
23
|
+
|
|
24
|
+
import {
|
|
25
|
+
startParallel,
|
|
26
|
+
getAggregateCost,
|
|
27
|
+
isBudgetExceeded,
|
|
28
|
+
refreshWorkerStatuses,
|
|
29
|
+
resetOrchestrator,
|
|
30
|
+
getOrchestratorState,
|
|
31
|
+
isParallelActive,
|
|
32
|
+
getWorkerStatuses,
|
|
33
|
+
} from "../parallel-orchestrator.ts";
|
|
34
|
+
import {
|
|
35
|
+
writeSessionStatus,
|
|
36
|
+
readSessionStatus,
|
|
37
|
+
removeSessionStatus,
|
|
38
|
+
} from "../session-status-io.ts";
|
|
39
|
+
import type { GSDPreferences } from "../preferences.ts";
|
|
40
|
+
|
|
41
|
+
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
42
|
+
|
|
43
|
+
function makeTmpBase(): string {
|
|
44
|
+
const base = join(tmpdir(), `gsd-budget-test-${randomUUID()}`);
|
|
45
|
+
mkdirSync(join(base, ".gsd"), { recursive: true });
|
|
46
|
+
return base;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function cleanup(base: string): void {
|
|
50
|
+
try { rmSync(base, { recursive: true, force: true }); } catch { /* */ }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function makePrefs(ceiling?: number): GSDPreferences {
|
|
54
|
+
return {
|
|
55
|
+
parallel: {
|
|
56
|
+
enabled: true,
|
|
57
|
+
max_workers: 2,
|
|
58
|
+
budget_ceiling: ceiling,
|
|
59
|
+
merge_strategy: "per-milestone",
|
|
60
|
+
auto_merge: "confirm",
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Write a session status file for a milestone with a specific cost. */
|
|
66
|
+
function writeWorkerCost(
|
|
67
|
+
base: string,
|
|
68
|
+
milestoneId: string,
|
|
69
|
+
cost: number,
|
|
70
|
+
completedUnits = 1,
|
|
71
|
+
): void {
|
|
72
|
+
writeSessionStatus(base, {
|
|
73
|
+
milestoneId,
|
|
74
|
+
pid: process.pid,
|
|
75
|
+
state: "running",
|
|
76
|
+
currentUnit: null,
|
|
77
|
+
completedUnits,
|
|
78
|
+
cost,
|
|
79
|
+
lastHeartbeat: Date.now(),
|
|
80
|
+
startedAt: Date.now() - 60000,
|
|
81
|
+
worktreePath: join(base, ".gsd", "worktrees", milestoneId.toLowerCase()),
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
86
|
+
// Ceiling Enforcement
|
|
87
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
88
|
+
|
|
89
|
+
test("budget — isBudgetExceeded returns true when totalCost >= ceiling", async () => {
|
|
90
|
+
const base = makeTmpBase();
|
|
91
|
+
try {
|
|
92
|
+
await startParallel(base, ["M001", "M002"], makePrefs(1.0));
|
|
93
|
+
|
|
94
|
+
// Initial state: cost is 0, not exceeded
|
|
95
|
+
assert.equal(getAggregateCost(), 0);
|
|
96
|
+
assert.equal(isBudgetExceeded(), false);
|
|
97
|
+
|
|
98
|
+
// Write costs that exceed the $1.00 ceiling
|
|
99
|
+
writeWorkerCost(base, "M001", 0.6);
|
|
100
|
+
writeWorkerCost(base, "M002", 0.5);
|
|
101
|
+
refreshWorkerStatuses(base);
|
|
102
|
+
|
|
103
|
+
// Total: 0.6 + 0.5 = 1.1 > 1.0
|
|
104
|
+
assert.ok(getAggregateCost() >= 1.0, `aggregate cost should be >= 1.0, got ${getAggregateCost()}`);
|
|
105
|
+
assert.equal(isBudgetExceeded(), true, "should be exceeded at 1.1 vs ceiling 1.0");
|
|
106
|
+
} finally {
|
|
107
|
+
resetOrchestrator();
|
|
108
|
+
cleanup(base);
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("budget — isBudgetExceeded returns false when totalCost < ceiling", async () => {
|
|
113
|
+
const base = makeTmpBase();
|
|
114
|
+
try {
|
|
115
|
+
await startParallel(base, ["M001", "M002"], makePrefs(5.0));
|
|
116
|
+
|
|
117
|
+
writeWorkerCost(base, "M001", 1.0);
|
|
118
|
+
writeWorkerCost(base, "M002", 1.5);
|
|
119
|
+
refreshWorkerStatuses(base);
|
|
120
|
+
|
|
121
|
+
// Total: 1.0 + 1.5 = 2.5 < 5.0
|
|
122
|
+
assert.equal(getAggregateCost(), 2.5);
|
|
123
|
+
assert.equal(isBudgetExceeded(), false, "should not be exceeded at 2.5 vs ceiling 5.0");
|
|
124
|
+
} finally {
|
|
125
|
+
resetOrchestrator();
|
|
126
|
+
cleanup(base);
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("budget — isBudgetExceeded returns true at exact ceiling", async () => {
|
|
131
|
+
const base = makeTmpBase();
|
|
132
|
+
try {
|
|
133
|
+
await startParallel(base, ["M001"], makePrefs(2.0));
|
|
134
|
+
|
|
135
|
+
writeWorkerCost(base, "M001", 2.0);
|
|
136
|
+
refreshWorkerStatuses(base);
|
|
137
|
+
|
|
138
|
+
assert.equal(getAggregateCost(), 2.0);
|
|
139
|
+
assert.equal(isBudgetExceeded(), true, "should be exceeded at exact ceiling");
|
|
140
|
+
} finally {
|
|
141
|
+
resetOrchestrator();
|
|
142
|
+
cleanup(base);
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
147
|
+
// Cost Aggregation
|
|
148
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
149
|
+
|
|
150
|
+
test("budget — cost aggregation sums all worker costs correctly", async () => {
|
|
151
|
+
const base = makeTmpBase();
|
|
152
|
+
try {
|
|
153
|
+
await startParallel(base, ["M001", "M002"], makePrefs(100.0));
|
|
154
|
+
|
|
155
|
+
writeWorkerCost(base, "M001", 3.14159);
|
|
156
|
+
writeWorkerCost(base, "M002", 2.71828);
|
|
157
|
+
refreshWorkerStatuses(base);
|
|
158
|
+
|
|
159
|
+
const expected = 3.14159 + 2.71828;
|
|
160
|
+
const actual = getAggregateCost();
|
|
161
|
+
assert.ok(
|
|
162
|
+
Math.abs(actual - expected) < 0.0001,
|
|
163
|
+
`cost should be ~${expected}, got ${actual}`,
|
|
164
|
+
);
|
|
165
|
+
} finally {
|
|
166
|
+
resetOrchestrator();
|
|
167
|
+
cleanup(base);
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test("budget — worker cost update reflects in aggregate after refresh", async () => {
|
|
172
|
+
const base = makeTmpBase();
|
|
173
|
+
try {
|
|
174
|
+
await startParallel(base, ["M001"], makePrefs(10.0));
|
|
175
|
+
|
|
176
|
+
// Initial cost
|
|
177
|
+
writeWorkerCost(base, "M001", 0.5);
|
|
178
|
+
refreshWorkerStatuses(base);
|
|
179
|
+
assert.equal(getAggregateCost(), 0.5);
|
|
180
|
+
|
|
181
|
+
// Cost increases as worker progresses
|
|
182
|
+
writeWorkerCost(base, "M001", 1.5);
|
|
183
|
+
refreshWorkerStatuses(base);
|
|
184
|
+
assert.equal(getAggregateCost(), 1.5, "should reflect updated cost, not accumulated");
|
|
185
|
+
|
|
186
|
+
// Cost increases again
|
|
187
|
+
writeWorkerCost(base, "M001", 3.0);
|
|
188
|
+
refreshWorkerStatuses(base);
|
|
189
|
+
assert.equal(getAggregateCost(), 3.0);
|
|
190
|
+
} finally {
|
|
191
|
+
resetOrchestrator();
|
|
192
|
+
cleanup(base);
|
|
193
|
+
}
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
197
|
+
// No Double-Counting
|
|
198
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
199
|
+
|
|
200
|
+
test("budget — multiple refreshes don't accumulate cost", async () => {
|
|
201
|
+
const base = makeTmpBase();
|
|
202
|
+
try {
|
|
203
|
+
await startParallel(base, ["M001", "M002"], makePrefs(10.0));
|
|
204
|
+
|
|
205
|
+
writeWorkerCost(base, "M001", 0.5);
|
|
206
|
+
writeWorkerCost(base, "M002", 0.3);
|
|
207
|
+
|
|
208
|
+
// Refresh multiple times
|
|
209
|
+
refreshWorkerStatuses(base);
|
|
210
|
+
refreshWorkerStatuses(base);
|
|
211
|
+
refreshWorkerStatuses(base);
|
|
212
|
+
refreshWorkerStatuses(base);
|
|
213
|
+
refreshWorkerStatuses(base);
|
|
214
|
+
|
|
215
|
+
// Cost should be 0.5 + 0.3 = 0.8 regardless of how many refreshes
|
|
216
|
+
assert.equal(getAggregateCost(), 0.8, "cost should be 0.8 after 5 refreshes");
|
|
217
|
+
} finally {
|
|
218
|
+
resetOrchestrator();
|
|
219
|
+
cleanup(base);
|
|
220
|
+
}
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
test("budget — refresh between cost updates tracks correctly", async () => {
|
|
224
|
+
const base = makeTmpBase();
|
|
225
|
+
try {
|
|
226
|
+
await startParallel(base, ["M001", "M002"], makePrefs(10.0));
|
|
227
|
+
|
|
228
|
+
// Round 1: M001 has cost, M002 doesn't yet
|
|
229
|
+
writeWorkerCost(base, "M001", 0.5);
|
|
230
|
+
refreshWorkerStatuses(base);
|
|
231
|
+
const cost1 = getAggregateCost();
|
|
232
|
+
|
|
233
|
+
// Round 2: both workers have cost
|
|
234
|
+
writeWorkerCost(base, "M002", 0.7);
|
|
235
|
+
refreshWorkerStatuses(base);
|
|
236
|
+
const cost2 = getAggregateCost();
|
|
237
|
+
|
|
238
|
+
// Round 3: M001 cost increased
|
|
239
|
+
writeWorkerCost(base, "M001", 1.2);
|
|
240
|
+
refreshWorkerStatuses(base);
|
|
241
|
+
const cost3 = getAggregateCost();
|
|
242
|
+
|
|
243
|
+
assert.equal(cost1, 0.5, "round 1: only M001");
|
|
244
|
+
assert.equal(cost2, 1.2, "round 2: M001 + M002");
|
|
245
|
+
assert.equal(cost3, 1.9, "round 3: updated M001 + M002");
|
|
246
|
+
} finally {
|
|
247
|
+
resetOrchestrator();
|
|
248
|
+
cleanup(base);
|
|
249
|
+
}
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
253
|
+
// Budget Reset
|
|
254
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
255
|
+
|
|
256
|
+
test("budget — resetOrchestrator clears totalCost", async () => {
|
|
257
|
+
const base = makeTmpBase();
|
|
258
|
+
try {
|
|
259
|
+
await startParallel(base, ["M001"], makePrefs(10.0));
|
|
260
|
+
|
|
261
|
+
writeWorkerCost(base, "M001", 5.0);
|
|
262
|
+
refreshWorkerStatuses(base);
|
|
263
|
+
assert.equal(getAggregateCost(), 5.0, "cost should be 5.0 before reset");
|
|
264
|
+
|
|
265
|
+
resetOrchestrator();
|
|
266
|
+
|
|
267
|
+
assert.equal(getAggregateCost(), 0, "cost should be 0 after reset");
|
|
268
|
+
assert.equal(isBudgetExceeded(), false, "should not be exceeded after reset");
|
|
269
|
+
assert.equal(isParallelActive(), false, "should not be active after reset");
|
|
270
|
+
assert.equal(getOrchestratorState(), null, "state should be null after reset");
|
|
271
|
+
} finally {
|
|
272
|
+
resetOrchestrator();
|
|
273
|
+
cleanup(base);
|
|
274
|
+
}
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
278
|
+
// No Budget Ceiling
|
|
279
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
280
|
+
|
|
281
|
+
test("budget — isBudgetExceeded returns false when no ceiling configured", async () => {
|
|
282
|
+
const base = makeTmpBase();
|
|
283
|
+
try {
|
|
284
|
+
// No budget_ceiling set (undefined)
|
|
285
|
+
await startParallel(base, ["M001"], makePrefs(undefined));
|
|
286
|
+
|
|
287
|
+
writeWorkerCost(base, "M001", 999.99);
|
|
288
|
+
refreshWorkerStatuses(base);
|
|
289
|
+
|
|
290
|
+
assert.equal(getAggregateCost(), 999.99, "cost should be tracked even without ceiling");
|
|
291
|
+
assert.equal(isBudgetExceeded(), false, "should never be exceeded without ceiling");
|
|
292
|
+
} finally {
|
|
293
|
+
resetOrchestrator();
|
|
294
|
+
cleanup(base);
|
|
295
|
+
}
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
299
|
+
// Worker status tracking through refresh
|
|
300
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
301
|
+
|
|
302
|
+
test("budget — refreshWorkerStatuses updates worker state from disk", async () => {
|
|
303
|
+
const base = makeTmpBase();
|
|
304
|
+
try {
|
|
305
|
+
await startParallel(base, ["M001"], makePrefs(10.0));
|
|
306
|
+
|
|
307
|
+
// Write status with specific state
|
|
308
|
+
writeSessionStatus(base, {
|
|
309
|
+
milestoneId: "M001",
|
|
310
|
+
pid: process.pid,
|
|
311
|
+
state: "paused",
|
|
312
|
+
currentUnit: { type: "execute-task", id: "M001/S01/T02", startedAt: Date.now() },
|
|
313
|
+
completedUnits: 5,
|
|
314
|
+
cost: 2.5,
|
|
315
|
+
lastHeartbeat: Date.now(),
|
|
316
|
+
startedAt: Date.now() - 120000,
|
|
317
|
+
worktreePath: join(base, ".gsd", "worktrees", "m001"),
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
refreshWorkerStatuses(base);
|
|
321
|
+
|
|
322
|
+
const workers = getWorkerStatuses();
|
|
323
|
+
assert.equal(workers.length, 1);
|
|
324
|
+
assert.equal(workers[0]!.state, "paused", "worker state should be updated from disk");
|
|
325
|
+
assert.equal(workers[0]!.completedUnits, 5, "completedUnits should be updated from disk");
|
|
326
|
+
assert.equal(workers[0]!.cost, 2.5, "cost should be updated from disk");
|
|
327
|
+
} finally {
|
|
328
|
+
resetOrchestrator();
|
|
329
|
+
cleanup(base);
|
|
330
|
+
}
|
|
331
|
+
});
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for parallel orchestrator crash recovery.
|
|
3
|
+
*
|
|
4
|
+
* Validates that orchestrator state is persisted to disk and can be
|
|
5
|
+
* restored after a coordinator crash, with PID liveness filtering.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
mkdtempSync,
|
|
10
|
+
mkdirSync,
|
|
11
|
+
readFileSync,
|
|
12
|
+
writeFileSync,
|
|
13
|
+
existsSync,
|
|
14
|
+
rmSync,
|
|
15
|
+
} from "node:fs";
|
|
16
|
+
import { join } from "node:path";
|
|
17
|
+
import { tmpdir } from "node:os";
|
|
18
|
+
|
|
19
|
+
import {
|
|
20
|
+
persistState,
|
|
21
|
+
restoreState,
|
|
22
|
+
resetOrchestrator,
|
|
23
|
+
getOrchestratorState,
|
|
24
|
+
type PersistedState,
|
|
25
|
+
} from "../parallel-orchestrator.ts";
|
|
26
|
+
import { writeSessionStatus, readAllSessionStatuses, removeSessionStatus } from "../session-status-io.ts";
|
|
27
|
+
import { createTestContext } from './test-helpers.ts';
|
|
28
|
+
|
|
29
|
+
const { assertEq, assertTrue, report } = createTestContext();
|
|
30
|
+
|
|
31
|
+
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
32
|
+
|
|
33
|
+
function makeTempDir(): string {
|
|
34
|
+
const dir = mkdtempSync(join(tmpdir(), "gsd-crash-recovery-"));
|
|
35
|
+
mkdirSync(join(dir, ".gsd"), { recursive: true });
|
|
36
|
+
return dir;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function stateFilePath(basePath: string): string {
|
|
40
|
+
return join(basePath, ".gsd", "orchestrator.json");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function writeStateFile(basePath: string, state: PersistedState): void {
|
|
44
|
+
writeFileSync(stateFilePath(basePath), JSON.stringify(state, null, 2), "utf-8");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function makePersistedState(overrides: Partial<PersistedState> = {}): PersistedState {
|
|
48
|
+
return {
|
|
49
|
+
active: true,
|
|
50
|
+
workers: [],
|
|
51
|
+
totalCost: 0,
|
|
52
|
+
startedAt: Date.now(),
|
|
53
|
+
configSnapshot: { max_workers: 3 },
|
|
54
|
+
...overrides,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// ─── Tests ────────────────────────────────────────────────────────────────────
|
|
59
|
+
|
|
60
|
+
// Test 1: persistState writes valid JSON
|
|
61
|
+
{
|
|
62
|
+
const basePath = makeTempDir();
|
|
63
|
+
try {
|
|
64
|
+
// We can't call persistState directly without internal state set up,
|
|
65
|
+
// so we test the round-trip by writing a state file and reading it back
|
|
66
|
+
const state = makePersistedState({
|
|
67
|
+
workers: [
|
|
68
|
+
{
|
|
69
|
+
milestoneId: "M001",
|
|
70
|
+
title: "M001",
|
|
71
|
+
pid: process.pid,
|
|
72
|
+
worktreePath: "/tmp/wt-M001",
|
|
73
|
+
startedAt: Date.now(),
|
|
74
|
+
state: "running",
|
|
75
|
+
completedUnits: 3,
|
|
76
|
+
cost: 0.15,
|
|
77
|
+
},
|
|
78
|
+
],
|
|
79
|
+
totalCost: 0.15,
|
|
80
|
+
});
|
|
81
|
+
writeStateFile(basePath, state);
|
|
82
|
+
|
|
83
|
+
const raw = readFileSync(stateFilePath(basePath), "utf-8");
|
|
84
|
+
const parsed = JSON.parse(raw) as PersistedState;
|
|
85
|
+
assertEq(parsed.active, true, "persistState: active field preserved");
|
|
86
|
+
assertEq(parsed.workers.length, 1, "persistState: worker count preserved");
|
|
87
|
+
assertEq(parsed.workers[0].milestoneId, "M001", "persistState: milestoneId preserved");
|
|
88
|
+
assertEq(parsed.workers[0].cost, 0.15, "persistState: cost preserved");
|
|
89
|
+
assertEq(parsed.totalCost, 0.15, "persistState: totalCost preserved");
|
|
90
|
+
} finally {
|
|
91
|
+
rmSync(basePath, { recursive: true, force: true });
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Test 2: restoreState returns null for missing file
|
|
96
|
+
{
|
|
97
|
+
const basePath = makeTempDir();
|
|
98
|
+
try {
|
|
99
|
+
const result = restoreState(basePath);
|
|
100
|
+
assertEq(result, null, "restoreState: returns null when no state file");
|
|
101
|
+
} finally {
|
|
102
|
+
rmSync(basePath, { recursive: true, force: true });
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Test 3: restoreState filters dead PIDs
|
|
107
|
+
{
|
|
108
|
+
const basePath = makeTempDir();
|
|
109
|
+
try {
|
|
110
|
+
// PID 99999999 is almost certainly not alive
|
|
111
|
+
const state = makePersistedState({
|
|
112
|
+
workers: [
|
|
113
|
+
{
|
|
114
|
+
milestoneId: "M001",
|
|
115
|
+
title: "M001",
|
|
116
|
+
pid: 99999999,
|
|
117
|
+
worktreePath: "/tmp/wt-M001",
|
|
118
|
+
startedAt: Date.now(),
|
|
119
|
+
state: "running",
|
|
120
|
+
completedUnits: 0,
|
|
121
|
+
cost: 0,
|
|
122
|
+
},
|
|
123
|
+
{
|
|
124
|
+
milestoneId: "M002",
|
|
125
|
+
title: "M002",
|
|
126
|
+
pid: 99999998,
|
|
127
|
+
worktreePath: "/tmp/wt-M002",
|
|
128
|
+
startedAt: Date.now(),
|
|
129
|
+
state: "running",
|
|
130
|
+
completedUnits: 0,
|
|
131
|
+
cost: 0,
|
|
132
|
+
},
|
|
133
|
+
],
|
|
134
|
+
});
|
|
135
|
+
writeStateFile(basePath, state);
|
|
136
|
+
|
|
137
|
+
const result = restoreState(basePath);
|
|
138
|
+
// Both PIDs are dead, so result should be null and file should be cleaned up
|
|
139
|
+
assertEq(result, null, "restoreState: returns null when all PIDs dead");
|
|
140
|
+
assertTrue(!existsSync(stateFilePath(basePath)), "restoreState: cleans up state file when all dead");
|
|
141
|
+
} finally {
|
|
142
|
+
rmSync(basePath, { recursive: true, force: true });
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Test 4: restoreState keeps alive PIDs
|
|
147
|
+
{
|
|
148
|
+
const basePath = makeTempDir();
|
|
149
|
+
try {
|
|
150
|
+
// Use current process PID (definitely alive)
|
|
151
|
+
const state = makePersistedState({
|
|
152
|
+
workers: [
|
|
153
|
+
{
|
|
154
|
+
milestoneId: "M001",
|
|
155
|
+
title: "M001",
|
|
156
|
+
pid: process.pid,
|
|
157
|
+
worktreePath: "/tmp/wt-M001",
|
|
158
|
+
startedAt: Date.now(),
|
|
159
|
+
state: "running",
|
|
160
|
+
completedUnits: 5,
|
|
161
|
+
cost: 0.25,
|
|
162
|
+
},
|
|
163
|
+
{
|
|
164
|
+
milestoneId: "M002",
|
|
165
|
+
title: "M002",
|
|
166
|
+
pid: 99999999, // dead
|
|
167
|
+
worktreePath: "/tmp/wt-M002",
|
|
168
|
+
startedAt: Date.now(),
|
|
169
|
+
state: "running",
|
|
170
|
+
completedUnits: 0,
|
|
171
|
+
cost: 0,
|
|
172
|
+
},
|
|
173
|
+
],
|
|
174
|
+
totalCost: 0.25,
|
|
175
|
+
});
|
|
176
|
+
writeStateFile(basePath, state);
|
|
177
|
+
|
|
178
|
+
const result = restoreState(basePath);
|
|
179
|
+
assertTrue(result !== null, "restoreState: returns state when alive PID exists");
|
|
180
|
+
assertEq(result!.workers.length, 1, "restoreState: filters out dead PID");
|
|
181
|
+
assertEq(result!.workers[0].milestoneId, "M001", "restoreState: keeps alive worker");
|
|
182
|
+
assertEq(result!.workers[0].pid, process.pid, "restoreState: preserves PID");
|
|
183
|
+
assertEq(result!.workers[0].completedUnits, 5, "restoreState: preserves progress");
|
|
184
|
+
} finally {
|
|
185
|
+
rmSync(basePath, { recursive: true, force: true });
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Test 5: restoreState skips stopped/error workers even with alive PIDs
|
|
190
|
+
{
|
|
191
|
+
const basePath = makeTempDir();
|
|
192
|
+
try {
|
|
193
|
+
const state = makePersistedState({
|
|
194
|
+
workers: [
|
|
195
|
+
{
|
|
196
|
+
milestoneId: "M001",
|
|
197
|
+
title: "M001",
|
|
198
|
+
pid: process.pid,
|
|
199
|
+
worktreePath: "/tmp/wt-M001",
|
|
200
|
+
startedAt: Date.now(),
|
|
201
|
+
state: "stopped",
|
|
202
|
+
completedUnits: 10,
|
|
203
|
+
cost: 0.50,
|
|
204
|
+
},
|
|
205
|
+
],
|
|
206
|
+
});
|
|
207
|
+
writeStateFile(basePath, state);
|
|
208
|
+
|
|
209
|
+
const result = restoreState(basePath);
|
|
210
|
+
assertEq(result, null, "restoreState: skips stopped workers");
|
|
211
|
+
} finally {
|
|
212
|
+
rmSync(basePath, { recursive: true, force: true });
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Test 6: orphan detection finds stale sessions
|
|
217
|
+
{
|
|
218
|
+
const basePath = makeTempDir();
|
|
219
|
+
try {
|
|
220
|
+
// Write a session status with a dead PID
|
|
221
|
+
mkdirSync(join(basePath, ".gsd", "parallel"), { recursive: true });
|
|
222
|
+
writeSessionStatus(basePath, {
|
|
223
|
+
milestoneId: "M001",
|
|
224
|
+
pid: 99999999,
|
|
225
|
+
state: "running",
|
|
226
|
+
currentUnit: null,
|
|
227
|
+
completedUnits: 3,
|
|
228
|
+
cost: 0.10,
|
|
229
|
+
lastHeartbeat: Date.now(),
|
|
230
|
+
startedAt: Date.now(),
|
|
231
|
+
worktreePath: "/tmp/wt-M001",
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
// Write a session status with alive PID
|
|
235
|
+
writeSessionStatus(basePath, {
|
|
236
|
+
milestoneId: "M002",
|
|
237
|
+
pid: process.pid,
|
|
238
|
+
state: "running",
|
|
239
|
+
currentUnit: null,
|
|
240
|
+
completedUnits: 1,
|
|
241
|
+
cost: 0.05,
|
|
242
|
+
lastHeartbeat: Date.now(),
|
|
243
|
+
startedAt: Date.now(),
|
|
244
|
+
worktreePath: "/tmp/wt-M002",
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
// Read all sessions — both should exist initially
|
|
248
|
+
const before = readAllSessionStatuses(basePath);
|
|
249
|
+
assertEq(before.length, 2, "orphan: both sessions exist before detection");
|
|
250
|
+
|
|
251
|
+
// Now simulate orphan detection logic (same as prepareParallelStart)
|
|
252
|
+
const sessions = readAllSessionStatuses(basePath);
|
|
253
|
+
const orphans: Array<{ milestoneId: string; pid: number; alive: boolean }> = [];
|
|
254
|
+
for (const session of sessions) {
|
|
255
|
+
let alive: boolean;
|
|
256
|
+
try {
|
|
257
|
+
process.kill(session.pid, 0);
|
|
258
|
+
alive = true;
|
|
259
|
+
} catch {
|
|
260
|
+
alive = false;
|
|
261
|
+
}
|
|
262
|
+
orphans.push({ milestoneId: session.milestoneId, pid: session.pid, alive });
|
|
263
|
+
if (!alive) {
|
|
264
|
+
removeSessionStatus(basePath, session.milestoneId);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
assertTrue(orphans.length === 2, "orphan: detected both sessions");
|
|
269
|
+
const deadOrphan = orphans.find(o => o.milestoneId === "M001");
|
|
270
|
+
assertTrue(deadOrphan !== undefined && !deadOrphan.alive, "orphan: M001 detected as dead");
|
|
271
|
+
const aliveOrphan = orphans.find(o => o.milestoneId === "M002");
|
|
272
|
+
assertTrue(aliveOrphan !== undefined && aliveOrphan.alive, "orphan: M002 detected as alive");
|
|
273
|
+
|
|
274
|
+
// Dead session should be cleaned up
|
|
275
|
+
const after = readAllSessionStatuses(basePath);
|
|
276
|
+
assertEq(after.length, 1, "orphan: dead session cleaned up");
|
|
277
|
+
assertEq(after[0].milestoneId, "M002", "orphan: alive session remains");
|
|
278
|
+
} finally {
|
|
279
|
+
rmSync(basePath, { recursive: true, force: true });
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// Test 7: restoreState handles corrupt JSON gracefully
|
|
284
|
+
{
|
|
285
|
+
const basePath = makeTempDir();
|
|
286
|
+
try {
|
|
287
|
+
writeFileSync(stateFilePath(basePath), "{ not valid json !!!", "utf-8");
|
|
288
|
+
const result = restoreState(basePath);
|
|
289
|
+
assertEq(result, null, "restoreState: returns null for corrupt JSON");
|
|
290
|
+
} finally {
|
|
291
|
+
rmSync(basePath, { recursive: true, force: true });
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// Clean up module state
|
|
296
|
+
resetOrchestrator();
|
|
297
|
+
|
|
298
|
+
report();
|