gsd-pi 2.33.0 → 2.33.1-dev.29a8268
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-supervisor.ts +10 -5
- package/dist/resources/extensions/gsd/auto-worktree.ts +135 -1
- package/dist/resources/extensions/gsd/commands.ts +14 -2
- package/dist/resources/extensions/gsd/git-service.ts +17 -11
- package/dist/resources/extensions/gsd/index.ts +13 -2
- package/dist/resources/extensions/gsd/session-lock.ts +80 -16
- 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/git-service.test.ts +16 -7
- package/dist/resources/extensions/gsd/tests/loop-regression.test.ts +39 -1
- 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/package.json +1 -1
- package/packages/pi-coding-agent/package.json +1 -1
- package/pkg/package.json +1 -1
- package/src/resources/extensions/gsd/auto-supervisor.ts +10 -5
- package/src/resources/extensions/gsd/auto-worktree.ts +135 -1
- package/src/resources/extensions/gsd/commands.ts +14 -2
- package/src/resources/extensions/gsd/git-service.ts +17 -11
- package/src/resources/extensions/gsd/index.ts +13 -2
- package/src/resources/extensions/gsd/session-lock.ts +80 -16
- 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/git-service.test.ts +16 -7
- package/src/resources/extensions/gsd/tests/loop-regression.test.ts +39 -1
- 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/dist/resources/extensions/mcporter/extension-manifest.json +0 -12
- package/src/resources/extensions/mcporter/extension-manifest.json +0 -12
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* cache-staleness-regression.test.ts — Regression tests for stale cache bugs.
|
|
3
|
+
*
|
|
4
|
+
* The GSD parser caches are critical for performance but have caused multiple
|
|
5
|
+
* production bugs when not invalidated at the right time.
|
|
6
|
+
*
|
|
7
|
+
* Regression coverage for:
|
|
8
|
+
* #1249 Stale caches in discuss loop → slice appears "not discussed"
|
|
9
|
+
* #1240 Stale caches after milestone creation → "No roadmap yet"
|
|
10
|
+
* #1236 Same root cause as #1240
|
|
11
|
+
*
|
|
12
|
+
* Pattern: derive state → write file → invalidate cache → derive again → verify update
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { mkdtempSync, mkdirSync, writeFileSync, rmSync, existsSync } from 'node:fs';
|
|
16
|
+
import { join } from 'node:path';
|
|
17
|
+
import { tmpdir } from 'node:os';
|
|
18
|
+
|
|
19
|
+
import { deriveState, invalidateStateCache } from '../state.ts';
|
|
20
|
+
import { invalidateAllCaches } from '../cache.ts';
|
|
21
|
+
import { createTestContext } from './test-helpers.ts';
|
|
22
|
+
|
|
23
|
+
const { assertEq, assertTrue, report } = createTestContext();
|
|
24
|
+
|
|
25
|
+
function createBase(): string {
|
|
26
|
+
const base = mkdtempSync(join(tmpdir(), 'gsd-cache-stale-'));
|
|
27
|
+
mkdirSync(join(base, '.gsd', 'milestones'), { recursive: true });
|
|
28
|
+
return base;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function cleanup(base: string): void {
|
|
32
|
+
rmSync(base, { recursive: true, force: true });
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function writeMilestoneFile(base: string, mid: string, suffix: string, content: string): void {
|
|
36
|
+
const dir = join(base, '.gsd', 'milestones', mid);
|
|
37
|
+
mkdirSync(dir, { recursive: true });
|
|
38
|
+
writeFileSync(join(dir, `${mid}-${suffix}.md`), content);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function writeSliceFile(base: string, mid: string, sid: string, suffix: string, content: string): void {
|
|
42
|
+
const dir = join(base, '.gsd', 'milestones', mid, 'slices', sid);
|
|
43
|
+
mkdirSync(dir, { recursive: true });
|
|
44
|
+
writeFileSync(join(dir, `${sid}-${suffix}.md`), content);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function main(): Promise<void> {
|
|
48
|
+
|
|
49
|
+
// ─── 1. Regression #1240: New roadmap detected after cache invalidation ─
|
|
50
|
+
console.log('\n=== 1. #1240: roadmap written after first derive → detected after invalidation ===');
|
|
51
|
+
{
|
|
52
|
+
const base = createBase();
|
|
53
|
+
try {
|
|
54
|
+
// Step 1: Create milestone with just context (no roadmap)
|
|
55
|
+
writeMilestoneFile(base, 'M001', 'CONTEXT', '# M001: Test\n\nBuild a thing.\n');
|
|
56
|
+
|
|
57
|
+
invalidateAllCaches();
|
|
58
|
+
invalidateStateCache();
|
|
59
|
+
const state1 = await deriveState(base);
|
|
60
|
+
assertEq(state1.phase, 'pre-planning', 'initial: pre-planning (no roadmap)');
|
|
61
|
+
|
|
62
|
+
// Step 2: Write roadmap (simulating what the LLM does during planning)
|
|
63
|
+
const roadmap = [
|
|
64
|
+
'# M001: Test',
|
|
65
|
+
'',
|
|
66
|
+
'## Slices',
|
|
67
|
+
'',
|
|
68
|
+
'- [ ] **S01: First Slice** `risk:low` `depends:[]`',
|
|
69
|
+
'',
|
|
70
|
+
'## Boundary Map',
|
|
71
|
+
'',
|
|
72
|
+
].join('\n');
|
|
73
|
+
writeMilestoneFile(base, 'M001', 'ROADMAP', roadmap);
|
|
74
|
+
|
|
75
|
+
// Step 3: WITHOUT invalidation, the old state might be cached
|
|
76
|
+
// The state cache has a 100ms TTL, so wait just past it
|
|
77
|
+
await new Promise(r => setTimeout(r, 150));
|
|
78
|
+
|
|
79
|
+
// Step 4: Invalidate and re-derive — should see the new roadmap
|
|
80
|
+
invalidateAllCaches();
|
|
81
|
+
invalidateStateCache();
|
|
82
|
+
const state2 = await deriveState(base);
|
|
83
|
+
assertEq(state2.phase, 'planning', '#1240: after roadmap write + invalidation → planning phase');
|
|
84
|
+
assertEq(state2.activeSlice?.id, 'S01', '#1240: S01 is now the active slice');
|
|
85
|
+
} finally {
|
|
86
|
+
cleanup(base);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ─── 2. Regression #1249: Slice context detected after cache invalidation ─
|
|
91
|
+
console.log('\n=== 2. #1249: slice context written mid-loop → detected after invalidation ===');
|
|
92
|
+
{
|
|
93
|
+
const base = createBase();
|
|
94
|
+
try {
|
|
95
|
+
// Create a milestone in needs-discussion phase (CONTEXT-DRAFT, no CONTEXT)
|
|
96
|
+
const mDir = join(base, '.gsd', 'milestones', 'M001');
|
|
97
|
+
mkdirSync(mDir, { recursive: true });
|
|
98
|
+
writeFileSync(join(mDir, 'M001-CONTEXT-DRAFT.md'), '# Draft\n\nSome ideas.\n');
|
|
99
|
+
|
|
100
|
+
invalidateAllCaches();
|
|
101
|
+
invalidateStateCache();
|
|
102
|
+
const state1 = await deriveState(base);
|
|
103
|
+
assertEq(state1.phase, 'needs-discussion', 'initial: needs-discussion');
|
|
104
|
+
|
|
105
|
+
// Simulate: discussion completes, CONTEXT.md is written
|
|
106
|
+
writeMilestoneFile(base, 'M001', 'CONTEXT', '# M001: Test\n\nFull context after discussion.\n');
|
|
107
|
+
|
|
108
|
+
// Wait past TTL
|
|
109
|
+
await new Promise(r => setTimeout(r, 150));
|
|
110
|
+
|
|
111
|
+
// Without invalidation, we'd still see 'needs-discussion'
|
|
112
|
+
invalidateAllCaches();
|
|
113
|
+
invalidateStateCache();
|
|
114
|
+
const state2 = await deriveState(base);
|
|
115
|
+
// Should now be pre-planning (has context, but no roadmap yet)
|
|
116
|
+
// Actually needs-discussion won't trigger because now CONTEXT exists
|
|
117
|
+
// The state should advance past needs-discussion
|
|
118
|
+
assertTrue(
|
|
119
|
+
state2.phase !== 'needs-discussion',
|
|
120
|
+
'#1249: after context write + invalidation → not stuck in needs-discussion',
|
|
121
|
+
);
|
|
122
|
+
} finally {
|
|
123
|
+
cleanup(base);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// ─── 3. State cache TTL expires naturally ─────────────────────────────
|
|
128
|
+
console.log('\n=== 3. state cache TTL: fresh reads after 100ms ===');
|
|
129
|
+
{
|
|
130
|
+
const base = createBase();
|
|
131
|
+
try {
|
|
132
|
+
writeMilestoneFile(base, 'M001', 'CONTEXT', '# M001\n\nDesc.\n');
|
|
133
|
+
|
|
134
|
+
invalidateAllCaches();
|
|
135
|
+
invalidateStateCache();
|
|
136
|
+
const state1 = await deriveState(base);
|
|
137
|
+
assertEq(state1.phase, 'pre-planning', 'initial: pre-planning');
|
|
138
|
+
|
|
139
|
+
// Write roadmap immediately
|
|
140
|
+
writeMilestoneFile(base, 'M001', 'ROADMAP', [
|
|
141
|
+
'# M001: Test',
|
|
142
|
+
'',
|
|
143
|
+
'## Slices',
|
|
144
|
+
'',
|
|
145
|
+
'- [ ] **S01: Slice** `risk:low` `depends:[]`',
|
|
146
|
+
'',
|
|
147
|
+
].join('\n'));
|
|
148
|
+
|
|
149
|
+
// Immediately after writing (within 100ms TTL), the cache might be stale
|
|
150
|
+
const state2 = await deriveState(base);
|
|
151
|
+
// This MAY still show pre-planning if within TTL — that's expected behavior
|
|
152
|
+
|
|
153
|
+
// Wait past TTL
|
|
154
|
+
await new Promise(r => setTimeout(r, 150));
|
|
155
|
+
|
|
156
|
+
// ALSO invalidate parse cache (not just state cache)
|
|
157
|
+
invalidateAllCaches();
|
|
158
|
+
invalidateStateCache();
|
|
159
|
+
const state3 = await deriveState(base);
|
|
160
|
+
assertEq(state3.phase, 'planning', 'after TTL expiry + invalidation → planning');
|
|
161
|
+
} finally {
|
|
162
|
+
cleanup(base);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// ─── 4. Task completion detection after file write ────────────────────
|
|
167
|
+
console.log('\n=== 4. task marked done in plan → state advances ===');
|
|
168
|
+
{
|
|
169
|
+
const base = createBase();
|
|
170
|
+
try {
|
|
171
|
+
writeMilestoneFile(base, 'M001', 'CONTEXT', '# M001\n\nDesc.\n');
|
|
172
|
+
writeMilestoneFile(base, 'M001', 'ROADMAP', [
|
|
173
|
+
'# M001: Test',
|
|
174
|
+
'',
|
|
175
|
+
'## Slices',
|
|
176
|
+
'',
|
|
177
|
+
'- [ ] **S01: Slice** `risk:low` `depends:[]`',
|
|
178
|
+
'',
|
|
179
|
+
].join('\n'));
|
|
180
|
+
writeSliceFile(base, 'M001', 'S01', 'PLAN', [
|
|
181
|
+
'# S01: Slice',
|
|
182
|
+
'',
|
|
183
|
+
'## Tasks',
|
|
184
|
+
'',
|
|
185
|
+
'- [ ] **T01: First Task** `est:1h`',
|
|
186
|
+
'- [ ] **T02: Second Task** `est:1h`',
|
|
187
|
+
].join('\n'));
|
|
188
|
+
// Write task plan files
|
|
189
|
+
const tasksDir = join(base, '.gsd', 'milestones', 'M001', 'slices', 'S01', 'tasks');
|
|
190
|
+
mkdirSync(tasksDir, { recursive: true });
|
|
191
|
+
writeFileSync(join(tasksDir, 'T01-PLAN.md'), '# T01\nDo thing.');
|
|
192
|
+
writeFileSync(join(tasksDir, 'T02-PLAN.md'), '# T02\nDo other thing.');
|
|
193
|
+
|
|
194
|
+
invalidateAllCaches();
|
|
195
|
+
invalidateStateCache();
|
|
196
|
+
const state1 = await deriveState(base);
|
|
197
|
+
assertEq(state1.activeTask?.id, 'T01', 'initial: T01 is active task');
|
|
198
|
+
|
|
199
|
+
// Mark T01 as done by rewriting the plan
|
|
200
|
+
writeSliceFile(base, 'M001', 'S01', 'PLAN', [
|
|
201
|
+
'# S01: Slice',
|
|
202
|
+
'',
|
|
203
|
+
'## Tasks',
|
|
204
|
+
'',
|
|
205
|
+
'- [x] **T01: First Task** `est:1h`',
|
|
206
|
+
'- [ ] **T02: Second Task** `est:1h`',
|
|
207
|
+
].join('\n'));
|
|
208
|
+
|
|
209
|
+
await new Promise(r => setTimeout(r, 150));
|
|
210
|
+
invalidateAllCaches();
|
|
211
|
+
invalidateStateCache();
|
|
212
|
+
const state2 = await deriveState(base);
|
|
213
|
+
assertEq(state2.activeTask?.id, 'T02', 'after T01 done → T02 is active task');
|
|
214
|
+
} finally {
|
|
215
|
+
cleanup(base);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// ─── 5. Slice completion detection ────────────────────────────────────
|
|
220
|
+
console.log('\n=== 5. all tasks done → summarizing phase ===');
|
|
221
|
+
{
|
|
222
|
+
const base = createBase();
|
|
223
|
+
try {
|
|
224
|
+
writeMilestoneFile(base, 'M001', 'CONTEXT', '# M001\n\nDesc.\n');
|
|
225
|
+
writeMilestoneFile(base, 'M001', 'ROADMAP', [
|
|
226
|
+
'# M001: Test',
|
|
227
|
+
'',
|
|
228
|
+
'## Slices',
|
|
229
|
+
'',
|
|
230
|
+
'- [ ] **S01: First** `risk:low` `depends:[]`',
|
|
231
|
+
'- [ ] **S02: Second** `risk:low` `depends:[S01]`',
|
|
232
|
+
'',
|
|
233
|
+
].join('\n'));
|
|
234
|
+
writeSliceFile(base, 'M001', 'S01', 'PLAN', [
|
|
235
|
+
'# S01',
|
|
236
|
+
'',
|
|
237
|
+
'## Tasks',
|
|
238
|
+
'',
|
|
239
|
+
'- [ ] **T01: Task** `est:1h`',
|
|
240
|
+
].join('\n'));
|
|
241
|
+
const tasksDir = join(base, '.gsd', 'milestones', 'M001', 'slices', 'S01', 'tasks');
|
|
242
|
+
mkdirSync(tasksDir, { recursive: true });
|
|
243
|
+
writeFileSync(join(tasksDir, 'T01-PLAN.md'), '# T01\nDo it.');
|
|
244
|
+
|
|
245
|
+
invalidateAllCaches();
|
|
246
|
+
invalidateStateCache();
|
|
247
|
+
const state1 = await deriveState(base);
|
|
248
|
+
assertEq(state1.phase, 'executing', 'initial: executing');
|
|
249
|
+
|
|
250
|
+
// Mark task done
|
|
251
|
+
writeSliceFile(base, 'M001', 'S01', 'PLAN', [
|
|
252
|
+
'# S01',
|
|
253
|
+
'',
|
|
254
|
+
'## Tasks',
|
|
255
|
+
'',
|
|
256
|
+
'- [x] **T01: Task** `est:1h`',
|
|
257
|
+
].join('\n'));
|
|
258
|
+
|
|
259
|
+
await new Promise(r => setTimeout(r, 150));
|
|
260
|
+
invalidateAllCaches();
|
|
261
|
+
invalidateStateCache();
|
|
262
|
+
const state2 = await deriveState(base);
|
|
263
|
+
assertEq(state2.phase, 'summarizing', 'after all tasks done → summarizing');
|
|
264
|
+
} finally {
|
|
265
|
+
cleanup(base);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// ─── 6. Roadmap slice marked done → advance to next slice ─────────────
|
|
270
|
+
console.log('\n=== 6. roadmap slice marked [x] → next slice active ===');
|
|
271
|
+
{
|
|
272
|
+
const base = createBase();
|
|
273
|
+
try {
|
|
274
|
+
writeMilestoneFile(base, 'M001', 'CONTEXT', '# M001\n\nDesc.\n');
|
|
275
|
+
writeMilestoneFile(base, 'M001', 'ROADMAP', [
|
|
276
|
+
'# M001: Test',
|
|
277
|
+
'',
|
|
278
|
+
'## Slices',
|
|
279
|
+
'',
|
|
280
|
+
'- [ ] **S01: First** `risk:low` `depends:[]`',
|
|
281
|
+
'- [ ] **S02: Second** `risk:low` `depends:[S01]`',
|
|
282
|
+
'',
|
|
283
|
+
].join('\n'));
|
|
284
|
+
|
|
285
|
+
invalidateAllCaches();
|
|
286
|
+
invalidateStateCache();
|
|
287
|
+
const state1 = await deriveState(base);
|
|
288
|
+
assertEq(state1.activeSlice?.id, 'S01', 'initial: S01 active');
|
|
289
|
+
|
|
290
|
+
// Mark S01 as done in roadmap
|
|
291
|
+
writeMilestoneFile(base, 'M001', 'ROADMAP', [
|
|
292
|
+
'# M001: Test',
|
|
293
|
+
'',
|
|
294
|
+
'## Slices',
|
|
295
|
+
'',
|
|
296
|
+
'- [x] **S01: First** `risk:low` `depends:[]`',
|
|
297
|
+
'- [ ] **S02: Second** `risk:low` `depends:[S01]`',
|
|
298
|
+
'',
|
|
299
|
+
].join('\n'));
|
|
300
|
+
|
|
301
|
+
await new Promise(r => setTimeout(r, 150));
|
|
302
|
+
invalidateAllCaches();
|
|
303
|
+
invalidateStateCache();
|
|
304
|
+
const state2 = await deriveState(base);
|
|
305
|
+
assertEq(state2.activeSlice?.id, 'S02', 'after S01 done → S02 active');
|
|
306
|
+
} finally {
|
|
307
|
+
cleanup(base);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
report();
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
main().catch((error) => {
|
|
315
|
+
console.error(error);
|
|
316
|
+
process.exit(1);
|
|
317
|
+
});
|
|
@@ -1086,9 +1086,9 @@ async function main(): Promise<void> {
|
|
|
1086
1086
|
rmSync(repo, { recursive: true, force: true });
|
|
1087
1087
|
}
|
|
1088
1088
|
|
|
1089
|
-
// ─── smartStage
|
|
1089
|
+
// ─── smartStage excludes runtime files but allows milestone artifacts ──
|
|
1090
1090
|
|
|
1091
|
-
console.log("\n=== smartStage
|
|
1091
|
+
console.log("\n=== smartStage excludes runtime files, allows milestone artifacts ===");
|
|
1092
1092
|
|
|
1093
1093
|
{
|
|
1094
1094
|
const repo = mkdtempSync(join(tmpdir(), "gsd-smart-stage-excludes-"));
|
|
@@ -1098,21 +1098,30 @@ async function main(): Promise<void> {
|
|
|
1098
1098
|
writeFileSync(join(repo, "README.md"), "init");
|
|
1099
1099
|
run("git add -A && git commit -m init", repo);
|
|
1100
1100
|
|
|
1101
|
-
// Create .gsd/
|
|
1101
|
+
// Create .gsd/ runtime files + milestone artifacts + a normal source file
|
|
1102
1102
|
mkdirSync(join(repo, ".gsd", "milestones", "M001"), { recursive: true });
|
|
1103
|
+
mkdirSync(join(repo, ".gsd", "runtime"), { recursive: true });
|
|
1104
|
+
mkdirSync(join(repo, ".gsd", "activity"), { recursive: true });
|
|
1103
1105
|
writeFileSync(join(repo, ".gsd", "milestones", "M001", "ROADMAP.md"), "# Roadmap");
|
|
1104
1106
|
writeFileSync(join(repo, ".gsd", "preferences.md"), "---\nversion: 1\n---");
|
|
1107
|
+
writeFileSync(join(repo, ".gsd", "STATE.md"), "# State");
|
|
1108
|
+
writeFileSync(join(repo, ".gsd", "runtime", "units.json"), "{}");
|
|
1109
|
+
writeFileSync(join(repo, ".gsd", "activity", "log.jsonl"), "{}");
|
|
1105
1110
|
writeFileSync(join(repo, "src.ts"), "const x = 1;");
|
|
1106
1111
|
|
|
1107
|
-
// smartStage
|
|
1112
|
+
// smartStage excludes only runtime paths, not all of .gsd/ (#1326)
|
|
1108
1113
|
const svc = new GitServiceImpl(repo);
|
|
1109
1114
|
const msg = svc.commit({ message: "test commit" });
|
|
1110
|
-
assertTrue(msg !== null, "smartStage: commit succeeds
|
|
1115
|
+
assertTrue(msg !== null, "smartStage: commit succeeds");
|
|
1111
1116
|
|
|
1112
|
-
// .gsd/ files should NOT be in the commit
|
|
1113
1117
|
const committed = run("git show --name-only HEAD", repo);
|
|
1114
|
-
assertTrue(!committed.includes(".gsd/"), "smartStage: .gsd/ files not in commit");
|
|
1115
1118
|
assertTrue(committed.includes("src.ts"), "smartStage: source files ARE in commit");
|
|
1119
|
+
// Runtime files should NOT be committed
|
|
1120
|
+
assertTrue(!committed.includes(".gsd/STATE.md"), "smartStage: STATE.md excluded (runtime)");
|
|
1121
|
+
assertTrue(!committed.includes(".gsd/runtime/"), "smartStage: runtime/ excluded");
|
|
1122
|
+
assertTrue(!committed.includes(".gsd/activity/"), "smartStage: activity/ excluded");
|
|
1123
|
+
// Milestone artifacts SHOULD be committed when not gitignored (#1326)
|
|
1124
|
+
assertTrue(committed.includes(".gsd/milestones/"), "smartStage: milestone artifacts ARE committed");
|
|
1116
1125
|
|
|
1117
1126
|
rmSync(repo, { recursive: true, force: true });
|
|
1118
1127
|
}
|
|
@@ -609,10 +609,48 @@ test("session lock: onCompromised handler exists in both primary and retry paths
|
|
|
609
609
|
const compromisedMatches = [...lockSource.matchAll(/onCompromised/g)];
|
|
610
610
|
// Should have at least 2 onCompromised handlers (primary + retry)
|
|
611
611
|
// plus the flag declaration and the check in validateSessionLock
|
|
612
|
-
assert.ok(compromisedMatches.length >= 3,
|
|
612
|
+
assert.ok(compromisedMatches.length >= 3,
|
|
613
613
|
`expected ≥3 onCompromised references (primary + retry + flag), got ${compromisedMatches.length}`);
|
|
614
614
|
});
|
|
615
615
|
|
|
616
|
+
test("session lock: both onCompromised handlers null _releaseFunction (#1315)", async () => {
|
|
617
|
+
const lockSource = readFileSync(
|
|
618
|
+
"src/resources/extensions/gsd/session-lock.ts", "utf-8"
|
|
619
|
+
);
|
|
620
|
+
// Extract onCompromised handler blocks — both should set _releaseFunction = null
|
|
621
|
+
const handlers = lockSource.match(/onCompromised:\s*\(\)\s*=>\s*\{[^}]+\}/g) || [];
|
|
622
|
+
assert.ok(handlers.length >= 2, `expected ≥2 onCompromised handlers, got ${handlers.length}`);
|
|
623
|
+
for (const h of handlers) {
|
|
624
|
+
assert.ok(h.includes("_releaseFunction = null"),
|
|
625
|
+
`onCompromised handler should null _releaseFunction: ${h}`);
|
|
626
|
+
}
|
|
627
|
+
});
|
|
628
|
+
|
|
629
|
+
test("session lock: exit handler uses ensureExitHandler to prevent double-registration (#1315)", async () => {
|
|
630
|
+
const lockSource = readFileSync(
|
|
631
|
+
"src/resources/extensions/gsd/session-lock.ts", "utf-8"
|
|
632
|
+
);
|
|
633
|
+
// Should use ensureExitHandler instead of direct process.once("exit") in acquire paths
|
|
634
|
+
const directExitHandlers = (lockSource.match(/process\.once\("exit"/g) || []).length;
|
|
635
|
+
const ensureExitCalls = (lockSource.match(/ensureExitHandler\(/g) || []).length;
|
|
636
|
+
// Only 1 direct process.once("exit") allowed — inside ensureExitHandler itself
|
|
637
|
+
assert.ok(directExitHandlers <= 1,
|
|
638
|
+
`expected ≤1 direct process.once("exit") (inside ensureExitHandler), got ${directExitHandlers}`);
|
|
639
|
+
assert.ok(ensureExitCalls >= 2,
|
|
640
|
+
`expected ≥2 ensureExitHandler calls (primary + retry path), got ${ensureExitCalls}`);
|
|
641
|
+
});
|
|
642
|
+
|
|
643
|
+
test("signal handler: SIGINT handler registered alongside SIGTERM (#1315)", async () => {
|
|
644
|
+
const supervisorSource = readFileSync(
|
|
645
|
+
"src/resources/extensions/gsd/auto-supervisor.ts", "utf-8"
|
|
646
|
+
);
|
|
647
|
+
// registerSigtermHandler should register on both SIGTERM and SIGINT
|
|
648
|
+
assert.ok(supervisorSource.includes('process.on("SIGINT"') || supervisorSource.includes("process.on('SIGINT'"),
|
|
649
|
+
"registerSigtermHandler should register SIGINT handler");
|
|
650
|
+
assert.ok(supervisorSource.includes('process.off("SIGINT"') || supervisorSource.includes("process.off('SIGINT'"),
|
|
651
|
+
"deregisterSigtermHandler should deregister SIGINT handler");
|
|
652
|
+
});
|
|
653
|
+
|
|
616
654
|
// ─── Scope 5: Crash Recovery — Message Guidance per Unit Type ────────────
|
|
617
655
|
|
|
618
656
|
test("crash recovery: formatCrashInfo includes guidance for bootstrap crash", async () => {
|