specrails-desktop 2.29.2 → 2.29.3

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 (99) hide show
  1. package/client/dist/assets/{ActivityFeedPage-ClpD5CFj.js → ActivityFeedPage-CFB4UBhT.js} +1 -1
  2. package/client/dist/assets/{AgentBrowserCapture-CSPs9Ofc.js → AgentBrowserCapture-C-T0r16B.js} +1 -1
  3. package/client/dist/assets/{AgentModeCodePane-DwcyklA5.js → AgentModeCodePane-IljV5FOQ.js} +2 -2
  4. package/client/dist/assets/{AgentModeJobsPane-C69bqlMa.js → AgentModeJobsPane-B5miH10_.js} +1 -1
  5. package/client/dist/assets/{AgentsPage-pRiRr9-W.js → AgentsPage-BgYQrAcH.js} +1 -1
  6. package/client/dist/assets/{AnalyticsPage-C14exKsC.js → AnalyticsPage-BtVCuNjL.js} +1 -1
  7. package/client/dist/assets/{CodePage-CK76JKm9.js → CodePage-Cb_jAjSV.js} +1 -1
  8. package/client/dist/assets/{DesktopAnalyticsPage-Cnv0f3XQ.js → DesktopAnalyticsPage-s_Cc_JL1.js} +1 -1
  9. package/client/dist/assets/{DocsDialog-CHUm_Ym0.js → DocsDialog-Cr47QI9L.js} +1 -1
  10. package/client/dist/assets/{DocsPage-COI-oDok.js → DocsPage-C_WPRuG_.js} +1 -1
  11. package/client/dist/assets/{InteractiveJobComposer-CypM7R1F.js → InteractiveJobComposer-NfFOciRB.js} +1 -1
  12. package/client/dist/assets/{JobDetailModal-B_W0H9dw.js → JobDetailModal-DqEW7Z7K.js} +1 -1
  13. package/client/dist/assets/{JobDetailPage-CeeXOoKS.js → JobDetailPage-D4ppEpDE.js} +1 -1
  14. package/client/dist/assets/{JobsPage-CwhJG9jw.js → JobsPage-DStSoMyq.js} +1 -1
  15. package/client/dist/assets/{LoopBuilderPage-C51t1WOM.js → LoopBuilderPage-DltCwS4P.js} +1 -1
  16. package/client/dist/assets/{LoopPreviewModal-Y2HgtIJu.js → LoopPreviewModal-BWaiAbUq.js} +1 -1
  17. package/client/dist/assets/{LoopsPage-Bvn1Kw5V.js → LoopsPage-BqjbXuBZ.js} +1 -1
  18. package/client/dist/assets/{MinimizedChatsContext-DiFvZDPW.js → MinimizedChatsContext--o3JAM9F.js} +1 -1
  19. package/client/dist/assets/{PluginsPage-BGPRJpdf.js → PluginsPage-B-UOmzXw.js} +1 -1
  20. package/client/dist/assets/{ProjectSettingsDialog-D_GTyGLc.js → ProjectSettingsDialog-DkGajIxE.js} +1 -1
  21. package/client/dist/assets/{TemplatePreviewModal-szwBhd7h.js → TemplatePreviewModal-5CUXlkw7.js} +1 -1
  22. package/client/dist/assets/{TicketDetailModal-9UEyK974.js → TicketDetailModal-B_igqmti.js} +4 -4
  23. package/client/dist/assets/{Trans-B_vKZ-Hb.js → Trans-DDMIBBUd.js} +1 -1
  24. package/client/dist/assets/agent-BXc59ARK2.js +1 -0
  25. package/client/dist/assets/agent-BbIFXdDq.js +1 -0
  26. package/client/dist/assets/agent-BqEfA5u5.js +1 -0
  27. package/client/dist/assets/agent-BtGzgTC4.js +1 -0
  28. package/client/dist/assets/agent-CIhys_5d.js +1 -0
  29. package/client/dist/assets/agent-CJPaqsVR.js +1 -0
  30. package/client/dist/assets/agent-n-hY4GuL.js +1 -0
  31. package/client/dist/assets/agent-tJ-efa3_2.js +1 -0
  32. package/client/dist/assets/common-6MLbD-jM.js +1 -0
  33. package/client/dist/assets/common-BKGEu4rm.js +1 -0
  34. package/client/dist/assets/common-BNbnv8cE2.js +1 -0
  35. package/client/dist/assets/common-BqGWtDbt.js +1 -0
  36. package/client/dist/assets/common-DBtTzeYm2.js +1 -0
  37. package/client/dist/assets/common-DEW3Nr8n.js +1 -0
  38. package/client/dist/assets/{common-DVQuhs-m.js → common-DHsv9N5d.js} +1 -1
  39. package/client/dist/assets/common-mkrEcW-Y.js +1 -0
  40. package/client/dist/assets/dashboard-BYzxom0L.js +1 -0
  41. package/client/dist/assets/dashboard-BmDZmHyY.js +1 -0
  42. package/client/dist/assets/dashboard-CnNtJBQ5.js +1 -0
  43. package/client/dist/assets/dashboard-D4Uj-XWc2.js +1 -0
  44. package/client/dist/assets/dashboard-D57uRaGw2.js +1 -0
  45. package/client/dist/assets/dashboard-DAr6F9et.js +1 -0
  46. package/client/dist/assets/dashboard-vAcsn0L3.js +1 -0
  47. package/client/dist/assets/dashboard-vrjZvxWy.js +1 -0
  48. package/client/dist/assets/{dist-js-CcBCbb32.js → dist-js-BCUhGUXS.js} +1 -1
  49. package/client/dist/assets/{dist-js-VwztRkGj.js → dist-js-DcoXTnEI.js} +1 -1
  50. package/client/dist/assets/{formatDistanceToNow-ByHIauTQ.js → formatDistanceToNow-DP3Ab6fX.js} +1 -1
  51. package/client/dist/assets/{getTimezoneOffsetInMilliseconds-BxHAksaU.js → getTimezoneOffsetInMilliseconds-DdKKL8Fx.js} +1 -1
  52. package/client/dist/assets/{i18n-CgaRxpII.js → i18n-DREQCE2F.js} +2 -2
  53. package/client/dist/assets/index-B784m8RT.js +76 -0
  54. package/client/dist/assets/index-CQBIemiz.css +2 -0
  55. package/client/dist/assets/{spending-C4p06nen.js → spending-CtY7yYes.js} +1 -1
  56. package/client/dist/assets/{useBrowserCaptureSession-2OqX0vap.js → useBrowserCaptureSession-BFBdW59q.js} +1 -1
  57. package/client/dist/assets/{useDesktop-X8YB9xzv.js → useDesktop-uWVZ8eia.js} +1 -1
  58. package/client/dist/index.html +14 -14
  59. package/docs/internals/safe-pr-review-flow.md +221 -38
  60. package/package.json +1 -1
  61. package/server/dist/active-pr-continuation.js +237 -52
  62. package/server/dist/agent-chat-manager.js +29 -12
  63. package/server/dist/agent-store.js +19 -4
  64. package/server/dist/db.js +21 -0
  65. package/server/dist/pr-lifecycle.js +100 -8
  66. package/server/dist/project-git.js +62 -2
  67. package/server/dist/rail-isolated-launch.js +644 -108
  68. package/server/dist/rail-pr-decision.js +1065 -181
  69. package/server/dist/rail-pr-recovery-git.js +235 -0
  70. package/server/dist/rail-pr-store.js +120 -9
  71. package/server/dist/rail-worktree-release.js +290 -4
  72. package/server/dist/rails-router.js +97 -9
  73. package/server/dist/worktree-manager.js +120 -16
  74. package/server/dist/worktree-overlay.js +166 -4
  75. package/client/dist/assets/agent-4rmXoo_p.js +0 -1
  76. package/client/dist/assets/agent-BxZuNGih.js +0 -1
  77. package/client/dist/assets/agent-C3uXOYv_.js +0 -1
  78. package/client/dist/assets/agent-CA8Y4wbw.js +0 -1
  79. package/client/dist/assets/agent-CASKrhG12.js +0 -1
  80. package/client/dist/assets/agent-Cv-jud5n2.js +0 -1
  81. package/client/dist/assets/agent-DitUxYh2.js +0 -1
  82. package/client/dist/assets/agent-Doow4X7o.js +0 -1
  83. package/client/dist/assets/common-CD1QHriA2.js +0 -1
  84. package/client/dist/assets/common-CwwPaRxx.js +0 -1
  85. package/client/dist/assets/common-Dg2jaX7d.js +0 -1
  86. package/client/dist/assets/common-DuBwj6tK.js +0 -1
  87. package/client/dist/assets/common-MRj9MHcc.js +0 -1
  88. package/client/dist/assets/common-O4ZpnMLp.js +0 -1
  89. package/client/dist/assets/common-fyeF0v7C2.js +0 -1
  90. package/client/dist/assets/dashboard-B_NkSHYm.js +0 -1
  91. package/client/dist/assets/dashboard-ClBb_3pq.js +0 -1
  92. package/client/dist/assets/dashboard-CpCmJvMB.js +0 -1
  93. package/client/dist/assets/dashboard-Czohu35O2.js +0 -1
  94. package/client/dist/assets/dashboard-DcjbD0UK.js +0 -1
  95. package/client/dist/assets/dashboard-Dvx5F3we.js +0 -1
  96. package/client/dist/assets/dashboard-R1bOPjBh2.js +0 -1
  97. package/client/dist/assets/dashboard-owcDucDi.js +0 -1
  98. package/client/dist/assets/index-BflYQpWm.css +0 -2
  99. package/client/dist/assets/index-C1_-BTXy.js +0 -72
