@mrclrchtr/supi-review 2.6.1 → 2.7.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,329 @@
1
+ import type { AgentSession, AgentSessionEvent } from "@earendil-works/pi-coding-agent";
2
+
3
+ /** Maximum number of observed Child Lifecycle Trace entries retained per child run. */
4
+ export const CHILD_LIFECYCLE_TRACE_MAX = 32;
5
+
6
+ /** Maximum number of compact Recent Activity entries retained per child run. */
7
+ export const RECENT_ACTIVITY_MAX = 10;
8
+
9
+ /** Pi-defined assistant stop reasons safe to retain in diagnostics. */
10
+ export type SafeAssistantStopReason = "stop" | "length" | "toolUse" | "error" | "aborted";
11
+
12
+ type CompactionReason = "manual" | "threshold" | "overflow";
13
+
14
+ const SAFE_ASSISTANT_STOP_REASONS = new Set<SafeAssistantStopReason>([
15
+ "stop",
16
+ "length",
17
+ "toolUse",
18
+ "error",
19
+ "aborted",
20
+ ]);
21
+ const COMPACTION_REASONS = new Set<CompactionReason>(["manual", "threshold", "overflow"]);
22
+
23
+ /** Return a stop reason only when it is one of Pi's defined lifecycle values. */
24
+ export function toSafeAssistantStopReason(value: unknown): SafeAssistantStopReason | undefined {
25
+ return typeof value === "string" &&
26
+ SAFE_ASSISTANT_STOP_REASONS.has(value as SafeAssistantStopReason)
27
+ ? (value as SafeAssistantStopReason)
28
+ : undefined;
29
+ }
30
+
31
+ /** Read the child session's active registered tool names without exposing failures. */
32
+ export function getRegisteredToolNames(session: AgentSession): Set<string> {
33
+ try {
34
+ const names = session.getActiveToolNames() as unknown;
35
+ return Array.isArray(names)
36
+ ? new Set(names.filter((name): name is string => typeof name === "string"))
37
+ : new Set();
38
+ } catch {
39
+ return new Set();
40
+ }
41
+ }
42
+
43
+ /** One allowlisted lifecycle transition retained for a managed child session. */
44
+ export type ChildLifecycleTraceEntry =
45
+ | { type: "agent_start" }
46
+ | { type: "agent_end"; willRetry: boolean }
47
+ | { type: "agent_settled" }
48
+ | { type: "compaction_start"; reason: CompactionReason }
49
+ | {
50
+ type: "compaction_end";
51
+ reason: CompactionReason;
52
+ aborted: boolean;
53
+ willRetry: boolean;
54
+ hasResult: boolean;
55
+ hasError: boolean;
56
+ }
57
+ | { type: "auto_retry_start"; attempt: number; maxAttempts: number; delayMs: number }
58
+ | { type: "auto_retry_end"; success: boolean; attempt: number; hasFinalError: boolean }
59
+ | {
60
+ type: "summarization_retry_scheduled";
61
+ attempt: number;
62
+ maxAttempts: number;
63
+ delayMs: number;
64
+ }
65
+ | {
66
+ type: "summarization_retry_attempt_start";
67
+ source: "branchSummary" | "compaction";
68
+ reason?: CompactionReason;
69
+ }
70
+ | { type: "summarization_retry_finished" }
71
+ | { type: "queue_update"; steeringCount: number; followUpCount: number }
72
+ | ChildLifecycleHostMarker;
73
+
74
+ /** Minimal runner-control transitions that explain host-originated termination. */
75
+ export type ChildLifecycleHostMarker =
76
+ | { type: "steer_requested"; reason: "submit" | "timeout" }
77
+ | { type: "timeout_expired" }
78
+ | { type: "abort_requested"; reason: "canceled" | "timeout" }
79
+ | { type: "prompt_rejected" };
80
+
81
+ /** Bounded observed tail of lifecycle transitions for one managed child session. */
82
+ export interface ChildLifecycleTrace {
83
+ entries: ChildLifecycleTraceEntry[];
84
+ droppedCount: number;
85
+ }
86
+
87
+ /**
88
+ * Retains only allowlisted lifecycle metadata for one managed child session.
89
+ *
90
+ * Entries are copied into a bounded observed tail so raw SDK events and their
91
+ * potentially sensitive payloads never escape the runner.
92
+ */
93
+ export class ChildLifecycleTraceCollector {
94
+ private readonly entries: ChildLifecycleTraceEntry[] = [];
95
+ private readonly recentActivity: string[] = [];
96
+ private droppedCount = 0;
97
+
98
+ constructor(private readonly registeredToolNames: ReadonlySet<string> = new Set()) {}
99
+
100
+ /** Record one Pi session event when it belongs to the explicit allowlist. */
101
+ observe(event: AgentSessionEvent): void {
102
+ let entry: ChildLifecycleTraceEntry | undefined;
103
+ let activity: string | undefined;
104
+ try {
105
+ entry = mapLifecycleEvent(event);
106
+ activity = summarizeRecentActivity(event, this.registeredToolNames);
107
+ } catch {
108
+ return;
109
+ }
110
+
111
+ if (entry) this.push(entry);
112
+ if (activity) this.pushRecentActivity(activity);
113
+ }
114
+
115
+ /** Record an allowlisted runner-control transition. */
116
+ recordHostMarker(marker: ChildLifecycleHostMarker): void {
117
+ switch (marker.type) {
118
+ case "steer_requested":
119
+ if (marker.reason === "submit" || marker.reason === "timeout") {
120
+ this.push({ type: "steer_requested", reason: marker.reason });
121
+ }
122
+ break;
123
+ case "abort_requested":
124
+ if (marker.reason === "canceled" || marker.reason === "timeout") {
125
+ this.push({ type: "abort_requested", reason: marker.reason });
126
+ }
127
+ break;
128
+ case "timeout_expired":
129
+ this.push({ type: "timeout_expired" });
130
+ break;
131
+ case "prompt_rejected":
132
+ this.push({ type: "prompt_rejected" });
133
+ break;
134
+ }
135
+ }
136
+
137
+ /** Return an immutable-by-convention copy suitable for a failure artifact. */
138
+ snapshot(): ChildLifecycleTrace {
139
+ return {
140
+ entries: this.entries.map((entry) => ({ ...entry })),
141
+ droppedCount: this.droppedCount,
142
+ };
143
+ }
144
+
145
+ /** Return the separate, presentation-only Recent Activity lane. */
146
+ recentActivitySnapshot(): string[] {
147
+ return [...this.recentActivity];
148
+ }
149
+
150
+ private push(entry: ChildLifecycleTraceEntry): void {
151
+ this.entries.push(entry);
152
+ if (this.entries.length <= CHILD_LIFECYCLE_TRACE_MAX) return;
153
+
154
+ this.entries.shift();
155
+ this.droppedCount++;
156
+ }
157
+
158
+ private pushRecentActivity(activity: string): void {
159
+ this.recentActivity.push(activity);
160
+ if (this.recentActivity.length > RECENT_ACTIVITY_MAX) this.recentActivity.shift();
161
+ }
162
+ }
163
+
164
+ /** Format the complete retained Child Lifecycle Trace for parent-facing diagnostics. */
165
+ export function formatChildLifecycleTrace(trace: ChildLifecycleTrace): string {
166
+ const tail =
167
+ trace.droppedCount > 0
168
+ ? `incomplete observed tail; ${trace.droppedCount} older ${trace.droppedCount === 1 ? "entry" : "entries"} dropped`
169
+ : "observed tail";
170
+ const entries = trace.entries.map(formatChildLifecycleTraceEntry);
171
+ const body = entries.length > 0 ? entries.join(" → ") : "(no observed lifecycle entries)";
172
+ return `Child Lifecycle Trace (${tail}): ${body}`;
173
+ }
174
+
175
+ function formatChildLifecycleTraceEntry(entry: ChildLifecycleTraceEntry): string {
176
+ switch (entry.type) {
177
+ case "agent_start":
178
+ case "agent_settled":
179
+ case "summarization_retry_finished":
180
+ case "timeout_expired":
181
+ case "prompt_rejected":
182
+ return entry.type;
183
+ case "agent_end":
184
+ return `agent_end(willRetry=${entry.willRetry})`;
185
+ case "compaction_start":
186
+ return `compaction_start(reason=${entry.reason})`;
187
+ case "compaction_end":
188
+ return `compaction_end(reason=${entry.reason}, aborted=${entry.aborted}, willRetry=${entry.willRetry}, hasResult=${entry.hasResult}, hasError=${entry.hasError})`;
189
+ case "auto_retry_start":
190
+ return `auto_retry_start(attempt=${entry.attempt}/${entry.maxAttempts}, delayMs=${entry.delayMs})`;
191
+ case "auto_retry_end":
192
+ return `auto_retry_end(success=${entry.success}, attempt=${entry.attempt}, hasFinalError=${entry.hasFinalError})`;
193
+ case "summarization_retry_scheduled":
194
+ return `summarization_retry_scheduled(attempt=${entry.attempt}/${entry.maxAttempts}, delayMs=${entry.delayMs})`;
195
+ case "summarization_retry_attempt_start":
196
+ return entry.reason
197
+ ? `summarization_retry_attempt_start(source=${entry.source}, reason=${entry.reason})`
198
+ : `summarization_retry_attempt_start(source=${entry.source})`;
199
+ case "queue_update":
200
+ return `queue_update(steering=${entry.steeringCount}, followUp=${entry.followUpCount})`;
201
+ case "steer_requested":
202
+ return `steer_requested(reason=${entry.reason})`;
203
+ case "abort_requested":
204
+ return `abort_requested(reason=${entry.reason})`;
205
+ }
206
+ }
207
+
208
+ function summarizeRecentActivity(
209
+ event: AgentSessionEvent,
210
+ registeredToolNames: ReadonlySet<string>,
211
+ ): string | undefined {
212
+ switch (event.type) {
213
+ case "message_end": {
214
+ const message = event.message as { role?: unknown; stopReason?: unknown };
215
+ if (message.role !== "assistant") return undefined;
216
+ const stopReason = toSafeAssistantStopReason(message.stopReason);
217
+ return `assistant:end${stopReason ? `:${stopReason}` : ""}`;
218
+ }
219
+ case "turn_start":
220
+ return "turn:start";
221
+ case "turn_end":
222
+ return "turn:end";
223
+ case "tool_execution_start":
224
+ return typeof event.toolName === "string" && registeredToolNames.has(event.toolName)
225
+ ? `tool:start:${event.toolName}`
226
+ : undefined;
227
+ case "tool_execution_end":
228
+ return typeof event.toolName === "string" &&
229
+ typeof event.isError === "boolean" &&
230
+ registeredToolNames.has(event.toolName)
231
+ ? `tool:end:${event.toolName}${event.isError ? ":error" : ""}`
232
+ : undefined;
233
+ default:
234
+ return undefined;
235
+ }
236
+ }
237
+
238
+ function isCompactionReason(value: unknown): value is CompactionReason {
239
+ return typeof value === "string" && COMPACTION_REASONS.has(value as CompactionReason);
240
+ }
241
+
242
+ function isSafeCount(value: unknown): value is number {
243
+ return typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
244
+ }
245
+
246
+ // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: one explicit allowlist keeps copied event fields auditable
247
+ function mapLifecycleEvent(event: AgentSessionEvent): ChildLifecycleTraceEntry | undefined {
248
+ switch (event.type) {
249
+ case "agent_start":
250
+ return { type: "agent_start" };
251
+ case "agent_end":
252
+ return typeof event.willRetry === "boolean"
253
+ ? { type: "agent_end", willRetry: event.willRetry }
254
+ : undefined;
255
+ case "agent_settled":
256
+ return { type: "agent_settled" };
257
+ case "compaction_start":
258
+ return isCompactionReason(event.reason)
259
+ ? { type: "compaction_start", reason: event.reason }
260
+ : undefined;
261
+ case "compaction_end":
262
+ return isCompactionReason(event.reason) &&
263
+ typeof event.aborted === "boolean" &&
264
+ typeof event.willRetry === "boolean"
265
+ ? {
266
+ type: "compaction_end",
267
+ reason: event.reason,
268
+ aborted: event.aborted,
269
+ willRetry: event.willRetry,
270
+ hasResult: event.result !== undefined,
271
+ hasError: event.errorMessage !== undefined,
272
+ }
273
+ : undefined;
274
+ case "auto_retry_start":
275
+ return isSafeCount(event.attempt) &&
276
+ isSafeCount(event.maxAttempts) &&
277
+ isSafeCount(event.delayMs)
278
+ ? {
279
+ type: "auto_retry_start",
280
+ attempt: event.attempt,
281
+ maxAttempts: event.maxAttempts,
282
+ delayMs: event.delayMs,
283
+ }
284
+ : undefined;
285
+ case "auto_retry_end":
286
+ return typeof event.success === "boolean" && isSafeCount(event.attempt)
287
+ ? {
288
+ type: "auto_retry_end",
289
+ success: event.success,
290
+ attempt: event.attempt,
291
+ hasFinalError: event.finalError !== undefined,
292
+ }
293
+ : undefined;
294
+ case "summarization_retry_scheduled":
295
+ return isSafeCount(event.attempt) &&
296
+ isSafeCount(event.maxAttempts) &&
297
+ isSafeCount(event.delayMs)
298
+ ? {
299
+ type: "summarization_retry_scheduled",
300
+ attempt: event.attempt,
301
+ maxAttempts: event.maxAttempts,
302
+ delayMs: event.delayMs,
303
+ }
304
+ : undefined;
305
+ case "summarization_retry_attempt_start":
306
+ if (event.source === "branchSummary") {
307
+ return { type: "summarization_retry_attempt_start", source: "branchSummary" };
308
+ }
309
+ return event.source === "compaction" && isCompactionReason(event.reason)
310
+ ? {
311
+ type: "summarization_retry_attempt_start",
312
+ source: "compaction",
313
+ reason: event.reason,
314
+ }
315
+ : undefined;
316
+ case "summarization_retry_finished":
317
+ return { type: "summarization_retry_finished" };
318
+ case "queue_update":
319
+ return Array.isArray(event.steering) && Array.isArray(event.followUp)
320
+ ? {
321
+ type: "queue_update",
322
+ steeringCount: event.steering.length,
323
+ followUpCount: event.followUp.length,
324
+ }
325
+ : undefined;
326
+ default:
327
+ return undefined;
328
+ }
329
+ }
@@ -4,17 +4,13 @@ import {
4
4
  defineTool,
5
5
  } from "@earendil-works/pi-coding-agent";
