patchrelay 0.14.2 → 0.16.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/README.md CHANGED
@@ -1,8 +1,8 @@
1
1
  # PatchRelay
2
2
 
3
- PatchRelay is a self-hosted harness for Linear-driven Codex work on your own machine.
3
+ PatchRelay is a self-hosted harness for running a controlled coding loop per Linear issue on your own machine.
4
4
 
5
- It receives Linear webhooks, routes issues to the right local repository, prepares durable issue worktrees, runs Codex sessions through `codex app-server`, and keeps the whole run observable and resumable from the CLI. GitHub webhooks drive reactive loops for CI repair, review fixes, and merge queue failures.
5
+ It receives Linear webhooks, routes issues to the right local repository, prepares durable issue worktrees, runs Codex sessions through `codex app-server`, and keeps the whole issue loop observable and resumable from the CLI. GitHub webhooks drive reactive loops for CI repair, review fixes, and merge queue failures.
6
6
 
7
7
  PatchRelay is the system around the model:
8
8
 
@@ -10,7 +10,7 @@ PatchRelay is the system around the model:
10
10
  - Linear OAuth and workspace installations
11
11
  - issue-to-repo routing
12
12
  - issue worktree and branch lifecycle
13
- - run orchestration and thread continuity
13
+ - context packaging, run orchestration, and thread continuity
14
14
  - reactive CI repair, review fix, and merge queue repair loops
15
15
  - native Linear agent input forwarding into active runs
16
16
  - read-only inspection and run reporting
@@ -21,7 +21,7 @@ If you want Codex to work inside your real repos with your real tools, secrets,
21
21
 
22
22
  - Keep the agent in the real environment instead of rebuilding that environment in a hosted sandbox.
23
23
  - Use your existing machine, repos, secrets, SSH config, shell tools, and deployment access.
24
- - Keep deterministic workflow logic outside the model: routing, run orchestration, worktree ownership, and reporting.
24
+ - Keep deterministic workflow logic outside the model: context packaging, routing, run orchestration, worktree ownership, verification, and reporting.
25
25
  - Choose the Codex approval and sandbox settings that match your risk tolerance.
26
26
  - Let Linear drive the loop through delegation and native agent sessions.
27
27
  - Let GitHub drive reactive loops through PR reviews and CI check events.
@@ -33,6 +33,7 @@ PatchRelay does the deterministic harness work that you do not want to re-implem
33
33
 
34
34
  - verifies and deduplicates Linear and GitHub webhooks
35
35
  - maps issue events to the correct local project
36
+ - packages the right issue, repo, review, and failure context for each loop
36
37
  - creates and reuses one durable worktree and branch per issue lifecycle
37
38
  - starts Codex threads for implementation runs
38
39
  - triggers reactive runs for CI failures, review feedback, and merge queue failures
@@ -50,7 +51,7 @@ PatchRelay works best when read as five layers with clear ownership:
50
51
  - integration layer: Linear webhooks, GitHub webhooks, OAuth, project routing, and state sync
51
52
  - observability layer: CLI inspection, reports, event trails, and operator endpoints
52
53
 
53
- That separation is intentional. PatchRelay is not the policy itself and it is not the coding agent. It is the harness that keeps those pieces coordinated in a real repository with real operational state.
54
+ That separation is intentional. PatchRelay is not the policy itself and it is not the coding agent. It is the harness that keeps context, action, verification, and repair coordinated in a real repository with real operational state.
54
55
 
55
56
  ## Runtime Model
56
57
 
@@ -75,10 +76,10 @@ You will also need:
75
76
  ## How It Works
76
77
 
77
78
  1. A human delegates PatchRelay on an issue to start automation.
78
- 2. PatchRelay verifies the webhook and routes the issue to the right local project.
79
+ 2. PatchRelay verifies the webhook, routes the issue to the right local project, and packages the issue context for the first loop.
79
80
  3. Delegated issues create or reuse the issue worktree and launch an implementation run through `codex app-server`.
80
81
  4. PatchRelay persists thread ids, run state, and observations so the work stays inspectable and resumable.
81
- 5. GitHub webhooks drive reactive loops: CI repair on check failures, review fix on changes requested, and merge queue repair on queue failures.
82
+ 5. GitHub webhooks drive reactive verification and repair loops: CI repair on check failures, review fix on changes requested, and merge queue repair on queue failures.
82
83
  6. Native agent prompts and Linear comments can steer the active run. An operator can take over from the exact same worktree when needed.
83
84
 
84
85
  ## Factory State Machine
@@ -101,6 +102,8 @@ Run types:
101
102
  - `ci_repair` — fix failing CI checks
102
103
  - `queue_repair` — fix merge queue failures
103
104
 
105
+ PatchRelay treats these as distinct loop types with different context, entry conditions, and success criteria rather than as one generic "ask the agent again" workflow.
106
+
104
107
  ## Restart And Reconciliation
105
108
 
106
109
  PatchRelay treats restart safety as part of the harness contract, not as a best-effort extra.
@@ -122,7 +125,7 @@ PatchRelay uses repo-local workflow files as prompts for Codex runs:
122
125
  - `IMPLEMENTATION_WORKFLOW.md` — used for implementation, CI repair, and queue repair runs
123
126
  - `REVIEW_WORKFLOW.md` — used for review fix runs
124
127
 
125
- These files define how the agent should work in that repository. Keep them short and action-oriented.
128
+ These files define how the agent should work in that repository. Keep them short, action-oriented, and human-authored.
126
129
 
127
130
  ## Access Control
128
131
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "service": "patchrelay",
3
- "version": "0.14.2",
4
- "commit": "c132666b1710",
5
- "builtAt": "2026-03-25T11:31:53.145Z"
3
+ "version": "0.16.0",
4
+ "commit": "396ce21398c4",
5
+ "builtAt": "2026-03-25T12:29:18.014Z"
6
6
  }
