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:
1 parent
fc016fd459
commit
ede7c6afee
3 files changed
+502
-57
No files matched your search
@@ -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()
|
||||
|
||||
Reference in new issue
Block a user