patchrelay 0.32.0 → 0.32.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "service": "patchrelay",
3
- "version": "0.32.0",
4
- "commit": "21ba6968b0ff",
5
- "builtAt": "2026-04-01T11:53:53.815Z"
3
+ "version": "0.32.1",
4
+ "commit": "c48953273955",
5
+ "builtAt": "2026-04-01T21:29:18.620Z"
6
6
  }
@@ -24,7 +24,13 @@ export async function handleWatchCommand(params) {
24
24
  const issueKey = typeof params.parsed.flags.get("issue") === "string"
25
25
  ? String(params.parsed.flags.get("issue"))
26
26
  : undefined;
27
- const instance = render(createElement(App, { baseUrl, bearerToken, initialIssueKey: issueKey }), { stdout: process.stderr, stdin: process.stdin, patchConsole: false });
28
- await instance.waitUntilExit();
29
- return 0;
27
+ process.stderr.write("\u001b[?1049h\u001b[2J\u001b[H");
28
+ try {
29
+ const instance = render(createElement(App, { baseUrl, bearerToken, initialIssueKey: issueKey }), { stdout: process.stderr, stdin: process.stdin, patchConsole: false });
30
+ await instance.waitUntilExit();
31
+ return 0;
32
+ }
33
+ finally {
34
+ process.stderr.write("\u001b[?1049l");
35
+ }
30
36
  }
@@ -68,9 +68,10 @@ export function App({ baseUrl, bearerToken, initialIssueKey }) {
68
68
  ...(initialIssueKey ? { view: "detail", activeDetailKey: initialIssueKey } : {}),
69
69
  });
70
70
  const filtered = useMemo(() => filterIssues(state.issues, state.filter), [state.issues, state.filter]);
71
- useWatchStream({ baseUrl, bearerToken, dispatch });
72
- useDetailStream({ baseUrl, bearerToken, issueKey: state.activeDetailKey, dispatch });
73
- useFeedStream({ baseUrl, bearerToken, active: state.view === "feed", dispatch });
71
+ const [frozen, setFrozen] = useState(false);
72
+ useWatchStream({ baseUrl, bearerToken, dispatch, active: !frozen });
73
+ useDetailStream({ baseUrl, bearerToken, issueKey: state.activeDetailKey, dispatch, active: !frozen });
74
+ useFeedStream({ baseUrl, bearerToken, active: state.view === "feed" && !frozen, dispatch });
74
75
  const [promptMode, setPromptMode] = useState(false);
75
76
  const [promptBuffer, setPromptBuffer] = useState("");
76
77
  const handleRetry = useCallback(() => {
@@ -133,6 +134,10 @@ export function App({ baseUrl, bearerToken, initialIssueKey }) {
133
134
  exit();
134
135
  return;
135
136
  }
137
+ if (input === "x") {
138
+ setFrozen((value) => !value);
139
+ return;
140
+ }
136
141
  if (state.view === "list") {
137
142
  if (input === "j" || key.downArrow) {
138
143
  dispatch({ type: "select", index: state.selectedIndex + 1 });
@@ -197,5 +202,5 @@ export function App({ baseUrl, bearerToken, initialIssueKey }) {
197
202
  }
198
203
  }
199
204
  });
200
- 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 })) : 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, activeRunStartedAt: state.activeRunStartedAt, activeRunId: state.activeRunId, tokenUsage: state.tokenUsage, diffSummary: state.diffSummary, plan: state.plan, issueContext: state.issueContext, detailTab: state.detailTab, timelineMode: state.timelineMode, rawRuns: state.rawRuns, rawFeedEvents: state.rawFeedEvents, connected: state.connected, lastServerMessageAt: state.lastServerMessageAt }), promptMode && (_jsxs(Box, { children: [_jsx(Text, { color: "yellow", children: "prompt> " }), _jsx(Text, { children: promptBuffer }), _jsx(Text, { dimColor: true, children: "_" })] })), promptStatus && !promptMode && (_jsx(Text, { dimColor: true, children: promptStatus }))] })) : (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { dimColor: true, children: "Issues" }), _jsx(Text, { dimColor: true, children: " \u203A " }), _jsx(Text, { bold: true, children: "Operator Feed" })] }), _jsx(FeedView, { events: state.feedEvents, connected: state.connected, lastServerMessageAt: state.lastServerMessageAt })] })) }));
205
+ 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, activeRunStartedAt: state.activeRunStartedAt, activeRunId: state.activeRunId, tokenUsage: state.tokenUsage, diffSummary: state.diffSummary, plan: state.plan, issueContext: state.issueContext, detailTab: state.detailTab, timelineMode: state.timelineMode, rawRuns: state.rawRuns, rawFeedEvents: state.rawFeedEvents, connected: state.connected, lastServerMessageAt: state.lastServerMessageAt }), promptMode && (_jsxs(Box, { children: [_jsx(Text, { color: "yellow", children: "prompt> " }), _jsx(Text, { children: promptBuffer }), _jsx(Text, { dimColor: true, children: "_" })] })), promptStatus && !promptMode && (_jsx(Text, { dimColor: true, children: promptStatus }))] })) : (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { dimColor: true, children: "Issues" }), _jsx(Text, { dimColor: true, children: " \u203A " }), _jsx(Text, { bold: true, children: "Operator Feed" })] }), _jsx(FeedView, { events: state.feedEvents, connected: state.connected, lastServerMessageAt: state.lastServerMessageAt })] })) }));
201
206
  }
@@ -1,7 +1,7 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { Box, Text } from "ink";
3
3
  const HELP_TEXT = {
4
- list: "j/k: navigate Enter: detail F: feed Tab: filter q: quit",
4
+ list: "j/k: navigate Enter: detail F: feed Tab: filter x: freeze q: quit",
5
5
  detail: "",
6
6
  feed: "Esc: list q: quit",
7
7
  };
