gsd-pi 2.78.1-dev.d8826a445 → 2.78.1-dev.eccf86e27

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (121) hide show
  1. package/README.md +5 -7
  2. package/dist/help-text.js +1 -1
  3. package/dist/resource-loader.js +6 -1
  4. package/dist/resources/.managed-resources-content-hash +1 -1
  5. package/dist/resources/extensions/gsd/auto/detect-stuck.js +41 -5
  6. package/dist/resources/extensions/gsd/auto/loop.js +235 -36
  7. package/dist/resources/extensions/gsd/auto/phases.js +7 -5
  8. package/dist/resources/extensions/gsd/auto/session.js +33 -0
  9. package/dist/resources/extensions/gsd/auto-dispatch.js +46 -2
  10. package/dist/resources/extensions/gsd/auto-post-unit.js +19 -11
  11. package/dist/resources/extensions/gsd/auto-worktree.js +26 -187
  12. package/dist/resources/extensions/gsd/auto.js +79 -50
  13. package/dist/resources/extensions/gsd/bootstrap/register-hooks.js +9 -4
  14. package/dist/resources/extensions/gsd/crash-recovery.js +160 -47
  15. package/dist/resources/extensions/gsd/db/auto-workers.js +227 -0
  16. package/dist/resources/extensions/gsd/db/command-queue.js +105 -0
  17. package/dist/resources/extensions/gsd/db/milestone-leases.js +210 -0
  18. package/dist/resources/extensions/gsd/db/runtime-kv.js +91 -0
  19. package/dist/resources/extensions/gsd/db/unit-dispatches.js +322 -0
  20. package/dist/resources/extensions/gsd/docs/COORDINATION.md +42 -0
  21. package/dist/resources/extensions/gsd/doctor-proactive.js +4 -0
  22. package/dist/resources/extensions/gsd/doctor-runtime-checks.js +22 -6
  23. package/dist/resources/extensions/gsd/doctor.js +12 -2
  24. package/dist/resources/extensions/gsd/gsd-db.js +161 -3
  25. package/dist/resources/extensions/gsd/guided-flow.js +6 -2
  26. package/dist/resources/extensions/gsd/interrupted-session.js +18 -15
  27. package/dist/resources/extensions/gsd/state.js +21 -6
  28. package/dist/resources/extensions/gsd/worktree-resolver.js +64 -0
  29. package/dist/tsconfig.extensions.tsbuildinfo +1 -1
  30. package/dist/web/standalone/.next/BUILD_ID +1 -1
  31. package/dist/web/standalone/.next/app-path-routes-manifest.json +12 -12
  32. package/dist/web/standalone/.next/build-manifest.json +2 -2
  33. package/dist/web/standalone/.next/prerender-manifest.json +3 -3
  34. package/dist/web/standalone/.next/server/app/_global-error.html +1 -1
  35. package/dist/web/standalone/.next/server/app/_global-error.rsc +1 -1
  36. package/dist/web/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  37. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
  38. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error.segment.rsc +1 -1
  39. package/dist/web/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  40. package/dist/web/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  41. package/dist/web/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  42. package/dist/web/standalone/.next/server/app/_not-found.html +1 -1
  43. package/dist/web/standalone/.next/server/app/_not-found.rsc +1 -1
  44. package/dist/web/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
  45. package/dist/web/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
  46. package/dist/web/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
  47. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  48. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  49. package/dist/web/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  50. package/dist/web/standalone/.next/server/app/index.html +1 -1
  51. package/dist/web/standalone/.next/server/app/index.rsc +1 -1
  52. package/dist/web/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +1 -1
  53. package/dist/web/standalone/.next/server/app/index.segments/_full.segment.rsc +1 -1
  54. package/dist/web/standalone/.next/server/app/index.segments/_head.segment.rsc +1 -1
  55. package/dist/web/standalone/.next/server/app/index.segments/_index.segment.rsc +1 -1
  56. package/dist/web/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
  57. package/dist/web/standalone/.next/server/app-paths-manifest.json +12 -12
  58. package/dist/web/standalone/.next/server/middleware-build-manifest.js +1 -1
  59. package/dist/web/standalone/.next/server/pages/404.html +1 -1
  60. package/dist/web/standalone/.next/server/pages/500.html +1 -1
  61. package/dist/web/standalone/.next/server/server-reference-manifest.json +1 -1
  62. package/package.json +1 -1
  63. package/src/resources/extensions/gsd/auto/detect-stuck.ts +37 -5
  64. package/src/resources/extensions/gsd/auto/loop.ts +263 -41
  65. package/src/resources/extensions/gsd/auto/phases.ts +7 -5
  66. package/src/resources/extensions/gsd/auto/session.ts +36 -0
  67. package/src/resources/extensions/gsd/auto-dispatch.ts +53 -2
  68. package/src/resources/extensions/gsd/auto-post-unit.ts +19 -11
  69. package/src/resources/extensions/gsd/auto-worktree.ts +26 -211
  70. package/src/resources/extensions/gsd/auto.ts +89 -44
  71. package/src/resources/extensions/gsd/bootstrap/register-hooks.ts +9 -4
  72. package/src/resources/extensions/gsd/crash-recovery.ts +177 -43
  73. package/src/resources/extensions/gsd/db/auto-workers.ts +273 -0
  74. package/src/resources/extensions/gsd/db/command-queue.ts +149 -0
  75. package/src/resources/extensions/gsd/db/milestone-leases.ts +274 -0
  76. package/src/resources/extensions/gsd/db/runtime-kv.ts +127 -0
  77. package/src/resources/extensions/gsd/db/unit-dispatches.ts +446 -0
  78. package/src/resources/extensions/gsd/docs/COORDINATION.md +42 -0
  79. package/src/resources/extensions/gsd/doctor-proactive.ts +4 -0
  80. package/src/resources/extensions/gsd/doctor-runtime-checks.ts +24 -6
  81. package/src/resources/extensions/gsd/doctor.ts +10 -2
  82. package/src/resources/extensions/gsd/gsd-db.ts +170 -3
  83. package/src/resources/extensions/gsd/guided-flow.ts +6 -2
  84. package/src/resources/extensions/gsd/interrupted-session.ts +19 -12
  85. package/src/resources/extensions/gsd/state.ts +44 -6
  86. package/src/resources/extensions/gsd/tests/auto-loop-no-copy-artifacts.test.ts +72 -0
  87. package/src/resources/extensions/gsd/tests/auto-loop-symlink-worktree.test.ts +190 -0
  88. package/src/resources/extensions/gsd/tests/auto-workers.test.ts +105 -0
  89. package/src/resources/extensions/gsd/tests/command-queue.test.ts +141 -0
  90. package/src/resources/extensions/gsd/tests/crash-recovery-via-db.test.ts +203 -0
  91. package/src/resources/extensions/gsd/tests/crash-recovery.test.ts +169 -59
  92. package/src/resources/extensions/gsd/tests/detect-stuck-respects-retry.test.ts +173 -0
  93. package/src/resources/extensions/gsd/tests/integration/auto-worktree.test.ts +22 -12
  94. package/src/resources/extensions/gsd/tests/integration/doctor-proactive.test.ts +24 -10
  95. package/src/resources/extensions/gsd/tests/integration/doctor-runtime.test.ts +35 -23
  96. package/src/resources/extensions/gsd/tests/integration/workspace-collapse-integration.test.ts +3 -5
  97. package/src/resources/extensions/gsd/tests/interrupted-session-auto.test.ts +72 -25
  98. package/src/resources/extensions/gsd/tests/interrupted-session-ui.test.ts +72 -25
  99. package/src/resources/extensions/gsd/tests/memory-pressure-stuck-state.test.ts +9 -6
  100. package/src/resources/extensions/gsd/tests/milestone-leases.test.ts +152 -0
  101. package/src/resources/extensions/gsd/tests/parallel-milestone-isolation.test.ts +106 -0
  102. package/src/resources/extensions/gsd/tests/paused-session-via-db.test.ts +119 -0
  103. package/src/resources/extensions/gsd/tests/pipeline-variant-dispatch.test.ts +58 -0
  104. package/src/resources/extensions/gsd/tests/preferences-worktree-sync.test.ts +3 -17
  105. package/src/resources/extensions/gsd/tests/register-hooks-depth-verification.test.ts +110 -0
  106. package/src/resources/extensions/gsd/tests/runtime-kv.test.ts +120 -0
  107. package/src/resources/extensions/gsd/tests/skipped-validation-completion.test.ts +133 -28
  108. package/src/resources/extensions/gsd/tests/skipped-validation-db-atomicity.test.ts +17 -0
  109. package/src/resources/extensions/gsd/tests/stuck-state-via-db.test.ts +134 -0
  110. package/src/resources/extensions/gsd/tests/sync-layer-scope.test.ts +7 -26
  111. package/src/resources/extensions/gsd/tests/teardown-cleanup-parity.test.ts +4 -8
  112. package/src/resources/extensions/gsd/tests/unit-dispatches.test.ts +247 -0
  113. package/src/resources/extensions/gsd/tests/validate-milestone.test.ts +41 -1
  114. package/src/resources/extensions/gsd/tests/workspace.test.ts +15 -9
  115. package/src/resources/extensions/gsd/tests/write-gate.test.ts +31 -23
  116. package/src/resources/extensions/gsd/worktree-resolver.ts +62 -0
  117. package/src/resources/extensions/gsd/tests/auto-lock-creation.test.ts +0 -213
  118. package/src/resources/extensions/gsd/tests/auto-stale-lock-self-kill.test.ts +0 -87
  119. package/src/resources/extensions/gsd/tests/stop-auto-remote.test.ts +0 -159
  120. /package/dist/web/standalone/.next/static/{AT5qi39nKXkdmQIOIoh0f → Y5UeGFkXTYM9WIQOWHkot}/_buildManifest.js +0 -0
  121. /package/dist/web/standalone/.next/static/{AT5qi39nKXkdmQIOIoh0f → Y5UeGFkXTYM9WIQOWHkot}/_ssgManifest.js +0 -0
