gsd-pi 2.52.0 → 2.53.0-dev.a67436f

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 (247) hide show
  1. package/README.md +55 -32
  2. package/dist/headless-query.js +1 -1
  3. package/dist/headless-ui.d.ts +2 -2
  4. package/dist/headless-ui.js +18 -15
  5. package/dist/headless.d.ts +11 -0
  6. package/dist/headless.js +178 -38
  7. package/dist/resources/extensions/get-secrets-from-user.js +7 -0
  8. package/dist/resources/extensions/gsd/auto/phases.js +28 -8
  9. package/dist/resources/extensions/gsd/auto-dispatch.js +5 -1
  10. package/dist/resources/extensions/gsd/auto-worktree.js +70 -14
  11. package/dist/resources/extensions/gsd/auto.js +22 -0
  12. package/dist/resources/extensions/gsd/bootstrap/agent-end-recovery.js +4 -10
  13. package/dist/resources/extensions/gsd/commands-prefs-wizard.js +3 -3
  14. package/dist/resources/extensions/gsd/docs/preferences-reference.md +2 -2
  15. package/dist/resources/extensions/gsd/git-service.js +4 -3
  16. package/dist/resources/extensions/gsd/guided-flow.js +4 -3
  17. package/dist/resources/extensions/gsd/markdown-renderer.js +5 -4
  18. package/dist/resources/extensions/gsd/parallel-orchestrator.js +18 -2
  19. package/dist/resources/extensions/gsd/preferences-types.js +1 -1
  20. package/dist/resources/extensions/gsd/state.js +18 -29
  21. package/dist/resources/extensions/gsd/status-guards.js +12 -0
  22. package/dist/resources/extensions/gsd/tools/complete-milestone.js +4 -3
  23. package/dist/resources/extensions/gsd/tools/complete-slice.js +4 -3
  24. package/dist/resources/extensions/gsd/tools/complete-task.js +4 -3
  25. package/dist/resources/extensions/gsd/tools/plan-milestone.js +4 -14
  26. package/dist/resources/extensions/gsd/tools/plan-slice.js +4 -14
  27. package/dist/resources/extensions/gsd/tools/plan-task.js +4 -14
  28. package/dist/resources/extensions/gsd/tools/reassess-roadmap.js +6 -7
  29. package/dist/resources/extensions/gsd/tools/reopen-slice.js +4 -3
  30. package/dist/resources/extensions/gsd/tools/reopen-task.js +5 -4
  31. package/dist/resources/extensions/gsd/tools/replan-slice.js +5 -6
  32. package/dist/resources/extensions/gsd/validation.js +21 -0
  33. package/dist/resources/extensions/shared/rtk.js +14 -4
  34. package/dist/rtk.js +3 -1
  35. package/dist/web/standalone/.next/BUILD_ID +1 -1
  36. package/dist/web/standalone/.next/app-path-routes-manifest.json +20 -20
  37. package/dist/web/standalone/.next/build-manifest.json +4 -4
  38. package/dist/web/standalone/.next/prerender-manifest.json +3 -3
  39. package/dist/web/standalone/.next/react-loadable-manifest.json +1 -1
  40. package/dist/web/standalone/.next/required-server-files.json +3 -3
  41. package/dist/web/standalone/.next/server/app/_global-error/page.js +3 -3
  42. package/dist/web/standalone/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
  43. package/dist/web/standalone/.next/server/app/_global-error.html +2 -2
  44. package/dist/web/standalone/.next/server/app/_global-error.rsc +1 -1
  45. package/dist/web/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  46. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
  47. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error.segment.rsc +1 -1
  48. package/dist/web/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  49. package/dist/web/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  50. package/dist/web/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  51. package/dist/web/standalone/.next/server/app/_not-found/page.js +2 -2
  52. package/dist/web/standalone/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  53. package/dist/web/standalone/.next/server/app/_not-found.html +1 -1
  54. package/dist/web/standalone/.next/server/app/_not-found.rsc +3 -3
  55. package/dist/web/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +3 -3
  56. package/dist/web/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
  57. package/dist/web/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +3 -3
  58. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  59. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  60. package/dist/web/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  61. package/dist/web/standalone/.next/server/app/api/boot/route.js +1 -1
  62. package/dist/web/standalone/.next/server/app/api/boot/route_client-reference-manifest.js +1 -1
  63. package/dist/web/standalone/.next/server/app/api/bridge-terminal/input/route.js +1 -1
  64. package/dist/web/standalone/.next/server/app/api/bridge-terminal/input/route_client-reference-manifest.js +1 -1
  65. package/dist/web/standalone/.next/server/app/api/bridge-terminal/resize/route.js +1 -1
  66. package/dist/web/standalone/.next/server/app/api/bridge-terminal/resize/route_client-reference-manifest.js +1 -1
  67. package/dist/web/standalone/.next/server/app/api/bridge-terminal/stream/route.js +2 -2
  68. package/dist/web/standalone/.next/server/app/api/bridge-terminal/stream/route_client-reference-manifest.js +1 -1
  69. package/dist/web/standalone/.next/server/app/api/browse-directories/route.js +1 -1
  70. package/dist/web/standalone/.next/server/app/api/browse-directories/route_client-reference-manifest.js +1 -1
  71. package/dist/web/standalone/.next/server/app/api/captures/route.js +1 -1
  72. package/dist/web/standalone/.next/server/app/api/captures/route_client-reference-manifest.js +1 -1
  73. package/dist/web/standalone/.next/server/app/api/cleanup/route.js +1 -1
  74. package/dist/web/standalone/.next/server/app/api/cleanup/route_client-reference-manifest.js +1 -1
  75. package/dist/web/standalone/.next/server/app/api/dev-mode/route.js +1 -1
  76. package/dist/web/standalone/.next/server/app/api/dev-mode/route_client-reference-manifest.js +1 -1
  77. package/dist/web/standalone/.next/server/app/api/doctor/route.js +1 -1
  78. package/dist/web/standalone/.next/server/app/api/doctor/route_client-reference-manifest.js +1 -1
  79. package/dist/web/standalone/.next/server/app/api/experimental/route.js +2 -2
  80. package/dist/web/standalone/.next/server/app/api/experimental/route_client-reference-manifest.js +1 -1
  81. package/dist/web/standalone/.next/server/app/api/export-data/route.js +1 -1
  82. package/dist/web/standalone/.next/server/app/api/export-data/route_client-reference-manifest.js +1 -1
  83. package/dist/web/standalone/.next/server/app/api/files/route.js +1 -1
  84. package/dist/web/standalone/.next/server/app/api/files/route_client-reference-manifest.js +1 -1
  85. package/dist/web/standalone/.next/server/app/api/forensics/route.js +1 -1
  86. package/dist/web/standalone/.next/server/app/api/forensics/route_client-reference-manifest.js +1 -1
  87. package/dist/web/standalone/.next/server/app/api/git/route.js +1 -1
  88. package/dist/web/standalone/.next/server/app/api/git/route_client-reference-manifest.js +1 -1
  89. package/dist/web/standalone/.next/server/app/api/history/route.js +1 -1
  90. package/dist/web/standalone/.next/server/app/api/history/route_client-reference-manifest.js +1 -1
  91. package/dist/web/standalone/.next/server/app/api/hooks/route.js +1 -1
  92. package/dist/web/standalone/.next/server/app/api/hooks/route_client-reference-manifest.js +1 -1
  93. package/dist/web/standalone/.next/server/app/api/inspect/route.js +1 -1
  94. package/dist/web/standalone/.next/server/app/api/inspect/route_client-reference-manifest.js +1 -1
  95. package/dist/web/standalone/.next/server/app/api/knowledge/route.js +1 -1
  96. package/dist/web/standalone/.next/server/app/api/knowledge/route_client-reference-manifest.js +1 -1
  97. package/dist/web/standalone/.next/server/app/api/live-state/route.js +1 -1
  98. package/dist/web/standalone/.next/server/app/api/live-state/route_client-reference-manifest.js +1 -1
  99. package/dist/web/standalone/.next/server/app/api/onboarding/route.js +1 -1
  100. package/dist/web/standalone/.next/server/app/api/onboarding/route_client-reference-manifest.js +1 -1
  101. package/dist/web/standalone/.next/server/app/api/preferences/route.js +1 -1
  102. package/dist/web/standalone/.next/server/app/api/preferences/route_client-reference-manifest.js +1 -1
  103. package/dist/web/standalone/.next/server/app/api/projects/route.js +1 -1
  104. package/dist/web/standalone/.next/server/app/api/projects/route_client-reference-manifest.js +1 -1
  105. package/dist/web/standalone/.next/server/app/api/recovery/route.js +1 -1
  106. package/dist/web/standalone/.next/server/app/api/recovery/route_client-reference-manifest.js +1 -1
  107. package/dist/web/standalone/.next/server/app/api/remote-questions/route.js +2 -2
  108. package/dist/web/standalone/.next/server/app/api/remote-questions/route_client-reference-manifest.js +1 -1
  109. package/dist/web/standalone/.next/server/app/api/session/browser/route.js +1 -1
  110. package/dist/web/standalone/.next/server/app/api/session/browser/route_client-reference-manifest.js +1 -1
  111. package/dist/web/standalone/.next/server/app/api/session/command/route.js +1 -1
  112. package/dist/web/standalone/.next/server/app/api/session/command/route_client-reference-manifest.js +1 -1
  113. package/dist/web/standalone/.next/server/app/api/session/events/route.js +2 -2
  114. package/dist/web/standalone/.next/server/app/api/session/events/route_client-reference-manifest.js +1 -1
  115. package/dist/web/standalone/.next/server/app/api/session/manage/route.js +1 -1
  116. package/dist/web/standalone/.next/server/app/api/session/manage/route_client-reference-manifest.js +1 -1
  117. package/dist/web/standalone/.next/server/app/api/settings-data/route.js +1 -1
  118. package/dist/web/standalone/.next/server/app/api/settings-data/route_client-reference-manifest.js +1 -1
  119. package/dist/web/standalone/.next/server/app/api/shutdown/route.js +1 -1
  120. package/dist/web/standalone/.next/server/app/api/shutdown/route_client-reference-manifest.js +1 -1
  121. package/dist/web/standalone/.next/server/app/api/skill-health/route.js +1 -1
  122. package/dist/web/standalone/.next/server/app/api/skill-health/route_client-reference-manifest.js +1 -1
  123. package/dist/web/standalone/.next/server/app/api/steer/route.js +1 -1
  124. package/dist/web/standalone/.next/server/app/api/steer/route_client-reference-manifest.js +1 -1
  125. package/dist/web/standalone/.next/server/app/api/switch-root/route.js +1 -1
  126. package/dist/web/standalone/.next/server/app/api/switch-root/route_client-reference-manifest.js +1 -1
  127. package/dist/web/standalone/.next/server/app/api/terminal/input/route.js +2 -2
  128. package/dist/web/standalone/.next/server/app/api/terminal/input/route_client-reference-manifest.js +1 -1
  129. package/dist/web/standalone/.next/server/app/api/terminal/resize/route.js +2 -2
  130. package/dist/web/standalone/.next/server/app/api/terminal/resize/route_client-reference-manifest.js +1 -1
  131. package/dist/web/standalone/.next/server/app/api/terminal/sessions/route.js +2 -2
  132. package/dist/web/standalone/.next/server/app/api/terminal/sessions/route_client-reference-manifest.js +1 -1
  133. package/dist/web/standalone/.next/server/app/api/terminal/stream/route.js +4 -4
  134. package/dist/web/standalone/.next/server/app/api/terminal/stream/route_client-reference-manifest.js +1 -1
  135. package/dist/web/standalone/.next/server/app/api/terminal/upload/route.js +1 -1
  136. package/dist/web/standalone/.next/server/app/api/terminal/upload/route_client-reference-manifest.js +1 -1
  137. package/dist/web/standalone/.next/server/app/api/undo/route.js +1 -1
  138. package/dist/web/standalone/.next/server/app/api/undo/route_client-reference-manifest.js +1 -1
  139. package/dist/web/standalone/.next/server/app/api/update/route.js +1 -1
  140. package/dist/web/standalone/.next/server/app/api/update/route_client-reference-manifest.js +1 -1
  141. package/dist/web/standalone/.next/server/app/api/visualizer/route.js +1 -1
  142. package/dist/web/standalone/.next/server/app/api/visualizer/route_client-reference-manifest.js +1 -1
  143. package/dist/web/standalone/.next/server/app/index.html +1 -1
  144. package/dist/web/standalone/.next/server/app/index.rsc +4 -4
  145. package/dist/web/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +2 -2
  146. package/dist/web/standalone/.next/server/app/index.segments/_full.segment.rsc +4 -4
  147. package/dist/web/standalone/.next/server/app/index.segments/_head.segment.rsc +1 -1
  148. package/dist/web/standalone/.next/server/app/index.segments/_index.segment.rsc +3 -3
  149. package/dist/web/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
  150. package/dist/web/standalone/.next/server/app/page.js +2 -2
  151. package/dist/web/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
  152. package/dist/web/standalone/.next/server/app-paths-manifest.json +20 -20
  153. package/dist/web/standalone/.next/server/chunks/2229.js +1 -1
  154. package/dist/web/standalone/.next/server/chunks/7471.js +3 -3
  155. package/dist/web/standalone/.next/server/middleware-build-manifest.js +1 -1
  156. package/dist/web/standalone/.next/server/middleware-react-loadable-manifest.js +1 -1
  157. package/dist/web/standalone/.next/server/middleware.js +2 -2
  158. package/dist/web/standalone/.next/server/next-font-manifest.js +1 -1
  159. package/dist/web/standalone/.next/server/next-font-manifest.json +1 -1
  160. package/dist/web/standalone/.next/server/pages/404.html +1 -1
  161. package/dist/web/standalone/.next/server/pages/500.html +2 -2
  162. package/dist/web/standalone/.next/server/server-reference-manifest.json +1 -1
  163. package/dist/web/standalone/.next/static/chunks/4024.87fd909ae0110f50.js +9 -0
  164. package/dist/web/standalone/.next/static/chunks/app/_not-found/{page-2f24283c162b6ab3.js → page-f2a7482d42a5614b.js} +1 -1
  165. package/dist/web/standalone/.next/static/chunks/app/{layout-9ecfd95f343793f0.js → layout-a16c7a7ecdf0c2cf.js} +1 -1
  166. package/dist/web/standalone/.next/static/chunks/app/page-b950e4e384cc62b3.js +1 -0
  167. package/dist/web/standalone/.next/static/chunks/main-app-fdab67f7802d7832.js +1 -0
  168. package/dist/web/standalone/.next/static/chunks/next/dist/client/components/builtin/global-error-459824ffb8c323dd.js +1 -0
  169. package/dist/web/standalone/.next/static/chunks/{webpack-024d82be84800e52.js → webpack-bca0e732db0dcec3.js} +1 -1
  170. package/dist/web/standalone/node_modules/node-pty/build/Makefile +2 -2
  171. package/dist/web/standalone/node_modules/node-pty/build/Release/pty.node +0 -0
  172. package/dist/web/standalone/node_modules/node-pty/build/pty.target.mk +14 -14
  173. package/dist/web/standalone/node_modules/node-pty/node-addon-api/node_addon_api.target.mk +14 -14
  174. package/dist/web/standalone/node_modules/node-pty/node-addon-api/node_addon_api_except.target.mk +14 -14
  175. package/dist/web/standalone/node_modules/node-pty/node-addon-api/node_addon_api_maybe.target.mk +14 -14
  176. package/dist/web/standalone/server.js +1 -1
  177. package/package.json +1 -1
  178. package/packages/mcp-server/README.md +6 -6
  179. package/packages/mcp-server/package.json +14 -4
  180. package/packages/mcp-server/src/cli.ts +1 -1
  181. package/packages/mcp-server/src/index.ts +1 -1
  182. package/packages/mcp-server/src/mcp-server.test.ts +2 -2
  183. package/packages/mcp-server/src/session-manager.ts +2 -2
  184. package/packages/mcp-server/src/types.ts +1 -1
  185. package/packages/pi-coding-agent/package.json +1 -1
  186. package/packages/rpc-client/README.md +125 -0
  187. package/packages/rpc-client/examples/basic-usage.ts +13 -0
  188. package/packages/rpc-client/package.json +17 -3
  189. package/packages/rpc-client/src/index.ts +10 -0
  190. package/packages/rpc-client/src/jsonl.ts +64 -0
  191. package/packages/rpc-client/src/rpc-client.test.ts +568 -0
  192. package/packages/rpc-client/src/rpc-client.ts +666 -0
  193. package/packages/rpc-client/src/rpc-types.ts +399 -0
  194. package/packages/rpc-client/tsconfig.examples.json +17 -0
  195. package/packages/rpc-client/tsconfig.json +24 -0
  196. package/pkg/package.json +1 -1
  197. package/scripts/ensure-workspace-builds.cjs +36 -8
  198. package/src/resources/extensions/get-secrets-from-user.ts +8 -0
  199. package/src/resources/extensions/gsd/auto/phases.ts +38 -7
  200. package/src/resources/extensions/gsd/auto-dispatch.ts +6 -1
  201. package/src/resources/extensions/gsd/auto-worktree.ts +73 -14
  202. package/src/resources/extensions/gsd/auto.ts +21 -0
  203. package/src/resources/extensions/gsd/bootstrap/agent-end-recovery.ts +4 -11
  204. package/src/resources/extensions/gsd/commands-prefs-wizard.ts +3 -3
  205. package/src/resources/extensions/gsd/docs/preferences-reference.md +2 -2
  206. package/src/resources/extensions/gsd/git-service.ts +4 -3
  207. package/src/resources/extensions/gsd/guided-flow.ts +4 -3
  208. package/src/resources/extensions/gsd/markdown-renderer.ts +5 -4
  209. package/src/resources/extensions/gsd/parallel-orchestrator.ts +23 -1
  210. package/src/resources/extensions/gsd/preferences-types.ts +1 -1
  211. package/src/resources/extensions/gsd/state.ts +18 -29
  212. package/src/resources/extensions/gsd/status-guards.ts +13 -0
  213. package/src/resources/extensions/gsd/tests/active-milestone-id-guard.test.ts +91 -0
  214. package/src/resources/extensions/gsd/tests/auto-stale-lock-self-kill.test.ts +87 -0
  215. package/src/resources/extensions/gsd/tests/auto-worktree-auto-resolve.test.ts +80 -0
  216. package/src/resources/extensions/gsd/tests/auto-worktree-milestone-merge.test.ts +1 -1
  217. package/src/resources/extensions/gsd/tests/collect-from-manifest.test.ts +39 -0
  218. package/src/resources/extensions/gsd/tests/git-service.test.ts +64 -30
  219. package/src/resources/extensions/gsd/tests/milestone-report-path.test.ts +51 -0
  220. package/src/resources/extensions/gsd/tests/parallel-orchestrator-zombie-cleanup.test.ts +277 -0
  221. package/src/resources/extensions/gsd/tests/phases-merge-error-stops-auto.test.ts +103 -0
  222. package/src/resources/extensions/gsd/tests/preferences.test.ts +1 -1
  223. package/src/resources/extensions/gsd/tests/rate-limit-model-fallback.test.ts +90 -0
  224. package/src/resources/extensions/gsd/tests/session-lock-transient-read.test.ts +9 -8
  225. package/src/resources/extensions/gsd/tests/stash-pop-gsd-conflict.test.ts +125 -0
  226. package/src/resources/extensions/gsd/tests/status-guards.test.ts +30 -0
  227. package/src/resources/extensions/gsd/tests/validate-milestone.test.ts +12 -2
  228. package/src/resources/extensions/gsd/tests/validation-gate-patterns.test.ts +124 -0
  229. package/src/resources/extensions/gsd/tests/validation.test.ts +72 -0
  230. package/src/resources/extensions/gsd/tools/complete-milestone.ts +4 -3
  231. package/src/resources/extensions/gsd/tools/complete-slice.ts +4 -3
  232. package/src/resources/extensions/gsd/tools/complete-task.ts +4 -3
  233. package/src/resources/extensions/gsd/tools/plan-milestone.ts +4 -16
  234. package/src/resources/extensions/gsd/tools/plan-slice.ts +4 -16
  235. package/src/resources/extensions/gsd/tools/plan-task.ts +4 -16
  236. package/src/resources/extensions/gsd/tools/reassess-roadmap.ts +6 -7
  237. package/src/resources/extensions/gsd/tools/reopen-slice.ts +4 -3
  238. package/src/resources/extensions/gsd/tools/reopen-task.ts +5 -4
  239. package/src/resources/extensions/gsd/tools/replan-slice.ts +5 -7
  240. package/src/resources/extensions/gsd/validation.ts +23 -0
  241. package/src/resources/extensions/shared/rtk.ts +22 -4
  242. package/dist/web/standalone/.next/static/chunks/4024.21054f459af5cc78.js +0 -9
  243. package/dist/web/standalone/.next/static/chunks/app/page-fbecd1237e2d6d1f.js +0 -1
  244. package/dist/web/standalone/.next/static/chunks/main-app-d3d4c336195465f9.js +0 -1
  245. package/dist/web/standalone/.next/static/chunks/next/dist/client/components/builtin/global-error-ab5a8926e07ec673.js +0 -1
  246. /package/dist/web/standalone/.next/static/{vlgS2rkXjxeKhgXhdp4lh → YO-PWFRitlHM-L-dotlmm}/_buildManifest.js +0 -0
  247. /package/dist/web/standalone/.next/static/{vlgS2rkXjxeKhgXhdp4lh → YO-PWFRitlHM-L-dotlmm}/_ssgManifest.js +0 -0
