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
@@ -18,39 +18,96 @@ import {
18
18
  hasResumableDerivedState,
19
19
  isBootstrapCrashLock,
20
20
  readPausedSessionMetadata,
21
+ PAUSED_SESSION_KV_KEY,
21
22
  } from "../interrupted-session.ts";
22
- import { gsdRoot } from "../paths.ts";
23
+ import {
24
+ openDatabase,
25
+ closeDatabase,
26
+ insertMilestone,
27
+ _getAdapter,
28
+ } from "../gsd-db.ts";
29
+ import { registerAutoWorker } from "../db/auto-workers.ts";
30
+ import { claimMilestoneLease } from "../db/milestone-leases.ts";
31
+ import { recordDispatchClaim } from "../db/unit-dispatches.ts";
32
+ import { insertSlice, insertTask } from "../gsd-db.ts";
33
+ import { setRuntimeKv } from "../db/runtime-kv.ts";
34
+ import { normalizeRealPath } from "../paths.ts";
23
35
  import type { GSDState } from "../types.ts";
24
36
  import { _synthesizePausedSessionRecoveryForTest } from "../auto.ts";
25
37
 
26
38
  function makeTmpBase(): string {
27
39
  const base = join(tmpdir(), `gsd-test-${randomUUID()}`);
28
40
  mkdirSync(join(base, ".gsd"), { recursive: true });
41
+ // Phase C pt 2: lock and paused-session live in the DB now. Open it
42
+ // for every test base so the helpers below can write through.
43
+ openDatabase(join(base, ".gsd", "gsd.db"));
29
44
  return base;
30
45
  }
31
46
 
32
47
  function cleanup(base: string): void {
48
+ try { closeDatabase(); } catch { /* */ }
33
49
  try { rmSync(base, { recursive: true, force: true }); } catch { /* */ }
34
50
  }
35
51
 
52
+ /**
53
+ * Phase C pt 2 fixture: insert a stale worker row + dispatch + session_file
54
+ * directly via SQL so it appears as a crashed PEER process, not as the
55
+ * current test process. assessInterruptedSession filters out
56
+ * `rawLock.pid === process.pid` to avoid classifying its own process as
57
+ * a previous crash; using PID 999999999 (functionally guaranteed dead)
58
+ * bypasses that guard exactly the way the old file-based writeTestLock
59
+ * did with the same PID.
60
+ */
36
61
  function writeTestLock(
37
62
  base: string,
38
63
  unitType: string,
39
64
  unitId: string,
40
65
  sessionFile?: string,
41
66
  ): void {
42
- writeFileSync(
43
- join(gsdRoot(base), "auto.lock"),
44
- JSON.stringify({
45
- pid: 999999999,
46
- startedAt: new Date().toISOString(),
47
- unitType,
48
- unitId,
49
- unitStartedAt: new Date().toISOString(),
50
- sessionFile,
51
- }, null, 2),
52
- "utf-8",
53
- );
67
+ const projectRoot = normalizeRealPath(base);
68
+ const workerId = `test-fake-${randomUUID().slice(0, 8)}`;
69
+ const fakePid = 999999999;
70
+ const stalePast = "1970-01-01T00:00:00.000Z";
71
+ const db = _getAdapter()!;
72
+ db.prepare(
73
+ `INSERT INTO workers (
74
+ worker_id, host, pid, started_at, version,
75
+ last_heartbeat_at, status, project_root_realpath
76
+ ) VALUES (
77
+ :w, 'test-host', :pid, :started_at, 'test',
78
+ :stale, 'active', :project_root
79
+ )`,
80
+ ).run({
81
+ ":w": workerId,
82
+ ":pid": fakePid,
83
+ ":started_at": new Date().toISOString(),
84
+ ":stale": stalePast,
85
+ ":project_root": projectRoot,
86
+ });
87
+
88
+ // Ensure milestones referenced by the unitId exist so the dispatch
89
+ // FK is satisfied. Parse "M###/S##" or "M###" or "starting" / etc.
90
+ const midMatch = unitId.match(/^(M\d+)/);
91
+ if (midMatch && unitType !== "starting") {
92
+ const mid = midMatch[1];
93
+ try { insertMilestone({ id: mid, title: `Test ${mid}`, status: "active" }); }
94
+ catch { /* may already exist */ }
95
+ try {
96
+ const lease = claimMilestoneLease(workerId, mid);
97
+ recordDispatchClaim({
98
+ traceId: randomUUID(),
99
+ workerId,
100
+ milestoneLeaseToken: lease.ok ? lease.token : 0,
101
+ milestoneId: mid,
102
+ unitType,
103
+ unitId,
104
+ });
105
+ } catch { /* ignore — best-effort */ }
106
+ }
107
+
108
+ if (sessionFile) {
109
+ setRuntimeKv("worker", workerId, "session_file", sessionFile);
110
+ }
54
111
  }
