patchrelay 0.35.0 → 0.35.2

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.35.0",
4
- "commit": "e1a5b7d3b9e0",
5
- "builtAt": "2026-04-02T21:55:42.542Z"
3
+ "version": "0.35.2",
4
+ "commit": "19528baf10d3",
5
+ "builtAt": "2026-04-03T02:18:23.308Z"
6
6
  }
@@ -1,31 +1,35 @@
1
- import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Box, Text } from "ink";
3
- function statusColor(status) {
4
- switch (status) {
5
- case "current":
6
- return "cyan";
7
- case "visited":
8
- return "green";
9
- case "upcoming":
10
- return "gray";
11
- }
3
+ const STATE_LABELS = {
4
+ delegated: "delegated",
5
+ implementing: "implementing",
6
+ pr_open: "PR open",
7
+ awaiting_queue: "merge queue",
8
+ done: "done",
9
+ changes_requested: "review fix",
10
+ repairing_ci: "CI repair",
11
+ repairing_queue: "queue repair",
12
+ awaiting_input: "needs input",
13
+ escalated: "escalated",
14
+ failed: "failed",
15
+ };
16
+ function displayLabel(state) {
17
+ return STATE_LABELS[state] ?? state;
12
18
  }
13
- function statusPrefix(status) {
14
- switch (status) {
15
- case "current":
16
- return "*";
17
- case "visited":
18
- return "+";
19
- case "upcoming":
20
- return " ";
21
- }
22
- }
23
- function NodePill({ node }) {
24
- return (_jsxs(Text, { color: statusColor(node.status), bold: node.status === "current", children: ["[", statusPrefix(node.status), " ", node.label, "]"] }));
25
- }
26
- function NodeRow({ label, nodes, connector = " -> ", }) {
27
- return (_jsxs(Box, { children: [_jsx(Text, { dimColor: true, children: label.padEnd(11, " ") }), nodes.map((node, index) => (_jsxs(Box, { children: [index > 0 && _jsx(Text, { dimColor: true, children: connector }), _jsx(NodePill, { node: node })] }, node.state)))] }));
19
+ function NodeRow({ nodes, connector }) {
20
+ const visible = nodes.filter((n) => connector === " \u2192 " || n.status !== "upcoming");
21
+ if (visible.length === 0)
22
+ return _jsx(_Fragment, {});
23
+ return (_jsx(Box, { gap: 0, children: visible.map((node, i) => {
24
+ const dot = node.status === "upcoming" ? "\u25cb" : "\u25cf";
25
+ const color = node.status === "current" ? "cyan"
26
+ : node.status === "visited" ? "green"
27
+ : "gray";
28
+ return (_jsxs(Box, { gap: 0, children: [i > 0 && _jsx(Text, { dimColor: true, children: connector }), _jsx(Text, { color: color, bold: node.status === "current", children: dot }), _jsx(Text, { color: color, bold: node.status === "current", children: ` ${displayLabel(node.state)}` })] }, node.state));
29
+ }) }));
28
30
  }
29
31
  export function FactoryStateGraph({ main, prLoops, queueLoop, exits, }) {
30
- return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { bold: true, children: "State Graph" }), _jsx(NodeRow, { label: "main", nodes: main }), _jsx(NodeRow, { label: "pr loops", nodes: prLoops, connector: " " }), _jsx(NodeRow, { label: "queue loop", nodes: queueLoop, connector: " " }), _jsx(NodeRow, { label: "exits", nodes: exits, connector: " " })] }));
32
+ const hasLoops = prLoops.some((n) => n.status !== "upcoming") || queueLoop.some((n) => n.status !== "upcoming");
33
+ const hasExits = exits.some((n) => n.status !== "upcoming");
34
+ return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(NodeRow, { nodes: main, connector: " \\u2192 " }), hasLoops && (_jsx(Box, { gap: 0, paddingLeft: 2, children: _jsx(NodeRow, { nodes: [...prLoops, ...queueLoop], connector: " " }) })), hasExits && (_jsx(Box, { gap: 0, paddingLeft: 2, children: _jsx(NodeRow, { nodes: exits, connector: " " }) }))] }));
31
35
  }
