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.
- package/config/patchrelay.example.json +7 -0
- package/dist/build-info.json +3 -3
- package/dist/cli/watch/App.js +2 -1
- package/dist/cli/watch/HelpBar.js +9 -3
- package/dist/cli/watch/IssueDetailView.js +4 -4
- package/dist/cli/watch/IssueListView.js +8 -8
- package/dist/cli/watch/IssueRow.js +16 -10
- package/dist/cli/watch/StatusBar.js +15 -1
- package/dist/codex-app-server.js +13 -0
- package/dist/config.js +2 -0
- package/dist/db/issue-store.js +10 -2
- package/dist/db/migrations.js +4 -0
- package/dist/github-webhook-state-projector.js +2 -0
- package/dist/idle-reconciliation.js +14 -2
- package/dist/index.js +1 -1
- package/dist/interrupted-run-recovery.js +1 -0
- package/dist/issue-session-lease-service.js +14 -3
- package/dist/linear-workflow-state-sync.js +7 -2
- package/dist/linear-workflow.js +10 -0
- package/dist/no-pr-completion-check.js +1 -0
- package/dist/reactive-run-policy.js +8 -0
- package/dist/run-finalizer.js +1 -0
- package/dist/run-launcher.js +1 -0
- package/dist/run-orchestrator.js +28 -1
- package/dist/service-startup-recovery.js +8 -2
- package/dist/service.js +4 -2
- package/dist/webhook-handler.js +20 -0
- package/package.json +1 -1
package/dist/build-info.json
CHANGED
package/dist/cli/watch/App.js
CHANGED
|
@@ -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,
|
|
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 &&
|
|
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)
|
|
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)
|
|
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)
|
|
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
|
}
|
package/dist/codex-app-server.js
CHANGED
|
@@ -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
|
package/dist/db/issue-store.js
CHANGED
|
@@ -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),
|
package/dist/db/migrations.js
CHANGED
|
@@ -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
|
-
|
|
54
|
-
|
|
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 {
|
|
@@ -14,10 +14,10 @@ export class IssueSessionLeaseService {
|
|
|
14
14
|
this.readThreadWithRetry = readThreadWithRetry;
|
|
15
15
|
}
|
|
16
16
|
hasLocalLease(projectId, linearIssueId) {
|
|
17
|
-
return this.
|
|
17
|
+
return this.getValidatedLocalLeaseId(projectId, linearIssueId) !== undefined;
|
|
18
18
|
}
|
|
19
19
|
getHeldLease(projectId, linearIssueId) {
|
|
20
|
-
const leaseId = this.
|
|
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.
|
|
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"
|
package/dist/linear-workflow.js
CHANGED
|
@@ -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"],
|
|
@@ -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",
|
package/dist/run-finalizer.js
CHANGED
package/dist/run-launcher.js
CHANGED
package/dist/run-orchestrator.js
CHANGED
|
@@ -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.
|
|
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({
|
|
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, {
|
package/dist/webhook-handler.js
CHANGED
|
@@ -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
|
+
}
|