@cr1992/agentkit 1.0.0 → 1.1.0
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/CHANGELOG.md +9 -0
- package/README.en.md +13 -1
- package/README.md +12 -1
- package/docs/worktree/reclaim-and-watch.md +16 -2
- package/docs/worktree/review-lifecycle.md +20 -3
- package/domains/worktree/worktree-core.mjs +1 -1
- package/domains/worktree/worktree-doctor.mjs +54 -3
- package/domains/worktree/worktree-mgr.mjs +13 -3
- package/domains/worktree/worktree-reclaim.mjs +56 -14
- package/domains/worktree/worktree-review-watch.mjs +200 -22
- package/domains/worktree/worktree-watch-service.mjs +224 -0
- package/manage-worktrees/SKILL.md +4 -0
- package/package.json +1 -1
- package/shell-manifest.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
本项目遵循 [Semantic Versioning](https://semver.org/)。
|
|
4
4
|
|
|
5
|
+
## Unreleased
|
|
6
|
+
|
|
7
|
+
## 1.1.0 - 2026-09-07
|
|
8
|
+
|
|
9
|
+
- 修复从待回收 worktree 自身执行 `reclaim` 时,目录删除后误判本地分支已不存在的问题;未完成的
|
|
10
|
+
branch cleanup 现在返回非零并可幂等重试。
|
|
11
|
+
- `manage-worktrees` 持久化评审 watcher 的 pending/disabled 意图,让 `doctor` 与 `resume-all` 不再忽略未武装记录。
|
|
12
|
+
- 新增 macOS `watch-service` LaunchAgent,为已登记 watcher 提供跨 Agent 会话与重启后的自动恢复触发。
|
|
13
|
+
|
|
5
14
|
## 1.0.0 - 2026-09-04
|
|
6
15
|
|
|
7
16
|
- 将四个 Agent 工程 Skill 收敛为一个零依赖 Node.js CLI 与四个薄壳。
|
package/README.en.md
CHANGED
|
@@ -12,7 +12,7 @@ the `orchestrate-subagents` control plane, which then chooses lightweight or ful
|
|
|
12
12
|
rerouting from effective capabilities, task scale, and local model policy. Providers remain independently usable and
|
|
13
13
|
compose through frozen Artifact, Binding, and Evidence envelopes.
|
|
14
14
|
|
|
15
|
-
[Detailed collaboration contracts and safety boundaries (Chinese, v1.
|
|
15
|
+
[Detailed collaboration contracts and safety boundaries (Chinese, v1.1.0)](./docs/architecture/skill-system-architecture.md)
|
|
16
16
|
|
|
17
17
|
## Included skills
|
|
18
18
|
|
|
@@ -39,6 +39,18 @@ npm install -g @cr1992/agentkit
|
|
|
39
39
|
agentkit doctor
|
|
40
40
|
```
|
|
41
41
|
|
|
42
|
+
On macOS, explicitly install the per-repository user service when worktree merge watchers must recover across agent
|
|
43
|
+
sessions and restarts:
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
agentkit worktree watch-service install
|
|
47
|
+
agentkit worktree watch-service status
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
The npm install does not create a persistent service. The service pins the Node and agentkit paths present at install
|
|
51
|
+
time; rerun `watch-service install` after moving either installation, and inspect `program_available` in
|
|
52
|
+
`status --json`. Other platforms currently use `agentkit worktree resume-all` for manual recovery.
|
|
53
|
+
|
|
42
54
|
### Skills
|
|
43
55
|
|
|
44
56
|
Install all four skills:
|
package/README.md
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
其内部再按有效能力、任务规模、本地模型配置和验收证据选择轻量或完整运行方式及后续重路由。
|
|
12
12
|
各 provider 仍可独立使用,并通过冻结的 Artifact、Binding 和 Evidence envelope 按需组合。
|
|
13
13
|
|
|
14
|
-
[Agent Skills 协作契约与安全边界(v1.
|
|
14
|
+
[Agent Skills 协作契约与安全边界(v1.1.0)](./docs/architecture/skill-system-architecture.md)
|
|
15
15
|
|
|
16
16
|
## 包含的 Skill
|
|
17
17
|
|
|
@@ -37,6 +37,17 @@ npm install -g @cr1992/agentkit
|
|
|
37
37
|
agentkit doctor
|
|
38
38
|
```
|
|
39
39
|
|
|
40
|
+
macOS 上若要让 worktree 合入监听跨 Agent 会话和重启持续恢复,可按仓库显式安装用户级维护器:
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
agentkit worktree watch-service install
|
|
44
|
+
agentkit worktree watch-service status
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
它不会随 npm install 自动常驻。服务固定安装时的 Node 与 agentkit 路径;升级 agentkit、切换 Node
|
|
48
|
+
安装位置后应重新执行 `watch-service install`,`status --json` 的 `program_available` 可检查原路径。
|
|
49
|
+
其他平台当前使用 `agentkit worktree resume-all` 手工恢复。
|
|
50
|
+
|
|
40
51
|
### Skill
|
|
41
52
|
|
|
42
53
|
安装全部 Skill:
|
|
@@ -6,7 +6,15 @@
|
|
|
6
6
|
|
|
7
7
|
监听绑定“冻结的 head SHA 已成为目标 ref 祖先”这一事实,不绑定 change request 载体。载体改为他人代推、
|
|
8
8
|
聚合 MR 或其他分支时不解除监听。进入 `ready_for_review` 默认武装;`submit` 只是其中一个入口。电脑重启后
|
|
9
|
-
由 `resume-all` 恢复 stale watcher;
|
|
9
|
+
由 `resume-all` 恢复 stale watcher;macOS 要让这一步无需新 Agent 会话触发,显式安装用户级维护器:
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
agentkit worktree watch-service install
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
维护器定期执行有限的 `resume-all`,实际 watcher 仍用 token/event CAS 与 heartbeat 裁决;它不放宽任何
|
|
16
|
+
回收前置条件。`watch-service status` 检查 plist、launchd job 与当前 Node/runtime 路径,`uninstall` 解除。
|
|
17
|
+
未安装时只有进程级自动回收,不得称作跨会话保证。change request 已关闭且明确不会合入时用 `unwatch`。
|
|
10
18
|
|
|
11
19
|
## 已推送成果
|
|
12
20
|
|
|
@@ -20,6 +28,11 @@ agentkit worktree reclaim <task-or-id> --pushed <sha-or-unique-prefix>
|
|
|
20
28
|
final_snapshot -> reclaim_ready -> git worktree remove -> branch cleanup -> reclaimed
|
|
21
29
|
```
|
|
22
30
|
|
|
31
|
+
命令可以从待回收 worktree 自身发起;runtime 在删除目录前冻结 primary worktree 作为后续仓库级 Git
|
|
32
|
+
操作的稳定 cwd。branch probe 只有在 `show-ref` 明确返回“不存在”时才记为 `absent`,cwd、权限或其他
|
|
33
|
+
执行错误一律 fail closed。目录已删除但 branch cleanup 未完成时保留 `BRANCH_PENDING`,命令返回非零,
|
|
34
|
+
从任一仍存在的 worktree 重跑同一 `reclaim` 完成对账。
|
|
35
|
+
|
|
23
36
|
终态事件将非 `abandoned` 任务收敛为 `done`,并以最终 source HEAD 闭合 ownership epoch。旧版本若留下
|
|
24
37
|
`reclaimed` 但状态或 epoch 未闭合,重复同一命令会追加 reconciliation event 后再对账。
|
|
25
38
|
|
|
@@ -88,7 +101,8 @@ agentkit worktree reclaim old-task \
|
|
|
88
101
|
|
|
89
102
|
- `abandoned` 只冻结写入,不等于已回收;`doctor` 持续报告残留树和断裂替代关系。
|
|
90
103
|
- 禁止 `rm -rf`、`git worktree remove --force` 和 `branch -D`。
|
|
91
|
-
- branch cleanup 失败时保留 `BRANCH_PENDING
|
|
104
|
+
- branch cleanup 失败时保留 `BRANCH_PENDING` 并返回非零;修复占用后从仍存在的 worktree 重跑相同
|
|
105
|
+
`reclaim`,工具重新验证 branch tip。
|
|
92
106
|
- `git worktree remove` 失败返回非零 `KEEP` 并记录原始错误。即使 Git 登记已解除,物理目录仍在也不能
|
|
93
107
|
标为 `reclaimed`;先恢复权限或登记关系,不用强删掩盖孤儿目录。
|
|
94
108
|
- 含 submodule 的树逐个验证已初始化工作区干净,再自动 deinit 并清理该树私有元数据。submodule 脏、
|
|
@@ -13,7 +13,22 @@ agentkit worktree touch <selector> --status ready_for_review
|
|
|
13
13
|
`ready_for_review` 默认按该树登记的 `base_ref` 武装监听,Profile `default_base` 只在 record 没有 base 时
|
|
14
14
|
兜底;显式 target 与既有人工 target 仍然优先。HEAD 前进时同一 event 使旧 watcher 失效,
|
|
15
15
|
再尝试冻结新 HEAD。worktree dirty、HEAD 未推送或远端主干不可刷新时 fail-soft,状态更新成功但明确
|
|
16
|
-
|
|
16
|
+
回报未武装原因,并把 `review_watch=auto/pending` 与原因写入 trace;`resume-all` 会重试。`--no-watch`
|
|
17
|
+
则持久登记 `disabled`,与启动失败严格区分。`doctor` 对 pending、缺失 intent 和显式退出分别报告
|
|
18
|
+
`AUTO_RECLAIM_NOT_ARMED`、`AUTO_RECLAIM_INTENT_MISSING` 与 `AUTO_RECLAIM_DISABLED`。
|
|
19
|
+
|
|
20
|
+
进程级 watcher 只保证当前宿主允许后台进程存活期间的即时回收。需要跨 Agent 会话或机器重启:
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
agentkit worktree watch-service install
|
|
24
|
+
agentkit worktree watch-service status --json
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
macOS adapter 安装用户级 LaunchAgent,每 60 秒(可用 `--interval-seconds` 调整)从 primary worktree
|
|
28
|
+
运行一次 `resume-all`。安装是显式动作,不随 npm install 自动发生,不保存调用会话的环境变量或凭证。
|
|
29
|
+
服务固定安装时的 Node 与 manager script 绝对路径;升级 agentkit 或切换 Node 安装位置后重跑 install,
|
|
30
|
+
`status --json` 的 `program_available` 会检查这两个路径。卸载使用 `watch-service uninstall`。其他平台仍可
|
|
31
|
+
手工运行 `resume-all`,在相应 service adapter 落地前不宣称跨会话保证。
|
|
17
32
|
|
|
18
33
|
人工 `watch --target` 可改默认目标;已经显式武装的 target 不被静默改写,换目标先 `unwatch`。陈旧
|
|
19
34
|
watcher 不能覆盖并发 rearm 或 `merge_detected`。旧 SHA 合入不代表新 HEAD 已完成。
|
|
@@ -88,5 +103,7 @@ agentkit worktree touch <selector> \
|
|
|
88
103
|
未给 target 时使用 record 的 `base_ref`;远端 MR target 不同时先核对 provider/UI,再显式传
|
|
89
104
|
真实目标。`--mr` 只接受 HTTP(S) URL;纯文本载体使用 `--change-ref`。
|
|
90
105
|
|
|
91
|
-
watcher 只在冻结 SHA 成为目标 ref
|
|
92
|
-
|
|
106
|
+
watcher 只在冻结 SHA 成为目标 ref 祖先后回收。`resume-all` 恢复 stale watcher 和已登记的 pending
|
|
107
|
+
intent;对没有 intent 的 legacy 评审记录只报告 skipped,要求重新 `touch ready_for_review`,不猜测它
|
|
108
|
+
是否曾显式 `--no-watch`。change request 关闭且确定不会合入时 `unwatch`。详细回收见
|
|
109
|
+
[reclaim-and-watch.md](reclaim-and-watch.md)。
|
|
@@ -346,7 +346,7 @@ export function readJsonFileOrDie(path, label) {
|
|
|
346
346
|
export function parseArgs(argv) {
|
|
347
347
|
const flags = new Map();
|
|
348
348
|
const positionals = [];
|
|
349
|
-
const booleanFlags = new Set(['json', 'all', 'present', 'archived', 'verbose', 'recover-lock', 'no-watch', 'abort-on-conflict', 'no-rerere', 'recompose', 'scan-conflicts', 'abort', 'continue', 'pause-before-push']);
|
|
349
|
+
const booleanFlags = new Set(['json', 'all', 'present', 'archived', 'verbose', 'quiet', 'recover-lock', 'no-watch', 'abort-on-conflict', 'no-rerere', 'recompose', 'scan-conflicts', 'abort', 'continue', 'pause-before-push']);
|
|
350
350
|
for (let index = 0; index < argv.length; index++) {
|
|
351
351
|
const value = argv[index];
|
|
352
352
|
if (!value.startsWith('--')) {
|
|
@@ -35,6 +35,7 @@ export function createCommands(deps) {
|
|
|
35
35
|
buildListing,
|
|
36
36
|
learningRoot,
|
|
37
37
|
isSettledWorktreeState,
|
|
38
|
+
watchServiceStatus,
|
|
38
39
|
} = deps;
|
|
39
40
|
|
|
40
41
|
function collectDoctorRecordMetadataFindings(loaded, listing, recordsById, record, findings) {
|
|
@@ -243,10 +244,45 @@ export function createCommands(deps) {
|
|
|
243
244
|
|
|
244
245
|
/** @param {ReturnType<typeof loadRepositoryProfile>} loaded @param {Record<string,any>} record @param {Record<string,any>[]} findings */
|
|
245
246
|
function collectDoctorRecordWatcherFindings(loaded, record, findings) {
|
|
246
|
-
|
|
247
|
+
const reviewLifecycle = ['ready_for_review', 'integrating'].includes(record.task_status)
|
|
248
|
+
&& !['reclaimed', 'archived'].includes(record.worktree_state);
|
|
249
|
+
const activeWatch = record.auto_reclaim && !['disarmed', 'reclaimed'].includes(record.auto_reclaim.state)
|
|
250
|
+
? record.auto_reclaim
|
|
251
|
+
: null;
|
|
252
|
+
if (reviewLifecycle && !activeWatch) {
|
|
253
|
+
if (record.review_watch?.policy === 'disabled') {
|
|
254
|
+
findings.push({
|
|
255
|
+
code: 'AUTO_RECLAIM_DISABLED',
|
|
256
|
+
severity: 'warning',
|
|
257
|
+
worktree_id: record.worktree_id,
|
|
258
|
+
path: record.path,
|
|
259
|
+
detail: `已显式关闭跨会话回收(${record.review_watch.reason ?? 'no reason'});合入后需要人工 reclaim。`,
|
|
260
|
+
});
|
|
261
|
+
} else if (record.review_watch?.policy === 'auto') {
|
|
262
|
+
findings.push({
|
|
263
|
+
code: 'AUTO_RECLAIM_NOT_ARMED',
|
|
264
|
+
severity: 'error',
|
|
265
|
+
worktree_id: record.worktree_id,
|
|
266
|
+
path: record.path,
|
|
267
|
+
target_ref: record.review_watch.target_ref ?? null,
|
|
268
|
+
head_sha: record.review_watch.head_sha ?? null,
|
|
269
|
+
detail: record.review_watch.reason ?? '自动回收 intent 已登记,但 watcher 尚未武装;运行 resume-all 重试。',
|
|
270
|
+
});
|
|
271
|
+
} else {
|
|
272
|
+
findings.push({
|
|
273
|
+
code: 'AUTO_RECLAIM_INTENT_MISSING',
|
|
274
|
+
severity: 'error',
|
|
275
|
+
worktree_id: record.worktree_id,
|
|
276
|
+
path: record.path,
|
|
277
|
+
detail: `评审态 record 没有 watcher 或显式退出记录;运行 touch ${record.task} --status ready_for_review 重新登记。`,
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
if (activeWatch) {
|
|
247
283
|
const heartbeat = readWatcherHeartbeat(loaded.context.common_dir, record.worktree_id);
|
|
248
284
|
const health = watcherHealth(record, heartbeat);
|
|
249
|
-
const advance =
|
|
285
|
+
const advance = activeWatch.target_advance;
|
|
250
286
|
if (advance) {
|
|
251
287
|
const prediction = advance.prediction?.state ?? 'unknown';
|
|
252
288
|
findings.push({
|
|
@@ -258,7 +294,7 @@ export function createCommands(deps) {
|
|
|
258
294
|
severity: 'warning',
|
|
259
295
|
worktree_id: record.worktree_id,
|
|
260
296
|
path: record.path,
|
|
261
|
-
target_ref:
|
|
297
|
+
target_ref: activeWatch.target_ref,
|
|
262
298
|
target_sha: advance.target_sha,
|
|
263
299
|
recorded_base_sha: advance.recorded_base_sha,
|
|
264
300
|
prediction: advance.prediction,
|
|
@@ -462,6 +498,21 @@ export function createCommands(deps) {
|
|
|
462
498
|
}
|
|
463
499
|
}
|
|
464
500
|
collectDoctorRecordFindings(loaded, listing, recordsById, findings);
|
|
501
|
+
const expectsDurableWatch = listing.rows.some((row) => row.record
|
|
502
|
+
&& ['ready_for_review', 'integrating'].includes(row.record.task_status)
|
|
503
|
+
&& (row.record.review_watch?.policy === 'auto'
|
|
504
|
+
|| (row.record.auto_reclaim && !['disarmed', 'reclaimed'].includes(row.record.auto_reclaim.state))));
|
|
505
|
+
if (expectsDurableWatch) {
|
|
506
|
+
const service = watchServiceStatus(loaded);
|
|
507
|
+
if (service.supported && (!service.installed || !service.loaded || !service.program_available)) {
|
|
508
|
+
findings.push({
|
|
509
|
+
code: 'AUTO_RECLAIM_SERVICE_INACTIVE',
|
|
510
|
+
severity: 'warning',
|
|
511
|
+
path: service.plist_path ?? loaded.context.primary_worktree,
|
|
512
|
+
detail: '当前 watcher 只有进程级存活保证;运行 agentkit worktree watch-service install 启用跨会话恢复。',
|
|
513
|
+
});
|
|
514
|
+
}
|
|
515
|
+
}
|
|
465
516
|
collectDoctorSupersessionFindings(listing, findings);
|
|
466
517
|
collectDoctorSessionFindings(listing, findings);
|
|
467
518
|
collectDoctorRuntimeFindings(loaded, findings);
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
import { spawn } from 'node:child_process';
|
|
5
5
|
import { createHash, randomUUID } from 'node:crypto';
|
|
6
6
|
import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
7
|
-
import { tmpdir } from 'node:os';
|
|
7
|
+
import { homedir, tmpdir } from 'node:os';
|
|
8
8
|
import { basename, dirname, join, resolve } from 'node:path';
|
|
9
9
|
import { fileURLToPath } from 'node:url';
|
|
10
10
|
|
|
@@ -25,6 +25,7 @@ import { createCommands as createLifecycleCommands } from './worktree-lifecycle.
|
|
|
25
25
|
import { createCommands as createReclaimCommands } from './worktree-reclaim.mjs';
|
|
26
26
|
import { createCommands as createReviewRefreshCommands } from './worktree-review-refresh.mjs';
|
|
27
27
|
import { createCommands as createReviewWatchCommands } from './worktree-review-watch.mjs';
|
|
28
|
+
import { createCommands as createWatchServiceCommands } from './worktree-watch-service.mjs';
|
|
28
29
|
|
|
29
30
|
export { runFileCapture, runFileTry } from './worktree-process.mjs';
|
|
30
31
|
export {
|
|
@@ -71,12 +72,16 @@ const dependencies = {
|
|
|
71
72
|
statSync,
|
|
72
73
|
writeFileSync,
|
|
73
74
|
tmpdir,
|
|
75
|
+
homedir,
|
|
74
76
|
basename,
|
|
75
77
|
dirname,
|
|
76
78
|
join,
|
|
77
79
|
resolve,
|
|
78
80
|
fileURLToPath,
|
|
79
81
|
managerScript,
|
|
82
|
+
processPlatform: process.platform,
|
|
83
|
+
processExecPath: process.execPath,
|
|
84
|
+
processGetuid: () => typeof process.getuid === 'function' ? process.getuid() : 0,
|
|
80
85
|
...mergePreview,
|
|
81
86
|
...profile,
|
|
82
87
|
...provider,
|
|
@@ -119,6 +124,8 @@ const artifactCommands = createArtifactCommands(dependencies);
|
|
|
119
124
|
assignNew(dependencies, artifactCommands, 'worktree-artifact');
|
|
120
125
|
const learningCommands = createLearningCommands(dependencies);
|
|
121
126
|
assignNew(dependencies, learningCommands, 'worktree-learning');
|
|
127
|
+
const watchServiceCommands = createWatchServiceCommands(dependencies);
|
|
128
|
+
assignNew(dependencies, watchServiceCommands, 'worktree-watch-service');
|
|
122
129
|
const doctorCommands = createDoctorCommands(dependencies);
|
|
123
130
|
|
|
124
131
|
export const batchFingerprint = batchCommands.batchFingerprint;
|
|
@@ -126,7 +133,7 @@ export const verifyArtifactEnvelope = artifactCommands.verifyArtifactEnvelope;
|
|
|
126
133
|
|
|
127
134
|
function cmdCapabilities(args) {
|
|
128
135
|
rejectUnknownFlags(args.flags, ['json']);
|
|
129
|
-
console.log(JSON.stringify({ skill: 'manage-worktrees', runtime_version: '1.
|
|
136
|
+
console.log(JSON.stringify({ skill: 'manage-worktrees', runtime_version: '1.4.0', contracts: { worktree_binding: [1], artifact_ref: [1], reflection_record: [1], improvement_proposal: [1], batch_result: [1] }, features: ['git-common-dir-ledger', 'ownership-epochs', 'artifact-verification', 'incident-reflection', 'proposed-only-improvement', 'batch-integrate', 'batch-conflict-scan', 'declared-post-integrate-steps', 'batch-result', 'evidence-archive-reclaim', 'durable-pushed-ref-proof', 'auto-armed-review-watch', 'persistent-review-watch-intent', 'launchd-watch-service', 'review-target-advance-prediction', 'explicit-review-refresh', 'managed-history-rewrite', 'stack-parent-attribution', 'structured-change-registration'], content_digest: worktreeSkillDigest() }, null, 2));
|
|
130
137
|
}
|
|
131
138
|
|
|
132
139
|
function usage() {
|
|
@@ -165,7 +172,9 @@ doctor [--json] [--verbose]
|
|
|
165
172
|
rebuild [<selector>] [--id <uuid>] [--recover-lock]
|
|
166
173
|
watch <selector> [--target <remote/ref>] [--interval-ms <ms>] [--change-ref <text>] [--notify auto|off] [--id <uuid>]
|
|
167
174
|
submit <selector> [--title <text>] [--description <text>] [--target <branch>] [--remote <name>] [--interval-ms <ms>] [--notify auto|off] [--id <uuid>]
|
|
168
|
-
resume-all [--json]
|
|
175
|
+
resume-all [--json] [--quiet]
|
|
176
|
+
watch-service install|status|uninstall [--json] [--interval-seconds <seconds>]
|
|
177
|
+
macOS 用户级 LaunchAgent 定期运行 resume-all;显式安装,不在 npm install 时自动常驻
|
|
169
178
|
unwatch <selector> [--id <uuid>]
|
|
170
179
|
reclaim <selector> --pushed <sha> [--id <uuid>]
|
|
171
180
|
reclaim <selector> --superseded-by <replacement-selector> [--discard <exact-old-head>] [--id <uuid>] [--replacement-id <uuid>]
|
|
@@ -211,6 +220,7 @@ function main(argv = process.argv.slice(2)) {
|
|
|
211
220
|
submit: reviewCommands.cmdSubmit,
|
|
212
221
|
watch: reviewCommands.cmdWatch,
|
|
213
222
|
'resume-all': reviewCommands.cmdResumeAll,
|
|
223
|
+
'watch-service': watchServiceCommands.cmdWatchService,
|
|
214
224
|
unwatch: reviewCommands.cmdUnwatch,
|
|
215
225
|
'watch-worker': reviewCommands.cmdWatchWorker,
|
|
216
226
|
reclaim: reclaimCommands.cmdReclaim,
|
|
@@ -281,19 +281,49 @@ export function createCommands(deps) {
|
|
|
281
281
|
return refs.out.split('\n').filter(Boolean).filter((ref) => ref !== ownBranch).sort();
|
|
282
282
|
}
|
|
283
283
|
|
|
284
|
+
/**
|
|
285
|
+
* 回收可以从目标 worktree 自身发起。目标目录一旦被移除,命令启动时的 current_worktree
|
|
286
|
+
* 就不再是合法 cwd;后续仓库级 Git 操作必须固定使用不会被本次回收删除的 primary worktree。
|
|
287
|
+
* @param {ReturnType<typeof loadRepositoryProfile>} loaded
|
|
288
|
+
*/
|
|
289
|
+
function repositoryGitCwd(loaded) {
|
|
290
|
+
return loaded.context.primary_worktree ?? loaded.context.current_worktree;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* `git show-ref --verify --quiet` 的退出码 1 才表示 ref 不存在。ENOENT、权限错误或其他
|
|
295
|
+
* operational failure 不能降级成 absent,否则会产生“branch=absent”假成功。
|
|
296
|
+
* @param {ReturnType<typeof loadRepositoryProfile>} loaded
|
|
297
|
+
* @param {Record<string,any>} record
|
|
298
|
+
*/
|
|
299
|
+
function inspectLocalBranch(loaded, record) {
|
|
300
|
+
if (!record.branch) return { state: 'absent', reason: null };
|
|
301
|
+
const checked = gitTry(
|
|
302
|
+
['show-ref', '--verify', '--quiet', `refs/heads/${record.branch}`],
|
|
303
|
+
repositoryGitCwd(loaded),
|
|
304
|
+
);
|
|
305
|
+
if (checked.ok) return { state: 'exists', reason: null };
|
|
306
|
+
const status = checked.error && typeof checked.error === 'object' && 'status' in checked.error
|
|
307
|
+
? checked.error.status
|
|
308
|
+
: null;
|
|
309
|
+
if (status === 1) return { state: 'absent', reason: null };
|
|
310
|
+
return {
|
|
311
|
+
state: 'error',
|
|
312
|
+
reason: commandFailureReason(checked, `unable to inspect local branch ${record.branch}`),
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
|
|
284
316
|
/** @param {ReturnType<typeof loadRepositoryProfile>} loaded @param {Record<string,any>} record */
|
|
285
317
|
function localBranchExists(loaded, record) {
|
|
286
|
-
return
|
|
287
|
-
['show-ref', '--verify', '--quiet', `refs/heads/${record.branch}`],
|
|
288
|
-
loaded.context.current_worktree,
|
|
289
|
-
).ok;
|
|
318
|
+
return inspectLocalBranch(loaded, record).state !== 'absent';
|
|
290
319
|
}
|
|
291
320
|
|
|
292
321
|
/** @param {ReturnType<typeof loadRepositoryProfile>} loaded @param {Record<string,any>} record @param {string} pushed */
|
|
293
322
|
function attemptLocalBranchCleanup(loaded, record, pushed) {
|
|
294
323
|
const checkedAt = new Date().toISOString();
|
|
295
324
|
const previousAttempts = Number(record.branch_cleanup?.attempts ?? 0);
|
|
296
|
-
|
|
325
|
+
const branchState = inspectLocalBranch(loaded, record);
|
|
326
|
+
if (branchState.state === 'absent') {
|
|
297
327
|
return {
|
|
298
328
|
status: 'absent',
|
|
299
329
|
branch: record.branch ?? null,
|
|
@@ -302,7 +332,17 @@ export function createCommands(deps) {
|
|
|
302
332
|
reason: null,
|
|
303
333
|
};
|
|
304
334
|
}
|
|
305
|
-
if (
|
|
335
|
+
if (branchState.state === 'error') {
|
|
336
|
+
return {
|
|
337
|
+
status: 'failed',
|
|
338
|
+
branch: record.branch,
|
|
339
|
+
attempts: previousAttempts + 1,
|
|
340
|
+
checked_at: checkedAt,
|
|
341
|
+
reason: branchState.reason,
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
const cwd = repositoryGitCwd(loaded);
|
|
345
|
+
if (!gitTry(['merge-base', '--is-ancestor', record.branch, pushed], cwd).ok) {
|
|
306
346
|
return {
|
|
307
347
|
status: 'failed',
|
|
308
348
|
branch: record.branch,
|
|
@@ -311,7 +351,7 @@ export function createCommands(deps) {
|
|
|
311
351
|
reason: `local branch tip is not merged into pushed sha: ${pushed}`,
|
|
312
352
|
};
|
|
313
353
|
}
|
|
314
|
-
const removed = gitTry(['branch', '-D', '--', record.branch],
|
|
354
|
+
const removed = gitTry(['branch', '-D', '--', record.branch], cwd);
|
|
315
355
|
if (removed.ok) {
|
|
316
356
|
return {
|
|
317
357
|
status: 'deleted',
|
|
@@ -332,8 +372,8 @@ export function createCommands(deps) {
|
|
|
332
372
|
|
|
333
373
|
/** @param {ReturnType<typeof loadRepositoryProfile>} loaded @param {Record<string,any>} record @param {string} pushed */
|
|
334
374
|
function reconcileReclaimedBranchCleanup(loaded, record, pushed) {
|
|
335
|
-
const
|
|
336
|
-
if (['deleted', 'absent'].includes(record.branch_cleanup?.status) &&
|
|
375
|
+
const branchState = inspectLocalBranch(loaded, record);
|
|
376
|
+
if (['deleted', 'absent'].includes(record.branch_cleanup?.status) && branchState.state === 'absent') {
|
|
337
377
|
return { record, branch_cleanup: record.branch_cleanup, changed: false };
|
|
338
378
|
}
|
|
339
379
|
const cleanup = attemptLocalBranchCleanup(loaded, record, pushed);
|
|
@@ -511,7 +551,8 @@ export function createCommands(deps) {
|
|
|
511
551
|
branch_cleanup_changed: terminal.changed || reconciled.changed,
|
|
512
552
|
};
|
|
513
553
|
}
|
|
514
|
-
const
|
|
554
|
+
const repositoryCwd = repositoryGitCwd(loaded);
|
|
555
|
+
const registeredAtStart = parseWorktrees(repositoryCwd)
|
|
515
556
|
.find((worktree) => worktree.path === canonicalSelectorPath(record.path));
|
|
516
557
|
if (!registeredAtStart && existsSync(record.path)) {
|
|
517
558
|
return {
|
|
@@ -538,14 +579,14 @@ export function createCommands(deps) {
|
|
|
538
579
|
}, { pushed, evidence: options.evidence ?? null });
|
|
539
580
|
}
|
|
540
581
|
|
|
541
|
-
const live = parseWorktrees(
|
|
582
|
+
const live = parseWorktrees(repositoryCwd).find((worktree) => worktree.path === canonicalSelectorPath(record.path));
|
|
542
583
|
if (live) {
|
|
543
584
|
const submodules = reclaimSubmodules(live.path);
|
|
544
585
|
if (submodules.reason) return { reclaimed: false, reason: submodules.reason, record };
|
|
545
|
-
const removed = gitTry(['worktree', 'remove', live.path],
|
|
586
|
+
const removed = gitTry(['worktree', 'remove', live.path], repositoryCwd);
|
|
546
587
|
if (!removed.ok) {
|
|
547
588
|
const detail = commandFailureReason(removed, 'git worktree remove refused');
|
|
548
|
-
const stillRegistered = parseWorktrees(
|
|
589
|
+
const stillRegistered = parseWorktrees(repositoryCwd)
|
|
549
590
|
.some((worktree) => worktree.path === canonicalSelectorPath(record.path));
|
|
550
591
|
const residue = !stillRegistered && existsSync(record.path)
|
|
551
592
|
? '; Git registration was removed but the physical directory remains'
|
|
@@ -569,7 +610,7 @@ export function createCommands(deps) {
|
|
|
569
610
|
};
|
|
570
611
|
}
|
|
571
612
|
}
|
|
572
|
-
gitTry(['worktree', 'prune'],
|
|
613
|
+
gitTry(['worktree', 'prune'], repositoryCwd);
|
|
573
614
|
const branchCleanup = attemptLocalBranchCleanup(loaded, record, pushed);
|
|
574
615
|
record = appendReclaimEvent(loaded.context.common_dir, record, 'reclaimed', (next) => {
|
|
575
616
|
const completedAt = new Date().toISOString();
|
|
@@ -659,6 +700,7 @@ export function createCommands(deps) {
|
|
|
659
700
|
}
|
|
660
701
|
if (result.branch_cleanup?.status === 'failed') {
|
|
661
702
|
log(`目录已回收 ${result.record.worktree_id.slice(0, 8)};本地分支 ${result.record.branch} 清理待重试: ${result.branch_cleanup.reason}`);
|
|
703
|
+
process.exitCode = 1;
|
|
662
704
|
return;
|
|
663
705
|
}
|
|
664
706
|
const recovery = result.record.evidence_archive?.archive_ref
|
|
@@ -48,14 +48,103 @@ export function createCommands(deps) {
|
|
|
48
48
|
reclaimRecord,
|
|
49
49
|
} = deps;
|
|
50
50
|
|
|
51
|
+
/** @param {Record<string,any>} record */
|
|
52
|
+
function activeAutoReclaim(record) {
|
|
53
|
+
return record.auto_reclaim && !['disarmed', 'reclaimed'].includes(record.auto_reclaim.state)
|
|
54
|
+
? record.auto_reclaim
|
|
55
|
+
: null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* review_watch 是“应不应该自动回收”的持久意图;auto_reclaim 是某一次实际 watcher 的租约。
|
|
60
|
+
* 两者分开后,启动前置条件失败、宿主杀进程和显式 --no-watch 不再都坍缩成字段缺失。
|
|
61
|
+
* @param {ReturnType<typeof loadRepositoryProfile>} loaded
|
|
62
|
+
* @param {Record<string,any>} record
|
|
63
|
+
* @param {{flags:Map<string,unknown>}} args
|
|
64
|
+
* @param {{head:string|null}} snapshot
|
|
65
|
+
*/
|
|
66
|
+
function reviewWatchIntent(loaded, record, args, snapshot) {
|
|
67
|
+
const previous = record.review_watch?.policy === 'auto'
|
|
68
|
+
? record.review_watch
|
|
69
|
+
: record.auto_reclaim ?? null;
|
|
70
|
+
const targetRef = aliasedFlag(args.flags, 'target', 'watch-target')
|
|
71
|
+
?? previous?.target_ref
|
|
72
|
+
?? record.base_ref
|
|
73
|
+
?? loaded.profile.default_base
|
|
74
|
+
?? null;
|
|
75
|
+
const changeRefValue = aliasedFlag(args.flags, 'change-ref', 'mr');
|
|
76
|
+
return {
|
|
77
|
+
policy: 'auto',
|
|
78
|
+
state: 'pending',
|
|
79
|
+
target_ref: targetRef,
|
|
80
|
+
head_sha: snapshot.head,
|
|
81
|
+
interval_ms: parseWatchInterval(flag(args.flags, 'interval-ms') ?? previous?.interval_ms),
|
|
82
|
+
change_ref: changeRefValue
|
|
83
|
+
? oneLine(changeRefValue, 'change-ref', 1000)
|
|
84
|
+
: previous?.change_ref ?? null,
|
|
85
|
+
notify: parseNotifyMode(flag(args.flags, 'notify') ?? previous?.notify),
|
|
86
|
+
reason: null,
|
|
87
|
+
source: 'auto_touch',
|
|
88
|
+
updated_at: new Date().toISOString(),
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* @param {ReturnType<typeof loadRepositoryProfile>} loaded
|
|
94
|
+
* @param {Record<string,any>} record
|
|
95
|
+
* @param {Record<string,any>} intent
|
|
96
|
+
* @param {'pending'|'disabled'} state
|
|
97
|
+
* @param {string} reason
|
|
98
|
+
* @param {string|null} [disarmToken]
|
|
99
|
+
*/
|
|
100
|
+
function persistReviewWatchIntent(loaded, record, intent, state, reason, disarmToken = null) {
|
|
101
|
+
const now = new Date().toISOString();
|
|
102
|
+
return appendTraceEvent({
|
|
103
|
+
commonDir: loaded.context.common_dir,
|
|
104
|
+
worktreeId: record.worktree_id,
|
|
105
|
+
eventType: state === 'disabled' ? 'review_watch_disabled' : 'review_watch_pending',
|
|
106
|
+
actor: record.agent,
|
|
107
|
+
details: {
|
|
108
|
+
policy: state === 'disabled' ? 'disabled' : 'auto',
|
|
109
|
+
target_ref: intent.target_ref,
|
|
110
|
+
head_sha: intent.head_sha,
|
|
111
|
+
reason,
|
|
112
|
+
},
|
|
113
|
+
mutate(current) {
|
|
114
|
+
if (current.worktree_state === 'reclaimed' || current.worktree_state === 'archived') {
|
|
115
|
+
throw new WorktreeTraceError('WATCH_INTENT_SETTLED', `已结算 record 不能更新 watch intent: ${record.worktree_id}`);
|
|
116
|
+
}
|
|
117
|
+
const next = structuredClone(current);
|
|
118
|
+
if (disarmToken) {
|
|
119
|
+
if (next.auto_reclaim?.token !== disarmToken || ['disarmed', 'reclaimed'].includes(next.auto_reclaim?.state)) {
|
|
120
|
+
throw new WorktreeTraceError('WATCHER_CHANGED', `watch token 已被并发更新或解除: ${record.worktree_id}`);
|
|
121
|
+
}
|
|
122
|
+
next.auto_reclaim.state = 'disarmed';
|
|
123
|
+
next.auto_reclaim.disarmed_at = now;
|
|
124
|
+
next.auto_reclaim.disarm_reason = 'explicit_no_watch';
|
|
125
|
+
}
|
|
126
|
+
next.review_watch = {
|
|
127
|
+
...intent,
|
|
128
|
+
policy: state === 'disabled' ? 'disabled' : 'auto',
|
|
129
|
+
state,
|
|
130
|
+
reason,
|
|
131
|
+
updated_at: now,
|
|
132
|
+
};
|
|
133
|
+
next.updated_at = now;
|
|
134
|
+
return next;
|
|
135
|
+
},
|
|
136
|
+
}).record;
|
|
137
|
+
}
|
|
138
|
+
|
|
51
139
|
function autoArmReviewWatch(loaded, record, args, snapshot) {
|
|
140
|
+
const existing = activeAutoReclaim(record);
|
|
141
|
+
const intent = reviewWatchIntent(loaded, record, args, snapshot);
|
|
52
142
|
if (args.flags.get('no-watch')) {
|
|
143
|
+
persistReviewWatchIntent(loaded, record, intent, 'disabled', 'explicit_no_watch', existing?.token ?? null);
|
|
144
|
+
if (existing?.token) removeWatcherHeartbeat(loaded.context.common_dir, record.worktree_id, existing.token);
|
|
53
145
|
log('watch 未武装:--no-watch 显式退出;合入后需要人工回收。');
|
|
54
146
|
return;
|
|
55
147
|
}
|
|
56
|
-
const existing = record.auto_reclaim && !['disarmed', 'reclaimed'].includes(record.auto_reclaim.state)
|
|
57
|
-
? record.auto_reclaim
|
|
58
|
-
: null;
|
|
59
148
|
// 原子失效旧 watcher 后仍沿用它的 target/interval/change-ref 默认值,避免自动重冻结
|
|
60
149
|
// 把人工显式选择的 target 静默改回该树登记的 base。
|
|
61
150
|
const previous = record.auto_reclaim ?? null;
|
|
@@ -63,8 +152,11 @@ export function createCommands(deps) {
|
|
|
63
152
|
log('watch 保持原冻结 SHA:目标分支已确认包含该 head,自动回收已进入提交阶段。');
|
|
64
153
|
return;
|
|
65
154
|
}
|
|
66
|
-
const skip = (reason) =>
|
|
67
|
-
|
|
155
|
+
const skip = (reason) => {
|
|
156
|
+
persistReviewWatchIntent(loaded, record, intent, 'pending', reason);
|
|
157
|
+
log(`watch 未武装:${reason};自动回收保持关闭,已登记 pending,resume-all/维护服务会重试。`);
|
|
158
|
+
};
|
|
159
|
+
const targetRef = intent.target_ref;
|
|
68
160
|
if (!targetRef || !targetRef.includes('/')) {
|
|
69
161
|
skip(`无法确定远端主干 target(Profile default_base=${loaded.profile.default_base ?? 'null'})`);
|
|
70
162
|
return;
|
|
@@ -102,11 +194,9 @@ export function createCommands(deps) {
|
|
|
102
194
|
targetRef,
|
|
103
195
|
targetSha: refreshedTarget.target_sha,
|
|
104
196
|
headSha: snapshot.head,
|
|
105
|
-
intervalMs:
|
|
106
|
-
changeRef:
|
|
107
|
-
|
|
108
|
-
: previous?.change_ref ?? null,
|
|
109
|
-
notifyMode: parseNotifyMode(flag(args.flags, 'notify') ?? previous?.notify),
|
|
197
|
+
intervalMs: intent.interval_ms,
|
|
198
|
+
changeRef: intent.change_ref,
|
|
199
|
+
notifyMode: intent.notify,
|
|
110
200
|
explicitConfig: flag(args.flags, 'config') ? loaded.profile_path : null,
|
|
111
201
|
previousHealth: health.reason,
|
|
112
202
|
armedBy: 'auto_touch',
|
|
@@ -197,6 +287,18 @@ export function createCommands(deps) {
|
|
|
197
287
|
disarm_reason: null,
|
|
198
288
|
pid: null,
|
|
199
289
|
};
|
|
290
|
+
next.review_watch = {
|
|
291
|
+
policy: 'auto',
|
|
292
|
+
state: 'armed',
|
|
293
|
+
target_ref: options.targetRef,
|
|
294
|
+
head_sha: options.headSha,
|
|
295
|
+
interval_ms: options.intervalMs,
|
|
296
|
+
change_ref: options.changeRef,
|
|
297
|
+
notify: options.notifyMode,
|
|
298
|
+
reason: null,
|
|
299
|
+
source: options.armedBy ?? existing?.armed_by ?? 'explicit',
|
|
300
|
+
updated_at: now,
|
|
301
|
+
};
|
|
200
302
|
next.updated_at = now;
|
|
201
303
|
return next;
|
|
202
304
|
},
|
|
@@ -465,24 +567,71 @@ export function createCommands(deps) {
|
|
|
465
567
|
}
|
|
466
568
|
|
|
467
569
|
function cmdResumeAll(args) {
|
|
468
|
-
rejectUnknownFlags(args.flags, ['json', 'config']);
|
|
570
|
+
rejectUnknownFlags(args.flags, ['json', 'quiet', 'config']);
|
|
469
571
|
if (args.positionals.length) die('resume-all 不接受 selector;它只扫描已 arm record。', 2);
|
|
470
572
|
const loaded = loadRepositoryProfile({ explicitConfigPath: flag(args.flags, 'config') });
|
|
471
573
|
const result = { resumed: [], healthy: [], skipped: [] };
|
|
472
|
-
const records = loadRecords(loaded.context.common_dir).filter((record) =>
|
|
473
|
-
record.
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
//
|
|
477
|
-
record.
|
|
574
|
+
const records = loadRecords(loaded.context.common_dir).filter((record) => {
|
|
575
|
+
if (record.worktree_state === 'reclaimed' || record.worktree_state === 'archived') return false;
|
|
576
|
+
if (activeAutoReclaim(record)) return true;
|
|
577
|
+
if (!['ready_for_review', 'integrating'].includes(record.task_status)) return false;
|
|
578
|
+
// auto intent 要重试;没有任何 intent 的 legacy/live record 也必须进入 skipped,不能继续 0/0/0 隐身。
|
|
579
|
+
return record.review_watch?.policy === 'auto' || !record.review_watch;
|
|
580
|
+
});
|
|
478
581
|
for (const record of records) {
|
|
582
|
+
const existing = activeAutoReclaim(record);
|
|
583
|
+
if (!existing) {
|
|
584
|
+
const intent = record.review_watch;
|
|
585
|
+
if (!intent) {
|
|
586
|
+
result.skipped.push({ worktree_id: record.worktree_id, task: record.task, reason: 'review watch intent missing; touch ready_for_review again' });
|
|
587
|
+
continue;
|
|
588
|
+
}
|
|
589
|
+
const snapshot = liveGitSnapshot(record);
|
|
590
|
+
let reason = null;
|
|
591
|
+
if (!snapshot.present) reason = 'worktree missing';
|
|
592
|
+
else if (snapshot.dirty !== false) reason = 'worktree dirty';
|
|
593
|
+
else if (!snapshot.head || snapshot.head !== intent.head_sha) reason = 'live head differs from pending intent';
|
|
594
|
+
else {
|
|
595
|
+
const upstream = gitTry(['rev-parse', '@{upstream}^{commit}'], record.path);
|
|
596
|
+
if (!upstream.ok || upstream.out !== snapshot.head) reason = 'current head not fully pushed to upstream';
|
|
597
|
+
}
|
|
598
|
+
if (!intent.target_ref || typeof intent.target_ref !== 'string') reason ??= 'target ref missing';
|
|
599
|
+
const intervalMs = Number(intent.interval_ms);
|
|
600
|
+
if (!Number.isInteger(intervalMs) || intervalMs < WATCH_MIN_INTERVAL_MS || intervalMs > WATCH_MAX_INTERVAL_MS) reason ??= 'interval invalid';
|
|
601
|
+
let refreshed = null;
|
|
602
|
+
if (!reason) {
|
|
603
|
+
refreshed = refreshTargetRef(intent.target_ref, loaded.context.current_worktree);
|
|
604
|
+
if (!refreshed.ok) reason = `target ref unavailable: ${intent.target_ref}`;
|
|
605
|
+
}
|
|
606
|
+
if (reason) {
|
|
607
|
+
result.skipped.push({ worktree_id: record.worktree_id, task: record.task, reason });
|
|
608
|
+
continue;
|
|
609
|
+
}
|
|
610
|
+
try {
|
|
611
|
+
const started = startWatcher(loaded, record, {
|
|
612
|
+
targetRef: intent.target_ref,
|
|
613
|
+
targetSha: refreshed.target_sha,
|
|
614
|
+
headSha: intent.head_sha,
|
|
615
|
+
intervalMs,
|
|
616
|
+
changeRef: intent.change_ref ?? null,
|
|
617
|
+
notifyMode: intent.notify ?? 'auto',
|
|
618
|
+
explicitConfig: flag(args.flags, 'config') ? loaded.profile_path : null,
|
|
619
|
+
previousHealth: intent.reason ?? 'pending intent',
|
|
620
|
+
armedBy: intent.source ?? 'auto_touch',
|
|
621
|
+
});
|
|
622
|
+
result.resumed.push({ worktree_id: record.worktree_id, task: record.task, pid: started.pid, previous_health: intent.reason ?? 'pending intent', dirty: false });
|
|
623
|
+
} catch (error) {
|
|
624
|
+
result.skipped.push({ worktree_id: record.worktree_id, task: record.task, reason: error instanceof Error ? error.message : String(error) });
|
|
625
|
+
}
|
|
626
|
+
continue;
|
|
627
|
+
}
|
|
479
628
|
const heartbeat = readWatcherHeartbeat(loaded.context.common_dir, record.worktree_id);
|
|
480
629
|
const health = watcherHealth(record, heartbeat);
|
|
481
630
|
if (health.healthy) {
|
|
482
631
|
result.healthy.push({ worktree_id: record.worktree_id, task: record.task, pid: heartbeat.state.pid });
|
|
483
632
|
continue;
|
|
484
633
|
}
|
|
485
|
-
const auto =
|
|
634
|
+
const auto = existing;
|
|
486
635
|
const intervalMs = Number(auto.interval_ms);
|
|
487
636
|
const snapshot = liveGitSnapshot(record);
|
|
488
637
|
let reason = null;
|
|
@@ -512,6 +661,7 @@ export function createCommands(deps) {
|
|
|
512
661
|
result.skipped.push({ worktree_id: record.worktree_id, task: record.task, reason: error instanceof Error ? error.message : String(error) });
|
|
513
662
|
}
|
|
514
663
|
}
|
|
664
|
+
if (args.flags.get('quiet')) return;
|
|
515
665
|
if (args.flags.get('json')) {
|
|
516
666
|
console.log(JSON.stringify(result, null, 2));
|
|
517
667
|
return;
|
|
@@ -526,17 +676,45 @@ export function createCommands(deps) {
|
|
|
526
676
|
rejectUnknownFlags(args.flags, ['id', 'config']);
|
|
527
677
|
const loaded = loadRepositoryProfile({ explicitConfigPath: flag(args.flags, 'config') });
|
|
528
678
|
let record = selectRecord(loadRecords(loaded.context.common_dir), args.positionals[0] ?? null, flag(args.flags, 'id'));
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
log(`watcher 未 arm: ${record.worktree_id.slice(0, 8)}`);
|
|
679
|
+
if (record.worktree_state === 'reclaimed' || record.worktree_state === 'archived') {
|
|
680
|
+
log(`已结算,无需 unwatch: ${record.worktree_id.slice(0, 8)}`);
|
|
532
681
|
return;
|
|
533
682
|
}
|
|
534
|
-
|
|
683
|
+
const active = activeAutoReclaim(record);
|
|
684
|
+
const token = active?.token;
|
|
685
|
+
if (!token) {
|
|
686
|
+
if (record.review_watch?.policy === 'disabled') {
|
|
687
|
+
log(`watcher 未 arm: ${record.worktree_id.slice(0, 8)}`);
|
|
688
|
+
return;
|
|
689
|
+
}
|
|
690
|
+
const snapshot = liveGitSnapshot(record);
|
|
691
|
+
const intent = {
|
|
692
|
+
...(record.review_watch ?? {}),
|
|
693
|
+
target_ref: record.review_watch?.target_ref ?? record.base_ref ?? null,
|
|
694
|
+
head_sha: record.review_watch?.head_sha ?? snapshot.head,
|
|
695
|
+
interval_ms: record.review_watch?.interval_ms ?? parseWatchInterval(null),
|
|
696
|
+
change_ref: record.review_watch?.change_ref ?? null,
|
|
697
|
+
notify: record.review_watch?.notify ?? 'auto',
|
|
698
|
+
source: record.review_watch?.source ?? 'explicit',
|
|
699
|
+
};
|
|
700
|
+
persistReviewWatchIntent(loaded, record, intent, 'disabled', 'explicit_unwatch');
|
|
701
|
+
log(`auto-reclaim 已明确关闭: ${record.worktree_id.slice(0, 8)}`);
|
|
702
|
+
return;
|
|
703
|
+
}
|
|
704
|
+
if (active.state === 'merge_detected') {
|
|
535
705
|
die('目标分支已确认包含冻结的 MR head,自动回收已进入提交阶段,不能再 unwatch。');
|
|
536
706
|
}
|
|
537
707
|
record = appendReclaimEvent(loaded.context.common_dir, record, 'auto_reclaim_disarmed', (next) => {
|
|
538
708
|
next.auto_reclaim.state = 'disarmed';
|
|
539
709
|
next.auto_reclaim.disarmed_at = new Date().toISOString();
|
|
710
|
+
next.auto_reclaim.disarm_reason = 'explicit_unwatch';
|
|
711
|
+
next.review_watch = {
|
|
712
|
+
...(next.review_watch ?? {}),
|
|
713
|
+
policy: 'disabled',
|
|
714
|
+
state: 'disabled',
|
|
715
|
+
reason: 'explicit_unwatch',
|
|
716
|
+
updated_at: new Date().toISOString(),
|
|
717
|
+
};
|
|
540
718
|
});
|
|
541
719
|
removeWatcherHeartbeat(loaded.context.common_dir, record.worktree_id, token);
|
|
542
720
|
log(`auto-reclaim watcher 已解除: ${record.worktree_id.slice(0, 8)}`);
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
const DEFAULT_INTERVAL_SECONDS = 60;
|
|
4
|
+
const MIN_INTERVAL_SECONDS = 30;
|
|
5
|
+
const MAX_INTERVAL_SECONDS = 60 * 60;
|
|
6
|
+
|
|
7
|
+
/** @param {string} value */
|
|
8
|
+
function xmlEscape(value) {
|
|
9
|
+
return value
|
|
10
|
+
.replaceAll('&', '&')
|
|
11
|
+
.replaceAll('<', '<')
|
|
12
|
+
.replaceAll('>', '>');
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** @param {string} repositoryId */
|
|
16
|
+
export function launchAgentLabel(repositoryId) {
|
|
17
|
+
const suffix = String(repositoryId).toLowerCase().replaceAll(/[^a-z0-9]/gu, '').slice(0, 32);
|
|
18
|
+
if (!suffix) throw new Error('repository id 不能生成 launchd label。');
|
|
19
|
+
return `io.github.cr1992.agentkit.worktree.${suffix}`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* 生成固定 argv 的 LaunchAgent;不经过 shell,也不把调用会话的环境变量或凭证写进 plist。
|
|
24
|
+
* @param {{label:string,nodePath:string,managerScript:string,workingDirectory:string,intervalSeconds:number,logPath:string,configPath?:string|null}} options
|
|
25
|
+
*/
|
|
26
|
+
export function renderLaunchAgentPlist(options) {
|
|
27
|
+
const args = [options.nodePath, options.managerScript, 'resume-all', '--quiet'];
|
|
28
|
+
if (options.configPath) args.push('--config', options.configPath);
|
|
29
|
+
const argumentXml = args.map((arg) => ` <string>${xmlEscape(arg)}</string>`).join('\n');
|
|
30
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
31
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
32
|
+
<plist version="1.0">
|
|
33
|
+
<dict>
|
|
34
|
+
<key>Label</key>
|
|
35
|
+
<string>${xmlEscape(options.label)}</string>
|
|
36
|
+
<key>ProgramArguments</key>
|
|
37
|
+
<array>
|
|
38
|
+
${argumentXml}
|
|
39
|
+
</array>
|
|
40
|
+
<key>WorkingDirectory</key>
|
|
41
|
+
<string>${xmlEscape(options.workingDirectory)}</string>
|
|
42
|
+
<key>RunAtLoad</key>
|
|
43
|
+
<true/>
|
|
44
|
+
<key>StartInterval</key>
|
|
45
|
+
<integer>${options.intervalSeconds}</integer>
|
|
46
|
+
<key>AbandonProcessGroup</key>
|
|
47
|
+
<true/>
|
|
48
|
+
<key>ProcessType</key>
|
|
49
|
+
<string>Background</string>
|
|
50
|
+
<key>StandardOutPath</key>
|
|
51
|
+
<string>/dev/null</string>
|
|
52
|
+
<key>StandardErrorPath</key>
|
|
53
|
+
<string>${xmlEscape(options.logPath)}</string>
|
|
54
|
+
</dict>
|
|
55
|
+
</plist>
|
|
56
|
+
`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** @param {unknown} raw */
|
|
60
|
+
export function parseServiceInterval(raw) {
|
|
61
|
+
const value = raw === null || raw === undefined ? DEFAULT_INTERVAL_SECONDS : Number(raw);
|
|
62
|
+
if (!Number.isInteger(value) || value < MIN_INTERVAL_SECONDS || value > MAX_INTERVAL_SECONDS) {
|
|
63
|
+
throw new Error(`--interval-seconds 必须是 ${MIN_INTERVAL_SECONDS}-${MAX_INTERVAL_SECONDS} 的整数。`);
|
|
64
|
+
}
|
|
65
|
+
return value;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* @param {Record<string,any>} deps
|
|
70
|
+
*/
|
|
71
|
+
export function createCommands(deps) {
|
|
72
|
+
const {
|
|
73
|
+
processPlatform,
|
|
74
|
+
processExecPath,
|
|
75
|
+
processGetuid,
|
|
76
|
+
homedir,
|
|
77
|
+
randomUUID,
|
|
78
|
+
existsSync,
|
|
79
|
+
mkdirSync,
|
|
80
|
+
readFileSync,
|
|
81
|
+
renameSync,
|
|
82
|
+
rmSync,
|
|
83
|
+
writeFileSync,
|
|
84
|
+
dirname,
|
|
85
|
+
join,
|
|
86
|
+
managerScript,
|
|
87
|
+
runFileCapture,
|
|
88
|
+
loadRepositoryProfile,
|
|
89
|
+
readRepositoryIdentity,
|
|
90
|
+
ensureRepositoryIdentity,
|
|
91
|
+
traceLayout,
|
|
92
|
+
flag,
|
|
93
|
+
rejectUnknownFlags,
|
|
94
|
+
die,
|
|
95
|
+
log,
|
|
96
|
+
} = deps;
|
|
97
|
+
|
|
98
|
+
/** @param {ReturnType<typeof loadRepositoryProfile>} loaded @param {boolean} createIdentity */
|
|
99
|
+
function descriptor(loaded, createIdentity) {
|
|
100
|
+
const identity = createIdentity
|
|
101
|
+
? ensureRepositoryIdentity(loaded.context)
|
|
102
|
+
: readRepositoryIdentity(loaded.context);
|
|
103
|
+
if (!identity) return null;
|
|
104
|
+
const label = launchAgentLabel(identity.repository_id);
|
|
105
|
+
const plistPath = join(homedir(), 'Library', 'LaunchAgents', `${label}.plist`);
|
|
106
|
+
const logPath = join(traceLayout(loaded.context.common_dir).root, 'watch-service.log');
|
|
107
|
+
const uid = processGetuid();
|
|
108
|
+
return {
|
|
109
|
+
label,
|
|
110
|
+
plist_path: plistPath,
|
|
111
|
+
log_path: logPath,
|
|
112
|
+
domain: `gui/${uid}`,
|
|
113
|
+
service_target: `gui/${uid}/${label}`,
|
|
114
|
+
working_directory: loaded.context.primary_worktree,
|
|
115
|
+
config_path: loaded.profile_source === 'explicit' ? loaded.profile_path : null,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** @param {ReturnType<typeof loadRepositoryProfile>} loaded */
|
|
120
|
+
function watchServiceStatus(loaded) {
|
|
121
|
+
if (processPlatform !== 'darwin') {
|
|
122
|
+
return { supported: false, installed: false, loaded: false, platform: processPlatform, reason: 'macOS LaunchAgent adapter only' };
|
|
123
|
+
}
|
|
124
|
+
const value = descriptor(loaded, false);
|
|
125
|
+
if (!value) {
|
|
126
|
+
return { supported: true, installed: false, loaded: false, platform: processPlatform, reason: 'repository identity missing' };
|
|
127
|
+
}
|
|
128
|
+
const probe = runFileCapture('/bin/launchctl', ['print', value.service_target], { cwd: loaded.context.primary_worktree });
|
|
129
|
+
return {
|
|
130
|
+
supported: true,
|
|
131
|
+
installed: existsSync(value.plist_path),
|
|
132
|
+
loaded: probe.ok,
|
|
133
|
+
platform: processPlatform,
|
|
134
|
+
label: value.label,
|
|
135
|
+
plist_path: value.plist_path,
|
|
136
|
+
log_path: value.log_path,
|
|
137
|
+
node_path: processExecPath,
|
|
138
|
+
manager_script: managerScript,
|
|
139
|
+
program_available: existsSync(processExecPath) && existsSync(managerScript),
|
|
140
|
+
reason: probe.ok ? null : (probe.out || 'launchd job not loaded'),
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** @param {ReturnType<typeof loadRepositoryProfile>} loaded @param {number} intervalSeconds */
|
|
145
|
+
function install(loaded, intervalSeconds) {
|
|
146
|
+
if (processPlatform !== 'darwin') die(`watch-service install 当前只支持 macOS;${processPlatform} 可继续手工运行 resume-all。`, 2);
|
|
147
|
+
const value = descriptor(loaded, true);
|
|
148
|
+
const plist = renderLaunchAgentPlist({
|
|
149
|
+
label: value.label,
|
|
150
|
+
nodePath: processExecPath,
|
|
151
|
+
managerScript,
|
|
152
|
+
workingDirectory: value.working_directory,
|
|
153
|
+
intervalSeconds,
|
|
154
|
+
logPath: value.log_path,
|
|
155
|
+
configPath: value.config_path,
|
|
156
|
+
});
|
|
157
|
+
mkdirSync(dirname(value.plist_path), { recursive: true });
|
|
158
|
+
mkdirSync(dirname(value.log_path), { recursive: true });
|
|
159
|
+
const previous = existsSync(value.plist_path) ? readFileSync(value.plist_path, 'utf8') : null;
|
|
160
|
+
const temporary = `${value.plist_path}.${processGetuid()}.${randomUUID()}.tmp`;
|
|
161
|
+
writeFileSync(temporary, plist, { encoding: 'utf8', mode: 0o600, flag: 'wx' });
|
|
162
|
+
|
|
163
|
+
const wasLoaded = runFileCapture('/bin/launchctl', ['print', value.service_target], { cwd: value.working_directory }).ok;
|
|
164
|
+
if (wasLoaded) {
|
|
165
|
+
const stopped = runFileCapture('/bin/launchctl', ['bootout', value.service_target], { cwd: value.working_directory });
|
|
166
|
+
if (!stopped.ok) {
|
|
167
|
+
rmSync(temporary, { force: true });
|
|
168
|
+
die(`无法重载现有 LaunchAgent:${stopped.out || 'launchctl bootout failed'}`);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
renameSync(temporary, value.plist_path);
|
|
172
|
+
const started = runFileCapture('/bin/launchctl', ['bootstrap', value.domain, value.plist_path], { cwd: value.working_directory });
|
|
173
|
+
if (!started.ok) {
|
|
174
|
+
if (previous === null) rmSync(value.plist_path, { force: true });
|
|
175
|
+
else writeFileSync(value.plist_path, previous, { encoding: 'utf8', mode: 0o600 });
|
|
176
|
+
if (wasLoaded && previous !== null) runFileCapture('/bin/launchctl', ['bootstrap', value.domain, value.plist_path], { cwd: value.working_directory });
|
|
177
|
+
die(`LaunchAgent 安装失败:${started.out || 'launchctl bootstrap failed'}`);
|
|
178
|
+
}
|
|
179
|
+
const kicked = runFileCapture('/bin/launchctl', ['kickstart', '-k', value.service_target], { cwd: value.working_directory });
|
|
180
|
+
if (!kicked.ok) die(`LaunchAgent 已加载但首次执行失败:${kicked.out || 'launchctl kickstart failed'}`);
|
|
181
|
+
return { ...watchServiceStatus(loaded), interval_seconds: intervalSeconds };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** @param {ReturnType<typeof loadRepositoryProfile>} loaded */
|
|
185
|
+
function uninstall(loaded) {
|
|
186
|
+
if (processPlatform !== 'darwin') die(`watch-service uninstall 当前只支持 macOS;当前平台 ${processPlatform}。`, 2);
|
|
187
|
+
const value = descriptor(loaded, false);
|
|
188
|
+
if (!value) return { supported: true, installed: false, loaded: false, removed: false };
|
|
189
|
+
const loadedBefore = runFileCapture('/bin/launchctl', ['print', value.service_target], { cwd: value.working_directory }).ok;
|
|
190
|
+
if (loadedBefore) {
|
|
191
|
+
const stopped = runFileCapture('/bin/launchctl', ['bootout', value.service_target], { cwd: value.working_directory });
|
|
192
|
+
if (!stopped.ok) die(`LaunchAgent 停止失败:${stopped.out || 'launchctl bootout failed'}`);
|
|
193
|
+
}
|
|
194
|
+
const installedBefore = existsSync(value.plist_path);
|
|
195
|
+
rmSync(value.plist_path, { force: true });
|
|
196
|
+
return { supported: true, installed: false, loaded: false, removed: installedBefore || loadedBefore, label: value.label, plist_path: value.plist_path };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function cmdWatchService(args) {
|
|
200
|
+
rejectUnknownFlags(args.flags, ['json', 'config', 'interval-seconds']);
|
|
201
|
+
const action = args.positionals[0] ?? 'status';
|
|
202
|
+
if (args.positionals.length > 1 || !['install', 'status', 'uninstall'].includes(action)) {
|
|
203
|
+
die('watch-service 用法:watch-service install|status|uninstall [--json] [--interval-seconds <seconds>]', 2);
|
|
204
|
+
}
|
|
205
|
+
const loaded = loadRepositoryProfile({ explicitConfigPath: flag(args.flags, 'config') });
|
|
206
|
+
let result;
|
|
207
|
+
if (action === 'install') {
|
|
208
|
+
let intervalSeconds;
|
|
209
|
+
try { intervalSeconds = parseServiceInterval(flag(args.flags, 'interval-seconds')); }
|
|
210
|
+
catch (error) { die(error instanceof Error ? error.message : String(error), 2); }
|
|
211
|
+
result = install(loaded, intervalSeconds);
|
|
212
|
+
} else if (action === 'uninstall') {
|
|
213
|
+
result = uninstall(loaded);
|
|
214
|
+
} else {
|
|
215
|
+
result = watchServiceStatus(loaded);
|
|
216
|
+
}
|
|
217
|
+
if (args.flags.get('json')) console.log(JSON.stringify(result, null, 2));
|
|
218
|
+
else if (action === 'install') log(`跨会话 watch-service 已安装 label=${result.label} interval=${result.interval_seconds}s`);
|
|
219
|
+
else if (action === 'uninstall') log(`跨会话 watch-service ${result.removed ? '已卸载' : '原本未安装'}`);
|
|
220
|
+
else log(`watch-service supported=${result.supported} installed=${result.installed} loaded=${result.loaded}${result.reason ? ` reason=${result.reason}` : ''}`);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
return { cmdWatchService, watchServiceStatus };
|
|
224
|
+
}
|
|
@@ -67,6 +67,10 @@ manager 的 trace/common-dir 状态只应由受信 operator/controller 与 runti
|
|
|
67
67
|
| 批量集成 | `plan-batch` → `batch-integrate` → `batch-step` → `batch-result` | 同仓同 target:≥3 默认聚合,2 个仅在碰撞时;只合成冻结 SHA,不代跑门禁;合同/顺序/target 变化即重规划;读[批量集成](../docs/worktree/batch-integration.md) |
|
|
68
68
|
| 回收 | `reclaim` / `archive` | 必须有远端/其他 ref 可达证明或明确 archive evidence;dirty、stash、submodule、权限或 branch-tip 异常一律 `KEEP`;替代树默认先归档,`--discard` 需人工明确授权;读[回收与看护](../docs/worktree/reclaim-and-watch.md) |
|
|
69
69
|
|
|
70
|
+
`resume-all` 只提供当次恢复。需要跨 Agent 会话、注销或重启后仍自动恢复 watcher 时,先检查
|
|
71
|
+
`agentkit worktree watch-service status`;macOS 可由用户一次性显式执行 `watch-service install`。
|
|
72
|
+
未安装常驻维护器时不得把进程级 watcher 描述成跨会话保证。
|
|
73
|
+
|
|
70
74
|
不得按目录年龄、名称或“看起来干净”删除。禁止 `rm -rf`、`git worktree remove --force`、`branch -D`
|
|
71
75
|
和 feature owner 手工 rebase。watcher 的 `merge-tree` 结果只是目标前进后的只读成本预判,不保证逐
|
|
72
76
|
commit rebase 成功;`batch-result` 只冻结 controller 已完成的验收结论,不替代验证。
|
package/package.json
CHANGED