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:
parent
56e414e046
commit
ae93016442
|
|
@ -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]` 列任务。
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
15
PROGRESS.md
15
PROGRESS.md
|
|
@ -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
148
cli.py
|
|
@ -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")
|
||||
|
|
|
|||
4
main.py
4
main.py
|
|
@ -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):
|
||||
|
|
|
|||
Loading…
Reference in New Issue