patchrelay 0.35.10 → 0.35.12

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 (50) hide show
  1. package/README.md +41 -9
  2. package/dist/build-info.json +3 -3
  3. package/dist/cli/args.js +0 -1
  4. package/dist/cli/commands/issues.js +2 -56
  5. package/dist/cli/commands/watch.js +5 -0
  6. package/dist/cli/data.js +110 -47
  7. package/dist/cli/formatters/text.js +6 -90
  8. package/dist/cli/help.js +3 -8
  9. package/dist/cli/index.js +0 -48
  10. package/dist/cli/operator-client.js +0 -82
  11. package/dist/cli/watch/App.js +1 -12
  12. package/dist/cli/watch/HelpBar.js +2 -2
  13. package/dist/cli/watch/IssueDetailView.js +57 -26
  14. package/dist/cli/watch/IssueRow.js +71 -27
  15. package/dist/cli/watch/StatusBar.js +7 -4
  16. package/dist/cli/watch/state-visualization.js +48 -23
  17. package/dist/cli/watch/timeline-builder.js +2 -1
  18. package/dist/cli/watch/use-detail-stream.js +10 -104
  19. package/dist/cli/watch/use-watch-stream.js +11 -102
  20. package/dist/cli/watch/watch-state.js +18 -50
  21. package/dist/codex-thread-utils.js +3 -0
  22. package/dist/db/migrations.js +239 -2
  23. package/dist/db.js +628 -39
  24. package/dist/github-app-token.js +7 -0
  25. package/dist/github-failure-context.js +44 -1
  26. package/dist/github-rollup.js +47 -0
  27. package/dist/github-webhook-handler.js +248 -51
  28. package/dist/github-webhooks.js +5 -0
  29. package/dist/http.js +12 -264
  30. package/dist/idle-reconciliation.js +275 -74
  31. package/dist/issue-query-service.js +221 -129
  32. package/dist/issue-session-events.js +151 -0
  33. package/dist/issue-session.js +99 -0
  34. package/dist/linear-client.js +39 -25
  35. package/dist/linear-session-reporting.js +12 -0
  36. package/dist/linear-session-sync.js +253 -24
  37. package/dist/linear-workflow.js +33 -0
  38. package/dist/merge-queue-protocol.js +0 -51
  39. package/dist/preflight.js +1 -4
  40. package/dist/queue-health-monitor.js +11 -7
  41. package/dist/run-orchestrator.js +1295 -146
  42. package/dist/run-reporting.js +5 -3
  43. package/dist/service.js +279 -102
  44. package/dist/status-note.js +56 -0
  45. package/dist/waiting-reason.js +65 -0
  46. package/dist/webhook-handler.js +270 -79
  47. package/package.json +1 -1
  48. package/dist/cli/commands/feed.js +0 -60
  49. package/dist/cli/watch/FeedView.js +0 -28
  50. package/dist/cli/watch/use-feed-stream.js +0 -92
@@ -23,27 +23,33 @@ const LINEAR_ISSUE_SELECTION = `
23
23
  name
24
24
  }
25
25
  }
26
- blockedBy {
26
+ inverseRelations {
27
27
  nodes {
28
- id
29
- identifier
30
- title
31
- state {
28
+ type
29
+ issue {
32
30
  id
33
- name
34
- type
31
+ identifier
32
+ title
33
+ state {
34
+ id
35
+ name
36
+ type
37
+ }
35
38
  }
36
39
  }
37
40
  }
38
- blocks {
41
+ relations {
39
42
  nodes {
40
- id
41
- identifier
42
- title
43
- state {
43
+ type
44
+ relatedIssue {
44
45
  id
45
- name
46
- type
46
+ identifier
47
+ title
48
+ state {
49
+ id
50
+ name
51
+ type
52
+ }
47
53
  }
48
54
  }
49
55
  }
@@ -92,19 +98,16 @@ export class LinearGraphqlClient {
92
98
  throw new Error(`Linear state "${stateName}" was not found for issue ${issue.identifier ?? issueId}`);
93
99
  }
94
100
  const response = await this.request(`