@@ -1,11 +1,21 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
- import { useReducer, useMemo } from "react";
2
+ import { useReducer, useMemo, useCallback } from "react";
3
3
  import { Box, useApp, useInput } from "ink";
4
4
  import { watchReducer, initialWatchState, filterIssues } from "./watch-state.js";
5
5
  import { useWatchStream } from "./use-watch-stream.js";
6
6
  import { useDetailStream } from "./use-detail-stream.js";
7
7
  import { IssueListView } from "./IssueListView.js";
8
8
  import { IssueDetailView } from "./IssueDetailView.js";
9
+ async function postRetry(baseUrl, issueKey, bearerToken) {
10
+ const headers = { "content-type": "application/json" };
11
+ if (bearerToken)
12
+ headers.authorization = `Bearer ${bearerToken}`;
13
+ await fetch(new URL(`/api/issues/${encodeURIComponent(issueKey)}/retry`, baseUrl), {
14
+ method: "POST",
15
+ headers,
16
+ signal: AbortSignal.timeout(5000),
17
+ }).catch(() => { });
18
+ }
9
19
  export function App({ baseUrl, bearerToken, initialIssueKey }) {
10
20
  const { exit } = useApp();
11
21
  const [state, dispatch] = useReducer(watchReducer, {
@@ -15,6 +25,11 @@ export function App({ baseUrl, bearerToken, initialIssueKey }) {
15
25
  const filtered = useMemo(() => filterIssues(state.issues, state.filter), [state.issues, state.filter]);
16
26
  useWatchStream({ baseUrl, bearerToken, dispatch });
17
27
  useDetailStream({ baseUrl, bearerToken, issueKey: state.activeDetailKey, dispatch });
28
+ const handleRetry = useCallback(() => {
29
+ if (state.activeDetailKey) {
30
+ void postRetry(baseUrl, state.activeDetailKey, bearerToken);
31
+ }
32
+ }, [baseUrl, bearerToken, state.activeDetailKey]);
18
33
  useInput((input, key) => {
19
34
  if (input === "q") {
20
35
  exit();
@@ -41,7 +56,13 @@ export function App({ baseUrl, bearerToken, initialIssueKey }) {
41
56
  if (key.escape || key.backspace || key.delete) {
42
57
  dispatch({ type: "exit-detail" });
43
58
  }
59
+ else if (input === "f") {
60
+ dispatch({ type: "toggle-follow" });
61
+ }
62
+ else if (input === "r") {
63
+ handleRetry();
64
+ }
44
65
  }
45
66
  });
46
- return (_jsx(Box, { flexDirection: "column", children: state.view === "list" ? (_jsx(IssueListView, { issues: filtered, selectedIndex: state.selectedIndex, connected: state.connected, filter: state.filter, totalCount: state.issues.length })) : (_jsx(IssueDetailView, { issue: state.issues.find((i) => i.issueKey === state.activeDetailKey), thread: state.thread, report: state.report })) }));
67
+ return (_jsx(Box, { flexDirection: "column", children: state.view === "list" ? (_jsx(IssueListView, { issues: filtered, selectedIndex: state.selectedIndex, connected: state.connected, filter: state.filter, totalCount: state.issues.length })) : (_jsx(IssueDetailView, { issue: state.issues.find((i) => i.issueKey === state.activeDetailKey), thread: state.thread, report: state.report, follow: state.follow, feedEntries: state.detailFeed })) }));
47
68
  }
@@ -0,0 +1,23 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text } from "ink";
3
+ const KIND_COLORS = {
4
+ stage: "cyan",
5
+ turn: "yellow",
6
+ github: "green",
7
+ webhook: "blue",
8
+ workflow: "magenta",
9
+ hook: "white",
10
+ };
11
+ function kindColor(kind) {
12
+ return KIND_COLORS[kind] ?? "white";
13
+ }
14
+ function formatTime(iso) {
15
+ return new Date(iso).toLocaleTimeString("en-GB", { hour12: false });
16
+ }
17
+ export function FeedTimeline({ entries, maxEntries }) {
18
+ const visible = maxEntries ? entries.slice(-maxEntries) : entries;
19
+ if (visible.length === 0) {
20
+ return _jsx(Text, { dimColor: true, children: "No events yet." });
21
+ }
22
+ return (_jsx(Box, { flexDirection: "column", children: visible.map((entry, i) => (_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: formatTime(entry.at) }), _jsx(Text, { color: kindColor(entry.kind), children: (entry.status ?? entry.kind).padEnd(15) }), _jsx(Text, { children: entry.summary })] }, `feed-${i}`))) }));
23
+ }
@@ -1,7 +1,7 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { Box, Text } from "ink";
3
- export function HelpBar({ view }) {
3
+ export function HelpBar({ view, follow }) {
4
4
  return (_jsx(Box, { children: _jsx(Text, { dimColor: true, children: view === "list"
5
5
  ? "j/k: navigate Enter: detail Tab: filter q: quit"
6
- : "Esc: back q: quit" }) }));
6
+ : `Esc: back f: follow ${follow ? "on" : "off"} r: retry q: quit` }) }));
7
7
  }
