patchrelay 0.67.0 → 0.67.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -1
- package/config/patchrelay.example.json +1 -0
- package/dist/build-info.json +3 -3
- package/dist/codex-app-server.js +33 -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 +3 -1
- package/dist/run-orchestrator.js +52 -11
- package/dist/run-reconciler.js +17 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -114,10 +114,12 @@ See the [merge-steward package README](./packages/merge-steward/README.md) for t
|
|
|
114
114
|
|
|
115
115
|
## Docs
|
|
116
116
|
|
|
117
|
+
- [Concepts](./docs/concepts.md) — the shared mental model (three roles, four primitives, four states, carry-forward, eviction). Start here.
|
|
117
118
|
- [Self-hosting and deployment](./docs/self-hosting.md) — install, ingress, OAuth and GitHub App setup
|
|
118
119
|
- [Architecture](./docs/architecture.md) — components, ownership, state machine, failure taxonomy
|
|
119
120
|
- [Operator guide](./docs/operator-guide.md) — daily loop, CLI cheatsheet, troubleshooting
|
|
120
|
-
- [Merge queue](./docs/merge-queue.md) — the
|
|
121
|
+
- [Merge queue](./docs/merge-queue.md) — the three-service delivery story
|
|
122
|
+
- [GitHub queue contract](./docs/github-queue-contract.md) — bus artifacts, identity algorithms, configurable names
|
|
121
123
|
- [Prompting](./docs/prompting.md) — how workflow files and the built-in scaffold compose
|
|
122
124
|
- [Secrets](./docs/secrets.md) — systemd credentials, resolution order
|
|
123
125
|
- [review-quill reference](./docs/review-quill.md) · [merge-steward reference](./docs/merge-steward.md)
|
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");
|
|
@@ -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.",
|
|
@@ -305,7 +307,7 @@ function buildRequestedChangesContext(runType, context) {
|
|
|
305
307
|
else {
|
|
306
308
|
const reviewer = typeof context?.reviewerName === "string" ? context.reviewerName : undefined;
|
|
307
309
|
const reviewBody = typeof context?.reviewBody === "string" ? context.reviewBody.trim() : "";
|
|
308
|
-
lines.push("Requested changes on the existing PR branch.", "Goal: restore review readiness on the current PR branch. Push a newer head only when the fix actually changes the diff; if the reviewer-pass produces only comments, test wording, or PR-body changes, edit the PR body via `gh pr edit` instead.", "Address the real concern behind the feedback and verify nearby invariants in the touched flow before you publish.", reviewer ? `Reviewer: ${reviewer}` : "", reviewBody ? `Review summary:\n${reviewBody}` : "");
|
|
310
|
+
lines.push("Requested changes on the existing PR branch.", "Goal: restore review readiness on the current PR branch. Push a newer head only when the fix actually changes the diff; if the reviewer-pass produces only comments, test wording, or PR-body changes, edit the PR body via `gh pr edit` instead.", "Address the real concern behind the feedback and verify nearby invariants in the touched flow before you publish.", "For each review comment, identify the resource, epoch, or token it touches (e.g. session, capture, route, persistence handle, in-flight turn id), enumerate the other transitions that share that same resource, and verify each one before pushing — not just the exact path called out. If you find an adjacent transition that violates the same invariant, fix it in this iteration rather than waiting for the reviewer to surface it next round.", reviewer ? `Reviewer: ${reviewer}` : "", reviewBody ? `Review summary:\n${reviewBody}` : "");
|
|
309
311
|
appendStructuredReviewContext(lines, context);
|
|
310
312
|
}
|
|
311
313
|
return lines.join("\n").trim();
|
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" });
|