patchrelay 0.35.7 → 0.35.9

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,6 +1,6 @@
1
1
  {
2
2
  "service": "patchrelay",
3
- "version": "0.35.7",
4
- "commit": "880bc08921ac",
5
- "builtAt": "2026-04-03T11:50:11.628Z"
3
+ "version": "0.35.9",
4
+ "commit": "26b4d319fde3",
5
+ "builtAt": "2026-04-03T23:53:54.564Z"
6
6
  }
@@ -203,8 +203,10 @@ async function resolveBotIdentity(jwt) {
203
203
  throw new Error(`Failed to fetch bot user ${botLogin} (${userResponse.status}): ${body}`);
204
204
  }
205
205
  const user = await userResponse.json();
206
+ const { tokenFile } = getGitHubAppPaths();
206
207
  return {
207
208
  name: user.login,
208
209
  email: `${user.id}+${user.login}@users.noreply.github.com`,
210
+ tokenFile,
209
211
  };
210
212
  }
@@ -0,0 +1,262 @@
1
+ import { resolveMergeQueueProtocol } from "./merge-queue-protocol.js";
2
+ import { parseGitHubFailureContext } from "./github-failure-context.js";
3
+ import { parseStoredQueueRepairContext } from "./merge-queue-incident.js";
4
+ import { execCommand } from "./utils.js";
5
+ function isDuplicateRepairAttempt(issue, context) {
6
+ const signature = typeof context?.failureSignature === "string" ? context.failureSignature : undefined;
7
+ const headSha = typeof context?.failureHeadSha === "string"
8
+ ? context.failureHeadSha
9
+ : typeof context?.headSha === "string" ? context.headSha : undefined;
10
+ if (!signature)
11
+ return false;
12
+ return issue.lastAttemptedFailureSignature === signature
13
+ && (headSha === undefined || issue.lastAttemptedFailureHeadSha === headSha);
14
+ }
15
+ function buildFailureContext(issue) {
16
+ const storedFailureContext = parseGitHubFailureContext(issue.lastGitHubFailureContextJson);
17
+ const queueRepairContext = issue.lastQueueIncidentJson
18
+ ? parseStoredQueueRepairContext(issue.lastQueueIncidentJson)
19
+ : undefined;
20
+ if (!queueRepairContext
21
+ && !issue.lastGitHubFailureSource
22
+ && !issue.lastGitHubFailureHeadSha
23
+ && !issue.lastGitHubFailureSignature
24
+ && !issue.lastGitHubFailureCheckName
25
+ && !issue.lastGitHubFailureCheckUrl
26
+ && !storedFailureContext) {
27
+ return undefined;
28
+ }
29
+ return {
30
+ ...(issue.lastGitHubFailureSource ? { failureReason: issue.lastGitHubFailureSource } : {}),
31
+ ...(issue.lastGitHubFailureHeadSha ? { failureHeadSha: issue.lastGitHubFailureHeadSha } : {}),
32
+ ...(issue.lastGitHubFailureSignature ? { failureSignature: issue.lastGitHubFailureSignature } : {}),
33
+ ...(issue.lastGitHubFailureCheckName ? { checkName: issue.lastGitHubFailureCheckName } : {}),
34
+ ...(issue.lastGitHubFailureCheckUrl ? { checkUrl: issue.lastGitHubFailureCheckUrl } : {}),
35
+ ...(storedFailureContext ? storedFailureContext : {}),
36
+ ...(queueRepairContext ? queueRepairContext : {}),
37
+ };
38
+ }
39
+ export function resolveBranchOwnerForStateTransition(newState, pendingRunType) {
40
+ if (pendingRunType)
41
+ return "patchrelay";
42
+ if (newState === "awaiting_queue")
43
+ return "merge_steward";
44
+ if (newState === "repairing_ci" || newState === "repairing_queue")
45
+ return "patchrelay";
46
+ return undefined;
47
+ }
48
+ export class IdleIssueReconciler {
49
+ db;
50
+ config;
51
+ deps;
52
+ logger;
53
+ feed;
54
+ constructor(db, config, deps, logger, feed) {
55
+ this.db = db;
56
+ this.config = config;
57
+ this.deps = deps;
58
+ this.logger = logger;
59
+ this.feed = feed;
60
+ }
61
+ async reconcile() {
62
+ for (const issue of this.db.listIdleNonTerminalIssues()) {
63
+ if (issue.prState === "merged") {
64
+ this.advanceIdleIssue(issue, "done", { clearFailureProvenance: true });
65
+ continue;
66
+ }
67
+ if (issue.prReviewState === "approved" && issue.prCheckStatus !== "failed") {
68
+ if (issue.factoryState !== "awaiting_queue" || issue.branchOwner !== "merge_steward") {
69
+ this.advanceIdleIssue(issue, "awaiting_queue", { clearFailureProvenance: true });
70
+ }
71
+ else if (!issue.queueLabelApplied) {
72
+ await this.deps.requestMergeQueueAdmission(issue, issue.projectId);
73
+ }
74
+ continue;
75
+ }
76
+ if (issue.prCheckStatus === "failed") {
77
+ await this.routeFailedIssue(issue);
78
+ continue;
79
+ }
80
+ // Probe GitHub for stale pr_open issues: detect missed reviews,
81
+ // merge conflicts, and other state that webhooks may have missed.
82
+ if (issue.factoryState === "pr_open") {
83
+ await this.reconcileFromGitHub(issue);
84
+ }
85
+ }
86
+ for (const issue of this.db.listBlockedDelegatedIssues()) {
87
+ const unresolved = this.db.countUnresolvedBlockers(issue.projectId, issue.linearIssueId);
88
+ if (unresolved === 0) {
89
+ this.db.upsertIssue({
90
+ projectId: issue.projectId,
91
+ linearIssueId: issue.linearIssueId,
92
+ pendingRunType: "implementation",
93
+ });
94
+ this.deps.enqueueIssue(issue.projectId, issue.linearIssueId);
95
+ }
96
+ }
97
+ }
98
+ advanceIdleIssue(issue, newState, options) {
99
+ if (issue.factoryState === newState && !options?.pendingRunType && !options?.clearFailureProvenance) {
100
+ return;
101
+ }
102
+ this.logger.info({ issueKey: issue.issueKey, from: issue.factoryState, to: newState, pendingRunType: options?.pendingRunType }, "Reconciliation: advancing idle issue");
103
+ const resetQueueLabel = newState === "awaiting_queue" || issue.factoryState === "awaiting_queue";
104
+ this.db.upsertIssue({
105
+ projectId: issue.projectId,
106
+ linearIssueId: issue.linearIssueId,
107
+ factoryState: newState,
108
+ ...(options?.pendingRunType ? { pendingRunType: options.pendingRunType } : {}),
109
+ ...(options?.pendingRunType
110
+ ? {
111
+ pendingRunContextJson: options.pendingRunContext ? JSON.stringify(options.pendingRunContext) : null,
112
+ }
113
+ : {}),
114
+ ...(resetQueueLabel ? { queueLabelApplied: false } : {}),
115
+ ...(options?.clearFailureProvenance
116
+ ? {
117
+ lastGitHubFailureSource: null,
118
+ lastGitHubFailureHeadSha: null,
119
+ lastGitHubFailureSignature: null,
120
+ lastGitHubFailureCheckName: null,
121
+ lastGitHubFailureCheckUrl: null,
122
+ lastGitHubFailureContextJson: null,
123
+ lastGitHubFailureAt: null,
124
+ lastQueueIncidentJson: null,
125
+ lastAttemptedFailureHeadSha: null,
126
+ lastAttemptedFailureSignature: null,
127
+ }
128
+ : {}),
129
+ });
130
+ const branchOwner = resolveBranchOwnerForStateTransition(newState, options?.pendingRunType);
131
+ if (branchOwner) {
132
+ this.db.setBranchOwner(issue.projectId, issue.linearIssueId, branchOwner);
133
+ }
134
+ this.feed?.publish({
135
+ level: "info",
136
+ kind: "stage",
137
+ issueKey: issue.issueKey,
138
+ projectId: issue.projectId,
139
+ stage: newState,
140
+ status: "reconciled",
141
+ summary: `Reconciliation: ${issue.factoryState} \u2192 ${newState}`,
142
+ });
143
+ if (newState === "awaiting_queue" && issue.factoryState !== "awaiting_queue") {
144
+ void this.deps.requestMergeQueueAdmission(issue, issue.projectId);
145
+ }
146
+ if (options?.pendingRunType) {
147
+ this.deps.enqueueIssue(issue.projectId, issue.linearIssueId);
148
+ }
149
+ }
150
+ async routeFailedIssue(issue) {
151
+ if (issue.lastGitHubFailureSource === "queue_eviction") {
152
+ const pendingRunContext = buildFailureContext(issue);
153
+ if (isDuplicateRepairAttempt(issue, pendingRunContext)) {
154
+ this.advanceIdleIssue(issue, "repairing_queue");
155
+ }
156
+ else {
157
+ this.advanceIdleIssue(issue, "repairing_queue", {
158
+ pendingRunType: "queue_repair",
159
+ ...(pendingRunContext ? { pendingRunContext } : {}),
160
+ });
161
+ }
162
+ return;
163
+ }
164
+ if (issue.lastGitHubFailureSource === "branch_ci") {
165
+ const pendingRunContext = buildFailureContext(issue);
166
+ if (isDuplicateRepairAttempt(issue, pendingRunContext)) {
167
+ this.advanceIdleIssue(issue, "repairing_ci");
168
+ }
169
+ else {
170
+ this.advanceIdleIssue(issue, "repairing_ci", {
171
+ pendingRunType: "ci_repair",
172
+ ...(pendingRunContext ? { pendingRunContext } : {}),
173
+ });
174
+ }
175
+ return;
176
+ }
177
+ if (issue.factoryState === "awaiting_queue") {
178
+ const inferProject = this.config.projects.find((p) => p.id === issue.projectId);
179
+ const inferProtocol = resolveMergeQueueProtocol(inferProject);
180
+ let inferred = "branch_ci";
181
+ const probeSha = issue.lastGitHubFailureHeadSha ?? issue.lastGitHubCiSnapshotHeadSha;
182
+ if (inferProject?.github?.repoFullName && issue.prNumber && probeSha) {
183
+ try {
184
+ const { stdout } = await execCommand("gh", [
185
+ "api",
186
+ `repos/${inferProject.github.repoFullName}/commits/${probeSha}/check-runs`,
187
+ "--jq", `.check_runs[] | select(.name == "${inferProtocol.evictionCheckName}" and .conclusion == "failure") | .name`,
188
+ ], { timeoutMs: 10_000 });
189
+ if (stdout.trim().length > 0)
190
+ inferred = "queue_eviction";
191
+ }
192
+ catch { /* best effort */ }
193
+ }
194
+ const inferRunType = inferred === "queue_eviction" ? "queue_repair" : "ci_repair";
195
+ const inferState = inferred === "queue_eviction" ? "repairing_queue" : "repairing_ci";
196
+ this.logger.info({ issueKey: issue.issueKey, prNumber: issue.prNumber, inferred }, "Inferred failure provenance for awaiting_queue issue");
197
+ const pendingRunContext = buildFailureContext(issue);
198
+ this.advanceIdleIssue(issue, inferState, {
199
+ pendingRunType: inferRunType,
200
+ ...(pendingRunContext ? { pendingRunContext } : {}),
201
+ });
202
+ return;
203
+ }
204
+ const pendingRunContext = buildFailureContext(issue);
205
+ if (isDuplicateRepairAttempt(issue, pendingRunContext)) {
206
+ this.advanceIdleIssue(issue, "repairing_ci");
207
+ }
208
+ else {
209
+ this.advanceIdleIssue(issue, "repairing_ci", {
210
+ pendingRunType: "ci_repair",
211
+ ...(pendingRunContext ? { pendingRunContext } : {}),
212
+ });
213
+ }
214
+ }
215
+ async reconcileFromGitHub(issue) {
216
+ const project = this.config.projects.find((p) => p.id === issue.projectId);
217
+ if (!project?.github?.repoFullName || !issue.prNumber)
218
+ return;
219
+ try {
220
+ const { stdout } = await execCommand("gh", [
221
+ "pr", "view", String(issue.prNumber),
222
+ "--repo", project.github.repoFullName,
223
+ "--json", "state,reviewDecision,mergeable,mergeStateStatus",
224
+ ], { timeoutMs: 10_000 });
225
+ const pr = JSON.parse(stdout);
226
+ if (pr.state === "MERGED") {
227
+ this.db.upsertIssue({ projectId: issue.projectId, linearIssueId: issue.linearIssueId, prState: "merged" });
228
+ this.advanceIdleIssue(issue, "done", { clearFailureProvenance: true });
229
+ return;
230
+ }
231
+ if (pr.reviewDecision === "APPROVED") {
232
+ this.db.upsertIssue({ projectId: issue.projectId, linearIssueId: issue.linearIssueId, prReviewState: "approved" });
233
+ this.advanceIdleIssue(issue, "awaiting_queue", { clearFailureProvenance: true });
234
+ return;
235
+ }
236
+ // Merge conflict detected — dispatch a repair run to rebase the branch.
237
+ if (pr.mergeable === "CONFLICTING" || pr.mergeStateStatus === "DIRTY") {
238
+ this.logger.info({ issueKey: issue.issueKey, prNumber: issue.prNumber, mergeable: pr.mergeable }, "Reconciliation: PR has merge conflicts, dispatching rebase");
239
+ this.advanceIdleIssue(issue, "repairing_queue", {
240
+ pendingRunType: "queue_repair",
241
+ pendingRunContext: {
242
+ source: "idle_reconciliation",
243
+ failureReason: "merge_conflict_detected",
244
+ failureSignature: `conflict:${issue.prNumber}`,
245
+ },
246
+ });
247
+ this.feed?.publish({
248
+ level: "warn",
249
+ kind: "github",
250
+ issueKey: issue.issueKey,
251
+ projectId: issue.projectId,
252
+ stage: "repairing_queue",
253
+ status: "conflict_detected",
254
+ summary: `PR #${issue.prNumber} has merge conflicts with main, dispatching rebase`,
255
+ });
256
+ }
257
+ }
258
+ catch (error) {
259
+ this.logger.debug({ issueKey: issue.issueKey, error: error instanceof Error ? error.message : String(error) }, "Failed to query GitHub PR state during reconciliation");
260
+ }
261
+ }
262
+ }
@@ -0,0 +1,143 @@
1
+ import { buildAgentSessionPlanForIssue } from "./agent-session-plan.js";
2
+ import { buildAgentSessionExternalUrls } from "./agent-session-presentation.js";
3
+ const PROGRESS_THROTTLE_MS = 5_000;
4
+ export class LinearSessionSync {
5
+ config;
6
+ db;
7
+ linearProvider;
8
+ logger;
9
+ feed;
10
+ progressThrottle = new Map();
11
+ constructor(config, db, linearProvider, logger, feed) {
12
+ this.config = config;
13
+ this.db = db;
14
+ this.linearProvider = linearProvider;
15
+ this.logger = logger;
16
+ this.feed = feed;
17
+ }
18
+ async emitActivity(issue, content, options) {
19
+ if (!issue.agentSessionId)
20
+ return;
21
+ try {
22
+ const linear = await this.linearProvider.forProject(issue.projectId);
23
+ if (!linear)
24
+ return;
25
+ const allowEphemeral = content.type === "thought" || content.type === "action";
26
+ await linear.createAgentActivity({
27
+ agentSessionId: issue.agentSessionId,
28
+ content,
29
+ ...(options?.ephemeral && allowEphemeral ? { ephemeral: true } : {}),
30
+ });
31
+ }
32
+ catch (error) {
33
+ 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");
35
+ this.feed?.publish({
36
+ level: "warn",
37
+ kind: "linear",
38
+ issueKey: issue.issueKey,
39
+ projectId: issue.projectId,
40
+ status: "linear_error",
41
+ summary: `Linear activity failed: ${msg}`,
42
+ });
43
+ }
44
+ }
45
+ async syncSession(issue, options) {
46
+ if (!issue.agentSessionId)
47
+ return;
48
+ try {
49
+ const linear = await this.linearProvider.forProject(issue.projectId);
50
+ if (!linear?.updateAgentSession)
51
+ 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
+ });
61
+ }
62
+ catch (error) {
63
+ const msg = error instanceof Error ? error.message : String(error);
64
+ this.logger.warn({ issueKey: issue.issueKey, error: msg }, "Failed to update Linear plan");
65
+ }
66
+ }
67
+ async syncCodexPlan(issue, params) {
68
+ if (!issue.agentSessionId)
69
+ return;
70
+ const plan = params.plan;
71
+ if (!Array.isArray(plan))
72
+ return;
73
+ const STATUS_MAP = {
74
+ pending: "pending",
75
+ inProgress: "inProgress",
76
+ completed: "completed",
77
+ };
78
+ const steps = plan.map((entry) => {
79
+ const e = entry;
80
+ const step = typeof e.step === "string" ? e.step : String(e.step ?? "");
81
+ const status = typeof e.status === "string" ? (STATUS_MAP[e.status] ?? "pending") : "pending";
82
+ return { content: step, status };
83
+ });
84
+ const fullPlan = [
85
+ { content: "Prepare workspace", status: "completed" },
86
+ ...steps,
87
+ { content: "Merge", status: "pending" },
88
+ ];
89
+ try {
90
+ const linear = await this.linearProvider.forProject(issue.projectId);
91
+ if (!linear?.updateAgentSession)
92
+ return;
93
+ await linear.updateAgentSession({
94
+ agentSessionId: issue.agentSessionId,
95
+ plan: fullPlan,
96
+ });
97
+ }
98
+ catch (error) {
99
+ 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");
101
+ }
102
+ }
103
+ maybeEmitProgress(notification, run) {
104
+ const activity = resolveProgressActivity(notification);
105
+ if (!activity)
106
+ return;
107
+ const now = Date.now();
108
+ const lastEmit = this.progressThrottle.get(run.id) ?? 0;
109
+ if (now - lastEmit < PROGRESS_THROTTLE_MS)
110
+ return;
111
+ this.progressThrottle.set(run.id, now);
112
+ const issue = this.db.getIssue(run.projectId, run.linearIssueId);
113
+ if (issue) {
114
+ void this.emitActivity(issue, activity, { ephemeral: true });
115
+ }
116
+ }
117
+ clearProgress(runId) {
118
+ this.progressThrottle.delete(runId);
119
+ }
120
+ }
121
+ function resolveProgressActivity(notification) {
122
+ if (notification.method === "item/started") {
123
+ const item = notification.params.item;
124
+ if (!item)
125
+ return undefined;
126
+ const type = typeof item.type === "string" ? item.type : undefined;
127
+ if (type === "commandExecution") {
128
+ const cmd = item.command;
129
+ const cmdStr = Array.isArray(cmd) ? cmd.join(" ") : typeof cmd === "string" ? cmd : undefined;
130
+ return { type: "action", action: "Running", parameter: cmdStr?.slice(0, 120) ?? "command" };
131
+ }
132
+ if (type === "mcpToolCall") {
133
+ const server = typeof item.server === "string" ? item.server : "";
134
+ const tool = typeof item.tool === "string" ? item.tool : "";
135
+ return { type: "action", action: "Using", parameter: `${server}/${tool}` };
136
+ }
137
+ if (type === "dynamicToolCall") {
138
+ const tool = typeof item.tool === "string" ? item.tool : "tool";
139
+ return { type: "action", action: "Using", parameter: tool };
140
+ }
141
+ }
142
+ return undefined;
143
+ }
@@ -0,0 +1,131 @@
1
+ import { resolveMergeQueueProtocol } from "./merge-queue-protocol.js";
2
+ import { execCommand } from "./utils.js";
3
+ const QUEUE_HEALTH_GRACE_MS = 120_000;
4
+ const QUEUE_HEALTH_PROBE_FAILURE_COOLDOWN_MS = 300_000;
5
+ function isDuplicateProbe(issue, context) {
6
+ const signature = typeof context?.failureSignature === "string" ? context.failureSignature : undefined;
7
+ const headSha = typeof context?.failureHeadSha === "string" ? context.failureHeadSha : undefined;
8
+ if (!signature)
9
+ return false;
10
+ return issue.lastAttemptedFailureSignature === signature
11
+ && (headSha === undefined || issue.lastAttemptedFailureHeadSha === headSha);
12
+ }
13
+ export class QueueHealthMonitor {
14
+ db;
15
+ config;
16
+ advancer;
17
+ logger;
18
+ feed;
19
+ probeFailureFeedTimes = new Map();
20
+ constructor(db, config, advancer, logger, feed) {
21
+ this.db = db;
22
+ this.config = config;
23
+ this.advancer = advancer;
24
+ this.logger = logger;
25
+ this.feed = feed;
26
+ }
27
+ async reconcile() {
28
+ for (const issue of this.db.listAwaitingQueueIssues()) {
29
+ await this.probeQueuedIssue(issue);
30
+ }
31
+ }
32
+ async probeQueuedIssue(issue) {
33
+ if (!issue.prNumber)
34
+ return;
35
+ const project = this.config.projects.find((p) => p.id === issue.projectId);
36
+ if (!project?.github?.repoFullName)
37
+ return;
38
+ const age = Date.now() - Date.parse(issue.updatedAt);
39
+ if (age < QUEUE_HEALTH_GRACE_MS)
40
+ return;
41
+ const protocol = resolveMergeQueueProtocol(project);
42
+ let pr;
43
+ try {
44
+ const { stdout } = await execCommand("gh", [
45
+ "pr", "view", String(issue.prNumber),
46
+ "--repo", project.github.repoFullName,
47
+ "--json", "state,mergeable,mergeStateStatus,headRefOid,labels",
48
+ ], { timeoutMs: 10_000 });
49
+ pr = JSON.parse(stdout);
50
+ }
51
+ catch (error) {
52
+ this.logger.debug({ issueKey: issue.issueKey, prNumber: issue.prNumber, error: error instanceof Error ? error.message : String(error) }, "Queue health: failed to probe GitHub PR state");
53
+ const issueKey = `${issue.projectId}::${issue.linearIssueId}`;
54
+ const lastFeedAt = this.probeFailureFeedTimes.get(issueKey) ?? 0;
55
+ if (Date.now() - lastFeedAt >= QUEUE_HEALTH_PROBE_FAILURE_COOLDOWN_MS) {
56
+ this.probeFailureFeedTimes.set(issueKey, Date.now());
57
+ this.feed?.publish({
58
+ level: "info",
59
+ kind: "github",
60
+ issueKey: issue.issueKey,
61
+ projectId: issue.projectId,
62
+ stage: "awaiting_queue",
63
+ status: "queue_health_probe_failed",
64
+ summary: `Queue health: failed to probe PR #${issue.prNumber}`,
65
+ });
66
+ }
67
+ return;
68
+ }
69
+ this.probeFailureFeedTimes.delete(`${issue.projectId}::${issue.linearIssueId}`);
70
+ if (pr.state === "MERGED") {
71
+ this.db.upsertIssue({ projectId: issue.projectId, linearIssueId: issue.linearIssueId, prState: "merged" });
72
+ this.advancer.advanceIdleIssue(issue, "done", { clearFailureProvenance: true });
73
+ return;
74
+ }
75
+ if (pr.state !== "OPEN")
76
+ return;
77
+ const hasQueueLabel = pr.labels?.some((l) => l.name === protocol.admissionLabel) ?? false;
78
+ if (!hasQueueLabel)
79
+ return;
80
+ const isDirty = pr.mergeStateStatus === "DIRTY" || pr.mergeable === "CONFLICTING";
81
+ let hasEvictionCheckRun = false;
82
+ if (!isDirty) {
83
+ try {
84
+ const { stdout: checksOut } = await execCommand("gh", [
85
+ "api", `repos/${project.github.repoFullName}/commits/${pr.headRefOid}/check-runs`,
86
+ "--jq", `.check_runs[] | select(.name == "${protocol.evictionCheckName}" and .conclusion == "failure") | .name`,
87
+ ], { timeoutMs: 10_000 });
88
+ hasEvictionCheckRun = checksOut.trim().length > 0;
89
+ }
90
+ catch {
91
+ // Best-effort check.
92
+ }
93
+ }
94
+ if (isDirty || hasEvictionCheckRun) {
95
+ const headRefOid = pr.headRefOid ?? "unknown";
96
+ const reason = hasEvictionCheckRun ? "queue_eviction_missed" : "preemptive_conflict";
97
+ const signature = `preemptive_queue_conflict:${headRefOid}`;
98
+ const pendingRunContext = {
99
+ source: "queue_health_monitor",
100
+ failureReason: reason,
101
+ failureHeadSha: headRefOid,
102
+ failureSignature: signature,
103
+ };
104
+ if (isDuplicateProbe(issue, pendingRunContext)) {
105
+ return;
106
+ }
107
+ this.db.upsertIssue({
108
+ projectId: issue.projectId,
109
+ linearIssueId: issue.linearIssueId,
110
+ lastAttemptedFailureHeadSha: headRefOid,
111
+ lastAttemptedFailureSignature: signature,
112
+ });
113
+ this.advancer.advanceIdleIssue(issue, "repairing_queue", {
114
+ pendingRunType: "queue_repair",
115
+ pendingRunContext,
116
+ });
117
+ this.logger.info({ issueKey: issue.issueKey, prNumber: issue.prNumber, headRefOid, reason }, "Queue health: queue issue detected, dispatching repair");
118
+ this.feed?.publish({
119
+ level: "warn",
120
+ kind: "github",
121
+ issueKey: issue.issueKey,
122
+ projectId: issue.projectId,
123
+ stage: "repairing_queue",
124
+ status: hasEvictionCheckRun ? "queue_health_eviction_detected" : "queue_health_conflict_detected",
125
+ summary: hasEvictionCheckRun
126
+ ? `Queue health: missed eviction detected on PR #${issue.prNumber}, dispatching repair`
127
+ : `Queue health: merge conflict detected on PR #${issue.prNumber}, dispatching preemptive repair`,
128
+ });
129
+ }
130
+ }
131
+ }
@@ -1,14 +1,10 @@
1
1
  import { existsSync, readFileSync } from "node:fs";
