63 KiB
63 KiB
实施进度
配合
DESIGN.md。本文件只记 phase 状态、决策偏差、文件量、下一步。
最后更新:2026-05-18(cli.py 改名 main.py(入口);原 main.py 挪到 core/agent_builder.py(装配 lib);CLI REPL chat / tasks / export 删,入口只剩 web / db / probe;§7 E CLI 双模式路线撤)
状态
| Phase | 标题 | 状态 | 备注 |
|---|---|---|---|
| 1-3 | 骨架 + Skill + run_python | ✅ | 三个 skill;CoreCoder 唯一匹配 edit;敏感 env 过滤 |
| 4 | 演化性能力 | 🟡 | Model Profile + Probing ✅;版本化 prompt 未做 |
| 5 | Eval Suite | ⏸ 不做 | dogfooding 替代,probe 覆盖健康检查 |
| 6 | 长任务工程化 | 🟡 | task + 恢复 ✅;双层记忆 ✅;context 压缩未做 |
| 7 | 打磨 | ❌ | Docker 沙盒 / 更多 skill |
| §7 SaaS | DESIGN §7 路线 | 🟡 | A 事件流化 ✅;B 完工;D /v1 JSON API 完工 ✅(原 Phase G Jinja2/HTMX UI 撤,改 platform 端实现);D' 过渡 auth(PLATFORM_KEY → JWT)+ dev SPA ✅;同 task 单活 run 锁 ✅;task-level cancel + dev SPA stop 按钮 ✅;0004 schema 瘦身 ✅(删 runs/usage_events);入口归位 ✅(cli.py→main.py,装配 lib 挪 core/agent_builder.py,CLI REPL 删,§7 E 撤);真 OIDC 待;C(Executor)待。 |
已完成关键能力
- 05-18 / 入口归位:
cli.py改名main.py、原main.py挪core/agent_builder.py,删 CLI REPLchat/tasks/export,§7 E 双模式路线撤:接 0004 schema 大瘦身后又一轮架构清理。用户复盘"§7 E--remote是不是可以移除""有 dev SPA 后 CLI REPL 还需要吗""统一到 main.py 是否合理"——一连串问题指向同一个底层:cli.py(CLI 入口)+main.py(装配 lib)+chat / tasks / exportREPL 子命令是历史多形态共存遗留,在"UI 由 platform 实现 + dev SPA 是开发主路径"的新形态下都是冗余。架构判断:main.py此前混三角色(装配 lib + 路径/验证 utility + 被 cli+web 共同 import 的事实入口),按 §5 Less Scaffolding + SoC 应该拆;直接答案是cli.py改名main.py(入口),原main.py改core/agent_builder.py(装配 lib),单一职责对齐 Python 社区惯例(入口叫 main.py,lib 在子模块)。改动:①git mv main.py core/agent_builder.py;②git mv cli.py main.py(覆盖);③ 5 处web/app.py::from main import xxx→from core.agent_builder import xxx(build_agent / sync_task_tokens / working_dir_from_name / resolve_workspace / user_root / InvalidTaskName / validate_task_name);④ 新main.py自指from main import改from core.agent_builder import;⑤ 删chat / tasks / export三个 click 命令 + REPL 内部 helpers(_cleanup_if_empty / _delete_task_db_row / _task_has_messages / _list_task_rows共 ~110 行)+ REPL 主循环(/exit /reset /new /resume /id /status /done /abandon /desc /export共 ~200 行)+--name --working-dir --skill --desc --resume --modelCLI 选项 +tasks列表渲染 +export命令 — 共 ~400 行;新main.py~180 行(db {upgrade,downgrade,current}+probe+web三命令组);⑥core/agent_builder.py顺手清:删_resolve_uuid_or_prefix函数(web 端只传完整 UUID,前缀匹配无 caller)+resolve_task_id内task_id_arg in (None, "", "last")分支(web 不传 "last"),resume 直接UUID(task_id_arg);模块 docstring "本地 CLI user_id = SENTINEL" → "所有入口走 web/v1+ JWT;dev SPA 默认填 SENTINEL 走同一条路径"。Smoke 6 case 全绿(in-process TestClient + 子进程跑python main.py db current):①/healthz200 ② POST /v1/tasks → GET → POST messages(返events_url无 run_id)→ cancel → DELETE 全链路 ③/v1/folders走core.agent_builder.user_root路径 ④/v1/files走_load_user_root⑤resolve_task_id完整 UUID resume(去前缀匹配后用UUID(...)直接解析;非 UUID 字符串 ValueError;ghost UUIDempty working_dirValueError)⑥subprocess.run([sys.executable, "main.py", "db", "current"])子进程跑通 + stdout 含0004 (head)(验证 click 入口、alembic config 路径、ROOT 解析都没坏)。文档同步:DESIGN §1 形态兼容(删--remote,讲"无 CLI / in-process 分叉")/ §2 目录树({main.py, cli.py}→core/agent_builder.py + main.py)/ §3.3cli.py probe→main.py probe/ §3.6 "REPL 内 task 切换"段改"Task 切换 / 软删 走 dev SPA + /v1" + "入口"段讲python main.py web/ §7.0 共享差别表入口列改python main.py web+ auth 行讲"dev SPA 填 sentinel + 本地 key" / §7.6 #8 标"已撤" / §7.7 E 阶段标"撤" / §7.8 风险表"CLI 双模式分叉"行融合进"过早抽象" / §7.9 新增"CLI REPL 撤,入口统一 main.py"取舍说明 + 删原"CLI 双模式共存"段;RUN 顶 / 一次性初始化 / 日常命令 / 故障兜底 / 关键路径全部cli.py→main.py,且日常命令段重写"只剩web / db / probe+ 所有 task 交互走 main.py web 后浏览器或 /v1";PROGRESS 文件清单 / 状态表 / 下一步候选同步(去掉 E 路线)。**净效果**:总代码 -360 行(cli.py558 行删 →main.py180 行 +core/agent_builder.py~320 行 = ~500;原main.py337 +cli.py558 = 895;净减 -395);入口文件数 2 → 1;维护面 -1 套 task 切换语义(REPL/new /resume /done /abandon全归到/v1/tasks*`);测试面 -1 套(原 cli build_agent 调用链 smoke 全归到 web TestClient)。 - 05-18 / 0004 schema 大瘦身:删 runs / usage_events 表,run_status / run_error 合入 tasks;路由从 run_id 维度改 task 维度:用户复盘"为什么 cancel 接口要带 run_id?现在不是一个 task 一个 run 吗",顺手把 runs / usage_events 表也重新审视 —
usage_events全代码库零引用、零写入、零读取,纯死代码(为未来计费预付的架构成本);runs表tokens_p/c写但从未被读(tokens 累计走 tasks 列),started_at / finished_at / error也只写不读,run_id唯二实用是 broker 频道键 + cancel 参数 — 但 §7.1 已选定单活 run 形态,同 task 同时最多 1 个活 run,客户端只需要 task_id(永远有)就够,run_id 完全冗余。按"开发期不写兼容层"心智一把切干净。alembic 0004:DROP TABLE usage_events / runs,tasks加run_status text not null default 'idle'(idle / running / cancelling / error)+run_error text null。ORMmodels.py删Run/UsageEvent两 class + 删BigIntegerimport;Task加两列;storage/__init__.py文档示例同步;Task.run_status终态语义:ok / cancelled收尾都回idle(用户视角"跑完 / 停了"等价不留持久标记),只有error是持久终态,起新 run 时清。Broker(web/broker.py)全面 task_id 索引:_subs / _done / _cancel_flags三个 dict key 从 run_id 换 task_id;加start(task_id)入口在新 run 起来前清_done标记(避免上一轮 done 让新订阅者立刻断流)。Sink(web/sinks.py)绑 task_id 替代 run_id。web/app.py:①_run_agent_bg(task_id, user_id, content)去掉 run_id 参数;装agent.cancel_check = lambda tid=task_id: broker.is_cancelled(tid);终态写tasks.run_status = "idle"(原Run.status = "ok"/"cancelled")或"error"(run_error = err);finallybroker.clear_cancel(tid) + broker.close(tid)。②POST /v1/tasks/{tid}/messages改:SELECT Task.run_status … FOR UPDATE替代select(Run.run_id) … running/cancelling;同事务UPDATE Task SET run_status='running', run_error=NULL(error 也算可重启视为清);commit 后broker.start(tid)清 done;返{"events_url": "/v1/tasks/{tid}/events"}去掉run_id。③POST /v1/tasks/{tid}/cancel取代POST /v1/tasks/{tid}/runs/{rid}/cancel,只校验 task 归属 user;run_status != 'running'→ 409。④GET /v1/tasks/{tid}/events取代/runs/{rid}/events,broker.subscribe(tid)。⑤ lifespan reaperUPDATE Task SET run_status='error' WHERE run_status IN ('running','cancelling'),文案不变。⑥_task_dict暴露run_status/run_error字段给前端。dev SPA(web/static/dev.html):state.currentRunId改state.streamingbool;POST /messages拿到events_url直接订阅,不再保存 run_id;cancel 按钮 click →POST /v1/tasks/{tid}/cancel(去掉/runs/{rid}/)。Migration 跑通:本地 PGdb upgrade 0003 → 0004 (head)一把过(用户授权清旧数据,无 backfill)。Smoke 18 case 全绿(in-process TestClient + BG mock):POST /messages 返events_url无 run_id / tasks.run_status='running' / gate when running 409 / POST /cancel 202 + run_status='cancelling' + broker flag set / double cancel 409(状态非 running)/ gate during cancelling 也 409 / cancel idle 409 / cancel error 409 / error 状态可发新消息(error 不挂 gate + 清 run_error) / ghost task 404 / invalid UUID 404 / cross-user 404 / no auth 401 / GET /events 路由注册(SSE 流式跑会挂 30s 心跳,smoke 只验路径 + headers) / GET /tasks 返回 run_status / run_error 字段 / stale reaper 扫 running+cancelling 标 error / broker.start 清 _done / broker.subscribe + emit + close + late subscriber 立刻收 done / broker.request_cancel + is_cancelled + clear_cancel。净增量:核心代码 -200 行(删表 ORM + 两路由层简化),broker 加 21 行 start/cancel API,dev.html 几行字段重命名;DB 表 5 → 3,路由/runs/{rid}/{events,cancel}→/{events,cancel},前端 SPA 不再需要先拿 run_id 才能 cancel / 订阅 — 客户端只需 task_id。文档同步:DESIGN §7.2 路由表 messages 路由返events_url(去run_id)+ cancel / events 改 task-level + lead-in 注 0004 简化 + SSE schema text event 字段delta(实际就是 delta,文档原content笔误);§7.4 schema 块 tasks 加两列 + 注 0004 合并;§7.9 hard cascade 行注 "原 usage_events 0004 删" + 加专项取舍说明"0004 删 runs + usage_events 表";§7.7 风险表两行同步 / 改 task-level 路由名;RUN 路由表三路由全改 + 故障兜底 cancel 409 文案改 + db upgrade head 改 0004;PROGRESS 已完成 + 状态表 + 文件清单。 - 05-18 / cancel run endpoint + AgentLoop 协作式 cancel + dev SPA stop 按钮:用户反馈"等待回复或 LLM 操作时没有停止接口"。落地 DESIGN §7.2 原标"待"的
POST /v1/tasks/{id}/runs/{rid}/cancel。Broker(web/broker.py):加request_cancel(rid)/is_cancelled(rid)/clear_cancel(rid)三方法,内部dict[UUID, threading.Event]per-run;setdefault保证 BG 还没 register 也能 set。Loop(core/loop.py):AgentLoop加cancel_check: Optional[Callable[[], bool]]字段(CLI 路径不传 = None 永不 cancel),_is_cancelled()helper +_fill_cancelled_tool_results(remaining)给未执行的 tool_call 全部 append[cancelled by user]tool message —— LiteLLM 协议要求每个 assistant tool_call 必须有匹配 tool result,否则 resume 时 LLM 报错。Check 点:每轮 LLM 前 + tool_calls 之间。命中 emitcancelledevent + return[cancelled]。LLM 同步 call 本身不可中断(litellm 同步阻塞,无原生 cancel)—— 接受最坏等当前一轮跑完(通常几十秒),注释里讲清楚。Endpoint(web/app.py::cancel_run):校验 task 归属 user + run 归属 task(else 404),run.status必须是running(else 409 含具体 status);标cancelling(过渡态)+broker.request_cancel(rid);202。_run_agent_bg装配时agent.cancel_check = lambda rid=run_id: broker.is_cancelled(rid),run 完时判broker.is_cancelled写终态cancelledvsok;finallybroker.clear_cancel + broker.close。Gate 同步扩:post_message单活 run 检查从status == 'running'改status in ('running', 'cancelling'),确保 cancel 后旧 BG 还没退出时新 POST 仍 409(避免新旧 run 撞 messages.idx)。Reaper 同步扩:lifespan 启动也扫cancelling(进程 crash 时 BG 来不及写终态 cancelled,反正没线程在跑就清掉)。dev SPA(web/static/dev.html):chat 表单加<button id="chat-cancel" class="small danger">stop</button>(常态 hidden);state 加currentRunId;sendMessage 拿到 run_id 后 show stop,fetchSsetry/finally收尾时一并 hide stop / 清 currentRunId / 复原 send button(确保 SSE 失败路径 UI 也 reset 不卡死)。cancel 按钮 click →POST /runs/{rid}/cancel;409 静默忽略(并发 done 不算错)。handleSseEvent加cancelledcase → 在当前 assistant 卡贴一个虚线红框 "已停止(stopped by user)" badge。CSS 加.cancelled-badge。Smoke 15 case 全绿:HTTP 层 11 case(cancel happy + 双 cancel 409 + cancelling 期间 POST messages 409 + ghost run 404 + invalid UUID 404 + cross-task 404 + cross-user 404 + cancel 已 ok 409 + cancel 已 error 409 + no auth 401 + stale reaper 扫 cancelling);Loop 层 4 case(cancel before first iter 不调 LLM / cancel between tool_calls 补 cancelled placeholder 3 个 + 保协议 + emit cancelled / 正常 done 不 emit cancelled / CLI 路径 cancel_check=None 默永不 cancel)。没动 SSE handler 的 break list(("done", "error")):cancelled 在 SSE 里走流给前端看,broker.close 之后立即跟 done 收流。文档同步:DESIGN §7.2 路由表 cancel 行从"待"扩成完整描述 + SSE 事件加cancelled{}行 + §7.7 风险表加"Run 跑太久 / 用户想中断"行;RUN 路由表加 cancel 行 + POST /messages 409 文案改 "running / cancelling" + 故障兜底加三行(cancel 409 / 点 stop 没立刻停 / reaper 扫 cancelling);PROGRESS 已完成 + 下一步重排(去掉 cancel,留 OIDC / C Executor / E CLI 双模式)。 - 05-18 /
POST /v1/tasks/{id}/messages单活 run 锁 + 孤儿 reaper:用户连点 send / 多 tab 同时发消息 → 两个 BG 线程争messages.idx(UniqueConstraint 会 race-crash 第二个 INSERT)的旧 TODO 落地。实现:web/app.py::post_message把所有权 + 活跃 Run 检查 + 新 Run INSERT 收进一个session_scope()事务,首行用select(Task.task_id).where(...).with_for_update()锁 task 行序列化并发 POST;事务内查Run.status='running'命中即 raiseHTTPException(409, "task already has a running run ({rid}); wait for it to finish");无活跃则同事务s.add(Run(...status="running"))—— 三步原子完成,避免 TOCTOU。lifespan 加 stale-run reaper:启动时UPDATE runs SET status='error', error='server restarted before run finished' WHERE status='running',把进程 crash 留下的孤儿 running 全清掉(否则对应 task 永挂 409)。结果 rowcount > 0 时 print info 行[startup] reaped N stale running run(s)。Cancel 路由(DESIGN §7.2 标 "待")没改:有了它 409 时用户可主动 cancel,不必等流式结束。没动Session.append:gate 已在 HTTP 层挡住了,单写者前提下 idx 自递增不会冲;在 ORM 里再加锁是过度。Smoke 10 case 全绿(in-process TestClient +_run_agent_bgmock 不真起 LLM):happy(202 + Run INSERT running)/ gate(同 task 第二 POST 409 + detail 含 "running run" + "wait for it to finish")/ clear after Run.status=ok 解锁(202)/ clear after Run.status=error 同(202)/ ghost task 跨用户路径 404(锁前所有权检查)/ invalid UUID 404 / empty content 400 早于 lock / no auth 401 早于 lock / stale reaper 测试(强行 SET 全部 Run=running → 开新 TestClient 触发 lifespan → 所有 running 变 error + 之后 POST 还能 202)/ cross-user(other UID token 访 sentinel task → 404 不暴露存在性)。采坑:@case每个用make_client()起新 app 会重复触发 reaper,把 case 1 留下的 running 清掉 → case 2 的 409 测不出来;改成全部 case 共享一个 SHARED_CLIENT 跑,仅 stale-reaper case 用fresh=True开第二个。文档同步:DESIGN §7.2 POST /messages 行注 409 行为 + cancel "待" 后注"做出来后 409 可主动 cancel" / §7.7 风险表加"同 task 并发 POST messages.idx race"行;RUN 路由表 POST /messages 注 409;故障兜底替过期 TODO 行 → 加 "POST 返 409" 处置 + "[startup] reaped N stale running" 解释。未来 TODO:multi-worker 部署形态下 reaper 不能简单全表清(会误清其他 worker 的真在跑 run),换 heartbeat + lease(注释里记了)。 - 05-17 / files API 全面 user-rooted(去掉 task_id 前置):用户反馈"web 页应该能看到 user 的所有目录,现在只能选 task 后右侧才刷新"——根因是原 files API 用 task_id 拐杖间接拿 working_dir,迫使前端必须先选 task。语义上 files 操作只关心"路径 + user 边界",task_id 是多余的;同时 §7.1 心智模型早就把 task 和 dir 定义为正交副视图,API 不该混。后端:删
_load_working_dir(task_id, user_id),加_load_user_root(user_id)(走main.user_root(ws, uid)自动 mkdir 拿workspace/users/<uid>/);4 路由全换:GET /v1/files?path=/GET /v1/files/download?path=/POST /v1/files/upload/POST /v1/files/delete。_safe_join边界从 task_dir 改 user_root,安全性不降低;_enumerate_files加 dotfile 过滤(if p.name.startswith(".")跳过.memory/等,同/v1/folders约定);_rel_to把Path(".")归一为空串(避免 root 时 current="." 这种 ugly 形态)。删from_db_pathimport(只剩to_db_path)。dev SPA:loadFiles()不再 gate onstate.taskId,enterApp 时直接调一次拉 user_root;selectTask在拿到 task meta 后state.filesPath = wdName(从 working_dir 末段抽出)再 loadFiles,选 task 自动跳到对应子目录但用户可点 crumb 回 root 看其他目录;crumbs root 标签 "/" → "我的"(user_root 直观);files-proj header 从"项目名(state.taskMeta 派生)"改"路径首段(数据驱动)",空时显示(user root)。新增 upload 按钮(原来藏在外部页面里没暴露给 SPA):pane-head 加⬆按钮 + 隐藏<input type=file multiple>,onchange 走 FormData POST/v1/files/upload,path 取当前state.filesPath(空 → user_root);上传完 loadFiles 刷新。deleteCurrentTask不再重置 files 面板(task 删了但 FS 文件保留,继续浏览有意义),只 reload 当前路径。btn-refresh-files移除 disabled 状态(任何时候可用)。Smoke 68 case 全绿(in-process TestClient,跑完即删_smoke_files.py):列 user_root(包含 working_dir 目录,.memory被过滤) / 列子目录 2 层 / 不存在路径 200+exists=False / 路径安全 6 case(..// 绝对 / Windows 绝对 /\\起头)/ upload 单 / multi+nested mkdir / 上传到 root / 文件名攻击 4 case(../../\\)/ download 文件 + 深度 + 目录 400 + ghost 404 + 越界 400 / delete 文件 / 空目录 / 非空 400 / user_root 拒 / ghost 404 / 越界 400 / 跨 user 隔离 4 case(A 不见 B,B 不见 A)/ 无 token 全 401(GET list / POST upload / POST delete / GET download)/ 子目录里 dotfile 也过滤 / 新 user 首访 user_root 自动 mkdir + 列表空。文档:DESIGN §7.2 路由表段 + lead-in 同步("Task 一等公民,files 是其副视图(经 task_dir 暴露)" → "Task 一等公民;files 与 task 正交,走 user-rooted /v1/files*,以 workspace/users// 为边界")。 - Q1 → 05-06 / Phase 1-4:骨架 / 三 skill / run_python / Model Profile + Probing。ppt v3 加商务红 + apply_brand + Iconify;素材摄取改 markitdown CLI。
- 05-06 / Phase 6 部分:task + state.json + tokens 累计;CLI
tasks+ REPL/status /done /abandon /desc;移除 legacyworkspace/sessions/。 - 05-07 / TUI + task_dir:rich Markdown 渲染;spinner 显实时耗时 + 累计 token;system prompt 注入 task_dir 绝对路径,产物收敛
workspace/tasks/<id>/;.gitignore删 bandaid。 - 05-08 / REPL 切换 + 懒创建:
/resume [last|<id>];build_agent不预占文件;_cleanup_if_empty三条件守门。 - 05-09 → 05-10 / §7 草案 + 导出:DESIGN §7 初版(05-12 重写);
cli.py export <task_id>+core/export_docx.py。 - 05-11 / 原子写 + 双层记忆 + §7 A:
atomic_write_text接管 save;core/memory.py(core.md 入 prompt,extended/* 走索引);loop 事件流化(sink.emit)铺 SSE 路。 - 05-12 / §7 改写:platform/core 多租户方案废弃,改 user-direct(folder-centric、task/messages 入 PG、no-subtask、hard cascade)。
- 05-14 / §7.1 心智模型修正:
Folder-centric→ Task 一等公民 + Dir 文件副视图(双视图正交,dir 不是 task 父容器);task_dir 留空=一次性对话 / 指定=项目化二分语义入文。 - 05-14 / §7 B Step 1 基建:
core/storage/{engine,models}.pySQLAlchemy 2.x ORM(users/tasks/messages/runs/usage_events 5 表)+ alembic(初版 migration0001_initial_schema,GIN/复合索引)+cli db {upgrade,downgrade,current}子命令组 + 本地 sentinel user(00000000-...)+ZCBOT_DB_URL必填(未设给清晰报错,不引导 docker)。已在远端测试 PG 跑通db upgrade head。 - 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_rowidempotent 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,显式 setupdated_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.pymeta 改从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 时从 PGtasks.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 字符串转义陷阱)。空 / whitespacetask_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)。 - 05-14 / §7 Phase G G1 Web UI 脚手架:新增
web/包(app.pyFastAPI 工厂 +templates/{base,home}.html+static/style.css),cli.py web --host --port --reload子命令(默认 127.0.0.1:8765,本地形态 sentinel user 无 auth,Phase D 才上 OIDC)。模板用 Jinja2 + HTMX/HTMX-SSE 走 CDN(无 node 链路),base.html留{% block nav %}让 G2+ 扩。Starlette 新版TemplateResponse签名:(request, name, context),旧式塞 context 里会让 jinja 用 dict 当 cache key 报unhashable type,踩过修了。requirements 加fastapi>=0.111 uvicorn[standard] jinja2>=3.1 python-multipart(后者为 G5 文件上传留)。Smoke 四路径全绿(in-process via StarletteTestClient):/healthz→ "ok" //→ 1063B(title + static link + version) //static/style.css→ 1624B //nonexistent→ 404。Linux portability 顺手:模板里 path 显示约定用Path.as_posix()(G3+ 模板落地);SSE 响应头 G4 上时带X-Accel-Buffering: no(nginx 反代友好)。 - 05-14 / §7 Phase G G2 task list 页:
web/app.py::list_tasks(limit, status)读 PGtasks+messagescount(updated_at 降序),返回模板友好的 dict 列表;不复用cli.py::_list_task_rows—— CLI 拿 tuple, Web 拿 dict,数据形状有别,等真有 schema 变更同步成本时再抽(避免预付抽象)。/路由换成 task 表渲染,filter via?status=active|completed|abandoned(无效值静默降级为 all);/tasks/{task_id}占位路由 UUID 校验 + DB 存在性校验,缺一则 404,有效则渲染task_placeholder.html(G3 来填消息流)。Linux portability 落地:_norm_path()把存的 backslash 在显示时全替成 forward slash(Path.as_posix()在 Linux 读 Win backslash 串时不归一,所以直接replace('\\','/'));Win Path.resolve() 存D:\projects\...、Linux 存/home/user/...,都能正确显示。template:home.html表格(id/updated/status/mode/model/msgs/tokens/desc-dir),status 用 badge(status-active/completed/abandoned配色),hover 高亮;空态文案。CSS:table 紧凑(.9rem)+tabular-nums对齐 + accent-soft placeholder note。Smoke 18 路径全绿(in-process):3 task seed(active/completed/abandoned)+ Win\Linux 双路径形态 → / 渲染对、status filter 正/反向、garbage status 静默 all、UUID 占位、notauuid 404、ghost UUID 404、limit 生效、/healthz 不退化。版本 0.1 → 0.2。 - 05-15 / §7 Phase G G3 chat 只读页:
web/app.py加_get_md()单例 MarkdownIt(gfm-like预设 + linkify + breaks,html=False禁内联 HTML 防 XSS),fenced code 走 pygments_pygments_highlight()回调(codehilitecssclass)。load_chat_messages(tid)读 PG idx asc;build_chat_blocks(messages)聚合显示块 —— system / tool 不入 block(tool 内嵌进 assistant 的 tool_call.result),user / assistant text 走 markdown 渲染,assistant.tool_calls 配对 tool result(orphan tool_call →[no result])。_args_preview60 字符截断,_pretty_json解析失败 fallback 原串。/tasks/{id}替换占位为chat.html渲染,删task_placeholder.html。template:.msg卡片(user 浅蓝 / assistant 白底),.bodymarkdown 区(<pre>/<code>/<table>/<blockquote>/<s>全 GFM 样式),tool_call 用<details>默认折叠(无 JS,浏览器原生开闭;summary显示 tool 名 + args 前 60 字预览,展开看 args_pretty + result)。CSS 加.codehilite浅色 token 配色(keyword / string / comment / function / number / operator 6 类,余下黑色)。Smoke 28 路径全绿:4 display blocks(user/assistant×3,system/tool 跳过)+ markdown 特性(table / fence / autolink / strikethrough / bold)+ tool 配对(call_1 命中、orphan 走[no result])+ HTML 含<details>/tool-badge/codehilite/<s>+ 空 task 文案 + invalid UUID 404 + util 单测(args_preview / pretty_json / render_md 边界)。版本 0.2 → 0.3。requirements 加markdown-it-py[linkify]/mdit-py-plugins/pygments。 - 05-15 / §7 Phase G G6 部分:/new 入口(提前于 G5 落):用户反馈 Web 没"新建对话"入口 — 加
GET /new表单页(description / mode / task_dir 三字段)+POST /new处理(strip 校验 +description与task_dir至少填一个否则 400 +check_no_subtask同 CLI / build_agent 一致拦前缀嵌套 → 409 +ensure_local_task_row写占位行 + 303 See Other 跳转/tasks/{tid})。task_dir 空 → 默认派生workspace/tasks/<uuid>/(同_default_task_dir),显式 →Path.expanduser().resolve()同 cli.py--task-dir。模板new_task.html加表单 + error 渲染(400/409 重渲带 form_state 不丢用户填的值);home.html 加+ new task主按钮 + nav 加new链接;base.html 默认 nav 也带 tasks/new。CSS 加.btn-primary/.new-task-form/.navlinks .active配色。懒创建保留语义:Task 在 /new POST 时入库,后续 build_agent 走 resume 路径(已存在,不冲突);CLI REPL/new仍走 build_agent 懒创建路径,不互相干扰。Smoke 21 路径全绿:GET 表单 200 + 三字段 / POST happy(description-only / custom task_dir)→ 303 + Location 正确 / DB 行字段对 + default-derived task_dir 含 uuid / 空描述空 task_dir → 400 重渲表单带 error / no-subtask 父子嵌套 → 409 + 错误文案 / home 页+ new task按钮 + nav 链接 //newnav 链接 active 标记。版本 0.4 → 0.5。 - 05-15 / litellm 启动 cost map 网络警告兜底:litellm 启动会去 GitHub 拉
model_prices_and_context_window.json,墙内 SSL 握手常超时,虽然有本地 backup 不影响功能,但 stdout 一行 WARNING 噪声大。core/llm.py在import litellm之前os.environ.setdefault("LITELLM_LOCAL_MODEL_COST_MAP", "True")(setdefault 不覆盖用户已显式设的值),走 litellm 的LITELLM_LOCAL_MODEL_COST_MAP=True路径直接用打包的本地 cost map,跳过 httpx.get。CLI / Web 都经core.llm走这条单点,不需要在多个入口分别设。冷启动从原来 ~5s SSL 超时降到 <1s。 - 05-15 / task_dir 改相对存储:DB
tasks.task_dir原存绝对(D:\projects\zcbot\workspace\tasks\<uuid>),改为 ROOT 内→相对 posix(workspace/tasks/<uuid>)、ROOT 外→保留绝对(用户--task-dir指外部项目的场景)。新增core/paths.py提供ROOT/to_db_path/from_db_path三个出口,所有读写边界统一过这里。读端:resolve_task_idresume 分支from_db_path(db_dir)(相对走ROOT/.,绝对原样 resolve);export_chat_to_docx自动从 PG 读时同样过from_db_path。写端:build_agent构造meta和TaskState时to_db_path(task_dir),web/app.py::/new同步。check_no_subtask抛掉原来 SQL 里replace(task_dir, :bs, '/')的拼接,改 Python 端 fetch + 双侧from_db_path归一到 absolute posix 后比前缀,逻辑更清晰且天然支持混合形态(老绝对 + 新相对 DB row 并存也对)。alembic0002_task_dir_relative一次 UPDATE 把现有 ROOT-prefix 行转相对(本机两条 active row 已 migrate 完);downgrade 反向用_:%//%LIKE 区分相对 vs 绝对。Smoke 四段全绿:round-trip(ROOT-内 / 外 / 空 / Windows backslash)/ check_no_subtask 混合形态 7 case(same / child / parent / sibling / outside-child / 绝对串新值 vs 相对串老 row 仍能拦 / 空跳过)/ resolve_task_id resume 还原一致 / build_agent 端到端写 DB 验证默认派生→相对、--task-dir外部→绝对。CLAUDE.md加"开发阶段不写兼容层"心智(用户指示)。 - 05-15 / §7 D 阶段:
/v1JSON API 落地;Phase G Jinja2/HTMX 路线撤掉:用户决定与已有 platform 联调,前端用 platform 框架,本仓库再维护 HTML 就是双套 UI 浪费(DESIGN §7.9 新增取舍说明)。删除:web/templates/*9 个模板 +web/static/*CSS 全去;requirements.txt拿掉 jinja2 / markdown-it-py / mdit-py-plugins / pygments(python-multipartupload 还要用,保留)。重写web/app.py全/v1/前缀,JSON 响应:POST /v1/tasks(创建,Pydantic body)/GET /v1/tasks?status=&limit=(列表)/GET /v1/tasks/{id}(单 meta,不含 messages 走 /messages 拿)/PATCH /v1/tasks/{id}({status?,description?,mode?}部分更新,active 不让从 web 切回)/GET /v1/tasks/{id}/messages(LiteLLM 原 payload 透传)/POST /v1/tasks/{id}/messages(JSON{content},返{run_id, events_url}+ 起 BG run)/GET /v1/tasks/{id}/runs/{rid}/events(SSE)/ files 4 路由全/v1/+ JSON 返回 /GET /v1/tasks/{id}/export(.docx 下载不变)/GET /healthz({"status":"ok"})/GET /302→/docs(Swagger UI)。SSE 事件 payload 由 HTML 片段切 JSON:每帧event: <type>+data: <JSON dict>,前端自渲染;event typesrun_start / llm_start / text / tool_call / tool_result / llm_end / error / done(去掉type键的剩余字段进 data)。Pydantic 请求体 给 FastAPI auto-docs 自动出 schema。CORSallow_origins=["*"]起步(部署 platform 时收紧)。没动:core/loop.pyevent shape(已是 dict)/web/broker.pyfan-out /web/sinks.pyWebEventSink / 文件路径安全归一 / no-subtask 校验。Smoke 50+ case 全绿(in-process TestClient + 真实 HTTP):root 302、healthz JSON、docs/openapi 暴露、tasks CRUD 全分支(create happy + custom dir + 双空 400 + 嵌套 409 + 列表 + 单 get + ghost/非 UUID 404 + PATCH 多分支 + 空 PATCH 400)、messages list/post(payload 透传 + run_id 返 + events_url 拼对 + 空 content 400)、files list/upload/download/delete(攻击名 400、路径越界 400、root 拒、size raw int、mtime ISO)、export PK\x03\x04 magic、CORS preflightAccess-Control-Allow-Origin: *。真实 HTTPcli.py web起服务 → curl/healthz/v1/tasks/openapi.json全 200 + 干净 JSON。版本 0.7 → 0.7(API surface 完工)。_smoke_api.pyad-hoc 跑完即删。沉淀的 Phase G 工作:sink 协议 / RunBroker fan-out / no-subtask 校验 / files 路径安全归一 / task_dir 相对存储 全部保留 —— 删的只是 UI 层。 - 05-15 / §7 Phase G G5 文件浏览 + 上传 / 下载 / 删:
web/app.py加四件套路由 —GET /tasks/{id}/files?path=<rel>(列目录树,面包屑 + 目录在前文件在后 + size humanize + mtime 格式化)/GET /tasks/{id}/files/download?path=<rel>(FileResponse + Content-Disposition)/POST /tasks/{id}/files/upload(multipartlist[UploadFile],?path=指目标子目录,自动mkdir(parents=True),303 回浏览页)/POST /tasks/{id}/files/delete(formpath=...,文件 / 空目录可删,非空目录 → 400,root → 400)。核心:_safe_join(root, rel)路径安全归一——空 / "."→ root;/\\起头 → 400(absolute-style 拒);Path.is_absolute → 400;(root / rel).resolve().relative_to(root.resolve())校验仍在 root 内(防..// symlink 逃逸)。上传文件名 strict 拒带 path 痕迹(/\\..parts)—— 现代浏览器只给 basename,异常 client 直接 400 不悄悄 sanitize。task_dir 不存在(skill 还没产物)→ 200 + 空文案,不报错。task_dir 空(legacy / 未绑)→ 400。_load_task_dir(task_id)共用入口:404 if 非 UUID / task 不存在,400 if task_dir 空,否则返(tid, abs_path)。模板:新增files.html(面包屑 nav + upload-formmultipart/form-data+<table class="file-list">行渲染目录用蓝色 +/后缀,文件用download链 + size + mtime + 删除按钮);chat.html在 page-head 加files按钮(task_dir 非空时显示)。CSS:.crumbs/.upload-form(虚线红框 accent-soft 区)/.file-list表 /.btn-minimini 按钮 +.btn-mini-danger红 hover /.ico-dir.ico-file文件类型标识。Smoke 50+ case 全绿:task_dir 不存在 200(2) / 列文件 + 子目录(12) / download 文件 + 子目录 + 404 + 目录-是非文件 400(7) / path 安全 6 case(../越界 + POSIX 绝对 + Win 绝对 +\\越界 +/tmp) / upload 单文件 + multi-file + nested mkdir + 攻击名../escape.txt/../../boom/ empty 全拒 + 目标 path 是文件 400 + 文件落 FS 内容一致(13) / delete 文件 + 空目录 + 非空 400 + ghost 404 + root 拒 + 越界拒(9) / chat.html files 链接 + ghost task_id 全 404(5) / task_dir 空 400(2)。版本 0.6 → 0.7。_smoke_g5.pyad-hoc 跑完即删。 - 05-15 / §7 Phase G G6 三件套:/done /abandon 按钮 + /export 下载 + 全局 toast:①
POST /tasks/{id}/status(status=completed|abandoned,active 不让从 web 切回 → 400)走UPDATE tasks SET status,303 redirect 回/tasks/{id}—— 浏览器全页刷新,聊天流不重发。chat.html active task 渲两个<form method="post">按钮(原生confirm()防误操,无 HTMX 依赖),completed/abandoned 自动隐藏按钮只显 status badge。②GET /tasks/{id}/export走tempfile.mkstemp(suffix=.docx)→export_chat_to_docx(tid, out_path=tmp)→FileResponse(..., background=BackgroundTask(tmp.unlink, missing_ok=True))响应完成自动删 tmpfile;无 messages → 400 / ghost UUID → 404 / 失败 → 500 带错文。chat.html 在n_messages > 0时渲<a class="btn">export .docx</a>(浏览器原生下载,无 HTMX 干预 Content-Disposition)。export_chat_to_docx顺手修了一个 bug:task_dir is None且 PG 也空时旧逻辑硬抛ValueError,即便out_path已经显式传入 —— 现在task_dir改为可选(None 时 meta 段显示(未绑)),只在out_path也 None 时才报错。③base.html末尾加<div id="toast-region">+ inline JS 监听htmx:responseError(4xx/5xx 抓 responseText 截 200 字)和htmx:sendError(网络层挂),自动 5-6s dismiss + 手动 × 关。CSS.toast/.toast-error右上角 fixed 区 +@keyframes toast-in/out滑入滑出;#toast-regionz-index 9999 +pointer-events: none(容器穿透,toast 自身可点)。Smoke 32 case 全绿:status 6 case(completed/abandoned 303 + DB UPDATE + GET 不再渲按钮、invalid status 400、active 400 拒切回、非 UUID 404、ghost 404)+ export 7 case(200 + Content-Disposition attachment + filenamechat_<8>.docx+ media-type docx + size > 8KB + magicPK\x03\x04+ no messages 400 + 404 双路径)+ toast 6 case(div / 两 listener / CSS)+ chat.html 7 case(active 渲 done/abandon/export + confirm 文案 + completed 不渲)。版本 0.5 → 0.6。_smoke_g6.pyad-hoc 跑完即删(不入 git)。TODO:并发同 task 多 run lock 还没做(留到 D 阶段或下次)。 - 05-15 / §7 D' 过渡 auth + dev SPA:platform 联调前需要 auth,但完整 OIDC 还要等;落地 PLATFORM_KEY → JWT 兑换 过渡形态(
web/auth.py),前后端走完全同一条流(platform 服务端 / dev 浏览器都持有 PLATFORM_KEY、调/v1/auth/login换 token、后续Authorization: Bearer <jwt>)。实现:pyjwtHS256,AuthConfig.from_env()启动校验PLATFORM_KEY/JWT_SECRET必填(任一缺失 fail-fast)、ZCBOT_JWT_TTL_SECONDS默认 7d、mint_token/verify_token/ensure_user_row(任意 user_id 幂等 INSERT users 行避免 FK 失败)。HTTPBearer(auto_error=False)Depends 拿凭证 →verify_token→ UUID;make_require_user(cfg)工厂闭包持 cfg,FastAPI Depends 抽签到每个 /v1/tasks* 路由。数据隔离:所有SELECT Task/UPDATE Task增Task.user_id == user_id条件;_load_task_dir(task_id, user_id)跨 user 视为 404(不暴露存在性);check_no_subtask(... user_id=user_id)、ensure_local_task_row(... user_id=user_id)同 user 隔离 no-subtask 校验。新增_assert_owns_task(s, tid, user_id)helper 复用 messages / SSE / export 三处所有权校验。豁免:/、/healthz、/docs、/openapi.json、/v1/auth/login、/static/*不验 token。dev SPA(web/static/dev.html~600 行单文件 vanilla JS):login overlay(user_id 默 SENTINEL 全 0 + platform_key) → localStorage 存 token → 3 栏布局(左 task 列表 + 状态 filter + 新建按钮;中 chat meta + 流式消息卡 + send 表单;右 file 浏览 + 面包屑 + 下载)+ 顶 bar(user 显示 + logout)+ new task modal。SSE 走 fetch + ReadableStream(不用 EventSource,因为 EventSource API 不支持自定义 header,token 没法塞;改用 fetch + 手解 SSE frame\n\n切帧、event:data:行解析、JSON.parse data 字段)。/ 302 → /static/dev.html(Swagger 仍在 /docs)。Smoke 32 case 全绿(TestClient + 真实 HTTP via uvicorn @8767):基本路由(/healthz / / 302 / dev.html 28KB / /docs 仍 200)+ 未带 token 8 路径全 401 + login 路径(bad key 403 / bad user_id 400 / happy 200 + token/expires_at/user_id 回显)+ 带 token CRUD 200/201 + 跨 user 隔离 4 case(other 看 sentinel 404 / 列表不串 / 各自创建独立 / sentinel 看 other 404)+ token 异常(garbled / Basic scheme / wrong-secret / expired 全 401)+ 真实 HTTP login + bearer call + dev.html 静态服务 29KB + root 302 Location 正确 + /docs 仍开放。版本 0.7 → 0.8。requirements 加pyjwt>=2.8.0。没动:core/*、build_agent、Session.append、CLI 全链(本地 SENTINEL 单 user 默认走通,不进 web auth)。TODO:真 OIDC 接入(替换 /v1/auth/login 内部为 ID token 校验,路由层不动)。 - 05-17 /
GET /v1/tasks+ ordering 排序(DRF 风格):加orderingquery 参数,逗号分隔多字段,-field倒序;allowlistcreated_at/updated_at/name/status;非法字段静默丢弃,全非法 fallback 默认。默认从-updated_at改-created_at(用户要求,创建时间倒序更稳定)。_parse_ordering(s)helper 返 sqlalchemyorder_by列表直接*expand。dev SPA 加 ordering dropdown(7 个常用选项:创建/更新时间双向 + 名称双向 + 状态分组),默认值-created_at不发送 URL(参数干净);onchange 同 filter 一样 reset page=1。Smoke 7 case:default =-created_at(mu/alpha/zeta) /created_atasc 反向 /nameasc(alpha/mu/zeta) /-namedesc 反向 / 多字段status,-created_at(状态 alpha 排序 abandoned→active→completed) / 非法字段garbage→ fallback default / 混合garbage,-name→ 仅-name生效。文档 DESIGN §7.2 / RUN 路由表同步。 - 05-17 /
GET /v1/tasks分页 + 多维筛选 + dev SPA 翻页/搜索:用户反馈 list 接口缺分页和 status 等筛选。改list_tasks_route:① 标准分页壳{page, page_size, count, results}(响应键固定顺序,前端契约稳定);② 6 个 query 参数 ——page(default 1, ≥1 clamp)/page_size(default 20,1–100 clamp)/status单值 active|completed|abandoned(非法值静默忽略)/skill精确匹配 /working_dir末段目录名(后端自动拼workspace/users/<uid>/<name>比对,客户端不用知道完整 db form)/q走 PGILIKE '%q%'同时打name+description两列;③ 实现 select 出 conditions list 一把过 + 单COUNT(*)+ 单SELECT LIMIT/OFFSET,无 N+1。dev SPA 改loadTaskList:从老的"无分页 ?status=" 改成构造 URLSearchParams 传 page+filters;state 新增taskPage / taskPageSize / taskTotal;新增renderPager()显示from–to / count (第 P/L 页)+ prev/next 按钮(disabled边界态);筛选输入框(#filter-q#filter-wd)debounce 300ms 后 reset page=1 重拉;#filter-wdautocomplete 复用<datalist id="folders-datalist">(focus 时 lazy 拉/v1/folders)。task list pane 改成三段:① label + status select + 刷新 ② q 搜索 + 工作目录筛选 ③ pager(条件态显示)。Smoke 12 case 全绿:无 filter (count=25, page1=20条) / page=2 (count=25, 5条) / page_size clamp 500 → 100 / page=0 → 1 / status 单维 (10) / skill 单维 (10) / working_dir CJK '水泥申报' (10) / q 'AI' (10) / q CJK '废弃' (5) / status+skill 组合 (10) / status+skill 不命中 (0+[]) / 非法 status 静默 → 全集 25。新 envelope 验证:list(data.keys()) == ['page','page_size','count','results']顺序固定。文档 DESIGN §7.2 路由签名 + RUN 路由表同步。 - 05-17 / 0003 schema:name + working_dir + skill 三件套(去掉 task_dir / mode 旧名):用户反馈"name 应该自动 / 可改;现在的 name 其实是工作目录(可建可选)"——也就是要把任务标识和工作目录解耦。同时观察到
mode命名抽象,跟项目skills/注册表对不上,顺手改skill。alembic 0003:用户授权清表(TRUNCATE tasks CASCADE)+task_dir → working_dir+mode → skill+ 加name TEXT NOT NULL(空表上 NOT NULL 不需要 backfill)。ORMTask三列同步;TaskState 加name字段、task_dir → working_dir、mode → skill;ensure_local_task_row / upsert_task 签名重排(name 必传 INSERT 路径);check_no_subtask ORM 引用 + 形参task_dir → working_dir;core/paths.py / export_docx.py / session.py 同步刷;main.py::build_agent 重构:new task 必传name(任务名)+ 可选working_dir(留空 → fallback name 作目录),两者都过validate_task_name;working_dir_from_name取代task_dir_from_name(纯路径派生);resolve_task_id形参working_dir_name;mkdir 在新建分支后 + check_no_subtask 后立即落盘;meta dict 多name字段、原task_dir/mode改working_dir/skill。CLI:chat --name <必填>+--working-dir <可选>+--skill <coding/ppt/...>;/new <name>自动复用当前 working_dir(取上层 task_dir 末段);/new无参 → 自动 gen新任务_HH-MM-SS;/status显示 name + skill + working_dir 全套;/resume列表加 name + skill 两列。web /v1:TaskCreateRequest字段name(req)+working_dir(opt) +description+skill;TaskPatchRequest加name+skill(去mode);create_taskworking_dir 留空 fallback 用 name + mkdir + check_no_subtask;新增GET /v1/folders(列 user 下 FS 非 dotfile 子目录 + 关联 task 计数 + 最后使用时间,sortlast_used desc, name asc);_load_task_dir → _load_working_dir;_task_dict返回 dict 加name字段、task_dir → working_dir、mode → skill;路径越界错文案 task_dir → working_dir。dev SPA modal:任务名 + 工作目录(配<datalist>autocomplete 走/v1/folders)+ skill + description 四字段;hd-new打开 modal 时拉 folders;nt-wd输入时实时提示"→ 复用已有目录 (N 个 task)" / "→ 新建目录 X" / "留空 → 用任务名 fallback";renderTaskList主行从"working_dir 末段"改为t.name(任务名优先),📁 工作目录名+ skill + description 走副行;renderChatMeta同步把 name 顶头 + 📁 + skill + tid + desc + 计数。Smoke:9 case/v1/tasksPOST 全绿(name+working_dir 双填 / 同 working_dir 二次共享 / 留空 fallback / name 缺 → 422 / name 非法 → 400 / working_dir 非法 → 400 / GET 列表含三新字段 / PATCH name+skill //v1/folders含 水泥申报 n_tasks=2 last_used 非空);CLI build_agent 4 case(new 双填 / append 后 reloaded 字段对 / resume 还原 / fallback)。文档:DESIGN §3.1 目录树注释 / §3.6 三件套字段语义 + 创建语义 / §7.2 POST + PATCH + DELETE + 新 GET /v1/folders / §7.4 schema 块 + index 同步;PROGRESS 单条记录;RUN 待刷。 - 05-17 / files 面板 UX 让用户清楚"我在哪个项目里" + 修 root crumb bug:用户反馈"web 右侧 files 看不到文件夹"——实际场景是用户建了 task name=水泥申报,FS
workspace/users/.../水泥申报/已建,但里面是空的,files 面板只显示"(空目录)"——用户混淆为"看不到 水泥申报 这个文件夹本身"。真因是 UI 没把"现在面板内部就是 水泥申报 的内容"说清楚。修两处:① 后端_enumerate_files:cur_rel == "."(target == root)时不再追加一个无意义 "." crumb(原来if cur_rel:把 "." 当真值,会塞{label: ".", rel: "."}进 crumbs[1]);改为if cur_rel and cur_rel != "."。② dev SPArenderFiles:pane-head旁加<span id="files-proj">(muted small样式 + ellipsis),textContent = "· " + projName(取task_dir.split('/').filter(Boolean).pop());crumbs 第一格 label 从 "/" 替换为项目名(projName),整条路径直观为水泥申报 / 草稿 / draft.md。deleteCurrentTask清面板时也 resetfiles-proj。Smoke:root 路径 crumbs 长度 == 1(原 == 2);进水泥申报/草稿子目录 crumbs == 2 且第二格 label == "草稿"(CJK 透传 OK);GBK 控制台显示乱码确认是 stdout encoding 而非 PG 存储问题(task_dir.encode('utf-8')字节正确 + codepoints 是 [0x6c34, 0x6ce5, 0x7533, 0x62a5])。 - 05-17 / task 硬删 API + dev SPA delete 按钮 + 文件 per-row 删:用户反馈缺 task 删除入口(原本只有 PATCH status=abandoned 软态)。新增
DELETE /v1/tasks/{id}:user_id ownership 校验(跨 user → 404 不暴露存在性)+ DB 行 DELETE(messages / runs CASCADE)+ FS task_dir 不动(同 name 多 task 共享语义下"最后一个 task 删了顺便 rmtree"的判断有边界 case,易擦用户素材;让用户经 /files/delete 或文件管理器显式清更安全)。返 204 No Content。dev SPA:chat 面板 head 加btn-delete-task(small danger样式,title 说明"清 DB 行 + messages,FS 文件不动"),disabled仅在没选 task 时 true —— 任何 status 都可删(active / completed / abandoned 不限,confirm 弹窗带项目名 + 消息条数二次确认)。点击后清空 chat 面板 + files 面板 + state reset + reload task list。file 面板 per-row 加红×按钮(del-fileclass),click stopPropagation 不触发行的下载/进目录;调原有POST /v1/tasks/{id}/files/delete(API 没改,非空目录仍 400 拒,弹错文)。Smoke 6 case 全绿:happy 路径 204 + DB 行 gone + FSshould_survive.txt保留 / messages CASCADE 真生效(idx=1 user msg INSERT 后 DELETE task → messages count = 0)/ ghost UUID 404 / 非 UUID 字符串 404 / 跨 user delete 404 + 原 user 仍可删 + 原 task 行未被擦 / 无 token 401。文档:DESIGN §7.2 资源模型加DELETE /v1/tasks/{id}行 + 注释 "FS task_dir 保留"。 - 05-17 / task_dir 改 eager mkdir + dev SPA 列表显示项目名:用户反馈"创建 task 给了名字也聊了天,文件夹没建出来"——原"懒 mkdir(skill 第一次写产物时建)"是 UUID-named 派生目录时代的设计,现在 task_dir 是用户给的项目名(
workspace/users/<uid>/<name>/),name = 项目声明,目录就该在 task 创建时存在(用户可立刻往里塞素材文件,而非等 LLM 触发 skill)。改两处入口:main.py::build_agent新建分支(not resume&& no-subtask 校验后)+web/app.py::create_task(fs_dir = task_dir_from_name(...)之后),都加mkdir(parents=True, exist_ok=True)。同 name 多 task 共享同目录(§7.1),exist_ok=True无冲突 + 已有内容(其他 task 产物或用户素材)不被擦。task_dir_from_name仍保持纯路径派生(docstring 同步)。cli.py::_cleanup_if_empty注释里"未触发 lazy mkdir"过时表述修正。dev SPAdev.html:renderTaskList主行原本t.description || "(no desc)",description 空时一片"(no desc)"丑;改为 主行 = 项目名(task_dir.split('/').filter(Boolean).pop())+ description 移到副行(空则不渲)+task_id[:8]移到 badge 行末段,信息密度更高且每条都有标识。renderChatMeta同步同样规则。DESIGN §3.6 同步:删"task_dir 在 skill 第一次落产物时 mkdir"+ 修过时的_cleanup_if_empty描述("DB 无 messages 且 FS 无产物 → DELETE + rmdir" → 实际现在 "无 user msg → DELETE DB 行;FS 一律不动")。Smoke:task_dir_from_name纯路径不预 mkdir +mkdiridempotent + POST /v1/tasks 后 FS 真存在 + 同 name 二次 create reuse 不擦已落入文件(user_marker.txt保留)+ DB 双行 task_id 不同 task_dir 同。 - 05-17 / task = name-based 项目目录 + memory dotfile:废弃自动 UUID 派生 +
tasks/中间层。新建 task 必须给name(简单名,项目目录名),task_dir 派生为workspace/users/<uid>/<name>/;同 name 多 task 自动共享同目录(§7.1 task-primary)。name校验(main.py::validate_task_name):非空 / 不含/\NUL/ 不以.起头(挡.memory等系统区)/ ≤ 255 字符;允许 CJK 与其他 Unicode。memory 搬 dotfile:workspace/users/<uid>/.memory/{core.md, extended/},跟用户项目目录扁平共存不撞名;validate_task_name拒.起头双向防呆。删函数:_default_task_dir/is_managed_task_dir/tasks_dir全删,build_agent.task_dir_arg改name,cli.py --task-dir改--name;webTaskCreateRequest.task_dir→name(必填),POST /v1/tasks缺 name → 422 (Pydantic) / 不合法 → 400(InvalidTaskName文案)/ 同名共享 task_dir 不触发 no-subtask。_cleanup_if_empty简化:FS 一律不动(项目目录跨 task 复用,绝不 rmtree),空 task 只删 DB 行;原"managed 派生模板"概念整个废弃。dev SPA:新建 task modal 字段task_dir(留空)→name(必填),task 列表行末段显示项目名(task_dir.split('/').pop())而非两段 UUID/path。清旧数据:workspace/users/*/tasks/上轮白建的中间层空目录 +users/<uid>/memory/(非 dotfile)全 rm,DB 已空。Smoke 全绿:validate_task_name 14 case 边界(简单名 / 中间含./ 空白 strip / CJK / 含/\NUL拒 /.起头四种拒 / 长度边界 255 vs 256)+ 路径派生 + memory dotfile 路径 + CLI build_agent name 必填强制 + web/v1/tasks4 + name 不合法 400 全分支 + 同 name 多 task 共享同 task_dir + resume + dotfile memory 注入 + 跨 user 隔离。文档同步:DESIGN.md §3.1 目录树 / §3.7 memory 路径 + dotfile 说明 / §7.0 表 / §7.1 留空派生改"必给 name" / §7.2 /v1/tasks POST 入参 / §7.4 schema 注释 + 文件系统块 / §7.6 Step 3 描述 全部刷新。未来形态:外部绝对路径(项目目录在 workspace 外的场景)暂未保留,日后真需要再开--external-path单独通道。 - 05-15 / workspace 布局统一 per-user:DESIGN §7.0 / §7.4 落地补 —— 原默认 task_dir
workspace/tasks/<uuid>/+ 全局workspace/memory/改为workspace/users/<user_id>/{tasks/<uuid>,memory/}/(本地 CLI user_id = SENTINEL,web/JWT user_id = JWT sub)。main.py::_default_task_dir(workspace, tid, user_id)/tasks_dir(workspace, user_id)/ 新增user_root(workspace, user_id)/is_managed_task_dir(td, ws, user_id)都接 user_id;resolve_task_id/build_agent/_build_system_prompt透传(build_agent.user_id留 Optional 默认 SENTINEL,CLI 不需要显式传)。core/memory.py::memory_block(workspace, user_id)per-user 子树读users/<uid>/memory/,prompt 段从"workspace 级"改"user 级"。web/app.py::create_task把 Depends 拿到的 JWT user_id 喂进_default_task_dir;_run_agent_bg(task_id, run_id, user_id, msg)加 user_id 参数透传给build_agent(resume=True, user_id=...)—— 确保 web resume 时 memory_block 读对 per-user 子树。cli.py::_cleanup_if_empty调is_managed_task_dir(..., SENTINEL_USER_ID);tasks_dir(ws, SENTINEL_USER_ID)显示路径。没动 session.py ——Session.append → ensure_local_task_row(user_id=SENTINEL)默认值对 CLI 正确;web 路径create_task已提前预 INSERT 真 user_id 的占位行,后续 ON CONFLICT DO NOTHING 不会落 SENTINEL 默认值。开发期心态(CLAUDE.md):清旧数据不留兼容 —— DELETE FROM tasks(CASCADE messages/runs)+ usage_events;rm -rf workspace/tasks/;保留 users 表 2 行(sentinel + 已登录 web user)避免 JWT FK 失败。文档同步:DESIGN.md §3.1 目录树 / §3.7 memory 路径 / §7.0 task_dir & memory 默认值表 / §7.1 留空派生路径 / §7.2 /v1/tasks POST task_dir 默认值 / §7.4 task_dir 存储约定注释 / §7.4 文件系统块布局 / §7.6 Step 3 描述 全部刷新。 - 05-15 / §7 Phase G G4 chat 发送 + SSE 流式:新增
web/broker.py::RunBroker(in-process pub/sub,subscribe/emit/close/unsubscribe)+web/sinks.py::WebEventSink实现 §7 A 的 sink 协议,把AgentLoop._emit桥到 broker。异步策略 =asyncio.to_thread(不改 core):POST/tasks/{tid}/messagesasync handler → 校验 task + INSERTruns行 +asyncio.create_task(asyncio.to_thread(_run_agent_bg, ...)),_run_agent_bg在工作线程跑build_agent(resume=True) + agent.run,sink 通过loop.call_soon_threadsafe(q.put_nowait, ev)跨线程桥事件回 asyncio queue。多访问策略 = fan-out:每订阅一个独立asyncio.Queue,同 run 多 tab / 刷新 / 桌面+移动都看得到流;_done集合让晚到订阅者立即收done(不挂)。GET/tasks/{tid}/runs/{rid}/events返StreamingResponseasync gen,响应头带text/event-stream / Cache-Control: no-cache / X-Accel-Buffering: no(nginx 反代友好);第一帧发: connected\nretry: 3000\n\n让 EventSource 立即建立,30s 无 event 发: ping注释心跳。SSE multi-line data:HTML 片段含换行,每行加data:前缀(SSE spec),EventSource API 还原成\n拼接的 HTML 字符串。Event → HTML 片段:_render_event_fragment渲染text/tool_call/tool_result/error四种,run_start/llm_start/llm_end/done发空 data(只让客户端识别 event type)。新 fragment 模板_frag_text.html/_frag_tool_call.html/_frag_tool_result.html/_frag_error.html+_send_response.html(POST 响应:user msg 卡 +msg-assistant streaming容器带sse-connect/sse-swap/sse-close)。chat.html加 send 表单(Enter 发送、Shift+Enter 换行,HTMXhx-post / hx-target=#chat-stream / hx-swap=beforeend / hx-on::after-request reset);chatsection 改id="chat-stream"让 SSE 追加进同一容器;非 active task 隐藏表单。CSS 加.streaming .run-indicator红点脉冲 /.send-form表单样式 /.tool-result-inline追加式样式 /.msg-error错误卡。Run 状态写 PGruns表:POST 时 status=running,正常完结 status=ok + tokens_p/c,异常 status=error + error 文本;DB 写失败不放大噪声(已 emit error 给前端)。lifespanbind_loop(asyncio.get_running_loop())让 broker 拿到 asyncio loop 引用。Smoke 双层全绿:broker 单元 8 case(subscribe/emit/get、fan-out 双订阅、跨 run_id 隔离、close 派 done、late subscribe 立刻收 done、unsubscribe 后失联、WebEventSink 桥、unbinded loop silent drop);端到端 24 case(POST 200 + HTML 含 sse-connect + run_id 抽出 + SSE stream content-type/x-accel-buffering/cache-control 头对、event types 序列run_start/llm_start/text/tool_call/tool_result/llm_end/done、text fragment 含<strong>markdown、tool_call 含<details>、tool_result 含 preview、empty body 400、invalid/ghost UUID 404、late subscribe 立刻 done、PG runs 行 INSERT)。版本 0.3 → 0.4。TODO:并发同 task 多 run 互锁(messages idx UniqueConstraint 在并发 POST 下会冲突 — 用户连续点 send 暂时不会触发,但需要在 G6 或 D 阶段加 lock_for_update);event log 持久化(刷新继续看流式)留到未来。
关键决策与偏差
| 项 | 决策 | 备注 |
|---|---|---|
| 工具基目录 | cwd(读)+ task_dir(写) | system prompt 同时注入两者绝对路径 |
| Workspace 布局 | workspace/users/<user_id>/{.memory/, <name>/} |
per-user 隔离;memory dotfile 防撞;<name> 用户起项目名,同 name 多 task 共享;CLI sentinel = 00000000-... |
| Eval Suite | 不做 | 个人工具 dogfooding |
| 版本化 prompt | 直接 general_v1.md |
Windows 软链接麻烦,真要切再做 |
| run_python 沙盒 | subprocess + env 过滤 | Docker 在 §7 C 阶段 |
文件清单
core/capabilities.py 71
core/llm.py 93 ← +litellm 离线 cost map env
core/loop.py 182 ← §7 A sink.emit + cancel_check 协作式 cancel
core/sinks.py 101 ← §7 A
core/ui.py 38
core/paths.py 50 ← task_dir db form 归一(to_db_path / from_db_path)
core/probe.py 243
core/session.py 153 ← §7 B Step 2-3: ORM + ensure 补 meta
core/skills.py 81
core/task.py 82 ← §7 B Step 3: PG-backed TaskState,去 cwd
core/memory.py 81 ← per-user `.memory/` dotfile
core/export_docx.py 383 ← §7 B Step 2-4 + from_db_path 还原 + task_dir Optional
core/storage/__init__.py 27 ← §7 B Step 1-3
core/storage/engine.py 80 ← §7 B Step 1
core/storage/models.py 99 ← 3 表(0004 删 runs/usage_events;Task + run_status/run_error)
core/storage/utils.py 136 ← check_no_subtask 改 Python 端归一
tools/base.py 34
tools/fs.py 182
tools/shell.py 94
tools/run_python.py 84
tools/skill_tool.py 45
main.py 164 ← 入口: web / db / probe 三 click 命令(05-18 改名归位)
core/agent_builder.py 307 ← 装配 lib: build_agent / system prompt / validate_task_name(原 main.py 内容)
db/migrations/env.py 61 ← §7 B Step 1
db/migrations/versions/
0001_initial_schema.py 125 ← §7 B Step 1
0002_task_dir_relative.py 61 ← 现有 ROOT-prefix 绝对 → 相对
0003_task_name_and_working_dir.py
51 ← name 必填 + task_dir→working_dir + mode→skill
0004_drop_runs_usage_events.py
77 ← 删 runs/usage_events + tasks 加 run_status/run_error
web/__init__.py 5 ← Phase G G1
web/app.py 889 ← /v1/ JSON API + user_id 隔离 + run lock + task-level cancel
web/auth.py 115 ← D' 过渡:PLATFORM_KEY → JWT 兑换
web/broker.py 121 ← in-process pub/sub + cancel signal,全 task_id 索引(0004)
web/sinks.py 21 ← WebEventSink 绑 task_id(0004)
web/static/dev.html 1133 ← D' dev SPA + stop 按钮 + cancelled badge
─────────────────────────────────
Python 合计 ~3400 行(+ dev.html 1133 静态)— 05-18 入口归位净减 ~400 行 REPL/CLI
加 skills/ppt 脚本 ~600 行 + SKILL.md / references / config / prompts + alembic.ini,总仓库约 3500 行。
下一步候选(性价比排序)
- 真 OIDC 接入 + CORS 收紧(~1 天)—— 把
/v1/auth/login内部从 platform_key 校验换成 OIDC ID token 校验(路由层 Depends 不动);CORS 改成 platform 域名 allowlist。真发布给真实用户前必做。 - §7 C Executor + sandbox(~2-3 天)——
run_python/shell→Executor.run(...),本地保留 subprocess、SaaS 走 docker;api_key_env→KeyProvider运行时注入。多用户在线跑代码前置。 - Phase 6 context 三层压缩(~1 天)—— 兜底,V4 长上下文一般用不到。
§7 B + D + D'(过渡 auth)+ 单活 run 锁 + cancel + 0004 schema 瘦身 + 入口归位 主体已完工。剩余路线:真 OIDC → C(Executor)→ F(deploy / billing)。§7 E CLI 双模式撤(2026-05-18 §7.9):dev SPA 已是本地 dogfood 主路径,CLI REPL 删,无
--remote双 transport 维护税。原 Phase G Web UI 路线撤(§7.9),UI 改 platform 端实现;web/static/dev.html是开发期单文件 SPA,跟 platform UI 并存不冲突。