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:
1 parent
f1aab5415c
commit
e81ba8d2bf
8 files changed
+892
-51
No files matched your search
+93
-36
@@ -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
|
||||
|
||||
|
||||
Reference in new issue
Block a user