@@ -1,18 +1,29 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { jsxs as _jsxs, jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  import { Box, Text } from "ink";
3
3
  import { ThreadView } from "./ThreadView.js";
4
+ import { FeedTimeline } from "./FeedTimeline.js";
4
5
  import { HelpBar } from "./HelpBar.js";
5
6
  function truncate(text, max) {
6
7
  const line = text.replace(/\n/g, " ").trim();
7
8
  return line.length > max ? `${line.slice(0, max - 3)}...` : line;
8
9
  }
10
+ function formatTokens(n) {
11
+ if (n >= 1_000_000)
12
+ return `${(n / 1_000_000).toFixed(1)}M`;
13
+ if (n >= 1_000)
14
+ return `${(n / 1_000).toFixed(1)}k`;
15
+ return String(n);
16
+ }
17
+ function ThreadStatusBar({ thread, follow }) {
18
+ return (_jsxs(Box, { gap: 2, children: [thread.tokenUsage && (_jsxs(Text, { dimColor: true, children: ["tokens: ", formatTokens(thread.tokenUsage.inputTokens), " in / ", formatTokens(thread.tokenUsage.outputTokens), " out"] })), thread.diffSummary && thread.diffSummary.filesChanged > 0 && (_jsxs(Text, { dimColor: true, children: ["diff: ", thread.diffSummary.filesChanged, " file", thread.diffSummary.filesChanged !== 1 ? "s" : "", " ", "+", thread.diffSummary.linesAdded, " -", thread.diffSummary.linesRemoved] })), follow && _jsx(Text, { color: "yellow", children: "follow" })] }));
19
+ }
9
20
  function ReportView({ report }) {
10
21
  return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { gap: 2, children: [_jsx(Text, { dimColor: true, children: "Latest run:" }), _jsx(Text, { bold: true, children: report.runType }), _jsx(Text, { color: report.status === "completed" ? "green" : "red", children: report.status })] }), report.summary && (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { bold: true, children: "Summary:" }), _jsx(Text, { wrap: "wrap", children: truncate(report.summary, 300) })] })), report.commands.length > 0 && (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsxs(Text, { bold: true, children: ["Commands (", report.commands.length, "):"] }), report.commands.slice(-10).map((cmd, i) => (_jsxs(Box, { gap: 1, children: [_jsx(Text, { color: cmd.exitCode === 0 ? "green" : cmd.exitCode !== undefined ? "red" : "white", children: cmd.exitCode === 0 ? "\u2713" : cmd.exitCode !== undefined ? "\u2717" : " " }), _jsx(Text, { dimColor: true, children: "$ " }), _jsx(Text, { children: truncate(cmd.command, 60) }), cmd.durationMs !== undefined && _jsxs(Text, { dimColor: true, children: [" ", (cmd.durationMs / 1000).toFixed(1), "s"] })] }, `cmd-${i}`)))] })), _jsxs(Box, { marginTop: 1, gap: 2, children: [report.fileChanges > 0 && _jsxs(Text, { dimColor: true, children: [report.fileChanges, " file change", report.fileChanges !== 1 ? "s" : ""] }), report.toolCalls > 0 && _jsxs(Text, { dimColor: true, children: [report.toolCalls, " tool call", report.toolCalls !== 1 ? "s" : ""] }), report.assistantMessages.length > 0 && _jsxs(Text, { dimColor: true, children: [report.assistantMessages.length, " message", report.assistantMessages.length !== 1 ? "s" : ""] })] })] }));
11
22
  }
12
- export function IssueDetailView({ issue, thread, report }) {
23
+ export function IssueDetailView({ issue, thread, report, follow, feedEntries }) {
13
24
  if (!issue) {
14
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: "red", children: "Issue not found." }), _jsx(HelpBar, { view: "detail" })] }));
25
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: "red", children: "Issue not found." }), _jsx(HelpBar, { view: "detail", follow: follow })] }));
15
26
  }
16
27
  const key = issue.issueKey ?? issue.projectId;
17
- 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.activeRunType && _jsx(Text, { color: "yellow", children: issue.activeRunType }), issue.prNumber !== undefined && _jsxs(Text, { dimColor: true, children: ["PR #", issue.prNumber] })] }), issue.title && _jsx(Text, { dimColor: true, children: issue.title }), _jsx(Text, { dimColor: true, children: "─".repeat(72) }), thread ? (_jsx(ThreadView, { thread: thread })) : report ? (_jsx(ReportView, { report: report })) : (_jsx(Text, { dimColor: true, children: "Loading..." })), _jsx(Text, { dimColor: true, children: "─".repeat(72) }), _jsx(HelpBar, { view: "detail" })] }));
28
+ 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.activeRunType && _jsx(Text, { color: "yellow", children: issue.activeRunType }), issue.prNumber !== undefined && _jsxs(Text, { dimColor: true, children: ["PR #", issue.prNumber] })] }), issue.title && _jsx(Text, { dimColor: true, children: issue.title }), thread && _jsx(ThreadStatusBar, { thread: thread, follow: follow }), _jsx(Text, { dimColor: true, children: "─".repeat(72) }), thread ? (_jsx(ThreadView, { thread: thread, follow: follow })) : report ? (_jsx(ReportView, { report: report })) : (_jsx(Text, { dimColor: true, children: "Loading..." })), feedEntries.length > 0 && (_jsxs(_Fragment, { children: [_jsx(Text, { dimColor: true, children: "─".repeat(72) }), _jsx(Text, { bold: true, dimColor: true, children: "Events:" }), _jsx(FeedTimeline, { entries: feedEntries, maxEntries: follow ? 5 : undefined })] })), _jsx(Text, { dimColor: true, children: "─".repeat(72) }), _jsx(HelpBar, { view: "detail", follow: follow })] }));
18
29
  }
@@ -15,6 +15,12 @@ function planStepColor(status) {
15
15
  return "yellow";
16
16
  return "white";
17
17
  }
