@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.
- package/node_modules/@mrclrchtr/supi-core/package.json +1 -1
- package/package.json +2 -2
- package/src/history/synthesize.ts +1 -5
- package/src/review.ts +43 -31
- package/src/tool/agent-review-tools.ts +8 -10
- package/src/tool/agent-review-workflow.ts +41 -28
- package/src/tool/brief-runner.ts +25 -25
- package/src/tool/child-failure-diagnostics.ts +145 -0
- package/src/tool/child-lifecycle-trace.ts +329 -0
- package/src/tool/review-handlers.ts +9 -30
- package/src/tool/review-runner.ts +25 -44
- package/src/tool/runner-helpers.ts +0 -14
- package/src/tool/session-lifecycle.ts +105 -10
- package/src/types.ts +51 -40
- package/src/ui/format-content.ts +47 -38
- package/src/ui/renderer.ts +67 -45
- package/src/ui/review-tool-renderer.ts +32 -1
- package/src/tool/review-debug.ts +0 -124
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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"
|
|
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
|
-
|
|
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"
|
|
86
|
+
kind: "canceled",
|
|
92
87
|
snapshot: runner.invocation.snapshot,
|
|
93
88
|
brief: runner.invocation.brief,
|
|
94
89
|
modelId: runner.invocation.model.canonicalId,
|
|
95
|
-
|
|
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
|
-
|
|
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"
|
|
110
|
-
|
|
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
|
-
|
|
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
|
|
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"
|
|
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
|
-
|
|
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
|
|
148
|
+
* Build the custom soft-timeout behavior for reviewer sessions.
|
|
166
149
|
*
|
|
167
|
-
*
|
|
168
|
-
*
|
|
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
|
|
242
|
+
} catch {
|
|
260
243
|
return {
|
|
261
244
|
kind: "failed",
|
|
262
|
-
|
|
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: (
|
|
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") {
|