feat: goal 断点续跑端点(G10)+ 摘要持久化与推送 outbox(G11/G12)
G10:POST /tasks/{id}/goal-resume,仅 interrupted 的 goal 任务,
走 host.py goal-resume(无 --goal-text)从 goal_rounds_done 下一轮
断点续跑,保留原线程与 worktree,状态回 queued 并置
goal_resume_pending(worker 认领后清掉),写 task_goal_resumed
审计;goal-met/unmet、failed、非 goal 一律 409;/continue 开新
循环语义不动。Local/Docker 两路新增 goal_resume_breakpoint_task,
不支持断点的执行器明确失败,绝不回落成 goal 或开新循环。
G11:digest 非失败事件持久化进 push_digest_events,worker 循环
每小时按(项目、订阅)分组汇总发一条,失败类仍即时发。
G12:webhook/slack 投递失败写 push_outbox,worker 循环后台重试
(最多 3 次、默认 60 秒退避),3 次仍失败删行并记日志,不丢
任务状态;缺凭证与无 sender 不进 outbox;文档写明 at-least-once
下 webhook/slack 可能重复,接收方须去重。
测试:cloud 769(4 跳过)、host 68、deploy 19 全过。
This commit is contained in:
1 parent
ac553509ea
commit
5883169c82
8 files changed
+958
-21
No files matched your search
@@ -1096,6 +1096,12 @@ def migrate_tasks_table(conn):
|
||||
"coordinator_parts": "TEXT",
|
||||
"parent_task_id": "TEXT",
|
||||
"coordinator_part": "TEXT",
|
||||
# G10:goal 断点续跑待处理标记(1 = 已调 /goal-resume、
|
||||
# 待 worker 按断点续跑;worker 认领后清掉,跑完不再
|
||||
# 重复触发)。与 resume_message 不同:后者是“给旧会
|
||||
# 话发一条新消息、开新循环”,本标记是“从
|
||||
# goal_rounds_done 的下一轮接着跑同一循环”。
|
||||
"goal_resume_pending": "INTEGER DEFAULT 0",
|
||||
}
|
||||
for name, coltype in new_cols.items():
|
||||
if name not in cols:
|
||||
@@ -1522,6 +1528,7 @@ def _task_to_dict(row, attachments=None):
|
||||
if _row_get(row, "coordinator_parts") else None),
|
||||
"parent_task_id": _row_get(row, "parent_task_id"),
|
||||
"coordinator_part": _row_get(row, "coordinator_part"),
|
||||
"goal_resume_pending": _row_get(row, "goal_resume_pending") or 0,
|
||||
}
|
||||
|
||||
|
||||
@@ -1909,6 +1916,8 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
|
||||
self._task_pr(user_id, rest[: -len("/pr")])
|
||||
elif rest.endswith("/retry") and method == "POST":
|
||||
self._retry_task(user_id, rest[: -len("/retry")])
|
||||
elif rest.endswith("/goal-resume") and method == "POST":
|
||||
self._goal_resume_task(user_id, rest[: -len("/goal-resume")])
|
||||
elif rest.endswith("/heartbeat") and method == "POST":
|
||||
self._heartbeat_task(user_id, rest[: -len("/heartbeat")])
|
||||
elif rest.endswith("/followups") and method == "POST":
|
||||
@@ -6104,6 +6113,82 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
|
||||
task_id)
|
||||
self._send_json(200, _task_to_dict(new_row))
|
||||
|
||||
def _goal_resume_task(self, user_id, task_id):
|
||||
"""G10:云端 goal 断点续跑——只接受 interrupted 的 goal 任务。
|
||||
|
||||
与 /continue 的区别(绝不能混):/continue 是“给旧会话
|
||||
发一条新消息”,goal 任务走 goal-resume --goal-text 在旧
|
||||
线程上开一个新目标循环;本端点走 host.py goal-resume
|
||||
(无 --goal-text)的断点语义:从 host 任务的
|
||||
goal_rounds_done 的下一轮接着跑同一循环,保留原线程
|
||||
与 worktree,不新建任务、不重置轮次。
|
||||
|
||||
拒绝口径:任务不存在 404;调用方须有 approve_merge
|
||||
(同重试:断点续跑要再花模型钱);状态非 interrupted
|
||||
一律 409(goal-met/goal-unmet 已终局、failed 按旧规则
|
||||
新建任务、running/queued 本来就在跑/等);非 goal 任务
|
||||
409。配额按任务创建者查(与重试/续跑同款),超限 402
|
||||
且不改状态。成功后状态回 queued 并置
|
||||
goal_resume_pending=1(worker 认领后清掉并按断点跑),
|
||||
上一轮 result/error/actual_model 清空,写审计
|
||||
task_goal_resumed。CAS 更新,没改到行说明状态已变,
|
||||
回 409 而不是假装成功。
|
||||
"""
|
||||
row = self._fetch_task(task_id)
|
||||
if row is None:
|
||||
self._send_json(404, {"error": "task not found"})
|
||||
return
|
||||
task = _task_to_dict(row)
|
||||
with _auth_store(self.server.db_path) as store:
|
||||
if not store.check_permission(
|
||||
user_id, task["project_id"], "approve_merge"):
|
||||
self._send_json(403, {"error": "forbidden"})
|
||||
return
|
||||
if self._reject_if_over_quota(
|
||||
user_id, quota_user_id=task.get("created_by") or user_id):
|
||||
return
|
||||
if task["status"] != "interrupted":
|
||||
self._send_json(409, {
|
||||
"error": "only interrupted goal tasks can goal-resume",
|
||||
"status": task["status"],
|
||||
})
|
||||
return
|
||||
if not (task.get("goal_mode") or task.get("verify_command")):
|
||||
self._send_json(409, {
|
||||
"error": "not a goal task",
|
||||
"status": task["status"],
|
||||
})
|
||||
return
|
||||
now = time.time()
|
||||
conn = self._db()
|
||||
try:
|
||||
migrate_tasks_table(conn)
|
||||
cur = conn.execute(
|
||||
"UPDATE tasks SET status = 'queued',"
|
||||
" goal_resume_pending = 1, result_text = NULL,"
|
||||
" error_text = NULL, actual_model = NULL, updated_at = ?"
|
||||
" WHERE id = ? AND status = 'interrupted'",
|
||||
(now, task_id),
|
||||
)
|
||||
conn.commit()
|
||||
if cur.rowcount != 1:
|
||||
self._send_json(409, {
|
||||
"error": "task status changed, goal-resume aborted",
|
||||
"status": task["status"],
|
||||
})
|
||||
return
|
||||
task = _task_to_dict(
|
||||
conn.execute(
|
||||
"SELECT * FROM tasks WHERE id = ?", (task_id,)
|
||||
).fetchone()
|
||||
)
|
||||
finally:
|
||||
conn.close()
|
||||
with _auth_store(self.server.db_path) as store:
|
||||
store.audit(user_id, task["project_id"],
|
||||
"task_goal_resumed", task_id)
|
||||
self._send_json(200, task)
|
||||
|
||||
def _retry_task(self, user_id, task_id):
|
||||
"""把终态失败任务重新排队:只改状态,不直接执行。
|
||||
|
||||
|
||||
Reference in new issue
Block a user