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.
Files changed (101) hide show
  1. package/client/dist/assets/{ActivityFeedPage-ClpD5CFj.js → ActivityFeedPage-CDMzpGXd.js} +1 -1
  2. package/client/dist/assets/{AgentBrowserCapture-CSPs9Ofc.js → AgentBrowserCapture-BVTacapZ.js} +1 -1
  3. package/client/dist/assets/{AgentModeCodePane-DwcyklA5.js → AgentModeCodePane-D9bhFyuy.js} +2 -2
  4. package/client/dist/assets/{AgentModeJobsPane-C69bqlMa.js → AgentModeJobsPane-DAjN8Au5.js} +1 -1
  5. package/client/dist/assets/{AgentsPage-pRiRr9-W.js → AgentsPage-CebykajE.js} +1 -1
  6. package/client/dist/assets/{AnalyticsPage-C14exKsC.js → AnalyticsPage-BiPrVvgv.js} +1 -1
  7. package/client/dist/assets/{CodePage-CK76JKm9.js → CodePage-Dw9eQFRS.js} +1 -1
  8. package/client/dist/assets/{DesktopAnalyticsPage-Cnv0f3XQ.js → DesktopAnalyticsPage-cCG_JAEi.js} +1 -1
  9. package/client/dist/assets/{DocsDialog-CHUm_Ym0.js → DocsDialog-C1kZ_mRP.js} +1 -1
  10. package/client/dist/assets/{DocsPage-COI-oDok.js → DocsPage-3GivxWJm.js} +1 -1
  11. package/client/dist/assets/{InteractiveJobComposer-CypM7R1F.js → InteractiveJobComposer-KuZXGdr8.js} +1 -1
  12. package/client/dist/assets/{JobDetailModal-B_W0H9dw.js → JobDetailModal-DWpRALkB.js} +1 -1
  13. package/client/dist/assets/{JobDetailPage-CeeXOoKS.js → JobDetailPage-mrXDKafj.js} +1 -1
  14. package/client/dist/assets/{JobsPage-CwhJG9jw.js → JobsPage-CUYXjcoc.js} +1 -1
  15. package/client/dist/assets/{LoopBuilderPage-C51t1WOM.js → LoopBuilderPage-CwjBD5Gm.js} +1 -1
  16. package/client/dist/assets/{LoopPreviewModal-Y2HgtIJu.js → LoopPreviewModal-CO9ton0-.js} +1 -1
  17. package/client/dist/assets/{LoopsPage-Bvn1Kw5V.js → LoopsPage-CLL5UuvN.js} +1 -1
  18. package/client/dist/assets/{MinimizedChatsContext-DiFvZDPW.js → MinimizedChatsContext-DJAkRYps.js} +1 -1
  19. package/client/dist/assets/{PluginsPage-BGPRJpdf.js → PluginsPage-DZvCC5Ri.js} +1 -1
  20. package/client/dist/assets/{ProjectSettingsDialog-D_GTyGLc.js → ProjectSettingsDialog-C7Hesadg.js} +1 -1
  21. package/client/dist/assets/{TemplatePreviewModal-szwBhd7h.js → TemplatePreviewModal-DczCzUzP.js} +1 -1
  22. package/client/dist/assets/{TicketDetailModal-9UEyK974.js → TicketDetailModal-CFh7GOv5.js} +4 -4
  23. package/client/dist/assets/{Trans-B_vKZ-Hb.js → Trans-Cm9rhdnG.js} +1 -1
  24. package/client/dist/assets/agent-BXc59ARK2.js +1 -0
  25. package/client/dist/assets/agent-BbIFXdDq.js +1 -0
  26. package/client/dist/assets/agent-BqEfA5u5.js +1 -0
  27. package/client/dist/assets/agent-BtGzgTC4.js +1 -0
  28. package/client/dist/assets/agent-CIhys_5d.js +1 -0
  29. package/client/dist/assets/agent-CJPaqsVR.js +1 -0
  30. package/client/dist/assets/agent-n-hY4GuL.js +1 -0
  31. package/client/dist/assets/agent-tJ-efa3_2.js +1 -0
  32. package/client/dist/assets/common-6MLbD-jM.js +1 -0
  33. package/client/dist/assets/common-BKGEu4rm.js +1 -0
  34. package/client/dist/assets/common-BNbnv8cE2.js +1 -0
  35. package/client/dist/assets/common-BqGWtDbt.js +1 -0
  36. package/client/dist/assets/common-DBtTzeYm2.js +1 -0
  37. package/client/dist/assets/common-DEW3Nr8n.js +1 -0
  38. package/client/dist/assets/{common-DVQuhs-m.js → common-DHsv9N5d.js} +1 -1
  39. package/client/dist/assets/common-mkrEcW-Y.js +1 -0
  40. package/client/dist/assets/dashboard-CHCEkds4.js +1 -0
  41. package/client/dist/assets/dashboard-Cqmmbh3l2.js +1 -0
  42. package/client/dist/assets/dashboard-CrlTV1ms.js +1 -0
  43. package/client/dist/assets/dashboard-Cslu_GCH.js +1 -0
  44. package/client/dist/assets/dashboard-DUU8-_Rg.js +1 -0
  45. package/client/dist/assets/dashboard-DweT3Y4R.js +1 -0
  46. package/client/dist/assets/dashboard-Dy2Um-US2.js +1 -0
  47. package/client/dist/assets/dashboard-WhzOQw1S.js +1 -0
  48. package/client/dist/assets/{dist-js-VwztRkGj.js → dist-js-BERxOT0X.js} +1 -1
  49. package/client/dist/assets/{dist-js-CcBCbb32.js → dist-js-Di8lflPo.js} +1 -1
  50. package/client/dist/assets/{formatDistanceToNow-ByHIauTQ.js → formatDistanceToNow-BAXZRbEO.js} +1 -1
  51. package/client/dist/assets/{getTimezoneOffsetInMilliseconds-BxHAksaU.js → getTimezoneOffsetInMilliseconds-DwzZXHtg.js} +1 -1
  52. package/client/dist/assets/{i18n-CgaRxpII.js → i18n-cK5pPbj8.js} +2 -2
  53. package/client/dist/assets/index-BT6xDQjB.css +2 -0
  54. package/client/dist/assets/index-CCq_kIrF.js +76 -0
  55. package/client/dist/assets/{spending-C4p06nen.js → spending-BLb5pj6G.js} +1 -1
  56. package/client/dist/assets/{useBrowserCaptureSession-2OqX0vap.js → useBrowserCaptureSession-DR1_jL56.js} +1 -1
  57. package/client/dist/assets/{useDesktop-X8YB9xzv.js → useDesktop-D4DxBGPU.js} +1 -1
  58. package/client/dist/index.html +14 -14
  59. package/docs/internals/safe-pr-review-flow.md +250 -38
  60. package/package.json +1 -1
  61. package/server/dist/active-pr-continuation.js +352 -53
  62. package/server/dist/agent-chat-manager.js +29 -12
  63. package/server/dist/agent-operator-prompt.js +9 -0
  64. package/server/dist/agent-store.js +19 -4
  65. package/server/dist/db.js +21 -0
  66. package/server/dist/mcp/tools/rails.js +8 -0
  67. package/server/dist/pr-lifecycle.js +100 -8
  68. package/server/dist/project-git.js +68 -3
  69. package/server/dist/rail-isolated-launch.js +719 -124
  70. package/server/dist/rail-pr-decision.js +1073 -181
  71. package/server/dist/rail-pr-recovery-git.js +235 -0
  72. package/server/dist/rail-pr-store.js +120 -9
  73. package/server/dist/rail-worktree-release.js +376 -4
  74. package/server/dist/rails-router.js +196 -10
  75. package/server/dist/worktree-manager.js +149 -18
  76. package/server/dist/worktree-overlay.js +166 -4
  77. package/client/dist/assets/agent-4rmXoo_p.js +0 -1
  78. package/client/dist/assets/agent-BxZuNGih.js +0 -1
  79. package/client/dist/assets/agent-C3uXOYv_.js +0 -1
  80. package/client/dist/assets/agent-CA8Y4wbw.js +0 -1
  81. package/client/dist/assets/agent-CASKrhG12.js +0 -1
  82. package/client/dist/assets/agent-Cv-jud5n2.js +0 -1
  83. package/client/dist/assets/agent-DitUxYh2.js +0 -1
  84. package/client/dist/assets/agent-Doow4X7o.js +0 -1
  85. package/client/dist/assets/common-CD1QHriA2.js +0 -1
  86. package/client/dist/assets/common-CwwPaRxx.js +0 -1
  87. package/client/dist/assets/common-Dg2jaX7d.js +0 -1
  88. package/client/dist/assets/common-DuBwj6tK.js +0 -1
  89. package/client/dist/assets/common-MRj9MHcc.js +0 -1
  90. package/client/dist/assets/common-O4ZpnMLp.js +0 -1
  91. package/client/dist/assets/common-fyeF0v7C2.js +0 -1
  92. package/client/dist/assets/dashboard-B_NkSHYm.js +0 -1
  93. package/client/dist/assets/dashboard-ClBb_3pq.js +0 -1
  94. package/client/dist/assets/dashboard-CpCmJvMB.js +0 -1
  95. package/client/dist/assets/dashboard-Czohu35O2.js +0 -1
  96. package/client/dist/assets/dashboard-DcjbD0UK.js +0 -1
  97. package/client/dist/assets/dashboard-Dvx5F3we.js +0 -1
  98. package/client/dist/assets/dashboard-R1bOPjBh2.js +0 -1
  99. package/client/dist/assets/dashboard-owcDucDi.js +0 -1
  100. package/client/dist/assets/index-BflYQpWm.css +0 -2
  101. package/client/dist/assets/index-C1_-BTXy.js +0 -72
