patchrelay 0.74.0 → 0.74.2

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.74.0",
4
- "commit": "d04d91223dec",
5
- "builtAt": "2026-05-28T18:16:54.428Z"
3
+ "version": "0.74.2",
4
+ "commit": "5e2c2733aa21",
5
+ "builtAt": "2026-05-28T21:38:50.236Z"
6
6
  }
package/dist/cli/data.js CHANGED
@@ -71,6 +71,9 @@ function summarizeRun(run) {
71
71
  : completionCheck.summary;
72
72
  }
73
73
  const summary = parseObjectJson(run.summaryJson);
74
+ if (typeof summary?.outcomeSummary === "string" && summary.outcomeSummary.trim()) {
75
+ return summary.outcomeSummary.trim();
76
+ }
74
77
  if (typeof summary?.publicationRecapSummary === "string" && summary.publicationRecapSummary.trim()) {
75
78
  return summary.publicationRecapSummary.trim();
76
79
  }
@@ -98,6 +101,9 @@ export class CliDataAccess extends CliOperatorApiClient {
98
101
  super(config);
99
102
  this.config = config;
100
103
  this.db = options?.db ?? new PatchRelayDatabase(config.database.path, config.database.wal);
104
+ if (!options?.db) {
105
+ this.db.assertSchemaReady();
106
+ }
101
107
  this.codex = options?.codex;
102
108
  }
103
109
  close() {
@@ -9,14 +9,6 @@ const COMPLETION_CHECK_DEVELOPER_INSTRUCTIONS = [
9
9
  "Use only the prior thread context and the facts in the current prompt.",
10
10
  "Return only the requested JSON object.",
11
11
  ].join("\n");
12
- const PUBLICATION_RECAP_DEVELOPER_INSTRUCTIONS = [
13
- "You are PatchRelay's publication recap helper.",
14
- "This is a read-only follow-up used only to produce one concise Linear-visible summary for a successful run.",
15
- "Keep reasoning light and concise.",
16
- "Do not run commands, do not call tools, do not edit files, and do not inspect or modify the repository.",
17
- "Use only the prior thread context and the facts in the current prompt.",
18
- "Return only the requested JSON object.",
19
- ].join("\n");
20
12
  const ISSUE_TRIAGE_DEVELOPER_INSTRUCTIONS = [
21
13
  "You are PatchRelay's issue triage classifier.",
22
14
  "This is a read-only preflight step used only to choose the execution shape for one Linear issue.",
@@ -202,14 +194,6 @@ export class CodexAppServerClient extends EventEmitter {
202
194
  developerInstructions: COMPLETION_CHECK_DEVELOPER_INSTRUCTIONS,
203
195
  });
204
196
  }
