patchrelay 0.29.3 → 0.30.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "service": "patchrelay",
3
- "version": "0.29.3",
4
- "commit": "7f7c95a676dc",
5
- "builtAt": "2026-04-01T00:22:06.894Z"
3
+ "version": "0.30.1",
4
+ "commit": "dee1d8b522b1",
5
+ "builtAt": "2026-04-01T08:31:08.208Z"
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
  }
@@ -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) {
@@ -7,6 +7,7 @@ CREATE TABLE IF NOT EXISTS issues (
7
7
  title TEXT,
8
8
  url TEXT,
9
9
  current_linear_state TEXT,
10
+ current_linear_state_type TEXT,
10
11
  factory_state TEXT NOT NULL DEFAULT 'delegated',
11
12
  pending_run_type TEXT,
12
13
  pending_run_context_json TEXT,
@@ -145,6 +146,18 @@ CREATE TABLE IF NOT EXISTS operator_feed_events (
145
146
  status TEXT
146
147
  );
147
148
 
149
+ CREATE TABLE IF NOT EXISTS issue_dependencies (
150
+ project_id TEXT NOT NULL,
151
+ linear_issue_id TEXT NOT NULL,
152
+ blocker_linear_issue_id TEXT NOT NULL,
153
+ blocker_issue_key TEXT,
154
+ blocker_title TEXT,
155
+ blocker_current_linear_state TEXT,
156
+ blocker_current_linear_state_type TEXT,
157
+ updated_at TEXT NOT NULL,
158
+ PRIMARY KEY (project_id, linear_issue_id, blocker_linear_issue_id)
159
+ );
160
+
148
161
  CREATE INDEX IF NOT EXISTS idx_issues_project ON issues(project_id, linear_issue_id);
149
162
  CREATE INDEX IF NOT EXISTS idx_issues_key ON issues(issue_key);
150
163
  CREATE INDEX IF NOT EXISTS idx_issues_ready ON issues(pending_run_type, active_run_id);
@@ -158,6 +171,8 @@ CREATE INDEX IF NOT EXISTS idx_operator_feed_events_project ON operator_feed_eve
158
171
  CREATE INDEX IF NOT EXISTS idx_repository_links_installation ON repository_links(installation_id, github_repo);
159
172
  CREATE INDEX IF NOT EXISTS idx_linear_catalog_teams_installation ON linear_catalog_teams(installation_id, team_key, team_name);
160
173
  CREATE INDEX IF NOT EXISTS idx_linear_catalog_projects_installation ON linear_catalog_projects(installation_id, project_name);
174
+ CREATE INDEX IF NOT EXISTS idx_issue_dependencies_issue ON issue_dependencies(project_id, linear_issue_id);
175
+ CREATE INDEX IF NOT EXISTS idx_issue_dependencies_blocker ON issue_dependencies(project_id, blocker_linear_issue_id);
161
176
  `;
162
177
  export function runPatchRelayMigrations(connection) {
163
178
  connection.exec(schema);
@@ -175,6 +190,8 @@ export function runPatchRelayMigrations(connection) {
175
190
  addColumnIfMissing(connection, "issues", "description", "TEXT");
176
191
  addColumnIfMissing(connection, "issues", "priority", "INTEGER");
177
192
  addColumnIfMissing(connection, "issues", "estimate", "REAL");
193
+ addColumnIfMissing(connection, "issues", "current_linear_state_type", "TEXT");
194
+ addColumnIfMissing(connection, "issue_dependencies", "blocker_current_linear_state_type", "TEXT");
178
195
  // Zombie/stale recovery backoff
179
196
  addColumnIfMissing(connection, "issues", "zombie_recovery_attempts", "INTEGER NOT NULL DEFAULT 0");
180
197
  addColumnIfMissing(connection, "issues", "last_zombie_recovery_at", "TEXT");
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
@@ -104,6 +104,10 @@ export class PatchRelayDatabase {
104
104
  sets.push("current_linear_state = COALESCE(@currentLinearState, current_linear_state)");
105
105
  values.currentLinearState = params.currentLinearState;
106
106
  }
107
+ if (params.currentLinearStateType !== undefined) {
108
+ sets.push("current_linear_state_type = COALESCE(@currentLinearStateType, current_linear_state_type)");
109
+ values.currentLinearStateType = params.currentLinearStateType;
110
+ }
107
111
  if (params.factoryState !== undefined) {
108
112
  sets.push("factory_state = @factoryState");
109
113
  values.factoryState = params.factoryState;
@@ -207,7 +211,7 @@ export class PatchRelayDatabase {
207
211
  INSERT INTO issues (
208
212
  project_id, linear_issue_id, issue_key, title, description, url,
209
213
  priority, estimate,
210
- current_linear_state, factory_state, pending_run_type, pending_run_context_json,
214
+ current_linear_state, current_linear_state_type, factory_state, pending_run_type, pending_run_context_json,
211
215
  branch_name, worktree_path, thread_id, active_run_id,
212
216
  agent_session_id,
213
217
  pr_number, pr_url, pr_state, pr_review_state, pr_check_status,
@@ -216,7 +220,7 @@ export class PatchRelayDatabase {
216
220
  ) VALUES (
217
221
  @projectId, @linearIssueId, @issueKey, @title, @description, @url,
218
222
  @priority, @estimate,
219
- @currentLinearState, @factoryState, @pendingRunType, @pendingRunContextJson,
223
+ @currentLinearState, @currentLinearStateType, @factoryState, @pendingRunType, @pendingRunContextJson,
220
224
  @branchName, @worktreePath, @threadId, @activeRunId,
221
225
  @agentSessionId,
222
226
  @prNumber, @prUrl, @prState, @prReviewState, @prCheckStatus,
@@ -233,6 +237,7 @@ export class PatchRelayDatabase {
233
237
  priority: params.priority ?? null,
234
238
  estimate: params.estimate ?? null,
235
239
  currentLinearState: params.currentLinearState ?? null,
240
+ currentLinearStateType: params.currentLinearStateType ?? null,
236
241
  factoryState: params.factoryState ?? "delegated",
237
242
  pendingRunType: params.pendingRunType ?? null,
238
243
  pendingRunContextJson: params.pendingRunContextJson ?? null,
@@ -279,9 +284,111 @@ export class PatchRelayDatabase {
279
284
  const row = this.connection.prepare("SELECT * FROM issues WHERE pr_number = ?").get(prNumber);
280
285
  return row ? mapIssueRow(row) : undefined;
281
286
  }
287
+ replaceIssueDependencies(params) {
288
+ const now = isoNow();
289
+ this.connection
290
+ .prepare("DELETE FROM issue_dependencies WHERE project_id = ? AND linear_issue_id = ?")
291
+ .run(params.projectId, params.linearIssueId);
292
+ if (params.blockers.length === 0) {
293
+ return;
294
+ }
295
+ const insert = this.connection.prepare(`
296
+ INSERT INTO issue_dependencies (
297
+ project_id,
298
+ linear_issue_id,
299
+ blocker_linear_issue_id,
300
+ blocker_issue_key,
301
+ blocker_title,
302
+ blocker_current_linear_state,
303
+ blocker_current_linear_state_type,
304
+ updated_at
305
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
306
+ `);
307
+ for (const blocker of params.blockers) {
308
+ insert.run(params.projectId, params.linearIssueId, blocker.blockerLinearIssueId, blocker.blockerIssueKey ?? null, blocker.blockerTitle ?? null, blocker.blockerCurrentLinearState ?? null, blocker.blockerCurrentLinearStateType ?? null, now);
309
+ }
310
+ }
311
+ listIssueDependencies(projectId, linearIssueId) {
312
+ const rows = this.connection.prepare(`
313
+ SELECT
314
+ d.project_id,
315
+ d.linear_issue_id,
316
+ d.blocker_linear_issue_id,
317
+ COALESCE(blockers.issue_key, d.blocker_issue_key) AS blocker_issue_key,
318
+ COALESCE(blockers.title, d.blocker_title) AS blocker_title,
319
+ COALESCE(blockers.current_linear_state, d.blocker_current_linear_state) AS blocker_current_linear_state,
320
+ COALESCE(blockers.current_linear_state_type, d.blocker_current_linear_state_type) AS blocker_current_linear_state_type,
321
+ d.updated_at
322
+ FROM issue_dependencies d
323
+ LEFT JOIN issues blockers
324
+ ON blockers.project_id = d.project_id
325
+ AND blockers.linear_issue_id = d.blocker_linear_issue_id
326
+ WHERE d.project_id = ? AND d.linear_issue_id = ?
327
+ ORDER BY COALESCE(blockers.issue_key, d.blocker_issue_key, d.blocker_linear_issue_id) ASC
328
+ `).all(projectId, linearIssueId);
329
+ return rows.map((row) => ({
330
+ projectId: String(row.project_id),
331
+ linearIssueId: String(row.linear_issue_id),
332
+ blockerLinearIssueId: String(row.blocker_linear_issue_id),
333
+ ...(row.blocker_issue_key !== null && row.blocker_issue_key !== undefined ? { blockerIssueKey: String(row.blocker_issue_key) } : {}),
334
+ ...(row.blocker_title !== null && row.blocker_title !== undefined ? { blockerTitle: String(row.blocker_title) } : {}),
335
+ ...(row.blocker_current_linear_state !== null && row.blocker_current_linear_state !== undefined
336
+ ? { blockerCurrentLinearState: String(row.blocker_current_linear_state) }
337
+ : {}),
338
+ ...(row.blocker_current_linear_state_type !== null && row.blocker_current_linear_state_type !== undefined
339
+ ? { blockerCurrentLinearStateType: String(row.blocker_current_linear_state_type) }
340
+ : {}),
341
+ updatedAt: String(row.updated_at),
342
+ }));
343
+ }
344
+ listDependents(projectId, blockerLinearIssueId) {
345
+ const rows = this.connection.prepare(`
346
+ SELECT project_id, linear_issue_id
347
+ FROM issue_dependencies
348
+ WHERE project_id = ? AND blocker_linear_issue_id = ?
349
+ ORDER BY linear_issue_id ASC
350
+ `).all(projectId, blockerLinearIssueId);
351
+ return rows.map((row) => ({
352
+ projectId: String(row.project_id),
353
+ linearIssueId: String(row.linear_issue_id),
354
+ }));
355
+ }
356
+ countUnresolvedBlockers(projectId, linearIssueId) {
357
+ const row = this.connection.prepare(`
358
+ SELECT COUNT(*) AS count
359
+ FROM issue_dependencies d
360
+ LEFT JOIN issues blockers
361
+ ON blockers.project_id = d.project_id
362
+ AND blockers.linear_issue_id = d.blocker_linear_issue_id
363
+ WHERE d.project_id = ? AND d.linear_issue_id = ?
364
+ AND (
365
+ COALESCE(blockers.current_linear_state_type, d.blocker_current_linear_state_type, '') != 'completed'
366
+ AND LOWER(TRIM(COALESCE(blockers.current_linear_state, d.blocker_current_linear_state, ''))) != 'done'
367
+ )
368
+ `).get(projectId, linearIssueId);
369
+ return Number(row?.count ?? 0);
370
+ }
282
371
  listIssuesReadyForExecution() {
283
372
  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")
373
+ .prepare(`
374
+ SELECT i.project_id, i.linear_issue_id
375
+ FROM issues i
376
+ WHERE i.pending_run_type IS NOT NULL
377
+ AND i.active_run_id IS NULL
378
+ AND NOT EXISTS (
379
+ SELECT 1
380
+ FROM issue_dependencies d
381
+ LEFT JOIN issues blockers
382
+ ON blockers.project_id = d.project_id
383
+ AND blockers.linear_issue_id = d.blocker_linear_issue_id
384
+ WHERE d.project_id = i.project_id
385
+ AND d.linear_issue_id = i.linear_issue_id
386
+ AND (
387
+ COALESCE(blockers.current_linear_state_type, d.blocker_current_linear_state_type, '') != 'completed'
388
+ AND LOWER(TRIM(COALESCE(blockers.current_linear_state, d.blocker_current_linear_state, ''))) != 'done'
389
+ )
390
+ )
391
+ `)
285
392
  .all();
286
393
  return rows.map((row) => ({
287
394
  projectId: String(row.project_id),
@@ -399,6 +506,8 @@ export class PatchRelayDatabase {
399
506
  }
400
507
  // ─── View builders ──────────────────────────────────────────────
401
508
  issueToTrackedIssue(issue) {
509
+ const blockedBy = this.listIssueDependencies(issue.projectId, issue.linearIssueId);
510
+ const unresolvedBlockedBy = blockedBy.filter((entry) => !isResolvedLinearState(entry.blockerCurrentLinearStateType, entry.blockerCurrentLinearState));
402
511
  return {
403
512
  id: issue.id,
404
513
  projectId: issue.projectId,
@@ -408,6 +517,10 @@ export class PatchRelayDatabase {
408
517
  ...(issue.url ? { issueUrl: issue.url } : {}),
409
518
  ...(issue.currentLinearState ? { currentLinearState: issue.currentLinearState } : {}),
410
519
  factoryState: issue.factoryState,
520
+ blockedByCount: unresolvedBlockedBy.length,
521
+ blockedByKeys: unresolvedBlockedBy
522
+ .map((entry) => entry.blockerIssueKey ?? entry.blockerLinearIssueId),
523
+ readyForExecution: issue.pendingRunType !== undefined && issue.activeRunId === undefined && unresolvedBlockedBy.length === 0,
411
524
  ...(issue.activeRunId !== undefined ? { activeRunId: issue.activeRunId } : {}),
412
525
  ...(issue.agentSessionId ? { activeAgentSessionId: issue.agentSessionId } : {}),
413
526
  updatedAt: issue.updatedAt,
@@ -447,6 +560,9 @@ function mapIssueRow(row) {
447
560
  ...(row.priority !== null && row.priority !== undefined ? { priority: Number(row.priority) } : {}),
448
561
  ...(row.estimate !== null && row.estimate !== undefined ? { estimate: Number(row.estimate) } : {}),
449
562
  ...(row.current_linear_state !== null ? { currentLinearState: String(row.current_linear_state) } : {}),
563
+ ...(row.current_linear_state_type !== null && row.current_linear_state_type !== undefined
564
+ ? { currentLinearStateType: String(row.current_linear_state_type) }
565
+ : {}),
450
566
  factoryState: String(row.factory_state ?? "delegated"),
451
567
  ...(row.pending_run_type !== null && row.pending_run_type !== undefined ? { pendingRunType: String(row.pending_run_type) } : {}),
452
568
  ...(row.pending_run_context_json !== null && row.pending_run_context_json !== undefined ? { pendingRunContextJson: String(row.pending_run_context_json) } : {}),
@@ -505,3 +621,6 @@ function mapRunRow(row) {
505
621
  ...(row.ended_at !== null ? { endedAt: String(row.ended_at) } : {}),
506
622
  };
507
623
  }
624
+ function isResolvedLinearState(stateType, stateName) {
625
+ return stateType === "completed" || stateName?.trim().toLowerCase() === "done";
626
+ }
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,70 @@
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
+ type
19
+ }
20
+ labels {
21
+ nodes {
22
+ id
23
+ name
24
+ }
25
+ }
26
+ blockedBy {
27
+ nodes {
28
+ id
29
+ identifier
30
+ title
31
+ state {
32
+ id
33
+ name
34
+ type
35
+ }
36
+ }
37
+ }
38
+ blocks {
39
+ nodes {
40
+ id
41
+ identifier
42
+ title
43
+ state {
44
+ id
45
+ name
46
+ type
47
+ }
48
+ }
49
+ }
50
+ team {
51
+ id
52
+ key
53
+ states {
54
+ nodes {
55
+ id
56
+ name
57
+ type
58
+ }
59
+ }
60
+ labels {
61
+ nodes {
62
+ id
63
+ name
64
+ }
65
+ }
66
+ }
67
+ `;
3
68
  export class LinearGraphqlClient {
4
69
  options;
5
70
  logger;
@@ -11,44 +76,7 @@ export class LinearGraphqlClient {
11
76
  const response = await this.request(`
12
77
  query PatchRelayIssue($id: String!) {
13
78
  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
- }
79
+ ${LINEAR_ISSUE_SELECTION}
52
80
  }
53
81
  }
54
82
  `, { id: issueId });
@@ -68,44 +96,7 @@ export class LinearGraphqlClient {
68
96
  issueUpdate(id: $id, input: { stateId: $stateId }) {
69
97
  success
70
98
  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
- }
99
+ ${LINEAR_ISSUE_SELECTION}
109
100
  }
110
101
  }
111
102
  }
@@ -208,40 +199,7 @@ export class LinearGraphqlClient {
208
199
  issueUpdate(id: $id, input: { addedLabelIds: $addedLabelIds, removedLabelIds: $removedLabelIds }) {
209
200
  success
210
201
  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
- }
202
+ ${LINEAR_ISSUE_SELECTION}
245
203
  }
246
204
  }
247
205
  }
@@ -360,6 +318,7 @@ export class LinearGraphqlClient {
360
318
  ...(issue.estimate != null ? { estimate: issue.estimate } : {}),
361
319
  ...(issue.state?.id ? { stateId: issue.state.id } : {}),
362
320
  ...(issue.state?.name ? { stateName: issue.state.name } : {}),
321
+ ...(issue.state?.type ? { stateType: issue.state.type } : {}),
363
322
  ...(issue.team?.id ? { teamId: issue.team.id } : {}),
364
323
  ...(issue.team?.key ? { teamKey: issue.team.key } : {}),
365
324
  ...(issue.delegate?.id ? { delegateId: issue.delegate.id } : {}),
@@ -372,6 +331,8 @@ export class LinearGraphqlClient {
372
331
  labelIds: labels.map((label) => label.id),
373
332
  labels,
374
333
  teamLabels,
334
+ blockedBy: (issue.blockedBy?.nodes ?? []).map(mapIssueRelation),
335
+ blocks: (issue.blocks?.nodes ?? []).map(mapIssueRelation),
375
336
  };
376
337
  }
377
338
  resolveLabelIds(issue, names) {
@@ -389,6 +350,16 @@ export class LinearGraphqlClient {
389
350
  return labelIds;
390
351
  }
391
352
  }
353
+ function mapIssueRelation(raw) {
354
+ return {
355
+ id: raw.id,
356
+ ...(raw.identifier ? { identifier: raw.identifier } : {}),
357
+ ...(raw.title ? { title: raw.title } : {}),
358
+ ...(raw.state?.id ? { stateId: raw.state.id } : {}),
359
+ ...(raw.state?.name ? { stateName: raw.state.name } : {}),
360
+ ...(raw.state?.type ? { stateType: raw.state.type } : {}),
361
+ };
362
+ }
392
363
  export class DatabaseBackedLinearClientProvider {
393
364
  config;
394
365
  db;
package/dist/service.js CHANGED
@@ -214,12 +214,39 @@ 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 (
233
+ COALESCE(blockers.current_linear_state_type, d.blocker_current_linear_state_type, '') != 'completed'
234
+ AND LOWER(TRIM(COALESCE(blockers.current_linear_state, d.blocker_current_linear_state, ''))) != 'done'
235
+ )
236
+ ) AS blocked_by_count,
237
+ (
238
+ SELECT json_group_array(COALESCE(blockers.issue_key, d.blocker_issue_key, d.blocker_linear_issue_id))
239
+ FROM issue_dependencies d
240
+ LEFT JOIN issues blockers
241
+ ON blockers.project_id = d.project_id
242
+ AND blockers.linear_issue_id = d.blocker_linear_issue_id
243
+ WHERE d.project_id = i.project_id
244
+ AND d.linear_issue_id = i.linear_issue_id
245
+ AND (
246
+ COALESCE(blockers.current_linear_state_type, d.blocker_current_linear_state_type, '') != 'completed'
247
+ AND LOWER(TRIM(COALESCE(blockers.current_linear_state, d.blocker_current_linear_state, ''))) != 'done'
248
+ )
249
+ ) AS blocked_by_keys_json
223
250
  FROM issues i
224
251
  LEFT JOIN runs active_run ON active_run.id = i.active_run_id
225
252
  LEFT JOIN runs latest_run ON latest_run.id = (
@@ -231,14 +258,24 @@ export class PatchRelayService {
231
258
  .all();
232
259
  return rows.map((row) => {
233
260
  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);
261
+ const blockedByKeys = parseStringArray(typeof row.blocked_by_keys_json === "string" ? row.blocked_by_keys_json : undefined);
262
+ const blockedByCount = Number(row.blocked_by_count ?? 0);
263
+ const readyForExecution = row.pending_run_type !== null && row.pending_run_type !== undefined && row.active_run_type === null && blockedByCount === 0;
264
+ const statusNoteWithBlockers = blockedByCount > 0
265
+ ? `Blocked by ${blockedByKeys.join(", ")}`
266
+ : statusNote;
234
267
  return {
235
268
  ...(row.issue_key !== null ? { issueKey: String(row.issue_key) } : {}),
236
269
  ...(row.title !== null ? { title: String(row.title) } : {}),
237
- ...(statusNote ? { statusNote } : {}),
270
+ ...(statusNoteWithBlockers ? { statusNote: statusNoteWithBlockers } : {}),
238
271
  projectId: String(row.project_id),
239
272
  factoryState: String(row.factory_state ?? "delegated"),
273
+ blockedByCount,
274
+ blockedByKeys,
275
+ readyForExecution,
240
276
  ...(row.current_linear_state !== null ? { currentLinearState: String(row.current_linear_state) } : {}),
241
277
  ...(row.active_run_type !== null ? { activeRunType: String(row.active_run_type) } : {}),
278
+ ...(row.pending_run_type !== null ? { pendingRunType: String(row.pending_run_type) } : {}),
242
279
  ...(row.latest_run_type !== null ? { latestRunType: String(row.latest_run_type) } : {}),
243
280
  ...(row.latest_run_status !== null ? { latestRunStatus: String(row.latest_run_status) } : {}),
244
281
  ...(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,16 @@ 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 } : {}),
183
+ ...(hydratedIssue.stateType ? { currentLinearStateType: hydratedIssue.stateType } : {}),
169
184
  ...(pendingRunType ? { pendingRunType, factoryState: "delegated" } : {}),
185
+ ...(clearPendingImplementation ? { pendingRunType: null } : {}),
170
186
  ...((pendingRunType || existingIssue?.pendingRunType === "implementation") && pendingRunContextJson
171
187
  ? { pendingRunContextJson }
172
188
  : {}),
@@ -186,6 +202,64 @@ export class WebhookHandler {
186
202
  return false;
187
203
  return normalized.issue.delegateId === installation.actorId;
188
204
  }
205
+ async syncIssueDependencies(projectId, issue) {
206
+ let source = issue;
207
+ if (!source.relationsKnown) {
208
+ const linear = await this.linearProvider.forProject(projectId);
209
+ if (linear) {
210
+ try {
211
+ source = mergeIssueMetadata(source, await linear.getIssue(issue.id));
212
+ }
213
+ catch {
214
+ // Preserve existing dependency rows when webhook relation data is incomplete.
215
+ }
216
+ }
217
+ }
218
+ if (source.relationsKnown) {
219
+ this.db.replaceIssueDependencies({
220
+ projectId,
221
+ linearIssueId: source.id,
222
+ blockers: source.blockedBy.map((blocker) => ({
223
+ blockerLinearIssueId: blocker.id,
224
+ ...(blocker.identifier ? { blockerIssueKey: blocker.identifier } : {}),
225
+ ...(blocker.title ? { blockerTitle: blocker.title } : {}),
226
+ ...(blocker.stateName ? { blockerCurrentLinearState: blocker.stateName } : {}),
227
+ ...(blocker.stateType ? { blockerCurrentLinearStateType: blocker.stateType } : {}),
228
+ })),
229
+ });
230
+ }
231
+ return source;
232
+ }
233
+ reconcileDependentReadiness(projectId, blockerLinearIssueId) {
234
+ const newlyReady = [];
235
+ for (const dependent of this.db.listDependents(projectId, blockerLinearIssueId)) {
236
+ const issue = this.db.getIssue(projectId, dependent.linearIssueId);
237
+ if (!issue) {
238
+ continue;
239
+ }
240
+ const unresolved = this.db.countUnresolvedBlockers(projectId, dependent.linearIssueId);
241
+ if (unresolved > 0) {
242
+ if (issue.pendingRunType === "implementation" && issue.activeRunId === undefined) {
243
+ this.db.upsertIssue({
244
+ projectId,
245
+ linearIssueId: dependent.linearIssueId,
246
+ pendingRunType: null,
247
+ });
248
+ }
249
+ continue;
250
+ }
251
+ if (issue.factoryState !== "delegated" || issue.activeRunId !== undefined || issue.pendingRunType !== undefined) {
252
+ continue;
253
+ }
254
+ this.db.upsertIssue({
255
+ projectId,
256
+ linearIssueId: dependent.linearIssueId,
257
+ pendingRunType: "implementation",
258
+ });
259
+ newlyReady.push(dependent.linearIssueId);
260
+ }
261
+ return newlyReady;
262
+ }
189
263
  // ─── Agent session handling (inlined) ─────────────────────────────
190
264
  async handleAgentSession(normalized, project, trackedIssue, desiredStage, delegated) {
191
265
  if (!normalized.agentSession?.id || !normalized.issue)
@@ -213,9 +287,12 @@ export class WebhookHandler {
213
287
  await this.publishAgentActivity(linear, normalized.agentSession.id, buildAlreadyRunningThought(activeRun.runType));
214
288
  return;
215
289
  }
290
+ const blockerSummary = trackedIssue?.blockedByCount
291
+ ? `PatchRelay is delegated and waiting on blockers to reach Done: ${trackedIssue.blockedByKeys.join(", ")}.`
292
+ : "PatchRelay is delegated, but no work is queued. Delegate the issue or move it to Start to trigger implementation.";
216
293
  await this.publishAgentActivity(linear, normalized.agentSession.id, {
217
294
  type: "elicitation",
218
- body: "PatchRelay is delegated, but no work is queued. Delegate the issue or move it to Start to trigger implementation.",
295
+ body: blockerSummary,
219
296
  });
220
297
  return;
221
298
  }
@@ -423,9 +500,10 @@ export class WebhookHandler {
423
500
  async hydrateIssueContext(projectId, normalized) {
424
501
  if (!normalized.issue)
425
502
  return normalized;
426
- if (normalized.triggerEvent !== "agentSessionCreated" && normalized.triggerEvent !== "agentPrompted")
503
+ if (normalized.triggerEvent !== "agentSessionCreated" && normalized.triggerEvent !== "agentPrompted" && normalized.entityType !== "Issue") {
427
504
  return normalized;
428
- if (hasCompleteIssueContext(normalized.issue))
505
+ }
506
+ if (normalized.entityType !== "Issue" && hasCompleteIssueContext(normalized.issue))
429
507
  return normalized;
430
508
  const linear = await this.linearProvider.forProject(projectId);
431
509
  if (!linear)
@@ -485,8 +563,12 @@ function mergeIssueMetadata(issue, liveIssue) {
485
563
  ...(issue.teamKey ? {} : liveIssue.teamKey ? { teamKey: liveIssue.teamKey } : {}),
486
564
  ...(issue.stateId ? {} : liveIssue.stateId ? { stateId: liveIssue.stateId } : {}),
487
565
  ...(issue.stateName ? {} : liveIssue.stateName ? { stateName: liveIssue.stateName } : {}),
566
+ ...(issue.stateType ? {} : liveIssue.stateType ? { stateType: liveIssue.stateType } : {}),
488
567
  ...(issue.delegateId ? {} : liveIssue.delegateId ? { delegateId: liveIssue.delegateId } : {}),
489
568
  ...(issue.delegateName ? {} : liveIssue.delegateName ? { delegateName: liveIssue.delegateName } : {}),
569
+ relationsKnown: issue.relationsKnown || liveIssue.blockedBy !== undefined || liveIssue.blocks !== undefined,
490
570
  labelNames: issue.labelNames.length > 0 ? issue.labelNames : (liveIssue.labels ?? []).map((l) => l.name),
571
+ blockedBy: issue.relationsKnown ? issue.blockedBy : (liveIssue.blockedBy ?? issue.blockedBy),
572
+ blocks: issue.relationsKnown ? issue.blocks : (liveIssue.blocks ?? issue.blocks),
491
573
  };
492
574
  }
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") {
@@ -227,7 +227,10 @@ function extractIssueMetadata(payload) {
227
227
  ...(delegateName ? { delegateName } : {}),
228
228
  ...(priority != null ? { priority } : {}),
229
229
  ...(estimate != null ? { estimate } : {}),
230
+ relationsKnown: false,
230
231
  labelNames: extractLabelNames(issueRecord),
232
+ blockedBy: [],
233
+ blocks: [],
231
234
  };
232
235
  }
233
236
  function extractActorFromRecord(record) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "patchrelay",
3
- "version": "0.29.3",
3
+ "version": "0.30.1",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "repository": {