gsd-pi 3.0.0-dev.2e8b124f7 → 3.0.0-dev.6c9a50fd0

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 (87) hide show
  1. package/dist/resources/.managed-resources-content-hash +1 -1
  2. package/dist/resources/extensions/gsd/auto/loop.js +2 -3
  3. package/dist/resources/extensions/gsd/auto/orchestrator.js +2 -2
  4. package/dist/resources/extensions/gsd/auto/phases.js +12 -4
  5. package/dist/resources/extensions/gsd/auto-dispatch.js +34 -4
  6. package/dist/resources/extensions/gsd/auto-recovery.js +1 -0
  7. package/dist/resources/extensions/gsd/auto.js +27 -11
  8. package/dist/resources/extensions/gsd/bootstrap/agent-end-recovery.js +35 -4
  9. package/dist/resources/extensions/gsd/crash-recovery.js +4 -1
  10. package/dist/resources/extensions/gsd/db/auto-workers.js +21 -0
  11. package/dist/resources/extensions/gsd/preferences.js +4 -0
  12. package/dist/resources/extensions/gsd/repo-identity.js +39 -22
  13. package/dist/resources/extensions/gsd/session-lock.js +15 -2
  14. package/dist/resources/extensions/gsd/tools/complete-milestone.js +9 -1
  15. package/dist/resources/extensions/gsd/tools/complete-slice.js +50 -2
  16. package/dist/resources/extensions/gsd/tools/workflow-tool-executors.js +66 -40
  17. package/dist/resources/extensions/gsd/worktree-safety.js +10 -3
  18. package/dist/resources/extensions/shared/next-action-ui.js +13 -5
  19. package/dist/tsconfig.extensions.tsbuildinfo +1 -1
  20. package/dist/web/standalone/.next/BUILD_ID +1 -1
  21. package/dist/web/standalone/.next/app-path-routes-manifest.json +6 -6
  22. package/dist/web/standalone/.next/build-manifest.json +2 -2
  23. package/dist/web/standalone/.next/prerender-manifest.json +3 -3
  24. package/dist/web/standalone/.next/server/app/_global-error.html +1 -1
  25. package/dist/web/standalone/.next/server/app/_global-error.rsc +1 -1
  26. package/dist/web/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  27. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
  28. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error.segment.rsc +1 -1
  29. package/dist/web/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  30. package/dist/web/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  31. package/dist/web/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  32. package/dist/web/standalone/.next/server/app/_not-found.html +1 -1
  33. package/dist/web/standalone/.next/server/app/_not-found.rsc +1 -1
  34. package/dist/web/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
  35. package/dist/web/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
  36. package/dist/web/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
  37. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  38. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  39. package/dist/web/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  40. package/dist/web/standalone/.next/server/app/index.html +1 -1
  41. package/dist/web/standalone/.next/server/app/index.rsc +1 -1
  42. package/dist/web/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +1 -1
  43. package/dist/web/standalone/.next/server/app/index.segments/_full.segment.rsc +1 -1
  44. package/dist/web/standalone/.next/server/app/index.segments/_head.segment.rsc +1 -1
  45. package/dist/web/standalone/.next/server/app/index.segments/_index.segment.rsc +1 -1
  46. package/dist/web/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
  47. package/dist/web/standalone/.next/server/app-paths-manifest.json +6 -6
  48. package/dist/web/standalone/.next/server/middleware-build-manifest.js +1 -1
  49. package/dist/web/standalone/.next/server/pages/404.html +1 -1
  50. package/dist/web/standalone/.next/server/pages/500.html +1 -1
  51. package/dist/web/standalone/.next/server/server-reference-manifest.json +1 -1
  52. package/package.json +1 -1
  53. package/src/resources/extensions/gsd/auto/contracts.ts +2 -0
  54. package/src/resources/extensions/gsd/auto/loop.ts +2 -2
  55. package/src/resources/extensions/gsd/auto/orchestrator.ts +2 -2
  56. package/src/resources/extensions/gsd/auto/phases.ts +14 -4
  57. package/src/resources/extensions/gsd/auto-dispatch.ts +52 -3
  58. package/src/resources/extensions/gsd/auto-recovery.ts +1 -0
  59. package/src/resources/extensions/gsd/auto.ts +63 -18
  60. package/src/resources/extensions/gsd/bootstrap/agent-end-recovery.ts +25 -4
  61. package/src/resources/extensions/gsd/crash-recovery.ts +3 -0
  62. package/src/resources/extensions/gsd/db/auto-workers.ts +25 -0
  63. package/src/resources/extensions/gsd/preferences.ts +4 -0
  64. package/src/resources/extensions/gsd/repo-identity.ts +45 -25
  65. package/src/resources/extensions/gsd/session-lock.ts +15 -2
  66. package/src/resources/extensions/gsd/tests/auto-loop.test.ts +135 -0
  67. package/src/resources/extensions/gsd/tests/auto-orchestrator.test.ts +64 -35
  68. package/src/resources/extensions/gsd/tests/auto-recovery.test.ts +17 -15
  69. package/src/resources/extensions/gsd/tests/auto-workers.test.ts +13 -0
  70. package/src/resources/extensions/gsd/tests/complete-milestone.test.ts +51 -1
  71. package/src/resources/extensions/gsd/tests/complete-slice.test.ts +55 -0
  72. package/src/resources/extensions/gsd/tests/dispatch-missing-task-plans.test.ts +111 -1
  73. package/src/resources/extensions/gsd/tests/integration/state-machine-live-validation.test.ts +15 -0
  74. package/src/resources/extensions/gsd/tests/interrupted-session-auto.test.ts +38 -0
  75. package/src/resources/extensions/gsd/tests/repo-identity-worktree.test.ts +28 -1
  76. package/src/resources/extensions/gsd/tests/session-lock-regression.test.ts +35 -0
  77. package/src/resources/extensions/gsd/tests/session-switch-abort-misclassification.test.ts +38 -0
  78. package/src/resources/extensions/gsd/tests/workflow-tool-executors.test.ts +120 -0
  79. package/src/resources/extensions/gsd/tests/worktree-safety.test.ts +44 -0
  80. package/src/resources/extensions/gsd/tools/complete-milestone.ts +10 -0
  81. package/src/resources/extensions/gsd/tools/complete-slice.ts +51 -2
  82. package/src/resources/extensions/gsd/tools/workflow-tool-executors.ts +31 -17
  83. package/src/resources/extensions/gsd/worktree-safety.ts +12 -4
  84. package/src/resources/extensions/shared/next-action-ui.ts +11 -5
  85. package/src/resources/extensions/shared/tests/next-action-ui-hasui.test.ts +32 -0
  86. /package/dist/web/standalone/.next/static/{zCegwxH2e6vLp1vEZLLuZ → 8wipfz6TDZ6YWoaQjgqYD}/_buildManifest.js +0 -0
  87. /package/dist/web/standalone/.next/static/{zCegwxH2e6vLp1vEZLLuZ → 8wipfz6TDZ6YWoaQjgqYD}/_ssgManifest.js +0 -0
