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