diff --git a/.doc/ops/CURRENT.md b/.doc/ops/CURRENT.md index 90f922a9..b8569cb3 100644 --- a/.doc/ops/CURRENT.md +++ b/.doc/ops/CURRENT.md @@ -18,6 +18,7 @@ | owner | task | branch | worktree | mutex | status | |---|---|---|---|---|---| +| Cursor | pi-runner-env-mismatch-2026-09-28 | fix/pi-runner-env-mismatch-2026-09-28 | .worktrees/pi-runner-env-mismatch-2026-09-28 | pi-runner-env | done | | Cursor | error-events-pg-roundtrip-2026-09-28 | feature/error-events-pg-roundtrip-2026-09-28 | .worktrees/error-events-pg-roundtrip-2026-09-28 | error-events-pg-evidence | done | | Cursor | error-code-observability-2026-09-28 | feature/error-code-observability-2026-09-28 | .worktrees/error-code-observability-2026-09-28 | error-code-observability | done | | Cursor | attachment-only-turn-2026-09-28 | fix/attachment-only-turn-2026-09-28 | .worktrees/attachment-only-turn-2026-09-28 | attachment-only-turn | done | diff --git a/.doc/test-reports/2026-09-28-pi-runner-env-mismatch-test-report.md b/.doc/test-reports/2026-09-28-pi-runner-env-mismatch-test-report.md new file mode 100644 index 00000000..8604e34f --- /dev/null +++ b/.doc/test-reports/2026-09-28-pi-runner-env-mismatch-test-report.md @@ -0,0 +1,84 @@ +# PI runner 环境错配(MISSING_TARGET)测试报告 + +## 一、 测试背景 (Background) + +test 环境用户发送「1」确认推进后,对话返回 `INTERNAL_ERROR` / `fatal`,upstream 为 `step step_03_regulatory failed: MISSING_TARGET`(requestId `req_1790558986255320703`,session `sess-p5-u32`)。 + +已证实根因:test 服务器进程的 `PI_AGENT_RUNNER_URL` 来自根 `.env` 的 dev sidecar `http://127.0.0.1:18865`,压过 `configs/.env.test` 的端口约定 `18866`。dev sidecar 的 `resolveWorkspace` 把 `data/test/workspaces/...` 重写到 `data/dev/workspaces/...` 影子目录。影子目录没有 `context/`、`template.md`、`blackboard.json`,模型无法写出 `output.md`,runner 报 `MISSING_TARGET`,步骤按 permanent-missing 不再重试。 + +## 二、 测试目标 (Test Objectives) + +- test 进程连到本环境 sidecar `18866`,不再使用根 `.env` 泄漏的 `18865`。 +- 发往 runner 之前,工作区必须位于该 runner `data_dir` 之下;不一致时 fail-fast,错误码 `RUNNER_MISMATCH`。 +- 与故障同构的确认「1」推进 V0.5 时,`bd_research`(含原先失败的 `step_03_regulatory`)在 `data/test/workspaces` 本地执行并成功,且不在 `data/dev/workspaces` 生成影子目录。 + +## 三、 测试方案与环境 (Test Plan & Environment) + +- OS:Linux;test 后端 `bin/agent-server-test` pid `1222963`,`ENV=test`,`DATA_DIR=/home/weichen/projects/Agent-System/data/test`。 +- 启动:worktree 内 `env -u PI_AGENT_RUNNER_URL ./scripts/launch-single.sh test`。sidecar `18866` 已健康,脚本未重启 sidecar。 +- 层级:`go test` 覆盖 agent/stream/errcode;产品验收为 test 环境真实 PI runner(MiniMax-M2.7-highspeed),对话 `sess-p5-u32` 发送「1」,命中 `ADVANCE_STAGE` / `EXPECTATION_HIT`,目标阶段 `V0.5_feasibility`。 +- 验收会话为一次性 `auth_sessions` 行(user 32),验收后已 `revoked_at`。令牌未写入本报告。 + +## 四、 预期结果 (Expected Results) + +- 进程环境 `PI_AGENT_RUNNER_URL=http://127.0.0.1:18866`;`18866` 的 `data_dir` 为 `data/test/workspaces`。 +- 首次 `/v1/run` 前日志出现 `pi runner data_dir check passed`。 +- `bd_research` 七步 `completed`,`step_03_regulatory/output.md` 非空,且路径在 test 工作区。 +- 不出现 `MISSING_TARGET` / `RUNNER_MISMATCH`。 +- 单测:data_dir 不一致时不调用 `/v1/run`;一致时透传;分类码为 `RUNNER_MISMATCH` 且不泄漏路径。 + +## 五、 实际测试结果与判定 (Actual Results & Evaluation) + +### 用例 1:进程指向与健康检查 + +- **输入**:重启后读 `/proc/1222963/environ`,`GET /health`,`GET` 两个 sidecar `/health`。 +- **输出**:`PI_AGENT_RUNNER_URL=http://127.0.0.1:18866`;`DATA_DIR=.../data/test`;应用 health `schema_version=78`、`agent_backend=pi-agent`;18866 `data_dir=.../data/test/workspaces`;18865 仍为 `.../data/dev/workspaces`。见 `assets/2026-09-28-pi-runner-env-mismatch/unit-and-runtime.txt`。 +- **判定**:PASS。 +- **说明**:新二进制首次启动把 test 库 schema 从 77 升到 78(`00078` 只新增 `error_events`,加法迁移)。这是基线已有迁移随本次二进制生效,不是 runner 修复本身。 + +### 用例 2:真实对话推进(与故障同构) + +- **输入**:`POST /api/v1/chat/stream`,`session_id=sess-p5-u32`,`project_id=5`,`message=1`,`client_turn_id=e2e-pi-runner-20260928`。 +- **输出**: + - HTTP 200,`X-Request-Id: req_1790565623110640277`。 + - 意图 `ADVANCE_STAGE`,分支 `EXPECTATION_HIT`,目标 `V0.5_feasibility`。 + - 服务器日志三次 `pi runner data_dir check passed`(并行前三步)。 + - sidecar 工作区全部位于 `data/test/workspaces/bizdev/stage_V0.5_feasibility_1790565623120057798/...`。 + - `bd_research` 七步均为 `completed`;`tasks/03_regulatory/output.md` 19851 字节;工作区含 `context/knowledge/hits.md`。 + - 同名目录在 `data/dev/workspaces` 不存在。 + - SSE `done.status=completed`,`duration_ms=1046800`。对话流中无 `MISSING_TARGET`、`INTERNAL_ERROR`、`RUNNER_MISMATCH`。 + - `chat_turns.id=32` 状态 `completed`。 +- **判定**:PASS(本次缺陷:错配导致的 MISSING_TARGET 未再现,原先失败步骤在 test 工作区成功)。 +- **耗时**:整段 SSE 1046.8s(03:20:23Z–03:37:49Z)。`step_03_regulatory` 阶段事件耗时 151081ms。 + +摘录:`assets/2026-09-28-pi-runner-env-mismatch/sse-phases.txt`、`server-data-dir-check.txt`、`sidecar-workspace-lines.txt`。 + +### 用例 3:阶段链在质量门禁处停止(非本次缺陷) + +- **实际**:`bd_research` 质量门禁 100/100 后,`bd_feasibility_dev` 两步执行完成,门禁 85/100,原因「缺少必填章节: 输入摘要」。`classifyStageTaskHalt` 按既有规则停止阶段链,后续 `bd_feasibility_reg`、`bd_user_requirements`、`bd_prelim_design` 未启动。 +- **对话文案**仍写「已为您成功推进并完成了【V0.5…】」。协调器在 `ExecuteStage` 返回 `err=nil` 时不读取 `Success=false`(`internal/coordinator/engine_stage.go`)。数据库 `projects.id=5` 的 `current_stage` 仍为 `V0.1_triage`。 +- **判定**:不计入本次修复 FAIL。这是质量门禁停链与成功文案不一致,超出本任务范围(不改 `resolveWorkspace`、不改 phase 归类、不改质量门禁)。 + +### 数据质量警示 + +2026-09-28 01:40 与 01:56(UTC)两次在错配 runner 下生成的 V0.5 产物未读 test 侧 `context/`。本任务不清洗、不自动重跑,由业务 owner 决定是否作废重做。 + +## 六、 自动化测试套件汇总 (Test Suite Execution) + +在 worktree `fix/pi-runner-env-mismatch-2026-09-28` 执行: + +- `go test ./internal/agent/ ./internal/stream/ ./internal/errcode/ -count=1`:三包 `ok`。 +- `go test -run TestPiAgentRemoteRunner_DataRoot` 与 `TestClassifyError_RunnerDataRootMismatch`:PASS。完整输出见 `assets/2026-09-28-pi-runner-env-mismatch/unit-and-runtime.txt`。 + +未跑全仓 `go test`。产品链路以第五节真实 PI 为准,不用 stub。 + +## 七、 测试结论与后续优化建议 (Conclusion & Next Steps) + +本次授权范围内的缺陷已关闭:test 进程连接 `18866`,工作区与 runner `data_dir` 一致,`bd_research` 全步骤(含原 `MISSING_TARGET` 的法规初筛)在 test 工作区成功。 + +残留(不在本任务实施): + +- 质量门禁未通过时阶段链停止,但协调器文案仍宣称阶段完成;项目阶段列未更新。 +- 错配期间已生成的文档需要业务 owner 决定是否重跑。 + +五态(收口时填写):review_state=accepted;integration_state 以合入结果为准;deployment=test 环境已用本 worktree 二进制重启(schema 78);cleanup=pending;remote_sync=not_requested。 diff --git a/.doc/test-reports/assets/2026-09-28-pi-runner-env-mismatch/server-data-dir-check.txt b/.doc/test-reports/assets/2026-09-28-pi-runner-env-mismatch/server-data-dir-check.txt new file mode 100644 index 00000000..a846208a --- /dev/null +++ b/.doc/test-reports/assets/2026-09-28-pi-runner-env-mismatch/server-data-dir-check.txt @@ -0,0 +1,3 @@ +40:{"time":"2026-09-28T03:20:25.737217126Z","level":"INFO","msg":"pi runner data_dir check passed"} +41:{"time":"2026-09-28T03:20:25.737493268Z","level":"INFO","msg":"pi runner data_dir check passed"} +42:{"time":"2026-09-28T03:20:25.738223226Z","level":"INFO","msg":"pi runner data_dir check passed"} diff --git a/.doc/test-reports/assets/2026-09-28-pi-runner-env-mismatch/sidecar-workspace-lines.txt b/.doc/test-reports/assets/2026-09-28-pi-runner-env-mismatch/sidecar-workspace-lines.txt new file mode 100644 index 00000000..02edb196 --- /dev/null +++ b/.doc/test-reports/assets/2026-09-28-pi-runner-env-mismatch/sidecar-workspace-lines.txt @@ -0,0 +1,9 @@ +13:[runner] Starting pi run in workspace: /home/weichen/projects/Agent-System/data/test/workspaces/bizdev/stage_V0.5_feasibility_1790565623120057798/bd_research/tasks/02_market, model: MiniMax-M2.7-highspeed +14:[runner] Starting pi run in workspace: /home/weichen/projects/Agent-System/data/test/workspaces/bizdev/stage_V0.5_feasibility_1790565623120057798/bd_research/tasks/03_regulatory, model: MiniMax-M2.7-highspeed +15:[runner] Starting pi run in workspace: /home/weichen/projects/Agent-System/data/test/workspaces/bizdev/stage_V0.5_feasibility_1790565623120057798/bd_research/tasks/01_search, model: MiniMax-M2.7-highspeed +16:[runner] Starting pi run in workspace: /home/weichen/projects/Agent-System/data/test/workspaces/bizdev/stage_V0.5_feasibility_1790565623120057798/bd_research/tasks/04_plan, model: MiniMax-M2.7-highspeed +17:[runner] Starting pi run in workspace: /home/weichen/projects/Agent-System/data/test/workspaces/bizdev/stage_V0.5_feasibility_1790565623120057798/bd_research/tasks/05_review_plan, model: MiniMax-M2.7-highspeed +18:[runner] Starting pi run in workspace: /home/weichen/projects/Agent-System/data/test/workspaces/bizdev/stage_V0.5_feasibility_1790565623120057798/bd_research/tasks/06_draft, model: MiniMax-M2.7-highspeed +19:[runner] Starting pi run in workspace: /home/weichen/projects/Agent-System/data/test/workspaces/bizdev/stage_V0.5_feasibility_1790565623120057798/bd_research/tasks/07_review, model: MiniMax-M2.7-highspeed +20:[runner] Starting pi run in workspace: /home/weichen/projects/Agent-System/data/test/workspaces/bizdev/stage_V0.5_feasibility_1790565623120057798/bd_feasibility_dev/tasks/01_draft, model: MiniMax-M2.7-highspeed +21:[runner] Starting pi run in workspace: /home/weichen/projects/Agent-System/data/test/workspaces/bizdev/stage_V0.5_feasibility_1790565623120057798/bd_feasibility_dev/tasks/02_review, model: MiniMax-M2.7-highspeed diff --git a/.doc/test-reports/assets/2026-09-28-pi-runner-env-mismatch/sse-phases.txt b/.doc/test-reports/assets/2026-09-28-pi-runner-env-mismatch/sse-phases.txt new file mode 100644 index 00000000..9db32072 --- /dev/null +++ b/.doc/test-reports/assets/2026-09-28-pi-runner-env-mismatch/sse-phases.txt @@ -0,0 +1,107 @@ +event: phase +data: {"id":"evt_1790565623115044567_1","type":"phase","timestamp":"2026-09-28T03:20:23.115046566Z","phase":{"phase_id":"intent_recognizing","message":"正在研判您的意图与项目上下文...","role":"coordinator"}} + +event: turn_ack +data: {"id":"evt_1790565623118588251_2","type":"turn_ack","timestamp":"2026-09-28T03:20:23.118589961Z","turn":{"client_turn_id":"e2e-pi-runner-20260928","user_message_id":95,"status":"processing"}} + +event: status +data: {"id":"evt_1790565623119470725_3","type":"status","timestamp":"2026-09-28T03:20:23.119471465Z","status":{"intent":"ADVANCE_STAGE","branch":"EXPECTATION_HIT","current_stage":"V0.1_triage","patches":{"target_stage":"V0.5_feasibility"},"message":"识别分支: EXPECTATION_HIT (置信度: 98%)"}} + +event: phase +data: {"id":"evt_1790565623119857514_4","type":"phase","timestamp":"2026-09-28T03:20:23.119859693Z","phase":{"phase_id":"dialogue_generating","message":"正在整理回复...","role":"assistant","progress":40}} + +event: status +data: {"id":"evt_1790565623119924622_5","type":"status","timestamp":"2026-09-28T03:20:23.119925612Z","status":{"intent":"ADVANCE_STAGE","branch":"EXPECTATION_HIT","current_stage":"V0.1_triage","patches":{"target_stage":"V0.5_feasibility"},"message":"意图判定: ADVANCE_STAGE (置信度: 98%)"}} + +event: phase +data: {"id":"evt_1790565623120033928_6","type":"phase","timestamp":"2026-09-28T03:20:23.120034458Z","phase":{"phase_id":"stage_transition","message":"准备推进项目成熟度阶段至【V0.5_feasibility】...","role":"coordinator"}} + +event: phase +data: {"id":"evt_1790565623120257272_7","type":"phase","timestamp":"2026-09-28T03:20:23.120257652Z","phase":{"phase_id":"stage_started","message":"开始推进商务成熟度阶段【V0.5_feasibility】(包含 5 项任务序列)...","role":"coordinator","total_steps":5,"progress":5}} + +event: phase +data: {"id":"evt_1790565623120306040_8","type":"phase","timestamp":"2026-09-28T03:20:23.12030643Z","phase":{"phase_id":"knowledge_retrieval","message":"项目助手正在检索权威法规知识库并筛选适用条款(关键词: 医疗器械研发项目)...","role":"regulatory","progress":10}} + +event: phase +data: {"id":"evt_1790565625734830247_9","type":"phase","timestamp":"2026-09-28T03:20:25.734831767Z","phase":{"phase_id":"knowledge_retrieved","message":"法规知识库初筛完成,精准命中 5 条国家与行业标准条款","role":"regulatory","progress":15}} + +event: phase +data: {"id":"evt_1790565625736184437_10","type":"phase","timestamp":"2026-09-28T03:20:25.736184847Z","phase":{"phase_id":"squad_assembly","message":"正在组建并启动【调研报告】智能体小队(后端: pi-agent)...","role":"leader","progress":5}} + +event: phase +data: {"id":"evt_1790565625736408330_11","type":"phase","timestamp":"2026-09-28T03:20:25.73640907Z","phase":{"phase_id":"step_executing","message":"法规专员正在进行:医疗器械法规与指导原则初筛...","role":"regulatory"}} + +event: phase +data: {"id":"evt_1790565625736428550_13","type":"phase","timestamp":"2026-09-28T03:20:25.73642911Z","phase":{"phase_id":"step_executing","message":"市场情报专员正在进行:市场格局与竞品同维度对比...","role":"market"}} + +event: phase +data: {"id":"evt_1790565625736413970_12","type":"phase","timestamp":"2026-09-28T03:20:25.7364153Z","phase":{"phase_id":"step_executing","message":"资料检索专员正在进行:公开资料与标准检索...","role":"searcher"}} + +event: phase +data: {"id":"evt_1790565769142850003_14","type":"phase","timestamp":"2026-09-28T03:22:49.142850863Z","phase":{"phase_id":"step_completed","message":"市场情报专员完成任务:市场格局与竞品同维度对比 (耗时 143404ms)","role":"market"}} + +event: phase +data: {"id":"evt_1790565776819693644_15","type":"phase","timestamp":"2026-09-28T03:22:56.819694444Z","phase":{"phase_id":"step_completed","message":"法规专员完成任务:医疗器械法规与指导原则初筛 (耗时 151081ms)","role":"regulatory"}} + +event: phase +data: {"id":"evt_1790565786074672686_16","type":"phase","timestamp":"2026-09-28T03:23:06.074673966Z","phase":{"phase_id":"step_completed","message":"资料检索专员完成任务:公开资料与标准检索 (耗时 160336ms)","role":"searcher"}} + +event: phase +data: {"id":"evt_1790565786075423684_17","type":"phase","timestamp":"2026-09-28T03:23:06.075424624Z","phase":{"phase_id":"step_executing","message":"撰写专员正在进行:撰写调研报告段落计划...","role":"writer"}} + +event: phase +data: {"id":"evt_1790565909928606231_18","type":"phase","timestamp":"2026-09-28T03:25:09.928610311Z","phase":{"phase_id":"step_completed","message":"撰写专员完成任务:撰写调研报告段落计划 (耗时 123852ms)","role":"writer"}} + +event: phase +data: {"id":"evt_1790565909928909502_19","type":"phase","timestamp":"2026-09-28T03:25:09.928910012Z","phase":{"phase_id":"step_executing","message":"审核主管正在进行:审核调研报告撰写计划...","role":"leader"}} + +event: phase +data: {"id":"evt_1790566029437296969_20","type":"phase","timestamp":"2026-09-28T03:27:09.437297799Z","phase":{"phase_id":"step_completed","message":"审核主管完成任务:审核调研报告撰写计划 (耗时 119506ms)","role":"leader"}} + +event: phase +data: {"id":"evt_1790566029437606029_21","type":"phase","timestamp":"2026-09-28T03:27:09.437606359Z","phase":{"phase_id":"step_executing","message":"撰写专员正在进行:按计划合成调研报告正文...","role":"writer"}} + +event: phase +data: {"id":"evt_1790566212844798701_22","type":"phase","timestamp":"2026-09-28T03:30:12.844803911Z","phase":{"phase_id":"step_completed","message":"撰写专员完成任务:按计划合成调研报告正文 (耗时 183406ms)","role":"writer"}} + +event: phase +data: {"id":"evt_1790566212845147631_23","type":"phase","timestamp":"2026-09-28T03:30:12.845148571Z","phase":{"phase_id":"step_executing","message":"审核主管正在进行:终审并定稿...","role":"leader"}} + +event: phase +data: {"id":"evt_1790566364912367169_24","type":"phase","timestamp":"2026-09-28T03:32:44.912367969Z","phase":{"phase_id":"step_completed","message":"审核主管完成任务:终审并定稿 (耗时 152064ms)","role":"leader"}} + +event: phase +data: {"id":"evt_1790566364912870564_25","type":"phase","timestamp":"2026-09-28T03:32:44.912871504Z","phase":{"phase_id":"quality_gate","message":"审核主管正在核验交付物规范并执行质量门禁把关...","role":"leader","progress":85}} + +event: phase +data: {"id":"evt_1790566364913381949_26","type":"phase","timestamp":"2026-09-28T03:32:44.913382489Z","phase":{"phase_id":"quality_gate","message":"质量门禁审核通过(评分: 100.0 / 100)","role":"leader","progress":95}} + +event: phase +data: {"id":"evt_1790566364914441848_28","type":"phase","timestamp":"2026-09-28T03:32:44.914442348Z","phase":{"phase_id":"advisor_briefing","message":"贴身业务参谋正在提炼关键决策摘要与风险雷达...","role":"coordinator","progress":98}} + +event: phase +data: {"id":"evt_1790566364915348721_31","type":"phase","timestamp":"2026-09-28T03:32:44.915349271Z","phase":{"phase_id":"squad_assembly","message":"正在组建并启动【设计开发可行性评估报告】智能体小队(后端: pi-agent)...","role":"leader","progress":5}} + +event: phase +data: {"id":"evt_1790566364915498706_32","type":"phase","timestamp":"2026-09-28T03:32:44.915499226Z","phase":{"phase_id":"step_executing","message":"撰写专员正在进行:撰写初稿...","role":"writer"}} + +event: phase +data: {"id":"evt_1790566494739847744_33","type":"phase","timestamp":"2026-09-28T03:34:54.739848934Z","phase":{"phase_id":"step_completed","message":"撰写专员完成任务:撰写初稿 (耗时 129822ms)","role":"writer"}} + +event: phase +data: {"id":"evt_1790566494740139705_34","type":"phase","timestamp":"2026-09-28T03:34:54.740140415Z","phase":{"phase_id":"step_executing","message":"审核主管正在进行:审核定稿...","role":"leader"}} + +event: phase +data: {"id":"evt_1790566669911651657_35","type":"phase","timestamp":"2026-09-28T03:37:49.911652837Z","phase":{"phase_id":"step_completed","message":"审核主管完成任务:审核定稿 (耗时 175167ms)","role":"leader"}} + +event: phase +data: {"id":"evt_1790566669912136492_36","type":"phase","timestamp":"2026-09-28T03:37:49.912137252Z","phase":{"phase_id":"quality_gate","message":"审核主管正在核验交付物规范并执行质量门禁把关...","role":"leader","progress":85}} + +event: phase +data: {"id":"evt_1790566669912488342_37","type":"phase","timestamp":"2026-09-28T03:37:49.912488672Z","phase":{"phase_id":"quality_gate","message":"质量门禁未通过(评分: 85.0 / 100):缺少必填章节: 输入摘要","role":"leader","progress":95}} + +event: phase +data: {"id":"evt_1790566669912824672_39","type":"phase","timestamp":"2026-09-28T03:37:49.912824872Z","phase":{"phase_id":"advisor_briefing","message":"贴身业务参谋正在提炼关键决策摘要与风险雷达...","role":"coordinator","progress":98}} + +event: done +data: {"id":"evt_1790566669915344077_43","type":"done","timestamp":"2026-09-28T03:37:49.915344407Z","done":{"session_id":"sess-p5-u32","client_turn_id":"e2e-pi-runner-20260928","duration_ms":1046800,"total_events":42,"user_message_id":95,"message_id":99,"status":"completed","content_revision":1,"content_sha256":"97614c72ccb9eb2a8ef3794bf84a10aee429b53e73dee16b629c311aa3f3722e","export_capability":{"policy":"full","formats":["rich_clipboard","markdown","docx","pdf","private_document"],"body_selector":"content_only","content_revision":1,"content_sha256":"97614c72ccb9eb2a8ef3794bf84a10aee429b53e73dee16b629c311aa3f3722e"}}} diff --git a/.doc/test-reports/assets/2026-09-28-pi-runner-env-mismatch/unit-and-runtime.txt b/.doc/test-reports/assets/2026-09-28-pi-runner-env-mismatch/unit-and-runtime.txt new file mode 100644 index 00000000..5c0895a4 --- /dev/null +++ b/.doc/test-reports/assets/2026-09-28-pi-runner-env-mismatch/unit-and-runtime.txt @@ -0,0 +1,33 @@ +# 2026-09-28 worktree go test(新增用例) + +=== RUN TestPiAgentRemoteRunner_DataRootMismatchSkipsRun +2026/09/28 03:41:20 pi runner data_dir check passed +2026/09/28 03:41:20 pi runner data root mismatch; refusing /v1/run +--- PASS: TestPiAgentRemoteRunner_DataRootMismatchSkipsRun (0.00s) +=== RUN TestPiAgentRemoteRunner_DataRootMatchStillRuns +2026/09/28 03:41:20 pi runner data_dir check passed +--- PASS: TestPiAgentRemoteRunner_DataRootMatchStillRuns (0.00s) +PASS +ok chat-agent-system/internal/agent 0.004s + +=== RUN TestClassifyError_RunnerDataRootMismatch +--- PASS: TestClassifyError_RunnerDataRootMismatch (0.00s) +PASS +ok chat-agent-system/internal/stream 0.003s + +# 同目录包回归 +ok chat-agent-system/internal/agent 3.663s +ok chat-agent-system/internal/stream 0.003s +ok chat-agent-system/internal/errcode 0.003s + +# test 进程(pid 1222963,监听 8081)environ 摘录 +ENV=test +DATA_DIR=/home/weichen/projects/Agent-System/data/test +PI_AGENT_RUNNER_URL=http://127.0.0.1:18866 + +# GET /health +{"status":"ok","env":"test","agent_backend":"pi-agent","schema_version":78,"schema_ready":true} + +# runner /health data_dir +18866 data_dir=/home/weichen/projects/Agent-System/data/test/workspaces +18865 data_dir=/home/weichen/projects/Agent-System/data/dev/workspaces diff --git a/configs/.env.test.example b/configs/.env.test.example index 73f96e38..4b4750a7 100644 --- a/configs/.env.test.example +++ b/configs/.env.test.example @@ -28,6 +28,9 @@ KB_DB_PORT=5432 # 5. Cursor / LLM Agent Runner 服务 PI_AGENT_RUNNER_PORT=18866 +# 与端口一致。本文件在根 .env 之后加载,可盖住根 .env 里的 dev runner URL。 +# launch-single.sh 在进程启动前未导出 URL 时仍按端口派生,避免 dev sidecar 泄漏。 +PI_AGENT_RUNNER_URL=http://127.0.0.1:18866 AGENT_BACKEND=pi-agent AGENT_TIMEOUT=60m # app 与 pi-agent 容器共享(test 环境建议设置非空值) diff --git a/internal/agent/backend_test.go b/internal/agent/backend_test.go index 1df81878..aec10c73 100644 --- a/internal/agent/backend_test.go +++ b/internal/agent/backend_test.go @@ -368,3 +368,86 @@ func TestPiAgentRemoteRunner_BodyTooLargeDoesNotWrite(t *testing.T) { t.Fatal("must not materialize after body-too-large reject") } } + +func TestPiAgentRemoteRunner_DataRootMismatchSkipsRun(t *testing.T) { + dir := t.TempDir() + var runHits int + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/health": + _ = json.NewEncoder(w).Encode(map[string]any{"data_dir": filepath.Join(dir, "other-root")}) + case "/v1/run": + runHits++ + http.Error(w, "should not be called", http.StatusInternalServerError) + default: + http.NotFound(w, r) + } + })) + defer ts.Close() + res, err := agent.NewBackend("pi-agent", &config.Config{ + AgentBackend: "pi-agent", + PiAgentRunnerURL: ts.URL, + PiAgentModel: "MiniMax-M3", + PiAgentProvider: "minimax", + AgentTimeout: 5 * time.Second, + }).Execute(context.Background(), agent.Request{ + WorkDir: dir, Prompt: "draft", Targets: []string{"output.md"}, + }) + if err != nil { + t.Fatal(err) + } + if res.Success || res.ErrorReason != "pi runner data root mismatch" { + t.Fatalf("got success=%v reason=%q", res.Success, res.ErrorReason) + } + if strings.Contains(res.ErrorReason, dir) { + t.Fatalf("reason leaked a path: %s", res.ErrorReason) + } + if runHits != 0 { + t.Fatalf("/v1/run hits=%d", runHits) + } +} + +func TestPiAgentRemoteRunner_DataRootMatchStillRuns(t *testing.T) { + dir := t.TempDir() + var runHits int + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/health": + _ = json.NewEncoder(w).Encode(map[string]any{"data_dir": dir}) + case "/v1/run": + runHits++ + body := "ok" + runID := filepath.Base(dir) + rv := agent.BuildResultV1(runID, "success", false, "", []struct { + RelPath string + Content []byte + }{{"output.md", []byte(body)}}) + _ = json.NewEncoder(w).Encode(map[string]any{ + "ok": true, "run_id": runID, "runner_protocol": "typed-v1", + "patches": []map[string]string{{"rel_path": "output.md", "content": body}}, + "result_v1": rv, + }) + default: + http.NotFound(w, r) + } + })) + defer ts.Close() + res, err := agent.NewBackend("pi-agent", &config.Config{ + AgentBackend: "pi-agent", + PiAgentRunnerURL: ts.URL, + PiAgentModel: "MiniMax-M3", + PiAgentProvider: "minimax", + AgentTimeout: 5 * time.Second, + }).Execute(context.Background(), agent.Request{ + WorkDir: dir, Prompt: "draft", Targets: []string{"output.md"}, + }) + if err != nil { + t.Fatal(err) + } + if !res.Success { + t.Fatalf("matched data_dir must run: %s", res.ErrorReason) + } + if runHits != 1 { + t.Fatalf("/v1/run hits=%d", runHits) + } +} diff --git a/internal/agent/piagent.go b/internal/agent/piagent.go index 615e6c06..e3d6c20d 100644 --- a/internal/agent/piagent.go +++ b/internal/agent/piagent.go @@ -6,11 +6,13 @@ import ( "encoding/json" "fmt" "io" + "log" "net/http" "os" "os/exec" "path/filepath" "strings" + "sync" "time" "chat-agent-system/internal/config" @@ -155,7 +157,60 @@ func (p *PiAgentBackend) runnerPayload(req Request, workDir string, remote bool, return body } +// runnerDataRoots caches a successful /health data_dir per runner URL. +// A failed probe is not cached: the next call may try again, and this call skips the check. +var runnerDataRoots sync.Map + +func probeRunnerDataRoot(ctx context.Context, runnerURL string) (string, bool) { + if v, ok := runnerDataRoots.Load(runnerURL); ok { + root, _ := v.(string) + return root, root != "" + } + probeCtx, cancel := context.WithTimeout(ctx, 2*time.Second) + defer cancel() + httpReq, err := http.NewRequestWithContext(probeCtx, http.MethodGet, runnerURL+"/health", nil) + if err != nil { + return "", false + } + client := &http.Client{Timeout: 2 * time.Second} + resp, err := client.Do(httpReq) + if err != nil { + return "", false + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return "", false + } + var body struct { + DataDir string `json:"data_dir"` + } + if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&body); err != nil { + return "", false + } + root := filepath.Clean(strings.TrimSpace(body.DataDir)) + if root == "" || root == "." { + return "", false + } + runnerDataRoots.Store(runnerURL, root) + log.Printf("pi runner data_dir check passed") + return root, true +} + +func workDirWithinRunnerRoot(workDir, root string) bool { + rel, err := filepath.Rel(filepath.Clean(root), filepath.Clean(workDir)) + if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { + return false + } + return true +} + func (p *PiAgentBackend) executeRemote(ctx context.Context, runnerURL string, req Request, workDir string, timeout time.Duration) (*Result, error) { + if root, ok := probeRunnerDataRoot(ctx, runnerURL); ok && !workDirWithinRunnerRoot(workDir, root) { + // Do not include either path: the caller classifies this phrase, and paths leak host layout. + log.Printf("pi runner data root mismatch; refusing /v1/run") + return &Result{Success: false, ErrorReason: "pi runner data root mismatch"}, nil + } + expectedRunID := filepath.Base(workDir) if expectedRunID == "" || expectedRunID == "." || expectedRunID == string(filepath.Separator) { expectedRunID = "unknown" diff --git a/internal/errcode/codes_domain.go b/internal/errcode/codes_domain.go index a867d755..514ab30a 100644 --- a/internal/errcode/codes_domain.go +++ b/internal/errcode/codes_domain.go @@ -48,6 +48,7 @@ func init() { {ID: "CHAT_TIMEOUT", Status: 504, Message: "生成回复超时,请稍后重试", Module: "stream", Class: ClassTransient, Actions: []string{"retry"}}, {ID: "NETWORK_ERROR", Status: 502, Message: "网络连接异常,请稍后重试", Module: "stream", Class: ClassTransient, Actions: []string{"retry"}}, {ID: "RUNNER_RESULT_REJECTED", Status: 502, Message: "助手执行结果校验失败,请重新开始任务", Module: "stream", Actions: []string{"restart"}}, + {ID: "RUNNER_MISMATCH", Status: 500, Message: "模型执行环境与数据目录不一致,请联系管理员检查部署配置", Module: "stream"}, {ID: "TASK_INTERRUPTED", Status: 409, Message: "任务执行中断,可继续或重新开始", Module: "taskrun", Class: ClassResumable, Actions: []string{"continue", "restart"}}, {ID: "TASK_SCOPE_UNRESOLVED", Status: 400, Message: "无法确定任务所属阶段", Module: "projectinstance"}, diff --git a/internal/stream/classify.go b/internal/stream/classify.go index 2d5324f7..2fddc100 100644 --- a/internal/stream/classify.go +++ b/internal/stream/classify.go @@ -71,6 +71,12 @@ func ClassifyError(err error, phaseID, requestID, sessionID, workspaceID string) info.UserMessage = "网络连接异常,请稍后重试" info.Actions = []string{"retry"} + case strings.Contains(msg, "runner data root mismatch"): + info.Code = "RUNNER_MISMATCH" + info.Class = ErrorClassFatal + info.UserMessage = "模型执行环境与数据目录不一致,请联系管理员检查部署配置" + info.Actions = []string{} + case isRunnerContractError(msg): // Permanent runner wire/contract failures must not look "resumable". info.Code = "RUNNER_RESULT_REJECTED" diff --git a/internal/stream/classify_test.go b/internal/stream/classify_test.go index b2331d2f..073f1d27 100644 --- a/internal/stream/classify_test.go +++ b/internal/stream/classify_test.go @@ -145,6 +145,31 @@ func TestClassifyError_TypedResultRejectedIsFatalNotResumable(t *testing.T) { } } +func TestClassifyError_RunnerDataRootMismatch(t *testing.T) { + info := ClassifyError( + errors.New("task bd_research failed: pi runner data root mismatch"), + "dialogue_generating", + "req_mismatch", + "sess-p5-u32", + "ws_should_not_make_this_resumable", + ) + if info.Code != "RUNNER_MISMATCH" { + t.Fatalf("code=%s", info.Code) + } + if info.Class != ErrorClassFatal { + t.Fatalf("class=%s", info.Class) + } + if len(info.Actions) != 0 { + t.Fatalf("actions=%v", info.Actions) + } + if strings.Contains(info.UserMessage, "/") || strings.Contains(info.Upstream, "/home/") { + t.Fatalf("leaked path: %+v", info) + } + if info.UserMessage == "" || info.Message != info.UserMessage { + t.Fatalf("message=%q user=%q", info.Message, info.UserMessage) + } +} + func TestClassifyError_BodyTooLargeIsFatalNotResumable(t *testing.T) { info := ClassifyError( errors.New("step step_01_organize failed: runner result body too large"), diff --git a/scripts/launch-single.sh b/scripts/launch-single.sh index 3d320e56..f4ad7b92 100755 --- a/scripts/launch-single.sh +++ b/scripts/launch-single.sh @@ -51,6 +51,8 @@ act() { printf '\033[1;33m 🔄 %s\033[0m\n' "$*"; } fail() { printf '\033[1;31m ❌ %s\033[0m\n' "$*"; } # ---------- 分层加载配置:根 .env(公共密钥)→ configs/.env.(环境差异) ---------- +# 先记下调用方在 shell 里显式导出的 runner URL。根 .env 里的 dev URL 不能算“显式”。 +PROCESS_RUNNER_URL="${PI_AGENT_RUNNER_URL:-}" # shellcheck disable=SC1091 set -a [[ -f "$ROOT/.env" ]] && . "$ROOT/.env" @@ -84,7 +86,13 @@ export CONTEXT_DB_PASSWORD="${SYSTEM_DB_PASSWORD:?SYSTEM_DB_PASSWORD required}" export CONTEXT_DB_NAME="${SYSTEM_DB_NAME:-agent_system_$ENV_NAME}" PI_PORT="${PI_AGENT_RUNNER_PORT:-18866}" -export PI_AGENT_RUNNER_URL="${PI_AGENT_RUNNER_URL:-http://127.0.0.1:$PI_PORT}" +# 非 dev 环境以端口为 runner 权威。只有进程在加载 .env 之前就导出了 URL,才尊重它 +# (运维直连远端 runner)。否则根 .env 的 dev URL(18865)会把 test 工作区打到 dev sidecar。 +if [[ -n "${PROCESS_RUNNER_URL}" ]]; then + export PI_AGENT_RUNNER_URL="${PROCESS_RUNNER_URL}" +else + export PI_AGENT_RUNNER_URL="http://127.0.0.1:${PI_PORT}" +fi # 与 compose 对齐的 vault 状态根(FAV 关闭时 inert;无 master key 时 health 同样报 disconnected) export VAULT_STATE_ROOT="${VAULT_STATE_ROOT:-$ROOT/data/vault/$ENV_NAME}"