feat: 主线 /goal 命令自动建 goal

发给主线的一句话(含字面 /goal)即可自动建 goal,不用先
知道主线是进行中还是已终态,也不用勾选项:
- cloud/goal_command.py:server 与 worker 共用的纯文本解析,
  只认命令形状(/goal 开头、分隔符/目标标记、开启类动词),
  讨论里提到 /goal 不误触发;空目标返回空串供调用方 400。
- server followup/thread/message/continue 三入口同款:queued
  原地转 goal,已终态复活并转 goal,running 排队后由 worker
  在轮次边界转 goal;已是 goal 409、plan 400、merged 409。
- worker 排空:/goal 不当普通轮次跑,CAS 把 running 主线
  改回 queued 并写 goal/resume_message,下次认领走 goal-resume
  在同一 thread 上开新目标循环;取消/回收时 CAS 失败则把
  followup 放回 pending,绝不把取消过的任务改回 queued。
- 单测 14 项:解析、三态转换、拒绝口径、普通消息不受影响、
  continue/thread 同款、worker 轮次边界转换 + 下次 goal 跑通。
This commit is contained in:
host committed 2026-10-30 07:15:05 +08:00
1 parent 3a5c5db9fb
commit 3096e04345
5 files changed
+720 -11

No files matched your search

