patchrelay 0.41.8 → 0.43.0

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "service": "patchrelay",
3
- "version": "0.41.8",
4
- "commit": "8142a14a520c",
5
- "builtAt": "2026-04-14T13:31:00.535Z"
3
+ "version": "0.43.0",
4
+ "commit": "cfb77c67aa5e",
5
+ "builtAt": "2026-04-15T08:38:14.720Z"
6
6
  }
@@ -192,6 +192,10 @@ export class IssueStore {
192
192
  sets.push("last_attempted_failure_signature = @lastAttemptedFailureSignature");
193
193
  values.lastAttemptedFailureSignature = params.lastAttemptedFailureSignature;
194
194
  }
195
+ if (params.lastAttemptedFailureAt !== undefined) {
196
+ sets.push("last_attempted_failure_at = @lastAttemptedFailureAt");
197
+ values.lastAttemptedFailureAt = params.lastAttemptedFailureAt;
198
+ }
195
199
  if (params.ciRepairAttempts !== undefined) {
196
200
  sets.push("ci_repair_attempts = @ciRepairAttempts");
197
201
  values.ciRepairAttempts = params.ciRepairAttempts;
@@ -226,7 +230,7 @@ export class IssueStore {
226
230
  last_github_failure_source, last_github_failure_head_sha, last_github_failure_signature, last_github_failure_check_name, last_github_failure_check_url, last_github_failure_context_json, last_github_failure_at,
227
231
  last_github_ci_snapshot_head_sha, last_github_ci_snapshot_gate_check_name, last_github_ci_snapshot_gate_check_status, last_github_ci_snapshot_json, last_github_ci_snapshot_settled_at,
228
232
  last_queue_signal_at, last_queue_incident_json,
229
- last_attempted_failure_head_sha, last_attempted_failure_signature,
233
+ last_attempted_failure_head_sha, last_attempted_failure_signature, last_attempted_failure_at,
230
234
  ci_repair_attempts, queue_repair_attempts, review_fix_attempts, zombie_recovery_attempts, last_zombie_recovery_at,
231
235
  updated_at
232
236
  ) VALUES (
@@ -239,7 +243,7 @@ export class IssueStore {
239
243
  @lastGitHubFailureSource, @lastGitHubFailureHeadSha, @lastGitHubFailureSignature, @lastGitHubFailureCheckName, @lastGitHubFailureCheckUrl, @lastGitHubFailureContextJson, @lastGitHubFailureAt,
240
244
  @lastGitHubCiSnapshotHeadSha, @lastGitHubCiSnapshotGateCheckName, @lastGitHubCiSnapshotGateCheckStatus, @lastGitHubCiSnapshotJson, @lastGitHubCiSnapshotSettledAt,
241
245
  @lastQueueSignalAt, @lastQueueIncidentJson,
242
- @lastAttemptedFailureHeadSha, @lastAttemptedFailureSignature,
246
+ @lastAttemptedFailureHeadSha, @lastAttemptedFailureSignature, @lastAttemptedFailureAt,
243
247
  @ciRepairAttempts, @queueRepairAttempts, @reviewFixAttempts, @zombieRecoveryAttempts, @lastZombieRecoveryAt,
244
248
  @now
245
249
  )
@@ -290,6 +294,7 @@ export class IssueStore {
290
294
  lastQueueIncidentJson: params.lastQueueIncidentJson ?? null,
291
295
  lastAttemptedFailureHeadSha: params.lastAttemptedFailureHeadSha ?? null,
292
296
  lastAttemptedFailureSignature: params.lastAttemptedFailureSignature ?? null,
297
+ lastAttemptedFailureAt: params.lastAttemptedFailureAt ?? null,
293
298
  ciRepairAttempts: params.ciRepairAttempts ?? 0,
294
299
  queueRepairAttempts: params.queueRepairAttempts ?? 0,
295
300
  reviewFixAttempts: params.reviewFixAttempts ?? 0,
@@ -556,6 +561,9 @@ export function mapIssueRow(row) {
556
561
  ...(row.last_attempted_failure_signature !== null && row.last_attempted_failure_signature !== undefined
557
562
  ? { lastAttemptedFailureSignature: String(row.last_attempted_failure_signature) }
558
563
  : {}),
564
+ ...(row.last_attempted_failure_at !== null && row.last_attempted_failure_at !== undefined
565
+ ? { lastAttemptedFailureAt: String(row.last_attempted_failure_at) }
566
+ : {}),
559
567
  ciRepairAttempts: Number(row.ci_repair_attempts ?? 0),
560
568
  queueRepairAttempts: Number(row.queue_repair_attempts ?? 0),
561
569
  reviewFixAttempts: Number(row.review_fix_attempts ?? 0),
@@ -293,6 +293,7 @@ export function runPatchRelayMigrations(connection) {
293
293
  addColumnIfMissing(connection, "issues", "last_queue_incident_json", "TEXT");
294
294
  addColumnIfMissing(connection, "issues", "last_attempted_failure_head_sha", "TEXT");
295
295
  addColumnIfMissing(connection, "issues", "last_attempted_failure_signature", "TEXT");
296
+ addColumnIfMissing(connection, "issues", "last_attempted_failure_at", "TEXT");
296
297
  removeRetiredIssueColumnsIfPresent(connection);
297
298
  }
298
299
  function addColumnIfMissing(connection, table, column, definition) {
@@ -359,6 +360,7 @@ function removeRetiredIssueColumnsIfPresent(connection) {
359
360
  last_queue_incident_json TEXT,
360
361
  last_attempted_failure_head_sha TEXT,
361
362
  last_attempted_failure_signature TEXT,
363
+ last_attempted_failure_at TEXT,
362
364
  ci_repair_attempts INTEGER NOT NULL DEFAULT 0,
363
365
  queue_repair_attempts INTEGER NOT NULL DEFAULT 0,
364
366
  review_fix_attempts INTEGER NOT NULL DEFAULT 0,
@@ -416,6 +418,7 @@ function removeRetiredIssueColumnsIfPresent(connection) {
416
418
  last_queue_incident_json,
417
419
  last_attempted_failure_head_sha,
418
420
  last_attempted_failure_signature,
421
+ last_attempted_failure_at,
419
422
  ci_repair_attempts,
420
423
  queue_repair_attempts,
421
424
  review_fix_attempts,
@@ -471,6 +474,7 @@ function removeRetiredIssueColumnsIfPresent(connection) {
471
474
  last_queue_incident_json,
472
475
  last_attempted_failure_head_sha,
473
476
  last_attempted_failure_signature,
477
+ last_attempted_failure_at,
474
478
  COALESCE(ci_repair_attempts, 0),
475
479
  COALESCE(queue_repair_attempts, 0),
476
480
  COALESCE(review_fix_attempts, 0),
@@ -80,6 +80,7 @@ export async function projectGitHubWebhookState(deps, issue, event, project, lin
80
80
  lastQueueIncidentJson: null,
81
81
  lastAttemptedFailureHeadSha: null,
82
82
  lastAttemptedFailureSignature: null,
83
+ lastAttemptedFailureAt: null,
83
84
  });
84
85
  }
85
86
  deps.logger.info({ issueKey: issue.issueKey, branchName: event.branchName, triggerEvent: event.triggerEvent, prNumber: event.prNumber }, "GitHub webhook: updated issue PR state");
@@ -240,6 +241,7 @@ async function updateGitHubFailureProvenance(deps, issue, event, project, failur
240
241
  lastQueueIncidentJson: null,
241
242
  lastAttemptedFailureHeadSha: null,
242
243
  lastAttemptedFailureSignature: null,
244
+ lastAttemptedFailureAt: null,
243
245
  });
244
246
  }
245
247
  }
@@ -50,8 +50,19 @@ function isDuplicateRepairAttempt(issue, context) {
50
50
  : typeof context?.headSha === "string" ? context.headSha : undefined;
51
51
  if (!signature)
52
52
  return false;
53
- return issue.lastAttemptedFailureSignature === signature
54
- && (headSha === undefined || issue.lastAttemptedFailureHeadSha === headSha);
53
+ if (issue.lastAttemptedFailureSignature !== signature)
54
+ return false;
55
+ if (headSha !== undefined && issue.lastAttemptedFailureHeadSha !== headSha)
56
+ return false;
57
+ // A signature+headSha match alone isn't enough: for queue evictions the PR head
58
+ // doesn't advance (we haven't pushed) and the steward's check name is constant,
59
+ // so a fresh incident after main advances looks identical. Treat the attempt as
60
+ // stale if a newer failure has been observed since it was recorded.
61
+ if (issue.lastAttemptedFailureAt && issue.lastGitHubFailureAt
62
+ && issue.lastGitHubFailureAt > issue.lastAttemptedFailureAt) {
63
+ return false;
64
+ }
65
+ return true;
55
66
  }
56
67
  function buildFailureContext(issue) {
57
68
  const storedFailureContext = parseGitHubFailureContext(issue.lastGitHubFailureContextJson);
@@ -196,6 +207,7 @@ export class IdleIssueReconciler {
196
207
  lastQueueIncidentJson: null,
197
208
  lastAttemptedFailureHeadSha: null,
198
209
  lastAttemptedFailureSignature: null,
210
+ lastAttemptedFailureAt: null,
199
211
  }
200
212
  : {}),
201
213
  });
@@ -90,6 +90,7 @@ export class InterruptedRunRecovery {
90
90
  linearIssueId: issue.linearIssueId,
91
91
  lastAttemptedFailureHeadSha: null,
92
92
  lastAttemptedFailureSignature: null,
93
+ lastAttemptedFailureAt: null,
93
94
  });
