patchrelay 0.78.1 → 0.80.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,6 +1,6 @@
1
1
  import { buildFollowupStatusActivity, buildNonActionableFollowupActivity, buildPromptDeliveryFailedActivity, buildPromptDeliveredThought, } from "./linear-session-reporting.js";
2
2
  import { deriveIssueStatusNote } from "./status-note.js";
3
- import { extractLatestAssistantSummary } from "./issue-session-events.js";
3
+ import { extractLatestAssistantSummary, } from "./issue-session-events.js";
4
4
  import { dirtyWorktreeEventPayload, inspectGitWorktreeStatus } from "./git-worktree-status.js";
5
5
  const WRITER = "agent-input-service";
6
6
  export class AgentInputService {
@@ -1,9 +1,8 @@
1
+ import { deriveIssueExecutionState } from "./issue-execution-state.js";
1
2
  export function resolveAwaitingInputReason(params) {
2
- if (params.issue.factoryState !== "awaiting_input") {
3
- return undefined;
4
- }
5
- if (params.latestRun?.completionCheckOutcome === "needs_input") {
6
- return "completion_check_question";
7
- }
8
- return "paused_local_work";
3
+ const state = deriveIssueExecutionState({
4
+ factoryState: params.issue.factoryState,
5
+ latestRunCompletionCheckOutcome: params.latestRun?.completionCheckOutcome,
6
+ });
7
+ return state.kind === "waiting_input" ? state.reason : undefined;
9
8
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "service": "patchrelay",
3
- "version": "0.78.1",
4
- "commit": "6907b2e338b5",
5
- "builtAt": "2026-06-10T20:42:13.572Z"
3
+ "version": "0.80.0",
4
+ "commit": "61fe1041ab2b",
5
+ "builtAt": "2026-06-10T21:38:37.466Z"
6
6
  }
@@ -64,6 +64,6 @@ export function dirtyWorktreeEventPayload(status) {
64
64
  mergeInProgress: status.mergeInProgress,
65
65
  unmergedPaths: status.unmergedPaths,
66
66
  changedPaths: status.changedPaths,
67
- summary: status.summary,
67
+ ...(status.summary !== undefined ? { summary: status.summary } : {}),
68
68
  };
69
69
  }
