gsd-pi 2.81.0-dev.3cddbbba2 → 2.81.0-dev.72a81bdf3

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 (89) hide show
  1. package/dist/resources/.managed-resources-content-hash +1 -1
  2. package/dist/resources/extensions/gsd/auto/phases.js +100 -95
  3. package/dist/resources/extensions/gsd/auto-recovery.js +6 -181
  4. package/dist/resources/extensions/gsd/auto.js +6 -3
  5. package/dist/resources/extensions/gsd/bootstrap/agent-end-recovery.js +2 -5
  6. package/dist/resources/extensions/gsd/commands/handlers/parallel.js +9 -0
  7. package/dist/resources/extensions/gsd/gsd-db.js +7 -23
  8. package/dist/resources/extensions/gsd/markdown-renderer.js +0 -95
  9. package/dist/resources/extensions/gsd/recovery-classification.js +15 -1
  10. package/dist/resources/extensions/gsd/session-lock.js +40 -0
  11. package/dist/resources/extensions/gsd/state-reconciliation/drift/completion.js +131 -0
  12. package/dist/resources/extensions/gsd/state-reconciliation/drift/merge-state.js +247 -0
  13. package/dist/resources/extensions/gsd/state-reconciliation/drift/project-md.js +50 -0
  14. package/dist/resources/extensions/gsd/state-reconciliation/drift/roadmap.js +87 -0
  15. package/dist/resources/extensions/gsd/state-reconciliation/drift/sketch-flag.js +50 -0
  16. package/dist/resources/extensions/gsd/state-reconciliation/drift/stale-render.js +124 -0
  17. package/dist/resources/extensions/gsd/state-reconciliation/drift/stale-worker.js +32 -0
  18. package/dist/resources/extensions/gsd/state-reconciliation/errors.js +41 -0
  19. package/dist/resources/extensions/gsd/state-reconciliation/index.js +99 -0
  20. package/dist/resources/extensions/gsd/state-reconciliation/registry.js +24 -0
  21. package/dist/resources/extensions/gsd/state-reconciliation/spawn-gate.js +43 -0
  22. package/dist/resources/extensions/gsd/state-reconciliation/types.js +3 -0
  23. package/dist/resources/extensions/gsd/state-reconciliation.js +5 -26
  24. package/dist/tsconfig.extensions.tsbuildinfo +1 -1
  25. package/dist/web/standalone/.next/BUILD_ID +1 -1
  26. package/dist/web/standalone/.next/app-path-routes-manifest.json +10 -10
  27. package/dist/web/standalone/.next/build-manifest.json +2 -2
  28. package/dist/web/standalone/.next/prerender-manifest.json +3 -3
  29. package/dist/web/standalone/.next/server/app/_global-error.html +1 -1
  30. package/dist/web/standalone/.next/server/app/_global-error.rsc +1 -1
  31. package/dist/web/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  32. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
  33. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error.segment.rsc +1 -1
  34. package/dist/web/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  35. package/dist/web/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  36. package/dist/web/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  37. package/dist/web/standalone/.next/server/app/_not-found.html +1 -1
  38. package/dist/web/standalone/.next/server/app/_not-found.rsc +1 -1
  39. package/dist/web/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
  40. package/dist/web/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
  41. package/dist/web/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
  42. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  43. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  44. package/dist/web/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  45. package/dist/web/standalone/.next/server/app/index.html +1 -1
  46. package/dist/web/standalone/.next/server/app/index.rsc +1 -1
  47. package/dist/web/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +1 -1
  48. package/dist/web/standalone/.next/server/app/index.segments/_full.segment.rsc +1 -1
  49. package/dist/web/standalone/.next/server/app/index.segments/_head.segment.rsc +1 -1
  50. package/dist/web/standalone/.next/server/app/index.segments/_index.segment.rsc +1 -1
  51. package/dist/web/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
  52. package/dist/web/standalone/.next/server/app-paths-manifest.json +10 -10
  53. package/dist/web/standalone/.next/server/middleware-build-manifest.js +1 -1
  54. package/dist/web/standalone/.next/server/pages/404.html +1 -1
  55. package/dist/web/standalone/.next/server/pages/500.html +1 -1
  56. package/dist/web/standalone/.next/server/server-reference-manifest.json +1 -1
  57. package/package.json +1 -1
  58. package/src/resources/extensions/gsd/auto/phases.ts +25 -17
  59. package/src/resources/extensions/gsd/auto-recovery.ts +7 -209
  60. package/src/resources/extensions/gsd/auto.ts +7 -3
  61. package/src/resources/extensions/gsd/bootstrap/agent-end-recovery.ts +2 -5
  62. package/src/resources/extensions/gsd/commands/handlers/parallel.ts +12 -0
  63. package/src/resources/extensions/gsd/gsd-db.ts +7 -23
  64. package/src/resources/extensions/gsd/markdown-renderer.ts +4 -95
  65. package/src/resources/extensions/gsd/recovery-classification.ts +18 -1
  66. package/src/resources/extensions/gsd/session-lock.ts +41 -0
  67. package/src/resources/extensions/gsd/state-reconciliation/drift/completion.ts +172 -0
  68. package/src/resources/extensions/gsd/state-reconciliation/drift/merge-state.ts +337 -0
  69. package/src/resources/extensions/gsd/state-reconciliation/drift/project-md.ts +69 -0
  70. package/src/resources/extensions/gsd/state-reconciliation/drift/roadmap.ts +109 -0
  71. package/src/resources/extensions/gsd/state-reconciliation/drift/sketch-flag.ts +68 -0
  72. package/src/resources/extensions/gsd/state-reconciliation/drift/stale-render.ts +185 -0
  73. package/src/resources/extensions/gsd/state-reconciliation/drift/stale-worker.ts +46 -0
  74. package/src/resources/extensions/gsd/state-reconciliation/errors.ts +67 -0
  75. package/src/resources/extensions/gsd/state-reconciliation/index.ts +142 -0
  76. package/src/resources/extensions/gsd/state-reconciliation/registry.ts +27 -0
  77. package/src/resources/extensions/gsd/state-reconciliation/spawn-gate.ts +60 -0
  78. package/src/resources/extensions/gsd/state-reconciliation/types.ts +83 -0
  79. package/src/resources/extensions/gsd/state-reconciliation.ts +21 -53
  80. package/src/resources/extensions/gsd/tests/artifact-retry-cap.test.ts +1 -1
  81. package/src/resources/extensions/gsd/tests/auto-loop.test.ts +81 -10
  82. package/src/resources/extensions/gsd/tests/integration/integration-proof.test.ts +1 -1
  83. package/src/resources/extensions/gsd/tests/markdown-renderer.test.ts +1 -1
  84. package/src/resources/extensions/gsd/tests/progressive-planning.test.ts +1 -1
  85. package/src/resources/extensions/gsd/tests/runtime-invariant-modules.test.ts +6 -3
  86. package/src/resources/extensions/gsd/tests/session-switch-abort-misclassification.test.ts +24 -0
  87. package/src/resources/extensions/gsd/tests/state-reconciliation-drift.test.ts +952 -0
  88. /package/dist/web/standalone/.next/static/{F5x9E6H9k_52fjqyql93y → rIkMv4YSNlfSeqmGqWVns}/_buildManifest.js +0 -0
  89. /package/dist/web/standalone/.next/static/{F5x9E6H9k_52fjqyql93y → rIkMv4YSNlfSeqmGqWVns}/_ssgManifest.js +0 -0
