merge: candidate pi-runner-env-mismatch-2026-09-28 into dev

This commit is contained in:
weichen
2026-09-28 03:52:33 +00:00
13 changed files with 419 additions and 1 deletions
+1
View File
@@ -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 |
@@ -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。
@@ -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"}
@@ -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
@@ -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"}}}
@@ -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
+3
View File
@@ -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 环境建议设置非空值)
+83
View File
@@ -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)
}
}
+55
View File
@@ -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"
+1
View File
@@ -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"},
+6
View File
@@ -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"
+25
View File
@@ -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"),
+9 -1
View File
@@ -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.<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}"