action: surface agent hang context in progress comment (#733)
* action: surface agent hang context in progress comment When the activity-timeout watchdog kills a stalled opencode subprocess, the user used to see a bare "activity timeout: no output for 30Xs" — no provider context, no stderr trace, no clue why the run died. Investigation of the six runs in #728 showed the same shape every time: opencode hangs after a non-retryable provider event (auth 401, 502 stream lost, free-tier flake), and the only useful signal was buried in stderr where the user couldn't see it without diving into Actions logs. Stop trying to prevent the hang. Surface it. Add a small `AgentDiagnostic` handle on `toolState` that the harness mutates as a run progresses (recent stderr ring buffer reference, last provider-error label, event count). `formatAgentHangBody` renders that into a markdown body — bold headline, one-line explanation, collapsible `<details>` with the last ~10 stderr lines (capped to 3KB) — used by both the agent harness's own catch path and main.ts's outer catch when the watchdog wins the race against the harness. Both paths converge on one formatter; the existing "View workflow run ➔" footer affordance in `reportErrorToComment` is unchanged, so the user still has one click from the comment to the raw logs to develop their own thesis. * address review: gate hang body on isHang; fix contradictory copy - Only render `hangBody` when `isHang`. The harness sets `agentDiagnostic` on entry, so any non-hang throw past `runOpenCode`'s own catch (post-success `output_schema` validator, late cleanup throws) was rendering "Pullfrog failed — N events processed…" with the real exception message dropped — including for runs that actually succeeded before a late throw. - When `lastProviderError` already names the cause in the headline, the zero-events sentence "check whether the model provider is reachable" contradicts it (a 401 produces zero events but isn't a reachability issue). Drop the nudge in that case; keep it for the silent-stall path where it's still actionable. * address copilot review: fence escape, idle parsing, secret redaction, tests - pick a backtick fence longer than any backtick run in the rendered stderr tail. opencode error JSON occasionally embeds triple backticks in tool input dumps; the fixed three-tick fence let those terminate the fence early and corrupt the rest of the comment markdown. - parse idle seconds out of the timer reject string ("activity timeout: no output for 301s") and use that for the hang explanation. previously rendered total runtime, which overstated the stall by 20+ minutes for runs that streamed for a long time before going quiet (e.g. Rohithgilla12/data-peek#25784038918, 1230s elapsed but 304s idle). - redact sensitive env-var values from the rendered stderr tail before it lands in the PR comment / job summary. workflow log writes already go through `core.setSecret` masking; PR comments and summaries bypass that pipeline entirely. matches against `isSensitiveEnvName` (the same *_KEY/*_TOKEN/*_SECRET/*_PASSWORD/*_CREDENTIAL surface that `normalizeEnv` registers with the runner) and only redacts values >= 8 chars to avoid false-positive substring hits. - add `agentHangReport.test.ts` covering the branchy bits: idle-seconds parsing, eventCount-zero copy with and without provider error, fence-escape against embedded triple backticks, 3 KB tail truncation, null-on-no-diagnostic, and secret redaction. `startedAtMs` is dropped from `AgentDiagnostic` — total runtime was the only consumer and idle seconds replaces it. * strip slop: drop tests, drop redactSecrets, simplify ternary - delete `agentHangReport.test.ts`. half the cases just pinned literal copy ("**Pullfrog stalled**", "check whether the model provider is reachable") which is exactly the "performative tests to every string utility" pattern AGENTS.md flags. the other half tested 2-5 line pure helpers (parseIdleSec / pickFence / truncation) that code review catches. the formatter is a best-effort string output; pinning it in tests creates churn without catching real regressions. - remove `redactSecrets` and revert the formatter's import. theatrical defense: opencode doesn't dump env on startup, bearer tokens aren't in request bodies, bash is denied. the action has many other PR-comment write paths that don't redact (comment.ts, errorReport.ts, the progress writer) — if PR-comment secret hygiene matters, it's a cross-cutting concern at the comment-write layer, not bolted onto one formatter. - factor the explanation triple-ternary into `formatExplanation` with early returns. same logic, easier to read. `isHang` gate, fence-length escaping, and idle-seconds parsing stay — those are real correctness fixes.
This commit is contained in:
committed by
pullfrog[bot]
parent
b9383bbcfd
commit
ba7f5a0b89
@@ -16,6 +16,7 @@ import {
|
||||
DEFAULT_ACTIVITY_TIMEOUT_MS,
|
||||
} from "./utils/activity.ts";
|
||||
import { resolveAgent, resolveModel } from "./utils/agent.ts";
|
||||
import { formatAgentHangBody } from "./utils/agentHangReport.ts";
|
||||
import { apiFetch } from "./utils/apiFetch.ts";
|
||||
import {
|
||||
formatApiKeyErrorSummary,
|
||||
@@ -1123,12 +1124,32 @@ export async function main(): Promise<MainResult> {
|
||||
? new BillingError(errorMessage, { code: "router_keylimit_exhausted" })
|
||||
: null;
|
||||
|
||||
// when the activity-timeout watchdog wins the race against the agent
|
||||
// harness's own catch, the bare timer reject reason ("activity timeout:
|
||||
// no output for 302s") tells the user nothing actionable. the harness
|
||||
// keeps a structured diagnostic on toolState as it runs — recent stderr,
|
||||
// last provider-error label, event count — and `formatAgentHangBody`
|
||||
// renders that as a markdown body suitable for both the job summary tab
|
||||
// and the PR progress comment.
|
||||
//
|
||||
// gated on isHang because the harness sets `agentDiagnostic` on entry,
|
||||
// so any non-hang throw that hits the outer catch (e.g. the post-success
|
||||
// output_schema validator, or a late cleanup throw after the run already
|
||||
// succeeded) would otherwise render "Pullfrog failed" with stale event
|
||||
// counts and silently drop the real `errorMessage`.
|
||||
const isHang =
|
||||
errorMessage.startsWith("activity timeout") || errorMessage.startsWith("agent still pending");
|
||||
const hangBody = isHang
|
||||
? formatAgentHangBody({ diagnostic: toolState.agentDiagnostic, isHang: true, errorMessage })
|
||||
: null;
|
||||
|
||||
const apiKeySource = hangBody ?? errorMessage;
|
||||
const apiKeyErrorSummary =
|
||||
!billingError && isApiKeyAuthError(errorMessage)
|
||||
!billingError && isApiKeyAuthError(apiKeySource)
|
||||
? formatApiKeyErrorSummary({
|
||||
owner: runContext.repo.owner,
|
||||
name: runContext.repo.name,
|
||||
raw: errorMessage,
|
||||
raw: apiKeySource,
|
||||
})
|
||||
: null;
|
||||
|
||||
@@ -1136,7 +1157,10 @@ export async function main(): Promise<MainResult> {
|
||||
try {
|
||||
const errorSummary = billingError
|
||||
? formatBillingErrorSummary(billingError, runContext.repo.owner)
|
||||
: (apiKeyErrorSummary ?? `### ❌ Pullfrog failed\n\n\`\`\`\n${errorMessage}\n\`\`\``);
|
||||
: (apiKeyErrorSummary ??
|
||||
(hangBody
|
||||
? `### ❌ Pullfrog failed\n\n${hangBody}`
|
||||
: `### ❌ Pullfrog failed\n\n\`\`\`\n${errorMessage}\n\`\`\``));
|
||||
const usageSummary = formatUsageSummary(toolState.usageEntries);
|
||||
const parts = [errorSummary, toolState.lastProgressBody, usageSummary].filter(Boolean);
|
||||
await writeSummary(parts.join("\n\n"));
|
||||
@@ -1145,7 +1169,7 @@ export async function main(): Promise<MainResult> {
|
||||
try {
|
||||
const commentBody = billingError
|
||||
? formatBillingErrorSummary(billingError, runContext.repo.owner)
|
||||
: (apiKeyErrorSummary ?? errorMessage);
|
||||
: (apiKeyErrorSummary ?? hangBody ?? errorMessage);
|
||||
await reportErrorToComment({ toolState, error: commentBody });
|
||||
} catch {
|
||||
// error reporting failed, but don't let it mask the original error
|
||||
|
||||
Reference in New Issue
Block a user