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
@@ -0,0 +1,134 @@
1
+ // gsd-2 + Stuck-state DB-migration regression (Phase C)
2
+ //
3
+ // stuck-state.json file IO has been deleted. The auto-loop now reconstructs
4
+ // recentUnits from unit_dispatches (Phase B ledger) and persists
5
+ // stuckRecoveryAttempts in runtime_kv (stable project scope, soft state).
6
+ //
7
+ // This test verifies the round-trip via the db modules directly: write
8
+ // dispatch rows + a runtime_kv counter, then confirm the same data shape
9
+ // the loop expects is returned.
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 {
25
+ recordDispatchClaim,
26
+ getRecentUnitKeysForWorker,
27
+ } from "../db/unit-dispatches.ts";
28
+ import { setRuntimeKv, getRuntimeKv } from "../db/runtime-kv.ts";
29
+
30
+ function makeBase(): string {
31
+ const base = mkdtempSync(join(tmpdir(), "gsd-stuck-state-db-"));
32
+ mkdirSync(join(base, ".gsd"), { recursive: true });
33
+ return base;
34
+ }
35
+
36
+ function cleanup(base: string): void {
37
+ try { closeDatabase(); } catch { /* noop */ }
38
+ try { rmSync(base, { recursive: true, force: true }); } catch { /* noop */ }
39
+ }
40
+
41
+ test("getRecentUnitKeysForWorker reconstructs the recentUnits sliding window", (t) => {
42
+ const base = makeBase();
43
+ t.after(() => cleanup(base));
44
+ openDatabase(join(base, ".gsd", "gsd.db"));
45
+ insertMilestone({ id: "M001", title: "T", status: "active" });
46
+ const worker = registerAutoWorker({ projectRootRealpath: base });
47
+ const lease = claimMilestoneLease(worker, "M001");
48
+ assert.equal(lease.ok, true);
49
+ if (!lease.ok) return;
50
+
51
+ // Record three dispatches in chronological order. Each must transition
52
+ // out of 'claimed' before the next one with the same unit_id can claim
53
+ // (partial unique index). We use distinct unit IDs so all three coexist.
54
+ const claims: number[] = [];
55
+ for (const id of ["U1", "U2", "U3"]) {
56
+ const c = recordDispatchClaim({
57
+ traceId: id, workerId: worker, milestoneLeaseToken: lease.token,
58
+ milestoneId: "M001", unitType: "plan-slice", unitId: id,
59
+ });
60
+ assert.equal(c.ok, true);
61
+ if (c.ok) claims.push(c.dispatchId);
62
+ }
63
+
64
+ // The loader should return them oldest-first to match the in-memory
65
+ // window semantics that detect-stuck.ts expects.
66
+ const window = getRecentUnitKeysForWorker(worker, 20);
67
+ assert.deepEqual(window.map(w => w.key), ["U1", "U2", "U3"]);
68
+ });
69
+
70
+ test("getRecentUnitKeysForWorker honors the limit parameter", (t) => {
71
+ const base = makeBase();
72
+ t.after(() => cleanup(base));
73
+ openDatabase(join(base, ".gsd", "gsd.db"));
74
+ insertMilestone({ id: "M001", title: "T", status: "active" });
75
+ const worker = registerAutoWorker({ projectRootRealpath: base });
76
+ const lease = claimMilestoneLease(worker, "M001");
77
+ assert.equal(lease.ok, true);
78
+ if (!lease.ok) return;
79
+
80
+ for (let i = 0; i < 25; i++) {
81
+ const c = recordDispatchClaim({
82
+ traceId: `t${i}`, workerId: worker, milestoneLeaseToken: lease.token,
83
+ milestoneId: "M001", unitType: "plan-slice", unitId: `U${i}`,
84
+ });
85
+ assert.equal(c.ok, true);
86
+ }
87
+
88
+ const win20 = getRecentUnitKeysForWorker(worker, 20);
89
+ assert.equal(win20.length, 20);
90
+ // Most recent 20 are U5..U24 (chronological), oldest-first → U5..U24.
91
+ assert.equal(win20[0].key, "U5");
92
+ assert.equal(win20[19].key, "U24");
93
+ });
94
+
95
+ test("stuckRecoveryAttempts round-trips via runtime_kv (stable project scope)", (t) => {
96
+ const base = makeBase();
97
+ t.after(() => cleanup(base));
98
+ openDatabase(join(base, ".gsd", "gsd.db"));
99
+ registerAutoWorker({ projectRootRealpath: base });
100
+
101
+ setRuntimeKv("global", base, "stuck_recovery_attempts", 3);
102
+ assert.equal(getRuntimeKv<number>("global", base, "stuck_recovery_attempts"), 3);
103
+ setRuntimeKv("global", base, "stuck_recovery_attempts", 7);
104
+ assert.equal(getRuntimeKv<number>("global", base, "stuck_recovery_attempts"), 7);
105
+ });
106
+
107
+ test("getRecentUnitKeysForWorker filters by worker_id (no cross-worker bleed)", (t) => {
108
+ const base = makeBase();
109
+ t.after(() => cleanup(base));
110
+ openDatabase(join(base, ".gsd", "gsd.db"));
111
+ insertMilestone({ id: "M001", title: "T", status: "active" });
112
+ insertMilestone({ id: "M002", title: "U", status: "active" });
113
+ const w1 = registerAutoWorker({ projectRootRealpath: base });
114
+ const w2 = registerAutoWorker({ projectRootRealpath: base });
115
+ const lease1 = claimMilestoneLease(w1, "M001");
116
+ const lease2 = claimMilestoneLease(w2, "M002");
117
+ assert.equal(lease1.ok, true);
118
+ assert.equal(lease2.ok, true);
119
+ if (!lease1.ok || !lease2.ok) return;
120
+
121
+ recordDispatchClaim({
122
+ traceId: "ta", workerId: w1, milestoneLeaseToken: lease1.token,
123
+ milestoneId: "M001", unitType: "plan-slice", unitId: "for-w1",
124
+ });
125
+ recordDispatchClaim({
126
+ traceId: "tb", workerId: w2, milestoneLeaseToken: lease2.token,
127
+ milestoneId: "M002", unitType: "plan-slice", unitId: "for-w2",
128
+ });
129
+
130
+ const w1Window = getRecentUnitKeysForWorker(w1, 20);
131
+ const w2Window = getRecentUnitKeysForWorker(w2, 20);
132
+ assert.deepEqual(w1Window.map(w => w.key), ["for-w1"]);
133
+ assert.deepEqual(w2Window.map(w => w.key), ["for-w2"]);
134
+ });
@@ -21,8 +21,10 @@ import {
21
21
  syncStateToProjectRootByScope,
22
22
  syncGsdStateToWorktree,
23
23
  syncGsdStateToWorktreeByScope,
24
- reconcilePlanCheckboxesByScope,
25
24
  } from "../auto-worktree.ts";
