patchrelay 0.36.16 → 0.36.18

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.
@@ -0,0 +1,48 @@
1
+ import { resolvePreferredCompletedLinearState } from "./linear-workflow.js";
2
+ export class MergedLinearCompletionReconciler {
3
+ db;
4
+ linearProvider;
5
+ logger;
6
+ constructor(db, linearProvider, logger) {
7
+ this.db = db;
8
+ this.linearProvider = linearProvider;
9
+ this.logger = logger;
10
+ }
11
+ async reconcile() {
12
+ for (const issue of this.db.issues.listIssues()) {
13
+ if (issue.prState !== "merged")
14
+ continue;
15
+ if (issue.currentLinearStateType?.trim().toLowerCase() === "completed")
16
+ continue;
17
+ const linear = await this.linearProvider.forProject(issue.projectId).catch(() => undefined);
18
+ if (!linear)
19
+ continue;
20
+ try {
21
+ const liveIssue = await linear.getIssue(issue.linearIssueId);
22
+ const targetState = resolvePreferredCompletedLinearState(liveIssue);
23
+ if (!targetState)
24
+ continue;
25
+ const normalizedCurrent = liveIssue.stateName?.trim().toLowerCase();
26
+ if (normalizedCurrent === targetState.trim().toLowerCase()) {
27
+ this.db.issues.upsertIssue({
28
+ projectId: issue.projectId,
29
+ linearIssueId: issue.linearIssueId,
30
+ ...(liveIssue.stateName ? { currentLinearState: liveIssue.stateName } : {}),
31
+ ...(liveIssue.stateType ? { currentLinearStateType: liveIssue.stateType } : {}),
32
+ });
33
+ continue;
34
+ }
35
+ const updated = await linear.setIssueState(issue.linearIssueId, targetState);
36
+ this.db.issues.upsertIssue({
37
+ projectId: issue.projectId,
38
+ linearIssueId: issue.linearIssueId,
39
+ ...(updated.stateName ? { currentLinearState: updated.stateName } : {}),
40
+ ...(updated.stateType ? { currentLinearStateType: updated.stateType } : {}),
41
+ });
42
+ }
43
+ catch (error) {
44
+ this.logger.warn({ issueKey: issue.issueKey, error: error instanceof Error ? error.message : String(error) }, "Failed to reconcile merged issue to a completed Linear state");
45
+ }
46
+ }
47
+ }
48
+ }
@@ -29,62 +29,6 @@ function readWorkflowFile(repoPath, runType) {
29
29
  return undefined;
30
30
  return readFileSync(filePath, "utf8").trim();
31
31
  }
