patchrelay 0.29.2 → 0.30.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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "service": "patchrelay",
3
- "version": "0.29.2",
4
- "commit": "c98ff7a5985a",
5
- "builtAt": "2026-04-01T00:13:06.178Z"
3
+ "version": "0.30.0",
4
+ "commit": "88eca79d5f8d",
5
+ "builtAt": "2026-04-01T02:27:09.365Z"
6
6
  }
@@ -35,7 +35,11 @@ async function postStop(baseUrl, issueKey, bearerToken) {
35
35
  headers,
36
36
  signal: AbortSignal.timeout(5000),
37
37
  });
38
- return await response.json();
38
+ const result = await response.json();
39
+ if (result.ok === undefined && result.stopped === true) {
40
+ return { ...result, ok: true };
41
+ }
42
+ return result;
39
43
  }
40
44
  catch {
41
45
  return { reason: "request failed" };
@@ -44,5 +44,5 @@ export function IssueDetailView({ issue, timeline, follow, activeRunStartedAt, a
44
44
  const history = useMemo(() => buildStateHistory(rawRuns, rawFeedEvents, issue.factoryState, activeRunId), [rawRuns, rawFeedEvents, issue.factoryState, activeRunId]);
45
45
  const graph = useMemo(() => buildPatchRelayStateGraph(history, issue.factoryState), [history, issue.factoryState]);
46
46
  const queueObservations = useMemo(() => buildPatchRelayQueueObservations(issue, rawFeedEvents), [issue, rawFeedEvents]);
47
- return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { gap: 2, children: [_jsx(Text, { bold: true, children: key }), _jsx(Text, { color: "cyan", children: issue.factoryState }), issue.activeRunType && _jsx(Text, { color: "yellow", children: issue.activeRunType }), issue.prNumber !== undefined && _jsxs(Text, { dimColor: true, children: ["#", issue.prNumber] }), activeRunStartedAt && _jsx(ElapsedTime, { startedAt: activeRunStartedAt }), meta.length > 0 && _jsx(Text, { dimColor: true, children: meta.join(" ") }), detailTab === "timeline" && _jsx(Text, { dimColor: true, children: timelineMode }), follow && _jsx(Text, { color: "yellow", children: "follow" }), _jsx(FreshnessBadge, { connected: connected, lastServerMessageAt: lastServerMessageAt })] }), issue.title && _jsx(Text, { children: issue.title }), detailTab === "timeline" ? (_jsxs(_Fragment, { children: [plan && plan.length > 0 && (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: "Plan" }), _jsx(Text, { children: progressBar(plan.filter((s) => s.status === "completed").length, plan.length, 16) }), _jsxs(Text, { dimColor: true, children: [plan.filter((s) => s.status === "completed").length, "/", plan.length] })] }), plan.map((entry, i) => (_jsxs(Box, { gap: 1, children: [_jsxs(Text, { color: planStepColor(entry.status), children: ["[", planStepSymbol(entry.status), "]"] }), _jsx(Text, { children: entry.step })] }, `plan-${i}`)))] })), _jsx(Box, { marginTop: 1, flexDirection: "column", children: _jsx(Timeline, { entries: timeline, follow: follow, mode: timelineMode }) })] })) : (_jsxs(_Fragment, { children: [_jsx(FactoryStateGraph, { main: graph.main, prLoops: graph.prLoops, queueLoop: graph.queueLoop, exits: graph.exits }), _jsx(QueueObservationView, { observations: queueObservations }), _jsx(Box, { marginTop: 1, children: _jsx(StateHistoryView, { history: history, plan: plan, activeRunId: activeRunId }) })] })), _jsx(Box, { marginTop: 1, children: _jsx(HelpBar, { view: "detail", follow: follow, detailTab: detailTab, timelineMode: timelineMode }) })] }));
47
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { gap: 2, children: [_jsx(Text, { bold: true, children: key }), _jsx(Text, { color: "cyan", children: issue.factoryState }), issue.blockedByCount > 0 && _jsxs(Text, { color: "yellow", children: ["blocked by ", issue.blockedByKeys.join(", ")] }), issue.readyForExecution && !issue.activeRunType && issue.blockedByCount === 0 && _jsx(Text, { color: "blueBright", children: "ready" }), issue.activeRunType && _jsx(Text, { color: "yellow", children: issue.activeRunType }), issue.prNumber !== undefined && _jsxs(Text, { dimColor: true, children: ["#", issue.prNumber] }), activeRunStartedAt && _jsx(ElapsedTime, { startedAt: activeRunStartedAt }), meta.length > 0 && _jsx(Text, { dimColor: true, children: meta.join(" ") }), detailTab === "timeline" && _jsx(Text, { dimColor: true, children: timelineMode }), follow && _jsx(Text, { color: "yellow", children: "follow" }), _jsx(FreshnessBadge, { connected: connected, lastServerMessageAt: lastServerMessageAt })] }), issue.title && _jsx(Text, { children: issue.title }), detailTab === "timeline" ? (_jsxs(_Fragment, { children: [plan && plan.length > 0 && (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: "Plan" }), _jsx(Text, { children: progressBar(plan.filter((s) => s.status === "completed").length, plan.length, 16) }), _jsxs(Text, { dimColor: true, children: [plan.filter((s) => s.status === "completed").length, "/", plan.length] })] }), plan.map((entry, i) => (_jsxs(Box, { gap: 1, children: [_jsxs(Text, { color: planStepColor(entry.status), children: ["[", planStepSymbol(entry.status), "]"] }), _jsx(Text, { children: entry.step })] }, `plan-${i}`)))] })), _jsx(Box, { marginTop: 1, flexDirection: "column", children: _jsx(Timeline, { entries: timeline, follow: follow, mode: timelineMode }) })] })) : (_jsxs(_Fragment, { children: [_jsx(FactoryStateGraph, { main: graph.main, prLoops: graph.prLoops, queueLoop: graph.queueLoop, exits: graph.exits }), _jsx(QueueObservationView, { observations: queueObservations }), _jsx(Box, { marginTop: 1, children: _jsx(StateHistoryView, { history: history, plan: plan, activeRunId: activeRunId }) })] })), _jsx(Box, { marginTop: 1, children: _jsx(HelpBar, { view: "detail", follow: follow, detailTab: detailTab, timelineMode: timelineMode }) })] }));
48
48
  }
@@ -2,6 +2,8 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Box, Text } from "ink";
3
3
  import { summarizeIssueStatusNote } from "./issue-status-note.js";
