cli: REPL /resume 切 task + 懒创建 task_dir + 切走前空清理

- 加 /resume [last|<id>] REPL 命令,无参数列最近 10 个表格让用户挑;
  和 /new 对称,都在 REPL 内重建五元组。tasks 命令复用 _list_task_rows
- main.py 新建分支不再 session.save() / task_state.save() 占位 ——
  推迟到首条 user 消息触发的 Session.append → save() 才物化 task_dir。
  启动 REPL 立刻 /exit 磁盘无痕,跨进程也安全
- _cleanup_if_empty 在 /exit /quit /new /resume + Ctrl-C/EOF 守门:
  无 user 消息 + 目录在磁盘上 + 文件集 ⊆ {messages.json} 才删,
  state.json 存在(/done /abandon /desc 留下的显式痕迹)就保

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
caoqianming 2026-05-08 09:55:56 +08:00
parent 56e414e046
commit ae93016442
4 changed files with 139 additions and 36 deletions

View File

@ -76,7 +76,7 @@ zcbot/
4. 解析 task_dir(新建 or resume)
5. 拼 system prompt:`prompts/system/general_v1.md` + `SkillRegistry.discovery_block()`(skill 列表)+ cwd + **task_dir 绝对路径**(产物根)
6. 装配工具集(fs / shell / load_skill / run_python)
7. 写初始 `state.json` + `messages.json`,启动 REPL
7. 启动 REPL —— **新建路径不预占文件**(懒创建,见 §3.6)
---
@ -136,7 +136,11 @@ yaml 是手填的,可能错。`probe` 用真实 LLM 调用对账:
存储:`workspace/tasks/<task_id>/{state.json, messages.json}`。每轮 `agent.run` 后调 `sync_task_tokens` 把 LLM 累计 tokens 写回。
CLI:`chat --mode coding --desc "..."`;REPL `/status /done /abandon /desc`;`tasks [--status active|completed|abandoned]` 列任务。
**懒创建** —— `build_agent` 新建分支不立刻 save,task_dir 在第一条 user 消息触发 `Session.append → save()` 时才物化(`Session.save` / `TaskState.save``mkdir(parents=True)`)。启动 REPL 后立刻 `/exit` 磁盘无痕,跨进程也安全(没有"另一个 REPL 刚 build_agent 还没说话就被这个进程当空 task 删掉"的窗口)。
**REPL 内 task 切换** —— `/new` 开新 task,`/resume [last|<id>]` 切到已有 task(无参数列最近 10 个表格让用户选),`/done /abandon` 改状态,`/desc` 改描述。切走前 `_cleanup_if_empty` 守门:三条都满足才删 task_dir —— ① session 没 user 消息 ② 目录在磁盘上 ③ 目录里只剩 `messages.json`(state.json 存在 = `/done /abandon /desc` 留下的显式痕迹,要保)。
CLI:`chat --mode coding --desc "..." [--resume last|<id>]`;`tasks [--status active|completed|abandoned]` 列任务。
---

View File

@ -2,7 +2,7 @@
> 配合 `DESIGN.md` 阅读。本文件只记录 phase 状态、决策偏差、文件量、下一步。
最后更新:2026-05-07(TUI 打磨 + task_dir 概念真正落地)
最后更新:2026-05-08(REPL `/resume` + 懒创建 task_dir + 切换前空清理)
---
@ -40,6 +40,11 @@
- system prompt 显式注入 `task_dir` 绝对路径,SKILL.md 里 `<task_dir>` 占位符**真正落地**;`spec_lock.md` / `sections/` / `slides/` / 最终 docx/pptx 全收敛到 `workspace/tasks/<id>/`
- `.gitignore``sections/` `slides/` `spec_lock.md` 三条无锚 bandaid —— 现在写错位置 git status 立刻报红,不再靠 ignore 兜底
**REPL 内 task 切换 + 懒创建**(2026-05-08):
- `/resume [last|<id>]` REPL 命令,无参数列最近 10 个 task 表格让用户挑序号或 task_id;和 `/new` 对称,都在 REPL 内重建 (agent, session, sid, task_state, task_dir) 五元组。`tasks` 命令和 `/resume` 共用 `_list_task_rows` helper
- **懒创建 task_dir**:`build_agent` 新建分支不再 `session.save()` / `task_state.save()` 占位,推迟到首条 user 消息触发的 `Session.append → save()`。启动 REPL 立刻 `/exit` 磁盘无痕,跨进程安全(没有"另一个 REPL 刚 build_agent 没说话就被本进程当空 task 删"的窗口)
- `_cleanup_if_empty` 在切走前(`/exit /quit /new /resume` + Ctrl-C/EOF)守门。三条都满足才删 task_dir:① 无 user 消息 ② 目录在磁盘上 ③ 文件集 ⊆ `{messages.json}`(state.json 存在 = 用户跑过 `/done /abandon /desc` 留下显式痕迹,要保)
---
## 关键决策与偏差
@ -59,7 +64,7 @@
```
core/capabilities.py 71
core/llm.py 89
core/loop.py 157 ← +markdown 渲染 / spinner 显时长+token
core/loop.py 158 ← +markdown 渲染 / spinner 显时长+token
core/probe.py 243 ← Phase 4
core/session.py 77
core/skills.py 81
@ -69,10 +74,10 @@ tools/fs.py 182
tools/shell.py 94
tools/run_python.py 84
tools/skill_tool.py 45
main.py 185 ← Phase 6 task 装配 / +task_dir 注入
cli.py 264 ← +probe / +tasks 子命令
main.py 185 ← Phase 6 task 装配 / +task_dir 注入 / -占位 save (懒创建)
cli.py 358 ← +probe / +tasks / +/resume / +空 task 清理
─────────────────────────────────
Python 合计 ~1669
Python 合计 ~1764
```
加上 skills/ppt 下的脚本(~600 行)、SKILL.md / references / config / prompts,总仓库约 2500 行可读源码。

