gsd-pi 2.78.1-dev.e9d88a536 → 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 (212) 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 +14 -7
  8. package/dist/resources/extensions/gsd/auto/session.js +36 -0
  9. package/dist/resources/extensions/gsd/auto-dispatch.js +49 -4
  10. package/dist/resources/extensions/gsd/auto-post-unit.js +26 -12
  11. package/dist/resources/extensions/gsd/auto-worktree.js +185 -201
  12. package/dist/resources/extensions/gsd/auto.js +139 -49
  13. package/dist/resources/extensions/gsd/bootstrap/agent-end-recovery.js +1 -1
  14. package/dist/resources/extensions/gsd/bootstrap/register-hooks.js +26 -20
  15. package/dist/resources/extensions/gsd/bootstrap/write-gate.js +67 -55
  16. package/dist/resources/extensions/gsd/crash-recovery.js +160 -47
  17. package/dist/resources/extensions/gsd/db/auto-workers.js +227 -0
  18. package/dist/resources/extensions/gsd/db/command-queue.js +105 -0
  19. package/dist/resources/extensions/gsd/db/milestone-leases.js +210 -0
  20. package/dist/resources/extensions/gsd/db/runtime-kv.js +91 -0
  21. package/dist/resources/extensions/gsd/db/unit-dispatches.js +322 -0
  22. package/dist/resources/extensions/gsd/db-writer.js +96 -16
  23. package/dist/resources/extensions/gsd/delegation-policy.js +155 -0
  24. package/dist/resources/extensions/gsd/docs/COORDINATION.md +42 -0
  25. package/dist/resources/extensions/gsd/doctor-proactive.js +4 -0
  26. package/dist/resources/extensions/gsd/doctor-runtime-checks.js +22 -6
  27. package/dist/resources/extensions/gsd/doctor.js +12 -2
  28. package/dist/resources/extensions/gsd/gsd-db.js +355 -3
  29. package/dist/resources/extensions/gsd/guided-flow-queue.js +1 -1
  30. package/dist/resources/extensions/gsd/guided-flow.js +116 -26
  31. package/dist/resources/extensions/gsd/interrupted-session.js +18 -15
  32. package/dist/resources/extensions/gsd/metrics.js +287 -1
  33. package/dist/resources/extensions/gsd/paths.js +79 -8
  34. package/dist/resources/extensions/gsd/prompts/complete-slice.md +4 -4
  35. package/dist/resources/extensions/gsd/prompts/execute-task.md +3 -3
  36. package/dist/resources/extensions/gsd/prompts/guided-discuss-milestone.md +8 -1
  37. package/dist/resources/extensions/gsd/prompts/guided-discuss-project.md +22 -7
  38. package/dist/resources/extensions/gsd/prompts/guided-discuss-requirements.md +6 -2
  39. package/dist/resources/extensions/gsd/prompts/guided-discuss-slice.md +8 -1
  40. package/dist/resources/extensions/gsd/state.js +21 -6
  41. package/dist/resources/extensions/gsd/templates/project.md +10 -0
  42. package/dist/resources/extensions/gsd/workflow-mcp.js +2 -2
  43. package/dist/resources/extensions/gsd/workspace.js +59 -0
  44. package/dist/resources/extensions/gsd/worktree-resolver.js +79 -2
  45. package/dist/resources/extensions/gsd/write-intercept.js +3 -3
  46. package/dist/tsconfig.extensions.tsbuildinfo +1 -1
  47. package/dist/web/standalone/.next/BUILD_ID +1 -1
  48. package/dist/web/standalone/.next/app-path-routes-manifest.json +14 -14
  49. package/dist/web/standalone/.next/build-manifest.json +2 -2
  50. package/dist/web/standalone/.next/prerender-manifest.json +3 -3
  51. package/dist/web/standalone/.next/required-server-files.json +1 -1
  52. package/dist/web/standalone/.next/server/app/_global-error.html +1 -1
  53. package/dist/web/standalone/.next/server/app/_global-error.rsc +1 -1
  54. package/dist/web/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  55. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
  56. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error.segment.rsc +1 -1
  57. package/dist/web/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  58. package/dist/web/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  59. package/dist/web/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  60. package/dist/web/standalone/.next/server/app/_not-found.html +1 -1
  61. package/dist/web/standalone/.next/server/app/_not-found.rsc +1 -1
  62. package/dist/web/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
  63. package/dist/web/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
  64. package/dist/web/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
  65. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  66. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  67. package/dist/web/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  68. package/dist/web/standalone/.next/server/app/index.html +1 -1
  69. package/dist/web/standalone/.next/server/app/index.rsc +1 -1
  70. package/dist/web/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +1 -1
  71. package/dist/web/standalone/.next/server/app/index.segments/_full.segment.rsc +1 -1
  72. package/dist/web/standalone/.next/server/app/index.segments/_head.segment.rsc +1 -1
  73. package/dist/web/standalone/.next/server/app/index.segments/_index.segment.rsc +1 -1
  74. package/dist/web/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
  75. package/dist/web/standalone/.next/server/app-paths-manifest.json +14 -14
  76. package/dist/web/standalone/.next/server/middleware-build-manifest.js +1 -1
  77. package/dist/web/standalone/.next/server/pages/404.html +1 -1
  78. package/dist/web/standalone/.next/server/pages/500.html +1 -1
  79. package/dist/web/standalone/.next/server/server-reference-manifest.json +1 -1
  80. package/dist/web/standalone/server.js +1 -1
  81. package/package.json +1 -1
  82. package/packages/mcp-server/README.md +2 -11
  83. package/packages/mcp-server/dist/remote-questions.d.ts +27 -0
  84. package/packages/mcp-server/dist/remote-questions.d.ts.map +1 -1
  85. package/packages/mcp-server/dist/remote-questions.js +28 -0
  86. package/packages/mcp-server/dist/remote-questions.js.map +1 -1
  87. package/packages/mcp-server/dist/server.d.ts +28 -0
  88. package/packages/mcp-server/dist/server.d.ts.map +1 -1
  89. package/packages/mcp-server/dist/server.js +94 -4
  90. package/packages/mcp-server/dist/server.js.map +1 -1
  91. package/packages/mcp-server/dist/workflow-tools.js.map +1 -1
  92. package/packages/mcp-server/src/mcp-server.test.ts +226 -0
  93. package/packages/mcp-server/src/remote-questions.test.ts +103 -0
  94. package/packages/mcp-server/src/remote-questions.ts +35 -0
  95. package/packages/mcp-server/src/server.ts +129 -6
  96. package/packages/mcp-server/src/workflow-tools.ts +1 -1
  97. package/packages/mcp-server/tsconfig.tsbuildinfo +1 -1
  98. package/src/resources/extensions/gsd/auto/detect-stuck.ts +37 -5
  99. package/src/resources/extensions/gsd/auto/loop.ts +263 -41
  100. package/src/resources/extensions/gsd/auto/phases.ts +15 -7
  101. package/src/resources/extensions/gsd/auto/session.ts +40 -0
  102. package/src/resources/extensions/gsd/auto-dispatch.ts +63 -4
  103. package/src/resources/extensions/gsd/auto-post-unit.ts +27 -12
  104. package/src/resources/extensions/gsd/auto-worktree.ts +218 -225
  105. package/src/resources/extensions/gsd/auto.ts +166 -43
  106. package/src/resources/extensions/gsd/bootstrap/agent-end-recovery.ts +1 -1
  107. package/src/resources/extensions/gsd/bootstrap/register-hooks.ts +26 -21
  108. package/src/resources/extensions/gsd/bootstrap/tests/write-gate-basepath.test.ts +103 -0
  109. package/src/resources/extensions/gsd/bootstrap/write-gate.ts +80 -55
  110. package/src/resources/extensions/gsd/crash-recovery.ts +177 -43
  111. package/src/resources/extensions/gsd/db/auto-workers.ts +273 -0
  112. package/src/resources/extensions/gsd/db/command-queue.ts +149 -0
  113. package/src/resources/extensions/gsd/db/milestone-leases.ts +274 -0
  114. package/src/resources/extensions/gsd/db/runtime-kv.ts +127 -0
  115. package/src/resources/extensions/gsd/db/unit-dispatches.ts +446 -0
  116. package/src/resources/extensions/gsd/db-writer.ts +113 -17
  117. package/src/resources/extensions/gsd/delegation-policy.ts +197 -0
  118. package/src/resources/extensions/gsd/docs/COORDINATION.md +42 -0
  119. package/src/resources/extensions/gsd/doctor-proactive.ts +4 -0
  120. package/src/resources/extensions/gsd/doctor-runtime-checks.ts +24 -6
  121. package/src/resources/extensions/gsd/doctor.ts +10 -2
  122. package/src/resources/extensions/gsd/gsd-db.ts +354 -3
  123. package/src/resources/extensions/gsd/guided-flow-queue.ts +1 -1
  124. package/src/resources/extensions/gsd/guided-flow.ts +152 -26
  125. package/src/resources/extensions/gsd/interrupted-session.ts +19 -12
  126. package/src/resources/extensions/gsd/metrics.ts +321 -1
  127. package/src/resources/extensions/gsd/paths.ts +67 -8
  128. package/src/resources/extensions/gsd/prompts/complete-slice.md +4 -4
  129. package/src/resources/extensions/gsd/prompts/execute-task.md +3 -3
  130. package/src/resources/extensions/gsd/prompts/guided-discuss-milestone.md +8 -1
  131. package/src/resources/extensions/gsd/prompts/guided-discuss-project.md +22 -7
  132. package/src/resources/extensions/gsd/prompts/guided-discuss-requirements.md +6 -2
  133. package/src/resources/extensions/gsd/prompts/guided-discuss-slice.md +8 -1
  134. package/src/resources/extensions/gsd/state.ts +44 -6
  135. package/src/resources/extensions/gsd/templates/project.md +10 -0
  136. package/src/resources/extensions/gsd/tests/auto-discuss-milestone-deadlock-4973.test.ts +14 -14
  137. package/src/resources/extensions/gsd/tests/auto-loop-no-copy-artifacts.test.ts +72 -0
  138. package/src/resources/extensions/gsd/tests/auto-loop-symlink-worktree.test.ts +190 -0
  139. package/src/resources/extensions/gsd/tests/auto-session-scope.test.ts +331 -0
  140. package/src/resources/extensions/gsd/tests/auto-workers.test.ts +105 -0
  141. package/src/resources/extensions/gsd/tests/auto-worktree-registry.test.ts +176 -0
  142. package/src/resources/extensions/gsd/tests/command-queue.test.ts +141 -0
  143. package/src/resources/extensions/gsd/tests/crash-recovery-via-db.test.ts +203 -0
  144. package/src/resources/extensions/gsd/tests/crash-recovery.test.ts +169 -59
  145. package/src/resources/extensions/gsd/tests/db-writer-path-containment.test.ts +152 -0
  146. package/src/resources/extensions/gsd/tests/db-writer-root-artifact.test.ts +221 -0
  147. package/src/resources/extensions/gsd/tests/db-writer-scope.test.ts +230 -0
  148. package/src/resources/extensions/gsd/tests/delegation-policy.test.ts +151 -0
  149. package/src/resources/extensions/gsd/tests/detect-stuck-respects-retry.test.ts +173 -0
  150. package/src/resources/extensions/gsd/tests/dispatch-backgroundable-annotation.test.ts +55 -0
  151. package/src/resources/extensions/gsd/tests/draft-promotion.test.ts +3 -23
  152. package/src/resources/extensions/gsd/tests/gate-1b-orphan-discrimination.test.ts +193 -0
  153. package/src/resources/extensions/gsd/tests/gate-1b-recovery-bound-corrections.test.ts +246 -0
  154. package/src/resources/extensions/gsd/tests/gate-1b-recovery-bound.test.ts +218 -0
  155. package/src/resources/extensions/gsd/tests/gsd-db-failed-open-restore.test.ts +117 -0
  156. package/src/resources/extensions/gsd/tests/gsd-db-workspace-scope.test.ts +226 -0
  157. package/src/resources/extensions/gsd/tests/gsd-root-canonical.test.ts +66 -0
  158. package/src/resources/extensions/gsd/tests/gsd-root-home-guard.test.ts +68 -5
  159. package/src/resources/extensions/gsd/tests/guided-flow-prompt-consolidation.test.ts +4 -4
  160. package/src/resources/extensions/gsd/tests/integration/auto-worktree.test.ts +22 -12
  161. package/src/resources/extensions/gsd/tests/integration/doctor-proactive.test.ts +24 -10
  162. package/src/resources/extensions/gsd/tests/integration/doctor-runtime.test.ts +35 -23
  163. package/src/resources/extensions/gsd/tests/integration/workspace-collapse-integration.test.ts +369 -0
  164. package/src/resources/extensions/gsd/tests/interrupted-session-auto.test.ts +72 -25
  165. package/src/resources/extensions/gsd/tests/interrupted-session-ui.test.ts +72 -25
  166. package/src/resources/extensions/gsd/tests/memory-pressure-stuck-state.test.ts +9 -6
  167. package/src/resources/extensions/gsd/tests/metrics-atomic-merge.test.ts +222 -0
  168. package/src/resources/extensions/gsd/tests/metrics-lock-hardening.test.ts +400 -0
  169. package/src/resources/extensions/gsd/tests/metrics-lock-not-acquired.test.ts +141 -0
  170. package/src/resources/extensions/gsd/tests/metrics-lock-retry-sleep.test.ts +287 -0
  171. package/src/resources/extensions/gsd/tests/metrics-prune-cache-invalidation.test.ts +149 -0
  172. package/src/resources/extensions/gsd/tests/metrics-scope.test.ts +378 -0
  173. package/src/resources/extensions/gsd/tests/milestone-leases.test.ts +152 -0
  174. package/src/resources/extensions/gsd/tests/originalbase-path-comparison.test.ts +329 -0
  175. package/src/resources/extensions/gsd/tests/parallel-milestone-isolation.test.ts +106 -0
  176. package/src/resources/extensions/gsd/tests/path-cache-decoupled.test.ts +209 -0
  177. package/src/resources/extensions/gsd/tests/path-normalization-unified.test.ts +175 -0
  178. package/src/resources/extensions/gsd/tests/paths-cache.test.ts +170 -0
  179. package/src/resources/extensions/gsd/tests/paused-session-via-db.test.ts +119 -0
  180. package/src/resources/extensions/gsd/tests/pending-autostart-scope.test.ts +120 -0
  181. package/src/resources/extensions/gsd/tests/pipeline-variant-dispatch.test.ts +58 -0
  182. package/src/resources/extensions/gsd/tests/preferences-worktree-sync.test.ts +3 -17
  183. package/src/resources/extensions/gsd/tests/prompt-contracts.test.ts +150 -7
  184. package/src/resources/extensions/gsd/tests/register-hooks-depth-verification.test.ts +138 -16
  185. package/src/resources/extensions/gsd/tests/resume-missing-worktree-warning.test.ts +209 -0
  186. package/src/resources/extensions/gsd/tests/runtime-kv.test.ts +120 -0
  187. package/src/resources/extensions/gsd/tests/skipped-validation-completion.test.ts +133 -28
  188. package/src/resources/extensions/gsd/tests/skipped-validation-db-atomicity.test.ts +17 -0
  189. package/src/resources/extensions/gsd/tests/stuck-state-via-db.test.ts +134 -0
  190. package/src/resources/extensions/gsd/tests/sync-layer-scope.test.ts +434 -0
  191. package/src/resources/extensions/gsd/tests/teardown-chdir-failure-clears-registry.test.ts +162 -0
  192. package/src/resources/extensions/gsd/tests/teardown-cleanup-parity.test.ts +98 -0
  193. package/src/resources/extensions/gsd/tests/teardown-failure-clears-registry.test.ts +186 -0
  194. package/src/resources/extensions/gsd/tests/tool-invocation-error-loop-break.test.ts +1 -1
  195. package/src/resources/extensions/gsd/tests/unit-dispatches.test.ts +247 -0
  196. package/src/resources/extensions/gsd/tests/validate-milestone.test.ts +41 -1
  197. package/src/resources/extensions/gsd/tests/validator-scope-parity.test.ts +239 -0
  198. package/src/resources/extensions/gsd/tests/workflow-mcp.test.ts +2 -2
  199. package/src/resources/extensions/gsd/tests/workflow-tool-executors.test.ts +9 -15
  200. package/src/resources/extensions/gsd/tests/workspace.test.ts +196 -0
  201. package/src/resources/extensions/gsd/tests/write-gate-predicates.test.ts +35 -35
  202. package/src/resources/extensions/gsd/tests/write-gate.test.ts +94 -71
  203. package/src/resources/extensions/gsd/tests/write-intercept.test.ts +1 -1
  204. package/src/resources/extensions/gsd/workflow-mcp.ts +2 -2
  205. package/src/resources/extensions/gsd/workspace.ts +95 -0
  206. package/src/resources/extensions/gsd/worktree-resolver.ts +78 -2
  207. package/src/resources/extensions/gsd/write-intercept.ts +3 -3
  208. package/src/resources/extensions/gsd/tests/auto-lock-creation.test.ts +0 -213
  209. package/src/resources/extensions/gsd/tests/auto-stale-lock-self-kill.test.ts +0 -87
  210. package/src/resources/extensions/gsd/tests/stop-auto-remote.test.ts +0 -159
  211. /package/dist/web/standalone/.next/static/{oZGTPvJBQX_IDKKnuV8Bt → Y5UeGFkXTYM9WIQOWHkot}/_buildManifest.js +0 -0
  212. /package/dist/web/standalone/.next/static/{oZGTPvJBQX_IDKKnuV8Bt → Y5UeGFkXTYM9WIQOWHkot}/_ssgManifest.js +0 -0
