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.
- package/dist/agent-session-plan.js +1 -2
- package/dist/build-info.json +3 -3
- package/dist/cli/watch/App.js +1 -1
- package/dist/cli/watch/IssueDetailView.js +28 -13
- package/dist/cli/watch/IssueRow.js +0 -1
- package/dist/cli/watch/Timeline.js +14 -0
- package/dist/cli/watch/TimelineRow.js +62 -0
- package/dist/cli/watch/timeline-builder.js +363 -0
- package/dist/cli/watch/use-detail-stream.js +29 -107
- package/dist/cli/watch/watch-state.js +62 -193
- package/dist/db/migrations.js +4 -0
- package/dist/db.js +5 -0
- package/dist/factory-state.js +3 -1
- package/dist/github-webhook-handler.js +5 -4
- package/dist/http.js +8 -0
- package/dist/issue-query-service.js +23 -0
- package/dist/linear-session-reporting.js +0 -2
- package/dist/merge-queue.js +0 -1
- package/dist/run-orchestrator.js +77 -17
- package/dist/service.js +3 -0
- package/package.json +1 -1
- package/dist/cli/watch/FeedTimeline.js +0 -23
- package/dist/cli/watch/ThreadView.js +0 -26
- package/dist/cli/watch/TurnSection.js +0 -20
package/dist/run-orchestrator.js
CHANGED
|
@@ -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 =
|
|
13
|
-
const DEFAULT_QUEUE_REPAIR_BUDGET =
|
|
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
|
|
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
|
-
|
|
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,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
|
-
}
|