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:
1 parent
3a5c5db9fb
commit
3096e04345
5 files changed
+720
-11
No files matched your search
+207
-7
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user