@mrclrchtr/supi-review 2.6.1 → 2.8.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.
@@ -11,7 +11,8 @@ import type {
11
11
  BriefSynthesisRunResult,
12
12
  SynthesizedReviewBrief,
13
13
  } from "../types.ts";
14
- import { buildProgressTokens, extractLastAssistantText } from "./runner-helpers.ts";
14
+ import { createEarlyCancellationDiagnostics } from "./child-failure-diagnostics.ts";
15
+ import { buildProgressTokens } from "./runner-helpers.ts";
15
16
  import { reviewBriefSchema } from "./schemas.ts";
16
17
  import { type LifecycleCtx, runWithLifecycle } from "./session-lifecycle.ts";
17
18
 
@@ -88,24 +89,19 @@ function emitBriefProgress(
88
89
 
89
90
  /** Finalize after retries and compaction recovery, never at the earlier `agent_end` boundary. */
90
91
  function handleAgentSettled(options: {
91
- session: AgentSession;
92
92
  brief: SynthesizedReviewBrief | undefined;
93
93
  state: { settled: boolean; aborting: boolean };
94
94
  cleanup: (result: BriefSynthesisRunResult) => BriefSynthesisRunResult;
95
+ getFailureDiagnostics: LifecycleCtx<BriefSynthesisRunResult>["getFailureDiagnostics"];
95
96
  }): BriefSynthesisRunResult | undefined {
96
- const { session, brief, state, cleanup } = options;
97
- if (state.settled || state.aborting) {
98
- return undefined;
99
- }
100
- if (brief) {
101
- return cleanup({ kind: "success", brief });
102
- }
103
- const lastText = extractLastAssistantText(session.messages);
97
+ const { brief, state, cleanup, getFailureDiagnostics } = options;
98
+ if (state.settled || state.aborting) return undefined;
99
+ if (brief) return cleanup({ kind: "success", brief });
100
+
104
101
  return cleanup({
105
102
  kind: "failed",
106
- reason: lastText
107
- ? `Brief synthesizer did not call submit_review_brief. Assistant said: ${lastText}`
108
- : "Brief synthesizer did not produce any output.",
103
+ failureCode: "missing-structured-output",
104
+ diagnostics: getFailureDiagnostics(),
109
105
  });
110
106
  }
111
107
 
@@ -114,7 +110,7 @@ export async function runBriefSynthesis(
114
110
  invocation: BriefSynthesisInvocation,
115
111
  ): Promise<BriefSynthesisRunResult> {
116
112
  if (invocation.signal?.aborted) {
117
- return { kind: "canceled" };
113
+ return { kind: "canceled", diagnostics: createEarlyCancellationDiagnostics() };
118
114
  }
119
115
 
120
116
  const resultHolder: { value: SynthesizedReviewBrief | undefined } = { value: undefined };
@@ -123,11 +119,8 @@ export async function runBriefSynthesis(
123
119
  let session: AgentSession;
124
120
  try {
125
121
  session = await createBriefSession(invocation, submitBriefTool);
126
- } catch (error) {
127
- return {
128
- kind: "failed",
129
- reason: `Failed to create brief synthesis session: ${error instanceof Error ? error.message : String(error)}`,
130
- };
122
+ } catch {
123
+ return { kind: "failed", failureCode: "session-creation-failed" };
131
124
  }
132
125
 
133
126
  return runWithLifecycle<BriefSynthesisRunResult>({
@@ -155,10 +148,10 @@ export async function runBriefSynthesis(
155
148
  break;
156
149
  case "agent_settled": {
157
150
  const result = handleAgentSettled({
158
- session,
159
151
  brief: resultHolder.value,
160
152
  state: ctx.state,
161
153
  cleanup: ctx.cleanup,
154
+ getFailureDiagnostics: ctx.getFailureDiagnostics,
162
155
  });
163
156
  if (result) {
164
157
  ctx.resolve(result);
@@ -169,11 +162,19 @@ export async function runBriefSynthesis(
169
162
  break;
170
163
  }
171
164
  },
172
- canceledResult: () => ({ kind: "canceled" as const }),
173
- failedResult: (reason) => ({
165
+ canceledResult: (ctx) => ({
166
+ kind: "canceled" as const,
167
+ diagnostics: ctx.getFailureDiagnostics(),
168
+ }),
169
+ failedResult: (failureCode, ctx) => ({
174
170
  kind: "failed" as const,
175
- reason,
171
+ failureCode,
172
+ diagnostics: ctx.getFailureDiagnostics(),
173
+ }),
174
+ timeoutResult: (ms, ctx) => ({
175
+ kind: "timeout" as const,
176
+ timeoutMs: ms,
177
+ diagnostics: ctx.getFailureDiagnostics(),
176
178
  }),
177
- timeoutResult: (ms) => ({ kind: "timeout" as const, timeoutMs: ms }),
178
179
  });
179
180
  }
@@ -0,0 +1,145 @@
1
+ import type { AgentSession } from "@earendil-works/pi-coding-agent";
2
+ import type {
3
+ ChildFailureCode,
4
+ ChildFailureDiagnostics,
5
+ ChildStage,
6
+ ReviewProgress,
7
+ } from "../types.ts";
8
+ import {
9
+ type ChildLifecycleTrace,
10
+ ChildLifecycleTraceCollector,
11
+ formatChildLifecycleTrace,
12
+ getRegisteredToolNames,
13
+ toSafeAssistantStopReason,
14
+ } from "./child-lifecycle-trace.ts";
15
+ import { buildProgressTokens } from "./runner-helpers.ts";
16
+
17
+ /** Inputs used to create one safe non-success child-run diagnostic artifact. */
18
+ export interface BuildChildFailureDiagnosticsInput {
19
+ progress: ReviewProgress;
20
+ lifecycleTrace: ChildLifecycleTrace;
21
+ recentActivity: string[];
22
+ session?: AgentSession;
23
+ }
24
+
25
+ /** Build diagnostics from allowlisted control metadata only. */
26
+ export function buildChildFailureDiagnostics(
27
+ input: BuildChildFailureDiagnosticsInput,
28
+ ): ChildFailureDiagnostics {
29
+ const { session } = input;
30
+ const tokens = session
31
+ ? buildProgressTokens(() => session.getSessionStats())
32
+ : input.progress.tokens;
33
+ const lastAssistant = session ? extractLastAssistantMetadata(session) : undefined;
34
+
35
+ return {
36
+ lifecycleTrace: input.lifecycleTrace,
37
+ turns: input.progress.turns,
38
+ toolUses: input.progress.toolUses,
39
+ tokens,
40
+ recentActivity: input.recentActivity.length > 0 ? [...input.recentActivity] : undefined,
41
+ lastAssistantStopReason: lastAssistant?.stopReason,
42
+ lastAssistantToolCalls: lastAssistant?.toolCalls,
43
+ };
44
+ }
45
+
46
+ /** Create safe diagnostics when a host failure occurred without observed child lifecycle events. */
47
+ export function createUnobservedChildFailureDiagnostics(): ChildFailureDiagnostics {
48
+ return buildChildFailureDiagnostics({
49
+ progress: { turns: 0, toolUses: 0 },
50
+ lifecycleTrace: { entries: [], droppedCount: 0 },
51
+ recentActivity: [],
52
+ });
53
+ }
54
+
55
+ /** Create safe cancellation diagnostics when no child session was started. */
56
+ export function createEarlyCancellationDiagnostics(): ChildFailureDiagnostics {
57
+ const collector = new ChildLifecycleTraceCollector();
58
+ collector.recordHostMarker({ type: "abort_requested", reason: "canceled" });
59
+ return buildChildFailureDiagnostics({
60
+ progress: { turns: 0, toolUses: 0 },
61
+ lifecycleTrace: collector.snapshot(),
62
+ recentActivity: collector.recentActivitySnapshot(),
63
+ });
64
+ }
65
+
66
+ /** Generate static parent-facing copy for a host-owned child failure code. */
67
+ export function formatChildFailureCopy(stage: ChildStage, code: ChildFailureCode): string {
68
+ const label = stage === "brief-synthesis" ? "Brief synthesis" : "Reviewer";
69
+ switch (code) {
70
+ case "session-creation-failed":
71
+ return `${label} session could not be created.`;
72
+ case "prompt-rejected":
73
+ return `${label} prompt was rejected before it ran.`;
74
+ case "missing-structured-output":
75
+ return `${label} ended without the required structured output.`;
76
+ case "unexpected-runner-failure":
77
+ return `${label} ended unexpectedly.`;
78
+ }
79
+ }
80
+
81
+ /** Format the complete retained safe diagnostic artifact for parent-facing text. */
82
+ export function formatChildFailureDiagnostics(diagnostics: ChildFailureDiagnostics): string[] {
83
+ const lines = [
84
+ `- Turns: ${diagnostics.turns}`,
85
+ `- Tool uses: ${diagnostics.toolUses}`,
86
+ diagnostics.tokens
87
+ ? `- Tokens: ${diagnostics.tokens.input} in / ${diagnostics.tokens.output} out / ${diagnostics.tokens.total} total`
88
+ : undefined,
89
+ diagnostics.recentActivity && diagnostics.recentActivity.length > 0
90
+ ? `- Recent activity: ${diagnostics.recentActivity.join(" → ")}`
91
+ : undefined,
92
+ diagnostics.lastAssistantStopReason
93
+ ? `- Last assistant stop: ${diagnostics.lastAssistantStopReason}`
94
+ : undefined,
95
+ diagnostics.lastAssistantToolCalls && diagnostics.lastAssistantToolCalls.length > 0
96
+ ? `- Last assistant tools: ${diagnostics.lastAssistantToolCalls.join(", ")}`
97
+ : undefined,
98
+ `- ${formatChildLifecycleTrace(diagnostics.lifecycleTrace)}`,
99
+ ];
100
+ return lines.filter((line): line is string => !!line);
101
+ }
102
+
103
+ interface LastAssistantMetadata {
104
+ stopReason?: string;
105
+ toolCalls?: string[];
106
+ }
107
+
108
+ function extractLastAssistantMetadata(session: AgentSession): LastAssistantMetadata | undefined {
109
+ try {
110
+ const messages = session.messages as unknown;
111
+ if (!Array.isArray(messages)) return undefined;
112
+
113
+ const registeredToolNames = getRegisteredToolNames(session);
114
+ for (let index = messages.length - 1; index >= 0; index--) {
115
+ const message = messages[index] as Record<string, unknown> | undefined;
116
+ if (message?.role !== "assistant") continue;
117
+
118
+ const stopReason = toSafeAssistantStopReason(message.stopReason);
119
+ const toolCalls = extractAssistantToolCalls(message.content, registeredToolNames);
120
+ return {
121
+ stopReason,
122
+ toolCalls: toolCalls.length > 0 ? toolCalls : undefined,
123
+ };
124
+ }
125
+ return undefined;
126
+ } catch {
127
+ return undefined;
128
+ }
129
+ }
130
+
131
+ function extractAssistantToolCalls(content: unknown, registeredToolNames: Set<string>): string[] {
132
+ if (!Array.isArray(content)) return [];
133
+
134
+ return content
135
+ .map((part) => {
136
+ if (typeof part !== "object" || !part) return undefined;
137
+ const tool = part as { type?: unknown; name?: unknown };
138
+ return tool.type === "toolCall" &&
139
+ typeof tool.name === "string" &&
140
+ registeredToolNames.has(tool.name)
141
+ ? tool.name
142
+ : undefined;
143
+ })
144
+ .filter((name): name is string => !!name);
145
+ }
@@ -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
  });