2
2
  import path from "node:path";
3
3
  import { ACTIVE_RUN_STATES, TERMINAL_STATES } from "./factory-state.js";
4
- import { parseGitHubFailureContext } from "./github-failure-context.js";
5
4
  import { buildHookEnv, runProjectHook } from "./hook-runner.js";
6
- import { buildAgentSessionPlanForIssue, } from "./agent-session-plan.js";
7
5
  import { buildStageReport, countEventMethods, extractTurnId, resolveRunCompletionStatus, summarizeCurrentThread, } from "./run-reporting.js";
8
6
  import { buildRunCompletedActivity, buildRunFailureActivity, buildRunStartedActivity, } from "./linear-session-reporting.js";
9
7
  import { requestMergeQueueAdmission, resolveMergeQueueProtocol, } from "./merge-queue-protocol.js";
10
- import { parseStoredQueueRepairContext } from "./merge-queue-incident.js";
11
- import { buildAgentSessionExternalUrls } from "./agent-session-presentation.js";
12
8
  import { WorktreeManager } from "./worktree-manager.js";
13
9
  import { resolveAuthoritativeLinearStopState } from "./linear-workflow.js";
14
10
  import { execCommand } from "./utils.js";
@@ -17,12 +13,9 @@ const DEFAULT_QUEUE_REPAIR_BUDGET = 3;
17
13
  const DEFAULT_REVIEW_FIX_BUDGET = 3;