@@ -1 +1 @@
1
- 411ea8182d6c507e
1
+ 718ae1fc3f4ba06c
@@ -11,7 +11,7 @@
11
11
  import { randomUUID } from "node:crypto";
12
12
  import { MAX_LOOP_ITERATIONS, } from "./types.js";
13
13
  import { _clearCurrentResolve } from "./resolve.js";
14
- import { runPreDispatch, runDispatch, runGuards, runFinalize, } from "./phases.js";
14
+ import { runPreDispatch, runDispatch, runGuards, runFinalize, STUCK_WINDOW_SIZE, } from "./phases.js";
15
15
  import { debugLog } from "../debug-logger.js";
16
16
  import { isInfrastructureError, isTransientCooldownError, getCooldownRetryAfterMs, COOLDOWN_FALLBACK_WAIT_MS, MAX_COOLDOWN_RETRIES } from "./infra-errors.js";
17
17
  import { ModelPolicyDispatchBlockedError } from "../auto-model-selection.js";
@@ -57,7 +57,6 @@ import { handleCustomEngineReconcileOutcome } from "./workflow-custom-engine-rec
57
57
  // helpers degrade to the empty-state fallback that #3704 already
58
58
  // tolerates — same behavior as a fresh session.
59
59
  const STUCK_RECOVERY_ATTEMPTS_KEY = "stuck_recovery_attempts";
60
- const RECENT_UNIT_KEYS_LIMIT = 20;
61
60
  function stableStuckStateScopeId(s) {
62
61
  return normalizeRealPath(s.scope?.workspace.projectRoot ?? (s.originalBasePath || s.basePath));
63
62
  }
