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:
1 parent
aee60fcb7d
commit
cf08c4cf37
5 files changed
+1343
-56
No files matched your search
@@ -0,0 +1,288 @@
|
|||||||
|
"""线程生命周期补充:worktree 快照、任务外部引用、自动化心跳绑定。
|
||||||
|
|
||||||
|
本模块只补控制面已有能力的缺口,不发明执行能力:
|
||||||
|
|
||||||
|
- worktree 快照(task_worktree_snapshots):归档线程时真正要
|
||||||
|
保住的是“未提交修改 + 未推送 commit 的位置 + 非忽略文件
|
||||||
|
清单”。快照记 branch / head_sha / git status(截 4000 字)/
|
||||||
|
git diff(截 20000 字)/ untracked 路径清单。注意诚实边界:
|
||||||
|
untracked 文件只记路径不拷内容,diff 只覆盖已跟踪文件的
|
||||||
|
修改;快照是事后可查的证据,不是可一键还原的备份。恢复
|
||||||
|
(restore)只在 worktree 目录还在时解归档;目录已删时
|
||||||
|
回 409 并附快照(重建需要仓库路径,服务端没有,不编造)。
|
||||||
|
- 任务外部引用(task_refs):任务附件表只记文件包路径,
|
||||||
|
交付物里的“生成的文档 / 构建结果 / 外部服务结果引用”
|
||||||
|
没有地方放。refs 只记 kind + ref + note 的引用行,不存
|
||||||
|
文件内容、不校验外部链接可达——引用就是引用。
|
||||||
|
- 自动化心跳绑定(automation_bindings):自动化定义本身
|
||||||
|
只有 interval + prompt,没有“绑到哪个线程”。绑定记
|
||||||
|
automation -> task + mode(heartbeat | cron)。heartbeat
|
||||||
|
是“到期唤醒已有线程”(走 thread/message 的投递路);
|
||||||
|
cron 是“到期独立跑新任务”(走自动化自身的产出路)。
|
||||||
|
本模块只记绑定与取绑定;到期触发仍由外部触发器
|
||||||
|
(host run-due / 未来的 run_due_automation)调用
|
||||||
|
/heartbeat 端点,服务端没有也不会偷偷起定时器。
|
||||||
|
|
||||||
|
三张表都走 UNMIGRATED_TABLES(见 cloud/migrations.py):
|
||||||
|
运行时 ensure 建表,与 task_handoffs 同款,不进
|
||||||
|
POSTGRES_SCHEMA 快照。"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
SNAPSHOT_STATUS_LIMIT = 4000
|
||||||
|
SNAPSHOT_DIFF_LIMIT = 20000
|
||||||
|
REF_LIMIT = 2000
|
||||||
|
NOTE_LIMIT = 1000
|
||||||
|
REF_KINDS = ("doc", "build", "external", "pr", "file")
|
||||||
|
BINDING_MODES = ("heartbeat", "cron")
|
||||||
|
|
||||||
|
|
||||||
|
def _row_get(row, name):
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
if isinstance(row, dict):
|
||||||
|
return row.get(name)
|
||||||
|
try:
|
||||||
|
return row[name]
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# -- worktree 快照 ------------------------------------------------------
|
||||||
|
|
||||||
|
def ensure_snapshots_table(conn):
|
||||||
|
conn.execute(
|
||||||
|
"CREATE TABLE IF NOT EXISTS task_worktree_snapshots("
|
||||||
|
"id TEXT PRIMARY KEY, "
|
||||||
|
"task_id TEXT, "
|
||||||
|
"project_id TEXT, "
|
||||||
|
"worktree_path TEXT, "
|
||||||
|
"branch TEXT, "
|
||||||
|
"head_sha TEXT, "
|
||||||
|
"status_text TEXT, "
|
||||||
|
"diff_text TEXT, "
|
||||||
|
"untracked_json TEXT, "
|
||||||
|
"created_at DOUBLE PRECISION, "
|
||||||
|
"restored_at DOUBLE PRECISION, "
|
||||||
|
"restore_note TEXT)")
|
||||||
|
conn.execute(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_snapshots_task"
|
||||||
|
" ON task_worktree_snapshots(task_id, created_at)")
|
||||||
|
|
||||||
|
|
||||||
|
def snapshot_to_dict(row):
|
||||||
|
try:
|
||||||
|
untracked = json.loads(_row_get(row, "untracked_json") or "[]")
|
||||||
|
except ValueError:
|
||||||
|
untracked = []
|
||||||
|
if not isinstance(untracked, list):
|
||||||
|
untracked = []
|
||||||
|
return {
|
||||||
|
"id": _row_get(row, "id"),
|
||||||
|
"task_id": _row_get(row, "task_id"),
|
||||||
|
"project_id": _row_get(row, "project_id"),
|
||||||
|
"worktree_path": _row_get(row, "worktree_path"),
|
||||||
|
"branch": _row_get(row, "branch"),
|
||||||
|
"head_sha": _row_get(row, "head_sha"),
|
||||||
|
"status_text": _row_get(row, "status_text"),
|
||||||
|
"diff_text": _row_get(row, "diff_text"),
|
||||||
|
"untracked": untracked,
|
||||||
|
"created_at": _row_get(row, "created_at"),
|
||||||
|
"restored_at": _row_get(row, "restored_at"),
|
||||||
|
"restore_note": _row_get(row, "restore_note"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def create_snapshot(conn, task_id, project_id, worktree_path, branch,
|
||||||
|
head_sha, status_text, diff_text, untracked, now=None):
|
||||||
|
"""插一条快照并提交。status/diff 在调用方截断,这里再截
|
||||||
|
一次防直接调用方越界;untracked 只接受 list[str]。"""
|
||||||
|
ensure_snapshots_table(conn)
|
||||||
|
snap_id = uuid.uuid4().hex
|
||||||
|
now = time.time() if now is None else now
|
||||||
|
untracked = [str(p) for p in (untracked or []) if isinstance(p, str)]
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO task_worktree_snapshots(id, task_id, project_id,"
|
||||||
|
" worktree_path, branch, head_sha, status_text, diff_text,"
|
||||||
|
" untracked_json, created_at, restored_at, restore_note)"
|
||||||
|
" VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, NULL)",
|
||||||
|
(snap_id, task_id, project_id, worktree_path, branch,
|
||||||
|
head_sha, (status_text or "")[:SNAPSHOT_STATUS_LIMIT],
|
||||||
|
(diff_text or "")[:SNAPSHOT_DIFF_LIMIT],
|
||||||
|
json.dumps(untracked), now))
|
||||||
|
conn.commit()
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT * FROM task_worktree_snapshots WHERE id = ?",
|
||||||
|
(snap_id,)).fetchone()
|
||||||
|
return snapshot_to_dict(row)
|
||||||
|
|
||||||
|
|
||||||
|
def list_snapshots(conn, task_id):
|
||||||
|
ensure_snapshots_table(conn)
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT * FROM task_worktree_snapshots WHERE task_id = ?"
|
||||||
|
" ORDER BY created_at DESC, id",
|
||||||
|
(task_id,)).fetchall()
|
||||||
|
return [snapshot_to_dict(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def latest_snapshot(conn, task_id):
|
||||||
|
rows = list_snapshots(conn, task_id)
|
||||||
|
return rows[0] if rows else None
|
||||||
|
|
||||||
|
|
||||||
|
def mark_restored(conn, snapshot_id, note, now=None):
|
||||||
|
ensure_snapshots_table(conn)
|
||||||
|
now = time.time() if now is None else now
|
||||||
|
cur = conn.execute(
|
||||||
|
"UPDATE task_worktree_snapshots SET restored_at = ?,"
|
||||||
|
" restore_note = ? WHERE id = ?",
|
||||||
|
(now, note, snapshot_id))
|
||||||
|
conn.commit()
|
||||||
|
if cur.rowcount != 1:
|
||||||
|
return None
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT * FROM task_worktree_snapshots WHERE id = ?",
|
||||||
|
(snapshot_id,)).fetchone()
|
||||||
|
return snapshot_to_dict(row)
|
||||||
|
|
||||||
|
|
||||||
|
# -- 任务外部引用 ---------------------------------------------------------
|
||||||
|
|
||||||
|
def ensure_refs_table(conn):
|
||||||
|
conn.execute(
|
||||||
|
"CREATE TABLE IF NOT EXISTS task_refs("
|
||||||
|
"id TEXT PRIMARY KEY, "
|
||||||
|
"task_id TEXT, "
|
||||||
|
"project_id TEXT, "
|
||||||
|
"kind TEXT, "
|
||||||
|
"ref TEXT, "
|
||||||
|
"note TEXT, "
|
||||||
|
"created_by TEXT, "
|
||||||
|
"created_at DOUBLE PRECISION)")
|
||||||
|
conn.execute(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_refs_task"
|
||||||
|
" ON task_refs(task_id, created_at)")
|
||||||
|
|
||||||
|
|
||||||
|
def ref_to_dict(row):
|
||||||
|
return {
|
||||||
|
"id": _row_get(row, "id"),
|
||||||
|
"task_id": _row_get(row, "task_id"),
|
||||||
|
"project_id": _row_get(row, "project_id"),
|
||||||
|
"kind": _row_get(row, "kind"),
|
||||||
|
"ref": _row_get(row, "ref"),
|
||||||
|
"note": _row_get(row, "note"),
|
||||||
|
"created_by": _row_get(row, "created_by"),
|
||||||
|
"created_at": _row_get(row, "created_at"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def add_ref(conn, task_id, project_id, kind, ref, note, created_by,
|
||||||
|
now=None):
|
||||||
|
"""校验后插引用行。kind 不在白名单、ref 为空或超长、
|
||||||
|
note 超长时抛 ValueError(调用方转 400)。"""
|
||||||
|
if kind not in REF_KINDS:
|
||||||
|
raise ValueError("kind must be one of %s" % ", ".join(REF_KINDS))
|
||||||
|
if not ref or not isinstance(ref, str) or not ref.strip():
|
||||||
|
raise ValueError("ref required")
|
||||||
|
ref = ref.strip()
|
||||||
|
if len(ref) > REF_LIMIT:
|
||||||
|
raise ValueError("ref too long")
|
||||||
|
if note is not None and len(str(note)) > NOTE_LIMIT:
|
||||||
|
raise ValueError("note too long")
|
||||||
|
ensure_refs_table(conn)
|
||||||
|
ref_id = uuid.uuid4().hex
|
||||||
|
now = time.time() if now is None else now
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO task_refs(id, task_id, project_id, kind, ref,"
|
||||||
|
" note, created_by, created_at) VALUES(?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
(ref_id, task_id, project_id, kind, ref, note, created_by,
|
||||||
|
now))
|
||||||
|
conn.commit()
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT * FROM task_refs WHERE id = ?", (ref_id,)).fetchone()
|
||||||
|
return ref_to_dict(row)
|
||||||
|
|
||||||
|
|
||||||
|
def list_refs(conn, task_id):
|
||||||
|
ensure_refs_table(conn)
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT * FROM task_refs WHERE task_id = ?"
|
||||||
|
" ORDER BY created_at, id",
|
||||||
|
(task_id,)).fetchall()
|
||||||
|
return [ref_to_dict(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def delete_ref(conn, task_id, ref_id):
|
||||||
|
"""只删属于该任务的引用行;行不存在或不属于该任务时
|
||||||
|
返回 False(调用方一律 404,不泄露跨任务存在性)。"""
|
||||||
|
ensure_refs_table(conn)
|
||||||
|
cur = conn.execute(
|
||||||
|
"DELETE FROM task_refs WHERE id = ? AND task_id = ?",
|
||||||
|
(ref_id, task_id))
|
||||||
|
conn.commit()
|
||||||
|
return cur.rowcount == 1
|
||||||
|
|
||||||
|
|
||||||
|
# -- 自动化心跳绑定 --------------------------------------------------------
|
||||||
|
|
||||||
|
def ensure_bindings_table(conn):
|
||||||
|
conn.execute(
|
||||||
|
"CREATE TABLE IF NOT EXISTS automation_bindings("
|
||||||
|
"automation_id TEXT PRIMARY KEY, "
|
||||||
|
"project_id TEXT, "
|
||||||
|
"task_id TEXT, "
|
||||||
|
"mode TEXT, "
|
||||||
|
"created_at DOUBLE PRECISION)")
|
||||||
|
|
||||||
|
|
||||||
|
def binding_to_dict(row):
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
"automation_id": _row_get(row, "automation_id"),
|
||||||
|
"project_id": _row_get(row, "project_id"),
|
||||||
|
"task_id": _row_get(row, "task_id"),
|
||||||
|
"mode": _row_get(row, "mode"),
|
||||||
|
"created_at": _row_get(row, "created_at"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def bind_automation(conn, automation_id, project_id, task_id, mode,
|
||||||
|
now=None):
|
||||||
|
if mode not in BINDING_MODES:
|
||||||
|
raise ValueError("mode must be heartbeat or cron")
|
||||||
|
ensure_bindings_table(conn)
|
||||||
|
now = time.time() if now is None else now
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO automation_bindings(automation_id, project_id,"
|
||||||
|
" task_id, mode, created_at) VALUES(?, ?, ?, ?, ?)"
|
||||||
|
" ON CONFLICT(automation_id) DO UPDATE SET"
|
||||||
|
" task_id = excluded.task_id, mode = excluded.mode,"
|
||||||
|
" created_at = excluded.created_at",
|
||||||
|
(automation_id, project_id, task_id, mode, now))
|
||||||
|
conn.commit()
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT * FROM automation_bindings WHERE automation_id = ?",
|
||||||
|
(automation_id,)).fetchone()
|
||||||
|
return binding_to_dict(row)
|
||||||
|
|
||||||
|
|
||||||
|
def get_binding(conn, automation_id):
|
||||||
|
ensure_bindings_table(conn)
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT * FROM automation_bindings WHERE automation_id = ?",
|
||||||
|
(automation_id,)).fetchone()
|
||||||
|
return binding_to_dict(row)
|
||||||
|
|
||||||
|
|
||||||
|
def unbind_automation(conn, automation_id):
|
||||||
|
ensure_bindings_table(conn)
|
||||||
|
cur = conn.execute(
|
||||||
|
"DELETE FROM automation_bindings WHERE automation_id = ?",
|
||||||
|
(automation_id,))
|
||||||
|
conn.commit()
|
||||||
|
return cur.rowcount == 1
|
||||||
@@ -105,6 +105,9 @@ UNMIGRATED_TABLES = (
|
|||||||
"external_identities",
|
"external_identities",
|
||||||
"pr_jobs",
|
"pr_jobs",
|
||||||
"task_handoffs",
|
"task_handoffs",
|
||||||
|
"task_worktree_snapshots",
|
||||||
|
"task_refs",
|
||||||
|
"automation_bindings",
|
||||||
"rate_limit_hits",
|
"rate_limit_hits",
|
||||||
"user_gateway_keys",
|
"user_gateway_keys",
|
||||||
"webhook_deliveries",
|
"webhook_deliveries",
|
||||||
|
|||||||
+540
-56
@@ -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 get_automation as _auto_get
|
||||||
from cloud.automations import list_automation_runs as _auto_runs
|
from cloud.automations import list_automation_runs as _auto_runs
|
||||||
from cloud.automations import update_automation as _auto_update
|
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 build_pr_diagnostics as _wait_build_diag
|
||||||
from cloud.wait_ops import create_handoff as _handoff_create
|
from cloud.wait_ops import create_handoff as _handoff_create
|
||||||
from cloud.wait_ops import decode_wait_cursor as _wait_decode_cursor
|
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":
|
elif rest.endswith("/worktree") and method == "GET":
|
||||||
self._task_worktree_status(user_id,
|
self._task_worktree_status(user_id,
|
||||||
rest[: -len("/worktree")])
|
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":
|
elif rest.endswith("/pr/checks") and method == "GET":
|
||||||
self._task_pr_checks(user_id,
|
self._task_pr_checks(user_id,
|
||||||
rest[: -len("/pr/checks")])
|
rest[: -len("/pr/checks")])
|
||||||
@@ -2769,6 +2798,40 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
|
|||||||
return
|
return
|
||||||
self._automation_cancel(user_id, project_id, parts[2])
|
self._automation_cancel(user_id, project_id, parts[2])
|
||||||
return
|
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
|
# /projects/{id}/triage
|
||||||
if len(parts) == 2 and parts[1] == "triage":
|
if len(parts) == 2 and parts[1] == "triage":
|
||||||
if method != "GET":
|
if method != "GET":
|
||||||
@@ -4542,9 +4605,17 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
|
|||||||
def _activity(entry):
|
def _activity(entry):
|
||||||
if not isinstance(entry, dict):
|
if not isinstance(entry, dict):
|
||||||
return {"kind": "unknown"}
|
return {"kind": "unknown"}
|
||||||
text = (entry.get("text") or entry.get("command")
|
if entry.get("kind") == "tool":
|
||||||
or entry.get("path") or entry.get("summary")
|
# tool 条目没有 text/command,旧代码只取 tool 名,
|
||||||
or entry.get("tool") or "")
|
# 丢了 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"),
|
return {"kind": entry.get("kind"),
|
||||||
"id": entry.get("id"),
|
"id": entry.get("id"),
|
||||||
"text": str(text)[:300]}
|
"text": str(text)[:300]}
|
||||||
@@ -4686,14 +4757,25 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
|
|||||||
if mode not in ("queued", "direct"):
|
if mode not in ("queued", "direct"):
|
||||||
self._send_json(400, {"error": "mode must be queued or direct"})
|
self._send_json(400, {"error": "mode must be queued or direct"})
|
||||||
return
|
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"):
|
if task.get("verify_command") or task.get("goal_mode"):
|
||||||
self._send_json(409, {"error": "goal 任务暂不支持后续消息"})
|
return 409, {"error": "goal 任务暂不支持后续消息"}
|
||||||
return
|
|
||||||
if task["status"] == "merged":
|
if task["status"] == "merged":
|
||||||
self._send_json(409, {
|
return 409, {
|
||||||
"error": "已合并任务不能继续(worktree 已删),请分支",
|
"error": "已合并任务不能继续(worktree 已删),请分支",
|
||||||
"status": task["status"]})
|
"status": task["status"]}
|
||||||
return
|
|
||||||
if task["status"] in TERMINAL_STATUSES:
|
if task["status"] in TERMINAL_STATUSES:
|
||||||
# 复活路:与 /continue 同款终态集(merged 已在上
|
# 复活路:与 /continue 同款终态集(merged 已在上
|
||||||
# 面排除),CAS 防“已有待续跑消息”时重复复活。
|
# 面排除),CAS 防“已有待续跑消息”时重复复活。
|
||||||
@@ -4710,22 +4792,19 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
|
|||||||
(text, now, task_id))
|
(text, now, task_id))
|
||||||
conn.commit()
|
conn.commit()
|
||||||
if cur.rowcount != 1:
|
if cur.rowcount != 1:
|
||||||
self._send_json(409, {
|
return 409, {
|
||||||
"error": "target has a pending resume message"
|
"error": "target has a pending resume message"
|
||||||
" or status changed",
|
" or status changed",
|
||||||
"status": task["status"]})
|
"status": task["status"]}
|
||||||
return
|
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
with _auth_store(self.server.db_path) as store:
|
with _auth_store(self.server.db_path) as store:
|
||||||
store.audit(user_id, task["project_id"],
|
store.audit(user_id, task["project_id"], audit_action,
|
||||||
"thread_message_sent",
|
|
||||||
"%s:revived" % task_id)
|
"%s:revived" % task_id)
|
||||||
self._send_json(201, {
|
return 201, {
|
||||||
"task_id": task_id, "delivery": "revived",
|
"task_id": task_id, "delivery": "revived",
|
||||||
"followup_id": None, "status": "queued",
|
"followup_id": None, "status": "queued",
|
||||||
"mode": mode})
|
"mode": mode}
|
||||||
return
|
|
||||||
now = time.time()
|
now = time.time()
|
||||||
conn = self._db()
|
conn = self._db()
|
||||||
try:
|
try:
|
||||||
@@ -4747,11 +4826,10 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
|
|||||||
if mode == "direct":
|
if mode == "direct":
|
||||||
self._append_direct_trace(task, fu_id, text)
|
self._append_direct_trace(task, fu_id, text)
|
||||||
with _auth_store(self.server.db_path) as store:
|
with _auth_store(self.server.db_path) as store:
|
||||||
store.audit(user_id, task["project_id"],
|
store.audit(user_id, task["project_id"], audit_action,
|
||||||
"thread_message_sent",
|
|
||||||
"%s:%s" % (task_id, fu_id))
|
"%s:%s" % (task_id, fu_id))
|
||||||
self._send_json(201, dict(fu, delivery="queued"
|
return 201, dict(fu, delivery="queued"
|
||||||
if mode == "queued" else "direct"))
|
if mode == "queued" else "direct")
|
||||||
|
|
||||||
def _task_handoff_create(self, user_id, task_id):
|
def _task_handoff_create(self, user_id, task_id):
|
||||||
"""POST /tasks/{id}/handoff:登记一次移交意图
|
"""POST /tasks/{id}/handoff:登记一次移交意图
|
||||||
@@ -4850,12 +4928,312 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
|
|||||||
else:
|
else:
|
||||||
self._send_json(400, {"error": result})
|
self._send_json(400, {"error": result})
|
||||||
return
|
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:
|
with _auth_store(self.server.db_path) as store:
|
||||||
store.audit(user_id, task["project_id"],
|
store.audit(user_id, task["project_id"],
|
||||||
"handoff_reported",
|
"handoff_reported",
|
||||||
"%s:%s" % (task_id, handoff_id))
|
"%s:%s" % (task_id, handoff_id))
|
||||||
self._send_json(200, result)
|
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):
|
def _task_worktree_status(self, user_id, task_id):
|
||||||
"""GET /tasks/{id}/worktree(get_worktree_creation_status):
|
"""GET /tasks/{id}/worktree(get_worktree_creation_status):
|
||||||
派生状态,不是异步创建任务的轮询——云端 worktree 由
|
派生状态,不是异步创建任务的轮询——云端 worktree 由
|
||||||
@@ -4868,41 +5246,8 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
|
|||||||
if err is not None:
|
if err is not None:
|
||||||
self._send_json(err[0], {"error": err[1]})
|
self._send_json(err[0], {"error": err[1]})
|
||||||
return
|
return
|
||||||
worktree_host = task.get("worktree_host")
|
summary = self._worktree_summary(task)
|
||||||
remote = worktree_host if (
|
self._send_json(200, dict(summary, task_id=task_id))
|
||||||
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")))
|
|
||||||
|
|
||||||
def _task_pr_checks(self, user_id, task_id):
|
def _task_pr_checks(self, user_id, task_id):
|
||||||
"""GET /tasks/{id}/pr/checks(read_pull_request_check_diagnostics):
|
"""GET /tasks/{id}/pr/checks(read_pull_request_check_diagnostics):
|
||||||
@@ -4985,6 +5330,7 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
|
|||||||
"task_id": task_id,
|
"task_id": task_id,
|
||||||
"pr_url": task.get("pr_url"),
|
"pr_url": task.get("pr_url"),
|
||||||
"pr_number": task["pr_number"],
|
"pr_number": task["pr_number"],
|
||||||
|
"head_sha": head_sha,
|
||||||
"checks": diag["checks"],
|
"checks": diag["checks"],
|
||||||
"all_pass": diag["all_pass"],
|
"all_pass": diag["all_pass"],
|
||||||
"failed": diag["failed"],
|
"failed": diag["failed"],
|
||||||
@@ -7295,8 +7641,146 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
|
|||||||
if auto is None or auto["project_id"] != project_id:
|
if auto is None or auto["project_id"] != project_id:
|
||||||
self._send_json(404, {"error": "automation not found"})
|
self._send_json(404, {"error": "automation not found"})
|
||||||
return
|
return
|
||||||
|
conn = self._db()
|
||||||
|
try:
|
||||||
|
binding = _life_get_binding(conn, automation_id)
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
self._send_json(200, dict(auto, runs=runs,
|
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):
|
def _automation_update(self, user_id, project_id, automation_id):
|
||||||
"""PATCH 单条自动化(automation_update):改名、改
|
"""PATCH 单条自动化(automation_update):改名、改
|
||||||
|
|||||||
@@ -0,0 +1,298 @@
|
|||||||
|
"""线程生命周期补充单测:cloud/lifecycle 纯逻辑 + server 端点。
|
||||||
|
|
||||||
|
覆盖:bindings 聚合、外部引用增删查、心跳绑定与唤醒、
|
||||||
|
worktree 快照归档/恢复、移交完成更新 worktree_host、
|
||||||
|
PR checks 补 head_sha。不跑真实模型、不联网、不调真实
|
||||||
|
git(快照测试用空 .git 目录,git 命令失败时仍能建快照,
|
||||||
|
只 branch/head 为空——这是诚实降级分支本身)。"""
|
||||||
|
|
||||||
|
import http.client
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import sqlite3
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import threading
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
try:
|
||||||
|
from cloud.auth import AuthStore
|
||||||
|
from cloud.server import create_server
|
||||||
|
from cloud.lifecycle import (add_ref, bind_automation,
|
||||||
|
create_snapshot, get_binding,
|
||||||
|
latest_snapshot, list_refs,
|
||||||
|
list_snapshots)
|
||||||
|
except ImportError:
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
||||||
|
from cloud.auth import AuthStore
|
||||||
|
from cloud.server import create_server
|
||||||
|
from cloud.lifecycle import (add_ref, bind_automation,
|
||||||
|
create_snapshot, get_binding,
|
||||||
|
latest_snapshot, list_refs,
|
||||||
|
list_snapshots)
|
||||||
|
|
||||||
|
|
||||||
|
class LifecyclePureTestCase(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.conn = sqlite3.connect(":memory:")
|
||||||
|
self.conn.row_factory = sqlite3.Row
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self.conn.close()
|
||||||
|
|
||||||
|
def test_snapshot_truncates_and_lists(self):
|
||||||
|
snap = create_snapshot(
|
||||||
|
self.conn, "t1", "p1", "/tmp/wt", "main", "abc",
|
||||||
|
"M a.py", "diff " * 10000, ["untracked.txt"])
|
||||||
|
self.assertLessEqual(len(snap["diff_text"]), 20000)
|
||||||
|
self.assertEqual(snap["untracked"], ["untracked.txt"])
|
||||||
|
self.assertIsNone(snap["restored_at"])
|
||||||
|
self.assertEqual(latest_snapshot(self.conn, "t1")["id"],
|
||||||
|
snap["id"])
|
||||||
|
self.assertEqual(len(list_snapshots(self.conn, "t1")), 1)
|
||||||
|
|
||||||
|
def test_ref_validation(self):
|
||||||
|
ref = add_ref(self.conn, "t1", "p1", "doc",
|
||||||
|
"https://example.invalid/spec.md", "规格", "u1")
|
||||||
|
self.assertEqual(ref["kind"], "doc")
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
add_ref(self.conn, "t1", "p1", "bogus", "x", None, "u1")
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
add_ref(self.conn, "t1", "p1", "doc", " ", None, "u1")
|
||||||
|
self.assertEqual(len(list_refs(self.conn, "t1")), 1)
|
||||||
|
|
||||||
|
def test_binding_mode_and_overwrite(self):
|
||||||
|
b = bind_automation(self.conn, "a1", "p1", "t1", "heartbeat")
|
||||||
|
self.assertEqual(b["mode"], "heartbeat")
|
||||||
|
b = bind_automation(self.conn, "a1", "p1", "t2", "cron")
|
||||||
|
self.assertEqual((b["task_id"], b["mode"]), ("t2", "cron"))
|
||||||
|
self.assertEqual(get_binding(self.conn, "a1")["task_id"], "t2")
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
bind_automation(self.conn, "a2", "p1", "t1", "weekly")
|
||||||
|
|
||||||
|
|
||||||
|
class LifecycleServerTestCase(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.tmp = tempfile.TemporaryDirectory()
|
||||||
|
self.db_path = os.path.join(self.tmp.name, "control.db")
|
||||||
|
self.auth = AuthStore(self.db_path)
|
||||||
|
self.owner_id, self.owner_key = self.auth.create_user("owner")
|
||||||
|
self.reader_id, self.reader_key = self.auth.create_user("reader")
|
||||||
|
self.outsider_id, self.outsider_key = self.auth.create_user(
|
||||||
|
"outsider")
|
||||||
|
self.server = create_server(self.db_path, "127.0.0.1", 0)
|
||||||
|
self.server.tenants_root = os.path.join(self.tmp.name, "tenants")
|
||||||
|
self.server.host_worktrees_dir = os.path.join(
|
||||||
|
self.tmp.name, "worktrees")
|
||||||
|
self.port = self.server.server_address[1]
|
||||||
|
self.thread = threading.Thread(
|
||||||
|
target=self.server.serve_forever, daemon=True)
|
||||||
|
self.thread.start()
|
||||||
|
status, self.project = self._req(
|
||||||
|
"POST", "/projects", {"name": "demo"}, self.owner_key)
|
||||||
|
self.assertEqual(status, 201, self.project)
|
||||||
|
self.auth.set_member(self.project["id"], self.owner_id,
|
||||||
|
self.reader_id, "viewer")
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self.server.shutdown()
|
||||||
|
self.server.server_close()
|
||||||
|
self.thread.join(timeout=5)
|
||||||
|
try:
|
||||||
|
self.auth.conn.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
self.tmp.cleanup()
|
||||||
|
|
||||||
|
def _req(self, method, path, body=None, key=None):
|
||||||
|
conn = http.client.HTTPConnection(
|
||||||
|
"127.0.0.1", self.port, timeout=10)
|
||||||
|
hdrs = {}
|
||||||
|
payload = None
|
||||||
|
if body is not None:
|
||||||
|
payload = json.dumps(body).encode("utf-8")
|
||||||
|
hdrs["Content-Type"] = "application/json"
|
||||||
|
if key is not None:
|
||||||
|
hdrs["X-API-Key"] = key
|
||||||
|
try:
|
||||||
|
conn.request(method, path, body=payload, headers=hdrs)
|
||||||
|
resp = conn.getresponse()
|
||||||
|
data = resp.read()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
try:
|
||||||
|
parsed = json.loads(data.decode("utf-8")) if data else None
|
||||||
|
except ValueError:
|
||||||
|
parsed = None
|
||||||
|
return resp.status, parsed
|
||||||
|
|
||||||
|
def _create_task(self, **extra):
|
||||||
|
body = {"project_id": self.project["id"], "prompt": "做事"}
|
||||||
|
body.update(extra)
|
||||||
|
status, task = self._req("POST", "/tasks", body, self.owner_key)
|
||||||
|
self.assertEqual(status, 201, task)
|
||||||
|
return task
|
||||||
|
|
||||||
|
def _set_task(self, task_id, **fields):
|
||||||
|
conn = sqlite3.connect(self.db_path)
|
||||||
|
try:
|
||||||
|
for key, value in fields.items():
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE tasks SET %s = ? WHERE id = ?" % key,
|
||||||
|
(value, task_id))
|
||||||
|
conn.commit()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def _worktree_dir(self, task_id):
|
||||||
|
# _thread_worktree_for_task 的算法:host_worktrees_dir +
|
||||||
|
# 安全化后的任务 id(本测试任务 id 只含 hex,无需转义)。
|
||||||
|
return os.path.join(self.server.host_worktrees_dir, task_id)
|
||||||
|
|
||||||
|
def test_bindings_aggregates(self):
|
||||||
|
task = self._create_task()
|
||||||
|
self._set_task(task["id"], pr_number=7,
|
||||||
|
pr_url="https://example.invalid/pr/7")
|
||||||
|
status, body = self._req(
|
||||||
|
"GET", "/tasks/%s/bindings" % task["id"], None,
|
||||||
|
self.reader_key)
|
||||||
|
self.assertEqual(status, 200, body)
|
||||||
|
self.assertEqual(body["project"]["name"], "demo")
|
||||||
|
self.assertEqual(body["pull_request"]["number"], 7)
|
||||||
|
self.assertEqual(body["worktree"]["status"], "creating")
|
||||||
|
self.assertTrue(body["host"]["local"])
|
||||||
|
self.assertEqual(body["lineage"]["parent_task_id"], None)
|
||||||
|
self.assertEqual(body["attachments"]["files"], [])
|
||||||
|
status, _ = self._req(
|
||||||
|
"GET", "/tasks/%s/bindings" % task["id"], None,
|
||||||
|
self.outsider_key)
|
||||||
|
self.assertEqual(status, 403)
|
||||||
|
|
||||||
|
def test_refs_crud(self):
|
||||||
|
task = self._create_task()
|
||||||
|
status, ref = self._req(
|
||||||
|
"POST", "/tasks/%s/refs" % task["id"],
|
||||||
|
{"kind": "doc", "ref": "https://example.invalid/spec.md",
|
||||||
|
"note": "规格"}, self.owner_key)
|
||||||
|
self.assertEqual(status, 201, ref)
|
||||||
|
status, body = self._req(
|
||||||
|
"POST", "/tasks/%s/refs" % task["id"],
|
||||||
|
{"kind": "bogus", "ref": "x"}, self.owner_key)
|
||||||
|
self.assertEqual(status, 400, body)
|
||||||
|
status, body = self._req(
|
||||||
|
"GET", "/tasks/%s/refs" % task["id"], None, self.reader_key)
|
||||||
|
self.assertEqual(len(body["refs"]), 1)
|
||||||
|
status, body = self._req(
|
||||||
|
"DELETE", "/tasks/%s/refs/%s" % (task["id"], ref["id"]),
|
||||||
|
None, self.owner_key)
|
||||||
|
self.assertEqual(status, 200, body)
|
||||||
|
status, body = self._req(
|
||||||
|
"DELETE", "/tasks/%s/refs/%s" % (task["id"], ref["id"]),
|
||||||
|
None, self.owner_key)
|
||||||
|
self.assertEqual(status, 404, body)
|
||||||
|
|
||||||
|
def test_worktree_snapshot_and_restore(self):
|
||||||
|
task = self._create_task()
|
||||||
|
wt = self._worktree_dir(task["id"])
|
||||||
|
os.makedirs(os.path.join(wt, ".git"), exist_ok=True)
|
||||||
|
status, body = self._req(
|
||||||
|
"POST", "/tasks/%s/worktree/archive" % task["id"],
|
||||||
|
{}, self.owner_key)
|
||||||
|
self.assertEqual(status, 201, body)
|
||||||
|
self.assertEqual(body["untracked_count"], 0)
|
||||||
|
status, body = self._req(
|
||||||
|
"GET", "/tasks/%s/worktree/snapshots" % task["id"],
|
||||||
|
None, self.reader_key)
|
||||||
|
self.assertEqual(len(body["snapshots"]), 1)
|
||||||
|
status, body = self._req(
|
||||||
|
"POST", "/tasks/%s/worktree/restore" % task["id"],
|
||||||
|
{}, self.owner_key)
|
||||||
|
self.assertEqual(status, 200, body)
|
||||||
|
self.assertIsNotNone(body["snapshot"]["restored_at"])
|
||||||
|
# 目录删了之后再恢复:409 并附快照,不编假恢复。
|
||||||
|
shutil.rmtree(wt)
|
||||||
|
status, body = self._req(
|
||||||
|
"POST", "/tasks/%s/worktree/archive" % task["id"],
|
||||||
|
{}, self.owner_key)
|
||||||
|
self.assertEqual(status, 409, body)
|
||||||
|
# 先再拍一张是不可能的(目录已删),直接用旧快照恢复:
|
||||||
|
status, body = self._req(
|
||||||
|
"POST", "/tasks/%s/worktree/restore" % task["id"],
|
||||||
|
{}, self.owner_key)
|
||||||
|
self.assertEqual(status, 409, body)
|
||||||
|
self.assertIn("snapshot", body)
|
||||||
|
|
||||||
|
def test_handoff_completed_moves_host(self):
|
||||||
|
task = self._create_task()
|
||||||
|
status, h = self._req(
|
||||||
|
"POST", "/tasks/%s/handoff" % task["id"],
|
||||||
|
{"target_host": "host-b"}, self.owner_key)
|
||||||
|
self.assertEqual(status, 201, h)
|
||||||
|
for st in ("running", "completed"):
|
||||||
|
status, body = self._req(
|
||||||
|
"POST", "/tasks/%s/handoff/%s/report"
|
||||||
|
% (task["id"], h["id"]),
|
||||||
|
{"status": st}, self.owner_key)
|
||||||
|
self.assertEqual(status, 200, body)
|
||||||
|
status, task_after = self._req(
|
||||||
|
"GET", "/tasks/%s" % task["id"], None, self.owner_key)
|
||||||
|
self.assertEqual(task_after["worktree_host"], "host-b")
|
||||||
|
|
||||||
|
def test_automation_heartbeat_binding(self):
|
||||||
|
status, auto = self._req(
|
||||||
|
"POST", "/projects/%s/automations" % self.project["id"],
|
||||||
|
{"name": "watch", "prompt": "检查部署健康",
|
||||||
|
"interval_seconds": 3600}, self.owner_key)
|
||||||
|
self.assertEqual(status, 201, auto)
|
||||||
|
aid = auto["id"]
|
||||||
|
task = self._create_task()
|
||||||
|
self._set_task(task["id"], status="running")
|
||||||
|
# 未绑定时心跳 409。
|
||||||
|
status, body = self._req(
|
||||||
|
"POST", "/projects/%s/automations/%s/heartbeat"
|
||||||
|
% (self.project["id"], aid),
|
||||||
|
{"message": "查一下"}, self.owner_key)
|
||||||
|
self.assertEqual(status, 409, body)
|
||||||
|
status, body = self._req(
|
||||||
|
"POST", "/projects/%s/automations/%s/bind"
|
||||||
|
% (self.project["id"], aid),
|
||||||
|
{"task_id": task["id"], "mode": "heartbeat"},
|
||||||
|
self.owner_key)
|
||||||
|
self.assertEqual(status, 200, body)
|
||||||
|
self.assertEqual(body["binding"]["task_id"], task["id"])
|
||||||
|
status, body = self._req(
|
||||||
|
"GET", "/projects/%s/automations/%s" % (self.project["id"],
|
||||||
|
aid),
|
||||||
|
None, self.reader_key)
|
||||||
|
self.assertEqual(body["heartbeat_binding"]["mode"], "heartbeat")
|
||||||
|
status, body = self._req(
|
||||||
|
"POST", "/projects/%s/automations/%s/heartbeat"
|
||||||
|
% (self.project["id"], aid),
|
||||||
|
{"message": "检查部署健康并汇报"}, self.owner_key)
|
||||||
|
self.assertEqual(status, 201, body)
|
||||||
|
self.assertEqual(body["delivery"], "queued")
|
||||||
|
# cron 绑定不唤醒线程。
|
||||||
|
status, body = self._req(
|
||||||
|
"POST", "/projects/%s/automations/%s/bind"
|
||||||
|
% (self.project["id"], aid),
|
||||||
|
{"task_id": task["id"], "mode": "cron"}, self.owner_key)
|
||||||
|
self.assertEqual(status, 200, body)
|
||||||
|
status, body = self._req(
|
||||||
|
"POST", "/projects/%s/automations/%s/heartbeat"
|
||||||
|
% (self.project["id"], aid),
|
||||||
|
{}, self.owner_key)
|
||||||
|
self.assertEqual(status, 409, body)
|
||||||
|
status, body = self._req(
|
||||||
|
"DELETE", "/projects/%s/automations/%s/bind"
|
||||||
|
% (self.project["id"], aid), None, self.owner_key)
|
||||||
|
self.assertEqual(status, 200, body)
|
||||||
|
status, body = self._req(
|
||||||
|
"GET", "/projects/%s/automations/%s/bind"
|
||||||
|
% (self.project["id"], aid), None, self.reader_key)
|
||||||
|
self.assertIsNone(body["binding"])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
# 与 Codex app 的功能对齐:15 项逐项对照
|
||||||
|
|
||||||
|
本批(feat-thread-lifecycle)之前已有 #103 的 8 项
|
||||||
|
(wait/read/message/handoff/worktree/pr-checks/automation/
|
||||||
|
sleep,见 docs/thread-wait-ops.md)。本文件逐项说明 15 项
|
||||||
|
在控制面的现状:已实现 / 本批补齐 / 待合 PR / 未实现。
|
||||||
|
“已实现”指端点真实存在且有单测,不是文档宣称。
|
||||||
|
|
||||||
|
## 1. Agent 线程管理
|
||||||
|
|
||||||
|
| 能力 | 落点 | 状态 |
|
||||||
|
|---|---|---|
|
||||||
|
| 创建独立线程 | POST /tasks | 已实现 |
|
||||||
|
| 从当前线程分叉,保留上下文 | POST /tasks/{id}/fork(终态任务;worker 调 host.py fork,分叉 thread 历史 + 拷 worktree 文件) | 已实现 |
|
||||||
|
| 继续执行暂停/空闲线程 | POST /tasks/{id}/continue(终态复活);POST /tasks/{id}/thread/message(终态 CAS 复活、进行中写后续) | 已实现 |
|
||||||
|
| 向其他线程发后续指令 | POST /tasks/{id}/thread/message;跨任务 POST /tasks/{id}/messages(peer,跨项目拒绝) | 已实现 |
|
||||||
|
| 读取状态、最近结果、历史摘要 | GET /tasks/{id}/thread/read;GET /tasks/{id}/thread?include_turns=1 | 已实现(#103) |
|
||||||
|
| 设置线程标题 | POST /tasks/{id}/thread/rename {name}(终态任务 + 本机线程) | 已实现 |
|
||||||
|
| 归档和恢复线程 | POST /tasks/{id}/thread/archive、/thread/unarchive(终态任务 + 本机线程,经 host.py CLI) | 已实现 |
|
||||||
|
| 查看主机、项目、工作目录 | GET /tasks/{id}/bindings(本批) | 本批补齐 |
|
||||||
|
|
||||||
|
注意:rename/archive/unarchive 走 `_task_thread_mutate`,
|
||||||
|
只允许终态任务且本机线程;docker 任务、进行中任务会
|
||||||
|
409/守卫拦截——这是线程正在变、快照不可信的保护,
|
||||||
|
不是缺口。归档只归档线程本身,worktree 快照见第 5 项。
|
||||||
|
|
||||||
|
## 2. 并行任务等待
|
||||||
|
|
||||||
|
wait_threads = POST /tasks/{id}/wait(#103):最多 8 个、
|
||||||
|
首个重要变化返回(完成/失败/需输入/需审批/需处理)、
|
||||||
|
超时回进度、afterCursor 只取新变化。事件等待机制,
|
||||||
|
不是固定间隔定时器;变化检测只读数据库(任务行、
|
||||||
|
审批表、后续表),不轮询 trace。
|
||||||
|
|
||||||
|
## 3. 线程状态读取
|
||||||
|
|
||||||
|
read_thread = GET /tasks/{id}/thread/read(#103):状态映射、
|
||||||
|
最近活动、改动文件、命令与退出码、待审批/待后续、
|
||||||
|
attention 汇总。本批补了一个小缺口:tool 条目原来只取
|
||||||
|
tool 名,丢了 server 与 status,现在拼成
|
||||||
|
`server/tool: status` 一行。`test_results` 仍为 null:
|
||||||
|
trace 里没有独立测试结果记录,不编“测试通过”。
|
||||||
|
|
||||||
|
## 4. 任务移交和远程主机切换
|
||||||
|
|
||||||
|
- 发起移交:POST /tasks/{id}/handoff {target_host}(#103)
|
||||||
|
- 查询进度/是否成功:GET /tasks/{id}/handoff(含 arrived)
|
||||||
|
- 对方主机上报:POST /tasks/{id}/handoff/{hid}/report
|
||||||
|
- 移交后继续发指令:POST /tasks/{id}/thread/message(与
|
||||||
|
主机无关,认领方按新主机判断)
|
||||||
|
- 本批补齐的落点:report 到 completed 时,任务的
|
||||||
|
worktree_host 改成目标主机——之前上报只改移交行,
|
||||||
|
任务行还指着旧主机,后续 /worktree、/pr/checks 会
|
||||||
|
按旧主机判断,这是真缺口,现已补上。
|
||||||
|
- 未实现:服务端不执行 SSH 迁移、不搬文件;迁移本身
|
||||||
|
由目标主机 worker 执行并回报(见 thread-wait-ops.md)。
|
||||||
|
|
||||||
|
## 5. Git Worktree 生命周期管理
|
||||||
|
|
||||||
|
| 能力 | 现状 |
|
||||||
|
|---|---|
|
||||||
|
| 创建独立 worktree | 已实现:worker 认领后同步建(host.py make_worktree,一任务一分支 host/<task>,非 git 仓库退化为目录拷贝) |
|
||||||
|
| 从指定 commit/分支/ref 创建 | 部分:任务绑 environment 时按 environment.branch 检出(environment 本身是异步创建过程,creating→ready/failed);任务直接建时一律从 HEAD 建,不接受 commit/ref 参数 |
|
||||||
|
| 将已有 worktree 附加到任务 | 未实现:worker 派发路径按任务 id 确定 worktree 目录,附加需要改派发,不在本批编一个只记关联、不被执行的“附加” |
|
||||||
|
| 查看创建进度 | GET /tasks/{id}/worktree(派生状态;创建是同步的,没有阶段流,不编百分比) |
|
||||||
|
| 恢复已归档 worktree | 本批:POST /tasks/{id}/worktree/restore(见下) |
|
||||||
|
| 归档不再需要的 worktree | 本批:POST /tasks/{id}/worktree/archive 拍快照(见下);真删目录仍是 merge/discard 的职责 |
|
||||||
|
| 保存未提交修改、未推送 commit、非忽略文件 | 本批快照记:branch、head_sha(即未推送 commit 的位置)、git status、git diff、untracked 路径清单。诚实边界:untracked 文件只记路径不拷内容,diff 只覆盖已跟踪文件;快照是证据,不是可一键还原的备份 |
|
||||||
|
| 保留 worktree 与线程的关联 | 已实现:快照行含 task_id + worktree_path;bindings 里 worktree 与任务同表返回 |
|
||||||
|
|
||||||
|
Restore 语义(不编造):目录还在时只把快照标 restored
|
||||||
|
(目录没动过,谈不上还原);目录已删时 409 并附快照
|
||||||
|
(重建需要仓库路径,服务端没有)。
|
||||||
|
|
||||||
|
## 6. 任务与 Git 状态绑定
|
||||||
|
|
||||||
|
本批 GET /tasks/{id}/bindings 一次返回:项目(名、
|
||||||
|
repo_url)、环境(名、分支、执行器、状态)、主机
|
||||||
|
(worktree_host、local/remote、本机名)、worktree
|
||||||
|
(状态、路径、分支)、分支、PR(url/number)、执行器
|
||||||
|
(任务值或全局缺省)、附件(文件 + 外部引用)、
|
||||||
|
worktree 快照清单、血缘(parent_task_id、forked_from、
|
||||||
|
coordinator_part)。之前这些字段散在任务行、项目行、
|
||||||
|
环境行、worktree 端点里,没有一张总表。
|
||||||
|
|
||||||
|
## 7. Pull Request 和 CI 诊断
|
||||||
|
|
||||||
|
read_pull_request_check_diagnostics =
|
||||||
|
GET /tasks/{id}/pr/checks(#103):checks JSON(workflow、
|
||||||
|
link、description)、失败 run 的 check-runs 日志原文
|
||||||
|
(截 4000 字)与行号注释(上限 20 条)。本批只补一个
|
||||||
|
字段:响应里加 head_sha(PR 当前 commit,之前内部
|
||||||
|
算出来只用于查 check-runs,没返回)。只读:不重跑、
|
||||||
|
不批准、不合并。外部 CI 状态即 check-runs 的
|
||||||
|
conclusion;构建/测试失败日志即 output.text。
|
||||||
|
|
||||||
|
## 8. 自动化、心跳和定时任务
|
||||||
|
|
||||||
|
| 能力 | 现状 |
|
||||||
|
|---|---|
|
||||||
|
| 创建/查看/修改/删除长期自动化 | POST/GET/PATCH /projects/{id}/automations、cancel(#103 前已有 + #103 的 update) |
|
||||||
|
| 按时间周期运行 | 已实现:interval_seconds(60 秒–30 天),到期判断 last_run + interval |
|
||||||
|
| 定期唤醒已有线程(heartbeat) | 本批:POST .../automations/{aid}/bind {task_id, mode: heartbeat} + POST .../automations/{aid}/heartbeat {message?}(缺省用自动化 prompt);投递走 thread/message 同款路(终态复活、进行中写后续、配额门同款) |
|
||||||
|
| cron(到期独立跑新任务) | 绑定 mode=cron 只作标记;heartbeat 口对 cron 绑定回 409(cron 不唤醒线程) |
|
||||||
|
| 复用已有对话上下文 | heartbeat 唤醒的是同一线程,上下文自然复用;cron 新任务不继承历史(与 fork 的语义一致) |
|
||||||
|
| 继续执行多日/多周任务 | 机制上可以(interval 上限 30 天 + 绑定线程长期存在),未做跨周的专项验证 |
|
||||||
|
| 只在变化/完成/失败/需处理时通知,状态不变保持安静 | 未实现:没有“与上次产出比对、无变化静默”的抑制逻辑;每次运行终态仍按现有推送通道通知 |
|
||||||
|
| 到期触发器 | 未实现(服务端本身不起定时器):触发需外部调 /heartbeat,或等 PR #101 的 run_due_automation 落地;本批只给了绑定与唤醒口,没宣称“已自动跑” |
|
||||||
|
|
||||||
|
## 9. 线程归档和恢复
|
||||||
|
|
||||||
|
线程归档/恢复见第 1 项(host.py CLI 透传)。归档时
|
||||||
|
保留的东西分两处:对话记录与线程状态在 codex-home
|
||||||
|
的线程存档里;worktree 快照在本批的
|
||||||
|
task_worktree_snapshots 里;PR 附件在任务行的
|
||||||
|
pr_url/pr_number 与 bindings 里;未提交文件快照即
|
||||||
|
第 5 项的快照(status/diff/untracked 路径)。长期
|
||||||
|
项目、阶段性任务、暂时暂停的 Agent 走“归档线程 +
|
||||||
|
拍 worktree 快照”两步,不是同一个端点。
|
||||||
|
|
||||||
|
## 10. 任务附件和交付物管理
|
||||||
|
|
||||||
|
- 文件:已实现——task_attachments(上传包绑定,任务行
|
||||||
|
attachments 字段,fork 时带走清单)。
|
||||||
|
- PR、worktree:已实现——任务行 pr_url/pr_number、
|
||||||
|
worktree 路径,bindings 同表返回。
|
||||||
|
- 生成的文档、构建结果、外部服务结果引用:本批——
|
||||||
|
POST/GET/DELETE /tasks/{id}/refs(kind: doc/build/
|
||||||
|
external/pr/file,只记引用不存内容、不校验链接可达)。
|
||||||
|
- 移除:refs 可 DELETE;文件附件的移除走上传包的
|
||||||
|
used 状态与任务删除,不在本批另开删除口。
|
||||||
|
|
||||||
|
## 11. 项目和主机上下文
|
||||||
|
|
||||||
|
GET /tasks/{id}/bindings 即上下文总表(项目、仓库、
|
||||||
|
主机、本地/远程、工作目录、默认执行器、环境、归属)。
|
||||||
|
项目默认执行器:任务 executor 为空时取全局缺省
|
||||||
|
(_default_executor),没有“项目级默认执行器”字段——
|
||||||
|
环境行(executor)是更细的默认,任务绑 environment
|
||||||
|
时以环境为准。
|
||||||
|
|
||||||
|
## 12. 插件、MCP 和外部连接器
|
||||||
|
|
||||||
|
已实现(经 host.py CLI 透传,项目 scope):
|
||||||
|
|
||||||
|
- GET /projects/{id}/skills(skills/list)
|
||||||
|
- GET /projects/{id}/apps(app/list)
|
||||||
|
- GET /projects/{id}/mcp-servers(mcpServerStatus/list)
|
||||||
|
- POST /projects/{id}/mcp-servers/reload
|
||||||
|
- POST /projects/{id}/mcp-servers/{name}/oauth-login
|
||||||
|
|
||||||
|
限制与官方一致:连接受账户、工作区、管理员权限、
|
||||||
|
插件安装状态限制;oauth-login 只是发起流程,真正
|
||||||
|
授权在宿主侧完成;容器内视图与宿主侧视图可能不同
|
||||||
|
(skills/MCP 端点 docstring 已注明)。GitHub、Slack
|
||||||
|
等具体外部系统在本系统是另外的连接器(GitHub App、
|
||||||
|
Slack webhook),不是 MCP 一个口。
|
||||||
|
|
||||||
|
## 13. Memory 和长期上下文
|
||||||
|
|
||||||
|
分两层,不要混淆:
|
||||||
|
|
||||||
|
- 已实现(本系统自有):KB(/projects/{id}/kb)与
|
||||||
|
任务 summaries——按任务/项目存的背景,不是跨任务
|
||||||
|
的用户长期记忆。
|
||||||
|
- 待合 PR:PR #102(feat-cloud-memory-identity,
|
||||||
|
user_profiles + identity_memories,用户级仅本人、
|
||||||
|
项目级项目成员可见,上下文各取最新 5 条)——
|
||||||
|
这才是官方 Memory 的对位。本批不重复实现,避免
|
||||||
|
与 #102 合并冲突;自动注入任务/thread 是 #102
|
||||||
|
之后仍待显式决策的下一步(注入点、去重、成本上限)。
|
||||||
|
- 可用性限制与官方一致:记忆的范围受账户与发布状态
|
||||||
|
影响,本系统里受项目成员权限限制。
|
||||||
|
|
||||||
|
## 14. 主动续作和建议性任务
|
||||||
|
|
||||||
|
未实现为独立服务。现有拼图:自动化(到期唤醒/独立
|
||||||
|
跑)+ triage(/projects/{id}/triage,自动化到期与
|
||||||
|
待处理汇总)+ #102 的记忆 + bindings 的 PR/任务状态。
|
||||||
|
“根据未完成任务、关联 PR、插件内容、Memory、项目
|
||||||
|
上下文建议下一步”需要一个建议器(读上述输入、产出
|
||||||
|
建议任务),不在本批范围;本批的 heartbeat 与
|
||||||
|
bindings 是给建议器准备的输入口与执行口,不是建议器
|
||||||
|
本身。不要把 triage 的到期计数说成“已会建议”。
|
||||||
|
|
||||||
|
## 15. 使用量和连接状态管理
|
||||||
|
|
||||||
|
已实现(不新开端点,本批只做对照):
|
||||||
|
|
||||||
|
- 账户使用量:GET /users/me/quota、GET /tasks/{id}/cost
|
||||||
|
- 限制窗口剩余:quota 的美元配额与无价 token cap
|
||||||
|
- 连接的主机:GET /workers(心跳与主机名)
|
||||||
|
- 远程主机是否可用:worktree_host + /workers 心跳龄
|
||||||
|
(_workers_heartbeat)+ /health(db_ok、worker_ok)
|
||||||
|
- App 更新状态:/health 的 commit 字段(部署提交)
|
||||||
|
- 已安装插件和连接状态:第 12 项的 skills/mcp-servers
|
||||||
|
- 项目和线程的运行环境:第 6 项的 bindings
|
||||||
|
|
||||||
|
这些用于任务调度与故障判断,不直接跑任务。
|
||||||
|
|
||||||
|
## 本批新增端点一览
|
||||||
|
|
||||||
|
- GET /tasks/{id}/bindings
|
||||||
|
- POST /tasks/{id}/refs、GET /tasks/{id}/refs、
|
||||||
|
DELETE /tasks/{id}/refs/{ref_id}
|
||||||
|
- POST /tasks/{id}/worktree/archive、
|
||||||
|
GET /tasks/{id}/worktree/snapshots、
|
||||||
|
POST /tasks/{id}/worktree/restore
|
||||||
|
- GET/POST/DELETE /projects/{id}/automations/{aid}/bind
|
||||||
|
- POST /projects/{id}/automations/{aid}/heartbeat
|
||||||
|
- GET /projects/{id}/automations/{aid} 响应加
|
||||||
|
heartbeat_binding 字段
|
||||||
|
- GET /tasks/{id}/pr/checks 响应加 head_sha 字段
|
||||||
|
- 移交 completed 上报后任务 worktree_host 跟随切换
|
||||||
Reference in new issue
Block a user