18
14
  const DEFAULT_ZOMBIE_RECOVERY_BUDGET = 5;
19
15
  const ZOMBIE_RECOVERY_BASE_DELAY_MS = 15_000; // 15s, 30s, 60s, 120s, 240s
20
- // Queue health monitor: wait before probing a freshly-queued PR.
21
- // TODO: replace updatedAt with a true factory_state_changed_at timestamp —
22
- // updatedAt can reset on unrelated row mutations (e.g. webhook metadata).
23
- const QUEUE_HEALTH_GRACE_MS = 120_000;
24
- // Suppress repeated probe-failure feed events — at most one per issue per window.
25
- const QUEUE_HEALTH_PROBE_FAILURE_COOLDOWN_MS = 300_000; // 5 minutes
16
+ import { QueueHealthMonitor } from "./queue-health-monitor.js";
17
+ import { IdleIssueReconciler, resolveBranchOwnerForStateTransition } from "./idle-reconciliation.js";
18
+ import { LinearSessionSync } from "./linear-session-sync.js";
26
19
  function slugify(value) {
27
20
  return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 60);
28
21
  }
@@ -93,7 +86,6 @@ function buildRunPrompt(issue, runType, repoPath, context) {
93
86
  }
94
87
  return lines.join("\n");
95
88
  }
