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.
- package/README.md +5 -7
- package/dist/help-text.js +1 -1
- package/dist/resource-loader.js +6 -1
- package/dist/resources/.managed-resources-content-hash +1 -1
- package/dist/resources/extensions/gsd/auto/detect-stuck.js +41 -5
- package/dist/resources/extensions/gsd/auto/loop.js +235 -36
- package/dist/resources/extensions/gsd/auto/phases.js +7 -5
- package/dist/resources/extensions/gsd/auto/session.js +33 -0
- package/dist/resources/extensions/gsd/auto-dispatch.js +46 -2
- package/dist/resources/extensions/gsd/auto-post-unit.js +19 -11
- package/dist/resources/extensions/gsd/auto-worktree.js +26 -187
- package/dist/resources/extensions/gsd/auto.js +79 -50
- package/dist/resources/extensions/gsd/bootstrap/register-hooks.js +9 -4
- package/dist/resources/extensions/gsd/crash-recovery.js +160 -47
- package/dist/resources/extensions/gsd/db/auto-workers.js +227 -0
- package/dist/resources/extensions/gsd/db/command-queue.js +105 -0
- package/dist/resources/extensions/gsd/db/milestone-leases.js +210 -0
- package/dist/resources/extensions/gsd/db/runtime-kv.js +91 -0
- package/dist/resources/extensions/gsd/db/unit-dispatches.js +322 -0
- package/dist/resources/extensions/gsd/docs/COORDINATION.md +42 -0
- package/dist/resources/extensions/gsd/doctor-proactive.js +4 -0
- package/dist/resources/extensions/gsd/doctor-runtime-checks.js +22 -6
- package/dist/resources/extensions/gsd/doctor.js +12 -2
- package/dist/resources/extensions/gsd/gsd-db.js +161 -3
- package/dist/resources/extensions/gsd/guided-flow.js +6 -2
- package/dist/resources/extensions/gsd/interrupted-session.js +18 -15
- package/dist/resources/extensions/gsd/state.js +21 -6
- package/dist/resources/extensions/gsd/worktree-resolver.js +64 -0
- package/dist/tsconfig.extensions.tsbuildinfo +1 -1
- package/dist/web/standalone/.next/BUILD_ID +1 -1
- package/dist/web/standalone/.next/app-path-routes-manifest.json +12 -12
- package/dist/web/standalone/.next/build-manifest.json +2 -2
- package/dist/web/standalone/.next/prerender-manifest.json +3 -3
- package/dist/web/standalone/.next/server/app/_global-error.html +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.html +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/index.html +1 -1
- package/dist/web/standalone/.next/server/app/index.rsc +1 -1
- package/dist/web/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/index.segments/_full.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/index.segments/_head.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/index.segments/_index.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app-paths-manifest.json +12 -12
- package/dist/web/standalone/.next/server/middleware-build-manifest.js +1 -1
- package/dist/web/standalone/.next/server/pages/404.html +1 -1
- package/dist/web/standalone/.next/server/pages/500.html +1 -1
- package/dist/web/standalone/.next/server/server-reference-manifest.json +1 -1
- package/package.json +1 -1
- package/src/resources/extensions/gsd/auto/detect-stuck.ts +37 -5
- package/src/resources/extensions/gsd/auto/loop.ts +263 -41
- package/src/resources/extensions/gsd/auto/phases.ts +7 -5
- package/src/resources/extensions/gsd/auto/session.ts +36 -0
- package/src/resources/extensions/gsd/auto-dispatch.ts +53 -2
- package/src/resources/extensions/gsd/auto-post-unit.ts +19 -11
- package/src/resources/extensions/gsd/auto-worktree.ts +26 -211
- package/src/resources/extensions/gsd/auto.ts +89 -44
- package/src/resources/extensions/gsd/bootstrap/register-hooks.ts +9 -4
- package/src/resources/extensions/gsd/crash-recovery.ts +177 -43
- package/src/resources/extensions/gsd/db/auto-workers.ts +273 -0
- package/src/resources/extensions/gsd/db/command-queue.ts +149 -0
- package/src/resources/extensions/gsd/db/milestone-leases.ts +274 -0
- package/src/resources/extensions/gsd/db/runtime-kv.ts +127 -0
- package/src/resources/extensions/gsd/db/unit-dispatches.ts +446 -0
- package/src/resources/extensions/gsd/docs/COORDINATION.md +42 -0
- package/src/resources/extensions/gsd/doctor-proactive.ts +4 -0
- package/src/resources/extensions/gsd/doctor-runtime-checks.ts +24 -6
- package/src/resources/extensions/gsd/doctor.ts +10 -2
- package/src/resources/extensions/gsd/gsd-db.ts +170 -3
- package/src/resources/extensions/gsd/guided-flow.ts +6 -2
- package/src/resources/extensions/gsd/interrupted-session.ts +19 -12
- package/src/resources/extensions/gsd/state.ts +44 -6
- package/src/resources/extensions/gsd/tests/auto-loop-no-copy-artifacts.test.ts +72 -0
- package/src/resources/extensions/gsd/tests/auto-loop-symlink-worktree.test.ts +190 -0
- package/src/resources/extensions/gsd/tests/auto-workers.test.ts +105 -0
- package/src/resources/extensions/gsd/tests/command-queue.test.ts +141 -0
- package/src/resources/extensions/gsd/tests/crash-recovery-via-db.test.ts +203 -0
- package/src/resources/extensions/gsd/tests/crash-recovery.test.ts +169 -59
- package/src/resources/extensions/gsd/tests/detect-stuck-respects-retry.test.ts +173 -0
- package/src/resources/extensions/gsd/tests/integration/auto-worktree.test.ts +22 -12
- package/src/resources/extensions/gsd/tests/integration/doctor-proactive.test.ts +24 -10
- package/src/resources/extensions/gsd/tests/integration/doctor-runtime.test.ts +35 -23
- package/src/resources/extensions/gsd/tests/integration/workspace-collapse-integration.test.ts +3 -5
- package/src/resources/extensions/gsd/tests/interrupted-session-auto.test.ts +72 -25
- package/src/resources/extensions/gsd/tests/interrupted-session-ui.test.ts +72 -25
- package/src/resources/extensions/gsd/tests/memory-pressure-stuck-state.test.ts +9 -6
- package/src/resources/extensions/gsd/tests/milestone-leases.test.ts +152 -0
- package/src/resources/extensions/gsd/tests/parallel-milestone-isolation.test.ts +106 -0
- package/src/resources/extensions/gsd/tests/paused-session-via-db.test.ts +119 -0
- package/src/resources/extensions/gsd/tests/pipeline-variant-dispatch.test.ts +58 -0
- package/src/resources/extensions/gsd/tests/preferences-worktree-sync.test.ts +3 -17
- package/src/resources/extensions/gsd/tests/register-hooks-depth-verification.test.ts +110 -0
- package/src/resources/extensions/gsd/tests/runtime-kv.test.ts +120 -0
- package/src/resources/extensions/gsd/tests/skipped-validation-completion.test.ts +133 -28
- package/src/resources/extensions/gsd/tests/skipped-validation-db-atomicity.test.ts +17 -0
- package/src/resources/extensions/gsd/tests/stuck-state-via-db.test.ts +134 -0
- package/src/resources/extensions/gsd/tests/sync-layer-scope.test.ts +7 -26
- package/src/resources/extensions/gsd/tests/teardown-cleanup-parity.test.ts +4 -8
- package/src/resources/extensions/gsd/tests/unit-dispatches.test.ts +247 -0
- package/src/resources/extensions/gsd/tests/validate-milestone.test.ts +41 -1
- package/src/resources/extensions/gsd/tests/workspace.test.ts +15 -9
- package/src/resources/extensions/gsd/tests/write-gate.test.ts +31 -23
- package/src/resources/extensions/gsd/worktree-resolver.ts +62 -0
- package/src/resources/extensions/gsd/tests/auto-lock-creation.test.ts +0 -213
- package/src/resources/extensions/gsd/tests/auto-stale-lock-self-kill.test.ts +0 -87
- package/src/resources/extensions/gsd/tests/stop-auto-remote.test.ts +0 -159
- /package/dist/web/standalone/.next/static/{AT5qi39nKXkdmQIOIoh0f → Y5UeGFkXTYM9WIQOWHkot}/_buildManifest.js +0 -0
- /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
|
+
}
|