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.
Files changed (171) hide show
  1. package/README.md +43 -6
  2. package/dist/cli.js +4 -2
  3. package/dist/headless.d.ts +3 -0
  4. package/dist/headless.js +136 -8
  5. package/dist/help-text.js +3 -0
  6. package/dist/loader.js +33 -4
  7. package/dist/resources/extensions/bg-shell/index.ts +19 -2
  8. package/dist/resources/extensions/bg-shell/process-manager.ts +45 -0
  9. package/dist/resources/extensions/bg-shell/types.ts +21 -1
  10. package/dist/resources/extensions/gsd/auto/session.ts +224 -0
  11. package/dist/resources/extensions/gsd/auto-budget.ts +32 -0
  12. package/dist/resources/extensions/gsd/auto-dashboard.ts +63 -10
  13. package/dist/resources/extensions/gsd/auto-direct-dispatch.ts +229 -0
  14. package/dist/resources/extensions/gsd/auto-dispatch.ts +23 -10
  15. package/dist/resources/extensions/gsd/auto-model-selection.ts +179 -0
  16. package/dist/resources/extensions/gsd/auto-observability.ts +74 -0
  17. package/dist/resources/extensions/gsd/auto-prompts.ts +0 -1
  18. package/dist/resources/extensions/gsd/auto-timeout-recovery.ts +262 -0
  19. package/dist/resources/extensions/gsd/auto-tool-tracking.ts +54 -0
  20. package/dist/resources/extensions/gsd/auto-unit-closeout.ts +46 -0
  21. package/dist/resources/extensions/gsd/auto-worktree-sync.ts +207 -0
  22. package/dist/resources/extensions/gsd/auto.ts +977 -1551
  23. package/dist/resources/extensions/gsd/commands.ts +3 -3
  24. package/dist/resources/extensions/gsd/dashboard-overlay.ts +47 -72
  25. package/dist/resources/extensions/gsd/doctor-proactive.ts +9 -4
  26. package/dist/resources/extensions/gsd/export-html.ts +1001 -0
  27. package/dist/resources/extensions/gsd/export.ts +49 -1
  28. package/dist/resources/extensions/gsd/git-service.ts +6 -0
  29. package/dist/resources/extensions/gsd/gitignore.ts +4 -1
  30. package/dist/resources/extensions/gsd/guided-flow.ts +24 -5
  31. package/dist/resources/extensions/gsd/index.ts +54 -1
  32. package/dist/resources/extensions/gsd/native-git-bridge.ts +30 -2
  33. package/dist/resources/extensions/gsd/observability-validator.ts +21 -0
  34. package/dist/resources/extensions/gsd/parallel-orchestrator.ts +231 -20
  35. package/dist/resources/extensions/gsd/preferences.ts +62 -1
  36. package/dist/resources/extensions/gsd/prompts/execute-task.md +4 -3
  37. package/dist/resources/extensions/gsd/prompts/system.md +1 -1
  38. package/dist/resources/extensions/gsd/reports.ts +510 -0
  39. package/dist/resources/extensions/gsd/roadmap-slices.ts +1 -1
  40. package/dist/resources/extensions/gsd/skills/gsd-headless/SKILL.md +178 -0
  41. package/dist/resources/extensions/gsd/skills/gsd-headless/references/answer-injection.md +54 -0
  42. package/dist/resources/extensions/gsd/skills/gsd-headless/references/commands.md +59 -0
  43. package/dist/resources/extensions/gsd/skills/gsd-headless/references/multi-session.md +185 -0
  44. package/dist/resources/extensions/gsd/state.ts +30 -0
  45. package/dist/resources/extensions/gsd/templates/task-summary.md +9 -0
  46. package/dist/resources/extensions/gsd/tests/auto-dashboard.test.ts +13 -0
  47. package/dist/resources/extensions/gsd/tests/continue-here.test.ts +81 -0
  48. package/dist/resources/extensions/gsd/tests/derive-state-db.test.ts +5 -0
  49. package/dist/resources/extensions/gsd/tests/derive-state-deps.test.ts +1 -0
  50. package/dist/resources/extensions/gsd/tests/derive-state-draft.test.ts +1 -0
  51. package/dist/resources/extensions/gsd/tests/derive-state.test.ts +10 -1
  52. package/dist/resources/extensions/gsd/tests/dispatch-missing-task-plans.test.ts +132 -0
  53. package/dist/resources/extensions/gsd/tests/doctor-proactive.test.ts +14 -0
  54. package/dist/resources/extensions/gsd/tests/integration-mixed-milestones.test.ts +1 -0
  55. package/dist/resources/extensions/gsd/tests/milestone-transition-worktree.test.ts +1 -1
  56. package/dist/resources/extensions/gsd/tests/native-has-changes-cache.test.ts +61 -0
  57. package/dist/resources/extensions/gsd/tests/network-error-fallback.test.ts +51 -1
  58. package/dist/resources/extensions/gsd/tests/parallel-budget-atomicity.test.ts +331 -0
  59. package/dist/resources/extensions/gsd/tests/parallel-crash-recovery.test.ts +298 -0
  60. package/dist/resources/extensions/gsd/tests/parallel-merge.test.ts +465 -0
  61. package/dist/resources/extensions/gsd/tests/parallel-orchestration.test.ts +39 -10
  62. package/dist/resources/extensions/gsd/tests/plan-slice-prompt.test.ts +71 -0
  63. package/dist/resources/extensions/gsd/tests/replan-slice.test.ts +42 -0
  64. package/dist/resources/extensions/gsd/tests/triage-dispatch.test.ts +9 -9
  65. package/dist/resources/extensions/gsd/tests/verification-evidence.test.ts +743 -0
  66. package/dist/resources/extensions/gsd/tests/verification-gate.test.ts +965 -0
  67. package/dist/resources/extensions/gsd/tests/visualizer-data.test.ts +1 -1
  68. package/dist/resources/extensions/gsd/tests/visualizer-overlay.test.ts +44 -10
  69. package/dist/resources/extensions/gsd/tests/worktree.test.ts +3 -1
  70. package/dist/resources/extensions/gsd/types.ts +38 -0
  71. package/dist/resources/extensions/gsd/verification-evidence.ts +183 -0
  72. package/dist/resources/extensions/gsd/verification-gate.ts +567 -0
  73. package/dist/resources/extensions/gsd/visualizer-data.ts +25 -3
  74. package/dist/resources/extensions/gsd/visualizer-overlay.ts +31 -21
  75. package/dist/resources/extensions/gsd/visualizer-views.ts +15 -66
  76. package/dist/resources/extensions/search-the-web/tool-search.ts +26 -0
  77. package/dist/resources/extensions/shared/format-utils.ts +85 -0
  78. package/dist/resources/extensions/shared/tests/format-utils.test.ts +153 -0
  79. package/dist/resources/extensions/subagent/index.ts +46 -1
  80. package/dist/resources/extensions/subagent/isolation.ts +9 -6
  81. package/package.json +1 -1
  82. package/packages/pi-ai/dist/providers/openai-completions.js +7 -4
  83. package/packages/pi-ai/dist/providers/openai-completions.js.map +1 -1
  84. package/packages/pi-ai/src/providers/openai-completions.ts +7 -4
  85. package/packages/pi-coding-agent/dist/core/lsp/client.d.ts.map +1 -1
  86. package/packages/pi-coding-agent/dist/core/lsp/client.js +7 -0
  87. package/packages/pi-coding-agent/dist/core/lsp/client.js.map +1 -1
  88. package/packages/pi-coding-agent/dist/core/lsp/config.d.ts.map +1 -1
  89. package/packages/pi-coding-agent/dist/core/lsp/config.js +9 -2
  90. package/packages/pi-coding-agent/dist/core/lsp/config.js.map +1 -1
  91. package/packages/pi-coding-agent/src/core/lsp/client.ts +8 -0
  92. package/packages/pi-coding-agent/src/core/lsp/config.ts +9 -2
  93. package/packages/pi-tui/dist/components/editor.d.ts.map +1 -1
  94. package/packages/pi-tui/dist/components/editor.js +1 -1
  95. package/packages/pi-tui/dist/components/editor.js.map +1 -1
  96. package/packages/pi-tui/src/components/editor.ts +3 -1
  97. package/scripts/link-workspace-packages.cjs +22 -6
  98. package/src/resources/extensions/bg-shell/index.ts +19 -2
  99. package/src/resources/extensions/bg-shell/process-manager.ts +45 -0
  100. package/src/resources/extensions/bg-shell/types.ts +21 -1
  101. package/src/resources/extensions/gsd/auto/session.ts +224 -0
  102. package/src/resources/extensions/gsd/auto-budget.ts +32 -0
  103. package/src/resources/extensions/gsd/auto-dashboard.ts +63 -10
  104. package/src/resources/extensions/gsd/auto-direct-dispatch.ts +229 -0
  105. package/src/resources/extensions/gsd/auto-dispatch.ts +23 -10
  106. package/src/resources/extensions/gsd/auto-model-selection.ts +179 -0
  107. package/src/resources/extensions/gsd/auto-observability.ts +74 -0
  108. package/src/resources/extensions/gsd/auto-prompts.ts +0 -1
  109. package/src/resources/extensions/gsd/auto-timeout-recovery.ts +262 -0
  110. package/src/resources/extensions/gsd/auto-tool-tracking.ts +54 -0
  111. package/src/resources/extensions/gsd/auto-unit-closeout.ts +46 -0
  112. package/src/resources/extensions/gsd/auto-worktree-sync.ts +207 -0
  113. package/src/resources/extensions/gsd/auto.ts +977 -1551
  114. package/src/resources/extensions/gsd/commands.ts +3 -3
  115. package/src/resources/extensions/gsd/dashboard-overlay.ts +47 -72
  116. package/src/resources/extensions/gsd/doctor-proactive.ts +9 -4
  117. package/src/resources/extensions/gsd/export-html.ts +1001 -0
  118. package/src/resources/extensions/gsd/export.ts +49 -1
  119. package/src/resources/extensions/gsd/git-service.ts +6 -0
  120. package/src/resources/extensions/gsd/gitignore.ts +4 -1
  121. package/src/resources/extensions/gsd/guided-flow.ts +24 -5
  122. package/src/resources/extensions/gsd/index.ts +54 -1
  123. package/src/resources/extensions/gsd/native-git-bridge.ts +30 -2
  124. package/src/resources/extensions/gsd/observability-validator.ts +21 -0
  125. package/src/resources/extensions/gsd/parallel-orchestrator.ts +231 -20
  126. package/src/resources/extensions/gsd/preferences.ts +62 -1
  127. package/src/resources/extensions/gsd/prompts/execute-task.md +4 -3
  128. package/src/resources/extensions/gsd/prompts/system.md +1 -1
  129. package/src/resources/extensions/gsd/reports.ts +510 -0
  130. package/src/resources/extensions/gsd/roadmap-slices.ts +1 -1
  131. package/src/resources/extensions/gsd/skills/gsd-headless/SKILL.md +178 -0
  132. package/src/resources/extensions/gsd/skills/gsd-headless/references/answer-injection.md +54 -0
  133. package/src/resources/extensions/gsd/skills/gsd-headless/references/commands.md +59 -0
  134. package/src/resources/extensions/gsd/skills/gsd-headless/references/multi-session.md +185 -0
  135. package/src/resources/extensions/gsd/state.ts +30 -0
  136. package/src/resources/extensions/gsd/templates/task-summary.md +9 -0
  137. package/src/resources/extensions/gsd/tests/auto-dashboard.test.ts +13 -0
  138. package/src/resources/extensions/gsd/tests/continue-here.test.ts +81 -0
  139. package/src/resources/extensions/gsd/tests/derive-state-db.test.ts +5 -0
  140. package/src/resources/extensions/gsd/tests/derive-state-deps.test.ts +1 -0
  141. package/src/resources/extensions/gsd/tests/derive-state-draft.test.ts +1 -0
  142. package/src/resources/extensions/gsd/tests/derive-state.test.ts +10 -1
  143. package/src/resources/extensions/gsd/tests/dispatch-missing-task-plans.test.ts +132 -0
  144. package/src/resources/extensions/gsd/tests/doctor-proactive.test.ts +14 -0
  145. package/src/resources/extensions/gsd/tests/integration-mixed-milestones.test.ts +1 -0
  146. package/src/resources/extensions/gsd/tests/milestone-transition-worktree.test.ts +1 -1
  147. package/src/resources/extensions/gsd/tests/native-has-changes-cache.test.ts +61 -0
  148. package/src/resources/extensions/gsd/tests/network-error-fallback.test.ts +51 -1
  149. package/src/resources/extensions/gsd/tests/parallel-budget-atomicity.test.ts +331 -0
  150. package/src/resources/extensions/gsd/tests/parallel-crash-recovery.test.ts +298 -0
  151. package/src/resources/extensions/gsd/tests/parallel-merge.test.ts +465 -0
  152. package/src/resources/extensions/gsd/tests/parallel-orchestration.test.ts +39 -10
  153. package/src/resources/extensions/gsd/tests/plan-slice-prompt.test.ts +71 -0
  154. package/src/resources/extensions/gsd/tests/replan-slice.test.ts +42 -0
  155. package/src/resources/extensions/gsd/tests/triage-dispatch.test.ts +9 -9
  156. package/src/resources/extensions/gsd/tests/verification-evidence.test.ts +743 -0
  157. package/src/resources/extensions/gsd/tests/verification-gate.test.ts +965 -0
  158. package/src/resources/extensions/gsd/tests/visualizer-data.test.ts +1 -1
  159. package/src/resources/extensions/gsd/tests/visualizer-overlay.test.ts +44 -10
  160. package/src/resources/extensions/gsd/tests/worktree.test.ts +3 -1
  161. package/src/resources/extensions/gsd/types.ts +38 -0
  162. package/src/resources/extensions/gsd/verification-evidence.ts +183 -0
  163. package/src/resources/extensions/gsd/verification-gate.ts +567 -0
  164. package/src/resources/extensions/gsd/visualizer-data.ts +25 -3
  165. package/src/resources/extensions/gsd/visualizer-overlay.ts +31 -21
  166. package/src/resources/extensions/gsd/visualizer-views.ts +15 -66
  167. package/src/resources/extensions/search-the-web/tool-search.ts +26 -0
  168. package/src/resources/extensions/shared/format-utils.ts +85 -0
  169. package/src/resources/extensions/shared/tests/format-utils.test.ts +153 -0
  170. package/src/resources/extensions/subagent/index.ts +46 -1
  171. 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();