patchrelay 0.35.7 → 0.35.8

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.8",
4
+ "commit": "727fa6d1f476",
5
+ "builtAt": "2026-04-03T23:33:50.106Z"
6
6
  }
@@ -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) {
@@ -279,8 +280,8 @@ export class RunOrchestrator {
279
280
  });
280
281
  this.logger.error({ issueKey: issue.issueKey, runType, error: message }, `Failed to launch ${runType} run`);
281
282
  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 });
283
+ void this.linearSync.emitActivity(failedIssue, buildRunFailureActivity(runType, `Failed to start ${lowerCaseFirst(message)}`));
284
+ void this.linearSync.syncSession(failedIssue, { activeRunType: runType });
284
285
  throw error;
285
286
  }
286
287
  this.db.updateRunThread(run.id, { threadId, turnId });
@@ -296,8 +297,8 @@ export class RunOrchestrator {
296
297
  this.logger.info({ issueKey: issue.issueKey, runType, threadId, turnId }, `Started ${runType} run`);
297
298
  // Emit Linear activity + plan
298
299
  const freshIssue = this.db.getIssue(item.projectId, item.issueId) ?? issue;
299
- void this.emitLinearActivity(freshIssue, buildRunStartedActivity(runType));
300
- void this.syncLinearSession(freshIssue, { activeRunType: runType });
300
+ void this.linearSync.emitActivity(freshIssue, buildRunStartedActivity(runType));
301
+ void this.linearSync.syncSession(freshIssue, { activeRunType: runType });
301
302
  }
302
303
  // ─── Pre-run branch freshening ────────────────────────────────────
