patchrelay 0.75.3 → 0.77.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 (45) 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 +11 -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/issue-session-projection-invalidator.js +9 -0
  15. package/dist/linear-agent-session-client.js +16 -8
  16. package/dist/linear-issue-projection.js +15 -11
  17. package/dist/linear-status-comment-sync.js +8 -4
  18. package/dist/linear-workflow-state-sync.js +9 -5
  19. package/dist/merged-linear-completion-reconciler.js +39 -17
  20. package/dist/no-pr-completion-check.js +51 -29
  21. package/dist/orchestration-parent-wake.js +15 -8
  22. package/dist/queue-health-monitor.js +17 -8
  23. package/dist/reactive-run-policy.js +5 -1
  24. package/dist/run-budgets.js +40 -6
  25. package/dist/run-completion-policy.js +50 -9
  26. package/dist/run-failure-policy.js +463 -0
  27. package/dist/run-finalizer.js +68 -35
  28. package/dist/run-launcher.js +63 -12
  29. package/dist/run-notification-handler.js +19 -9
  30. package/dist/run-orchestrator.js +70 -78
  31. package/dist/run-reconciler.js +137 -64
  32. package/dist/run-settlement.js +57 -0
  33. package/dist/run-wake-planner.js +39 -29
  34. package/dist/service-issue-actions.js +45 -28
  35. package/dist/service-startup-recovery.js +61 -35
  36. package/dist/telemetry.js +9 -0
  37. package/dist/terminal-wake-reconciler.js +20 -3
  38. package/dist/webhooks/agent-session-handler.js +22 -12
  39. package/dist/webhooks/dependency-readiness-handler.js +17 -10
  40. package/dist/webhooks/desired-stage-recorder.js +32 -13
  41. package/dist/webhooks/issue-removal-handler.js +24 -13
  42. package/package.json +1 -1
  43. package/dist/interrupted-run-recovery.js +0 -227
  44. package/dist/run-recovery-service.js +0 -202
  45. package/dist/zombie-recovery.js +0 -13
@@ -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.3",
4
- "commit": "0186011684f3",
5
- "builtAt": "2026-06-09T00:09:44.822Z"
3
+ "version": "0.77.0",
4
+ "commit": "618168f38a38",
5
+ "builtAt": "2026-06-10T04:00:01.424Z"
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,
@@ -419,6 +420,13 @@ export class IssueStore {
419
420
  }
420
421
  }
421
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
+ }
422
430
  return {
423
431
  id: Number(row.id),
424
432
  projectId: String(row.project_id),
@@ -448,7 +456,7 @@ export function mapIssueRow(row) {
448
456
  ...(row.current_linear_state_type !== null && row.current_linear_state_type !== undefined
449
457
  ? { currentLinearStateType: String(row.current_linear_state_type) }
450
458
  : {}),
451
- factoryState: String(row.factory_state ?? "delegated"),
459
+ factoryState,
452
460
  ...(row.pending_run_type !== null && row.pending_run_type !== undefined ? { pendingRunType: String(row.pending_run_type) } : {}),
453
461
  ...(row.pending_run_context_json !== null && row.pending_run_context_json !== undefined ? { pendingRunContextJson: String(row.pending_run_context_json) } : {}),
454
462
  ...(row.branch_name !== null ? { branchName: String(row.branch_name) } : {}),
@@ -546,5 +554,6 @@ export function mapIssueRow(row) {
546
554
  ...(row.deploy_started_at !== null && row.deploy_started_at !== undefined
547
555
  ? { deployStartedAt: String(row.deploy_started_at) }
548
556
  : {}),
557
+ version: Number(row.version ?? 0),
549
558
  };
550
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