gsd-pi 2.78.1-dev.d8826a445 → 2.78.1-dev.eccf86e27

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 (121) hide show
  1. package/README.md +5 -7
  2. package/dist/help-text.js +1 -1
  3. package/dist/resource-loader.js +6 -1
  4. package/dist/resources/.managed-resources-content-hash +1 -1
  5. package/dist/resources/extensions/gsd/auto/detect-stuck.js +41 -5
  6. package/dist/resources/extensions/gsd/auto/loop.js +235 -36
  7. package/dist/resources/extensions/gsd/auto/phases.js +7 -5
  8. package/dist/resources/extensions/gsd/auto/session.js +33 -0
  9. package/dist/resources/extensions/gsd/auto-dispatch.js +46 -2
  10. package/dist/resources/extensions/gsd/auto-post-unit.js +19 -11
  11. package/dist/resources/extensions/gsd/auto-worktree.js +26 -187
  12. package/dist/resources/extensions/gsd/auto.js +79 -50
  13. package/dist/resources/extensions/gsd/bootstrap/register-hooks.js +9 -4
  14. package/dist/resources/extensions/gsd/crash-recovery.js +160 -47
  15. package/dist/resources/extensions/gsd/db/auto-workers.js +227 -0
  16. package/dist/resources/extensions/gsd/db/command-queue.js +105 -0
  17. package/dist/resources/extensions/gsd/db/milestone-leases.js +210 -0
  18. package/dist/resources/extensions/gsd/db/runtime-kv.js +91 -0
  19. package/dist/resources/extensions/gsd/db/unit-dispatches.js +322 -0
  20. package/dist/resources/extensions/gsd/docs/COORDINATION.md +42 -0
  21. package/dist/resources/extensions/gsd/doctor-proactive.js +4 -0
  22. package/dist/resources/extensions/gsd/doctor-runtime-checks.js +22 -6
  23. package/dist/resources/extensions/gsd/doctor.js +12 -2
  24. package/dist/resources/extensions/gsd/gsd-db.js +161 -3
  25. package/dist/resources/extensions/gsd/guided-flow.js +6 -2
  26. package/dist/resources/extensions/gsd/interrupted-session.js +18 -15
  27. package/dist/resources/extensions/gsd/state.js +21 -6
  28. package/dist/resources/extensions/gsd/worktree-resolver.js +64 -0
  29. package/dist/tsconfig.extensions.tsbuildinfo +1 -1
  30. package/dist/web/standalone/.next/BUILD_ID +1 -1
  31. package/dist/web/standalone/.next/app-path-routes-manifest.json +12 -12
  32. package/dist/web/standalone/.next/build-manifest.json +2 -2
  33. package/dist/web/standalone/.next/prerender-manifest.json +3 -3
  34. package/dist/web/standalone/.next/server/app/_global-error.html +1 -1
  35. package/dist/web/standalone/.next/server/app/_global-error.rsc +1 -1
  36. package/dist/web/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  37. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
  38. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error.segment.rsc +1 -1
  39. package/dist/web/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  40. package/dist/web/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  41. package/dist/web/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  42. package/dist/web/standalone/.next/server/app/_not-found.html +1 -1
  43. package/dist/web/standalone/.next/server/app/_not-found.rsc +1 -1
  44. package/dist/web/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
  45. package/dist/web/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
  46. package/dist/web/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
  47. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  48. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  49. package/dist/web/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  50. package/dist/web/standalone/.next/server/app/index.html +1 -1
  51. package/dist/web/standalone/.next/server/app/index.rsc +1 -1
  52. package/dist/web/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +1 -1
  53. package/dist/web/standalone/.next/server/app/index.segments/_full.segment.rsc +1 -1
  54. package/dist/web/standalone/.next/server/app/index.segments/_head.segment.rsc +1 -1
  55. package/dist/web/standalone/.next/server/app/index.segments/_index.segment.rsc +1 -1
  56. package/dist/web/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
  57. package/dist/web/standalone/.next/server/app-paths-manifest.json +12 -12
  58. package/dist/web/standalone/.next/server/middleware-build-manifest.js +1 -1
  59. package/dist/web/standalone/.next/server/pages/404.html +1 -1
  60. package/dist/web/standalone/.next/server/pages/500.html +1 -1
  61. package/dist/web/standalone/.next/server/server-reference-manifest.json +1 -1
  62. package/package.json +1 -1
  63. package/src/resources/extensions/gsd/auto/detect-stuck.ts +37 -5
  64. package/src/resources/extensions/gsd/auto/loop.ts +263 -41
  65. package/src/resources/extensions/gsd/auto/phases.ts +7 -5
  66. package/src/resources/extensions/gsd/auto/session.ts +36 -0
  67. package/src/resources/extensions/gsd/auto-dispatch.ts +53 -2
  68. package/src/resources/extensions/gsd/auto-post-unit.ts +19 -11
  69. package/src/resources/extensions/gsd/auto-worktree.ts +26 -211
  70. package/src/resources/extensions/gsd/auto.ts +89 -44
  71. package/src/resources/extensions/gsd/bootstrap/register-hooks.ts +9 -4
  72. package/src/resources/extensions/gsd/crash-recovery.ts +177 -43
  73. package/src/resources/extensions/gsd/db/auto-workers.ts +273 -0
  74. package/src/resources/extensions/gsd/db/command-queue.ts +149 -0
  75. package/src/resources/extensions/gsd/db/milestone-leases.ts +274 -0
  76. package/src/resources/extensions/gsd/db/runtime-kv.ts +127 -0
  77. package/src/resources/extensions/gsd/db/unit-dispatches.ts +446 -0
  78. package/src/resources/extensions/gsd/docs/COORDINATION.md +42 -0
  79. package/src/resources/extensions/gsd/doctor-proactive.ts +4 -0
  80. package/src/resources/extensions/gsd/doctor-runtime-checks.ts +24 -6
  81. package/src/resources/extensions/gsd/doctor.ts +10 -2
  82. package/src/resources/extensions/gsd/gsd-db.ts +170 -3
  83. package/src/resources/extensions/gsd/guided-flow.ts +6 -2
  84. package/src/resources/extensions/gsd/interrupted-session.ts +19 -12
  85. package/src/resources/extensions/gsd/state.ts +44 -6
  86. package/src/resources/extensions/gsd/tests/auto-loop-no-copy-artifacts.test.ts +72 -0
  87. package/src/resources/extensions/gsd/tests/auto-loop-symlink-worktree.test.ts +190 -0
  88. package/src/resources/extensions/gsd/tests/auto-workers.test.ts +105 -0
  89. package/src/resources/extensions/gsd/tests/command-queue.test.ts +141 -0
  90. package/src/resources/extensions/gsd/tests/crash-recovery-via-db.test.ts +203 -0
  91. package/src/resources/extensions/gsd/tests/crash-recovery.test.ts +169 -59
  92. package/src/resources/extensions/gsd/tests/detect-stuck-respects-retry.test.ts +173 -0
  93. package/src/resources/extensions/gsd/tests/integration/auto-worktree.test.ts +22 -12
  94. package/src/resources/extensions/gsd/tests/integration/doctor-proactive.test.ts +24 -10
  95. package/src/resources/extensions/gsd/tests/integration/doctor-runtime.test.ts +35 -23
  96. package/src/resources/extensions/gsd/tests/integration/workspace-collapse-integration.test.ts +3 -5
  97. package/src/resources/extensions/gsd/tests/interrupted-session-auto.test.ts +72 -25
  98. package/src/resources/extensions/gsd/tests/interrupted-session-ui.test.ts +72 -25
  99. package/src/resources/extensions/gsd/tests/memory-pressure-stuck-state.test.ts +9 -6
  100. package/src/resources/extensions/gsd/tests/milestone-leases.test.ts +152 -0
  101. package/src/resources/extensions/gsd/tests/parallel-milestone-isolation.test.ts +106 -0
  102. package/src/resources/extensions/gsd/tests/paused-session-via-db.test.ts +119 -0
  103. package/src/resources/extensions/gsd/tests/pipeline-variant-dispatch.test.ts +58 -0
  104. package/src/resources/extensions/gsd/tests/preferences-worktree-sync.test.ts +3 -17
  105. package/src/resources/extensions/gsd/tests/register-hooks-depth-verification.test.ts +110 -0
  106. package/src/resources/extensions/gsd/tests/runtime-kv.test.ts +120 -0
  107. package/src/resources/extensions/gsd/tests/skipped-validation-completion.test.ts +133 -28
  108. package/src/resources/extensions/gsd/tests/skipped-validation-db-atomicity.test.ts +17 -0
  109. package/src/resources/extensions/gsd/tests/stuck-state-via-db.test.ts +134 -0
  110. package/src/resources/extensions/gsd/tests/sync-layer-scope.test.ts +7 -26
  111. package/src/resources/extensions/gsd/tests/teardown-cleanup-parity.test.ts +4 -8
  112. package/src/resources/extensions/gsd/tests/unit-dispatches.test.ts +247 -0
  113. package/src/resources/extensions/gsd/tests/validate-milestone.test.ts +41 -1
  114. package/src/resources/extensions/gsd/tests/workspace.test.ts +15 -9
  115. package/src/resources/extensions/gsd/tests/write-gate.test.ts +31 -23
  116. package/src/resources/extensions/gsd/worktree-resolver.ts +62 -0
  117. package/src/resources/extensions/gsd/tests/auto-lock-creation.test.ts +0 -213
  118. package/src/resources/extensions/gsd/tests/auto-stale-lock-self-kill.test.ts +0 -87
  119. package/src/resources/extensions/gsd/tests/stop-auto-remote.test.ts +0 -159
  120. /package/dist/web/standalone/.next/static/{AT5qi39nKXkdmQIOIoh0f → Y5UeGFkXTYM9WIQOWHkot}/_buildManifest.js +0 -0
  121. /package/dist/web/standalone/.next/static/{AT5qi39nKXkdmQIOIoh0f → Y5UeGFkXTYM9WIQOWHkot}/_ssgManifest.js +0 -0
