gsd-pi 2.52.0-dev.585e355 → 2.52.0-dev.655ad8a

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 (76) hide show
  1. package/README.md +55 -32
  2. package/dist/headless-query.js +1 -1
  3. package/dist/resources/extensions/get-secrets-from-user.js +7 -0
  4. package/dist/resources/extensions/gsd/auto/phases.js +28 -8
  5. package/dist/resources/extensions/gsd/auto-dispatch.js +5 -1
  6. package/dist/resources/extensions/gsd/auto-worktree.js +70 -14
  7. package/dist/resources/extensions/gsd/auto.js +22 -0
  8. package/dist/resources/extensions/gsd/bootstrap/agent-end-recovery.js +4 -10
  9. package/dist/resources/extensions/gsd/guided-flow.js +4 -3
  10. package/dist/resources/extensions/gsd/parallel-orchestrator.js +18 -2
  11. package/dist/resources/extensions/gsd/state.js +5 -11
  12. package/dist/resources/extensions/shared/rtk.js +9 -1
  13. package/dist/web/standalone/.next/BUILD_ID +1 -1
  14. package/dist/web/standalone/.next/app-path-routes-manifest.json +14 -14
  15. package/dist/web/standalone/.next/build-manifest.json +3 -3
  16. package/dist/web/standalone/.next/prerender-manifest.json +3 -3
  17. package/dist/web/standalone/.next/react-loadable-manifest.json +1 -1
  18. package/dist/web/standalone/.next/server/app/_global-error.html +2 -2
  19. package/dist/web/standalone/.next/server/app/_global-error.rsc +1 -1
  20. package/dist/web/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  21. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
  22. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error.segment.rsc +1 -1
  23. package/dist/web/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  24. package/dist/web/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  25. package/dist/web/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  26. package/dist/web/standalone/.next/server/app/_not-found.html +1 -1
  27. package/dist/web/standalone/.next/server/app/_not-found.rsc +1 -1
  28. package/dist/web/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
  29. package/dist/web/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
  30. package/dist/web/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
  31. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  32. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  33. package/dist/web/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  34. package/dist/web/standalone/.next/server/app/index.html +1 -1
  35. package/dist/web/standalone/.next/server/app/index.rsc +1 -1
  36. package/dist/web/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +1 -1
  37. package/dist/web/standalone/.next/server/app/index.segments/_full.segment.rsc +1 -1
  38. package/dist/web/standalone/.next/server/app/index.segments/_head.segment.rsc +1 -1
  39. package/dist/web/standalone/.next/server/app/index.segments/_index.segment.rsc +1 -1
  40. package/dist/web/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
  41. package/dist/web/standalone/.next/server/app-paths-manifest.json +14 -14
  42. package/dist/web/standalone/.next/server/middleware-build-manifest.js +1 -1
  43. package/dist/web/standalone/.next/server/middleware-react-loadable-manifest.js +1 -1
  44. package/dist/web/standalone/.next/server/pages/404.html +1 -1
  45. package/dist/web/standalone/.next/server/pages/500.html +2 -2
  46. package/dist/web/standalone/.next/server/server-reference-manifest.json +1 -1
  47. package/dist/web/standalone/.next/static/chunks/4024.87fd909ae0110f50.js +9 -0
  48. package/dist/web/standalone/.next/static/chunks/{webpack-024d82be84800e52.js → webpack-bca0e732db0dcec3.js} +1 -1
  49. package/package.json +1 -1
  50. package/src/resources/extensions/get-secrets-from-user.ts +8 -0
  51. package/src/resources/extensions/gsd/auto/phases.ts +38 -7
  52. package/src/resources/extensions/gsd/auto-dispatch.ts +6 -1
  53. package/src/resources/extensions/gsd/auto-worktree.ts +73 -14
  54. package/src/resources/extensions/gsd/auto.ts +21 -0
  55. package/src/resources/extensions/gsd/bootstrap/agent-end-recovery.ts +4 -11
  56. package/src/resources/extensions/gsd/guided-flow.ts +4 -3
  57. package/src/resources/extensions/gsd/parallel-orchestrator.ts +23 -1
  58. package/src/resources/extensions/gsd/state.ts +5 -10
  59. package/src/resources/extensions/gsd/tests/active-milestone-id-guard.test.ts +91 -0
  60. package/src/resources/extensions/gsd/tests/auto-stale-lock-self-kill.test.ts +87 -0
  61. package/src/resources/extensions/gsd/tests/auto-worktree-auto-resolve.test.ts +80 -0
  62. package/src/resources/extensions/gsd/tests/auto-worktree-milestone-merge.test.ts +1 -1
  63. package/src/resources/extensions/gsd/tests/collect-from-manifest.test.ts +39 -0
  64. package/src/resources/extensions/gsd/tests/git-service.test.ts +28 -27
  65. package/src/resources/extensions/gsd/tests/milestone-report-path.test.ts +51 -0
  66. package/src/resources/extensions/gsd/tests/parallel-orchestrator-zombie-cleanup.test.ts +277 -0
  67. package/src/resources/extensions/gsd/tests/phases-merge-error-stops-auto.test.ts +103 -0
  68. package/src/resources/extensions/gsd/tests/rate-limit-model-fallback.test.ts +90 -0
  69. package/src/resources/extensions/gsd/tests/session-lock-transient-read.test.ts +9 -8
  70. package/src/resources/extensions/gsd/tests/stash-pop-gsd-conflict.test.ts +125 -0
  71. package/src/resources/extensions/gsd/tests/validate-milestone.test.ts +12 -2
  72. package/src/resources/extensions/gsd/tests/validation-gate-patterns.test.ts +124 -0
  73. package/src/resources/extensions/shared/rtk.ts +10 -1
  74. package/dist/web/standalone/.next/static/chunks/4024.21054f459af5cc78.js +0 -9
  75. /package/dist/web/standalone/.next/static/{KTe1kB5nPLQFIIFz2OcmI → zpvUPKoW5jRAMB_fWHlPi}/_buildManifest.js +0 -0
  76. /package/dist/web/standalone/.next/static/{KTe1kB5nPLQFIIFz2OcmI → zpvUPKoW5jRAMB_fWHlPi}/_ssgManifest.js +0 -0
