patchrelay 0.16.0 → 0.17.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -8,70 +8,50 @@ export function useDetailStream(options) {
8
8
  return;
9
9
  const abortController = new AbortController();
10
10
  const { baseUrl, bearerToken, dispatch } = optionsRef.current;
11
- const headers = { accept: "application/json" };
11
+ const headers = {};
12
12
  if (bearerToken) {
13
13
  headers.authorization = `Bearer ${bearerToken}`;
14
14
  }
15
- // Rehydrate from thread/read via /api/issues/:key/live
15
+ // Rehydrate from timeline endpoint
16
16
  void rehydrate(baseUrl, issueKey, headers, abortController.signal, dispatch);
17
- // Stream codex notifications via filtered SSE
18
- void streamCodexEvents(baseUrl, issueKey, headers, abortController.signal, dispatch);
17
+ // Stream codex notifications + feed events via filtered SSE
18
+ void streamEvents(baseUrl, issueKey, headers, abortController.signal, dispatch);
19
19
  return () => {
20
20
  abortController.abort();
21
21
  };
22
22
  }, [options.issueKey]);
23
23
  }
24
+ // ─── Rehydration ──────────────────────────────────────────────────
24
25
  async function rehydrate(baseUrl, issueKey, headers, signal, dispatch) {
25
26
  try {
26
- const url = new URL(`/api/issues/${encodeURIComponent(issueKey)}/live`, baseUrl);
27
- const response = await fetch(url, { headers, signal });
28
- if (!response.ok)
29
- return;
30
- const data = await response.json();
31
- const threadData = data.thread;
32
- if (threadData) {
33
- dispatch({ type: "thread-snapshot", thread: materializeThread(threadData) });
34
- return;
35
- }
36
- // No active thread — fall back to latest run report
37
- await rehydrateFromReport(baseUrl, issueKey, headers, signal, dispatch);
38
- }
39
- catch {
40
- // Rehydration is best-effort — SSE stream will provide updates
41
- }
42
- }
43
- async function rehydrateFromReport(baseUrl, issueKey, headers, signal, dispatch) {
44
- try {
45
- const url = new URL(`/api/issues/${encodeURIComponent(issueKey)}/report`, baseUrl);
46
- const response = await fetch(url, { headers, signal });
27
+ const url = new URL(`/api/issues/${encodeURIComponent(issueKey)}/timeline`, baseUrl);
28
+ const response = await fetch(url, { headers: { ...headers, accept: "application/json" }, signal });
47
29
  if (!response.ok)
48
30
  return;
49
31
  const data = await response.json();
50
- const latest = data.runs?.[0];
51
- if (!latest)
52
- return;
53
- const report = {
54
- runType: latest.run.runType,
55
- status: latest.run.status,
56
- summary: typeof latest.summary?.latestAssistantMessage === "string"
57
- ? latest.summary.latestAssistantMessage
58
- : latest.report?.assistantMessages.at(-1),
59
- commands: latest.report?.commands.map((c) => ({
60
- command: c.command,
61
- ...(typeof c.exitCode === "number" ? { exitCode: c.exitCode } : {}),
62
- ...(typeof c.durationMs === "number" ? { durationMs: c.durationMs } : {}),
63
- })) ?? [],
64
- fileChanges: latest.report?.fileChanges.length ?? 0,
65
- toolCalls: latest.report?.toolCalls.length ?? 0,
66
- assistantMessages: latest.report?.assistantMessages ?? [],
67
- };
68
- dispatch({ type: "report-snapshot", report });
32
+ const runs = (data.runs ?? []).map((r) => ({
33
+ id: r.id,
34
+ runType: r.runType,
35
+ status: r.status,
36
+ startedAt: r.startedAt,
37
+ endedAt: r.endedAt,
38
+ threadId: r.threadId,
39
+ ...(r.report ? { report: r.report } : {}),
40
+ }));
41
+ dispatch({
42
+ type: "timeline-rehydrate",
43
+ runs,
44
+ feedEvents: data.feedEvents ?? [],
45
+ liveThread: data.liveThread ?? null,
46
+ activeRunId: data.activeRunId ?? null,
47
+ });
69
48
  }
70
49
  catch {
71
- // Report fetch is best-effort
50
+ // Rehydration is best-effort
72
51
  }
73
52
  }
