patchrelay 0.78.1 → 0.80.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,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;
@@ -13,19 +13,13 @@ function hasRecoveredContext(context) {
13
13
  function hasLocalHumanContext(context) {
14
14
  if (hasRecoveredContext(context))
15
15
  return true;
16
- for (const key of ["promptContext", "promptBody", "operatorPrompt", "userComment"]) {
17
- const value = context?.[key];
16
+ for (const value of [context?.promptContext, context?.promptBody, context?.operatorPrompt, context?.userComment]) {
18
17
  if (typeof value === "string" && value.trim().length > 0)
19
18
  return true;
20
19
  }
21
20
  if (!Array.isArray(context?.followUps))
22
21
  return false;
23
- return context.followUps.some((entry) => {
24
- if (!entry || typeof entry !== "object")
25
- return false;
26
- const text = entry.text;
27
- return typeof text === "string" && text.trim().length > 0;
28
- });
22
+ return context.followUps.some((entry) => typeof entry.text === "string" && entry.text.trim().length > 0);
29
23
  }
30
24
  function activitySortKey(activity) {
31
25
  const parsed = activity.updatedAt ? Date.parse(activity.updatedAt) : NaN;
@@ -1,19 +1,25 @@
1
1
  import { buildRequestedChangesWakeIdentity } from "./reactive-wake-keys.js";
2
- function parseObjectJson(value) {
2
+ import { tryParseRunContextValue } from "./run-context.js";
3
+ // Boundary over the stored failure/incident columns: malformed JSON or a
4
+ // schema-rejected legacy shape degrades to "no context" (pre-existing
5
+ // behavior of the old parseObjectJson for malformed JSON; this pure module
6
+ // has no logger to warn through).
7
+ function parseRunContextColumn(value) {
3
8
  if (!value)
4
9
  return undefined;
10
+ let parsed;
5
11
  try {
6
- const parsed = JSON.parse(value);
7
- return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : undefined;
12
+ parsed = JSON.parse(value);
8
13
  }
9
14
  catch {
10
15
  return undefined;
11
16
  }
17
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? tryParseRunContextValue(parsed) : undefined;
12
18
  }
13
19
  export function buildOperatorRetryEvent(issue, runType, source = "operator_retry") {
14
20
  if (runType === "queue_repair") {
15
- const queueIncident = parseObjectJson(issue.lastQueueIncidentJson);
16
- const failureContext = parseObjectJson(issue.lastGitHubFailureContextJson);
21
+ const queueIncident = parseRunContextColumn(issue.lastQueueIncidentJson);
22
+ const failureContext = parseRunContextColumn(issue.lastGitHubFailureContextJson);
17
23
  return {
18
24
  eventType: "merge_steward_incident",
19
25
  eventJson: JSON.stringify({
@@ -32,7 +38,7 @@ export function buildOperatorRetryEvent(issue, runType, source = "operator_retry
32
38
  };
33
39
  }
34
40
  if (runType === "ci_repair") {
35
- const failureContext = parseObjectJson(issue.lastGitHubFailureContextJson);
41
+ const failureContext = parseRunContextColumn(issue.lastGitHubFailureContextJson);
36
42
  return {
37
43
  eventType: "settled_red_ci",
38
44
  eventJson: JSON.stringify({
@@ -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)
@@ -121,14 +121,8 @@ function summarizeRelationEntries(entries, options) {
121
121
  return lines;
122
122
  }
123
123
  function buildIssueTopology(context) {
124
- const unresolvedBlockers = Array.isArray(context?.unresolvedBlockers)
125
- ? context.unresolvedBlockers.filter((entry) => Boolean(entry) && typeof entry === "object")
126
- : [];
127
- const childIssues = Array.isArray(context?.childIssues)
128
- ? context.childIssues.filter((entry) => Boolean(entry) && typeof entry === "object")
129
- : Array.isArray(context?.trackedDependents)
130
- ? context.trackedDependents.filter((entry) => Boolean(entry) && typeof entry === "object")
131
- : [];
124
+ const unresolvedBlockers = context?.unresolvedBlockers ?? [];
125
+ const childIssues = context?.childIssues ?? context?.trackedDependents ?? [];
132
126
  if (unresolvedBlockers.length === 0 && childIssues.length === 0) {
133
127
  return [];
134
128
  }
@@ -165,14 +159,8 @@ function buildConstraints(issue, context) {
165
159
  ].join("\n");
166
160
  }
167
161
  function buildOrchestrationConstraints(context) {
168
- const unresolvedBlockers = Array.isArray(context?.unresolvedBlockers)
169
- ? context.unresolvedBlockers.filter((entry) => Boolean(entry) && typeof entry === "object")
170
- : [];
171
- const childIssues = Array.isArray(context?.childIssues)
172
- ? context.childIssues.filter((entry) => Boolean(entry) && typeof entry === "object")
173
- : Array.isArray(context?.trackedDependents)
174
- ? context.trackedDependents.filter((entry) => Boolean(entry) && typeof entry === "object")
175
- : [];
162
+ const unresolvedBlockers = context?.unresolvedBlockers ?? [];
163
+ const childIssues = context?.childIssues ?? context?.trackedDependents ?? [];
176
164
  return [
177
165
  "## Constraints",
178
166
  "",
@@ -201,13 +189,11 @@ function buildOrchestrationConstraints(context) {
201
189
  ].join("\n");
202
190
  }
203
191
  function buildHumanContextLines(context) {
204
- const promptContext = typeof context?.promptContext === "string" ? context.promptContext.trim() : "";
205
- const latestPrompt = typeof context?.promptBody === "string" ? context.promptBody.trim() : "";
206
- const operatorPrompt = typeof context?.operatorPrompt === "string" ? context.operatorPrompt.trim() : "";
207
- const userComment = typeof context?.userComment === "string" ? context.userComment.trim() : "";
208
- const linearAgentActivityContext = typeof context?.linearAgentActivityContext === "string"
209
- ? context.linearAgentActivityContext.trim()
210
- : "";
192
+ const promptContext = context?.promptContext?.trim() ?? "";
193
+ const latestPrompt = context?.promptBody?.trim() ?? "";
194
+ const operatorPrompt = context?.operatorPrompt?.trim() ?? "";
195
+ const userComment = context?.userComment?.trim() ?? "";
196
+ const linearAgentActivityContext = context?.linearAgentActivityContext?.trim() ?? "";
211
197
  const lines = [];
212
198
  if (promptContext) {
213
199
  lines.push("Linear session context:", promptContext, "");
@@ -235,35 +221,28 @@ function resolveRequestedChangesMode(runType, context) {
235
221
  : "address_review_feedback";
236
222
  }
237
223
  function readReviewFixComments(context) {
238
- const raw = context?.reviewComments;
239
- if (!Array.isArray(raw)) {
240
- return [];
241
- }
242
224
  const comments = [];
243
- for (const entry of raw) {
244
- if (!entry || typeof entry !== "object")
245
- continue;
246
- const record = entry;
247
- const body = typeof record.body === "string" ? record.body.trim() : "";
225
+ for (const record of context?.reviewComments ?? []) {
226
+ const body = record.body?.trim() ?? "";
248
227
  if (!body)
249
228
  continue;
250
229
  comments.push({
251
230
  body,
252
- ...(typeof record.path === "string" ? { path: record.path } : {}),
253
- ...(typeof record.line === "number" ? { line: record.line } : {}),
254
- ...(typeof record.side === "string" ? { side: record.side } : {}),
255
- ...(typeof record.startLine === "number" ? { startLine: record.startLine } : {}),
256
- ...(typeof record.startSide === "string" ? { startSide: record.startSide } : {}),
257
- ...(typeof record.url === "string" ? { url: record.url } : {}),
258
- ...(typeof record.authorLogin === "string" ? { authorLogin: record.authorLogin } : {}),
231
+ ...(record.path !== undefined ? { path: record.path } : {}),
232
+ ...(record.line !== undefined ? { line: record.line } : {}),
233
+ ...(record.side !== undefined ? { side: record.side } : {}),
234
+ ...(record.startLine !== undefined ? { startLine: record.startLine } : {}),
235
+ ...(record.startSide !== undefined ? { startSide: record.startSide } : {}),
236
+ ...(record.url !== undefined ? { url: record.url } : {}),
237
+ ...(record.authorLogin !== undefined ? { authorLogin: record.authorLogin } : {}),
259
238
  });
260
239
  }
261
240
  return comments;
262
241
  }
263
242
  function buildStructuredReviewContext(context) {
264
- const reviewId = typeof context?.reviewId === "number" ? context.reviewId : undefined;
265
- const reviewCommitId = typeof context?.reviewCommitId === "string" ? context.reviewCommitId : undefined;
266
- const reviewUrl = typeof context?.reviewUrl === "string" ? context.reviewUrl : undefined;
243
+ const reviewId = context?.reviewId;
244
+ const reviewCommitId = context?.reviewCommitId;
245
+ const reviewUrl = context?.reviewUrl;
267
246
  const reviewComments = readReviewFixComments(context);
268
247
  const degraded = context?.reviewContextDegraded === true;
269
248
  if (!degraded && !reviewId && !reviewCommitId && !reviewUrl && reviewComments.length === 0) {
@@ -271,9 +250,8 @@ function buildStructuredReviewContext(context) {
271
250
  }
272
251
  const lines = ["## Structured Review Context", ""];
273
252
  if (degraded) {
274
- const reason = typeof context?.reviewContextDegradedReason === "string" && context.reviewContextDegradedReason.trim()
275
- ? context.reviewContextDegradedReason.trim()
276
- : "GitHub requested-changes context could not be refreshed before launch.";
253
+ const reason = context?.reviewContextDegradedReason?.trim()
254
+ || "GitHub requested-changes context could not be refreshed before launch.";
277
255
  lines.push("GitHub review context refresh: degraded", reason, "Do not assume cached review details are current. Re-read the PR review in GitHub before making review-fix changes.");
278
256
  }
279
257
  if (reviewId !== undefined)
@@ -311,17 +289,15 @@ function buildRequestedChangesContext(runType, context) {
311
289
  lines.push("Branch upkeep is required on the existing PR branch.", "Goal: restore merge readiness on the current branch. Push a newer head only when the work actually changes the diff against the base; do not republish a patch-id-equivalent head.");
312
290
  }
313
291
  else {
314
- const reviewer = typeof context?.reviewerName === "string" ? context.reviewerName : undefined;
315
- const reviewBody = typeof context?.reviewBody === "string" ? context.reviewBody.trim() : "";
292
+ const reviewer = context?.reviewerName;
293
+ const reviewBody = context?.reviewBody?.trim() ?? "";
316
294
  lines.push("Requested changes on the existing PR branch.", "Goal: restore review readiness on the current PR branch. Push a newer head only when the fix actually changes the diff; if the reviewer-pass produces only comments, test wording, or PR-body changes, edit the PR body via `gh pr edit` instead.", "Address the real concern behind the feedback and verify nearby invariants in the touched flow before you publish.", "For each review comment, identify the resource, epoch, or token it touches (e.g. session, capture, route, persistence handle, in-flight turn id), enumerate the other transitions that share that same resource, and verify each one before pushing — not just the exact path called out. If you find an adjacent transition that violates the same invariant, fix it in this iteration rather than waiting for the reviewer to surface it next round.", reviewer ? `Reviewer: ${reviewer}` : "", reviewBody ? `Review summary:\n${reviewBody}` : "");
317
295
  appendStructuredReviewContext(lines, context);
318
296
  }
319
297
  return lines.join("\n").trim();
320
298
  }
321
299
  function buildCiRepairContext(context) {
322
- const snapshot = context?.ciSnapshot && typeof context.ciSnapshot === "object"
323
- ? context.ciSnapshot
324
- : undefined;
300
+ const snapshot = context?.ciSnapshot;
325
301
  return [
326
302
  "Settled CI failure on the existing PR branch.",
327
303
  "Goal: restore CI readiness and push a branch that is likely to pass the next full CI run.",
@@ -346,31 +322,26 @@ function buildCiRepairContext(context) {
346
322
  ].filter(Boolean).join("\n");
347
323
  }
348
324
  function appendQueueRepairContext(lines, context) {
349
- const queueContext = context?.mergeQueueContext;
350
- if (!queueContext || typeof queueContext !== "object") {
325
+ const record = context?.mergeQueueContext;
326
+ if (!record) {
351
327
  return;
352
328
  }
353
- const record = queueContext;
354
- const conflictingFiles = Array.isArray(record.conflictingFiles)
355
- ? record.conflictingFiles.filter((entry) => typeof entry === "string" && entry.trim().length > 0)
356
- : [];
357
- const operatorHints = Array.isArray(record.operatorHints)
358
- ? record.operatorHints.filter((entry) => typeof entry === "string" && entry.trim().length > 0)
359
- : [];
329
+ const conflictingFiles = (record.conflictingFiles ?? []).filter((entry) => entry.trim().length > 0);
330
+ const operatorHints = (record.operatorHints ?? []).filter((entry) => entry.trim().length > 0);
360
331
  lines.push("## Merge Queue Context", "");
361
- if (typeof record.baseBranch === "string") {
332
+ if (record.baseBranch !== undefined) {
362
333
  lines.push(`Base branch: ${record.baseBranch}`);
363
334
  }
364
- if (typeof record.baseSha === "string") {
335
+ if (record.baseSha !== undefined) {
365
336
  lines.push(`Base SHA at eviction: ${record.baseSha}`);
366
337
  }
367
- if (typeof record.mergeCommitSha === "string") {
338
+ if (record.mergeCommitSha !== undefined) {
368
339
  lines.push(`Synthetic merge commit SHA: ${record.mergeCommitSha}`);
369
340
  }
370
- if (typeof record.checkRunUrl === "string") {
341
+ if (record.checkRunUrl !== undefined) {
371
342
  lines.push(`Steward check run: ${record.checkRunUrl}`);
372
343
  }
373
- if (typeof record.incidentSummary === "string") {
344
+ if (record.incidentSummary !== undefined) {
374
345
  lines.push(`Steward summary: ${record.incidentSummary}`);
375
346
  }
376
347
  if (conflictingFiles.length > 0) {
@@ -391,11 +362,9 @@ function buildQueueRepairContext(context) {
391
362
  }
392
363
  function buildFollowUpContextLines(issue, runType, context) {
393
364
  const prContext = derivePrDisplayContext(issue);
394
- const wakeReason = typeof context?.wakeReason === "string" ? context.wakeReason : undefined;
395
- const followUps = Array.isArray(context?.followUps) ? context.followUps : [];
396
- const followUpLines = followUps
397
- .filter((entry) => Boolean(entry) && typeof entry === "object")
398
- .map((entry) => `${String(entry.type ?? "follow_up")} from ${String(entry.author ?? "unknown")}: ${String(entry.text ?? "").trim()}`.trim())
365
+ const wakeReason = context?.wakeReason;
366
+ const followUpLines = (context?.followUps ?? [])
367
+ .map((entry) => `${entry.type ?? "follow_up"} from ${entry.author ?? "unknown"}: ${(entry.text ?? "").trim()}`.trim())
399
368
  .filter((line) => !line.endsWith(":"));
400
369
  const lines = [];
401
370
  const turnReason = wakeReason === "direct_reply"
@@ -418,17 +387,15 @@ function buildFollowUpContextLines(issue, runType, context) {
418
387
  ? "A human follow-up comment arrived after the previous turn."
419
388
  : `Continue the existing ${runType} run from the latest issue state.`;
420
389
  lines.push(`Turn reason: ${turnReason}`);
421
- if (wakeReason === "completion_check_continue" && typeof context?.completionCheckSummary === "string" && context.completionCheckSummary.trim()) {
390
+ if (wakeReason === "completion_check_continue" && context?.completionCheckSummary?.trim()) {
422
391
  lines.push(`Completion check summary: ${context.completionCheckSummary.trim()}`);
423
392
  }
424
393
  if (context?.preserveDirtyWorktree === true) {
425
394
  lines.push("", "Unpublished local work:", "PatchRelay detected that the previous repair turn ended with uncommitted changes in this worktree.", "Do not reset, clean, stash-drop, or otherwise discard the current worktree. Inspect the existing local diff, keep the intended in-scope repair, then commit and push a fresh PR head.");
426
- if (typeof context.dirtyWorktreeSummary === "string" && context.dirtyWorktreeSummary.trim()) {
395
+ if (context.dirtyWorktreeSummary?.trim()) {
427
396
  lines.push(`Dirty worktree summary: ${context.dirtyWorktreeSummary.trim()}`);
428
397
  }
429
- const changedPaths = Array.isArray(context.dirtyWorktreeChangedPaths)
430
- ? context.dirtyWorktreeChangedPaths.filter((entry) => typeof entry === "string" && entry.trim().length > 0)
431
- : [];
398
+ const changedPaths = (context.dirtyWorktreeChangedPaths ?? []).filter((entry) => entry.trim().length > 0);
432
399
  if (changedPaths.length > 0) {
433
400
  lines.push("Changed paths:");
434
401
  changedPaths.slice(0, 12).forEach((entry) => lines.push(`- ${entry}`));
@@ -443,16 +410,16 @@ function buildFollowUpContextLines(issue, runType, context) {
443
410
  }
444
411
  if (context?.replacementPrRequired === true) {
445
412
  lines.push("", "Previous PR facts:");
446
- if (typeof context.previousPrNumber === "number") {
413
+ if (context.previousPrNumber !== undefined) {
447
414
  lines.push(`Previous PR: #${context.previousPrNumber} (replacement PR needed)`);
448
415
  }
449
- if (typeof context.previousPrUrl === "string") {
416
+ if (context.previousPrUrl !== undefined) {
450
417
  lines.push(`Previous PR URL: ${context.previousPrUrl}`);
451
418
  }
452
- if (typeof context.previousPrState === "string") {
419
+ if (context.previousPrState !== undefined) {
453
420
  lines.push(`Previous PR state: ${context.previousPrState}`);
454
421
  }
455
- if (typeof context.previousPrHeadSha === "string") {
422
+ if (context.previousPrHeadSha !== undefined) {
456
423
  lines.push(`Previous PR head SHA: ${context.previousPrHeadSha}`);
457
424
  }
458
425
  lines.push("Create a fresh replacement PR for the new requested changes; do not mutate or republish the completed PR.");
@@ -476,7 +443,7 @@ function buildFollowUpContextLines(issue, runType, context) {
476
443
  : "";
477
444
  lines.push("", prHeading, `Fact freshness: ${context?.githubFactsFresh === true
478
445
  ? "refreshed immediately before this turn was created."
479
- : "may now be stale; refresh before making irreversible decisions."}`, prLine, issue.prHeadSha ? `Current relevant head SHA: ${issue.prHeadSha}` : "", issue.prReviewState ? `Current review state: ${issue.prReviewState}` : "", typeof context?.mergeStateStatus === "string" ? `Merge state against ${String(context?.baseBranch ?? "main")}: ${String(context.mergeStateStatus)}` : "");
446
+ : "may now be stale; refresh before making irreversible decisions."}`, prLine, issue.prHeadSha ? `Current relevant head SHA: ${issue.prHeadSha}` : "", issue.prReviewState ? `Current review state: ${issue.prReviewState}` : "", context?.mergeStateStatus !== undefined ? `Merge state against ${context?.baseBranch ?? "main"}: ${context.mergeStateStatus}` : "");
480
447
  }
481
448
  return lines.filter(Boolean);
482
449
  }
@@ -670,7 +637,7 @@ function shouldBuildFollowUpPrompt(runType, context) {
670
637
  return true;
671
638
  if (runType !== "implementation")
672
639
  return true;
673
- const wakeReason = typeof context?.wakeReason === "string" ? context.wakeReason : undefined;
640
+ const wakeReason = context?.wakeReason;
674
641
  return Boolean(wakeReason && wakeReason !== "delegated");
675
642
  }
676
643
  export function resolvePromptLayers(config, runType) {
@@ -1,5 +1,6 @@
1
1
  import { resolveMergeQueueProtocol } from "./merge-queue-protocol.js";
2
2
  import { buildRepairWakeDedupeKey } from "./reactive-wake-keys.js";
3
+ import { serializeRunContext } from "./run-context.js";
3
4
  import { execCommand } from "./utils.js";
4
5
  const WRITER = "queue-health-monitor";
5
6
  const QUEUE_HEALTH_GRACE_MS = 120_000;
@@ -10,8 +11,8 @@ const QUEUE_HEALTH_PROBE_FAILURE_COOLDOWN_MS = 300_000;
10
11
  const IN_REVIEW_STUCK_THRESHOLD_MS = 30 * 60 * 1000;
11
12
  const IN_REVIEW_STUCK_FEED_COOLDOWN_MS = 30 * 60 * 1000;
12
13
  function isDuplicateProbe(issue, context) {
13
- const signature = typeof context?.failureSignature === "string" ? context.failureSignature : undefined;
14
- const headSha = typeof context?.failureHeadSha === "string" ? context.failureHeadSha : undefined;
14
+ const signature = context?.failureSignature;
15
+ const headSha = context?.failureHeadSha;
15
16
  if (!signature)
16
17
  return false;
17
18
  if (context?.requiresFreshHead === true)
@@ -176,7 +177,7 @@ export class QueueHealthMonitor {
176
177
  const probed = probedCommit.outcome === "applied" ? probedCommit.issue : issue;
177
178
  this.advancer.wakeDispatcher.recordEventAndDispatch(issue.projectId, issue.linearIssueId, {
178
179
  eventType: "merge_steward_incident",
179
- eventJson: JSON.stringify(pendingRunContext),
180
+ eventJson: serializeRunContext(pendingRunContext, "queue health repair context"),
180
181
  dedupeKey: buildRepairWakeDedupeKey({
181
182
  scope: "queue_health",
182
183
  runType: "queue_repair",