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,105 @@
1
+ // gsd-2 + Auto-mode worker registry tests (Phase B coordination)
2
+
3
+ import test from "node:test";
4
+ import assert from "node:assert/strict";
5
+ import { mkdtempSync, mkdirSync, rmSync } from "node:fs";
6
+ import { join } from "node:path";
7
+ import { tmpdir } from "node:os";
8
+
9
+ import { openDatabase, closeDatabase } from "../gsd-db.ts";
10
+ import { _getAdapter } from "../gsd-db.ts";
11
+ import {
12
+ registerAutoWorker,
13
+ heartbeatAutoWorker,
14
+ markWorkerCrashed,
15
+ markWorkerStopping,
16
+ getActiveAutoWorkers,
17
+ getAutoWorker,
18
+ } from "../db/auto-workers.ts";
19
+
20
+ function makeBase(): string {
21
+ const base = mkdtempSync(join(tmpdir(), "gsd-auto-workers-"));
22
+ mkdirSync(join(base, ".gsd"), { recursive: true });
23
+ return base;
24
+ }
25
+
26
+ function cleanup(base: string): void {
27
+ try { closeDatabase(); } catch { /* noop */ }
28
+ try { rmSync(base, { recursive: true, force: true }); } catch { /* noop */ }
29
+ }
30
+
31
+ test("registerAutoWorker creates a row with active status and heartbeat", (t) => {
32
+ const base = makeBase();
33
+ t.after(() => cleanup(base));
34
+ openDatabase(join(base, ".gsd", "gsd.db"));
35
+
36
+ const id = registerAutoWorker({ projectRootRealpath: base });
37
+ assert.match(id, /^auto-/, "worker_id has expected prefix");
38
+
39
+ const row = getAutoWorker(id);
40
+ assert.ok(row, "row exists");
41
+ assert.equal(row!.status, "active");
42
+ assert.equal(row!.project_root_realpath, base);
43
+ assert.equal(row!.pid, process.pid);
44
+ });
45
+
46
+ test("heartbeatAutoWorker updates last_heartbeat_at", async (t) => {
47
+ const base = makeBase();
48
+ t.after(() => cleanup(base));
49
+ openDatabase(join(base, ".gsd", "gsd.db"));
50
+
51
+ const id = registerAutoWorker({ projectRootRealpath: base });
52
+ const initial = getAutoWorker(id)!;
53
+ await new Promise(r => setTimeout(r, 10));
54
+ heartbeatAutoWorker(id);
55
+ const after = getAutoWorker(id)!;
56
+ const initialTs = Date.parse(initial.last_heartbeat_at);
57
+ const afterTs = Date.parse(after.last_heartbeat_at);
58
+ assert.ok(Number.isFinite(initialTs), "initial heartbeat parses");
59
+ assert.ok(Number.isFinite(afterTs), "updated heartbeat parses");
60
+ assert.ok(afterTs > initialTs, "heartbeat advanced");
61
+ });
62
+
63
+ test("markWorkerStopping flips status to stopping", (t) => {
64
+ const base = makeBase();
65
+ t.after(() => cleanup(base));
66
+ openDatabase(join(base, ".gsd", "gsd.db"));
67
+
68
+ const id = registerAutoWorker({ projectRootRealpath: base });
69
+ markWorkerStopping(id);
70
+ const row = getAutoWorker(id)!;
71
+ assert.equal(row.status, "stopping");
72
+ });
73
+
74
+ test("markWorkerCrashed flips status to crashed", (t) => {
75
+ const base = makeBase();
76
+ t.after(() => cleanup(base));
77
+ openDatabase(join(base, ".gsd", "gsd.db"));
78
+
79
+ const id = registerAutoWorker({ projectRootRealpath: base });
80
+ markWorkerCrashed(id);
81
+ const row = getAutoWorker(id)!;
82
+ assert.equal(row.status, "crashed");
83
+ });
84
+
85
+ test("getActiveAutoWorkers filters by status and TTL", (t) => {
86
+ const base = makeBase();
87
+ t.after(() => cleanup(base));
88
+ openDatabase(join(base, ".gsd", "gsd.db"));
89
+
90
+ const a = registerAutoWorker({ projectRootRealpath: base });
91
+ const b = registerAutoWorker({ projectRootRealpath: base });
92
+
93
+ const active = getActiveAutoWorkers();
94
+ assert.equal(active.length, 2);
95
+ assert.ok(active.find(w => w.worker_id === a));
96
+ assert.ok(active.find(w => w.worker_id === b));
97
+
98
+ _getAdapter()!.prepare(
99
+ `UPDATE workers SET last_heartbeat_at = '1970-01-01T00:00:00.000Z' WHERE worker_id = :worker_id`,
100
+ ).run({ ":worker_id": a });
101
+
102
+ const after = getActiveAutoWorkers();
103
+ assert.equal(after.length, 1);
104
+ assert.equal(after[0].worker_id, b);
105
+ });
@@ -0,0 +1,141 @@
1
+ // gsd-2 + Command queue tests (Phase B coordination — IPC inbox + broadcast NULL semantics)
2
+
3
+ import test from "node:test";
4
+ import assert from "node:assert/strict";
5
+ import { mkdtempSync, mkdirSync, rmSync } from "node:fs";
6
+ import { join } from "node:path";
7
+ import { tmpdir } from "node:os";
8
+
9
+ import { openDatabase, closeDatabase } from "../gsd-db.ts";
10
+ import {
11
+ enqueueCommand,
12
+ claimNextCommand,
13
+ completeCommand,
14
+ getCommand,
15
+ } from "../db/command-queue.ts";
16
+
17
+ function makeBase(): string {
18
+ const base = mkdtempSync(join(tmpdir(), "gsd-cmd-q-"));
19
+ mkdirSync(join(base, ".gsd"), { recursive: true });
20
+ return base;
21
+ }
22
+
23
+ function cleanup(base: string): void {
24
+ try { closeDatabase(); } catch { /* noop */ }
25
+ try { rmSync(base, { recursive: true, force: true }); } catch { /* noop */ }
26
+ }
27
+
28
+ test("enqueue + claim + complete round-trip for targeted command", (t) => {
29
+ const base = makeBase();
30
+ t.after(() => cleanup(base));
31
+ openDatabase(join(base, ".gsd", "gsd.db"));
32
+
33
+ const id = enqueueCommand({
34
+ targetWorker: "worker-A",
35
+ command: "cancel",
36
+ args: { reason: "user-request" },
37
+ });
38
+ assert.ok(id > 0);
39
+
40
+ const claimed = claimNextCommand("worker-A");
41
+ assert.ok(claimed);
42
+ assert.equal(claimed!.id, id);
43
+ assert.equal(claimed!.command, "cancel");
44
+ assert.equal(claimed!.claimed_by, "worker-A");
45
+ assert.ok(claimed!.claimed_at);
46
+
47
+ completeCommand(id, "worker-A", { acknowledged: true });
48
+ const final = getCommand(id);
49
+ assert.ok(final!.completed_at);
50
+ assert.equal(final!.result_json, JSON.stringify({ acknowledged: true }));
51
+ });
52
+
53
+ test("targeted command is invisible to other workers", (t) => {
54
+ const base = makeBase();
55
+ t.after(() => cleanup(base));
56
+ openDatabase(join(base, ".gsd", "gsd.db"));
57
+
58
+ enqueueCommand({ targetWorker: "worker-A", command: "for-A" });
59
+ const wrong = claimNextCommand("worker-B");
60
+ assert.equal(wrong, null, "worker-B sees nothing for worker-A");
61
+
62
+ const right = claimNextCommand("worker-A");
63
+ assert.ok(right);
64
+ assert.equal(right!.command, "for-A");
65
+ });
66
+
67
+ test("broadcast command (target=null) is visible to ANY worker, claimed exactly once", (t) => {
68
+ const base = makeBase();
69
+ t.after(() => cleanup(base));
70
+ openDatabase(join(base, ".gsd", "gsd.db"));
71
+
72
+ enqueueCommand({ targetWorker: null, command: "broadcast-cancel" });
73
+
74
+ const a = claimNextCommand("worker-A");
75
+ assert.ok(a, "first poller wins");
76
+ assert.equal(a!.command, "broadcast-cancel");
77
+
78
+ // Second poller (different worker) sees nothing — broadcast is single-delivery
79
+ const b = claimNextCommand("worker-B");
80
+ assert.equal(b, null);
81
+ });
82
+
83
+ test("oldest-first ordering across mixed targeted + broadcast queue", (t) => {
84
+ const base = makeBase();
85
+ t.after(() => cleanup(base));
86
+ openDatabase(join(base, ".gsd", "gsd.db"));
87
+
88
+ enqueueCommand({ targetWorker: null, command: "first" });
89
+ enqueueCommand({ targetWorker: "worker-A", command: "second" });
90
+ enqueueCommand({ targetWorker: null, command: "third" });
91
+
92
+ const c1 = claimNextCommand("worker-A")!;
93
+ const c2 = claimNextCommand("worker-A")!;
94
+ const c3 = claimNextCommand("worker-A")!;
95
+ assert.equal(c1.command, "first");
96
+ assert.equal(c2.command, "second");
97
+ assert.equal(c3.command, "third");
98
+ assert.equal(claimNextCommand("worker-A"), null);
99
+ });
100
+
101
+ test("completeCommand is idempotent — second call does not overwrite", (t) => {
102
+ const base = makeBase();
103
+ t.after(() => cleanup(base));
104
+ openDatabase(join(base, ".gsd", "gsd.db"));
105
+
106
+ const id = enqueueCommand({ targetWorker: "w", command: "x" });
107
+ claimNextCommand("w");
108
+ completeCommand(id, "w", { result: 1 });
109
+ completeCommand(id, "w", { result: 2 }); // second call should no-op
110
+ const row = getCommand(id)!;
111
+ assert.equal(row.result_json, JSON.stringify({ result: 1 }));
112
+ });
113
+
114
+ test("completed commands cannot be reclaimed or completed by a different worker", (t) => {
115
+ const base = makeBase();
116
+ t.after(() => cleanup(base));
117
+ openDatabase(join(base, ".gsd", "gsd.db"));
118
+
119
+ const id = enqueueCommand({ targetWorker: "worker-A", command: "x" });
120
+ const claimed = claimNextCommand("worker-A");
121
+ assert.ok(claimed);
122
+
123
+ completeCommand(id, "worker-A", { result: 1 });
124
+ completeCommand(id, "worker-B", { result: 2 });
125
+
126
+ assert.equal(claimNextCommand("worker-A"), null);
127
+ const row = getCommand(id)!;
128
+ assert.equal(row.result_json, JSON.stringify({ result: 1 }));
129
+ });
130
+
131
+ test("completeCommand does not complete an unclaimed command", (t) => {
132
+ const base = makeBase();
133
+ t.after(() => cleanup(base));
134
+ openDatabase(join(base, ".gsd", "gsd.db"));
135
+
136
+ const id = enqueueCommand({ targetWorker: "w", command: "x" });
137
+ completeCommand(id, "w", { result: 1 });
138
+ const row = getCommand(id)!;
139
+ assert.equal(row.completed_at, null);
140
+ assert.equal(row.result_json, null);
141
+ });
@@ -0,0 +1,203 @@
1
+ // gsd-2 + Crash recovery via DB (Phase C pt 2 — auto.lock migration)
2
+ //
3
+ // auto.lock file IO is gone. readCrashLock now synthesizes a LockData
4
+ // from the workers + unit_dispatches + runtime_kv tables. These tests
5
+ // verify the synthesis end-to-end: register a worker, simulate it going
6
+ // stale (heartbeat lapsed), and confirm readCrashLock returns the
7
+ // correct LockData with PID, started_at, unit details, and session
8
+ // file derived from the DB.
9
+
10
+ import test from "node:test";
11
+ import assert from "node:assert/strict";
12
+ import { mkdtempSync, mkdirSync, rmSync } from "node:fs";
13
+ import { join } from "node:path";
14
+ import { tmpdir } from "node:os";
15
+
16
+ import {
17
+ openDatabase,
18
+ closeDatabase,
19
+ insertMilestone,
20
+ _getAdapter,
21
+ } from "../gsd-db.ts";
22
+ import { registerAutoWorker } from "../db/auto-workers.ts";
23
+ import { claimMilestoneLease } from "../db/milestone-leases.ts";
24
+ import { recordDispatchClaim } from "../db/unit-dispatches.ts";
25
+ import { setRuntimeKv, getRuntimeKv } from "../db/runtime-kv.ts";
26
+ import {
27
+ writeLock,
28
+ readCrashLock,
29
+ clearLock,
30
+ isLockProcessAlive,
31
+ } from "../crash-recovery.ts";
32
+ import { normalizeRealPath } from "../paths.ts";
33
+
34
+ function makeBase(): string {
35
+ const base = mkdtempSync(join(tmpdir(), "gsd-crash-recovery-"));
36
+ mkdirSync(join(base, ".gsd"), { recursive: true });
37
+ return base;
38
+ }
39
+
40
+ function cleanup(base: string): void {
41
+ try { closeDatabase(); } catch { /* noop */ }
42
+ try { rmSync(base, { recursive: true, force: true }); } catch { /* noop */ }
43
+ }
44
+
45
+ /** Force a worker's last_heartbeat_at into the past so the stale-detector picks it up. */
46
+ function expireWorker(workerId: string): void {
47
+ const db = _getAdapter()!;
48
+ db.prepare(
49
+ `UPDATE workers SET last_heartbeat_at = '1970-01-01T00:00:00.000Z' WHERE worker_id = :w`,
50
+ ).run({ ":w": workerId });
51
+ }
52
+
53
+ function setWorkerPid(workerId: string, pid: number): void {
54
+ const db = _getAdapter()!;
55
+ db.prepare(
56
+ `UPDATE workers SET pid = :pid WHERE worker_id = :w`,
57
+ ).run({ ":pid": pid, ":w": workerId });
58
+ }
59
+
60
+ test("readCrashLock returns null when no workers exist", (t) => {
61
+ const base = makeBase();
62
+ t.after(() => cleanup(base));
63
+ openDatabase(join(base, ".gsd", "gsd.db"));
64
+ assert.equal(readCrashLock(base), null);
65
+ });
66
+
67
+ test("readCrashLock returns null when only fresh (un-expired) workers exist", (t) => {
68
+ const base = makeBase();
69
+ t.after(() => cleanup(base));
70
+ openDatabase(join(base, ".gsd", "gsd.db"));
71
+ registerAutoWorker({ projectRootRealpath: normalizeRealPath(base) });
72
+ // Heartbeat is fresh — not stale yet.
73
+ assert.equal(readCrashLock(base), null);
74
+ });
75
+
76
+ test("readCrashLock ignores a stale heartbeat when the worker PID is still alive", (t) => {
77
+ const base = makeBase();
78
+ t.after(() => cleanup(base));
79
+ openDatabase(join(base, ".gsd", "gsd.db"));
80
+ const projectRoot = normalizeRealPath(base);
81
+ const workerId = registerAutoWorker({ projectRootRealpath: projectRoot });
82
+ expireWorker(workerId);
83
+
84
+ assert.equal(readCrashLock(base), null);
85
+ });
86
+
87
+ test("readCrashLock synthesizes LockData from a stale dead worker (no dispatches yet)", (t) => {
88
+ const base = makeBase();
89
+ t.after(() => cleanup(base));
90
+ openDatabase(join(base, ".gsd", "gsd.db"));
91
+ const projectRoot = normalizeRealPath(base);
92
+ const workerId = registerAutoWorker({ projectRootRealpath: projectRoot });
93
+ setWorkerPid(workerId, 99999);
94
+ expireWorker(workerId);
95
+
96
+ const lock = readCrashLock(base);
97
+ assert.ok(lock, "stale worker surfaced as a crash lock");
98
+ assert.equal(lock!.pid, 99999);
99
+ // Bootstrap default — no dispatches recorded
100
+ assert.equal(lock!.unitType, "starting");
101
+ assert.equal(lock!.unitId, "bootstrap");
102
+ assert.ok(lock!.startedAt, "startedAt populated from workers.started_at");
103
+ });
104
+
105
+ test("readCrashLock includes the most recent dispatch as unitType/unitId", (t) => {
106
+ const base = makeBase();
107
+ t.after(() => cleanup(base));
108
+ openDatabase(join(base, ".gsd", "gsd.db"));
109
+ insertMilestone({ id: "M001", title: "T", status: "active" });
110
+ const projectRoot = normalizeRealPath(base);
111
+ const workerId = registerAutoWorker({ projectRootRealpath: projectRoot });
112
+ const lease = claimMilestoneLease(workerId, "M001");
113
+ assert.equal(lease.ok, true);
114
+ if (!lease.ok) return;
115
+ recordDispatchClaim({
116
+ traceId: "t1", workerId, milestoneLeaseToken: lease.token,
117
+ milestoneId: "M001", unitType: "plan-slice", unitId: "M001/S01",
118
+ });
119
+ setWorkerPid(workerId, 99999);
120
+ expireWorker(workerId);
121
+
122
+ const lock = readCrashLock(base);
123
+ assert.ok(lock);
124
+ assert.equal(lock!.unitType, "plan-slice");
125
+ assert.equal(lock!.unitId, "M001/S01");
126
+ });
127
+
128
+ test("readCrashLock surfaces sessionFile from runtime_kv", (t) => {
129
+ const base = makeBase();
130
+ t.after(() => cleanup(base));
131
+ openDatabase(join(base, ".gsd", "gsd.db"));
132
+ const projectRoot = normalizeRealPath(base);
133
+ const workerId = registerAutoWorker({ projectRootRealpath: projectRoot });
134
+ setRuntimeKv("worker", workerId, "session_file", "/tmp/pi-session-abc.jsonl");
135
+ setWorkerPid(workerId, 99999);
136
+ expireWorker(workerId);
137
+
138
+ const lock = readCrashLock(base);
139
+ assert.ok(lock);
140
+ assert.equal(lock!.sessionFile, "/tmp/pi-session-abc.jsonl");
141
+ });
142
+
143
+ test("isLockProcessAlive returns true for the current process", () => {
144
+ const lock = {
145
+ pid: process.pid,
146
+ startedAt: new Date().toISOString(),
147
+ unitType: "starting",
148
+ unitId: "bootstrap",
149
+ unitStartedAt: new Date().toISOString(),
150
+ };
151
+ assert.equal(isLockProcessAlive(lock), true);
152
+ });
153
+
154
+ test("isLockProcessAlive returns false for a dead PID", () => {
155
+ // PID 99999 is essentially guaranteed dead on a fresh test box.
156
+ const lock = {
157
+ pid: 99999,
158
+ startedAt: new Date().toISOString(),
159
+ unitType: "starting",
160
+ unitId: "bootstrap",
161
+ unitStartedAt: new Date().toISOString(),
162
+ };
163
+ assert.equal(isLockProcessAlive(lock), false);
164
+ });
165
+
166
+ test("writeLock stores the session_file in runtime_kv (worker scope)", (t) => {
167
+ const base = makeBase();
168
+ t.after(() => cleanup(base));
169
+ openDatabase(join(base, ".gsd", "gsd.db"));
170
+ const projectRoot = normalizeRealPath(base);
171
+ const workerId = registerAutoWorker({ projectRootRealpath: projectRoot });
172
+
173
+ writeLock(base, "plan-slice", "M001/S01", "/tmp/session-xyz.jsonl");
174
+
175
+ // Verify the value was written for the live worker.
176
+ const stored = getRuntimeKv<string>("worker", workerId, "session_file");
177
+ assert.equal(stored, "/tmp/session-xyz.jsonl");
178
+
179
+ // Confirm a stale read picks it up via readCrashLock.
180
+ setWorkerPid(workerId, 99999);
181
+ expireWorker(workerId);
182
+ const lock = readCrashLock(base);
183
+ assert.ok(lock);
184
+ assert.equal(lock!.sessionFile, "/tmp/session-xyz.jsonl");
185
+ });
186
+
187
+ test("clearLock removes the session_file row for the active worker", (t) => {
188
+ const base = makeBase();
189
+ t.after(() => cleanup(base));
190
+ openDatabase(join(base, ".gsd", "gsd.db"));
191
+ const projectRoot = normalizeRealPath(base);
192
+ const workerId = registerAutoWorker({ projectRootRealpath: projectRoot });
193
+
194
+ writeLock(base, "plan-slice", "M001/S01", "/tmp/session-xyz.jsonl");
195
+ assert.equal(getRuntimeKv("worker", workerId, "session_file"), "/tmp/session-xyz.jsonl");
196
+
197
+ // clearLock operates on the active worker (this process) — must run
198
+ // BEFORE expiring the heartbeat, mirroring stopAuto's order: clearLock
199
+ // → markWorkerStopping → done.
200
+ clearLock(base);
201
+ assert.equal(getRuntimeKv("worker", workerId, "session_file"), null,
202
+ "session_file row deleted by clearLock");
203
+ });