@@ -9,12 +9,11 @@ export function Timeline({ entries, follow }) {
9
9
  const rows = stdout?.rows ?? 24;
10
10
  const maxActive = Math.max(ACTIVE_TAIL, rows - 12);
11
11
  const displayRows = useMemo(() => buildTimelineRows(entries), [entries]);
12
- const splitIndex = useMemo(() => {
13
- if (!follow)
14
- return 0;
15
- return Math.max(0, displayRows.length - maxActive);
16
- }, [displayRows.length, follow, maxActive]);
17
- const finalized = displayRows.slice(0, splitIndex);
12
+ // Always cap the rendered entries to prevent OOM/WASM crashes.
13
+ // In follow mode: older entries go to Static (terminal scrollback).
14
+ // Without follow: show last maxActive entries only.
15
+ const splitIndex = Math.max(0, displayRows.length - maxActive);
16
+ const finalized = follow ? displayRows.slice(0, splitIndex) : [];
18
17
  const active = displayRows.slice(splitIndex);
19
18
  if (displayRows.length === 0) {
20
19
  return _jsx(Text, { dimColor: true, children: "No timeline events yet." });
@@ -126,6 +126,10 @@ export class RunOrchestrator {
126
126
  const issue = this.db.getIssue(item.projectId, item.issueId);
127
127
  if (!issue?.pendingRunType || issue.activeRunId !== undefined)
128
128
  return;
129
+ if (issue.prState === "merged") {
130
+ this.db.upsertIssue({ projectId: issue.projectId, linearIssueId: issue.linearIssueId, pendingRunType: null, factoryState: "done" });
131
+ return;
132
+ }
129
133
  const runType = issue.pendingRunType;
130
134
  const contextJson = issue.pendingRunContextJson;
131
135
  const context = contextJson ? JSON.parse(contextJson) : undefined;
@@ -632,15 +636,31 @@ export class RunOrchestrator {
632
636
  const hasQueueLabel = pr.labels?.some((l) => l.name === protocol.admissionLabel) ?? false;
633
637
  if (!hasQueueLabel)
634
638
  return;
635
- // Conflict detected dispatch preemptive queue repair.
636
- if (pr.mergeStateStatus === "DIRTY" || pr.mergeable === "CONFLICTING") {
639
+ // Detect queue issues: either GitHub reports DIRTY, or the steward
640
+ // eviction check run failed (webhook may have been missed).
641
+ const isDirty = pr.mergeStateStatus === "DIRTY" || pr.mergeable === "CONFLICTING";
642
+ let hasEvictionCheckRun = false;
643
+ if (!isDirty) {
644
+ // Check for missed eviction webhook by looking for the steward's
645
+ // check run on the PR head.
646
+ try {
647
+ const { stdout: checksOut } = await execCommand("gh", [
648
+ "api", `repos/${project.github.repoFullName}/commits/${pr.headRefOid}/check-runs`,
649
+ "--jq", `.check_runs[] | select(.name == "${protocol.evictionCheckName}" and .conclusion == "failure") | .name`,
650
+ ], { timeoutMs: 10_000 });
651
+ hasEvictionCheckRun = checksOut.trim().length > 0;
652
+ }
653
+ catch {
654
+ // Best-effort check.
655
+ }
656
+ }
657
+ if (isDirty || hasEvictionCheckRun) {
637
658
  const headRefOid = pr.headRefOid ?? "unknown";
638
- // TODO: include baseSha in signature (headRefOid + baseSha) so that a
639
- // main-only advance with the same PR head is recognized as a new conflict.
659
+ const reason = hasEvictionCheckRun ? "queue_eviction_missed" : "preemptive_conflict";
640
660
  const signature = `preemptive_queue_conflict:${headRefOid}`;
641
661
  const pendingRunContext = {
642
662
  source: "queue_health_monitor",
643
- failureReason: "preemptive_conflict",
663
+ failureReason: reason,
644
664
  failureHeadSha: headRefOid,
645
665
  failureSignature: signature,
646
666
  };
@@ -657,15 +677,17 @@ export class RunOrchestrator {
657
677
  pendingRunType: "queue_repair",
658
678
  pendingRunContext,
659
679
  });
660
- this.logger.info({ issueKey: issue.issueKey, prNumber: issue.prNumber, headRefOid }, "Queue health: merge conflict detected, dispatching preemptive repair");
680
+ this.logger.info({ issueKey: issue.issueKey, prNumber: issue.prNumber, headRefOid, reason }, "Queue health: queue issue detected, dispatching repair");
661
681
  this.feed?.publish({
662
682
  level: "warn",
663
683
  kind: "github",
664
684
  issueKey: issue.issueKey,
665
685
  projectId: issue.projectId,
666
686
  stage: "repairing_queue",
667
- status: "queue_health_conflict_detected",
668
- summary: `Queue health: merge conflict detected on PR #${issue.prNumber}, dispatching preemptive repair`,
687
+ status: hasEvictionCheckRun ? "queue_health_eviction_detected" : "queue_health_conflict_detected",
688
+ summary: hasEvictionCheckRun
689
+ ? `Queue health: missed eviction detected on PR #${issue.prNumber}, dispatching repair`
690
+ : `Queue health: merge conflict detected on PR #${issue.prNumber}, dispatching preemptive repair`,
669
691
  });
670
692
  }
671
693
  }
package/dist/service.js CHANGED
@@ -487,6 +487,10 @@ export class PatchRelayService {
487
487
  return undefined;
488
488
  if (issue.activeRunId)
489
489
  return { error: "Issue already has an active run" };
490
+ if (issue.prState === "merged") {
491
+ this.db.upsertIssue({ projectId: issue.projectId, linearIssueId: issue.linearIssueId, factoryState: "done" });
492
+ return { issueKey, runType: "none" };
493
+ }
490
494
  // Infer run type from current state instead of always resetting to implementation
491
495
  let runType = "implementation";
492
496
  let factoryState = "delegated";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "patchrelay",
3
- "version": "0.35.0",
3
+ "version": "0.35.2",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "repository": {