@@ -60,20 +60,24 @@ describe('doctor-runtime', async () => {
60
60
 
61
61
  try {
62
62
  // ─── Test 1: Stale crash lock detection & fix ─────────────────────
63
- test('stale_crash_lock', async () => {
63
+ test('stale_crash_lock', async (t) => {
64
64
  const dir = createMinimalProject();
65
65
  cleanups.push(dir);
66
66
 
67
- // Write a lock file with a PID that is definitely dead (use PID 1 million+)
68
- const lockData = {
69
- pid: 9999999,
70
- startedAt: "2026-03-10T00:00:00Z",
71
- unitType: "execute-task",
72
- unitId: "M001/S01/T01",
73
- unitStartedAt: "2026-03-10T00:01:00Z",
74
- completedUnits: 3,
75
- };
76
- writeFileSync(join(dir, ".gsd", "auto.lock"), JSON.stringify(lockData, null, 2));
67
+ // Phase C pt 2: stale lock state lives in the workers table now.
68
+ // Insert a fake stale worker row directly (PID 9999999 is dead).
69
+ const { openDatabase, _getAdapter } = await import("../../gsd-db.ts");
70
+ const gsdDb = await import("../../gsd-db.ts");
71
+ t.after(() => { gsdDb.closeDatabase(); });
72
+ const { randomUUID } = await import("node:crypto");
73
+ openDatabase(join(dir, ".gsd", "gsd.db"));
74
+ const db = _getAdapter()!;
75
+ db.prepare(
76
+ `INSERT INTO workers (worker_id, host, pid, started_at, version, last_heartbeat_at, status, project_root_realpath)
77
+ VALUES (:w, 'test-host', 9999999, '2026-03-10T00:00:00Z', 'test', '1970-01-01T00:00:00.000Z', 'active', :root)`,
78
+ ).run({ ":w": `test-fake-${randomUUID().slice(0, 8)}`, ":root": dir });
79
+ // Leave DB open — runGSDDoctor's readCrashLock relies on the
80
+ // currently-open DB connection (it does not open one of its own).
77
81
 
78
82
  const detect = await runGSDDoctor(dir);
79
83
  const lockIssues = detect.issues.filter(i => i.code === "stale_crash_lock");
@@ -82,8 +86,15 @@ describe('doctor-runtime', async () => {
82
86
  assert.ok(lockIssues[0]?.fixable === true, "stale lock is fixable");
83
87
 
84
88
  const fixed = await runGSDDoctor(dir, { fix: true });
85
- assert.ok(fixed.fixesApplied.some(f => f.includes("cleared stale auto.lock")), "fix clears stale lock");
86
- assert.ok(!existsSync(join(dir, ".gsd", "auto.lock")), "auto.lock removed after fix");
89
+ assert.ok(
90
+ fixed.fixesApplied.some(f => f.includes("cleared stale")),
91
+ `fix clears stale lock (got: ${fixed.fixesApplied.join(", ")})`,
92
+ );
93
+
94
+ // Close DB so subsequent tests in this file (which expect a clean
95
+ // state) don't see this test's connection lingering.
96
+ const { closeDatabase } = await import("../../gsd-db.ts");
97
+ closeDatabase();
87
98
  });
88
99
 
89
100
  // ─── Test 2: No false positive for missing lock ───────────────────
@@ -417,18 +428,19 @@ node_modules/
417
428
  const dir = createMinimalProject();
418
429
  cleanups.push(dir);
419
430
 
420
- // Create lock dir + auto.lock with PID 1 (init/launchd — always alive, never our own PID)
431
+ // Create lock dir + insert a live worker row (PID 1 = init/launchd —
432
+ // always alive, never our own PID). Phase C pt 2: worker liveness
433
+ // lives in the workers table. last_heartbeat_at = now → not stale.
421
434
  const lockDir = join(dir, ".gsd.lock");
422
435
  mkdirSync(lockDir, { recursive: true });
423
- const liveLockData = {
424
- pid: 1,
425
- startedAt: new Date().toISOString(),
426
- unitType: "execute-task",
427
- unitId: "M001/S01/T01",
428
- unitStartedAt: new Date().toISOString(),
429
- completedUnits: 1,
430
- };
431
- writeFileSync(join(dir, ".gsd", "auto.lock"), JSON.stringify(liveLockData, null, 2));
436
+ const { openDatabase, _getAdapter } = await import("../../gsd-db.ts");
437
+ const { randomUUID } = await import("node:crypto");
438
+ openDatabase(join(dir, ".gsd", "gsd.db"));
439
+ const db = _getAdapter()!;
440
+ db.prepare(
441
+ `INSERT INTO workers (worker_id, host, pid, started_at, version, last_heartbeat_at, status, project_root_realpath)
442
+ VALUES (:w, 'test-host', 1, :now, 'test', :now, 'active', :root)`,
443
+ ).run({ ":w": `test-fake-${randomUUID().slice(0, 8)}`, ":now": new Date().toISOString(), ":root": dir });
432
444
 
433
445
  const detect = await runGSDDoctor(dir);
434
446
  const strandedIssues = detect.issues.filter(i => i.code === "stranded_lock_directory");
@@ -141,21 +141,20 @@ describe("workspace-collapse integration: Test 2 — abort teardown clears stale
141
141
  rmSync(repoDir, { recursive: true, force: true });
142
142
  });
143
143
 
144
- test("STATE.md, auto.lock, and M001-META.json are removed by teardownAutoWorktree", () => {
144
+ test("STATE.md and M001-META.json are removed by teardownAutoWorktree", () => {
145
+ // Phase C pt 2: auto.lock no longer exists as a file — it migrated
146
+ // to the workers + unit_dispatches tables.
145
147
  const gsdDir = join(repoDir, ".gsd");
146
148
  const milestonesDir = join(gsdDir, "milestones", "M001");
147
149
  mkdirSync(milestonesDir, { recursive: true });
148
150
 
149
151
  const stateMd = join(gsdDir, "STATE.md");
150
- const autoLock = join(gsdDir, "auto.lock");
151
152
  const metaJson = join(milestonesDir, "M001-META.json");
152
153
 
153
154
  writeFileSync(stateMd, "# State\nactive\n");
154
- writeFileSync(autoLock, JSON.stringify({ pid: process.pid, unitType: "plan-milestone", unitId: "M001" }));
155
155
  writeFileSync(metaJson, JSON.stringify({ milestoneId: "M001" }));
156
156
 
157
157
  assert.ok(existsSync(stateMd), "STATE.md exists before teardown");
158
- assert.ok(existsSync(autoLock), "auto.lock exists before teardown");
159
158
  assert.ok(existsSync(metaJson), "M001-META.json exists before teardown");
160
159
 
161
160
  // teardownAutoWorktree clears state files before the git step; git removal
@@ -167,7 +166,6 @@ describe("workspace-collapse integration: Test 2 — abort teardown clears stale
167
166
  }
168
167
 
169
168
  assert.ok(!existsSync(stateMd), "STATE.md removed by teardownAutoWorktree (regression: A5)");
170
- assert.ok(!existsSync(autoLock), "auto.lock removed by teardownAutoWorktree (regression: A5)");
171
169
  assert.ok(!existsSync(metaJson), "M001-META.json removed by teardownAutoWorktree (regression: A5)");
172
170
  });
173
171
  });
@@ -6,6 +6,21 @@ import { tmpdir } from "node:os";
6
6
  import { randomUUID } from "node:crypto";
7
7
 
8
8
  import { assessInterruptedSession } from "../interrupted-session.ts";
9
+ import {
10
+ openDatabase,
11
+ closeDatabase,
12
+ insertMilestone,
13
+ _getAdapter,
14
+ } from "../gsd-db.ts";
15
+ import { registerAutoWorker } from "../db/auto-workers.ts";
16
+ import { claimMilestoneLease } from "../db/milestone-leases.ts";
17
+ import { recordDispatchClaim } from "../db/unit-dispatches.ts";
18
+ import { setRuntimeKv } from "../db/runtime-kv.ts";
19
+ import {
20
+ PAUSED_SESSION_KV_KEY,
21
+ type PausedSessionMetadata,
22
+ } from "../interrupted-session.ts";
23
+ import { normalizeRealPath } from "../paths.ts";
9
24
 
10
25
  function makeTmpBase(): string {
11
26
  const base = join(tmpdir(), `gsd-auto-interrupted-${randomUUID()}`);
@@ -14,9 +29,61 @@ function makeTmpBase(): string {
14
29
  }
15
30
 
16
31
  function cleanup(base: string): void {
32
+ try { closeDatabase(); } catch { /* */ }
17
33
  try { rmSync(base, { recursive: true, force: true }); } catch { /* */ }
18
34
  }
19
35
 
36
+ function openFixtureDb(base: string): void {
37
+ openDatabase(join(base, ".gsd", "gsd.db"));
38
+ }
39
+
40
+ function expireWorker(workerId: string): void {
41
+ const db = _getAdapter()!;
42
+ db.prepare(
43
+ `UPDATE workers SET last_heartbeat_at = '1970-01-01T00:00:00.000Z' WHERE worker_id = :worker_id`,
44
+ ).run({ ":worker_id": workerId });
45
+ }
46
+
47
+ function writeLock(base: string, unitType: string, unitId: string): void {
48
+ openFixtureDb(base);
49
+ insertMilestone({
50
+ id: "M001",
51
+ title: "Test Milestone",
52
+ status: unitType === "complete-slice" ? "complete" : "active",
53
+ });
54
+ const workerId = registerAutoWorker({ projectRootRealpath: normalizeRealPath(base) });
55
+ const lease = claimMilestoneLease(workerId, "M001");
56
+ assert.equal(lease.ok, true);
57
+ if (lease.ok) {
58
+ const [, sliceId = null, taskId = null] = unitId.split("/");
59
+ const claimed = recordDispatchClaim({
60
+ traceId: `trace-${randomUUID().slice(0, 8)}`,
61
+ workerId,
62
+ milestoneLeaseToken: lease.token,
63
+ milestoneId: "M001",
64
+ sliceId,
65
+ taskId,
66
+ unitType,
67
+ unitId,
68
+ });
69
+ assert.equal(claimed.ok, true);
70
+ }
71
+ _getAdapter()!
72
+ .prepare(`UPDATE workers SET pid = 99999 WHERE worker_id = :worker_id`)
73
+ .run({ ":worker_id": workerId });
74
+ expireWorker(workerId);
75
+ }
76
+
77
+ function writePausedSession(base: string, milestoneId = "M001", stepMode = false): void {
78
+ openFixtureDb(base);
79
+ const meta: PausedSessionMetadata = {
80
+ milestoneId,
81
+ originalBasePath: base,
82
+ stepMode,
83
+ };
84
+ setRuntimeKv("global", "", PAUSED_SESSION_KV_KEY, meta);
85
+ }
86
+
20
87
  function writeRoadmap(base: string, checked = false): void {
21
88
  const milestoneDir = join(base, ".gsd", "milestones", "M001");
22
89
  mkdirSync(join(milestoneDir, "slices", "S01", "tasks"), { recursive: true });
@@ -51,42 +118,22 @@ function writeRoadmap(base: string, checked = false): void {
51
118
  function writeCompleteArtifacts(base: string): void {
52
119
  const milestoneDir = join(base, ".gsd", "milestones", "M001");
53
120
  const sliceDir = join(milestoneDir, "slices", "S01");
121
+ const tasksDir = join(sliceDir, "tasks");
54
122
  mkdirSync(sliceDir, { recursive: true });
123
+ mkdirSync(tasksDir, { recursive: true });
124
+ writeFileSync(join(sliceDir, "S01-PLAN.md"), "# S01: Test Slice\n\n## Tasks\n- [x] **T01: Do thing** `est:10m`\n", "utf-8");
125
+ writeFileSync(join(tasksDir, "T01-SUMMARY.md"), "# Task Summary\nDone.\n", "utf-8");
55
126
  writeFileSync(join(sliceDir, "S01-SUMMARY.md"), "# Summary\nDone.\n", "utf-8");
56
127
  writeFileSync(join(sliceDir, "S01-UAT.md"), "# UAT\nPassed.\n", "utf-8");
57
128
  writeFileSync(join(milestoneDir, "M001-SUMMARY.md"), "# Milestone Summary\nDone.\n", "utf-8");
58
129
  }
59
130
 
60
- function writeLock(base: string, unitType: string, unitId: string): void {
61
- writeFileSync(
62
- join(base, ".gsd", "auto.lock"),
63
- JSON.stringify({
64
- pid: 999999999,
65
- startedAt: new Date().toISOString(),
66
- unitType,
67
- unitId,
68
- unitStartedAt: new Date().toISOString(),
69
- }, null, 2),
70
- "utf-8",
71
- );
72
- }
73
-
74
- function writePausedSession(base: string, milestoneId = "M001", stepMode = false): void {
75
- const runtimeDir = join(base, ".gsd", "runtime");
76
- mkdirSync(runtimeDir, { recursive: true });
77
- writeFileSync(
78
- join(runtimeDir, "paused-session.json"),
79
- JSON.stringify({ milestoneId, originalBasePath: base, stepMode }, null, 2),
80
- "utf-8",
81
- );
82
- }
83
-
84
131
  test("direct /gsd auto stale complete repo yields stale classification with no recovery payload", async () => {
85
132
  const base = makeTmpBase();
86
133
  try {
87
134
  writeRoadmap(base, true);
88
135
  writeCompleteArtifacts(base);
89
- writeLock(base, "execute-task", "M001/S01/T01");
136
+ writeLock(base, "complete-slice", "M001/S01");
90
137
 
91
138
  const assessment = await assessInterruptedSession(base);
92
139
  assert.equal(assessment.classification, "stale");
@@ -6,6 +6,21 @@ import { tmpdir } from "node:os";
6
6
  import { randomUUID } from "node:crypto";
7
7
 
8
8
  import { assessInterruptedSession } from "../interrupted-session.ts";
9
+ import {
10
+ openDatabase,
11
+ closeDatabase,
12
+ insertMilestone,
13
+ _getAdapter,
14
+ } from "../gsd-db.ts";
15
+ import { registerAutoWorker } from "../db/auto-workers.ts";
16
+ import { claimMilestoneLease } from "../db/milestone-leases.ts";
17
+ import { recordDispatchClaim } from "../db/unit-dispatches.ts";
18
+ import { setRuntimeKv } from "../db/runtime-kv.ts";
19
+ import {
20
+ PAUSED_SESSION_KV_KEY,
21
+ type PausedSessionMetadata,
22
+ } from "../interrupted-session.ts";
23
+ import { normalizeRealPath } from "../paths.ts";
9
24
 
10
25
  function makeTmpBase(): string {
11
26
  const base = join(tmpdir(), `gsd-smart-entry-${randomUUID()}`);
@@ -14,9 +29,61 @@ function makeTmpBase(): string {
14
29
  }
15
30
 
16
31
  function cleanup(base: string): void {
32
+ try { closeDatabase(); } catch { /* */ }
17
33
  try { rmSync(base, { recursive: true, force: true }); } catch { /* */ }
18
34
  }
19
35
 
36
+ function openFixtureDb(base: string): void {
37
+ openDatabase(join(base, ".gsd", "gsd.db"));
38
+ }
39
+
40
+ function expireWorker(workerId: string): void {
41
+ const db = _getAdapter()!;
42
+ db.prepare(
43
+ `UPDATE workers SET last_heartbeat_at = '1970-01-01T00:00:00.000Z' WHERE worker_id = :worker_id`,
44
+ ).run({ ":worker_id": workerId });
45
+ }
46
+
47
+ function writePausedSession(base: string, milestoneId = "M001", stepMode = false): void {
48
+ openFixtureDb(base);
49
+ const meta: PausedSessionMetadata = {
50
+ milestoneId,
51
+ originalBasePath: base,
52
+ stepMode,
53
+ };
54
+ setRuntimeKv("global", "", PAUSED_SESSION_KV_KEY, meta);
55
+ }
56
+
57
+ function writeLock(base: string, unitType: string, unitId: string): void {
58
+ openFixtureDb(base);
59
+ insertMilestone({
60
+ id: "M001",
61
+ title: "Test Milestone",
62
+ status: unitType === "complete-slice" ? "complete" : "active",
63
+ });
64
+ const workerId = registerAutoWorker({ projectRootRealpath: normalizeRealPath(base) });
65
+ const lease = claimMilestoneLease(workerId, "M001");
66
+ assert.equal(lease.ok, true);
67
+ if (lease.ok) {
68
+ const [, sliceId = null, taskId = null] = unitId.split("/");
69
+ const claimed = recordDispatchClaim({
70
+ traceId: `trace-${randomUUID().slice(0, 8)}`,
71
+ workerId,
72
+ milestoneLeaseToken: lease.token,
73
+ milestoneId: "M001",
74
+ sliceId,
75
+ taskId,
76
+ unitType,
77
+ unitId,
78
+ });
79
+ assert.equal(claimed.ok, true);
80
+ }
81
+ _getAdapter()!
82
+ .prepare(`UPDATE workers SET pid = 99999 WHERE worker_id = :worker_id`)
83
+ .run({ ":worker_id": workerId });
84
+ expireWorker(workerId);
85
+ }
86
+
20
87
  function writeRoadmap(base: string, checked = false): void {
21
88
  const milestoneDir = join(base, ".gsd", "milestones", "M001");
22
89
  mkdirSync(join(milestoneDir, "slices", "S01", "tasks"), { recursive: true });
@@ -51,42 +118,22 @@ function writeRoadmap(base: string, checked = false): void {
51
118
  function writeCompleteArtifacts(base: string): void {
52
119
  const milestoneDir = join(base, ".gsd", "milestones", "M001");
53
120
  const sliceDir = join(milestoneDir, "slices", "S01");
121
+ const tasksDir = join(sliceDir, "tasks");
54
122
  mkdirSync(sliceDir, { recursive: true });
123
+ mkdirSync(tasksDir, { recursive: true });
124
+ writeFileSync(join(sliceDir, "S01-PLAN.md"), "# S01: Test Slice\n\n## Tasks\n- [x] **T01: Do thing** `est:10m`\n", "utf-8");
125
+ writeFileSync(join(tasksDir, "T01-SUMMARY.md"), "# Task Summary\nDone.\n", "utf-8");
55
126
  writeFileSync(join(sliceDir, "S01-SUMMARY.md"), "# Summary\nDone.\n", "utf-8");
56
127
  writeFileSync(join(sliceDir, "S01-UAT.md"), "# UAT\nPassed.\n", "utf-8");
57
128
  writeFileSync(join(milestoneDir, "M001-SUMMARY.md"), "# Milestone Summary\nDone.\n", "utf-8");
58
129
  }
59
130
 
60
- function writePausedSession(base: string, milestoneId = "M001", stepMode = false): void {
61
- const runtimeDir = join(base, ".gsd", "runtime");
62
- mkdirSync(runtimeDir, { recursive: true });
63
- writeFileSync(
64
- join(runtimeDir, "paused-session.json"),
65
- JSON.stringify({ milestoneId, originalBasePath: base, stepMode }, null, 2),
66
- "utf-8",
67
- );
68
- }
69
-
70
- function writeLock(base: string, unitType: string, unitId: string): void {
71
- writeFileSync(
72
- join(base, ".gsd", "auto.lock"),
73
- JSON.stringify({
74
- pid: 999999999,
75
- startedAt: new Date().toISOString(),
76
- unitType,
77
- unitId,
78
- unitStartedAt: new Date().toISOString(),
79
- }, null, 2),
80
- "utf-8",
81
- );
82
- }
83
-
84
131
  test("guided-flow stale complete scenario classifies as stale so the resume prompt can be suppressed", async () => {
85
132
  const base = makeTmpBase();
86
133
  try {
87
134
  writeRoadmap(base, true);
88
135
  writeCompleteArtifacts(base);
89
- writeLock(base, "execute-task", "M001/S01/T01");
136
+ writeLock(base, "complete-slice", "M001/S01");
90
137
 
91
138
  const assessment = await assessInterruptedSession(base);
92
139
  assert.equal(assessment.classification, "stale");
@@ -40,23 +40,26 @@ describe("stuck detection persistence (#3704)", () => {
40
40
  assert.match(loopSource, /function saveStuckState/);
41
41
  });
42
42
 
43
+ // Phase C: API changed from (basePath) to (session) — recentUnits is
44
+ // now reconstructed from unit_dispatches and stuckRecoveryAttempts
45
+ // persists in runtime_kv (worker scope).
43
46
  test("loopState initialized from persisted state", () => {
44
- assert.match(loopSource, /loadStuckState\(s\.basePath\)/);
47
+ assert.match(loopSource, /loadStuckState\(s\)/);
45
48
  });
46
49
 
47
50
  test("stuck state saved after each iteration", () => {
48
- assert.match(loopSource, /saveStuckState\(s\.basePath,\s*loopState\)/);
51
+ assert.match(loopSource, /saveStuckState\(s,\s*loopState\)/);
49
52
  });
50
53
 
51
- test("stuck state file path uses runtime directory", () => {
52
- assert.match(loopSource, /stuck-state\.json/);
53
- });
54
+ // Phase C: stuck-state.json file IO deleted; persistence moved to
55
+ // unit_dispatches (recentUnits) + runtime_kv (stuckRecoveryAttempts).
56
+ // The stuck-state-via-db.test.ts suite covers the round-trip.
54
57
 
55
58
  test("saveStuckState called in standard dev path as well as custom engine path (#4382)", () => {
56
59
  // Count all call-sites of saveStuckState (excluding the function definition itself).
57
60
  // After the fix, both the custom-engine path and the standard dev path must each
58
61
  // call saveStuckState so stuckRecoveryAttempts survives session restarts.
59
- const callMatches = loopSource.match(/saveStuckState\(s\.basePath,\s*loopState\)/g) ?? [];
62
+ const callMatches = loopSource.match(/saveStuckState\(s,\s*loopState\)/g) ?? [];
60
63
  assert.ok(
61
64
  callMatches.length >= 2,
62
65
  `saveStuckState must be called in both the custom-engine path and the standard dev path ` +
@@ -0,0 +1,152 @@
1
+ // gsd-2 + Milestone leases tests (Phase B coordination — fencing semantics)
2
+
3
+ import test from "node:test";
4
+ import assert from "node:assert/strict";
5
+ import { mkdtempSync, mkdirSync, rmSync } from "node:fs";
6
+ import { join } from "node:path";
7
+ import { tmpdir } from "node:os";
8
+
9
+ import {
10
+ openDatabase,
11
+ closeDatabase,
12
+ insertMilestone,
13
+ _getAdapter,
14
+ } from "../gsd-db.ts";
15
+ import { registerAutoWorker } from "../db/auto-workers.ts";
16
+ import {
17
+ claimMilestoneLease,
18
+ releaseMilestoneLease,
19
+ refreshMilestoneLease,
20
+ getMilestoneLease,
21
+ } from "../db/milestone-leases.ts";
22
+
23
+ function makeBase(): string {
24
+ const base = mkdtempSync(join(tmpdir(), "gsd-leases-"));
25
+ mkdirSync(join(base, ".gsd"), { recursive: true });
26
+ return base;
27
+ }
28
+
29
+ function cleanup(base: string): void {
30
+ try { closeDatabase(); } catch { /* noop */ }
31
+ try { rmSync(base, { recursive: true, force: true }); } catch { /* noop */ }
32
+ }
33
+
34
+ test("first claim returns ok=true with token=1", (t) => {
35
+ const base = makeBase();
36
+ t.after(() => cleanup(base));
37
+ openDatabase(join(base, ".gsd", "gsd.db"));
38
+ insertMilestone({ id: "M001", title: "Test", status: "active" });
39
+
40
+ const w1 = registerAutoWorker({ projectRootRealpath: base });
41
+ const claim = claimMilestoneLease(w1, "M001");
42
+ assert.equal(claim.ok, true);
43
+ if (claim.ok) {
44
+ assert.equal(claim.token, 1, "fresh claim starts fencing token at 1");
45
+ }
46
+
47
+ const row = getMilestoneLease("M001");
48
+ assert.ok(row);
49
+ assert.equal(row!.worker_id, w1);
50
+ assert.equal(row!.fencing_token, 1);
51
+ assert.equal(row!.status, "held");
52
+ });
53
+
54
+ test("second claim by different worker is rejected while lease is held", (t) => {
55
+ const base = makeBase();
56
+ t.after(() => cleanup(base));
57
+ openDatabase(join(base, ".gsd", "gsd.db"));
58
+ insertMilestone({ id: "M001", title: "Test", status: "active" });
59
+
60
+ const w1 = registerAutoWorker({ projectRootRealpath: base });
61
+ const w2 = registerAutoWorker({ projectRootRealpath: base });
62
+ const first = claimMilestoneLease(w1, "M001");
63
+ assert.equal(first.ok, true);
64
+
65
+ const second = claimMilestoneLease(w2, "M001");
66
+ assert.equal(second.ok, false);
67
+ if (!second.ok) {
68
+ assert.equal(second.error, "held_by");
69
+ assert.equal(second.byWorker, w1);
70
+ }
71
+ });
72
+
73
+ test("releaseMilestoneLease frees the lease for takeover", (t) => {
74
+ const base = makeBase();
75
+ t.after(() => cleanup(base));
76
+ openDatabase(join(base, ".gsd", "gsd.db"));
77
+ insertMilestone({ id: "M001", title: "Test", status: "active" });
78
+
79
+ const w1 = registerAutoWorker({ projectRootRealpath: base });
80
+ const w2 = registerAutoWorker({ projectRootRealpath: base });
81
+ const first = claimMilestoneLease(w1, "M001");
82
+ assert.equal(first.ok, true);
83
+
84
+ if (first.ok) {
85
+ const released = releaseMilestoneLease(w1, "M001", first.token);
86
+ assert.equal(released, true);
87
+ }
88
+
89
+ // After release, w2 may take over with monotonically larger token
90
+ const second = claimMilestoneLease(w2, "M001");
91
+ assert.equal(second.ok, true);
92
+ if (second.ok) {
93
+ assert.equal(second.token, 2, "takeover increments fencing token monotonically");
94
+ }
95
+ });
96
+
97
+ test("expired lease (TTL passed) allows takeover with token+1", (t) => {
98
+ const base = makeBase();
99
+ t.after(() => cleanup(base));
100
+ openDatabase(join(base, ".gsd", "gsd.db"));
101
+ insertMilestone({ id: "M001", title: "Test", status: "active" });
102
+
103
+ const w1 = registerAutoWorker({ projectRootRealpath: base });
104
+ const w2 = registerAutoWorker({ projectRootRealpath: base });
105
+ const first = claimMilestoneLease(w1, "M001");
106
+ assert.equal(first.ok, true);
107
+
108
+ // Force expiration by patching the row's expires_at into the past.
109
+ const db = _getAdapter()!;
110
+ db.prepare(
111
+ `UPDATE milestone_leases SET expires_at = '1970-01-01T00:00:00.000Z' WHERE milestone_id = 'M001'`,
112
+ ).run();
113
+
114
+ const takeover = claimMilestoneLease(w2, "M001");
115
+ assert.equal(takeover.ok, true);
116
+ if (takeover.ok) {
117
+ assert.equal(takeover.token, 2);
118
+ }
119
+ const row = getMilestoneLease("M001");
120
+ assert.equal(row!.worker_id, w2);
121
+ assert.equal(row!.fencing_token, 2);
122
+ });
123
+
124
+ test("refreshMilestoneLease only succeeds with the matching fencing token", (t) => {
125
+ const base = makeBase();
126
+ t.after(() => cleanup(base));
127
+ openDatabase(join(base, ".gsd", "gsd.db"));
128
+ insertMilestone({ id: "M001", title: "Test", status: "active" });
129
+
130
+ const w1 = registerAutoWorker({ projectRootRealpath: base });
131
+ const claim = claimMilestoneLease(w1, "M001");
132
+ assert.equal(claim.ok, true);
133
+ if (!claim.ok) return;
134
+
135
+ // Correct token refreshes
136
+ assert.equal(refreshMilestoneLease(w1, "M001", claim.token), true);
137
+
138
+ // Stale token (e.g. claim.token - 1) refuses
139
+ assert.equal(refreshMilestoneLease(w1, "M001", claim.token - 1), false);
140
+ });
141
+
142
+ test("claimMilestoneLease rethrows foreign-key failures instead of treating them as lease contention", (t) => {
143
+ const base = makeBase();
144
+ t.after(() => cleanup(base));
145
+ openDatabase(join(base, ".gsd", "gsd.db"));
146
+ insertMilestone({ id: "M001", title: "Test", status: "active" });
147
+
148
+ assert.throws(
149
+ () => claimMilestoneLease("missing-worker", "M001"),
150
+ /FOREIGN KEY constraint failed/,
151
+ );
152
+ });
@@ -0,0 +1,106 @@
1
+ // gsd-2 + Parallel-worker isolation regression (Phase B coordination)
2
+ //
3
+ // Two simulated workers attempt to claim leases on the same project. The
4
+ // lease infrastructure must guarantee:
5
+ // - On the same milestone: only one wins; the loser sees held_by error
6
+ // - On different milestones: both succeed independently
7
+ //
8
+ // This is the integration check that ties registerAutoWorker +
9
+ // claimMilestoneLease + recordDispatchClaim together end-to-end.
10
+
11
+ import test from "node:test";
12
+ import assert from "node:assert/strict";
13
+ import { mkdtempSync, mkdirSync, rmSync } from "node:fs";
14
+ import { join } from "node:path";
15
+ import { tmpdir } from "node:os";
16
+
17
+ import {
18
+ openDatabase,
19
+ closeDatabase,
20
+ insertMilestone,
21
+ } from "../gsd-db.ts";
22
+ import { registerAutoWorker } from "../db/auto-workers.ts";
23
+ import { claimMilestoneLease } from "../db/milestone-leases.ts";
24
+ import { recordDispatchClaim } from "../db/unit-dispatches.ts";
25
+
26
+ function makeBase(): string {
27
+ const base = mkdtempSync(join(tmpdir(), "gsd-parallel-iso-"));
28
+ mkdirSync(join(base, ".gsd"), { recursive: true });
29
+ return base;
30
+ }
31
+
32
+ function cleanup(base: string): void {
33
+ try { closeDatabase(); } catch { /* noop */ }
34
+ try { rmSync(base, { recursive: true, force: true }); } catch { /* noop */ }
35
+ }
36
+
37
+ test("two workers contesting the same milestone: only one wins the lease", (t) => {
38
+ const base = makeBase();
39
+ t.after(() => cleanup(base));
40
+ openDatabase(join(base, ".gsd", "gsd.db"));
41
+ insertMilestone({ id: "M001", title: "Contested", status: "active" });
42
+
43
+ const w1 = registerAutoWorker({ projectRootRealpath: base });
44
+ const w2 = registerAutoWorker({ projectRootRealpath: base });
45
+
46
+ const r1 = claimMilestoneLease(w1, "M001");
47
+ const r2 = claimMilestoneLease(w2, "M001");
48
+
49
+ assert.equal(r1.ok, true, "first claim wins");
50
+ assert.equal(r2.ok, false, "second claim is rejected");
51
+ if (!r2.ok) {
52
+ assert.equal(r2.error, "held_by");
53
+ assert.equal(r2.byWorker, w1);
54
+ }
55
+ });
56
+
57
+ test("two workers on different milestones can both proceed independently", (t) => {
58
+ const base = makeBase();
59
+ t.after(() => cleanup(base));
60
+ openDatabase(join(base, ".gsd", "gsd.db"));
61
+ insertMilestone({ id: "M001", title: "First", status: "active" });
62
+ insertMilestone({ id: "M002", title: "Second", status: "active" });
63
+
64
+ const w1 = registerAutoWorker({ projectRootRealpath: base });
65
+ const w2 = registerAutoWorker({ projectRootRealpath: base });
66
+
67
+ const r1 = claimMilestoneLease(w1, "M001");
68
+ const r2 = claimMilestoneLease(w2, "M002");
69
+
70
+ assert.equal(r1.ok, true);
71
+ assert.equal(r2.ok, true);
72
+ if (r1.ok && r2.ok) {
73
+ assert.equal(r1.token, 1);
74
+ assert.equal(r2.token, 1);
75
+ }
76
+ });
77
+
78
+ test("dispatch ledger ties unit_id uniqueness to active status", (t) => {
79
+ const base = makeBase();
80
+ t.after(() => cleanup(base));
81
+ openDatabase(join(base, ".gsd", "gsd.db"));
82
+ insertMilestone({ id: "M001", title: "T", status: "active" });
83
+
84
+ const w1 = registerAutoWorker({ projectRootRealpath: base });
85
+ const lease = claimMilestoneLease(w1, "M001");
86
+ assert.equal(lease.ok, true);
87
+ if (!lease.ok) return;
88
+
89
+ // The same lease holder attempts to claim the same unit twice.
90
+ // The partial unique index on unit_dispatches.unit_id WHERE status IN
91
+ // ('claimed','running') must serialize the writes even before the unit transitions.
92
+ const claim1 = recordDispatchClaim({
93
+ traceId: "t1", workerId: w1, milestoneLeaseToken: lease.token,
94
+ milestoneId: "M001", unitType: "plan-slice", unitId: "M001/S01",
95
+ });
96
+ const claim2 = recordDispatchClaim({
97
+ traceId: "t2", workerId: w1, milestoneLeaseToken: lease.token,
98
+ milestoneId: "M001", unitType: "plan-slice", unitId: "M001/S01",
99
+ });
100
+
101
+ assert.equal(claim1.ok, true);
102
+ assert.equal(claim2.ok, false);
103
+ if (!claim2.ok) {
104
+ assert.equal(claim2.error, "already_active");
105
+ }
106
+ });