18
- export function ThreadView({ thread }) {
19
- return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { gap: 2, children: [_jsxs(Text, { dimColor: true, children: ["Thread: ", thread.threadId.slice(0, 16)] }), _jsxs(Text, { dimColor: true, children: ["Status: ", thread.status] }), _jsxs(Text, { dimColor: true, children: ["Turns: ", thread.turns.length] })] }), thread.plan && thread.plan.length > 0 && (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { bold: true, children: "Plan:" }), thread.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, { flexDirection: "column", marginTop: 1, children: thread.turns.map((turn, i) => (_jsx(TurnSection, { turn: turn, index: i }, turn.id))) })] }));
18
+ export function ThreadView({ thread, follow }) {
19
+ const visibleTurns = follow && thread.turns.length > 1
20
+ ? thread.turns.slice(-1)
21
+ : thread.turns;
22
+ const turnOffset = follow && thread.turns.length > 1
23
+ ? thread.turns.length - 1
24
+ : 0;
25
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { gap: 2, children: [_jsxs(Text, { dimColor: true, children: ["Thread: ", thread.threadId.slice(0, 16)] }), _jsxs(Text, { dimColor: true, children: ["Status: ", thread.status] }), _jsxs(Text, { dimColor: true, children: ["Turns: ", thread.turns.length] })] }), thread.plan && thread.plan.length > 0 && (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { bold: true, children: "Plan:" }), thread.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, { flexDirection: "column", marginTop: 1, children: visibleTurns.map((turn, i) => (_jsx(TurnSection, { turn: turn, index: i + turnOffset, follow: follow }, turn.id))) })] }));
20
26
  }
@@ -10,6 +10,11 @@ function turnStatusColor(status) {
10
10
  return "yellow";
11
11
  return "white";
12
12
  }
13
- export function TurnSection({ turn, index }) {
14
- return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { gap: 1, children: [_jsxs(Text, { bold: true, children: ["Turn #", index + 1] }), _jsx(Text, { color: turnStatusColor(turn.status), children: turn.status }), _jsxs(Text, { dimColor: true, children: ["(", turn.items.length, " items)"] })] }), turn.items.map((item, i) => (_jsx(ItemLine, { item: item, isLast: i === turn.items.length - 1 }, item.id)))] }));
13
+ const FOLLOW_TAIL_SIZE = 8;
14
+ export function TurnSection({ turn, index, follow }) {
15
+ const items = follow && turn.items.length > FOLLOW_TAIL_SIZE
16
+ ? turn.items.slice(-FOLLOW_TAIL_SIZE)
17
+ : turn.items;
18
+ const skipped = turn.items.length - items.length;
19
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { gap: 1, children: [_jsxs(Text, { bold: true, children: ["Turn #", index + 1] }), _jsx(Text, { color: turnStatusColor(turn.status), children: turn.status }), _jsxs(Text, { dimColor: true, children: ["(", turn.items.length, " items)"] })] }), skipped > 0 && _jsxs(Text, { dimColor: true, children: [" ... ", skipped, " earlier items"] }), items.map((item, i) => (_jsx(ItemLine, { item: item, isLast: i === items.length - 1 }, item.id)))] }));
15
20
  }
@@ -7,6 +7,8 @@ export const initialWatchState = {
7
7
  thread: null,
8
8
  report: null,
9
9
  filter: "non-done",
10
+ follow: true,
11
+ detailFeed: [],
10
12
  };
11
13
  const TERMINAL_FACTORY_STATES = new Set(["done", "failed"]);
12
14
  export function filterIssues(issues, filter) {
@@ -46,9 +48,9 @@ export function watchReducer(state, action) {
46
48
  selectedIndex: Math.max(0, Math.min(action.index, state.issues.length - 1)),
47
49
  };
48
50
  case "enter-detail":
49
- return { ...state, view: "detail", activeDetailKey: action.issueKey, thread: null, report: null };
51
+ return { ...state, view: "detail", activeDetailKey: action.issueKey, thread: null, report: null, detailFeed: [] };
50
52
  case "exit-detail":
51
- return { ...state, view: "list", activeDetailKey: null, thread: null, report: null };
53
+ return { ...state, view: "list", activeDetailKey: null, thread: null, report: null, detailFeed: [] };
52
54
  case "thread-snapshot":
53
55
  return { ...state, thread: action.thread };
54
56
  case "report-snapshot":
@@ -57,6 +59,8 @@ export function watchReducer(state, action) {
57
59
  return applyCodexNotification(state, action.method, action.params);
58
60
  case "cycle-filter":
59
61
  return { ...state, filter: nextFilter(state.filter), selectedIndex: 0 };
62
+ case "toggle-follow":
63
+ return { ...state, follow: !state.follow };
60
64
  }
61
65
  }
62
66
  // ─── Feed Event Application ───────────────────────────────────────
@@ -89,7 +93,17 @@ function applyFeedEvent(state, event) {
89
93
  }
90
94
  issue.updatedAt = event.at;
91
95
  updated[index] = issue;
92
- return { ...state, issues: updated };
96
+ // Append to detail feed if this event matches the active detail issue
97
+ const detailFeed = state.view === "detail" && state.activeDetailKey === event.issueKey
98
+ ? [...state.detailFeed, {
99
+ at: event.at,
100
+ kind: event.kind,
101
+ summary: event.summary,
102
+ ...(event.status ? { status: event.status } : {}),
103
+ ...(event.detail ? { detail: event.detail } : {}),
104
+ }]
105
+ : state.detailFeed;
106
+ return { ...state, issues: updated, detailFeed };
93
107
  }
94
108
  // ─── Codex Notification Application ───────────────────────────────
