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
@@ -7,7 +7,6 @@
7
7
  * globals or AutoContext dependency.
8
8
  */
9
9
 
10
- import type { ExtensionContext } from "@gsd/pi-coding-agent";
11
10
  import { parseUnitId } from "./unit-id.js";
12
11
  import { MILESTONE_ID_RE } from "./milestone-ids.js";
13
12
  import { appendEvent } from "./workflow-events.js";
@@ -20,15 +19,6 @@ import { getErrorMessage } from "./error-utils.js";
20
19
  import { logWarning, logError } from "./workflow-logger.js";
21
20
  import { readIntegrationBranch } from "./git-service.js";
22
21
  import { isClosedStatus } from "./status-guards.js";
23
- import {
24
- nativeConflictFiles,
25
- nativeCommit,
26
- nativeCheckoutTheirs,
27
- nativeAddPaths,
28
- nativeMergeAbort,
29
- nativeRebaseAbort,
30
- nativeResetHard,
31
- } from "./native-git-bridge.js";
32
22
  import {
33
23
  resolveSlicePath,
34
24
  resolveSliceFile,
@@ -46,7 +36,6 @@ import {
46
36
  mkdirSync,
47
37
  readFileSync,
48
38
  writeFileSync,
49
- unlinkSync,
50
39
  } from "node:fs";
51
40
  import { execFileSync } from "node:child_process";
52
41
  import { dirname, join } from "node:path";
@@ -1001,205 +990,14 @@ export function writeBlockerPlaceholder(
1001
990
  }
1002
991
 
1003
992
  // ─── Merge State Reconciliation ───────────────────────────────────────────────
993
+ // Body relocated to state-reconciliation/drift/merge-state.ts (ADR-017 #5701).
994
+ // Re-exported here for backward compatibility with existing call sites:
995
+ // auto.ts, auto/loop-deps.ts, tests/integration/auto-recovery.test.ts.
1004
996
 
1005
- /**
1006
- * Best-effort abort of a pending merge/squash and hard-reset to HEAD.
1007
- * Handles both real merges (MERGE_HEAD) and squash merges (SQUASH_MSG).
1008
- */
1009
- function abortAndResetMerge(
1010
- basePath: string,
1011
- hasMergeHead: boolean,
1012
- squashMsgPath: string,
1013
- ): void {
1014
- if (hasMergeHead) {
1015
- try {
1016
- nativeMergeAbort(basePath);
1017
- } catch (err) {
1018
- /* best-effort */
1019
- logWarning("recovery", `git merge-abort failed: ${err instanceof Error ? err.message : String(err)}`);
1020
- }
1021
- } else if (squashMsgPath) {
1022
- try {
1023
- unlinkSync(squashMsgPath);
1024
- } catch (err) {
1025
- /* best-effort */
1026
- logWarning("recovery", `file unlink failed: ${err instanceof Error ? err.message : String(err)}`);
1027
- }
1028
- }
1029
- try {
1030
- nativeResetHard(basePath);
1031
- } catch (err) {
1032
- /* best-effort */
1033
- logError("recovery", `git reset failed: ${err instanceof Error ? err.message : String(err)}`);
1034
- }
1035
- }
1036
-
1037
- export type MergeReconcileResult = "clean" | "reconciled" | "blocked";
1038
-
1039
- /**
1040
- * Detect and abort other in-progress git operations left behind by a SIGKILL'd
1041
- * worker (rebase, cherry-pick, revert). Without this, a killed worker mid-rebase
1042
- * leaves `.git/rebase-merge/` or `.git/CHERRY_PICK_HEAD` and the worktree is
1043
- * wedged until the user manually runs the matching `--abort`.
1044
- *
1045
- * Called before merge-state reconciliation because these states block any
1046
- * subsequent merge/commit operation. (Issue #4980 HIGH-7)
1047
- */
1048
- function reconcileOtherInProgressGitOps(
1049
- basePath: string,
1050
- ctx: ExtensionContext,
1051
- ): "clean" | "reconciled" | "blocked" {
1052
- const gitDir = join(basePath, ".git");
1053
- const states: Array<{
1054
- label: string;
1055
- indicators: string[];
1056
- abort: () => void;
1057
- }> = [
1058
- {
1059
- label: "rebase",
1060
- indicators: [join(gitDir, "rebase-merge"), join(gitDir, "rebase-apply")],
1061
- abort: () => nativeRebaseAbort(basePath),
1062
- },
1063
- {
1064
- label: "cherry-pick",
1065
- indicators: [join(gitDir, "CHERRY_PICK_HEAD")],
1066
- abort: () => {
1067
- // No native helper; fall back to git CLI.
1068
- try {
1069
- execFileSync("git", ["cherry-pick", "--abort"], {
1070
- cwd: basePath, stdio: ["ignore", "pipe", "pipe"], encoding: "utf-8",
1071
- });
1072
- } catch (err) { logWarning("recovery", `cherry-pick --abort failed: ${getErrorMessage(err)}`); }
1073
- },
1074
- },
1075
- {
1076
- label: "revert",
1077
- indicators: [join(gitDir, "REVERT_HEAD")],
1078
- abort: () => {
1079
- try {
1080
- execFileSync("git", ["revert", "--abort"], {
1081
- cwd: basePath, stdio: ["ignore", "pipe", "pipe"], encoding: "utf-8",
1082
- });
1083
- } catch (err) { logWarning("recovery", `revert --abort failed: ${getErrorMessage(err)}`); }
1084
- },
1085
- },
1086
- ];
1087
-
1088
- let reconciled = false;
1089
- for (const s of states) {
1090
- const present = s.indicators.some((p) => existsSync(p));
1091
- if (!present) continue;
1092
- try {
1093
- s.abort();
1094
- ctx.ui.notify(
1095
- `Detected leftover ${s.label} state from prior session — aborted.`,
1096
- "warning",
1097
- );
1098
- reconciled = true;
1099
- } catch (err) {
1100
- logError("recovery", `${s.label} abort failed: ${getErrorMessage(err)}`);
1101
- ctx.ui.notify(
1102
- `Detected leftover ${s.label} state but auto-abort failed. ` +
1103
- `Run \`git ${s.label} --abort\` manually before retrying.`,
1104
- "error",
1105
- );
1106
- return "blocked";
1107
- }
1108
- }
1109
- return reconciled ? "reconciled" : "clean";
1110
- }
1111
-
1112
- /**
1113
- * Detect leftover merge state from a prior session and reconcile it.
1114
- * If MERGE_HEAD or SQUASH_MSG exists, check whether conflicts are resolved.
1115
- * If resolved: finalize the commit. If only .gsd conflicts remain: auto-resolve.
1116
- * If code conflicts remain: fail safe without modifying the worktree.
1117
- */
1118
- export function reconcileMergeState(
1119
- basePath: string,
1120
- ctx: ExtensionContext,
1121
- ): MergeReconcileResult {
1122
- // First, abort any rebase/cherry-pick/revert left over from a SIGKILL'd
1123
- // worker. Doing this before the merge-state check unblocks any merge that
1124
- // would otherwise refuse with "you have unfinished operation". (HIGH-7)
1125
- const otherOpsResult = reconcileOtherInProgressGitOps(basePath, ctx);
1126
- if (otherOpsResult === "blocked") return "blocked";
1127
-
1128
- const mergeHeadPath = join(basePath, ".git", "MERGE_HEAD");
1129
- const squashMsgPath = join(basePath, ".git", "SQUASH_MSG");
1130
- const hasMergeHead = existsSync(mergeHeadPath);
1131
- const hasSquashMsg = existsSync(squashMsgPath);
1132
- if (!hasMergeHead && !hasSquashMsg) {
1133
- // If we cleaned up another op type, return "reconciled" so the caller
1134
- // re-derives state from a known-good baseline.
1135
- return otherOpsResult === "reconciled" ? "reconciled" : "clean";
1136
- }
1137
-
1138
- const conflictedFiles = nativeConflictFiles(basePath);
1139
- if (conflictedFiles.length === 0) {
1140
- // All conflicts resolved — finalize the merge/squash commit
1141
- try {
1142
- const commitSha = nativeCommit(basePath, "chore(gsd): reconcile merge state");
1143
- if (commitSha) {
1144
- const mode = hasMergeHead ? "merge" : "squash commit";
1145
- ctx.ui.notify(`Finalized leftover ${mode} from prior session.`, "info");
1146
- } else {
1147
- ctx.ui.notify("No new commit needed for leftover merge/squash state — already committed.", "info");
1148
- }
1149
- } catch (err) {
1150
- const errorMessage = getErrorMessage(err);
1151
- ctx.ui.notify(`Failed to finalize leftover merge/squash commit: ${errorMessage}`, "error");
1152
- return "blocked";
1153
- }
1154
- } else {
1155
- // Still conflicted — try auto-resolving .gsd/ state file conflicts (#530)
1156
- const gsdConflicts = conflictedFiles.filter((f) => f.startsWith(".gsd/"));
1157
- const codeConflicts = conflictedFiles.filter((f) => !f.startsWith(".gsd/"));
1158
-
1159
- if (gsdConflicts.length > 0 && codeConflicts.length === 0) {
1160
- // All conflicts are in .gsd/ state files — auto-resolve by accepting theirs
1161
- let resolved = true;
1162
- try {
1163
- nativeCheckoutTheirs(basePath, gsdConflicts);
1164
- nativeAddPaths(basePath, gsdConflicts);
1165
- } catch (e) {
1166
- logError("recovery", `auto-resolve .gsd/ conflicts failed: ${(e as Error).message}`);
1167
- resolved = false;
1168
- }
1169
- if (resolved) {
1170
- try {
1171
- nativeCommit(
1172
- basePath,
1173
- "chore: auto-resolve .gsd/ state file conflicts",
1174
- );
1175
- ctx.ui.notify(
1176
- `Auto-resolved ${gsdConflicts.length} .gsd/ state file conflict(s) from prior merge.`,
1177
- "info",
1178
- );
1179
- } catch (e) {
1180
- logError("recovery", `auto-commit .gsd/ conflict resolution failed: ${(e as Error).message}`);
1181
- resolved = false;
1182
- }
1183
- }
1184
- if (!resolved) {
1185
- abortAndResetMerge(basePath, hasMergeHead, squashMsgPath);
1186
- ctx.ui.notify(
1187
- "Detected leftover merge state — auto-resolve failed, cleaned up. Re-deriving state.",
1188
- "warning",
1189
- );
1190
- }
1191
- } else {
1192
- // Code conflicts present — fail safe and preserve any manual resolution
1193
- // work instead of discarding it with merge --abort/reset --hard.
1194
- ctx.ui.notify(
1195
- "Detected leftover merge state with unresolved code conflicts. Auto-mode will pause without modifying the worktree so manual conflict resolution is preserved.",
1196
- "error",
1197
- );
1198
- return "blocked";
1199
- }
1200
- }
1201
- return "reconciled";
1202
- }
997
+ export {
998
+ reconcileMergeState,
999
+ type MergeReconcileResult,
1000
+ } from "./state-reconciliation/drift/merge-state.js";
1203
1001
 
1204
1002
  // ─── Loop Remediation ─────────────────────────────────────────────────────────
1205
1003
 
@@ -1812,16 +1812,20 @@ export function createWiredAutoOrchestrationModule(
1812
1812
  stateReconciliation: {
1813
1813
  async reconcileBeforeDispatch() {
1814
1814
  const result = await reconcileBeforeDispatch(dispatchBasePath);
1815
- if (!result.ok) {
1815
+ if (result.blockers.length > 0) {
1816
1816
  return {
1817
1817
  ok: false,
1818
- reason: result.reason,
1818
+ reason: result.blockers[0],
1819
1819
  stateSnapshot: result.stateSnapshot,
1820
1820
  };
1821
1821
  }
1822
+ const repairedKinds = result.repaired.map((d) => d.kind);
1822
1823
  return {
1823
1824
  ok: true,
1824
- reason: result.repaired.join(", "),
1825
+ reason:
1826
+ repairedKinds.length > 0
1827
+ ? `repaired: ${repairedKinds.join(", ")}`
1828
+ : "clean",
1825
1829
  stateSnapshot: result.stateSnapshot,
1826
1830
  };
1827
1831
  },
@@ -153,8 +153,7 @@ export function isBareClaudeCodeStreamAbortPlaceholder(lastMsg: unknown): boolea
153
153
  * Claude Code abort markers are intentionally ignored when the abort fires
154
154
  * while the session-switch is in flight: the abort is the expected side-effect
155
155
  * of the transition, not a user signal. Other branches (genuine `stopReason
156
- * === "aborted"` with diagnostic content/errorMessage) preserve the prior
157
- * behavior.
156
+ * === "aborted"` with explicit errorMessage) preserve the prior behavior.
158
157
  */
159
158
  export function _handleSessionSwitchAgentEnd(
160
159
  lastMsg: unknown,
@@ -178,10 +177,8 @@ export function _handleSessionSwitchAgentEnd(
178
177
  }
179
178
 
180
179
  if (m.stopReason === "aborted") {
181
- const content = m.content;
182
- const hasEmptyContent = Array.isArray(content) && content.length === 0;
183
180
  const hasErrorMessage = !!m.errorMessage;
184
- if (!hasEmptyContent || hasErrorMessage) {
181
+ if (hasErrorMessage) {
185
182
  resolveCancelled(_buildAbortedPauseContext(m as { errorMessage?: unknown }));
186
183
  }
187
184
  }
@@ -14,6 +14,7 @@ import {
14
14
  import { formatEligibilityReport } from "../../parallel-eligibility.js";
15
15
  import { formatMergeResults, mergeAllCompleted, mergeCompletedMilestone } from "../../parallel-merge.js";
16
16
  import { loadEffectiveGSDPreferences, resolveParallelConfig } from "../../preferences.js";
17
+ import { reconcileBeforeSpawn } from "../../state-reconciliation.js";
17
18
  import { projectRoot } from "../context.js";
18
19
  function emitParallelMessage(pi: ExtensionAPI, content: string): void {
19
20
  pi.sendMessage({ customType: "gsd-parallel", content, display: true });
@@ -40,6 +41,17 @@ export async function handleParallelCommand(trimmed: string, _ctx: ExtensionComm
40
41
  emitParallelMessage(pi, `${report}\n\nNo milestones are eligible for parallel execution.`);
41
42
  return true;
42
43
  }
44
+ // ADR-017 #5707: reconcile before spawning so workers don't independently
45
+ // race on the same drift. Failures abort the spawn with an actionable
46
+ // user-visible message.
47
+ const gate = await reconcileBeforeSpawn(root);
48
+ if (!gate.ok) {
49
+ emitParallelMessage(
50
+ pi,
51
+ `${report}\n\nParallel orchestration aborted before spawn — ${gate.reason}`,
52
+ );
53
+ return true;
54
+ }
43
55
  const result = await startParallel(
44
56
  root,
45
57
  candidates.eligible.map((candidate) => candidate.milestoneId),
@@ -1136,33 +1136,17 @@ export function setSliceSketchFlag(milestoneId: string, sliceId: string, isSketc
1136
1136
  }
1137
1137
 
1138
1138
  /**
1139
- * ADR-011 auto-heal: reconcile stale is_sketch=1 rows whose PLAN already exists.
1140
- *
1141
- * Callers pass a predicate that resolves whether a plan file exists for a slice.
1142
- * The predicate MUST use the canonical path resolver (`resolveSliceFile`, etc.)
1143
- * to keep path logic in one place — do not hand-roll the path inside the callback.
1144
- *
1145
- * Recovers from two scenarios:
1146
- * 1. Crash between `gsd_plan_slice` write and the sketch flag flip.
1147
- * 2. Flag-OFF downgrade path: when `progressive_planning` is off, the dispatch
1148
- * rule routes sketch slices to plan-slice, which writes PLAN.md but leaves
1149
- * `is_sketch=1` — the next state derivation auto-heals it to 0 here.
1150
- *
1151
- * Not aggressive in practice: PLAN.md is only written via the DB-backed
1152
- * `gsd_plan_slice` tool (which also inserts tasks), so a "stale PLAN.md with
1153
- * is_sketch=1" is extremely unlikely to indicate anything other than the two
1154
- * recovery scenarios above.
1139
+ * ADR-017 raw primitive: returns slice IDs in a milestone whose is_sketch flag
1140
+ * is still 1. The stale-sketch-flag drift handler at
1141
+ * `state-reconciliation/drift/sketch-flag.ts` composes this with PLAN.md
1142
+ * existence checks to detect drift, then writes via `setSliceSketchFlag`.
1155
1143
  */
1156
- export function autoHealSketchFlags(milestoneId: string, hasPlanFile: (sliceId: string) => boolean): void {
1157
- if (!currentDb) return;
1144
+ export function getSketchedSliceIds(milestoneId: string): string[] {
1145
+ if (!currentDb) return [];
1158
1146
  const rows = currentDb.prepare(
1159
1147
  `SELECT id FROM slices WHERE milestone_id = :mid AND is_sketch = 1`,
1160
1148
  ).all({ ":mid": milestoneId }) as Array<{ id: string }>;
1161
- for (const row of rows) {
1162
- if (hasPlanFile(row.id)) {
1163
- setSliceSketchFlag(milestoneId, row.id, false);
1164
- }
1165
- }
1149
+ return rows.map((r) => r.id);
1166
1150
  }
1167
1151
 
1168
1152
  export function upsertSlicePlanning(milestoneId: string, sliceId: string, planning: Partial<SlicePlanningRecord>): void {
@@ -920,101 +920,10 @@ export function detectStaleRenders(basePath: string): StaleEntry[] {
920
920
  }
921
921
 
922
922
  // ─── Stale Repair ─────────────────────────────────────────────────────────
923
-
924
- /**
925
- * Repair all stale renders detected by `detectStaleRenders()`.
926
- *
927
- * For each stale entry, calls the appropriate render function:
928
- * - Roadmap checkbox mismatches → renderRoadmapCheckboxes()
929
- * - Plan checkbox mismatches → renderPlanCheckboxes()
930
- * - Missing task summaries → renderTaskSummary()
931
- * - Missing slice summaries/UATs → renderSliceSummary()
932
- *
933
- * Idempotent: calling twice with no DB changes produces zero repairs on the second call.
934
- *
935
- * @returns the number of files repaired
936
- */
937
- export async function repairStaleRenders(basePath: string): Promise<number> {
938
- const staleEntries = detectStaleRenders(basePath);
939
- if (staleEntries.length === 0) return 0;
940
-
941
- // Deduplicate: a single roadmap/plan file might appear multiple times
942
- // (once per mismatched checkbox). We only need to re-render it once.
943
- const repairedPaths = new Set<string>();
944
- let repairCount = 0;
945
-
946
- for (const entry of staleEntries) {
947
- if (repairedPaths.has(entry.path)) continue;
948
- // Normalize path separators for cross-platform regex matching
949
- const normPath = entry.path.replace(/\\/g, "/");
950
-
951
- try {
952
- // Determine repair action from the reason
953
- if (entry.reason.includes("in roadmap")) {
954
- // Roadmap checkbox mismatch — extract milestone ID from path
955
- const milestoneMatch = normPath.match(/milestones\/([^/]+)\//);
956
- if (milestoneMatch) {
957
- const ok = await renderRoadmapCheckboxes(basePath, milestoneMatch[1]);
958
- if (ok) {
959
- repairedPaths.add(entry.path);
960
- repairCount++;
961
- }
962
- }
963
- } else if (entry.reason.includes("in plan")) {
964
- // Plan checkbox mismatch — extract milestone + slice IDs from path
965
- const pathMatch = normPath.match(/milestones\/([^/]+)\/slices\/([^/]+)\//);
966
- if (pathMatch) {
967
- const ok = await renderPlanCheckboxes(basePath, pathMatch[1], pathMatch[2]);
968
- if (ok) {
969
- repairedPaths.add(entry.path);
970
- repairCount++;
971
- }
972
- }
973
- } else if (entry.reason.includes("SUMMARY.md missing") && entry.reason.match(/^T\d+/)) {
974
- // Missing task summary — extract IDs from path
975
- const pathMatch = normPath.match(/milestones\/([^/]+)\/slices\/([^/]+)\/tasks\//);
976
- const taskMatch = entry.reason.match(/^(T\d+)/);
977
- if (pathMatch && taskMatch) {
978
- const ok = await renderTaskSummary(basePath, pathMatch[1], pathMatch[2], taskMatch[1]);
979
- if (ok) {
980
- repairedPaths.add(entry.path);
981
- repairCount++;
982
- }
983
- }
984
- } else if (entry.reason.includes("SUMMARY.md missing") && entry.reason.match(/^S\d+/)) {
985
- // Missing slice summary — extract IDs from path
986
- const pathMatch = normPath.match(/milestones\/([^/]+)\/slices\/([^/]+)\//);
987
- if (pathMatch) {
988
- const ok = await renderSliceSummary(basePath, pathMatch[1], pathMatch[2]);
989
- if (ok) {
990
- repairedPaths.add(entry.path);
991
- repairCount++;
992
- }
993
- }
994
- } else if (entry.reason.includes("UAT.md missing")) {
995
- // Missing slice UAT — renderSliceSummary handles both SUMMARY + UAT
996
- const pathMatch = normPath.match(/milestones\/([^/]+)\/slices\/([^/]+)\//);
997
- if (pathMatch) {
998
- const ok = await renderSliceSummary(basePath, pathMatch[1], pathMatch[2]);
999
- if (ok) {
1000
- repairedPaths.add(entry.path);
1001
- repairCount++;
1002
- }
1003
- }
1004
- }
1005
- } catch (err) {
1006
- logWarning("renderer", `repair failed for ${entry.path}: ${(err as Error).message}`);
1007
- }
1008
- }
1009
-
1010
- if (repairCount > 0) {
1011
- process.stderr.write(
1012
- `markdown-renderer: repaired ${repairCount} stale render(s)\n`,
1013
- );
1014
- }
1015
-
1016
- return repairCount;
1017
- }
923
+ // Body relocated to state-reconciliation/drift/stale-render.ts (ADR-017 #5702).
924
+ // detectStaleRenders above stays as a useful diagnostic primitive; the
925
+ // drift handler composes it with the per-reason renderer dispatch and the
926
+ // reconcileBeforeDispatch lifecycle.
1018
927
 
1019
928
  // ─── Replan & Assessment Renderers ────────────────────────────────────────
1020
929
 
@@ -2,6 +2,7 @@
2
2
  // File Purpose: ADR-015 Recovery Classification module for runtime failure taxonomy.
3
3
 
4
4
  import { classifyError, isTransient, type ErrorClass } from "./error-classifier.js";
5
+ import { ReconciliationFailedError } from "./state-reconciliation.js";
5
6
 
6
7
  export type RecoveryFailureKind =
7
8
  | "tool-schema"
@@ -9,6 +10,7 @@ export type RecoveryFailureKind =
9
10
  | "stale-worker"
10
11
  | "worktree-invalid"
11
12
  | "verification-drift"
13
+ | "reconciliation-drift"
12
14
  | "provider"
13
15
  | "runtime-unknown";
14
16
 
@@ -33,7 +35,13 @@ export interface RecoveryClassification {
33
35
 
34
36
  export function classifyFailure(input: RecoveryClassificationInput): RecoveryClassification {
35
37
  const message = errorMessage(input.error);
36
- const failureKind = input.failureKind ?? inferFailureKind(message);
38
+ // ADR-017: ReconciliationFailedError is a typed throw from the State
39
+ // Reconciliation Module. Recognize it by class regardless of caller-supplied
40
+ // failureKind so the taxonomy stays consistent.
41
+ const failureKind =
42
+ input.error instanceof ReconciliationFailedError
43
+ ? "reconciliation-drift"
44
+ : input.failureKind ?? inferFailureKind(message);
37
45
 
38
46
  switch (failureKind) {
39
47
  case "tool-schema":
@@ -76,6 +84,15 @@ export function classifyFailure(input: RecoveryClassificationInput): RecoveryCla
76
84
  exitReason: "verification-drift",
77
85
  remediation: "Inspect the verification artifact and reconcile the state snapshot before resuming.",
78
86
  };
87
+ case "reconciliation-drift":
88
+ return {
89
+ failureKind,
90
+ action: "escalate",
91
+ reason: `Reconciliation drift${unitSuffix(input)}: ${message}`,
92
+ exitReason: "reconciliation-drift",
93
+ remediation:
94
+ "Inspect the persistent or repair-failed drift kinds reported by the State Reconciliation Module before resuming.",
95
+ };
79
96
  case "provider": {
80
97
  const providerClass = classifyError(message, input.retryAfterMs);
81
98
  return {
@@ -597,6 +597,47 @@ export function isSessionLockProcessAlive(data: SessionLockData): boolean {
597
597
  return isPidAlive(data.pid);
598
598
  }
599
599
 
600
+ /**
601
+ * ADR-017 raw primitive: remove orphaned lock artifacts (lock dir + lock file)
602
+ * when the recorded PID is dead or no metadata is present. Mirrors the
603
+ * pre-flight cleanup logic in acquireSessionLock so the stale-worker drift
604
+ * handler can clear the orphan proactively without going through the full
605
+ * acquire path. No-op when the lock is held by an alive process.
606
+ *
607
+ * Returns true when artifacts were removed (drift was present).
608
+ */
609
+ export function removeStaleSessionLock(basePath: string): boolean {
610
+ const lp = lockPath(basePath);
611
+ const gsdDir = gsdRoot(basePath);
612
+ const lockTarget = effectiveLockTarget(gsdDir);
613
+ const lockDir = lockTarget + ".lock";
614
+
615
+ const existingData = readExistingLockData(lp);
616
+ const isOrphan =
617
+ !existingData ||
618
+ (typeof existingData.pid === "number" && !isPidAlive(existingData.pid));
619
+ if (!isOrphan) return false;
620
+
621
+ let removed = false;
622
+ if (existsSync(lockDir)) {
623
+ try {
624
+ rmSync(lockDir, { recursive: true, force: true });
625
+ removed = true;
626
+ } catch {
627
+ /* best-effort */
628
+ }
629
+ }
630
+ if (existsSync(lp)) {
631
+ try {
632
+ unlinkSync(lp);
633
+ removed = true;
634
+ } catch {
635
+ /* best-effort */
636
+ }
637
+ }
638
+ return removed;
639
+ }
640
+
600
641
  /**
601
642
  * Returns true if we currently hold a session lock for the given path.
602
643
  */