55
112
 
56
113
  function writeRoadmap(base: string, checked = false): void {
@@ -82,6 +139,25 @@ function writeRoadmap(base: string, checked = false): void {
82
139
  ].join("\n"),
83
140
  "utf-8",
84
141
  );
142
+ // Phase C pt 2: makeTmpBase() opens the DB so writeTestLock can write
143
+ // the workers row. deriveState then goes DB-first; mirror the markdown
144
+ // fixture into the DB so the assessment sees the same milestone state.
145
+ // Use direct upsert SQL so calling writeRoadmap twice (e.g. once for
146
+ // base + once for a paused worktree) actually flips the status.
147
+ const status = checked ? "complete" : "active";
148
+ const adapter = _getAdapter();
149
+ if (adapter) {
150
+ adapter.prepare(
151
+ `INSERT INTO milestones (id, title, status, created_at)
152
+ VALUES (:id, :title, :status, :now)
153
+ ON CONFLICT(id) DO UPDATE SET status = excluded.status, title = excluded.title`,
154
+ ).run({ ":id": "M001", ":title": "Test Milestone", ":status": status, ":now": new Date().toISOString() });
155
+ adapter.prepare(
156
+ `INSERT INTO slices (milestone_id, id, title, status, created_at)
157
+ VALUES (:mid, :sid, :title, :status, :now)
158
+ ON CONFLICT(milestone_id, id) DO UPDATE SET status = excluded.status, title = excluded.title`,
159
+ ).run({ ":mid": "M001", ":sid": "S01", ":title": "Test slice", ":status": status, ":now": new Date().toISOString() });
160
+ }
85
161
  }
86
162
 