96
- const PROGRESS_THROTTLE_MS = 10_000;
97
89
  export class RunOrchestrator {
98
90
  config;
99
91
  db;
@@ -103,9 +95,10 @@ export class RunOrchestrator {
103
95
  logger;
104
96
  feed;
105
97
  worktreeManager;
106
- progressThrottle = new Map();
107
98
  /** Tracks last probe-failure feed event per issue to avoid spamming the operator feed. */
108
- probeFailureFeedTimes = new Map();
99
+ queueHealthMonitor;
100
+ idleReconciler;
101
+ linearSync;
109
102
  activeThreadId;
110
103
  botIdentity;
111
104
  constructor(config, db, codex, linearProvider, enqueueIssue, logger, feed) {
@@ -117,6 +110,14 @@ export class RunOrchestrator {
117
110
  this.logger = logger;
118
111
  this.feed = feed;
119
112
  this.worktreeManager = new WorktreeManager(config);
113
+ this.linearSync = new LinearSessionSync(config, db, linearProvider, logger, feed);
114
+ this.idleReconciler = new IdleIssueReconciler(db, config, {
115
+ requestMergeQueueAdmission: (issue, projectId) => this.requestMergeQueueAdmission(issue, projectId),
116
+ enqueueIssue: (projectId, issueId) => this.enqueueIssue(projectId, issueId),
117
+ }, logger, feed);
118
+ this.queueHealthMonitor = new QueueHealthMonitor(db, config, {
119
+ advanceIdleIssue: (issue, newState, options) => this.idleReconciler.advanceIdleIssue(issue, newState, options),
120
+ }, logger, feed);
120
121
  }
121
122
  // ─── Run ────────────────────────────────────────────────────────
122
123
  async run(item) {
@@ -219,11 +220,17 @@ export class RunOrchestrator {
219
220
  try {
220
221
  // Ensure worktree
221
222
  await this.worktreeManager.ensureIssueWorktree(project.repoPath, project.worktreeRoot, worktreePath, branchName, { allowExistingOutsideRoot: issue.branchName !== undefined });
222
- // Set bot git identity when GitHub App is configured
223
+ // Set bot git identity and push credentials when GitHub App is configured.
224
+ // This ensures commits are authored by and pushes are authenticated as
225
+ // patchrelay[bot], not the system user.
223
226
  if (this.botIdentity) {
224
227
  const gitBin = this.config.runner.gitBin;
225
228
  await execCommand(gitBin, ["-C", worktreePath, "config", "user.name", this.botIdentity.name], { timeoutMs: 5_000 });
226
229
  await execCommand(gitBin, ["-C", worktreePath, "config", "user.email", this.botIdentity.email], { timeoutMs: 5_000 });
230
+ // Override credential helper to use the App installation token for git push.
231
+ // The helper script reads the token file and returns it as the password.
232
+ const credentialHelper = `!f() { echo "username=x-access-token"; echo "password=$(cat ${this.botIdentity.tokenFile})"; }; f`;
233
+ await execCommand(gitBin, ["-C", worktreePath, "config", "credential.helper", credentialHelper], { timeoutMs: 5_000 });
227
234
  }
228
235
  // Freshen the worktree: fetch + rebase onto latest base branch.
229
236
  // This prevents branch contamination when local main has drifted
@@ -279,8 +286,8 @@ export class RunOrchestrator {
279
286
  });
280
287
  this.logger.error({ issueKey: issue.issueKey, runType, error: message }, `Failed to launch ${runType} run`);
281
288
  const failedIssue = this.db.getIssue(item.projectId, item.issueId) ?? issue;
282
- void this.emitLinearActivity(failedIssue, buildRunFailureActivity(runType, `Failed to start ${lowerCaseFirst(message)}`));
283
- void this.syncLinearSession(failedIssue, { activeRunType: runType });
289
+ void this.linearSync.emitActivity(failedIssue, buildRunFailureActivity(runType, `Failed to start ${lowerCaseFirst(message)}`));
290
+ void this.linearSync.syncSession(failedIssue, { activeRunType: runType });
284
291
  throw error;
285
292
  }
286
293
  this.db.updateRunThread(run.id, { threadId, turnId });
@@ -296,8 +303,8 @@ export class RunOrchestrator {
296
303
  this.logger.info({ issueKey: issue.issueKey, runType, threadId, turnId }, `Started ${runType} run`);
297
304
  // Emit Linear activity + plan
298
305
  const freshIssue = this.db.getIssue(item.projectId, item.issueId) ?? issue;
299
- void this.emitLinearActivity(freshIssue, buildRunStartedActivity(runType));
300
- void this.syncLinearSession(freshIssue, { activeRunType: runType });
306
+ void this.linearSync.emitActivity(freshIssue, buildRunStartedActivity(runType));
307
+ void this.linearSync.syncSession(freshIssue, { activeRunType: runType });
301
308
  }
302
309
  // ─── Pre-run branch freshening ────────────────────────────────────
303
310
  /**
@@ -378,12 +385,12 @@ export class RunOrchestrator {
378
385
  });
379
386
  }
380
387
  // Emit ephemeral progress activity to Linear for notable in-flight events
381
- this.maybeEmitProgressActivity(notification, run);
388
+ this.linearSync.maybeEmitProgress(notification, run);
382
389
  // Sync codex plan to Linear session when it updates
383
390
  if (notification.method === "turn/plan/updated") {
384
391
  const issue = this.db.getIssue(run.projectId, run.linearIssueId);
385
392
  if (issue) {
386
- void this.syncLinearSessionWithCodexPlan(issue, notification.params);
393
+ void this.linearSync.syncCodexPlan(issue, notification.params);
387
394
  }
388
395
  }
389
396
  if (notification.method !== "turn/completed")
@@ -417,9 +424,9 @@ export class RunOrchestrator {
417
424
  summary: `Turn failed for ${run.runType}`,
418
425
  });
419
426
  const failedIssue = this.db.getIssue(run.projectId, run.linearIssueId) ?? issue;
420
- void this.emitLinearActivity(failedIssue, buildRunFailureActivity(run.runType));
421
- void this.syncLinearSession(failedIssue, { activeRunType: run.runType });
422
- this.progressThrottle.delete(run.id);
427
+ void this.linearSync.emitActivity(failedIssue, buildRunFailureActivity(run.runType));
428
+ void this.linearSync.syncSession(failedIssue, { activeRunType: run.runType });
429
+ this.linearSync.clearProgress(run.id);
423
430
  this.activeThreadId = undefined;
424
431
  return;
425
432
  }
@@ -442,9 +449,9 @@ export class RunOrchestrator {
442
449
  status: "branch_not_advanced",
443
450
  summary: verifiedRepairError,
444
451
  });
445
- void this.emitLinearActivity(heldIssue, buildRunFailureActivity(run.runType, verifiedRepairError));
446
- void this.syncLinearSession(heldIssue, { activeRunType: run.runType });
447
- this.progressThrottle.delete(run.id);
452
+ void this.linearSync.emitActivity(heldIssue, buildRunFailureActivity(run.runType, verifiedRepairError));
453
+ void this.linearSync.syncSession(heldIssue, { activeRunType: run.runType });
454
+ this.linearSync.clearProgress(run.id);
448
455
  this.activeThreadId = undefined;
449
456
  return;
450
457
  }
@@ -499,54 +506,16 @@ export class RunOrchestrator {
499
506
  // Emit Linear completion activity + plan
500
507
  const updatedIssue = this.db.getIssue(run.projectId, run.linearIssueId) ?? issue;
501
508
  const completionSummary = report.assistantMessages.at(-1)?.slice(0, 300) ?? `${run.runType} completed.`;
502
- void this.emitLinearActivity(updatedIssue, buildRunCompletedActivity({
509
+ void this.linearSync.emitActivity(updatedIssue, buildRunCompletedActivity({
503
510
  runType: run.runType,
504
511
  completionSummary,
505
512
  postRunState: updatedIssue.factoryState,
506
513
  ...(updatedIssue.prNumber !== undefined ? { prNumber: updatedIssue.prNumber } : {}),
507
514
  }));
508
- void this.syncLinearSession(updatedIssue);
509
- this.progressThrottle.delete(run.id);
515
+ void this.linearSync.syncSession(updatedIssue);
516
+ this.linearSync.clearProgress(run.id);
510
517
  this.activeThreadId = undefined;
511
518
  }
512
- // ─── In-flight progress ──────────────────────────────────────────
513
- maybeEmitProgressActivity(notification, run) {
514
- const activity = this.resolveProgressActivity(notification);
515
- if (!activity)
516
- return;
517
- const now = Date.now();
518
- const lastEmit = this.progressThrottle.get(run.id) ?? 0;
519
- if (now - lastEmit < PROGRESS_THROTTLE_MS)
520
- return;
521
- this.progressThrottle.set(run.id, now);
522
- const issue = this.db.getIssue(run.projectId, run.linearIssueId);
523
- if (issue) {
524
- void this.emitLinearActivity(issue, activity, { ephemeral: true });
525
- }
526
- }
527
- resolveProgressActivity(notification) {
528
- if (notification.method === "item/started") {
529
- const item = notification.params.item;
530
- if (!item)
531
- return undefined;
532
- const type = typeof item.type === "string" ? item.type : undefined;
533
- if (type === "commandExecution") {
534
- const cmd = item.command;
535
- const cmdStr = Array.isArray(cmd) ? cmd.join(" ") : typeof cmd === "string" ? cmd : undefined;
536
- return { type: "action", action: "Running", parameter: cmdStr?.slice(0, 120) ?? "command" };
537
- }
538
- if (type === "mcpToolCall") {
539
- const server = typeof item.server === "string" ? item.server : "";
540
- const tool = typeof item.tool === "string" ? item.tool : "";
541
- return { type: "action", action: "Using", parameter: `${server}/${tool}` };
542
- }
543
- if (type === "dynamicToolCall") {
544
- const tool = typeof item.tool === "string" ? item.tool : "tool";
545
- return { type: "action", action: "Using", parameter: tool };
546
- }
547
- }
548
- return undefined;
549
- }
550
519
  // ─── Active status for query ──────────────────────────────────────
551
520
  async getActiveRunStatus(issueKey) {
552
521
  const issue = this.db.getIssueByKey(issueKey);
@@ -570,309 +539,14 @@ export class RunOrchestrator {
570
539
  }
571
540
  // Preemptively detect stuck merge-queue PRs (conflicts visible on
572
541
  // GitHub) and dispatch queue_repair before the Steward evicts.
573
- await this.reconcileQueueHealth();
542
+ await this.queueHealthMonitor.reconcile();
574
543
  // Advance issues stuck in pr_open whose stored PR metadata already
575
544
  // shows they should transition (e.g. approved PR, missed webhook).
576
- await this.reconcileIdleIssues();
577
- }
578
- // ─── Queue Health Monitor ──────────────────────────────────────────
579
- async reconcileQueueHealth() {
580
- for (const issue of this.db.listAwaitingQueueIssues()) {
581
- await this.probeQueuedIssue(issue);
582
- }
583
- }
584
- async probeQueuedIssue(issue) {
585
- if (!issue.prNumber)
586
- return;
587
- const project = this.config.projects.find((p) => p.id === issue.projectId);
588
- if (!project?.github?.repoFullName)
589
- return;
590
- // Grace period — don't probe PRs that just entered the queue.
591
- const age = Date.now() - Date.parse(issue.updatedAt);
592
- if (age < QUEUE_HEALTH_GRACE_MS)
593
- return;
594
- const protocol = resolveMergeQueueProtocol(project);
595
- let pr;
596
- try {
597
- const { stdout } = await execCommand("gh", [
598
- "pr", "view", String(issue.prNumber),
599
- "--repo", project.github.repoFullName,
600
- "--json", "state,mergeable,mergeStateStatus,headRefOid,labels",
601
- ], { timeoutMs: 10_000 });
602
- pr = JSON.parse(stdout);
603
- }
604
- catch (error) {
605
- this.logger.debug({ issueKey: issue.issueKey, prNumber: issue.prNumber, error: error instanceof Error ? error.message : String(error) }, "Queue health: failed to probe GitHub PR state");
606
- // Throttle feed events — at most one per issue per cooldown window.
607
- const issueKey = `${issue.projectId}::${issue.linearIssueId}`;
608
- const lastFeedAt = this.probeFailureFeedTimes.get(issueKey) ?? 0;
609
- if (Date.now() - lastFeedAt >= QUEUE_HEALTH_PROBE_FAILURE_COOLDOWN_MS) {
610
- this.probeFailureFeedTimes.set(issueKey, Date.now());
611
- this.feed?.publish({
612
- level: "info",
613
- kind: "github",
614
- issueKey: issue.issueKey,
615
- projectId: issue.projectId,
616
- stage: "awaiting_queue",
617
- status: "queue_health_probe_failed",
618
- summary: `Queue health: failed to probe PR #${issue.prNumber}`,
619
- });
620
- }
621
- return;
622
- }
623
- // Successful probe — clear any probe-failure throttle for this issue.
624
- this.probeFailureFeedTimes.delete(`${issue.projectId}::${issue.linearIssueId}`);
625
- // Missed merge webhook — advance to done.
626
- if (pr.state === "MERGED") {
627
- this.db.upsertIssue({ projectId: issue.projectId, linearIssueId: issue.linearIssueId, prState: "merged" });
628
- this.advanceIdleIssue(issue, "done", { clearFailureProvenance: true });
629
- return;
630
- }
631
- // Non-open PRs (closed, draft) — don't enter repair logic.
632
- if (pr.state !== "OPEN")
633
- return;
634
- // Verify admission label is still present — if the Steward removed it
635
- // (eviction, dequeue) but PatchRelay missed the webhook, we should not
636
- // treat a DIRTY PR as a queue-health problem.
637
- const hasQueueLabel = pr.labels?.some((l) => l.name === protocol.admissionLabel) ?? false;
638
- if (!hasQueueLabel)
639
- return;
640
- // Detect queue issues: either GitHub reports DIRTY, or the steward
641
- // eviction check run failed (webhook may have been missed).
642
- const isDirty = pr.mergeStateStatus === "DIRTY" || pr.mergeable === "CONFLICTING";
643
- let hasEvictionCheckRun = false;
644
- if (!isDirty) {
645
- // Check for missed eviction webhook by looking for the steward's
646
- // check run on the PR head.
647
- try {
648
- const { stdout: checksOut } = await execCommand("gh", [
649
- "api", `repos/${project.github.repoFullName}/commits/${pr.headRefOid}/check-runs`,
650
- "--jq", `.check_runs[] | select(.name == "${protocol.evictionCheckName}" and .conclusion == "failure") | .name`,
651
- ], { timeoutMs: 10_000 });
652
- hasEvictionCheckRun = checksOut.trim().length > 0;
653
- }
654
- catch {
655
- // Best-effort check.
656
- }
657
- }
658
- if (isDirty || hasEvictionCheckRun) {
659
- const headRefOid = pr.headRefOid ?? "unknown";
660
- const reason = hasEvictionCheckRun ? "queue_eviction_missed" : "preemptive_conflict";
661
- const signature = `preemptive_queue_conflict:${headRefOid}`;
662
- const pendingRunContext = {
663
- source: "queue_health_monitor",
664
- failureReason: reason,
665
- failureHeadSha: headRefOid,
666
- failureSignature: signature,
667
- };
668
- if (isDuplicateRepairAttempt(issue, pendingRunContext)) {
669
- return;
670
- }
671
- this.db.upsertIssue({
672
- projectId: issue.projectId,
673
- linearIssueId: issue.linearIssueId,
674
- lastAttemptedFailureHeadSha: headRefOid,
675
- lastAttemptedFailureSignature: signature,
676
- });
677
- this.advanceIdleIssue(issue, "repairing_queue", {
678
- pendingRunType: "queue_repair",
679
- pendingRunContext,
680
- });
681
- this.logger.info({ issueKey: issue.issueKey, prNumber: issue.prNumber, headRefOid, reason }, "Queue health: queue issue detected, dispatching repair");
682
- this.feed?.publish({
683
- level: "warn",
684
- kind: "github",
685
- issueKey: issue.issueKey,
686
- projectId: issue.projectId,
687
- stage: "repairing_queue",
688
- status: hasEvictionCheckRun ? "queue_health_eviction_detected" : "queue_health_conflict_detected",
689
- summary: hasEvictionCheckRun
690
- ? `Queue health: missed eviction detected on PR #${issue.prNumber}, dispatching repair`
691
- : `Queue health: merge conflict detected on PR #${issue.prNumber}, dispatching preemptive repair`,
692
- });
693
- }
694
- }
695
- async reconcileIdleIssues() {
696
- for (const issue of this.db.listIdleNonTerminalIssues()) {
697
- // PR already merged — advance to done regardless of current state
698
- if (issue.prState === "merged") {
699
- this.advanceIdleIssue(issue, "done", { clearFailureProvenance: true });
700
- continue;
701
- }
702
- // Review approved + checks not failed — advance to awaiting_queue
703
- if (issue.prReviewState === "approved" && issue.prCheckStatus !== "failed") {
704
- if (issue.factoryState !== "awaiting_queue" || issue.branchOwner !== "merge_steward") {
705
- this.advanceIdleIssue(issue, "awaiting_queue", { clearFailureProvenance: true });
706
- }
707
- else if (!issue.queueLabelApplied) {
708
- // Retry failed label application
709
- await this.requestMergeQueueAdmission(issue, issue.projectId);
710
- }
711
- continue;
712
- }
713
- // Checks failed + idle — route based on durable GitHub failure provenance.
714
- if (issue.prCheckStatus === "failed") {
715
- if (issue.lastGitHubFailureSource === "queue_eviction") {
716
- const pendingRunContext = buildFailureContext(issue);
717
- if (isDuplicateRepairAttempt(issue, pendingRunContext)) {
718
- this.advanceIdleIssue(issue, "repairing_queue");
719
- }
720
- else {
721
- this.advanceIdleIssue(issue, "repairing_queue", {
722
- pendingRunType: "queue_repair",
723
- ...(pendingRunContext ? { pendingRunContext } : {}),
724
- });
725
- }
726
- continue;
727
- }
728
- if (issue.lastGitHubFailureSource === "branch_ci") {
729
- const pendingRunContext = buildFailureContext(issue);
730
- if (isDuplicateRepairAttempt(issue, pendingRunContext)) {
731
- this.advanceIdleIssue(issue, "repairing_ci");
732
- }
733
- else {
734
- this.advanceIdleIssue(issue, "repairing_ci", {
735
- pendingRunType: "ci_repair",
736
- ...(pendingRunContext ? { pendingRunContext } : {}),
737
- });
738
- }
739
- continue;
740
- }
741
- if (issue.factoryState === "awaiting_queue") {
742
- // Infer provenance: check if steward eviction check run exists on the PR
743
- const inferProject = this.config.projects.find((p) => p.id === issue.projectId);
744
- const inferProtocol = resolveMergeQueueProtocol(inferProject);
745
- let inferred = "branch_ci";
746
- const probeSha = issue.lastGitHubFailureHeadSha ?? issue.lastGitHubCiSnapshotHeadSha;
747
- if (inferProject?.github?.repoFullName && issue.prNumber && probeSha) {
748
- try {
749
- const { stdout } = await execCommand("gh", [
750
- "api",
751
- `repos/${inferProject.github.repoFullName}/commits/${probeSha}/check-runs`,
752
- "--jq", `.check_runs[] | select(.name == "${inferProtocol.evictionCheckName}" and .conclusion == "failure") | .name`,
753
- ], { timeoutMs: 10_000 });
754
- if (stdout.trim().length > 0)
755
- inferred = "queue_eviction";
756
- }
757
- catch { /* best effort */ }
758
- }
759
- const inferRunType = inferred === "queue_eviction" ? "queue_repair" : "ci_repair";
760
- const inferState = inferred === "queue_eviction" ? "repairing_queue" : "repairing_ci";
761
- this.logger.info({ issueKey: issue.issueKey, prNumber: issue.prNumber, inferred }, "Inferred failure provenance for awaiting_queue issue");
762
- const pendingRunContext = buildFailureContext(issue);
763
- this.advanceIdleIssue(issue, inferState, {
764
- pendingRunType: inferRunType,
765
- ...(pendingRunContext ? { pendingRunContext } : {}),
766
- });
767
- continue;
768
- }
769
- const pendingRunContext = buildFailureContext(issue);
770
- if (isDuplicateRepairAttempt(issue, pendingRunContext)) {
771
- this.advanceIdleIssue(issue, "repairing_ci");
772
- }
773
- else {
774
- this.advanceIdleIssue(issue, "repairing_ci", {
775
- pendingRunType: "ci_repair",
776
- ...(pendingRunContext ? { pendingRunContext } : {}),
777
- });
778
- }
779
- continue;
780
- }
781
- // For pr_open issues with no review decision, check GitHub for stale metadata
782
- if (issue.factoryState === "pr_open" && !issue.prReviewState) {
783
- await this.reconcileFromGitHub(issue);
784
- }
785
- }
786
- // Unblock delegated issues whose blockers have been resolved.
787
- for (const issue of this.db.listBlockedDelegatedIssues()) {
788
- const unresolved = this.db.countUnresolvedBlockers(issue.projectId, issue.linearIssueId);
789
- if (unresolved === 0) {
790
- this.db.upsertIssue({
791
- projectId: issue.projectId,
792
- linearIssueId: issue.linearIssueId,
793
- pendingRunType: "implementation",
794
- });
795
- this.enqueueIssue(issue.projectId, issue.linearIssueId);
796
- }
797
- }
798
- }
799
- async reconcileFromGitHub(issue) {
800
- const project = this.config.projects.find((p) => p.id === issue.projectId);
801
- if (!project?.github?.repoFullName || !issue.prNumber)
802
- return;
803
- try {
804
- const { stdout } = await execCommand("gh", [
805
- "pr", "view", String(issue.prNumber),
806
- "--repo", project.github.repoFullName,
807
- "--json", "state,reviewDecision",
808
- ], { timeoutMs: 10_000 });
809
- const pr = JSON.parse(stdout);
810
- if (pr.state === "MERGED") {
811
- this.db.upsertIssue({ projectId: issue.projectId, linearIssueId: issue.linearIssueId, prState: "merged" });
812
- this.advanceIdleIssue(issue, "done", { clearFailureProvenance: true });
813
- }
814
- else if (pr.reviewDecision === "APPROVED") {
815
- this.db.upsertIssue({ projectId: issue.projectId, linearIssueId: issue.linearIssueId, prReviewState: "approved" });
816
- this.advanceIdleIssue(issue, "awaiting_queue", { clearFailureProvenance: true });
817
- }
818
- }
819
- catch (error) {
820
- this.logger.debug({ issueKey: issue.issueKey, error: error instanceof Error ? error.message : String(error) }, "Failed to query GitHub PR state during reconciliation");
821
- }
545
+ await this.idleReconciler.reconcile();
822
546
  }
