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
@@ -182,7 +182,7 @@ function openRawDb(path: string): unknown {
182
182
  return new Database(path);
183
183
  }
184
184
 
185
- export const SCHEMA_VERSION = 23;
185
+ export const SCHEMA_VERSION = 25;
186
186
 
187
187
  function indexExists(db: DbAdapter, name: string): boolean {
188
188
  return !!db.prepare(
@@ -190,6 +190,137 @@ function indexExists(db: DbAdapter, name: string): boolean {
190
190
  ).get(name);
191
191
  }
192
192
 
193
+ /**
194
+ * Create the v24 coordination tables (workers, milestone_leases,
195
+ * unit_dispatches, cancellation_requests, command_queue) and their indexes.
196
+ *
197
+ * Idempotent — uses IF NOT EXISTS throughout. Called from both the
198
+ * fresh-install path and the v24 migration block in migrateSchema().
199
+ *
200
+ * Single-host invariant: these tables coordinate concurrent auto-mode
201
+ * workers via shared SQLite WAL on local disk only. NFS / network
202
+ * filesystems break the coordination semantics — multi-host execution
203
+ * needs a real coordinator (etcd, Postgres) and is out of scope.
204
+ */
205
+ function createCoordinationTablesV24(db: DbAdapter): void {
206
+ const ddl = [
207
+ `CREATE TABLE IF NOT EXISTS workers (
208
+ worker_id TEXT PRIMARY KEY,
209
+ host TEXT NOT NULL,
210
+ pid INTEGER NOT NULL,
211
+ started_at TEXT NOT NULL,
212
+ version TEXT NOT NULL,
213
+ last_heartbeat_at TEXT NOT NULL,
214
+ status TEXT NOT NULL,
215
+ project_root_realpath TEXT NOT NULL
216
+ )`,
217
+ `CREATE TABLE IF NOT EXISTS milestone_leases (
218
+ milestone_id TEXT PRIMARY KEY,
219
+ worker_id TEXT NOT NULL,
220
+ fencing_token INTEGER NOT NULL,
221
+ acquired_at TEXT NOT NULL,
222
+ expires_at TEXT NOT NULL,
223
+ status TEXT NOT NULL,
224
+ FOREIGN KEY (worker_id) REFERENCES workers(worker_id),
225
+ FOREIGN KEY (milestone_id) REFERENCES milestones(id)
226
+ )`,
227
+ `CREATE TABLE IF NOT EXISTS unit_dispatches (
228
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
229
+ trace_id TEXT NOT NULL,
230
+ turn_id TEXT,
231
+ worker_id TEXT NOT NULL,
232
+ milestone_lease_token INTEGER NOT NULL,
233
+ milestone_id TEXT NOT NULL,
234
+ slice_id TEXT,
235
+ task_id TEXT,
236
+ unit_type TEXT NOT NULL,
237
+ unit_id TEXT NOT NULL,
238
+ status TEXT NOT NULL,
239
+ attempt_n INTEGER NOT NULL DEFAULT 1,
240
+ started_at TEXT NOT NULL,
241
+ ended_at TEXT,
242
+ exit_reason TEXT,
243
+ error_summary TEXT,
244
+ verification_evidence_id INTEGER,
245
+ next_run_at TEXT,
246
+ retry_after_ms INTEGER,
247
+ max_attempts INTEGER NOT NULL DEFAULT 3,
248
+ last_error_code TEXT,
249
+ last_error_at TEXT,
250
+ FOREIGN KEY (worker_id) REFERENCES workers(worker_id),
251
+ FOREIGN KEY (verification_evidence_id) REFERENCES verification_evidence(id)
252
+ )`,
253
+ `CREATE TABLE IF NOT EXISTS cancellation_requests (
254
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
255
+ requested_at TEXT NOT NULL,
256
+ requested_by TEXT NOT NULL,
257
+ scope TEXT NOT NULL,
258
+ scope_id TEXT NOT NULL,
259
+ dispatch_id INTEGER,
260
+ reason TEXT NOT NULL,
261
+ status TEXT NOT NULL,
262
+ acked_at TEXT,
263
+ acked_worker_id TEXT,
264
+ FOREIGN KEY (dispatch_id) REFERENCES unit_dispatches(id),
265
+ FOREIGN KEY (acked_worker_id) REFERENCES workers(worker_id)
266
+ )`,
267
+ `CREATE TABLE IF NOT EXISTS command_queue (
268
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
269
+ target_worker TEXT,
270
+ command TEXT NOT NULL,
271
+ args_json TEXT NOT NULL DEFAULT '{}',
272
+ enqueued_at TEXT NOT NULL,
273
+ claimed_at TEXT,
274
+ claimed_by TEXT,
275
+ completed_at TEXT,
276
+ result_json TEXT
277
+ )`,
278
+ ];
279
+ for (const stmt of ddl) db.exec(stmt);
280
+
281
+ // Indexes — created here so both fresh-install and v24-migration paths
282
+ // produce identical structure.
283
+ db.exec("CREATE INDEX IF NOT EXISTS idx_unit_dispatches_active ON unit_dispatches(milestone_id, status)");
284
+ db.exec("CREATE INDEX IF NOT EXISTS idx_unit_dispatches_trace ON unit_dispatches(trace_id, turn_id)");
285
+ // Partial unique index — prevents two workers from claiming the same
286
+ // unit concurrently. Codex review MEDIUM B2: enforces double-claim guard
287
+ // at the DB level.
288
+ db.exec(
289
+ "CREATE UNIQUE INDEX IF NOT EXISTS idx_unit_dispatches_active_per_unit "
290
+ + "ON unit_dispatches(unit_id) WHERE status IN ('claimed','running')",
291
+ );
292
+ // command_queue index — SQLite indexes NULLs in B-trees, so this single
293
+ // index serves both targeted (target_worker = ?) and broadcast
294
+ // (target_worker IS NULL) queries. Codex review LOW B4 documented.
295
+ db.exec("CREATE INDEX IF NOT EXISTS idx_command_queue_pending ON command_queue(target_worker, claimed_at)");
296
+ }
297
+
298
+ /**
299
+ * Create the v25 runtime_kv table. Idempotent — uses IF NOT EXISTS.
300
+ *
301
+ * STRICT INVARIANT: runtime_kv is NON-CORRECTNESS-CRITICAL. UI cursors,
302
+ * dashboard caches, last-seen-version markers, resume cursors, and other
303
+ * "soft" state are OK. Anything that drives auto-mode control flow gets
304
+ * typed columns in unit_dispatches / workers / milestone_leases — never
305
+ * a bag of JSON in runtime_kv.
306
+ *
307
+ * Scope partitioning: ('global', '', key) for project-wide values;
308
+ * ('worker', worker_id, key) for per-worker state (resume cursors);
309
+ * ('milestone', milestone_id, key) for per-milestone soft state.
310
+ */
311
+ function createRuntimeKvTableV25(db: DbAdapter): void {
312
+ db.exec(`
313
+ CREATE TABLE IF NOT EXISTS runtime_kv (
314
+ scope TEXT NOT NULL,
315
+ scope_id TEXT NOT NULL DEFAULT '',
316
+ key TEXT NOT NULL,
317
+ value_json TEXT NOT NULL,
318
+ updated_at TEXT NOT NULL,
319
+ PRIMARY KEY (scope, scope_id, key)
320
+ )
321
+ `);
322
+ }
323
+
193
324
  function dedupeVerificationEvidenceRows(db: DbAdapter): void {
194
325
  db.exec(`
195
326
  DELETE FROM verification_evidence
@@ -586,6 +717,9 @@ function initSchema(db: DbAdapter, fileBacked: boolean): void {
586
717
 
587
718
  const existing = db.prepare("SELECT count(*) as cnt FROM schema_version").get();
588
719
  if (existing && (existing["cnt"] as number) === 0) {
720
+ createCoordinationTablesV24(db);
721
+ createRuntimeKvTableV25(db);
722
+
589
723
  // Fresh install — all tables are created above with the full current schema,
590
724
  // so it is safe to create all migration-specific indexes here. For existing
591
725
  // databases these indexes are created inside the individual migration guards
@@ -1246,6 +1380,28 @@ function migrateSchema(db: DbAdapter): void {
1246
1380
  });
1247
1381
  }
1248
1382
 
1383
+ if (currentVersion < 24) {
1384
+ // v24: auto-mode coordination tables. See createCoordinationTablesV24
1385
+ // for full schema + invariants. No-op for fresh installs (the same
1386
+ // helper runs in the fresh-install path); for upgraded DBs this is
1387
+ // the only place these tables get created.
1388
+ createCoordinationTablesV24(db);
1389
+ db.prepare("INSERT INTO schema_version (version, applied_at) VALUES (:version, :applied_at)").run({
1390
+ ":version": 24,
1391
+ ":applied_at": new Date().toISOString(),
1392
+ });
1393
+ }
1394
+
1395
+ if (currentVersion < 25) {
1396
+ // v25: runtime_kv non-correctness-critical key-value storage. See
1397
+ // createRuntimeKvTableV25 for the full schema + invariants.
1398
+ createRuntimeKvTableV25(db);
1399
+ db.prepare("INSERT INTO schema_version (version, applied_at) VALUES (:version, :applied_at)").run({
1400
+ ":version": 25,
1401
+ ":applied_at": new Date().toISOString(),
1402
+ });
1403
+ }
1404
+
1249
1405
  db.exec("COMMIT");
1250
1406
  } catch (err) {
1251
1407
  db.exec("ROLLBACK");
@@ -3301,6 +3457,9 @@ export function deleteMilestone(milestoneId: string): void {
3301
3457
  currentDb!.prepare(
3302
3458
  `DELETE FROM artifacts WHERE milestone_id = :mid`,
3303
3459
  ).run({ ":mid": milestoneId });
3460
+ currentDb!.prepare(
3461
+ `DELETE FROM milestone_leases WHERE milestone_id = :mid`,
3462
+ ).run({ ":mid": milestoneId });
3304
3463
  currentDb!.prepare(
3305
3464
  `DELETE FROM milestones WHERE id = :mid`,
3306
3465
  ).run({ ":mid": milestoneId });
@@ -3723,14 +3882,20 @@ export function deleteArtifactByPath(path: string): void {
3723
3882
  }
3724
3883
 
3725
3884
  /**
3726
- * Drop all rows from tasks/slices/milestones in dependency order inside a
3727
- * transaction. Used by `gsd recover` to rebuild engine state from markdown.
3885
+ * Drop hierarchy rows in dependency order inside a transaction. Used by
3886
+ * `gsd recover` to rebuild engine state from markdown.
3728
3887
  */
3729
3888
  export function clearEngineHierarchy(): void {
3730
3889
  if (!currentDb) throw new GSDError(GSD_STALE_STATE, "gsd-db: No database open");
3731
3890
  transaction(() => {
3891
+ currentDb!.exec("DELETE FROM verification_evidence");
3892
+ currentDb!.exec("DELETE FROM quality_gates");
3893
+ currentDb!.exec("DELETE FROM slice_dependencies");
3894
+ currentDb!.exec("DELETE FROM assessments");
3895
+ currentDb!.exec("DELETE FROM replan_history");
3732
3896
  currentDb!.exec("DELETE FROM tasks");
3733
3897
  currentDb!.exec("DELETE FROM slices");
3898
+ currentDb!.exec("DELETE FROM milestone_leases");
3734
3899
  currentDb!.exec("DELETE FROM milestones");
3735
3900
  });
3736
3901
  }
@@ -3844,6 +4009,7 @@ export function restoreManifest(manifest: StateManifest): void {
3844
4009
  db.exec("DELETE FROM verification_evidence");
3845
4010
  db.exec("DELETE FROM tasks");
3846
4011
  db.exec("DELETE FROM slices");
4012
+ db.exec("DELETE FROM milestone_leases");
3847
4013
  db.exec("DELETE FROM milestones");
3848
4014
  db.exec("DELETE FROM decisions WHERE 1=1");
3849
4015
 
@@ -3982,6 +4148,7 @@ export function bulkInsertLegacyHierarchy(payload: {
3982
4148
  transaction(() => {
3983
4149
  db.prepare(`DELETE FROM tasks WHERE milestone_id IN (${placeholders})`).run(...clearMilestoneIds);
3984
4150
  db.prepare(`DELETE FROM slices WHERE milestone_id IN (${placeholders})`).run(...clearMilestoneIds);
4151
+ db.prepare(`DELETE FROM milestone_leases WHERE milestone_id IN (${placeholders})`).run(...clearMilestoneIds);
3985
4152
  db.prepare(`DELETE FROM milestones WHERE id IN (${placeholders})`).run(...clearMilestoneIds);
3986
4153
 
3987
4154
  const insertMilestone = db.prepare(
@@ -83,6 +83,8 @@ export {
83
83
  buildExistingMilestonesContext,
84
84
  } from "./guided-flow-queue.js";
85
85
  import { logWarning } from "./workflow-logger.js";
86
+ import { deleteRuntimeKv } from "./db/runtime-kv.js";
87
+ import { PAUSED_SESSION_KV_KEY } from "./interrupted-session.js";
86
88
 
87
89
  // ─── Scope-based validator wrappers ──────────────────────────────────────────
88
90
  // These thin wrappers accept a MilestoneScope so callers that already hold a
@@ -1963,10 +1965,12 @@ export async function showSmartEntry(
1963
1965
  if (interrupted.classification === "stale") {
1964
1966
  clearLock(basePath);
1965
1967
  if (interrupted.pausedSession) {
1968
+ // Phase C pt 2: paused-session.json migrated to runtime_kv
1969
+ // (global scope, key PAUSED_SESSION_KV_KEY).
1966
1970
  try {
1967
- unlinkSync(join(gsdRoot(basePath), "runtime", "paused-session.json"));
1971
+ deleteRuntimeKv("global", "", PAUSED_SESSION_KV_KEY);
1968
1972
  } catch (e) {
1969
- logWarning("guided", `stale pause file cleanup failed: ${(e as Error).message}`, { file: "guided-flow.ts" });
1973
+ logWarning("guided", `stale paused-session DB cleanup failed: ${(e as Error).message}`, { file: "guided-flow.ts" });
1970
1974
  }
1971
1975
  }
1972
1976
  } else if (interrupted.classification === "recoverable") {
@@ -1,4 +1,4 @@
1
- import { existsSync, readFileSync, unlinkSync } from "node:fs";
1
+ import { existsSync, readFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
 
4
4
  import { verifyExpectedArtifact } from "./auto-recovery.js";
@@ -16,6 +16,7 @@ import {
16
16
  } from "./session-forensics.js";
17
17
  import { deriveState } from "./state.js";
18
18
  import type { GSDState } from "./types.js";
19
+ import { getRuntimeKv, deleteRuntimeKv } from "./db/runtime-kv.js";
19
20
 
20
21
  export type InterruptedSessionClassification =
21
22
  | "none"
@@ -82,22 +83,28 @@ function isStalePseudoMilestonePause(meta: PausedSessionMetadata): boolean {
82
83
  && LEGACY_DEEP_SETUP_UNITS.has(`${meta.unitType}:${meta.unitId}`);
83
84
  }
84
85
 
86
+ /**
87
+ * runtime_kv key (global scope) that stores the most recent paused-session
88
+ * metadata. Phase C pt 2: replaces runtime/paused-session.json. The key is
89
+ * project-wide (not worker-scoped) because the paused state represents the
90
+ * last time auto-mode paused on this project — there is at most one paused
91
+ * session per project at a time.
92
+ */
93
+ export const PAUSED_SESSION_KV_KEY = "paused_session";
94
+
85
95
  export function readPausedSessionMetadata(
86
96
  basePath: string,
87
97
  ): PausedSessionMetadata | null {
88
- const pausedPath = join(gsdRoot(basePath), "runtime", "paused-session.json");
89
- if (!existsSync(pausedPath)) return null;
90
-
91
- try {
92
- const meta = JSON.parse(readFileSync(pausedPath, "utf-8")) as PausedSessionMetadata;
93
- if (isStalePseudoMilestonePause(meta)) {
94
- try { unlinkSync(pausedPath); } catch { /* non-fatal */ }
95
- return null;
96
- }
97
- return meta;
98
- } catch {
98
+ // basePath is unused now (the DB is workspace-scoped via the connection
99
+ // openDatabase opened on it) but kept in the signature for callers.
100
+ void basePath;
101
+ const meta = getRuntimeKv<PausedSessionMetadata>("global", "", PAUSED_SESSION_KV_KEY);
102
+ if (!meta) return null;
103
+ if (isStalePseudoMilestonePause(meta)) {
104
+ deleteRuntimeKv("global", "", PAUSED_SESSION_KV_KEY);
99
105
  return null;
100
106
  }
107
+ return meta;
101
108
  }
102
109
 
103
110
  export function isBootstrapCrashLock(lock: LockData | null): boolean {
@@ -270,6 +270,21 @@ export async function getActiveMilestoneId(basePath: string): Promise<string | n
270
270
  return null;
271
271
  }
272
272
 
273
+ /**
274
+ * Options for deriveState read-path routing.
275
+ *
276
+ * `projectRootForReads`: canonical project root (e.g. from
277
+ * `s.canonicalProjectRoot`) used for both the cache key and the artifact-read
278
+ * root in `_deriveStateImpl`. When omitted, behavior is identical to the
279
+ * single-arg signature (back-compat for all existing callers).
280
+ *
281
+ * Typed as an object literal (not `string | DeriveStateOptions`) so accidental
282
+ * `deriveState(path, "string")` is rejected at compile time.
283
+ */
284
+ export interface DeriveStateOptions {
285
+ projectRootForReads?: string;
286
+ }
287
+
273
288
  /**
274
289
  * Reconstruct GSD state from the authoritative DB.
275
290
  * STATE.md is a rendered cache of this output.
@@ -278,11 +293,22 @@ export async function getActiveMilestoneId(basePath: string): Promise<string | n
278
293
  * Legacy filesystem parsing is available only through an explicit opt-in for
279
294
  * tests/recovery flows; runtime must not silently infer state from markdown.
280
295
  */
281
- export async function deriveState(basePath: string): Promise<GSDState> {
282
- // Return cached result if within the TTL window for the same basePath
296
+ export async function deriveState(
297
+ basePath: string,
298
+ opts?: DeriveStateOptions,
299
+ ): Promise<GSDState> {
300
+ // Use the canonical project root (when provided) as the cache key so that
301
+ // two calls with different basePath strings (e.g. worktree path vs project
302
+ // root) but the same canonical .gsd/ share a single cache entry. The same
303
+ // key is used for both the lookup AND the write below — keying lookup on
304
+ // canonical-root while writing on basePath would silently return stale
305
+ // results across path-form alternation.
306
+ const cacheKey = opts?.projectRootForReads ?? basePath;
307
+
308
+ // Return cached result if within the TTL window for the same cacheKey
283
309
  if (
284
310
  _stateCache &&
285
- _stateCache.basePath === basePath &&
311
+ _stateCache.basePath === cacheKey &&
286
312
  Date.now() - _stateCache.timestamp < CACHE_TTL_MS
287
313
  ) {
288
314
  return _stateCache.result;
@@ -303,7 +329,7 @@ export async function deriveState(basePath: string): Promise<GSDState> {
303
329
  if (wasDbOpenAttempted()) {
304
330
  logWarning("state", "DB unavailable — using explicit legacy filesystem state derivation");
305
331
  }
306
- result = await _deriveStateImpl(basePath);
332
+ result = await _deriveStateImpl(basePath, opts);
307
333
  _telemetry.markdownDeriveCount++;
308
334
  } else {
309
335
  if (wasDbOpenAttempted()) {
@@ -325,7 +351,7 @@ export async function deriveState(basePath: string): Promise<GSDState> {
325
351
 
326
352
  stopTimer({ phase: result.phase, milestone: result.activeMilestone?.id });
327
353
  debugCount("deriveStateCalls");
328
- _stateCache = { basePath, result, timestamp: Date.now() };
354
+ _stateCache = { basePath: cacheKey, result, timestamp: Date.now() };
329
355
  return result;
330
356
  }
331
357
 
@@ -838,7 +864,19 @@ export async function deriveStateFromDb(basePath: string): Promise<GSDState> {
838
864
  // LEGACY: Filesystem-based state derivation for unmigrated projects.
839
865
  // DB-backed projects use deriveStateFromDb() above. Target: extract to
840
866
  // state-legacy.ts when all projects are DB-backed.
841
- export async function _deriveStateImpl(basePath: string): Promise<GSDState> {
867
+ export async function _deriveStateImpl(
868
+ basePath: string,
869
+ opts?: DeriveStateOptions,
870
+ ): Promise<GSDState> {
871
+ // When the caller supplies a canonical project root for reads (e.g.
872
+ // s.canonicalProjectRoot from auto-mode), route all artifact reads through
873
+ // it. This prevents the worktree-local empty `.gsd/` from being consulted
874
+ // when the canonical state lives at the project root (or via a `.gsd`
875
+ // symlink into the external state dir).
876
+ if (opts?.projectRootForReads) {
877
+ basePath = opts.projectRootForReads;
878
+ }
879
+
842
880
  const diskIds = findMilestoneIds(basePath);
843
881
  const customOrder = loadQueueOrder(basePath);
844
882
  const milestoneIds = sortByQueueOrder(diskIds, customOrder);
@@ -0,0 +1,72 @@
1
+ // gsd-2 + Phase C deletion regression: createAutoWorktree no longer copies .gsd/
2
+ //
3
+ // Verifies that createAutoWorktree on a project with a real (non-symlinked)
4
+ // .gsd/ does NOT populate .gsd/milestones/ inside the worktree. Pre-Phase-C,
5
+ // copyPlanningArtifacts would mirror the project-root .gsd/ into the
6
+ // worktree-local .gsd/. Phase C deleted that helper because writers in
7
+ // auto-mode now route through s.canonicalProjectRoot, so the worktree never
8
+ // needs a parallel .gsd/ projection.
9
+
10
+ import test from "node:test";
11
+ import assert from "node:assert/strict";
12
+ import { mkdtempSync, mkdirSync, writeFileSync, existsSync, realpathSync, rmSync } from "node:fs";
13
+ import { join } from "node:path";
14
+ import { tmpdir } from "node:os";
15
+ import { execFileSync } from "node:child_process";
16
+
17
+ import { createAutoWorktree, teardownAutoWorktree } from "../auto-worktree.ts";
18
+
19
+ function git(args: string[], cwd: string): void {
20
+ execFileSync("git", args, { cwd, stdio: ["ignore", "pipe", "pipe"] });
21
+ }
22
+
23
+ test("createAutoWorktree does NOT copy project-root .gsd/milestones into the worktree", (t) => {
24
+ const base = realpathSync(mkdtempSync(join(tmpdir(), "gsd-no-copy-")));
25
+
26
+ // Initialize a real git repo with a real .gsd/ directory containing some
27
+ // planning artifacts that the deleted copyPlanningArtifacts would have
28
+ // mirrored.
29
+ git(["init", "-b", "main"], base);
30
+ git(["config", "user.name", "Pi Test"], base);
31
+ git(["config", "user.email", "pi@example.com"], base);
32
+ writeFileSync(join(base, "README.md"), "# Test\n", "utf-8");
33
+ git(["add", "README.md"], base);
34
+ git(["commit", "-m", "chore: init"], base);
35
+
36
+ mkdirSync(join(base, ".gsd", "milestones", "M001"), { recursive: true });
37
+ writeFileSync(
38
+ join(base, ".gsd", "milestones", "M001", "M001-CONTEXT.md"),
39
+ "# M001 Context\n",
40
+ "utf-8",
41
+ );
42
+ writeFileSync(
43
+ join(base, ".gsd", "milestones", "M001", "M001-ROADMAP.md"),
44
+ "# M001 Roadmap\n",
45
+ "utf-8",
46
+ );
47
+
48
+ const wtPath = createAutoWorktree(base, "M001");
49
+ t.after(() => {
50
+ try { teardownAutoWorktree(base, "M001"); } catch { /* noop */ }
51
+ try { rmSync(base, { recursive: true, force: true }); } catch { /* noop */ }
52
+ });
53
+
54
+ // Phase C invariant: the worktree's .gsd/milestones/M001 must NOT exist
55
+ // (no copyPlanningArtifacts), and the project-root version must still be
56
+ // intact (it was the source, not destination).
57
+ assert.equal(
58
+ existsSync(join(wtPath, ".gsd", "milestones", "M001", "M001-CONTEXT.md")),
59
+ false,
60
+ "worktree should NOT have a copy of M001-CONTEXT.md (copyPlanningArtifacts deleted)",
61
+ );
62
+ assert.equal(
63
+ existsSync(join(wtPath, ".gsd", "milestones", "M001", "M001-ROADMAP.md")),
64
+ false,
65
+ "worktree should NOT have a copy of M001-ROADMAP.md",
66
+ );
67
+ assert.equal(
68
+ existsSync(join(base, ".gsd", "milestones", "M001", "M001-CONTEXT.md")),
69
+ true,
70
+ "project-root .gsd/ retains the canonical CONTEXT.md",
71
+ );
72
+ });
@@ -0,0 +1,190 @@
1
+ // gsd-2 + Symlinked .gsd worktree-loop reproduction (Phase A pt 2 follow-up to PR #5236)
2
+ //
3
+ // Regression coverage for the auto-mode loop bug observed on projects whose
4
+ // .gsd/ is a symlink into ~/.gsd/projects/<hash>/ (the external-state layout).
5
+ //
6
+ // Two assertions:
7
+ // 1. deriveState's cache key is the canonical project root when callers
8
+ // opt into projectRootForReads — so two derive calls that should refer
9
+ // to the same canonical state share a single cache entry, regardless of
10
+ // whether the caller passed the worktree path or the project-root path.
11
+ // 2. _deriveStateImpl's projectRootForReads option routes legacy markdown
12
+ // reads through the canonical project root, finding files that live in
13
+ // the symlink target rather than the worktree-local empty .gsd/.
14
+ //
15
+ // Per project rule #11: regression test using node:test + node:assert/strict,
16
+ // no source-grep assertions. The first test would fail on main without the
17
+ // cache-key fix in state.ts (lookup vs write keys would diverge across
18
+ // path-form alternation, producing cache misses). The second test would
19
+ // fail on main because _deriveStateImpl doesn't accept the option at all.
20
+
21
+ import test from "node:test";
22
+ import assert from "node:assert/strict";
23
+ import { mkdtempSync, mkdirSync, rmSync, writeFileSync, realpathSync, symlinkSync } from "node:fs";
24
+ import { join } from "node:path";
25
+ import { tmpdir } from "node:os";
26
+
27
+ import {
28
+ deriveState,
29
+ _deriveStateImpl,
30
+ invalidateStateCache,
31
+ type DeriveStateOptions,
32
+ } from "../state.ts";
33
+ import {
34
+ openDatabase,
35
+ closeDatabase,
36
+ insertMilestone,
37
+ insertSlice,
38
+ insertTask,
39
+ } from "../gsd-db.ts";
40
+
41
+ // ─── Fixture helpers ──────────────────────────────────────────────────────
42
+
43
+ interface SymlinkedFixture {
44
+ /** Project root containing .gsd as a symlink. */
45
+ projectRoot: string;
46
+ /** External state dir that .gsd points at (acts as the canonical .gsd/). */
47
+ externalState: string;
48
+ /** Worktree path under the external state's worktrees/ dir. */
49
+ worktreePath: string;
50
+ }
51
+
52
+ function makeSymlinkedFixture(prefix: string): SymlinkedFixture {
53
+ // Use realpathSync on tmpdir so that subsequent realpath comparisons are stable
54
+ // — macOS /var symlinks to /private/var, which would otherwise pollute the
55
+ // canonical-root assertions below.
56
+ const root = realpathSync(mkdtempSync(join(tmpdir(), `gsd-${prefix}-`)));
57
+ const projectRoot = join(root, "project");
58
+ const externalState = join(root, "external-state", "projects", "abc123");
59
+
60
+ mkdirSync(projectRoot, { recursive: true });
61
+ mkdirSync(externalState, { recursive: true });
62
+
63
+ // .gsd → externalState (the layout that triggered the original bug)
64
+ symlinkSync(externalState, join(projectRoot, ".gsd"), "junction");
65
+
66
+ // Worktree path lives under the external state's worktrees/ dir, mirroring
67
+ // the canonicalProjectRoot resolution that resolveGsdPathContract performs
68
+ // for the external-state layout.
69
+ const worktreePath = join(externalState, "worktrees", "M001");
70
+ mkdirSync(worktreePath, { recursive: true });
71
+
72
+ return { projectRoot, externalState, worktreePath };
73
+ }
74
+
75
+ function cleanupFixture(fx: SymlinkedFixture): void {
76
+ try { closeDatabase(); } catch { /* noop */ }
77
+ // The mkdtemp root is two levels above projectRoot.
78
+ try {
79
+ const root = join(fx.projectRoot, "..");
80
+ rmSync(root, { recursive: true, force: true });
81
+ } catch { /* noop */ }
82
+ }
83
+
84
+ // ═══════════════════════════════════════════════════════════════════════════
85
+ // Test 1: cache-key invariance under projectRootForReads
86
+ // ═══════════════════════════════════════════════════════════════════════════
87
+
88
+ test("deriveState: cache key is canonical when projectRootForReads is supplied", async (t) => {
89
+ const fx = makeSymlinkedFixture("symlink-cache");
90
+ t.after(() => cleanupFixture(fx));
91
+
92
+ // Open the DB at the canonical .gsd location (externalState).
93
+ openDatabase(join(fx.externalState, "gsd.db"));
94
+ insertMilestone({ id: "M001", title: "Symlinked", status: "active" });
95
+ insertSlice({ id: "S01", milestoneId: "M001", title: "Slice" });
96
+ // No tasks → DB-derived state is "planning".
97
+
98
+ invalidateStateCache();
99
+
100
+ const optsCanonical: DeriveStateOptions = { projectRootForReads: fx.projectRoot };
101
+
102
+ // First call: seed the cache through the worktree-path form.
103
+ const stateA = await deriveState(fx.worktreePath, optsCanonical);
104
+ assert.equal(stateA.activeMilestone?.id, "M001");
105
+ assert.equal(stateA.activeSlice?.id, "S01");
106
+ assert.equal(stateA.phase, "planning");
107
+
108
+ // Second call: canonical project-root form must hit the same cache entry.
109
+ const stateB = await deriveState(fx.projectRoot);
110
+ assert.equal(stateB, stateA, "second call with same canonical key must return the cached object");
111
+
112
+ // Third call: worktree-path form with projectRootForReads must also hit the
113
+ // same cache entry, proving the cache key is symmetric across both call
114
+ // orders.
115
+ const stateC = await deriveState(fx.worktreePath, optsCanonical);
116
+ assert.equal(stateC, stateA, "third call with worktree path plus canonical reads must hit the same cache entry");
117
+
118
+ // Mutation invalidates: insert a task, clear cache, re-derive — must
119
+ // observe the new state via the canonical key path.
120
+ insertTask({ id: "T01", sliceId: "S01", milestoneId: "M001", title: "Task", status: "active" });
121
+ invalidateStateCache();
122
+
123
+ const stateD = await deriveState(fx.worktreePath, optsCanonical);
124
+ assert.notEqual(stateD, stateA, "post-mutation derive must re-compute, not reuse the prior cached object");
125
+ assert.equal(stateD.activeTask?.id, "T01", "mutation must surface in the re-derived state");
126
+ assert.equal(stateD.phase, "executing");
127
+ });
128
+
129
+ // ═══════════════════════════════════════════════════════════════════════════
130
+ // Test 2: _deriveStateImpl reads from canonical root via projectRootForReads
131
+ // ═══════════════════════════════════════════════════════════════════════════
132
+
133
+ test("_deriveStateImpl: projectRootForReads routes legacy markdown reads to the canonical .gsd/", async (t) => {
134
+ const fx = makeSymlinkedFixture("symlink-md");
135
+ t.after(() => cleanupFixture(fx));
136
+ // No DB opened — exercise the markdown fallback.
137
+
138
+ // Seed the external state dir (the symlink target) with a roadmap so the
139
+ // legacy filesystem state derivation has a milestone to find.
140
+ const m1Dir = join(fx.externalState, "milestones", "M001");
141
+ mkdirSync(m1Dir, { recursive: true });
142
+ writeFileSync(
143
+ join(m1Dir, "M001-CONTEXT.md"),
144
+ "# M001: Symlinked legacy md test\n\nTest project.\n",
145
+ "utf-8",
146
+ );
147
+ writeFileSync(
148
+ join(m1Dir, "M001-ROADMAP.md"),
149
+ [
150
+ "# M001 Roadmap",
151
+ "",
152
+ "## Slices",
153
+ "",
154
+ "- [ ] **S01: First slice** — depends:",
155
+ "",
156
+ ].join("\n"),
157
+ "utf-8",
158
+ );
159
+
160
+ invalidateStateCache();
161
+
162
+ // Calling _deriveStateImpl with the worktree path AND projectRootForReads
163
+ // pointing at the project root must consult the canonical .gsd/ (via the
164
+ // symlink target externalState), find M001/S01, and report planning phase
165
+ // because no slice plan file exists yet.
166
+ const state = await _deriveStateImpl(fx.worktreePath, { projectRootForReads: fx.projectRoot });
167
+ assert.equal(state.activeMilestone?.id, "M001", "must find M001 via canonical .gsd/ reads");
168
+ assert.equal(state.activeSlice?.id, "S01", "must find S01 from the roadmap");
169
+ assert.equal(state.phase, "planning", "no slice PLAN.md yet → planning phase");
170
+ });
171
+
172
+ // ═══════════════════════════════════════════════════════════════════════════
173
+ // Test 3: type-safety guard for the deriveState opts overload (compile-time)
174
+ // ═══════════════════════════════════════════════════════════════════════════
175
+ //
176
+ // The DeriveStateOptions parameter is typed as an object literal so accidental
177
+ // `deriveState(path, "string")` is a TypeScript compile error. The
178
+ // expect-error directive verifies that this guard is in place — if the
179
+ // overload were widened to `string | DeriveStateOptions`, the directive would
180
+ // trigger TS2578 ("Unused '@ts-expect-error' directive") at build time.
181
+
182
+ test("deriveState: opts param rejects non-object values at compile time", () => {
183
+ // The actual assertion is the TypeScript compile-time check below; the
184
+ // runtime body just confirms the test ran.
185
+ if (false) {
186
+ // @ts-expect-error — projectRootForReads must be a string
187
+ void deriveState("/nonexistent", { projectRootForReads: 123 });
188
+ }
189
+ assert.ok(true);
190
+ });