@@ -10,7 +10,7 @@ export function HelpBar({ view, follow, detailTab, timelineMode }) {
10
10
  if (view === "detail") {
11
11
  const tabHint = detailTab === "history" ? "t: timeline" : "h: history";
12
12
  const timelineHint = detailTab === "timeline" ? `v: ${timelineMode === "verbose" ? "compact" : "verbose"}` : undefined;
13
- text = [tabHint, timelineHint, "j/k: prev/next", "Esc: list", `f: follow ${follow ? "on" : "off"}`, "p: prompt", "s: stop", "r: retry", "q: quit"]
13
+ text = [tabHint, timelineHint, "j/k: prev/next", "Esc: list", `f: follow ${follow ? "on" : "off"}`, "x: freeze", "p: prompt", "s: stop", "r: retry", "q: quit"]
14
14
  .filter(Boolean)
15
15
  .join(" ");
16
16
  }
@@ -18,6 +18,92 @@ function formatTokens(n) {
18
18
  return `${(n / 1_000).toFixed(1)}k`;
19
19
  return String(n);
20
20
  }
21
+ function formatReviewState(reviewState) {
22
+ switch (reviewState) {
23
+ case "approved":
24
+ return "approved";
25
+ case "changes_requested":
26
+ return "changes requested";
27
+ case "commented":
28
+ return "commented";
29
+ default:
30
+ return reviewState ? reviewState.replaceAll("_", " ") : null;
31
+ }
32
+ }
33
+ function formatCheckState(checkState) {
34
+ switch (checkState) {
35
+ case "passed":
36
+ case "success":
37
+ return "checks passed";
38
+ case "failed":
39
+ case "failure":
40
+ return "checks failed";
41
+ case "pending":
42
+ case "in_progress":
43
+ case "queued":
44
+ return "checks pending";
45
+ default:
46
+ return null;
47
+ }
48
+ }
49
+ function buildPrStatusSummary(issue, issueContext) {
50
+ if (issue.prNumber === undefined)
51
+ return [];
52
+ const summary = [`PR #${issue.prNumber}`];
53
+ const checkState = formatCheckState(issue.prCheckStatus);
54
+ const reviewState = formatReviewState(issue.prReviewState);
55
+ const failedCheck = issueContext?.latestFailureCheckName ?? issue.latestFailureCheckName;
56
+ if (checkState === "checks failed" && failedCheck) {
57
+ summary.push(`${failedCheck} failed`);
58
+ }
59
+ else if (checkState) {
60
+ summary.push(checkState);
61
+ }
62
+ if (reviewState) {
63
+ summary.push(`review ${reviewState}`);
64
+ }
65
+ else if (issue.factoryState === "pr_open" || issue.factoryState === "repairing_ci" || issue.factoryState === "awaiting_queue") {
66
+ summary.push("review pending");
67
+ }
68
+ if (issue.factoryState === "awaiting_queue") {
69
+ summary.push("queued for merge");
70
+ }
71
+ else if (issue.factoryState === "repairing_queue") {
72
+ summary.push("merge queue repair needed");
73
+ }
74
+ else if (issue.factoryState === "done") {
75
+ summary.push("merged");
76
+ }
77
+ else if (issue.prCheckStatus === "failed" || issue.prReviewState === undefined || issue.prReviewState === "changes_requested") {
78
+ summary.push("not mergeable");
79
+ }
80
+ return summary;
81
+ }
82
+ function resolvePrimaryBlocker(issue, issueContext) {
83
+ if (issue.blockedByCount > 0) {
84
+ return {
85
+ text: `Waiting on blockers: ${issue.blockedByKeys.join(", ")}`,
86
+ color: "yellow",
87
+ };
88
+ }
89
+ if (issue.prCheckStatus === "failed") {
90
+ const failedCheck = issueContext?.latestFailureCheckName ?? issue.latestFailureCheckName;
91
+ return {
92
+ text: failedCheck ? `Blocked by failed check: ${failedCheck}` : "Blocked by failed PR checks",
93
+ color: "red",
94
+ };
95
+ }
96
+ if (issue.prReviewState === "changes_requested") {
97
+ return { text: "Blocked by requested review changes", color: "yellow" };
98
+ }
99
+ if (issue.prNumber !== undefined && !issue.prReviewState && issue.factoryState !== "done") {
100
+ return { text: "Blocked pending review approval", color: "yellow" };
101
+ }
102
+ if (issue.factoryState === "awaiting_queue") {
103
+ return { text: "Waiting in merge queue", color: "yellow" };
104
+ }
105
+ return null;
106
+ }
21
107
  function ElapsedTime({ startedAt }) {
22
108
  const [, tick] = useReducer((c) => c + 1, 0);
23
109
  useEffect(() => {
@@ -44,5 +130,7 @@ export function IssueDetailView({ issue, timeline, follow, activeRunStartedAt, a
44
130
  const history = useMemo(() => buildStateHistory(rawRuns, rawFeedEvents, issue.factoryState, activeRunId), [rawRuns, rawFeedEvents, issue.factoryState, activeRunId]);
45
131
  const graph = useMemo(() => buildPatchRelayStateGraph(history, issue.factoryState), [history, issue.factoryState]);
46
132
  const queueObservations = useMemo(() => buildPatchRelayQueueObservations(issue, rawFeedEvents), [issue, rawFeedEvents]);
47
- return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { gap: 2, children: [_jsx(Text, { bold: true, children: key }), _jsx(Text, { color: "cyan", children: issue.factoryState }), issue.blockedByCount > 0 && _jsxs(Text, { color: "yellow", children: ["blocked by ", issue.blockedByKeys.join(", ")] }), issue.readyForExecution && !issue.activeRunType && issue.blockedByCount === 0 && _jsx(Text, { color: "blueBright", children: "ready" }), issue.activeRunType && _jsx(Text, { color: "yellow", children: issue.activeRunType }), issue.prNumber !== undefined && _jsxs(Text, { dimColor: true, children: ["#", issue.prNumber] }), activeRunStartedAt && _jsx(ElapsedTime, { startedAt: activeRunStartedAt }), meta.length > 0 && _jsx(Text, { dimColor: true, children: meta.join(" ") }), detailTab === "timeline" && _jsx(Text, { dimColor: true, children: timelineMode }), follow && _jsx(Text, { color: "yellow", children: "follow" }), _jsx(FreshnessBadge, { connected: connected, lastServerMessageAt: lastServerMessageAt })] }), issue.title && _jsx(Text, { children: issue.title }), issueContext?.latestFailureSummary && (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: issueContext.latestFailureSource === "queue_eviction" ? "yellow" : "red", children: ["Latest failure: ", issueContext.latestFailureSummary, issueContext.latestFailureHeadSha ? ` @ ${issueContext.latestFailureHeadSha.slice(0, 8)}` : ""] }) })), detailTab === "timeline" ? (_jsxs(_Fragment, { children: [plan && plan.length > 0 && (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: "Plan" }), _jsx(Text, { children: progressBar(plan.filter((s) => s.status === "completed").length, plan.length, 16) }), _jsxs(Text, { dimColor: true, children: [plan.filter((s) => s.status === "completed").length, "/", plan.length] })] }), plan.map((entry, i) => (_jsxs(Box, { gap: 1, children: [_jsxs(Text, { color: planStepColor(entry.status), children: ["[", planStepSymbol(entry.status), "]"] }), _jsx(Text, { children: entry.step })] }, `plan-${i}`)))] })), _jsx(Box, { marginTop: 1, flexDirection: "column", children: _jsx(Timeline, { entries: timeline, follow: follow, mode: timelineMode }) })] })) : (_jsxs(_Fragment, { children: [_jsx(FactoryStateGraph, { main: graph.main, prLoops: graph.prLoops, queueLoop: graph.queueLoop, exits: graph.exits }), _jsx(QueueObservationView, { observations: queueObservations }), _jsx(Box, { marginTop: 1, children: _jsx(StateHistoryView, { history: history, plan: plan, activeRunId: activeRunId }) })] })), _jsx(Box, { marginTop: 1, children: _jsx(HelpBar, { view: "detail", follow: follow, detailTab: detailTab, timelineMode: timelineMode }) })] }));
133
+ const prStatusSummary = useMemo(() => buildPrStatusSummary(issue, issueContext), [issue, issueContext]);
134
+ const primaryBlocker = useMemo(() => resolvePrimaryBlocker(issue, issueContext), [issue, issueContext]);
135
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { gap: 2, children: [_jsx(Text, { bold: true, children: key }), _jsx(Text, { color: "cyan", children: issue.factoryState }), issue.blockedByCount > 0 && _jsxs(Text, { color: "yellow", children: ["blocked by ", issue.blockedByKeys.join(", ")] }), issue.readyForExecution && !issue.activeRunType && issue.blockedByCount === 0 && _jsx(Text, { color: "blueBright", children: "ready" }), issue.activeRunType && _jsx(Text, { color: "yellow", children: issue.activeRunType }), issue.prNumber !== undefined && _jsxs(Text, { dimColor: true, children: ["#", issue.prNumber] }), activeRunStartedAt && _jsx(ElapsedTime, { startedAt: activeRunStartedAt }), meta.length > 0 && _jsx(Text, { dimColor: true, children: meta.join(" ") }), detailTab === "timeline" && _jsx(Text, { dimColor: true, children: timelineMode }), follow && _jsx(Text, { color: "yellow", children: "follow" }), _jsx(FreshnessBadge, { connected: connected, lastServerMessageAt: lastServerMessageAt })] }), issue.title && _jsx(Text, { children: issue.title }), prStatusSummary.length > 0 && (_jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: prStatusSummary.join(" | ") }) })), primaryBlocker && (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: primaryBlocker.color, children: ["Blocked by: ", primaryBlocker.text] }) })), issueContext?.latestFailureSummary && (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: issueContext.latestFailureSource === "queue_eviction" ? "yellow" : "red", children: ["Latest failure: ", issueContext.latestFailureSummary, issueContext.latestFailureHeadSha ? ` @ ${issueContext.latestFailureHeadSha.slice(0, 8)}` : ""] }) })), detailTab === "timeline" ? (_jsxs(_Fragment, { children: [plan && plan.length > 0 && (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: "Plan" }), _jsx(Text, { children: progressBar(plan.filter((s) => s.status === "completed").length, plan.length, 16) }), _jsxs(Text, { dimColor: true, children: [plan.filter((s) => s.status === "completed").length, "/", plan.length] })] }), plan.map((entry, i) => (_jsxs(Box, { gap: 1, children: [_jsxs(Text, { color: planStepColor(entry.status), children: ["[", planStepSymbol(entry.status), "]"] }), _jsx(Text, { children: entry.step })] }, `plan-${i}`)))] })), _jsx(Box, { marginTop: 1, flexDirection: "column", children: _jsx(Timeline, { entries: timeline, follow: follow, mode: timelineMode }) })] })) : (_jsxs(_Fragment, { children: [_jsx(FactoryStateGraph, { main: graph.main, prLoops: graph.prLoops, queueLoop: graph.queueLoop, exits: graph.exits }), _jsx(QueueObservationView, { observations: queueObservations }), _jsx(Box, { marginTop: 1, children: _jsx(StateHistoryView, { history: history, plan: plan, activeRunId: activeRunId }) })] })), _jsx(Box, { marginTop: 1, children: _jsx(HelpBar, { view: "detail", follow: follow, detailTab: detailTab, timelineMode: timelineMode }) })] }));
48
136
  }