@@ -66,7 +65,7 @@ function loadStuckState(s) {
66
65
  if (!scopeId)
67
66
  return { recentUnits: [], stuckRecoveryAttempts: 0 };
68
67
  try {
69
- const recentUnits = getRecentUnitKeysForProjectRoot(scopeId, RECENT_UNIT_KEYS_LIMIT);
68
+ const recentUnits = getRecentUnitKeysForProjectRoot(scopeId, STUCK_WINDOW_SIZE);
70
69
  const stuckRecoveryAttempts = getRuntimeKv("global", scopeId, STUCK_RECOVERY_ATTEMPTS_KEY) ?? 0;
71
70
  return { recentUnits, stuckRecoveryAttempts };
72
71
  }
@@ -30,7 +30,7 @@ export class AutoOrchestrator {
30
30
  this.bumpTransition();
31
31
  await this.deps.runtime.journalTransition({ name: "start" });
32
32
  await this.deps.notifications.notifyLifecycle({ name: "start" });
33
- return this.advance();
33
+ return { kind: "started" };
34
34
  }
35
35
  async advance() {
36
36
  try {
@@ -279,7 +279,7 @@ export class AutoOrchestrator {
279
279
  this.bumpTransition();
280
280
  await this.deps.runtime.journalTransition({ name: "resume" });
281
281
  await this.deps.notifications.notifyLifecycle({ name: "resume" });
282
- return this.advance();
282
+ return { kind: "resumed" };
283
283
  }
284
284
  async stop(reason) {
285
285
  if (this.status.phase === "stopped") {
@@ -46,6 +46,7 @@ import { isSuspiciousGhostCompletion } from "../auto-unit-closeout.js";
46
46
  import { decideVerificationRetry, verificationRetryKey } from "./verification-retry-policy.js";
47
47
  import { buildPhaseHandoffOutcome, setAutoOutcomeWidget } from "../auto-dashboard.js";
48
48
  import { getConsecutiveDispatchBlocker } from "../dispatch-guard.js";
49
+ export const STUCK_WINDOW_SIZE = 6;
49
50
  // ─── Path Comparison Helper ───────────────────────────────────────────────
50
51
  /** Compare two paths for physical identity, tolerating trailing slashes and symlinks. */
51
52
  function isSamePathLocal(a, b) {
@@ -97,7 +98,12 @@ export function shouldDegradeEmptyWorktreeToProjectRoot(worktreeClassification,
97
98
  projectRootClassification.kind !== "invalid-repo");
98
99
  }
99
100
  function unitWritesSource(unitType) {
100
- const manifest = resolveManifest(unitType);
101
+ // Backward compatibility: sidecar queues from older builds may persist
102
+ // prefixed unit types (e.g. "sidecar/quick-task").
103
+ const normalizedUnitType = unitType.startsWith("sidecar/")
104
+ ? unitType.slice("sidecar/".length)
105
+ : unitType;
106
+ const manifest = resolveManifest(normalizedUnitType);
101
107
  if (!manifest)
102
108
  return null;
103
109
  return manifest.tools.mode === "all" || manifest.tools.mode === "docs";
@@ -151,7 +157,8 @@ async function validateSourceWriteWorktreeSafety(ic, unitType, unitId, milestone
151
157
  if (!writesSource)
152
158
  return null;
153
159
  const projectRoot = s.canonicalProjectRoot ?? resolveWorktreeProjectRoot(s.basePath, s.originalBasePath);
154
- if (deps.getIsolationMode(projectRoot) !== "worktree")
160
+ const isolationMode = deps.getIsolationMode(projectRoot);
161
+ if (isolationMode !== "worktree")
155
162
  return null;
156
163
  const safety = createWorktreeSafetyModule();
157
164
  const result = safety.validateUnitRoot({
@@ -161,6 +168,7 @@ async function validateSourceWriteWorktreeSafety(ic, unitType, unitId, milestone
161
168
  projectRoot,
162
169
  unitRoot: s.basePath,
163
170
  milestoneId,
171
+ isolationMode,
164
172
  expectedBranch: milestoneId ? deps.autoWorktreeBranch(milestoneId) : null,
165
173
  emptyWorktreeWithProjectContent: resolveEmptyWorktreeWithProjectContent(s.basePath, projectRoot),
166
174
  lease: s.workerId
@@ -899,7 +907,6 @@ export async function runPreDispatch(ic, loopState) {
899
907
  export async function runDispatch(ic, preData, loopState) {
900
908
  const { ctx, pi, s, deps, prefs } = ic;
901
909
  const { state, mid, midTitle } = preData;
902
- const STUCK_WINDOW_SIZE = 6;
903
910
  const provider = ctx.model?.provider;
904
911
  const authMode = provider && typeof ctx.modelRegistry?.getProviderAuthMode === "function"
905
912
  ? ctx.modelRegistry.getProviderAuthMode(provider)
@@ -1007,8 +1014,9 @@ export async function runDispatch(ic, preData, loopState) {
1007
1014
  // Rules 1/3/4 can catch retry loops with repeated failure content (#5719).
1008
1015
  // Rules 2/2b suppress legitimate retry backoff through the dispatch ledger.
1009
1016
  loopState.recentUnits.push({ key: derivedKey });
1010
- if (loopState.recentUnits.length > STUCK_WINDOW_SIZE)
1017
+ while (loopState.recentUnits.length > STUCK_WINDOW_SIZE) {
1011
1018
  loopState.recentUnits.shift();
1019
+ }
1012
1020
  const stuckSignal = detectStuck(loopState.recentUnits);
1013
1021
  if (stuckSignal) {
1014
1022
  debugLog("autoLoop", {
@@ -4,7 +4,7 @@ import { loadFile, extractUatType, loadActiveOverrides } from "./files.js";
4
4
  import { isDbAvailable, getMilestoneSlices, getPendingGates, markAllGatesOmitted, getMilestone, insertAssessment, transaction } from "./gsd-db.js";
5
5
  import { isClosedStatus } from "./status-guards.js";
6
6
  import { extractVerdict, isAcceptableUatVerdict } from "./verdict-parser.js";
7
- import { gsdRoot, resolveMilestoneFile, resolveMilestonePath, resolveSliceFile, resolveTaskFile, relSliceFile, buildMilestoneFileName, } from "./paths.js";
7
+ import { gsdRoot, resolveMilestoneFile, resolveMilestonePath, resolveSliceFile, resolveTaskFile, relTaskFile, relSliceFile, buildMilestoneFileName, buildTaskFileName, gsdProjectionRoot, } from "./paths.js";
8
8
  import { parseRoadmap } from "./parsers-legacy.js";
9
9
  import { validateArtifact } from "./schemas/validate.js";
10
10
  import { existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync } from "node:fs";
@@ -27,6 +27,8 @@ import { annotateBackgroundable } from "./delegation-policy.js";
27
27
  import { invalidateAllCaches } from "./cache.js";
28
28
  import { insertMilestoneValidationGates } from "./milestone-validation-gates.js";
29
29
  import { nativeHasChanges } from "./native-git-bridge.js";
30
+ import { debugLog, isDebugEnabled } from "./debug-logger.js";
31
+ import { resolveCanonicalMilestoneRoot } from "./worktree-manager.js";
30
32
  let reassessmentChecker = checkNeedsReassessment;
31
33
  let researchProjectPromptBuilder = buildResearchProjectPrompt;
32
34
  function shouldBypassMilestoneDepthGateInAuto(prefs) {
@@ -96,6 +98,15 @@ export function shouldRunDeepProjectSetup(state, prefs, basePath, options = {})
96
98
  }
97
99
  return hasPendingDeepStage(prefs, basePath);
98
100
  }
101
+ function resolveArtifactBasePath(basePath, mid, session) {
102
+ if (session?.basePath &&
103
+ session.currentMilestoneId === mid &&
104
+ session.basePath !== session.originalBasePath &&
105
+ existsSync(session.basePath)) {
106
+ return session.basePath;
107
+ }
108
+ return resolveCanonicalMilestoneRoot(basePath, mid);
109
+ }
99
110
  function missingSliceStop(mid, phase) {
100
111
  return {
101
112
  action: "stop",
@@ -951,7 +962,7 @@ export const DISPATCH_RULES = [
951
962
  },
952
963
  {
953
964
  name: "executing → execute-task (recover missing task plan → plan-slice)",
954
- match: async ({ state, mid, midTitle, basePath, sessionContextWindow, modelRegistry, sessionProvider }) => {
965
+ match: async ({ state, mid, midTitle, basePath, session, sessionContextWindow, modelRegistry, sessionProvider }) => {
955
966
  if (state.phase !== "executing" || !state.activeTask)
956
967
  return null;
957
968
  if (!state.activeSlice)
@@ -959,13 +970,32 @@ export const DISPATCH_RULES = [
959
970
  const sid = state.activeSlice.id;
960
971
  const sTitle = state.activeSlice.title;
961
972
  const tid = state.activeTask.id;
973
+ const artifactBasePath = resolveArtifactBasePath(basePath, mid, session);
962
974
  // Guard: if the slice plan exists but the individual task plan files are
963
975
  // missing, the planner created S##-PLAN.md with task entries but never
964
976
  // wrote the tasks/ directory files. Dispatch plan-slice to regenerate
965
977
  // them rather than hard-stopping — fixes the infinite-loop described in
966
978
  // issue #909.
967
- const taskPlanPath = resolveTaskFile(basePath, mid, sid, tid, "PLAN");
968
- if (!taskPlanPath || !existsSync(taskPlanPath)) {
979
+ const taskPlanPath = resolveTaskFile(artifactBasePath, mid, sid, tid, "PLAN");
980
+ const projectionTaskPlanPath = join(gsdProjectionRoot(artifactBasePath), "milestones", mid, "slices", sid, "tasks", buildTaskFileName(tid, "PLAN"));
981
+ if ((!taskPlanPath || !existsSync(taskPlanPath)) && !existsSync(projectionTaskPlanPath)) {
982
+ if (isDebugEnabled()) {
983
+ const expectedTaskPlanPath = join(artifactBasePath, relTaskFile(artifactBasePath, mid, sid, tid, "PLAN"));
984
+ const originalProjectRoot = session?.originalBasePath || basePath;
985
+ const activeMilestoneWorktreePath = session?.basePath || basePath;
986
+ const artifactExists = taskPlanPath ? existsSync(taskPlanPath) : false;
987
+ debugLog("dispatch-missing-task-plan-recovery", {
988
+ selectedDispatchRule: "executing → execute-task (recover missing task plan → plan-slice)",
989
+ basePathUsedForArtifactChecks: artifactBasePath,
990
+ milestoneRoot: artifactBasePath,
991
+ originalProjectRoot,
992
+ activeMilestoneWorktreePath,
993
+ expectedTaskPlanPath,
994
+ projectionTaskPlanPath,
995
+ artifactExists,
996
+ projectionArtifactExists: existsSync(projectionTaskPlanPath),
997
+ });
998
+ }
969
999
  return {
970
1000
  action: "dispatch",
971
1001
  unitType: "plan-slice",
@@ -188,6 +188,7 @@ export function hasImplementationArtifacts(basePath, milestoneId) {
188
188
  return "unknown";
189
189
  if (milestoneEvidence.matched)
190
190
  return classifyImplementationFiles(milestoneEvidence.files);
191
+ return "unknown";
191
192
  }
192
193
  if (currentBranch && currentBranch !== "HEAD")
193
194
  return "absent";
@@ -52,7 +52,7 @@ import { createAutoWorktree, teardownAutoWorktree, isInAutoWorktree, getAutoWork
52
52
  import { pruneQueueOrder } from "./queue-order.js";
53
53
  import { startCommandPolling as _startCommandPolling, isRemoteConfigured } from "../remote-questions/manager.js";
54
54
  import { debugLog, isDebugEnabled, writeDebugSummary } from "./debug-logger.js";
55
- import { reconcileMergeState, } from "./auto-recovery.js";
55
+ import { reconcileMergeState, verifyExpectedArtifact, } from "./auto-recovery.js";
56
56
  import { classifyMilestoneSummaryContent } from "./milestone-summary-classifier.js";
57
57
  import { resolveDispatch, DISPATCH_RULES } from "./auto-dispatch.js";
58
58
  import { getErrorMessage } from "./error-utils.js";
@@ -110,7 +110,7 @@ export { STUB_RECOVERY_THRESHOLD, NEW_SESSION_TIMEOUT_MS, } from "./auto/session
110
110
  import { autoSession as s } from "./auto-runtime-state.js";
111
111
  import { gsdHome } from "./gsd-home.js";
112
112
  import { createWorkspace, scopeMilestone } from "./workspace.js";
113
- import { registerAutoWorker, markWorkerStopping } from "./db/auto-workers.js";
113
+ import { registerAutoWorker, markWorkerStopping, markWorkerStoppingByPid, } from "./db/auto-workers.js";
114
114
  import { releaseMilestoneLease } from "./db/milestone-leases.js";
115
115
  import { normalizeRealPath } from "./paths.js";
116
116
  // ── ENCAPSULATION INVARIANT ─────────────────────────────────────────────────
@@ -259,6 +259,27 @@ function synthesizePausedSessionRecovery(basePath, unitType, unitId, sessionFile
259
259
  export function _synthesizePausedSessionRecoveryForTest(basePath, unitType, unitId, sessionFile) {
260
260
  return synthesizePausedSessionRecovery(basePath, unitType, unitId, sessionFile);
261
261
  }
262
+ function handlePausedSessionResumeRecovery(basePath, state, notify) {
263
+ if (!state.pausedSessionFile)
264
+ return { skippedReplay: false };
265
+ const pausedRecoveryUnitType = state.currentUnit?.type ?? state.pausedUnitType ?? "unknown";
266
+ const pausedRecoveryUnitId = state.currentUnit?.id ?? state.pausedUnitId ?? "unknown";
267
+ const completedPausedUnit = verifyExpectedArtifact(pausedRecoveryUnitType, pausedRecoveryUnitId, basePath);
268
+ if (completedPausedUnit) {
269
+ state.pausedSessionFile = null;
270
+ return { skippedReplay: true };
271
+ }
272
+ const recovery = synthesizePausedSessionRecovery(basePath, pausedRecoveryUnitType, pausedRecoveryUnitId, state.pausedSessionFile);
273
+ if (recovery && recovery.trace.toolCallCount > 0) {
274
+ state.pendingCrashRecovery = recovery.prompt;
275
+ notify(`Recovered ${recovery.trace.toolCallCount} tool calls from paused session. Resuming with context.`);
276
+ }
277
+ state.pausedSessionFile = null;
278
+ return { skippedReplay: false };
279
+ }
280
+ export function _handlePausedSessionResumeRecoveryForTest(basePath, state) {
281
+ return handlePausedSessionResumeRecovery(basePath, state, () => { });
282
+ }
262
283
  // `_resolvePausedResumeBasePathForTest` was retired in ADR-016 phase 2 / B3
263
284
  // (#5621). Production callers go through
264
285
  // `WorktreeLifecycle.resumeFromPausedSession`; the pure helper for tests is
@@ -566,6 +587,7 @@ export function checkRemoteAutoSession(projectRoot) {
566
587
  return { running: false };
567
588
  if (!isLockProcessAlive(lock)) {
568
589
  // Stale lock from a dead process — not a live remote session
590
+ markWorkerStoppingByPid(normalizeRealPath(projectRoot), lock.pid);
569
591
  return { running: false };
570
592
  }
571
593
  return {
@@ -1430,7 +1452,7 @@ export function createWiredDispatchAdapter(ctx, pi, dispatchBasePath, session) {
1430
1452
  midTitle: active.title,
1431
1453
  state,
1432
1454
  prefs,
1433
- session: input.session,
1455
+ session: input.session ?? session,
1434
1456
  structuredQuestionsAvailable,
1435
1457
  sessionContextWindow,
1436
1458
  sessionProvider,
@@ -1546,6 +1568,7 @@ export function createWiredAutoOrchestrationModule(ctx, pi, dispatchBasePath, ru
1546
1568
  projectRoot: runtimeBasePath,
1547
1569
  unitRoot: dispatchBasePath,
1548
1570
  milestoneId,
1571
+ isolationMode: getIsolationMode(runtimeBasePath),
1549
1572
  expectedBranch,
1550
1573
  });
1551
1574
  if (!result.ok) {
@@ -2104,14 +2127,7 @@ export async function startAuto(ctx, pi, base, verboseMode, options) {
2104
2127
  });
2105
2128
  }
2106
2129
  invalidateAllCaches();
2107
- if (s.pausedSessionFile) {
2108
- const recovery = synthesizePausedSessionRecovery(s.basePath, s.currentUnit?.type ?? s.pausedUnitType ?? "unknown", s.currentUnit?.id ?? s.pausedUnitId ?? "unknown", s.pausedSessionFile);
2109
- if (recovery && recovery.trace.toolCallCount > 0) {
2110
- s.pendingCrashRecovery = recovery.prompt;
2111
- ctx.ui.notify(`Recovered ${recovery.trace.toolCallCount} tool calls from paused session. Resuming with context.`, "info");
2112
- }
2113
- s.pausedSessionFile = null;
2114
- }
2130
+ handlePausedSessionResumeRecovery(s.basePath, s, (message) => ctx.ui.notify(message, "info"));
2115
2131
  captureProjectRootEnv(s.originalBasePath || s.basePath);
2116
2132
  registerAutoWorkerForSession(s);
2117
2133
  updateSessionLock(lockBase(), "resuming", s.currentMilestoneId ?? "unknown");
@@ -20,7 +20,20 @@ function isObjectRecord(value) {
20
20
  return !!value && typeof value === "object";
21
21
  }
22
22
  export function _hasEmptyAgentEndContent(content) {
23
- return content == null || (Array.isArray(content) && content.length === 0);
23
+ if (content == null)
24
+ return true;
25
+ if (!Array.isArray(content))
26
+ return false;
27
+ if (content.length === 0)
28
+ return true;
29
+ return content.every((block) => {
30
+ if (!block || typeof block !== "object")
31
+ return true;
32
+ const typedBlock = block;
33
+ if (typedBlock.type !== "text")
34
+ return false;
35
+ return typeof typedBlock.text !== "string" || typedBlock.text.trim() === "";
36
+ });
24
37
  }
25
38
  /**
26
39
  * Cap on auto-resume attempts for sustained transient-provider errors.
@@ -248,9 +261,27 @@ export async function handleAgentEnd(pi, event, ctx) {
248
261
  return;
249
262
  }
250
263
  if (isBareClaudeCodeStreamAbortPlaceholder(lastMsg)) {
251
- // The Claude Code adapter can emit this placeholder after a prior turn has
252
- // already completed and the next unit is active. It has no user/provider
253
- // diagnostic value and must not cancel the newly-dispatched unit.
264
+ if (isSessionSwitchAbortGraceActive()) {
265
+ // Old turn leaking through after a session switch drop it.
266
+ return;
267
+ }
268
+ // Mid-unit stream abort with no diagnostic. Treat as non-fatal so the loop
269
+ // can continue, but surface it to the user and resolve the in-flight unit.
270
+ ctx.ui.notify("Claude Code stream aborted mid-unit (no diagnostic). Continuing.", "warning");
271
+ try {
272
+ resetRetryState(retryState);
273
+ resolveAgentEnd(event);
274
+ }
275
+ catch (err) {
276
+ const message = err instanceof Error ? err.message : String(err);
277
+ ctx.ui.notify(`Auto-mode error after stream-abort placeholder: ${message}. Stopping auto-mode.`, "error");
278
+ try {
279
+ await pauseAuto(ctx, pi);
280
+ }
281
+ catch (e) {
282
+ logWarning("bootstrap", `pauseAuto failed after stream-abort placeholder: ${e.message}`);
283
+ }
284
+ }
254
285
  return;
255
286
  }
256
287
  if (isObjectRecord(lastMsg) && "stopReason" in lastMsg && lastMsg.stopReason === "aborted") {
@@ -23,7 +23,7 @@
23
23
  import { emitJournalEvent, queryJournal, } from "./journal.js";
24
24
  import { readFileSync, unlinkSync, existsSync } from "node:fs";
25
25
  import { join } from "node:path";
26
- import { findStaleWorkerForProject, getAllAutoWorkers, markWorkerCrashed, markWorkerStopping, } from "./db/auto-workers.js";
26
+ import { findStaleWorkerForProject, getAllAutoWorkers, markWorkerCrashed, markWorkerStopping, markWorkerStoppingByPid, } from "./db/auto-workers.js";
27
27
  import { forceReleaseLeasesForWorker } from "./db/milestone-leases.js";
28
28
  import { markLatestActiveForWorkerCanceled } from "./db/unit-dispatches.js";
29
29
  import { getRuntimeKv, setRuntimeKv, deleteRuntimeKv } from "./db/runtime-kv.js";
@@ -197,6 +197,9 @@ export function clearLock(basePath) {
197
197
  deleteRuntimeKv("worker", staleWorker.worker_id, SESSION_FILE_KV_KEY);
198
198
  return;
199
199
  }
200
+ const lock = readLegacyLock(basePath);
201
+ if (lock?.pid)
202
+ markWorkerStoppingByPid(projectRoot, lock.pid);
200
203
  const worker = findActiveWorkerForCurrentProcess(projectRoot);
201
204
  if (worker)
202
205
  deleteRuntimeKv("worker", worker.worker_id, SESSION_FILE_KV_KEY);
@@ -122,6 +122,27 @@ export function markWorkerStopping(workerId) {
122
122
  db.prepare(`UPDATE workers SET status = 'stopping' WHERE worker_id = :worker_id`).run({ ":worker_id": workerId });
123
123
  });
124
124
  }
125
+ /**
126
+ * Mark the active worker row for a specific PID/project root as stopping.
127
+ * Used when we detect a dead PID from lock metadata before heartbeat expiry.
128
+ */
129
+ export function markWorkerStoppingByPid(projectRootRealpath, pid) {
130
+ if (!isDbAvailable())
131
+ return;
132
+ if (!Number.isInteger(pid) || pid <= 0)
133
+ return;
134
+ const db = _getAdapter();
135
+ transaction(() => {
136
+ db.prepare(`UPDATE workers
137
+ SET status = 'stopping'
138
+ WHERE pid = :pid
139
+ AND project_root_realpath = :project_root
140
+ AND status = 'active'`).run({
141
+ ":pid": pid,
142
+ ":project_root": projectRootRealpath,
143
+ });
144
+ });
145
+ }
125
146
  /**
126
147
  * Return all workers whose status is 'active' AND whose heartbeat is within
127
148
  * the TTL window. Workers older than the TTL are NOT auto-marked crashed
@@ -274,6 +274,10 @@ export function applyModeDefaults(mode, prefs) {
274
274
  }
275
275
  function mergePreferences(base, override) {
276
276
  return {
277
+ // Preserve validated preference keys that do not need custom merge logic.
278
+ // The explicit fields below still own defaults, arrays, and deep merges.
279
+ ...base,
280
+ ...override,
277
281
  version: override.version ?? base.version,
278
282
  mode: override.mode ?? base.mode,
279
283
  always_use_skills: mergeStringLists(base.always_use_skills, override.always_use_skills),
@@ -405,19 +405,32 @@ function hasProjectState(externalPath) {
405
405
  * Returns the resolved external path (may differ from the computed identity).
406
406
  */
407
407
  function resolveExternalPathWithRecovery(projectPath) {
408
- const computedPath = externalGsdRoot(projectPath);
408
+ const base = process.env.GSD_STATE_DIR || gsdHome();
409
409
  const computedId = repoIdentity(projectPath);
410
+ const computedPath = join(base, "projects", computedId);
411
+ const computedHasState = hasProjectState(computedPath);
412
+ const markerId = readGsdIdMarker(projectPath);
413
+ const markerPath = markerId ? join(base, "projects", markerId) : null;
414
+ const markerHasState = markerPath ? hasProjectState(markerPath) : false;
415
+ // Split-brain guard: when marker and computed identities disagree and both
416
+ // directories contain state, prefer the marker-backed directory. This keeps
417
+ // all writers anchored to a single canonical project identity even if a
418
+ // transient identity computation produced a stale computed hash.
419
+ if (markerId
420
+ && markerPath
421
+ && markerId !== computedId
422
+ && markerHasState
423
+ && computedHasState) {
424
+ return { path: markerPath, identity: markerId };
425
+ }
410
426
  // Check if computed path already has state — fast path, no recovery needed.
411
- if (hasProjectState(computedPath)) {
412
- return computedPath;
427
+ if (computedHasState) {
428
+ return { path: computedPath, identity: computedId };
413
429
  }
414
430
  // Check for .gsd-id marker from a previous location.
415
- const markerId = readGsdIdMarker(projectPath);
416
- if (markerId && markerId !== computedId) {
431
+ if (markerId && markerPath && markerId !== computedId) {
417
432
  // The marker points to a different identity — the repo was likely moved.
418
- const base = process.env.GSD_STATE_DIR || gsdHome();
419
- const markerPath = join(base, "projects", markerId);
420
- if (hasProjectState(markerPath)) {
433
+ if (markerHasState) {
421
434
  // Recover: use the old state directory and update the marker to the new identity.
422
435
  // Move the state from the old hash dir to the new one so future lookups work
423
436
  // without the marker.
@@ -446,11 +459,11 @@ function resolveExternalPathWithRecovery(projectPath) {
446
459
  }
447
460
  catch {
448
461
  // If migration fails, just point at the old directory.
449
- return markerPath;
462
+ return { path: markerPath, identity: markerId };
450
463
  }
451
464
  }
452
465
  }
453
- return computedPath;
466
+ return { path: computedPath, identity: computedId };
454
467
  }
455
468
  // ─── Symlink Management ─────────────────────────────────────────────────────
456
469
  /**
@@ -467,17 +480,18 @@ function resolveExternalPathWithRecovery(projectPath) {
467
480
  * Returns the resolved external path.
468
481
  */
469
482
  export function ensureGsdSymlink(projectPath) {
470
- const result = ensureGsdSymlinkCore(projectPath);
483
+ const { path: result, identity } = ensureGsdSymlinkCore(projectPath);
471
484
  // Write .gsd-id marker so future relocations can recover this state (#2750).
472
485
  // Only write for the project root (not subdirectories or worktrees that
473
486
  // delegate to a parent .gsd).
474
487
  if (!isInsideWorktree(projectPath)) {
475
- writeGsdIdMarker(projectPath, repoIdentity(projectPath));
488
+ writeGsdIdMarker(projectPath, identity);
476
489
  }
477
490
  return result;
478
491
  }
479
492
  function ensureGsdSymlinkCore(projectPath) {
480
- const externalPath = resolveExternalPathWithRecovery(projectPath);
493
+ const resolved = resolveExternalPathWithRecovery(projectPath);
494
+ const externalPath = resolved.path;
481
495
  const localGsd = join(projectPath, ".gsd");
482
496
  const inWorktree = isInsideWorktree(projectPath);
483
497
  // Guard: Never create a symlink at ~/.gsd — that's the user-level GSD home,
@@ -498,7 +512,7 @@ function ensureGsdSymlinkCore(projectPath) {
498
512
  const localGsdNormalized = normalizeForGuard(localGsd);
499
513
  const gsdHomeNorm = normalizeForGuard(gsdHome());
500
514
  if (localGsdNormalized === gsdHomeNorm) {
501
- return localGsd;
515
+ return { path: localGsd, identity: resolved.identity };
502
516
  }
503
517
  // Guard: If projectPath is a plain subdirectory (not a worktree) of a git
504
518
  // repo that already has a .gsd at the git root, do not create a duplicate
@@ -516,7 +530,10 @@ function ensureGsdSymlinkCore(projectPath) {
516
530
  try {
517
531
  const rootStat = lstatSync(rootGsd);
518
532
  if (rootStat.isSymbolicLink() || rootStat.isDirectory()) {
519
- return rootStat.isSymbolicLink() ? realpathSync(rootGsd) : rootGsd;
533
+ return {
534
+ path: rootStat.isSymbolicLink() ? realpathSync(rootGsd) : rootGsd,
535
+ identity: resolved.identity,
536
+ };
520
537
  }
521
538
  }
522
539
  catch {
@@ -544,7 +561,7 @@ function ensureGsdSymlinkCore(projectPath) {
544
561
  }
545
562
  catch { /* already gone */ }
546
563
  symlinkSync(externalPath, localGsd, "junction");
547
- return externalPath;
564
+ return resolved;
548
565
  };
549
566
  // Check for dangling symlinks (e.g. after relocation recovery removed the old
550
567
  // state dir). existsSync follows symlinks, so it returns false for dangling ones.
@@ -567,7 +584,7 @@ function ensureGsdSymlinkCore(projectPath) {
567
584
  }
568
585
  catch { /* nothing to remove */ }
569
586
  symlinkSync(externalPath, localGsd, "junction");
570
- return externalPath;
587
+ return resolved;
571
588
  }
572
589
  try {
573
590
  const stat = lstatSync(localGsd);
@@ -575,7 +592,7 @@ function ensureGsdSymlinkCore(projectPath) {
575
592
  // Already a symlink — verify it points to the right place
576
593
  const target = realpathSync(localGsd);
577
594
  if (target === externalPath) {
578
- return externalPath; // correct symlink, no-op
595
+ return resolved; // correct symlink, no-op
579
596
  }
580
597
  // In a worktree, mismatched symlinks are always stale. Heal them so
581
598
  // the worktree points at the same external state dir as the main repo.
@@ -610,24 +627,24 @@ function ensureGsdSymlinkCore(projectPath) {
610
627
  }
611
628
  catch {
612
629
  // Migration failed — preserve old symlink
613
- return target;
630
+ return { path: target, identity: resolved.identity };
614
631
  }
615
632
  }
616
633
  // Outside worktrees, preserve custom overrides or legacy symlinks.
617
- return target;
634
+ return { path: target, identity: resolved.identity };
618
635
  }
619
636
  if (stat.isDirectory()) {
620
637
  // Real directory in the main repo — migration will handle this later.
621
638
  // In worktrees, keep the directory in place and let syncGsdStateToWorktree
622
639
  // refresh its contents. Replacing a git-tracked .gsd directory with a
623
640
  // symlink makes git think tracked planning files were deleted.
624
- return localGsd;
641
+ return { path: localGsd, identity: resolved.identity };
625
642
  }
626
643
  }
627
644
  catch {
628
645
  // lstat failed — path exists but we can't stat it
629
646
  }
630
- return localGsd;
647
+ return { path: localGsd, identity: resolved.identity };
631
648
  }
632
649
  // ─── Worktree Detection ─────────────────────────────────────────────────────
633
650
  /**
@@ -18,8 +18,9 @@
18
18
  import { createRequire } from "node:module";
19
19
  import { existsSync, readFileSync, readdirSync, mkdirSync, unlinkSync, rmSync, statSync } from "node:fs";
20
20
  import { join, dirname } from "node:path";
21
- import { gsdRoot } from "./paths.js";
21
+ import { gsdRoot, normalizeRealPath } from "./paths.js";
22
22
  import { atomicWriteSync } from "./atomic-write.js";
23
+ import { markWorkerStoppingByPid } from "./db/auto-workers.js";
23
24
  const _require = createRequire(import.meta.url);
24
25
  // ─── Module State ───────────────────────────────────────────────────────────
25
26
  /** Release function from proper-lockfile — calling it releases the OS lock. */
@@ -225,6 +226,12 @@ export function acquireSessionLock(basePath) {
225
226
  mkdirSync(dirname(lp), { recursive: true });
226
227
  // Clean up numbered lock file variants from cloud sync conflicts (#1315)
227
228
  cleanupStrayLockFiles(basePath);
229
+ // If lock metadata points to a dead PID, mark that worker row stopping so
230
+ // crash diagnostics do not keep surfacing it as active.
231
+ const existingPreflight = readExistingLockData(lp);
232
+ if (existingPreflight?.pid && !isPidAlive(existingPreflight.pid)) {
233
+ markWorkerStoppingByPid(normalizeRealPath(basePath), existingPreflight.pid);
234
+ }
228
235
  // Write our lock data first (the content is informational; the OS lock is the real guard)
229
236
  const lockData = {
230
237
  pid: process.pid,
@@ -250,7 +257,10 @@ export function acquireSessionLock(basePath) {
250
257
  const lockDir = lockTarget + ".lock";
251
258
  if (existsSync(lockDir)) {
252
259
  const existingData = readExistingLockData(lp);
253
- const isOrphan = !existingData || (existingData.pid && !isPidAlive(existingData.pid));
260
+ const deadPid = existingData?.pid && !isPidAlive(existingData.pid) ? existingData.pid : null;
261
+ if (deadPid)
262
+ markWorkerStoppingByPid(normalizeRealPath(basePath), deadPid);
263
+ const isOrphan = !existingData || !!deadPid;
254
264
  if (isOrphan) {
255
265
  try {
256
266
  rmSync(lockDir, { recursive: true, force: true });
@@ -288,6 +298,9 @@ export function acquireSessionLock(basePath) {
288
298
  // Check: if auto.lock is gone and no process is alive, the lock dir is stale.
289
299
  const existingData = readExistingLockData(lp);
290
300
  const existingPid = existingData?.pid;
301
+ if (existingPid && !isPidAlive(existingPid)) {
302
+ markWorkerStoppingByPid(normalizeRealPath(basePath), existingPid);
303
+ }
291
304
  // If no lock file or no alive process, try to clean up and re-acquire (#1245)
292
305
  if (!existingData || (existingPid && !isPidAlive(existingPid))) {
293
306
  try {
@@ -8,7 +8,7 @@
8
8
  */
9
9
  import { join } from "node:path";
10
10
  import { mkdirSync, existsSync } from "node:fs";
11
- import { transaction, getMilestone, getMilestoneSlices, getSliceTasks, updateMilestoneStatus, } from "../gsd-db.js";
11
+ import { transaction, getMilestone, getMilestoneSlices, getSliceTasks, getLatestAssessmentByScope, updateMilestoneStatus, } from "../gsd-db.js";
12
12
  import { resolveMilestonePath, clearPathCache } from "../paths.js";
13
13
  import { isClosedStatus } from "../status-guards.js";
14
14
  import { saveFile, clearParseCache } from "../files.js";
@@ -99,6 +99,14 @@ export async function handleCompleteMilestone(params, basePath) {
99
99
  alreadyComplete = true;
100
100
  return;
101
101
  }
102
+ // Defense-in-depth: only a passing milestone validation permits closeout.
103
+ const validation = getLatestAssessmentByScope(params.milestoneId, "milestone-validation");
104
+ if (validation?.status !== "pass") {
105
+ guardError =
106
+ `Refusing to complete ${params.milestoneId}: latest milestone-validation verdict is ` +
107
+ `"${validation?.status ?? "absent"}". Only verdict=pass permits closeout.`;
108
+ return;
109
+ }
102
110
  // Verify all slices are complete
103
111
  const slices = getMilestoneSlices(params.milestoneId);
104
112
  if (slices.length === 0) {