+207 -7
View File
@@ -67,6 +67,7 @@ from cloud.db import create_database as _create_database
from cloud.db import init_postgres_schema as _init_postgres_schema
from cloud.db import PostgresIntegrityError as _PgIntegrityError
from cloud.db import PostgresOperationalError as _PgOperationalError
from cloud.goal_command import parse_goal_command as _parse_goal_command
from cloud.peer_messages import deliver_peer_message as _deliver_peer_message
from cloud.peer_messages import ensure_followups_table as _peer_ensure_followups
from cloud.peer_messages import peer_message_prefix as _peer_prefix
@@ -4705,6 +4706,58 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
finally:
conn.close()
def _goal_convert_queued(self, task_id, target):
"""把尚未认领的 queued 主线原地转成新语义 goal 任务。
CAS 只改仍是 queued 的行:worker 在本方法执行间认领
时 rowcount 为 0,调用方回 409 请重试,绝不把已在跑
的任务悄悄改模式(在跑任务的转换走 followup 排队 +
worker 轮次边界转换,见 worker._convert_running_task_to_goal)。
成功返回新任务 dict,失败返回 None。"""
conn = self._db()
try:
cur = conn.execute(
"UPDATE tasks SET goal_mode = 1, goal = ?,"
" verify_command = NULL, goal_max_rounds = 0,"
" updated_at = ? WHERE id = ? AND status = 'queued'",
(target, time.time(), task_id))
conn.commit()
if cur.rowcount != 1:
return None
return _task_to_dict(conn.execute(
"SELECT * FROM tasks WHERE id = ?", (task_id,)
).fetchone())
finally:
conn.close()
def _goal_revive_terminal(self, task_id, target):
"""把已终态主线复活并转成新语义 goal 任务(/continue 同款)。
CAS 要求状态在终态四态且 resume_message 为空:已有待续跑
消息时不覆盖它(那条消息会先跑,goal 请求不应把它顶掉)。
成功返回新任务 dict,失败返回 None。merged 不在此列,
调用方须先按 merged 409 拦掉。"""
conn = self._db()
try:
cur = conn.execute(
"UPDATE tasks SET status = 'queued',"
" resume_message = ?, goal_mode = 1, goal = ?,"
" verify_command = NULL, goal_max_rounds = 0,"
" result_text = NULL, error_text = NULL,"
" actual_model = NULL, updated_at = ?"
" WHERE id = ? AND status IN ('completed', 'failed',"
" 'interrupted', 'merge-failed')"
" AND resume_message IS NULL",
(target, target, time.time(), task_id))
conn.commit()
if cur.rowcount != 1:
return None
return _task_to_dict(conn.execute(
"SELECT * FROM tasks WHERE id = ?", (task_id,)
).fetchone())
finally:
conn.close()
def _get_task(self, user_id, task_id):
row = self._fetch_task(task_id)
if row is None:
@@ -4878,6 +4931,10 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
送达(链路里可见),本轮跑完后立刻以它开下一轮,且
优先于排队中的消息。v1 只做运行时(含排队中)的任务:
已终态的任务 409;goal 任务 409(goal 循环自有轮次)。
例外:消息是 /goal 命令时(见 cloud/goal_command.py),
不走普通后续消息——queued 原地转 goal、running 排队后
由 worker 在轮次边界转 goal、已终态复活并转 goal;
已是 goal、plan 任务、merged、空目标各按 409/400 拒绝。
权限与取消同款:项目 create_task(写操作,不能只读)。
"""
body, err = self._read_json()
@@ -4909,13 +4966,79 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
self._send_json(400, {
"error": "mode must be queued or direct"})
return
if task["status"] not in ("queued", "running"):
goal_target = _parse_goal_command(text)
if goal_target is not None:
if not goal_target:
self._send_json(400, {"error": "goal 目标不能为空"})
return
if task.get("goal_mode") or task.get("verify_command"):
self._send_json(409, {
"error": "主线已有 goal,/goal 不会重复创建",
"status": task["status"],
"goal": task.get("goal") or task.get("prompt"),
})
return
if task.get("plan"):
self._send_json(400, {
"error": "plan 模式与 goal 模式冲突"})
return
if task["status"] == "merged":
self._send_json(409, {
"error": "已合并任务不能继续(worktree 已删),请分支",
"status": task["status"],
})
return
if task["status"] == "queued":
new_task = self._goal_convert_queued(task_id, goal_target)
if new_task is None:
self._send_json(409, {
"error": "任务状态已变化,请刷新后重试",
"status": task["status"],
})
return
with _auth_store(self.server.db_path) as store:
store.audit(user_id, task["project_id"],
"mainline_goal_created",
"%s:goal=%s" % (task_id, goal_target))
self._send_json(200, dict(
new_task, delivery="goal_converted",
goal=goal_target))
return
if task["status"] in TERMINAL_STATUSES:
new_task = self._goal_revive_terminal(task_id, goal_target)
if new_task is None:
self._send_json(409, {
"error": "任务已有待续跑消息或状态已变化,"
"请刷新后重试",
"status": task["status"],
})
return
with _auth_store(self.server.db_path) as store:
store.audit(user_id, task["project_id"],
"mainline_goal_created",
"%s:goal_revived=%s"
% (task_id, goal_target))
self._send_json(200, dict(
new_task, delivery="goal_revived",
goal=goal_target))
return
# running:不打断本轮,只排队;worker 在本轮完成、
# 排空到这条时把主线转成 goal(见 worker 排空段),
# 本轮若失败则这条留在 pending,等下次成功轮次再转。
# 下面落到普通 followup 插入路,但响应里标明是 goal。
if task["status"] not in ("queued", "running"):
self._send_json(409, {
"error": "后续消息仅支持进行中任务",
"status": task["status"],
})
return
elif task["status"] not in ("queued", "running"):
self._send_json(409, {
"error": "后续消息仅支持进行中任务",
"status": task["status"],
})
return
if task.get("verify_command"):
if task.get("verify_command") and goal_target is None:
self._send_json(409, {"error": "goal 任务暂不支持后续消息"})
return
now = time.time()
@@ -4944,6 +5067,14 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
with _auth_store(self.server.db_path) as store:
store.audit(user_id, task["project_id"],
"followup_created", "%s:%s" % (task_id, fu_id))
if goal_target is not None:
store.audit(user_id, task["project_id"],
"mainline_goal_created",
"%s:goal_queued=%s" % (task_id, goal_target))
if goal_target is not None:
self._send_json(201, dict(
fu, delivery="goal_queued", goal=goal_target))
return
self._send_json(201, fu)
def _append_direct_trace(self, task, fu_id, text):
@@ -6194,9 +6325,60 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
自动化 heartbeat 共用同一路,不各写一套复活逻辑):
goal 任务 409、merged 409、终态任务 CAS 复活(清旧
结果/旧错误)、进行中任务写 followup(direct 再追加
直接 trace)。返回 (http_code, payload),不直接
_send_json;配额检查由调用方在调本方法前做。"""
直接 trace)。例外:文本是 /goal 命令时(见
cloud/goal_command.py),按 /followups 的 goal 分支
处理——queued 原地转 goal、running 排队后由 worker
在轮次边界转 goal、已终态复活并转 goal。返回
(http_code, payload),不直接 _send_json;配额检查
由调用方在调本方法前做。"""
task_id = task["id"]
goal_target = _parse_goal_command(text)
if goal_target is not None:
if not goal_target:
return 400, {"error": "goal 目标不能为空"}
if task.get("verify_command") or task.get("goal_mode"):
return 409, {"error": "主线已有 goal,/goal 不会重复创建",
"status": task["status"],
"goal": task.get("goal") or task.get("prompt")}
if task.get("plan"):
return 400, {"error": "plan 模式与 goal 模式冲突"}
if task["status"] == "merged":
return 409, {
"error": "已合并任务不能继续(worktree 已删),请分支",
"status": task["status"]}
if task["status"] in TERMINAL_STATUSES:
new_task = self._goal_revive_terminal(task_id, goal_target)
if new_task is None:
return 409, {
"error": "target has a pending resume message"
" or status changed",
"status": task["status"]}
with _auth_store(self.server.db_path) as store:
store.audit(user_id, task["project_id"], audit_action,
"%s:goal_revived" % task_id)
return 201, {
"task_id": task_id, "delivery": "goal_revived",
"followup_id": None, "status": "queued",
"mode": mode, "goal": goal_target}
if task["status"] == "queued":
new_task = self._goal_convert_queued(task_id, goal_target)
if new_task is None:
return 409, {
"error": "任务状态已变化,请刷新后重试",
"status": task["status"]}
with _auth_store(self.server.db_path) as store:
store.audit(user_id, task["project_id"], audit_action,
"%s:goal_converted" % task_id)
return 201, {
"task_id": task_id, "delivery": "goal_converted",
"followup_id": None, "status": "queued",
"mode": mode, "goal": goal_target}
if task["status"] != "running":
return 409, {
"error": "后续消息仅支持进行中任务",
"status": task["status"]}
# running:落到下面的普通 followup 插入路,响应里
# 标 delivery=goal_queued(见本函数末尾)。
if task.get("verify_command") or task.get("goal_mode"):
return 409, {"error": "goal 任务暂不支持后续消息"}
if task["status"] == "merged":
@@ -6255,6 +6437,13 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
with _auth_store(self.server.db_path) as store:
store.audit(user_id, task["project_id"], audit_action,
"%s:%s" % (task_id, fu_id))
if goal_target is not None:
store.audit(user_id, task["project_id"],
"mainline_goal_created",
"%s:goal_queued=%s" % (task_id, goal_target))
if goal_target is not None:
return 201, dict(fu, delivery="goal_queued",
goal=goal_target)
return 201, dict(fu, delivery="queued"
if mode == "queued" else "direct")
@@ -7362,7 +7551,9 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
goal_mode=1 时同时把任务转成新 goal 语义(goal=新消息、
verify_command 清空、轮次 0),worker 走 goal-resume
在旧线程上开新目标循环;goal_mode 不传时 goal 任务沿用
原语义续跑。状态回 queued 时上一轮 result/error 清空
原语义续跑。例外:message 本身是 /goal 命令时强制
goal_mode=1,goal 与 resume_message 都取解析出的目标
而非整句(见 cloud/goal_command.py)。状态回 queued 时上一轮 result/error 清空
(与重试一致,否则排队中的任务还挂着旧结果)。
配额:全部校验通过后、写库之前按任务创建者查一次
(与重试同口径,费用记创建者名下);超限回 402,不
@@ -7415,6 +7606,11 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
if len(message) > 20000:
self._send_json(400, {"error": "message too long"})
return
goal_target = _parse_goal_command(message)
if goal_target is not None and not goal_target:
self._send_json(400, {"error": "goal 目标不能为空"})
return
resume_text = goal_target if goal_target else message
model = body.get("model")
if model is not None:
if not isinstance(model, str):
@@ -7469,6 +7665,10 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
goal_mode = None
if "goal_mode" in body:
goal_mode = 1 if body["goal_mode"] in (True, 1) else 0
if goal_target is not None:
# /goal 命令本身就是显式 goal 请求,优先于 body 里的
# goal_mode=0(消息比选项更具体),plan 冲突仍在下面 400。
goal_mode = 1
plan_eff = plan if plan is not None else (task["plan"] or 0)
goal_eff = goal_mode if goal_mode is not None \
else (task["goal_mode"] or 0)
@@ -7485,7 +7685,7 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
sets = ["status = 'queued'", "resume_message = ?",
"result_text = NULL", "error_text = NULL",
"actual_model = NULL", "updated_at = ?"]
vals = [message, time.time()]
vals = [resume_text, time.time()]
if model is not None or "model" in body:
sets.append("model = ?")
vals.append(model)
@@ -7509,7 +7709,7 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
vals.append(goal_mode)
if goal_mode == 1:
sets.append("goal = ?")
vals.append(message)
vals.append(resume_text)
sets.append("verify_command = NULL")
sets.append("goal_max_rounds = 0")
vals.append(task_id)