@mrclrchtr/supi-review 2.6.0 → 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,
@@ -42,7 +42,6 @@ async function createReviewerSession(
42
42
  const { session } = await createAgentSession({
43
43
  cwd: invocation.cwd,
44
44
  model: invocation.model.model,
45
- modelRegistry: invocation.modelRegistry,
46
45
  thinkingLevel: clampThinkingLevel(invocation.model.model, "max"),
47
46
  tools: [
48
47
  "read",
@@ -70,16 +69,12 @@ function buildTimeoutResult(
70
69
  runner: ReviewerRunnerState,
71
70
  ): RawReviewResult {
72
71
  return {
73
- kind: "timeout" as const,
72
+ kind: "timeout",
74
73
  snapshot: runner.invocation.snapshot,
75
74
  timeoutMs: runner.invocation.timeoutMs ?? DEFAULT_TIMEOUT_MS,
76
75
  brief: runner.invocation.brief,
77
76
  modelId: runner.invocation.model.canonicalId,
78
- debug: buildFailureDebug({
79
- progress: lcCtx.progress,
80
- session: lcCtx.session,
81
- recentEvents: runner.debug.recentEvents,
82
- }),
77
+ diagnostics: lcCtx.getFailureDiagnostics(),
83
78
  };
84
79
  }
85
80
 
@@ -88,34 +83,26 @@ function buildCanceledResult(
88
83
  runner: ReviewerRunnerState,
89
84
  ): RawReviewResult {
90
85
  return {
91
- kind: "canceled" as const,
86
+ kind: "canceled",
92
87
  snapshot: runner.invocation.snapshot,
93
88
  brief: runner.invocation.brief,
94
89
  modelId: runner.invocation.model.canonicalId,
95
- debug: buildFailureDebug({
96
- progress: lcCtx.progress,
97
- session: lcCtx.session,
98
- recentEvents: runner.debug.recentEvents,
99
- }),
90
+ diagnostics: lcCtx.getFailureDiagnostics(),
100
91
  };
101
92
  }
102
93
 
103
94
  function buildFailedResult(
104
- reason: string,
95
+ failureCode: "prompt-rejected" | "unexpected-runner-failure",
105
96
  lcCtx: LifecycleCtx<RawReviewResult>,
106
97
  runner: ReviewerRunnerState,
107
98
  ): RawReviewResult {
108
99
  return {
109
- kind: "failed" as const,
110
- reason,
100
+ kind: "failed",
101
+ failureCode,
111
102
  snapshot: runner.invocation.snapshot,
112
103
  brief: runner.invocation.brief,
113
104
  modelId: runner.invocation.model.canonicalId,
114
- debug: buildFailureDebug({
115
- progress: lcCtx.progress,
116
- session: lcCtx.session,
117
- recentEvents: runner.debug.recentEvents,
118
- }),
105
+ diagnostics: lcCtx.getFailureDiagnostics(),
119
106
  };
120
107
  }
121
108
 
@@ -125,49 +112,43 @@ interface ReviewerRunnerState {
125
112
  submitSteered: boolean;
126
113
  timeoutSteered: boolean;
127
114
  graceTurnsRemaining: number | undefined;
128
- debug: { recentEvents: string[] };
129
115
  }
130
116
 
131
117
  // ---------------------------------------------------------------------------
132
118
  // Steer / abort helpers
133
119
  // ---------------------------------------------------------------------------
134
120
 
135
- /** 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. */
136
122
  function doFinalAbortFromLifecycle(
137
123
  lcCtx: LifecycleCtx<RawReviewResult>,
138
124
  runner: ReviewerRunnerState,
139
125
  ): void {
126
+ if (lcCtx.state.settled || lcCtx.state.aborting) return;
127
+
140
128
  lcCtx.state.aborting = true;
129
+ lcCtx.recordHostMarker({ type: "abort_requested", reason: "timeout" });
141
130
  void lcCtx.session
142
131
  .abort()
143
132
  .catch(() => {})
144
133
  .finally(() => {
145
- const partialText = extractLastAssistantDebug(lcCtx.session)?.text;
146
134
  lcCtx.resolve(
147
135
  lcCtx.cleanup({
148
- kind: "timeout" as const,
136
+ kind: "timeout",
149
137
  snapshot: runner.invocation.snapshot,
150
138
  timeoutMs: runner.invocation.timeoutMs ?? DEFAULT_TIMEOUT_MS,
151
- partialOutput: partialText,
152
139
  brief: runner.invocation.brief,
153
140
  modelId: runner.invocation.model.canonicalId,
154
- debug: buildFailureDebug({
155
- progress: lcCtx.progress,
156
- session: lcCtx.session,
157
- recentEvents: runner.debug.recentEvents,
158
- }),
141
+ diagnostics: lcCtx.getFailureDiagnostics(),
159
142
  }),
160
143
  );
161
144
  });
162
145
  }
163
146
 
164
147
  /**
165
- * Build the custom `onTimeout` handler for the review runner.
148
+ * Build the custom soft-timeout behavior for reviewer sessions.
166
149
  *
167
- * When the soft timeout fires, the handler:
168
- * 1. Steers the reviewer session to wrap up
169
- * 2. Starts a grace timer (hard abort after GRACE_TURNS or HARD_ABORT_GRACE_MS)
170
- * 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.
171
152
  */
172
153
  function buildReviewTimeoutHandler(
173
154
  runner: ReviewerRunnerState,
@@ -175,9 +156,9 @@ function buildReviewTimeoutHandler(
175
156
  ): void {
176
157
  runner.timeoutSteered = true;
177
158
  runner.graceTurnsRemaining = GRACE_TURNS;
159
+ lcCtx.recordHostMarker({ type: "steer_requested", reason: "timeout" });
178
160
 
179
161
  const hardAbortTimer = setTimeout(() => {
180
- if (lcCtx.state.settled) return;
181
162
  doFinalAbortFromLifecycle(lcCtx, runner);
182
163
  }, HARD_ABORT_GRACE_MS);
183
164
  hardAbortTimer.unref?.();
@@ -200,7 +181,6 @@ function buildRunnerCtx(runner: ReviewerRunnerState): RunnerContext {
200
181
  ctx.submitSteered = runner.submitSteered;
201
182
  ctx.timeoutSteered = runner.timeoutSteered;
202
183
  ctx.graceTurnsRemaining = runner.graceTurnsRemaining;
203
- ctx.debug = runner.debug;
204
184
  ctx.toolCounts = {};
205
185
  ctx.inspectedFiles = new Set();
206
186
  return ctx;
@@ -217,6 +197,8 @@ function syncCtxFromLifecycle(
217
197
  ctx.cleanup = lcCtx.cleanup;
218
198
  ctx.state = lcCtx.state;
219
199
  ctx.startTime = lcCtx.startTime;
200
+ ctx.getFailureDiagnostics = lcCtx.getFailureDiagnostics;
201
+ ctx.recordHostMarker = lcCtx.recordHostMarker;
220
202
  ctx.submitSteered = runner.submitSteered;
221
203
  ctx.timeoutSteered = runner.timeoutSteered;
222
204
  ctx.graceTurnsRemaining = runner.graceTurnsRemaining;
@@ -240,6 +222,7 @@ export async function runReviewer(invocation: ReviewInvocation): Promise<RawRevi
240
222
  snapshot: invocation.snapshot,
241
223
  brief: invocation.brief,
242
224
  modelId: invocation.model.canonicalId,
225
+ diagnostics: createEarlyCancellationDiagnostics(),
243
226
  };
244
227
  }
245
228
 
@@ -256,10 +239,10 @@ export async function runReviewer(invocation: ReviewInvocation): Promise<RawRevi
256
239
  snapshotDiffTool,
257
240
  snapshotFileTool,
258
241
  );
259
- } catch (error) {
242
+ } catch {
260
243
  return {
261
244
  kind: "failed",
262
- reason: `Failed to create reviewer session: ${error instanceof Error ? error.message : String(error)}`,
245
+ failureCode: "session-creation-failed",
263
246
  snapshot: invocation.snapshot,
264
247
  brief: invocation.brief,
265
248
  modelId: invocation.model.canonicalId,
@@ -272,9 +255,7 @@ export async function runReviewer(invocation: ReviewInvocation): Promise<RawRevi
272
255
  submitSteered: false,
273
256
  timeoutSteered: false,
274
257
  graceTurnsRemaining: undefined,
275
- debug: { recentEvents: [] },
276
258
  };
277
-
278
259
  const ctx = buildRunnerCtx(runner);
279
260
 
280
261
  return runWithLifecycle<RawReviewResult>({
@@ -289,7 +270,7 @@ export async function runReviewer(invocation: ReviewInvocation): Promise<RawRevi
289
270
  },
290
271
  onTimeout: (lcCtx) => buildReviewTimeoutHandler(runner, lcCtx),
291
272
  canceledResult: (lcCtx) => buildCanceledResult(lcCtx, runner),
292
- failedResult: (reason, lcCtx) => buildFailedResult(reason, lcCtx, runner),
273
+ failedResult: (failureCode, lcCtx) => buildFailedResult(failureCode, lcCtx, runner),
293
274
  timeoutResult: (_, lcCtx) => buildTimeoutResult(lcCtx, runner),
294
275
  });
295
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") {