@@ -0,0 +1,227 @@
1
+ // gsd-2 + Auto-mode worker process registry (DB-backed coordination, Phase B)
2
+ //
3
+ // IMPORTANT — naming clarification (codex review LOW N1):
4
+ // This module is the AUTO-MODE PROCESS REGISTRY. It tracks long-running
5
+ // `gsd auto` worker processes for cross-process coordination via the shared
6
+ // SQLite WAL. It is NOT the in-process subagent registry, which lives at
7
+ // `src/resources/extensions/subagent/worker-registry.ts` and tracks dispatched
8
+ // subagent threads within a single process.
9
+ //
10
+ // Both modules use the word "worker" but they are unrelated:
11
+ // - subagent/worker-registry.ts → ephemeral in-process subagent threads
12
+ // - db/auto-workers.ts → durable cross-process auto-mode sessions
13
+ //
14
+ // Single-host invariant: SQLite WAL coordination only works on local disk.
15
+ // NFS / network filesystems break heartbeat semantics. Multi-host execution
16
+ // needs a real coordinator (etcd, Postgres) — out of scope for Phase B.
17
+ import { randomUUID } from "node:crypto";
18
+ import { hostname } from "node:os";
19
+ import { _getAdapter, isDbAvailable, transaction, insertAuditEvent, } from "../gsd-db.js";
20
+ import { normalizeRealPath } from "../paths.js";
21
+ const HEARTBEAT_TTL_SECONDS = 60;
22
+ // Version label is for diagnostics only — embedded in audit_events and
23
+ // workers.version. Bumping this manually on protocol changes is fine; we
24
+ // don't pull it from package.json to avoid module-load filesystem I/O.
25
+ const WORKER_REGISTRY_VERSION = "1";
26
+ /**
27
+ * Register a new auto-mode worker process. Returns the generated worker_id
28
+ * for the session to store on its AutoSession.
29
+ *
30
+ * The worker is created with `status='active'` and an initial heartbeat
31
+ * stamp; callers must invoke heartbeatAutoWorker() periodically (e.g. once
32
+ * per loop iteration) to refresh the TTL.
33
+ */
34
+ export function registerAutoWorker(opts) {
35
+ if (!isDbAvailable()) {
36
+ throw new Error("registerAutoWorker: DB unavailable");
37
+ }
38
+ const workerId = `auto-${hostname()}-${process.pid}-${randomUUID().slice(0, 8)}`;
39
+ const now = new Date().toISOString();
40
+ transaction(() => {
41
+ const db = _getAdapter();
42
+ db.prepare(`INSERT INTO workers (
43
+ worker_id, host, pid, started_at, version,
44
+ last_heartbeat_at, status, project_root_realpath
45
+ ) VALUES (
46
+ :worker_id, :host, :pid, :started_at, :version,
47
+ :last_heartbeat_at, 'active', :project_root_realpath
48
+ )`).run({
49
+ ":worker_id": workerId,
50
+ ":host": hostname(),
51
+ ":pid": process.pid,
52
+ ":started_at": now,
53
+ ":version": WORKER_REGISTRY_VERSION,
54
+ ":last_heartbeat_at": now,
55
+ ":project_root_realpath": opts.projectRootRealpath,
56
+ });
57
+ });
58
+ insertAuditEvent({
59
+ eventId: randomUUID(),
60
+ traceId: workerId,
61
+ category: "orchestration",
62
+ type: "worker-registered",
63
+ ts: now,
64
+ payload: {
65
+ workerId,
66
+ host: hostname(),
67
+ pid: process.pid,
68
+ version: WORKER_REGISTRY_VERSION,
69
+ projectRootRealpath: opts.projectRootRealpath,
70
+ },
71
+ });
72
+ return workerId;
73
+ }
74
+ /**
75
+ * Refresh the worker's heartbeat. Call once per auto-loop iteration.
76
+ * Idempotent — silently no-ops if the worker no longer exists (e.g. row was
77
+ * cleaned up by a janitor).
78
+ */
79
+ export function heartbeatAutoWorker(workerId) {
80
+ if (!isDbAvailable())
81
+ return;
82
+ const now = new Date().toISOString();
83
+ const db = _getAdapter();
84
+ db.prepare(`UPDATE workers SET last_heartbeat_at = :now WHERE worker_id = :worker_id AND status = 'active'`).run({ ":now": now, ":worker_id": workerId });
85
+ }
86
+ /**
87
+ * Mark the worker as crashed. Used by janitors / doctor commands when a
88
+ * worker's heartbeat has expired beyond the TTL window.
89
+ */
90
+ export function markWorkerCrashed(workerId) {
91
+ if (!isDbAvailable())
92
+ return;
93
+ const db = _getAdapter();
94
+ let changes = 0;
95
+ transaction(() => {
96
+ const result = db.prepare(`UPDATE workers SET status = 'crashed' WHERE worker_id = :worker_id AND status = 'active'`).run({ ":worker_id": workerId });
97
+ changes =
98
+ typeof result.changes === "number"
99
+ ? result.changes
100
+ : 0;
101
+ });
102
+ if (changes < 1)
103
+ return;
104
+ insertAuditEvent({
105
+ eventId: randomUUID(),
106
+ traceId: workerId,
107
+ category: "orchestration",
108
+ type: "worker-crashed",
109
+ ts: new Date().toISOString(),
110
+ payload: { workerId },
111
+ });
112
+ }
113
+ /**
114
+ * Mark the worker as stopping. Called from the stopAuto path when the user
115
+ * cleanly shuts down auto-mode.
116
+ */
117
+ export function markWorkerStopping(workerId) {
118
+ if (!isDbAvailable())
119
+ return;
120
+ const db = _getAdapter();
121
+ transaction(() => {
122
+ db.prepare(`UPDATE workers SET status = 'stopping' WHERE worker_id = :worker_id`).run({ ":worker_id": workerId });
123
+ });
124
+ }
125
+ /**
126
+ * Return all workers whose status is 'active' AND whose heartbeat is within
127
+ * the TTL window. Workers older than the TTL are NOT auto-marked crashed
128
+ * here — that's a separate janitor responsibility — but they are filtered
129
+ * out of the active set so callers see a fresh view.
130
+ */
131
+ export function getActiveAutoWorkers() {
132
+ if (!isDbAvailable())
133
+ return [];
134
+ const db = _getAdapter();
135
+ const cutoffMs = Date.now() - HEARTBEAT_TTL_SECONDS * 1000;
136
+ const cutoffIso = new Date(cutoffMs).toISOString();
137
+ const rows = db.prepare(`SELECT worker_id, host, pid, started_at, version,
138
+ last_heartbeat_at, status, project_root_realpath
139
+ FROM workers
140
+ WHERE status = 'active' AND last_heartbeat_at >= :cutoff
141
+ ORDER BY started_at`).all({ ":cutoff": cutoffIso });
142
+ return rows;
143
+ }
144
+ /** Return all worker rows regardless of status or TTL. */
145
+ export function getAllAutoWorkers() {
146
+ if (!isDbAvailable())
147
+ return [];
148
+ const db = _getAdapter();
149
+ const rows = db.prepare(`SELECT worker_id, host, pid, started_at, version,
150
+ last_heartbeat_at, status, project_root_realpath
151
+ FROM workers
152
+ ORDER BY started_at`).all();
153
+ return rows;
154
+ }
155
+ /**
156
+ * Look up a single worker row. Returns null if no row exists.
157
+ */
158
+ export function getAutoWorker(workerId) {
159
+ if (!isDbAvailable())
160
+ return null;
161
+ const db = _getAdapter();
162
+ const row = db.prepare(`SELECT worker_id, host, pid, started_at, version,
163
+ last_heartbeat_at, status, project_root_realpath
164
+ FROM workers WHERE worker_id = :worker_id`).get({ ":worker_id": workerId });
165
+ return row ?? null;
166
+ }
167
+ /** Test/janitor helper: TTL constant exported for callers to compute expirations. */
168
+ export function autoWorkerHeartbeatTtlSeconds() {
169
+ return HEARTBEAT_TTL_SECONDS;
170
+ }
171
+ function isWorkerProcessAlive(candidate) {
172
+ const pid = candidate.pid;
173
+ if (!Number.isInteger(pid) || pid <= 0)
174
+ return false;
175
+ if (candidate.host !== hostname())
176
+ return false;
177
+ if (pid === process.pid)
178
+ return true;
179
+ try {
180
+ process.kill(pid, 0);
181
+ return true;
182
+ }
183
+ catch (err) {
184
+ if (err.code === "EPERM")
185
+ return true;
186
+ return false;
187
+ }
188
+ }
189
+ /**
190
+ * Phase C pt 2 — find the most recently active worker for a project root
191
+ * whose heartbeat has lapsed (the "previous crashed session" indicator).
192
+ *
193
+ * Used by crash-recovery.ts:readCrashLock to detect when a prior auto-mode
194
+ * session ended without cleanup. Workers are only treated as stale after
195
+ * their heartbeat has lapsed and the OS PID liveness check says the process
196
+ * is no longer alive.
197
+ *
198
+ * Returns null if no stale worker exists for this project root.
199
+ */
200
+ export function findStaleWorkerForProject(projectRootRealpath) {
201
+ if (!isDbAvailable())
202
+ return null;
203
+ const db = _getAdapter();
204
+ const cutoffMs = Date.now() - HEARTBEAT_TTL_SECONDS * 1000;
205
+ const cutoffIso = new Date(cutoffMs).toISOString();
206
+ const row = db.prepare(`SELECT worker_id, host, pid, started_at, version,
207
+ last_heartbeat_at, status, project_root_realpath
208
+ FROM workers
209
+ WHERE project_root_realpath = :project_root
210
+ AND status = 'active'
211
+ AND last_heartbeat_at < :cutoff
212
+ ORDER BY started_at DESC
213
+ LIMIT 1`).get({ ":project_root": projectRootRealpath, ":cutoff": cutoffIso });
214
+ if (row && !isWorkerProcessAlive(row))
215
+ return row;
216
+ // Older rows and external fixtures may have captured a non-realpath spelling
217
+ // of the same project root, e.g. /var/... vs /private/var/... on macOS.
218
+ const canonicalProjectRoot = normalizeRealPath(projectRootRealpath);
219
+ const staleRows = db.prepare(`SELECT worker_id, host, pid, started_at, version,
220
+ last_heartbeat_at, status, project_root_realpath
221
+ FROM workers
222
+ WHERE status = 'active'
223
+ AND last_heartbeat_at < :cutoff
224
+ ORDER BY started_at DESC`).all({ ":cutoff": cutoffIso });
225
+ return staleRows.find((candidate) => normalizeRealPath(candidate.project_root_realpath) === canonicalProjectRoot
226
+ && !isWorkerProcessAlive(candidate)) ?? null;
227
+ }
@@ -0,0 +1,105 @@
1
+ // gsd-2 + Worker IPC command queue (DB-backed coordination, Phase B)
2
+ //
3
+ // New infrastructure for dispatcher-to-worker IPC (cancel signals, pause
4
+ // requests, etc.). NOT a replacement for any existing on-disk queue and
5
+ // NOT related to startAutoCommandPolling() in auto.ts (which polls a
6
+ // remote channel like Telegram, not a local file queue).
7
+ //
8
+ // Broadcast semantics (codex review LOW B4):
9
+ // SQLite indexes NULLs in B-trees, so the single index
10
+ // idx_command_queue_pending(target_worker, claimed_at) serves both:
11
+ // - targeted queries: WHERE target_worker = ?
12
+ // - broadcast queries: WHERE target_worker IS NULL
13
+ // Workers should poll for both forms (their own ID + broadcasts) on each
14
+ // claim cycle.
15
+ import { _getAdapter, isDbAvailable, transaction, } from "../gsd-db.js";
16
+ /**
17
+ * Enqueue a command. Returns the new row id. Broadcast commands
18
+ * (targetWorker=null) will be claimed by exactly one worker — the IPC
19
+ * model is "single delivery to whoever claims first", not pub-sub.
20
+ */
21
+ export function enqueueCommand(input) {
22
+ if (!isDbAvailable()) {
23
+ throw new Error("enqueueCommand: DB unavailable");
24
+ }
25
+ const now = new Date().toISOString();
26
+ const db = _getAdapter();
27
+ const result = transaction(() => {
28
+ return db.prepare(`INSERT INTO command_queue (target_worker, command, args_json, enqueued_at)
29
+ VALUES (:target_worker, :command, :args_json, :enqueued_at)`).run({
30
+ ":target_worker": input.targetWorker,
31
+ ":command": input.command,
32
+ ":args_json": JSON.stringify(input.args ?? {}),
33
+ ":enqueued_at": now,
34
+ });
35
+ });
36
+ return Number(result.lastInsertRowid ?? 0);
37
+ }
38
+ /**
39
+ * Atomically claim the next pending command for the given worker. Returns
40
+ * the claimed row, or null if nothing to claim.
41
+ *
42
+ * Polls both targeted (target_worker = workerId) and broadcast
43
+ * (target_worker IS NULL) queues, oldest-first.
44
+ */
45
+ export function claimNextCommand(workerId) {
46
+ if (!isDbAvailable())
47
+ return null;
48
+ const now = new Date().toISOString();
49
+ const db = _getAdapter();
50
+ return transaction(() => {
51
+ // Find the oldest unclaimed command targeted at this worker OR
52
+ // broadcast. The partial index covers both via NULL-in-B-tree.
53
+ const row = db.prepare(`SELECT id, target_worker, command, args_json, enqueued_at,
54
+ claimed_at, claimed_by, completed_at, result_json
55
+ FROM command_queue
56
+ WHERE claimed_at IS NULL
57
+ AND completed_at IS NULL
58
+ AND (target_worker = :worker_id OR target_worker IS NULL)
59
+ ORDER BY enqueued_at ASC, id ASC
60
+ LIMIT 1`).get({ ":worker_id": workerId });
61
+ if (!row)
62
+ return null;
63
+ // Conditional UPDATE — only succeeds if still unclaimed (guards against
64
+ // races between two workers polling simultaneously).
65
+ const result = db.prepare(`UPDATE command_queue
66
+ SET claimed_at = :now, claimed_by = :worker_id
67
+ WHERE id = :id AND claimed_at IS NULL AND completed_at IS NULL`).run({ ":now": now, ":worker_id": workerId, ":id": row.id });
68
+ const changes = typeof result.changes === "number"
69
+ ? result.changes
70
+ : 0;
71
+ if (changes !== 1)
72
+ return null; // lost the race
73
+ return { ...row, claimed_at: now, claimed_by: workerId };
74
+ });
75
+ }
76
+ /**
77
+ * Mark a command complete with optional result payload. Idempotent — if
78
+ * the command is already completed, the second call is a no-op.
79
+ */
80
+ export function completeCommand(id, workerId, result) {
81
+ if (!isDbAvailable())
82
+ return;
83
+ const now = new Date().toISOString();
84
+ const db = _getAdapter();
85
+ db.prepare(`UPDATE command_queue
86
+ SET completed_at = :now, result_json = :result_json
87
+ WHERE id = :id
88
+ AND claimed_by = :worker_id
89
+ AND completed_at IS NULL`).run({
90
+ ":id": id,
91
+ ":worker_id": workerId,
92
+ ":now": now,
93
+ ":result_json": result ? JSON.stringify(result) : null,
94
+ });
95
+ }
96
+ /** Diagnostic helper: read a single row by id. */
97
+ export function getCommand(id) {
98
+ if (!isDbAvailable())
99
+ return null;
100
+ const db = _getAdapter();
101
+ const row = db.prepare(`SELECT id, target_worker, command, args_json, enqueued_at,
102
+ claimed_at, claimed_by, completed_at, result_json
103
+ FROM command_queue WHERE id = :id`).get({ ":id": id });
104
+ return row ?? null;
105
+ }
@@ -0,0 +1,210 @@
1
+ // gsd-2 + Milestone leases with fencing tokens (DB-backed coordination, Phase B)
2
+ //
3
+ // One worker at a time may hold a lease on a given milestone. Leases carry a
4
+ // monotonic fencing token that increments on every successful takeover, so
5
+ // stale workers can be cheaply detected and rejected at write time
6
+ // (unit_dispatches.milestone_lease_token).
7
+ //
8
+ // Codex review BLOCKING B1: claim semantics must atomically handle two
9
+ // distinct cases inside one transaction:
10
+ // 1. First claim (no row exists) → INSERT with fencing_token=1
11
+ // 2. Takeover (row exists, expired/released) → UPDATE w/ fencing_token+1
12
+ // `INSERT OR ABORT` alone is wrong because the row already exists for any
13
+ // takeover and a plain INSERT cannot succeed.
14
+ import { randomUUID } from "node:crypto";
15
+ import { _getAdapter, isDbAvailable, transaction, insertAuditEvent, } from "../gsd-db.js";
16
+ const LEASE_TTL_SECONDS = 60;
17
+ function isDuplicateLeaseInsertError(err) {
18
+ const code = err && typeof err === "object" && "code" in err
19
+ ? String(err.code ?? "")
20
+ : "";
21
+ const msg = err instanceof Error ? err.message : String(err);
22
+ if (/\bFOREIGN KEY\b/i.test(msg)) {
23
+ return false;
24
+ }
25
+ if (code === "SQLITE_CONSTRAINT" || code === "SQLITE_CONSTRAINT_PRIMARYKEY" || code === "SQLITE_CONSTRAINT_UNIQUE") {
26
+ return true;
27
+ }
28
+ return /\bUNIQUE\b|\bPRIMARY KEY\b|\bconstraint failed\b/i.test(msg);
29
+ }
30
+ function ttlExpiry(now) {
31
+ return new Date(now.getTime() + LEASE_TTL_SECONDS * 1000).toISOString();
32
+ }
33
+ /**
34
+ * Acquire (or take over an expired) milestone lease for the given worker.
35
+ *
36
+ * Atomicity: the entire claim runs inside a single transaction so the
37
+ * INSERT-vs-UPDATE branch decision can never tear under concurrent claims.
38
+ * Fencing token is computed by SQL (`fencing_token + 1`), never supplied
39
+ * by the client. Initial value is 1.
40
+ *
41
+ * datetime('now') uses local wall-clock time, so this remains single-host
42
+ * SQLite WAL coordination only. Cross-host coordination would need a real
43
+ * coordinator; out of scope for Phase B.
44
+ */
45
+ export function claimMilestoneLease(workerId, milestoneId) {
46
+ if (!isDbAvailable()) {
47
+ throw new Error("claimMilestoneLease: DB unavailable");
48
+ }
49
+ const now = new Date();
50
+ const nowIso = now.toISOString();
51
+ const expiresIso = ttlExpiry(now);
52
+ return transaction(() => {
53
+ const db = _getAdapter();
54
+ // Step 1: try a fresh INSERT. If it fails because the row already
55
+ // exists, fall through to the takeover branch below.
56
+ let inserted = false;
57
+ try {
58
+ db.prepare(`INSERT INTO milestone_leases (
59
+ milestone_id, worker_id, fencing_token,
60
+ acquired_at, expires_at, status
61
+ ) VALUES (
62
+ :milestone_id, :worker_id, 1,
63
+ :acquired_at, :expires_at, 'held'
64
+ )`).run({
65
+ ":milestone_id": milestoneId,
66
+ ":worker_id": workerId,
67
+ ":acquired_at": nowIso,
68
+ ":expires_at": expiresIso,
69
+ });
70
+ inserted = true;
71
+ }
72
+ catch (err) {
73
+ // SQLite raises a constraint error on duplicate PK — catch and fall
74
+ // through to UPDATE. Any other error is a bug; rethrow.
75
+ if (!isDuplicateLeaseInsertError(err))
76
+ throw err;
77
+ }
78
+ if (inserted) {
79
+ insertAuditEvent({
80
+ eventId: randomUUID(),
81
+ traceId: workerId,
82
+ category: "orchestration",
83
+ type: "lease-acquired",
84
+ ts: nowIso,
85
+ payload: { workerId, milestoneId, token: 1, mode: "fresh" },
86
+ });
87
+ return { ok: true, token: 1, expiresAt: expiresIso };
88
+ }
89
+ // Step 2: takeover. Conditional UPDATE — only succeeds if the existing
90
+ // lease is expired or explicitly released. Fencing token is incremented
91
+ // by SQL (`fencing_token + 1`) so the new holder's token monotonically
92
+ // exceeds the prior holder's. db.changes() === 1 confirms the takeover
93
+ // actually happened (vs. losing the race to another worker).
94
+ const updateResult = db.prepare(`UPDATE milestone_leases
95
+ SET worker_id = :worker_id,
96
+ fencing_token = fencing_token + 1,
97
+ acquired_at = :acquired_at,
98
+ expires_at = :expires_at,
99
+ status = 'held'
100
+ WHERE milestone_id = :milestone_id
101
+ AND (status IN ('expired','released')
102
+ OR datetime(expires_at) < datetime('now'))`).run({
103
+ ":milestone_id": milestoneId,
104
+ ":worker_id": workerId,
105
+ ":acquired_at": nowIso,
106
+ ":expires_at": expiresIso,
107
+ });
108
+ const changes = typeof updateResult.changes === "number"
109
+ ? updateResult.changes
110
+ : 0;
111
+ if (changes === 1) {
112
+ // Read back to obtain the new token value.
113
+ const row = db.prepare(`SELECT worker_id, fencing_token, expires_at FROM milestone_leases WHERE milestone_id = :milestone_id`).get({ ":milestone_id": milestoneId });
114
+ const token = row?.fencing_token ?? 1;
115
+ insertAuditEvent({
116
+ eventId: randomUUID(),
117
+ traceId: workerId,
118
+ category: "orchestration",
119
+ type: "lease-acquired",
120
+ ts: nowIso,
121
+ payload: { workerId, milestoneId, token, mode: "takeover" },
122
+ });
123
+ return { ok: true, token, expiresAt: expiresIso };
124
+ }
125
+ // Lease still held by someone else — read current holder for the error.
126
+ const holder = db.prepare(`SELECT worker_id, expires_at FROM milestone_leases WHERE milestone_id = :milestone_id`).get({ ":milestone_id": milestoneId });
127
+ return {
128
+ ok: false,
129
+ error: "held_by",
130
+ byWorker: holder?.worker_id ?? "unknown",
131
+ expiresAt: holder?.expires_at ?? "",
132
+ };
133
+ });
134
+ }
135
+ /**
136
+ * Refresh the lease's expires_at when the worker heartbeats. Idempotent —
137
+ * silently no-ops if the lease was already taken over or released.
138
+ */
139
+ export function refreshMilestoneLease(workerId, milestoneId, fencingToken) {
140
+ if (!isDbAvailable())
141
+ return false;
142
+ const now = new Date();
143
+ const expiresIso = ttlExpiry(now);
144
+ const db = _getAdapter();
145
+ const result = db.prepare(`UPDATE milestone_leases
146
+ SET expires_at = :expires_at
147
+ WHERE milestone_id = :milestone_id
148
+ AND worker_id = :worker_id
149
+ AND fencing_token = :token
150
+ AND status = 'held'`).run({
151
+ ":expires_at": expiresIso,
152
+ ":milestone_id": milestoneId,
153
+ ":worker_id": workerId,
154
+ ":token": fencingToken,
155
+ });
156
+ const changes = typeof result.changes === "number"
157
+ ? result.changes
158
+ : 0;
159
+ return changes === 1;
160
+ }
161
+ /**
162
+ * Voluntarily release the lease (e.g. clean shutdown). Future claims may
163
+ * proceed without waiting for TTL expiry.
164
+ */
165
+ export function releaseMilestoneLease(workerId, milestoneId, fencingToken) {
166
+ if (!isDbAvailable())
167
+ return false;
168
+ const db = _getAdapter();
169
+ return transaction(() => {
170
+ const result = db.prepare(`UPDATE milestone_leases
171
+ SET status = 'released'
172
+ WHERE milestone_id = :milestone_id
173
+ AND worker_id = :worker_id
174
+ AND fencing_token = :token
175
+ AND status = 'held'`).run({
176
+ ":milestone_id": milestoneId,
177
+ ":worker_id": workerId,
178
+ ":token": fencingToken,
179
+ });
180
+ const changes = typeof result.changes === "number"
181
+ ? result.changes
182
+ : 0;
183
+ if (changes === 1) {
184
+ insertAuditEvent({
185
+ eventId: randomUUID(),
186
+ traceId: workerId,
187
+ category: "orchestration",
188
+ type: "lease-released",
189
+ ts: new Date().toISOString(),
190
+ payload: { workerId, milestoneId, token: fencingToken },
191
+ });
192
+ }
193
+ return changes === 1;
194
+ });
195
+ }
196
+ /**
197
+ * Read current lease row for diagnostics. Returns null if no row exists.
198
+ */
199
+ export function getMilestoneLease(milestoneId) {
200
+ if (!isDbAvailable())
201
+ return null;
202
+ const db = _getAdapter();
203
+ const row = db.prepare(`SELECT milestone_id, worker_id, fencing_token, acquired_at, expires_at, status
204
+ FROM milestone_leases WHERE milestone_id = :milestone_id`).get({ ":milestone_id": milestoneId });
205
+ return row ?? null;
206
+ }
207
+ /** TTL exported so callers (e.g. tests / janitors) can compute expirations. */
208
+ export function milestoneLeaseTtlSeconds() {
209
+ return LEASE_TTL_SECONDS;
210
+ }
@@ -0,0 +1,91 @@
1
+ // gsd-2 + Non-correctness-critical key-value storage (Phase C — file-state migration)
2
+ //
3
+ // STRICT INVARIANT (re-stated from gsd-db.ts createRuntimeKvTableV25):
4
+ // runtime_kv is for SOFT state only. UI cursors, dashboard caches,
5
+ // last-seen-version markers, resume cursors, and similar values that
6
+ // can be lost without breaking auto-mode correctness.
7
+ //
8
+ // Anything that drives the auto-loop's control flow MUST get typed
9
+ // columns in unit_dispatches / workers / milestone_leases — never a
10
+ // bag of JSON in runtime_kv. The reviewer's smell test: if losing the
11
+ // row would cause the loop to reorder, double-execute, or stuck-loop,
12
+ // it does NOT belong here.
13
+ //
14
+ // Single-host invariant: SQLite WAL coordination, local disk only.
15
+ // See db/auto-workers.ts for the same constraint applied to coordination.
16
+ import { _getAdapter, isDbAvailable, transaction, } from "../gsd-db.js";
17
+ /**
18
+ * Set or update a runtime_kv row. The value is JSON-stringified before
19
+ * storage. Best-effort — silently no-ops when the DB is unavailable.
20
+ */
21
+ export function setRuntimeKv(scope, scopeId, key, value) {
22
+ if (!isDbAvailable())
23
+ return;
24
+ const now = new Date().toISOString();
25
+ const db = _getAdapter();
26
+ let valueJson;
27
+ try {
28
+ valueJson = JSON.stringify(value);
29
+ }
30
+ catch {
31
+ valueJson = JSON.stringify(String(value));
32
+ }
33
+ if (valueJson === undefined) {
34
+ valueJson = JSON.stringify(null);
35
+ }
36
+ transaction(() => {
37
+ db.prepare(`INSERT INTO runtime_kv (scope, scope_id, key, value_json, updated_at)
38
+ VALUES (:scope, :scope_id, :key, :value_json, :updated_at)
39
+ ON CONFLICT (scope, scope_id, key) DO UPDATE SET
40
+ value_json = excluded.value_json,
41
+ updated_at = excluded.updated_at`).run({
42
+ ":scope": scope,
43
+ ":scope_id": scopeId,
44
+ ":key": key,
45
+ ":value_json": valueJson,
46
+ ":updated_at": now,
47
+ });
48
+ });
49
+ }
50
+ /**
51
+ * Read a runtime_kv value, parsed from JSON. Returns null if the row
52
+ * doesn't exist or the DB is unavailable.
53
+ */
54
+ export function getRuntimeKv(scope, scopeId, key) {
55
+ if (!isDbAvailable())
56
+ return null;
57
+ const db = _getAdapter();
58
+ const row = db.prepare(`SELECT value_json FROM runtime_kv
59
+ WHERE scope = :scope AND scope_id = :scope_id AND key = :key`).get({ ":scope": scope, ":scope_id": scopeId, ":key": key });
60
+ if (!row)
61
+ return null;
62
+ try {
63
+ return JSON.parse(row.value_json);
64
+ }
65
+ catch {
66
+ return null;
67
+ }
68
+ }
69
+ /**
70
+ * Delete a runtime_kv row. Idempotent — silently no-ops when the row
71
+ * doesn't exist or the DB is unavailable.
72
+ */
73
+ export function deleteRuntimeKv(scope, scopeId, key) {
74
+ if (!isDbAvailable())
75
+ return;
76
+ const db = _getAdapter();
77
+ db.prepare(`DELETE FROM runtime_kv WHERE scope = :scope AND scope_id = :scope_id AND key = :key`).run({ ":scope": scope, ":scope_id": scopeId, ":key": key });
78
+ }
79
+ /**
80
+ * List all rows within a (scope, scopeId) bucket. Useful for diagnostics
81
+ * and bulk migrations.
82
+ */
83
+ export function listRuntimeKv(scope, scopeId) {
84
+ if (!isDbAvailable())
85
+ return [];
86
+ const db = _getAdapter();
87
+ return db.prepare(`SELECT scope, scope_id, key, value_json, updated_at
88
+ FROM runtime_kv
89
+ WHERE scope = :scope AND scope_id = :scope_id
90
+ ORDER BY key`).all({ ":scope": scope, ":scope_id": scopeId });
91
+ }