patchrelay 0.4.1 → 0.6.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.
package/dist/http.js CHANGED
@@ -220,6 +220,50 @@ export async function buildHttpServer(config, service, logger) {
220
220
  });
221
221
  }
222
222
  if (managementRoutesEnabled) {
223
+ app.get("/api/feed", async (request, reply) => {
224
+ const limit = getPositiveIntegerQueryParam(request, "limit") ?? 50;
225
+ const issueKey = getQueryParam(request, "issue")?.trim() || undefined;
226
+ const projectId = getQueryParam(request, "project")?.trim() || undefined;
227
+ const feedQuery = {
228
+ limit,
229
+ ...(issueKey ? { issueKey } : {}),
230
+ ...(projectId ? { projectId } : {}),
231
+ };
232
+ if (getQueryParam(request, "follow") !== "1") {
233
+ return reply.send({ ok: true, events: service.listOperatorFeed(feedQuery) });
234
+ }
235
+ reply.hijack();
236
+ reply.raw.writeHead(200, {
237
+ "content-type": "text/event-stream; charset=utf-8",
238
+ "cache-control": "no-cache, no-transform",
239
+ connection: "keep-alive",
240
+ "x-accel-buffering": "no",
241
+ });
242
+ const writeEvent = (event) => {
243
+ reply.raw.write(`event: feed\n`);
244
+ reply.raw.write(`data: ${JSON.stringify(event)}\n\n`);
245
+ };
246
+ for (const event of service.listOperatorFeed(feedQuery)) {
247
+ writeEvent(event);
248
+ }
249
+ const unsubscribe = service.subscribeOperatorFeed((event) => {
250
+ if (issueKey && event.issueKey !== issueKey) {
251
+ return;
252
+ }
253
+ if (projectId && event.projectId !== projectId) {
254
+ return;
255
+ }
256
+ writeEvent(event);
257
+ });
258
+ const keepAlive = setInterval(() => {
259
+ reply.raw.write(": keepalive\n\n");
260
+ }, 15000);
261
+ request.raw.on("close", () => {
262
+ clearInterval(keepAlive);
263
+ unsubscribe();
264
+ reply.raw.end();
265
+ });
266
+ });
223
267
  app.get("/api/installations", async (_request, reply) => {
224
268
  return reply.send({ ok: true, installations: service.listLinearInstallations() });
225
269
  });
@@ -298,6 +342,14 @@ function getQueryParam(request, key) {
298
342
  const value = request.query?.[key];
299
343
  return typeof value === "string" ? value : undefined;
300
344
  }
345
+ function getPositiveIntegerQueryParam(request, key) {
346
+ const value = getQueryParam(request, key);
347
+ if (!value || !/^\d+$/.test(value)) {
348
+ return undefined;
349
+ }
350
+ const parsed = Number(value);
351
+ return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : undefined;
352
+ }
301
353
  function renderOAuthResult(message) {
302
354
  return `<!doctype html>
303
355
  <html lang="en">
@@ -0,0 +1,85 @@
1
+ export class OperatorEventFeed {
2
+ store;
3
+ maxEvents;
4
+ persistedFallbackEvents = [];
5
+ listeners = new Set();
6
+ nextFallbackId = -1;
7
+ constructor(store, maxEvents = 500) {
8
+ this.store = store;
9
+ this.maxEvents = maxEvents;
10
+ }
11
+ publish(event) {
12
+ const fullEvent = this.persist(event);
13
+ for (const listener of this.listeners) {
14
+ listener(fullEvent);
15
+ }
16
+ return fullEvent;
17
+ }
18
+ list(options) {
19
+ const persisted = this.store?.list(options) ?? [];
20
+ const fallback = this.listFallback(options);
21
+ const combined = [...persisted, ...fallback].sort(compareFeedEvents);
22
+ const limit = options?.limit ?? 50;
23
+ return combined.slice(Math.max(0, combined.length - limit));
24
+ }
25
+ subscribe(listener) {
26
+ this.listeners.add(listener);
27
+ return () => {
28
+ this.listeners.delete(listener);
29
+ };
30
+ }
31
+ persist(event) {
32
+ const normalizedEvent = {
33
+ at: event.at ?? new Date().toISOString(),
34
+ level: event.level,
35
+ kind: event.kind,
36
+ summary: event.summary,
37
+ ...(event.detail ? { detail: event.detail } : {}),
38
+ ...(event.issueKey ? { issueKey: event.issueKey } : {}),
39
+ ...(event.projectId ? { projectId: event.projectId } : {}),
40
+ ...(event.stage ? { stage: event.stage } : {}),
41
+ ...(event.status ? { status: event.status } : {}),
42
+ };
43
+ if (!this.store) {
44
+ return this.pushFallbackEvent(normalizedEvent);
45
+ }
46
+ try {
47
+ return this.store.save(normalizedEvent);
48
+ }
49
+ catch {
50
+ return this.pushFallbackEvent(normalizedEvent);
51
+ }
52
+ }
53
+ pushFallbackEvent(event) {
54
+ const fullEvent = {
55
+ id: this.nextFallbackId,
56
+ ...event,
57
+ };
58
+ this.nextFallbackId -= 1;
59
+ this.persistedFallbackEvents.push(fullEvent);
60
+ if (this.persistedFallbackEvents.length > this.maxEvents) {
61
+ this.persistedFallbackEvents.shift();
62
+ }
63
+ return fullEvent;
64
+ }
65
+ listFallback(options) {
66
+ return this.persistedFallbackEvents.filter((event) => {
67
+ if (options?.afterId !== undefined && event.id <= options.afterId) {
68
+ return false;
69
+ }
70
+ if (options?.issueKey && event.issueKey !== options.issueKey) {
71
+ return false;
72
+ }
73
+ if (options?.projectId && event.projectId !== options.projectId) {
74
+ return false;
75
+ }
76
+ return true;
77
+ });
78
+ }
79
+ }
80
+ function compareFeedEvents(left, right) {
81
+ if (left.at !== right.at) {
82
+ return left.at.localeCompare(right.at);
83
+ }
84
+ return left.id - right.id;
85
+ }
@@ -11,20 +11,22 @@ export class ServiceStageFinalizer {
11
11
  codex;
12
12
  linearProvider;
13
13
  enqueueIssue;
14
+ feed;
14
15
  inputDispatcher;
15
16
  lifecyclePublisher;
16
17
  actionApplier;
17
18
  runAtomically;
18
- constructor(config, stores, codex, linearProvider, enqueueIssue, logger, runAtomically = (fn) => fn()) {
19
+ constructor(config, stores, codex, linearProvider, enqueueIssue, logger, feed, runAtomically = (fn) => fn()) {
19
20
  this.config = config;
20
21
  this.stores = stores;
21
22
  this.codex = codex;
22
23
  this.linearProvider = linearProvider;
23
24
  this.enqueueIssue = enqueueIssue;
25
+ this.feed = feed;
24
26
  this.runAtomically = runAtomically;
25
27
  const lifecycleLogger = logger ?? consoleLogger();
26
28
  this.inputDispatcher = new StageTurnInputDispatcher(stores, codex, lifecycleLogger);
27
- this.lifecyclePublisher = new StageLifecyclePublisher(config, stores, linearProvider, lifecycleLogger);
29
+ this.lifecyclePublisher = new StageLifecyclePublisher(config, stores, linearProvider, lifecycleLogger, feed);
28
30
  this.actionApplier = new ReconciliationActionApplier({
29
31
  enqueueIssue,
30
32
  deliverPendingObligations: (projectId, linearIssueId, threadId, turnId) => this.deliverPendingObligations(projectId, linearIssueId, threadId, turnId),
@@ -71,6 +73,19 @@ export class ServiceStageFinalizer {
71
73
  });
72
74
  }
73
75
  if (notification.method === "turn/started" || notification.method.startsWith("item/")) {
76
+ if (notification.method === "turn/started") {
77
+ const issue = this.stores.issueWorkflows.getTrackedIssue(stageRun.projectId, stageRun.linearIssueId);
78
+ this.feed?.publish({
79
+ level: "info",
80
+ kind: "turn",
81
+ issueKey: issue?.issueKey,
82
+ projectId: stageRun.projectId,
83
+ stage: stageRun.stage,
84
+ status: "started",
85
+ summary: `Turn started for ${stageRun.stage}`,
86
+ detail: turnId ? `Turn ${turnId} is now live.` : undefined,
87
+ });
88
+ }
74
89
  await this.flushQueuedTurnInputs(stageRun);
75
90
  }
76
91
  if (notification.method !== "turn/completed") {
@@ -84,11 +99,31 @@ export class ServiceStageFinalizer {
84
99
  const completedTurnId = extractTurnId(notification.params);
85
100
  const status = resolveStageRunStatus(notification.params);
86
101
  if (status === "failed") {
102
+ this.feed?.publish({
103
+ level: "error",
104
+ kind: "turn",
105
+ issueKey: issue.issueKey,
106
+ projectId: stageRun.projectId,
107
+ stage: stageRun.stage,
108
+ status: "failed",
109
+ summary: `Turn failed for ${stageRun.stage}`,
110
+ detail: completedTurnId ? `Turn ${completedTurnId} completed in a failed state.` : undefined,
111
+ });
87
112
  await this.failStageRunAndSync(stageRun, issue, threadId, "Codex reported the turn completed in a failed state", {
88
113
  ...(completedTurnId ? { turnId: completedTurnId } : {}),
89
114
  });
90
115
  return;
91
116
  }
117
+ this.feed?.publish({
118
+ level: "info",
119
+ kind: "turn",
120
+ issueKey: issue.issueKey,
121
+ projectId: stageRun.projectId,
122
+ stage: stageRun.stage,
123
+ status: "completed",
124
+ summary: `Turn completed for ${stageRun.stage}`,
125
+ detail: summarizeCurrentThread(thread).latestAgentMessage,
126
+ });
92
127
  this.completeStageRun(stageRun, issue, thread, status, {
93
128
  threadId,
94
129
  ...(completedTurnId ? { turnId: completedTurnId } : {}),
@@ -10,20 +10,22 @@ export class ServiceStageRunner {
10
10
  codex;
11
11
  linearProvider;
12
12
  logger;
13
+ feed;
13
14
  worktreeManager;
14
15
  inputDispatcher;
15
16
  lifecyclePublisher;
16
17
  runAtomically;
17
- constructor(config, stores, codex, linearProvider, logger, runAtomically = (fn) => fn()) {
18
+ constructor(config, stores, codex, linearProvider, logger, runAtomically = (fn) => fn(), feed) {
18
19
  this.config = config;
19
20
  this.stores = stores;
20
21
  this.codex = codex;
21
22
  this.linearProvider = linearProvider;
22
23
  this.logger = logger;
24
+ this.feed = feed;
23
25
  this.runAtomically = runAtomically;
24
26
  this.worktreeManager = new WorktreeManager(config);
25
27
  this.inputDispatcher = new StageTurnInputDispatcher(stores, codex, logger);
26
- this.lifecyclePublisher = new StageLifecyclePublisher(config, stores, linearProvider, logger);
28
+ this.lifecyclePublisher = new StageLifecyclePublisher(config, stores, linearProvider, logger, feed);
27
29
  }
28
30
  async run(item) {
29
31
  const project = this.config.projects.find((candidate) => candidate.id === item.projectId);
@@ -45,6 +47,16 @@ export class ServiceStageRunner {
45
47
  return;
46
48
  }
47
49
  const plan = buildStageLaunchPlan(project, issue, desiredStage);
50
+ this.feed?.publish({
51
+ level: "info",
52
+ kind: "stage",
53
+ issueKey: issue.issueKey,
54
+ projectId: item.projectId,
55
+ stage: desiredStage,
56
+ status: "starting",
57
+ summary: `Starting ${desiredStage} workflow`,
58
+ detail: `Preparing ${plan.branchName}`,
59
+ });
48
60
  const claim = this.stores.workflowCoordinator.claimStageRun({
49
61
  projectId: item.projectId,
50
62
  linearIssueId: item.issueId,
@@ -81,6 +93,16 @@ export class ServiceStageRunner {
81
93
  error: err.message,
82
94
  stack: err.stack,
83
95
  }, "Failed to launch Codex stage run");
96
+ this.feed?.publish({
97
+ level: "error",
98
+ kind: "stage",
99
+ issueKey: issue.issueKey,
100
+ projectId: item.projectId,
101
+ stage: claim.stageRun.stage,
102
+ status: "failed",
103
+ summary: `Failed to launch ${claim.stageRun.stage} workflow`,
104
+ detail: err.message,
105
+ });
84
106
  throw err;
85
107
  }
86
108
  this.stores.workflowCoordinator.updateStageRunThread({
@@ -111,6 +133,16 @@ export class ServiceStageRunner {
111
133
  threadId: threadLaunch.threadId,
112
134
  turnId: turn.turnId,
113
135
  }, "Started Codex stage run");
136
+ this.feed?.publish({
137
+ level: "info",
138
+ kind: "stage",
139
+ issueKey: issue.issueKey,
140
+ projectId: item.projectId,
141
+ stage: claim.stageRun.stage,
142
+ status: "running",
143
+ summary: `Started ${claim.stageRun.stage} workflow`,
144
+ detail: `Turn ${turn.turnId} is running in ${plan.branchName}.`,
145
+ });
114
146
  }
115
147
  async ensureLaunchIssueMirror(project, linearIssueId, _desiredStage, _desiredWebhookId) {
116
148
  const existing = this.stores.issueWorkflows.getTrackedIssue(project.id, linearIssueId);
@@ -158,6 +190,15 @@ export class ServiceStageRunner {
158
190
  parentThreadId,
159
191
  error: err.message,
160
192
  }, "Falling back to a fresh Codex thread after parent thread fork failed");
193
+ this.feed?.publish({
194
+ level: "warn",
195
+ kind: "turn",
196
+ issueKey,
197
+ projectId,
198
+ status: "fallback",
199
+ summary: "Could not fork the previous Codex thread",
200
+ detail: "Starting a fresh thread instead.",
201
+ });
161
202
  }
162
203
  }
163
204
  const thread = await this.codex.startThread({ cwd: worktreePath });
@@ -12,20 +12,22 @@ export class ServiceWebhookProcessor {
12
12
  stores;
13
13
  enqueueIssue;
14
14
  logger;
15
+ feed;
15
16
  desiredStageRecorder;
16
17
  agentSessionHandler;
17
18
  commentHandler;
18
19
  installationHandler;
19
- constructor(config, stores, linearProvider, codex, enqueueIssue, logger) {
20
+ constructor(config, stores, linearProvider, codex, enqueueIssue, logger, feed) {
20
21
  this.config = config;
21
22
  this.stores = stores;
22
23
  this.enqueueIssue = enqueueIssue;
23
24
  this.logger = logger;
25
+ this.feed = feed;
24
26
  const turnInputDispatcher = new StageTurnInputDispatcher(stores, codex, logger);
25
27
  const agentActivity = new StageAgentActivityPublisher(linearProvider, logger);
26
28
  this.desiredStageRecorder = new WebhookDesiredStageRecorder(stores);
27
- this.agentSessionHandler = new AgentSessionWebhookHandler(stores, turnInputDispatcher, agentActivity);
28
- this.commentHandler = new CommentWebhookHandler(stores, turnInputDispatcher);
29
+ this.agentSessionHandler = new AgentSessionWebhookHandler(stores, turnInputDispatcher, agentActivity, feed);
30
+ this.commentHandler = new CommentWebhookHandler(stores, turnInputDispatcher, feed);
29
31
  this.installationHandler = new InstallationWebhookHandler(config, stores, logger);
30
32
  }
31
33
  async processWebhookEvent(webhookEventId) {
@@ -54,6 +56,12 @@ export class ServiceWebhookProcessor {
54
56
  issueId: normalized.issue?.id,
55
57
  }, "Processing stored webhook event");
56
58
  if (!normalized.issue) {
59
+ this.feed?.publish({
60
+ level: "info",
61
+ kind: "webhook",
62
+ status: normalized.triggerEvent,
63
+ summary: `Received ${normalized.triggerEvent} webhook`,
64
+ });
57
65
  this.installationHandler.handle(normalized);
58
66
  this.stores.webhookEvents.markWebhookProcessed(webhookEventId, "processed");
59
67
  this.markEventReceiptProcessed(event.webhookId, "processed");
@@ -61,6 +69,14 @@ export class ServiceWebhookProcessor {
61
69
  }
62
70
  const project = resolveProject(this.config, normalized.issue);
63
71
  if (!project) {
72
+ this.feed?.publish({
73
+ level: "warn",
74
+ kind: "webhook",
75
+ issueKey: normalized.issue.identifier,
76
+ status: "ignored",
77
+ summary: "Ignored webhook with no matching project route",
78
+ detail: normalized.triggerEvent,
79
+ });
64
80
  this.logger.info({
65
81
  webhookEventId,
66
82
  webhookId: event.webhookId,
@@ -75,6 +91,15 @@ export class ServiceWebhookProcessor {
75
91
  return;
76
92
  }
77
93
  if (!trustedActorAllowed(project, normalized.actor)) {
94
+ this.feed?.publish({
95
+ level: "warn",
96
+ kind: "webhook",
97
+ issueKey: normalized.issue.identifier,
98
+ projectId: project.id,
99
+ status: "ignored",
100
+ summary: "Ignored webhook from an untrusted actor",
101
+ detail: normalized.actor?.name ?? normalized.actor?.email ?? normalized.triggerEvent,
102
+ });
78
103
  this.logger.info({
79
104
  webhookId: normalized.webhookId,
80
105
  projectId: project.id,
@@ -91,6 +116,18 @@ export class ServiceWebhookProcessor {
91
116
  this.stores.webhookEvents.assignWebhookProject(webhookEventId, project.id);
92
117
  const receipt = this.ensureEventReceipt(event, project.id, normalized.issue.id);
93
118
  const issueState = this.desiredStageRecorder.record(project, normalized, receipt ? { eventReceiptId: receipt.id } : undefined);
119
+ const observation = describeWebhookObservation(normalized, issueState.delegatedToPatchRelay);
120
+ if (observation) {
121
+ this.feed?.publish({
122
+ level: "info",
123
+ kind: observation.kind,
124
+ issueKey: normalized.issue.identifier,
125
+ projectId: project.id,
126
+ ...(observation.status ? { status: observation.status } : {}),
127
+ summary: observation.summary,
128
+ ...(observation.detail ? { detail: observation.detail } : {}),
129
+ });
130
+ }
94
131
  await this.agentSessionHandler.handle({
95
132
  normalized,
96
133
  project,
@@ -102,6 +139,16 @@ export class ServiceWebhookProcessor {
102
139
  this.stores.webhookEvents.markWebhookProcessed(webhookEventId, "processed");
103
140
  this.markEventReceiptProcessed(event.webhookId, "processed");
104
141
  if (issueState.desiredStage) {
142
+ this.feed?.publish({
143
+ level: "info",
144
+ kind: "stage",
145
+ issueKey: normalized.issue.identifier,
146
+ projectId: project.id,
147
+ stage: issueState.desiredStage,
148
+ status: "queued",
149
+ summary: `Queued ${issueState.desiredStage} workflow`,
150
+ detail: `Triggered by ${normalized.triggerEvent}${normalized.issue.stateName ? ` from ${normalized.issue.stateName}` : ""}.`,
151
+ });
105
152
  this.logger.info({
106
153
  webhookEventId,
107
154
  webhookId: event.webhookId,
@@ -128,6 +175,14 @@ export class ServiceWebhookProcessor {
128
175
  this.stores.webhookEvents.markWebhookProcessed(webhookEventId, "failed");
129
176
  this.markEventReceiptProcessed(event.webhookId, "failed");
130
177
  const err = error instanceof Error ? error : new Error(String(error));
178
+ this.feed?.publish({
179
+ level: "error",
180
+ kind: "webhook",
181
+ projectId: event.projectId ?? undefined,
182
+ status: "failed",
183
+ summary: "Failed to process webhook",
184
+ detail: sanitizeDiagnosticText(err.message),
185
+ });
131
186
  this.logger.error({
132
187
  webhookEventId,
133
188
  webhookId: event.webhookId,
@@ -179,3 +234,50 @@ export class ServiceWebhookProcessor {
179
234
  return this.stores.eventReceipts.getEventReceipt(inserted.id);
180
235
  }
181
236
  }
237
+ function describeWebhookObservation(normalized, delegatedToPatchRelay) {
238
+ switch (normalized.triggerEvent) {
239
+ case "delegateChanged":
240
+ return delegatedToPatchRelay
241
+ ? {
242
+ kind: "agent",
243
+ status: "delegated",
244
+ summary: "Delegated to PatchRelay",
245
+ detail: normalized.issue?.stateName ? `Current Linear state: ${normalized.issue.stateName}.` : undefined,
246
+ }
247
+ : {
248
+ kind: "agent",
249
+ status: "undelegated",
250
+ summary: "Delegation moved away from PatchRelay",
251
+ };
252
+ case "agentSessionCreated":
253
+ return {
254
+ kind: "agent",
255
+ status: delegatedToPatchRelay ? "session" : "mention",
256
+ summary: delegatedToPatchRelay ? "Opened a delegated agent session" : "Mentioned PatchRelay in Linear",
257
+ detail: normalized.agentSession?.promptBody ?? normalized.agentSession?.promptContext,
258
+ };
259
+ case "agentPrompted":
260
+ return {
261
+ kind: "agent",
262
+ status: "prompted",
263
+ summary: "Received follow-up agent instructions",
264
+ detail: normalized.agentSession?.promptBody ?? normalized.agentSession?.promptContext,
265
+ };
266
+ case "commentCreated":
267
+ case "commentUpdated":
268
+ return {
269
+ kind: "comment",
270
+ status: "received",
271
+ summary: "Received a Linear comment",
272
+ detail: normalized.comment?.userName ?? normalized.comment?.body,
273
+ };
274
+ case "statusChanged":
275
+ return {
276
+ kind: "webhook",
277
+ status: "status_changed",
278
+ summary: normalized.issue?.stateName ? `Linear state changed to ${normalized.issue.stateName}` : "Linear state changed",
279
+ };
280
+ default:
281
+ return undefined;
282
+ }
283
+ }
package/dist/service.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { IssueQueryService } from "./issue-query-service.js";
2
2
  import { LinearOAuthService } from "./linear-oauth-service.js";
3
+ import { OperatorEventFeed } from "./operator-feed.js";
3
4
  import { ServiceRuntime } from "./service-runtime.js";
4
5
  import { ServiceStageFinalizer } from "./service-stage-finalizer.js";
5
6
  import { ServiceStageRunner } from "./service-stage-runner.js";
@@ -44,19 +45,21 @@ export class PatchRelayService {
44
45
  oauthService;
45
46
  queryService;
46
47
  runtime;
48
+ feed;
47
49
  constructor(config, db, codex, linearProvider, logger) {
48
50
  this.config = config;
49
51
  this.db = db;
50
52
  this.codex = codex;
51
53
  this.logger = logger;
52
54
  this.linearProvider = toLinearClientProvider(linearProvider);
55
+ this.feed = new OperatorEventFeed(db.operatorFeed);
53
56
  const stores = createServiceStores(db);
54
- this.stageRunner = new ServiceStageRunner(config, stores, codex, this.linearProvider, logger, (fn) => db.connection.transaction(fn)());
57
+ this.stageRunner = new ServiceStageRunner(config, stores, codex, this.linearProvider, logger, (fn) => db.connection.transaction(fn)(), this.feed);
55
58
  let enqueueIssue = () => {
56
59
  throw new Error("Service runtime enqueueIssue is not initialized");
57
60
  };
58
- this.stageFinalizer = new ServiceStageFinalizer(config, stores, codex, this.linearProvider, (projectId, issueId) => enqueueIssue(projectId, issueId), logger, (fn) => db.connection.transaction(fn)());
59
- this.webhookProcessor = new ServiceWebhookProcessor(config, stores, this.linearProvider, codex, (projectId, issueId) => enqueueIssue(projectId, issueId), logger);
61
+ this.stageFinalizer = new ServiceStageFinalizer(config, stores, codex, this.linearProvider, (projectId, issueId) => enqueueIssue(projectId, issueId), logger, this.feed, (fn) => db.connection.transaction(fn)());
62
+ this.webhookProcessor = new ServiceWebhookProcessor(config, stores, this.linearProvider, codex, (projectId, issueId) => enqueueIssue(projectId, issueId), logger, this.feed);
60
63
  const runtime = new ServiceRuntime(codex, logger, this.stageFinalizer, createReadyIssueSource(stores), this.webhookProcessor, {
61
64
  processIssue: (item) => this.stageRunner.run(item),
62
65
  });
@@ -89,6 +92,12 @@ export class PatchRelayService {
89
92
  getReadiness() {
90
93
  return this.runtime.getReadiness();
91
94
  }
95
+ listOperatorFeed(options) {
96
+ return this.feed.list(options);
97
+ }
98
+ subscribeOperatorFeed(listener) {
99
+ return this.feed.subscribe(listener);
100
+ }
92
101
  async acceptWebhook(params) {
93
102
  const result = await acceptIncomingWebhook({
94
103
  config: this.config,
@@ -5,11 +5,13 @@ export class StageLifecyclePublisher {
5
5
  stores;
6
6
  linearProvider;
7
7
  logger;
8
- constructor(config, stores, linearProvider, logger) {
8
+ feed;
9
+ constructor(config, stores, linearProvider, logger, feed) {
9
10
  this.config = config;
10
11
  this.stores = stores;
11
12
  this.linearProvider = linearProvider;
12
13
  this.logger = logger;
14
+ this.feed = feed;
13
15
  }
14
16
  async markStageActive(project, issue, stageRun) {
15
17
  const activeState = resolveActiveLinearState(project, stageRun.stage);
@@ -99,6 +101,15 @@ export class StageLifecyclePublisher {
99
101
  async publishStageCompletion(stageRun, enqueueIssue) {
100
102
  const refreshedIssue = this.stores.issueWorkflows.getTrackedIssue(stageRun.projectId, stageRun.linearIssueId);
101
103
  if (refreshedIssue?.desiredStage) {
104
+ this.feed?.publish({
105
+ level: "info",
106
+ kind: "stage",
107
+ issueKey: refreshedIssue.issueKey,
108
+ projectId: refreshedIssue.projectId,
109
+ stage: stageRun.stage,
110
+ status: "queued",
111
+ summary: `Completed ${stageRun.stage} workflow and queued ${refreshedIssue.desiredStage}`,
112
+ });
102
113
  await this.publishAgentCompletion(refreshedIssue, {
103
114
  type: "thought",
104
115
  body: `The ${stageRun.stage} workflow finished. PatchRelay is preparing the next requested workflow.`,
@@ -133,6 +144,16 @@ export class StageLifecyclePublisher {
133
144
  }),
134
145
  });
135
146
  this.stores.workflowCoordinator.setIssueStatusComment(stageRun.projectId, stageRun.linearIssueId, result.id);
147
+ this.feed?.publish({
148
+ level: "info",
149
+ kind: "stage",
150
+ issueKey: refreshedIssue.issueKey,
151
+ projectId: refreshedIssue.projectId,
152
+ stage: stageRun.stage,
153
+ status: "handoff",
154
+ summary: `Completed ${stageRun.stage} workflow`,
155
+ detail: `Waiting for a Linear state change or follow-up input while the issue remains in ${activeState}.`,
156
+ });
136
157
  await this.publishAgentCompletion(refreshedIssue, {
137
158
  type: "elicitation",
138
159
  body: `PatchRelay finished the ${stageRun.stage} workflow. Move the issue to its next workflow state or leave a follow-up prompt to continue.`,
@@ -158,6 +179,15 @@ export class StageLifecyclePublisher {
158
179
  }
159
180
  }
160
181
  if (refreshedIssue) {
182
+ this.feed?.publish({
183
+ level: "info",
184
+ kind: "stage",
185
+ issueKey: refreshedIssue.issueKey,
186
+ projectId: refreshedIssue.projectId,
187
+ stage: stageRun.stage,
188
+ status: "completed",
189
+ summary: `Completed ${stageRun.stage} workflow`,
190
+ });
161
191
  await this.publishAgentCompletion(refreshedIssue, {
162
192
  type: "response",
163
193
  body: `PatchRelay finished the ${stageRun.stage} workflow.`,
@@ -24,14 +24,15 @@ export class StageTurnInputDispatcher {
24
24
  }
25
25
  async flush(stageRun, options) {
26
26
  if (!stageRun.threadId || !stageRun.turnId) {
27
- return { deliveredInputIds: [], deliveredObligationIds: [], deliveredCount: 0 };
27
+ return { deliveredInputIds: [], deliveredObligationIds: [], deliveredCount: 0, failedObligationIds: [] };
28
28
  }
29
29
  const issueControl = this.inputs.issueControl.getIssueControl(stageRun.projectId, stageRun.linearIssueId);
30
30
  if (!issueControl?.activeRunLeaseId) {
31
- return { deliveredInputIds: [], deliveredObligationIds: [], deliveredCount: 0 };
31
+ return { deliveredInputIds: [], deliveredObligationIds: [], deliveredCount: 0, failedObligationIds: [] };
32
32
  }
33
33
  const deliveredInputIds = [];
34
34
  const deliveredObligationIds = [];
35
+ const failedObligationIds = [];
35
36
  let deliveredCount = 0;
36
37
  const obligationQuery = options?.retryInProgress ? { includeInProgress: true } : undefined;
37
38
  for (const obligation of this.listPendingInputObligations(stageRun.projectId, stageRun.linearIssueId, issueControl.activeRunLeaseId, obligationQuery)) {
@@ -76,6 +77,7 @@ export class StageTurnInputDispatcher {
76
77
  }
77
78
  catch (error) {
78
79
  this.inputs.obligations.markObligationStatus(obligation.id, "pending", error instanceof Error ? error.message : String(error));
80
+ failedObligationIds.push(obligation.id);
79
81
  this.logger.warn({
80
82
  issueKey: options?.issueKey,
81
83
  threadId: stageRun.threadId,
@@ -87,7 +89,7 @@ export class StageTurnInputDispatcher {
87
89
  break;
88
90
  }
89
91
  }
90
- return { deliveredInputIds, deliveredObligationIds, deliveredCount };
92
+ return { deliveredInputIds, deliveredObligationIds, deliveredCount, failedObligationIds };
91
93
  }
92
94
  listPendingInputObligations(projectId, linearIssueId, activeRunLeaseId, options) {
93
95
  const query = options?.includeInProgress