Feat/llm retry report:SDK retry-attempt observability for review (#785) - #790
Open
Gongyl01 wants to merge 5 commits into
Open
Feat/llm retry report:SDK retry-attempt observability for review (#785)#790Gongyl01 wants to merge 5 commits into
Gongyl01 wants to merge 5 commits into
Conversation
Add the internal data layer for an explicit LLM request retry report: request identity, attempt classification, and a per-run collector that freezes into an immutable report. No behavior change — nothing is mounted on any client and no output is produced, so this is inert until the observer is wired up. - RequestMeta identifies one logical request (provider, model, file path, task type, request no) and travels through the request context, so the single-method LLMClient interface and every call site stay unchanged. - logical_request_id is SHA-256 over a canonical NUL-terminated encoding of run_id plus the meta. It is computed in Freeze, so the collector can be constructed before the session exists. - classifyAttempt derives error_class and failure_phase from the HTTP status and the Go error type only, never from error message text. A non-2xx status outranks the error, since it is the stronger fact. - RetryCollector is created per run with no package-level state, is safe for concurrent use, and drops attempts that carry no identity, which is how scan and llm test requests stay out of the report. - The request outcome is decided once, in Finalize, from the attempt sequence plus the returned error and the parent context state, rather than inferred from the last attempt: cancelling during backoff produces no new attempt, so the sequence still ends in an error while the outcome is cancelled. - Freeze recomputes every aggregate from the listed requests and returns a construction error instead of publishing self-contradictory numbers. Ordering bugs (double Finalize, mutation after Finalize) are recorded as violations and surface there. The report has no free-text field, so there is nothing to redact: no bodies, prompts, URLs or raw SDK error strings. A test pins the exact set of plain string fields so adding one has to be argued for. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: 艺临 <gongyiling.gyl@alibaba-inc.com>
Mount a shared observer on all three LLM clients (Anthropic, OpenAI Chat Completions, OpenAI Responses) through option.WithMiddleware, so every real HTTP attempt the SDK retry loop makes is recorded against the logical request that issued it. The observer reads response headers only -- status code, request-id / x-request-id, Retry-After (all three forms, at the SDK's own precedence), x-should-retry -- and never touches the body, which the SDK owns and closes before retrying. Attempts without a RequestMeta on the context are dropped whole, which is how scan and `ocr llm test` stay out of the report. RecordAttempt now takes the attempt's start and end timestamps instead of pre-computed durations. observed_backoff_ms spans two attempts, so only the collector can derive it; deriving both durations there also means the observer cannot desynchronize numbering from the real call order. No clock abstraction is needed and the values stay deterministic in tests. The collector is reached through an unexported ClientConfig field rather than new constructor parameters, keeping the three exported constructors unchanged. It is created per run in loadLLMRuntime, not package-level, so two runs in one process cannot share data. Nothing consumes it yet -- P5 calls Freeze at the run boundary. The roadmap's X-Stainless-Retry-Count cross-check is deliberately not implemented: the SDK stops maintaining that header once ExtraHeaders overrides it, so the mismatch branch is only reachable from a legitimate configuration, and the desync it guards against is already caught at build time by the exhaustion and recovery tests asserting exact attempt counts. WithMaxRetries(5) and WithRequestTimeout are untouched; the SDK's retry decisions are observed, never overridden. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The middleware can only observe real HTTP attempts, so an HTTP 200 that carried a truncated body, undecodable JSON, a mid-stream failure, or a dead Responses object was recorded as a success. Each client now corrects its last attempt before returning and finalizes the logical request exactly once. - add retry_boundary.go: classifyBoundaryError (unrecognized errors are left alone rather than bucketed as unknown, since the only way left to tell them apart would be message text), classifyStreamError, reviseAttempt, finalizeRequest, streamIntegrityError and the panic sentinel - defer the boundary on all three CompletionsWithCtx, which now use named results; correction runs before Finalize, as the reverse order would be a "revised after Finalize" violation and drop the whole run's report - correct both EOF branches ahead of their ctx early return, so a parent cancel between the two SDK calls cannot leave a truncated attempt as success - split completionsStreaming into a wrapper with a single exit, so the four inner returns need no correction call of their own - replace the three bare fmt.Errorf stream integrity errors with a dedicated type, messages unchanged - parentCancelled reads only context.Canceled: the per-attempt deadline from WithRequestTimeout must surface as failed, not as a user abort - drop finalizeForTest from the observer tests; every case now reaches Freeze through a client, so a missing defer fails that case instead of passing Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
review 的五类逻辑请求在调用 SDK 前建立 RequestMeta,使 observer 能按请求身份收集 attempt;scan 的六类请求保持无 meta、不进报告。 - Deps 增加 NewRequestMeta 工厂字段:review 在 agent.New 注入闭包,scan 保持 nil;不用空 provider 当开关,空串是 unnamed endpoint 的合法值 - main_task / memory compression / re-location / plan / review filter 五个落点遵循固定顺序:AppendTaskRecord -> requestCtx -> 请求 - compression 的记录创建移到请求之前,使 request_no 在请求发起时即存在;orphan llm_request 对 resume 无害(applyResumeLine 无该分支),补回归断言 - ReLocateComment 拆出纯 prompt 构造 BuildReLocationMessages,internal/diff 不接触 session / meta;Duration 口径保持含 prompt 构造时间不变 - 导出 RequestMetaFromContext,供 llmloop / agent / scan 三包的测试跨包验收请求身份
在 review 运行边界冻结重试报告并经两个出口发布;scan 与 llm test 输出不变,session JSONL 与 run manifest 契约不动。 - Runner 增加后台 WaitGroup 与 WaitBackground():agent.Run 在 dispatchSubtasks 之后、finalizeManifest 之前收口 async compression,消除 Freeze 见到未 Finalize 请求而吞掉整份报告的竞态;不加第二个超时,等待依赖 SDK 遵守取消契约 - review_cmd.go 在 ag.Run 返回后调用 Freeze,run_id 取 session 内存 UUID 而非持久化门控的 SessionID();构造错误并入 emitErr 而非 runErr,不包装成 review failed、不触发失败 usage、不打 --resume 提示 - 报告以末位参数传给 emitRunResult / outputJSONWithWarnings,不扩展 ResultProvider;双出口去重:emitRunResult 已执行时 emitFailureUsage 不重复携带 - 终端摘要走 stdout,位于评审结果与项目摘要之间,全量渲染不截断,file_path / task_type 经 sanitizeTerminal 防控制字符注入 - JSON 在 jsonOutput 末位追加 retry_report(omitempty),直接复用 llm.RetryReport 的字段与 tag;首次成功运行输出逐字节不变 - 端到端:假 Anthropic server + 真 git 仓库驱动 runReview,覆盖干净运行、recovered+failed、全失败去重、Freeze 构造错误、session 持久化失败五个场景;manual_e2e tag 保留写码前的手工验证夹具
Contributor
|
Thank you, but this is big and hard to review. |
Contributor
|
✅ OpenCodeReview: Review partially complete: 0 finding(s); 2 of 16 selected item(s) failed. |
Contributor
Author
Hi @wu21-web, Thanks for the feedback! To help with the review, here's a breakdown — about 4,670 of the 6,197 added lines (~75%) are tests. The core implementation is only ~1,500 lines: New core files (suggested reading order):
Integration points:
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Closes #785.
Part of #368.
Adds a versioned, immutable
RetryReporttoocr reviewthat records the real HTTP attempts made inside the official Anthropic and OpenAI SDK retry loops. OCR currently sees only the final return value of a logical LLM request, so a request that receives 429, then 529, and finally succeeds is indistinguishable from a first-attempt success.The report is frozen once after the review run and its background work have finished. Text output renders a compact attempt chain, while
--format jsonexposes the same frozen value under the optional top-levelretry_reportfield. A clean first-attempt-success run emits no report, so the existing output remains unchanged.This is the observability slice of #368. It explains what the existing SDK retry loops did; it does not take ownership of retry policy.
internal/llm/retry_report.go: adds theocr.llm-retry-report/v1value model and a per-run, concurrency-safeRetryCollector. The collector derives attempt numbers and timings, decides request outcomes exactly once, validates aggregate invariants, and freezes requests in deterministiclogical_request_idorder.internal/llm/retry_observer.go: mounts one shared Middleware implementation on Anthropic, OpenAI Chat Completions, and OpenAI Responses clients. It observes each real HTTP attempt without reading response bodies or overriding SDK decisions, recording status, provider request ID, server retry hints,x-should-retry, time to response headers, and the measured gap between attempts.internal/llm/retry_boundary.go+ client boundaries: correct attempts whose failure becomes visible only after HTTP 200, including unexpected EOF or malformed decoding, interrupted/incomplete SSE streams, and unsuccessful Responses object statuses. Every logical request finalizes on success, error, cancellation, or panic.internal/llm/retry_meta.go+ review call sites: stamps stable, non-secret identity onplan,main_task, memory compression, re-location, and review filter requests. The identity joins the report to existing session task records;scanandllm testremain outside the report.cmd/opencodereview: freezes the collector at the same run boundary as the manifest and publishes the report exactly once across normal and failure exits. Terminal and JSON output consume the same immutable snapshot.Commits (5 layered slices)
6c7525cfeat(llm): add retry report data layer5b9509bfeat(llm): observe retry attempts via SDK middleware4145d13feat(llm): correct attempts and finalize requests at the client boundary8fec2a3feat(llm): stamp request identity on review LLM requestse41741efeat(cmd): publish the frozen retry report at the run boundaryDesign (key invariants)
WithMaxRetries(5), SDK backoff or jitter,Retry-Afterbehavior, or retry admission.succeeded,recovered,failed, orcancelled. It is never inferred from the last attempt alone.Agent.Runhas joined background work. Normal result and failure-usage paths cannot publish the same report twice, and an invariant violation suppresses the self-contradictory report instead of emitting partial data.logical_request_idoutput make concurrent runs reproducible.retry_reportis optional and usesomitempty; a run with only clean first attempts keeps its previous terminal and JSON output.Attempt classification
error_classrate_limitedoverloadedauthenticationtimeoutcontext.DeadlineExceedednetworkprovidercancelledcontext.Canceled, including explicit parent-context cancellationunknownOutput contract
Example terminal output:
The JSON path is additive:
The terminal summary and
retry_reportare generated from the same frozenRetryReport; JSON mode emits one JSON document and never mixes in the terminal summary.Upstream integration
This branch is synchronized through
upstream/main@62e2b99. The retry report is published alongside #367's frozen run manifest without changing its coverage or terminal-state contract. Existing scan output,llm test, Resume/checkpoint behavior, budget reporting, and session persistence semantics remain unchanged.How to test
Checklist
make testpasses locally (full non-extension package suite with-race -count=1)make vetpassesgit diff --check upstream/main...HEADis cleangofmt -lreports no changed Go filesretry_reportOut of scope (deliberately)