547
+ // advanceIdleIssue is now on IdleIssueReconciler — delegate for internal callers
823
548
  advanceIdleIssue(issue, newState, options) {
824
- if (issue.factoryState === newState && !options?.pendingRunType && !options?.clearFailureProvenance) {
825
- return;
826
- }
827
- this.logger.info({ issueKey: issue.issueKey, from: issue.factoryState, to: newState, pendingRunType: options?.pendingRunType }, "Reconciliation: advancing idle issue");
828
- // Reset queueLabelApplied when entering or leaving awaiting_queue so
829
- // the retry loop re-applies the label on each queue cycle.
830
- const resetQueueLabel = newState === "awaiting_queue" || issue.factoryState === "awaiting_queue";
831
- this.db.upsertIssue({
832
- projectId: issue.projectId,
833
- linearIssueId: issue.linearIssueId,
834
- factoryState: newState,
835
- ...(options?.pendingRunType ? { pendingRunType: options.pendingRunType } : {}),
836
- ...(options?.pendingRunType
837
- ? {
838
- pendingRunContextJson: options.pendingRunContext ? JSON.stringify(options.pendingRunContext) : null,
839
- }
840
- : {}),
841
- ...(resetQueueLabel ? { queueLabelApplied: false } : {}),
842
- ...(options?.clearFailureProvenance
843
- ? {
844
- lastGitHubFailureSource: null,
845
- lastGitHubFailureHeadSha: null,
846
- lastGitHubFailureSignature: null,
847
- lastGitHubFailureCheckName: null,
848
- lastGitHubFailureCheckUrl: null,
849
- lastGitHubFailureContextJson: null,
850
- lastGitHubFailureAt: null,
851
- lastQueueIncidentJson: null,
852
- lastAttemptedFailureHeadSha: null,
853
- lastAttemptedFailureSignature: null,
854
- }
855
- : {}),
856
- });
857
- const branchOwner = this.resolveBranchOwnerForStateTransition(newState, options?.pendingRunType);
858
- if (branchOwner) {
859
- this.db.setBranchOwner(issue.projectId, issue.linearIssueId, branchOwner);
860
- }
861
- this.feed?.publish({
862
- level: "info",
863
- kind: "stage",
864
- issueKey: issue.issueKey,
865
- projectId: issue.projectId,
866
- stage: newState,
867
- status: "reconciled",
868
- summary: `Reconciliation: ${issue.factoryState} \u2192 ${newState}`,
869
- });
870
- if (newState === "awaiting_queue" && issue.factoryState !== "awaiting_queue") {
871
- this.requestMergeQueueAdmission(issue, issue.projectId);
872
- }
873
- if (options?.pendingRunType) {
874
- this.enqueueIssue(issue.projectId, issue.linearIssueId);
875
- }
549
+ this.idleReconciler.advanceIdleIssue(issue, newState, options);
876
550
  }
