diff --git a/cloud/sandbox.py b/cloud/sandbox.py index 352fb42..8f416f0 100644 --- a/cloud/sandbox.py +++ b/cloud/sandbox.py @@ -494,7 +494,8 @@ class LocalExecutor(Executor): return self._goal_result_from_runner( task["id"], self._run_local(cmd, extra_env)) - def goal_resume_breakpoint_task(self, task) -> dict: + def goal_resume_breakpoint_task(self, task, + model_provider=None) -> dict: """G10:调 host.py goal-resume(无 --goal-text)断点续跑。 命令:host.py goal-resume --task 。不传 @@ -502,14 +503,18 @@ class LocalExecutor(Executor): goal/verify_command/goal_max_rounds,从 goal_rounds_done 的下一轮接着跑同一循环,不开新循环、 不新建 worktree、不重置轮次。结论解析与 goal_task 同款。 - (worker 只以 (task) 一个参数调本地断点方法,与 Docker - 断点方法的签名不同,不要顺手统一。) + model_provider 可选但 worker 必须传:首轮 thread 在该 + provider 下创建,续跑不带同一 --model-provider 时 + thread/resume 报 provider not found(与 Docker 断点 + 方法同 bug,2026-10-29 probe15 实弹)。(task) 单参 + 调用仍可用;与 Docker 断点方法的签名不同,不要顺手统一。 """ if not isinstance(task, dict) or task.get("id") is None: raise ValueError("任务缺少 id") cmd = self._base_command(model=task.get("model"), effort=task.get("effort"), - approval=task.get("approval")) + approval=task.get("approval"), + model_provider=model_provider) cmd += ["goal-resume", "--task", self._host_name(task)] return self._goal_result_from_runner( task["id"], self.runner(cmd)) @@ -1424,19 +1429,25 @@ class DockerExecutor(Executor): def goal_resume_breakpoint_task(self, task, timeout=None, extra_mounts=None, network=None, - extra_env=None) -> dict: + extra_env=None, + model_provider=None) -> dict: """G10:容器内调 host.py goal-resume(无 --goal-text) 断点续跑,与 LocalExecutor.goal_resume_breakpoint_task 同语义(从 goal_rounds_done 下一轮接着跑,不开新循环)。 解析与容器内 goal_resume_task 同款;真 Docker 实弹 未做,命令形状由单测覆盖。 + model_provider 必须透传:首轮 thread 在该 provider 下 + 创建,续跑容器不带同一 --model-provider 时 Server 的 + provider 表里没有它,thread/resume 报 provider not found + (2026-10-29 probe15 实弹)。 """ if not isinstance(task, dict) or task.get("id") is None: raise ValueError("任务缺少 id") inner = self._inner_host_command( "goal-resume", "--task", self._host_name(task), model=task.get("model"), effort=task.get("effort"), - approval=task.get("approval")) + approval=task.get("approval"), + model_provider=model_provider) cmd = self.build_run_command(task, inner, extra_mounts=extra_mounts, network=network, diff --git a/cloud/tests/test_p1_security_worker.py b/cloud/tests/test_p1_security_worker.py index 3bfa293..744cc17 100644 --- a/cloud/tests/test_p1_security_worker.py +++ b/cloud/tests/test_p1_security_worker.py @@ -414,6 +414,82 @@ class P1WorkerTestCase(unittest.TestCase): self.assertIn("goal-resume", cmd) self.assertNotIn("--goal-text", cmd) + # 12b. 断点续跑透传 model_provider:首轮 thread 在该 + # provider 下创建,续跑命令必须带同一 --model-provider, + # 否则 thread/resume 因 provider 表里没有它而失败 + # (2026-10-29 probe15 实弹:sub2api not found)。 + def test_goal_resume_breakpoint_forwards_model_provider(self): + docker_calls = [] + + def dfake(cmd, timeout=None): + docker_calls.append(list(cmd)) + return SimpleNamespace( + returncode=0, + stdout="goal met after rounds\n" + "usage input=10 cached=0 cache_write=0 output=5 " + "reasoning=0 total=15 completions=1\n", + stderr="") + + task = {"id": "T1", "prompt": "orig goal"} + docker = DockerExecutor(runner=dfake, env=FAKE_ENV) + with mock.patch("cloud.sandbox.shutil.which", + return_value="/usr/bin/docker"): + docker.goal_resume_breakpoint_task( + task, model_provider="sub2api") + cmd = docker_calls[-1] + self.assertIn("goal-resume", cmd) + self.assertNotIn("--goal-text", cmd) + self.assertIn("--model-provider", cmd) + self.assertIn("sub2api", cmd) + # 不传 provider 时不带该 flag(透传语义,不是强制默认值)。 + docker_calls.clear() + with mock.patch("cloud.sandbox.shutil.which", + return_value="/usr/bin/docker"): + docker.goal_resume_breakpoint_task(task) + self.assertNotIn("--model-provider", docker_calls[-1]) + + calls = [] + + def fake(cmd): + calls.append(list(cmd)) + return SimpleNamespace( + returncode=0, + stdout="goal met after rounds\n" + "usage input=10 cached=0 cache_write=0 output=5 " + "reasoning=0 total=15 completions=1\n", + stderr="") + + local = LocalExecutor("/fake/host.py", runner=fake) + local.goal_resume_breakpoint_task(task, model_provider="sub2api") + self.assertIn("--model-provider", calls[-1]) + self.assertIn("sub2api", calls[-1]) + + # 12c. worker 本地断点调用点透传网关 provider:run_one + # 认领 goal_resume_pending 任务后,本地断点命令必须带 + # 网关解析出的 --model-provider(Docker 调用点同理, + # 沙箱级已覆盖命令形状)。 + def test_worker_local_breakpoint_forwards_gateway_provider(self): + def fake(cmd): + self.calls.append(list(cmd)) + return SimpleNamespace( + returncode=0, + stdout="goal met after rounds\n" + "usage input=10 cached=0 cache_write=0 output=5 " + "reasoning=0 total=15 completions=1\n", + stderr="") + + worker = self._worker(runner=fake) + worker._ensure_gateway = lambda task: ("sub2api", {}) + _insert_task(self.db_path, "T1", goal_mode=1, + goal="orig goal", goal_max_rounds=3, + goal_resume_pending=1, + model="gpt-5.6-terra") + worker.run_one() + self.assertEqual(_status(self.db_path, "T1"), "completed") + self.assertTrue(any( + "--model-provider" in cmd and "sub2api" in cmd + for cmd in self.calls)) + # 13. 开新目标:goal-resume 带 --goal-text(旧行为保留) def test_goal_resume_new_goal_keeps_goal_text(self): calls = [] diff --git a/cloud/worker.py b/cloud/worker.py index fb11313..d4486b6 100644 --- a/cloud/worker.py +++ b/cloud/worker.py @@ -3029,7 +3029,8 @@ class Worker: extra_mounts=tenant_mounts, network=tenant_network, extra_env=_merge_env(proxy_env, - gateway_env)), + gateway_env), + model_provider=gateway_provider), quota_check) elif task.get("_resume"): # P1 断点续跑传 None(不传 --goal-text, @@ -3091,7 +3092,7 @@ class Worker: if task.get("_goal_resume_breakpoint") and isinstance( self.executor, LocalExecutor): result = self.executor.goal_resume_breakpoint_task( - task) + task, model_provider=gateway_provider) elif task.get("_goal_resume_breakpoint"): # 假执行器/CloudExecutor 没有断点方法时 # 绝不回落成 goal(会撞已存在)或