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
|
@@ -60,20 +60,24 @@ describe('doctor-runtime', async () => {
|
|
|
60
60
|
|
|
61
61
|
try {
|
|
62
62
|
// ─── Test 1: Stale crash lock detection & fix ─────────────────────
|
|
63
|
-
test('stale_crash_lock', async () => {
|
|
63
|
+
test('stale_crash_lock', async (t) => {
|
|
64
64
|
const dir = createMinimalProject();
|
|
65
65
|
cleanups.push(dir);
|
|
66
66
|
|
|
67
|
-
//
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
67
|
+
// Phase C pt 2: stale lock state lives in the workers table now.
|
|
68
|
+
// Insert a fake stale worker row directly (PID 9999999 is dead).
|
|
69
|
+
const { openDatabase, _getAdapter } = await import("../../gsd-db.ts");
|
|
70
|
+
const gsdDb = await import("../../gsd-db.ts");
|
|
71
|
+
t.after(() => { gsdDb.closeDatabase(); });
|
|
72
|
+
const { randomUUID } = await import("node:crypto");
|
|
73
|
+
openDatabase(join(dir, ".gsd", "gsd.db"));
|
|
74
|
+
const db = _getAdapter()!;
|
|
75
|
+
db.prepare(
|
|
76
|
+
`INSERT INTO workers (worker_id, host, pid, started_at, version, last_heartbeat_at, status, project_root_realpath)
|
|
77
|
+
VALUES (:w, 'test-host', 9999999, '2026-03-10T00:00:00Z', 'test', '1970-01-01T00:00:00.000Z', 'active', :root)`,
|
|
78
|
+
).run({ ":w": `test-fake-${randomUUID().slice(0, 8)}`, ":root": dir });
|
|
79
|
+
// Leave DB open — runGSDDoctor's readCrashLock relies on the
|
|
80
|
+
// currently-open DB connection (it does not open one of its own).
|
|
77
81
|
|
|
78
82
|
const detect = await runGSDDoctor(dir);
|
|
79
83
|
const lockIssues = detect.issues.filter(i => i.code === "stale_crash_lock");
|
|
@@ -82,8 +86,15 @@ describe('doctor-runtime', async () => {
|
|
|
82
86
|
assert.ok(lockIssues[0]?.fixable === true, "stale lock is fixable");
|
|
83
87
|
|
|
84
88
|
const fixed = await runGSDDoctor(dir, { fix: true });
|
|
85
|
-
assert.ok(
|
|
86
|
-
|
|
89
|
+
assert.ok(
|
|
90
|
+
fixed.fixesApplied.some(f => f.includes("cleared stale")),
|
|
91
|
+
`fix clears stale lock (got: ${fixed.fixesApplied.join(", ")})`,
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
// Close DB so subsequent tests in this file (which expect a clean
|
|
95
|
+
// state) don't see this test's connection lingering.
|
|
96
|
+
const { closeDatabase } = await import("../../gsd-db.ts");
|
|
97
|
+
closeDatabase();
|
|
87
98
|
});
|
|
88
99
|
|
|
89
100
|
// ─── Test 2: No false positive for missing lock ───────────────────
|
|
@@ -417,18 +428,19 @@ node_modules/
|
|
|
417
428
|
const dir = createMinimalProject();
|
|
418
429
|
cleanups.push(dir);
|
|
419
430
|
|
|
420
|
-
// Create lock dir +
|
|
431
|
+
// Create lock dir + insert a live worker row (PID 1 = init/launchd —
|
|
432
|
+
// always alive, never our own PID). Phase C pt 2: worker liveness
|
|
433
|
+
// lives in the workers table. last_heartbeat_at = now → not stale.
|
|
421
434
|
const lockDir = join(dir, ".gsd.lock");
|
|
422
435
|
mkdirSync(lockDir, { recursive: true });
|
|
423
|
-
const
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
};
|
|
431
|
-
writeFileSync(join(dir, ".gsd", "auto.lock"), JSON.stringify(liveLockData, null, 2));
|
|
436
|
+
const { openDatabase, _getAdapter } = await import("../../gsd-db.ts");
|
|
437
|
+
const { randomUUID } = await import("node:crypto");
|
|
438
|
+
openDatabase(join(dir, ".gsd", "gsd.db"));
|
|
439
|
+
const db = _getAdapter()!;
|
|
440
|
+
db.prepare(
|
|
441
|
+
`INSERT INTO workers (worker_id, host, pid, started_at, version, last_heartbeat_at, status, project_root_realpath)
|
|
442
|
+
VALUES (:w, 'test-host', 1, :now, 'test', :now, 'active', :root)`,
|
|
443
|
+
).run({ ":w": `test-fake-${randomUUID().slice(0, 8)}`, ":now": new Date().toISOString(), ":root": dir });
|
|
432
444
|
|
|
433
445
|
const detect = await runGSDDoctor(dir);
|
|
434
446
|
const strandedIssues = detect.issues.filter(i => i.code === "stranded_lock_directory");
|
package/src/resources/extensions/gsd/tests/integration/workspace-collapse-integration.test.ts
CHANGED
|
@@ -141,21 +141,20 @@ describe("workspace-collapse integration: Test 2 — abort teardown clears stale
|
|
|
141
141
|
rmSync(repoDir, { recursive: true, force: true });
|
|
142
142
|
});
|
|
143
143
|
|
|
144
|
-
test("STATE.md
|
|
144
|
+
test("STATE.md and M001-META.json are removed by teardownAutoWorktree", () => {
|
|
145
|
+
// Phase C pt 2: auto.lock no longer exists as a file — it migrated
|
|
146
|
+
// to the workers + unit_dispatches tables.
|
|
145
147
|
const gsdDir = join(repoDir, ".gsd");
|
|
146
148
|
const milestonesDir = join(gsdDir, "milestones", "M001");
|
|
147
149
|
mkdirSync(milestonesDir, { recursive: true });
|
|
148
150
|
|
|
149
151
|
const stateMd = join(gsdDir, "STATE.md");
|
|
150
|
-
const autoLock = join(gsdDir, "auto.lock");
|
|
151
152
|
const metaJson = join(milestonesDir, "M001-META.json");
|
|
152
153
|
|
|
153
154
|
writeFileSync(stateMd, "# State\nactive\n");
|
|
154
|
-
writeFileSync(autoLock, JSON.stringify({ pid: process.pid, unitType: "plan-milestone", unitId: "M001" }));
|
|
155
155
|
writeFileSync(metaJson, JSON.stringify({ milestoneId: "M001" }));
|
|
156
156
|
|
|
157
157
|
assert.ok(existsSync(stateMd), "STATE.md exists before teardown");
|
|
158
|
-
assert.ok(existsSync(autoLock), "auto.lock exists before teardown");
|
|
159
158
|
assert.ok(existsSync(metaJson), "M001-META.json exists before teardown");
|
|
160
159
|
|
|
161
160
|
// teardownAutoWorktree clears state files before the git step; git removal
|
|
@@ -167,7 +166,6 @@ describe("workspace-collapse integration: Test 2 — abort teardown clears stale
|
|
|
167
166
|
}
|
|
168
167
|
|
|
169
168
|
assert.ok(!existsSync(stateMd), "STATE.md removed by teardownAutoWorktree (regression: A5)");
|
|
170
|
-
assert.ok(!existsSync(autoLock), "auto.lock removed by teardownAutoWorktree (regression: A5)");
|
|
171
169
|
assert.ok(!existsSync(metaJson), "M001-META.json removed by teardownAutoWorktree (regression: A5)");
|
|
172
170
|
});
|
|
173
171
|
});
|
|
@@ -6,6 +6,21 @@ import { tmpdir } from "node:os";
|
|
|
6
6
|
import { randomUUID } from "node:crypto";
|
|
7
7
|
|
|
8
8
|
import { assessInterruptedSession } from "../interrupted-session.ts";
|
|
9
|
+
import {
|
|
10
|
+
openDatabase,
|
|
11
|
+
closeDatabase,
|
|
12
|
+
insertMilestone,
|
|
13
|
+
_getAdapter,
|
|
14
|
+
} from "../gsd-db.ts";
|
|
15
|
+
import { registerAutoWorker } from "../db/auto-workers.ts";
|
|
16
|
+
import { claimMilestoneLease } from "../db/milestone-leases.ts";
|
|
17
|
+
import { recordDispatchClaim } from "../db/unit-dispatches.ts";
|
|
18
|
+
import { setRuntimeKv } from "../db/runtime-kv.ts";
|
|
19
|
+
import {
|
|
20
|
+
PAUSED_SESSION_KV_KEY,
|
|
21
|
+
type PausedSessionMetadata,
|
|
22
|
+
} from "../interrupted-session.ts";
|
|
23
|
+
import { normalizeRealPath } from "../paths.ts";
|
|
9
24
|
|
|
10
25
|
function makeTmpBase(): string {
|
|
11
26
|
const base = join(tmpdir(), `gsd-auto-interrupted-${randomUUID()}`);
|
|
@@ -14,9 +29,61 @@ function makeTmpBase(): string {
|
|
|
14
29
|
}
|
|
15
30
|
|
|
16
31
|
function cleanup(base: string): void {
|
|
32
|
+
try { closeDatabase(); } catch { /* */ }
|
|
17
33
|
try { rmSync(base, { recursive: true, force: true }); } catch { /* */ }
|
|
18
34
|
}
|
|
19
35
|
|
|
36
|
+
function openFixtureDb(base: string): void {
|
|
37
|
+
openDatabase(join(base, ".gsd", "gsd.db"));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function expireWorker(workerId: string): void {
|
|
41
|
+
const db = _getAdapter()!;
|
|
42
|
+
db.prepare(
|
|
43
|
+
`UPDATE workers SET last_heartbeat_at = '1970-01-01T00:00:00.000Z' WHERE worker_id = :worker_id`,
|
|
44
|
+
).run({ ":worker_id": workerId });
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function writeLock(base: string, unitType: string, unitId: string): void {
|
|
48
|
+
openFixtureDb(base);
|
|
49
|
+
insertMilestone({
|
|
50
|
+
id: "M001",
|
|
51
|
+
title: "Test Milestone",
|
|
52
|
+
status: unitType === "complete-slice" ? "complete" : "active",
|
|
53
|
+
});
|
|
54
|
+
const workerId = registerAutoWorker({ projectRootRealpath: normalizeRealPath(base) });
|
|
55
|
+
const lease = claimMilestoneLease(workerId, "M001");
|
|
56
|
+
assert.equal(lease.ok, true);
|
|
57
|
+
if (lease.ok) {
|
|
58
|
+
const [, sliceId = null, taskId = null] = unitId.split("/");
|
|
59
|
+
const claimed = recordDispatchClaim({
|
|
60
|
+
traceId: `trace-${randomUUID().slice(0, 8)}`,
|
|
61
|
+
workerId,
|
|
62
|
+
milestoneLeaseToken: lease.token,
|
|
63
|
+
milestoneId: "M001",
|
|
64
|
+
sliceId,
|
|
65
|
+
taskId,
|
|
66
|
+
unitType,
|
|
67
|
+
unitId,
|
|
68
|
+
});
|
|
69
|
+
assert.equal(claimed.ok, true);
|
|
70
|
+
}
|
|
71
|
+
_getAdapter()!
|
|
72
|
+
.prepare(`UPDATE workers SET pid = 99999 WHERE worker_id = :worker_id`)
|
|
73
|
+
.run({ ":worker_id": workerId });
|
|
74
|
+
expireWorker(workerId);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function writePausedSession(base: string, milestoneId = "M001", stepMode = false): void {
|
|
78
|
+
openFixtureDb(base);
|
|
79
|
+
const meta: PausedSessionMetadata = {
|
|
80
|
+
milestoneId,
|
|
81
|
+
originalBasePath: base,
|
|
82
|
+
stepMode,
|
|
83
|
+
};
|
|
84
|
+
setRuntimeKv("global", "", PAUSED_SESSION_KV_KEY, meta);
|
|
85
|
+
}
|
|
86
|
+
|
|
20
87
|
function writeRoadmap(base: string, checked = false): void {
|
|
21
88
|
const milestoneDir = join(base, ".gsd", "milestones", "M001");
|
|
22
89
|
mkdirSync(join(milestoneDir, "slices", "S01", "tasks"), { recursive: true });
|
|
@@ -51,42 +118,22 @@ function writeRoadmap(base: string, checked = false): void {
|
|
|
51
118
|
function writeCompleteArtifacts(base: string): void {
|
|
52
119
|
const milestoneDir = join(base, ".gsd", "milestones", "M001");
|
|
53
120
|
const sliceDir = join(milestoneDir, "slices", "S01");
|
|
121
|
+
const tasksDir = join(sliceDir, "tasks");
|
|
54
122
|
mkdirSync(sliceDir, { recursive: true });
|
|
123
|
+
mkdirSync(tasksDir, { recursive: true });
|
|
124
|
+
writeFileSync(join(sliceDir, "S01-PLAN.md"), "# S01: Test Slice\n\n## Tasks\n- [x] **T01: Do thing** `est:10m`\n", "utf-8");
|
|
125
|
+
writeFileSync(join(tasksDir, "T01-SUMMARY.md"), "# Task Summary\nDone.\n", "utf-8");
|
|
55
126
|
writeFileSync(join(sliceDir, "S01-SUMMARY.md"), "# Summary\nDone.\n", "utf-8");
|
|
56
127
|
writeFileSync(join(sliceDir, "S01-UAT.md"), "# UAT\nPassed.\n", "utf-8");
|
|
57
128
|
writeFileSync(join(milestoneDir, "M001-SUMMARY.md"), "# Milestone Summary\nDone.\n", "utf-8");
|
|
58
129
|
}
|
|
59
130
|
|
|
60
|
-
function writeLock(base: string, unitType: string, unitId: string): void {
|
|
61
|
-
writeFileSync(
|
|
62
|
-
join(base, ".gsd", "auto.lock"),
|
|
63
|
-
JSON.stringify({
|
|
64
|
-
pid: 999999999,
|
|
65
|
-
startedAt: new Date().toISOString(),
|
|
66
|
-
unitType,
|
|
67
|
-
unitId,
|
|
68
|
-
unitStartedAt: new Date().toISOString(),
|
|
69
|
-
}, null, 2),
|
|
70
|
-
"utf-8",
|
|
71
|
-
);
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
function writePausedSession(base: string, milestoneId = "M001", stepMode = false): void {
|
|
75
|
-
const runtimeDir = join(base, ".gsd", "runtime");
|
|
76
|
-
mkdirSync(runtimeDir, { recursive: true });
|
|
77
|
-
writeFileSync(
|
|
78
|
-
join(runtimeDir, "paused-session.json"),
|
|
79
|
-
JSON.stringify({ milestoneId, originalBasePath: base, stepMode }, null, 2),
|
|
80
|
-
"utf-8",
|
|
81
|
-
);
|
|
82
|
-
}
|
|
83
|
-
|
|
84
131
|
test("direct /gsd auto stale complete repo yields stale classification with no recovery payload", async () => {
|
|
85
132
|
const base = makeTmpBase();
|
|
86
133
|
try {
|
|
87
134
|
writeRoadmap(base, true);
|
|
88
135
|
writeCompleteArtifacts(base);
|
|
89
|
-
writeLock(base, "
|
|
136
|
+
writeLock(base, "complete-slice", "M001/S01");
|
|
90
137
|
|
|
91
138
|
const assessment = await assessInterruptedSession(base);
|
|
92
139
|
assert.equal(assessment.classification, "stale");
|
|
@@ -6,6 +6,21 @@ import { tmpdir } from "node:os";
|
|
|
6
6
|
import { randomUUID } from "node:crypto";
|
|
7
7
|
|
|
8
8
|
import { assessInterruptedSession } from "../interrupted-session.ts";
|
|
9
|
+
import {
|
|
10
|
+
openDatabase,
|
|
11
|
+
closeDatabase,
|
|
12
|
+
insertMilestone,
|
|
13
|
+
_getAdapter,
|
|
14
|
+
} from "../gsd-db.ts";
|
|
15
|
+
import { registerAutoWorker } from "../db/auto-workers.ts";
|
|
16
|
+
import { claimMilestoneLease } from "../db/milestone-leases.ts";
|
|
17
|
+
import { recordDispatchClaim } from "../db/unit-dispatches.ts";
|
|
18
|
+
import { setRuntimeKv } from "../db/runtime-kv.ts";
|
|
19
|
+
import {
|
|
20
|
+
PAUSED_SESSION_KV_KEY,
|
|
21
|
+
type PausedSessionMetadata,
|
|
22
|
+
} from "../interrupted-session.ts";
|
|
23
|
+
import { normalizeRealPath } from "../paths.ts";
|
|
9
24
|
|
|
10
25
|
function makeTmpBase(): string {
|
|
11
26
|
const base = join(tmpdir(), `gsd-smart-entry-${randomUUID()}`);
|
|
@@ -14,9 +29,61 @@ function makeTmpBase(): string {
|
|
|
14
29
|
}
|
|
15
30
|
|
|
16
31
|
function cleanup(base: string): void {
|
|
32
|
+
try { closeDatabase(); } catch { /* */ }
|
|
17
33
|
try { rmSync(base, { recursive: true, force: true }); } catch { /* */ }
|
|
18
34
|
}
|
|
19
35
|
|
|
36
|
+
function openFixtureDb(base: string): void {
|
|
37
|
+
openDatabase(join(base, ".gsd", "gsd.db"));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function expireWorker(workerId: string): void {
|
|
41
|
+
const db = _getAdapter()!;
|
|
42
|
+
db.prepare(
|
|
43
|
+
`UPDATE workers SET last_heartbeat_at = '1970-01-01T00:00:00.000Z' WHERE worker_id = :worker_id`,
|
|
44
|
+
).run({ ":worker_id": workerId });
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function writePausedSession(base: string, milestoneId = "M001", stepMode = false): void {
|
|
48
|
+
openFixtureDb(base);
|
|
49
|
+
const meta: PausedSessionMetadata = {
|
|
50
|
+
milestoneId,
|
|
51
|
+
originalBasePath: base,
|
|
52
|
+
stepMode,
|
|
53
|
+
};
|
|
54
|
+
setRuntimeKv("global", "", PAUSED_SESSION_KV_KEY, meta);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function writeLock(base: string, unitType: string, unitId: string): void {
|
|
58
|
+
openFixtureDb(base);
|
|
59
|
+
insertMilestone({
|
|
60
|
+
id: "M001",
|
|
61
|
+
title: "Test Milestone",
|
|
62
|
+
status: unitType === "complete-slice" ? "complete" : "active",
|
|
63
|
+
});
|
|
64
|
+
const workerId = registerAutoWorker({ projectRootRealpath: normalizeRealPath(base) });
|
|
65
|
+
const lease = claimMilestoneLease(workerId, "M001");
|
|
66
|
+
assert.equal(lease.ok, true);
|
|
67
|
+
if (lease.ok) {
|
|
68
|
+
const [, sliceId = null, taskId = null] = unitId.split("/");
|
|
69
|
+
const claimed = recordDispatchClaim({
|
|
70
|
+
traceId: `trace-${randomUUID().slice(0, 8)}`,
|
|
71
|
+
workerId,
|
|
72
|
+
milestoneLeaseToken: lease.token,
|
|
73
|
+
milestoneId: "M001",
|
|
74
|
+
sliceId,
|
|
75
|
+
taskId,
|
|
76
|
+
unitType,
|
|
77
|
+
unitId,
|
|
78
|
+
});
|
|
79
|
+
assert.equal(claimed.ok, true);
|
|
80
|
+
}
|
|
81
|
+
_getAdapter()!
|
|
82
|
+
.prepare(`UPDATE workers SET pid = 99999 WHERE worker_id = :worker_id`)
|
|
83
|
+
.run({ ":worker_id": workerId });
|
|
84
|
+
expireWorker(workerId);
|
|
85
|
+
}
|
|
86
|
+
|
|
20
87
|
function writeRoadmap(base: string, checked = false): void {
|
|
21
88
|
const milestoneDir = join(base, ".gsd", "milestones", "M001");
|
|
22
89
|
mkdirSync(join(milestoneDir, "slices", "S01", "tasks"), { recursive: true });
|
|
@@ -51,42 +118,22 @@ function writeRoadmap(base: string, checked = false): void {
|
|
|
51
118
|
function writeCompleteArtifacts(base: string): void {
|
|
52
119
|
const milestoneDir = join(base, ".gsd", "milestones", "M001");
|
|
53
120
|
const sliceDir = join(milestoneDir, "slices", "S01");
|
|
121
|
+
const tasksDir = join(sliceDir, "tasks");
|
|
54
122
|
mkdirSync(sliceDir, { recursive: true });
|
|
123
|
+
mkdirSync(tasksDir, { recursive: true });
|
|
124
|
+
writeFileSync(join(sliceDir, "S01-PLAN.md"), "# S01: Test Slice\n\n## Tasks\n- [x] **T01: Do thing** `est:10m`\n", "utf-8");
|
|
125
|
+
writeFileSync(join(tasksDir, "T01-SUMMARY.md"), "# Task Summary\nDone.\n", "utf-8");
|
|
55
126
|
writeFileSync(join(sliceDir, "S01-SUMMARY.md"), "# Summary\nDone.\n", "utf-8");
|
|
56
127
|
writeFileSync(join(sliceDir, "S01-UAT.md"), "# UAT\nPassed.\n", "utf-8");
|
|
57
128
|
writeFileSync(join(milestoneDir, "M001-SUMMARY.md"), "# Milestone Summary\nDone.\n", "utf-8");
|
|
58
129
|
}
|
|
59
130
|
|
|
60
|
-
function writePausedSession(base: string, milestoneId = "M001", stepMode = false): void {
|
|
61
|
-
const runtimeDir = join(base, ".gsd", "runtime");
|
|
62
|
-
mkdirSync(runtimeDir, { recursive: true });
|
|
63
|
-
writeFileSync(
|
|
64
|
-
join(runtimeDir, "paused-session.json"),
|
|
65
|
-
JSON.stringify({ milestoneId, originalBasePath: base, stepMode }, null, 2),
|
|
66
|
-
"utf-8",
|
|
67
|
-
);
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
function writeLock(base: string, unitType: string, unitId: string): void {
|
|
71
|
-
writeFileSync(
|
|
72
|
-
join(base, ".gsd", "auto.lock"),
|
|
73
|
-
JSON.stringify({
|
|
74
|
-
pid: 999999999,
|
|
75
|
-
startedAt: new Date().toISOString(),
|
|
76
|
-
unitType,
|
|
77
|
-
unitId,
|
|
78
|
-
unitStartedAt: new Date().toISOString(),
|
|
79
|
-
}, null, 2),
|
|
80
|
-
"utf-8",
|
|
81
|
-
);
|
|
82
|
-
}
|
|
83
|
-
|
|
84
131
|
test("guided-flow stale complete scenario classifies as stale so the resume prompt can be suppressed", async () => {
|
|
85
132
|
const base = makeTmpBase();
|
|
86
133
|
try {
|
|
87
134
|
writeRoadmap(base, true);
|
|
88
135
|
writeCompleteArtifacts(base);
|
|
89
|
-
writeLock(base, "
|
|
136
|
+
writeLock(base, "complete-slice", "M001/S01");
|
|
90
137
|
|
|
91
138
|
const assessment = await assessInterruptedSession(base);
|
|
92
139
|
assert.equal(assessment.classification, "stale");
|
|
@@ -40,23 +40,26 @@ describe("stuck detection persistence (#3704)", () => {
|
|
|
40
40
|
assert.match(loopSource, /function saveStuckState/);
|
|
41
41
|
});
|
|
42
42
|
|
|
43
|
+
// Phase C: API changed from (basePath) to (session) — recentUnits is
|
|
44
|
+
// now reconstructed from unit_dispatches and stuckRecoveryAttempts
|
|
45
|
+
// persists in runtime_kv (worker scope).
|
|
43
46
|
test("loopState initialized from persisted state", () => {
|
|
44
|
-
assert.match(loopSource, /loadStuckState\(s
|
|
47
|
+
assert.match(loopSource, /loadStuckState\(s\)/);
|
|
45
48
|
});
|
|
46
49
|
|
|
47
50
|
test("stuck state saved after each iteration", () => {
|
|
48
|
-
assert.match(loopSource, /saveStuckState\(s
|
|
51
|
+
assert.match(loopSource, /saveStuckState\(s,\s*loopState\)/);
|
|
49
52
|
});
|
|
50
53
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
+
// Phase C: stuck-state.json file IO deleted; persistence moved to
|
|
55
|
+
// unit_dispatches (recentUnits) + runtime_kv (stuckRecoveryAttempts).
|
|
56
|
+
// The stuck-state-via-db.test.ts suite covers the round-trip.
|
|
54
57
|
|
|
55
58
|
test("saveStuckState called in standard dev path as well as custom engine path (#4382)", () => {
|
|
56
59
|
// Count all call-sites of saveStuckState (excluding the function definition itself).
|
|
57
60
|
// After the fix, both the custom-engine path and the standard dev path must each
|
|
58
61
|
// call saveStuckState so stuckRecoveryAttempts survives session restarts.
|
|
59
|
-
const callMatches = loopSource.match(/saveStuckState\(s
|
|
62
|
+
const callMatches = loopSource.match(/saveStuckState\(s,\s*loopState\)/g) ?? [];
|
|
60
63
|
assert.ok(
|
|
61
64
|
callMatches.length >= 2,
|
|
62
65
|
`saveStuckState must be called in both the custom-engine path and the standard dev path ` +
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
// gsd-2 + Milestone leases tests (Phase B coordination — fencing 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 {
|
|
10
|
+
openDatabase,
|
|
11
|
+
closeDatabase,
|
|
12
|
+
insertMilestone,
|
|
13
|
+
_getAdapter,
|
|
14
|
+
} from "../gsd-db.ts";
|
|
15
|
+
import { registerAutoWorker } from "../db/auto-workers.ts";
|
|
16
|
+
import {
|
|
17
|
+
claimMilestoneLease,
|
|
18
|
+
releaseMilestoneLease,
|
|
19
|
+
refreshMilestoneLease,
|
|
20
|
+
getMilestoneLease,
|
|
21
|
+
} from "../db/milestone-leases.ts";
|
|
22
|
+
|
|
23
|
+
function makeBase(): string {
|
|
24
|
+
const base = mkdtempSync(join(tmpdir(), "gsd-leases-"));
|
|
25
|
+
mkdirSync(join(base, ".gsd"), { recursive: true });
|
|
26
|
+
return base;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function cleanup(base: string): void {
|
|
30
|
+
try { closeDatabase(); } catch { /* noop */ }
|
|
31
|
+
try { rmSync(base, { recursive: true, force: true }); } catch { /* noop */ }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
test("first claim returns ok=true with token=1", (t) => {
|
|
35
|
+
const base = makeBase();
|
|
36
|
+
t.after(() => cleanup(base));
|
|
37
|
+
openDatabase(join(base, ".gsd", "gsd.db"));
|
|
38
|
+
insertMilestone({ id: "M001", title: "Test", status: "active" });
|
|
39
|
+
|
|
40
|
+
const w1 = registerAutoWorker({ projectRootRealpath: base });
|
|
41
|
+
const claim = claimMilestoneLease(w1, "M001");
|
|
42
|
+
assert.equal(claim.ok, true);
|
|
43
|
+
if (claim.ok) {
|
|
44
|
+
assert.equal(claim.token, 1, "fresh claim starts fencing token at 1");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const row = getMilestoneLease("M001");
|
|
48
|
+
assert.ok(row);
|
|
49
|
+
assert.equal(row!.worker_id, w1);
|
|
50
|
+
assert.equal(row!.fencing_token, 1);
|
|
51
|
+
assert.equal(row!.status, "held");
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("second claim by different worker is rejected while lease is held", (t) => {
|
|
55
|
+
const base = makeBase();
|
|
56
|
+
t.after(() => cleanup(base));
|
|
57
|
+
openDatabase(join(base, ".gsd", "gsd.db"));
|
|
58
|
+
insertMilestone({ id: "M001", title: "Test", status: "active" });
|
|
59
|
+
|
|
60
|
+
const w1 = registerAutoWorker({ projectRootRealpath: base });
|
|
61
|
+
const w2 = registerAutoWorker({ projectRootRealpath: base });
|
|
62
|
+
const first = claimMilestoneLease(w1, "M001");
|
|
63
|
+
assert.equal(first.ok, true);
|
|
64
|
+
|
|
65
|
+
const second = claimMilestoneLease(w2, "M001");
|
|
66
|
+
assert.equal(second.ok, false);
|
|
67
|
+
if (!second.ok) {
|
|
68
|
+
assert.equal(second.error, "held_by");
|
|
69
|
+
assert.equal(second.byWorker, w1);
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("releaseMilestoneLease frees the lease for takeover", (t) => {
|
|
74
|
+
const base = makeBase();
|
|
75
|
+
t.after(() => cleanup(base));
|
|
76
|
+
openDatabase(join(base, ".gsd", "gsd.db"));
|
|
77
|
+
insertMilestone({ id: "M001", title: "Test", status: "active" });
|
|
78
|
+
|
|
79
|
+
const w1 = registerAutoWorker({ projectRootRealpath: base });
|
|
80
|
+
const w2 = registerAutoWorker({ projectRootRealpath: base });
|
|
81
|
+
const first = claimMilestoneLease(w1, "M001");
|
|
82
|
+
assert.equal(first.ok, true);
|
|
83
|
+
|
|
84
|
+
if (first.ok) {
|
|
85
|
+
const released = releaseMilestoneLease(w1, "M001", first.token);
|
|
86
|
+
assert.equal(released, true);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// After release, w2 may take over with monotonically larger token
|
|
90
|
+
const second = claimMilestoneLease(w2, "M001");
|
|
91
|
+
assert.equal(second.ok, true);
|
|
92
|
+
if (second.ok) {
|
|
93
|
+
assert.equal(second.token, 2, "takeover increments fencing token monotonically");
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("expired lease (TTL passed) allows takeover with token+1", (t) => {
|
|
98
|
+
const base = makeBase();
|
|
99
|
+
t.after(() => cleanup(base));
|
|
100
|
+
openDatabase(join(base, ".gsd", "gsd.db"));
|
|
101
|
+
insertMilestone({ id: "M001", title: "Test", status: "active" });
|
|
102
|
+
|
|
103
|
+
const w1 = registerAutoWorker({ projectRootRealpath: base });
|
|
104
|
+
const w2 = registerAutoWorker({ projectRootRealpath: base });
|
|
105
|
+
const first = claimMilestoneLease(w1, "M001");
|
|
106
|
+
assert.equal(first.ok, true);
|
|
107
|
+
|
|
108
|
+
// Force expiration by patching the row's expires_at into the past.
|
|
109
|
+
const db = _getAdapter()!;
|
|
110
|
+
db.prepare(
|
|
111
|
+
`UPDATE milestone_leases SET expires_at = '1970-01-01T00:00:00.000Z' WHERE milestone_id = 'M001'`,
|
|
112
|
+
).run();
|
|
113
|
+
|
|
114
|
+
const takeover = claimMilestoneLease(w2, "M001");
|
|
115
|
+
assert.equal(takeover.ok, true);
|
|
116
|
+
if (takeover.ok) {
|
|
117
|
+
assert.equal(takeover.token, 2);
|
|
118
|
+
}
|
|
119
|
+
const row = getMilestoneLease("M001");
|
|
120
|
+
assert.equal(row!.worker_id, w2);
|
|
121
|
+
assert.equal(row!.fencing_token, 2);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("refreshMilestoneLease only succeeds with the matching fencing token", (t) => {
|
|
125
|
+
const base = makeBase();
|
|
126
|
+
t.after(() => cleanup(base));
|
|
127
|
+
openDatabase(join(base, ".gsd", "gsd.db"));
|
|
128
|
+
insertMilestone({ id: "M001", title: "Test", status: "active" });
|
|
129
|
+
|
|
130
|
+
const w1 = registerAutoWorker({ projectRootRealpath: base });
|
|
131
|
+
const claim = claimMilestoneLease(w1, "M001");
|
|
132
|
+
assert.equal(claim.ok, true);
|
|
133
|
+
if (!claim.ok) return;
|
|
134
|
+
|
|
135
|
+
// Correct token refreshes
|
|
136
|
+
assert.equal(refreshMilestoneLease(w1, "M001", claim.token), true);
|
|
137
|
+
|
|
138
|
+
// Stale token (e.g. claim.token - 1) refuses
|
|
139
|
+
assert.equal(refreshMilestoneLease(w1, "M001", claim.token - 1), false);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("claimMilestoneLease rethrows foreign-key failures instead of treating them as lease contention", (t) => {
|
|
143
|
+
const base = makeBase();
|
|
144
|
+
t.after(() => cleanup(base));
|
|
145
|
+
openDatabase(join(base, ".gsd", "gsd.db"));
|
|
146
|
+
insertMilestone({ id: "M001", title: "Test", status: "active" });
|
|
147
|
+
|
|
148
|
+
assert.throws(
|
|
149
|
+
() => claimMilestoneLease("missing-worker", "M001"),
|
|
150
|
+
/FOREIGN KEY constraint failed/,
|
|
151
|
+
);
|
|
152
|
+
});
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// gsd-2 + Parallel-worker isolation regression (Phase B coordination)
|
|
2
|
+
//
|
|
3
|
+
// Two simulated workers attempt to claim leases on the same project. The
|
|
4
|
+
// lease infrastructure must guarantee:
|
|
5
|
+
// - On the same milestone: only one wins; the loser sees held_by error
|
|
6
|
+
// - On different milestones: both succeed independently
|
|
7
|
+
//
|
|
8
|
+
// This is the integration check that ties registerAutoWorker +
|
|
9
|
+
// claimMilestoneLease + recordDispatchClaim together end-to-end.
|
|
10
|
+
|
|
11
|
+
import test from "node:test";
|
|
12
|
+
import assert from "node:assert/strict";
|
|
13
|
+
import { mkdtempSync, mkdirSync, rmSync } from "node:fs";
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
import { tmpdir } from "node:os";
|
|
16
|
+
|
|
17
|
+
import {
|
|
18
|
+
openDatabase,
|
|
19
|
+
closeDatabase,
|
|
20
|
+
insertMilestone,
|
|
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
|
+
|
|
26
|
+
function makeBase(): string {
|
|
27
|
+
const base = mkdtempSync(join(tmpdir(), "gsd-parallel-iso-"));
|
|
28
|
+
mkdirSync(join(base, ".gsd"), { recursive: true });
|
|
29
|
+
return base;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function cleanup(base: string): void {
|
|
33
|
+
try { closeDatabase(); } catch { /* noop */ }
|
|
34
|
+
try { rmSync(base, { recursive: true, force: true }); } catch { /* noop */ }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
test("two workers contesting the same milestone: only one wins the lease", (t) => {
|
|
38
|
+
const base = makeBase();
|
|
39
|
+
t.after(() => cleanup(base));
|
|
40
|
+
openDatabase(join(base, ".gsd", "gsd.db"));
|
|
41
|
+
insertMilestone({ id: "M001", title: "Contested", status: "active" });
|
|
42
|
+
|
|
43
|
+
const w1 = registerAutoWorker({ projectRootRealpath: base });
|
|
44
|
+
const w2 = registerAutoWorker({ projectRootRealpath: base });
|
|
45
|
+
|
|
46
|
+
const r1 = claimMilestoneLease(w1, "M001");
|
|
47
|
+
const r2 = claimMilestoneLease(w2, "M001");
|
|
48
|
+
|
|
49
|
+
assert.equal(r1.ok, true, "first claim wins");
|
|
50
|
+
assert.equal(r2.ok, false, "second claim is rejected");
|
|
51
|
+
if (!r2.ok) {
|
|
52
|
+
assert.equal(r2.error, "held_by");
|
|
53
|
+
assert.equal(r2.byWorker, w1);
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("two workers on different milestones can both proceed independently", (t) => {
|
|
58
|
+
const base = makeBase();
|
|
59
|
+
t.after(() => cleanup(base));
|
|
60
|
+
openDatabase(join(base, ".gsd", "gsd.db"));
|
|
61
|
+
insertMilestone({ id: "M001", title: "First", status: "active" });
|
|
62
|
+
insertMilestone({ id: "M002", title: "Second", status: "active" });
|
|
63
|
+
|
|
64
|
+
const w1 = registerAutoWorker({ projectRootRealpath: base });
|
|
65
|
+
const w2 = registerAutoWorker({ projectRootRealpath: base });
|
|
66
|
+
|
|
67
|
+
const r1 = claimMilestoneLease(w1, "M001");
|
|
68
|
+
const r2 = claimMilestoneLease(w2, "M002");
|
|
69
|
+
|
|
70
|
+
assert.equal(r1.ok, true);
|
|
71
|
+
assert.equal(r2.ok, true);
|
|
72
|
+
if (r1.ok && r2.ok) {
|
|
73
|
+
assert.equal(r1.token, 1);
|
|
74
|
+
assert.equal(r2.token, 1);
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("dispatch ledger ties unit_id uniqueness to active status", (t) => {
|
|
79
|
+
const base = makeBase();
|
|
80
|
+
t.after(() => cleanup(base));
|
|
81
|
+
openDatabase(join(base, ".gsd", "gsd.db"));
|
|
82
|
+
insertMilestone({ id: "M001", title: "T", status: "active" });
|
|
83
|
+
|
|
84
|
+
const w1 = registerAutoWorker({ projectRootRealpath: base });
|
|
85
|
+
const lease = claimMilestoneLease(w1, "M001");
|
|
86
|
+
assert.equal(lease.ok, true);
|
|
87
|
+
if (!lease.ok) return;
|
|
88
|
+
|
|
89
|
+
// The same lease holder attempts to claim the same unit twice.
|
|
90
|
+
// The partial unique index on unit_dispatches.unit_id WHERE status IN
|
|
91
|
+
// ('claimed','running') must serialize the writes even before the unit transitions.
|
|
92
|
+
const claim1 = recordDispatchClaim({
|
|
93
|
+
traceId: "t1", workerId: w1, milestoneLeaseToken: lease.token,
|
|
94
|
+
milestoneId: "M001", unitType: "plan-slice", unitId: "M001/S01",
|
|
95
|
+
});
|
|
96
|
+
const claim2 = recordDispatchClaim({
|
|
97
|
+
traceId: "t2", workerId: w1, milestoneLeaseToken: lease.token,
|
|
98
|
+
milestoneId: "M001", unitType: "plan-slice", unitId: "M001/S01",
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
assert.equal(claim1.ok, true);
|
|
102
|
+
assert.equal(claim2.ok, false);
|
|
103
|
+
if (!claim2.ok) {
|
|
104
|
+
assert.equal(claim2.error, "already_active");
|
|
105
|
+
}
|
|
106
|
+
});
|