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 +11 -8
- package/dist/build-info.json +3 -3
- package/dist/cli/watch/App.js +23 -2
- package/dist/cli/watch/FeedTimeline.js +23 -0
- package/dist/cli/watch/HelpBar.js +2 -2
- package/dist/cli/watch/IssueDetailView.js +15 -4
- package/dist/cli/watch/ThreadView.js +8 -2
- package/dist/cli/watch/TurnSection.js +7 -2
- package/dist/cli/watch/watch-state.js +47 -4
- package/dist/config.js +1 -1
- package/dist/db/migrations.js +2 -0
- package/dist/db.js +5 -0
- package/dist/http.js +11 -0
- package/dist/linear-session-reporting.js +28 -0
- package/dist/merge-queue.js +39 -3
- package/dist/run-orchestrator.js +1 -1
- package/dist/service.js +37 -1
- package/dist/webhook-handler.js +43 -1
- package/dist/webhooks.js +16 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# PatchRelay
|
|
2
2
|
|
|
3
|
-
PatchRelay is a self-hosted harness for Linear
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
package/dist/build-info.json
CHANGED
package/dist/cli/watch/App.js
CHANGED
|
@@ -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
|
-
:
|
|
6
|
+
: `Esc: back f: follow ${follow ? "on" : "off"} r: retry q: quit` }) }));
|
|
7
7
|
}
|
|
@@ -1,18 +1,29 @@
|
|
|
1
|
-
import { jsx as _jsx,
|
|
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
|
-
|
|
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
|
-
|
|
14
|
-
|
|
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
|
-
|
|
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(
|
|
98
|
+
experimental_raw_events: z.boolean().default(true),
|
|
99
99
|
}),
|
|
100
100
|
}),
|
|
101
101
|
projects: z.array(projectSchema).default([]),
|
package/dist/db/migrations.js
CHANGED
|
@@ -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":
|
package/dist/merge-queue.js
CHANGED
|
@@ -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
|
-
|
|
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
|
package/dist/run-orchestrator.js
CHANGED
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
|
}
|
package/dist/webhook-handler.js
CHANGED
|
@@ -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) {
|