gsd-pi 2.76.0-dev.355e107a0 → 2.76.0-dev.479ad0e78

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 (92) hide show
  1. package/dist/resources/extensions/gsd/auto/loop.js +9 -0
  2. package/dist/resources/extensions/gsd/auto/phases.js +11 -2
  3. package/dist/resources/extensions/gsd/auto/run-unit.js +11 -2
  4. package/dist/resources/extensions/gsd/auto/session.js +6 -1
  5. package/dist/resources/extensions/gsd/auto-recovery.js +19 -1
  6. package/dist/resources/extensions/gsd/auto.js +11 -0
  7. package/dist/resources/extensions/gsd/gitignore.js +1 -0
  8. package/dist/resources/extensions/gsd/gsd-db.js +23 -6
  9. package/dist/resources/extensions/gsd/worktree-resolver.js +50 -10
  10. package/dist/tsconfig.extensions.tsbuildinfo +1 -1
  11. package/dist/web/standalone/.next/BUILD_ID +1 -1
  12. package/dist/web/standalone/.next/app-path-routes-manifest.json +16 -16
  13. package/dist/web/standalone/.next/build-manifest.json +2 -2
  14. package/dist/web/standalone/.next/prerender-manifest.json +3 -3
  15. package/dist/web/standalone/.next/server/app/_global-error.html +1 -1
  16. package/dist/web/standalone/.next/server/app/_global-error.rsc +1 -1
  17. package/dist/web/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  18. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
  19. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error.segment.rsc +1 -1
  20. package/dist/web/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  21. package/dist/web/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  22. package/dist/web/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  23. package/dist/web/standalone/.next/server/app/_not-found.html +1 -1
  24. package/dist/web/standalone/.next/server/app/_not-found.rsc +1 -1
  25. package/dist/web/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
  26. package/dist/web/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
  27. package/dist/web/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
  28. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  29. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  30. package/dist/web/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  31. package/dist/web/standalone/.next/server/app/index.html +1 -1
  32. package/dist/web/standalone/.next/server/app/index.rsc +1 -1
  33. package/dist/web/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +1 -1
  34. package/dist/web/standalone/.next/server/app/index.segments/_full.segment.rsc +1 -1
  35. package/dist/web/standalone/.next/server/app/index.segments/_head.segment.rsc +1 -1
  36. package/dist/web/standalone/.next/server/app/index.segments/_index.segment.rsc +1 -1
  37. package/dist/web/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
  38. package/dist/web/standalone/.next/server/app-paths-manifest.json +16 -16
  39. package/dist/web/standalone/.next/server/middleware-build-manifest.js +1 -1
  40. package/dist/web/standalone/.next/server/middleware-manifest.json +5 -5
  41. package/dist/web/standalone/.next/server/pages/404.html +1 -1
  42. package/dist/web/standalone/.next/server/pages/500.html +1 -1
  43. package/dist/web/standalone/.next/server/server-reference-manifest.json +1 -1
  44. package/package.json +1 -1
  45. package/packages/mcp-server/dist/server.d.ts.map +1 -1
  46. package/packages/mcp-server/dist/server.js +29 -4
  47. package/packages/mcp-server/dist/server.js.map +1 -1
  48. package/packages/mcp-server/dist/session-manager.d.ts +14 -0
  49. package/packages/mcp-server/dist/session-manager.d.ts.map +1 -1
  50. package/packages/mcp-server/dist/session-manager.js +49 -1
  51. package/packages/mcp-server/dist/session-manager.js.map +1 -1
  52. package/packages/mcp-server/src/mcp-server.test.ts +37 -0
  53. package/packages/mcp-server/src/server.ts +27 -4
  54. package/packages/mcp-server/src/session-manager.ts +43 -1
  55. package/packages/mcp-server/tsconfig.tsbuildinfo +1 -1
  56. package/packages/pi-coding-agent/dist/core/agent-session-abort-order.test.js +3 -2
  57. package/packages/pi-coding-agent/dist/core/agent-session-abort-order.test.js.map +1 -1
  58. package/packages/pi-coding-agent/dist/core/agent-session.d.ts +2 -0
  59. package/packages/pi-coding-agent/dist/core/agent-session.d.ts.map +1 -1
  60. package/packages/pi-coding-agent/dist/core/agent-session.js +7 -0
  61. package/packages/pi-coding-agent/dist/core/agent-session.js.map +1 -1
  62. package/packages/pi-coding-agent/dist/core/extensions/runner.d.ts +2 -0
  63. package/packages/pi-coding-agent/dist/core/extensions/runner.d.ts.map +1 -1
  64. package/packages/pi-coding-agent/dist/core/extensions/runner.js.map +1 -1
  65. package/packages/pi-coding-agent/dist/core/extensions/types.d.ts +7 -0
  66. package/packages/pi-coding-agent/dist/core/extensions/types.d.ts.map +1 -1
  67. package/packages/pi-coding-agent/dist/core/extensions/types.js.map +1 -1
  68. package/packages/pi-coding-agent/src/core/agent-session-abort-order.test.ts +3 -2
  69. package/packages/pi-coding-agent/src/core/agent-session.ts +11 -0
  70. package/packages/pi-coding-agent/src/core/extensions/runner.ts +2 -0
  71. package/packages/pi-coding-agent/src/core/extensions/types.ts +7 -0
  72. package/packages/pi-coding-agent/tsconfig.tsbuildinfo +1 -1
  73. package/src/resources/extensions/gsd/auto/loop.ts +9 -0
  74. package/src/resources/extensions/gsd/auto/phases.ts +11 -2
  75. package/src/resources/extensions/gsd/auto/run-unit.ts +11 -2
  76. package/src/resources/extensions/gsd/auto/session.ts +6 -1
  77. package/src/resources/extensions/gsd/auto-recovery.ts +11 -1
  78. package/src/resources/extensions/gsd/auto.ts +11 -0
  79. package/src/resources/extensions/gsd/gitignore.ts +1 -1
  80. package/src/resources/extensions/gsd/gsd-db.ts +23 -6
  81. package/src/resources/extensions/gsd/tests/auto-loop.test.ts +69 -1
  82. package/src/resources/extensions/gsd/tests/integration/git-service.test.ts +1 -0
  83. package/src/resources/extensions/gsd/tests/integration/gitignore-tracked-gsd.test.ts +1 -0
  84. package/src/resources/extensions/gsd/tests/integration/idle-recovery.test.ts +30 -0
  85. package/src/resources/extensions/gsd/tests/memory-pressure-stuck-state.test.ts +12 -0
  86. package/src/resources/extensions/gsd/tests/resume-dispatch-worktree.test.ts +230 -0
  87. package/src/resources/extensions/gsd/tests/worktree-db.test.ts +35 -0
  88. package/src/resources/extensions/gsd/tests/worktree-journal-events.test.ts +6 -1
  89. package/src/resources/extensions/gsd/tests/worktree-resolver.test.ts +78 -5
  90. package/src/resources/extensions/gsd/worktree-resolver.ts +54 -9
  91. /package/dist/web/standalone/.next/static/{TKKbktue8R7x5oPjkT9j1 → JgU2F-5N9mTyB7kUSSk9A}/_buildManifest.js +0 -0
  92. /package/dist/web/standalone/.next/static/{TKKbktue8R7x5oPjkT9j1 → JgU2F-5N9mTyB7kUSSk9A}/_ssgManifest.js +0 -0