4
4
  const STATE_COLORS = {
5
+ blocked: "yellow",
6
+ ready: "blueBright",
5
7
  delegated: "cyan",
6
8
  implementing: "cyan",
7
9
  pr_open: "cyan",
@@ -15,6 +17,8 @@ const STATE_COLORS = {
15
17
  awaiting_input: "yellow",
16
18
  };
17
19
  const STATE_SHORT = {
20
+ blocked: "blocked",
21
+ ready: "ready",
18
22
  delegated: "delegated",
19
23
  implementing: "impl",
20
24
  pr_open: "pr open",
@@ -69,7 +73,12 @@ function truncate(text, max) {
69
73
  }
70
74
  const TERMINAL_STATES = new Set(["done", "failed", "escalated", "awaiting_input"]);
71
75
  function formatStatus(issue) {
72
- const state = STATE_SHORT[issue.factoryState] ?? issue.factoryState;
76
+ const effectiveState = issue.blockedByCount > 0 && !issue.activeRunType
77
+ ? "blocked"
78
+ : issue.readyForExecution && !issue.activeRunType
79
+ ? "ready"
80
+ : issue.factoryState;
81
+ const state = STATE_SHORT[effectiveState] ?? effectiveState;
73
82
  // Terminal states: just the label, no run symbol
74
83
  if (TERMINAL_STATES.has(issue.factoryState))
75
84
  return state;
@@ -88,5 +97,5 @@ export function IssueRow({ issue, selected, titleWidth }) {
88
97
  const tw = titleWidth ?? 30;
89
98
  const title = issue.title ? truncate(issue.title, tw) : "";
90
99
  const detail = selected ? summarizeIssueStatusNote(issue.statusNote) : undefined;
91
- return (_jsxs(Box, { flexDirection: "column", marginBottom: detail ? 1 : 0, children: [_jsxs(Box, { children: [_jsx(Text, { color: selected ? "blueBright" : "white", bold: selected, children: selected ? "\u25b8" : " " }), _jsx(Text, { bold: true, children: ` ${key.padEnd(9)}` }), _jsx(Text, { color: stateColor(issue.factoryState), children: ` ${status.padEnd(12)}` }), _jsx(Text, { dimColor: true, children: ` ${pr.padEnd(6)}` }), _jsx(Text, { dimColor: true, children: ` ${ago.padStart(3)}` }), title ? _jsx(Text, { dimColor: true, children: ` ${title}` }) : null] }), detail ? (_jsx(Box, { paddingLeft: 4, children: _jsx(Text, { dimColor: true, wrap: "wrap", children: detail }) })) : null] }));
100
+ return (_jsxs(Box, { flexDirection: "column", marginBottom: detail ? 1 : 0, children: [_jsxs(Box, { children: [_jsx(Text, { color: selected ? "blueBright" : "white", bold: selected, children: selected ? "\u25b8" : " " }), _jsx(Text, { bold: true, children: ` ${key.padEnd(9)}` }), _jsx(Text, { color: stateColor(issue.blockedByCount > 0 && !issue.activeRunType ? "blocked" : issue.readyForExecution && !issue.activeRunType ? "ready" : issue.factoryState), children: ` ${status.padEnd(12)}` }), _jsx(Text, { dimColor: true, children: ` ${pr.padEnd(6)}` }), _jsx(Text, { dimColor: true, children: ` ${ago.padStart(3)}` }), title ? _jsx(Text, { dimColor: true, children: ` ${title}` }) : null] }), detail ? (_jsx(Box, { paddingLeft: 4, children: _jsx(Text, { dimColor: true, wrap: "wrap", children: detail }) })) : null] }));
92
101
  }
@@ -12,5 +12,5 @@ export function StatusBar({ issues, totalCount, filter, connected, lastServerMes
12
12
  const agg = computeAggregates(allIssues);
13
13
  const withPr = allIssues.filter((i) => i.prNumber !== undefined).length;
14
14
  const awaitingInput = allIssues.filter((i) => i.factoryState === "awaiting_input").length;
15
- return (_jsxs(Box, { justifyContent: "space-between", children: [_jsxs(Box, { gap: 1, children: [_jsx(Text, { bold: true, children: showing }), _jsxs(Text, { dimColor: true, children: ["[", FILTER_LABELS[filter], "]"] }), _jsx(Text, { dimColor: true, children: "|" }), agg.active > 0 && _jsxs(Text, { color: "cyan", children: [agg.active, " active"] }), withPr > 0 && _jsxs(Text, { dimColor: true, children: [withPr, " PRs"] }), awaitingInput > 0 && _jsxs(Text, { color: "yellow", children: [awaitingInput, " awaiting input"] }), agg.done > 0 && _jsxs(Text, { color: "green", children: [agg.done, " done"] }), agg.failed > 0 && _jsxs(Text, { color: "red", children: [agg.failed, " failed"] })] }), _jsx(FreshnessBadge, { connected: connected, lastServerMessageAt: lastServerMessageAt })] }));
15
+ return (_jsxs(Box, { justifyContent: "space-between", children: [_jsxs(Box, { gap: 1, children: [_jsx(Text, { bold: true, children: showing }), _jsxs(Text, { dimColor: true, children: ["[", FILTER_LABELS[filter], "]"] }), _jsx(Text, { dimColor: true, children: "|" }), agg.active > 0 && _jsxs(Text, { color: "cyan", children: [agg.active, " active"] }), agg.ready > 0 && _jsxs(Text, { color: "blueBright", children: [agg.ready, " ready"] }), agg.blocked > 0 && _jsxs(Text, { color: "yellow", children: [agg.blocked, " blocked"] }), withPr > 0 && _jsxs(Text, { dimColor: true, children: [withPr, " PRs"] }), awaitingInput > 0 && _jsxs(Text, { color: "yellow", children: [awaitingInput, " awaiting input"] }), agg.done > 0 && _jsxs(Text, { color: "green", children: [agg.done, " done"] }), agg.failed > 0 && _jsxs(Text, { color: "red", children: [agg.failed, " failed"] })] }), _jsx(FreshnessBadge, { connected: connected, lastServerMessageAt: lastServerMessageAt })] }));
16
16
  }
@@ -74,7 +74,8 @@ function verboseItemLabel(type) {
74
74
  }
75
75
  function FeedRow({ entry }) {
76
76
  const label = entry.feed.status ?? entry.feed.feedKind;
77
- return (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsxs(Box, { children: [_jsxs(Text, { dimColor: true, children: [formatTime(entry.at), " "] }), _jsx(Text, { color: "cyan", bold: true, children: label.padEnd(12) })] }), _jsx(Box, { paddingLeft: 6, children: _jsx(Text, { wrap: "wrap", children: entry.feed.summary }) })] }));
77
+ const repeatSuffix = entry.repeatCount && entry.repeatCount > 1 ? ` ×${entry.repeatCount}` : "";
78
+ return (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsxs(Box, { children: [_jsxs(Text, { dimColor: true, children: [formatTime(entry.at), " "] }), _jsx(Text, { color: "cyan", bold: true, children: label.padEnd(12) })] }), _jsx(Box, { paddingLeft: 6, children: _jsxs(Text, { wrap: "wrap", children: [entry.feed.summary, repeatSuffix] }) })] }));
78
79
  }