6
6
  import type {
7
+ ChildFailureDiagnostics,
7
8
  RawReviewResult,
8
9
  ReviewInvocation,
9
10
  ReviewOutputEvent,
10
11
  ReviewProgress,
11
12
  } from "../types.ts";
12
- import {
13
- buildFailureDebug,
14
- extractLastAssistantDebug,
15
- pushRecentEvent,
16
- summarizeSessionEvent,
17
- } from "./review-debug.ts";
13
+ import type { ChildLifecycleHostMarker } from "./child-lifecycle-trace.ts";
18
14
  import { buildProgressTokens } from "./runner-helpers.ts";
19
15
  import { reviewOutputSchema } from "./schemas.ts";
20
16
 
@@ -37,7 +33,8 @@ export interface RunnerContext {
37
33
  submitSteered: boolean;
38
34
  timeoutSteered: boolean;
39
35
  graceTurnsRemaining: number | undefined;
40
- debug: { recentEvents: string[] };
36
+ getFailureDiagnostics: () => ChildFailureDiagnostics;
37
+ recordHostMarker: (marker: ChildLifecycleHostMarker) => void;
41
38
  /** Accumulated per-tool-label execution counts. */
42
39
  toolCounts: Record<string, number>;
43
40
  /** Set of distinct file paths inspected via read_snapshot_diff / read_snapshot_file. */
@@ -233,6 +230,7 @@ export function handleMessageEnd(
233
230
  if (msg.role !== "assistant" || msg.stopReason !== "stop") return;
234
231
 
235
232
  ctx.submitSteered = true;
233
+ ctx.recordHostMarker({ type: "steer_requested", reason: "submit" });
236
234
  ctx.session.steer(STEER_SUBMIT_MESSAGE).catch(() => {});
237
235
  }
238
236
 
@@ -256,28 +254,19 @@ export function handleAgentSettled(ctx: RunnerContext): void {
256
254
  return;
257
255
  }
258
256
 
259
- const lastText = extractLastAssistantDebug(ctx.session)?.text;
260
257
  ctx.resolve(
261
258
  ctx.cleanup({
262
259
  kind: "failed",
263
- reason: lastText
264
- ? `Reviewer did not call submit_review. Assistant said: ${truncateText(lastText, 400)}`
265
- : "Reviewer did not produce any output.",
260
+ failureCode: "missing-structured-output",
266
261
  snapshot: ctx.invocation.snapshot,
267
262
  brief: ctx.invocation.brief,
268
263
  modelId: ctx.invocation.model.canonicalId,
269
- debug: buildFailureDebug({
270
- progress: ctx.progress,
271
- session: ctx.session,
272
- recentEvents: ctx.debug.recentEvents,
273
- }),
264
+ diagnostics: ctx.getFailureDiagnostics(),
274
265
  }),
275
266
  );
276
267
  }