87
163
  function writeCompleteSliceArtifacts(base: string): void {
@@ -105,13 +181,16 @@ function writePausedSession(
105
181
  unitType?: string,
106
182
  unitId?: string,
107
183
  ): void {
108
- const runtimeDir = join(base, ".gsd", "runtime");
109
- mkdirSync(runtimeDir, { recursive: true });
110
- writeFileSync(
111
- join(runtimeDir, "paused-session.json"),
112
- JSON.stringify({ milestoneId, originalBasePath: base, stepMode, worktreePath, unitType, unitId }, null, 2),
113
- "utf-8",
114
- );
184
+ // Phase C pt 2: paused-session.json migrated to runtime_kv
185
+ // (global scope, key PAUSED_SESSION_KV_KEY).
186
+ setRuntimeKv("global", "", PAUSED_SESSION_KV_KEY, {
187
+ milestoneId,
188
+ originalBasePath: base,
189
+ stepMode,
190
+ worktreePath,
191
+ unitType,
192
+ unitId,
193
+ });
115
194
  }
116
195
 
117
196
  function writeActivityLog(base: string, entries: Record<string, unknown>[]): void {
@@ -231,14 +310,12 @@ test("readPausedSessionMetadata preserves unitType and unitId through round-trip
231
310
  test("readPausedSessionMetadata handles legacy metadata without unitType/unitId", () => {
232
311
  const base = makeTmpBase();
233
312
  try {
234
- // Write metadata without unitType/unitId (simulates older version)
235
- const runtimeDir = join(base, ".gsd", "runtime");
236
- mkdirSync(runtimeDir, { recursive: true });
237
- writeFileSync(
238
- join(runtimeDir, "paused-session.json"),
239
- JSON.stringify({ milestoneId: "M001", originalBasePath: base }),
240
- "utf-8",
241
- );
313
+ // Phase C pt 2: write directly to runtime_kv (simulates older payload
314
+ // missing the now-canonical unitType/unitId fields).
315
+ setRuntimeKv("global", "", PAUSED_SESSION_KV_KEY, {
316
+ milestoneId: "M001",
317
+ originalBasePath: base,
318
+ });
242
319
  const meta = readPausedSessionMetadata(base);
243
320
  assert.equal(meta?.milestoneId, "M001");
244
321
  assert.equal(meta?.unitType, undefined);
@@ -251,23 +328,23 @@ test("readPausedSessionMetadata handles legacy metadata without unitType/unitId"
251
328
  test("readPausedSessionMetadata drops stale discuss-milestone pseudo PROJECT metadata", () => {
252
329
  const base = makeTmpBase();
253
330
  try {
254
- const runtimeDir = join(base, ".gsd", "runtime");
255
- const pausedPath = join(runtimeDir, "paused-session.json");
256
- mkdirSync(runtimeDir, { recursive: true });
257
- writeFileSync(
258
- pausedPath,
259
- JSON.stringify({
260
- milestoneId: null,
261
- originalBasePath: base,
262
- unitType: "discuss-milestone",
263
- unitId: "PROJECT",
264
- }, null, 2),
265
- "utf-8",
266
- );
331
+ // Phase C pt 2: write directly to runtime_kv (the file location is gone)
332
+ setRuntimeKv("global", "", PAUSED_SESSION_KV_KEY, {
333
+ milestoneId: null,
334
+ originalBasePath: base,
335
+ unitType: "discuss-milestone",
336
+ unitId: "PROJECT",
337
+ });
267
338
 
268
339
  const meta = readPausedSessionMetadata(base);
269
340
  assert.equal(meta, null);
270
- assert.equal(existsSync(pausedPath), false);
341
+ // Confirm the row was deleted by readPausedSessionMetadata's
342
+ // isStalePseudoMilestonePause branch.
343
+ const adapter = _getAdapter()!;
344
+ const row = adapter.prepare(
345
+ `SELECT 1 FROM runtime_kv WHERE scope = 'global' AND scope_id = '' AND key = :k`,
346
+ ).get({ ":k": PAUSED_SESSION_KV_KEY });
347
+ assert.equal(row, undefined);
271
348
  } finally {
272
349
  cleanup(base);
273
350
  }
@@ -276,23 +353,20 @@ test("readPausedSessionMetadata drops stale discuss-milestone pseudo PROJECT met
276
353
  test("readPausedSessionMetadata drops stale deep setup pseudo-unit metadata", () => {
277
354
  const base = makeTmpBase();
278
355
  try {
279
- const runtimeDir = join(base, ".gsd", "runtime");
280
- const pausedPath = join(runtimeDir, "paused-session.json");
281
- mkdirSync(runtimeDir, { recursive: true });
282
- writeFileSync(
283
- pausedPath,
284
- JSON.stringify({
285
- milestoneId: "WORKFLOW-PREFS",
286
- originalBasePath: base,
287
- unitType: "workflow-preferences",
288
- unitId: "WORKFLOW-PREFS",
289
- }, null, 2),
290
- "utf-8",
291
- );
356
+ setRuntimeKv("global", "", PAUSED_SESSION_KV_KEY, {
357
+ milestoneId: "WORKFLOW-PREFS",
358
+ originalBasePath: base,
359
+ unitType: "workflow-preferences",
360
+ unitId: "WORKFLOW-PREFS",
361
+ });
292
362
 
293
363
  const meta = readPausedSessionMetadata(base);
294
364
  assert.equal(meta, null);
295
- assert.equal(existsSync(pausedPath), false);
365
+ const adapter = _getAdapter()!;
366
+ const row = adapter.prepare(
367
+ `SELECT 1 FROM runtime_kv WHERE scope = 'global' AND scope_id = '' AND key = :k`,
368
+ ).get({ ":k": PAUSED_SESSION_KV_KEY });
369
+ assert.equal(row, undefined);
296
370
  } finally {
297
371
  cleanup(base);
298
372
  }
@@ -504,10 +578,29 @@ test("assessInterruptedSession treats bootstrap crash as stale without paused me
504
578
  // ─── writeLock / readCrashLock ────────────────────────────────────────────
505
579
 
506
580
  test("writeLock creates lock file and readCrashLock reads it", (t) => {
581
+ // Phase C pt 2: lock state is reconstructed from workers + unit_dispatches
582
+ // + runtime_kv. The fresh worker is not stale yet — we register, dispatch,
583
+ // write the session_file, then expire the heartbeat to simulate a crash.
507
584
  const base = makeTmpBase();
508
585
  t.after(() => cleanup(base));
509
586
 
587
+ insertMilestone({ id: "M001", title: "Test", status: "active" });
588
+ const projectRoot = normalizeRealPath(base);
589
+ const workerId = registerAutoWorker({ projectRootRealpath: projectRoot });
590
+ const lease = claimMilestoneLease(workerId, "M001");
591
+ assert.equal(lease.ok, true);
592
+ if (!lease.ok) return;
593
+ recordDispatchClaim({
594
+ traceId: "t1", workerId, milestoneLeaseToken: lease.token,
595
+ milestoneId: "M001", unitType: "execute-task", unitId: "M001/S01/T01",
596
+ });
510
597
  writeLock(base, "execute-task", "M001/S01/T01", "/tmp/session.jsonl");
598
+
599
+ // Force stale so readCrashLock surfaces it.
600
+ _getAdapter()!.prepare(
601
+ `UPDATE workers SET last_heartbeat_at = '1970-01-01T00:00:00.000Z' WHERE worker_id = :w`,
602
+ ).run({ ":w": workerId });
603
+
511
604
  const lock = readCrashLock(base);
512
605
  assert.ok(lock, "lock should exist");
513
606
  assert.equal(lock!.unitType, "execute-task");
@@ -527,13 +620,30 @@ test("readCrashLock returns null when no lock exists", (t) => {
527
620
  // ─── clearLock ────────────────────────────────────────────────────────────
528
621
 
529
622
  test("clearLock removes existing lock file", (t) => {
623
+ // Phase C pt 2: clearLock now drops the session_file runtime_kv row
624
+ // for the LIVE worker (not the stale one). The "lock state" itself
625
+ // (pid, unitType, etc.) lives in workers + unit_dispatches; those are
626
+ // managed by markWorkerStopping (called from stopAuto, not here).
530
627
  const base = makeTmpBase();
531
628
  t.after(() => cleanup(base));
532
629
 
533
- writeLock(base, "plan-slice", "M001/S01");
534
- assert.ok(readCrashLock(base), "lock should exist before clear");
630
+ const projectRoot = normalizeRealPath(base);
631
+ const workerId = registerAutoWorker({ projectRootRealpath: projectRoot });
632
+
633
+ writeLock(base, "plan-slice", "M001/S01", "/tmp/session.jsonl");
634
+ // Confirm the session_file row landed for the live worker.
635
+ const adapter = _getAdapter()!;
636
+ const before = adapter.prepare(
637
+ `SELECT 1 FROM runtime_kv WHERE scope = 'worker' AND scope_id = :w AND key = 'session_file'`,
638
+ ).get({ ":w": workerId });
639
+ assert.ok(before, "session_file row exists before clear");
640
+
535
641
  clearLock(base);
536
- assert.equal(readCrashLock(base), null, "lock should be gone after clear");
642
+
643
+ const after = adapter.prepare(
644
+ `SELECT 1 FROM runtime_kv WHERE scope = 'worker' AND scope_id = :w AND key = 'session_file'`,
645
+ ).get({ ":w": workerId });
646
+ assert.equal(after, undefined, "session_file row gone after clearLock");
537
647
  });
538
648
 
539
649
  test("clearLock is safe when no lock exists", (t) => {
@@ -0,0 +1,173 @@
1
+ // gsd-2 + Stuck-detector retry coupling regression (Phase B / codex MEDIUM B3)
2
+ //
3
+ // Rule 2b previously tripped on 3 same-unit appearances regardless of
4
+ // retry budget. With unit_dispatches.attempt_n + next_run_at driving in-DB
5
+ // backoff, a unit that fails 3× under retry would trip the stuck-detector
6
+ // before its retry budget exhausted. This test verifies suppression while
7
+ // the retry window is open and re-engagement once the window passes or
8
+ // budget exhausts.
9
+
10
+ import test from "node:test";
11
+ import assert from "node:assert/strict";
12
+ import { mkdtempSync, mkdirSync, rmSync } from "node:fs";
13
+ import { join } from "node:path";
14
+ import { tmpdir } from "node:os";
15
+
16
+ import {
17
+ openDatabase,
18
+ closeDatabase,
19
+ insertMilestone,
20
+ _getAdapter,
21
+ } from "../gsd-db.ts";
22
+ import { registerAutoWorker } from "../db/auto-workers.ts";
23
+ import { claimMilestoneLease } from "../db/milestone-leases.ts";
24
+ import {
25
+ recordDispatchClaim,
26
+ markFailed,
27
+ getLatestForUnit,
28
+ } from "../db/unit-dispatches.ts";
29
+ import { detectStuck } from "../auto/detect-stuck.ts";
30
+ import type { WindowEntry } from "../auto/types.ts";
31
+
32
+ function makeBase(): string {
33
+ const base = mkdtempSync(join(tmpdir(), "gsd-detect-stuck-retry-"));
34
+ mkdirSync(join(base, ".gsd"), { recursive: true });
35
+ return base;
36
+ }
37
+
38
+ function cleanup(base: string): void {
39
+ try { closeDatabase(); } catch { /* noop */ }
40
+ try { rmSync(base, { recursive: true, force: true }); } catch { /* noop */ }
41
+ }
42
+
43
+ function windowOf(...keys: string[]): WindowEntry[] {
44
+ return keys.map((key) => ({ key }));
45
+ }
46
+
47
+ test("rule 2b trips with no DB context (legacy behavior preserved)", () => {
48
+ // No DB open — getLatestForUnit returns null, suppression cannot fire,
49
+ // pre-Phase-B behavior is intact.
50
+ const result = detectStuck(
51
+ windowOf(
52
+ "plan-slice:M001/S01",
53
+ "other-unit",
54
+ "plan-slice:M001/S01",
55
+ "third-unit",
56
+ "plan-slice:M001/S01",
57
+ ),
58
+ );
59
+ assert.ok(result, "stuck signal returned");
60
+ assert.equal(result!.stuck, true);
61
+ });
62
+
63
+ test("rule 2b SUPPRESSED while retry budget remains and next_run_at is in the future", (t) => {
64
+ const base = makeBase();
65
+ t.after(() => cleanup(base));
66
+ openDatabase(join(base, ".gsd", "gsd.db"));
67
+ insertMilestone({ id: "M001", title: "T", status: "active" });
68
+ const w = registerAutoWorker({ projectRootRealpath: base });
69
+ const lease = claimMilestoneLease(w, "M001");
70
+ assert.equal(lease.ok, true);
71
+ if (!lease.ok) return;
72
+
73
+ // Record a failed dispatch with attempt_n=1, max_attempts=3, retry_after
74
+ // pushing next_run_at into the future.
75
+ const claim = recordDispatchClaim({
76
+ traceId: "t1", workerId: w, milestoneLeaseToken: lease.token,
77
+ milestoneId: "M001", unitType: "plan-slice", unitId: "plan-slice:M001/S01",
78
+ attemptN: 1, maxAttempts: 3,
79
+ });
80
+ assert.equal(claim.ok, true);
81
+ if (!claim.ok) return;
82
+ markFailed(claim.dispatchId, { errorSummary: "transient", retryAfterMs: 60_000 });
83
+
84
+ const latest = getLatestForUnit("plan-slice:M001/S01")!;
85
+ assert.equal(latest.attempt_n, 1);
86
+ assert.ok(latest.next_run_at);
87
+
88
+ const result = detectStuck(
89
+ windowOf(
90
+ "plan-slice:M001/S01",
91
+ "other-unit",
92
+ "plan-slice:M001/S01",
93
+ "third-unit",
94
+ "plan-slice:M001/S01",
95
+ ),
96
+ );
97
+ assert.equal(result, null, "rule 2b suppressed while retry window is active");
98
+ });
99
+
100
+ test("rule 2b RE-ENGAGES once attempt_n reaches max_attempts", (t) => {
101
+ const base = makeBase();
102
+ t.after(() => cleanup(base));
103
+ openDatabase(join(base, ".gsd", "gsd.db"));
104
+ insertMilestone({ id: "M001", title: "T", status: "active" });
105
+ const w = registerAutoWorker({ projectRootRealpath: base });
106
+ const lease = claimMilestoneLease(w, "M001");
107
+ assert.equal(lease.ok, true);
108
+ if (!lease.ok) return;
109
+
110
+ // Burn through attempts up to the cap — last attempt = max_attempts.
111
+ for (let attempt = 1; attempt <= 3; attempt++) {
112
+ const claim = recordDispatchClaim({
113
+ traceId: `t${attempt}`, workerId: w, milestoneLeaseToken: lease.token,
114
+ milestoneId: "M001", unitType: "plan-slice", unitId: "plan-slice:M001/S01",
115
+ attemptN: attempt, maxAttempts: 3,
116
+ });
117
+ assert.equal(claim.ok, true);
118
+ if (!claim.ok) return;
119
+ markFailed(claim.dispatchId, { errorSummary: "transient", retryAfterMs: 60_000 });
120
+ }
121
+
122
+ const latest = getLatestForUnit("plan-slice:M001/S01")!;
123
+ assert.equal(latest.attempt_n, 3);
124
+ assert.equal(latest.max_attempts, 3);
125
+
126
+ const result = detectStuck(
127
+ windowOf(
128
+ "plan-slice:M001/S01",
129
+ "other-unit",
130
+ "plan-slice:M001/S01",
131
+ "third-unit",
132
+ "plan-slice:M001/S01",
133
+ ),
134
+ );
135
+ assert.ok(result, "stuck signal returned once retry budget is exhausted");
136
+ });
137
+
138
+ test("rule 2b RE-ENGAGES once next_run_at is in the past", (t) => {
139
+ const base = makeBase();
140
+ t.after(() => cleanup(base));
141
+ openDatabase(join(base, ".gsd", "gsd.db"));
142
+ insertMilestone({ id: "M001", title: "T", status: "active" });
143
+ const w = registerAutoWorker({ projectRootRealpath: base });
144
+ const lease = claimMilestoneLease(w, "M001");
145
+ assert.equal(lease.ok, true);
146
+ if (!lease.ok) return;
147
+
148
+ const claim = recordDispatchClaim({
149
+ traceId: "t", workerId: w, milestoneLeaseToken: lease.token,
150
+ milestoneId: "M001", unitType: "plan-slice", unitId: "plan-slice:M001/S01",
151
+ attemptN: 1, maxAttempts: 3,
152
+ });
153
+ assert.equal(claim.ok, true);
154
+ if (!claim.ok) return;
155
+ markFailed(claim.dispatchId, { errorSummary: "transient", retryAfterMs: 60_000 });
156
+
157
+ // Force next_run_at into the past — retry window has already lapsed.
158
+ const db = _getAdapter()!;
159
+ db.prepare(
160
+ `UPDATE unit_dispatches SET next_run_at = '1970-01-01T00:00:00.000Z' WHERE id = :id`,
161
+ ).run({ ":id": claim.dispatchId });
162
+
163
+ const result = detectStuck(
164
+ windowOf(
165
+ "plan-slice:M001/S01",
166
+ "other-unit",
167
+ "plan-slice:M001/S01",
168
+ "third-unit",
169
+ "plan-slice:M001/S01",
170
+ ),
171
+ );
172
+ assert.ok(result, "stuck re-engages once retry window has passed");
173
+ });
@@ -160,7 +160,7 @@ describe("auto-worktree lifecycle", () => {
160
160
  assert.ok(realWtPath.startsWith(storage), "git registered the symlink-resolved worktree path");
161
161
 
162
162
  _resetAutoWorktreeOriginalBaseForTests();
163
- process.chdir(join(realWtPath, ".gsd", "milestones", "M001"));
163
+ process.chdir(realWtPath);
164
164
 
165
165
  assert.ok(isInAutoWorktree(tempDir), "structural detection works without module originalBase");
166
166
  const resolved = getAutoWorktreePath(realWtPath, "M001");
@@ -169,7 +169,7 @@ describe("auto-worktree lifecycle", () => {
169
169
  assert.equal(existsSync(join(realWtPath, ".gsd", "worktrees", "M001")), false);
170
170
 
171
171
  enterAutoWorktree(tempDir, "M001");
172
- process.chdir(join(realWtPath, ".gsd", "milestones", "M001"));
172
+ process.chdir(realWtPath);
173
173
  assert.deepStrictEqual(
174
174
  getActiveAutoWorktreeContext(),
175
175
  {
@@ -282,7 +282,7 @@ describe("auto-worktree lifecycle", () => {
282
282
  teardownAutoWorktree(tempDir, "M010");
283
283
  });
284
284
 
285
- test("#778: reconcile plan checkboxes on re-attach", async () => {
285
+ test("#778: re-attach does not reconcile plan checkboxes into a worktree-local .gsd projection", async () => {
286
286
  tempDir = createTempRepo();
287
287
  const msDir = join(tempDir, ".gsd", "milestones", "M003");
288
288
  mkdirSync(msDir, { recursive: true });
@@ -322,23 +322,31 @@ describe("auto-worktree lifecycle", () => {
322
322
  "# S01 Plan\n- [x] **T01:** task one\n- [x] **T02:** task two\n- [ ] **T03:** task three\n",
323
323
  );
324
324
 
325
- // Create worktree re-attached to existing milestone branch (T02 still [ ] in branch)
325
+ // Re-attaching the worktree should not reconcile the branch copy from the
326
+ // project-root plan. The project root stays canonical; the worktree keeps
327
+ // the milestone branch's tracked file contents until a git operation
328
+ // changes them.
326
329
  const wtPath = createAutoWorktree(tempDir, "M004");
327
330
 
328
331
  try {
329
332
  const wtPlanPath = join(wtPath, planRelPath);
330
- assert.ok(existsSync(wtPlanPath), "plan file exists in worktree after re-attach");
333
+ assert.ok(existsSync(wtPlanPath), "tracked plan file remains present in the worktree branch");
331
334
 
332
335
  const wtPlan = read(wtPlanPath, "utf-8");
333
- assert.ok(wtPlan.includes("- [x] **T02:"), "T02 should be [x] after reconciliation (was [ ] on branch)");
334
- assert.ok(wtPlan.includes("- [x] **T01:"), "T01 stays [x]");
335
- assert.ok(wtPlan.includes("- [ ] **T03:"), "T03 stays [ ] (not in root either)");
336
+ assert.ok(wtPlan.includes("- [ ] **T02:"), "worktree branch should retain its unreconciled T02 [ ] state");
337
+ assert.ok(wtPlan.includes("- [x] **T01:"), "worktree branch should retain T01 [x]");
338
+ assert.ok(wtPlan.includes("- [ ] **T03:"), "worktree branch should retain T03 [ ]");
339
+
340
+ const rootPlan = read(join(tempDir, planRelPath), "utf-8");
341
+ assert.ok(rootPlan.includes("- [x] **T02:"), "canonical root plan retains the newer T02 [x] state");
342
+ assert.ok(rootPlan.includes("- [x] **T01:"), "canonical root plan retains T01 [x]");
343
+ assert.ok(rootPlan.includes("- [ ] **T03:"), "canonical root plan retains T03 [ ]");
336
344
  } finally {
337
345
  teardownAutoWorktree(tempDir, "M004");
338
346
  }
339
347
  });
340
348
 
341
- test("#2791: mcp.json copied into worktree via copyPlanningArtifacts", () => {
349
+ test("#2791: mcp.json is not copied into worktree on creation after copyPlanningArtifacts removal", () => {
342
350
  tempDir = createTempRepo();
343
351
  const msDir = join(tempDir, ".gsd", "milestones", "M003");
344
352
  mkdirSync(msDir, { recursive: true });
@@ -347,7 +355,8 @@ describe("auto-worktree lifecycle", () => {
347
355
  run("git commit -m \"add milestone\"", tempDir);
348
356
 
349
357
  // Create mcp.json in .gsd/ AFTER the commit (untracked, like real usage).
350
- // copyPlanningArtifacts should copy it into the worktree's .gsd/.
358
+ // Phase C removed copyPlanningArtifacts, so creation should not seed a
359
+ // second worktree-local copy.
351
360
  writeFileSync(
352
361
  join(tempDir, ".gsd", "mcp.json"),
353
362
  JSON.stringify({ servers: { test: { command: "echo" } } }),
@@ -356,9 +365,10 @@ describe("auto-worktree lifecycle", () => {
356
365
  const wtPath = createAutoWorktree(tempDir, "M003");
357
366
 
358
367
  try {
359
- assert.ok(
368
+ assert.equal(
360
369
  existsSync(join(wtPath, ".gsd", "mcp.json")),
361
- "mcp.json should be copied into worktree .gsd/ on creation",
370
+ false,
371
+ "mcp.json should not be copied into worktree .gsd/ on creation",
362
372
  );
363
373
  } finally {
364
374
  teardownAutoWorktree(tempDir, "M003");
@@ -240,17 +240,31 @@ describe('doctor-proactive', async () => {
240
240
  cleanups.push(dir);
241
241
  mkdirSync(join(dir, ".gsd"), { recursive: true });
242
242
 
243
- // Write a stale lock
244
- writeFileSync(join(dir, ".gsd", "auto.lock"), JSON.stringify({
245
- pid: 9999999, startedAt: "2026-03-10T00:00:00Z",
246
- unitType: "execute-task", unitId: "M001/S01/T01",
247
- unitStartedAt: "2026-03-10T00:01:00Z", completedUnits: 3,
248
- }));
243
+ // Phase C pt 2: stale lock state lives in the workers table now.
244
+ // Open the DB, insert a fake stale worker row directly (PID 9999999
245
+ // is functionally guaranteed dead), then close — the doctor will
246
+ // re-open via its own path.
247
+ const { openDatabase, _getAdapter } = await import("../../gsd-db.ts");
248
+ const { randomUUID } = await import("node:crypto");
249
+ openDatabase(join(dir, ".gsd", "gsd.db"));
250
+ const db = _getAdapter()!;
251
+ db.prepare(
252
+ `INSERT INTO workers (worker_id, host, pid, started_at, version, last_heartbeat_at, status, project_root_realpath)
253
+ VALUES (:w, 'test-host', 9999999, '2026-03-10T00:00:00Z', 'test', '1970-01-01T00:00:00.000Z', 'active', :root)`,
254
+ ).run({ ":w": `test-fake-${randomUUID().slice(0, 8)}`, ":root": dir });
255
+ const { closeDatabase } = await import("../../gsd-db.ts");
256
+ closeDatabase();
249
257
 
250
- const result = await preDispatchHealthGate(dir);
251
- assert.ok(result.proceed, "gate passes after auto-clearing stale lock");
252
- assert.ok(result.fixesApplied.some(f => f.includes("cleared stale auto.lock")), "reports lock cleared");
253
- assert.ok(!existsSync(join(dir, ".gsd", "auto.lock")), "lock file removed");
258
+ try {
259
+ const result = await preDispatchHealthGate(dir);
260
+ assert.ok(result.proceed, "gate passes after auto-clearing stale lock");
261
+ assert.ok(
262
+ result.fixesApplied.some(f => f.includes("cleared stale") || f.includes("cleared stale auto.lock")),
263
+ `reports lock cleared (got: ${result.fixesApplied.join(", ")})`,
264
+ );
265
+ } finally {
266
+ closeDatabase();
267
+ }
254
268
  });
255
269
 
256
270
  test('health gate: corrupt merge state auto-healed', async () => {