gsd-pi 2.22.0 → 2.24.0
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 +25 -1
- package/dist/cli.js +74 -7
- package/dist/headless.d.ts +25 -0
- package/dist/headless.js +454 -0
- package/dist/help-text.js +47 -0
- package/dist/mcp-server.d.ts +20 -3
- package/dist/mcp-server.js +21 -1
- package/dist/models-resolver.d.ts +32 -0
- package/dist/models-resolver.js +50 -0
- package/dist/resource-loader.js +64 -9
- package/dist/resources/extensions/bg-shell/output-formatter.ts +36 -16
- package/dist/resources/extensions/bg-shell/process-manager.ts +6 -4
- package/dist/resources/extensions/bg-shell/types.ts +33 -1
- package/dist/resources/extensions/browser-tools/capture.ts +18 -16
- package/dist/resources/extensions/browser-tools/index.ts +20 -0
- package/dist/resources/extensions/browser-tools/tests/browser-tools-unit.test.cjs +25 -0
- package/dist/resources/extensions/browser-tools/tools/action-cache.ts +216 -0
- package/dist/resources/extensions/browser-tools/tools/codegen.ts +274 -0
- package/dist/resources/extensions/browser-tools/tools/device.ts +183 -0
- package/dist/resources/extensions/browser-tools/tools/extract.ts +229 -0
- package/dist/resources/extensions/browser-tools/tools/injection-detect.ts +221 -0
- package/dist/resources/extensions/browser-tools/tools/network-mock.ts +244 -0
- package/dist/resources/extensions/browser-tools/tools/pdf.ts +92 -0
- package/dist/resources/extensions/browser-tools/tools/state-persistence.ts +202 -0
- package/dist/resources/extensions/browser-tools/tools/visual-diff.ts +209 -0
- package/dist/resources/extensions/browser-tools/tools/zoom.ts +104 -0
- package/dist/resources/extensions/gsd/auto-dashboard.ts +2 -0
- package/dist/resources/extensions/gsd/auto-dispatch.ts +51 -2
- package/dist/resources/extensions/gsd/auto-prompts.ts +73 -0
- package/dist/resources/extensions/gsd/auto-recovery.ts +51 -2
- package/dist/resources/extensions/gsd/auto-worktree.ts +15 -3
- package/dist/resources/extensions/gsd/auto.ts +560 -52
- package/dist/resources/extensions/gsd/captures.ts +49 -0
- package/dist/resources/extensions/gsd/commands.ts +194 -11
- package/dist/resources/extensions/gsd/complexity.ts +1 -0
- package/dist/resources/extensions/gsd/dashboard-overlay.ts +54 -2
- package/dist/resources/extensions/gsd/diff-context.ts +73 -80
- package/dist/resources/extensions/gsd/doctor.ts +76 -12
- package/dist/resources/extensions/gsd/exit-command.ts +2 -2
- package/dist/resources/extensions/gsd/forensics.ts +95 -52
- package/dist/resources/extensions/gsd/gitignore.ts +1 -0
- package/dist/resources/extensions/gsd/guided-flow.ts +85 -5
- package/dist/resources/extensions/gsd/index.ts +34 -1
- package/dist/resources/extensions/gsd/mcp-server.ts +33 -12
- package/dist/resources/extensions/gsd/parallel-eligibility.ts +233 -0
- package/dist/resources/extensions/gsd/parallel-merge.ts +156 -0
- package/dist/resources/extensions/gsd/parallel-orchestrator.ts +496 -0
- package/dist/resources/extensions/gsd/post-unit-hooks.ts +2 -1
- package/dist/resources/extensions/gsd/preferences.ts +65 -1
- package/dist/resources/extensions/gsd/prompts/discuss-headless.md +86 -0
- package/dist/resources/extensions/gsd/prompts/execute-task.md +5 -0
- package/dist/resources/extensions/gsd/prompts/guided-discuss-milestone.md +104 -1
- package/dist/resources/extensions/gsd/prompts/plan-milestone.md +1 -0
- package/dist/resources/extensions/gsd/prompts/research-slice.md +1 -1
- package/dist/resources/extensions/gsd/prompts/system.md +2 -1
- package/dist/resources/extensions/gsd/prompts/validate-milestone.md +70 -0
- package/dist/resources/extensions/gsd/provider-error-pause.ts +29 -2
- package/dist/resources/extensions/gsd/roadmap-slices.ts +41 -1
- package/dist/resources/extensions/gsd/session-forensics.ts +36 -2
- package/dist/resources/extensions/gsd/session-status-io.ts +197 -0
- package/dist/resources/extensions/gsd/state.ts +72 -30
- package/dist/resources/extensions/gsd/templates/milestone-validation.md +62 -0
- package/dist/resources/extensions/gsd/tests/agent-end-provider-error.test.ts +81 -0
- package/dist/resources/extensions/gsd/tests/auto-budget-alerts.test.ts +20 -3
- package/dist/resources/extensions/gsd/tests/auto-lock-creation.test.ts +186 -0
- package/dist/resources/extensions/gsd/tests/auto-preflight.test.ts +1 -0
- package/dist/resources/extensions/gsd/tests/auto-recovery.test.ts +264 -0
- package/dist/resources/extensions/gsd/tests/auto-skip-loop.test.ts +123 -0
- package/dist/resources/extensions/gsd/tests/auto-worktree-milestone-merge.test.ts +34 -0
- package/dist/resources/extensions/gsd/tests/complete-milestone.test.ts +8 -1
- package/dist/resources/extensions/gsd/tests/derive-state-db.test.ts +9 -15
- package/dist/resources/extensions/gsd/tests/derive-state-deps.test.ts +9 -0
- package/dist/resources/extensions/gsd/tests/derive-state-draft.test.ts +8 -0
- package/dist/resources/extensions/gsd/tests/derive-state.test.ts +14 -0
- package/dist/resources/extensions/gsd/tests/doctor.test.ts +58 -0
- package/dist/resources/extensions/gsd/tests/in-flight-tool-tracking.test.ts +17 -6
- package/dist/resources/extensions/gsd/tests/integration/headless-command.ts +534 -0
- package/dist/resources/extensions/gsd/tests/integration-mixed-milestones.test.ts +8 -0
- package/dist/resources/extensions/gsd/tests/migrate-writer-integration.test.ts +5 -5
- package/dist/resources/extensions/gsd/tests/parallel-orchestration.test.ts +656 -0
- package/dist/resources/extensions/gsd/tests/parallel-workers-multi-milestone-e2e.test.ts +354 -0
- package/dist/resources/extensions/gsd/tests/queue-reorder-e2e.test.ts +1 -0
- package/dist/resources/extensions/gsd/tests/roadmap-slices.test.ts +43 -1
- package/dist/resources/extensions/gsd/tests/triage-dispatch.test.ts +120 -0
- package/dist/resources/extensions/gsd/tests/triage-resolution.test.ts +203 -2
- package/dist/resources/extensions/gsd/tests/validate-milestone.test.ts +316 -0
- package/dist/resources/extensions/gsd/tests/visualizer-overlay.test.ts +8 -3
- package/dist/resources/extensions/gsd/tests/worker-registry.test.ts +148 -0
- package/dist/resources/extensions/gsd/triage-resolution.ts +83 -0
- package/dist/resources/extensions/gsd/types.ts +15 -1
- package/dist/resources/extensions/gsd/visualizer-overlay.ts +8 -1
- package/dist/resources/extensions/gsd/workspace-index.ts +34 -6
- package/dist/resources/extensions/subagent/index.ts +5 -0
- package/dist/resources/extensions/subagent/worker-registry.ts +99 -0
- package/dist/update-check.d.ts +9 -0
- package/dist/update-check.js +97 -0
- package/package.json +6 -1
- package/packages/pi-ai/dist/providers/anthropic.d.ts.map +1 -1
- package/packages/pi-ai/dist/providers/anthropic.js +16 -7
- package/packages/pi-ai/dist/providers/anthropic.js.map +1 -1
- package/packages/pi-ai/dist/providers/azure-openai-responses.d.ts.map +1 -1
- package/packages/pi-ai/dist/providers/azure-openai-responses.js +12 -4
- package/packages/pi-ai/dist/providers/azure-openai-responses.js.map +1 -1
- package/packages/pi-ai/dist/providers/google-vertex.d.ts.map +1 -1
- package/packages/pi-ai/dist/providers/google-vertex.js +21 -9
- package/packages/pi-ai/dist/providers/google-vertex.js.map +1 -1
- package/packages/pi-ai/dist/providers/openai-completions.d.ts.map +1 -1
- package/packages/pi-ai/dist/providers/openai-completions.js +12 -4
- package/packages/pi-ai/dist/providers/openai-completions.js.map +1 -1
- package/packages/pi-ai/dist/providers/openai-responses.d.ts.map +1 -1
- package/packages/pi-ai/dist/providers/openai-responses.js +12 -4
- package/packages/pi-ai/dist/providers/openai-responses.js.map +1 -1
- package/packages/pi-ai/src/providers/anthropic.ts +21 -8
- package/packages/pi-ai/src/providers/azure-openai-responses.ts +16 -4
- package/packages/pi-ai/src/providers/google-vertex.ts +32 -17
- package/packages/pi-ai/src/providers/openai-completions.ts +16 -4
- package/packages/pi-ai/src/providers/openai-responses.ts +16 -4
- package/packages/pi-coding-agent/dist/core/agent-session.js +1 -1
- package/packages/pi-coding-agent/dist/core/agent-session.js.map +1 -1
- package/packages/pi-coding-agent/dist/core/settings-manager.js +1 -1
- package/packages/pi-coding-agent/dist/core/settings-manager.js.map +1 -1
- package/packages/pi-coding-agent/dist/core/tools/bash-background.test.d.ts +10 -0
- package/packages/pi-coding-agent/dist/core/tools/bash-background.test.d.ts.map +1 -0
- package/packages/pi-coding-agent/dist/core/tools/bash-background.test.js +79 -0
- package/packages/pi-coding-agent/dist/core/tools/bash-background.test.js.map +1 -0
- package/packages/pi-coding-agent/dist/core/tools/bash.d.ts +18 -0
- package/packages/pi-coding-agent/dist/core/tools/bash.d.ts.map +1 -1
- package/packages/pi-coding-agent/dist/core/tools/bash.js +77 -1
- package/packages/pi-coding-agent/dist/core/tools/bash.js.map +1 -1
- package/packages/pi-coding-agent/dist/core/tools/index.d.ts +1 -1
- package/packages/pi-coding-agent/dist/core/tools/index.d.ts.map +1 -1
- package/packages/pi-coding-agent/dist/core/tools/index.js +1 -1
- package/packages/pi-coding-agent/dist/core/tools/index.js.map +1 -1
- package/packages/pi-coding-agent/dist/index.d.ts +1 -1
- package/packages/pi-coding-agent/dist/index.d.ts.map +1 -1
- package/packages/pi-coding-agent/dist/index.js +1 -1
- package/packages/pi-coding-agent/dist/index.js.map +1 -1
- package/packages/pi-coding-agent/src/core/agent-session.ts +1 -1
- package/packages/pi-coding-agent/src/core/settings-manager.ts +2 -2
- package/packages/pi-coding-agent/src/core/tools/bash-background.test.ts +91 -0
- package/packages/pi-coding-agent/src/core/tools/bash.ts +83 -1
- package/packages/pi-coding-agent/src/core/tools/index.ts +1 -0
- package/packages/pi-coding-agent/src/index.ts +1 -0
- package/scripts/postinstall.js +7 -109
- package/src/resources/extensions/bg-shell/output-formatter.ts +36 -16
- package/src/resources/extensions/bg-shell/process-manager.ts +6 -4
- package/src/resources/extensions/bg-shell/types.ts +33 -1
- package/src/resources/extensions/browser-tools/capture.ts +18 -16
- package/src/resources/extensions/browser-tools/index.ts +20 -0
- package/src/resources/extensions/browser-tools/tests/browser-tools-unit.test.cjs +25 -0
- package/src/resources/extensions/browser-tools/tools/action-cache.ts +216 -0
- package/src/resources/extensions/browser-tools/tools/codegen.ts +274 -0
- package/src/resources/extensions/browser-tools/tools/device.ts +183 -0
- package/src/resources/extensions/browser-tools/tools/extract.ts +229 -0
- package/src/resources/extensions/browser-tools/tools/injection-detect.ts +221 -0
- package/src/resources/extensions/browser-tools/tools/network-mock.ts +244 -0
- package/src/resources/extensions/browser-tools/tools/pdf.ts +92 -0
- package/src/resources/extensions/browser-tools/tools/state-persistence.ts +202 -0
- package/src/resources/extensions/browser-tools/tools/visual-diff.ts +209 -0
- package/src/resources/extensions/browser-tools/tools/zoom.ts +104 -0
- package/src/resources/extensions/gsd/auto-dashboard.ts +2 -0
- package/src/resources/extensions/gsd/auto-dispatch.ts +51 -2
- package/src/resources/extensions/gsd/auto-prompts.ts +73 -0
- package/src/resources/extensions/gsd/auto-recovery.ts +51 -2
- package/src/resources/extensions/gsd/auto-worktree.ts +15 -3
- package/src/resources/extensions/gsd/auto.ts +560 -52
- package/src/resources/extensions/gsd/captures.ts +49 -0
- package/src/resources/extensions/gsd/commands.ts +194 -11
- package/src/resources/extensions/gsd/complexity.ts +1 -0
- package/src/resources/extensions/gsd/dashboard-overlay.ts +54 -2
- package/src/resources/extensions/gsd/diff-context.ts +73 -80
- package/src/resources/extensions/gsd/doctor.ts +76 -12
- package/src/resources/extensions/gsd/exit-command.ts +2 -2
- package/src/resources/extensions/gsd/forensics.ts +95 -52
- package/src/resources/extensions/gsd/gitignore.ts +1 -0
- package/src/resources/extensions/gsd/guided-flow.ts +85 -5
- package/src/resources/extensions/gsd/index.ts +34 -1
- package/src/resources/extensions/gsd/mcp-server.ts +33 -12
- package/src/resources/extensions/gsd/parallel-eligibility.ts +233 -0
- package/src/resources/extensions/gsd/parallel-merge.ts +156 -0
- package/src/resources/extensions/gsd/parallel-orchestrator.ts +496 -0
- package/src/resources/extensions/gsd/post-unit-hooks.ts +2 -1
- package/src/resources/extensions/gsd/preferences.ts +65 -1
- package/src/resources/extensions/gsd/prompts/discuss-headless.md +86 -0
- package/src/resources/extensions/gsd/prompts/execute-task.md +5 -0
- package/src/resources/extensions/gsd/prompts/guided-discuss-milestone.md +104 -1
- package/src/resources/extensions/gsd/prompts/plan-milestone.md +1 -0
- package/src/resources/extensions/gsd/prompts/research-slice.md +1 -1
- package/src/resources/extensions/gsd/prompts/system.md +2 -1
- package/src/resources/extensions/gsd/prompts/validate-milestone.md +70 -0
- package/src/resources/extensions/gsd/provider-error-pause.ts +29 -2
- package/src/resources/extensions/gsd/roadmap-slices.ts +41 -1
- package/src/resources/extensions/gsd/session-forensics.ts +36 -2
- package/src/resources/extensions/gsd/session-status-io.ts +197 -0
- package/src/resources/extensions/gsd/state.ts +72 -30
- package/src/resources/extensions/gsd/templates/milestone-validation.md +62 -0
- package/src/resources/extensions/gsd/tests/agent-end-provider-error.test.ts +81 -0
- package/src/resources/extensions/gsd/tests/auto-budget-alerts.test.ts +20 -3
- package/src/resources/extensions/gsd/tests/auto-lock-creation.test.ts +186 -0
- package/src/resources/extensions/gsd/tests/auto-preflight.test.ts +1 -0
- package/src/resources/extensions/gsd/tests/auto-recovery.test.ts +264 -0
- package/src/resources/extensions/gsd/tests/auto-skip-loop.test.ts +123 -0
- package/src/resources/extensions/gsd/tests/auto-worktree-milestone-merge.test.ts +34 -0
- package/src/resources/extensions/gsd/tests/complete-milestone.test.ts +8 -1
- package/src/resources/extensions/gsd/tests/derive-state-db.test.ts +9 -15
- package/src/resources/extensions/gsd/tests/derive-state-deps.test.ts +9 -0
- package/src/resources/extensions/gsd/tests/derive-state-draft.test.ts +8 -0
- package/src/resources/extensions/gsd/tests/derive-state.test.ts +14 -0
- package/src/resources/extensions/gsd/tests/doctor.test.ts +58 -0
- package/src/resources/extensions/gsd/tests/in-flight-tool-tracking.test.ts +17 -6
- package/src/resources/extensions/gsd/tests/integration/headless-command.ts +534 -0
- package/src/resources/extensions/gsd/tests/integration-mixed-milestones.test.ts +8 -0
- package/src/resources/extensions/gsd/tests/migrate-writer-integration.test.ts +5 -5
- package/src/resources/extensions/gsd/tests/parallel-orchestration.test.ts +656 -0
- package/src/resources/extensions/gsd/tests/parallel-workers-multi-milestone-e2e.test.ts +354 -0
- package/src/resources/extensions/gsd/tests/queue-reorder-e2e.test.ts +1 -0
- package/src/resources/extensions/gsd/tests/roadmap-slices.test.ts +43 -1
- package/src/resources/extensions/gsd/tests/triage-dispatch.test.ts +120 -0
- package/src/resources/extensions/gsd/tests/triage-resolution.test.ts +203 -2
- package/src/resources/extensions/gsd/tests/validate-milestone.test.ts +316 -0
- package/src/resources/extensions/gsd/tests/visualizer-overlay.test.ts +8 -3
- package/src/resources/extensions/gsd/tests/worker-registry.test.ts +148 -0
- package/src/resources/extensions/gsd/triage-resolution.ts +83 -0
- package/src/resources/extensions/gsd/types.ts +15 -1
- package/src/resources/extensions/gsd/visualizer-overlay.ts +8 -1
- package/src/resources/extensions/gsd/workspace-index.ts +34 -6
- package/src/resources/extensions/subagent/index.ts +5 -0
- package/src/resources/extensions/subagent/worker-registry.ts +99 -0
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { mkdirSync, mkdtempSync, writeFileSync, existsSync, readFileSync, rmSync } from "node:fs";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
|
|
7
|
+
import { writeLock, readCrashLock, clearLock, isLockProcessAlive } from "../crash-recovery.ts";
|
|
8
|
+
|
|
9
|
+
// ─── writeLock creates auto.lock in .gsd/ ────────────────────────────────
|
|
10
|
+
|
|
11
|
+
test("writeLock creates auto.lock with correct structure", () => {
|
|
12
|
+
const dir = mkdtempSync(join(tmpdir(), "gsd-lock-test-"));
|
|
13
|
+
mkdirSync(join(dir, ".gsd"), { recursive: true });
|
|
14
|
+
|
|
15
|
+
writeLock(dir, "starting", "M001", 0);
|
|
16
|
+
|
|
17
|
+
const lockPath = join(dir, ".gsd", "auto.lock");
|
|
18
|
+
assert.ok(existsSync(lockPath), "auto.lock should exist after writeLock");
|
|
19
|
+
|
|
20
|
+
const data = JSON.parse(readFileSync(lockPath, "utf-8"));
|
|
21
|
+
assert.equal(data.pid, process.pid, "lock should contain current PID");
|
|
22
|
+
assert.equal(data.unitType, "starting", "lock should contain unit type");
|
|
23
|
+
assert.equal(data.unitId, "M001", "lock should contain unit ID");
|
|
24
|
+
assert.equal(data.completedUnits, 0, "lock should show 0 completed units");
|
|
25
|
+
assert.ok(data.startedAt, "lock should have startedAt timestamp");
|
|
26
|
+
|
|
27
|
+
rmSync(dir, { recursive: true, force: true });
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("writeLock updates existing lock with new unit info", () => {
|
|
31
|
+
const dir = mkdtempSync(join(tmpdir(), "gsd-lock-test-"));
|
|
32
|
+
mkdirSync(join(dir, ".gsd"), { recursive: true });
|
|
33
|
+
|
|
34
|
+
writeLock(dir, "starting", "M001", 0);
|
|
35
|
+
writeLock(dir, "execute-task", "M001/S01/T01", 2, "/tmp/session.jsonl");
|
|
36
|
+
|
|
37
|
+
const data = JSON.parse(readFileSync(join(dir, ".gsd", "auto.lock"), "utf-8"));
|
|
38
|
+
assert.equal(data.unitType, "execute-task", "lock should be updated to new unit type");
|
|
39
|
+
assert.equal(data.unitId, "M001/S01/T01", "lock should be updated to new unit ID");
|
|
40
|
+
assert.equal(data.completedUnits, 2, "completed count should be updated");
|
|
41
|
+
assert.equal(data.sessionFile, "/tmp/session.jsonl", "session file should be recorded");
|
|
42
|
+
|
|
43
|
+
rmSync(dir, { recursive: true, force: true });
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
// ─── readCrashLock reads auto.lock data ──────────────────────────────────
|
|
47
|
+
|
|
48
|
+
test("readCrashLock returns null when no lock file exists", () => {
|
|
49
|
+
const dir = mkdtempSync(join(tmpdir(), "gsd-lock-test-"));
|
|
50
|
+
mkdirSync(join(dir, ".gsd"), { recursive: true });
|
|
51
|
+
|
|
52
|
+
const lock = readCrashLock(dir);
|
|
53
|
+
assert.equal(lock, null, "should return null when no lock file");
|
|
54
|
+
|
|
55
|
+
rmSync(dir, { recursive: true, force: true });
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("readCrashLock returns lock data when file exists", () => {
|
|
59
|
+
const dir = mkdtempSync(join(tmpdir(), "gsd-lock-test-"));
|
|
60
|
+
mkdirSync(join(dir, ".gsd"), { recursive: true });
|
|
61
|
+
|
|
62
|
+
writeLock(dir, "plan-milestone", "M002", 5);
|
|
63
|
+
const lock = readCrashLock(dir);
|
|
64
|
+
|
|
65
|
+
assert.ok(lock, "should return lock data");
|
|
66
|
+
assert.equal(lock!.unitType, "plan-milestone");
|
|
67
|
+
assert.equal(lock!.unitId, "M002");
|
|
68
|
+
assert.equal(lock!.completedUnits, 5);
|
|
69
|
+
|
|
70
|
+
rmSync(dir, { recursive: true, force: true });
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
// ─── clearLock removes auto.lock ─────────────────────────────────────────
|
|
74
|
+
|
|
75
|
+
test("clearLock removes the lock file", () => {
|
|
76
|
+
const dir = mkdtempSync(join(tmpdir(), "gsd-lock-test-"));
|
|
77
|
+
mkdirSync(join(dir, ".gsd"), { recursive: true });
|
|
78
|
+
|
|
79
|
+
writeLock(dir, "starting", "M001", 0);
|
|
80
|
+
assert.ok(existsSync(join(dir, ".gsd", "auto.lock")), "lock should exist before clear");
|
|
81
|
+
|
|
82
|
+
clearLock(dir);
|
|
83
|
+
assert.ok(!existsSync(join(dir, ".gsd", "auto.lock")), "lock should be removed after clear");
|
|
84
|
+
|
|
85
|
+
rmSync(dir, { recursive: true, force: true });
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("clearLock is safe when no lock file exists", () => {
|
|
89
|
+
const dir = mkdtempSync(join(tmpdir(), "gsd-lock-test-"));
|
|
90
|
+
mkdirSync(join(dir, ".gsd"), { recursive: true });
|
|
91
|
+
|
|
92
|
+
// Should not throw
|
|
93
|
+
clearLock(dir);
|
|
94
|
+
|
|
95
|
+
rmSync(dir, { recursive: true, force: true });
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
// ─── isLockProcessAlive detects live vs dead PIDs ────────────────────────
|
|
99
|
+
|
|
100
|
+
test("isLockProcessAlive returns false for dead PID", () => {
|
|
101
|
+
const lock = {
|
|
102
|
+
pid: 9999999,
|
|
103
|
+
startedAt: new Date().toISOString(),
|
|
104
|
+
unitType: "execute-task",
|
|
105
|
+
unitId: "M001/S01/T01",
|
|
106
|
+
unitStartedAt: new Date().toISOString(),
|
|
107
|
+
completedUnits: 0,
|
|
108
|
+
};
|
|
109
|
+
assert.equal(isLockProcessAlive(lock), false, "dead PID should return false");
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("isLockProcessAlive returns false for own PID (recycled)", () => {
|
|
113
|
+
const lock = {
|
|
114
|
+
pid: process.pid,
|
|
115
|
+
startedAt: new Date().toISOString(),
|
|
116
|
+
unitType: "execute-task",
|
|
117
|
+
unitId: "M001/S01/T01",
|
|
118
|
+
unitStartedAt: new Date().toISOString(),
|
|
119
|
+
completedUnits: 0,
|
|
120
|
+
};
|
|
121
|
+
assert.equal(isLockProcessAlive(lock), false, "own PID should return false (recycled)");
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("isLockProcessAlive returns false for invalid PID", () => {
|
|
125
|
+
const lock = {
|
|
126
|
+
pid: -1,
|
|
127
|
+
startedAt: new Date().toISOString(),
|
|
128
|
+
unitType: "execute-task",
|
|
129
|
+
unitId: "M001/S01/T01",
|
|
130
|
+
unitStartedAt: new Date().toISOString(),
|
|
131
|
+
completedUnits: 0,
|
|
132
|
+
};
|
|
133
|
+
assert.equal(isLockProcessAlive(lock), false, "negative PID should return false");
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
// ─── Cross-process detection via lock file ───────────────────────────────
|
|
137
|
+
|
|
138
|
+
test("lock file enables cross-process auto-mode detection", () => {
|
|
139
|
+
const dir = mkdtempSync(join(tmpdir(), "gsd-lock-test-"));
|
|
140
|
+
mkdirSync(join(dir, ".gsd"), { recursive: true });
|
|
141
|
+
|
|
142
|
+
// Use the parent process PID — guaranteed alive on all platforms (Unix and Windows).
|
|
143
|
+
// PID 1 (init) only works on Unix; on Windows it doesn't exist.
|
|
144
|
+
const alivePid = process.ppid;
|
|
145
|
+
const lockData = {
|
|
146
|
+
pid: alivePid,
|
|
147
|
+
startedAt: new Date().toISOString(),
|
|
148
|
+
unitType: "execute-task",
|
|
149
|
+
unitId: "M001/S01/T02",
|
|
150
|
+
unitStartedAt: new Date().toISOString(),
|
|
151
|
+
completedUnits: 3,
|
|
152
|
+
};
|
|
153
|
+
writeFileSync(join(dir, ".gsd", "auto.lock"), JSON.stringify(lockData, null, 2));
|
|
154
|
+
|
|
155
|
+
const lock = readCrashLock(dir);
|
|
156
|
+
assert.ok(lock, "should read the lock");
|
|
157
|
+
assert.equal(lock!.pid, alivePid);
|
|
158
|
+
|
|
159
|
+
// Parent PID is always alive — isLockProcessAlive should detect it
|
|
160
|
+
const alive = isLockProcessAlive(lock!);
|
|
161
|
+
assert.equal(alive, true, "parent PID should be detected as alive");
|
|
162
|
+
|
|
163
|
+
rmSync(dir, { recursive: true, force: true });
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test("stale lock from dead process is detected as not alive", () => {
|
|
167
|
+
const dir = mkdtempSync(join(tmpdir(), "gsd-lock-test-"));
|
|
168
|
+
mkdirSync(join(dir, ".gsd"), { recursive: true });
|
|
169
|
+
|
|
170
|
+
// Simulate a stale lock from a process that no longer exists
|
|
171
|
+
const lockData = {
|
|
172
|
+
pid: 9999999,
|
|
173
|
+
startedAt: "2026-03-01T00:00:00Z",
|
|
174
|
+
unitType: "plan-slice",
|
|
175
|
+
unitId: "M001/S02",
|
|
176
|
+
unitStartedAt: "2026-03-01T00:05:00Z",
|
|
177
|
+
completedUnits: 1,
|
|
178
|
+
};
|
|
179
|
+
writeFileSync(join(dir, ".gsd", "auto.lock"), JSON.stringify(lockData, null, 2));
|
|
180
|
+
|
|
181
|
+
const lock = readCrashLock(dir);
|
|
182
|
+
assert.ok(lock, "should read the stale lock");
|
|
183
|
+
assert.equal(isLockProcessAlive(lock!), false, "dead process should not be alive");
|
|
184
|
+
|
|
185
|
+
rmSync(dir, { recursive: true, force: true });
|
|
186
|
+
});
|
|
@@ -17,6 +17,7 @@ writeFileSync(join(gsd, "milestones", "M001", "slices", "S01", "S01-PLAN.md"), `
|
|
|
17
17
|
writeFileSync(join(gsd, "milestones", "M001", "slices", "S01", "tasks", "T01-SUMMARY.md"), `---\nid: T01\nparent: S01\nmilestone: M001\nprovides: []\nrequires: []\naffects: []\nkey_files: []\nkey_decisions: []\npatterns_established: []\nobservability_surfaces: []\ndrill_down_paths: []\nduration: 5m\nverification_result: passed\ncompleted_at: 2026-03-09T00:00:00Z\n---\n\n# T01: Old Task\n\n**Done**\n\n## What Happened\nDone.\n\n## Diagnostics\n- log\n`);
|
|
18
18
|
writeFileSync(join(gsd, "milestones", "M001", "slices", "S01", "S01-SUMMARY.md"), `---\nid: S01\nparent: M001\nmilestone: M001\nprovides: []\nrequires: []\naffects: []\nkey_files: []\nkey_decisions: []\npatterns_established: []\nobservability_surfaces: []\ndrill_down_paths: []\nduration: 5m\nverification_result: passed\ncompleted_at: 2026-03-09T00:00:00Z\n---\n\n# S01: Old Slice\n\n**Done**\n\n## What Happened\nDone.\n\n## Verification\nDone.\n\n## Deviations\nNone\n\n## Known Limitations\nNone\n\n## Follow-ups\nNone\n\n## Files Created/Modified\n- \`x\` — x\n\n## Forward Intelligence\n\n### What the next slice should know\n- x\n\n### What's fragile\n- x\n\n### Authoritative diagnostics\n- x\n\n### What assumptions changed\n- x\n`);
|
|
19
19
|
|
|
20
|
+
writeFileSync(join(gsd, "milestones", "M001", "M001-VALIDATION.md"), `---\nverdict: pass\nremediation_round: 0\n---\n\n# Validation\nPassed.\n`);
|
|
20
21
|
writeFileSync(join(gsd, "milestones", "M001", "M001-SUMMARY.md"), `---\nid: M001\nstatus: complete\ncompleted_at: 2026-03-09T00:00:00Z\n---\n\n# M001: Historical\n\nComplete.\n`);
|
|
21
22
|
|
|
22
23
|
writeFileSync(join(gsd, "milestones", "M009", "M009-ROADMAP.md"), `# M009: Active\n\n## Slices\n- [ ] **S01: Active Slice** \`risk:low\` \`depends:[]\`\n > After this: active works\n`);
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
verifyExpectedArtifact,
|
|
11
11
|
diagnoseExpectedArtifact,
|
|
12
12
|
buildLoopRemediationSteps,
|
|
13
|
+
selfHealRuntimeRecords,
|
|
13
14
|
completedKeysPath,
|
|
14
15
|
persistCompletedKey,
|
|
15
16
|
removePersistedKey,
|
|
@@ -273,6 +274,68 @@ test("removePersistedKey is safe when file doesn't exist", () => {
|
|
|
273
274
|
}
|
|
274
275
|
});
|
|
275
276
|
|
|
277
|
+
// ─── Dual-load across worktree boundary (#769) ───────────────────────────
|
|
278
|
+
|
|
279
|
+
test("loadPersistedKeys unions keys from project root and worktree", () => {
|
|
280
|
+
// Simulate two separate .gsd directories (project root + worktree)
|
|
281
|
+
// each with a different set of completed keys. Loading from both
|
|
282
|
+
// into the same Set should produce the union.
|
|
283
|
+
const projectRoot = makeTmpBase();
|
|
284
|
+
const worktree = makeTmpBase();
|
|
285
|
+
try {
|
|
286
|
+
// Persist different keys in each location
|
|
287
|
+
persistCompletedKey(projectRoot, "execute-task/M001/S01/T01");
|
|
288
|
+
persistCompletedKey(projectRoot, "plan-slice/M001/S02");
|
|
289
|
+
|
|
290
|
+
persistCompletedKey(worktree, "execute-task/M001/S01/T02");
|
|
291
|
+
persistCompletedKey(worktree, "plan-slice/M001/S02"); // overlap
|
|
292
|
+
|
|
293
|
+
// Load from both into the same set (mimicking startup dual-load)
|
|
294
|
+
const keys = new Set<string>();
|
|
295
|
+
loadPersistedKeys(projectRoot, keys);
|
|
296
|
+
loadPersistedKeys(worktree, keys);
|
|
297
|
+
|
|
298
|
+
assert.ok(keys.has("execute-task/M001/S01/T01"), "key from project root");
|
|
299
|
+
assert.ok(keys.has("plan-slice/M001/S02"), "shared key");
|
|
300
|
+
assert.ok(keys.has("execute-task/M001/S01/T02"), "key from worktree");
|
|
301
|
+
assert.equal(keys.size, 3, "union should deduplicate overlapping keys");
|
|
302
|
+
} finally {
|
|
303
|
+
cleanup(projectRoot);
|
|
304
|
+
cleanup(worktree);
|
|
305
|
+
}
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
test("completed-units.json set-union merge produces correct result", () => {
|
|
309
|
+
// Verify that a manual set-union merge (as done in syncStateToProjectRoot)
|
|
310
|
+
// correctly merges two JSON arrays of keys.
|
|
311
|
+
const projectRoot = makeTmpBase();
|
|
312
|
+
const worktree = makeTmpBase();
|
|
313
|
+
try {
|
|
314
|
+
// Write keys to both locations
|
|
315
|
+
const prKeysFile = join(projectRoot, ".gsd", "completed-units.json");
|
|
316
|
+
const wtKeysFile = join(worktree, ".gsd", "completed-units.json");
|
|
317
|
+
|
|
318
|
+
writeFileSync(prKeysFile, JSON.stringify(["a", "b"]));
|
|
319
|
+
writeFileSync(wtKeysFile, JSON.stringify(["b", "c", "d"]));
|
|
320
|
+
|
|
321
|
+
// Perform the same merge logic used in syncStateToProjectRoot
|
|
322
|
+
const srcKeys: string[] = JSON.parse(readFileSync(wtKeysFile, "utf8"));
|
|
323
|
+
let dstKeys: string[] = [];
|
|
324
|
+
if (existsSync(prKeysFile)) {
|
|
325
|
+
dstKeys = JSON.parse(readFileSync(prKeysFile, "utf8"));
|
|
326
|
+
}
|
|
327
|
+
const merged = [...new Set([...dstKeys, ...srcKeys])];
|
|
328
|
+
writeFileSync(prKeysFile, JSON.stringify(merged, null, 2));
|
|
329
|
+
|
|
330
|
+
// Verify the merged result
|
|
331
|
+
const result: string[] = JSON.parse(readFileSync(prKeysFile, "utf8"));
|
|
332
|
+
assert.deepStrictEqual(result.sort(), ["a", "b", "c", "d"]);
|
|
333
|
+
} finally {
|
|
334
|
+
cleanup(projectRoot);
|
|
335
|
+
cleanup(worktree);
|
|
336
|
+
}
|
|
337
|
+
});
|
|
338
|
+
|
|
276
339
|
// ─── verifyExpectedArtifact: parse cache collision regression ─────────────
|
|
277
340
|
|
|
278
341
|
test("verifyExpectedArtifact detects roadmap [x] change despite parse cache", () => {
|
|
@@ -320,3 +383,204 @@ test("verifyExpectedArtifact detects roadmap [x] change despite parse cache", ()
|
|
|
320
383
|
cleanup(base);
|
|
321
384
|
}
|
|
322
385
|
});
|
|
386
|
+
|
|
387
|
+
// ─── verifyExpectedArtifact: plan-slice empty scaffold regression (#699) ──
|
|
388
|
+
|
|
389
|
+
test("verifyExpectedArtifact rejects plan-slice with empty scaffold", () => {
|
|
390
|
+
const base = makeTmpBase();
|
|
391
|
+
try {
|
|
392
|
+
const sliceDir = join(base, ".gsd", "milestones", "M001", "slices", "S01");
|
|
393
|
+
mkdirSync(sliceDir, { recursive: true });
|
|
394
|
+
writeFileSync(join(sliceDir, "S01-PLAN.md"), "# S01: Test Slice\n\n## Tasks\n\n");
|
|
395
|
+
assert.strictEqual(
|
|
396
|
+
verifyExpectedArtifact("plan-slice", "M001/S01", base),
|
|
397
|
+
false,
|
|
398
|
+
"Empty scaffold should not be treated as completed artifact",
|
|
399
|
+
);
|
|
400
|
+
} finally {
|
|
401
|
+
cleanup(base);
|
|
402
|
+
}
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
test("verifyExpectedArtifact accepts plan-slice with actual tasks", () => {
|
|
406
|
+
const base = makeTmpBase();
|
|
407
|
+
try {
|
|
408
|
+
const sliceDir = join(base, ".gsd", "milestones", "M001", "slices", "S01");
|
|
409
|
+
const tasksDir = join(sliceDir, "tasks");
|
|
410
|
+
mkdirSync(tasksDir, { recursive: true });
|
|
411
|
+
writeFileSync(join(sliceDir, "S01-PLAN.md"), [
|
|
412
|
+
"# S01: Test Slice",
|
|
413
|
+
"",
|
|
414
|
+
"## Tasks",
|
|
415
|
+
"",
|
|
416
|
+
"- [ ] **T01: Implement feature** `est:2h`",
|
|
417
|
+
"- [ ] **T02: Write tests** `est:1h`",
|
|
418
|
+
].join("\n"));
|
|
419
|
+
writeFileSync(join(tasksDir, "T01-PLAN.md"), "# T01 Plan");
|
|
420
|
+
writeFileSync(join(tasksDir, "T02-PLAN.md"), "# T02 Plan");
|
|
421
|
+
assert.strictEqual(
|
|
422
|
+
verifyExpectedArtifact("plan-slice", "M001/S01", base),
|
|
423
|
+
true,
|
|
424
|
+
"Plan with task entries should be treated as completed artifact",
|
|
425
|
+
);
|
|
426
|
+
} finally {
|
|
427
|
+
cleanup(base);
|
|
428
|
+
}
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
test("verifyExpectedArtifact accepts plan-slice with completed tasks", () => {
|
|
432
|
+
const base = makeTmpBase();
|
|
433
|
+
try {
|
|
434
|
+
const sliceDir = join(base, ".gsd", "milestones", "M001", "slices", "S01");
|
|
435
|
+
const tasksDir = join(sliceDir, "tasks");
|
|
436
|
+
mkdirSync(tasksDir, { recursive: true });
|
|
437
|
+
writeFileSync(join(sliceDir, "S01-PLAN.md"), [
|
|
438
|
+
"# S01: Test Slice",
|
|
439
|
+
"",
|
|
440
|
+
"## Tasks",
|
|
441
|
+
"",
|
|
442
|
+
"- [x] **T01: Implement feature** `est:2h`",
|
|
443
|
+
"- [ ] **T02: Write tests** `est:1h`",
|
|
444
|
+
].join("\n"));
|
|
445
|
+
writeFileSync(join(tasksDir, "T01-PLAN.md"), "# T01 Plan");
|
|
446
|
+
writeFileSync(join(tasksDir, "T02-PLAN.md"), "# T02 Plan");
|
|
447
|
+
assert.strictEqual(
|
|
448
|
+
verifyExpectedArtifact("plan-slice", "M001/S01", base),
|
|
449
|
+
true,
|
|
450
|
+
"Plan with completed task entries should be treated as completed artifact",
|
|
451
|
+
);
|
|
452
|
+
} finally {
|
|
453
|
+
cleanup(base);
|
|
454
|
+
}
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
// ─── verifyExpectedArtifact: plan-slice task plan check (#739) ────────────
|
|
458
|
+
|
|
459
|
+
test("verifyExpectedArtifact plan-slice passes when all task plan files exist", () => {
|
|
460
|
+
const base = makeTmpBase();
|
|
461
|
+
try {
|
|
462
|
+
const tasksDir = join(base, ".gsd", "milestones", "M001", "slices", "S01", "tasks");
|
|
463
|
+
const planPath = join(base, ".gsd", "milestones", "M001", "slices", "S01", "S01-PLAN.md");
|
|
464
|
+
const planContent = [
|
|
465
|
+
"# S01: Test Slice",
|
|
466
|
+
"",
|
|
467
|
+
"## Tasks",
|
|
468
|
+
"",
|
|
469
|
+
"- [ ] **T01: First task** `est:1h`",
|
|
470
|
+
"- [ ] **T02: Second task** `est:2h`",
|
|
471
|
+
].join("\n");
|
|
472
|
+
writeFileSync(planPath, planContent);
|
|
473
|
+
writeFileSync(join(tasksDir, "T01-PLAN.md"), "# T01 Plan\n\nDo the thing.");
|
|
474
|
+
writeFileSync(join(tasksDir, "T02-PLAN.md"), "# T02 Plan\n\nDo the other thing.");
|
|
475
|
+
|
|
476
|
+
const result = verifyExpectedArtifact("plan-slice", "M001/S01", base);
|
|
477
|
+
assert.equal(result, true, "should pass when all task plan files exist");
|
|
478
|
+
} finally {
|
|
479
|
+
cleanup(base);
|
|
480
|
+
}
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
test("verifyExpectedArtifact plan-slice fails when a task plan file is missing (#739)", () => {
|
|
484
|
+
const base = makeTmpBase();
|
|
485
|
+
try {
|
|
486
|
+
const tasksDir = join(base, ".gsd", "milestones", "M001", "slices", "S01", "tasks");
|
|
487
|
+
const planPath = join(base, ".gsd", "milestones", "M001", "slices", "S01", "S01-PLAN.md");
|
|
488
|
+
const planContent = [
|
|
489
|
+
"# S01: Test Slice",
|
|
490
|
+
"",
|
|
491
|
+
"## Tasks",
|
|
492
|
+
"",
|
|
493
|
+
"- [ ] **T01: First task** `est:1h`",
|
|
494
|
+
"- [ ] **T02: Second task** `est:2h`",
|
|
495
|
+
].join("\n");
|
|
496
|
+
writeFileSync(planPath, planContent);
|
|
497
|
+
// Only write T01-PLAN.md — T02 is missing
|
|
498
|
+
writeFileSync(join(tasksDir, "T01-PLAN.md"), "# T01 Plan\n\nDo the thing.");
|
|
499
|
+
|
|
500
|
+
const result = verifyExpectedArtifact("plan-slice", "M001/S01", base);
|
|
501
|
+
assert.equal(result, false, "should fail when T02-PLAN.md is missing");
|
|
502
|
+
} finally {
|
|
503
|
+
cleanup(base);
|
|
504
|
+
}
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
test("verifyExpectedArtifact plan-slice fails for plan with no tasks (#699)", () => {
|
|
508
|
+
const base = makeTmpBase();
|
|
509
|
+
try {
|
|
510
|
+
const planPath = join(base, ".gsd", "milestones", "M001", "slices", "S01", "S01-PLAN.md");
|
|
511
|
+
const planContent = [
|
|
512
|
+
"# S01: Test Slice",
|
|
513
|
+
"",
|
|
514
|
+
"## Goal",
|
|
515
|
+
"",
|
|
516
|
+
"Just some documentation updates, no tasks.",
|
|
517
|
+
].join("\n");
|
|
518
|
+
writeFileSync(planPath, planContent);
|
|
519
|
+
|
|
520
|
+
const result = verifyExpectedArtifact("plan-slice", "M001/S01", base);
|
|
521
|
+
assert.equal(result, false, "should fail when plan has no task entries (empty scaffold, #699)");
|
|
522
|
+
} finally {
|
|
523
|
+
cleanup(base);
|
|
524
|
+
}
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
// ─── selfHealRuntimeRecords — worktree base path (#769) ──────────────────
|
|
528
|
+
|
|
529
|
+
test("selfHealRuntimeRecords clears stale record when artifact exists at worktree base (#769)", async () => {
|
|
530
|
+
// Simulate worktree layout: the runtime record AND the artifact both live
|
|
531
|
+
// under the worktree's .gsd/, not the main project root.
|
|
532
|
+
const worktreeBase = makeTmpBase();
|
|
533
|
+
const mainBase = makeTmpBase();
|
|
534
|
+
try {
|
|
535
|
+
const { writeUnitRuntimeRecord, readUnitRuntimeRecord } = await import("../unit-runtime.ts");
|
|
536
|
+
|
|
537
|
+
// Write a stale runtime record in the worktree .gsd/runtime/units/
|
|
538
|
+
writeUnitRuntimeRecord(worktreeBase, "run-uat", "M001/S01", Date.now() - 7200_000, {
|
|
539
|
+
phase: "dispatched",
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
// Write the UAT result artifact in the worktree .gsd/milestones/
|
|
543
|
+
const uatPath = join(worktreeBase, ".gsd", "milestones", "M001", "slices", "S01", "S01-UAT-RESULT.md");
|
|
544
|
+
writeFileSync(uatPath, "---\nresult: pass\n---\n# UAT Result\nAll tests passed.\n");
|
|
545
|
+
|
|
546
|
+
// Verify the runtime record exists before heal
|
|
547
|
+
const before = readUnitRuntimeRecord(worktreeBase, "run-uat", "M001/S01");
|
|
548
|
+
assert.ok(before, "runtime record should exist before heal");
|
|
549
|
+
|
|
550
|
+
// Mock ExtensionContext with minimal notify
|
|
551
|
+
const notifications: string[] = [];
|
|
552
|
+
const mockCtx = {
|
|
553
|
+
ui: { notify: (msg: string) => { notifications.push(msg); } },
|
|
554
|
+
} as any;
|
|
555
|
+
|
|
556
|
+
// Call selfHeal with worktreeBase — this is the fix: using the worktree path
|
|
557
|
+
// so both the runtime record and artifact are found
|
|
558
|
+
const completedKeys = new Set<string>();
|
|
559
|
+
await selfHealRuntimeRecords(worktreeBase, mockCtx, completedKeys);
|
|
560
|
+
|
|
561
|
+
// The stale record should be cleared
|
|
562
|
+
const after = readUnitRuntimeRecord(worktreeBase, "run-uat", "M001/S01");
|
|
563
|
+
assert.equal(after, null, "runtime record should be cleared after heal");
|
|
564
|
+
|
|
565
|
+
// The completion key should be persisted
|
|
566
|
+
assert.ok(completedKeys.has("run-uat/M001/S01"), "completion key should be added");
|
|
567
|
+
assert.ok(notifications.some(n => n.includes("Self-heal")), "should emit self-heal notification");
|
|
568
|
+
|
|
569
|
+
// Now verify that calling with mainBase does NOT find/clear anything (the old bug)
|
|
570
|
+
// Write a stale record at mainBase but NO artifact there
|
|
571
|
+
writeUnitRuntimeRecord(mainBase, "run-uat", "M001/S01", Date.now() - 7200_000, {
|
|
572
|
+
phase: "dispatched",
|
|
573
|
+
});
|
|
574
|
+
const mainKeys = new Set<string>();
|
|
575
|
+
await selfHealRuntimeRecords(mainBase, mockCtx, mainKeys);
|
|
576
|
+
|
|
577
|
+
// The record at mainBase should be cleared by the stale timeout (>1h),
|
|
578
|
+
// but the completion key should NOT be set (artifact doesn't exist at mainBase)
|
|
579
|
+
const afterMain = readUnitRuntimeRecord(mainBase, "run-uat", "M001/S01");
|
|
580
|
+
assert.equal(afterMain, null, "stale record at main base should be cleared by timeout");
|
|
581
|
+
assert.ok(!mainKeys.has("run-uat/M001/S01"), "completion key should NOT be set when artifact is missing");
|
|
582
|
+
} finally {
|
|
583
|
+
cleanup(worktreeBase);
|
|
584
|
+
cleanup(mainBase);
|
|
585
|
+
}
|
|
586
|
+
});
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* auto-skip-loop.test.ts — Tests for the consecutive-skip loop breaker.
|
|
3
|
+
*
|
|
4
|
+
* Regression for #728: auto-mode infinite skip loop on previously completed
|
|
5
|
+
* plan-slice units when deriveState keeps returning the same unit.
|
|
6
|
+
*
|
|
7
|
+
* The skip paths in dispatchNextUnit track consecutive skips per unit via
|
|
8
|
+
* unitConsecutiveSkips. When the same unit is skipped > MAX_CONSECUTIVE_SKIPS
|
|
9
|
+
* times without a real dispatch in between, the completion record is evicted
|
|
10
|
+
* so deriveState can reconcile.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
import { tmpdir } from "node:os";
|
|
16
|
+
|
|
17
|
+
import {
|
|
18
|
+
_getUnitConsecutiveSkips,
|
|
19
|
+
_resetUnitConsecutiveSkips,
|
|
20
|
+
MAX_CONSECUTIVE_SKIPS,
|
|
21
|
+
} from "../auto.ts";
|
|
22
|
+
import { persistCompletedKey, removePersistedKey, loadPersistedKeys } from "../auto-recovery.ts";
|
|
23
|
+
import { createTestContext } from "./test-helpers.ts";
|
|
24
|
+
|
|
25
|
+
const { assertEq, assertTrue, report } = createTestContext();
|
|
26
|
+
|
|
27
|
+
function makeTmpBase(): string {
|
|
28
|
+
const dir = mkdtempSync(join(tmpdir(), "gsd-skip-loop-test-"));
|
|
29
|
+
mkdirSync(join(dir, ".gsd"), { recursive: true });
|
|
30
|
+
return dir;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function main(): Promise<void> {
|
|
34
|
+
// ─── Counter starts at zero ────────────────────────────────────────────
|
|
35
|
+
console.log("\n=== skip loop counter: initial state ===");
|
|
36
|
+
{
|
|
37
|
+
_resetUnitConsecutiveSkips();
|
|
38
|
+
const map = _getUnitConsecutiveSkips();
|
|
39
|
+
assertEq(map.size, 0, "counter map starts empty after reset");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// ─── Counter increments correctly ────────────────────────────────────
|
|
43
|
+
console.log("\n=== skip loop counter: increments on repeated calls ===");
|
|
44
|
+
{
|
|
45
|
+
_resetUnitConsecutiveSkips();
|
|
46
|
+
const map = _getUnitConsecutiveSkips();
|
|
47
|
+
const key = "plan-slice/M001/S04";
|
|
48
|
+
|
|
49
|
+
for (let i = 1; i <= MAX_CONSECUTIVE_SKIPS; i++) {
|
|
50
|
+
const prev = map.get(key) ?? 0;
|
|
51
|
+
map.set(key, prev + 1);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
assertEq(map.get(key), MAX_CONSECUTIVE_SKIPS, `counter reaches MAX_CONSECUTIVE_SKIPS (${MAX_CONSECUTIVE_SKIPS})`);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ─── Threshold constant is sane ──────────────────────────────────────
|
|
58
|
+
console.log("\n=== skip loop counter: threshold is reasonable ===");
|
|
59
|
+
{
|
|
60
|
+
assertTrue(MAX_CONSECUTIVE_SKIPS >= 3, "threshold allows a few legitimate skips");
|
|
61
|
+
assertTrue(MAX_CONSECUTIVE_SKIPS <= 10, "threshold catches loops quickly");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ─── Reset clears all keys ────────────────────────────────────────────
|
|
65
|
+
console.log("\n=== skip loop counter: reset clears all keys ===");
|
|
66
|
+
{
|
|
67
|
+
_resetUnitConsecutiveSkips();
|
|
68
|
+
const map = _getUnitConsecutiveSkips();
|
|
69
|
+
map.set("plan-slice/M001/S01", 2);
|
|
70
|
+
map.set("plan-slice/M001/S02", 1);
|
|
71
|
+
assertEq(map.size, 2, "map has 2 entries before reset");
|
|
72
|
+
|
|
73
|
+
_resetUnitConsecutiveSkips();
|
|
74
|
+
assertEq(_getUnitConsecutiveSkips().size, 0, "map empty after reset");
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ─── Eviction path: persistCompletedKey + removePersistedKey round-trip
|
|
78
|
+
// (simulates what the loop-breaker does) ───────────────────────────
|
|
79
|
+
console.log("\n=== skip loop counter: eviction removes persisted key ===");
|
|
80
|
+
{
|
|
81
|
+
_resetUnitConsecutiveSkips();
|
|
82
|
+
const base = makeTmpBase();
|
|
83
|
+
try {
|
|
84
|
+
const key = "plan-slice/M001/S04";
|
|
85
|
+
const keySet = new Set<string>();
|
|
86
|
+
|
|
87
|
+
persistCompletedKey(base, key);
|
|
88
|
+
loadPersistedKeys(base, keySet);
|
|
89
|
+
assertTrue(keySet.has(key), "key persisted before eviction");
|
|
90
|
+
|
|
91
|
+
// Simulate loop-breaker eviction
|
|
92
|
+
keySet.delete(key);
|
|
93
|
+
removePersistedKey(base, key);
|
|
94
|
+
const keySet2 = new Set<string>();
|
|
95
|
+
loadPersistedKeys(base, keySet2);
|
|
96
|
+
assertTrue(!keySet2.has(key), "key absent after eviction");
|
|
97
|
+
} finally {
|
|
98
|
+
rmSync(base, { recursive: true, force: true });
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ─── Counter resets per-key, not globally ─────────────────────────────
|
|
103
|
+
console.log("\n=== skip loop counter: per-key isolation ===");
|
|
104
|
+
{
|
|
105
|
+
_resetUnitConsecutiveSkips();
|
|
106
|
+
const map = _getUnitConsecutiveSkips();
|
|
107
|
+
map.set("plan-slice/M001/S04", MAX_CONSECUTIVE_SKIPS + 1);
|
|
108
|
+
map.set("plan-slice/M001/S05", 1);
|
|
109
|
+
|
|
110
|
+
// Deleting S04 (eviction) should not affect S05
|
|
111
|
+
map.delete("plan-slice/M001/S04");
|
|
112
|
+
assertTrue(!map.has("plan-slice/M001/S04"), "S04 evicted");
|
|
113
|
+
assertEq(map.get("plan-slice/M001/S05"), 1, "S05 counter unaffected");
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
_resetUnitConsecutiveSkips();
|
|
117
|
+
report();
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
main().catch((err) => {
|
|
121
|
+
console.error(err);
|
|
122
|
+
process.exit(1);
|
|
123
|
+
});
|
|
@@ -290,6 +290,40 @@ async function main(): Promise<void> {
|
|
|
290
290
|
assertTrue(existsSync(join(repo, "feature.ts")), "feature.ts merged to main");
|
|
291
291
|
}
|
|
292
292
|
|
|
293
|
+
// ─── Test 6: Skip checkout when main already current (#757) ───────
|
|
294
|
+
console.log("\n=== skip checkout when main already current (#757) ===");
|
|
295
|
+
{
|
|
296
|
+
const repo = freshRepo();
|
|
297
|
+
const wtPath = createAutoWorktree(repo, "M060");
|
|
298
|
+
|
|
299
|
+
addSliceToMilestone(repo, wtPath, "M060", "S01", "Skip checkout test", [
|
|
300
|
+
{ file: "skip-checkout.ts", content: "export const skip = true;\n", message: "add skip-checkout" },
|
|
301
|
+
]);
|
|
302
|
+
|
|
303
|
+
const roadmap = makeRoadmap("M060", "Skip checkout verification", [
|
|
304
|
+
{ id: "S01", title: "Skip checkout test" },
|
|
305
|
+
]);
|
|
306
|
+
|
|
307
|
+
// Verify main is already checked out at repo root (worktree default)
|
|
308
|
+
const branchAtRoot = run("git rev-parse --abbrev-ref HEAD", repo);
|
|
309
|
+
assertEq(branchAtRoot, "main", "main is already checked out at project root");
|
|
310
|
+
|
|
311
|
+
// mergeMilestoneToMain should succeed without attempting to checkout main
|
|
312
|
+
// (which would fail with "already used by worktree" error)
|
|
313
|
+
let threw = false;
|
|
314
|
+
try {
|
|
315
|
+
const result = mergeMilestoneToMain(repo, "M060", roadmap);
|
|
316
|
+
assertTrue(result.commitMessage.includes("feat(M060)"), "merge commit created");
|
|
317
|
+
} catch (err) {
|
|
318
|
+
threw = true;
|
|
319
|
+
console.error("Unexpected error:", err);
|
|
320
|
+
}
|
|
321
|
+
assertTrue(!threw, "does not fail when main is already checked out at project root");
|
|
322
|
+
|
|
323
|
+
// Verify the merge actually happened
|
|
324
|
+
assertTrue(existsSync(join(repo, "skip-checkout.ts")), "skip-checkout.ts merged to main");
|
|
325
|
+
}
|
|
326
|
+
|
|
293
327
|
} finally {
|
|
294
328
|
process.chdir(savedCwd);
|
|
295
329
|
for (const d of tempDirs) {
|
|
@@ -45,6 +45,12 @@ function writeMilestoneSummary(base: string, mid: string, content: string): void
|
|
|
45
45
|
writeFileSync(join(dir, `${mid}-SUMMARY.md`), content);
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
+
function writeMilestoneValidation(base: string, mid: string, verdict: string = "pass"): void {
|
|
49
|
+
const dir = join(base, ".gsd", "milestones", mid);
|
|
50
|
+
mkdirSync(dir, { recursive: true });
|
|
51
|
+
writeFileSync(join(dir, `${mid}-VALIDATION.md`), `---\nverdict: ${verdict}\nremediation_round: 0\n---\n\n# Validation\nValidated.`);
|
|
52
|
+
}
|
|
53
|
+
|
|
48
54
|
function cleanup(base: string): void {
|
|
49
55
|
rmSync(base, { recursive: true, force: true });
|
|
50
56
|
}
|
|
@@ -176,7 +182,8 @@ async function main(): Promise<void> {
|
|
|
176
182
|
const roadmap = parseRoadmap(roadmapContent!);
|
|
177
183
|
assertTrue(isMilestoneComplete(roadmap), "isMilestoneComplete returns true when all slices are [x]");
|
|
178
184
|
|
|
179
|
-
// Verify deriveState returns completing-milestone phase
|
|
185
|
+
// Verify deriveState returns completing-milestone phase (with validation already done)
|
|
186
|
+
writeMilestoneValidation(base, "M001");
|
|
180
187
|
const state = await deriveState(base);
|
|
181
188
|
assertEq(state.phase, "completing-milestone", "deriveState returns completing-milestone when all slices done, no summary");
|
|
182
189
|
assertEq(state.activeMilestone?.id, "M001", "active milestone is M001");
|