@@ -0,0 +1,235 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.scanUnreachableRecoveryCommits = scanUnreachableRecoveryCommits;
4
+ exports.discoverRunMarkedCommit = discoverRunMarkedCommit;
5
+ exports.commitCarriesRunMarker = commitCarriesRunMarker;
6
+ exports.recoveryRefForDelivery = recoveryRefForDelivery;
7
+ exports.listRecoveryCommitProtections = listRecoveryCommitProtections;
8
+ exports.inspectRecoveryCommitProtection = inspectRecoveryCommitProtection;
9
+ exports.protectRecoveryCommit = protectRecoveryCommit;
10
+ exports.advanceRecoveryCommitProtection = advanceRecoveryCommitProtection;
11
+ exports.releaseRecoveryCommit = releaseRecoveryCommit;
12
+ const crypto_1 = require("crypto");
13
+ const COMMIT_SHA_RE = /^[0-9a-f]{40,64}$/i;
14
+ const MAX_RECOVERY_COMMIT_CANDIDATES = 512;
15
+ /**
16
+ * Read the complete bounded unreachable-commit surface. Unknown output and an
17
+ * excessive candidate universe fail closed: truncating either could turn an
18
+ * ambiguous recovery into a false unique result.
19
+ */
20
+ async function scanUnreachableRecoveryCommits(git, repoDir) {
21
+ try {
22
+ const result = await git.run(['fsck', '--unreachable', '--no-reflogs', '--no-progress'], repoDir);
23
+ if (result.code !== 0) {
24
+ return { ok: false, detail: 'Git could not enumerate unreachable recovery objects' };
25
+ }
26
+ const commits = new Set();
27
+ for (const rawLine of result.stdout.split(/\r?\n/)) {
28
+ const line = rawLine.trim();
29
+ if (!line)
30
+ continue;
31
+ const commit = /^unreachable commit ([0-9a-f]{40,64})$/i.exec(line);
32
+ if (commit) {
33
+ commits.add(commit[1].toLowerCase());
34
+ if (commits.size > MAX_RECOVERY_COMMIT_CANDIDATES) {
35
+ return { ok: false, detail: 'Git exposed too many unreachable commits for safe recovery' };
36
+ }
37
+ continue;
38
+ }
39
+ if (/^(?:unreachable|dangling) (?:blob|tree|tag) [0-9a-f]{40,64}$/i.test(line))
40
+ continue;
41
+ return { ok: false, detail: 'Git returned malformed unreachable-object evidence' };
42
+ }
43
+ return { ok: true, commits: [...commits] };
44
+ }
45
+ catch {
46
+ return { ok: false, detail: 'Git could not enumerate unreachable recovery objects' };
47
+ }
48
+ }
49
+ /**
50
+ * Combine refs, reflogs and unreachable commits, then inspect every subject.
51
+ * Any enumeration/inspection failure invalidates the entire uniqueness proof.
52
+ */
53
+ async function discoverRunMarkedCommit(git, repoDir, runId, suppliedScan) {
54
+ if (!runId || runId.length > 256 || /[\0\r\n]/.test(runId)) {
55
+ return { kind: 'scan_failed', detail: 'The recorded run identifier is not safe to inspect' };
56
+ }
57
+ const unreachable = suppliedScan ?? await scanUnreachableRecoveryCommits(git, repoDir);
58
+ if (!unreachable.ok)
59
+ return { kind: 'scan_failed', detail: unreachable.detail };
60
+ const candidates = new Set(unreachable.commits.map((sha) => sha.toLowerCase()));
61
+ let refs;
62
+ try {
63
+ refs = await git.run(['log', '--all', '--reflog', '--fixed-strings', `--grep=(run ${runId})`, '--format=%H'], repoDir);
64
+ }
65
+ catch {
66
+ return { kind: 'scan_failed', detail: 'Git could not enumerate recovery refs and reflogs' };
67
+ }
68
+ if (refs.code !== 0) {
69
+ return { kind: 'scan_failed', detail: 'Git could not enumerate recovery refs and reflogs' };
70
+ }
71
+ for (const rawLine of refs.stdout.split(/\r?\n/)) {
72
+ const sha = rawLine.trim();
73
+ if (!sha)
74
+ continue;
75
+ if (!COMMIT_SHA_RE.test(sha)) {
76
+ return { kind: 'scan_failed', detail: 'Git returned malformed recovery commit evidence' };
77
+ }
78
+ candidates.add(sha.toLowerCase());
79
+ if (candidates.size > MAX_RECOVERY_COMMIT_CANDIDATES) {
80
+ return { kind: 'scan_failed', detail: 'Git exposed too many commits for safe recovery' };
81
+ }
82
+ }
83
+ const marker = `(run ${runId})`;
84
+ const marked = [];
85
+ for (const sha of candidates) {
86
+ let subject;
87
+ try {
88
+ subject = await git.run(['show', '-s', '--format=%s', sha], repoDir);
89
+ }
90
+ catch {
91
+ return { kind: 'scan_failed', detail: 'Git could not inspect every recovery commit subject' };
92
+ }
93
+ if (subject.code !== 0) {
94
+ return { kind: 'scan_failed', detail: 'Git could not inspect every recovery commit subject' };
95
+ }
96
+ if (subject.stdout.trim().includes(marker))
97
+ marked.push(sha);
98
+ }
99
+ if (marked.length === 0)
100
+ return { kind: 'none' };
101
+ if (marked.length > 1)
102
+ return { kind: 'ambiguous', count: marked.length };
103
+ return { kind: 'unique', sha: marked[0] };
104
+ }
105
+ async function commitCarriesRunMarker(git, repoDir, sha, runId) {
106
+ if (!COMMIT_SHA_RE.test(sha))
107
+ return false;
108
+ try {
109
+ const result = await git.run(['show', '-s', '--format=%s', sha], repoDir);
110
+ return result.code === 0 && result.stdout.trim().includes(`(run ${runId})`);
111
+ }
112
+ catch {
113
+ return false;
114
+ }
115
+ }
116
+ function recoveryRefForDelivery(deliveryId) {
117
+ const digest = (0, crypto_1.createHash)('sha256').update(deliveryId).digest('hex');
118
+ return `refs/specrails/recovery/${digest}`;
119
+ }
120
+ async function listRecoveryCommitProtections(git, repoDir) {
121
+ try {
122
+ const result = await git.run(['for-each-ref', '--format=%(refname)%09%(objectname)', 'refs/specrails/recovery/'], repoDir);
123
+ if (result.code !== 0) {
124
+ return { ok: false, detail: 'Git could not enumerate delivery recovery refs' };
125
+ }
126
+ const protections = new Map();
127
+ for (const rawLine of result.stdout.split(/\r?\n/)) {
128
+ const line = rawLine.trim();
129
+ if (!line)
130
+ continue;
131
+ const match = /^(refs\/specrails\/recovery\/[0-9a-f]{64})\t([0-9a-f]{40,64})$/i.exec(line);
132
+ if (!match || protections.has(match[1])) {
133
+ return { ok: false, detail: 'Git returned malformed delivery recovery refs' };
134
+ }
135
+ protections.set(match[1], match[2].toLowerCase());
136
+ if (protections.size > MAX_RECOVERY_COMMIT_CANDIDATES) {
137
+ return { ok: false, detail: 'Git exposed too many delivery recovery refs for safe cleanup' };
138
+ }
139
+ }
140
+ return { ok: true, protections };
141
+ }
142
+ catch {
143
+ return { ok: false, detail: 'Git could not enumerate delivery recovery refs' };
144
+ }
145
+ }
146
+ async function inspectRecoveryCommitProtection(git, repoDir, deliveryId) {
147
+ const ref = recoveryRefForDelivery(deliveryId);
148
+ try {
149
+ const exists = await git.run(['show-ref', '--verify', '--quiet', ref], repoDir);
150
+ if (exists.code === 1)
151
+ return { kind: 'absent', ref };
152
+ if (exists.code !== 0)
153
+ return { kind: 'unreadable', ref };
154
+ const result = await git.run(['rev-parse', '--verify', ref], repoDir);
155
+ if (result.code !== 0)
156
+ return { kind: 'unreadable', ref };
157
+ const sha = result.stdout.trim().toLowerCase();
158
+ return COMMIT_SHA_RE.test(sha)
159
+ ? { kind: 'present', ref, sha }
160
+ : { kind: 'unreadable', ref };
161
+ }
162
+ catch {
163
+ return { kind: 'unreadable', ref };
164
+ }
165
+ }
166
+ /** Pin a proven orphan without moving any user-visible branch or checkout. */
167
+ async function protectRecoveryCommit(git, repoDir, deliveryId, sha) {
168
+ if (!COMMIT_SHA_RE.test(sha))
169
+ return { ok: false, detail: 'The recovery commit SHA is invalid' };
170
+ const ref = recoveryRefForDelivery(deliveryId);
171
+ const zero = '0'.repeat(sha.length);
172
+ try {
173
+ const created = await git.run(['update-ref', ref, sha, zero], repoDir);
174
+ if (created.code !== 0) {
175
+ const existing = await git.run(['rev-parse', '--verify', ref], repoDir);
176
+ if (existing.code !== 0 || existing.stdout.trim().toLowerCase() !== sha.toLowerCase()) {
177
+ return { ok: false, detail: 'A different commit already owns this delivery recovery ref' };
178
+ }
179
+ }
180
+ const verified = await git.run(['rev-parse', '--verify', ref], repoDir);
181
+ if (verified.code !== 0 || verified.stdout.trim().toLowerCase() !== sha.toLowerCase()) {
182
+ return { ok: false, detail: 'Git could not verify the delivery recovery ref' };
183
+ }
184
+ return { ok: true, ref };
185
+ }
186
+ catch {
187
+ return { ok: false, detail: 'Git could not protect the recovered commit from cleanup' };
188
+ }
189
+ }
190
+ /** Move an already-owned protection ref to a newly committed descendant using
191
+ * an exact compare-and-set. The old orphan remains pinned unless the caller
192
+ * proves it still owns the ref at the instant of the update. */
193
+ async function advanceRecoveryCommitProtection(git, repoDir, deliveryId, previousSha, nextSha) {
194
+ if (!COMMIT_SHA_RE.test(previousSha) || !COMMIT_SHA_RE.test(nextSha)) {
195
+ return { ok: false, detail: 'The recovery commit SHA is invalid' };
196
+ }
197
+ if (previousSha.toLowerCase() === nextSha.toLowerCase()) {
198
+ return protectRecoveryCommit(git, repoDir, deliveryId, nextSha);
199
+ }
200
+ const ref = recoveryRefForDelivery(deliveryId);
201
+ try {
202
+ const moved = await git.run(['update-ref', ref, nextSha, previousSha], repoDir);
203
+ if (moved.code !== 0) {
204
+ return { ok: false, detail: 'The delivery recovery ref changed before the new commit could be protected' };
205
+ }
206
+ const verified = await git.run(['rev-parse', '--verify', ref], repoDir);
207
+ if (verified.code !== 0 || verified.stdout.trim().toLowerCase() !== nextSha.toLowerCase()) {
208
+ return { ok: false, detail: 'Git could not verify the advanced delivery recovery ref' };
209
+ }
210
+ return { ok: true, ref };
211
+ }
212
+ catch {
213
+ return { ok: false, detail: 'Git could not advance the delivery recovery ref safely' };
214
+ }
215
+ }
216
+ /** Delete only the exact protected object; a substituted ref is preserved. */
217
+ async function releaseRecoveryCommit(git, repoDir, deliveryId, expectedSha) {
218
+ // A missing/invalid expected object is not authority to delete a durable
219
+ // protection ref. Callers may be settling legacy rows that never created
220
+ // one, while a concurrently repaired row could already own this delivery's
221
+ // ref. Preserve unknown state rather than deleting by name alone.
222
+ if (!expectedSha || !COMMIT_SHA_RE.test(expectedSha))
223
+ return false;
224
+ const ref = recoveryRefForDelivery(deliveryId);
225
+ try {
226
+ const removed = await git.run(['update-ref', '-d', ref, expectedSha], repoDir);
227
+ if (removed.code === 0)
228
+ return true;
229
+ const existing = await git.run(['rev-parse', '--verify', ref], repoDir);
230
+ return existing.code !== 0;
231
+ }
232
+ catch {
233
+ return false;
234
+ }
235
+ }
@@ -8,10 +8,15 @@ exports.failPrDeliveryAndRestoreSuperseded = failPrDeliveryAndRestoreSuperseded;
8
8
  exports.getPrDelivery = getPrDelivery;