32
- function collectImplementationInstructionText(issue, context, promptText) {
33
- const parts = [];
34
- if (issue.title)
35
- parts.push(issue.title);
36
- if (issue.description)
37
- parts.push(issue.description);
38
- if (promptText)
39
- parts.push(promptText);
40
- const stringFields = ["promptContext", "promptBody", "operatorPrompt", "userComment"];
41
- for (const field of stringFields) {
42
- const value = context?.[field];
43
- if (typeof value === "string" && value.trim()) {
44
- parts.push(value);
45
- }
46
- }
47
- if (Array.isArray(context?.followUps)) {
48
- for (const entry of context.followUps) {
49
- if (!entry || typeof entry !== "object")
50
- continue;
51
- const text = entry.text;
52
- if (typeof text === "string" && text.trim()) {
53
- parts.push(text);
54
- }
55
- }
56
- }
57
- return parts.join("\n").toLowerCase();
58
- }
59
- export function resolveImplementationDeliveryMode(issue, context, promptText) {
60
- const instructionText = collectImplementationInstructionText(issue, context, promptText);
61
- if (!instructionText)
62
- return "publish_pr";
63
- const hasExplicitNoPr = [
64
- /\bdo not open (?:a |any )?pr\b/,
65
- /\bdo not open (?:a |any )?pull request\b/,
66
- /\bno pr is opened\b/,
67
- /\bpatchrelay should not open a pr\b/,
68
- /\bwithout opening a pr\b/,
69
- ].some((pattern) => pattern.test(instructionText));
70
- const forbidsRepoChanges = [
71
- /\bdo not make repository changes\b/,
72
- /\bdo not make repo changes\b/,
73
- /\bno repository changes\b/,
74
- /\bno repo changes\b/,
75
- /\bdo not modify repo files\b/,
76
- ].some((pattern) => pattern.test(instructionText));
77
- const planningOnly = [
78
- /\bplanning\/specification issue only\b/,
79
- /\bplanning[- ]only\b/,
80
- /\bspecification[- ]only\b/,
81
- /\bplanning issue only\b/,
82
- ].some((pattern) => pattern.test(instructionText));
83
- if (hasExplicitNoPr || (planningOnly && forbidsRepoChanges)) {
84
- return "linear_only";
85
- }
86
- return "publish_pr";
87
- }
88
32
  function buildPromptHeader(issue) {
89
33
  return [
90
34
  `Issue: ${issue.issueKey ?? issue.linearIssueId}`,
@@ -338,16 +282,23 @@ function buildFollowUpPromptPrelude(issue, runType, context) {
338
282
  "",
339
283
  wakeReason === "direct_reply"
340
284
  ? "Why this turn exists: A human reply arrived for the outstanding question from the previous turn."
341
- : wakeReason === "branch_upkeep"
342
- ? "Why this turn exists: GitHub still shows the PR branch as needing upkeep after the requested code change was addressed."
343
- : wakeReason === "followup_comment"
344
- ? "Why this turn exists: A human follow-up comment arrived after the previous turn."
345
- : `Why this turn exists: Continue the existing ${runType} run from the latest issue state.`,
285
+ : wakeReason === "completion_check_continue"
286
+ ? "Why this turn exists: The previous turn ended without a PR, and PatchRelay's completion check decided the work should continue automatically."
287
+ : wakeReason === "branch_upkeep"
288
+ ? "Why this turn exists: GitHub still shows the PR branch as needing upkeep after the requested code change was addressed."
289
+ : wakeReason === "followup_comment"
290
+ ? "Why this turn exists: A human follow-up comment arrived after the previous turn."
291
+ : `Why this turn exists: Continue the existing ${runType} run from the latest issue state.`,
346
292
  wakeReason === "direct_reply"
347
293
  ? "Required action now: Apply the latest human answer, continue from the current branch/session context, and publish the next concrete result."
348
- : "Required action now: Continue from the latest branch state, refresh any stale assumptions, and publish the next concrete result.",
294
+ : wakeReason === "completion_check_continue"
295
+ ? "Required action now: Continue from the current branch and thread context, finish the task, and publish the next concrete result."
296
+ : "Required action now: Continue from the latest branch state, refresh any stale assumptions, and publish the next concrete result.",
349
297
  "",
350
298
  ];
299
+ if (wakeReason === "completion_check_continue" && typeof context?.completionCheckSummary === "string" && context.completionCheckSummary.trim()) {
300
+ lines.push(`Completion check summary: ${context.completionCheckSummary.trim()}`, "");
301
+ }
351
302
  if (followUpLines.length > 0) {
352
303
  lines.push("Recent updates:");
353
304
  followUpLines.forEach((line) => lines.push(`- ${line}`));
@@ -391,25 +342,13 @@ function buildWorkflowGuidance(repoPath, runType) {
391
342
  }
392
343
  return "";
393
344
  }
394
- function buildPublicationContract(runType, issue, context) {
395
- const deliveryMode = runType === "implementation" && issue
396
- ? resolveImplementationDeliveryMode(issue, context)
397
- : "publish_pr";
398
- if (runType === "implementation" && deliveryMode === "linear_only") {
399
- return [
400
- "## Delivery Requirements",
401
- "",
402
- "This issue is planning/specification only.",
403
- "Do not modify repo files or open a PR for this issue.",
404
- "Deliver the result through Linear artifacts such as follow-up issues, documents, and a concise summary.",
405
- "Leave the worktree clean before stopping.",
406
- ].join("\n");
407
- }
345
+ function buildPublicationContract(runType) {
408
346
  if (runType === "implementation") {
409
347
  return [
410
348
  "## Publication Requirements",
411
349
  "",
412
350
  "Before finishing, publish the result instead of leaving it only in the worktree.",
351
+ "If the task is genuinely complete without a PR, say so clearly in your normal summary instead of inventing one.",
413
352
  "If the worktree already contains relevant changes for this issue, verify them and publish them.",
414
353
  "If you changed files for this issue, commit them, push the issue branch, and open or update the PR before stopping.",
415
354
  "Do not stop with only local commits or uncommitted changes.",
@@ -444,7 +383,7 @@ function buildSections(issue, runType, repoPath, context, followUp = false) {
444
383
  if (workflow) {
445
384
  sections.push({ id: "workflow-guidance", content: workflow });
446
385
  }
447
- sections.push({ id: "publication-contract", content: buildPublicationContract(runType, issue, context) });
386
+ sections.push({ id: "publication-contract", content: buildPublicationContract(runType) });
448
387
  return sections;
449
388
  }
450
389
  function filterAllowedReplacements(promptLayer) {
@@ -0,0 +1,52 @@
1
+ import { extractStageSummary, summarizeCurrentThread } from "./run-reporting.js";
2
+ import { parseStageReport } from "./issue-overview-query.js";
3
+ export class PublicAgentSessionStatusQuery {
4
+ db;
5
+ overviewQuery;
6
+ constructor(db, overviewQuery) {
7
+ this.db = db;
8
+ this.overviewQuery = overviewQuery;
9
+ }
10
+ async getStatus(issueKey) {
11
+ const overview = await this.overviewQuery.getIssueOverview(issueKey);
12
+ if (!overview)
13
+ return undefined;
14
+ const issueRecord = this.db.issues.getIssueByKey(issueKey);
15
+ const latestRunReport = parseStageReport(overview.latestRun?.reportJson, overview.latestRun?.status ?? "unknown");
16
+ const runs = (overview.runs ?? this.overviewQuery.buildRuns(overview.issue.projectId, overview.issue.linearIssueId)).map((run) => ({
17
+ run: {
18
+ id: run.id,
19
+ runType: run.runType,
20
+ status: run.status,
21
+ startedAt: run.startedAt,
22
+ ...(run.endedAt ? { endedAt: run.endedAt } : {}),
23
+ },
24
+ ...(run.report ? { report: run.report } : {}),
25
+ }));
26
+ return {
27
+ issue: {
28
+ issueKey: overview.issue.issueKey,
29
+ title: overview.issue.title,
30
+ issueUrl: overview.issue.issueUrl,
31
+ currentLinearState: overview.issue.currentLinearState,
32
+ ...(overview.session?.sessionState ? { sessionState: overview.session.sessionState } : {}),
33
+ factoryState: overview.issue.factoryState,
34
+ ...(overview.session?.prNumber !== undefined ? { prNumber: overview.session.prNumber } : {}),
35
+ ...(issueRecord?.prUrl ? { prUrl: issueRecord.prUrl } : {}),
36
+ ...(issueRecord?.prState ? { prState: issueRecord.prState } : {}),
37
+ ...(issueRecord?.prReviewState ? { prReviewState: issueRecord.prReviewState } : {}),
38
+ ...(issueRecord?.prCheckStatus ? { prCheckStatus: issueRecord.prCheckStatus } : {}),
39
+ ...(issueRecord ? { ciRepairAttempts: issueRecord.ciRepairAttempts, queueRepairAttempts: issueRecord.queueRepairAttempts } : {}),
40
+ ...(overview.issue.waitingReason ? { waitingReason: overview.issue.waitingReason } : {}),
41
+ ...(overview.issue.statusNote ? { statusNote: overview.issue.statusNote } : {}),
42
+ ...(overview.session?.lastWakeReason ? { lastWakeReason: overview.session.lastWakeReason } : {}),
43
+ },
44
+ ...(overview.activeRun ? { activeRun: overview.activeRun } : {}),
45
+ ...(overview.latestRun ? { latestRun: overview.latestRun } : {}),
46
+ ...(overview.liveThread ? { liveThread: summarizeCurrentThread(overview.liveThread) } : {}),
47
+ ...(latestRunReport ? { latestReportSummary: extractStageSummary(latestRunReport) } : {}),
48
+ runs,
49
+ generatedAt: new Date().toISOString(),
50
+ };
51
+ }
52
+ }
@@ -1,5 +1,4 @@
1
1
  import { ACTIVE_RUN_STATES } from "./factory-state.js";
2
- import { resolveImplementationDeliveryMode, } from "./prompting/patchrelay.js";
3
2
  import { ImplementationOutcomePolicy } from "./implementation-outcome-policy.js";
4
3
  import { ReactiveRunPolicy } from "./reactive-run-policy.js";
5
4
  function resolvePostRunState(issue) {
@@ -12,10 +11,7 @@ function resolvePostRunState(issue) {
12
11
  }
13
12
  return undefined;
14
13
  }
15
- export function resolveCompletedRunState(issue, run) {
16
- if (run.runType === "implementation" && resolveImplementationDeliveryMode(issue, undefined, run.promptText) === "linear_only") {
17
- return "done";
18
- }
14
+ export function resolveCompletedRunState(issue, _run) {
19
15
  return resolvePostRunState(issue);
20
16
  }
21
17
  export class RunCompletionPolicy {
@@ -1,5 +1,5 @@
1
1
  import { buildStageReport, countEventMethods } from "./run-reporting.js";
2
- import { buildRunCompletedActivity, buildRunFailureActivity } from "./linear-session-reporting.js";
2
+ import { buildCompletionCheckActivity, buildRunCompletedActivity, buildRunFailureActivity } from "./linear-session-reporting.js";
3
3
  import { resolveCompletedRunState } from "./run-completion-policy.js";
4
4
  export class RunFinalizer {
5
5
  db;
@@ -11,8 +11,9 @@ export class RunFinalizer {
11
11
  appendWakeEventWithLease;
12
12
  failRunAndClear;
13
13
  completionPolicy;
14
+ completionCheck;
14
15
  feed;
15
- constructor(db, logger, linearSync, enqueueIssue, withHeldLease, releaseLease, appendWakeEventWithLease, failRunAndClear, completionPolicy, feed) {
16
+ constructor(db, logger, linearSync, enqueueIssue, withHeldLease, releaseLease, appendWakeEventWithLease, failRunAndClear, completionPolicy, completionCheck, feed) {
16
17
  this.db = db;
17
18
  this.logger = logger;
18
19
  this.linearSync = linearSync;
@@ -22,8 +23,22 @@ export class RunFinalizer {
22
23
  this.appendWakeEventWithLease = appendWakeEventWithLease;
23
24
  this.failRunAndClear = failRunAndClear;
24
25
  this.completionPolicy = completionPolicy;
26
+ this.completionCheck = completionCheck;
25
27
  this.feed = feed;
26
28
  }
29
+ buildCompletedRunUpdate(params) {
30
+ return {
31
+ status: "completed",
32
+ threadId: params.threadId,
33
+ ...(params.completedTurnId ? { turnId: params.completedTurnId } : {}),
34
+ summaryJson: JSON.stringify({ latestAssistantMessage: params.report.assistantMessages.at(-1) ?? null }),
35
+ reportJson: JSON.stringify(params.report),
36
+ };
37
+ }
38
+ clearProgressAndRelease(run) {
39
+ this.linearSync.clearProgress(run.id);
40
+ this.releaseLease(run.projectId, run.linearIssueId);
41
+ }
27
42
  async finalizeCompletedRun(params) {
28
43
  const { run, issue, thread, threadId } = params;
29
44
  const trackedIssue = this.db.issueToTrackedIssue(issue);
@@ -70,7 +85,206 @@ export class RunFinalizer {
70
85
  }
71
86
  const publishedOutcomeError = await this.completionPolicy.verifyPublishedRunOutcome(run, freshIssue);
72
87
  if (publishedOutcomeError) {
73
- this.failRunAndClear(run, publishedOutcomeError, "failed");
88
+ this.feed?.publish({
89
+ level: "info",
90
+ kind: "turn",
91
+ issueKey: freshIssue.issueKey,
92
+ projectId: run.projectId,
93
+ stage: run.runType,
94
+ status: "completion_check_started",
95
+ summary: "No PR found; checking next step",
96
+ detail: publishedOutcomeError,
97
+ });
98
+ void this.linearSync.emitActivity(freshIssue, buildCompletionCheckActivity("started"), { ephemeral: true });
99
+ let completionCheck;
100
+ try {
101
+ completionCheck = await this.completionCheck.run({
102
+ issue: freshIssue,
103
+ run,
104
+ noPrSummary: publishedOutcomeError,
105
+ onStarted: ({ threadId: completionCheckThreadId, turnId: completionCheckTurnId }) => {
106
+ this.db.runs.markCompletionCheckStarted(run.id, {
107
+ threadId: completionCheckThreadId,
108
+ turnId: completionCheckTurnId,
109
+ });
110
+ },
111
+ });
112
+ }
113
+ catch (error) {
114
+ const message = error instanceof Error ? error.message : String(error);
115
+ this.failRunAndClear(run, `No PR observed and the completion check failed: ${message}`, "failed");
116
+ const failedIssue = this.db.issues.getIssue(run.projectId, run.linearIssueId) ?? freshIssue;
117
+ this.feed?.publish({
118
+ level: "error",
119
+ kind: "turn",
120
+ issueKey: freshIssue.issueKey,
121
+ projectId: run.projectId,
122
+ stage: run.runType,
123
+ status: "completion_check_failed",
124
+ summary: "No PR found; completion check failed",
125
+ detail: message,
126
+ });
127
+ void this.linearSync.emitActivity(failedIssue, buildRunFailureActivity(run.runType, `No PR observed and the completion check failed: ${message}`));
128
+ void this.linearSync.syncSession(failedIssue, { activeRunType: run.runType });
129
+ this.clearProgressAndRelease(run);
130
+ return;
131
+ }
132
+ const completedRunUpdate = this.buildCompletedRunUpdate({
133
+ threadId,
134
+ ...(params.completedTurnId ? { completedTurnId: params.completedTurnId } : {}),
135
+ report,
136
+ });
137
+ if (completionCheck.outcome === "continue") {
138
+ const continued = this.withHeldLease(run.projectId, run.linearIssueId, (lease) => {
139
+ this.db.runs.finishRun(run.id, completedRunUpdate);
140
+ this.db.runs.saveCompletionCheck(run.id, completionCheck);
141
+ this.db.issues.upsertIssue({
142
+ projectId: run.projectId,
143
+ linearIssueId: run.linearIssueId,
144
+ activeRunId: null,
145
+ factoryState: "delegated",
146
+ pendingRunType: null,
147
+ pendingRunContextJson: null,
148
+ });
149
+ return Boolean(this.db.issueSessions.appendIssueSessionEventWithLease(lease, {
150
+ projectId: run.projectId,
151
+ linearIssueId: run.linearIssueId,
152
+ eventType: "completion_check_continue",
153
+ eventJson: JSON.stringify({
154
+ runType: run.runType,
155
+ summary: completionCheck.summary,
156
+ }),
157
+ dedupeKey: `completion_check_continue:${run.id}`,
158
+ }));
159
+ });
160
+ if (!continued) {
161
+ this.logger.warn({ runId: run.id, issueId: run.linearIssueId }, "Skipping completion-check continue writes after losing issue-session lease");
162
+ this.clearProgressAndRelease(run);
163
+ return;
164
+ }
165
+ const continuedIssue = this.db.issues.getIssue(run.projectId, run.linearIssueId) ?? freshIssue;
166
+ this.feed?.publish({
167
+ level: "info",
168
+ kind: "turn",
169
+ issueKey: freshIssue.issueKey,
170
+ projectId: run.projectId,
171
+ stage: run.runType,
172
+ status: "completion_check_continue",
173
+ summary: "No PR found; continuing automatically",
174
+ detail: completionCheck.summary,
175
+ });
176
+ void this.linearSync.emitActivity(continuedIssue, buildCompletionCheckActivity("continue"), { ephemeral: true });
177
+ void this.linearSync.syncSession(continuedIssue);
178
+ this.linearSync.clearProgress(run.id);
179
+ this.enqueueIssue(run.projectId, run.linearIssueId);
180
+ this.releaseLease(run.projectId, run.linearIssueId);
181
+ return;
182
+ }
183
+ if (completionCheck.outcome === "needs_input") {
184
+ const completed = this.withHeldLease(run.projectId, run.linearIssueId, (lease) => {
185
+ this.db.runs.finishRun(run.id, completedRunUpdate);
186
+ this.db.runs.saveCompletionCheck(run.id, completionCheck);
187
+ this.db.issueSessions.clearPendingIssueSessionEventsWithLease(lease);
188
+ this.db.issues.upsertIssue({
189
+ projectId: run.projectId,
190
+ linearIssueId: run.linearIssueId,
191
+ activeRunId: null,
192
+ factoryState: "awaiting_input",
193
+ pendingRunType: null,
194
+ pendingRunContextJson: null,
195
+ });
196
+ return true;
197
+ });
198
+ if (!completed) {
199
+ this.logger.warn({ runId: run.id, issueId: run.linearIssueId }, "Skipping completion-check needs-input writes after losing issue-session lease");
200
+ this.clearProgressAndRelease(run);
201
+ return;
202
+ }
203
+ const awaitingIssue = this.db.issues.getIssue(run.projectId, run.linearIssueId) ?? freshIssue;
204
+ this.feed?.publish({
205
+ level: "warn",
206
+ kind: "turn",
207
+ issueKey: freshIssue.issueKey,
208
+ projectId: run.projectId,
209
+ stage: run.runType,
210
+ status: "completion_check_needs_input",
211
+ summary: "No PR found; waiting for answer",
212
+ detail: completionCheck.question ?? completionCheck.summary,
213
+ });
214
+ void this.linearSync.emitActivity(awaitingIssue, buildCompletionCheckActivity("needs_input", completionCheck));
215
+ void this.linearSync.syncSession(awaitingIssue);
216
+ this.clearProgressAndRelease(run);
217
+ return;
218
+ }
219
+ if (completionCheck.outcome === "done") {
220
+ const completed = this.withHeldLease(run.projectId, run.linearIssueId, (lease) => {
221
+ this.db.runs.finishRun(run.id, completedRunUpdate);
222
+ this.db.runs.saveCompletionCheck(run.id, completionCheck);
223
+ this.db.issueSessions.clearPendingIssueSessionEventsWithLease(lease);
224
+ this.db.issues.upsertIssue({
225
+ projectId: run.projectId,
226
+ linearIssueId: run.linearIssueId,
227
+ activeRunId: null,
228
+ factoryState: "done",
229
+ pendingRunType: null,
230
+ pendingRunContextJson: null,
231
+ lastGitHubFailureSource: null,
232
+ lastGitHubFailureHeadSha: null,
233
+ lastGitHubFailureSignature: null,
234
+ lastGitHubFailureCheckName: null,
235
+ lastGitHubFailureCheckUrl: null,
236
+ lastGitHubFailureContextJson: null,
237
+ lastGitHubFailureAt: null,
238
+ lastQueueIncidentJson: null,
239
+ lastAttemptedFailureHeadSha: null,
240
+ lastAttemptedFailureSignature: null,
241
+ });
242
+ return true;
243
+ });
244
+ if (!completed) {
245
+ this.logger.warn({ runId: run.id, issueId: run.linearIssueId }, "Skipping completion-check done writes after losing issue-session lease");
246
+ this.clearProgressAndRelease(run);
247
+ return;
248
+ }
249
+ const doneIssue = this.db.issues.getIssue(run.projectId, run.linearIssueId) ?? freshIssue;
250
+ this.feed?.publish({
251
+ level: "info",
252
+ kind: "turn",
253
+ issueKey: freshIssue.issueKey,
254
+ projectId: run.projectId,
255
+ stage: run.runType,
256
+ status: "completion_check_done",
257
+ summary: "No PR found; confirmed done",
258
+ detail: completionCheck.summary,
259
+ });
260
+ void this.linearSync.emitActivity(doneIssue, buildCompletionCheckActivity("done", completionCheck));
261
+ void this.linearSync.syncSession(doneIssue);
262
+ this.clearProgressAndRelease(run);
263
+ return;
264
+ }
265
+ const failureReason = `No PR observed and the completion check failed this run: ${completionCheck.summary}`;
266
+ const failed = this.withHeldLease(run.projectId, run.linearIssueId, () => {
267
+ this.db.runs.finishRun(run.id, {
268
+ ...completedRunUpdate,
269
+ status: "failed",
270
+ failureReason,
271
+ });
272
+ this.db.runs.saveCompletionCheck(run.id, completionCheck);
273
+ this.db.issues.upsertIssue({
274
+ projectId: run.projectId,
275
+ linearIssueId: run.linearIssueId,
276
+ activeRunId: null,
277
+ factoryState: "failed",
278
+ pendingRunType: null,
279
+ pendingRunContextJson: null,
280
+ });
281
+ return true;
282
+ });
283
+ if (!failed) {
284
+ this.logger.warn({ runId: run.id, issueId: run.linearIssueId }, "Skipping completion-check failed writes after losing issue-session lease");
285
+ this.clearProgressAndRelease(run);
286
+ return;
287
+ }
74
288
  const failedIssue = this.db.issues.getIssue(run.projectId, run.linearIssueId) ?? freshIssue;
75
289
  this.feed?.publish({
76
290
  level: "warn",
@@ -78,15 +292,13 @@ export class RunFinalizer {
78
292
  issueKey: freshIssue.issueKey,
79
293
  projectId: run.projectId,
80
294
  stage: run.runType,
81
- status: "publish_incomplete",
82
- summary: publishedOutcomeError,
295
+ status: "completion_check_failed",
296
+ summary: "No PR found; completion check failed",
297
+ detail: completionCheck.summary,
83
298
  });
84
- void this.linearSync.emitActivity(failedIssue, buildRunFailureActivity(run.runType, publishedOutcomeError));
299
+ void this.linearSync.emitActivity(failedIssue, buildRunFailureActivity(run.runType, failureReason));
85
300
  void this.linearSync.syncSession(failedIssue, { activeRunType: run.runType });
86
- this.linearSync.clearProgress(run.id);
87
- if (params.source === "notification") {
88
- this.releaseLease(run.projectId, run.linearIssueId);
89
- }
301
+ this.clearProgressAndRelease(run);
90
302
  return;
91
303
  }
92
304
  const refreshedIssue = await this.completionPolicy.refreshIssueAfterReactivePublish(run, freshIssue);
@@ -1,7 +1,8 @@
1
1
  import { summarizeCurrentThread } from "./run-reporting.js";
2
2
  import { buildRunStartedActivity, } from "./linear-session-reporting.js";
3
+ import { CompletionCheckService } from "./completion-check.js";
3
4
  import { WorktreeManager } from "./worktree-manager.js";
4
- import { resolvePreferredCompletedLinearState } from "./linear-workflow.js";
5
+ import { MergedLinearCompletionReconciler } from "./merged-linear-completion-reconciler.js";
5
6
  import { QueueHealthMonitor } from "./queue-health-monitor.js";
6
7
  import { IdleIssueReconciler, resolveBranchOwnerForStateTransition } from "./idle-reconciliation.js";
7
8
  import { LinearSessionSync } from "./linear-session-sync.js";
@@ -49,8 +50,10 @@ export class RunOrchestrator {
49
50
  runWakePlanner;
50
51
  interruptedRunRecovery;
51
52
  runCompletionPolicy;
53
+ completionCheck;
52
54
  runNotificationHandler;
53
55
  runReconciler;
56
+ mergedLinearCompletionReconciler;
54
57
  activeSessionLeases;
55
58
  botIdentity;
56
59
  constructor(config, db, codex, linearProvider, enqueueIssue, logger, feed) {
@@ -66,7 +69,8 @@ export class RunOrchestrator {
66
69
  this.leaseService = new IssueSessionLeaseService(db, logger, this.workerId, (threadId, maxRetries) => this.readThreadWithRetry(threadId, maxRetries));
67
70
  this.activeSessionLeases = this.leaseService.activeSessionLeases;
68
71
  this.runCompletionPolicy = new RunCompletionPolicy(config, db, logger, (projectId, linearIssueId, fn) => this.withHeldIssueSessionLease(projectId, linearIssueId, fn));
69
- this.runFinalizer = new RunFinalizer(db, logger, this.linearSync, this.enqueueIssue, (projectId, linearIssueId, fn) => this.withHeldIssueSessionLease(projectId, linearIssueId, fn), (projectId, linearIssueId) => this.releaseIssueSessionLease(projectId, linearIssueId), (lease, issue, runType, context, dedupeScope) => this.appendWakeEventWithLease(lease, issue, runType, context, dedupeScope), (run, message, nextState) => this.failRunAndClear(run, message, nextState), this.runCompletionPolicy, feed);
72
+ this.completionCheck = new CompletionCheckService(codex, logger);
73
+ this.runFinalizer = new RunFinalizer(db, logger, this.linearSync, this.enqueueIssue, (projectId, linearIssueId, fn) => this.withHeldIssueSessionLease(projectId, linearIssueId, fn), (projectId, linearIssueId) => this.releaseIssueSessionLease(projectId, linearIssueId), (lease, issue, runType, context, dedupeScope) => this.appendWakeEventWithLease(lease, issue, runType, context, dedupeScope), (run, message, nextState) => this.failRunAndClear(run, message, nextState), this.runCompletionPolicy, this.completionCheck, feed);
70
74
  this.runLauncher = new RunLauncher(config, db, codex, logger, this.worktreeManager);
71
75
  this.runNotificationHandler = new RunNotificationHandler(config, db, logger, this.linearSync, this.runFinalizer, (threadId, maxRetries) => this.readThreadWithRetry(threadId, maxRetries), (projectId, linearIssueId, fn) => this.withHeldIssueSessionLease(projectId, linearIssueId, fn), (projectId, linearIssueId) => this.heartbeatIssueSessionLease(projectId, linearIssueId), (projectId, linearIssueId) => this.releaseIssueSessionLease(projectId, linearIssueId), feed);
72
76
  this.runRecovery = new RunRecoveryService(db, logger, this.linearSync, (projectId, linearIssueId, fn) => this.withHeldIssueSessionLease(projectId, linearIssueId, fn), (projectId, linearIssueId) => this.getHeldIssueSessionLease(projectId, linearIssueId), (lease, issue, runType, context, dedupeScope) => this.appendWakeEventWithLease(lease, issue, runType, context, dedupeScope), (projectId, linearIssueId) => this.releaseIssueSessionLease(projectId, linearIssueId), (projectId, issueId) => this.enqueueIssue(projectId, issueId), (newState, pendingRunType) => this.resolveBranchOwnerForStateTransition(newState, pendingRunType), feed);
@@ -76,6 +80,7 @@ export class RunOrchestrator {
76
80
  this.idleReconciler = new IdleIssueReconciler(db, config, {
77
81
  enqueueIssue: (projectId, issueId) => this.enqueueIssue(projectId, issueId),
78
82
  }, logger, feed);
83
+ this.mergedLinearCompletionReconciler = new MergedLinearCompletionReconciler(db, linearProvider, logger);
79
84
  this.queueHealthMonitor = new QueueHealthMonitor(db, config, {
80
85
  advanceIdleIssue: (issue, newState, options) => this.idleReconciler.advanceIdleIssue(issue, newState, options),
81
86
  enqueueIssue: (projectId, issueId) => this.enqueueIssue(projectId, issueId),
@@ -251,44 +256,7 @@ export class RunOrchestrator {
251
256
  // Advance issues stuck in pr_open whose stored PR metadata already
252
257
  // shows they should transition (e.g. approved PR, missed webhook).
253
258
  await this.idleReconciler.reconcile();
254
- await this.reconcileMergedLinearCompletion();
255
- }
256
- async reconcileMergedLinearCompletion() {
257
- for (const issue of this.db.issues.listIssues()) {
258
- if (issue.prState !== "merged")
259
- continue;
260
- if (issue.currentLinearStateType?.trim().toLowerCase() === "completed")
261
- continue;
262
- const linear = await this.linearProvider.forProject(issue.projectId).catch(() => undefined);
263
- if (!linear)
264
- continue;
265
- try {
266
- const liveIssue = await linear.getIssue(issue.linearIssueId);
267
- const targetState = resolvePreferredCompletedLinearState(liveIssue);
268
- if (!targetState)
269
- continue;
270
- const normalizedCurrent = liveIssue.stateName?.trim().toLowerCase();
271
- if (normalizedCurrent === targetState.trim().toLowerCase()) {
272
- this.db.issues.upsertIssue({
273
- projectId: issue.projectId,
274
- linearIssueId: issue.linearIssueId,
275
- ...(liveIssue.stateName ? { currentLinearState: liveIssue.stateName } : {}),
276
- ...(liveIssue.stateType ? { currentLinearStateType: liveIssue.stateType } : {}),
277
- });
278
- continue;
279
- }
280
- const updated = await linear.setIssueState(issue.linearIssueId, targetState);
281
- this.db.issues.upsertIssue({
282
- projectId: issue.projectId,
283
- linearIssueId: issue.linearIssueId,
284
- ...(updated.stateName ? { currentLinearState: updated.stateName } : {}),
285
- ...(updated.stateType ? { currentLinearStateType: updated.stateType } : {}),
286
- });
287
- }
288
- catch (error) {
289
- this.logger.warn({ issueKey: issue.issueKey, error: error instanceof Error ? error.message : String(error) }, "Failed to reconcile merged issue to a completed Linear state");
290
- }
291
- }
259
+ await this.mergedLinearCompletionReconciler.reconcile();
292
260
  }
293
261
  // advanceIdleIssue is now on IdleIssueReconciler — delegate for internal callers
294
262
  advanceIdleIssue(issue, newState, options) {
package/dist/service.js CHANGED
@@ -371,10 +371,18 @@ export class PatchRelayService {
371
371
  i.last_github_failure_check_name,
372
372
  i.last_github_failure_context_json,
373
373
  active_run.run_type AS active_run_type,
374
+ active_run.completion_check_thread_id AS active_completion_check_thread_id,
375
+ active_run.completion_check_outcome AS active_completion_check_outcome,
374
376
  latest_run.run_type AS latest_run_type,
375
377
  latest_run.status AS latest_run_status,
376
378
  latest_run.summary_json AS latest_run_summary_json,
377
379
  latest_run.report_json AS latest_run_report_json,
380
+ latest_run.completion_check_thread_id AS latest_run_completion_check_thread_id,
381
+ latest_run.completion_check_outcome AS latest_run_completion_check_outcome,
382
+ latest_run.completion_check_summary AS latest_run_completion_check_summary,
383
+ latest_run.completion_check_question AS latest_run_completion_check_question,
384
+ latest_run.completion_check_why AS latest_run_completion_check_why,
385
+ latest_run.completion_check_recommended_reply AS latest_run_completion_check_recommended_reply,
378
386
  (
379
387
  SELECT COUNT(*)
380
388
  FROM issue_session_events e
@@ -470,6 +478,12 @@ export class PatchRelayService {
470
478
  status: String(row.latest_run_status),
471
479
  ...(typeof row.latest_run_summary_json === "string" ? { summaryJson: row.latest_run_summary_json } : {}),
472
480
  ...(typeof row.latest_run_report_json === "string" ? { reportJson: row.latest_run_report_json } : {}),
481
+ ...(typeof row.latest_run_completion_check_thread_id === "string" ? { completionCheckThreadId: row.latest_run_completion_check_thread_id } : {}),
482
+ ...(typeof row.latest_run_completion_check_outcome === "string" ? { completionCheckOutcome: row.latest_run_completion_check_outcome } : {}),
483
+ ...(typeof row.latest_run_completion_check_summary === "string" ? { completionCheckSummary: row.latest_run_completion_check_summary } : {}),
484
+ ...(typeof row.latest_run_completion_check_question === "string" ? { completionCheckQuestion: row.latest_run_completion_check_question } : {}),
485
+ ...(typeof row.latest_run_completion_check_why === "string" ? { completionCheckWhy: row.latest_run_completion_check_why } : {}),
486
+ ...(typeof row.latest_run_completion_check_recommended_reply === "string" ? { completionCheckRecommendedReply: row.latest_run_completion_check_recommended_reply } : {}),
473
487
  startedAt: String(row.updated_at),
474
488
  }
475
489
  : undefined;
@@ -490,6 +504,10 @@ export class PatchRelayService {
490
504
  })
491
505
  ? undefined
492
506
  : statusNoteCandidate;
507
+ const completionCheckActive = typeof row.active_completion_check_thread_id === "string"
508
+ && row.active_completion_check_thread_id.length > 0
509
+ && row.active_completion_check_outcome === null
510
+ && row.active_run_type !== null;
493
511
  return {
494
512
  ...(row.issue_key !== null ? { issueKey: String(row.issue_key) } : {}),
495
513
  ...(row.title !== null ? { title: String(row.title) } : {}),
@@ -515,6 +533,7 @@ export class PatchRelayService {
515
533
  ...(failureContext?.stepName ? { latestFailureStepName: failureContext.stepName } : {}),
516
534
  ...(failureContext?.summary ? { latestFailureSummary: failureContext.summary } : {}),
517
535
  ...(waitingReason ? { waitingReason } : {}),
536
+ ...(completionCheckActive ? { completionCheckActive } : {}),
518
537
  updatedAt: String(row.updated_at),
519
538
  };
520
539
  });