94
95
  }
95
96
  return true;
@@ -14,10 +14,10 @@ export class IssueSessionLeaseService {
14
14
  this.readThreadWithRetry = readThreadWithRetry;
15
15
  }
16
16
  hasLocalLease(projectId, linearIssueId) {
17
- return this.activeSessionLeases.has(this.issueSessionLeaseKey(projectId, linearIssueId));
17
+ return this.getValidatedLocalLeaseId(projectId, linearIssueId) !== undefined;
18
18
  }
19
19
  getHeldLease(projectId, linearIssueId) {
20
- const leaseId = this.activeSessionLeases.get(this.issueSessionLeaseKey(projectId, linearIssueId));
20
+ const leaseId = this.getValidatedLocalLeaseId(projectId, linearIssueId);
21
21
  if (!leaseId)
22
22
  return undefined;
23
23
  return { projectId, linearIssueId, leaseId };
@@ -133,10 +133,21 @@ export class IssueSessionLeaseService {
133
133
  }
134
134
  release(projectId, linearIssueId) {
135
135
  const key = this.issueSessionLeaseKey(projectId, linearIssueId);
136
- const leaseId = this.activeSessionLeases.get(key);
136
+ const leaseId = this.getValidatedLocalLeaseId(projectId, linearIssueId);
137
137
  this.db.issueSessions.releaseIssueSessionLease(projectId, linearIssueId, leaseId);
138
138
  this.activeSessionLeases.delete(key);
139
139
  }
140
+ getValidatedLocalLeaseId(projectId, linearIssueId) {
141
+ const key = this.issueSessionLeaseKey(projectId, linearIssueId);
142
+ const leaseId = this.activeSessionLeases.get(key);
143
+ if (!leaseId)
144
+ return undefined;
145
+ if (this.db.issueSessions.hasActiveIssueSessionLease(projectId, linearIssueId, leaseId)) {
146
+ return leaseId;
147
+ }
148
+ this.activeSessionLeases.delete(key);
149
+ return undefined;
150
+ }
140
151
  issueSessionLeaseKey(projectId, linearIssueId) {
141
152
  return `${projectId}:${linearIssueId}`;
142
153
  }
@@ -1,4 +1,4 @@
1
- import { resolvePreferredCompletedLinearState, resolvePreferredDeployingLinearState, resolvePreferredHumanNeededLinearState, resolvePreferredImplementingLinearState, resolvePreferredReviewLinearState, resolvePreferredReviewingLinearState, } from "./linear-workflow.js";
1
+ import { resolvePreferredQueuedLinearState, resolvePreferredCompletedLinearState, resolvePreferredDeployingLinearState, resolvePreferredHumanNeededLinearState, resolvePreferredImplementingLinearState, resolvePreferredReviewLinearState, resolvePreferredReviewingLinearState, } from "./linear-workflow.js";
2
2
  import { isCompletedLinearState } from "./pr-state.js";
3
3
  import { hasTrustedNoPrCompletion } from "./trusted-no-pr-completion.js";
4
4
  export async function syncActiveWorkflowState(params) {
@@ -72,10 +72,15 @@ function resolveDesiredActiveWorkflowState(issue, trackedIssue, options, liveIss
72
72
  || trackedIssue?.sessionState === "waiting_input" || trackedIssue?.sessionState === "failed") {
73
73
  return resolvePreferredHumanNeededLinearState(liveIssue);
74
74
  }
75
+ const blocked = (trackedIssue?.blockedByCount ?? 0) > 0;
76
+ const pausedNoPrWork = issue.prNumber === undefined && (!issue.delegatedToPatchRelay || blocked);
77
+ if (pausedNoPrWork) {
78
+ return resolvePreferredQueuedLinearState(liveIssue);
79
+ }
75
80
  const activelyWorking = issue.delegatedToPatchRelay !== false && (issue.activeRunId !== undefined
76
81
  || options?.activeRunType !== undefined
77
82
  || trackedIssue?.sessionState === "running"
78
- || issue.factoryState === "delegated"
83
+ || (issue.factoryState === "delegated" && !blocked && trackedIssue?.readyForExecution !== false)
79
84
  || issue.factoryState === "implementing"
80
85
  || issue.factoryState === "changes_requested"
81
86
  || issue.factoryState === "repairing_ci"
@@ -24,6 +24,16 @@ export function resolvePreferredStartedLinearState(issue) {
24
24
  });
25
25
  return preferred?.name ?? startedStates[0]?.name;
26
26
  }
27
+ export function resolvePreferredQueuedLinearState(issue) {
28
+ return resolvePreferredLinearState(issue, {
29
+ names: ["backlog", "start", "todo", "to do", "planned", "ready"],
30
+ types: ["backlog", "unstarted"],
31
+ fallback: issue.workflowStates.find((state) => {
32
+ const normalizedType = normalizeLinearState(state.type);
33
+ return normalizedType === "backlog" || normalizedType === "unstarted";
34
+ })?.name,
35
+ });
36
+ }
27
37
  export function resolvePreferredImplementingLinearState(issue) {
28
38
  return resolvePreferredLinearState(issue, {
29
39
  names: ["implementing", "in progress", "in-progress", "started", "doing"],
@@ -188,6 +188,7 @@ export async function handleNoPrCompletionCheck(params) {
188
188
  lastQueueIncidentJson: null,
189
189
  lastAttemptedFailureHeadSha: null,
190
190
  lastAttemptedFailureSignature: null,
191
+ lastAttemptedFailureAt: null,
191
192
  });
192
193
  return true;
193
194
  });
@@ -356,6 +356,22 @@ function buildPublicationContract(runType) {
356
356
  "If the worktree already contains relevant changes for this issue, verify them and publish them.",
357
357
  "If you changed files for this issue, commit them, push the issue branch, and open or update the PR before stopping.",
358
358
  "Do not stop with only local commits or uncommitted changes.",
359
+ "",
360
+ "## PR Body Contract",
361
+ "",
362
+ "When you open or update a PR, shape the body so a strict reviewer can decide in one pass.",
363
+ "",
364
+ "Title: imperative, ≤72 chars. Do not prefix with the issue key — the branch carries it.",
365
+ "",
366
+ "Body sections, in this order. Omit any that do not apply but keep the order:",
367
+ "",
368
+ " ## Why — 1-3 sentences on the problem and motivation.",
369
+ " ## What — ≤5 bullets naming the files or surfaces that change.",
370
+ " ## Tradeoffs — one explicit tradeoff taken, or the single word \"None\".",
371
+ " ## Risks — 1-3 things a strict reviewer would ask about. For each, either fix it before committing or explain why it is acceptable. This section is load-bearing; a strict reviewer reads it first.",
372
+ "",
373
+ "Do not restate the diff in prose. Quote the ambiguous fragment directly if the reader needs to see it.",
374
+ "Do not add a \"Verification\" or \"I ran these commands\" section; CI owns pass/fail and posts check runs the reviewer already sees.",
359
375
  ].join("\n");
360
376
  }