@@ -0,0 +1,149 @@
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
+
16
+ import {
17
+ _getAdapter,
18
+ isDbAvailable,
19
+ transaction,
20
+ } from "../gsd-db.js";
21
+
22
+ export interface CommandQueueRow {
23
+ id: number;
24
+ target_worker: string | null;
25
+ command: string;
26
+ args_json: string;
27
+ enqueued_at: string;
28
+ claimed_at: string | null;
29
+ claimed_by: string | null;
30
+ completed_at: string | null;
31
+ result_json: string | null;
32
+ }
33
+
34
+ export interface EnqueueInput {
35
+ /** null = broadcast to all workers; string = target a specific worker_id */
36
+ targetWorker: string | null;
37
+ command: string;
38
+ args?: Record<string, unknown>;
39
+ }
40
+
41
+ /**
42
+ * Enqueue a command. Returns the new row id. Broadcast commands
43
+ * (targetWorker=null) will be claimed by exactly one worker — the IPC
44
+ * model is "single delivery to whoever claims first", not pub-sub.
45
+ */
46
+ export function enqueueCommand(input: EnqueueInput): number {
47
+ if (!isDbAvailable()) {
48
+ throw new Error("enqueueCommand: DB unavailable");
49
+ }
50
+ const now = new Date().toISOString();
51
+ const db = _getAdapter()!;
52
+ const result = transaction(() => {
53
+ return db.prepare(
54
+ `INSERT INTO command_queue (target_worker, command, args_json, enqueued_at)
55
+ VALUES (:target_worker, :command, :args_json, :enqueued_at)`,
56
+ ).run({
57
+ ":target_worker": input.targetWorker,
58
+ ":command": input.command,
59
+ ":args_json": JSON.stringify(input.args ?? {}),
60
+ ":enqueued_at": now,
61
+ });
62
+ });
63
+ return Number((result as { lastInsertRowid?: number | bigint }).lastInsertRowid ?? 0);
64
+ }
65
+
66
+ /**
67
+ * Atomically claim the next pending command for the given worker. Returns
68
+ * the claimed row, or null if nothing to claim.
69
+ *
70
+ * Polls both targeted (target_worker = workerId) and broadcast
71
+ * (target_worker IS NULL) queues, oldest-first.
72
+ */
73
+ export function claimNextCommand(workerId: string): CommandQueueRow | null {
74
+ if (!isDbAvailable()) return null;
75
+ const now = new Date().toISOString();
76
+ const db = _getAdapter()!;
77
+
78
+ return transaction((): CommandQueueRow | null => {
79
+ // Find the oldest unclaimed command targeted at this worker OR
80
+ // broadcast. The partial index covers both via NULL-in-B-tree.
81
+ const row = db.prepare(
82
+ `SELECT id, target_worker, command, args_json, enqueued_at,
83
+ claimed_at, claimed_by, completed_at, result_json
84
+ FROM command_queue
85
+ WHERE claimed_at IS NULL
86
+ AND completed_at IS NULL
87
+ AND (target_worker = :worker_id OR target_worker IS NULL)
88
+ ORDER BY enqueued_at ASC, id ASC
89
+ LIMIT 1`,
90
+ ).get({ ":worker_id": workerId }) as CommandQueueRow | undefined;
91
+
92
+ if (!row) return null;
93
+
94
+ // Conditional UPDATE — only succeeds if still unclaimed (guards against
95
+ // races between two workers polling simultaneously).
96
+ const result = db.prepare(
97
+ `UPDATE command_queue
98
+ SET claimed_at = :now, claimed_by = :worker_id
99
+ WHERE id = :id AND claimed_at IS NULL AND completed_at IS NULL`,
100
+ ).run({ ":now": now, ":worker_id": workerId, ":id": row.id });
101
+
102
+ const changes =
103
+ typeof (result as { changes?: unknown }).changes === "number"
104
+ ? (result as { changes: number }).changes
105
+ : 0;
106
+
107
+ if (changes !== 1) return null; // lost the race
108
+
109
+ return { ...row, claimed_at: now, claimed_by: workerId };
110
+ });
111
+ }
112
+
113
+ /**
114
+ * Mark a command complete with optional result payload. Idempotent — if
115
+ * the command is already completed, the second call is a no-op.
116
+ */
117
+ export function completeCommand(
118
+ id: number,
119
+ workerId: string,
120
+ result?: Record<string, unknown>,
121
+ ): void {
122
+ if (!isDbAvailable()) return;
123
+ const now = new Date().toISOString();
124
+ const db = _getAdapter()!;
125
+ db.prepare(
126
+ `UPDATE command_queue
127
+ SET completed_at = :now, result_json = :result_json
128
+ WHERE id = :id
129
+ AND claimed_by = :worker_id
130
+ AND completed_at IS NULL`,
131
+ ).run({
132
+ ":id": id,
133
+ ":worker_id": workerId,
134
+ ":now": now,
135
+ ":result_json": result ? JSON.stringify(result) : null,
136
+ });
137
+ }
138
+
139
+ /** Diagnostic helper: read a single row by id. */
140
+ export function getCommand(id: number): CommandQueueRow | null {
141
+ if (!isDbAvailable()) return null;
142
+ const db = _getAdapter()!;
143
+ const row = db.prepare(
144
+ `SELECT id, target_worker, command, args_json, enqueued_at,
145
+ claimed_at, claimed_by, completed_at, result_json
146
+ FROM command_queue WHERE id = :id`,
147
+ ).get({ ":id": id }) as CommandQueueRow | undefined;
148
+ return row ?? null;
149
+ }
@@ -0,0 +1,274 @@
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
+
15
+ import { randomUUID } from "node:crypto";
16
+
17
+ import {
18
+ _getAdapter,
19
+ isDbAvailable,
20
+ transaction,
21
+ insertAuditEvent,
22
+ } from "../gsd-db.js";
23
+
24
+ const LEASE_TTL_SECONDS = 60;
25
+
26
+ export type LeaseStatus = "held" | "released" | "expired";
27
+
28
+ export interface MilestoneLeaseRow {
29
+ milestone_id: string;
30
+ worker_id: string;
31
+ fencing_token: number;
32
+ acquired_at: string;
33
+ expires_at: string;
34
+ status: LeaseStatus;
35
+ }
36
+
37
+ export type ClaimResult =
38
+ | { ok: true; token: number; expiresAt: string }
39
+ | { ok: false; error: "held_by"; byWorker: string; expiresAt: string };
40
+
41
+ function isDuplicateLeaseInsertError(err: unknown): boolean {
42
+ const code =
43
+ err && typeof err === "object" && "code" in err
44
+ ? String((err as { code?: unknown }).code ?? "")
45
+ : "";
46
+ const msg = err instanceof Error ? err.message : String(err);
47
+ if (/\bFOREIGN KEY\b/i.test(msg)) {
48
+ return false;
49
+ }
50
+
51
+ if (code === "SQLITE_CONSTRAINT" || code === "SQLITE_CONSTRAINT_PRIMARYKEY" || code === "SQLITE_CONSTRAINT_UNIQUE") {
52
+ return true;
53
+ }
54
+
55
+ return /\bUNIQUE\b|\bPRIMARY KEY\b|\bconstraint failed\b/i.test(msg);
56
+ }
57
+
58
+ function ttlExpiry(now: Date): string {
59
+ return new Date(now.getTime() + LEASE_TTL_SECONDS * 1000).toISOString();
60
+ }
61
+
62
+ /**
63
+ * Acquire (or take over an expired) milestone lease for the given worker.
64
+ *
65
+ * Atomicity: the entire claim runs inside a single transaction so the
66
+ * INSERT-vs-UPDATE branch decision can never tear under concurrent claims.
67
+ * Fencing token is computed by SQL (`fencing_token + 1`), never supplied
68
+ * by the client. Initial value is 1.
69
+ *
70
+ * datetime('now') uses local wall-clock time, so this remains single-host
71
+ * SQLite WAL coordination only. Cross-host coordination would need a real
72
+ * coordinator; out of scope for Phase B.
73
+ */
74
+ export function claimMilestoneLease(
75
+ workerId: string,
76
+ milestoneId: string,
77
+ ): ClaimResult {
78
+ if (!isDbAvailable()) {
79
+ throw new Error("claimMilestoneLease: DB unavailable");
80
+ }
81
+ const now = new Date();
82
+ const nowIso = now.toISOString();
83
+ const expiresIso = ttlExpiry(now);
84
+
85
+ return transaction((): ClaimResult => {
86
+ const db = _getAdapter()!;
87
+
88
+ // Step 1: try a fresh INSERT. If it fails because the row already
89
+ // exists, fall through to the takeover branch below.
90
+ let inserted = false;
91
+ try {
92
+ db.prepare(
93
+ `INSERT INTO milestone_leases (
94
+ milestone_id, worker_id, fencing_token,
95
+ acquired_at, expires_at, status
96
+ ) VALUES (
97
+ :milestone_id, :worker_id, 1,
98
+ :acquired_at, :expires_at, 'held'
99
+ )`,
100
+ ).run({
101
+ ":milestone_id": milestoneId,
102
+ ":worker_id": workerId,
103
+ ":acquired_at": nowIso,
104
+ ":expires_at": expiresIso,
105
+ });
106
+ inserted = true;
107
+ } catch (err) {
108
+ // SQLite raises a constraint error on duplicate PK — catch and fall
109
+ // through to UPDATE. Any other error is a bug; rethrow.
110
+ if (!isDuplicateLeaseInsertError(err)) throw err;
111
+ }
112
+
113
+ if (inserted) {
114
+ insertAuditEvent({
115
+ eventId: randomUUID(),
116
+ traceId: workerId,
117
+ category: "orchestration",
118
+ type: "lease-acquired",
119
+ ts: nowIso,
120
+ payload: { workerId, milestoneId, token: 1, mode: "fresh" },
121
+ });
122
+ return { ok: true, token: 1, expiresAt: expiresIso };
123
+ }
124
+
125
+ // Step 2: takeover. Conditional UPDATE — only succeeds if the existing
126
+ // lease is expired or explicitly released. Fencing token is incremented
127
+ // by SQL (`fencing_token + 1`) so the new holder's token monotonically
128
+ // exceeds the prior holder's. db.changes() === 1 confirms the takeover
129
+ // actually happened (vs. losing the race to another worker).
130
+ const updateResult = db.prepare(
131
+ `UPDATE milestone_leases
132
+ SET worker_id = :worker_id,
133
+ fencing_token = fencing_token + 1,
134
+ acquired_at = :acquired_at,
135
+ expires_at = :expires_at,
136
+ status = 'held'
137
+ WHERE milestone_id = :milestone_id
138
+ AND (status IN ('expired','released')
139
+ OR datetime(expires_at) < datetime('now'))`,
140
+ ).run({
141
+ ":milestone_id": milestoneId,
142
+ ":worker_id": workerId,
143
+ ":acquired_at": nowIso,
144
+ ":expires_at": expiresIso,
145
+ });
146
+
147
+ const changes =
148
+ typeof (updateResult as { changes?: unknown }).changes === "number"
149
+ ? (updateResult as { changes: number }).changes
150
+ : 0;
151
+
152
+ if (changes === 1) {
153
+ // Read back to obtain the new token value.
154
+ const row = db.prepare(
155
+ `SELECT worker_id, fencing_token, expires_at FROM milestone_leases WHERE milestone_id = :milestone_id`,
156
+ ).get({ ":milestone_id": milestoneId }) as Pick<MilestoneLeaseRow, "worker_id" | "fencing_token" | "expires_at"> | undefined;
157
+ const token = row?.fencing_token ?? 1;
158
+ insertAuditEvent({
159
+ eventId: randomUUID(),
160
+ traceId: workerId,
161
+ category: "orchestration",
162
+ type: "lease-acquired",
163
+ ts: nowIso,
164
+ payload: { workerId, milestoneId, token, mode: "takeover" },
165
+ });
166
+ return { ok: true, token, expiresAt: expiresIso };
167
+ }
168
+
169
+ // Lease still held by someone else — read current holder for the error.
170
+ const holder = db.prepare(
171
+ `SELECT worker_id, expires_at FROM milestone_leases WHERE milestone_id = :milestone_id`,
172
+ ).get({ ":milestone_id": milestoneId }) as { worker_id: string; expires_at: string } | undefined;
173
+
174
+ return {
175
+ ok: false,
176
+ error: "held_by",
177
+ byWorker: holder?.worker_id ?? "unknown",
178
+ expiresAt: holder?.expires_at ?? "",
179
+ };
180
+ });
181
+ }
182
+
183
+ /**
184
+ * Refresh the lease's expires_at when the worker heartbeats. Idempotent —
185
+ * silently no-ops if the lease was already taken over or released.
186
+ */
187
+ export function refreshMilestoneLease(
188
+ workerId: string,
189
+ milestoneId: string,
190
+ fencingToken: number,
191
+ ): boolean {
192
+ if (!isDbAvailable()) return false;
193
+ const now = new Date();
194
+ const expiresIso = ttlExpiry(now);
195
+ const db = _getAdapter()!;
196
+ const result = db.prepare(
197
+ `UPDATE milestone_leases
198
+ SET expires_at = :expires_at
199
+ WHERE milestone_id = :milestone_id
200
+ AND worker_id = :worker_id
201
+ AND fencing_token = :token
202
+ AND status = 'held'`,
203
+ ).run({
204
+ ":expires_at": expiresIso,
205
+ ":milestone_id": milestoneId,
206
+ ":worker_id": workerId,
207
+ ":token": fencingToken,
208
+ });
209
+ const changes =
210
+ typeof (result as { changes?: unknown }).changes === "number"
211
+ ? (result as { changes: number }).changes
212
+ : 0;
213
+ return changes === 1;
214
+ }
215
+
216
+ /**
217
+ * Voluntarily release the lease (e.g. clean shutdown). Future claims may
218
+ * proceed without waiting for TTL expiry.
219
+ */
220
+ export function releaseMilestoneLease(
221
+ workerId: string,
222
+ milestoneId: string,
223
+ fencingToken: number,
224
+ ): boolean {
225
+ if (!isDbAvailable()) return false;
226
+ const db = _getAdapter()!;
227
+ return transaction(() => {
228
+ const result = db.prepare(
229
+ `UPDATE milestone_leases
230
+ SET status = 'released'
231
+ WHERE milestone_id = :milestone_id
232
+ AND worker_id = :worker_id
233
+ AND fencing_token = :token
234
+ AND status = 'held'`,
235
+ ).run({
236
+ ":milestone_id": milestoneId,
237
+ ":worker_id": workerId,
238
+ ":token": fencingToken,
239
+ });
240
+ const changes =
241
+ typeof (result as { changes?: unknown }).changes === "number"
242
+ ? (result as { changes: number }).changes
243
+ : 0;
244
+ if (changes === 1) {
245
+ insertAuditEvent({
246
+ eventId: randomUUID(),
247
+ traceId: workerId,
248
+ category: "orchestration",
249
+ type: "lease-released",
250
+ ts: new Date().toISOString(),
251
+ payload: { workerId, milestoneId, token: fencingToken },
252
+ });
253
+ }
254
+ return changes === 1;
255
+ });
256
+ }
257
+
258
+ /**
259
+ * Read current lease row for diagnostics. Returns null if no row exists.
260
+ */
261
+ export function getMilestoneLease(milestoneId: string): MilestoneLeaseRow | null {
262
+ if (!isDbAvailable()) return null;
263
+ const db = _getAdapter()!;
264
+ const row = db.prepare(
265
+ `SELECT milestone_id, worker_id, fencing_token, acquired_at, expires_at, status
266
+ FROM milestone_leases WHERE milestone_id = :milestone_id`,
267
+ ).get({ ":milestone_id": milestoneId }) as MilestoneLeaseRow | undefined;
268
+ return row ?? null;
269
+ }
270
+
271
+ /** TTL exported so callers (e.g. tests / janitors) can compute expirations. */
272
+ export function milestoneLeaseTtlSeconds(): number {
273
+ return LEASE_TTL_SECONDS;
274
+ }
@@ -0,0 +1,127 @@
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
+
17
+ import {
18
+ _getAdapter,
19
+ isDbAvailable,
20
+ transaction,
21
+ } from "../gsd-db.js";
22
+
23
+ export type RuntimeKvScope = "global" | "worker" | "milestone";
24
+
25
+ export interface RuntimeKvRow {
26
+ scope: RuntimeKvScope;
27
+ scope_id: string;
28
+ key: string;
29
+ value_json: string;
30
+ updated_at: string;
31
+ }
32
+
33
+ /**
34
+ * Set or update a runtime_kv row. The value is JSON-stringified before
35
+ * storage. Best-effort — silently no-ops when the DB is unavailable.
36
+ */
37
+ export function setRuntimeKv(
38
+ scope: RuntimeKvScope,
39
+ scopeId: string,
40
+ key: string,
41
+ value: unknown,
42
+ ): void {
43
+ if (!isDbAvailable()) return;
44
+ const now = new Date().toISOString();
45
+ const db = _getAdapter()!;
46
+ let valueJson: string;
47
+ try {
48
+ valueJson = JSON.stringify(value);
49
+ } catch {
50
+ valueJson = JSON.stringify(String(value));
51
+ }
52
+ if (valueJson === undefined) {
53
+ valueJson = JSON.stringify(null);
54
+ }
55
+ transaction(() => {
56
+ db.prepare(
57
+ `INSERT INTO runtime_kv (scope, scope_id, key, value_json, updated_at)
58
+ VALUES (:scope, :scope_id, :key, :value_json, :updated_at)
59
+ ON CONFLICT (scope, scope_id, key) DO UPDATE SET
60
+ value_json = excluded.value_json,
61
+ updated_at = excluded.updated_at`,
62
+ ).run({
63
+ ":scope": scope,
64
+ ":scope_id": scopeId,
65
+ ":key": key,
66
+ ":value_json": valueJson,
67
+ ":updated_at": now,
68
+ });
69
+ });
70
+ }
71
+
72
+ /**
73
+ * Read a runtime_kv value, parsed from JSON. Returns null if the row
74
+ * doesn't exist or the DB is unavailable.
75
+ */
76
+ export function getRuntimeKv<T = unknown>(
77
+ scope: RuntimeKvScope,
78
+ scopeId: string,
79
+ key: string,
80
+ ): T | null {
81
+ if (!isDbAvailable()) return null;
82
+ const db = _getAdapter()!;
83
+ const row = db.prepare(
84
+ `SELECT value_json FROM runtime_kv
85
+ WHERE scope = :scope AND scope_id = :scope_id AND key = :key`,
86
+ ).get({ ":scope": scope, ":scope_id": scopeId, ":key": key }) as { value_json: string } | undefined;
87
+ if (!row) return null;
88
+ try {
89
+ return JSON.parse(row.value_json) as T;
90
+ } catch {
91
+ return null;
92
+ }
93
+ }
94
+
95
+ /**
96
+ * Delete a runtime_kv row. Idempotent — silently no-ops when the row
97
+ * doesn't exist or the DB is unavailable.
98
+ */
99
+ export function deleteRuntimeKv(
100
+ scope: RuntimeKvScope,
101
+ scopeId: string,
102
+ key: string,
103
+ ): void {
104
+ if (!isDbAvailable()) return;
105
+ const db = _getAdapter()!;
106
+ db.prepare(
107
+ `DELETE FROM runtime_kv WHERE scope = :scope AND scope_id = :scope_id AND key = :key`,
108
+ ).run({ ":scope": scope, ":scope_id": scopeId, ":key": key });
109
+ }
110
+
111
+ /**
112
+ * List all rows within a (scope, scopeId) bucket. Useful for diagnostics
113
+ * and bulk migrations.
114
+ */
115
+ export function listRuntimeKv(
116
+ scope: RuntimeKvScope,
117
+ scopeId: string,
118
+ ): readonly RuntimeKvRow[] {
119
+ if (!isDbAvailable()) return [];
120
+ const db = _getAdapter()!;
121
+ return db.prepare(
122
+ `SELECT scope, scope_id, key, value_json, updated_at
123
+ FROM runtime_kv
124
+ WHERE scope = :scope AND scope_id = :scope_id
125
+ ORDER BY key`,
126
+ ).all({ ":scope": scope, ":scope_id": scopeId }) as unknown as RuntimeKvRow[];
127
+ }