@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.
- package/node_modules/@mrclrchtr/supi-core/package.json +1 -1
- package/package.json +2 -2
- package/src/review.ts +43 -29
- package/src/tool/agent-review-tools.ts +8 -8
- package/src/tool/agent-review-workflow.ts +41 -23
- package/src/tool/brief-runner.ts +25 -24
- 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 -43
- package/src/tool/runner-helpers.ts +0 -14
- package/src/tool/session-lifecycle.ts +105 -10
- package/src/types.ts +51 -37
- 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,
|
|
@@ -69,16 +69,12 @@ function buildTimeoutResult(
|
|
|
69
69
|
runner: ReviewerRunnerState,
|
|
70
70
|
): RawReviewResult {
|
|
71
71
|
return {
|
|
72
|
-
kind: "timeout"
|
|
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
|
-
|
|
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"
|
|
86
|
+
kind: "canceled",
|
|
91
87
|
snapshot: runner.invocation.snapshot,
|
|
92
88
|
brief: runner.invocation.brief,
|
|
93
89
|
modelId: runner.invocation.model.canonicalId,
|
|
94
|
-
|
|
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
|
-
|
|
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"
|
|
109
|
-
|
|
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
|
-
|
|
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
|
|
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"
|
|
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
|
-
|
|
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
|
|
148
|
+
* Build the custom soft-timeout behavior for reviewer sessions.
|
|
165
149
|
*
|
|
166
|
-
*
|
|
167
|
-
*
|
|
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
|
|
242
|
+
} catch {
|
|
259
243
|
return {
|
|
260
244
|
kind: "failed",
|
|
261
|
-
|
|
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: (
|
|
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") {
|