277
268
 
278
269
  export function handleSessionEvent(event: AgentSessionEvent, ctx: RunnerContext): void {
279
- pushRecentEvent(ctx.debug.recentEvents, summarizeSessionEvent(event));
280
-
281
270
  switch (event.type) {
282
271
  case "turn_end":
283
272
  handleTurnEnd(ctx);
@@ -300,35 +289,25 @@ export function handleSessionEvent(event: AgentSessionEvent, ctx: RunnerContext)
300
289
  }
301
290
  }
302
291
 
303
- function truncateText(text: string, maxLen: number): string {
304
- if (text.length <= maxLen) return text;
305
- return `${text.slice(0, maxLen)}... (${text.length - maxLen} more chars)`;
306
- }
307
-
308
292
  // ---------------------------------------------------------------------------
309
293
  // Hard-abort helper (called from handleTurnEnd when grace turns expire)
310
294
  // ---------------------------------------------------------------------------
311
295
 
312
296
  function doFinalAbort(ctx: RunnerContext): void {
313
297
  emitProgress(ctx);
298
+ ctx.recordHostMarker({ type: "abort_requested", reason: "timeout" });
314
299
  void ctx.session
315
300
  .abort()
316
301
  .catch(() => {})
317
302
  .finally(() => {
318
- const partialText = extractLastAssistantDebug(ctx.session)?.text;
319
303
  ctx.resolve(
320
304
  ctx.cleanup({
321
305
  kind: "timeout" as const,
322
306
  snapshot: ctx.invocation.snapshot,
323
307
  timeoutMs: ctx.invocation.timeoutMs ?? DEFAULT_TIMEOUT_MS,
324
- partialOutput: partialText,
325
308
  brief: ctx.invocation.brief,
326
309
  modelId: ctx.invocation.model.canonicalId,
327
- debug: buildFailureDebug({
328
- progress: ctx.progress,
329
- session: ctx.session,
330
- recentEvents: ctx.debug.recentEvents,
331
- }),
310
+ diagnostics: ctx.getFailureDiagnostics(),
332
311
  }),
333
312
  );
334
313
  });
@@ -6,7 +6,7 @@ import {
6
6
  SessionManager,
7
7
  } from "@earendil-works/pi-coding-agent";
8
8
  import type { RawReviewResult, ReviewInvocation, ReviewOutputEvent } from "../types.ts";
9
- import { buildFailureDebug, extractLastAssistantDebug } from "./review-debug.ts";
9
+ import { createEarlyCancellationDiagnostics } from "./child-failure-diagnostics.ts";
10
10
  import {
11
11
  createSubmitReviewTool,
12
12
  handleSessionEvent,
@@ -69,16 +69,12 @@ function buildTimeoutResult(
69
69
  runner: ReviewerRunnerState,
70
70
  ): RawReviewResult {
71
71
  return {
72
- kind: "timeout" as const,
72
+ kind: "timeout",
73
73
  snapshot: runner.invocation.snapshot,
74
74
  timeoutMs: runner.invocation.timeoutMs ?? DEFAULT_TIMEOUT_MS,
75
75
  brief: runner.invocation.brief,
76
76
  modelId: runner.invocation.model.canonicalId,
77
- debug: buildFailureDebug({
78
- progress: lcCtx.progress,
79
- session: lcCtx.session,
80
- recentEvents: runner.debug.recentEvents,
81
- }),
77
+ diagnostics: lcCtx.getFailureDiagnostics(),
82
78
  };
83
79
  }
84
80
 
@@ -87,34 +83,26 @@ function buildCanceledResult(
87
83
  runner: ReviewerRunnerState,
88
84
  ): RawReviewResult {
89
85
  return {
90
- kind: "canceled" as const,
86
+ kind: "canceled",
91
87
  snapshot: runner.invocation.snapshot,
92
88
  brief: runner.invocation.brief,
93
89
  modelId: runner.invocation.model.canonicalId,
94
- debug: buildFailureDebug({
95
- progress: lcCtx.progress,
96
- session: lcCtx.session,
97
- recentEvents: runner.debug.recentEvents,
98
- }),
90
+ diagnostics: lcCtx.getFailureDiagnostics(),
99
91
  };
100
92
  }
101
93
 
102
94
  function buildFailedResult(
103
- reason: string,
95
+ failureCode: "prompt-rejected" | "unexpected-runner-failure",
104
96
  lcCtx: LifecycleCtx<RawReviewResult>,
105
97
  runner: ReviewerRunnerState,
106
98
  ): RawReviewResult {
107
99
  return {
108
- kind: "failed" as const,
109
- reason,
100
+ kind: "failed",
101
+ failureCode,
110
102
  snapshot: runner.invocation.snapshot,
111
103
  brief: runner.invocation.brief,
112
104
  modelId: runner.invocation.model.canonicalId,
113
- debug: buildFailureDebug({
114
- progress: lcCtx.progress,
115
- session: lcCtx.session,
116
- recentEvents: runner.debug.recentEvents,
117
- }),
105
+ diagnostics: lcCtx.getFailureDiagnostics(),
118
106
  };
119
107
  }
120
108
 
@@ -124,49 +112,43 @@ interface ReviewerRunnerState {
124
112
  submitSteered: boolean;
125
113
  timeoutSteered: boolean;
126
114
  graceTurnsRemaining: number | undefined;
127
- debug: { recentEvents: string[] };
128
115
  }
129
116
 
130
117
  // ---------------------------------------------------------------------------
131
118
  // Steer / abort helpers
132
119
  // ---------------------------------------------------------------------------
133
120
 
134
- /** Abort the session and resolve with a timeout result (from lifecycle context). */
121
+ /** Abort the session and resolve with a timeout result from the lifecycle context. */
135
122
  function doFinalAbortFromLifecycle(
136
123
  lcCtx: LifecycleCtx<RawReviewResult>,
137
124
  runner: ReviewerRunnerState,
138
125
  ): void {
126
+ if (lcCtx.state.settled || lcCtx.state.aborting) return;
127
+
139
128
  lcCtx.state.aborting = true;
129
+ lcCtx.recordHostMarker({ type: "abort_requested", reason: "timeout" });
140
130
  void lcCtx.session
141
131
  .abort()
142
132
  .catch(() => {})
143
133
  .finally(() => {
144
- const partialText = extractLastAssistantDebug(lcCtx.session)?.text;
145
134
  lcCtx.resolve(
146
135
  lcCtx.cleanup({
147
- kind: "timeout" as const,
136
+ kind: "timeout",
148
137
  snapshot: runner.invocation.snapshot,
149
138
  timeoutMs: runner.invocation.timeoutMs ?? DEFAULT_TIMEOUT_MS,
150
- partialOutput: partialText,
151
139
  brief: runner.invocation.brief,
152
140
  modelId: runner.invocation.model.canonicalId,
153
- debug: buildFailureDebug({
154
- progress: lcCtx.progress,
155
- session: lcCtx.session,
156
- recentEvents: runner.debug.recentEvents,
157
- }),
141
+ diagnostics: lcCtx.getFailureDiagnostics(),
158
142
  }),
159
143
  );
160
144
  });
161
145
  }
162
146
 
163
147
  /**
164
- * Build the custom `onTimeout` handler for the review runner.
148
+ * Build the custom soft-timeout behavior for reviewer sessions.
165
149
  *
166
- * When the soft timeout fires, the handler:
167
- * 1. Steers the reviewer session to wrap up
168
- * 2. Starts a grace timer (hard abort after GRACE_TURNS or HARD_ABORT_GRACE_MS)
169
- * 3. If steer fails, immediately hard-aborts
150
+ * The shared lifecycle runner records `timeout_expired`; this runner adds a
151
+ * timeout steering marker and later records the hard-abort marker if needed.
170
152
  */
171
153
  function buildReviewTimeoutHandler(
172
154
  runner: ReviewerRunnerState,
@@ -174,9 +156,9 @@ function buildReviewTimeoutHandler(
174
156
  ): void {
175
157
  runner.timeoutSteered = true;
176
158
  runner.graceTurnsRemaining = GRACE_TURNS;
159
+ lcCtx.recordHostMarker({ type: "steer_requested", reason: "timeout" });
177
160
 
178
161
  const hardAbortTimer = setTimeout(() => {
179
- if (lcCtx.state.settled) return;
180
162
  doFinalAbortFromLifecycle(lcCtx, runner);
181
163
  }, HARD_ABORT_GRACE_MS);
182
164
  hardAbortTimer.unref?.();
@@ -199,7 +181,6 @@ function buildRunnerCtx(runner: ReviewerRunnerState): RunnerContext {
199
181
  ctx.submitSteered = runner.submitSteered;
200
182
  ctx.timeoutSteered = runner.timeoutSteered;
201
183
  ctx.graceTurnsRemaining = runner.graceTurnsRemaining;
202
- ctx.debug = runner.debug;
203
184
  ctx.toolCounts = {};
204
185
  ctx.inspectedFiles = new Set();
205
186
  return ctx;
@@ -216,6 +197,8 @@ function syncCtxFromLifecycle(
216
197
  ctx.cleanup = lcCtx.cleanup;
217
198
  ctx.state = lcCtx.state;
218
199
  ctx.startTime = lcCtx.startTime;
200
+ ctx.getFailureDiagnostics = lcCtx.getFailureDiagnostics;
201
+ ctx.recordHostMarker = lcCtx.recordHostMarker;
219
202
  ctx.submitSteered = runner.submitSteered;
220
203
  ctx.timeoutSteered = runner.timeoutSteered;
221
204
  ctx.graceTurnsRemaining = runner.graceTurnsRemaining;
@@ -239,6 +222,7 @@ export async function runReviewer(invocation: ReviewInvocation): Promise<RawRevi
239
222
  snapshot: invocation.snapshot,
240
223
  brief: invocation.brief,
241
224
  modelId: invocation.model.canonicalId,
225
+ diagnostics: createEarlyCancellationDiagnostics(),
242
226
  };
243
227
  }
244
228
 
@@ -255,10 +239,10 @@ export async function runReviewer(invocation: ReviewInvocation): Promise<RawRevi
255
239
  snapshotDiffTool,
256
240
  snapshotFileTool,
257
241
  );
258
- } catch (error) {
242
+ } catch {
259
243
  return {
260
244
  kind: "failed",
261
- reason: `Failed to create reviewer session: ${error instanceof Error ? error.message : String(error)}`,
245
+ failureCode: "session-creation-failed",
262
246
  snapshot: invocation.snapshot,
263
247
  brief: invocation.brief,
264
248
  modelId: invocation.model.canonicalId,
@@ -271,9 +255,7 @@ export async function runReviewer(invocation: ReviewInvocation): Promise<RawRevi
271
255
  submitSteered: false,
272
256
  timeoutSteered: false,
273
257
  graceTurnsRemaining: undefined,
274
- debug: { recentEvents: [] },
275
258
  };
276
-
277
259
  const ctx = buildRunnerCtx(runner);
278
260
 
279
261
  return runWithLifecycle<RawReviewResult>({
@@ -288,7 +270,7 @@ export async function runReviewer(invocation: ReviewInvocation): Promise<RawRevi
288
270
  },
289
271
  onTimeout: (lcCtx) => buildReviewTimeoutHandler(runner, lcCtx),
290
272
  canceledResult: (lcCtx) => buildCanceledResult(lcCtx, runner),
291
- failedResult: (reason, lcCtx) => buildFailedResult(reason, lcCtx, runner),
273
+ failedResult: (failureCode, lcCtx) => buildFailedResult(failureCode, lcCtx, runner),
292
274
  timeoutResult: (_, lcCtx) => buildTimeoutResult(lcCtx, runner),
293
275
  });
294
276
  }
@@ -5,20 +5,6 @@
5
5
  * eliminate duplication.
6
6
  */
7
7
 
8
- /** Extract the last assistant text from a session's message history. */
9
- export function extractLastAssistantText(
10
- messages: ArrayLike<unknown> | undefined,
11
- ): string | undefined {
12
- if (!messages) return undefined;
13
- for (let i = messages.length - 1; i >= 0; i--) {
14
- const message = messages[i] as { role?: string; content?: unknown } | undefined;
15
- if (message?.role !== "assistant") continue;
16
- const text = extractAssistantText(message.content);
17
- if (text) return text;
18
- }
19
- return undefined;
20
- }
21
-
22
8
  /** Extract text content from a message content value (string | content-part[]). */
23
9
  export function extractAssistantText(content: unknown): string | undefined {
24
10
  if (typeof content === "string") {