@tea-agent/loop-agent 0.28.12-beta.1 → 0.28.13
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 +46 -1
- package/README.md +3 -1
- package/dist/application/task-lifecycle/advance.js +21 -4
- package/dist/application/task-lifecycle/plan-transitions.js +17 -5
- package/dist/commands/import-prd.js +1 -1
- package/dist/commands/init.js +2 -2
- package/dist/shared/operator/capabilities.js +21 -0
- package/dist/worker/console/operator-actions.js +11 -0
- package/dist/worker/scheduler/cli.js +110 -5
- package/dist/worker/scheduler/clock-install/darwin-launchd.js +238 -0
- package/dist/worker/scheduler/clock-install/linux-systemd-user.js +224 -0
- package/dist/worker/scheduler/clock-install/types.js +1 -0
- package/dist/worker/scheduler/clock-install/win32-schtasks.js +172 -0
- package/dist/worker/scheduler/clock-install.js +284 -0
- package/dist/worker/scheduler/clock.js +420 -0
- package/dist/worker/scheduler/doctor.js +86 -1
- package/dist/worker/scheduler/index.js +2 -0
- package/dist/worker/scheduler/morning-window.js +31 -1
- package/dist/worker/scheduler/paths.js +8 -0
- package/docs/templates/init-managed-agents.md +3 -1
- package/package.json +1 -1
- package/skills/loop-agent/SKILL.md +3 -1
- package/skills/loop-agent/references/command-reference.md +9 -4
- package/skills/loop-agent/references/harness-policy.md +3 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,51 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [0.28.13] - 2026-08-06
|
|
6
|
+
|
|
7
|
+
### 重点更新
|
|
8
|
+
|
|
9
|
+
- 显著缩短 CI 与发布流程的测试耗时,优化 2 vCPU runner 上的并发策略
|
|
10
|
+
- 修复集成测试在多核环境下因工作区指纹竞态导致的不稳定问题
|
|
11
|
+
|
|
12
|
+
### 改进
|
|
13
|
+
|
|
14
|
+
- 优化 CI/发布环境的测试执行效率:固定 fast 测试池为 2 个 worker,移除重复的 pre-prepare 类型检查,并将 npm dist-tag 收敛轮询改为前快后慢的退避策略
|
|
15
|
+
- 默认 fast 测试池现按可用 CPU 核数(上限 4)分配,避免在 2 核环境下意外退化为串行执行
|
|
16
|
+
|
|
17
|
+
### 修复
|
|
18
|
+
|
|
19
|
+
- 修复集成测试在多 worker 下因读取工作区指纹竞态导致的包校验不稳定问题,现固定集成测试为串行执行
|
|
20
|
+
- 修复 pre-push 验证收据漂移测试受 CI 环境固定 worker 数量影响的问题,确保使用不同值以正确验证指纹失效场景
|
|
21
|
+
|
|
22
|
+
## [0.28.12] - 2026-08-06
|
|
23
|
+
|
|
24
|
+
### 重点更新
|
|
25
|
+
|
|
26
|
+
- Night Scheduler 交付 Clock Guarantee,支持通过 macOS、Linux 和 Windows 原生系统定时器自动周期触发调度
|
|
27
|
+
- 加固前端 DAG 验证的回退机制与 JSON 解析,提升复杂环境下的容错能力
|
|
28
|
+
- 修复 pre-push 验证在本地缺失远程提交记录时异常中断的问题,保障推送流程顺畅
|
|
29
|
+
|
|
30
|
+
### 新增
|
|
31
|
+
|
|
32
|
+
- 新增 `scheduler clock install|status|uninstall` 命令,支持安装用户级别的 launchd、systemd 和 schtasks 定时器
|
|
33
|
+
- 统一 Clock receipt 记录机制,使手动触发与系统定时触发共享运行状态
|
|
34
|
+
- 新增 Clock 健康状态分类器,并接入 doctor、morning 和 scheduler add 的状态反馈
|
|
35
|
+
|
|
36
|
+
### 改进
|
|
37
|
+
|
|
38
|
+
- 改进 `--verify` 相关文档示例,不再硬编码特定命令,改用占位符并指向项目配置文件以避免误导
|
|
39
|
+
- 规范化初始化升级测试套件的代码格式,使其符合项目的统一排版规范
|
|
40
|
+
|
|
41
|
+
### 修复
|
|
42
|
+
|
|
43
|
+
- 修复 pre-push 验证在本地无法解析远程最新提交 SHA 时直接报错中断的问题,现会安全回退到完整验证流程
|
|
44
|
+
- 修复 CI 环境下因系统定时器精度不足导致的重试退避时间断言不稳定问题
|
|
45
|
+
- 修复前端 DAG 验证 JSON 解析及覆盖率证据推导的问题,增强验证回退机制的健壮性
|
|
46
|
+
- 修复任务草稿复用时因缺少 `--dag-output` 参数导致无法正确导出 DAG 的问题
|
|
47
|
+
- 修复空事实情况下误触发契约准备的问题,现会安全软停止并提示需要输入
|
|
48
|
+
- 修复 macOS 下因路径别名导致初始化升级测试中写入保护用例失败的问题
|
|
49
|
+
|
|
5
50
|
## [0.28.11] - 2026-08-06
|
|
6
51
|
|
|
7
52
|
### 重点更新
|
|
@@ -98,7 +143,7 @@
|
|
|
98
143
|
|
|
99
144
|
### 修复
|
|
100
145
|
|
|
101
|
-
- Night Scheduler 准入控制:freezeCleanBase 忽略 .harness
|
|
146
|
+
- Night Scheduler 准入控制:freezeCleanBase 忽略 .harness/**与 .worktrees/** 运行态事实,避免 lifecycle 运行事实自污染导致 clean-tree 误拒,同时保留失败 admission 的 submitted → rejected 审计链
|
|
102
147
|
- 调整 freezeCleanBase 的执行顺序,将其提前至 submitSchedule 之前,确保 baseCommit 能在调度器写入控制文件前正确捕获 HEAD
|
|
103
148
|
- freezeCleanBase 将 .harness 与 .worktrees 视为非源码脏污(与 harvest 逻辑共享),避免准入流程自写入的调度事实干扰基线冻结
|
|
104
149
|
|
package/README.md
CHANGED
|
@@ -70,7 +70,7 @@ loop-agent task advance example-task "实现一个有明确验收标准的小功
|
|
|
70
70
|
--prd path/to/prd.md \
|
|
71
71
|
--allowed-path "src/**" \
|
|
72
72
|
--forbidden-path ".harness/**" \
|
|
73
|
-
--verify "
|
|
73
|
+
--verify "<label>:<command>" \
|
|
74
74
|
--json
|
|
75
75
|
|
|
76
76
|
# 审查返回的 writeSet / gate.digest 后,同一命令批准并长跑到稳定终态
|
|
@@ -82,6 +82,8 @@ loop-agent task advance example-task \
|
|
|
82
82
|
loop-agent task status example-task --json
|
|
83
83
|
```
|
|
84
84
|
|
|
85
|
+
`--verify` 命令应取项目 `AGENTS.md` / `docs/governance/verification-matrix.md` 登记的验证命令(不要假定 `npm run typecheck` 存在);`--verify` 可选,省略时自动从 package.json scripts 或既有 managed `task.json.verifyCommands` 推导建议。
|
|
86
|
+
|
|
85
87
|
首次 `task advance` 会 create/import PRD/派生 managed contract/生成并 strict validate DAG,然后停在 writeSet gate;未批准不会启动业务 writer。详细步骤见[第一次运行](website/docs/quick-start/first-run.md)和 [Agent DAG 工作流](website/docs/guides/agent-dag.md)。
|
|
86
88
|
|
|
87
89
|
## 查看执行状态
|
|
@@ -50,6 +50,24 @@ function uniqueTransitions(existing, extra) {
|
|
|
50
50
|
set.add(item);
|
|
51
51
|
return [...set];
|
|
52
52
|
}
|
|
53
|
+
/**
|
|
54
|
+
* Materialize `--dag-output` from the task DAG draft (or an explicit source).
|
|
55
|
+
* Must run even when generate-dag is skipped because a draft already exists —
|
|
56
|
+
* Console prepareConfirmDag relies on this side effect.
|
|
57
|
+
*/
|
|
58
|
+
async function exportDagDraftIfRequested(repoRoot, taskId, dagOutputPath, sourcePath) {
|
|
59
|
+
if (!dagOutputPath?.trim())
|
|
60
|
+
return;
|
|
61
|
+
const from = sourcePath ?? getTaskPaths(repoRoot, taskId).dagDraftPath;
|
|
62
|
+
try {
|
|
63
|
+
await access(from);
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
await mkdir(path.dirname(dagOutputPath), { recursive: true });
|
|
69
|
+
await copyFile(from, dagOutputPath);
|
|
70
|
+
}
|
|
53
71
|
function extractMeaningfulProgressAt(state) {
|
|
54
72
|
const activeNode = Object.values(state.nodes ?? {}).find((node) => node.status === "RUNNING");
|
|
55
73
|
if (activeNode) {
|
|
@@ -549,10 +567,7 @@ export async function advanceTaskLifecycle(input) {
|
|
|
549
567
|
});
|
|
550
568
|
break;
|
|
551
569
|
}
|
|
552
|
-
|
|
553
|
-
await mkdir(path.dirname(input.dagOutputPath), { recursive: true });
|
|
554
|
-
await copyFile(generated.outputPath, input.dagOutputPath);
|
|
555
|
-
}
|
|
570
|
+
await exportDagDraftIfRequested(input.repoRoot, input.taskId, input.dagOutputPath, generated.outputPath);
|
|
556
571
|
}
|
|
557
572
|
catch (error) {
|
|
558
573
|
const message = error instanceof Error ? error.message : String(error);
|
|
@@ -633,6 +648,7 @@ export async function advanceTaskLifecycle(input) {
|
|
|
633
648
|
completed.push("write-set-gate-opened");
|
|
634
649
|
await saveLifecycleRecord(input.repoRoot, record);
|
|
635
650
|
snapshot = await observeTaskLifecycle(input.repoRoot, input.taskId);
|
|
651
|
+
await exportDagDraftIfRequested(input.repoRoot, input.taskId, input.dagOutputPath);
|
|
636
652
|
// Stop before writers.
|
|
637
653
|
return {
|
|
638
654
|
taskId: input.taskId,
|
|
@@ -852,6 +868,7 @@ export async function advanceTaskLifecycle(input) {
|
|
|
852
868
|
? (snapshot.gate ??
|
|
853
869
|
(await buildCurrentGate(input.repoRoot, input.taskId)))
|
|
854
870
|
: snapshot.gate;
|
|
871
|
+
await exportDagDraftIfRequested(input.repoRoot, input.taskId, input.dagOutputPath);
|
|
855
872
|
return {
|
|
856
873
|
taskId: input.taskId,
|
|
857
874
|
lifecycleState: snapshot.lifecycleState,
|
|
@@ -87,8 +87,7 @@ export function planTransitions(input) {
|
|
|
87
87
|
}
|
|
88
88
|
if (shouldImportPrd ||
|
|
89
89
|
input.hasEngineeringBoundary ||
|
|
90
|
-
alreadyImportedPrd
|
|
91
|
-
snapshot.exists) {
|
|
90
|
+
alreadyImportedPrd) {
|
|
92
91
|
if (shouldImportPrd) {
|
|
93
92
|
actions.push({ type: "import-prd" });
|
|
94
93
|
expectedTransitions.push("prd-imported");
|
|
@@ -97,11 +96,24 @@ export function planTransitions(input) {
|
|
|
97
96
|
expectedTransitions.push("contract-managed");
|
|
98
97
|
}
|
|
99
98
|
else {
|
|
99
|
+
// Task exists (e.g. just created in this advance loop) but intake is
|
|
100
|
+
// still incomplete. Soft-stop with INTAKE_REQUIRED so create-only /
|
|
101
|
+
// Console bootstrap does not attempt prepare-contract on empty facts.
|
|
102
|
+
actions.push({ type: "stop", reason: "blocker" });
|
|
100
103
|
blockers.push({
|
|
101
|
-
code: "
|
|
102
|
-
message: "
|
|
104
|
+
code: "INTAKE_REQUIRED",
|
|
105
|
+
message: "task created or creatable; provide --prd and engineering boundary flags (or --from-text) to continue",
|
|
103
106
|
});
|
|
104
|
-
return
|
|
107
|
+
return {
|
|
108
|
+
actions,
|
|
109
|
+
gate: null,
|
|
110
|
+
blockers,
|
|
111
|
+
next: {
|
|
112
|
+
kind: "advance",
|
|
113
|
+
command: `loop-agent task advance ${snapshot.taskId} --prd <path> --allowed-path <glob> --json`,
|
|
114
|
+
},
|
|
115
|
+
expectedTransitions,
|
|
116
|
+
};
|
|
105
117
|
}
|
|
106
118
|
}
|
|
107
119
|
const dagExists = snapshot.dagDraft?.exists === true;
|
|
@@ -63,7 +63,7 @@ export async function runImportPrd(repoRoot, args) {
|
|
|
63
63
|
referenceDocs: result.referenceDocs,
|
|
64
64
|
message: "Imported PRD as an immutable source reference. Next: task source prepare --use-imported-prd (plus engineering --allowed-path/--verify) then --apply. Do not rewrite source/references/*.",
|
|
65
65
|
next: [
|
|
66
|
-
`loop-agent task source prepare ${result.taskId} --use-imported-prd --allowed-path <glob> --verify "
|
|
66
|
+
`loop-agent task source prepare ${result.taskId} --use-imported-prd --allowed-path <glob> --verify "<label>:<command>" --json`,
|
|
67
67
|
`loop-agent task source prepare ${result.taskId} --use-imported-prd --allowed-path <glob> --apply --json`,
|
|
68
68
|
],
|
|
69
69
|
}, null, 2));
|
package/dist/commands/init.js
CHANGED
|
@@ -1954,7 +1954,7 @@ function buildManagedReadmeBlock(input) {
|
|
|
1954
1954
|
'loop-agent task advance <task-id> "任务标题" \\',
|
|
1955
1955
|
" --prd <prd.md> \\",
|
|
1956
1956
|
' --allowed-path "<glob>" \\',
|
|
1957
|
-
' --verify "
|
|
1957
|
+
' --verify "<label>:<command>" \\',
|
|
1958
1958
|
" --json",
|
|
1959
1959
|
"# 审查 writeSet gate digest 后:",
|
|
1960
1960
|
'loop-agent task advance <task-id> --approve-gate "write-set-review:<digest>" --json',
|
|
@@ -2188,7 +2188,7 @@ function buildTargetFeatureWorkflow(input) {
|
|
|
2188
2188
|
'loop-agent task advance <task-id> "Task title" \\',
|
|
2189
2189
|
" --prd <prd.md> \\",
|
|
2190
2190
|
' --allowed-path "<glob>" \\',
|
|
2191
|
-
' --verify "
|
|
2191
|
+
' --verify "<label>:<command>" \\',
|
|
2192
2192
|
" --json",
|
|
2193
2193
|
"# Review writeSet gate digest, then:",
|
|
2194
2194
|
'loop-agent task advance <task-id> --approve-gate "write-set-review:<digest>" --json',
|
|
@@ -2175,6 +2175,27 @@ export const OPERATOR_COMMAND_COVERAGE = Object.freeze([
|
|
|
2175
2175
|
source: "agent-worker",
|
|
2176
2176
|
exclusionReason: "Daemon/clock path; not exposed to Operator Chat (use CLI/cron).",
|
|
2177
2177
|
},
|
|
2178
|
+
{
|
|
2179
|
+
command: "agent-worker scheduler clock install",
|
|
2180
|
+
coverage: "excluded",
|
|
2181
|
+
action: null,
|
|
2182
|
+
source: "agent-worker",
|
|
2183
|
+
exclusionReason: "OS timer install is CLI-only; Operator Console does not manage launchd/systemd/schtasks.",
|
|
2184
|
+
},
|
|
2185
|
+
{
|
|
2186
|
+
command: "agent-worker scheduler clock status",
|
|
2187
|
+
coverage: "excluded",
|
|
2188
|
+
action: null,
|
|
2189
|
+
source: "agent-worker",
|
|
2190
|
+
exclusionReason: "Clock status is CLI-only in MVP; use scheduler doctor for Console-facing health.",
|
|
2191
|
+
},
|
|
2192
|
+
{
|
|
2193
|
+
command: "agent-worker scheduler clock uninstall",
|
|
2194
|
+
coverage: "excluded",
|
|
2195
|
+
action: null,
|
|
2196
|
+
source: "agent-worker",
|
|
2197
|
+
exclusionReason: "OS timer uninstall is CLI-only; not exposed to Operator Chat.",
|
|
2198
|
+
},
|
|
2178
2199
|
{
|
|
2179
2200
|
command: "agent-worker scheduler submit",
|
|
2180
2201
|
coverage: "excluded",
|
|
@@ -689,6 +689,17 @@ export async function dispatchOperatorAction(ctx, req) {
|
|
|
689
689
|
};
|
|
690
690
|
}
|
|
691
691
|
dagText = await readFile(staged.path, "utf8");
|
|
692
|
+
if (!dagText.trim()) {
|
|
693
|
+
// Fallback: advance may skip generate-dag when a draft already
|
|
694
|
+
// exists; still prefer the task DAG over an empty staged file.
|
|
695
|
+
const taskDagPath = getTaskPaths(ctx.repoRoot, taskId).dagDraftPath;
|
|
696
|
+
try {
|
|
697
|
+
dagText = await readFile(taskDagPath, "utf8");
|
|
698
|
+
}
|
|
699
|
+
catch {
|
|
700
|
+
dagText = "";
|
|
701
|
+
}
|
|
702
|
+
}
|
|
692
703
|
if (!dagText.trim()) {
|
|
693
704
|
return {
|
|
694
705
|
kind: "error",
|
|
@@ -6,6 +6,8 @@ import { diagnoseScheduler, formatSchedulerDoctorHuman } from "./doctor.js";
|
|
|
6
6
|
import { readAdmission, readLedgerEvents, recoverAllPendingJournals, } from "./store.js";
|
|
7
7
|
import { discardNightSchedule, harvestNightSchedule } from "./night-harvest.js";
|
|
8
8
|
import { schedulerTick } from "./scheduler-loop.js";
|
|
9
|
+
import { formatClockHealthHuman, runRecordedSchedulerTick, } from "./clock.js";
|
|
10
|
+
import { detectClockSourceFromEnv, installClockTimer, statusClockTimer, uninstallClockTimer, } from "./clock-install.js";
|
|
9
11
|
import { scheduleStatusSchema, SchedulerError, } from "./types.js";
|
|
10
12
|
import { parseOnceLocalToUtc } from "./trigger.js";
|
|
11
13
|
/**
|
|
@@ -80,12 +82,43 @@ export function registerSchedulerCommands(program) {
|
|
|
80
82
|
.option("--json", "Emit JSON")
|
|
81
83
|
.action(async (scheduleId, options) => {
|
|
82
84
|
try {
|
|
85
|
+
const controlRepoRoot = path.resolve(options.repo);
|
|
83
86
|
const result = await addSchedule({
|
|
84
|
-
controlRepoRoot
|
|
87
|
+
controlRepoRoot,
|
|
85
88
|
scheduleId,
|
|
86
89
|
approveGate: options.approveGate,
|
|
87
90
|
});
|
|
88
|
-
|
|
91
|
+
// Soft clock hint only — never hard-gate schedule creation.
|
|
92
|
+
let clockHint = null;
|
|
93
|
+
try {
|
|
94
|
+
const clock = await statusClockTimer({ controlRepoRoot });
|
|
95
|
+
clockHint = {
|
|
96
|
+
status: clock.health.status,
|
|
97
|
+
nextAction: clock.health.nextAction,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
clockHint = null;
|
|
102
|
+
}
|
|
103
|
+
const payload = { ...result, clock: clockHint };
|
|
104
|
+
emit(options.json, payload, () => {
|
|
105
|
+
const base = formatScheduleHuman(result.schedule);
|
|
106
|
+
if (!clockHint || clockHint.status === "healthy")
|
|
107
|
+
return base;
|
|
108
|
+
const lines = [
|
|
109
|
+
base.trimEnd(),
|
|
110
|
+
`Clock: ${clockHint.status}`,
|
|
111
|
+
"Automatic invocation is not currently guaranteed.",
|
|
112
|
+
];
|
|
113
|
+
if (clockHint.nextAction) {
|
|
114
|
+
lines.push(`Next: ${clockHint.nextAction}`);
|
|
115
|
+
}
|
|
116
|
+
else {
|
|
117
|
+
lines.push("Next: agent-worker scheduler clock install --repo .");
|
|
118
|
+
}
|
|
119
|
+
lines.push("");
|
|
120
|
+
return `${lines.join("\n")}\n`;
|
|
121
|
+
});
|
|
89
122
|
}
|
|
90
123
|
catch (error) {
|
|
91
124
|
handleSchedulerCliError(error, options.json === true);
|
|
@@ -251,6 +284,7 @@ export function registerSchedulerCommands(program) {
|
|
|
251
284
|
.requiredOption("--repo <repo-root>", "Control repo root")
|
|
252
285
|
.option("--loop-agent-bin <path>", "Published loop-agent binary")
|
|
253
286
|
.option("--max-claims <n>", "Max schedules to claim this tick", "1")
|
|
287
|
+
.option("--clock-source <source>", "Clock receipt source (manual|launchd|systemd-user|schtasks|clock-run)")
|
|
254
288
|
.option("--json", "Emit JSON")
|
|
255
289
|
.action(async (options) => {
|
|
256
290
|
try {
|
|
@@ -261,10 +295,15 @@ export function registerSchedulerCommands(program) {
|
|
|
261
295
|
resolveIdentity: true,
|
|
262
296
|
});
|
|
263
297
|
const maxClaims = Number(options.maxClaims ?? "1");
|
|
264
|
-
const
|
|
298
|
+
const source = detectClockSourceFromEnv(options.clockSource);
|
|
299
|
+
const result = await runRecordedSchedulerTick({
|
|
265
300
|
controlRepoRoot,
|
|
266
|
-
|
|
267
|
-
|
|
301
|
+
source,
|
|
302
|
+
tick: () => schedulerTick({
|
|
303
|
+
controlRepoRoot,
|
|
304
|
+
client,
|
|
305
|
+
maxClaims: Number.isFinite(maxClaims) ? maxClaims : 1,
|
|
306
|
+
}),
|
|
268
307
|
});
|
|
269
308
|
if (options.json) {
|
|
270
309
|
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
@@ -276,6 +315,72 @@ export function registerSchedulerCommands(program) {
|
|
|
276
315
|
handleSchedulerCliError(error, options.json === true);
|
|
277
316
|
}
|
|
278
317
|
});
|
|
318
|
+
const clock = scheduler
|
|
319
|
+
.command("clock")
|
|
320
|
+
.description("OS timer install / status / uninstall for automatic scheduler tick");
|
|
321
|
+
clock
|
|
322
|
+
.command("install")
|
|
323
|
+
.description("Install or idempotently update user-level OS timer that runs scheduler tick")
|
|
324
|
+
.requiredOption("--repo <repo-root>", "Control repo root")
|
|
325
|
+
.option("--interval-sec <n>", "Tick interval in seconds (default 60)", "60")
|
|
326
|
+
.option("--loop-agent-bin <path>", "Published loop-agent binary")
|
|
327
|
+
.option("--json", "Emit JSON")
|
|
328
|
+
.action(async (options) => {
|
|
329
|
+
try {
|
|
330
|
+
const intervalSec = Number(options.intervalSec ?? "60");
|
|
331
|
+
const result = await installClockTimer({
|
|
332
|
+
controlRepoRoot: path.resolve(options.repo),
|
|
333
|
+
intervalSec: Number.isFinite(intervalSec) ? intervalSec : 60,
|
|
334
|
+
...(options.loopAgentBin
|
|
335
|
+
? { loopAgentBin: options.loopAgentBin }
|
|
336
|
+
: {}),
|
|
337
|
+
});
|
|
338
|
+
emit(options.json, result, () => [
|
|
339
|
+
result.message,
|
|
340
|
+
`Provider: ${result.provider}`,
|
|
341
|
+
`Interval: ${result.receipt.intervalSec}s`,
|
|
342
|
+
`Repo: ${result.receipt.controlRepoRoot}`,
|
|
343
|
+
`Unit: ${result.receipt.unitName}`,
|
|
344
|
+
"Next: agent-worker scheduler clock status --repo .",
|
|
345
|
+
"",
|
|
346
|
+
].join("\n"));
|
|
347
|
+
}
|
|
348
|
+
catch (error) {
|
|
349
|
+
handleSchedulerCliError(error, options.json === true);
|
|
350
|
+
}
|
|
351
|
+
});
|
|
352
|
+
clock
|
|
353
|
+
.command("status")
|
|
354
|
+
.description("Show OS timer + install receipt + tick health (read-only)")
|
|
355
|
+
.requiredOption("--repo <repo-root>", "Control repo root")
|
|
356
|
+
.option("--json", "Emit JSON")
|
|
357
|
+
.action(async (options) => {
|
|
358
|
+
try {
|
|
359
|
+
const result = await statusClockTimer({
|
|
360
|
+
controlRepoRoot: path.resolve(options.repo),
|
|
361
|
+
});
|
|
362
|
+
emit(options.json, result, () => formatClockHealthHuman(result.health));
|
|
363
|
+
}
|
|
364
|
+
catch (error) {
|
|
365
|
+
handleSchedulerCliError(error, options.json === true);
|
|
366
|
+
}
|
|
367
|
+
});
|
|
368
|
+
clock
|
|
369
|
+
.command("uninstall")
|
|
370
|
+
.description("Remove ownership-matched managed OS timer (keeps schedule/receipt history)")
|
|
371
|
+
.requiredOption("--repo <repo-root>", "Control repo root")
|
|
372
|
+
.option("--json", "Emit JSON")
|
|
373
|
+
.action(async (options) => {
|
|
374
|
+
try {
|
|
375
|
+
const result = await uninstallClockTimer({
|
|
376
|
+
controlRepoRoot: path.resolve(options.repo),
|
|
377
|
+
});
|
|
378
|
+
emit(options.json, result, () => `${result.message}\n`);
|
|
379
|
+
}
|
|
380
|
+
catch (error) {
|
|
381
|
+
handleSchedulerCliError(error, options.json === true);
|
|
382
|
+
}
|
|
383
|
+
});
|
|
279
384
|
scheduler
|
|
280
385
|
.command("harvest")
|
|
281
386
|
.description("Exact-base fast-forward night branch into base (succeeded + pending-harvest only)")
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { access, mkdir, readFile, unlink, writeFile } from "node:fs/promises";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { deriveClockUnitName, hashManagedConfig, } from "../clock.js";
|
|
6
|
+
import { MANAGED_MARKER } from "./types.js";
|
|
7
|
+
function launchAgentsDir() {
|
|
8
|
+
return path.join(os.homedir(), "Library", "LaunchAgents");
|
|
9
|
+
}
|
|
10
|
+
function plistPath(unitName) {
|
|
11
|
+
return path.join(launchAgentsDir(), `${unitName}.plist`);
|
|
12
|
+
}
|
|
13
|
+
export function renderLaunchdPlist(input) {
|
|
14
|
+
const args = [
|
|
15
|
+
input.nodeBin,
|
|
16
|
+
input.agentWorkerEntry,
|
|
17
|
+
"scheduler",
|
|
18
|
+
"tick",
|
|
19
|
+
"--repo",
|
|
20
|
+
input.controlRepoRoot,
|
|
21
|
+
"--loop-agent-bin",
|
|
22
|
+
input.loopAgentBin,
|
|
23
|
+
"--clock-source",
|
|
24
|
+
"launchd",
|
|
25
|
+
"--json",
|
|
26
|
+
];
|
|
27
|
+
const programArgs = args
|
|
28
|
+
.map((arg) => `\t\t<string>${escapeXml(arg)}</string>`)
|
|
29
|
+
.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
|
+
\t<key>Label</key>
|
|
35
|
+
\t<string>${escapeXml(input.unitName)}</string>
|
|
36
|
+
\t<key>Comment</key>
|
|
37
|
+
\t<string>${MANAGED_MARKER} ${escapeXml(input.managedConfigHash)}</string>
|
|
38
|
+
\t<key>ProgramArguments</key>
|
|
39
|
+
\t<array>
|
|
40
|
+
${programArgs}
|
|
41
|
+
\t</array>
|
|
42
|
+
\t<key>StartInterval</key>
|
|
43
|
+
\t<integer>${input.intervalSec}</integer>
|
|
44
|
+
\t<key>RunAtLoad</key>
|
|
45
|
+
\t<true/>
|
|
46
|
+
\t<key>WorkingDirectory</key>
|
|
47
|
+
\t<string>${escapeXml(input.controlRepoRoot)}</string>
|
|
48
|
+
\t<key>StandardOutPath</key>
|
|
49
|
+
\t<string>${escapeXml(path.join(input.controlRepoRoot, ".harness/task-pool/scheduler/clock-launchd.out.log"))}</string>
|
|
50
|
+
\t<key>StandardErrorPath</key>
|
|
51
|
+
\t<string>${escapeXml(path.join(input.controlRepoRoot, ".harness/task-pool/scheduler/clock-launchd.err.log"))}</string>
|
|
52
|
+
\t<key>ProcessType</key>
|
|
53
|
+
\t<string>Background</string>
|
|
54
|
+
</dict>
|
|
55
|
+
</plist>
|
|
56
|
+
`;
|
|
57
|
+
}
|
|
58
|
+
function escapeXml(value) {
|
|
59
|
+
return value
|
|
60
|
+
.replace(/&/g, "&")
|
|
61
|
+
.replace(/</g, "<")
|
|
62
|
+
.replace(/>/g, ">")
|
|
63
|
+
.replace(/"/g, """)
|
|
64
|
+
.replace(/'/g, "'");
|
|
65
|
+
}
|
|
66
|
+
async function runLaunchctl(args) {
|
|
67
|
+
return new Promise((resolve) => {
|
|
68
|
+
const child = spawn("launchctl", args, {
|
|
69
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
70
|
+
});
|
|
71
|
+
let stdout = "";
|
|
72
|
+
let stderr = "";
|
|
73
|
+
child.stdout.on("data", (chunk) => {
|
|
74
|
+
stdout += String(chunk);
|
|
75
|
+
});
|
|
76
|
+
child.stderr.on("data", (chunk) => {
|
|
77
|
+
stderr += String(chunk);
|
|
78
|
+
});
|
|
79
|
+
child.on("close", (code) => {
|
|
80
|
+
resolve({ code: code ?? 1, stdout, stderr });
|
|
81
|
+
});
|
|
82
|
+
child.on("error", (error) => {
|
|
83
|
+
resolve({
|
|
84
|
+
code: 1,
|
|
85
|
+
stdout,
|
|
86
|
+
stderr: error instanceof Error ? error.message : String(error),
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
export const darwinLaunchdAdapter = {
|
|
92
|
+
provider: "launchd",
|
|
93
|
+
async isAvailable() {
|
|
94
|
+
if (process.platform !== "darwin")
|
|
95
|
+
return false;
|
|
96
|
+
try {
|
|
97
|
+
await access("/bin/launchctl");
|
|
98
|
+
return true;
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
},
|
|
104
|
+
unitName(controlRepoRoot) {
|
|
105
|
+
return deriveClockUnitName(controlRepoRoot);
|
|
106
|
+
},
|
|
107
|
+
renderConfig(input) {
|
|
108
|
+
return renderLaunchdPlist({
|
|
109
|
+
unitName: input.receipt.unitName,
|
|
110
|
+
intervalSec: input.receipt.intervalSec,
|
|
111
|
+
nodeBin: input.entrypoints.nodeBin,
|
|
112
|
+
agentWorkerEntry: input.entrypoints.agentWorkerEntry,
|
|
113
|
+
controlRepoRoot: input.entrypoints.controlRepoRoot,
|
|
114
|
+
loopAgentBin: input.entrypoints.loopAgentBin,
|
|
115
|
+
managedConfigHash: input.receipt.managedConfigHash,
|
|
116
|
+
});
|
|
117
|
+
},
|
|
118
|
+
async install(input) {
|
|
119
|
+
const file = plistPath(input.receipt.unitName);
|
|
120
|
+
await mkdir(launchAgentsDir(), { recursive: true });
|
|
121
|
+
// Fail-closed if an unmanaged unit with same label exists.
|
|
122
|
+
try {
|
|
123
|
+
const existing = await readFile(file, "utf-8");
|
|
124
|
+
if (!existing.includes(MANAGED_MARKER) &&
|
|
125
|
+
!existing.includes("tea-agent.loop-agent.night")) {
|
|
126
|
+
throw new Error(`Refusing to overwrite unmanaged LaunchAgent at ${file}`);
|
|
127
|
+
}
|
|
128
|
+
// bootout before rewrite when loaded
|
|
129
|
+
await runLaunchctl([
|
|
130
|
+
"bootout",
|
|
131
|
+
`gui/${process.getuid?.() ?? 501}/${input.receipt.unitName}`,
|
|
132
|
+
]);
|
|
133
|
+
}
|
|
134
|
+
catch (error) {
|
|
135
|
+
if (!(error &&
|
|
136
|
+
typeof error === "object" &&
|
|
137
|
+
"code" in error &&
|
|
138
|
+
error.code === "ENOENT")) {
|
|
139
|
+
// ignore bootout failures for unloaded units; rethrow ownership errors
|
|
140
|
+
if (error instanceof Error &&
|
|
141
|
+
error.message.includes("Refusing to overwrite")) {
|
|
142
|
+
throw error;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
const body = renderLaunchdPlist({
|
|
147
|
+
unitName: input.receipt.unitName,
|
|
148
|
+
intervalSec: input.receipt.intervalSec,
|
|
149
|
+
nodeBin: input.entrypoints.nodeBin,
|
|
150
|
+
agentWorkerEntry: input.entrypoints.agentWorkerEntry,
|
|
151
|
+
controlRepoRoot: input.entrypoints.controlRepoRoot,
|
|
152
|
+
loopAgentBin: input.entrypoints.loopAgentBin,
|
|
153
|
+
managedConfigHash: input.receipt.managedConfigHash,
|
|
154
|
+
});
|
|
155
|
+
await writeFile(file, body, "utf-8");
|
|
156
|
+
const uid = process.getuid?.() ?? 501;
|
|
157
|
+
const bootstrap = await runLaunchctl(["bootstrap", `gui/${uid}`, file]);
|
|
158
|
+
if (bootstrap.code !== 0) {
|
|
159
|
+
// try enable + kickstart as softer recovery
|
|
160
|
+
const enable = await runLaunchctl([
|
|
161
|
+
"enable",
|
|
162
|
+
`gui/${uid}/${input.receipt.unitName}`,
|
|
163
|
+
]);
|
|
164
|
+
const kick = await runLaunchctl([
|
|
165
|
+
"kickstart",
|
|
166
|
+
"-k",
|
|
167
|
+
`gui/${uid}/${input.receipt.unitName}`,
|
|
168
|
+
]);
|
|
169
|
+
if (enable.code !== 0 && kick.code !== 0 && bootstrap.code !== 0) {
|
|
170
|
+
throw new Error(`launchctl bootstrap failed: ${bootstrap.stderr || bootstrap.stdout || `exit ${bootstrap.code}`}`);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
},
|
|
174
|
+
async uninstall(input) {
|
|
175
|
+
const file = plistPath(input.receipt.unitName);
|
|
176
|
+
try {
|
|
177
|
+
const existing = await readFile(file, "utf-8");
|
|
178
|
+
if (!existing.includes(MANAGED_MARKER) &&
|
|
179
|
+
!existing.includes(input.receipt.managedConfigHash)) {
|
|
180
|
+
throw new Error(`Refusing to uninstall non-managed LaunchAgent at ${file}`);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
catch (error) {
|
|
184
|
+
if (error &&
|
|
185
|
+
typeof error === "object" &&
|
|
186
|
+
"code" in error &&
|
|
187
|
+
error.code === "ENOENT") {
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
throw error;
|
|
191
|
+
}
|
|
192
|
+
const uid = process.getuid?.() ?? 501;
|
|
193
|
+
await runLaunchctl(["bootout", `gui/${uid}/${input.receipt.unitName}`]);
|
|
194
|
+
await unlink(file).catch(() => { });
|
|
195
|
+
},
|
|
196
|
+
async probe(input) {
|
|
197
|
+
const unitName = input.receipt?.unitName ?? input.unitName ?? deriveClockUnitName(".");
|
|
198
|
+
const file = plistPath(unitName);
|
|
199
|
+
let present = false;
|
|
200
|
+
let body = "";
|
|
201
|
+
try {
|
|
202
|
+
body = await readFile(file, "utf-8");
|
|
203
|
+
present = true;
|
|
204
|
+
}
|
|
205
|
+
catch {
|
|
206
|
+
present = false;
|
|
207
|
+
}
|
|
208
|
+
const uid = process.getuid?.() ?? 501;
|
|
209
|
+
const print = await runLaunchctl(["print", `gui/${uid}/${unitName}`]);
|
|
210
|
+
const loaded = print.code === 0;
|
|
211
|
+
let configMatches = null;
|
|
212
|
+
if (present && input.receipt) {
|
|
213
|
+
configMatches =
|
|
214
|
+
body.includes(MANAGED_MARKER) &&
|
|
215
|
+
(body.includes(input.receipt.managedConfigHash) ||
|
|
216
|
+
body.includes(input.receipt.controlRepoRoot));
|
|
217
|
+
}
|
|
218
|
+
return {
|
|
219
|
+
present,
|
|
220
|
+
loaded,
|
|
221
|
+
configMatches,
|
|
222
|
+
detail: loaded
|
|
223
|
+
? "launchctl print ok"
|
|
224
|
+
: print.stderr.trim() || print.stdout.trim() || "not loaded",
|
|
225
|
+
};
|
|
226
|
+
},
|
|
227
|
+
};
|
|
228
|
+
export function expectedLaunchdHash(receipt) {
|
|
229
|
+
return hashManagedConfig({
|
|
230
|
+
provider: "launchd",
|
|
231
|
+
unitName: receipt.unitName,
|
|
232
|
+
controlRepoRoot: receipt.controlRepoRoot,
|
|
233
|
+
intervalSec: receipt.intervalSec,
|
|
234
|
+
nodeBin: receipt.nodeBin,
|
|
235
|
+
agentWorkerEntry: receipt.agentWorkerEntry,
|
|
236
|
+
loopAgentBin: receipt.loopAgentBin,
|
|
237
|
+
});
|
|
238
|
+
}
|