9
9
  exports.getActivePrDeliveryByRail = getActivePrDeliveryByRail;
10
10
  exports.listActivePrDeliveries = listActivePrDeliveries;
11
+ exports.listTerminalPrDeliveries = listTerminalPrDeliveries;
12
+ exports.getLatestTerminalPrDeliveryTouchingTicketSet = getLatestTerminalPrDeliveryTouchingTicketSet;
11
13
  exports.listOriginLinkedPrDeliveries = listOriginLinkedPrDeliveries;
12
14
  exports.reconcileFailedBuildingPrDeliveries = reconcileFailedBuildingPrDeliveries;
13
15
  exports.boundPrDiagnostic = boundPrDiagnostic;
14
16
  exports.boundCleanupWarnings = boundCleanupWarnings;
17
+ exports.normalizeSafetyArchives = normalizeSafetyArchives;
18
+ exports.mergeSafetyArchives = mergeSafetyArchives;
19
+ exports.appendPrDeliverySafetyArchive = appendPrDeliverySafetyArchive;
15
20
  exports.transitionDecision = transitionDecision;
16
21
  exports.transitionClaimedDecision = transitionClaimedDecision;
17
22
  exports.claimPrDeliveryOperation = claimPrDeliveryOperation;
@@ -65,6 +70,16 @@ class PrDeliveryGenerationConflict extends Error {
65
70
  }
