patchrelay 0.75.2 → 0.76.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.
Files changed (40) hide show
  1. package/dist/agent-input-service.js +40 -26
  2. package/dist/build-info.json +3 -3
  3. package/dist/cli/data.js +3 -1
  4. package/dist/db/issue-session-store.js +44 -9
  5. package/dist/db/issue-store.js +31 -2
  6. package/dist/db/migrations.js +3 -0
  7. package/dist/factory-state.js +23 -0
  8. package/dist/github-webhook-reactive-run.js +15 -11
  9. package/dist/github-webhook-stack-coordination.js +8 -4
  10. package/dist/github-webhook-state-projector.js +204 -139
  11. package/dist/github-webhook-terminal-handler.js +37 -27
  12. package/dist/idle-reconciliation.js +122 -66
  13. package/dist/implementation-outcome-policy.js +5 -1
  14. package/dist/interrupted-run-recovery.js +46 -33
  15. package/dist/issue-session-projection-invalidator.js +9 -0
  16. package/dist/linear-agent-session-client.js +16 -8
  17. package/dist/linear-issue-projection.js +15 -11
  18. package/dist/linear-status-comment-sync.js +8 -4
  19. package/dist/linear-workflow-state-sync.js +9 -5
  20. package/dist/merged-linear-completion-reconciler.js +39 -17
  21. package/dist/no-pr-completion-check.js +51 -29
  22. package/dist/orchestration-parent-wake.js +15 -8
  23. package/dist/queue-health-monitor.js +17 -8
  24. package/dist/reactive-run-policy.js +5 -1
  25. package/dist/run-finalizer.js +61 -29
  26. package/dist/run-launcher.js +42 -12
  27. package/dist/run-notification-handler.js +19 -7
  28. package/dist/run-orchestrator.js +121 -18
  29. package/dist/run-reconciler.js +121 -50
  30. package/dist/run-recovery-service.js +70 -33
  31. package/dist/run-wake-planner.js +39 -29
  32. package/dist/service-issue-actions.js +45 -28
  33. package/dist/service-startup-recovery.js +61 -35
  34. package/dist/telemetry.js +9 -0
  35. package/dist/terminal-wake-reconciler.js +20 -3
  36. package/dist/webhooks/agent-session-handler.js +22 -12
  37. package/dist/webhooks/dependency-readiness-handler.js +17 -10
  38. package/dist/webhooks/desired-stage-recorder.js +32 -13
  39. package/dist/webhooks/issue-removal-handler.js +24 -13
  40. package/package.json +1 -1