@@ -7,7 +7,7 @@ import { HelpBar } from "./HelpBar.js";
7
7
  // selector(2) + key(10) + status(13) + pr(7) + ago(4) + gaps = ~36
8
8
  const FIXED_COLS = 40;
9
9
  const CHROME_ROWS = 4;
10
- export function IssueListView({ issues, allIssues, selectedIndex, connected, lastServerMessageAt, filter, totalCount, }) {
10
+ export function IssueListView({ issues, allIssues, selectedIndex, connected, lastServerMessageAt, filter, totalCount, frozen, }) {
11
11
  const { stdout } = useStdout();
12
12
  const cols = stdout?.columns ?? 80;
13
13
  const rows = stdout?.rows ?? 24;
@@ -15,7 +15,12 @@ export function IssueListView({ issues, allIssues, selectedIndex, connected, las
15
15
  const maxVisible = Math.max(1, rows - CHROME_ROWS);
16
16
  // Periodic refresh for elapsed times
17
17
  const [, tick] = useReducer((c) => c + 1, 0);
18
- useEffect(() => { const id = setInterval(tick, 5000); return () => clearInterval(id); }, []);
18
+ useEffect(() => {
19
+ if (frozen)
20
+ return;
21
+ const id = setInterval(tick, 5000);
22
+ return () => clearInterval(id);
23
+ }, [frozen]);
19
24
  let startIndex = 0;
20
25
  if (issues.length > maxVisible) {
21
26
  startIndex = Math.max(0, Math.min(selectedIndex - Math.floor(maxVisible / 2), issues.length - maxVisible));
@@ -23,5 +28,5 @@ export function IssueListView({ issues, allIssues, selectedIndex, connected, las
23
28
  const visible = issues.slice(startIndex, startIndex + maxVisible);
24
29
  const hiddenAbove = startIndex;
25
30
  const hiddenBelow = Math.max(0, issues.length - startIndex - maxVisible);
26
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(StatusBar, { issues: issues, totalCount: totalCount, filter: filter, connected: connected, lastServerMessageAt: lastServerMessageAt, allIssues: allIssues }), _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" }) })] }));
31
+ 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" }) })] }));
27
32
  }
@@ -44,12 +44,65 @@ function formatPr(issue) {
44
44
  if (!issue.prNumber)
45
45
  return "";
46
46
  const parts = [`#${issue.prNumber}`];
47
- if (issue.prReviewState === "approved")
48
- parts.push("\u2713");
49
- else if (issue.prReviewState === "changes_requested")
50
- parts.push("\u2717");
47
+ const review = formatReviewState(issue.prReviewState);
48
+ const checks = formatCheckState(issue.prCheckStatus);
49
+ const merge = formatMergeState(issue);
50
+ if (review)
51
+ parts.push(review);
52
+ if (checks)
53
+ parts.push(checks);
54
+ if (merge)
55
+ parts.push(merge);
51
56
  return parts.join("");
52
57
  }
58
+ function formatReviewState(reviewState) {
59
+ switch (reviewState) {
60
+ case "approved":
61
+ return "rev:+";
62
+ case "changes_requested":
63
+ return "rev:x";
64
+ case "commented":
65
+ return "rev:c";
66
+ case "dismissed":
67
+ return "rev:-";
68
+ default:
69
+ return null;
70
+ }
71
+ }
72
+ function formatCheckState(checkState) {
73
+ switch (checkState) {
74
+ case "passed":
75
+ case "success":
76
+ return "ci:+";
77
+ case "failed":
78
+ case "failure":
79
+ return "ci:x";
80
+ case "pending":
81
+ case "in_progress":
82
+ case "queued":
83
+ return "ci:…";
84
+ default:
85
+ return null;
86
+ }
87
+ }
88
+ function formatMergeState(issue) {
89
+ if (!issue.prNumber)
90
+ return null;
91
+ switch (issue.factoryState) {
92
+ case "awaiting_queue":
93
+ return "queue";
94
+ case "repairing_queue":
95
+ return "mq-fix";
96
+ case "done":
97
+ return "merged";
98
+ case "pr_open":
99
+ if (issue.prReviewState === "approved" && issue.prCheckStatus === "passed")
100
+ return "ready";
101
+ return "open";
102
+ default:
103
+ return null;
104
+ }
105
+ }
53
106
  function relativeTime(iso) {
54
107
  const ms = Date.now() - new Date(iso).getTime();
55
108
  if (ms < 0)
@@ -97,5 +150,5 @@ export function IssueRow({ issue, selected, titleWidth }) {
97
150
  const tw = titleWidth ?? 30;
98
151
  const title = issue.title ? truncate(issue.title, tw) : "";
99
152
  const detail = selected ? summarizeIssueStatusNote(issue.statusNote) : undefined;
100
- return (_jsxs(Box, { flexDirection: "column", marginBottom: detail ? 1 : 0, children: [_jsxs(Box, { children: [_jsx(Text, { color: selected ? "blueBright" : "white", bold: selected, children: selected ? "\u25b8" : " " }), _jsx(Text, { bold: true, children: ` ${key.padEnd(9)}` }), _jsx(Text, { color: stateColor(issue.blockedByCount > 0 && !issue.activeRunType ? "blocked" : issue.readyForExecution && !issue.activeRunType ? "ready" : issue.factoryState), children: ` ${status.padEnd(12)}` }), _jsx(Text, { dimColor: true, children: ` ${pr.padEnd(6)}` }), _jsx(Text, { dimColor: true, children: ` ${ago.padStart(3)}` }), title ? _jsx(Text, { dimColor: true, children: ` ${title}` }) : null] }), detail ? (_jsx(Box, { paddingLeft: 4, children: _jsx(Text, { dimColor: true, wrap: "wrap", children: detail }) })) : null] }));
153
+ return (_jsxs(Box, { flexDirection: "column", marginBottom: detail ? 1 : 0, children: [_jsxs(Box, { children: [_jsx(Text, { color: selected ? "blueBright" : "white", bold: selected, children: selected ? "\u25b8" : " " }), _jsx(Text, { bold: true, children: ` ${key.padEnd(9)}` }), _jsx(Text, { color: stateColor(issue.blockedByCount > 0 && !issue.activeRunType ? "blocked" : issue.readyForExecution && !issue.activeRunType ? "ready" : issue.factoryState), children: ` ${status.padEnd(12)}` }), _jsx(Text, { dimColor: true, children: ` ${pr.padEnd(26)}` }), _jsx(Text, { dimColor: true, children: ` ${ago.padStart(3)}` }), title ? _jsx(Text, { dimColor: true, children: ` ${title}` }) : null] }), detail ? (_jsx(Box, { paddingLeft: 4, children: _jsx(Text, { dimColor: true, wrap: "wrap", children: detail }) })) : null] }));
101
154
  }
@@ -7,10 +7,10 @@ const FILTER_LABELS = {
7
7
  "active": "active",
8
8
  "non-done": "in progress",
9
9
  };
10
- export function StatusBar({ issues, totalCount, filter, connected, lastServerMessageAt, allIssues, }) {
10
+ export function StatusBar({ issues, totalCount, filter, connected, lastServerMessageAt, allIssues, frozen, }) {
11
11
  const showing = filter === "all" ? `${totalCount} issues` : `${issues.length}/${totalCount} issues`;
12
12
  const agg = computeAggregates(allIssues);
13
13
  const withPr = allIssues.filter((i) => i.prNumber !== undefined).length;
14
14
  const awaitingInput = allIssues.filter((i) => i.factoryState === "awaiting_input").length;
15
- 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: "|" }), agg.active > 0 && _jsxs(Text, { color: "cyan", children: [agg.active, " active"] }), 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"] }), awaitingInput > 0 && _jsxs(Text, { color: "yellow", children: [awaitingInput, " awaiting input"] }), agg.done > 0 && _jsxs(Text, { color: "green", children: [agg.done, " done"] }), agg.failed > 0 && _jsxs(Text, { color: "red", children: [agg.failed, " failed"] })] }), _jsx(FreshnessBadge, { connected: connected, lastServerMessageAt: lastServerMessageAt })] }));
15
+ 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: "|" }), agg.active > 0 && _jsxs(Text, { color: "cyan", children: [agg.active, " active"] }), 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"] }), awaitingInput > 0 && _jsxs(Text, { color: "yellow", children: [awaitingInput, " awaiting input"] }), 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 })] }));
16
16
  }
@@ -3,7 +3,9 @@ export function useDetailStream(options) {
3
3
  const optionsRef = useRef(options);
4
4
  optionsRef.current = options;
5
5
  useEffect(() => {
6
- const { issueKey } = optionsRef.current;
6
+ const { issueKey, active } = optionsRef.current;
7
+ if (active === false)
8
+ return;
7
9
  if (!issueKey)
8
10
  return;
9
11
  const abortController = new AbortController();
@@ -19,7 +21,7 @@ export function useDetailStream(options) {
19
21
  return () => {
20
22
  abortController.abort();
21
23
  };
22
- }, [options.issueKey]);
24
+ }, [options.issueKey, options.active]);
23
25
  }
24
26
  // ─── Rehydration ──────────────────────────────────────────────────
25
27
  async function rehydrate(baseUrl, issueKey, headers, signal, dispatch) {
@@ -3,6 +3,8 @@ export function useWatchStream(options) {
3
3
  const optionsRef = useRef(options);
4
4
  optionsRef.current = options;
5
5
  useEffect(() => {
6
+ if (options.active === false)
7
+ return;
6
8
  let abortController = new AbortController();
7
9
  let reconnectTimeout;
8
10
  let attempt = 0;
@@ -110,7 +112,7 @@ export function useWatchStream(options) {
110
112
  }
111
113
  clearInterval(snapshotInterval);
112
114
  };
113
- }, []);
115
+ }, [options.active]);
114
116
  }
115
117
  function processEvent(dispatch, eventType, data) {
116
118
  try {
@@ -122,6 +122,27 @@ export class LinearInstallationStore {
122
122
  .all();
123
123
  return rows.map((row) => mapProjectInstallation(row));
124
124
  }
125
+ repairProjectInstallations(projectIds) {
126
+ const repairs = [];
127
+ for (const projectId of projectIds) {
128
+ const existing = this.getProjectInstallation(projectId);
129
+ const existingInstallation = existing ? this.getLinearInstallation(existing.installationId) : undefined;
130
+ if (existing && existingInstallation) {
131
+ continue;
132
+ }
133
+ const installationId = this.resolveRepairInstallationId(projectId);
134
+ if (installationId === undefined) {
135
+ continue;
136
+ }
137
+ this.linkProjectInstallation(projectId, installationId);
138
+ repairs.push({
139
+ projectId,
140
+ installationId,
141
+ reason: existing ? "dangling" : "missing",
142
+ });
143
+ }
144
+ return repairs;
145
+ }
125
146
  unlinkProjectInstallation(projectId) {
126
147
  this.connection.prepare("DELETE FROM project_installations WHERE project_id = ?").run(projectId);
127
148
  }
@@ -136,12 +157,55 @@ export class LinearInstallationStore {
136
157
  .prepare(`
137
158
  SELECT li.*
138
159
  FROM linear_installations li
139
- INNER JOIN project_installations pi ON pi.installation_id = li.id
140
- WHERE pi.project_id = ?
160
+ WHERE li.id = COALESCE(
161
+ (
162
+ SELECT pi.installation_id
163
+ FROM project_installations pi
164
+ WHERE pi.project_id = ?
165
+ LIMIT 1
166
+ ),
167
+ (
168
+ SELECT rl.installation_id
169
+ FROM repository_links rl
170
+ WHERE rl.github_repo = ?
171
+ LIMIT 1
172
+ ),
173
+ (
174
+ SELECT li_single.id
175
+ FROM linear_installations li_single
176
+ WHERE (SELECT COUNT(*) FROM linear_installations) = 1
177
+ ORDER BY li_single.updated_at DESC, li_single.id DESC
178
+ LIMIT 1
179
+ )
180
+ )
141
181
  `)
142
- .get(projectId);
182
+ .get(projectId, projectId);
143
183
  return row ? mapLinearInstallation(row) : undefined;
144
184
  }
185
+ resolveRepairInstallationId(projectId) {
186
+ const repoLink = this.connection
187
+ .prepare(`
188
+ SELECT rl.installation_id AS installation_id
189
+ FROM repository_links rl
190
+ INNER JOIN linear_installations li ON li.id = rl.installation_id
191
+ WHERE rl.github_repo = ?
192
+ LIMIT 1
193
+ `)
194
+ .get(projectId);
195
+ if (repoLink) {
196
+ return Number(repoLink.installation_id);
197
+ }
198
+ const singleInstallation = this.connection
199
+ .prepare(`
200
+ SELECT li.id AS installation_id
201
+ FROM linear_installations li
202
+ WHERE (SELECT COUNT(*) FROM linear_installations) = 1
203
+ ORDER BY li.updated_at DESC, li.id DESC
204
+ LIMIT 1
205
+ `)
206
+ .get();
207
+ return singleInstallation ? Number(singleInstallation.installation_id) : undefined;
208
+ }
145
209
  createOAuthState(params) {
146
210
  const now = isoNow();
147
211
  const result = this.connection
@@ -12,6 +12,8 @@ CREATE TABLE IF NOT EXISTS issues (
12
12
  pending_run_type TEXT,
13
13
  pending_run_context_json TEXT,
14
14
  branch_name TEXT,
15
+ branch_owner TEXT NOT NULL DEFAULT 'patchrelay',
16
+ branch_ownership_changed_at TEXT,
15
17
  worktree_path TEXT,
16
18
  thread_id TEXT,
17
19
  active_run_id INTEGER,
@@ -180,6 +182,9 @@ export function runPatchRelayMigrations(connection) {
180
182
  connection.prepare("UPDATE webhook_events SET processing_status = 'processed' WHERE processing_status = 'pending' AND payload_json IS NULL").run();
181
183
  // Add pending_merge_prep column for merge queue stewardship
182
184
  addColumnIfMissing(connection, "issues", "pending_merge_prep", "INTEGER NOT NULL DEFAULT 0");
185
+ // Explicit PR branch ownership hand-off between PatchRelay and MergeSteward
186
+ addColumnIfMissing(connection, "issues", "branch_owner", "TEXT NOT NULL DEFAULT 'patchrelay'");
187
+ addColumnIfMissing(connection, "issues", "branch_ownership_changed_at", "TEXT");
183
188
  // Add merge_prep_attempts for retry budget / escalation
184
189
  addColumnIfMissing(connection, "issues", "merge_prep_attempts", "INTEGER NOT NULL DEFAULT 0");
185
190
  // Add review_fix_attempts counter
package/dist/db.js CHANGED
@@ -341,6 +341,13 @@ export class PatchRelayDatabase {
341
341
  const row = this.connection.prepare("SELECT * FROM issues WHERE pr_number = ?").get(prNumber);
342
342
  return row ? mapIssueRow(row) : undefined;
343
343
  }
344
+ setBranchOwner(projectId, linearIssueId, owner) {
345
+ this.connection.prepare(`
346
+ UPDATE issues
347
+ SET branch_owner = ?, branch_ownership_changed_at = ?, updated_at = ?
348
+ WHERE project_id = ? AND linear_issue_id = ?
349
+ `).run(owner, isoNow(), isoNow(), projectId, linearIssueId);
350
+ }
344
351
  replaceIssueDependencies(params) {
345
352
  const now = isoNow();
346
353
  this.connection
@@ -641,6 +648,10 @@ function mapIssueRow(row) {
641
648
  ...(row.pending_run_type !== null && row.pending_run_type !== undefined ? { pendingRunType: String(row.pending_run_type) } : {}),
642
649
  ...(row.pending_run_context_json !== null && row.pending_run_context_json !== undefined ? { pendingRunContextJson: String(row.pending_run_context_json) } : {}),
643
650
  ...(row.branch_name !== null ? { branchName: String(row.branch_name) } : {}),
651
+ ...(row.branch_owner !== null && row.branch_owner !== undefined ? { branchOwner: String(row.branch_owner) } : { branchOwner: "patchrelay" }),
652
+ ...(row.branch_ownership_changed_at !== null && row.branch_ownership_changed_at !== undefined
653
+ ? { branchOwnershipChangedAt: String(row.branch_ownership_changed_at) }
654
+ : {}),
644
655
  ...(row.worktree_path !== null ? { worktreePath: String(row.worktree_path) } : {}),
645
656
  ...(row.thread_id !== null ? { threadId: String(row.thread_id) } : {}),
646
657
  ...(row.active_run_id !== null ? { activeRunId: Number(row.active_run_id) } : {}),
@@ -146,6 +146,9 @@ export class GitHubWebhookHandler {
146
146
  linearIssueId: issue.linearIssueId,
147
147
  factoryState: newState,
148
148
  });
149
+ if (newState === "awaiting_queue") {
150
+ this.db.setBranchOwner(issue.projectId, issue.linearIssueId, "merge_steward");
151
+ }
149
152
  this.logger.info({ issueKey: issue.issueKey, from: afterMetadata.factoryState, to: newState, trigger: event.triggerEvent }, "Factory state transition from GitHub event");
150
153
  const transitionedIssue = this.db.getIssue(issue.projectId, issue.linearIssueId) ?? issue;
151
154
  void this.emitLinearActivity(transitionedIssue, newState, event);
@@ -318,6 +321,7 @@ export class GitHubWebhookHandler {
318
321
  lastQueueSignalAt: new Date().toISOString(),
319
322
  lastQueueIncidentJson: JSON.stringify(queueRepairContext),
320
323
  });
324
+ this.db.setBranchOwner(issue.projectId, issue.linearIssueId, "patchrelay");
321
325
  this.enqueueIssue(issue.projectId, issue.linearIssueId);
322
326
  this.logger.info({ issueKey: issue.issueKey, checkName: event.checkName }, "Queue eviction detected, enqueued queue repair");
323
327
  this.feed?.publish({
@@ -367,6 +371,7 @@ export class GitHubWebhookHandler {
367
371
  lastGitHubFailureAt: new Date().toISOString(),
368
372
  lastQueueIncidentJson: null,
369
373
  });
374
+ this.db.setBranchOwner(issue.projectId, issue.linearIssueId, "patchrelay");
370
375
  this.enqueueIssue(issue.projectId, issue.linearIssueId);
371
376
  this.logger.info({ issueKey: issue.issueKey, checkName: failureContext.checkName ?? event.checkName }, "Enqueued CI repair run");
372
377
  this.feed?.publish({
@@ -391,6 +396,7 @@ export class GitHubWebhookHandler {
391
396
  reviewerName: event.reviewerName,
392
397
  }),
393
398
  });
399
+ this.db.setBranchOwner(issue.projectId, issue.linearIssueId, "patchrelay");
394
400
  this.enqueueIssue(issue.projectId, issue.linearIssueId);
395
401
  this.logger.info({ issueKey: issue.issueKey, reviewerName: event.reviewerName }, "Enqueued review fix run");
396
402
  }
@@ -370,9 +370,9 @@ export class DatabaseBackedLinearClientProvider {
370
370
  this.logger = logger;
371
371
  }
372
372
  async forProject(projectId) {
373
- const link = this.db.linearInstallations.getProjectInstallation(projectId);
374
- if (link) {
375
- return await this.forInstallationId(link.installationId);
373
+ const installation = this.db.linearInstallations.getLinearInstallationForProject(projectId);
374
+ if (installation) {
375
+ return await this.forInstallationId(installation.id);
376
376
  }
377
377
  return undefined;
378
378
  }
@@ -61,11 +61,11 @@ function buildRunPrompt(issue, runType, repoPath, context) {
61
61
  const snapshot = context?.ciSnapshot && typeof context.ciSnapshot === "object"
62
62
  ? context.ciSnapshot
63
63
  : undefined;
64
- lines.push("## CI Repair", "", "A full CI iteration has settled failed on your PR. Diagnose the whole snapshot, fix the root cause and directly related fallout, then push to the same PR branch.", snapshot?.gateCheckName ? `Gate check: ${String(snapshot.gateCheckName)}` : "", snapshot?.gateCheckStatus ? `Gate status: ${String(snapshot.gateCheckStatus)}` : "", snapshot?.settledAt ? `Settled at: ${String(snapshot.settledAt)}` : "", context?.failureHeadSha ? `Failing head SHA: ${String(context.failureHeadSha)}` : "", context?.checkName ? `Failed check: ${String(context.checkName)}` : "", context?.jobName && context?.jobName !== context?.checkName ? `Failed job: ${String(context.jobName)}` : "", context?.stepName ? `Failed step: ${String(context.stepName)}` : "", context?.summary ? `Failure summary: ${String(context.summary)}` : "", Array.isArray(snapshot?.failedChecks) && snapshot.failedChecks.length > 0
65
- ? `All failed checks in settled snapshot:\n${snapshot.failedChecks.map((entry) => `- ${String(entry.name ?? "unknown")}${entry.summary ? `: ${String(entry.summary)}` : ""}`).join("\n")}`
64
+ lines.push("## CI Repair", "", "A full CI iteration has settled failed on your PR. Start from the specific failing check/job/step below on the latest remote PR branch tip, fix that concrete failure first, then push to the same PR branch.", snapshot?.gateCheckName ? `Gate check: ${String(snapshot.gateCheckName)}` : "", snapshot?.gateCheckStatus ? `Gate status: ${String(snapshot.gateCheckStatus)}` : "", snapshot?.settledAt ? `Settled at: ${String(snapshot.settledAt)}` : "", context?.failureHeadSha ? `Failing head SHA: ${String(context.failureHeadSha)}` : "", context?.checkName ? `Failed check: ${String(context.checkName)}` : "", context?.jobName && context?.jobName !== context?.checkName ? `Failed job: ${String(context.jobName)}` : "", context?.stepName ? `Failed step: ${String(context.stepName)}` : "", context?.summary ? `Failure summary: ${String(context.summary)}` : "", Array.isArray(snapshot?.failedChecks) && snapshot.failedChecks.length > 0
65
+ ? `Other failed checks in the settled snapshot (context only; ignore unless the logs show the same root cause):\n${snapshot.failedChecks.map((entry) => `- ${String(entry.name ?? "unknown")}${entry.summary ? `: ${String(entry.summary)}` : ""}`).join("\n")}`
66
66
  : "", context?.checkUrl ? `Check URL: ${String(context.checkUrl)}` : "", Array.isArray(context?.annotations) && context.annotations.length > 0
67
67
  ? `Annotations:\n${context.annotations.map((entry) => `- ${String(entry)}`).join("\n")}`
68
- : "", "", "Read the latest CI logs, consider the broader PR context, fix the likely root cause and any directly related fallout in one pass, run verification, commit and push.", "Do not open a new PR. Keep working on the existing branch until CI goes green or the situation is clearly stuck.", "Do not change test expectations unless the test is genuinely wrong.", "");
68
+ : "", "", "Fetch the latest remote branch state first. If the branch moved since this failure, restart from the new tip instead of pushing older work.", "Read the latest logs for the named failing check, fix that root cause, and only broaden scope when the logs show direct fallout from the same issue.", "Do not change workflows, dependency installation, or unrelated tests unless the failing logs clearly point there.", "Run focused verification for the named failure, then commit and push.", "Do not open a new PR. Keep working on the existing branch until CI goes green or the situation is clearly stuck.", "Do not change test expectations unless the test is genuinely wrong.", "");
69
69
  break;
70
70
  }
71
71
  case "review_fix":
@@ -188,6 +188,7 @@ export class RunOrchestrator {
188
188
  }
189
189
  : {}),
190
190
  });
191
+ this.db.setBranchOwner(item.projectId, item.issueId, "patchrelay");
191
192
  return created;
192
193
  });
193
194
  if (!run)
@@ -293,8 +294,9 @@ export class RunOrchestrator {
293
294
  * Risks mitigated:
294
295
  * - Dirty worktree from interrupted run → stash before, pop after
295
296
  * - Conflicts → abort rebase, throw so the run fails with a clear reason
296
- * - Already up-to-date → no-op, no force-push needed
297
- * - Force-push invalidates reviews only push if rebase actually moved commits
297
+ * - Already up-to-date → no-op
298
+ * - Keep publishing explicit: the orchestrator updates the local worktree
299
+ * only; the agent/run owns any later branch push.
298
300
  */
299
301
  async freshenWorktree(worktreePath, project, issue) {
300
302
  const gitBin = this.config.runner.gitBin;
@@ -331,14 +333,7 @@ export class RunOrchestrator {
331
333
  this.logger.warn({ issueKey: issue.issueKey, baseBranch }, "Pre-run freshen: rebase conflict, agent will resolve");
332
334
  return;
333
335
  }
334
- // Push the rebased branch (force-with-lease to protect against concurrent pushes)
335
- const pushResult = await execCommand(gitBin, ["-C", worktreePath, "push", "--force-with-lease"], { timeoutMs: 60_000 });
336
- if (pushResult.exitCode !== 0) {
337
- this.logger.warn({ issueKey: issue.issueKey, stderr: pushResult.stderr?.slice(0, 300) }, "Pre-run rebase push failed, proceeding anyway");
338
- }
339
- else {
340
- this.logger.info({ issueKey: issue.issueKey, baseBranch }, "Pre-run freshen: rebased and pushed onto latest base");
341
- }
336
+ this.logger.info({ issueKey: issue.issueKey, baseBranch }, "Pre-run freshen: rebased locally onto latest base");
342
337
  // Restore stashed changes
343
338
  if (didStash)
344
339
  await execCommand(gitBin, ["-C", worktreePath, "stash", "pop"], { timeoutMs: 10_000 });
@@ -470,6 +465,9 @@ export class RunOrchestrator {
470
465
  }
471
466
  : {}),
472
467
  });