66
71
  }
67
72
  exports.PrDeliveryGenerationConflict = PrDeliveryGenerationConflict;
73
+ /** SQLite's `datetime('now')` has one-second precision, so a claim, transition,
74
+ * and release in the same second used to produce conflicting snapshots with an
75
+ * identical `updatedAt`. Clients must fail closed on such ties. Keep the wire
76
+ * timestamp strictly increasing per row while retaining a parseable UTC ISO
77
+ * value for legacy rows and every mutation path. */
78
+ const NEXT_PR_UPDATED_AT_SQL = `CASE
79
+ WHEN julianday('now') > julianday(updated_at)
80
+ THEN strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
81
+ ELSE strftime('%Y-%m-%dT%H:%M:%fZ', updated_at, '+0.001 seconds')
82
+ END`;
68
83
  /** Recheck admission and, for a continuation, replace the prior generation in
69
84
  * one SQLite transaction. The partial unique index is therefore an invariant,
70
85
  * not merely a router convention. Pass null for a genuinely fresh launch. */
@@ -87,7 +102,8 @@ function createPrDeliveryGeneration(db, input, expectedActive) {
87
102
  };
88
103
  const moved = db.prepare(`
89
104
  UPDATE rail_pr_deliveries
90
- SET decision = 'superseded', updated_at = datetime('now')
105
+ SET decision = 'superseded', restored_from_delivery_id = NULL,
106
+ updated_at = ${NEXT_PR_UPDATED_AT_SQL}
91
107
  WHERE id = ? AND decision = ? AND operation_token IS NULL
92
108
  `).run(current.id, current.decision);
