gsd-pi 2.78.1-dev.d8826a445 → 2.78.1-dev.eccf86e27
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -7
- package/dist/help-text.js +1 -1
- package/dist/resource-loader.js +6 -1
- package/dist/resources/.managed-resources-content-hash +1 -1
- package/dist/resources/extensions/gsd/auto/detect-stuck.js +41 -5
- package/dist/resources/extensions/gsd/auto/loop.js +235 -36
- package/dist/resources/extensions/gsd/auto/phases.js +7 -5
- package/dist/resources/extensions/gsd/auto/session.js +33 -0
- package/dist/resources/extensions/gsd/auto-dispatch.js +46 -2
- package/dist/resources/extensions/gsd/auto-post-unit.js +19 -11
- package/dist/resources/extensions/gsd/auto-worktree.js +26 -187
- package/dist/resources/extensions/gsd/auto.js +79 -50
- package/dist/resources/extensions/gsd/bootstrap/register-hooks.js +9 -4
- package/dist/resources/extensions/gsd/crash-recovery.js +160 -47
- package/dist/resources/extensions/gsd/db/auto-workers.js +227 -0
- package/dist/resources/extensions/gsd/db/command-queue.js +105 -0
- package/dist/resources/extensions/gsd/db/milestone-leases.js +210 -0
- package/dist/resources/extensions/gsd/db/runtime-kv.js +91 -0
- package/dist/resources/extensions/gsd/db/unit-dispatches.js +322 -0
- package/dist/resources/extensions/gsd/docs/COORDINATION.md +42 -0
- package/dist/resources/extensions/gsd/doctor-proactive.js +4 -0
- package/dist/resources/extensions/gsd/doctor-runtime-checks.js +22 -6
- package/dist/resources/extensions/gsd/doctor.js +12 -2
- package/dist/resources/extensions/gsd/gsd-db.js +161 -3
- package/dist/resources/extensions/gsd/guided-flow.js +6 -2
- package/dist/resources/extensions/gsd/interrupted-session.js +18 -15
- package/dist/resources/extensions/gsd/state.js +21 -6
- package/dist/resources/extensions/gsd/worktree-resolver.js +64 -0
- package/dist/tsconfig.extensions.tsbuildinfo +1 -1
- package/dist/web/standalone/.next/BUILD_ID +1 -1
- package/dist/web/standalone/.next/app-path-routes-manifest.json +12 -12
- package/dist/web/standalone/.next/build-manifest.json +2 -2
- package/dist/web/standalone/.next/prerender-manifest.json +3 -3
- package/dist/web/standalone/.next/server/app/_global-error.html +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.html +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/index.html +1 -1
- package/dist/web/standalone/.next/server/app/index.rsc +1 -1
- package/dist/web/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/index.segments/_full.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/index.segments/_head.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/index.segments/_index.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app-paths-manifest.json +12 -12
- package/dist/web/standalone/.next/server/middleware-build-manifest.js +1 -1
- package/dist/web/standalone/.next/server/pages/404.html +1 -1
- package/dist/web/standalone/.next/server/pages/500.html +1 -1
- package/dist/web/standalone/.next/server/server-reference-manifest.json +1 -1
- package/package.json +1 -1
- package/src/resources/extensions/gsd/auto/detect-stuck.ts +37 -5
- package/src/resources/extensions/gsd/auto/loop.ts +263 -41
- package/src/resources/extensions/gsd/auto/phases.ts +7 -5
- package/src/resources/extensions/gsd/auto/session.ts +36 -0
- package/src/resources/extensions/gsd/auto-dispatch.ts +53 -2
- package/src/resources/extensions/gsd/auto-post-unit.ts +19 -11
- package/src/resources/extensions/gsd/auto-worktree.ts +26 -211
- package/src/resources/extensions/gsd/auto.ts +89 -44
- package/src/resources/extensions/gsd/bootstrap/register-hooks.ts +9 -4
- package/src/resources/extensions/gsd/crash-recovery.ts +177 -43
- package/src/resources/extensions/gsd/db/auto-workers.ts +273 -0
- package/src/resources/extensions/gsd/db/command-queue.ts +149 -0
- package/src/resources/extensions/gsd/db/milestone-leases.ts +274 -0
- package/src/resources/extensions/gsd/db/runtime-kv.ts +127 -0
- package/src/resources/extensions/gsd/db/unit-dispatches.ts +446 -0
- package/src/resources/extensions/gsd/docs/COORDINATION.md +42 -0
- package/src/resources/extensions/gsd/doctor-proactive.ts +4 -0
- package/src/resources/extensions/gsd/doctor-runtime-checks.ts +24 -6
- package/src/resources/extensions/gsd/doctor.ts +10 -2
- package/src/resources/extensions/gsd/gsd-db.ts +170 -3
- package/src/resources/extensions/gsd/guided-flow.ts +6 -2
- package/src/resources/extensions/gsd/interrupted-session.ts +19 -12
- package/src/resources/extensions/gsd/state.ts +44 -6
- package/src/resources/extensions/gsd/tests/auto-loop-no-copy-artifacts.test.ts +72 -0
- package/src/resources/extensions/gsd/tests/auto-loop-symlink-worktree.test.ts +190 -0
- package/src/resources/extensions/gsd/tests/auto-workers.test.ts +105 -0
- package/src/resources/extensions/gsd/tests/command-queue.test.ts +141 -0
- package/src/resources/extensions/gsd/tests/crash-recovery-via-db.test.ts +203 -0
- package/src/resources/extensions/gsd/tests/crash-recovery.test.ts +169 -59
- package/src/resources/extensions/gsd/tests/detect-stuck-respects-retry.test.ts +173 -0
- package/src/resources/extensions/gsd/tests/integration/auto-worktree.test.ts +22 -12
- package/src/resources/extensions/gsd/tests/integration/doctor-proactive.test.ts +24 -10
- package/src/resources/extensions/gsd/tests/integration/doctor-runtime.test.ts +35 -23
- package/src/resources/extensions/gsd/tests/integration/workspace-collapse-integration.test.ts +3 -5
- package/src/resources/extensions/gsd/tests/interrupted-session-auto.test.ts +72 -25
- package/src/resources/extensions/gsd/tests/interrupted-session-ui.test.ts +72 -25
- package/src/resources/extensions/gsd/tests/memory-pressure-stuck-state.test.ts +9 -6
- package/src/resources/extensions/gsd/tests/milestone-leases.test.ts +152 -0
- package/src/resources/extensions/gsd/tests/parallel-milestone-isolation.test.ts +106 -0
- package/src/resources/extensions/gsd/tests/paused-session-via-db.test.ts +119 -0
- package/src/resources/extensions/gsd/tests/pipeline-variant-dispatch.test.ts +58 -0
- package/src/resources/extensions/gsd/tests/preferences-worktree-sync.test.ts +3 -17
- package/src/resources/extensions/gsd/tests/register-hooks-depth-verification.test.ts +110 -0
- package/src/resources/extensions/gsd/tests/runtime-kv.test.ts +120 -0
- package/src/resources/extensions/gsd/tests/skipped-validation-completion.test.ts +133 -28
- package/src/resources/extensions/gsd/tests/skipped-validation-db-atomicity.test.ts +17 -0
- package/src/resources/extensions/gsd/tests/stuck-state-via-db.test.ts +134 -0
- package/src/resources/extensions/gsd/tests/sync-layer-scope.test.ts +7 -26
- package/src/resources/extensions/gsd/tests/teardown-cleanup-parity.test.ts +4 -8
- package/src/resources/extensions/gsd/tests/unit-dispatches.test.ts +247 -0
- package/src/resources/extensions/gsd/tests/validate-milestone.test.ts +41 -1
- package/src/resources/extensions/gsd/tests/workspace.test.ts +15 -9
- package/src/resources/extensions/gsd/tests/write-gate.test.ts +31 -23
- package/src/resources/extensions/gsd/worktree-resolver.ts +62 -0
- package/src/resources/extensions/gsd/tests/auto-lock-creation.test.ts +0 -213
- package/src/resources/extensions/gsd/tests/auto-stale-lock-self-kill.test.ts +0 -87
- package/src/resources/extensions/gsd/tests/stop-auto-remote.test.ts +0 -159
- /package/dist/web/standalone/.next/static/{AT5qi39nKXkdmQIOIoh0f → Y5UeGFkXTYM9WIQOWHkot}/_buildManifest.js +0 -0
- /package/dist/web/standalone/.next/static/{AT5qi39nKXkdmQIOIoh0f → Y5UeGFkXTYM9WIQOWHkot}/_ssgManifest.js +0 -0
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// gsd-2 + Paused-session via runtime_kv (Phase C pt 2 — paused-session.json migration)
|
|
2
|
+
//
|
|
3
|
+
// runtime/paused-session.json is gone. The metadata that the old file
|
|
4
|
+
// stored now lives in runtime_kv (global scope, key PAUSED_SESSION_KV_KEY).
|
|
5
|
+
// readPausedSessionMetadata reads the key; the writer in pauseAuto +
|
|
6
|
+
// the cleanup in stopAuto/startAuto/guided-flow all use the same key.
|
|
7
|
+
//
|
|
8
|
+
// These tests verify the round-trip via the storage layer directly.
|
|
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
|
+
} from "../gsd-db.ts";
|
|
20
|
+
import {
|
|
21
|
+
setRuntimeKv,
|
|
22
|
+
getRuntimeKv,
|
|
23
|
+
deleteRuntimeKv,
|
|
24
|
+
} from "../db/runtime-kv.ts";
|
|
25
|
+
import {
|
|
26
|
+
readPausedSessionMetadata,
|
|
27
|
+
PAUSED_SESSION_KV_KEY,
|
|
28
|
+
type PausedSessionMetadata,
|
|
29
|
+
} from "../interrupted-session.ts";
|
|
30
|
+
|
|
31
|
+
function makeBase(): string {
|
|
32
|
+
const base = mkdtempSync(join(tmpdir(), "gsd-paused-session-"));
|
|
33
|
+
mkdirSync(join(base, ".gsd"), { recursive: true });
|
|
34
|
+
return base;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function cleanup(base: string): void {
|
|
38
|
+
try { closeDatabase(); } catch { /* noop */ }
|
|
39
|
+
try { rmSync(base, { recursive: true, force: true }); } catch { /* noop */ }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
test("readPausedSessionMetadata returns null when no row exists", (t) => {
|
|
43
|
+
const base = makeBase();
|
|
44
|
+
t.after(() => cleanup(base));
|
|
45
|
+
openDatabase(join(base, ".gsd", "gsd.db"));
|
|
46
|
+
assert.equal(readPausedSessionMetadata(base), null);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("readPausedSessionMetadata round-trips a real PausedSessionMetadata payload", (t) => {
|
|
50
|
+
const base = makeBase();
|
|
51
|
+
t.after(() => cleanup(base));
|
|
52
|
+
openDatabase(join(base, ".gsd", "gsd.db"));
|
|
53
|
+
|
|
54
|
+
const meta: PausedSessionMetadata = {
|
|
55
|
+
milestoneId: "M001",
|
|
56
|
+
worktreePath: "/tmp/wt",
|
|
57
|
+
originalBasePath: base,
|
|
58
|
+
stepMode: false,
|
|
59
|
+
pausedAt: new Date().toISOString(),
|
|
60
|
+
sessionFile: "/tmp/session.jsonl",
|
|
61
|
+
unitType: "plan-slice",
|
|
62
|
+
unitId: "M001/S01",
|
|
63
|
+
activeEngineId: "dev",
|
|
64
|
+
activeRunDir: null,
|
|
65
|
+
autoStartTime: Date.now(),
|
|
66
|
+
milestoneLock: null,
|
|
67
|
+
};
|
|
68
|
+
setRuntimeKv("global", "", PAUSED_SESSION_KV_KEY, meta);
|
|
69
|
+
|
|
70
|
+
const loaded = readPausedSessionMetadata(base);
|
|
71
|
+
assert.ok(loaded);
|
|
72
|
+
assert.equal(loaded!.milestoneId, "M001");
|
|
73
|
+
assert.equal(loaded!.unitType, "plan-slice");
|
|
74
|
+
assert.equal(loaded!.unitId, "M001/S01");
|
|
75
|
+
assert.equal(loaded!.sessionFile, "/tmp/session.jsonl");
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("readPausedSessionMetadata auto-deletes stale pseudo-milestone pause rows", (t) => {
|
|
79
|
+
const base = makeBase();
|
|
80
|
+
t.after(() => cleanup(base));
|
|
81
|
+
openDatabase(join(base, ".gsd", "gsd.db"));
|
|
82
|
+
|
|
83
|
+
// discuss-milestone with a non-MID-shaped unitId triggers
|
|
84
|
+
// isStalePseudoMilestonePause → returns null + deletes the row.
|
|
85
|
+
const stale: PausedSessionMetadata = {
|
|
86
|
+
milestoneId: "M001",
|
|
87
|
+
unitType: "discuss-milestone",
|
|
88
|
+
unitId: "PROJECT-thing",
|
|
89
|
+
activeEngineId: "dev",
|
|
90
|
+
};
|
|
91
|
+
setRuntimeKv("global", "", PAUSED_SESSION_KV_KEY, stale);
|
|
92
|
+
|
|
93
|
+
assert.equal(readPausedSessionMetadata(base), null);
|
|
94
|
+
assert.equal(getRuntimeKv("global", "", PAUSED_SESSION_KV_KEY), null,
|
|
95
|
+
"stale row was deleted by readPausedSessionMetadata");
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("deleteRuntimeKv on PAUSED_SESSION_KV_KEY removes the row idempotently", (t) => {
|
|
99
|
+
const base = makeBase();
|
|
100
|
+
t.after(() => cleanup(base));
|
|
101
|
+
openDatabase(join(base, ".gsd", "gsd.db"));
|
|
102
|
+
|
|
103
|
+
setRuntimeKv("global", "", PAUSED_SESSION_KV_KEY, { milestoneId: "M001" });
|
|
104
|
+
deleteRuntimeKv("global", "", PAUSED_SESSION_KV_KEY);
|
|
105
|
+
deleteRuntimeKv("global", "", PAUSED_SESSION_KV_KEY); // idempotent — no throw
|
|
106
|
+
assert.equal(readPausedSessionMetadata(base), null);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("readPausedSessionMetadata returns null when DB is unavailable", () => {
|
|
110
|
+
// No openDatabase call — DB is closed.
|
|
111
|
+
try { closeDatabase(); } catch { /* noop */ }
|
|
112
|
+
// Use a tmpdir-style base; the function should handle DB-unavailable gracefully.
|
|
113
|
+
const base = mkdtempSync(join(tmpdir(), "gsd-paused-no-db-"));
|
|
114
|
+
try {
|
|
115
|
+
assert.equal(readPausedSessionMetadata(base), null);
|
|
116
|
+
} finally {
|
|
117
|
+
rmSync(base, { recursive: true, force: true });
|
|
118
|
+
}
|
|
119
|
+
});
|
|
@@ -11,6 +11,7 @@ import { tmpdir } from "node:os";
|
|
|
11
11
|
|
|
12
12
|
import { DISPATCH_RULES, type DispatchContext } from "../auto-dispatch.ts";
|
|
13
13
|
import {
|
|
14
|
+
_getAdapter,
|
|
14
15
|
openDatabase,
|
|
15
16
|
closeDatabase,
|
|
16
17
|
insertMilestone,
|
|
@@ -241,9 +242,66 @@ test("#4781 phase 2: validate-milestone rule writes pass-through VALIDATION for
|
|
|
241
242
|
const { readFileSync } = await import("node:fs");
|
|
242
243
|
const content = readFileSync(validationPath, "utf-8");
|
|
243
244
|
assert.match(content, /verdict: pass/);
|
|
245
|
+
assert.match(content, /skip_validation: true/);
|
|
244
246
|
assert.match(content, /trivial-scope pipeline variant \(#4781\)/);
|
|
245
247
|
});
|
|
246
248
|
|
|
249
|
+
test("#4781 phase 2: validate-milestone skip path does not persist gates without a real slice", async (t) => {
|
|
250
|
+
const base = makeBase();
|
|
251
|
+
t.after(() => cleanup(base));
|
|
252
|
+
|
|
253
|
+
openDatabase(join(base, ".gsd", "gsd.db"));
|
|
254
|
+
insertMilestone({
|
|
255
|
+
id: "M001",
|
|
256
|
+
title: TRIVIAL_INPUT.title,
|
|
257
|
+
status: "active",
|
|
258
|
+
depends_on: [],
|
|
259
|
+
});
|
|
260
|
+
upsertMilestonePlanning("M001", {
|
|
261
|
+
title: TRIVIAL_INPUT.title,
|
|
262
|
+
status: "active",
|
|
263
|
+
vision: TRIVIAL_INPUT.vision,
|
|
264
|
+
successCriteria: TRIVIAL_INPUT.successCriteria,
|
|
265
|
+
keyRisks: [],
|
|
266
|
+
proofStrategy: [],
|
|
267
|
+
verificationContract: "",
|
|
268
|
+
verificationIntegration: "",
|
|
269
|
+
verificationOperational: "",
|
|
270
|
+
verificationUat: "",
|
|
271
|
+
definitionOfDone: [],
|
|
272
|
+
requirementCoverage: "",
|
|
273
|
+
boundaryMapMarkdown: "",
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
const { writeFileSync, readFileSync } = await import("node:fs");
|
|
277
|
+
writeFileSync(
|
|
278
|
+
join(base, ".gsd", "milestones", "M001", "M001-ROADMAP.md"),
|
|
279
|
+
[
|
|
280
|
+
"# M001",
|
|
281
|
+
"## Slices",
|
|
282
|
+
"",
|
|
283
|
+
"_No slices required for this trivial milestone._",
|
|
284
|
+
].join("\n"),
|
|
285
|
+
);
|
|
286
|
+
|
|
287
|
+
const ctx = makeCtx({ base, mid: "M001", phase: "validating-milestone" });
|
|
288
|
+
const result = await findRule(VALIDATE_RULE).match(ctx);
|
|
289
|
+
|
|
290
|
+
assert.ok(result, "rule must return a result, not null");
|
|
291
|
+
assert.strictEqual(result!.action, "skip", "trivial variant must still skip without slices");
|
|
292
|
+
|
|
293
|
+
const validationPath = join(base, ".gsd", "milestones", "M001", "M001-VALIDATION.md");
|
|
294
|
+
const content = readFileSync(validationPath, "utf-8");
|
|
295
|
+
assert.match(content, /skip_validation: true/);
|
|
296
|
+
|
|
297
|
+
const adapter = _getAdapter();
|
|
298
|
+
assert.ok(adapter, "test database should be open");
|
|
299
|
+
const gateCount = adapter.prepare(
|
|
300
|
+
"SELECT count(*) AS n FROM quality_gates WHERE milestone_id = 'M001'",
|
|
301
|
+
).get() as { n: number };
|
|
302
|
+
assert.equal(gateCount.n, 0, "skip path must not persist milestone gates without a real slice id");
|
|
303
|
+
});
|
|
304
|
+
|
|
247
305
|
test("#4781 phase 2: validate-milestone rule dispatches normally for standard variant", async (t) => {
|
|
248
306
|
const base = makeBase();
|
|
249
307
|
t.after(() => cleanup(base));
|
|
@@ -12,7 +12,6 @@ import assert from "node:assert/strict";
|
|
|
12
12
|
import { readFileSync, mkdtempSync, mkdirSync, writeFileSync, existsSync, readdirSync, rmSync } from "node:fs";
|
|
13
13
|
import { join } from "node:path";
|
|
14
14
|
import { tmpdir } from "node:os";
|
|
15
|
-
import { extractSourceRegion } from "./test-helpers.ts";
|
|
16
15
|
|
|
17
16
|
test("#2684: preferences files are NOT in ROOT_STATE_FILES (forward-only sync)", () => {
|
|
18
17
|
const srcPath = join(import.meta.dirname, "..", "auto-worktree.ts");
|
|
@@ -41,22 +40,9 @@ test("#2684: preferences files are NOT in ROOT_STATE_FILES (forward-only sync)",
|
|
|
41
40
|
);
|
|
42
41
|
});
|
|
43
42
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
// Find the copyPlanningArtifacts function body
|
|
49
|
-
const fnIdx = src.indexOf("function copyPlanningArtifacts");
|
|
50
|
-
assert.ok(fnIdx !== -1, "copyPlanningArtifacts function exists");
|
|
51
|
-
|
|
52
|
-
// Extract function body (up to the next top-level function)
|
|
53
|
-
const fnBody = extractSourceRegion(src, "function copyPlanningArtifacts");
|
|
54
|
-
|
|
55
|
-
assert.ok(
|
|
56
|
-
fnBody.includes("PROJECT_PREFERENCES_FILE") && fnBody.includes("LEGACY_PROJECT_PREFERENCES_FILE"),
|
|
57
|
-
"copyPlanningArtifacts should prefer canonical PREFERENCES.md and retain lowercase fallback via the shared constants",
|
|
58
|
-
);
|
|
59
|
-
});
|
|
43
|
+
// Phase C: copyPlanningArtifacts was deleted. Worktrees no longer
|
|
44
|
+
// maintain a parallel .gsd/ projection; preference seeding is now
|
|
45
|
+
// handled exclusively by syncGsdStateToWorktree() (covered below).
|
|
60
46
|
|
|
61
47
|
test("syncGsdStateToWorktree copies canonical PREFERENCES.md", async () => {
|
|
62
48
|
// Functional test: create a mock source and destination, call the sync
|
|
@@ -231,6 +231,116 @@ test("register-hooks returns hard blocker when depth question is cancelled", asy
|
|
|
231
231
|
patch?.content?.[0]?.text ?? "",
|
|
232
232
|
/Do not infer approval from earlier or prior messages/,
|
|
233
233
|
);
|
|
234
|
+
// Regression for milestone-hang: the cancelled-gate instruction must direct
|
|
235
|
+
// the agent toward the most reliable recovery path — re-calling
|
|
236
|
+
// ask_user_questions with the same gate id. The plain-text path also clears
|
|
237
|
+
// the gate via isExplicitApprovalResponse on the next before_agent_start,
|
|
238
|
+
// but the structured re-ask is more deterministic, so the message points
|
|
239
|
+
// there and avoids the prior dead-end "ask in plain chat, then stop" wording.
|
|
240
|
+
assert.match(
|
|
241
|
+
patch?.content?.[0]?.text ?? "",
|
|
242
|
+
/Re-call ask_user_questions with the same gate question id/,
|
|
243
|
+
"must instruct the agent to re-ask via ask_user_questions",
|
|
244
|
+
);
|
|
245
|
+
assert.doesNotMatch(
|
|
246
|
+
patch?.content?.[0]?.text ?? "",
|
|
247
|
+
/confirm in plain chat, then stop/,
|
|
248
|
+
"must not direct the agent down the prior dead-end plain-chat-and-stop path",
|
|
249
|
+
);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
test("register-hooks recovers from a cancelled depth question via re-asked ask_user_questions (milestone-hang regression)", async (t) => {
|
|
253
|
+
const dir = makeTempDir("recovery");
|
|
254
|
+
const originalCwd = process.cwd();
|
|
255
|
+
process.chdir(dir);
|
|
256
|
+
resetWriteGateState(dir);
|
|
257
|
+
|
|
258
|
+
t.after(() => {
|
|
259
|
+
try {
|
|
260
|
+
resetWriteGateState(dir);
|
|
261
|
+
} finally {
|
|
262
|
+
process.chdir(originalCwd);
|
|
263
|
+
rmSync(dir, { recursive: true, force: true });
|
|
264
|
+
}
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
const handlers = new Map<string, Array<(event: any, ctx?: any) => Promise<any> | any>>();
|
|
268
|
+
const pi = {
|
|
269
|
+
on(event: string, handler: (event: any, ctx?: any) => Promise<any> | any) {
|
|
270
|
+
const existing = handlers.get(event) ?? [];
|
|
271
|
+
existing.push(handler);
|
|
272
|
+
handlers.set(event, existing);
|
|
273
|
+
},
|
|
274
|
+
} as any;
|
|
275
|
+
|
|
276
|
+
registerHooks(pi, []);
|
|
277
|
+
|
|
278
|
+
const questionId = "depth_verification_M001_confirm";
|
|
279
|
+
const questions = [
|
|
280
|
+
{
|
|
281
|
+
id: questionId,
|
|
282
|
+
question: "Did I capture the project correctly?",
|
|
283
|
+
options: [
|
|
284
|
+
{ label: "Yes, you got it (Recommended)" },
|
|
285
|
+
{ label: "Not quite — let me clarify" },
|
|
286
|
+
],
|
|
287
|
+
},
|
|
288
|
+
];
|
|
289
|
+
|
|
290
|
+
// 1. Initial ask sets the gate.
|
|
291
|
+
for (const handler of handlers.get("tool_call") ?? []) {
|
|
292
|
+
await handler({ toolName: "ask_user_questions", input: { questions } });
|
|
293
|
+
}
|
|
294
|
+
assert.equal(getPendingGate(), questionId, "initial ask must set the gate");
|
|
295
|
+
|
|
296
|
+
// 2. User cancels (simulates the trap from the screenshot: question never
|
|
297
|
+
// answered through the structured channel). Gate must stay pending.
|
|
298
|
+
for (const handler of handlers.get("tool_result") ?? []) {
|
|
299
|
+
await handler({
|
|
300
|
+
toolName: "ask_user_questions",
|
|
301
|
+
input: { questions },
|
|
302
|
+
details: { cancelled: true, response: null },
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
assert.equal(getPendingGate(), questionId, "cancelled response must leave gate pending");
|
|
306
|
+
|
|
307
|
+
// 3. Recovery path: immediately re-call ask_user_questions with the same
|
|
308
|
+
// gate id and identical input. This must not be blocked by the strict
|
|
309
|
+
// duplicate-call loop guard, because the hard-block instruction above
|
|
310
|
+
// tells the agent to do exactly this and not to interleave other tools.
|
|
311
|
+
const reaskBlocks: any[] = [];
|
|
312
|
+
for (const handler of handlers.get("tool_call") ?? []) {
|
|
313
|
+
const result = await handler({ toolName: "ask_user_questions", input: { questions } });
|
|
314
|
+
if (result?.block) reaskBlocks.push(result);
|
|
315
|
+
}
|
|
316
|
+
assert.equal(
|
|
317
|
+
reaskBlocks.length,
|
|
318
|
+
0,
|
|
319
|
+
"immediate identical re-ask must not be blocked by the tool-call loop guard",
|
|
320
|
+
);
|
|
321
|
+
|
|
322
|
+
// 4. The re-asked question receives a confirming response, which clears the
|
|
323
|
+
// gate and unlocks the milestone context save.
|
|
324
|
+
for (const handler of handlers.get("tool_result") ?? []) {
|
|
325
|
+
await handler({
|
|
326
|
+
toolName: "ask_user_questions",
|
|
327
|
+
input: { questions },
|
|
328
|
+
details: {
|
|
329
|
+
response: {
|
|
330
|
+
answers: {
|
|
331
|
+
[questionId]: { selected: "Yes, you got it (Recommended)" },
|
|
332
|
+
},
|
|
333
|
+
},
|
|
334
|
+
},
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
assert.equal(getPendingGate(), null, "confirming re-ask must clear the gate");
|
|
339
|
+
assert.equal(
|
|
340
|
+
shouldBlockContextArtifactSave("CONTEXT", "M001").block,
|
|
341
|
+
false,
|
|
342
|
+
"context save must unlock after recovery",
|
|
343
|
+
);
|
|
234
344
|
});
|
|
235
345
|
|
|
236
346
|
test("register-hooks gates MCP ask_user_questions cancellation before requirement saves", async (t) => {
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// gsd-2 + runtime_kv non-correctness-critical key-value storage tests (Phase C)
|
|
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, _getAdapter } from "../gsd-db.ts";
|
|
10
|
+
import {
|
|
11
|
+
setRuntimeKv,
|
|
12
|
+
getRuntimeKv,
|
|
13
|
+
deleteRuntimeKv,
|
|
14
|
+
listRuntimeKv,
|
|
15
|
+
} from "../db/runtime-kv.ts";
|
|
16
|
+
|
|
17
|
+
function makeBase(): string {
|
|
18
|
+
const base = mkdtempSync(join(tmpdir(), "gsd-runtime-kv-"));
|
|
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("set + get round-trip preserves the value", (t) => {
|
|
29
|
+
const base = makeBase();
|
|
30
|
+
t.after(() => cleanup(base));
|
|
31
|
+
openDatabase(join(base, ".gsd", "gsd.db"));
|
|
32
|
+
|
|
33
|
+
setRuntimeKv("global", "", "ui_cursor", { row: 5, col: 10 });
|
|
34
|
+
const got = getRuntimeKv<{ row: number; col: number }>("global", "", "ui_cursor");
|
|
35
|
+
assert.deepEqual(got, { row: 5, col: 10 });
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("get returns null for missing keys", (t) => {
|
|
39
|
+
const base = makeBase();
|
|
40
|
+
t.after(() => cleanup(base));
|
|
41
|
+
openDatabase(join(base, ".gsd", "gsd.db"));
|
|
42
|
+
|
|
43
|
+
assert.equal(getRuntimeKv("global", "", "missing"), null);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("set on existing key updates the value (idempotent upsert)", (t) => {
|
|
47
|
+
const base = makeBase();
|
|
48
|
+
t.after(() => cleanup(base));
|
|
49
|
+
openDatabase(join(base, ".gsd", "gsd.db"));
|
|
50
|
+
|
|
51
|
+
setRuntimeKv("worker", "w1", "counter", 1);
|
|
52
|
+
setRuntimeKv("worker", "w1", "counter", 42);
|
|
53
|
+
assert.equal(getRuntimeKv("worker", "w1", "counter"), 42);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("scope partitioning: same key under different scopes is independent", (t) => {
|
|
57
|
+
const base = makeBase();
|
|
58
|
+
t.after(() => cleanup(base));
|
|
59
|
+
openDatabase(join(base, ".gsd", "gsd.db"));
|
|
60
|
+
|
|
61
|
+
setRuntimeKv("global", "", "k", "global-value");
|
|
62
|
+
setRuntimeKv("worker", "w1", "k", "worker-value");
|
|
63
|
+
setRuntimeKv("milestone", "M001", "k", "milestone-value");
|
|
64
|
+
|
|
65
|
+
assert.equal(getRuntimeKv("global", "", "k"), "global-value");
|
|
66
|
+
assert.equal(getRuntimeKv("worker", "w1", "k"), "worker-value");
|
|
67
|
+
assert.equal(getRuntimeKv("milestone", "M001", "k"), "milestone-value");
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("scope_id partitioning: same scope+key under different scope_ids is independent", (t) => {
|
|
71
|
+
const base = makeBase();
|
|
72
|
+
t.after(() => cleanup(base));
|
|
73
|
+
openDatabase(join(base, ".gsd", "gsd.db"));
|
|
74
|
+
|
|
75
|
+
setRuntimeKv("worker", "w1", "k", "v1");
|
|
76
|
+
setRuntimeKv("worker", "w2", "k", "v2");
|
|
77
|
+
assert.equal(getRuntimeKv("worker", "w1", "k"), "v1");
|
|
78
|
+
assert.equal(getRuntimeKv("worker", "w2", "k"), "v2");
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("delete removes the row; subsequent get returns null", (t) => {
|
|
82
|
+
const base = makeBase();
|
|
83
|
+
t.after(() => cleanup(base));
|
|
84
|
+
openDatabase(join(base, ".gsd", "gsd.db"));
|
|
85
|
+
|
|
86
|
+
setRuntimeKv("worker", "w1", "k", "value");
|
|
87
|
+
deleteRuntimeKv("worker", "w1", "k");
|
|
88
|
+
assert.equal(getRuntimeKv("worker", "w1", "k"), null);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("list returns all rows for a scope+scope_id, ordered by key", (t) => {
|
|
92
|
+
const base = makeBase();
|
|
93
|
+
t.after(() => cleanup(base));
|
|
94
|
+
openDatabase(join(base, ".gsd", "gsd.db"));
|
|
95
|
+
|
|
96
|
+
setRuntimeKv("milestone", "M001", "alpha", 1);
|
|
97
|
+
setRuntimeKv("milestone", "M001", "gamma", 3);
|
|
98
|
+
setRuntimeKv("milestone", "M001", "beta", 2);
|
|
99
|
+
setRuntimeKv("milestone", "M002", "ignored", "different-scope");
|
|
100
|
+
|
|
101
|
+
const rows = listRuntimeKv("milestone", "M001");
|
|
102
|
+
assert.equal(rows.length, 3);
|
|
103
|
+
assert.deepEqual(rows.map(r => r.key), ["alpha", "beta", "gamma"]);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("malformed JSON in storage returns null without throwing", (t) => {
|
|
107
|
+
const base = makeBase();
|
|
108
|
+
t.after(() => cleanup(base));
|
|
109
|
+
openDatabase(join(base, ".gsd", "gsd.db"));
|
|
110
|
+
|
|
111
|
+
// Inject a malformed value directly (bypassing setRuntimeKv's JSON.stringify).
|
|
112
|
+
setRuntimeKv("global", "", "k", "valid");
|
|
113
|
+
// Then poison the row via raw SQL.
|
|
114
|
+
const db = _getAdapter()!;
|
|
115
|
+
db.prepare(
|
|
116
|
+
`UPDATE runtime_kv SET value_json = '{not json' WHERE scope = 'global' AND scope_id = '' AND key = 'k'`,
|
|
117
|
+
).run();
|
|
118
|
+
|
|
119
|
+
assert.equal(getRuntimeKv("global", "", "k"), null);
|
|
120
|
+
});
|
|
@@ -1,39 +1,144 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Regression test for #3698 — allow milestone completion when validation
|
|
3
|
-
* was skipped by preference
|
|
4
|
-
*
|
|
5
|
-
* When validation is skipped due to user preference (e.g. budget profile),
|
|
6
|
-
* auto-dispatch should recognize the "skipped by preference" pattern and
|
|
7
|
-
* allow completion instead of treating it as a missing validation.
|
|
3
|
+
* was skipped by preference.
|
|
8
4
|
*/
|
|
9
5
|
|
|
10
|
-
import
|
|
11
|
-
import assert from
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
6
|
+
import test from "node:test";
|
|
7
|
+
import assert from "node:assert/strict";
|
|
8
|
+
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import { tmpdir } from "node:os";
|
|
15
11
|
|
|
16
|
-
|
|
17
|
-
|
|
12
|
+
import { DISPATCH_RULES, type DispatchContext } from "../auto-dispatch.ts";
|
|
13
|
+
import {
|
|
14
|
+
closeDatabase,
|
|
15
|
+
insertMilestone,
|
|
16
|
+
insertSlice,
|
|
17
|
+
openDatabase,
|
|
18
|
+
upsertMilestonePlanning,
|
|
19
|
+
} from "../gsd-db.ts";
|
|
20
|
+
import { invalidateAllCaches } from "../cache.ts";
|
|
21
|
+
import type { GSDState } from "../types.ts";
|
|
18
22
|
|
|
19
|
-
const
|
|
20
|
-
join(__dirname, '..', 'auto-dispatch.ts'),
|
|
21
|
-
'utf-8',
|
|
22
|
-
);
|
|
23
|
+
const COMPLETE_RULE = "completing-milestone → complete-milestone";
|
|
23
24
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
25
|
+
function makeBase(): string {
|
|
26
|
+
const base = mkdtempSync(join(tmpdir(), "gsd-skipped-validation-"));
|
|
27
|
+
mkdirSync(join(base, ".gsd", "milestones", "M001", "slices", "S01", "tasks"), { recursive: true });
|
|
28
|
+
writeFileSync(join(base, "app.js"), "export const shipped = true;\n");
|
|
29
|
+
return base;
|
|
30
|
+
}
|
|
29
31
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
});
|
|
32
|
+
function cleanup(base: string): void {
|
|
33
|
+
try { closeDatabase(); } catch { /* noop */ }
|
|
34
|
+
invalidateAllCaches();
|
|
35
|
+
rmSync(base, { recursive: true, force: true });
|
|
36
|
+
}
|
|
34
37
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
+
function seedMilestone(base: string): void {
|
|
39
|
+
openDatabase(join(base, ".gsd", "gsd.db"));
|
|
40
|
+
insertMilestone({
|
|
41
|
+
id: "M001",
|
|
42
|
+
title: "Preference-skipped validation milestone",
|
|
43
|
+
status: "active",
|
|
44
|
+
depends_on: [],
|
|
45
|
+
});
|
|
46
|
+
upsertMilestonePlanning("M001", {
|
|
47
|
+
title: "Preference-skipped validation milestone",
|
|
48
|
+
status: "active",
|
|
49
|
+
vision: "Ship a small implementation with a documented validation skip.",
|
|
50
|
+
successCriteria: ["Completion remains unblocked when validation was intentionally skipped."],
|
|
51
|
+
keyRisks: [],
|
|
52
|
+
proofStrategy: [],
|
|
53
|
+
verificationContract: "",
|
|
54
|
+
verificationIntegration: "",
|
|
55
|
+
verificationOperational: "Smoke-test the shipped workflow before completion.",
|
|
56
|
+
verificationUat: "",
|
|
57
|
+
definitionOfDone: [],
|
|
58
|
+
requirementCoverage: "",
|
|
59
|
+
boundaryMapMarkdown: "",
|
|
38
60
|
});
|
|
61
|
+
insertSlice({
|
|
62
|
+
id: "S01",
|
|
63
|
+
milestoneId: "M001",
|
|
64
|
+
title: "First",
|
|
65
|
+
status: "done",
|
|
66
|
+
risk: "low",
|
|
67
|
+
depends: [],
|
|
68
|
+
demo: "",
|
|
69
|
+
sequence: 1,
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function writeFixtureFiles(base: string): void {
|
|
74
|
+
const milestoneDir = join(base, ".gsd", "milestones", "M001");
|
|
75
|
+
writeFileSync(
|
|
76
|
+
join(milestoneDir, "M001-ROADMAP.md"),
|
|
77
|
+
[
|
|
78
|
+
"# M001",
|
|
79
|
+
"## Slices",
|
|
80
|
+
"- [x] **S01: First** `risk:low` `depends:[]`",
|
|
81
|
+
].join("\n"),
|
|
82
|
+
);
|
|
83
|
+
writeFileSync(
|
|
84
|
+
join(milestoneDir, "slices", "S01", "S01-SUMMARY.md"),
|
|
85
|
+
"# S01\n\nImplemented the shipped workflow.\n",
|
|
86
|
+
);
|
|
87
|
+
writeFileSync(
|
|
88
|
+
join(milestoneDir, "M001-VALIDATION.md"),
|
|
89
|
+
[
|
|
90
|
+
"---",
|
|
91
|
+
"verdict: pass",
|
|
92
|
+
"skip_validation: true",
|
|
93
|
+
"skip_validation_reason: preference",
|
|
94
|
+
"remediation_round: 0",
|
|
95
|
+
"---",
|
|
96
|
+
"",
|
|
97
|
+
"# Milestone Validation (skipped)",
|
|
98
|
+
"",
|
|
99
|
+
"Milestone validation was skipped by preference.",
|
|
100
|
+
].join("\n"),
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function findRule(name: string) {
|
|
105
|
+
const rule = DISPATCH_RULES.find(candidate => candidate.name === name);
|
|
106
|
+
assert.ok(rule, `rule "${name}" must exist`);
|
|
107
|
+
return rule!;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function makeCtx(base: string): DispatchContext {
|
|
111
|
+
const state: GSDState = {
|
|
112
|
+
phase: "completing-milestone",
|
|
113
|
+
activeMilestone: { id: "M001", title: "Preference-skipped validation milestone" },
|
|
114
|
+
activeSlice: null,
|
|
115
|
+
activeTask: null,
|
|
116
|
+
recentDecisions: [],
|
|
117
|
+
blockers: [],
|
|
118
|
+
nextAction: "",
|
|
119
|
+
registry: [{ id: "M001", title: "Preference-skipped validation milestone", status: "active" }],
|
|
120
|
+
};
|
|
121
|
+
return {
|
|
122
|
+
basePath: base,
|
|
123
|
+
mid: "M001",
|
|
124
|
+
midTitle: "Preference-skipped validation milestone",
|
|
125
|
+
state,
|
|
126
|
+
prefs: undefined,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
test("#3698: completing-milestone dispatch accepts skipped validation fixture", async (t) => {
|
|
131
|
+
const base = makeBase();
|
|
132
|
+
t.after(() => cleanup(base));
|
|
133
|
+
|
|
134
|
+
seedMilestone(base);
|
|
135
|
+
writeFixtureFiles(base);
|
|
136
|
+
|
|
137
|
+
const result = await findRule(COMPLETE_RULE).match(makeCtx(base));
|
|
138
|
+
|
|
139
|
+
assert.ok(result, "rule must return a result");
|
|
140
|
+
assert.strictEqual(result!.action, "dispatch", "skipped validation should still allow completion dispatch");
|
|
141
|
+
if (result!.action === "dispatch") {
|
|
142
|
+
assert.strictEqual(result.unitType, "complete-milestone");
|
|
143
|
+
}
|
|
39
144
|
});
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { readFileSync } from "node:fs";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
|
|
7
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
8
|
+
|
|
9
|
+
test("skipped validation DB persistence stays atomic", () => {
|
|
10
|
+
const source = readFileSync(join(__dirname, "..", "auto-dispatch.ts"), "utf-8");
|
|
11
|
+
|
|
12
|
+
assert.match(
|
|
13
|
+
source,
|
|
14
|
+
/if \(isDbAvailable\(\)\) \{\s+transaction\(\(\) => \{\s+insertAssessment\([\s\S]*?insertMilestoneValidationGates\(/,
|
|
15
|
+
"skipped validation DB writes must remain inside a single transaction",
|
|
16
|
+
);
|
|
17
|
+
});
|