79
80
  function RunRow({ entry, mode, }) {
80
81
  const run = entry.run;
@@ -1,5 +1,6 @@
1
1
  export function buildTimelineRows(entries, mode) {
2
- return mode === "compact" ? buildCompactTimelineRows(entries) : buildVerboseTimelineRows(entries);
2
+ const rows = mode === "compact" ? buildCompactTimelineRows(entries) : buildVerboseTimelineRows(entries);
3
+ return collapseRepeatedFeedRows(rows);
3
4
  }
4
5
  function buildVerboseTimelineRows(entries) {
5
6
  const rows = [];
@@ -43,6 +44,9 @@ function buildVerboseTimelineRows(entries) {
43
44
  }
44
45
  switch (entry.kind) {
45
46
  case "feed":
47
+ if (shouldHideFeed(entry.feed)) {
48
+ break;
49
+ }
46
50
  rows.push({
47
51
  id: entry.id,
48
52
  kind: "feed",
@@ -79,9 +83,9 @@ function buildVerboseTimelineRows(entries) {
79
83
  finalized: run.items.every((item) => item.status !== "inProgress") && run.run.status !== "running",
80
84
  run: { ...run.run, ...(run.endedAt ? { endedAt: run.endedAt } : {}) },
81
85
  details: [],
82
- items: entries
86
+ items: summarizeVerboseItems(entries
83
87
  .filter((entry) => entry.kind === "item" && entry.runId === runId)
84
- .map((entry) => ({ at: entry.at, item: entry.item })),
88
+ .map((entry) => ({ at: entry.at, item: entry.item }))),
85
89
  });
86
90
  }
87
91
  rows.sort((left, right) => {
@@ -135,7 +139,7 @@ function buildCompactTimelineRows(entries) {
135
139
  runs.get(entry.runId).items.push(entry.item);
136
140
  continue;
137
141
  }
138
- if (entry.kind === "feed" && shouldHideFeedInCompact(entry.feed)) {
142
+ if (entry.kind === "feed" && shouldHideFeed(entry.feed)) {
139
143
  continue;
140
144
  }
141
145
  if (entry.kind === "feed") {
@@ -191,15 +195,25 @@ function buildCompactTimelineRows(entries) {
191
195
  });
192
196
  return rows;
193
197
  }
194
- function shouldHideFeedInCompact(feed) {
198
+ function shouldHideFeed(feed) {
195
199
  if (feed.feedKind === "stage" && feed.status === "starting") {
196
200
  return true;
197
201
  }
202
+ if (feed.feedKind === "stage" && feed.status === "reconciled" && isNoOpReconciliation(feed.summary)) {
203
+ return true;
204
+ }
198
205
  if (feed.feedKind === "turn" && (feed.status === "completed" || feed.status === "failed")) {
199
206
  return true;
200
207
  }
208
+ if (feed.feedKind === "queue" && feed.status === "queue_label_requested") {
209
+ return true;
210
+ }
201
211
  return false;
202
212
  }
213
+ function isNoOpReconciliation(summary) {
214
+ const match = summary.match(/^Reconciliation:\s+([a-z_]+)\s+→\s+([a-z_]+)$/i);
215
+ return Boolean(match?.[1] && match[1] === match[2]);
216
+ }
203
217
  function resolveCompactRunStatus(run, items) {
204
218
  if (run.endedAt || run.status === "completed" || run.status === "failed" || run.status === "released") {
205
219
  return run.status;
@@ -321,6 +335,60 @@ function rowKindOrder(kind) {
321
335
  return 3;
322
336
  }
323
337
  }
338
+ function summarizeVerboseItems(items) {
339
+ const directTypes = new Set(["userMessage", "commandExecution", "fileChange", "plan"]);
340
+ const kept = items.filter((entry) => directTypes.has(entry.item.type));
341
+ const latestAgentMessage = findLatestVerboseItem(items, (entry) => entry.item.type === "agentMessage" && Boolean(entry.item.text?.trim()));
342
+ if (latestAgentMessage) {
343
+ kept.push(latestAgentMessage);
344
+ }
345
+ else {
346
+ const latestReasoning = findLatestVerboseItem(items, (entry) => entry.item.type === "reasoning" && Boolean(entry.item.text?.trim()));
347
+ if (latestReasoning) {
348
+ kept.push(latestReasoning);
349
+ }
350
+ }
351
+ const deduped = new Map();
352
+ for (const entry of kept) {
353
+ deduped.set(entry.item.id, entry);
354
+ }
355
+ return Array.from(deduped.values()).sort((left, right) => {
356
+ const cmp = left.at.localeCompare(right.at);
357
+ if (cmp !== 0)
358
+ return cmp;
359
+ return left.item.id.localeCompare(right.item.id);
360
+ });
361
+ }
362
+ function findLatestVerboseItem(items, predicate) {
363
+ for (let i = items.length - 1; i >= 0; i -= 1) {
364
+ const item = items[i];
365
+ if (predicate(item)) {
366
+ return item;
367
+ }
368
+ }
369
+ return undefined;
370
+ }
371
+ function collapseRepeatedFeedRows(rows) {
372
+ const collapsed = [];
373
+ for (const row of rows) {
374
+ if (row.kind !== "feed") {
375
+ collapsed.push(row);
376
+ continue;
377
+ }
378
+ const previous = collapsed.at(-1);
379
+ if (previous?.kind === "feed"
380
+ && previous.feed.feedKind === row.feed.feedKind
381
+ && previous.feed.status === row.feed.status
382
+ && previous.feed.summary === row.feed.summary
383
+ && previous.feed.detail === row.feed.detail) {
384
+ previous.at = row.at;
385
+ previous.repeatCount = (previous.repeatCount ?? 1) + 1;
386
+ continue;
387
+ }
388
+ collapsed.push({ ...row, ...(row.repeatCount ? { repeatCount: row.repeatCount } : {}) });
389
+ }
390
+ return collapsed;
391
+ }
324
392
  function cleanCommand(raw) {
325
393
  const bashMatch = raw.match(/^\/bin\/(?:ba)?sh\s+-\w*c\s+['"](.+?)['"]$/s);
326
394
  if (bashMatch?.[1])
@@ -6,6 +6,19 @@ export function useWatchStream(options) {
6
6
  let abortController = new AbortController();
7
7
  let reconnectTimeout;
8
8
  let attempt = 0;
9
+ const fetchIssueSnapshot = async () => {
10
+ const { baseUrl, bearerToken, dispatch } = optionsRef.current;
11
+ const headers = { accept: "application/json" };
12
+ if (bearerToken) {
13
+ headers.authorization = `Bearer ${bearerToken}`;
14
+ }
15
+ const response = await fetch(new URL("/api/watch/issues", baseUrl), { headers });
16
+ if (!response.ok) {
17
+ throw new Error(`Issue snapshot failed: ${response.status}`);
18
+ }
19
+ const payload = await response.json();
20
+ dispatch({ type: "issues-snapshot", issues: Array.isArray(payload.issues) ? payload.issues : [], receivedAt: Date.now() });
21
+ };
9
22
  const connect = () => {
10
23
  abortController = new AbortController();
11
24
  const { baseUrl, bearerToken, issueFilter, dispatch } = optionsRef.current;
@@ -24,6 +37,12 @@ export function useWatchStream(options) {
24
37
  }
25
38
  dispatch({ type: "connected" });
26
39
  attempt = 0;
40
+ try {
41
+ await fetchIssueSnapshot();
42
+ }
43
+ catch {
44
+ // Keep the stream alive even if the snapshot endpoint temporarily fails.
45
+ }
27
46
  const reader = response.body.getReader();
28
47
  const decoder = new TextDecoder();
29
48
  let buffer = "";
@@ -80,11 +99,16 @@ export function useWatchStream(options) {
80
99
  });
81
100
  };
82
101
  connect();
102
+ void fetchIssueSnapshot().catch(() => undefined);
103
+ const snapshotInterval = setInterval(() => {
104
+ void fetchIssueSnapshot().catch(() => undefined);
105
+ }, 5000);
83
106
  return () => {
84
107
  abortController.abort();
85
108
  if (reconnectTimeout !== undefined) {
86
109
  clearTimeout(reconnectTimeout);
87
110
  }
111
+ clearInterval(snapshotInterval);
88
112
  };
89
113
  }, []);
90
114
  }
@@ -46,17 +46,23 @@ const DONE_STATES = new Set(["done"]);
46
46
  const FAILED_STATES = new Set(["failed", "escalated"]);
47
47
  export function computeAggregates(issues) {
48
48
  let active = 0;
49
+ let blocked = 0;
50
+ let ready = 0;
49
51
  let done = 0;
50
52
  let failed = 0;
51
53
  for (const issue of issues) {
52
54
  if (issue.activeRunType)
53
55
  active++;
56
+ if (!issue.activeRunType && issue.blockedByCount > 0)
57
+ blocked++;
58
+ if (!issue.activeRunType && issue.readyForExecution)
59
+ ready++;
54
60
  if (DONE_STATES.has(issue.factoryState))
55
61
  done++;
56
62
  if (FAILED_STATES.has(issue.factoryState))
57
63
  failed++;
58
64
  }
59
- return { active, done, failed, total: issues.length };
65
+ return { active, blocked, ready, done, failed, total: issues.length };
60
66
  }
61
67
  function nextFilter(filter) {
62
68
  switch (filter) {
@@ -145,6 +145,17 @@ CREATE TABLE IF NOT EXISTS operator_feed_events (
145
145
  status TEXT
146
146
  );
147
147
 
148
+ CREATE TABLE IF NOT EXISTS issue_dependencies (
149
+ project_id TEXT NOT NULL,
150
+ linear_issue_id TEXT NOT NULL,
151
+ blocker_linear_issue_id TEXT NOT NULL,
152
+ blocker_issue_key TEXT,
153
+ blocker_title TEXT,
154
+ blocker_current_linear_state TEXT,
155
+ updated_at TEXT NOT NULL,
156
+ PRIMARY KEY (project_id, linear_issue_id, blocker_linear_issue_id)
157
+ );
158
+
148
159
  CREATE INDEX IF NOT EXISTS idx_issues_project ON issues(project_id, linear_issue_id);
149
160
  CREATE INDEX IF NOT EXISTS idx_issues_key ON issues(issue_key);
150
161
  CREATE INDEX IF NOT EXISTS idx_issues_ready ON issues(pending_run_type, active_run_id);
@@ -158,6 +169,8 @@ CREATE INDEX IF NOT EXISTS idx_operator_feed_events_project ON operator_feed_eve
158
169
  CREATE INDEX IF NOT EXISTS idx_repository_links_installation ON repository_links(installation_id, github_repo);
159
170
  CREATE INDEX IF NOT EXISTS idx_linear_catalog_teams_installation ON linear_catalog_teams(installation_id, team_key, team_name);
160
171
  CREATE INDEX IF NOT EXISTS idx_linear_catalog_projects_installation ON linear_catalog_projects(installation_id, project_name);
172
+ CREATE INDEX IF NOT EXISTS idx_issue_dependencies_issue ON issue_dependencies(project_id, linear_issue_id);
173
+ CREATE INDEX IF NOT EXISTS idx_issue_dependencies_blocker ON issue_dependencies(project_id, blocker_linear_issue_id);
161
174
  `;
162
175
  export function runPatchRelayMigrations(connection) {
163
176
  connection.exec(schema);
package/dist/db/shared.js CHANGED
@@ -66,6 +66,7 @@ export class SqliteConnection {
66
66
  savepointId = 0;
67
67
  constructor(path) {
68
68
  this.database = new DatabaseSync(path);
69
+ this.database.exec("PRAGMA busy_timeout = 5000");
69
70
  }
70
71
  close() {
71
72
  this.database.close();
package/dist/db.js CHANGED
@@ -279,9 +279,100 @@ export class PatchRelayDatabase {
279
279
  const row = this.connection.prepare("SELECT * FROM issues WHERE pr_number = ?").get(prNumber);
280
280
  return row ? mapIssueRow(row) : undefined;
281
281
  }
282
+ replaceIssueDependencies(params) {
283
+ const now = isoNow();
284
+ this.connection
285
+ .prepare("DELETE FROM issue_dependencies WHERE project_id = ? AND linear_issue_id = ?")
286
+ .run(params.projectId, params.linearIssueId);
287
+ if (params.blockers.length === 0) {
288
+ return;
289
+ }
290
+ const insert = this.connection.prepare(`
291
+ INSERT INTO issue_dependencies (
292
+ project_id,
293
+ linear_issue_id,
294
+ blocker_linear_issue_id,
295
+ blocker_issue_key,
296
+ blocker_title,
297
+ blocker_current_linear_state,
298
+ updated_at
299
+ ) VALUES (?, ?, ?, ?, ?, ?, ?)
300
+ `);
301
+ for (const blocker of params.blockers) {
302
+ insert.run(params.projectId, params.linearIssueId, blocker.blockerLinearIssueId, blocker.blockerIssueKey ?? null, blocker.blockerTitle ?? null, blocker.blockerCurrentLinearState ?? null, now);
303
+ }
304
+ }
305
+ listIssueDependencies(projectId, linearIssueId) {
306
+ const rows = this.connection.prepare(`
307
+ SELECT
308
+ d.project_id,
309
+ d.linear_issue_id,
310
+ d.blocker_linear_issue_id,
311
+ COALESCE(blockers.issue_key, d.blocker_issue_key) AS blocker_issue_key,
312
+ COALESCE(blockers.title, d.blocker_title) AS blocker_title,
313
+ COALESCE(blockers.current_linear_state, d.blocker_current_linear_state) AS blocker_current_linear_state,
314
+ d.updated_at
315
+ FROM issue_dependencies d
316
+ LEFT JOIN issues blockers
317
+ ON blockers.project_id = d.project_id
318
+ AND blockers.linear_issue_id = d.blocker_linear_issue_id
319
+ WHERE d.project_id = ? AND d.linear_issue_id = ?
320
+ ORDER BY COALESCE(blockers.issue_key, d.blocker_issue_key, d.blocker_linear_issue_id) ASC
321
+ `).all(projectId, linearIssueId);
322
+ return rows.map((row) => ({
323
+ projectId: String(row.project_id),
324
+ linearIssueId: String(row.linear_issue_id),
325
+ blockerLinearIssueId: String(row.blocker_linear_issue_id),
326
+ ...(row.blocker_issue_key !== null && row.blocker_issue_key !== undefined ? { blockerIssueKey: String(row.blocker_issue_key) } : {}),
327
+ ...(row.blocker_title !== null && row.blocker_title !== undefined ? { blockerTitle: String(row.blocker_title) } : {}),
328
+ ...(row.blocker_current_linear_state !== null && row.blocker_current_linear_state !== undefined
329
+ ? { blockerCurrentLinearState: String(row.blocker_current_linear_state) }
330
+ : {}),
331
+ updatedAt: String(row.updated_at),
332
+ }));
333
+ }
334
+ listDependents(projectId, blockerLinearIssueId) {
335
+ const rows = this.connection.prepare(`
336
+ SELECT project_id, linear_issue_id
337
+ FROM issue_dependencies
338
+ WHERE project_id = ? AND blocker_linear_issue_id = ?
339
+ ORDER BY linear_issue_id ASC
340
+ `).all(projectId, blockerLinearIssueId);
341
+ return rows.map((row) => ({
342
+ projectId: String(row.project_id),
343
+ linearIssueId: String(row.linear_issue_id),
344
+ }));
345
+ }
346
+ countUnresolvedBlockers(projectId, linearIssueId) {
347
+ const row = this.connection.prepare(`
348
+ SELECT COUNT(*) AS count
349
+ FROM issue_dependencies d
350
+ LEFT JOIN issues blockers
351
+ ON blockers.project_id = d.project_id
352
+ AND blockers.linear_issue_id = d.blocker_linear_issue_id
353
+ WHERE d.project_id = ? AND d.linear_issue_id = ?
354
+ AND LOWER(TRIM(COALESCE(blockers.current_linear_state, d.blocker_current_linear_state, ''))) != 'done'
355
+ `).get(projectId, linearIssueId);
356
+ return Number(row?.count ?? 0);
357
+ }
282
358
  listIssuesReadyForExecution() {
283
359
  const rows = this.connection
284
- .prepare("SELECT project_id, linear_issue_id FROM issues WHERE pending_run_type IS NOT NULL AND active_run_id IS NULL")
360
+ .prepare(`
361
+ SELECT i.project_id, i.linear_issue_id
362
+ FROM issues i
363
+ WHERE i.pending_run_type IS NOT NULL
364
+ AND i.active_run_id IS NULL
365
+ AND NOT EXISTS (
366
+ SELECT 1
367
+ FROM issue_dependencies d
368
+ LEFT JOIN issues blockers
369
+ ON blockers.project_id = d.project_id
370
+ AND blockers.linear_issue_id = d.blocker_linear_issue_id
371
+ WHERE d.project_id = i.project_id
372
+ AND d.linear_issue_id = i.linear_issue_id
373
+ AND LOWER(TRIM(COALESCE(blockers.current_linear_state, d.blocker_current_linear_state, ''))) != 'done'
374
+ )
375
+ `)
285
376
  .all();
286
377
  return rows.map((row) => ({
287
378
  projectId: String(row.project_id),
@@ -399,6 +490,7 @@ export class PatchRelayDatabase {
399
490
  }
400
491
  // ─── View builders ──────────────────────────────────────────────
401
492
  issueToTrackedIssue(issue) {
493
+ const blockedBy = this.listIssueDependencies(issue.projectId, issue.linearIssueId);
402
494
  return {
403
495
  id: issue.id,
404
496
  projectId: issue.projectId,
@@ -408,6 +500,11 @@ export class PatchRelayDatabase {
408
500
  ...(issue.url ? { issueUrl: issue.url } : {}),
409
501
  ...(issue.currentLinearState ? { currentLinearState: issue.currentLinearState } : {}),
410
502
  factoryState: issue.factoryState,
503
+ blockedByCount: blockedBy.filter((entry) => !isDoneState(entry.blockerCurrentLinearState)).length,
504
+ blockedByKeys: blockedBy
505
+ .filter((entry) => !isDoneState(entry.blockerCurrentLinearState))
506
+ .map((entry) => entry.blockerIssueKey ?? entry.blockerLinearIssueId),
507
+ readyForExecution: issue.pendingRunType !== undefined && issue.activeRunId === undefined,
411
508
  ...(issue.activeRunId !== undefined ? { activeRunId: issue.activeRunId } : {}),
412
509
  ...(issue.agentSessionId ? { activeAgentSessionId: issue.agentSessionId } : {}),
413
510
  updatedAt: issue.updatedAt,
@@ -505,3 +602,6 @@ function mapRunRow(row) {
505
602
  ...(row.ended_at !== null ? { endedAt: String(row.ended_at) } : {}),
506
603
  };
507
604
  }
605
+ function isDoneState(stateName) {
606
+ return stateName?.trim().toLowerCase() === "done";
607
+ }
package/dist/http.js CHANGED
@@ -378,6 +378,9 @@ export async function buildHttpServer(config, service, logger) {
378
378
  reply.raw.on("error", cleanup);
379
379
  request.raw.on("close", cleanup);
380
380
  });
381
+ app.get("/api/watch/issues", async (_request, reply) => {
382
+ return reply.send({ ok: true, issues: service.listTrackedIssues() });
383
+ });
381
384
  app.get("/api/watch", async (request, reply) => {
382
385
  reply.hijack();
383
386
  reply.raw.writeHead(200, {
@@ -1,5 +1,69 @@
1
1
  import { refreshLinearOAuthToken } from "./linear-oauth.js";
2
2
  import { decryptSecret, encryptSecret } from "./token-crypto.js";
3
+ const LINEAR_ISSUE_SELECTION = `
4
+ id
5
+ identifier
6
+ title
7
+ description
8
+ url
9
+ priority
10
+ estimate
11
+ delegate {
12
+ id
13
+ name
14
+ }
15
+ state {
16
+ id
17
+ name
18
+ }
19
+ labels {
20
+ nodes {
21
+ id
22
+ name
23
+ }
24
+ }
25
+ blockedBy {
26
+ nodes {
27
+ id
28
+ identifier
29
+ title
30
+ state {
31
+ id
32
+ name
33
+ type
34
+ }
35
+ }
36
+ }
37
+ blocks {
38
+ nodes {
39
+ id
40
+ identifier
41
+ title
42
+ state {
43
+ id
44
+ name
45
+ type
46
+ }
47
+ }
48
+ }
49
+ team {
50
+ id
51
+ key
52
+ states {
53
+ nodes {
54
+ id
55
+ name
56
+ type
57
+ }
58
+ }
59
+ labels {
60
+ nodes {
61
+ id
62
+ name
63
+ }
64
+ }
65
+ }
66
+ `;
3
67
  export class LinearGraphqlClient {
4
68
  options;
5
69
  logger;
@@ -11,44 +75,7 @@ export class LinearGraphqlClient {
11
75
  const response = await this.request(`
12
76
  query PatchRelayIssue($id: String!) {
13
77
  issue(id: $id) {
14
- id
15
- identifier
16
- title
17
- description
18
- url
19
- priority
20
- estimate
21
- delegate {
22
- id
23
- name
24
- }
25
- state {
26
- id
27
- name
28
- }
29
- labels {
30
- nodes {
31
- id
32
- name
33
- }
34
- }
35
- team {
36
- id
37
- key
38
- states {
39
- nodes {
40
- id
41
- name
42
- type
43
- }
44
- }
45
- labels {
46
- nodes {
47
- id
48
- name
49
- }
50
- }
51
- }
78
+ ${LINEAR_ISSUE_SELECTION}
52
79
  }
53
80
  }
54
81
  `, { id: issueId });
@@ -68,44 +95,7 @@ export class LinearGraphqlClient {
68
95
  issueUpdate(id: $id, input: { stateId: $stateId }) {
69
96
  success
70
97
  issue {
71
- id
72
- identifier
73
- title
74
- description
75
- url
76
- priority
77
- estimate
78
- delegate {
79
- id
80
- name
81
- }
82
- state {
83
- id
84
- name
85
- }
86
- labels {
87
- nodes {
88
- id
89
- name
90
- }
91
- }
92
- team {
93
- id
94
- key
95
- states {
96
- nodes {
97
- id
98
- name
99
- type
100
- }
101
- }
102
- labels {
103
- nodes {
104
- id
105
- name
106
- }
107
- }
108
- }
98
+ ${LINEAR_ISSUE_SELECTION}
109
99
  }
110
100
  }
111
101
  }
@@ -208,40 +198,7 @@ export class LinearGraphqlClient {
208
198
  issueUpdate(id: $id, input: { addedLabelIds: $addedLabelIds, removedLabelIds: $removedLabelIds }) {
209
199
  success
210
200
  issue {
211
- id
212
- identifier
213
- title
214
- description
215
- url
216
- priority
217
- estimate
218
- state {
219
- id
220
- name
221
- }
222
- labels {
223
- nodes {
224
- id
225
- name
226
- }
227
- }
228
- team {
229
- id
230
- key
231
- states {
232
- nodes {
233
- id
234
- name
235
- type
236
- }
237
- }
238
- labels {
239
- nodes {
240
- id
241
- name
242
- }
243
- }
244
- }
201
+ ${LINEAR_ISSUE_SELECTION}
245
202
  }
246
203
  }
247
204
  }
@@ -372,6 +329,8 @@ export class LinearGraphqlClient {
372
329
  labelIds: labels.map((label) => label.id),
373
330
  labels,
374
331
  teamLabels,
332
+ blockedBy: (issue.blockedBy?.nodes ?? []).map(mapIssueRelation),
333
+ blocks: (issue.blocks?.nodes ?? []).map(mapIssueRelation),
375
334
  };
376
335
  }
377
336
  resolveLabelIds(issue, names) {
@@ -389,6 +348,16 @@ export class LinearGraphqlClient {
389
348
  return labelIds;
390
349
  }
391
350
  }
351
+ function mapIssueRelation(raw) {
352
+ return {
353
+ id: raw.id,
354
+ ...(raw.identifier ? { identifier: raw.identifier } : {}),
355
+ ...(raw.title ? { title: raw.title } : {}),
356
+ ...(raw.state?.id ? { stateId: raw.state.id } : {}),
357
+ ...(raw.state?.name ? { stateName: raw.state.name } : {}),
358
+ ...(raw.state?.type ? { stateType: raw.state.type } : {}),
359
+ };
360
+ }
392
361
  export class DatabaseBackedLinearClientProvider {
393
362
  config;
394
363
  db;
package/dist/service.js CHANGED
@@ -214,12 +214,33 @@ export class PatchRelayService {
214
214
  .prepare(`SELECT
215
215
  i.project_id, i.linear_issue_id, i.issue_key, i.title,
216
216
  i.current_linear_state, i.factory_state, i.updated_at,
217
+ i.pending_run_type,
217
218
  i.pr_number, i.pr_review_state, i.pr_check_status,
218
219
  active_run.run_type AS active_run_type,
219
220
  latest_run.run_type AS latest_run_type,
220
221
  latest_run.status AS latest_run_status,
221
222
  latest_run.summary_json AS latest_run_summary_json,
222
- latest_run.report_json AS latest_run_report_json
223
+ latest_run.report_json AS latest_run_report_json,
224
+ (
225
+ SELECT COUNT(*)
226
+ FROM issue_dependencies d
227
+ LEFT JOIN issues blockers
228
+ ON blockers.project_id = d.project_id
229
+ AND blockers.linear_issue_id = d.blocker_linear_issue_id
230
+ WHERE d.project_id = i.project_id
231
+ AND d.linear_issue_id = i.linear_issue_id
232
+ AND LOWER(TRIM(COALESCE(blockers.current_linear_state, d.blocker_current_linear_state, ''))) != 'done'
233
+ ) AS blocked_by_count,
234
+ (
235
+ SELECT json_group_array(COALESCE(blockers.issue_key, d.blocker_issue_key, d.blocker_linear_issue_id))
236
+ FROM issue_dependencies d
237
+ LEFT JOIN issues blockers
238
+ ON blockers.project_id = d.project_id
239
+ AND blockers.linear_issue_id = d.blocker_linear_issue_id
240
+ WHERE d.project_id = i.project_id
241
+ AND d.linear_issue_id = i.linear_issue_id
242
+ AND LOWER(TRIM(COALESCE(blockers.current_linear_state, d.blocker_current_linear_state, ''))) != 'done'
243
+ ) AS blocked_by_keys_json
223
244
  FROM issues i
224
245
  LEFT JOIN runs active_run ON active_run.id = i.active_run_id
225
246
  LEFT JOIN runs latest_run ON latest_run.id = (
@@ -231,14 +252,24 @@ export class PatchRelayService {
231
252
  .all();
232
253
  return rows.map((row) => {
233
254
  const statusNote = extractStatusNote(typeof row.latest_run_summary_json === "string" ? row.latest_run_summary_json : undefined, typeof row.latest_run_report_json === "string" ? row.latest_run_report_json : undefined);
255
+ const blockedByKeys = parseStringArray(typeof row.blocked_by_keys_json === "string" ? row.blocked_by_keys_json : undefined);
256
+ const blockedByCount = Number(row.blocked_by_count ?? 0);
257
+ const readyForExecution = row.pending_run_type !== null && row.pending_run_type !== undefined && row.active_run_type === null && blockedByCount === 0;
258
+ const statusNoteWithBlockers = blockedByCount > 0
259
+ ? `Blocked by ${blockedByKeys.join(", ")}`
260
+ : statusNote;
234
261
  return {
235
262
  ...(row.issue_key !== null ? { issueKey: String(row.issue_key) } : {}),
236
263
  ...(row.title !== null ? { title: String(row.title) } : {}),
237
- ...(statusNote ? { statusNote } : {}),
264
+ ...(statusNoteWithBlockers ? { statusNote: statusNoteWithBlockers } : {}),
238
265
  projectId: String(row.project_id),
239
266
  factoryState: String(row.factory_state ?? "delegated"),
267
+ blockedByCount,
268
+ blockedByKeys,
269
+ readyForExecution,
240
270
  ...(row.current_linear_state !== null ? { currentLinearState: String(row.current_linear_state) } : {}),
241
271
  ...(row.active_run_type !== null ? { activeRunType: String(row.active_run_type) } : {}),
272
+ ...(row.pending_run_type !== null ? { pendingRunType: String(row.pending_run_type) } : {}),
242
273
  ...(row.latest_run_type !== null ? { latestRunType: String(row.latest_run_type) } : {}),
243
274
  ...(row.latest_run_status !== null ? { latestRunStatus: String(row.latest_run_status) } : {}),
244
275
  ...(row.pr_number !== null ? { prNumber: Number(row.pr_number) } : {}),
@@ -94,8 +94,9 @@ export class WebhookHandler {
94
94
  const hydrated = await this.hydrateIssueContext(project.id, normalized);
95
95
  const issue = hydrated.issue ?? routedIssue;
96
96
  // Record desired stage and upsert issue
97
- const result = this.recordDesiredStage(project, hydrated);
97
+ const result = await this.recordDesiredStage(project, hydrated);
98
98
  const trackedIssue = result.issue;
99
+ const newlyReadyDependents = this.reconcileDependentReadiness(project.id, issue.id);
99
100
  // Handle agent session events
100
101
  await this.handleAgentSession(hydrated, project, trackedIssue, result.desiredStage, result.delegated);
101
102
  // Handle comments during active run
@@ -114,6 +115,20 @@ export class WebhookHandler {
114
115
  });
115
116
  this.enqueueIssue(project.id, issue.id);
116
117
  }
118
+ for (const dependentIssueId of newlyReadyDependents) {
119
+ const dependent = this.db.getTrackedIssue(project.id, dependentIssueId);
120
+ this.feed?.publish({
121
+ level: "info",
122
+ kind: "stage",
123
+ issueKey: dependent?.issueKey,
124
+ projectId: project.id,
125
+ stage: "implementation",
126
+ status: "queued",
127
+ summary: "Queued implementation after blockers resolved",
128
+ detail: `All blockers are now done for ${dependent?.issueKey ?? dependentIssueId}.`,
129
+ });
130
+ this.enqueueIssue(project.id, dependentIssueId);
131
+ }
117
132
  }
118
133
  catch (error) {
119
134
  this.db.markWebhookProcessed(webhookEventId, "failed");
@@ -130,7 +145,7 @@ export class WebhookHandler {
130
145
  throw err;
131
146
  }
132
147
  }
133
- recordDesiredStage(project, normalized) {
148
+ async recordDesiredStage(project, normalized) {
134
149
  const normalizedIssue = normalized.issue;
135
150
  if (!normalizedIssue) {
136
151
  return { issue: undefined, desiredStage: undefined, delegated: false };
@@ -139,19 +154,18 @@ export class WebhookHandler {
139
154
  const activeRun = existingIssue?.activeRunId ? this.db.getRun(existingIssue.activeRunId) : undefined;
140
155
  const delegated = this.isDelegatedToPatchRelay(project, normalized);
141
156
  const triggerAllowed = triggerEventAllowed(project, normalized.triggerEvent);
157
+ const shouldTrack = Boolean(existingIssue || delegated);
158
+ if (!shouldTrack) {
159
+ return { issue: undefined, desiredStage: undefined, delegated };
160
+ }
161
+ const hydratedIssue = await this.syncIssueDependencies(project.id, normalizedIssue);
162
+ const unresolvedBlockers = this.db.countUnresolvedBlockers(project.id, normalizedIssue.id);
142
163
  const pendingRunContextJson = mergePendingImplementationContext(existingIssue?.pendingRunContextJson, normalized);
143
- // In the factory model, only a true delegation queues implementation work.
144
164
  let pendingRunType;
145
- const isDelegationSignal = delegated;
146
- if (isDelegationSignal && triggerAllowed && !activeRun && !existingIssue?.pendingRunType) {
165
+ if (delegated && triggerAllowed && unresolvedBlockers === 0 && !activeRun && !existingIssue?.pendingRunType) {
147
166
  pendingRunType = "implementation";
148
167
  }
149
- // Do not create tracked issue rows for unrelated Linear traffic.
150
- // An issue becomes PatchRelay-relevant only once it is already tracked
151
- // or a true delegation queues work.
152
- if (!existingIssue && !pendingRunType) {
153
- return { issue: undefined, desiredStage: undefined, delegated };
154
- }
168
+ const clearPendingImplementation = unresolvedBlockers > 0 && existingIssue?.pendingRunType === "implementation" && !activeRun;
155
169
  // Resolve agent session
156
170
  const agentSessionId = normalized.agentSession?.id ??
157
171
  (!activeRun && (pendingRunType || (normalized.triggerEvent === "delegateChanged" && !delegated)) ? null : undefined);
@@ -159,14 +173,15 @@ export class WebhookHandler {
159
173
  const issue = this.db.upsertIssue({
160
174
  projectId: project.id,
161
175
  linearIssueId: normalizedIssue.id,
162
- ...(normalizedIssue.identifier ? { issueKey: normalizedIssue.identifier } : {}),
163
- ...(normalizedIssue.title ? { title: normalizedIssue.title } : {}),
164
- ...(normalizedIssue.description ? { description: normalizedIssue.description } : {}),
165
- ...(normalizedIssue.url ? { url: normalizedIssue.url } : {}),
166
- ...(normalizedIssue.priority != null ? { priority: normalizedIssue.priority } : {}),
167
- ...(normalizedIssue.estimate != null ? { estimate: normalizedIssue.estimate } : {}),
168
- ...(normalizedIssue.stateName ? { currentLinearState: normalizedIssue.stateName } : {}),
176
+ ...(hydratedIssue.identifier ? { issueKey: hydratedIssue.identifier } : {}),
177
+ ...(hydratedIssue.title ? { title: hydratedIssue.title } : {}),
178
+ ...(hydratedIssue.description ? { description: hydratedIssue.description } : {}),
179
+ ...(hydratedIssue.url ? { url: hydratedIssue.url } : {}),
180
+ ...(hydratedIssue.priority != null ? { priority: hydratedIssue.priority } : {}),
181
+ ...(hydratedIssue.estimate != null ? { estimate: hydratedIssue.estimate } : {}),
182
+ ...(hydratedIssue.stateName ? { currentLinearState: hydratedIssue.stateName } : {}),
169
183
  ...(pendingRunType ? { pendingRunType, factoryState: "delegated" } : {}),
184
+ ...(clearPendingImplementation ? { pendingRunType: null } : {}),
170
185
  ...((pendingRunType || existingIssue?.pendingRunType === "implementation") && pendingRunContextJson
171
186
  ? { pendingRunContextJson }
172
187
  : {}),
@@ -186,6 +201,61 @@ export class WebhookHandler {
186
201
  return false;
187
202
  return normalized.issue.delegateId === installation.actorId;
188
203
  }
204
+ async syncIssueDependencies(projectId, issue) {
205
+ let source = issue;
206
+ if (source.blockedBy.length === 0 && source.blocks.length === 0) {
207
+ const linear = await this.linearProvider.forProject(projectId);
208
+ if (linear) {
209
+ try {
210
+ source = mergeIssueMetadata(source, await linear.getIssue(issue.id));
211
+ }
212
+ catch {
213
+ // Fall back to webhook payload data when live hydration is unavailable.
214
+ }
215
+ }
216
+ }
217
+ this.db.replaceIssueDependencies({
218
+ projectId,
219
+ linearIssueId: source.id,
220
+ blockers: source.blockedBy.map((blocker) => ({
221
+ blockerLinearIssueId: blocker.id,
222
+ ...(blocker.identifier ? { blockerIssueKey: blocker.identifier } : {}),
223
+ ...(blocker.title ? { blockerTitle: blocker.title } : {}),
224
+ ...(blocker.stateName ? { blockerCurrentLinearState: blocker.stateName } : {}),
225
+ })),
226
+ });
227
+ return source;
228
+ }
229
+ reconcileDependentReadiness(projectId, blockerLinearIssueId) {
230
+ const newlyReady = [];
231
+ for (const dependent of this.db.listDependents(projectId, blockerLinearIssueId)) {
232
+ const issue = this.db.getIssue(projectId, dependent.linearIssueId);
233
+ if (!issue) {
234
+ continue;
235
+ }
236
+ const unresolved = this.db.countUnresolvedBlockers(projectId, dependent.linearIssueId);
237
+ if (unresolved > 0) {
238
+ if (issue.pendingRunType === "implementation" && issue.activeRunId === undefined) {
239
+ this.db.upsertIssue({
240
+ projectId,
241
+ linearIssueId: dependent.linearIssueId,
242
+ pendingRunType: null,
243
+ });
244
+ }
245
+ continue;
246
+ }
247
+ if (issue.factoryState !== "delegated" || issue.activeRunId !== undefined || issue.pendingRunType !== undefined) {
248
+ continue;
249
+ }
250
+ this.db.upsertIssue({
251
+ projectId,
252
+ linearIssueId: dependent.linearIssueId,
253
+ pendingRunType: "implementation",
254
+ });
255
+ newlyReady.push(dependent.linearIssueId);
256
+ }
257
+ return newlyReady;
258
+ }
189
259
  // ─── Agent session handling (inlined) ─────────────────────────────
190
260
  async handleAgentSession(normalized, project, trackedIssue, desiredStage, delegated) {
191
261
  if (!normalized.agentSession?.id || !normalized.issue)
@@ -213,9 +283,12 @@ export class WebhookHandler {
213
283
  await this.publishAgentActivity(linear, normalized.agentSession.id, buildAlreadyRunningThought(activeRun.runType));
214
284
  return;
215
285
  }
286
+ const blockerSummary = trackedIssue?.blockedByCount
287
+ ? `PatchRelay is delegated and waiting on blockers to reach Done: ${trackedIssue.blockedByKeys.join(", ")}.`
288
+ : "PatchRelay is delegated, but no work is queued. Delegate the issue or move it to Start to trigger implementation.";
216
289
  await this.publishAgentActivity(linear, normalized.agentSession.id, {
217
290
  type: "elicitation",
218
- body: "PatchRelay is delegated, but no work is queued. Delegate the issue or move it to Start to trigger implementation.",
291
+ body: blockerSummary,
219
292
  });
220
293
  return;
221
294
  }
@@ -423,9 +496,10 @@ export class WebhookHandler {
423
496
  async hydrateIssueContext(projectId, normalized) {
424
497
  if (!normalized.issue)
425
498
  return normalized;
426
- if (normalized.triggerEvent !== "agentSessionCreated" && normalized.triggerEvent !== "agentPrompted")
499
+ if (normalized.triggerEvent !== "agentSessionCreated" && normalized.triggerEvent !== "agentPrompted" && normalized.entityType !== "Issue") {
427
500
  return normalized;
428
- if (hasCompleteIssueContext(normalized.issue))
501
+ }
502
+ if (normalized.entityType !== "Issue" && hasCompleteIssueContext(normalized.issue))
429
503
  return normalized;
430
504
  const linear = await this.linearProvider.forProject(projectId);
431
505
  if (!linear)
@@ -488,5 +562,7 @@ function mergeIssueMetadata(issue, liveIssue) {
488
562
  ...(issue.delegateId ? {} : liveIssue.delegateId ? { delegateId: liveIssue.delegateId } : {}),
489
563
  ...(issue.delegateName ? {} : liveIssue.delegateName ? { delegateName: liveIssue.delegateName } : {}),
490
564
  labelNames: issue.labelNames.length > 0 ? issue.labelNames : (liveIssue.labels ?? []).map((l) => l.name),
565
+ blockedBy: issue.blockedBy.length > 0 ? issue.blockedBy : (liveIssue.blockedBy ?? []),
566
+ blocks: issue.blocks.length > 0 ? issue.blocks : (liveIssue.blocks ?? []),
491
567
  };
492
568
  }
package/dist/webhooks.js CHANGED
@@ -80,12 +80,12 @@ function deriveTriggerEvent(payload) {
80
80
  if (updatedFields.has("stateId") || updatedFields.has("state")) {
81
81
  return "statusChanged";
82
82
  }
83
- if (updatedFields.has("assigneeId") || updatedFields.has("assignee")) {
84
- return "assignmentChanged";
85
- }
86
83
  if (updatedFields.has("delegateId") || updatedFields.has("delegate")) {
87
84
  return "delegateChanged";
88
85
  }
86
+ if (updatedFields.has("assigneeId") || updatedFields.has("assignee")) {
87
+ return "assignmentChanged";
88
+ }
89
89
  return "issueUpdated";
90
90
  }
91
91
  if (payload.type === "Comment") {
@@ -228,6 +228,8 @@ function extractIssueMetadata(payload) {
228
228
  ...(priority != null ? { priority } : {}),
229
229
  ...(estimate != null ? { estimate } : {}),
230
230
  labelNames: extractLabelNames(issueRecord),
231
+ blockedBy: [],
232
+ blocks: [],
231
233
  };
232
234
  }
233
235
  function extractActorFromRecord(record) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "patchrelay",
3
- "version": "0.29.2",
3
+ "version": "0.30.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "repository": {