93
109
  if (moved.changes !== 1)
@@ -112,16 +128,17 @@ function failPrDeliveryAndRestoreSuperseded(db, failedId, previous) {
112
128
  const failed = db.prepare(`
113
129
  UPDATE rail_pr_deliveries
114
130
  SET decision = 'discarded', status_code = 'delivery_failed',
115
- delivery_outcome = 'blocked', updated_at = datetime('now')
131
+ delivery_outcome = 'blocked', updated_at = ${NEXT_PR_UPDATED_AT_SQL}
116
132
  WHERE id = ? AND decision = 'building'
117
133
  `).run(failedId);
118
134
  if (failed.changes !== 1)
119
135
  return false;
120
136
  const restored = db.prepare(`
121
137
  UPDATE rail_pr_deliveries
122
- SET decision = ?, status_code = ?, updated_at = datetime('now')
138
+ SET decision = ?, status_code = ?, restored_from_delivery_id = ?,
139
+ updated_at = ${NEXT_PR_UPDATED_AT_SQL}
123
140
  WHERE id = ? AND decision = 'superseded'
124
- `).run(previous.decision, previous.statusCode, previous.id);
141
+ `).run(previous.decision, previous.statusCode, failedId, previous.id);
125
142
  if (restored.changes !== 1)
126
143
  throw new Error(`failed to restore superseded delivery ${previous.id}`);
127
144
  return true;
@@ -150,6 +167,39 @@ function listActivePrDeliveries(db) {
150
167
  ORDER BY rail_index, created_at DESC, rowid DESC`)