877
551
  /**
878
552
  * After a zombie/stale run is cleared, decide whether to re-enqueue
@@ -1036,9 +710,9 @@ export class RunOrchestrator {
1036
710
  });
1037
711
  }
1038
712
  else {
1039
- void this.emitLinearActivity(failedIssue, buildRunFailureActivity(run.runType, "The Codex turn was interrupted."));
713
+ void this.linearSync.emitActivity(failedIssue, buildRunFailureActivity(run.runType, "The Codex turn was interrupted."));
1040
714
  }
1041
- void this.syncLinearSession(failedIssue, { activeRunType: run.runType });
715
+ void this.linearSync.syncSession(failedIssue, { activeRunType: run.runType });
1042
716
  return;
1043
717
  }
1044
718
  // Handle completed turn discovered during reconciliation
@@ -1135,11 +809,11 @@ export class RunOrchestrator {
1135
809
  summary: `Escalated: ${reason}`,
1136
810
  });
1137
811
  const escalatedIssue = this.db.getIssue(issue.projectId, issue.linearIssueId) ?? issue;
1138
- void this.emitLinearActivity(escalatedIssue, {
812
+ void this.linearSync.emitActivity(escalatedIssue, {
1139
813
  type: "error",
1140
814
  body: `PatchRelay needs human help to continue.\n\n${reason}`,
1141
815
  });
1142
- void this.syncLinearSession(escalatedIssue);
816
+ void this.linearSync.syncSession(escalatedIssue);
1143
817
  }
1144
818
  /** Add the merge queue admission label for external-queue projects (best-effort). */
