Skip to content

fix(agent-runtime): classify the network cause behind NETWORK_ERROR - #435

Merged
vastsa merged 3 commits into
mainfrom
fix/issue-234-network-cause-diagnostics
Sep 16, 2026
Merged

vastsa merged 3 commits into
mainfrom
fix/issue-234-network-cause-diagnostics

Conversation

@vastsa

@vastsa vastsa commented Sep 15, 2026

Copy link
Copy Markdown
Owner

Summary

Reported in #234: one Turn in a long Codex session failed 10 consecutive retries over ~112 s, every attempt surfacing only NETWORK_ERROR: fetch failed with phase=stream, streamMs=1~2 and no error.cause, while a new Desktop session and a new Turn in the same session both worked immediately. The reporter could not tell which layer failed, so the report's own root-cause hypothesis could not be tested.

This PR makes the failing layer identifiable and correlates it with request size, without changing the retry policy.

One suggestion in the report is obsolete

The report asks to write the sanitized cause chain into agent/timing.log. docs/adr/0212-remove-diagnostic-timing-log-streams.md (Accepted 2026-09-10) deleted the sidecar [timing] lines, the timing log category and PI_DESKTOP_TIMING, and the report's logs predate that on 0.14.6. This PR therefore does not reintroduce any timing output. Per ADR 0212, failures stay diagnosable through lifecycle records, error codes and the transcript — which is the channel used here. The issue thread should be corrected on this point.

Changes

  • packages/agent-runtime/src/agent-errors.ts — the network branch of classifyAgentError now walks the cause chain for a low-cardinality classification: networkCategory (dns | tls | timeout | refused | unreachable | reset | proxy | unknown), plus networkCode (errno-style), networkSyscall and networkHost.
    • It deliberately does not add user-visible codes such as DNS_ERROR / TLS_ERROR. A new code needs an i18n string in every shipped locale and a registry entry (error-code-registry.test.mjs enforces it) for no additional diagnostic power; details is the already-specified channel for this (docs/spec/03-runtime/08-error-codes.md §5). STREAM_OPEN_FAILED is already expressed by the existing phase=stream + streamMs.
    • Every retained field is validated against a strict shape before it is kept: errno-ish tokens only, a syscall-shaped token, and a hostname regex that cannot match user:pass@host, host:8080, a URL, a path, a query or an IPv6 literal. A hostname taken from message text must contain a dot, so a credential-shaped token cannot be reported as a host. No raw provider text is retained.
  • packages/agent-runtime/src/provider-retry.tscaptureProviderResponse reports the outgoing request's byte length only (never the body content), including on a request that dies before headers, which is exactly the failure worth correlating.
  • packages/agent-runtime/src/runtime.ts — the error details now carry requestBytes, requestMessages and compactionGeneration; the retry activity error carries networkCode.
  • apps/desktop/.../transcript/ActivityGroup.tsx + shared.tsx — the retry bubble and the assistant error card show the errno next to the code (NETWORK_ERROR · ENOTFOUND). This is a bare technical token in the existing code-chip style, so it adds no i18n key.
  • packages/shared/src/types/sessions.ts — optional AgentActivityError.networkCode.
  • Specs updated in both languages: docs/spec/03-runtime/08-error-codes.md, 01-ipc-protocol.md, 02-agent-runtime.md and their docs/zh-CN mirrors.

What is deliberately NOT done

Suggestion 2 — "after several consecutive pre-stream failures, rebuild the host-proxy transport / HTTP dispatcher / keep-alive connection pool". A process-wide undici dispatcher does exist (node-proxy.ts setGlobalDispatcher + globalThis.fetch = undiciFetch; closeActiveDispatchers() runs only at boot and on a settings change), so this is technically reachable. But: rebuilding it on a failure counter is a global behavior change affecting every session, it needs its own ADR, and the evidence does not actually show retries reusing a dead connection — phase=stream with streamMs=1~2 means response headers had already been received. So it is reported, not implemented.

Suggestion 1 (a fresh request per attempt) was verified rather than changed: runtime.ts creates a new AbortController per retry, the retry re-drives agent.continue(), each round builds a new inner stream, and pi-ai constructs a new SDK client per request.

Validation

On the integrated local main (merge commit 93abcf0e, after merging this branch):

Command Result
pnpm build:js pass
pnpm --filter @pi-desktop/desktop typecheck pass
pnpm --filter @pi-desktop/agent-runtime typecheck pass
pnpm lint:biome pass
pnpm -r --if-present test pass — desktop 1980/1980, agent-runtime 472, shared 658, 0 fail
pnpm docs:check pass, 443 pages
pnpm test:e2e:transcript pass
pnpm test:e2e:boot pass
pnpm test:e2e:subagents pass, 31/31