@@ -1,8 +1,258 @@
1
1
  "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
2
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.durableBranchHeads = durableBranchHeads;
37
+ exports.durableOverlayCleanupEvidence = durableOverlayCleanupEvidence;
3
38
  exports.releaseRailWorktrees = releaseRailWorktrees;
4
39
  const rail_worktrees_store_1 = require("./rail-worktrees-store");
5
40
  const worktree_manager_1 = require("./worktree-manager");
41
+ const path = __importStar(require("path"));
42
+ const fs = __importStar(require("fs"));
43
+ const worktree_overlay_1 = require("./worktree-overlay");
44
+ const COMMIT_SHA_RE = /^[0-9a-f]{40,64}$/i;
45
+ /** Collapse per-unit settlement evidence into one unambiguous immutable HEAD per
46
+ * branch. Conflicting or malformed records intentionally yield no authorization. */
47
+ function durableBranchHeads(records) {
48
+ const heads = new Map();
49
+ const conflicted = new Set();
50
+ for (const record of records) {
51
+ const sha = record.finalSha?.trim().toLowerCase() ?? '';
52
+ if (!record.branch || !COMMIT_SHA_RE.test(sha) || conflicted.has(record.branch))
53
+ continue;
54
+ const existing = heads.get(record.branch);
55
+ if (existing && existing !== sha) {
56
+ heads.delete(record.branch);
57
+ conflicted.add(record.branch);
58
+ continue;
59
+ }
60
+ heads.set(record.branch, sha);
61
+ }
62
+ return heads;
63
+ }
64
+ function safeRelativeOverlayPath(value) {
65
+ if (process.platform === 'win32' && value.includes('\\'))
66
+ return null;
67
+ if (value.split('/').includes('..'))
68
+ return null;
69
+ const normalized = path.posix.normalize(value);
70
+ if (!normalized || normalized === '.' || normalized.startsWith('../') ||
71
+ path.posix.isAbsolute(normalized) || normalized.includes('\0'))
72
+ return null;
73
+ return normalized;
74
+ }
75
+ /** Collapse persisted overlay fingerprints. Conflicting evidence for the same
76
+ * branch/path grants no exclusion permission. */
77
+ function durableOverlayCleanupEvidence(records) {
78
+ const byBranch = new Map();
79
+ const conflicted = new Map();
80
+ for (const record of records) {
81
+ if (!record.branch || !Array.isArray(record.overlayCleanupEvidence))
82
+ continue;
83
+ const branchEvidence = byBranch.get(record.branch) ?? new Map();
84
+ const branchConflicts = conflicted.get(record.branch) ?? new Set();
85
+ for (const value of record.overlayCleanupEvidence) {
86
+ if (!value || typeof value.path !== 'string' || typeof value.digest !== 'string')
87
+ continue;
88
+ if (value.kind !== 'symlink' && value.kind !== 'file' && value.kind !== 'directory')
89
+ continue;
90
+ const safe = safeRelativeOverlayPath(value.path);
91
+ if (!safe || !/^[0-9a-f]{64}$/.test(value.digest) || branchConflicts.has(safe))
92
+ continue;
93
+ const normalized = { path: safe, kind: value.kind, digest: value.digest };
94
+ const existing = branchEvidence.get(safe);
95
+ if (existing && (existing.kind !== normalized.kind || existing.digest !== normalized.digest)) {
96
+ branchEvidence.delete(safe);
97
+ branchConflicts.add(safe);
98
+ continue;
99
+ }
100
+ branchEvidence.set(safe, normalized);
101
+ }
102
+ byBranch.set(record.branch, branchEvidence);
103
+ conflicted.set(record.branch, branchConflicts);
104
+ }
105
+ return new Map([...byBranch].map(([branch, values]) => [branch, [...values.values()]]));
106
+ }
107
+ async function verifyReleaseEvidence(input, worktree) {
108
+ const expected = input.expectedHeadByBranch.get(worktree.branch)?.toLowerCase();
109
+ if (!expected || !COMMIT_SHA_RE.test(expected)) {
110
+ return { failure: 'no durable settled HEAD is recorded for its branch', overlayEntries: [] };
111
+ }
112
+ const overlayEntries = (input.overlayEvidenceByBranch.get(worktree.branch) ?? [])
113
+ .filter((entry) => (0, worktree_overlay_1.matchesOverlayCleanupEvidence)(worktree.worktree_path, entry));
114
+ const overlayExcludes = overlayEntries.map((entry) => entry.path);
115
+ try {
116
+ const status = await input.git.run([
117
+ 'status', '--porcelain', '--untracked-files=all', '--ignored=matching', '--', '.',
118
+ ...overlayExcludes.map((entry) => `:(top,exclude,literal)${entry}`),
119
+ ], worktree.worktree_path);
120
+ if (status.code !== 0)
121
+ return { failure: 'Git could not verify worktree cleanliness', overlayEntries: [] };
122
+ if (status.stdout.trim())
123
+ return { failure: 'the worktree contains changes made after settlement', overlayEntries: [] };
124
+ const [head, branchRef] = await Promise.all([
125
+ input.git.run(['rev-parse', '--verify', 'HEAD'], worktree.worktree_path),
126
+ input.git.run(['rev-parse', '--verify', `refs/heads/${worktree.branch}`], input.repoDir),
127
+ ]);
128
+ const headSha = head.code === 0 ? head.stdout.trim().toLowerCase() : '';
129
+ const branchSha = branchRef.code === 0 ? branchRef.stdout.trim().toLowerCase() : '';
130
+ if (!COMMIT_SHA_RE.test(headSha) || !COMMIT_SHA_RE.test(branchSha)) {
131
+ return { failure: 'Git could not verify the worktree HEAD and recorded branch ref', overlayEntries: [] };
132
+ }
133
+ if (headSha !== expected || branchSha !== expected) {
134
+ return { failure: `HEAD/ref moved after settlement (expected ${expected.slice(0, 12)})`, overlayEntries: [] };
135
+ }
136
+ return { failure: null, overlayEntries };
137
+ }
138
+ catch (err) {
139
+ return { failure: `verification failed: ${err instanceof Error ? err.message : String(err)}`, overlayEntries: [] };
140
+ }
141
+ }
142
+ function pathStillExists(target) {
143
+ try {
144
+ fs.lstatSync(target);
145
+ return true;
146
+ }
147
+ catch {
148
+ return false;
149
+ }
150
+ }
151
+ function quarantineLocationNotes(entries) {
152
+ return entries
153
+ .filter((entry) => pathStillExists(entry.quarantinePath))
154
+ .map((entry) => `overlay data is preserved at ${entry.quarantinePath}`);
155
+ }
156
+ /** Atomically move fingerprint-matching, untracked overlay roots out of the
157
+ * worktree. Quarantine is a unique sibling on the same filesystem. It is never
158
+ * automatically deleted or restored: writes through an already-open fd after
159
+ * rename remain recoverable, and a concurrently recreated source can never be
160
+ * overwritten by rollback. */
161
+ async function quarantineVerifiedOverlayEntries(input, worktree, evidence) {
162
+ const removable = [];
163
+ for (const entry of evidence) {
164
+ const target = path.join(worktree.worktree_path, ...entry.path.split('/'));
165
+ if (!pathStillExists(target))
166
+ continue;
167
+ const tracked = await input.git.run(['ls-files', '--error-unmatch', '--', entry.path], worktree.worktree_path);
168
+ if (tracked.code === 0)
169
+ continue;
170
+ if (tracked.code !== 1) {
171
+ return { failure: `Git could not verify ownership of overlay path ${entry.path}`, entries: [] };
172
+ }
173
+ if (!(0, worktree_overlay_1.matchesOverlayCleanupEvidence)(worktree.worktree_path, entry)) {
174
+ return { failure: `overlay path ${entry.path} changed after cleanup verification`, entries: [] };
175
+ }
176
+ removable.push(entry);
177
+ }
178
+ const cleanupRoots = [];
179
+ for (const entry of [...removable].sort((a, b) => a.path.split('/').length - b.path.split('/').length)) {
180
+ if (cleanupRoots.some((root) => root.kind === 'directory' && entry.path.startsWith(`${root.path}/`)))
181
+ continue;
182
+ cleanupRoots.push(entry);
183
+ }
184
+ if (cleanupRoots.length === 0)
185
+ return { failure: null, entries: [] };
186
+ let quarantineRoot;
187
+ try {
188
+ // mkdtemp creates the batch directory atomically beside the worktree. The
189
+ // ensuing rename therefore remains within one filesystem.
190
+ quarantineRoot = fs.mkdtempSync(`${worktree.worktree_path}.specrails-overlay-quarantine-`);
191
+ }
192
+ catch (err) {
193
+ return {
194
+ failure: `could not create overlay quarantine beside ${worktree.worktree_path}: ${err instanceof Error ? err.message : String(err)}`,
195
+ entries: [],
196
+ };
197
+ }
198
+ try {
199
+ await input.onSafetyArchive?.(quarantineRoot);
200
+ }
201
+ catch (err) {
202
+ return {
203
+ failure: `could not record safety archive ${quarantineRoot}: ${err instanceof Error ? err.message : String(err)}`,
204
+ entries: [],
205
+ };
206
+ }
207
+ const quarantined = [];
208
+ for (const entry of cleanupRoots) {
209
+ const sourcePath = path.join(worktree.worktree_path, ...entry.path.split('/'));
210
+ if (!pathStillExists(sourcePath))
211
+ continue;
212
+ if (!(0, worktree_overlay_1.matchesOverlayCleanupEvidenceAtPath)(sourcePath, entry)) {
213
+ return { failure: `overlay path ${entry.path} changed before quarantine`, entries: quarantined };
214
+ }
215
+ const quarantinePath = path.join(quarantineRoot, ...entry.path.split('/'));
216
+ try {
217
+ fs.mkdirSync(path.dirname(quarantinePath), { recursive: true });
218
+ await input.beforeOverlayQuarantine?.(sourcePath);
219
+ fs.renameSync(sourcePath, quarantinePath);
220
+ }
221
+ catch (err) {
222
+ return {
223
+ failure: `could not quarantine overlay path ${entry.path}: ${err instanceof Error ? err.message : String(err)}`,
224
+ entries: quarantined,
225
+ };
226
+ }
227
+ const moved = { sourcePath, quarantinePath, evidence: entry };
228
+ quarantined.push(moved);
229
+ try {
230
+ await input.afterOverlayRename?.(sourcePath, quarantinePath);
231
+ }
232
+ catch (err) {
233
+ return {
234
+ failure: `overlay post-rename hook failed at ${quarantinePath}: ${err instanceof Error ? err.message : String(err)}`,
235
+ entries: quarantined,
236
+ };
237
+ }
238
+ if (!(0, worktree_overlay_1.matchesOverlayCleanupEvidenceAtPath)(quarantinePath, entry)) {
239
+ return {
240
+ failure: `overlay path ${entry.path} changed during atomic quarantine at ${quarantinePath}`,
241
+ entries: quarantined,
242
+ };
243
+ }
244
+ try {
245
+ await input.afterOverlayQuarantine?.(quarantinePath);
246
+ }
247
+ catch (err) {
248
+ return {
249
+ failure: `overlay quarantine hook failed at ${quarantinePath}: ${err instanceof Error ? err.message : String(err)}`,
250
+ entries: quarantined,
251
+ };
252
+ }
253
+ }
254
+ return { failure: null, entries: quarantined };
255
+ }
6
256
  /** Remove Specrails-managed linked worktrees while keeping their local branches.
7
257
  * PR deliveries use this once the work has a durable remote branch/PR: the
8
258
  * branch remains available for local checkout, but the internal worktree stops
@@ -15,19 +265,50 @@ async function releaseRailWorktrees(input) {
15
265
  const wt = (0, rail_worktrees_store_1.getRailWorktree)(input.db, wtId);
16
266
  if (!wt)
17
267
  continue;
18
- if (wt.merge_state === 'needs-review' && input.allowNeedsReview !== true)
19
- continue;
20
268
  // Successful removal already terminalized these rows. Re-running `git
21
269
  // worktree remove` against their now-missing paths turns ordinary Publish /
22
270
  // Poll / Discard follow-ups into false cleanup_incomplete warnings.
23
271
  if (wt.merge_state !== 'needs-review' && (0, rail_worktrees_store_1.isTerminalMergeState)(wt.merge_state))
24
272
  continue;
273
+ const evidence = await verifyReleaseEvidence(input, wt);
274
+ if (evidence.failure) {
275
+ (0, rail_worktrees_store_1.updateRailWorktreeState)(input.db, wt.id, 'needs-review');
276
+ const warning = `worktree ${wt.worktree_path}: preserved because ${evidence.failure}`;
277
+ warnings.push(warning);
278
+ console.warn(`[rail-worktree-release] ${warning}`);
279
+ continue;
280
+ }
281
+ const quarantine = await quarantineVerifiedOverlayEntries(input, wt, evidence.overlayEntries);
282
+ if (quarantine.failure) {
283
+ (0, rail_worktrees_store_1.updateRailWorktreeState)(input.db, wt.id, 'needs-review');
284
+ const warning = `worktree ${wt.worktree_path}: preserved because ${[
285
+ quarantine.failure,
286
+ ...quarantineLocationNotes(quarantine.entries),
287
+ ].join('; ')}`;
288
+ warnings.push(warning);
289
+ console.warn(`[rail-worktree-release] ${warning}`);
290
+ continue;
291
+ }
292
+ // Quarantining changes the worktree. Re-prove both cleanliness and HEAD/ref
293
+ // identity immediately before asking Git for its final non-force guard.
294
+ const finalEvidence = await verifyReleaseEvidence(input, wt);
295
+ if (finalEvidence.failure) {
296
+ (0, rail_worktrees_store_1.updateRailWorktreeState)(input.db, wt.id, 'needs-review');
297
+ const warning = `worktree ${wt.worktree_path}: preserved because ${[
298
+ finalEvidence.failure,
299
+ ...quarantineLocationNotes(quarantine.entries),
300
+ ].join('; ')}`;
301
+ warnings.push(warning);
302
+ console.warn(`[rail-worktree-release] ${warning}`);
303
+ continue;
304
+ }
25
305
  try {
26
306
  await remove(input.git, {
27
307
  repoDir: input.repoDir,
28
308
  worktreePath: wt.worktree_path,
29
309
  branch: wt.branch,
30
310
  deleteBranch: false,
311
+ force: false,
31
312
  });
32
313
  }
33
314
  catch (err) {
@@ -35,13 +316,18 @@ async function releaseRailWorktrees(input) {
35
316
  if (current && current.merge_state !== 'needs-review' && (0, rail_worktrees_store_1.isTerminalMergeState)(current.merge_state)) {
36
317
  continue;
37
318
  }
38
- const warning = `worktree ${wt.worktree_path}: ${err instanceof Error ? err.message : String(err)}`;
319
+ (0, rail_worktrees_store_1.updateRailWorktreeState)(input.db, wt.id, 'needs-review');
320
+ const warning = `worktree ${wt.worktree_path}: ${[
321
+ err instanceof Error ? err.message : String(err),
322
+ ...quarantineLocationNotes(quarantine.entries),
323
+ ].join('; ')}`;
39
324
  warnings.push(warning);
40
325
  console.warn(`[rail-worktree-release] failed to remove ${warning}`);
41
326
  continue;
42
327
  }
43
- if (!(0, rail_worktrees_store_1.isTerminalMergeState)(wt.merge_state))
328
+ if (wt.merge_state === 'needs-review' || !(0, rail_worktrees_store_1.isTerminalMergeState)(wt.merge_state)) {
44
329
  (0, rail_worktrees_store_1.updateRailWorktreeState)(input.db, wt.id, state);
330
+ }
45
331
  }
46
332
  return warnings;
47
333
  }
@@ -40,12 +40,21 @@ const VALID_REASONING_EFFORTS = new Set(['low', 'medium', 'high']);
40
40
  function prDeliveryContinuesTickets(delivery, ticketIds) {
41
41
  if (delivery.decision !== 'pr_draft' && delivery.decision !== 'pr_ready')
42
42
  return false;
43
- if (!delivery.prUrl || !delivery.branch || delivery.prState !== 'pr-created')
43
+ if (!delivery.prUrl || !delivery.branch || !delivery.deliverySha || delivery.prState !== 'pr-created')
44
44
  return false;
45
45
  const covered = new Set(delivery.ticketIds);
46
46
  const requested = new Set(ticketIds);
47
47
  return requested.size > 0 && requested.size === covered.size && [...requested].every((id) => covered.has(id));
48
48
  }
49
+ function prDeliveryCheckoutBlock(delivery) {
50
+ if (delivery.deliveryOutcome === 'blocked') {
51
+ return 'delivery is blocked and has no safely checkoutable result';
52
+ }
53
+ if (!delivery.deliverySha) {
54
+ return 'delivery has no verified commit available for checkout';
55
+ }
56
+ return null;
57
+ }
49
58
  function emitPrDeliveryUpdate(c, prDeliveryId) {
50
59
  const row = (0, rail_pr_store_1.getPrDelivery)(c.db, prDeliveryId);
51
60
  if (!row)
@@ -603,8 +612,10 @@ function createRailsRouter() {
603
612
  deliveryId: continuablePrDelivery.id,
604
613
  decision: continuablePrDelivery.decision,
605
614
  branch: continuablePrDelivery.branch,
615
+ baseBranch: continuablePrDelivery.baseBranch,
606
616
  prUrl: continuablePrDelivery.prUrl,
607
617
  prNumber: continuablePrDelivery.prNumber,
618
+ deliverySha: continuablePrDelivery.deliverySha,
608
619
  },
609
620
  } : {}),
610
621
  });
@@ -881,7 +892,7 @@ function createRailsRouter() {
881
892
  // POST /rails/pr-decision — the ONE decision action (safe-pr-review-flow) both
882
893
  // surfaces (dashboard rail row + agent-chat card) call on a rail_pr_deliveries
883
894
  // row: create-pr | publish | discard/dismiss | poll/reopen | merge-local |
884
- // acknowledge-no-changes, compare-and-set-guarded by
895
+ // acknowledge-no-changes | recover-and-retry, compare-and-set-guarded by
885
896
  // expectedDecision (a raced concurrent answer loses with 409 stale_decision).
886
897
  // Replaces the stateless v1 POST /rails/pr-review passthrough. The route only
887
898
  // validates and delegates — the action logic lives in rail-pr-decision.ts.
@@ -894,7 +905,7 @@ function createRailsRouter() {
894
905
  return;
895
906
  }
896
907
  if (!(0, rail_pr_decision_1.isPrDecisionAction)(action)) {
897
- res.status(400).json({ error: "action must be 'create-pr', 'publish', 'discard', 'dismiss', 'poll-merge', 'reopen', 'merge-local' or 'acknowledge-no-changes'" });
908
+ res.status(400).json({ error: `action must be one of: ${rail_pr_decision_1.PR_DECISION_ACTIONS.join(', ')}` });
898
909
  return;
899
910
  }
900
911
  if (typeof expectedDecision !== 'string' || !expectedDecision) {
@@ -968,7 +979,21 @@ function createRailsRouter() {
968
979
  res.status(404).json({ error: 'Unknown prDeliveryId' });
969
980
  return;
970
981
  }
982
+ const active = (0, rail_pr_store_1.getActivePrDeliveryByRail)(c.db, row.rail_index);
983
+ if (!active || active.id !== row.id) {
984
+ res.status(409).json({
985
+ error: 'stale_decision',
986
+ current: active?.decision ?? null,
987
+ currentPrDeliveryId: active?.id ?? null,
988
+ });
989
+ return;
990
+ }
971
991
  const snap = (0, rail_pr_store_1.toPrDeliverySnapshot)(row);
992
+ const checkoutBlock = prDeliveryCheckoutBlock(snap);
993
+ if (checkoutBlock) {
994
+ res.status(409).json({ error: 'checkout_not_deliverable', detail: checkoutBlock });
995
+ return;
996
+ }
972
997
  if (!snap.branch || !snap.prUrl) {
973
998
  res.status(409).json({ error: 'checkout_unavailable', detail: 'delivery has no PR branch' });
974
999
  return;
@@ -976,20 +1001,83 @@ function createRailsRouter() {
976
1001
  try {
977
1002
  const outcome = await (0, repo_lock_1.withRepoLock)(c.project.path, async () => {
978
1003
  admission.assertCurrent();
1004
+ const current = (0, rail_pr_store_1.getActivePrDeliveryByRail)(c.db, row.rail_index);
1005
+ if (!current || current.id !== row.id) {
1006
+ return {
1007
+ ok: false,
1008
+ status: 409,
1009
+ error: 'stale_decision',
1010
+ detail: 'The delivery generation changed before checkout.',
1011
+ };
1012
+ }
1013
+ const currentSnap = (0, rail_pr_store_1.toPrDeliverySnapshot)(current);
1014
+ const currentCheckoutBlock = prDeliveryCheckoutBlock(currentSnap);
1015
+ if (currentCheckoutBlock) {
1016
+ return {
1017
+ ok: false,
1018
+ status: 409,
1019
+ error: 'checkout_not_deliverable',
1020
+ detail: currentCheckoutBlock,
1021
+ };
1022
+ }
1023
+ if (!currentSnap.branch || !currentSnap.prUrl) {
1024
+ return {
1025
+ ok: false,
1026
+ status: 409,
1027
+ error: 'checkout_not_deliverable',
1028
+ detail: 'The current delivery no longer has an attached PR branch.',
1029
+ };
1030
+ }
1031
+ const cleanliness = await (0, project_git_1.inspectProjectCheckoutCleanliness)(c.project.path);
1032
+ if (!cleanliness.ok) {
1033
+ return {
1034
+ ok: false,
1035
+ status: 409,
1036
+ error: 'checkout_safety_unknown',
1037
+ detail: cleanliness.detail,
1038
+ };
1039
+ }
1040
+ if (!cleanliness.clean) {
1041
+ return { ok: false, status: 409, error: 'checkout_dirty', detail: 'Working tree has uncommitted changes. Commit or stash them before checkout.' };
1042
+ }
979
1043
  const info = await (0, project_git_1.getProjectGitInfo)(c.project.path);
980
1044
  if (!info.git) {
981
1045
  return { ok: false, status: 409, error: 'checkout_unavailable', detail: 'project is not a git repository' };
982
1046
  }
983
- if (info.dirty) {
984
- return { ok: false, status: 409, error: 'checkout_dirty', detail: 'Working tree has uncommitted changes. Commit or stash them before checkout.' };
985
- }
986
- await (0, rail_worktree_release_1.releaseRailWorktrees)({
1047
+ let archived = false;
1048
+ const cleanupWarnings = await (0, rail_worktree_release_1.releaseRailWorktrees)({
987
1049
  db: c.db,
988
1050
  git: worktree_manager_1.defaultGitRunner,
989
1051
  repoDir: c.project.path,
990
- worktreeIds: snap.worktreeIds,
1052
+ worktreeIds: currentSnap.worktreeIds,
1053
+ expectedHeadByBranch: (0, rail_worktree_release_1.durableBranchHeads)(currentSnap.branches),
1054
+ overlayEvidenceByBranch: (0, rail_worktree_release_1.durableOverlayCleanupEvidence)(currentSnap.branches),
1055
+ onSafetyArchive: (archive) => {
1056
+ archived = true;
1057
+ if (!(0, rail_pr_store_1.appendPrDeliverySafetyArchive)(c.db, row.id, archive)) {
1058
+ throw new Error(`delivery ${row.id} disappeared while recording safety archive ${archive}`);
1059
+ }
1060
+ },
991
1061
  });
992
- const checkedOut = await (0, project_git_1.checkoutProjectReviewBranch)(c.project.path, snap.branch);
1062
+ if (archived) {
1063
+ const archivedRow = (0, rail_pr_store_1.getPrDelivery)(c.db, row.id);
1064
+ if (archivedRow) {
1065
+ const archivedSnap = (0, rail_pr_store_1.toPrDeliverySnapshot)(archivedRow);
1066
+ c.broadcast((0, rail_pr_store_1.toRailPrStateMessage)(c.project.id, archivedSnap));
1067
+ if (archivedRow.origin_conversation_id) {
1068
+ (0, agent_chat_registry_1.getAgentChatManager)()?.updatePrDecisionCard(archivedRow.origin_conversation_id, (0, rail_pr_store_1.toPrDecisionCardEnvelope)(c.project.id, archivedSnap));
1069
+ }
1070
+ }
1071
+ }
1072
+ if (cleanupWarnings.length > 0) {
1073
+ return {
1074
+ ok: false,
1075
+ status: 409,
1076
+ error: 'checkout_unavailable',
1077
+ detail: cleanupWarnings[0],
1078
+ };
1079
+ }
1080
+ const checkedOut = await (0, project_git_1.checkoutProjectReviewBranch)(c.project.path, currentSnap.branch, currentSnap.deliverySha);
993
1081
  if (!checkedOut.ok) {
994
1082
  return { ok: false, status: 409, error: 'checkout_failed', detail: checkedOut.error };
995
1083
  }