Merge pull request #137 from openqj/feat-longtask-resume-guard

fix: 断点丢失时 goal-resume 拒绝成 failed,阻断自动恢复空转
This commit is contained in:
openqj authored and GitHub committed 2026-10-29 14:24:44 +08:00
commit b34812f605
4 files changed
+138 -3

No files matched your search

+31 -3
View File
@@ -117,6 +117,19 @@ def _parse_goal_output(stdout):
return None, None return None, None
def _goal_resume_refusal(stdout):
"""host.py goal-resume 的拒绝行(断点丢失时),无则返回 None。
断点丢失不是可续跑的中断:host-state 里没有任务,任何一次
续跑都会原样失败;调用方必须判 failed 终态,绝不能判
interrupted——否则 worker 自动恢复会 12 秒一轮空转到上限。
"""
for line in (stdout or "").splitlines():
if line.startswith("goal-resume 拒绝:"):
return line.strip()
return None
def _goal_interrupted_reason(stdout): def _goal_interrupted_reason(stdout):
"""goal interrupted 标记行的原因,无标记返回 None。 """goal interrupted 标记行的原因,无标记返回 None。
@@ -569,8 +582,13 @@ class LocalExecutor(Executor):
stderr = getattr(result, "stderr", "") or "" stderr = getattr(result, "stderr", "") or ""
met, goal_error = _parse_goal_output(stdout_full) met, goal_error = _parse_goal_output(stdout_full)
interrupted_reason = _goal_interrupted_reason(stdout_full) interrupted_reason = _goal_interrupted_reason(stdout_full)
refusal = _goal_resume_refusal(stdout_full)
combined = stdout_full + stderr combined = stdout_full + stderr
if "已存在" in combined: if refusal is not None and met is None:
# 断点丢失拒绝:终态 failed,不进自动恢复(见 helper)。
met, goal_error = None, refusal
status = "failed"
elif "已存在" in combined:
met, goal_error = None, ( met, goal_error = None, (
"host 任务已存在:goal 失败请用继续而非重试——" "host 任务已存在:goal 失败请用继续而非重试——"
"同任务重试会撞已存在 host-state,worker 会先" "同任务重试会撞已存在 host-state,worker 会先"
@@ -1388,7 +1406,12 @@ class DockerExecutor(Executor):
stderr = getattr(result, "stderr", "") or "" stderr = getattr(result, "stderr", "") or ""
met, goal_error = _parse_goal_output(stdout_full) met, goal_error = _parse_goal_output(stdout_full)
interrupted_reason = _goal_interrupted_reason(stdout_full) interrupted_reason = _goal_interrupted_reason(stdout_full)
if interrupted_reason is not None: refusal = _goal_resume_refusal(stdout_full)
if refusal is not None and met is None:
# 断点丢失拒绝:终态 failed,不进自动恢复(见 helper)。
status = "failed"
goal_error = refusal
elif interrupted_reason is not None:
# host 侧已记 interrupted:异常中断不是失败,云端跟 # host 侧已记 interrupted:异常中断不是失败,云端跟
# 着记 interrupted,任务可 /goal-resume 断点续跑。 # 着记 interrupted,任务可 /goal-resume 断点续跑。
status = "interrupted" status = "interrupted"
@@ -1484,7 +1507,12 @@ class DockerExecutor(Executor):
stderr = getattr(result, "stderr", "") or "" stderr = getattr(result, "stderr", "") or ""
met, goal_error = _parse_goal_output(stdout_full) met, goal_error = _parse_goal_output(stdout_full)
interrupted_reason = _goal_interrupted_reason(stdout_full) interrupted_reason = _goal_interrupted_reason(stdout_full)
if interrupted_reason is not None: refusal = _goal_resume_refusal(stdout_full)
if refusal is not None and met is None:
# 断点丢失拒绝:终态 failed,不进自动恢复(见 helper)。
status = "failed"
goal_error = refusal
elif interrupted_reason is not None:
# host 侧已记 interrupted:异常中断不是失败,云端跟 # host 侧已记 interrupted:异常中断不是失败,云端跟
# 着记 interrupted,任务可 /goal-resume 断点续跑。 # 着记 interrupted,任务可 /goal-resume 断点续跑。
status = "interrupted" status = "interrupted"
+31
View File
@@ -391,6 +391,37 @@ class TestGoalExecutors(unittest.TestCase):
self.assertEqual(result["status"], "interrupted") self.assertEqual(result["status"], "interrupted")
self.assertIn("boom", result["goal_error"]) self.assertIn("boom", result["goal_error"])
def test_goal_resume_refusal_is_failed_not_interrupted(self):
# 断点丢失拒绝(2026-10-29 实弹):host-state 里没有任务时
# goal-resume 只打印拒绝行、正常退出;三处解析(本地、
# Docker goal_resume_task、Docker 断点)都必须判 failed
# 终态,绝不能判 interrupted——否则自动恢复 12 秒一轮
# 空转到 50 次上限。
refusal = ("goal-resume 拒绝:host-state 里没有该任务 t1,"
"断点已丢失,不能续跑\n")
fake = SimpleNamespace(returncode=0, stdout=refusal, stderr="")
result = LocalExecutor("/fake/host.py",
runner=lambda cmd: fake
)._goal_result_from_runner("t1", fake)
self.assertEqual(result["status"], "failed")
self.assertIn("goal-resume 拒绝", result["goal_error"])
def fake_runner(cmd, timeout=None, env=None):
return SimpleNamespace(returncode=0, stdout=refusal,
stderr="")
with mock.patch("sandbox.shutil.which",
return_value="/usr/bin/docker"):
executor = DockerExecutor(
"/fake/host.py", runner=fake_runner,
env={"XCM_API_KEY": "placeholder-not-a-real-key"})
for method in ("goal_resume_task",
"goal_resume_breakpoint_task"):
result = getattr(executor, method)(
{"id": "t1", "prompt": "do it"})
self.assertEqual(result["status"], "failed")
self.assertIn("goal-resume 拒绝", result["goal_error"])
def test_local_goal_task_already_exists_names_retry(self): def test_local_goal_task_already_exists_names_retry(self):
def fake(cmd): def fake(cmd):
return SimpleNamespace( return SimpleNamespace(
+10
View File
@@ -3079,6 +3079,16 @@ def main():
timeout=args.turn_timeout, timeout=args.turn_timeout,
verify_timeout=args.verify_timeout) verify_timeout=args.verify_timeout)
elif args.cmd == "goal-resume": elif args.cmd == "goal-resume":
# 断点丢失守卫(2026-10-29 长任务实弹):host-state 里没有
# 该任务时直接拒绝、正常退出,绝不能让 KeyError traceback
# 冲出去——worker 会把无结论的异常退出判成 interrupted,
# 自动恢复再排队、再 KeyError,12 秒一轮空转到 50 次上限
# 才停。拒绝行由 worker 解析成 failed 终态(断点已丢,
# 续跑无意义),不进自动恢复循环。
if args.task not in (state.get("tasks") or {}):
print(f"goal-resume 拒绝:host-state 里没有该任务 "
f"{args.task},断点已丢失,不能续跑")
return
t = state["tasks"][args.task] t = state["tasks"][args.task]
# P1 goal-resume 契约(与 cloud/sandbox.py、cloud/worker.py # P1 goal-resume 契约(与 cloud/sandbox.py、cloud/worker.py
# 三处一致,绝不能混用): # 三处一致,绝不能混用):
+66
View File
@@ -0,0 +1,66 @@
"""host.py goal-resume 断点丢失守卫:state 里没有任务时拒绝不炸。
2026-10-29 长任务实弹:断点续跑在 state 里找不到任务时抛
KeyError traceback,worker 把无结论异常退出判成 interrupted,
自动恢复再排队、再 KeyError,12 秒一轮空转到 50 次上限。
守卫后:打印拒绝行、正常返回,state 文件不动、线程不恢复。
"""
import io
import json
import os
import sys
import tempfile
import unittest
from contextlib import redirect_stdout
from pathlib import Path
from unittest import mock
REPO_ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(REPO_ROOT))
import host
class FakeServer(object):
def __init__(self):
self.resumed = []
def initialize(self):
pass
def close(self):
pass
def resume_thread(self, thread_id):
self.resumed.append(thread_id)
class GoalResumeMissingTest(unittest.TestCase):
def test_missing_task_refuses_without_traceback(self):
with tempfile.TemporaryDirectory() as tmp:
state_path = os.path.join(tmp, "host-state.json")
cfg_path = os.path.join(tmp, "host-config.json")
with open(state_path, "w", encoding="utf-8") as f:
json.dump({"tasks": {"g": {"name": "g",
"thread_id": "x",
"status": "goal-running"}},
"automations": []}, f)
with open(cfg_path, "w", encoding="utf-8") as f:
json.dump({}, f)
fake = FakeServer()
argv = ["host.py", "--state", state_path,
"--config", cfg_path,
"goal-resume", "--task", "missing-task"]
out = io.StringIO()
with mock.patch.object(sys, "argv", argv):
with mock.patch.object(host, "Server",
lambda **kw: fake):
with redirect_stdout(out):
host.main()
self.assertIn("goal-resume 拒绝", out.getvalue())
self.assertIn("missing-task", out.getvalue())
self.assertEqual(fake.resumed, [])
with open(state_path, encoding="utf-8") as f:
saved = json.load(f)
self.assertEqual(list(saved["tasks"].keys()), ["g"])