@@ -46,10 +46,8 @@ export function getGateCheckNames(project) {
46
46
  * to a stale one without the timestamp check.
47
47
  */
48
48
  export function isDuplicateRepairAttempt(issue, context) {
49
- const signature = typeof context?.failureSignature === "string" ? context.failureSignature : undefined;
50
- const headSha = typeof context?.failureHeadSha === "string"
51
- ? context.failureHeadSha
52
- : typeof context?.headSha === "string" ? context.headSha : undefined;
49
+ const signature = context?.failureSignature;
50
+ const headSha = context?.failureHeadSha ?? context?.headSha;
53
51
  if (!signature)
54
52
  return false;
55
53
  if (issue.lastAttemptedFailureSignature !== signature)
@@ -6,6 +6,7 @@ import { buildBranchUpkeepContext, buildFailureContext, getGateCheckNames, hasCo
6
6
  import { resolveMergeQueueProtocol } from "./merge-queue-protocol.js";
7
7
  import { deriveGateCheckStatusFromRollup } from "./github-rollup.js";
8
8
  import { deriveIssueSessionReactiveIntent } from "./issue-session.js";
9
+ import { serializeRunContext } from "./run-context.js";
9
10
  import { buildClosedPrCleanupFields, resolveClosedPrDisposition } from "./pr-state.js";
10
11
  import { getReviewFixBudget } from "./run-budgets.js";
11
12
  import { queueSettledOrchestrationIssue } from "./orchestration-parent-wake.js";
@@ -348,13 +349,13 @@ export class IdleIssueReconciler {
348
349
  this.wakeDispatcher.recordEventAndDispatch(issue.projectId, issue.linearIssueId, {
349
350
  eventType,
350
351
  ...(context || requestedChangesIdentity ? {
351
- eventJson: JSON.stringify({
352
+ eventJson: serializeRunContext({
352
353
  ...context,
353
354
  ...(requestedChangesIdentity ? {
354
355
  requestedChangesCoalesceKey: requestedChangesIdentity.coalesceKey,
355
356
  ...(requestedChangesIdentity.headSha ? { requestedChangesHeadSha: requestedChangesIdentity.headSha } : {}),
356
357
  } : {}),
357
- }),
358
+ }, "reconciliation wake context"),
358
359
  } : {}),
359
360
  dedupeKey,
360
361
  });
@@ -0,0 +1,139 @@
1
+ import { hasOpenPr } from "./pr-state.js";
2
+ /** Run statuses that may legally occupy an issue's active-run slot. */
3
+ const ACTIVE_RUN_STATUSES = new Set(["queued", "running"]);
4
+ export function deriveIssueExecutionState(params) {
5
+ const factoryState = params.factoryState;
6
+ // Undelegation pauses automation for any non-finished issue and outranks
7
+ // every other answer (including an active run, which keeps executing but
8
+ // is reported as paused-with-downstream-continuation where relevant).
9
+ if (params.delegatedToPatchRelay === false
10
+ && factoryState !== "done"
11
+ && factoryState !== "failed"
12
+ && factoryState !== "escalated") {
13
+ const downstreamMayContinue = factoryState === "awaiting_queue"
14
+ || (hasOpenPr(params.prNumber, params.prState) && params.prReviewState === "approved");
15
+ return { kind: "undelegated", downstreamMayContinue };
16
+ }
17
+ // Active run facts win next — the issue is moving (or claims to be).
18
+ if (params.activeRunType || params.activeRunId !== undefined) {
19
+ const run = {
20
+ ...(params.activeRunId !== undefined ? { activeRunId: params.activeRunId } : {}),
21
+ ...(params.activeRunType ? { runType: params.activeRunType } : {}),
22
+ phase: resolveRunPhase(params),
23
+ };
24
+ // `done` + active run is a legitimate finalizing window (the post-run
25
+ // finalizer advances factoryState before clearing the slot), and
26
+ // `awaiting_input` + active run is a resumed reply turn. `failed` /
27
+ // `escalated` should never hold a slot: settleRun clears it before the
28
+ // terminal transition lands.
29
+ if (factoryState === "failed" || factoryState === "escalated") {
30
+ return {
31
+ kind: "inconsistent",
32
+ description: `terminal factoryState "${factoryState}" still holds an active run slot`,
33
+ run,
34
+ };
35
+ }
36
+ if (params.activeRunStatus !== undefined && !ACTIVE_RUN_STATUSES.has(params.activeRunStatus)) {
37
+ return {
38
+ kind: "inconsistent",
39
+ description: `active run slot points at a ${params.activeRunStatus} run`,
40
+ run,
41
+ };
42
+ }
43
+ return { kind: "running", run };
44
+ }
45
+ if (params.orchestrationSettleUntil) {
46
+ const settleAt = Date.parse(params.orchestrationSettleUntil);
47
+ if (Number.isFinite(settleAt) && settleAt > (params.now ?? Date.now())) {
48
+ return { kind: "settling", settleUntil: params.orchestrationSettleUntil };
49
+ }
50
+ }
51
+ const blockedByKeys = (params.blockedByKeys ?? []).filter((value) => value.trim().length > 0);
52
+ if (blockedByKeys.length > 0) {
53
+ return { kind: "blocked", blockedByKeys };
54
+ }
55
+ switch (factoryState) {
56
+ case "awaiting_input":
57
+ return {
58
+ kind: "waiting_input",
59
+ reason: params.latestRunCompletionCheckOutcome === "needs_input"
60
+ ? "completion_check_question"
61
+ : "paused_local_work",
62
+ };
63
+ case "changes_requested":
64
+ return { kind: "awaiting_followup", followup: "review_fix" };
65
+ case "repairing_ci":
66
+ return { kind: "awaiting_followup", followup: "ci_repair", checkName: params.latestFailureCheckName };
67
+ case "repairing_queue":
68
+ return { kind: "awaiting_followup", followup: "queue_repair" };
69
+ case "awaiting_queue":
70
+ return { kind: "idle_awaiting_external", waitingOn: "merge_queue" };
71
+ case "done":
72
+ return { kind: "terminal", outcome: "done" };
73
+ case "failed":
74
+ return { kind: "terminal", outcome: "failed" };
75
+ case "escalated":
76
+ return { kind: "terminal", outcome: "escalated" };
77
+ default:
78
+ break;
79
+ }
80
+ // delegated / implementing / pr_open / deploying: the wait, if any, is
81
+ // derived from live PR truth.
82
+ if (params.prCheckStatus === "failed" || params.prCheckStatus === "failure") {
83
+ return { kind: "idle_awaiting_external", waitingOn: "ci_failure", checkName: params.latestFailureCheckName };
84
+ }
85
+ if (params.prReviewState === "changes_requested") {
86
+ if (params.prCheckStatus === "passed" || params.prCheckStatus === "success") {
87
+ if (params.prHeadSha
88
+ && params.lastBlockingReviewHeadSha
89
+ && params.prHeadSha !== params.lastBlockingReviewHeadSha) {
90
+ return { kind: "idle_awaiting_external", waitingOn: "review_of_new_head" };
91
+ }
92
+ return { kind: "idle_awaiting_external", waitingOn: "blocking_review_same_head" };
93
+ }
94
+ return { kind: "idle_awaiting_external", waitingOn: "review_feedback" };
95
+ }
96
+ if (params.prReviewState === "approved") {
97
+ return { kind: "idle_awaiting_external", waitingOn: "downstream_automation" };
98
+ }
99
+ if (hasOpenPr(params.prNumber, params.prState)) {
100
+ return { kind: "idle_awaiting_external", waitingOn: "external_review" };
101
+ }
102
+ if (params.pendingRunType) {
103
+ return { kind: "ready", pendingRunType: params.pendingRunType };
104
+ }
105
+ return { kind: "idle" };
106
+ }
107
+ function resolveRunPhase(params) {
108
+ if (hasOpenPr(params.prNumber, params.prState) && (params.factoryState === "pr_open" || params.factoryState === "awaiting_queue")) {
109
+ return "finalizing_published_pr";
110
+ }
111
+ if (params.factoryState === "done") {
112
+ return "finalizing_merged_change";
113
+ }
114
+ return "working";
115
+ }
116
+ /** Build the deriver input from full records (issue row + resolved runs). */
117
+ export function issueExecutionStateInputFromRecords(issue, extras) {
118
+ return {
119
+ delegatedToPatchRelay: issue.delegatedToPatchRelay,
120
+ factoryState: issue.factoryState,
121
+ activeRunId: issue.activeRunId,
122
+ activeRunType: extras?.activeRun?.runType,
123
+ activeRunStatus: extras?.activeRun?.status,
124
+ latestRunCompletionCheckOutcome: extras?.latestRun?.completionCheckOutcome,
125
+ pendingRunType: issue.pendingRunType,
126
+ blockedByKeys: extras?.blockedByKeys,
127
+ orchestrationSettleUntil: issue.orchestrationSettleUntil,
128
+ prNumber: issue.prNumber,
129
+ prState: issue.prState,
130
+ prHeadSha: issue.prHeadSha,
131
+ prReviewState: issue.prReviewState,
132
+ prCheckStatus: issue.prCheckStatus,
133
+ lastBlockingReviewHeadSha: issue.lastBlockingReviewHeadSha,
134
+ latestFailureCheckName: issue.lastGitHubFailureCheckName,
135
+ };
136
+ }
137
+ export function deriveIssueExecutionStateFromRecords(issue, extras) {
138
+ return deriveIssueExecutionState(issueExecutionStateInputFromRecords(issue, extras));
139
+ }
@@ -1,4 +1,171 @@
1
+ import { z } from "zod";
1
2
  import { sanitizeOperatorFacingText } from "./presentation-text.js";
3
+ import { runContextSchema } from "./run-context.js";
4
+ import { assertNever } from "./utils.js";
5
+ // ─── Typed session-event payloads (plan §D2) ──────────────────────────
6
+ //
7
+ // Each eventType gets a typed payload; `parseIssueSessionEvent` is the parse
8
+ // boundary over the stringly DB storage (event_json stays JSON text). The
9
+ // same doctrine as D1 applies: malformed payloads fail loudly at parse, and
10
+ // boundary callers that iterate possibly-old DB rows degrade gracefully via
11
+ // `parseIssueSessionEventOrWarn`. All payload schemas are loose objects:
12
+ // legacy rows carry fields newer code no longer writes, and wake payloads are
13
+ // merged wholesale into the run context (which tolerates unknown keys too).
14
+ /** Human input payload for direct_reply / followup_prompt / followup_comment
15
+ * / operator_prompt. Produced by agent-input-service.ts,
16
+ * github-pr-comment-handler.ts and webhooks/agent-session-handler.ts;
17
+ * consumed by deriveSessionWakePlan (followUps + replacement-PR facts). */
18
+ const inputMessagePayloadSchema = z.looseObject({
19
+ text: z.string().optional(),
20
+ body: z.string().optional(),
21
+ author: z.string().optional(),
22
+ source: z.string().optional(),
23
+ operatorSource: z.string().optional(),
24
+ replacementPrRequired: z.boolean().optional(),
25
+ previousPrNumber: z.number().optional(),
26
+ previousPrUrl: z.string().optional(),
27
+ previousPrState: z.string().optional(),
28
+ previousPrHeadSha: z.string().optional(),
29
+ });
30
+ /** Produced by agent-input-service.ts after steering a running turn;
31
+ * consumed by run-finalizer.ts summarizePromptDeliveryEvents. */
32
+ const promptDeliveredPayloadSchema = z.looseObject({
33
+ source: z.string().optional(),
34
+ runId: z.number().optional(),
35
+ runType: z.string().optional(),
36
+ status: z.enum(["delivered", "delivery_failed"]).optional(),
37
+ body: z.string().optional(),
38
+ primitive: z.string().optional(),
39
+ threadId: z.string().optional(),
40
+ turnId: z.string().optional(),
41
+ error: z.string().optional(),
42
+ });
43
+ /** Produced by agent-input-service.ts / service-issue-actions.ts /
44
+ * webhooks/agent-session-handler.ts; consumed by status-note.ts. */
45
+ const stopRequestedPayloadSchema = z.looseObject({
46
+ body: z.string().optional(),
47
+ source: z.string().optional(),
48
+ author: z.string().optional(),
49
+ // Dirty-worktree facts from git-worktree-status.ts dirtyWorktreeEventPayload.
50
+ summary: z.string().optional(),
51
+ dirtyWorktree: z.boolean().optional(),
52
+ mergeInProgress: z.boolean().optional(),
53
+ unmergedPaths: z.array(z.string()).optional(),
54
+ changedPaths: z.array(z.string()).optional(),
55
+ });
56
+ /** Produced by webhooks/desired-stage-recorder.ts; consumed by status-note.ts. */
57
+ const undelegatedPayloadSchema = z.looseObject({
58
+ // Dirty-worktree facts from git-worktree-status.ts dirtyWorktreeEventPayload.
59
+ summary: z.string().optional(),
60
+ dirtyWorktree: z.boolean().optional(),
61
+ mergeInProgress: z.boolean().optional(),
62
+ unmergedPaths: z.array(z.string()).optional(),
63
+ changedPaths: z.array(z.string()).optional(),
64
+ });
65
+ /** Produced by service-issue-actions.ts / cli/data.ts when an operator
66
+ * force-closes an issue. */
67
+ const operatorClosedPayloadSchema = z.looseObject({
68
+ terminalState: z.enum(["done", "failed"]).optional(),
69
+ reason: z.string().optional(),
70
+ });
71
+ /** Audit payloads (delegation-audit.ts) and marker events carry free-form
72
+ * diagnostic objects; nothing branches on their fields. */
73
+ const freeFormPayloadSchema = z.looseObject({});
74
+ export class IssueSessionEventPayloadError extends Error {
75
+ constructor(message, options) {
76
+ super(message, options);
77
+ this.name = "IssueSessionEventPayloadError";
78
+ }
79
+ }
80
+ function parsePayloadJson(event) {
81
+ if (!event.eventJson)
82
+ return undefined;
83
+ let parsed;
84
+ try {
85
+ parsed = JSON.parse(event.eventJson);
86
+ }
87
+ catch (error) {
88
+ throw new IssueSessionEventPayloadError(`Malformed ${event.eventType} session-event payload JSON: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
89
+ }
90
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
91
+ throw new IssueSessionEventPayloadError(`Malformed ${event.eventType} session-event payload: expected a JSON object, got ${Array.isArray(parsed) ? "array" : typeof parsed}`);
92
+ }
93
+ return parsed;
94
+ }
95
+ function parseWithSchema(event, schema) {
96
+ const raw = parsePayloadJson(event);
97
+ if (raw === undefined)
98
+ return undefined;
99
+ const result = schema.safeParse(raw);
100
+ if (!result.success) {
101
+ throw new IssueSessionEventPayloadError(`Invalid ${event.eventType} session-event payload: ${result.error.issues
102
+ .map((issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`)
103
+ .join("; ")}`, { cause: result.error });
104
+ }
105
+ return result.data;
106
+ }
107
+ /**
108
+ * Parse boundary for session events: returns the typed union member for the
109
+ * event. FAILS LOUDLY (IssueSessionEventPayloadError) on malformed JSON or
110
+ * schema violations — boundary callers over possibly-old DB rows should use
111
+ * `parseIssueSessionEventOrWarn` instead.
112
+ */
113
+ export function parseIssueSessionEvent(event) {
114
+ const eventType = event.eventType;
115
+ switch (eventType) {
116
+ case "delegated":
117
+ case "child_changed":
118
+ case "child_delivered":
119
+ case "child_regressed":
120
+ case "completion_check_continue":
121
+ case "review_changes_requested":
122
+ case "settled_red_ci":
123
+ case "merge_steward_incident":
124
+ return { eventType, payload: parseWithSchema(event, runContextSchema) };
125
+ case "direct_reply":
126
+ case "followup_prompt":
127
+ case "followup_comment":
128
+ case "operator_prompt":
129
+ return { eventType, payload: parseWithSchema(event, inputMessagePayloadSchema) };
130
+ case "prompt_delivered":
131
+ return { eventType, payload: parseWithSchema(event, promptDeliveredPayloadSchema) };
132
+ case "stop_requested":
133
+ return { eventType, payload: parseWithSchema(event, stopRequestedPayloadSchema) };
134
+ case "operator_closed":
135
+ return { eventType, payload: parseWithSchema(event, operatorClosedPayloadSchema) };
136
+ case "undelegated":
137
+ return { eventType, payload: parseWithSchema(event, undelegatedPayloadSchema) };
138
+ case "delegation_observed":
139
+ case "self_comment":
140
+ case "issue_removed":
141
+ case "pr_closed":
142
+ case "pr_merged":
143
+ case "run_released_authority":
144
+ return { eventType, payload: parseWithSchema(event, freeFormPayloadSchema) };
145
+ default:
146
+ // Also reached at runtime for event_type values written by versions
147
+ // that no longer exist in the union; the OrWarn boundary degrades them.
148
+ return assertNever(eventType, "Unknown issue session event type");
149
+ }
150
+ }
151
+ /**
152
+ * Boundary variant: parse loudly, but degrade a bad payload to `undefined`
153
+ * (and an unknown stored event type to `undefined` entirely) after reporting
154
+ * through `warn`. The parse itself never silently coerces.
155
+ */
156
+ export function parseIssueSessionEventOrWarn(event, warn) {
157
+ try {
158
+ return parseIssueSessionEvent(event);
159
+ }
160
+ catch (error) {
161
+ warn?.(error instanceof Error ? error.message : String(error));
162
+ if (error instanceof IssueSessionEventPayloadError) {
163
+ // The event type itself is known — keep the event, drop the payload.
164
+ return { eventType: event.eventType, payload: undefined };
165
+ }
166
+ return undefined;
167
+ }
168
+ }
2
169
  const TERMINAL_SESSION_EVENTS = new Set([
3
170
  "stop_requested",
4
171
  "operator_closed",
@@ -20,7 +187,7 @@ const RUN_TYPES = new Set(["implementation", "review_fix", "branch_upkeep", "ci_
20
187
  function parseRunType(value) {
21
188
  return typeof value === "string" && RUN_TYPES.has(value) ? value : undefined;
22
189
  }
23
- export function deriveSessionWakePlan(issue, events) {
190
+ export function deriveSessionWakePlan(issue, events, onPayloadError) {
24
191
  const actionableEvents = events.filter((event) => !NON_ACTIONABLE_SESSION_EVENTS.has(event.eventType));
25
192
  if (actionableEvents.length === 0)
26
193
  return undefined;
@@ -34,53 +201,58 @@ export function deriveSessionWakePlan(issue, events) {
34
201
  let runType;
35
202
  let resumeThread = false;
36
203
  for (const event of actionableEvents) {
37
- const payload = parseEventJson(event.eventJson);
38
- switch (event.eventType) {
204
+ // Boundary over DB rows: a payload written by an older version that no
205
+ // longer matches the schema degrades to "no payload" instead of wedging
206
+ // wake derivation for the whole issue.
207
+ const typed = parseIssueSessionEventOrWarn(event, onPayloadError ? (message) => onPayloadError(event, message) : undefined);
208
+ if (!typed)
209
+ continue;
210
+ switch (typed.eventType) {
39
211
  case "merge_steward_incident":
40
212
  runType = "queue_repair";
41
213
  wakeReason = "merge_steward_incident";
42
214
  eventIds = [event.id];
43
- Object.assign(context, payload ?? {});
215
+ Object.assign(context, typed.payload ?? {});
44
216
  break;
45
217
  case "settled_red_ci":
46
218
  if (runType !== "queue_repair") {
47
219
  runType = "ci_repair";
48
220
  wakeReason = "settled_red_ci";
49
221
  eventIds = [event.id];
50
- Object.assign(context, payload ?? {});
222
+ Object.assign(context, typed.payload ?? {});
51
223
  }
52
224
  break;
53
225
  case "review_changes_requested":
54
226
  if (runType !== "queue_repair" && runType !== "ci_repair") {
55
- runType = payload?.branchUpkeepRequired === true ? "branch_upkeep" : "review_fix";
56
- wakeReason = payload?.branchUpkeepRequired === true ? "branch_upkeep" : "review_changes_requested";
227
+ runType = typed.payload?.branchUpkeepRequired === true ? "branch_upkeep" : "review_fix";
228
+ wakeReason = typed.payload?.branchUpkeepRequired === true ? "branch_upkeep" : "review_changes_requested";
57
229
  eventIds = [event.id];
58
- Object.assign(context, payload ?? {});
230
+ Object.assign(context, typed.payload ?? {});
59
231
  }
60
232
  break;
61
233
  case "delegated":
62
234
  if (!runType) {
63
- runType = parseRunType(payload?.runType) ?? "implementation";
235
+ runType = parseRunType(typed.payload?.runType) ?? "implementation";
64
236
  wakeReason = issue.issueClass === "orchestration" ? "initial_delegate" : "delegated";
65
237
  eventIds = [event.id];
66
238
  }
67
239
  else {
68
240
  eventIds.push(event.id);
69
241
  }
70
- Object.assign(context, payload ?? {});
242
+ Object.assign(context, typed.payload ?? {});
71
243
  break;
72
244
  case "child_changed":
73
245
  case "child_delivered":
74
246
  case "child_regressed":
75
247
  if (!runType) {
76
248
  runType = "implementation";
77
- wakeReason = event.eventType;
249
+ wakeReason = typed.eventType;
78
250
  eventIds = [event.id];
79
251
  }
80
252
  else {
81
253
  eventIds.push(event.id);
82
254
  }
83
- Object.assign(context, payload ?? {});
255
+ Object.assign(context, typed.payload ?? {});
84
256
  resumeThread = true;
85
257
  break;
86
258
  case "direct_reply": {
@@ -92,14 +264,12 @@ export function deriveSessionWakePlan(issue, events) {
92
264
  else {
93
265
  eventIds.push(event.id);
94
266
  }
95
- const text = typeof payload?.text === "string"
96
- ? payload.text
97
- : typeof payload?.body === "string" ? payload.body : undefined;
267
+ const text = typed.payload?.text ?? typed.payload?.body;
98
268
  if (text) {
99
269
  followUps.push({
100
- type: event.eventType,
270
+ type: typed.eventType,
101
271
  text,
102
- ...(typeof payload?.author === "string" ? { author: payload.author } : {}),
272
+ ...(typed.payload?.author !== undefined ? { author: typed.payload.author } : {}),
103
273
  });
104
274
  }
105
275
  context.directReplyMode = true;
@@ -108,7 +278,7 @@ export function deriveSessionWakePlan(issue, events) {
108
278
  }
109
279
  case "completion_check_continue": {
110
280
  if (!runType) {
111
- runType = parseRunType(payload?.runType)
281
+ runType = parseRunType(typed.payload?.runType)
112
282
  ?? (issue.prReviewState === "changes_requested" ? "review_fix" : "implementation");
113
283
  wakeReason = "completion_check_continue";
114
284
  eventIds = [event.id];
@@ -116,9 +286,9 @@ export function deriveSessionWakePlan(issue, events) {
116
286
  else {
117
287
  eventIds.push(event.id);
118
288
  }
119
- Object.assign(context, payload ?? {});
120
- if (typeof payload?.summary === "string" && payload.summary.trim()) {
121
- context.completionCheckSummary = payload.summary.trim();
289
+ Object.assign(context, typed.payload ?? {});
290
+ if (typed.payload?.summary?.trim()) {
291
+ context.completionCheckSummary = typed.payload.summary.trim();
122
292
  }
123
293
  context.completionCheckMode = true;
124
294
  resumeThread = true;
@@ -129,42 +299,49 @@ export function deriveSessionWakePlan(issue, events) {
129
299
  case "operator_prompt": {
130
300
  if (!runType) {
131
301
  runType = issue.prReviewState === "changes_requested" ? "review_fix" : "implementation";
132
- wakeReason = issue.issueClass === "orchestration" ? "human_instruction" : event.eventType;
302
+ wakeReason = issue.issueClass === "orchestration" ? "human_instruction" : typed.eventType;
133
303
  eventIds = [event.id];
134
304
  }
135
305
  else {
136
306
  eventIds.push(event.id);
137
307
  }
138
- const text = typeof payload?.text === "string"
139
- ? payload.text
140
- : typeof payload?.body === "string" ? payload.body : undefined;
308
+ const text = typed.payload?.text ?? typed.payload?.body;
141
309
  if (text) {
142
310
  followUps.push({
143
- type: event.eventType,
311
+ type: typed.eventType,
144
312
  text,
145
- ...(typeof payload?.author === "string" ? { author: payload.author } : {}),
313
+ ...(typed.payload?.author !== undefined ? { author: typed.payload.author } : {}),
146
314
  });
147
315
  }
148
- if (payload?.replacementPrRequired === true) {
316
+ if (typed.payload?.replacementPrRequired === true) {
149
317
  context.replacementPrRequired = true;
150
- if (typeof payload.previousPrNumber === "number")
151
- context.previousPrNumber = payload.previousPrNumber;
152
- if (typeof payload.previousPrUrl === "string")
153
- context.previousPrUrl = payload.previousPrUrl;
154
- if (typeof payload.previousPrState === "string")
155
- context.previousPrState = payload.previousPrState;
156
- if (typeof payload.previousPrHeadSha === "string")
157
- context.previousPrHeadSha = payload.previousPrHeadSha;
158
- }
159
- if (event.eventType === "followup_prompt"
160
- || event.eventType === "followup_comment"
161
- || event.eventType === "operator_prompt") {
162
- resumeThread = true;
318
+ if (typed.payload.previousPrNumber !== undefined)
319
+ context.previousPrNumber = typed.payload.previousPrNumber;
320
+ if (typed.payload.previousPrUrl !== undefined)
321
+ context.previousPrUrl = typed.payload.previousPrUrl;
322
+ if (typed.payload.previousPrState !== undefined)
323
+ context.previousPrState = typed.payload.previousPrState;
324
+ if (typed.payload.previousPrHeadSha !== undefined)
325
+ context.previousPrHeadSha = typed.payload.previousPrHeadSha;
163
326
  }
327
+ resumeThread = true;
164
328
  break;
165
329
  }
166
- default:
330
+ // Terminal and non-actionable events were filtered out above; listed
331
+ // here so the switch stays exhaustive over the union.
332
+ case "delegation_observed":
333
+ case "prompt_delivered":
334
+ case "self_comment":
335
+ case "run_released_authority":
336
+ case "stop_requested":
337
+ case "operator_closed":
338
+ case "undelegated":
339
+ case "issue_removed":
340
+ case "pr_closed":
341
+ case "pr_merged":
167
342
  break;
343
+ default:
344
+ assertNever(typed, "Unhandled issue session event in wake derivation");
168
345
  }
169
346
  }
170
347
  if (!runType)
@@ -217,14 +394,3 @@ export function extractLatestAssistantSummary(run) {
217
394
  }
218
395
  return sanitizeOperatorFacingText(run.failureReason);
219
396
  }
220
- function parseEventJson(raw) {
221
- if (!raw)
222
- return undefined;
223
- try {
224
- const parsed = JSON.parse(raw);
225
- return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : undefined;
226
- }
227
- catch {
228
- return undefined;
229
- }
230
- }