205
- async forkThreadForPublicationRecap(threadId) {
206
- return await this.forkThread(threadId, tmpdir(), {
207
- approvalPolicy: "never",
208
- sandboxMode: "read-only",
209
- reasoningEffort: "low",
210
- developerInstructions: PUBLICATION_RECAP_DEVELOPER_INSTRUCTIONS,
211
- });
212
- }
213
197
  async startTurn(options) {
214
198
  const response = (await this.sendRequest("turn/start", {
215
199
  threadId: options.threadId,
@@ -0,0 +1,17 @@
1
+ const REQUIRED_PATCHRELAY_TABLES = [
2
+ "issues",
3
+ "runs",
4
+ "issue_sessions",
5
+ "issue_session_events",
6
+ ];
7
+ export function assertPatchRelaySchemaReady(connection, databasePath) {
8
+ const rows = connection
9
+ .prepare("SELECT name FROM sqlite_master WHERE type = 'table'")
10
+ .all();
11
+ const tables = new Set(rows.map((row) => String(row.name)));
12
+ const missing = REQUIRED_PATCHRELAY_TABLES.filter((table) => !tables.has(table));
13
+ if (missing.length === 0) {
14
+ return;
15
+ }
16
+ throw new Error(`PatchRelay database is uninitialized or points at the wrong path: ${databasePath}. Missing required table(s): ${missing.join(", ")}`);
17
+ }
package/dist/db.js CHANGED
@@ -7,6 +7,7 @@ import { RepositoryLinkStore } from "./db/repository-link-store.js";
7
7
  import { RunStore } from "./db/run-store.js";
8
8
  import { WebhookEventStore } from "./db/webhook-event-store.js";
9
9
  import { runPatchRelayMigrations } from "./db/migrations.js";
10
+ import { assertPatchRelaySchemaReady } from "./db/schema-guard.js";
10
11
  import { SqliteConnection } from "./db/shared.js";
11
12
  import { syncIssueSessionFromIssue } from "./issue-session-projector.js";
12
13
  import { TrackedIssueQuery } from "./tracked-issue-query.js";
@@ -23,6 +24,7 @@ export class PatchRelayDatabase {
23
24
  runs;
24
25
  trackedIssues;
25
26
  constructor(databasePath, wal) {
27
+ this.databasePath = databasePath;
26
28
  this.connection = new SqliteConnection(databasePath);
27
29
  this.connection.pragma("foreign_keys = ON");
28
30
  if (wal) {
@@ -45,8 +47,13 @@ export class PatchRelayDatabase {
45
47
  this.workflowWakes = new WorkflowWakeResolver(this.issues, this.issueSessions);
46
48
  this.trackedIssues = new TrackedIssueQuery(this.issues, this.issueSessions, this.workflowWakes, this.runs);
47
49
  }
50
+ databasePath;
48
51
  runMigrations() {
49
52
  runPatchRelayMigrations(this.connection);
53
+ this.assertSchemaReady();
54
+ }
55
+ assertSchemaReady() {
56
+ assertPatchRelaySchemaReady(this.connection, this.databasePath);
50
57
  }
51
58
  transaction(fn) {
52
59
  return this.connection.transaction(fn)();
@@ -1,5 +1,6 @@
1
1
  import { existsSync } from "node:fs";
2
2
  import { mkdir, writeFile } from "node:fs/promises";
3
+ import { spawn } from "node:child_process";
3
4
  import path from "node:path";
4
5
  /**
5
6
  * Unified GitHub App credential delivery for `git` and the `gh` CLI.
@@ -88,3 +89,45 @@ export function buildAgentChildEnv(parentEnv = process.env) {
88
89
  delete env.GITHUB_TOKEN;
89
90
  return env;
90
91
  }
92
+ export async function verifyGitHubCliAuthEnv(env = process.env, options = {}) {
93
+ const timeoutMs = options.timeoutMs ?? 10_000;
94
+ await new Promise((resolve, reject) => {
95
+ const child = spawn("git", ["credential", "fill"], {
96
+ env,
97
+ stdio: ["pipe", "pipe", "pipe"],
98
+ });
99
+ let stderr = "";
100
+ let settled = false;
101
+ const timer = setTimeout(() => {
102
+ if (settled)
103
+ return;
104
+ settled = true;
105
+ child.kill("SIGTERM");
106
+ reject(new Error(`GitHub git credential check timed out after ${timeoutMs}ms`));
107
+ }, timeoutMs);
108
+ timer.unref?.();
109
+ child.stderr.on("data", (chunk) => {
110
+ stderr += String(chunk);
111
+ });
112
+ child.on("error", (error) => {
113
+ if (settled)
114
+ return;
115
+ settled = true;
116
+ clearTimeout(timer);
117
+ reject(error);
118
+ });
119
+ child.on("close", (code) => {
120
+ if (settled)
121
+ return;
122
+ settled = true;
123
+ clearTimeout(timer);
124
+ if (code === 0) {
125
+ resolve();
126
+ return;
127
+ }
128
+ const detail = stderr.trim();
129
+ reject(new Error(`GitHub git credential check failed${detail ? `: ${detail}` : ""}`));
130
+ });
131
+ child.stdin.end(`protocol=https\nhost=${GITHUB_HOST}\n\n`);
132
+ });
133
+ }
@@ -116,6 +116,7 @@ export function deriveSessionWakePlan(issue, events) {
116
116
  else {
117
117
  eventIds.push(event.id);
118
118
  }
119
+ Object.assign(context, payload ?? {});
119
120
  if (typeof payload?.summary === "string" && payload.summary.trim()) {
120
121
  context.completionCheckSummary = payload.summary.trim();
121
122
  }
@@ -187,6 +188,9 @@ export function extractLatestAssistantSummary(run) {
187
188
  if (run.summaryJson) {
188
189
  try {
189
190
  const parsed = JSON.parse(run.summaryJson);
191
+ if (typeof parsed.outcomeSummary === "string" && parsed.outcomeSummary.trim()) {
192
+ return sanitizeOperatorFacingText(parsed.outcomeSummary);
193
+ }
190
194
  if (typeof parsed.publicationRecapSummary === "string" && parsed.publicationRecapSummary.trim()) {
191
195
  return sanitizeOperatorFacingText(parsed.publicationRecapSummary);
192
196
  }
@@ -23,6 +23,9 @@ function deriveProgressFactFromCompletedItem(rawItem, issue) {
23
23
  return undefined;
24
24
  }
25
25
  const ephemeralBody = compactOperatorSentence(fullBody) ?? fullBody;
26
+ if (looksLikeOperationalChatter(fullBody)) {
27
+ return undefined;
28
+ }
26
29
  if (looksLikeVerification(fullBody)) {
27
30
  return {
28
31
  kind: "verification_started",
@@ -131,6 +134,16 @@ function looksLikePublishing(text) {
131
134
  || normalized.includes("opening the pr")
132
135
  || normalized.includes("opening pull request");
133
136
  }
137
+ function looksLikeOperationalChatter(text) {
138
+ const normalized = text.toLowerCase().replace(/\s+/g, " ").trim();
139
+ const firstPerson = /\b(i('|’)m|i am|i('|’)ll|i will|i need|i’m|i’ll)\b/.test(normalized);
140
+ const operational = /\b(waiting|watching|checking|running|rerunning|pushing|publishing|creating|opening|committing|preparing|verification|publish pass|push)\b/.test(normalized);
141
+ return (firstPerson && operational)
142
+ || /^(i('|’)m|i am|i('|’)ll|i will|i need|i’m|i’ll)\b/.test(normalized)
143
+ || /^(continuing|resuming) from\b/.test(normalized)
144
+ || /\b(i('|’)m|i am|i('|’)ll|i will|i need|i’m|i’ll)\s+(waiting|watching|checking|running|rerunning|pushing|publishing|creating|opening|committing)\b/.test(normalized)
145
+ || /\b(is|are)\s+(now\s+)?(running|still running|in progress)\b/.test(normalized);
146
+ }
134
147
  function compactOperatorSentence(text, maxLength = 160) {
135
148
  const sanitized = sanitizeOperatorFacingText(text)?.replace(/\s+/g, " ").trim();
136
149
  if (!sanitized) {
@@ -108,14 +108,13 @@ export function buildRunStartedActivity(runType) {
108
108
  }
109
109
  export function buildReviewRoundStartedActivity(params) {
110
110
  const reviewer = params.reviewerName ? ` from @${params.reviewerName}` : "";
111
- const head = params.headSha ? ` on head ${params.headSha.slice(0, 8)}` : "";
112
111
  const comments = params.commentCount !== undefined
113
112
  ? `; ${params.commentCount} inline comment${params.commentCount === 1 ? "" : "s"} captured`
114
113
  : "";
115
114
  return {
116
115
  type: "action",
117
116
  action: "Review round",
118
- parameter: `${params.round}${reviewer}${head}${comments}`,
117
+ parameter: `${params.round}${reviewer}${comments}`,
119
118
  };
120
119
  }
121
120
  function formatFactoryState(state) {
@@ -123,13 +122,13 @@ function formatFactoryState(state) {
123
122
  }
124
123
  export function buildRunCompletedActivity(params) {
125
124
  const prLabel = params.prNumber ? `PR #${params.prNumber}` : "the pull request";
126
- const summary = trimSummary(params.completionSummary);
125
+ const summary = cleanOutcomeSummary(trimSummary(params.completionSummary));
127
126
  const detail = summary ? ` ${summary}` : "";
128
127
  const steeringSummary = buildSteeringSummary(params.steeringDeliveredCount, params.steeringFailedCount);
129
128
  switch (params.runType) {
130
129
  case "implementation":
131
130
  if (params.postRunState === "pr_open") {
132
- const body = `${prLabel} opened:${detail || " Published and ready for review."}`;
131
+ const body = `${prLabel} opened:${detail || " Ready for review."}`;
133
132
  return {
134
133
  type: "response",
135
134
  body: steeringSummary ? `${body}\n\n${steeringSummary}` : body,
@@ -140,15 +139,10 @@ export function buildRunCompletedActivity(params) {
140
139
  {
141
140
  const lines = [];
142
141
  lines.push(params.reviewRound ? `Review round ${params.reviewRound} completed.` : "Review fix completed.");
143
- if (params.resultHeadSha)
144
- lines.push(`Resulting head: ${params.resultHeadSha.slice(0, 8)}.`);
145
142
  if (steeringSummary)
146
143
  lines.push(steeringSummary);
147
- const resolution = buildReviewResolutionSections(summary);
148
- lines.push("", "Addressed:", resolution.addressed, "", "Deferred:", resolution.deferred, "", "Not applicable:", resolution.notApplicable);
149
- if (!summary && !params.resultHeadSha) {
150
- lines[0] = `Updated ${prLabel} to address review feedback.`;
151
- }
144
+ const addressed = summary ? `- ${summary}` : "- Review feedback addressed.";
145
+ lines.push("", "Addressed:", addressed);
152
146
  return {
153
147
  type: "response",
154
148
  body: lines.join("\n").trim(),
@@ -196,6 +190,15 @@ export function buildRunCompletedActivity(params) {
196
190
  }
197
191
  }
198
192
  }
193
+ function cleanOutcomeSummary(summary) {
194
+ if (!summary)
195
+ return undefined;
196
+ return summary
197
+ .replace(/\s*(?:,?\s*(?:and|then)\s+)?(?:force-)?pushed(?:\s+(?:a\s+)?(?:new\s+)?head|\s+the\s+branch|\s+changes|\s+an?\s+update|\s+the\s+repaired\s+branch)?\.?$/i, ".")
198
+ .replace(/\s*(?:,?\s*(?:and|then)\s+)?published(?:\s+(?:a\s+)?(?:new\s+)?head|\s+the\s+branch|\s+changes|\s+an?\s+update)?\.?$/i, ".")
199
+ .replace(/\.\.+$/, ".")
200
+ .trim();
201
+ }
199
202
  function buildSteeringSummary(delivered = 0, failed = 0) {
200
203
  if (delivered === 0 && failed === 0)
201
204
  return undefined;
@@ -208,20 +211,6 @@ function buildSteeringSummary(delivered = 0, failed = 0) {
208
211
  }
209
212
  return `Steering: ${parts.join("; ")}.`;
210
213
  }
211
- function buildReviewResolutionSections(summary) {
212
- if (!summary) {
213
- return {
214
- addressed: "- Review feedback addressed and published.",
215
- deferred: "- None reported.",
216
- notApplicable: "- None reported.",
217
- };
218
- }
219
- return {
220
- addressed: `- ${summary}`,
221
- deferred: "- None reported.",
222
- notApplicable: "- None reported.",
223
- };
224
- }
225
214
  export function buildRunFailureActivity(runType, reason) {
226
215
  const label = formatRunTypeLabel(runType);
227
216
  return {
package/dist/preflight.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { accessSync, constants, existsSync, mkdirSync, statSync } from "node:fs";
2
2
  import path from "node:path";
3
3
  import { runPatchRelayMigrations } from "./db/migrations.js";
4
+ import { assertPatchRelaySchemaReady } from "./db/schema-guard.js";
4
5
  import { resolveMergeQueueProtocol } from "./merge-queue-protocol.js";
5
6
  import { SqliteConnection } from "./db/shared.js";
6
7
  import { execCommand } from "./utils.js";
@@ -132,6 +133,7 @@ function checkDatabaseHealth(config) {
132
133
  connection.pragma("journal_mode = WAL");
133
134
  }
134
135
  runPatchRelayMigrations(connection);
136
+ assertPatchRelaySchemaReady(connection, config.database.path);
135
137
  const quickCheck = connection.prepare("PRAGMA quick_check").get();
136
138
  const quickCheckResult = quickCheck ? Object.values(quickCheck)[0] : undefined;
137
139
  if (quickCheckResult !== "ok") {
@@ -421,6 +421,22 @@ function buildFollowUpContextLines(issue, runType, context) {
421
421
  if (wakeReason === "completion_check_continue" && typeof context?.completionCheckSummary === "string" && context.completionCheckSummary.trim()) {
422
422
  lines.push(`Completion check summary: ${context.completionCheckSummary.trim()}`);
423
423
  }
424
+ if (context?.preserveDirtyWorktree === true) {
425
+ lines.push("", "Unpublished local work:", "PatchRelay detected that the previous repair turn ended with uncommitted changes in this worktree.", "Do not reset, clean, stash-drop, or otherwise discard the current worktree. Inspect the existing local diff, keep the intended in-scope repair, then commit and push a fresh PR head.");
426
+ if (typeof context.dirtyWorktreeSummary === "string" && context.dirtyWorktreeSummary.trim()) {
427
+ lines.push(`Dirty worktree summary: ${context.dirtyWorktreeSummary.trim()}`);
428
+ }
429
+ const changedPaths = Array.isArray(context.dirtyWorktreeChangedPaths)
430
+ ? context.dirtyWorktreeChangedPaths.filter((entry) => typeof entry === "string" && entry.trim().length > 0)
431
+ : [];
432
+ if (changedPaths.length > 0) {
433
+ lines.push("Changed paths:");
434
+ changedPaths.slice(0, 12).forEach((entry) => lines.push(`- ${entry}`));
435
+ if (changedPaths.length > 12) {
436
+ lines.push(`- ...and ${changedPaths.length - 12} more`);
437
+ }
438
+ }
439
+ }
424
440
  if (followUpLines.length > 0) {
425
441
  lines.push("", "Recent updates:");
426
442
  followUpLines.forEach((line) => lines.push(`- ${line}`));
@@ -4,6 +4,7 @@ import { handleNoPrCompletionCheck } from "./no-pr-completion-check.js";
4
4
  import { resolveCompletedRunState } from "./run-completion-policy.js";
5
5
  import { computeChangeIdentityFromWorktree } from "./change-identity.js";
6
6
  import { inspectGitWorktreeStatus, isRepairRunType } from "./git-worktree-status.js";
7
+ import { buildRunOutcomeSummary } from "./run-outcome-summary.js";
7
8
  function parseEventJson(eventJson) {
8
9
  if (!eventJson)
9
10
  return undefined;
@@ -15,10 +16,12 @@ function parseEventJson(eventJson) {
15
16
  return undefined;
16
17
  }
17
18
  }
18
- function buildRunSummaryJson(report, publicationRecapSummary) {
19
+ function buildRunSummaryJson(report, outcomeSummary) {
19
20
  return JSON.stringify({
20
21
  latestAssistantMessage: report.assistantMessages.at(-1) ?? null,
21
- publicationRecapSummary: publicationRecapSummary ?? null,
22
+ outcomeSummary: outcomeSummary ?? null,
23
+ // Backward compatibility for older CLI/status readers.
24
+ publicationRecapSummary: outcomeSummary ?? null,
22
25
  });
23
26
  }
24
27
  function summarizePromptDeliveryEvents(events, run) {
@@ -39,13 +42,6 @@ function summarizePromptDeliveryEvents(events, run) {
39
42
  }
40
43
  return { delivered, failed };
41
44
  }
42
- function shouldGeneratePublicationRecap(runType) {
43
- return runType === "implementation"
44
- || runType === "review_fix"
45
- || runType === "branch_upkeep"
46
- || runType === "ci_repair"
47
- || runType === "queue_repair";
48
- }
49
45
  export class RunFinalizer {
50
46
  db;
51
47
  logger;
@@ -57,9 +53,8 @@ export class RunFinalizer {
57
53
  failRunAndClear;
58
54
  completionPolicy;
59
55
  completionCheck;
60
- publicationRecap;
61
56
  feed;
62
- constructor(db, logger, linearSync, wakeDispatcher, withHeldLease, releaseLease, appendWakeEventWithLease, failRunAndClear, completionPolicy, completionCheck, publicationRecap, feed) {
57
+ constructor(db, logger, linearSync, wakeDispatcher, withHeldLease, releaseLease, appendWakeEventWithLease, failRunAndClear, completionPolicy, completionCheck, feed) {
63
58
  this.db = db;
64
59
  this.logger = logger;
65
60
  this.linearSync = linearSync;
@@ -70,7 +65,6 @@ export class RunFinalizer {
70
65
  this.failRunAndClear = failRunAndClear;
71
66
  this.completionPolicy = completionPolicy;
72
67
  this.completionCheck = completionCheck;
73
- this.publicationRecap = publicationRecap;
74
68
  this.feed = feed;
75
69
  }
76
70
  buildCompletedRunUpdate(params) {
@@ -78,7 +72,7 @@ export class RunFinalizer {
78
72
  status: "completed",
79
73
  threadId: params.threadId,
80
74
  ...(params.completedTurnId ? { turnId: params.completedTurnId } : {}),
81
- summaryJson: buildRunSummaryJson(params.report, params.publicationRecapSummary),
75
+ summaryJson: buildRunSummaryJson(params.report, params.outcomeSummary),
82
76
  reportJson: JSON.stringify(params.report),
83
77
  };
84
78
  }
@@ -88,7 +82,7 @@ export class RunFinalizer {
88
82
  .filter((event) => event.consumedByRunId === run.id)
89
83
  .at(-1);
90
84
  }
91
- resolvePublicationRecapFacts(params) {
85
+ resolveRunOutcomeFacts(params) {
92
86
  const session = this.db.issueSessions.getIssueSession(params.run.projectId, params.run.linearIssueId);
93
87
  const facts = {
94
88
  ...(session?.lastWakeReason ? { wakeReason: session.lastWakeReason } : {}),
@@ -125,31 +119,16 @@ export class RunFinalizer {
125
119
  return facts;
126
120
  }
127
121
  }
128
- async generatePublicationRecap(params) {
129
- if (!this.publicationRecap || !shouldGeneratePublicationRecap(params.run.runType)) {
130
- return undefined;
131
- }
132
- try {
133
- const result = await this.publicationRecap.run({
134
- issue: params.issue,
122
+ buildOutcomeSummary(params) {
123
+ return buildRunOutcomeSummary({
124
+ runType: params.run.runType,
125
+ facts: this.resolveRunOutcomeFacts({
135
126
  run: params.run,
136
- facts: this.resolvePublicationRecapFacts({
137
- run: params.run,
138
- issue: params.issue,
139
- postRunState: params.postRunState,
140
- latestAssistantSummary: params.latestAssistantSummary,
141
- }),
142
- });
143
- return result.summary;
144
- }
145
- catch (error) {
146
- this.logger.warn({
147
- runId: params.run.id,
148
- issueKey: params.issue.issueKey,
149
- error: error instanceof Error ? error.message : String(error),
150
- }, "Publication recap failed; falling back to the main run summary");
151
- return undefined;
152
- }
127
+ issue: params.issue,
128
+ postRunState: params.postRunState,
129
+ latestAssistantSummary: params.latestAssistantSummary,
130
+ }),
131
+ });
153
132
  }
154
133
  // Plan §4.2(c): record the identity of the head we just published
155
134
  // so subsequent runs can recognize a patch-id-equivalent re-push.
@@ -274,15 +253,77 @@ export class RunFinalizer {
274
253
  // exists. Keeping the parameter would be redundant.
275
254
  this.clearProgressAndRelease(params.run);
276
255
  }
277
- verifyRepairWorktreeClean(run, issue) {
256
+ inspectDirtyRepairWorktree(run, issue) {
278
257
  if (!isRepairRunType(run.runType) || !issue.worktreePath)
279
258
  return undefined;
280
259
  const status = inspectGitWorktreeStatus(issue.worktreePath);
281
260
  if (!status.dirty)
282
261
  return undefined;
283
- return status.summary
284
- ? `Repair run finished with a dirty worktree; ${status.summary}`
262
+ return status;
263
+ }
264
+ continueDirtyRepairWorktree(params) {
265
+ const message = params.status.summary
266
+ ? `Repair run finished with a dirty worktree; ${params.status.summary}`
285
267
  : "Repair run finished with a dirty worktree";
268
+ const outcomeSummary = "Repair left unpublished local changes; continuing automatically to publish them.";
269
+ const continued = this.withHeldLease(params.run.projectId, params.run.linearIssueId, (lease) => {
270
+ this.db.runs.finishRun(params.run.id, this.buildCompletedRunUpdate({
271
+ threadId: params.threadId,
272
+ ...(params.completedTurnId ? { completedTurnId: params.completedTurnId } : {}),
273
+ report: params.report,
274
+ outcomeSummary,
275
+ }));
276
+ this.db.issueSessions.upsertIssueWithLease(lease, {
277
+ projectId: params.run.projectId,
278
+ linearIssueId: params.run.linearIssueId,
279
+ activeRunId: null,
280
+ factoryState: "delegated",
281
+ pendingRunType: null,
282
+ pendingRunContextJson: null,
283
+ ...(params.run.runType === "ci_repair" && params.issue.ciRepairAttempts > 0
284
+ ? { ciRepairAttempts: params.issue.ciRepairAttempts - 1 }
285
+ : {}),
286
+ ...(params.run.runType === "queue_repair" && params.issue.queueRepairAttempts > 0
287
+ ? { queueRepairAttempts: params.issue.queueRepairAttempts - 1 }
288
+ : {}),
289
+ ...((params.run.runType === "review_fix" || params.run.runType === "branch_upkeep") && params.issue.reviewFixAttempts > 0
290
+ ? { reviewFixAttempts: params.issue.reviewFixAttempts - 1 }
291
+ : {}),
292
+ });
293
+ return Boolean(this.db.issueSessions.appendIssueSessionEventWithLease(lease, {
294
+ projectId: params.run.projectId,
295
+ linearIssueId: params.run.linearIssueId,
296
+ eventType: "completion_check_continue",
297
+ eventJson: JSON.stringify({
298
+ runType: params.run.runType,
299
+ summary: message,
300
+ preserveDirtyWorktree: true,
301
+ dirtyWorktreeSummary: params.status.summary,
302
+ dirtyWorktreeChangedPaths: params.status.changedPaths,
303
+ dirtyWorktreeMergeInProgress: params.status.mergeInProgress,
304
+ }),
305
+ dedupeKey: `dirty_repair_continue:${params.run.id}`,
306
+ }));
307
+ });
308
+ if (!continued) {
309
+ this.logger.warn({ runId: params.run.id, issueId: params.run.linearIssueId }, "Skipping dirty-repair continuation after losing issue-session lease");
310
+ this.clearProgressAndRelease(params.run);
311
+ return;
312
+ }
313
+ this.publishTurnEvent({
314
+ level: "warn",
315
+ run: params.run,
316
+ issueKey: params.issue.issueKey,
317
+ status: "dirty_repair_continue",
318
+ summary: "Repair left unpublished local changes; continuing automatically",
319
+ detail: message,
320
+ });
321
+ void this.linearSync.emitActivity(params.issue, {
322
+ type: "thought",
323
+ body: "PatchRelay found unpublished repair changes and is continuing automatically to commit and push them.",
324
+ }, { ephemeral: true });
325
+ void this.linearSync.syncSession(params.issue, { activeRunType: params.run.runType });
326
+ this.clearProgressAndRelease(params.run);
286
327
  }
287
328
  async finalizeCompletedRun(params) {
288
329
  const { run, issue, thread, threadId } = params;
@@ -302,16 +343,15 @@ export class RunFinalizer {
302
343
  const trackedIssue = this.db.issueToTrackedIssue(issue);
303
344
  const report = buildStageReport({ ...run, status: "completed" }, trackedIssue, thread, countEventMethods(this.db.runs.listThreadEvents(run.id)));
304
345
  const freshIssue = this.db.issues.getIssue(run.projectId, run.linearIssueId) ?? issue;
305
- const dirtyRepairWorktreeError = this.verifyRepairWorktreeClean(run, freshIssue);
306
- if (dirtyRepairWorktreeError) {
307
- this.failRunAndClear(run, dirtyRepairWorktreeError, "escalated");
308
- this.syncFailureOutcome({
346
+ const dirtyRepairWorktree = this.inspectDirtyRepairWorktree(run, freshIssue);
347
+ if (dirtyRepairWorktree) {
348
+ this.continueDirtyRepairWorktree({
309
349
  run,
310
- fallbackIssue: freshIssue,
311
- message: dirtyRepairWorktreeError,
312
- level: "error",
313
- status: "dirty_repair_worktree",
314
- summary: dirtyRepairWorktreeError,
350
+ issue: freshIssue,
351
+ status: dirtyRepairWorktree,
352
+ threadId,
353
+ ...(params.completedTurnId ? { completedTurnId: params.completedTurnId } : {}),
354
+ report,
315
355
  });
316
356
  return;
317
357
  }
@@ -388,7 +428,7 @@ export class RunFinalizer {
388
428
  this.maybeUpdateLastPublishedIdentity(run, refreshedIssue);
389
429
  const postRunFollowUp = await this.completionPolicy.resolvePostRunFollowUp(run, refreshedIssue);
390
430
  const postRunState = postRunFollowUp?.factoryState ?? resolveCompletedRunState(refreshedIssue, run);
391
- const publicationRecapSummary = await this.generatePublicationRecap({
431
+ const outcomeSummary = this.buildOutcomeSummary({
392
432
  run,
393
433
  issue: refreshedIssue,
394
434
  postRunState,
@@ -399,7 +439,7 @@ export class RunFinalizer {
399
439
  threadId,
400
440
  ...(params.completedTurnId ? { completedTurnId: params.completedTurnId } : {}),
401
441
  report,
402
- publicationRecapSummary,
442
+ outcomeSummary,
403
443
  }));
404
444
  this.db.issues.upsertIssue({
405
445
  projectId: run.projectId,
@@ -453,12 +493,10 @@ export class RunFinalizer {
453
493
  summary: params.source === "notification"
454
494
  ? `Turn completed for ${run.runType}`
455
495
  : `Reconciliation: ${run.runType} completed${postRunState ? ` -> ${postRunState}` : ""}`,
456
- detail: publicationRecapSummary ?? report.assistantMessages.at(-1),
496
+ detail: outcomeSummary,
457
497
  });
458
498
  const updatedIssue = this.db.issues.getIssue(run.projectId, run.linearIssueId) ?? refreshedIssue;
459
- const completionSummary = publicationRecapSummary
460
- ?? report.assistantMessages.at(-1)?.slice(0, 300)
461
- ?? `${run.runType} completed.`;
499
+ const completionSummary = outcomeSummary;
462
500
  const steeringSummary = summarizePromptDeliveryEvents(this.db.issueSessions.listIssueSessionEvents(run.projectId, run.linearIssueId), run);
463
501
  const linearActivity = buildRunCompletedActivity({
464
502
  runType: run.runType,
@@ -466,7 +504,6 @@ export class RunFinalizer {
466
504
  postRunState: updatedIssue.factoryState,
467
505
  ...(updatedIssue.prNumber !== undefined ? { prNumber: updatedIssue.prNumber } : {}),
468
506
  ...(run.runType === "review_fix" ? { reviewRound: Math.max(1, updatedIssue.reviewFixAttempts) } : {}),
469
- ...(run.runType === "review_fix" && updatedIssue.prHeadSha ? { resultHeadSha: updatedIssue.prHeadSha } : {}),
470
507
  ...(steeringSummary.delivered > 0 ? { steeringDeliveredCount: steeringSummary.delivered } : {}),
471
508
  ...(steeringSummary.failed > 0 ? { steeringFailedCount: steeringSummary.failed } : {}),
472
509
  });
@@ -41,6 +41,9 @@ export function shouldReuseIssueThread(params) {
41
41
  return Boolean(params.existingThreadId) && !params.compactThread && params.resumeThread;
42
42
  }
43
43
  export function shouldFreshenWorktreeBeforeLaunch(params) {
44
+ if (shouldPreserveDirtyWorktreeBeforeLaunch(params)) {
45
+ return false;
46
+ }
44
47
  if (params.runType === "queue_repair") {
45
48
  return false;
46
49
  }
@@ -50,6 +53,13 @@ export function shouldFreshenWorktreeBeforeLaunch(params) {
50
53
  }
51
54
  return true;
52
55
  }
56
+ export function shouldPreserveDirtyWorktreeBeforeLaunch(params) {
57
+ return params.effectiveContext?.preserveDirtyWorktree === true
58
+ && (params.runType === "review_fix"
59
+ || params.runType === "branch_upkeep"
60
+ || params.runType === "ci_repair"
61
+ || params.runType === "queue_repair");
62
+ }
53
63
  export class RunLauncher {
54
64
  config;
55
65
  db;
@@ -154,7 +164,16 @@ export class RunLauncher {
154
164
  // process env (GH_CONFIG_DIR + gh credential helper + GIT_AUTHOR/COMMITTER). Nothing
155
165
  // is written into the worktree git config, so credentials never leak into interactive
156
166
  // shell sessions on the shared clone.
157
- await this.worktreeManager.resetWorktreeToTrackedBranch(params.worktreePath, params.branchName, params.issue, this.logger);
167
+ const preserveDirtyWorktree = shouldPreserveDirtyWorktreeBeforeLaunch({
168
+ runType: params.runType,
169
+ ...(params.effectiveContext ? { effectiveContext: params.effectiveContext } : {}),
170
+ });
171
+ if (preserveDirtyWorktree) {
172
+ this.logger.warn({ issueKey: params.issue.issueKey, runType: params.runType, worktreePath: params.worktreePath }, "Preserving dirty repair worktree for automatic publication continuation");
173
+ }
174
+ else {
175
+ await this.worktreeManager.resetWorktreeToTrackedBranch(params.worktreePath, params.branchName, params.issue, this.logger);
176
+ }
158
177
  if (shouldFreshenWorktreeBeforeLaunch({
159
178
  runType: params.runType,
160
179
  ...(params.effectiveContext ? { effectiveContext: params.effectiveContext } : {}),
@@ -4,6 +4,7 @@ import { resolveRecoverablePostRunState } from "./interrupted-run-recovery.js";
4
4
  function isRequestedChangesRunType(runType) {
5
5
  return runType === "review_fix" || runType === "branch_upkeep";
6
6
  }
7
+ const DEFAULT_PUBLISH_COMMAND_TIMEOUT_MS = 10 * 60 * 1000;
7
8
  export class RunNotificationHandler {
8
9
  config;
9
10
  db;
@@ -15,8 +16,10 @@ export class RunNotificationHandler {
15
16
  heartbeatIssueSessionLease;
16
17
  releaseIssueSessionLease;
17
18
  feed;
19
+ options;
18
20
  activeThreadId;
19
- constructor(config, db, logger, linearSync, runFinalizer, readThreadWithRetry, withHeldIssueSessionLease, heartbeatIssueSessionLease, releaseIssueSessionLease, feed) {
21
+ publishCommandWatchdogs = new Map();
22
+ constructor(config, db, logger, linearSync, runFinalizer, readThreadWithRetry, withHeldIssueSessionLease, heartbeatIssueSessionLease, releaseIssueSessionLease, feed, options = {}) {
20
23
  this.config = config;
21
24
  this.db = db;
22
25
  this.logger = logger;
@@ -27,6 +30,7 @@ export class RunNotificationHandler {
27
30
  this.heartbeatIssueSessionLease = heartbeatIssueSessionLease;
28
31
  this.releaseIssueSessionLease = releaseIssueSessionLease;
29
32
  this.feed = feed;
33
+ this.options = options;
30
34
  }
31
35
  async handle(notification) {
32
36
  let threadId = typeof notification.params.threadId === "string" ? notification.params.threadId : undefined;
@@ -50,6 +54,7 @@ export class RunNotificationHandler {
50
54
  return;
51
55
  }
52
56
  const turnId = typeof notification.params.turnId === "string" ? notification.params.turnId : undefined;
57
+ this.observePublishCommand(notification, run, threadId, turnId ?? run.turnId);
53
58
  if (this.config.runner.codex.persistExtendedHistory) {
54
59
  this.db.runs.saveThreadEvent({
55
60
  runId: run.id,
@@ -59,15 +64,13 @@ export class RunNotificationHandler {
59
64
  eventJson: JSON.stringify(notification.params),
60
65
  });
61
66
  }
62
- this.linearSync.maybeEmitProgress(notification, run);
67
+ this.maybeEmitProgress(notification, run);
63
68
  if (notification.method === "turn/plan/updated") {
64
- const issue = this.db.issues.getIssue(run.projectId, run.linearIssueId);
65
- if (issue) {
66
- void this.linearSync.syncCodexPlan(issue, notification.params);
67
- }
69
+ this.syncCodexPlan(notification, run);
68
70
  }
69
71
  if (notification.method !== "turn/completed")
70
72
  return;
73
+ this.clearPublishWatchdogsForThread(threadId);
71
74
  const thread = await this.readThreadWithRetry(threadId);
72
75
  const issue = this.db.issues.getIssue(run.projectId, run.linearIssueId);
73
76
  if (!issue)
@@ -137,4 +140,120 @@ export class RunNotificationHandler {
137
140
  });
138
141
  this.activeThreadId = undefined;
139
142
  }
143
+ observePublishCommand(notification, run, threadId, turnId) {
144
+ const item = notification.params.item;
145
+ if (!item || typeof item !== "object") {
146
+ return;
147
+ }
148
+ const itemRecord = item;
149
+ const itemId = typeof itemRecord.id === "string" ? itemRecord.id : undefined;
150
+ if (!itemId) {
151
+ return;
152
+ }
153
+ if (notification.method === "item/completed" || isTerminalItemUpdate(notification.method, itemRecord)) {
154
+ this.clearPublishWatchdog(threadId, itemId);
155
+ return;
156
+ }
157
+ if (notification.method !== "item/started" || itemRecord.type !== "commandExecution" || !turnId || !this.options.interruptTurn) {
158
+ return;
159
+ }
160
+ const command = extractCommandText(itemRecord.command);
161
+ if (!command || !isGitPushCommand(command)) {
162
+ return;
163
+ }
164
+ const key = publishWatchdogKey(threadId, itemId);
165
+ if (this.publishCommandWatchdogs.has(key)) {
166
+ return;
167
+ }
168
+ const timeoutMs = this.options.publishCommandTimeoutMs ?? DEFAULT_PUBLISH_COMMAND_TIMEOUT_MS;
169
+ const timer = setTimeout(() => {
170
+ this.publishCommandWatchdogs.delete(key);
171
+ this.logger.warn({ runId: run.id, projectId: run.projectId, issueId: run.linearIssueId, threadId, turnId, timeoutMs }, "Interrupting stuck git push command");
172
+ this.feed?.publish({
173
+ level: "error",
174
+ kind: "turn",
175
+ projectId: run.projectId,
176
+ stage: run.runType,
177
+ status: "interrupted",
178
+ summary: `Interrupted stuck publish command after ${Math.round(timeoutMs / 1000)}s`,
179
+ });
180
+ void this.options.interruptTurn?.({ threadId, turnId }).catch((error) => {
181
+ this.logger.warn({ runId: run.id, projectId: run.projectId, issueId: run.linearIssueId, threadId, turnId, error: formatError(error) }, "Failed to interrupt stuck git push command");
182
+ });
183
+ }, timeoutMs);
184
+ timer.unref?.();
185
+ this.publishCommandWatchdogs.set(key, timer);
186
+ }
187
+ clearPublishWatchdog(threadId, itemId) {
188
+ const key = publishWatchdogKey(threadId, itemId);
189
+ const timer = this.publishCommandWatchdogs.get(key);
190
+ if (!timer) {
191
+ return;
192
+ }
193
+ clearTimeout(timer);
194
+ this.publishCommandWatchdogs.delete(key);
195
+ }
196
+ clearPublishWatchdogsForThread(threadId) {
197
+ for (const [key, timer] of this.publishCommandWatchdogs) {
198
+ if (!key.startsWith(`${threadId}:`)) {
199
+ continue;
200
+ }
201
+ clearTimeout(timer);
202
+ this.publishCommandWatchdogs.delete(key);
203
+ }
204
+ }
205
+ maybeEmitProgress(notification, run) {
206
+ try {
207
+ this.linearSync.maybeEmitProgress(notification, run);
208
+ }
209
+ catch (error) {
210
+ this.logger.warn({ runId: run.id, projectId: run.projectId, issueId: run.linearIssueId, method: notification.method, error: formatError(error) }, "Linear progress reporting failed");
211
+ }
212
+ }
213
+ syncCodexPlan(notification, run) {
214
+ let issue;
215
+ try {
216
+ issue = this.db.issues.getIssue(run.projectId, run.linearIssueId);
217
+ }
218
+ catch (error) {
219
+ this.logger.warn({ runId: run.id, projectId: run.projectId, issueId: run.linearIssueId, method: notification.method, error: formatError(error) }, "Linear plan sync lookup failed");
220
+ return;
221
+ }
222
+ if (!issue)
223
+ return;
224
+ try {
225
+ void this.linearSync.syncCodexPlan(issue, notification.params).catch((error) => {
226
+ this.logger.warn({ runId: run.id, issueKey: issue.issueKey, projectId: run.projectId, issueId: run.linearIssueId, method: notification.method, error: formatError(error) }, "Linear plan sync failed");
227
+ });
228
+ }
229
+ catch (error) {
230
+ this.logger.warn({ runId: run.id, issueKey: issue.issueKey, projectId: run.projectId, issueId: run.linearIssueId, method: notification.method, error: formatError(error) }, "Linear plan sync failed");
231
+ }
232
+ }
233
+ }
234
+ function formatError(error) {
235
+ return error instanceof Error ? error.message : String(error);
236
+ }
237
+ function publishWatchdogKey(threadId, itemId) {
238
+ return `${threadId}:${itemId}`;
239
+ }
240
+ function extractCommandText(command) {
241
+ if (typeof command === "string") {
242
+ return command;
243
+ }
244
+ if (Array.isArray(command)) {
245
+ return command.map((entry) => String(entry)).join(" ");
246
+ }
247
+ return undefined;
248
+ }
249
+ function isGitPushCommand(command) {
250
+ const normalized = command.replace(/\s+/g, " ").trim();
251
+ return /(?:^|[;&|({\s])git(?:\s+-C\s+\S+)?\s+push(?:\s|$)/.test(normalized);
252
+ }
253
+ function isTerminalItemUpdate(method, item) {
254
+ if (method !== "item/updated") {
255
+ return false;
256
+ }
257
+ const status = typeof item.status === "string" ? item.status.toLowerCase() : "";
258
+ return status === "completed" || status === "failed" || status === "cancelled" || status === "canceled";
140
259
  }
@@ -1,7 +1,6 @@
1
1
  import { summarizeCurrentThread } from "./run-reporting.js";
2
2
  import { buildReviewRoundStartedActivity, buildRunStartedActivity, } from "./linear-session-reporting.js";
3
3
  import { CompletionCheckService } from "./completion-check.js";
4
- import { PublicationRecapService } from "./publication-recap.js";
5
4
  import { WorktreeManager } from "./worktree-manager.js";
6
5
  import { MergedLinearCompletionReconciler } from "./merged-linear-completion-reconciler.js";
7
6
  import { QueueHealthMonitor } from "./queue-health-monitor.js";
@@ -62,7 +61,6 @@ export class RunOrchestrator {
62
61
  interruptedRunRecovery;
63
62
  runCompletionPolicy;
64
63
  completionCheck;
65
- publicationRecap;
66
64
  issueTriage;
67
65
  runNotificationHandler;
68
66
  runReconciler;
@@ -125,11 +123,10 @@ export class RunOrchestrator {
125
123
  this.activeSessionLeases = this.leaseService.activeSessionLeases;
126
124
  this.runCompletionPolicy = new RunCompletionPolicy(config, db, logger, this.leasePorts.withHeldLease);
127
125
  this.completionCheck = new CompletionCheckService(codex, logger);
128
- this.publicationRecap = new PublicationRecapService(codex, logger);
129
126
  this.issueTriage = new IssueTriageService(codex, logger);
130
- this.runFinalizer = new RunFinalizer(db, logger, this.linearSync, this.wakeDispatcher, this.leasePorts.withHeldLease, this.leasePorts.releaseLease, (lease, issue, runType, context, dedupeScope) => this.appendWakeEventWithLease(lease, issue, runType, context, dedupeScope), this.recoveryPorts.failRunAndClear, this.runCompletionPolicy, this.completionCheck, this.publicationRecap, feed);
127
+ this.runFinalizer = new RunFinalizer(db, logger, this.linearSync, this.wakeDispatcher, this.leasePorts.withHeldLease, this.leasePorts.releaseLease, (lease, issue, runType, context, dedupeScope) => this.appendWakeEventWithLease(lease, issue, runType, context, dedupeScope), this.recoveryPorts.failRunAndClear, this.runCompletionPolicy, this.completionCheck, feed);
131
128
  this.runLauncher = new RunLauncher(config, db, codex, logger, this.worktreeManager);
132
- this.runNotificationHandler = new RunNotificationHandler(config, db, logger, this.linearSync, this.runFinalizer, this.threadPorts.readThreadWithRetry, this.leasePorts.withHeldLease, this.leasePorts.heartbeatLease, this.leasePorts.releaseLease, feed);
129
+ this.runNotificationHandler = new RunNotificationHandler(config, db, logger, this.linearSync, this.runFinalizer, this.threadPorts.readThreadWithRetry, this.leasePorts.withHeldLease, this.leasePorts.heartbeatLease, this.leasePorts.releaseLease, feed, { interruptTurn: (options) => codex.interruptTurn(options) });
133
130
  this.runRecovery = new RunRecoveryService(db, logger, this.linearSync, this.leasePorts.withHeldLease, this.leasePorts.getHeldLease, (lease, issue, runType, context, dedupeScope) => this.appendWakeEventWithLease(lease, issue, runType, context, dedupeScope), this.leasePorts.releaseLease, (projectId, issueId) => this.enqueueIssue(projectId, issueId), feed);
134
131
  this.interruptedRunRecovery = new InterruptedRunRecovery(db, logger, this.linearSync, this.leasePorts.withHeldLease, this.leasePorts.releaseLease, this.recoveryPorts.failRunAndClear, this.recoveryPorts.restoreIdleWorktree, this.runCompletionPolicy, (projectId, issueId) => this.enqueueIssue(projectId, issueId), feed);
135
132
  this.runReconciler = new RunReconciler(db, logger, linearProvider, this.linearSync, this.interruptedRunRecovery, this.runFinalizer, this.leasePorts.withHeldLease, this.leasePorts.releaseLease, this.threadPorts.readThreadWithRetry, this.recoveryPorts.recoverOrEscalate, (projectId) => this.config.projects.find((project) => project.id === projectId)?.github?.repoFullName, feed);
@@ -0,0 +1,89 @@
1
+ import { sanitizeOperatorFacingText } from "./presentation-text.js";
2
+ export function buildRunOutcomeSummary(params) {
3
+ switch (params.runType) {
4
+ case "implementation":
5
+ return summarizeImplementation(params.facts.postRunState);
6
+ case "review_fix":
7
+ return summarizeReviewFix(params.facts);
8
+ case "ci_repair":
9
+ return summarizeCiRepair(params.facts);
10
+ case "queue_repair":
11
+ return summarizeQueueRepair(params.facts);
12
+ case "branch_upkeep":
13
+ return "Branch updated.";
14
+ }
15
+ }
16
+ function summarizeImplementation(postRunState) {
17
+ switch (postRunState) {
18
+ case "awaiting_queue":
19
+ return "Ready for merge.";
20
+ case "done":
21
+ return "Completed.";
22
+ case "pr_open":
23
+ default:
24
+ return "Ready for review.";
25
+ }
26
+ }
27
+ function summarizeReviewFix(facts) {
28
+ const concern = summarizeKnownConcern(facts.reviewSummary);
29
+ return concern ? rewriteConcernAsOutcome(concern) : "Review feedback addressed.";
30
+ }
31
+ function summarizeCiRepair(facts) {
32
+ const check = sanitizeOperatorFacingText(facts.failingCheckName)?.replace(/\s+/g, " ").trim();
33
+ if (check) {
34
+ return `${check} fixed.`;
35
+ }
36
+ const concern = summarizeKnownConcern(facts.failureSummary);
37
+ if (concern) {
38
+ return rewriteConcernAsOutcome(concern);
39
+ }
40
+ return "CI fixed.";
41
+ }
42
+ function summarizeQueueRepair(facts) {
43
+ const concern = summarizeKnownConcern(facts.queueIncidentSummary);
44
+ if (concern) {
45
+ return rewriteConcernAsOutcome(concern);
46
+ }
47
+ return "Merge queue issue resolved.";
48
+ }
49
+ function summarizeKnownConcern(value) {
50
+ const sanitized = sanitizeOperatorFacingText(value)?.replace(/\s+/g, " ").trim();
51
+ if (!sanitized) {
52
+ return undefined;
53
+ }
54
+ const stripped = sanitized
55
+ .replace(/^please\s+/i, "")
56
+ .replace(/[.]+$/, "")
57
+ .trim();
58
+ if (!stripped) {
59
+ return undefined;
60
+ }
61
+ return stripped.length <= 80 ? stripped : `${stripped.slice(0, 80).trimEnd()}...`;
62
+ }
63
+ function rewriteConcernAsOutcome(concern) {
64
+ const normalized = concern.trim().replace(/[.]+$/, "");
65
+ const tightened = normalized.match(/^tighten\s+(.+)$/i);
66
+ if (tightened?.[1]) {
67
+ return `${capitalizeFirst(stripLeadingArticle(tightened[1]))} tightened.`;
68
+ }
69
+ const fixed = normalized.match(/^(fix|repair)\s+(.+)$/i);
70
+ if (fixed?.[2]) {
71
+ return `${capitalizeFirst(stripLeadingArticle(fixed[2]))} fixed.`;
72
+ }
73
+ const resolved = normalized.match(/^resolve\s+(.+)$/i);
74
+ if (resolved?.[1]) {
75
+ return `${capitalizeFirst(stripLeadingArticle(resolved[1]))} resolved.`;
76
+ }
77
+ const updated = normalized.match(/^update\s+(.+)$/i);
78
+ if (updated?.[1]) {
79
+ return `${capitalizeFirst(stripLeadingArticle(updated[1]))} updated.`;
80
+ }
81
+ return `${capitalizeFirst(normalized)}.`;
82
+ }
83
+ function capitalizeFirst(value) {
84
+ const trimmed = value.trim();
85
+ return trimmed ? `${trimmed.slice(0, 1).toUpperCase()}${trimmed.slice(1)}` : trimmed;
86
+ }
87
+ function stripLeadingArticle(value) {
88
+ return value.trim().replace(/^the\s+/i, "");
89
+ }
@@ -65,7 +65,7 @@ export class ServiceRuntime {
65
65
  }
66
66
  getReadiness() {
67
67
  return {
68
- ready: this.ready && this.codex.isStarted() && this.linearConnected,
68
+ ready: this.ready && this.codex.isStarted() && this.linearConnected && this.githubAppAuthHealthy,
69
69
  codexStarted: this.codex.isStarted(),
70
70
  linearConnected: this.linearConnected,
71
71
  githubAppAuthHealthy: this.githubAppAuthHealthy,
package/dist/service.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { resolveGitHubAppCredentials, createGitHubAppTokenManager, getGitHubAppPaths, } from "./github-app-token.js";
2
- import { applyGitHubCliAuthEnv, resolveGhBin } from "./github-cli-auth.js";
2
+ import { applyGitHubCliAuthEnv, resolveGhBin, verifyGitHubCliAuthEnv } from "./github-cli-auth.js";
3
3
  import { remediateLeakedBotAuth } from "./github-auth-remediation.js";
4
4
  import { GitHubWebhookHandler } from "./github-webhook-handler.js";
5
5
  import { IssueQueryService } from "./issue-query-service.js";
@@ -149,8 +149,18 @@ export class PatchRelayService {
149
149
  this.runtime.setGithubAppAuthHealthy(ghAuthStatus.healthy, ghAuthStatus.lastRefreshError ?? undefined);
150
150
  if (!ghAuthStatus.healthy) {
151
151
  this.logger.error({ ghAuthStatus }, "GitHub App auth is NOT healthy at startup — git/gh operations will fail until a token is minted");
152
+ throw new Error(`GitHub App auth is not healthy at startup: ${ghAuthStatus.lastRefreshError ?? "no fresh installation token"}`);
152
153
  }
153
154
  else {
155
+ try {
156
+ await verifyGitHubCliAuthEnv(process.env);
157
+ }
158
+ catch (error) {
159
+ const msg = error instanceof Error ? error.message : String(error);
160
+ this.runtime.setGithubAppAuthHealthy(false, msg);
161
+ this.logger.error({ error: msg, ghConfigDir }, "GitHub App auth smoke test failed — service will not accept work");
162
+ throw error;
163
+ }
154
164
  this.logger.info({ installationId: ghAuthStatus.installationId, expiresAt: ghAuthStatus.expiresAt }, "GitHub App auth ready — gh + git authenticate as the bot");
155
165
  }
156
166
  // Clean up credentials older versions persisted into managed repo configs.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "patchrelay",
3
- "version": "0.74.0",
3
+ "version": "0.74.2",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "repository": {
@@ -1,113 +0,0 @@
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 PUBLICATION_RECAP_TIMEOUT_MS = 45_000;
6
- const PUBLICATION_RECAP_POLL_MS = 1_000;
7
- export class PublicationRecapService {
8
- codex;
9
- logger;
10
- constructor(codex, logger) {
11
- this.codex = codex;
12
- this.logger = logger;
13
- }
14
- async run(params) {
15
- const threadId = params.run.threadId;
16
- if (!threadId) {
17
- throw new Error("Publication recap could not run because the main thread is missing.");
18
- }
19
- const fork = await this.codex.forkThreadForPublicationRecap(threadId);
20
- const turn = await this.codex.startTurn({
21
- threadId: fork.id,
22
- ...(fork.cwd ? { cwd: fork.cwd } : {}),
23
- input: buildPublicationRecapPrompt(params),
24
- });
25
- const completedThread = await this.waitForTurn(fork.id, turn.turnId);
26
- const completedTurn = getThreadTurns(completedThread).find((entry) => entry.id === turn.turnId);
27
- const latestMessage = completedTurn?.items
28
- .filter((item) => item.type === "agentMessage")
29
- .at(-1)?.text;
30
- const parsed = parsePublicationRecapResult(latestMessage);
31
- if (!parsed) {
32
- this.logger.warn({ runId: params.run.id, issueKey: params.issue.issueKey, threadId: fork.id, turnId: turn.turnId }, "Publication recap returned invalid JSON");
33
- throw new Error("Publication recap returned an invalid result.");
34
- }
35
- return {
36
- ...parsed,
37
- threadId: fork.id,
38
- turnId: turn.turnId,
39
- };
40
- }
41
- async waitForTurn(threadId, turnId) {
42
- const deadline = Date.now() + PUBLICATION_RECAP_TIMEOUT_MS;
43
- while (Date.now() < deadline) {
44
- const thread = await this.codex.readThread(threadId, true);
45
- const turn = getThreadTurns(thread).find((entry) => entry.id === turnId);
46
- if (turn?.status === "completed") {
47
- return thread;
48
- }
49
- if (turn?.status === "failed" || turn?.status === "interrupted") {
50
- throw new Error(`Publication recap turn ${turnId} ended with status ${turn.status}`);
51
- }
52
- await new Promise((resolve) => setTimeout(resolve, PUBLICATION_RECAP_POLL_MS));
53
- }
54
- throw new Error(`Publication recap timed out after ${PUBLICATION_RECAP_TIMEOUT_MS}ms`);
55
- }
56
- }
57
- function buildPublicationRecapPrompt(params) {
58
- const latestSummary = params.facts?.latestAssistantSummary
59
- ? sanitizeOperatorFacingText(params.facts.latestAssistantSummary)
60
- : extractLatestAssistantSummary(params.run);
61
- return [
62
- "PatchRelay publication recap",
63
- "",
64
- "The main task run succeeded.",
65
- "This is a read-only follow-up used only to produce one concise Linear-visible recap for that successful run.",
66
- "Do not run commands, call tools, edit files, or inspect the repository.",
67
- "Use only the prior thread context and the facts in this prompt.",
68
- "Return exactly one JSON object and no extra prose.",
69
- "",
70
- "Schema:",
71
- '{',
72
- ' "summary": "one short sentence, max 30 words"',
73
- '}',
74
- "",
75
- "Writing rules:",
76
- "- Focus on what this session chunk achieved.",
77
- "- Mention the wake reason only when it makes the change clearer, for example requested changes, a failing CI check, or a merge queue incident.",
78
- "- Do not list touched files, test commands, branch names, commit SHAs, or internal process details.",
79
- "- Do not say that you reviewed files or ran checks unless that is the only meaningful achievement.",
80
- "- For implementation runs, summarize the delivered user-facing or system-facing change.",
81
- "- For review-fix runs, summarize the concern that was addressed and imply that a newer head was published.",
82
- "- For CI repair runs, summarize the failure that was fixed if known.",
83
- "- For queue repair runs, summarize the queue or merge issue that was resolved if known.",
84
- "",
85
- "Facts:",
86
- `- Issue: ${params.issue.issueKey ?? params.issue.linearIssueId}`,
87
- ...(params.issue.title ? [`- Title: ${params.issue.title}`] : []),
88
- `- Run type: ${params.run.runType}`,
89
- ...(params.facts?.postRunState ? [`- Post-run state: ${params.facts.postRunState}`] : []),
90
- ...(params.facts?.wakeReason ? [`- Wake reason: ${params.facts.wakeReason}`] : []),
91
- ...(params.facts?.prNumber !== undefined ? [`- PR number: ${params.facts.prNumber}`] : []),
92
- ...(params.facts?.reviewerName ? [`- Reviewer: ${params.facts.reviewerName}`] : []),
93
- ...(params.facts?.reviewSummary ? [`- Review summary: ${sanitizeOperatorFacingText(params.facts.reviewSummary)}`] : []),
94
- ...(params.facts?.failingCheckName ? [`- Failing check: ${params.facts.failingCheckName}`] : []),
95
- ...(params.facts?.failureSummary ? [`- Failure summary: ${sanitizeOperatorFacingText(params.facts.failureSummary)}`] : []),
96
- ...(params.facts?.queueIncidentSummary ? [`- Queue incident: ${sanitizeOperatorFacingText(params.facts.queueIncidentSummary)}`] : []),
97
- ...(latestSummary ? [`- Latest assistant summary: ${latestSummary}`] : []),
98
- ...(params.run.failureReason ? [`- Failure reason: ${sanitizeOperatorFacingText(params.run.failureReason)}`] : []),
99
- ...(params.issue.description ? ["", "Issue description:", params.issue.description] : []),
100
- ].join("\n");
101
- }
102
- function parsePublicationRecapResult(text) {
103
- const raw = sanitizeOperatorFacingText(text);
104
- if (!raw)
105
- return undefined;
106
- const candidate = safeJsonParse(raw) ?? safeJsonParse(extractFirstJsonObject(raw) ?? "");
107
- if (!candidate)
108
- return undefined;
109
- const summary = typeof candidate.summary === "string" ? sanitizeOperatorFacingText(candidate.summary) : undefined;
110
- if (!summary)
111
- return undefined;
112
- return { summary };
113
- }