74
- async function streamCodexEvents(baseUrl, issueKey, baseHeaders, signal, dispatch) {
53
+ // ─── Live SSE Stream ──────────────────────────────────────────────
54
+ async function streamEvents(baseUrl, issueKey, baseHeaders, signal, dispatch) {
75
55
  try {
76
56
  const url = new URL("/api/watch", baseUrl);
77
57
  url.searchParams.set("issue", issueKey);
@@ -96,7 +76,7 @@ async function streamCodexEvents(baseUrl, issueKey, baseHeaders, signal, dispatc
96
76
  const line = rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine;
97
77
  if (!line) {
98
78
  if (dataLines.length > 0) {
99
- processDetailEvent(dispatch, eventType, dataLines.join("\n"));
79
+ processEvent(dispatch, eventType, dataLines.join("\n"));
100
80
  dataLines = [];
101
81
  eventType = "";
102
82
  }
@@ -121,73 +101,15 @@ async function streamCodexEvents(baseUrl, issueKey, baseHeaders, signal, dispatc
121
101
  // Stream ended or aborted
122
102
  }
123
103
  }
124
- function processDetailEvent(dispatch, eventType, data) {
104
+ function processEvent(dispatch, eventType, data) {
125
105
  try {
126
106
  if (eventType === "codex") {
127
107
  const parsed = JSON.parse(data);
128
108
  dispatch({ type: "codex-notification", method: parsed.method, params: parsed.params });
129
109
  }
130
- // Feed events are already handled by the main watch stream
110
+ // Feed events are handled by the main watch stream
131
111
  }
132
112
  catch {
133
113
  // Ignore parse errors
134
114
  }
135
115
  }
136
- // ─── Thread Materialization from thread/read ──────────────────────
137
- function materializeThread(summary) {
138
- return {
139
- threadId: summary.id,
140
- status: summary.status,
141
- turns: summary.turns.map(materializeTurn),
142
- };
143
- }
144
- function materializeTurn(turn) {
145
- return {
146
- id: turn.id,
147
- status: turn.status,
148
- items: turn.items.map(materializeItem),
149
- };
150
- }
151
- function materializeItem(item) {
152
- // CodexThreadItem has an index-signature catch-all that defeats narrowing.
153
- // Access fields via Record<string, unknown> and coerce explicitly.
154
- const r = item;
155
- const id = String(r.id ?? "unknown");
156
- const type = String(r.type ?? "unknown");
157
- const base = { id, type, status: "completed" };
158
- switch (type) {
159
- case "agentMessage":
160
- return { ...base, text: String(r.text ?? "") };
161
- case "commandExecution":
162
- return {
163
- ...base,
164
- command: String(r.command ?? ""),
165
- status: String(r.status ?? "completed"),
166
- ...(typeof r.exitCode === "number" ? { exitCode: r.exitCode } : {}),
167
- ...(typeof r.durationMs === "number" ? { durationMs: r.durationMs } : {}),
168
- ...(typeof r.aggregatedOutput === "string" ? { output: r.aggregatedOutput } : {}),
169
- };
170
- case "fileChange":
171
- return { ...base, status: String(r.status ?? "completed"), changes: Array.isArray(r.changes) ? r.changes : [] };
172
- case "mcpToolCall":
173
- return {
174
- ...base,
175
- status: String(r.status ?? "completed"),
176
- toolName: `${String(r.server ?? "")}/${String(r.tool ?? "")}`,
177
- ...(typeof r.durationMs === "number" ? { durationMs: r.durationMs } : {}),
178
- };
179
- case "dynamicToolCall":
180
- return {
181
- ...base,
182
- status: String(r.status ?? "completed"),
183
- toolName: String(r.tool ?? ""),
184
- ...(typeof r.durationMs === "number" ? { durationMs: r.durationMs } : {}),
185
- };
186
- case "plan":
187
- return { ...base, text: String(r.text ?? "") };
188
- case "reasoning":
189
- return { ...base, text: Array.isArray(r.summary) ? r.summary.join("\n") : "" };
190
- default:
191
- return base;
192
- }
193
- }
@@ -1,14 +1,21 @@
1
+ import { buildTimelineFromRehydration, appendFeedToTimeline, appendCodexItemToTimeline, completeCodexItemInTimeline, appendDeltaToTimelineItem, } from "./timeline-builder.js";
2
+ const DETAIL_INITIAL = {
3
+ timeline: [],
4
+ activeRunId: null,
5
+ activeRunStartedAt: null,
6
+ tokenUsage: null,
7
+ diffSummary: null,
8
+ plan: null,
9
+ };
1
10
  export const initialWatchState = {
2
11
  connected: false,
3
12
  issues: [],
4
13
  selectedIndex: 0,
5
14
  view: "list",
6
15
  activeDetailKey: null,
7
- thread: null,
8
- report: null,
9
16
  filter: "non-done",
10
17
  follow: true,
11
- detailFeed: [],
18
+ ...DETAIL_INITIAL,
12
19
  };
13
20
  const TERMINAL_FACTORY_STATES = new Set(["done", "failed"]);
14
21
  export function filterIssues(issues, filter) {
@@ -28,6 +35,7 @@ function nextFilter(filter) {
28
35
  case "all": return "non-done";
29
36
  }
30
37
  }
38
+ // ─── Reducer ──────────────────────────────────────────────────────
31
39
  export function watchReducer(state, action) {
32
40
  switch (action.type) {
33
41
  case "connected":
@@ -48,13 +56,19 @@ export function watchReducer(state, action) {
48
56
  selectedIndex: Math.max(0, Math.min(action.index, state.issues.length - 1)),
49
57
  };
50
58
  case "enter-detail":
51
- return { ...state, view: "detail", activeDetailKey: action.issueKey, thread: null, report: null, detailFeed: [] };
59
+ return { ...state, view: "detail", activeDetailKey: action.issueKey, ...DETAIL_INITIAL };
52
60
  case "exit-detail":
53
- return { ...state, view: "list", activeDetailKey: null, thread: null, report: null, detailFeed: [] };
54
- case "thread-snapshot":
55
- return { ...state, thread: action.thread };
56
- case "report-snapshot":
57
- return { ...state, report: action.report };
61
+ return { ...state, view: "list", activeDetailKey: null, ...DETAIL_INITIAL };
62
+ case "timeline-rehydrate": {
63
+ const timeline = buildTimelineFromRehydration(action.runs, action.feedEvents, action.liveThread, action.activeRunId);
64
+ const activeRun = action.runs.find((r) => r.id === action.activeRunId);
65
+ return {
66
+ ...state,
67
+ timeline,
68
+ activeRunId: action.activeRunId,
69
+ activeRunStartedAt: activeRun?.startedAt ?? null,
70
+ };
71
+ }
58
72
  case "codex-notification":
59
73
  return applyCodexNotification(state, action.method, action.params);
60
74
  case "cycle-filter":
@@ -63,7 +77,7 @@ export function watchReducer(state, action) {
63
77
  return { ...state, follow: !state.follow };
64
78
  }
65
79
  }
66
- // ─── Feed Event Application ───────────────────────────────────────
80
+ // ─── Feed Event Issue List + Timeline ───────────────────────────
67
81
  function applyFeedEvent(state, event) {
68
82
  if (!event.issueKey) {
69
83
  return state;
@@ -93,103 +107,52 @@ function applyFeedEvent(state, event) {
93
107
  }
94
108
  issue.updatedAt = event.at;
95
109
  updated[index] = issue;
96
- // Append to detail feed if this event matches the active detail issue
97
- const detailFeed = state.view === "detail" && state.activeDetailKey === event.issueKey
98
- ? [...state.detailFeed, {
99
- at: event.at,
100
- kind: event.kind,
101
- summary: event.summary,
102
- ...(event.status ? { status: event.status } : {}),
103
- ...(event.detail ? { detail: event.detail } : {}),
104
- }]
105
- : state.detailFeed;
106
- return { ...state, issues: updated, detailFeed };
110
+ // Append to timeline if this event matches the active detail issue
111
+ const timeline = state.view === "detail" && state.activeDetailKey === event.issueKey
112
+ ? appendFeedToTimeline(state.timeline, event)
113
+ : state.timeline;
114
+ return { ...state, issues: updated, timeline };
107
115
  }
108
- // ─── Codex Notification Application ───────────────────────────────
116
+ // ─── Codex Notification Timeline + Metadata ─────────────────────
109
117
  function applyCodexNotification(state, method, params) {
110
- if (!state.thread) {
111
- // No thread loaded yet — only turn/started can bootstrap one
112
- if (method === "turn/started") {
113
- return bootstrapThreadFromTurnStarted(state, params);
114
- }
115
- return state;
116
- }
117
118
  switch (method) {
118
- case "turn/started":
119
- return withThread(state, addTurn(state.thread, params));
120
- case "turn/completed":
121
- return withThread(state, completeTurn(state.thread, params));
122
- case "turn/plan/updated":
123
- return withThread(state, updatePlan(state.thread, params));
124
- case "turn/diff/updated":
125
- return withThread(state, updateDiff(state.thread, params));
126
119
  case "item/started":
127
- return withThread(state, addItem(state.thread, params));
120
+ return { ...state, timeline: appendCodexItemToTimeline(state.timeline, params, state.activeRunId) };
128
121
  case "item/completed":
129
- return withThread(state, completeItem(state.thread, params));
122
+ return { ...state, timeline: completeCodexItemInTimeline(state.timeline, params) };
130
123
  case "item/agentMessage/delta":
131
- return withThread(state, appendItemText(state.thread, params));
132
- case "item/commandExecution/outputDelta":
133
- return withThread(state, appendItemOutput(state.thread, params));
134
124
  case "item/plan/delta":
135
- return withThread(state, appendItemText(state.thread, params));
136
- case "item/reasoning/summaryTextDelta":
137
- return withThread(state, appendItemText(state.thread, params));
138
- case "thread/status/changed":
139
- return withThread(state, updateThreadStatus(state.thread, params));
125
+ case "item/reasoning/summaryTextDelta": {
126
+ const itemId = typeof params.itemId === "string" ? params.itemId : undefined;
127
+ const delta = typeof params.delta === "string" ? params.delta : undefined;
128
+ if (!itemId || !delta)
129
+ return state;
130
+ return { ...state, timeline: appendDeltaToTimelineItem(state.timeline, itemId, "text", delta) };
131
+ }
132
+ case "item/commandExecution/outputDelta": {
133
+ const itemId = typeof params.itemId === "string" ? params.itemId : undefined;
134
+ const delta = typeof params.delta === "string" ? params.delta : undefined;
135
+ if (!itemId || !delta)
136
+ return state;
137
+ return { ...state, timeline: appendDeltaToTimelineItem(state.timeline, itemId, "output", delta) };
138
+ }
139
+ case "turn/plan/updated":
140
+ return applyPlanUpdate(state, params);
141
+ case "turn/diff/updated":
142
+ return applyDiffUpdate(state, params);
140
143
  case "thread/tokenUsage/updated":
141
- return withThread(state, updateTokenUsage(state.thread, params));
144
+ return applyTokenUsageUpdate(state, params);
142
145
  default:
143
146
  return state;
144
147
  }
145
148
  }
146
- function withThread(state, thread) {
147
- return { ...state, thread };
148
- }
149
- function bootstrapThreadFromTurnStarted(state, params) {
150
- const turnObj = params.turn;
151
- const threadId = typeof params.threadId === "string" ? params.threadId : "unknown";
152
- const turnId = typeof turnObj?.id === "string" ? turnObj.id : "unknown";
153
- return {
154
- ...state,
155
- thread: {
156
- threadId,
157
- status: "active",
158
- turns: [{ id: turnId, status: "inProgress", items: [] }],
159
- },
160
- };
161
- }
162
- // ─── Turn Handlers ────────────────────────────────────────────────
163
- function addTurn(thread, params) {
164
- const turnObj = params.turn;
165
- const turnId = typeof turnObj?.id === "string" ? turnObj.id : "unknown";
166
- const existing = thread.turns.find((t) => t.id === turnId);
167
- if (existing) {
168
- return thread;
169
- }
170
- return {
171
- ...thread,
172
- status: "active",
173
- turns: [...thread.turns, { id: turnId, status: "inProgress", items: [] }],
174
- };
175
- }
176
- function completeTurn(thread, params) {
177
- const turnObj = params.turn;
178
- const turnId = typeof turnObj?.id === "string" ? turnObj.id : undefined;
179
- const status = typeof turnObj?.status === "string" ? turnObj.status : "completed";
180
- if (!turnId)
181
- return thread;
182
- return {
183
- ...thread,
184
- turns: thread.turns.map((t) => t.id === turnId ? { ...t, status } : t),
185
- };
186
- }
187
- function updatePlan(thread, params) {
149
+ // ─── Metadata Handlers (header, not timeline) ─────────────────────
150
+ function applyPlanUpdate(state, params) {
188
151
  const plan = params.plan;
189
152
  if (!Array.isArray(plan))
190
- return thread;
153
+ return state;
191
154
  return {
192
- ...thread,
155
+ ...state,
193
156
  plan: plan.map((entry) => {
194
157
  const e = entry;
195
158
  return {
@@ -199,9 +162,11 @@ function updatePlan(thread, params) {
199
162
  }),
200
163
  };
201
164
  }
202
- function updateDiff(thread, params) {
165
+ function applyDiffUpdate(state, params) {
203
166
  const diff = typeof params.diff === "string" ? params.diff : undefined;
204
- return { ...thread, diff, diffSummary: diff ? parseDiffSummary(diff) : undefined };
167
+ if (!diff)
168
+ return state;
169
+ return { ...state, diffSummary: parseDiffSummary(diff) };
205
170
  }
206
171
  function parseDiffSummary(diff) {
207
172
  const files = new Set();
@@ -220,109 +185,13 @@ function parseDiffSummary(diff) {
220
185
  }
221
186
  return { filesChanged: files.size, linesAdded: added, linesRemoved: removed };
222
187
  }
223
- function updateTokenUsage(thread, params) {
188
+ function applyTokenUsageUpdate(state, params) {
224
189
  const usage = params.usage;
225
190
  if (!usage)
226
- return thread;
191
+ return state;
227
192
  const inputTokens = typeof usage.inputTokens === "number" ? usage.inputTokens
228
193
  : typeof usage.input_tokens === "number" ? usage.input_tokens : 0;
229
194
  const outputTokens = typeof usage.outputTokens === "number" ? usage.outputTokens
230
195
  : typeof usage.output_tokens === "number" ? usage.output_tokens : 0;
231
- return { ...thread, tokenUsage: { inputTokens, outputTokens } };
232
- }
233
- function updateThreadStatus(thread, params) {
234
- const statusObj = params.status;
235
- const statusType = typeof statusObj?.type === "string" ? statusObj.type : undefined;
236
- if (!statusType)
237
- return thread;
238
- return { ...thread, status: statusType };
239
- }
240
- // ─── Item Handlers ────────────────────────────────────────────────
241
- function getLatestTurn(thread) {
242
- return thread.turns[thread.turns.length - 1];
243
- }
244
- function updateLatestTurn(thread, updater) {
245
- const last = getLatestTurn(thread);
246
- if (!last)
247
- return thread;
248
- return {
249
- ...thread,
250
- turns: [...thread.turns.slice(0, -1), updater(last)],
251
- };
252
- }
253
- function addItem(thread, params) {
254
- const itemObj = params.item;
255
- if (!itemObj)
256
- return thread;
257
- const id = typeof itemObj.id === "string" ? itemObj.id : "unknown";
258
- const type = typeof itemObj.type === "string" ? itemObj.type : "unknown";
259
- const status = typeof itemObj.status === "string" ? itemObj.status : "inProgress";
260
- const item = { id, type, status };
261
- if (type === "agentMessage" && typeof itemObj.text === "string") {
262
- item.text = itemObj.text;
263
- }
264
- if (type === "commandExecution") {
265
- const cmd = itemObj.command;
266
- item.command = Array.isArray(cmd) ? cmd.join(" ") : typeof cmd === "string" ? cmd : undefined;
267
- }
268
- if (type === "mcpToolCall") {
269
- const server = typeof itemObj.server === "string" ? itemObj.server : "";
270
- const tool = typeof itemObj.tool === "string" ? itemObj.tool : "";
271
- item.toolName = `${server}/${tool}`;
272
- }
273
- if (type === "dynamicToolCall") {
274
- item.toolName = typeof itemObj.tool === "string" ? itemObj.tool : undefined;
275
- }
276
- return updateLatestTurn(thread, (turn) => ({
277
- ...turn,
278
- items: [...turn.items, item],
279
- }));
280
- }
281
- function completeItem(thread, params) {
282
- const itemObj = params.item;
283
- if (!itemObj)
284
- return thread;
285
- const id = typeof itemObj.id === "string" ? itemObj.id : undefined;
286
- if (!id)
287
- return thread;
288
- const status = typeof itemObj.status === "string" ? itemObj.status : "completed";
289
- const exitCode = typeof itemObj.exitCode === "number" ? itemObj.exitCode : undefined;
290
- const durationMs = typeof itemObj.durationMs === "number" ? itemObj.durationMs : undefined;
291
- const text = typeof itemObj.text === "string" ? itemObj.text : undefined;
292
- const changes = Array.isArray(itemObj.changes) ? itemObj.changes : undefined;
293
- return updateLatestTurn(thread, (turn) => ({
294
- ...turn,
295
- items: turn.items.map((item) => {
296
- if (item.id !== id)
297
- return item;
298
- return {
299
- ...item,
300
- status,
301
- ...(exitCode !== undefined ? { exitCode } : {}),
302
- ...(durationMs !== undefined ? { durationMs } : {}),
303
- ...(text !== undefined ? { text } : {}),
304
- ...(changes !== undefined ? { changes } : {}),
305
- };
306
- }),
307
- }));
308
- }
309
- function appendItemText(thread, params) {
310
- const itemId = typeof params.itemId === "string" ? params.itemId : undefined;
311
- const delta = typeof params.delta === "string" ? params.delta : undefined;
312
- if (!itemId || !delta)
313
- return thread;
314
- return updateLatestTurn(thread, (turn) => ({
315
- ...turn,
316
- items: turn.items.map((item) => item.id === itemId ? { ...item, text: (item.text ?? "") + delta } : item),
317
- }));
318
- }
319
- function appendItemOutput(thread, params) {
320
- const itemId = typeof params.itemId === "string" ? params.itemId : undefined;
321
- const delta = typeof params.delta === "string" ? params.delta : undefined;
322
- if (!itemId || !delta)
323
- return thread;
324
- return updateLatestTurn(thread, (turn) => ({
325
- ...turn,
326
- items: turn.items.map((item) => item.id === itemId ? { ...item, output: (item.output ?? "") + delta } : item),
327
- }));
196
+ return { ...state, tokenUsage: { inputTokens, outputTokens } };
328
197
  }
@@ -133,6 +133,10 @@ export function runPatchRelayMigrations(connection) {
133
133
  addColumnIfMissing(connection, "issues", "pending_merge_prep", "INTEGER NOT NULL DEFAULT 0");
134
134
  // Add merge_prep_attempts for retry budget / escalation
135
135
  addColumnIfMissing(connection, "issues", "merge_prep_attempts", "INTEGER NOT NULL DEFAULT 0");
136
+ // Add review_fix_attempts counter
137
+ addColumnIfMissing(connection, "issues", "review_fix_attempts", "INTEGER NOT NULL DEFAULT 0");
138
+ // Collapse awaiting_review into pr_open (state normalization)
139
+ connection.prepare("UPDATE issues SET factory_state = 'pr_open' WHERE factory_state = 'awaiting_review'").run();
136
140
  }
137
141
  function addColumnIfMissing(connection, table, column, definition) {
138
142
  const cols = connection.prepare(`PRAGMA table_info(${table})`).all();
package/dist/db.js CHANGED
@@ -149,6 +149,10 @@ export class PatchRelayDatabase {
149
149
  sets.push("queue_repair_attempts = @queueRepairAttempts");
150
150
  values.queueRepairAttempts = params.queueRepairAttempts;
151
151
  }
152
+ if (params.reviewFixAttempts !== undefined) {
153
+ sets.push("review_fix_attempts = @reviewFixAttempts");
154
+ values.reviewFixAttempts = params.reviewFixAttempts;
155
+ }
152
156
  if (params.mergePrepAttempts !== undefined) {
153
157
  sets.push("merge_prep_attempts = @mergePrepAttempts");
154
158
  values.mergePrepAttempts = params.mergePrepAttempts;
@@ -389,6 +393,7 @@ function mapIssueRow(row) {
389
393
  ...(row.pr_check_status !== null && row.pr_check_status !== undefined ? { prCheckStatus: String(row.pr_check_status) } : {}),
390
394
  ciRepairAttempts: Number(row.ci_repair_attempts ?? 0),
391
395
  queueRepairAttempts: Number(row.queue_repair_attempts ?? 0),
396
+ reviewFixAttempts: Number(row.review_fix_attempts ?? 0),
392
397
  mergePrepAttempts: Number(row.merge_prep_attempts ?? 0),
393
398
  pendingMergePrep: Boolean(row.pending_merge_prep),
394
399
  };
@@ -5,10 +5,12 @@ export const ACTIVE_RUN_STATES = new Set([
5
5
  "changes_requested",
6
6
  "repairing_queue",
7
7
  ]);
8
- /** Which factory states are terminal (no further transitions possible). */
8
+ /** Which factory states are terminal (no further transitions possible except pr_merged → done). */
9
9
  export const TERMINAL_STATES = new Set([
10
10
  "done",
11
11
  "escalated",
12
+ "failed",
13
+ "awaiting_input",
12
14
  ]);
13
15
  // ─── Semantic guards ─────────────────────────────────────────────
14
16
  //
@@ -158,8 +158,11 @@ export class GitHubWebhookHandler {
158
158
  }
159
159
  }
160
160
  }
161
- // Reset repair counters on new push
162
- if (event.triggerEvent === "pr_synchronize") {
161
+ // Re-read issue after all upserts so reactive run logic sees current state
162
+ const freshIssue = this.db.getIssue(issue.projectId, issue.linearIssueId) ?? issue;
163
+ // Reset repair counters on new push — but only when no repair run is active,
164
+ // since Codex pushes during repair and resetting mid-run would bypass budgets.
165
+ if (event.triggerEvent === "pr_synchronize" && !freshIssue.activeRunId) {
163
166
  this.db.upsertIssue({
164
167
  projectId: issue.projectId,
165
168
  linearIssueId: issue.linearIssueId,
@@ -167,8 +170,6 @@ export class GitHubWebhookHandler {
167
170
  queueRepairAttempts: 0,
168
171
  });
169
172
  }
170
- // Re-read issue after all upserts so reactive run logic sees current state
171
- const freshIssue = this.db.getIssue(issue.projectId, issue.linearIssueId) ?? issue;
172
173
  this.logger.info({ issueKey: issue.issueKey, branchName: event.branchName, triggerEvent: event.triggerEvent, prNumber: event.prNumber }, "GitHub webhook: updated issue PR state");
173
174
  this.feed?.publish({
174
175
  level: event.triggerEvent.includes("failed") ? "warn" : "info",
package/dist/http.js CHANGED
@@ -259,6 +259,14 @@ export async function buildHttpServer(config, service, logger) {
259
259
  }
260
260
  return reply.send({ ok: true, ...result });
261
261
  });
262
+ app.get("/api/issues/:issueKey/timeline", async (request, reply) => {
263
+ const issueKey = request.params.issueKey;
264
+ const result = await service.getIssueTimeline(issueKey);
265
+ if (!result) {
266
+ return reply.code(404).send({ ok: false, reason: "issue_not_found" });
267
+ }
268
+ return reply.send({ ok: true, ...result });
269
+ });
262
270
  app.get("/api/issues/:issueKey/live", async (request, reply) => {
263
271
  const issueKey = request.params.issueKey;
264
272
  const result = await service.getActiveRunStatus(issueKey);
@@ -58,6 +58,29 @@ export class IssueQueryService {
58
58
  })),
59
59
  };
60
60
  }
61
+ async getIssueTimeline(issueKey) {
62
+ const issue = this.db.getTrackedIssueByKey(issueKey);
63
+ if (!issue)
64
+ return undefined;
65
+ const fullIssue = this.db.getIssueByKey(issueKey);
66
+ const runs = this.db.listRunsForIssue(issue.projectId, issue.linearIssueId).map((run) => ({
67
+ id: run.id,
68
+ runType: run.runType,
69
+ status: run.status,
70
+ startedAt: run.startedAt,
71
+ endedAt: run.endedAt,
72
+ threadId: run.threadId,
73
+ ...(run.reportJson ? { report: JSON.parse(run.reportJson) } : {}),
74
+ }));
75
+ const feedEvents = this.db.operatorFeed.list({ issueKey, limit: 500 });
76
+ let liveThread = undefined;
77
+ const activeRunId = fullIssue?.activeRunId;
78
+ const activeRun = activeRunId !== undefined ? runs.find((r) => r.id === activeRunId) : undefined;
79
+ if (activeRun?.threadId) {
80
+ liveThread = await this.codex.readThread(activeRun.threadId, true).catch(() => undefined);
81
+ }
82
+ return { issue, runs, feedEvents, liveThread, activeRunId };
83
+ }
61
84
  async getActiveRunStatus(issueKey) {
62
85
  return await this.runStatusProvider.getActiveRunStatus(issueKey);
63
86
  }
@@ -13,7 +13,6 @@ function describeNextState(state, prNumber) {
13
13
  const prLabel = prNumber ? `PR #${prNumber}` : "the pull request";
14
14
  switch (state) {
15
15
  case "pr_open":
16
- case "awaiting_review":
17
16
  return `${prLabel} is ready for review.`;
18
17
  case "awaiting_queue":
19
18
  return `${prLabel} is approved and back in the merge flow.`;
@@ -149,7 +148,6 @@ export function buildMergePrepEscalationActivity(attempts) {
149
148
  }
150
149
  export function summarizeIssueStateForLinear(issue) {
151
150
  switch (issue.factoryState) {
152
- case "awaiting_review":
153
151
  case "pr_open":
154
152
  return issue.prNumber ? `PR #${issue.prNumber} is awaiting review.` : "Awaiting review.";
155
153
  case "awaiting_queue":
@@ -103,7 +103,6 @@ export class MergeQueue {
103
103
  pendingRunType: "queue_repair",
104
104
  pendingRunContextJson: JSON.stringify({ failureReason: "merge_conflict" }),
105
105
  pendingMergePrep: false,
106
- mergePrepAttempts: 0,
107
106
  });
108
107
  this.enqueueIssue(issue.projectId, issue.linearIssueId);
109
108
  this.feed?.publish({