From 127cfcccccc598903c5868042953f22b6beb8afb Mon Sep 17 00:00:00 2001 From: weichen Date: Mon, 21 Sep 2026 15:29:33 +0000 Subject: [PATCH] feat(agent): fall back to the container backend when pi-native fails Dev starts on pi-native and keeps pi-agent available for a failed turn. Latency tuning stays deferred. --- .doc/ops/CURRENT.md | 4 +- .../2026-09-21-go-agentx-runtime.md | 4 +- .doc/status/FEATURE-MATRIX.md | 2 +- configs/.env.dev.example | 3 +- docker-compose.dev.yml | 5 +- internal/agent/backend.go | 5 +- internal/agent/fallback.go | 46 ++++++++++++++ internal/agent/fallback_test.go | 63 +++++++++++++++++++ 8 files changed, 124 insertions(+), 8 deletions(-) create mode 100644 internal/agent/fallback.go create mode 100644 internal/agent/fallback_test.go diff --git a/.doc/ops/CURRENT.md b/.doc/ops/CURRENT.md index 4c5bb9a6..7c2f5d92 100644 --- a/.doc/ops/CURRENT.md +++ b/.doc/ops/CURRENT.md @@ -10,13 +10,13 @@ 3. **上传解析提示 / 去重 / CoW 收口**:MinerU 解析状态轮询闭环(pending→running→done)、重复文件「已复用」提示、失败原因+重新解析、CAS 不可变 CoW 契约测试,已合入 `dev`(代码 `99905571`、证据 `74587ccf`、收口 `a0464eb5`)。见 [报告](../test-reports/2026-09-21-upload-parse-ux-dedup-test-report.md)。test/main 未晋升。前序任务执行状态卡收口见 [报告](../test-reports/2026-09-21-taskrun-status-card-persistence-test-report.md)(含二轮修复:completed 步骤 label 冻结为"正在…(NN%)"的收口归一化 + dev 历史回填,已合入 `dev@4b303247` 并真实 PI 验收)。 4. **MinerU 真实 PDF 二轮验收**:用户反馈「PDF 内容未被理解」由两因叠加——(A) 首轮在 worktree 内跑 `env-service dev rebuild` 令挂载漂到 worktree `data/dev`,真实历史 blob 全部读不到;(B) Agent 轻量 API 硬限 20 页,28 页真实文件失败。首轮结论作废。二轮从**主目录** rebuild 修复挂载,`internal/mineru` 在 `-30003` 时按 20 页窗分块解析并重编全局页锚;`ReadRefBytes` 失败改为 `slog.Warn`。已合入 `dev@362b68a0`。真实 `bd_intake`(项目 6532)`completed`,工作区 `materials_text` 注入 29152 字节中文正文。见 [报告](../test-reports/2026-09-21-mineru-doc-parsing-test-report.md) 第八节。 5. **材料切块不再看体积**:项目 6579 `doc_proj6579_bd_intake` 已确认候选 535,正式正文不再含「二进制压缩流」。成功解析的材料一律写入 `material_chunks`,≤60KB 仍全文注入。见 [报告](../test-reports/2026-09-21-force-material-chunk-index-test-report.md)。已合入 `dev` 并从主目录 rebuild。test/main 未晋升。 -6. **红线**:`pi-native` 对比未过 P95 门槛,默认 `AGENT_BACKEND` 保持 `pi-agent`,未合入、未部署。禁止整支 merge FAV;不默认升 `MULTI_INTENT_PLAN_TIER=full`;`SEMANTIC_SUMMARY_REPLACE` 不随 W2 开启;FAV live 观察窗证据驱动;`FAV_DEGRADED_READ=false`;项目 143 只读;SWBOOT-10 保持 fail-closed stub;D7 对话 ADR 仍待合规签字(追踪表留存类别已定为 `record`,不授权物理删除);**不改 `production_ready`**;模块解耦批次 A–G 与回溯实施切片未单独认领前保持 `planned`;**合入 dev 后必须回主目录跑 `env-service.sh dev rebuild`,禁止任何 worktree 内运行 `env-service`**(脚本 ROOT_DIR 取自脚本所在目录树,会带走 bind mount,导致 `/app/data` 漂到 worktree 空目录)。 +6. **红线**:dev 启用 `pi-native`,失败回退容器 `pi-agent`;时延优化后置。test/main 仍默认 `pi-agent`。禁止整支 merge FAV;不默认升 `MULTI_INTENT_PLAN_TIER=full`;`SEMANTIC_SUMMARY_REPLACE` 不随 W2 开启;FAV live 观察窗证据驱动;`FAV_DEGRADED_READ=false`;项目 143 只读;SWBOOT-10 保持 fail-closed stub;D7 对话 ADR 仍待合规签字(追踪表留存类别已定为 `record`,不授权物理删除);**不改 `production_ready`**;模块解耦批次 A–G 与回溯实施切片未单独认领前保持 `planned`;**合入 dev 后必须回主目录跑 `env-service.sh dev rebuild`,禁止任何 worktree 内运行 `env-service`**(脚本 ROOT_DIR 取自脚本所在目录树,会带走 bind mount,导致 `/app/data` 漂到 worktree 空目录)。 ## 并行认领 | owner | task | branch | worktree | mutex | status | |-------|------|--------|----------|-------|--------| -| Cursor | 2026-09-21-go-agentx-runtime | feature/go-agentx-runtime-2026-09-21 | .worktrees/go-agentx-runtime-2026-09-21 | agent-backend-swap | blocked | +| Cursor | 2026-09-21-go-agentx-runtime | feature/go-agentx-runtime-2026-09-21 | .worktrees/go-agentx-runtime-2026-09-21 | agent-backend-swap | in_progress | | Cursor | 2026-09-21-force-material-chunk-index | feature/force-material-chunk-index-2026-09-21 | .worktrees/force-material-chunk-index-2026-09-21 | bizdev+materialindex | done | | Cursor | 2026-09-21-workflow-scope-weighting | feature/workflow-scope-weighting-2026-09-21 | .worktrees/workflow-scope-weighting-2026-09-21 | intent+web+docgov | done | | Cursor | 2026-09-21-upload-parse-ux-dedup | feature/upload-parse-ux-2026-09-21 | .worktrees/upload-parse-ux-2026-09-21 | server+web+storage | done | diff --git a/.doc/ops/short-term/2026-09-21-go-agentx-runtime.md b/.doc/ops/short-term/2026-09-21-go-agentx-runtime.md index 8fc7bc0a..ba943041 100644 --- a/.doc/ops/short-term/2026-09-21-go-agentx-runtime.md +++ b/.doc/ops/short-term/2026-09-21-go-agentx-runtime.md @@ -1,6 +1,6 @@ --- title: "Go 原生 PI Agent 运行时(pi-native 并存验证)" -status: blocked +status: in_progress created: 2026-09-21 updated: 2026-09-21 owner: Cursor @@ -63,4 +63,4 @@ mutex: agent-backend-swap 远端同步状态:创建时主目录为本地 `dev`,ahead of `origin/dev`,未为创建任务执行 fetch/pull。 -对比未过 P95 门槛,状态 `blocked`,默认后端不改。差距见 `.doc/audit/2026-09-21-go-agentx-runtime-latency-gap.md`。`deployment=pending`,`cleanup=pending`(未合入,worktree 保留)。短测试曾对 `127.0.0.1:5433` 执行 goose,dev schema 现为 62,表 `agentx_workspace_registry` 已存在;迁移文件仍只在本分支。 +对比未过 P95 门槛后,用户决定先不把时延作为切换条件:dev 启用 `pi-native`,失败时在进程内回退到容器 `pi-agent`。test/main 的 compose 默认仍是 `pi-agent`。`deployment` 在主目录 rebuild 之后再记。 diff --git a/.doc/status/FEATURE-MATRIX.md b/.doc/status/FEATURE-MATRIX.md index 6bb0a651..9ed5ed77 100644 --- a/.doc/status/FEATURE-MATRIX.md +++ b/.doc/status/FEATURE-MATRIX.md @@ -66,7 +66,7 @@ | 阶段回溯与影响闭环 | 五表追踪索引、影响报告、L1 文档修订、L2 阶段再打开、待复核软标记、对话修订提案 | done | deferred_enhancement(不挡首发) | [设计](../system-design/2026-09-20-stage-backtrack-and-impact-loop-design.md);[Track T](../remediation/2026-09-20-capability-enablement-impact-plan.md);[对话化报告](../test-reports/2026-09-21-trace-panel-to-chat-test-report.md) | 已合入 dev。常驻「需求与影响」面板已退役,新待复核与下游影响改走已有项目会话。dev schema 59。test/main 未升。阶段推进确认门禁仍在。不签 D7 对话 ADR,不改 `production_ready` | `trace-index` | | PI change-report 叙事 | 只读消费 diff+lineage 的 PI 叙事;失败全量降级 fallback | partial | deferred_enhancement | [Round20](../test-reports/2026-09-14-document-revision-runtime-ui-test-report.md);[go/no-go GO](../ops/research/2026-09-16-wave3-pu6-pu8-go-no-go.md);[pu7](../ops/long-term/2026-09-16-pu7-pi-narrative-contract.md);[脚手架](../test-reports/2026-09-16-pu7-pi-narrative-test-report.md);[W3 真 PI](../test-reports/2026-09-17-w3-pi-change-report-narrative-test-report.md);[2026-09-20 复核](../ops/research/2026-09-20-capability-enablement-impact-research.md);[最小化输入](../ops/short-term/2026-09-20-pi-narrative-clipped-diff.md) | flag 默认 off。`facts.json` 含截断后的 hunk 前后文和影响说明,以及完整 hunk/impact 计数与省略数;摘要 digest 仍不含正文。叙事请求已带 `tool_profile=reasoning`。运行中的 runner 尚未切到 `--no-tools`。不打开开关 | `docgov` | | 前端工作台 | 三栏 + 块文档 + SSE;错误卡/`TaskRunCard` 竖向进度/busy;版本冲突信封;demo seed 默认关;终止 preview | done | non_blocker | [workbench](../test-reports/2026-09-08-three-column-workbench-and-block-document-test-report.md);[双标签](../test-reports/assets/2026-09-15-conversation-intent-dual-tab/screenshots/tab-b-busy.png);[demo](../test-reports/2026-09-16-demo-seed-pollution-test-report.md);[B4](../test-reports/2026-09-16-frontend-gate-b4-test-report.md)(项目 6363);[TaskRunCard](../test-reports/2026-09-21-taskrun-status-card-persistence-test-report.md) | **before**:空「缺口」却 partial → **after**:本行 done。其他 409 消费方信封复用另列 hardening backlog | `web` | -| PI Runner / 配置链 | pi-agent + MiniMax + typed-result-v1;分支并存 pi-native,默认未切 | done | non_blocker | [piagent](../test-reports/2026-09-13-codex-audit-p0-remediation-test-report.md);[container-env](../test-reports/2026-09-13-container-env-configuration-test-report.md);[typed-result](../test-reports/2026-09-15-pi-typed-result-v1-test-report.md);[pi-native 对比](../test-reports/2026-09-21-go-agentx-runtime-test-report.md) | 容器链路仍是默认。pi-native 真实双跑成功率 1.00、容器 0.33、P0 为 0,P95 16.6s 对 6.9s,未过 1.3 倍,不切 AGENT_BACKEND。cursor-cli 已从该分支移除,容器服务保留 | `pi-agent` | +| PI Runner / 配置链 | pi-agent + MiniMax + typed-result-v1;dev 默认 pi-native,失败回退 pi-agent | done | non_blocker | [piagent](../test-reports/2026-09-13-codex-audit-p0-remediation-test-report.md);[container-env](../test-reports/2026-09-13-container-env-configuration-test-report.md);[typed-result](../test-reports/2026-09-15-pi-typed-result-v1-test-report.md);[pi-native 对比](../test-reports/2026-09-21-go-agentx-runtime-test-report.md) | 用户决定时延后置。dev compose 默认 pi-native,进程内失败再调用容器 pi-agent。test/main 默认仍是 pi-agent。cursor-cli 已移除 | `pi-agent` | | 三环境运维 | dev/test/main + 共享 KB;对话/业务库隔离 | done | non_blocker | [PROJECT.md](../../PROJECT.md) §4;CURRENT 环境快照 dev/test schema **48**、main **47** | — | `infra` | | CI / E2E | PR CI、PG 集成、治理 PG、已签真实 PI 门禁;**nightly** schedule/`workflow_dispatch` + infra/product 分类 receipt | done | 加固(阻止**下一次**晋升,不自动否定已发布) | [BUG-08](../test-reports/2026-09-16-ci-governance-pg-bug08-test-report.md);[PI 三工作流](../test-reports/2026-09-16-pi-three-workflow-e2e-test-report.md);[Wave2B](../test-reports/2026-09-16-wave2b-nightly-promotion-receipt-test-report.md);[Wave2C](../test-reports/2026-09-16-wave2c-same-image-digest-test-report.md);[W1 green](../test-reports/2026-09-17-w1-ci-e2e-green-nightly-test-report.md) + [receipt `6e2476f0…`](../assets/promotion-receipts/6e2476f0f2e408e111fe06ca7c31a38294a340b3.json) | **before**:partial(FullNone product_fail / secrets 文案过时)→ **after**:secrets 已配;duplicate goose 00048 已清;FullNone+U8 nightly `verdict=pass` | `ci` | | Artifact 同 digest 晋升 | 候选 commit 一次构建、按 digest 晋升、promotion receipt | done | 加固 | [计划 PU5B](../remediation/2026-09-16-feature-matrix-production-readiness-plan.md);[Wave2B](../test-reports/2026-09-16-wave2b-nightly-promotion-receipt-test-report.md);[Wave2C](../test-reports/2026-09-16-wave2c-same-image-digest-test-report.md) | **关闭**:same-image pin(`candidate-images-*` + `PINNED_IMAGE_MODE` + promote `--require-digests`)。**声明**:单机 retag/digest pin;远程 registry 推送另立任务 | `infra` | diff --git a/configs/.env.dev.example b/configs/.env.dev.example index d1a5f103..c3b75a00 100644 --- a/configs/.env.dev.example +++ b/configs/.env.dev.example @@ -30,7 +30,8 @@ KB_DB_PORT=5432 # 5. Agent 后端与 Runner 端口 PI_AGENT_RUNNER_PORT=18865 -AGENT_BACKEND=pi-agent +AGENT_BACKEND=pi-native +# pi-native 失败时进程内回退到容器 pi-agent,无需改此值。 AGENT_TIMEOUT=60m # 6. Pi-Agent / MiniMax / Tavily / Runner 密钥统一读取根 .env diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index b89d5e98..159aaa2f 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -80,7 +80,10 @@ services: HTTP_ADDR: ":${HTTP_PORT:-8080}" HTTP_PORT: "${HTTP_PORT:-8080}" HTTP_HOST: "0.0.0.0" - AGENT_BACKEND: ${AGENT_BACKEND:-pi-agent} + AGENT_BACKEND: ${AGENT_BACKEND:-pi-native} + AGENTX_API_BASE: ${MINIMAX_ENDPOINT_URL:-https://api.minimaxi.com/v1} + AGENTX_API_KEY: ${MINIMAX_API_KEY:-} + AGENTX_MODEL: ${MINIMAX_MODEL_ID:-MiniMax-M2.7-highspeed} AGENT_TIMEOUT: ${AGENT_TIMEOUT:-30m} MAX_CONCURRENT_AGENTS: ${MAX_CONCURRENT_AGENTS:-8} DATA_DIR: /app/data diff --git a/internal/agent/backend.go b/internal/agent/backend.go index 20e39d97..994cbe93 100644 --- a/internal/agent/backend.go +++ b/internal/agent/backend.go @@ -46,7 +46,10 @@ func NewBackend(name string, cfg *config.Config) Backend { case "pi-agent": inner = &PiAgentBackend{cfg: cfg} case "pi-native": - inner = &PiNativeBackend{cfg: cfg} + inner = &fallbackBackend{ + primary: NewPiNativeBackend(cfg), + fallback: &PiAgentBackend{cfg: cfg}, + } default: inner = &StubBackend{} } diff --git a/internal/agent/fallback.go b/internal/agent/fallback.go new file mode 100644 index 00000000..2cd5eb96 --- /dev/null +++ b/internal/agent/fallback.go @@ -0,0 +1,46 @@ +package agent + +import ( + "context" + "strings" +) + +// fallbackBackend runs pi-native first. A failed turn is retried on the container pi-agent. +type fallbackBackend struct { + primary Backend + fallback Backend +} + +func (f *fallbackBackend) Name() string { + if f.primary == nil { + return "pi-native" + } + return f.primary.Name() +} + +func (f *fallbackBackend) Execute(ctx context.Context, req Request) (*Result, error) { + res, err := f.primary.Execute(ctx, req) + if ctx.Err() != nil || succeeded(res, err) || f.fallback == nil { + return res, err + } + fb, fbErr := f.fallback.Execute(ctx, req) + if !succeeded(fb, fbErr) { + return res, err + } + note := "pi-native failed; served by pi-agent" + if res != nil && strings.TrimSpace(res.ErrorReason) != "" { + note += ": " + strings.TrimSpace(res.ErrorReason) + } else if err != nil { + note += ": " + err.Error() + } + if strings.TrimSpace(fb.Logs) == "" { + fb.Logs = note + } else { + fb.Logs = note + "\n" + fb.Logs + } + return fb, nil +} + +func succeeded(res *Result, err error) bool { + return err == nil && res != nil && res.Success +} diff --git a/internal/agent/fallback_test.go b/internal/agent/fallback_test.go new file mode 100644 index 00000000..477df951 --- /dev/null +++ b/internal/agent/fallback_test.go @@ -0,0 +1,63 @@ +package agent + +import ( + "context" + "errors" + "strings" + "testing" +) + +type scriptBackend struct { + name string + res *Result + err error + calls int +} + +func (s *scriptBackend) Name() string { return s.name } + +func (s *scriptBackend) Execute(ctx context.Context, req Request) (*Result, error) { + s.calls++ + return s.res, s.err +} + +func TestFallbackKeepsNativeSuccess(t *testing.T) { + primary := &scriptBackend{name: "pi-native", res: &Result{Success: true, Logs: "native"}} + spare := &scriptBackend{name: "pi-agent", res: &Result{Success: true}} + got, err := (&fallbackBackend{primary: primary, fallback: spare}).Execute(context.Background(), Request{}) + if err != nil || !got.Success || got.Logs != "native" || spare.calls != 0 { + t.Fatalf("got %#v err=%v spare=%d", got, err, spare.calls) + } +} + +func TestFallbackUsesContainerAfterNativeFailure(t *testing.T) { + primary := &scriptBackend{name: "pi-native", res: &Result{Success: false, ErrorReason: "missing target deliverable"}} + spare := &scriptBackend{name: "pi-agent", res: &Result{Success: true, Logs: "container"}} + got, err := (&fallbackBackend{primary: primary, fallback: spare}).Execute(context.Background(), Request{}) + if err != nil || !got.Success || spare.calls != 1 { + t.Fatalf("got %#v err=%v calls=%d", got, err, spare.calls) + } + if !strings.Contains(got.Logs, "served by pi-agent") || !strings.Contains(got.Logs, "missing target deliverable") { + t.Fatalf("logs %q", got.Logs) + } +} + +func TestFallbackReturnsNativeWhenContainerAlsoFails(t *testing.T) { + primary := &scriptBackend{name: "pi-native", err: errors.New("native down")} + spare := &scriptBackend{name: "pi-agent", res: &Result{Success: false, ErrorReason: "runner down"}} + got, err := (&fallbackBackend{primary: primary, fallback: spare}).Execute(context.Background(), Request{}) + if err == nil || err.Error() != "native down" || got != nil || spare.calls != 1 { + t.Fatalf("got %#v err=%v", got, err) + } +} + +func TestFallbackStopsWhenContextCancelled(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + primary := &scriptBackend{name: "pi-native", err: context.Canceled} + spare := &scriptBackend{name: "pi-agent", res: &Result{Success: true}} + _, err := (&fallbackBackend{primary: primary, fallback: spare}).Execute(ctx, Request{}) + if !errors.Is(err, context.Canceled) || spare.calls != 0 { + t.Fatalf("err=%v spare=%d", err, spare.calls) + } +}