25
+ // Phase C: reconcilePlanCheckboxesByScope was deleted along with the
26
+ // underlying reconcilePlanCheckboxes (auto-worktree.ts). Worktrees no
27
+ // longer maintain a parallel .gsd/ projection that needs reconciliation.
26
28
 
27
29
  // ─── Helpers ────────────────────────────────────────────────────────────────
28
30
 
@@ -124,19 +126,8 @@ describe("ByScope variants: mismatched-workspace identity assertion", () => {
124
126
  );
125
127
  });
126
128
 
127
- test("reconcilePlanCheckboxesByScope throws when identityKeys differ", () => {
128
- mkdirSync(join(tmpA, ".gsd"), { recursive: true });
129
- mkdirSync(join(tmpB, ".gsd"), { recursive: true });
130
- const wsA = createWorkspace(tmpA);
131
- const wsB = createWorkspace(tmpB);
132
- const scopeA = scopeMilestone(wsA, MID);
133
- const scopeB = scopeMilestone(wsB, MID);
134
-
135
- assert.throws(
136
- () => reconcilePlanCheckboxesByScope(scopeA, scopeB),
137
- /scope identity mismatch/,
138
- );
139
- });
129
+ // Phase C: reconcilePlanCheckboxesByScope identity-mismatch test
130
+ // removed along with the deleted function.
140
131
  });
141
132
 
142
133
  // ─── Suite: same-milestone, same-workspace path identity ────────────────────
