fix: sub-agent hang/crash stranding runs mid-phase with no save
Some checks failed
port-to-omp / port (push) Failing after 2s

A stalled sub-agent (provider stream never settling after the final tool
call, or a throwing session-event listener recursing through the SDK's
run-failure path to stack overflow) left the phase stuck in_progress with
no state save, no error, and no completion message — observed twice in
freno-dev, both times after comments wrote findings.md.

- agent-runner: 60-min settle watchdog on every agent phase
  (PYGIENIUM_AGENT_TIMEOUT_MS, env-tunable); timeout disposes the session
  and fails the phase loudly with a /pygienium-resume hint instead of
  hanging the check-runner's await forever.
- agent-runner: applySessionEvent — the session.subscribe listener can no
  longer throw into the SDK event pipeline (guards for partial/malformed
  events, optional-chained message_update, throwing chat forwarder).
- comments: scan no longer regenerates the full report as its final
  message (findings.md is the source of truth); fix phase reads
  findings.md with embedded fallback.
- check-runner: failing state-save inside the catch path can't double-
  fault or escape as an unhandled rejection; completion posting is
  best-effort.
- tests: watchdog timeout test, comments task-text regression tests,
  applySessionEvent malformed-shape unit tests. 146 pass, tsc clean.
- README: document PYGIENIUM_AGENT_TIMEOUT_MS.
This commit is contained in:
2026-08-11 08:47:12 -04:00
parent 6d23b04ef6
commit 8768f9de97
7 changed files with 435 additions and 48 deletions

View File

