patchrelay 0.68.7 → 0.69.1
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/codex-app-server.js +18 -0
- package/dist/codex-conversation-adapter.js +270 -0
- package/dist/followup-intent.js +167 -54
- package/dist/github-worktree-auth.js +7 -5
- package/dist/issue-session-events.js +12 -0
- package/dist/linear-progress-reporter.js +81 -2
- package/dist/linear-session-reporting.js +74 -14
- package/dist/prompting/patchrelay.js +26 -3
- package/dist/reactive-run-policy.js +12 -25
- package/dist/run-finalizer.js +23 -0
- package/dist/run-orchestrator.js +11 -2
- package/dist/webhook-handler.js +8 -4
- package/dist/webhooks/agent-session-handler.js +18 -93
- package/dist/webhooks/comment-policy.js +19 -1
- package/dist/webhooks/comment-wake-handler.js +25 -161
- package/infra/patchrelay.service +6 -6
- package/package.json +1 -1
package/dist/build-info.json
CHANGED
package/dist/codex-app-server.js
CHANGED
|
@@ -24,6 +24,13 @@ const ISSUE_TRIAGE_DEVELOPER_INSTRUCTIONS = [
|
|
|
24
24
|
"Use only the facts in the current prompt.",
|
|
25
25
|
"Return only the requested JSON object.",
|
|
26
26
|
].join("\n");
|
|
27
|
+
const FOLLOWUP_INTENT_DEVELOPER_INSTRUCTIONS = [
|
|
28
|
+
"You are PatchRelay's follow-up intent classifier.",
|
|
29
|
+
"This is a read-only routing step used only to classify one human Linear follow-up.",
|
|
30
|
+
"Do not run commands, do not call tools, do not edit files, and do not inspect or modify the repository.",
|
|
31
|
+
"Use only the text and state facts in the current prompt.",
|
|
32
|
+
"Return only the requested JSON object.",
|
|
33
|
+
].join("\n");
|
|
27
34
|
export function resolveCodexAppServerLaunch(config) {
|
|
28
35
|
if (!config.sourceBashrc) {
|
|
29
36
|
return {
|
|
@@ -121,6 +128,17 @@ export class CodexAppServerClient extends EventEmitter {
|
|
|
121
128
|
developerInstructions: ISSUE_TRIAGE_DEVELOPER_INSTRUCTIONS,
|
|
122
129
|
});
|
|
123
130
|
}
|
|
131
|
+
async startThreadForFollowupIntent() {
|
|
132
|
+
return await this.startThreadWithOverrides({ cwd: tmpdir() }, {
|
|
133
|
+
approvalPolicy: "never",
|
|
134
|
+
sandboxMode: "read-only",
|
|
135
|
+
model: this.config.triageModel ?? "gpt-5.4-mini",
|
|
136
|
+
modelProvider: this.config.triageModelProvider ?? this.config.modelProvider ?? null,
|
|
137
|
+
reasoningEffort: "low",
|
|
138
|
+
baseInstructions: null,
|
|
139
|
+
developerInstructions: FOLLOWUP_INTENT_DEVELOPER_INSTRUCTIONS,
|
|
140
|
+
});
|
|
141
|
+
}
|
|
124
142
|
async startThreadWithOverrides(options, overrides) {
|
|
125
143
|
const params = {
|
|
126
144
|
cwd: options.cwd,
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
import { buildFollowupStatusActivity, buildNonActionableFollowupActivity, buildPromptDeliveryFailedActivity, buildPromptDeliveredThought, } from "./linear-session-reporting.js";
|
|
2
|
+
import { deriveIssueStatusNote } from "./status-note.js";
|
|
3
|
+
import { extractLatestAssistantSummary } from "./issue-session-events.js";
|
|
4
|
+
export class CodexConversationAdapter {
|
|
5
|
+
db;
|
|
6
|
+
codex;
|
|
7
|
+
wakeDispatcher;
|
|
8
|
+
logger;
|
|
9
|
+
feed;
|
|
10
|
+
followupClassifier;
|
|
11
|
+
constructor(db, codex, wakeDispatcher, logger, feed, followupClassifier) {
|
|
12
|
+
this.db = db;
|
|
13
|
+
this.codex = codex;
|
|
14
|
+
this.wakeDispatcher = wakeDispatcher;
|
|
15
|
+
this.logger = logger;
|
|
16
|
+
this.feed = feed;
|
|
17
|
+
this.followupClassifier = followupClassifier;
|
|
18
|
+
}
|
|
19
|
+
async deliverAgentInput(params) {
|
|
20
|
+
const body = params.body.trim();
|
|
21
|
+
if (!body)
|
|
22
|
+
return { status: "ignored" };
|
|
23
|
+
const issue = this.db.issues.getIssue(params.issue.projectId, params.issue.linearIssueId) ?? params.issue;
|
|
24
|
+
const activeRun = issue.activeRunId ? this.db.runs.getRunById(issue.activeRunId) : undefined;
|
|
25
|
+
const intent = await this.classify(body, params.source, issue, activeRun, params.directReply === true);
|
|
26
|
+
if (intent?.intent === "status" && !params.directReply) {
|
|
27
|
+
await params.emitActivity?.(this.buildStatusActivity(issue, activeRun, params.peekPendingSessionWakeRunType, activeRun ? "thought" : "response"), activeRun ? { ephemeral: true } : undefined);
|
|
28
|
+
return { status: "answered", ...(activeRun?.runType ? { activeRunType: activeRun.runType } : {}) };
|
|
29
|
+
}
|
|
30
|
+
if (intent?.intent === "stop") {
|
|
31
|
+
if (activeRun) {
|
|
32
|
+
await this.stopActiveRun(issue, activeRun, body, params.source);
|
|
33
|
+
}
|
|
34
|
+
else {
|
|
35
|
+
this.wakeDispatcher.recordEventAndDispatch(issue.projectId, issue.linearIssueId, {
|
|
36
|
+
eventType: "stop_requested",
|
|
37
|
+
eventJson: JSON.stringify({ body, source: params.source, ...(params.author ? { author: params.author } : {}) }),
|
|
38
|
+
});
|
|
39
|
+
this.db.issueSessions.clearPendingIssueSessionEventsRespectingActiveLease(issue.projectId, issue.linearIssueId);
|
|
40
|
+
}
|
|
41
|
+
return { status: "stopped", ...(activeRun?.runType ? { activeRunType: activeRun.runType } : {}) };
|
|
42
|
+
}
|
|
43
|
+
if (!issue.delegatedToPatchRelay) {
|
|
44
|
+
await params.emitActivity?.({ type: "thought", body: "PatchRelay is paused because the issue is undelegated." }, { ephemeral: true });
|
|
45
|
+
return { status: "ignored" };
|
|
46
|
+
}
|
|
47
|
+
if (activeRun) {
|
|
48
|
+
return await this.steerActiveRun({
|
|
49
|
+
issue,
|
|
50
|
+
activeRun,
|
|
51
|
+
body,
|
|
52
|
+
source: params.source,
|
|
53
|
+
author: params.author,
|
|
54
|
+
emitActivity: params.emitActivity,
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
return await this.queueIdleInput({
|
|
58
|
+
project: params.project,
|
|
59
|
+
issue,
|
|
60
|
+
body,
|
|
61
|
+
source: params.source,
|
|
62
|
+
author: params.author,
|
|
63
|
+
directReply: params.directReply === true,
|
|
64
|
+
emitActivity: params.emitActivity,
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
async classify(body, source, issue, activeRun, directReply) {
|
|
68
|
+
if (!this.followupClassifier)
|
|
69
|
+
return undefined;
|
|
70
|
+
return await this.followupClassifier.classify(body, {
|
|
71
|
+
source: source === "agent_session_prompt" ? "agentPrompted" : "comment",
|
|
72
|
+
...(activeRun?.runType ? { activeRunType: activeRun.runType } : {}),
|
|
73
|
+
factoryState: issue.factoryState,
|
|
74
|
+
directReply,
|
|
75
|
+
delegatedToPatchRelay: issue.delegatedToPatchRelay,
|
|
76
|
+
prReviewState: issue.prReviewState,
|
|
77
|
+
explicitWakeIntent: true,
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
async steerActiveRun(params) {
|
|
81
|
+
const { issue, activeRun, body, source } = params;
|
|
82
|
+
if (!activeRun.threadId || !activeRun.turnId) {
|
|
83
|
+
const queuedRunType = this.queueFollowUpEvent(issue, body, source, params.author, false);
|
|
84
|
+
return { status: "queued", ...(queuedRunType ? { queuedRunType } : {}) };
|
|
85
|
+
}
|
|
86
|
+
const input = [
|
|
87
|
+
source === "agent_session_prompt"
|
|
88
|
+
? "New Linear agent-session prompt received while you are working."
|
|
89
|
+
: "New explicitly addressed Linear issue comment received while you are working.",
|
|
90
|
+
params.author ? `Author: ${params.author}` : undefined,
|
|
91
|
+
"",
|
|
92
|
+
"Checkpoint contract: incorporate this instruction before your next meaningful side effect when possible. If you are already inside a non-interruptible command, finish that command, then re-plan with this input before continuing.",
|
|
93
|
+
"",
|
|
94
|
+
body,
|
|
95
|
+
].filter(Boolean).join("\n");
|
|
96
|
+
try {
|
|
97
|
+
await this.codex.steerTurn({ threadId: activeRun.threadId, turnId: activeRun.turnId, input });
|
|
98
|
+
this.recordPromptDelivery({
|
|
99
|
+
issue,
|
|
100
|
+
source,
|
|
101
|
+
runId: activeRun.id,
|
|
102
|
+
runType: activeRun.runType,
|
|
103
|
+
status: "delivered",
|
|
104
|
+
body,
|
|
105
|
+
primitive: "turn/steer",
|
|
106
|
+
threadId: activeRun.threadId,
|
|
107
|
+
turnId: activeRun.turnId,
|
|
108
|
+
});
|
|
109
|
+
this.feed?.publish({
|
|
110
|
+
level: "info",
|
|
111
|
+
kind: source === "agent_session_prompt" ? "agent" : "comment",
|
|
112
|
+
projectId: issue.projectId,
|
|
113
|
+
issueKey: issue.issueKey,
|
|
114
|
+
stage: activeRun.runType,
|
|
115
|
+
status: "delivered",
|
|
116
|
+
summary: `Delivered agent input to active ${activeRun.runType} workflow`,
|
|
117
|
+
});
|
|
118
|
+
await params.emitActivity?.(buildPromptDeliveredThought(activeRun.runType), { ephemeral: true });
|
|
119
|
+
return { status: "steered", activeRunType: activeRun.runType };
|
|
120
|
+
}
|
|
121
|
+
catch (error) {
|
|
122
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
123
|
+
this.logger.warn({ issueKey: issue.issueKey, error: message }, "Failed to deliver agent input to active Codex turn");
|
|
124
|
+
this.recordPromptDelivery({
|
|
125
|
+
issue,
|
|
126
|
+
source,
|
|
127
|
+
runId: activeRun.id,
|
|
128
|
+
runType: activeRun.runType,
|
|
129
|
+
status: "delivery_failed",
|
|
130
|
+
body,
|
|
131
|
+
primitive: "turn/steer",
|
|
132
|
+
threadId: activeRun.threadId,
|
|
133
|
+
turnId: activeRun.turnId,
|
|
134
|
+
error: message,
|
|
135
|
+
});
|
|
136
|
+
const queuedRunType = this.queueFollowUpEvent(issue, body, source, params.author, false);
|
|
137
|
+
this.feed?.publish({
|
|
138
|
+
level: "warn",
|
|
139
|
+
kind: source === "agent_session_prompt" ? "agent" : "comment",
|
|
140
|
+
projectId: issue.projectId,
|
|
141
|
+
issueKey: issue.issueKey,
|
|
142
|
+
stage: activeRun.runType,
|
|
143
|
+
status: "delivery_failed",
|
|
144
|
+
summary: `Could not deliver agent input to active ${activeRun.runType} workflow`,
|
|
145
|
+
});
|
|
146
|
+
await params.emitActivity?.(buildPromptDeliveryFailedActivity(activeRun.runType, message));
|
|
147
|
+
return { status: "delivery_failed", activeRunType: activeRun.runType, ...(queuedRunType ? { queuedRunType } : {}) };
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
async queueIdleInput(params) {
|
|
151
|
+
const originalIssue = params.issue;
|
|
152
|
+
let issue = originalIssue;
|
|
153
|
+
const replacementPrRequired = originalIssue.factoryState === "done" && originalIssue.prNumber !== undefined;
|
|
154
|
+
if (replacementPrRequired) {
|
|
155
|
+
issue = this.prepareReplacementWork(params.project, originalIssue);
|
|
156
|
+
}
|
|
157
|
+
const queuedRunType = this.queueFollowUpEvent(issue, params.body, params.source, params.author, params.directReply, replacementPrRequired ? originalIssue : undefined);
|
|
158
|
+
if (queuedRunType) {
|
|
159
|
+
await params.emitActivity?.(replacementPrRequired
|
|
160
|
+
? { type: "action", action: "Reopening", parameter: `completed PR #${originalIssue.prNumber} for replacement work` }
|
|
161
|
+
: buildPromptDeliveredThought(queuedRunType), { ephemeral: true });
|
|
162
|
+
}
|
|
163
|
+
else {
|
|
164
|
+
await params.emitActivity?.(buildNonActionableFollowupActivity("unknown_needs_ack"));
|
|
165
|
+
}
|
|
166
|
+
return { status: queuedRunType ? "queued" : "ignored", ...(queuedRunType ? { queuedRunType } : {}) };
|
|
167
|
+
}
|
|
168
|
+
queueFollowUpEvent(issue, body, source, author, directReply, previousIssue) {
|
|
169
|
+
return this.wakeDispatcher.recordEventAndDispatch(issue.projectId, issue.linearIssueId, {
|
|
170
|
+
eventType: directReply ? "direct_reply" : source === "agent_session_prompt" ? "followup_prompt" : "followup_comment",
|
|
171
|
+
eventJson: JSON.stringify({
|
|
172
|
+
...(source === "agent_session_prompt" ? { text: body } : { body }),
|
|
173
|
+
source: source === "agent_session_prompt" ? "linear_agent_prompt" : "linear_comment",
|
|
174
|
+
...(author ? { author } : {}),
|
|
175
|
+
...(previousIssue?.prNumber !== undefined
|
|
176
|
+
? {
|
|
177
|
+
replacementPrRequired: true,
|
|
178
|
+
previousPrNumber: previousIssue.prNumber,
|
|
179
|
+
...(previousIssue.prUrl ? { previousPrUrl: previousIssue.prUrl } : {}),
|
|
180
|
+
...(previousIssue.prState ? { previousPrState: previousIssue.prState } : {}),
|
|
181
|
+
...(previousIssue.prHeadSha ? { previousPrHeadSha: previousIssue.prHeadSha } : {}),
|
|
182
|
+
}
|
|
183
|
+
: {}),
|
|
184
|
+
}),
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
prepareReplacementWork(project, issue) {
|
|
188
|
+
const issueRef = (issue.issueKey ?? issue.linearIssueId).replace(/[^a-zA-Z0-9._-]+/g, "-");
|
|
189
|
+
const suffix = Date.now().toString(36);
|
|
190
|
+
return this.db.issueSessions.upsertIssueRespectingActiveLease(issue.projectId, issue.linearIssueId, {
|
|
191
|
+
projectId: issue.projectId,
|
|
192
|
+
linearIssueId: issue.linearIssueId,
|
|
193
|
+
factoryState: "delegated",
|
|
194
|
+
branchName: `${project.branchPrefix}/${issueRef}-replacement-${suffix}`,
|
|
195
|
+
prNumber: null,
|
|
196
|
+
prUrl: null,
|
|
197
|
+
prState: null,
|
|
198
|
+
prIsDraft: null,
|
|
199
|
+
prHeadSha: null,
|
|
200
|
+
prAuthorLogin: null,
|
|
201
|
+
prReviewState: null,
|
|
202
|
+
prCheckStatus: null,
|
|
203
|
+
lastBlockingReviewHeadSha: null,
|
|
204
|
+
}) ?? issue;
|
|
205
|
+
}
|
|
206
|
+
async stopActiveRun(issue, run, body, source) {
|
|
207
|
+
if (run.threadId && run.turnId) {
|
|
208
|
+
try {
|
|
209
|
+
await this.codex.steerTurn({
|
|
210
|
+
threadId: run.threadId,
|
|
211
|
+
turnId: run.turnId,
|
|
212
|
+
input: "STOP: The user has requested you stop working immediately. Do not make further changes. Wrap up and exit.",
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
catch (error) {
|
|
216
|
+
this.logger.warn({ issueKey: issue.issueKey, error: error instanceof Error ? error.message : String(error) }, "Failed to steer Codex turn for stop request");
|
|
217
|
+
}
|
|
218
|
+
this.db.runs.finishRun(run.id, { status: "released", threadId: run.threadId, turnId: run.turnId });
|
|
219
|
+
}
|
|
220
|
+
this.db.issueSessions.upsertIssueRespectingActiveLease(issue.projectId, issue.linearIssueId, {
|
|
221
|
+
projectId: issue.projectId,
|
|
222
|
+
linearIssueId: issue.linearIssueId,
|
|
223
|
+
activeRunId: null,
|
|
224
|
+
factoryState: "awaiting_input",
|
|
225
|
+
});
|
|
226
|
+
this.wakeDispatcher.recordEventAndDispatch(issue.projectId, issue.linearIssueId, {
|
|
227
|
+
eventType: "stop_requested",
|
|
228
|
+
eventJson: JSON.stringify({ body, source }),
|
|
229
|
+
});
|
|
230
|
+
this.db.issueSessions.clearPendingIssueSessionEventsRespectingActiveLease(issue.projectId, issue.linearIssueId);
|
|
231
|
+
this.db.issueSessions.releaseIssueSessionLeaseRespectingActiveLease(issue.projectId, issue.linearIssueId);
|
|
232
|
+
}
|
|
233
|
+
buildStatusActivity(issue, activeRun, peekPendingSessionWakeRunType, activityType) {
|
|
234
|
+
const latestRun = activeRun ?? this.db.runs.getLatestRunForIssue(issue.projectId, issue.linearIssueId);
|
|
235
|
+
const latestEvent = this.db.issueSessions.listIssueSessionEvents(issue.projectId, issue.linearIssueId).at(-1);
|
|
236
|
+
const statusNote = deriveIssueStatusNote({
|
|
237
|
+
issue,
|
|
238
|
+
latestRun,
|
|
239
|
+
latestEvent,
|
|
240
|
+
sessionSummary: extractLatestAssistantSummary(latestRun),
|
|
241
|
+
waitingReason: undefined,
|
|
242
|
+
});
|
|
243
|
+
const pendingRunType = peekPendingSessionWakeRunType?.(issue.projectId, issue.linearIssueId);
|
|
244
|
+
return buildFollowupStatusActivity({
|
|
245
|
+
issue,
|
|
246
|
+
...(statusNote ? { statusNote } : {}),
|
|
247
|
+
...(activeRun?.runType ? { activeRunType: activeRun.runType } : {}),
|
|
248
|
+
...(pendingRunType ? { pendingRunType } : {}),
|
|
249
|
+
activityType,
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
recordPromptDelivery(params) {
|
|
253
|
+
this.db.issueSessions.appendIssueSessionEventRespectingActiveLease(params.issue.projectId, params.issue.linearIssueId, {
|
|
254
|
+
projectId: params.issue.projectId,
|
|
255
|
+
linearIssueId: params.issue.linearIssueId,
|
|
256
|
+
eventType: "prompt_delivered",
|
|
257
|
+
eventJson: JSON.stringify({
|
|
258
|
+
source: params.source === "agent_session_prompt" ? "linear_agent_prompt" : "linear_comment",
|
|
259
|
+
runId: params.runId,
|
|
260
|
+
runType: params.runType,
|
|
261
|
+
status: params.status,
|
|
262
|
+
body: params.body,
|
|
263
|
+
primitive: params.primitive,
|
|
264
|
+
...(params.threadId ? { threadId: params.threadId } : {}),
|
|
265
|
+
...(params.turnId ? { turnId: params.turnId } : {}),
|
|
266
|
+
...(params.error ? { error: params.error } : {}),
|
|
267
|
+
}),
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
}
|
package/dist/followup-intent.js
CHANGED
|
@@ -1,56 +1,169 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
const
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
const
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
1
|
+
import { getThreadTurns } from "./codex-thread-utils.js";
|
|
2
|
+
import { isThreadMaterializingError } from "./codex-thread-errors.js";
|
|
3
|
+
import { extractFirstJsonObject, safeJsonParse } from "./utils.js";
|
|
4
|
+
const FOLLOWUP_INTENT_TIMEOUT_MS = 45_000;
|
|
5
|
+
const FOLLOWUP_INTENT_POLL_MS = 1_000;
|
|
6
|
+
export const FOLLOWUP_INTENT_MIN_CONFIDENCE = 0.55;
|
|
7
|
+
export class CodexFollowupIntentClassifier {
|
|
8
|
+
codex;
|
|
9
|
+
logger;
|
|
10
|
+
constructor(codex, logger) {
|
|
11
|
+
this.codex = codex;
|
|
12
|
+
this.logger = logger;
|
|
13
|
+
}
|
|
14
|
+
async classify(input, context) {
|
|
15
|
+
if (!input.trim()) {
|
|
16
|
+
return lowConfidenceFollowupIntent("Empty follow-up text.");
|
|
17
|
+
}
|
|
18
|
+
try {
|
|
19
|
+
const thread = await this.codex.startThreadForFollowupIntent();
|
|
20
|
+
const turn = await this.codex.startTurn({
|
|
21
|
+
threadId: thread.id,
|
|
22
|
+
...(thread.cwd ? { cwd: thread.cwd } : {}),
|
|
23
|
+
input: buildFollowupIntentPrompt(input, context),
|
|
24
|
+
});
|
|
25
|
+
const completedThread = await this.waitForTurn(thread.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 = parseFollowupIntentClassification(latestMessage);
|
|
31
|
+
if (!parsed) {
|
|
32
|
+
this.logger.warn({ threadId: thread.id, turnId: turn.turnId }, "Follow-up intent classifier returned invalid JSON");
|
|
33
|
+
return lowConfidenceFollowupIntent("Classifier returned invalid JSON.");
|
|
34
|
+
}
|
|
35
|
+
if (parsed.confidence < FOLLOWUP_INTENT_MIN_CONFIDENCE) {
|
|
36
|
+
return {
|
|
37
|
+
intent: "unknown_needs_ack",
|
|
38
|
+
confidence: parsed.confidence,
|
|
39
|
+
reason: `Low confidence (${parsed.confidence}): ${parsed.reason}`,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
return parsed;
|
|
43
|
+
}
|
|
44
|
+
catch (error) {
|
|
45
|
+
this.logger.warn({ error: error instanceof Error ? error.message : String(error) }, "Follow-up intent classification failed");
|
|
46
|
+
return lowConfidenceFollowupIntent("Classifier unavailable.");
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
async waitForTurn(threadId, turnId) {
|
|
50
|
+
const deadline = Date.now() + FOLLOWUP_INTENT_TIMEOUT_MS;
|
|
51
|
+
while (Date.now() < deadline) {
|
|
52
|
+
let thread;
|
|
53
|
+
try {
|
|
54
|
+
thread = await this.codex.readThread(threadId, true);
|
|
55
|
+
}
|
|
56
|
+
catch (error) {
|
|
57
|
+
if (isThreadMaterializingError(error)) {
|
|
58
|
+
await new Promise((resolve) => setTimeout(resolve, FOLLOWUP_INTENT_POLL_MS));
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
throw error;
|
|
62
|
+
}
|
|
63
|
+
const turn = getThreadTurns(thread).find((entry) => entry.id === turnId);
|
|
64
|
+
if (turn?.status === "completed") {
|
|
65
|
+
return thread;
|
|
66
|
+
}
|
|
67
|
+
if (turn?.status === "failed" || turn?.status === "interrupted") {
|
|
68
|
+
throw new Error(`Follow-up intent turn ${turnId} ended with status ${turn.status}`);
|
|
69
|
+
}
|
|
70
|
+
await new Promise((resolve) => setTimeout(resolve, FOLLOWUP_INTENT_POLL_MS));
|
|
71
|
+
}
|
|
72
|
+
throw new Error(`Follow-up intent timed out after ${FOLLOWUP_INTENT_TIMEOUT_MS}ms`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
export function buildFollowupIntentPrompt(input, context) {
|
|
76
|
+
return [
|
|
77
|
+
"PatchRelay follow-up intent classification",
|
|
78
|
+
"",
|
|
79
|
+
"Classify one human follow-up so PatchRelay can route it through an explicit workflow state.",
|
|
80
|
+
"Do not solve the task, draft a reply, or start work. Infer ordinary natural-language intent from the text and state facts.",
|
|
81
|
+
"Return exactly one JSON object and no extra prose.",
|
|
82
|
+
"",
|
|
83
|
+
"Schema:",
|
|
84
|
+
"{",
|
|
85
|
+
' "intent": "stop" | "status" | "resume_or_retry" | "implementation_instruction" | "answer_to_question" | "context_only" | "unknown_needs_ack",',
|
|
86
|
+
' "confidence": 0.0,',
|
|
87
|
+
' "reason": "one short sentence"',
|
|
88
|
+
"}",
|
|
89
|
+
"",
|
|
90
|
+
"Intent definitions:",
|
|
91
|
+
'- "stop": the user asks PatchRelay to halt or cancel active work.',
|
|
92
|
+
'- "status": the user asks only for current state, progress, or what happened.',
|
|
93
|
+
'- "resume_or_retry": the user asks PatchRelay to continue, resume, retry, or run again.',
|
|
94
|
+
'- "implementation_instruction": the user gives work instructions, constraints, review feedback, or asks for code-changing work.',
|
|
95
|
+
'- "answer_to_question": the user is answering a PatchRelay question or unblocking awaiting-input work.',
|
|
96
|
+
'- "context_only": the user provides background that should not start idle work by itself.',
|
|
97
|
+
'- "unknown_needs_ack": the text is too ambiguous to route confidently.',
|
|
98
|
+
"",
|
|
99
|
+
"Routing facts:",
|
|
100
|
+
`- Source: ${context.source}`,
|
|
101
|
+
`- Active run type: ${context.activeRunType ?? "none"}`,
|
|
102
|
+
`- Factory state: ${context.factoryState ?? "unknown"}`,
|
|
103
|
+
`- Direct reply to outstanding PatchRelay question: ${context.directReply ? "yes" : "no"}`,
|
|
104
|
+
`- Delegated to PatchRelay: ${context.delegatedToPatchRelay ? "yes" : "no"}`,
|
|
105
|
+
`- PR review state: ${context.prReviewState ?? "none"}`,
|
|
106
|
+
`- Explicit PatchRelay wake context: ${context.explicitWakeIntent ? "yes" : "no"}`,
|
|
107
|
+
"",
|
|
108
|
+
"Follow-up text:",
|
|
109
|
+
input.trim(),
|
|
110
|
+
].join("\n");
|
|
111
|
+
}
|
|
112
|
+
export function parseFollowupIntentClassification(text) {
|
|
32
113
|
if (!text)
|
|
33
|
-
return
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
if (
|
|
37
|
-
return
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
114
|
+
return undefined;
|
|
115
|
+
const json = extractFirstJsonObject(text) ?? text;
|
|
116
|
+
const parsed = safeJsonParse(json);
|
|
117
|
+
if (!parsed)
|
|
118
|
+
return undefined;
|
|
119
|
+
const intent = parsed.intent;
|
|
120
|
+
const confidence = parsed.confidence;
|
|
121
|
+
const reason = parsed.reason;
|
|
122
|
+
if (!isFollowupIntent(intent))
|
|
123
|
+
return undefined;
|
|
124
|
+
if (typeof confidence !== "number" || !Number.isFinite(confidence))
|
|
125
|
+
return undefined;
|
|
126
|
+
if (typeof reason !== "string" || !reason.trim())
|
|
127
|
+
return undefined;
|
|
128
|
+
return {
|
|
129
|
+
intent,
|
|
130
|
+
confidence: Math.max(0, Math.min(1, confidence)),
|
|
131
|
+
reason: reason.trim().slice(0, 240),
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
export function lowConfidenceFollowupIntent(reason) {
|
|
135
|
+
return {
|
|
136
|
+
intent: "unknown_needs_ack",
|
|
137
|
+
confidence: 0,
|
|
138
|
+
reason,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
export function followupIntentQueuesWork(classification) {
|
|
142
|
+
const intent = typeof classification === "string" ? classification : classification.intent;
|
|
143
|
+
return intent === "implementation_instruction"
|
|
144
|
+
|| intent === "resume_or_retry"
|
|
145
|
+
|| intent === "answer_to_question";
|
|
146
|
+
}
|
|
147
|
+
export function followupIntentIsNonActionable(classification) {
|
|
148
|
+
const intent = typeof classification === "string" ? classification : classification.intent;
|
|
149
|
+
return intent === "status"
|
|
150
|
+
|| intent === "context_only"
|
|
151
|
+
|| intent === "unknown_needs_ack";
|
|
152
|
+
}
|
|
153
|
+
export function followupIntentShouldSteerActiveRun(classification) {
|
|
154
|
+
const intent = typeof classification === "string" ? classification : classification.intent;
|
|
155
|
+
return intent === "implementation_instruction"
|
|
156
|
+
|| intent === "resume_or_retry"
|
|
157
|
+
|| intent === "answer_to_question"
|
|
158
|
+
|| intent === "context_only"
|
|
159
|
+
|| intent === "unknown_needs_ack";
|
|
160
|
+
}
|
|
161
|
+
function isFollowupIntent(value) {
|
|
162
|
+
return value === "stop"
|
|
163
|
+
|| value === "status"
|
|
164
|
+
|| value === "resume_or_retry"
|
|
165
|
+
|| value === "implementation_instruction"
|
|
166
|
+
|| value === "answer_to_question"
|
|
167
|
+
|| value === "context_only"
|
|
168
|
+
|| value === "unknown_needs_ack";
|
|
56
169
|
}
|
|
@@ -8,11 +8,13 @@ export function buildGitHubBotCredentialHelper(tokenFile) {
|
|
|
8
8
|
}
|
|
9
9
|
export async function configureGitHubBotAuthForWorktree(params) {
|
|
10
10
|
const helper = buildGitHubBotCredentialHelper(params.botIdentity.tokenFile);
|
|
11
|
-
const
|
|
12
|
-
|
|
13
|
-
await execCommand(params.gitBin, [...
|
|
11
|
+
const gitConfigArgs = ["-C", params.worktreePath, "config"];
|
|
12
|
+
const gitWorktreeConfigArgs = [...gitConfigArgs, "--worktree"];
|
|
13
|
+
await execCommand(params.gitBin, [...gitConfigArgs, "extensions.worktreeConfig", "true"], { timeoutMs: 5_000 });
|
|
14
|
+
await execCommand(params.gitBin, [...gitWorktreeConfigArgs, "user.name", params.botIdentity.name], { timeoutMs: 5_000 });
|
|
15
|
+
await execCommand(params.gitBin, [...gitWorktreeConfigArgs, "user.email", params.botIdentity.email], { timeoutMs: 5_000 });
|
|
14
16
|
// Clear inherited GitHub-specific helpers such as `gh auth git-credential`
|
|
15
17
|
// so git HTTPS operations use the same bot token as the wrapped `gh` CLI.
|
|
16
|
-
await execCommand(params.gitBin, [...
|
|
17
|
-
await execCommand(params.gitBin, [...
|
|
18
|
+
await execCommand(params.gitBin, [...gitWorktreeConfigArgs, "--replace-all", "credential.https://github.com.helper", ""], { timeoutMs: 5_000 });
|
|
19
|
+
await execCommand(params.gitBin, [...gitWorktreeConfigArgs, "--add", "credential.https://github.com.helper", helper], { timeoutMs: 5_000 });
|
|
18
20
|
}
|
|
@@ -9,6 +9,7 @@ const TERMINAL_SESSION_EVENTS = new Set([
|
|
|
9
9
|
]);
|
|
10
10
|
const NON_ACTIONABLE_SESSION_EVENTS = new Set([
|
|
11
11
|
"delegation_observed",
|
|
12
|
+
"prompt_delivered",
|
|
12
13
|
"run_released_authority",
|
|
13
14
|
]);
|
|
14
15
|
const RUN_TYPES = new Set(["implementation", "main_repair", "review_fix", "branch_upkeep", "ci_repair", "queue_repair"]);
|
|
@@ -117,6 +118,17 @@ export function deriveSessionWakePlan(issue, events) {
|
|
|
117
118
|
...(typeof payload?.author === "string" ? { author: payload.author } : {}),
|
|
118
119
|
});
|
|
119
120
|
}
|
|
121
|
+
if (payload?.replacementPrRequired === true) {
|
|
122
|
+
context.replacementPrRequired = true;
|
|
123
|
+
if (typeof payload.previousPrNumber === "number")
|
|
124
|
+
context.previousPrNumber = payload.previousPrNumber;
|
|
125
|
+
if (typeof payload.previousPrUrl === "string")
|
|
126
|
+
context.previousPrUrl = payload.previousPrUrl;
|
|
127
|
+
if (typeof payload.previousPrState === "string")
|
|
128
|
+
context.previousPrState = payload.previousPrState;
|
|
129
|
+
if (typeof payload.previousPrHeadSha === "string")
|
|
130
|
+
context.previousPrHeadSha = payload.previousPrHeadSha;
|
|
131
|
+
}
|
|
120
132
|
if (event.eventType === "followup_prompt"
|
|
121
133
|
|| event.eventType === "followup_comment"
|
|
122
134
|
|| event.eventType === "operator_prompt") {
|