patchrelay 0.67.1 → 0.68.0
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/config/patchrelay.example.json +1 -0
- package/dist/build-info.json +3 -3
- package/dist/codex-app-server.js +59 -12
- package/dist/codex-thread-errors.js +13 -0
- package/dist/config.js +4 -0
- package/dist/db/issue-store.js +18 -2
- package/dist/db/migrations.js +12 -0
- package/dist/issue-class.js +3 -0
- package/dist/issue-triage.js +151 -0
- package/dist/prompting/patchrelay.js +2 -0
- package/dist/run-launcher.js +48 -0
- package/dist/run-orchestrator.js +52 -11
- package/dist/run-reconciler.js +17 -1
- package/package.json +1 -1
package/dist/build-info.json
CHANGED
package/dist/codex-app-server.js
CHANGED
|
@@ -17,6 +17,13 @@ const PUBLICATION_RECAP_DEVELOPER_INSTRUCTIONS = [
|
|
|
17
17
|
"Use only the prior thread context and the facts in the current prompt.",
|
|
18
18
|
"Return only the requested JSON object.",
|
|
19
19
|
].join("\n");
|
|
20
|
+
const ISSUE_TRIAGE_DEVELOPER_INSTRUCTIONS = [
|
|
21
|
+
"You are PatchRelay's issue triage classifier.",
|
|
22
|
+
"This is a read-only preflight step used only to choose the execution shape for one Linear issue.",
|
|
23
|
+
"Do not run commands, do not call tools, do not edit files, and do not inspect or modify the repository.",
|
|
24
|
+
"Use only the facts in the current prompt.",
|
|
25
|
+
"Return only the requested JSON object.",
|
|
26
|
+
].join("\n");
|
|
20
27
|
export function resolveCodexAppServerLaunch(config) {
|
|
21
28
|
if (!config.sourceBashrc) {
|
|
22
29
|
return {
|
|
@@ -101,16 +108,30 @@ export class CodexAppServerClient extends EventEmitter {
|
|
|
101
108
|
await Promise.race([exited, timeout]);
|
|
102
109
|
}
|
|
103
110
|
async startThread(options) {
|
|
111
|
+
return await this.startThreadWithOverrides(options, {});
|
|
112
|
+
}
|
|
113
|
+
async startThreadForIssueTriage() {
|
|
114
|
+
return await this.startThreadWithOverrides({ cwd: tmpdir() }, {
|
|
115
|
+
approvalPolicy: "never",
|
|
116
|
+
sandboxMode: "read-only",
|
|
117
|
+
model: this.config.triageModel ?? "gpt-5.4-mini",
|
|
118
|
+
modelProvider: this.config.triageModelProvider ?? this.config.modelProvider ?? null,
|
|
119
|
+
reasoningEffort: "low",
|
|
120
|
+
baseInstructions: null,
|
|
121
|
+
developerInstructions: ISSUE_TRIAGE_DEVELOPER_INSTRUCTIONS,
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
async startThreadWithOverrides(options, overrides) {
|
|
104
125
|
const params = {
|
|
105
126
|
cwd: options.cwd,
|
|
106
|
-
approvalPolicy: this.config.approvalPolicy,
|
|
107
|
-
sandbox: this.config.sandboxMode,
|
|
127
|
+
approvalPolicy: overrides.approvalPolicy ?? this.config.approvalPolicy,
|
|
128
|
+
sandbox: overrides.sandboxMode ?? this.config.sandboxMode,
|
|
108
129
|
serviceName: this.config.serviceName ?? "patchrelay",
|
|
109
|
-
model: this.config.model ?? null,
|
|
110
|
-
modelProvider: this.config.modelProvider ?? null,
|
|
111
|
-
reasoningEffort: this.config.reasoningEffort ?? null,
|
|
112
|
-
baseInstructions: this.config.baseInstructions ?? null,
|
|
113
|
-
developerInstructions: this.config.developerInstructions ?? null,
|
|
130
|
+
model: "model" in overrides ? overrides.model ?? null : this.config.model ?? null,
|
|
131
|
+
modelProvider: "modelProvider" in overrides ? overrides.modelProvider ?? null : this.config.modelProvider ?? null,
|
|
132
|
+
reasoningEffort: "reasoningEffort" in overrides ? overrides.reasoningEffort ?? null : this.config.reasoningEffort ?? null,
|
|
133
|
+
baseInstructions: "baseInstructions" in overrides ? overrides.baseInstructions ?? null : this.config.baseInstructions ?? null,
|
|
134
|
+
developerInstructions: "developerInstructions" in overrides ? overrides.developerInstructions ?? null : this.config.developerInstructions ?? null,
|
|
114
135
|
experimentalRawEvents: this.config.experimentalRawEvents ?? false,
|
|
115
136
|
};
|
|
116
137
|
const response = (await this.sendRequest("thread/start", params));
|
|
@@ -140,11 +161,11 @@ export class CodexAppServerClient extends EventEmitter {
|
|
|
140
161
|
cwd: overrides?.cwd ?? cwd ?? null,
|
|
141
162
|
approvalPolicy: overrides?.approvalPolicy ?? this.config.approvalPolicy,
|
|
142
163
|
sandbox: overrides?.sandboxMode ?? this.config.sandboxMode,
|
|
143
|
-
model: overrides
|
|
144
|
-
modelProvider: overrides
|
|
145
|
-
reasoningEffort: overrides
|
|
146
|
-
baseInstructions: overrides
|
|
147
|
-
developerInstructions: overrides
|
|
164
|
+
model: overrides && "model" in overrides ? overrides.model ?? null : this.config.model ?? null,
|
|
165
|
+
modelProvider: overrides && "modelProvider" in overrides ? overrides.modelProvider ?? null : this.config.modelProvider ?? null,
|
|
166
|
+
reasoningEffort: overrides && "reasoningEffort" in overrides ? overrides.reasoningEffort ?? null : this.config.reasoningEffort ?? null,
|
|
167
|
+
baseInstructions: overrides && "baseInstructions" in overrides ? overrides.baseInstructions ?? null : this.config.baseInstructions ?? null,
|
|
168
|
+
developerInstructions: overrides && "developerInstructions" in overrides ? overrides.developerInstructions ?? null : this.config.developerInstructions ?? null,
|
|
148
169
|
};
|
|
149
170
|
if (this.config.persistExtendedHistory) {
|
|
150
171
|
this.logger.warn("persistExtendedHistory is requested but not enabled in the active app-server capability handshake; ignoring");
|
|
@@ -185,6 +206,20 @@ export class CodexAppServerClient extends EventEmitter {
|
|
|
185
206
|
status: String(response.turn.status),
|
|
186
207
|
};
|
|
187
208
|
}
|
|
209
|
+
async setThreadGoal(options) {
|
|
210
|
+
const params = {
|
|
211
|
+
threadId: options.threadId,
|
|
212
|
+
objective: options.objective,
|
|
213
|
+
};
|
|
214
|
+
if (options.status !== undefined) {
|
|
215
|
+
params.status = options.status;
|
|
216
|
+
}
|
|
217
|
+
if (options.tokenBudget !== undefined) {
|
|
218
|
+
params.tokenBudget = options.tokenBudget;
|
|
219
|
+
}
|
|
220
|
+
const response = (await this.sendRequest("thread/goal/set", params));
|
|
221
|
+
return this.mapThreadGoal(response.goal);
|
|
222
|
+
}
|
|
188
223
|
async readThread(threadId, includeTurns = true) {
|
|
189
224
|
const response = (await this.sendRequest("thread/read", {
|
|
190
225
|
threadId,
|
|
@@ -209,6 +244,18 @@ export class CodexAppServerClient extends EventEmitter {
|
|
|
209
244
|
],
|
|
210
245
|
});
|
|
211
246
|
}
|
|
247
|
+
mapThreadGoal(goal) {
|
|
248
|
+
return {
|
|
249
|
+
threadId: String(goal.threadId),
|
|
250
|
+
objective: String(goal.objective ?? ""),
|
|
251
|
+
status: String(goal.status ?? "active"),
|
|
252
|
+
...(goal.tokenBudget === null || goal.tokenBudget === undefined ? { tokenBudget: null } : { tokenBudget: Number(goal.tokenBudget) }),
|
|
253
|
+
tokensUsed: Number(goal.tokensUsed ?? 0),
|
|
254
|
+
timeUsedSeconds: Number(goal.timeUsedSeconds ?? 0),
|
|
255
|
+
createdAt: Number(goal.createdAt ?? 0),
|
|
256
|
+
updatedAt: Number(goal.updatedAt ?? 0),
|
|
257
|
+
};
|
|
258
|
+
}
|
|
212
259
|
sendNotification(method, params) {
|
|
213
260
|
this.writeMessage({
|
|
214
261
|
jsonrpc: "2.0",
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export class CodexThreadMaterializingError extends Error {
|
|
2
|
+
code = "thread_materializing";
|
|
3
|
+
constructor(threadId, attempts, cause) {
|
|
4
|
+
super(`Codex thread ${threadId} is not materialized yet after ${attempts} read attempt(s)`, { cause });
|
|
5
|
+
this.name = "CodexThreadMaterializingError";
|
|
6
|
+
}
|
|
7
|
+
}
|
|
8
|
+
export function isThreadMaterializingError(error) {
|
|
9
|
+
if (error instanceof CodexThreadMaterializingError)
|
|
10
|
+
return true;
|
|
11
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
12
|
+
return /not materialized yet|not materialized/i.test(message);
|
|
13
|
+
}
|
package/dist/config.js
CHANGED
|
@@ -162,6 +162,8 @@ const configSchema = z.object({
|
|
|
162
162
|
model: z.string().default("gpt-5.5"),
|
|
163
163
|
model_provider: z.string().optional(),
|
|
164
164
|
reasoning_effort: z.enum(["low", "medium", "high"]).optional(),
|
|
165
|
+
triage_model: z.string().default("gpt-5.4-mini"),
|
|
166
|
+
triage_model_provider: z.string().optional(),
|
|
165
167
|
service_name: z.string().default("patchrelay"),
|
|
166
168
|
base_instructions: z.string().optional(),
|
|
167
169
|
developer_instructions: z.string().optional(),
|
|
@@ -580,6 +582,8 @@ export function loadConfig(configPath = process.env.PATCHRELAY_CONFIG ?? getDefa
|
|
|
580
582
|
...(parsed.runner.codex.model ? { model: parsed.runner.codex.model } : {}),
|
|
581
583
|
...(parsed.runner.codex.model_provider ? { modelProvider: parsed.runner.codex.model_provider } : {}),
|
|
582
584
|
...(parsed.runner.codex.reasoning_effort ? { reasoningEffort: parsed.runner.codex.reasoning_effort } : {}),
|
|
585
|
+
...(parsed.runner.codex.triage_model ? { triageModel: parsed.runner.codex.triage_model } : {}),
|
|
586
|
+
...(parsed.runner.codex.triage_model_provider ? { triageModelProvider: parsed.runner.codex.triage_model_provider } : {}),
|
|
583
587
|
...(parsed.runner.codex.service_name ? { serviceName: parsed.runner.codex.service_name } : {}),
|
|
584
588
|
...(parsed.runner.codex.base_instructions ? { baseInstructions: parsed.runner.codex.base_instructions } : {}),
|
|
585
589
|
developerInstructions: mergeDeveloperInstructions(parsed.runner.codex.developer_instructions),
|
package/dist/db/issue-store.js
CHANGED
|
@@ -28,6 +28,14 @@ export class IssueStore {
|
|
|
28
28
|
sets.push("issue_class_source = @issueClassSource");
|
|
29
29
|
values.issueClassSource = params.issueClassSource;
|
|
30
30
|
}
|
|
31
|
+
if (params.issueTriageHash !== undefined) {
|
|
32
|
+
sets.push("issue_triage_hash = @issueTriageHash");
|
|
33
|
+
values.issueTriageHash = params.issueTriageHash;
|
|
34
|
+
}
|
|
35
|
+
if (params.issueTriageResultJson !== undefined) {
|
|
36
|
+
sets.push("issue_triage_result_json = @issueTriageResultJson");
|
|
37
|
+
values.issueTriageResultJson = params.issueTriageResultJson;
|
|
38
|
+
}
|
|
31
39
|
if (params.parentLinearIssueId !== undefined) {
|
|
32
40
|
sets.push("parent_linear_issue_id = @parentLinearIssueId");
|
|
33
41
|
values.parentLinearIssueId = params.parentLinearIssueId;
|
|
@@ -257,7 +265,7 @@ export class IssueStore {
|
|
|
257
265
|
else {
|
|
258
266
|
this.connection.prepare(`
|
|
259
267
|
INSERT INTO issues (
|
|
260
|
-
project_id, linear_issue_id, delegated_to_patchrelay, issue_class, issue_class_source, parent_linear_issue_id, parent_issue_key, issue_key, title, description, url,
|
|
268
|
+
project_id, linear_issue_id, delegated_to_patchrelay, issue_class, issue_class_source, issue_triage_hash, issue_triage_result_json, parent_linear_issue_id, parent_issue_key, issue_key, title, description, url,
|
|
261
269
|
priority, estimate,
|
|
262
270
|
current_linear_state, current_linear_state_type, factory_state, pending_run_type, pending_run_context_json,
|
|
263
271
|
branch_name, worktree_path, thread_id, active_run_id, status_comment_id,
|
|
@@ -272,7 +280,7 @@ export class IssueStore {
|
|
|
272
280
|
ci_repair_attempts, queue_repair_attempts, review_fix_attempts, zombie_recovery_attempts, last_zombie_recovery_at, orchestration_settle_until,
|
|
273
281
|
updated_at
|
|
274
282
|
) VALUES (
|
|
275
|
-
@projectId, @linearIssueId, @delegatedToPatchRelay, @issueClass, @issueClassSource, @parentLinearIssueId, @parentIssueKey, @issueKey, @title, @description, @url,
|
|
283
|
+
@projectId, @linearIssueId, @delegatedToPatchRelay, @issueClass, @issueClassSource, @issueTriageHash, @issueTriageResultJson, @parentLinearIssueId, @parentIssueKey, @issueKey, @title, @description, @url,
|
|
276
284
|
@priority, @estimate,
|
|
277
285
|
@currentLinearState, @currentLinearStateType, @factoryState, @pendingRunType, @pendingRunContextJson,
|
|
278
286
|
@branchName, @worktreePath, @threadId, @activeRunId, @statusCommentId,
|
|
@@ -293,6 +301,8 @@ export class IssueStore {
|
|
|
293
301
|
delegatedToPatchRelay: params.delegatedToPatchRelay === false ? 0 : 1,
|
|
294
302
|
issueClass: params.issueClass ?? null,
|
|
295
303
|
issueClassSource: params.issueClassSource ?? null,
|
|
304
|
+
issueTriageHash: params.issueTriageHash ?? null,
|
|
305
|
+
issueTriageResultJson: params.issueTriageResultJson ?? null,
|
|
296
306
|
parentLinearIssueId: params.parentLinearIssueId ?? null,
|
|
297
307
|
parentIssueKey: params.parentIssueKey ?? null,
|
|
298
308
|
issueKey: params.issueKey ?? null,
|
|
@@ -650,6 +660,12 @@ export function mapIssueRow(row) {
|
|
|
650
660
|
...(row.issue_class_source !== null && row.issue_class_source !== undefined
|
|
651
661
|
? { issueClassSource: String(row.issue_class_source) }
|
|
652
662
|
: {}),
|
|
663
|
+
...(row.issue_triage_hash !== null && row.issue_triage_hash !== undefined
|
|
664
|
+
? { issueTriageHash: String(row.issue_triage_hash) }
|
|
665
|
+
: {}),
|
|
666
|
+
...(row.issue_triage_result_json !== null && row.issue_triage_result_json !== undefined
|
|
667
|
+
? { issueTriageResultJson: String(row.issue_triage_result_json) }
|
|
668
|
+
: {}),
|
|
653
669
|
...(row.parent_linear_issue_id !== null && row.parent_linear_issue_id !== undefined
|
|
654
670
|
? { parentLinearIssueId: String(row.parent_linear_issue_id) }
|
|
655
671
|
: {}),
|
package/dist/db/migrations.js
CHANGED
|
@@ -6,6 +6,8 @@ CREATE TABLE IF NOT EXISTS issues (
|
|
|
6
6
|
delegated_to_patchrelay INTEGER NOT NULL DEFAULT 1,
|
|
7
7
|
issue_class TEXT,
|
|
8
8
|
issue_class_source TEXT,
|
|
9
|
+
issue_triage_hash TEXT,
|
|
10
|
+
issue_triage_result_json TEXT,
|
|
9
11
|
parent_linear_issue_id TEXT,
|
|
10
12
|
parent_issue_key TEXT,
|
|
11
13
|
issue_key TEXT,
|
|
@@ -264,6 +266,8 @@ export function runPatchRelayMigrations(connection) {
|
|
|
264
266
|
addColumnIfMissing(connection, "issues", "delegated_to_patchrelay", "INTEGER NOT NULL DEFAULT 1");
|
|
265
267
|
addColumnIfMissing(connection, "issues", "issue_class", "TEXT");
|
|
266
268
|
addColumnIfMissing(connection, "issues", "issue_class_source", "TEXT");
|
|
269
|
+
addColumnIfMissing(connection, "issues", "issue_triage_hash", "TEXT");
|
|
270
|
+
addColumnIfMissing(connection, "issues", "issue_triage_result_json", "TEXT");
|
|
267
271
|
addColumnIfMissing(connection, "issues", "parent_linear_issue_id", "TEXT");
|
|
268
272
|
addColumnIfMissing(connection, "issues", "parent_issue_key", "TEXT");
|
|
269
273
|
addColumnIfMissing(connection, "issues", "orchestration_settle_until", "TEXT");
|
|
@@ -353,6 +357,8 @@ export function runPatchRelayMigrations(connection) {
|
|
|
353
357
|
addColumnIfMissing(connection, "linear_installations", "health_reason", "TEXT");
|
|
354
358
|
addColumnIfMissing(connection, "linear_installations", "health_updated_at", "TEXT");
|
|
355
359
|
removeRetiredIssueColumnsIfPresent(connection);
|
|
360
|
+
addColumnIfMissing(connection, "issues", "issue_triage_hash", "TEXT");
|
|
361
|
+
addColumnIfMissing(connection, "issues", "issue_triage_result_json", "TEXT");
|
|
356
362
|
}
|
|
357
363
|
function addColumnIfMissing(connection, table, column, definition) {
|
|
358
364
|
const cols = connection.prepare(`PRAGMA table_info(${table})`).all();
|
|
@@ -377,6 +383,8 @@ function removeRetiredIssueColumnsIfPresent(connection) {
|
|
|
377
383
|
delegated_to_patchrelay INTEGER NOT NULL DEFAULT 1,
|
|
378
384
|
issue_class TEXT,
|
|
379
385
|
issue_class_source TEXT,
|
|
386
|
+
issue_triage_hash TEXT,
|
|
387
|
+
issue_triage_result_json TEXT,
|
|
380
388
|
parent_linear_issue_id TEXT,
|
|
381
389
|
parent_issue_key TEXT,
|
|
382
390
|
issue_key TEXT,
|
|
@@ -444,6 +452,8 @@ function removeRetiredIssueColumnsIfPresent(connection) {
|
|
|
444
452
|
delegated_to_patchrelay,
|
|
445
453
|
issue_class,
|
|
446
454
|
issue_class_source,
|
|
455
|
+
issue_triage_hash,
|
|
456
|
+
issue_triage_result_json,
|
|
447
457
|
parent_linear_issue_id,
|
|
448
458
|
parent_issue_key,
|
|
449
459
|
issue_key,
|
|
@@ -509,6 +519,8 @@ function removeRetiredIssueColumnsIfPresent(connection) {
|
|
|
509
519
|
COALESCE(delegated_to_patchrelay, 1),
|
|
510
520
|
issue_class,
|
|
511
521
|
issue_class_source,
|
|
522
|
+
issue_triage_hash,
|
|
523
|
+
issue_triage_result_json,
|
|
512
524
|
parent_linear_issue_id,
|
|
513
525
|
parent_issue_key,
|
|
514
526
|
issue_key,
|
package/dist/issue-class.js
CHANGED
|
@@ -11,5 +11,8 @@ export function classifyIssue(params) {
|
|
|
11
11
|
if (params.issue.issueClassSource === "explicit" && params.issue.issueClass === "implementation") {
|
|
12
12
|
return { issueClass: "implementation", issueClassSource: "explicit" };
|
|
13
13
|
}
|
|
14
|
+
if (params.issue.issueClassSource === "triage" && params.issue.issueClass) {
|
|
15
|
+
return { issueClass: params.issue.issueClass, issueClassSource: "triage" };
|
|
16
|
+
}
|
|
14
17
|
return { issueClass: "implementation", issueClassSource: "heuristic" };
|
|
15
18
|
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { getThreadTurns } from "./codex-thread-utils.js";
|
|
3
|
+
import { isThreadMaterializingError } from "./codex-thread-errors.js";
|
|
4
|
+
import { extractFirstJsonObject, safeJsonParse } from "./utils.js";
|
|
5
|
+
const TRIAGE_TIMEOUT_MS = 45_000;
|
|
6
|
+
const TRIAGE_POLL_MS = 1_000;
|
|
7
|
+
const MIN_CONFIDENCE = 0.55;
|
|
8
|
+
export function buildIssueTriageHash(params) {
|
|
9
|
+
const payload = {
|
|
10
|
+
linearIssueId: params.issue.linearIssueId,
|
|
11
|
+
issueKey: params.issue.issueKey ?? null,
|
|
12
|
+
title: params.issue.title ?? "",
|
|
13
|
+
description: params.issue.description ?? "",
|
|
14
|
+
parentLinearIssueId: params.issue.parentLinearIssueId ?? null,
|
|
15
|
+
childIssues: params.childIssues.map((child) => ({
|
|
16
|
+
linearIssueId: child.linearIssueId,
|
|
17
|
+
issueKey: child.issueKey ?? null,
|
|
18
|
+
title: child.title ?? "",
|
|
19
|
+
currentLinearState: child.currentLinearState ?? null,
|
|
20
|
+
factoryState: child.factoryState,
|
|
21
|
+
})),
|
|
22
|
+
};
|
|
23
|
+
return createHash("sha256").update(JSON.stringify(payload)).digest("hex");
|
|
24
|
+
}
|
|
25
|
+
export class IssueTriageService {
|
|
26
|
+
codex;
|
|
27
|
+
logger;
|
|
28
|
+
constructor(codex, logger) {
|
|
29
|
+
this.codex = codex;
|
|
30
|
+
this.logger = logger;
|
|
31
|
+
}
|
|
32
|
+
async classify(params) {
|
|
33
|
+
const thread = await this.codex.startThreadForIssueTriage();
|
|
34
|
+
const turn = await this.codex.startTurn({
|
|
35
|
+
threadId: thread.id,
|
|
36
|
+
...(thread.cwd ? { cwd: thread.cwd } : {}),
|
|
37
|
+
input: buildIssueTriagePrompt(params),
|
|
38
|
+
});
|
|
39
|
+
const completedThread = await this.waitForTurn(thread.id, turn.turnId);
|
|
40
|
+
const completedTurn = getThreadTurns(completedThread).find((entry) => entry.id === turn.turnId);
|
|
41
|
+
const latestMessage = completedTurn?.items
|
|
42
|
+
.filter((item) => item.type === "agentMessage")
|
|
43
|
+
.at(-1)?.text;
|
|
44
|
+
const parsed = parseIssueTriageResult(latestMessage);
|
|
45
|
+
if (!parsed) {
|
|
46
|
+
this.logger.warn({ issueKey: params.issue.issueKey, linearIssueId: params.issue.linearIssueId, threadId: thread.id, turnId: turn.turnId }, "Issue triage returned invalid JSON");
|
|
47
|
+
return undefined;
|
|
48
|
+
}
|
|
49
|
+
if (parsed.confidence < MIN_CONFIDENCE) {
|
|
50
|
+
return {
|
|
51
|
+
...parsed,
|
|
52
|
+
issueClass: "implementation",
|
|
53
|
+
reason: `Low confidence triage (${parsed.confidence}): ${parsed.reason}`,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
return parsed;
|
|
57
|
+
}
|
|
58
|
+
async waitForTurn(threadId, turnId) {
|
|
59
|
+
const deadline = Date.now() + TRIAGE_TIMEOUT_MS;
|
|
60
|
+
while (Date.now() < deadline) {
|
|
61
|
+
let thread;
|
|
62
|
+
try {
|
|
63
|
+
thread = await this.codex.readThread(threadId, true);
|
|
64
|
+
}
|
|
65
|
+
catch (error) {
|
|
66
|
+
if (isThreadMaterializingError(error)) {
|
|
67
|
+
await new Promise((resolve) => setTimeout(resolve, TRIAGE_POLL_MS));
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
throw error;
|
|
71
|
+
}
|
|
72
|
+
const turn = getThreadTurns(thread).find((entry) => entry.id === turnId);
|
|
73
|
+
if (turn?.status === "completed") {
|
|
74
|
+
return thread;
|
|
75
|
+
}
|
|
76
|
+
if (turn?.status === "failed" || turn?.status === "interrupted") {
|
|
77
|
+
throw new Error(`Issue triage turn ${turnId} ended with status ${turn.status}`);
|
|
78
|
+
}
|
|
79
|
+
await new Promise((resolve) => setTimeout(resolve, TRIAGE_POLL_MS));
|
|
80
|
+
}
|
|
81
|
+
throw new Error(`Issue triage timed out after ${TRIAGE_TIMEOUT_MS}ms`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
function buildIssueTriagePrompt(params) {
|
|
85
|
+
return [
|
|
86
|
+
"PatchRelay issue triage",
|
|
87
|
+
"",
|
|
88
|
+
"Classify the Linear issue so PatchRelay can choose the right first worker prompt.",
|
|
89
|
+
"Do not solve the task, decompose the work, create a plan, or propose child issue titles.",
|
|
90
|
+
"Return exactly one JSON object and no extra prose.",
|
|
91
|
+
"",
|
|
92
|
+
"Schema:",
|
|
93
|
+
"{",
|
|
94
|
+
' "issueClass": "implementation" | "orchestration",',
|
|
95
|
+
' "intent": "code_change" | "split_into_children" | "investigate" | "review" | "coordination" | "needs_input",',
|
|
96
|
+
' "confidence": 0.0,',
|
|
97
|
+
' "reason": "one short sentence"',
|
|
98
|
+
"}",
|
|
99
|
+
"",
|
|
100
|
+
"Choose issueClass:",
|
|
101
|
+
'- "implementation" when the worker should directly change code, investigate a concrete bug, repair a PR, answer with repo findings, or do a single bounded task.',
|
|
102
|
+
'- "orchestration" when the worker should coordinate a parent issue, split work into child issues, supervise existing children, or converge already-delegated work instead of directly implementing the full scope.',
|
|
103
|
+
"",
|
|
104
|
+
"Facts:",
|
|
105
|
+
`- Issue: ${params.issue.issueKey ?? params.issue.linearIssueId}`,
|
|
106
|
+
`- Parent issue id: ${params.issue.parentLinearIssueId ?? "none"}`,
|
|
107
|
+
`- Title: ${params.issue.title ?? ""}`,
|
|
108
|
+
"Description:",
|
|
109
|
+
params.issue.description?.trim() ? params.issue.description.trim() : "(empty)",
|
|
110
|
+
"",
|
|
111
|
+
"Existing children:",
|
|
112
|
+
...(params.childIssues.length > 0
|
|
113
|
+
? params.childIssues.map((child) => {
|
|
114
|
+
const label = child.issueKey ?? child.linearIssueId;
|
|
115
|
+
const state = child.currentLinearState ? `; state ${child.currentLinearState}` : "";
|
|
116
|
+
return `- ${label}: ${child.title ?? "(untitled)"} (${child.factoryState}${state})`;
|
|
117
|
+
})
|
|
118
|
+
: ["- none"]),
|
|
119
|
+
].join("\n");
|
|
120
|
+
}
|
|
121
|
+
function parseIssueTriageResult(text) {
|
|
122
|
+
if (!text)
|
|
123
|
+
return undefined;
|
|
124
|
+
const json = extractFirstJsonObject(text) ?? text;
|
|
125
|
+
const parsed = safeJsonParse(json);
|
|
126
|
+
if (!parsed)
|
|
127
|
+
return undefined;
|
|
128
|
+
const issueClass = parsed.issueClass;
|
|
129
|
+
const intent = parsed.intent;
|
|
130
|
+
const confidence = parsed.confidence;
|
|
131
|
+
const reason = parsed.reason;
|
|
132
|
+
if (issueClass !== "implementation" && issueClass !== "orchestration")
|
|
133
|
+
return undefined;
|
|
134
|
+
if (intent !== "code_change" &&
|
|
135
|
+
intent !== "split_into_children" &&
|
|
136
|
+
intent !== "investigate" &&
|
|
137
|
+
intent !== "review" &&
|
|
138
|
+
intent !== "coordination" &&
|
|
139
|
+
intent !== "needs_input")
|
|
140
|
+
return undefined;
|
|
141
|
+
if (typeof confidence !== "number" || !Number.isFinite(confidence))
|
|
142
|
+
return undefined;
|
|
143
|
+
if (typeof reason !== "string" || !reason.trim())
|
|
144
|
+
return undefined;
|
|
145
|
+
return {
|
|
146
|
+
issueClass,
|
|
147
|
+
intent,
|
|
148
|
+
confidence: Math.max(0, Math.min(1, confidence)),
|
|
149
|
+
reason: reason.trim().slice(0, 240),
|
|
150
|
+
};
|
|
151
|
+
}
|
|
@@ -179,6 +179,8 @@ function buildOrchestrationConstraints(context) {
|
|
|
179
179
|
"",
|
|
180
180
|
"This issue is orchestration work. Coordinate convergence instead of duplicating child implementation.",
|
|
181
181
|
"Inspect the current child set before acting. Reuse existing child issues when they already cover the needed slices instead of creating duplicates.",
|
|
182
|
+
"Before creating child issues, list existing children and recent parent context, normalize the intended child purpose, and update or reuse matching issues.",
|
|
183
|
+
"When you create or reuse children, leave a concise parent-visible split manifest naming the child IDs and what each one covers.",
|
|
182
184
|
"Babysit child progress and solve parent-owned integration or convergence issues when the delivered pieces do not yet fit together cleanly.",
|
|
183
185
|
"Do not open an overlapping umbrella PR unless this parent owns unique direct work.",
|
|
184
186
|
"Create new child issues only for genuinely missing required work needed to satisfy the parent goal.",
|
package/dist/run-launcher.js
CHANGED
|
@@ -3,6 +3,7 @@ import { buildRunFailureActivity } from "./linear-session-reporting.js";
|
|
|
3
3
|
import { loadPatchRelayRepoPrompting } from "./patchrelay-customization.js";
|
|
4
4
|
import { buildRunPrompt as buildPatchRelayRunPrompt, findDisallowedPatchRelayPromptSectionIds, findUnknownPatchRelayPromptSectionIds, mergePromptCustomizationLayers, resolvePromptLayers, } from "./prompting/patchrelay.js";
|
|
5
5
|
import { configureGitHubBotAuthForWorktree } from "./github-worktree-auth.js";
|
|
6
|
+
import { sanitizeDiagnosticText } from "./utils.js";
|
|
6
7
|
function slugify(value) {
|
|
7
8
|
return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 60);
|
|
8
9
|
}
|
|
@@ -15,6 +16,28 @@ function shouldCompactThread(issue, threadGeneration, context) {
|
|
|
15
16
|
&& (threadGeneration ?? 0) >= 4
|
|
16
17
|
&& followUpCount >= 4;
|
|
17
18
|
}
|
|
19
|
+
function compactGoalText(value, maxLength = 600) {
|
|
20
|
+
const normalized = value.replace(/\s+/g, " ").trim();
|
|
21
|
+
return normalized.length <= maxLength ? normalized : `${normalized.slice(0, maxLength - 3).trimEnd()}...`;
|
|
22
|
+
}
|
|
23
|
+
function extractIssueSection(description, heading) {
|
|
24
|
+
if (!description)
|
|
25
|
+
return undefined;
|
|
26
|
+
const headingLine = `## ${heading}`.toLowerCase();
|
|
27
|
+
const lines = description.split(/\r?\n/);
|
|
28
|
+
const start = lines.findIndex((line) => line.trim().toLowerCase() === headingLine);
|
|
29
|
+
if (start === -1)
|
|
30
|
+
return undefined;
|
|
31
|
+
const end = lines.findIndex((line, index) => index > start && /^##\s+/.test(line));
|
|
32
|
+
const body = lines.slice(start + 1, end === -1 ? undefined : end).join("\n").trim();
|
|
33
|
+
return body && body.length > 0 ? body : undefined;
|
|
34
|
+
}
|
|
35
|
+
export function buildInitialImplementationGoal(issue) {
|
|
36
|
+
const title = issue.title?.trim() || `Complete ${issue.issueKey ?? issue.linearIssueId}`;
|
|
37
|
+
const description = issue.description?.trim();
|
|
38
|
+
const goal = extractIssueSection(description, "Goal");
|
|
39
|
+
return compactGoalText(goal ? `${title}. ${goal}` : title);
|
|
40
|
+
}
|
|
18
41
|
export function shouldReuseIssueThread(params) {
|
|
19
42
|
return Boolean(params.existingThreadId) && !params.compactThread && params.resumeThread;
|
|
20
43
|
}
|
|
@@ -125,6 +148,8 @@ export class RunLauncher {
|
|
|
125
148
|
let threadId;
|
|
126
149
|
let turnId;
|
|
127
150
|
let parentThreadId;
|
|
151
|
+
let createdThreadForRun = false;
|
|
152
|
+
const firstThreadForIssue = !params.issue.threadId;
|
|
128
153
|
try {
|
|
129
154
|
await this.worktreeManager.ensureIssueWorktree(params.project.repoPath, params.project.worktreeRoot, params.worktreePath, params.branchName, { allowExistingOutsideRoot: params.issue.branchName !== undefined });
|
|
130
155
|
if (params.botIdentity) {
|
|
@@ -157,6 +182,7 @@ export class RunLauncher {
|
|
|
157
182
|
else {
|
|
158
183
|
const thread = await this.codex.startThread({ cwd: params.worktreePath });
|
|
159
184
|
threadId = thread.id;
|
|
185
|
+
createdThreadForRun = true;
|
|
160
186
|
this.db.issueSessions.upsertIssueWithLease({ projectId: params.project.id, linearIssueId: params.issue.linearIssueId, leaseId: params.leaseId }, { projectId: params.project.id, linearIssueId: params.issue.linearIssueId, threadId });
|
|
161
187
|
}
|
|
162
188
|
try {
|
|
@@ -169,6 +195,7 @@ export class RunLauncher {
|
|
|
169
195
|
this.logger.info({ issueKey: params.issue.issueKey, staleThreadId: threadId }, "Thread is stale, retrying with fresh thread");
|
|
170
196
|
const thread = await this.codex.startThread({ cwd: params.worktreePath });
|
|
171
197
|
threadId = thread.id;
|
|
198
|
+
createdThreadForRun = true;
|
|
172
199
|
this.db.issueSessions.upsertIssueWithLease({ projectId: params.project.id, linearIssueId: params.issue.linearIssueId, leaseId: params.leaseId }, { projectId: params.project.id, linearIssueId: params.issue.linearIssueId, threadId });
|
|
173
200
|
const turn = await this.codex.startTurn({ threadId, cwd: params.worktreePath, input: params.prompt });
|
|
174
201
|
turnId = turn.turnId;
|
|
@@ -177,6 +204,9 @@ export class RunLauncher {
|
|
|
177
204
|
throw turnError;
|
|
178
205
|
}
|
|
179
206
|
}
|
|
207
|
+
if (createdThreadForRun && firstThreadForIssue && params.runType === "implementation") {
|
|
208
|
+
await this.setInitialImplementationGoal(threadId, params.issue);
|
|
209
|
+
}
|
|
180
210
|
params.assertLaunchLease(params.run, "after starting the Codex turn");
|
|
181
211
|
return { threadId, turnId, ...(parentThreadId ? { parentThreadId } : {}) };
|
|
182
212
|
}
|
|
@@ -204,4 +234,22 @@ export class RunLauncher {
|
|
|
204
234
|
throw error;
|
|
205
235
|
}
|
|
206
236
|
}
|
|
237
|
+
async setInitialImplementationGoal(threadId, issue) {
|
|
238
|
+
const goalSetter = this.codex.setThreadGoal;
|
|
239
|
+
if (typeof goalSetter !== "function") {
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
const objective = buildInitialImplementationGoal(issue);
|
|
243
|
+
try {
|
|
244
|
+
await goalSetter.call(this.codex, { threadId, objective, status: "active" });
|
|
245
|
+
this.logger.info({ issueKey: issue.issueKey, threadId }, "Set Codex thread goal for implementation run");
|
|
246
|
+
}
|
|
247
|
+
catch (error) {
|
|
248
|
+
this.logger.warn({
|
|
249
|
+
issueKey: issue.issueKey,
|
|
250
|
+
threadId,
|
|
251
|
+
error: sanitizeDiagnosticText(error instanceof Error ? error.message : String(error)),
|
|
252
|
+
}, "Failed to set Codex thread goal for implementation run");
|
|
253
|
+
}
|
|
254
|
+
}
|
|
207
255
|
}
|
package/dist/run-orchestrator.js
CHANGED
|
@@ -20,7 +20,9 @@ import { RunRecoveryService } from "./run-recovery-service.js";
|
|
|
20
20
|
import { RunWakePlanner } from "./run-wake-planner.js";
|
|
21
21
|
import { getRemainingZombieRecoveryDelayMs } from "./zombie-recovery.js";
|
|
22
22
|
import { classifyIssue } from "./issue-class.js";
|
|
23
|
+
import { buildIssueTriageHash, IssueTriageService } from "./issue-triage.js";
|
|
23
24
|
import { loadConfig } from "./config.js";
|
|
25
|
+
import { CodexThreadMaterializingError, isThreadMaterializingError } from "./codex-thread-errors.js";
|
|
24
26
|
function lowerCaseFirst(value) {
|
|
25
27
|
return value ? `${value.slice(0, 1).toLowerCase()}${value.slice(1)}` : value;
|
|
26
28
|
}
|
|
@@ -62,6 +64,7 @@ export class RunOrchestrator {
|
|
|
62
64
|
runCompletionPolicy;
|
|
63
65
|
completionCheck;
|
|
64
66
|
publicationRecap;
|
|
67
|
+
issueTriage;
|
|
65
68
|
runNotificationHandler;
|
|
66
69
|
runReconciler;
|
|
67
70
|
mergedLinearCompletionReconciler;
|
|
@@ -99,6 +102,7 @@ export class RunOrchestrator {
|
|
|
99
102
|
this.runCompletionPolicy = new RunCompletionPolicy(config, db, logger, this.leasePorts.withHeldLease);
|
|
100
103
|
this.completionCheck = new CompletionCheckService(codex, logger);
|
|
101
104
|
this.publicationRecap = new PublicationRecapService(codex, logger);
|
|
105
|
+
this.issueTriage = new IssueTriageService(codex, logger);
|
|
102
106
|
this.runFinalizer = new RunFinalizer(db, logger, this.linearSync, this.enqueueIssue, this.leasePorts.withHeldLease, this.leasePorts.releaseLease, (lease, issue, runType, context, dedupeScope) => this.appendWakeEventWithLease(lease, issue, runType, context, dedupeScope), this.recoveryPorts.failRunAndClear, this.runCompletionPolicy, this.completionCheck, this.publicationRecap, feed);
|
|
103
107
|
this.runLauncher = new RunLauncher(config, db, codex, logger, this.worktreeManager);
|
|
104
108
|
this.runNotificationHandler = new RunNotificationHandler(config, db, logger, this.linearSync, this.runFinalizer, this.threadPorts.readThreadWithRetry, this.leasePorts.withHeldLease, this.leasePorts.heartbeatLease, this.leasePorts.releaseLease, feed);
|
|
@@ -176,17 +180,43 @@ export class RunOrchestrator {
|
|
|
176
180
|
...(childIssues.length > 0 ? { childIssues } : {}),
|
|
177
181
|
};
|
|
178
182
|
}
|
|
179
|
-
classifyTrackedIssue(issue) {
|
|
180
|
-
const
|
|
181
|
-
const classification = classifyIssue({ issue, childIssueCount });
|
|
183
|
+
async classifyTrackedIssue(issue) {
|
|
184
|
+
const childIssues = this.db.issues.listChildIssues(issue.projectId, issue.linearIssueId);
|
|
185
|
+
const classification = classifyIssue({ issue, childIssueCount: childIssues.length });
|
|
186
|
+
const triageHash = buildIssueTriageHash({ issue, childIssues });
|
|
187
|
+
const triageCacheFresh = issue.issueClassSource === "triage" && issue.issueTriageHash === triageHash;
|
|
182
188
|
if (issue.issueClass === classification.issueClass && issue.issueClassSource === classification.issueClassSource) {
|
|
183
|
-
|
|
189
|
+
if (classification.issueClassSource !== "triage" || triageCacheFresh) {
|
|
190
|
+
return issue;
|
|
191
|
+
}
|
|
184
192
|
}
|
|
193
|
+
if (classification.issueClassSource === "heuristic" || (classification.issueClassSource === "triage" && !triageCacheFresh)) {
|
|
194
|
+
try {
|
|
195
|
+
const triage = await this.issueTriage.classify({ issue, childIssues });
|
|
196
|
+
if (triage) {
|
|
197
|
+
return this.db.issues.upsertIssue({
|
|
198
|
+
projectId: issue.projectId,
|
|
199
|
+
linearIssueId: issue.linearIssueId,
|
|
200
|
+
issueClass: triage.issueClass,
|
|
201
|
+
issueClassSource: "triage",
|
|
202
|
+
issueTriageHash: triageHash,
|
|
203
|
+
issueTriageResultJson: JSON.stringify(triage),
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
catch (error) {
|
|
208
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
209
|
+
this.logger.warn({ issueKey: issue.issueKey, linearIssueId: issue.linearIssueId, error: err.message }, "Issue triage failed; falling back to heuristic classification");
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
const fallbackClassification = classification.issueClassSource === "triage" && !triageCacheFresh
|
|
213
|
+
? { issueClass: "implementation", issueClassSource: "heuristic" }
|
|
214
|
+
: classification;
|
|
185
215
|
return this.db.issues.upsertIssue({
|
|
186
216
|
projectId: issue.projectId,
|
|
187
217
|
linearIssueId: issue.linearIssueId,
|
|
188
|
-
issueClass:
|
|
189
|
-
issueClassSource:
|
|
218
|
+
issueClass: fallbackClassification.issueClass,
|
|
219
|
+
issueClassSource: fallbackClassification.issueClassSource,
|
|
190
220
|
});
|
|
191
221
|
}
|
|
192
222
|
// ─── Run ────────────────────────────────────────────────────────
|
|
@@ -199,7 +229,9 @@ export class RunOrchestrator {
|
|
|
199
229
|
return;
|
|
200
230
|
}
|
|
201
231
|
const initialIssue = this.db.issues.getIssue(item.projectId, item.issueId);
|
|
202
|
-
|
|
232
|
+
if (!initialIssue || initialIssue.activeRunId !== undefined)
|
|
233
|
+
return;
|
|
234
|
+
const issue = await this.classifyTrackedIssue(initialIssue);
|
|
203
235
|
if (!issue || issue.activeRunId !== undefined)
|
|
204
236
|
return;
|
|
205
237
|
const issueSession = this.db.issueSessions.getIssueSession(item.projectId, item.issueId);
|
|
@@ -426,17 +458,26 @@ export class RunOrchestrator {
|
|
|
426
458
|
return await this.runCompletionPolicy.resolveRequestedChangesWakeContext(issue, runType, context);
|
|
427
459
|
}
|
|
428
460
|
async readThreadWithRetry(threadId, maxRetries = 3) {
|
|
461
|
+
let lastError;
|
|
429
462
|
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
|
430
463
|
try {
|
|
431
464
|
return await this.codex.readThread(threadId, true);
|
|
432
465
|
}
|
|
433
|
-
catch {
|
|
434
|
-
|
|
435
|
-
|
|
466
|
+
catch (error) {
|
|
467
|
+
lastError = error;
|
|
468
|
+
if (attempt === maxRetries - 1) {
|
|
469
|
+
if (isThreadMaterializingError(error)) {
|
|
470
|
+
throw new CodexThreadMaterializingError(threadId, maxRetries, error);
|
|
471
|
+
}
|
|
472
|
+
throw new Error(`Failed to read thread ${threadId} after ${maxRetries} attempts`, { cause: error });
|
|
473
|
+
}
|
|
436
474
|
await new Promise((resolve) => setTimeout(resolve, 1000 * (attempt + 1)));
|
|
437
475
|
}
|
|
438
476
|
}
|
|
439
|
-
|
|
477
|
+
if (isThreadMaterializingError(lastError)) {
|
|
478
|
+
throw new CodexThreadMaterializingError(threadId, maxRetries, lastError);
|
|
479
|
+
}
|
|
480
|
+
throw new Error(`Failed to read thread ${threadId}`, { cause: lastError });
|
|
440
481
|
}
|
|
441
482
|
getHeldIssueSessionLease(projectId, linearIssueId) {
|
|
442
483
|
return this.leaseService.getHeldLease(projectId, linearIssueId);
|
package/dist/run-reconciler.js
CHANGED
|
@@ -5,6 +5,14 @@ import { buildRunFailureActivity } from "./linear-session-reporting.js";
|
|
|
5
5
|
import { getThreadTurns } from "./codex-thread-utils.js";
|
|
6
6
|
import { resolveRecoverablePostRunState } from "./interrupted-run-recovery.js";
|
|
7
7
|
import { resolveEffectiveActiveRun } from "./effective-active-run.js";
|
|
8
|
+
import { isThreadMaterializingError } from "./codex-thread-errors.js";
|
|
9
|
+
const THREAD_MATERIALIZATION_GRACE_MS = 10 * 60_000;
|
|
10
|
+
function isWithinThreadMaterializationGrace(run, nowMs = Date.now()) {
|
|
11
|
+
const startedAtMs = Date.parse(run.startedAt);
|
|
12
|
+
if (!Number.isFinite(startedAtMs))
|
|
13
|
+
return true;
|
|
14
|
+
return nowMs - startedAtMs < THREAD_MATERIALIZATION_GRACE_MS;
|
|
15
|
+
}
|
|
8
16
|
export class RunReconciler {
|
|
9
17
|
db;
|
|
10
18
|
logger;
|
|
@@ -89,7 +97,15 @@ export class RunReconciler {
|
|
|
89
97
|
try {
|
|
90
98
|
thread = await this.readThreadWithRetry(run.threadId);
|
|
91
99
|
}
|
|
92
|
-
catch {
|
|
100
|
+
catch (error) {
|
|
101
|
+
if (isThreadMaterializingError(error) && isWithinThreadMaterializationGrace(run)) {
|
|
102
|
+
this.logger.info({ issueKey: effectiveIssue.issueKey, runId: run.id, runType: run.runType, threadId: run.threadId }, "Codex thread still materializing during reconciliation; keeping run active");
|
|
103
|
+
void this.linearSync.syncSession(effectiveIssue, { activeRunType: run.runType });
|
|
104
|
+
if (acquiredRecoveryLease) {
|
|
105
|
+
this.releaseLease(run.projectId, run.linearIssueId);
|
|
106
|
+
}
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
93
109
|
this.logger.warn({ issueKey: effectiveIssue.issueKey, runId: run.id, runType: run.runType, threadId: run.threadId }, "Stale thread during reconciliation");
|
|
94
110
|
this.withHeldLease(run.projectId, run.linearIssueId, () => {
|
|
95
111
|
this.db.runs.finishRun(run.id, { status: "failed", failureReason: "Stale thread after restart" });
|