gsd-pi 2.32.0-dev.3d7932c → 2.32.0-dev.d332657

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.
@@ -65,6 +65,28 @@ export function resetRewriteCircuitBreaker(): void {
65
65
  rewriteAttemptCount = 0;
66
66
  }
67
67
 
68
+ /**
69
+ * Guard for accessing activeSlice/activeTask in dispatch rules.
70
+ * Returns a stop action if the expected ref is null (corrupt state).
71
+ */
72
+ function requireSlice(state: GSDState): { sid: string; sTitle: string } | DispatchAction {
73
+ if (!state.activeSlice) {
74
+ return { action: "stop", reason: `Phase "${state.phase}" but no active slice — run /gsd doctor.`, level: "error" };
75
+ }
76
+ return { sid: state.activeSlice.id, sTitle: state.activeSlice.title };
77
+ }
78
+
79
+ function requireTask(state: GSDState): { sid: string; sTitle: string; tid: string; tTitle: string } | DispatchAction {
80
+ if (!state.activeSlice || !state.activeTask) {
81
+ return { action: "stop", reason: `Phase "${state.phase}" but no active slice/task — run /gsd doctor.`, level: "error" };
82
+ }
83
+ return { sid: state.activeSlice.id, sTitle: state.activeSlice.title, tid: state.activeTask.id, tTitle: state.activeTask.title };
84
+ }
85
+
86
+ function isStopAction(v: unknown): v is DispatchAction {
87
+ return typeof v === "object" && v !== null && "action" in v;
88
+ }
89
+
68
90
  // ─── Rules ────────────────────────────────────────────────────────────────
69
91
 