151
168
  .all();
152
169
  }
170
+ /** Terminal delivery history, newest first. Continuation discovery uses this
171
+ * only as a conservative ownership hint: callers must still require an exact
172
+ * ticket target and revalidate the recorded PR/ref identity before reuse. */
173
+ function listTerminalPrDeliveries(db) {
174
+ return db
175
+ .prepare(`SELECT * FROM rail_pr_deliveries
176
+ WHERE decision IN ('completed','merged','discarded','superseded')
177
+ ORDER BY created_at DESC, rowid DESC`)
178
+ .all();
179
+ }
180
+ function canonicalTicketSet(values) {
181
+ if (values.length === 0)
182
+ return null;
183
+ const tickets = new Set();
184
+ for (const value of values) {
185
+ if (typeof value === 'number' && Number.isSafeInteger(value) && value > 0)
186
+ tickets.add(value);
187
+ }
188
+ return tickets.size > 0 ? [...tickets].sort((a, b) => a - b).join(',') : null;
189
+ }
190
+ /**
191
+ * Newest terminal generation touching any requested ticket. The returned row
192
+ * is intentionally not otherwise validated: a newer subset, superset, or
193
+ * malformed row still owns the overlapping ticket lineage and must shadow
194
+ * older exact-set history rather than letting a caller resurrect a stale PR.
195
+ */
196
+ function getLatestTerminalPrDeliveryTouchingTicketSet(db, ticketIds) {
197
+ const target = canonicalTicketSet(ticketIds);
198
+ if (!target || target.split(',').length !== ticketIds.length)
199
+ return undefined;
200
+ const wanted = new Set(target.split(',').map(Number));
201
+ return listTerminalPrDeliveries(db).find((row) => (parseJsonArray(row.ticket_ids).some((value) => (typeof value === 'number' && Number.isSafeInteger(value) && wanted.has(value)))));
202
+ }
153
203
  /** Startup projection source for persisted Agent cards. Terminal rows matter:
154
204
  * a process may die after their SQLite transition but before the conversation
155
205
  * envelope update, leaving an obsolete actionable card pinned forever. */
