specrails-desktop 2.29.2 → 2.29.4
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.
- package/client/dist/assets/{ActivityFeedPage-ClpD5CFj.js → ActivityFeedPage-CDMzpGXd.js} +1 -1
- package/client/dist/assets/{AgentBrowserCapture-CSPs9Ofc.js → AgentBrowserCapture-BVTacapZ.js} +1 -1
- package/client/dist/assets/{AgentModeCodePane-DwcyklA5.js → AgentModeCodePane-D9bhFyuy.js} +2 -2
- package/client/dist/assets/{AgentModeJobsPane-C69bqlMa.js → AgentModeJobsPane-DAjN8Au5.js} +1 -1
- package/client/dist/assets/{AgentsPage-pRiRr9-W.js → AgentsPage-CebykajE.js} +1 -1
- package/client/dist/assets/{AnalyticsPage-C14exKsC.js → AnalyticsPage-BiPrVvgv.js} +1 -1
- package/client/dist/assets/{CodePage-CK76JKm9.js → CodePage-Dw9eQFRS.js} +1 -1
- package/client/dist/assets/{DesktopAnalyticsPage-Cnv0f3XQ.js → DesktopAnalyticsPage-cCG_JAEi.js} +1 -1
- package/client/dist/assets/{DocsDialog-CHUm_Ym0.js → DocsDialog-C1kZ_mRP.js} +1 -1
- package/client/dist/assets/{DocsPage-COI-oDok.js → DocsPage-3GivxWJm.js} +1 -1
- package/client/dist/assets/{InteractiveJobComposer-CypM7R1F.js → InteractiveJobComposer-KuZXGdr8.js} +1 -1
- package/client/dist/assets/{JobDetailModal-B_W0H9dw.js → JobDetailModal-DWpRALkB.js} +1 -1
- package/client/dist/assets/{JobDetailPage-CeeXOoKS.js → JobDetailPage-mrXDKafj.js} +1 -1
- package/client/dist/assets/{JobsPage-CwhJG9jw.js → JobsPage-CUYXjcoc.js} +1 -1
- package/client/dist/assets/{LoopBuilderPage-C51t1WOM.js → LoopBuilderPage-CwjBD5Gm.js} +1 -1
- package/client/dist/assets/{LoopPreviewModal-Y2HgtIJu.js → LoopPreviewModal-CO9ton0-.js} +1 -1
- package/client/dist/assets/{LoopsPage-Bvn1Kw5V.js → LoopsPage-CLL5UuvN.js} +1 -1
- package/client/dist/assets/{MinimizedChatsContext-DiFvZDPW.js → MinimizedChatsContext-DJAkRYps.js} +1 -1
- package/client/dist/assets/{PluginsPage-BGPRJpdf.js → PluginsPage-DZvCC5Ri.js} +1 -1
- package/client/dist/assets/{ProjectSettingsDialog-D_GTyGLc.js → ProjectSettingsDialog-C7Hesadg.js} +1 -1
- package/client/dist/assets/{TemplatePreviewModal-szwBhd7h.js → TemplatePreviewModal-DczCzUzP.js} +1 -1
- package/client/dist/assets/{TicketDetailModal-9UEyK974.js → TicketDetailModal-CFh7GOv5.js} +4 -4
- package/client/dist/assets/{Trans-B_vKZ-Hb.js → Trans-Cm9rhdnG.js} +1 -1
- package/client/dist/assets/agent-BXc59ARK2.js +1 -0
- package/client/dist/assets/agent-BbIFXdDq.js +1 -0
- package/client/dist/assets/agent-BqEfA5u5.js +1 -0
- package/client/dist/assets/agent-BtGzgTC4.js +1 -0
- package/client/dist/assets/agent-CIhys_5d.js +1 -0
- package/client/dist/assets/agent-CJPaqsVR.js +1 -0
- package/client/dist/assets/agent-n-hY4GuL.js +1 -0
- package/client/dist/assets/agent-tJ-efa3_2.js +1 -0
- package/client/dist/assets/common-6MLbD-jM.js +1 -0
- package/client/dist/assets/common-BKGEu4rm.js +1 -0
- package/client/dist/assets/common-BNbnv8cE2.js +1 -0
- package/client/dist/assets/common-BqGWtDbt.js +1 -0
- package/client/dist/assets/common-DBtTzeYm2.js +1 -0
- package/client/dist/assets/common-DEW3Nr8n.js +1 -0
- package/client/dist/assets/{common-DVQuhs-m.js → common-DHsv9N5d.js} +1 -1
- package/client/dist/assets/common-mkrEcW-Y.js +1 -0
- package/client/dist/assets/dashboard-CHCEkds4.js +1 -0
- package/client/dist/assets/dashboard-Cqmmbh3l2.js +1 -0
- package/client/dist/assets/dashboard-CrlTV1ms.js +1 -0
- package/client/dist/assets/dashboard-Cslu_GCH.js +1 -0
- package/client/dist/assets/dashboard-DUU8-_Rg.js +1 -0
- package/client/dist/assets/dashboard-DweT3Y4R.js +1 -0
- package/client/dist/assets/dashboard-Dy2Um-US2.js +1 -0
- package/client/dist/assets/dashboard-WhzOQw1S.js +1 -0
- package/client/dist/assets/{dist-js-VwztRkGj.js → dist-js-BERxOT0X.js} +1 -1
- package/client/dist/assets/{dist-js-CcBCbb32.js → dist-js-Di8lflPo.js} +1 -1
- package/client/dist/assets/{formatDistanceToNow-ByHIauTQ.js → formatDistanceToNow-BAXZRbEO.js} +1 -1
- package/client/dist/assets/{getTimezoneOffsetInMilliseconds-BxHAksaU.js → getTimezoneOffsetInMilliseconds-DwzZXHtg.js} +1 -1
- package/client/dist/assets/{i18n-CgaRxpII.js → i18n-cK5pPbj8.js} +2 -2
- package/client/dist/assets/index-BT6xDQjB.css +2 -0
- package/client/dist/assets/index-CCq_kIrF.js +76 -0
- package/client/dist/assets/{spending-C4p06nen.js → spending-BLb5pj6G.js} +1 -1
- package/client/dist/assets/{useBrowserCaptureSession-2OqX0vap.js → useBrowserCaptureSession-DR1_jL56.js} +1 -1
- package/client/dist/assets/{useDesktop-X8YB9xzv.js → useDesktop-D4DxBGPU.js} +1 -1
- package/client/dist/index.html +14 -14
- package/docs/internals/safe-pr-review-flow.md +250 -38
- package/package.json +1 -1
- package/server/dist/active-pr-continuation.js +352 -53
- package/server/dist/agent-chat-manager.js +29 -12
- package/server/dist/agent-operator-prompt.js +9 -0
- package/server/dist/agent-store.js +19 -4
- package/server/dist/db.js +21 -0
- package/server/dist/mcp/tools/rails.js +8 -0
- package/server/dist/pr-lifecycle.js +100 -8
- package/server/dist/project-git.js +68 -3
- package/server/dist/rail-isolated-launch.js +719 -124
- package/server/dist/rail-pr-decision.js +1073 -181
- package/server/dist/rail-pr-recovery-git.js +235 -0
- package/server/dist/rail-pr-store.js +120 -9
- package/server/dist/rail-worktree-release.js +376 -4
- package/server/dist/rails-router.js +196 -10
- package/server/dist/worktree-manager.js +149 -18
- package/server/dist/worktree-overlay.js +166 -4
- package/client/dist/assets/agent-4rmXoo_p.js +0 -1
- package/client/dist/assets/agent-BxZuNGih.js +0 -1
- package/client/dist/assets/agent-C3uXOYv_.js +0 -1
- package/client/dist/assets/agent-CA8Y4wbw.js +0 -1
- package/client/dist/assets/agent-CASKrhG12.js +0 -1
- package/client/dist/assets/agent-Cv-jud5n2.js +0 -1
- package/client/dist/assets/agent-DitUxYh2.js +0 -1
- package/client/dist/assets/agent-Doow4X7o.js +0 -1
- package/client/dist/assets/common-CD1QHriA2.js +0 -1
- package/client/dist/assets/common-CwwPaRxx.js +0 -1
- package/client/dist/assets/common-Dg2jaX7d.js +0 -1
- package/client/dist/assets/common-DuBwj6tK.js +0 -1
- package/client/dist/assets/common-MRj9MHcc.js +0 -1
- package/client/dist/assets/common-O4ZpnMLp.js +0 -1
- package/client/dist/assets/common-fyeF0v7C2.js +0 -1
- package/client/dist/assets/dashboard-B_NkSHYm.js +0 -1
- package/client/dist/assets/dashboard-ClBb_3pq.js +0 -1
- package/client/dist/assets/dashboard-CpCmJvMB.js +0 -1
- package/client/dist/assets/dashboard-Czohu35O2.js +0 -1
- package/client/dist/assets/dashboard-DcjbD0UK.js +0 -1
- package/client/dist/assets/dashboard-Dvx5F3we.js +0 -1
- package/client/dist/assets/dashboard-R1bOPjBh2.js +0 -1
- package/client/dist/assets/dashboard-owcDucDi.js +0 -1
- package/client/dist/assets/index-BflYQpWm.css +0 -2
- package/client/dist/assets/index-C1_-BTXy.js +0 -72
|
@@ -65,6 +65,7 @@ exports.executePrDecision = executePrDecision;
|
|
|
65
65
|
* without git/gh/net — the route in rails-router validates and delegates here.
|
|
66
66
|
*/
|
|
67
67
|
const path = __importStar(require("path"));
|
|
68
|
+
const fs = __importStar(require("fs"));
|
|
68
69
|
const artifact_registry_1 = require("./artifact-registry");
|
|
69
70
|
const worktree_manager_1 = require("./worktree-manager");
|
|
70
71
|
const pr_publisher_1 = require("./pr-publisher");
|
|
@@ -82,7 +83,15 @@ const ids_1 = require("./ids");
|
|
|
82
83
|
const repo_lock_1 = require("./repo-lock");
|
|
83
84
|
const rail_pr_ticket_effects_1 = require("./rail-pr-ticket-effects");
|
|
84
85
|
const pr_lifecycle_1 = require("./pr-lifecycle");
|
|
85
|
-
|
|
86
|
+
const rail_pr_recovery_git_1 = require("./rail-pr-recovery-git");
|
|
87
|
+
exports.PR_DECISION_ACTIONS = ['create-pr', 'publish', 'discard', 'dismiss', 'poll-merge', 'reopen', 'merge-local', 'acknowledge-no-changes', 'recover-and-retry'];
|
|
88
|
+
function safetyArchiveRecorder(deps, row) {
|
|
89
|
+
return (archive) => {
|
|
90
|
+
if (!(0, rail_pr_store_1.appendPrDeliverySafetyArchive)(deps.db, row.id, archive)) {
|
|
91
|
+
throw new Error(`delivery ${row.id} disappeared while recording safety archive ${archive}`);
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
}
|
|
86
95
|
function isPrDecisionAction(v) {
|
|
87
96
|
return typeof v === 'string' && exports.PR_DECISION_ACTIONS.includes(v);
|
|
88
97
|
}
|
|
@@ -121,8 +130,7 @@ function actionAllowed(action, row) {
|
|
|
121
130
|
row.decision === 'pr_failed';
|
|
122
131
|
case 'dismiss':
|
|
123
132
|
return row.is_continuation === 1 && row.decision !== 'building' &&
|
|
124
|
-
row.decision !== 'merged' && row.decision !== 'discarded' && row.decision !== 'superseded'
|
|
125
|
-
row.delivery_outcome !== 'blocked';
|
|
133
|
+
row.decision !== 'merged' && row.decision !== 'discarded' && row.decision !== 'superseded';
|
|
126
134
|
case 'poll-merge':
|
|
127
135
|
return (row.decision === 'pr_draft' || row.decision === 'pr_ready' || row.decision === 'pr_closed') && row.pr_url !== null;
|
|
128
136
|
case 'reopen':
|
|
@@ -134,6 +142,12 @@ function actionAllowed(action, row) {
|
|
|
134
142
|
(row.decision === 'on_review' || row.decision === 'pr_failed' || row.decision === 'pr_draft');
|
|
135
143
|
case 'acknowledge-no-changes':
|
|
136
144
|
return row.decision === 'no_changes' && row.is_continuation !== 1;
|
|
145
|
+
case 'recover-and-retry':
|
|
146
|
+
return row.decision === 'pr_failed' && row.delivery_outcome === 'blocked' &&
|
|
147
|
+
(row.status_code === 'settlement_interrupted' || row.status_code === 'recovery_unavailable') &&
|
|
148
|
+
row.is_continuation === 1 &&
|
|
149
|
+
(row.implementation_outcome === 'succeeded' || row.implementation_outcome === 'partially_succeeded') &&
|
|
150
|
+
row.pr_url !== null && row.branch !== null;
|
|
137
151
|
}
|
|
138
152
|
}
|
|
139
153
|
async function executePrDecision(deps, input) {
|
|
@@ -173,6 +187,7 @@ async function executePrDecision(deps, input) {
|
|
|
173
187
|
case 'reopen': return await runReopen(deps, claimedRow);
|
|
174
188
|
case 'merge-local': return await runMergeLocal(deps, claimedRow);
|
|
175
189
|
case 'acknowledge-no-changes': return await runAcknowledgeNoChanges(deps, claimedRow);
|
|
190
|
+
case 'recover-and-retry': return await runRecoverAndRetry(deps, claimedRow);
|
|
176
191
|
}
|
|
177
192
|
return illegalAction(row.decision);
|
|
178
193
|
}
|
|
@@ -270,6 +285,49 @@ function ownedDeliveryBranches(row, snap, cleanupWarnings) {
|
|
|
270
285
|
owned.add(row.branch);
|
|
271
286
|
return owned;
|
|
272
287
|
}
|
|
288
|
+
function immutableHeadForOwnedBranch(row, snap, branch) {
|
|
289
|
+
if (branch === row.branch && row.delivery_sha && COMMIT_SHA_RE.test(row.delivery_sha)) {
|
|
290
|
+
return row.delivery_sha.toLowerCase();
|
|
291
|
+
}
|
|
292
|
+
const unitHeads = new Set(snap.branches
|
|
293
|
+
.filter((unit) => unitWasDelivered(unit) && unit.branch === branch && unit.finalSha && COMMIT_SHA_RE.test(unit.finalSha))
|
|
294
|
+
.map((unit) => unit.finalSha.toLowerCase()));
|
|
295
|
+
return unitHeads.size === 1 ? [...unitHeads][0] : null;
|
|
296
|
+
}
|
|
297
|
+
async function deleteOwnedBranchesIfUnchanged(deps, row, snap, cleanupWarnings, protectedBranches = new Set()) {
|
|
298
|
+
for (const branch of ownedDeliveryBranches(row, snap, cleanupWarnings)) {
|
|
299
|
+
if (!branch || branch === row.base_branch)
|
|
300
|
+
continue;
|
|
301
|
+
// A failed release leaves the linked worktree mounted for inspection. Do
|
|
302
|
+
// not even attempt to delete its checked-out branch: preserving the files
|
|
303
|
+
// while erasing their durable ref would make the recovery story depend on
|
|
304
|
+
// Git's incidental checked-out-branch rejection.
|
|
305
|
+
if (protectedBranches.has(branch)) {
|
|
306
|
+
cleanupWarnings.push(`branch ${branch}: retained with its worktree for inspection`);
|
|
307
|
+
continue;
|
|
308
|
+
}
|
|
309
|
+
try {
|
|
310
|
+
const expectedHead = immutableHeadForOwnedBranch(row, snap, branch);
|
|
311
|
+
if (!expectedHead) {
|
|
312
|
+
cleanupWarnings.push(`branch ${branch}: retained because no unambiguous immutable delivered HEAD was recorded`);
|
|
313
|
+
continue;
|
|
314
|
+
}
|
|
315
|
+
const observed = await deps.git.run(['rev-parse', '--verify', `refs/heads/${branch}`], deps.project.path);
|
|
316
|
+
const observedHead = observed.code === 0 ? observed.stdout.trim().toLowerCase() : '';
|
|
317
|
+
if (!COMMIT_SHA_RE.test(observedHead) || observedHead !== expectedHead) {
|
|
318
|
+
cleanupWarnings.push(`branch ${branch}: retained because its current tip no longer matches delivered HEAD ${expectedHead.slice(0, 12)}`);
|
|
319
|
+
continue;
|
|
320
|
+
}
|
|
321
|
+
const deleted = await deps.git.run(['branch', '-D', branch], deps.project.path);
|
|
322
|
+
if (deleted.code !== 0) {
|
|
323
|
+
cleanupWarnings.push(`branch ${branch}: ${(deleted.stderr.trim() || deleted.stdout.trim()).split('\n')[0] || `exit ${deleted.code}`}`);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
catch (err) {
|
|
327
|
+
cleanupWarnings.push(`branch ${branch}: ${err instanceof Error ? err.message : String(err)}`);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
}
|
|
273
331
|
function releasableWorktreeIds(deps, snap) {
|
|
274
332
|
const preservedBranches = new Set(snap.branches.filter((unit) => !unitWasDelivered(unit)).map((unit) => unit.branch));
|
|
275
333
|
return snap.worktreeIds.filter((id) => {
|
|
@@ -277,10 +335,6 @@ function releasableWorktreeIds(deps, snap) {
|
|
|
277
335
|
return !wt || !preservedBranches.has(wt.branch);
|
|
278
336
|
});
|
|
279
337
|
}
|
|
280
|
-
async function observePrLifecycle(deps, prUrl) {
|
|
281
|
-
const observed = await (0, pr_lifecycle_1.observePrLifecycle)(deps.exec, deps.project.path, prUrl);
|
|
282
|
-
return observed.ok ? { state: observed.state, isDraft: observed.isDraft } : null;
|
|
283
|
-
}
|
|
284
338
|
function mergedEvidenceDetail(observation) {
|
|
285
339
|
return observation.includesExpectedSha === null
|
|
286
340
|
? 'GitHub did not return enough commit evidence to prove the previous PR included the verified implementation commit'
|
|
@@ -291,15 +345,20 @@ async function rerouteFromStalePr(deps, row, detail, exactWasPushed) {
|
|
|
291
345
|
const cleanupWarnings = await (0, rail_worktree_release_1.releaseRailWorktrees)({
|
|
292
346
|
db: deps.db, git: deps.git, repoDir: deps.project.path,
|
|
293
347
|
worktreeIds: releasableWorktreeIds(deps, snap),
|
|
348
|
+
expectedHeadByBranch: (0, rail_worktree_release_1.durableBranchHeads)(snap.branches),
|
|
349
|
+
overlayEvidenceByBranch: (0, rail_worktree_release_1.durableOverlayCleanupEvidence)(snap.branches),
|
|
350
|
+
settlementIgnoredByBranch: (0, rail_worktree_release_1.durableSettlementIgnoredPaths)(snap.branches),
|
|
351
|
+
onSafetyArchive: safetyArchiveRecorder(deps, row),
|
|
294
352
|
});
|
|
295
353
|
const partial = row.implementation_outcome === 'partially_succeeded';
|
|
354
|
+
const preserved = row.delivery_sha ? 'the preserved exact commit' : 'the preserved recorded branch';
|
|
296
355
|
const conflict = casTransition(deps, row, 'on_review', {
|
|
297
356
|
prUrl: null,
|
|
298
357
|
prNumber: null,
|
|
299
358
|
prState: exactWasPushed ? 'pushed' : 'local-only',
|
|
300
359
|
deliveryOutcome: partial ? 'partial' : 'ready',
|
|
301
360
|
statusCode: cleanupWarnings.length > 0 ? 'cleanup_incomplete' : partial ? 'partial_success' : 'ready_for_review',
|
|
302
|
-
statusDetail: `${detail}; create a new draft PR from
|
|
361
|
+
statusDetail: `${detail}; create a new draft PR from ${preserved}`,
|
|
303
362
|
cleanupWarnings,
|
|
304
363
|
// The replacement PR belongs to this generation. Per-unit branch ownership
|
|
305
364
|
// remains unchanged so a borrowed/pre-existing ref is still never deleted.
|
|
@@ -320,23 +379,78 @@ async function rerouteFromStalePr(deps, row, detail, exactWasPushed) {
|
|
|
320
379
|
},
|
|
321
380
|
};
|
|
322
381
|
}
|
|
323
|
-
async function settleObservedExistingPr(deps, row, observation) {
|
|
382
|
+
async function settleObservedExistingPr(deps, row, observation, pushed) {
|
|
324
383
|
const next = observation.state === 'MERGED' || observation.isDraft === false ? 'pr_ready' : 'pr_draft';
|
|
325
384
|
const snap = (0, rail_pr_store_1.toPrDeliverySnapshot)(row);
|
|
326
385
|
const cleanupWarnings = await (0, rail_worktree_release_1.releaseRailWorktrees)({
|
|
327
386
|
db: deps.db, git: deps.git, repoDir: deps.project.path,
|
|
328
387
|
worktreeIds: releasableWorktreeIds(deps, snap),
|
|
388
|
+
expectedHeadByBranch: (0, rail_worktree_release_1.durableBranchHeads)(snap.branches),
|
|
389
|
+
overlayEvidenceByBranch: (0, rail_worktree_release_1.durableOverlayCleanupEvidence)(snap.branches),
|
|
390
|
+
settlementIgnoredByBranch: (0, rail_worktree_release_1.durableSettlementIgnoredPaths)(snap.branches),
|
|
391
|
+
onSafetyArchive: safetyArchiveRecorder(deps, row),
|
|
329
392
|
});
|
|
330
393
|
const conflict = casTransition(deps, row, next, {
|
|
331
394
|
prState: 'pr-created',
|
|
332
395
|
deliveryOutcome: 'delivered',
|
|
333
396
|
statusCode: cleanupWarnings.length > 0 ? 'cleanup_incomplete' : next === 'pr_ready' ? 'pr_ready' : 'existing_pr_updated',
|
|
397
|
+
statusDetail: null,
|
|
334
398
|
cleanupWarnings,
|
|
335
399
|
});
|
|
336
400
|
if (conflict)
|
|
337
401
|
return conflict;
|
|
402
|
+
await releaseDeliveredRecoveryLineage(deps, row, row.delivery_sha);
|
|
338
403
|
const after = finalizeTransition(deps, row.id);
|
|
339
|
-
return {
|
|
404
|
+
return {
|
|
405
|
+
status: 200,
|
|
406
|
+
body: {
|
|
407
|
+
ok: true,
|
|
408
|
+
decision: next,
|
|
409
|
+
prUrl: after?.prUrl ?? row.pr_url,
|
|
410
|
+
prState: 'pr-created',
|
|
411
|
+
deliveryVerified: true,
|
|
412
|
+
verifiedSha: row.delivery_sha,
|
|
413
|
+
remoteHeadSha: observation.headRefOid,
|
|
414
|
+
pushed,
|
|
415
|
+
},
|
|
416
|
+
};
|
|
417
|
+
}
|
|
418
|
+
async function settleObservedClosedPr(deps, row, observation) {
|
|
419
|
+
const snap = (0, rail_pr_store_1.toPrDeliverySnapshot)(row);
|
|
420
|
+
const cleanupWarnings = await (0, rail_worktree_release_1.releaseRailWorktrees)({
|
|
421
|
+
db: deps.db, git: deps.git, repoDir: deps.project.path,
|
|
422
|
+
worktreeIds: releasableWorktreeIds(deps, snap),
|
|
423
|
+
expectedHeadByBranch: (0, rail_worktree_release_1.durableBranchHeads)(snap.branches),
|
|
424
|
+
overlayEvidenceByBranch: (0, rail_worktree_release_1.durableOverlayCleanupEvidence)(snap.branches),
|
|
425
|
+
settlementIgnoredByBranch: (0, rail_worktree_release_1.durableSettlementIgnoredPaths)(snap.branches),
|
|
426
|
+
onSafetyArchive: safetyArchiveRecorder(deps, row),
|
|
427
|
+
});
|
|
428
|
+
const conflict = casTransition(deps, row, 'pr_closed', {
|
|
429
|
+
prState: 'pr-created',
|
|
430
|
+
deliveryOutcome: 'delivered',
|
|
431
|
+
statusCode: cleanupWarnings.length > 0 ? 'cleanup_incomplete' : 'pr_closed',
|
|
432
|
+
statusDetail: null,
|
|
433
|
+
cleanupWarnings,
|
|
434
|
+
});
|
|
435
|
+
if (conflict)
|
|
436
|
+
return conflict;
|
|
437
|
+
await releaseDeliveredRecoveryLineage(deps, row, row.delivery_sha);
|
|
438
|
+
const after = finalizeTransition(deps, row.id);
|
|
439
|
+
return {
|
|
440
|
+
status: 200,
|
|
441
|
+
body: {
|
|
442
|
+
ok: true,
|
|
443
|
+
decision: 'pr_closed',
|
|
444
|
+
closed: true,
|
|
445
|
+
merged: false,
|
|
446
|
+
prUrl: after?.prUrl ?? row.pr_url,
|
|
447
|
+
prState: 'pr-created',
|
|
448
|
+
deliveryVerified: true,
|
|
449
|
+
verifiedSha: row.delivery_sha,
|
|
450
|
+
remoteHeadSha: observation.headRefOid,
|
|
451
|
+
pushed: false,
|
|
452
|
+
},
|
|
453
|
+
};
|
|
340
454
|
}
|
|
341
455
|
function persistRetryablePrObservationFailure(deps, row, detail) {
|
|
342
456
|
const conflict = casTransition(deps, row, 'pr_failed', {
|
|
@@ -360,6 +474,580 @@ function persistMovedPrHead(deps, row, detail) {
|
|
|
360
474
|
finalizeTransition(deps, row.id);
|
|
361
475
|
return { status: 200, body: { ok: true, decision: 'pr_failed', prUrl: row.pr_url, detail } };
|
|
362
476
|
}
|
|
477
|
+
function persistRecoveryBlocked(deps, row, detail, deliverySha) {
|
|
478
|
+
const conflict = casTransition(deps, row, 'pr_failed', {
|
|
479
|
+
deliveryOutcome: 'blocked',
|
|
480
|
+
statusCode: 'recovery_unavailable',
|
|
481
|
+
statusDetail: detail,
|
|
482
|
+
...(deliverySha !== undefined ? { deliverySha } : {}),
|
|
483
|
+
isContinuation: true,
|
|
484
|
+
});
|
|
485
|
+
if (conflict)
|
|
486
|
+
return conflict;
|
|
487
|
+
finalizeTransition(deps, row.id);
|
|
488
|
+
return {
|
|
489
|
+
status: 200,
|
|
490
|
+
body: {
|
|
491
|
+
ok: true,
|
|
492
|
+
decision: 'pr_failed',
|
|
493
|
+
prUrl: row.pr_url,
|
|
494
|
+
recoveryUnavailable: true,
|
|
495
|
+
detail,
|
|
496
|
+
},
|
|
497
|
+
};
|
|
498
|
+
}
|
|
499
|
+
function persistProtectedRecoveryRetry(deps, row, snap, matchingUnitIndexes, runId, candidateSha, detail) {
|
|
500
|
+
const recoveredIndexes = new Set(matchingUnitIndexes);
|
|
501
|
+
const recoveredBranches = snap.branches.map((unit, index) => recoveredIndexes.has(index)
|
|
502
|
+
? {
|
|
503
|
+
...unit,
|
|
504
|
+
runId,
|
|
505
|
+
branch: row.branch,
|
|
506
|
+
succeeded: true,
|
|
507
|
+
implementationOutcome: 'succeeded',
|
|
508
|
+
deliveryOutcome: 'ready',
|
|
509
|
+
finalSha: candidateSha,
|
|
510
|
+
changed: unit.changed ?? (unit.initialSha ? unit.initialSha.toLowerCase() !== candidateSha.toLowerCase() : true),
|
|
511
|
+
failureCode: null,
|
|
512
|
+
branchOwnership: 'borrowed-pr',
|
|
513
|
+
}
|
|
514
|
+
: unit);
|
|
515
|
+
const conflict = casTransition(deps, row, 'pr_failed', {
|
|
516
|
+
branches: recoveredBranches,
|
|
517
|
+
deliverySha: candidateSha,
|
|
518
|
+
deliveryOutcome: 'retryable_failure',
|
|
519
|
+
statusCode: 'settlement_interrupted',
|
|
520
|
+
statusDetail: detail,
|
|
521
|
+
isContinuation: true,
|
|
522
|
+
});
|
|
523
|
+
if (conflict)
|
|
524
|
+
return conflict;
|
|
525
|
+
finalizeTransition(deps, row.id);
|
|
526
|
+
return {
|
|
527
|
+
status: 200,
|
|
528
|
+
body: {
|
|
529
|
+
ok: true,
|
|
530
|
+
decision: 'pr_failed',
|
|
531
|
+
prUrl: row.pr_url,
|
|
532
|
+
recovered: true,
|
|
533
|
+
detail,
|
|
534
|
+
},
|
|
535
|
+
};
|
|
536
|
+
}
|
|
537
|
+
async function exactRefSha(deps, cwd, ref) {
|
|
538
|
+
try {
|
|
539
|
+
const result = await deps.git.run(['rev-parse', '--verify', ref], cwd);
|
|
540
|
+
const sha = result.code === 0 ? result.stdout.trim() : '';
|
|
541
|
+
return COMMIT_SHA_RE.test(sha) ? sha : null;
|
|
542
|
+
}
|
|
543
|
+
catch {
|
|
544
|
+
return null;
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
async function isFastForwardCandidate(deps, baselineSha, candidateSha) {
|
|
548
|
+
if (baselineSha === candidateSha)
|
|
549
|
+
return true;
|
|
550
|
+
try {
|
|
551
|
+
return (await deps.git.run(['merge-base', '--is-ancestor', baselineSha, candidateSha], deps.project.path)).code === 0;
|
|
552
|
+
}
|
|
553
|
+
catch {
|
|
554
|
+
return false;
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
async function releaseDeliveredRecoveryLineage(deps, row, deliveredSha) {
|
|
558
|
+
if (!deliveredSha || !COMMIT_SHA_RE.test(deliveredSha))
|
|
559
|
+
return;
|
|
560
|
+
const currentProtection = await (0, rail_pr_recovery_git_1.inspectRecoveryCommitProtection)(deps.git, deps.project.path, row.id);
|
|
561
|
+
if (currentProtection.kind === 'present' &&
|
|
562
|
+
currentProtection.sha.toLowerCase() === deliveredSha.toLowerCase()) {
|
|
563
|
+
await (0, rail_pr_recovery_git_1.releaseRecoveryCommit)(deps.git, deps.project.path, row.id, deliveredSha);
|
|
564
|
+
}
|
|
565
|
+
const visited = new Set([row.id]);
|
|
566
|
+
let predecessorId = row.supersedes_delivery_id;
|
|
567
|
+
for (let depth = 0; predecessorId && depth < 32 && !visited.has(predecessorId); depth++) {
|
|
568
|
+
visited.add(predecessorId);
|
|
569
|
+
const predecessor = (0, rail_pr_store_1.getPrDelivery)(deps.db, predecessorId);
|
|
570
|
+
if (!predecessor)
|
|
571
|
+
break;
|
|
572
|
+
if (predecessor.decision !== 'discarded' && predecessor.decision !== 'superseded')
|
|
573
|
+
break;
|
|
574
|
+
const protection = await (0, rail_pr_recovery_git_1.inspectRecoveryCommitProtection)(deps.git, deps.project.path, predecessor.id);
|
|
575
|
+
if (protection.kind === 'present' && await isFastForwardCandidate(deps, protection.sha, deliveredSha)) {
|
|
576
|
+
await (0, rail_pr_recovery_git_1.releaseRecoveryCommit)(deps.git, deps.project.path, predecessor.id, protection.sha);
|
|
577
|
+
}
|
|
578
|
+
predecessorId = predecessor.supersedes_delivery_id;
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
function parseRegisteredGitWorktrees(stdout) {
|
|
582
|
+
const worktrees = [];
|
|
583
|
+
let current = null;
|
|
584
|
+
const finish = () => {
|
|
585
|
+
if (current)
|
|
586
|
+
worktrees.push(current);
|
|
587
|
+
current = null;
|
|
588
|
+
};
|
|
589
|
+
for (const field of stdout.split('\0')) {
|
|
590
|
+
if (field === '') {
|
|
591
|
+
finish();
|
|
592
|
+
continue;
|
|
593
|
+
}
|
|
594
|
+
if (field.startsWith('worktree ')) {
|
|
595
|
+
finish();
|
|
596
|
+
current = { worktreePath: field.slice('worktree '.length), head: null, branch: null };
|
|
597
|
+
continue;
|
|
598
|
+
}
|
|
599
|
+
if (!current)
|
|
600
|
+
continue;
|
|
601
|
+
if (field.startsWith('HEAD '))
|
|
602
|
+
current.head = field.slice('HEAD '.length);
|
|
603
|
+
if (field.startsWith('branch '))
|
|
604
|
+
current.branch = field.slice('branch '.length);
|
|
605
|
+
}
|
|
606
|
+
finish();
|
|
607
|
+
return worktrees;
|
|
608
|
+
}
|
|
609
|
+
/** Authenticate a ledger path as a live, non-symlink worktree registered by
|
|
610
|
+
* this exact repository. The main checkout and ordinary directories fail
|
|
611
|
+
* closed. Callers repeat this immediately before staging to close the useful
|
|
612
|
+
* path-replacement window between inspection and mutation. */
|
|
613
|
+
async function authenticateRecoveryWorktree(deps, ledgerPath, expectedBranch, expectedHead) {
|
|
614
|
+
if (!path.isAbsolute(ledgerPath)) {
|
|
615
|
+
return { ok: false, detail: 'the recorded worktree path is not absolute' };
|
|
616
|
+
}
|
|
617
|
+
let realPath;
|
|
618
|
+
let projectRealPath;
|
|
619
|
+
try {
|
|
620
|
+
const ledgerStat = fs.lstatSync(ledgerPath);
|
|
621
|
+
if (ledgerStat.isSymbolicLink()) {
|
|
622
|
+
return { ok: false, detail: 'the recorded worktree path is a symbolic link' };
|
|
623
|
+
}
|
|
624
|
+
if (!ledgerStat.isDirectory()) {
|
|
625
|
+
return { ok: false, detail: 'the recorded worktree path is not a directory' };
|
|
626
|
+
}
|
|
627
|
+
realPath = fs.realpathSync(ledgerPath);
|
|
628
|
+
projectRealPath = fs.realpathSync(deps.project.path);
|
|
629
|
+
if (!fs.statSync(realPath).isDirectory()) {
|
|
630
|
+
return { ok: false, detail: 'the canonical worktree path is not a directory' };
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
catch {
|
|
634
|
+
return { ok: false, detail: 'the recorded worktree path could not be resolved safely' };
|
|
635
|
+
}
|
|
636
|
+
if (realPath === projectRealPath) {
|
|
637
|
+
return { ok: false, detail: 'the recorded worktree path resolves to the main project checkout' };
|
|
638
|
+
}
|
|
639
|
+
let listed;
|
|
640
|
+
try {
|
|
641
|
+
listed = await deps.git.run(['worktree', 'list', '--porcelain', '-z'], deps.project.path);
|
|
642
|
+
}
|
|
643
|
+
catch {
|
|
644
|
+
return { ok: false, detail: 'Git worktree registration could not be verified' };
|
|
645
|
+
}
|
|
646
|
+
if (listed.code !== 0) {
|
|
647
|
+
return { ok: false, detail: 'Git worktree registration could not be verified' };
|
|
648
|
+
}
|
|
649
|
+
const matches = parseRegisteredGitWorktrees(listed.stdout).filter((registered) => {
|
|
650
|
+
try {
|
|
651
|
+
return fs.realpathSync(registered.worktreePath) === realPath;
|
|
652
|
+
}
|
|
653
|
+
catch {
|
|
654
|
+
return false;
|
|
655
|
+
}
|
|
656
|
+
});
|
|
657
|
+
if (matches.length !== 1) {
|
|
658
|
+
return { ok: false, detail: 'the recorded path is not uniquely registered as a Git worktree of this project' };
|
|
659
|
+
}
|
|
660
|
+
const registered = matches[0];
|
|
661
|
+
if (registered.branch !== `refs/heads/${expectedBranch}`) {
|
|
662
|
+
return { ok: false, detail: 'the registered Git worktree is not on the recorded delivery branch' };
|
|
663
|
+
}
|
|
664
|
+
if (!registered.head || !COMMIT_SHA_RE.test(registered.head)) {
|
|
665
|
+
return { ok: false, detail: 'the registered Git worktree has no verifiable commit identity' };
|
|
666
|
+
}
|
|
667
|
+
if (expectedHead && registered.head.toLowerCase() !== expectedHead.toLowerCase()) {
|
|
668
|
+
return { ok: false, detail: 'the registered Git worktree changed commits before staging' };
|
|
669
|
+
}
|
|
670
|
+
return { ok: true, realPath, head: registered.head };
|
|
671
|
+
}
|
|
672
|
+
/**
|
|
673
|
+
* Explicit recovery for a legacy successful continuation whose automatic
|
|
674
|
+
* causal scan could not freeze a commit. Unlike Checkout, this operates only
|
|
675
|
+
* on the delivery-owned isolated worktree/branch and never changes the user's
|
|
676
|
+
* main checkout. The user's confirmation authorizes adopting already-committed
|
|
677
|
+
* progress on that exact branch, but only as a non-force fast-forward of the
|
|
678
|
+
* live PR head.
|
|
679
|
+
*/
|
|
680
|
+
async function runRecoverAndRetry(deps, row) {
|
|
681
|
+
if (!row.pr_url || !row.branch || !row.operation_token)
|
|
682
|
+
return illegalAction(row.decision);
|
|
683
|
+
const snap = (0, rail_pr_store_1.toPrDeliverySnapshot)(row);
|
|
684
|
+
const runIds = [...new Set(snap.runIds.filter((runId) => typeof runId === 'string' && runId.length > 0))];
|
|
685
|
+
if (runIds.length !== 1) {
|
|
686
|
+
return persistRecoveryBlocked(deps, row, `Commit & retry push requires exactly one recorded implementation run; found ${runIds.length}. No local evidence was changed.`);
|
|
687
|
+
}
|
|
688
|
+
const runId = runIds[0];
|
|
689
|
+
const matchingUnitIndexes = snap.branches
|
|
690
|
+
.map((branch, index) => branch.branch === row.branch && branch.runId === runId ? index : -1)
|
|
691
|
+
.filter((index) => index >= 0);
|
|
692
|
+
if (matchingUnitIndexes.length === 0) {
|
|
693
|
+
return persistRecoveryBlocked(deps, row, 'Commit & retry push could not prove an exact recorded unit for this run and PR branch. No local evidence was changed.');
|
|
694
|
+
}
|
|
695
|
+
const matchingUnits = matchingUnitIndexes.map((index) => snap.branches[index]);
|
|
696
|
+
const recordedFinalValues = matchingUnits.map((unit) => unit.finalSha);
|
|
697
|
+
const hasAnyRecordedFinal = recordedFinalValues.some((sha) => sha != null);
|
|
698
|
+
const recordedFinalShas = [...new Set(recordedFinalValues
|
|
699
|
+
.filter((sha) => typeof sha === 'string' && COMMIT_SHA_RE.test(sha))
|
|
700
|
+
.map((sha) => sha.toLowerCase()))];
|
|
701
|
+
const recordedFinalEvidenceUnsafe = hasAnyRecordedFinal && (recordedFinalValues.some((sha) => typeof sha !== 'string' || !COMMIT_SHA_RE.test(sha)) ||
|
|
702
|
+
recordedFinalShas.length !== 1);
|
|
703
|
+
// Durable final evidence is one sufficient ownership proof, not a veto over
|
|
704
|
+
// the independent exact run-marker proof. Incomplete/disagreeing migrated
|
|
705
|
+
// unit fields are ignored here; a unique causal commit may still recover the
|
|
706
|
+
// result without adopting any of those disputed SHAs.
|
|
707
|
+
const recordedFinalSha = recordedFinalEvidenceUnsafe ? null : recordedFinalShas[0] ?? null;
|
|
708
|
+
let discovery = null;
|
|
709
|
+
const discoverCandidate = async () => {
|
|
710
|
+
if (!discovery) {
|
|
711
|
+
discovery = await (0, rail_pr_recovery_git_1.discoverRunMarkedCommit)(deps.git, deps.project.path, runId);
|
|
712
|
+
}
|
|
713
|
+
return discovery;
|
|
714
|
+
};
|
|
715
|
+
const ownedWorktrees = snap.worktreeIds
|
|
716
|
+
.map((worktreeId) => (0, rail_worktrees_store_1.getRailWorktree)(deps.db, worktreeId))
|
|
717
|
+
.filter((worktree) => Boolean(worktree && worktree.branch === row.branch && worktree.run_id === runId));
|
|
718
|
+
const presentWorktrees = ownedWorktrees.filter((worktree) => {
|
|
719
|
+
try {
|
|
720
|
+
fs.lstatSync(worktree.worktree_path);
|
|
721
|
+
return true;
|
|
722
|
+
}
|
|
723
|
+
catch {
|
|
724
|
+
return false;
|
|
725
|
+
}
|
|
726
|
+
});
|
|
727
|
+
const worktree = presentWorktrees[0] ?? null;
|
|
728
|
+
let preprotectedCandidateSha = null;
|
|
729
|
+
let preprotectedCandidateSource = null;
|
|
730
|
+
// Discover and pin an exact object before any network dependency, even when
|
|
731
|
+
// a ledger path still exists. A recreated/unsafe/dirty path must not hide an
|
|
732
|
+
// older run-owned orphan or leave it exposed to Git GC.
|
|
733
|
+
{
|
|
734
|
+
if (recordedFinalSha && await commitObjectExists(deps, recordedFinalSha)) {
|
|
735
|
+
preprotectedCandidateSha = recordedFinalSha;
|
|
736
|
+
preprotectedCandidateSource = 'recorded-final';
|
|
737
|
+
}
|
|
738
|
+
else {
|
|
739
|
+
const found = await discoverCandidate();
|
|
740
|
+
if (found.kind === 'ambiguous') {
|
|
741
|
+
return persistRecoveryBlocked(deps, row, `Git found ${found.count} different run-marked commits. No ambiguous object, branch, or worktree was changed.`);
|
|
742
|
+
}
|
|
743
|
+
if (found.kind === 'scan_failed') {
|
|
744
|
+
return persistRecoveryBlocked(deps, row, `${found.detail}. No branch, worktree, or object was changed.`);
|
|
745
|
+
}
|
|
746
|
+
if (found.kind === 'unique') {
|
|
747
|
+
preprotectedCandidateSha = found.sha;
|
|
748
|
+
preprotectedCandidateSource = 'run-marker';
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
if (preprotectedCandidateSha) {
|
|
752
|
+
const protection = await (0, rail_pr_recovery_git_1.protectRecoveryCommit)(deps.git, deps.project.path, row.id, preprotectedCandidateSha);
|
|
753
|
+
if (!protection.ok) {
|
|
754
|
+
return persistRecoveryBlocked(deps, row, `${protection.detail}. The candidate object and every user-visible branch were left unchanged.`);
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
if (presentWorktrees.length > 1) {
|
|
759
|
+
return persistRecoveryBlocked(deps, row, 'Multiple live worktrees claim this run and branch, so Specrails refused to choose one. Every local result remains intact.', preprotectedCandidateSha);
|
|
760
|
+
}
|
|
761
|
+
const observed = await (0, pr_lifecycle_1.observePrLifecycle)(deps.exec, deps.project.path, row.pr_url, preprotectedCandidateSha);
|
|
762
|
+
if (!observed.ok) {
|
|
763
|
+
if (preprotectedCandidateSha) {
|
|
764
|
+
if (worktree) {
|
|
765
|
+
return persistRecoveryBlocked(deps, row, `Protected an exact run-owned commit, but a live worktree also exists and the PR could not be observed: ${observed.detail}. Both local results were preserved and neither was selected or pushed.`, preprotectedCandidateSha);
|
|
766
|
+
}
|
|
767
|
+
const durablyProvenNoChange = row.implementation_outcome === 'succeeded' &&
|
|
768
|
+
matchingUnitIndexes.length === snap.branches.length &&
|
|
769
|
+
matchingUnits.every((unit) => (unit.changed === false &&
|
|
770
|
+
unit.initialSha?.toLowerCase() === preprotectedCandidateSha.toLowerCase() &&
|
|
771
|
+
unit.finalSha?.toLowerCase() === preprotectedCandidateSha.toLowerCase()));
|
|
772
|
+
const detail = `Recovered and protected the exact run-owned commit, but the recorded PR could not be observed: ${observed.detail}; retry will revalidate before any push.`;
|
|
773
|
+
if (durablyProvenNoChange) {
|
|
774
|
+
return persistRecoveryBlocked(deps, row, detail, preprotectedCandidateSha);
|
|
775
|
+
}
|
|
776
|
+
return persistProtectedRecoveryRetry(deps, row, snap, matchingUnitIndexes, runId, preprotectedCandidateSha, detail);
|
|
777
|
+
}
|
|
778
|
+
return persistRecoveryBlocked(deps, row, `The existing PR could not be verified before local recovery: ${observed.detail}. No local evidence was changed.`);
|
|
779
|
+
}
|
|
780
|
+
if (!(0, pr_lifecycle_1.isExactOpenPr)(observed, row.branch, row.base_branch) || !observed.headRefOid) {
|
|
781
|
+
if (preprotectedCandidateSha) {
|
|
782
|
+
if (worktree) {
|
|
783
|
+
return persistRecoveryBlocked(deps, row, 'A protected run-owned commit and a live worktree both exist while the recorded PR lifecycle changed. Specrails preserved both and refused to choose or push either automatically.', preprotectedCandidateSha);
|
|
784
|
+
}
|
|
785
|
+
const frozen = persistProtectedRecoveryRetry(deps, row, snap, matchingUnitIndexes, runId, preprotectedCandidateSha, 'Protected the exact run-owned commit; revalidating the changed PR lifecycle before any push.');
|
|
786
|
+
if (frozen.status !== 200 || frozen.body.decision !== 'pr_failed')
|
|
787
|
+
return frozen;
|
|
788
|
+
const recoveredRow = (0, rail_pr_store_1.getPrDelivery)(deps.db, row.id);
|
|
789
|
+
if (!recoveredRow || recoveredRow.operation_token !== row.operation_token) {
|
|
790
|
+
return staleDecision(recoveredRow?.decision ?? row.decision);
|
|
791
|
+
}
|
|
792
|
+
return retryExistingPrFollowupPush(deps, recoveredRow);
|
|
793
|
+
}
|
|
794
|
+
return persistRecoveryBlocked(deps, row, 'The recorded PR is no longer an exact open head/base recovery target. No local evidence was changed.');
|
|
795
|
+
}
|
|
796
|
+
const baselineSha = observed.headRefOid;
|
|
797
|
+
// Ensure the live remote baseline object is available for the ancestry proof
|
|
798
|
+
// without advancing any local branch or touching the main checkout.
|
|
799
|
+
if (!(await commitObjectExists(deps, baselineSha))) {
|
|
800
|
+
try {
|
|
801
|
+
await deps.git.run(['fetch', 'origin', `refs/heads/${row.branch}`], deps.project.path);
|
|
802
|
+
}
|
|
803
|
+
catch { /* the exact object check below remains authoritative */ }
|
|
804
|
+
}
|
|
805
|
+
if (!(await commitObjectExists(deps, baselineSha))) {
|
|
806
|
+
return persistRecoveryBlocked(deps, row, 'The live PR head object is unavailable locally, so fast-forward recovery cannot be proven. No local evidence was changed.', preprotectedCandidateSha);
|
|
807
|
+
}
|
|
808
|
+
let candidateSha = preprotectedCandidateSha;
|
|
809
|
+
let candidateOwned = preprotectedCandidateSha !== null;
|
|
810
|
+
let candidateSource = preprotectedCandidateSource ?? 'baseline';
|
|
811
|
+
let committed = false;
|
|
812
|
+
if (worktree) {
|
|
813
|
+
const authenticated = await authenticateRecoveryWorktree(deps, worktree.worktree_path, row.branch);
|
|
814
|
+
if (!authenticated.ok) {
|
|
815
|
+
return persistRecoveryBlocked(deps, row, `The preserved path at ${worktree.worktree_path} could not be authenticated safely: ${authenticated.detail}. Nothing was staged, pushed, or removed.`, preprotectedCandidateSha);
|
|
816
|
+
}
|
|
817
|
+
const authenticatedPath = authenticated.realPath;
|
|
818
|
+
const [actualBranch, worktreeHead, branchHead] = await Promise.all([
|
|
819
|
+
deps.git.run(['rev-parse', '--abbrev-ref', 'HEAD'], authenticatedPath).catch(() => ({ code: 1, stdout: '', stderr: '' })),
|
|
820
|
+
exactRefSha(deps, authenticatedPath, 'HEAD'),
|
|
821
|
+
exactRefSha(deps, deps.project.path, `refs/heads/${row.branch}`),
|
|
822
|
+
]);
|
|
823
|
+
if (actualBranch.code !== 0 || actualBranch.stdout.trim() !== row.branch ||
|
|
824
|
+
!worktreeHead || !branchHead || worktreeHead !== branchHead ||
|
|
825
|
+
authenticated.head.toLowerCase() !== worktreeHead.toLowerCase()) {
|
|
826
|
+
return persistRecoveryBlocked(deps, row, `The preserved worktree at ${worktree.worktree_path} is not on the exact recorded branch/HEAD. It was left untouched.`, preprotectedCandidateSha);
|
|
827
|
+
}
|
|
828
|
+
if (preprotectedCandidateSha &&
|
|
829
|
+
worktreeHead.toLowerCase() !== preprotectedCandidateSha.toLowerCase()) {
|
|
830
|
+
return persistRecoveryBlocked(deps, row, `A protected run-owned commit and a different live worktree result both exist. Specrails preserved both and refused to choose or bundle them automatically. Inspect ${worktree.worktree_path} before retrying or discarding either result.`, preprotectedCandidateSha);
|
|
831
|
+
}
|
|
832
|
+
if (!(await isFastForwardCandidate(deps, baselineSha, worktreeHead))) {
|
|
833
|
+
return persistRecoveryBlocked(deps, row, `The preserved branch diverges from the live PR head. The worktree at ${worktree.worktree_path} was left untouched.`, preprotectedCandidateSha);
|
|
834
|
+
}
|
|
835
|
+
let skipWorktreeCommit = false;
|
|
836
|
+
if (worktreeHead === baselineSha) {
|
|
837
|
+
candidateOwned = true;
|
|
838
|
+
candidateSource = 'baseline';
|
|
839
|
+
}
|
|
840
|
+
else if (recordedFinalSha && worktreeHead.toLowerCase() === recordedFinalSha) {
|
|
841
|
+
candidateOwned = true;
|
|
842
|
+
candidateSource = 'recorded-final';
|
|
843
|
+
}
|
|
844
|
+
else {
|
|
845
|
+
const found = await discoverCandidate();
|
|
846
|
+
if (found.kind === 'unique' && found.sha === worktreeHead.toLowerCase()) {
|
|
847
|
+
candidateOwned = true;
|
|
848
|
+
candidateSource = 'run-marker';
|
|
849
|
+
}
|
|
850
|
+
else if (found.kind === 'unique') {
|
|
851
|
+
// A later/unrelated checked-out tip must not be bundled into the
|
|
852
|
+
// recovered result. Preserve it untouched and use only the exact
|
|
853
|
+
// run-owned object discovered independently.
|
|
854
|
+
candidateSha = found.sha;
|
|
855
|
+
candidateOwned = true;
|
|
856
|
+
candidateSource = 'run-marker';
|
|
857
|
+
skipWorktreeCommit = true;
|
|
858
|
+
}
|
|
859
|
+
else {
|
|
860
|
+
const reason = found.kind === 'ambiguous'
|
|
861
|
+
? `Git found ${found.count} run-marked commits, so no unique worktree baseline could be chosen.`
|
|
862
|
+
: found.kind === 'scan_failed'
|
|
863
|
+
? `${found.detail}.`
|
|
864
|
+
: 'The preserved worktree branch advanced beyond every delivery-owned commit.';
|
|
865
|
+
return persistRecoveryBlocked(deps, row, `${reason} The worktree at ${worktree.worktree_path} was left untouched.`);
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
if (!skipWorktreeCommit) {
|
|
869
|
+
deps.assertAdmission?.();
|
|
870
|
+
const reauthenticated = await authenticateRecoveryWorktree(deps, worktree.worktree_path, row.branch, worktreeHead);
|
|
871
|
+
if (!reauthenticated.ok || reauthenticated.realPath !== authenticatedPath) {
|
|
872
|
+
const detail = reauthenticated.ok
|
|
873
|
+
? 'the canonical worktree path changed before staging'
|
|
874
|
+
: reauthenticated.detail;
|
|
875
|
+
return persistRecoveryBlocked(deps, row, `The preserved path at ${worktree.worktree_path} failed final authentication: ${detail}. Nothing was staged, pushed, or removed.`);
|
|
876
|
+
}
|
|
877
|
+
deps.assertAdmission?.();
|
|
878
|
+
const overlayExcludes = [...new Set(matchingUnits.flatMap((unit) => unit.overlayExcludes ?? []))];
|
|
879
|
+
const commit = await (0, worktree_manager_1.commitWorktreeAndVerify)(deps.git, reauthenticated.realPath, `specrails: recovered follow-up (run ${runId})`, overlayExcludes);
|
|
880
|
+
if (!commit.clean) {
|
|
881
|
+
return persistRecoveryBlocked(deps, row, `The preserved worktree could not be committed safely: ${commit.error ?? (commit.dirty.join(', ') || 'deliverable changes remain')}. It was left intact at ${worktree.worktree_path}.`);
|
|
882
|
+
}
|
|
883
|
+
committed = commit.committed;
|
|
884
|
+
const [afterBranch, afterHead, afterBranchHead] = await Promise.all([
|
|
885
|
+
deps.git.run(['rev-parse', '--abbrev-ref', 'HEAD'], reauthenticated.realPath).catch(() => ({ code: 1, stdout: '', stderr: '' })),
|
|
886
|
+
exactRefSha(deps, reauthenticated.realPath, 'HEAD'),
|
|
887
|
+
exactRefSha(deps, deps.project.path, `refs/heads/${row.branch}`),
|
|
888
|
+
]);
|
|
889
|
+
if (afterBranch.code !== 0 || afterBranch.stdout.trim() !== row.branch ||
|
|
890
|
+
!afterHead || afterHead !== afterBranchHead) {
|
|
891
|
+
return persistRecoveryBlocked(deps, row, `The recovery commit exists, but final branch verification failed. It remains preserved at ${worktree.worktree_path}.`);
|
|
892
|
+
}
|
|
893
|
+
candidateSha = afterHead;
|
|
894
|
+
if (committed) {
|
|
895
|
+
candidateOwned = true;
|
|
896
|
+
candidateSource = 'worktree';
|
|
897
|
+
}
|
|
898
|
+
if (candidateSource === 'baseline' && recordedFinalSha && afterHead.toLowerCase() === recordedFinalSha) {
|
|
899
|
+
candidateSource = 'recorded-final';
|
|
900
|
+
}
|
|
901
|
+
candidateOwned = candidateOwned || committed || candidateSource !== 'baseline' || afterHead === baselineSha;
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
else {
|
|
905
|
+
candidateSha ??= await exactRefSha(deps, deps.project.path, `refs/heads/${row.branch}`);
|
|
906
|
+
}
|
|
907
|
+
// A consistent durable final SHA outranks a mutable branch name. This also
|
|
908
|
+
// lets another checkout recover an object whose branch was later reset.
|
|
909
|
+
if (!committed && recordedFinalSha &&
|
|
910
|
+
await commitObjectExists(deps, recordedFinalSha) &&
|
|
911
|
+
(!candidateOwned || candidateSha === baselineSha)) {
|
|
912
|
+
candidateSha = recordedFinalSha;
|
|
913
|
+
candidateOwned = true;
|
|
914
|
+
candidateSource = 'recorded-final';
|
|
915
|
+
}
|
|
916
|
+
// Before claiming absence, inspect the same complete causal surface as
|
|
917
|
+
// startup. A unique run-marked object may survive after both the worktree and
|
|
918
|
+
// visible branch were removed.
|
|
919
|
+
const baselineHasDurableFinalProof = candidateSha === baselineSha && recordedFinalSha === baselineSha;
|
|
920
|
+
if (!candidateSha || (candidateSha === baselineSha && !baselineHasDurableFinalProof) || !candidateOwned) {
|
|
921
|
+
const found = await discoverCandidate();
|
|
922
|
+
if (found.kind === 'ambiguous') {
|
|
923
|
+
return persistRecoveryBlocked(deps, row, `Git found ${found.count} different run-marked commits. No ambiguous object, branch, or worktree was changed.`);
|
|
924
|
+
}
|
|
925
|
+
if (found.kind === 'scan_failed') {
|
|
926
|
+
return persistRecoveryBlocked(deps, row, `${found.detail}. No branch, worktree, or object was changed.`);
|
|
927
|
+
}
|
|
928
|
+
if (found.kind === 'unique') {
|
|
929
|
+
candidateSha = found.sha;
|
|
930
|
+
candidateOwned = true;
|
|
931
|
+
candidateSource = 'run-marker';
|
|
932
|
+
}
|
|
933
|
+
else if (candidateSha && candidateSha !== baselineSha && !candidateOwned) {
|
|
934
|
+
return persistRecoveryBlocked(deps, row, 'The recorded local branch advanced beyond the run-owned result. The later branch tip was preserved and was not committed or pushed.');
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
if (!candidateSha || !(await commitObjectExists(deps, candidateSha))) {
|
|
938
|
+
return persistRecoveryBlocked(deps, row, 'No delivery-owned commit is available in this clone’s refs, reflogs, worktrees, or unreachable objects. The result may still exist on the computer where the run executed; nothing here was changed or removed.');
|
|
939
|
+
}
|
|
940
|
+
if (candidateSha === baselineSha) {
|
|
941
|
+
const provesNoChanges = row.implementation_outcome === 'succeeded' &&
|
|
942
|
+
matchingUnitIndexes.length === snap.branches.length &&
|
|
943
|
+
matchingUnits.every((unit) => (unit.changed === false &&
|
|
944
|
+
unit.initialSha?.toLowerCase() === baselineSha.toLowerCase() &&
|
|
945
|
+
unit.finalSha?.toLowerCase() === baselineSha.toLowerCase()));
|
|
946
|
+
if (provesNoChanges) {
|
|
947
|
+
const noChangeIndexes = new Set(matchingUnitIndexes);
|
|
948
|
+
const noChangeBranches = snap.branches.map((unit, index) => noChangeIndexes.has(index)
|
|
949
|
+
? {
|
|
950
|
+
...unit,
|
|
951
|
+
succeeded: true,
|
|
952
|
+
implementationOutcome: 'succeeded',
|
|
953
|
+
deliveryOutcome: 'no_changes',
|
|
954
|
+
initialSha: baselineSha,
|
|
955
|
+
finalSha: baselineSha,
|
|
956
|
+
changed: false,
|
|
957
|
+
failureCode: null,
|
|
958
|
+
}
|
|
959
|
+
: unit);
|
|
960
|
+
const conflict = casTransition(deps, row, 'no_changes', {
|
|
961
|
+
branches: noChangeBranches,
|
|
962
|
+
deliverySha: baselineSha,
|
|
963
|
+
deliveryOutcome: 'no_changes',
|
|
964
|
+
statusCode: 'no_changes',
|
|
965
|
+
statusDetail: null,
|
|
966
|
+
isContinuation: true,
|
|
967
|
+
});
|
|
968
|
+
if (conflict)
|
|
969
|
+
return conflict;
|
|
970
|
+
await releaseDeliveredRecoveryLineage(deps, row, baselineSha);
|
|
971
|
+
finalizeTransition(deps, row.id);
|
|
972
|
+
return {
|
|
973
|
+
status: 200,
|
|
974
|
+
body: {
|
|
975
|
+
ok: true,
|
|
976
|
+
decision: 'no_changes',
|
|
977
|
+
prUrl: row.pr_url,
|
|
978
|
+
noChanges: true,
|
|
979
|
+
deliveryVerified: true,
|
|
980
|
+
verifiedSha: baselineSha,
|
|
981
|
+
pushed: false,
|
|
982
|
+
},
|
|
983
|
+
};
|
|
984
|
+
}
|
|
985
|
+
const alreadyDelivered = recordedFinalSha === baselineSha || candidateSource === 'run-marker';
|
|
986
|
+
if (!alreadyDelivered) {
|
|
987
|
+
return persistRecoveryBlocked(deps, row, 'This clone contains only the current PR head and no additional run-owned result. The original execution computer may still retain its worktree or orphan commit; nothing here was changed or removed.');
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
else {
|
|
991
|
+
const protection = preprotectedCandidateSha &&
|
|
992
|
+
preprotectedCandidateSha.toLowerCase() !== candidateSha.toLowerCase()
|
|
993
|
+
? await (0, rail_pr_recovery_git_1.advanceRecoveryCommitProtection)(deps.git, deps.project.path, row.id, preprotectedCandidateSha, candidateSha)
|
|
994
|
+
: await (0, rail_pr_recovery_git_1.protectRecoveryCommit)(deps.git, deps.project.path, row.id, candidateSha);
|
|
995
|
+
if (!protection.ok) {
|
|
996
|
+
return persistRecoveryBlocked(deps, row, `${protection.detail}. The candidate object and every user-visible branch were left unchanged.`, preprotectedCandidateSha);
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
if (!(await isFastForwardCandidate(deps, baselineSha, candidateSha))) {
|
|
1000
|
+
return persistRecoveryBlocked(deps, row, 'The exact run-owned recovery commit is not a fast-forward of the live PR head. It remains protected locally and nothing was pushed or removed.', candidateSha);
|
|
1001
|
+
}
|
|
1002
|
+
const recoveredIndexes = new Set(matchingUnitIndexes);
|
|
1003
|
+
const recoveredBranches = snap.branches.map((unit, index) => recoveredIndexes.has(index)
|
|
1004
|
+
? {
|
|
1005
|
+
...unit,
|
|
1006
|
+
runId,
|
|
1007
|
+
branch: row.branch,
|
|
1008
|
+
succeeded: true,
|
|
1009
|
+
implementationOutcome: 'succeeded',
|
|
1010
|
+
deliveryOutcome: 'ready',
|
|
1011
|
+
// Equality with the live PR head can mean the exact run commit was
|
|
1012
|
+
// already delivered; it is not no-change evidence. Preserve a durable
|
|
1013
|
+
// initial SHA when available and infer the observed baseline only for
|
|
1014
|
+
// a still-missing descendant. The proven no-change branch returned
|
|
1015
|
+
// above before reaching this mapping.
|
|
1016
|
+
initialSha: unit.initialSha ?? (candidateSha !== baselineSha ? baselineSha : null),
|
|
1017
|
+
finalSha: candidateSha,
|
|
1018
|
+
changed: true,
|
|
1019
|
+
failureCode: null,
|
|
1020
|
+
branchOwnership: 'borrowed-pr',
|
|
1021
|
+
...(worktree ? { worktreePath: worktree.worktree_path } : {}),
|
|
1022
|
+
}
|
|
1023
|
+
: unit);
|
|
1024
|
+
// Freeze before the network mutation. A crash after this write leaves a
|
|
1025
|
+
// normal immutable Retry push card; a crash before it leaves the run-marked
|
|
1026
|
+
// commit discoverable by startup recovery.
|
|
1027
|
+
if (!(0, rail_pr_store_1.transitionClaimedDecision)(deps.db, row.id, row.decision, 'pr_failed', row.operation_token, {
|
|
1028
|
+
branches: recoveredBranches,
|
|
1029
|
+
deliverySha: candidateSha,
|
|
1030
|
+
deliveryOutcome: 'retryable_failure',
|
|
1031
|
+
statusCode: 'settlement_interrupted',
|
|
1032
|
+
statusDetail: candidateSource === 'worktree'
|
|
1033
|
+
? `Committed the preserved worktree as ${candidateSha.slice(0, 8)}; validating the exact PR before push.`
|
|
1034
|
+
: candidateSource === 'run-marker'
|
|
1035
|
+
? `Protected the unique run-owned commit ${candidateSha.slice(0, 8)}; validating the exact PR before push.`
|
|
1036
|
+
: `Recovered the durable final commit ${candidateSha.slice(0, 8)}; validating the exact PR before push.`,
|
|
1037
|
+
isContinuation: true,
|
|
1038
|
+
})) {
|
|
1039
|
+
const current = (0, rail_pr_store_1.getPrDelivery)(deps.db, row.id);
|
|
1040
|
+
return staleDecision(current?.decision ?? row.decision);
|
|
1041
|
+
}
|
|
1042
|
+
if (worktree)
|
|
1043
|
+
(0, rail_worktrees_store_1.updateRailWorktreeState)(deps.db, worktree.id, 'built');
|
|
1044
|
+
const recoveredRow = (0, rail_pr_store_1.getPrDelivery)(deps.db, row.id);
|
|
1045
|
+
if (!recoveredRow || recoveredRow.operation_token !== row.operation_token) {
|
|
1046
|
+
return staleDecision(recoveredRow?.decision ?? row.decision);
|
|
1047
|
+
}
|
|
1048
|
+
deps.assertAdmission?.();
|
|
1049
|
+
return retryExistingPrFollowupPush(deps, recoveredRow);
|
|
1050
|
+
}
|
|
363
1051
|
async function retryExistingPrFollowupPush(deps, row) {
|
|
364
1052
|
if (!row.branch || !row.pr_url)
|
|
365
1053
|
return { status: 502, body: { error: 'push_failed', detail: 'missing PR branch/url' } };
|
|
@@ -380,21 +1068,51 @@ async function retryExistingPrFollowupPush(deps, row) {
|
|
|
380
1068
|
return persistRetryablePrObservationFailure(deps, row, `could not confirm the existing PR is open before retry: ${beforePush.detail}`);
|
|
381
1069
|
}
|
|
382
1070
|
if (!(0, pr_lifecycle_1.isExactOpenPr)(beforePush, row.branch, row.base_branch)) {
|
|
383
|
-
|
|
384
|
-
|
|
1071
|
+
const identityMatches = (0, pr_lifecycle_1.matchesRecordedPrIdentity)(beforePush, row.branch, row.base_branch);
|
|
1072
|
+
if (beforePush.state === 'MERGED' && identityMatches && beforePush.includesExpectedSha === true) {
|
|
1073
|
+
return settleMergedPr(deps, row);
|
|
385
1074
|
}
|
|
386
1075
|
if (beforePush.state === 'MERGED') {
|
|
387
|
-
return rerouteFromStalePr(deps, row, mergedEvidenceDetail(beforePush), false);
|
|
1076
|
+
return rerouteFromStalePr(deps, row, identityMatches ? mergedEvidenceDetail(beforePush) : 'the previous PR merged after its recorded head/base identity changed', false);
|
|
388
1077
|
}
|
|
389
1078
|
if (beforePush.state === 'CLOSED') {
|
|
1079
|
+
if (identityMatches && beforePush.includesExpectedSha === true) {
|
|
1080
|
+
return settleObservedClosedPr(deps, row, beforePush);
|
|
1081
|
+
}
|
|
390
1082
|
return rerouteFromStalePr(deps, row, 'the previous PR closed before this implementation was delivered', false);
|
|
391
1083
|
}
|
|
392
|
-
return
|
|
1084
|
+
return rerouteFromStalePr(deps, row, 'the existing open PR no longer matches its recorded head/base identity', false);
|
|
1085
|
+
}
|
|
1086
|
+
if (beforePush.includesExpectedSha === true) {
|
|
1087
|
+
return settleObservedExistingPr(deps, row, beforePush, false);
|
|
1088
|
+
}
|
|
1089
|
+
if (!beforePush.headRefOid) {
|
|
1090
|
+
return persistRetryablePrObservationFailure(deps, row, 'the open PR did not expose a verifiable head commit; Retry push will revalidate before mutation');
|
|
1091
|
+
}
|
|
1092
|
+
if (!(await commitObjectExists(deps, beforePush.headRefOid))) {
|
|
1093
|
+
try {
|
|
1094
|
+
await deps.git.run(['fetch', 'origin', `refs/heads/${row.branch}`], deps.project.path);
|
|
1095
|
+
}
|
|
1096
|
+
catch { /* the immutable object check below remains authoritative */ }
|
|
1097
|
+
}
|
|
1098
|
+
if (!(await commitObjectExists(deps, beforePush.headRefOid))) {
|
|
1099
|
+
return persistRetryablePrObservationFailure(deps, row, 'the live PR head object is unavailable locally; Retry push will revalidate before mutation');
|
|
1100
|
+
}
|
|
1101
|
+
if (!(await isFastForwardCandidate(deps, beforePush.headRefOid, row.delivery_sha))) {
|
|
1102
|
+
const detail = 'the exact delivery commit is not a fast-forward of the live PR head; it was not pushed';
|
|
1103
|
+
return row.is_continuation === 1
|
|
1104
|
+
? persistRecoveryBlocked(deps, row, `${detail} and remains protected`, row.delivery_sha)
|
|
1105
|
+
: persistMovedPrHead(deps, row, detail);
|
|
1106
|
+
}
|
|
1107
|
+
const remote = await (0, pr_lifecycle_1.verifyPushRemoteForPr)(deps.exec, deps.project.path, row.pr_url);
|
|
1108
|
+
if (!remote.ok) {
|
|
1109
|
+
return persistRetryablePrObservationFailure(deps, row, `refusing to push until the PR repository and origin are proven identical: ${remote.detail}`);
|
|
393
1110
|
}
|
|
394
1111
|
const pushed = await (0, pr_publisher_1.pushBranch)(deps.exec, {
|
|
395
1112
|
repoDir: deps.project.path,
|
|
396
1113
|
branch: row.branch,
|
|
397
1114
|
baseBranch: row.base_branch,
|
|
1115
|
+
remote: remote.pushTarget,
|
|
398
1116
|
sourceSha: row.delivery_sha,
|
|
399
1117
|
});
|
|
400
1118
|
if (pushed.state === 'local-only') {
|
|
@@ -414,21 +1132,25 @@ async function retryExistingPrFollowupPush(deps, row) {
|
|
|
414
1132
|
return persistRetryablePrObservationFailure(deps, row, `exact commit was pushed, but the PR lifecycle could not be confirmed: ${afterPush.detail}`);
|
|
415
1133
|
}
|
|
416
1134
|
if ((0, pr_lifecycle_1.isExactOpenPr)(afterPush, row.branch, row.base_branch)) {
|
|
417
|
-
if (afterPush.
|
|
418
|
-
return
|
|
1135
|
+
if (afterPush.includesExpectedSha !== true) {
|
|
1136
|
+
return persistRetryablePrObservationFailure(deps, row, 'the exact push completed, but the PR does not yet expose the verified commit as its head; Retry push remains safe and uses the preserved SHA');
|
|
419
1137
|
}
|
|
420
|
-
return settleObservedExistingPr(deps, row, afterPush);
|
|
1138
|
+
return settleObservedExistingPr(deps, row, afterPush, true);
|
|
421
1139
|
}
|
|
422
|
-
|
|
423
|
-
|
|
1140
|
+
const identityMatches = (0, pr_lifecycle_1.matchesRecordedPrIdentity)(afterPush, row.branch, row.base_branch);
|
|
1141
|
+
if (afterPush.state === 'MERGED' && identityMatches && afterPush.includesExpectedSha === true) {
|
|
1142
|
+
return settleMergedPr(deps, row);
|
|
424
1143
|
}
|
|
425
1144
|
if (afterPush.state === 'MERGED') {
|
|
426
|
-
return rerouteFromStalePr(deps, row, mergedEvidenceDetail(afterPush), true);
|
|
1145
|
+
return rerouteFromStalePr(deps, row, identityMatches ? mergedEvidenceDetail(afterPush) : 'the previous PR merged after its recorded head/base identity changed', true);
|
|
427
1146
|
}
|
|
428
1147
|
if (afterPush.state === 'CLOSED') {
|
|
1148
|
+
if (identityMatches && afterPush.includesExpectedSha === true) {
|
|
1149
|
+
return settleObservedClosedPr(deps, row, afterPush);
|
|
1150
|
+
}
|
|
429
1151
|
return rerouteFromStalePr(deps, row, 'the previous PR closed before this implementation was delivered', true);
|
|
430
1152
|
}
|
|
431
|
-
return
|
|
1153
|
+
return rerouteFromStalePr(deps, row, 'the existing open PR no longer matches its recorded head/base identity after the exact push', true);
|
|
432
1154
|
}
|
|
433
1155
|
/**
|
|
434
1156
|
* Load the PR's ticket data (title/labels/description) from the ticket store,
|
|
@@ -504,11 +1226,19 @@ async function runCreatePr(deps, row) {
|
|
|
504
1226
|
// belongs to the same ticket.
|
|
505
1227
|
let effectiveBranches = snap.branches;
|
|
506
1228
|
let branchEvidenceCaptured = false;
|
|
1229
|
+
const singleDeliverable = snap.branches.filter((unit) => unit.succeeded).length === 1;
|
|
1230
|
+
const frozenSingleSha = singleDeliverable && row.delivery_sha && COMMIT_SHA_RE.test(row.delivery_sha) &&
|
|
1231
|
+
await commitObjectExists(deps, row.delivery_sha)
|
|
1232
|
+
? row.delivery_sha
|
|
1233
|
+
: null;
|
|
507
1234
|
for (let index = 0; index < snap.branches.length; index++) {
|
|
508
1235
|
const unit = snap.branches[index];
|
|
509
1236
|
if (!unit.succeeded)
|
|
510
1237
|
continue;
|
|
511
|
-
|
|
1238
|
+
// A detached/stale single-unit delivery already has authoritative immutable
|
|
1239
|
+
// evidence. Prefer it over both legacy unit gaps and a branch ref that may
|
|
1240
|
+
// have advanced while the card waited for the user's decision.
|
|
1241
|
+
let sourceSha = frozenSingleSha ?? (unit.finalSha && COMMIT_SHA_RE.test(unit.finalSha) ? unit.finalSha : null);
|
|
512
1242
|
if (sourceSha) {
|
|
513
1243
|
if (!(await commitObjectExists(deps, sourceSha)))
|
|
514
1244
|
sourceSha = null;
|
|
@@ -668,11 +1398,20 @@ async function runCreatePr(deps, row) {
|
|
|
668
1398
|
if ((next === 'pr_draft' || next === 'pr_ready') && patch.prUrl) {
|
|
669
1399
|
patch.cleanupWarnings = await (0, rail_worktree_release_1.releaseRailWorktrees)({
|
|
670
1400
|
db: deps.db, git: deps.git, repoDir: deps.project.path, worktreeIds: releasableWorktreeIds(deps, snap),
|
|
1401
|
+
expectedHeadByBranch: (0, rail_worktree_release_1.durableBranchHeads)(effectiveBranches),
|
|
1402
|
+
overlayEvidenceByBranch: (0, rail_worktree_release_1.durableOverlayCleanupEvidence)(effectiveBranches),
|
|
1403
|
+
settlementIgnoredByBranch: (0, rail_worktree_release_1.durableSettlementIgnoredPaths)(effectiveBranches),
|
|
1404
|
+
onSafetyArchive: safetyArchiveRecorder(deps, row),
|
|
671
1405
|
});
|
|
1406
|
+
if (patch.cleanupWarnings.length > 0)
|
|
1407
|
+
patch.statusCode = 'cleanup_incomplete';
|
|
672
1408
|
}
|
|
673
1409
|
const conflict = casTransition(deps, row, next, patch);
|
|
674
1410
|
if (conflict)
|
|
675
1411
|
return conflict;
|
|
1412
|
+
if ((next === 'pr_draft' || next === 'pr_ready') && patch.prUrl) {
|
|
1413
|
+
await releaseDeliveredRecoveryLineage(deps, row, patch.deliverySha ?? row.delivery_sha);
|
|
1414
|
+
}
|
|
676
1415
|
const after = finalizeTransition(deps, row.id);
|
|
677
1416
|
// Tickets stay on_review — a draft PR is still awaiting the engineer's merge.
|
|
678
1417
|
return {
|
|
@@ -687,6 +1426,32 @@ async function runCreatePr(deps, row) {
|
|
|
687
1426
|
};
|
|
688
1427
|
}
|
|
689
1428
|
async function runPublish(deps, row) {
|
|
1429
|
+
if (!row.delivery_sha || !row.branch) {
|
|
1430
|
+
return persistMovedPrHead(deps, row, 'the draft PR has no immutable delivery SHA/head identity; refusing to mutate unverifiable review state');
|
|
1431
|
+
}
|
|
1432
|
+
const beforePublish = await (0, pr_lifecycle_1.observePrLifecycle)(deps.exec, deps.project.path, row.pr_url, row.delivery_sha);
|
|
1433
|
+
if (!beforePublish.ok) {
|
|
1434
|
+
return { status: 502, body: { error: 'gh_failed', detail: beforePublish.detail } };
|
|
1435
|
+
}
|
|
1436
|
+
if (!(0, pr_lifecycle_1.matchesRecordedPrIdentity)(beforePublish, row.branch, row.base_branch)) {
|
|
1437
|
+
return rerouteFromStalePr(deps, row, `the recorded PR is ${beforePublish.state.toLowerCase()} but no longer matches its original head/base identity`, beforePublish.includesExpectedSha === true);
|
|
1438
|
+
}
|
|
1439
|
+
if (beforePublish.state === 'MERGED') {
|
|
1440
|
+
return beforePublish.includesExpectedSha === true
|
|
1441
|
+
? settleMergedPr(deps, row)
|
|
1442
|
+
: rerouteFromStalePr(deps, row, mergedEvidenceDetail(beforePublish), false);
|
|
1443
|
+
}
|
|
1444
|
+
if (beforePublish.state === 'CLOSED') {
|
|
1445
|
+
return beforePublish.includesExpectedSha === true
|
|
1446
|
+
? settleObservedClosedPr(deps, row, beforePublish)
|
|
1447
|
+
: rerouteFromStalePr(deps, row, 'the draft PR closed without the verified implementation commit', false);
|
|
1448
|
+
}
|
|
1449
|
+
if (beforePublish.includesExpectedSha !== true) {
|
|
1450
|
+
return persistRetryablePrObservationFailure(deps, row, 'the draft PR no longer exposes the verified implementation commit; Retry push is available and will use the preserved exact SHA');
|
|
1451
|
+
}
|
|
1452
|
+
if (beforePublish.isDraft === false) {
|
|
1453
|
+
return settleObservedExistingPr(deps, row, beforePublish, false);
|
|
1454
|
+
}
|
|
690
1455
|
let r;
|
|
691
1456
|
try {
|
|
692
1457
|
r = await deps.exec.run('gh', ['pr', 'ready', row.pr_url], deps.project.path);
|
|
@@ -694,46 +1459,53 @@ async function runPublish(deps, row) {
|
|
|
694
1459
|
catch (err) {
|
|
695
1460
|
r = { code: 1, stdout: '', stderr: err instanceof Error ? err.message : String(err) };
|
|
696
1461
|
}
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
if (lifecycle?.state === 'CLOSED') {
|
|
703
|
-
const conflict = casTransition(deps, row, 'pr_closed', {
|
|
704
|
-
deliveryOutcome: 'delivered', statusCode: 'pr_closed',
|
|
705
|
-
});
|
|
706
|
-
if (conflict)
|
|
707
|
-
return conflict;
|
|
708
|
-
finalizeTransition(deps, row.id);
|
|
709
|
-
return { status: 200, body: { ok: true, decision: 'pr_closed', closed: true, prUrl: row.pr_url } };
|
|
710
|
-
}
|
|
711
|
-
if (lifecycle?.state !== 'OPEN' || lifecycle.isDraft)
|
|
1462
|
+
// `gh pr ready` is externally mutating and may return an ambiguous error.
|
|
1463
|
+
// Re-observe exact remote truth in every case before changing the ledger.
|
|
1464
|
+
const afterPublish = await (0, pr_lifecycle_1.observePrLifecycle)(deps.exec, deps.project.path, row.pr_url, row.delivery_sha);
|
|
1465
|
+
if (!afterPublish.ok) {
|
|
1466
|
+
if (r.code !== 0)
|
|
712
1467
|
return ghFailed(r);
|
|
1468
|
+
return { status: 502, body: { error: 'gh_failed', detail: `PR readiness could not be verified: ${afterPublish.detail}` } };
|
|
713
1469
|
}
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
1470
|
+
if (!(0, pr_lifecycle_1.matchesRecordedPrIdentity)(afterPublish, row.branch, row.base_branch)) {
|
|
1471
|
+
return rerouteFromStalePr(deps, row, `the PR identity changed while publishing it for review (${afterPublish.state.toLowerCase()})`, afterPublish.includesExpectedSha === true);
|
|
1472
|
+
}
|
|
1473
|
+
if (afterPublish.state === 'MERGED') {
|
|
1474
|
+
return afterPublish.includesExpectedSha === true
|
|
1475
|
+
? settleMergedPr(deps, row)
|
|
1476
|
+
: rerouteFromStalePr(deps, row, mergedEvidenceDetail(afterPublish), false);
|
|
1477
|
+
}
|
|
1478
|
+
if (afterPublish.state === 'CLOSED') {
|
|
1479
|
+
return afterPublish.includesExpectedSha === true
|
|
1480
|
+
? settleObservedClosedPr(deps, row, afterPublish)
|
|
1481
|
+
: rerouteFromStalePr(deps, row, 'the PR closed without the verified implementation commit while being published', false);
|
|
1482
|
+
}
|
|
1483
|
+
if (afterPublish.includesExpectedSha !== true) {
|
|
1484
|
+
return persistRetryablePrObservationFailure(deps, row, 'the PR no longer exposes the verified implementation commit after publishing; Retry push is available');
|
|
1485
|
+
}
|
|
1486
|
+
if (afterPublish.isDraft) {
|
|
1487
|
+
if (r.code !== 0)
|
|
1488
|
+
return ghFailed(r);
|
|
1489
|
+
return { status: 502, body: { error: 'gh_failed', detail: 'PR remained a draft after publishing' } };
|
|
1490
|
+
}
|
|
1491
|
+
return settleObservedExistingPr(deps, row, afterPublish, false);
|
|
725
1492
|
}
|
|
726
1493
|
async function runDiscard(deps, row, opts = { removeNeedsReview: true }) {
|
|
727
1494
|
const snap = (0, rail_pr_store_1.toPrDeliverySnapshot)(row);
|
|
728
1495
|
const implementationFailed = row.decision === 'implementation_failed';
|
|
729
|
-
|
|
1496
|
+
// PR lifecycle ownership and head-ref ownership are separate. Startup repairs
|
|
1497
|
+
// historical continuation rows before admission opens; only that durable bit
|
|
1498
|
+
// borrows the PR/ticket lifecycle. Per-unit ownership below independently
|
|
1499
|
+
// prevents deletion of borrowed/pre-existing head refs for fresh replacements.
|
|
1500
|
+
const preserveExternalReview = row.is_continuation === 1;
|
|
730
1501
|
const cleanupWarnings = [];
|
|
731
1502
|
// A continuation borrows an existing PR/head. Discarding its local iteration
|
|
732
1503
|
// may remove Specrails' worktree, but never closes or deletes borrowed review
|
|
733
|
-
// state.
|
|
734
|
-
|
|
1504
|
+
// state. A fresh PR is closed without GitHub's unleased --delete-branch: its
|
|
1505
|
+
// remote head may have advanced since the card was rendered.
|
|
1506
|
+
if (row.pr_url && !implementationFailed && !preserveExternalReview) {
|
|
735
1507
|
try {
|
|
736
|
-
const r = await deps.exec.run('gh', ['pr', 'close', row.pr_url
|
|
1508
|
+
const r = await deps.exec.run('gh', ['pr', 'close', row.pr_url], deps.project.path);
|
|
737
1509
|
if (r.code !== 0) {
|
|
738
1510
|
const detail = (r.stderr.trim() || r.stdout.trim()).split('\n')[0] || `exit ${r.code}`;
|
|
739
1511
|
cleanupWarnings.push(`PR close ${row.pr_url}: ${detail}`);
|
|
@@ -745,59 +1517,83 @@ async function runDiscard(deps, row, opts = { removeNeedsReview: true }) {
|
|
|
745
1517
|
console.warn('[rail-pr-decision] gh pr close threw (continuing discard):', err);
|
|
746
1518
|
}
|
|
747
1519
|
}
|
|
748
|
-
// 2.
|
|
749
|
-
//
|
|
750
|
-
//
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
try {
|
|
762
|
-
await (0, worktree_manager_1.removeWorktree)(deps.git, {
|
|
763
|
-
repoDir: deps.project.path, worktreePath: wt.worktree_path, branch: wt.branch, deleteBranch: false,
|
|
764
|
-
});
|
|
765
|
-
}
|
|
766
|
-
catch (err) {
|
|
767
|
-
const detail = err instanceof Error ? err.message : String(err);
|
|
768
|
-
cleanupWarnings.push(`worktree ${wt.worktree_path}: ${detail}`);
|
|
769
|
-
console.warn(`[rail-pr-decision] discard could not remove worktree ${wt.worktree_path}: ${detail}`);
|
|
770
|
-
continue;
|
|
771
|
-
}
|
|
772
|
-
if (!(0, rail_worktrees_store_1.isTerminalMergeState)(wt.merge_state))
|
|
773
|
-
(0, rail_worktrees_store_1.updateRailWorktreeState)(deps.db, wt.id, 'failed');
|
|
1520
|
+
// 2. Release only worktrees whose live tracked/untracked/ignored state and
|
|
1521
|
+
// exact HEAD/ref still match durable settlement evidence. Explicit
|
|
1522
|
+
// discard is not permission to contradict the confirmation copy by
|
|
1523
|
+
// force-removing subsequently changed local work. Authenticated overlays
|
|
1524
|
+
// take the same lossless quarantine path as automatic settlement.
|
|
1525
|
+
const deleteOwnedBranches = !implementationFailed && !preserveExternalReview;
|
|
1526
|
+
const skippedNeedsReview = opts.removeNeedsReview === true
|
|
1527
|
+
? []
|
|
1528
|
+
: snap.worktreeIds
|
|
1529
|
+
.map((wtId) => (0, rail_worktrees_store_1.getRailWorktree)(deps.db, wtId))
|
|
1530
|
+
.filter((wt) => wt?.merge_state === 'needs-review');
|
|
1531
|
+
for (const worktree of skippedNeedsReview) {
|
|
1532
|
+
cleanupWarnings.push(`worktree ${worktree.worktree_path}: preserved for inspection because it already requires review`);
|
|
774
1533
|
}
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
1534
|
+
const skippedIds = new Set(skippedNeedsReview.map((wt) => wt.id));
|
|
1535
|
+
const worktreeIds = snap.worktreeIds.filter((wtId) => !skippedIds.has(wtId));
|
|
1536
|
+
cleanupWarnings.push(...await (0, rail_worktree_release_1.releaseRailWorktrees)({
|
|
1537
|
+
db: deps.db,
|
|
1538
|
+
git: deps.git,
|
|
1539
|
+
repoDir: deps.project.path,
|
|
1540
|
+
worktreeIds,
|
|
1541
|
+
state: 'failed',
|
|
1542
|
+
expectedHeadByBranch: (0, rail_worktree_release_1.durableBranchHeads)(snap.branches),
|
|
1543
|
+
overlayEvidenceByBranch: (0, rail_worktree_release_1.durableOverlayCleanupEvidence)(snap.branches),
|
|
1544
|
+
settlementIgnoredByBranch: (0, rail_worktree_release_1.durableSettlementIgnoredPaths)(snap.branches),
|
|
1545
|
+
onSafetyArchive: safetyArchiveRecorder(deps, row),
|
|
1546
|
+
}));
|
|
1547
|
+
const retainedWorktreeBranches = new Set(snap.worktreeIds
|
|
1548
|
+
.map((wtId) => (0, rail_worktrees_store_1.getRailWorktree)(deps.db, wtId))
|
|
1549
|
+
.filter((wt) => Boolean(wt && (wt.merge_state === 'needs-review' || fs.existsSync(wt.worktree_path))))
|
|
1550
|
+
.map((wt) => wt.branch));
|
|
1551
|
+
// 3. Delete only branches this delivery durably records as created, and only
|
|
1552
|
+
// while their live tips still equal the immutable delivered commits.
|
|
1553
|
+
// Later user/collaborator commits are retained with a cleanup warning.
|
|
780
1554
|
if (deleteOwnedBranches) {
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
1555
|
+
await deleteOwnedBranchesIfUnchanged(deps, row, snap, cleanupWarnings, retainedWorktreeBranches);
|
|
1556
|
+
}
|
|
1557
|
+
// Explicit Discard local result is also the only authority (besides proven
|
|
1558
|
+
// delivery) to release a pinned orphan. A crash can occur after the atomic
|
|
1559
|
+
// ref creation but before delivery_sha is written, so recover that ownership
|
|
1560
|
+
// only from this delivery's exact durable final SHA or run-marker evidence.
|
|
1561
|
+
if (opts.removeNeedsReview === true) {
|
|
1562
|
+
let recoverySha = row.delivery_sha && COMMIT_SHA_RE.test(row.delivery_sha)
|
|
1563
|
+
? row.delivery_sha.toLowerCase()
|
|
1564
|
+
: null;
|
|
1565
|
+
if (!recoverySha && row.is_continuation === 1 &&
|
|
1566
|
+
(row.status_code === 'settlement_interrupted' || row.status_code === 'recovery_unavailable')) {
|
|
1567
|
+
const protection = await (0, rail_pr_recovery_git_1.inspectRecoveryCommitProtection)(deps.git, deps.project.path, row.id);
|
|
1568
|
+
if (protection.kind === 'present') {
|
|
1569
|
+
const durableFinalShas = [...new Set(snap.branches
|
|
1570
|
+
.map((unit) => unit.finalSha)
|
|
1571
|
+
.filter((sha) => typeof sha === 'string' && COMMIT_SHA_RE.test(sha))
|
|
1572
|
+
.map((sha) => sha.toLowerCase()))];
|
|
1573
|
+
const runIds = [...new Set(snap.runIds.filter(Boolean))];
|
|
1574
|
+
const exactDurableFinal = durableFinalShas.length === 1 && durableFinalShas[0] === protection.sha;
|
|
1575
|
+
const exactRunMarker = runIds.length === 1 && await (0, rail_pr_recovery_git_1.commitCarriesRunMarker)(deps.git, deps.project.path, protection.sha, runIds[0]);
|
|
1576
|
+
if (exactDurableFinal || exactRunMarker) {
|
|
1577
|
+
recoverySha = protection.sha;
|
|
1578
|
+
}
|
|
1579
|
+
else {
|
|
1580
|
+
cleanupWarnings.push(`recovery ref ${protection.ref}: preserved because its commit could not be proven to belong to this delivery`);
|
|
788
1581
|
}
|
|
789
1582
|
}
|
|
790
|
-
|
|
791
|
-
cleanupWarnings.push(`
|
|
1583
|
+
else if (protection.kind === 'unreadable') {
|
|
1584
|
+
cleanupWarnings.push(`recovery ref ${protection.ref}: preserved because its exact commit could not be read safely`);
|
|
792
1585
|
}
|
|
793
1586
|
}
|
|
1587
|
+
if (recoverySha && !await (0, rail_pr_recovery_git_1.releaseRecoveryCommit)(deps.git, deps.project.path, row.id, recoverySha)) {
|
|
1588
|
+
cleanupWarnings.push('the delivery recovery ref changed or could not be removed; it was preserved for inspection');
|
|
1589
|
+
}
|
|
794
1590
|
}
|
|
795
1591
|
const terminalPatch = {
|
|
796
1592
|
deliveryOutcome: 'not_started',
|
|
797
1593
|
statusCode: cleanupWarnings.length > 0 ? 'cleanup_incomplete' : 'discarded',
|
|
798
1594
|
cleanupWarnings,
|
|
799
1595
|
};
|
|
800
|
-
const conflict =
|
|
1596
|
+
const conflict = preserveExternalReview
|
|
801
1597
|
? casTransition(deps, row, 'discarded', terminalPatch)
|
|
802
1598
|
: casTransitionWithTicketEffect(deps, row, 'discarded', terminalPatch, {
|
|
803
1599
|
deliveryId: row.id,
|
|
@@ -810,7 +1606,7 @@ async function runDiscard(deps, row, opts = { removeNeedsReview: true }) {
|
|
|
810
1606
|
});
|
|
811
1607
|
if (conflict)
|
|
812
1608
|
return conflict;
|
|
813
|
-
if (!
|
|
1609
|
+
if (!preserveExternalReview)
|
|
814
1610
|
applyTerminalTicketEffect(deps, row, 'discarded', cleanupWarnings);
|
|
815
1611
|
finalizeTransition(deps, row.id);
|
|
816
1612
|
return {
|
|
@@ -819,7 +1615,7 @@ async function runDiscard(deps, row, opts = { removeNeedsReview: true }) {
|
|
|
819
1615
|
ok: true,
|
|
820
1616
|
decision: 'discarded',
|
|
821
1617
|
...(cleanupWarnings.length > 0 ? { cleanupWarnings } : {}),
|
|
822
|
-
...(
|
|
1618
|
+
...(preserveExternalReview ? { preservedBorrowedReview: true, preservedExternalReview: true } : {}),
|
|
823
1619
|
},
|
|
824
1620
|
};
|
|
825
1621
|
}
|
|
@@ -835,6 +1631,10 @@ async function runAcknowledgeNoChanges(deps, row) {
|
|
|
835
1631
|
git: deps.git,
|
|
836
1632
|
repoDir: deps.project.path,
|
|
837
1633
|
worktreeIds: snap.worktreeIds,
|
|
1634
|
+
expectedHeadByBranch: (0, rail_worktree_release_1.durableBranchHeads)(snap.branches),
|
|
1635
|
+
overlayEvidenceByBranch: (0, rail_worktree_release_1.durableOverlayCleanupEvidence)(snap.branches),
|
|
1636
|
+
settlementIgnoredByBranch: (0, rail_worktree_release_1.durableSettlementIgnoredPaths)(snap.branches),
|
|
1637
|
+
onSafetyArchive: safetyArchiveRecorder(deps, row),
|
|
838
1638
|
});
|
|
839
1639
|
// A fresh no-change branch is disposable only while Git still proves that it
|
|
840
1640
|
// has no commits ahead of the integration base. If the user added work after
|
|
@@ -882,21 +1682,90 @@ async function runAcknowledgeNoChanges(deps, row) {
|
|
|
882
1682
|
},
|
|
883
1683
|
};
|
|
884
1684
|
}
|
|
1685
|
+
async function settleMergedPr(deps, row) {
|
|
1686
|
+
const snap = (0, rail_pr_store_1.toPrDeliverySnapshot)(row);
|
|
1687
|
+
const cleanupWarnings = await (0, rail_worktree_release_1.releaseRailWorktrees)({
|
|
1688
|
+
db: deps.db, git: deps.git, repoDir: deps.project.path,
|
|
1689
|
+
worktreeIds: releasableWorktreeIds(deps, snap), state: 'merged',
|
|
1690
|
+
expectedHeadByBranch: (0, rail_worktree_release_1.durableBranchHeads)(snap.branches),
|
|
1691
|
+
overlayEvidenceByBranch: (0, rail_worktree_release_1.durableOverlayCleanupEvidence)(snap.branches),
|
|
1692
|
+
settlementIgnoredByBranch: (0, rail_worktree_release_1.durableSettlementIgnoredPaths)(snap.branches),
|
|
1693
|
+
onSafetyArchive: safetyArchiveRecorder(deps, row),
|
|
1694
|
+
});
|
|
1695
|
+
await deleteOwnedBranchesIfUnchanged(deps, row, snap, cleanupWarnings);
|
|
1696
|
+
const conflict = casTransitionWithTicketEffect(deps, row, 'merged', {
|
|
1697
|
+
deliveryOutcome: 'delivered',
|
|
1698
|
+
statusCode: cleanupWarnings.length > 0 ? 'cleanup_incomplete' : 'merged',
|
|
1699
|
+
statusDetail: null,
|
|
1700
|
+
cleanupWarnings,
|
|
1701
|
+
}, {
|
|
1702
|
+
deliveryId: row.id,
|
|
1703
|
+
ticketIds: snap.ticketIds,
|
|
1704
|
+
targetStatus: 'done',
|
|
1705
|
+
jiraAction: 'merged',
|
|
1706
|
+
prUrl: row.pr_url,
|
|
1707
|
+
});
|
|
1708
|
+
if (conflict)
|
|
1709
|
+
return conflict;
|
|
1710
|
+
await releaseDeliveredRecoveryLineage(deps, row, row.delivery_sha);
|
|
1711
|
+
applyTerminalTicketEffect(deps, row, 'merged', cleanupWarnings);
|
|
1712
|
+
finalizeTransition(deps, row.id);
|
|
1713
|
+
return {
|
|
1714
|
+
status: 200,
|
|
1715
|
+
body: {
|
|
1716
|
+
ok: true,
|
|
1717
|
+
decision: 'merged',
|
|
1718
|
+
merged: true,
|
|
1719
|
+
prUrl: row.pr_url,
|
|
1720
|
+
...(row.delivery_sha ? {
|
|
1721
|
+
deliveryVerified: true,
|
|
1722
|
+
verifiedSha: row.delivery_sha,
|
|
1723
|
+
pushed: false,
|
|
1724
|
+
} : {}),
|
|
1725
|
+
},
|
|
1726
|
+
};
|
|
1727
|
+
}
|
|
885
1728
|
async function runPollMerge(deps, row) {
|
|
886
1729
|
const observed = await (0, pr_lifecycle_1.observePrLifecycle)(deps.exec, deps.project.path, row.pr_url, row.delivery_sha);
|
|
887
1730
|
if (!observed.ok)
|
|
888
1731
|
return { status: 502, body: { error: 'gh_failed', detail: observed.detail } };
|
|
889
1732
|
const { state, isDraft } = observed;
|
|
1733
|
+
if (!row.branch) {
|
|
1734
|
+
return persistMovedPrHead(deps, row, `the ${state.toLowerCase()} PR has no recorded delivery branch; refusing to infer recoverable work`);
|
|
1735
|
+
}
|
|
1736
|
+
if (!row.delivery_sha) {
|
|
1737
|
+
// A legacy OPEN row cannot be retried without an immutable object. Once the
|
|
1738
|
+
// attached PR is terminal, however, keeping that stale URL would strand the
|
|
1739
|
+
// only recorded branch behind Discard. Detach it so Create PR can perform
|
|
1740
|
+
// the existing one-time legacy ref capture, without claiming any delivery.
|
|
1741
|
+
if (state === 'CLOSED' || state === 'MERGED') {
|
|
1742
|
+
return rerouteFromStalePr(deps, row, `the previous PR is ${state.toLowerCase()} and this legacy delivery has no persisted verified SHA`, false);
|
|
1743
|
+
}
|
|
1744
|
+
return persistMovedPrHead(deps, row, 'the open PR has no persisted verified delivery SHA; refusing to infer delivery from a mutable branch');
|
|
1745
|
+
}
|
|
1746
|
+
if (!(0, pr_lifecycle_1.matchesRecordedPrIdentity)(observed, row.branch, row.base_branch)) {
|
|
1747
|
+
return rerouteFromStalePr(deps, row, `the recorded PR is ${state.toLowerCase()} but no longer matches its original head/base identity`, observed.includesExpectedSha === true);
|
|
1748
|
+
}
|
|
890
1749
|
if (state === 'CLOSED') {
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
}
|
|
894
|
-
|
|
895
|
-
return conflict;
|
|
896
|
-
finalizeTransition(deps, row.id);
|
|
897
|
-
return { status: 200, body: { ok: true, decision: 'pr_closed', merged: false, closed: true, prUrl: row.pr_url } };
|
|
1750
|
+
if (observed.includesExpectedSha !== true) {
|
|
1751
|
+
return rerouteFromStalePr(deps, row, 'the previous PR closed without the verified implementation commit', false);
|
|
1752
|
+
}
|
|
1753
|
+
return settleObservedClosedPr(deps, row, observed);
|
|
898
1754
|
}
|
|
899
1755
|
if (state === 'OPEN') {
|
|
1756
|
+
if (observed.includesExpectedSha !== true) {
|
|
1757
|
+
const detail = 'the open PR no longer exposes the verified implementation commit; Retry push is available and will use the preserved exact SHA';
|
|
1758
|
+
const result = persistRetryablePrObservationFailure(deps, row, detail);
|
|
1759
|
+
return {
|
|
1760
|
+
...result,
|
|
1761
|
+
body: {
|
|
1762
|
+
...result.body,
|
|
1763
|
+
deliveryVerified: false,
|
|
1764
|
+
verifiedSha: row.delivery_sha,
|
|
1765
|
+
remoteHeadSha: observed.headRefOid,
|
|
1766
|
+
},
|
|
1767
|
+
};
|
|
1768
|
+
}
|
|
900
1769
|
// A successful reopen can be observed even if the explicit action lost its
|
|
901
1770
|
// follow-up `gh view` response. Polling heals pr_closed from remote truth.
|
|
902
1771
|
if (row.decision === 'pr_closed') {
|
|
@@ -907,9 +1776,33 @@ async function runPollMerge(deps, row) {
|
|
|
907
1776
|
if (conflict)
|
|
908
1777
|
return conflict;
|
|
909
1778
|
finalizeTransition(deps, row.id);
|
|
910
|
-
return {
|
|
1779
|
+
return {
|
|
1780
|
+
status: 200,
|
|
1781
|
+
body: {
|
|
1782
|
+
ok: true,
|
|
1783
|
+
decision: next,
|
|
1784
|
+
merged: false,
|
|
1785
|
+
reopened: true,
|
|
1786
|
+
prUrl: row.pr_url,
|
|
1787
|
+
deliveryVerified: true,
|
|
1788
|
+
verifiedSha: row.delivery_sha,
|
|
1789
|
+
remoteHeadSha: observed.headRefOid,
|
|
1790
|
+
pushed: false,
|
|
1791
|
+
},
|
|
1792
|
+
};
|
|
911
1793
|
}
|
|
912
|
-
return {
|
|
1794
|
+
return {
|
|
1795
|
+
status: 200,
|
|
1796
|
+
body: {
|
|
1797
|
+
ok: true,
|
|
1798
|
+
decision: row.decision,
|
|
1799
|
+
merged: false,
|
|
1800
|
+
deliveryVerified: true,
|
|
1801
|
+
verifiedSha: row.delivery_sha,
|
|
1802
|
+
remoteHeadSha: observed.headRefOid,
|
|
1803
|
+
pushed: false,
|
|
1804
|
+
},
|
|
1805
|
+
};
|
|
913
1806
|
}
|
|
914
1807
|
if (state !== 'MERGED') {
|
|
915
1808
|
return { status: 502, body: { error: 'gh_failed', detail: `unexpected PR state: ${state}` } };
|
|
@@ -917,49 +1810,43 @@ async function runPollMerge(deps, row) {
|
|
|
917
1810
|
// A PR can merge just before a continuation push; GitHub may then accept a
|
|
918
1811
|
// later update to the head branch even though that object was never part of
|
|
919
1812
|
// the merge. Never move tickets to Done from state=MERGED alone.
|
|
920
|
-
if (!row.delivery_sha) {
|
|
921
|
-
return persistMovedPrHead(deps, row, 'the merged PR has no persisted verified delivery SHA; refusing to infer delivery from a mutable branch');
|
|
922
|
-
}
|
|
923
1813
|
if (observed.includesExpectedSha !== true) {
|
|
924
|
-
return rerouteFromStalePr(deps, row, mergedEvidenceDetail(observed),
|
|
1814
|
+
return rerouteFromStalePr(deps, row, mergedEvidenceDetail(observed), false);
|
|
925
1815
|
}
|
|
926
|
-
|
|
927
|
-
const cleanupWarnings = await (0, rail_worktree_release_1.releaseRailWorktrees)({
|
|
928
|
-
db: deps.db, git: deps.git, repoDir: deps.project.path,
|
|
929
|
-
worktreeIds: releasableWorktreeIds(deps, snap), state: 'merged',
|
|
930
|
-
});
|
|
931
|
-
const branchSet = ownedDeliveryBranches(row, snap, cleanupWarnings);
|
|
932
|
-
for (const branch of branchSet) {
|
|
933
|
-
if (!branch || branch === row.base_branch)
|
|
934
|
-
continue;
|
|
935
|
-
try {
|
|
936
|
-
const deleted = await deps.git.run(['branch', '-D', branch], deps.project.path);
|
|
937
|
-
if (deleted.code !== 0) {
|
|
938
|
-
cleanupWarnings.push(`branch ${branch}: ${(deleted.stderr.trim() || deleted.stdout.trim()).split('\n')[0] || `exit ${deleted.code}`}`);
|
|
939
|
-
}
|
|
940
|
-
}
|
|
941
|
-
catch (err) {
|
|
942
|
-
cleanupWarnings.push(`branch ${branch}: ${err instanceof Error ? err.message : String(err)}`);
|
|
943
|
-
}
|
|
944
|
-
}
|
|
945
|
-
const conflict = casTransitionWithTicketEffect(deps, row, 'merged', {
|
|
946
|
-
deliveryOutcome: 'delivered',
|
|
947
|
-
statusCode: cleanupWarnings.length > 0 ? 'cleanup_incomplete' : 'merged',
|
|
948
|
-
cleanupWarnings,
|
|
949
|
-
}, {
|
|
950
|
-
deliveryId: row.id,
|
|
951
|
-
ticketIds: snap.ticketIds,
|
|
952
|
-
targetStatus: 'done',
|
|
953
|
-
jiraAction: 'merged',
|
|
954
|
-
prUrl: row.pr_url,
|
|
955
|
-
});
|
|
956
|
-
if (conflict)
|
|
957
|
-
return conflict;
|
|
958
|
-
applyTerminalTicketEffect(deps, row, 'merged', cleanupWarnings);
|
|
959
|
-
finalizeTransition(deps, row.id);
|
|
960
|
-
return { status: 200, body: { ok: true, decision: 'merged', merged: true, prUrl: row.pr_url } };
|
|
1816
|
+
return settleMergedPr(deps, row);
|
|
961
1817
|
}
|
|
962
1818
|
async function runReopen(deps, row) {
|
|
1819
|
+
if (!row.delivery_sha || !row.branch) {
|
|
1820
|
+
return rerouteFromStalePr(deps, row, 'the closed PR cannot be reopened safely because its immutable delivery SHA/head identity is unavailable', false);
|
|
1821
|
+
}
|
|
1822
|
+
const beforeReopen = await (0, pr_lifecycle_1.observePrLifecycle)(deps.exec, deps.project.path, row.pr_url, row.delivery_sha);
|
|
1823
|
+
if (!beforeReopen.ok) {
|
|
1824
|
+
return { status: 502, body: { error: 'gh_failed', detail: beforeReopen.detail } };
|
|
1825
|
+
}
|
|
1826
|
+
if (!(0, pr_lifecycle_1.matchesRecordedPrIdentity)(beforeReopen, row.branch, row.base_branch) ||
|
|
1827
|
+
beforeReopen.includesExpectedSha !== true) {
|
|
1828
|
+
return rerouteFromStalePr(deps, row, 'the closed PR no longer proves the recorded head/base and verified implementation commit', false);
|
|
1829
|
+
}
|
|
1830
|
+
if (beforeReopen.state === 'MERGED')
|
|
1831
|
+
return settleMergedPr(deps, row);
|
|
1832
|
+
if (beforeReopen.state === 'OPEN') {
|
|
1833
|
+
const next = beforeReopen.isDraft ? 'pr_draft' : 'pr_ready';
|
|
1834
|
+
const conflict = casTransition(deps, row, next, {
|
|
1835
|
+
deliveryOutcome: 'delivered', statusCode: beforeReopen.isDraft ? 'pr_draft_ready' : 'pr_ready',
|
|
1836
|
+
statusDetail: null,
|
|
1837
|
+
});
|
|
1838
|
+
if (conflict)
|
|
1839
|
+
return conflict;
|
|
1840
|
+
finalizeTransition(deps, row.id);
|
|
1841
|
+
return {
|
|
1842
|
+
status: 200,
|
|
1843
|
+
body: {
|
|
1844
|
+
ok: true, decision: next, reopened: true, prUrl: row.pr_url,
|
|
1845
|
+
deliveryVerified: true, verifiedSha: row.delivery_sha,
|
|
1846
|
+
remoteHeadSha: beforeReopen.headRefOid, pushed: false,
|
|
1847
|
+
},
|
|
1848
|
+
};
|
|
1849
|
+
}
|
|
963
1850
|
let reopened;
|
|
964
1851
|
try {
|
|
965
1852
|
reopened = await deps.exec.run('gh', ['pr', 'reopen', row.pr_url], deps.project.path);
|
|
@@ -967,20 +1854,45 @@ async function runReopen(deps, row) {
|
|
|
967
1854
|
catch (err) {
|
|
968
1855
|
reopened = { code: 1, stdout: '', stderr: err instanceof Error ? err.message : String(err) };
|
|
969
1856
|
}
|
|
970
|
-
const
|
|
971
|
-
if (
|
|
1857
|
+
const observed = await (0, pr_lifecycle_1.observePrLifecycle)(deps.exec, deps.project.path, row.pr_url, row.delivery_sha);
|
|
1858
|
+
if (!observed.ok) {
|
|
972
1859
|
if (reopened.code !== 0)
|
|
973
1860
|
return ghFailed(reopened);
|
|
974
|
-
return { status: 502, body: { error: 'gh_failed', detail:
|
|
1861
|
+
return { status: 502, body: { error: 'gh_failed', detail: observed.detail } };
|
|
1862
|
+
}
|
|
1863
|
+
const identityMatches = (0, pr_lifecycle_1.matchesRecordedPrIdentity)(observed, row.branch, row.base_branch);
|
|
1864
|
+
if (observed.state === 'MERGED') {
|
|
1865
|
+
if (identityMatches && observed.includesExpectedSha === true)
|
|
1866
|
+
return settleMergedPr(deps, row);
|
|
1867
|
+
return rerouteFromStalePr(deps, row, identityMatches ? mergedEvidenceDetail(observed) : 'the PR identity changed while it was being reopened', false);
|
|
1868
|
+
}
|
|
1869
|
+
if (observed.state === 'CLOSED') {
|
|
1870
|
+
if (!identityMatches || observed.includesExpectedSha !== true) {
|
|
1871
|
+
return rerouteFromStalePr(deps, row, 'the PR remained closed without proving the recorded head/base and verified implementation commit', false);
|
|
1872
|
+
}
|
|
1873
|
+
if (reopened.code !== 0)
|
|
1874
|
+
return ghFailed(reopened);
|
|
1875
|
+
return { status: 502, body: { error: 'gh_failed', detail: 'PR remained closed after reopen' } };
|
|
975
1876
|
}
|
|
976
|
-
|
|
1877
|
+
if (!identityMatches || observed.includesExpectedSha !== true) {
|
|
1878
|
+
return rerouteFromStalePr(deps, row, 'the reopened PR no longer proves the recorded head/base and verified implementation commit', true);
|
|
1879
|
+
}
|
|
1880
|
+
const next = observed.isDraft ? 'pr_draft' : 'pr_ready';
|
|
977
1881
|
const conflict = casTransition(deps, row, next, {
|
|
978
|
-
deliveryOutcome: 'delivered', statusCode:
|
|
1882
|
+
deliveryOutcome: 'delivered', statusCode: observed.isDraft ? 'pr_draft_ready' : 'pr_ready',
|
|
1883
|
+
statusDetail: null,
|
|
979
1884
|
});
|
|
980
1885
|
if (conflict)
|
|
981
1886
|
return conflict;
|
|
982
1887
|
finalizeTransition(deps, row.id);
|
|
983
|
-
return {
|
|
1888
|
+
return {
|
|
1889
|
+
status: 200,
|
|
1890
|
+
body: {
|
|
1891
|
+
ok: true, decision: next, reopened: true, prUrl: row.pr_url,
|
|
1892
|
+
deliveryVerified: true, verifiedSha: row.delivery_sha,
|
|
1893
|
+
remoteHeadSha: observed.headRefOid, pushed: false,
|
|
1894
|
+
},
|
|
1895
|
+
};
|
|
984
1896
|
}
|
|
985
1897
|
async function runMergeLocal(deps, row) {
|
|
986
1898
|
return (0, repo_lock_1.withRepoLock)(deps.project.path, () => runMergeLocalLocked(deps, row));
|
|
@@ -1136,38 +2048,18 @@ async function runMergeLocalLocked(deps, row) {
|
|
|
1136
2048
|
await removeAssembly();
|
|
1137
2049
|
// Post-advance sweep: the work now lives on
|
|
1138
2050
|
// the integration branch, so the launch's worktrees + branches are spent.
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
cleanupWarnings.push(`worktree ${wt.worktree_path}: ${detail}`);
|
|
1152
|
-
console.warn(`[rail-pr-decision] merge-local could not remove worktree ${wt.worktree_path}: ${detail}`);
|
|
1153
|
-
continue;
|
|
1154
|
-
}
|
|
1155
|
-
if (!(0, rail_worktrees_store_1.isTerminalMergeState)(wt.merge_state))
|
|
1156
|
-
(0, rail_worktrees_store_1.updateRailWorktreeState)(deps.db, wt.id, 'merged');
|
|
1157
|
-
}
|
|
1158
|
-
for (const branch of branchSet) {
|
|
1159
|
-
if (!branch || branch === row.base_branch)
|
|
1160
|
-
continue;
|
|
1161
|
-
try {
|
|
1162
|
-
const deleted = await deps.git.run(['branch', '-D', branch], deps.project.path);
|
|
1163
|
-
if (deleted.code !== 0) {
|
|
1164
|
-
cleanupWarnings.push(`branch ${branch}: ${(deleted.stderr.trim() || deleted.stdout.trim()).split('\n')[0] || `exit ${deleted.code}`}`);
|
|
1165
|
-
}
|
|
1166
|
-
}
|
|
1167
|
-
catch (err) {
|
|
1168
|
-
cleanupWarnings.push(`branch ${branch}: ${err instanceof Error ? err.message : String(err)}`);
|
|
1169
|
-
}
|
|
1170
|
-
}
|
|
2051
|
+
cleanupWarnings.push(...await (0, rail_worktree_release_1.releaseRailWorktrees)({
|
|
2052
|
+
db: deps.db,
|
|
2053
|
+
git: deps.git,
|
|
2054
|
+
repoDir: deps.project.path,
|
|
2055
|
+
worktreeIds: releasableWorktreeIds(deps, snap),
|
|
2056
|
+
state: 'merged',
|
|
2057
|
+
expectedHeadByBranch: (0, rail_worktree_release_1.durableBranchHeads)(snap.branches),
|
|
2058
|
+
overlayEvidenceByBranch: (0, rail_worktree_release_1.durableOverlayCleanupEvidence)(snap.branches),
|
|
2059
|
+
settlementIgnoredByBranch: (0, rail_worktree_release_1.durableSettlementIgnoredPaths)(snap.branches),
|
|
2060
|
+
onSafetyArchive: safetyArchiveRecorder(deps, row),
|
|
2061
|
+
}));
|
|
2062
|
+
await deleteOwnedBranchesIfUnchanged(deps, row, snap, cleanupWarnings);
|
|
1171
2063
|
const conflict = casTransitionWithTicketEffect(deps, row, 'merged', {
|
|
1172
2064
|
deliveryOutcome: 'delivered',
|
|
1173
2065
|
statusCode: cleanupWarnings.length > 0 ? 'cleanup_incomplete' : 'merged',
|