patchrelay 0.68.6 → 0.69.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.
@@ -2,10 +2,13 @@ import { deriveLinearProgressFact } from "./linear-progress-facts.js";
2
2
  export class LinearProgressReporter {
3
3
  db;
4
4
  emitActivity;
5
+ options;
6
+ static DEFAULT_HEARTBEAT_INTERVAL_MS = 15 * 60 * 1000;
5
7
  publicationsByRun = new Map();
6
- constructor(db, emitActivity) {
8
+ constructor(db, emitActivity, options = {}) {
7
9
  this.db = db;
8
10
  this.emitActivity = emitActivity;
11
+ this.options = options;
9
12
  }
10
13
  maybeEmitProgress(notification, run) {
11
14
  const issue = this.db.getIssue(run.projectId, run.linearIssueId);
@@ -14,6 +17,7 @@ export class LinearProgressReporter {
14
17
  }
15
18
  const fact = deriveLinearProgressFact(notification, issue);
16
19
  if (!fact) {
20
+ this.maybeEmitHeartbeat(notification, run);
17
21
  return;
18
22
  }
19
23
  const previous = this.publicationsByRun.get(run.id);
@@ -22,9 +26,10 @@ export class LinearProgressReporter {
22
26
  if (!shouldEmitEphemeral && !shouldEmitHistory) {
23
27
  return;
24
28
  }
25
- const now = Date.now();
29
+ const now = this.now();
26
30
  const publication = {
27
31
  ...previous,
32
+ quietSinceMs: now,
28
33
  ...(shouldEmitEphemeral
29
34
  ? {
30
35
  ephemeralMeaningKey: fact.meaningKey,
@@ -53,6 +58,56 @@ export class LinearProgressReporter {
53
58
  clearProgress(runId) {
54
59
  this.publicationsByRun.delete(runId);
55
60
  }
61
+ maybeEmitHeartbeat(notification, run) {
62
+ const previous = this.publicationsByRun.get(run.id);
63
+ const now = this.now();
64
+ const quietSinceMs = previous?.quietSinceMs ?? previous?.ephemeralPublishedAtMs ?? previous?.historyPublishedAtMs ?? now;
65
+ const elapsedMs = now - quietSinceMs;
66
+ const intervalMs = this.options.heartbeatIntervalMs ?? LinearProgressReporter.DEFAULT_HEARTBEAT_INTERVAL_MS;
67
+ if (elapsedMs < intervalMs) {
68
+ if (!previous) {
69
+ this.publicationsByRun.set(run.id, { quietSinceMs });
70
+ }
71
+ return;
72
+ }
73
+ if (previous?.lastHeartbeatAtMs !== undefined && now - previous.lastHeartbeatAtMs < intervalMs) {
74
+ return;
75
+ }
76
+ const issue = this.db.getIssue(run.projectId, run.linearIssueId);
77
+ if (!issue) {
78
+ return;
79
+ }
80
+ const detail = describeHeartbeatDetail(notification);
81
+ const content = {
82
+ type: "thought",
83
+ body: detail
84
+ ? `PatchRelay is still working on ${run.runType.replaceAll("_", " ")}. Latest signal: ${detail}.`
85
+ : `PatchRelay is still working on ${run.runType.replaceAll("_", " ")}.`,
86
+ };
87
+ this.publicationsByRun.set(run.id, {
88
+ ...previous,
89
+ quietSinceMs,
90
+ lastHeartbeatAtMs: now,
91
+ });
92
+ void this.emitActivity(issue, content, { ephemeral: true }).catch(() => {
93
+ const current = this.publicationsByRun.get(run.id);
94
+ if (current?.lastHeartbeatAtMs === now) {
95
+ const restored = {
96
+ ...current,
97
+ };
98
+ if (previous?.lastHeartbeatAtMs !== undefined) {
99
+ restored.lastHeartbeatAtMs = previous.lastHeartbeatAtMs;
100
+ }
101
+ else {
102
+ delete restored.lastHeartbeatAtMs;
103
+ }
104
+ this.publicationsByRun.set(run.id, restored);
105
+ }
106
+ });
107
+ }
108
+ now() {
109
+ return this.options.now?.() ?? Date.now();
110
+ }
56
111
  clearFailedPublication(runId, channel, meaningKey, publishedAtMs) {
57
112
  const current = this.publicationsByRun.get(runId);
58
113
  if (!current) {
@@ -93,3 +148,27 @@ export class LinearProgressReporter {
93
148
  this.publicationsByRun.set(runId, next);
94
149
  }
95
150
  }
151
+ function describeHeartbeatDetail(notification) {
152
+ if (notification.method === "item/started" || notification.method === "item/updated" || notification.method === "item/completed") {
153
+ const item = notification.params.item;
154
+ if (item && typeof item === "object") {
155
+ const typed = item;
156
+ if (typed.type === "commandExecution" && typeof typed.command === "string") {
157
+ return `command ${trimHeartbeatDetail(typed.command)}`;
158
+ }
159
+ if (typed.type === "dynamicToolCall" && typeof typed.tool === "string") {
160
+ return `tool ${trimHeartbeatDetail(typed.tool)}`;
161
+ }
162
+ if (typed.type === "mcpToolCall" && typeof typed.server === "string" && typeof typed.tool === "string") {
163
+ return `tool ${trimHeartbeatDetail(`${typed.server}/${typed.tool}`)}`;
164
+ }
165
+ }
166
+ }
167
+ return notification.method;
168
+ }
169
+ function trimHeartbeatDetail(value, maxLength = 120) {
170
+ const compact = value.replace(/\s+/g, " ").trim();
171
+ if (compact.length <= maxLength)
172
+ return compact;
173
+ return `${compact.slice(0, maxLength).trimEnd()}...`;
174
+ }
@@ -61,7 +61,14 @@ export function buildBlockedDelegationActivity(blockedByKeys = []) {
61
61
  export function buildPromptDeliveredThought(runType) {
62
62
  return {
63
63
  type: "thought",
64
- body: `PatchRelay routed your latest instructions into the active ${lowerRunTypeLabel(runType)} workflow.`,
64
+ body: `PatchRelay routed your latest instructions into the active ${lowerRunTypeLabel(runType)} workflow; it will fold them in at the next checkpoint.`,
65
+ };
66
+ }
67
+ export function buildPromptDeliveryFailedActivity(runType, reason) {
68
+ const suffix = reason ? ` ${trimSummary(reason, 180)}` : "";
69
+ return {
70
+ type: "thought",
71
+ body: `PatchRelay could not route your latest instructions into the active ${lowerRunTypeLabel(runType)} workflow.${suffix}`,
65
72
  };
66
73
  }
67
74
  export function buildFollowupStatusActivity(params) {
@@ -72,14 +79,16 @@ export function buildFollowupStatusActivity(params) {
72
79
  const prNote = params.issue.prNumber ? ` PR #${params.issue.prNumber}.` : "";
73
80
  const statusNote = params.statusNote ? ` ${params.statusNote}` : "";
74
81
  return {
75
- type: "response",
82
+ type: params.activityType ?? "response",
76
83
  body: `PatchRelay status: ${subject} is ${formatFactoryState(params.issue.factoryState)}.${prNote}${runNote}${statusNote}`.trim(),
77
84
  };
78
85
  }
79
86
  export function buildNonActionableFollowupActivity(intent) {
80
87
  const body = intent === "status"
81
88
  ? "PatchRelay status is available in the current agent session."
82
- : "PatchRelay did not start implementation because this looks like a question or clarification. Ask PatchRelay to continue, retry, or implement when you want work to run.";
89
+ : intent === "context_only"
90
+ ? "PatchRelay recorded this as context and did not start a new run. Ask PatchRelay to continue, retry, or implement when you want work to run."
91
+ : "PatchRelay did not start a run because this did not clearly request work. Ask PatchRelay to continue, retry, or implement when you want work to run.";
83
92
  return { type: "response", body };
84
93
  }
85
94
  export function buildRunStartedActivity(runType) {
@@ -97,6 +106,18 @@ export function buildRunStartedActivity(runType) {
97
106
  return { type: "action", action: "Implementing", parameter: "requested change" };
98
107
  }
99
108
  }
109
+ export function buildReviewRoundStartedActivity(params) {
110
+ const reviewer = params.reviewerName ? ` from @${params.reviewerName}` : "";
111
+ const head = params.headSha ? ` on head ${params.headSha.slice(0, 8)}` : "";
112
+ const comments = params.commentCount !== undefined
113
+ ? `; ${params.commentCount} inline comment${params.commentCount === 1 ? "" : "s"} captured`
114
+ : "";
115
+ return {
116
+ type: "action",
117
+ action: "Review round",
118
+ parameter: `${params.round}${reviewer}${head}${comments}`,
119
+ };
120
+ }
100
121
  function formatFactoryState(state) {
101
122
  return state.replaceAll("_", " ");
102
123
  }
@@ -104,44 +125,54 @@ export function buildRunCompletedActivity(params) {
104
125
  const prLabel = params.prNumber ? `PR #${params.prNumber}` : "the pull request";
105
126
  const summary = trimSummary(params.completionSummary);
106
127
  const detail = summary ? ` ${summary}` : "";
128
+ const steeringSummary = buildSteeringSummary(params.steeringDeliveredCount, params.steeringFailedCount);
107
129
  switch (params.runType) {
108
130
  case "implementation":
109
131
  if (params.postRunState === "pr_open") {
132
+ const body = `${prLabel} opened:${detail || " Published and ready for review."}`;
110
133
  return {
111
134
  type: "response",
112
- body: `${prLabel} opened:${detail || " Published and ready for review."}`,
135
+ body: steeringSummary ? `${body}\n\n${steeringSummary}` : body,
113
136
  };
114
137
  }
115
138
  return undefined;
116
139
  case "review_fix":
117
- return summary
118
- ? {
119
- type: "response",
120
- body: summary,
140
+ {
141
+ const lines = [];
142
+ lines.push(params.reviewRound ? `Review round ${params.reviewRound} completed.` : "Review fix completed.");
143
+ if (params.resultHeadSha)
144
+ lines.push(`Resulting head: ${params.resultHeadSha.slice(0, 8)}.`);
145
+ if (steeringSummary)
146
+ lines.push(steeringSummary);
147
+ const resolution = buildReviewResolutionSections(summary);
148
+ lines.push("", "Addressed:", resolution.addressed, "", "Deferred:", resolution.deferred, "", "Not applicable:", resolution.notApplicable);
149
+ if (!summary && !params.resultHeadSha) {
150
+ lines[0] = `Updated ${prLabel} to address review feedback.`;
121
151
  }
122
- : {
152
+ return {
123
153
  type: "response",
124
- body: `Updated ${prLabel} to address review feedback.`,
154
+ body: lines.join("\n").trim(),
125
155
  };
156
+ }
126
157
  case "ci_repair":
127
158
  return summary
128
159
  ? {
129
160
  type: "response",
130
- body: summary,
161
+ body: steeringSummary ? `${summary}\n\n${steeringSummary}` : summary,
131
162
  }
132
163
  : {
133
164
  type: "response",
134
- body: `Updated ${prLabel} after CI repair.`,
165
+ body: steeringSummary ? `Updated ${prLabel} after CI repair.\n\n${steeringSummary}` : `Updated ${prLabel} after CI repair.`,
135
166
  };
136
167
  case "queue_repair":
137
168
  return summary
138
169
  ? {
139
170
  type: "response",
140
- body: summary,
171
+ body: steeringSummary ? `${summary}\n\n${steeringSummary}` : summary,
141
172
  }
142
173
  : {
143
174
  type: "response",
144
- body: `Updated ${prLabel} after merge-queue repair.`,
175
+ body: steeringSummary ? `Updated ${prLabel} after merge-queue repair.\n\n${steeringSummary}` : `Updated ${prLabel} after merge-queue repair.`,
145
176
  };
146
177
  case "branch_upkeep":
147
178
  return undefined;
@@ -155,6 +186,9 @@ export function buildRunCompletedActivity(params) {
155
186
  if (summary) {
156
187
  lines.push("", summary);
157
188
  }
189
+ if (steeringSummary) {
190
+ lines.push("", steeringSummary);
191
+ }
158
192
  return {
159
193
  type: "response",
160
194
  body: lines.join("\n"),
@@ -162,6 +196,32 @@ export function buildRunCompletedActivity(params) {
162
196
  }
163
197
  }
164
198
  }
199
+ function buildSteeringSummary(delivered = 0, failed = 0) {
200
+ if (delivered === 0 && failed === 0)
201
+ return undefined;
202
+ const parts = [];
203
+ if (delivered > 0) {
204
+ parts.push(`${delivered} follow-up prompt${delivered === 1 ? "" : "s"} delivered`);
205
+ }
206
+ if (failed > 0) {
207
+ parts.push(`${failed} follow-up delivery failure${failed === 1 ? "" : "s"}`);
208
+ }
209
+ return `Steering: ${parts.join("; ")}.`;
210
+ }
211
+ function buildReviewResolutionSections(summary) {
212
+ if (!summary) {
213
+ return {
214
+ addressed: "- Review feedback addressed and published.",
215
+ deferred: "- None reported.",
216
+ notApplicable: "- None reported.",
217
+ };
218
+ }
219
+ return {
220
+ addressed: `- ${summary}`,
221
+ deferred: "- None reported.",
222
+ notApplicable: "- None reported.",
223
+ };
224
+ }
165
225
  export function buildRunFailureActivity(runType, reason) {
166
226
  const label = formatRunTypeLabel(runType);
167
227
  return {
@@ -6,6 +6,11 @@ export function resolveRetryTarget(params) {
6
6
  if (hasOpenPr(params.prNumber, params.prState) && params.lastGitHubFailureSource === "queue_eviction") {
7
7
  return { runType: "queue_repair", factoryState: "repairing_queue" };
8
8
  }
9
+ if (hasOpenPr(params.prNumber, params.prState)
10
+ && params.prReviewState === "approved"
11
+ && (params.factoryState === "awaiting_queue" || params.lastRunType === "queue_repair")) {
12
+ return { runType: "queue_repair", factoryState: "repairing_queue" };
13
+ }
9
14
  if (hasOpenPr(params.prNumber, params.prState)
10
15
  && (params.prCheckStatus === "failed" || params.prCheckStatus === "failure" || params.lastGitHubFailureSource === "branch_ci")) {
11
16
  return { runType: "ci_repair", factoryState: "repairing_ci" };
@@ -19,6 +19,13 @@ export function buildOperatorRetryEvent(issue, runType, source = "operator_retry
19
19
  ...(queueIncident ?? {}),
20
20
  ...(failureContext ?? {}),
21
21
  source,
22
+ requiresFreshHead: true,
23
+ promptContext: [
24
+ "Operator retry is recovering a merge queue rejection on an approved PR.",
25
+ "If the previous repair left the same head SHA in place, merge-steward may still consider it terminally evicted.",
26
+ "Preserve the approved diff, but publish a new head SHA on the existing PR branch before finishing.",
27
+ "If rebasing onto the current base produces no content change, create an empty queue-kick commit.",
28
+ ].join(" "),
22
29
  }),
23
30
  dedupeKey: `${source}:queue_repair:${issue.linearIssueId}:${issue.prHeadSha ?? issue.lastGitHubFailureHeadSha ?? "unknown-sha"}`,
24
31
  };
@@ -266,10 +266,17 @@ function buildStructuredReviewContext(context) {
266
266
  const reviewCommitId = typeof context?.reviewCommitId === "string" ? context.reviewCommitId : undefined;
267
267
  const reviewUrl = typeof context?.reviewUrl === "string" ? context.reviewUrl : undefined;
268
268
  const reviewComments = readReviewFixComments(context);
269
- if (!reviewId && !reviewCommitId && !reviewUrl && reviewComments.length === 0) {
269
+ const degraded = context?.reviewContextDegraded === true;
270
+ if (!degraded && !reviewId && !reviewCommitId && !reviewUrl && reviewComments.length === 0) {
270
271
  return undefined;
271
272
  }
272
273
  const lines = ["## Structured Review Context", ""];
274
+ if (degraded) {
275
+ const reason = typeof context?.reviewContextDegradedReason === "string" && context.reviewContextDegradedReason.trim()
276
+ ? context.reviewContextDegradedReason.trim()
277
+ : "GitHub requested-changes context could not be refreshed before launch.";
278
+ lines.push("GitHub review context refresh: degraded", reason, "Do not assume cached review details are current. Re-read the PR review in GitHub before making review-fix changes.");
279
+ }
273
280
  if (reviewId !== undefined)
274
281
  lines.push(`Review ID: ${reviewId}`);
275
282
  if (reviewCommitId)
@@ -436,6 +443,22 @@ function buildFollowUpContextLines(issue, runType, context) {
436
443
  lines.push("", "Recent updates:");
437
444
  followUpLines.forEach((line) => lines.push(`- ${line}`));
438
445
  }
446
+ if (context?.replacementPrRequired === true) {
447
+ lines.push("", "Previous PR facts:");
448
+ if (typeof context.previousPrNumber === "number") {
449
+ lines.push(`Previous PR: #${context.previousPrNumber} (replacement PR needed)`);
450
+ }
451
+ if (typeof context.previousPrUrl === "string") {
452
+ lines.push(`Previous PR URL: ${context.previousPrUrl}`);
453
+ }
454
+ if (typeof context.previousPrState === "string") {
455
+ lines.push(`Previous PR state: ${context.previousPrState}`);
456
+ }
457
+ if (typeof context.previousPrHeadSha === "string") {
458
+ lines.push(`Previous PR head SHA: ${context.previousPrHeadSha}`);
459
+ }
460
+ lines.push("Create a fresh replacement PR for the new requested changes; do not mutate or republish the completed PR.");
461
+ }
439
462
  if (issue.prNumber || issue.prHeadSha || issue.prReviewState || context?.mergeStateStatus) {
440
463
  const prHeading = prContext.kind === "closed_historical_pr"
441
464
  || prContext.kind === "closed_replacement_pending"
@@ -531,7 +554,7 @@ function buildPrePushSelfReviewSection(target, runType) {
531
554
  }
532
555
  return lines;
533
556
  }
534
- function buildPublicationContract(runType, issueClass) {
557
+ function buildPublicationContract(runType, issueClass, context) {
535
558
  if (issueClass === "orchestration") {
536
559
  return [
537
560
  "## Publish",
@@ -554,6 +577,7 @@ function buildPublicationContract(runType, issueClass) {
554
577
  ...buildPrePushSelfReviewSection("new_pr", runType),
555
578
  ].join("\n");
556
579
  }
580
+ const requiresFreshQueueHead = runType === "queue_repair" && context?.requiresFreshHead === true;
557
581
  return [
558
582
  "## Publish",
559
583
  "",
@@ -561,8 +585,16 @@ function buildPublicationContract(runType, issueClass) {
561
585
  "Do not open a new PR.",
562
586
  "A PR-less stop is not a successful outcome for a repair run unless a genuine external blocker prevents any correct push.",
563
587
  "",
564
- "Before pushing, compute `git diff $(git merge-base origin/main HEAD)..HEAD | git patch-id --stable` and compare its first field to the `Last published patch-id` shown in the prompt header (if any).",
565
- "If they match, do not push — finish the run as a no-op. Edit the PR body via `gh pr edit` instead if a textual update is needed.",
588
+ ...(requiresFreshQueueHead
589
+ ? [
590
+ "This queue repair requires a fresh PR head SHA because the previous head was terminally evicted by merge-steward.",
591
+ "Before pushing, compute `git diff $(git merge-base origin/main HEAD)..HEAD | git patch-id --stable` and compare its first field to the `Last published patch-id` shown in the prompt header (if any).",
592
+ "If the patch-id matches, preserve the approved diff and still push a new head SHA on the existing PR branch. Prefer a real rebase onto current `origin/main`; if that produces no content change, create an empty queue-kick commit.",
593
+ ]
594
+ : [
595
+ "Before pushing, compute `git diff $(git merge-base origin/main HEAD)..HEAD | git patch-id --stable` and compare its first field to the `Last published patch-id` shown in the prompt header (if any).",
596
+ "If they match, do not push — finish the run as a no-op. Edit the PR body via `gh pr edit` instead if a textual update is needed.",
597
+ ]),
566
598
  "",
567
599
  ...buildPrePushSelfReviewSection("existing_pr", runType),
568
600
  ].join("\n");
@@ -586,7 +618,7 @@ function buildSections(issue, runType, repoPath, context, followUp = false) {
586
618
  if (workflow) {
587
619
  sections.push({ id: "workflow-guidance", content: workflow });
588
620
  }
589
- sections.push({ id: "publication-contract", content: buildPublicationContract(runType, issueClass) });
621
+ sections.push({ id: "publication-contract", content: buildPublicationContract(runType, issueClass, context) });
590
622
  return sections;
591
623
  }
592
624
  function filterAllowedReplacements(promptLayer) {
@@ -654,8 +686,8 @@ export function mergePromptCustomizationLayers(base, override) {
654
686
  ? { extraInstructions: base.extraInstructions }
655
687
  : {}),
656
688
  replaceSections: {
657
- ...(base?.replaceSections ?? {}),
658
- ...(override?.replaceSections ?? {}),
689
+ ...base?.replaceSections,
690
+ ...override?.replaceSections,
659
691
  },
660
692
  };
661
693
  }
@@ -12,6 +12,8 @@ function isDuplicateProbe(issue, context) {
12
12
  const headSha = typeof context?.failureHeadSha === "string" ? context.failureHeadSha : undefined;
13
13
  if (!signature)
14
14
  return false;
15
+ if (context?.requiresFreshHead === true)
16
+ return false;
15
17
  return issue.lastAttemptedFailureSignature === signature
16
18
  && (headSha === undefined || issue.lastAttemptedFailureHeadSha === headSha);
17
19
  }
@@ -133,12 +135,25 @@ export class QueueHealthMonitor {
133
135
  if (isDirty || hasEvictionCheckRun) {
134
136
  const headRefOid = pr.headRefOid ?? "unknown";
135
137
  const reason = hasEvictionCheckRun ? "queue_eviction_missed" : "preemptive_conflict";
136
- const signature = `preemptive_queue_conflict:${headRefOid}`;
138
+ const signature = hasEvictionCheckRun
139
+ ? `same_head_queue_eviction:${headRefOid}`
140
+ : `preemptive_queue_conflict:${headRefOid}`;
137
141
  const pendingRunContext = {
138
142
  source: "queue_health_monitor",
139
143
  failureReason: reason,
140
144
  failureHeadSha: headRefOid,
141
145
  failureSignature: signature,
146
+ ...(hasEvictionCheckRun
147
+ ? {
148
+ requiresFreshHead: true,
149
+ promptContext: [
150
+ `merge-steward/queue is already failed on PR #${issue.prNumber} at head ${headRefOid}.`,
151
+ "merge-steward will not re-admit the same evicted head SHA.",
152
+ "Preserve the approved diff, but publish a new head SHA on the existing PR branch before finishing.",
153
+ "If rebasing onto the current base produces no content change, create an empty queue-kick commit.",
154
+ ].join(" "),
155
+ }
156
+ : {}),
142
157
  };
143
158
  if (isDuplicateProbe(issue, pendingRunContext)) {
144
159
  return;
@@ -289,19 +289,24 @@ export class ReactiveRunPolicy {
289
289
  return updated;
290
290
  }
291
291
  async hydrateRequestedChangesContext(projectId, prNumber, repoFullName, headSha, context) {
292
- const merged = {
293
- ...(context ?? {}),
294
- ...(headSha ? { headSha } : {}),
295
- };
296
- if (hasStructuredReviewContext(merged)) {
297
- return merged;
292
+ const merged = { ...context };
293
+ if (headSha) {
294
+ merged.headSha = headSha;
295
+ merged.currentPrHeadSha = headSha;
298
296
  }
299
297
  const liveReview = await readLatestRequestedChangesReviewContext(repoFullName, prNumber);
300
298
  if (!liveReview) {
301
- return Object.keys(merged).length > 0 ? merged : context;
299
+ return {
300
+ ...merged,
301
+ reviewContextStatus: "degraded",
302
+ reviewContextDegraded: true,
303
+ reviewContextDegradedReason: "GitHub requested-changes review context could not be fetched before launch.",
304
+ };
302
305
  }
303
306
  return {
304
307
  ...merged,
308
+ reviewContextStatus: "fresh",
309
+ reviewContextDegraded: false,
305
310
  ...(liveReview.reviewId !== undefined ? { reviewId: liveReview.reviewId } : {}),
306
311
  ...(liveReview.reviewCommitId ? { reviewCommitId: liveReview.reviewCommitId } : {}),
307
312
  ...(liveReview.reviewUrl ? { reviewUrl: liveReview.reviewUrl } : {}),
@@ -327,21 +332,3 @@ function isReactiveScopeRiskPath(filePath) {
327
332
  return REACTIVE_SCOPE_RISK_EXACT_PATHS.has(filePath)
328
333
  || REACTIVE_SCOPE_RISK_PREFIXES.some((prefix) => filePath.startsWith(prefix));
329
334
  }
330
- function hasStructuredReviewContext(context) {
331
- if (!context)
332
- return false;
333
- const reviewBody = typeof context.reviewBody === "string" ? context.reviewBody.trim() : "";
334
- const isOperatorRetryPlaceholder = typeof context.source === "string"
335
- && context.source === "operator_retry"
336
- && /^operator requested retry of review-fix work\.?$/i.test(reviewBody);
337
- if (isOperatorRetryPlaceholder) {
338
- return false;
339
- }
340
- if (reviewBody)
341
- return true;
342
- if (typeof context.reviewUrl === "string" && context.reviewUrl.trim())
343
- return true;
344
- if (typeof context.reviewerName === "string" && context.reviewerName.trim())
345
- return true;
346
- return Array.isArray(context.reviewComments) && context.reviewComments.length > 0;
347
- }
@@ -20,6 +20,24 @@ function buildRunSummaryJson(report, publicationRecapSummary) {
20
20
  publicationRecapSummary: publicationRecapSummary ?? null,
21
21
  });
22
22
  }
23
+ function summarizePromptDeliveryEvents(events, run) {
24
+ let delivered = 0;
25
+ let failed = 0;
26
+ for (const event of events) {
27
+ if (event.eventType !== "prompt_delivered")
28
+ continue;
29
+ const payload = parseEventJson(event.eventJson);
30
+ if (payload?.runId !== run.id)
31
+ continue;
32
+ if (payload.status === "delivered") {
33
+ delivered += 1;
34
+ }
35
+ else if (payload.status === "delivery_failed") {
36
+ failed += 1;
37
+ }
38
+ }
39
+ return { delivered, failed };
40
+ }
23
41
  function shouldGeneratePublicationRecap(runType) {
24
42
  return runType === "implementation"
25
43
  || runType === "review_fix"
@@ -417,11 +435,16 @@ export class RunFinalizer {
417
435
  const completionSummary = publicationRecapSummary
418
436
  ?? report.assistantMessages.at(-1)?.slice(0, 300)
419
437
  ?? `${run.runType} completed.`;
438
+ const steeringSummary = summarizePromptDeliveryEvents(this.db.issueSessions.listIssueSessionEvents(run.projectId, run.linearIssueId), run);
420
439
  const linearActivity = buildRunCompletedActivity({
421
440
  runType: run.runType,
422
441
  completionSummary,
423
442
  postRunState: updatedIssue.factoryState,
424
443
  ...(updatedIssue.prNumber !== undefined ? { prNumber: updatedIssue.prNumber } : {}),
444
+ ...(run.runType === "review_fix" ? { reviewRound: Math.max(1, updatedIssue.reviewFixAttempts) } : {}),
445
+ ...(run.runType === "review_fix" && updatedIssue.prHeadSha ? { resultHeadSha: updatedIssue.prHeadSha } : {}),
446
+ ...(steeringSummary.delivered > 0 ? { steeringDeliveredCount: steeringSummary.delivered } : {}),
447
+ ...(steeringSummary.failed > 0 ? { steeringFailedCount: steeringSummary.failed } : {}),
425
448
  });
426
449
  if (linearActivity) {
427
450
  void this.linearSync.emitActivity(updatedIssue, linearActivity);
@@ -1,5 +1,5 @@
1
1
  import { summarizeCurrentThread } from "./run-reporting.js";
2
- import { buildRunStartedActivity, } from "./linear-session-reporting.js";
2
+ import { buildReviewRoundStartedActivity, buildRunStartedActivity, } from "./linear-session-reporting.js";
3
3
  import { CompletionCheckService } from "./completion-check.js";
4
4
  import { PublicationRecapService } from "./publication-recap.js";
5
5
  import { WorktreeManager } from "./worktree-manager.js";
@@ -411,7 +411,16 @@ export class RunOrchestrator {
411
411
  this.logger.info({ issueKey: issue.issueKey, runType, threadId, turnId }, `Started ${runType} run`);
412
412
  // Emit Linear activity + plan
413
413
  const freshIssue = this.db.issues.getIssue(item.projectId, item.issueId) ?? issue;
414
- void this.linearSync.emitActivity(freshIssue, buildRunStartedActivity(runType));
414
+ const reviewComments = Array.isArray(effectiveContext?.reviewComments) ? effectiveContext.reviewComments : undefined;
415
+ const reviewRoundActivity = runType === "review_fix"
416
+ ? buildReviewRoundStartedActivity({
417
+ round: Math.max(1, freshIssue.reviewFixAttempts),
418
+ ...(typeof effectiveContext?.reviewerName === "string" ? { reviewerName: effectiveContext.reviewerName } : {}),
419
+ ...(reviewComments ? { commentCount: reviewComments.length } : {}),
420
+ ...(typeof sourceHeadSha === "string" ? { headSha: sourceHeadSha } : {}),
421
+ })
422
+ : undefined;
423
+ void this.linearSync.emitActivity(freshIssue, reviewRoundActivity ?? buildRunStartedActivity(runType));
415
424
  void this.linearSync.syncSession(freshIssue, { activeRunType: runType });
416
425
  }
417
426
  async resetWorktreeToTrackedBranch(worktreePath, branchName, issue) {
@@ -108,6 +108,7 @@ export class ServiceIssueActions {
108
108
  prState: issue.prState,
109
109
  prReviewState: issue.prReviewState,
110
110
  prCheckStatus: issue.prCheckStatus,
111
+ factoryState: issue.factoryState,
111
112
  pendingRunType: issue.pendingRunType,
112
113
  lastRunType: issueSession?.lastRunType,
113
114
  lastGitHubFailureSource: issue.lastGitHubFailureSource,
@@ -12,6 +12,8 @@ import { IssueRemovalHandler } from "./webhooks/issue-removal-handler.js";
12
12
  import { safeJsonParse, sanitizeDiagnosticText } from "./utils.js";
13
13
  import { extractLatestAssistantSummary } from "./issue-session-events.js";
14
14
  import { WakeDispatcher } from "./wake-dispatcher.js";
15
+ import { CodexFollowupIntentClassifier } from "./followup-intent.js";
16
+ import { CodexConversationAdapter } from "./codex-conversation-adapter.js";
15
17
  export class WebhookHandler {
16
18
  config;
17
19
  db;
@@ -28,7 +30,7 @@ export class WebhookHandler {
28
30
  dependencyReadinessHandler;
29
31
  linearSync;
30
32
  wakeDispatcher;
31
- constructor(config, db, linearProvider, codex, wakeDispatcherOrEnqueueIssue, logger, feed) {
33
+ constructor(config, db, linearProvider, codex, wakeDispatcherOrEnqueueIssue, logger, feed, followupClassifier) {
32
34
  this.config = config;
33
35
  this.db = db;
34
36
  this.linearProvider = linearProvider;
@@ -44,11 +46,13 @@ export class WebhookHandler {
44
46
  : new WakeDispatcher(db, wakeDispatcherOrEnqueueIssue, () => undefined, logger, feed);
45
47
  this.installationHandler = new InstallationWebhookHandler(config, { linearInstallations: db.linearInstallations }, logger, feed);
46
48
  this.issueRemovalHandler = new IssueRemovalHandler(db, feed);
47
- this.commentWakeHandler = new CommentWakeHandler(db, codex, this.wakeDispatcher, logger, feed);
48
- this.agentSessionHandler = new AgentSessionHandler(config, db, linearProvider, codex, this.wakeDispatcher, logger, feed);
49
+ this.linearSync = new LinearSessionSync(config, db, linearProvider, logger, feed);
50
+ const intentClassifier = followupClassifier ?? new CodexFollowupIntentClassifier(codex, logger);
51
+ const conversationAdapter = new CodexConversationAdapter(db, codex, this.wakeDispatcher, logger, feed, intentClassifier);
52
+ this.commentWakeHandler = new CommentWakeHandler(db, this.wakeDispatcher, feed, conversationAdapter, (issue, content, options) => this.linearSync.emitActivity(issue, content, options));
53
+ this.agentSessionHandler = new AgentSessionHandler(config, db, linearProvider, codex, this.wakeDispatcher, logger, feed, conversationAdapter);
49
54
  this.desiredStageRecorder = new DesiredStageRecorder(db, linearProvider, this.wakeDispatcher, feed);
50
55
  this.contextLoader = new WebhookContextLoader(config, linearProvider);
51
- this.linearSync = new LinearSessionSync(config, db, linearProvider, logger, feed);
52
56
  this.dependencyReadinessHandler = new DependencyReadinessHandler(db, this.wakeDispatcher, (projectId, issueId) => this.peekPendingSessionWakeRunType(projectId, issueId));
53
57
  }
54
58
  async processWebhookEvent(webhookEventId) {