gsd-pi 2.59.0-dev.d77b3dd → 2.60.0-dev.2580e65
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/dist/resources/extensions/ask-user-questions.js +7 -4
- package/dist/resources/extensions/gsd/auto/phases.js +15 -7
- package/dist/resources/extensions/gsd/auto-dashboard.js +21 -8
- package/dist/resources/extensions/gsd/auto-dispatch.js +6 -3
- package/dist/resources/extensions/gsd/auto-model-selection.js +58 -9
- package/dist/resources/extensions/gsd/auto-post-unit.js +3 -2
- package/dist/resources/extensions/gsd/auto-prompts.js +36 -20
- package/dist/resources/extensions/gsd/auto-recovery.js +37 -18
- package/dist/resources/extensions/gsd/auto-start.js +9 -5
- package/dist/resources/extensions/gsd/auto-timers.js +11 -5
- package/dist/resources/extensions/gsd/auto-unit-closeout.js +5 -3
- package/dist/resources/extensions/gsd/auto-verification.js +3 -2
- package/dist/resources/extensions/gsd/auto-worktree.js +120 -55
- package/dist/resources/extensions/gsd/auto.js +39 -17
- package/dist/resources/extensions/gsd/bootstrap/agent-end-recovery.js +6 -3
- package/dist/resources/extensions/gsd/bootstrap/db-tools.js +2 -2
- package/dist/resources/extensions/gsd/bootstrap/dynamic-tools.js +4 -10
- package/dist/resources/extensions/gsd/bootstrap/journal-tools.js +2 -1
- package/dist/resources/extensions/gsd/bootstrap/register-hooks.js +7 -0
- package/dist/resources/extensions/gsd/bootstrap/system-context.js +11 -10
- package/dist/resources/extensions/gsd/commands/catalog.js +2 -0
- package/dist/resources/extensions/gsd/commands-codebase.js +48 -21
- package/dist/resources/extensions/gsd/commands-inspect.js +2 -1
- package/dist/resources/extensions/gsd/commands-maintenance.js +32 -19
- package/dist/resources/extensions/gsd/complexity-classifier.js +8 -4
- package/dist/resources/extensions/gsd/custom-verification.js +3 -2
- package/dist/resources/extensions/gsd/gsd-db.js +33 -13
- package/dist/resources/extensions/gsd/guided-flow.js +19 -9
- package/dist/resources/extensions/gsd/init-wizard.js +12 -0
- package/dist/resources/extensions/gsd/markdown-renderer.js +11 -9
- package/dist/resources/extensions/gsd/md-importer.js +5 -4
- package/dist/resources/extensions/gsd/milestone-actions.js +3 -2
- package/dist/resources/extensions/gsd/milestone-ids.js +2 -1
- package/dist/resources/extensions/gsd/model-router.js +156 -121
- package/dist/resources/extensions/gsd/parallel-merge.js +5 -3
- package/dist/resources/extensions/gsd/parallel-orchestrator.js +26 -14
- package/dist/resources/extensions/gsd/preferences-types.js +1 -0
- package/dist/resources/extensions/gsd/preferences-validation.js +45 -0
- package/dist/resources/extensions/gsd/preferences.js +15 -3
- package/dist/resources/extensions/gsd/prompt-loader.js +3 -2
- package/dist/resources/extensions/gsd/prompts/rethink.md +1 -1
- package/dist/resources/extensions/gsd/rule-registry.js +7 -6
- package/dist/resources/extensions/gsd/safe-fs.js +6 -8
- package/dist/resources/extensions/gsd/tools/complete-milestone.js +3 -2
- package/dist/resources/extensions/gsd/tools/complete-slice.js +3 -2
- package/dist/resources/extensions/gsd/tools/complete-task.js +3 -2
- package/dist/resources/extensions/gsd/tools/plan-milestone.js +3 -2
- package/dist/resources/extensions/gsd/tools/plan-slice.js +3 -2
- package/dist/resources/extensions/gsd/tools/plan-task.js +2 -1
- package/dist/resources/extensions/gsd/tools/reassess-roadmap.js +4 -4
- package/dist/resources/extensions/gsd/tools/reopen-slice.js +2 -1
- package/dist/resources/extensions/gsd/tools/reopen-task.js +2 -1
- package/dist/resources/extensions/gsd/tools/replan-slice.js +2 -1
- package/dist/resources/extensions/gsd/tools/validate-milestone.js +2 -1
- package/dist/resources/extensions/gsd/triage-resolution.js +11 -4
- package/dist/resources/extensions/gsd/workflow-events.js +2 -1
- package/dist/resources/extensions/gsd/workflow-logger.js +37 -4
- package/dist/resources/extensions/gsd/workflow-migration.js +14 -12
- package/dist/resources/extensions/gsd/workflow-projections.js +2 -2
- package/dist/resources/extensions/gsd/workflow-reconcile.js +2 -2
- package/dist/resources/extensions/gsd/worktree-manager.js +26 -14
- package/dist/resources/extensions/shared/interview-ui.js +3 -1
- package/dist/web/standalone/.next/BUILD_ID +1 -1
- package/dist/web/standalone/.next/app-path-routes-manifest.json +19 -19
- 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 +2 -2
- 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 +19 -19
- package/dist/web/standalone/.next/server/pages/404.html +1 -1
- package/dist/web/standalone/.next/server/pages/500.html +2 -2
- package/dist/web/standalone/.next/server/server-reference-manifest.json +1 -1
- package/package.json +1 -1
- package/packages/pi-coding-agent/dist/core/extensions/loader.d.ts.map +1 -1
- package/packages/pi-coding-agent/dist/core/extensions/loader.js +5 -0
- package/packages/pi-coding-agent/dist/core/extensions/loader.js.map +1 -1
- package/packages/pi-coding-agent/dist/core/extensions/runner.d.ts +2 -1
- package/packages/pi-coding-agent/dist/core/extensions/runner.d.ts.map +1 -1
- package/packages/pi-coding-agent/dist/core/extensions/runner.js +16 -0
- package/packages/pi-coding-agent/dist/core/extensions/runner.js.map +1 -1
- package/packages/pi-coding-agent/dist/core/extensions/types.d.ts +26 -0
- package/packages/pi-coding-agent/dist/core/extensions/types.d.ts.map +1 -1
- package/packages/pi-coding-agent/dist/core/extensions/types.js.map +1 -1
- package/packages/pi-coding-agent/dist/core/lsp/config.d.ts.map +1 -1
- package/packages/pi-coding-agent/dist/core/lsp/config.js +6 -1
- package/packages/pi-coding-agent/dist/core/lsp/config.js.map +1 -1
- package/packages/pi-coding-agent/dist/core/lsp/defaults.json +2 -2
- package/packages/pi-coding-agent/dist/core/lsp/lsp-legacy-alias.test.d.ts +2 -0
- package/packages/pi-coding-agent/dist/core/lsp/lsp-legacy-alias.test.d.ts.map +1 -0
- package/packages/pi-coding-agent/dist/core/lsp/lsp-legacy-alias.test.js +47 -0
- package/packages/pi-coding-agent/dist/core/lsp/lsp-legacy-alias.test.js.map +1 -0
- package/packages/pi-coding-agent/package.json +1 -1
- package/packages/pi-coding-agent/src/core/extensions/loader.ts +6 -0
- package/packages/pi-coding-agent/src/core/extensions/runner.ts +19 -0
- package/packages/pi-coding-agent/src/core/extensions/types.ts +26 -0
- package/packages/pi-coding-agent/src/core/lsp/config.ts +7 -1
- package/packages/pi-coding-agent/src/core/lsp/defaults.json +2 -2
- package/packages/pi-coding-agent/src/core/lsp/lsp-legacy-alias.test.ts +70 -0
- package/pkg/package.json +1 -1
- package/src/resources/extensions/ask-user-questions.ts +7 -3
- package/src/resources/extensions/gsd/auto/phases.ts +17 -7
- package/src/resources/extensions/gsd/auto-dashboard.ts +22 -8
- package/src/resources/extensions/gsd/auto-dispatch.ts +7 -3
- package/src/resources/extensions/gsd/auto-model-selection.ts +77 -15
- package/src/resources/extensions/gsd/auto-post-unit.ts +4 -4
- package/src/resources/extensions/gsd/auto-prompts.ts +37 -20
- package/src/resources/extensions/gsd/auto-recovery.ts +38 -18
- package/src/resources/extensions/gsd/auto-start.ts +10 -9
- package/src/resources/extensions/gsd/auto-timers.ts +12 -5
- package/src/resources/extensions/gsd/auto-unit-closeout.ts +6 -2
- package/src/resources/extensions/gsd/auto-verification.ts +3 -6
- package/src/resources/extensions/gsd/auto-worktree.ts +121 -55
- package/src/resources/extensions/gsd/auto.ts +40 -17
- package/src/resources/extensions/gsd/bootstrap/agent-end-recovery.ts +4 -3
- package/src/resources/extensions/gsd/bootstrap/db-tools.ts +2 -2
- package/src/resources/extensions/gsd/bootstrap/dynamic-tools.ts +4 -16
- package/src/resources/extensions/gsd/bootstrap/journal-tools.ts +2 -1
- package/src/resources/extensions/gsd/bootstrap/register-hooks.ts +8 -0
- package/src/resources/extensions/gsd/bootstrap/system-context.ts +11 -10
- package/src/resources/extensions/gsd/commands/catalog.ts +2 -0
- package/src/resources/extensions/gsd/commands-codebase.ts +52 -20
- package/src/resources/extensions/gsd/commands-inspect.ts +2 -1
- package/src/resources/extensions/gsd/commands-maintenance.ts +28 -19
- package/src/resources/extensions/gsd/complexity-classifier.ts +9 -4
- package/src/resources/extensions/gsd/custom-verification.ts +3 -2
- package/src/resources/extensions/gsd/gsd-db.ts +12 -14
- package/src/resources/extensions/gsd/guided-flow.ts +9 -8
- package/src/resources/extensions/gsd/init-wizard.ts +12 -0
- package/src/resources/extensions/gsd/markdown-renderer.ts +11 -17
- package/src/resources/extensions/gsd/md-importer.ts +5 -4
- package/src/resources/extensions/gsd/milestone-actions.ts +3 -2
- package/src/resources/extensions/gsd/milestone-ids.ts +2 -1
- package/src/resources/extensions/gsd/model-router.ts +199 -173
- package/src/resources/extensions/gsd/parallel-merge.ts +5 -3
- package/src/resources/extensions/gsd/parallel-orchestrator.ts +18 -14
- package/src/resources/extensions/gsd/preferences-types.ts +13 -0
- package/src/resources/extensions/gsd/preferences-validation.ts +45 -0
- package/src/resources/extensions/gsd/preferences.ts +16 -3
- package/src/resources/extensions/gsd/prompt-loader.ts +3 -2
- package/src/resources/extensions/gsd/prompts/rethink.md +1 -1
- package/src/resources/extensions/gsd/rule-registry.ts +7 -6
- package/src/resources/extensions/gsd/safe-fs.ts +6 -5
- package/src/resources/extensions/gsd/tests/capability-router.test.ts +347 -0
- package/src/resources/extensions/gsd/tests/codebase-generator.test.ts +63 -0
- package/src/resources/extensions/gsd/tests/complexity-classifier.test.ts +27 -2
- package/src/resources/extensions/gsd/tests/db-path-worktree-symlink.test.ts +4 -4
- package/src/resources/extensions/gsd/tests/integration/state-machine-edge-cases.test.ts +1188 -0
- package/src/resources/extensions/gsd/tests/integration/state-machine-runtime-failures.test.ts +841 -0
- package/src/resources/extensions/gsd/tests/model-router.test.ts +403 -3
- package/src/resources/extensions/gsd/tests/preferences.test.ts +62 -0
- package/src/resources/extensions/gsd/tests/remote-questions.test.ts +21 -0
- package/src/resources/extensions/gsd/tests/silent-catch-diagnostics.test.ts +284 -0
- package/src/resources/extensions/gsd/tests/workflow-logger-audit.test.ts +120 -0
- package/src/resources/extensions/gsd/tests/workflow-logger.test.ts +6 -6
- package/src/resources/extensions/gsd/tools/complete-milestone.ts +3 -6
- package/src/resources/extensions/gsd/tools/complete-slice.ts +3 -6
- package/src/resources/extensions/gsd/tools/complete-task.ts +3 -6
- package/src/resources/extensions/gsd/tools/plan-milestone.ts +3 -6
- package/src/resources/extensions/gsd/tools/plan-slice.ts +3 -6
- package/src/resources/extensions/gsd/tools/plan-task.ts +2 -3
- package/src/resources/extensions/gsd/tools/reassess-roadmap.ts +4 -6
- package/src/resources/extensions/gsd/tools/reopen-slice.ts +2 -3
- package/src/resources/extensions/gsd/tools/reopen-task.ts +2 -3
- package/src/resources/extensions/gsd/tools/replan-slice.ts +2 -3
- package/src/resources/extensions/gsd/tools/validate-milestone.ts +2 -3
- package/src/resources/extensions/gsd/triage-resolution.ts +11 -4
- package/src/resources/extensions/gsd/types.ts +1 -0
- package/src/resources/extensions/gsd/workflow-events.ts +2 -1
- package/src/resources/extensions/gsd/workflow-logger.ts +52 -5
- package/src/resources/extensions/gsd/workflow-migration.ts +14 -12
- package/src/resources/extensions/gsd/workflow-projections.ts +2 -2
- package/src/resources/extensions/gsd/workflow-reconcile.ts +2 -2
- package/src/resources/extensions/gsd/worktree-manager.ts +16 -14
- package/src/resources/extensions/shared/interview-ui.ts +3 -1
- package/src/resources/extensions/shared/tests/interview-notes-loop.test.ts +144 -0
- /package/dist/web/standalone/.next/static/{t_cBZAENjaOJIRST3dw08 → ogyMN7M-3bGGuRY08L5HR}/_buildManifest.js +0 -0
- /package/dist/web/standalone/.next/static/{t_cBZAENjaOJIRST3dw08 → ogyMN7M-3bGGuRY08L5HR}/_ssgManifest.js +0 -0
|
@@ -0,0 +1,841 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* state-machine-runtime-failures.test.ts — Tests for auto-loop runtime failures,
|
|
3
|
+
* infrastructure errors, stuck detection, session management, merge conflicts,
|
|
4
|
+
* concurrent access, and race conditions.
|
|
5
|
+
*
|
|
6
|
+
* These tests use mocked LoopDeps and AutoSession to exercise the auto-loop
|
|
7
|
+
* error handling paths without requiring real LLM sessions or network access.
|
|
8
|
+
*
|
|
9
|
+
* Coverage gaps filled:
|
|
10
|
+
* 1. Infrastructure error detection and immediate stop (ENOSPC, ENOMEM, etc.)
|
|
11
|
+
* 2. Consecutive error graduated recovery (1st → retry, 2nd → cache flush, 3rd → stop)
|
|
12
|
+
* 3. Stuck detection: same error repeated, same unit 3x, oscillation A↔B
|
|
13
|
+
* 4. Session lock validation: compromised, pid-mismatch, missing-metadata
|
|
14
|
+
* 5. Session creation timeout (NEW_SESSION_TIMEOUT_MS = 30s)
|
|
15
|
+
* 6. MergeConflictError stops auto-loop
|
|
16
|
+
* 7. Max iteration safety valve
|
|
17
|
+
* 8. s.active race: pause signal during unit execution
|
|
18
|
+
* 9. Filesystem mutation during dispatch cycle
|
|
19
|
+
* 10. Worktree disappearance detection
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
// GSD State Machine Runtime Failure Tests
|
|
23
|
+
|
|
24
|
+
import { describe, test, beforeEach, afterEach } from "node:test";
|
|
25
|
+
import assert from "node:assert/strict";
|
|
26
|
+
import {
|
|
27
|
+
mkdtempSync,
|
|
28
|
+
mkdirSync,
|
|
29
|
+
writeFileSync,
|
|
30
|
+
rmSync,
|
|
31
|
+
existsSync,
|
|
32
|
+
unlinkSync,
|
|
33
|
+
} from "node:fs";
|
|
34
|
+
import { tmpdir } from "node:os";
|
|
35
|
+
import { join } from "node:path";
|
|
36
|
+
|
|
37
|
+
// ── Infrastructure error detection ───────────────────────────────────────
|
|
38
|
+
import {
|
|
39
|
+
isInfrastructureError,
|
|
40
|
+
INFRA_ERROR_CODES,
|
|
41
|
+
} from "../../auto/infra-errors.ts";
|
|
42
|
+
|
|
43
|
+
// ── Stuck detection ──────────────────────────────────────────────────────
|
|
44
|
+
import { detectStuck } from "../../auto/detect-stuck.ts";
|
|
45
|
+
import type { WindowEntry } from "../../auto/types.ts";
|
|
46
|
+
|
|
47
|
+
// ── Session constants ────────────────────────────────────────────────────
|
|
48
|
+
import {
|
|
49
|
+
AutoSession,
|
|
50
|
+
NEW_SESSION_TIMEOUT_MS,
|
|
51
|
+
MAX_UNIT_DISPATCHES,
|
|
52
|
+
STUB_RECOVERY_THRESHOLD,
|
|
53
|
+
MAX_LIFETIME_DISPATCHES,
|
|
54
|
+
} from "../../auto/session.ts";
|
|
55
|
+
|
|
56
|
+
// ── Auto-loop types ──────────────────────────────────────────────────────
|
|
57
|
+
import { MAX_LOOP_ITERATIONS } from "../../auto/types.ts";
|
|
58
|
+
|
|
59
|
+
// ── MergeConflictError ───────────────────────────────────────────────────
|
|
60
|
+
import { MergeConflictError } from "../../git-service.ts";
|
|
61
|
+
|
|
62
|
+
// ── Session lock ─────────────────────────────────────────────────────────
|
|
63
|
+
import type { SessionLockStatus } from "../../session-lock.ts";
|
|
64
|
+
|
|
65
|
+
// ── State & DB ───────────────────────────────────────────────────────────
|
|
66
|
+
import {
|
|
67
|
+
openDatabase,
|
|
68
|
+
closeDatabase,
|
|
69
|
+
insertMilestone,
|
|
70
|
+
insertSlice,
|
|
71
|
+
insertTask,
|
|
72
|
+
} from "../../gsd-db.ts";
|
|
73
|
+
import {
|
|
74
|
+
deriveState,
|
|
75
|
+
deriveStateFromDb,
|
|
76
|
+
invalidateStateCache,
|
|
77
|
+
isGhostMilestone,
|
|
78
|
+
} from "../../state.ts";
|
|
79
|
+
import { invalidateAllCaches } from "../../cache.ts";
|
|
80
|
+
|
|
81
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
82
|
+
// Fixture Helpers
|
|
83
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
84
|
+
|
|
85
|
+
function makeTempDir(): string {
|
|
86
|
+
return mkdtempSync(join(tmpdir(), "gsd-runtime-fail-"));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function createMinimalFixture(): string {
|
|
90
|
+
const base = makeTempDir();
|
|
91
|
+
const mDir = join(base, ".gsd", "milestones", "M001", "slices", "S01", "tasks");
|
|
92
|
+
mkdirSync(mDir, { recursive: true });
|
|
93
|
+
writeFileSync(
|
|
94
|
+
join(base, ".gsd", "milestones", "M001", "M001-CONTEXT.md"),
|
|
95
|
+
"# M001: Runtime Test\n\n## Purpose\nTest runtime failures.\n",
|
|
96
|
+
);
|
|
97
|
+
writeFileSync(
|
|
98
|
+
join(base, ".gsd", "milestones", "M001", "M001-ROADMAP.md"),
|
|
99
|
+
[
|
|
100
|
+
"# M001: Runtime Test",
|
|
101
|
+
"",
|
|
102
|
+
"## Vision",
|
|
103
|
+
"Test.",
|
|
104
|
+
"",
|
|
105
|
+
"## Success Criteria",
|
|
106
|
+
"- Works",
|
|
107
|
+
"",
|
|
108
|
+
"## Slices",
|
|
109
|
+
"",
|
|
110
|
+
"- [ ] **S01: Feature** `risk:low` `depends:[]`",
|
|
111
|
+
" - After this: Done.",
|
|
112
|
+
"",
|
|
113
|
+
"## Boundary Map",
|
|
114
|
+
"",
|
|
115
|
+
"| From | To | Produces | Consumes |",
|
|
116
|
+
"|------|----|----------|----------|",
|
|
117
|
+
"| S01 | terminal | out | nothing |",
|
|
118
|
+
].join("\n"),
|
|
119
|
+
);
|
|
120
|
+
writeFileSync(
|
|
121
|
+
join(base, ".gsd", "milestones", "M001", "slices", "S01", "S01-PLAN.md"),
|
|
122
|
+
[
|
|
123
|
+
"# S01: Feature",
|
|
124
|
+
"",
|
|
125
|
+
"**Goal:** Build.",
|
|
126
|
+
"",
|
|
127
|
+
"## Tasks",
|
|
128
|
+
"",
|
|
129
|
+
"- [ ] **T01: Build** `est:30m`",
|
|
130
|
+
" - Do: Build it",
|
|
131
|
+
" - Verify: Test it",
|
|
132
|
+
].join("\n"),
|
|
133
|
+
);
|
|
134
|
+
writeFileSync(
|
|
135
|
+
join(mDir, "T01-PLAN.md"),
|
|
136
|
+
"# T01 Plan\nBuild it.\n",
|
|
137
|
+
);
|
|
138
|
+
return base;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
142
|
+
// Test Suite
|
|
143
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
144
|
+
|
|
145
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
146
|
+
// SECTION 1: Infrastructure Error Detection
|
|
147
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
148
|
+
|
|
149
|
+
describe("infrastructure error detection", () => {
|
|
150
|
+
test("ENOSPC (disk full) is detected as infrastructure error", () => {
|
|
151
|
+
const err = Object.assign(new Error("write ENOSPC"), { code: "ENOSPC" });
|
|
152
|
+
assert.equal(isInfrastructureError(err), "ENOSPC");
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test("ENOMEM (out of memory) is detected", () => {
|
|
156
|
+
const err = Object.assign(new Error("Cannot allocate memory"), { code: "ENOMEM" });
|
|
157
|
+
assert.equal(isInfrastructureError(err), "ENOMEM");
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test("EROFS (read-only filesystem) is detected", () => {
|
|
161
|
+
const err = Object.assign(new Error("Read-only file system"), { code: "EROFS" });
|
|
162
|
+
assert.equal(isInfrastructureError(err), "EROFS");
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test("EDQUOT (disk quota exceeded) is detected", () => {
|
|
166
|
+
const err = Object.assign(new Error("Disk quota exceeded"), { code: "EDQUOT" });
|
|
167
|
+
assert.equal(isInfrastructureError(err), "EDQUOT");
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test("EMFILE (too many open files - process) is detected", () => {
|
|
171
|
+
const err = Object.assign(new Error("too many open files"), { code: "EMFILE" });
|
|
172
|
+
assert.equal(isInfrastructureError(err), "EMFILE");
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test("ENFILE (too many open files - system) is detected", () => {
|
|
176
|
+
const err = Object.assign(new Error("file table overflow"), { code: "ENFILE" });
|
|
177
|
+
assert.equal(isInfrastructureError(err), "ENFILE");
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test("ECONNREFUSED (connection refused) is detected", () => {
|
|
181
|
+
const err = Object.assign(new Error("Connection refused"), { code: "ECONNREFUSED" });
|
|
182
|
+
assert.equal(isInfrastructureError(err), "ECONNREFUSED");
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test("ENOTFOUND (DNS lookup failed) is detected", () => {
|
|
186
|
+
const err = Object.assign(new Error("getaddrinfo ENOTFOUND api.anthropic.com"), { code: "ENOTFOUND" });
|
|
187
|
+
assert.equal(isInfrastructureError(err), "ENOTFOUND");
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test("ENETUNREACH (network unreachable) is detected", () => {
|
|
191
|
+
const err = Object.assign(new Error("network is unreachable"), { code: "ENETUNREACH" });
|
|
192
|
+
assert.equal(isInfrastructureError(err), "ENETUNREACH");
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test("EAGAIN (resource temporarily unavailable) is detected", () => {
|
|
196
|
+
const err = Object.assign(new Error("resource temporarily unavailable"), { code: "EAGAIN" });
|
|
197
|
+
assert.equal(isInfrastructureError(err), "EAGAIN");
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test("SQLite WAL corruption is detected via message scan", () => {
|
|
201
|
+
const err = new Error("database disk image is malformed");
|
|
202
|
+
assert.equal(isInfrastructureError(err), "SQLITE_CORRUPT");
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
test("code-based detection when code property is present", () => {
|
|
206
|
+
const err = { code: "ENOSPC", message: "something" };
|
|
207
|
+
assert.equal(isInfrastructureError(err), "ENOSPC");
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test("message fallback when no code property (e.g. string errors)", () => {
|
|
211
|
+
const err = new Error("write failed: ENOSPC: no space left on device");
|
|
212
|
+
assert.equal(isInfrastructureError(err), "ENOSPC");
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
test("non-infrastructure error returns null", () => {
|
|
216
|
+
assert.equal(isInfrastructureError(new Error("TypeError: x is not a function")), null);
|
|
217
|
+
assert.equal(isInfrastructureError(new Error("SyntaxError: Unexpected token")), null);
|
|
218
|
+
assert.equal(isInfrastructureError(new Error("rate_limit_exceeded")), null);
|
|
219
|
+
assert.equal(isInfrastructureError("just a string error"), null);
|
|
220
|
+
assert.equal(isInfrastructureError(null), null);
|
|
221
|
+
assert.equal(isInfrastructureError(undefined), null);
|
|
222
|
+
assert.equal(isInfrastructureError(42), null);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
test("all INFRA_ERROR_CODES are covered", () => {
|
|
226
|
+
const expectedCodes = [
|
|
227
|
+
"ENOSPC", "ENOMEM", "EROFS", "EDQUOT", "EMFILE",
|
|
228
|
+
"ENFILE", "EAGAIN", "ECONNREFUSED", "ENOTFOUND", "ENETUNREACH",
|
|
229
|
+
];
|
|
230
|
+
for (const code of expectedCodes) {
|
|
231
|
+
assert.ok(INFRA_ERROR_CODES.has(code), `${code} should be in INFRA_ERROR_CODES`);
|
|
232
|
+
}
|
|
233
|
+
assert.equal(INFRA_ERROR_CODES.size, expectedCodes.length, "no unexpected codes");
|
|
234
|
+
});
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
238
|
+
// SECTION 2: Stuck Detection
|
|
239
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
240
|
+
|
|
241
|
+
describe("stuck detection", () => {
|
|
242
|
+
test("Rule 1: same error repeated consecutively → stuck", () => {
|
|
243
|
+
const window: WindowEntry[] = [
|
|
244
|
+
{ key: "M001/S01/T01", error: "Provider returned 500" },
|
|
245
|
+
{ key: "M001/S01/T01", error: "Provider returned 500" },
|
|
246
|
+
];
|
|
247
|
+
const result = detectStuck(window);
|
|
248
|
+
assert.ok(result?.stuck, "same error twice should be stuck");
|
|
249
|
+
assert.ok(result?.reason.includes("Same error repeated"), "reason should mention error");
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
test("Rule 1: different errors are NOT stuck", () => {
|
|
253
|
+
const window: WindowEntry[] = [
|
|
254
|
+
{ key: "M001/S01/T01", error: "Provider returned 500" },
|
|
255
|
+
{ key: "M001/S01/T01", error: "Provider returned 429" },
|
|
256
|
+
];
|
|
257
|
+
const result = detectStuck(window);
|
|
258
|
+
// Different errors → not stuck by Rule 1 (but might be by Rule 2 with more entries)
|
|
259
|
+
assert.equal(result, null, "different errors should not trigger Rule 1");
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
test("Rule 2: same unit 3 consecutive times → stuck", () => {
|
|
263
|
+
const window: WindowEntry[] = [
|
|
264
|
+
{ key: "M001/S01/T01" },
|
|
265
|
+
{ key: "M001/S01/T01" },
|
|
266
|
+
{ key: "M001/S01/T01" },
|
|
267
|
+
];
|
|
268
|
+
const result = detectStuck(window);
|
|
269
|
+
assert.ok(result?.stuck, "same unit 3x should be stuck");
|
|
270
|
+
assert.ok(result?.reason.includes("3 consecutive times"), "reason should mention 3x");
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
test("Rule 2: 2 consecutive same units is NOT stuck", () => {
|
|
274
|
+
const window: WindowEntry[] = [
|
|
275
|
+
{ key: "M001/S01/T01" },
|
|
276
|
+
{ key: "M001/S01/T01" },
|
|
277
|
+
];
|
|
278
|
+
const result = detectStuck(window);
|
|
279
|
+
assert.equal(result, null, "2x same unit is not stuck");
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
test("Rule 3: oscillation A→B→A→B → stuck", () => {
|
|
283
|
+
const window: WindowEntry[] = [
|
|
284
|
+
{ key: "M001/S01/T01" },
|
|
285
|
+
{ key: "M001/S01/T02" },
|
|
286
|
+
{ key: "M001/S01/T01" },
|
|
287
|
+
{ key: "M001/S01/T02" },
|
|
288
|
+
];
|
|
289
|
+
const result = detectStuck(window);
|
|
290
|
+
assert.ok(result?.stuck, "A→B→A→B should be stuck");
|
|
291
|
+
assert.ok(result?.reason.includes("Oscillation"), "reason should mention oscillation");
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
test("Rule 3: A→B→C→D is NOT oscillation", () => {
|
|
295
|
+
const window: WindowEntry[] = [
|
|
296
|
+
{ key: "A" },
|
|
297
|
+
{ key: "B" },
|
|
298
|
+
{ key: "C" },
|
|
299
|
+
{ key: "D" },
|
|
300
|
+
];
|
|
301
|
+
assert.equal(detectStuck(window), null, "sequential progress is not stuck");
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
test("empty window returns null", () => {
|
|
305
|
+
assert.equal(detectStuck([]), null);
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
test("single entry returns null", () => {
|
|
309
|
+
assert.equal(detectStuck([{ key: "A" }]), null);
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
test("Rule 1 takes precedence over Rule 2 when both apply", () => {
|
|
313
|
+
const window: WindowEntry[] = [
|
|
314
|
+
{ key: "A", error: "fail" },
|
|
315
|
+
{ key: "A", error: "fail" },
|
|
316
|
+
{ key: "A", error: "fail" },
|
|
317
|
+
];
|
|
318
|
+
const result = detectStuck(window);
|
|
319
|
+
assert.ok(result?.stuck);
|
|
320
|
+
// Rule 1 fires first (same error at indices 1,2)
|
|
321
|
+
assert.ok(result?.reason.includes("Same error repeated"));
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
test("errors on different keys are not stuck by Rule 1", () => {
|
|
325
|
+
const window: WindowEntry[] = [
|
|
326
|
+
{ key: "A", error: "fail" },
|
|
327
|
+
{ key: "B", error: "fail" },
|
|
328
|
+
];
|
|
329
|
+
// Same error but different keys — Rule 1 compares errors regardless of key
|
|
330
|
+
const result = detectStuck(window);
|
|
331
|
+
// Rule 1 says "same error repeated consecutively" — it checks error strings
|
|
332
|
+
assert.ok(result?.stuck, "same error string on different keys still triggers Rule 1");
|
|
333
|
+
});
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
337
|
+
// SECTION 3: Session Management
|
|
338
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
339
|
+
|
|
340
|
+
describe("session management", () => {
|
|
341
|
+
test("AutoSession reset() clears all mutable state", () => {
|
|
342
|
+
const s = new AutoSession();
|
|
343
|
+
s.active = true;
|
|
344
|
+
s.paused = true;
|
|
345
|
+
s.basePath = "/tmp/test";
|
|
346
|
+
s.currentUnit = { type: "execute-task", id: "M001/S01/T01", startedAt: Date.now() };
|
|
347
|
+
s.currentMilestoneId = "M001";
|
|
348
|
+
s.unitDispatchCount.set("M001/S01/T01", 3);
|
|
349
|
+
s.unitLifetimeDispatches.set("M001/S01/T01", 5);
|
|
350
|
+
s.unitRecoveryCount.set("M001/S01/T01", 1);
|
|
351
|
+
|
|
352
|
+
s.reset();
|
|
353
|
+
|
|
354
|
+
assert.equal(s.active, false, "active should be false after reset");
|
|
355
|
+
assert.equal(s.paused, false, "paused should be false after reset");
|
|
356
|
+
assert.equal(s.currentUnit, null, "currentUnit should be null after reset");
|
|
357
|
+
assert.equal(s.currentMilestoneId, null, "currentMilestoneId should be null");
|
|
358
|
+
assert.equal(s.unitDispatchCount.size, 0, "dispatch counts cleared");
|
|
359
|
+
assert.equal(s.unitLifetimeDispatches.size, 0, "lifetime dispatches cleared");
|
|
360
|
+
assert.equal(s.unitRecoveryCount.size, 0, "recovery counts cleared");
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
test("NEW_SESSION_TIMEOUT_MS is 30 seconds", () => {
|
|
364
|
+
assert.equal(NEW_SESSION_TIMEOUT_MS, 30_000, "session timeout should be 30s");
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
test("MAX_UNIT_DISPATCHES limits retries for a single unit", () => {
|
|
368
|
+
assert.equal(MAX_UNIT_DISPATCHES, 3, "max unit dispatches should be 3");
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
test("MAX_LIFETIME_DISPATCHES is the absolute limit per unit", () => {
|
|
372
|
+
assert.equal(MAX_LIFETIME_DISPATCHES, 6, "max lifetime dispatches should be 6");
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
test("STUB_RECOVERY_THRESHOLD triggers recovery after N stub completions", () => {
|
|
376
|
+
assert.equal(STUB_RECOVERY_THRESHOLD, 2, "stub recovery threshold should be 2");
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
test("MAX_LOOP_ITERATIONS prevents runaway loops", () => {
|
|
380
|
+
assert.equal(MAX_LOOP_ITERATIONS, 500, "max iterations should be 500");
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
test("AutoSession dispatch counter tracks per-unit dispatches", () => {
|
|
384
|
+
const s = new AutoSession();
|
|
385
|
+
const unitId = "M001/S01/T01";
|
|
386
|
+
|
|
387
|
+
assert.equal(s.unitDispatchCount.get(unitId), undefined);
|
|
388
|
+
|
|
389
|
+
s.unitDispatchCount.set(unitId, 1);
|
|
390
|
+
assert.equal(s.unitDispatchCount.get(unitId), 1);
|
|
391
|
+
|
|
392
|
+
s.unitDispatchCount.set(unitId, 2);
|
|
393
|
+
assert.equal(s.unitDispatchCount.get(unitId), 2);
|
|
394
|
+
|
|
395
|
+
// Exceeding MAX_UNIT_DISPATCHES
|
|
396
|
+
s.unitDispatchCount.set(unitId, MAX_UNIT_DISPATCHES + 1);
|
|
397
|
+
assert.ok(
|
|
398
|
+
s.unitDispatchCount.get(unitId)! > MAX_UNIT_DISPATCHES,
|
|
399
|
+
"should track count beyond max for detection",
|
|
400
|
+
);
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
test("AutoSession toJSON() provides diagnostic snapshot", () => {
|
|
404
|
+
const s = new AutoSession();
|
|
405
|
+
s.active = true;
|
|
406
|
+
s.basePath = "/tmp/test";
|
|
407
|
+
s.currentUnit = { type: "execute-task", id: "M001/S01/T01", startedAt: Date.now() };
|
|
408
|
+
|
|
409
|
+
const json = s.toJSON();
|
|
410
|
+
assert.ok(json, "toJSON should return a value");
|
|
411
|
+
assert.equal(typeof json, "object", "toJSON should return an object");
|
|
412
|
+
});
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
416
|
+
// SECTION 4: Session Lock Validation
|
|
417
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
418
|
+
|
|
419
|
+
describe("session lock validation", () => {
|
|
420
|
+
test("SessionLockStatus: valid lock", () => {
|
|
421
|
+
const status: SessionLockStatus = { valid: true };
|
|
422
|
+
assert.equal(status.valid, true);
|
|
423
|
+
assert.equal(status.failureReason, undefined);
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
test("SessionLockStatus: compromised lock (sleep/wake cycle)", () => {
|
|
427
|
+
const status: SessionLockStatus = {
|
|
428
|
+
valid: false,
|
|
429
|
+
failureReason: "compromised",
|
|
430
|
+
};
|
|
431
|
+
assert.equal(status.valid, false);
|
|
432
|
+
assert.equal(status.failureReason, "compromised");
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
test("SessionLockStatus: pid-mismatch (another process took over)", () => {
|
|
436
|
+
const status: SessionLockStatus = {
|
|
437
|
+
valid: false,
|
|
438
|
+
failureReason: "pid-mismatch",
|
|
439
|
+
existingPid: 12345,
|
|
440
|
+
expectedPid: 67890,
|
|
441
|
+
};
|
|
442
|
+
assert.equal(status.valid, false);
|
|
443
|
+
assert.equal(status.failureReason, "pid-mismatch");
|
|
444
|
+
assert.notEqual(status.existingPid, status.expectedPid);
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
test("SessionLockStatus: missing-metadata", () => {
|
|
448
|
+
const status: SessionLockStatus = {
|
|
449
|
+
valid: false,
|
|
450
|
+
failureReason: "missing-metadata",
|
|
451
|
+
};
|
|
452
|
+
assert.equal(status.valid, false);
|
|
453
|
+
assert.equal(status.failureReason, "missing-metadata");
|
|
454
|
+
});
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
458
|
+
// SECTION 5: MergeConflictError
|
|
459
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
460
|
+
|
|
461
|
+
describe("MergeConflictError handling", () => {
|
|
462
|
+
test("MergeConflictError has correct properties", () => {
|
|
463
|
+
const err = new MergeConflictError(
|
|
464
|
+
["src/feature.ts", "src/utils.ts"],
|
|
465
|
+
"squash",
|
|
466
|
+
"gsd/auto/M001",
|
|
467
|
+
"main",
|
|
468
|
+
);
|
|
469
|
+
|
|
470
|
+
assert.ok(err instanceof Error, "should be an Error");
|
|
471
|
+
assert.ok(err instanceof MergeConflictError, "should be a MergeConflictError");
|
|
472
|
+
assert.deepEqual(err.conflictedFiles, ["src/feature.ts", "src/utils.ts"]);
|
|
473
|
+
assert.equal(err.strategy, "squash");
|
|
474
|
+
assert.equal(err.branch, "gsd/auto/M001");
|
|
475
|
+
assert.equal(err.mainBranch, "main");
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
test("MergeConflictError with merge strategy", () => {
|
|
479
|
+
const err = new MergeConflictError(
|
|
480
|
+
["package.json"],
|
|
481
|
+
"merge",
|
|
482
|
+
"feat/new-feature",
|
|
483
|
+
"main",
|
|
484
|
+
);
|
|
485
|
+
assert.equal(err.strategy, "merge");
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
test("MergeConflictError with empty conflict list", () => {
|
|
489
|
+
const err = new MergeConflictError([], "squash", "branch", "main");
|
|
490
|
+
assert.deepEqual(err.conflictedFiles, []);
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
test("MergeConflictError is distinguishable from generic errors", () => {
|
|
494
|
+
const mergeErr = new MergeConflictError(["file.ts"], "squash", "b", "m");
|
|
495
|
+
const genericErr = new Error("merge failed");
|
|
496
|
+
|
|
497
|
+
assert.ok(mergeErr instanceof MergeConflictError);
|
|
498
|
+
assert.ok(!(genericErr instanceof MergeConflictError));
|
|
499
|
+
|
|
500
|
+
// This is the exact pattern used in phases.ts catch blocks
|
|
501
|
+
if (mergeErr instanceof MergeConflictError) {
|
|
502
|
+
assert.ok(true, "instanceof check works for catch blocks");
|
|
503
|
+
}
|
|
504
|
+
});
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
508
|
+
// SECTION 6: Filesystem Race Conditions
|
|
509
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
510
|
+
|
|
511
|
+
describe("filesystem race conditions", () => {
|
|
512
|
+
let base: string;
|
|
513
|
+
|
|
514
|
+
afterEach(() => {
|
|
515
|
+
try { closeDatabase(); } catch { /* may not be open */ }
|
|
516
|
+
if (base) rmSync(base, { recursive: true, force: true });
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
test("ROADMAP deleted during derive cycle → graceful degradation", async () => {
|
|
520
|
+
base = createMinimalFixture();
|
|
521
|
+
openDatabase(join(base, ".gsd", "gsd.db"));
|
|
522
|
+
insertMilestone({ id: "M001", title: "Active", status: "active" });
|
|
523
|
+
insertSlice({ id: "S01", milestoneId: "M001", title: "Feature", status: "in_progress" });
|
|
524
|
+
insertTask({ id: "T01", sliceId: "S01", milestoneId: "M001", status: "pending" });
|
|
525
|
+
|
|
526
|
+
invalidateAllCaches();
|
|
527
|
+
const state1 = await deriveStateFromDb(base);
|
|
528
|
+
assert.equal(state1.phase, "executing");
|
|
529
|
+
|
|
530
|
+
// Delete ROADMAP mid-flow
|
|
531
|
+
const roadmapPath = join(base, ".gsd", "milestones", "M001", "M001-ROADMAP.md");
|
|
532
|
+
unlinkSync(roadmapPath);
|
|
533
|
+
|
|
534
|
+
invalidateAllCaches();
|
|
535
|
+
// DB still has the slice/task data, so derivation should still work
|
|
536
|
+
const state2 = await deriveStateFromDb(base);
|
|
537
|
+
assert.ok(state2.phase, "should produce a valid phase even after ROADMAP deletion");
|
|
538
|
+
});
|
|
539
|
+
|
|
540
|
+
test("CONTEXT deleted during derive → falls back gracefully", async () => {
|
|
541
|
+
base = createMinimalFixture();
|
|
542
|
+
openDatabase(join(base, ".gsd", "gsd.db"));
|
|
543
|
+
insertMilestone({ id: "M001", title: "Active", status: "active" });
|
|
544
|
+
|
|
545
|
+
const contextPath = join(base, ".gsd", "milestones", "M001", "M001-CONTEXT.md");
|
|
546
|
+
unlinkSync(contextPath);
|
|
547
|
+
|
|
548
|
+
invalidateAllCaches();
|
|
549
|
+
const state = await deriveStateFromDb(base);
|
|
550
|
+
// Without CONTEXT, title fallback should still work
|
|
551
|
+
assert.ok(state.activeMilestone, "should still have an active milestone from DB");
|
|
552
|
+
});
|
|
553
|
+
|
|
554
|
+
test("entire slice directory deleted → derive produces valid state", async () => {
|
|
555
|
+
base = createMinimalFixture();
|
|
556
|
+
openDatabase(join(base, ".gsd", "gsd.db"));
|
|
557
|
+
insertMilestone({ id: "M001", title: "Active", status: "active" });
|
|
558
|
+
insertSlice({ id: "S01", milestoneId: "M001", title: "Feature", status: "in_progress" });
|
|
559
|
+
insertTask({ id: "T01", sliceId: "S01", milestoneId: "M001", status: "pending" });
|
|
560
|
+
|
|
561
|
+
// Delete entire S01 directory
|
|
562
|
+
rmSync(join(base, ".gsd", "milestones", "M001", "slices", "S01"), { recursive: true, force: true });
|
|
563
|
+
|
|
564
|
+
invalidateAllCaches();
|
|
565
|
+
const state = await deriveStateFromDb(base);
|
|
566
|
+
// DB still has slice/task rows, disk is gone — state should degrade gracefully
|
|
567
|
+
assert.ok(state.phase, "should produce valid phase after slice dir deletion");
|
|
568
|
+
});
|
|
569
|
+
|
|
570
|
+
test("task PLAN file deleted between dispatch and execution → recovery dispatch", async () => {
|
|
571
|
+
base = createMinimalFixture();
|
|
572
|
+
openDatabase(join(base, ".gsd", "gsd.db"));
|
|
573
|
+
insertMilestone({ id: "M001", title: "Active", status: "active" });
|
|
574
|
+
insertSlice({ id: "S01", milestoneId: "M001", title: "Feature", status: "in_progress" });
|
|
575
|
+
insertTask({ id: "T01", sliceId: "S01", milestoneId: "M001", status: "pending" });
|
|
576
|
+
|
|
577
|
+
// Delete T01-PLAN.md
|
|
578
|
+
const planPath = join(base, ".gsd", "milestones", "M001", "slices", "S01", "tasks", "T01-PLAN.md");
|
|
579
|
+
unlinkSync(planPath);
|
|
580
|
+
|
|
581
|
+
// Also write milestone RESEARCH so research-slice rule doesn't fire first
|
|
582
|
+
writeFileSync(
|
|
583
|
+
join(base, ".gsd", "milestones", "M001", "M001-RESEARCH.md"),
|
|
584
|
+
"# Research\nDone.\n",
|
|
585
|
+
);
|
|
586
|
+
// Write slice RESEARCH so research-slice rule for non-S01 doesn't fire
|
|
587
|
+
writeFileSync(
|
|
588
|
+
join(base, ".gsd", "milestones", "M001", "slices", "S01", "S01-RESEARCH.md"),
|
|
589
|
+
"# S01 Research\nDone.\n",
|
|
590
|
+
);
|
|
591
|
+
|
|
592
|
+
const { resolveDispatch } = await import("../../auto-dispatch.ts");
|
|
593
|
+
|
|
594
|
+
invalidateAllCaches();
|
|
595
|
+
const state = await deriveStateFromDb(base);
|
|
596
|
+
|
|
597
|
+
const ctx = {
|
|
598
|
+
basePath: base,
|
|
599
|
+
mid: "M001",
|
|
600
|
+
midTitle: "Active",
|
|
601
|
+
state,
|
|
602
|
+
prefs: undefined,
|
|
603
|
+
};
|
|
604
|
+
|
|
605
|
+
const result = await resolveDispatch(ctx);
|
|
606
|
+
// The "executing → execute-task (recover missing task plan)" rule should
|
|
607
|
+
// detect missing T01-PLAN.md and dispatch plan-slice instead of execute-task
|
|
608
|
+
if (result.action === "dispatch") {
|
|
609
|
+
assert.equal(
|
|
610
|
+
(result as any).unitType,
|
|
611
|
+
"plan-slice",
|
|
612
|
+
"missing task plan should trigger plan-slice recovery",
|
|
613
|
+
);
|
|
614
|
+
}
|
|
615
|
+
// It's also valid if the state changed due to cache invalidation
|
|
616
|
+
assert.ok(result.action, "should produce a valid dispatch action");
|
|
617
|
+
});
|
|
618
|
+
|
|
619
|
+
test("worktree directory disappearance: isGhostMilestone still works", () => {
|
|
620
|
+
const tmpBase = makeTempDir();
|
|
621
|
+
const mDir = join(tmpBase, ".gsd", "milestones", "M001");
|
|
622
|
+
mkdirSync(mDir, { recursive: true });
|
|
623
|
+
|
|
624
|
+
// Create worktree dir then delete it (simulates external deletion)
|
|
625
|
+
const wtDir = join(tmpBase, ".gsd", "worktrees", "M001");
|
|
626
|
+
mkdirSync(wtDir, { recursive: true });
|
|
627
|
+
|
|
628
|
+
// With worktree → not a ghost
|
|
629
|
+
assert.equal(isGhostMilestone(tmpBase, "M001"), false, "with worktree: not ghost");
|
|
630
|
+
|
|
631
|
+
// Delete worktree (simulates external process removing it)
|
|
632
|
+
rmSync(wtDir, { recursive: true, force: true });
|
|
633
|
+
assert.ok(!existsSync(wtDir), "worktree should be gone");
|
|
634
|
+
|
|
635
|
+
// Without worktree AND without DB → ghost (existsSync handles missing dir)
|
|
636
|
+
assert.equal(isGhostMilestone(tmpBase, "M001"), true, "without worktree: ghost");
|
|
637
|
+
|
|
638
|
+
rmSync(tmpBase, { recursive: true, force: true });
|
|
639
|
+
});
|
|
640
|
+
});
|
|
641
|
+
|
|
642
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
643
|
+
// SECTION 7: Graduated Error Recovery in Auto-Loop
|
|
644
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
645
|
+
|
|
646
|
+
describe("graduated error recovery logic", () => {
|
|
647
|
+
test("infrastructure error codes are exhaustive and non-overlapping", () => {
|
|
648
|
+
// Verify the set contains only OS-level error codes
|
|
649
|
+
for (const code of INFRA_ERROR_CODES) {
|
|
650
|
+
assert.ok(code.startsWith("E"), `infra code ${code} should start with E`);
|
|
651
|
+
assert.ok(code.length >= 4, `infra code ${code} should be at least 4 chars`);
|
|
652
|
+
}
|
|
653
|
+
});
|
|
654
|
+
|
|
655
|
+
test("SQLite corruption detection via message scan (no code property)", () => {
|
|
656
|
+
// Simulates sql.js or better-sqlite3 error without proper Node code
|
|
657
|
+
const err = new Error("SqliteError: database disk image is malformed");
|
|
658
|
+
const result = isInfrastructureError(err);
|
|
659
|
+
assert.equal(result, "SQLITE_CORRUPT");
|
|
660
|
+
});
|
|
661
|
+
|
|
662
|
+
test("provider rate limit is NOT an infrastructure error (retryable)", () => {
|
|
663
|
+
const err = new Error("rate_limit_exceeded: Too many requests");
|
|
664
|
+
assert.equal(isInfrastructureError(err), null);
|
|
665
|
+
});
|
|
666
|
+
|
|
667
|
+
test("overloaded_error is NOT an infrastructure error (retryable)", () => {
|
|
668
|
+
const err = new Error("overloaded_error: The model is currently overloaded");
|
|
669
|
+
assert.equal(isInfrastructureError(err), null);
|
|
670
|
+
});
|
|
671
|
+
|
|
672
|
+
test("authentication error is NOT an infrastructure error", () => {
|
|
673
|
+
const err = new Error("authentication_error: Invalid API key");
|
|
674
|
+
assert.equal(isInfrastructureError(err), null);
|
|
675
|
+
});
|
|
676
|
+
|
|
677
|
+
test("permission denied (EACCES) is NOT in infrastructure set", () => {
|
|
678
|
+
// EACCES is intentionally not in the set — it may indicate a fixable
|
|
679
|
+
// permissions issue rather than a hardware-level failure
|
|
680
|
+
const err = Object.assign(new Error("permission denied"), { code: "EACCES" });
|
|
681
|
+
assert.equal(isInfrastructureError(err), null);
|
|
682
|
+
});
|
|
683
|
+
});
|
|
684
|
+
|
|
685
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
686
|
+
// SECTION 8: Multi-Iteration Stuck Scenarios
|
|
687
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
688
|
+
|
|
689
|
+
describe("multi-iteration stuck scenarios", () => {
|
|
690
|
+
test("progressive window: normal → stuck after 3rd same unit", () => {
|
|
691
|
+
const window: WindowEntry[] = [];
|
|
692
|
+
|
|
693
|
+
window.push({ key: "A" });
|
|
694
|
+
assert.equal(detectStuck(window), null, "1 entry: not stuck");
|
|
695
|
+
|
|
696
|
+
window.push({ key: "A" });
|
|
697
|
+
assert.equal(detectStuck(window), null, "2 entries: not stuck yet");
|
|
698
|
+
|
|
699
|
+
window.push({ key: "A" });
|
|
700
|
+
assert.ok(detectStuck(window)?.stuck, "3 entries: stuck");
|
|
701
|
+
});
|
|
702
|
+
|
|
703
|
+
test("progressive window: oscillation builds up", () => {
|
|
704
|
+
const window: WindowEntry[] = [];
|
|
705
|
+
|
|
706
|
+
window.push({ key: "A" });
|
|
707
|
+
assert.equal(detectStuck(window), null);
|
|
708
|
+
|
|
709
|
+
window.push({ key: "B" });
|
|
710
|
+
assert.equal(detectStuck(window), null);
|
|
711
|
+
|
|
712
|
+
window.push({ key: "A" });
|
|
713
|
+
assert.equal(detectStuck(window), null, "3 entries A→B→A: not stuck yet");
|
|
714
|
+
|
|
715
|
+
window.push({ key: "B" });
|
|
716
|
+
assert.ok(detectStuck(window)?.stuck, "4 entries A→B→A→B: stuck");
|
|
717
|
+
});
|
|
718
|
+
|
|
719
|
+
test("mixed progress then stuck: A→B→C→C→C → stuck on C", () => {
|
|
720
|
+
const window: WindowEntry[] = [
|
|
721
|
+
{ key: "A" },
|
|
722
|
+
{ key: "B" },
|
|
723
|
+
{ key: "C" },
|
|
724
|
+
{ key: "C" },
|
|
725
|
+
{ key: "C" },
|
|
726
|
+
];
|
|
727
|
+
const result = detectStuck(window);
|
|
728
|
+
assert.ok(result?.stuck, "3 consecutive C: stuck");
|
|
729
|
+
assert.ok(result?.reason.includes("C"), "reason should mention stuck unit");
|
|
730
|
+
});
|
|
731
|
+
|
|
732
|
+
test("error in middle of window does not false-positive", () => {
|
|
733
|
+
const window: WindowEntry[] = [
|
|
734
|
+
{ key: "A" },
|
|
735
|
+
{ key: "B", error: "transient failure" },
|
|
736
|
+
{ key: "C" },
|
|
737
|
+
{ key: "D" },
|
|
738
|
+
];
|
|
739
|
+
assert.equal(detectStuck(window), null, "single error should not trigger stuck");
|
|
740
|
+
});
|
|
741
|
+
|
|
742
|
+
test("consecutive errors on different keys still triggers Rule 1", () => {
|
|
743
|
+
const window: WindowEntry[] = [
|
|
744
|
+
{ key: "A", error: "Provider returned 503 Service Unavailable" },
|
|
745
|
+
{ key: "B", error: "Provider returned 503 Service Unavailable" },
|
|
746
|
+
];
|
|
747
|
+
const result = detectStuck(window);
|
|
748
|
+
assert.ok(result?.stuck, "same error on different keys: stuck by Rule 1");
|
|
749
|
+
});
|
|
750
|
+
});
|
|
751
|
+
|
|
752
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
753
|
+
// SECTION 9: State Consistency Under Concurrent DB Operations
|
|
754
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
755
|
+
|
|
756
|
+
describe("state consistency under DB mutations", () => {
|
|
757
|
+
let base: string;
|
|
758
|
+
|
|
759
|
+
afterEach(() => {
|
|
760
|
+
try { closeDatabase(); } catch { /* may not be open */ }
|
|
761
|
+
if (base) rmSync(base, { recursive: true, force: true });
|
|
762
|
+
});
|
|
763
|
+
|
|
764
|
+
test("rapid DB mutations produce consistent deriveStateFromDb results", async () => {
|
|
765
|
+
base = createMinimalFixture();
|
|
766
|
+
openDatabase(join(base, ".gsd", "gsd.db"));
|
|
767
|
+
insertMilestone({ id: "M001", title: "Active", status: "active" });
|
|
768
|
+
insertSlice({ id: "S01", milestoneId: "M001", title: "Feature", status: "in_progress" });
|
|
769
|
+
insertTask({ id: "T01", sliceId: "S01", milestoneId: "M001", status: "pending" });
|
|
770
|
+
|
|
771
|
+
// Rapid mutations with invalidation between each
|
|
772
|
+
invalidateAllCaches();
|
|
773
|
+
const states: string[] = [];
|
|
774
|
+
|
|
775
|
+
const s1 = await deriveStateFromDb(base);
|
|
776
|
+
states.push(s1.phase);
|
|
777
|
+
|
|
778
|
+
// pending → complete
|
|
779
|
+
const { updateTaskStatus } = await import("../../gsd-db.ts");
|
|
780
|
+
updateTaskStatus("M001", "S01", "T01", "complete", new Date().toISOString());
|
|
781
|
+
invalidateAllCaches();
|
|
782
|
+
const s2 = await deriveStateFromDb(base);
|
|
783
|
+
states.push(s2.phase);
|
|
784
|
+
|
|
785
|
+
// S01 should now be summarizing (all tasks done)
|
|
786
|
+
assert.equal(states[0], "executing", "initially executing");
|
|
787
|
+
assert.equal(states[1], "summarizing", "after task complete → summarizing");
|
|
788
|
+
|
|
789
|
+
// No state should be undefined or null
|
|
790
|
+
for (const phase of states) {
|
|
791
|
+
assert.ok(phase, "every state should have a valid phase");
|
|
792
|
+
}
|
|
793
|
+
});
|
|
794
|
+
|
|
795
|
+
test("DB milestone status change is reflected after cache invalidation", async () => {
|
|
796
|
+
base = createMinimalFixture();
|
|
797
|
+
openDatabase(join(base, ".gsd", "gsd.db"));
|
|
798
|
+
insertMilestone({ id: "M001", title: "Active", status: "active" });
|
|
799
|
+
insertSlice({ id: "S01", milestoneId: "M001", title: "Feature", status: "complete" });
|
|
800
|
+
insertTask({ id: "T01", sliceId: "S01", milestoneId: "M001", status: "complete" });
|
|
801
|
+
|
|
802
|
+
invalidateAllCaches();
|
|
803
|
+
const s1 = await deriveStateFromDb(base);
|
|
804
|
+
assert.equal(s1.phase, "validating-milestone");
|
|
805
|
+
|
|
806
|
+
// Mark milestone complete directly
|
|
807
|
+
const { updateMilestoneStatus } = await import("../../gsd-db.ts");
|
|
808
|
+
updateMilestoneStatus("M001", "complete", new Date().toISOString());
|
|
809
|
+
// Write SUMMARY to make it truly complete
|
|
810
|
+
writeFileSync(
|
|
811
|
+
join(base, ".gsd", "milestones", "M001", "M001-SUMMARY.md"),
|
|
812
|
+
"# M001 Summary\nDone.\n",
|
|
813
|
+
);
|
|
814
|
+
|
|
815
|
+
invalidateAllCaches();
|
|
816
|
+
const s2 = await deriveStateFromDb(base);
|
|
817
|
+
// With only M001 and it's complete, should be "complete"
|
|
818
|
+
assert.equal(s2.phase, "complete", "after milestone completion should be complete");
|
|
819
|
+
});
|
|
820
|
+
|
|
821
|
+
test("deriveState is idempotent: same inputs produce same outputs", async () => {
|
|
822
|
+
base = createMinimalFixture();
|
|
823
|
+
openDatabase(join(base, ".gsd", "gsd.db"));
|
|
824
|
+
insertMilestone({ id: "M001", title: "Active", status: "active" });
|
|
825
|
+
insertSlice({ id: "S01", milestoneId: "M001", title: "Feature", status: "in_progress" });
|
|
826
|
+
insertTask({ id: "T01", sliceId: "S01", milestoneId: "M001", status: "pending" });
|
|
827
|
+
|
|
828
|
+
// Call deriveState 5 times with cache invalidation between each
|
|
829
|
+
const results: string[] = [];
|
|
830
|
+
for (let i = 0; i < 5; i++) {
|
|
831
|
+
invalidateAllCaches();
|
|
832
|
+
const state = await deriveStateFromDb(base);
|
|
833
|
+
results.push(state.phase);
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
// All should be identical
|
|
837
|
+
const unique = new Set(results);
|
|
838
|
+
assert.equal(unique.size, 1, `expected all identical, got: ${[...unique].join(", ")}`);
|
|
839
|
+
assert.equal(results[0], "executing");
|
|
840
|
+
});
|
|
841
|
+
});
|