@@ -425,18 +416,8 @@ describe("ByScope variants: milestoneId mismatch throws for milestone-aware wrap
425
416
  );
426
417
  });
427
418
 
428
- test("reconcilePlanCheckboxesByScope throws when milestoneIds differ", () => {
429
- const { projectDir, worktreeDir } = makeProjectAndWorktree(tmp);
430
- const rootWs = createWorkspace(projectDir);
431
- const worktreeWs = createWorkspace(worktreeDir);
432
- const rootScope = scopeMilestone(rootWs, "M001-abc123");
433
- const worktreeScope = scopeMilestone(worktreeWs, "M002-def456");
434
-
435
- assert.throws(
436
- () => reconcilePlanCheckboxesByScope(rootScope, worktreeScope),
437
- /milestoneId mismatch/,
438
- );
439
- });
419
+ // Phase C: reconcilePlanCheckboxesByScope milestoneId-mismatch test
420
+ // removed along with the deleted function.
440
421
 
441
422
  test("syncGsdStateToWorktreeByScope does NOT throw when milestoneIds differ (workspace-only wrapper)", () => {
442
423
  const { projectDir, worktreeDir } = makeProjectAndWorktree(tmp);
@@ -53,24 +53,21 @@ describe("teardownAutoWorktree cleanup parity", () => {
53
53
  _resetAutoWorktreeOriginalBaseForTests();
54
54
  });
55
55
 
56
- test("STATE.md, auto.lock, and M001-META.json are removed after abort teardown", () => {
56
+ test("STATE.md and M001-META.json are removed after abort teardown", () => {
57
+ // Phase C pt 2: auto.lock no longer exists as a file — it migrated
58
+ // to the workers + unit_dispatches tables. clearProjectRootStateFiles
59
+ // still removes STATE.md and {MID}-META.json on teardown.
57
60
  const gsdDir = join(repoDir, ".gsd");
58
61
  const milestonesDir = join(gsdDir, "milestones", "M001");
59
62
  mkdirSync(milestonesDir, { recursive: true });
60
63
 
61
64
  const stateMd = join(gsdDir, "STATE.md");
62
- const autoLock = join(gsdDir, "auto.lock");
63
65
  const metaJson = join(milestonesDir, "M001-META.json");
64
66
 
65
67
  writeFileSync(stateMd, "# State\nactive\n");
66
- writeFileSync(
67
- autoLock,
68
- JSON.stringify({ pid: process.pid, unitType: "plan-milestone", unitId: "M001" }),
69
- );
70
68
  writeFileSync(metaJson, JSON.stringify({ milestoneId: "M001" }));
71
69
 
72
70
  assert.ok(existsSync(stateMd), "STATE.md exists before teardown");
73
- assert.ok(existsSync(autoLock), "auto.lock exists before teardown");
74
71
  assert.ok(existsSync(metaJson), "M001-META.json exists before teardown");
75
72
 
76
73
  // teardownAutoWorktree may throw when git worktree removal fails
@@ -83,7 +80,6 @@ describe("teardownAutoWorktree cleanup parity", () => {
83
80
  }
84
81
 
85
82
  assert.ok(!existsSync(stateMd), "STATE.md removed by teardownAutoWorktree");
86
- assert.ok(!existsSync(autoLock), "auto.lock removed by teardownAutoWorktree");
87
83
  assert.ok(!existsSync(metaJson), "M001-META.json removed by teardownAutoWorktree");
88
84
  });
89
85
 
@@ -0,0 +1,247 @@
1
+ // gsd-2 + Unit dispatch ledger tests (Phase B coordination — partial unique index, retry metadata)
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
+ insertSlice,
14
+ } from "../gsd-db.ts";
15
+ import { registerAutoWorker } from "../db/auto-workers.ts";
16
+ import { claimMilestoneLease } from "../db/milestone-leases.ts";
17
+ import {
18
+ recordDispatchClaim,
19
+ markRunning,
20
+ markCompleted,
21
+ markFailed,
22
+ markStuck,
23
+ markCanceled,
24
+ getRecentForUnit,
25
+ getLatestForUnit,
26
+ } from "../db/unit-dispatches.ts";
27
+
28
+ function makeBase(): string {
29
+ const base = mkdtempSync(join(tmpdir(), "gsd-dispatches-"));
30
+ mkdirSync(join(base, ".gsd"), { recursive: true });
31
+ return base;
32
+ }
33
+
34
+ function cleanup(base: string): void {
35
+ try { closeDatabase(); } catch { /* noop */ }
36
+ try { rmSync(base, { recursive: true, force: true }); } catch { /* noop */ }
37
+ }
38
+
39
+ function setup(base: string): { workerId: string; leaseToken: number } {
40
+ openDatabase(join(base, ".gsd", "gsd.db"));
41
+ insertMilestone({ id: "M001", title: "Test", status: "active" });
42
+ insertSlice({ id: "S01", milestoneId: "M001", title: "Slice" });
43
+ const workerId = registerAutoWorker({ projectRootRealpath: base });
44
+ const lease = claimMilestoneLease(workerId, "M001");
45
+ assert.equal(lease.ok, true);
46
+ if (!lease.ok) throw new Error("expected test lease");
47
+ return { workerId, leaseToken: lease.token };
48
+ }
49
+
50
+ test("recordDispatchClaim creates a claimed row", (t) => {
51
+ const base = makeBase();
52
+ t.after(() => cleanup(base));
53
+ const { workerId, leaseToken } = setup(base);
54
+
55
+ const claim = recordDispatchClaim({
56
+ traceId: "trace-1",
57
+ turnId: "turn-1",
58
+ workerId,
59
+ milestoneLeaseToken: leaseToken,
60
+ milestoneId: "M001",
61
+ sliceId: "S01",
62
+ unitType: "plan-slice",
63
+ unitId: "M001/S01",
64
+ });
65
+ assert.equal(claim.ok, true);
66
+ if (claim.ok) {
67
+ const row = getLatestForUnit("M001/S01");
68
+ assert.ok(row);
69
+ assert.equal(row!.id, claim.dispatchId);
70
+ assert.equal(row!.status, "claimed");
71
+ assert.equal(row!.worker_id, workerId);
72
+ assert.equal(row!.attempt_n, 1);
73
+ }
74
+ });
75
+
76
+ test("partial unique index rejects double-claim of the same active unit", (t) => {
77
+ const base = makeBase();
78
+ t.after(() => cleanup(base));
79
+ const { workerId, leaseToken } = setup(base);
80
+
81
+ const first = recordDispatchClaim({
82
+ traceId: "t-a",
83
+ workerId,
84
+ milestoneLeaseToken: leaseToken,
85
+ milestoneId: "M001",
86
+ unitType: "plan-slice",
87
+ unitId: "M001/S01",
88
+ });
89
+ assert.equal(first.ok, true);
90
+
91
+ // Second worker tries to claim the same unit while first is still claimed
92
+ const second = recordDispatchClaim({
93
+ traceId: "t-b",
94
+ workerId,
95
+ milestoneLeaseToken: leaseToken,
96
+ milestoneId: "M001",
97
+ unitType: "plan-slice",
98
+ unitId: "M001/S01",
99
+ });
100
+ assert.equal(second.ok, false);
101
+ if (!second.ok) {
102
+ assert.equal(second.error, "already_active");
103
+ assert.equal(second.existingWorker, workerId);
104
+ }
105
+ });
106
+
107
+ test("after markCompleted, a fresh claim for the same unit succeeds", (t) => {
108
+ const base = makeBase();
109
+ t.after(() => cleanup(base));
110
+ const { workerId, leaseToken } = setup(base);
111
+
112
+ const first = recordDispatchClaim({
113
+ traceId: "t-1",
114
+ workerId,
115
+ milestoneLeaseToken: leaseToken,
116
+ milestoneId: "M001",
117
+ unitType: "plan-slice",
118
+ unitId: "M001/S01",
119
+ });
120
+ assert.equal(first.ok, true);
121
+ if (!first.ok) return;
122
+ markRunning(first.dispatchId);
123
+ markCompleted(first.dispatchId);
124
+
125
+ // Re-dispatch
126
+ const second = recordDispatchClaim({
127
+ traceId: "t-2",
128
+ workerId,
129
+ milestoneLeaseToken: leaseToken,
130
+ milestoneId: "M001",
131
+ unitType: "plan-slice",
132
+ unitId: "M001/S01",
133
+ attemptN: 2,
134
+ });
135
+ assert.equal(second.ok, true);
136
+ if (second.ok) {
137
+ const recent = getRecentForUnit("M001/S01", 5);
138
+ assert.equal(recent.length, 2);
139
+ assert.equal(recent[0].status, "claimed");
140
+ assert.equal(recent[0].attempt_n, 2);
141
+ assert.equal(recent[1].status, "completed");
142
+ }
143
+ });
144
+
145
+ test("markFailed records error_summary and retry metadata", (t) => {
146
+ const base = makeBase();
147
+ t.after(() => cleanup(base));
148
+ const { workerId, leaseToken } = setup(base);
149
+
150
+ const claim = recordDispatchClaim({
151
+ traceId: "t-1",
152
+ workerId,
153
+ milestoneLeaseToken: leaseToken,
154
+ milestoneId: "M001",
155
+ unitType: "plan-slice",
156
+ unitId: "M001/S01",
157
+ });
158
+ assert.equal(claim.ok, true);
159
+ if (!claim.ok) return;
160
+ markRunning(claim.dispatchId);
161
+ markFailed(claim.dispatchId, {
162
+ errorSummary: "boom",
163
+ errorCode: "test-fail",
164
+ retryAfterMs: 5000,
165
+ });
166
+
167
+ const row = getLatestForUnit("M001/S01")!;
168
+ assert.equal(row.status, "failed");
169
+ assert.equal(row.error_summary, "boom");
170
+ assert.equal(row.last_error_code, "test-fail");
171
+ assert.equal(row.retry_after_ms, 5000);
172
+ assert.ok(row.next_run_at, "next_run_at scheduled");
173
+ });
174
+
175
+ test("markStuck and markCanceled set their respective statuses", (t) => {
176
+ const base = makeBase();
177
+ t.after(() => cleanup(base));
178
+ const { workerId, leaseToken } = setup(base);
179
+
180
+ const a = recordDispatchClaim({
181
+ traceId: "ta", workerId, milestoneLeaseToken: leaseToken,
182
+ milestoneId: "M001", unitType: "plan-slice", unitId: "M001/S01",
183
+ });
184
+ assert.equal(a.ok, true);
185
+ if (!a.ok) return;
186
+ markStuck(a.dispatchId, "test-stuck");
187
+ assert.equal(getLatestForUnit("M001/S01")!.status, "stuck");
188
+
189
+ const b = recordDispatchClaim({
190
+ traceId: "tb", workerId, milestoneLeaseToken: leaseToken,
191
+ milestoneId: "M001", unitType: "run-task", unitId: "M001/S01/T01",
192
+ });
193
+ assert.equal(b.ok, true);
194
+ if (!b.ok) return;
195
+ markCanceled(b.dispatchId, "user-cancel");
196
+ assert.equal(getLatestForUnit("M001/S01/T01")!.status, "canceled");
197
+ });
198
+
199
+ test("terminal transitions do not overwrite an already terminal dispatch", (t) => {
200
+ const base = makeBase();
201
+ t.after(() => cleanup(base));
202
+ const { workerId, leaseToken } = setup(base);
203
+
204
+ const claim = recordDispatchClaim({
205
+ traceId: "t-terminal",
206
+ workerId,
207
+ milestoneLeaseToken: leaseToken,
208
+ milestoneId: "M001",
209
+ unitType: "plan-slice",
210
+ unitId: "M001/S09",
211
+ });
212
+ assert.equal(claim.ok, true);
213
+ if (!claim.ok) return;
214
+
215
+ markRunning(claim.dispatchId);
216
+ markCompleted(claim.dispatchId, { exitReason: "done" });
217
+ markFailed(claim.dispatchId, { errorSummary: "late-failure" });
218
+ markStuck(claim.dispatchId, "late-stuck");
219
+
220
+ const row = getLatestForUnit("M001/S09")!;
221
+ assert.equal(row.status, "completed");
222
+ assert.equal(row.exit_reason, "done");
223
+ assert.equal(row.error_summary, null);
224
+ });
225
+
226
+ test("recordDispatchClaim rejects claims for missing leases before insert", (t) => {
227
+ const base = makeBase();
228
+ t.after(() => cleanup(base));
229
+ setup(base);
230
+
231
+ const claim = recordDispatchClaim({
232
+ traceId: "t-stale-lease",
233
+ workerId: "missing-worker",
234
+ milestoneLeaseToken: 1,
235
+ milestoneId: "M001",
236
+ unitType: "plan-slice",
237
+ unitId: "M001/S01",
238
+ });
239
+
240
+ assert.deepEqual(claim, {
241
+ ok: false,
242
+ error: "stale_lease",
243
+ milestoneId: "M001",
244
+ workerId: "missing-worker",
245
+ milestoneLeaseToken: 1,
246
+ });
247
+ });
@@ -5,7 +5,7 @@ import { join } from "node:path";
5
5
  import { tmpdir } from "node:os";
6
6
  import { randomUUID } from "node:crypto";
7
7
 
8
- import { deriveState, isValidationTerminal } from "../state.ts";
8
+ import { deriveState, invalidateStateCache, isValidationTerminal } from "../state.ts";
9
9
  import { resolveExpectedArtifactPath, diagnoseExpectedArtifact } from "../auto-artifact-paths.ts";
10
10
  import { verifyExpectedArtifact, buildLoopRemediationSteps } from "../auto-recovery.ts";
11
11
  import { resolveDispatch, type DispatchContext } from "../auto-dispatch.ts";
@@ -24,6 +24,7 @@ function makeTmpBase(): string {
24
24
  }
25
25
 
26
26
  function cleanup(base: string): void {
27
+ invalidateStateCache();
27
28
  clearPathCache();
28
29
  clearParseCache();
29
30
  closeDatabase();
@@ -394,6 +395,45 @@ test("dispatch rule skips when skip_milestone_validation preference is set", asy
394
395
  }
395
396
  });
396
397
 
398
+ test("skip write immediately advances deriveState out of validating-milestone", async () => {
399
+ const base = makeTmpBase();
400
+ try {
401
+ openTestDb(base);
402
+ insertMilestone({ id: "M001", title: "Test", status: "active" } as any);
403
+ insertSlice({ id: "S01", milestoneId: "M001", title: "Slice 1", status: "complete" } as any);
404
+
405
+ writeContext(base, "M001");
406
+ writeRoadmap(base, "M001", ALL_DONE_ROADMAP);
407
+ writeSliceSummary(base, "M001", "S01", "# S01 Summary\nDone.");
408
+
409
+ invalidateStateCache();
410
+ clearPathCache();
411
+ clearParseCache();
412
+
413
+ const before = await deriveState(base);
414
+ assert.equal(before.phase, "validating-milestone", "precondition: missing VALIDATION keeps phase in validation");
415
+
416
+ const ctx: DispatchContext = {
417
+ basePath: base,
418
+ mid: "M001",
419
+ midTitle: "Test",
420
+ state: before,
421
+ prefs: { phases: { skip_milestone_validation: true } },
422
+ };
423
+ const result = await resolveDispatch(ctx);
424
+ assert.equal(result.action, "skip");
425
+
426
+ const after = await deriveState(base);
427
+ assert.equal(
428
+ after.phase,
429
+ "completing-milestone",
430
+ "post-skip deriveState should see the new VALIDATION file without manual cache invalidation",
431
+ );
432
+ } finally {
433
+ cleanup(base);
434
+ }
435
+ });
436
+
397
437
  test("dispatch rule ignores failure-path SUMMARY projection when DB milestone is not complete (#4658 superseded)", async () => {
398
438
  const state: GSDState = {
399
439
  activeMilestone: { id: "M001", title: "Test" },
@@ -60,11 +60,12 @@ describe("createWorkspace", () => {
60
60
  });
61
61
 
62
62
  test("follows symlinks — identityKey matches realpath of target", (t) => {
63
- const linkPath = join(tmpdir(), `gsd-ws-link-${Date.now()}`);
64
- symlinkSync(projectDir, linkPath);
63
+ const linkParent = mkdtempSync(join(tmpdir(), "gsd-ws-link-"));
64
+ const linkPath = join(linkParent, "project");
65
65
  t.after(() => {
66
- try { rmSync(linkPath); } catch { /* ignore */ }
66
+ rmSync(linkParent, { recursive: true, force: true });
67
67
  });
68
+ symlinkSync(projectDir, linkPath, "junction");
68
69
 
69
70
  const ws = createWorkspace(linkPath);
70
71
  assert.equal(ws.identityKey, realpathSync(projectDir));
@@ -146,18 +147,23 @@ describe("scopeMilestone path methods", () => {
146
147
  });
147
148
 
148
149
  describe("createWorkspace: contract.projectGsd is realpath-canonicalized when basePath is a symlink", () => {
149
- let projectDir: string;
150
- let linkPath: string;
150
+ let projectDir = "";
151
+ let linkParent = "";
152
+ let linkPath = "";
151
153
 
152
154
  beforeEach(() => {
153
155
  projectDir = makeProjectDir();
154
- linkPath = join(tmpdir(), `gsd-ws-symlink-${Date.now()}`);
155
- symlinkSync(projectDir, linkPath);
156
+ linkParent = mkdtempSync(join(tmpdir(), "gsd-ws-symlink-"));
157
+ linkPath = join(linkParent, "project");
158
+ symlinkSync(projectDir, linkPath, "junction");
156
159
  });
157
160
 
158
161
  afterEach(() => {
159
- try { rmSync(linkPath); } catch { /* ignore */ }
160
- rmSync(projectDir, { recursive: true, force: true });
162
+ if (linkParent) rmSync(linkParent, { recursive: true, force: true });
163
+ if (projectDir) rmSync(projectDir, { recursive: true, force: true });
164
+ linkParent = "";
165
+ linkPath = "";
166
+ projectDir = "";
161
167
  });
162
168
 
163
169
  test("contract.projectGsd matches realpath of projectRoot when workspace is created via symlink", () => {
@@ -299,31 +299,39 @@ test('write-gate: deep root PROJECT/REQUIREMENTS final saves require verified ap
299
299
  });
300
300
 
301
301
  test('write-gate: reopening a gate revokes its previous verified approval', () => {
302
- clearDiscussionFlowState(process.cwd());
302
+ const base = join(tmpdir(), `gsd-write-gate-reopen-${randomUUID()}`);
303
+ mkdirSync(base, { recursive: true });
303
304
 
304
- markApprovalGateVerified('depth_verification_project_confirm');
305
- assert.strictEqual(
306
- shouldBlockRootArtifactSaveInSnapshot(
307
- loadWriteGateSnapshot(process.cwd()),
308
- 'PROJECT',
309
- { requireVerifiedApproval: true },
310
- ).block,
311
- false,
312
- 'precondition: verified approval unlocks the final project artifact',
313
- );
314
-
315
- setPendingGate('depth_verification_project_confirm', process.cwd());
316
- clearPendingGate(process.cwd());
305
+ try {
306
+ clearDiscussionFlowState(base);
317
307
 
318
- assert.strictEqual(
319
- shouldBlockRootArtifactSaveInSnapshot(
320
- loadWriteGateSnapshot(process.cwd()),
321
- 'PROJECT',
322
- { requireVerifiedApproval: true },
323
- ).block,
324
- true,
325
- 'a re-asked gate must require a fresh approval',
326
- );
308
+ markApprovalGateVerified('depth_verification_project_confirm', base);
309
+ assert.strictEqual(
310
+ shouldBlockRootArtifactSaveInSnapshot(
311
+ loadWriteGateSnapshot(base),
312
+ 'PROJECT',
313
+ { requireVerifiedApproval: true },
314
+ ).block,
315
+ false,
316
+ 'precondition: verified approval unlocks the final project artifact',
317
+ );
318
+
319
+ setPendingGate('depth_verification_project_confirm', base);
320
+ clearPendingGate(base);
321
+
322
+ assert.strictEqual(
323
+ shouldBlockRootArtifactSaveInSnapshot(
324
+ loadWriteGateSnapshot(base),
325
+ 'PROJECT',
326
+ { requireVerifiedApproval: true },
327
+ ).block,
328
+ true,
329
+ 'a re-asked gate must require a fresh approval',
330
+ );
331
+ } finally {
332
+ clearDiscussionFlowState(base);
333
+ rmSync(base, { recursive: true, force: true });
334
+ }
327
335
  });
328
336
 
329
337
  // ═══════════════════════════════════════════════════════════════════════