95
109
  function applyCodexNotification(state, method, params) {
@@ -123,6 +137,8 @@ function applyCodexNotification(state, method, params) {
123
137
  return withThread(state, appendItemText(state.thread, params));
124
138
  case "thread/status/changed":
125
139
  return withThread(state, updateThreadStatus(state.thread, params));
140
+ case "thread/tokenUsage/updated":
141
+ return withThread(state, updateTokenUsage(state.thread, params));
126
142
  default:
127
143
  return state;
128
144
  }
@@ -185,7 +201,34 @@ function updatePlan(thread, params) {
185
201
  }
186
202
  function updateDiff(thread, params) {
187
203
  const diff = typeof params.diff === "string" ? params.diff : undefined;
188
- return { ...thread, diff };
204
+ return { ...thread, diff, diffSummary: diff ? parseDiffSummary(diff) : undefined };
205
+ }
206
+ function parseDiffSummary(diff) {
207
+ const files = new Set();
208
+ let added = 0;
209
+ let removed = 0;
210
+ for (const line of diff.split("\n")) {
211
+ if (line.startsWith("+++ b/")) {
212
+ files.add(line.slice(6));
213
+ }
214
+ else if (line.startsWith("+") && !line.startsWith("+++")) {
215
+ added += 1;
216
+ }
217
+ else if (line.startsWith("-") && !line.startsWith("---")) {
218
+ removed += 1;
219
+ }
220
+ }
221
+ return { filesChanged: files.size, linesAdded: added, linesRemoved: removed };
222
+ }
223
+ function updateTokenUsage(thread, params) {
224
+ const usage = params.usage;
225
+ if (!usage)
226
+ return thread;
227
+ const inputTokens = typeof usage.inputTokens === "number" ? usage.inputTokens
228
+ : typeof usage.input_tokens === "number" ? usage.input_tokens : 0;
229
+ const outputTokens = typeof usage.outputTokens === "number" ? usage.outputTokens
230
+ : typeof usage.output_tokens === "number" ? usage.output_tokens : 0;
231
+ return { ...thread, tokenUsage: { inputTokens, outputTokens } };
189
232
  }
190
233
  function updateThreadStatus(thread, params) {
191
234
  const statusObj = params.status;
package/dist/config.js CHANGED
@@ -95,7 +95,7 @@ const configSchema = z.object({
95
95
  approval_policy: z.enum(["never", "on-request", "on-failure", "untrusted"]).default("never"),
96
96
  sandbox_mode: z.enum(["danger-full-access", "workspace-write", "read-only"]).default("danger-full-access"),
97
97
  persist_extended_history: z.boolean().default(false),
98
- experimental_raw_events: z.boolean().default(false),
98
+ experimental_raw_events: z.boolean().default(true),
99
99
  }),
100
100
  }),
101
101
  projects: z.array(projectSchema).default([]),
@@ -131,6 +131,8 @@ export function runPatchRelayMigrations(connection) {
131
131
  connection.prepare("UPDATE webhook_events SET processing_status = 'processed' WHERE processing_status = 'pending' AND payload_json IS NULL").run();
132
132
  // Add pending_merge_prep column for merge queue stewardship
133
133
  addColumnIfMissing(connection, "issues", "pending_merge_prep", "INTEGER NOT NULL DEFAULT 0");
134
+ // Add merge_prep_attempts for retry budget / escalation
135
+ addColumnIfMissing(connection, "issues", "merge_prep_attempts", "INTEGER NOT NULL DEFAULT 0");
134
136
  }
135
137
  function addColumnIfMissing(connection, table, column, definition) {
136
138
  const cols = connection.prepare(`PRAGMA table_info(${table})`).all();
package/dist/db.js CHANGED
@@ -149,6 +149,10 @@ export class PatchRelayDatabase {
149
149
  sets.push("queue_repair_attempts = @queueRepairAttempts");
150
150
  values.queueRepairAttempts = params.queueRepairAttempts;
151
151
  }
152
+ if (params.mergePrepAttempts !== undefined) {
153
+ sets.push("merge_prep_attempts = @mergePrepAttempts");
154
+ values.mergePrepAttempts = params.mergePrepAttempts;
155
+ }
152
156
  if (params.pendingMergePrep !== undefined) {
153
157
  sets.push("pending_merge_prep = @pendingMergePrep");
154
158
  values.pendingMergePrep = params.pendingMergePrep ? 1 : 0;
@@ -385,6 +389,7 @@ function mapIssueRow(row) {
385
389
  ...(row.pr_check_status !== null && row.pr_check_status !== undefined ? { prCheckStatus: String(row.pr_check_status) } : {}),
386
390
  ciRepairAttempts: Number(row.ci_repair_attempts ?? 0),
387
391
  queueRepairAttempts: Number(row.queue_repair_attempts ?? 0),
392
+ mergePrepAttempts: Number(row.merge_prep_attempts ?? 0),
388
393
  pendingMergePrep: Boolean(row.pending_merge_prep),
389
394
  };
390
395
  }
package/dist/http.js CHANGED
@@ -290,6 +290,17 @@ export async function buildHttpServer(config, service, logger) {
290
290
  });
291
291
  }
