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:
parent
2b3692c8bf
commit
e8dbfa57a5
18
PROGRESS.md
18
PROGRESS.md
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
> 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。
|
||||
|
||||
最后更新:2026-05-14(Step 4)
|
||||
最后更新:2026-05-14(Step 6)
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -15,7 +15,7 @@
|
|||
| 5 | Eval Suite | ⏸ 不做 | dogfooding 替代,probe 覆盖健康检查 |
|
||||
| 6 | 长任务工程化 | 🟡 | task + 恢复 ✅;双层记忆 ✅;context 压缩未做 |
|
||||
| 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 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 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/engine.py 80 ← §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/fs.py 182
|
||||
tools/shell.py 94
|
||||
tools/run_python.py 84
|
||||
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 保护用户目录
|
||||
db/migrations/env.py 61 ← §7 B Step 1
|
||||
db/migrations/versions/
|
||||
|
|
@ -86,11 +87,10 @@ Python 合计 ~3101 行
|
|||
|
||||
## 下一步候选(性价比排序)
|
||||
|
||||
1. **§7 B 剩余 Step 6**(~0.5 天)
|
||||
- Step 6 no-subtask SQL 校验(`new LIKE existing/%` cascade,放 build_agent / Folder API 入口)
|
||||
- ~~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 无强序。
|
||||
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。
|
||||
2. **§7 C Executor + sandbox**(~2-3 天)—— `run_python`/`shell` → `Executor.run(...)`;本地保留 subprocess executor,SaaS 走 docker;`api_key_env` → `KeyProvider` 运行时注入。
|
||||
3. **Phase 6 context 三层压缩**(~1 天)—— 兜底,V4 长上下文一般用不到。
|
||||
4. **Phase 7 更多 skill / 模型档案**(持续)。
|
||||
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
3
RUN.md
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
> 怎么把 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 |
|
||||
| `--task-dir` 指定后 `/exit` 没清 task_dir | 设计如此 —— 用户路径绝不 rmtree;DB 行该删还是删。要彻底删手动 `rm -rf <dir>` |
|
||||
| Export 报 "无可导出内容" | task 没 messages(只 system 不算);先在 REPL 发条消息再 export |
|
||||
| `NoSubtaskError: task_dir ... 与已有 task ... 前缀嵌套` | §7.4 no-subtask:同 user 不允许 task_dir 嵌套(child 或 parent)。**同项目多对话**请传**完全相同**的 `--task-dir`;否则改路径成 sibling(平级) |
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -13,10 +13,19 @@ from .engine import (
|
|||
session_scope,
|
||||
)
|
||||
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__ = [
|
||||
"NoSubtaskError",
|
||||
"SENTINEL_USER_ID",
|
||||
"check_no_subtask",
|
||||
"ensure_local_sentinel",
|
||||
"ensure_local_task_row",
|
||||
"get_engine",
|
||||
|
|
|
|||
|
|
@ -1,16 +1,20 @@
|
|||
"""Storage 辅助:tasks 表的 idempotent 创建 / UPSERT / UPDATE。"""
|
||||
"""Storage 辅助:tasks 表的 idempotent 创建 / UPSERT / UPDATE / no-subtask 校验。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Optional
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy import func, select, update
|
||||
from sqlalchemy import func, select, text, update
|
||||
from sqlalchemy.dialects.postgresql import insert
|
||||
|
||||
from .engine import session_scope
|
||||
from .models import SENTINEL_USER_ID, Task
|
||||
|
||||
|
||||
class NoSubtaskError(ValueError):
|
||||
"""task_dir 与同 user 已有 task 形成前缀嵌套(§7.4 no-subtask 策略)。"""
|
||||
|
||||
|
||||
def ensure_local_task_row(
|
||||
task_id: UUID,
|
||||
task_dir: str = "",
|
||||
|
|
@ -93,3 +97,43 @@ def get_task(task_id: UUID) -> Optional[Task]:
|
|||
return s.execute(
|
||||
select(Task).where(Task.task_id == task_id)
|
||||
).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"
|
||||
)
|
||||
|
|
|
|||
7
main.py
7
main.py
|
|
@ -22,7 +22,7 @@ from core.memory import memory_block
|
|||
from core.session import Session
|
||||
from core.sinks import ConsoleEventSink
|
||||
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 tools.fs import EditTool, GlobTool, GrepTool, ReadTool, WriteTool
|
||||
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)
|
||||
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()
|
||||
|
||||
skills = SkillRegistry(ROOT / cfg.get("skills_dir", "skills"))
|
||||
|
|
|
|||
Loading…
Reference in New Issue