patchrelay 0.78.0 → 0.79.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.
@@ -0,0 +1,382 @@
1
+ import { z } from "zod";
2
+ // Plan §D1: the one typed schema for the run context object — the bag that is
3
+ // (a) stored in `issues.pending_run_context_json` (legacy pending-wake path,
4
+ // read back by RunWakePlanner.materializeLegacyPendingWake),
5
+ // (b) carried in session-event `event_json` payloads for wake events and
6
+ // merged into the wake plan by deriveSessionWakePlan, and
7
+ // (c) passed around in memory as `context` / `effectiveContext` /
8
+ // `pendingRunContext` until it reaches the prompt builder and launcher.
9
+ //
10
+ // Every known field is typed strictly so a mistyped field fails loudly at the
11
+ // parse boundary. Unknown keys are deliberately TOLERATED (loose object), not
12
+ // rejected, because:
13
+ // - existing DB rows contain contexts written by older PatchRelay versions
14
+ // whose field sets we no longer produce (e.g. `mergeQueueContext`,
15
+ // `userComment`, `operatorPrompt` below survive only as legacy reads), and
16
+ // - deriveSessionWakePlan merges whole event payloads into the context via
17
+ // Object.assign, so producer-side extra keys flow through by design.
18
+ // The static `RunContext` type intentionally has NO index signature (it is
19
+ // inferred from a non-loose mirror of the same shape), so compile-time access
20
+ // to undeclared fields is an error even though runtime parsing passes unknown
21
+ // keys through. NESTED objects (ciSnapshot, incidentContext, reviewComments
22
+ // entries, ...) use plain z.object — unknown nested keys are stripped at the
23
+ // boundary instead of passed through: they are leaf display data, every field
24
+ // any consumer reads is declared here, and a single definition keeps the
25
+ // static type free of index signatures so producer-side `satisfies RunContext`
26
+ // checks stay sound.
27
+ /** Entry of `followUps`, assembled by deriveSessionWakePlan from
28
+ * direct_reply / followup_prompt / followup_comment / operator_prompt event
29
+ * payloads; consumed by prompting/patchrelay.ts buildFollowUpContextLines. */
30
+ const followUpEntryShape = {
31
+ type: z.string().optional(),
32
+ text: z.string().optional(),
33
+ author: z.string().optional(),
34
+ };
35
+ /** Inline review comment captured from GitHub. Produced by
36
+ * github-webhook-reactive-run.ts fetchReviewCommentsForEvent and
37
+ * reactive-run-policy.ts hydrateRequestedChangesContext (remote-pr-review.ts);
38
+ * consumed by prompting/patchrelay.ts readReviewFixComments and
39
+ * run-orchestrator.ts (review round activity comment count). */
40
+ const reviewCommentShape = {
41
+ id: z.number().optional(),
42
+ body: z.string().optional(),
43
+ path: z.string().optional(),
44
+ line: z.number().optional(),
45
+ side: z.string().optional(),
46
+ startLine: z.number().optional(),
47
+ startSide: z.string().optional(),
48
+ commitId: z.string().optional(),
49
+ url: z.string().optional(),
50
+ diffHunk: z.string().optional(),
51
+ authorLogin: z.string().optional(),
52
+ };
53
+ /** Related-issue summary used by the issue-topology prompt sections. Produced
54
+ * by run-orchestrator.ts buildRelatedIssueContext; consumed by
55
+ * prompting/patchrelay.ts summarizeRelationEntries. */
56
+ const relatedIssueShape = {
57
+ linearIssueId: z.string().optional(),
58
+ issueKey: z.string().optional(),
59
+ title: z.string().optional(),
60
+ stateName: z.string().optional(),
61
+ stateType: z.string().optional(),
62
+ factoryState: z.string().optional(),
63
+ currentLinearState: z.string().optional(),
64
+ delegatedToPatchRelay: z.boolean().optional(),
65
+ hasOpenPr: z.boolean().optional(),
66
+ };
67
+ /** One check inside a CI snapshot (github-failure-context.ts
68
+ * mapCiSnapshotCheck). */
69
+ const ciSnapshotCheckShape = {
70
+ name: z.string().optional(),
71
+ status: z.string().optional(),
72
+ conclusion: z.string().optional(),
73
+ detailsUrl: z.string().optional(),
74
+ summary: z.string().optional(),
75
+ };
76
+ /** Settled CI snapshot. Produced by github-failure-context.ts
77
+ * buildCiSnapshotFromChecks (attached to settled_red_ci payloads by
78
+ * github-webhook-reactive-run.ts and to implicit ci_repair wakes by
79
+ * workflow-wake-resolver.ts); consumed by prompting/patchrelay.ts
80
+ * buildCiRepairContext. */
81
+ const ciSnapshotShape = {
82
+ headSha: z.string().optional(),
83
+ gateCheckName: z.string().optional(),
84
+ gateCheckStatus: z.string().optional(),
85
+ settledAt: z.string().optional(),
86
+ capturedAt: z.string().optional(),
87
+ failedChecks: z.array(z.object(ciSnapshotCheckShape)).optional(),
88
+ checks: z.array(z.object(ciSnapshotCheckShape)).optional(),
89
+ };
90
+ /** Queue-eviction incident detail (merge-queue-incident.ts
91
+ * QueueEvictionIncidentContext), parsed from the steward's check-run output. */
92
+ const queueIncidentContextShape = {
93
+ version: z.number().optional(),
94
+ failureClass: z.string().optional(),
95
+ baseSha: z.string().optional(),
96
+ prHeadSha: z.string().optional(),
97
+ queuePosition: z.number().optional(),
98
+ baseBranch: z.string().optional(),
99
+ branch: z.string().optional(),
100
+ issueKey: z.string().nullable().optional(),
101
+ conflictFiles: z.array(z.string()).optional(),
102
+ failedChecks: z.array(z.object({
103
+ name: z.string().optional(),
104
+ conclusion: z.string().optional(),
105
+ url: z.string().optional(),
106
+ })).optional(),
107
+ retryHistory: z.array(z.object({
108
+ at: z.string().optional(),
109
+ baseSha: z.string().optional(),
110
+ outcome: z.string().optional(),
111
+ })).optional(),
112
+ };
113
+ /** LEGACY: merge-queue context block read by prompting/patchrelay.ts
114
+ * appendQueueRepairContext. No current producer writes this field — it only
115
+ * appears in contexts persisted by older versions, so it stays in the schema
116
+ * for legacy-row compatibility. */
117
+ const mergeQueueContextShape = {
118
+ baseBranch: z.string().optional(),
119
+ baseSha: z.string().optional(),
120
+ mergeCommitSha: z.string().optional(),
121
+ checkRunUrl: z.string().optional(),
122
+ incidentSummary: z.string().optional(),
123
+ conflictingFiles: z.array(z.string()).optional(),
124
+ operatorHints: z.array(z.string()).optional(),
125
+ };
126
+ const runContextShape = {
127
+ // ── Wake framing ──────────────────────────────────────────────────
128
+ /** Why this wake exists. Produced by deriveSessionWakePlan (and by
129
+ * branch-upkeep context builders, operator-retry-event); consumed by
130
+ * prompting/patchrelay.ts (turn reason, follow-up prompt selection). Kept a
131
+ * free string: the value set spans wake reasons and event types and legacy
132
+ * rows carry values we no longer emit. */
133
+ wakeReason: z.string().optional(),
134
+ /** Requested run type inside a `delegated` / `completion_check_continue`
135
+ * payload. Free string because legacy payloads carry removed run types
136
+ * (e.g. "main_repair"); consumers narrow via parseRunType and fall back to
137
+ * "implementation". */
138
+ runType: z.string().optional(),
139
+ /** Producer tag ("operator_retry", "queue_health_monitor",
140
+ * "idle_reconciliation", ...). Produced by operator-retry-event.ts,
141
+ * queue-health-monitor.ts, idle-reconciliation.ts; diagnostic only. */
142
+ source: z.string().optional(),
143
+ // ── Human / orchestration context ─────────────────────────────────
144
+ /** Prompt guidance. Produced by buildBranchUpkeepContext /
145
+ * buildReviewFixBranchUpkeepContext, operator-retry-event.ts,
146
+ * queue-health-monitor.ts, webhooks/desired-stage-recorder.ts; consumed by
147
+ * prompting/patchrelay.ts buildHumanContextLines. */
148
+ promptContext: z.string().optional(),
149
+ /** Latest human instruction body, from `delegated` payloads
150
+ * (webhooks/desired-stage-recorder.ts); consumed by buildHumanContextLines. */
151
+ promptBody: z.string().optional(),
152
+ /** LEGACY: read by prompting/patchrelay.ts and
153
+ * linear-agent-activity-recovery.ts; no current producer. */
154
+ operatorPrompt: z.string().optional(),
155
+ /** LEGACY: read by prompting/patchrelay.ts and
156
+ * linear-agent-activity-recovery.ts; no current producer. */
157
+ userComment: z.string().optional(),
158
+ /** Recovered Linear agent-activity transcript. Produced by
159
+ * linear-agent-activity-recovery.ts summarizeLinearAgentActivities; consumed
160
+ * by prompting/patchrelay.ts buildHumanContextLines. */
161
+ linearAgentActivityContext: z.string().optional(),
162
+ /** Companion count for linearAgentActivityContext (same producer). */
163
+ linearAgentActivityCount: z.number().optional(),
164
+ /** Follow-up messages collected by deriveSessionWakePlan; consumed by
165
+ * prompting/patchrelay.ts and linear-agent-activity-recovery.ts. */
166
+ followUps: z.array(z.object(followUpEntryShape)).optional(),
167
+ /** Set by deriveSessionWakePlan when followUps is non-empty; consumed by
168
+ * prompting/patchrelay.ts shouldBuildFollowUpPrompt. */
169
+ followUpMode: z.boolean().optional(),
170
+ /** Produced by deriveSessionWakePlan; consumed by run-launcher.ts
171
+ * shouldCompactThread. */
172
+ followUpCount: z.number().optional(),
173
+ /** Produced by deriveSessionWakePlan for direct_reply events. */
174
+ directReplyMode: z.boolean().optional(),
175
+ // ── Completion-check continuation ─────────────────────────────────
176
+ /** Produced by deriveSessionWakePlan for completion_check_continue events. */
177
+ completionCheckMode: z.boolean().optional(),
178
+ /** Produced by deriveSessionWakePlan (from the event payload `summary`);
179
+ * consumed by prompting/patchrelay.ts buildFollowUpContextLines. */
180
+ completionCheckSummary: z.string().optional(),
181
+ // ── Dirty-worktree continuation (run-finalizer.ts
182
+ // continueDirtyRepairWorktree → completion_check_continue payload) ──
183
+ /** Consumed by run-launcher.ts shouldPreserveDirtyWorktreeBeforeLaunch and
184
+ * prompting/patchrelay.ts buildFollowUpContextLines. */
185
+ preserveDirtyWorktree: z.boolean().optional(),
186
+ dirtyWorktreeSummary: z.string().optional(),
187
+ dirtyWorktreeChangedPaths: z.array(z.string()).optional(),
188
+ dirtyWorktreeMergeInProgress: z.boolean().optional(),
189
+ // ── Replacement-PR facts (agent-input-service payloads merged by
190
+ // deriveSessionWakePlan; consumed by prompting/patchrelay.ts) ──
191
+ replacementPrRequired: z.boolean().optional(),
192
+ previousPrNumber: z.number().optional(),
193
+ previousPrUrl: z.string().optional(),
194
+ previousPrState: z.string().optional(),
195
+ previousPrHeadSha: z.string().optional(),
196
+ // ── Requested-changes / review fix ────────────────────────────────
197
+ /** Coalescing identity for review_changes_requested wakes. Produced by
198
+ * buildRequestedChangesWakeIdentity callers (run-wake-planner.ts,
199
+ * github-webhook-reactive-run.ts, operator-retry-event.ts,
200
+ * idle-reconciliation.ts); consumed by reactive-wake-keys.ts
201
+ * readRequestedChangesCoalesceKey for event coalescing. */
202
+ requestedChangesCoalesceKey: z.string().optional(),
203
+ requestedChangesHeadSha: z.string().optional(),
204
+ /** "branch_upkeep" is the only value ever produced (idle-reconciliation-
205
+ * helpers.ts buildBranchUpkeepContext, reactive-pr-state.ts
206
+ * buildReviewFixBranchUpkeepContext, run-failure-policy.ts); consumed by
207
+ * run-launcher.ts, run-failure-policy.ts resolveRetryRunType and
208
+ * prompting/patchrelay.ts resolveRequestedChangesMode. */
209
+ reviewFixMode: z.enum(["branch_upkeep"]).optional(),
210
+ /** Same producers/consumers as reviewFixMode (plus
211
+ * review_changes_requested payloads from operator-retry-event.ts and
212
+ * deriveSessionWakePlan branch selection). */
213
+ branchUpkeepRequired: z.boolean().optional(),
214
+ /** GitHub review id. Produced by github-webhook-reactive-run.ts and
215
+ * reactive-run-policy.ts hydrateRequestedChangesContext; consumed by
216
+ * prompting/patchrelay.ts buildStructuredReviewContext. */
217
+ reviewId: z.number().optional(),
218
+ reviewCommitId: z.string().optional(),
219
+ reviewUrl: z.string().optional(),
220
+ reviewerName: z.string().optional(),
221
+ reviewBody: z.string().optional(),
222
+ reviewComments: z.array(z.object(reviewCommentShape)).optional(),
223
+ /** Produced by reactive-run-policy.ts hydrateRequestedChangesContext. */
224
+ reviewContextStatus: z.enum(["fresh", "degraded"]).optional(),
225
+ reviewContextDegraded: z.boolean().optional(),
226
+ reviewContextDegradedReason: z.string().optional(),
227
+ /** Produced by reactive-run-policy.ts hydrateRequestedChangesContext. */
228
+ currentPrHeadSha: z.string().optional(),
229
+ // ── Failure provenance (CI / queue repair) ────────────────────────
230
+ /** Free-form failure tag: "queue_eviction" (merge-queue-incident.ts),
231
+ * GitHubFailureSource values (idle-reconciliation-helpers.ts
232
+ * buildFailureContext), "queue_eviction_missed" / "preemptive_conflict"
233
+ * (queue-health-monitor.ts), "merge_conflict_detected"
234
+ * (idle-reconciliation.ts); consumed by prompting/patchrelay.ts. */
235
+ failureReason: z.string().optional(),
236
+ failureSignature: z.string().optional(),
237
+ failureHeadSha: z.string().optional(),
238
+ /** Legacy alias for failureHeadSha still consulted by run-launcher.ts,
239
+ * run-orchestrator.ts and idle-reconciliation-helpers.ts
240
+ * isDuplicateRepairAttempt; also set by reactive-run-policy.ts
241
+ * hydrateRequestedChangesContext (current PR head). */
242
+ headSha: z.string().optional(),
243
+ /** Produced by buildBranchUpkeepContext / buildReviewFixBranchUpkeepContext
244
+ * (head that was failing/dirty at wake time). */
245
+ failingHeadSha: z.string().optional(),
246
+ // GitHubFailureContext fields (github-failure-context.ts), spread into
247
+ // contexts by buildFailureContext / workflow-wake-resolver.ts /
248
+ // operator-retry-event.ts; consumed by prompting/patchrelay.ts.
249
+ checkName: z.string().optional(),
250
+ checkUrl: z.string().optional(),
251
+ checkDetailsUrl: z.string().optional(),
252
+ jobName: z.string().optional(),
253
+ stepName: z.string().optional(),
254
+ summary: z.string().optional(),
255
+ annotations: z.array(z.string()).optional(),
256
+ workflowRunId: z.number().optional(),
257
+ workflowName: z.string().optional(),
258
+ repoFullName: z.string().optional(),
259
+ capturedAt: z.string().optional(),
260
+ /** Check classification from github-webhook-failure-context.ts
261
+ * resolveGitHubCheckClass, attached to settled_red_ci payloads. */
262
+ checkClass: z.string().optional(),
263
+ /** See ciSnapshotShape. */
264
+ ciSnapshot: z.object(ciSnapshotShape).optional(),
265
+ // ── Queue repair (merge-queue-incident.ts QueueRepairContext) ─────
266
+ incidentId: z.string().optional(),
267
+ incidentUrl: z.string().optional(),
268
+ incidentTitle: z.string().optional(),
269
+ incidentSummary: z.string().optional(),
270
+ incidentContext: z.object(queueIncidentContextShape).optional(),
271
+ /** LEGACY (see mergeQueueContextShape). */
272
+ mergeQueueContext: z.object(mergeQueueContextShape).optional(),
273
+ queuePosition: z.number().optional(),
274
+ /** Force a fresh PR head SHA on queue repair. Produced by
275
+ * queue-health-monitor.ts and operator-retry-event.ts; consumed by
276
+ * prompting/patchrelay.ts buildPublicationContract and
277
+ * queue-health-monitor.ts isDuplicateProbe. */
278
+ requiresFreshHead: z.boolean().optional(),
279
+ // ── Branch upkeep facts ───────────────────────────────────────────
280
+ /** Produced by buildBranchUpkeepContext / buildReviewFixBranchUpkeepContext;
281
+ * consumed by prompting/patchrelay.ts buildFollowUpContextLines. */
282
+ mergeStateStatus: z.string().optional(),
283
+ baseBranch: z.string().optional(),
284
+ /** Set when GitHub facts were refreshed immediately before launch;
285
+ * consumed by prompting/patchrelay.ts (fact-freshness line). */
286
+ githubFactsFresh: z.boolean().optional(),
287
+ // ── Issue topology (implementation coordination context) ──────────
288
+ /** Produced by run-orchestrator.ts buildRelatedIssueContext; consumed by
289
+ * prompting/patchrelay.ts buildIssueTopology / orchestration constraints. */
290
+ unresolvedBlockers: z.array(z.object(relatedIssueShape)).optional(),
291
+ childIssues: z.array(z.object(relatedIssueShape)).optional(),
292
+ /** LEGACY alias of childIssues, still read by prompting/patchrelay.ts. */
293
+ trackedDependents: z.array(z.object(relatedIssueShape)).optional(),
294
+ // ── Child-event facts (orchestration-parent-wake.ts payloads merged by
295
+ // deriveSessionWakePlan for child_changed / child_delivered /
296
+ // child_regressed) ──
297
+ childIssueId: z.string().optional(),
298
+ childIssueKey: z.string().optional(),
299
+ childTitle: z.string().optional(),
300
+ factoryState: z.string().optional(),
301
+ currentLinearState: z.string().optional(),
302
+ prNumber: z.number().optional(),
303
+ prState: z.string().optional(),
304
+ changeKind: z.string().optional(),
305
+ };
306
+ // Type source: no index signature, so reading an undeclared field is a
307
+ // compile-time error at every consumer.
308
+ const runContextTypeSchema = z.object(runContextShape);
309
+ // Parse source: tolerates unknown keys (legacy rows / merged event payloads —
310
+ // see module comment) while still failing loudly on mistyped known fields.
311
+ export const runContextSchema = z.looseObject(runContextShape);
312
+ export class RunContextParseError extends Error {
313
+ constructor(message, options) {
314
+ super(message, options);
315
+ this.name = "RunContextParseError";
316
+ }
317
+ }
318
+ /**
319
+ * Validate an already-parsed value as a run context. FAILS LOUDLY
320
+ * (RunContextParseError) on non-object values or mistyped known fields —
321
+ * that is the point of D1; callers at legacy-row boundaries may catch,
322
+ * warn, and treat the context as absent.
323
+ */
324
+ export function parseRunContextValue(value, where = "run context") {
325
+ const result = runContextSchema.safeParse(value);
326
+ if (!result.success) {
327
+ throw new RunContextParseError(`Invalid ${where}: ${result.error.issues
328
+ .map((issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`)
329
+ .join("; ")}`, { cause: result.error });
330
+ }
331
+ return result.data;
332
+ }
333
+ /**
334
+ * Parse a stored run-context JSON string. Returns undefined for
335
+ * null/undefined/empty input. FAILS LOUDLY (RunContextParseError) on
336
+ * malformed JSON or schema violations — no silent fallback.
337
+ */
338
+ export function parseRunContext(json, where = "run context") {
339
+ if (json === null || json === undefined || json.trim() === "")
340
+ return undefined;
341
+ let parsed;
342
+ try {
343
+ parsed = JSON.parse(json);
344
+ }
345
+ catch (error) {
346
+ throw new RunContextParseError(`Malformed ${where} JSON: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
347
+ }
348
+ return parseRunContextValue(parsed, where);
349
+ }
350
+ /**
351
+ * Boundary helper for sites that ingest possibly-old DB rows: parse loudly,
352
+ * but on failure report through `warn` and degrade to "no context" instead of
353
+ * unwinding the caller. The parse itself never silently coerces.
354
+ */
355
+ export function parseRunContextOrWarn(json, warn, where = "run context") {
356
+ try {
357
+ return parseRunContext(json, where);
358
+ }
359
+ catch (error) {
360
+ warn(error instanceof Error ? error.message : String(error));
361
+ return undefined;
362
+ }
363
+ }
364
+ /**
365
+ * Non-throwing variant for boundaries inside the persistence layer where no
366
+ * logger is plumbed (workflow-wake-resolver assembling implicit wake contexts
367
+ * from reconciliation columns): a value the schema rejects degrades to
368
+ * "no context", which was already the legacy behavior for malformed JSON in
369
+ * those columns. Everywhere a logger exists, prefer parseRunContextOrWarn so
370
+ * the failure is at least observable.
371
+ */
372
+ export function tryParseRunContextValue(value) {
373
+ const result = runContextSchema.safeParse(value);
374
+ return result.success ? result.data : undefined;
375
+ }
376
+ /**
377
+ * Serialize a run context for storage. Round-trips through the schema so
378
+ * writers cannot persist a shape the parser would reject.
379
+ */
380
+ export function serializeRunContext(context, where = "run context") {
381
+ return JSON.stringify(parseRunContextValue(context, where));
382
+ }
@@ -2,6 +2,7 @@ import { buildRunFailureActivity } from "./linear-session-reporting.js";
2
2
  import { getRemainingZombieRecoveryDelayMs, getZombieRecoveryBudget } from "./run-budgets.js";
