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.
@@ -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.18",
4
+ "commit": "2d0aa4ca856e",
5
+ "builtAt": "2026-04-10T12:35:59.590Z"
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
  }
@@ -1,13 +1,13 @@
1
- import { extractStageSummary, summarizeCurrentThread } from "./run-reporting.js";
2
- import { IssueOverviewQuery, parseStageReport, } from "./issue-overview-query.js";
1
+ import { IssueOverviewQuery, } from "./issue-overview-query.js";
2
+ import { PublicAgentSessionStatusQuery } from "./public-agent-session-status-query.js";
3
3
  export class IssueQueryService {
4
- db;
5
4
  runStatusProvider;
6
5
  overviewQuery;
6
+ publicStatusQuery;
7
7
  constructor(db, codex, runStatusProvider) {
8
- this.db = db;
9
8
  this.runStatusProvider = runStatusProvider;
10
9
  this.overviewQuery = new IssueOverviewQuery(db, codex, runStatusProvider);
10
+ this.publicStatusQuery = new PublicAgentSessionStatusQuery(db, this.overviewQuery);
11
11
  }
12
12
  async getIssueOverview(issueKey) {
13
13
  return await this.overviewQuery.getIssueOverview(issueKey);
@@ -16,45 +16,6 @@ export class IssueQueryService {
16
16
  return await this.runStatusProvider.getActiveRunStatus(issueKey);
17
17
  }
18
18
  async getPublicAgentSessionStatus(issueKey) {
19
- const overview = await this.overviewQuery.getIssueOverview(issueKey);
20
- if (!overview)
21
- return undefined;
22
- const issueRecord = this.db.issues.getIssueByKey(issueKey);
23
- const latestRunReport = parseStageReport(overview.latestRun?.reportJson, overview.latestRun?.status ?? "unknown");
24
- const runs = (overview.runs ?? this.overviewQuery.buildRuns(overview.issue.projectId, overview.issue.linearIssueId)).map((run) => ({
25
- run: {
26
- id: run.id,
27
- runType: run.runType,
28
- status: run.status,
29
- startedAt: run.startedAt,
30
- ...(run.endedAt ? { endedAt: run.endedAt } : {}),
31
- },
32
- ...(run.report ? { report: run.report } : {}),
33
- }));
34
- return {
35
- issue: {
36
- issueKey: overview.issue.issueKey,
37
- title: overview.issue.title,
38
- issueUrl: overview.issue.issueUrl,
39
- currentLinearState: overview.issue.currentLinearState,
40
- ...(overview.session?.sessionState ? { sessionState: overview.session.sessionState } : {}),
41
- factoryState: overview.issue.factoryState,
42
- ...(overview.session?.prNumber !== undefined ? { prNumber: overview.session.prNumber } : {}),
43
- ...(issueRecord?.prUrl ? { prUrl: issueRecord.prUrl } : {}),
44
- ...(issueRecord?.prState ? { prState: issueRecord.prState } : {}),
45
- ...(issueRecord?.prReviewState ? { prReviewState: issueRecord.prReviewState } : {}),
46
- ...(issueRecord?.prCheckStatus ? { prCheckStatus: issueRecord.prCheckStatus } : {}),
47
- ...(issueRecord ? { ciRepairAttempts: issueRecord.ciRepairAttempts, queueRepairAttempts: issueRecord.queueRepairAttempts } : {}),
48
- ...(overview.issue.waitingReason ? { waitingReason: overview.issue.waitingReason } : {}),
49
- ...(overview.issue.statusNote ? { statusNote: overview.issue.statusNote } : {}),
50
- ...(overview.session?.lastWakeReason ? { lastWakeReason: overview.session.lastWakeReason } : {}),
51
- },
52
- ...(overview.activeRun ? { activeRun: overview.activeRun } : {}),
53
- ...(overview.latestRun ? { latestRun: overview.latestRun } : {}),
54
- ...(overview.liveThread ? { liveThread: summarizeCurrentThread(overview.liveThread) } : {}),
55
- ...(latestRunReport ? { latestReportSummary: extractStageSummary(latestRunReport) } : {}),
56
- runs,
57
- generatedAt: new Date().toISOString(),
58
- };
19
+ return await this.publicStatusQuery.getStatus(issueKey);
59
20
  }
60
21
  }
@@ -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}`);