468
+ if (postRunState === "awaiting_queue") {
469
+ this.db.setBranchOwner(run.projectId, run.linearIssueId, "merge_steward");
470
+ }
473
471
  });
474
472
  // If we advanced to awaiting_queue, enqueue for merge prep
475
473
  if (postRunState === "awaiting_queue") {
@@ -570,7 +568,7 @@ export class RunOrchestrator {
570
568
  }
571
569
  // Review approved + checks not failed — advance to awaiting_queue
572
570
  if (issue.prReviewState === "approved" && issue.prCheckStatus !== "failed") {
573
- if (issue.factoryState !== "awaiting_queue") {
571
+ if (issue.factoryState !== "awaiting_queue" || issue.branchOwner !== "merge_steward") {
574
572
  this.advanceIdleIssue(issue, "awaiting_queue", { clearFailureProvenance: true });
575
573
  }
576
574
  continue;
@@ -688,6 +686,10 @@ export class RunOrchestrator {
688
686
  }
689
687
  : {}),
690
688
  });
689
+ const branchOwner = this.resolveBranchOwnerForStateTransition(newState, options?.pendingRunType);
690
+ if (branchOwner) {
691
+ this.db.setBranchOwner(issue.projectId, issue.linearIssueId, branchOwner);
692
+ }
691
693
  this.feed?.publish({
692
694
  level: "info",
693
695
  kind: "stage",
@@ -697,7 +699,7 @@ export class RunOrchestrator {
697
699
  status: "reconciled",
698
700
  summary: `Reconciliation: ${issue.factoryState} \u2192 ${newState}`,
699
701
  });
700
- if (newState === "awaiting_queue") {
702
+ if (newState === "awaiting_queue" && issue.factoryState !== "awaiting_queue") {
701
703
  this.requestMergeQueueAdmission(issue, issue.projectId);
702
704
  }
703
705
  if (options?.pendingRunType) {
@@ -920,6 +922,9 @@ export class RunOrchestrator {
920
922
  }
921
923
  : {}),
922
924
  });
