patchrelay 0.42.0 → 0.44.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,12 @@
1
1
  {
2
2
  "server": {
3
3
  "public_base_url": "https://patchrelay.example.com"
4
+ },
5
+ "runner": {
6
+ "git_bin": "git",
7
+ "codex": {
8
+ "model": "gpt-5.3-codex-spark",
9
+ "reasoning_effort": "high"
10
+ }
4
11
  }
5
12
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "service": "patchrelay",
3
- "version": "0.42.0",
4
- "commit": "ad4fbfa6efc4",
5
- "builtAt": "2026-04-14T14:44:46.179Z"
3
+ "version": "0.44.0",
4
+ "commit": "969e4e3e58e7",
5
+ "builtAt": "2026-04-16T14:04:18.962Z"
6
6
  }
@@ -73,6 +73,7 @@ export function App({ baseUrl, bearerToken, initialIssueKey }) {
73
73
  const filtered = useMemo(() => filterIssues(state.issues, state.filter), [state.issues, state.filter]);
74
74
  const [frozen, setFrozen] = useState(false);
75
75
  const width = Math.max(20, stdout?.columns ?? 80);
76
+ const compact = width < 90;
76
77
  useWatchStream({ baseUrl, bearerToken, dispatch, active: !frozen });
77
78
  useDetailStream({ baseUrl, bearerToken, issueKey: state.activeDetailKey, dispatch, active: !frozen });
78
79
  const [promptMode, setPromptMode] = useState(false);
@@ -400,7 +401,7 @@ export function App({ baseUrl, bearerToken, initialIssueKey }) {
400
401
  : promptStatus
401
402
  ? measureRenderedTextRows(promptStatus, width)
402
403
  : 0);
403
- return (_jsx(Box, { flexDirection: "column", children: state.view === "list" ? (_jsx(IssueListView, { issues: filtered, allIssues: state.issues, selectedIndex: state.selectedIndex, connected: state.connected, lastServerMessageAt: state.lastServerMessageAt, filter: state.filter, totalCount: state.issues.length, frozen: frozen })) : state.view === "detail" ? (_jsxs(Box, { flexDirection: "column", children: [state.activeDetailKey && (_jsxs(Box, { children: [_jsx(Text, { dimColor: true, children: "Issues" }), _jsx(Text, { dimColor: true, children: " \u203A " }), _jsx(Text, { bold: true, children: state.activeDetailKey }), _jsx(Text, { dimColor: true, children: " \u203A " }), _jsx(Text, { dimColor: true, children: state.detailTab === "timeline" ? "Timeline" : "History" })] })), _jsx(IssueDetailView, { issue: state.issues.find((i) => i.issueKey === state.activeDetailKey), timeline: state.timeline, follow: state.follow, scrollOffset: state.detailScrollOffset, unreadBelow: state.detailUnreadBelow, activeRunStartedAt: state.activeRunStartedAt, activeRunId: state.activeRunId, tokenUsage: state.tokenUsage, diffSummary: state.diffSummary, plan: state.plan, issueContext: state.issueContext, detailTab: state.detailTab, rawRuns: state.rawRuns, rawFeedEvents: state.rawFeedEvents, connected: state.connected, lastServerMessageAt: state.lastServerMessageAt, reservedRows: reservedRows, onLayoutChange: (viewportRows, contentRows) => {
404
+ return (_jsx(Box, { flexDirection: "column", children: state.view === "list" ? (_jsx(IssueListView, { issues: filtered, allIssues: state.issues, selectedIndex: state.selectedIndex, connected: state.connected, lastServerMessageAt: state.lastServerMessageAt, filter: state.filter, totalCount: state.issues.length, frozen: frozen, compact: compact })) : state.view === "detail" ? (_jsxs(Box, { flexDirection: "column", children: [state.activeDetailKey && (_jsxs(Box, { children: [_jsx(Text, { dimColor: true, children: "Issues" }), _jsx(Text, { dimColor: true, children: " \u203A " }), _jsx(Text, { bold: true, children: state.activeDetailKey }), _jsx(Text, { dimColor: true, children: " \u203A " }), _jsx(Text, { dimColor: true, children: state.detailTab === "timeline" ? "Timeline" : "History" })] })), _jsx(IssueDetailView, { issue: state.issues.find((i) => i.issueKey === state.activeDetailKey), timeline: state.timeline, follow: state.follow, scrollOffset: state.detailScrollOffset, unreadBelow: state.detailUnreadBelow, activeRunStartedAt: state.activeRunStartedAt, activeRunId: state.activeRunId, tokenUsage: state.tokenUsage, diffSummary: state.diffSummary, plan: state.plan, issueContext: state.issueContext, detailTab: state.detailTab, rawRuns: state.rawRuns, rawFeedEvents: state.rawFeedEvents, connected: state.connected, lastServerMessageAt: state.lastServerMessageAt, compact: compact, reservedRows: reservedRows, onLayoutChange: (viewportRows, contentRows) => {
404
405
  dispatch({ type: "detail-layout-updated", viewportRows, contentRows });
405
406
  } }), promptMode && (_jsx(PromptComposer, { buffer: promptBuffer, cursor: promptCursor })), promptStatus && !promptMode && (_jsx(Text, { dimColor: true, children: promptStatus }))] })) : null }));
406
407
  }
@@ -1,8 +1,11 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { Box, Text } from "ink";
3
- export function buildHelpBarText(view, follow, detailTab) {
3
+ export function buildHelpBarText(view, follow, detailTab, compact = false) {
4
4
  if (view === "detail") {
5
5
  const tabHint = detailTab === "history" ? "t: timeline" : "h: history";
6
+ if (compact) {
7
+ return "j/k: scroll f: live p: prompt y/c/o: copy r: retry s: stop esc: list q: quit";
8
+ }
6
9
  return [
7
10
  tabHint,
8
11
  "j/k: scroll",
@@ -19,9 +22,12 @@ export function buildHelpBarText(view, follow, detailTab) {
19
22
  .filter(Boolean)
20
23
  .join(" ");
21
24
  }
25
+ if (compact) {
26
+ return "Enter: detail Tab: filter x: pause q: quit";
27
+ }
22
28
  return "Enter: detail Tab: filter";
23
29
  }
24
- export function HelpBar({ view, follow, detailTab }) {
25
- const text = buildHelpBarText(view, follow, detailTab);
30
+ export function HelpBar({ view, follow, detailTab, compact = false }) {
31
+ const text = buildHelpBarText(view, follow, detailTab, compact);
26
32
  return (_jsx(Box, { children: _jsx(Text, { dimColor: true, children: text }) }));
27
33
  }
@@ -5,7 +5,7 @@ import { HelpBar, buildHelpBarText } from "./HelpBar.js";
5
5
  import { buildDetailLines } from "./detail-rows.js";
6
6
  import { buildDetailStatusSegments, buildDetailStatusText } from "./detail-status.js";
7
7
  import { measureRenderedTextRows } from "./layout-measure.js";
8
- export function IssueDetailView({ issue, timeline, follow, scrollOffset, unreadBelow, activeRunStartedAt, activeRunId, tokenUsage, diffSummary, plan, issueContext, detailTab, rawRuns, rawFeedEvents, connected, lastServerMessageAt, reservedRows = 0, onLayoutChange, }) {
8
+ export function IssueDetailView({ issue, timeline, follow, scrollOffset, unreadBelow, activeRunStartedAt, activeRunId, tokenUsage, diffSummary, plan, issueContext, detailTab, rawRuns, rawFeedEvents, connected, lastServerMessageAt, reservedRows = 0, compact = false, onLayoutChange, }) {
9
9
  const { stdout } = useStdout();
10
10
  const width = Math.max(20, stdout?.columns ?? 80);
11
11
  const totalRows = stdout?.rows ?? 24;
@@ -17,9 +17,9 @@ export function IssueDetailView({ issue, timeline, follow, scrollOffset, unreadB
17
17
  connected,
18
18
  lastServerMessageAt,
19
19
  }), width);
20
- const helpRows = measureRenderedTextRows(buildHelpBarText("detail", follow, detailTab), width);
20
+ const helpRows = measureRenderedTextRows(buildHelpBarText("detail", follow, detailTab, compact), width);
21
21
  return statusRows + helpRows;
22
- }, [activeRunStartedAt, connected, detailTab, follow, lastServerMessageAt, unreadBelow, width]);
22
+ }, [activeRunStartedAt, connected, detailTab, follow, lastServerMessageAt, unreadBelow, width, compact]);
23
23
  const viewportRows = Math.max(4, totalRows - reservedRows - footerRows);
24
24
  const lines = useMemo(() => {
25
25
  if (!issue) {
@@ -60,7 +60,7 @@ export function IssueDetailView({ issue, timeline, follow, scrollOffset, unreadB
60
60
  const start = Math.min(scrollOffset, maxOffset);
61
61
  const visibleLines = lines.slice(start, start + viewportRows);
62
62
  const fillerCount = Math.max(0, viewportRows - visibleLines.length);
63
- return (_jsxs(Box, { flexDirection: "column", children: [visibleLines.map((line) => (_jsx(RenderedLine, { line: line }, line.key))), Array.from({ length: fillerCount }, (_, index) => (_jsx(Text, { children: " " }, `detail-fill-${index}`))), _jsx(DetailStatusStrip, { follow: follow, unreadBelow: unreadBelow, activeRunStartedAt: activeRunStartedAt, connected: connected, lastServerMessageAt: lastServerMessageAt }), _jsx(HelpBar, { view: "detail", follow: follow, detailTab: detailTab })] }));
63
+ return (_jsxs(Box, { flexDirection: "column", children: [visibleLines.map((line) => (_jsx(RenderedLine, { line: line }, line.key))), Array.from({ length: fillerCount }, (_, index) => (_jsx(Text, { children: " " }, `detail-fill-${index}`))), _jsx(DetailStatusStrip, { follow: follow, unreadBelow: unreadBelow, activeRunStartedAt: activeRunStartedAt, connected: connected, lastServerMessageAt: lastServerMessageAt }), _jsx(HelpBar, { view: "detail", follow: follow, detailTab: detailTab, compact: compact })] }));
64
64
  }
65
65
  function RenderedLine({ line }) {
66
66
  if (line.segments.length === 0) {
@@ -1,4 +1,4 @@
1
- import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
1
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useEffect, useReducer } from "react";
3
3
  import { Box, Text, useStdout } from "ink";
4
4
  import { IssueRow, estimateIssueRowHeight } from "./IssueRow.js";
@@ -6,11 +6,11 @@ import { StatusBar } from "./StatusBar.js";
6
6
  import { HelpBar } from "./HelpBar.js";
7
7
  const FIXED_COLS = 8;
8
8
  const CHROME_ROWS = 4;
9
- export function computeVisibleWindow(issues, selectedIndex, maxRows, cols, titleWidth) {
9
+ export function computeVisibleWindow(issues, selectedIndex, maxRows, cols, titleWidth, compact) {
10
10
  if (issues.length === 0)
11
11
  return { start: 0, end: 0 };
12
12
  const clampedSelected = Math.max(0, Math.min(selectedIndex, issues.length - 1));
13
- const heights = issues.map((issue, index) => estimateIssueRowHeight(issue, index === clampedSelected, cols, titleWidth));
13
+ const heights = issues.map((issue, index) => estimateIssueRowHeight(issue, index === clampedSelected, cols, titleWidth, compact));
14
14
  let start = clampedSelected;
15
15
  let end = clampedSelected + 1;
16
16
  let usedRows = heights[clampedSelected] ?? 1;
@@ -34,12 +34,12 @@ export function computeVisibleWindow(issues, selectedIndex, maxRows, cols, title
34
34
  }
35
35
  return { start, end };
36
36
  }
37
- export function IssueListView({ issues, allIssues, selectedIndex, connected, lastServerMessageAt, filter, totalCount, frozen, }) {
37
+ export function IssueListView({ issues, allIssues, selectedIndex, connected, lastServerMessageAt, filter, totalCount, frozen, compact = false, }) {
38
38
  const { stdout } = useStdout();
39
39
  const cols = stdout?.columns ?? 80;
40
40
  const rows = stdout?.rows ?? 24;
41
- const titleWidth = Math.max(0, cols - FIXED_COLS);
42
- const maxVisibleRows = Math.max(1, rows - CHROME_ROWS);
41
+ const titleWidth = Math.max(0, cols - (compact ? 24 : FIXED_COLS));
42
+ const maxVisibleRows = Math.max(1, rows - (compact ? 3 : CHROME_ROWS));
43
43
  // Periodic refresh for elapsed times
44
44
  const [, tick] = useReducer((c) => c + 1, 0);
45
45
  useEffect(() => {
@@ -48,9 +48,9 @@ export function IssueListView({ issues, allIssues, selectedIndex, connected, las
48
48
  const id = setInterval(tick, 5000);
49
49
  return () => clearInterval(id);
50
50
  }, [frozen]);
51
- const { start: startIndex, end: endIndex } = computeVisibleWindow(issues, selectedIndex, maxVisibleRows, cols, titleWidth);
51
+ const { start: startIndex, end: endIndex } = computeVisibleWindow(issues, selectedIndex, maxVisibleRows, cols, titleWidth, compact);
52
52
  const visible = issues.slice(startIndex, endIndex);
53
53
  const hiddenAbove = startIndex;
54
54
  const hiddenBelow = Math.max(0, issues.length - endIndex);
55
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(StatusBar, { issues: issues, totalCount: totalCount, filter: filter, connected: connected, lastServerMessageAt: lastServerMessageAt, allIssues: allIssues, frozen: frozen ?? false }), _jsx(Box, { marginTop: 1, flexDirection: "column", children: issues.length === 0 ? (_jsx(Text, { dimColor: true, children: "No issues match the current filter." })) : (_jsxs(_Fragment, { children: [hiddenAbove > 0 && _jsxs(Text, { dimColor: true, children: [" ", hiddenAbove, " more above"] }), visible.map((issue, i) => (_jsx(IssueRow, { issue: issue, selected: startIndex + i === selectedIndex, titleWidth: titleWidth }, issue.issueKey ?? `${issue.projectId}-${startIndex + i}`))), hiddenBelow > 0 && _jsxs(Text, { dimColor: true, children: [" ", hiddenBelow, " more below"] })] })) }), _jsx(Box, { marginTop: 1, children: _jsx(HelpBar, { view: "list" }) })] }));
55
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(StatusBar, { issues: issues, totalCount: totalCount, filter: filter, connected: connected, lastServerMessageAt: lastServerMessageAt, allIssues: allIssues, frozen: frozen ?? false, compact: compact }), _jsx(Box, { marginTop: 1, flexDirection: "column", children: issues.length === 0 ? (_jsx(Text, { dimColor: true, children: "No issues match the current filter." })) : (_jsxs(_Fragment, { children: [hiddenAbove > 0 && _jsx(Text, { dimColor: true, children: compact ? `↑${hiddenAbove}` : ` ${hiddenAbove} more above` }), visible.map((issue, i) => (_jsx(IssueRow, { issue: issue, selected: startIndex + i === selectedIndex, titleWidth: titleWidth, compact: compact }, issue.issueKey ?? `${issue.projectId}-${startIndex + i}`))), hiddenBelow > 0 && _jsx(Text, { dimColor: true, children: compact ? `↓${hiddenBelow}` : ` ${hiddenBelow} more below` })] })) }), _jsx(Box, { marginTop: 1, children: _jsx(HelpBar, { view: "list", compact: compact }) })] }));
56
56
  }
@@ -67,7 +67,7 @@ function stageLabel(issue) {
67
67
  }
68
68
  }
69
69
  // ─── Context facts (what matters right now) ─────────────────────
70
- function buildFacts(issue, selected) {
70
+ function buildFacts(issue, selected, compact = false) {
71
71
  const facts = [];
72
72
  const rereviewNeeded = isRereviewNeeded(issue);
73
73
  // PR number
@@ -112,7 +112,7 @@ function buildFacts(issue, selected) {
112
112
  if (issue.blockedByCount > 0) {
113
113
  facts.push({ text: `waiting on ${issue.blockedByKeys.join(", ")}`, color: "yellow" });
114
114
  }
115
- return facts;
115
+ return compact ? facts.slice(0, 3) : facts;
116
116
  }
117
117
  // ─── What's blocking progress ───────────────────────────────────
118
118
  function blockerText(issue) {
@@ -153,34 +153,40 @@ function blockerText(issue) {
153
153
  return null;
154
154
  }
155
155
  // ─── Render ─────────────────────────────────────────────────────
156
- export function IssueRow({ issue, selected, titleWidth }) {
156
+ export function IssueRow({ issue, selected, titleWidth, compact = false, }) {
157
157
  const key = issue.issueKey ?? issue.projectId;
158
158
  const tw = titleWidth ?? 60;
159
159
  const title = issue.title ? truncate(issue.title, tw) : "";
160
- const detail = selected ? summarizeIssueStatusNote(issue.statusNote) : undefined;
161
160
  const session = sessionDisplay(issue);
162
- const facts = buildFacts(issue, selected);
163
- const blocker = selected ? blockerText(issue) : null;
161
+ const facts = buildFacts(issue, selected, compact);
162
+ const blocker = compact || !selected ? null : blockerText(issue);
163
+ const detail = compact || !selected ? undefined : summarizeIssueStatusNote(issue.statusNote);
164
164
  const isTerminal = TERMINAL_STATES.has(effectiveState(issue));
165
165
  // Terminal issues: compact single line
166
166
  if (isTerminal && !selected) {
167
167
  return (_jsxs(Box, { children: [_jsx(Text, { dimColor: true, children: " " }), _jsx(Text, { dimColor: true, children: ` ${key}` }), _jsx(Text, { dimColor: true, children: ` ${relativeTime(issue.updatedAt).padStart(4)}` }), _jsx(Text, { children: ` ` }), _jsx(Text, { color: session.color, children: session.label })] }));
168
168
  }
169
+ if (compact) {
170
+ return (_jsxs(Box, { children: [_jsx(Text, { color: selected ? "blueBright" : "gray", children: selected ? "\u25b8" : " " }), _jsx(Text, { bold: true, children: ` ${key}` }), _jsx(Text, { dimColor: true, children: ` ${relativeTime(issue.updatedAt).padStart(4)}` }), _jsx(Text, { children: ` ` }), _jsx(Text, { color: session.color, children: session.label }), facts.length > 0 && _jsx(Text, { dimColor: true, children: ` · ` }), facts.map((fact, i) => (_jsxs(Text, { children: [i > 0 ? _jsx(Text, { dimColor: true, children: ` ` }) : null, _jsx(Text, { color: fact.color ?? "white", dimColor: !fact.color, children: fact.text })] }, i)))] }));
171
+ }
169
172
  return (_jsxs(Box, { flexDirection: "column", marginBottom: detail ? 1 : 0, children: [_jsxs(Box, { children: [_jsx(Text, { color: selected ? "blueBright" : "gray", children: selected ? "\u25b8" : " " }), _jsx(Text, { bold: true, children: ` ${key}` }), _jsx(Text, { dimColor: true, children: ` ${relativeTime(issue.updatedAt).padStart(4)}` }), _jsx(Text, { children: ` ` }), _jsx(Text, { color: session.color, children: session.label }), facts.length > 0 && (_jsx(Text, { dimColor: true, children: ` \u00b7 ` })), facts.map((fact, i) => (_jsxs(Text, { children: [i > 0 ? _jsx(Text, { dimColor: true, children: ` \u00b7 ` }) : null, _jsx(Text, { color: fact.color ?? "white", dimColor: !fact.color, children: fact.text })] }, i)))] }), title ? (_jsx(Box, { paddingLeft: 2, children: _jsx(Text, { dimColor: true, children: title }) })) : null, blocker ? (_jsx(Box, { paddingLeft: 2, children: _jsx(Text, { color: "yellow", children: blocker }) })) : null, detail ? (_jsx(Box, { paddingLeft: 4, children: _jsx(Text, { dimColor: true, wrap: "wrap", children: detail }) })) : null, selected && issue.factoryState && issue.sessionState ? (_jsx(Box, { paddingLeft: 4, children: _jsx(Text, { dimColor: true, children: `Debug stage: ${stageLabel(issue)}` }) })) : null] }));
170
173
  }
171
- export function estimateIssueRowHeight(issue, selected, cols, titleWidth) {
174
+ export function estimateIssueRowHeight(issue, selected, cols, titleWidth, compact = false) {
172
175
  const width = Math.max(20, cols);
173
176
  const key = issue.issueKey ?? issue.projectId;
174
177
  const tw = titleWidth ?? 60;
175
178
  const title = issue.title ? truncate(issue.title, tw) : "";
176
- const detail = selected ? summarizeIssueStatusNote(issue.statusNote) : undefined;
179
+ const detail = compact || !selected ? undefined : summarizeIssueStatusNote(issue.statusNote);
177
180
  const session = sessionDisplay(issue);
178
- const facts = buildFacts(issue, selected);
179
- const blocker = selected ? blockerText(issue) : null;
181
+ const facts = buildFacts(issue, selected, compact);
182
+ const blocker = compact || !selected ? null : blockerText(issue);
180
183
  const isTerminal = TERMINAL_STATES.has(effectiveState(issue));
181
184
  if (isTerminal && !selected) {
182
185
  return 1;
183
186
  }
187
+ if (compact) {
188
+ return 1;
189
+ }
184
190
  const line1Parts = [
185
191
  `${selected ? "\u25b8" : " "} ${key}`,
186
192
  relativeTime(issue.updatedAt).padStart(4),
@@ -8,7 +8,7 @@ const FILTER_LABELS = {
8
8
  "active": "active",
9
9
  "non-done": "in progress",
10
10
  };
11
- export function StatusBar({ issues, totalCount, filter, connected, lastServerMessageAt, allIssues, frozen, }) {
11
+ export function StatusBar({ issues, totalCount, filter, connected, lastServerMessageAt, allIssues, frozen, compact = false, }) {
12
12
  const showing = filter === "all" ? `${totalCount} issues` : `${issues.length}/${totalCount} issues`;
13
13
  const aggregateSource = filter === "all" ? allIssues : issues;
14
14
  const agg = computeAggregates(aggregateSource);
@@ -17,5 +17,19 @@ export function StatusBar({ issues, totalCount, filter, connected, lastServerMes
17
17
  const intervention = aggregateSource.filter((i) => i.sessionState === "failed" || i.factoryState === "failed" || i.factoryState === "escalated").length;
18
18
  const running = aggregateSource.filter((i) => i.sessionState === "running").length;
19
19
  const idle = aggregateSource.filter((i) => i.sessionState === "idle").length;
20
+ if (compact) {
21
+ const compactParts = [
22
+ withPr > 0 ? `p${withPr}` : null,
23
+ running > 0 ? `r${running}` : null,
24
+ waitingInput > 0 ? `w${waitingInput}` : null,
25
+ intervention > 0 ? `x${intervention}` : null,
26
+ agg.blocked > 0 ? `b${agg.blocked}` : null,
27
+ agg.ready > 0 ? `q${agg.ready}` : null,
28
+ agg.failed > 0 ? `f${agg.failed}` : null,
29
+ agg.done > 0 ? `d${agg.done}` : null,
30
+ frozen ? "frozen" : null,
31
+ ].filter(Boolean);
32
+ return (_jsxs(Box, { justifyContent: "space-between", children: [_jsxs(Box, { gap: 1, children: [_jsx(Text, { bold: true, children: "patchrelay" }), _jsx(Text, { dimColor: true, children: showing }), _jsxs(Text, { dimColor: true, children: ["[", FILTER_LABELS[filter][0], "]"] }), compactParts.length > 0 ? _jsx(Text, { dimColor: true, children: compactParts.join(" ") }) : null] }), _jsx(FreshnessBadge, { connected: connected, lastServerMessageAt: lastServerMessageAt })] }));
33
+ }
20
34
  return (_jsxs(Box, { justifyContent: "space-between", children: [_jsxs(Box, { gap: 1, children: [_jsx(Text, { bold: true, children: showing }), _jsxs(Text, { dimColor: true, children: ["[", FILTER_LABELS[filter], "]"] }), _jsx(Text, { dimColor: true, children: "|" }), running > 0 && _jsxs(Text, { color: "cyan", children: [running, " running"] }), idle > 0 && _jsxs(Text, { color: "blueBright", children: [idle, " idle"] }), agg.ready > 0 && _jsxs(Text, { color: "blueBright", children: [agg.ready, " ready"] }), agg.blocked > 0 && _jsxs(Text, { color: "yellow", children: [agg.blocked, " blocked"] }), withPr > 0 && _jsxs(Text, { dimColor: true, children: [withPr, " PRs"] }), waitingInput > 0 && _jsxs(Text, { color: "yellow", children: [waitingInput, " needs input"] }), intervention > 0 && _jsxs(Text, { color: "red", children: [intervention, " needs help"] }), agg.done > 0 && _jsxs(Text, { color: "green", children: [agg.done, " done"] }), agg.failed > 0 && _jsxs(Text, { color: "red", children: [agg.failed, " failed"] }), frozen && _jsx(Text, { color: "magenta", children: "frozen" })] }), _jsx(FreshnessBadge, { connected: connected, lastServerMessageAt: lastServerMessageAt })] }));
21
35
  }
@@ -43,6 +43,16 @@ export class CodexAppServerClient extends EventEmitter {
43
43
  this.logger = logger;
44
44
  this.spawnProcess = spawnProcess;
45
45
  }
46
+ /**
47
+ * Update runtime codex settings used by future thread/thread-fork calls.
48
+ * This allows service config changes to take effect without restarting.
49
+ */
50
+ setRuntimeConfig(config) {
51
+ this.config = {
52
+ ...this.config,
53
+ ...config,
54
+ };
55
+ }
46
56
  isStarted() {
47
57
  return this.started;
48
58
  }
@@ -149,6 +159,7 @@ export class CodexAppServerClient extends EventEmitter {
149
159
  serviceName: this.config.serviceName ?? "patchrelay",
150
160
  model: this.config.model ?? null,
151
161
  modelProvider: this.config.modelProvider ?? null,
162
+ reasoningEffort: this.config.reasoningEffort ?? null,
152
163
  baseInstructions: this.config.baseInstructions ?? null,
153
164
  developerInstructions: this.config.developerInstructions ?? null,
154
165
  experimentalRawEvents: this.config.experimentalRawEvents ?? false,
@@ -164,6 +175,7 @@ export class CodexAppServerClient extends EventEmitter {
164
175
  sandbox: this.config.sandboxMode,
165
176
  model: this.config.model ?? null,
166
177
  modelProvider: this.config.modelProvider ?? null,
178
+ reasoningEffort: this.config.reasoningEffort ?? null,
167
179
  baseInstructions: this.config.baseInstructions ?? null,
168
180
  developerInstructions: this.config.developerInstructions ?? null,
169
181
  };
@@ -181,6 +193,7 @@ export class CodexAppServerClient extends EventEmitter {
181
193
  sandbox: overrides?.sandboxMode ?? this.config.sandboxMode,
182
194
  model: overrides?.model ?? this.config.model ?? null,
183
195
  modelProvider: overrides?.modelProvider ?? this.config.modelProvider ?? null,
196
+ reasoningEffort: overrides?.reasoningEffort ?? this.config.reasoningEffort ?? null,
184
197
  baseInstructions: overrides?.baseInstructions ?? this.config.baseInstructions ?? null,
185
198
  developerInstructions: overrides?.developerInstructions ?? this.config.developerInstructions ?? null,
186
199
  };
package/dist/config.js CHANGED
@@ -138,6 +138,7 @@ const configSchema = z.object({
138
138
  request_timeout_ms: z.number().int().positive().default(30000),
139
139
  model: z.string().optional(),
140
140
  model_provider: z.string().optional(),
141
+ reasoning_effort: z.enum(["low", "medium", "high"]).optional(),
141
142
  service_name: z.string().default("patchrelay"),
142
143
  base_instructions: z.string().optional(),
143
144
  developer_instructions: z.string().optional(),
@@ -544,6 +545,7 @@ export function loadConfig(configPath = process.env.PATCHRELAY_CONFIG ?? getDefa
544
545
  requestTimeoutMs: parsed.runner.codex.request_timeout_ms,
545
546
  ...(parsed.runner.codex.model ? { model: parsed.runner.codex.model } : {}),
546
547
  ...(parsed.runner.codex.model_provider ? { modelProvider: parsed.runner.codex.model_provider } : {}),
548
+ ...(parsed.runner.codex.reasoning_effort ? { reasoningEffort: parsed.runner.codex.reasoning_effort } : {}),
547
549
  ...(parsed.runner.codex.service_name ? { serviceName: parsed.runner.codex.service_name } : {}),
548
550
  ...(parsed.runner.codex.base_instructions ? { baseInstructions: parsed.runner.codex.base_instructions } : {}),
549
551
  ...(parsed.runner.codex.developer_instructions
@@ -192,6 +192,10 @@ export class IssueStore {
192
192
  sets.push("last_attempted_failure_signature = @lastAttemptedFailureSignature");
193
193
  values.lastAttemptedFailureSignature = params.lastAttemptedFailureSignature;
194
194
  }
195
+ if (params.lastAttemptedFailureAt !== undefined) {
196
+ sets.push("last_attempted_failure_at = @lastAttemptedFailureAt");
197
+ values.lastAttemptedFailureAt = params.lastAttemptedFailureAt;
198
+ }
195
199
  if (params.ciRepairAttempts !== undefined) {
196
200
  sets.push("ci_repair_attempts = @ciRepairAttempts");
197
201
  values.ciRepairAttempts = params.ciRepairAttempts;
@@ -226,7 +230,7 @@ export class IssueStore {
226
230
  last_github_failure_source, last_github_failure_head_sha, last_github_failure_signature, last_github_failure_check_name, last_github_failure_check_url, last_github_failure_context_json, last_github_failure_at,
227
231
  last_github_ci_snapshot_head_sha, last_github_ci_snapshot_gate_check_name, last_github_ci_snapshot_gate_check_status, last_github_ci_snapshot_json, last_github_ci_snapshot_settled_at,
228
232
  last_queue_signal_at, last_queue_incident_json,
229
- last_attempted_failure_head_sha, last_attempted_failure_signature,
233
+ last_attempted_failure_head_sha, last_attempted_failure_signature, last_attempted_failure_at,
230
234
  ci_repair_attempts, queue_repair_attempts, review_fix_attempts, zombie_recovery_attempts, last_zombie_recovery_at,
231
235
  updated_at
232
236
  ) VALUES (
@@ -239,7 +243,7 @@ export class IssueStore {
239
243
  @lastGitHubFailureSource, @lastGitHubFailureHeadSha, @lastGitHubFailureSignature, @lastGitHubFailureCheckName, @lastGitHubFailureCheckUrl, @lastGitHubFailureContextJson, @lastGitHubFailureAt,
240
244
  @lastGitHubCiSnapshotHeadSha, @lastGitHubCiSnapshotGateCheckName, @lastGitHubCiSnapshotGateCheckStatus, @lastGitHubCiSnapshotJson, @lastGitHubCiSnapshotSettledAt,
241
245
  @lastQueueSignalAt, @lastQueueIncidentJson,
242
- @lastAttemptedFailureHeadSha, @lastAttemptedFailureSignature,
246
+ @lastAttemptedFailureHeadSha, @lastAttemptedFailureSignature, @lastAttemptedFailureAt,
243
247
  @ciRepairAttempts, @queueRepairAttempts, @reviewFixAttempts, @zombieRecoveryAttempts, @lastZombieRecoveryAt,
244
248
  @now
245
249
  )
@@ -290,6 +294,7 @@ export class IssueStore {
290
294
  lastQueueIncidentJson: params.lastQueueIncidentJson ?? null,
291
295
  lastAttemptedFailureHeadSha: params.lastAttemptedFailureHeadSha ?? null,
292
296
  lastAttemptedFailureSignature: params.lastAttemptedFailureSignature ?? null,
297
+ lastAttemptedFailureAt: params.lastAttemptedFailureAt ?? null,
293
298
  ciRepairAttempts: params.ciRepairAttempts ?? 0,
294
299
  queueRepairAttempts: params.queueRepairAttempts ?? 0,
295
300
  reviewFixAttempts: params.reviewFixAttempts ?? 0,
@@ -556,6 +561,9 @@ export function mapIssueRow(row) {
556
561
  ...(row.last_attempted_failure_signature !== null && row.last_attempted_failure_signature !== undefined
557
562
  ? { lastAttemptedFailureSignature: String(row.last_attempted_failure_signature) }
558
563
  : {}),
564
+ ...(row.last_attempted_failure_at !== null && row.last_attempted_failure_at !== undefined
565
+ ? { lastAttemptedFailureAt: String(row.last_attempted_failure_at) }
566
+ : {}),
559
567
  ciRepairAttempts: Number(row.ci_repair_attempts ?? 0),
560
568
  queueRepairAttempts: Number(row.queue_repair_attempts ?? 0),
561
569
  reviewFixAttempts: Number(row.review_fix_attempts ?? 0),
@@ -293,6 +293,7 @@ export function runPatchRelayMigrations(connection) {
293
293
  addColumnIfMissing(connection, "issues", "last_queue_incident_json", "TEXT");
294
294
  addColumnIfMissing(connection, "issues", "last_attempted_failure_head_sha", "TEXT");
295
295
  addColumnIfMissing(connection, "issues", "last_attempted_failure_signature", "TEXT");
296
+ addColumnIfMissing(connection, "issues", "last_attempted_failure_at", "TEXT");
296
297
  removeRetiredIssueColumnsIfPresent(connection);
297
298
  }
298
299
  function addColumnIfMissing(connection, table, column, definition) {
@@ -359,6 +360,7 @@ function removeRetiredIssueColumnsIfPresent(connection) {
359
360
  last_queue_incident_json TEXT,
360
361
  last_attempted_failure_head_sha TEXT,
361
362
  last_attempted_failure_signature TEXT,
363
+ last_attempted_failure_at TEXT,
362
364
  ci_repair_attempts INTEGER NOT NULL DEFAULT 0,
363
365
  queue_repair_attempts INTEGER NOT NULL DEFAULT 0,
364
366
  review_fix_attempts INTEGER NOT NULL DEFAULT 0,
@@ -416,6 +418,7 @@ function removeRetiredIssueColumnsIfPresent(connection) {
416
418
  last_queue_incident_json,
417
419
  last_attempted_failure_head_sha,
418
420
  last_attempted_failure_signature,
421
+ last_attempted_failure_at,
419
422
  ci_repair_attempts,
420
423
  queue_repair_attempts,
421
424
  review_fix_attempts,
@@ -471,6 +474,7 @@ function removeRetiredIssueColumnsIfPresent(connection) {
471
474
  last_queue_incident_json,
472
475
  last_attempted_failure_head_sha,
473
476
  last_attempted_failure_signature,
477
+ last_attempted_failure_at,
474
478
  COALESCE(ci_repair_attempts, 0),
475
479
  COALESCE(queue_repair_attempts, 0),
476
480
  COALESCE(review_fix_attempts, 0),
@@ -80,6 +80,7 @@ export async function projectGitHubWebhookState(deps, issue, event, project, lin
80
80
  lastQueueIncidentJson: null,
81
81
  lastAttemptedFailureHeadSha: null,
82
82
  lastAttemptedFailureSignature: null,
83
+ lastAttemptedFailureAt: null,
83
84
  });
84
85
  }
85
86
  deps.logger.info({ issueKey: issue.issueKey, branchName: event.branchName, triggerEvent: event.triggerEvent, prNumber: event.prNumber }, "GitHub webhook: updated issue PR state");
@@ -240,6 +241,7 @@ async function updateGitHubFailureProvenance(deps, issue, event, project, failur
240
241
  lastQueueIncidentJson: null,
241
242
  lastAttemptedFailureHeadSha: null,
242
243
  lastAttemptedFailureSignature: null,
244
+ lastAttemptedFailureAt: null,
243
245
  });
244
246
  }
245
247
  }
@@ -50,8 +50,19 @@ function isDuplicateRepairAttempt(issue, context) {
50
50
  : typeof context?.headSha === "string" ? context.headSha : undefined;
51
51
  if (!signature)
52
52
  return false;
53
- return issue.lastAttemptedFailureSignature === signature
54
- && (headSha === undefined || issue.lastAttemptedFailureHeadSha === headSha);
53
+ if (issue.lastAttemptedFailureSignature !== signature)
54
+ return false;
55
+ if (headSha !== undefined && issue.lastAttemptedFailureHeadSha !== headSha)
56
+ return false;
57
+ // A signature+headSha match alone isn't enough: for queue evictions the PR head
58
+ // doesn't advance (we haven't pushed) and the steward's check name is constant,
59
+ // so a fresh incident after main advances looks identical. Treat the attempt as
60
+ // stale if a newer failure has been observed since it was recorded.
61
+ if (issue.lastAttemptedFailureAt && issue.lastGitHubFailureAt
62
+ && issue.lastGitHubFailureAt > issue.lastAttemptedFailureAt) {
63
+ return false;
64
+ }
65
+ return true;
55
66
  }
56
67
  function buildFailureContext(issue) {
57
68
  const storedFailureContext = parseGitHubFailureContext(issue.lastGitHubFailureContextJson);
@@ -196,6 +207,7 @@ export class IdleIssueReconciler {
196
207
  lastQueueIncidentJson: null,
197
208
  lastAttemptedFailureHeadSha: null,
198
209
  lastAttemptedFailureSignature: null,
210
+ lastAttemptedFailureAt: null,
199
211
  }
200
212
  : {}),
201
213
  });
package/dist/index.js CHANGED
@@ -38,7 +38,7 @@ async function main() {
38
38
  db.runMigrations();
39
39
  const codex = new CodexAppServerClient(config.runner.codex, logger);
40
40
  const linearProvider = new DatabaseBackedLinearClientProvider(config, db, logger);
41
- const service = new PatchRelayService(config, db, codex, linearProvider, logger);
41
+ const service = new PatchRelayService(config, db, codex, linearProvider, logger, configPath);
42
42
  await service.start();
43
43
  const app = await buildHttpServer(config, service, logger);
44
44
  try {
@@ -90,6 +90,7 @@ export class InterruptedRunRecovery {
90
90
  linearIssueId: issue.linearIssueId,
91
91
  lastAttemptedFailureHeadSha: null,
92
92
  lastAttemptedFailureSignature: null,
93
+ lastAttemptedFailureAt: null,
93
94
  });
94
95
  }
95
96
  return true;
@@ -14,10 +14,10 @@ export class IssueSessionLeaseService {
14
14
  this.readThreadWithRetry = readThreadWithRetry;
15
15
  }
16
16
  hasLocalLease(projectId, linearIssueId) {
17
- return this.activeSessionLeases.has(this.issueSessionLeaseKey(projectId, linearIssueId));
17
+ return this.getValidatedLocalLeaseId(projectId, linearIssueId) !== undefined;
18
18
  }
19
19
  getHeldLease(projectId, linearIssueId) {
20
- const leaseId = this.activeSessionLeases.get(this.issueSessionLeaseKey(projectId, linearIssueId));
20
+ const leaseId = this.getValidatedLocalLeaseId(projectId, linearIssueId);
21
21
  if (!leaseId)
22
22
  return undefined;
23
23
  return { projectId, linearIssueId, leaseId };
@@ -133,10 +133,21 @@ export class IssueSessionLeaseService {
133
133
  }
134
134
  release(projectId, linearIssueId) {
135
135
  const key = this.issueSessionLeaseKey(projectId, linearIssueId);
136
- const leaseId = this.activeSessionLeases.get(key);
136
+ const leaseId = this.getValidatedLocalLeaseId(projectId, linearIssueId);
137
137
  this.db.issueSessions.releaseIssueSessionLease(projectId, linearIssueId, leaseId);
138
138
  this.activeSessionLeases.delete(key);
139
139
  }
140
+ getValidatedLocalLeaseId(projectId, linearIssueId) {
141
+ const key = this.issueSessionLeaseKey(projectId, linearIssueId);
142
+ const leaseId = this.activeSessionLeases.get(key);
143
+ if (!leaseId)
144
+ return undefined;
145
+ if (this.db.issueSessions.hasActiveIssueSessionLease(projectId, linearIssueId, leaseId)) {
146
+ return leaseId;
147
+ }
148
+ this.activeSessionLeases.delete(key);
149
+ return undefined;
150
+ }
140
151
  issueSessionLeaseKey(projectId, linearIssueId) {
141
152
  return `${projectId}:${linearIssueId}`;
142
153
  }
@@ -1,4 +1,4 @@
1
- import { resolvePreferredCompletedLinearState, resolvePreferredDeployingLinearState, resolvePreferredHumanNeededLinearState, resolvePreferredImplementingLinearState, resolvePreferredReviewLinearState, resolvePreferredReviewingLinearState, } from "./linear-workflow.js";
1
+ import { resolvePreferredQueuedLinearState, resolvePreferredCompletedLinearState, resolvePreferredDeployingLinearState, resolvePreferredHumanNeededLinearState, resolvePreferredImplementingLinearState, resolvePreferredReviewLinearState, resolvePreferredReviewingLinearState, } from "./linear-workflow.js";
2
2
  import { isCompletedLinearState } from "./pr-state.js";
3
3
  import { hasTrustedNoPrCompletion } from "./trusted-no-pr-completion.js";
4
4
  export async function syncActiveWorkflowState(params) {
@@ -72,10 +72,15 @@ function resolveDesiredActiveWorkflowState(issue, trackedIssue, options, liveIss
72
72
  || trackedIssue?.sessionState === "waiting_input" || trackedIssue?.sessionState === "failed") {
73
73
  return resolvePreferredHumanNeededLinearState(liveIssue);
74
74
  }
75
+ const blocked = (trackedIssue?.blockedByCount ?? 0) > 0;
76
+ const pausedNoPrWork = issue.prNumber === undefined && (!issue.delegatedToPatchRelay || blocked);
77
+ if (pausedNoPrWork) {
78
+ return resolvePreferredQueuedLinearState(liveIssue);
79
+ }
75
80
  const activelyWorking = issue.delegatedToPatchRelay !== false && (issue.activeRunId !== undefined
76
81
  || options?.activeRunType !== undefined
77
82
  || trackedIssue?.sessionState === "running"
78
- || issue.factoryState === "delegated"
83
+ || (issue.factoryState === "delegated" && !blocked && trackedIssue?.readyForExecution !== false)
79
84
  || issue.factoryState === "implementing"
80
85
  || issue.factoryState === "changes_requested"
81
86
  || issue.factoryState === "repairing_ci"
@@ -24,6 +24,16 @@ export function resolvePreferredStartedLinearState(issue) {
24
24
  });
25
25
  return preferred?.name ?? startedStates[0]?.name;
26
26
  }
27
+ export function resolvePreferredQueuedLinearState(issue) {
28
+ return resolvePreferredLinearState(issue, {
29
+ names: ["backlog", "start", "todo", "to do", "planned", "ready"],
30
+ types: ["backlog", "unstarted"],
31
+ fallback: issue.workflowStates.find((state) => {
32
+ const normalizedType = normalizeLinearState(state.type);
33
+ return normalizedType === "backlog" || normalizedType === "unstarted";
34
+ })?.name,
35
+ });
36
+ }
27
37
  export function resolvePreferredImplementingLinearState(issue) {
28
38
  return resolvePreferredLinearState(issue, {
29
39
  names: ["implementing", "in progress", "in-progress", "started", "doing"],
@@ -188,6 +188,7 @@ export async function handleNoPrCompletionCheck(params) {
188
188
  lastQueueIncidentJson: null,
189
189
  lastAttemptedFailureHeadSha: null,
190
190
  lastAttemptedFailureSignature: null,
191
+ lastAttemptedFailureAt: null,
191
192
  });
192
193
  return true;
193
194
  });
@@ -23,6 +23,13 @@ export class ReactiveRunPolicy {
23
23
  return undefined;
24
24
  if (!snapshot.headSha || snapshot.headSha !== issue.lastGitHubFailureHeadSha)
25
25
  return undefined;
26
+ // For queue repairs, the agent's no-op is legitimate when the incident has
27
+ // already self-resolved: GitHub reports the PR as mergeable, so there is no
28
+ // conflict left to push. Only flag as failed when the merge state is still
29
+ // DIRTY after the run — then the agent really did miss the fix.
30
+ if (run.runType === "queue_repair" && !isDirtyMergeStateStatus(snapshot.pr.mergeStateStatus)) {
31
+ return undefined;
32
+ }
26
33
  return `Repair finished but PR #${issue.prNumber} is still on failing head ${issue.lastGitHubFailureHeadSha.slice(0, 8)}`;
27
34
  }
28
35
  catch (error) {
@@ -99,6 +106,7 @@ export class ReactiveRunPolicy {
99
106
  lastQueueIncidentJson: null,
100
107
  lastAttemptedFailureHeadSha: null,
101
108
  lastAttemptedFailureSignature: null,
109
+ lastAttemptedFailureAt: null,
102
110
  lastGitHubCiSnapshotHeadSha: snapshot.headSha ?? null,
103
111
  lastGitHubCiSnapshotGateCheckName: snapshot.gateCheckName,
104
112
  lastGitHubCiSnapshotGateCheckStatus: "pending",
@@ -168,6 +168,7 @@ export class RunFinalizer {
168
168
  lastQueueIncidentJson: null,
169
169
  lastAttemptedFailureHeadSha: null,
170
170
  lastAttemptedFailureSignature: null,
171
+ lastAttemptedFailureAt: null,
171
172
  }
172
173
  : {})),
173
174
  });
@@ -101,6 +101,7 @@ export class RunLauncher {
101
101
  ? {
102
102
  lastAttemptedFailureSignature: failureSignature,
103
103
  lastAttemptedFailureHeadSha: failureHeadSha ?? null,
104
+ lastAttemptedFailureAt: new Date().toISOString(),
104
105
  }
105
106
  : {}),
106
107
  });
@@ -16,6 +16,7 @@ import { RunReconciler } from "./run-reconciler.js";
16
16
  import { RunRecoveryService } from "./run-recovery-service.js";
17
17
  import { RunWakePlanner } from "./run-wake-planner.js";
18
18
  import { getRemainingZombieRecoveryDelayMs } from "./zombie-recovery.js";
19
+ import { loadConfig } from "./config.js";
19
20
  function lowerCaseFirst(value) {
20
21
  return value ? `${value.slice(0, 1).toLowerCase()}${value.slice(1)}` : value;
21
22
  }
@@ -37,6 +38,7 @@ export class RunOrchestrator {
37
38
  enqueueIssue;
38
39
  logger;
39
40
  feed;
41
+ configPath;
40
42
  worktreeManager;
41
43
  /** Tracks last probe-failure feed event per issue to avoid spamming the operator feed. */
42
44
  queueHealthMonitor;
@@ -54,6 +56,7 @@ export class RunOrchestrator {
54
56
  runNotificationHandler;
55
57
  runReconciler;
56
58
  mergedLinearCompletionReconciler;
59
+ codexRuntimeConfig;
57
60
  threadPorts = {
58
61
  readThreadWithRetry: (threadId, maxRetries) => this.readThreadWithRetry(threadId, maxRetries),
59
62
  };
@@ -70,7 +73,7 @@ export class RunOrchestrator {
70
73
  };
71
74
  activeSessionLeases;
72
75
  botIdentity;
73
- constructor(config, db, codex, linearProvider, enqueueIssue, logger, feed) {
76
+ constructor(config, db, codex, linearProvider, enqueueIssue, logger, feed, configPath) {
74
77
  this.config = config;
75
78
  this.db = db;
76
79
  this.codex = codex;
@@ -78,7 +81,9 @@ export class RunOrchestrator {
78
81
  this.enqueueIssue = enqueueIssue;
79
82
  this.logger = logger;
80
83
  this.feed = feed;
84
+ this.configPath = configPath;
81
85
  this.worktreeManager = new WorktreeManager(config);
86
+ this.codexRuntimeConfig = config.runner.codex;
82
87
  this.linearSync = new LinearSessionSync(config, db, linearProvider, logger, feed);
83
88
  this.leaseService = new IssueSessionLeaseService(db, logger, this.workerId, this.threadPorts.readThreadWithRetry);
84
89
  this.activeSessionLeases = this.leaseService.activeSessionLeases;
@@ -100,6 +105,27 @@ export class RunOrchestrator {
100
105
  enqueueIssue: (projectId, issueId) => this.enqueueIssue(projectId, issueId),
101
106
  }, logger, feed);
102
107
  }
108
+ async refreshCodexRuntimeConfig() {
109
+ if (!this.configPath) {
110
+ return;
111
+ }
112
+ try {
113
+ const freshConfig = loadConfig(this.configPath, { profile: "service" });
114
+ if (this.codexRuntimeConfig.model === freshConfig.runner.codex.model &&
115
+ this.codexRuntimeConfig.modelProvider === freshConfig.runner.codex.modelProvider &&
116
+ this.codexRuntimeConfig.reasoningEffort === freshConfig.runner.codex.reasoningEffort) {
117
+ return;
118
+ }
119
+ this.codexRuntimeConfig = freshConfig.runner.codex;
120
+ this.codex.setRuntimeConfig(this.codexRuntimeConfig);
121
+ }
122
+ catch (error) {
123
+ this.logger.warn({
124
+ error: error instanceof Error ? error.message : String(error),
125
+ configPath: this.configPath,
126
+ }, "Failed to reload patchrelay runtime config before run; using previous codex configuration");
127
+ }
128
+ }
103
129
  resolveRunWake(issue) {
104
130
  return this.runWakePlanner.resolveRunWake(issue);
105
131
  }
@@ -111,6 +137,7 @@ export class RunOrchestrator {
111
137
  }
112
138
  // ─── Run ────────────────────────────────────────────────────────
113
139
  async run(item) {
140
+ await this.refreshCodexRuntimeConfig();
114
141
  const project = this.config.projects.find((p) => p.id === item.projectId);
115
142
  if (!project)
116
143
  return;
@@ -38,7 +38,7 @@ export class ServiceStartupRecovery {
38
38
  }
39
39
  }
40
40
  async recoverDelegatedIssueStateFromLinear() {
41
- for (const issue of this.db.issues.listIssuesWithAgentSessions()) {
41
+ for (const issue of this.db.issues.listIssues()) {
42
42
  if (issue.factoryState === "done" || issue.activeRunId !== undefined) {
43
43
  continue;
44
44
  }
@@ -87,7 +87,13 @@ export class ServiceStartupRecovery {
87
87
  const unresolvedBlockers = this.db.issues.countUnresolvedBlockers(issue.projectId, issue.linearIssueId);
88
88
  const latestRun = this.db.runs.getLatestRunForIssue(issue.projectId, issue.linearIssueId);
89
89
  const shouldRecoverPausedLocalWork = delegated
90
- && isResumablePausedLocalWork({ issue, latestRun })
90
+ && isResumablePausedLocalWork({
91
+ issue: {
92
+ ...issue,
93
+ delegatedToPatchRelay: delegated,
94
+ },
95
+ latestRun,
96
+ })
91
97
  && this.db.issueSessions.peekIssueSessionWake(issue.projectId, issue.linearIssueId) === undefined;
92
98
  const updated = this.db.issues.upsertIssue({
93
99
  projectId: issue.projectId,
package/dist/service.js CHANGED
@@ -17,6 +17,7 @@ export class PatchRelayService {
17
17
  db;
18
18
  codex;
19
19
  logger;
20
+ configPath;
20
21
  linearProvider;
21
22
  orchestrator;
22
23
  githubAppTokenManager;
@@ -29,17 +30,18 @@ export class PatchRelayService {
29
30
  issueActions;
30
31
  startupRecovery;
31
32
  trackedIssueListQuery;
32
- constructor(config, db, codex, linearProvider, logger) {
33
+ constructor(config, db, codex, linearProvider, logger, configPath) {
33
34
  this.config = config;
34
35
  this.db = db;
35
36
  this.codex = codex;
36
37
  this.logger = logger;
38
+ this.configPath = configPath;
37
39
  this.linearProvider = toLinearClientProvider(linearProvider);
38
40
  this.feed = new OperatorEventFeed(db.operatorFeed);
39
41
  let enqueueIssue = () => {
40
42
  throw new Error("Service runtime enqueueIssue is not initialized");
41
43
  };
42
- this.orchestrator = new RunOrchestrator(config, db, codex, this.linearProvider, (projectId, issueId) => enqueueIssue(projectId, issueId), logger, this.feed);
44
+ this.orchestrator = new RunOrchestrator(config, db, codex, this.linearProvider, (projectId, issueId) => enqueueIssue(projectId, issueId), logger, this.feed, this.configPath);
43
45
  this.webhookHandler = new WebhookHandler(config, db, this.linearProvider, codex, (projectId, issueId) => enqueueIssue(projectId, issueId), logger, this.feed);
44
46
  this.githubWebhookHandler = new GitHubWebhookHandler(config, db, this.linearProvider, (projectId, issueId) => enqueueIssue(projectId, issueId), logger, codex, this.feed);
45
47
  const runtime = new ServiceRuntime(codex, logger, this.orchestrator, { listIssuesReadyForExecution: () => db.listIssuesReadyForExecution() }, this.webhookHandler, {
@@ -1,4 +1,5 @@
1
1
  import { deriveIssueStatusNote } from "./status-note.js";
2
+ import { LinearSessionSync } from "./linear-session-sync.js";
2
3
  import { trustedActorAllowed } from "./project-resolution.js";
3
4
  import { normalizeWebhook } from "./webhooks.js";
4
5
  import { InstallationWebhookHandler } from "./webhook-installation-handler.js";
@@ -25,6 +26,7 @@ export class WebhookHandler {
25
26
  desiredStageRecorder;
26
27
  contextLoader;
27
28
  dependencyReadinessHandler;
29
+ linearSync;
28
30
  constructor(config, db, linearProvider, codex, enqueueIssue, logger, feed) {
29
31
  this.config = config;
30
32
  this.db = db;
@@ -39,6 +41,7 @@ export class WebhookHandler {
39
41
  this.agentSessionHandler = new AgentSessionHandler(config, db, linearProvider, codex, logger, feed);
40
42
  this.desiredStageRecorder = new DesiredStageRecorder(db, linearProvider, feed);
41
43
  this.contextLoader = new WebhookContextLoader(config, linearProvider);
44
+ this.linearSync = new LinearSessionSync(config, db, linearProvider, logger, feed);
42
45
  this.dependencyReadinessHandler = new DependencyReadinessHandler(db, (projectId, issueId) => this.peekPendingSessionWakeRunType(projectId, issueId));
43
46
  }
44
47
  async processWebhookEvent(webhookEventId) {
@@ -114,6 +117,9 @@ export class WebhookHandler {
114
117
  });
115
118
  const trackedIssue = result.issue;
116
119
  const newlyReadyDependents = this.dependencyReadinessHandler.reconcile(project.id, issue.id);
120
+ const syncTargets = new Set(shouldSyncLinearStateAfterWebhook(hydrated.triggerEvent)
121
+ ? [issue.id, ...newlyReadyDependents]
122
+ : newlyReadyDependents);
117
123
  // Handle issue removal: release active runs, mark as failed.
118
124
  if (hydrated.triggerEvent === "issueRemoved") {
119
125
  await this.issueRemovalHandler.handle({
@@ -172,6 +178,13 @@ export class WebhookHandler {
172
178
  detail: `All blockers are now done for ${dependent?.issueKey ?? dependentIssueId}.`,
173
179
  });
174
180
  }
181
+ for (const issueId of syncTargets) {
182
+ const syncIssue = this.db.getIssue(project.id, issueId);
183
+ if (!syncIssue) {
184
+ continue;
185
+ }
186
+ await this.linearSync.syncSession(syncIssue);
187
+ }
175
188
  }
176
189
  catch (error) {
177
190
  this.db.webhookEvents.markWebhookProcessed(webhookEventId, "failed");
@@ -233,3 +246,10 @@ export class WebhookHandler {
233
246
  return Boolean(statusNote?.endsWith("?"));
234
247
  }
235
248
  }
249
+ function shouldSyncLinearStateAfterWebhook(triggerEvent) {
250
+ return triggerEvent !== "agentSessionCreated"
251
+ && triggerEvent !== "agentPrompted"
252
+ && triggerEvent !== "commentCreated"
253
+ && triggerEvent !== "commentUpdated"
254
+ && triggerEvent !== "commentRemoved";
255
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "patchrelay",
3
- "version": "0.42.0",
3
+ "version": "0.44.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "repository": {