70
92
  const DISPATCH_RULES: DispatchRule[] = [
@@ -93,8 +115,9 @@ const DISPATCH_RULES: DispatchRule[] = [
93
115
  name: "summarizing → complete-slice",
94
116
  match: async ({ state, mid, midTitle, basePath }) => {
95
117
  if (state.phase !== "summarizing") return null;
96
- const sid = state.activeSlice!.id;
97
- const sTitle = state.activeSlice!.title;
118
+ const sliceRef = requireSlice(state);
119
+ if (isStopAction(sliceRef)) return sliceRef as DispatchAction;
120
+ const { sid, sTitle } = sliceRef;
98
121
  return {
99
122
  action: "dispatch",
100
123
  unitType: "complete-slice",
@@ -222,8 +245,9 @@ const DISPATCH_RULES: DispatchRule[] = [
222
245
  if (state.phase !== "planning") return null;
223
246
  // Phase skip: skip research when preference or profile says so
224
247
  if (prefs?.phases?.skip_research || prefs?.phases?.skip_slice_research) return null;
225
- const sid = state.activeSlice!.id;
226
- const sTitle = state.activeSlice!.title;
248
+ const sliceRef = requireSlice(state);
249
+ if (isStopAction(sliceRef)) return sliceRef as DispatchAction;
250
+ const { sid, sTitle } = sliceRef;
227
251
  const researchFile = resolveSliceFile(basePath, mid, sid, "RESEARCH");
228
252
  if (researchFile) return null; // has research, fall through
229
253
  // Skip slice research for S01 when milestone research already exists —
@@ -242,8 +266,9 @@ const DISPATCH_RULES: DispatchRule[] = [
242
266
  name: "planning → plan-slice",
243
267
  match: async ({ state, mid, midTitle, basePath }) => {
244
268
  if (state.phase !== "planning") return null;
245
- const sid = state.activeSlice!.id;
246
- const sTitle = state.activeSlice!.title;
269
+ const sliceRef = requireSlice(state);
270
+ if (isStopAction(sliceRef)) return sliceRef as DispatchAction;
271
+ const { sid, sTitle } = sliceRef;
247
272
  return {
248
273
  action: "dispatch",
249
274
  unitType: "plan-slice",
@@ -256,8 +281,9 @@ const DISPATCH_RULES: DispatchRule[] = [
256
281
  name: "replanning-slice → replan-slice",
257
282
  match: async ({ state, mid, midTitle, basePath }) => {
258
283
  if (state.phase !== "replanning-slice") return null;
259
- const sid = state.activeSlice!.id;
260
- const sTitle = state.activeSlice!.title;
284
+ const sliceRef = requireSlice(state);
285
+ if (isStopAction(sliceRef)) return sliceRef as DispatchAction;
286
+ const { sid, sTitle } = sliceRef;
261
287
  return {
262
288
  action: "dispatch",
263
289
  unitType: "replan-slice",
@@ -270,8 +296,9 @@ const DISPATCH_RULES: DispatchRule[] = [
270
296
  name: "executing → execute-task (recover missing task plan → plan-slice)",
271
297
  match: async ({ state, mid, midTitle, basePath }) => {
272
298
  if (state.phase !== "executing" || !state.activeTask) return null;
273
- const sid = state.activeSlice!.id;
274
- const sTitle = state.activeSlice!.title;
299
+ const sliceRef = requireSlice(state);
300
+ if (isStopAction(sliceRef)) return sliceRef as DispatchAction;
301
+ const { sid, sTitle } = sliceRef;
275
302
  const tid = state.activeTask.id;
276
303
 
277
304
  // Guard: if the slice plan exists but the individual task plan files are
@@ -296,8 +323,9 @@ const DISPATCH_RULES: DispatchRule[] = [
296
323
  name: "executing → execute-task",
297
324
  match: async ({ state, mid, basePath }) => {
298
325
  if (state.phase !== "executing" || !state.activeTask) return null;
299
- const sid = state.activeSlice!.id;
300
- const sTitle = state.activeSlice!.title;
326
+ const sliceRef = requireSlice(state);
327
+ if (isStopAction(sliceRef)) return sliceRef as DispatchAction;
328
+ const { sid, sTitle } = sliceRef;
301
329
  const tid = state.activeTask.id;
302
330
  const tTitle = state.activeTask.title;
303
331
 
@@ -215,52 +215,7 @@ export function shouldUseWorktreeIsolation(): boolean {
215
215
  return true; // default: worktree
216
216
  }
217
217
 
218
- /** Crash recovery prompt set by startAuto, consumed by first dispatchNextUnit */
219
-
220
- /** Pending verification retry — set when gate fails with retries remaining, consumed by dispatchNextUnit */
221
-
222
- /** Verification retry count per unitId — separate from s.unitDispatchCount which tracks artifact-missing retries */
223
-
224
- /** Session file path captured at pause — used to synthesize recovery briefing on resume */
225
-
226
- /** Dashboard tracking */
227
-
228
- /** Track dynamic routing decision for the current unit (for metrics) */
229
-
230
- /** Queue of quick-task captures awaiting dispatch after triage resolution */
231
-
232
- /**
233
- * Model captured at auto-mode start. Used to prevent model bleed between
234
- * concurrent GSD instances sharing the same global settings.json (#650).
235
- * When preferences don't specify a model for a unit type, this ensures
236
- * the session's original model is re-applied instead of reading from
237
- * the shared global settings (which another instance may have overwritten).
238
- */
239
-
240
- /** Track current milestone to detect transitions */
241
-
242
- /** Model the user had selected before auto-mode started */
243
-
244
- /** Progress-aware timeout supervision */
245
-
246
- /** Context-pressure continue-here monitor — fires once when context usage >= 70% */
247
-
248
- /** Dispatch gap watchdog — detects when the state machine stalls between units.
249
- * After handleAgentEnd completes, if auto-mode is still active but no new unit
250
- * has been dispatched (sendMessage not called), this timer fires to force a
251
- * re-evaluation. Covers the case where dispatchNextUnit silently fails or
252
- * an unhandled error kills the dispatch chain. */
253
-
254
- /** Prompt character measurement for token savings analysis (R051). */
255
-
256
- /** SIGTERM handler registered while auto-mode is active — cleared on stop/pause. */
257
-
258
- /**
259
- * Tool calls currently being executed — prevents false idle detection during long-running tools.
260
- * Maps toolCallId → start timestamp (ms) so the idle watchdog can detect tools that have been
261
- * running suspiciously long (e.g., a Bash command hung because `&` kept stdout open).
262
- */
263
-
218
+ // All mutable state lives in AutoSession (auto/session.ts) see encapsulation invariant above.
264
219
  /** Wrapper: register SIGTERM handler and store reference. */
265
220
  function registerSigtermHandler(currentBasePath: string): void {
266
221
  s.sigtermHandler = _registerSigtermHandler(currentBasePath, s.sigtermHandler);
@@ -406,6 +361,79 @@ function buildSnapshotOpts(unitType: string, unitId: string): { continueHereFire
406
361
  };
407
362
  }
408
363
 
364
+ // ─── Extracted Merge Helper ───────────────────────────────────────────────
365
+
366
+ /**
367
+ * Attempt to merge the current milestone branch to main.
368
+ * Handles both worktree and branch isolation modes with a single code path.
369
+ * Returns true if merge succeeded, false on error (non-fatal, logged).
370
+ *
371
+ * Extracted from 4 duplicate merge blocks in dispatchNextUnit to eliminate
372
+ * the bug factory where fixing one copy didn't fix the others (#1308).
373
+ */
374
+ function tryMergeMilestone(ctx: ExtensionContext, milestoneId: string, mode: "transition" | "complete"): boolean {
375
+ const isolationMode = getIsolationMode();
376
+
377
+ // Worktree merge path
378
+ if (isInAutoWorktree(s.basePath) && s.originalBasePath) {
379
+ try {
380
+ const roadmapPath = resolveMilestoneFile(s.originalBasePath, milestoneId, "ROADMAP");
381
+ if (!roadmapPath) {
382
+ teardownAutoWorktree(s.originalBasePath, milestoneId);
383
+ ctx.ui.notify(`Exited worktree for ${milestoneId} (no roadmap for merge).`, "info");
384
+ return false;
385
+ }
386
+ const roadmapContent = readFileSync(roadmapPath, "utf-8");
387
+ const mergeResult = mergeMilestoneToMain(s.originalBasePath, milestoneId, roadmapContent);
388
+ s.basePath = s.originalBasePath;
389
+ s.gitService = createGitService(s.basePath);
390
+ ctx.ui.notify(
391
+ `Milestone ${milestoneId} merged to main.${mergeResult.pushed ? " Pushed to remote." : ""}`,
392
+ "info",
393
+ );
394
+ return true;
395
+ } catch (err) {
396
+ ctx.ui.notify(
397
+ `Milestone merge failed: ${err instanceof Error ? err.message : String(err)}`,
398
+ "warning",
399
+ );
400
+ if (s.originalBasePath) {
401
+ s.basePath = s.originalBasePath;
402
+ try { process.chdir(s.basePath); } catch { /* best-effort */ }
403
+ }
404
+ return false;
405
+ }
406
+ }
407
+
408
+ // Branch-mode merge path
409
+ if (isolationMode === "branch") {
410
+ try {
411
+ const currentBranch = getCurrentBranch(s.basePath);
412
+ const milestoneBranch = autoWorktreeBranch(milestoneId);
413
+ if (currentBranch === milestoneBranch) {
414
+ const roadmapPath = resolveMilestoneFile(s.basePath, milestoneId, "ROADMAP");
415
+ if (roadmapPath) {
416
+ const roadmapContent = readFileSync(roadmapPath, "utf-8");
417
+ const mergeResult = mergeMilestoneToMain(s.basePath, milestoneId, roadmapContent);
418
+ s.gitService = createGitService(s.basePath);
419
+ ctx.ui.notify(
420
+ `Milestone ${milestoneId} merged (branch mode).${mergeResult.pushed ? " Pushed to remote." : ""}`,
421
+ "info",
422
+ );
423
+ return true;
424
+ }
425
+ }
426
+ } catch (err) {
427
+ ctx.ui.notify(
428
+ `Milestone merge failed (branch mode): ${err instanceof Error ? err.message : String(err)}`,
429
+ "warning",
430
+ );
431
+ }
432
+ }
433
+
434
+ return false;
435
+ }
436
+
409
437
  /**
410
438
  * Start a watchdog that fires if no new unit is dispatched within DISPATCH_GAP_TIMEOUT_MS
411
439
  * after handleAgentEnd completes. This catches the case where the dispatch chain silently
@@ -1107,32 +1135,14 @@ async function dispatchNextUnit(
1107
1135
  } catch (e) { debugLog("completed-keys-reset-failed", { error: getErrorMessage(e) }); }
1108
1136
 
1109
1137
  // ── Worktree lifecycle on milestone transition (#616) ──
1110
- if (isInAutoWorktree(s.basePath) && s.originalBasePath && shouldUseWorktreeIsolation()) {
1111
- try {
1112
- const roadmapPath = resolveMilestoneFile(s.originalBasePath, s.currentMilestoneId, "ROADMAP");
1113
- if (roadmapPath) {
1114
- const roadmapContent = readFileSync(roadmapPath, "utf-8");
1115
- const mergeResult = mergeMilestoneToMain(s.originalBasePath, s.currentMilestoneId, roadmapContent);
1116
- ctx.ui.notify(
1117
- `Milestone ${ s.currentMilestoneId } merged to main.${mergeResult.pushed ? " Pushed to remote." : ""}`,
1118
- "info",
1119
- );
1120
- } else {
1121
- teardownAutoWorktree(s.originalBasePath, s.currentMilestoneId);
1122
- ctx.ui.notify(`Exited worktree for ${ s.currentMilestoneId } (no roadmap for merge).`, "info");
1123
- }
1124
- } catch (err) {
1125
- ctx.ui.notify(
1126
- `Milestone merge failed during transition: ${getErrorMessage(err)}`,
1127
- "warning",
1128
- );
1129
- if (s.originalBasePath) {
1130
- try { process.chdir(s.originalBasePath); } catch { /* best-effort */ }
1131
- }
1132
- }
1138
+ if ((isInAutoWorktree(s.basePath) || getIsolationMode() === "branch") && shouldUseWorktreeIsolation()) {
1139
+ tryMergeMilestone(ctx, s.currentMilestoneId, "transition");
1133
1140
 
1134
- s.basePath = s.originalBasePath;
1135
- s.gitService = createGitService(s.basePath);
1141
+ // Reset to project root and re-derive state for the new milestone
1142
+ if (s.originalBasePath) {
1143
+ s.basePath = s.originalBasePath;
1144
+ s.gitService = createGitService(s.basePath);
1145
+ }
1136
1146
  invalidateAllCaches();
1137
1147
 
1138
1148
  state = await deriveState(s.basePath);
@@ -1177,51 +1187,8 @@ async function dispatchNextUnit(
1177
1187
  const incomplete = (state.registry ?? []).filter(m => m.status !== "complete" && m.status !== "parked");
1178
1188
  if (incomplete.length === 0) {
1179
1189
  // Genuinely all complete (parked milestones excluded) — merge milestone branch to main before stopping (#962)
1180
- if (s.currentMilestoneId && isInAutoWorktree(s.basePath) && s.originalBasePath) {
1181
- try {
1182
- const roadmapPath = resolveMilestoneFile(s.originalBasePath, s.currentMilestoneId, "ROADMAP");
1183
- if (roadmapPath) {
1184
- const roadmapContent = readFileSync(roadmapPath, "utf-8");
1185
- const mergeResult = mergeMilestoneToMain(s.originalBasePath, s.currentMilestoneId, roadmapContent);
1186
- s.basePath = s.originalBasePath;
1187
- s.gitService = createGitService(s.basePath);
1188
- ctx.ui.notify(
1189
- `Milestone ${ s.currentMilestoneId } merged to main.${mergeResult.pushed ? " Pushed to remote." : ""}`,
1190
- "info",
1191
- );
1192
- }
1193
- } catch (err) {
1194
- ctx.ui.notify(
1195
- `Milestone merge failed: ${getErrorMessage(err)}`,
1196
- "warning",
1197
- );
1198
- if (s.originalBasePath) {
1199
- s.basePath = s.originalBasePath;
1200
- try { process.chdir(s.basePath); } catch { /* best-effort */ }
1201
- }
1202
- }
1203
- } else if (s.currentMilestoneId && !isInAutoWorktree(s.basePath) && getIsolationMode() === "branch") {
1204
- try {
1205
- const currentBranch = getCurrentBranch(s.basePath);
1206
- const milestoneBranch = autoWorktreeBranch(s.currentMilestoneId);
1207
- if (currentBranch === milestoneBranch) {
1208
- const roadmapPath = resolveMilestoneFile(s.basePath, s.currentMilestoneId, "ROADMAP");
1209
- if (roadmapPath) {
1210
- const roadmapContent = readFileSync(roadmapPath, "utf-8");
1211
- const mergeResult = mergeMilestoneToMain(s.basePath, s.currentMilestoneId, roadmapContent);
1212
- s.gitService = createGitService(s.basePath);
1213
- ctx.ui.notify(
1214
- `Milestone ${ s.currentMilestoneId } merged (branch mode).${mergeResult.pushed ? " Pushed to remote." : ""}`,
1215
- "info",
1216
- );
1217
- }
1218
- }
1219
- } catch (err) {
1220
- ctx.ui.notify(
1221
- `Milestone merge failed (branch mode): ${getErrorMessage(err)}`,
1222
- "warning",
1223
- );
1224
- }
1190
+ if (s.currentMilestoneId) {
1191
+ tryMergeMilestone(ctx, s.currentMilestoneId, "complete");
1225
1192
  }
1226
1193
  sendDesktopNotification("GSD", "All milestones complete!", "success", "milestone");
1227
1194
  await stopAuto(ctx, pi, "All milestones complete");
@@ -1280,50 +1247,8 @@ async function dispatchNextUnit(
1280
1247
  s.completedKeySet.clear();
1281
1248
  } catch (e) { debugLog("completed-keys-reset-failed", { error: getErrorMessage(e) }); }
1282
1249
  // ── Milestone merge ──
1283
- if (s.currentMilestoneId && isInAutoWorktree(s.basePath) && s.originalBasePath) {
1284
- try {
1285
- const roadmapPath = resolveMilestoneFile(s.originalBasePath, s.currentMilestoneId, "ROADMAP");
1286
- if (!roadmapPath) throw new GSDError(GSD_ARTIFACT_MISSING, `Cannot resolve ROADMAP file for milestone ${ s.currentMilestoneId }`);
1287
- const roadmapContent = readFileSync(roadmapPath, "utf-8");
1288
- const mergeResult = mergeMilestoneToMain(s.originalBasePath, s.currentMilestoneId, roadmapContent);
1289
- s.basePath = s.originalBasePath;
1290
- s.gitService = createGitService(s.basePath);
1291
- ctx.ui.notify(
1292
- `Milestone ${ s.currentMilestoneId } merged to main.${mergeResult.pushed ? " Pushed to remote." : ""}`,
1293
- "info",
1294
- );
1295
- } catch (err) {
1296
- ctx.ui.notify(
1297
- `Milestone merge failed: ${getErrorMessage(err)}`,
1298
- "warning",
1299
- );
1300
- if (s.originalBasePath) {
1301
- s.basePath = s.originalBasePath;
1302
- try { process.chdir(s.basePath); } catch { /* best-effort */ }
1303
- }
1304
- }
1305
- } else if (s.currentMilestoneId && !isInAutoWorktree(s.basePath) && getIsolationMode() === "branch") {
1306
- try {
1307
- const currentBranch = getCurrentBranch(s.basePath);
1308
- const milestoneBranch = autoWorktreeBranch(s.currentMilestoneId);
1309
- if (currentBranch === milestoneBranch) {
1310
- const roadmapPath = resolveMilestoneFile(s.basePath, s.currentMilestoneId, "ROADMAP");
1311
- if (roadmapPath) {
1312
- const roadmapContent = readFileSync(roadmapPath, "utf-8");
1313
- const mergeResult = mergeMilestoneToMain(s.basePath, s.currentMilestoneId, roadmapContent);
1314
- s.gitService = createGitService(s.basePath);
1315
- ctx.ui.notify(
1316
- `Milestone ${ s.currentMilestoneId } merged (branch mode).${mergeResult.pushed ? " Pushed to remote." : ""}`,
1317
- "info",
1318
- );
1319
- }
1320
- }
1321
- } catch (err) {
1322
- ctx.ui.notify(
1323
- `Milestone merge failed (branch mode): ${getErrorMessage(err)}`,
1324
- "warning",
1325
- );
1326
- }
1250
+ if (s.currentMilestoneId) {
1251
+ tryMergeMilestone(ctx, s.currentMilestoneId, "complete");
1327
1252
  }
1328
1253
  sendDesktopNotification("GSD", `Milestone ${mid} complete!`, "success", "milestone");
1329
1254
  await stopAuto(ctx, pi, `Milestone ${mid} complete`);
@@ -70,31 +70,34 @@ test("auto.ts 'all milestones complete' path merges before stopping (#962)", ()
70
70
  const incompleteIdx = autoSrc.indexOf("incomplete.length === 0");
71
71
  assert.ok(incompleteIdx > -1, "auto.ts should have 'incomplete.length === 0' check");
72
72
 
73
- // The merge call must appear BETWEEN the incomplete check and the stopAuto call
74
- // in that same block
73
+ // The merge call must appear BETWEEN the incomplete check and the stopAuto call.
74
+ // After the #1308 refactor, the merge is delegated to tryMergeMilestone.
75
75
  const blockAfterIncomplete = autoSrc.slice(incompleteIdx, incompleteIdx + 3000);
76
76
 
77
77
  assert.ok(
78
- blockAfterIncomplete.includes("mergeMilestoneToMain"),
79
- "auto.ts should call mergeMilestoneToMain in the 'all milestones complete' path",
78
+ blockAfterIncomplete.includes("tryMergeMilestone"),
79
+ "auto.ts should call tryMergeMilestone in the 'all milestones complete' path",
80
80
  );
81
81
 
82
82
  // The merge should come before stopAuto in this block
83
- const mergePos = blockAfterIncomplete.indexOf("mergeMilestoneToMain");
83
+ const mergePos = blockAfterIncomplete.indexOf("tryMergeMilestone");
84
84
  const stopPos = blockAfterIncomplete.indexOf("stopAuto");
85
85
  assert.ok(
86
86
  mergePos < stopPos,
87
- "mergeMilestoneToMain should be called before stopAuto in the 'all complete' path",
87
+ "tryMergeMilestone should be called before stopAuto in the 'all complete' path",
88
88
  );
89
89
 
90
- // Should handle both worktree and branch isolation modes
90
+ // Verify tryMergeMilestone handles both worktree and branch isolation
91
+ const helperIdx = autoSrc.indexOf("function tryMergeMilestone");
92
+ assert.ok(helperIdx > -1, "tryMergeMilestone helper should exist");
93
+ const helperBlock = autoSrc.slice(helperIdx, helperIdx + 2000);
91
94
  assert.ok(
92
- blockAfterIncomplete.includes("isInAutoWorktree"),
93
- "should check isInAutoWorktree for worktree mode",
95
+ helperBlock.includes("isInAutoWorktree"),
96
+ "tryMergeMilestone should check isInAutoWorktree for worktree mode",
94
97
  );
95
98
  assert.ok(
96
- blockAfterIncomplete.includes("getIsolationMode"),
97
- "should check getIsolationMode for branch isolation mode",
99
+ helperBlock.includes("getIsolationMode") || helperBlock.includes("isolationMode"),
100
+ "tryMergeMilestone should check isolation mode for branch mode",
98
101
  );
99
102
  });
100
103