@@ -0,0 +1,87 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { mkdirSync, mkdtempSync, writeFileSync, existsSync, rmSync } from "node:fs";
4
+ import { join } from "node:path";
5
+ import { tmpdir } from "node:os";
6
+
7
+ import { writeLock, readCrashLock, clearLock } from "../crash-recovery.ts";
8
+ import { checkRemoteAutoSession, stopAutoRemote } from "../auto.ts";
9
+
10
+ function makeTmpProject(): string {
11
+ const dir = mkdtempSync(join(tmpdir(), "gsd-stale-lock-test-"));
12
+ mkdirSync(join(dir, ".gsd"), { recursive: true });
13
+ return dir;
14
+ }
15
+
16
+ // ─── checkRemoteAutoSession: own-PID filtering (#2730) ───────────────────
17
+
18
+ test("#2730: checkRemoteAutoSession returns { running: false } when lock PID matches current process", (t) => {
19
+ const dir = makeTmpProject();
20
+ t.after(() => rmSync(dir, { recursive: true, force: true }));
21
+
22
+ // Write a lock with the current process PID — simulates a stale lock
23
+ // left behind after step-mode exit without full cleanup.
24
+ writeLock(dir, "execute-task", "M001/S01/T01");
25
+
26
+ const lock = readCrashLock(dir);
27
+ assert.ok(lock, "lock file should exist");
28
+ assert.equal(lock!.pid, process.pid, "lock should have our PID");
29
+
30
+ const result = checkRemoteAutoSession(dir);
31
+ assert.equal(result.running, false, "own PID must not be treated as a remote session");
32
+ });
33
+
34
+ test("#2730: checkRemoteAutoSession still detects a genuine remote session (different PID)", (t) => {
35
+ const dir = makeTmpProject();
36
+ t.after(() => rmSync(dir, { recursive: true, force: true }));
37
+
38
+ // Use parent PID — guaranteed alive, guaranteed not our PID.
39
+ const remotePid = process.ppid;
40
+ const lockData = {
41
+ pid: remotePid,
42
+ startedAt: new Date().toISOString(),
43
+ unitType: "execute-task",
44
+ unitId: "M001/S01/T02",
45
+ unitStartedAt: new Date().toISOString(),
46
+ };
47
+ writeFileSync(join(dir, ".gsd", "auto.lock"), JSON.stringify(lockData, null, 2));
48
+
49
+ const result = checkRemoteAutoSession(dir);
50
+ assert.equal(result.running, true, "different live PID should be detected as running");
51
+ assert.equal(result.pid, remotePid);
52
+ });
53
+
54
+ // ─── stopAutoRemote: self-kill prevention (#2730) ────────────────────────
55
+
56
+ test("#2730: stopAutoRemote does not send SIGTERM when lock PID matches current process", (t) => {
57
+ const dir = makeTmpProject();
58
+ t.after(() => rmSync(dir, { recursive: true, force: true }));
59
+
60
+ // Write a lock with our own PID
61
+ writeLock(dir, "execute-task", "M001/S01/T01");
62
+
63
+ const result = stopAutoRemote(dir);
64
+ assert.equal(result.found, false, "own PID must not be signalled");
65
+
66
+ // The lock should be cleared as part of the self-detection cleanup
67
+ assert.ok(!existsSync(join(dir, ".gsd", "auto.lock")), "stale self-lock should be cleared");
68
+ });
69
+
70
+ test("#2730: stopAutoRemote clears stale lock from dead remote process without error", (t) => {
71
+ const dir = makeTmpProject();
72
+ t.after(() => rmSync(dir, { recursive: true, force: true }));
73
+
74
+ // Simulate a stale lock from a process that no longer exists
75
+ const lockData = {
76
+ pid: 9999999,
77
+ startedAt: "2026-03-01T00:00:00Z",
78
+ unitType: "plan-slice",
79
+ unitId: "M001/S02",
80
+ unitStartedAt: "2026-03-01T00:05:00Z",
81
+ };
82
+ writeFileSync(join(dir, ".gsd", "auto.lock"), JSON.stringify(lockData, null, 2));
83
+
84
+ const result = stopAutoRemote(dir);
85
+ assert.equal(result.found, false, "dead remote PID should not be reported as found");
86
+ assert.ok(!existsSync(join(dir, ".gsd", "auto.lock")), "stale lock should be cleaned up");
87
+ });
@@ -0,0 +1,80 @@
1
+ /**
2
+ * auto-worktree-auto-resolve.test.ts — Unit tests for isSafeToAutoResolve.
3
+ *
4
+ * Covers: .gsd/ state files, build artifacts (.tsbuildinfo, .pyc, __pycache__,
5
+ * .DS_Store, .map), and rejection of real source files.
6
+ */
7
+
8
+ import { describe, test } from "node:test";
9
+ import assert from "node:assert/strict";
10
+
11
+ import {
12
+ isSafeToAutoResolve,
13
+ SAFE_AUTO_RESOLVE_PATTERNS,
14
+ } from "../auto-worktree.ts";
15
+
16
+ describe("isSafeToAutoResolve", () => {
17
+ // ─── .gsd/ state files ───────────────────────────────────────────────────
18
+ test("returns true for .gsd/ prefixed paths", () => {
19
+ assert.ok(isSafeToAutoResolve(".gsd/STATE.md"));
20
+ assert.ok(isSafeToAutoResolve(".gsd/milestones/M001/CONTEXT.md"));
21
+ assert.ok(isSafeToAutoResolve(".gsd/gsd.db"));
22
+ });
23
+
24
+ // ─── Build artifact patterns ─────────────────────────────────────────────
25
+ test("returns true for .tsbuildinfo files", () => {
26
+ assert.ok(isSafeToAutoResolve("tsconfig.tsbuildinfo"));
27
+ assert.ok(isSafeToAutoResolve("dist/tsconfig.tsbuildinfo"));
28
+ });
29
+
30
+ test("returns true for .pyc files", () => {
31
+ assert.ok(isSafeToAutoResolve("module.pyc"));
32
+ assert.ok(isSafeToAutoResolve("src/utils/helpers.pyc"));
33
+ });
34
+
35
+ test("returns true for __pycache__/ paths", () => {
36
+ assert.ok(isSafeToAutoResolve("src/__pycache__/module.cpython-311.pyc"));
37
+ assert.ok(isSafeToAutoResolve("lib/__pycache__/foo.py"));
38
+ });
39
+
40
+ test("returns true for .DS_Store files", () => {
41
+ assert.ok(isSafeToAutoResolve(".DS_Store"));
42
+ assert.ok(isSafeToAutoResolve("src/.DS_Store"));
43
+ });
44
+
45
+ test("returns true for .map source map files", () => {
46
+ assert.ok(isSafeToAutoResolve("dist/index.js.map"));
47
+ assert.ok(isSafeToAutoResolve("out/bundle.css.map"));
48
+ });
49
+
50
+ // ─── Real source files (should NOT be auto-resolved) ─────────────────────
51
+ test("returns false for .ts source files", () => {
52
+ assert.ok(!isSafeToAutoResolve("src/index.ts"));
53
+ assert.ok(!isSafeToAutoResolve("lib/utils.ts"));
54
+ });
55
+
56
+ test("returns false for .js source files", () => {
57
+ assert.ok(!isSafeToAutoResolve("src/index.js"));
58
+ assert.ok(!isSafeToAutoResolve("lib/helpers.js"));
59
+ });
60
+
61
+ test("returns false for .py source files", () => {
62
+ assert.ok(!isSafeToAutoResolve("src/main.py"));
63
+ assert.ok(!isSafeToAutoResolve("scripts/deploy.py"));
64
+ });
65
+
66
+ test("returns false for config and data files", () => {
67
+ assert.ok(!isSafeToAutoResolve("package.json"));
68
+ assert.ok(!isSafeToAutoResolve("tsconfig.json"));
69
+ assert.ok(!isSafeToAutoResolve("README.md"));
70
+ });
71
+
72
+ // ─── SAFE_AUTO_RESOLVE_PATTERNS export ────────────────────────────────────
73
+ test("SAFE_AUTO_RESOLVE_PATTERNS is a non-empty array of RegExp", () => {
74
+ assert.ok(Array.isArray(SAFE_AUTO_RESOLVE_PATTERNS));
75
+ assert.ok(SAFE_AUTO_RESOLVE_PATTERNS.length > 0);
76
+ for (const pattern of SAFE_AUTO_RESOLVE_PATTERNS) {
77
+ assert.ok(pattern instanceof RegExp);
78
+ }
79
+ });
80
+ });
@@ -77,7 +77,7 @@ function addSliceToMilestone(
77
77
  run(`git branch -d ${sliceBranch}`, wtPath);
78
78
  }
