mirror of
https://github.com/alibaba/open-code-review.git
synced 2026-08-21 06:34:29 +00:00
* feat(llm): add retry report data layer 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> * feat(llm): observe retry attempts via SDK middleware 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> * feat(llm): correct attempts and finalize requests at the client boundary 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> * feat(llm): stamp request identity on review LLM requests 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 三包的测试跨包验收请求身份 * feat(cmd): publish the frozen retry report at the run boundary 在 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 保留写码前的手工验证夹具 * test(cmd): consolidate retry report tests by responsibility The retry-report tests for #368 P5 split coverage of emitRunResult and emitFailureUsage into their own file, leaving the review-run emit functions tested in two places. Move those emit-boundary cases into emit_run_result_test.go beside the pre-existing emitRunResult tests, and rename the remaining file to retry_report_render_test.go so it holds only the report-rendering cases (outputRetryReportText, the JSON key-set allowlist, retryAttemptChain). The shared retryReportFixture stays with the rendering tests; both files are package main so it is still reachable. No test logic changes; only relocation. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix: separate cancelled retry requests --------- Signed-off-by: 艺临 <gongyiling.gyl@alibaba-inc.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
641 lines
21 KiB
Go
641 lines
21 KiB
Go
// SPDX-License-Identifier: Apache-2.0
|
|
// Copyright 2026 alibaba/open-code-review Contributors
|
|
|
|
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"strings"
|
|
"time"
|
|
"unicode"
|
|
|
|
"github.com/alibaba/open-code-review/internal/agent"
|
|
"github.com/alibaba/open-code-review/internal/llm"
|
|
"github.com/alibaba/open-code-review/internal/model"
|
|
"github.com/alibaba/open-code-review/internal/session"
|
|
"github.com/alibaba/open-code-review/internal/suggestdiff"
|
|
)
|
|
|
|
func outputText(comments []model.LlmComment) {
|
|
if len(comments) == 0 {
|
|
fmt.Println("No comments generated. Looks good to me.")
|
|
return
|
|
}
|
|
for _, c := range comments {
|
|
renderComment(c)
|
|
}
|
|
}
|
|
|
|
func hasSubtaskErrors(warnings []agent.AgentWarning) bool {
|
|
for _, w := range warnings {
|
|
if isSubtaskErrorType(w.Type) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// warningsForOutput removes coverage-level subtask diagnostics once a manifest
|
|
// is present. Their classification and safe summary already live in the frozen
|
|
// coverage.failed set; retaining the original warning would duplicate that fact
|
|
// and could expose the provider's raw error text in JSON. Non-coverage warnings
|
|
// remain visible, and legacy output keeps its existing warning behavior.
|
|
func warningsForOutput(warnings []agent.AgentWarning, manifest *session.RunManifest) []agent.AgentWarning {
|
|
if manifest == nil || len(warnings) == 0 {
|
|
return warnings
|
|
}
|
|
filtered := make([]agent.AgentWarning, 0, len(warnings))
|
|
for _, warning := range warnings {
|
|
if !isSubtaskErrorType(warning.Type) {
|
|
filtered = append(filtered, warning)
|
|
}
|
|
}
|
|
if len(filtered) == 0 {
|
|
return nil
|
|
}
|
|
return filtered
|
|
}
|
|
|
|
func isSubtaskErrorType(warningType string) bool {
|
|
return warningType == "subtask_error" || warningType == "scan_subtask_error"
|
|
}
|
|
|
|
func outputTextWithWarnings(comments []model.LlmComment, warnings []agent.AgentWarning, manifest *session.RunManifest) {
|
|
if manifest != nil {
|
|
fmt.Println(manifestMessage(manifest, len(comments)))
|
|
for _, c := range comments {
|
|
renderComment(c)
|
|
}
|
|
} else if len(comments) == 0 {
|
|
if hasSubtaskErrors(warnings) {
|
|
fmt.Println("Some files could not be reviewed due to errors (see warnings below).")
|
|
} else {
|
|
fmt.Println("No comments generated. Looks good to me.")
|
|
}
|
|
} else {
|
|
for _, c := range comments {
|
|
renderComment(c)
|
|
}
|
|
}
|
|
for _, w := range warnings {
|
|
if isSubtaskErrorType(w.Type) {
|
|
continue
|
|
}
|
|
fmt.Fprintf(os.Stderr, "[ocr] WARNING [%s] %s: %s\n", w.Type, sanitizeTerminal(w.File), sanitizeTerminal(w.Message))
|
|
}
|
|
}
|
|
|
|
func renderComment(comment model.LlmComment) {
|
|
lines := buildDiffLines(comment)
|
|
if len(lines) == 0 && comment.Content == "" {
|
|
return
|
|
}
|
|
|
|
fmt.Printf("\n\033[2m─── %s:%d-%d ───\033[0m\n", sanitizeTerminal(comment.Path), comment.StartLine, comment.EndLine)
|
|
|
|
if comment.Content != "" {
|
|
badge := buildBadge(comment)
|
|
content := sanitizeTerminal(comment.Content)
|
|
if badge != "" {
|
|
// Prepend the plain badge text to the content so it wraps inline with
|
|
// the first line, then colorize just the badge prefix after wrapping.
|
|
content = badge + " " + content
|
|
}
|
|
lines := wrapByRunes(content, 100)
|
|
for i, ln := range lines {
|
|
if i == 0 && badge != "" && strings.HasPrefix(ln, badge) {
|
|
color := severityColor(comment.Severity)
|
|
ln = color + badge + "\033[0m" + ln[len(badge):]
|
|
}
|
|
fmt.Printf("%s\n", ln)
|
|
}
|
|
fmt.Println()
|
|
}
|
|
|
|
if len(lines) > 0 {
|
|
for _, dl := range lines {
|
|
switch dl.Type {
|
|
case suggestdiff.DiffAdded:
|
|
printDiffLine("+", sanitizeTerminal(dl.Content), "\033[92m", "\033[48;2;0;60;0m")
|
|
case suggestdiff.DiffDeleted:
|
|
printDiffLine("-", sanitizeTerminal(dl.Content), "\033[91m", "\033[48;2;70;0;0m")
|
|
case suggestdiff.DiffContext:
|
|
printDiffLine(" ", sanitizeTerminal(dl.Content), "\033[2m", "\033[48;2;38;38;38m")
|
|
}
|
|
}
|
|
}
|
|
|
|
fmt.Println()
|
|
}
|
|
|
|
// buildBadge renders a compact "[category · severity]" tag for a finding. It returns
|
|
// an empty string when neither structured field is present, so text output for findings
|
|
// without metadata is unchanged.
|
|
func buildBadge(comment model.LlmComment) string {
|
|
category := sanitizeTerminal(comment.Category)
|
|
severity := sanitizeTerminal(comment.Severity)
|
|
switch {
|
|
case category != "" && severity != "":
|
|
return fmt.Sprintf("[%s · %s]", category, severity)
|
|
case category != "":
|
|
return fmt.Sprintf("[%s]", category)
|
|
case severity != "":
|
|
return fmt.Sprintf("[%s]", severity)
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
// severityColor maps a finding severity to an ANSI color used for its badge.
|
|
// Unknown or empty severities fall back to dim.
|
|
func severityColor(severity string) string {
|
|
switch severity {
|
|
case "critical":
|
|
return "\033[1;91m" // bold bright red
|
|
case "high":
|
|
return "\033[91m" // bright red
|
|
case "medium":
|
|
return "\033[93m" // bright yellow
|
|
case "low":
|
|
return "\033[94m" // bright blue
|
|
default:
|
|
return "\033[2m" // dim
|
|
}
|
|
}
|
|
|
|
// printDiffLine renders a single diff line with colored prefix and background on content.
|
|
func printDiffLine(prefix, content, fgColor, bgColor string) {
|
|
fmt.Printf("%s%s%s %s%s\033[0m\n", fgColor+bgColor, prefix, "\033[0m"+bgColor, content, "\033[0m")
|
|
}
|
|
|
|
// wrapByRunes splits text into lines that fit within maxWidth **rune** columns.
|
|
// Respects existing newlines and wraps at word boundaries.
|
|
func wrapByRunes(text string, maxW int) []string {
|
|
if text == "" {
|
|
return nil
|
|
}
|
|
var result []string
|
|
for _, para := range strings.Split(text, "\n") {
|
|
result = append(result, wrapSingleRuneLine(para, maxW)...)
|
|
}
|
|
return result
|
|
}
|
|
|
|
// wrapSingleRuneLine breaks one paragraph (no newlines) into rune-width-constrained lines.
|
|
func wrapSingleRuneLine(line string, maxW int) []string {
|
|
runes := []rune(line)
|
|
if visibleRunesLen(runes) <= maxW {
|
|
return []string{line}
|
|
}
|
|
var result []string
|
|
for len(runes) > 0 {
|
|
cut := runeWrapCut(runes, maxW)
|
|
result = append(result, string(runes[:cut]))
|
|
runes = runes[cut:]
|
|
// trim leading spaces of next segment
|
|
for len(runes) > 0 && runes[0] == ' ' {
|
|
runes = runes[1:]
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
// runeWrapCut returns a rune index suitable for breaking the line at ~maxW display width.
|
|
func runeWrapCut(runes []rune, maxW int) int {
|
|
if visibleRunesLen(runes) <= maxW {
|
|
return len(runes)
|
|
}
|
|
best := maxW
|
|
if best >= len(runes) {
|
|
return len(runes)
|
|
}
|
|
for i := best; i > 0; i-- {
|
|
if runes[i] == ' ' || runes[i] == '\t' {
|
|
return i
|
|
}
|
|
}
|
|
return best
|
|
}
|
|
|
|
func visibleRunesLen(runes []rune) int {
|
|
n := 0
|
|
for _, r := range runes {
|
|
if r >= 32 && r != 127 {
|
|
n++
|
|
}
|
|
}
|
|
return n
|
|
}
|
|
|
|
func sanitizeTerminal(s string) string {
|
|
var b strings.Builder
|
|
b.Grow(len(s))
|
|
for _, r := range s {
|
|
if r == '\t' || r == '\n' || !unicode.IsControl(r) {
|
|
b.WriteRune(r)
|
|
}
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
func splitToLines(s string) []string {
|
|
lines := strings.Split(strings.ReplaceAll(s, "\r\n", "\n"), "\n")
|
|
if len(lines) > 0 && lines[len(lines)-1] == "" {
|
|
lines = lines[:len(lines)-1]
|
|
}
|
|
return lines
|
|
}
|
|
|
|
func buildDiffLines(comment model.LlmComment) []suggestdiff.DiffLine {
|
|
if comment.SuggestionCode == "" || comment.ExistingCode == "" {
|
|
return nil
|
|
}
|
|
oldLines := splitToLines(comment.ExistingCode)
|
|
newLines := splitToLines(comment.SuggestionCode)
|
|
return suggestdiff.ComputeLineDiff(oldLines, newLines)
|
|
}
|
|
|
|
type jsonSummary struct {
|
|
FilesReviewed int64 `json:"files_reviewed"`
|
|
Comments int64 `json:"comments"`
|
|
TotalTokens int64 `json:"total_tokens"`
|
|
InputTokens int64 `json:"input_tokens"`
|
|
OutputTokens int64 `json:"output_tokens"`
|
|
CacheReadTokens int64 `json:"cache_read_tokens,omitempty"`
|
|
CacheWriteTokens int64 `json:"cache_write_tokens,omitempty"`
|
|
Elapsed string `json:"elapsed"`
|
|
BudgetExceeded bool `json:"budget_exceeded,omitempty"`
|
|
}
|
|
|
|
type jsonToolCalls struct {
|
|
Total int64 `json:"total"`
|
|
ByTool map[string]int64 `json:"by_tool"`
|
|
}
|
|
|
|
type jsonLLMIdentity struct {
|
|
Provider string `json:"provider,omitempty"`
|
|
Model string `json:"model"`
|
|
}
|
|
|
|
type jsonOutput struct {
|
|
Status string `json:"status"`
|
|
LLM *jsonLLMIdentity `json:"llm,omitempty"`
|
|
TraceID string `json:"trace_id,omitempty"`
|
|
Message string `json:"message,omitempty"`
|
|
Summary *jsonSummary `json:"summary,omitempty"`
|
|
ToolCalls *jsonToolCalls `json:"tool_calls"`
|
|
Comments []model.LlmComment `json:"comments"`
|
|
Warnings []agent.AgentWarning `json:"warnings,omitempty"`
|
|
ProjectSummary string `json:"project_summary,omitempty"`
|
|
Resume *agent.ResumeInfo `json:"resume,omitempty"`
|
|
SessionID string `json:"session_id,omitempty"`
|
|
Manifest *session.RunManifest `json:"manifest,omitempty"`
|
|
// RetryReport is the frozen LLM retry report (ocr.llm-retry-report/v1).
|
|
// Reuses llm.RetryReport's own field/tag definitions rather than mirroring
|
|
// them here, and sits last with omitempty so a first-try-success run emits
|
|
// byte-identical JSON to before #368.
|
|
RetryReport *llm.RetryReport `json:"retry_report,omitempty"`
|
|
}
|
|
|
|
func outputJSON(comments []model.LlmComment) error {
|
|
out := jsonOutput{
|
|
Status: "success",
|
|
Comments: comments,
|
|
}
|
|
if len(comments) == 0 {
|
|
out.Message = "No comments generated. Looks good to me."
|
|
}
|
|
enc := json.NewEncoder(os.Stdout)
|
|
enc.SetIndent("", " ")
|
|
return enc.Encode(out)
|
|
}
|
|
|
|
func outputJSONWithWarnings(comments []model.LlmComment, warnings []agent.AgentWarning,
|
|
filesReviewed, inputTokens, outputTokens, totalTokens, cacheReadTokens, cacheWriteTokens int64,
|
|
duration time.Duration, projectSummary string, toolCalls map[string]int64, traceID string, resumeInfo *agent.ResumeInfo, sessionID string,
|
|
manifest *session.RunManifest, budgetExceeded bool, llmIdentity *jsonLLMIdentity,
|
|
retryReport *llm.RetryReport) error {
|
|
publishedWarnings := warningsForOutput(warnings, manifest)
|
|
out := jsonOutput{
|
|
Status: "success",
|
|
LLM: llmIdentity,
|
|
TraceID: traceID,
|
|
Comments: comments,
|
|
Summary: &jsonSummary{
|
|
FilesReviewed: filesReviewed,
|
|
Comments: int64(len(comments)),
|
|
TotalTokens: totalTokens,
|
|
InputTokens: inputTokens,
|
|
OutputTokens: outputTokens,
|
|
CacheReadTokens: cacheReadTokens,
|
|
CacheWriteTokens: cacheWriteTokens,
|
|
Elapsed: duration.Round(time.Second).String(),
|
|
BudgetExceeded: budgetExceeded,
|
|
},
|
|
ProjectSummary: projectSummary,
|
|
Resume: resumeInfo,
|
|
SessionID: sessionID,
|
|
Manifest: manifest,
|
|
RetryReport: retryReport,
|
|
}
|
|
var total int64
|
|
for _, v := range toolCalls {
|
|
total += v
|
|
}
|
|
byTool := toolCalls
|
|
if byTool == nil {
|
|
byTool = make(map[string]int64)
|
|
}
|
|
out.ToolCalls = &jsonToolCalls{
|
|
Total: total,
|
|
ByTool: byTool,
|
|
}
|
|
if manifest != nil {
|
|
out.Status = string(manifest.TerminalState)
|
|
out.Message = manifestMessage(manifest, len(comments))
|
|
} else if len(comments) == 0 {
|
|
if hasSubtaskErrors(warnings) {
|
|
out.Message = "Some files could not be reviewed due to errors."
|
|
} else {
|
|
out.Message = "No comments generated. Looks good to me."
|
|
}
|
|
}
|
|
if len(publishedWarnings) > 0 {
|
|
out.Warnings = publishedWarnings
|
|
if manifest == nil && hasSubtaskErrors(publishedWarnings) {
|
|
out.Status = "completed_with_errors"
|
|
} else if manifest == nil {
|
|
out.Status = "completed_with_warnings"
|
|
}
|
|
}
|
|
// budgetExceeded deliberately does NOT touch out.Status. Reaching the
|
|
// aggregate token budget is a controlled coverage truncation, so it is already
|
|
// expressed in the manifest as failed(budget) on the items that never got
|
|
// dispatched — which makes terminal_state read "partial" whenever anything was
|
|
// covered. The status set above is therefore the single source of truth,
|
|
// and the budget reason stays observable through three deterministic outlets:
|
|
// summary.budget_exceeded, the token_budget_reached warning, and
|
|
// coverage.failed[].classification == "budget".
|
|
enc := json.NewEncoder(os.Stdout)
|
|
enc.SetIndent("", " ")
|
|
return enc.Encode(out)
|
|
}
|
|
|
|
// outputRetryReportText renders the frozen retry report as the terminal
|
|
// summary. It is a run result, not a warning, so it goes to the same writer as
|
|
// the review result rather than to stderr; JSON mode never calls this (stdout
|
|
// must stay a single JSON document).
|
|
//
|
|
// Nothing here is free text from a provider: only stable classes, numeric
|
|
// status codes, the file path and the task type — no API keys, headers,
|
|
// bodies, prompts, URLs or raw SDK error strings. Every request in the report
|
|
// is listed; the report already only contains requests that erred or retried,
|
|
// so there is no separate terminal truncation contract to reason about.
|
|
func outputRetryReportText(w io.Writer, rep *llm.RetryReport) {
|
|
if rep == nil {
|
|
return
|
|
}
|
|
retryWord := "retries"
|
|
if rep.TotalRetries == 1 {
|
|
retryWord = "retry"
|
|
}
|
|
fmt.Fprintf(w, "\nLLM retry report: %d/%d requests retried, %d %s, %d recovered, %d failed, %d cancelled\n",
|
|
rep.RetriedRequests, rep.TotalRequests, rep.TotalRetries, retryWord,
|
|
rep.RecoveredRequests, rep.FailedRequests, rep.CancelledRequests)
|
|
for _, r := range rep.Requests {
|
|
fmt.Fprintf(w, "- %s / %s #%d: %s\n",
|
|
sanitizeTerminal(r.FilePath), sanitizeTerminal(r.TaskType),
|
|
r.RequestNo, retryAttemptChain(r))
|
|
}
|
|
}
|
|
|
|
// retryAttemptChain renders one logical request's attempts as
|
|
// "rate_limited(429) -> overloaded(529) -> success".
|
|
//
|
|
// A trailing request-level outcome is appended for failed and, when the last
|
|
// attempt does not already say so, cancelled. A recovered or succeeded request
|
|
// already ends in a "success" attempt, so repeating the outcome there would be
|
|
// noise, whereas a request that never succeeded would otherwise end on its last
|
|
// error with no sign of how it finished. cancelled in particular is a routine
|
|
// outcome (background memory compression is deliberately abandoned at the end
|
|
// of every file), so it must be visibly distinct from a provider failure.
|
|
func retryAttemptChain(r llm.RequestReport) string {
|
|
parts := make([]string, 0, len(r.Attempts)+1)
|
|
for _, a := range r.Attempts {
|
|
switch {
|
|
case a.Outcome == llm.AttemptSuccess:
|
|
parts = append(parts, "success")
|
|
case a.StatusCode > 0:
|
|
parts = append(parts, fmt.Sprintf("%s(%d)", a.ErrorClass, a.StatusCode))
|
|
default:
|
|
parts = append(parts, string(a.ErrorClass))
|
|
}
|
|
}
|
|
if r.Outcome == llm.OutcomeFailed ||
|
|
(r.Outcome == llm.OutcomeCancelled &&
|
|
(len(parts) == 0 || parts[len(parts)-1] != string(llm.OutcomeCancelled))) {
|
|
parts = append(parts, string(r.Outcome))
|
|
}
|
|
return strings.Join(parts, " -> ")
|
|
}
|
|
|
|
func manifestMessage(manifest *session.RunManifest, findings int) string {
|
|
if manifest == nil {
|
|
return ""
|
|
}
|
|
selected := len(manifest.Coverage.Selected)
|
|
failed := len(manifest.Coverage.Failed)
|
|
waived := len(manifest.Coverage.Waived)
|
|
switch manifest.TerminalState {
|
|
case session.StateComplete:
|
|
if waived > 0 {
|
|
return fmt.Sprintf("Review complete: %d finding(s) across %d selected item(s), including %d waived.", findings, selected, waived)
|
|
}
|
|
return fmt.Sprintf("Review complete: %d finding(s) across %d selected item(s).", findings, selected)
|
|
case session.StatePartial:
|
|
return fmt.Sprintf("Review partially complete: %d finding(s); %d of %d selected item(s) failed.", findings, failed, selected)
|
|
case session.StateFailed:
|
|
if manifest.RunFailure != nil {
|
|
return fmt.Sprintf("Review failed (%s): %d finding(s); %d of %d selected item(s) failed.", manifest.RunFailure.Classification, findings, failed, selected)
|
|
}
|
|
return fmt.Sprintf("Review failed: %d finding(s); %d of %d selected item(s) failed.", findings, failed, selected)
|
|
case session.StateSkipped:
|
|
return "Review skipped: no items were selected."
|
|
default:
|
|
return fmt.Sprintf("Review finished with unknown manifest state %q.", manifest.TerminalState)
|
|
}
|
|
}
|
|
|
|
func outputJSONNoFiles(traceID string, llmIdentity *jsonLLMIdentity) error {
|
|
out := jsonOutput{
|
|
Status: "skipped",
|
|
LLM: llmIdentity,
|
|
TraceID: traceID,
|
|
Message: "No supported files changed.",
|
|
Comments: []model.LlmComment{},
|
|
ToolCalls: &jsonToolCalls{
|
|
ByTool: map[string]int64{},
|
|
},
|
|
}
|
|
enc := json.NewEncoder(os.Stdout)
|
|
enc.SetIndent("", " ")
|
|
return enc.Encode(out)
|
|
}
|
|
|
|
// emitFailureUsage writes a best-effort structured usage record to stderr when
|
|
// a review fails, so the outer caller still sees the cost of the failed attempt.
|
|
// It carries only token/tool-call tallies and elapsed, never credentials or
|
|
// prompts.
|
|
//
|
|
// A plain aggregate budget stop does NOT reach here: it is a controlled coverage
|
|
// truncation, so it yields terminal_state=partial and a nil error. It only
|
|
// arrives when the truncation left nothing covered at all (every selected item
|
|
// failed(budget) ⇒ terminal_state=failed), or alongside an unrelated failure.
|
|
// Whenever the manifest was constructed, stdout has already published the
|
|
// complete frozen result before this runs — so this record supplements it, never
|
|
// replaces it. We report the agent's actual BudgetExceeded() value rather than
|
|
// hardcoding false, so the record can never contradict the agent's state.
|
|
//
|
|
// In json format it emits a jsonOutput-shaped object to stderr (kept separate
|
|
// from stdout so it does not pollute the machine-readable result stream, which
|
|
// therefore always carries exactly one JSON document); otherwise a single
|
|
// human-readable [ocr] line. It must never return an error that masks the
|
|
// original failure — all writes are best-effort.
|
|
//
|
|
// retryReport must be nil whenever emitRunResult already ran: a constructed
|
|
// manifest is publishable even on a failed run, so both this record and the
|
|
// normal result exit can execute for the same run, and the report belongs to
|
|
// exactly one of them. Pass the frozen report here only when the normal exit
|
|
// was skipped, so the report is never duplicated and never silently dropped.
|
|
func emitFailureUsage(ag ResultProvider, duration time.Duration, outputFormat string, llmIdentity *jsonLLMIdentity,
|
|
retryReport *llm.RetryReport) {
|
|
var toolTotal int64
|
|
for _, v := range ag.ToolCalls() {
|
|
toolTotal += v
|
|
}
|
|
budgetExceeded := ag.BudgetExceeded()
|
|
if outputFormat == "json" {
|
|
out := jsonOutput{
|
|
Status: "failed",
|
|
LLM: llmIdentity,
|
|
Summary: &jsonSummary{
|
|
FilesReviewed: ag.FilesReviewed(),
|
|
TotalTokens: ag.TotalTokensUsed(),
|
|
InputTokens: ag.TotalInputTokens(),
|
|
OutputTokens: ag.TotalOutputTokens(),
|
|
CacheReadTokens: ag.TotalCacheReadTokens(),
|
|
CacheWriteTokens: ag.TotalCacheWriteTokens(),
|
|
Elapsed: duration.Round(time.Second).String(),
|
|
BudgetExceeded: budgetExceeded,
|
|
},
|
|
ToolCalls: &jsonToolCalls{
|
|
Total: toolTotal,
|
|
ByTool: ag.ToolCalls(),
|
|
},
|
|
SessionID: ag.SessionID(),
|
|
RetryReport: retryReport,
|
|
}
|
|
enc := json.NewEncoder(os.Stderr)
|
|
enc.SetIndent("", " ")
|
|
_ = enc.Encode(out)
|
|
return
|
|
}
|
|
fmt.Fprintf(os.Stderr, "[ocr] usage on failure: %d file(s), %d input + %d output = %d total tokens, %d tool calls, elapsed %s, budget_exceeded=%v",
|
|
ag.FilesReviewed(), ag.TotalInputTokens(), ag.TotalOutputTokens(), ag.TotalTokensUsed(),
|
|
toolTotal, duration.Round(time.Second).String(), budgetExceeded)
|
|
if id := ag.SessionID(); id != "" {
|
|
fmt.Fprintf(os.Stderr, ", session %s", id)
|
|
}
|
|
fmt.Fprintln(os.Stderr)
|
|
// Text mode has no structured envelope, so the report follows the usage
|
|
// line on the same stream.
|
|
outputRetryReportText(os.Stderr, retryReport)
|
|
}
|
|
|
|
// outputPreview renders a preview in the requested output format. sarif is
|
|
// rejected with an error because a preview contains file/rule metadata, not
|
|
// review findings — there is no SARIF result to emit, and a differently-shaped
|
|
// document would confuse consumers expecting a SARIF report.
|
|
func outputPreview(p *agent.DiffPreview, outputFormat string) error {
|
|
if outputFormat == "sarif" {
|
|
return fmt.Errorf("--format sarif is not supported with --preview: SARIF output requires completed review findings")
|
|
}
|
|
if outputFormat == "json" {
|
|
return outputPreviewJSON(p)
|
|
}
|
|
outputPreviewText(p)
|
|
return nil
|
|
}
|
|
|
|
func outputPreviewJSON(p *agent.DiffPreview) error {
|
|
enc := json.NewEncoder(os.Stdout)
|
|
enc.SetIndent("", " ")
|
|
return enc.Encode(p)
|
|
}
|
|
|
|
func outputPreviewText(p *agent.DiffPreview) {
|
|
if p.TotalFiles == 0 {
|
|
fmt.Println("No files changed.")
|
|
return
|
|
}
|
|
|
|
maxPathLen := 0
|
|
for _, e := range p.Entries {
|
|
if n := len(sanitizeTerminal(e.Path)); n > maxPathLen {
|
|
maxPathLen = n
|
|
}
|
|
}
|
|
if maxPathLen < 20 {
|
|
maxPathLen = 20
|
|
}
|
|
pathFmt := fmt.Sprintf("%%-%ds", maxPathLen)
|
|
|
|
fmt.Printf("\nPreview: %d file(s) changed | \033[32m+%d\033[0m \033[31m-%d\033[0m\n",
|
|
p.TotalFiles, p.TotalInsertions, p.TotalDeletions)
|
|
|
|
if p.ReviewableCount > 0 {
|
|
fmt.Printf("\n\033[1mWill review (%d):\033[0m\n", p.ReviewableCount)
|
|
for _, e := range p.Entries {
|
|
if !e.WillReview {
|
|
continue
|
|
}
|
|
fmt.Printf(" %s "+pathFmt+" \033[32m+%-4d\033[0m \033[31m-%-4d\033[0m\n",
|
|
statusBadge(e.Status), sanitizeTerminal(e.Path), e.Insertions, e.Deletions)
|
|
}
|
|
}
|
|
|
|
if p.ExcludedCount > 0 {
|
|
fmt.Printf("\n\033[1mExcluded from review (%d):\033[0m\n", p.ExcludedCount)
|
|
for _, e := range p.Entries {
|
|
if e.WillReview {
|
|
continue
|
|
}
|
|
fmt.Printf(" %s "+pathFmt+" \033[2m(%s)\033[0m\n",
|
|
statusBadge(e.Status), sanitizeTerminal(e.Path), sanitizeTerminal(string(e.ExcludeReason)))
|
|
}
|
|
}
|
|
|
|
fmt.Println()
|
|
}
|
|
|
|
func statusBadge(status string) string {
|
|
switch status {
|
|
case "added":
|
|
return "\033[32m[A]\033[0m"
|
|
case "modified":
|
|
return "\033[33m[M]\033[0m"
|
|
case "deleted":
|
|
return "\033[31m[D]\033[0m"
|
|
case "renamed":
|
|
return "\033[36m[R]\033[0m"
|
|
case "binary":
|
|
return "\033[35m[B]\033[0m"
|
|
case "scan":
|
|
return "\033[34m[S]\033[0m"
|
|
default:
|
|
return "[?]"
|
|
}
|
|
}
|