@@ -0,0 +1,277 @@
1
+ /**
2
+ * Regression tests for zombie worker cleanup (#2736).
3
+ *
4
+ * Verifies that:
5
+ * 1. refreshWorkerStatuses() deactivates the orchestrator when all workers
6
+ * are in terminal states (error/stopped).
7
+ * 2. restoreRuntimeState() (via getWorkerStatuses) returns empty when the
8
+ * cached state has only dead workers.
9
+ */
10
+
11
+ import test from "node:test";
12
+ import assert from "node:assert/strict";
13
+ import { mkdirSync, writeFileSync, rmSync, existsSync } from "node:fs";
14
+ import { join } from "node:path";
15
+ import { tmpdir } from "node:os";
16
+ import { randomUUID } from "node:crypto";
17
+
18
+ import {
19
+ persistState,
20
+ resetOrchestrator,
21
+ refreshWorkerStatuses,
22
+ isParallelActive,
23
+ getOrchestratorState,
24
+ getWorkerStatuses,
25
+ type PersistedState,
26
+ } from "../parallel-orchestrator.ts";
27
+
28
+ function makeTmpBase(): string {
29
+ const base = join(tmpdir(), `gsd-test-zombie-${randomUUID()}`);
30
+ mkdirSync(join(base, ".gsd", "parallel"), { recursive: true });
31
+ return base;
32
+ }
33
+
34
+ function cleanup(base: string): void {
35
+ try {
36
+ rmSync(base, { recursive: true, force: true });
37
+ } catch { /* non-fatal */ }
38
+ }
39
+
40
+ /** Write a fake orchestrator.json to simulate persisted state. */
41
+ function writePersistedState(basePath: string, data: PersistedState): void {
42
+ const dest = join(basePath, ".gsd", "orchestrator.json");
43
+ writeFileSync(dest, JSON.stringify(data, null, 2), "utf-8");
44
+ }
45
+
46
+ /** Write a fake session status file to .gsd/parallel/<milestoneId>.status.json */
47
+ function writeSessionStatusFile(
48
+ basePath: string,
49
+ milestoneId: string,
50
+ state: "running" | "paused" | "stopped" | "error",
51
+ pid: number,
52
+ ): void {
53
+ const dest = join(basePath, ".gsd", "parallel", `${milestoneId}.status.json`);
54
+ writeFileSync(
55
+ dest,
56
+ JSON.stringify({
57
+ milestoneId,
58
+ pid,
59
+ state,
60
+ currentUnit: null,
61
+ completedUnits: 0,
62
+ cost: 0.5,
63
+ lastHeartbeat: Date.now(),
64
+ startedAt: Date.now() - 60_000,
65
+ worktreePath: join(basePath, "worktrees", milestoneId),
66
+ }),
67
+ "utf-8",
68
+ );
69
+ }
70
+
71
+ // Use a PID that is guaranteed dead — PID 1 is init/launchd and won't be
72
+ // killable by this process, but 2147483647 is unlikely to exist.
73
+ const DEAD_PID = 2147483647;
74
+
75
+ // ─── refreshWorkerStatuses: deactivates when all workers dead ──────────
76
+
77
+ test("#2736: refreshWorkerStatuses deactivates orchestrator when all workers are error/stopped", (t) => {
78
+ const base = makeTmpBase();
79
+ t.after(() => {
80
+ resetOrchestrator();
81
+ cleanup(base);
82
+ });
83
+
84
+ // Seed persisted state with two workers using current PID (alive) so
85
+ // restoreState() accepts them, then immediately mark them as error via
86
+ // session status files so refreshWorkerStatuses sees terminal states.
87
+ const persisted: PersistedState = {
88
+ active: true,
89
+ workers: [
90
+ {
91
+ milestoneId: "M001",
92
+ title: "Milestone 1",
93
+ pid: process.pid, // alive PID so restoreState accepts it
94
+ worktreePath: join(base, "worktrees", "M001"),
95
+ startedAt: Date.now() - 60_000,
96
+ state: "running",
97
+ cost: 1.0,
98
+ },
99
+ {
100
+ milestoneId: "M002",
101
+ title: "Milestone 2",
102
+ pid: process.pid,
103
+ worktreePath: join(base, "worktrees", "M002"),
104
+ startedAt: Date.now() - 60_000,
105
+ state: "running",
106
+ cost: 0.5,
107
+ },
108
+ ],
109
+ totalCost: 1.5,
110
+ startedAt: Date.now() - 60_000,
111
+ configSnapshot: { max_workers: 3 },
112
+ };
113
+ writePersistedState(base, persisted);
114
+
115
+ // First, restore the state into memory via getWorkerStatuses (triggers restoreIfNeeded)
116
+ const workers = getWorkerStatuses(base);
117
+ assert.equal(workers.length, 2, "should have 2 workers after restore");
118
+ assert.ok(isParallelActive(), "orchestrator should be active after restore");
119
+
120
+ // Now write session status files marking both workers as error
121
+ writeSessionStatusFile(base, "M001", "error", process.pid);
122
+ writeSessionStatusFile(base, "M002", "error", process.pid);
123
+
124
+ // Refresh — should detect all-dead and deactivate
125
+ refreshWorkerStatuses(base);
126
+
127
+ assert.equal(isParallelActive(), false, "orchestrator should be inactive after all workers died");
128
+ assert.equal(getOrchestratorState(), null, "state should be null after cleanup");
129
+ });
130
+
131
+ test("#2736: refreshWorkerStatuses keeps orchestrator active when some workers are still running", (t) => {
132
+ const base = makeTmpBase();
133
+ t.after(() => {
134
+ resetOrchestrator();
135
+ cleanup(base);
136
+ });
137
+
138
+ const persisted: PersistedState = {
139
+ active: true,
140
+ workers: [
141
+ {
142
+ milestoneId: "M001",
143
+ title: "Milestone 1",
144
+ pid: process.pid,
145
+ worktreePath: join(base, "worktrees", "M001"),
146
+ startedAt: Date.now() - 60_000,
147
+ state: "running",
148
+ cost: 1.0,
149
+ },
150
+ {
151
+ milestoneId: "M002",
152
+ title: "Milestone 2",
153
+ pid: process.pid,
154
+ worktreePath: join(base, "worktrees", "M002"),
155
+ startedAt: Date.now() - 60_000,
156
+ state: "running",
157
+ cost: 0.5,
158
+ },
159
+ ],
160
+ totalCost: 1.5,
161
+ startedAt: Date.now() - 60_000,
162
+ configSnapshot: { max_workers: 3 },
163
+ };
164
+ writePersistedState(base, persisted);
165
+
166
+ // Restore state
167
+ getWorkerStatuses(base);
168
+
169
+ // Mark M001 as error but keep M002 running
170
+ writeSessionStatusFile(base, "M001", "error", process.pid);
171
+ writeSessionStatusFile(base, "M002", "running", process.pid);
172
+
173
+ refreshWorkerStatuses(base);
174
+
175
+ assert.ok(isParallelActive(), "orchestrator should remain active with a running worker");
176
+ assert.ok(getOrchestratorState() !== null, "state should still exist");
177
+ });
178
+
179
+ // ─── restoreRuntimeState: returns false when cached state has only dead workers ─
180
+
181
+ test("#2736: getWorkerStatuses returns empty when all cached workers are in error state", (t) => {
182
+ const base = makeTmpBase();
183
+ t.after(() => {
184
+ resetOrchestrator();
185
+ cleanup(base);
186
+ });
187
+
188
+ // First, set up active state with live workers
189
+ const persisted: PersistedState = {
190
+ active: true,
191
+ workers: [
192
+ {
193
+ milestoneId: "M001",
194
+ title: "Milestone 1",
195
+ pid: process.pid,
196
+ worktreePath: join(base, "worktrees", "M001"),
197
+ startedAt: Date.now() - 60_000,
198
+ state: "running",
199
+ cost: 0.5,
200
+ },
201
+ ],
202
+ totalCost: 0.5,
203
+ startedAt: Date.now() - 60_000,
204
+ configSnapshot: { max_workers: 3 },
205
+ };
206
+ writePersistedState(base, persisted);
207
+
208
+ // Restore into memory
209
+ getWorkerStatuses(base);
210
+ assert.ok(isParallelActive(), "should be active initially");
211
+
212
+ // Simulate all workers dying: write error status then refresh to update
213
+ writeSessionStatusFile(base, "M001", "error", process.pid);
214
+ refreshWorkerStatuses(base);
215
+
216
+ // State should now be cleared
217
+ assert.equal(getOrchestratorState(), null, "state should be null after all workers error");
218
+
219
+ // Reset and try again — getWorkerStatuses with restoreIfNeeded should
220
+ // find no live workers on disk (orchestrator.json was cleaned up)
221
+ const workers = getWorkerStatuses(base);
222
+ assert.equal(workers.length, 0, "should return empty when no live workers exist");
223
+ });
224
+
225
+ test("#2736: restoreRuntimeState clears stale state when all workers are stopped", (t) => {
226
+ const base = makeTmpBase();
227
+ t.after(() => {
228
+ resetOrchestrator();
229
+ cleanup(base);
230
+ });
231
+
232
+ // Set up and restore state
233
+ const persisted: PersistedState = {
234
+ active: true,
235
+ workers: [
236
+ {
237
+ milestoneId: "M001",
238
+ title: "Milestone 1",
239
+ pid: process.pid,
240
+ worktreePath: join(base, "worktrees", "M001"),
241
+ startedAt: Date.now() - 60_000,
242
+ state: "running",
243
+ cost: 0.3,
244
+ },
245
+ {
246
+ milestoneId: "M002",
247
+ title: "Milestone 2",
248
+ pid: process.pid,
249
+ worktreePath: join(base, "worktrees", "M002"),
250
+ startedAt: Date.now() - 60_000,
251
+ state: "running",
252
+ cost: 0.7,
253
+ },
254
+ ],
255
+ totalCost: 1.0,
256
+ startedAt: Date.now() - 60_000,
257
+ configSnapshot: { max_workers: 3 },
258
+ };
259
+ writePersistedState(base, persisted);
260
+
261
+ // Restore into memory
262
+ getWorkerStatuses(base);
263
+ assert.ok(isParallelActive(), "should be active initially");
264
+
265
+ // Mark all as stopped via session status, then refresh
266
+ writeSessionStatusFile(base, "M001", "stopped", process.pid);
267
+ writeSessionStatusFile(base, "M002", "stopped", process.pid);
268
+ refreshWorkerStatuses(base);
269
+
270
+ // Orchestrator should be deactivated and state cleaned
271
+ assert.equal(isParallelActive(), false, "should be inactive after all workers stopped");
272
+ assert.equal(getOrchestratorState(), null, "state should be null");
273
+
274
+ // Verify the state file was removed
275
+ const stateFile = join(base, ".gsd", "orchestrator.json");
276
+ assert.equal(existsSync(stateFile), false, "orchestrator.json should be removed");
277
+ });
@@ -0,0 +1,103 @@
1
+ /**
2
+ * phases-merge-error-stops-auto.test.ts — Regression test for #2766.
3
+ *
4
+ * When mergeAndExit throws a non-MergeConflictError, the auto loop must
5
+ * stop instead of continuing with unmerged work. This test verifies that
6
+ * all catch blocks in auto/phases.ts that handle mergeAndExit errors
7
+ * call stopAuto and return { action: "break" } for non-conflict errors.
8
+ */
9
+
10
+ import { readFileSync } from "node:fs";
11
+ import { join } from "node:path";
12
+ import { createTestContext } from "./test-helpers.ts";
13
+
14
+ const { assertTrue, report } = createTestContext();
15
+
16
+ const phasesPath = join(import.meta.dirname, "..", "auto", "phases.ts");
17
+ const phasesSrc = readFileSync(phasesPath, "utf-8");
18
+
19
+ console.log("\n=== #2766: Non-MergeConflictError stops auto mode ===");
20
+
21
+ // ── Test 1: phases.ts calls logError for non-conflict merge errors ──────
22
+
23
+ assertTrue(
24
+ phasesPath.length > 0 && phasesPath.endsWith("phases.ts"),
25
+ "phases.ts file exists and is readable",
26
+ );
27
+
28
+ // Count all mergeAndExit catch blocks by finding "} catch (mergeErr)" patterns
29
+ const mergeErrCatches = [...phasesPath.matchAll(/\} catch \(mergeErr\)/g)];
30
+ // Use the source itself for matching
31
+ const mergeErrCatchCount = [...phasesSrc.matchAll(/\} catch \(mergeErr\)/g)].length;
32
+ assertTrue(
33
+ mergeErrCatchCount >= 3,
34
+ `all mergeAndExit call sites have catch (mergeErr) blocks (found ${mergeErrCatchCount}, expected >= 3)`,
35
+ );
36
+
37
+ // ── Test 2: Every mergeErr catch block handles non-MergeConflictError ───
38
+
39
+ // Find each catch block and verify it has the non-conflict error handling pattern
40
+ const catchPattern = /\} catch \(mergeErr\) \{/g;
41
+ let match;
42
+ let blocksWithNonConflictHandling = 0;
43
+ let blocksTotal = 0;
44
+
45
+ while ((match = catchPattern.exec(phasesSrc)) !== null) {
46
+ blocksTotal++;
47
+ // Look at the ~800 chars after the catch to find both the MergeConflictError
48
+ // instanceof check AND the non-conflict handling
49
+ const afterCatch = phasesSrc.slice(match.index, match.index + 1200);
50
+
51
+ const hasInstanceofCheck = afterCatch.includes("instanceof MergeConflictError");
52
+ const hasNonConflictStop = afterCatch.includes('reason: "merge-failed"');
53
+ const hasStopAuto = afterCatch.includes("stopAuto");
54
+ const hasLogError = afterCatch.includes("logError");
55
+
56
+ if (hasInstanceofCheck && hasNonConflictStop && hasStopAuto && hasLogError) {
57
+ blocksWithNonConflictHandling++;
58
+ }
59
+ }
60
+
61
+ assertTrue(
62
+ blocksWithNonConflictHandling === blocksTotal && blocksTotal >= 3,
63
+ `all ${blocksTotal} mergeAndExit catch blocks stop auto on non-conflict errors (${blocksWithNonConflictHandling}/${blocksTotal})`,
64
+ );
65
+
66
+ // ── Test 3: Non-conflict handler returns break (does not continue) ──────
67
+
68
+ // Verify the pattern: after the MergeConflictError instanceof block,
69
+ // the non-conflict path returns { action: "break", reason: "merge-failed" }
70
+ const mergeFailedReasons = [...phasesSrc.matchAll(/reason: "merge-failed"/g)].length;
71
+ assertTrue(
72
+ mergeFailedReasons >= 3,
73
+ `all catch blocks return reason: "merge-failed" (found ${mergeFailedReasons}, expected >= 3)`,
74
+ );
75
+
76
+ // ── Test 4: Non-conflict handler notifies user ──────────────────────────
77
+
78
+ // Each non-conflict block should call ctx.ui.notify with error severity
79
+ const notifyErrorPattern = /Merge failed:.*Resolve and run \/gsd auto to resume/g;
80
+ const notifyCount = [...phasesSrc.matchAll(notifyErrorPattern)].length;
81
+ assertTrue(
82
+ notifyCount >= 3,
83
+ `all catch blocks notify user about merge failure (found ${notifyCount}, expected >= 3)`,
84
+ );
85
+
86
+ // ── Test 5: logError replaces logWarning for non-conflict merge errors ──
87
+
88
+ // The old code used logWarning — verify logError is used instead
89
+ const logWarningMergePattern = /logWarning\(.*Milestone merge failed with non-conflict error/g;
90
+ const logWarningCount = [...phasesSrc.matchAll(logWarningMergePattern)].length;
91
+ assertTrue(
92
+ logWarningCount === 0,
93
+ "logWarning is no longer used for non-conflict merge errors (replaced by logError)",
94
+ );
95
+
96
+ const logErrorMergePattern = /logError\(.*Milestone merge failed with non-conflict error/g;
97
+ const logErrorCount = [...phasesSrc.matchAll(logErrorMergePattern)].length;
98
+ assertTrue(
99
+ logErrorCount >= 3,
100
+ `logError is used for non-conflict merge errors (found ${logErrorCount}, expected >= 3)`,
101
+ );
102
+
103
+ report();
@@ -59,7 +59,7 @@ test("solo mode applies correct defaults", () => {
59
59
  const result = applyModeDefaults("solo", { mode: "solo" });
60
60
  assert.equal(result.git?.auto_push, true);
61
61
  assert.equal(result.git?.push_branches, false);
62
- assert.equal(result.git?.pre_merge_check, false);
62
+ assert.equal(result.git?.pre_merge_check, "auto");
63
63
  assert.equal(result.git?.merge_strategy, "squash");
64
64
  assert.equal(result.git?.isolation, "none");
65
65
  assert.equal(result.unique_milestone_ids, false);
@@ -0,0 +1,90 @@
1
+ /**
2
+ * rate-limit-model-fallback.test.ts — Regression test for #2770.
3
+ *
4
+ * Rate-limit errors enter the model fallback path before falling through
5
+ * to pause. This verifies the structural contract in agent-end-recovery.ts.
6
+ */
7
+
8
+ import test from "node:test";
9
+ import assert from "node:assert/strict";
10
+ import { readFileSync } from "node:fs";
11
+ import { join, dirname } from "node:path";
12
+ import { fileURLToPath } from "node:url";
13
+
14
+ const __dirname = dirname(fileURLToPath(import.meta.url));
15
+ const RECOVERY_PATH = join(__dirname, "..", "bootstrap", "agent-end-recovery.ts");
16
+
17
+ function getRecoverySource(): string {
18
+ return readFileSync(RECOVERY_PATH, "utf-8");
19
+ }
20
+
21
+ // ── Rate-limit errors attempt model fallback (#2770) ─────────────────────────
22
+
23
+ test("rate-limit errors enter the model fallback branch alongside other transient errors", () => {
24
+ const src = getRecoverySource();
25
+
26
+ // The condition that gates model fallback must include rate-limit.
27
+ // Match the if-condition that contains both "rate-limit" and fallback-related kinds.
28
+ const fallbackConditionRe = /if\s*\([^)]*cls\.kind\s*===\s*"rate-limit"[^)]*cls\.kind\s*===\s*"network"/;
29
+ const fallbackConditionReAlt = /if\s*\([^)]*cls\.kind\s*===\s*"network"[^)]*cls\.kind\s*===\s*"rate-limit"/;
30
+
31
+ assert.ok(
32
+ fallbackConditionRe.test(src) || fallbackConditionReAlt.test(src),
33
+ 'rate-limit must appear in the same if-condition as network/server for model fallback (#2770)',
34
+ );
35
+ });
36
+
37
+ test("rate-limit errors are NOT short-circuited to pause before model fallback", () => {
38
+ const src = getRecoverySource();
39
+
40
+ // The old code had a dedicated rate-limit early-return block before the fallback block.
41
+ // Verify it no longer exists.
42
+ const earlyRateLimitPause = /if\s*\(\s*cls\.kind\s*===\s*"rate-limit"\s*\)\s*\{[^}]*pauseTransientWithBackoff/;
43
+ assert.ok(
44
+ !earlyRateLimitPause.test(src),
45
+ 'rate-limit must NOT have a dedicated early pause before the model fallback path (#2770)',
46
+ );
47
+ });
48
+
49
+ test("rate-limit errors fall through to pause if no fallback model is available", () => {
50
+ const src = getRecoverySource();
51
+
52
+ // After the fallback block, the transient fallback pause must still fire for rate-limit.
53
+ // The isTransient check covers rate-limit (verified by error-classifier tests).
54
+ // Verify pauseTransientWithBackoff is called with isRateLimit derived from cls.kind.
55
+ assert.ok(
56
+ src.includes('cls.kind === "rate-limit"'),
57
+ 'agent-end-recovery.ts must reference cls.kind === "rate-limit" for fallback and pause paths (#2770)',
58
+ );
59
+
60
+ // The transient fallback pause must pass the isRateLimit flag correctly.
61
+ const pauseCallRe = /pauseTransientWithBackoff\([^)]*cls\.kind\s*===\s*"rate-limit"/;
62
+ assert.ok(
63
+ pauseCallRe.test(src),
64
+ 'pauseTransientWithBackoff must receive isRateLimit based on cls.kind === "rate-limit" (#2770)',
65
+ );
66
+ });
67
+
68
+ test("other transient errors (server, connection, stream) still attempt model fallback", () => {
69
+ const src = getRecoverySource();
70
+
71
+ // All transient kinds must appear in the fallback condition.
72
+ for (const kind of ["server", "connection", "stream"]) {
73
+ assert.ok(
74
+ src.includes(`cls.kind === "${kind}"`),
75
+ `model fallback condition must include cls.kind === "${kind}"`,
76
+ );
77
+ }
78
+ });
79
+
80
+ test("permanent errors still bypass model fallback and pause indefinitely", () => {
81
+ const src = getRecoverySource();
82
+
83
+ // The permanent/unknown error handler must exist and call pauseAutoForProviderError
84
+ // with isTransient: false.
85
+ const permanentPauseRe = /pauseAutoForProviderError[\s\S]{0,300}isTransient:\s*false/;
86
+ assert.ok(
87
+ permanentPauseRe.test(src),
88
+ 'permanent errors must pause with isTransient: false (no auto-resume)',
89
+ );
90
+ });
@@ -95,13 +95,13 @@ async function main(): Promise<void> {
95
95
  writeFileSync(lockFile, JSON.stringify(lockData, null, 2));
96
96
 
97
97
  // Simulate transient unavailability: move file away, spawn a child process
98
- // to restore it after 100ms. The child runs outside our event loop so it
99
- // fires even during busy-wait retries.
98
+ // to restore it shortly after. The child runs outside our event loop so it
99
+ // fires even during busy-wait retries. Give the test extra retry budget so
100
+ // it stays stable under full-suite CPU contention.
100
101
  renameSync(lockFile, tmpFile);
101
- spawn('bash', ['-c', `sleep 0.1 && mv "${tmpFile}" "${lockFile}"`], { stdio: 'ignore', detached: true }).unref();
102
+ spawn('bash', ['-c', `sleep 0.05 && mv "${tmpFile}" "${lockFile}"`], { stdio: 'ignore', detached: true }).unref();
102
103
 
103
- // With retries (3 attempts, 200ms delay), it should recover on 2nd or 3rd attempt
104
- const result = readExistingLockDataWithRetry(lockFile, { maxAttempts: 3, delayMs: 200 });
104
+ const result = readExistingLockDataWithRetry(lockFile, { maxAttempts: 8, delayMs: 400 });
105
105
  assertTrue(result !== null, 'data recovered after transient unavailability');
106
106
  if (result) {
107
107
  assertEq(result.pid, process.pid, 'correct PID after recovery');
@@ -131,11 +131,12 @@ async function main(): Promise<void> {
131
131
  writeFileSync(lockFile, JSON.stringify(lockData, null, 2));
132
132
 
133
133
  // Remove read permission to simulate NFS/CIFS latency, then spawn a child
134
- // to restore permissions after 100ms (runs outside our event loop).
134
+ // to restore permissions shortly after (runs outside our event loop).
135
+ // Use the same wider retry window as the rename case for full-suite stability.
135
136
  chmodSync(lockFile, 0o000);
136
- spawn('bash', ['-c', `sleep 0.1 && chmod 644 "${lockFile}"`], { stdio: 'ignore', detached: true }).unref();
137
+ spawn('bash', ['-c', `sleep 0.05 && chmod 644 "${lockFile}"`], { stdio: 'ignore', detached: true }).unref();
137
138
 
138
- const result = readExistingLockDataWithRetry(lockFile, { maxAttempts: 3, delayMs: 200 });
139
+ const result = readExistingLockDataWithRetry(lockFile, { maxAttempts: 8, delayMs: 400 });
139
140
  assertTrue(result !== null, 'data recovered after transient permission error');
140
141
  if (result) {
141
142
  assertEq(result.pid, process.pid, 'correct PID after permission recovery');
@@ -0,0 +1,125 @@
1
+ /**
2
+ * stash-pop-gsd-conflict.test.ts — Regression test for #2766.
3
+ *
4
+ * When a squash merge stash-pops and hits conflicts on .gsd/ state files,
5
+ * the UU entries block every subsequent merge. This test verifies that
6
+ * mergeMilestoneToMain auto-resolves .gsd/ conflicts by accepting HEAD
7
+ * and drops the stash, leaving the repo in a clean state.
8
+ */
9
+
10
+ import test from "node:test";
11
+ import assert from "node:assert/strict";
12
+ import { mkdtempSync, mkdirSync, writeFileSync, rmSync, existsSync, readFileSync, realpathSync } from "node:fs";
13
+ import { join } from "node:path";
14
+ import { tmpdir } from "node:os";
15
+ import { execSync } from "node:child_process";
16
+
17
+ import { createAutoWorktree, mergeMilestoneToMain } from "../auto-worktree.ts";
18
+
19
+ function run(cmd: string, cwd: string): string {
20
+ return execSync(cmd, { cwd, stdio: ["ignore", "pipe", "pipe"], encoding: "utf-8" }).trim();
21
+ }
22
+
23
+ function createTempRepo(): string {
24
+ const dir = realpathSync(mkdtempSync(join(tmpdir(), "wt-stashpop-test-")));
25
+ run("git init", dir);
26
+ run("git config user.email test@test.com", dir);
27
+ run("git config user.name Test", dir);
28
+ writeFileSync(join(dir, "README.md"), "# test\n");
29
+ mkdirSync(join(dir, ".gsd"), { recursive: true });
30
+ writeFileSync(join(dir, ".gsd", "STATE.md"), "version: 1\n");
31
+ run("git add .", dir);
32
+ run("git commit -m init", dir);
33
+ run("git branch -M main", dir);
34
+ return dir;
35
+ }
36
+
37
+ function makeRoadmap(milestoneId: string, title: string, slices: Array<{ id: string; title: string }>): string {
38
+ const sliceLines = slices.map(s => `- [x] **${s.id}: ${s.title}**`).join("\n");
39
+ return `# ${milestoneId}: ${title}\n\n## Slices\n${sliceLines}\n`;
40
+ }
41
+
42
+ test("#2766: stash pop conflict on .gsd/ files is auto-resolved", () => {
43
+ const repo = createTempRepo();
44
+ try {
45
+ const wtPath = createAutoWorktree(repo, "M300");
46
+
47
+ // Add a slice with real code on the milestone branch
48
+ const normalizedPath = wtPath.replaceAll("\\", "/");
49
+ const worktreeName = normalizedPath.split("/").pop() || "M300";
50
+ const sliceBranch = `slice/${worktreeName}/S01`;
51
+ run(`git checkout -b "${sliceBranch}"`, wtPath);
52
+ writeFileSync(join(wtPath, "feature.ts"), "export const feature = true;\n");
53
+
54
+ // Modify .gsd/STATE.md on the milestone branch (diverges from main)
55
+ writeFileSync(join(wtPath, ".gsd", "STATE.md"), "version: 2-milestone\n");
56
+ run("git add .", wtPath);
57
+ run('git commit -m "add feature and update state"', wtPath);
58
+ run("git checkout milestone/M300", wtPath);
59
+ run(`git merge --no-ff "${sliceBranch}" -m "merge S01: feature"`, wtPath);
60
+
61
+ // Dirty .gsd/STATE.md in the main repo (stash will conflict on pop)
62
+ writeFileSync(join(repo, ".gsd", "STATE.md"), "version: 2-main-dirty\n");
63
+
64
+ const roadmap = makeRoadmap("M300", "Stash pop conflict test", [
65
+ { id: "S01", title: "Feature" },
66
+ ]);
67
+
68
+ // mergeMilestoneToMain should succeed — .gsd/ conflict auto-resolved
69
+ const result = mergeMilestoneToMain(repo, "M300", roadmap);
70
+ assert.ok(
71
+ result.commitMessage.includes("GSD-Milestone: M300"),
72
+ "merge succeeds despite stash pop conflict on .gsd/ file",
73
+ );
74
+ assert.ok(existsSync(join(repo, "feature.ts")), "milestone code merged to main");
75
+
76
+ // Verify repo is clean (no UU entries blocking future merges)
77
+ const status = run("git status --porcelain", repo);
78
+ assert.ok(
79
+ !status.includes("UU "),
80
+ "no unmerged (UU) entries remain after stash pop conflict resolution",
81
+ );
82
+
83
+ // Stash should be dropped (no remaining stash entries)
84
+ let stashList = "";
85
+ try { stashList = run("git stash list", repo); } catch { /* empty stash */ }
86
+ assert.strictEqual(stashList, "", "stash is empty after .gsd/ conflict auto-resolution");
87
+ } finally {
88
+ try { rmSync(repo, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); } catch { /* cleanup best-effort */ }
89
+ }
90
+ });
91
+
92
+ test("#2766: stash pop conflict on non-.gsd files preserves stash for manual resolution", () => {
93
+ const repo = createTempRepo();
94
+ try {
95
+ const wtPath = createAutoWorktree(repo, "M301");
96
+
97
+ // Add a slice that modifies a file also dirty on main
98
+ const normalizedPath = wtPath.replaceAll("\\", "/");
99
+ const worktreeName = normalizedPath.split("/").pop() || "M301";
100
+ const sliceBranch = `slice/${worktreeName}/S01`;
101
+ run(`git checkout -b "${sliceBranch}"`, wtPath);
102
+ writeFileSync(join(wtPath, "README.md"), "# milestone version\n");
103
+ run("git add .", wtPath);
104
+ run('git commit -m "update readme"', wtPath);
105
+ run("git checkout milestone/M301", wtPath);
106
+ run(`git merge --no-ff "${sliceBranch}" -m "merge S01: readme"`, wtPath);
107
+
108
+ // Dirty README.md in the main repo — this will conflict on stash pop
109
+ // and is NOT a .gsd/ file, so it should be left for manual resolution
110
+ writeFileSync(join(repo, "README.md"), "# locally modified\n");
111
+
112
+ const roadmap = makeRoadmap("M301", "Non-gsd stash conflict", [
113
+ { id: "S01", title: "Readme update" },
114
+ ]);
115
+
116
+ // The merge itself should still succeed (stash pop conflict is non-fatal)
117
+ const result = mergeMilestoneToMain(repo, "M301", roadmap);
118
+ assert.ok(
119
+ result.commitMessage.includes("GSD-Milestone: M301"),
120
+ "merge succeeds even with non-.gsd stash pop conflict",
121
+ );
122
+ } finally {
123
+ try { rmSync(repo, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); } catch { /* cleanup best-effort */ }
124
+ }
125
+ });
@@ -0,0 +1,30 @@
1
+ // GSD — status-guards unit tests
2
+
3
+ import test from 'node:test';
4
+ import assert from 'node:assert/strict';
5
+
6
+ import { isClosedStatus } from '../status-guards.ts';
7
+
8
+ test('isClosedStatus: "complete" returns true', () => {
9
+ assert.equal(isClosedStatus('complete'), true);
10
+ });
11
+
12
+ test('isClosedStatus: "done" returns true', () => {
13
+ assert.equal(isClosedStatus('done'), true);
14
+ });
15
+
16
+ test('isClosedStatus: "pending" returns false', () => {
17
+ assert.equal(isClosedStatus('pending'), false);
18
+ });
19
+
20
+ test('isClosedStatus: "in_progress" returns false', () => {
21
+ assert.equal(isClosedStatus('in_progress'), false);
22
+ });
23
+
24
+ test('isClosedStatus: "active" returns false', () => {
25
+ assert.equal(isClosedStatus('active'), false);
26
+ });
27
+
28
+ test('isClosedStatus: "" (empty string) returns false', () => {
29
+ assert.equal(isClosedStatus(''), false);
30
+ });