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) 4. 解析 task_dir(新建 or resume)
5. 拼 system prompt:`prompts/system/general_v1.md` + `SkillRegistry.discovery_block()`(skill 列表)+ cwd + **task_dir 绝对路径**(产物根) 5. 拼 system prompt:`prompts/system/general_v1.md` + `SkillRegistry.discovery_block()`(skill 列表)+ cwd + **task_dir 绝对路径**(产物根)
6. 装配工具集(fs / shell / load_skill / run_python) 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 写回。 存储:`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 状态、决策偏差、文件量、下一步。 > 配合 `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>/` - 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 兜底 - `.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/capabilities.py 71
core/llm.py 89 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/probe.py 243 ← Phase 4
core/session.py 77 core/session.py 77
core/skills.py 81 core/skills.py 81
@ -69,10 +74,10 @@ 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 185 ← Phase 6 task 装配 / +task_dir 注入 main.py 185 ← Phase 6 task 装配 / +task_dir 注入 / -占位 save (懒创建)
cli.py 264 ← +probe / +tasks 子命令 cli.py 358 ← +probe / +tasks / +/resume / +空 task 清理
───────────────────────────────── ─────────────────────────────────
Python 合计 ~1669 Python 合计 ~1764
``` ```
加上 skills/ppt 下的脚本(~600 行)、SKILL.md / references / config / prompts,总仓库约 2500 行可读源码。 加上 skills/ppt 下的脚本(~600 行)、SKILL.md / references / config / prompts,总仓库约 2500 行可读源码。

148
cli.py
View File

@ -12,6 +12,7 @@
from __future__ import annotations from __future__ import annotations
import json import json
import shutil
import sys import sys
import click import click
@ -35,6 +36,56 @@ def cli() -> None:
"""zcbot - 个人任务 agent""" """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() @cli.command()
@click.option("--model", default=None, help="模型档案,如 deepseek_v4.flash 或 deepseek_v4.pro") @click.option("--model", default=None, help="模型档案,如 deepseek_v4.flash 或 deepseek_v4.pro")
@click.option("--workspace", default=None, help="工作目录(存 tasks/ 和 sessions/)") @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}" f"model: [accent]{agent.caps.model_id}[/accent]{meta_tail}"
) )
console.print( 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" "/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) user_input = Prompt.ask("[user]you[/user]", console=console)
except (EOFError, KeyboardInterrupt): except (EOFError, KeyboardInterrupt):
console.print("\n[muted]bye[/muted]") console.print("\n[muted]bye[/muted]")
_cleanup_if_empty(task_dir, session, console)
break break
cmd = user_input.strip() cmd = user_input.strip()
if cmd in ("/exit", "/quit"): if cmd in ("/exit", "/quit"):
_cleanup_if_empty(task_dir, session, console)
break break
if cmd == "/reset": if cmd == "/reset":
session.reset(keep_system=True) session.reset(keep_system=True)
console.print("[info]当前 task 对话已重置(保留 system 和 state)[/info]") console.print("[info]当前 task 对话已重置(保留 system 和 state)[/info]")
continue continue
if cmd == "/new": if cmd == "/new":
_cleanup_if_empty(task_dir, session, console)
try: try:
agent, session, sid, task_state, task_dir = build_agent( agent, session, sid, task_state, task_dir = build_agent(
model_name=model, workspace=workspace, console=console, 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 continue
console.print(f"[ok]新 task[/ok] [bold]{sid}[/bold]") console.print(f"[ok]新 task[/ok] [bold]{sid}[/bold]")
continue 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": if cmd == "/id":
cwd_disp = session.meta.get("cwd", "?") cwd_disp = session.meta.get("cwd", "?")
model_disp = session.meta.get("model", agent.caps.model_id) 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)。""" """列出已有 task(新格式,workspace/tasks/<id>/state.json)。"""
cfg = load_config() cfg = load_config()
ws = resolve_workspace(workspace, cfg) ws = resolve_workspace(workspace, cfg)
tdir = tasks_dir(ws) rows = _list_task_rows(ws, limit=limit, status=status)
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]
if not rows: if not rows:
click.echo(f"(no tasks in {tdir})") click.echo(f"(no tasks in {tasks_dir(ws)})")
return return
tbl = Table(show_lines=False) tbl = Table(show_lines=False)
tbl.add_column("task id", style="bold") tbl.add_column("task id", style="bold")

View File

@ -146,7 +146,8 @@ def build_agent(
"model_profile": model, "model_profile": model,
} }
session = Session(system_prompt=system_prompt, path=session_path, meta=meta) 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_state = TaskState(
task_id=sid, task_id=sid,
mode=mode, mode=mode,
@ -158,7 +159,6 @@ def build_agent(
cwd=str(tool_base), cwd=str(tool_base),
created_at=now_iso, created_at=now_iso,
) )
task_state.save(task_dir)
tools = {} tools = {}
for cls in (ReadTool, WriteTool, EditTool, GlobTool, GrepTool, ShellTool): for cls in (ReadTool, WriteTool, EditTool, GlobTool, GrepTool, ShellTool):