@@ -294,6 +344,7 @@ const PATCH_COLUMNS = {
294
344
  operationToken: 'operation_token',
295
345
  operationStartedAtMs: 'operation_started_at_ms',
296
346
  cleanupWarnings: 'cleanup_warnings',
347
+ safetyArchives: 'safety_archives',
297
348
  };
298
349
  function patchValue(key, patch) {
299
350
  const raw = patch[key];
@@ -301,6 +352,8 @@ function patchValue(key, patch) {
301
352
  return JSON.stringify(raw);
302
353
  if (key === 'cleanupWarnings')
303
354
  return JSON.stringify(boundCleanupWarnings(raw));
355
+ if (key === 'safetyArchives')
356
+ return JSON.stringify(normalizeSafetyArchives(raw));
304
357
  if (key === 'statusDetail')
305
358
  return raw == null ? null : boundPrDiagnostic(String(raw));
306
359
  if (key === 'isContinuation')
@@ -317,6 +370,45 @@ function boundPrDiagnostic(value, maxLength = 512) {
317
370
  function boundCleanupWarnings(values) {
318
371
  return [...new Set(values.map((value) => boundPrDiagnostic(value)).filter(Boolean))].slice(0, 8);
319
372
  }
373
+ /** Safety archive paths must remain exact filesystem locations. They are
374
+ * internally generated pointers to bytes that still occupy disk, so dropping
375
+ * an older pointer is never a valid payload-size optimization. Deduplicate
376
+ * exact non-empty OS-representable paths without trimming them. */
377
+ function normalizeSafetyArchives(values) {
378
+ const newestUnique = [];
379
+ for (const value of values) {
380
+ if (typeof value !== 'string' || value.length === 0 || value.length > 8192 || value.includes('\0'))
381
+ continue;
382
+ const previous = newestUnique.indexOf(value);
383
+ if (previous >= 0)
384
+ newestUnique.splice(previous, 1);
385
+ newestUnique.push(value);
386
+ }
387
+ return newestUnique;
388
+ }
389
+ function mergeSafetyArchives(existing, additions) {
390
+ return normalizeSafetyArchives([...existing, ...additions]);
391
+ }
392
+ /** Add one quarantine path independently of lifecycle state. Safety archives
393
+ * are monotonic recovery metadata: a concurrent decision transition must not
394
+ * erase a path created just before a crash. */
395
+ function appendPrDeliverySafetyArchive(db, id, archive) {
396
+ return db.transaction(() => {
397
+ const row = db.prepare(`SELECT safety_archives FROM rail_pr_deliveries WHERE id = ?`)
398
+ .get(id);
399
+ if (!row)
400
+ return null;
401
+ const existing = normalizeSafetyArchives(parseJsonArray(row.safety_archives ?? '[]')
402
+ .filter((value) => typeof value === 'string'));
403
+ const merged = mergeSafetyArchives(existing, [archive]);
404
+ db.prepare(`
405
+ UPDATE rail_pr_deliveries
406
+ SET safety_archives = ?, updated_at = ${NEXT_PR_UPDATED_AT_SQL}
407
+ WHERE id = ?
408
+ `).run(JSON.stringify(merged), id);
409
+ return merged;
410
+ })();
411
+ }
320
412
  /**
321
413
  * COMPARE-AND-SET decision transition: one atomic
322
414
  * `UPDATE … WHERE id = ? AND decision = ?` (no read-modify-write), so two
@@ -331,7 +423,7 @@ function transitionDecision(db, id, expected, next, patch = {}) {
331
423
  const values = keys.map((k) => patchValue(k, patch));
332
424
  return (db
333
425
  .prepare(`UPDATE rail_pr_deliveries
334
- SET decision = ?, updated_at = datetime('now')${patchClause}
426
+ SET decision = ?, updated_at = ${NEXT_PR_UPDATED_AT_SQL}${patchClause}
335
427
  WHERE id = ? AND decision = ?`)
336
428
  .run(next, ...values, id, expected).changes > 0);
337
429
  }
@@ -347,7 +439,7 @@ function transitionClaimedDecision(db, id, expected, next, operationToken, patch
347
439
  const values = keys.map((key) => patchValue(key, patch));
348
440
  return db.prepare(`
349
441
  UPDATE rail_pr_deliveries
350
- SET decision = ?, updated_at = datetime('now')${patchClause}
442
+ SET decision = ?, updated_at = ${NEXT_PR_UPDATED_AT_SQL}${patchClause}
351
443
  WHERE id = ? AND decision = ? AND operation_token = ?
352
444
  `).run(next, ...values, id, expected, operationToken).changes > 0;
353
445
  }
@@ -362,7 +454,7 @@ function claimPrDeliveryOperation(db, id, expected, operation, token, nowMs = Da
362
454
  return db.prepare(`
363
455
  UPDATE rail_pr_deliveries
364
456
  SET operation = ?, operation_token = ?, operation_started_at_ms = ?,
365
- updated_at = datetime('now')
457
+ updated_at = ${NEXT_PR_UPDATED_AT_SQL}
366
458
  WHERE id = ? AND decision = ?
367
459
  AND (operation_token IS NULL OR operation_started_at_ms IS NULL OR operation_started_at_ms < ?)
368
460
  `).run(operation, token, nowMs, id, expected, staleBefore).changes > 0;
@@ -373,7 +465,7 @@ function releasePrDeliveryOperation(db, id, token) {
373
465
  return db.prepare(`
374
466
  UPDATE rail_pr_deliveries
375
467
  SET operation = NULL, operation_token = NULL, operation_started_at_ms = NULL,
376
- updated_at = datetime('now')
468
+ updated_at = ${NEXT_PR_UPDATED_AT_SQL}
377
469
  WHERE id = ? AND operation_token = ?
378
470
  `).run(id, token).changes > 0;
379
471
  }
@@ -387,16 +479,24 @@ function clearOrphanedPrDeliveryOperations(db) {
387
479
  SET operation = NULL, operation_token = NULL, operation_started_at_ms = NULL,
388
480
  status_code = CASE
389
481
  WHEN decision NOT IN ('completed','merged','discarded','superseded')
482
+ AND NOT (
483
+ operation = 'recover-and-retry'
484
+ AND status_code IN ('settlement_interrupted','recovery_unavailable')
485
+ )
390
486
  THEN 'operation_interrupted'
391
487
  ELSE status_code
392
488
  END,
393
489
  status_detail = CASE
394
490
  WHEN decision NOT IN ('completed','merged','discarded','superseded')
491
+ AND NOT (
492
+ operation = 'recover-and-retry'
493
+ AND status_code IN ('settlement_interrupted','recovery_unavailable')
494
+ )
395
495
  AND (status_detail IS NULL OR status_detail = '')
396
496
  THEN 'A previous delivery action was interrupted by restart. Its durable evidence was preserved; review the current state and retry.'
397
497
  ELSE status_detail
398
498
  END,
399
- updated_at = datetime('now')
499
+ updated_at = ${NEXT_PR_UPDATED_AT_SQL}
400
500
  WHERE operation IS NOT NULL OR operation_token IS NOT NULL OR operation_started_at_ms IS NOT NULL
401
501
  `).run().changes;
402
502
  }
@@ -430,8 +530,11 @@ function toPrDeliverySnapshot(row) {
430
530
  deliverySha: row.delivery_sha ?? null,
431
531
  isContinuation: row.is_continuation === 1,
432
532
  supersedesDeliveryId: row.supersedes_delivery_id ?? null,
533
+ restoredFromDeliveryId: row.restored_from_delivery_id ?? null,
433
534
  operation: row.operation ?? null,
434
535
  cleanupWarnings: parseJsonArray(row.cleanup_warnings ?? '[]'),
536
+ safetyArchives: normalizeSafetyArchives(parseJsonArray(row.safety_archives ?? '[]')
537
+ .filter((value) => typeof value === 'string')),
435
538
  branches: units,
436
539
  units,
437
540
  loopName: row.loop_name,
@@ -470,11 +573,15 @@ function toRailPrStateMessage(projectId, snap) {
470
573
  deliverySha: snap.deliverySha,
471
574
  isContinuation: snap.isContinuation,
472
575
  supersedesDeliveryId: snap.supersedesDeliveryId,
576
+ restoredFromDeliveryId: snap.restoredFromDeliveryId,
473
577
  operation: snap.operation,
474
578
  cleanupWarnings: snap.cleanupWarnings,
579
+ safetyArchives: snap.safetyArchives,
475
580
  units: snap.units,
476
581
  runIds: snap.runIds,
477
582
  originConversationId: snap.originConversationId,
583
+ createdAt: snap.createdAt,
584
+ updatedAt: snap.updatedAt,
478
585
  };
479
586
  }
480
587
  /**
@@ -499,13 +606,17 @@ function toPrDecisionCardEnvelope(projectId, snap) {
499
606
  deliverySha: snap.deliverySha,
500
607
  isContinuation: snap.isContinuation,
501
608
  supersedesDeliveryId: snap.supersedesDeliveryId,
609
+ restoredFromDeliveryId: snap.restoredFromDeliveryId,
502
610
  operation: snap.operation,
503
611
  cleanupWarnings: snap.cleanupWarnings,
612
+ safetyArchives: snap.safetyArchives,
504
613
  units: snap.units,
505
614
  prUrl: snap.prUrl,
506
615
  prNumber: snap.prNumber,
507
616
  prState: snap.prState,
508
617
  branch: snap.branch,
509
618
  runIds: snap.runIds,
619
+ createdAt: snap.createdAt,
620
+ updatedAt: snap.updatedAt,
510
621
  };
511
622
  }