1145
819
  async requestMergeQueueAdmission(issue, projectId) {
@@ -1171,13 +845,7 @@ export class RunOrchestrator {
1171
845
  });
1172
846
  }
1173
847
  resolveBranchOwnerForStateTransition(newState, pendingRunType) {
1174
- if (pendingRunType)
1175
- return "patchrelay";
1176
- if (newState === "awaiting_queue")
1177
- return "merge_steward";
1178
- if (newState === "repairing_ci" || newState === "repairing_queue")
1179
- return "patchrelay";
1180
- return undefined;
848
+ return resolveBranchOwnerForStateTransition(newState, pendingRunType);
1181
849
  }
1182
850
  async verifyReactiveRunAdvancedBranch(run, issue) {
1183
851
  if (run.runType !== "ci_repair" && run.runType !== "queue_repair") {
@@ -1214,93 +882,6 @@ export class RunOrchestrator {
1214
882
  return undefined;
1215
883
  }
1216
884
  }
1217
- async emitLinearActivity(issue, content, options) {
1218
- if (!issue.agentSessionId)
1219
- return;
1220
- try {
1221
- const linear = await this.linearProvider.forProject(issue.projectId);
1222
- if (!linear)
1223
- return;
1224
- const allowEphemeral = content.type === "thought" || content.type === "action";
1225
- await linear.createAgentActivity({
1226
- agentSessionId: issue.agentSessionId,
1227
- content,
1228
- ...(options?.ephemeral && allowEphemeral ? { ephemeral: true } : {}),
1229
- });
1230
- }
1231
- catch (error) {
1232
- const msg = error instanceof Error ? error.message : String(error);
1233
- this.logger.warn({ issueKey: issue.issueKey, type: content.type, error: msg }, "Failed to emit Linear activity");
1234
- this.feed?.publish({
1235
- level: "warn",
1236
- kind: "linear",
1237
- issueKey: issue.issueKey,
1238
- projectId: issue.projectId,
1239
- status: "linear_error",
1240
- summary: `Linear activity failed: ${msg}`,
1241
- });
1242
- }
1243
- }
1244
- async syncLinearSession(issue, options) {
1245
- if (!issue.agentSessionId)
1246
- return;
1247
- try {
1248
- const linear = await this.linearProvider.forProject(issue.projectId);
1249
- if (!linear?.updateAgentSession)
1250
- return;
1251
- const externalUrls = buildAgentSessionExternalUrls(this.config, {
1252
- ...(issue.issueKey ? { issueKey: issue.issueKey } : {}),
1253
- ...(issue.prUrl ? { prUrl: issue.prUrl } : {}),
1254
- });
1255
- await linear.updateAgentSession({
1256
- agentSessionId: issue.agentSessionId,
1257
- plan: buildAgentSessionPlanForIssue(issue, options),
1258
- ...(externalUrls ? { externalUrls } : {}),
1259
- });
1260
- }
1261
- catch (error) {
1262
- const msg = error instanceof Error ? error.message : String(error);
1263
- this.logger.warn({ issueKey: issue.issueKey, error: msg }, "Failed to update Linear plan");
1264
- }
1265
- }
1266
- async syncLinearSessionWithCodexPlan(issue, params) {
1267
- if (!issue.agentSessionId)
1268
- return;
1269
- const plan = params.plan;
1270
- if (!Array.isArray(plan))
1271
- return;
1272
- const STATUS_MAP = {
1273
- pending: "pending",
1274
- inProgress: "inProgress",
1275
- completed: "completed",
1276
- };
1277
- const steps = plan.map((entry) => {
1278
- const e = entry;
1279
- const step = typeof e.step === "string" ? e.step : String(e.step ?? "");
1280
- const status = typeof e.status === "string" ? (STATUS_MAP[e.status] ?? "pending") : "pending";
1281
- return { content: step, status };
1282
- });
1283
- // Prepend a "Prepare workspace" completed step and append a "Merge" pending step
1284
- // to frame the codex plan within the PatchRelay lifecycle
1285
- const fullPlan = [
1286
- { content: "Prepare workspace", status: "completed" },
1287
- ...steps,
1288
- { content: "Merge", status: "pending" },
1289
- ];
1290
- try {
1291
- const linear = await this.linearProvider.forProject(issue.projectId);
1292
- if (!linear?.updateAgentSession)
1293
- return;
1294
- await linear.updateAgentSession({
1295
- agentSessionId: issue.agentSessionId,
1296
- plan: fullPlan,
1297
- });
1298
- }
1299
- catch (error) {
1300
- const msg = error instanceof Error ? error.message : String(error);
1301
- this.logger.warn({ issueKey: issue.issueKey, error: msg }, "Failed to sync codex plan to Linear");
1302
- }
1303
- }
1304
885
  async readThreadWithRetry(threadId, maxRetries = 3) {
1305
886
  for (let attempt = 0; attempt < maxRetries; attempt++) {
1306
887
  try {
@@ -1350,40 +931,6 @@ function resolveRecoverablePostRunState(issue) {
1350
931
  }
1351
932
  return resolvePostRunState(issue);
1352
933
  }
1353
- function buildFailureContext(issue) {
1354
- const storedFailureContext = parseGitHubFailureContext(issue.lastGitHubFailureContextJson);
1355
- const queueRepairContext = issue.lastQueueIncidentJson
1356
- ? parseStoredQueueRepairContext(issue.lastQueueIncidentJson)
1357
- : undefined;
1358
- if (!queueRepairContext
1359
- && !issue.lastGitHubFailureSource
1360
- && !issue.lastGitHubFailureHeadSha
1361
- && !issue.lastGitHubFailureSignature
1362
- && !issue.lastGitHubFailureCheckName
1363
- && !issue.lastGitHubFailureCheckUrl
1364
- && !storedFailureContext) {
1365
- return undefined;
1366
- }
1367
- return {
1368
- ...(issue.lastGitHubFailureSource ? { failureReason: issue.lastGitHubFailureSource } : {}),
1369
- ...(issue.lastGitHubFailureHeadSha ? { failureHeadSha: issue.lastGitHubFailureHeadSha } : {}),
1370
- ...(issue.lastGitHubFailureSignature ? { failureSignature: issue.lastGitHubFailureSignature } : {}),
1371
- ...(issue.lastGitHubFailureCheckName ? { checkName: issue.lastGitHubFailureCheckName } : {}),
1372
- ...(issue.lastGitHubFailureCheckUrl ? { checkUrl: issue.lastGitHubFailureCheckUrl } : {}),
1373
- ...(storedFailureContext ? storedFailureContext : {}),
1374
- ...(queueRepairContext ? queueRepairContext : {}),
1375
- };
1376
- }
1377
- function isDuplicateRepairAttempt(issue, context) {
1378
- const signature = typeof context?.failureSignature === "string" ? context.failureSignature : undefined;
1379
- const headSha = typeof context?.failureHeadSha === "string"
1380
- ? context.failureHeadSha
1381
- : typeof context?.headSha === "string" ? context.headSha : undefined;
1382
- if (!signature)
1383
- return false;
1384
- return issue.lastAttemptedFailureSignature === signature
1385
- && (headSha === undefined || issue.lastAttemptedFailureHeadSha === headSha);
1386
- }
1387
934
  function appendQueueRepairContext(lines, context) {
1388
935
  const incidentTitle = typeof context?.incidentTitle === "string" ? context.incidentTitle.trim() : "";
1389
936
  const incidentSummary = typeof context?.incidentSummary === "string" ? context.incidentSummary.trim() : "";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "patchrelay",
3
- "version": "0.35.7",
3
+ "version": "0.35.9",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "repository": {