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.
- package/README.md +55 -32
- package/dist/headless-query.js +1 -1
- package/dist/headless-ui.d.ts +2 -2
- package/dist/headless-ui.js +18 -15
- package/dist/headless.d.ts +11 -0
- package/dist/headless.js +178 -38
- package/dist/resources/extensions/get-secrets-from-user.js +7 -0
- package/dist/resources/extensions/gsd/auto/phases.js +28 -8
- package/dist/resources/extensions/gsd/auto-dispatch.js +5 -1
- package/dist/resources/extensions/gsd/auto-worktree.js +70 -14
- package/dist/resources/extensions/gsd/auto.js +22 -0
- package/dist/resources/extensions/gsd/bootstrap/agent-end-recovery.js +4 -10
- package/dist/resources/extensions/gsd/commands-prefs-wizard.js +3 -3
- package/dist/resources/extensions/gsd/docs/preferences-reference.md +2 -2
- package/dist/resources/extensions/gsd/git-service.js +4 -3
- package/dist/resources/extensions/gsd/guided-flow.js +4 -3
- package/dist/resources/extensions/gsd/markdown-renderer.js +5 -4
- package/dist/resources/extensions/gsd/parallel-orchestrator.js +18 -2
- package/dist/resources/extensions/gsd/preferences-types.js +1 -1
- package/dist/resources/extensions/gsd/state.js +18 -29
- package/dist/resources/extensions/gsd/status-guards.js +12 -0
- package/dist/resources/extensions/gsd/tools/complete-milestone.js +4 -3
- package/dist/resources/extensions/gsd/tools/complete-slice.js +4 -3
- package/dist/resources/extensions/gsd/tools/complete-task.js +4 -3
- package/dist/resources/extensions/gsd/tools/plan-milestone.js +4 -14
- package/dist/resources/extensions/gsd/tools/plan-slice.js +4 -14
- package/dist/resources/extensions/gsd/tools/plan-task.js +4 -14
- package/dist/resources/extensions/gsd/tools/reassess-roadmap.js +6 -7
- package/dist/resources/extensions/gsd/tools/reopen-slice.js +4 -3
- package/dist/resources/extensions/gsd/tools/reopen-task.js +5 -4
- package/dist/resources/extensions/gsd/tools/replan-slice.js +5 -6
- package/dist/resources/extensions/gsd/validation.js +21 -0
- package/dist/resources/extensions/shared/rtk.js +14 -4
- package/dist/rtk.js +3 -1
- package/dist/web/standalone/.next/BUILD_ID +1 -1
- package/dist/web/standalone/.next/app-path-routes-manifest.json +20 -20
- package/dist/web/standalone/.next/build-manifest.json +4 -4
- package/dist/web/standalone/.next/prerender-manifest.json +3 -3
- package/dist/web/standalone/.next/react-loadable-manifest.json +1 -1
- package/dist/web/standalone/.next/required-server-files.json +3 -3
- package/dist/web/standalone/.next/server/app/_global-error/page.js +3 -3
- package/dist/web/standalone/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.html +2 -2
- package/dist/web/standalone/.next/server/app/_global-error.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found/page.js +2 -2
- package/dist/web/standalone/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.html +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.rsc +3 -3
- package/dist/web/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +3 -3
- package/dist/web/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +3 -3
- package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/api/boot/route.js +1 -1
- package/dist/web/standalone/.next/server/app/api/boot/route_client-reference-manifest.js +1 -1
- package/dist/web/standalone/.next/server/app/api/bridge-terminal/input/route.js +1 -1
- package/dist/web/standalone/.next/server/app/api/bridge-terminal/input/route_client-reference-manifest.js +1 -1
- package/dist/web/standalone/.next/server/app/api/bridge-terminal/resize/route.js +1 -1
- package/dist/web/standalone/.next/server/app/api/bridge-terminal/resize/route_client-reference-manifest.js +1 -1
- package/dist/web/standalone/.next/server/app/api/bridge-terminal/stream/route.js +2 -2
- package/dist/web/standalone/.next/server/app/api/bridge-terminal/stream/route_client-reference-manifest.js +1 -1
- package/dist/web/standalone/.next/server/app/api/browse-directories/route.js +1 -1
- package/dist/web/standalone/.next/server/app/api/browse-directories/route_client-reference-manifest.js +1 -1
- package/dist/web/standalone/.next/server/app/api/captures/route.js +1 -1
- package/dist/web/standalone/.next/server/app/api/captures/route_client-reference-manifest.js +1 -1
- package/dist/web/standalone/.next/server/app/api/cleanup/route.js +1 -1
- package/dist/web/standalone/.next/server/app/api/cleanup/route_client-reference-manifest.js +1 -1
- package/dist/web/standalone/.next/server/app/api/dev-mode/route.js +1 -1
- package/dist/web/standalone/.next/server/app/api/dev-mode/route_client-reference-manifest.js +1 -1
- package/dist/web/standalone/.next/server/app/api/doctor/route.js +1 -1
- package/dist/web/standalone/.next/server/app/api/doctor/route_client-reference-manifest.js +1 -1
- package/dist/web/standalone/.next/server/app/api/experimental/route.js +2 -2
- package/dist/web/standalone/.next/server/app/api/experimental/route_client-reference-manifest.js +1 -1
- package/dist/web/standalone/.next/server/app/api/export-data/route.js +1 -1
- package/dist/web/standalone/.next/server/app/api/export-data/route_client-reference-manifest.js +1 -1
- package/dist/web/standalone/.next/server/app/api/files/route.js +1 -1
- package/dist/web/standalone/.next/server/app/api/files/route_client-reference-manifest.js +1 -1
- package/dist/web/standalone/.next/server/app/api/forensics/route.js +1 -1
- package/dist/web/standalone/.next/server/app/api/forensics/route_client-reference-manifest.js +1 -1
- package/dist/web/standalone/.next/server/app/api/git/route.js +1 -1
- package/dist/web/standalone/.next/server/app/api/git/route_client-reference-manifest.js +1 -1
- package/dist/web/standalone/.next/server/app/api/history/route.js +1 -1
- package/dist/web/standalone/.next/server/app/api/history/route_client-reference-manifest.js +1 -1
- package/dist/web/standalone/.next/server/app/api/hooks/route.js +1 -1
- package/dist/web/standalone/.next/server/app/api/hooks/route_client-reference-manifest.js +1 -1
- package/dist/web/standalone/.next/server/app/api/inspect/route.js +1 -1
- package/dist/web/standalone/.next/server/app/api/inspect/route_client-reference-manifest.js +1 -1
- package/dist/web/standalone/.next/server/app/api/knowledge/route.js +1 -1
- package/dist/web/standalone/.next/server/app/api/knowledge/route_client-reference-manifest.js +1 -1
- package/dist/web/standalone/.next/server/app/api/live-state/route.js +1 -1
- package/dist/web/standalone/.next/server/app/api/live-state/route_client-reference-manifest.js +1 -1
- package/dist/web/standalone/.next/server/app/api/onboarding/route.js +1 -1
- package/dist/web/standalone/.next/server/app/api/onboarding/route_client-reference-manifest.js +1 -1
- package/dist/web/standalone/.next/server/app/api/preferences/route.js +1 -1
- package/dist/web/standalone/.next/server/app/api/preferences/route_client-reference-manifest.js +1 -1
- package/dist/web/standalone/.next/server/app/api/projects/route.js +1 -1
- package/dist/web/standalone/.next/server/app/api/projects/route_client-reference-manifest.js +1 -1
- package/dist/web/standalone/.next/server/app/api/recovery/route.js +1 -1
- package/dist/web/standalone/.next/server/app/api/recovery/route_client-reference-manifest.js +1 -1
- package/dist/web/standalone/.next/server/app/api/remote-questions/route.js +2 -2
- package/dist/web/standalone/.next/server/app/api/remote-questions/route_client-reference-manifest.js +1 -1
- package/dist/web/standalone/.next/server/app/api/session/browser/route.js +1 -1
- package/dist/web/standalone/.next/server/app/api/session/browser/route_client-reference-manifest.js +1 -1
- package/dist/web/standalone/.next/server/app/api/session/command/route.js +1 -1
- package/dist/web/standalone/.next/server/app/api/session/command/route_client-reference-manifest.js +1 -1
- package/dist/web/standalone/.next/server/app/api/session/events/route.js +2 -2
- package/dist/web/standalone/.next/server/app/api/session/events/route_client-reference-manifest.js +1 -1
- package/dist/web/standalone/.next/server/app/api/session/manage/route.js +1 -1
- package/dist/web/standalone/.next/server/app/api/session/manage/route_client-reference-manifest.js +1 -1
- package/dist/web/standalone/.next/server/app/api/settings-data/route.js +1 -1
- package/dist/web/standalone/.next/server/app/api/settings-data/route_client-reference-manifest.js +1 -1
- package/dist/web/standalone/.next/server/app/api/shutdown/route.js +1 -1
- package/dist/web/standalone/.next/server/app/api/shutdown/route_client-reference-manifest.js +1 -1
- package/dist/web/standalone/.next/server/app/api/skill-health/route.js +1 -1
- package/dist/web/standalone/.next/server/app/api/skill-health/route_client-reference-manifest.js +1 -1
- package/dist/web/standalone/.next/server/app/api/steer/route.js +1 -1
- package/dist/web/standalone/.next/server/app/api/steer/route_client-reference-manifest.js +1 -1
- package/dist/web/standalone/.next/server/app/api/switch-root/route.js +1 -1
- package/dist/web/standalone/.next/server/app/api/switch-root/route_client-reference-manifest.js +1 -1
- package/dist/web/standalone/.next/server/app/api/terminal/input/route.js +2 -2
- package/dist/web/standalone/.next/server/app/api/terminal/input/route_client-reference-manifest.js +1 -1
- package/dist/web/standalone/.next/server/app/api/terminal/resize/route.js +2 -2
- package/dist/web/standalone/.next/server/app/api/terminal/resize/route_client-reference-manifest.js +1 -1
- package/dist/web/standalone/.next/server/app/api/terminal/sessions/route.js +2 -2
- package/dist/web/standalone/.next/server/app/api/terminal/sessions/route_client-reference-manifest.js +1 -1
- package/dist/web/standalone/.next/server/app/api/terminal/stream/route.js +4 -4
- package/dist/web/standalone/.next/server/app/api/terminal/stream/route_client-reference-manifest.js +1 -1
- package/dist/web/standalone/.next/server/app/api/terminal/upload/route.js +1 -1
- package/dist/web/standalone/.next/server/app/api/terminal/upload/route_client-reference-manifest.js +1 -1
- package/dist/web/standalone/.next/server/app/api/undo/route.js +1 -1
- package/dist/web/standalone/.next/server/app/api/undo/route_client-reference-manifest.js +1 -1
- package/dist/web/standalone/.next/server/app/api/update/route.js +1 -1
- package/dist/web/standalone/.next/server/app/api/update/route_client-reference-manifest.js +1 -1
- package/dist/web/standalone/.next/server/app/api/visualizer/route.js +1 -1
- package/dist/web/standalone/.next/server/app/api/visualizer/route_client-reference-manifest.js +1 -1
- package/dist/web/standalone/.next/server/app/index.html +1 -1
- package/dist/web/standalone/.next/server/app/index.rsc +4 -4
- package/dist/web/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +2 -2
- package/dist/web/standalone/.next/server/app/index.segments/_full.segment.rsc +4 -4
- package/dist/web/standalone/.next/server/app/index.segments/_head.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/index.segments/_index.segment.rsc +3 -3
- package/dist/web/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/page.js +2 -2
- package/dist/web/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
- package/dist/web/standalone/.next/server/app-paths-manifest.json +20 -20
- package/dist/web/standalone/.next/server/chunks/2229.js +1 -1
- package/dist/web/standalone/.next/server/chunks/7471.js +3 -3
- package/dist/web/standalone/.next/server/middleware-build-manifest.js +1 -1
- package/dist/web/standalone/.next/server/middleware-react-loadable-manifest.js +1 -1
- package/dist/web/standalone/.next/server/middleware.js +2 -2
- package/dist/web/standalone/.next/server/next-font-manifest.js +1 -1
- package/dist/web/standalone/.next/server/next-font-manifest.json +1 -1
- package/dist/web/standalone/.next/server/pages/404.html +1 -1
- package/dist/web/standalone/.next/server/pages/500.html +2 -2
- package/dist/web/standalone/.next/server/server-reference-manifest.json +1 -1
- package/dist/web/standalone/.next/static/chunks/4024.87fd909ae0110f50.js +9 -0
- package/dist/web/standalone/.next/static/chunks/app/_not-found/{page-2f24283c162b6ab3.js → page-f2a7482d42a5614b.js} +1 -1
- package/dist/web/standalone/.next/static/chunks/app/{layout-9ecfd95f343793f0.js → layout-a16c7a7ecdf0c2cf.js} +1 -1
- package/dist/web/standalone/.next/static/chunks/app/page-b950e4e384cc62b3.js +1 -0
- package/dist/web/standalone/.next/static/chunks/main-app-fdab67f7802d7832.js +1 -0
- package/dist/web/standalone/.next/static/chunks/next/dist/client/components/builtin/global-error-459824ffb8c323dd.js +1 -0
- package/dist/web/standalone/.next/static/chunks/{webpack-024d82be84800e52.js → webpack-bca0e732db0dcec3.js} +1 -1
- package/dist/web/standalone/node_modules/node-pty/build/Makefile +2 -2
- package/dist/web/standalone/node_modules/node-pty/build/Release/pty.node +0 -0
- package/dist/web/standalone/node_modules/node-pty/build/pty.target.mk +14 -14
- package/dist/web/standalone/node_modules/node-pty/node-addon-api/node_addon_api.target.mk +14 -14
- package/dist/web/standalone/node_modules/node-pty/node-addon-api/node_addon_api_except.target.mk +14 -14
- package/dist/web/standalone/node_modules/node-pty/node-addon-api/node_addon_api_maybe.target.mk +14 -14
- package/dist/web/standalone/server.js +1 -1
- package/package.json +1 -1
- package/packages/mcp-server/README.md +6 -6
- package/packages/mcp-server/package.json +14 -4
- package/packages/mcp-server/src/cli.ts +1 -1
- package/packages/mcp-server/src/index.ts +1 -1
- package/packages/mcp-server/src/mcp-server.test.ts +2 -2
- package/packages/mcp-server/src/session-manager.ts +2 -2
- package/packages/mcp-server/src/types.ts +1 -1
- package/packages/pi-coding-agent/package.json +1 -1
- package/packages/rpc-client/README.md +125 -0
- package/packages/rpc-client/examples/basic-usage.ts +13 -0
- package/packages/rpc-client/package.json +17 -3
- package/packages/rpc-client/src/index.ts +10 -0
- package/packages/rpc-client/src/jsonl.ts +64 -0
- package/packages/rpc-client/src/rpc-client.test.ts +568 -0
- package/packages/rpc-client/src/rpc-client.ts +666 -0
- package/packages/rpc-client/src/rpc-types.ts +399 -0
- package/packages/rpc-client/tsconfig.examples.json +17 -0
- package/packages/rpc-client/tsconfig.json +24 -0
- package/pkg/package.json +1 -1
- package/scripts/ensure-workspace-builds.cjs +36 -8
- package/src/resources/extensions/get-secrets-from-user.ts +8 -0
- package/src/resources/extensions/gsd/auto/phases.ts +38 -7
- package/src/resources/extensions/gsd/auto-dispatch.ts +6 -1
- package/src/resources/extensions/gsd/auto-worktree.ts +73 -14
- package/src/resources/extensions/gsd/auto.ts +21 -0
- package/src/resources/extensions/gsd/bootstrap/agent-end-recovery.ts +4 -11
- package/src/resources/extensions/gsd/commands-prefs-wizard.ts +3 -3
- package/src/resources/extensions/gsd/docs/preferences-reference.md +2 -2
- package/src/resources/extensions/gsd/git-service.ts +4 -3
- package/src/resources/extensions/gsd/guided-flow.ts +4 -3
- package/src/resources/extensions/gsd/markdown-renderer.ts +5 -4
- package/src/resources/extensions/gsd/parallel-orchestrator.ts +23 -1
- package/src/resources/extensions/gsd/preferences-types.ts +1 -1
- package/src/resources/extensions/gsd/state.ts +18 -29
- package/src/resources/extensions/gsd/status-guards.ts +13 -0
- package/src/resources/extensions/gsd/tests/active-milestone-id-guard.test.ts +91 -0
- package/src/resources/extensions/gsd/tests/auto-stale-lock-self-kill.test.ts +87 -0
- package/src/resources/extensions/gsd/tests/auto-worktree-auto-resolve.test.ts +80 -0
- package/src/resources/extensions/gsd/tests/auto-worktree-milestone-merge.test.ts +1 -1
- package/src/resources/extensions/gsd/tests/collect-from-manifest.test.ts +39 -0
- package/src/resources/extensions/gsd/tests/git-service.test.ts +64 -30
- package/src/resources/extensions/gsd/tests/milestone-report-path.test.ts +51 -0
- package/src/resources/extensions/gsd/tests/parallel-orchestrator-zombie-cleanup.test.ts +277 -0
- package/src/resources/extensions/gsd/tests/phases-merge-error-stops-auto.test.ts +103 -0
- package/src/resources/extensions/gsd/tests/preferences.test.ts +1 -1
- package/src/resources/extensions/gsd/tests/rate-limit-model-fallback.test.ts +90 -0
- package/src/resources/extensions/gsd/tests/session-lock-transient-read.test.ts +9 -8
- package/src/resources/extensions/gsd/tests/stash-pop-gsd-conflict.test.ts +125 -0
- package/src/resources/extensions/gsd/tests/status-guards.test.ts +30 -0
- package/src/resources/extensions/gsd/tests/validate-milestone.test.ts +12 -2
- package/src/resources/extensions/gsd/tests/validation-gate-patterns.test.ts +124 -0
- package/src/resources/extensions/gsd/tests/validation.test.ts +72 -0
- package/src/resources/extensions/gsd/tools/complete-milestone.ts +4 -3
- package/src/resources/extensions/gsd/tools/complete-slice.ts +4 -3
- package/src/resources/extensions/gsd/tools/complete-task.ts +4 -3
- package/src/resources/extensions/gsd/tools/plan-milestone.ts +4 -16
- package/src/resources/extensions/gsd/tools/plan-slice.ts +4 -16
- package/src/resources/extensions/gsd/tools/plan-task.ts +4 -16
- package/src/resources/extensions/gsd/tools/reassess-roadmap.ts +6 -7
- package/src/resources/extensions/gsd/tools/reopen-slice.ts +4 -3
- package/src/resources/extensions/gsd/tools/reopen-task.ts +5 -4
- package/src/resources/extensions/gsd/tools/replan-slice.ts +5 -7
- package/src/resources/extensions/gsd/validation.ts +23 -0
- package/src/resources/extensions/shared/rtk.ts +22 -4
- package/dist/web/standalone/.next/static/chunks/4024.21054f459af5cc78.js +0 -9
- package/dist/web/standalone/.next/static/chunks/app/page-fbecd1237e2d6d1f.js +0 -1
- package/dist/web/standalone/.next/static/chunks/main-app-d3d4c336195465f9.js +0 -1
- package/dist/web/standalone/.next/static/chunks/next/dist/client/components/builtin/global-error-ab5a8926e07ec673.js +0 -1
- /package/dist/web/standalone/.next/static/{vlgS2rkXjxeKhgXhdp4lh → YO-PWFRitlHM-L-dotlmm}/_buildManifest.js +0 -0
- /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,
|
|
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
|
|
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.
|
|
102
|
+
spawn('bash', ['-c', `sleep 0.05 && mv "${tmpFile}" "${lockFile}"`], { stdio: 'ignore', detached: true }).unref();
|
|
102
103
|
|
|
103
|
-
|
|
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
|
|
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.
|
|
137
|
+
spawn('bash', ['-c', `sleep 0.05 && chmod 644 "${lockFile}"`], { stdio: 'ignore', detached: true }).unref();
|
|
137
138
|
|
|
138
|
-
const result = readExistingLockDataWithRetry(lockFile, { maxAttempts:
|
|
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
|
+
});
|