Reverting the four source files to origin/main while keeping the new tests gives 14 failed / 195 passed, so the coverage is real.

Remaining risk (stated plainly)

The root cause is not fixed — only made diagnosable. Whether retries reused dead transport state is neither proven nor addressed. The next occurrence can now be classified (DNS vs TLS vs timeout vs refused vs reset vs proxy) and correlated with requestMessages / requestBytes / compactionGeneration, which is what is needed to decide whether the ADR-gated transport rebuild is warranted.

Known limitation: if the failure is in the proxy hop but the error text never says "proxy", it classifies as refused rather than proxy — the classifier has no access to the proxy configuration.

Refs #234

Issue #234: one turn retried ten times with `NETWORK_ERROR: fetch failed`
(phase=stream, streamMs=1-2) while a new turn in the same session recovered
immediately, and nothing in the logs identified the failing layer because no
`error.cause` was kept. `classifyAgentError` already walked the cause chain to
detect a network failure, but then returned a bare `result("NETWORK_ERROR",
true)` with no cause detail.

- Summarize the transport failure in bounded `details`: `networkCategory`
  (dns | tls | timeout | refused | unreachable | reset | proxy | unknown),
  `networkCode` (the errno from the cause chain, including undici's
  happy-eyeballs `AggregateError.errors`), `networkSyscall`, and `networkHost`.
- Keep the existing user-visible code. Per-layer codes (DNS_ERROR, TLS_ERROR,
  SOCKET_RESET, PROXY_ERROR, STREAM_OPEN_FAILED) would each need a spec entry
  and strings in all eight shipped locales; the category splits the layers
  inside the already-spec'd, non-user-visible `details` channel instead.
- Redaction: only errno-shaped codes, a lowercase syscall, and a bare hostname
  (never a URL, port, path, query, or credential) can reach the details, and
  `providerCode` is omitted when it would repeat `networkCode`.
- Correlate a failure that produced no response: `requestMessages`,
  `requestBytes` (measured at the fetch wrapper - byte size only, the body is
  never read) and `compactionGeneration`.
- Surface the errno where the user already is: the retry-reason popover and the
  assistant error card render `NETWORK_ERROR - ENOTFOUND` in the existing code
  chip, so no new locale strings are needed.

Suggestion 3 of the issue (write the sanitized cause chain to
`agent/timing.log`) is obsolete and is deliberately not implemented: ADR 0212
(Accepted 2026-09-10) removed the sidecar `[timing]` lines, the `timing` log
category, and `PI_DESKTOP_TIMING`. Failures stay diagnosable through the
channels ADR 0212 keeps - the `agent/session.log` error record (its `details`
pass through unwhitelisted), the stable error code, and the transcript. No
timing record is reintroduced.

Verified against the code and deliberately unchanged: every retry already
builds a fresh AbortController, stream, and SDK client
(`retryPendingProviderFailure` -> `agent.continue()` -> `createProviderRetryStream`
-> pi-ai), so suggestion 1 needed no change. A process-global undici dispatcher
does exist (`node-proxy.ts`), but rebuilding it after N failures is a behavior
change for every session and needs its own ADR; the reported evidence does not
prove that retries reused a dead pooled socket, so it is left alone.

Specs updated: 03-runtime/08-error-codes.md (details enumeration and the new
network diagnosis), 03-runtime/01-ipc-protocol.md, 03-runtime/02-agent-runtime.md
and their zh-CN pairs.

Refs #234
Follow-up to the review of the previous commit (issue #234):

- Validate message-derived candidates before they can be reported: the error
  message is untrusted provider text, so an errno-shaped run inside it
  (`EDNS` + 3000 characters) can no longer become an unbounded `networkCode`,
  and the message scan is capped at 16 candidates.
- Anchor the proxy code pattern on the code itself instead of any occurrence of
  "proxy" inside a body word, and pick a proxy-classified code from anywhere in
  the cause chain: undici reports the proxy's own socket errno as a deeper
  cause, so traversal order was deciding the category the comment claimed was
  decided by specificity.
- A hostname read out of the message now has to be dotted, so a
  credential-shaped token, or the `user` left over from a truncated
  `getaddrinfo ENOTFOUND user:pass@host`, is never reported as `networkHost`.
  A Node-supplied `err.hostname` keeps its single-label case (`localhost`).
- Repair the spec sentences the previous commit's insertions broke, in both the
  English and the zh-CN spec pairs.
- Cover both renderer surfaces in the existing source-contract test, and cover
  the unmeasurable-body and bounded-token cases in the unit tests.

Refs #234

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

@vastsa
vastsa merged commit f7135b9 into main Sep 16, 2026
4 checks passed
@vastsa
vastsa deleted the fix/issue-234-network-cause-diagnostics branch September 16, 2026 00:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants