patchrelay 0.23.4 → 0.24.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/dist/build-info.json +3 -3
- package/dist/cli/watch/App.js +7 -1
- package/dist/cli/watch/HelpBar.js +9 -4
- package/dist/cli/watch/IssueDetailView.js +9 -19
- package/dist/cli/watch/StateHistoryView.js +96 -0
- package/dist/cli/watch/history-builder.js +204 -0
- package/dist/cli/watch/plan-helpers.js +14 -0
- package/dist/cli/watch/watch-state.js +14 -3
- package/dist/db/migrations.js +3 -0
- package/dist/db.js +10 -0
- package/dist/github-webhook-handler.js +5 -1
- package/dist/run-orchestrator.js +100 -24
- package/dist/worktree-manager.js +7 -1
- package/package.json +1 -1
package/dist/build-info.json
CHANGED
package/dist/cli/watch/App.js
CHANGED
|
@@ -171,6 +171,12 @@ export function App({ baseUrl, bearerToken, initialIssueKey }) {
|
|
|
171
171
|
});
|
|
172
172
|
}
|
|
173
173
|
}
|
|
174
|
+
else if (input === "h") {
|
|
175
|
+
dispatch({ type: "switch-detail-tab", tab: "history" });
|
|
176
|
+
}
|
|
177
|
+
else if (input === "t") {
|
|
178
|
+
dispatch({ type: "switch-detail-tab", tab: "timeline" });
|
|
179
|
+
}
|
|
174
180
|
else if (input === "j" || key.downArrow) {
|
|
175
181
|
dispatch({ type: "detail-navigate", direction: "next", filtered });
|
|
176
182
|
}
|
|
@@ -184,5 +190,5 @@ export function App({ baseUrl, bearerToken, initialIssueKey }) {
|
|
|
184
190
|
}
|
|
185
191
|
}
|
|
186
192
|
});
|
|
187
|
-
return (_jsx(Box, { flexDirection: "column", children: state.view === "list" ? (_jsx(IssueListView, { issues: filtered, allIssues: state.issues, selectedIndex: state.selectedIndex, connected: state.connected, filter: state.filter, totalCount: state.issues.length })) : state.view === "detail" ? (_jsxs(Box, { flexDirection: "column", children: [_jsx(IssueDetailView, { issue: state.issues.find((i) => i.issueKey === state.activeDetailKey), timeline: state.timeline, follow: state.follow, activeRunStartedAt: state.activeRunStartedAt, tokenUsage: state.tokenUsage, diffSummary: state.diffSummary, plan: state.plan, issueContext: state.issueContext }), 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 }))] })) : (_jsx(FeedView, { events: state.feedEvents, connected: state.connected })) }));
|
|
193
|
+
return (_jsx(Box, { flexDirection: "column", children: state.view === "list" ? (_jsx(IssueListView, { issues: filtered, allIssues: state.issues, selectedIndex: state.selectedIndex, connected: state.connected, filter: state.filter, totalCount: state.issues.length })) : state.view === "detail" ? (_jsxs(Box, { flexDirection: "column", children: [_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, rawRuns: state.rawRuns, rawFeedEvents: state.rawFeedEvents }), 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 }))] })) : (_jsx(FeedView, { events: state.feedEvents, connected: state.connected })) }));
|
|
188
194
|
}
|
|
@@ -5,9 +5,14 @@ const HELP_TEXT = {
|
|
|
5
5
|
detail: "",
|
|
6
6
|
feed: "Esc: list q: quit",
|
|
7
7
|
};
|
|
8
|
-
export function HelpBar({ view, follow }) {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
:
|
|
8
|
+
export function HelpBar({ view, follow, detailTab }) {
|
|
9
|
+
let text;
|
|
10
|
+
if (view === "detail") {
|
|
11
|
+
const tabHint = detailTab === "history" ? "t: timeline" : "h: history";
|
|
12
|
+
text = `${tabHint} j/k: prev/next Esc: list f: follow ${follow ? "on" : "off"} p: prompt s: stop r: retry q: quit`;
|
|
13
|
+
}
|
|
14
|
+
else {
|
|
15
|
+
text = HELP_TEXT[view];
|
|
16
|
+
}
|
|
12
17
|
return (_jsx(Box, { children: _jsx(Text, { dimColor: true, children: text }) }));
|
|
13
18
|
}
|
|
@@ -1,8 +1,11 @@
|
|
|
1
|
-
import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
-
import { useEffect, useReducer } from "react";
|
|
1
|
+
import { jsxs as _jsxs, jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
import { useEffect, useMemo, useReducer } from "react";
|
|
3
3
|
import { Box, Text } from "ink";
|
|
4
4
|
import { Timeline } from "./Timeline.js";
|
|
5
|
+
import { StateHistoryView } from "./StateHistoryView.js";
|
|
6
|
+
import { buildStateHistory } from "./history-builder.js";
|
|
5
7
|
import { HelpBar } from "./HelpBar.js";
|
|
8
|
+
import { planStepSymbol, planStepColor } from "./plan-helpers.js";
|
|
6
9
|
function formatTokens(n) {
|
|
7
10
|
if (n >= 1_000_000)
|
|
8
11
|
return `${(n / 1_000_000).toFixed(1)}M`;
|
|
@@ -21,23 +24,9 @@ function ElapsedTime({ startedAt }) {
|
|
|
21
24
|
const seconds = elapsed % 60;
|
|
22
25
|
return _jsxs(Text, { dimColor: true, children: [minutes, "m ", String(seconds).padStart(2, "0"), "s"] });
|
|
23
26
|
}
|
|
24
|
-
function
|
|
25
|
-
if (status === "completed")
|
|
26
|
-
return "\u2713";
|
|
27
|
-
if (status === "inProgress")
|
|
28
|
-
return "\u25b8";
|
|
29
|
-
return " ";
|
|
30
|
-
}
|
|
31
|
-
function planStepColor(status) {
|
|
32
|
-
if (status === "completed")
|
|
33
|
-
return "green";
|
|
34
|
-
if (status === "inProgress")
|
|
35
|
-
return "yellow";
|
|
36
|
-
return "white";
|
|
37
|
-
}
|
|
38
|
-
export function IssueDetailView({ issue, timeline, follow, activeRunStartedAt, tokenUsage, diffSummary, plan, issueContext, }) {
|
|
27
|
+
export function IssueDetailView({ issue, timeline, follow, activeRunStartedAt, activeRunId, tokenUsage, diffSummary, plan, issueContext, detailTab, rawRuns, rawFeedEvents, }) {
|
|
39
28
|
if (!issue) {
|
|
40
|
-
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: "red", children: "Issue not found." }), _jsx(HelpBar, { view: "detail", follow: follow })] }));
|
|
29
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: "red", children: "Issue not found." }), _jsx(HelpBar, { view: "detail", follow: follow, detailTab: detailTab })] }));
|
|
41
30
|
}
|
|
42
31
|
const key = issue.issueKey ?? issue.projectId;
|
|
43
32
|
const meta = [];
|
|
@@ -47,5 +36,6 @@ export function IssueDetailView({ issue, timeline, follow, activeRunStartedAt, t
|
|
|
47
36
|
meta.push(`${diffSummary.filesChanged}f +${diffSummary.linesAdded} -${diffSummary.linesRemoved}`);
|
|
48
37
|
if (issueContext?.runCount)
|
|
49
38
|
meta.push(`${issueContext.runCount} runs`);
|
|
50
|
-
|
|
39
|
+
const history = useMemo(() => buildStateHistory(rawRuns, rawFeedEvents, issue.factoryState, activeRunId), [rawRuns, rawFeedEvents, issue.factoryState, activeRunId]);
|
|
40
|
+
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: ["#", issue.prNumber] }), activeRunStartedAt && _jsx(ElapsedTime, { startedAt: activeRunStartedAt }), meta.length > 0 && _jsx(Text, { dimColor: true, children: meta.join(" ") }), follow && _jsx(Text, { color: "yellow", children: "follow" })] }), issue.title && _jsx(Text, { children: issue.title }), detailTab === "timeline" ? (_jsxs(_Fragment, { children: [plan && plan.length > 0 && (_jsx(Box, { flexDirection: "column", marginTop: 1, children: 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 }) })] })) : (_jsx(StateHistoryView, { history: history, plan: plan, activeRunId: activeRunId })), _jsx(Box, { marginTop: 1, children: _jsx(HelpBar, { view: "detail", follow: follow, detailTab: detailTab }) })] }));
|
|
51
41
|
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from "ink";
|
|
3
|
+
import { planStepSymbol, planStepColor } from "./plan-helpers.js";
|
|
4
|
+
// ─── Formatting helpers ──────────────────────────────────────────
|
|
5
|
+
function formatTime(iso) {
|
|
6
|
+
return new Date(iso).toLocaleTimeString("en-GB", { hour12: false, hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
|
7
|
+
}
|
|
8
|
+
function formatDuration(startedAt, endedAt) {
|
|
9
|
+
const ms = new Date(endedAt).getTime() - new Date(startedAt).getTime();
|
|
10
|
+
const seconds = Math.floor(ms / 1000);
|
|
11
|
+
if (seconds < 60)
|
|
12
|
+
return `${seconds}s`;
|
|
13
|
+
const minutes = Math.floor(seconds / 60);
|
|
14
|
+
const s = seconds % 60;
|
|
15
|
+
return `${minutes}m${s > 0 ? `${String(s).padStart(2, "0")}s` : ""}`;
|
|
16
|
+
}
|
|
17
|
+
const RUN_LABELS = {
|
|
18
|
+
implementation: "implementation",
|
|
19
|
+
ci_repair: "ci repair",
|
|
20
|
+
review_fix: "review fix",
|
|
21
|
+
queue_repair: "queue repair",
|
|
22
|
+
};
|
|
23
|
+
function runStatusSymbol(status) {
|
|
24
|
+
if (status === "completed")
|
|
25
|
+
return "\u2713";
|
|
26
|
+
if (status === "failed")
|
|
27
|
+
return "\u2717";
|
|
28
|
+
if (status === "running")
|
|
29
|
+
return "\u25b8";
|
|
30
|
+
return " ";
|
|
31
|
+
}
|
|
32
|
+
function runStatusColor(status) {
|
|
33
|
+
if (status === "completed")
|
|
34
|
+
return "green";
|
|
35
|
+
if (status === "failed")
|
|
36
|
+
return "red";
|
|
37
|
+
if (status === "running")
|
|
38
|
+
return "yellow";
|
|
39
|
+
return "white";
|
|
40
|
+
}
|
|
41
|
+
const STATE_LABELS = {
|
|
42
|
+
delegated: "delegated",
|
|
43
|
+
preparing: "preparing",
|
|
44
|
+
implementing: "implementing",
|
|
45
|
+
pr_open: "pr open",
|
|
46
|
+
changes_requested: "changes requested",
|
|
47
|
+
repairing_ci: "repairing ci",
|
|
48
|
+
awaiting_queue: "awaiting queue",
|
|
49
|
+
repairing_queue: "repairing queue",
|
|
50
|
+
awaiting_input: "awaiting input",
|
|
51
|
+
escalated: "escalated",
|
|
52
|
+
done: "done",
|
|
53
|
+
failed: "failed",
|
|
54
|
+
};
|
|
55
|
+
// ─── Sub-components ──────────────────────────────────────────────
|
|
56
|
+
function RunLine({ run, index }) {
|
|
57
|
+
const label = RUN_LABELS[run.runType] ?? run.runType;
|
|
58
|
+
const dur = run.endedAt
|
|
59
|
+
? formatDuration(run.startedAt, run.endedAt)
|
|
60
|
+
: undefined;
|
|
61
|
+
const isActive = run.status === "running";
|
|
62
|
+
return (_jsxs(Box, { children: [_jsxs(Text, { color: runStatusColor(run.status), children: [runStatusSymbol(run.status), " "] }), _jsxs(Text, { dimColor: true, children: ["run #", index + 1, " "] }), _jsxs(Text, { children: ["(", label, ")"] }), dur && _jsxs(Text, { dimColor: true, children: [" ", dur] }), isActive && _jsx(Text, { dimColor: true, children: " ..." })] }));
|
|
63
|
+
}
|
|
64
|
+
function PlanSteps({ plan }) {
|
|
65
|
+
return (_jsx(Box, { flexDirection: "column", paddingLeft: 2, children: 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}`))) }));
|
|
66
|
+
}
|
|
67
|
+
function SideTripBlock({ trip, runOffset, isLast, }) {
|
|
68
|
+
const stateLabel = STATE_LABELS[trip.state] ?? trip.state;
|
|
69
|
+
const hasReturn = trip.returnedAt && trip.returnState !== trip.state;
|
|
70
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { dimColor: true, children: " \u2502 \u250c " }), _jsx(Text, { color: "magenta", bold: true, children: stateLabel }), _jsxs(Text, { dimColor: true, children: [" ", formatTime(trip.enteredAt)] })] }), trip.reason && (_jsxs(Box, { children: [_jsx(Text, { dimColor: true, children: " \u2502 \u2502 " }), _jsx(Text, { dimColor: true, children: trip.reason })] })), trip.runs.map((run, ri) => (_jsxs(Box, { children: [_jsx(Text, { dimColor: true, children: " \u2502 \u2502 " }), _jsx(RunLine, { run: run, index: runOffset + ri })] }, `st-run-${run.id}`))), hasReturn ? (_jsxs(Box, { children: [_jsx(Text, { dimColor: true, children: " \u2502 \u2514\u2192 " }), _jsx(Text, { children: STATE_LABELS[trip.returnState] ?? trip.returnState }), trip.returnedAt && _jsxs(Text, { dimColor: true, children: [" ", formatTime(trip.returnedAt)] })] })) : (_jsxs(Box, { children: [_jsx(Text, { dimColor: true, children: " \u2502 \u2514\u2500" }), _jsx(Text, { dimColor: true, children: " (active)" })] })), !isLast && (_jsx(Box, { children: _jsx(Text, { dimColor: true, children: " \u2502" }) }))] }));
|
|
71
|
+
}
|
|
72
|
+
function MainPathNode({ node, isLast, runOffset, plan, activeRunId, }) {
|
|
73
|
+
const stateLabel = STATE_LABELS[node.state] ?? node.state;
|
|
74
|
+
const marker = node.isCurrent ? "\u25c9" : "\u25cb";
|
|
75
|
+
const stateColor = node.isCurrent ? "green" : "white";
|
|
76
|
+
const hasActiveRun = node.runs.some((r) => r.id === activeRunId);
|
|
77
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsxs(Text, { color: stateColor, bold: node.isCurrent, children: [" ", marker, " "] }), _jsx(Text, { color: stateColor, bold: node.isCurrent, children: stateLabel }), _jsxs(Text, { dimColor: true, children: [" ", formatTime(node.enteredAt)] })] }), node.reason && (_jsxs(Box, { children: [_jsx(Text, { dimColor: true, children: isLast && node.sideTrips.length === 0 ? " " : " \u2502 " }), _jsx(Text, { dimColor: true, children: node.reason })] })), node.runs.map((run, ri) => (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { dimColor: true, children: isLast && node.sideTrips.length === 0 ? " " : " \u2502 " }), _jsx(RunLine, { run: run, index: runOffset + ri })] }), run.id === activeRunId && plan && plan.length > 0 && (_jsxs(Box, { children: [_jsx(Text, { dimColor: true, children: isLast ? " " : " \u2502 " }), _jsx(PlanSteps, { plan: plan })] }))] }, `run-${run.id}`))), node.sideTrips.length > 0 && (_jsx(Box, { flexDirection: "column", children: node.sideTrips.map((trip, ti) => {
|
|
78
|
+
// Count runs before this side-trip for numbering
|
|
79
|
+
const priorSideTripRuns = node.sideTrips.slice(0, ti).reduce((acc, st) => acc + st.runs.length, 0);
|
|
80
|
+
const tripRunOffset = runOffset + node.runs.length + priorSideTripRuns;
|
|
81
|
+
return (_jsx(SideTripBlock, { trip: trip, runOffset: tripRunOffset, isLast: ti === node.sideTrips.length - 1 }, `trip-${ti}`));
|
|
82
|
+
}) })), !isLast && (_jsx(Box, { children: _jsx(Text, { dimColor: true, children: " \u2502" }) }))] }));
|
|
83
|
+
}
|
|
84
|
+
// ─── Main component ──────────────────────────────────────────────
|
|
85
|
+
export function StateHistoryView({ history, plan, activeRunId }) {
|
|
86
|
+
if (history.length === 0) {
|
|
87
|
+
return (_jsx(Box, { children: _jsx(Text, { dimColor: true, children: "No state history available." }) }));
|
|
88
|
+
}
|
|
89
|
+
// Compute global run numbering (sequential across all nodes)
|
|
90
|
+
let runCounter = 0;
|
|
91
|
+
return (_jsx(Box, { flexDirection: "column", marginTop: 1, children: history.map((node, i) => {
|
|
92
|
+
const offset = runCounter;
|
|
93
|
+
runCounter += node.runs.length + node.sideTrips.reduce((acc, st) => acc + st.runs.length, 0);
|
|
94
|
+
return (_jsx(MainPathNode, { node: node, isLast: i === history.length - 1, runOffset: offset, plan: plan, activeRunId: activeRunId }, `node-${i}`));
|
|
95
|
+
}) }));
|
|
96
|
+
}
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
// ─── Constants ───────────────────────────────────────────────────
|
|
2
|
+
const SIDE_TRIP_STATES = new Set(["changes_requested", "repairing_ci", "repairing_queue"]);
|
|
3
|
+
const RUN_TYPE_TO_STATE = {
|
|
4
|
+
implementation: "implementing",
|
|
5
|
+
ci_repair: "repairing_ci",
|
|
6
|
+
review_fix: "changes_requested",
|
|
7
|
+
queue_repair: "repairing_queue",
|
|
8
|
+
};
|
|
9
|
+
function extractTransitions(feedEvents) {
|
|
10
|
+
const transitions = [];
|
|
11
|
+
for (const event of feedEvents) {
|
|
12
|
+
if (!event.stage)
|
|
13
|
+
continue;
|
|
14
|
+
let state;
|
|
15
|
+
let reason;
|
|
16
|
+
if (event.kind === "stage") {
|
|
17
|
+
if (event.status === "starting") {
|
|
18
|
+
// stage field is runType, map to factory state
|
|
19
|
+
state = RUN_TYPE_TO_STATE[event.stage] ?? event.stage;
|
|
20
|
+
reason = event.summary;
|
|
21
|
+
}
|
|
22
|
+
else if (event.status === "reconciled" || event.status === "retry" || event.status === "queued") {
|
|
23
|
+
state = event.stage;
|
|
24
|
+
reason = event.summary;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
else if (event.kind === "github") {
|
|
28
|
+
// stage field is the factory state AFTER the transition
|
|
29
|
+
state = event.stage;
|
|
30
|
+
reason = event.summary;
|
|
31
|
+
}
|
|
32
|
+
if (state) {
|
|
33
|
+
// Deduplicate consecutive identical states
|
|
34
|
+
if (transitions.length > 0 && transitions[transitions.length - 1].state === state) {
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
transitions.push({ state, at: event.at, reason });
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return transitions;
|
|
41
|
+
}
|
|
42
|
+
// ─── Run matching ────────────────────────────────────────────────
|
|
43
|
+
function buildRunQueue(runs) {
|
|
44
|
+
// Group runs by their corresponding factory state, preserving chronological order.
|
|
45
|
+
// Each call to consumeNextRun() pops from the front.
|
|
46
|
+
const map = new Map();
|
|
47
|
+
for (const run of runs) {
|
|
48
|
+
const state = RUN_TYPE_TO_STATE[run.runType] ?? run.runType;
|
|
49
|
+
const info = {
|
|
50
|
+
id: run.id,
|
|
51
|
+
runType: run.runType,
|
|
52
|
+
status: run.status,
|
|
53
|
+
startedAt: run.startedAt,
|
|
54
|
+
endedAt: run.endedAt,
|
|
55
|
+
};
|
|
56
|
+
const list = map.get(state);
|
|
57
|
+
if (list) {
|
|
58
|
+
list.push(info);
|
|
59
|
+
}
|
|
60
|
+
else {
|
|
61
|
+
map.set(state, [info]);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return map;
|
|
65
|
+
}
|
|
66
|
+
// ─── Tree builder ────────────────────────────────────────────────
|
|
67
|
+
export function buildStateHistory(runs, feedEvents, currentFactoryState, activeRunId) {
|
|
68
|
+
const transitions = extractTransitions(feedEvents);
|
|
69
|
+
const runQueue = buildRunQueue(runs);
|
|
70
|
+
function consumeNextRun(state) {
|
|
71
|
+
const queue = runQueue.get(state);
|
|
72
|
+
if (!queue || queue.length === 0)
|
|
73
|
+
return [];
|
|
74
|
+
const run = queue.shift();
|
|
75
|
+
return run ? [run] : [];
|
|
76
|
+
}
|
|
77
|
+
// Walk transitions and build nodes
|
|
78
|
+
const nodes = [];
|
|
79
|
+
let currentMainNode = null;
|
|
80
|
+
let currentSideTrip = null;
|
|
81
|
+
for (let i = 0; i < transitions.length; i++) {
|
|
82
|
+
const t = transitions[i];
|
|
83
|
+
const isSideTrip = SIDE_TRIP_STATES.has(t.state);
|
|
84
|
+
if (isSideTrip) {
|
|
85
|
+
// Start or continue a side-trip
|
|
86
|
+
if (currentSideTrip) {
|
|
87
|
+
// Close previous side-trip first (nested side-trip is rare but handle it)
|
|
88
|
+
closeSideTrip(currentMainNode, currentSideTrip, t.state, t.at);
|
|
89
|
+
}
|
|
90
|
+
currentSideTrip = {
|
|
91
|
+
state: t.state,
|
|
92
|
+
enteredAt: t.at,
|
|
93
|
+
reason: t.reason,
|
|
94
|
+
returnState: "",
|
|
95
|
+
runs: [],
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
else {
|
|
99
|
+
// Main-path state
|
|
100
|
+
if (currentSideTrip && currentMainNode) {
|
|
101
|
+
// Close the active side-trip — we're returning to the main path
|
|
102
|
+
// Consume runs for the side-trip state now
|
|
103
|
+
currentSideTrip.runs = consumeNextRun(currentSideTrip.state);
|
|
104
|
+
currentSideTrip.returnState = t.state;
|
|
105
|
+
currentSideTrip.returnedAt = t.at;
|
|
106
|
+
currentMainNode.sideTrips.push(currentSideTrip);
|
|
107
|
+
currentSideTrip = null;
|
|
108
|
+
}
|
|
109
|
+
// Skip duplicate main-path nodes if returning to the same state (e.g., pr_open → changes_requested → pr_open)
|
|
110
|
+
if (currentMainNode && currentMainNode.state === t.state) {
|
|
111
|
+
// Same main-path state revisited — don't create a new node
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
currentMainNode = {
|
|
115
|
+
state: t.state,
|
|
116
|
+
enteredAt: t.at,
|
|
117
|
+
reason: t.reason,
|
|
118
|
+
isCurrent: false,
|
|
119
|
+
runs: consumeNextRun(t.state),
|
|
120
|
+
sideTrips: [],
|
|
121
|
+
};
|
|
122
|
+
nodes.push(currentMainNode);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
// If we ended in a side-trip (e.g., currently repairing_ci), close it
|
|
126
|
+
if (currentSideTrip && currentMainNode) {
|
|
127
|
+
currentSideTrip.runs = consumeNextRun(currentSideTrip.state);
|
|
128
|
+
currentSideTrip.returnState = currentFactoryState;
|
|
129
|
+
currentMainNode.sideTrips.push(currentSideTrip);
|
|
130
|
+
}
|
|
131
|
+
// Handle edge case: no transitions extracted but we have runs
|
|
132
|
+
if (nodes.length === 0 && runs.length > 0) {
|
|
133
|
+
// Seed with delegated state from earliest run
|
|
134
|
+
const earliest = runs[0];
|
|
135
|
+
nodes.push({
|
|
136
|
+
state: "delegated",
|
|
137
|
+
enteredAt: earliest.startedAt,
|
|
138
|
+
isCurrent: currentFactoryState === "delegated",
|
|
139
|
+
runs: [],
|
|
140
|
+
sideTrips: [],
|
|
141
|
+
});
|
|
142
|
+
const implState = RUN_TYPE_TO_STATE[earliest.runType] ?? "implementing";
|
|
143
|
+
nodes.push({
|
|
144
|
+
state: implState,
|
|
145
|
+
enteredAt: earliest.startedAt,
|
|
146
|
+
isCurrent: currentFactoryState === implState,
|
|
147
|
+
runs: consumeNextRun(implState),
|
|
148
|
+
sideTrips: [],
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
// Mark the current state
|
|
152
|
+
markCurrent(nodes, currentFactoryState);
|
|
153
|
+
// Mark active run
|
|
154
|
+
if (activeRunId !== null) {
|
|
155
|
+
markActiveRun(nodes, activeRunId);
|
|
156
|
+
}
|
|
157
|
+
return nodes;
|
|
158
|
+
}
|
|
159
|
+
function closeSideTrip(mainNode, sideTrip, returnState, returnedAt) {
|
|
160
|
+
if (!mainNode)
|
|
161
|
+
return;
|
|
162
|
+
sideTrip.returnState = returnState;
|
|
163
|
+
sideTrip.returnedAt = returnedAt;
|
|
164
|
+
mainNode.sideTrips.push(sideTrip);
|
|
165
|
+
}
|
|
166
|
+
function markCurrent(nodes, currentState) {
|
|
167
|
+
// If current state is a side-trip state, mark the last main node as current
|
|
168
|
+
// (the side-trip is "in progress" from that main node)
|
|
169
|
+
if (SIDE_TRIP_STATES.has(currentState)) {
|
|
170
|
+
if (nodes.length > 0) {
|
|
171
|
+
nodes[nodes.length - 1].isCurrent = true;
|
|
172
|
+
}
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
// Find the last node matching the current state
|
|
176
|
+
for (let i = nodes.length - 1; i >= 0; i--) {
|
|
177
|
+
if (nodes[i].state === currentState) {
|
|
178
|
+
nodes[i].isCurrent = true;
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
// Fallback: mark the last node
|
|
183
|
+
if (nodes.length > 0) {
|
|
184
|
+
nodes[nodes.length - 1].isCurrent = true;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
function markActiveRun(nodes, activeRunId) {
|
|
188
|
+
for (const node of nodes) {
|
|
189
|
+
for (const run of node.runs) {
|
|
190
|
+
if (run.id === activeRunId) {
|
|
191
|
+
run.status = "running";
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
for (const trip of node.sideTrips) {
|
|
196
|
+
for (const run of trip.runs) {
|
|
197
|
+
if (run.id === activeRunId) {
|
|
198
|
+
run.status = "running";
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export function planStepSymbol(status) {
|
|
2
|
+
if (status === "completed")
|
|
3
|
+
return "\u2713";
|
|
4
|
+
if (status === "inProgress")
|
|
5
|
+
return "\u25b8";
|
|
6
|
+
return " ";
|
|
7
|
+
}
|
|
8
|
+
export function planStepColor(status) {
|
|
9
|
+
if (status === "completed")
|
|
10
|
+
return "green";
|
|
11
|
+
if (status === "inProgress")
|
|
12
|
+
return "yellow";
|
|
13
|
+
return "white";
|
|
14
|
+
}
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { buildTimelineFromRehydration, appendFeedToTimeline, appendCodexItemToTimeline, completeCodexItemInTimeline, appendDeltaToTimelineItem, } from "./timeline-builder.js";
|
|
2
2
|
const DETAIL_INITIAL = {
|
|
3
|
+
detailTab: "timeline",
|
|
3
4
|
timeline: [],
|
|
5
|
+
rawRuns: [],
|
|
6
|
+
rawFeedEvents: [],
|
|
4
7
|
activeRunId: null,
|
|
5
8
|
activeRunStartedAt: null,
|
|
6
9
|
tokenUsage: null,
|
|
@@ -96,6 +99,8 @@ export function watchReducer(state, action) {
|
|
|
96
99
|
return {
|
|
97
100
|
...state,
|
|
98
101
|
timeline,
|
|
102
|
+
rawRuns: action.runs,
|
|
103
|
+
rawFeedEvents: action.feedEvents,
|
|
99
104
|
activeRunId: action.activeRunId,
|
|
100
105
|
activeRunStartedAt: activeRun?.startedAt ?? null,
|
|
101
106
|
issueContext: action.issueContext,
|
|
@@ -115,6 +120,8 @@ export function watchReducer(state, action) {
|
|
|
115
120
|
return { ...state, feedEvents: action.events };
|
|
116
121
|
case "feed-new-event":
|
|
117
122
|
return { ...state, feedEvents: [...state.feedEvents, action.event] };
|
|
123
|
+
case "switch-detail-tab":
|
|
124
|
+
return { ...state, detailTab: action.tab };
|
|
118
125
|
}
|
|
119
126
|
}
|
|
120
127
|
// ─── Feed Event → Issue List + Timeline ───────────────────────────
|
|
@@ -147,11 +154,15 @@ function applyFeedEvent(state, event) {
|
|
|
147
154
|
}
|
|
148
155
|
issue.updatedAt = event.at;
|
|
149
156
|
updated[index] = issue;
|
|
150
|
-
// Append to timeline if this event matches the active detail issue
|
|
151
|
-
const
|
|
157
|
+
// Append to timeline and raw feed events if this event matches the active detail issue
|
|
158
|
+
const isActiveDetail = state.view === "detail" && state.activeDetailKey === event.issueKey;
|
|
159
|
+
const timeline = isActiveDetail
|
|
152
160
|
? appendFeedToTimeline(state.timeline, event)
|
|
153
161
|
: state.timeline;
|
|
154
|
-
|
|
162
|
+
const rawFeedEvents = isActiveDetail
|
|
163
|
+
? [...state.rawFeedEvents, event]
|
|
164
|
+
: state.rawFeedEvents;
|
|
165
|
+
return { ...state, issues: updated, timeline, rawFeedEvents };
|
|
155
166
|
}
|
|
156
167
|
// ─── Codex Notification → Timeline + Metadata ─────────────────────
|
|
157
168
|
function applyCodexNotification(state, method, params) {
|
package/dist/db/migrations.js
CHANGED
|
@@ -141,6 +141,9 @@ export function runPatchRelayMigrations(connection) {
|
|
|
141
141
|
addColumnIfMissing(connection, "issues", "description", "TEXT");
|
|
142
142
|
addColumnIfMissing(connection, "issues", "priority", "INTEGER");
|
|
143
143
|
addColumnIfMissing(connection, "issues", "estimate", "REAL");
|
|
144
|
+
// Zombie/stale recovery backoff
|
|
145
|
+
addColumnIfMissing(connection, "issues", "zombie_recovery_attempts", "INTEGER NOT NULL DEFAULT 0");
|
|
146
|
+
addColumnIfMissing(connection, "issues", "last_zombie_recovery_at", "TEXT");
|
|
144
147
|
}
|
|
145
148
|
function addColumnIfMissing(connection, table, column, definition) {
|
|
146
149
|
const cols = connection.prepare(`PRAGMA table_info(${table})`).all();
|
package/dist/db.js
CHANGED
|
@@ -173,6 +173,14 @@ export class PatchRelayDatabase {
|
|
|
173
173
|
sets.push("pending_merge_prep = @pendingMergePrep");
|
|
174
174
|
values.pendingMergePrep = params.pendingMergePrep ? 1 : 0;
|
|
175
175
|
}
|
|
176
|
+
if (params.zombieRecoveryAttempts !== undefined) {
|
|
177
|
+
sets.push("zombie_recovery_attempts = @zombieRecoveryAttempts");
|
|
178
|
+
values.zombieRecoveryAttempts = params.zombieRecoveryAttempts;
|
|
179
|
+
}
|
|
180
|
+
if (params.lastZombieRecoveryAt !== undefined) {
|
|
181
|
+
sets.push("last_zombie_recovery_at = @lastZombieRecoveryAt");
|
|
182
|
+
values.lastZombieRecoveryAt = params.lastZombieRecoveryAt;
|
|
183
|
+
}
|
|
176
184
|
this.connection.prepare(`UPDATE issues SET ${sets.join(", ")} WHERE project_id = @projectId AND linear_issue_id = @linearIssueId`).run(values);
|
|
177
185
|
}
|
|
178
186
|
else {
|
|
@@ -424,6 +432,8 @@ function mapIssueRow(row) {
|
|
|
424
432
|
reviewFixAttempts: Number(row.review_fix_attempts ?? 0),
|
|
425
433
|
mergePrepAttempts: Number(row.merge_prep_attempts ?? 0),
|
|
426
434
|
pendingMergePrep: Boolean(row.pending_merge_prep),
|
|
435
|
+
zombieRecoveryAttempts: Number(row.zombie_recovery_attempts ?? 0),
|
|
436
|
+
...(row.last_zombie_recovery_at !== null && row.last_zombie_recovery_at !== undefined ? { lastZombieRecoveryAt: String(row.last_zombie_recovery_at) } : {}),
|
|
427
437
|
};
|
|
428
438
|
}
|
|
429
439
|
function mapRunRow(row) {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { resolveFactoryStateFromGitHub } from "./factory-state.js";
|
|
1
|
+
import { resolveFactoryStateFromGitHub, TERMINAL_STATES } from "./factory-state.js";
|
|
2
2
|
import { normalizeGitHubWebhook, verifyGitHubWebhookSignature } from "./github-webhooks.js";
|
|
3
3
|
import { buildAgentSessionPlanForIssue } from "./agent-session-plan.js";
|
|
4
4
|
import { buildAgentSessionExternalUrls } from "./agent-session-presentation.js";
|
|
@@ -202,6 +202,10 @@ export class GitHubWebhookHandler {
|
|
|
202
202
|
// Don't trigger if there's already an active run
|
|
203
203
|
if (issue.activeRunId !== undefined)
|
|
204
204
|
return;
|
|
205
|
+
// Don't trigger on terminal issues — late-arriving webhooks (e.g.
|
|
206
|
+
// merge_group_failed after pr_merged) must not resurrect done issues.
|
|
207
|
+
if (TERMINAL_STATES.has(issue.factoryState))
|
|
208
|
+
return;
|
|
205
209
|
if (event.triggerEvent === "check_failed" && issue.prState === "open") {
|
|
206
210
|
this.db.upsertIssue({
|
|
207
211
|
projectId: issue.projectId,
|
package/dist/run-orchestrator.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
|
-
import { ACTIVE_RUN_STATES } from "./factory-state.js";
|
|
3
|
+
import { ACTIVE_RUN_STATES, TERMINAL_STATES } from "./factory-state.js";
|
|
4
4
|
import { buildHookEnv, runProjectHook } from "./hook-runner.js";
|
|
5
5
|
import { buildAgentSessionPlanForIssue, } from "./agent-session-plan.js";
|
|
6
6
|
import { buildStageReport, countEventMethods, extractTurnId, resolveRunCompletionStatus, summarizeCurrentThread, } from "./run-reporting.js";
|
|
@@ -12,6 +12,8 @@ import { execCommand } from "./utils.js";
|
|
|
12
12
|
const DEFAULT_CI_REPAIR_BUDGET = 3;
|
|
13
13
|
const DEFAULT_QUEUE_REPAIR_BUDGET = 3;
|
|
14
14
|
const DEFAULT_REVIEW_FIX_BUDGET = 3;
|
|
15
|
+
const DEFAULT_ZOMBIE_RECOVERY_BUDGET = 5;
|
|
16
|
+
const ZOMBIE_RECOVERY_BASE_DELAY_MS = 15_000; // 15s, 30s, 60s, 120s, 240s
|
|
15
17
|
function slugify(value) {
|
|
16
18
|
return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 60);
|
|
17
19
|
}
|
|
@@ -191,7 +193,10 @@ export class RunOrchestrator {
|
|
|
191
193
|
// Freshen the worktree: fetch + rebase onto latest base branch.
|
|
192
194
|
// This prevents branch contamination when local main has drifted
|
|
193
195
|
// and avoids scope-bundling review rejections from stale commits.
|
|
194
|
-
|
|
196
|
+
// Skip for queue_repair — its entire purpose is to resolve rebase conflicts.
|
|
197
|
+
if (runType !== "queue_repair") {
|
|
198
|
+
await this.freshenWorktree(worktreePath, project, issue);
|
|
199
|
+
}
|
|
195
200
|
// Run prepare-worktree hook
|
|
196
201
|
const hookEnv = buildHookEnv(issue.issueKey ?? issue.linearIssueId, branchName, runType, worktreePath);
|
|
197
202
|
const prepareResult = await runProjectHook(project.repoPath, "prepare-worktree", { cwd: worktreePath, env: hookEnv });
|
|
@@ -244,6 +249,15 @@ export class RunOrchestrator {
|
|
|
244
249
|
throw error;
|
|
245
250
|
}
|
|
246
251
|
this.db.updateRunThread(run.id, { threadId, turnId });
|
|
252
|
+
// Reset zombie recovery counter — this run started successfully
|
|
253
|
+
if (issue.zombieRecoveryAttempts > 0) {
|
|
254
|
+
this.db.upsertIssue({
|
|
255
|
+
projectId: item.projectId,
|
|
256
|
+
linearIssueId: item.issueId,
|
|
257
|
+
zombieRecoveryAttempts: 0,
|
|
258
|
+
lastZombieRecoveryAt: null,
|
|
259
|
+
});
|
|
260
|
+
}
|
|
247
261
|
this.logger.info({ issueKey: issue.issueKey, runType, threadId, turnId }, `Started ${runType} run`);
|
|
248
262
|
// Emit Linear activity + plan
|
|
249
263
|
const freshIssue = this.db.getIssue(item.projectId, item.issueId) ?? issue;
|
|
@@ -286,11 +300,14 @@ export class RunOrchestrator {
|
|
|
286
300
|
// Rebase onto latest base
|
|
287
301
|
const rebaseResult = await execCommand(gitBin, ["-C", worktreePath, "rebase", `origin/${baseBranch}`], { timeoutMs: 120_000 });
|
|
288
302
|
if (rebaseResult.exitCode !== 0) {
|
|
289
|
-
// Abort the failed rebase and restore state
|
|
303
|
+
// Abort the failed rebase and restore state — then let the agent run
|
|
304
|
+
// proceed. The agent can resolve the conflict itself (the workflow
|
|
305
|
+
// prompt tells it to rebase and handle conflicts).
|
|
290
306
|
await execCommand(gitBin, ["-C", worktreePath, "rebase", "--abort"], { timeoutMs: 10_000 });
|
|
291
307
|
if (didStash)
|
|
292
308
|
await execCommand(gitBin, ["-C", worktreePath, "stash", "pop"], { timeoutMs: 10_000 });
|
|
293
|
-
|
|
309
|
+
this.logger.warn({ issueKey: issue.issueKey, baseBranch }, "Pre-run freshen: rebase conflict, agent will resolve");
|
|
310
|
+
return;
|
|
294
311
|
}
|
|
295
312
|
// Push the rebased branch (force-with-lease to protect against concurrent pushes)
|
|
296
313
|
const pushResult = await execCommand(gitBin, ["-C", worktreePath, "push", "--force-with-lease"], { timeoutMs: 60_000 });
|
|
@@ -563,26 +580,91 @@ export class RunOrchestrator {
|
|
|
563
580
|
this.enqueueIssue(issue.projectId, issue.linearIssueId);
|
|
564
581
|
}
|
|
565
582
|
}
|
|
583
|
+
/**
|
|
584
|
+
* After a zombie/stale run is cleared, decide whether to re-enqueue
|
|
585
|
+
* or escalate. Checks: PR already merged → done; budget exhausted →
|
|
586
|
+
* escalate; backoff delay not elapsed → skip.
|
|
587
|
+
*/
|
|
588
|
+
recoverOrEscalate(issue, runType, reason) {
|
|
589
|
+
// Re-read issue after the run was cleared (activeRunId is now null)
|
|
590
|
+
const fresh = this.db.getIssue(issue.projectId, issue.linearIssueId);
|
|
591
|
+
if (!fresh)
|
|
592
|
+
return;
|
|
593
|
+
// If PR already merged, transition to done — no retry needed
|
|
594
|
+
if (fresh.prState === "merged") {
|
|
595
|
+
this.db.upsertIssue({
|
|
596
|
+
projectId: fresh.projectId,
|
|
597
|
+
linearIssueId: fresh.linearIssueId,
|
|
598
|
+
factoryState: "done",
|
|
599
|
+
zombieRecoveryAttempts: 0,
|
|
600
|
+
lastZombieRecoveryAt: null,
|
|
601
|
+
});
|
|
602
|
+
this.logger.info({ issueKey: fresh.issueKey, reason }, "Recovery: PR already merged — transitioning to done");
|
|
603
|
+
return;
|
|
604
|
+
}
|
|
605
|
+
// Budget check
|
|
606
|
+
const attempts = fresh.zombieRecoveryAttempts + 1;
|
|
607
|
+
if (attempts > DEFAULT_ZOMBIE_RECOVERY_BUDGET) {
|
|
608
|
+
this.db.upsertIssue({
|
|
609
|
+
projectId: fresh.projectId,
|
|
610
|
+
linearIssueId: fresh.linearIssueId,
|
|
611
|
+
factoryState: "escalated",
|
|
612
|
+
});
|
|
613
|
+
this.logger.warn({ issueKey: fresh.issueKey, attempts, reason }, "Recovery: budget exhausted — escalating");
|
|
614
|
+
this.feed?.publish({
|
|
615
|
+
level: "error",
|
|
616
|
+
kind: "workflow",
|
|
617
|
+
issueKey: fresh.issueKey,
|
|
618
|
+
projectId: fresh.projectId,
|
|
619
|
+
stage: "escalated",
|
|
620
|
+
status: "budget_exhausted",
|
|
621
|
+
summary: `${reason} recovery failed after ${DEFAULT_ZOMBIE_RECOVERY_BUDGET} attempts`,
|
|
622
|
+
});
|
|
623
|
+
return;
|
|
624
|
+
}
|
|
625
|
+
// Exponential backoff — skip if delay hasn't elapsed
|
|
626
|
+
if (fresh.lastZombieRecoveryAt) {
|
|
627
|
+
const elapsed = Date.now() - new Date(fresh.lastZombieRecoveryAt).getTime();
|
|
628
|
+
const delay = ZOMBIE_RECOVERY_BASE_DELAY_MS * Math.pow(2, fresh.zombieRecoveryAttempts);
|
|
629
|
+
if (elapsed < delay) {
|
|
630
|
+
this.logger.debug({ issueKey: fresh.issueKey, attempts: fresh.zombieRecoveryAttempts, delay, elapsed }, "Recovery: backoff not elapsed, skipping");
|
|
631
|
+
return;
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
// Re-enqueue with backoff tracking
|
|
635
|
+
this.db.upsertIssue({
|
|
636
|
+
projectId: fresh.projectId,
|
|
637
|
+
linearIssueId: fresh.linearIssueId,
|
|
638
|
+
pendingRunType: runType,
|
|
639
|
+
pendingRunContextJson: null,
|
|
640
|
+
zombieRecoveryAttempts: attempts,
|
|
641
|
+
lastZombieRecoveryAt: new Date().toISOString(),
|
|
642
|
+
});
|
|
643
|
+
this.enqueueIssue(fresh.projectId, fresh.linearIssueId);
|
|
644
|
+
this.logger.info({ issueKey: fresh.issueKey, attempts, reason }, "Recovery: re-enqueued with backoff");
|
|
645
|
+
}
|
|
566
646
|
async reconcileRun(run) {
|
|
567
647
|
const issue = this.db.getIssue(run.projectId, run.linearIssueId);
|
|
568
648
|
if (!issue)
|
|
569
649
|
return;
|
|
650
|
+
// If the issue reached a terminal state while this run was active
|
|
651
|
+
// (e.g. pr_merged processed, DB manually edited), just release the run.
|
|
652
|
+
if (TERMINAL_STATES.has(issue.factoryState)) {
|
|
653
|
+
this.db.transaction(() => {
|
|
654
|
+
this.db.finishRun(run.id, { status: "released", failureReason: "Issue reached terminal state during active run" });
|
|
655
|
+
this.db.upsertIssue({ projectId: run.projectId, linearIssueId: run.linearIssueId, activeRunId: null });
|
|
656
|
+
});
|
|
657
|
+
this.logger.info({ issueKey: issue.issueKey, runId: run.id, factoryState: issue.factoryState }, "Reconciliation: released run on terminal issue");
|
|
658
|
+
return;
|
|
659
|
+
}
|
|
570
660
|
// Zombie run: claimed in DB but Codex never started (no thread).
|
|
571
|
-
// This happens when the service crashes between claiming the run
|
|
572
|
-
// and starting the Codex turn. Re-enqueue instead of failing.
|
|
573
661
|
if (!run.threadId) {
|
|
574
|
-
this.logger.warn({ issueKey: issue.issueKey, runId: run.id, runType: run.runType }, "Zombie run detected (no thread)
|
|
662
|
+
this.logger.warn({ issueKey: issue.issueKey, runId: run.id, runType: run.runType }, "Zombie run detected (no thread)");
|
|
575
663
|
this.db.transaction(() => {
|
|
576
664
|
this.db.finishRun(run.id, { status: "failed", failureReason: "Zombie: never started (no thread after restart)" });
|
|
577
|
-
this.db.upsertIssue({
|
|
578
|
-
projectId: run.projectId,
|
|
579
|
-
linearIssueId: run.linearIssueId,
|
|
580
|
-
activeRunId: null,
|
|
581
|
-
pendingRunType: run.runType,
|
|
582
|
-
pendingRunContextJson: null,
|
|
583
|
-
});
|
|
665
|
+
this.db.upsertIssue({ projectId: run.projectId, linearIssueId: run.linearIssueId, activeRunId: null });
|
|
584
666
|
});
|
|
585
|
-
this.
|
|
667
|
+
this.recoverOrEscalate(issue, run.runType, "zombie");
|
|
586
668
|
return;
|
|
587
669
|
}
|
|
588
670
|
// Read Codex state — thread may not exist after app-server restart.
|
|
@@ -591,18 +673,12 @@ export class RunOrchestrator {
|
|
|
591
673
|
thread = await this.readThreadWithRetry(run.threadId);
|
|
592
674
|
}
|
|
593
675
|
catch {
|
|
594
|
-
this.logger.warn({ issueKey: issue.issueKey, runId: run.id, runType: run.runType, threadId: run.threadId }, "Stale thread during reconciliation
|
|
676
|
+
this.logger.warn({ issueKey: issue.issueKey, runId: run.id, runType: run.runType, threadId: run.threadId }, "Stale thread during reconciliation");
|
|
595
677
|
this.db.transaction(() => {
|
|
596
678
|
this.db.finishRun(run.id, { status: "failed", failureReason: "Stale thread after restart" });
|
|
597
|
-
this.db.upsertIssue({
|
|
598
|
-
projectId: run.projectId,
|
|
599
|
-
linearIssueId: run.linearIssueId,
|
|
600
|
-
activeRunId: null,
|
|
601
|
-
pendingRunType: run.runType,
|
|
602
|
-
pendingRunContextJson: null,
|
|
603
|
-
});
|
|
679
|
+
this.db.upsertIssue({ projectId: run.projectId, linearIssueId: run.linearIssueId, activeRunId: null });
|
|
604
680
|
});
|
|
605
|
-
this.
|
|
681
|
+
this.recoverOrEscalate(issue, run.runType, "stale_thread");
|
|
606
682
|
return;
|
|
607
683
|
}
|
|
608
684
|
// Check Linear state (non-fatal — token refresh may fail)
|
package/dist/worktree-manager.js
CHANGED
|
@@ -12,7 +12,13 @@ export class WorktreeManager {
|
|
|
12
12
|
return;
|
|
13
13
|
}
|
|
14
14
|
await ensureDir(path.dirname(worktreePath));
|
|
15
|
-
|
|
15
|
+
// Fetch latest main so the branch forks from a clean, up-to-date base.
|
|
16
|
+
// This prevents branch contamination when local HEAD has drifted.
|
|
17
|
+
// freshenWorktree in run-orchestrator acts as a secondary safety net.
|
|
18
|
+
await execCommand(this.config.runner.gitBin, ["-C", repoPath, "fetch", "origin", "main"], {
|
|
19
|
+
timeoutMs: 60_000,
|
|
20
|
+
});
|
|
21
|
+
await execCommand(this.config.runner.gitBin, ["-C", repoPath, "worktree", "add", "--force", "-B", branchName, worktreePath, "origin/main"], {
|
|
16
22
|
timeoutMs: 120_000,
|
|
17
23
|
});
|
|
18
24
|
}
|