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.
- package/dist/build-info.json +3 -3
- package/dist/cli/data.js +6 -0
- package/dist/codex-app-server.js +0 -16
- package/dist/db/schema-guard.js +17 -0
- package/dist/db.js +7 -0
- package/dist/github-cli-auth.js +43 -0
- package/dist/issue-session-events.js +4 -0
- package/dist/linear-progress-facts.js +13 -0
- package/dist/linear-session-reporting.js +14 -25
- package/dist/preflight.js +2 -0
- package/dist/prompting/patchrelay.js +16 -0
- package/dist/run-finalizer.js +94 -57
- package/dist/run-launcher.js +20 -1
- package/dist/run-notification-handler.js +125 -6
- package/dist/run-orchestrator.js +2 -5
- package/dist/run-outcome-summary.js +89 -0
- package/dist/service-runtime.js +1 -1
- package/dist/service.js +11 -1
- package/package.json +1 -1
- package/dist/publication-recap.js +0 -113
package/dist/build-info.json
CHANGED
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() {
|
package/dist/codex-app-server.js
CHANGED
|
@@ -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)();
|
package/dist/github-cli-auth.js
CHANGED
|
@@ -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}${
|
|
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 || "
|
|
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
|
|
148
|
-
lines.push("", "Addressed:",
|
|
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}`));
|
package/dist/run-finalizer.js
CHANGED
|
@@ -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,
|
|
19
|
+
function buildRunSummaryJson(report, outcomeSummary) {
|
|
19
20
|
return JSON.stringify({
|
|
20
21
|
latestAssistantMessage: report.assistantMessages.at(-1) ?? null,
|
|
21
|
-
|
|
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,
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
-
|
|
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
|
|
284
|
-
|
|
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
|
|
306
|
-
if (
|
|
307
|
-
this.
|
|
308
|
-
this.syncFailureOutcome({
|
|
346
|
+
const dirtyRepairWorktree = this.inspectDirtyRepairWorktree(run, freshIssue);
|
|
347
|
+
if (dirtyRepairWorktree) {
|
|
348
|
+
this.continueDirtyRepairWorktree({
|
|
309
349
|
run,
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
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
|
|
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
|
-
|
|
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:
|
|
496
|
+
detail: outcomeSummary,
|
|
457
497
|
});
|
|
458
498
|
const updatedIssue = this.db.issues.getIssue(run.projectId, run.linearIssueId) ?? refreshedIssue;
|
|
459
|
-
const completionSummary =
|
|
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
|
});
|
package/dist/run-launcher.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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.
|
|
67
|
+
this.maybeEmitProgress(notification, run);
|
|
63
68
|
if (notification.method === "turn/plan/updated") {
|
|
64
|
-
|
|
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
|
}
|
package/dist/run-orchestrator.js
CHANGED
|
@@ -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,
|
|
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
|
+
}
|
package/dist/service-runtime.js
CHANGED
|
@@ -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,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
|
-
}
|