79
79
 
80
- describe("auto-worktree-milestone-merge", () => {
80
+ describe("auto-worktree-milestone-merge", { timeout: 300_000 }, () => {
81
81
  const savedCwd = process.cwd();
82
82
  const tempDirs: string[] = [];
83
83
 
@@ -227,6 +227,45 @@ test("collectSecretsFromManifest: manifest statuses are updated after collection
227
227
  "KEY_TO_SKIP should have status 'skipped' after user skipped it");
228
228
  });
229
229
 
230
+ test("collectSecretsFromManifest: applied keys hydrate process.env for the running session", async (t) => {
231
+ const { collectSecretsFromManifest } = await loadOrchestrator();
232
+
233
+ const tmp = makeTempDir("manifest-live-env");
234
+ const envKey = "CONTEXT7_API_KEY";
235
+ const saved = process.env[envKey];
236
+ t.after(() => {
237
+ if (saved === undefined) delete process.env[envKey];
238
+ else process.env[envKey] = saved;
239
+ rmSync(tmp, { recursive: true, force: true });
240
+ });
241
+
242
+ delete process.env[envKey];
243
+
244
+ const manifest = makeManifest([
245
+ { key: envKey, status: "pending" },
246
+ ]);
247
+ await writeManifestFile(tmp, manifest);
248
+
249
+ let callIndex = 0;
250
+ const mockCtx = {
251
+ cwd: tmp,
252
+ hasUI: true,
253
+ ui: {
254
+ custom: async (_factory: any) => {
255
+ callIndex++;
256
+ if (callIndex <= 1) return null; // summary screen dismiss
257
+ return "c7_live_test_key";
258
+ },
259
+ },
260
+ };
261
+
262
+ const result = await collectSecretsFromManifest(tmp, "M001", mockCtx as any);
263
+
264
+ assert.ok(result.applied.includes(envKey), "CONTEXT7_API_KEY should be applied");
265
+ assert.equal(process.env[envKey], "c7_live_test_key",
266
+ "applied keys should be available through process.env without restarting");
267
+ });
268
+
230
269
  // ─── showSecretsSummary: render output ────────────────────────────────────────
231
270
 
232
271
  test("showSecretsSummary: produces lines with correct status glyphs for each entry status", async () => {
@@ -287,9 +287,9 @@ describe('git-service', async () => {
287
287
 
288
288
 
289
289
  const tempDir = mkdtempSync(join(tmpdir(), "gsd-git-service-test-"));
290
- run("git init -b main", tempDir);
291
- run('git config user.name "Pi Test"', tempDir);
292
- run('git config user.email "pi@example.com"', tempDir);
290
+ runGit(tempDir, ["init", "-b", "main"]);
291
+ runGit(tempDir, ["config", "user.name", "Pi Test"]);
292
+ runGit(tempDir, ["config", "user.email", "pi@example.com"]);
293
293
 
294
294
  // runGit should work on a valid repo
295
295
  const branch = runGit(tempDir, ["branch", "--show-current"]);
@@ -334,13 +334,13 @@ describe('git-service', async () => {
334
334
 
335
335
  function initTempRepo(): string {
336
336
  const dir = mkdtempSync(join(tmpdir(), "gsd-git-t02-"));
337
- run("git init -b main", dir);
338
- run('git config user.name "Pi Test"', dir);
339
- run('git config user.email "pi@example.com"', dir);
337
+ runGit(dir, ["init", "-b", "main"]);
338
+ runGit(dir, ["config", "user.name", "Pi Test"]);
339
+ runGit(dir, ["config", "user.email", "pi@example.com"]);
340
340
  // Need an initial commit so HEAD exists
341
341
  createFile(dir, ".gitkeep", "");
342
- run("git add -A", dir);
343
- run('git commit -m "init"', dir);
342
+ runGit(dir, ["add", "-A"]);
343
+ runGit(dir, ["commit", "-m", "init"]);
344
344
  return dir;
345
345
  }
346
346
 
@@ -577,12 +577,12 @@ describe('git-service', async () => {
577
577
 
578
578
  function initBranchTestRepo(): string {
579
579
  const dir = mkdtempSync(join(tmpdir(), "gsd-git-t03-"));
580
- run("git init -b main", dir);
581
- run('git config user.name "Pi Test"', dir);
582
- run('git config user.email "pi@example.com"', dir);
580
+ runGit(dir, ["init", "-b", "main"]);
581
+ runGit(dir, ["config", "user.name", "Pi Test"]);
582
+ runGit(dir, ["config", "user.email", "pi@example.com"]);
583
583
  createFile(dir, ".gitkeep", "");
584
- run("git add -A", dir);
585
- run('git commit -m "init"', dir);
584
+ runGit(dir, ["add", "-A"]);
585
+ runGit(dir, ["commit", "-m", "init"]);
586
586
  return dir;
587
587
  }
588
588
 
@@ -618,12 +618,12 @@ describe('git-service', async () => {
618
618
  {
619
619
  // master-only repo
620
620
  const repo = mkdtempSync(join(tmpdir(), "gsd-git-t03-master-"));
621
- run("git init -b master", repo);
622
- run('git config user.name "Pi Test"', repo);
623
- run('git config user.email "pi@example.com"', repo);
621
+ runGit(repo, ["init", "-b", "master"]);
622
+ runGit(repo, ["config", "user.name", "Pi Test"]);
623
+ runGit(repo, ["config", "user.email", "pi@example.com"]);
624
624
  createFile(repo, ".gitkeep", "");
625
- run("git add -A", repo);
626
- run('git commit -m "init"', repo);
625
+ runGit(repo, ["add", "-A"]);
626
+ runGit(repo, ["commit", "-m", "init"]);
627
627
 
628
628
  const svc = new GitServiceImpl(repo);
629
629
  assert.deepStrictEqual(svc.getMainBranch(), "master", "getMainBranch returns master when only master exists");
@@ -1115,9 +1115,9 @@ describe('git-service', async () => {
1115
1115
  test('untrackRuntimeFiles', async () => {
1116
1116
  const { untrackRuntimeFiles } = await import("../gitignore.ts");
1117
1117
  const repo = mkdtempSync(join(tmpdir(), "gsd-untrack-"));
1118
- run("git init -b main", repo);
1119
- run("git config user.email test@test.com", repo);
1120
- run("git config user.name Test", repo);
1118
+ runGit(repo, ["init", "-b", "main"]);
1119
+ runGit(repo, ["config", "user.email", "test@test.com"]);
1120
+ runGit(repo, ["config", "user.name", "Test"]);
1121
1121
 
1122
1122
  // Create and track runtime files (simulates pre-.gitignore state)
1123
1123
  mkdirSync(join(repo, ".gsd", "activity"), { recursive: true });
@@ -1128,8 +1128,8 @@ describe('git-service', async () => {
1128
1128
  writeFileSync(join(repo, ".gsd", "activity", "log.jsonl"), "{}");
1129
1129
  writeFileSync(join(repo, ".gsd", "runtime", "data.json"), "{}");
1130
1130
  writeFileSync(join(repo, "src.ts"), "code");
1131
- run("git add -A", repo);
1132
- run("git commit -m init", repo);
1131
+ runGit(repo, ["add", "-A"]);
1132
+ runGit(repo, ["commit", "-m", "init"]);
1133
1133
 
1134
1134
  // Precondition: runtime files are tracked
1135
1135
  const trackedBefore = run("git ls-files .gsd/", repo);
@@ -1164,11 +1164,12 @@ describe('git-service', async () => {
1164
1164
 
1165
1165
  test('smartStage excludes runtime files, allows milestone artifacts', () => {
1166
1166
  const repo = mkdtempSync(join(tmpdir(), "gsd-smart-stage-excludes-"));
1167
- run("git init -b main", repo);
1168
- run("git config user.email test@test.com", repo);
1169
- run("git config user.name Test", repo);
1167
+ runGit(repo, ["init", "-b", "main"]);
1168
+ runGit(repo, ["config", "user.email", "test@test.com"]);
1169
+ runGit(repo, ["config", "user.name", "Test"]);
1170
1170
  writeFileSync(join(repo, "README.md"), "init");
1171
- run("git add -A && git commit -m init", repo);
1171
+ runGit(repo, ["add", "-A"]);
1172
+ runGit(repo, ["commit", "-m", "init"]);
1172
1173
 
1173
1174
  // Create .gsd/ runtime files + milestone artifacts + a normal source file
1174
1175
  mkdirSync(join(repo, ".gsd", "milestones", "M001"), { recursive: true });
@@ -0,0 +1,51 @@
1
+ /**
2
+ * milestone-report-path.test.ts — Regression test for milestone report path resolution.
3
+ *
4
+ * When running in a worktree, milestone reports must be written to the
5
+ * original project root (originalBasePath), not the worktree path (basePath).
6
+ *
7
+ * Covers: _resolveReportBasePath from auto/phases.ts
8
+ */
9
+
10
+ import { describe, test } from "node:test";
11
+ import assert from "node:assert/strict";
12
+
13
+ import { _resolveReportBasePath } from "../auto/phases.ts";
14
+
15
+ describe("_resolveReportBasePath", () => {
16
+ test("uses originalBasePath when set (worktree scenario)", () => {
17
+ const session = {
18
+ originalBasePath: "/projects/my-app",
19
+ basePath: "/projects/my-app/.claude/worktrees/agent-abc123",
20
+ };
21
+
22
+ assert.equal(_resolveReportBasePath(session), "/projects/my-app");
23
+ });
24
+
25
+ test("falls back to basePath when originalBasePath is empty", () => {
26
+ const session = {
27
+ originalBasePath: "",
28
+ basePath: "/projects/my-app",
29
+ };
30
+
31
+ assert.equal(_resolveReportBasePath(session), "/projects/my-app");
32
+ });
33
+
34
+ test("falls back to basePath when originalBasePath is undefined", () => {
35
+ const session = {
36
+ originalBasePath: undefined as unknown as string,
37
+ basePath: "/projects/my-app",
38
+ };
39
+
40
+ assert.equal(_resolveReportBasePath(session), "/projects/my-app");
41
+ });
42
+
43
+ test("uses originalBasePath even when basePath differs", () => {
44
+ const session = {
45
+ originalBasePath: "/home/user/repo",
46
+ basePath: "/tmp/worktree-xyz",
47
+ };
48
+
49
+ assert.equal(_resolveReportBasePath(session), "/home/user/repo");
50
+ });
51
+ });
@@ -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
+ });