feat: 线程生命周期对齐 15 项(bindings/refs/快照/心跳绑定/移交落点)

对照 Codex app 第二批:GET /tasks/{id}/bindings(项目/
环境/主机/worktree/PR/附件/血缘总表)、/refs 外部引用
增删查、/worktree/archive 快照(branch/head/status/diff/
untracked 路径)与 restore(目录在才解归档、目录删了
409 附快照)、自动化 bind + heartbeat(走 thread/message
同款投递路,cron 绑定不唤醒)、移交 completed 后任务
worktree_host 跟随切换、pr/checks 响应补 head_sha、
thread/read 的 tool 条目补 server/status。

诚实边界见 docs/thread-lifecycle-align.md:已有 worktree
附加、自动化变化静默、建议器、服务端定时触发器未实现;
Memory 对位是待合的 PR #102,本批不重复实现。

三套件:cloud 948 OK(4 跳过)、host 91 OK、deploy 29 OK
This commit is contained in:
host committed 2026-10-28 04:56:22 +08:00
1 parent aee60fcb7d
commit cf08c4cf37
5 files changed
+1343 -56

No files matched your search

+540 -56
View File
@@ -76,6 +76,16 @@ from cloud.automations import create_automation as _auto_create
from cloud.automations import get_automation as _auto_get
from cloud.automations import list_automation_runs as _auto_runs
from cloud.automations import update_automation as _auto_update
from cloud.lifecycle import add_ref as _life_add_ref
from cloud.lifecycle import bind_automation as _life_bind
from cloud.lifecycle import create_snapshot as _life_snapshot
from cloud.lifecycle import delete_ref as _life_delete_ref
from cloud.lifecycle import get_binding as _life_get_binding
from cloud.lifecycle import latest_snapshot as _life_latest_snapshot
from cloud.lifecycle import list_refs as _life_list_refs
from cloud.lifecycle import list_snapshots as _life_list_snapshots
from cloud.lifecycle import mark_restored as _life_mark_restored
from cloud.lifecycle import unbind_automation as _life_unbind
from cloud.wait_ops import build_pr_diagnostics as _wait_build_diag
from cloud.wait_ops import create_handoff as _handoff_create
from cloud.wait_ops import decode_wait_cursor as _wait_decode_cursor
@@ -1916,6 +1926,25 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
elif rest.endswith("/worktree") and method == "GET":
self._task_worktree_status(user_id,
rest[: -len("/worktree")])
elif rest.endswith("/bindings") and method == "GET":
self._task_bindings(user_id,
rest[: -len("/bindings")])
elif rest.endswith("/refs") and method == "GET":
self._task_refs_list(user_id, rest[: -len("/refs")])
elif rest.endswith("/refs") and method == "POST":
self._task_refs_create(user_id, rest[: -len("/refs")])
elif "/refs/" in rest and method == "DELETE":
_task_part, _ref_part = rest.split("/refs/", 1)
self._task_refs_delete(user_id, _task_part, _ref_part)
elif rest.endswith("/worktree/archive") and method == "POST":
self._task_worktree_archive(
user_id, rest[: -len("/worktree/archive")])
elif rest.endswith("/worktree/restore") and method == "POST":
self._task_worktree_restore(
user_id, rest[: -len("/worktree/restore")])
elif rest.endswith("/worktree/snapshots") and method == "GET":
self._task_worktree_snapshots(
user_id, rest[: -len("/worktree/snapshots")])
elif rest.endswith("/pr/checks") and method == "GET":
self._task_pr_checks(user_id,
rest[: -len("/pr/checks")])
@@ -2769,6 +2798,40 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
return
self._automation_cancel(user_id, project_id, parts[2])
return
# /projects/{id}/automations/{aid}/bind(GET/POST/DELETE)
# / /heartbeat(POST):心跳绑定与唤醒,见 handlers。
if len(parts) == 4 and parts[1] == "automations" \
and parts[3] in ("bind", "heartbeat"):
if parts[3] == "bind" and method == "GET":
err = self._check_project(user_id, project_id, "read")
if err:
self._send_json(err[0], {"error": err[1]})
return
self._automation_bind_get(user_id, project_id, parts[2])
elif parts[3] == "bind" and method == "POST":
err = self._check_project(user_id, project_id,
"create_task")
if err:
self._send_json(err[0], {"error": err[1]})
return
self._automation_bind(user_id, project_id, parts[2])
elif parts[3] == "bind" and method == "DELETE":
err = self._check_project(user_id, project_id,
"create_task")
if err:
self._send_json(err[0], {"error": err[1]})
return
self._automation_unbind(user_id, project_id, parts[2])
elif parts[3] == "heartbeat" and method == "POST":
err = self._check_project(user_id, project_id,
"create_task")
if err:
self._send_json(err[0], {"error": err[1]})
return
self._automation_heartbeat(user_id, project_id, parts[2])
else:
self._send_json(404, {"error": "not found"})
return
# /projects/{id}/triage
if len(parts) == 2 and parts[1] == "triage":
if method != "GET":
@@ -4542,9 +4605,17 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
def _activity(entry):
if not isinstance(entry, dict):
return {"kind": "unknown"}
text = (entry.get("text") or entry.get("command")
or entry.get("path") or entry.get("summary")
or entry.get("tool") or "")
if entry.get("kind") == "tool":
# tool 条目没有 text/command,旧代码只取 tool 名,
# 丢了 server 与 status;拼成可读的一行。
text = "%s/%s: %s" % (
entry.get("server") or "",
entry.get("tool") or "",
entry.get("status") or "")
else:
text = (entry.get("text") or entry.get("command")
or entry.get("path") or entry.get("summary")
or entry.get("tool") or "")
return {"kind": entry.get("kind"),
"id": entry.get("id"),
"text": str(text)[:300]}
@@ -4686,14 +4757,25 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
if mode not in ("queued", "direct"):
self._send_json(400, {"error": "mode must be queued or direct"})
return
code, payload = self._deliver_to_thread(
user_id, task, text, mode)
self._send_json(code, payload)
def _deliver_to_thread(self, user_id, task, text, mode,
audit_action="thread_message_sent"):
"""给线程投递后续指令的共用实现(thread/message 与
自动化 heartbeat 共用同一路,不各写一套复活逻辑):
goal 任务 409、merged 409、终态任务 CAS 复活(清旧
结果/旧错误)、进行中任务写 followup(direct 再追加
直接 trace)。返回 (http_code, payload),不直接
_send_json;配额检查由调用方在调本方法前做。"""
task_id = task["id"]
if task.get("verify_command") or task.get("goal_mode"):
self._send_json(409, {"error": "goal 任务暂不支持后续消息"})
return
return 409, {"error": "goal 任务暂不支持后续消息"}
if task["status"] == "merged":
self._send_json(409, {
return 409, {
"error": "已合并任务不能继续(worktree 已删),请分支",
"status": task["status"]})
return
"status": task["status"]}
if task["status"] in TERMINAL_STATUSES:
# 复活路:与 /continue 同款终态集(merged 已在上
# 面排除),CAS 防“已有待续跑消息”时重复复活。
@@ -4710,22 +4792,19 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
(text, now, task_id))
conn.commit()
if cur.rowcount != 1:
self._send_json(409, {
return 409, {
"error": "target has a pending resume message"
" or status changed",
"status": task["status"]})
return
"status": task["status"]}
finally:
conn.close()
with _auth_store(self.server.db_path) as store:
store.audit(user_id, task["project_id"],
"thread_message_sent",
store.audit(user_id, task["project_id"], audit_action,
"%s:revived" % task_id)
self._send_json(201, {
return 201, {
"task_id": task_id, "delivery": "revived",
"followup_id": None, "status": "queued",
"mode": mode})
return
"mode": mode}
now = time.time()
conn = self._db()
try:
@@ -4747,11 +4826,10 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
if mode == "direct":
self._append_direct_trace(task, fu_id, text)
with _auth_store(self.server.db_path) as store:
store.audit(user_id, task["project_id"],
"thread_message_sent",
store.audit(user_id, task["project_id"], audit_action,
"%s:%s" % (task_id, fu_id))
self._send_json(201, dict(fu, delivery="queued"
if mode == "queued" else "direct"))
return 201, dict(fu, delivery="queued"
if mode == "queued" else "direct")
def _task_handoff_create(self, user_id, task_id):
"""POST /tasks/{id}/handoff:登记一次移交意图
@@ -4850,12 +4928,312 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
else:
self._send_json(400, {"error": result})
return
if result.get("status") == "completed":
# 移交完成即执行位置切换的落点:任务的 worktree_host
# 改成目标主机,后续 /worktree、/pr/checks、线程解析
# 都按新主机判断;失败/进行中的上报不改主机。
conn = self._db()
try:
conn.execute(
"UPDATE tasks SET worktree_host = ?,"
" updated_at = ? WHERE id = ?",
(result.get("target_host"), time.time(), task_id))
conn.commit()
finally:
conn.close()
with _auth_store(self.server.db_path) as store:
store.audit(user_id, task["project_id"],
"handoff_reported",
"%s:%s" % (task_id, handoff_id))
self._send_json(200, result)
def _worktree_summary(self, task):
"""worktree 摘要(bindings 与 /worktree 共用的读逻辑):
remote 时 exists/is_git/branch 一律 null(本机查不到,
不谎报);本机时读目录 + .git + 分支(尽力,失败 null)。"""
worktree_host = task.get("worktree_host")
remote = worktree_host if (
worktree_host and worktree_host != socket.gethostname()
) else None
path = _thread_worktree_for_task(self.server, task)
exists = None
is_git = None
branch = None
if remote is None:
exists = os.path.isdir(path)
is_git = os.path.exists(os.path.join(path, ".git")) \
if exists else False
if exists and is_git:
try:
proc = subprocess.run(
["git", "-C", path, "branch", "--show-current"],
capture_output=True, text=True, timeout=5)
if proc.returncode == 0:
branch = (proc.stdout or "").strip() or None
except Exception:
branch = None
env_status = None
if task.get("environment_id"):
env_row = self._fetch_environment(task["environment_id"])
if env_row is not None:
env_status = _environment_to_dict(env_row)["status"]
status = _wait_worktree_status(
task["status"], path, exists, is_git,
environment_status=env_status, remote_host=remote)
return dict(status, path=path, branch=branch,
environment_id=task.get("environment_id"),
remote_host=remote)
def _task_bindings(self, user_id, task_id):
"""GET /tasks/{id}/bindings(任务与 Git 状态绑定 +
项目/主机上下文):把散在任务行、项目行、环境行、
worktree、PR、附件、外部引用里的一行拼成一张总表,
供跨线程协调时一次取全。只读聚合,不产生新状态;
读权限 read。local/remote 按 worktree_host 与本机
主机名判定;default_executor 是全局缺省,任务行
的 executor 为空时才生效。"""
task, err = self._thread_task(user_id, task_id, "read")
if err is not None:
self._send_json(err[0], {"error": err[1]})
return
conn = self._db()
try:
proj = conn.execute(
"SELECT id, name, repo_url FROM projects WHERE id = ?",
(task["project_id"],)).fetchone()
atts = _attachments_map(conn, [task_id])[task_id]
refs = _life_list_refs(conn, task_id)
snaps = _life_list_snapshots(conn, task_id)
finally:
conn.close()
env = None
if task.get("environment_id"):
env_row = self._fetch_environment(task["environment_id"])
if env_row is not None:
env = _environment_to_dict(env_row)
worktree = self._worktree_summary(task)
self._send_json(200, {
"task_id": task_id,
"task_status": task["status"],
"project": ({
"id": _row_get(proj, "id"),
"name": _row_get(proj, "name"),
"repo_url": _row_get(proj, "repo_url"),
} if proj is not None else None),
"environment": env,
"host": {
"worktree_host": task.get("worktree_host"),
"local": worktree["remote_host"] is None,
"current_host": socket.gethostname(),
},
"worktree": worktree,
"branch": worktree["branch"],
"pull_request": ({
"url": task.get("pr_url"),
"number": task.get("pr_number"),
} if task.get("pr_number") else None),
"executor": task.get("executor") or _default_executor(),
"attachments": {
"files": atts,
"refs": refs,
},
"worktree_snapshots": [{
"id": s["id"], "branch": s["branch"],
"head_sha": s["head_sha"],
"created_at": s["created_at"],
"restored_at": s["restored_at"],
} for s in snaps],
"lineage": {
"parent_task_id": task.get("parent_task_id"),
"forked_from": task.get("forked_from"),
"coordinator_part": task.get("coordinator_part"),
},
})
def _task_refs_list(self, user_id, task_id):
task, err = self._thread_task(user_id, task_id, "read")
if err is not None:
self._send_json(err[0], {"error": err[1]})
return
conn = self._db()
try:
refs = _life_list_refs(conn, task_id)
finally:
conn.close()
self._send_json(200, {"task_id": task_id, "refs": refs})
def _task_refs_create(self, user_id, task_id):
"""POST /tasks/{id}/refs:记一条外部引用(生成的文档 /
构建结果 / 外部服务结果)。只记引用,不存内容、不
校验链接可达;kind 白名单见 lifecycle.REF_KINDS。
权限 create_task。"""
task, err = self._thread_task(user_id, task_id, "create_task")
if err is not None:
self._send_json(err[0], {"error": err[1]})
return
body, jerr = self._read_json()
if jerr:
self._send_json(400, {"error": jerr})
return
conn = self._db()
try:
try:
ref = _life_add_ref(
conn, task_id, task["project_id"],
body.get("kind"), body.get("ref"),
body.get("note"), user_id)
except ValueError as exc:
self._send_json(400, {"error": str(exc)})
return
finally:
conn.close()
with _auth_store(self.server.db_path) as store:
store.audit(user_id, task["project_id"], "task_ref_added",
"%s:%s" % (task_id, ref["id"]))
self._send_json(201, ref)
def _task_refs_delete(self, user_id, task_id, ref_id):
task, err = self._thread_task(user_id, task_id, "create_task")
if err is not None:
self._send_json(err[0], {"error": err[1]})
return
conn = self._db()
try:
ok = _life_delete_ref(conn, task_id, ref_id)
finally:
conn.close()
if not ok:
self._send_json(404, {"error": "ref not found"})
return
with _auth_store(self.server.db_path) as store:
store.audit(user_id, task["project_id"], "task_ref_deleted",
"%s:%s" % (task_id, ref_id))
self._send_json(200, {"task_id": task_id, "ref_id": ref_id,
"deleted": True})
def _git_text(self, path, args, limit):
"""worktree 上尽力跑一条只读 git 命令;git 不在、
不是仓库、超时都回空串,不拖垮快照。"""
try:
proc = subprocess.run(
["git", "-C", path] + args,
capture_output=True, text=True, timeout=10)
if proc.returncode != 0:
return ""
return (proc.stdout or "")[:limit]
except Exception:
return ""
def _task_worktree_archive(self, user_id, task_id):
"""POST /tasks/{id}/worktree/archive:给当前 worktree
拍一张快照(branch / head / status / diff / untracked
路径),行进 task_worktree_snapshots。快照不删目录、
不删分支——删目录是 merge/discard 的职责,归档只是
“先把证据存下来”。worktree 在别机时 409(本机
git 读不到对方目录);目录不存在时 409(无可拍)。
权限 create_task;记 worktree_snapshot_created 审计。"""
task, err = self._thread_task(user_id, task_id, "create_task")
if err is not None:
self._send_json(err[0], {"error": err[1]})
return
summary = self._worktree_summary(task)
if summary["remote_host"] is not None:
self._send_json(409, {
"error": "task worktree is on host %s;"
" snapshot must run there"
% summary["remote_host"],
"worktree_host": summary["remote_host"]})
return
path = summary["path"]
if not summary["exists"]:
self._send_json(409, {
"error": "worktree directory does not exist",
"path": path})
return
status_text = self._git_text(
path, ["status", "--porcelain"], 4000) \
if summary["is_git"] else ""
diff_text = self._git_text(
path, ["diff", "HEAD"], 20000) if summary["is_git"] else ""
head_sha = None
if summary["is_git"]:
head_sha = self._git_text(
path, ["rev-parse", "HEAD"], 100).strip() or None
untracked = [line[3:] for line in status_text.splitlines()
if line.startswith("?? ")]
conn = self._db()
try:
snap = _life_snapshot(
conn, task_id, task["project_id"], path,
summary["branch"], head_sha, status_text, diff_text,
untracked)
finally:
conn.close()
with _auth_store(self.server.db_path) as store:
store.audit(user_id, task["project_id"],
"worktree_snapshot_created",
"%s:%s" % (task_id, snap["id"]))
self._send_json(201, {
"task_id": task_id, "snapshot_id": snap["id"],
"branch": snap["branch"], "head_sha": snap["head_sha"],
"untracked_count": len(snap["untracked"]),
"status_lines": len(
(snap["status_text"] or "").splitlines()),
"note": ("snapshot only: untracked files are listed,"
" not copied; restore does not recreate a"
" deleted directory"),
})
def _task_worktree_snapshots(self, user_id, task_id):
task, err = self._thread_task(user_id, task_id, "read")
if err is not None:
self._send_json(err[0], {"error": err[1]})
return
conn = self._db()
try:
snaps = _life_list_snapshots(conn, task_id)
finally:
conn.close()
self._send_json(200, {"task_id": task_id, "snapshots": snaps})
def _task_worktree_restore(self, user_id, task_id):
"""POST /tasks/{id}/worktree/restore:解归档最新快照。
目录还在时只把快照标 restored(目录本身没动过,谈
不上“还原”);目录已删时 409 并附快照(重建需要
仓库路径与分支检出,服务端没有,不编一个假恢复)。
权限 create_task。"""
task, err = self._thread_task(user_id, task_id, "create_task")
if err is not None:
self._send_json(err[0], {"error": err[1]})
return
conn = self._db()
try:
snap = _life_latest_snapshot(conn, task_id)
finally:
conn.close()
if snap is None:
self._send_json(409, {"error": "no snapshot to restore"})
return
if not os.path.isdir(snap["worktree_path"]):
self._send_json(409, {
"error": "worktree directory is gone; snapshot"
" cannot recreate it without the repo",
"snapshot": snap})
return
conn = self._db()
try:
snap = _life_mark_restored(
conn, snap["id"],
"worktree directory still present; snapshot"
" unarchived only")
finally:
conn.close()
with _auth_store(self.server.db_path) as store:
store.audit(user_id, task["project_id"],
"worktree_snapshot_restored",
"%s:%s" % (task_id, snap["id"]))
self._send_json(200, {"task_id": task_id, "snapshot": snap})
def _task_worktree_status(self, user_id, task_id):
"""GET /tasks/{id}/worktree(get_worktree_creation_status):
派生状态,不是异步创建任务的轮询——云端 worktree 由
@@ -4868,41 +5246,8 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
if err is not None:
self._send_json(err[0], {"error": err[1]})
return
worktree_host = task.get("worktree_host")
remote = worktree_host if (
worktree_host and worktree_host != socket.gethostname()
) else None
path = _thread_worktree_for_task(self.server, task)
exists = None
is_git = None
if remote is None:
exists = os.path.isdir(path)
is_git = os.path.exists(os.path.join(path, ".git")) \
if exists else False
env_status = None
if task.get("environment_id"):
env_row = self._fetch_environment(task["environment_id"])
if env_row is not None:
env_status = _environment_to_dict(env_row)["status"]
status = _wait_worktree_status(
task["status"], path, exists, is_git,
environment_status=env_status, remote_host=remote)
branch = None
if remote is None and exists and is_git:
# 尽力取分支:git 不在、不是真仓库、超时都回 null,
# 不让分支查询失败拖垮整个 worktree 状态。
try:
proc = subprocess.run(
["git", "-C", path, "branch", "--show-current"],
capture_output=True, text=True, timeout=5)
if proc.returncode == 0:
branch = (proc.stdout or "").strip() or None
except Exception:
branch = None
self._send_json(200, dict(status, task_id=task_id,
branch=branch,
environment_id=task.get(
"environment_id")))
summary = self._worktree_summary(task)
self._send_json(200, dict(summary, task_id=task_id))
def _task_pr_checks(self, user_id, task_id):
"""GET /tasks/{id}/pr/checks(read_pull_request_check_diagnostics):
@@ -4985,6 +5330,7 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
"task_id": task_id,
"pr_url": task.get("pr_url"),
"pr_number": task["pr_number"],
"head_sha": head_sha,
"checks": diag["checks"],
"all_pass": diag["all_pass"],
"failed": diag["failed"],
@@ -7295,8 +7641,146 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
if auto is None or auto["project_id"] != project_id:
self._send_json(404, {"error": "automation not found"})
return
conn = self._db()
try:
binding = _life_get_binding(conn, automation_id)
finally:
conn.close()
self._send_json(200, dict(auto, runs=runs,
runs_note=runs_note))
runs_note=runs_note,
heartbeat_binding=binding))
def _automation_bind_get(self, user_id, project_id, automation_id):
conn = self._db()
try:
auto = _auto_get(conn, automation_id)
binding = _life_get_binding(conn, automation_id)
finally:
conn.close()
if auto is None or auto["project_id"] != project_id:
self._send_json(404, {"error": "automation not found"})
return
self._send_json(200, {"automation_id": automation_id,
"binding": binding})
def _automation_bind(self, user_id, project_id, automation_id):
"""POST .../automations/{aid}/bind {task_id, mode}:
把自动化绑到已有线程。mode=heartbeat 表示到期唤醒
该线程(走 thread/message 投递路);mode=cron 表示
到期独立跑新任务(绑定只作标记,不唤醒)。目标任务
必须在同一项目,否则 404(不泄露跨项目存在性)。
权限 create_task;记 automation_bound 审计。"""
body, jerr = self._read_json()
if jerr:
self._send_json(400, {"error": jerr})
return
conn = self._db()
try:
auto = _auto_get(conn, automation_id)
if auto is None or auto["project_id"] != project_id:
self._send_json(404, {"error": "automation not found"})
return
if auto["status"] != "active":
self._send_json(409, {
"error": "cancelled automation cannot be bound"})
return
target_id = body.get("task_id")
target = self._fetch_task(target_id) \
if isinstance(target_id, str) else None
if target is None:
self._send_json(404, {"error": "task not found"})
return
target = _task_to_dict(target)
if target["project_id"] != project_id:
self._send_json(404, {"error": "task not found"})
return
try:
binding = _life_bind(
conn, automation_id, project_id, target_id,
body.get("mode", "heartbeat"))
except ValueError as exc:
self._send_json(400, {"error": str(exc)})
return
finally:
conn.close()
with _auth_store(self.server.db_path) as store:
store.audit(user_id, project_id, "automation_bound",
"%s:%s" % (automation_id, target_id))
self._send_json(200, {"automation_id": automation_id,
"binding": binding})
def _automation_unbind(self, user_id, project_id, automation_id):
conn = self._db()
try:
auto = _auto_get(conn, automation_id)
if auto is None or auto["project_id"] != project_id:
self._send_json(404, {"error": "automation not found"})
return
ok = _life_unbind(conn, automation_id)
finally:
conn.close()
if not ok:
self._send_json(404, {"error": "no binding"})
return
with _auth_store(self.server.db_path) as store:
store.audit(user_id, project_id, "automation_unbound",
automation_id)
self._send_json(200, {"automation_id": automation_id,
"deleted": True})
def _automation_heartbeat(self, user_id, project_id, automation_id):
"""POST .../automations/{aid}/heartbeat {message?}:
手动触发一次心跳唤醒——到期触发器(host run-due /
run_due_automation)调的就是这个口,服务端本身不
起定时器。无绑定 409;mode=cron 409(cron 不唤醒
线程);message 缺省用自动化 prompt。投递走
_deliver_to_thread,与 thread/message 同款复活路,
配额门同款。记 automation_heartbeat_sent 审计。"""
body, jerr = self._read_json()
if jerr:
self._send_json(400, {"error": jerr})
return
conn = self._db()
try:
auto = _auto_get(conn, automation_id)
binding = _life_get_binding(conn, automation_id)
finally:
conn.close()
if auto is None or auto["project_id"] != project_id:
self._send_json(404, {"error": "automation not found"})
return
if binding is None:
self._send_json(409, {
"error": "automation has no heartbeat binding"})
return
if binding["mode"] != "heartbeat":
self._send_json(409, {
"error": "cron binding does not wake a thread;"
" use the automation run path"})
return
target_row = self._fetch_task(binding["task_id"])
if target_row is None:
self._send_json(404, {"error": "bound task not found"})
return
target = _task_to_dict(target_row)
if target["project_id"] != project_id:
self._send_json(404, {"error": "bound task not found"})
return
if self._reject_if_over_quota_for_task(user_id, target):
return
message = body.get("message") or auto.get("prompt") or ""
if not isinstance(message, str) or not message.strip():
self._send_json(400, {"error": "message required"})
return
message = message.strip()
if len(message) > 20000:
self._send_json(400, {"error": "message too long"})
return
code, payload = self._deliver_to_thread(
user_id, target, message, "queued",
audit_action="automation_heartbeat_sent")
self._send_json(code, dict(payload,
automation_id=automation_id))
def _automation_update(self, user_id, project_id, automation_id):
"""PATCH 单条自动化(automation_update):改名、改