patchrelay 0.15.0 → 0.17.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,4 +1,6 @@
1
+ import { buildMergePrepActivity, buildMergePrepEscalationActivity } from "./linear-session-reporting.js";
1
2
  import { execCommand } from "./utils.js";
3
+ const DEFAULT_MERGE_PREP_BUDGET = 3;
2
4
  /**
3
5
  * Merge queue steward — keeps PatchRelay-managed PR branches up to date
4
6
  * with the base branch and enables auto-merge so GitHub merges when CI passes.
@@ -14,12 +16,14 @@ export class MergeQueue {
14
16
  enqueueIssue;
15
17
  logger;
16
18
  feed;
17
- constructor(config, db, enqueueIssue, logger, feed) {
19
+ onLinearActivity;
20
+ constructor(config, db, enqueueIssue, logger, feed, onLinearActivity) {
18
21
  this.config = config;
19
22
  this.db = db;
20
23
  this.enqueueIssue = enqueueIssue;
21
24
  this.logger = logger;
22
25
  this.feed = feed;
26
+ this.onLinearActivity = onLinearActivity;
23
27
  }
24
28
  /**
25
29
  * Prepare the front-of-queue issue for merge:
@@ -43,11 +47,37 @@ export class MergeQueue {
43
47
  this.db.upsertIssue({ projectId: issue.projectId, linearIssueId: issue.linearIssueId, pendingMergePrep: false });
44
48
  return;
45
49
  }
50
+ // Retry budget — escalate after repeated infrastructure failures
51
+ const attempts = issue.mergePrepAttempts + 1;
52
+ this.db.upsertIssue({ projectId: issue.projectId, linearIssueId: issue.linearIssueId, mergePrepAttempts: attempts });
53
+ if (attempts > DEFAULT_MERGE_PREP_BUDGET) {
54
+ this.logger.warn({ issueKey: issue.issueKey, attempts }, "Merge prep budget exhausted, escalating");
55
+ this.db.upsertIssue({
56
+ projectId: issue.projectId,
57
+ linearIssueId: issue.linearIssueId,
58
+ factoryState: "escalated",
59
+ pendingMergePrep: false,
60
+ });
61
+ this.feed?.publish({
62
+ level: "error",
63
+ kind: "workflow",
64
+ issueKey: issue.issueKey,
65
+ projectId: issue.projectId,
66
+ stage: "awaiting_queue",
67
+ status: "escalated",
68
+ summary: `Merge prep failed ${attempts - 1} times — escalating for human help`,
69
+ });
70
+ this.onLinearActivity?.(issue, buildMergePrepEscalationActivity(attempts - 1));
71
+ return;
72
+ }
46
73
  const repoFullName = project.github?.repoFullName;
47
74
  const baseBranch = project.github?.baseBranch ?? "main";
48
75
  const gitBin = this.config.runner.gitBin;
49
76
  // Enable auto-merge (idempotent)
50
77
  const autoMergeOk = repoFullName ? await this.enableAutoMerge(issue, repoFullName) : false;
78
+ if (autoMergeOk) {
79
+ this.onLinearActivity?.(issue, buildMergePrepActivity("auto_merge"), { ephemeral: true });
80
+ }
51
81
  // Fetch latest base branch
52
82
  const fetchResult = await execCommand(gitBin, ["-C", issue.worktreePath, "fetch", "origin", baseBranch], {
53
83
  timeoutMs: 60_000,
@@ -55,6 +85,7 @@ export class MergeQueue {
55
85
  if (fetchResult.exitCode !== 0) {
56
86
  // Transient failure — leave pendingMergePrep set so the next event retries.
57
87
  this.logger.warn({ issueKey: issue.issueKey, stderr: fetchResult.stderr?.slice(0, 300) }, "Merge prep: fetch failed, will retry on next event");
88
+ this.onLinearActivity?.(issue, buildMergePrepActivity("fetch_retry"), { ephemeral: true });
58
89
  return;
59
90
  }
60
91
  // Merge base branch into the PR branch
@@ -72,6 +103,7 @@ export class MergeQueue {
72
103
  pendingRunType: "queue_repair",
73
104
  pendingRunContextJson: JSON.stringify({ failureReason: "merge_conflict" }),
74
105
  pendingMergePrep: false,
106
+ mergePrepAttempts: 0,
75
107
  });
76
108
  this.enqueueIssue(issue.projectId, issue.linearIssueId);
77
109
  this.feed?.publish({
@@ -83,12 +115,13 @@ export class MergeQueue {
83
115
  status: "conflict",
84
116
  summary: `Merge conflict with ${baseBranch} — queue repair enqueued`,
85
117
  });
118
+ this.onLinearActivity?.(issue, buildMergePrepActivity("conflict"));
86
119
  return;
87
120
  }
88
121
  // Check if merge was a no-op (already up to date)
89
122
  if (mergeResult.stdout?.includes("Already up to date")) {
90
123
  this.logger.debug({ issueKey: issue.issueKey }, "Merge prep: branch already up to date");
91
- this.db.upsertIssue({ projectId: issue.projectId, linearIssueId: issue.linearIssueId, pendingMergePrep: false });
124
+ this.db.upsertIssue({ projectId: issue.projectId, linearIssueId: issue.linearIssueId, pendingMergePrep: false, mergePrepAttempts: 0 });
92
125
  if (!autoMergeOk) {
93
126
  this.feed?.publish({
94
127
  level: "warn",
@@ -99,6 +132,7 @@ export class MergeQueue {
99
132
  status: "blocked",
100
133
  summary: "Branch up to date but auto-merge not enabled — check gh auth and repo settings",
101
134
  });
135
+ this.onLinearActivity?.(issue, buildMergePrepActivity("blocked"));
102
136
  }
103
137
  return;
104
138
  }
@@ -109,10 +143,11 @@ export class MergeQueue {
109
143
  if (pushResult.exitCode !== 0) {
110
144
  // Push failed — leave pendingMergePrep set so the next event retries.
111
145
  this.logger.warn({ issueKey: issue.issueKey, stderr: pushResult.stderr?.slice(0, 300) }, "Merge prep: push failed, will retry on next event");
146
+ this.onLinearActivity?.(issue, buildMergePrepActivity("push_retry"), { ephemeral: true });
112
147
  return;
113
148
  }
114
149
  this.logger.info({ issueKey: issue.issueKey, baseBranch }, "Merge prep: branch updated and pushed");
115
- this.db.upsertIssue({ projectId: issue.projectId, linearIssueId: issue.linearIssueId, pendingMergePrep: false });
150
+ this.db.upsertIssue({ projectId: issue.projectId, linearIssueId: issue.linearIssueId, pendingMergePrep: false, mergePrepAttempts: 0 });
116
151
  this.feed?.publish({
117
152
  level: "info",
118
153
  kind: "workflow",
@@ -122,6 +157,7 @@ export class MergeQueue {
122
157
  status: "prepared",
123
158
  summary: `Branch updated to latest ${baseBranch} — CI will run`,
124
159
  });
160
+ this.onLinearActivity?.(issue, buildMergePrepActivity("branch_update", baseBranch), { ephemeral: true });
125
161
  }
126
162
  /**
127
163
  * Seed the merge queue on startup: for each project, ensure the front-of-queue
@@ -72,6 +72,7 @@ function buildRunPrompt(issue, runType, repoPath, context) {
72
72
  }
73
73
  return lines.join("\n");
74
74
  }
75
+ const PROGRESS_THROTTLE_MS = 10_000;
75
76
  export class RunOrchestrator {
76
77
  config;
77
78
  db;
@@ -81,6 +82,7 @@ export class RunOrchestrator {
81
82
  logger;
82
83
  feed;
83
84
  worktreeManager;
85
+ progressThrottle = new Map();
84
86
  botIdentity;
85
87
  constructor(config, db, codex, linearProvider, enqueueIssue, logger, feed) {
86
88
  this.config = config;
@@ -253,6 +255,8 @@ export class RunOrchestrator {
253
255
  eventJson: JSON.stringify(notification.params),
254
256
  });
255
257
  }
258
+ // Emit ephemeral progress activity to Linear for notable in-flight events
259
+ this.maybeEmitProgressActivity(notification, run);
256
260
  if (notification.method !== "turn/completed")
257
261
  return;
258
262
  const thread = await this.readThreadWithRetry(threadId);
@@ -286,6 +290,7 @@ export class RunOrchestrator {
286
290
  const failedIssue = this.db.getIssue(run.projectId, run.linearIssueId) ?? issue;
287
291
  void this.emitLinearActivity(failedIssue, buildRunFailureActivity(run.runType));
288
292
  void this.syncLinearSession(failedIssue, { activeRunType: run.runType });
293
+ this.progressThrottle.delete(run.id);
289
294
  return;
290
295
  }
291
296
  // Complete the run
@@ -304,7 +309,7 @@ export class RunOrchestrator {
304
309
  postRunState = "done";
305
310
  }
306
311
  else {
307
- postRunState = "pr_open";
312
+ postRunState = "awaiting_review";
308
313
  }
309
314
  }
310
315
  this.db.transaction(() => {
@@ -347,6 +352,45 @@ export class RunOrchestrator {
347
352
  ...(updatedIssue.prNumber !== undefined ? { prNumber: updatedIssue.prNumber } : {}),
348
353
  }));
349
354
  void this.syncLinearSession(updatedIssue);
355
+ this.progressThrottle.delete(run.id);
356
+ }
357
+ // ─── In-flight progress ──────────────────────────────────────────
358
+ maybeEmitProgressActivity(notification, run) {
359
+ const activity = this.resolveProgressActivity(notification);
360
+ if (!activity)
361
+ return;
362
+ const now = Date.now();
363
+ const lastEmit = this.progressThrottle.get(run.id) ?? 0;
364
+ if (now - lastEmit < PROGRESS_THROTTLE_MS)
365
+ return;
366
+ this.progressThrottle.set(run.id, now);
367
+ const issue = this.db.getIssue(run.projectId, run.linearIssueId);
368
+ if (issue) {
369
+ void this.emitLinearActivity(issue, activity, { ephemeral: true });
370
+ }
371
+ }
372
+ resolveProgressActivity(notification) {
373
+ if (notification.method === "item/started") {
374
+ const item = notification.params.item;
375
+ if (!item)
376
+ return undefined;
377
+ const type = typeof item.type === "string" ? item.type : undefined;
378
+ if (type === "commandExecution") {
379
+ const cmd = item.command;
380
+ const cmdStr = Array.isArray(cmd) ? cmd.join(" ") : typeof cmd === "string" ? cmd : undefined;
381
+ return { type: "action", action: "Running", parameter: cmdStr?.slice(0, 120) ?? "command" };
382
+ }
383
+ if (type === "mcpToolCall") {
384
+ const server = typeof item.server === "string" ? item.server : "";
385
+ const tool = typeof item.tool === "string" ? item.tool : "";
386
+ return { type: "action", action: "Using", parameter: `${server}/${tool}` };
387
+ }
388
+ if (type === "dynamicToolCall") {
389
+ const tool = typeof item.tool === "string" ? item.tool : "tool";
390
+ return { type: "action", action: "Using", parameter: tool };
391
+ }
392
+ }
393
+ return undefined;
350
394
  }
351
395
  // ─── Active status for query ──────────────────────────────────────
352
396
  async getActiveRunStatus(issueKey) {
package/dist/service.js CHANGED
@@ -35,7 +35,27 @@ export class PatchRelayService {
35
35
  throw new Error("Service runtime enqueueIssue is not initialized");
36
36
  };
37
37
  this.orchestrator = new RunOrchestrator(config, db, codex, this.linearProvider, (projectId, issueId) => enqueueIssue(projectId, issueId), logger, this.feed);
38
- this.mergeQueue = new MergeQueue(config, db, (projectId, issueId) => enqueueIssue(projectId, issueId), logger, this.feed);
38
+ this.mergeQueue = new MergeQueue(config, db, (projectId, issueId) => enqueueIssue(projectId, issueId), logger, this.feed, (issue, content, options) => {
39
+ if (!issue.agentSessionId)
40
+ return;
41
+ void (async () => {
42
+ try {
43
+ const linear = await this.linearProvider.forProject(issue.projectId);
44
+ if (!linear)
45
+ return;
46
+ const allowEphemeral = content.type === "thought" || content.type === "action";
47
+ await linear.createAgentActivity({
48
+ agentSessionId: issue.agentSessionId,
49
+ content,
50
+ ...(options?.ephemeral && allowEphemeral ? { ephemeral: true } : {}),
51
+ });
52
+ }
53
+ catch (error) {
54
+ const msg = error instanceof Error ? error.message : String(error);
55
+ logger.warn({ issueKey: issue.issueKey, type: content.type, error: msg }, "Failed to emit merge-prep Linear activity");
56
+ }
57
+ })();
58
+ });
39
59
  this.webhookHandler = new WebhookHandler(config, db, this.linearProvider, codex, (projectId, issueId) => enqueueIssue(projectId, issueId), logger, this.feed);
40
60
  this.githubWebhookHandler = new GitHubWebhookHandler(config, db, this.linearProvider, (projectId, issueId) => enqueueIssue(projectId, issueId), this.mergeQueue, logger, this.feed);
41
61
  const runtime = new ServiceRuntime(codex, logger, this.orchestrator, { listIssuesReadyForExecution: () => db.listIssuesReadyForExecution() }, this.webhookHandler, {
@@ -198,6 +218,22 @@ export class PatchRelayService {
198
218
  this.codex.on("notification", handler);
199
219
  return () => { this.codex.off("notification", handler); };
200
220
  }
221
+ retryIssue(issueKey) {
222
+ const issue = this.db.getIssueByKey(issueKey);
223
+ if (!issue)
224
+ return undefined;
225
+ if (issue.activeRunId)
226
+ return { error: "Issue already has an active run" };
227
+ const runType = "implementation";
228
+ this.db.upsertIssue({
229
+ projectId: issue.projectId,
230
+ linearIssueId: issue.linearIssueId,
231
+ pendingRunType: runType,
232
+ factoryState: "delegated",
233
+ });
234
+ this.runtime.enqueueIssue(issue.projectId, issue.linearIssueId);
235
+ return { issueKey, runType };
236
+ }
201
237
  listOperatorFeed(options) {
202
238
  return this.feed.list(options);
203
239
  }
@@ -253,6 +289,9 @@ export class PatchRelayService {
253
289
  async getIssueReport(issueKey) {
254
290
  return await this.queryService.getIssueReport(issueKey);
255
291
  }
292
+ async getIssueTimeline(issueKey) {
293
+ return await this.queryService.getIssueTimeline(issueKey);
294
+ }
256
295
  async getRunEvents(issueKey, runId) {
257
296
  return await this.queryService.getRunEvents(issueKey, runId);
258
297
  }
@@ -1,6 +1,6 @@
1
1
  import { buildAgentSessionPlanForIssue, } from "./agent-session-plan.js";
2
2
  import { buildAgentSessionExternalUrls } from "./agent-session-presentation.js";
3
- import { buildAlreadyRunningThought, buildDelegationThought, buildPromptDeliveredThought, } from "./linear-session-reporting.js";
3
+ import { buildAlreadyRunningThought, buildDelegationThought, buildPromptDeliveredThought, buildStopConfirmationActivity, } from "./linear-session-reporting.js";
4
4
  import { resolveProject, triggerEventAllowed, trustedActorAllowed } from "./project-resolution.js";
5
5
  import { normalizeWebhook } from "./webhooks.js";
6
6
  import { InstallationWebhookHandler } from "./webhook-installation-handler.js";
@@ -210,6 +210,11 @@ export class WebhookHandler {
210
210
  });
211
211
  return;
212
212
  }
213
+ // Stop signal — halt active work and confirm disengagement
214
+ if (normalized.triggerEvent === "agentSignal" && normalized.agentSession.signal === "stop") {
215
+ await this.handleStopSignal(normalized, project, trackedIssue, existingIssue, activeRun, linear);
216
+ return;
217
+ }
213
218
  if (normalized.triggerEvent !== "agentPrompted")
214
219
  return;
215
220
  if (!triggerEventAllowed(project, normalized.triggerEvent))
@@ -251,6 +256,43 @@ export class WebhookHandler {
251
256
  await this.publishAgentActivity(linear, normalized.agentSession.id, buildDelegationThought(desiredStage, "prompt"), { ephemeral: true });
252
257
  }
253
258
  }
259
+ // ─── Stop signal handling ────────────────────────────────────────
260
+ async handleStopSignal(normalized, project, trackedIssue, existingIssue, activeRun, linear) {
261
+ const issueId = normalized.issue.id;
262
+ const sessionId = normalized.agentSession.id;
263
+ // Best-effort halt: steer the active Codex turn with a stop instruction
264
+ if (activeRun?.threadId && activeRun.turnId) {
265
+ try {
266
+ await this.codex.steerTurn({
267
+ threadId: activeRun.threadId,
268
+ turnId: activeRun.turnId,
269
+ input: "STOP: The user has requested you stop working immediately. Do not make further changes. Wrap up and exit.",
270
+ });
271
+ }
272
+ catch (error) {
273
+ this.logger.warn({ issueKey: trackedIssue?.issueKey, error: error instanceof Error ? error.message : String(error) }, "Failed to steer Codex turn for stop signal");
274
+ }
275
+ this.db.finishRun(activeRun.id, { status: "released", threadId: activeRun.threadId, turnId: activeRun.turnId });
276
+ }
277
+ this.db.upsertIssue({
278
+ projectId: project.id,
279
+ linearIssueId: issueId,
280
+ activeRunId: null,
281
+ factoryState: "awaiting_input",
282
+ agentSessionId: sessionId,
283
+ });
284
+ this.feed?.publish({
285
+ level: "info",
286
+ kind: "agent",
287
+ projectId: project.id,
288
+ issueKey: trackedIssue?.issueKey,
289
+ status: "stopped",
290
+ summary: "Stop signal received — work halted",
291
+ });
292
+ const updatedIssue = this.db.getIssue(project.id, issueId);
293
+ await this.publishAgentActivity(linear, sessionId, buildStopConfirmationActivity());
294
+ await this.syncAgentSession(linear, sessionId, updatedIssue ?? trackedIssue);
295
+ }
254
296
  // ─── Comment handling (inlined) ───────────────────────────────────
255
297
  async handleComment(normalized, project, trackedIssue) {
256
298
  if ((normalized.triggerEvent !== "commentCreated" && normalized.triggerEvent !== "commentUpdated") ||
package/dist/webhooks.js CHANGED
@@ -46,6 +46,18 @@ function deriveTriggerEvent(payload) {
46
46
  ["resource", "agentSession"],
47
47
  ])) || Boolean(getString(data, "agentSessionId"));
48
48
  if (payload.type === "AgentSessionEvent" || payload.type === "AgentSession" || hasAgentSession) {
49
+ // Detect signal-bearing payloads (e.g. stop signal from Linear)
50
+ const agentActivityForSignal = getFirstNestedRecord(data, [
51
+ ["agentActivity"],
52
+ ["agentSession", "agentActivity"],
53
+ ["session", "agentActivity"],
54
+ ["agentSessionEvent", "agentActivity"],
55
+ ["payload", "agentActivity"],
56
+ ["resource", "agentActivity"],
57
+ ]);
58
+ if (agentActivityForSignal && getString(agentActivityForSignal, "signal")) {
59
+ return "agentSignal";
60
+ }
49
61
  if (payload.action === "created" || payload.action === "create") {
50
62
  return "agentSessionCreated";
51
63
  }
@@ -310,11 +322,15 @@ function extractAgentSessionMetadata(payload) {
310
322
  getString(commentRecord ?? {}, "body") ??
311
323
  getString(data, "body");
312
324
  const issueCommentId = getString(commentRecord ?? {}, "id") ?? getString(data, "issueCommentId");
325
+ const signal = getString(agentActivity ?? {}, "signal");
326
+ const signalMetadata = asRecord((agentActivity ?? {}).signalMetadata);
313
327
  return {
314
328
  id,
315
329
  ...(promptContext ? { promptContext } : {}),
316
330
  ...(promptBody ? { promptBody } : {}),
317
331
  ...(issueCommentId ? { issueCommentId } : {}),
332
+ ...(signal ? { signal } : {}),
333
+ ...(signalMetadata ? { signalMetadata } : {}),
318
334
  };
319
335
  }
320
336
  function extractInstallationMetadata(payload) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "patchrelay",
3
- "version": "0.15.0",
3
+ "version": "0.17.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "repository": {
@@ -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
- }