925
+ if (postRunState === "awaiting_queue") {
926
+ this.db.setBranchOwner(run.projectId, run.linearIssueId, "merge_steward");
927
+ }
923
928
  });
924
929
  if (postRunState) {
925
930
  this.feed?.publish({
@@ -987,8 +992,21 @@ export class RunOrchestrator {
987
992
  activeRunId: null,
988
993
  factoryState: nextState,
989
994
  });
995
+ const branchOwner = this.resolveBranchOwnerForStateTransition(nextState);
996
+ if (branchOwner) {
997
+ this.db.setBranchOwner(run.projectId, run.linearIssueId, branchOwner);
998
+ }
990
999
  });
991
1000
  }
1001
+ resolveBranchOwnerForStateTransition(newState, pendingRunType) {
1002
+ if (pendingRunType)
1003
+ return "patchrelay";
1004
+ if (newState === "awaiting_queue")
1005
+ return "merge_steward";
1006
+ if (newState === "repairing_ci" || newState === "repairing_queue")
1007
+ return "patchrelay";
1008
+ return undefined;
1009
+ }
992
1010
  async verifyReactiveRunAdvancedBranch(run, issue) {
993
1011
  if (run.runType !== "ci_repair" && run.runType !== "queue_repair") {
994
1012
  return undefined;
package/dist/service.js CHANGED
@@ -85,6 +85,10 @@ export class PatchRelayService {
85
85
  });
86
86
  }
