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