taskforce-loop-engineering 0.8.0 → 0.8.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/CHANGELOG.md +5 -0
- package/MIGRATING.md +1 -0
- package/README.md +11 -4
- package/package.json +1 -1
- package/scripts/openclaw-install-self-test.mjs +34 -5
- package/scripts/openclaw-install.mjs +45 -7
- package/scripts/openclaw-manage.mjs +24 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.8.1 - 2026-08-07
|
|
4
|
+
|
|
5
|
+
- Make the standard OpenClaw installer and upgrade path install and enable a managed per-queue systemd user scheduler, configure required scheduler heartbeats by default, and use an absolute packaged CLI path so systemd does not depend on an interactive shell `PATH`.
|
|
6
|
+
- Make uninstall disable and remove the managed scheduler units, while retaining queue runtime and refusing to overwrite locally modified managed units.
|
|
7
|
+
|
|
3
8
|
## 0.8.0 - 2026-08-07
|
|
4
9
|
|
|
5
10
|
- Add project-aware completion semantics: accepted milestones return project tasks to `inbox/` as `project_in_progress` until an explicit project terminal contract is accepted.
|
package/MIGRATING.md
CHANGED
|
@@ -49,6 +49,7 @@ Use `--confirm-install` only after resolving path conflicts. Existing queue runt
|
|
|
49
49
|
- Worker agent names are installation settings and are not fixed to Ironman.
|
|
50
50
|
- Every task uses an isolated `agent:<worker>:loop-task-<task-id>` session.
|
|
51
51
|
- Asynchronous delivery requires recorded source `channel` and `target`; missing routing metadata fails closed.
|
|
52
|
+
- A confirmed install or managed upgrade now creates and enables a per-queue systemd user scheduler. Queue configs require its heartbeat, so queued project work cannot silently wait forever. Uninstall disables and removes the managed units while retaining queue runtime.
|
|
52
53
|
- `repair-plan` is read-only. Version 0.6 does not add an automatic configuration repair command.
|
|
53
54
|
|
|
54
55
|
After installation or upgrade, validate the integration:
|
package/README.md
CHANGED
|
@@ -59,6 +59,11 @@ already loop-managed to prevent recursive re-enqueue. Existing generated files
|
|
|
59
59
|
are not overwritten unless `--force` is supplied after review. The installed
|
|
60
60
|
conversation policy treats `走 loop` as enqueue plus immediate execution;
|
|
61
61
|
`只入队` and `只排队` remain explicit queue-only overrides.
|
|
62
|
+
The confirmed installer also creates and enables a managed per-queue systemd
|
|
63
|
+
user timer. It wakes the adaptive scheduler once per minute; the persisted
|
|
64
|
+
scheduler cadence still decides whether work is due. Generated queue configs
|
|
65
|
+
require a fresh scheduler heartbeat, so queued work fails `doctor` with
|
|
66
|
+
`scheduler_missing` instead of waiting indefinitely when the timer is absent.
|
|
62
67
|
After every installed runner tick, the wrapper idempotently scans human-input
|
|
63
68
|
gates and terminal tasks. The generated notifier delivers through
|
|
64
69
|
`openclaw message send` using the task's recorded `channel`, `target`, `account`,
|
|
@@ -102,10 +107,12 @@ loop-engineering-openclaw-manage --root /path/to/workspace --action uninstall-pl
|
|
|
102
107
|
loop-engineering-openclaw-manage --root /path/to/workspace --action uninstall --confirm-uninstall
|
|
103
108
|
```
|
|
104
109
|
|
|
105
|
-
The installer manifest records SHA-256 hashes for generated files
|
|
106
|
-
managed `AGENTS.md` block. Upgrade/uninstall refuses when
|
|
107
|
-
|
|
108
|
-
|
|
110
|
+
The installer manifest records SHA-256 hashes for generated files, systemd
|
|
111
|
+
units, and the exact managed `AGENTS.md` block. Upgrade/uninstall refuses when
|
|
112
|
+
managed content was edited. Upgrade installs and enables the scheduler for
|
|
113
|
+
older managed integrations. Uninstall first disables the timer, then removes
|
|
114
|
+
only clean managed files, units, and that exact instructions block; queue
|
|
115
|
+
runtime is explicitly retained.
|
|
109
116
|
|
|
110
117
|
## Commands
|
|
111
118
|
|
package/package.json
CHANGED
|
@@ -7,6 +7,10 @@ import path from 'node:path';
|
|
|
7
7
|
const root = await mkdtemp(path.join(tmpdir(), 'loop-openclaw-install-'));
|
|
8
8
|
const deliveryCapture = path.join(root, 'delivery.json');
|
|
9
9
|
const mockOpenClaw = path.join(root, 'mock-openclaw.mjs');
|
|
10
|
+
const mockSystemctl = path.join(root, 'mock-systemctl.mjs');
|
|
11
|
+
const systemctlCapture = path.join(root, 'systemctl-calls.jsonl');
|
|
12
|
+
process.env.XDG_CONFIG_HOME = path.join(root, 'xdg');
|
|
13
|
+
process.env.SYSTEMCTL_CAPTURE = systemctlCapture;
|
|
10
14
|
await writeFile(mockOpenClaw, `#!/usr/bin/env node
|
|
11
15
|
import { mkdir, writeFile } from 'node:fs/promises';
|
|
12
16
|
import path from 'node:path';
|
|
@@ -24,6 +28,11 @@ else {
|
|
|
24
28
|
}
|
|
25
29
|
`);
|
|
26
30
|
await chmod(mockOpenClaw, 0o755);
|
|
31
|
+
await writeFile(mockSystemctl, `#!/usr/bin/env node
|
|
32
|
+
import { appendFile } from 'node:fs/promises';
|
|
33
|
+
if (process.env.SYSTEMCTL_CAPTURE) await appendFile(process.env.SYSTEMCTL_CAPTURE, JSON.stringify(process.argv.slice(2)) + '\\n');
|
|
34
|
+
`);
|
|
35
|
+
await chmod(mockSystemctl, 0o755);
|
|
27
36
|
const installer = new URL('./openclaw-install.mjs', import.meta.url).pathname;
|
|
28
37
|
function run(args) {
|
|
29
38
|
return new Promise((resolve) => {
|
|
@@ -34,21 +43,38 @@ function run(args) {
|
|
|
34
43
|
child.on('close', (code) => resolve({ code, stdout, stderr }));
|
|
35
44
|
});
|
|
36
45
|
}
|
|
37
|
-
const
|
|
46
|
+
const installBase = ['--root', root, '--queue', 'test-tasks', '--openclaw-bin', mockOpenClaw, '--systemctl-bin', mockSystemctl];
|
|
47
|
+
const plan = await run([...installBase, '--json']);
|
|
38
48
|
const planReport = JSON.parse(plan.stdout);
|
|
39
49
|
if (plan.code !== 0 || planReport.status !== 'plan_only' || planReport.workerAgent !== 'builder' || planReport.workerSelection !== 'only_available' || !planReport.workerValidated || planReport.createsWorkerAgent) throw new Error(`plan failed: ${plan.stderr}`);
|
|
40
|
-
const missingWorker = await run([
|
|
50
|
+
const missingWorker = await run([...installBase, '--worker-agent', 'missing', '--json']);
|
|
41
51
|
if (missingWorker.code === 0 || !missingWorker.stderr.includes('does not exist')) throw new Error('installer accepted a missing worker agent');
|
|
42
|
-
const install = await run([
|
|
52
|
+
const install = await run([...installBase, '--worker-agent', 'builder', '--confirm-install', '--json']);
|
|
43
53
|
if (install.code !== 0 || JSON.parse(install.stdout).status !== 'installed') throw new Error(`install failed: ${install.stderr}`);
|
|
44
54
|
const queue = JSON.parse(await readFile(path.join(root, 'configs/loops/queues/test-tasks.json'), 'utf8'));
|
|
45
55
|
if (queue.dispatcher !== 'node scripts/loops/openclaw-loop-dispatch.mjs') throw new Error('dispatcher was not installed');
|
|
56
|
+
if (queue.scheduler?.required !== true || queue.scheduler?.heartbeatMaxAgeMs !== 300000) throw new Error('required scheduler heartbeat was not installed');
|
|
46
57
|
const dispatcher = await readFile(path.join(root, 'scripts/loops/openclaw-loop-dispatch.mjs'), 'utf8');
|
|
47
58
|
if (!dispatcher.includes('already loop-managed') || !dispatcher.includes("'--agent', \"builder\"") || !dispatcher.includes('LOOP_LATEST_AMENDMENT_FILE')) throw new Error('worker, recursion guard, or amendment polling missing');
|
|
48
59
|
const instructions = await readFile(path.join(root, 'AGENTS.md'), 'utf8');
|
|
49
60
|
if (!instructions.includes('走 loop') || !instructions.includes('immediately execute')) throw new Error('conversation instructions missing');
|
|
50
61
|
const wrapper = await readFile(path.join(root, 'scripts/loops/openclaw-loop.mjs'), 'utf8');
|
|
51
|
-
if (!wrapper.includes('--supersede-active') || !wrapper.includes('--amend-active') || !wrapper.includes('--progress-notify-command') || !wrapper.includes('runWhenUnlocked') || wrapper.includes("run-queue-drain', '--config'") || !wrapper.includes('queue-human-input-notify') || !wrapper.includes('queue-terminal-notify') || !wrapper.includes('只入队')) throw new Error('supersede/amend routing, live progress, async notification, or queue-only routing missing');
|
|
62
|
+
if (!wrapper.includes('--supersede-active') || !wrapper.includes('--amend-active') || !wrapper.includes('--progress-notify-command') || !wrapper.includes('runWhenUnlocked') || wrapper.includes("run-queue-drain', '--config'") || wrapper.includes("spawn('loop-engineering'") || !wrapper.includes('queue-human-input-notify') || !wrapper.includes('queue-terminal-notify') || !wrapper.includes('queue-scheduler-tick') || !wrapper.includes('只入队')) throw new Error('supersede/amend routing, absolute CLI, scheduler, live progress, async notification, or queue-only routing missing');
|
|
63
|
+
const serviceFile = path.join(process.env.XDG_CONFIG_HOME, 'systemd/user/openclaw-loop-test-tasks-scheduler.service');
|
|
64
|
+
const timerFile = path.join(process.env.XDG_CONFIG_HOME, 'systemd/user/openclaw-loop-test-tasks-scheduler.timer');
|
|
65
|
+
const service = await readFile(serviceFile, 'utf8');
|
|
66
|
+
const timer = await readFile(timerFile, 'utf8');
|
|
67
|
+
if (!service.includes('scheduler-tick') || !timer.includes('OnUnitActiveSec=1min')) throw new Error('scheduler systemd units were not installed');
|
|
68
|
+
const installSystemctlCalls = await readFile(systemctlCapture, 'utf8');
|
|
69
|
+
if (!installSystemctlCalls.includes('["--user","enable","--now","openclaw-loop-test-tasks-scheduler.timer"]')) throw new Error('scheduler timer was not enabled');
|
|
70
|
+
const schedulerTick = await new Promise((resolve) => {
|
|
71
|
+
const child = spawn(process.execPath, [path.join(root, 'scripts/loops/openclaw-loop.mjs'), 'scheduler-tick', '--force-due', '--plan-only'], { cwd: root, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
72
|
+
let stdout = ''; let stderr = ''; child.stdout.on('data', (chunk) => { stdout += chunk; }); child.stderr.on('data', (chunk) => { stderr += chunk; });
|
|
73
|
+
child.on('close', (code) => resolve({ code, stdout, stderr }));
|
|
74
|
+
});
|
|
75
|
+
if (schedulerTick.code !== 0) throw new Error(`installed scheduler tick failed: ${schedulerTick.stderr || schedulerTick.stdout}`);
|
|
76
|
+
const schedulerState = JSON.parse(await readFile(path.join(root, 'runtime/loops/test-tasks/scheduler/state.json'), 'utf8'));
|
|
77
|
+
if (!schedulerState.generatedAt || !schedulerState.nextRunAt) throw new Error('installed scheduler tick did not persist its heartbeat and cadence');
|
|
52
78
|
const notifier = await readFile(path.join(root, 'scripts/loops/openclaw-loop-notify.mjs'), 'utf8');
|
|
53
79
|
if (!notifier.includes("'message', 'send'") || !notifier.includes('source.channel') || !notifier.includes('source.target')) throw new Error('channel-neutral notifier missing');
|
|
54
80
|
const delivery = await new Promise((resolve) => {
|
|
@@ -100,7 +126,7 @@ for (const generated of ['scripts/loops/openclaw-loop-dispatch.mjs', 'scripts/lo
|
|
|
100
126
|
});
|
|
101
127
|
if (check.code !== 0) throw new Error(`generated script syntax failed: ${generated}: ${check.stderr}`);
|
|
102
128
|
}
|
|
103
|
-
const conflict = await run([
|
|
129
|
+
const conflict = await run([...installBase, '--worker-agent', 'builder', '--confirm-install', '--json']);
|
|
104
130
|
if (conflict.code === 0) throw new Error('installer overwrote existing files without --force');
|
|
105
131
|
const manager = new URL('./openclaw-manage.mjs', import.meta.url).pathname;
|
|
106
132
|
async function manage(args) {
|
|
@@ -126,4 +152,7 @@ const uninstall = await manage(['--action', 'uninstall', '--confirm-uninstall'])
|
|
|
126
152
|
if (uninstall.code !== 0 || JSON.parse(uninstall.stdout).status !== 'uninstalled') throw new Error(`uninstall failed: ${uninstall.stderr}`);
|
|
127
153
|
if (!await readFile(path.join(root, 'runtime/loops/test-tasks/state.json'), 'utf8').catch(() => 'retained')) throw new Error('unexpected runtime cleanup result');
|
|
128
154
|
if (await readFile(path.join(root, 'scripts/loops/openclaw-loop.mjs'), 'utf8').then(() => true).catch(() => false)) throw new Error('managed wrapper survived uninstall');
|
|
155
|
+
if (await readFile(serviceFile, 'utf8').then(() => true).catch(() => false) || await readFile(timerFile, 'utf8').then(() => true).catch(() => false)) throw new Error('managed scheduler units survived uninstall');
|
|
156
|
+
const finalSystemctlCalls = await readFile(systemctlCapture, 'utf8');
|
|
157
|
+
if (!finalSystemctlCalls.includes('["--user","disable","--now","openclaw-loop-test-tasks-scheduler.timer"]')) throw new Error('scheduler timer was not disabled during uninstall');
|
|
129
158
|
console.log('openclaw installer self-test passed');
|
|
@@ -5,13 +5,14 @@ import { spawn } from 'node:child_process';
|
|
|
5
5
|
import path from 'node:path';
|
|
6
6
|
|
|
7
7
|
function parseArgs(argv) {
|
|
8
|
-
const out = { root: process.cwd(), queue: 'agent-tasks', workerAgent: null, openclawBin: 'openclaw', json: false, confirmInstall: false, force: false };
|
|
8
|
+
const out = { root: process.cwd(), queue: 'agent-tasks', workerAgent: null, openclawBin: 'openclaw', systemctlBin: 'systemctl', json: false, confirmInstall: false, force: false };
|
|
9
9
|
for (let i = 0; i < argv.length; i++) {
|
|
10
10
|
const arg = argv[i];
|
|
11
11
|
if (arg === '--root') out.root = path.resolve(argv[++i]);
|
|
12
12
|
else if (arg === '--queue') out.queue = argv[++i];
|
|
13
13
|
else if (arg === '--worker-agent') out.workerAgent = argv[++i];
|
|
14
14
|
else if (arg === '--openclaw-bin') out.openclawBin = argv[++i];
|
|
15
|
+
else if (arg === '--systemctl-bin') out.systemctlBin = argv[++i];
|
|
15
16
|
else if (arg === '--confirm-install') out.confirmInstall = true;
|
|
16
17
|
else if (arg === '--force') out.force = true;
|
|
17
18
|
else if (arg === '--json') out.json = true;
|
|
@@ -21,6 +22,10 @@ function parseArgs(argv) {
|
|
|
21
22
|
return out;
|
|
22
23
|
}
|
|
23
24
|
|
|
25
|
+
function systemdEscape(value) {
|
|
26
|
+
return String(value).replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
27
|
+
}
|
|
28
|
+
|
|
24
29
|
function safeId(value, label) {
|
|
25
30
|
if (!/^[a-zA-Z0-9._-]+$/.test(value)) throw new Error(`${label} contains unsupported characters.`);
|
|
26
31
|
return value;
|
|
@@ -86,13 +91,13 @@ child.on('close', (code, signal) => { process.exitCode = code ?? (signal ? 128 :
|
|
|
86
91
|
`;
|
|
87
92
|
}
|
|
88
93
|
|
|
89
|
-
function wrapperSource({ queue }) {
|
|
94
|
+
function wrapperSource({ queue, loopBin }) {
|
|
90
95
|
return `#!/usr/bin/env node
|
|
91
96
|
import { spawn } from 'node:child_process';
|
|
92
97
|
const [command, ...rest] = process.argv.slice(2);
|
|
93
98
|
function run(args) {
|
|
94
99
|
return new Promise((resolve) => {
|
|
95
|
-
const child = spawn(
|
|
100
|
+
const child = spawn(process.execPath, [${JSON.stringify(loopBin)}, ...args], { cwd: process.cwd(), env: process.env, stdio: 'inherit' });
|
|
96
101
|
child.on('close', (code, signal) => resolve(code ?? (signal ? 128 : 1)));
|
|
97
102
|
});
|
|
98
103
|
}
|
|
@@ -121,6 +126,11 @@ if (command === 'route') {
|
|
|
121
126
|
const humanNotifyCode = await run(['queue-human-input-notify', '--queue', ${JSON.stringify(queue)}, '--notify-command', 'node scripts/loops/openclaw-loop-notify.mjs']);
|
|
122
127
|
const terminalNotifyCode = await run(['queue-terminal-notify', '--queue', ${JSON.stringify(queue)}, '--notify-command', 'node scripts/loops/openclaw-loop-notify.mjs']);
|
|
123
128
|
process.exitCode = runCode || humanNotifyCode || terminalNotifyCode;
|
|
129
|
+
} else if (command === 'scheduler-tick') {
|
|
130
|
+
const tickCode = await run(['queue-scheduler-tick', '--config', ${JSON.stringify(`configs/loops/queues/${queue}.json`)}, '--progress-notify-command', 'node scripts/loops/openclaw-loop-notify.mjs', ...rest]);
|
|
131
|
+
const humanNotifyCode = await run(['queue-human-input-notify', '--queue', ${JSON.stringify(queue)}, '--notify-command', 'node scripts/loops/openclaw-loop-notify.mjs']);
|
|
132
|
+
const terminalNotifyCode = await run(['queue-terminal-notify', '--queue', ${JSON.stringify(queue)}, '--notify-command', 'node scripts/loops/openclaw-loop-notify.mjs']);
|
|
133
|
+
process.exitCode = tickCode || humanNotifyCode || terminalNotifyCode;
|
|
124
134
|
} else {
|
|
125
135
|
console.error('Usage: node scripts/loops/openclaw-loop.mjs route --message "走 loop:任务" [source metadata]');
|
|
126
136
|
process.exitCode = 1;
|
|
@@ -128,6 +138,14 @@ if (command === 'route') {
|
|
|
128
138
|
`;
|
|
129
139
|
}
|
|
130
140
|
|
|
141
|
+
function schedulerServiceSource({ root, queue }) {
|
|
142
|
+
return `[Unit]\nDescription=Taskforce Loop Engineering scheduler for ${queue}\nAfter=default.target\n\n[Service]\nType=oneshot\nWorkingDirectory="${systemdEscape(root)}"\nExecStart="${systemdEscape(process.execPath)}" "${systemdEscape(path.join(root, 'scripts', 'loops', 'openclaw-loop.mjs'))}" scheduler-tick --json\n`;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function schedulerTimerSource({ queue }) {
|
|
146
|
+
return `[Unit]\nDescription=Wake Taskforce Loop Engineering scheduler for ${queue}\n\n[Timer]\nOnBootSec=30s\nOnUnitActiveSec=1min\nAccuracySec=10s\nPersistent=true\nUnit=openclaw-loop-${queue}-scheduler.service\n\n[Install]\nWantedBy=timers.target\n`;
|
|
147
|
+
}
|
|
148
|
+
|
|
131
149
|
function notifierSource({ openclawBin }) {
|
|
132
150
|
return `#!/usr/bin/env node
|
|
133
151
|
import { spawn } from 'node:child_process';
|
|
@@ -164,13 +182,17 @@ function instructionsBlock({ queue }) {
|
|
|
164
182
|
async function main() {
|
|
165
183
|
const args = parseArgs(process.argv.slice(2));
|
|
166
184
|
if (args.help) {
|
|
167
|
-
console.log('Usage: loop-engineering-openclaw-install [--root workspace] [--queue agent-tasks] [--worker-agent agent-id] [--openclaw-bin openclaw] [--confirm-install] [--force] [--json]');
|
|
185
|
+
console.log('Usage: loop-engineering-openclaw-install [--root workspace] [--queue agent-tasks] [--worker-agent agent-id] [--openclaw-bin openclaw] [--systemctl-bin systemctl] [--confirm-install] [--force] [--json]');
|
|
168
186
|
return;
|
|
169
187
|
}
|
|
170
188
|
safeId(args.queue, 'queue');
|
|
171
189
|
if (args.workerAgent) safeId(args.workerAgent, 'worker agent');
|
|
172
190
|
const worker = await resolveWorkerAgent(args);
|
|
173
191
|
args.workerAgent = worker.workerAgent;
|
|
192
|
+
args.loopBin = new URL('../bin/loop-engineering.mjs', import.meta.url).pathname;
|
|
193
|
+
const systemdUserDir = path.join(process.env.XDG_CONFIG_HOME || path.join(process.env.HOME || '', '.config'), 'systemd', 'user');
|
|
194
|
+
const schedulerUnit = `openclaw-loop-${args.queue}-scheduler.service`;
|
|
195
|
+
const schedulerTimer = `openclaw-loop-${args.queue}-scheduler.timer`;
|
|
174
196
|
const files = {
|
|
175
197
|
workspaceHealth: path.join(args.root, 'configs', 'loops', 'workspace-health.json'),
|
|
176
198
|
queueConfig: path.join(args.root, 'configs', 'loops', 'queues', `${args.queue}.json`),
|
|
@@ -178,11 +200,13 @@ async function main() {
|
|
|
178
200
|
wrapper: path.join(args.root, 'scripts', 'loops', 'openclaw-loop.mjs'),
|
|
179
201
|
notifier: path.join(args.root, 'scripts', 'loops', 'openclaw-loop-notify.mjs'),
|
|
180
202
|
manifest: path.join(args.root, 'runtime', 'loop-engineering-openclaw-install.json'),
|
|
181
|
-
instructions: path.join(args.root, 'AGENTS.md')
|
|
203
|
+
instructions: path.join(args.root, 'AGENTS.md'),
|
|
204
|
+
schedulerService: path.join(systemdUserDir, schedulerUnit),
|
|
205
|
+
schedulerTimer: path.join(systemdUserDir, schedulerTimer)
|
|
182
206
|
};
|
|
183
207
|
const conflicts = [];
|
|
184
208
|
for (const [kind, file] of Object.entries(files)) if (!['instructions', 'workspaceHealth', 'manifest'].includes(kind) && await exists(file)) conflicts.push(path.relative(args.root, file));
|
|
185
|
-
const report = { version: 1, status: args.confirmInstall ? 'installed' : 'plan_only', readOnly: !args.confirmInstall, root: args.root, queue: args.queue, workerAgent: args.workerAgent, workerSelection: worker.selection, availableAgents: worker.availableAgents, workerValidated: true, createsWorkerAgent: false, openclawBin: args.openclawBin, files: Object.fromEntries(Object.entries(files).map(([key, file]) => [key, path.relative(args.root, file)])), conflicts };
|
|
209
|
+
const report = { version: 1, status: args.confirmInstall ? 'installed' : 'plan_only', readOnly: !args.confirmInstall, root: args.root, queue: args.queue, workerAgent: args.workerAgent, workerSelection: worker.selection, availableAgents: worker.availableAgents, workerValidated: true, createsWorkerAgent: false, openclawBin: args.openclawBin, systemctlBin: args.systemctlBin, scheduler: { required: true, unit: schedulerUnit, timer: schedulerTimer }, files: Object.fromEntries(Object.entries(files).map(([key, file]) => [key, path.relative(args.root, file)])), conflicts };
|
|
186
210
|
report.next = args.confirmInstall ? 'Run loop-engineering-openclaw-doctor, then route a harmless smoke task.' : 'Review this plan, then rerun with --confirm-install.';
|
|
187
211
|
if (conflicts.length && !args.force && args.confirmInstall) throw new Error(`Refusing to overwrite: ${conflicts.join(', ')}. Use --force after review.`);
|
|
188
212
|
if (args.confirmInstall) {
|
|
@@ -203,28 +227,42 @@ async function main() {
|
|
|
203
227
|
dispatcher: 'node scripts/loops/openclaw-loop-dispatch.mjs',
|
|
204
228
|
preflightConfig: 'configs/loops/workspace-health.json',
|
|
205
229
|
timeoutMs: 1800000, leaseMs: 1860000, staleActiveMs: 3600000,
|
|
230
|
+
scheduler: { required: true, heartbeatMaxAgeMs: 300000, initialInterval: '1m', minInterval: '1m', maxInterval: '4h', speedupFactor: 0.5, backoffFactor: 2, idleBackoffFactor: 2, humanGateBackoffFactor: 3, longRunHeadroomFactor: 1.25, jitter: '10s' },
|
|
206
231
|
retry: { maxAttempts: 1, retryDelayMs: 0, retryExitCodes: [1], requiresHumanActionPatterns: ['requires human', '需要人工', 'Permission denied', 'Operation not permitted'] },
|
|
207
232
|
revisionPolicy: { enabled: true, maxRevisionRounds: 3, sameFailureThreshold: 2, requireStrategyChange: true }
|
|
208
233
|
}, null, 2)}\n`;
|
|
209
234
|
const dispatcherContent = dispatcherSource(args);
|
|
210
235
|
const wrapperContent = wrapperSource(args);
|
|
211
236
|
const notifierContent = notifierSource(args);
|
|
237
|
+
const schedulerServiceContent = schedulerServiceSource(args);
|
|
238
|
+
const schedulerTimerContent = schedulerTimerSource(args);
|
|
212
239
|
await writeFile(files.queueConfig, queueContent);
|
|
213
240
|
await writeFile(files.dispatcher, dispatcherContent);
|
|
214
241
|
await writeFile(files.wrapper, wrapperContent);
|
|
215
242
|
await writeFile(files.notifier, notifierContent);
|
|
243
|
+
await mkdir(systemdUserDir, { recursive: true });
|
|
244
|
+
await writeFile(files.schedulerService, schedulerServiceContent);
|
|
245
|
+
await writeFile(files.schedulerTimer, schedulerTimerContent);
|
|
246
|
+
const daemonReload = await run(args.systemctlBin, ['--user', 'daemon-reload'], { cwd: args.root });
|
|
247
|
+
if (daemonReload.code !== 0) throw new Error(`Cannot reload user systemd units: ${(daemonReload.stderr || daemonReload.stdout).trim() || `exit ${daemonReload.code}`}`);
|
|
248
|
+
const enableTimer = await run(args.systemctlBin, ['--user', 'enable', '--now', schedulerTimer], { cwd: args.root });
|
|
249
|
+
if (enableTimer.code !== 0) throw new Error(`Cannot enable Loop scheduler timer ${schedulerTimer}: ${(enableTimer.stderr || enableTimer.stdout).trim() || `exit ${enableTimer.code}`}`);
|
|
216
250
|
const instructions = await exists(files.instructions) ? await readFile(files.instructions, 'utf8') : '';
|
|
217
251
|
const managedInstructions = instructionsBlock(args);
|
|
218
252
|
if (!instructions.includes('<!-- loop-engineering:openclaw:start -->')) await appendFile(files.instructions, managedInstructions);
|
|
219
253
|
await mkdir(path.dirname(files.manifest), { recursive: true });
|
|
220
254
|
await writeFile(files.manifest, `${JSON.stringify({
|
|
221
|
-
version:
|
|
255
|
+
version: 2, queue: args.queue, workerAgent: args.workerAgent, openclawBin: args.openclawBin, systemctlBin: args.systemctlBin, installedAt: new Date().toISOString(),
|
|
222
256
|
managedFiles: [
|
|
223
257
|
{ path: path.relative(args.root, files.queueConfig), sha256: sha256(queueContent) },
|
|
224
258
|
{ path: path.relative(args.root, files.dispatcher), sha256: sha256(dispatcherContent) },
|
|
225
259
|
{ path: path.relative(args.root, files.wrapper), sha256: sha256(wrapperContent) },
|
|
226
260
|
{ path: path.relative(args.root, files.notifier), sha256: sha256(notifierContent) }
|
|
227
261
|
],
|
|
262
|
+
managedUnits: [
|
|
263
|
+
{ path: files.schedulerService, unit: schedulerUnit, sha256: sha256(schedulerServiceContent) },
|
|
264
|
+
{ path: files.schedulerTimer, unit: schedulerTimer, sha256: sha256(schedulerTimerContent) }
|
|
265
|
+
],
|
|
228
266
|
managedInstructions: { path: 'AGENTS.md', sha256: sha256(managedInstructions), content: managedInstructions },
|
|
229
267
|
retainedOnUninstall: [`runtime/loops/${args.queue}`]
|
|
230
268
|
}, null, 2)}\n`);
|
|
@@ -6,6 +6,13 @@ import path from 'node:path';
|
|
|
6
6
|
|
|
7
7
|
const sha256 = (value) => createHash('sha256').update(value).digest('hex');
|
|
8
8
|
async function exists(file) { try { await access(file); return true; } catch { return false; } }
|
|
9
|
+
function run(command, args, cwd) {
|
|
10
|
+
return new Promise((resolve) => {
|
|
11
|
+
const child = spawn(command, args, { cwd, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
12
|
+
let stdout = ''; let stderr = ''; child.stdout.on('data', (c) => { stdout += c; }); child.stderr.on('data', (c) => { stderr += c; });
|
|
13
|
+
child.on('close', (code) => resolve({ code, stdout, stderr }));
|
|
14
|
+
});
|
|
15
|
+
}
|
|
9
16
|
function parseArgs(argv) {
|
|
10
17
|
const out = { root: process.cwd(), action: 'uninstall-plan', json: false, confirm: false };
|
|
11
18
|
for (let i = 0; i < argv.length; i++) {
|
|
@@ -36,15 +43,29 @@ async function main() {
|
|
|
36
43
|
const current = present ? await readFile(file, 'utf8') : '';
|
|
37
44
|
files.push({ path: entry.path, present, clean: present && sha256(current) === entry.sha256 });
|
|
38
45
|
}
|
|
46
|
+
const units = [];
|
|
47
|
+
for (const entry of manifest.managedUnits || []) {
|
|
48
|
+
const present = await exists(entry.path);
|
|
49
|
+
const current = present ? await readFile(entry.path, 'utf8') : '';
|
|
50
|
+
units.push({ ...entry, present, clean: present && sha256(current) === entry.sha256 });
|
|
51
|
+
}
|
|
39
52
|
const agentsFile = path.join(args.root, manifest.managedInstructions?.path || 'AGENTS.md');
|
|
40
53
|
const agentsText = await exists(agentsFile) ? await readFile(agentsFile, 'utf8') : '';
|
|
41
54
|
const block = manifest.managedInstructions?.content || '';
|
|
42
55
|
const instructionsClean = Boolean(block) && sha256(block) === manifest.managedInstructions?.sha256 && agentsText.includes(block);
|
|
43
|
-
const modified = files.filter((item) => item.present && !item.clean).map((item) => item.path);
|
|
44
|
-
const plan = { version:
|
|
56
|
+
const modified = [...files.filter((item) => item.present && !item.clean).map((item) => item.path), ...units.filter((item) => item.present && !item.clean).map((item) => item.path)];
|
|
57
|
+
const plan = { version: 2, action: args.action, readOnly: args.action.endsWith('-plan'), queue: manifest.queue, workerAgent: manifest.workerAgent, files, units, instructionsClean, modified, retained: manifest.retainedOnUninstall || [], ready: modified.length === 0 && instructionsClean };
|
|
45
58
|
if (args.action === 'uninstall') {
|
|
46
59
|
if (!plan.ready) throw new Error(`Refusing uninstall because managed content changed: ${[...modified, ...(!instructionsClean ? ['AGENTS.md managed block'] : [])].join(', ')}`);
|
|
60
|
+
const timer = units.find((item) => item.unit?.endsWith('.timer'));
|
|
61
|
+
if (timer) {
|
|
62
|
+
const stopped = await run(manifest.systemctlBin || 'systemctl', ['--user', 'disable', '--now', timer.unit], args.root);
|
|
63
|
+
if (stopped.code !== 0) throw new Error(`Cannot disable Loop scheduler timer ${timer.unit}: ${stopped.stderr || stopped.stdout}`);
|
|
64
|
+
}
|
|
47
65
|
for (const item of files) if (item.present && item.clean) await rm(path.join(args.root, item.path), { force: true });
|
|
66
|
+
for (const item of units) if (item.present && item.clean) await rm(item.path, { force: true });
|
|
67
|
+
const reload = await run(manifest.systemctlBin || 'systemctl', ['--user', 'daemon-reload'], args.root);
|
|
68
|
+
if (reload.code !== 0) throw new Error(`Cannot reload user systemd units: ${reload.stderr || reload.stdout}`);
|
|
48
69
|
await writeFile(agentsFile, agentsText.replace(block, ''));
|
|
49
70
|
await rm(manifestFile, { force: true });
|
|
50
71
|
plan.status = 'uninstalled'; plan.readOnly = false;
|
|
@@ -52,7 +73,7 @@ async function main() {
|
|
|
52
73
|
if (!plan.ready) throw new Error(`Refusing upgrade because managed content changed: ${[...modified, ...(!instructionsClean ? ['AGENTS.md managed block'] : [])].join(', ')}`);
|
|
53
74
|
const installer = new URL('./openclaw-install.mjs', import.meta.url).pathname;
|
|
54
75
|
const result = await new Promise((resolve) => {
|
|
55
|
-
const child = spawn(process.execPath, [installer, '--root', args.root, '--queue', manifest.queue, '--worker-agent', manifest.workerAgent, '--openclaw-bin', manifest.openclawBin || 'openclaw', '--confirm-install', '--force', '--json'], { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
76
|
+
const child = spawn(process.execPath, [installer, '--root', args.root, '--queue', manifest.queue, '--worker-agent', manifest.workerAgent, '--openclaw-bin', manifest.openclawBin || 'openclaw', '--systemctl-bin', manifest.systemctlBin || 'systemctl', '--confirm-install', '--force', '--json'], { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
56
77
|
let stdout = ''; let stderr = ''; child.stdout.on('data', (c) => { stdout += c; }); child.stderr.on('data', (c) => { stderr += c; });
|
|
57
78
|
child.on('close', (code) => resolve({ code, stdout, stderr }));
|
|
58
79
|
});
|