feat: 长任务对齐 Codex App + /goal 检查点续跑

- --turn-timeout / turn_timeout 可配置,0 不限时(对齐桌面 App 等待语义);超时回合保留部分文本,任务记 interrupted 而非 failed
- Server.interrupt_turn + host.py interrupt --task(协议 turn/interrupt,无活动回合时不改任务状态)
- goal 循环抽成 run_goal_loop:每轮落盘检查点(含首轮前初始保存),goal-resume 从下一轮续跑;终局 goal-met/goal-unmet 后拒绝续跑
- 回合超时/中断时下一轮提示写明是回合中断而非验收失败;预算耗尽时记 interrupted 不冒充 unmet;--verify-timeout(默认 600,0 不限时,超时记 rc 124)
- 云端解析契约未动:goal met/unmet after rounds、round 行、usage 行格式保持
- 真模型实弹:端到端、断点续跑、正常多轮反馈修复(低档、gpt-5.6-terra)
This commit is contained in:
host committed 2026-10-27 02:45:44 +08:00
1 parent fc016fd459
commit ede7c6afee
3 files changed
+502 -57

No files matched your search

+239
View File
@@ -651,6 +651,245 @@ class TurnUsageSumTest(unittest.TestCase):
self.assertEqual(result["usage"]["completions"], 2)
class LongTurnAlignmentTest(unittest.TestCase):
def test_partial_text_collects_agent_messages_only(self):
events = [
{"method": "item/completed",
"params": {"item": {"type": "agentMessage",
"text": "first part"}}},
{"method": "item/agentMessage/delta",
"params": {"delta": "should be ignored"}},
{"method": "item/completed",
"params": {"item": {"type": "toolCall",
"text": "not a message"}}},
{"method": "item/completed",
"params": {"item": {"type": "agentMessage",
"text": "second part"}}},
]
self.assertEqual(host._partial_text_from_events(events),
"first part\nsecond part")
self.assertEqual(host._partial_text_from_events([]), "")
def test_timeout_maps_to_interrupted_and_threads_timeout(self):
class FakeServer:
def __init__(self):
self.seen_timeout = None
def run_turn(self, thread_id, prompt, model=None, effort=None,
sandbox=None, timeout=None):
self.seen_timeout = timeout
return {"status": "timeout", "text": "partial",
"turn_id": "u1"}
task = {"name": "t", "thread_id": "x", "sandbox": None,
"max_retries": 0, "retry_count": 0, "turns": []}
state = {"tasks": {"t": task}, "automations": []}
srv = FakeServer()
result = host.run_task_turn(srv, state, task, "do it", "m", "low",
timeout=0)
self.assertEqual(result["status"], "timeout")
self.assertEqual(task["status"], "interrupted")
self.assertEqual(task["last_text"], "partial")
self.assertEqual(srv.seen_timeout, 0)
def test_timeout_none_keeps_old_fake_server_working(self):
class OldFakeServer:
def run_turn(self, thread_id, prompt, model=None, effort=None,
sandbox=None):
return {"status": "completed", "text": "ok"}
task = {"name": "t", "thread_id": "x", "sandbox": None,
"max_retries": 0, "retry_count": 0, "turns": []}
state = {"tasks": {"t": task}, "automations": []}
result = host.run_task_turn(OldFakeServer(), state, task, "do it",
"m", "low")
self.assertEqual(result["status"], "completed")
self.assertEqual(task["status"], "completed")
def test_interrupt_turn_sends_thread_and_turn_ids(self):
srv = object.__new__(host.Server)
seen = {}
def fake_request(method, params=None, timeout=120):
seen["method"] = method
seen["params"] = params
seen["timeout"] = timeout
return {"result": {}}
srv.request = fake_request
self.assertEqual(srv.interrupt_turn("th1", "tu1"),
{"result": {}})
self.assertEqual(seen["method"], "turn/interrupt")
self.assertEqual(seen["params"],
{"threadId": "th1", "turnId": "tu1"})
self.assertIsNone(srv.interrupt_turn("th1", None))
self.assertIsNone(srv.interrupt_turn(None, "tu1"))
class GoalLoopLongTaskTest(unittest.TestCase):
def _task(self, tmp):
return {"name": "g", "thread_id": "x", "cwd": tmp, "sandbox": None,
"max_retries": 0, "retry_count": 0, "turns": []}
def test_goal_met_on_first_round_checkpoints(self):
class FakeServer:
def run_turn(self, thread_id, prompt, model=None, effort=None,
sandbox=None, timeout=None):
return {"status": "completed", "text": "done",
"turn_id": "u1"}
with tempfile.TemporaryDirectory() as tmp:
state_path = os.path.join(tmp, "host-state.json")
state = {"tasks": {}, "automations": []}
task = self._task(tmp)
state["tasks"]["g"] = task
result = host.run_goal_loop(FakeServer(), state, state_path,
task, "make it", "true", 3, "m",
"low", None, cfg={}, timeout=600)
self.assertTrue(result["ok"])
self.assertEqual(task["status"], "goal-met")
self.assertEqual(task["goal_rounds_done"], 1)
self.assertEqual(task["goal"], "make it")
self.assertEqual(task["verify_command"], "true")
saved = host.load_state(state_path)
self.assertEqual(saved["tasks"]["g"]["status"], "goal-met")
def test_goal_unmet_after_max_rounds_and_timeout_feedback(self):
class FakeServer:
def __init__(self):
self.prompts = []
def run_turn(self, thread_id, prompt, model=None, effort=None,
sandbox=None, timeout=None):
self.prompts.append(prompt)
if len(self.prompts) == 1:
return {"status": "timeout", "text": "partial work",
"turn_id": "u1"}
return {"status": "completed", "text": "done",
"turn_id": "u2"}
with tempfile.TemporaryDirectory() as tmp:
state_path = os.path.join(tmp, "host-state.json")
state = {"tasks": {}, "automations": []}
task = self._task(tmp)
state["tasks"]["g"] = task
srv = FakeServer()
result = host.run_goal_loop(srv, state, state_path,
task, "make it", "false", 2, "m",
"low", None, cfg={}, timeout=600)
self.assertFalse(result["ok"])
self.assertEqual(task["status"], "goal-unmet")
self.assertEqual(task["goal_rounds_done"], 2)
# 第二轮的提示必须说清是回合中断,不是验收失败。
self.assertIn("timeout", srv.prompts[1])
self.assertIn("未完成", srv.prompts[1])
saved = host.load_state(state_path)
self.assertEqual(saved["tasks"]["g"]["goal_rounds_done"], 2)
def test_goal_resume_continues_from_checkpoint(self):
class FakeServer:
def __init__(self):
self.prompts = []
def run_turn(self, thread_id, prompt, model=None, effort=None,
sandbox=None, timeout=None):
self.prompts.append(prompt)
return {"status": "completed", "text": "done",
"turn_id": "u9"}
with tempfile.TemporaryDirectory() as tmp:
state_path = os.path.join(tmp, "host-state.json")
state = {"tasks": {}, "automations": []}
task = self._task(tmp)
state["tasks"]["g"] = task
srv = FakeServer()
# 模拟被杀在检查点后的任务:已跑 1 轮、验测失败、状态 goal-running。
task["status"] = "goal-running"
task["goal"] = "make it"
task["verify_command"] = "true"
task["goal_max_rounds"] = 3
task["goal_rounds_done"] = 1
task["goal_last_verify"] = "missing file"
task["goal_last_turn_status"] = "completed"
task["turns"] = [{"prompt": "make it",
"result": {"status": "completed",
"text": "partial"}}]
result = host.run_goal_loop(srv, state, state_path, task,
"make it", "true", 3, "m", "low",
None, cfg={}, timeout=600,
start_round=2,
last_verify="missing file",
last_turn_status="completed")
self.assertTrue(result["ok"])
self.assertEqual(task["status"], "goal-met")
self.assertEqual(task["goal_rounds_done"], 2)
self.assertIn("missing file", srv.prompts[-1])
def test_goal_fields_saved_before_first_turn(self):
class FakeServer:
def run_turn(self, thread_id, prompt, model=None, effort=None,
sandbox=None, timeout=None):
raise RuntimeError("killed during turn")
with tempfile.TemporaryDirectory() as tmp:
state_path = os.path.join(tmp, "host-state.json")
state = {"tasks": {}, "automations": []}
task = self._task(tmp)
state["tasks"]["g"] = task
with self.assertRaises(RuntimeError):
host.run_goal_loop(FakeServer(), state, state_path,
task, "make it", "true", 3, "m", "low",
None, cfg={}, timeout=600)
saved = host.load_state(state_path)
saved_task = saved["tasks"]["g"]
self.assertEqual(saved_task["goal"], "make it")
self.assertEqual(saved_task["verify_command"], "true")
self.assertEqual(saved_task["goal_rounds_done"], 0)
self.assertEqual(saved_task["status"], "goal-running")
def test_goal_budget_stop_is_interrupted_not_unmet(self):
class FakeServer:
def run_turn(self, thread_id, prompt, model=None, effort=None,
sandbox=None, timeout=None):
raise AssertionError("budget stop must not run a turn")
with tempfile.TemporaryDirectory() as tmp:
state_path = os.path.join(tmp, "host-state.json")
state = {"tasks": {}, "automations": []}
task = self._task(tmp)
state["tasks"]["g"] = task
today = __import__("time").strftime("%Y-%m-%d")
state["usage"] = {"date": today, "turns": 5, "failed_turns": 0}
result = host.run_goal_loop(FakeServer(), state, state_path,
task, "make it", "true", 3, "m",
"low", None,
cfg={"max_daily_turns": 5},
timeout=600)
self.assertTrue(result["budget_stopped"])
self.assertFalse(result["ok"])
self.assertEqual(task["status"], "interrupted")
def test_goal_verify_timeout_is_a_verify_failure(self):
class FakeServer:
def run_turn(self, thread_id, prompt, model=None, effort=None,
sandbox=None, timeout=None):
return {"status": "completed", "text": "done",
"turn_id": "u1"}
with tempfile.TemporaryDirectory() as tmp:
state_path = os.path.join(tmp, "host-state.json")
state = {"tasks": {}, "automations": []}
task = self._task(tmp)
state["tasks"]["g"] = task
result = host.run_goal_loop(FakeServer(), state, state_path,
task, "make it", "sleep 5", 1, "m",
"low", None, cfg={}, timeout=600,
verify_timeout=1)
self.assertFalse(result["ok"])
self.assertEqual(task["status"], "goal-unmet")
self.assertIn("verify timeout", task["goal_last_verify"])
if __name__ == "__main__":
unittest.main()