gsd-pi 2.32.0-dev.f3d5d53 → 2.33.0-dev.69bff0f
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 +13 -18
- package/dist/resources/extensions/gsd/auto-dashboard.ts +3 -1
- package/dist/resources/extensions/gsd/auto-dispatch.ts +40 -12
- package/dist/resources/extensions/gsd/auto-idempotency.ts +3 -2
- package/dist/resources/extensions/gsd/auto-observability.ts +2 -4
- package/dist/resources/extensions/gsd/auto-post-unit.ts +5 -5
- package/dist/resources/extensions/gsd/auto-recovery.ts +8 -22
- package/dist/resources/extensions/gsd/auto-start.ts +2 -1
- package/dist/resources/extensions/gsd/auto-stuck-detection.ts +3 -2
- package/dist/resources/extensions/gsd/auto-supervisor.ts +10 -5
- package/dist/resources/extensions/gsd/auto-timeout-recovery.ts +2 -1
- package/dist/resources/extensions/gsd/auto-verification.ts +4 -5
- package/dist/resources/extensions/gsd/auto-worktree.ts +135 -1
- package/dist/resources/extensions/gsd/auto.ts +89 -164
- package/dist/resources/extensions/gsd/commands.ts +14 -2
- package/dist/resources/extensions/gsd/complexity-classifier.ts +5 -7
- package/dist/resources/extensions/gsd/dispatch-guard.ts +2 -1
- package/dist/resources/extensions/gsd/metrics.ts +3 -3
- package/dist/resources/extensions/gsd/post-unit-hooks.ts +8 -9
- package/dist/resources/extensions/gsd/session-lock.ts +80 -16
- package/dist/resources/extensions/gsd/tests/all-milestones-complete-merge.test.ts +14 -11
- package/dist/resources/extensions/gsd/tests/auto-dispatch-loop.test.ts +691 -0
- package/dist/resources/extensions/gsd/tests/cache-staleness-regression.test.ts +317 -0
- package/dist/resources/extensions/gsd/tests/loop-regression.test.ts +877 -0
- package/dist/resources/extensions/gsd/tests/roadmap-parse-regression.test.ts +358 -0
- package/dist/resources/extensions/gsd/tests/session-lock-regression.test.ts +216 -0
- package/dist/resources/extensions/gsd/tests/session-lock.test.ts +119 -0
- package/dist/resources/extensions/gsd/tests/worktree-sync-milestones.test.ts +206 -0
- package/dist/resources/extensions/gsd/undo.ts +5 -7
- package/dist/resources/extensions/gsd/unit-id.ts +14 -0
- package/dist/resources/extensions/gsd/unit-runtime.ts +2 -1
- package/package.json +3 -2
- package/packages/pi-coding-agent/package.json +1 -1
- package/pkg/package.json +1 -1
- package/src/resources/extensions/gsd/auto-dashboard.ts +3 -1
- package/src/resources/extensions/gsd/auto-dispatch.ts +40 -12
- package/src/resources/extensions/gsd/auto-idempotency.ts +3 -2
- package/src/resources/extensions/gsd/auto-observability.ts +2 -4
- package/src/resources/extensions/gsd/auto-post-unit.ts +5 -5
- package/src/resources/extensions/gsd/auto-recovery.ts +8 -22
- package/src/resources/extensions/gsd/auto-start.ts +2 -1
- package/src/resources/extensions/gsd/auto-stuck-detection.ts +3 -2
- package/src/resources/extensions/gsd/auto-supervisor.ts +10 -5
- package/src/resources/extensions/gsd/auto-timeout-recovery.ts +2 -1
- package/src/resources/extensions/gsd/auto-verification.ts +4 -5
- package/src/resources/extensions/gsd/auto-worktree.ts +135 -1
- package/src/resources/extensions/gsd/auto.ts +89 -164
- package/src/resources/extensions/gsd/commands.ts +14 -2
- package/src/resources/extensions/gsd/complexity-classifier.ts +5 -7
- package/src/resources/extensions/gsd/dispatch-guard.ts +2 -1
- package/src/resources/extensions/gsd/metrics.ts +3 -3
- package/src/resources/extensions/gsd/post-unit-hooks.ts +8 -9
- package/src/resources/extensions/gsd/session-lock.ts +80 -16
- package/src/resources/extensions/gsd/tests/all-milestones-complete-merge.test.ts +14 -11
- package/src/resources/extensions/gsd/tests/auto-dispatch-loop.test.ts +691 -0
- package/src/resources/extensions/gsd/tests/cache-staleness-regression.test.ts +317 -0
- package/src/resources/extensions/gsd/tests/loop-regression.test.ts +877 -0
- package/src/resources/extensions/gsd/tests/roadmap-parse-regression.test.ts +358 -0
- package/src/resources/extensions/gsd/tests/session-lock-regression.test.ts +216 -0
- package/src/resources/extensions/gsd/tests/session-lock.test.ts +119 -0
- package/src/resources/extensions/gsd/tests/worktree-sync-milestones.test.ts +206 -0
- package/src/resources/extensions/gsd/undo.ts +5 -7
- package/src/resources/extensions/gsd/unit-id.ts +14 -0
- package/src/resources/extensions/gsd/unit-runtime.ts +2 -1
- package/dist/resources/extensions/mcporter/extension-manifest.json +0 -12
- package/src/resources/extensions/mcporter/extension-manifest.json +0 -12
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* roadmap-parse-regression.test.ts — Regression tests for roadmap parsing.
|
|
3
|
+
*
|
|
4
|
+
* Exercises parseRoadmapSlices() and the prose fallback parser against
|
|
5
|
+
* every known LLM-generated roadmap variant that has caused production bugs.
|
|
6
|
+
*
|
|
7
|
+
* Regression coverage for:
|
|
8
|
+
* #807 Prose slice headers not parsed → "No slice eligible" block
|
|
9
|
+
* #1248 Prose header regex only matched H2 with colon separator
|
|
10
|
+
* #1243 Same root cause as #1248
|
|
11
|
+
*
|
|
12
|
+
* Also covers dependency expansion (range syntax) and edge cases.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { parseRoadmapSlices, expandDependencies } from '../roadmap-slices.ts';
|
|
16
|
+
import { createTestContext } from './test-helpers.ts';
|
|
17
|
+
|
|
18
|
+
const { assertEq, assertTrue, report } = createTestContext();
|
|
19
|
+
|
|
20
|
+
async function main(): Promise<void> {
|
|
21
|
+
|
|
22
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
23
|
+
// A. Standard machine-readable format (should always work)
|
|
24
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
25
|
+
|
|
26
|
+
console.log('\n=== A. Standard checkbox format ===');
|
|
27
|
+
|
|
28
|
+
{
|
|
29
|
+
const content = [
|
|
30
|
+
'# M001: Test Project',
|
|
31
|
+
'',
|
|
32
|
+
'## Slices',
|
|
33
|
+
'',
|
|
34
|
+
'- [ ] **S01: First Slice** `risk:low` `depends:[]`',
|
|
35
|
+
'- [ ] **S02: Second Slice** `risk:medium` `depends:[S01]`',
|
|
36
|
+
'- [x] **S03: Third Slice** `risk:high` `depends:[S01,S02]`',
|
|
37
|
+
'',
|
|
38
|
+
'## Boundary Map',
|
|
39
|
+
'',
|
|
40
|
+
].join('\n');
|
|
41
|
+
|
|
42
|
+
const slices = parseRoadmapSlices(content);
|
|
43
|
+
assertEq(slices.length, 3, 'standard format: 3 slices');
|
|
44
|
+
assertEq(slices[0].id, 'S01', 'S01 id');
|
|
45
|
+
assertEq(slices[0].title, 'First Slice', 'S01 title');
|
|
46
|
+
assertEq(slices[0].done, false, 'S01 not done');
|
|
47
|
+
assertEq(slices[0].risk, 'low', 'S01 risk');
|
|
48
|
+
assertEq(slices[0].depends.length, 0, 'S01 no deps');
|
|
49
|
+
|
|
50
|
+
assertEq(slices[1].id, 'S02', 'S02 id');
|
|
51
|
+
assertEq(slices[1].depends.length, 1, 'S02 has 1 dep');
|
|
52
|
+
assertEq(slices[1].depends[0], 'S01', 'S02 depends on S01');
|
|
53
|
+
|
|
54
|
+
assertEq(slices[2].id, 'S03', 'S03 id');
|
|
55
|
+
assertEq(slices[2].done, true, 'S03 is done');
|
|
56
|
+
assertEq(slices[2].risk, 'high', 'S03 risk');
|
|
57
|
+
assertEq(slices[2].depends.length, 2, 'S03 has 2 deps');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
61
|
+
// B. Prose fallback: H2 with colon (the only format the old regex matched)
|
|
62
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
63
|
+
|
|
64
|
+
console.log('\n=== B. Prose fallback: H2 with colon ===');
|
|
65
|
+
|
|
66
|
+
{
|
|
67
|
+
const content = [
|
|
68
|
+
'# M001: Test',
|
|
69
|
+
'',
|
|
70
|
+
'## S01: Setup Foundation',
|
|
71
|
+
'',
|
|
72
|
+
'Do the setup work.',
|
|
73
|
+
'',
|
|
74
|
+
'## S02: Core Features',
|
|
75
|
+
'',
|
|
76
|
+
'Build the features.',
|
|
77
|
+
'',
|
|
78
|
+
].join('\n');
|
|
79
|
+
|
|
80
|
+
const slices = parseRoadmapSlices(content);
|
|
81
|
+
assertEq(slices.length, 2, 'prose H2 colon: 2 slices');
|
|
82
|
+
assertEq(slices[0].id, 'S01', 'S01 id');
|
|
83
|
+
assertEq(slices[0].title, 'Setup Foundation', 'S01 title');
|
|
84
|
+
assertEq(slices[1].id, 'S02', 'S02 id');
|
|
85
|
+
assertEq(slices[1].title, 'Core Features', 'S02 title');
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
89
|
+
// C. Regression #1248: H3 headers (the old regex only matched ##)
|
|
90
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
91
|
+
|
|
92
|
+
console.log('\n=== C. #1248: H3 headers ===');
|
|
93
|
+
|
|
94
|
+
{
|
|
95
|
+
const content = [
|
|
96
|
+
'# M001: Test',
|
|
97
|
+
'',
|
|
98
|
+
'### S01: Setup Foundation',
|
|
99
|
+
'',
|
|
100
|
+
'Do the setup work.',
|
|
101
|
+
'',
|
|
102
|
+
'### S02: Core Features',
|
|
103
|
+
'',
|
|
104
|
+
'Build the features.',
|
|
105
|
+
'',
|
|
106
|
+
].join('\n');
|
|
107
|
+
|
|
108
|
+
const slices = parseRoadmapSlices(content);
|
|
109
|
+
assertEq(slices.length, 2, '#1248 H3: 2 slices parsed');
|
|
110
|
+
assertEq(slices[0].id, 'S01', 'S01 from H3');
|
|
111
|
+
assertEq(slices[1].id, 'S02', 'S02 from H3');
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
115
|
+
// D. Regression #1248: H4 headers
|
|
116
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
117
|
+
|
|
118
|
+
console.log('\n=== D. #1248: H4 headers ===');
|
|
119
|
+
|
|
120
|
+
{
|
|
121
|
+
const content = [
|
|
122
|
+
'# M001: Test',
|
|
123
|
+
'',
|
|
124
|
+
'#### S01: Setup Foundation',
|
|
125
|
+
'',
|
|
126
|
+
'#### S02: Core Features',
|
|
127
|
+
'',
|
|
128
|
+
].join('\n');
|
|
129
|
+
|
|
130
|
+
const slices = parseRoadmapSlices(content);
|
|
131
|
+
assertEq(slices.length, 2, '#1248 H4: 2 slices parsed');
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
135
|
+
// E. Regression #1248: H1 header (unusual but LLMs produce it)
|
|
136
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
137
|
+
|
|
138
|
+
console.log('\n=== E. #1248: H1 headers ===');
|
|
139
|
+
|
|
140
|
+
{
|
|
141
|
+
const content = [
|
|
142
|
+
'# S01: Setup Foundation',
|
|
143
|
+
'',
|
|
144
|
+
'Setup stuff.',
|
|
145
|
+
'',
|
|
146
|
+
'# S02: Core Features',
|
|
147
|
+
'',
|
|
148
|
+
'Build stuff.',
|
|
149
|
+
'',
|
|
150
|
+
].join('\n');
|
|
151
|
+
|
|
152
|
+
const slices = parseRoadmapSlices(content);
|
|
153
|
+
assertEq(slices.length, 2, '#1248 H1: 2 slices parsed');
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
157
|
+
// F. Regression #1248: Bold-wrapped IDs
|
|
158
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
159
|
+
|
|
160
|
+
console.log('\n=== F. #1248: Bold-wrapped ===');
|
|
161
|
+
|
|
162
|
+
{
|
|
163
|
+
const content1 = '## **S01: Setup Foundation**\n\nDo stuff.\n\n## **S02: Features**\n\nMore stuff.\n';
|
|
164
|
+
const slices1 = parseRoadmapSlices(content1);
|
|
165
|
+
assertEq(slices1.length, 2, 'bold-wrapped: 2 slices');
|
|
166
|
+
assertEq(slices1[0].title, 'Setup Foundation', 'bold-wrapped: title extracted without bold');
|
|
167
|
+
|
|
168
|
+
const content2 = '## **S01**: Setup Foundation\n\n## **S02**: Features\n';
|
|
169
|
+
const slices2 = parseRoadmapSlices(content2);
|
|
170
|
+
assertEq(slices2.length, 2, 'bold ID only: 2 slices');
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
174
|
+
// G. Regression #1248: Dot separator
|
|
175
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
176
|
+
|
|
177
|
+
console.log('\n=== G. #1248: Dot separator ===');
|
|
178
|
+
|
|
179
|
+
{
|
|
180
|
+
const content = '## S01. Setup Foundation\n\n## S02. Core Features\n';
|
|
181
|
+
const slices = parseRoadmapSlices(content);
|
|
182
|
+
assertEq(slices.length, 2, 'dot separator: 2 slices');
|
|
183
|
+
assertEq(slices[0].title, 'Setup Foundation', 'dot separator: title');
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
187
|
+
// H. Regression #1248: Em dash separator
|
|
188
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
189
|
+
|
|
190
|
+
console.log('\n=== H. #1248: Em/en dash separators ===');
|
|
191
|
+
|
|
192
|
+
{
|
|
193
|
+
const content = '## S01 — Setup Foundation\n\n## S02 – Core Features\n';
|
|
194
|
+
const slices = parseRoadmapSlices(content);
|
|
195
|
+
assertEq(slices.length, 2, 'em/en dash: 2 slices');
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
199
|
+
// I. Regression #1248: Space-only separator (no punctuation)
|
|
200
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
201
|
+
|
|
202
|
+
console.log('\n=== I. #1248: Space-only separator ===');
|
|
203
|
+
|
|
204
|
+
{
|
|
205
|
+
const content = '## S01 Setup Foundation\n\n## S02 Core Features\n';
|
|
206
|
+
const slices = parseRoadmapSlices(content);
|
|
207
|
+
assertEq(slices.length, 2, 'space-only: 2 slices');
|
|
208
|
+
assertEq(slices[0].title, 'Setup Foundation', 'space-only: title');
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
212
|
+
// J. Regression #1248: Non-zero-padded IDs
|
|
213
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
214
|
+
|
|
215
|
+
console.log('\n=== J. #1248: Non-zero-padded IDs ===');
|
|
216
|
+
|
|
217
|
+
{
|
|
218
|
+
const content = '## S1: Setup\n\n## S2: Features\n';
|
|
219
|
+
const slices = parseRoadmapSlices(content);
|
|
220
|
+
assertEq(slices.length, 2, 'non-padded: 2 slices');
|
|
221
|
+
assertEq(slices[0].id, 'S1', 'non-padded: S1');
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
225
|
+
// K. Regression #1248: "Slice" prefix
|
|
226
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
227
|
+
|
|
228
|
+
console.log('\n=== K. #1248: "Slice" prefix ===');
|
|
229
|
+
|
|
230
|
+
{
|
|
231
|
+
const content = '## Slice S01: Setup Foundation\n\n## Slice S02: Core Features\n';
|
|
232
|
+
const slices = parseRoadmapSlices(content);
|
|
233
|
+
assertEq(slices.length, 2, 'Slice prefix: 2 slices');
|
|
234
|
+
assertEq(slices[0].id, 'S01', 'Slice prefix: S01');
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
238
|
+
// L. Prose with "Depends on:" line
|
|
239
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
240
|
+
|
|
241
|
+
console.log('\n=== L. Prose with Depends on: ===');
|
|
242
|
+
|
|
243
|
+
{
|
|
244
|
+
const content = [
|
|
245
|
+
'## S01: Foundation',
|
|
246
|
+
'',
|
|
247
|
+
'Build the base.',
|
|
248
|
+
'',
|
|
249
|
+
'## S02: Features',
|
|
250
|
+
'',
|
|
251
|
+
'**Depends on:** S01',
|
|
252
|
+
'',
|
|
253
|
+
'Build features.',
|
|
254
|
+
].join('\n');
|
|
255
|
+
|
|
256
|
+
const slices = parseRoadmapSlices(content);
|
|
257
|
+
assertEq(slices.length, 2, 'prose deps: 2 slices');
|
|
258
|
+
assertEq(slices[1].depends.length, 1, 'S02 has 1 dep');
|
|
259
|
+
assertEq(slices[1].depends[0], 'S01', 'S02 depends on S01');
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
263
|
+
// M. Empty / edge cases
|
|
264
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
265
|
+
|
|
266
|
+
console.log('\n=== M. Edge cases ===');
|
|
267
|
+
|
|
268
|
+
{
|
|
269
|
+
assertEq(parseRoadmapSlices('').length, 0, 'empty content → 0 slices');
|
|
270
|
+
assertEq(parseRoadmapSlices('# Just a title\n\nSome text.').length, 0, 'no slices at all → 0');
|
|
271
|
+
|
|
272
|
+
// Mixed format: ## Slices section with one checkbox + prose below
|
|
273
|
+
const mixed = [
|
|
274
|
+
'## Slices',
|
|
275
|
+
'',
|
|
276
|
+
'- [ ] **S01: Foundation** `risk:low` `depends:[]`',
|
|
277
|
+
'',
|
|
278
|
+
'## S02: Features',
|
|
279
|
+
'',
|
|
280
|
+
'Prose content.',
|
|
281
|
+
].join('\n');
|
|
282
|
+
const mixedSlices = parseRoadmapSlices(mixed);
|
|
283
|
+
// The ## Slices section takes priority — prose headers outside it aren't picked up
|
|
284
|
+
assertEq(mixedSlices.length, 1, 'mixed: only 1 slice from ## Slices section');
|
|
285
|
+
assertEq(mixedSlices[0].id, 'S01', 'mixed: S01 from checkbox');
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
289
|
+
// N. Dependency range expansion
|
|
290
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
291
|
+
|
|
292
|
+
console.log('\n=== N. Dependency range expansion ===');
|
|
293
|
+
|
|
294
|
+
{
|
|
295
|
+
assertEq(
|
|
296
|
+
expandDependencies(['S01-S04']),
|
|
297
|
+
['S01', 'S02', 'S03', 'S04'],
|
|
298
|
+
'S01-S04 → 4 individual deps',
|
|
299
|
+
);
|
|
300
|
+
|
|
301
|
+
assertEq(
|
|
302
|
+
expandDependencies(['S01..S03']),
|
|
303
|
+
['S01', 'S02', 'S03'],
|
|
304
|
+
'S01..S03 → 3 individual deps',
|
|
305
|
+
);
|
|
306
|
+
|
|
307
|
+
assertEq(
|
|
308
|
+
expandDependencies(['S01']),
|
|
309
|
+
['S01'],
|
|
310
|
+
'single dep passes through',
|
|
311
|
+
);
|
|
312
|
+
|
|
313
|
+
assertEq(
|
|
314
|
+
expandDependencies(['S01', 'S03-S05']),
|
|
315
|
+
['S01', 'S03', 'S04', 'S05'],
|
|
316
|
+
'mixed single + range',
|
|
317
|
+
);
|
|
318
|
+
|
|
319
|
+
assertEq(
|
|
320
|
+
expandDependencies(['']),
|
|
321
|
+
[],
|
|
322
|
+
'empty string filtered out',
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
327
|
+
// O. No-separator colon-less: "S01:Title" (no space after colon)
|
|
328
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
329
|
+
|
|
330
|
+
console.log('\n=== O. No space after colon ===');
|
|
331
|
+
|
|
332
|
+
{
|
|
333
|
+
const content = '## S01:Foundation\n\n## S02:Features\n';
|
|
334
|
+
const slices = parseRoadmapSlices(content);
|
|
335
|
+
// The regex uses [:\s.—–-]* which allows colon with no space
|
|
336
|
+
assertEq(slices.length, 2, 'no-space-colon: 2 slices');
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
340
|
+
// P. Three-digit padded IDs
|
|
341
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
342
|
+
|
|
343
|
+
console.log('\n=== P. Three-digit padded IDs ===');
|
|
344
|
+
|
|
345
|
+
{
|
|
346
|
+
const content = '## S001: Foundation\n\n## S002: Features\n';
|
|
347
|
+
const slices = parseRoadmapSlices(content);
|
|
348
|
+
assertEq(slices.length, 2, 'three-digit: 2 slices');
|
|
349
|
+
assertEq(slices[0].id, 'S001', 'three-digit: S001');
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
report();
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
main().catch((error) => {
|
|
356
|
+
console.error(error);
|
|
357
|
+
process.exit(1);
|
|
358
|
+
});
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* session-lock-regression.test.ts — Regression tests for session lock lifecycle.
|
|
3
|
+
*
|
|
4
|
+
* Regression coverage for:
|
|
5
|
+
* #1257 False-positive "Session lock lost" during auto-mode
|
|
6
|
+
* #1245 Stranded .gsd.lock/ directory preventing new sessions
|
|
7
|
+
* #1251 Same root cause as #1245
|
|
8
|
+
*
|
|
9
|
+
* Tests the acquire → validate → release lifecycle and edge cases
|
|
10
|
+
* without requiring concurrent processes.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { mkdtempSync, mkdirSync, writeFileSync, rmSync, existsSync, readFileSync } from 'node:fs';
|
|
14
|
+
import { join } from 'node:path';
|
|
15
|
+
import { tmpdir } from 'node:os';
|
|
16
|
+
|
|
17
|
+
import {
|
|
18
|
+
acquireSessionLock,
|
|
19
|
+
validateSessionLock,
|
|
20
|
+
releaseSessionLock,
|
|
21
|
+
readSessionLockData,
|
|
22
|
+
updateSessionLock,
|
|
23
|
+
isSessionLockHeld,
|
|
24
|
+
} from '../session-lock.ts';
|
|
25
|
+
import { gsdRoot } from '../paths.ts';
|
|
26
|
+
import { createTestContext } from './test-helpers.ts';
|
|
27
|
+
|
|
28
|
+
const { assertEq, assertTrue, report } = createTestContext();
|
|
29
|
+
|
|
30
|
+
async function main(): Promise<void> {
|
|
31
|
+
|
|
32
|
+
// ─── 1. Basic acquire/release lifecycle ───────────────────────────────
|
|
33
|
+
console.log('\n=== 1. acquire → validate → release lifecycle ===');
|
|
34
|
+
{
|
|
35
|
+
const base = mkdtempSync(join(tmpdir(), 'gsd-session-lock-'));
|
|
36
|
+
mkdirSync(join(base, '.gsd'), { recursive: true });
|
|
37
|
+
|
|
38
|
+
try {
|
|
39
|
+
const result = acquireSessionLock(base);
|
|
40
|
+
assertTrue(result.acquired, 'lock acquired successfully');
|
|
41
|
+
|
|
42
|
+
const valid = validateSessionLock(base);
|
|
43
|
+
assertTrue(valid, 'lock validates after acquisition');
|
|
44
|
+
|
|
45
|
+
assertTrue(isSessionLockHeld(base), 'isSessionLockHeld returns true');
|
|
46
|
+
|
|
47
|
+
releaseSessionLock(base);
|
|
48
|
+
|
|
49
|
+
// After release, the lock file should be cleaned up
|
|
50
|
+
const lockFile = join(gsdRoot(base), 'auto.lock');
|
|
51
|
+
assertTrue(!existsSync(lockFile), 'lock file removed after release');
|
|
52
|
+
|
|
53
|
+
// The .gsd.lock/ directory should be cleaned up
|
|
54
|
+
const lockDir = gsdRoot(base) + '.lock';
|
|
55
|
+
assertTrue(!existsSync(lockDir), '.gsd.lock/ directory removed after release (#1245)');
|
|
56
|
+
} finally {
|
|
57
|
+
rmSync(base, { recursive: true, force: true });
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ─── 2. Double release is safe ────────────────────────────────────────
|
|
62
|
+
console.log('\n=== 2. double release does not throw ===');
|
|
63
|
+
{
|
|
64
|
+
const base = mkdtempSync(join(tmpdir(), 'gsd-session-lock-'));
|
|
65
|
+
mkdirSync(join(base, '.gsd'), { recursive: true });
|
|
66
|
+
|
|
67
|
+
try {
|
|
68
|
+
acquireSessionLock(base);
|
|
69
|
+
releaseSessionLock(base);
|
|
70
|
+
// Second release should not throw
|
|
71
|
+
let threw = false;
|
|
72
|
+
try {
|
|
73
|
+
releaseSessionLock(base);
|
|
74
|
+
} catch {
|
|
75
|
+
threw = true;
|
|
76
|
+
}
|
|
77
|
+
assertTrue(!threw, 'double release does not throw');
|
|
78
|
+
} finally {
|
|
79
|
+
rmSync(base, { recursive: true, force: true });
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ─── 3. updateSessionLock preserves lock data ─────────────────────────
|
|
84
|
+
console.log('\n=== 3. updateSessionLock writes metadata ===');
|
|
85
|
+
{
|
|
86
|
+
const base = mkdtempSync(join(tmpdir(), 'gsd-session-lock-'));
|
|
87
|
+
mkdirSync(join(base, '.gsd'), { recursive: true });
|
|
88
|
+
|
|
89
|
+
try {
|
|
90
|
+
acquireSessionLock(base);
|
|
91
|
+
|
|
92
|
+
updateSessionLock(base, 'execute-task', 'M001/S01/T01', 5, '/tmp/session.json');
|
|
93
|
+
|
|
94
|
+
const data = readSessionLockData(base);
|
|
95
|
+
assertTrue(data !== null, 'lock data readable after update');
|
|
96
|
+
if (data) {
|
|
97
|
+
assertEq(data.pid, process.pid, 'lock data has correct PID');
|
|
98
|
+
assertEq(data.unitType, 'execute-task', 'lock data has correct unit type');
|
|
99
|
+
assertEq(data.unitId, 'M001/S01/T01', 'lock data has correct unit ID');
|
|
100
|
+
assertEq(data.completedUnits, 5, 'lock data has correct completed count');
|
|
101
|
+
assertEq(data.sessionFile, '/tmp/session.json', 'lock data has session file');
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
releaseSessionLock(base);
|
|
105
|
+
} finally {
|
|
106
|
+
rmSync(base, { recursive: true, force: true });
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ─── 4. Stale lock from dead PID → re-acquirable (#1245) ─────────────
|
|
111
|
+
console.log('\n=== 4. stale lock from dead PID → re-acquirable ===');
|
|
112
|
+
{
|
|
113
|
+
const base = mkdtempSync(join(tmpdir(), 'gsd-session-lock-'));
|
|
114
|
+
mkdirSync(join(base, '.gsd'), { recursive: true });
|
|
115
|
+
|
|
116
|
+
try {
|
|
117
|
+
// Write a lock file with a definitely-dead PID
|
|
118
|
+
const lockFile = join(gsdRoot(base), 'auto.lock');
|
|
119
|
+
const staleLock = {
|
|
120
|
+
pid: 99999999, // extremely unlikely to be alive
|
|
121
|
+
startedAt: new Date(Date.now() - 3600000).toISOString(),
|
|
122
|
+
unitType: 'execute-task',
|
|
123
|
+
unitId: 'M001/S01/T01',
|
|
124
|
+
unitStartedAt: new Date(Date.now() - 3600000).toISOString(),
|
|
125
|
+
completedUnits: 3,
|
|
126
|
+
};
|
|
127
|
+
writeFileSync(lockFile, JSON.stringify(staleLock, null, 2));
|
|
128
|
+
|
|
129
|
+
// Should be able to acquire despite the stale lock
|
|
130
|
+
const result = acquireSessionLock(base);
|
|
131
|
+
assertTrue(result.acquired, '#1245: stale lock from dead PID → re-acquirable');
|
|
132
|
+
|
|
133
|
+
releaseSessionLock(base);
|
|
134
|
+
} finally {
|
|
135
|
+
rmSync(base, { recursive: true, force: true });
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// ─── 5. readSessionLockData with no lock → null ───────────────────────
|
|
140
|
+
console.log('\n=== 5. readSessionLockData with no lock → null ===');
|
|
141
|
+
{
|
|
142
|
+
const base = mkdtempSync(join(tmpdir(), 'gsd-session-lock-'));
|
|
143
|
+
mkdirSync(join(base, '.gsd'), { recursive: true });
|
|
144
|
+
|
|
145
|
+
try {
|
|
146
|
+
const data = readSessionLockData(base);
|
|
147
|
+
assertEq(data, null, 'no lock file → null');
|
|
148
|
+
} finally {
|
|
149
|
+
rmSync(base, { recursive: true, force: true });
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// ─── 6. validateSessionLock after own acquisition → true ──────────────
|
|
154
|
+
console.log('\n=== 6. validateSessionLock after own acquisition → true ===');
|
|
155
|
+
{
|
|
156
|
+
const base = mkdtempSync(join(tmpdir(), 'gsd-session-lock-'));
|
|
157
|
+
mkdirSync(join(base, '.gsd'), { recursive: true });
|
|
158
|
+
|
|
159
|
+
try {
|
|
160
|
+
acquireSessionLock(base);
|
|
161
|
+
|
|
162
|
+
// Multiple validations should all return true (regression for #1257)
|
|
163
|
+
for (let i = 0; i < 5; i++) {
|
|
164
|
+
const valid = validateSessionLock(base);
|
|
165
|
+
assertTrue(valid, `#1257: validation ${i + 1} returns true for own lock`);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
releaseSessionLock(base);
|
|
169
|
+
} finally {
|
|
170
|
+
rmSync(base, { recursive: true, force: true });
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// ─── 7. readSessionLockData with corrupt JSON → null ──────────────────
|
|
175
|
+
console.log('\n=== 7. corrupt lock file → null ===');
|
|
176
|
+
{
|
|
177
|
+
const base = mkdtempSync(join(tmpdir(), 'gsd-session-lock-'));
|
|
178
|
+
mkdirSync(join(base, '.gsd'), { recursive: true });
|
|
179
|
+
|
|
180
|
+
try {
|
|
181
|
+
const lockFile = join(gsdRoot(base), 'auto.lock');
|
|
182
|
+
writeFileSync(lockFile, 'NOT VALID JSON {{{');
|
|
183
|
+
|
|
184
|
+
const data = readSessionLockData(base);
|
|
185
|
+
assertEq(data, null, 'corrupt JSON → null');
|
|
186
|
+
} finally {
|
|
187
|
+
rmSync(base, { recursive: true, force: true });
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// ─── 8. Acquire after release is possible ─────────────────────────────
|
|
192
|
+
console.log('\n=== 8. acquire after release → re-acquirable ===');
|
|
193
|
+
{
|
|
194
|
+
const base = mkdtempSync(join(tmpdir(), 'gsd-session-lock-'));
|
|
195
|
+
mkdirSync(join(base, '.gsd'), { recursive: true });
|
|
196
|
+
|
|
197
|
+
try {
|
|
198
|
+
const r1 = acquireSessionLock(base);
|
|
199
|
+
assertTrue(r1.acquired, 'first acquisition');
|
|
200
|
+
releaseSessionLock(base);
|
|
201
|
+
|
|
202
|
+
const r2 = acquireSessionLock(base);
|
|
203
|
+
assertTrue(r2.acquired, 're-acquisition after release');
|
|
204
|
+
releaseSessionLock(base);
|
|
205
|
+
} finally {
|
|
206
|
+
rmSync(base, { recursive: true, force: true });
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
report();
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
main().catch((error) => {
|
|
214
|
+
console.error(error);
|
|
215
|
+
process.exit(1);
|
|
216
|
+
});
|