361
377
  return [
@@ -23,6 +23,13 @@ export class ReactiveRunPolicy {
23
23
  return undefined;
24
24
  if (!snapshot.headSha || snapshot.headSha !== issue.lastGitHubFailureHeadSha)
25
25
  return undefined;
26
+ // For queue repairs, the agent's no-op is legitimate when the incident has
27
+ // already self-resolved: GitHub reports the PR as mergeable, so there is no
28
+ // conflict left to push. Only flag as failed when the merge state is still
29
+ // DIRTY after the run — then the agent really did miss the fix.
30
+ if (run.runType === "queue_repair" && !isDirtyMergeStateStatus(snapshot.pr.mergeStateStatus)) {
31
+ return undefined;
32
+ }
26
33
  return `Repair finished but PR #${issue.prNumber} is still on failing head ${issue.lastGitHubFailureHeadSha.slice(0, 8)}`;
27
34
  }
28
35
  catch (error) {
@@ -99,6 +106,7 @@ export class ReactiveRunPolicy {
99
106
  lastQueueIncidentJson: null,
100
107
  lastAttemptedFailureHeadSha: null,
101
108
  lastAttemptedFailureSignature: null,
109
+ lastAttemptedFailureAt: null,
102
110
  lastGitHubCiSnapshotHeadSha: snapshot.headSha ?? null,
103
111
  lastGitHubCiSnapshotGateCheckName: snapshot.gateCheckName,
104
112
  lastGitHubCiSnapshotGateCheckStatus: "pending",
@@ -168,6 +168,7 @@ export class RunFinalizer {
168
168
  lastQueueIncidentJson: null,
169
169
  lastAttemptedFailureHeadSha: null,
170
170
  lastAttemptedFailureSignature: null,
171
+ lastAttemptedFailureAt: null,
171
172
  }
172
173
  : {})),
173
174
  });
