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
@@ -136,10 +136,137 @@ function openRawDb(path) {
136
136
  const Database = providerModule;
137
137
  return new Database(path);
138
138
  }
139
- export const SCHEMA_VERSION = 23;
139
+ export const SCHEMA_VERSION = 25;
140
140
  function indexExists(db, name) {
141
141
  return !!db.prepare("SELECT 1 as present FROM sqlite_master WHERE type = 'index' AND name = ?").get(name);
142
142
  }
143
+ /**
144
+ * Create the v24 coordination tables (workers, milestone_leases,
145
+ * unit_dispatches, cancellation_requests, command_queue) and their indexes.
146
+ *
147
+ * Idempotent — uses IF NOT EXISTS throughout. Called from both the
148
+ * fresh-install path and the v24 migration block in migrateSchema().
149
+ *
150
+ * Single-host invariant: these tables coordinate concurrent auto-mode
151
+ * workers via shared SQLite WAL on local disk only. NFS / network
152
+ * filesystems break the coordination semantics — multi-host execution
153
+ * needs a real coordinator (etcd, Postgres) and is out of scope.
154
+ */
155
+ function createCoordinationTablesV24(db) {
156
+ const ddl = [
157
+ `CREATE TABLE IF NOT EXISTS workers (
158
+ worker_id TEXT PRIMARY KEY,
159
+ host TEXT NOT NULL,
160
+ pid INTEGER NOT NULL,
161
+ started_at TEXT NOT NULL,
162
+ version TEXT NOT NULL,
163
+ last_heartbeat_at TEXT NOT NULL,
164
+ status TEXT NOT NULL,
165
+ project_root_realpath TEXT NOT NULL
166
+ )`,
167
+ `CREATE TABLE IF NOT EXISTS milestone_leases (
168
+ milestone_id TEXT PRIMARY KEY,
169
+ worker_id TEXT NOT NULL,
170
+ fencing_token INTEGER NOT NULL,
171
+ acquired_at TEXT NOT NULL,
172
+ expires_at TEXT NOT NULL,
173
+ status TEXT NOT NULL,
174
+ FOREIGN KEY (worker_id) REFERENCES workers(worker_id),
175
+ FOREIGN KEY (milestone_id) REFERENCES milestones(id)
176
+ )`,
177
+ `CREATE TABLE IF NOT EXISTS unit_dispatches (
178
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
179
+ trace_id TEXT NOT NULL,
180
+ turn_id TEXT,
181
+ worker_id TEXT NOT NULL,
182
+ milestone_lease_token INTEGER NOT NULL,
183
+ milestone_id TEXT NOT NULL,
184
+ slice_id TEXT,
185
+ task_id TEXT,
186
+ unit_type TEXT NOT NULL,
187
+ unit_id TEXT NOT NULL,
188
+ status TEXT NOT NULL,
189
+ attempt_n INTEGER NOT NULL DEFAULT 1,
190
+ started_at TEXT NOT NULL,
191
+ ended_at TEXT,
192
+ exit_reason TEXT,
193
+ error_summary TEXT,
194
+ verification_evidence_id INTEGER,
195
+ next_run_at TEXT,
196
+ retry_after_ms INTEGER,
197
+ max_attempts INTEGER NOT NULL DEFAULT 3,
198
+ last_error_code TEXT,
199
+ last_error_at TEXT,
200
+ FOREIGN KEY (worker_id) REFERENCES workers(worker_id),
201
+ FOREIGN KEY (verification_evidence_id) REFERENCES verification_evidence(id)
202
+ )`,
203
+ `CREATE TABLE IF NOT EXISTS cancellation_requests (
204
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
205
+ requested_at TEXT NOT NULL,
206
+ requested_by TEXT NOT NULL,
207
+ scope TEXT NOT NULL,
208
+ scope_id TEXT NOT NULL,
209
+ dispatch_id INTEGER,
210
+ reason TEXT NOT NULL,
211
+ status TEXT NOT NULL,
212
+ acked_at TEXT,
213
+ acked_worker_id TEXT,
214
+ FOREIGN KEY (dispatch_id) REFERENCES unit_dispatches(id),
215
+ FOREIGN KEY (acked_worker_id) REFERENCES workers(worker_id)
216
+ )`,
217
+ `CREATE TABLE IF NOT EXISTS command_queue (
218
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
219
+ target_worker TEXT,
220
+ command TEXT NOT NULL,
221
+ args_json TEXT NOT NULL DEFAULT '{}',
222
+ enqueued_at TEXT NOT NULL,
223
+ claimed_at TEXT,
224
+ claimed_by TEXT,
225
+ completed_at TEXT,
226
+ result_json TEXT
227
+ )`,
228
+ ];
229
+ for (const stmt of ddl)
230
+ db.exec(stmt);
231
+ // Indexes — created here so both fresh-install and v24-migration paths
232
+ // produce identical structure.
233
+ db.exec("CREATE INDEX IF NOT EXISTS idx_unit_dispatches_active ON unit_dispatches(milestone_id, status)");
234
+ db.exec("CREATE INDEX IF NOT EXISTS idx_unit_dispatches_trace ON unit_dispatches(trace_id, turn_id)");
235
+ // Partial unique index — prevents two workers from claiming the same
236
+ // unit concurrently. Codex review MEDIUM B2: enforces double-claim guard
237
+ // at the DB level.
238
+ db.exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_unit_dispatches_active_per_unit "
239
+ + "ON unit_dispatches(unit_id) WHERE status IN ('claimed','running')");
240
+ // command_queue index — SQLite indexes NULLs in B-trees, so this single
241
+ // index serves both targeted (target_worker = ?) and broadcast
242
+ // (target_worker IS NULL) queries. Codex review LOW B4 documented.
243
+ db.exec("CREATE INDEX IF NOT EXISTS idx_command_queue_pending ON command_queue(target_worker, claimed_at)");
244
+ }
245
+ /**
246
+ * Create the v25 runtime_kv table. Idempotent — uses IF NOT EXISTS.
247
+ *
248
+ * STRICT INVARIANT: runtime_kv is NON-CORRECTNESS-CRITICAL. UI cursors,
249
+ * dashboard caches, last-seen-version markers, resume cursors, and other
250
+ * "soft" state are OK. Anything that drives auto-mode control flow gets
251
+ * typed columns in unit_dispatches / workers / milestone_leases — never
252
+ * a bag of JSON in runtime_kv.
253
+ *
254
+ * Scope partitioning: ('global', '', key) for project-wide values;
255
+ * ('worker', worker_id, key) for per-worker state (resume cursors);
256
+ * ('milestone', milestone_id, key) for per-milestone soft state.
257
+ */
258
+ function createRuntimeKvTableV25(db) {
259
+ db.exec(`
260
+ CREATE TABLE IF NOT EXISTS runtime_kv (
261
+ scope TEXT NOT NULL,
262
+ scope_id TEXT NOT NULL DEFAULT '',
263
+ key TEXT NOT NULL,
264
+ value_json TEXT NOT NULL,
265
+ updated_at TEXT NOT NULL,
266
+ PRIMARY KEY (scope, scope_id, key)
267
+ )
268
+ `);
269
+ }
143
270
  function dedupeVerificationEvidenceRows(db) {
144
271
  db.exec(`
145
272
  DELETE FROM verification_evidence
@@ -513,6 +640,8 @@ function initSchema(db, fileBacked) {
513
640
  db.exec(`CREATE VIEW IF NOT EXISTS active_memories AS SELECT * FROM memories WHERE superseded_by IS NULL`);
514
641
  const existing = db.prepare("SELECT count(*) as cnt FROM schema_version").get();
515
642
  if (existing && existing["cnt"] === 0) {
643
+ createCoordinationTablesV24(db);
644
+ createRuntimeKvTableV25(db);
516
645
  // Fresh install — all tables are created above with the full current schema,
517
646
  // so it is safe to create all migration-specific indexes here. For existing
518
647
  // databases these indexes are created inside the individual migration guards
@@ -1142,6 +1271,26 @@ function migrateSchema(db) {
1142
1271
  ":applied_at": new Date().toISOString(),
1143
1272
  });
1144
1273
  }
1274
+ if (currentVersion < 24) {
1275
+ // v24: auto-mode coordination tables. See createCoordinationTablesV24
1276
+ // for full schema + invariants. No-op for fresh installs (the same
1277
+ // helper runs in the fresh-install path); for upgraded DBs this is
1278
+ // the only place these tables get created.
1279
+ createCoordinationTablesV24(db);
1280
+ db.prepare("INSERT INTO schema_version (version, applied_at) VALUES (:version, :applied_at)").run({
1281
+ ":version": 24,
1282
+ ":applied_at": new Date().toISOString(),
1283
+ });
1284
+ }
1285
+ if (currentVersion < 25) {
1286
+ // v25: runtime_kv non-correctness-critical key-value storage. See
1287
+ // createRuntimeKvTableV25 for the full schema + invariants.
1288
+ createRuntimeKvTableV25(db);
1289
+ db.prepare("INSERT INTO schema_version (version, applied_at) VALUES (:version, :applied_at)").run({
1290
+ ":version": 25,
1291
+ ":applied_at": new Date().toISOString(),
1292
+ });
1293
+ }
1145
1294
  db.exec("COMMIT");
1146
1295
  }
1147
1296
  catch (err) {
@@ -2876,6 +3025,7 @@ export function deleteMilestone(milestoneId) {
2876
3025
  currentDb.prepare(`DELETE FROM replan_history WHERE milestone_id = :mid`).run({ ":mid": milestoneId });
2877
3026
  currentDb.prepare(`DELETE FROM assessments WHERE milestone_id = :mid`).run({ ":mid": milestoneId });
2878
3027
  currentDb.prepare(`DELETE FROM artifacts WHERE milestone_id = :mid`).run({ ":mid": milestoneId });
3028
+ currentDb.prepare(`DELETE FROM milestone_leases WHERE milestone_id = :mid`).run({ ":mid": milestoneId });
2879
3029
  currentDb.prepare(`DELETE FROM milestones WHERE id = :mid`).run({ ":mid": milestoneId });
2880
3030
  });
2881
3031
  }
@@ -3190,15 +3340,21 @@ export function deleteArtifactByPath(path) {
3190
3340
  currentDb.prepare("DELETE FROM artifacts WHERE path = :path").run({ ":path": path });
3191
3341
  }
3192
3342
  /**
3193
- * Drop all rows from tasks/slices/milestones in dependency order inside a
3194
- * transaction. Used by `gsd recover` to rebuild engine state from markdown.
3343
+ * Drop hierarchy rows in dependency order inside a transaction. Used by
3344
+ * `gsd recover` to rebuild engine state from markdown.
3195
3345
  */
3196
3346
  export function clearEngineHierarchy() {
3197
3347
  if (!currentDb)
3198
3348
  throw new GSDError(GSD_STALE_STATE, "gsd-db: No database open");
3199
3349
  transaction(() => {
3350
+ currentDb.exec("DELETE FROM verification_evidence");
3351
+ currentDb.exec("DELETE FROM quality_gates");
3352
+ currentDb.exec("DELETE FROM slice_dependencies");
3353
+ currentDb.exec("DELETE FROM assessments");
3354
+ currentDb.exec("DELETE FROM replan_history");
3200
3355
  currentDb.exec("DELETE FROM tasks");
3201
3356
  currentDb.exec("DELETE FROM slices");
3357
+ currentDb.exec("DELETE FROM milestone_leases");
3202
3358
  currentDb.exec("DELETE FROM milestones");
3203
3359
  });
3204
3360
  }
@@ -3281,6 +3437,7 @@ export function restoreManifest(manifest) {
3281
3437
  db.exec("DELETE FROM verification_evidence");
3282
3438
  db.exec("DELETE FROM tasks");
3283
3439
  db.exec("DELETE FROM slices");
3440
+ db.exec("DELETE FROM milestone_leases");
3284
3441
  db.exec("DELETE FROM milestones");
3285
3442
  db.exec("DELETE FROM decisions WHERE 1=1");
3286
3443
  // Restore milestones
@@ -3343,6 +3500,7 @@ export function bulkInsertLegacyHierarchy(payload) {
3343
3500
  transaction(() => {
3344
3501
  db.prepare(`DELETE FROM tasks WHERE milestone_id IN (${placeholders})`).run(...clearMilestoneIds);
3345
3502
  db.prepare(`DELETE FROM slices WHERE milestone_id IN (${placeholders})`).run(...clearMilestoneIds);
3503
+ db.prepare(`DELETE FROM milestone_leases WHERE milestone_id IN (${placeholders})`).run(...clearMilestoneIds);
3346
3504
  db.prepare(`DELETE FROM milestones WHERE id IN (${placeholders})`).run(...clearMilestoneIds);
3347
3505
  const insertMilestone = db.prepare("INSERT INTO milestones (id, title, status, created_at) VALUES (?, ?, ?, ?)");
3348
3506
  for (const m of milestones) {
@@ -48,6 +48,8 @@ import { createWorkspace, scopeMilestone } from "./workspace.js";
48
48
  export { MILESTONE_ID_RE, generateMilestoneSuffix, nextMilestoneId, extractMilestoneSeq, parseMilestoneId, milestoneIdSort, maxMilestoneNum, findMilestoneIds, reserveMilestoneId, claimReservedId, getReservedMilestoneIds, clearReservedMilestoneIds, } from "./milestone-ids.js";
49
49
  export { showQueue, handleQueueReorder, showQueueAdd, buildExistingMilestonesContext, } from "./guided-flow-queue.js";
50
50
  import { logWarning } from "./workflow-logger.js";
51
+ import { deleteRuntimeKv } from "./db/runtime-kv.js";
52
+ import { PAUSED_SESSION_KV_KEY } from "./interrupted-session.js";
51
53
  // ─── Scope-based validator wrappers ──────────────────────────────────────────
52
54
  // These thin wrappers accept a MilestoneScope so callers that already hold a
53
55
  // pinned scope never have to re-derive (basePath, milestoneId) separately.
@@ -1621,11 +1623,13 @@ export async function showSmartEntry(ctx, pi, basePath, options) {
1621
1623
  if (interrupted.classification === "stale") {
1622
1624
  clearLock(basePath);
1623
1625
  if (interrupted.pausedSession) {
1626
+ // Phase C pt 2: paused-session.json migrated to runtime_kv
1627
+ // (global scope, key PAUSED_SESSION_KV_KEY).
1624
1628
  try {
1625
- unlinkSync(join(gsdRoot(basePath), "runtime", "paused-session.json"));
1629
+ deleteRuntimeKv("global", "", PAUSED_SESSION_KV_KEY);
1626
1630
  }
1627
1631
  catch (e) {
1628
- logWarning("guided", `stale pause file cleanup failed: ${e.message}`, { file: "guided-flow.ts" });
1632
+ logWarning("guided", `stale paused-session DB cleanup failed: ${e.message}`, { file: "guided-flow.ts" });
1629
1633
  }
1630
1634
  }
1631
1635
  }
@@ -1,4 +1,4 @@
1
- import { existsSync, readFileSync, unlinkSync } from "node:fs";
1
+ import { existsSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { verifyExpectedArtifact } from "./auto-recovery.js";
4
4
  import { formatCrashInfo, isLockProcessAlive, readCrashLock, } from "./crash-recovery.js";
@@ -6,6 +6,7 @@ import { gsdRoot } from "./paths.js";
6
6
  import { MILESTONE_ID_RE } from "./milestone-ids.js";
7
7
  import { synthesizeCrashRecovery, } from "./session-forensics.js";
8
8
  import { deriveState } from "./state.js";
9
+ import { getRuntimeKv, deleteRuntimeKv } from "./db/runtime-kv.js";
9
10
  const LEGACY_DEEP_SETUP_UNITS = new Set([
10
11
  "workflow-preferences:WORKFLOW-PREFS",
11
12
  "discuss-project:PROJECT",
@@ -32,24 +33,26 @@ function isStalePseudoMilestonePause(meta) {
32
33
  && typeof meta.unitId === "string"
33
34
  && LEGACY_DEEP_SETUP_UNITS.has(`${meta.unitType}:${meta.unitId}`);
34
35
  }
36
+ /**
37
+ * runtime_kv key (global scope) that stores the most recent paused-session
38
+ * metadata. Phase C pt 2: replaces runtime/paused-session.json. The key is
39
+ * project-wide (not worker-scoped) because the paused state represents the
40
+ * last time auto-mode paused on this project — there is at most one paused
41
+ * session per project at a time.
42
+ */
43
+ export const PAUSED_SESSION_KV_KEY = "paused_session";
35
44
  export function readPausedSessionMetadata(basePath) {
36
- const pausedPath = join(gsdRoot(basePath), "runtime", "paused-session.json");
37
- if (!existsSync(pausedPath))
45
+ // basePath is unused now (the DB is workspace-scoped via the connection
46
+ // openDatabase opened on it) but kept in the signature for callers.
47
+ void basePath;
48
+ const meta = getRuntimeKv("global", "", PAUSED_SESSION_KV_KEY);
49
+ if (!meta)
38
50
  return null;
39
- try {
40
- const meta = JSON.parse(readFileSync(pausedPath, "utf-8"));
41
- if (isStalePseudoMilestonePause(meta)) {
42
- try {
43
- unlinkSync(pausedPath);
44
- }
45
- catch { /* non-fatal */ }
46
- return null;
47
- }
48
- return meta;
49
- }
50
- catch {
51
+ if (isStalePseudoMilestonePause(meta)) {
52
+ deleteRuntimeKv("global", "", PAUSED_SESSION_KV_KEY);
51
53
  return null;
52
54
  }
55
+ return meta;
53
56
  }
54
57
  export function isBootstrapCrashLock(lock) {
55
58
  return !!(lock &&
@@ -208,10 +208,17 @@ export async function getActiveMilestoneId(basePath) {
208
208
  * Legacy filesystem parsing is available only through an explicit opt-in for
209
209
  * tests/recovery flows; runtime must not silently infer state from markdown.
210
210
  */
211
- export async function deriveState(basePath) {
212
- // Return cached result if within the TTL window for the same basePath
211
+ export async function deriveState(basePath, opts) {
212
+ // Use the canonical project root (when provided) as the cache key so that
213
+ // two calls with different basePath strings (e.g. worktree path vs project
214
+ // root) but the same canonical .gsd/ share a single cache entry. The same
215
+ // key is used for both the lookup AND the write below — keying lookup on
216
+ // canonical-root while writing on basePath would silently return stale
217
+ // results across path-form alternation.
218
+ const cacheKey = opts?.projectRootForReads ?? basePath;
219
+ // Return cached result if within the TTL window for the same cacheKey
213
220
  if (_stateCache &&
214
- _stateCache.basePath === basePath &&
221
+ _stateCache.basePath === cacheKey &&
215
222
  Date.now() - _stateCache.timestamp < CACHE_TTL_MS) {
216
223
  return _stateCache.result;
217
224
  }
@@ -230,7 +237,7 @@ export async function deriveState(basePath) {
230
237
  if (wasDbOpenAttempted()) {
231
238
  logWarning("state", "DB unavailable — using explicit legacy filesystem state derivation");
232
239
  }
233
- result = await _deriveStateImpl(basePath);
240
+ result = await _deriveStateImpl(basePath, opts);
234
241
  _telemetry.markdownDeriveCount++;
235
242
  }
236
243
  else {
@@ -252,7 +259,7 @@ export async function deriveState(basePath) {
252
259
  }
253
260
  stopTimer({ phase: result.phase, milestone: result.activeMilestone?.id });
254
261
  debugCount("deriveStateCalls");
255
- _stateCache = { basePath, result, timestamp: Date.now() };
262
+ _stateCache = { basePath: cacheKey, result, timestamp: Date.now() };
256
263
  return result;
257
264
  }
258
265
  /**
@@ -689,7 +696,15 @@ export async function deriveStateFromDb(basePath) {
689
696
  // LEGACY: Filesystem-based state derivation for unmigrated projects.
690
697
  // DB-backed projects use deriveStateFromDb() above. Target: extract to
691
698
  // state-legacy.ts when all projects are DB-backed.
692
- export async function _deriveStateImpl(basePath) {
699
+ export async function _deriveStateImpl(basePath, opts) {
700
+ // When the caller supplies a canonical project root for reads (e.g.
701
+ // s.canonicalProjectRoot from auto-mode), route all artifact reads through
702
+ // it. This prevents the worktree-local empty `.gsd/` from being consulted
703
+ // when the canonical state lives at the project root (or via a `.gsd`
704
+ // symlink into the external state dir).
705
+ if (opts?.projectRootForReads) {
706
+ basePath = opts.projectRootForReads;
707
+ }
693
708
  const diskIds = findMilestoneIds(basePath);
694
709
  const customOrder = loadQueueOrder(basePath);
695
710
  const milestoneIds = sortByQueueOrder(diskIds, customOrder);
@@ -22,6 +22,7 @@ import { emitWorktreeCreated, emitWorktreeMerged } from "./worktree-telemetry.js
22
22
  import { getCollapseCadence, getMilestoneResquash, resquashMilestoneOnMain } from "./slice-cadence.js";
23
23
  import { loadEffectiveGSDPreferences } from "./preferences.js";
24
24
  import { resolveWorktreeProjectRoot, normalizeWorktreePathForCompare } from "./worktree-root.js";
25
+ import { claimMilestoneLease, releaseMilestoneLease } from "./db/milestone-leases.js";
25
26
  // ─── Path Comparison Helper ────────────────────────────────────────────────
26
27
  /**
27
28
  * Compare two paths for physical identity, tolerating trailing slashes,
@@ -111,6 +112,69 @@ export class WorktreeResolver {
111
112
  });
112
113
  return;
113
114
  }
115
+ // Phase B: claim a milestone lease before any worktree mutation. Two
116
+ // workers cannot enter the same milestone concurrently. Best-effort:
117
+ // skip if no worker registered (single-worker fallback) or DB
118
+ // unavailable; reuse existing lease if we already hold it on this
119
+ // milestone (re-entry within the same session).
120
+ if (this.s.workerId) {
121
+ if (this.s.currentMilestoneId === milestoneId && this.s.milestoneLeaseToken !== null) {
122
+ // Already held — no-op, the heartbeat in loop.ts refreshes TTL.
123
+ }
124
+ else {
125
+ // If we held a different milestone, release it first so other
126
+ // workers don't have to wait for TTL.
127
+ if (this.s.currentMilestoneId && this.s.currentMilestoneId !== milestoneId && this.s.milestoneLeaseToken !== null) {
128
+ try {
129
+ releaseMilestoneLease(this.s.workerId, this.s.currentMilestoneId, this.s.milestoneLeaseToken);
130
+ }
131
+ catch (err) {
132
+ debugLog("WorktreeResolver", {
133
+ action: "enterMilestone",
134
+ milestoneId,
135
+ releasePriorLeaseError: err instanceof Error ? err.message : String(err),
136
+ });
137
+ }
138
+ this.s.milestoneLeaseToken = null;
139
+ }
140
+ try {
141
+ const claim = claimMilestoneLease(this.s.workerId, milestoneId);
142
+ if (claim.ok) {
143
+ this.s.milestoneLeaseToken = claim.token;
144
+ debugLog("WorktreeResolver", {
145
+ action: "enterMilestone",
146
+ milestoneId,
147
+ leaseAcquired: true,
148
+ fencingToken: claim.token,
149
+ expiresAt: claim.expiresAt,
150
+ });
151
+ }
152
+ else {
153
+ // Lease held by another worker — fail loud so the user can
154
+ // see the conflict instead of silently double-running.
155
+ const msg = `Milestone ${milestoneId} is held by worker ${claim.byWorker} until ${claim.expiresAt}.`;
156
+ debugLog("WorktreeResolver", {
157
+ action: "enterMilestone",
158
+ milestoneId,
159
+ leaseHeldByOther: claim.byWorker,
160
+ expiresAt: claim.expiresAt,
161
+ });
162
+ ctx.notify(`${msg} Another auto-mode worker is active. Stop it before entering ${milestoneId}.`, "error");
163
+ return;
164
+ }
165
+ }
166
+ catch (err) {
167
+ // DB unavailable or other error — log and fall through to the
168
+ // pre-Phase-B single-worker behavior so a fresh project before
169
+ // DB init still works.
170
+ debugLog("WorktreeResolver", {
171
+ action: "enterMilestone",
172
+ milestoneId,
173
+ leaseError: err instanceof Error ? err.message : String(err),
174
+ });
175
+ }
176
+ }
177
+ }
114
178
  // Resolve the project root for worktree operations via shared helper.
115
179
  // Handles the case where originalBasePath is falsy and basePath is itself
116
180
  // a worktree path — prevents double-nested worktree paths (#3729).