patchrelay 0.79.0 → 0.80.1

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,9 +1,8 @@
1
+ import { deriveIssueExecutionState } from "./issue-execution-state.js";
1
2
  export function resolveAwaitingInputReason(params) {
2
- if (params.issue.factoryState !== "awaiting_input") {
3
- return undefined;
4
- }
5
- if (params.latestRun?.completionCheckOutcome === "needs_input") {
6
- return "completion_check_question";
7
- }
8
- return "paused_local_work";
3
+ const state = deriveIssueExecutionState({
4
+ factoryState: params.issue.factoryState,
5
+ latestRunCompletionCheckOutcome: params.latestRun?.completionCheckOutcome,
6
+ });
7
+ return state.kind === "waiting_input" ? state.reason : undefined;
9
8
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "service": "patchrelay",
3
- "version": "0.79.0",
4
- "commit": "497f534ca6a5",
5
- "builtAt": "2026-06-10T21:17:11.105Z"
3
+ "version": "0.80.1",
4
+ "commit": "d2c60661d5fa",
5
+ "builtAt": "2026-06-10T22:15:08.651Z"
6
6
  }
@@ -775,7 +775,7 @@ export class IdleIssueReconciler {
775
775
  return;
776
776
  }
777
777
  if (isReviewDecisionApproved(pr.reviewDecision)) {
778
- this.db.issueSessions.commitIssueState({
778
+ const reviewCommit = this.db.issueSessions.commitIssueState({
779
779
  writer: WRITER,
780
780
  update: {
781
781
  projectId: issue.projectId,
@@ -783,6 +783,14 @@ export class IdleIssueReconciler {
783
783
  prReviewState: "approved",
784
784
  },
785
785
  });
786
+ // Continue with the refreshed row so the version-checked advance
787
+ // below doesn't see our own review-state write as a conflict (same
788
+ // pattern as the facts commit above). Without this the advance was
789
+ // conflict-skipped on EVERY pass while the poll succeeded, so a lost
790
+ // review_approved webhook never converged to awaiting_queue.
791
+ if (reviewCommit.outcome === "applied") {
792
+ issue = reviewCommit.issue;
793
+ }
786
794
  const approvedState = deriveFactoryStateFromPrFacts(observed, currentFacts(issue));
787
795
  if (approvedState === "awaiting_queue") {
788
796
  // Provenance survives unless the polled evidence is newer than the
@@ -0,0 +1,139 @@
1
+ import { hasOpenPr } from "./pr-state.js";
2
+ /** Run statuses that may legally occupy an issue's active-run slot. */
3
+ const ACTIVE_RUN_STATUSES = new Set(["queued", "running"]);
4
+ export function deriveIssueExecutionState(params) {
5
+ const factoryState = params.factoryState;
6
+ // Undelegation pauses automation for any non-finished issue and outranks
7
+ // every other answer (including an active run, which keeps executing but
8
+ // is reported as paused-with-downstream-continuation where relevant).
9
+ if (params.delegatedToPatchRelay === false
10
+ && factoryState !== "done"
11
+ && factoryState !== "failed"
12
+ && factoryState !== "escalated") {
13
+ const downstreamMayContinue = factoryState === "awaiting_queue"
14
+ || (hasOpenPr(params.prNumber, params.prState) && params.prReviewState === "approved");
15
+ return { kind: "undelegated", downstreamMayContinue };
16
+ }
17
+ // Active run facts win next — the issue is moving (or claims to be).
18
+ if (params.activeRunType || params.activeRunId !== undefined) {
19
+ const run = {
20
+ ...(params.activeRunId !== undefined ? { activeRunId: params.activeRunId } : {}),
21
+ ...(params.activeRunType ? { runType: params.activeRunType } : {}),
22
+ phase: resolveRunPhase(params),
23
+ };
24
+ // `done` + active run is a legitimate finalizing window (the post-run
25
+ // finalizer advances factoryState before clearing the slot), and
26
+ // `awaiting_input` + active run is a resumed reply turn. `failed` /
27
+ // `escalated` should never hold a slot: settleRun clears it before the
28
+ // terminal transition lands.
29
+ if (factoryState === "failed" || factoryState === "escalated") {
30
+ return {
31
+ kind: "inconsistent",
32
+ description: `terminal factoryState "${factoryState}" still holds an active run slot`,
33
+ run,
34
+ };
35
+ }
36
+ if (params.activeRunStatus !== undefined && !ACTIVE_RUN_STATUSES.has(params.activeRunStatus)) {
37
+ return {
38
+ kind: "inconsistent",
39
+ description: `active run slot points at a ${params.activeRunStatus} run`,
40
+ run,
41
+ };
42
+ }
43
+ return { kind: "running", run };
44
+ }
45
+ if (params.orchestrationSettleUntil) {
46
+ const settleAt = Date.parse(params.orchestrationSettleUntil);
47
+ if (Number.isFinite(settleAt) && settleAt > (params.now ?? Date.now())) {
48
+ return { kind: "settling", settleUntil: params.orchestrationSettleUntil };
49
+ }
50
+ }
51
+ const blockedByKeys = (params.blockedByKeys ?? []).filter((value) => value.trim().length > 0);
52
+ if (blockedByKeys.length > 0) {
53
+ return { kind: "blocked", blockedByKeys };
54
+ }
55
+ switch (factoryState) {
56
+ case "awaiting_input":
57
+ return {
58
+ kind: "waiting_input",
59
+ reason: params.latestRunCompletionCheckOutcome === "needs_input"
60
+ ? "completion_check_question"
61
+ : "paused_local_work",
62
+ };
63
+ case "changes_requested":
64
+ return { kind: "awaiting_followup", followup: "review_fix" };
65
+ case "repairing_ci":
66
+ return { kind: "awaiting_followup", followup: "ci_repair", checkName: params.latestFailureCheckName };
67
+ case "repairing_queue":
68
+ return { kind: "awaiting_followup", followup: "queue_repair" };
69
+ case "awaiting_queue":
70
+ return { kind: "idle_awaiting_external", waitingOn: "merge_queue" };
71
+ case "done":
72
+ return { kind: "terminal", outcome: "done" };
73
+ case "failed":
74
+ return { kind: "terminal", outcome: "failed" };
75
+ case "escalated":
76
+ return { kind: "terminal", outcome: "escalated" };
77
+ default:
78
+ break;
79
+ }
80
+ // delegated / implementing / pr_open / deploying: the wait, if any, is
81
+ // derived from live PR truth.
82
+ if (params.prCheckStatus === "failed" || params.prCheckStatus === "failure") {
83
+ return { kind: "idle_awaiting_external", waitingOn: "ci_failure", checkName: params.latestFailureCheckName };
84
+ }
85
+ if (params.prReviewState === "changes_requested") {
86
+ if (params.prCheckStatus === "passed" || params.prCheckStatus === "success") {
87
+ if (params.prHeadSha
88
+ && params.lastBlockingReviewHeadSha
89
+ && params.prHeadSha !== params.lastBlockingReviewHeadSha) {
90
+ return { kind: "idle_awaiting_external", waitingOn: "review_of_new_head" };
91
+ }
92
+ return { kind: "idle_awaiting_external", waitingOn: "blocking_review_same_head" };
93
+ }
94
+ return { kind: "idle_awaiting_external", waitingOn: "review_feedback" };
95
+ }
96
+ if (params.prReviewState === "approved") {
97
+ return { kind: "idle_awaiting_external", waitingOn: "downstream_automation" };
98
+ }
99
+ if (hasOpenPr(params.prNumber, params.prState)) {
100
+ return { kind: "idle_awaiting_external", waitingOn: "external_review" };
101
+ }
102
+ if (params.pendingRunType) {
103
+ return { kind: "ready", pendingRunType: params.pendingRunType };
104
+ }
105
+ return { kind: "idle" };
106
+ }
107
+ function resolveRunPhase(params) {
108
+ if (hasOpenPr(params.prNumber, params.prState) && (params.factoryState === "pr_open" || params.factoryState === "awaiting_queue")) {
109
+ return "finalizing_published_pr";
110
+ }
111
+ if (params.factoryState === "done") {
112
+ return "finalizing_merged_change";
113
+ }
114
+ return "working";
115
+ }
116
+ /** Build the deriver input from full records (issue row + resolved runs). */
117
+ export function issueExecutionStateInputFromRecords(issue, extras) {
118
+ return {
119
+ delegatedToPatchRelay: issue.delegatedToPatchRelay,
120
+ factoryState: issue.factoryState,
121
+ activeRunId: issue.activeRunId,
122
+ activeRunType: extras?.activeRun?.runType,
123
+ activeRunStatus: extras?.activeRun?.status,
124
+ latestRunCompletionCheckOutcome: extras?.latestRun?.completionCheckOutcome,
125
+ pendingRunType: issue.pendingRunType,
126
+ blockedByKeys: extras?.blockedByKeys,
127
+ orchestrationSettleUntil: issue.orchestrationSettleUntil,
128
+ prNumber: issue.prNumber,
129
+ prState: issue.prState,
130
+ prHeadSha: issue.prHeadSha,
131
+ prReviewState: issue.prReviewState,
132
+ prCheckStatus: issue.prCheckStatus,
133
+ lastBlockingReviewHeadSha: issue.lastBlockingReviewHeadSha,
134
+ latestFailureCheckName: issue.lastGitHubFailureCheckName,
135
+ };
136
+ }
137
+ export function deriveIssueExecutionStateFromRecords(issue, extras) {
138
+ return deriveIssueExecutionState(issueExecutionStateInputFromRecords(issue, extras));
139
+ }
@@ -1,34 +1,51 @@
1
1
  import { randomUUID } from "node:crypto";
2
- import { getThreadTurns } from "./codex-thread-utils.js";
3
2
  import { emitTelemetry, noopTelemetry } from "./telemetry.js";
4
- const ISSUE_SESSION_LEASE_MS = 10 * 60_000;
3
+ export const ISSUE_SESSION_LEASE_MS = 10 * 60_000;
4
+ /**
5
+ * Expected heartbeat cadence for a live lease holder. Heartbeats are
6
+ * notification-driven (every Codex notification renews the lease) rather
7
+ * than timer-driven, so this is the staleness budget granted to a live
8
+ * holder, not a timer the service runs. A foreign holder whose inferred
9
+ * last heartbeat (`leasedUntil - ISSUE_SESSION_LEASE_MS`) is older than
10
+ * 2x this budget is presumed dead (crashed process) and its lease may be
11
+ * reclaimed for recovery without waiting for full TTL expiry.
12
+ */
13
+ export const ISSUE_SESSION_HEARTBEAT_INTERVAL_MS = 60_000;
14
+ const FOREIGN_LEASE_RECLAIM_STALENESS_MS = 2 * ISSUE_SESSION_HEARTBEAT_INTERVAL_MS;
15
+ /**
16
+ * Issue-session lease coordination over the `issue_sessions` lease columns.
17
+ * The DB row (`lease_id`, `worker_id`, `leased_until`) is the only truth —
18
+ * there is no in-memory mirror, so a restarted process loses no lease state
19
+ * (D4, core simplification plan). "Held by us" means the row carries this
20
+ * service's `workerId` with an unexpired `leased_until`.
21
+ */
5
22
  export class IssueSessionLeaseService {
6
23
  db;
7
24
  logger;
8
25
  workerId;
9
- readThreadWithRetry;
10
26
  telemetry;
11
- activeSessionLeases = new Map();
12
- constructor(db, logger, workerId, readThreadWithRetry, telemetry = noopTelemetry) {
27
+ constructor(db, logger, workerId, telemetry = noopTelemetry) {
13
28
  this.db = db;
14
29
  this.logger = logger;
15
30
  this.workerId = workerId;
16
- this.readThreadWithRetry = readThreadWithRetry;
17
31
  this.telemetry = telemetry;
18
32
  }
19
33
  hasLocalLease(projectId, linearIssueId) {
20
- return this.getValidatedLocalLeaseId(projectId, linearIssueId) !== undefined;
34
+ return this.getHeldLease(projectId, linearIssueId) !== undefined;
21
35
  }
22
36
  getHeldLease(projectId, linearIssueId) {
23
- const leaseId = this.getValidatedLocalLeaseId(projectId, linearIssueId);
24
- if (!leaseId)
37
+ const session = this.db.issueSessions.getIssueSession(projectId, linearIssueId);
38
+ if (!session?.leaseId || session.workerId !== this.workerId || !isLeaseActive(session)) {
25
39
  return undefined;
26
- return { projectId, linearIssueId, leaseId };
40
+ }
41
+ return { projectId, linearIssueId, leaseId: session.leaseId };
27
42
  }
28
43
  withHeldLease(projectId, linearIssueId, fn) {
29
44
  const lease = this.getHeldLease(projectId, linearIssueId);
30
45
  if (!lease)
31
46
  return undefined;
47
+ // Re-validated inside the store's transaction so the check and the
48
+ // guarded writes are atomic.
32
49
  return this.db.issueSessions.withIssueSessionLease(projectId, linearIssueId, lease.leaseId, () => fn(lease));
33
50
  }
34
51
  acquire(projectId, linearIssueId) {
@@ -46,7 +63,6 @@ export class IssueSessionLeaseService {
46
63
  this.emitStaleLeaseInvariantIfRunnable(projectId, linearIssueId);
47
64
  return undefined;
48
65
  }
49
- this.activeSessionLeases.set(this.issueSessionLeaseKey(projectId, linearIssueId), leaseId);
50
66
  this.emitLease("lease.acquired", projectId, linearIssueId, leaseId);
51
67
  return leaseId;
52
68
  }
@@ -64,20 +80,17 @@ export class IssueSessionLeaseService {
64
80
  this.emitLease("lease.acquire_failed", projectId, linearIssueId);
65
81
  return undefined;
66
82
  }
67
- this.activeSessionLeases.set(this.issueSessionLeaseKey(projectId, linearIssueId), leaseId);
68
83
  this.emitLease("lease.acquired", projectId, linearIssueId, leaseId);
69
84
  return leaseId;
70
85
  }
71
86
  claimForReconciliation(projectId, linearIssueId) {
72
- const key = this.issueSessionLeaseKey(projectId, linearIssueId);
73
- if (this.activeSessionLeases.has(key)) {
74
- return "owned";
75
- }
76
87
  const session = this.db.issueSessions.getIssueSession(projectId, linearIssueId);
77
88
  if (!session)
78
89
  return "skip";
79
- const leasedUntilMs = session.leasedUntil ? Date.parse(session.leasedUntil) : undefined;
80
- if (leasedUntilMs !== undefined && Number.isFinite(leasedUntilMs) && leasedUntilMs > Date.now()) {
90
+ if (isLeaseActive(session)) {
91
+ if (session.workerId === this.workerId) {
92
+ return "owned";
93
+ }
81
94
  this.emitStaleLeaseInvariantIfRunnable(projectId, linearIssueId);
82
95
  return "skip";
83
96
  }
@@ -86,11 +99,16 @@ export class IssueSessionLeaseService {
86
99
  }
87
100
  return this.acquire(projectId, linearIssueId) ? true : "skip";
88
101
  }
89
- async reclaimForeignRecoveryLeaseIfSafe(run, issue) {
90
- const key = this.issueSessionLeaseKey(run.projectId, run.linearIssueId);
91
- if (this.activeSessionLeases.has(key)) {
92
- return false;
93
- }
102
+ /**
103
+ * Post-crash recovery shortcut: take over a foreign (other-worker) lease on
104
+ * an issue whose active run needs recovery, without waiting for full TTL
105
+ * expiry. Safe purely on heartbeat staleness — post-phase-B invariants make
106
+ * the recovery path idempotent (settleRun is idempotent, slot-clearing has
107
+ * exactly one owner, recovery is detect → RunFailurePolicy → settleRun) —
108
+ * so no Codex thread probing is needed. A holder that has not heartbeat
109
+ * for 2x the heartbeat budget is presumed dead.
110
+ */
111
+ reclaimForeignRecoveryLeaseIfSafe(run, issue) {
94
112
  const session = this.db.issueSessions.getIssueSession(run.projectId, run.linearIssueId);
95
113
  if (!session?.leaseId || !session.workerId || session.workerId === this.workerId) {
96
114
  return false;
@@ -98,20 +116,12 @@ export class IssueSessionLeaseService {
98
116
  if (issue.activeRunId !== run.id) {
99
117
  return false;
100
118
  }
101
- let safeToReclaim = !run.threadId;
102
- if (!safeToReclaim && run.threadId) {
103
- try {
104
- const thread = await this.readThreadWithRetry(run.threadId, 1);
105
- const latestTurn = getThreadTurns(thread).at(-1);
106
- safeToReclaim = thread.status === "notLoaded"
107
- || latestTurn?.status === "interrupted"
108
- || latestTurn?.status === "completed";
109
- }
110
- catch {
111
- safeToReclaim = true;
112
- }
113
- }
114
- if (!safeToReclaim) {
119
+ const leasedUntilMs = session.leasedUntil ? Date.parse(session.leasedUntil) : Number.NaN;
120
+ // A lease row without a parseable leasedUntil cannot prove a live holder.
121
+ const heartbeatAgeMs = Number.isFinite(leasedUntilMs)
122
+ ? Date.now() - (leasedUntilMs - ISSUE_SESSION_LEASE_MS)
123
+ : Number.POSITIVE_INFINITY;
124
+ if (heartbeatAgeMs < FOREIGN_LEASE_RECLAIM_STALENESS_MS) {
115
125
  return false;
116
126
  }
117
127
  const leaseId = this.forceAcquire(run.projectId, run.linearIssueId);
@@ -123,6 +133,7 @@ export class IssueSessionLeaseService {
123
133
  runId: run.id,
124
134
  previousWorkerId: session.workerId,
125
135
  previousLeaseId: session.leaseId,
136
+ heartbeatAgeMs: Math.round(heartbeatAgeMs),
126
137
  reclaimedLeaseId: leaseId,
127
138
  }, "Reclaimed foreign issue-session lease for active-run recovery");
128
139
  emitTelemetry(this.telemetry, {
@@ -138,43 +149,29 @@ export class IssueSessionLeaseService {
138
149
  return true;
139
150
  }
140
151
  heartbeat(projectId, linearIssueId) {
141
- const key = this.issueSessionLeaseKey(projectId, linearIssueId);
142
- const leaseId = this.activeSessionLeases.get(key) ?? this.db.issueSessions.getIssueSession(projectId, linearIssueId)?.leaseId;
143
- if (!leaseId)
152
+ const session = this.db.issueSessions.getIssueSession(projectId, linearIssueId);
153
+ // Only this worker's lease may be renewed: extending a foreign holder's
154
+ // lease would let a launch proceed against a lease we do not hold.
155
+ if (!session?.leaseId || session.workerId !== this.workerId) {
144
156
  return false;
145
- const renewed = this.db.issueSessions.renewIssueSessionLease({
157
+ }
158
+ return this.db.issueSessions.renewIssueSessionLease({
146
159
  projectId,
147
160
  linearIssueId,
148
- leaseId,
161
+ leaseId: session.leaseId,
149
162
  leasedUntil: new Date(Date.now() + ISSUE_SESSION_LEASE_MS).toISOString(),
150
163
  });
151
- if (renewed) {
152
- this.activeSessionLeases.set(key, leaseId);
153
- return true;
154
- }
155
- this.activeSessionLeases.delete(key);
156
- return false;
157
164
  }
158
165
  release(projectId, linearIssueId) {
159
- const key = this.issueSessionLeaseKey(projectId, linearIssueId);
160
- const leaseId = this.getValidatedLocalLeaseId(projectId, linearIssueId);
161
- this.db.issueSessions.releaseIssueSessionLease(projectId, linearIssueId, leaseId);
162
- this.activeSessionLeases.delete(key);
163
- this.emitLease("lease.released", projectId, linearIssueId, leaseId);
164
- }
165
- getValidatedLocalLeaseId(projectId, linearIssueId) {
166
- const key = this.issueSessionLeaseKey(projectId, linearIssueId);
167
- const leaseId = this.activeSessionLeases.get(key);
168
- if (!leaseId)
169
- return undefined;
170
- if (this.db.issueSessions.hasActiveIssueSessionLease(projectId, linearIssueId, leaseId)) {
171
- return leaseId;
166
+ const session = this.db.issueSessions.getIssueSession(projectId, linearIssueId);
167
+ const ownLeaseId = session?.workerId === this.workerId ? session.leaseId : undefined;
168
+ if (!ownLeaseId && session?.leaseId && isLeaseActive(session)) {
169
+ // An active foreign lease is not ours to clear; expired leftovers are
170
+ // swept by releaseExpiredIssueSessionLeases.
171
+ return;
172
172
  }
173
- this.activeSessionLeases.delete(key);
174
- return undefined;
175
- }
176
- issueSessionLeaseKey(projectId, linearIssueId) {
177
- return `${projectId}:${linearIssueId}`;
173
+ this.db.issueSessions.releaseIssueSessionLease(projectId, linearIssueId, ownLeaseId);
174
+ this.emitLease("lease.released", projectId, linearIssueId, ownLeaseId);
178
175
  }
179
176
  emitLease(type, projectId, linearIssueId, leaseId) {
180
177
  const issue = this.db.issues.getIssue(projectId, linearIssueId);
@@ -206,3 +203,9 @@ export class IssueSessionLeaseService {
206
203
  });
207
204
  }
208
205
  }
206
+ function isLeaseActive(session, now = Date.now()) {
207
+ if (!session.leaseId || !session.leasedUntil)
208
+ return false;
209
+ const leasedUntilMs = Date.parse(session.leasedUntil);
210
+ return Number.isFinite(leasedUntilMs) && leasedUntilMs > now;
211
+ }
@@ -1,4 +1,3 @@
1
- import { derivePatchRelayWaitingReason } from "./waiting-reason.js";
2
1
  export function deriveIssueSessionState(params) {
3
2
  if (params.factoryState === "done")
4
3
  return "done";
@@ -10,9 +9,6 @@ export function deriveIssueSessionState(params) {
10
9
  return "running";
11
10
  return "idle";
12
11
  }
13
- export function deriveIssueSessionWaitingReason(params) {
14
- return derivePatchRelayWaitingReason(params);
15
- }
16
12
  export function deriveIssueSessionWakeReason(params) {
17
13
  if (params.delegatedToPatchRelay === false)
18
14
  return undefined;
@@ -1,9 +1,10 @@
1
1
  import { resolveAwaitingInputReason } from "./awaiting-input-reason.js";
2
+ import { deriveIssueExecutionState } from "./issue-execution-state.js";
2
3
  export function isUndelegatedPausedIssue(issue) {
3
- return issue.delegatedToPatchRelay === false
4
- && issue.factoryState !== "done"
5
- && issue.factoryState !== "failed"
6
- && issue.factoryState !== "escalated";
4
+ return deriveIssueExecutionState({
5
+ delegatedToPatchRelay: issue.delegatedToPatchRelay,
6
+ factoryState: issue.factoryState,
7
+ }).kind === "undelegated";
7
8
  }
8
9
  export function isUndelegatedPausedNoPrWork(issue) {
9
10
  return isUndelegatedPausedIssue(issue)
@@ -82,7 +82,6 @@ export class RunOrchestrator {
82
82
  failRunAndClear: (run, message, nextState) => this.failRunAndClear(run, message, nextState),
83
83
  restoreIdleWorktree: (issue) => this.restoreIdleWorktree(issue),
84
84
  };
85
- activeSessionLeases;
86
85
  botIdentity;
87
86
  wakeDispatcher;
88
87
  logger;
@@ -124,8 +123,7 @@ export class RunOrchestrator {
124
123
  this.worktreeManager = new WorktreeManager(config);
125
124
  this.codexRuntimeConfig = config.runner.codex;
126
125
  this.linearSync = new LinearSessionSync(config, db, linearProvider, logger, feed);
127
- this.leaseService = new IssueSessionLeaseService(db, logger, this.workerId, this.threadPorts.readThreadWithRetry, telemetry);
128
- this.activeSessionLeases = this.leaseService.activeSessionLeases;
126
+ this.leaseService = new IssueSessionLeaseService(db, logger, this.workerId, telemetry);
129
127
  this.runCompletionPolicy = new RunCompletionPolicy(config, db, logger, this.leasePorts.withHeldLease);
130
128
  this.completionCheck = new CompletionCheckService(codex, logger);
131
129
  this.issueTriage = new IssueTriageService(codex, logger);
@@ -648,7 +646,7 @@ export class RunOrchestrator {
648
646
  if (!issue)
649
647
  return;
650
648
  let recoveryLease = this.claimLeaseForReconciliation(run.projectId, run.linearIssueId);
651
- if (recoveryLease === "skip" && await this.reclaimForeignRecoveryLeaseIfSafe(run, issue)) {
649
+ if (recoveryLease === "skip" && this.reclaimForeignRecoveryLeaseIfSafe(run, issue)) {
652
650
  recoveryLease = true;
653
651
  }
654
652
  if (recoveryLease === "skip")
@@ -725,8 +723,8 @@ export class RunOrchestrator {
725
723
  claimLeaseForReconciliation(projectId, linearIssueId) {
726
724
  return this.leaseService.claimForReconciliation(projectId, linearIssueId);
727
725
  }
728
- async reclaimForeignRecoveryLeaseIfSafe(run, issue) {
729
- return await this.leaseService.reclaimForeignRecoveryLeaseIfSafe(run, issue);
726
+ reclaimForeignRecoveryLeaseIfSafe(run, issue) {
727
+ return this.leaseService.reclaimForeignRecoveryLeaseIfSafe(run, issue);
730
728
  }
731
729
  heartbeatIssueSessionLease(projectId, linearIssueId) {
732
730
  return this.leaseService.heartbeat(projectId, linearIssueId);
@@ -137,7 +137,11 @@ export class TrackedIssueListQuery {
137
137
  ...(row.last_blocking_review_head_sha !== null ? { lastBlockingReviewHeadSha: String(row.last_blocking_review_head_sha) } : {}),
138
138
  ...(row.last_github_failure_check_name !== null ? { latestFailureCheckName: String(row.last_github_failure_check_name) } : {}),
139
139
  });
140
- const waitingReason = detachedActiveRun ? derivedWaitingReason : sessionWaitingReason ?? derivedWaitingReason;
140
+ // The derivation (issue-execution-state.ts via waiting-reason.ts) is the
141
+ // single source; the stored session projection is only a fallback for
142
+ // rows whose live facts derive no reason. A detached active run means
143
+ // the projection is stale, so it is not consulted at all.
144
+ const waitingReason = derivedWaitingReason ?? (detachedActiveRun ? undefined : sessionWaitingReason);
141
145
  const latestRun = row.latest_run_type !== null && row.latest_run_status !== null
142
146
  ? {
143
147
  id: 0,
@@ -1,4 +1,4 @@
1
- import { hasOpenPr } from "./pr-state.js";
1
+ import { deriveIssueExecutionState, } from "./issue-execution-state.js";
2
2
  export const PATCHRELAY_WAITING_REASONS = {
3
3
  activeWork: "PatchRelay is actively working",
4
4
  automationPaused: "PatchRelay automation is paused because the issue is undelegated",
@@ -16,82 +16,82 @@ export const PATCHRELAY_WAITING_REASONS = {
16
16
  waitingForOperatorIntervention: "Waiting on operator intervention",
17
17
  waitingForExternalReview: "Waiting on external review",
18
18
  };
19
+ /**
20
+ * `waitingReason` is a pure function of {@link IssueExecutionState} — the
21
+ * union (issue-execution-state.ts) is the single derivation of "why is this
22
+ * issue not moving"; this module only renders it for operators.
23
+ */
19
24
  export function derivePatchRelayWaitingReason(params) {
20
- if (params.delegatedToPatchRelay === false && params.factoryState !== "done" && params.factoryState !== "failed" && params.factoryState !== "escalated") {
21
- return params.factoryState === "awaiting_queue" || (hasLiveOpenPr(params.prNumber, params.prState) && params.prReviewState === "approved")
22
- ? PATCHRELAY_WAITING_REASONS.automationPausedDownstream
23
- : PATCHRELAY_WAITING_REASONS.automationPaused;
24
- }
25
- if (params.activeRunType) {
26
- if (hasOpenPr(params.prNumber, params.prState) && (params.factoryState === "pr_open" || params.factoryState === "awaiting_queue")) {
27
- return PATCHRELAY_WAITING_REASONS.finalizingPublishedPr;
28
- }
29
- if (params.factoryState === "done") {
30
- return PATCHRELAY_WAITING_REASONS.finalizingMergedChange;
31
- }
32
- return `PatchRelay is running ${humanize(params.activeRunType)}`;
33
- }
34
- if (params.activeRunId !== undefined) {
35
- return PATCHRELAY_WAITING_REASONS.activeWork;
36
- }
37
- if (params.orchestrationSettleUntil) {
38
- const settleAt = Date.parse(params.orchestrationSettleUntil);
39
- if (Number.isFinite(settleAt) && settleAt > Date.now()) {
25
+ return waitingReasonForExecutionState(deriveIssueExecutionState(params));
26
+ }
27
+ export function waitingReasonForExecutionState(state) {
28
+ switch (state.kind) {
29
+ case "undelegated":
30
+ return state.downstreamMayContinue
31
+ ? PATCHRELAY_WAITING_REASONS.automationPausedDownstream
32
+ : PATCHRELAY_WAITING_REASONS.automationPaused;
33
+ case "running":
34
+ // An inconsistent row still describes what is observably happening (a run
35
+ // occupies the slot); reconcilers act on the union kind, not this string.
36
+ case "inconsistent":
37
+ return describeRun(state.run);
38
+ case "settling":
40
39
  return PATCHRELAY_WAITING_REASONS.waitingForChildSettle;
41
- }
42
- }
43
- const blockedByKeys = (params.blockedByKeys ?? []).filter((value) => value.trim().length > 0);
44
- if (blockedByKeys.length > 0) {
45
- return `Blocked by ${blockedByKeys.join(", ")}`;
46
- }
47
- const checkName = params.latestFailureCheckName ?? "CI";
48
- switch (params.factoryState) {
49
- case "awaiting_input":
40
+ case "blocked":
41
+ return `Blocked by ${state.blockedByKeys.join(", ")}`;
42
+ case "waiting_input":
50
43
  return PATCHRELAY_WAITING_REASONS.waitingForOperatorInput;
51
- case "changes_requested":
52
- return PATCHRELAY_WAITING_REASONS.waitingForReviewFeedback;
53
- case "repairing_ci":
54
- return `Waiting to repair ${checkName}`;
55
- case "repairing_queue":
56
- return PATCHRELAY_WAITING_REASONS.waitingForMergeStewardRepair;
57
- case "awaiting_queue":
58
- return PATCHRELAY_WAITING_REASONS.waitingForDownstreamAutomation;
59
- case "done":
60
- return PATCHRELAY_WAITING_REASONS.workComplete;
61
- case "failed":
62
- case "escalated":
63
- return PATCHRELAY_WAITING_REASONS.waitingForOperatorIntervention;
64
- default:
44
+ case "awaiting_followup":
45
+ switch (state.followup) {
46
+ case "review_fix":
47
+ return PATCHRELAY_WAITING_REASONS.waitingForReviewFeedback;
48
+ case "ci_repair":
49
+ return `Waiting to repair ${state.checkName ?? "CI"}`;
50
+ case "queue_repair":
51
+ return PATCHRELAY_WAITING_REASONS.waitingForMergeStewardRepair;
52
+ }
65
53
  break;
66
- }
67
- if (params.prCheckStatus === "failed" || params.prCheckStatus === "failure") {
68
- return `${checkName} failed`;
69
- }
70
- if (params.prReviewState === "changes_requested") {
71
- if (params.prCheckStatus === "passed" || params.prCheckStatus === "success") {
72
- if (params.prHeadSha
73
- && params.lastBlockingReviewHeadSha
74
- && params.prHeadSha !== params.lastBlockingReviewHeadSha) {
75
- return PATCHRELAY_WAITING_REASONS.waitingForReviewOnNewHead;
54
+ case "terminal":
55
+ return state.outcome === "done"
56
+ ? PATCHRELAY_WAITING_REASONS.workComplete
57
+ : PATCHRELAY_WAITING_REASONS.waitingForOperatorIntervention;
58
+ case "idle_awaiting_external":
59
+ switch (state.waitingOn) {
60
+ case "merge_queue":
61
+ case "downstream_automation":
62
+ return PATCHRELAY_WAITING_REASONS.waitingForDownstreamAutomation;
63
+ case "ci_failure":
64
+ return `${state.checkName ?? "CI"} failed`;
65
+ case "review_of_new_head":
66
+ return PATCHRELAY_WAITING_REASONS.waitingForReviewOnNewHead;
67
+ case "blocking_review_same_head":
68
+ return PATCHRELAY_WAITING_REASONS.sameHeadStillBlocked;
69
+ case "review_feedback":
70
+ return PATCHRELAY_WAITING_REASONS.waitingForReviewFeedback;
71
+ case "external_review":
72
+ return PATCHRELAY_WAITING_REASONS.waitingForExternalReview;
76
73
  }
77
- return PATCHRELAY_WAITING_REASONS.sameHeadStillBlocked;
78
- }
79
- return PATCHRELAY_WAITING_REASONS.waitingForReviewFeedback;
80
- }
81
- if (params.prReviewState === "approved") {
82
- return PATCHRELAY_WAITING_REASONS.waitingForDownstreamAutomation;
74
+ break;
75
+ case "ready":
76
+ return `Ready to run ${humanize(state.pendingRunType)}`;
77
+ case "idle":
78
+ return undefined;
83
79
  }
84
- if (hasOpenPr(params.prNumber, params.prState)) {
85
- return PATCHRELAY_WAITING_REASONS.waitingForExternalReview;
80
+ return undefined;
81
+ }
82
+ function describeRun(run) {
83
+ if (!run.runType) {
84
+ return PATCHRELAY_WAITING_REASONS.activeWork;
86
85
  }
87
- if (params.pendingRunType) {
88
- return `Ready to run ${humanize(params.pendingRunType)}`;
86
+ switch (run.phase) {
87
+ case "finalizing_published_pr":
88
+ return PATCHRELAY_WAITING_REASONS.finalizingPublishedPr;
89
+ case "finalizing_merged_change":
90
+ return PATCHRELAY_WAITING_REASONS.finalizingMergedChange;
91
+ case "working":
92
+ return `PatchRelay is running ${humanize(run.runType)}`;
89
93
  }
90
- return undefined;
91
94
  }
92
95
  function humanize(value) {
93
96
  return value.replaceAll("_", " ");
94
97
  }
95
- function hasLiveOpenPr(prNumber, prState) {
96
- return prNumber !== undefined && (prState === undefined || prState === "open");
97
- }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "patchrelay",
3
- "version": "0.79.0",
3
+ "version": "0.80.1",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "repository": {