core(§7 B Step 6): no-subtask 前缀嵌套校验

- core/storage/utils.py 加 check_no_subtask + NoSubtaskError;PG LIKE
  双向(new LIKE existing/%  OR  existing LIKE new/%),同 task_dir
  允许(同项目多对话),空 / whitespace 跳过。
- 分隔符容差:SQL replace(task_dir, '\', '/') 把存的 Windows 反斜杠
  与新值统一到 '/' 再比;backslash 通过 bind 参数传,绕开 SQL 转义。
- main.py::build_agent 在 resolve_task_id 后、TaskState 构造前调,
  if not resume 单层闸 —— resume 跳过(改名走未来 Folder API cascade).
- cli.py 三处 build_agent 调用现有 try/except 直接接住 NoSubtaskError.
- PROGRESS / RUN 同步:Step 6 完工,故障兜底加一条 NoSubtaskError 处理.

Smoke(9 路径 + e2e 3 分支)全绿。§7 B 完工(Step 5 取消)。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
caoqianming 2026-05-14 11:59:37 +08:00
parent 2b3692c8bf
commit e8dbfa57a5
5 changed files with 73 additions and 14 deletions

View File

@ -2,7 +2,7 @@
> 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。 > 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。
最后更新:2026-05-14(Step 4) 最后更新:2026-05-14(Step 6)
--- ---
@ -15,7 +15,7 @@
| 5 | Eval Suite | ⏸ 不做 | dogfooding 替代,probe 覆盖健康检查 | | 5 | Eval Suite | ⏸ 不做 | dogfooding 替代,probe 覆盖健康检查 |
| 6 | 长任务工程化 | 🟡 | task + 恢复 ✅;双层记忆 ✅;context 压缩未做 | | 6 | 长任务工程化 | 🟡 | task + 恢复 ✅;双层记忆 ✅;context 压缩未做 |
| 7 | 打磨 | ❌ | Docker 沙盒 / 更多 skill | | 7 | 打磨 | ❌ | Docker 沙盒 / 更多 skill |
| §7 SaaS | DESIGN §7 路线 | 🟡 | A 事件流化 ✅;B 进行中(Step 1 基建 ✅;Step 2 Session ORM ✅;Step 3 TaskState ORM ✅;Step 4 task_dir 双形态 ✅;Step 6 待;Step 5 migrate-from-fs 取消)。Phase G(Web UI 简洁版)已上设计,排在 D 后。 | | §7 SaaS | DESIGN §7 路线 | 🟡 | A 事件流化 ✅;B 完工(Step 1 基建 ✅;Step 2 Session ORM ✅;Step 3 TaskState ORM ✅;Step 4 task_dir 双形态 ✅;Step 6 no-subtask 校验 ✅;Step 5 migrate-from-fs 取消)。下一阶 C(Executor + sandbox)或 Phase G(Web UI)。 |
--- ---
@ -33,6 +33,7 @@
- **05-14 / §7 B Step 2 Session ORM**:`core/session.py` 重写,messages 走 PG(append-only,jsonb,idx 严格递增);system prompt 不入库(每次 build_agent 重建);`Session.load(task_id, system_prompt=...)` resume 接口;`ensure_local_task_row` idempotent UPSERT(`INSERT ... ON CONFLICT DO NOTHING`)在首条非 system 消息前打底 tasks 行。task_id 切换为 UUID(原时间戳格式废弃,旧 workspace **不做兼容**)。main.py/cli.py 适配:`resolve_task_id`(UUID 前缀解析)、`_cleanup_if_empty` 双检查(DB messages + FS 产物)、`_list_task_rows` 改读 PG。`core/export_docx.py` 改从 PG 读 messages。端到端 build/append/resume/cleanup smoke 全绿。**取消 Step 5 migrate-from-fs**(用户决定不兼容旧 workspace)。 - **05-14 / §7 B Step 2 Session ORM**:`core/session.py` 重写,messages 走 PG(append-only,jsonb,idx 严格递增);system prompt 不入库(每次 build_agent 重建);`Session.load(task_id, system_prompt=...)` resume 接口;`ensure_local_task_row` idempotent UPSERT(`INSERT ... ON CONFLICT DO NOTHING`)在首条非 system 消息前打底 tasks 行。task_id 切换为 UUID(原时间戳格式废弃,旧 workspace **不做兼容**)。main.py/cli.py 适配:`resolve_task_id`(UUID 前缀解析)、`_cleanup_if_empty` 双检查(DB messages + FS 产物)、`_list_task_rows` 改读 PG。`core/export_docx.py` 改从 PG 读 messages。端到端 build/append/resume/cleanup smoke 全绿。**取消 Step 5 migrate-from-fs**(用户决定不兼容旧 workspace)。
- **05-14 / §7 B Step 3 TaskState ORM**:`core/task.py` 重写,TaskState dataclass 保留为内存 DTO 但落地走 PG —— `save()``upsert_task`(INSERT ON CONFLICT DO UPDATE,显式 set `updated_at=func.now()`),`load(task_id)` 走 SELECT;**字段去掉 `cwd`**(改读 task_dir,§7 SaaS task_dir-as-identity)。`state.json` 文件**全面废除**,task_dir 只承担 skill 产物。`core/storage/utils.py` 加 `upsert_task` / `update_task` 工具。`main.py::sync_task_tokens` 改 `update_task(tokens_p,tokens_c)` 单字段 UPDATE(ORM-level update 自带 onupdate=func.now())。`core/session.py::Session.append` 的 ensure 调用补传 `mode/description/reasoning_effort`,避免首次 INSERT 后 _list_task_rows 看到空 meta。`cli.py` 全字段从 ORM Task 列读;`_cleanup_if_empty` 去 state.json 特例(任何 FS 文件 / 子目录都算实质痕迹);`/done /abandon /desc` 走 PG。`core/export_docx.py` meta 改从 `TaskState.load(tid)` 读(asdict 拿到 dict),去 CWD 字段。端到端 smoke:storage UPSERT/UPDATE round-trip + build_agent 懒创建 + Session.append 自动 INSERT 完整 meta + sync_task_tokens 局部 UPDATE + task_state.save UPSERT 保留 task_dir/tokens + export → .docx 37KB 全绿。 - **05-14 / §7 B Step 3 TaskState ORM**:`core/task.py` 重写,TaskState dataclass 保留为内存 DTO 但落地走 PG —— `save()``upsert_task`(INSERT ON CONFLICT DO UPDATE,显式 set `updated_at=func.now()`),`load(task_id)` 走 SELECT;**字段去掉 `cwd`**(改读 task_dir,§7 SaaS task_dir-as-identity)。`state.json` 文件**全面废除**,task_dir 只承担 skill 产物。`core/storage/utils.py` 加 `upsert_task` / `update_task` 工具。`main.py::sync_task_tokens` 改 `update_task(tokens_p,tokens_c)` 单字段 UPDATE(ORM-level update 自带 onupdate=func.now())。`core/session.py::Session.append` 的 ensure 调用补传 `mode/description/reasoning_effort`,避免首次 INSERT 后 _list_task_rows 看到空 meta。`cli.py` 全字段从 ORM Task 列读;`_cleanup_if_empty` 去 state.json 特例(任何 FS 文件 / 子目录都算实质痕迹);`/done /abandon /desc` 走 PG。`core/export_docx.py` meta 改从 `TaskState.load(tid)` 读(asdict 拿到 dict),去 CWD 字段。端到端 smoke:storage UPSERT/UPDATE round-trip + build_agent 懒创建 + Session.append 自动 INSERT 完整 meta + sync_task_tokens 局部 UPDATE + task_state.save UPSERT 保留 task_dir/tokens + export → .docx 37KB 全绿。
- **05-14 / §7 B Step 4 task_dir 双形态**:CLI `chat --task-dir <path>` 支持用户显式指定项目目录(§7.1 task-primary + dir 副视图心智模型落地)—— 留空走默认派生 `workspace/tasks/<uuid>/`,显式走用户路径(绝对或相对 cwd,Path.resolve())。`main.py::resolve_task_id` 增 `task_dir_arg`;resume 时从 PG `tasks.task_dir` 读(`SELECT task_dir WHERE task_id=?`),空则降级默认派生。新增 `is_managed_task_dir(td, ws)` 判断是否在 `workspace/tasks/<uuid>/` 模板下,作 `_cleanup_if_empty` 保护开关 —— 用户自指定的项目目录**绝不 rmtree**(可能含用户已有文件);DB 行该删还是删。`core/export_docx.py::export_chat_to_docx` 重构:task_id 升一等参数(从 `task_dir.name` 提取改入参传入),task_dir 留空时自动从 PG 读,支持用户目录(非 UUID 命名)正常导出。cli `/export``cli.py export` 子命令均改走 `_resolve_uuid_or_prefix` + task_id 直传。Smoke 4 路径全绿:default-derived(managed=True, cleanup rmtree)/ --task-dir(managed=False, FS preserved)/ resume reads DB / export 自动 PG 查路径。 - **05-14 / §7 B Step 4 task_dir 双形态**:CLI `chat --task-dir <path>` 支持用户显式指定项目目录(§7.1 task-primary + dir 副视图心智模型落地)—— 留空走默认派生 `workspace/tasks/<uuid>/`,显式走用户路径(绝对或相对 cwd,Path.resolve())。`main.py::resolve_task_id` 增 `task_dir_arg`;resume 时从 PG `tasks.task_dir` 读(`SELECT task_dir WHERE task_id=?`),空则降级默认派生。新增 `is_managed_task_dir(td, ws)` 判断是否在 `workspace/tasks/<uuid>/` 模板下,作 `_cleanup_if_empty` 保护开关 —— 用户自指定的项目目录**绝不 rmtree**(可能含用户已有文件);DB 行该删还是删。`core/export_docx.py::export_chat_to_docx` 重构:task_id 升一等参数(从 `task_dir.name` 提取改入参传入),task_dir 留空时自动从 PG 读,支持用户目录(非 UUID 命名)正常导出。cli `/export``cli.py export` 子命令均改走 `_resolve_uuid_or_prefix` + task_id 直传。Smoke 4 路径全绿:default-derived(managed=True, cleanup rmtree)/ --task-dir(managed=False, FS preserved)/ resume reads DB / export 自动 PG 查路径。
- **05-14 / §7 B Step 6 no-subtask 校验**:`core/storage/utils.py::check_no_subtask(task_dir, user_id=SENTINEL)` —— 同 user 下查 `new LIKE existing||'/%' OR existing LIKE new||'/%'`(`task_dir != new` 过滤掉同 task_dir 同项目多对话场景)。冲突抛 `NoSubtaskError`(`ValueError` 子类),消息带冲突 task 的 UUID 前 8 位 + 它的 task_dir。**分隔符容差**:SQL 里 `replace(task_dir, :bs, '/')` 把存的 Windows `\` 在比较前归一,新值也 `replace('\\', '/')`,跨 OS / 历史数据混合分隔符不漏判;`bs` 通过 bind 参数传(绕开 SQL 字符串转义陷阱)。空 / whitespace `task_dir` 直接 return(legacy / 未绑项目)。`main.py::build_agent` 在 `resolve_task_id` 后、TaskState 构造前调,`if not resume` 单层闸 —— resume 跳过(目录改名走未来 Folder API cascade,这里只拦新建)。`cli.py` 三处 build_agent 调用现有 try/except 直接接住 NoSubtaskError 并友好打印。Smoke 全绿:同 dir 允许 / child 拒 / parent 拒 / sibling 允许 / `proj_a_other` 不误中 `proj_a`(因为用 `/%` 而非 `%`)/ 空跳过 / Win `\` 子目录拒 / 混合分隔符(`\` 存 + `/` 查)仍拒 / build_agent 端到端三分支(child raise / same pass / resume bypass)。
--- ---
@ -65,13 +66,13 @@ core/export_docx.py 379 ← §7 B Step 2-4: task_id 升一等
core/storage/__init__.py 27 ← §7 B Step 1-3 core/storage/__init__.py 27 ← §7 B Step 1-3
core/storage/engine.py 80 ← §7 B Step 1 core/storage/engine.py 80 ← §7 B Step 1
core/storage/models.py 124 ← §7 B Step 1 core/storage/models.py 124 ← §7 B Step 1
core/storage/utils.py 95 ← §7 B Step 3: +upsert_task/update_task core/storage/utils.py 139 ← §7 B Step 3-6: +upsert_task/update_task/check_no_subtask
tools/base.py 34 tools/base.py 34
tools/fs.py 182 tools/fs.py 182
tools/shell.py 94 tools/shell.py 94
tools/run_python.py 84 tools/run_python.py 84
tools/skill_tool.py 45 tools/skill_tool.py 45
main.py 272 ← §7 B Step 4: +is_managed_task_dir / task_dir_arg main.py 277 ← §7 B Step 4-6: +is_managed_task_dir / task_dir_arg / no-subtask check
cli.py 538 ← §7 B Step 4: --task-dir / cleanup 保护用户目录 cli.py 538 ← §7 B Step 4: --task-dir / cleanup 保护用户目录
db/migrations/env.py 61 ← §7 B Step 1 db/migrations/env.py 61 ← §7 B Step 1
db/migrations/versions/ db/migrations/versions/
@ -86,11 +87,10 @@ Python 合计 ~3101 行
## 下一步候选(性价比排序) ## 下一步候选(性价比排序)
1. **§7 B 剩余 Step 6**(~0.5 天) 1. **§7 Phase G Web UI 简洁版**(~2-3 天)—— FastAPI + Jinja2 + HTMX + SSE,task list / chat / folder tree / 文件上传下载;依赖 D(HTTP /v1)的 SSE 端点,与 E 无强序。Phase G 也可先上 task list + chat(读 PG)再补 folder tree。
- Step 6 no-subtask SQL 校验(`new LIKE existing/%` cascade,放 build_agent / Folder API 入口) 2. **§7 C Executor + sandbox**(~2-3 天)—— `run_python`/`shell` → `Executor.run(...)`;本地保留 subprocess executor,SaaS 走 docker;`api_key_env` → `KeyProvider` 运行时注入。
- ~~Step 4 task_dir 双形态~~ ✅(05-14)
- ~~Step 5 migrate-from-fs~~(取消,不兼容旧 workspace)
2. **§7 Phase G Web UI 简洁版**(~2-3 天)—— FastAPI + Jinja2 + HTMX + SSE,task list / chat / folder tree / 文件上传下载;依赖 D(HTTP /v1)的 SSE 端点,与 E 无强序。
3. **Phase 6 context 三层压缩**(~1 天)—— 兜底,V4 长上下文一般用不到。 3. **Phase 6 context 三层压缩**(~1 天)—— 兜底,V4 长上下文一般用不到。
4. **Phase 7 更多 skill / 模型档案**(持续)。 4. **Phase 7 更多 skill / 模型档案**(持续)。
5. **Proposal mermaid 预渲染**(~半天)—— ASCII 透传不够用时再上 `mmdc` 5. **Proposal mermaid 预渲染**(~半天)—— ASCII 透传不够用时再上 `mmdc`
> §7 B 已完工(Step 1-4 + Step 6,Step 5 取消)。剩余路线:C(Executor)/ D(HTTP API)/ E(auth + UI)/ G(Web UI)/ F(deploy / billing)。

3
RUN.md
View File

@ -2,7 +2,7 @@
> 怎么把 zcbot 跑起来。env / 常用命令 / 故障兜底。设计看 `DESIGN.md`,进度看 `PROGRESS.md` > 怎么把 zcbot 跑起来。env / 常用命令 / 故障兜底。设计看 `DESIGN.md`,进度看 `PROGRESS.md`
最后更新:2026-05-14(Step 4) 最后更新:2026-05-14(Step 6)
--- ---
@ -103,6 +103,7 @@ REPL 内命令:`/exit /reset /new /resume [last|<id>] /id /status /done /abandon
| Resume 找不到 task | `cli.py tasks` 看 task_id 是否在;前缀冲突报 ambiguous 时给完整 UUID | | Resume 找不到 task | `cli.py tasks` 看 task_id 是否在;前缀冲突报 ambiguous 时给完整 UUID |
| `--task-dir` 指定后 `/exit` 没清 task_dir | 设计如此 —— 用户路径绝不 rmtree;DB 行该删还是删。要彻底删手动 `rm -rf <dir>` | | `--task-dir` 指定后 `/exit` 没清 task_dir | 设计如此 —— 用户路径绝不 rmtree;DB 行该删还是删。要彻底删手动 `rm -rf <dir>` |
| Export 报 "无可导出内容" | task 没 messages(只 system 不算);先在 REPL 发条消息再 export | | Export 报 "无可导出内容" | task 没 messages(只 system 不算);先在 REPL 发条消息再 export |
| `NoSubtaskError: task_dir ... 与已有 task ... 前缀嵌套` | §7.4 no-subtask:同 user 不允许 task_dir 嵌套(child 或 parent)。**同项目多对话**请传**完全相同**的 `--task-dir`;否则改路径成 sibling(平级) |
--- ---

View File

@ -13,10 +13,19 @@ from .engine import (
session_scope, session_scope,
) )
from .models import SENTINEL_USER_ID from .models import SENTINEL_USER_ID
from .utils import ensure_local_task_row, get_task, update_task, upsert_task from .utils import (
NoSubtaskError,
check_no_subtask,
ensure_local_task_row,
get_task,
update_task,
upsert_task,
)
__all__ = [ __all__ = [
"NoSubtaskError",
"SENTINEL_USER_ID", "SENTINEL_USER_ID",
"check_no_subtask",
"ensure_local_sentinel", "ensure_local_sentinel",
"ensure_local_task_row", "ensure_local_task_row",
"get_engine", "get_engine",

View File

@ -1,16 +1,20 @@
"""Storage 辅助:tasks 表的 idempotent 创建 / UPSERT / UPDATE""" """Storage 辅助:tasks 表的 idempotent 创建 / UPSERT / UPDATE / no-subtask 校验"""
from __future__ import annotations from __future__ import annotations
from typing import Any, Optional from typing import Any, Optional
from uuid import UUID from uuid import UUID
from sqlalchemy import func, select, update from sqlalchemy import func, select, text, update
from sqlalchemy.dialects.postgresql import insert from sqlalchemy.dialects.postgresql import insert
from .engine import session_scope from .engine import session_scope
from .models import SENTINEL_USER_ID, Task from .models import SENTINEL_USER_ID, Task
class NoSubtaskError(ValueError):
"""task_dir 与同 user 已有 task 形成前缀嵌套(§7.4 no-subtask 策略)。"""
def ensure_local_task_row( def ensure_local_task_row(
task_id: UUID, task_id: UUID,
task_dir: str = "", task_dir: str = "",
@ -93,3 +97,43 @@ def get_task(task_id: UUID) -> Optional[Task]:
return s.execute( return s.execute(
select(Task).where(Task.task_id == task_id) select(Task).where(Task.task_id == task_id)
).scalar_one_or_none() ).scalar_one_or_none()
def check_no_subtask(
task_dir: str,
user_id: UUID = SENTINEL_USER_ID,
) -> None:
"""§7.4 no-subtask:同 user 下校验 task_dir 不能与已有 task_dir 形成前缀嵌套。
允许: task_dir(同项目多对话)完全无关路径(平级或不相关)
拒绝:new existing 的子目录existing new 的子目录
task_dir / whitespace 跳过(legacy / 未绑项目)
分隔符容差:Windows `\\` Linux `/` LIKE 前统一替换成 `/`, OS 不漏判
"""
if not task_dir or not task_dir.strip():
return
td_norm = task_dir.replace("\\", "/")
# 用 bind 参数传 backslash,绕开 SQL 字符串转义陷阱
sql = text(
"SELECT task_id, task_dir FROM tasks "
"WHERE user_id = :uid "
" AND task_dir <> '' "
" AND replace(task_dir, :bs, '/') <> :td "
" AND ("
" :td LIKE replace(task_dir, :bs, '/') || '/%' "
" OR replace(task_dir, :bs, '/') LIKE :td || '/%'"
" ) "
"LIMIT 1"
)
with session_scope() as s:
row = s.execute(
sql, {"uid": str(user_id), "td": td_norm, "bs": "\\"}
).first()
if row is None:
return
existing_id, existing_dir = row
raise NoSubtaskError(
f"task_dir {task_dir!r} 与已有 task {str(existing_id)[:8]}"
f"task_dir {existing_dir!r} 前缀嵌套 — 同项目多对话请用相同 task_dir"
)

View File

@ -22,7 +22,7 @@ from core.memory import memory_block
from core.session import Session from core.session import Session
from core.sinks import ConsoleEventSink from core.sinks import ConsoleEventSink
from core.skills import SkillRegistry from core.skills import SkillRegistry
from core.storage import ensure_local_sentinel from core.storage import check_no_subtask, ensure_local_sentinel
from core.task import TaskState from core.task import TaskState
from tools.fs import EditTool, GlobTool, GrepTool, ReadTool, WriteTool from tools.fs import EditTool, GlobTool, GrepTool, ReadTool, WriteTool
from tools.run_python import RunPythonTool from tools.run_python import RunPythonTool
@ -195,6 +195,11 @@ def build_agent(
task_id, task_dir = resolve_task_id(workspace_dir, session_id, resume, task_dir_arg) task_id, task_dir = resolve_task_id(workspace_dir, session_id, resume, task_dir_arg)
sid = str(task_id) sid = str(task_id)
# §7.4 no-subtask:新建 task 时校验 task_dir 不与同 user 已有 task 形成前缀嵌套
# (resume 跳过 —— 该 task 已落库,改名走 Folder API 的 cascade)
if not resume:
check_no_subtask(str(task_dir))
tool_base = Path(tool_base) if tool_base else Path.cwd() tool_base = Path(tool_base) if tool_base else Path.cwd()
skills = SkillRegistry(ROOT / cfg.get("skills_dir", "skills")) skills = SkillRegistry(ROOT / cfg.get("skills_dir", "skills"))