303
304
  /**
@@ -378,12 +379,12 @@ export class RunOrchestrator {
378
379
  });
379
380
  }
380
381
  // Emit ephemeral progress activity to Linear for notable in-flight events
381
- this.maybeEmitProgressActivity(notification, run);
382
+ this.linearSync.maybeEmitProgress(notification, run);
382
383
  // Sync codex plan to Linear session when it updates
383
384
  if (notification.method === "turn/plan/updated") {
384
385
  const issue = this.db.getIssue(run.projectId, run.linearIssueId);
385
386
  if (issue) {
386
- void this.syncLinearSessionWithCodexPlan(issue, notification.params);
387
+ void this.linearSync.syncCodexPlan(issue, notification.params);
387
388
  }
388
389
  }
389
390
  if (notification.method !== "turn/completed")
@@ -417,9 +418,9 @@ export class RunOrchestrator {
417
418
  summary: `Turn failed for ${run.runType}`,
418
419
  });
419
420
  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);
421
+ void this.linearSync.emitActivity(failedIssue, buildRunFailureActivity(run.runType));
422
+ void this.linearSync.syncSession(failedIssue, { activeRunType: run.runType });
423
+ this.linearSync.clearProgress(run.id);
423
424
  this.activeThreadId = undefined;
424
425
  return;
425
426
  }
@@ -442,9 +443,9 @@ export class RunOrchestrator {
442
443
  status: "branch_not_advanced",
443
444
  summary: verifiedRepairError,
444
445
  });
445
- void this.emitLinearActivity(heldIssue, buildRunFailureActivity(run.runType, verifiedRepairError));
446
- void this.syncLinearSession(heldIssue, { activeRunType: run.runType });
447
- this.progressThrottle.delete(run.id);
446
+ void this.linearSync.emitActivity(heldIssue, buildRunFailureActivity(run.runType, verifiedRepairError));
447
+ void this.linearSync.syncSession(heldIssue, { activeRunType: run.runType });
448
+ this.linearSync.clearProgress(run.id);
448
449
  this.activeThreadId = undefined;
449
450
  return;
450
451
  }
@@ -499,54 +500,16 @@ export class RunOrchestrator {
499
500
  // Emit Linear completion activity + plan
500
501
  const updatedIssue = this.db.getIssue(run.projectId, run.linearIssueId) ?? issue;
501
502
  const completionSummary = report.assistantMessages.at(-1)?.slice(0, 300) ?? `${run.runType} completed.`;
502
- void this.emitLinearActivity(updatedIssue, buildRunCompletedActivity({
503
+ void this.linearSync.emitActivity(updatedIssue, buildRunCompletedActivity({
503
504
  runType: run.runType,
504
505
  completionSummary,
505
506
  postRunState: updatedIssue.factoryState,
506
507
  ...(updatedIssue.prNumber !== undefined ? { prNumber: updatedIssue.prNumber } : {}),
507
508
  }));
508
- void this.syncLinearSession(updatedIssue);
509
- this.progressThrottle.delete(run.id);
509
+ void this.linearSync.syncSession(updatedIssue);
510
+ this.linearSync.clearProgress(run.id);
510
511
  this.activeThreadId = undefined;
511
512
  }
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
513
  // ─── Active status for query ──────────────────────────────────────
551
514
  async getActiveRunStatus(issueKey) {
552
515
  const issue = this.db.getIssueByKey(issueKey);
@@ -570,309 +533,14 @@ export class RunOrchestrator {
570
533
  }
571
534
  // Preemptively detect stuck merge-queue PRs (conflicts visible on
572
535
  // GitHub) and dispatch queue_repair before the Steward evicts.
573
- await this.reconcileQueueHealth();
536
+ await this.queueHealthMonitor.reconcile();
574
537
  // Advance issues stuck in pr_open whose stored PR metadata already
575
538
  // 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
- }
539
+ await this.idleReconciler.reconcile();
822
540
  }
541
+ // advanceIdleIssue is now on IdleIssueReconciler — delegate for internal callers
823
542
  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
- }
543
+ this.idleReconciler.advanceIdleIssue(issue, newState, options);
876
544
  }
877
545
  /**
878
546
  * After a zombie/stale run is cleared, decide whether to re-enqueue
@@ -1036,9 +704,9 @@ export class RunOrchestrator {
1036
704
  });
1037
705
  }
1038
706
  else {
1039
- void this.emitLinearActivity(failedIssue, buildRunFailureActivity(run.runType, "The Codex turn was interrupted."));
707
+ void this.linearSync.emitActivity(failedIssue, buildRunFailureActivity(run.runType, "The Codex turn was interrupted."));
1040
708
  }
1041
- void this.syncLinearSession(failedIssue, { activeRunType: run.runType });
709
+ void this.linearSync.syncSession(failedIssue, { activeRunType: run.runType });
1042
710
  return;
1043
711
  }
1044
712
  // Handle completed turn discovered during reconciliation
@@ -1135,11 +803,11 @@ export class RunOrchestrator {
1135
803
  summary: `Escalated: ${reason}`,
1136
804
  });
1137
805
  const escalatedIssue = this.db.getIssue(issue.projectId, issue.linearIssueId) ?? issue;
1138
- void this.emitLinearActivity(escalatedIssue, {
806
+ void this.linearSync.emitActivity(escalatedIssue, {
1139
807
  type: "error",
1140
808
  body: `PatchRelay needs human help to continue.\n\n${reason}`,
1141
809
  });
1142
- void this.syncLinearSession(escalatedIssue);
810
+ void this.linearSync.syncSession(escalatedIssue);
1143
811
  }
1144
812
  /** Add the merge queue admission label for external-queue projects (best-effort). */
1145
813
  async requestMergeQueueAdmission(issue, projectId) {
@@ -1171,13 +839,7 @@ export class RunOrchestrator {
1171
839
  });
1172
840
  }
1173
841
  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;
842
+ return resolveBranchOwnerForStateTransition(newState, pendingRunType);
1181
843
  }
1182
844
  async verifyReactiveRunAdvancedBranch(run, issue) {
1183
845
  if (run.runType !== "ci_repair" && run.runType !== "queue_repair") {
@@ -1214,93 +876,6 @@ export class RunOrchestrator {
1214
876
  return undefined;
1215
877
  }
1216
878
  }
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
879
  async readThreadWithRetry(threadId, maxRetries = 3) {
1305
880
  for (let attempt = 0; attempt < maxRetries; attempt++) {
1306
881
  try {
@@ -1350,40 +925,6 @@ function resolveRecoverablePostRunState(issue) {
1350
925
  }
1351
926
  return resolvePostRunState(issue);
1352
927
  }
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
928
  function appendQueueRepairContext(lines, context) {
1388
929
  const incidentTitle = typeof context?.incidentTitle === "string" ? context.incidentTitle.trim() : "";
1389
930
  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.8",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "repository": {