95
- mutation PatchRelaySetIssueState($id: String!, $stateId: String!) {
96
- issueUpdate(id: $id, input: { stateId: $stateId }) {
101
+ mutation PatchRelaySetIssueState($id: String!, $input: IssueUpdateInput!) {
102
+ issueUpdate(id: $id, input: $input) {
97
103
  success
98
- issue {
99
- ${LINEAR_ISSUE_SELECTION}
100
- }
101
104
  }
102
105
  }
103
- `, { id: issueId, stateId: state.id });
104
- if (!response.issueUpdate.success || !response.issueUpdate.issue) {
106
+ `, { id: issueId, input: { stateId: state.id } });
107
+ if (!response.issueUpdate.success) {
105
108
  throw new Error(`Linear rejected state update for issue ${issue.identifier ?? issueId}`);
106
109
  }
107
- return this.mapIssue(response.issueUpdate.issue);
110
+ return await this.getIssue(issueId);
108
111
  }
109
112
  async upsertIssueComment(params) {
110
113
  if (params.commentId) {
@@ -292,7 +295,10 @@ export class LinearGraphqlClient {
292
295
  }),
293
296
  });
294
297
  if (!response.ok) {
295
- throw new Error(`Linear API request failed with HTTP ${response.status}`);
298
+ const body = (await response.text()).trim();
299
+ throw new Error(body
300
+ ? `Linear API request failed with HTTP ${response.status}: ${body}`
301
+ : `Linear API request failed with HTTP ${response.status}`);
296
302
  }
297
303
  const payload = (await response.json());