@@ -101,6 +101,7 @@ export class RunLauncher {
101
101
  ? {
102
102
  lastAttemptedFailureSignature: failureSignature,
103
103
  lastAttemptedFailureHeadSha: failureHeadSha ?? null,
104
+ lastAttemptedFailureAt: new Date().toISOString(),
104
105
  }
105
106
  : {}),
106
107
  });
@@ -38,7 +38,7 @@ export class ServiceStartupRecovery {
38
38
  }
39
39
  }
40
40
  async recoverDelegatedIssueStateFromLinear() {
41
- for (const issue of this.db.issues.listIssuesWithAgentSessions()) {
41
+ for (const issue of this.db.issues.listIssues()) {
42
42
  if (issue.factoryState === "done" || issue.activeRunId !== undefined) {
43
43
  continue;
44
44
  }
@@ -87,7 +87,13 @@ export class ServiceStartupRecovery {
87
87
  const unresolvedBlockers = this.db.issues.countUnresolvedBlockers(issue.projectId, issue.linearIssueId);
88
88
  const latestRun = this.db.runs.getLatestRunForIssue(issue.projectId, issue.linearIssueId);
89
89
  const shouldRecoverPausedLocalWork = delegated
90
- && isResumablePausedLocalWork({ issue, latestRun })
90
+ && isResumablePausedLocalWork({
91
+ issue: {
92
+ ...issue,
93
+ delegatedToPatchRelay: delegated,
94
+ },
95
+ latestRun,
96
+ })
91
97
  && this.db.issueSessions.peekIssueSessionWake(issue.projectId, issue.linearIssueId) === undefined;
92
98
  const updated = this.db.issues.upsertIssue({
93
99
  projectId: issue.projectId,
@@ -1,4 +1,5 @@
1
1
  import { deriveIssueStatusNote } from "./status-note.js";
2
+ import { LinearSessionSync } from "./linear-session-sync.js";
2
3
  import { trustedActorAllowed } from "./project-resolution.js";
3
4
  import { normalizeWebhook } from "./webhooks.js";
4
5
  import { InstallationWebhookHandler } from "./webhook-installation-handler.js";
@@ -25,6 +26,7 @@ export class WebhookHandler {
25
26
  desiredStageRecorder;
26
27
  contextLoader;
27
28
  dependencyReadinessHandler;
29
+ linearSync;
28
30
  constructor(config, db, linearProvider, codex, enqueueIssue, logger, feed) {
29
31
  this.config = config;
30
32
  this.db = db;
@@ -39,6 +41,7 @@ export class WebhookHandler {
39
41
  this.agentSessionHandler = new AgentSessionHandler(config, db, linearProvider, codex, logger, feed);
40
42
  this.desiredStageRecorder = new DesiredStageRecorder(db, linearProvider, feed);
41
43
  this.contextLoader = new WebhookContextLoader(config, linearProvider);
44
+ this.linearSync = new LinearSessionSync(config, db, linearProvider, logger, feed);
42
45
  this.dependencyReadinessHandler = new DependencyReadinessHandler(db, (projectId, issueId) => this.peekPendingSessionWakeRunType(projectId, issueId));
43
46
  }
44
47
  async processWebhookEvent(webhookEventId) {
@@ -114,6 +117,9 @@ export class WebhookHandler {
114
117
  });
115
118
  const trackedIssue = result.issue;
116
119
  const newlyReadyDependents = this.dependencyReadinessHandler.reconcile(project.id, issue.id);
120
+ const syncTargets = new Set(shouldSyncLinearStateAfterWebhook(hydrated.triggerEvent)
121
+ ? [issue.id, ...newlyReadyDependents]
122
+ : newlyReadyDependents);
117
123
  // Handle issue removal: release active runs, mark as failed.
118
124
  if (hydrated.triggerEvent === "issueRemoved") {
119
125
  await this.issueRemovalHandler.handle({
@@ -172,6 +178,13 @@ export class WebhookHandler {
172
178
  detail: `All blockers are now done for ${dependent?.issueKey ?? dependentIssueId}.`,
173
179
  });
174
180
  }
181
+ for (const issueId of syncTargets) {
182
+ const syncIssue = this.db.getIssue(project.id, issueId);
183
+ if (!syncIssue) {
184
+ continue;
185
+ }
186
+ await this.linearSync.syncSession(syncIssue);
187
+ }
175
188
  }
176
189
  catch (error) {
177
190
  this.db.webhookEvents.markWebhookProcessed(webhookEventId, "failed");
@@ -233,3 +246,10 @@ export class WebhookHandler {
233
246
  return Boolean(statusNote?.endsWith("?"));
234
247
  }
235
248
  }
249
+ function shouldSyncLinearStateAfterWebhook(triggerEvent) {
250
+ return triggerEvent !== "agentSessionCreated"
251
+ && triggerEvent !== "agentPrompted"
252
+ && triggerEvent !== "commentCreated"
253
+ && triggerEvent !== "commentUpdated"
254
+ && triggerEvent !== "commentRemoved";
255
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "patchrelay",
3
- "version": "0.41.8",
3
+ "version": "0.43.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "repository": {