@@ -17,9 +17,32 @@
import { mkdir, writeFile } from "node:fs/promises";
import { dirname, isAbsolute, join } from "node:path";
import type { AgentSessionEvent } from "@earendil-works/pi-coding-agent";
import type {
AgentSession,
AgentSessionEvent,
} from "@earendil-works/pi-coding-agent";
import { loadAgents, extensionRoot, type AgentDef } from "./agents.js";
/**
* Env var overriding the per-agent settle timeout (ms). Guards against a
* sub-agent session that never settles (stalled provider stream, hung retry /
* auto-compaction after the final tool call), which otherwise leaves the run
* stuck mid-phase with no state save, no error, and no completion message.
*/
export const AGENT_TIMEOUT_ENV = "PYGIENIUM_AGENT_TIMEOUT_MS";
/** Default settle timeout per agent phase: 60 minutes. */
const DEFAULT_AGENT_TIMEOUT_MS = 60 * 60_000;
/** Resolve the per-agent settle timeout, honouring the env override. */
export function agentTimeoutMs(): number {
const raw = process.env[AGENT_TIMEOUT_ENV];
if (raw && /^\d+$/.test(raw.trim()) && Number(raw.trim()) > 0) {
return Number(raw.trim());
}
return DEFAULT_AGENT_TIMEOUT_MS;
}
export interface AgentTaskOptions {
/** Absolute working directory for the sub-agent. */
cwd: string;
@@ -37,6 +60,62 @@ export interface AgentTaskOptions {
* turns tool_execution_start/end + assistant turns into chat messages.
*/
onEvent?: (event: AgentSessionEvent) => void;
/**
* Maximum time the agent run may take before it is aborted and the phase
* fails loudly (default {@link agentTimeoutMs}). A session that never
* settles — stalled provider stream, hung retry/compaction after its last
* tool call — would otherwise hang the check run silently with no state
* update and no completion message.
*/
timeoutMs?: number;
}
/**
* Race `promise` against a settle deadline. Returns `{ value }` on success or
* `{ error }` when the deadline elapsed first (the caller aborts the work).
* `promise` is still awaited-then-ignored afterwards so late rejections can
* never surface as unhandled.
*/
export async function withSettleTimeout<T>(
promise: Promise<T>,
timeoutMs: number,
label: string,
): Promise<{ value: T } | { error: string }> {
if (timeoutMs <= 0) {
try {
return { value: await promise };
} catch (err) {
return { error: err instanceof Error ? err.message : String(err) };
}
}
const settled = promise.then(
(value) => ({ value }) as { value: T },
(err) =>
({ error: err instanceof Error ? err.message : String(err) }) as {
error: string;
},
);
let timer: ReturnType<typeof setTimeout> | undefined;
const deadline = new Promise<{ error: string }>((resolve) => {
timer = setTimeout(() => {
// ~`1m 30s` / `45s` for the message (timeoutMs is ms; tests use small values).
const totalSec = Math.round(timeoutMs / 1000);
const duration =
totalSec >= 60
? `${Math.floor(totalSec / 60)}m${totalSec % 60 ? ` ${totalSec % 60}s` : ""}`
: `${totalSec}s`;
resolve({
error: `${label} did not settle within ${duration}; aborted. Check model/provider connectivity, then resume with /pygienium-resume.`,
});
}, timeoutMs);
// Never hold the process open just because a deadline is pending.
timer.unref?.();
});
try {
return await Promise.race([settled, deadline]);
} finally {
if (timer) clearTimeout(timer);
}
}
export interface AgentRunResult {
@@ -54,8 +133,21 @@ export type AgentRunner = (opts: AgentTaskOptions) => Promise<AgentRunResult>;
let currentRunner: AgentRunner = defaultAgentRunner;
/** Entry point used by the check-runner. */
export function runAgentTask(opts: AgentTaskOptions): Promise<AgentRunResult> {
return currentRunner(opts);
export async function runAgentTask(
opts: AgentTaskOptions,
): Promise<AgentRunResult> {
// Backstop: even a third-party/custom runner must not be able to hang the
// run forever. The default runner additionally aborts its session on
// timeout (see defaultAgentRunner); this race covers every other runner.
const settled = await withSettleTimeout(
currentRunner(opts),
opts.timeoutMs ?? agentTimeoutMs(),
`sub-agent "${opts.agentName}"`,
);
if ("error" in settled) {
return { ok: false, text: "", error: settled.error };
}
return settled.value;
}
/** Override the active agent runner (primarily for tests). */
@@ -121,43 +213,40 @@ export async function defaultAgentRunner(
resourceLoader: loader,
});
// A session that never settles (stalled provider stream, hung retry /
// auto-compaction after its last tool call) must fail the phase loudly
// instead of hanging the run mid-transition with zero diagnostics. On
// timeout the session is disposed, which aborts the in-flight run.
const settled = await withSettleTimeout(
runSessionToCompletion(session, opts),
opts.timeoutMs ?? agentTimeoutMs(),
`sub-agent "${opts.agentName}"`,
);
if ("error" in settled) {
// Abort the still-running session so it can't keep burning provider
// calls; the background settle path then finishes and disposes too.
try {
session.dispose();
} catch {
/* ignore dispose errors */
}
return { ok: false, text: "", error: settled.error };
}
return settled.value;
}
/**
* Run an in-memory agent session to completion, streaming tool events to the
* chat forwarder, and return the agent's final text. Owns session cleanup.
*/
async function runSessionToCompletion(
session: AgentSession,
opts: AgentTaskOptions,
): Promise<AgentRunResult> {
const acc: SessionEventAccumulator = { text: "" };
try {
let text = "";
let stopReason: string | undefined;
let errorMessage: string | undefined;
const unsubscribe = session.subscribe((event: AgentSessionEvent) => {
if (
event.type === "message_update" &&
event.assistantMessageEvent.type === "text_delta"
) {
text += event.assistantMessageEvent.delta;
}
if (event.type === "message_end") {
// Capture the full assistant text from the finalized message —
// models that don't stream text_delta (or truncate) still surface
// their output here. Prefer the streamed text when non-empty.
const message = event.message as {
role?: string;
content?: unknown;
stopReason?: string;
errorMessage?: string;
};
if (message.stopReason) stopReason = message.stopReason;
if (message.errorMessage) errorMessage = message.errorMessage;
if (message.role === "assistant") {
const full = extractAssistantText(message.content).trim();
if (full && !text.trim()) text = full;
}
}
// Forward the stream-driving events to the chat forwarder; it turns
// each into its own `pygienium-stream` message (see index.ts).
if (
event.type === "tool_execution_start" ||
event.type === "tool_execution_end" ||
event.type === "message_end"
) {
opts.onEvent?.(event);
}
applySessionEvent(acc, event, opts.onEvent);
});
await session.prompt(opts.task, { expandPromptTemplates: false });
// Ensure the agent has fully settled (tool calls may still be in-flight
@@ -168,17 +257,17 @@ export async function defaultAgentRunner(
// Surface session errors that didn't throw but left no useful output.
// A session ending with stopReason "error" and no text means the model
// call failed silently — treat that as a failed run, not ok:true.
if (errorMessage) {
return { ok: false, text, error: errorMessage };
if (acc.errorMessage) {
return { ok: false, text: acc.text, error: acc.errorMessage };
}
if (!text.trim() && stopReason === "error") {
if (!acc.text.trim() && acc.stopReason === "error") {
return {
ok: false,
text,
text: acc.text,
error: "sub-agent session ended in error with no output.",
};
}
return { ok: true, text };
return { ok: true, text: acc.text };
} catch (err) {
return {
ok: false,
@@ -194,6 +283,83 @@ export async function defaultAgentRunner(
}
}
/** Running capture state while a session streams assistant output. */
export interface SessionEventAccumulator {
/** Joined assistant text seen so far (text_delta stream). */
text: string;
/** stopReason of the final assistant message, when reported. */
stopReason?: string;
/** errorMessage of the final assistant message, when reported. */
errorMessage?: string;
}
/**
* Interpret one session event into the running accumulator and forward the
* stream-driving events to the chat.
*
* MUST never throw: it runs synchronously inside the SDK's event pipeline
* (`session.subscribe` listeners are invoked from `processEvents`/`_emit`).
* A throw there is not contained — the SDK's run-failure path re-emits
* failure events through the same callback, so a callback that always throws
* recurses until stack overflow and crashes the host, stranding run-state at
* the phase boundary with no save and no completion. Guard every shape (the
* runtime events are partial/streaming and weaker than their types) and never
* let the cosmetic chat forwarder take the run down.
*/
export function applySessionEvent(
acc: SessionEventAccumulator,
event: AgentSessionEvent,
forward?: (event: AgentSessionEvent) => void,
): void {
if (!event) return;
try {
if (
event.type === "message_update" &&
event.assistantMessageEvent?.type === "text_delta"
) {
acc.text += event.assistantMessageEvent.delta ?? "";
}
if (event.type === "message_end") {
// Capture the full assistant text from the finalized message —
// models that don't stream text_delta (or truncate) still surface
// their output here. Prefer the streamed text when non-empty.
const message = event.message as
| {
role?: string;
content?: unknown;
stopReason?: string;
errorMessage?: string;
}
| undefined;
if (message) {
if (message.stopReason) acc.stopReason = message.stopReason;
if (message.errorMessage) acc.errorMessage = message.errorMessage;
if (message.role === "assistant") {
const full = extractAssistantText(message.content).trim();
if (full && !acc.text.trim()) acc.text = full;
}
}
}
} catch (err) {
// A malformed event must not crash the SDK event pipeline; log and skip.
console.error("pygienium: error processing sub-agent event", err);
}
// Forward the stream-driving events to the chat forwarder; it turns each
// into its own `pygienium-stream` message (see index.ts). Cosmetic chat
// UI: a broken forwarder must not fail or hang the agent run.
if (
event.type === "tool_execution_start" ||
event.type === "tool_execution_end" ||
event.type === "message_end"
) {
try {
forward?.(event);
} catch (err) {
console.error("pygienium: error forwarding sub-agent event", err);
}
}
}
/**
* Extract joined text from an assistant message's content blocks.
* Mirrors piolium's `extractAssistantText`.