3
3
  import { resolvePostRunFactoryState } from "./run-completion-policy.js";
4
4
  import { isRequestedChangesRunType } from "./reactive-pr-state.js";
5
+ import { serializeRunContext } from "./run-context.js";
5
6
  import { settleRun } from "./run-settlement.js";
6
7
  const WRITER = "run-failure-policy";
7
8
  // Roll back the attempt counter consumed by the interrupted run and clear the
@@ -431,7 +432,7 @@ export class RunFailurePolicy {
431
432
  projectId: run.projectId,
432
433
  linearIssueId: run.linearIssueId,
433
434
  pendingRunType: retryRunType,
434
- pendingRunContextJson: retryContext ? JSON.stringify(retryContext) : null,
435
+ pendingRunContextJson: retryContext ? serializeRunContext(retryContext, "requested-changes retry context") : null,
435
436
  },
436
437
  });
437
438
  this.feed?.publish({
@@ -1,3 +1,5 @@
1
+ import { parseIssueSessionEventOrWarn } from "./issue-session-events.js";
2
+ import { assertNever } from "./utils.js";
1
3
  import { CLEARED_FAILURE_PROVENANCE } from "./failure-provenance.js";
2
4
  import { buildStageReport, countEventMethods } from "./run-reporting.js";
3
5
  import { buildRunCompletedActivity, buildRunFailureActivity } from "./linear-session-reporting.js";
@@ -8,17 +10,6 @@ import { inspectGitWorktreeStatus, isRepairRunType } from "./git-worktree-status
8
10
  import { buildRunOutcomeSummary } from "./run-outcome-summary.js";
9
11
  import { settleRun } from "./run-settlement.js";
10
12
  const WRITER = "run-finalizer";
11
- function parseEventJson(eventJson) {
12
- if (!eventJson)
13
- return undefined;
14
- try {
15
- const parsed = JSON.parse(eventJson);
16
- return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : undefined;
17
- }
18
- catch {
19
- return undefined;
20
- }
21
- }
22
13
  function buildRunSummaryJson(report, outcomeSummary) {
23
14
  return JSON.stringify({
24
15
  latestAssistantMessage: report.assistantMessages.at(-1) ?? null,
@@ -31,15 +22,16 @@ function summarizePromptDeliveryEvents(events, run) {
31
22
  let delivered = 0;
32
23
  let failed = 0;
33
24
  for (const event of events) {
34
- if (event.eventType !== "prompt_delivered")
25
+ // Boundary over DB rows: a malformed payload degrades to "not counted".
26
+ const typed = parseIssueSessionEventOrWarn(event);
27
+ if (typed?.eventType !== "prompt_delivered")
35
28
  continue;
36
- const payload = parseEventJson(event.eventJson);
37
- if (payload?.runId !== run.id)
29
+ if (typed.payload?.runId !== run.id)
38
30
  continue;
39
- if (payload.status === "delivered") {
31
+ if (typed.payload.status === "delivered") {
40
32
  delivered += 1;
41
33
  }
42
- else if (payload.status === "delivery_failed") {
34
+ else if (typed.payload.status === "delivery_failed") {
43
35
  failed += 1;
44
36
  }
45
37
  }
@@ -94,32 +86,56 @@ export class RunFinalizer {
94
86
  ...(params.latestAssistantSummary ? { latestAssistantSummary: params.latestAssistantSummary } : {}),
95
87
  };
96
88
  const wakeEvent = this.resolveConsumedWakeEvent(params.run);
97
- const payload = parseEventJson(wakeEvent?.eventJson);
98
- if (!wakeEvent || !payload) {
89
+ if (!wakeEvent) {
99
90
  return facts;
100
91
  }
101
- switch (wakeEvent.eventType) {
92
+ // Boundary over DB rows: a malformed wake payload degrades to bare facts.
93
+ const typed = parseIssueSessionEventOrWarn(wakeEvent, (message) => this.logger.warn({ runId: params.run.id, eventId: wakeEvent.id }, message));
94
+ if (!typed?.payload) {
95
+ return facts;
96
+ }
97
+ switch (typed.eventType) {
102
98
  case "review_changes_requested":
103
99
  return {
104
100
  ...facts,
105
- ...(typeof payload.reviewerName === "string" ? { reviewerName: payload.reviewerName } : {}),
106
- ...(typeof payload.reviewBody === "string" ? { reviewSummary: payload.reviewBody } : {}),
101
+ ...(typed.payload.reviewerName !== undefined ? { reviewerName: typed.payload.reviewerName } : {}),
102
+ ...(typed.payload.reviewBody !== undefined ? { reviewSummary: typed.payload.reviewBody } : {}),
107
103
  };
108
104
  case "settled_red_ci":
109
105
  return {
110
106
  ...facts,
111
- ...(typeof payload.jobName === "string"
112
- ? { failingCheckName: payload.jobName }
113
- : typeof payload.checkName === "string" ? { failingCheckName: payload.checkName } : {}),
114
- ...(typeof payload.summary === "string" ? { failureSummary: payload.summary } : {}),
107
+ ...(typed.payload.jobName !== undefined
108
+ ? { failingCheckName: typed.payload.jobName }
109
+ : typed.payload.checkName !== undefined ? { failingCheckName: typed.payload.checkName } : {}),
110
+ ...(typed.payload.summary !== undefined ? { failureSummary: typed.payload.summary } : {}),
115
111
  };
116
112
  case "merge_steward_incident":
117
113
  return {
118
114
  ...facts,
119
- ...(typeof payload.incidentSummary === "string" ? { queueIncidentSummary: payload.incidentSummary } : {}),
115
+ ...(typed.payload.incidentSummary !== undefined ? { queueIncidentSummary: typed.payload.incidentSummary } : {}),
120
116
  };
121
- default:
117
+ case "delegated":
118
+ case "delegation_observed":
119
+ case "child_changed":
120
+ case "child_delivered":
121
+ case "child_regressed":
122
+ case "direct_reply":
123
+ case "completion_check_continue":
124
+ case "followup_prompt":
125
+ case "followup_comment":
126
+ case "prompt_delivered":
127
+ case "self_comment":
128
+ case "operator_prompt":
129
+ case "stop_requested":
130
+ case "operator_closed":
131
+ case "undelegated":
132
+ case "issue_removed":
133
+ case "pr_closed":
134
+ case "pr_merged":
135
+ case "run_released_authority":
122
136
  return facts;
137
+ default:
138
+ return assertNever(typed, "Unhandled issue session event in run outcome facts");
123
139
  }
124
140
  }
125
141
  buildOutcomeSummary(params) {
@@ -318,7 +334,7 @@ export class RunFinalizer {
318
334
  runType: params.run.runType,
319
335
  summary: message,
320
336
  preserveDirtyWorktree: true,
321
- dirtyWorktreeSummary: params.status.summary,
337
+ ...(params.status.summary !== undefined ? { dirtyWorktreeSummary: params.status.summary } : {}),
322
338
  dirtyWorktreeChangedPaths: params.status.changedPaths,
323
339
  dirtyWorktreeMergeInProgress: params.status.mergeInProgress,
324
340
  }),
@@ -12,7 +12,7 @@ function sanitizePathSegment(value) {
12
12
  return value.replace(/[^a-zA-Z0-9._-]+/g, "-");
13
13
  }
14
14
  function shouldCompactThread(issue, threadGeneration, context) {
15
- const followUpCount = typeof context?.followUpCount === "number" ? context.followUpCount : 0;
15
+ const followUpCount = context?.followUpCount ?? 0;
16
16
  return issue.threadId !== undefined
17
17
  && (threadGeneration ?? 0) >= 4
18
18
  && followUpCount >= 4;
@@ -125,10 +125,8 @@ export class RunLauncher {
125
125
  ...(params.sourceHeadSha ? { sourceHeadSha: params.sourceHeadSha } : {}),
126
126
  promptText: params.prompt,
127
127
  });
128
- const failureHeadSha = typeof params.effectiveContext?.failureHeadSha === "string"
129
- ? params.effectiveContext.failureHeadSha
130
- : typeof params.effectiveContext?.headSha === "string" ? params.effectiveContext.headSha : undefined;
131
- const failureSignature = typeof params.effectiveContext?.failureSignature === "string" ? params.effectiveContext.failureSignature : undefined;
128
+ const failureHeadSha = params.effectiveContext?.failureHeadSha ?? params.effectiveContext?.headSha;
129
+ const failureSignature = params.effectiveContext?.failureSignature;
132
130
  const claimUpdate = {
133
131
  projectId: params.item.projectId,
134
132
  linearIssueId: params.item.issueId,
@@ -134,7 +134,7 @@ export class RunOrchestrator {
134
134
  this.runNotificationHandler = new RunNotificationHandler(config, db, logger, this.linearSync, this.runFinalizer, this.threadPorts.readThreadWithRetry, this.leasePorts.withHeldLease, this.leasePorts.heartbeatLease, this.leasePorts.releaseLease, feed, { interruptTurn: (options) => codex.interruptTurn(options) });
135
135
  this.runFailurePolicy = new RunFailurePolicy(db, logger, this.linearSync, this.leasePorts.withHeldLease, this.leasePorts.releaseLease, (lease, issue, runType, context, dedupeScope) => this.appendWakeEventWithLease(lease, issue, runType, context, dedupeScope), this.wakeDispatcher, this.recoveryPorts.restoreIdleWorktree, this.runCompletionPolicy, (projectId) => this.config.projects.find((project) => project.id === projectId), feed);
136
136
  this.runReconciler = new RunReconciler(db, logger, linearProvider, this.linearSync, this.runFailurePolicy, this.runFinalizer, this.leasePorts.withHeldLease, this.leasePorts.releaseLease, this.threadPorts.readThreadWithRetry, (projectId) => this.config.projects.find((project) => project.id === projectId)?.github?.repoFullName, feed, telemetry);
137
- this.runWakePlanner = new RunWakePlanner(db);
137
+ this.runWakePlanner = new RunWakePlanner(db, logger);
138
138
  this.linearIssueProjection = new LinearIssueProjectionService(db, linearProvider, logger);
139
139
  this.runAdmission = new RunAdmissionController(db, this.linearIssueProjection);
140
140
  this.idleReconciler = new IdleIssueReconciler(db, config, this.wakeDispatcher, logger, feed, undefined, (issue) => this.linearSync.syncSession(issue), linearProvider);
@@ -419,11 +419,9 @@ export class RunOrchestrator {
419
419
  const effectiveContext = coordinationContext
420
420
  ? { ...coordinationContext, ...baseContextWithRecoveredActivity }
421
421
  : baseContextWithRecoveredActivity;
422
- const sourceHeadSha = typeof effectiveContext?.failureHeadSha === "string"
423
- ? effectiveContext.failureHeadSha
424
- : typeof effectiveContext?.headSha === "string"
425
- ? effectiveContext.headSha
426
- : issue.prHeadSha;
422
+ const sourceHeadSha = effectiveContext?.failureHeadSha
423
+ ?? effectiveContext?.headSha
424
+ ?? issue.prHeadSha;
427
425
  const budgetExceeded = this.runWakePlanner.budgetExceeded(issue, project, runType, isRequestedChangesRunType);
428
426
  if (budgetExceeded) {
429
427
  this.emitRunSkipped(item, "budget_exceeded", issue, { runType });
@@ -508,11 +506,11 @@ export class RunOrchestrator {
508
506
  this.logger.info({ issueKey: issue.issueKey, runType, threadId, turnId }, `Started ${runType} run`);
509
507
  // Emit Linear activity + plan
510
508
  const freshIssue = this.db.issues.getIssue(item.projectId, item.issueId) ?? issue;
511
- const reviewComments = Array.isArray(effectiveContext?.reviewComments) ? effectiveContext.reviewComments : undefined;
509
+ const reviewComments = effectiveContext?.reviewComments;
512
510
  const reviewRoundActivity = runType === "review_fix"
513
511
  ? buildReviewRoundStartedActivity({
514
512
  round: Math.max(1, freshIssue.reviewFixAttempts),
515
- ...(typeof effectiveContext?.reviewerName === "string" ? { reviewerName: effectiveContext.reviewerName } : {}),
513
+ ...(effectiveContext?.reviewerName !== undefined ? { reviewerName: effectiveContext.reviewerName } : {}),
516
514
  ...(reviewComments ? { commentCount: reviewComments.length } : {}),
517
515
  ...(typeof sourceHeadSha === "string" ? { headSha: sourceHeadSha } : {}),
518
516
  })