87
87
  async start() {
88
+ const repairedInstallations = this.db.linearInstallations.repairProjectInstallations(this.config.projects.map((project) => project.id));
89
+ for (const repair of repairedInstallations) {
90
+ this.logger.info({ projectId: repair.projectId, installationId: repair.installationId, reason: repair.reason }, "Repaired Linear project installation link");
91
+ }
88
92
  // Verify Linear connectivity for all configured projects before starting.
89
93
  // Auth errors do not prevent startup (the OAuth callback must be reachable
90
94
  // for `patchrelay linear connect`), but the service reports NOT READY until at
@@ -1,5 +1,6 @@
1
1
  import { buildAgentSessionPlanForIssue, } from "./agent-session-plan.js";
2
2
  import { buildAgentSessionExternalUrls } from "./agent-session-presentation.js";
3
+ import { TERMINAL_STATES } from "./factory-state.js";
3
4
  import { buildAlreadyRunningThought, buildDelegationThought, buildPromptDeliveredThought, buildStopConfirmationActivity, } from "./linear-session-reporting.js";
4
5
  import { resolveProject, triggerEventAllowed, trustedActorAllowed } from "./project-resolution.js";
5
6
  import { normalizeWebhook } from "./webhooks.js";
@@ -161,8 +162,9 @@ export class WebhookHandler {
161
162
  const hydratedIssue = await this.syncIssueDependencies(project.id, normalizedIssue);
162
163
  const unresolvedBlockers = this.db.countUnresolvedBlockers(project.id, normalizedIssue.id);
163
164
  const pendingRunContextJson = mergePendingImplementationContext(existingIssue?.pendingRunContextJson, normalized);
165
+ const terminalForAutomation = isTerminalDelegationState(existingIssue, hydratedIssue);
164
166
  let pendingRunType;
165
- if (delegated && triggerAllowed && unresolvedBlockers === 0 && !activeRun && !existingIssue?.pendingRunType) {
167
+ if (delegated && triggerAllowed && unresolvedBlockers === 0 && !activeRun && !existingIssue?.pendingRunType && !terminalForAutomation) {
166
168
  pendingRunType = "implementation";
167
169
  }
168
170
  const clearPendingImplementation = unresolvedBlockers > 0 && existingIssue?.pendingRunType === "implementation" && !activeRun;
@@ -537,6 +539,18 @@ export class WebhookHandler {
537
539
  return undefined;
538
540
  }
539
541
  }
542
+ function isResolvedLinearState(stateType, stateName) {
543
+ return stateType === "completed" || stateName?.trim().toLowerCase() === "done";
544
+ }
545
+ function isTerminalDelegationState(existingIssue, hydratedIssue) {
546
+ if (existingIssue?.prState === "merged") {
547
+ return true;
548
+ }
549
+ if (existingIssue?.factoryState && TERMINAL_STATES.has(existingIssue.factoryState)) {
550
+ return true;
551
+ }
552
+ return isResolvedLinearState(hydratedIssue.stateType, hydratedIssue.stateName);
553
+ }
540
554
  function hasCompleteIssueContext(issue) {
541
555
  return Boolean(issue.stateName && issue.delegateId && issue.teamId && issue.teamKey);
542
556
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "patchrelay",
3
- "version": "0.32.0",
3
+ "version": "0.32.1",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "repository": {
@@ -33,8 +33,8 @@
33
33
  "prepack": "npm run build",
34
34
  "start": "node dist/index.js serve",
35
35
  "doctor": "node dist/index.js doctor",
36
- "restart": "node dist/index.js restart-service",
37
- "deploy": "npm run build && npm install -g . && node dist/index.js restart-service",
36
+ "restart": "node dist/index.js service restart",
37
+ "deploy": "npm run build && npm install -g . && node dist/index.js service restart",
38
38
  "lint": "eslint .",
39
39
  "typecheck": "tsc -p tsconfig.json --noEmit",
40
40
  "check": "npm run typecheck",