@@ -1 +1 @@
1
- f38fa6411e8b7897
1
+ cec78fe13fe69412
@@ -31,6 +31,7 @@ import { withTimeout, FINALIZE_PRE_TIMEOUT_MS, FINALIZE_POST_TIMEOUT_MS } from "
31
31
  import { getEligibleSlices } from "../slice-parallel-eligibility.js";
32
32
  import { startSliceParallel } from "../slice-parallel-orchestrator.js";
33
33
  import { isDbAvailable, getMilestoneSlices } from "../gsd-db.js";
34
+ import { reconcileBeforeSpawn } from "../state-reconciliation.js";
34
35
  import { ensurePlanV2Graph, isEmptyPlanV2GraphResult, isMissingFinalizedContextResult } from "../uok/plan-v2.js";
35
36
  import { resolveUokFlags } from "../uok/flags.js";
36
37
  import { UokGateRunner } from "../uok/gate-runner.js";
@@ -647,6 +648,13 @@ export async function runPreDispatch(ic, loopState) {
647
648
  eligibleSlices: eligible.map(e => e.id),
648
649
  });
649
650
  ctx.ui.notify(`Slice-parallel: dispatching ${eligible.length} eligible slices for ${mid}.`, "info");
651
+ // ADR-017 #5707: reconcile before spawning so each worker doesn't
652
+ // independently race on the same drift. Failure aborts the spawn.
653
+ const spawnGate = await reconcileBeforeSpawn(s.basePath);
654
+ if (!spawnGate.ok) {
655
+ ctx.ui.notify(`Slice-parallel: aborting spawn — ${spawnGate.reason}`, "error");
656
+ return { action: "break", reason: `slice-parallel-reconciliation-failed: ${spawnGate.reason}` };
657
+ }
650
658
  const result = await startSliceParallel(s.basePath, mid, eligible, {
651
659
  maxWorkers: prefs.slice_parallel.max_workers ?? 2,
652
660
  useExecutionGraph: uokFlags.executionGraph,
@@ -971,118 +979,115 @@ export async function runDispatch(ic, preData, loopState) {
971
979
  return worktreeSafetyBlock;
972
980
  // ── Sliding-window stuck detection with graduated recovery ──
973
981
  const derivedKey = `${unitType}/${unitId}`;
974
- // Always record this dispatch in the sliding window so detectStuck() has
975
- // accurate history. Skipping the push when pendingVerificationRetry is set
976
- // caused infinite artifact-retry loops to be invisible to stuck detection
977
- // (#2007). Only the *response* to a stuck signal is suppressed during retries.
982
+ // Always record this dispatch in the sliding window and run detection so
983
+ // Rules 1/3/4 can catch retry loops with repeated failure content (#5719).
984
+ // Rules 2/2b suppress legitimate retry backoff through the dispatch ledger.
978
985
  loopState.recentUnits.push({ key: derivedKey });
979
986
  if (loopState.recentUnits.length > STUCK_WINDOW_SIZE)
980
987
  loopState.recentUnits.shift();
981
- if (!s.pendingVerificationRetry) {
982
- const stuckSignal = detectStuck(loopState.recentUnits);
983
- if (stuckSignal) {
984
- debugLog("autoLoop", {
985
- phase: "stuck-check",
986
- unitType,
987
- unitId,
988
- reason: stuckSignal.reason,
989
- recoveryAttempts: loopState.stuckRecoveryAttempts,
990
- });
991
- if (loopState.stuckRecoveryAttempts === 0) {
992
- // Level 1: try verifying the artifact, then cache invalidation + retry
993
- loopState.stuckRecoveryAttempts++;
994
- const artifactExists = verifyExpectedArtifact(unitType, unitId, s.basePath);
995
- if (artifactExists) {
996
- if (unitType === "complete-milestone") {
997
- const stuckDiag = diagnoseExpectedArtifact(unitType, unitId, s.basePath);
998
- const stuckParts = [
999
- `Detected ${unitType} ${unitId} output on disk, but the same unit is still being derived.`,
1000
- "This usually means the milestone summary exists while the DB row still does not mark the milestone complete.",
1001
- ];
1002
- if (stuckDiag)
1003
- stuckParts.push(`Expected: ${stuckDiag}`);
1004
- ctx.ui.notify(stuckParts.join(" "), "warning");
1005
- await deps.pauseAuto(ctx, pi);
1006
- return { action: "break", reason: "complete-milestone-artifact-db-mismatch" };
1007
- }
1008
- debugLog("autoLoop", {
1009
- phase: "stuck-recovery",
1010
- level: 1,
1011
- action: "artifact-found",
1012
- });
1013
- const recoveryDb = refreshRecoveryDbForArtifact(unitType, unitId);
1014
- if (!recoveryDb.ok) {
1015
- ctx.ui.notify(recoveryDb.fatal
1016
- ? `${recoveryDb.message} Pausing auto-mode for manual recovery.`
1017
- : `${recoveryDb.message} Keeping stuck state for retry.`, "warning");
1018
- if (recoveryDb.fatal) {
1019
- await deps.pauseAuto(ctx, pi);
1020
- return { action: "break", reason: recoveryDb.reason };
1021
- }
1022
- return { action: "continue" };
1023
- }
1024
- ctx.ui.notify(`Stuck recovery: artifact for ${unitType} ${unitId} found on disk. Invalidating caches.`, "info");
1025
- deps.invalidateAllCaches();
1026
- loopState.recentUnits.length = 0;
1027
- loopState.stuckRecoveryAttempts = 0;
1028
- return { action: "continue" };
988
+ const stuckSignal = detectStuck(loopState.recentUnits);
989
+ if (stuckSignal) {
990
+ debugLog("autoLoop", {
991
+ phase: "stuck-check",
992
+ unitType,
993
+ unitId,
994
+ reason: stuckSignal.reason,
995
+ recoveryAttempts: loopState.stuckRecoveryAttempts,
996
+ });
997
+ if (loopState.stuckRecoveryAttempts === 0) {
998
+ // Level 1: try verifying the artifact, then cache invalidation + retry
999
+ loopState.stuckRecoveryAttempts++;
1000
+ const artifactExists = verifyExpectedArtifact(unitType, unitId, s.basePath);
1001
+ if (artifactExists) {
1002
+ if (unitType === "complete-milestone") {
1003
+ const stuckDiag = diagnoseExpectedArtifact(unitType, unitId, s.basePath);
1004
+ const stuckParts = [
1005
+ `Detected ${unitType} ${unitId} output on disk, but the same unit is still being derived.`,
1006
+ "This usually means the milestone summary exists while the DB row still does not mark the milestone complete.",
1007
+ ];
1008
+ if (stuckDiag)
1009
+ stuckParts.push(`Expected: ${stuckDiag}`);
1010
+ ctx.ui.notify(stuckParts.join(" "), "warning");
1011
+ await deps.pauseAuto(ctx, pi);
1012
+ return { action: "break", reason: "complete-milestone-artifact-db-mismatch" };
1029
1013
  }
1030
- ctx.ui.notify(`Stuck on ${unitType} ${unitId} (${stuckSignal.reason}). Invalidating caches and retrying.`, "warning");
1031
- deps.invalidateAllCaches();
1032
- }
1033
- else {
1034
- // Level 2: hard stop — genuinely stuck
1035
- deps.invalidateAllCaches();
1036
- const artifactExists = verifyExpectedArtifact(unitType, unitId, s.basePath);
1037
- if (artifactExists && unitType !== "complete-milestone") {
1038
- debugLog("autoLoop", {
1039
- phase: "stuck-recovery",
1040
- level: 2,
1041
- action: "artifact-found",
1042
- });
1043
- const recoveryDb = refreshRecoveryDbForArtifact(unitType, unitId);
1044
- if (recoveryDb.ok) {
1045
- ctx.ui.notify(`Stuck recovery: artifact for ${unitType} ${unitId} found on disk after cache invalidation. Continuing.`, "info");
1046
- loopState.recentUnits.length = 0;
1047
- loopState.stuckRecoveryAttempts = 0;
1048
- return { action: "continue" };
1049
- }
1014
+ debugLog("autoLoop", {
1015
+ phase: "stuck-recovery",
1016
+ level: 1,
1017
+ action: "artifact-found",
1018
+ });
1019
+ const recoveryDb = refreshRecoveryDbForArtifact(unitType, unitId);
1020
+ if (!recoveryDb.ok) {
1050
1021
  ctx.ui.notify(recoveryDb.fatal
1051
1022
  ? `${recoveryDb.message} Pausing auto-mode for manual recovery.`
1052
- : `${recoveryDb.message} Stopping for manual recovery.`, "warning");
1023
+ : `${recoveryDb.message} Keeping stuck state for retry.`, "warning");
1053
1024
  if (recoveryDb.fatal) {
1054
1025
  await deps.pauseAuto(ctx, pi);
1055
1026
  return { action: "break", reason: recoveryDb.reason };
1056
1027
  }
1028
+ return { action: "continue" };
1057
1029
  }
1058
- debugLog("autoLoop", {
1059
- phase: "stuck-detected",
1060
- unitType,
1061
- unitId,
1062
- reason: stuckSignal.reason,
1063
- });
1064
- const stuckDiag = diagnoseExpectedArtifact(unitType, unitId, s.basePath);
1065
- const stuckRemediation = buildLoopRemediationSteps(unitType, unitId, s.basePath);
1066
- const stuckParts = [`Stuck on ${unitType} ${unitId} — ${stuckSignal.reason}.`];
1067
- if (stuckDiag)
1068
- stuckParts.push(`Expected: ${stuckDiag}`);
1069
- if (stuckRemediation)
1070
- stuckParts.push(`To recover:\n${stuckRemediation}`);
1071
- ctx.ui.notify(stuckParts.join(" "), "error");
1072
- await deps.stopAuto(ctx, pi, `Stuck: ${stuckSignal.reason}`);
1073
- return { action: "break", reason: "stuck-detected" };
1030
+ ctx.ui.notify(`Stuck recovery: artifact for ${unitType} ${unitId} found on disk. Invalidating caches.`, "info");
1031
+ deps.invalidateAllCaches();
1032
+ loopState.recentUnits.length = 0;
1033
+ loopState.stuckRecoveryAttempts = 0;
1034
+ return { action: "continue" };
1074
1035
  }
1036
+ ctx.ui.notify(`Stuck on ${unitType} ${unitId} (${stuckSignal.reason}). Invalidating caches and retrying.`, "warning");
1037
+ deps.invalidateAllCaches();
1075
1038
  }
1076
1039
  else {
1077
- // Progress detectedreset recovery counter
1078
- if (loopState.stuckRecoveryAttempts > 0) {
1040
+ // Level 2: hard stop genuinely stuck
1041
+ deps.invalidateAllCaches();
1042
+ const artifactExists = verifyExpectedArtifact(unitType, unitId, s.basePath);
1043
+ if (artifactExists && unitType !== "complete-milestone") {
1079
1044
  debugLog("autoLoop", {
1080
- phase: "stuck-counter-reset",
1081
- from: loopState.recentUnits[loopState.recentUnits.length - 2]?.key ?? "",
1082
- to: derivedKey,
1045
+ phase: "stuck-recovery",
1046
+ level: 2,
1047
+ action: "artifact-found",
1083
1048
  });
1084
- loopState.stuckRecoveryAttempts = 0;
1049
+ const recoveryDb = refreshRecoveryDbForArtifact(unitType, unitId);
1050
+ if (recoveryDb.ok) {
1051
+ ctx.ui.notify(`Stuck recovery: artifact for ${unitType} ${unitId} found on disk after cache invalidation. Continuing.`, "info");
1052
+ loopState.recentUnits.length = 0;
1053
+ loopState.stuckRecoveryAttempts = 0;
1054
+ return { action: "continue" };
1055
+ }
1056
+ ctx.ui.notify(recoveryDb.fatal
1057
+ ? `${recoveryDb.message} Pausing auto-mode for manual recovery.`
1058
+ : `${recoveryDb.message} Stopping for manual recovery.`, "warning");
1059
+ if (recoveryDb.fatal) {
1060
+ await deps.pauseAuto(ctx, pi);
1061
+ return { action: "break", reason: recoveryDb.reason };
1062
+ }
1085
1063
  }
1064
+ debugLog("autoLoop", {
1065
+ phase: "stuck-detected",
1066
+ unitType,
1067
+ unitId,
1068
+ reason: stuckSignal.reason,
1069
+ });
1070
+ const stuckDiag = diagnoseExpectedArtifact(unitType, unitId, s.basePath);
1071
+ const stuckRemediation = buildLoopRemediationSteps(unitType, unitId, s.basePath);
1072
+ const stuckParts = [`Stuck on ${unitType} ${unitId} — ${stuckSignal.reason}.`];
1073
+ if (stuckDiag)
1074
+ stuckParts.push(`Expected: ${stuckDiag}`);
1075
+ if (stuckRemediation)
1076
+ stuckParts.push(`To recover:\n${stuckRemediation}`);
1077
+ ctx.ui.notify(stuckParts.join(" "), "error");
1078
+ await deps.stopAuto(ctx, pi, `Stuck: ${stuckSignal.reason}`);
1079
+ return { action: "break", reason: "stuck-detected" };
1080
+ }
1081
+ }
1082
+ else {
1083
+ // Progress detected — reset recovery counter
1084
+ if (loopState.stuckRecoveryAttempts > 0) {
1085
+ debugLog("autoLoop", {
1086
+ phase: "stuck-counter-reset",
1087
+ from: loopState.recentUnits[loopState.recentUnits.length - 2]?.key ?? "",
1088
+ to: derivedKey,
1089
+ });
1090
+ loopState.stuckRecoveryAttempts = 0;
1086
1091
  }
1087
1092
  }
1088
1093
  return {
@@ -14,13 +14,11 @@ import { clearParseCache } from "./files.js";
14
14
  import { parseRoadmap as parseLegacyRoadmap, parsePlan as parseLegacyPlan } from "./parsers-legacy.js";
15
15
  import { isDbAvailable, getTask, getSlice, getSliceTasks, getPendingGates, updateTaskStatus, updateSliceStatus, insertSlice, getMilestone, refreshOpenDatabaseFromDisk, getCompletedMilestoneTaskFileHints, getMilestoneCommitAttributionShas, recordMilestoneCommitAttribution } from "./gsd-db.js";
16
16
  import { isValidationTerminal } from "./state.js";
17
- import { getErrorMessage } from "./error-utils.js";
18
- import { logWarning, logError } from "./workflow-logger.js";
17
+ import { logWarning } from "./workflow-logger.js";
19
18
  import { readIntegrationBranch } from "./git-service.js";
20
19
  import { isClosedStatus } from "./status-guards.js";
21
- import { nativeConflictFiles, nativeCommit, nativeCheckoutTheirs, nativeAddPaths, nativeMergeAbort, nativeRebaseAbort, nativeResetHard, } from "./native-git-bridge.js";
22
20
  import { resolveSlicePath, resolveSliceFile, resolveTasksDir, resolveTaskFiles, relMilestoneFile, relSliceFile, buildSliceFileName, resolveMilestoneFile, clearPathCache, resolveGsdRootFile, } from "./paths.js";
23
- import { existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync, } from "node:fs";
21
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, } from "node:fs";
24
22
  import { execFileSync } from "node:child_process";
25
23
  import { dirname, join } from "node:path";
26
24
  import { resolveExpectedArtifactPath, diagnoseExpectedArtifact, } from "./auto-artifact-paths.js";
@@ -932,183 +930,10 @@ export function writeBlockerPlaceholder(unitType, unitId, base, reason) {
932
930
  return diagnoseExpectedArtifact(unitType, unitId, base);
933
931
  }
934
932
  // ─── Merge State Reconciliation ───────────────────────────────────────────────
935
- /**
936
- * Best-effort abort of a pending merge/squash and hard-reset to HEAD.
937
- * Handles both real merges (MERGE_HEAD) and squash merges (SQUASH_MSG).
938
- */
939
- function abortAndResetMerge(basePath, hasMergeHead, squashMsgPath) {
940
- if (hasMergeHead) {
941
- try {
942
- nativeMergeAbort(basePath);
943
- }
944
- catch (err) {
945
- /* best-effort */
946
- logWarning("recovery", `git merge-abort failed: ${err instanceof Error ? err.message : String(err)}`);
947
- }
948
- }
949
- else if (squashMsgPath) {
950
- try {
951
- unlinkSync(squashMsgPath);
952
- }
953
- catch (err) {
954
- /* best-effort */
955
- logWarning("recovery", `file unlink failed: ${err instanceof Error ? err.message : String(err)}`);
956
- }
957
- }
958
- try {
959
- nativeResetHard(basePath);
960
- }
961
- catch (err) {
962
- /* best-effort */
963
- logError("recovery", `git reset failed: ${err instanceof Error ? err.message : String(err)}`);
964
- }
965
- }
966
- /**
967
- * Detect and abort other in-progress git operations left behind by a SIGKILL'd
968
- * worker (rebase, cherry-pick, revert). Without this, a killed worker mid-rebase
969
- * leaves `.git/rebase-merge/` or `.git/CHERRY_PICK_HEAD` and the worktree is
970
- * wedged until the user manually runs the matching `--abort`.
971
- *
972
- * Called before merge-state reconciliation because these states block any
973
- * subsequent merge/commit operation. (Issue #4980 HIGH-7)
974
- */
975
- function reconcileOtherInProgressGitOps(basePath, ctx) {
976
- const gitDir = join(basePath, ".git");
977
- const states = [
978
- {
979
- label: "rebase",
980
- indicators: [join(gitDir, "rebase-merge"), join(gitDir, "rebase-apply")],
981
- abort: () => nativeRebaseAbort(basePath),
982
- },
983
- {
984
- label: "cherry-pick",
985
- indicators: [join(gitDir, "CHERRY_PICK_HEAD")],
986
- abort: () => {
987
- // No native helper; fall back to git CLI.
988
- try {
989
- execFileSync("git", ["cherry-pick", "--abort"], {
990
- cwd: basePath, stdio: ["ignore", "pipe", "pipe"], encoding: "utf-8",
991
- });
992
- }
993
- catch (err) {
994
- logWarning("recovery", `cherry-pick --abort failed: ${getErrorMessage(err)}`);
995
- }
996
- },
997
- },
998
- {
999
- label: "revert",
1000
- indicators: [join(gitDir, "REVERT_HEAD")],
1001
- abort: () => {
1002
- try {
1003
- execFileSync("git", ["revert", "--abort"], {
1004
- cwd: basePath, stdio: ["ignore", "pipe", "pipe"], encoding: "utf-8",
1005
- });
1006
- }
1007
- catch (err) {
1008
- logWarning("recovery", `revert --abort failed: ${getErrorMessage(err)}`);
1009
- }
1010
- },
1011
- },
1012
- ];
1013
- let reconciled = false;
1014
- for (const s of states) {
1015
- const present = s.indicators.some((p) => existsSync(p));
1016
- if (!present)
1017
- continue;
1018
- try {
1019
- s.abort();
1020
- ctx.ui.notify(`Detected leftover ${s.label} state from prior session — aborted.`, "warning");
1021
- reconciled = true;
1022
- }
1023
- catch (err) {
1024
- logError("recovery", `${s.label} abort failed: ${getErrorMessage(err)}`);
1025
- ctx.ui.notify(`Detected leftover ${s.label} state but auto-abort failed. ` +
1026
- `Run \`git ${s.label} --abort\` manually before retrying.`, "error");
1027
- return "blocked";
1028
- }
1029
- }
1030
- return reconciled ? "reconciled" : "clean";
1031
- }
1032
- /**
1033
- * Detect leftover merge state from a prior session and reconcile it.
1034
- * If MERGE_HEAD or SQUASH_MSG exists, check whether conflicts are resolved.
1035
- * If resolved: finalize the commit. If only .gsd conflicts remain: auto-resolve.
1036
- * If code conflicts remain: fail safe without modifying the worktree.
1037
- */
1038
- export function reconcileMergeState(basePath, ctx) {
1039
- // First, abort any rebase/cherry-pick/revert left over from a SIGKILL'd
1040
- // worker. Doing this before the merge-state check unblocks any merge that
1041
- // would otherwise refuse with "you have unfinished operation". (HIGH-7)
1042
- const otherOpsResult = reconcileOtherInProgressGitOps(basePath, ctx);
1043
- if (otherOpsResult === "blocked")
1044
- return "blocked";
1045
- const mergeHeadPath = join(basePath, ".git", "MERGE_HEAD");
1046
- const squashMsgPath = join(basePath, ".git", "SQUASH_MSG");
1047
- const hasMergeHead = existsSync(mergeHeadPath);
1048
- const hasSquashMsg = existsSync(squashMsgPath);
1049
- if (!hasMergeHead && !hasSquashMsg) {
1050
- // If we cleaned up another op type, return "reconciled" so the caller
1051
- // re-derives state from a known-good baseline.
1052
- return otherOpsResult === "reconciled" ? "reconciled" : "clean";
1053
- }
1054
- const conflictedFiles = nativeConflictFiles(basePath);
1055
- if (conflictedFiles.length === 0) {
1056
- // All conflicts resolved — finalize the merge/squash commit
1057
- try {
1058
- const commitSha = nativeCommit(basePath, "chore(gsd): reconcile merge state");
1059
- if (commitSha) {
1060
- const mode = hasMergeHead ? "merge" : "squash commit";
1061
- ctx.ui.notify(`Finalized leftover ${mode} from prior session.`, "info");
1062
- }
1063
- else {
1064
- ctx.ui.notify("No new commit needed for leftover merge/squash state — already committed.", "info");
1065
- }
1066
- }
1067
- catch (err) {
1068
- const errorMessage = getErrorMessage(err);
1069
- ctx.ui.notify(`Failed to finalize leftover merge/squash commit: ${errorMessage}`, "error");
1070
- return "blocked";
1071
- }
1072
- }
1073
- else {
1074
- // Still conflicted — try auto-resolving .gsd/ state file conflicts (#530)
1075
- const gsdConflicts = conflictedFiles.filter((f) => f.startsWith(".gsd/"));
1076
- const codeConflicts = conflictedFiles.filter((f) => !f.startsWith(".gsd/"));
1077
- if (gsdConflicts.length > 0 && codeConflicts.length === 0) {
1078
- // All conflicts are in .gsd/ state files — auto-resolve by accepting theirs
1079
- let resolved = true;
1080
- try {
1081
- nativeCheckoutTheirs(basePath, gsdConflicts);
1082
- nativeAddPaths(basePath, gsdConflicts);
1083
- }
1084
- catch (e) {
1085
- logError("recovery", `auto-resolve .gsd/ conflicts failed: ${e.message}`);
1086
- resolved = false;
1087
- }
1088
- if (resolved) {
1089
- try {
1090
- nativeCommit(basePath, "chore: auto-resolve .gsd/ state file conflicts");
1091
- ctx.ui.notify(`Auto-resolved ${gsdConflicts.length} .gsd/ state file conflict(s) from prior merge.`, "info");
1092
- }
1093
- catch (e) {
1094
- logError("recovery", `auto-commit .gsd/ conflict resolution failed: ${e.message}`);
1095
- resolved = false;
1096
- }
1097
- }
1098
- if (!resolved) {
1099
- abortAndResetMerge(basePath, hasMergeHead, squashMsgPath);
1100
- ctx.ui.notify("Detected leftover merge state — auto-resolve failed, cleaned up. Re-deriving state.", "warning");
1101
- }
1102
- }
1103
- else {
1104
- // Code conflicts present — fail safe and preserve any manual resolution
1105
- // work instead of discarding it with merge --abort/reset --hard.
1106
- ctx.ui.notify("Detected leftover merge state with unresolved code conflicts. Auto-mode will pause without modifying the worktree so manual conflict resolution is preserved.", "error");
1107
- return "blocked";
1108
- }
1109
- }
1110
- return "reconciled";
1111
- }
933
+ // Body relocated to state-reconciliation/drift/merge-state.ts (ADR-017 #5701).
934
+ // Re-exported here for backward compatibility with existing call sites:
935
+ // auto.ts, auto/loop-deps.ts, tests/integration/auto-recovery.test.ts.
936
+ export { reconcileMergeState, } from "./state-reconciliation/drift/merge-state.js";
1112
937
  // ─── Loop Remediation ─────────────────────────────────────────────────────────
1113
938
  /**
1114
939
  * Build concrete, manual remediation steps for a loop-detected unit failure.
@@ -1399,16 +1399,19 @@ export function createWiredAutoOrchestrationModule(ctx, _pi, dispatchBasePath, r
1399
1399
  stateReconciliation: {
1400
1400
  async reconcileBeforeDispatch() {
1401
1401
  const result = await reconcileBeforeDispatch(dispatchBasePath);
1402
- if (!result.ok) {
1402
+ if (result.blockers.length > 0) {
1403
1403
  return {
1404
1404
  ok: false,
1405
- reason: result.reason,
1405
+ reason: result.blockers[0],
1406
1406
  stateSnapshot: result.stateSnapshot,
1407
1407
  };
1408
1408
  }
1409
+ const repairedKinds = result.repaired.map((d) => d.kind);
1409
1410
  return {
1410
1411
  ok: true,
1411
- reason: result.repaired.join(", "),
1412
+ reason: repairedKinds.length > 0
1413
+ ? `repaired: ${repairedKinds.join(", ")}`
1414
+ : "clean",
1412
1415
  stateSnapshot: result.stateSnapshot,
1413
1416
  };
1414
1417
  },
@@ -116,8 +116,7 @@ export function isBareClaudeCodeStreamAbortPlaceholder(lastMsg) {
116
116
  * Claude Code abort markers are intentionally ignored when the abort fires
117
117
  * while the session-switch is in flight: the abort is the expected side-effect
118
118
  * of the transition, not a user signal. Other branches (genuine `stopReason
119
- * === "aborted"` with diagnostic content/errorMessage) preserve the prior
120
- * behavior.
119
+ * === "aborted"` with explicit errorMessage) preserve the prior behavior.
121
120
  */
122
121
  export function _handleSessionSwitchAgentEnd(lastMsg, resolveCancelled) {
123
122
  if (!lastMsg || typeof lastMsg !== "object")
@@ -136,10 +135,8 @@ export function _handleSessionSwitchAgentEnd(lastMsg, resolveCancelled) {
136
135
  return;
137
136
  }
138
137
  if (m.stopReason === "aborted") {
139
- const content = m.content;
140
- const hasEmptyContent = Array.isArray(content) && content.length === 0;
141
138
  const hasErrorMessage = !!m.errorMessage;
142
- if (!hasEmptyContent || hasErrorMessage) {
139
+ if (hasErrorMessage) {
143
140
  resolveCancelled(_buildAbortedPauseContext(m));
144
141
  }
145
142
  }
@@ -2,6 +2,7 @@ import { getOrchestratorState, getWorkerStatuses, isParallelActive, pauseWorker,
2
2
  import { formatEligibilityReport } from "../../parallel-eligibility.js";
3
3
  import { formatMergeResults, mergeAllCompleted, mergeCompletedMilestone } from "../../parallel-merge.js";
4
4
  import { loadEffectiveGSDPreferences, resolveParallelConfig } from "../../preferences.js";
5
+ import { reconcileBeforeSpawn } from "../../state-reconciliation.js";
5
6
  import { projectRoot } from "../context.js";
6
7
  function emitParallelMessage(pi, content) {
7
8
  pi.sendMessage({ customType: "gsd-parallel", content, display: true });
@@ -26,6 +27,14 @@ export async function handleParallelCommand(trimmed, _ctx, pi) {
26
27
  emitParallelMessage(pi, `${report}\n\nNo milestones are eligible for parallel execution.`);
27
28
  return true;
28
29
  }
30
+ // ADR-017 #5707: reconcile before spawning so workers don't independently
31
+ // race on the same drift. Failures abort the spawn with an actionable
32
+ // user-visible message.
33
+ const gate = await reconcileBeforeSpawn(root);
34
+ if (!gate.ok) {
35
+ emitParallelMessage(pi, `${report}\n\nParallel orchestration aborted before spawn — ${gate.reason}`);
36
+ return true;
37
+ }
29
38
  const result = await startParallel(root, candidates.eligible.map((candidate) => candidate.milestoneId), loaded?.preferences);
30
39
  const lines = ["Parallel orchestration started.", `Workers: ${result.started.join(", ")}`];
31
40
  if (result.errors.length > 0) {
@@ -977,32 +977,16 @@ export function setSliceSketchFlag(milestoneId, sliceId, isSketch) {
977
977
  currentDb.prepare(`UPDATE slices SET is_sketch = :is_sketch WHERE milestone_id = :mid AND id = :sid`).run({ ":is_sketch": isSketch ? 1 : 0, ":mid": milestoneId, ":sid": sliceId });
978
978
  }
979
979
  /**
980
- * ADR-011 auto-heal: reconcile stale is_sketch=1 rows whose PLAN already exists.
981
- *
982
- * Callers pass a predicate that resolves whether a plan file exists for a slice.
983
- * The predicate MUST use the canonical path resolver (`resolveSliceFile`, etc.)
984
- * to keep path logic in one place — do not hand-roll the path inside the callback.
985
- *
986
- * Recovers from two scenarios:
987
- * 1. Crash between `gsd_plan_slice` write and the sketch flag flip.
988
- * 2. Flag-OFF downgrade path: when `progressive_planning` is off, the dispatch
989
- * rule routes sketch slices to plan-slice, which writes PLAN.md but leaves
990
- * `is_sketch=1` — the next state derivation auto-heals it to 0 here.
991
- *
992
- * Not aggressive in practice: PLAN.md is only written via the DB-backed
993
- * `gsd_plan_slice` tool (which also inserts tasks), so a "stale PLAN.md with
994
- * is_sketch=1" is extremely unlikely to indicate anything other than the two
995
- * recovery scenarios above.
980
+ * ADR-017 raw primitive: returns slice IDs in a milestone whose is_sketch flag
981
+ * is still 1. The stale-sketch-flag drift handler at
982
+ * `state-reconciliation/drift/sketch-flag.ts` composes this with PLAN.md
983
+ * existence checks to detect drift, then writes via `setSliceSketchFlag`.
996
984
  */
997
- export function autoHealSketchFlags(milestoneId, hasPlanFile) {
985
+ export function getSketchedSliceIds(milestoneId) {
998
986
  if (!currentDb)
999
- return;
987
+ return [];
1000
988
  const rows = currentDb.prepare(`SELECT id FROM slices WHERE milestone_id = :mid AND is_sketch = 1`).all({ ":mid": milestoneId });
1001
- for (const row of rows) {
1002
- if (hasPlanFile(row.id)) {
1003
- setSliceSketchFlag(milestoneId, row.id, false);
1004
- }
1005
- }
989
+ return rows.map((r) => r.id);
1006
990
  }
1007
991
  export function upsertSlicePlanning(milestoneId, sliceId, planning) {
1008
992
  if (!currentDb)