patchrelay 0.67.1 → 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.
@@ -6,6 +6,7 @@
6
6
  "git_bin": "git",
7
7
  "codex": {
8
8
  "model": "gpt-5.5",
9
+ "triage_model": "gpt-5.4-mini",
9
10
  "reasoning_effort": "high",
10
11
  "developer_instructions": "Keep the public API stable for this installation unless the task explicitly requires a breaking change."
11
12
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "service": "patchrelay",
3
- "version": "0.67.1",
4
- "commit": "cda2d1d453d6",
5
- "builtAt": "2026-05-05T20:30:58.013Z"
3
+ "version": "0.67.2",
4
+ "commit": "8d3d15a51ae1",
5
+ "builtAt": "2026-05-10T15:37:09.721Z"
6
6
  }
@@ -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?.model ?? this.config.model ?? null,
144
- modelProvider: overrides?.modelProvider ?? this.config.modelProvider ?? null,
145
- reasoningEffort: overrides?.reasoningEffort ?? this.config.reasoningEffort ?? null,
146
- baseInstructions: overrides?.baseInstructions ?? this.config.baseInstructions ?? null,
147
- developerInstructions: overrides?.developerInstructions ?? this.config.developerInstructions ?? null,
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),
@@ -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
  : {}),
@@ -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,
@@ -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.",
@@ -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 childIssueCount = this.db.issues.listChildIssues(issue.projectId, issue.linearIssueId).length;
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
- return issue;
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: classification.issueClass,
189
- issueClassSource: classification.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
- const issue = initialIssue ? this.classifyTrackedIssue(initialIssue) : undefined;
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
- if (attempt === maxRetries - 1)
435
- throw new Error(`Failed to read thread ${threadId} after ${maxRetries} attempts`);
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
- throw new Error(`Failed to read thread ${threadId}`);
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);
@@ -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" });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "patchrelay",
3
- "version": "0.67.1",
3
+ "version": "0.67.2",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "repository": {