task-ledger 1.3.0 → 1.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +29 -247
- package/SKILL.md +173 -88
- package/package.json +22 -22
- package/references/log-note.md +3 -1
- package/references/state-model.md +29 -27
- package/references/troubleshooting.md +97 -32
- package/scripts/ledgerkit/__init__.py +0 -0
- package/scripts/ledgerkit/actions/__init__.py +0 -0
- package/scripts/ledgerkit/actions/atom.py +154 -0
- package/scripts/ledgerkit/actions/bootstrap.py +27 -0
- package/scripts/ledgerkit/actions/claim.py +82 -0
- package/scripts/ledgerkit/actions/close_task.py +180 -0
- package/scripts/ledgerkit/actions/guard.py +140 -0
- package/scripts/ledgerkit/actions/handoff.py +74 -0
- package/scripts/ledgerkit/actions/list_tasks.py +53 -0
- package/scripts/ledgerkit/actions/log_progress.py +60 -0
- package/scripts/ledgerkit/actions/new_task.py +120 -0
- package/scripts/ledgerkit/actions/query.py +33 -0
- package/scripts/ledgerkit/actions/query_history.py +173 -0
- package/scripts/ledgerkit/actions/registry.py +82 -0
- package/scripts/ledgerkit/actions/release.py +51 -0
- package/scripts/ledgerkit/actions/result.py +88 -0
- package/scripts/ledgerkit/actions/set_task.py +43 -0
- package/scripts/ledgerkit/actions/show_task.py +27 -0
- package/scripts/ledgerkit/actions/validate.py +224 -0
- package/scripts/ledgerkit/core/__init__.py +0 -0
- package/scripts/ledgerkit/core/history_md.py +350 -0
- package/scripts/ledgerkit/core/identity.py +43 -0
- package/scripts/ledgerkit/core/ids.py +58 -0
- package/scripts/ledgerkit/core/labels.py +79 -0
- package/scripts/ledgerkit/core/paths.py +93 -0
- package/scripts/ledgerkit/core/time.py +102 -0
- package/scripts/ledgerkit/core/worklog_md.py +232 -0
- package/scripts/ledgerkit/env.py +93 -0
- package/scripts/ledgerkit/errors.py +15 -0
- package/scripts/ledgerkit/notices.py +28 -0
- package/scripts/ledgerkit/shell/__init__.py +0 -0
- package/scripts/ledgerkit/shell/cli.py +259 -0
- package/scripts/ledgerkit/shell/gitops.py +655 -0
- package/scripts/ledgerkit/shell/merge.py +266 -0
- package/scripts/ledgerkit/shell/presync.py +23 -0
- package/scripts/ledgerkit/shell/render.py +165 -0
- package/scripts/ledgerkit/shell/runner.py +225 -0
- package/scripts/ledgerkit/store/__init__.py +0 -0
- package/scripts/ledgerkit/store/atomic.py +130 -0
- package/scripts/ledgerkit/store/dirty.py +108 -0
- package/scripts/ledgerkit/store/history_store.py +189 -0
- package/scripts/ledgerkit/store/json_store.py +166 -0
- package/scripts/ledgerkit/store/locks.py +64 -0
- package/scripts/ledgerkit/store/owners_index.py +135 -0
- package/scripts/ledgerkit/store/task_store.py +31 -0
- package/scripts/ledgerkit/store/worklog_store.py +168 -0
- package/scripts/progress.py +8 -3109
- package/references/taxonomy.md +0 -26
- package/references/worklog.md +0 -39
|
@@ -1,45 +1,110 @@
|
|
|
1
1
|
# 排错
|
|
2
2
|
|
|
3
|
-
> TL;DR:非 0
|
|
3
|
+
> TL;DR:非 0 退出就停手、原样报告,不要绕过脚本改台账 JSON 或历史层。**退出码 0 也要看输出**:提交失败会退 1,推送失败仍退 0、原因只写在输出里。并发冲突重跑一次;同步交给脚本,只有提示说脚本已回滚、或说它不替你 rebase 时,才让用户手动合并。
|
|
4
4
|
|
|
5
5
|
## 退出码
|
|
6
6
|
|
|
7
7
|
| 码 | 含义 | 处置 |
|
|
8
8
|
|---|---|---|
|
|
9
|
-
| 0 |
|
|
10
|
-
| 1 | 业务错误(参数值 / 状态 /
|
|
11
|
-
| 2 |
|
|
12
|
-
| 3 |
|
|
9
|
+
| 0 | 成功(同步可能失败:输出里有 `提示:` 就要读) | — |
|
|
10
|
+
| 1 | 业务错误(参数值 / 状态 / 权限不对;**提交失败也是 1**) | 按提示纠正后重试;改不了的报告用户 |
|
|
11
|
+
| 2 | 命令行参数错误(argparse 自退) | 看 `<子命令> --help` 重新拼参数 |
|
|
12
|
+
| 3 | 未预期的内部失败(如台账 JSON 语法损坏、读命令撞上冲突标记) | 上报,不要重试;冲突标记先解冲突 |
|
|
13
13
|
| 130 | 被中断 | 重跑一次 |
|
|
14
14
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
- **「需要用户名才能执行」**:`git config user.name` 没配。让用户配好,不要自拟名字。
|
|
18
|
-
- **「不是你的任务 / 先 claim」**:任务 owner 不是你或是 `open`。让别人接手只能走「你释放 → 对方 `claim`」;不要用别的名字绕过。
|
|
19
|
-
- **「状态不一致」**:对已在 `doing` 的任务又跑了 `claim`。直接改正文 + `log` 即可。
|
|
20
|
-
- **`log` 报「没写进过程文档」**:worklog 路径丢失 / 不可读。用 `show --id` 查路径;`progress.json` 的 `updated` 已刷新,不要手写「执行记录」。
|
|
21
|
-
- **「文件在操作期间被改动」**(并发指纹冲突):重新执行一次即可。
|
|
22
|
-
- **「本地台账有未提交改动,写前取最新会覆盖它」**:写前只取 `progress.json` / `history/` 的最新版,本地这两处脏就先停下。先提交(`git add ledger/ && git commit -m "chore(ledger): ..."`)再重跑;暂时离线可加 `--no-sync`(写在子命令前后都行)继续本地写。
|
|
23
|
-
- **「另一个会话正在同步同一份台账(已等 60 秒)」**:同一仓库里另一个 agent 正在写并推送,锁没抢到。等它跑完再重跑即可,不要并行重试(会把排队队列越堆越长)。
|
|
24
|
-
- **「本地有 N 个台账提交还没推到 …」**:写前本来会自动补推,看到这句说明**补推失败**了(多半是本地还有你自己的提交,脚本不替你 rebase)。先 `git push`(非快进就 `git pull --rebase` / `git merge` 后再推),推完重跑;离线场景用 `--no-sync`(写在子命令前后都行),联网后统一提交推送。
|
|
25
|
-
- **「本地有 N 个你自己的提交还没推送 … 脚本不替你 rebase」**:推送被拒,且本地有非台账路径的提交。脚本故意不动它们(rebase 会改写提交号),**不要重跑台账命令**(会二次写入)。让用户自己 `git pull --rebase` / `git merge` 后 `git push`;台账改动已安全留在本地,推完重跑即可。
|
|
26
|
-
- **「没能确认本地有没有你自己的提交(git 数不出来)」**:旧版 git / 浅克隆下 `rev-list` 失败,脚本按「可能有」保守处理,不推也不 rebase。让用户 `git status` / `git log @{u}..HEAD` 自己看,处理完再重跑。
|
|
27
|
-
- **「写前把本地 N 个未推送的提交推上去了」/「本次推送会把本地 N 个你自己的提交一并推上去」**:纯告知(脚本已推或即将推),一句话带过「这次顺带把你本地 N 个提交推上去了」,不要回滚、不要重推。
|
|
28
|
-
- **「本地已提交,但推送被拒」**:改动已安全落盘,只是远端没收,**不要重跑台账命令**(会二次写入)。看提示里 `git:` 那行的原文:`! [rejected] ... (fetch first)` = 他人先推,脚本已自动合并过一次仍失败就手动合并台账文件;`Permission denied (publickey)` / `could not read Username` = 凭据问题,找用户修,不要反复重试。
|
|
29
|
-
- **「自动 rebase 未成功(已回滚到 rebase 前)」**:同一条任务被两边同时改了。脚本已 abort,仓库是干净的;按提示里的冲突原文手动合并后 `git push`。
|
|
30
|
-
- **「自动 rebase 冲突后回滚也失败了」**:仓库停在 rebase 中间态,**先别跑任何台账命令**。在仓库根 `git rebase --abort`;仍失败就 `git status` 看残留,必要时删 `.git/rebase-merge` / `.git/rebase-apply` 再重试。
|
|
31
|
-
- **「暂存了你的未提交改动」+ autostash 冲突**:你的未提交改动在 stash 里,没有丢;提示里点名的那几个文件此刻**带着 `<<<<<<<` 冲突标记**,git 不会拦你提交,别把它们当成品。解冲突后确认内容,再 `git stash drop`;只想要内容可 `git stash show -p stash@{0}` 取出。
|
|
32
|
-
- **「git add 失败」/「自动提交失败」**:台账已写进本地文件,只是没进 git。按错误原话处理后手动 `git add <文件> && git commit -m "chore(ledger): ..."`。
|
|
33
|
-
- **「没有上游跟踪分支」/「当前是 detached HEAD」**:台账照写、照提交到本地,但**不会推送**。让用户建上游(`git push -u origin <分支>`)或切回分支;离线时这样用,联网后统一推一次。
|
|
34
|
-
- **「写前快进 … 没成功」**:通常是工作区里有未提交改动(git 按文件拦,不看行),脚本改走「只刷新本次要写的台账文件」。想快进就先提交或 stash 那些文件。
|
|
35
|
-
- **「worklog 正文疑似被覆盖成模板骨架」**:`validate` 会对仍只有注释占位的在办任务打提示。从 git 恢复该文件(`git checkout HEAD -- <路径>`)后重写正文,再跑 `log`。
|
|
36
|
-
- **`validate` 报问题**:按行号读原文,先核对 `progress.json` 与 worklog 是否一致;能通过脚本修的走 `set` / `log`,**不要手改 JSON**。
|
|
15
|
+
> 两类退出码 1 不是故障:`validate` 查出问题(照实转述清单);提交阶段失败(文件已写下去,只差进 git,让用户先提交再重跑)。
|
|
37
16
|
|
|
38
|
-
##
|
|
17
|
+
## 常见错误 → 处置
|
|
18
|
+
|
|
19
|
+
引号内是提示原文的稳定片段(省略号处是变量)。按片段在输出里定位。
|
|
20
|
+
|
|
21
|
+
### 用法 / 参数类(退 1,改参数即可)
|
|
22
|
+
|
|
23
|
+
- **「set 必须带 --owner:空串 = 释放回公共池;对方身份 = 交接」**:`set` 只认 `--owner`,没有别的用法。
|
|
24
|
+
- **「任务号 X 不合法:只能用小写字母、数字、汉字和连字符」**:`--id` 传了大写 / 空格 / 符号。
|
|
25
|
+
- **「任务号 X 已经存在了(这不是重跑,是重号)」**:换个 `--id`,或不传让脚本自动生成。
|
|
26
|
+
- **「标题不能为空」/「--note 不能为空」/「结案要写一句「完成了什么」(--summary 不能为空)」**:必填项漏了。
|
|
27
|
+
- **「标题里没有可用字符(字母、数字或汉字),无法自动生成任务号。用 --id 指定一个」**:标题全是符号。
|
|
28
|
+
- **「标签最多 N 个,收到 M 个」/「太长:含中文时最多 N 个字」**:分类 / 标签超了上限(口径见 SKILL.md 的 new 参数节)。
|
|
29
|
+
- **「时间格式不对:(可用 YYYY-MM-DD、YYYY-MM-DD HH:MM、YYYY-MM-DD HH:MM:SS)」**:`--created` / `--finished` 的格式。
|
|
30
|
+
|
|
31
|
+
### 归属 / 状态类(退 1,先弄对归属)
|
|
32
|
+
|
|
33
|
+
- **「任务 X 还没有人认领(在公共池里),先 claim 再改」**:在动池里的任务。要接手先 `claim`。
|
|
34
|
+
- **「任务 X 不在我的分片里(只能给自己的任务记进展)」/「(只能结案自己的任务)」**:`log` / `close` 只能对自己名下的任务用;已结案的任务也会撞这条(结案后不在任何分片里)。
|
|
35
|
+
- **「任务 X 不在我的分片里,也没在公共池里」**:任务号打错,或任务已被别人认领。
|
|
36
|
+
- **「任务 X 已在 Y 的分片里(别人正在做),不能认领」/「任务 X 的负责人是 Y,不是你(Z)」**:动别人名下的任务一律拒绝。
|
|
37
|
+
- **「公共池里没有任务 X(已经被人认领或不存在)」**:`claim` 扑空。
|
|
38
|
+
- **「Y 还没在索引里登记过 ⇒ 不能交接给他」**:`set --owner <别人>` 指向一个没在 `owners.json` 登记过的身份。
|
|
39
|
+
- **「任务 X 在 Y 的分片里已经存在,且内容与本次不一致 ⇒ 拒绝交接」**:对方名下已有一份不一样的同名任务,交不过去。
|
|
40
|
+
- **换人只能「对方释放 → 你认领」**:`set --owner ""` 放回公共池,对方自己 `claim`;不要绕过脚本改 JSON。
|
|
41
|
+
|
|
42
|
+
### 台账本身有问题(退 1 / 退 3)
|
|
43
|
+
|
|
44
|
+
- **「任务 X 在公共池里但状态是 S(应为 open)—— 先跑 validate 看台账是不是乱了」**:池里混进了已认领的任务(多半搬移崩了一半)。跑 `validate` 看全部问题,确认哪份最新后删掉多余那份,或重跑一次对应命令(幂等)。
|
|
45
|
+
- **「X 的 N 不是对象(是 T),这份台账读不了」/「任务 X 在 Y 里不是一条对象,台账可能坏了」**:结构坏了 ⇒ **退 1**(业务错),按提示用 git 恢复(`git checkout -- <文件>`),不要手改。
|
|
46
|
+
- **「X 不是合法 JSON(第 N 行):…」**:语法坏了 ⇒ **退 3**。若文件里有 `<<<<<<<` 冲突标记(推送被拒后 rebase 没解完),先解冲突再重跑;`validate` 会把这种情况列为问题(退 1),别的读命令直接退 3。
|
|
47
|
+
- **「台账索引 owners.json 找不到了,但 owners/ 下还有任务文件」**:索引被手动删 / 改名。用 git 恢复 `owners.json` 再重试,程序不自动修复。
|
|
48
|
+
- **「任务号连续冲突 20 次,请检查台账是否异常」**:随机码连续撞号,先 `validate`。
|
|
49
|
+
- **「历史里有一条没有过程文档链接的旧行…请人工看一眼再重跑」**:`close` 时撞上解析不出来的历史行,人工确认那行后再跑。
|
|
50
|
+
|
|
51
|
+
### 写前被拦(guard 三查,退 1)
|
|
52
|
+
|
|
53
|
+
- **「本地有未提交的台账改动:… 处理:先提交它们(git add <文件> && git commit),再跑本命令」**:先提交再重跑;离线可加 `--no-sync` 只写本地。
|
|
54
|
+
- **「本地有未推送的台账提交:… 处理:先 git push;或者加 --no-sync 只写本地(之后要记得推)」**:没有「自动补推再写」这一步,就是让你先推。
|
|
55
|
+
- **「有还没入库的同名台账文件(提交会被它们挡住)」**:同名文件还没 `git add`,先入库。
|
|
56
|
+
- **「仓库处于中间态(rebase-merge / rebase-apply / MERGE_HEAD):先跑 git status 看提示,完成或中止它(git rebase --abort / git merge --abort)再跑本命令」**:上次 rebase / merge 没收尾。
|
|
57
|
+
- **「找不到台账:从当前目录往上都没有 ledger/ 目录」**:`--repo` 指对项目根;第一条写命令会自动把 `ledger/` 建出来,不用先初始化。
|
|
58
|
+
|
|
59
|
+
### 并发 / 锁(退 1 或提示)
|
|
39
60
|
|
|
40
|
-
|
|
61
|
+
- **「另一个会话正在同步同一份台账(已等 60 秒),请稍后重试」**:同仓库另一个 agent 正在写。等它跑完再重跑,不要并行重试。
|
|
62
|
+
- **「拿不到同步锁 …(本次未做任何写入)。处理:确认 git 目录可写;只需写本地文件可加 --no-sync,之后手动提交」**:锁文件是 git 公共目录里的 `dsh-task-ledger.sync.lock`,同仓库所有 worktree 共享;`--no-sync` 也跳不过这把锁。
|
|
63
|
+
- **「X 在本命令执行期间被改动过(可能是编辑器或 git 操作),已中止、未写入任何内容;重跑一次即可」**:写前发现目标文件被改,重跑。(目前只有 `close` 会做这项核对。)
|
|
41
64
|
|
|
42
|
-
|
|
65
|
+
### 写后提交 / 推送(退 1 或提示,文件都没丢)
|
|
66
|
+
|
|
67
|
+
- **「git add 失败:… 文件还在磁盘上(本次改动没丢)」**:按错误原话处理(常见:钩子 / 权限),再手动 `git add <文件> && git commit`。
|
|
68
|
+
- **「git commit 失败:… 文件已暂存、本次改动没丢」**:常见是 commit 检查钩子拒了消息,按提示处理后重跑。
|
|
69
|
+
- **「提示:没有上游分支,本地提交已保留、未推送」**:给分支设上游(`git push -u origin <分支>`)后重跑即可推走。
|
|
70
|
+
- **「提示:推送被拒(远端有新提交)… 且本地还有你自己的提交 ⇒ 不自动 rebase」**:脚本故意不动你的非台账提交。手动 `git pull --rebase && git push`;重推时它们会被一并推上去,自行确认。台账改动已提交在本地,不会丢。
|
|
71
|
+
- **「提示:推送被拒,本次台账改动已提交在本地、未推送」**:手动 `git pull --rebase && git push`(普通文本冲突自己解;脚本的「按任务号合并」只在它自动重试时发生)。
|
|
72
|
+
- **「提示:推送被拒后 rebase 撞上冲突,已按任务逐个标出冲突(标记里左边是远端、右边是本机)」**:同一任务被两边同时改。编辑该文件保留要的版本 → `git add` → `git rebase --continue`;不想合就 `git rebase --abort` 回到写之前。
|
|
73
|
+
- **「错误:自动 rebase 后回滚也失败了,仓库可能停在 rebase 中间态」**:**先别跑任何台账命令**。`git rebase --abort`;仍失败就看 `git status` 残留,必要时删 `.git/rebase-merge` / `.git/rebase-apply`。
|
|
74
|
+
- **「X 的手写改动与上游的最新版本冲突,冲突标记已留在工作树里」**:逐个编辑解决冲突 → `git add <文件>` → `git stash drop`,然后重跑。**顺序不能反**:先 drop 再解决,手写的那段就没了。
|
|
75
|
+
- **「提示:写前拉取失败(远端连不上),本次按本地最新内容写;推送也可能失败」**:网络问题,照写;稍后联网了重跑一次同步。
|
|
76
|
+
|
|
77
|
+
### 只提示、不拦(退出码 0)
|
|
78
|
+
|
|
79
|
+
- **「提示:读不到 git 身份(user.email),本次按兜底身份 X 记账」**:命令已成功。让用户配 `git config user.email`,否则任务记不到正确名下。
|
|
80
|
+
- **「提示:格式版本是 … 当前是 …;不拦,但字段可能有出入」**:数据是旧版本写的,功能照常;想统一就按提示升版本。
|
|
81
|
+
- **「提示:写前把本地快进到 …(N 个提交,仅 fast-forward)」**:纯告知。
|
|
82
|
+
- **「提示:写前快进到 … 没成功(工作树有未提交改动),改为只刷新本次要写的台账文件」**:没碍事,照写。
|
|
83
|
+
- **「提示:rebase 成功,但把你的未提交改动放回工作树时冲突了」**:冲突标记只在被点名的文件里,解完 `git add` 后重跑台账命令即可。
|
|
84
|
+
- **「提示:只写了本地文件、未签入 git(也未推送)」**:用了 `--no-sync`;之后自己 `git add ledger && commit && push`。
|
|
85
|
+
- **「提示:当前目录不是 git 仓库,台账只写了本地文件」**:没有 git 也能用;想纳入版本管理就 `git init`,否则继续本地写(多人各写各的,后写覆盖先写)。
|
|
86
|
+
- **「警告:历史表列数不是 9 且无法整体升级,本次按现有列数追加」**:历史文件是旧列数,本次照旧格式追加;要统一就手工升级表头(9 列见 state-model)。
|
|
87
|
+
- **「提示:任务 X 没有过程文档,已跳过封卷,历史末列留空」**:老数据没有 worklog 字段,结案照常,只是没有文档可封。
|
|
88
|
+
- **`无需重复:这次没有新的改动。`**(`claim` / `set` / `close` 的 already_done 通道):上次已经做成过,重跑是幂等空转;`close` 重跑还会顺手补齐上次崩在半途的封卷与移出。
|
|
89
|
+
- **`validate` 的 notices**(同号任务出现在多个文件、历史解析警告、worklog 仍是骨架等):只提示不判错,照实转述。
|
|
90
|
+
|
|
91
|
+
## validate 查什么(有问题 ⇒ 退出码 1)
|
|
92
|
+
|
|
93
|
+
两档:默认**浅度**只查格式与结构(账本 JSON + 当月历史 + 每篇过程文档的模板格式);加 **`--deep`** 才多读全部历史月份、并读正文判「还是不是空骨架」。
|
|
94
|
+
|
|
95
|
+
problems(任一即退 1):① 池里任务带着 owner;② 池里任务 status 不是 open/doing;③ 索引登记了身份但分片文件不在;④ 分片任务没有 owner;⑤ 分片任务 owner 与分片所属键不一致;⑥ 分片任务 status 非法;⑦ `owners/` 里有索引没登记的 `*.json`;⑧ 过程文档格式坏(第一行不是标题 / 头部缺「创建」行 / 前五节缺或顺序不对)。
|
|
96
|
+
notices(只提示):过程文档缺「执行记录」节或有重复的节、历史解析警告、历史有结案行但任务还在办、存量 `PROGRESS-HISTORY.md` 存在、还没有 `owners.json`;`--deep` 另加「过程文档还是空骨架」。
|
|
97
|
+
①~⑦ 里结构类的属人工裁决:读原文确认哪份是真的,删多余那份或把索引改对,**不要用脚本猜**。索引或任一 JSON 损坏 ⇒ 退 3。
|
|
98
|
+
|
|
99
|
+
## 写前取最新与写后同步(机制)
|
|
100
|
+
|
|
101
|
+
- 写命令全程持仓级锁(git 公共目录里的 `dsh-task-ledger.sync.lock`,同仓库所有 worktree 共享,最多等 60 秒;`--no-sync` 也跳不过这把锁)。只读命令不排队。
|
|
102
|
+
- **写前**:先 guard 三查 —— scope 内非 worklog 台账文件有未提交改动 / 有未推送的台账提交 / 有未入库的同名文件,命中即中止;再定向取最新:自举 scope(`owners.json` + `pool.json` + 我的分片)加动作 scope(`new` 加目标文件与当月 worklog 目录;`claim` / `set` 还加索引里其他人的全部分片;`close` 加当月 `history/`;任务没有 `worklog` 字段时退整个 `worklog/`)。干净就刷成远端最新,本地有未提交手写改动就 stash → 快进 → 叠回,冲突即中止。`worklog/` 目录只刷新本地已存在的文件(上游新增的不会自动补下来)。
|
|
103
|
+
- **写后**:只 `git add` 台账路径,然后 `commit --no-verify` —— 提交失败退 1;push 失败只提示、退 0,被拒时自动 `rebase --autostash` 合并一次:不同任务自动合并,同一任务两边都改留冲突标记让人定;本地有非台账提交时不 rebase。
|
|
104
|
+
- **回滚**:命令失败时,写前刷下来的内容按字节还原、本次新建的文件删掉;业务写的自补偿只有两处 —— `new`(删任务行 + 删文档)、`close`(三个文件按字节还原)。
|
|
105
|
+
- **`--no-sync` / `TASK_LEDGER_NO_SYNC=1`**:跳过 guard、取最新、提交与推送;自举照跑;有写入时会提示「只写了本地文件、未签入 git」。注意它挂在**顶层**:`progress.py --no-sync <子命令> …`,放子命令后面是用法错。
|
|
106
|
+
- **环境**:git ≥ 2.27(自动合并依赖 `rebase --autostash`)。不是 git 仓库 ⇒ 同步整体跳过、台账照写,并提示「只写了本地文件」;连 git 二进制都没装也照样写(身份走兜底链 + 提示)。
|
|
107
|
+
|
|
108
|
+
## 硬约束
|
|
43
109
|
|
|
44
|
-
|
|
45
|
-
- **没有 git 的目录自动跳过**:台账照常写入,不提交、不推送、不报错。
|
|
110
|
+
不要直接编辑 `ledger/pool.json`、`ledger/owners.json`、`ledger/owners/*.json`、`ledger/history/*.md`;不要另建 worklog;不要手写「执行记录」节。
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"""事务的唯一入口,20 行:把这次要碰的文件按固定顺序一次锁住。
|
|
2
|
+
|
|
3
|
+
只加锁,不管写盘、不碰 git、不打印。
|
|
4
|
+
|
|
5
|
+
三条硬约束:
|
|
6
|
+
1. 锁集**只许宽不许窄**;锁集以外的文件一个字节都不许写。
|
|
7
|
+
2. **所有判定依据必须在锁内重读** —— 锁外读到的快照只能用来决定"锁多宽"。
|
|
8
|
+
3. **任何失败补偿都不许再开一个 `atom`**(文件锁不可重入),只能在同一事务内做完。
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import contextlib
|
|
14
|
+
from collections.abc import Iterator
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
from ..core.identity import Identity, normalize
|
|
20
|
+
from ..core.paths import (
|
|
21
|
+
HISTORY_DIR,
|
|
22
|
+
OWNERS_REL,
|
|
23
|
+
POOL_REL,
|
|
24
|
+
WORKLOG_DIR,
|
|
25
|
+
history_path_for,
|
|
26
|
+
shard_rel,
|
|
27
|
+
worklog_dir_for,
|
|
28
|
+
worklog_rel,
|
|
29
|
+
)
|
|
30
|
+
from ..store.locks import ledger_lock
|
|
31
|
+
from ..store.owners_index import OwnersIndex
|
|
32
|
+
from ..store.task_store import TaskStore
|
|
33
|
+
|
|
34
|
+
ACTIONS = ("bootstrap", "new", "claim", "set", "log", "close", "tags")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True)
|
|
38
|
+
class Scope:
|
|
39
|
+
"""这次要碰哪些文件。`files` 加锁(也逐个刷),`dirs` 只刷盘(展开成已存在的文件)。"""
|
|
40
|
+
|
|
41
|
+
files: tuple[Path, ...] = ()
|
|
42
|
+
dirs: tuple[Path, ...] = ()
|
|
43
|
+
|
|
44
|
+
def __or__(self, other: Scope) -> Scope:
|
|
45
|
+
return Scope(
|
|
46
|
+
files=_merge(self.files, other.files),
|
|
47
|
+
dirs=_merge(self.dirs, other.dirs),
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _merge(a: tuple[Path, ...], b: tuple[Path, ...]) -> tuple[Path, ...]:
|
|
52
|
+
out: list[Path] = []
|
|
53
|
+
for item in (*a, *b):
|
|
54
|
+
if item not in out:
|
|
55
|
+
out.append(item)
|
|
56
|
+
return tuple(out)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _pool(repo: Path) -> Path:
|
|
60
|
+
return POOL_REL
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _shard(repo: Path, me: Identity) -> Path:
|
|
64
|
+
return shard_rel(me.email)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _other_shards(repo: Path, me: Identity) -> tuple[Path, ...]:
|
|
68
|
+
"""索引里除我之外的分片(宁宽:认领的前置要查、交接要写对方那份)。"""
|
|
69
|
+
index = OwnersIndex(repo)
|
|
70
|
+
me_key = normalize(me.email)
|
|
71
|
+
out: list[Path] = []
|
|
72
|
+
for key, record in index.all().items():
|
|
73
|
+
if key == me_key:
|
|
74
|
+
continue
|
|
75
|
+
name = (record or {}).get("file")
|
|
76
|
+
if name:
|
|
77
|
+
out.append(Path("owners") / str(name))
|
|
78
|
+
return tuple(out)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _worklog_of(
|
|
82
|
+
repo: Path, params: dict[str, Any]
|
|
83
|
+
) -> tuple[tuple[Path, ...], tuple[Path, ...]]:
|
|
84
|
+
"""这条任务的过程文档:查得到给文件,查不到退回整个 `worklog/` 目录(宁宽)。"""
|
|
85
|
+
tid = params.get("tid")
|
|
86
|
+
if not tid:
|
|
87
|
+
return (), (WORKLOG_DIR,)
|
|
88
|
+
pool = TaskStore(repo, POOL_REL)
|
|
89
|
+
task = pool.get(str(tid))
|
|
90
|
+
if task is None:
|
|
91
|
+
index = OwnersIndex(repo)
|
|
92
|
+
for path in index.shards().values():
|
|
93
|
+
rel = Path(path).relative_to(repo / "ledger")
|
|
94
|
+
task = TaskStore(repo, rel).get(str(tid))
|
|
95
|
+
if task is not None:
|
|
96
|
+
break
|
|
97
|
+
worklog = (task or {}).get("worklog")
|
|
98
|
+
if worklog:
|
|
99
|
+
return (Path(worklog),), ()
|
|
100
|
+
return (), (WORKLOG_DIR,)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def lock_scope(repo: Path, me: Identity, action: str, params: dict[str, Any]) -> Scope:
|
|
104
|
+
"""算出这次可能碰哪些文件(**在锁外跑**,宁可算宽)。七个分支与 §四 的表逐行对应。"""
|
|
105
|
+
if action not in ACTIONS:
|
|
106
|
+
raise ValueError(f"未知的 lock_scope action:{action!r}")
|
|
107
|
+
mine = _shard(repo, me)
|
|
108
|
+
pool = _pool(repo)
|
|
109
|
+
index = OWNERS_REL
|
|
110
|
+
others = _other_shards(repo, me)
|
|
111
|
+
|
|
112
|
+
if action == "bootstrap":
|
|
113
|
+
return Scope(files=(index, pool, mine))
|
|
114
|
+
|
|
115
|
+
if action == "new":
|
|
116
|
+
mine_flag = bool(params.get("mine"))
|
|
117
|
+
target = mine if mine_flag else pool
|
|
118
|
+
created = params.get("created") or ""
|
|
119
|
+
tid = params.get("tid")
|
|
120
|
+
# 本次要新建那篇文档:路径算得出来(锁集允许含不存在的路径),不放进去写集就超出锁集
|
|
121
|
+
doc = (
|
|
122
|
+
(Path(worklog_rel(str(tid), created)),) if tid and len(created) >= 7 else ()
|
|
123
|
+
)
|
|
124
|
+
month_dir = worklog_dir_for(created) if len(created) >= 7 else WORKLOG_DIR
|
|
125
|
+
return Scope(files=(target, *doc), dirs=(month_dir,))
|
|
126
|
+
|
|
127
|
+
if action == "claim":
|
|
128
|
+
wfiles, wdirs = _worklog_of(repo, params)
|
|
129
|
+
return Scope(files=(pool, mine, *others, *wfiles), dirs=wdirs)
|
|
130
|
+
|
|
131
|
+
if action == "set":
|
|
132
|
+
wfiles, wdirs = _worklog_of(repo, params)
|
|
133
|
+
# 分支取并集:release 写池、handoff 写对方分片、指向自己且未认领先认领(写我的分片);已在名下则空转不写盘
|
|
134
|
+
return Scope(files=(mine, pool, *others, *wfiles), dirs=wdirs)
|
|
135
|
+
|
|
136
|
+
if action == "log":
|
|
137
|
+
wfiles, wdirs = _worklog_of(repo, params)
|
|
138
|
+
return Scope(files=(mine, *wfiles), dirs=wdirs)
|
|
139
|
+
|
|
140
|
+
if action == "close":
|
|
141
|
+
wfiles, wdirs = _worklog_of(repo, params)
|
|
142
|
+
finished = params.get("finished") or ""
|
|
143
|
+
history = history_path_for(finished) if len(finished) >= 7 else HISTORY_DIR
|
|
144
|
+
return Scope(files=(mine, history, *wfiles), dirs=wdirs)
|
|
145
|
+
|
|
146
|
+
# 六个分支已覆盖 ACTIONS 全集(入口校验在前),走到这里说明表与分支不同步
|
|
147
|
+
raise ValueError(f"lock_scope 没有处理 {action!r}")
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
@contextlib.contextmanager
|
|
151
|
+
def atom(repo: Path, *rels: Path) -> Iterator[None]:
|
|
152
|
+
"""按路径排序后依次加锁,退出即完成。不建记账本、不写盘、不提交、不打印。"""
|
|
153
|
+
with ledger_lock(repo, *rels):
|
|
154
|
+
yield
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""开工前的自举:台账文件不存在就建,没登记过就把自己登记进去、建自己的分片。
|
|
2
|
+
|
|
3
|
+
这三份由**外壳**单独一个 `atom` 包住,它自己不开事务、不拿锁。
|
|
4
|
+
返回 `Identity`(把索引里的 `aliases` 并进来),不参与「提交说明 / where / 打印」。
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from ..core.identity import Identity, with_aliases
|
|
12
|
+
from ..core.paths import LEDGER_DIR, OWNERS_DIR, POOL_REL, shard_rel, to_repo_rel
|
|
13
|
+
from ..store.atomic import ensure_dir
|
|
14
|
+
from ..store.owners_index import OwnersIndex
|
|
15
|
+
from ..store.task_store import TaskStore
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def bootstrap(repo: Path, me: Identity) -> Identity:
|
|
19
|
+
"""写序:`owners/` 目录 → 池 → 索引(登记自己)→ 我的分片。已登记且没变 ⇒ 零写。"""
|
|
20
|
+
ensure_dir(repo / to_repo_rel(LEDGER_DIR))
|
|
21
|
+
ensure_dir(repo / to_repo_rel(OWNERS_DIR))
|
|
22
|
+
TaskStore(repo, POOL_REL).ensure_file()
|
|
23
|
+
index = OwnersIndex(repo)
|
|
24
|
+
_, record = index.ensure(me)
|
|
25
|
+
TaskStore(repo, shard_rel(me.email), identity=me).ensure_file()
|
|
26
|
+
aliases = tuple(record.get("aliases") or ())
|
|
27
|
+
return with_aliases(me, aliases) if aliases else me
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""认领:先写进我的分片,再删池里那行。**顺序反了任务会凭空消失**。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from ..core.identity import Identity, normalize
|
|
8
|
+
from ..core.paths import POOL_REL, shard_rel
|
|
9
|
+
from ..core.time import now
|
|
10
|
+
from ..core.worklog_md import exec_line
|
|
11
|
+
from ..errors import Fail
|
|
12
|
+
from ..store.owners_index import OwnersIndex
|
|
13
|
+
from ..store.task_store import TaskStore
|
|
14
|
+
from ..store.worklog_store import WorklogStore
|
|
15
|
+
from .result import Result, rel
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def claim(repo: Path, me: Identity, tid: str) -> Result:
|
|
19
|
+
"""写序:我的分片 → 删池里那行 → 过程文档记一笔。"""
|
|
20
|
+
pool = TaskStore(repo, POOL_REL)
|
|
21
|
+
mine = TaskStore(repo, shard_rel(me.email), identity=me)
|
|
22
|
+
me_key = normalize(me.email)
|
|
23
|
+
|
|
24
|
+
# 前置:别人正在改它(他的分片里已有这个号)⇒ 拒绝
|
|
25
|
+
for key, path in OwnersIndex(repo).shards().items():
|
|
26
|
+
if key == me_key:
|
|
27
|
+
continue
|
|
28
|
+
other = TaskStore(repo, Path(path).relative_to(repo / "ledger"))
|
|
29
|
+
if other.get(tid) is not None:
|
|
30
|
+
raise Fail(f"任务 {tid} 已在 {key} 的分片里(别人正在做),不能认领。")
|
|
31
|
+
|
|
32
|
+
task = pool.get(tid) # 锁内重读
|
|
33
|
+
if task is None:
|
|
34
|
+
already = mine.get(tid)
|
|
35
|
+
if already is not None:
|
|
36
|
+
# 重跑:分片里已有这个号(上次崩了一半)⇒ 只补删池里那行
|
|
37
|
+
pool.drop(tid)
|
|
38
|
+
logs = WorklogStore(repo)
|
|
39
|
+
path = logs.find(tid, already.get("worklog"))
|
|
40
|
+
if path is not None:
|
|
41
|
+
logs.append_exec(
|
|
42
|
+
path,
|
|
43
|
+
exec_line(now(), me.email, "claim", "认领(补齐上次未完成的清理)"),
|
|
44
|
+
)
|
|
45
|
+
return Result(
|
|
46
|
+
already_done=True,
|
|
47
|
+
where=rel(repo, mine.path),
|
|
48
|
+
message=f"认领 {tid}:补齐上次未完成的清理",
|
|
49
|
+
notices=(f"任务 {tid} 已经在我的分片里,本次只补删公共池那行。",),
|
|
50
|
+
)
|
|
51
|
+
raise Fail(f"公共池里没有任务 {tid}(已经被人认领或不存在)。")
|
|
52
|
+
|
|
53
|
+
current = task.get("status")
|
|
54
|
+
if current not in ("open", "blocked"):
|
|
55
|
+
raise Fail(
|
|
56
|
+
f"任务 {tid} 在公共池里但状态是 {current}(应为 open)—— "
|
|
57
|
+
"先跑 validate 看台账是不是乱了。"
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
moved = dict(task)
|
|
61
|
+
moved["owner"] = me.email
|
|
62
|
+
# 认领即在做:看板把 doing 当「我的在办」算(home.service.ts),置 open 会让
|
|
63
|
+
# 认领后的任务看起来仍没人领。旧版 progress.py 置 doing,分片重构时丢了。
|
|
64
|
+
moved["status"] = "doing"
|
|
65
|
+
moved["started"] = moved.get("started") or now()
|
|
66
|
+
moved["updated"] = now()
|
|
67
|
+
mine.put(tid, moved)
|
|
68
|
+
pool.drop(tid)
|
|
69
|
+
|
|
70
|
+
note = f"任务 {tid} 已记到我的分片,过程文档没找到,跳过执行记录。"
|
|
71
|
+
logs = WorklogStore(repo)
|
|
72
|
+
path = logs.find(tid, moved.get("worklog"))
|
|
73
|
+
if path is not None:
|
|
74
|
+
logs.append_exec(path, exec_line(now(), me.email, "claim", "认领 → doing"))
|
|
75
|
+
note = None
|
|
76
|
+
return Result(
|
|
77
|
+
changed=("owner", "started", "updated"),
|
|
78
|
+
where=rel(repo, mine.path),
|
|
79
|
+
message=f"认领 {tid}:{moved.get('title') or ''}".strip(),
|
|
80
|
+
payload={},
|
|
81
|
+
notices=(note,) if note else (),
|
|
82
|
+
)
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""结案:历史加一行 → 封卷过程文档 → **最后**删分片那行。
|
|
2
|
+
|
|
3
|
+
`finished` 必填、由外壳算好(同一个值也用于算锁集与刷取范围)—— 业务层不许再调时钟,
|
|
4
|
+
这是跨月刷错月份的唯一根因。
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from ..core.identity import Identity
|
|
12
|
+
from ..core.labels import normalize_label, parse_tags
|
|
13
|
+
from ..core.paths import history_path_for, shard_rel, to_repo_rel, worklog_name
|
|
14
|
+
from ..core.worklog_md import (
|
|
15
|
+
append_exec_log,
|
|
16
|
+
exec_line,
|
|
17
|
+
has_frozen_line,
|
|
18
|
+
rewrite_label_line,
|
|
19
|
+
stamp_frozen,
|
|
20
|
+
)
|
|
21
|
+
from ..errors import Fail
|
|
22
|
+
from ..store.atomic import ensure_dir, fingerprint, restore, snapshot
|
|
23
|
+
from ..store.history_store import HIT, UNDECIDABLE, HistoryStore
|
|
24
|
+
from ..store.task_store import TaskStore
|
|
25
|
+
from ..store.worklog_store import WorklogStore
|
|
26
|
+
from .result import Result, rel
|
|
27
|
+
|
|
28
|
+
_COLS_FALLBACK_HINT = "警告:历史表列数不是 9 且无法整体升级,本次按现有列数追加。"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def close_task(
|
|
32
|
+
repo: Path,
|
|
33
|
+
me: Identity,
|
|
34
|
+
tid: str,
|
|
35
|
+
*,
|
|
36
|
+
summary: str,
|
|
37
|
+
finished: str,
|
|
38
|
+
category: str | None = None,
|
|
39
|
+
tags: tuple[str, ...] | None = None,
|
|
40
|
+
) -> Result:
|
|
41
|
+
"""写序四步:改标签行 → 历史加一行 → 封卷 → 删分片(永远是最后一步)。"""
|
|
42
|
+
if not finished:
|
|
43
|
+
raise Fail("结案必须由外壳先算好时间(内部错误:finished 为空)。")
|
|
44
|
+
text_summary = " ".join((summary or "").split())
|
|
45
|
+
if not text_summary:
|
|
46
|
+
raise Fail("结案要写一句「完成了什么」(--summary 不能为空)。")
|
|
47
|
+
new_category = normalize_label(category, field="分类") if category else None
|
|
48
|
+
new_tags = parse_tags(tags) if tags else None
|
|
49
|
+
|
|
50
|
+
logs = WorklogStore(repo)
|
|
51
|
+
history = HistoryStore(repo)
|
|
52
|
+
hist_path = repo / to_repo_rel(history_path_for(finished))
|
|
53
|
+
|
|
54
|
+
mine = TaskStore(repo, shard_rel(me.email), identity=me)
|
|
55
|
+
task = mine.get(tid) # 锁内重读
|
|
56
|
+
if task is None:
|
|
57
|
+
# 分片里已经没有这条:上次崩在「删分片」之后。只按过程文档名判重,命中即已结案
|
|
58
|
+
state = history.has_log(
|
|
59
|
+
hist_path, log_name=worklog_name(tid), created=None, title=None
|
|
60
|
+
)
|
|
61
|
+
if state == HIT:
|
|
62
|
+
return Result(
|
|
63
|
+
already_done=True,
|
|
64
|
+
where=rel(repo, hist_path),
|
|
65
|
+
notices=(f"任务 {tid} 已经结过案,无需重复。",),
|
|
66
|
+
)
|
|
67
|
+
raise Fail(f"任务 {tid} 不在我的分片里(只能结案自己的任务)。")
|
|
68
|
+
|
|
69
|
+
# 历史行那两列取**最终值**:给了参数用新值,没给用任务原值,两处必须一致
|
|
70
|
+
final_category = new_category if new_category else task.get("category")
|
|
71
|
+
final_tags = tuple(new_tags) if new_tags else tuple(task.get("tags") or ())
|
|
72
|
+
|
|
73
|
+
doc_path = logs.find(tid, task.get("worklog"))
|
|
74
|
+
|
|
75
|
+
# 写前核对:指纹任一变了就中止,同时留三份字节备用
|
|
76
|
+
paths = [p for p in (mine.path, hist_path, doc_path) if p is not None]
|
|
77
|
+
stamps = {p: fingerprint(p) for p in paths}
|
|
78
|
+
backups = {p: snapshot(p) for p in paths}
|
|
79
|
+
|
|
80
|
+
try:
|
|
81
|
+
log_name = Path(doc_path).name if doc_path else None
|
|
82
|
+
state = history.has_log(
|
|
83
|
+
hist_path,
|
|
84
|
+
log_name=log_name,
|
|
85
|
+
created=task.get("created"),
|
|
86
|
+
title=task.get("title"),
|
|
87
|
+
)
|
|
88
|
+
if state == HIT:
|
|
89
|
+
# 崩在②(历史行已写)之后、③/④之前:历史里已有,但分片行可能还在、
|
|
90
|
+
# 过程文档可能还没封卷。不补齐就留下「已结案却仍在办」的僵尸 ——
|
|
91
|
+
# 它还能收 log,甚至被释放推回公共池让别人认领(2026-10-24 修)。
|
|
92
|
+
notes = [f"任务 {tid} 已经结过案,无需重复。"]
|
|
93
|
+
if doc_path is not None:
|
|
94
|
+
text = logs.read(doc_path)
|
|
95
|
+
if not has_frozen_line(text):
|
|
96
|
+
frozen = stamp_frozen(text, finished, me.email)
|
|
97
|
+
frozen = append_exec_log(
|
|
98
|
+
frozen, exec_line(finished, me.email, "close", "结案封卷")
|
|
99
|
+
)
|
|
100
|
+
logs.write(doc_path, frozen)
|
|
101
|
+
notes.append("补齐上次未完成的封卷。")
|
|
102
|
+
if mine.get(tid) is not None:
|
|
103
|
+
mine.drop(tid)
|
|
104
|
+
notes.append("补齐上次未完成的移出分片。")
|
|
105
|
+
return Result(
|
|
106
|
+
already_done=True,
|
|
107
|
+
where=rel(repo, hist_path),
|
|
108
|
+
notices=tuple(notes),
|
|
109
|
+
)
|
|
110
|
+
if state == UNDECIDABLE:
|
|
111
|
+
raise Fail(
|
|
112
|
+
f"历史里有一条没有过程文档链接的旧行,判不出是不是 {tid} ⇒ 已中止,"
|
|
113
|
+
f"请人工看一眼 {rel(repo, hist_path)} 再重跑。"
|
|
114
|
+
)
|
|
115
|
+
# 写前核对(2026-10-23 补):三个文件的指纹与读到时不一致 ⇒ 有人在锁外动过,
|
|
116
|
+
# 一个字节都不写(snapshot 那份留着给下面的异常分支按字节退回)。
|
|
117
|
+
for path in paths:
|
|
118
|
+
if fingerprint(path) != stamps[path]:
|
|
119
|
+
raise Fail(
|
|
120
|
+
f"{rel(repo, path)} 在本命令执行期间被改动过"
|
|
121
|
+
"(可能是编辑器或 git 操作),已中止、未写入任何内容;重跑一次即可。"
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
# ① 改标签行(给了 --category / --tags 才做,且必须在封卷之前)
|
|
125
|
+
if doc_path is not None and (new_category or new_tags):
|
|
126
|
+
text = logs.read(doc_path)
|
|
127
|
+
logs.write(doc_path, rewrite_label_line(text, final_category, final_tags))
|
|
128
|
+
|
|
129
|
+
# ② 历史加一行(先 ensure_dir:首次结案时 history/ 可能还不存在)
|
|
130
|
+
ensure_dir(hist_path.parent)
|
|
131
|
+
out = history.append_row(
|
|
132
|
+
hist_path,
|
|
133
|
+
{
|
|
134
|
+
"finished": finished,
|
|
135
|
+
"created": task.get("created"),
|
|
136
|
+
"title": task.get("title") or "",
|
|
137
|
+
"category": final_category,
|
|
138
|
+
"tags": final_tags,
|
|
139
|
+
"owner": me.email,
|
|
140
|
+
"creator": task.get("creator") or me.email,
|
|
141
|
+
"summary": text_summary,
|
|
142
|
+
"worklog": task.get("worklog") if doc_path else None,
|
|
143
|
+
},
|
|
144
|
+
)
|
|
145
|
+
history.write(hist_path, out["text"])
|
|
146
|
+
|
|
147
|
+
# ③ 封卷(没有过程文档就跳过)
|
|
148
|
+
if doc_path is not None:
|
|
149
|
+
text = logs.read(doc_path)
|
|
150
|
+
if not has_frozen_line(text):
|
|
151
|
+
frozen = stamp_frozen(text, finished, me.email)
|
|
152
|
+
frozen = append_exec_log(
|
|
153
|
+
frozen, exec_line(finished, me.email, "close", "结案封卷")
|
|
154
|
+
)
|
|
155
|
+
logs.write(doc_path, frozen)
|
|
156
|
+
|
|
157
|
+
# ④ 删我分片那行(永远最后)
|
|
158
|
+
mine.drop(tid)
|
|
159
|
+
except Exception:
|
|
160
|
+
for path in paths:
|
|
161
|
+
if path in backups:
|
|
162
|
+
restore(path, backups[path])
|
|
163
|
+
raise
|
|
164
|
+
|
|
165
|
+
notices: list[str] = []
|
|
166
|
+
if out.get("fallback_cols"):
|
|
167
|
+
notices.append(_COLS_FALLBACK_HINT)
|
|
168
|
+
if doc_path is None:
|
|
169
|
+
notices.append(f"提示:任务 {tid} 没有过程文档,已跳过封卷,历史末列留空。")
|
|
170
|
+
return Result(
|
|
171
|
+
changed=("status", "finished"),
|
|
172
|
+
where=rel(repo, hist_path),
|
|
173
|
+
message=f"结案 {tid}:{task.get('title') or ''}".strip(),
|
|
174
|
+
payload={
|
|
175
|
+
"history_path": rel(repo, hist_path),
|
|
176
|
+
"worklog_path": rel(repo, doc_path) if doc_path else None,
|
|
177
|
+
"fallback_cols": out.get("fallback_cols"),
|
|
178
|
+
},
|
|
179
|
+
notices=tuple(notices),
|
|
180
|
+
)
|