specrails-desktop 2.29.2 → 2.29.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. package/client/dist/assets/{ActivityFeedPage-ClpD5CFj.js → ActivityFeedPage-CFB4UBhT.js} +1 -1
  2. package/client/dist/assets/{AgentBrowserCapture-CSPs9Ofc.js → AgentBrowserCapture-C-T0r16B.js} +1 -1
  3. package/client/dist/assets/{AgentModeCodePane-DwcyklA5.js → AgentModeCodePane-IljV5FOQ.js} +2 -2
  4. package/client/dist/assets/{AgentModeJobsPane-C69bqlMa.js → AgentModeJobsPane-B5miH10_.js} +1 -1
  5. package/client/dist/assets/{AgentsPage-pRiRr9-W.js → AgentsPage-BgYQrAcH.js} +1 -1
  6. package/client/dist/assets/{AnalyticsPage-C14exKsC.js → AnalyticsPage-BtVCuNjL.js} +1 -1
  7. package/client/dist/assets/{CodePage-CK76JKm9.js → CodePage-Cb_jAjSV.js} +1 -1
  8. package/client/dist/assets/{DesktopAnalyticsPage-Cnv0f3XQ.js → DesktopAnalyticsPage-s_Cc_JL1.js} +1 -1
  9. package/client/dist/assets/{DocsDialog-CHUm_Ym0.js → DocsDialog-Cr47QI9L.js} +1 -1
  10. package/client/dist/assets/{DocsPage-COI-oDok.js → DocsPage-C_WPRuG_.js} +1 -1
  11. package/client/dist/assets/{InteractiveJobComposer-CypM7R1F.js → InteractiveJobComposer-NfFOciRB.js} +1 -1
  12. package/client/dist/assets/{JobDetailModal-B_W0H9dw.js → JobDetailModal-DqEW7Z7K.js} +1 -1
  13. package/client/dist/assets/{JobDetailPage-CeeXOoKS.js → JobDetailPage-D4ppEpDE.js} +1 -1
  14. package/client/dist/assets/{JobsPage-CwhJG9jw.js → JobsPage-DStSoMyq.js} +1 -1
  15. package/client/dist/assets/{LoopBuilderPage-C51t1WOM.js → LoopBuilderPage-DltCwS4P.js} +1 -1
  16. package/client/dist/assets/{LoopPreviewModal-Y2HgtIJu.js → LoopPreviewModal-BWaiAbUq.js} +1 -1
  17. package/client/dist/assets/{LoopsPage-Bvn1Kw5V.js → LoopsPage-BqjbXuBZ.js} +1 -1
  18. package/client/dist/assets/{MinimizedChatsContext-DiFvZDPW.js → MinimizedChatsContext--o3JAM9F.js} +1 -1
  19. package/client/dist/assets/{PluginsPage-BGPRJpdf.js → PluginsPage-B-UOmzXw.js} +1 -1
  20. package/client/dist/assets/{ProjectSettingsDialog-D_GTyGLc.js → ProjectSettingsDialog-DkGajIxE.js} +1 -1
  21. package/client/dist/assets/{TemplatePreviewModal-szwBhd7h.js → TemplatePreviewModal-5CUXlkw7.js} +1 -1
  22. package/client/dist/assets/{TicketDetailModal-9UEyK974.js → TicketDetailModal-B_igqmti.js} +4 -4
  23. package/client/dist/assets/{Trans-B_vKZ-Hb.js → Trans-DDMIBBUd.js} +1 -1
  24. package/client/dist/assets/agent-BXc59ARK2.js +1 -0
  25. package/client/dist/assets/agent-BbIFXdDq.js +1 -0
  26. package/client/dist/assets/agent-BqEfA5u5.js +1 -0
  27. package/client/dist/assets/agent-BtGzgTC4.js +1 -0
  28. package/client/dist/assets/agent-CIhys_5d.js +1 -0
  29. package/client/dist/assets/agent-CJPaqsVR.js +1 -0
  30. package/client/dist/assets/agent-n-hY4GuL.js +1 -0
  31. package/client/dist/assets/agent-tJ-efa3_2.js +1 -0
  32. package/client/dist/assets/common-6MLbD-jM.js +1 -0
  33. package/client/dist/assets/common-BKGEu4rm.js +1 -0
  34. package/client/dist/assets/common-BNbnv8cE2.js +1 -0
  35. package/client/dist/assets/common-BqGWtDbt.js +1 -0
  36. package/client/dist/assets/common-DBtTzeYm2.js +1 -0
  37. package/client/dist/assets/common-DEW3Nr8n.js +1 -0
  38. package/client/dist/assets/{common-DVQuhs-m.js → common-DHsv9N5d.js} +1 -1
  39. package/client/dist/assets/common-mkrEcW-Y.js +1 -0
  40. package/client/dist/assets/dashboard-BYzxom0L.js +1 -0
  41. package/client/dist/assets/dashboard-BmDZmHyY.js +1 -0
  42. package/client/dist/assets/dashboard-CnNtJBQ5.js +1 -0
  43. package/client/dist/assets/dashboard-D4Uj-XWc2.js +1 -0
  44. package/client/dist/assets/dashboard-D57uRaGw2.js +1 -0
  45. package/client/dist/assets/dashboard-DAr6F9et.js +1 -0
  46. package/client/dist/assets/dashboard-vAcsn0L3.js +1 -0
  47. package/client/dist/assets/dashboard-vrjZvxWy.js +1 -0
  48. package/client/dist/assets/{dist-js-CcBCbb32.js → dist-js-BCUhGUXS.js} +1 -1
  49. package/client/dist/assets/{dist-js-VwztRkGj.js → dist-js-DcoXTnEI.js} +1 -1
  50. package/client/dist/assets/{formatDistanceToNow-ByHIauTQ.js → formatDistanceToNow-DP3Ab6fX.js} +1 -1
  51. package/client/dist/assets/{getTimezoneOffsetInMilliseconds-BxHAksaU.js → getTimezoneOffsetInMilliseconds-DdKKL8Fx.js} +1 -1
  52. package/client/dist/assets/{i18n-CgaRxpII.js → i18n-DREQCE2F.js} +2 -2
  53. package/client/dist/assets/index-B784m8RT.js +76 -0
  54. package/client/dist/assets/index-CQBIemiz.css +2 -0
  55. package/client/dist/assets/{spending-C4p06nen.js → spending-CtY7yYes.js} +1 -1
  56. package/client/dist/assets/{useBrowserCaptureSession-2OqX0vap.js → useBrowserCaptureSession-BFBdW59q.js} +1 -1
  57. package/client/dist/assets/{useDesktop-X8YB9xzv.js → useDesktop-uWVZ8eia.js} +1 -1
  58. package/client/dist/index.html +14 -14
  59. package/docs/internals/safe-pr-review-flow.md +221 -38
  60. package/package.json +1 -1
  61. package/server/dist/active-pr-continuation.js +237 -52
  62. package/server/dist/agent-chat-manager.js +29 -12
  63. package/server/dist/agent-store.js +19 -4
  64. package/server/dist/db.js +21 -0
  65. package/server/dist/pr-lifecycle.js +100 -8
  66. package/server/dist/project-git.js +62 -2
  67. package/server/dist/rail-isolated-launch.js +644 -108
  68. package/server/dist/rail-pr-decision.js +1065 -181
  69. package/server/dist/rail-pr-recovery-git.js +235 -0
  70. package/server/dist/rail-pr-store.js +120 -9
  71. package/server/dist/rail-worktree-release.js +290 -4
  72. package/server/dist/rails-router.js +97 -9
  73. package/server/dist/worktree-manager.js +120 -16
  74. package/server/dist/worktree-overlay.js +166 -4
  75. package/client/dist/assets/agent-4rmXoo_p.js +0 -1
  76. package/client/dist/assets/agent-BxZuNGih.js +0 -1
  77. package/client/dist/assets/agent-C3uXOYv_.js +0 -1
  78. package/client/dist/assets/agent-CA8Y4wbw.js +0 -1
  79. package/client/dist/assets/agent-CASKrhG12.js +0 -1
  80. package/client/dist/assets/agent-Cv-jud5n2.js +0 -1
  81. package/client/dist/assets/agent-DitUxYh2.js +0 -1
  82. package/client/dist/assets/agent-Doow4X7o.js +0 -1
  83. package/client/dist/assets/common-CD1QHriA2.js +0 -1
  84. package/client/dist/assets/common-CwwPaRxx.js +0 -1
  85. package/client/dist/assets/common-Dg2jaX7d.js +0 -1
  86. package/client/dist/assets/common-DuBwj6tK.js +0 -1
  87. package/client/dist/assets/common-MRj9MHcc.js +0 -1
  88. package/client/dist/assets/common-O4ZpnMLp.js +0 -1
  89. package/client/dist/assets/common-fyeF0v7C2.js +0 -1
  90. package/client/dist/assets/dashboard-B_NkSHYm.js +0 -1
  91. package/client/dist/assets/dashboard-ClBb_3pq.js +0 -1
  92. package/client/dist/assets/dashboard-CpCmJvMB.js +0 -1
  93. package/client/dist/assets/dashboard-Czohu35O2.js +0 -1
  94. package/client/dist/assets/dashboard-DcjbD0UK.js +0 -1
  95. package/client/dist/assets/dashboard-Dvx5F3we.js +0 -1
  96. package/client/dist/assets/dashboard-R1bOPjBh2.js +0 -1
  97. package/client/dist/assets/dashboard-owcDucDi.js +0 -1
  98. package/client/dist/assets/index-BflYQpWm.css +0 -2
  99. package/client/dist/assets/index-C1_-BTXy.js +0 -72
