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
|
@@ -18,39 +18,96 @@ import {
|
|
|
18
18
|
hasResumableDerivedState,
|
|
19
19
|
isBootstrapCrashLock,
|
|
20
20
|
readPausedSessionMetadata,
|
|
21
|
+
PAUSED_SESSION_KV_KEY,
|
|
21
22
|
} from "../interrupted-session.ts";
|
|
22
|
-
import {
|
|
23
|
+
import {
|
|
24
|
+
openDatabase,
|
|
25
|
+
closeDatabase,
|
|
26
|
+
insertMilestone,
|
|
27
|
+
_getAdapter,
|
|
28
|
+
} from "../gsd-db.ts";
|
|
29
|
+
import { registerAutoWorker } from "../db/auto-workers.ts";
|
|
30
|
+
import { claimMilestoneLease } from "../db/milestone-leases.ts";
|
|
31
|
+
import { recordDispatchClaim } from "../db/unit-dispatches.ts";
|
|
32
|
+
import { insertSlice, insertTask } from "../gsd-db.ts";
|
|
33
|
+
import { setRuntimeKv } from "../db/runtime-kv.ts";
|
|
34
|
+
import { normalizeRealPath } from "../paths.ts";
|
|
23
35
|
import type { GSDState } from "../types.ts";
|
|
24
36
|
import { _synthesizePausedSessionRecoveryForTest } from "../auto.ts";
|
|
25
37
|
|
|
26
38
|
function makeTmpBase(): string {
|
|
27
39
|
const base = join(tmpdir(), `gsd-test-${randomUUID()}`);
|
|
28
40
|
mkdirSync(join(base, ".gsd"), { recursive: true });
|
|
41
|
+
// Phase C pt 2: lock and paused-session live in the DB now. Open it
|
|
42
|
+
// for every test base so the helpers below can write through.
|
|
43
|
+
openDatabase(join(base, ".gsd", "gsd.db"));
|
|
29
44
|
return base;
|
|
30
45
|
}
|
|
31
46
|
|
|
32
47
|
function cleanup(base: string): void {
|
|
48
|
+
try { closeDatabase(); } catch { /* */ }
|
|
33
49
|
try { rmSync(base, { recursive: true, force: true }); } catch { /* */ }
|
|
34
50
|
}
|
|
35
51
|
|
|
52
|
+
/**
|
|
53
|
+
* Phase C pt 2 fixture: insert a stale worker row + dispatch + session_file
|
|
54
|
+
* directly via SQL so it appears as a crashed PEER process, not as the
|
|
55
|
+
* current test process. assessInterruptedSession filters out
|
|
56
|
+
* `rawLock.pid === process.pid` to avoid classifying its own process as
|
|
57
|
+
* a previous crash; using PID 999999999 (functionally guaranteed dead)
|
|
58
|
+
* bypasses that guard exactly the way the old file-based writeTestLock
|
|
59
|
+
* did with the same PID.
|
|
60
|
+
*/
|
|
36
61
|
function writeTestLock(
|
|
37
62
|
base: string,
|
|
38
63
|
unitType: string,
|
|
39
64
|
unitId: string,
|
|
40
65
|
sessionFile?: string,
|
|
41
66
|
): void {
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
67
|
+
const projectRoot = normalizeRealPath(base);
|
|
68
|
+
const workerId = `test-fake-${randomUUID().slice(0, 8)}`;
|
|
69
|
+
const fakePid = 999999999;
|
|
70
|
+
const stalePast = "1970-01-01T00:00:00.000Z";
|
|
71
|
+
const db = _getAdapter()!;
|
|
72
|
+
db.prepare(
|
|
73
|
+
`INSERT INTO workers (
|
|
74
|
+
worker_id, host, pid, started_at, version,
|
|
75
|
+
last_heartbeat_at, status, project_root_realpath
|
|
76
|
+
) VALUES (
|
|
77
|
+
:w, 'test-host', :pid, :started_at, 'test',
|
|
78
|
+
:stale, 'active', :project_root
|
|
79
|
+
)`,
|
|
80
|
+
).run({
|
|
81
|
+
":w": workerId,
|
|
82
|
+
":pid": fakePid,
|
|
83
|
+
":started_at": new Date().toISOString(),
|
|
84
|
+
":stale": stalePast,
|
|
85
|
+
":project_root": projectRoot,
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
// Ensure milestones referenced by the unitId exist so the dispatch
|
|
89
|
+
// FK is satisfied. Parse "M###/S##" or "M###" or "starting" / etc.
|
|
90
|
+
const midMatch = unitId.match(/^(M\d+)/);
|
|
91
|
+
if (midMatch && unitType !== "starting") {
|
|
92
|
+
const mid = midMatch[1];
|
|
93
|
+
try { insertMilestone({ id: mid, title: `Test ${mid}`, status: "active" }); }
|
|
94
|
+
catch { /* may already exist */ }
|
|
95
|
+
try {
|
|
96
|
+
const lease = claimMilestoneLease(workerId, mid);
|
|
97
|
+
recordDispatchClaim({
|
|
98
|
+
traceId: randomUUID(),
|
|
99
|
+
workerId,
|
|
100
|
+
milestoneLeaseToken: lease.ok ? lease.token : 0,
|
|
101
|
+
milestoneId: mid,
|
|
102
|
+
unitType,
|
|
103
|
+
unitId,
|
|
104
|
+
});
|
|
105
|
+
} catch { /* ignore — best-effort */ }
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (sessionFile) {
|
|
109
|
+
setRuntimeKv("worker", workerId, "session_file", sessionFile);
|
|
110
|
+
}
|
|
54
111
|
}
|
|
55
112
|
|
|
56
113
|
function writeRoadmap(base: string, checked = false): void {
|
|
@@ -82,6 +139,25 @@ function writeRoadmap(base: string, checked = false): void {
|
|
|
82
139
|
].join("\n"),
|
|
83
140
|
"utf-8",
|
|
84
141
|
);
|
|
142
|
+
// Phase C pt 2: makeTmpBase() opens the DB so writeTestLock can write
|
|
143
|
+
// the workers row. deriveState then goes DB-first; mirror the markdown
|
|
144
|
+
// fixture into the DB so the assessment sees the same milestone state.
|
|
145
|
+
// Use direct upsert SQL so calling writeRoadmap twice (e.g. once for
|
|
146
|
+
// base + once for a paused worktree) actually flips the status.
|
|
147
|
+
const status = checked ? "complete" : "active";
|
|
148
|
+
const adapter = _getAdapter();
|
|
149
|
+
if (adapter) {
|
|
150
|
+
adapter.prepare(
|
|
151
|
+
`INSERT INTO milestones (id, title, status, created_at)
|
|
152
|
+
VALUES (:id, :title, :status, :now)
|
|
153
|
+
ON CONFLICT(id) DO UPDATE SET status = excluded.status, title = excluded.title`,
|
|
154
|
+
).run({ ":id": "M001", ":title": "Test Milestone", ":status": status, ":now": new Date().toISOString() });
|
|
155
|
+
adapter.prepare(
|
|
156
|
+
`INSERT INTO slices (milestone_id, id, title, status, created_at)
|
|
157
|
+
VALUES (:mid, :sid, :title, :status, :now)
|
|
158
|
+
ON CONFLICT(milestone_id, id) DO UPDATE SET status = excluded.status, title = excluded.title`,
|
|
159
|
+
).run({ ":mid": "M001", ":sid": "S01", ":title": "Test slice", ":status": status, ":now": new Date().toISOString() });
|
|
160
|
+
}
|
|
85
161
|
}
|
|
86
162
|
|
|
87
163
|
function writeCompleteSliceArtifacts(base: string): void {
|
|
@@ -105,13 +181,16 @@ function writePausedSession(
|
|
|
105
181
|
unitType?: string,
|
|
106
182
|
unitId?: string,
|
|
107
183
|
): void {
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
184
|
+
// Phase C pt 2: paused-session.json migrated to runtime_kv
|
|
185
|
+
// (global scope, key PAUSED_SESSION_KV_KEY).
|
|
186
|
+
setRuntimeKv("global", "", PAUSED_SESSION_KV_KEY, {
|
|
187
|
+
milestoneId,
|
|
188
|
+
originalBasePath: base,
|
|
189
|
+
stepMode,
|
|
190
|
+
worktreePath,
|
|
191
|
+
unitType,
|
|
192
|
+
unitId,
|
|
193
|
+
});
|
|
115
194
|
}
|
|
116
195
|
|
|
117
196
|
function writeActivityLog(base: string, entries: Record<string, unknown>[]): void {
|
|
@@ -231,14 +310,12 @@ test("readPausedSessionMetadata preserves unitType and unitId through round-trip
|
|
|
231
310
|
test("readPausedSessionMetadata handles legacy metadata without unitType/unitId", () => {
|
|
232
311
|
const base = makeTmpBase();
|
|
233
312
|
try {
|
|
234
|
-
//
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
"utf-8",
|
|
241
|
-
);
|
|
313
|
+
// Phase C pt 2: write directly to runtime_kv (simulates older payload
|
|
314
|
+
// missing the now-canonical unitType/unitId fields).
|
|
315
|
+
setRuntimeKv("global", "", PAUSED_SESSION_KV_KEY, {
|
|
316
|
+
milestoneId: "M001",
|
|
317
|
+
originalBasePath: base,
|
|
318
|
+
});
|
|
242
319
|
const meta = readPausedSessionMetadata(base);
|
|
243
320
|
assert.equal(meta?.milestoneId, "M001");
|
|
244
321
|
assert.equal(meta?.unitType, undefined);
|
|
@@ -251,23 +328,23 @@ test("readPausedSessionMetadata handles legacy metadata without unitType/unitId"
|
|
|
251
328
|
test("readPausedSessionMetadata drops stale discuss-milestone pseudo PROJECT metadata", () => {
|
|
252
329
|
const base = makeTmpBase();
|
|
253
330
|
try {
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
originalBasePath: base,
|
|
262
|
-
unitType: "discuss-milestone",
|
|
263
|
-
unitId: "PROJECT",
|
|
264
|
-
}, null, 2),
|
|
265
|
-
"utf-8",
|
|
266
|
-
);
|
|
331
|
+
// Phase C pt 2: write directly to runtime_kv (the file location is gone)
|
|
332
|
+
setRuntimeKv("global", "", PAUSED_SESSION_KV_KEY, {
|
|
333
|
+
milestoneId: null,
|
|
334
|
+
originalBasePath: base,
|
|
335
|
+
unitType: "discuss-milestone",
|
|
336
|
+
unitId: "PROJECT",
|
|
337
|
+
});
|
|
267
338
|
|
|
268
339
|
const meta = readPausedSessionMetadata(base);
|
|
269
340
|
assert.equal(meta, null);
|
|
270
|
-
|
|
341
|
+
// Confirm the row was deleted by readPausedSessionMetadata's
|
|
342
|
+
// isStalePseudoMilestonePause branch.
|
|
343
|
+
const adapter = _getAdapter()!;
|
|
344
|
+
const row = adapter.prepare(
|
|
345
|
+
`SELECT 1 FROM runtime_kv WHERE scope = 'global' AND scope_id = '' AND key = :k`,
|
|
346
|
+
).get({ ":k": PAUSED_SESSION_KV_KEY });
|
|
347
|
+
assert.equal(row, undefined);
|
|
271
348
|
} finally {
|
|
272
349
|
cleanup(base);
|
|
273
350
|
}
|
|
@@ -276,23 +353,20 @@ test("readPausedSessionMetadata drops stale discuss-milestone pseudo PROJECT met
|
|
|
276
353
|
test("readPausedSessionMetadata drops stale deep setup pseudo-unit metadata", () => {
|
|
277
354
|
const base = makeTmpBase();
|
|
278
355
|
try {
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
milestoneId: "WORKFLOW-PREFS",
|
|
286
|
-
originalBasePath: base,
|
|
287
|
-
unitType: "workflow-preferences",
|
|
288
|
-
unitId: "WORKFLOW-PREFS",
|
|
289
|
-
}, null, 2),
|
|
290
|
-
"utf-8",
|
|
291
|
-
);
|
|
356
|
+
setRuntimeKv("global", "", PAUSED_SESSION_KV_KEY, {
|
|
357
|
+
milestoneId: "WORKFLOW-PREFS",
|
|
358
|
+
originalBasePath: base,
|
|
359
|
+
unitType: "workflow-preferences",
|
|
360
|
+
unitId: "WORKFLOW-PREFS",
|
|
361
|
+
});
|
|
292
362
|
|
|
293
363
|
const meta = readPausedSessionMetadata(base);
|
|
294
364
|
assert.equal(meta, null);
|
|
295
|
-
|
|
365
|
+
const adapter = _getAdapter()!;
|
|
366
|
+
const row = adapter.prepare(
|
|
367
|
+
`SELECT 1 FROM runtime_kv WHERE scope = 'global' AND scope_id = '' AND key = :k`,
|
|
368
|
+
).get({ ":k": PAUSED_SESSION_KV_KEY });
|
|
369
|
+
assert.equal(row, undefined);
|
|
296
370
|
} finally {
|
|
297
371
|
cleanup(base);
|
|
298
372
|
}
|
|
@@ -504,10 +578,29 @@ test("assessInterruptedSession treats bootstrap crash as stale without paused me
|
|
|
504
578
|
// ─── writeLock / readCrashLock ────────────────────────────────────────────
|
|
505
579
|
|
|
506
580
|
test("writeLock creates lock file and readCrashLock reads it", (t) => {
|
|
581
|
+
// Phase C pt 2: lock state is reconstructed from workers + unit_dispatches
|
|
582
|
+
// + runtime_kv. The fresh worker is not stale yet — we register, dispatch,
|
|
583
|
+
// write the session_file, then expire the heartbeat to simulate a crash.
|
|
507
584
|
const base = makeTmpBase();
|
|
508
585
|
t.after(() => cleanup(base));
|
|
509
586
|
|
|
587
|
+
insertMilestone({ id: "M001", title: "Test", status: "active" });
|
|
588
|
+
const projectRoot = normalizeRealPath(base);
|
|
589
|
+
const workerId = registerAutoWorker({ projectRootRealpath: projectRoot });
|
|
590
|
+
const lease = claimMilestoneLease(workerId, "M001");
|
|
591
|
+
assert.equal(lease.ok, true);
|
|
592
|
+
if (!lease.ok) return;
|
|
593
|
+
recordDispatchClaim({
|
|
594
|
+
traceId: "t1", workerId, milestoneLeaseToken: lease.token,
|
|
595
|
+
milestoneId: "M001", unitType: "execute-task", unitId: "M001/S01/T01",
|
|
596
|
+
});
|
|
510
597
|
writeLock(base, "execute-task", "M001/S01/T01", "/tmp/session.jsonl");
|
|
598
|
+
|
|
599
|
+
// Force stale so readCrashLock surfaces it.
|
|
600
|
+
_getAdapter()!.prepare(
|
|
601
|
+
`UPDATE workers SET last_heartbeat_at = '1970-01-01T00:00:00.000Z' WHERE worker_id = :w`,
|
|
602
|
+
).run({ ":w": workerId });
|
|
603
|
+
|
|
511
604
|
const lock = readCrashLock(base);
|
|
512
605
|
assert.ok(lock, "lock should exist");
|
|
513
606
|
assert.equal(lock!.unitType, "execute-task");
|
|
@@ -527,13 +620,30 @@ test("readCrashLock returns null when no lock exists", (t) => {
|
|
|
527
620
|
// ─── clearLock ────────────────────────────────────────────────────────────
|
|
528
621
|
|
|
529
622
|
test("clearLock removes existing lock file", (t) => {
|
|
623
|
+
// Phase C pt 2: clearLock now drops the session_file runtime_kv row
|
|
624
|
+
// for the LIVE worker (not the stale one). The "lock state" itself
|
|
625
|
+
// (pid, unitType, etc.) lives in workers + unit_dispatches; those are
|
|
626
|
+
// managed by markWorkerStopping (called from stopAuto, not here).
|
|
530
627
|
const base = makeTmpBase();
|
|
531
628
|
t.after(() => cleanup(base));
|
|
532
629
|
|
|
533
|
-
|
|
534
|
-
|
|
630
|
+
const projectRoot = normalizeRealPath(base);
|
|
631
|
+
const workerId = registerAutoWorker({ projectRootRealpath: projectRoot });
|
|
632
|
+
|
|
633
|
+
writeLock(base, "plan-slice", "M001/S01", "/tmp/session.jsonl");
|
|
634
|
+
// Confirm the session_file row landed for the live worker.
|
|
635
|
+
const adapter = _getAdapter()!;
|
|
636
|
+
const before = adapter.prepare(
|
|
637
|
+
`SELECT 1 FROM runtime_kv WHERE scope = 'worker' AND scope_id = :w AND key = 'session_file'`,
|
|
638
|
+
).get({ ":w": workerId });
|
|
639
|
+
assert.ok(before, "session_file row exists before clear");
|
|
640
|
+
|
|
535
641
|
clearLock(base);
|
|
536
|
-
|
|
642
|
+
|
|
643
|
+
const after = adapter.prepare(
|
|
644
|
+
`SELECT 1 FROM runtime_kv WHERE scope = 'worker' AND scope_id = :w AND key = 'session_file'`,
|
|
645
|
+
).get({ ":w": workerId });
|
|
646
|
+
assert.equal(after, undefined, "session_file row gone after clearLock");
|
|
537
647
|
});
|
|
538
648
|
|
|
539
649
|
test("clearLock is safe when no lock exists", (t) => {
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
// gsd-2 + Stuck-detector retry coupling regression (Phase B / codex MEDIUM B3)
|
|
2
|
+
//
|
|
3
|
+
// Rule 2b previously tripped on 3 same-unit appearances regardless of
|
|
4
|
+
// retry budget. With unit_dispatches.attempt_n + next_run_at driving in-DB
|
|
5
|
+
// backoff, a unit that fails 3× under retry would trip the stuck-detector
|
|
6
|
+
// before its retry budget exhausted. This test verifies suppression while
|
|
7
|
+
// the retry window is open and re-engagement once the window passes or
|
|
8
|
+
// budget exhausts.
|
|
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 {
|
|
25
|
+
recordDispatchClaim,
|
|
26
|
+
markFailed,
|
|
27
|
+
getLatestForUnit,
|
|
28
|
+
} from "../db/unit-dispatches.ts";
|
|
29
|
+
import { detectStuck } from "../auto/detect-stuck.ts";
|
|
30
|
+
import type { WindowEntry } from "../auto/types.ts";
|
|
31
|
+
|
|
32
|
+
function makeBase(): string {
|
|
33
|
+
const base = mkdtempSync(join(tmpdir(), "gsd-detect-stuck-retry-"));
|
|
34
|
+
mkdirSync(join(base, ".gsd"), { recursive: true });
|
|
35
|
+
return base;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function cleanup(base: string): void {
|
|
39
|
+
try { closeDatabase(); } catch { /* noop */ }
|
|
40
|
+
try { rmSync(base, { recursive: true, force: true }); } catch { /* noop */ }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function windowOf(...keys: string[]): WindowEntry[] {
|
|
44
|
+
return keys.map((key) => ({ key }));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
test("rule 2b trips with no DB context (legacy behavior preserved)", () => {
|
|
48
|
+
// No DB open — getLatestForUnit returns null, suppression cannot fire,
|
|
49
|
+
// pre-Phase-B behavior is intact.
|
|
50
|
+
const result = detectStuck(
|
|
51
|
+
windowOf(
|
|
52
|
+
"plan-slice:M001/S01",
|
|
53
|
+
"other-unit",
|
|
54
|
+
"plan-slice:M001/S01",
|
|
55
|
+
"third-unit",
|
|
56
|
+
"plan-slice:M001/S01",
|
|
57
|
+
),
|
|
58
|
+
);
|
|
59
|
+
assert.ok(result, "stuck signal returned");
|
|
60
|
+
assert.equal(result!.stuck, true);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("rule 2b SUPPRESSED while retry budget remains and next_run_at is in the future", (t) => {
|
|
64
|
+
const base = makeBase();
|
|
65
|
+
t.after(() => cleanup(base));
|
|
66
|
+
openDatabase(join(base, ".gsd", "gsd.db"));
|
|
67
|
+
insertMilestone({ id: "M001", title: "T", status: "active" });
|
|
68
|
+
const w = registerAutoWorker({ projectRootRealpath: base });
|
|
69
|
+
const lease = claimMilestoneLease(w, "M001");
|
|
70
|
+
assert.equal(lease.ok, true);
|
|
71
|
+
if (!lease.ok) return;
|
|
72
|
+
|
|
73
|
+
// Record a failed dispatch with attempt_n=1, max_attempts=3, retry_after
|
|
74
|
+
// pushing next_run_at into the future.
|
|
75
|
+
const claim = recordDispatchClaim({
|
|
76
|
+
traceId: "t1", workerId: w, milestoneLeaseToken: lease.token,
|
|
77
|
+
milestoneId: "M001", unitType: "plan-slice", unitId: "plan-slice:M001/S01",
|
|
78
|
+
attemptN: 1, maxAttempts: 3,
|
|
79
|
+
});
|
|
80
|
+
assert.equal(claim.ok, true);
|
|
81
|
+
if (!claim.ok) return;
|
|
82
|
+
markFailed(claim.dispatchId, { errorSummary: "transient", retryAfterMs: 60_000 });
|
|
83
|
+
|
|
84
|
+
const latest = getLatestForUnit("plan-slice:M001/S01")!;
|
|
85
|
+
assert.equal(latest.attempt_n, 1);
|
|
86
|
+
assert.ok(latest.next_run_at);
|
|
87
|
+
|
|
88
|
+
const result = detectStuck(
|
|
89
|
+
windowOf(
|
|
90
|
+
"plan-slice:M001/S01",
|
|
91
|
+
"other-unit",
|
|
92
|
+
"plan-slice:M001/S01",
|
|
93
|
+
"third-unit",
|
|
94
|
+
"plan-slice:M001/S01",
|
|
95
|
+
),
|
|
96
|
+
);
|
|
97
|
+
assert.equal(result, null, "rule 2b suppressed while retry window is active");
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("rule 2b RE-ENGAGES once attempt_n reaches max_attempts", (t) => {
|
|
101
|
+
const base = makeBase();
|
|
102
|
+
t.after(() => cleanup(base));
|
|
103
|
+
openDatabase(join(base, ".gsd", "gsd.db"));
|
|
104
|
+
insertMilestone({ id: "M001", title: "T", status: "active" });
|
|
105
|
+
const w = registerAutoWorker({ projectRootRealpath: base });
|
|
106
|
+
const lease = claimMilestoneLease(w, "M001");
|
|
107
|
+
assert.equal(lease.ok, true);
|
|
108
|
+
if (!lease.ok) return;
|
|
109
|
+
|
|
110
|
+
// Burn through attempts up to the cap — last attempt = max_attempts.
|
|
111
|
+
for (let attempt = 1; attempt <= 3; attempt++) {
|
|
112
|
+
const claim = recordDispatchClaim({
|
|
113
|
+
traceId: `t${attempt}`, workerId: w, milestoneLeaseToken: lease.token,
|
|
114
|
+
milestoneId: "M001", unitType: "plan-slice", unitId: "plan-slice:M001/S01",
|
|
115
|
+
attemptN: attempt, maxAttempts: 3,
|
|
116
|
+
});
|
|
117
|
+
assert.equal(claim.ok, true);
|
|
118
|
+
if (!claim.ok) return;
|
|
119
|
+
markFailed(claim.dispatchId, { errorSummary: "transient", retryAfterMs: 60_000 });
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const latest = getLatestForUnit("plan-slice:M001/S01")!;
|
|
123
|
+
assert.equal(latest.attempt_n, 3);
|
|
124
|
+
assert.equal(latest.max_attempts, 3);
|
|
125
|
+
|
|
126
|
+
const result = detectStuck(
|
|
127
|
+
windowOf(
|
|
128
|
+
"plan-slice:M001/S01",
|
|
129
|
+
"other-unit",
|
|
130
|
+
"plan-slice:M001/S01",
|
|
131
|
+
"third-unit",
|
|
132
|
+
"plan-slice:M001/S01",
|
|
133
|
+
),
|
|
134
|
+
);
|
|
135
|
+
assert.ok(result, "stuck signal returned once retry budget is exhausted");
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test("rule 2b RE-ENGAGES once next_run_at is in the past", (t) => {
|
|
139
|
+
const base = makeBase();
|
|
140
|
+
t.after(() => cleanup(base));
|
|
141
|
+
openDatabase(join(base, ".gsd", "gsd.db"));
|
|
142
|
+
insertMilestone({ id: "M001", title: "T", status: "active" });
|
|
143
|
+
const w = registerAutoWorker({ projectRootRealpath: base });
|
|
144
|
+
const lease = claimMilestoneLease(w, "M001");
|
|
145
|
+
assert.equal(lease.ok, true);
|
|
146
|
+
if (!lease.ok) return;
|
|
147
|
+
|
|
148
|
+
const claim = recordDispatchClaim({
|
|
149
|
+
traceId: "t", workerId: w, milestoneLeaseToken: lease.token,
|
|
150
|
+
milestoneId: "M001", unitType: "plan-slice", unitId: "plan-slice:M001/S01",
|
|
151
|
+
attemptN: 1, maxAttempts: 3,
|
|
152
|
+
});
|
|
153
|
+
assert.equal(claim.ok, true);
|
|
154
|
+
if (!claim.ok) return;
|
|
155
|
+
markFailed(claim.dispatchId, { errorSummary: "transient", retryAfterMs: 60_000 });
|
|
156
|
+
|
|
157
|
+
// Force next_run_at into the past — retry window has already lapsed.
|
|
158
|
+
const db = _getAdapter()!;
|
|
159
|
+
db.prepare(
|
|
160
|
+
`UPDATE unit_dispatches SET next_run_at = '1970-01-01T00:00:00.000Z' WHERE id = :id`,
|
|
161
|
+
).run({ ":id": claim.dispatchId });
|
|
162
|
+
|
|
163
|
+
const result = detectStuck(
|
|
164
|
+
windowOf(
|
|
165
|
+
"plan-slice:M001/S01",
|
|
166
|
+
"other-unit",
|
|
167
|
+
"plan-slice:M001/S01",
|
|
168
|
+
"third-unit",
|
|
169
|
+
"plan-slice:M001/S01",
|
|
170
|
+
),
|
|
171
|
+
);
|
|
172
|
+
assert.ok(result, "stuck re-engages once retry window has passed");
|
|
173
|
+
});
|
|
@@ -160,7 +160,7 @@ describe("auto-worktree lifecycle", () => {
|
|
|
160
160
|
assert.ok(realWtPath.startsWith(storage), "git registered the symlink-resolved worktree path");
|
|
161
161
|
|
|
162
162
|
_resetAutoWorktreeOriginalBaseForTests();
|
|
163
|
-
process.chdir(
|
|
163
|
+
process.chdir(realWtPath);
|
|
164
164
|
|
|
165
165
|
assert.ok(isInAutoWorktree(tempDir), "structural detection works without module originalBase");
|
|
166
166
|
const resolved = getAutoWorktreePath(realWtPath, "M001");
|
|
@@ -169,7 +169,7 @@ describe("auto-worktree lifecycle", () => {
|
|
|
169
169
|
assert.equal(existsSync(join(realWtPath, ".gsd", "worktrees", "M001")), false);
|
|
170
170
|
|
|
171
171
|
enterAutoWorktree(tempDir, "M001");
|
|
172
|
-
process.chdir(
|
|
172
|
+
process.chdir(realWtPath);
|
|
173
173
|
assert.deepStrictEqual(
|
|
174
174
|
getActiveAutoWorktreeContext(),
|
|
175
175
|
{
|
|
@@ -282,7 +282,7 @@ describe("auto-worktree lifecycle", () => {
|
|
|
282
282
|
teardownAutoWorktree(tempDir, "M010");
|
|
283
283
|
});
|
|
284
284
|
|
|
285
|
-
test("#778: reconcile plan checkboxes
|
|
285
|
+
test("#778: re-attach does not reconcile plan checkboxes into a worktree-local .gsd projection", async () => {
|
|
286
286
|
tempDir = createTempRepo();
|
|
287
287
|
const msDir = join(tempDir, ".gsd", "milestones", "M003");
|
|
288
288
|
mkdirSync(msDir, { recursive: true });
|
|
@@ -322,23 +322,31 @@ describe("auto-worktree lifecycle", () => {
|
|
|
322
322
|
"# S01 Plan\n- [x] **T01:** task one\n- [x] **T02:** task two\n- [ ] **T03:** task three\n",
|
|
323
323
|
);
|
|
324
324
|
|
|
325
|
-
//
|
|
325
|
+
// Re-attaching the worktree should not reconcile the branch copy from the
|
|
326
|
+
// project-root plan. The project root stays canonical; the worktree keeps
|
|
327
|
+
// the milestone branch's tracked file contents until a git operation
|
|
328
|
+
// changes them.
|
|
326
329
|
const wtPath = createAutoWorktree(tempDir, "M004");
|
|
327
330
|
|
|
328
331
|
try {
|
|
329
332
|
const wtPlanPath = join(wtPath, planRelPath);
|
|
330
|
-
assert.ok(existsSync(wtPlanPath), "plan file
|
|
333
|
+
assert.ok(existsSync(wtPlanPath), "tracked plan file remains present in the worktree branch");
|
|
331
334
|
|
|
332
335
|
const wtPlan = read(wtPlanPath, "utf-8");
|
|
333
|
-
assert.ok(wtPlan.includes("- [
|
|
334
|
-
assert.ok(wtPlan.includes("- [x] **T01:"), "T01
|
|
335
|
-
assert.ok(wtPlan.includes("- [ ] **T03:"), "
|
|
336
|
+
assert.ok(wtPlan.includes("- [ ] **T02:"), "worktree branch should retain its unreconciled T02 [ ] state");
|
|
337
|
+
assert.ok(wtPlan.includes("- [x] **T01:"), "worktree branch should retain T01 [x]");
|
|
338
|
+
assert.ok(wtPlan.includes("- [ ] **T03:"), "worktree branch should retain T03 [ ]");
|
|
339
|
+
|
|
340
|
+
const rootPlan = read(join(tempDir, planRelPath), "utf-8");
|
|
341
|
+
assert.ok(rootPlan.includes("- [x] **T02:"), "canonical root plan retains the newer T02 [x] state");
|
|
342
|
+
assert.ok(rootPlan.includes("- [x] **T01:"), "canonical root plan retains T01 [x]");
|
|
343
|
+
assert.ok(rootPlan.includes("- [ ] **T03:"), "canonical root plan retains T03 [ ]");
|
|
336
344
|
} finally {
|
|
337
345
|
teardownAutoWorktree(tempDir, "M004");
|
|
338
346
|
}
|
|
339
347
|
});
|
|
340
348
|
|
|
341
|
-
test("#2791: mcp.json copied into worktree
|
|
349
|
+
test("#2791: mcp.json is not copied into worktree on creation after copyPlanningArtifacts removal", () => {
|
|
342
350
|
tempDir = createTempRepo();
|
|
343
351
|
const msDir = join(tempDir, ".gsd", "milestones", "M003");
|
|
344
352
|
mkdirSync(msDir, { recursive: true });
|
|
@@ -347,7 +355,8 @@ describe("auto-worktree lifecycle", () => {
|
|
|
347
355
|
run("git commit -m \"add milestone\"", tempDir);
|
|
348
356
|
|
|
349
357
|
// Create mcp.json in .gsd/ AFTER the commit (untracked, like real usage).
|
|
350
|
-
// copyPlanningArtifacts
|
|
358
|
+
// Phase C removed copyPlanningArtifacts, so creation should not seed a
|
|
359
|
+
// second worktree-local copy.
|
|
351
360
|
writeFileSync(
|
|
352
361
|
join(tempDir, ".gsd", "mcp.json"),
|
|
353
362
|
JSON.stringify({ servers: { test: { command: "echo" } } }),
|
|
@@ -356,9 +365,10 @@ describe("auto-worktree lifecycle", () => {
|
|
|
356
365
|
const wtPath = createAutoWorktree(tempDir, "M003");
|
|
357
366
|
|
|
358
367
|
try {
|
|
359
|
-
assert.
|
|
368
|
+
assert.equal(
|
|
360
369
|
existsSync(join(wtPath, ".gsd", "mcp.json")),
|
|
361
|
-
|
|
370
|
+
false,
|
|
371
|
+
"mcp.json should not be copied into worktree .gsd/ on creation",
|
|
362
372
|
);
|
|
363
373
|
} finally {
|
|
364
374
|
teardownAutoWorktree(tempDir, "M003");
|
|
@@ -240,17 +240,31 @@ describe('doctor-proactive', async () => {
|
|
|
240
240
|
cleanups.push(dir);
|
|
241
241
|
mkdirSync(join(dir, ".gsd"), { recursive: true });
|
|
242
242
|
|
|
243
|
-
//
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
})
|
|
243
|
+
// Phase C pt 2: stale lock state lives in the workers table now.
|
|
244
|
+
// Open the DB, insert a fake stale worker row directly (PID 9999999
|
|
245
|
+
// is functionally guaranteed dead), then close — the doctor will
|
|
246
|
+
// re-open via its own path.
|
|
247
|
+
const { openDatabase, _getAdapter } = await import("../../gsd-db.ts");
|
|
248
|
+
const { randomUUID } = await import("node:crypto");
|
|
249
|
+
openDatabase(join(dir, ".gsd", "gsd.db"));
|
|
250
|
+
const db = _getAdapter()!;
|
|
251
|
+
db.prepare(
|
|
252
|
+
`INSERT INTO workers (worker_id, host, pid, started_at, version, last_heartbeat_at, status, project_root_realpath)
|
|
253
|
+
VALUES (:w, 'test-host', 9999999, '2026-03-10T00:00:00Z', 'test', '1970-01-01T00:00:00.000Z', 'active', :root)`,
|
|
254
|
+
).run({ ":w": `test-fake-${randomUUID().slice(0, 8)}`, ":root": dir });
|
|
255
|
+
const { closeDatabase } = await import("../../gsd-db.ts");
|
|
256
|
+
closeDatabase();
|
|
249
257
|
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
258
|
+
try {
|
|
259
|
+
const result = await preDispatchHealthGate(dir);
|
|
260
|
+
assert.ok(result.proceed, "gate passes after auto-clearing stale lock");
|
|
261
|
+
assert.ok(
|
|
262
|
+
result.fixesApplied.some(f => f.includes("cleared stale") || f.includes("cleared stale auto.lock")),
|
|
263
|
+
`reports lock cleared (got: ${result.fixesApplied.join(", ")})`,
|
|
264
|
+
);
|
|
265
|
+
} finally {
|
|
266
|
+
closeDatabase();
|
|
267
|
+
}
|
|
254
268
|
});
|
|
255
269
|
|
|
256
270
|
test('health gate: corrupt merge state auto-healed', async () => {
|