292
292
  if (managementRoutesEnabled) {
293
+ app.post("/api/issues/:issueKey/retry", async (request, reply) => {
294
+ const issueKey = request.params.issueKey;
295
+ const result = service.retryIssue(issueKey);
296
+ if (!result) {
297
+ return reply.code(404).send({ ok: false, reason: "issue_not_found" });
298
+ }
299
+ if ("error" in result) {
300
+ return reply.code(409).send({ ok: false, reason: result.error });
301
+ }
302
+ return reply.send({ ok: true, ...result });
303
+ });
293
304
  app.get("/api/feed", async (request, reply) => {
294
305
  const feedQuery = {
295
306
  limit: getPositiveIntegerQueryParam(request, "limit") ?? 50,
@@ -82,6 +82,12 @@ export function buildRunFailureActivity(runType, reason) {
82
82
  body: reason ? `${label} failed.\n\n${reason}` : `${label} failed.`,
83
83
  };
84
84
  }
85
+ export function buildStopConfirmationActivity() {
86
+ return {
87
+ type: "response",
88
+ body: "PatchRelay has stopped work as requested. Delegate the issue again or provide new instructions to resume.",
89
+ };
90
+ }
85
91
  export function buildGitHubStateActivity(newState, event) {
86
92
  switch (newState) {
87
93
  case "pr_open": {
@@ -119,6 +125,28 @@ export function buildGitHubStateActivity(newState, event) {
119
125
  return undefined;
120
126
  }
121
127
  }
128
+ export function buildMergePrepActivity(step, detail) {
129
+ switch (step) {
130
+ case "auto_merge":
131
+ return { type: "action", action: "Enabling", parameter: "auto-merge" };
132
+ case "branch_update":
133
+ return { type: "action", action: "Updating", parameter: detail ? `branch to latest ${detail}` : "branch to latest base" };
134
+ case "conflict":
135
+ return { type: "action", action: "Repairing", parameter: "merge conflict with base branch" };
136
+ case "blocked":
137
+ return { type: "error", body: "Branch is up to date but auto-merge could not be enabled — check repository settings." };
138
+ case "fetch_retry":
139
+ return { type: "thought", body: "Merge prep: fetch failed, will retry." };
140
+ case "push_retry":
141
+ return { type: "thought", body: "Merge prep: push failed, will retry." };
142
+ }
143
+ }
144
+ export function buildMergePrepEscalationActivity(attempts) {
145
+ return {
146
+ type: "error",
147
+ body: `Merge preparation failed ${attempts} times due to infrastructure issues. PatchRelay needs human help to continue.`,
148
+ };
149
+ }
122
150
  export function summarizeIssueStateForLinear(issue) {
123
151
  switch (issue.factoryState) {
124
152
  case "awaiting_review":
@@ -1,4 +1,6 @@
1
+ import { buildMergePrepActivity, buildMergePrepEscalationActivity } from "./linear-session-reporting.js";
1
2
  import { execCommand } from "./utils.js";
3
+ const DEFAULT_MERGE_PREP_BUDGET = 3;
2
4
  /**
3
5
  * Merge queue steward — keeps PatchRelay-managed PR branches up to date
4
6
  * with the base branch and enables auto-merge so GitHub merges when CI passes.
@@ -14,12 +16,14 @@ export class MergeQueue {
14
16
  enqueueIssue;
15
17
  logger;
16
18
  feed;
17
- constructor(config, db, enqueueIssue, logger, feed) {
19
+ onLinearActivity;
20
+ constructor(config, db, enqueueIssue, logger, feed, onLinearActivity) {
18
21
  this.config = config;
19
22
  this.db = db;
20
23
  this.enqueueIssue = enqueueIssue;
21
24
  this.logger = logger;
22
25
  this.feed = feed;
26
+ this.onLinearActivity = onLinearActivity;
23
27
  }
24
28
  /**
25
29
  * Prepare the front-of-queue issue for merge:
@@ -43,11 +47,37 @@ export class MergeQueue {
43
47
  this.db.upsertIssue({ projectId: issue.projectId, linearIssueId: issue.linearIssueId, pendingMergePrep: false });
44
48
  return;
45
49
  }
50
+ // Retry budget — escalate after repeated infrastructure failures
51
+ const attempts = issue.mergePrepAttempts + 1;
52
+ this.db.upsertIssue({ projectId: issue.projectId, linearIssueId: issue.linearIssueId, mergePrepAttempts: attempts });
53
+ if (attempts > DEFAULT_MERGE_PREP_BUDGET) {
54
+ this.logger.warn({ issueKey: issue.issueKey, attempts }, "Merge prep budget exhausted, escalating");
55
+ this.db.upsertIssue({
56
+ projectId: issue.projectId,
57
+ linearIssueId: issue.linearIssueId,
58
+ factoryState: "escalated",
59
+ pendingMergePrep: false,
60
+ });
61
+ this.feed?.publish({
62
+ level: "error",
63
+ kind: "workflow",
64
+ issueKey: issue.issueKey,
65
+ projectId: issue.projectId,
66
+ stage: "awaiting_queue",
67
+ status: "escalated",
68
+ summary: `Merge prep failed ${attempts - 1} times — escalating for human help`,
69
+ });
70
+ this.onLinearActivity?.(issue, buildMergePrepEscalationActivity(attempts - 1));
71
+ return;
72
+ }
46
73
  const repoFullName = project.github?.repoFullName;
47
74
  const baseBranch = project.github?.baseBranch ?? "main";
48
75
  const gitBin = this.config.runner.gitBin;
49
76
  // Enable auto-merge (idempotent)
50
77
  const autoMergeOk = repoFullName ? await this.enableAutoMerge(issue, repoFullName) : false;
78
+ if (autoMergeOk) {
79
+ this.onLinearActivity?.(issue, buildMergePrepActivity("auto_merge"), { ephemeral: true });
80
+ }
51
81
  // Fetch latest base branch
52
82
  const fetchResult = await execCommand(gitBin, ["-C", issue.worktreePath, "fetch", "origin", baseBranch], {
53
83
  timeoutMs: 60_000,
@@ -55,6 +85,7 @@ export class MergeQueue {
55
85
  if (fetchResult.exitCode !== 0) {
56
86
  // Transient failure — leave pendingMergePrep set so the next event retries.
57
87
  this.logger.warn({ issueKey: issue.issueKey, stderr: fetchResult.stderr?.slice(0, 300) }, "Merge prep: fetch failed, will retry on next event");
88
+ this.onLinearActivity?.(issue, buildMergePrepActivity("fetch_retry"), { ephemeral: true });
58
89
  return;
59
90
  }
60
91
  // Merge base branch into the PR branch
@@ -72,6 +103,7 @@ export class MergeQueue {
72
103
  pendingRunType: "queue_repair",
73
104
  pendingRunContextJson: JSON.stringify({ failureReason: "merge_conflict" }),
74
105
  pendingMergePrep: false,
106
+ mergePrepAttempts: 0,
75
107
  });
76
108
  this.enqueueIssue(issue.projectId, issue.linearIssueId);
77
109
  this.feed?.publish({
@@ -83,12 +115,13 @@ export class MergeQueue {
83
115
  status: "conflict",
84
116
  summary: `Merge conflict with ${baseBranch} — queue repair enqueued`,
85
117
  });
118
+ this.onLinearActivity?.(issue, buildMergePrepActivity("conflict"));
86
119
  return;
87
120
  }
88
121
  // Check if merge was a no-op (already up to date)
89
122
  if (mergeResult.stdout?.includes("Already up to date")) {
90
123
  this.logger.debug({ issueKey: issue.issueKey }, "Merge prep: branch already up to date");
91
- this.db.upsertIssue({ projectId: issue.projectId, linearIssueId: issue.linearIssueId, pendingMergePrep: false });
124
+ this.db.upsertIssue({ projectId: issue.projectId, linearIssueId: issue.linearIssueId, pendingMergePrep: false, mergePrepAttempts: 0 });
92
125
  if (!autoMergeOk) {
93
126
  this.feed?.publish({
94
127
  level: "warn",
@@ -99,6 +132,7 @@ export class MergeQueue {
99
132
  status: "blocked",
100
133
  summary: "Branch up to date but auto-merge not enabled — check gh auth and repo settings",
101
134
  });
135
+ this.onLinearActivity?.(issue, buildMergePrepActivity("blocked"));
102
136
  }
103
137
  return;
104
138
  }
@@ -109,10 +143,11 @@ export class MergeQueue {
109
143
  if (pushResult.exitCode !== 0) {
110
144
  // Push failed — leave pendingMergePrep set so the next event retries.
111
145
  this.logger.warn({ issueKey: issue.issueKey, stderr: pushResult.stderr?.slice(0, 300) }, "Merge prep: push failed, will retry on next event");
146
+ this.onLinearActivity?.(issue, buildMergePrepActivity("push_retry"), { ephemeral: true });
112
147
  return;
113
148
  }
114
149
  this.logger.info({ issueKey: issue.issueKey, baseBranch }, "Merge prep: branch updated and pushed");
115
- this.db.upsertIssue({ projectId: issue.projectId, linearIssueId: issue.linearIssueId, pendingMergePrep: false });
150
+ this.db.upsertIssue({ projectId: issue.projectId, linearIssueId: issue.linearIssueId, pendingMergePrep: false, mergePrepAttempts: 0 });
116
151
  this.feed?.publish({
117
152
  level: "info",
118
153
  kind: "workflow",
@@ -122,6 +157,7 @@ export class MergeQueue {
122
157
  status: "prepared",
123
158
  summary: `Branch updated to latest ${baseBranch} — CI will run`,
124
159
  });
160
+ this.onLinearActivity?.(issue, buildMergePrepActivity("branch_update", baseBranch), { ephemeral: true });
125
161
  }
126
162
  /**
127
163
  * Seed the merge queue on startup: for each project, ensure the front-of-queue
@@ -304,7 +304,7 @@ export class RunOrchestrator {
304
304
  postRunState = "done";
305
305
  }
306
306
  else {
307
- postRunState = "pr_open";
307
+ postRunState = "awaiting_review";
308
308
  }
309
309
  }
310
310
  this.db.transaction(() => {
package/dist/service.js CHANGED
@@ -35,7 +35,27 @@ export class PatchRelayService {
35
35
  throw new Error("Service runtime enqueueIssue is not initialized");
36
36
  };
37
37
  this.orchestrator = new RunOrchestrator(config, db, codex, this.linearProvider, (projectId, issueId) => enqueueIssue(projectId, issueId), logger, this.feed);
38
- this.mergeQueue = new MergeQueue(config, db, (projectId, issueId) => enqueueIssue(projectId, issueId), logger, this.feed);
38
+ this.mergeQueue = new MergeQueue(config, db, (projectId, issueId) => enqueueIssue(projectId, issueId), logger, this.feed, (issue, content, options) => {
39
+ if (!issue.agentSessionId)
40
+ return;
41
+ void (async () => {
42
+ try {
43
+ const linear = await this.linearProvider.forProject(issue.projectId);
44
+ if (!linear)
45
+ return;
46
+ const allowEphemeral = content.type === "thought" || content.type === "action";
47
+ await linear.createAgentActivity({
48
+ agentSessionId: issue.agentSessionId,
49
+ content,
50
+ ...(options?.ephemeral && allowEphemeral ? { ephemeral: true } : {}),
51
+ });
52
+ }
53
+ catch (error) {
54
+ const msg = error instanceof Error ? error.message : String(error);
55
+ logger.warn({ issueKey: issue.issueKey, type: content.type, error: msg }, "Failed to emit merge-prep Linear activity");
56
+ }
57
+ })();
58
+ });
39
59
  this.webhookHandler = new WebhookHandler(config, db, this.linearProvider, codex, (projectId, issueId) => enqueueIssue(projectId, issueId), logger, this.feed);
40
60
  this.githubWebhookHandler = new GitHubWebhookHandler(config, db, this.linearProvider, (projectId, issueId) => enqueueIssue(projectId, issueId), this.mergeQueue, logger, this.feed);
41
61
  const runtime = new ServiceRuntime(codex, logger, this.orchestrator, { listIssuesReadyForExecution: () => db.listIssuesReadyForExecution() }, this.webhookHandler, {
@@ -198,6 +218,22 @@ export class PatchRelayService {
198
218
  this.codex.on("notification", handler);
199
219
  return () => { this.codex.off("notification", handler); };
200
220
  }
221
+ retryIssue(issueKey) {
222
+ const issue = this.db.getIssueByKey(issueKey);
223
+ if (!issue)
224
+ return undefined;
225
+ if (issue.activeRunId)
226
+ return { error: "Issue already has an active run" };
227
+ const runType = "implementation";
228
+ this.db.upsertIssue({
229
+ projectId: issue.projectId,
230
+ linearIssueId: issue.linearIssueId,
231
+ pendingRunType: runType,
232
+ factoryState: "delegated",
233
+ });
234
+ this.runtime.enqueueIssue(issue.projectId, issue.linearIssueId);
235
+ return { issueKey, runType };
236
+ }
201
237
  listOperatorFeed(options) {
202
238
  return this.feed.list(options);
203
239
  }
@@ -1,6 +1,6 @@
1
1
  import { buildAgentSessionPlanForIssue, } from "./agent-session-plan.js";
2
2
  import { buildAgentSessionExternalUrls } from "./agent-session-presentation.js";
3
- import { buildAlreadyRunningThought, buildDelegationThought, buildPromptDeliveredThought, } from "./linear-session-reporting.js";
3
+ import { buildAlreadyRunningThought, buildDelegationThought, buildPromptDeliveredThought, buildStopConfirmationActivity, } from "./linear-session-reporting.js";
4
4
  import { resolveProject, triggerEventAllowed, trustedActorAllowed } from "./project-resolution.js";
5
5
  import { normalizeWebhook } from "./webhooks.js";
6
6
  import { InstallationWebhookHandler } from "./webhook-installation-handler.js";
@@ -210,6 +210,11 @@ export class WebhookHandler {
210
210
  });
211
211
  return;
212
212
  }
213
+ // Stop signal — halt active work and confirm disengagement
214
+ if (normalized.triggerEvent === "agentSignal" && normalized.agentSession.signal === "stop") {
215
+ await this.handleStopSignal(normalized, project, trackedIssue, existingIssue, activeRun, linear);
216
+ return;
217
+ }
213
218
  if (normalized.triggerEvent !== "agentPrompted")
214
219
  return;
215
220
  if (!triggerEventAllowed(project, normalized.triggerEvent))
@@ -251,6 +256,43 @@ export class WebhookHandler {
251
256
  await this.publishAgentActivity(linear, normalized.agentSession.id, buildDelegationThought(desiredStage, "prompt"), { ephemeral: true });
252
257
  }
253
258
  }
259
+ // ─── Stop signal handling ────────────────────────────────────────
260
+ async handleStopSignal(normalized, project, trackedIssue, existingIssue, activeRun, linear) {
261
+ const issueId = normalized.issue.id;
262
+ const sessionId = normalized.agentSession.id;
263
+ // Best-effort halt: steer the active Codex turn with a stop instruction
264
+ if (activeRun?.threadId && activeRun.turnId) {
265
+ try {
266
+ await this.codex.steerTurn({
267
+ threadId: activeRun.threadId,
268
+ turnId: activeRun.turnId,
269
+ input: "STOP: The user has requested you stop working immediately. Do not make further changes. Wrap up and exit.",
270
+ });
271
+ }
272
+ catch (error) {
273
+ this.logger.warn({ issueKey: trackedIssue?.issueKey, error: error instanceof Error ? error.message : String(error) }, "Failed to steer Codex turn for stop signal");
274
+ }
275
+ this.db.finishRun(activeRun.id, { status: "released", threadId: activeRun.threadId, turnId: activeRun.turnId });
276
+ }
277
+ this.db.upsertIssue({
278
+ projectId: project.id,
279
+ linearIssueId: issueId,
280
+ activeRunId: null,
281
+ factoryState: "awaiting_input",
282
+ agentSessionId: sessionId,
283
+ });
284
+ this.feed?.publish({
285
+ level: "info",
286
+ kind: "agent",
287
+ projectId: project.id,
288
+ issueKey: trackedIssue?.issueKey,
289
+ status: "stopped",
290
+ summary: "Stop signal received — work halted",
291
+ });
292
+ const updatedIssue = this.db.getIssue(project.id, issueId);
293
+ await this.publishAgentActivity(linear, sessionId, buildStopConfirmationActivity());
294
+ await this.syncAgentSession(linear, sessionId, updatedIssue ?? trackedIssue);
295
+ }
254
296
  // ─── Comment handling (inlined) ───────────────────────────────────
255
297
  async handleComment(normalized, project, trackedIssue) {
256
298
  if ((normalized.triggerEvent !== "commentCreated" && normalized.triggerEvent !== "commentUpdated") ||
package/dist/webhooks.js CHANGED
@@ -46,6 +46,18 @@ function deriveTriggerEvent(payload) {
46
46
  ["resource", "agentSession"],
47
47
  ])) || Boolean(getString(data, "agentSessionId"));
48
48
  if (payload.type === "AgentSessionEvent" || payload.type === "AgentSession" || hasAgentSession) {
49
+ // Detect signal-bearing payloads (e.g. stop signal from Linear)
50
+ const agentActivityForSignal = getFirstNestedRecord(data, [
51
+ ["agentActivity"],
52
+ ["agentSession", "agentActivity"],
53
+ ["session", "agentActivity"],
54
+ ["agentSessionEvent", "agentActivity"],
55
+ ["payload", "agentActivity"],
56
+ ["resource", "agentActivity"],
57
+ ]);
58
+ if (agentActivityForSignal && getString(agentActivityForSignal, "signal")) {
59
+ return "agentSignal";
60
+ }
49
61
  if (payload.action === "created" || payload.action === "create") {
50
62
  return "agentSessionCreated";
51
63
  }
@@ -310,11 +322,15 @@ function extractAgentSessionMetadata(payload) {
310
322
  getString(commentRecord ?? {}, "body") ??
311
323
  getString(data, "body");
312
324
  const issueCommentId = getString(commentRecord ?? {}, "id") ?? getString(data, "issueCommentId");
325
+ const signal = getString(agentActivity ?? {}, "signal");
326
+ const signalMetadata = asRecord((agentActivity ?? {}).signalMetadata);
313
327
  return {
314
328
  id,
315
329
  ...(promptContext ? { promptContext } : {}),
316
330
  ...(promptBody ? { promptBody } : {}),
317
331
  ...(issueCommentId ? { issueCommentId } : {}),
332
+ ...(signal ? { signal } : {}),
333
+ ...(signalMetadata ? { signalMetadata } : {}),
318
334
  };
319
335
  }
320
336
  function extractInstallationMetadata(payload) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "patchrelay",
3
- "version": "0.14.2",
3
+ "version": "0.16.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "repository": {