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:
host committed 2026-10-28 11:09:29 +08:00
1 parent ac553509ea
commit 5883169c82
8 files changed
+958 -21

No files matched your search

+85
View File
@@ -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):
"""把终态失败任务重新排队:只改状态,不直接执行。