patchrelay 0.36.16 → 0.36.17

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.36.16",
4
- "commit": "f8ad93bd7a05",
5
- "builtAt": "2026-04-10T04:39:43.765Z"
3
+ "version": "0.36.17",
4
+ "commit": "a9609f2ce42d",
5
+ "builtAt": "2026-04-10T12:32:40.306Z"
6
6
  }
package/dist/cli/data.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { existsSync } from "node:fs";
2
2
  import pino from "pino";
3
3
  import { CodexAppServerClient } from "../codex-app-server.js";
4
+ import { extractCompletionCheck } from "../completion-check.js";
4
5
  import { getThreadTurns } from "../codex-thread-utils.js";
5
6
  import { PatchRelayDatabase } from "../db.js";
6
7
  import { WorktreeManager } from "../worktree-manager.js";
@@ -57,6 +58,12 @@ function parseObjectJson(value) {
57
58
  }
58
59
  }
59
60
  function summarizeRun(run) {
61
+ const completionCheck = extractCompletionCheck(run);
62
+ if (completionCheck) {
63
+ return completionCheck.outcome === "needs_input"
64
+ ? completionCheck.question ?? completionCheck.summary
65
+ : completionCheck.summary;
66
+ }
60
67
  const summary = parseObjectJson(run.summaryJson);
61
68
  if (typeof summary?.latestAssistantMessage === "string" && summary.latestAssistantMessage.trim()) {
62
69
  return summary.latestAssistantMessage.trim();
@@ -99,7 +106,9 @@ export class CliDataAccess extends CliOperatorApiClient {
99
106
  const latestRun = this.db.runs.getLatestRunForIssue(issue.projectId, issue.linearIssueId);
100
107
  const latestReport = normalizeStageReport(latestRun?.reportJson, latestRun?.status);
101
108
  const latestSummary = safeJsonParse(latestRun?.summaryJson);
102
- const statusNote = latestReport?.assistantMessages.at(-1) ??
109
+ const completionCheck = latestRun ? extractCompletionCheck(latestRun) : undefined;
110
+ const statusNote = (completionCheck?.outcome === "needs_input" ? completionCheck.question : completionCheck?.summary) ??
111
+ latestReport?.assistantMessages.at(-1) ??
103
112
  (typeof latestSummary?.latestAssistantMessage === "string" ? latestSummary.latestAssistantMessage : undefined) ??
104
113
  (latestRun?.status === "failed" ? "Latest run failed." : undefined) ??
105
114
  undefined;
@@ -114,6 +123,11 @@ export class CliDataAccess extends CliOperatorApiClient {
114
123
  ...((dbIssue.sessionState) ? { sessionState: dbIssue.sessionState } : {}),
115
124
  ...((dbIssue.waitingReason) ? { waitingReason: dbIssue.waitingReason } : {}),
116
125
  ...(statusNote ? { statusNote } : {}),
126
+ ...(completionCheck?.outcome ? { completionCheckOutcome: completionCheck.outcome } : {}),
127
+ ...(completionCheck?.summary ? { completionCheckSummary: completionCheck.summary } : {}),
128
+ ...(completionCheck?.question ? { completionCheckQuestion: completionCheck.question } : {}),
129
+ ...(completionCheck?.why ? { completionCheckWhy: completionCheck.why } : {}),
130
+ ...(completionCheck?.recommendedReply ? { completionCheckRecommendedReply: completionCheck.recommendedReply } : {}),
117
131
  };
118
132
  }
119
133
  async live(issueKey) {
@@ -21,6 +21,11 @@ export function formatInspect(result) {
21
21
  result.activeRun ? value("Active run", `${result.activeRun.runType} (${result.activeRun.status})`) : undefined,
22
22
  result.latestRun && !result.activeRun ? value("Latest run", `${result.latestRun.runType} (${result.latestRun.status})`) : undefined,
23
23
  result.prNumber ? value("PR", `#${result.prNumber}${result.prReviewState ? ` [${result.prReviewState}]` : ""}`) : undefined,
24
+ result.completionCheckOutcome ? value("Completion check", result.completionCheckOutcome) : undefined,
25
+ result.completionCheckSummary ? value("Completion summary", truncateLine(result.completionCheckSummary)) : undefined,
26
+ result.completionCheckQuestion ? value("Question", truncateLine(result.completionCheckQuestion)) : undefined,
27
+ result.completionCheckWhy ? value("Why", truncateLine(result.completionCheckWhy)) : undefined,
28
+ result.completionCheckRecommendedReply ? value("Suggested reply", truncateLine(result.completionCheckRecommendedReply)) : undefined,
24
29
  result.statusNote ? value("Status", truncateLine(result.statusNote)) : undefined,
25
30
  ].filter(Boolean);
26
31
  return `${lines.join("\n")}\n`;
@@ -13,6 +13,8 @@ function effectiveState(issue) {
13
13
  return "done";
14
14
  if (issue.sessionState === "failed")
15
15
  return "failed";
16
+ if (issue.completionCheckActive)
17
+ return "completion_check";
16
18
  if (issue.blockedByCount > 0 && !issue.activeRunType)
17
19
  return "blocked";
18
20
  if (issue.sessionState === "waiting_input")
@@ -49,6 +51,7 @@ function stageLabel(issue) {
49
51
  case "ready": return "ready";
50
52
  case "delegated": return "delegated";
51
53
  case "implementing": return "implementing";
54
+ case "completion_check": return "completion check";
52
55
  case "pr_open": return "PR open";
53
56
  case "changes_requested": return "review changes";
54
57
  case "repairing_ci": return "repairing CI";
@@ -116,6 +119,8 @@ function blockerText(issue) {
116
119
  return issue.waitingReason ?? "Waiting for input";
117
120
  if (needsOperatorIntervention(issue))
118
121
  return issue.statusNote ?? issue.waitingReason ?? "Needs operator intervention";
122
+ if (issue.completionCheckActive)
123
+ return "No PR found; checking next step";
119
124
  if (issue.waitingReason && issue.activeRunType && issue.factoryState === "pr_open")
120
125
  return issue.waitingReason;
121
126
  if (issue.waitingReason && issue.activeRunType && issue.factoryState === "awaiting_queue")
@@ -17,6 +17,7 @@ const STAGE_DISPLAY = {
17
17
  ready: "ready",
18
18
  delegated: "delegated",
19
19
  implementing: "implementing",
20
+ completion_check: "completion check",
20
21
  pr_open: "PR open",
21
22
  changes_requested: "review changes",
22
23
  repairing_ci: "repairing CI",
@@ -513,6 +514,8 @@ function effectiveState(issue) {
513
514
  return "done";
514
515
  if (issue.sessionState === "failed")
515
516
  return "failed";
517
+ if (issue.completionCheckActive)
518
+ return "completion_check";
516
519
  if (issue.blockedByCount > 0 && !issue.activeRunType)
517
520
  return "blocked";
518
521
  if (issue.sessionState === "waiting_input")
@@ -527,6 +530,8 @@ function blockerText(issue, issueContext) {
527
530
  const rereviewNeeded = isRereviewNeeded(issue);
528
531
  if (issue.sessionState === "waiting_input")
529
532
  return issue.waitingReason ?? "Waiting for input";
533
+ if (issue.completionCheckActive)
534
+ return "No PR found; checking next step";
530
535
  if (issue.sessionState === "failed" || issue.factoryState === "failed" || issue.factoryState === "escalated") {
531
536
  return issue.statusNote ?? issue.waitingReason ?? "Needs operator intervention";
532
537
  }
@@ -1,6 +1,14 @@
1
1
  import { EventEmitter } from "node:events";
2
2
  import { spawn } from "node:child_process";
3
+ import { tmpdir } from "node:os";
3
4
  import { sanitizeDiagnosticText } from "./utils.js";
5
+ const COMPLETION_CHECK_DEVELOPER_INSTRUCTIONS = [
6
+ "You are PatchRelay's completion check.",
7
+ "This is a read-only follow-up used only to decide what should happen after a task ended without a PR.",
8
+ "Do not run commands, do not call tools, do not edit files, and do not inspect or modify the repository.",
9
+ "Use only the prior thread context and the facts in the current prompt.",
10
+ "Return only the requested JSON object.",
11
+ ].join("\n");
4
12
  export function resolveCodexAppServerLaunch(config) {
5
13
  if (!config.sourceBashrc) {
6
14
  return {
@@ -165,16 +173,16 @@ export class CodexAppServerClient extends EventEmitter {
165
173
  const response = (await this.sendRequest("thread/resume", params));
166
174
  return this.mapThread(response.thread);
167
175
  }
168
- async forkThread(threadId, cwd) {
176
+ async forkThread(threadId, cwd, overrides) {
169
177
  const params = {
170
178
  threadId,
171
- cwd: cwd ?? null,
172
- approvalPolicy: this.config.approvalPolicy,
173
- sandbox: this.config.sandboxMode,
174
- model: this.config.model ?? null,
175
- modelProvider: this.config.modelProvider ?? null,
176
- baseInstructions: this.config.baseInstructions ?? null,
177
- developerInstructions: this.config.developerInstructions ?? null,
179
+ cwd: overrides?.cwd ?? cwd ?? null,
180
+ approvalPolicy: overrides?.approvalPolicy ?? this.config.approvalPolicy,
181
+ sandbox: overrides?.sandboxMode ?? this.config.sandboxMode,
182
+ model: overrides?.model ?? this.config.model ?? null,
183
+ modelProvider: overrides?.modelProvider ?? this.config.modelProvider ?? null,
184
+ baseInstructions: overrides?.baseInstructions ?? this.config.baseInstructions ?? null,
185
+ developerInstructions: overrides?.developerInstructions ?? this.config.developerInstructions ?? null,
178
186
  };
179
187
  if (this.config.persistExtendedHistory) {
180
188
  this.logger.warn("persistExtendedHistory is requested but not enabled in the active app-server capability handshake; ignoring");
@@ -182,6 +190,13 @@ export class CodexAppServerClient extends EventEmitter {
182
190
  const response = (await this.sendRequest("thread/fork", params));
183
191
  return this.mapThread(response.thread);
184
192
  }
193
+ async forkThreadForCompletionCheck(threadId) {
194
+ return await this.forkThread(threadId, tmpdir(), {
195
+ approvalPolicy: "never",
196
+ sandboxMode: "read-only",
197
+ developerInstructions: COMPLETION_CHECK_DEVELOPER_INSTRUCTIONS,
198
+ });
199
+ }
185
200
  async startTurn(options) {
186
201
  const response = (await this.sendRequest("turn/start", {
187
202
  threadId: options.threadId,
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,143 @@
1
+ import { getThreadTurns } from "./codex-thread-utils.js";
2
+ import { extractLatestAssistantSummary } from "./issue-session-events.js";
3
+ import { sanitizeOperatorFacingText } from "./presentation-text.js";
4
+ import { extractFirstJsonObject, safeJsonParse } from "./utils.js";
5
+ const COMPLETION_CHECK_TIMEOUT_MS = 90_000;
6
+ const COMPLETION_CHECK_POLL_MS = 1_000;
7
+ export function extractCompletionCheck(run) {
8
+ if (!run?.completionCheckOutcome || !run.completionCheckSummary) {
9
+ return undefined;
10
+ }
11
+ return {
12
+ outcome: run.completionCheckOutcome,
13
+ summary: run.completionCheckSummary,
14
+ ...(run.completionCheckQuestion ? { question: run.completionCheckQuestion } : {}),
15
+ ...(run.completionCheckWhy ? { why: run.completionCheckWhy } : {}),
16
+ ...(run.completionCheckRecommendedReply ? { recommendedReply: run.completionCheckRecommendedReply } : {}),
17
+ };
18
+ }
19
+ export class CompletionCheckService {
20
+ codex;
21
+ logger;
22
+ constructor(codex, logger) {
23
+ this.codex = codex;
24
+ this.logger = logger;
25
+ }
26
+ async run(params) {
27
+ const threadId = params.run.threadId;
28
+ if (!threadId) {
29
+ return {
30
+ outcome: "failed",
31
+ summary: "No PR was found, and PatchRelay could not run the completion check because the main thread is missing.",
32
+ threadId: "missing-thread",
33
+ turnId: "missing-turn",
34
+ };
35
+ }
36
+ const fork = await this.codex.forkThreadForCompletionCheck(threadId);
37
+ const turn = await this.codex.startTurn({
38
+ threadId: fork.id,
39
+ ...(fork.cwd ? { cwd: fork.cwd } : {}),
40
+ input: buildCompletionCheckPrompt(params),
41
+ });
42
+ await params.onStarted?.({ threadId: fork.id, turnId: turn.turnId });
43
+ const completedThread = await this.waitForTurn(fork.id, turn.turnId);
44
+ const completedTurn = getThreadTurns(completedThread).find((entry) => entry.id === turn.turnId);
45
+ const latestMessage = completedTurn?.items
46
+ .filter((item) => item.type === "agentMessage")
47
+ .at(-1)?.text;
48
+ const parsed = parseCompletionCheckResult(latestMessage);
49
+ if (!parsed) {
50
+ this.logger.warn({ runId: params.run.id, issueKey: params.issue.issueKey, threadId: fork.id, turnId: turn.turnId }, "Completion check returned invalid JSON");
51
+ return {
52
+ outcome: "failed",
53
+ summary: "No PR was found, and the completion check returned an invalid result.",
54
+ threadId: fork.id,
55
+ turnId: turn.turnId,
56
+ };
57
+ }
58
+ return {
59
+ ...parsed,
60
+ threadId: fork.id,
61
+ turnId: turn.turnId,
62
+ };
63
+ }
64
+ async waitForTurn(threadId, turnId) {
65
+ const deadline = Date.now() + COMPLETION_CHECK_TIMEOUT_MS;
66
+ while (Date.now() < deadline) {
67
+ const thread = await this.codex.readThread(threadId, true);
68
+ const turn = getThreadTurns(thread).find((entry) => entry.id === turnId);
69
+ if (turn?.status === "completed") {
70
+ return thread;
71
+ }
72
+ if (turn?.status === "failed" || turn?.status === "interrupted") {
73
+ throw new Error(`Completion check turn ${turnId} ended with status ${turn.status}`);
74
+ }
75
+ await new Promise((resolve) => setTimeout(resolve, COMPLETION_CHECK_POLL_MS));
76
+ }
77
+ throw new Error(`Completion check timed out after ${COMPLETION_CHECK_TIMEOUT_MS}ms`);
78
+ }
79
+ }
80
+ function buildCompletionCheckPrompt(params) {
81
+ const latestSummary = extractLatestAssistantSummary(params.run);
82
+ return [
83
+ "PatchRelay completion check",
84
+ "",
85
+ "The main task run finished without a linked PR.",
86
+ "This is a read-only check. Do not run commands, call tools, edit files, or inspect the repository.",
87
+ "Return exactly one JSON object and no extra prose.",
88
+ "",
89
+ "Schema:",
90
+ '{',
91
+ ' "outcome": "continue" | "needs_input" | "done" | "failed",',
92
+ ' "summary": "short operator-facing summary",',
93
+ ' "question": "required only for needs_input",',
94
+ ' "why": "optional explanation",',
95
+ ' "recommendedReply": "optional suggested reply for needs_input"',
96
+ '}',
97
+ "",
98
+ "Choose:",
99
+ '- "continue" if PatchRelay should keep working automatically on the same thread.',
100
+ '- "needs_input" if a human must answer a concrete question before work can continue.',
101
+ '- "done" if the task was successfully completed without a PR.',
102
+ '- "failed" if the run stopped incorrectly and PatchRelay should not auto-continue.',
103
+ "",
104
+ "Facts:",
105
+ `- Issue: ${params.issue.issueKey ?? params.issue.linearIssueId}`,
106
+ ...(params.issue.title ? [`- Title: ${params.issue.title}`] : []),
107
+ `- Run type: ${params.run.runType}`,
108
+ `- No-PR summary: ${sanitizeOperatorFacingText(params.noPrSummary)}`,
109
+ ...(latestSummary ? [`- Latest assistant summary: ${sanitizeOperatorFacingText(latestSummary)}`] : []),
110
+ ...(params.run.failureReason ? [`- Failure reason: ${sanitizeOperatorFacingText(params.run.failureReason)}`] : []),
111
+ ...(params.issue.description ? ["", "Issue description:", params.issue.description] : []),
112
+ ].join("\n");
113
+ }
114
+ function parseCompletionCheckResult(text) {
115
+ const raw = sanitizeOperatorFacingText(text);
116
+ if (!raw)
117
+ return undefined;
118
+ const candidate = safeJsonParse(raw) ?? safeJsonParse(extractFirstJsonObject(raw) ?? "");
119
+ if (!candidate)
120
+ return undefined;
121
+ const outcome = typeof candidate.outcome === "string" ? candidate.outcome : undefined;
122
+ const summary = typeof candidate.summary === "string" ? sanitizeOperatorFacingText(candidate.summary) : undefined;
123
+ const question = typeof candidate.question === "string" ? sanitizeOperatorFacingText(candidate.question) : undefined;
124
+ const why = typeof candidate.why === "string" ? sanitizeOperatorFacingText(candidate.why) : undefined;
125
+ const recommendedReply = typeof candidate.recommendedReply === "string"
126
+ ? sanitizeOperatorFacingText(candidate.recommendedReply)
127
+ : undefined;
128
+ if (!summary)
129
+ return undefined;
130
+ if (outcome !== "continue" && outcome !== "needs_input" && outcome !== "done" && outcome !== "failed") {
131
+ return undefined;
132
+ }
133
+ if (outcome === "needs_input" && !question) {
134
+ return undefined;
135
+ }
136
+ return {
137
+ outcome,
138
+ summary,
139
+ ...(question ? { question } : {}),
140
+ ...(why ? { why } : {}),
141
+ ...(recommendedReply ? { recommendedReply } : {}),
142
+ };
143
+ }
@@ -45,6 +45,14 @@ CREATE TABLE IF NOT EXISTS runs (
45
45
  thread_id TEXT,
46
46
  turn_id TEXT,
47
47
  parent_thread_id TEXT,
48
+ completion_check_thread_id TEXT,
49
+ completion_check_turn_id TEXT,
50
+ completion_check_outcome TEXT,
51
+ completion_check_summary TEXT,
52
+ completion_check_question TEXT,
53
+ completion_check_why TEXT,
54
+ completion_check_recommended_reply TEXT,
55
+ completion_checked_at TEXT,
48
56
  summary_json TEXT,
49
57
  report_json TEXT,
50
58
  failure_reason TEXT,
@@ -244,6 +252,14 @@ export function runPatchRelayMigrations(connection) {
244
252
  // Preserve the PR head SHA seen when a run started so PatchRelay can
245
253
  // verify that requested-changes work actually published a new head.
246
254
  addColumnIfMissing(connection, "runs", "source_head_sha", "TEXT");
255
+ addColumnIfMissing(connection, "runs", "completion_check_thread_id", "TEXT");
256
+ addColumnIfMissing(connection, "runs", "completion_check_turn_id", "TEXT");
257
+ addColumnIfMissing(connection, "runs", "completion_check_outcome", "TEXT");
258
+ addColumnIfMissing(connection, "runs", "completion_check_summary", "TEXT");
259
+ addColumnIfMissing(connection, "runs", "completion_check_question", "TEXT");
260
+ addColumnIfMissing(connection, "runs", "completion_check_why", "TEXT");
261
+ addColumnIfMissing(connection, "runs", "completion_check_recommended_reply", "TEXT");
262
+ addColumnIfMissing(connection, "runs", "completion_checked_at", "TEXT");
247
263
  addColumnIfMissing(connection, "issues", "last_blocking_review_head_sha", "TEXT");
248
264
  // Collapse awaiting_review into pr_open (state normalization)
249
265
  connection.prepare("UPDATE issues SET factory_state = 'pr_open' WHERE factory_state = 'awaiting_review'").run();
@@ -102,6 +102,38 @@ export class RunStore {
102
102
  });
103
103
  }
104
104
  }
105
+ saveCompletionCheck(runId, params) {
106
+ this.connection.prepare(`
107
+ UPDATE runs SET
108
+ completion_check_thread_id = COALESCE(?, completion_check_thread_id),
109
+ completion_check_turn_id = COALESCE(?, completion_check_turn_id),
110
+ completion_check_outcome = ?,
111
+ completion_check_summary = ?,
112
+ completion_check_question = ?,
113
+ completion_check_why = ?,
114
+ completion_check_recommended_reply = ?,
115
+ completion_checked_at = ?
116
+ WHERE id = ?
117
+ `).run(params.threadId ?? null, params.turnId ?? null, params.outcome, params.summary, params.question ?? null, params.why ?? null, params.recommendedReply ?? null, isoNow(), runId);
118
+ }
119
+ markCompletionCheckStarted(runId, params) {
120
+ this.connection.prepare(`
121
+ UPDATE runs SET
122
+ completion_check_thread_id = ?,
123
+ completion_check_turn_id = ?,
124
+ completion_checked_at = NULL
125
+ WHERE id = ?
126
+ `).run(params.threadId, params.turnId, runId);
127
+ const run = this.getRunById(runId);
128
+ if (!run)
129
+ return;
130
+ const issue = this.issues.getIssue(run.projectId, run.linearIssueId);
131
+ if (issue) {
132
+ this.syncIssueSessionFromIssue(issue, {
133
+ lastRunType: run.runType,
134
+ });
135
+ }
136
+ }
105
137
  saveThreadEvent(params) {
106
138
  this.connection.prepare(`
107
139
  INSERT INTO run_thread_events (run_id, thread_id, turn_id, method, event_json, created_at)
package/dist/db.js CHANGED
@@ -267,6 +267,14 @@ function mapRunRow(row) {
267
267
  ...(row.thread_id !== null ? { threadId: String(row.thread_id) } : {}),
268
268
  ...(row.turn_id !== null ? { turnId: String(row.turn_id) } : {}),
269
269
  ...(row.parent_thread_id !== null ? { parentThreadId: String(row.parent_thread_id) } : {}),
270
+ ...(row.completion_check_thread_id !== null ? { completionCheckThreadId: String(row.completion_check_thread_id) } : {}),
271
+ ...(row.completion_check_turn_id !== null ? { completionCheckTurnId: String(row.completion_check_turn_id) } : {}),
272
+ ...(row.completion_check_outcome !== null ? { completionCheckOutcome: String(row.completion_check_outcome) } : {}),
273
+ ...(row.completion_check_summary !== null ? { completionCheckSummary: String(row.completion_check_summary) } : {}),
274
+ ...(row.completion_check_question !== null ? { completionCheckQuestion: String(row.completion_check_question) } : {}),
275
+ ...(row.completion_check_why !== null ? { completionCheckWhy: String(row.completion_check_why) } : {}),
276
+ ...(row.completion_check_recommended_reply !== null ? { completionCheckRecommendedReply: String(row.completion_check_recommended_reply) } : {}),
277
+ ...(row.completion_checked_at !== null ? { completionCheckedAt: String(row.completion_checked_at) } : {}),
270
278
  ...(row.summary_json !== null ? { summaryJson: String(row.summary_json) } : {}),
271
279
  ...(row.report_json !== null ? { reportJson: String(row.report_json) } : {}),
272
280
  ...(row.failure_reason !== null ? { failureReason: String(row.failure_reason) } : {}),
@@ -1,5 +1,4 @@
1
1
  import { execCommand } from "./utils.js";
2
- import { resolveImplementationDeliveryMode, } from "./prompting/patchrelay.js";
3
2
  export class ImplementationOutcomePolicy {
4
3
  config;
5
4
  db;
@@ -17,13 +16,6 @@ export class ImplementationOutcomePolicy {
17
16
  }
18
17
  const project = this.config.projects.find((entry) => entry.id === run.projectId);
19
18
  const baseBranch = project?.github?.baseBranch ?? "main";
20
- const deliveryMode = resolveImplementationDeliveryMode(issue, undefined, run.promptText);
21
- if (deliveryMode === "linear_only") {
22
- if (issue.prNumber !== undefined) {
23
- return `Planning-only implementation should not open a PR, but PR #${issue.prNumber} was observed`;
24
- }
25
- return this.describeLocalImplementationOutcome(issue, baseBranch, deliveryMode);
26
- }
27
19
  if (issue.prNumber && issue.prState && issue.prState !== "closed") {
28
20
  return undefined;
29
21
  }
@@ -67,7 +59,7 @@ export class ImplementationOutcomePolicy {
67
59
  }, "Failed to verify published PR state after implementation");
68
60
  }
69
61
  }
70
- const details = await this.describeLocalImplementationOutcome(issue, baseBranch, deliveryMode);
62
+ const details = await this.describeLocalImplementationOutcome(issue, baseBranch);
71
63
  return details ?? `Implementation completed without opening a PR for branch ${issue.branchName ?? issue.linearIssueId}`;
72
64
  }
73
65
  upsertIssueIfLeaseHeld(projectId, linearIssueId, params, context) {
@@ -77,7 +69,7 @@ export class ImplementationOutcomePolicy {
77
69
  }
78
70
  return updated;
79
71
  }
80
- async describeLocalImplementationOutcome(issue, baseBranch, deliveryMode = "publish_pr") {
72
+ async describeLocalImplementationOutcome(issue, baseBranch) {
81
73
  if (!issue.worktreePath) {
82
74
  return undefined;
83
75
  }
@@ -92,9 +84,6 @@ export class ImplementationOutcomePolicy {
92
84
  ? status.stdout.split("\n").map((line) => line.trim()).filter(Boolean)
93
85
  : [];
94
86
  if (dirtyEntries.length > 0) {
95
- if (deliveryMode === "linear_only") {
96
- return `Planning-only implementation should not modify the repo; worktree still has ${dirtyEntries.length} uncommitted change(s)`;
97
- }
98
87
  return `Implementation completed without opening a PR; worktree still has ${dirtyEntries.length} uncommitted change(s)`;
99
88
  }
100
89
  }
@@ -112,9 +101,6 @@ export class ImplementationOutcomePolicy {
112
101
  if (ahead.exitCode === 0) {
113
102
  const count = Number(ahead.stdout.trim());
114
103
  if (Number.isFinite(count) && count > 0) {
115
- if (deliveryMode === "linear_only") {
116
- return `Planning-only implementation should not create repo commits; worktree is ${count} local commit(s) ahead of origin/${baseBranch}`;
117
- }
118
104
  return `Implementation completed with ${count} local commit(s) ahead of origin/${baseBranch} but no PR was observed`;
119
105
  }
120
106
  }
@@ -6,6 +6,10 @@ const TERMINAL_SESSION_EVENTS = new Set([
6
6
  "pr_closed",
7
7
  "pr_merged",
8
8
  ]);
9
+ const RUN_TYPES = new Set(["implementation", "review_fix", "branch_upkeep", "ci_repair", "queue_repair"]);
10
+ function parseRunType(value) {
11
+ return typeof value === "string" && RUN_TYPES.has(value) ? value : undefined;
12
+ }
9
13
  export function deriveSessionWakePlan(issue, events) {
10
14
  if (events.length === 0)
11
15
  return undefined;
@@ -70,6 +74,19 @@ export function deriveSessionWakePlan(issue, events) {
70
74
  resumeThread = true;
71
75
  break;
72
76
  }
77
+ case "completion_check_continue": {
78
+ if (!runType) {
79
+ runType = parseRunType(payload?.runType)
80
+ ?? (issue.prReviewState === "changes_requested" ? "review_fix" : "implementation");
81
+ wakeReason = "completion_check_continue";
82
+ }
83
+ if (typeof payload?.summary === "string" && payload.summary.trim()) {
84
+ context.completionCheckSummary = payload.summary.trim();
85
+ }
86
+ context.completionCheckMode = true;
87
+ resumeThread = true;
88
+ break;
89
+ }
73
90
  case "followup_prompt":
74
91
  case "followup_comment":
75
92
  case "operator_prompt": {
@@ -115,6 +115,28 @@ export function buildRunFailureActivity(runType, reason) {
115
115
  body: reason ? `${label} failed.\n\n${reason}` : `${label} failed.`,
116
116
  };
117
117
  }
118
+ export function buildCompletionCheckActivity(phase, result) {
119
+ switch (phase) {
120
+ case "started":
121
+ return { type: "thought", body: "No PR found; checking the next step." };
122
+ case "continue":
123
+ return { type: "thought", body: "No PR found; PatchRelay is continuing automatically." };
124
+ case "needs_input":
125
+ return {
126
+ type: "response",
127
+ body: result?.question
128
+ ? `PatchRelay needs an answer before it can continue.\n\nQuestion: ${result.question}${result.why ? `\n\nWhy: ${result.why}` : ""}${result.recommendedReply ? `\n\nSuggested reply: ${result.recommendedReply}` : ""}`
129
+ : "PatchRelay needs more input before it can continue.",
130
+ };
131
+ case "done":
132
+ return {
133
+ type: "response",
134
+ body: result?.summary
135
+ ? `Completed without a PR.\n\n${result.summary}`
136
+ : "Completed without a PR.",
137
+ };
138
+ }
139
+ }
118
140
  export function buildStopConfirmationActivity() {
119
141
  return {
120
142
  type: "response",
@@ -1,3 +1,4 @@
1
+ import { extractCompletionCheck } from "./completion-check.js";
1
2
  import { buildAgentSessionPlanForIssue } from "./agent-session-plan.js";
2
3
  import { buildAgentSessionExternalUrls } from "./agent-session-presentation.js";
3
4
  import { deriveIssueStatusNote } from "./status-note.js";
@@ -326,6 +327,17 @@ function renderStatusComment(db, issue, trackedIssue, options) {
326
327
  : "Note";
327
328
  lines.push("", `${label}: ${statusNote}`);
328
329
  }
330
+ const completionCheck = extractCompletionCheck(latestRun);
331
+ if (completionCheck?.outcome === "needs_input") {
332
+ if (completionCheck.why) {
333
+ lines.push("", `Why: ${completionCheck.why}`);
334
+ }
335
+ if (completionCheck.recommendedReply) {
336
+ lines.push("", `Suggested reply: ${completionCheck.recommendedReply}`);
337
+ }
338
+ const issueRef = issue.issueKey ?? issue.linearIssueId;
339
+ lines.push("", `Reply in a Linear comment to continue, or run \`patchrelay issue prompt ${issueRef} "..."\`.`);
340
+ }
329
341
  if (issue.prNumber !== undefined || issue.prUrl) {
330
342
  const prLabel = issue.prNumber !== undefined ? `#${issue.prNumber}` : "open";
331
343
  lines.push("", `PR: ${issue.prUrl ? `[${prLabel}](${issue.prUrl})` : prLabel}`);
@@ -335,6 +347,9 @@ function renderStatusComment(db, issue, trackedIssue, options) {
335
347
  if (latestRun.failureReason) {
336
348
  lines.push("", `Failure: ${latestRun.failureReason}`);
337
349
  }
350
+ if (completionCheck && completionCheck.outcome !== "needs_input" && completionCheck.summary !== statusNote) {
351
+ lines.push("", `Completion check: ${completionCheck.summary}`);
352
+ }
338
353
  }
339
354
  if (issue.lastGitHubFailureCheckName && (issue.factoryState === "repairing_ci" || issue.prCheckStatus === "failed" || issue.prCheckStatus === "failure")) {
340
355
  lines.push("", `Latest failing check: ${issue.lastGitHubFailureCheckName}`);
@@ -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) {
@@ -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,5 +1,6 @@
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
5
  import { resolvePreferredCompletedLinearState } from "./linear-workflow.js";
5
6
  import { QueueHealthMonitor } from "./queue-health-monitor.js";
@@ -49,6 +50,7 @@ export class RunOrchestrator {
49
50
  runWakePlanner;
50
51
  interruptedRunRecovery;
51
52
  runCompletionPolicy;
53
+ completionCheck;
52
54
  runNotificationHandler;
53
55
  runReconciler;
54
56
  activeSessionLeases;
@@ -66,7 +68,8 @@ export class RunOrchestrator {
66
68
  this.leaseService = new IssueSessionLeaseService(db, logger, this.workerId, (threadId, maxRetries) => this.readThreadWithRetry(threadId, maxRetries));
67
69
  this.activeSessionLeases = this.leaseService.activeSessionLeases;
68
70
  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);
71
+ this.completionCheck = new CompletionCheckService(codex, logger);
72
+ 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
73
  this.runLauncher = new RunLauncher(config, db, codex, logger, this.worktreeManager);
71
74
  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
75
  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);
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
  });
@@ -1,3 +1,4 @@
1
+ import { extractCompletionCheck } from "./completion-check.js";
1
2
  import { extractLatestAssistantSummary } from "./issue-session-events.js";
2
3
  import { sanitizeOperatorFacingText } from "./presentation-text.js";
3
4
  function clean(value) {
@@ -27,26 +28,41 @@ export function deriveIssueStatusNote(params) {
27
28
  return `Blocked by ${blockedByKeys.join(", ")}`;
28
29
  }
29
30
  const sessionSummary = clean(params.sessionSummary);
31
+ const completionCheckActive = Boolean(params.latestRun?.status === "running"
32
+ && params.latestRun.completionCheckThreadId
33
+ && !params.latestRun.completionCheckOutcome);
34
+ const completionCheck = extractCompletionCheck(params.latestRun);
35
+ const completionCheckNote = clean(completionCheck?.outcome === "needs_input"
36
+ ? completionCheck.question ?? completionCheck.summary
37
+ : completionCheck?.summary);
30
38
  const latestRunNote = clean(extractLatestAssistantSummary(params.latestRun));
31
39
  const latestEventNote = clean(eventStatusNote(params.latestEvent));
32
40
  const failureSummary = clean(params.failureSummary);
33
41
  const waitingReason = clean(params.waitingReason);
34
42
  let note;
35
- switch (params.issue.factoryState) {
36
- case "awaiting_input":
37
- note = latestRunNote ?? latestEventNote ?? sessionSummary;
38
- break;
39
- case "failed":
40
- case "escalated":
41
- note = latestEventNote ?? failureSummary ?? latestRunNote ?? sessionSummary;
42
- break;
43
- case "repairing_ci":
44
- case "repairing_queue":
45
- note = failureSummary ?? sessionSummary ?? latestRunNote;
46
- break;
47
- default:
48
- note = sessionSummary ?? latestRunNote ?? failureSummary;
49
- break;
43
+ if (completionCheckActive) {
44
+ note = "No PR found; checking next step";
45
+ }
46
+ else {
47
+ switch (params.issue.factoryState) {
48
+ case "awaiting_input":
49
+ note = completionCheckNote ?? latestRunNote ?? latestEventNote ?? sessionSummary;
50
+ break;
51
+ case "failed":
52
+ case "escalated":
53
+ note = latestEventNote ?? completionCheckNote ?? failureSummary ?? latestRunNote ?? sessionSummary;
54
+ break;
55
+ case "done":
56
+ note = completionCheckNote ?? sessionSummary ?? latestRunNote ?? failureSummary;
57
+ break;
58
+ case "repairing_ci":
59
+ case "repairing_queue":
60
+ note = failureSummary ?? sessionSummary ?? latestRunNote;
61
+ break;
62
+ default:
63
+ note = sessionSummary ?? latestRunNote ?? failureSummary;
64
+ break;
65
+ }
50
66
  }
51
67
  if (!note)
52
68
  return undefined;
@@ -30,6 +30,11 @@ export function buildTrackedIssueRecord(params) {
30
30
  blockedByKeys,
31
31
  waitingReason,
32
32
  });
33
+ const completionCheckActive = Boolean(params.issue.activeRunId !== undefined
34
+ && params.latestRun?.id === params.issue.activeRunId
35
+ && params.latestRun.status === "running"
36
+ && params.latestRun.completionCheckThreadId
37
+ && !params.latestRun.completionCheckOutcome);
33
38
  return {
34
39
  id: params.issue.id,
35
40
  projectId: params.issue.projectId,
@@ -62,6 +67,7 @@ export function buildTrackedIssueRecord(params) {
62
67
  ...(failureContext?.stepName ? { latestFailureStepName: failureContext.stepName } : {}),
63
68
  ...(failureContext?.summary ? { latestFailureSummary: failureContext.summary } : {}),
64
69
  ...(waitingReason ? { waitingReason } : {}),
70
+ ...(completionCheckActive ? { completionCheckActive } : {}),
65
71
  ...(params.issue.activeRunId !== undefined ? { activeRunId: params.issue.activeRunId } : {}),
66
72
  ...(params.issue.agentSessionId ? { activeAgentSessionId: params.issue.agentSessionId } : {}),
67
73
  updatedAt: params.issue.updatedAt,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "patchrelay",
3
- "version": "0.36.16",
3
+ "version": "0.36.17",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "repository": {