@@ -30,6 +30,13 @@ function stuckStatePath(basePath) {
30
30
  function loadStuckState(basePath) {
31
31
  try {
32
32
  const data = JSON.parse(readFileSync(stuckStatePath(basePath), "utf-8"));
33
+ // Only load state written by a DIFFERENT process (real session restart).
34
+ // If the PID matches the current process, this state was written by an earlier
35
+ // autoLoop call in the same process (e.g., a test that completed before this
36
+ // one), not by a crashed session — skip it to prevent test state pollution.
37
+ if (data.pid === process.pid) {
38
+ return { recentUnits: [], stuckRecoveryAttempts: 0 };
39
+ }
33
40
  return {
34
41
  recentUnits: Array.isArray(data.recentUnits) ? data.recentUnits : [],
35
42
  stuckRecoveryAttempts: typeof data.stuckRecoveryAttempts === "number" ? data.stuckRecoveryAttempts : 0,
@@ -45,6 +52,7 @@ function saveStuckState(basePath, state) {
45
52
  const filePath = stuckStatePath(basePath);
46
53
  mkdirSync(join(gsdRoot(basePath), "runtime"), { recursive: true });
47
54
  writeFileSync(filePath, JSON.stringify({
55
+ pid: process.pid,
48
56
  recentUnits: state.recentUnits.slice(-20), // keep last 20 entries
49
57
  stuckRecoveryAttempts: state.stuckRecoveryAttempts,
50
58
  updatedAt: new Date().toISOString(),
@@ -497,6 +505,7 @@ export async function autoLoop(ctx, pi, s, deps, options) {
497
505
  consecutiveCooldowns = 0;
498
506
  recentErrorMessages.length = 0;
499
507
  deps.emitJournalEvent({ ts: new Date().toISOString(), flowId, seq: nextSeq(), eventType: "iteration-end", data: { iteration } });
508
+ saveStuckState(s.basePath, loopState); // persist across session restarts (#4382)
500
509
  debugLog("autoLoop", { phase: "iteration-complete", iteration });
501
510
  finishTurn("completed");
502
511
  }
@@ -48,7 +48,11 @@ export function resetSessionTimeoutState() {
48
48
  * Exported for testing as _resolveReportBasePath.
49
49
  */
50
50
  export function _resolveReportBasePath(s) {
51
- return s.originalBasePath || s.basePath;
51
+ // Strip /.gsd/worktrees/ suffix when basePath is itself a worktree path and
52
+ // originalBasePath is falsy — prevents reports landing in the worktree (#3729).
53
+ const resolved = s.originalBasePath || s.basePath;
54
+ const markerIdx = resolved.indexOf("/.gsd/worktrees/");
55
+ return markerIdx !== -1 ? resolved.slice(0, markerIdx) : resolved;
52
56
  }
53
57
  /**
54
58
  * Resolve the authoritative project base for dispatch guards.
@@ -56,7 +60,12 @@ export function _resolveReportBasePath(s) {
56
60
  * unit is running inside an auto worktree.
57
61
  */
58
62
  export function _resolveDispatchGuardBasePath(s) {
59
- return s.originalBasePath || s.basePath;
63
+ // Strip /.gsd/worktrees/ suffix when basePath is itself a worktree path and
64
+ // originalBasePath is falsy — prevents guard checks running against the
65
+ // worktree instead of the project root (#3729).
66
+ const resolved = s.originalBasePath || s.basePath;
67
+ const markerIdx = resolved.indexOf("/.gsd/worktrees/");
68
+ return markerIdx !== -1 ? resolved.slice(0, markerIdx) : resolved;
60
69
  }
61
70
  const PLAN_V2_GATE_PHASES = new Set([
62
71
  "executing",
@@ -26,15 +26,24 @@ export async function runUnit(ctx, pi, s, unitType, unitId, prompt) {
26
26
  let sessionResult;
27
27
  let sessionTimeoutHandle;
28
28
  const mySessionSwitchGeneration = ++sessionSwitchGeneration;
29
+ // #3731: Cancellation controller for newSession(). When the session-creation
30
+ // timeout fires, we abort this controller so that the still-in-flight
31
+ // newSession() discards itself after await this.abort() completes, preventing
32
+ // it from capturing the (now-root) process.cwd() and rebuilding the tool
33
+ // runtime with the wrong cwd.
34
+ const sessionAbortController = new AbortController();
29
35
  _setSessionSwitchInFlight(true);
30
36
  try {
31
- const sessionPromise = s.cmdCtx.newSession().finally(() => {
37
+ const sessionPromise = s.cmdCtx.newSession({ abortSignal: sessionAbortController.signal }).finally(() => {
32
38
  if (sessionSwitchGeneration === mySessionSwitchGeneration) {
33
39
  _setSessionSwitchInFlight(false);
34
40
  }
35
41
  });
36
42
  const timeoutPromise = new Promise((resolve) => {
37
- sessionTimeoutHandle = setTimeout(() => resolve({ cancelled: true }), NEW_SESSION_TIMEOUT_MS);
43
+ sessionTimeoutHandle = setTimeout(() => {
44
+ sessionAbortController.abort();
45
+ resolve({ cancelled: true });
46
+ }, NEW_SESSION_TIMEOUT_MS);
38
47
  });
39
48
  sessionResult = await Promise.race([sessionPromise, timeoutPromise]);
40
49
  }
@@ -150,7 +150,12 @@ export class AutoSession {
150
150
  this.unitLifetimeDispatches.clear();
151
151
  }
152
152
  get lockBasePath() {
153
- return this.originalBasePath || this.basePath;
153
+ // Prefer originalBasePath (project root); fall back to basePath.
154
+ // Strip /.gsd/worktrees/ suffix if basePath is itself a worktree path
155
+ // to avoid reading/writing the lock inside the worktree (#3729).
156
+ const resolved = this.originalBasePath || this.basePath;
157
+ const markerIdx = resolved.indexOf("/.gsd/worktrees/");
158
+ return markerIdx !== -1 ? resolved.slice(0, markerIdx) : resolved;
154
159
  }
155
160
  reset() {
156
161
  this.clearTimers();
@@ -11,7 +11,7 @@ import { appendEvent } from "./workflow-events.js";
11
11
  import { atomicWriteSync } from "./atomic-write.js";
12
12
  import { clearParseCache } from "./files.js";
13
13
  import { parseRoadmap as parseLegacyRoadmap, parsePlan as parseLegacyPlan } from "./parsers-legacy.js";
14
- import { isDbAvailable, getTask, getSlice, getSliceTasks, getPendingGates, updateTaskStatus, updateSliceStatus } from "./gsd-db.js";
14
+ import { isDbAvailable, getTask, getSlice, getSliceTasks, getPendingGates, updateTaskStatus, updateSliceStatus, insertSlice } from "./gsd-db.js";
15
15
  import { isValidationTerminal } from "./state.js";
16
16
  import { getErrorMessage } from "./error-utils.js";
17
17
  import { logWarning, logError } from "./workflow-logger.js";
@@ -510,6 +510,24 @@ export function writeBlockerPlaceholder(unitType, unitId, base, reason) {
510
510
  logWarning("recovery", `appendEvent failed for slice recovery: ${e instanceof Error ? e.message : String(e)}`);
511
511
  }
512
512
  }
513
+ // Insert a placeholder complete slice so deriveState sees activeMilestoneSlices.length > 0
514
+ // and exits the pre-planning phase. Without this, activeMilestoneSlices stays empty
515
+ // after the blocker ROADMAP.md is written, causing deriveState to return phase:'pre-planning'
516
+ // indefinitely and re-dispatching plan-milestone in an infinite loop (#4378).
517
+ if (unitType === "plan-milestone" && mid) {
518
+ try {
519
+ insertSlice({ id: "S00-blocker", milestoneId: mid, title: "Blocker placeholder — planning failed", status: "complete", sequence: 0 });
520
+ }
521
+ catch (e) {
522
+ logWarning("recovery", `insertSlice placeholder failed for plan-milestone recovery: ${e instanceof Error ? e.message : String(e)}`);
523
+ }
524
+ try {
525
+ appendEvent(base, { cmd: "plan-milestone", params: { milestoneId: mid }, ts, actor: "system", trigger_reason: "blocker-placeholder-recovery" });
526
+ }
527
+ catch (e) {
528
+ logWarning("recovery", `appendEvent failed for plan-milestone recovery: ${e instanceof Error ? e.message : String(e)}`);
529
+ }
530
+ }
513
531
  }
514
532
  return diagnoseExpectedArtifact(unitType, unitId, base);
515
533
  }
@@ -1174,6 +1174,17 @@ export async function startAuto(ctx, pi, base, verboseMode, options) {
1174
1174
  s.stepMode = requestedStepMode;
1175
1175
  s.cmdCtx = ctx;
1176
1176
  s.basePath = base;
1177
+ // ── Resume worktree: if the paused session was inside a milestone worktree,
1178
+ // apply that path as the dispatch basePath immediately (#3723).
1179
+ // This ensures the dispatch loop runs from the worktree directory even when
1180
+ // enterMilestone guard conditions differ between the original and resumed
1181
+ // session (e.g. isolation mode changed, detectWorktreeName differs across
1182
+ // process restarts). We guard with existsSync so a stale or deleted
1183
+ // worktree directory safely falls back to the project root.
1184
+ const resumeWorktreePath = freshStartAssessment.pausedSession?.worktreePath;
1185
+ if (resumeWorktreePath && existsSync(resumeWorktreePath)) {
1186
+ s.basePath = resumeWorktreePath;
1187
+ }
1177
1188
  // Ensure the workflow-logger audit log is pinned to the project root
1178
1189
  // even when auto-mode is entered via a path that bypasses the
1179
1190
  // bootstrap/dynamic-tools ensureDbOpen() → setLogBasePath() chain
@@ -45,6 +45,7 @@ const BASELINE_PATTERNS = [
45
45
  // ── GSD state directory (symlink to external storage) ──
46
46
  ".gsd",
47
47
  ".gsd-id",
48
+ ".mcp.json",
48
49
  ".bg-shell/",
49
50
  // ── OS junk ──
50
51
  ".DS_Store",
@@ -2396,7 +2396,10 @@ export function reconcileWorktreeDb(mainDbPath, worktreeDbPath) {
2396
2396
  SELECT path, artifact_type, milestone_id, slice_id, task_id, full_content, imported_at
2397
2397
  FROM wt.artifacts
2398
2398
  `).run());
2399
- // Merge milestones — worktree may have updated status/planning fields
2399
+ // Merge milestones — worktree may have updated status/planning fields.
2400
+ // Never downgrade status: complete > active > pre-planning (#4372).
2401
+ // A stale worktree may carry an older 'active' status for a milestone
2402
+ // that the main DB has already marked 'complete'; preserve the higher status.
2400
2403
  merged.milestones = countChanges(adapter.prepare(`
2401
2404
  INSERT OR REPLACE INTO milestones (
2402
2405
  id, title, status, depends_on, created_at, completed_at,
@@ -2404,11 +2407,25 @@ export function reconcileWorktreeDb(mainDbPath, worktreeDbPath) {
2404
2407
  verification_contract, verification_integration, verification_operational, verification_uat,
2405
2408
  definition_of_done, requirement_coverage, boundary_map_markdown
2406
2409
  )
2407
- SELECT id, title, status, depends_on, created_at, completed_at,
2408
- vision, success_criteria, key_risks, proof_strategy,
2409
- verification_contract, verification_integration, verification_operational, verification_uat,
2410
- definition_of_done, requirement_coverage, boundary_map_markdown
2411
- FROM wt.milestones
2410
+ SELECT w.id, w.title,
2411
+ CASE
2412
+ WHEN m.status IN ('complete', 'done') AND w.status NOT IN ('complete', 'done')
2413
+ THEN m.status ELSE w.status
2414
+ END,
2415
+ w.depends_on,
2416
+ CASE
2417
+ WHEN m.status IN ('complete', 'done') AND w.status NOT IN ('complete', 'done')
2418
+ THEN m.created_at ELSE w.created_at
2419
+ END,
2420
+ CASE
2421
+ WHEN m.status IN ('complete', 'done') AND w.status NOT IN ('complete', 'done')
2422
+ THEN m.completed_at ELSE w.completed_at
2423
+ END,
2424
+ w.vision, w.success_criteria, w.key_risks, w.proof_strategy,
2425
+ w.verification_contract, w.verification_integration, w.verification_operational, w.verification_uat,
2426
+ w.definition_of_done, w.requirement_coverage, w.boundary_map_markdown
2427
+ FROM wt.milestones w
2428
+ LEFT JOIN milestones m ON m.id = w.id
2412
2429
  `).run());
2413
2430
  // Merge slices — preserve worktree progress but never downgrade completed status (#2558).
2414
2431
  // ADR-011 Phase 1: carry is_sketch + sketch_scope so reconcile doesn't
@@ -16,8 +16,30 @@ import { existsSync, unlinkSync } from "node:fs";
16
16
  import { randomUUID } from "node:crypto";
17
17
  import { join } from "node:path";
18
18
  import { debugLog } from "./debug-logger.js";
19
- import { MergeConflictError } from "./git-service.js";
20
19
  import { emitJournalEvent } from "./journal.js";
20
+ // ─── Path Helpers ──────────────────────────────────────────────────────────
21
+ /**
22
+ * Worktree marker segment — present in any path produced by worktreePath().
23
+ * Used to strip the worktree suffix and recover the project root (#3729).
24
+ */
25
+ const WORKTREE_MARKER = "/.gsd/worktrees/";
26
+ /**
27
+ * Resolve the project root from session path state.
28
+ *
29
+ * Prefers `originalBasePath` (always the project root when set), but falls
30
+ * back to `basePath` when `originalBasePath` is falsy (e.g. fresh AutoSession
31
+ * with default empty string). If `basePath` itself is inside a worktree
32
+ * directory (contains `/.gsd/worktrees/`), strip that suffix to recover the
33
+ * actual project root — preventing double-nested worktree paths (#3729).
34
+ */
35
+ export function resolveProjectRoot(originalBasePath, basePath) {
36
+ let resolved = originalBasePath || basePath;
37
+ const markerIdx = resolved.indexOf(WORKTREE_MARKER);
38
+ if (markerIdx !== -1) {
39
+ resolved = resolved.slice(0, markerIdx);
40
+ }
41
+ return resolved;
42
+ }
21
43
  // ─── WorktreeResolver ──────────────────────────────────────────────────────
22
44
  export class WorktreeResolver {
23
45
  s;
@@ -33,11 +55,11 @@ export class WorktreeResolver {
33
55
  }
34
56
  /** Original project root — always the non-worktree path. */
35
57
  get projectRoot() {
36
- return this.s.originalBasePath || this.s.basePath;
58
+ return resolveProjectRoot(this.s.originalBasePath, this.s.basePath);
37
59
  }
38
60
  /** Path for auto.lock file — same as the old lockBase(). */
39
61
  get lockPath() {
40
- return this.s.originalBasePath || this.s.basePath;
62
+ return resolveProjectRoot(this.s.originalBasePath, this.s.basePath);
41
63
  }
42
64
  // ── Private Helpers ────────────────────────────────────────────────────
43
65
  rebuildGitService() {
@@ -99,7 +121,10 @@ export class WorktreeResolver {
99
121
  });
100
122
  return;
101
123
  }
102
- const basePath = this.s.originalBasePath || this.s.basePath;
124
+ // Resolve the project root for worktree operations via shared helper.
125
+ // Handles the case where originalBasePath is falsy and basePath is itself
126
+ // a worktree path — prevents double-nested worktree paths (#3729).
127
+ const basePath = resolveProjectRoot(this.s.originalBasePath, this.s.basePath);
103
128
  debugLog("WorktreeResolver", {
104
129
  action: "enterMilestone",
105
130
  milestoneId,
@@ -429,11 +454,13 @@ export class WorktreeResolver {
429
454
  /* best-effort */
430
455
  }
431
456
  }
432
- // Re-throw MergeConflictError so the auto loop can detect real code
433
- // conflicts and stop instead of retrying forever (#2330).
434
- if (err instanceof MergeConflictError) {
435
- throw err;
436
- }
457
+ // Restore state before re-throwing so callers always get a consistent
458
+ // session (#4380).
459
+ this.restoreToProjectRoot();
460
+ // Re-throw: MergeConflictError stops the auto loop (#2330); non-conflict
461
+ // errors (permission denied, filesystem failures) must also propagate so
462
+ // broken states are diagnosable (#4380).
463
+ throw err;
437
464
  }
438
465
  // Always restore basePath and rebuild — whether merge succeeded or failed
439
466
  this.restoreToProjectRoot();
@@ -500,6 +527,8 @@ export class WorktreeResolver {
500
527
  error: msg,
501
528
  });
502
529
  ctx.notify(`Milestone merge failed (branch mode): ${msg}`, "warning");
530
+ // Re-throw all errors so callers can apply their own recovery logic (#4380).
531
+ throw err;
503
532
  }
504
533
  }
505
534
  // ── Merge and Enter Next ───────────────────────────────────────────────
@@ -516,7 +545,18 @@ export class WorktreeResolver {
516
545
  currentMilestoneId,
517
546
  nextMilestoneId,
518
547
  });
519
- this.mergeAndExit(currentMilestoneId, ctx);
548
+ try {
549
+ this.mergeAndExit(currentMilestoneId, ctx);
550
+ }
551
+ catch (err) {
552
+ // mergeAndExit emits a warning and restores state when it fails during
553
+ // merge/cleanup. But if it throws before recovery runs (e.g., in
554
+ // validateMilestoneId or emitJournalEvent), basePath won't be restored
555
+ // to projectRoot — re-throw so we don't enter the next milestone with
556
+ // the current one unmerged.
557
+ if (this.s.basePath !== this.projectRoot)
558
+ throw err;
559
+ }
520
560
  this.enterMilestone(nextMilestoneId, ctx);
521
561
  }
522
562
  }