Merge pull request #179 from openqj/feat-goal-convert-port
goal-convert / automation-create 移植:/goal 转换链 + 会话内标记工具
This commit is contained in:
10 files changed
+1731
-63
No files matched your search
@@ -532,6 +532,42 @@ goal 模式的继续:server 把任务转成新 goal 语义(goal=新消息、
|
|||||||
合并任务 worktree 已删、请分支(merged 409);冲突与新建一致
|
合并任务 worktree 已删、请分支(merged 409);冲突与新建一致
|
||||||
(plan + approval=full、plan + goal 各 400)。
|
(plan + approval=full、plan + goal 各 400)。
|
||||||
|
|
||||||
|
## 主线 /goal 命令(发给主线一句话就自动建 goal)
|
||||||
|
|
||||||
|
不用先知道主线是进行中还是已终态,也不用勾选项:给主线发
|
||||||
|
一条含触发词的消息即可,例如“请在这条主线开启一个
|
||||||
|
持久 /goal,目标是收xxx”,或直接写“/goal 收xxx”。斜杠
|
||||||
|
不用:单发 `goal` 也同款触发,例如“goal 收xxx”、
|
||||||
|
“goal,目标是收xxx”。解析在
|
||||||
|
`cloud/goal_command.py`(server 与 worker 共用同一份,防两端
|
||||||
|
判断不一致);只是在讨论里提到“/goal 的用法”“goal 是什么”
|
||||||
|
这种不算命令。
|
||||||
|
新建目标也同款:`POST /tasks` 的 prompt 里直接写 `/goal
|
||||||
|
收xxx`(或上面那句整话)即新建 goal 任务,prompt 与 goal
|
||||||
|
都存解析出的目标,不用勾 goal 选项;空目标、plan 冲突、
|
||||||
|
带验收命令各 400。入口三处同款:`POST /tasks/{id}/followups`、
|
||||||
|
`POST /tasks/{id}/thread/message`、`POST /tasks/{id}/continue`
|
||||||
|
(message 字段);响应里 `delivery` 说明实际走了哪条路:
|
||||||
|
queued 主线原地转 goal(`goal_converted`)、已终态复活并转
|
||||||
|
goal(`goal_revived`)、running 主线先排队(`goal_queued`,
|
||||||
|
本轮不打断,worker 在本轮完成、排空到这条时把主线转成 goal
|
||||||
|
并回 queued,下次认领走 goal-resume 在同一 thread 上开新
|
||||||
|
目标循环;本轮若失败,这条留在 pending,等下次成功轮次再转)。
|
||||||
|
拒绝口径:已是 goal 任务 409(不重复创建)、plan 任务 400、
|
||||||
|
merged 409、空目标 400。goal 语义与新建 goal 模式一致:
|
||||||
|
不限轮次、模型宣告“目标已达成”才结束,宣告后即终态,不
|
||||||
|
是永不结束的常驻采集——需要继续收时再发一次 /goal 续开
|
||||||
|
新循环。
|
||||||
|
|
||||||
|
不靠关键词的另一条路(LLM 自己判断):会话里的 agent
|
||||||
|
也可以在回合输出里写 `goal-convert` 块把本会话转成 goal,
|
||||||
|
或写 `automation-create` 块新建自动化定义(首次运行时刻
|
||||||
|
`first_run_at`、只跑一次 `once`、绑回本会话 `bind:
|
||||||
|
"heartbeat"`),worker 在轮次边界执行,格式与口径见
|
||||||
|
`docs/marker-tools.md` 新增的两节。本批刻意不向 LLM 自动
|
||||||
|
注入这两个工具的指南(避免 prompt 臃肿),需要时由用户在
|
||||||
|
会话里手动告诉 agent;服务端不按关键词再猜意图。
|
||||||
|
|
||||||
## 任务间互发消息(peer messages,Muse 式通用互发)
|
## 任务间互发消息(peer messages,Muse 式通用互发)
|
||||||
|
|
||||||
任意两个同项目任务可以互发消息、对方做完可以回消息,不
|
任意两个同项目任务可以互发消息、对方做完可以回消息,不
|
||||||
|
|||||||
+93
-36
@@ -72,7 +72,8 @@ def row_get(row, name):
|
|||||||
AUTOMATION_COLUMNS = (
|
AUTOMATION_COLUMNS = (
|
||||||
"id", "project_id", "name", "prompt", "repo_url",
|
"id", "project_id", "name", "prompt", "repo_url",
|
||||||
"interval_seconds", "status", "last_run_at", "last_claimed_at",
|
"interval_seconds", "status", "last_run_at", "last_claimed_at",
|
||||||
"created_by", "created_at", "cancelled_at")
|
"created_by", "created_at", "cancelled_at",
|
||||||
|
"first_run_at", "run_once")
|
||||||
RUN_COLUMNS = (
|
RUN_COLUMNS = (
|
||||||
"id", "automation_id", "project_id", "task_id", "status",
|
"id", "automation_id", "project_id", "task_id", "status",
|
||||||
"detail", "created_at")
|
"detail", "created_at")
|
||||||
@@ -122,7 +123,9 @@ def ensure_automations_tables(conn):
|
|||||||
"last_claimed_at DOUBLE PRECISION, "
|
"last_claimed_at DOUBLE PRECISION, "
|
||||||
"created_by TEXT, "
|
"created_by TEXT, "
|
||||||
"created_at DOUBLE PRECISION, "
|
"created_at DOUBLE PRECISION, "
|
||||||
"cancelled_at DOUBLE PRECISION)"
|
"cancelled_at DOUBLE PRECISION, "
|
||||||
|
"first_run_at DOUBLE PRECISION, "
|
||||||
|
"run_once INTEGER)"
|
||||||
)
|
)
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"CREATE TABLE IF NOT EXISTS automation_runs("
|
"CREATE TABLE IF NOT EXISTS automation_runs("
|
||||||
@@ -138,7 +141,9 @@ def ensure_automations_tables(conn):
|
|||||||
for col, ddl in (
|
for col, ddl in (
|
||||||
("repo_url", "TEXT"),
|
("repo_url", "TEXT"),
|
||||||
("last_claimed_at", "DOUBLE PRECISION"),
|
("last_claimed_at", "DOUBLE PRECISION"),
|
||||||
("cancelled_at", "DOUBLE PRECISION")):
|
("cancelled_at", "DOUBLE PRECISION"),
|
||||||
|
("first_run_at", "DOUBLE PRECISION"),
|
||||||
|
("run_once", "INTEGER")):
|
||||||
if col not in existing:
|
if col not in existing:
|
||||||
try:
|
try:
|
||||||
conn.execute(
|
conn.execute(
|
||||||
@@ -169,23 +174,40 @@ def ensure_automations_tables(conn):
|
|||||||
|
|
||||||
|
|
||||||
def next_run_at(auto, now=None):
|
def next_run_at(auto, now=None):
|
||||||
"""下次到期时刻:从未跑过时就是创建时间(建完即到期,
|
"""下次到期时刻:
|
||||||
执行器下一轮即可认领);跑过时为上次执行 + 间隔。"""
|
- 只跑一次且已跑过:None(不再到期,不是“下次”);
|
||||||
|
- 从未跑过且给了 first_run_at:就是该时刻(“明天 9 点”
|
||||||
|
的首次运行就靠它,而不是建完即到期);
|
||||||
|
- 从未跑过且没给 first_run_at:创建时间(建完即到期,
|
||||||
|
执行器下一轮即可认领,沿用旧语义);
|
||||||
|
- 跑过:上次执行 + 间隔(first_run_at 只管首次,之后
|
||||||
|
不再看它)。"""
|
||||||
last = row_get(auto, "last_run_at")
|
last = row_get(auto, "last_run_at")
|
||||||
interval = row_get(auto, "interval_seconds") or 0
|
if row_get(auto, "run_once") and last is not None:
|
||||||
|
return None
|
||||||
if last is None:
|
if last is None:
|
||||||
|
first = row_get(auto, "first_run_at")
|
||||||
|
if first is not None:
|
||||||
|
return first
|
||||||
return row_get(auto, "created_at")
|
return row_get(auto, "created_at")
|
||||||
|
interval = row_get(auto, "interval_seconds") or 0
|
||||||
return last + interval
|
return last + interval
|
||||||
|
|
||||||
|
|
||||||
def is_due(auto, now=None):
|
def is_due(auto, now=None):
|
||||||
"""是否到期:仅 active 的定义可到期;now 缺省取当前时间。
|
"""是否到期:仅 active 的定义可到期;now 缺省取当前时间。
|
||||||
到期边界用 >=,与 host run-due 的 now - last_run >=
|
到期边界用 >=,与 host run-due 的 now - last_run >=
|
||||||
interval 同款(含边界那一秒)。"""
|
interval 同款(含边界那一秒)。只跑一次且已跑过时永远
|
||||||
|
不到期(先于 next_run_at 判断);next_run_at 为 None
|
||||||
|
的其它情形(行缺创建时间等脏数据)仍按旧代码认到期,
|
||||||
|
不静默改口径。"""
|
||||||
if row_get(auto, "status") != ACTIVE:
|
if row_get(auto, "status") != ACTIVE:
|
||||||
return False
|
return False
|
||||||
if now is None:
|
if now is None:
|
||||||
now = time.time()
|
now = time.time()
|
||||||
|
if row_get(auto, "run_once") and row_get(auto, "last_run_at") \
|
||||||
|
is not None:
|
||||||
|
return False
|
||||||
nxt = next_run_at(auto)
|
nxt = next_run_at(auto)
|
||||||
if nxt is None:
|
if nxt is None:
|
||||||
return True
|
return True
|
||||||
@@ -208,12 +230,15 @@ def automation_to_dict(row, now=None):
|
|||||||
"created_by": row["created_by"],
|
"created_by": row["created_by"],
|
||||||
"created_at": row["created_at"],
|
"created_at": row["created_at"],
|
||||||
"cancelled_at": row_get(row, "cancelled_at"),
|
"cancelled_at": row_get(row, "cancelled_at"),
|
||||||
|
"first_run_at": row_get(row, "first_run_at"),
|
||||||
|
"run_once": bool(row_get(row, "run_once")),
|
||||||
"next_run_at": nxt,
|
"next_run_at": nxt,
|
||||||
"due": is_due(row, now),
|
"due": is_due(row, now),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _validate_fields(name, prompt, interval_seconds, repo_url):
|
def _validate_fields(name, prompt, interval_seconds, repo_url,
|
||||||
|
first_run_at=None, run_once=False):
|
||||||
if not name or not isinstance(name, str) or not name.strip():
|
if not name or not isinstance(name, str) or not name.strip():
|
||||||
raise ValueError("name required")
|
raise ValueError("name required")
|
||||||
name = name.strip()
|
name = name.strip()
|
||||||
@@ -238,16 +263,31 @@ def _validate_fields(name, prompt, interval_seconds, repo_url):
|
|||||||
repo_url = repo_url.strip() or None
|
repo_url = repo_url.strip() or None
|
||||||
if repo_url is not None and len(repo_url) > MAX_REPO_URL_LEN:
|
if repo_url is not None and len(repo_url) > MAX_REPO_URL_LEN:
|
||||||
raise ValueError("repo_url too long")
|
raise ValueError("repo_url too long")
|
||||||
return name, prompt, interval_seconds, repo_url
|
if first_run_at is not None:
|
||||||
|
if isinstance(first_run_at, bool) or \
|
||||||
|
not isinstance(first_run_at, (int, float)):
|
||||||
|
raise ValueError("first_run_at must be a timestamp")
|
||||||
|
first_run_at = float(first_run_at)
|
||||||
|
if first_run_at <= 0:
|
||||||
|
raise ValueError("first_run_at must be positive")
|
||||||
|
if not isinstance(run_once, bool):
|
||||||
|
raise ValueError("run_once must be a boolean")
|
||||||
|
return name, prompt, interval_seconds, repo_url, \
|
||||||
|
first_run_at, run_once
|
||||||
|
|
||||||
|
|
||||||
def create_automation(conn, project_id, name, prompt, created_by,
|
def create_automation(conn, project_id, name, prompt, created_by,
|
||||||
interval_seconds=86400, repo_url=None, now=None):
|
interval_seconds=86400, repo_url=None, now=None,
|
||||||
|
first_run_at=None, run_once=False):
|
||||||
"""创建自动化定义:同项目内 active 的名字唯一(取消过的
|
"""创建自动化定义:同项目内 active 的名字唯一(取消过的
|
||||||
名字可重用,与环境名唯一规则同款语义)。成功时 commit
|
名字可重用,与环境名唯一规则同款语义)。成功时 commit
|
||||||
并返回 dict;校验失败抛 ValueError(调用方转 400)。"""
|
并返回 dict;校验失败抛 ValueError(调用方转 400)。
|
||||||
name, prompt, interval_seconds, repo_url = _validate_fields(
|
first_run_at 为首次运行时刻(epoch 秒,过去则建完即
|
||||||
name, prompt, interval_seconds, repo_url)
|
到期);run_once 为 True 时跑过一次后不再到期。"""
|
||||||
|
(name, prompt, interval_seconds, repo_url,
|
||||||
|
first_run_at, run_once) = _validate_fields(
|
||||||
|
name, prompt, interval_seconds, repo_url,
|
||||||
|
first_run_at, run_once)
|
||||||
if now is None:
|
if now is None:
|
||||||
now = time.time()
|
now = time.time()
|
||||||
ensure_automations_tables(conn)
|
ensure_automations_tables(conn)
|
||||||
@@ -261,10 +301,12 @@ def create_automation(conn, project_id, name, prompt, created_by,
|
|||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO automations(id, project_id, name, prompt,"
|
"INSERT INTO automations(id, project_id, name, prompt,"
|
||||||
" repo_url, interval_seconds, status, last_run_at,"
|
" repo_url, interval_seconds, status, last_run_at,"
|
||||||
" last_claimed_at, created_by, created_at, cancelled_at)"
|
" last_claimed_at, created_by, created_at, cancelled_at,"
|
||||||
" VALUES(?, ?, ?, ?, ?, ?, ?, NULL, NULL, ?, ?, NULL)",
|
" first_run_at, run_once)"
|
||||||
|
" VALUES(?, ?, ?, ?, ?, ?, ?, NULL, NULL, ?, ?, NULL, ?, ?)",
|
||||||
(auto_id, project_id, name, prompt, repo_url,
|
(auto_id, project_id, name, prompt, repo_url,
|
||||||
interval_seconds, ACTIVE, created_by, now))
|
interval_seconds, ACTIVE, created_by, now,
|
||||||
|
first_run_at, 1 if run_once else 0))
|
||||||
conn.commit()
|
conn.commit()
|
||||||
row = conn.execute(
|
row = conn.execute(
|
||||||
"SELECT * FROM automations WHERE id = ?", (auto_id,)).fetchone()
|
"SELECT * FROM automations WHERE id = ?", (auto_id,)).fetchone()
|
||||||
@@ -327,11 +369,12 @@ def cancel_automation(conn, automation_id, now=None):
|
|||||||
|
|
||||||
def update_automation(conn, automation_id, fields, now=None):
|
def update_automation(conn, automation_id, fields, now=None):
|
||||||
"""更新自动化定义(automation_update):fields 只认
|
"""更新自动化定义(automation_update):fields 只认
|
||||||
name / prompt / interval_seconds / repo_url 四个键,
|
name / prompt / interval_seconds / repo_url / first_run_at /
|
||||||
其它键一律忽略(不报错、不存入,避免调用方拼错字段
|
run_once 六个键,其它键一律忽略(不报错、不存入,避免
|
||||||
名后以为已改);四个键全没给时抛 ValueError(空更新
|
调用方拼错字段名后以为已改);六个键全没给时抛
|
||||||
不是成功)。校验与创建同款(_validate_fields,名字
|
ValueError(空更新不是成功)。校验与创建同款
|
||||||
仍按“同项目 active 名字唯一”查重,改名撞名时 400)。
|
(_validate_fields,名字仍按“同项目 active 名字唯一”
|
||||||
|
查重,改名撞名时 400)。
|
||||||
|
|
||||||
返回 (ok, auto):行不存在时 (False, None);已取消时
|
返回 (ok, auto):行不存在时 (False, None);已取消时
|
||||||
(False, 旧 dict)——取消是终态,改已取消的定义必须
|
(False, 旧 dict)——取消是终态,改已取消的定义必须
|
||||||
@@ -359,11 +402,16 @@ def update_automation(conn, automation_id, fields, now=None):
|
|||||||
interval = fields.get("interval_seconds",
|
interval = fields.get("interval_seconds",
|
||||||
current["interval_seconds"])
|
current["interval_seconds"])
|
||||||
repo_url = fields.get("repo_url", current["repo_url"])
|
repo_url = fields.get("repo_url", current["repo_url"])
|
||||||
|
first_run_at = fields.get("first_run_at",
|
||||||
|
current["first_run_at"])
|
||||||
|
run_once = fields.get("run_once", current["run_once"])
|
||||||
if not any(k in fields for k in (
|
if not any(k in fields for k in (
|
||||||
"name", "prompt", "interval_seconds", "repo_url")):
|
"name", "prompt", "interval_seconds", "repo_url",
|
||||||
|
"first_run_at", "run_once")):
|
||||||
raise ValueError("no updatable field given")
|
raise ValueError("no updatable field given")
|
||||||
name, prompt, interval, repo_url = _validate_fields(
|
(name, prompt, interval, repo_url,
|
||||||
name, prompt, interval, repo_url)
|
first_run_at, run_once) = _validate_fields(
|
||||||
|
name, prompt, interval, repo_url, first_run_at, run_once)
|
||||||
if name != current["name"]:
|
if name != current["name"]:
|
||||||
clash = conn.execute(
|
clash = conn.execute(
|
||||||
"SELECT id FROM automations WHERE project_id = ?"
|
"SELECT id FROM automations WHERE project_id = ?"
|
||||||
@@ -375,8 +423,10 @@ def update_automation(conn, automation_id, fields, now=None):
|
|||||||
"automation name already exists in this project")
|
"automation name already exists in this project")
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"UPDATE automations SET name = ?, prompt = ?,"
|
"UPDATE automations SET name = ?, prompt = ?,"
|
||||||
" interval_seconds = ?, repo_url = ? WHERE id = ?",
|
" interval_seconds = ?, repo_url = ?, first_run_at = ?,"
|
||||||
(name, prompt, interval, repo_url, automation_id))
|
" run_once = ? WHERE id = ?",
|
||||||
|
(name, prompt, interval, repo_url, first_run_at,
|
||||||
|
1 if run_once else 0, automation_id))
|
||||||
conn.commit()
|
conn.commit()
|
||||||
row = conn.execute(
|
row = conn.execute(
|
||||||
"SELECT * FROM automations WHERE id = ?", (automation_id,)
|
"SELECT * FROM automations WHERE id = ?", (automation_id,)
|
||||||
@@ -384,20 +434,29 @@ def update_automation(conn, automation_id, fields, now=None):
|
|||||||
return True, automation_to_dict(row, now)
|
return True, automation_to_dict(row, now)
|
||||||
|
|
||||||
|
|
||||||
|
# 到期 SQL(due 与 claim 共用,绝不能只改一处):
|
||||||
|
# - 从未跑过:没给 first_run_at 时即到期;给了则只到该时刻才到期;
|
||||||
|
# - 跑过:上次 + 间隔 <= now 才到期;
|
||||||
|
# - 只跑一次且已跑过:永远不到期(run_once=1 且 last_run_at 非空)。
|
||||||
|
_DUE_SQL = (
|
||||||
|
" AND (run_once IS NULL OR run_once = 0 OR last_run_at IS NULL)"
|
||||||
|
" AND ((last_run_at IS NULL AND (first_run_at IS NULL"
|
||||||
|
" OR first_run_at <= ?))"
|
||||||
|
" OR (last_run_at IS NOT NULL AND"
|
||||||
|
" last_run_at + interval_seconds <= ?))")
|
||||||
|
|
||||||
|
|
||||||
def due_automations(conn, project_id, now=None):
|
def due_automations(conn, project_id, now=None):
|
||||||
"""列出某项目当前到期的 active 定义(纯读,不抢单)。
|
"""列出某项目当前到期的 active 定义(纯读,不抢单)。
|
||||||
SQL 层算到期:从未跑过(last_run_at 为空)或上次执行距
|
SQL 层算到期:与 is_due 的 Python 算法同款,见 _DUE_SQL。"""
|
||||||
今已满间隔;与 is_due 的 Python 算法同款。"""
|
|
||||||
if now is None:
|
if now is None:
|
||||||
now = time.time()
|
now = time.time()
|
||||||
ensure_automations_tables(conn)
|
ensure_automations_tables(conn)
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
"SELECT * FROM automations WHERE project_id = ?"
|
"SELECT * FROM automations WHERE project_id = ?"
|
||||||
" AND status = ?"
|
" AND status = ?" + _DUE_SQL +
|
||||||
" AND (last_run_at IS NULL"
|
|
||||||
" OR last_run_at + interval_seconds <= ?)"
|
|
||||||
" ORDER BY created_at, id",
|
" ORDER BY created_at, id",
|
||||||
(project_id, ACTIVE, now)).fetchall()
|
(project_id, ACTIVE, now, now)).fetchall()
|
||||||
return [automation_to_dict(r, now) for r in rows]
|
return [automation_to_dict(r, now) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
@@ -420,10 +479,8 @@ def claim_due_automation(conn, automation_id, now=None):
|
|||||||
cur = conn.execute(
|
cur = conn.execute(
|
||||||
"UPDATE automations SET last_run_at = ?,"
|
"UPDATE automations SET last_run_at = ?,"
|
||||||
" last_claimed_at = ?"
|
" last_claimed_at = ?"
|
||||||
" WHERE id = ? AND status = ?"
|
" WHERE id = ? AND status = ?" + _DUE_SQL,
|
||||||
" AND (last_run_at IS NULL"
|
(now, now, automation_id, ACTIVE, now, now))
|
||||||
" OR last_run_at + interval_seconds <= ?)",
|
|
||||||
(now, now, automation_id, ACTIVE, now))
|
|
||||||
conn.commit()
|
conn.commit()
|
||||||
return cur.rowcount == 1
|
return cur.rowcount == 1
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
"""主线 /goal 命令解析:发给主线会话的一句话里自动建 goal。
|
||||||
|
|
||||||
|
背景:此前主线不会因为收到“请在这条主线开启一个持久 /goal”
|
||||||
|
这样的自然语言就自己变成 goal 任务——goal 只在建任务时由
|
||||||
|
调用方用 goal_mode=1 指定,或在 /continue 时显式勾 goal 模式。
|
||||||
|
本模块把“/goal”当成显式命令标记,让发给主线的消息本身就能
|
||||||
|
触发转换,调用方不用再知道任务当前是进行中还是已终态。
|
||||||
|
|
||||||
|
解析规则(刻意收紧,防误触发):
|
||||||
|
- 触发词是 "/goal" 或裸 "goal"(斜杠不用,全角 / 也认,
|
||||||
|
大小写不敏感,单数 only——"goals" 不算)。
|
||||||
|
- 光出现还不够,必须是命令形状,二者居一:
|
||||||
|
1. 文本以触发词开头(最显式的命令),例如“goal 收xxx”;
|
||||||
|
2. 触发词后面紧跟分隔符(空格、,、:、、)或“目标”标记,
|
||||||
|
例如“/goal 收xxx”“goal,目标是收xxx”;
|
||||||
|
3. 触发词前面有开启/发送类动词(开启/建立/创建/添加/设为/
|
||||||
|
设置/开一个/发/发送),例如“请开启一个持久/goal收xxx”、
|
||||||
|
“发goal 收xxx”。
|
||||||
|
只是在讨论里提到“/goal 的用法”“goal 的用法”“goal 是什么”
|
||||||
|
这种不算命令:触发词后面紧跟“的/是/吗/什么/怎么/如何/
|
||||||
|
模式/用法/意思/功能”时一律不当命令。
|
||||||
|
- 目标取触发词之后的部分:先去分隔符,再去“目标是/目标为/
|
||||||
|
目标:/目标:”或“持久目标是/为/:/:”这类前缀,最后去尾句号。
|
||||||
|
- 返回值:不是命令返回 None;是命令但没给目标返回 ""(调用方
|
||||||
|
据此回 400“目标不能为空”,而不是当成普通消息悄悄排队);
|
||||||
|
否则返回目标文本。
|
||||||
|
|
||||||
|
本模块只做纯文本解析,不碰数据库、不发请求,server 与 worker
|
||||||
|
共用同一份解析,避免两端对“是不是 /goal 命令”判断不一致。
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
_GOAL_RE = re.compile(r"/goal(?![A-Za-z])", re.IGNORECASE)
|
||||||
|
_BARE_GOAL_RE = re.compile(r"(?<![A-Za-z])goal(?![A-Za-z])", re.IGNORECASE)
|
||||||
|
_SEPARATORS = " \t\r\n::,,、"
|
||||||
|
_TARGET_MARKERS = (
|
||||||
|
"持久目标是", "持久目标为", "持久目标:", "持久目标:",
|
||||||
|
"目标是", "目标为", "目标:", "目标:",
|
||||||
|
)
|
||||||
|
_OPEN_VERBS = ("开启", "建立", "创建", "添加", "设为", "设置", "开一个",
|
||||||
|
"发", "发送")
|
||||||
|
# 触发词后紧跟这些词头时是在讨论 goal 本身,不是下命令。
|
||||||
|
_DISCUSSION_PREFIXES = ("的", "是", "吗", "什么", "怎么", "如何",
|
||||||
|
"模式", "用法", "意思", "功能")
|
||||||
|
|
||||||
|
|
||||||
|
def _strip_target(rest):
|
||||||
|
"""去掉目标前的分隔符与“目标是”类前缀、尾句号。"""
|
||||||
|
rest = rest.lstrip(_SEPARATORS)
|
||||||
|
for marker in _TARGET_MARKERS:
|
||||||
|
if rest.startswith(marker):
|
||||||
|
rest = rest[len(marker):].lstrip(_SEPARATORS)
|
||||||
|
break
|
||||||
|
rest = rest.strip()
|
||||||
|
while rest.endswith("。"):
|
||||||
|
rest = rest[:-1].rstrip()
|
||||||
|
return rest
|
||||||
|
|
||||||
|
|
||||||
|
def _rest_is_command(rest):
|
||||||
|
"""触发词后的部分是不是命令形状(讨论词头除外)。"""
|
||||||
|
if not rest:
|
||||||
|
# 单独一句“/goal”/“goal”:是命令,但没目标,调用方回 400。
|
||||||
|
return True
|
||||||
|
if rest[0] in _SEPARATORS:
|
||||||
|
return True
|
||||||
|
if rest.startswith("目标"):
|
||||||
|
return True
|
||||||
|
if rest.startswith(_DISCUSSION_PREFIXES):
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def parse_goal_command(text):
|
||||||
|
"""解析主线消息里的 /goal 命令,返回目标或 None/""(见模块说明)。"""
|
||||||
|
if not isinstance(text, str):
|
||||||
|
return None
|
||||||
|
s = text.strip().replace("/", "/")
|
||||||
|
if not s:
|
||||||
|
return None
|
||||||
|
# 先认带斜杠的:是命令直接返回;不是命令(如讨论里提到)
|
||||||
|
# 也不急着否掉,后面裸 goal 的位置还可能是一条真命令。
|
||||||
|
match = _GOAL_RE.search(s)
|
||||||
|
if match is not None:
|
||||||
|
before = s[:match.start()]
|
||||||
|
rest = s[match.end():]
|
||||||
|
# 斜杠本身已是显式标记,句首与否不再单算:命令与否
|
||||||
|
# 只看前面有没有动词、后面是不是讨论词头——这样
|
||||||
|
# “/goal的用法”也不会被误当成命令。
|
||||||
|
if (any(verb in before for verb in _OPEN_VERBS)
|
||||||
|
or _rest_is_command(rest)):
|
||||||
|
return _strip_target(rest)
|
||||||
|
# 再认不带斜杠的 goal:位置必须在句首,或前面是分隔符/
|
||||||
|
# 开启·发送类动词,否则“讨论goal的用法”这类也会误触发。
|
||||||
|
for bare in _BARE_GOAL_RE.finditer(s):
|
||||||
|
if bare.start() > 0 and s[bare.start() - 1] == "/":
|
||||||
|
continue # 斜杠那处已在上面判过
|
||||||
|
before = s[:bare.start()]
|
||||||
|
rest = s[bare.end():]
|
||||||
|
at_start = not before.strip()
|
||||||
|
before_ok = at_start or (
|
||||||
|
before.rstrip() and before.rstrip()[-1] in _SEPARATORS) or (
|
||||||
|
any(verb in before for verb in _OPEN_VERBS))
|
||||||
|
if before_ok and _rest_is_command(rest):
|
||||||
|
return _strip_target(rest)
|
||||||
|
return None
|
||||||
+225
-1
@@ -1,6 +1,18 @@
|
|||||||
"""任务内标记工具(marker tools):task-create / task-poll / task-read /
|
"""任务内标记工具(marker tools):task-create / task-poll / task-read /
|
||||||
wait-threads / task-cancel / task-retry / todo-list / todo-create /
|
wait-threads / task-cancel / task-retry / todo-list / todo-create /
|
||||||
todo-update / task-list / task-cost / quota-status / task-files。
|
todo-update / task-list / task-cost / quota-status / task-files /
|
||||||
|
goal-convert / automation-create。
|
||||||
|
|
||||||
|
新增的两个工具是“LLM 自己判断意图、不用用户说关键词”
|
||||||
|
那条路:用户只说“我要一直收 xxx”“明天 9 点看 yyy”这种
|
||||||
|
自然语言,agent 在回合里写 goal-convert / automation-create
|
||||||
|
块,worker 在轮次边界执行——不是服务端再用正则猜用户
|
||||||
|
是不是在说 goal / 自动化。关键词解析(cloud/goal_command)
|
||||||
|
仍保留作显式命令兜底,两条路不互斥。
|
||||||
|
注意:本批刻意不向 LLM 自动注入这两个工具的指南(避免
|
||||||
|
prompt 臃肿),agent 知道它们的唯一途径是用户在会话里
|
||||||
|
手动告诉它,或调用方在任务 prompt 里自己写明;格式见
|
||||||
|
docs/marker-tools.md 新增的两节。
|
||||||
|
|
||||||
设计(与 peer_messages、task_spawn 同款原则,不另起一套):
|
设计(与 peer_messages、task_spawn 同款原则,不另起一套):
|
||||||
|
|
||||||
@@ -68,6 +80,17 @@ todo-update / task-list / task-cost / quota-status / task-files。
|
|||||||
二进制文件拒绝);绝对路径、.. 逃逸、符号链接逃逸一律
|
二进制文件拒绝);绝对路径、.. 逃逸、符号链接逃逸一律
|
||||||
拒绝;cloud 任务与 worktree 在别机的任务拒绝(文件不在
|
拒绝;cloud 任务与 worktree 在别机的任务拒绝(文件不在
|
||||||
本机);目标没有本地 worktree 时按 404 报告,不编内容。
|
本机);目标没有本地 worktree 时按 404 报告,不编内容。
|
||||||
|
- goal-convert:把本会话在轮次边界转成新语义 goal 任务
|
||||||
|
(goal_mode=1、goal 即块里给的目标、无限轮直到模型自决
|
||||||
|
“目标已达成”,与 /goal 转换同款 CAS)。每轮最多 1 块;
|
||||||
|
实际执行时各块照常执行、报告先排、/goal 消息后排,转成
|
||||||
|
goal 后本任务不再解析标记块。
|
||||||
|
- automation-create:新建一个自动化定义(见
|
||||||
|
extract_automation_creates 的字段说明)。每轮最多 5 块;
|
||||||
|
创建人跟父任务走,须有项目 create_task 权限,配额超限
|
||||||
|
时整轮跳过;name 缺失时 worker 自动生成,避免重名撞号。
|
||||||
|
创建只是登记定义,到期执行仍走 automations 的 claim 门
|
||||||
|
与外部触发器,见 automations 模块。
|
||||||
|
|
||||||
上限口径(本模块最终定的值,文档与代码一致):
|
上限口径(本模块最终定的值,文档与代码一致):
|
||||||
|
|
||||||
@@ -111,8 +134,20 @@ import os
|
|||||||
import re
|
import re
|
||||||
import time
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
try:
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
except Exception: # pragma: no cover - 极老 Python 无 zoneinfo
|
||||||
|
ZoneInfo = None
|
||||||
|
|
||||||
MAX_CREATES_PER_TURN = 5
|
MAX_CREATES_PER_TURN = 5
|
||||||
|
MAX_GOAL_CONVERTS_PER_TURN = 1
|
||||||
|
MAX_AUTOMATION_CREATES_PER_TURN = 5
|
||||||
|
MIN_AUTOMATION_INTERVAL_SECONDS = 60
|
||||||
|
MAX_AUTOMATION_INTERVAL_SECONDS = 30 * 86400
|
||||||
|
MAX_AUTOMATION_REPO_URL_CHARS = 1000
|
||||||
|
DEFAULT_AUTOMATION_INTERVAL_SECONDS = 86400
|
||||||
MAX_POLLS_PER_TURN = 5
|
MAX_POLLS_PER_TURN = 5
|
||||||
MAX_WAITS_PER_TURN = 2
|
MAX_WAITS_PER_TURN = 2
|
||||||
MAX_WAIT_TARGETS_PER_BLOCK = 8
|
MAX_WAIT_TARGETS_PER_BLOCK = 8
|
||||||
@@ -176,6 +211,64 @@ _QUOTA_RE = re.compile(
|
|||||||
r"```(quota-status|quota_status)\s*\n(.*?)\n```", re.DOTALL)
|
r"```(quota-status|quota_status)\s*\n(.*?)\n```", re.DOTALL)
|
||||||
_TASK_FILES_RE = re.compile(
|
_TASK_FILES_RE = re.compile(
|
||||||
r"```(task-files|task_files)\s*\n(.*?)\n```", re.DOTALL)
|
r"```(task-files|task_files)\s*\n(.*?)\n```", re.DOTALL)
|
||||||
|
_GOAL_CONVERT_RE = re.compile(
|
||||||
|
r"```(goal-convert|goal_convert)\s*\n(.*?)\n```", re.DOTALL)
|
||||||
|
_AUTOMATION_CREATE_RE = re.compile(
|
||||||
|
r"```(automation-create|automation_create)\s*\n(.*?)\n```",
|
||||||
|
re.DOTALL)
|
||||||
|
|
||||||
|
|
||||||
|
def _shanghai_tz():
|
||||||
|
"""无时区时间默认按 Asia/Shanghai 理解(用户时区)。
|
||||||
|
|
||||||
|
有 zoneinfo 时用它;没有时退回固定 UTC+8,避免把“明天 9 点”
|
||||||
|
按 UTC 算成北京时间 17 点。"""
|
||||||
|
if ZoneInfo is not None:
|
||||||
|
try:
|
||||||
|
return ZoneInfo("Asia/Shanghai")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return timezone(timedelta(hours=8))
|
||||||
|
|
||||||
|
|
||||||
|
def parse_first_run_at(value):
|
||||||
|
"""解析 first_run_at,返回 epoch 秒(float)或 None(无效)。
|
||||||
|
|
||||||
|
接受:epoch 秒(int/float,毫秒级数字自动除 1000)、纯数字
|
||||||
|
字符串、ISO8601 字符串(带偏移量按偏移量算;无时区按
|
||||||
|
Asia/Shanghai 算;“YYYY-MM-DD HH:MM”这种 fromisoformat
|
||||||
|
能读的也算)。不判断“是否过去”——过去判断在 worker 层,
|
||||||
|
那里才能把“已过去”写进报告让 agent 下一轮纠正;纯解析层
|
||||||
|
只负责“是不是一个合法时刻”,否则 LLM 的一次笔误会连
|
||||||
|
报告都拿不到。
|
||||||
|
本函数只依赖标准库,server 解析 API 的 first_run_at 时
|
||||||
|
也复用它,避免两端对同一字段的解析不一致。"""
|
||||||
|
if value is None or isinstance(value, bool):
|
||||||
|
return None
|
||||||
|
if isinstance(value, (int, float)):
|
||||||
|
epoch = float(value)
|
||||||
|
elif isinstance(value, str):
|
||||||
|
s = value.strip()
|
||||||
|
if not s:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
epoch = float(s)
|
||||||
|
except ValueError:
|
||||||
|
try:
|
||||||
|
dt = datetime.fromisoformat(
|
||||||
|
s.replace("Z", "+00:00").replace("z", "+00:00"))
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
if dt.tzinfo is None:
|
||||||
|
dt = dt.replace(tzinfo=_shanghai_tz())
|
||||||
|
return dt.timestamp()
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
if epoch >= 1e11: # 毫秒级 epoch,秒级不可能这么大
|
||||||
|
epoch = epoch / 1000.0
|
||||||
|
if epoch <= 0 or epoch > 4102444800: # 2100-01-01 之后不认
|
||||||
|
return None
|
||||||
|
return epoch
|
||||||
|
|
||||||
|
|
||||||
def row_get(row, name):
|
def row_get(row, name):
|
||||||
@@ -786,6 +879,137 @@ def extract_task_retries(text):
|
|||||||
return _extract_controls(text, _RETRY_RE)
|
return _extract_controls(text, _RETRY_RE)
|
||||||
|
|
||||||
|
|
||||||
|
def extract_goal_converts(text):
|
||||||
|
"""提取 goal-convert / goal_convert 块:把本会话转成 goal。
|
||||||
|
|
||||||
|
块格式::
|
||||||
|
|
||||||
|
```goal-convert
|
||||||
|
{"goal": "收 xxx 到 yyy 为止"}
|
||||||
|
```
|
||||||
|
|
||||||
|
goal 必填、1-20000 字;每轮最多 1 块(转一次就够了,
|
||||||
|
多余的忽略)。不接受 verify_command / goal_max_rounds:
|
||||||
|
LLM 定的 goal 一律是新语义——无限轮直到模型自己输出
|
||||||
|
“目标已达成”,与服务端 /goal 转换口径一致,绝不让 LLM
|
||||||
|
顺手把旧验收语义混进来。"""
|
||||||
|
if not text:
|
||||||
|
return []
|
||||||
|
out = []
|
||||||
|
for match in _GOAL_CONVERT_RE.finditer(str(text)):
|
||||||
|
if len(out) >= MAX_GOAL_CONVERTS_PER_TURN:
|
||||||
|
break
|
||||||
|
try:
|
||||||
|
payload = json.loads(match.group(2))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
continue
|
||||||
|
goal = payload.get("goal") or payload.get("target")
|
||||||
|
if not isinstance(goal, str) or not goal.strip():
|
||||||
|
continue
|
||||||
|
goal = goal.strip()
|
||||||
|
if len(goal) > MAX_PROMPT_CHARS:
|
||||||
|
continue
|
||||||
|
out.append({"goal": goal})
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def extract_automation_creates(text):
|
||||||
|
"""提取 automation-create / automation_create 块。
|
||||||
|
|
||||||
|
块格式::
|
||||||
|
|
||||||
|
```automation-create
|
||||||
|
{"name": "看 xxx 每日", "prompt": "查看 xxx 最新并汇总",
|
||||||
|
"interval_seconds": 86400,
|
||||||
|
"first_run_at": "2026-10-30T09:00:00+08:00",
|
||||||
|
"once": false, "bind": null, "repo_url": null}
|
||||||
|
```
|
||||||
|
|
||||||
|
- prompt 必填、1-20000 字;name 可选、1-100 字,缺失时
|
||||||
|
worker 自动生成(LLM 不必为起名费劲,也不会因重名
|
||||||
|
撞上同项目已有的 active 自动化)。
|
||||||
|
- interval_seconds 可选、缺省 86400,必须是整数且在
|
||||||
|
60-30*86400 之间;非法时整块跳过,不静默钳制——钳制
|
||||||
|
会把“每小时”悄悄改成“每分钟下限”或把“每月”改
|
||||||
|
短,调度错误比拒绝更贵。
|
||||||
|
- first_run_at 可选,见 parse_first_run_at;不给表示
|
||||||
|
建完即到期(沿用自动化原有语义),给了表示首次在该
|
||||||
|
时刻跑——“每天 9 点”要写成 first_run_at=下一个 9 点
|
||||||
|
+ interval_seconds=86400,而不是只写间隔(只写间隔
|
||||||
|
会从创建时间起算,每天跑在创建时间那个钟点)。
|
||||||
|
- once 可选、缺省 false;true 表示只跑一次(跑过后
|
||||||
|
is_due / due / claim 三处都不再到期,见 automations)。
|
||||||
|
- bind 可选、缺省 null(到期独立跑新任务);"heartbeat"
|
||||||
|
表示绑到本会话,到期唤醒本线程;"cron" 与 null 同款,
|
||||||
|
只是显式写明独立跑。其它值整块跳过。
|
||||||
|
- repo_url 可选、最多 1000 字。
|
||||||
|
- 每轮最多 5 块,多余的忽略;非法 JSON、prompt 缺失/
|
||||||
|
空串/超长时整块跳过。"""
|
||||||
|
if not text:
|
||||||
|
return []
|
||||||
|
out = []
|
||||||
|
for match in _AUTOMATION_CREATE_RE.finditer(str(text)):
|
||||||
|
if len(out) >= MAX_AUTOMATION_CREATES_PER_TURN:
|
||||||
|
break
|
||||||
|
try:
|
||||||
|
payload = json.loads(match.group(2))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
continue
|
||||||
|
prompt = payload.get("prompt")
|
||||||
|
if not isinstance(prompt, str) or not prompt.strip():
|
||||||
|
continue
|
||||||
|
prompt = prompt.strip()
|
||||||
|
if len(prompt) > MAX_PROMPT_CHARS:
|
||||||
|
continue
|
||||||
|
name = payload.get("name")
|
||||||
|
if name is not None:
|
||||||
|
if not isinstance(name, str) or not name.strip():
|
||||||
|
continue
|
||||||
|
name = name.strip()
|
||||||
|
if len(name) > MAX_NAME_CHARS:
|
||||||
|
continue
|
||||||
|
interval = payload.get("interval_seconds")
|
||||||
|
if interval is None:
|
||||||
|
interval = DEFAULT_AUTOMATION_INTERVAL_SECONDS
|
||||||
|
elif isinstance(interval, bool):
|
||||||
|
continue
|
||||||
|
elif isinstance(interval, float) and interval.is_integer():
|
||||||
|
interval = int(interval)
|
||||||
|
elif not isinstance(interval, int):
|
||||||
|
continue
|
||||||
|
if interval < MIN_AUTOMATION_INTERVAL_SECONDS or \
|
||||||
|
interval > MAX_AUTOMATION_INTERVAL_SECONDS:
|
||||||
|
continue
|
||||||
|
first_run_at = None
|
||||||
|
if payload.get("first_run_at") is not None:
|
||||||
|
first_run_at = parse_first_run_at(payload.get("first_run_at"))
|
||||||
|
if first_run_at is None:
|
||||||
|
continue
|
||||||
|
once = payload.get("once", False)
|
||||||
|
if not isinstance(once, bool):
|
||||||
|
continue
|
||||||
|
bind = payload.get("bind", payload.get("bind_mode"))
|
||||||
|
if bind is not None and bind not in ("heartbeat", "cron"):
|
||||||
|
continue
|
||||||
|
repo_url = payload.get("repo_url")
|
||||||
|
if repo_url is not None:
|
||||||
|
if not isinstance(repo_url, str):
|
||||||
|
continue
|
||||||
|
repo_url = repo_url.strip() or None
|
||||||
|
if repo_url is not None and \
|
||||||
|
len(repo_url) > MAX_AUTOMATION_REPO_URL_CHARS:
|
||||||
|
continue
|
||||||
|
out.append({"name": name, "prompt": prompt,
|
||||||
|
"interval_seconds": interval,
|
||||||
|
"first_run_at": first_run_at, "once": once,
|
||||||
|
"bind": bind, "repo_url": repo_url})
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
def parent_spawn_generation(conn, parent_id):
|
def parent_spawn_generation(conn, parent_id):
|
||||||
"""父任务自身的派生代数:普通任务 0,第一代派生任务 1,依此类推。
|
"""父任务自身的派生代数:普通任务 0,第一代派生任务 1,依此类推。
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,10 @@ import sys
|
|||||||
import time
|
import time
|
||||||
|
|
||||||
from cloud.marker_tools import extract_quota_status as _extract_quota_status
|
from cloud.marker_tools import extract_quota_status as _extract_quota_status
|
||||||
|
from cloud.marker_tools import extract_automation_creates as \
|
||||||
|
_extract_automation_creates
|
||||||
|
from cloud.marker_tools import extract_goal_converts as \
|
||||||
|
_extract_goal_converts
|
||||||
from cloud.marker_tools import extract_task_cancels as _extract_task_cancels
|
from cloud.marker_tools import extract_task_cancels as _extract_task_cancels
|
||||||
from cloud.marker_tools import extract_task_costs as _extract_task_costs
|
from cloud.marker_tools import extract_task_costs as _extract_task_costs
|
||||||
from cloud.marker_tools import extract_task_creates as _extract_task_creates
|
from cloud.marker_tools import extract_task_creates as _extract_task_creates
|
||||||
@@ -418,6 +422,8 @@ class LocalExecutor(Executor):
|
|||||||
todo_lists = []
|
todo_lists = []
|
||||||
todo_creates = []
|
todo_creates = []
|
||||||
todo_updates = []
|
todo_updates = []
|
||||||
|
goal_converts = []
|
||||||
|
automation_creates = []
|
||||||
turn_id = None
|
turn_id = None
|
||||||
root_turn_id = None
|
root_turn_id = None
|
||||||
parsed = None
|
parsed = None
|
||||||
@@ -465,6 +471,9 @@ class LocalExecutor(Executor):
|
|||||||
todo_lists = _extract_todo_lists(parsed.get("text"))
|
todo_lists = _extract_todo_lists(parsed.get("text"))
|
||||||
todo_creates = _extract_todo_creates(parsed.get("text"))
|
todo_creates = _extract_todo_creates(parsed.get("text"))
|
||||||
todo_updates = _extract_todo_updates(parsed.get("text"))
|
todo_updates = _extract_todo_updates(parsed.get("text"))
|
||||||
|
goal_converts = _extract_goal_converts(parsed.get("text"))
|
||||||
|
automation_creates = _extract_automation_creates(
|
||||||
|
parsed.get("text"))
|
||||||
else:
|
else:
|
||||||
host_status = None
|
host_status = None
|
||||||
else:
|
else:
|
||||||
@@ -496,6 +505,8 @@ class LocalExecutor(Executor):
|
|||||||
"todo_lists": todo_lists,
|
"todo_lists": todo_lists,
|
||||||
"todo_creates": todo_creates,
|
"todo_creates": todo_creates,
|
||||||
"todo_updates": todo_updates,
|
"todo_updates": todo_updates,
|
||||||
|
"goal_converts": goal_converts,
|
||||||
|
"automation_creates": automation_creates,
|
||||||
"turn_id": turn_id,
|
"turn_id": turn_id,
|
||||||
"root_turn_id": root_turn_id,
|
"root_turn_id": root_turn_id,
|
||||||
}
|
}
|
||||||
@@ -1944,6 +1955,8 @@ def _parse_host_result(task_id, fake):
|
|||||||
todo_lists = []
|
todo_lists = []
|
||||||
todo_creates = []
|
todo_creates = []
|
||||||
todo_updates = []
|
todo_updates = []
|
||||||
|
goal_converts = []
|
||||||
|
automation_creates = []
|
||||||
turn_id = None
|
turn_id = None
|
||||||
root_turn_id = None
|
root_turn_id = None
|
||||||
parsed = None
|
parsed = None
|
||||||
@@ -1978,6 +1991,9 @@ def _parse_host_result(task_id, fake):
|
|||||||
todo_lists = _extract_todo_lists(parsed.get("text"))
|
todo_lists = _extract_todo_lists(parsed.get("text"))
|
||||||
todo_creates = _extract_todo_creates(parsed.get("text"))
|
todo_creates = _extract_todo_creates(parsed.get("text"))
|
||||||
todo_updates = _extract_todo_updates(parsed.get("text"))
|
todo_updates = _extract_todo_updates(parsed.get("text"))
|
||||||
|
goal_converts = _extract_goal_converts(parsed.get("text"))
|
||||||
|
automation_creates = _extract_automation_creates(
|
||||||
|
parsed.get("text"))
|
||||||
# 任务列表显示 turn 用:与 Local 版同款语义,取不到
|
# 任务列表显示 turn 用:与 Local 版同款语义,取不到
|
||||||
# 时保持 None,worker 写回时不覆盖旧值。
|
# 时保持 None,worker 写回时不覆盖旧值。
|
||||||
turn_id = parsed.get("turn_id")
|
turn_id = parsed.get("turn_id")
|
||||||
@@ -2006,6 +2022,8 @@ def _parse_host_result(task_id, fake):
|
|||||||
"todo_lists": todo_lists,
|
"todo_lists": todo_lists,
|
||||||
"todo_creates": todo_creates,
|
"todo_creates": todo_creates,
|
||||||
"todo_updates": todo_updates,
|
"todo_updates": todo_updates,
|
||||||
|
"goal_converts": goal_converts,
|
||||||
|
"automation_creates": automation_creates,
|
||||||
"turn_id": turn_id,
|
"turn_id": turn_id,
|
||||||
"root_turn_id": root_turn_id,
|
"root_turn_id": root_turn_id,
|
||||||
}
|
}
|
||||||
|
|||||||
+246
-10
@@ -67,6 +67,8 @@ from cloud.db import create_database as _create_database
|
|||||||
from cloud.db import init_postgres_schema as _init_postgres_schema
|
from cloud.db import init_postgres_schema as _init_postgres_schema
|
||||||
from cloud.db import PostgresIntegrityError as _PgIntegrityError
|
from cloud.db import PostgresIntegrityError as _PgIntegrityError
|
||||||
from cloud.db import PostgresOperationalError as _PgOperationalError
|
from cloud.db import PostgresOperationalError as _PgOperationalError
|
||||||
|
from cloud.goal_command import parse_goal_command as _parse_goal_command
|
||||||
|
from cloud.marker_tools import parse_first_run_at as _parse_first_run_at
|
||||||
from cloud.peer_messages import deliver_peer_message as _deliver_peer_message
|
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 ensure_followups_table as _peer_ensure_followups
|
||||||
from cloud.peer_messages import peer_message_prefix as _peer_prefix
|
from cloud.peer_messages import peer_message_prefix as _peer_prefix
|
||||||
@@ -4243,6 +4245,17 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
|
|||||||
if not prompt or not isinstance(prompt, str):
|
if not prompt or not isinstance(prompt, str):
|
||||||
self._send_json(400, {"error": "prompt required"})
|
self._send_json(400, {"error": "prompt required"})
|
||||||
return
|
return
|
||||||
|
# 新建目标:prompt 里直接写 /goal 也算新建 goal,不必
|
||||||
|
# 先勾选项——例如 prompt="/goal 收xxx" 或“请开启一个
|
||||||
|
# 持久 /goal,目标是收xxx”。解析在 cloud/goal_command.py,
|
||||||
|
# 与主线转换同款(讨论里提到 /goal 不算)。目标即新
|
||||||
|
# 任务的 prompt 与 goal;空目标 400,不建普通任务。
|
||||||
|
prompt_goal_target = _parse_goal_command(prompt)
|
||||||
|
if prompt_goal_target is not None and not prompt_goal_target:
|
||||||
|
self._send_json(400, {"error": "goal 目标不能为空"})
|
||||||
|
return
|
||||||
|
if prompt_goal_target:
|
||||||
|
prompt = prompt_goal_target
|
||||||
depends_on = body.get("depends_on") or []
|
depends_on = body.get("depends_on") or []
|
||||||
if isinstance(depends_on, str):
|
if isinstance(depends_on, str):
|
||||||
depends_on = [depends_on]
|
depends_on = [depends_on]
|
||||||
@@ -4326,7 +4339,10 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
|
|||||||
# verify_command 是语义冲突,400。旧语义(verify_command
|
# verify_command 是语义冲突,400。旧语义(verify_command
|
||||||
# 非空)保留:goal 文本缺省用 prompt,轮次缺省 3,非法值
|
# 非空)保留:goal 文本缺省用 prompt,轮次缺省 3,非法值
|
||||||
# 400 且不建任务;老客户端与已建的旧 goal 任务继续可用。
|
# 400 且不建任务;老客户端与已建的旧 goal 任务继续可用。
|
||||||
goal_mode = 1 if body.get("goal_mode") in (True, 1) else 0
|
# /goal 命令优先于 body 里的 goal_mode=0(prompt 里的
|
||||||
|
# 命令比选项更具体),plan/验收命令冲突仍在下面 400。
|
||||||
|
goal_mode = 1 if body.get("goal_mode") in (True, 1) \
|
||||||
|
or prompt_goal_target else 0
|
||||||
if goal_mode:
|
if goal_mode:
|
||||||
if plan:
|
if plan:
|
||||||
self._send_json(400, {
|
self._send_json(400, {
|
||||||
@@ -4705,6 +4721,58 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
|
|||||||
finally:
|
finally:
|
||||||
conn.close()
|
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):
|
def _get_task(self, user_id, task_id):
|
||||||
row = self._fetch_task(task_id)
|
row = self._fetch_task(task_id)
|
||||||
if row is None:
|
if row is None:
|
||||||
@@ -4878,6 +4946,10 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
|
|||||||
送达(链路里可见),本轮跑完后立刻以它开下一轮,且
|
送达(链路里可见),本轮跑完后立刻以它开下一轮,且
|
||||||
优先于排队中的消息。v1 只做运行时(含排队中)的任务:
|
优先于排队中的消息。v1 只做运行时(含排队中)的任务:
|
||||||
已终态的任务 409;goal 任务 409(goal 循环自有轮次)。
|
已终态的任务 409;goal 任务 409(goal 循环自有轮次)。
|
||||||
|
例外:消息是 /goal 命令时(见 cloud/goal_command.py),
|
||||||
|
不走普通后续消息——queued 原地转 goal、running 排队后
|
||||||
|
由 worker 在轮次边界转 goal、已终态复活并转 goal;
|
||||||
|
已是 goal、plan 任务、merged、空目标各按 409/400 拒绝。
|
||||||
权限与取消同款:项目 create_task(写操作,不能只读)。
|
权限与取消同款:项目 create_task(写操作,不能只读)。
|
||||||
"""
|
"""
|
||||||
body, err = self._read_json()
|
body, err = self._read_json()
|
||||||
@@ -4909,13 +4981,79 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
|
|||||||
self._send_json(400, {
|
self._send_json(400, {
|
||||||
"error": "mode must be queued or direct"})
|
"error": "mode must be queued or direct"})
|
||||||
return
|
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, {
|
self._send_json(409, {
|
||||||
"error": "后续消息仅支持进行中任务",
|
"error": "后续消息仅支持进行中任务",
|
||||||
"status": task["status"],
|
"status": task["status"],
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
if task.get("verify_command"):
|
if task.get("verify_command") and goal_target is None:
|
||||||
self._send_json(409, {"error": "goal 任务暂不支持后续消息"})
|
self._send_json(409, {"error": "goal 任务暂不支持后续消息"})
|
||||||
return
|
return
|
||||||
now = time.time()
|
now = time.time()
|
||||||
@@ -4944,6 +5082,14 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
|
|||||||
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"],
|
||||||
"followup_created", "%s:%s" % (task_id, fu_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)
|
self._send_json(201, fu)
|
||||||
|
|
||||||
def _append_direct_trace(self, task, fu_id, text):
|
def _append_direct_trace(self, task, fu_id, text):
|
||||||
@@ -6194,9 +6340,60 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
|
|||||||
自动化 heartbeat 共用同一路,不各写一套复活逻辑):
|
自动化 heartbeat 共用同一路,不各写一套复活逻辑):
|
||||||
goal 任务 409、merged 409、终态任务 CAS 复活(清旧
|
goal 任务 409、merged 409、终态任务 CAS 复活(清旧
|
||||||
结果/旧错误)、进行中任务写 followup(direct 再追加
|
结果/旧错误)、进行中任务写 followup(direct 再追加
|
||||||
直接 trace)。返回 (http_code, payload),不直接
|
直接 trace)。例外:文本是 /goal 命令时(见
|
||||||
_send_json;配额检查由调用方在调本方法前做。"""
|
cloud/goal_command.py),按 /followups 的 goal 分支
|
||||||
|
处理——queued 原地转 goal、running 排队后由 worker
|
||||||
|
在轮次边界转 goal、已终态复活并转 goal。返回
|
||||||
|
(http_code, payload),不直接 _send_json;配额检查
|
||||||
|
由调用方在调本方法前做。"""
|
||||||
task_id = task["id"]
|
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"):
|
if task.get("verify_command") or task.get("goal_mode"):
|
||||||
return 409, {"error": "goal 任务暂不支持后续消息"}
|
return 409, {"error": "goal 任务暂不支持后续消息"}
|
||||||
if task["status"] == "merged":
|
if task["status"] == "merged":
|
||||||
@@ -6255,6 +6452,13 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
|
|||||||
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"], audit_action,
|
store.audit(user_id, task["project_id"], audit_action,
|
||||||
"%s:%s" % (task_id, fu_id))
|
"%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"
|
return 201, dict(fu, delivery="queued"
|
||||||
if mode == "queued" else "direct")
|
if mode == "queued" else "direct")
|
||||||
|
|
||||||
@@ -7362,7 +7566,9 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
|
|||||||
goal_mode=1 时同时把任务转成新 goal 语义(goal=新消息、
|
goal_mode=1 时同时把任务转成新 goal 语义(goal=新消息、
|
||||||
verify_command 清空、轮次 0),worker 走 goal-resume
|
verify_command 清空、轮次 0),worker 走 goal-resume
|
||||||
在旧线程上开新目标循环;goal_mode 不传时 goal 任务沿用
|
在旧线程上开新目标循环;goal_mode 不传时 goal 任务沿用
|
||||||
原语义续跑。状态回 queued 时上一轮 result/error 清空
|
原语义续跑。例外:message 本身是 /goal 命令时强制
|
||||||
|
goal_mode=1,goal 与 resume_message 都取解析出的目标
|
||||||
|
而非整句(见 cloud/goal_command.py)。状态回 queued 时上一轮 result/error 清空
|
||||||
(与重试一致,否则排队中的任务还挂着旧结果)。
|
(与重试一致,否则排队中的任务还挂着旧结果)。
|
||||||
配额:全部校验通过后、写库之前按任务创建者查一次
|
配额:全部校验通过后、写库之前按任务创建者查一次
|
||||||
(与重试同口径,费用记创建者名下);超限回 402,不
|
(与重试同口径,费用记创建者名下);超限回 402,不
|
||||||
@@ -7415,6 +7621,11 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
|
|||||||
if len(message) > 20000:
|
if len(message) > 20000:
|
||||||
self._send_json(400, {"error": "message too long"})
|
self._send_json(400, {"error": "message too long"})
|
||||||
return
|
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")
|
model = body.get("model")
|
||||||
if model is not None:
|
if model is not None:
|
||||||
if not isinstance(model, str):
|
if not isinstance(model, str):
|
||||||
@@ -7469,6 +7680,10 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
|
|||||||
goal_mode = None
|
goal_mode = None
|
||||||
if "goal_mode" in body:
|
if "goal_mode" in body:
|
||||||
goal_mode = 1 if body["goal_mode"] in (True, 1) else 0
|
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)
|
plan_eff = plan if plan is not None else (task["plan"] or 0)
|
||||||
goal_eff = goal_mode if goal_mode is not None \
|
goal_eff = goal_mode if goal_mode is not None \
|
||||||
else (task["goal_mode"] or 0)
|
else (task["goal_mode"] or 0)
|
||||||
@@ -7485,7 +7700,7 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
|
|||||||
sets = ["status = 'queued'", "resume_message = ?",
|
sets = ["status = 'queued'", "resume_message = ?",
|
||||||
"result_text = NULL", "error_text = NULL",
|
"result_text = NULL", "error_text = NULL",
|
||||||
"actual_model = NULL", "updated_at = ?"]
|
"actual_model = NULL", "updated_at = ?"]
|
||||||
vals = [message, time.time()]
|
vals = [resume_text, time.time()]
|
||||||
if model is not None or "model" in body:
|
if model is not None or "model" in body:
|
||||||
sets.append("model = ?")
|
sets.append("model = ?")
|
||||||
vals.append(model)
|
vals.append(model)
|
||||||
@@ -7509,7 +7724,7 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
|
|||||||
vals.append(goal_mode)
|
vals.append(goal_mode)
|
||||||
if goal_mode == 1:
|
if goal_mode == 1:
|
||||||
sets.append("goal = ?")
|
sets.append("goal = ?")
|
||||||
vals.append(message)
|
vals.append(resume_text)
|
||||||
sets.append("verify_command = NULL")
|
sets.append("verify_command = NULL")
|
||||||
sets.append("goal_max_rounds = 0")
|
sets.append("goal_max_rounds = 0")
|
||||||
vals.append(task_id)
|
vals.append(task_id)
|
||||||
@@ -9258,7 +9473,10 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
|
|||||||
"""创建自动化定义:只登记定义与到期状态,不跑模型、
|
"""创建自动化定义:只登记定义与到期状态,不跑模型、
|
||||||
不另起调度器(执行器后续走 claim 门,见 automations
|
不另起调度器(执行器后续走 claim 门,见 automations
|
||||||
模块文档)。body:name、prompt 必填;interval_seconds
|
模块文档)。body:name、prompt 必填;interval_seconds
|
||||||
可选(默认 86400,最小 60);repo_url 可选。
|
可选(默认 86400,最小 60);repo_url 可选;
|
||||||
|
first_run_at 可选(epoch 秒或 ISO8601 字符串,与
|
||||||
|
marker 的 parse_first_run_at 同款解析,过去则建完
|
||||||
|
即到期);run_once 可选(true 时跑过一次后不再到期)。
|
||||||
|
|
||||||
P1 配额门:创建时先过与建任务同一道配额门(美元配额
|
P1 配额门:创建时先过与建任务同一道配额门(美元配额
|
||||||
与无价 token cap,超限 402),避免“定义免费建、到期
|
与无价 token cap,超限 402),避免“定义免费建、到期
|
||||||
@@ -9276,13 +9494,24 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
|
|||||||
interval = body.get("interval_seconds", 86400)
|
interval = body.get("interval_seconds", 86400)
|
||||||
if interval is None:
|
if interval is None:
|
||||||
interval = 86400
|
interval = 86400
|
||||||
|
first_run_at = body.get("first_run_at")
|
||||||
|
if first_run_at is not None:
|
||||||
|
first_run_at = _parse_first_run_at(first_run_at)
|
||||||
|
if first_run_at is None:
|
||||||
|
self._send_json(400, {"error": "invalid first_run_at"})
|
||||||
|
return
|
||||||
|
run_once = body.get("run_once", False)
|
||||||
|
if not isinstance(run_once, bool):
|
||||||
|
self._send_json(400, {"error": "run_once must be boolean"})
|
||||||
|
return
|
||||||
conn = self._db()
|
conn = self._db()
|
||||||
try:
|
try:
|
||||||
try:
|
try:
|
||||||
auto = _auto_create(
|
auto = _auto_create(
|
||||||
conn, project_id, body.get("name"),
|
conn, project_id, body.get("name"),
|
||||||
body.get("prompt"), user_id, interval,
|
body.get("prompt"), user_id, interval,
|
||||||
body.get("repo_url"))
|
body.get("repo_url"),
|
||||||
|
first_run_at=first_run_at, run_once=run_once)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
self._send_json(400, {"error": str(exc)})
|
self._send_json(400, {"error": str(exc)})
|
||||||
return
|
return
|
||||||
@@ -9631,6 +9860,13 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
|
|||||||
if jerr:
|
if jerr:
|
||||||
self._send_json(400, {"error": jerr})
|
self._send_json(400, {"error": jerr})
|
||||||
return
|
return
|
||||||
|
if body.get("first_run_at") is not None and \
|
||||||
|
isinstance(body.get("first_run_at"), str):
|
||||||
|
parsed = _parse_first_run_at(body["first_run_at"])
|
||||||
|
if parsed is None:
|
||||||
|
self._send_json(400, {"error": "invalid first_run_at"})
|
||||||
|
return
|
||||||
|
body = dict(body, first_run_at=parsed)
|
||||||
conn = self._db()
|
conn = self._db()
|
||||||
try:
|
try:
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -0,0 +1,413 @@
|
|||||||
|
"""主线 /goal 命令单测:发给主线的一句话自动建 goal。
|
||||||
|
|
||||||
|
不跑真实模型回合、不联网:server 侧走本地临时端口 + 临时
|
||||||
|
SQLite;worker 侧注入假 runner。覆盖:解析、followup 的
|
||||||
|
queued/running/已终态三态、已是 goal、空目标、plan 冲突、
|
||||||
|
普通消息不受影响、/continue 与 /thread/message 同款转换、
|
||||||
|
worker 在轮次边界把 running 主线转成 goal 并在下次认领走
|
||||||
|
goal-resume。
|
||||||
|
"""
|
||||||
|
|
||||||
|
import http.client
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
try:
|
||||||
|
from cloud.auth import AuthStore
|
||||||
|
from cloud.goal_command import parse_goal_command
|
||||||
|
from cloud.peer_messages import ensure_followups_table
|
||||||
|
from cloud.server import create_server, init_db
|
||||||
|
from cloud.worker import Worker
|
||||||
|
except ImportError: # discover -s cloud/tests 时把项目根目录补进 sys.path
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
||||||
|
from cloud.auth import AuthStore
|
||||||
|
from cloud.goal_command import parse_goal_command
|
||||||
|
from cloud.peer_messages import ensure_followups_table
|
||||||
|
from cloud.server import create_server, init_db
|
||||||
|
from cloud.worker import Worker
|
||||||
|
|
||||||
|
|
||||||
|
class ParserTestCase(unittest.TestCase):
|
||||||
|
def test_user_sentence(self):
|
||||||
|
self.assertEqual(
|
||||||
|
parse_goal_command("请在这条主线开启一个持久 /goal,目标是收xxx"),
|
||||||
|
"收xxx")
|
||||||
|
self.assertEqual(
|
||||||
|
parse_goal_command("请在这条主线开启一个持久 /goal,目标是收xxx。"),
|
||||||
|
"收xxx")
|
||||||
|
|
||||||
|
def test_slash_forms(self):
|
||||||
|
self.assertEqual(parse_goal_command("/goal 收xxx"), "收xxx")
|
||||||
|
self.assertEqual(parse_goal_command("/goal,目标是收xxx"), "收xxx")
|
||||||
|
self.assertEqual(parse_goal_command("/goal: 收xxx"), "收xxx")
|
||||||
|
self.assertEqual(parse_goal_command("请开启/goal收xxx"), "收xxx")
|
||||||
|
self.assertEqual(parse_goal_command("/goal"), "")
|
||||||
|
|
||||||
|
def test_discussion_is_not_a_command(self):
|
||||||
|
self.assertIsNone(parse_goal_command("讨论/goal的用法"))
|
||||||
|
self.assertIsNone(parse_goal_command("请帮我完成目标收xxx"))
|
||||||
|
self.assertIsNone(parse_goal_command("hello world"))
|
||||||
|
|
||||||
|
def test_bare_goal_without_slash(self):
|
||||||
|
# 不用斜杠:用户单发 goal 也触发同款目标。
|
||||||
|
self.assertEqual(parse_goal_command("goal 收xxx"), "收xxx")
|
||||||
|
self.assertEqual(parse_goal_command("goal,目标是收xxx"), "收xxx")
|
||||||
|
self.assertEqual(parse_goal_command("goal目标是收xxx"), "收xxx")
|
||||||
|
self.assertEqual(parse_goal_command("goal收xxx"), "收xxx")
|
||||||
|
self.assertEqual(parse_goal_command("发goal 收xxx"), "收xxx")
|
||||||
|
self.assertEqual(parse_goal_command("goal"), "")
|
||||||
|
|
||||||
|
def test_bare_goal_discussion_still_not_a_command(self):
|
||||||
|
self.assertIsNone(parse_goal_command("goal的用法"))
|
||||||
|
self.assertIsNone(parse_goal_command("goal是什么意思"))
|
||||||
|
self.assertIsNone(parse_goal_command("讨论goal的用法"))
|
||||||
|
self.assertIsNone(parse_goal_command("请问goal是什么意思"))
|
||||||
|
self.assertIsNone(parse_goal_command("/goal的用法"))
|
||||||
|
|
||||||
|
|
||||||
|
class ServerTestCase(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.server = create_server(self.db_path, "127.0.0.1", 0)
|
||||||
|
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)
|
||||||
|
|
||||||
|
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 key:
|
||||||
|
hdrs["X-API-Key"] = key
|
||||||
|
if body is not None:
|
||||||
|
payload = json.dumps(body)
|
||||||
|
hdrs["Content-Type"] = "application/json"
|
||||||
|
conn.request(method, path, body=payload, headers=hdrs)
|
||||||
|
resp = conn.getresponse()
|
||||||
|
raw = resp.read()
|
||||||
|
conn.close()
|
||||||
|
try:
|
||||||
|
return resp.status, json.loads(raw.decode("utf-8"))
|
||||||
|
except ValueError:
|
||||||
|
return resp.status, raw.decode("utf-8", errors="replace")
|
||||||
|
|
||||||
|
def _create(self, **extra):
|
||||||
|
body = {"project_id": self.project["id"], "prompt": "do work"}
|
||||||
|
body.update(extra)
|
||||||
|
status, task = self._req("POST", "/tasks", body, self.owner_key)
|
||||||
|
self.assertEqual(status, 201, task)
|
||||||
|
return task
|
||||||
|
|
||||||
|
def _set_status(self, task_id, status):
|
||||||
|
conn = sqlite3.connect(self.db_path)
|
||||||
|
try:
|
||||||
|
conn.execute("UPDATE tasks SET status = ? WHERE id = ?",
|
||||||
|
(status, task_id))
|
||||||
|
conn.commit()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def _get_task(self, task_id):
|
||||||
|
status, task = self._req("GET", "/tasks/" + task_id, None,
|
||||||
|
self.owner_key)
|
||||||
|
self.assertEqual(status, 200, task)
|
||||||
|
return task
|
||||||
|
|
||||||
|
def test_followup_queued_converts_in_place(self):
|
||||||
|
task = self._create()
|
||||||
|
status, body = self._req(
|
||||||
|
"POST", "/tasks/%s/followups" % task["id"],
|
||||||
|
{"body": "请在这条主线开启一个持久 /goal,目标是收xxx"},
|
||||||
|
self.owner_key)
|
||||||
|
self.assertEqual(status, 200, body)
|
||||||
|
self.assertEqual(body["delivery"], "goal_converted")
|
||||||
|
self.assertEqual(body["goal"], "收xxx")
|
||||||
|
after = self._get_task(task["id"])
|
||||||
|
self.assertEqual(after["status"], "queued")
|
||||||
|
self.assertEqual(after["goal_mode"], 1)
|
||||||
|
self.assertEqual(after["goal"], "收xxx")
|
||||||
|
self.assertIsNone(after["verify_command"])
|
||||||
|
self.assertEqual(after["goal_max_rounds"], 0)
|
||||||
|
|
||||||
|
def test_followup_running_queues_for_worker(self):
|
||||||
|
task = self._create()
|
||||||
|
self._set_status(task["id"], "running")
|
||||||
|
status, body = self._req(
|
||||||
|
"POST", "/tasks/%s/followups" % task["id"],
|
||||||
|
{"body": "/goal 收xxx"}, self.owner_key)
|
||||||
|
self.assertEqual(status, 201, body)
|
||||||
|
self.assertEqual(body["delivery"], "goal_queued")
|
||||||
|
self.assertEqual(body["goal"], "收xxx")
|
||||||
|
# running 主线本轮不被打断:DB 里仍是 running,未转 goal。
|
||||||
|
after = self._get_task(task["id"])
|
||||||
|
self.assertEqual(after["status"], "running")
|
||||||
|
self.assertEqual(after["goal_mode"], 0)
|
||||||
|
|
||||||
|
def test_followup_terminal_revives_as_goal(self):
|
||||||
|
task = self._create()
|
||||||
|
self._set_status(task["id"], "completed")
|
||||||
|
status, body = self._req(
|
||||||
|
"POST", "/tasks/%s/followups" % task["id"],
|
||||||
|
{"body": "/goal 收xxx"}, self.owner_key)
|
||||||
|
self.assertEqual(status, 200, body)
|
||||||
|
self.assertEqual(body["delivery"], "goal_revived")
|
||||||
|
after = self._get_task(task["id"])
|
||||||
|
self.assertEqual(after["status"], "queued")
|
||||||
|
self.assertEqual(after["goal_mode"], 1)
|
||||||
|
self.assertEqual(after["goal"], "收xxx")
|
||||||
|
self.assertEqual(after["resume_message"], "收xxx")
|
||||||
|
|
||||||
|
def test_followup_already_goal_409(self):
|
||||||
|
task = self._create(goal_mode=1)
|
||||||
|
status, body = self._req(
|
||||||
|
"POST", "/tasks/%s/followups" % task["id"],
|
||||||
|
{"body": "/goal 收yyy"}, self.owner_key)
|
||||||
|
self.assertEqual(status, 409, body)
|
||||||
|
after = self._get_task(task["id"])
|
||||||
|
self.assertEqual(after["goal"], "do work")
|
||||||
|
|
||||||
|
def test_followup_empty_goal_400(self):
|
||||||
|
task = self._create()
|
||||||
|
status, body = self._req(
|
||||||
|
"POST", "/tasks/%s/followups" % task["id"],
|
||||||
|
{"body": "/goal"}, self.owner_key)
|
||||||
|
self.assertEqual(status, 400, body)
|
||||||
|
|
||||||
|
def test_followup_plan_conflict_400(self):
|
||||||
|
task = self._create(plan=1)
|
||||||
|
status, body = self._req(
|
||||||
|
"POST", "/tasks/%s/followups" % task["id"],
|
||||||
|
{"body": "/goal 收xxx"}, self.owner_key)
|
||||||
|
self.assertEqual(status, 400, body)
|
||||||
|
|
||||||
|
def test_followup_merged_409(self):
|
||||||
|
task = self._create()
|
||||||
|
self._set_status(task["id"], "merged")
|
||||||
|
status, body = self._req(
|
||||||
|
"POST", "/tasks/%s/followups" % task["id"],
|
||||||
|
{"body": "/goal 收xxx"}, self.owner_key)
|
||||||
|
self.assertEqual(status, 409, body)
|
||||||
|
|
||||||
|
def test_ordinary_followup_unchanged(self):
|
||||||
|
task = self._create()
|
||||||
|
status, body = self._req(
|
||||||
|
"POST", "/tasks/%s/followups" % task["id"],
|
||||||
|
{"body": "继续做"}, self.owner_key)
|
||||||
|
self.assertEqual(status, 201, body)
|
||||||
|
self.assertNotIn("goal", body)
|
||||||
|
after = self._get_task(task["id"])
|
||||||
|
self.assertEqual(after["goal_mode"], 0)
|
||||||
|
|
||||||
|
def test_create_task_prompt_goal_command(self):
|
||||||
|
task = self._create(prompt="/goal 收xxx")
|
||||||
|
self.assertEqual(task["goal_mode"], 1)
|
||||||
|
self.assertEqual(task["goal"], "收xxx")
|
||||||
|
self.assertEqual(task["prompt"], "收xxx")
|
||||||
|
self.assertIsNone(task["verify_command"])
|
||||||
|
self.assertEqual(task["goal_max_rounds"], 0)
|
||||||
|
|
||||||
|
def test_create_task_prompt_goal_user_sentence(self):
|
||||||
|
task = self._create(
|
||||||
|
prompt="请在这条主线开启一个持久 /goal,目标是收xxx")
|
||||||
|
self.assertEqual(task["goal_mode"], 1)
|
||||||
|
self.assertEqual(task["goal"], "收xxx")
|
||||||
|
self.assertEqual(task["prompt"], "收xxx")
|
||||||
|
|
||||||
|
def test_create_task_prompt_goal_empty_400(self):
|
||||||
|
status, body = self._req(
|
||||||
|
"POST", "/tasks",
|
||||||
|
{"project_id": self.project["id"], "prompt": "/goal"},
|
||||||
|
self.owner_key)
|
||||||
|
self.assertEqual(status, 400, body)
|
||||||
|
|
||||||
|
def test_create_task_prompt_goal_conflicts_400(self):
|
||||||
|
status, _body = self._req(
|
||||||
|
"POST", "/tasks",
|
||||||
|
{"project_id": self.project["id"], "prompt": "/goal 收xxx",
|
||||||
|
"plan": 1},
|
||||||
|
self.owner_key)
|
||||||
|
self.assertEqual(status, 400)
|
||||||
|
status, _body = self._req(
|
||||||
|
"POST", "/tasks",
|
||||||
|
{"project_id": self.project["id"], "prompt": "/goal 收xxx",
|
||||||
|
"verify_command": "true"},
|
||||||
|
self.owner_key)
|
||||||
|
self.assertEqual(status, 400)
|
||||||
|
|
||||||
|
def test_create_task_discussion_prompt_unchanged(self):
|
||||||
|
task = self._create(prompt="讨论/goal的用法")
|
||||||
|
self.assertEqual(task["goal_mode"], 0)
|
||||||
|
|
||||||
|
def test_followup_bare_goal_converts(self):
|
||||||
|
task = self._create()
|
||||||
|
status, body = self._req(
|
||||||
|
"POST", "/tasks/%s/followups" % task["id"],
|
||||||
|
{"body": "goal 收xxx"}, self.owner_key)
|
||||||
|
self.assertEqual(status, 200, body)
|
||||||
|
self.assertEqual(body["delivery"], "goal_converted")
|
||||||
|
self.assertEqual(body["goal"], "收xxx")
|
||||||
|
|
||||||
|
def test_create_task_bare_goal(self):
|
||||||
|
task = self._create(prompt="goal 收xxx")
|
||||||
|
self.assertEqual(task["goal_mode"], 1)
|
||||||
|
self.assertEqual(task["goal"], "收xxx")
|
||||||
|
|
||||||
|
def test_continue_goal_command(self):
|
||||||
|
task = self._create()
|
||||||
|
self._set_status(task["id"], "completed")
|
||||||
|
status, body = self._req(
|
||||||
|
"POST", "/tasks/%s/continue" % task["id"],
|
||||||
|
{"message": "请开启/goal,目标是收xxx"}, self.owner_key)
|
||||||
|
self.assertEqual(status, 200, body)
|
||||||
|
self.assertEqual(body["goal_mode"], 1)
|
||||||
|
self.assertEqual(body["goal"], "收xxx")
|
||||||
|
self.assertEqual(body["resume_message"], "收xxx")
|
||||||
|
|
||||||
|
def test_thread_message_terminal_goal(self):
|
||||||
|
task = self._create()
|
||||||
|
self._set_status(task["id"], "failed")
|
||||||
|
status, body = self._req(
|
||||||
|
"POST", "/tasks/%s/thread/message" % task["id"],
|
||||||
|
{"body": "/goal 收xxx"}, self.owner_key)
|
||||||
|
self.assertEqual(status, 201, body)
|
||||||
|
self.assertEqual(body["delivery"], "goal_revived")
|
||||||
|
after = self._get_task(task["id"])
|
||||||
|
self.assertEqual(after["goal_mode"], 1)
|
||||||
|
self.assertEqual(after["goal"], "收xxx")
|
||||||
|
|
||||||
|
|
||||||
|
class WorkerTestCase(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.tmp = tempfile.TemporaryDirectory()
|
||||||
|
self.db_path = os.path.join(self.tmp.name, "control.db")
|
||||||
|
init_db(self.db_path)
|
||||||
|
self.env = {"XCM_API_KEY": "test-placeholder"}
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self.tmp.cleanup()
|
||||||
|
|
||||||
|
def _insert_task(self, task_id, **extra):
|
||||||
|
now = time.time()
|
||||||
|
cols = ["id", "project_id", "prompt", "status",
|
||||||
|
"idempotency_key", "depends_on", "priority",
|
||||||
|
"created_by", "created_at", "updated_at",
|
||||||
|
"last_heartbeat"]
|
||||||
|
vals = [task_id, "p1", "do work", "queued", None, "[]", 0,
|
||||||
|
"tester", now, now, None]
|
||||||
|
for k, v in extra.items():
|
||||||
|
cols.append(k)
|
||||||
|
vals.append(v)
|
||||||
|
conn = sqlite3.connect(self.db_path)
|
||||||
|
try:
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO tasks(%s) VALUES(%s)"
|
||||||
|
% (", ".join(cols), ", ".join("?" * len(cols))), vals)
|
||||||
|
conn.commit()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def _insert_followup(self, task_id, body):
|
||||||
|
conn = sqlite3.connect(self.db_path)
|
||||||
|
try:
|
||||||
|
ensure_followups_table(conn)
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO task_followups(id, task_id, project_id,"
|
||||||
|
" body, mode, status, created_by, created_at,"
|
||||||
|
" updated_at) VALUES(?, ?, ?, ?, 'queued', 'pending',"
|
||||||
|
" ?, ?, ?)",
|
||||||
|
("fu1", task_id, "p1", body, "tester",
|
||||||
|
time.time(), time.time()))
|
||||||
|
conn.commit()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def _row(self, task_id):
|
||||||
|
conn = sqlite3.connect(self.db_path)
|
||||||
|
try:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT status, goal_mode, goal, resume_message"
|
||||||
|
" FROM tasks WHERE id = ?", (task_id,)).fetchone()
|
||||||
|
fu = conn.execute(
|
||||||
|
"SELECT status FROM task_followups WHERE id = 'fu1'"
|
||||||
|
).fetchone()
|
||||||
|
return row, (fu[0] if fu else None)
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def test_running_task_converts_at_turn_boundary_then_goal_runs(self):
|
||||||
|
self._insert_task("T")
|
||||||
|
self._insert_followup("T", "/goal 收xxx")
|
||||||
|
seen = []
|
||||||
|
|
||||||
|
def fake_dispatch(cmd):
|
||||||
|
seen.append(list(cmd))
|
||||||
|
name = cmd[cmd.index("--name") + 1]
|
||||||
|
return SimpleNamespace(
|
||||||
|
returncode=0,
|
||||||
|
stdout=json.dumps({"task": name, "status": "completed",
|
||||||
|
"text": "done"}),
|
||||||
|
stderr="")
|
||||||
|
|
||||||
|
worker = Worker(self.db_path, "/fake/host.py",
|
||||||
|
runner=fake_dispatch, env=self.env)
|
||||||
|
result = worker.run_one()
|
||||||
|
self.assertEqual(result["status"], "queued")
|
||||||
|
(status, goal_mode, goal, resume), fu_status = self._row("T")
|
||||||
|
self.assertEqual(status, "queued")
|
||||||
|
self.assertEqual(goal_mode, 1)
|
||||||
|
self.assertEqual(goal, "收xxx")
|
||||||
|
self.assertEqual(resume, "收xxx")
|
||||||
|
self.assertEqual(fu_status, "done")
|
||||||
|
self.assertTrue(any("dispatch" in c for c in seen))
|
||||||
|
self.assertFalse(any("goal" in c for c in seen))
|
||||||
|
|
||||||
|
# 下次认领走 goal-resume:goal 循环宣告达成即完成。
|
||||||
|
def fake_goal(cmd):
|
||||||
|
seen.append(list(cmd))
|
||||||
|
# goal-resume 的命令元素是 "goal-resume",不是 "goal",
|
||||||
|
# 用子串判,别用元素相等判。
|
||||||
|
if any("goal" in part for part in cmd):
|
||||||
|
return SimpleNamespace(
|
||||||
|
returncode=0,
|
||||||
|
stdout="round 1: turn=completed verify_rc=-"
|
||||||
|
" verify_out=\n"
|
||||||
|
"goal met after rounds\n",
|
||||||
|
stderr="")
|
||||||
|
raise AssertionError("第二轮不应走 dispatch: %s" % cmd)
|
||||||
|
|
||||||
|
worker2 = Worker(self.db_path, "/fake/host.py",
|
||||||
|
runner=fake_goal, env=self.env)
|
||||||
|
result2 = worker2.run_one()
|
||||||
|
self.assertEqual(result2["status"], "completed")
|
||||||
|
self.assertTrue(any(any("goal" in part for part in c)
|
||||||
|
for c in seen))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,334 @@
|
|||||||
|
"""会话内 goal / 自动化标记工具单测:LLM 自己判断意图那条路。
|
||||||
|
|
||||||
|
不跑真实模型、不联网:
|
||||||
|
- 解析层是纯函数(goal-convert、automation-create、
|
||||||
|
first_run_at 的 ISO / 无时区 / 毫秒解析与非法跳过);
|
||||||
|
- automations 层用内存 SQLite,测 first_run_at 到期与
|
||||||
|
run_once 跑过后不再到期(Python 口径与 SQL due/claim
|
||||||
|
口径一致);
|
||||||
|
- worker 投递用 init_db 建的临时库 + 最小任务行,直接调
|
||||||
|
_deliver_marker_tools,不走 run_one 全量流程。
|
||||||
|
|
||||||
|
注意:本文件不测“LLM 真的会写块”——那是模型行为,
|
||||||
|
且本批刻意不向 LLM 自动注入工具指南(避免 prompt
|
||||||
|
臃肿);这里测的是块写出来后系统一定按约定执行,且
|
||||||
|
非法块一定不静默变调度。工具格式见 docs/marker-tools.md,
|
||||||
|
需要时由用户在会话里手动告诉 agent。
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
try:
|
||||||
|
from cloud.automations import (
|
||||||
|
claim_due_automation,
|
||||||
|
create_automation,
|
||||||
|
due_automations,
|
||||||
|
get_automation,
|
||||||
|
update_automation,
|
||||||
|
)
|
||||||
|
from cloud.marker_tools import (
|
||||||
|
extract_automation_creates,
|
||||||
|
extract_goal_converts,
|
||||||
|
parse_first_run_at,
|
||||||
|
)
|
||||||
|
from cloud.server import init_db
|
||||||
|
from cloud.worker import Worker
|
||||||
|
except ImportError: # discover -s cloud/tests 时把项目根目录补进 sys.path
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
||||||
|
from cloud.automations import (
|
||||||
|
claim_due_automation,
|
||||||
|
create_automation,
|
||||||
|
due_automations,
|
||||||
|
get_automation,
|
||||||
|
update_automation,
|
||||||
|
)
|
||||||
|
from cloud.marker_tools import (
|
||||||
|
extract_automation_creates,
|
||||||
|
extract_goal_converts,
|
||||||
|
parse_first_run_at,
|
||||||
|
)
|
||||||
|
from cloud.server import init_db
|
||||||
|
from cloud.worker import Worker
|
||||||
|
|
||||||
|
FAKE_ENV = {"XCM_API_KEY": "test-placeholder"}
|
||||||
|
|
||||||
|
|
||||||
|
class ParseTestCase(unittest.TestCase):
|
||||||
|
def test_goal_convert_basic_and_alias(self):
|
||||||
|
text = (
|
||||||
|
"```goal-convert\n{\"goal\": \"收 xxx\"}\n```\n"
|
||||||
|
"```goal_convert\n{\"goal\": \"收 yyy\"}\n```\n")
|
||||||
|
converts = extract_goal_converts(text)
|
||||||
|
# 每轮最多 1 块,第二个被忽略
|
||||||
|
self.assertEqual([c["goal"] for c in converts], ["收 xxx"])
|
||||||
|
|
||||||
|
def test_goal_convert_invalid_skipped(self):
|
||||||
|
text = (
|
||||||
|
"```goal-convert\nnot json\n```\n"
|
||||||
|
"```goal-convert\n{\"goal\": \" \"}\n```\n"
|
||||||
|
"```goal-convert\n{\"goal\": \"ok\"}\n```\n")
|
||||||
|
self.assertEqual(
|
||||||
|
[c["goal"] for c in extract_goal_converts(text)], ["ok"])
|
||||||
|
|
||||||
|
def test_first_run_at_formats(self):
|
||||||
|
iso = parse_first_run_at("2026-10-30T09:00:00+08:00")
|
||||||
|
naive = parse_first_run_at("2026-10-30 09:00")
|
||||||
|
self.assertEqual(iso, naive)
|
||||||
|
self.assertEqual(parse_first_run_at(iso), iso)
|
||||||
|
self.assertEqual(parse_first_run_at(int(iso * 1000)), iso)
|
||||||
|
self.assertIsNone(parse_first_run_at("not a time"))
|
||||||
|
self.assertIsNone(parse_first_run_at("2026-13-40 09:00"))
|
||||||
|
self.assertIsNone(parse_first_run_at(True))
|
||||||
|
self.assertIsNone(parse_first_run_at(-1))
|
||||||
|
|
||||||
|
def test_automation_create_basic(self):
|
||||||
|
text = (
|
||||||
|
"```automation-create\n"
|
||||||
|
"{\"prompt\": \"看 yyy\", \"interval_seconds\": 86400,"
|
||||||
|
" \"first_run_at\": \"2026-10-30T09:00:00+08:00\","
|
||||||
|
" \"once\": true, \"bind\": \"heartbeat\"}\n```\n")
|
||||||
|
blocks = extract_automation_creates(text)
|
||||||
|
self.assertEqual(len(blocks), 1)
|
||||||
|
block = blocks[0]
|
||||||
|
self.assertEqual(block["prompt"], "看 yyy")
|
||||||
|
self.assertEqual(block["interval_seconds"], 86400)
|
||||||
|
self.assertEqual(
|
||||||
|
block["first_run_at"],
|
||||||
|
parse_first_run_at("2026-10-30T09:00:00+08:00"))
|
||||||
|
self.assertTrue(block["once"])
|
||||||
|
self.assertEqual(block["bind"], "heartbeat")
|
||||||
|
self.assertIsNone(block["name"])
|
||||||
|
|
||||||
|
def test_automation_create_invalid_interval_and_bind_skipped(self):
|
||||||
|
text = (
|
||||||
|
"```automation-create\n{\"prompt\": \"x\","
|
||||||
|
" \"interval_seconds\": 30}\n```\n"
|
||||||
|
"```automation-create\n{\"prompt\": \"y\","
|
||||||
|
" \"bind\": \"weird\"}\n```\n"
|
||||||
|
"```automation-create\n{\"prompt\": \"good\"}\n```\n")
|
||||||
|
blocks = extract_automation_creates(text)
|
||||||
|
self.assertEqual([b["prompt"] for b in blocks], ["good"])
|
||||||
|
self.assertEqual(blocks[0]["interval_seconds"], 86400)
|
||||||
|
|
||||||
|
|
||||||
|
class AutomationsTestCase(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.conn = sqlite3.connect(":memory:")
|
||||||
|
self.conn.row_factory = sqlite3.Row
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self.conn.close()
|
||||||
|
|
||||||
|
def test_first_run_at_not_due_early_then_due(self):
|
||||||
|
now = 1700000000.0
|
||||||
|
auto = create_automation(
|
||||||
|
self.conn, "p", "future", "看 x", "u", now=now,
|
||||||
|
first_run_at=now + 3600)
|
||||||
|
self.assertFalse(auto["due"])
|
||||||
|
self.assertEqual(auto["next_run_at"], now + 3600)
|
||||||
|
self.assertEqual(due_automations(self.conn, "p", now=now), [])
|
||||||
|
self.assertFalse(
|
||||||
|
claim_due_automation(self.conn, auto["id"], now=now))
|
||||||
|
self.assertEqual(
|
||||||
|
[a["name"] for a in due_automations(
|
||||||
|
self.conn, "p", now=now + 3600)],
|
||||||
|
["future"])
|
||||||
|
self.assertTrue(
|
||||||
|
claim_due_automation(self.conn, auto["id"], now=now + 3600))
|
||||||
|
|
||||||
|
def test_run_once_never_due_after_first_run(self):
|
||||||
|
now = 1700000000.0
|
||||||
|
auto = create_automation(
|
||||||
|
self.conn, "p", "once", "看 y", "u", now=now,
|
||||||
|
first_run_at=now + 10, run_once=True)
|
||||||
|
self.assertTrue(
|
||||||
|
claim_due_automation(self.conn, auto["id"], now=now + 20))
|
||||||
|
after = get_automation(self.conn, auto["id"])
|
||||||
|
self.assertFalse(after["due"])
|
||||||
|
self.assertIsNone(after["next_run_at"])
|
||||||
|
self.assertEqual(
|
||||||
|
due_automations(self.conn, "p", now=now + 90000), [])
|
||||||
|
|
||||||
|
def test_update_first_run_at_and_run_once(self):
|
||||||
|
now = 1700000000.0
|
||||||
|
auto = create_automation(
|
||||||
|
self.conn, "p", "upd", "看 z", "u", now=now)
|
||||||
|
ok, updated = update_automation(
|
||||||
|
self.conn, auto["id"],
|
||||||
|
{"first_run_at": now + 7200, "run_once": True}, now=now)
|
||||||
|
self.assertTrue(ok)
|
||||||
|
self.assertEqual(updated["first_run_at"], now + 7200)
|
||||||
|
self.assertTrue(updated["run_once"])
|
||||||
|
self.assertFalse(updated["due"])
|
||||||
|
|
||||||
|
|
||||||
|
class WorkerDeliverTestCase(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.tmp = tempfile.TemporaryDirectory()
|
||||||
|
self.db_path = os.path.join(self.tmp.name, "control.db")
|
||||||
|
init_db(self.db_path)
|
||||||
|
self._insert("PM", status="running", created_by="tester")
|
||||||
|
self._member("tester", "maintainer")
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self.tmp.cleanup()
|
||||||
|
|
||||||
|
def _insert(self, task_id, status="queued", created_by="tester",
|
||||||
|
project_id="p1"):
|
||||||
|
now = time.time()
|
||||||
|
conn = sqlite3.connect(self.db_path)
|
||||||
|
try:
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO tasks(id, project_id, prompt, status,"
|
||||||
|
" idempotency_key, depends_on, priority, created_by,"
|
||||||
|
" created_at, updated_at, last_heartbeat)"
|
||||||
|
" VALUES(?, ?, ?, ?, NULL, '[]', 0, ?, ?, ?, NULL)",
|
||||||
|
(task_id, project_id, "do work", status, created_by,
|
||||||
|
now, now))
|
||||||
|
conn.commit()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def _member(self, user_id, role):
|
||||||
|
conn = sqlite3.connect(self.db_path)
|
||||||
|
try:
|
||||||
|
conn.execute(
|
||||||
|
"CREATE TABLE IF NOT EXISTS users(id TEXT PRIMARY KEY,"
|
||||||
|
" username TEXT UNIQUE, api_key_hash TEXT,"
|
||||||
|
" created_at REAL)")
|
||||||
|
conn.execute(
|
||||||
|
"CREATE TABLE IF NOT EXISTS project_members("
|
||||||
|
"project_id TEXT, user_id TEXT, role TEXT,"
|
||||||
|
" PRIMARY KEY(project_id, user_id))")
|
||||||
|
conn.execute(
|
||||||
|
"INSERT OR REPLACE INTO project_members"
|
||||||
|
"(project_id, user_id, role) VALUES('p1', ?, ?)",
|
||||||
|
(user_id, role))
|
||||||
|
conn.execute(
|
||||||
|
"INSERT OR IGNORE INTO users(id, username, api_key_hash,"
|
||||||
|
" created_at) VALUES(?, ?, '', 0)", (user_id, user_id))
|
||||||
|
conn.commit()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def _worker(self):
|
||||||
|
return Worker(self.db_path, "/fake/host.py", env=FAKE_ENV)
|
||||||
|
|
||||||
|
def _followup_bodies(self):
|
||||||
|
conn = sqlite3.connect(self.db_path)
|
||||||
|
try:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT body FROM task_followups"
|
||||||
|
" WHERE task_id = 'PM' ORDER BY created_at").fetchall()
|
||||||
|
return [row[0] for row in rows]
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def _automations(self):
|
||||||
|
conn = sqlite3.connect(self.db_path)
|
||||||
|
try:
|
||||||
|
try:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT name, prompt, interval_seconds, run_once"
|
||||||
|
" FROM automations").fetchall()
|
||||||
|
except sqlite3.OperationalError:
|
||||||
|
# 一个自动化都没建过时表还不存在,不是失败
|
||||||
|
return []
|
||||||
|
return [tuple(row) for row in rows]
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def test_deliver_automation_create_and_bind(self):
|
||||||
|
worker = self._worker()
|
||||||
|
sender = worker.get_task("PM")
|
||||||
|
future = time.time() + 3600
|
||||||
|
worker._deliver_marker_tools(sender, {
|
||||||
|
"status": "completed",
|
||||||
|
"task_creates": [], "task_polls": [], "wait_threads": [],
|
||||||
|
"task_cancels": [], "task_retries": [],
|
||||||
|
"goal_converts": [],
|
||||||
|
"automation_creates": [{
|
||||||
|
"name": None, "prompt": "看 yyy",
|
||||||
|
"interval_seconds": 86400, "first_run_at": future,
|
||||||
|
"once": False, "bind": "heartbeat",
|
||||||
|
"repo_url": None}],
|
||||||
|
})
|
||||||
|
autos = self._automations()
|
||||||
|
self.assertEqual(len(autos), 1)
|
||||||
|
self.assertEqual(autos[0][1], "看 yyy")
|
||||||
|
conn = sqlite3.connect(self.db_path)
|
||||||
|
try:
|
||||||
|
binding = conn.execute(
|
||||||
|
"SELECT task_id, mode FROM automation_bindings"
|
||||||
|
).fetchone()
|
||||||
|
bodies = self._followup_bodies()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
self.assertEqual(tuple(binding), ("PM", "heartbeat"))
|
||||||
|
self.assertTrue(any("已创建自动化" in b for b in bodies))
|
||||||
|
|
||||||
|
def test_deliver_automation_past_first_run_rejected(self):
|
||||||
|
worker = self._worker()
|
||||||
|
sender = worker.get_task("PM")
|
||||||
|
worker._deliver_marker_tools(sender, {
|
||||||
|
"status": "completed",
|
||||||
|
"task_creates": [], "task_polls": [], "wait_threads": [],
|
||||||
|
"task_cancels": [], "task_retries": [],
|
||||||
|
"goal_converts": [],
|
||||||
|
"automation_creates": [{
|
||||||
|
"name": "old", "prompt": "看旧",
|
||||||
|
"interval_seconds": 86400,
|
||||||
|
"first_run_at": time.time() - 3600,
|
||||||
|
"once": False, "bind": None, "repo_url": None}],
|
||||||
|
})
|
||||||
|
self.assertEqual(self._automations(), [])
|
||||||
|
bodies = self._followup_bodies()
|
||||||
|
self.assertTrue(any("已过去" in b for b in bodies))
|
||||||
|
|
||||||
|
def test_deliver_goal_convert_queues_after_report(self):
|
||||||
|
worker = self._worker()
|
||||||
|
sender = worker.get_task("PM")
|
||||||
|
worker._deliver_marker_tools(sender, {
|
||||||
|
"status": "completed",
|
||||||
|
"task_creates": [], "task_polls": [], "wait_threads": [],
|
||||||
|
"task_cancels": [], "task_retries": [],
|
||||||
|
"goal_converts": [{"goal": "收 xxx"}],
|
||||||
|
"automation_creates": [{
|
||||||
|
"name": "daily", "prompt": "看 yyy",
|
||||||
|
"interval_seconds": 86400, "first_run_at": None,
|
||||||
|
"once": False, "bind": None, "repo_url": None}],
|
||||||
|
})
|
||||||
|
bodies = self._followup_bodies()
|
||||||
|
# 报告先、/goal 后:排空先跑报告轮、再转 goal
|
||||||
|
self.assertTrue(any("已创建自动化" in b for b in bodies))
|
||||||
|
self.assertEqual(bodies[-1], "/goal 收 xxx")
|
||||||
|
self.assertEqual(len(self._automations()), 1)
|
||||||
|
|
||||||
|
def test_deliver_goal_task_ignored(self):
|
||||||
|
conn = sqlite3.connect(self.db_path)
|
||||||
|
try:
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE tasks SET goal_mode = 1 WHERE id = 'PM'")
|
||||||
|
conn.commit()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
worker = self._worker()
|
||||||
|
sender = worker.get_task("PM")
|
||||||
|
worker._deliver_marker_tools(sender, {
|
||||||
|
"status": "completed",
|
||||||
|
"task_creates": [], "task_polls": [], "wait_threads": [],
|
||||||
|
"task_cancels": [], "task_retries": [],
|
||||||
|
"goal_converts": [{"goal": "收 xxx"}],
|
||||||
|
"automation_creates": [],
|
||||||
|
})
|
||||||
|
self.assertEqual(self._followup_bodies(), [])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
+193
-15
@@ -144,11 +144,14 @@ from cloud.coordinator import nonterminal_children as _coord_nonterminal
|
|||||||
from cloud.cost import CostStore, estimate_tokens, price_usd_micros, \
|
from cloud.cost import CostStore, estimate_tokens, price_usd_micros, \
|
||||||
sum_usage, unpriced_token_cap
|
sum_usage, unpriced_token_cap
|
||||||
from cloud.db import connect as _db_connect
|
from cloud.db import connect as _db_connect
|
||||||
|
from cloud.goal_command import parse_goal_command as _parse_goal_command
|
||||||
from cloud.logging_config import get_logger, log_event, setup_logging
|
from cloud.logging_config import get_logger, log_event, setup_logging
|
||||||
from cloud.models import ModelRouter, is_fallbackable, record_fallback
|
from cloud.models import ModelRouter, is_fallbackable, record_fallback
|
||||||
from cloud.auto_memory import AutoMemoryStore
|
from cloud.auto_memory import AutoMemoryStore
|
||||||
from cloud.auto_memory import build_injection_prefix as _auto_prefix
|
from cloud.auto_memory import build_injection_prefix as _auto_prefix
|
||||||
from cloud.auto_memory import process_due_tasks as _auto_process_due
|
from cloud.auto_memory import process_due_tasks as _auto_process_due
|
||||||
|
from cloud.automations import create_automation as _auto_create
|
||||||
|
from cloud.lifecycle import bind_automation as _life_bind
|
||||||
from cloud.marker_tools import ancestry_relation as _marker_ancestry
|
from cloud.marker_tools import ancestry_relation as _marker_ancestry
|
||||||
from cloud.marker_tools import check_control_gate as _marker_gate
|
from cloud.marker_tools import check_control_gate as _marker_gate
|
||||||
from cloud.marker_tools import count_direct_spawn_children as _marker_count_direct
|
from cloud.marker_tools import count_direct_spawn_children as _marker_count_direct
|
||||||
@@ -3212,6 +3215,36 @@ class Worker:
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
def _convert_running_task_to_goal(self, task_id, target):
|
||||||
|
"""把正在跑的主线在轮次边界转成新语义 goal 任务。
|
||||||
|
|
||||||
|
只在排空循环里调用:本轮首轮已 completed、任务在库里
|
||||||
|
仍是 running,CAS 把它改回 queued 并写 goal 字段与
|
||||||
|
resume_message——下次 worker 认领时走 /continue 同款
|
||||||
|
goal-resume,在同一 thread 上开新目标循环(不是新任务、
|
||||||
|
不是分支)。CAS 失败(任务已被取消/回收、状态已不是
|
||||||
|
running)返回 False,调用方把 followup 放回 pending,
|
||||||
|
绝不把取消过的任务悄悄改回 queued。
|
||||||
|
成功返回 True。"""
|
||||||
|
try:
|
||||||
|
conn = _db_connect(self.db_path)
|
||||||
|
try:
|
||||||
|
self._ensure_result_columns(conn)
|
||||||
|
cur = conn.execute(
|
||||||
|
"UPDATE tasks SET goal_mode = 1, goal = ?,"
|
||||||
|
" verify_command = NULL, goal_max_rounds = 0,"
|
||||||
|
" status = 'queued', resume_message = ?,"
|
||||||
|
" result_text = NULL, error_text = NULL,"
|
||||||
|
" actual_model = NULL, updated_at = ?"
|
||||||
|
" WHERE id = ? AND status = 'running'",
|
||||||
|
(target, target, time.time(), task_id))
|
||||||
|
conn.commit()
|
||||||
|
return cur.rowcount == 1
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
def _restore_followup(self, fu_id):
|
def _restore_followup(self, fu_id):
|
||||||
"""取消时把跑中的后续消息放回 pending,留给重试入口。"""
|
"""取消时把跑中的后续消息放回 pending,留给重试入口。"""
|
||||||
try:
|
try:
|
||||||
@@ -3461,14 +3494,21 @@ class Worker:
|
|||||||
|
|
||||||
只执行 completed 轮次的块;goal 任务与协调员任务不解析
|
只执行 completed 轮次的块;goal 任务与协调员任务不解析
|
||||||
(只收不发,与 peer 同款);父任务被取消时不执行。
|
(只收不发,与 peer 同款);父任务被取消时不执行。
|
||||||
执行顺序:创建 → 取消 → 重试 → 待办创建 → 待办更新 →
|
执行顺序:创建 → 自动化创建 → 取消 → 重试 → 待办创建 →
|
||||||
待办列表 → 任务列表 → 任务成本 → 配额 → 任务文件 →
|
待办更新 → 待办列表 → 任务列表 → 任务成本 → 配额 →
|
||||||
轮询 → 等待——创建在前让同一轮刚创建的任务立刻能被
|
任务文件 → 轮询 → 等待——创建在前让同一轮刚创建的
|
||||||
轮询,控制操作在快照之前让轮询/等待读到取消或重试后
|
任务立刻能被轮询,控制操作在快照之前让轮询/等待读到
|
||||||
的新状态;待办列表放在待办写操作之后,让同一轮刚建/
|
取消或重试后的新状态;待办列表放在待办写操作之后,
|
||||||
刚改的待办立刻能在列表里看到。全部报告拼成一条系统
|
让同一轮刚建/刚改的待办立刻能在列表里看到。全部报告
|
||||||
后续消息排给父任务自己,由排空循环在轮次边界跑下
|
拼成一条系统后续消息排给父任务自己,由排空循环在轮次
|
||||||
一轮;拒绝只记报告与审计,不炸 run_one。"""
|
边界跑下一轮;拒绝只记报告与审计,不炸 run_one。
|
||||||
|
goal-convert 不在这里直接转:它排成一条“/goal <目标>”
|
||||||
|
的自后续消息,放在报告消息之后——排空先跑报告那轮
|
||||||
|
(agent 看到自动化等工具的确认),再跑 /goal 那条,
|
||||||
|
走已有的 followup /goal 转换路径(plan 冲突、CAS、
|
||||||
|
终局 queued 审计都在那条路里,不在这里另起一套)。
|
||||||
|
这样转 goal 后报告不会丢在 goal 循环外,也不会出现
|
||||||
|
两套转换逻辑打架。"""
|
||||||
if not result or result.get("status") != "completed":
|
if not result or result.get("status") != "completed":
|
||||||
return
|
return
|
||||||
if self._was_cancelled(task["id"]):
|
if self._was_cancelled(task["id"]):
|
||||||
@@ -3489,14 +3529,17 @@ class Worker:
|
|||||||
task_costs = result.get("task_costs") or []
|
task_costs = result.get("task_costs") or []
|
||||||
quota_status = result.get("quota_status") or []
|
quota_status = result.get("quota_status") or []
|
||||||
task_files = result.get("task_files") or []
|
task_files = result.get("task_files") or []
|
||||||
|
goal_converts = result.get("goal_converts") or []
|
||||||
|
automation_creates = result.get("automation_creates") or []
|
||||||
if not (creates or polls or waits or cancels or retries
|
if not (creates or polls or waits or cancels or retries
|
||||||
or todo_lists or todo_creates or todo_updates
|
or todo_lists or todo_creates or todo_updates
|
||||||
or task_lists or task_costs or quota_status
|
or task_lists or task_costs or quota_status
|
||||||
or task_files):
|
or task_files or goal_converts or automation_creates):
|
||||||
return
|
return
|
||||||
reports = []
|
reports = []
|
||||||
created = 0
|
created = 0
|
||||||
rejected = 0
|
rejected = 0
|
||||||
|
auto_created = 0
|
||||||
if creates:
|
if creates:
|
||||||
quota_error = None
|
quota_error = None
|
||||||
try:
|
try:
|
||||||
@@ -3585,6 +3628,93 @@ class Worker:
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
reports.append("创建失败:%s" % exc)
|
reports.append("创建失败:%s" % exc)
|
||||||
rejected += len(creates) - created
|
rejected += len(creates) - created
|
||||||
|
if automation_creates:
|
||||||
|
# 自动化创建:权限与配额是整轮门(与 task-create 同款,
|
||||||
|
# 配额超限时整轮跳过,不逐块半建);单块的首次时刻
|
||||||
|
# 已过去、重名等只拒该块。创建人跟父任务走,绝不
|
||||||
|
# 用 worker 自己的身份建——否则自动化到期产出的任务
|
||||||
|
# 会算到错误的人头上,配额门也绕过去了。
|
||||||
|
auth_ok, _auth_reason = self._marker_auth(
|
||||||
|
task, "create_task")
|
||||||
|
quota_error = None
|
||||||
|
try:
|
||||||
|
quota_error = self._quota_exceeded(task, None)
|
||||||
|
except Exception as exc:
|
||||||
|
quota_error = "quota_check_failed: %s" % exc
|
||||||
|
if auth_ok is not True:
|
||||||
|
reports.append(
|
||||||
|
"自动化创建跳过:发送方创建人无 create_task "
|
||||||
|
"权限或权限查不到,本轮 %d 个自动化块全部跳过。"
|
||||||
|
% len(automation_creates))
|
||||||
|
rejected += len(automation_creates)
|
||||||
|
elif quota_error is not None:
|
||||||
|
reports.append(
|
||||||
|
"自动化创建跳过:父任务配额检查未通过(%s),"
|
||||||
|
"本轮 %d 个自动化块全部跳过。"
|
||||||
|
% (quota_error, len(automation_creates)))
|
||||||
|
rejected += len(automation_creates)
|
||||||
|
else:
|
||||||
|
now = time.time()
|
||||||
|
for idx, block in enumerate(automation_creates):
|
||||||
|
first = block.get("first_run_at")
|
||||||
|
if first is not None and first < now - 60:
|
||||||
|
reports.append(
|
||||||
|
"自动化创建跳过:首次运行时刻已过去"
|
||||||
|
"(%s),请给一个未来时刻再写块。"
|
||||||
|
% block.get("prompt", "")[:40])
|
||||||
|
rejected += 1
|
||||||
|
continue
|
||||||
|
name = block.get("name") or (
|
||||||
|
"auto-%s-%d-%d"
|
||||||
|
% (task["id"][:8], int(now), idx))
|
||||||
|
try:
|
||||||
|
conn = _db_connect(self.db_path)
|
||||||
|
try:
|
||||||
|
auto = _auto_create(
|
||||||
|
conn, task["project_id"], name,
|
||||||
|
block["prompt"], task.get("created_by"),
|
||||||
|
block["interval_seconds"],
|
||||||
|
block.get("repo_url"), now=now,
|
||||||
|
first_run_at=first,
|
||||||
|
run_once=block.get("once", False))
|
||||||
|
bind = block.get("bind")
|
||||||
|
if bind:
|
||||||
|
try:
|
||||||
|
_life_bind(
|
||||||
|
conn, auto["id"],
|
||||||
|
task["project_id"], task["id"],
|
||||||
|
bind, now=now)
|
||||||
|
except Exception as exc:
|
||||||
|
reports.append(
|
||||||
|
"已创建自动化 %s(%s),但绑定"
|
||||||
|
"到本会话失败(%s),定义本身"
|
||||||
|
"仍有效,到期独立跑。"
|
||||||
|
% (auto["id"], name, exc))
|
||||||
|
auto_created += 1
|
||||||
|
continue
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
except Exception as exc:
|
||||||
|
reports.append(
|
||||||
|
"自动化创建失败(%s):%s。"
|
||||||
|
% (block.get("prompt", "")[:40], exc))
|
||||||
|
rejected += 1
|
||||||
|
continue
|
||||||
|
auto_created += 1
|
||||||
|
nxt = auto.get("next_run_at")
|
||||||
|
nxt_text = (
|
||||||
|
time.strftime(
|
||||||
|
"%Y-%m-%d %H:%M", time.localtime(nxt))
|
||||||
|
if nxt else "不再到期")
|
||||||
|
reports.append(
|
||||||
|
"已创建自动化 %s(%s),间隔 %d 秒,"
|
||||||
|
"下次 %s%s,到期后由执行器经 claim 抢单"
|
||||||
|
"建任务%s。"
|
||||||
|
% (auto["id"], name,
|
||||||
|
block["interval_seconds"], nxt_text,
|
||||||
|
"(只跑一次)" if block.get("once") else "",
|
||||||
|
",已绑到本会话唤醒"
|
||||||
|
if block.get("bind") == "heartbeat" else ""))
|
||||||
for kind, blocks, action in (("取消", cancels, "create_task"),
|
for kind, blocks, action in (("取消", cancels, "create_task"),
|
||||||
("重试", retries, "approve_merge")):
|
("重试", retries, "approve_merge")):
|
||||||
for block in blocks:
|
for block in blocks:
|
||||||
@@ -4040,16 +4170,26 @@ class Worker:
|
|||||||
if reports:
|
if reports:
|
||||||
body = _marker_header() + "\n" + "\n".join(reports)
|
body = _marker_header() + "\n" + "\n".join(reports)
|
||||||
self._queue_self_followup(task, body)
|
self._queue_self_followup(task, body)
|
||||||
|
if goal_converts:
|
||||||
|
# 顺序关键:报告消息先排、/goal 消息后排,排空先跑
|
||||||
|
# 报告那轮、再转 goal;反过来排会先转 goal,报告
|
||||||
|
# 就送不到 goal 循环里了。plan 冲突等拒绝由排空的
|
||||||
|
# /goal 路径处理(标 failed 停排空),这里不预判。
|
||||||
|
goal_target = goal_converts[0]["goal"]
|
||||||
|
self._queue_self_followup(
|
||||||
|
task, "/goal %s" % goal_target)
|
||||||
detail = ("%s: spawned=%d rejected=%d polls=%d waits=%d "
|
detail = ("%s: spawned=%d rejected=%d polls=%d waits=%d "
|
||||||
"cancels=%d retries=%d todo_lists=%d "
|
"cancels=%d retries=%d todo_lists=%d "
|
||||||
"todo_creates=%d todo_updates=%d task_lists=%d "
|
"todo_creates=%d todo_updates=%d task_lists=%d "
|
||||||
"task_costs=%d quota=%d task_files=%d"
|
"task_costs=%d quota=%d task_files=%d "
|
||||||
|
"automations=%d goal_converts=%d"
|
||||||
% (task["id"], created, rejected, len(polls),
|
% (task["id"], created, rejected, len(polls),
|
||||||
len(waits), len(cancels), len(retries),
|
len(waits), len(cancels), len(retries),
|
||||||
len(todo_lists), len(todo_creates),
|
len(todo_lists), len(todo_creates),
|
||||||
len(todo_updates), len(task_lists),
|
len(todo_updates), len(task_lists),
|
||||||
len(task_costs), len(quota_status),
|
len(task_costs), len(quota_status),
|
||||||
len(task_files)))
|
len(task_files), auto_created,
|
||||||
|
len(goal_converts)))
|
||||||
self._audit(task["project_id"], "worker_marker_tools", detail)
|
self._audit(task["project_id"], "worker_marker_tools", detail)
|
||||||
|
|
||||||
def _deliver_peer_messages(self, task, result):
|
def _deliver_peer_messages(self, task, result):
|
||||||
@@ -5693,6 +5833,11 @@ class Worker:
|
|||||||
# 这里只负责把轮次跑出来。一条失败即停:任务记 failed,
|
# 这里只负责把轮次跑出来。一条失败即停:任务记 failed,
|
||||||
# 剩余 pending 留给重试入口(重试走已存在续跑,跑完继续
|
# 剩余 pending 留给重试入口(重试走已存在续跑,跑完继续
|
||||||
# 排空剩余消息);取消时该条回 pending,任务走中断收尾。
|
# 排空剩余消息);取消时该条回 pending,任务走中断收尾。
|
||||||
|
# /goal 命令不在这里当普通轮次跑:排空到它时把主线转
|
||||||
|
# 成 goal 任务并回 queued,下次认领走 goal-resume 开
|
||||||
|
# 新目标循环(服务端已在 followup/thread 入口把 running
|
||||||
|
# 的 /goal 排成 followup,见 server._create_followup)。
|
||||||
|
goal_converted = False
|
||||||
if (result is not None and result.get("status") == "completed"
|
if (result is not None and result.get("status") == "completed"
|
||||||
and not quota_stop
|
and not quota_stop
|
||||||
and not task.get("verify_command")
|
and not task.get("verify_command")
|
||||||
@@ -5701,6 +5846,23 @@ class Worker:
|
|||||||
fu = self._claim_next_followup(task_id)
|
fu = self._claim_next_followup(task_id)
|
||||||
if fu is None:
|
if fu is None:
|
||||||
break
|
break
|
||||||
|
goal_target = _parse_goal_command(fu["body"])
|
||||||
|
if goal_target is not None:
|
||||||
|
if not goal_target or task.get("plan"):
|
||||||
|
# 服务端已拦空目标与 plan 冲突,这里是
|
||||||
|
# 兜底:不跑、不转,标 failed 停排空。
|
||||||
|
self._finish_followup(fu["id"], "failed")
|
||||||
|
break
|
||||||
|
if self._convert_running_task_to_goal(
|
||||||
|
task_id, goal_target):
|
||||||
|
self._finish_followup(fu["id"], "done")
|
||||||
|
self._audit(task["project_id"],
|
||||||
|
"worker_goal_converted",
|
||||||
|
"%s:goal=%s" % (task_id, goal_target))
|
||||||
|
goal_converted = True
|
||||||
|
else:
|
||||||
|
self._restore_followup(fu["id"])
|
||||||
|
break
|
||||||
# 后续消息执行时查配额:超限时该条回 pending(留
|
# 后续消息执行时查配额:超限时该条回 pending(留
|
||||||
# 给重试入口),任务走配额中断收尾,不再排空剩余;
|
# 给重试入口),任务走配额中断收尾,不再排空剩余;
|
||||||
# 建消息时不查(排队本身不花钱,顶上来后可跑)。
|
# 建消息时不查(排队本身不花钱,顶上来后可跑)。
|
||||||
@@ -5716,6 +5878,9 @@ class Worker:
|
|||||||
if fu.get("sender_task_id"):
|
if fu.get("sender_task_id"):
|
||||||
fu_prompt = _peer_prefix(fu["sender_task_id"]) + fu_prompt
|
fu_prompt = _peer_prefix(fu["sender_task_id"]) + fu_prompt
|
||||||
fu_task["prompt"] = fu_prompt
|
fu_task["prompt"] = fu_prompt
|
||||||
|
# 后续消息轮不重复加指南:首轮指南已在同一
|
||||||
|
# thread 历史里,后续轮靠历史想起工具;每轮
|
||||||
|
# 都加会让指南在历史里越积越多。
|
||||||
fu_result, fu_error = self._run_attempt(
|
fu_result, fu_error = self._run_attempt(
|
||||||
fu_task, quota_check=_quota_hit)
|
fu_task, quota_check=_quota_hit)
|
||||||
usage_total = sum_usage(
|
usage_total = sum_usage(
|
||||||
@@ -5909,10 +6074,23 @@ class Worker:
|
|||||||
self._notify_task_finished(
|
self._notify_task_finished(
|
||||||
task_id, task["project_id"], final, error=error)
|
task_id, task["project_id"], final, error=error)
|
||||||
else:
|
else:
|
||||||
# 已被取消/回收:DB 状态以 interrupted 为准,不再
|
if goal_converted:
|
||||||
# 补一条 worker_completed/failed 审计误导排查。
|
# /goal 转换:DB 状态已是本轮转换写回的 queued,
|
||||||
final = "interrupted"
|
# _set_status_if_running 因状态不是 running 而
|
||||||
self._audit(task["project_id"], "worker_interrupted", task_id)
|
# 没改行是预期,不是取消/回收;绝不能把 local
|
||||||
|
# 的 final 改成 interrupted(那只会误导返回体,
|
||||||
|
# DB 本身已是 queued)。审计也按转换记,不按中断记。
|
||||||
|
final = "queued"
|
||||||
|
self._audit(task["project_id"], "worker_goal_converted",
|
||||||
|
task_id)
|
||||||
|
log_event(self.logger, "worker_goal_converted",
|
||||||
|
task_id=task_id, project_id=task["project_id"],
|
||||||
|
status="queued")
|
||||||
|
else:
|
||||||
|
# 已被取消/回收:DB 状态以 interrupted 为准,不再
|
||||||
|
# 补一条 worker_completed/failed 审计误导排查。
|
||||||
|
final = "interrupted"
|
||||||
|
self._audit(task["project_id"], "worker_interrupted", task_id)
|
||||||
if resume_message and not quota_stop:
|
if resume_message and not quota_stop:
|
||||||
# 本轮已跑完(无论成败):续跑消息已消费,清掉;进程
|
# 本轮已跑完(无论成败):续跑消息已消费,清掉;进程
|
||||||
# 中途被杀时清不到,下次认领会重跑同一条消息——可接受
|
# 中途被杀时清不到,下次认领会重跑同一条消息——可接受
|
||||||
|
|||||||
+66
-1
@@ -3,9 +3,19 @@
|
|||||||
适用基线:main d129f7b 上的 feat-marker-tools 分支(本地提交,未推、
|
适用基线:main d129f7b 上的 feat-marker-tools 分支(本地提交,未推、
|
||||||
未建 PR、未部署)。本批只做四类标记工具,不要把本文档读成
|
未建 PR、未部署)。本批只做四类标记工具,不要把本文档读成
|
||||||
“所有 Codex app 线程工具都已实现”:handoff、worktree 创建状态、
|
“所有 Codex app 线程工具都已实现”:handoff、worktree 创建状态、
|
||||||
PR 检查诊断、automation、clock-sleep 都不在本批;MCP 真工具路
|
PR 检查诊断、clock-sleep 都不在本批;MCP 真工具路
|
||||||
(同轮函数返回)也不在本批。
|
(同轮函数返回)也不在本批。
|
||||||
|
|
||||||
|
新增(feat-mainline-goal 分支,本地提交,未推、未部署):
|
||||||
|
goal-convert 与 automation-create 两个标记工具已实现,见下面
|
||||||
|
新增的两节。注意:本批刻意**不**向 LLM 自动注入工具指南——
|
||||||
|
不靠服务端再猜关键词、也不让每个任务的 prompt 都变长;
|
||||||
|
agent 知道这两个工具的唯一途径,是用户在会话里手动告诉它
|
||||||
|
(例如把下面两节的格式贴给它),或由调用方在任务 prompt 里
|
||||||
|
自己写明。LLM 是否用它们、判成 goal 还是自动化,全由 LLM
|
||||||
|
按用户话里的意图决定,服务端不按“收 / 每天 / 明天”这类词
|
||||||
|
再做一次关键词判断。
|
||||||
|
|
||||||
## 一句话原理
|
## 一句话原理
|
||||||
|
|
||||||
agent 不用联网、不用凭证。agent 在回合输出里写标记块,本地与
|
agent 不用联网、不用凭证。agent 在回合输出里写标记块,本地与
|
||||||
@@ -32,6 +42,61 @@ Docker 执行器都经 sandbox 的结果解析提取标记块(与 task-message
|
|||||||
- 容易混淆的一点:协调员的子任务本身是普通任务(不是协调员
|
- 容易混淆的一点:协调员的子任务本身是普通任务(不是协调员
|
||||||
任务),它写标记块会被解析;它的派生代数从 0 计(见“派生护栏”)。
|
任务),它写标记块会被解析;它的派生代数从 0 计(见“派生护栏”)。
|
||||||
|
|
||||||
|
## 新增的两个工具:goal-convert 与 automation-create
|
||||||
|
|
||||||
|
### goal-convert:把本会话转成 goal
|
||||||
|
|
||||||
|
```text
|
||||||
|
```goal-convert
|
||||||
|
{"goal": "收 xxx 到 yyy 为止"}
|
||||||
|
```
|
||||||
|
```
|
||||||
|
|
||||||
|
- goal 必填、1-20000 字;每轮最多 1 块。转成的是新语义 goal
|
||||||
|
(goal_mode=1、无限轮直到模型自己输出“目标已达成”),
|
||||||
|
与服务端 /goal 转换同款,不接受 verify_command /
|
||||||
|
goal_max_rounds。
|
||||||
|
- 执行方式是排队,不是立即转:worker 把它排成一条
|
||||||
|
“/goal <目标>”的自后续消息,放在本轮其它工具的报告消息
|
||||||
|
之后——排空先跑报告那轮,再跑 /goal 那条走已有的 followup
|
||||||
|
/goal 转换路径(plan 冲突、CAS、终局 queued 都在那条路里)。
|
||||||
|
所以同轮其它标记块的报告不会丢在 goal 循环外。
|
||||||
|
- 只在普通任务里有效:goal 任务与协调员任务不解析标记块。
|
||||||
|
|
||||||
|
### automation-create:新建自动化定义
|
||||||
|
|
||||||
|
```text
|
||||||
|
```automation-create
|
||||||
|
{"name": "看 xxx 每日", "prompt": "查看 xxx 最新并汇总",
|
||||||
|
"interval_seconds": 86400,
|
||||||
|
"first_run_at": "2026-10-30T09:00:00+08:00",
|
||||||
|
"once": false, "bind": null, "repo_url": null}
|
||||||
|
```
|
||||||
|
```
|
||||||
|
|
||||||
|
- prompt 必填、1-20000 字;name 可选、1-100 字,缺失时
|
||||||
|
worker 自动生成(`auto-<任务短 id>-<时间>-<序号>`),不会因
|
||||||
|
重名撞上同项目已有的 active 自动化。
|
||||||
|
- interval_seconds 可选、缺省 86400,必须是整数且在 60 到
|
||||||
|
30*86400 之间;非法时整块跳过,不静默钳制。
|
||||||
|
- first_run_at 可选:epoch 秒(毫秒级自动除 1000)、ISO8601
|
||||||
|
字符串(带偏移量按偏移量算,无时区按 Asia/Shanghai 算)。
|
||||||
|
不给表示建完即到期(沿用自动化原有语义);给了表示首次
|
||||||
|
在该时刻跑——“每天 9 点”要写成 first_run_at=下一个 9 点 +
|
||||||
|
interval_seconds=86400,而不是只写间隔(只写间隔会从创建
|
||||||
|
时间起算,每天跑在创建时间那个钟点)。首次时刻已过去的块
|
||||||
|
在 worker 层拒绝并写进报告,agent 下一轮可纠正。
|
||||||
|
- once 可选、缺省 false;true 表示只跑一次,跑过后 is_due /
|
||||||
|
due / claim 三处都不再到期(automations 模块的
|
||||||
|
first_run_at / run_once 字段与 SQL 到期条件已同步改过,
|
||||||
|
API 的创建与更新也接受这两个字段)。
|
||||||
|
- bind 可选、缺省 null(到期独立跑新任务);"heartbeat"
|
||||||
|
表示绑到本会话,到期唤醒本线程;"cron" 与 null 同款。
|
||||||
|
- 每轮最多 5 块;创建人跟父任务走,须有项目 create_task
|
||||||
|
权限,配额超限时整轮跳过。创建只是登记定义,到期执行
|
||||||
|
仍走 automations 的 claim 门与外部触发器,不是建完就跑。
|
||||||
|
- 只在普通任务里有效:goal 任务与协调员任务不解析标记块。
|
||||||
|
|
||||||
## 四个工具的标记格式
|
## 四个工具的标记格式
|
||||||
|
|
||||||
### task-create:新建平级主线任务
|
### task-create:新建平级主线任务
|
||||||
|
|||||||
Reference in new issue
Block a user