@@ -2,6 +2,7 @@ import { buildFollowupStatusActivity, buildNonActionableFollowupActivity, buildP
2
2
  import { deriveIssueStatusNote } from "./status-note.js";
3
3
  import { extractLatestAssistantSummary } from "./issue-session-events.js";
4
4
  import { dirtyWorktreeEventPayload, inspectGitWorktreeStatus } from "./git-worktree-status.js";
5
+ const WRITER = "agent-input-service";
5
6
  export class AgentInputService {
6
7
  db;
7
8
  codex;
@@ -195,21 +196,25 @@ export class AgentInputService {
195
196
  prepareReplacementWork(project, issue) {
196
197
  const issueRef = (issue.issueKey ?? issue.linearIssueId).replace(/[^a-zA-Z0-9._-]+/g, "-");
197
198
  const suffix = Date.now().toString(36);
198
- return this.db.issueSessions.upsertIssueRespectingActiveLease(issue.projectId, issue.linearIssueId, {
199
- projectId: issue.projectId,
200
- linearIssueId: issue.linearIssueId,
201
- factoryState: "delegated",
202
- branchName: `${project.branchPrefix}/${issueRef}-replacement-${suffix}`,
203
- prNumber: null,
204
- prUrl: null,
205
- prState: null,
206
- prIsDraft: null,
207
- prHeadSha: null,
208
- prAuthorLogin: null,
209
- prReviewState: null,
210
- prCheckStatus: null,
211
- lastBlockingReviewHeadSha: null,
212
- }) ?? issue;
199
+ const commit = this.db.issueSessions.commitIssueState({
200
+ writer: WRITER,
201
+ update: {
202
+ projectId: issue.projectId,
203
+ linearIssueId: issue.linearIssueId,
204
+ factoryState: "delegated",
205
+ branchName: `${project.branchPrefix}/${issueRef}-replacement-${suffix}`,
206
+ prNumber: null,
207
+ prUrl: null,
208
+ prState: null,
209
+ prIsDraft: null,
210
+ prHeadSha: null,
211
+ prAuthorLogin: null,
212
+ prReviewState: null,
213
+ prCheckStatus: null,
214
+ lastBlockingReviewHeadSha: null,
215
+ },
216
+ });
217
+ return commit.outcome === "applied" ? commit.issue : issue;
213
218
  }
214
219
  async stopActiveRun(issue, run, body, source) {
215
220
  const worktreeStatus = issue.worktreePath ? inspectGitWorktreeStatus(issue.worktreePath) : undefined;
@@ -226,18 +231,27 @@ export class AgentInputService {
226
231
  catch (error) {
227
232
  this.logger.warn({ issueKey: issue.issueKey, error: error instanceof Error ? error.message : String(error) }, "Failed to steer Codex turn for stop request");
228
233
  }
229
- this.db.runs.finishRun(run.id, {
230
- status: "released",
231
- threadId: run.threadId,
232
- turnId: run.turnId,
233
- failureReason: dirtySummary ? `Operator stopped run; ${dirtySummary}` : "Operator stopped run",
234
- });
235
234
  }
236
- this.db.issueSessions.upsertIssueRespectingActiveLease(issue.projectId, issue.linearIssueId, {
237
- projectId: issue.projectId,
238
- linearIssueId: issue.linearIssueId,
239
- activeRunId: null,
240
- factoryState: "awaiting_input",
235
+ // The stop is an operator fact: the issue slot clear and the run release
236
+ // ride in one transaction, with the run gated on the issue commit.
237
+ this.db.transaction(() => {
238
+ const commit = this.db.issueSessions.commitIssueState({
239
+ writer: WRITER,
240
+ update: {
241
+ projectId: issue.projectId,
242
+ linearIssueId: issue.linearIssueId,
243
+ activeRunId: null,
244
+ factoryState: "awaiting_input",
245
+ },
246
+ });
247
+ if (commit.outcome === "applied" && run.threadId && run.turnId) {
248
+ this.db.runs.finishRun(run.id, {
249
+ status: "released",
250
+ threadId: run.threadId,
251
+ turnId: run.turnId,
252
+ failureReason: dirtySummary ? `Operator stopped run; ${dirtySummary}` : "Operator stopped run",
253
+ });
254
+ }
241
255
  });
242
256
  this.wakeDispatcher.recordEventAndDispatch(issue.projectId, issue.linearIssueId, {
243
257
  eventType: "stop_requested",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "service": "patchrelay",
3
- "version": "0.75.2",
4
- "commit": "ba00b4a18609",
5
- "builtAt": "2026-06-05T23:03:05.291Z"
3
+ "version": "0.76.0",
4
+ "commit": "b6c068ca2195",
5
+ "builtAt": "2026-06-10T03:29:06.189Z"
6
6
  }
package/dist/cli/data.js CHANGED
@@ -276,7 +276,9 @@ export class CliDataAccess extends CliOperatorApiClient {
276
276
  : `Operator closed issue as ${terminalState}`,
277
277
  });
278
278
  }
279
- this.db.issueSessions.upsertIssueRespectingActiveLease(issue.projectId, issue.linearIssueId, {
279
+ // Operator CLI manual close — interactive, single-writer by construction
280
+ // (see the issue-write-door guard allowlist), so a raw upsert is fine.
281
+ this.db.upsertIssue({
280
282
  projectId: issue.projectId,
281
283
  linearIssueId: issue.linearIssueId,
282
284
  delegatedToPatchRelay: false,
@@ -20,6 +20,7 @@ export class IssueSessionStore {
20
20
  this.telemetry = telemetry;
21
21
  }
22
22
  getIssueSession(projectId, linearIssueId) {
23
+ this.issueSessionProjection.assertNotMidBatch?.("getIssueSession");
23
24
  const row = this.connection
24
25
  .prepare("SELECT * FROM issue_sessions WHERE project_id = ? AND linear_issue_id = ?")
25
26
  .get(projectId, linearIssueId);
@@ -261,15 +262,49 @@ export class IssueSessionStore {
261
262
  return fn();
262
263
  })();
263
264
  }
264
- upsertIssueWithLease(lease, params) {
265
- return this.withIssueSessionLease(lease.projectId, lease.linearIssueId, lease.leaseId, () => this.issues.upsertIssue(params));
266
- }
267
- upsertIssueRespectingActiveLease(projectId, linearIssueId, params) {
268
- const lease = this.getActiveIssueSessionLease(projectId, linearIssueId);
269
- if (!lease) {
270
- return this.issues.upsertIssue(params);
271
- }
272
- return this.upsertIssueWithLease(lease, params);
265
+ /**
266
+ * The single door for issue-state writes (core simplification plan, phase
267
+ * A): one transaction wrapping lease validity, an optimistic version check
268
+ * against the row the update was derived from, and the write itself. A
269
+ * version mismatch means another writer landed between the caller's read
270
+ * and this commit — emitted as `state.write_conflict` telemetry and either
271
+ * recomputed via `onConflict`, skipped, or applied anyway (see params).
272
+ */
273
+ commitIssueState(params) {
274
+ return this.connection.transaction(() => {
275
+ const { projectId, linearIssueId } = params.update;
276
+ if (params.lease && !this.hasActiveIssueSessionLease(projectId, linearIssueId, params.lease.leaseId)) {
277
+ return { outcome: "lease_denied" };
278
+ }
279
+ const current = this.issues.getIssue(projectId, linearIssueId);
280
+ const actualVersion = current?.version ?? null;
281
+ if (params.expectedVersion === undefined || actualVersion === params.expectedVersion) {
282
+ return { outcome: "applied", issue: this.issues.upsertIssue(params.update), conflicted: false };
283
+ }
284
+ const emitConflict = (resolution) => {
285
+ emitTelemetry(this.telemetry, {
286
+ type: "state.write_conflict",
287
+ projectId,
288
+ linearIssueId,
289
+ ...(current?.issueKey ? { issueKey: current.issueKey } : {}),
290
+ writer: params.writer,
291
+ expectedVersion: params.expectedVersion ?? null,
292
+ actualVersion,
293
+ resolution,
294
+ });
295
+ };
296
+ if (params.onConflict && current) {
297
+ const recomputed = params.onConflict(current);
298
+ if (!recomputed) {
299
+ emitConflict("skipped");
300
+ return { outcome: "conflict_skipped", issue: current };
301
+ }
302
+ emitConflict("recomputed");
303
+ return { outcome: "applied", issue: this.issues.upsertIssue(recomputed), conflicted: true };
304
+ }
305
+ emitConflict("applied_anyway");
306
+ return { outcome: "applied", issue: this.issues.upsertIssue(params.update), conflicted: true };
307
+ })();
273
308
  }
274
309
  finishRunWithLease(lease, runId, params) {
275
310
  return this.withIssueSessionLease(lease.projectId, lease.linearIssueId, lease.leaseId, () => {
@@ -1,3 +1,4 @@
1
+ import { FACTORY_STATES, isFactoryState } from "../factory-state.js";
1
2
  import { buildInsertBindings, buildUpdateAssignments } from "./issue-upsert-columns.js";
2
3
  import { isoNow } from "./shared.js";
3
4
  const CANCELED_OR_DUPLICATE_CHILD_PREDICATE = `
@@ -20,7 +21,7 @@ export class IssueStore {
20
21
  const existing = this.getIssue(params.projectId, params.linearIssueId);
21
22
  if (existing) {
22
23
  const { assignments, values } = buildUpdateAssignments(params);
23
- const sql = `UPDATE issues SET ${["updated_at = @now", ...assignments].join(", ")} WHERE project_id = @projectId AND linear_issue_id = @linearIssueId`;
24
+ const sql = `UPDATE issues SET ${["updated_at = @now", "version = version + 1", ...assignments].join(", ")} WHERE project_id = @projectId AND linear_issue_id = @linearIssueId`;
24
25
  this.connection.prepare(sql).run({
25
26
  ...values,
26
27
  now,
@@ -92,6 +93,26 @@ export class IssueStore {
92
93
  .all();
93
94
  return rows.map(mapIssueRow);
94
95
  }
96
+ // Recovery net for a dangling active slot: an issue whose
97
+ // `active_run_id` still points at a run that has already reached a
98
+ // terminal status. This happens when the post-run finalize never ran
99
+ // to completion — almost always a service restart landing between
100
+ // `finishRun` (which marks the run terminal) and the issue write that
101
+ // clears `active_run_id` and arms the next wake. The Codex
102
+ // `turn/completed` notification that would finalize it never re-fires
103
+ // after restart, and every idle/recovery pass gates on
104
+ // `active_run_id IS NULL`, so the issue is invisible to all of them
105
+ // and freezes indefinitely. The orchestrator clears the slot so the
106
+ // idle reconciler can route the issue forward (review_fix, etc.).
107
+ listIssuesWithTerminalActiveRun() {
108
+ const rows = this.connection
109
+ .prepare(`SELECT i.* FROM issues i
110
+ JOIN runs r ON r.id = i.active_run_id
111
+ WHERE i.active_run_id IS NOT NULL
112
+ AND r.status IN ('completed', 'failed', 'released', 'superseded')`)
113
+ .all();
114
+ return rows.map(mapIssueRow);
115
+ }
95
116
  // Safety net for orphaned wakes: any delegated, non-terminal issue
96
117
  // with at least one unprocessed session event but no active run.
97
118
  // The orchestrator's enqueueIssue is the only path that drains these
@@ -399,6 +420,13 @@ export class IssueStore {
399
420
  }
400
421
  }
401
422
  export function mapIssueRow(row) {
423
+ const factoryState = String(row.factory_state ?? "delegated");
424
+ if (!isFactoryState(factoryState)) {
425
+ throw new Error(`Invalid factory_state '${factoryState}' on issue row `
426
+ + `${String(row.project_id)}/${String(row.linear_issue_id)}`
427
+ + `${row.issue_key != null ? ` (${String(row.issue_key)})` : ""}; `
428
+ + "expected one of: " + [...FACTORY_STATES].join(", "));
429
+ }
402
430
  return {
403
431
  id: Number(row.id),
404
432
  projectId: String(row.project_id),
@@ -428,7 +456,7 @@ export function mapIssueRow(row) {
428
456
  ...(row.current_linear_state_type !== null && row.current_linear_state_type !== undefined
429
457
  ? { currentLinearStateType: String(row.current_linear_state_type) }
430
458
  : {}),
431
- factoryState: String(row.factory_state ?? "delegated"),
459
+ factoryState,
432
460
  ...(row.pending_run_type !== null && row.pending_run_type !== undefined ? { pendingRunType: String(row.pending_run_type) } : {}),
433
461
  ...(row.pending_run_context_json !== null && row.pending_run_context_json !== undefined ? { pendingRunContextJson: String(row.pending_run_context_json) } : {}),
434
462
  ...(row.branch_name !== null ? { branchName: String(row.branch_name) } : {}),
@@ -526,5 +554,6 @@ export function mapIssueRow(row) {
526
554
  ...(row.deploy_started_at !== null && row.deploy_started_at !== undefined
527
555
  ? { deployStartedAt: String(row.deploy_started_at) }
528
556
  : {}),
557
+ version: Number(row.version ?? 0),
529
558
  };
530
559
  }
@@ -366,6 +366,9 @@ export function runPatchRelayMigrations(connection) {
366
366
  // `deploying` state, so the deploy watcher only considers deploy runs
367
367
  // created at/after the merge (and can time out a never-arriving deploy).
368
368
  addColumnIfMissing(connection, "issues", "deploy_started_at", "TEXT");
369
+ // Optimistic-concurrency counter for issue-state writes (core
370
+ // simplification plan, phase A). Bumped on every UPDATE by upsertIssue.
371
+ addColumnIfMissing(connection, "issues", "version", "INTEGER NOT NULL DEFAULT 0");
369
372
  }
370
373
  function addColumnIfMissing(connection, table, column, definition) {
371
374
  const cols = connection.prepare(`PRAGMA table_info(${table})`).all();
@@ -1,3 +1,26 @@
1
+ /**
2
+ * Canonical value set for {@link FactoryState}. Used for read-side validation
3
+ * (row mappers throw on unknown values instead of lying via cast) — keep in
4
+ * sync with the union above; `satisfies` rejects values outside the union.
5
+ */
6
+ const FACTORY_STATE_VALUES = [
7
+ "delegated",
8
+ "implementing",
9
+ "pr_open",
10
+ "changes_requested",
11
+ "repairing_ci",
12
+ "awaiting_queue",
13
+ "repairing_queue",
14
+ "deploying",
15
+ "awaiting_input",
16
+ "escalated",
17
+ "done",
18
+ "failed",
19
+ ];
20
+ export const FACTORY_STATES = new Set(FACTORY_STATE_VALUES);
21
+ export function isFactoryState(value) {
22
+ return FACTORY_STATES.has(value);
23
+ }
1
24
  /** Which factory states involve an active Codex run. */
2
25
  export const ACTIVE_RUN_STATES = new Set([
3
26
  "implementing",
@@ -4,6 +4,7 @@ import { isIssueTerminal } from "./pr-state.js";
4
4
  import { buildGitHubQueueFailureContext, getRelevantGitHubCiSnapshot, resolveGitHubBranchFailureContext, resolveGitHubCheckClass, } from "./github-webhook-failure-context.js";
5
5
  import { isQueueEvictionFailure, isSettledBranchFailure } from "./github-webhook-policy.js";
6
6
  import { buildRequestedChangesWakeIdentity } from "./reactive-wake-keys.js";
7
+ const WRITER = "github-webhook-reactive-run";
7
8
  export async function maybeEnqueueGitHubReactiveRun(params) {
8
9
  const { issue, event, project, logger, feed, wakeDispatcher, db, fetchImpl, failureContextResolver } = params;
9
10
  if (isIssueTerminal(issue))
@@ -116,17 +117,20 @@ async function handleCheckFailedEvent(params) {
116
117
  return;
117
118
  }
118
119
  const snapshot = getRelevantGitHubCiSnapshot(db, issue, event);
119
- db.issues.upsertIssue({
120
- projectId: issue.projectId,
121
- linearIssueId: issue.linearIssueId,
122
- lastGitHubFailureSource: "branch_ci",
123
- lastGitHubFailureHeadSha: failureContext.failureHeadSha ?? null,
124
- lastGitHubFailureSignature: failureContext.failureSignature ?? null,
125
- lastGitHubFailureCheckName: failureContext.checkName ?? event.checkName ?? null,
126
- lastGitHubFailureCheckUrl: failureContext.checkUrl ?? event.checkUrl ?? null,
127
- lastGitHubFailureContextJson: JSON.stringify(failureContext),
128
- lastGitHubFailureAt: new Date().toISOString(),
129
- lastQueueIncidentJson: null,
120
+ db.issueSessions.commitIssueState({
121
+ writer: WRITER,
122
+ update: {
123
+ projectId: issue.projectId,
124
+ linearIssueId: issue.linearIssueId,
125
+ lastGitHubFailureSource: "branch_ci",
126
+ lastGitHubFailureHeadSha: failureContext.failureHeadSha ?? null,
127
+ lastGitHubFailureSignature: failureContext.failureSignature ?? null,
128
+ lastGitHubFailureCheckName: failureContext.checkName ?? event.checkName ?? null,
129
+ lastGitHubFailureCheckUrl: failureContext.checkUrl ?? event.checkUrl ?? null,
130
+ lastGitHubFailureContextJson: JSON.stringify(failureContext),
131
+ lastGitHubFailureAt: new Date().toISOString(),
132
+ lastQueueIncidentJson: null,
133
+ },
130
134
  });
131
135
  const queuedRunType = wakeDispatcher.recordEventAndDispatch(issue.projectId, issue.linearIssueId, {
132
136
  eventType: "settled_red_ci",
@@ -1,3 +1,4 @@
1
+ const WRITER = "github-webhook-stack-coordination";
1
2
  // Plan §8.3-8.4: when a parent PR's head moves (review-fix push,
2
3
  // eviction repair, base-branch update), child PRs stacked on it
3
4
  // become stale. Patchrelay treats this as a wake event for each
@@ -19,10 +20,13 @@ export function maybeFanChildRebaseWakes(params) {
19
20
  logger.debug({ parentBranch: event.branchName, childIssue: child.issueKey, childRunId: child.activeRunId }, "Skipping child-rebase wake — child has an active run");
20
21
  continue;
21
22
  }
22
- db.issues.upsertIssue({
23
- projectId: child.projectId,
24
- linearIssueId: child.linearIssueId,
25
- pendingRunType: "branch_upkeep",
23
+ db.issueSessions.commitIssueState({
24
+ writer: WRITER,
25
+ update: {
26
+ projectId: child.projectId,
27
+ linearIssueId: child.linearIssueId,
28
+ pendingRunType: "branch_upkeep",
29
+ },
26
30
  });
27
31
  // The pending_run_type field above isn't an event, so we still need
28
32
  // an explicit dispatch call. dispatchIfWakePending will pick up the