patchrelay 0.16.0 → 0.17.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.
@@ -9,8 +9,9 @@ import { buildAgentSessionExternalUrls } from "./agent-session-presentation.js";
9
9
  import { WorktreeManager } from "./worktree-manager.js";
10
10
  import { resolveAuthoritativeLinearStopState } from "./linear-workflow.js";
11
11
  import { execCommand } from "./utils.js";
12
- const DEFAULT_CI_REPAIR_BUDGET = 2;
13
- const DEFAULT_QUEUE_REPAIR_BUDGET = 2;
12
+ const DEFAULT_CI_REPAIR_BUDGET = 3;
13
+ const DEFAULT_QUEUE_REPAIR_BUDGET = 3;
14
+ const DEFAULT_REVIEW_FIX_BUDGET = 3;
14
15
  function slugify(value) {
15
16
  return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 60);
16
17
  }
@@ -72,6 +73,7 @@ function buildRunPrompt(issue, runType, repoPath, context) {
72
73
  }
73
74
  return lines.join("\n");
74
75
  }
76
+ const PROGRESS_THROTTLE_MS = 10_000;
75
77
  export class RunOrchestrator {
76
78
  config;
77
79
  db;
@@ -81,6 +83,7 @@ export class RunOrchestrator {
81
83
  logger;
82
84
  feed;
83
85
  worktreeManager;
86
+ progressThrottle = new Map();
84
87
  botIdentity;
85
88
  constructor(config, db, codex, linearProvider, enqueueIssue, logger, feed) {
86
89
  this.config = config;
@@ -112,6 +115,10 @@ export class RunOrchestrator {
112
115
  this.escalate(issue, runType, `Queue repair budget exhausted (${DEFAULT_QUEUE_REPAIR_BUDGET} attempts)`);
113
116
  return;
114
117
  }
118
+ if (runType === "review_fix" && issue.reviewFixAttempts >= DEFAULT_REVIEW_FIX_BUDGET) {
119
+ this.escalate(issue, runType, `Review fix budget exhausted (${DEFAULT_REVIEW_FIX_BUDGET} attempts)`);
120
+ return;
121
+ }
115
122
  // Increment repair counters
116
123
  if (runType === "ci_repair") {
117
124
  this.db.upsertIssue({ projectId: issue.projectId, linearIssueId: issue.linearIssueId, ciRepairAttempts: issue.ciRepairAttempts + 1 });
@@ -119,6 +126,9 @@ export class RunOrchestrator {
119
126
  if (runType === "queue_repair") {
120
127
  this.db.upsertIssue({ projectId: issue.projectId, linearIssueId: issue.linearIssueId, queueRepairAttempts: issue.queueRepairAttempts + 1 });
121
128
  }
129
+ if (runType === "review_fix") {
130
+ this.db.upsertIssue({ projectId: issue.projectId, linearIssueId: issue.linearIssueId, reviewFixAttempts: issue.reviewFixAttempts + 1 });
131
+ }
122
132
  // Build prompt
123
133
  const prompt = buildRunPrompt(issue, runType, project.repoPath, context);
124
134
  // Resolve workspace
@@ -253,6 +263,8 @@ export class RunOrchestrator {
253
263
  eventJson: JSON.stringify(notification.params),
254
264
  });
255
265
  }
266
+ // Emit ephemeral progress activity to Linear for notable in-flight events
267
+ this.maybeEmitProgressActivity(notification, run);
256
268
  if (notification.method !== "turn/completed")
257
269
  return;
258
270
  const thread = await this.readThreadWithRetry(threadId);
@@ -286,27 +298,15 @@ export class RunOrchestrator {
286
298
  const failedIssue = this.db.getIssue(run.projectId, run.linearIssueId) ?? issue;
287
299
  void this.emitLinearActivity(failedIssue, buildRunFailureActivity(run.runType));
288
300
  void this.syncLinearSession(failedIssue, { activeRunType: run.runType });
301
+ this.progressThrottle.delete(run.id);
289
302
  return;
290
303
  }
291
304
  // Complete the run
292
305
  const trackedIssue = this.db.issueToTrackedIssue(issue);
293
306
  const report = buildStageReport(run, trackedIssue, thread, countEventMethods(this.db.listThreadEvents(run.id)));
294
- // Determine post-run state. When a re-run finds the PR already exists
295
- // and makes no changes, no pr_opened webhook arrives — the state would
296
- // stay in the active-run state forever. Advance based on PR metadata.
307
+ // Determine post-run state based on current PR metadata.
297
308
  const freshIssue = this.db.getIssue(run.projectId, run.linearIssueId) ?? issue;
298
- let postRunState;
299
- if (ACTIVE_RUN_STATES.has(freshIssue.factoryState) && freshIssue.prNumber) {
300
- if (freshIssue.prReviewState === "approved") {
301
- postRunState = "awaiting_queue";
302
- }
303
- else if (freshIssue.prState === "merged") {
304
- postRunState = "done";
305
- }
306
- else {
307
- postRunState = "awaiting_review";
308
- }
309
- }
309
+ const postRunState = resolvePostRunState(freshIssue);
310
310
  this.db.transaction(() => {
311
311
  this.db.finishRun(run.id, {
312
312
  status: "completed",
@@ -347,6 +347,45 @@ export class RunOrchestrator {
347
347
  ...(updatedIssue.prNumber !== undefined ? { prNumber: updatedIssue.prNumber } : {}),
348
348
  }));
349
349
  void this.syncLinearSession(updatedIssue);
350
+ this.progressThrottle.delete(run.id);
351
+ }
352
+ // ─── In-flight progress ──────────────────────────────────────────
353
+ maybeEmitProgressActivity(notification, run) {
354
+ const activity = this.resolveProgressActivity(notification);
355
+ if (!activity)
356
+ return;
357
+ const now = Date.now();
358
+ const lastEmit = this.progressThrottle.get(run.id) ?? 0;
359
+ if (now - lastEmit < PROGRESS_THROTTLE_MS)
360
+ return;
361
+ this.progressThrottle.set(run.id, now);
362
+ const issue = this.db.getIssue(run.projectId, run.linearIssueId);
363
+ if (issue) {
364
+ void this.emitLinearActivity(issue, activity, { ephemeral: true });
365
+ }
366
+ }
367
+ resolveProgressActivity(notification) {
368
+ if (notification.method === "item/started") {
369
+ const item = notification.params.item;
370
+ if (!item)
371
+ return undefined;
372
+ const type = typeof item.type === "string" ? item.type : undefined;
373
+ if (type === "commandExecution") {
374
+ const cmd = item.command;
375
+ const cmdStr = Array.isArray(cmd) ? cmd.join(" ") : typeof cmd === "string" ? cmd : undefined;
376
+ return { type: "action", action: "Running", parameter: cmdStr?.slice(0, 120) ?? "command" };
377
+ }
378
+ if (type === "mcpToolCall") {
379
+ const server = typeof item.server === "string" ? item.server : "";
380
+ const tool = typeof item.tool === "string" ? item.tool : "";
381
+ return { type: "action", action: "Using", parameter: `${server}/${tool}` };
382
+ }
383
+ if (type === "dynamicToolCall") {
384
+ const tool = typeof item.tool === "string" ? item.tool : "tool";
385
+ return { type: "action", action: "Using", parameter: tool };
386
+ }
387
+ }
388
+ return undefined;
350
389
  }
351
390
  // ─── Active status for query ──────────────────────────────────────
352
391
  async getActiveRunStatus(issueKey) {
@@ -500,6 +539,8 @@ export class RunOrchestrator {
500
539
  if (latestTurn?.status === "completed") {
501
540
  const trackedIssue = this.db.issueToTrackedIssue(issue);
502
541
  const report = buildStageReport(run, trackedIssue, thread, countEventMethods(this.db.listThreadEvents(run.id)));
542
+ const freshIssue = this.db.getIssue(run.projectId, run.linearIssueId) ?? issue;
543
+ const postRunState = resolvePostRunState(freshIssue);
503
544
  this.db.transaction(() => {
504
545
  this.db.finishRun(run.id, {
505
546
  status: "completed",
@@ -512,8 +553,13 @@ export class RunOrchestrator {
512
553
  projectId: run.projectId,
513
554
  linearIssueId: run.linearIssueId,
514
555
  activeRunId: null,
556
+ ...(postRunState ? { factoryState: postRunState } : {}),
557
+ ...(postRunState === "awaiting_queue" ? { pendingMergePrep: true } : {}),
515
558
  });
516
559
  });
560
+ if (postRunState === "awaiting_queue") {
561
+ this.enqueueIssue(run.projectId, run.linearIssueId);
562
+ }
517
563
  }
518
564
  }
519
565
  // ─── Internal helpers ─────────────────────────────────────────────
@@ -616,3 +662,17 @@ export class RunOrchestrator {
616
662
  throw new Error(`Failed to read thread ${threadId}`);
617
663
  }
618
664
  }
665
+ /**
666
+ * Determine post-run factory state from current PR metadata.
667
+ * Used by both the normal completion path and reconciliation.
668
+ */
669
+ function resolvePostRunState(issue) {
670
+ if (ACTIVE_RUN_STATES.has(issue.factoryState) && issue.prNumber) {
671
+ if (issue.prReviewState === "approved")
672
+ return "awaiting_queue";
673
+ if (issue.prState === "merged")
674
+ return "done";
675
+ return "pr_open";
676
+ }
677
+ return undefined;
678
+ }
package/dist/service.js CHANGED
@@ -289,6 +289,9 @@ export class PatchRelayService {
289
289
  async getIssueReport(issueKey) {
290
290
  return await this.queryService.getIssueReport(issueKey);
291
291
  }
292
+ async getIssueTimeline(issueKey) {
293
+ return await this.queryService.getIssueTimeline(issueKey);
294
+ }
292
295
  async getRunEvents(issueKey, runId) {
293
296
  return await this.queryService.getRunEvents(issueKey, runId);
294
297
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "patchrelay",
3
- "version": "0.16.0",
3
+ "version": "0.17.1",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "repository": {
@@ -1,23 +0,0 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { Box, Text } from "ink";
3
- const KIND_COLORS = {
4
- stage: "cyan",
5
- turn: "yellow",
6
- github: "green",
7
- webhook: "blue",
8
- workflow: "magenta",
9
- hook: "white",
10
- };
11
- function kindColor(kind) {
12
- return KIND_COLORS[kind] ?? "white";
13
- }
14
- function formatTime(iso) {
15
- return new Date(iso).toLocaleTimeString("en-GB", { hour12: false });
16
- }
17
- export function FeedTimeline({ entries, maxEntries }) {
18
- const visible = maxEntries ? entries.slice(-maxEntries) : entries;
19
- if (visible.length === 0) {
20
- return _jsx(Text, { dimColor: true, children: "No events yet." });
21
- }
22
- return (_jsx(Box, { flexDirection: "column", children: visible.map((entry, i) => (_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: formatTime(entry.at) }), _jsx(Text, { color: kindColor(entry.kind), children: (entry.status ?? entry.kind).padEnd(15) }), _jsx(Text, { children: entry.summary })] }, `feed-${i}`))) }));
23
- }
@@ -1,26 +0,0 @@
1
- import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
- import { Box, Text } from "ink";
3
- import { TurnSection } from "./TurnSection.js";
4
- function planStepSymbol(status) {
5
- if (status === "completed")
6
- return "\u2713";
7
- if (status === "inProgress")
8
- return "\u25b8";
9
- return " ";
10
- }
11
- function planStepColor(status) {
12
- if (status === "completed")
13
- return "green";
14
- if (status === "inProgress")
15
- return "yellow";
16
- return "white";
17
- }
18
- export function ThreadView({ thread, follow }) {
19
- const visibleTurns = follow && thread.turns.length > 1
20
- ? thread.turns.slice(-1)
21
- : thread.turns;
22
- const turnOffset = follow && thread.turns.length > 1
23
- ? thread.turns.length - 1
24
- : 0;
25
- return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { gap: 2, children: [_jsxs(Text, { dimColor: true, children: ["Thread: ", thread.threadId.slice(0, 16)] }), _jsxs(Text, { dimColor: true, children: ["Status: ", thread.status] }), _jsxs(Text, { dimColor: true, children: ["Turns: ", thread.turns.length] })] }), thread.plan && thread.plan.length > 0 && (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { bold: true, children: "Plan:" }), thread.plan.map((entry, i) => (_jsxs(Box, { gap: 1, children: [_jsxs(Text, { color: planStepColor(entry.status), children: ["[", planStepSymbol(entry.status), "]"] }), _jsx(Text, { children: entry.step })] }, `plan-${i}`)))] })), _jsx(Box, { flexDirection: "column", marginTop: 1, children: visibleTurns.map((turn, i) => (_jsx(TurnSection, { turn: turn, index: i + turnOffset, follow: follow }, turn.id))) })] }));
26
- }
@@ -1,20 +0,0 @@
1
- import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
- import { Box, Text } from "ink";
3
- import { ItemLine } from "./ItemLine.js";
4
- function turnStatusColor(status) {
5
- if (status === "completed")
6
- return "green";
7
- if (status === "failed" || status === "interrupted")
8
- return "red";
9
- if (status === "inProgress")
10
- return "yellow";
11
- return "white";
12
- }
13
- const FOLLOW_TAIL_SIZE = 8;
14
- export function TurnSection({ turn, index, follow }) {
15
- const items = follow && turn.items.length > FOLLOW_TAIL_SIZE
16
- ? turn.items.slice(-FOLLOW_TAIL_SIZE)
17
- : turn.items;
18
- const skipped = turn.items.length - items.length;
19
- return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { gap: 1, children: [_jsxs(Text, { bold: true, children: ["Turn #", index + 1] }), _jsx(Text, { color: turnStatusColor(turn.status), children: turn.status }), _jsxs(Text, { dimColor: true, children: ["(", turn.items.length, " items)"] })] }), skipped > 0 && _jsxs(Text, { dimColor: true, children: [" ... ", skipped, " earlier items"] }), items.map((item, i) => (_jsx(ItemLine, { item: item, isLast: i === items.length - 1 }, item.id)))] }));
20
- }