@@ -1,13 +1,48 @@
1
1
  "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
2
35
  Object.defineProperty(exports, "__esModule", { value: true });
3
36
  exports.resolveActivePrContinuationTargets = resolveActivePrContinuationTargets;
4
37
  const jira_db_1 = require("./jira/jira-db");
5
38
  const rail_pr_store_1 = require("./rail-pr-store");
6
39
  const integration_branch_1 = require("./integration-branch");
40
+ const rail_worktrees_store_1 = require("./rail-worktrees-store");
7
41
  const pr_lifecycle_1 = require("./pr-lifecycle");
42
+ const fs = __importStar(require("fs"));
43
+ const COMMIT_SHA_RE = /^[0-9a-f]{40,64}$/i;
8
44
  const PR_MATCH_RANK = {
9
- 'title-similarity': 1,
10
- 'ticket-ref': 2,
45
+ 'jira-key': 1,
11
46
  'pr-number': 3,
12
47
  };
13
48
  function parsePrNumber(prUrl) {
@@ -16,28 +51,18 @@ function parsePrNumber(prUrl) {
16
51
  const m = /\/pull\/(\d+)/.exec(prUrl);
17
52
  return m ? parseInt(m[1], 10) : null;
18
53
  }
19
- function normalizedText(v) {
20
- return typeof v === 'string' ? v.toLowerCase() : '';
21
- }
22
- function words(v) {
23
- return normalizedText(v)
24
- .replace(/[^a-z0-9]+/g, ' ')
25
- .split(/\s+/)
26
- .filter((w) => w.length >= 4);
27
- }
28
- function ticketRefs(db, ticketId, spec) {
29
- const refs = new Set([`#${ticketId}`, `[${ticketId}]`, `ticket ${ticketId}`, `spec ${ticketId}`]);
30
- let jiraKey = spec?.jira_key ?? null;
54
+ function jiraRefs(db, ticketId, spec) {
55
+ const refs = new Set();
56
+ if (spec?.jira_key?.trim())
57
+ refs.add(spec.jira_key.trim());
31
58
  try {
32
59
  const link = (0, jira_db_1.getLinkByLocalId)(db, ticketId);
33
60
  if (link && !link.tombstoned && link.jiraKey)
34
- jiraKey = link.jiraKey;
61
+ refs.add(link.jiraKey);
35
62
  }
36
63
  catch {
37
64
  /* tolerated */
38
65
  }
39
- if (jiraKey?.trim())
40
- refs.add(jiraKey.trim());
41
66
  return [...refs];
42
67
  }
43
68
  function mentionedPrNumbers(spec) {
@@ -67,23 +92,13 @@ function prMatchKind(db, ticketId, spec, pr) {
67
92
  const prNumber = typeof pr.number === 'number' ? pr.number : parsePrNumber(pr.url);
68
93
  if (prNumber !== null && mentionedPrs.has(prNumber))
69
94
  return 'pr-number';
70
- const haystack = `${pr.title ?? ''}\n${pr.body ?? ''}\n${pr.headRefName ?? ''}`.toLowerCase();
71
- for (const ref of ticketRefs(db, ticketId, spec)) {
72
- const needle = ref.toLowerCase();
73
- if (needle.startsWith('#') || needle.startsWith('[')) {
74
- if (haystack.includes(needle))
75
- return 'ticket-ref';
76
- }
77
- else if (new RegExp(`(^|[^a-z0-9])${needle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}([^a-z0-9]|$)`, 'i').test(haystack)) {
78
- return 'ticket-ref';
79
- }
95
+ const haystack = `${pr.title ?? ''}\n${pr.body ?? ''}\n${pr.headRefName ?? ''}`;
96
+ for (const key of jiraRefs(db, ticketId, spec)) {
97
+ const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
98
+ if (new RegExp(`(^|[^a-z0-9])${escaped}([^a-z0-9]|$)`, 'i').test(haystack))
99
+ return 'jira-key';
80
100
  }
81
- const titleWords = words(spec?.title);
82
- if (titleWords.length < 3)
83
- return null;
84
- const prWords = new Set(words(`${pr.title ?? ''} ${pr.body ?? ''}`));
85
- const hits = titleWords.filter((w) => prWords.has(w)).length;
86
- return hits >= Math.max(3, Math.ceil(titleWords.length * 0.7)) ? 'title-similarity' : null;
101
+ return null;
87
102
  }
88
103
  function canProbeGithubForContinuation(db, ticketId, spec) {
89
104
  if (spec?.status === 'on_review')
@@ -93,11 +108,6 @@ function canProbeGithubForContinuation(db, ticketId, spec) {
93
108
  // Jira-linked ticket and later only accept explicit PR references.
94
109
  return spec?.status === 'in_progress' && hasJiraRef(db, ticketId, spec);
95
110
  }
96
- function matchAllowedForTicket(spec, kind) {
97
- if (spec?.status === 'on_review')
98
- return true;
99
- return kind !== 'title-similarity';
100
- }
101
111
  async function localBranchExists(git, repoDir, branch) {
102
112
  const r = await git.run(['rev-parse', '--verify', '--quiet', `refs/heads/${branch}`], repoDir);
103
113
  return r.code === 0;
@@ -106,7 +116,21 @@ async function remoteBranchExists(git, repoDir, branch) {
106
116
  const r = await git.run(['rev-parse', '--verify', '--quiet', `refs/remotes/origin/${branch}`], repoDir);
107
117
  return r.code === 0;
108
118
  }
109
- async function materializeTarget(git, repoDir, fetchOk, ticketId, branch, baseBranch, prUrl, prNumber, isDraft, source) {
119
+ async function observeRef(git, repoDir, ref) {
120
+ try {
121
+ const result = await git.run(['rev-parse', '--verify', '--quiet', ref], repoDir);
122
+ if (result.code === 1)
123
+ return { state: 'missing' };
124
+ if (result.code !== 0)
125
+ return { state: 'invalid' };
126
+ const sha = result.stdout.trim().toLowerCase();
127
+ return COMMIT_SHA_RE.test(sha) ? { state: 'exact', sha } : { state: 'invalid' };
128
+ }
129
+ catch {
130
+ return { state: 'invalid' };
131
+ }
132
+ }
133
+ async function materializeTarget(git, repoDir, fetchOk, ticketId, branch, baseBranch, prUrl, prNumber, isDraft, deliverySha, deliveryId, source) {
110
134
  const head = branch?.trim();
111
135
  const base = baseBranch?.trim();
112
136
  if (!head || !base || head === base)
@@ -115,35 +139,135 @@ async function materializeTarget(git, repoDir, fetchOk, ticketId, branch, baseBr
115
139
  return null;
116
140
  const remoteRef = fetchOk && await remoteBranchExists(git, repoDir, head) ? `origin/${head}` : undefined;
117
141
  if (await localBranchExists(git, repoDir, head)) {
118
- return { ticketId, branch: head, baseBranch: base, baseRef: remoteRef, prUrl, prNumber, isDraft, source };
142
+ return { ticketId, deliveryId, branch: head, baseBranch: base, baseRef: remoteRef, prUrl, prNumber, isDraft, deliverySha, source };
119
143
  }
120
144
  if (remoteRef)
121
- return { ticketId, branch: head, baseBranch: base, baseRef: remoteRef, prUrl, prNumber, isDraft, source };
145
+ return { ticketId, deliveryId, branch: head, baseBranch: base, baseRef: remoteRef, prUrl, prNumber, isDraft, deliverySha, source };
122
146
  return null;
123
147
  }
124
- function internalDeliveryTargets(db, ticketIds) {
148
+ function hasRetainedOrUnsettledWorktree(db, worktreeIds) {
149
+ return worktreeIds.some((id) => {
150
+ const worktree = (0, rail_worktrees_store_1.getRailWorktree)(db, id);
151
+ if (!worktree)
152
+ return false;
153
+ return !['released', 'merged', 'failed'].includes(worktree.merge_state) || fs.existsSync(worktree.worktree_path);
154
+ });
155
+ }
156
+ function internalDeliveryTargets(db, rows, ticketIds) {
125
157
  const wanted = new Set(ticketIds);
126
158
  const out = new Map();
127
- for (const row of (0, rail_pr_store_1.listActivePrDeliveries)(db)) {
159
+ for (const row of rows) {
128
160
  const snap = (0, rail_pr_store_1.toPrDeliverySnapshot)(row);
129
161
  if (!snap.prUrl || !snap.branch)
130
162
  continue;
163
+ // A still-mounted or unsettled prior worktree can contain bytes not
164
+ // represented by delivery_sha. Never let the next agent iteration absorb
165
+ // them merely because HEAD/ref still happen to match the remote PR.
166
+ if (hasRetainedOrUnsettledWorktree(db, snap.worktreeIds))
167
+ continue;
131
168
  for (const ticketId of snap.ticketIds) {
132
169
  if (!wanted.has(ticketId) || out.has(ticketId))
133
170
  continue;
134
171
  out.set(ticketId, {
135
172
  ticketId,
173
+ deliveryId: row.id,
136
174
  branch: snap.branch,
137
175
  baseBranch: snap.baseBranch,
138
176
  prUrl: snap.prUrl,
139
177
  prNumber: snap.prNumber,
140
178
  isDraft: snap.decision === 'pr_draft',
179
+ deliverySha: snap.deliverySha,
141
180
  source: 'rail-pr-delivery',
142
181
  });
143
182
  }
144
183
  }
145
184
  return out;
146
185
  }
186
+ function ticketTargetKey(ticketIds) {
187
+ if (ticketIds.length === 0)
188
+ return null;
189
+ const seen = new Set();
190
+ for (const value of ticketIds) {
191
+ if (typeof value === 'number' && Number.isSafeInteger(value) && value > 0)
192
+ seen.add(value);
193
+ }
194
+ return seen.size > 0 ? [...seen].sort((a, b) => a - b).join(',') : null;
195
+ }
196
+ function exactTicketTargetKey(ticketIds) {
197
+ const key = ticketTargetKey(ticketIds);
198
+ if (!key || key.split(',').length !== ticketIds.length)
199
+ return null;
200
+ return key;
201
+ }
202
+ /** Recover only the newest terminal generation for the exact launch target.
203
+ * A newer fresh/other generation intentionally prevents an older continuation
204
+ * from lending its PR. Of terminal continuations, only an explicitly discarded
205
+ * one can be resumed: merged/completed/superseded history remains history. */
206
+ function latestHistoricalContinuation(db, ticketIds) {
207
+ const targetKey = exactTicketTargetKey(ticketIds);
208
+ if (!targetKey)
209
+ return { ownsTarget: false, candidate: null };
210
+ const latest = (0, rail_pr_store_1.getLatestTerminalPrDeliveryTouchingTicketSet)(db, ticketIds);
211
+ if (!latest)
212
+ return { ownsTarget: false, candidate: null };
213
+ // The newest exact-target generation is authoritative even when it is a
214
+ // fresh lineage. It must shadow every older continuation and also prevent a
215
+ // fuzzy GitHub/Jira fallback from rediscovering that stale PR.
216
+ if (latest.is_continuation !== 1)
217
+ return { ownsTarget: true, candidate: null };
218
+ if (latest.decision !== 'discarded')
219
+ return { ownsTarget: true, candidate: null };
220
+ const snap = (0, rail_pr_store_1.toPrDeliverySnapshot)(latest);
221
+ if (exactTicketTargetKey(snap.ticketIds) !== targetKey || latest.operation_token !== null) {
222
+ return { ownsTarget: true, candidate: null };
223
+ }
224
+ if (hasRetainedOrUnsettledWorktree(db, snap.worktreeIds)) {
225
+ return { ownsTarget: true, candidate: null };
226
+ }
227
+ const branch = snap.branch;
228
+ const baseBranch = snap.baseBranch;
229
+ const prUrl = snap.prUrl;
230
+ const deliverySha = snap.deliverySha?.toLowerCase() ?? null;
231
+ if (!branch || branch.trim() !== branch || !(0, integration_branch_1.isValidBranchName)(branch) ||
232
+ !baseBranch || baseBranch.trim() !== baseBranch || !(0, integration_branch_1.isValidBranchName)(baseBranch) ||
233
+ branch === baseBranch ||
234
+ !prUrl || prUrl.trim() !== prUrl ||
235
+ snap.prNumber === null || parsePrNumber(prUrl) !== snap.prNumber ||
236
+ !deliverySha || !COMMIT_SHA_RE.test(deliverySha))
237
+ return { ownsTarget: true, candidate: null };
238
+ return {
239
+ ownsTarget: true,
240
+ candidate: { deliveryId: latest.id, branch, baseBranch, prUrl, prNumber: snap.prNumber, deliverySha },
241
+ };
242
+ }
243
+ /** Historical recovery is stricter than ordinary active continuation
244
+ * materialization. Every available fetched/local ref must still name the exact
245
+ * immutable GitHub head; a stale, ahead, diverged, or unreadable ref fails
246
+ * closed rather than allowing worktree allocation to infer or rewrite it. */
247
+ async function materializeHistoricalContinuation(input, candidate, isDraft) {
248
+ const local = await observeRef(input.git, input.repoDir, `refs/heads/${candidate.branch}`);
249
+ if (local.state === 'invalid' || (local.state === 'exact' && local.sha !== candidate.deliverySha))
250
+ return null;
251
+ const remote = input.fetchOk
252
+ ? await observeRef(input.git, input.repoDir, `refs/remotes/origin/${candidate.branch}`)
253
+ : { state: 'missing' };
254
+ if (remote.state === 'invalid' || (remote.state === 'exact' && remote.sha !== candidate.deliverySha))
255
+ return null;
256
+ if (local.state !== 'exact' && remote.state !== 'exact')
257
+ return null;
258
+ return new Map(input.ticketIds.map((ticketId) => [ticketId, {
259
+ ticketId,
260
+ deliveryId: candidate.deliveryId,
261
+ branch: candidate.branch,
262
+ baseBranch: candidate.baseBranch,
263
+ ...(remote.state === 'exact' ? { baseRef: `origin/${candidate.branch}` } : {}),
264
+ prUrl: candidate.prUrl,
265
+ prNumber: candidate.prNumber,
266
+ isDraft,
267
+ deliverySha: candidate.deliverySha,
268
+ source: 'rail-pr-delivery',
269
+ }]));
270
+ }
147
271
  const PR_JSON_FIELDS = 'number,title,body,headRefName,baseRefName,url,isDraft,state';
148
272
  async function listOpenPrs(exec, repoDir) {
149
273
  const r = await exec.run('gh', ['pr', 'list', '--state', 'open', '--limit', '200', '--json', PR_JSON_FIELDS], repoDir);
@@ -198,32 +322,77 @@ function mergeOpenPrs(prs) {
198
322
  /**
199
323
  * Detects tickets whose next implementation pass should continue an already
200
324
  * open PR branch instead of creating fresh work from the integration branch.
201
- * It is intentionally fail-open: no gh, no auth, ambiguous branch state, or no
202
- * confident PR match all return no target, preserving the normal new-work flow.
325
+ * External discovery is fail-open when no durable Specrails lineage exists.
326
+ * Once an active or newest overlapping historical generation owns the target,
327
+ * however, missing/ambiguous GitHub or ref evidence is authoritative negative
328
+ * evidence and blocks fuzzy fallback to a different PR.
203
329
  */
204
330
  async function resolveActivePrContinuationTargets(input) {
205
331
  const out = new Map();
206
- const internal = internalDeliveryTargets(input.db, input.ticketIds);
332
+ const activeRows = (0, rail_pr_store_1.listActivePrDeliveries)(input.db);
333
+ const internal = internalDeliveryTargets(input.db, activeRows, input.ticketIds);
207
334
  const lifecycleByUrl = new Map();
208
335
  for (const [ticketId, target] of internal) {
209
336
  if (!target.prUrl)
210
337
  continue;
211
338
  let lifecycle = lifecycleByUrl.get(target.prUrl);
212
339
  if (!lifecycle) {
213
- lifecycle = (0, pr_lifecycle_1.observePrLifecycle)(input.exec, input.repoDir, target.prUrl);
340
+ lifecycle = (0, pr_lifecycle_1.observePrLifecycle)(input.exec, input.repoDir, target.prUrl, target.deliverySha);
214
341
  lifecycleByUrl.set(target.prUrl, lifecycle);
215
342
  }
216
343
  const observed = await lifecycle;
217
344
  // A ledger row is only a hint. GitHub remains lifecycle authority: a stale
218
345
  // draft/ready row for a CLOSED/MERGED or different head/base PR must not be
219
346
  // superseded and must not lend its branch to another implementation run.
220
- if (!observed.ok || !(0, pr_lifecycle_1.isExactOpenPr)(observed, target.branch, target.baseBranch))
347
+ if (!target.deliverySha || !observed.ok ||
348
+ !(0, pr_lifecycle_1.isExactOpenPr)(observed, target.branch, target.baseBranch) ||
349
+ observed.includesExpectedSha !== true ||
350
+ observed.headRefOid?.toLowerCase() !== target.deliverySha.toLowerCase())
221
351
  continue;
222
- const materialized = await materializeTarget(input.git, input.repoDir, input.fetchOk, ticketId, target.branch, target.baseBranch, target.prUrl, target.prNumber, observed.isDraft, 'rail-pr-delivery');
352
+ const materialized = await materializeTarget(input.git, input.repoDir, input.fetchOk, ticketId, target.branch, target.baseBranch, target.prUrl, target.prNumber, observed.isDraft, target.deliverySha, target.deliveryId, 'rail-pr-delivery');
223
353
  if (materialized)
224
354
  out.set(ticketId, materialized);
225
355
  }
226
- const remaining = input.ticketIds.filter((id) => !out.has(id));
356
+ // Dismiss/Discard closes the continuation generation but deliberately keeps
357
+ // its borrowed PR alive. Recover that exact target only when no active row
358
+ // owns any requested ticket and the latest terminal generation for the whole
359
+ // target is the discarded continuation. Failed historical verification is
360
+ // still authoritative negative evidence: do not fall through to fuzzy PR
361
+ // discovery and accidentally borrow a same-Jira foreign PR.
362
+ const wanted = new Set(input.ticketIds);
363
+ const activeTouchesTarget = activeRows.some((row) => ((0, rail_pr_store_1.toPrDeliverySnapshot)(row).ticketIds.some((ticketId) => wanted.has(ticketId))));
364
+ const historicalOwned = new Set();
365
+ const activeOwned = new Set();
366
+ if (activeTouchesTarget)
367
+ input.ticketIds.forEach((ticketId) => activeOwned.add(ticketId));
368
+ if (!activeTouchesTarget && out.size === 0) {
369
+ const historical = latestHistoricalContinuation(input.db, input.ticketIds);
370
+ if (historical.ownsTarget)
371
+ input.ticketIds.forEach((ticketId) => historicalOwned.add(ticketId));
372
+ if (historical.candidate) {
373
+ const candidate = historical.candidate;
374
+ let lifecycle = lifecycleByUrl.get(candidate.prUrl);
375
+ if (!lifecycle) {
376
+ lifecycle = (0, pr_lifecycle_1.observePrLifecycle)(input.exec, input.repoDir, candidate.prUrl, candidate.deliverySha);
377
+ lifecycleByUrl.set(candidate.prUrl, lifecycle);
378
+ }
379
+ const observed = await lifecycle;
380
+ if (observed.ok &&
381
+ (0, pr_lifecycle_1.isExactOpenPr)(observed, candidate.branch, candidate.baseBranch) &&
382
+ observed.headRefOid?.toLowerCase() === candidate.deliverySha &&
383
+ observed.includesExpectedSha === true) {
384
+ const materialized = await materializeHistoricalContinuation(input, candidate, observed.isDraft);
385
+ if (materialized) {
386
+ for (const [ticketId, target] of materialized)
387
+ out.set(ticketId, target);
388
+ }
389
+ }
390
+ }
391
+ }
392
+ // A durable Specrails delivery that fails exact verification must never be
393
+ // downgraded into an inferred "external" match for the same ticket/PR. The
394
+ // visible delivery card owns reconciliation of that stale immutable SHA.
395
+ const remaining = input.ticketIds.filter((id) => (!out.has(id) && !internal.has(id) && !historicalOwned.has(id) && !activeOwned.has(id)));
227
396
  const candidateIds = remaining.filter((id) => canProbeGithubForContinuation(input.db, id, input.getTicketSpec(id)));
228
397
  if (candidateIds.length === 0)
229
398
  return out;
@@ -240,7 +409,7 @@ async function resolveActivePrContinuationTargets(input) {
240
409
  const spec = input.getTicketSpec(ticketId);
241
410
  const matches = openPrs.flatMap((pr) => {
242
411
  const kind = prMatchKind(input.db, ticketId, spec, pr);
243
- return kind && matchAllowedForTicket(spec, kind) ? [{ pr, kind }] : [];
412
+ return kind ? [{ pr, kind }] : [];
244
413
  });
245
414
  if (matches.length === 0)
246
415
  continue;
@@ -249,7 +418,23 @@ async function resolveActivePrContinuationTargets(input) {
249
418
  if (best.length !== 1)
250
419
  continue;
251
420
  const pr = best[0].pr;
252
- const materialized = await materializeTarget(input.git, input.repoDir, input.fetchOk, ticketId, pr.headRefName, pr.baseRefName || input.integrationBranch, pr.url ?? null, typeof pr.number === 'number' ? pr.number : parsePrNumber(pr.url), typeof pr.isDraft === 'boolean' ? pr.isDraft : null, 'github-open-pr');
421
+ const prUrl = pr.url?.trim();
422
+ const branch = pr.headRefName?.trim();
423
+ const baseBranch = pr.baseRefName?.trim() || input.integrationBranch;
424
+ if (!prUrl || !branch || !baseBranch)
425
+ continue;
426
+ let lifecycle = lifecycleByUrl.get(prUrl);
427
+ if (!lifecycle) {
428
+ lifecycle = (0, pr_lifecycle_1.observePrLifecycle)(input.exec, input.repoDir, prUrl);
429
+ lifecycleByUrl.set(prUrl, lifecycle);
430
+ }
431
+ const observed = await lifecycle;
432
+ // `gh pr list` is discovery evidence only. Freeze the exact live remote
433
+ // head through an authoritative view before borrowing any local branch;
434
+ // otherwise an unrelated local-ahead commit could be carried into the PR.
435
+ if (!observed.ok || !observed.headRefOid || !(0, pr_lifecycle_1.isExactOpenPr)(observed, branch, baseBranch))
436
+ continue;
437
+ const materialized = await materializeTarget(input.git, input.repoDir, input.fetchOk, ticketId, branch, baseBranch, prUrl, typeof pr.number === 'number' ? pr.number : parsePrNumber(prUrl), observed.isDraft, observed.headRefOid, null, 'github-open-pr');
253
438
  if (materialized)
254
439
  out.set(ticketId, materialized);
255
440
  }
@@ -696,6 +696,11 @@ class AgentChatManager {
696
696
  console.log(`[agent-chat] pr-decision card skipped — conversation gone (${conversationId})`);
697
697
  return;
698
698
  }
699
+ const existing = this._findPrDecisionCards(conversationId, envelope.prDeliveryId);
700
+ if (existing.length > 0) {
701
+ this.updatePrDecisionCard(conversationId, envelope);
702
+ return;
703
+ }
699
704
  (0, agent_store_1.addAgentMessage)(this._db, { conversationId, role: 'system', content: JSON.stringify(envelope) });
700
705
  this._broadcastPrDecision(conversationId, envelope);
701
706
  }
@@ -714,33 +719,45 @@ class AgentChatManager {
714
719
  if (this._disposed)
715
720
  return;
716
721
  try {
717
- const existing = (0, agent_store_1.findAgentSystemMessage)(this._db, conversationId, (content) => {
718
- try {
719
- const parsed = JSON.parse(content);
720
- return parsed.kind === 'pr_decision' && parsed.prDeliveryId === envelope.prDeliveryId;
721
- }
722
- catch {
723
- return false;
724
- }
725
- });
726
- if (!existing) {
722
+ const existing = this._findPrDecisionCards(conversationId, envelope.prDeliveryId);
723
+ if (existing.length === 0) {
727
724
  this.postPrDecisionCard(conversationId, envelope);
728
725
  return;
729
726
  }
730
727
  const serialized = JSON.stringify(envelope);
728
+ // Keep the newest history anchor: client cold-load dedupe uses the same
729
+ // newest-row rule, so a crash between hydration and this repair cannot
730
+ // make an older blocked envelope authoritative again.
731
+ const canonical = existing[existing.length - 1];
732
+ const duplicateIds = existing.slice(0, -1).map((message) => message.id);
731
733
  // Startup recovery may inspect historical deliveries to heal a card that
732
734
  // missed its terminal update. Identical projection is not new activity:
733
735
  // avoid a redundant DB write and, crucially, do not broadcast an unread
734
736
  // event for an old card on every app launch.
735
- if (existing.content === serialized)
737
+ if (canonical.content === serialized && duplicateIds.length === 0)
736
738
  return;
737
- (0, agent_store_1.updateAgentMessageContent)(this._db, existing.id, serialized);
739
+ this._db.transaction(() => {
740
+ if (canonical.content !== serialized)
741
+ (0, agent_store_1.updateAgentMessageContent)(this._db, canonical.id, serialized);
742
+ (0, agent_store_1.deleteAgentMessagesByIds)(this._db, duplicateIds);
743
+ })();
738
744
  this._broadcastPrDecision(conversationId, envelope);
739
745
  }
740
746
  catch (err) {
741
747
  console.error(`[agent-chat] updatePrDecisionCard failed (${conversationId}):`, err);
742
748
  }
743
749
  }
750
+ _findPrDecisionCards(conversationId, prDeliveryId) {
751
+ return (0, agent_store_1.findAgentSystemMessages)(this._db, conversationId, (content) => {
752
+ try {
753
+ const parsed = JSON.parse(content);
754
+ return parsed.kind === 'pr_decision' && parsed.prDeliveryId === prDeliveryId;
755
+ }
756
+ catch {
757
+ return false;
758
+ }
759
+ });
760
+ }
744
761
  _broadcastPrDecision(conversationId, envelope) {
745
762
  this._broadcast({
746
763
  type: 'agent_pr_decision',
@@ -7,7 +7,9 @@ exports.updateAgentConversation = updateAgentConversation;
7
7
  exports.deleteAgentConversation = deleteAgentConversation;
8
8
  exports.addAgentMessage = addAgentMessage;
9
9
  exports.listAgentMessages = listAgentMessages;
10
+ exports.findAgentSystemMessages = findAgentSystemMessages;
10
11
  exports.findAgentSystemMessage = findAgentSystemMessage;
12
+ exports.deleteAgentMessagesByIds = deleteAgentMessagesByIds;
11
13
  exports.updateAgentMessageContent = updateAgentMessageContent;
12
14
  const crypto_1 = require("crypto");
13
15
  const agent_tier_1 = require("./agent-tier");
@@ -94,16 +96,29 @@ function listAgentMessages(db, conversationId) {
94
96
  .all(conversationId).map(mapMessage);
95
97
  }
96
98
  /**
97
- * First `system`-role message in a conversation whose content satisfies
99
+ * Every `system`-role message in a conversation whose content satisfies
98
100
  * `predicate` (oldest first). The store stays payload-agnostic — callers own
99
101
  * the content parsing (e.g. matching a PR-decision card by prDeliveryId).
100
102
  */
101
- function findAgentSystemMessage(db, conversationId, predicate = () => true) {
103
+ function findAgentSystemMessages(db, conversationId, predicate = () => true) {
102
104
  const rows = db
103
105
  .prepare("SELECT * FROM agent_messages WHERE conversation_id = ? AND role = 'system' ORDER BY created_at ASC, rowid ASC")
104
106
  .all(conversationId);
105
- const hit = rows.find((r) => predicate(r.content));
106
- return hit ? mapMessage(hit) : undefined;
107
+ return rows.filter((row) => predicate(row.content)).map(mapMessage);
108
+ }
109
+ function findAgentSystemMessage(db, conversationId, predicate = () => true) {
110
+ return findAgentSystemMessages(db, conversationId, predicate)[0];
111
+ }
112
+ /** Delete exact message ids without bumping conversation activity. Callers use
113
+ * this only to consolidate duplicate derived system cards, never user turns. */
114
+ function deleteAgentMessagesByIds(db, messageIds) {
115
+ if (messageIds.length === 0)
116
+ return 0;
117
+ const remove = db.prepare('DELETE FROM agent_messages WHERE id = ?');
118
+ let deleted = 0;
119
+ for (const id of messageIds)
120
+ deleted += remove.run(id).changes;
121
+ return deleted;
107
122
  }
108
123
  /**
109
124
  * Replace a message's content in place (inline-card state updates). Does NOT
package/server/dist/db.js CHANGED
@@ -1405,6 +1405,27 @@ const MIGRATIONS = [
1405
1405
  }
1406
1406
  backfillTerminalRailPrTicketEffects(db);
1407
1407
  },
1408
+ // Migration 53: an allocation rollback is the one legal way a previously
1409
+ // superseded delivery can become active again. Persist the failed replacement
1410
+ // id on the restored predecessor so clients can distinguish that explicit
1411
+ // rollback from an ordinary stale non-terminal replay.
1412
+ (db) => {
1413
+ const cols = new Set(db.prepare(`PRAGMA table_info(rail_pr_deliveries)`).all()
1414
+ .map((row) => row.name));
1415
+ if (!cols.has('restored_from_delivery_id')) {
1416
+ db.exec(`ALTER TABLE rail_pr_deliveries ADD COLUMN restored_from_delivery_id TEXT`);
1417
+ }
1418
+ },
1419
+ // Migration 54: automatic worktree release quarantines authenticated overlay
1420
+ // roots instead of deleting them. Persist the bounded archive locations so
1421
+ // recovery remains discoverable after restart and card refresh.
1422
+ (db) => {
1423
+ const cols = new Set(db.prepare(`PRAGMA table_info(rail_pr_deliveries)`).all()
1424
+ .map((row) => row.name));
1425
+ if (!cols.has('safety_archives')) {
1426
+ db.exec(`ALTER TABLE rail_pr_deliveries ADD COLUMN safety_archives TEXT NOT NULL DEFAULT '[]'`);
1427
+ }
1428
+ },
1408
1429
  ];
1409
1430
  function applyMigrations(db) {
1410
1431
  // Ensure the migrations table exists (migration 1 creates it, but we need