feat: 会话内 LLM 自判 goal / 自动化标记工具,不靠关键词

agent 在回合输出里写 goal-convert 块,worker 在轮次边界
排成 /goal 自后续消息、走已有 followup 转换路径把本会话
转成 goal;写 automation-create 块,worker 在轮次边界
创建自动化定义(权限 create_task、配额整轮门、创建人跟
父任务走、name 缺失自动生成、首次时刻已过去单块拒绝、
bind heartbeat 可绑回本会话)。

automations 新增 first_run_at / run_once:首次时刻只管
首次、之后按间隔算;只跑一次跑过后 is_due / due / claim
三处都不再到期(SQL 到期条件与 Python 口径共用同一
_DUE_SQL);API 创建与更新同步接受这两个字段。

刻意不向 LLM 自动注入工具指南:避免 prompt 臃肿,格式
只写进 docs/marker-tools.md 与 README,需要时由用户在
会话里手动告诉 agent;服务端不按关键词再猜意图。

单测 cloud/tests/test_session_goal_automation.py 12 项,
相关回归 218 项全过。
This commit is contained in:
host committed 2026-10-30 07:15:05 +08:00
1 parent f1aab5415c
commit e81ba8d2bf
8 files changed
+892 -51

No files matched your search

+9
View File
@@ -559,6 +559,15 @@ 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 式通用互发)
任意两个同项目任务可以互发消息、对方做完可以回消息,不
+93 -36
View File
@@ -72,7 +72,8 @@ def row_get(row, name):
AUTOMATION_COLUMNS = (
"id", "project_id", "name", "prompt", "repo_url",
"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 = (
"id", "automation_id", "project_id", "task_id", "status",
"detail", "created_at")
@@ -122,7 +123,9 @@ def ensure_automations_tables(conn):
"last_claimed_at DOUBLE PRECISION, "
"created_by TEXT, "
"created_at DOUBLE PRECISION, "
"cancelled_at DOUBLE PRECISION)"
"cancelled_at DOUBLE PRECISION, "
"first_run_at DOUBLE PRECISION, "
"run_once INTEGER)"
)
conn.execute(
"CREATE TABLE IF NOT EXISTS automation_runs("
@@ -138,7 +141,9 @@ def ensure_automations_tables(conn):
for col, ddl in (
("repo_url", "TEXT"),
("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:
try:
conn.execute(
@@ -169,23 +174,40 @@ def ensure_automations_tables(conn):
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")
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:
first = row_get(auto, "first_run_at")
if first is not None:
return first
return row_get(auto, "created_at")
interval = row_get(auto, "interval_seconds") or 0
return last + interval
def is_due(auto, now=None):
"""是否到期:仅 active 的定义可到期;now 缺省取当前时间。
到期边界用 >=,与 host run-due 的 now - last_run >=
interval 同款(含边界那一秒)。"""
interval 同款(含边界那一秒)。只跑一次且已跑过时永远
不到期(先于 next_run_at 判断);next_run_at 为 None
的其它情形(行缺创建时间等脏数据)仍按旧代码认到期,
不静默改口径。"""
if row_get(auto, "status") != ACTIVE:
return False
if now is None:
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)
if nxt is None:
return True
@@ -208,12 +230,15 @@ def automation_to_dict(row, now=None):
"created_by": row["created_by"],
"created_at": row["created_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,
"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():
raise ValueError("name required")
name = name.strip()
@@ -238,16 +263,31 @@ def _validate_fields(name, prompt, interval_seconds, repo_url):
repo_url = repo_url.strip() or None
if repo_url is not None and len(repo_url) > MAX_REPO_URL_LEN:
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,
interval_seconds=86400, repo_url=None, now=None):
interval_seconds=86400, repo_url=None, now=None,
first_run_at=None, run_once=False):
"""创建自动化定义:同项目内 active 的名字唯一(取消过的
名字可重用,与环境名唯一规则同款语义)。成功时 commit
并返回 dict;校验失败抛 ValueError(调用方转 400)。"""
name, prompt, interval_seconds, repo_url = _validate_fields(
name, prompt, interval_seconds, repo_url)
并返回 dict;校验失败抛 ValueError(调用方转 400)。
first_run_at 为首次运行时刻(epoch 秒,过去则建完即
到期);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:
now = time.time()
ensure_automations_tables(conn)
@@ -261,10 +301,12 @@ def create_automation(conn, project_id, name, prompt, created_by,
conn.execute(
"INSERT INTO automations(id, project_id, name, prompt,"
" repo_url, interval_seconds, status, last_run_at,"
" last_claimed_at, created_by, created_at, cancelled_at)"
" VALUES(?, ?, ?, ?, ?, ?, ?, NULL, NULL, ?, ?, NULL)",
" last_claimed_at, created_by, created_at, cancelled_at,"
" first_run_at, run_once)"
" VALUES(?, ?, ?, ?, ?, ?, ?, NULL, NULL, ?, ?, NULL, ?, ?)",
(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()
row = conn.execute(
"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):
"""更新自动化定义(automation_update):fields 只认
name / prompt / interval_seconds / repo_url 四个键,
其它键一律忽略(不报错、不存入,避免调用方拼错字段
名后以为已改);四个键全没给时抛 ValueError(空更新
不是成功)。校验与创建同款(_validate_fields,名字
仍按“同项目 active 名字唯一”查重,改名撞名时 400)。
name / prompt / interval_seconds / repo_url / first_run_at /
run_once 六个键,其它键一律忽略(不报错、不存入,避免
调用方拼错字段名后以为已改);六个键全没给时抛
ValueError(空更新不是成功)。校验与创建同款
(_validate_fields,名字仍按“同项目 active 名字唯一”
查重,改名撞名时 400)。
返回 (ok, auto):行不存在时 (False, None);已取消时
(False, 旧 dict)——取消是终态,改已取消的定义必须
@@ -359,11 +402,16 @@ def update_automation(conn, automation_id, fields, now=None):
interval = fields.get("interval_seconds",
current["interval_seconds"])
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 (
"name", "prompt", "interval_seconds", "repo_url")):
"name", "prompt", "interval_seconds", "repo_url",
"first_run_at", "run_once")):
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"]:
clash = conn.execute(
"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")
conn.execute(
"UPDATE automations SET name = ?, prompt = ?,"
" interval_seconds = ?, repo_url = ? WHERE id = ?",
(name, prompt, interval, repo_url, automation_id))
" interval_seconds = ?, repo_url = ?, first_run_at = ?,"
" run_once = ? WHERE id = ?",
(name, prompt, interval, repo_url, first_run_at,
1 if run_once else 0, automation_id))
conn.commit()
row = conn.execute(
"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)
# 到期 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):
"""列出某项目当前到期的 active 定义(纯读,不抢单)。
SQL 层算到期:从未跑过(last_run_at 为空)或上次执行距
今已满间隔;与 is_due 的 Python 算法同款。"""
SQL 层算到期:与 is_due 的 Python 算法同款,见 _DUE_SQL。"""
if now is None:
now = time.time()
ensure_automations_tables(conn)
rows = conn.execute(
"SELECT * FROM automations WHERE project_id = ?"
" AND status = ?"
" AND (last_run_at IS NULL"
" OR last_run_at + interval_seconds <= ?)"
" AND status = ?" + _DUE_SQL +
" 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]
@@ -420,10 +479,8 @@ def claim_due_automation(conn, automation_id, now=None):
cur = conn.execute(
"UPDATE automations SET last_run_at = ?,"
" last_claimed_at = ?"
" WHERE id = ? AND status = ?"
" AND (last_run_at IS NULL"
" OR last_run_at + interval_seconds <= ?)",
(now, now, automation_id, ACTIVE, now))
" WHERE id = ? AND status = ?" + _DUE_SQL,
(now, now, automation_id, ACTIVE, now, now))
conn.commit()
return cur.rowcount == 1
+225 -1
View File
@@ -1,6 +1,18 @@
"""任务内标记工具(marker tools):task-create / task-poll / task-read /
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 同款原则,不另起一套):
@@ -68,6 +80,17 @@ todo-update / task-list / task-cost / quota-status / task-files。
二进制文件拒绝);绝对路径、.. 逃逸、符号链接逃逸一律
拒绝;cloud 任务与 worktree 在别机的任务拒绝(文件不在
本机);目标没有本地 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 time
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_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_WAITS_PER_TURN = 2
MAX_WAIT_TARGETS_PER_BLOCK = 8
@@ -176,6 +211,64 @@ _QUOTA_RE = re.compile(
r"```(quota-status|quota_status)\s*\n(.*?)\n```", re.DOTALL)
_TASK_FILES_RE = re.compile(
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):
@@ -786,6 +879,137 @@ def extract_task_retries(text):
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):
"""父任务自身的派生代数:普通任务 0,第一代派生任务 1,依此类推。
+18
View File
@@ -23,6 +23,10 @@ import sys
import time
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_costs as _extract_task_costs
from cloud.marker_tools import extract_task_creates as _extract_task_creates
@@ -418,6 +422,8 @@ class LocalExecutor(Executor):
todo_lists = []
todo_creates = []
todo_updates = []
goal_converts = []
automation_creates = []
turn_id = None
root_turn_id = None
parsed = None
@@ -465,6 +471,9 @@ class LocalExecutor(Executor):
todo_lists = _extract_todo_lists(parsed.get("text"))
todo_creates = _extract_todo_creates(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:
host_status = None
else:
@@ -496,6 +505,8 @@ class LocalExecutor(Executor):
"todo_lists": todo_lists,
"todo_creates": todo_creates,
"todo_updates": todo_updates,
"goal_converts": goal_converts,
"automation_creates": automation_creates,
"turn_id": turn_id,
"root_turn_id": root_turn_id,
}
@@ -1944,6 +1955,8 @@ def _parse_host_result(task_id, fake):
todo_lists = []
todo_creates = []
todo_updates = []
goal_converts = []
automation_creates = []
turn_id = None
root_turn_id = None
parsed = None
@@ -1978,6 +1991,9 @@ def _parse_host_result(task_id, fake):
todo_lists = _extract_todo_lists(parsed.get("text"))
todo_creates = _extract_todo_creates(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 版同款语义,取不到
# 时保持 None,worker 写回时不覆盖旧值。
turn_id = parsed.get("turn_id")
@@ -2006,6 +2022,8 @@ def _parse_host_result(task_id, fake):
"todo_lists": todo_lists,
"todo_creates": todo_creates,
"todo_updates": todo_updates,
"goal_converts": goal_converts,
"automation_creates": automation_creates,
"turn_id": turn_id,
"root_turn_id": root_turn_id,
}
+24 -2
View File
@@ -68,6 +68,7 @@ from cloud.db import init_postgres_schema as _init_postgres_schema
from cloud.db import PostgresIntegrityError as _PgIntegrityError
from cloud.db import PostgresOperationalError as _PgOperationalError
from cloud.goal_command import parse_goal_command as _parse_goal_command
from cloud.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 ensure_followups_table as _peer_ensure_followups
from cloud.peer_messages import peer_message_prefix as _peer_prefix
@@ -9472,7 +9473,10 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
"""创建自动化定义:只登记定义与到期状态,不跑模型、
不另起调度器(执行器后续走 claim 门,见 automations
模块文档)。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 配额门:创建时先过与建任务同一道配额门(美元配额
与无价 token cap,超限 402),避免“定义免费建、到期
@@ -9490,13 +9494,24 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
interval = body.get("interval_seconds", 86400)
if interval is None:
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()
try:
try:
auto = _auto_create(
conn, project_id, body.get("name"),
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:
self._send_json(400, {"error": str(exc)})
return
@@ -9845,6 +9860,13 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
if jerr:
self._send_json(400, {"error": jerr})
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()
try:
try:
+334
View File
@@ -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()
+123 -11
View File
@@ -150,6 +150,8 @@ from cloud.models import ModelRouter, is_fallbackable, record_fallback
from cloud.auto_memory import AutoMemoryStore
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.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 check_control_gate as _marker_gate
from cloud.marker_tools import count_direct_spawn_children as _marker_count_direct
@@ -3492,14 +3494,21 @@ class Worker:
只执行 completed 轮次的块;goal 任务与协调员任务不解析
(只收不发,与 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":
return
if self._was_cancelled(task["id"]):
@@ -3520,14 +3529,17 @@ class Worker:
task_costs = result.get("task_costs") or []
quota_status = result.get("quota_status") 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
or todo_lists or todo_creates or todo_updates
or task_lists or task_costs or quota_status
or task_files):
or task_files or goal_converts or automation_creates):
return
reports = []
created = 0
rejected = 0
auto_created = 0
if creates:
quota_error = None
try:
@@ -3616,6 +3628,93 @@ class Worker:
except Exception as exc:
reports.append("创建失败:%s" % exc)
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"),
("重试", retries, "approve_merge")):
for block in blocks:
@@ -4071,16 +4170,26 @@ class Worker:
if reports:
body = _marker_header() + "\n" + "\n".join(reports)
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 "
"cancels=%d retries=%d todo_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),
len(waits), len(cancels), len(retries),
len(todo_lists), len(todo_creates),
len(todo_updates), len(task_lists),
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)
def _deliver_peer_messages(self, task, result):
@@ -5769,6 +5878,9 @@ class Worker:
if fu.get("sender_task_id"):
fu_prompt = _peer_prefix(fu["sender_task_id"]) + fu_prompt
fu_task["prompt"] = fu_prompt
# 后续消息轮不重复加指南:首轮指南已在同一
# thread 历史里,后续轮靠历史想起工具;每轮
# 都加会让指南在历史里越积越多。
fu_result, fu_error = self._run_attempt(
fu_task, quota_check=_quota_hit)
usage_total = sum_usage(