148
cli.py
View File

@ -12,6 +12,7 @@
from __future__ import annotations
import json
import shutil
import sys
import click
@ -35,6 +36,56 @@ def cli() -> None:
"""zcbot - 个人任务 agent"""
def _cleanup_if_empty(task_dir, session, console=None) -> bool:
"""切走前清理 task_dir。三条都满足才删:
1) session 没有 user 消息
2) task_dir 在磁盘上(懒创建后,没说话就没目录,直接 no-op)
3) 目录里只剩 messages.json(state.json 存在 = `/done /abandon /desc` 留下的显式痕迹,要保)
"""
if any(m.get("role") == "user" for m in session.messages):
return False
try:
entries = list(task_dir.iterdir())
except FileNotFoundError:
return False
if any(p.is_dir() for p in entries):
return False
if {p.name for p in entries if p.is_file()} - {"messages.json"}:
return False
shutil.rmtree(task_dir, ignore_errors=True)
if console is not None:
console.print(f"[muted]清理空 task {task_dir.name}[/muted]")
return True
def _list_task_rows(workspace_dir, limit=20, status=None):
"""返回 [(mtime, task_id, status, mode, model, tokens, n_msgs, desc), ...] mtime 降序。"""
tdir = tasks_dir(workspace_dir)
rows = []
for d in tdir.iterdir():
if not d.is_dir():
continue
msg_path = d / "messages.json"
if not msg_path.exists():
continue
st = TaskState.load(d)
if st is None:
continue
if status and st.status != status:
continue
try:
data = json.loads(msg_path.read_text(encoding="utf-8"))
n = len(data.get("messages", []))
except Exception:
n = -1
rows.append((
msg_path.stat().st_mtime, st.task_id, st.status, st.mode,
st.model_profile or st.model, st.tokens_total, n, st.description,
))
rows.sort(reverse=True)
return rows[:limit]
@cli.command()
@click.option("--model", default=None, help="模型档案,如 deepseek_v4.flash 或 deepseek_v4.pro")
@click.option("--workspace", default=None, help="工作目录(存 tasks/ 和 sessions/)")
@ -72,7 +123,8 @@ def chat(model: str, workspace: str, resume: str, mode: str, desc: str) -> None:
f"model: [accent]{agent.caps.model_id}[/accent]{meta_tail}"
)
console.print(
"[info]/exit 退出 /reset 清空对话(保留 task) /new 开新 task /id /status 查看 "
"[info]/exit 退出 /reset 清空对话(保留 task) /new 开新 task "
"/resume [last|<id>] 切到已有 task /id /status 查看 "
"/done /abandon 改状态 /desc <文本> 设描述[/info]\n"
)
@ -81,16 +133,19 @@ def chat(model: str, workspace: str, resume: str, mode: str, desc: str) -> None:
user_input = Prompt.ask("[user]you[/user]", console=console)
except (EOFError, KeyboardInterrupt):
console.print("\n[muted]bye[/muted]")
_cleanup_if_empty(task_dir, session, console)
break
cmd = user_input.strip()
if cmd in ("/exit", "/quit"):
_cleanup_if_empty(task_dir, session, console)
break
if cmd == "/reset":
session.reset(keep_system=True)
console.print("[info]当前 task 对话已重置(保留 system 和 state)[/info]")
continue
if cmd == "/new":
_cleanup_if_empty(task_dir, session, console)
try:
agent, session, sid, task_state, task_dir = build_agent(
model_name=model, workspace=workspace, console=console,
@ -101,6 +156,69 @@ def chat(model: str, workspace: str, resume: str, mode: str, desc: str) -> None:
continue
console.print(f"[ok]新 task[/ok] [bold]{sid}[/bold]")
continue
if cmd.startswith("/resume"):
arg = cmd[len("/resume"):].strip()
ws_dir = resolve_workspace(workspace)
target_id = None
if arg == "last":
rs = _list_task_rows(ws_dir, limit=1)
if not rs:
console.print("[warn]没有可恢复的 task[/warn]")
continue
target_id = rs[0][1]
elif arg:
target_id = arg
else:
rs = _list_task_rows(ws_dir, limit=10)
if not rs:
console.print("[warn]没有可恢复的 task[/warn]")
continue
tbl = Table(show_lines=False)
tbl.add_column("#", style="bold")
tbl.add_column("task id")
tbl.add_column("status")
tbl.add_column("mode")
tbl.add_column("msgs", justify="right")
tbl.add_column("desc")
sc = {"active": "status.active", "completed": "status.completed", "abandoned": "status.abandoned"}
for i, (_, tid, st, md, _mdl, _tok, n, dsc) in enumerate(rs, 1):
c = sc.get(st, "info")
d_show = dsc if len(dsc) <= 50 else dsc[:47] + "..."
tbl.add_row(str(i), tid, f"[{c}]{st}[/{c}]", md, str(n), d_show)
console.print(tbl)
try:
sel = Prompt.ask("[user]选编号或输入 task_id (回车取消)[/user]", console=console, default="")
except (EOFError, KeyboardInterrupt):
continue
sel = sel.strip()
if not sel:
continue
if sel.isdigit():
idx = int(sel) - 1
if 0 <= idx < len(rs):
target_id = rs[idx][1]
else:
console.print(f"[err]编号超界: {sel}[/err]")
continue
else:
target_id = sel
if target_id == sid:
console.print(f"[info]已是当前 task: {sid}[/info]")
continue
_cleanup_if_empty(task_dir, session, console)
try:
agent, session, sid, task_state, task_dir = build_agent(
model_name=model, workspace=workspace, console=console,
session_id=target_id, resume=True,
)
except Exception as e:
console.print(f"[err]恢复失败:[/err] {type(e).__name__}: {e}")
continue
console.print(
f"[ok]切到 task[/ok] [bold]{sid}[/bold] ({len(session.messages)} 条消息) "
f"model: [accent]{agent.caps.model_id}[/accent]"
)
continue
if cmd == "/id":
cwd_disp = session.meta.get("cwd", "?")
model_disp = session.meta.get("model", agent.caps.model_id)
@ -152,34 +270,10 @@ def tasks(workspace: str, limit: int, status: str) -> None:
"""列出已有 task(新格式,workspace/tasks/<id>/state.json)。"""
cfg = load_config()
ws = resolve_workspace(workspace, cfg)
tdir = tasks_dir(ws)
rows = [] # (mtime, task_id, status, mode, model, tokens, n_msgs, desc)
for d in tdir.iterdir():
if not d.is_dir():
continue
msg_path = d / "messages.json"
if not msg_path.exists():
continue
st = TaskState.load(d)
if st is None:
continue
if status and st.status != status:
continue
try:
data = json.loads(msg_path.read_text(encoding="utf-8"))
n = len(data.get("messages", []))
except Exception:
n = -1
rows.append((
msg_path.stat().st_mtime, st.task_id, st.status, st.mode,
st.model_profile or st.model, st.tokens_total, n, st.description,
))
rows.sort(reverse=True)
rows = rows[:limit]
rows = _list_task_rows(ws, limit=limit, status=status)
if not rows:
click.echo(f"(no tasks in {tdir})")
click.echo(f"(no tasks in {tasks_dir(ws)})")
return
tbl = Table(show_lines=False)
tbl.add_column("task id", style="bold")

View File

@ -146,7 +146,8 @@ def build_agent(
"model_profile": model,
}
session = Session(system_prompt=system_prompt, path=session_path, meta=meta)
session.save() # 占住文件名
# 懒创建:不预占文件。首条 user 消息触发 Session.append → save() 才会 mkdir + 落盘。
# task_state 同步推迟到首轮 sync_task_tokens。直到那一刻为止,task_dir 在磁盘上不存在。
task_state = TaskState(
task_id=sid,
mode=mode,
@ -158,7 +159,6 @@ def build_agent(
cwd=str(tool_base),
created_at=now_iso,
)
task_state.save(task_dir)
tools = {}
for cls in (ReadTool, WriteTool, EditTool, GlobTool, GrepTool, ShellTool):