298
304
  if (payload.errors?.length) {
@@ -308,6 +314,8 @@ export class LinearGraphqlClient {
308
314
  mapIssue(issue) {
309
315
  const labels = (issue.labels?.nodes ?? []).map((label) => ({ id: label.id, name: label.name }));
310
316
  const teamLabels = (issue.team?.labels?.nodes ?? []).map((label) => ({ id: label.id, name: label.name }));
317
+ const blocksRelations = (issue.relations?.nodes ?? []).filter((relation) => relation.type?.trim().toLowerCase() === "blocks");
318
+ const blockedByRelations = (issue.inverseRelations?.nodes ?? []).filter((relation) => relation.type?.trim().toLowerCase() === "blocks");
311
319
  return {
312
320
  id: issue.id,
313
321
  ...(issue.identifier ? { identifier: issue.identifier } : {}),
@@ -331,8 +339,14 @@ export class LinearGraphqlClient {
331
339
  labelIds: labels.map((label) => label.id),
332
340
  labels,
333
341
  teamLabels,
334
- blockedBy: (issue.blockedBy?.nodes ?? []).map(mapIssueRelation),
335
- blocks: (issue.blocks?.nodes ?? []).map(mapIssueRelation),
342
+ blockedBy: blockedByRelations
343
+ .map((relation) => relation.issue)
344
+ .filter((relation) => Boolean(relation))
345
+ .map(mapIssueRelation),
346
+ blocks: blocksRelations
347
+ .map((relation) => relation.relatedIssue)
348
+ .filter((relation) => Boolean(relation))
349
+ .map(mapIssueRelation),
336
350
  };
337
351
  }
338
352
  resolveLabelIds(issue, names) {
@@ -147,6 +147,18 @@ export function buildMergePrepEscalationActivity(attempts) {
147
147
  };
148
148
  }
149
149
  export function summarizeIssueStateForLinear(issue) {
150
+ switch (issue.sessionState) {
151
+ case "waiting_input":
152
+ return issue.waitingReason ?? (issue.prNumber ? `PR #${issue.prNumber} is waiting for input.` : "Waiting for input.");
153
+ case "running":
154
+ return issue.prNumber ? `PR #${issue.prNumber} is actively running.` : "Actively running.";
155
+ case "idle":
156
+ return issue.waitingReason ?? (issue.prNumber ? `PR #${issue.prNumber} is idle.` : "Idle.");
157
+ case "done":
158
+ return issue.prNumber ? `PR #${issue.prNumber} has merged.` : "Change merged.";
159
+ case "failed":
160
+ return issue.waitingReason ?? (issue.prNumber ? `PR #${issue.prNumber} needs help to recover.` : "Needs help to recover.");
161
+ }
150
162
  switch (issue.factoryState) {
151
163
  case "pr_open":
152
164
  return issue.prNumber ? `PR #${issue.prNumber} is awaiting review.` : "Awaiting review.";
@@ -1,5 +1,8 @@
1
1
  import { buildAgentSessionPlanForIssue } from "./agent-session-plan.js";
2
2
  import { buildAgentSessionExternalUrls } from "./agent-session-presentation.js";
3
+ import { deriveIssueStatusNote } from "./status-note.js";
4
+ import { derivePatchRelayWaitingReason } from "./waiting-reason.js";
5
+ import { resolvePreferredReviewLinearState, resolvePreferredStartedLinearState } from "./linear-workflow.js";
3
6
  const PROGRESS_THROTTLE_MS = 5_000;
4
7
  export class LinearSessionSync {
5
8
  config;
@@ -15,57 +18,119 @@ export class LinearSessionSync {
15
18
  this.logger = logger;
16
19
  this.feed = feed;
17
20
  }
21
+ ensureAgentSessionIssue(issue) {
22
+ if (issue.agentSessionId) {
23
+ return issue;
24
+ }
25
+ const recoveredAgentSessionId = this.db.findLatestAgentSessionIdForIssue(issue.linearIssueId);
26
+ if (!recoveredAgentSessionId)
27
+ return issue;
28
+ this.logger.info({ issueKey: issue.issueKey, agentSessionId: recoveredAgentSessionId }, "Recovered missing Linear agent session id from webhook history");
29
+ return this.db.upsertIssue({
30
+ projectId: issue.projectId,
31
+ linearIssueId: issue.linearIssueId,
32
+ agentSessionId: recoveredAgentSessionId,
33
+ });
34
+ }
18
35
  async emitActivity(issue, content, options) {
19
- if (!issue.agentSessionId)
36
+ const syncedIssue = this.ensureAgentSessionIssue(issue);
37
+ if (!syncedIssue.agentSessionId)
20
38
  return;
21
39
  try {
22
- const linear = await this.linearProvider.forProject(issue.projectId);
40
+ const linear = await this.linearProvider.forProject(syncedIssue.projectId);
23
41
  if (!linear)
24
42
  return;
25
43
  const allowEphemeral = content.type === "thought" || content.type === "action";
26
44
  await linear.createAgentActivity({
27
- agentSessionId: issue.agentSessionId,
45
+ agentSessionId: syncedIssue.agentSessionId,
28
46
  content,
29
47
  ...(options?.ephemeral && allowEphemeral ? { ephemeral: true } : {}),
30
48
  });
31
49
  }
32
50
  catch (error) {
33
51
  const msg = error instanceof Error ? error.message : String(error);
34
- this.logger.warn({ issueKey: issue.issueKey, type: content.type, error: msg }, "Failed to emit Linear activity");
52
+ this.logger.warn({ issueKey: syncedIssue.issueKey, type: content.type, error: msg }, "Failed to emit Linear activity");
35
53
  this.feed?.publish({
36
54
  level: "warn",
37
55
  kind: "linear",
38
- issueKey: issue.issueKey,
39
- projectId: issue.projectId,
56
+ issueKey: syncedIssue.issueKey,
57
+ projectId: syncedIssue.projectId,
40
58
  status: "linear_error",
41
59
  summary: `Linear activity failed: ${msg}`,
42
60
  });
43
61
  }
44
62
  }
45
63
  async syncSession(issue, options) {
46
- if (!issue.agentSessionId)
47
- return;
64
+ const syncedIssue = this.ensureAgentSessionIssue(issue);
48
65
  try {
49
- const linear = await this.linearProvider.forProject(issue.projectId);
50
- if (!linear?.updateAgentSession)
66
+ const linear = await this.linearProvider.forProject(syncedIssue.projectId);
67
+ if (!linear)
51
68
  return;
52
- const externalUrls = buildAgentSessionExternalUrls(this.config, {
53
- ...(issue.issueKey ? { issueKey: issue.issueKey } : {}),
54
- ...(issue.prUrl ? { prUrl: issue.prUrl } : {}),
55
- });
56
- await linear.updateAgentSession({
57
- agentSessionId: issue.agentSessionId,
58
- plan: buildAgentSessionPlanForIssue(issue, options),
59
- ...(externalUrls ? { externalUrls } : {}),
60
- });
69
+ const trackedIssue = this.db.getTrackedIssue(syncedIssue.projectId, syncedIssue.linearIssueId);
70
+ await this.syncActiveWorkflowState(syncedIssue, linear, trackedIssue, options);
71
+ if (syncedIssue.agentSessionId && linear.updateAgentSession) {
72
+ const externalUrls = buildAgentSessionExternalUrls(this.config, {
73
+ ...(syncedIssue.issueKey ? { issueKey: syncedIssue.issueKey } : {}),
74
+ ...(syncedIssue.prUrl ? { prUrl: syncedIssue.prUrl } : {}),
75
+ });
76
+ await linear.updateAgentSession({
77
+ agentSessionId: syncedIssue.agentSessionId,
78
+ plan: buildAgentSessionPlanForIssue(syncedIssue, options),
79
+ ...(externalUrls ? { externalUrls } : {}),
80
+ });
81
+ }
82
+ if (shouldSyncVisibleIssueComment(trackedIssue ?? syncedIssue, Boolean(syncedIssue.agentSessionId))) {
83
+ await this.syncStatusComment(syncedIssue, linear, options);
84
+ }
61
85
  }
62
86
  catch (error) {
63
87
  const msg = error instanceof Error ? error.message : String(error);
64
- this.logger.warn({ issueKey: issue.issueKey, error: msg }, "Failed to update Linear plan");
88
+ this.logger.warn({ issueKey: syncedIssue.issueKey, error: msg }, "Failed to update Linear plan");
65
89
  }
66
90
  }
91
+ async syncActiveWorkflowState(issue, linear, trackedIssue, options) {
92
+ if (!shouldAutoAdvanceLinearState(issue)) {
93
+ return;
94
+ }
95
+ const liveIssue = await linear.getIssue(issue.linearIssueId).catch(() => undefined);
96
+ if (!liveIssue)
97
+ return;
98
+ if (!shouldAutoAdvanceLinearState({
99
+ currentLinearState: liveIssue.stateName,
100
+ currentLinearStateType: liveIssue.stateType,
101
+ })) {
102
+ this.db.upsertIssue({
103
+ projectId: issue.projectId,
104
+ linearIssueId: issue.linearIssueId,
105
+ ...(liveIssue.stateName ? { currentLinearState: liveIssue.stateName } : {}),
106
+ ...(liveIssue.stateType ? { currentLinearStateType: liveIssue.stateType } : {}),
107
+ });
108
+ return;
109
+ }
110
+ const targetState = resolveDesiredActiveWorkflowState(issue, trackedIssue, options, liveIssue);
111
+ if (!targetState)
112
+ return;
113
+ const normalizedCurrent = liveIssue.stateName?.trim().toLowerCase();
114
+ if (normalizedCurrent === targetState.trim().toLowerCase()) {
115
+ this.db.upsertIssue({
116
+ projectId: issue.projectId,
117
+ linearIssueId: issue.linearIssueId,
118
+ ...(liveIssue.stateName ? { currentLinearState: liveIssue.stateName } : {}),
119
+ ...(liveIssue.stateType ? { currentLinearStateType: liveIssue.stateType } : {}),
120
+ });
121
+ return;
122
+ }
123
+ const updated = await linear.setIssueState(issue.linearIssueId, targetState);
124
+ this.db.upsertIssue({
125
+ projectId: issue.projectId,
126
+ linearIssueId: issue.linearIssueId,
127
+ ...(updated.stateName ? { currentLinearState: updated.stateName } : {}),
128
+ ...(updated.stateType ? { currentLinearStateType: updated.stateType } : {}),
129
+ });
130
+ }
67
131
  async syncCodexPlan(issue, params) {
68
- if (!issue.agentSessionId)
132
+ const syncedIssue = this.ensureAgentSessionIssue(issue);
133
+ if (!syncedIssue.agentSessionId)
69
134
  return;
70
135
  const plan = params.plan;
71
136
  if (!Array.isArray(plan))
@@ -87,17 +152,17 @@ export class LinearSessionSync {
87
152
  { content: "Merge", status: "pending" },
88
153
  ];
89
154
  try {
90
- const linear = await this.linearProvider.forProject(issue.projectId);
155
+ const linear = await this.linearProvider.forProject(syncedIssue.projectId);
91
156
  if (!linear?.updateAgentSession)
92
157
  return;
93
158
  await linear.updateAgentSession({
94
- agentSessionId: issue.agentSessionId,
159
+ agentSessionId: syncedIssue.agentSessionId,
95
160
  plan: fullPlan,
96
161
  });
97
162
  }
98
163
  catch (error) {
99
164
  const msg = error instanceof Error ? error.message : String(error);
100
- this.logger.warn({ issueKey: issue.issueKey, error: msg }, "Failed to sync codex plan to Linear");
165
+ this.logger.warn({ issueKey: syncedIssue.issueKey, error: msg }, "Failed to sync codex plan to Linear");
101
166
  }
102
167
  }
103
168
  maybeEmitProgress(notification, run) {
@@ -117,6 +182,28 @@ export class LinearSessionSync {
117
182
  clearProgress(runId) {
118
183
  this.progressThrottle.delete(runId);
119
184
  }
185
+ async syncStatusComment(issue, linear, options) {
186
+ try {
187
+ const trackedIssue = this.db.getTrackedIssue(issue.projectId, issue.linearIssueId);
188
+ const body = renderStatusComment(this.db, issue, trackedIssue, options);
189
+ const result = await linear.upsertIssueComment({
190
+ issueId: issue.linearIssueId,
191
+ ...(issue.statusCommentId ? { commentId: issue.statusCommentId } : {}),
192
+ body,
193
+ });
194
+ if (result.id !== issue.statusCommentId) {
195
+ this.db.upsertIssue({
196
+ projectId: issue.projectId,
197
+ linearIssueId: issue.linearIssueId,
198
+ statusCommentId: result.id,
199
+ });
200
+ }
201
+ }
202
+ catch (error) {
203
+ const msg = error instanceof Error ? error.message : String(error);
204
+ this.logger.warn({ issueKey: issue.issueKey, error: msg }, "Failed to sync Linear status comment");
205
+ }
206
+ }
120
207
  }
121
208
  function resolveProgressActivity(notification) {
122
209
  if (notification.method === "item/started") {
@@ -141,3 +228,145 @@ function resolveProgressActivity(notification) {
141
228
  }
142
229
  return undefined;
143
230
  }
231
+ function renderStatusComment(db, issue, trackedIssue, options) {
232
+ const activeRun = issue.activeRunId ? db.getRun(issue.activeRunId) : undefined;
233
+ const latestRun = db.getLatestRunForIssue(issue.projectId, issue.linearIssueId);
234
+ const latestEvent = db.listIssueSessionEvents(issue.projectId, issue.linearIssueId, { limit: 1 }).at(-1);
235
+ const activeRunType = issue.activeRunId !== undefined
236
+ ? (options?.activeRunType ?? activeRun?.runType)
237
+ : undefined;
238
+ const waitingReason = trackedIssue?.waitingReason ?? derivePatchRelayWaitingReason({
239
+ ...(activeRunType ? { activeRunType } : {}),
240
+ ...(issue.activeRunId !== undefined ? { activeRunId: issue.activeRunId } : {}),
241
+ factoryState: issue.factoryState,
242
+ pendingRunType: issue.pendingRunType,
243
+ ...(issue.prNumber !== undefined ? { prNumber: issue.prNumber } : {}),
244
+ prReviewState: issue.prReviewState,
245
+ prCheckStatus: issue.prCheckStatus,
246
+ latestFailureCheckName: issue.lastGitHubFailureCheckName,
247
+ });
248
+ const lines = [
249
+ "## PatchRelay status",
250
+ "",
251
+ statusHeadline(trackedIssue ?? issue, activeRunType),
252
+ ];
253
+ const statusNote = trackedIssue?.statusNote ?? deriveIssueStatusNote({ issue, latestRun, latestEvent, waitingReason });
254
+ if (waitingReason) {
255
+ lines.push("", `Waiting: ${waitingReason}`);
256
+ }
257
+ if (statusNote && statusNote !== waitingReason) {
258
+ const label = trackedIssue?.sessionState === "waiting_input" || issue.factoryState === "awaiting_input" ? "Input needed"
259
+ : trackedIssue?.sessionState === "failed" || issue.factoryState === "failed" || issue.factoryState === "escalated" ? "Action needed"
260
+ : "Note";
261
+ lines.push("", `${label}: ${statusNote}`);
262
+ }
263
+ if (issue.prNumber !== undefined || issue.prUrl) {
264
+ const prLabel = issue.prNumber !== undefined ? `#${issue.prNumber}` : "open";
265
+ lines.push("", `PR: ${issue.prUrl ? `[${prLabel}](${issue.prUrl})` : prLabel}`);
266
+ }
267
+ if (latestRun) {
268
+ lines.push("", `Latest run: ${formatLatestRun(latestRun)}`);
269
+ if (latestRun.failureReason) {
270
+ lines.push("", `Failure: ${latestRun.failureReason}`);
271
+ }
272
+ }
273
+ if (issue.lastGitHubFailureCheckName && (issue.factoryState === "repairing_ci" || issue.prCheckStatus === "failed" || issue.prCheckStatus === "failure")) {
274
+ lines.push("", `Latest failing check: ${issue.lastGitHubFailureCheckName}`);
275
+ }
276
+ lines.push("", "_PatchRelay updates this comment as it works. Review and merge remain downstream._");
277
+ return lines.join("\n");
278
+ }
279
+ function shouldSyncVisibleIssueComment(issue, hasAgentSession) {
280
+ if (!hasAgentSession) {
281
+ return true;
282
+ }
283
+ if (issue.sessionState === "waiting_input" || issue.sessionState === "failed"
284
+ || issue.factoryState === "awaiting_input" || issue.factoryState === "failed" || issue.factoryState === "escalated") {
285
+ return true;
286
+ }
287
+ if ((issue.sessionState === "done" || issue.factoryState === "done") && issue.prNumber === undefined && !issue.prUrl) {
288
+ return true;
289
+ }
290
+ return false;
291
+ }
292
+ function statusHeadline(issue, activeRunType) {
293
+ if (activeRunType) {
294
+ return `Running ${humanize(activeRunType)}`;
295
+ }
296
+ switch (issue.sessionState) {
297
+ case "waiting_input":
298
+ return issue.waitingReason ?? "Waiting for more input";
299
+ case "running":
300
+ return issue.prNumber !== undefined ? `PR #${issue.prNumber} is actively running` : "Actively running";
301
+ case "done":
302
+ return issue.prNumber !== undefined ? `Completed with PR #${issue.prNumber}` : "Completed";
303
+ case "failed":
304
+ return "Needs operator intervention";
305
+ default:
306
+ break;
307
+ }
308
+ switch (issue.factoryState) {
309
+ case "delegated":
310
+ return "Queued to start work";
311
+ case "implementing":
312
+ return "Implementing requested change";
313
+ case "pr_open":
314
+ return issue.prNumber !== undefined ? `PR #${issue.prNumber} opened` : "PR opened";
315
+ case "changes_requested":
316
+ return "Addressing requested review changes";
317
+ case "repairing_ci":
318
+ return "Repairing failing CI";
319
+ case "awaiting_queue":
320
+ return "Handed off downstream for merge";
321
+ case "repairing_queue":
322
+ return "Repairing merge handoff";
323
+ case "awaiting_input":
324
+ return "Waiting for more input";
325
+ case "failed":
326
+ return "Needs operator intervention";
327
+ case "escalated":
328
+ return "Escalated for human help";
329
+ case "done":
330
+ return issue.prNumber !== undefined ? `Completed with PR #${issue.prNumber}` : "Completed";
331
+ default:
332
+ return humanize(issue.factoryState);
333
+ }
334
+ }
335
+ function formatLatestRun(run) {
336
+ const at = run.endedAt ?? run.startedAt;
337
+ return `${humanize(run.runType)} ${run.status} at ${at}`;
338
+ }
339
+ function humanize(value) {
340
+ return value.replaceAll("_", " ");
341
+ }
342
+ function shouldAutoAdvanceLinearState(issue) {
343
+ const normalizedType = issue.currentLinearStateType?.trim().toLowerCase();
344
+ if (normalizedType === "backlog" || normalizedType === "unstarted") {
345
+ return true;
346
+ }
347
+ const normalizedName = issue.currentLinearState?.trim().toLowerCase();
348
+ return normalizedName === "backlog" || normalizedName === "todo" || normalizedName === "to do" || normalizedName === "triage";
349
+ }
350
+ function resolveDesiredActiveWorkflowState(issue, trackedIssue, options, liveIssue) {
351
+ const reviewBound = issue.prNumber !== undefined
352
+ || Boolean(issue.prUrl)
353
+ || issue.factoryState === "pr_open"
354
+ || issue.factoryState === "awaiting_queue"
355
+ || issue.factoryState === "changes_requested"
356
+ || issue.factoryState === "repairing_ci"
357
+ || issue.factoryState === "repairing_queue"
358
+ || issue.prReviewState !== undefined
359
+ || issue.prCheckStatus !== undefined;
360
+ if (reviewBound) {
361
+ return resolvePreferredReviewLinearState(liveIssue);
362
+ }
363
+ const activelyWorking = issue.activeRunId !== undefined
364
+ || options?.activeRunType !== undefined
365
+ || trackedIssue?.sessionState === "running"
366
+ || issue.factoryState === "delegated"
367
+ || issue.factoryState === "implementing";
368
+ if (activelyWorking) {
369
+ return resolvePreferredStartedLinearState(liveIssue);
370
+ }
371
+ return undefined;
372
+ }
@@ -2,6 +2,39 @@ function normalizeLinearState(value) {
2
2
  const trimmed = value?.trim();
3
3
  return trimmed ? trimmed.toLowerCase() : undefined;
4
4
  }
5
+ export function resolvePreferredStartedLinearState(issue) {
6
+ const startedStates = issue.workflowStates.filter((state) => normalizeLinearState(state.type) === "started");
7
+ const preferred = startedStates.find((state) => {
8
+ const normalized = normalizeLinearState(state.name);
9
+ return normalized === "in progress" || normalized === "in-progress" || normalized === "started" || normalized === "doing";
10
+ });
11
+ return preferred?.name ?? startedStates[0]?.name;
12
+ }
13
+ export function resolvePreferredReviewLinearState(issue) {
14
+ const reviewState = issue.workflowStates.find((state) => {
15
+ if (normalizeLinearState(state.type) !== "started")
16
+ return false;
17
+ const normalized = normalizeLinearState(state.name);
18
+ return normalized === "in review" || normalized === "review";
19
+ });
20
+ return reviewState?.name ?? resolvePreferredStartedLinearState(issue);
21
+ }
22
+ export function resolvePreferredCompletedLinearState(issue) {
23
+ const completed = issue.workflowStates.find((state) => normalizeLinearState(state.type) === "completed");
24
+ if (completed?.name) {
25
+ return completed.name;
26
+ }
27
+ const currentStateName = issue.stateName?.trim();
28
+ const normalizedCurrentState = normalizeLinearState(currentStateName);
29
+ if (normalizedCurrentState === "done" || normalizedCurrentState === "completed" || normalizedCurrentState === "complete") {
30
+ return currentStateName;
31
+ }
32
+ const named = issue.workflowStates.find((state) => {
33
+ const normalized = normalizeLinearState(state.name);
34
+ return normalized === "done" || normalized === "completed" || normalized === "complete";
35
+ });
36
+ return named?.name;
37
+ }
5
38
  export function resolveAuthoritativeLinearStopState(issue) {
6
39
  const currentStateName = issue.stateName?.trim();
7
40
  const normalizedCurrentState = normalizeLinearState(currentStateName);
@@ -1,4 +1,3 @@
1
- import { execCommand } from "./utils.js";
2
1
  export const DEFAULT_MERGE_QUEUE_LABEL = "queue";
3
2
  export const DEFAULT_MERGE_QUEUE_CHECK_NAME = "merge-steward/queue";
4
3
  export function resolveMergeQueueProtocol(project) {
@@ -9,53 +8,3 @@ export function resolveMergeQueueProtocol(project) {
9
8
  evictionCheckName: project?.github?.mergeQueueCheckName ?? DEFAULT_MERGE_QUEUE_CHECK_NAME,
10
9
  };
11
10
  }
12
- export async function requestMergeQueueAdmission(params) {
13
- const { issue, protocol, logger, feed } = params;
14
- if (!protocol.repoFullName || !issue.prNumber)
15
- return false;
16
- feed?.publish({
17
- level: "info",
18
- kind: "github",
19
- issueKey: issue.issueKey,
20
- projectId: issue.projectId,
21
- stage: "awaiting_queue",
22
- status: "queue_label_requested",
23
- summary: `Queue hand-off requested via label "${protocol.admissionLabel}" on PR #${issue.prNumber}`,
24
- });
25
- try {
26
- const [owner, repo] = protocol.repoFullName.split("/", 2);
27
- if (!owner || !repo) {
28
- throw new Error(`Invalid repoFullName: ${protocol.repoFullName}`);
29
- }
30
- await execCommand("gh", [
31
- "api",
32
- "--method", "POST",
33
- `repos/${owner}/${repo}/issues/${issue.prNumber}/labels`,
34
- "-f", `labels[]=${protocol.admissionLabel}`,
35
- ], { timeoutMs: 15_000 });
36
- feed?.publish({
37
- level: "info",
38
- kind: "github",
39
- issueKey: issue.issueKey,
40
- projectId: issue.projectId,
41
- stage: "awaiting_queue",
42
- status: "queue_label_applied",
43
- summary: `Queue label "${protocol.admissionLabel}" applied to PR #${issue.prNumber}`,
44
- });
45
- return true;
46
- }
47
- catch (error) {
48
- logger.warn({ issueKey: issue.issueKey, err: error }, "Failed to add merge queue label");
49
- feed?.publish({
50
- level: "warn",
51
- kind: "github",
52
- issueKey: issue.issueKey,
53
- projectId: issue.projectId,
54
- stage: "awaiting_queue",
55
- status: "queue_label_failed",
56
- summary: `Queue hand-off failed while adding label "${protocol.admissionLabel}" to PR #${issue.prNumber}`,
57
- detail: error instanceof Error ? error.message : String(error),
58
- });
59
- return false;
60
- }
61
- }
package/dist/preflight.js CHANGED
@@ -283,7 +283,7 @@ function checkGitHubProtocol(project, publicBaseUrl) {
283
283
  ];
284
284
  }
285
285
  const checks = [
286
- pass(scope, `GitHub protocol configured for ${protocol.repoFullName} (label "${protocol.admissionLabel}", eviction check "${protocol.evictionCheckName}")`),
286
+ pass(scope, `GitHub protocol configured for ${protocol.repoFullName} (base "${protocol.baseBranch ?? "main"}", queue incident check "${protocol.evictionCheckName}")`),
287
287
  ];
288
288
  if (!publicBaseUrl) {
289
289
  checks.push(warn(scope, "PatchRelay public base URL is not configured; public operator/session links will be incomplete"));
@@ -291,9 +291,6 @@ function checkGitHubProtocol(project, publicBaseUrl) {
291
291
  if (!protocol.baseBranch) {
292
292
  checks.push(warn(scope, "GitHub base branch is not configured; defaults may diverge from the target repository"));
293
293
  }
294
- if (!protocol.admissionLabel.trim()) {
295
- checks.push(fail(scope, "Merge queue admission label must not be empty"));
296
- }
297
294
  if (!protocol.evictionCheckName.trim()) {
298
295
  checks.push(fail(scope, "Merge queue eviction check name must not be empty"));
299
296
  }
@@ -44,7 +44,7 @@ export class QueueHealthMonitor {
44
44
  const { stdout } = await execCommand("gh", [
45
45
  "pr", "view", String(issue.prNumber),
46
46
  "--repo", project.github.repoFullName,
47
- "--json", "state,mergeable,mergeStateStatus,headRefOid,labels",
47
+ "--json", "state,mergeable,mergeStateStatus,headRefOid",
48
48
  ], { timeoutMs: 10_000 });
49
49
  pr = JSON.parse(stdout);
50
50
  }
@@ -74,9 +74,6 @@ export class QueueHealthMonitor {
74
74
  }
75
75
  if (pr.state !== "OPEN")
76
76
  return;
77
- const hasQueueLabel = pr.labels?.some((l) => l.name === protocol.admissionLabel) ?? false;
78
- if (!hasQueueLabel)
79
- return;
80
77
  const isDirty = pr.mergeStateStatus === "DIRTY" || pr.mergeable === "CONFLICTING";
81
78
  let hasEvictionCheckRun = false;
82
79
  if (!isDirty) {
@@ -110,10 +107,17 @@ export class QueueHealthMonitor {
110
107
  lastAttemptedFailureHeadSha: headRefOid,
111
108
  lastAttemptedFailureSignature: signature,
112
109
  });
113
- this.advancer.advanceIdleIssue(issue, "repairing_queue", {
114
- pendingRunType: "queue_repair",
115
- pendingRunContext,
110
+ this.db.appendIssueSessionEventRespectingActiveLease(issue.projectId, issue.linearIssueId, {
111
+ projectId: issue.projectId,
112
+ linearIssueId: issue.linearIssueId,
113
+ eventType: "merge_steward_incident",
114
+ eventJson: JSON.stringify(pendingRunContext),
115
+ dedupeKey: `queue_health:queue_repair:${issue.linearIssueId}:${signature}`,
116
116
  });
117
+ this.advancer.advanceIdleIssue(issue, "repairing_queue");
118
+ if (this.db.peekIssueSessionWake(issue.projectId, issue.linearIssueId)) {
119
+ this.advancer.enqueueIssue(issue.projectId, issue.linearIssueId);
120
+ }
117
121
  this.logger.info({ issueKey: issue.issueKey, prNumber: issue.prNumber, headRefOid, reason }, "Queue health: queue issue detected, dispatching repair");
118
122
  this.feed?.publish({
119
123
  level: "warn",