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.
Files changed (34) hide show
  1. package/README.md +13 -18
  2. package/dist/resources/extensions/gsd/auto-supervisor.ts +10 -5
  3. package/dist/resources/extensions/gsd/auto-worktree.ts +135 -1
  4. package/dist/resources/extensions/gsd/commands.ts +14 -2
  5. package/dist/resources/extensions/gsd/git-service.ts +17 -11
  6. package/dist/resources/extensions/gsd/index.ts +13 -2
  7. package/dist/resources/extensions/gsd/session-lock.ts +80 -16
  8. package/dist/resources/extensions/gsd/tests/auto-dispatch-loop.test.ts +691 -0
  9. package/dist/resources/extensions/gsd/tests/cache-staleness-regression.test.ts +317 -0
  10. package/dist/resources/extensions/gsd/tests/git-service.test.ts +16 -7
  11. package/dist/resources/extensions/gsd/tests/loop-regression.test.ts +39 -1
  12. package/dist/resources/extensions/gsd/tests/roadmap-parse-regression.test.ts +358 -0
  13. package/dist/resources/extensions/gsd/tests/session-lock-regression.test.ts +216 -0
  14. package/dist/resources/extensions/gsd/tests/session-lock.test.ts +119 -0
  15. package/dist/resources/extensions/gsd/tests/worktree-sync-milestones.test.ts +206 -0
  16. package/package.json +1 -1
  17. package/packages/pi-coding-agent/package.json +1 -1
  18. package/pkg/package.json +1 -1
  19. package/src/resources/extensions/gsd/auto-supervisor.ts +10 -5
  20. package/src/resources/extensions/gsd/auto-worktree.ts +135 -1
  21. package/src/resources/extensions/gsd/commands.ts +14 -2
  22. package/src/resources/extensions/gsd/git-service.ts +17 -11
  23. package/src/resources/extensions/gsd/index.ts +13 -2
  24. package/src/resources/extensions/gsd/session-lock.ts +80 -16
  25. package/src/resources/extensions/gsd/tests/auto-dispatch-loop.test.ts +691 -0
  26. package/src/resources/extensions/gsd/tests/cache-staleness-regression.test.ts +317 -0
  27. package/src/resources/extensions/gsd/tests/git-service.test.ts +16 -7
  28. package/src/resources/extensions/gsd/tests/loop-regression.test.ts +39 -1
  29. package/src/resources/extensions/gsd/tests/roadmap-parse-regression.test.ts +358 -0
  30. package/src/resources/extensions/gsd/tests/session-lock-regression.test.ts +216 -0
  31. package/src/resources/extensions/gsd/tests/session-lock.test.ts +119 -0
  32. package/src/resources/extensions/gsd/tests/worktree-sync-milestones.test.ts +206 -0
  33. package/dist/resources/extensions/mcporter/extension-manifest.json +0 -12
  34. 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 always excludes .gsd/ ──────────────────────────────
1089
+ // ─── smartStage excludes runtime files but allows milestone artifacts ──
1090
1090
 
1091
- console.log("\n=== smartStage always excludes .gsd/ ===");
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/ planning files + a normal source file
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 always excludes .gsd/ state is managed externally
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 with non-.gsd files");
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 () => {