taskforce-loop-engineering 0.9.1 → 0.12.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 +23 -0
- package/MIGRATING.md +47 -2
- package/README.md +81 -7
- package/bin/loop-engineering.mjs +181 -0
- package/docs/architecture.md +444 -0
- package/docs/multi-agent-control-plane.md +31 -0
- package/docs/operator-dashboard.md +27 -0
- package/docs/release-0.12-acceptance.md +35 -0
- package/lib/action-reservations.mjs +196 -0
- package/lib/core.mjs +230 -14
- package/lib/operator-dashboard.mjs +198 -0
- package/lib/todo-control-plane.mjs +287 -0
- package/package.json +3 -2
- package/scripts/action-reservation-self-test.mjs +65 -0
- package/scripts/hermes-install-self-test.mjs +2 -0
- package/scripts/hermes-install.mjs +40 -25
- package/scripts/human-gate-lifecycle-v2-self-test.mjs +81 -0
- package/scripts/openclaw-install-self-test.mjs +19 -5
- package/scripts/openclaw-install.mjs +101 -37
- package/scripts/openclaw-manage.mjs +1 -1
- package/scripts/operator-dashboard-self-test.mjs +74 -0
- package/scripts/route-notify-self-test.mjs +7 -0
- package/scripts/todo-control-plane-self-test.mjs +74 -0
- package/templates/operator-projection.schema.json +1 -0
- package/templates/todo.schema.json +28 -0
|
@@ -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', hermesBin: 'hermes', systemctlBin: 'systemctl', json: false, confirmInstall: false, force: false };
|
|
8
|
+
const out = { root: process.cwd(), queue: 'agent-tasks', hermesBin: 'hermes', systemctlBin: 'systemctl', language: 'auto', 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 === '--hermes-bin') out.hermesBin = argv[++i];
|
|
14
14
|
else if (arg === '--systemctl-bin') out.systemctlBin = argv[++i];
|
|
15
|
+
else if (arg === '--language') out.language = 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,13 @@ function parseArgs(argv) {
|
|
|
21
22
|
return out;
|
|
22
23
|
}
|
|
23
24
|
|
|
25
|
+
function resolveLanguage(requested = 'auto', env = process.env) {
|
|
26
|
+
if (!['auto', 'en', 'zh'].includes(requested)) throw new Error('--language must be auto, en, or zh.');
|
|
27
|
+
if (requested !== 'auto') return requested;
|
|
28
|
+
return String(env.LC_ALL || env.LC_MESSAGES || env.LANG || '').toLowerCase().startsWith('zh') ? 'zh' : 'en';
|
|
29
|
+
}
|
|
30
|
+
const text = (language, en, zh) => language === 'zh' ? zh : en;
|
|
31
|
+
|
|
24
32
|
function safeId(value, label) { if (!/^[a-zA-Z0-9._-]+$/.test(value)) throw new Error(`${label} contains unsupported characters.`); return value; }
|
|
25
33
|
function systemdEscapePath(value) {
|
|
26
34
|
return [...Buffer.from(String(value))].map((byte) => /[A-Za-z0-9/_.:-]/.test(String.fromCharCode(byte)) ? String.fromCharCode(byte) : `\\x${byte.toString(16).padStart(2, '0')}`).join('');
|
|
@@ -44,7 +52,8 @@ function run(command, args, options = {}) {
|
|
|
44
52
|
});
|
|
45
53
|
}
|
|
46
54
|
|
|
47
|
-
function formatConfirmationSummary(summary) {
|
|
55
|
+
function formatConfirmationSummary(summary, language) {
|
|
56
|
+
if (language === 'zh') return ['安装确认', ` 目标平台:${summary.targetPlatform}`, ` 平台 CLI:${summary.platformCli}`, ` 工作区:${summary.workspace}`, ` 队列:${summary.queue}`, ` 调度器:${summary.scheduler}`, ` 通知目标:${summary.notificationTarget}`, ` 允许写入:${summary.writesEnabled ? '是' : '否(仅生成计划)'}`].join('\n');
|
|
48
57
|
return [
|
|
49
58
|
'Installation confirmation',
|
|
50
59
|
` target platform: ${summary.targetPlatform}`,
|
|
@@ -57,15 +66,15 @@ function formatConfirmationSummary(summary) {
|
|
|
57
66
|
].join('\n');
|
|
58
67
|
}
|
|
59
68
|
|
|
60
|
-
function dispatcherSource({ hermesBin }) {
|
|
69
|
+
function dispatcherSource({ hermesBin, language }) {
|
|
70
|
+
const intro = language === 'zh' ? ['你收到的是一个已经由 Loop Engineering 管理的任务。', '不要再次路由或入队。', '实施前阅读任务合同、开发计划、验收计划和实时补充要求。'] : ['You are receiving an already loop-managed task.', 'Do not route or enqueue this task again.', 'Read the task contract, development plan, acceptance plan, and live amendments before implementation.'];
|
|
71
|
+
const finish = text(language, 'Before each checkpoint and final completion, reread the live amendment file. Write a checkpoint when possible and finish with status, evidence, verification, blockers, and next action.', '每次写检查点和最终完成前都重新读取实时补充要求文件。尽可能写检查点,并以状态、证据、验证、阻塞和下一步结束。');
|
|
61
72
|
return `#!/usr/bin/env node
|
|
62
73
|
import { readFile } from 'node:fs/promises';
|
|
63
74
|
import { spawn } from 'node:child_process';
|
|
64
75
|
const task = JSON.parse(await readFile(process.env.LOOP_TASK_FILE, 'utf8'));
|
|
65
76
|
const prompt = [
|
|
66
|
-
|
|
67
|
-
'Do not route or enqueue this task again.',
|
|
68
|
-
'Read the task contract, development plan, acceptance plan, and live amendments before implementation.',
|
|
77
|
+
${intro.map((line) => JSON.stringify(line)).join(',\n ')},
|
|
69
78
|
\`Task id: \${task.id}\`,
|
|
70
79
|
\`Task contract: \${process.env.LOOP_TASK_CONTRACT_FILE || 'not provided'}\`,
|
|
71
80
|
\`Development plan: \${process.env.LOOP_DEV_PLAN_FILE || 'not provided'}\`,
|
|
@@ -73,14 +82,16 @@ const prompt = [
|
|
|
73
82
|
\`Live amendments: \${process.env.LOOP_LATEST_AMENDMENT_FILE || 'not provided'}\`,
|
|
74
83
|
\`Checkpoints dir: \${process.env.LOOP_CHECKPOINTS_DIR || 'not provided'}\`,
|
|
75
84
|
'', task.body, '',
|
|
76
|
-
|
|
85
|
+
${JSON.stringify(finish)}
|
|
77
86
|
].join('\\n');
|
|
78
87
|
const child = spawn(${JSON.stringify(hermesBin)}, ['-z', prompt], { cwd: process.cwd(), env: process.env, stdio: 'inherit' });
|
|
79
88
|
child.on('close', (code, signal) => { process.exitCode = code ?? (signal ? 128 : 1); });
|
|
80
89
|
`;
|
|
81
90
|
}
|
|
82
91
|
|
|
83
|
-
function wrapperSource({ queue, loopBin }) {
|
|
92
|
+
function wrapperSource({ queue, loopBin, language }) {
|
|
93
|
+
const amendmentPattern = language === 'zh' ? '(?:继续(?:当前|这个)?\\s*loop|给(?:当前|这个)?\\s*loop\\s*(?:补充|增加|加)|补充当前\\s*loop)' : '(?:continue\\s+(?:the\\s+)?(?:current\\s+)?loop|amend\\s+(?:the\\s+)?(?:current\\s+)?loop|add\\s+(?:this\\s+)?amendment\\s+to\\s+(?:the\\s+)?(?:current\\s+)?loop)';
|
|
94
|
+
const queueOnlyPattern = language === 'zh' ? '(?:只入队|只排队|暂不执行|不立即执行)' : '(?:queue\\s+(?:this|it)\\s+only|only\\s+queue\\s+(?:this|it)|enqueue\\s+(?:this|it)\\s+only|do\\s+not\\s+(?:run|execute)\\s+(?:this|it)\\s+(?:yet|now))';
|
|
84
95
|
return `#!/usr/bin/env node
|
|
85
96
|
import { spawn } from 'node:child_process';
|
|
86
97
|
const [command, ...rest] = process.argv.slice(2);
|
|
@@ -89,9 +100,9 @@ const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
|
89
100
|
async function runWhenUnlocked(args, waitMs = 300000) { const deadline = Date.now() + waitMs; while (true) { const code = await run(args); if (code !== 2 || Date.now() >= deadline) return code; await wait(250); } }
|
|
90
101
|
if (command === 'route') {
|
|
91
102
|
const messageIndex = rest.indexOf('--message'); const message = messageIndex >= 0 ? String(rest[messageIndex + 1] || '') : '';
|
|
92
|
-
const amendment =
|
|
103
|
+
const amendment = new RegExp(${JSON.stringify(amendmentPattern)}, 'i').test(message);
|
|
93
104
|
const routeCode = await run(['route-message', '--queue', ${JSON.stringify(queue)}, '--route', '--confirm-execute', amendment ? '--amend-active' : '--supersede-active', ...rest]);
|
|
94
|
-
const queueOnly =
|
|
105
|
+
const queueOnly = new RegExp(${JSON.stringify(queueOnlyPattern)}, 'i').test(message);
|
|
95
106
|
const runCode = routeCode || queueOnly || amendment ? routeCode : await runWhenUnlocked(['run-queue', '--config', ${JSON.stringify(`configs/loops/queues/${queue}.json`)}, '--progress-notify-command', 'node scripts/loops/hermes-loop-notify.mjs']);
|
|
96
107
|
const humanCode = await run(['queue-human-input-notify', '--queue', ${JSON.stringify(queue)}, '--notify-command', 'node scripts/loops/hermes-loop-notify.mjs']);
|
|
97
108
|
const terminalCode = await run(['queue-terminal-notify', '--queue', ${JSON.stringify(queue)}, '--notify-command', 'node scripts/loops/hermes-loop-notify.mjs']);
|
|
@@ -106,18 +117,18 @@ if (command === 'route') {
|
|
|
106
117
|
const humanCode = await run(['queue-human-input-notify', '--queue', ${JSON.stringify(queue)}, '--notify-command', 'node scripts/loops/hermes-loop-notify.mjs']);
|
|
107
118
|
const terminalCode = await run(['queue-terminal-notify', '--queue', ${JSON.stringify(queue)}, '--notify-command', 'node scripts/loops/hermes-loop-notify.mjs']);
|
|
108
119
|
process.exitCode = tickCode || humanCode || terminalCode;
|
|
109
|
-
} else { console.error('Usage: node scripts/loops/hermes-loop.mjs route|run-once|scheduler-tick'); process.exitCode = 1; }
|
|
120
|
+
} else { console.error(${JSON.stringify(text(language, 'Usage: node scripts/loops/hermes-loop.mjs route|run-once|scheduler-tick', '用法:node scripts/loops/hermes-loop.mjs route|run-once|scheduler-tick'))}); process.exitCode = 1; }
|
|
110
121
|
`;
|
|
111
122
|
}
|
|
112
123
|
|
|
113
|
-
function notifierSource({ hermesBin }) {
|
|
124
|
+
function notifierSource({ hermesBin, language }) {
|
|
114
125
|
return `#!/usr/bin/env node
|
|
115
126
|
import { spawn } from 'node:child_process';
|
|
116
127
|
const message = process.argv.slice(2).join(' ').trim();
|
|
117
128
|
const rawSource = process.env.LOOP_HUMAN_INPUT_SOURCE || process.env.LOOP_NOTIFICATION_SOURCE || '';
|
|
118
|
-
if (!message) { console.error('loop notifier requires a message'); process.exit(2); }
|
|
119
|
-
let source; try { source = JSON.parse(rawSource); } catch { console.error('loop notifier received invalid source metadata'); process.exit(2); }
|
|
120
|
-
if (!source || typeof source !== 'object' || !source.target) { console.error('Hermes loop notifier requires source.target in platform[:chat[:thread]] form'); process.exit(2); }
|
|
129
|
+
if (!message) { console.error(${JSON.stringify(text(language, 'loop notifier requires a message', 'loop 通知器需要消息参数'))}); process.exit(2); }
|
|
130
|
+
let source; try { source = JSON.parse(rawSource); } catch { console.error(${JSON.stringify(text(language, 'loop notifier received invalid source metadata', 'loop 通知器收到无效的来源元数据'))}); process.exit(2); }
|
|
131
|
+
if (!source || typeof source !== 'object' || !source.target) { console.error(${JSON.stringify(text(language, 'Hermes loop notifier requires source.target in platform[:chat[:thread]] form', 'Hermes loop 通知器要求 source.target 使用 platform[:chat[:thread]] 格式'))}); process.exit(2); }
|
|
121
132
|
const args = ['send', '--to', String(source.target), '--quiet', message];
|
|
122
133
|
if (process.env.LOOP_NOTIFICATION_DRY_RUN === '1') { console.log(JSON.stringify({ dryRun: true, command: ${JSON.stringify(hermesBin)}, args })); }
|
|
123
134
|
else { const child = spawn(${JSON.stringify(hermesBin)}, args, { cwd: process.cwd(), env: process.env, stdio: 'inherit' }); child.on('close', (code, signal) => { process.exitCode = code ?? (signal ? 128 : 1); }); }
|
|
@@ -126,11 +137,15 @@ else { const child = spawn(${JSON.stringify(hermesBin)}, args, { cwd: process.cw
|
|
|
126
137
|
|
|
127
138
|
function schedulerServiceSource({ root, queue }) { return `[Unit]\nDescription=Taskforce Loop Engineering Hermes scheduler for ${queue}\nAfter=default.target\n\n[Service]\nType=oneshot\nWorkingDirectory=${systemdEscapePath(root)}\nExecStart=${systemdEscapePath(process.execPath)} ${systemdEscapePath(path.join(root, 'scripts', 'loops', 'hermes-loop.mjs'))} scheduler-tick --json\n`; }
|
|
128
139
|
function schedulerTimerSource({ queue }) { return `[Unit]\nDescription=Wake Taskforce Loop Engineering Hermes scheduler for ${queue}\n\n[Timer]\nOnBootSec=30s\nOnUnitActiveSec=1min\nAccuracySec=10s\nPersistent=true\nUnit=hermes-loop-${queue}-scheduler.service\n\n[Install]\nWantedBy=timers.target\n`; }
|
|
129
|
-
function instructionsBlock({ queue }) {
|
|
140
|
+
function instructionsBlock({ queue, language }) {
|
|
141
|
+
if (language === 'zh') return `\n<!-- loop-engineering:hermes:start -->\n## Loop Engineering 会话路由(Hermes)\n\n- 仅路由明确的 Loop Engineering 请求。已管理的任务不能再次路由。\n- 运行 \`node scripts/loops/hermes-loop.mjs route --message "<完整用户消息>"\` 并传入来源 channel 和 target,使通知返回原始 Hermes 会话。\n- \`走 loop\` 立即执行;只有 \`只入队\` 或 \`只排队\` 才不立即执行。高风险动作仍需单独门禁。\n- 队列:\`${queue}\`。\n<!-- loop-engineering:hermes:end -->\n`;
|
|
142
|
+
return `\n<!-- loop-engineering:hermes:start -->\n## Loop Engineering conversation routing (Hermes)\n\n- Route only explicit Loop Engineering requests. Already managed tasks must never be routed again.\n- Run \`node scripts/loops/hermes-loop.mjs route --message "<full user message>"\` and pass \`--source-channel <platform> --source-target <platform:chat_id[:thread_id]>\` so notifications return to the originating Hermes conversation.\n- \`Use Loop Engineering to fix this issue\` and \`Run this through Loop Engineering\` execute immediately. \`Queue this only; do not run it yet\` suppresses execution. \`Continue the current loop with this amendment: ...\` amends the active task.\n- External, destructive, production, credential, or memory migration actions remain separately gated.\n- Queue: \`${queue}\`.\n<!-- loop-engineering:hermes:end -->\n`;
|
|
143
|
+
}
|
|
130
144
|
|
|
131
145
|
async function main() {
|
|
132
146
|
const args = parseArgs(process.argv.slice(2));
|
|
133
|
-
|
|
147
|
+
args.language = resolveLanguage(args.language);
|
|
148
|
+
if (args.help) { console.log('Usage: loop-engineering-hermes-install [--root workspace] [--queue agent-tasks] [--language auto|en|zh] [--hermes-bin hermes] [--systemctl-bin systemctl] [--confirm-install] [--force] [--json]'); return; }
|
|
134
149
|
safeId(args.queue, 'queue');
|
|
135
150
|
args.hermesBin = await resolveExecutable(args.hermesBin);
|
|
136
151
|
const hermes = await run(args.hermesBin, ['--version'], { cwd: args.root });
|
|
@@ -140,22 +155,22 @@ async function main() {
|
|
|
140
155
|
const unit = `hermes-loop-${args.queue}-scheduler.service`; const timer = `hermes-loop-${args.queue}-scheduler.timer`;
|
|
141
156
|
const files = { workspaceHealth: path.join(args.root, 'configs/loops/workspace-health.json'), queueConfig: path.join(args.root, 'configs/loops/queues', `${args.queue}.json`), dispatcher: path.join(args.root, 'scripts/loops/hermes-loop-dispatch.mjs'), wrapper: path.join(args.root, 'scripts/loops/hermes-loop.mjs'), notifier: path.join(args.root, 'scripts/loops/hermes-loop-notify.mjs'), manifest: path.join(args.root, 'runtime/loop-engineering-hermes-install.json'), instructions: path.join(args.root, 'AGENTS.md'), schedulerService: path.join(systemdUserDir, unit), schedulerTimer: path.join(systemdUserDir, timer) };
|
|
142
157
|
const conflicts = []; for (const [kind, file] of Object.entries(files)) if (!['instructions', 'workspaceHealth', 'manifest'].includes(kind) && await exists(file)) conflicts.push(path.relative(args.root, file));
|
|
143
|
-
const confirmationSummary = { targetPlatform: 'Hermes', platformCli: args.hermesBin, workspace: args.root, queue: args.queue, scheduler: `systemd user timer ${timer}`, notificationTarget: 'source-bound at runtime (original Hermes conversation)', writesEnabled: args.confirmInstall };
|
|
144
|
-
const report = { version: 1, platform: 'hermes', status: args.confirmInstall ? 'installed' : 'plan_only', readOnly: !args.confirmInstall, root: args.root, queue: args.queue, hermesBin: args.hermesBin, hermesVersion: (hermes.stdout || hermes.stderr).trim().slice(0, 200), scheduler: { required: true, unit, timer }, confirmationSummary, files: Object.fromEntries(Object.entries(files).map(([key, file]) => [key, path.relative(args.root, file)])), conflicts };
|
|
158
|
+
const confirmationSummary = { targetPlatform: 'Hermes', platformCli: args.hermesBin, workspace: args.root, queue: args.queue, scheduler: `systemd user timer ${timer}`, notificationTarget: text(args.language, 'source-bound at runtime (original Hermes conversation)', '运行时绑定到原始 Hermes 会话'), writesEnabled: args.confirmInstall };
|
|
159
|
+
const report = { version: 1, platform: 'hermes', language: args.language, status: args.confirmInstall ? 'installed' : 'plan_only', readOnly: !args.confirmInstall, root: args.root, queue: args.queue, hermesBin: args.hermesBin, hermesVersion: (hermes.stdout || hermes.stderr).trim().slice(0, 200), scheduler: { required: true, unit, timer }, confirmationSummary, files: Object.fromEntries(Object.entries(files).map(([key, file]) => [key, path.relative(args.root, file)])), conflicts };
|
|
145
160
|
if (conflicts.length && !args.force && args.confirmInstall) throw new Error(`Refusing to overwrite: ${conflicts.join(', ')}. Use --force after review.`);
|
|
146
|
-
if (!args.json) console.log(formatConfirmationSummary(confirmationSummary));
|
|
161
|
+
if (!args.json) console.log(formatConfirmationSummary(confirmationSummary, args.language));
|
|
147
162
|
if (args.confirmInstall) {
|
|
148
163
|
await mkdir(path.dirname(files.queueConfig), { recursive: true }); await mkdir(path.dirname(files.dispatcher), { recursive: true }); await mkdir(path.join(args.root, 'runtime', 'loops', args.queue), { recursive: true });
|
|
149
164
|
if (!await exists(files.workspaceHealth)) await writeFile(files.workspaceHealth, `${JSON.stringify({ id: 'workspace-health', goal: 'Keep this workspace loop-ready and detect obvious drift.', level: 'L1', mode: 'report-only', maxRuntimeMs: 120000, breaker: { maxConsecutiveFailures: 3, sameFailureThreshold: 2 }, checks: [{ id: 'workspace-root', type: 'files', paths: ['.'] }] }, null, 2)}\n`);
|
|
150
|
-
const queueContent = `${JSON.stringify({ queue: args.queue, description: 'Hermes conversation queue.', dispatcher: 'node scripts/loops/hermes-loop-dispatch.mjs', preflightConfig: 'configs/loops/workspace-health.json', timeoutMs: 1800000, leaseMs: 1860000, staleActiveMs: 3600000, 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' }, retry: { maxAttempts: 1, runtimeRecoveryMaxAttempts: 2, sessionMaxTicks: 10, retryDelayMs: 0, retryExitCodes: [1], requiresHumanActionPatterns: ['requires human', '需要人工', 'Permission denied', 'Operation not permitted'] }, revisionPolicy: { enabled: true, maxRevisionRounds: 3, sameFailureThreshold: 2, requireStrategyChange: true } }, null, 2)}\n`;
|
|
151
|
-
const contents = { queueConfig: queueContent, dispatcher: dispatcherSource(args), wrapper: wrapperSource({ queue: args.queue, loopBin }), notifier: notifierSource(args), schedulerService: schedulerServiceSource({ root: args.root, queue: args.queue }), schedulerTimer: schedulerTimerSource({ queue: args.queue }) };
|
|
165
|
+
const queueContent = `${JSON.stringify({ queue: args.queue, language: args.language, description: text(args.language, 'Hermes conversation queue.', 'Hermes 会话队列。'), dispatcher: 'node scripts/loops/hermes-loop-dispatch.mjs', preflightConfig: 'configs/loops/workspace-health.json', timeoutMs: 1800000, leaseMs: 1860000, staleActiveMs: 3600000, 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' }, retry: { maxAttempts: 1, runtimeRecoveryMaxAttempts: 2, sessionMaxTicks: 10, retryDelayMs: 0, retryExitCodes: [1], requiresHumanActionPatterns: ['requires human', '需要人工', 'Permission denied', 'Operation not permitted'] }, revisionPolicy: { enabled: true, maxRevisionRounds: 3, sameFailureThreshold: 2, requireStrategyChange: true } }, null, 2)}\n`;
|
|
166
|
+
const contents = { queueConfig: queueContent, dispatcher: dispatcherSource(args), wrapper: wrapperSource({ queue: args.queue, loopBin, language: args.language }), notifier: notifierSource(args), schedulerService: schedulerServiceSource({ root: args.root, queue: args.queue }), schedulerTimer: schedulerTimerSource({ queue: args.queue }) };
|
|
152
167
|
await mkdir(systemdUserDir, { recursive: true });
|
|
153
168
|
for (const [kind, content] of Object.entries(contents)) await writeFile(files[kind], content);
|
|
154
169
|
const reload = await run(args.systemctlBin, ['--user', 'daemon-reload'], { cwd: args.root }); if (reload.code !== 0) throw new Error(`Cannot reload user systemd units: ${(reload.stderr || reload.stdout).trim()}`);
|
|
155
170
|
const enable = await run(args.systemctlBin, ['--user', 'enable', '--now', timer], { cwd: args.root }); if (enable.code !== 0) throw new Error(`Cannot enable Hermes Loop scheduler ${timer}: ${(enable.stderr || enable.stdout).trim()}`);
|
|
156
|
-
const marker = instructionsBlock({ queue: args.queue }); const current = await readFile(files.instructions, 'utf8').catch(() => ''); if (!current.includes('<!-- loop-engineering:hermes:start -->')) await appendFile(files.instructions, marker);
|
|
157
|
-
await mkdir(path.dirname(files.manifest), { recursive: true }); await writeFile(files.manifest, `${JSON.stringify({ version:
|
|
171
|
+
const marker = instructionsBlock({ queue: args.queue, language: args.language }); const current = await readFile(files.instructions, 'utf8').catch(() => ''); if (!current.includes('<!-- loop-engineering:hermes:start -->')) await appendFile(files.instructions, marker);
|
|
172
|
+
await mkdir(path.dirname(files.manifest), { recursive: true }); await writeFile(files.manifest, `${JSON.stringify({ version: 2, platform: 'hermes', language: args.language, installedAt: new Date().toISOString(), root: args.root, queue: args.queue, hermesBin: args.hermesBin, files: Object.entries(contents).map(([kind, content]) => ({ kind, path: files[kind], sha256: sha256(content) })), scheduler: { unit, timer } }, null, 2)}\n`);
|
|
158
173
|
}
|
|
159
|
-
console.log(args.json ? JSON.stringify(report, null, 2) : `Hermes Loop installer: ${report.status}\nnext: ${args.confirmInstall ? 'Run loop-engineering-hermes-doctor, then route a harmless smoke task.' : 'Review this summary, then rerun with --confirm-install.'}`);
|
|
174
|
+
console.log(args.json ? JSON.stringify(report, null, 2) : text(args.language, `Hermes Loop installer: ${report.status}\nnext: ${args.confirmInstall ? 'Run loop-engineering-hermes-doctor, then route a harmless smoke task.' : 'Review this summary, then rerun with --confirm-install.'}`, `Hermes Loop 安装器:${report.status}\n下一步:${args.confirmInstall ? '运行 loop-engineering-hermes-doctor,然后路由一个无害的冒烟任务。' : '检查此摘要,然后使用 --confirm-install 重新运行。'}`));
|
|
160
175
|
}
|
|
161
176
|
main().catch((error) => { console.error(error instanceof Error ? error.message : String(error)); process.exitCode = 1; });
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { mkdtemp, rm } from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import {
|
|
6
|
+
ensureQueueDirs,
|
|
7
|
+
parkQueueTask,
|
|
8
|
+
queueStatus,
|
|
9
|
+
queueSubdirFor,
|
|
10
|
+
readJson,
|
|
11
|
+
resumeParkedTask,
|
|
12
|
+
tickParkedTasks,
|
|
13
|
+
writeJson
|
|
14
|
+
} from '../lib/core.mjs';
|
|
15
|
+
|
|
16
|
+
const root = await mkdtemp(path.join(tmpdir(), 'loop-human-gate-v2-'));
|
|
17
|
+
const queue = 'vps-fixture';
|
|
18
|
+
const taskId = 'vps-down-ssh-banner-timeout';
|
|
19
|
+
const taskFile = path.join(queueSubdirFor(root, queue, 'inbox'), `${taskId}.json`);
|
|
20
|
+
|
|
21
|
+
try {
|
|
22
|
+
await ensureQueueDirs(root, queue);
|
|
23
|
+
await writeJson(taskFile, { version: 1, id: taskId, title: 'Recover provider VPS', status: 'queued' });
|
|
24
|
+
const parked = await parkQueueTask(root, {
|
|
25
|
+
queue,
|
|
26
|
+
taskId,
|
|
27
|
+
kind: 'external_condition',
|
|
28
|
+
reason: 'VPS is down; SSH banner timed out.',
|
|
29
|
+
now: '2026-08-13T00:00:00.000Z',
|
|
30
|
+
executionKey: 'provider-call-1',
|
|
31
|
+
authorization: { state: 'unconsumed', scope: 'provider_call' },
|
|
32
|
+
policy: { timeoutMs: 2_000, reminderIntervalMs: 1_000, escalationIntervalMs: 2_000, maxReminders: 1 }
|
|
33
|
+
});
|
|
34
|
+
assert.equal(parked.outcome, 'parked');
|
|
35
|
+
assert.equal(parked.task.parked.authorization.state, 'unconsumed');
|
|
36
|
+
assert.equal(parked.task.parked.execution_boundary.action_executed, false);
|
|
37
|
+
|
|
38
|
+
const status = await queueStatus(root, queue);
|
|
39
|
+
assert.equal(status.waitingStates.timed_out_or_escalated, 1);
|
|
40
|
+
assert.equal(status.waitingTasks[0].waitKind, 'external_condition');
|
|
41
|
+
assert.equal(status.waitingTasks[0].authorizationState, 'unconsumed');
|
|
42
|
+
|
|
43
|
+
const notifyCommand = 'node -e "process.exit(0)"';
|
|
44
|
+
const reminder = await tickParkedTasks(root, { queue, now: '2026-08-13T00:00:01.000Z', notifyCommand });
|
|
45
|
+
assert.equal(reminder.results[0].type, 'reminder');
|
|
46
|
+
const duplicateTick = await tickParkedTasks(root, { queue, now: '2026-08-13T00:00:01.000Z', notifyCommand });
|
|
47
|
+
assert.equal(duplicateTick.results[0].outcome, 'throttled');
|
|
48
|
+
const escalation = await tickParkedTasks(root, { queue, now: '2026-08-13T00:00:03.000Z', notifyCommand });
|
|
49
|
+
assert.equal(escalation.results[0].type, 'escalation');
|
|
50
|
+
|
|
51
|
+
await assert.rejects(
|
|
52
|
+
resumeParkedTask(root, { queue, taskId, recoverySignal: 'ssh banner verified' }),
|
|
53
|
+
/--verified/
|
|
54
|
+
);
|
|
55
|
+
const resumed = await resumeParkedTask(root, {
|
|
56
|
+
queue,
|
|
57
|
+
taskId,
|
|
58
|
+
verified: true,
|
|
59
|
+
recoverySignal: 'probe=vps-1;ssh_banner=verified',
|
|
60
|
+
now: '2026-08-13T00:00:04.000Z'
|
|
61
|
+
});
|
|
62
|
+
assert.equal(resumed.outcome, 'verified_and_requeued');
|
|
63
|
+
assert.equal(resumed.task.parked.state, 'runnable');
|
|
64
|
+
assert.equal(resumed.task.parked.authorization.state, 'unconsumed');
|
|
65
|
+
assert.equal(resumed.task.parked.execution_boundary.action_executed, false);
|
|
66
|
+
assert.ok(resumed.signalSha256);
|
|
67
|
+
|
|
68
|
+
const afterRestart = await resumeParkedTask(root, {
|
|
69
|
+
queue,
|
|
70
|
+
taskId,
|
|
71
|
+
verified: true,
|
|
72
|
+
recoverySignal: 'probe=vps-1;ssh_banner=verified'
|
|
73
|
+
});
|
|
74
|
+
assert.equal(afterRestart.outcome, 'already_resumed');
|
|
75
|
+
const durable = await readJson(path.join(queueSubdirFor(root, queue, 'inbox'), `${taskId}.json`));
|
|
76
|
+
assert.equal(durable.parked.execution_boundary.key, 'provider-call-1');
|
|
77
|
+
assert.equal(durable.parked.authorization.state, 'unconsumed');
|
|
78
|
+
console.log('human-gate-lifecycle-v2 self-test: ok');
|
|
79
|
+
} finally {
|
|
80
|
+
await rm(root, { recursive: true, force: true });
|
|
81
|
+
}
|
|
@@ -34,9 +34,9 @@ if (process.env.SYSTEMCTL_CAPTURE) await appendFile(process.env.SYSTEMCTL_CAPTUR
|
|
|
34
34
|
`);
|
|
35
35
|
await chmod(mockSystemctl, 0o755);
|
|
36
36
|
const installer = new URL('./openclaw-install.mjs', import.meta.url).pathname;
|
|
37
|
-
function run(args) {
|
|
37
|
+
function run(args, env = process.env) {
|
|
38
38
|
return new Promise((resolve) => {
|
|
39
|
-
const child = spawn(process.execPath, [installer, ...args], { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
39
|
+
const child = spawn(process.execPath, [installer, ...args], { env, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
40
40
|
let stdout = ''; let stderr = '';
|
|
41
41
|
child.stdout.on('data', (chunk) => { stdout += chunk; });
|
|
42
42
|
child.stderr.on('data', (chunk) => { stderr += chunk; });
|
|
@@ -46,7 +46,13 @@ function run(args) {
|
|
|
46
46
|
const installBase = ['--root', root, '--queue', 'test-tasks', '--openclaw-bin', mockOpenClaw, '--systemctl-bin', mockSystemctl];
|
|
47
47
|
const plan = await run([...installBase, '--json']);
|
|
48
48
|
const planReport = JSON.parse(plan.stdout);
|
|
49
|
-
if (plan.code !== 0 || planReport.status !== 'plan_only' || planReport.platform !== 'openclaw' || planReport.workerAgent !== 'builder' || planReport.workerSelection !== 'only_available' || !planReport.workerValidated || planReport.createsWorkerAgent || planReport.confirmationSummary?.targetPlatform !== 'OpenClaw' || planReport.confirmationSummary?.writesEnabled !== false || !path.isAbsolute(planReport.confirmationSummary?.platformCli || '') || !planReport.confirmationSummary?.notificationTarget.includes('OpenClaw')) throw new Error(`plan failed: ${plan.stderr}`);
|
|
49
|
+
if (plan.code !== 0 || planReport.language !== 'en' || planReport.status !== 'plan_only' || planReport.platform !== 'openclaw' || planReport.workerAgent !== 'builder' || planReport.workerSelection !== 'only_available' || !planReport.workerValidated || planReport.createsWorkerAgent || planReport.confirmationSummary?.targetPlatform !== 'OpenClaw' || planReport.confirmationSummary?.writesEnabled !== false || !path.isAbsolute(planReport.confirmationSummary?.platformCli || '') || !planReport.confirmationSummary?.notificationTarget.includes('OpenClaw')) throw new Error(`plan failed: ${plan.stderr}`);
|
|
50
|
+
const zhPlan = await run([...installBase, '--language', 'zh']);
|
|
51
|
+
if (zhPlan.code !== 0 || !zhPlan.stdout.includes('安装确认') || !zhPlan.stdout.includes('目标平台:OpenClaw') || !zhPlan.stdout.includes('允许写入:否(仅生成计划)')) throw new Error('explicit Chinese installation summary missing');
|
|
52
|
+
const autoZhPlan = await run([...installBase, '--json'], { ...process.env, LC_ALL: 'zh_CN.UTF-8', LANG: 'C' });
|
|
53
|
+
if (autoZhPlan.code !== 0 || JSON.parse(autoZhPlan.stdout).language !== 'zh') throw new Error('Chinese locale was not auto-detected');
|
|
54
|
+
const explicitEnglishPlan = await run([...installBase, '--language', 'en', '--json'], { ...process.env, LC_ALL: 'zh_CN.UTF-8' });
|
|
55
|
+
if (explicitEnglishPlan.code !== 0 || JSON.parse(explicitEnglishPlan.stdout).language !== 'en') throw new Error('explicit English did not override locale');
|
|
50
56
|
const humanPlan = await run(installBase);
|
|
51
57
|
if (humanPlan.code !== 0 || !humanPlan.stdout.includes('Installation confirmation') || !humanPlan.stdout.includes('target platform: OpenClaw') || !humanPlan.stdout.includes('writes enabled: no (plan only)')) throw new Error('human-readable OpenClaw confirmation summary missing');
|
|
52
58
|
const missingWorker = await run([...installBase, '--worker-agent', 'missing', '--json']);
|
|
@@ -55,14 +61,15 @@ const install = await run([...installBase, '--worker-agent', 'builder', '--confi
|
|
|
55
61
|
if (install.code !== 0 || JSON.parse(install.stdout).status !== 'installed') throw new Error(`install failed: ${install.stderr}`);
|
|
56
62
|
const queue = JSON.parse(await readFile(path.join(root, 'configs/loops/queues/test-tasks.json'), 'utf8'));
|
|
57
63
|
if (queue.dispatcher !== 'node scripts/loops/openclaw-loop-dispatch.mjs') throw new Error('dispatcher was not installed');
|
|
64
|
+
if (queue.language !== 'en') throw new Error('resolved installation language was not persisted');
|
|
58
65
|
if (queue.scheduler?.required !== true || queue.scheduler?.heartbeatMaxAgeMs !== 300000) throw new Error('required scheduler heartbeat was not installed');
|
|
59
66
|
const dispatcher = await readFile(path.join(root, 'scripts/loops/openclaw-loop-dispatch.mjs'), 'utf8');
|
|
60
67
|
if (!dispatcher.includes('already loop-managed') || !dispatcher.includes("'--agent', \"builder\"") || !dispatcher.includes('LOOP_LATEST_AMENDMENT_FILE') || !dispatcher.includes('LOOP_SESSION_GENERATION') || !dispatcher.includes('-g${sessionGeneration}')) throw new Error('worker, recursion guard, amendment polling, or session generation missing');
|
|
61
68
|
if (queue.retry?.runtimeRecoveryMaxAttempts !== 2 || queue.retry?.sessionMaxTicks !== 10) throw new Error('bounded runtime recovery policy was not installed');
|
|
62
69
|
const instructions = await readFile(path.join(root, 'AGENTS.md'), 'utf8');
|
|
63
|
-
if (!instructions.includes('
|
|
70
|
+
if (!instructions.includes('Use Loop Engineering to fix this issue') || !instructions.includes('Queue this only; do not run it yet') || /走 loop|只入队|只排队/.test(instructions)) throw new Error('English conversation instructions are missing or contain Chinese routing examples');
|
|
64
71
|
const wrapper = await readFile(path.join(root, 'scripts/loops/openclaw-loop.mjs'), 'utf8');
|
|
65
|
-
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('
|
|
72
|
+
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('queue') || !wrapper.includes('continue') || /只入队|只排队|继续当前/.test(wrapper) || !wrapper.includes('requiredSource') || !wrapper.includes('--source-message-id')) throw new Error('supersede/amend routing, source fail-closed policy, localization, absolute CLI, scheduler, live progress, async notification, or queue-only routing missing');
|
|
66
73
|
const missingSourceRoute = await new Promise((resolve) => {
|
|
67
74
|
const child = spawn(process.execPath, [path.join(root, 'scripts/loops/openclaw-loop.mjs'), 'route', '--message', '用 loop engineering 对齐系统'], { cwd: root, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
68
75
|
let stderr = ''; child.stderr.on('data', (chunk) => { stderr += chunk; });
|
|
@@ -172,4 +179,11 @@ if (await readFile(path.join(root, 'scripts/loops/openclaw-loop.mjs'), 'utf8').t
|
|
|
172
179
|
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');
|
|
173
180
|
const finalSystemctlCalls = await readFile(systemctlCapture, 'utf8');
|
|
174
181
|
if (!finalSystemctlCalls.includes('["--user","disable","--now","openclaw-loop-test-tasks-scheduler.timer"]')) throw new Error('scheduler timer was not disabled during uninstall');
|
|
182
|
+
const zhInstall = await run([...installBase, '--worker-agent', 'builder', '--language', 'zh', '--confirm-install', '--json']);
|
|
183
|
+
if (zhInstall.code !== 0 || JSON.parse(zhInstall.stdout).language !== 'zh') throw new Error(`Chinese installation failed: ${zhInstall.stderr}`);
|
|
184
|
+
const zhQueue = JSON.parse(await readFile(path.join(root, 'configs/loops/queues/test-tasks.json'), 'utf8'));
|
|
185
|
+
const zhInstructions = await readFile(path.join(root, 'AGENTS.md'), 'utf8');
|
|
186
|
+
const zhDispatcher = await readFile(path.join(root, 'scripts/loops/openclaw-loop-dispatch.mjs'), 'utf8');
|
|
187
|
+
const zhNotifier = await readFile(path.join(root, 'scripts/loops/openclaw-loop-notify.mjs'), 'utf8');
|
|
188
|
+
if (zhQueue.language !== 'zh' || !zhInstructions.includes('Loop Engineering 会话路由') || !zhDispatcher.includes('已经由 Loop Engineering 管理') || !zhNotifier.includes('通知器需要消息参数')) throw new Error('Chinese installation did not localize generated configuration and runtime files');
|
|
175
189
|
console.log('openclaw installer self-test passed');
|
|
@@ -5,7 +5,7 @@ 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', systemctlBin: 'systemctl', json: false, confirmInstall: false, force: false };
|
|
8
|
+
const out = { root: process.cwd(), queue: 'agent-tasks', workerAgent: null, openclawBin: 'openclaw', systemctlBin: 'systemctl', language: 'auto', 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]);
|
|
@@ -13,6 +13,7 @@ function parseArgs(argv) {
|
|
|
13
13
|
else if (arg === '--worker-agent') out.workerAgent = argv[++i];
|
|
14
14
|
else if (arg === '--openclaw-bin') out.openclawBin = argv[++i];
|
|
15
15
|
else if (arg === '--systemctl-bin') out.systemctlBin = argv[++i];
|
|
16
|
+
else if (arg === '--language') out.language = argv[++i];
|
|
16
17
|
else if (arg === '--confirm-install') out.confirmInstall = true;
|
|
17
18
|
else if (arg === '--force') out.force = true;
|
|
18
19
|
else if (arg === '--json') out.json = true;
|
|
@@ -22,6 +23,15 @@ function parseArgs(argv) {
|
|
|
22
23
|
return out;
|
|
23
24
|
}
|
|
24
25
|
|
|
26
|
+
function resolveLanguage(requested = 'auto', env = process.env) {
|
|
27
|
+
if (!['auto', 'en', 'zh'].includes(requested)) throw new Error('--language must be auto, en, or zh.');
|
|
28
|
+
if (requested !== 'auto') return requested;
|
|
29
|
+
const locale = String(env.LC_ALL || env.LC_MESSAGES || env.LANG || '').toLowerCase();
|
|
30
|
+
return /(^|[_.-])zh(?:[_-]|\.|$)/.test(locale) || locale.startsWith('zh') ? 'zh' : 'en';
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const text = (language, en, zh) => language === 'zh' ? zh : en;
|
|
34
|
+
|
|
25
35
|
function systemdEscapePath(value) {
|
|
26
36
|
return [...Buffer.from(String(value))]
|
|
27
37
|
.map((byte) => /[A-Za-z0-9/_.:-]/.test(String.fromCharCode(byte))
|
|
@@ -80,7 +90,17 @@ async function resolveExecutable(command) {
|
|
|
80
90
|
throw new Error(`Executable not found on PATH: ${command}`);
|
|
81
91
|
}
|
|
82
92
|
|
|
83
|
-
function formatConfirmationSummary(summary) {
|
|
93
|
+
function formatConfirmationSummary(summary, language) {
|
|
94
|
+
if (language === 'zh') return [
|
|
95
|
+
'安装确认',
|
|
96
|
+
` 目标平台:${summary.targetPlatform}`,
|
|
97
|
+
` 平台 CLI:${summary.platformCli}`,
|
|
98
|
+
` 工作区:${summary.workspace}`,
|
|
99
|
+
` 队列:${summary.queue}`,
|
|
100
|
+
` 调度器:${summary.scheduler}`,
|
|
101
|
+
` 通知目标:${summary.notificationTarget}`,
|
|
102
|
+
` 允许写入:${summary.writesEnabled ? '是' : '否(仅生成计划)'}`
|
|
103
|
+
].join('\n');
|
|
84
104
|
return [
|
|
85
105
|
'Installation confirmation',
|
|
86
106
|
` target platform: ${summary.targetPlatform}`,
|
|
@@ -93,25 +113,43 @@ function formatConfirmationSummary(summary) {
|
|
|
93
113
|
].join('\n');
|
|
94
114
|
}
|
|
95
115
|
|
|
96
|
-
function dispatcherSource({ workerAgent, openclawBin }) {
|
|
116
|
+
function dispatcherSource({ workerAgent, openclawBin, language }) {
|
|
117
|
+
const promptLines = language === 'zh' ? [
|
|
118
|
+
'你收到的是一个已经由 Loop Engineering 管理的任务。',
|
|
119
|
+
'不要再次路由或入队,即使引用的用户请求中包含 loop 触发词。',
|
|
120
|
+
'实施前先阅读任务合同、开发计划和验收计划。'
|
|
121
|
+
] : [
|
|
122
|
+
'You are receiving an already loop-managed task.',
|
|
123
|
+
'Do not route or enqueue this task again, even if its quoted request contains a loop trigger.',
|
|
124
|
+
'Read the task contract, development plan, and acceptance plan before implementation.'
|
|
125
|
+
];
|
|
126
|
+
const labels = language === 'zh' ? {
|
|
127
|
+
taskId: '任务 ID', taskContract: '任务合同', devPlan: '开发计划', acceptancePlan: '验收计划',
|
|
128
|
+
amendments: '实时补充要求', checkpoints: '检查点目录', missing: '未提供',
|
|
129
|
+
reread: '写入每个检查点以及最终完成前,如果实时补充要求文件存在,请重新读取。所有已记录的补充要求都是任务合同和验收标准的一部分。',
|
|
130
|
+
finish: '尽可能写入检查点,并包含最新已应用的 amendment_version。最终报告必须包含状态、证据、验证、阻塞和下一步。'
|
|
131
|
+
} : {
|
|
132
|
+
taskId: 'Task id', taskContract: 'Task contract', devPlan: 'Development plan', acceptancePlan: 'Acceptance plan',
|
|
133
|
+
amendments: 'Live amendments', checkpoints: 'Checkpoints dir', missing: 'not provided',
|
|
134
|
+
reread: 'Before writing each checkpoint and before final completion, reread the live amendment file if it exists. Treat every recorded amendment as part of the task contract and acceptance criteria.',
|
|
135
|
+
finish: 'Write a checkpoint when possible. Include the latest amendment_version applied. Finish with status, evidence, verification, blockers, and next action.'
|
|
136
|
+
};
|
|
97
137
|
return `#!/usr/bin/env node
|
|
98
138
|
import { readFile } from 'node:fs/promises';
|
|
99
139
|
import { spawn } from 'node:child_process';
|
|
100
140
|
const task = JSON.parse(await readFile(process.env.LOOP_TASK_FILE, 'utf8'));
|
|
101
141
|
const sessionGeneration = Number.parseInt(process.env.LOOP_SESSION_GENERATION || '0', 10) || 0;
|
|
102
142
|
const prompt = [
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
\`Live amendments: \${process.env.LOOP_LATEST_AMENDMENT_FILE || 'not provided'}\`,
|
|
111
|
-
\`Checkpoints dir: \${process.env.LOOP_CHECKPOINTS_DIR || 'not provided'}\`,
|
|
143
|
+
${promptLines.map((line) => JSON.stringify(line)).join(',\n ')},
|
|
144
|
+
\`${labels.taskId}: \${task.id}\`,
|
|
145
|
+
\`${labels.taskContract}: \${process.env.LOOP_TASK_CONTRACT_FILE || ${JSON.stringify(labels.missing)}}\`,
|
|
146
|
+
\`${labels.devPlan}: \${process.env.LOOP_DEV_PLAN_FILE || ${JSON.stringify(labels.missing)}}\`,
|
|
147
|
+
\`${labels.acceptancePlan}: \${process.env.LOOP_ACCEPTANCE_PLAN_FILE || ${JSON.stringify(labels.missing)}}\`,
|
|
148
|
+
\`${labels.amendments}: \${process.env.LOOP_LATEST_AMENDMENT_FILE || ${JSON.stringify(labels.missing)}}\`,
|
|
149
|
+
\`${labels.checkpoints}: \${process.env.LOOP_CHECKPOINTS_DIR || ${JSON.stringify(labels.missing)}}\`,
|
|
112
150
|
'', task.body, '',
|
|
113
|
-
|
|
114
|
-
|
|
151
|
+
${JSON.stringify(labels.reread)},
|
|
152
|
+
${JSON.stringify(labels.finish)}
|
|
115
153
|
].join('\\n');
|
|
116
154
|
const child = spawn(${JSON.stringify(openclawBin)}, [
|
|
117
155
|
'agent', '--agent', ${JSON.stringify(workerAgent)},
|
|
@@ -122,7 +160,15 @@ child.on('close', (code, signal) => { process.exitCode = code ?? (signal ? 128 :
|
|
|
122
160
|
`;
|
|
123
161
|
}
|
|
124
162
|
|
|
125
|
-
function wrapperSource({ queue, loopBin }) {
|
|
163
|
+
function wrapperSource({ queue, loopBin, language }) {
|
|
164
|
+
const missingSourceError = text(language, 'loop route requires conversation metadata', 'loop 路由需要会话来源元数据');
|
|
165
|
+
const usage = text(language, 'Usage: node scripts/loops/openclaw-loop.mjs route --message "Use loop: task" [source metadata]', '用法:node scripts/loops/openclaw-loop.mjs route --message "走 loop:任务" [来源元数据]');
|
|
166
|
+
const amendmentPattern = language === 'zh'
|
|
167
|
+
? '(?:继续(?:当前|这个)?\\s*loop|给(?:当前|这个)?\\s*loop\\s*(?:补充|增加|加)|补充当前\\s*loop)'
|
|
168
|
+
: '(?:continue\\s+(?:the\\s+)?(?:current\\s+)?loop|amend\\s+(?:the\\s+)?(?:current\\s+)?loop|add\\s+(?:this\\s+)?amendment\\s+to\\s+(?:the\\s+)?(?:current\\s+)?loop)';
|
|
169
|
+
const queueOnlyPattern = language === 'zh'
|
|
170
|
+
? '(?:只入队|只排队|暂不执行|不立即执行)'
|
|
171
|
+
: '(?:queue\\s+(?:this|it)\\s+only|only\\s+queue\\s+(?:this|it)|enqueue\\s+(?:this|it)\\s+only|do\\s+not\\s+(?:run|execute)\\s+(?:this|it)\\s+(?:yet|now))';
|
|
126
172
|
return `#!/usr/bin/env node
|
|
127
173
|
import { spawn } from 'node:child_process';
|
|
128
174
|
const [command, ...rest] = process.argv.slice(2);
|
|
@@ -151,14 +197,14 @@ if (command === 'route') {
|
|
|
151
197
|
const requiredSource = ['--source-channel', '--source-target', '--source-account', '--source-message-id'];
|
|
152
198
|
const missingSource = requiredSource.filter((name) => !optionValue(name));
|
|
153
199
|
if (missingSource.length) {
|
|
154
|
-
console.error(
|
|
200
|
+
console.error(\`${missingSourceError}: \${missingSource.join(', ')}\`);
|
|
155
201
|
process.exitCode = 2;
|
|
156
202
|
process.exit();
|
|
157
203
|
}
|
|
158
|
-
const amendment =
|
|
204
|
+
const amendment = new RegExp(${JSON.stringify(amendmentPattern)}, 'i').test(message);
|
|
159
205
|
const routeMode = amendment ? '--amend-active' : '--supersede-active';
|
|
160
206
|
const routeCode = await run(['route-message', '--queue', ${JSON.stringify(queue)}, '--route', '--confirm-execute', routeMode, ...rest]);
|
|
161
|
-
const queueOnly =
|
|
207
|
+
const queueOnly = new RegExp(${JSON.stringify(queueOnlyPattern)}, 'i').test(message);
|
|
162
208
|
const runCode = routeCode || queueOnly || amendment ? routeCode : await runWhenUnlocked(['run-queue', '--config', ${JSON.stringify(`configs/loops/queues/${queue}.json`)}, '--progress-notify-command', 'node scripts/loops/openclaw-loop-notify.mjs']);
|
|
163
209
|
const humanNotifyCode = await run(['queue-human-input-notify', '--queue', ${JSON.stringify(queue)}, '--notify-command', 'node scripts/loops/openclaw-loop-notify.mjs']);
|
|
164
210
|
const terminalNotifyCode = await run(['queue-terminal-notify', '--queue', ${JSON.stringify(queue)}, '--notify-command', 'node scripts/loops/openclaw-loop-notify.mjs']);
|
|
@@ -174,30 +220,33 @@ if (command === 'route') {
|
|
|
174
220
|
const terminalNotifyCode = await run(['queue-terminal-notify', '--queue', ${JSON.stringify(queue)}, '--notify-command', 'node scripts/loops/openclaw-loop-notify.mjs']);
|
|
175
221
|
process.exitCode = tickCode || humanNotifyCode || terminalNotifyCode;
|
|
176
222
|
} else {
|
|
177
|
-
console.error(
|
|
223
|
+
console.error(${JSON.stringify(usage)});
|
|
178
224
|
process.exitCode = 1;
|
|
179
225
|
}
|
|
180
226
|
`;
|
|
181
227
|
}
|
|
182
228
|
|
|
183
|
-
function schedulerServiceSource({ root, queue }) {
|
|
184
|
-
return `[Unit]\nDescription
|
|
229
|
+
function schedulerServiceSource({ root, queue, language }) {
|
|
230
|
+
return `[Unit]\nDescription=${text(language, `Taskforce Loop Engineering scheduler for ${queue}`, `${queue} 的 Taskforce Loop Engineering 调度器`)}\nAfter=default.target\n\n[Service]\nType=oneshot\nWorkingDirectory=${systemdEscapePath(root)}\nExecStart=${systemdEscapePath(process.execPath)} ${systemdEscapePath(path.join(root, 'scripts', 'loops', 'openclaw-loop.mjs'))} scheduler-tick --json\n`;
|
|
185
231
|
}
|
|
186
232
|
|
|
187
|
-
function schedulerTimerSource({ queue }) {
|
|
188
|
-
return `[Unit]\nDescription
|
|
233
|
+
function schedulerTimerSource({ queue, language }) {
|
|
234
|
+
return `[Unit]\nDescription=${text(language, `Wake Taskforce Loop Engineering scheduler for ${queue}`, `唤醒 ${queue} 的 Taskforce Loop Engineering 调度器`)}\n\n[Timer]\nOnBootSec=30s\nOnUnitActiveSec=1min\nAccuracySec=10s\nPersistent=true\nUnit=openclaw-loop-${queue}-scheduler.service\n\n[Install]\nWantedBy=timers.target\n`;
|
|
189
235
|
}
|
|
190
236
|
|
|
191
|
-
function notifierSource({ openclawBin }) {
|
|
237
|
+
function notifierSource({ openclawBin, language }) {
|
|
238
|
+
const missingMessage = text(language, 'loop notifier requires a message argument.', 'loop 通知器需要消息参数。');
|
|
239
|
+
const invalidSource = text(language, 'loop notifier received invalid source metadata.', 'loop 通知器收到无效的来源元数据。');
|
|
240
|
+
const unscoped = text(language, 'loop notifier requires source.channel and source.target; refusing an unscoped delivery.', 'loop 通知器需要 source.channel 和 source.target;拒绝无范围投递。');
|
|
192
241
|
return `#!/usr/bin/env node
|
|
193
242
|
import { spawn } from 'node:child_process';
|
|
194
243
|
const message = process.argv.slice(2).join(' ').trim();
|
|
195
244
|
const rawSource = process.env.LOOP_HUMAN_INPUT_SOURCE || process.env.LOOP_NOTIFICATION_SOURCE || '';
|
|
196
|
-
if (!message) { console.error(
|
|
245
|
+
if (!message) { console.error(${JSON.stringify(missingMessage)}); process.exit(2); }
|
|
197
246
|
let source;
|
|
198
|
-
try { source = JSON.parse(rawSource); } catch { console.error(
|
|
247
|
+
try { source = JSON.parse(rawSource); } catch { console.error(${JSON.stringify(invalidSource)}); process.exit(2); }
|
|
199
248
|
if (!source || typeof source !== 'object' || !source.channel || !source.target) {
|
|
200
|
-
console.error(
|
|
249
|
+
console.error(${JSON.stringify(unscoped)});
|
|
201
250
|
process.exit(2);
|
|
202
251
|
}
|
|
203
252
|
const args = ['message', 'send', '--channel', String(source.channel), '--target', String(source.target), '--message', message, '--json'];
|
|
@@ -209,12 +258,25 @@ child.on('close', (code, signal) => { process.exitCode = code ?? (signal ? 128 :
|
|
|
209
258
|
`;
|
|
210
259
|
}
|
|
211
260
|
|
|
212
|
-
function instructionsBlock({ queue }) {
|
|
261
|
+
function instructionsBlock({ queue, language }) {
|
|
262
|
+
if (language === 'zh') return `\n<!-- loop-engineering:openclaw:start -->
|
|
263
|
+
## Loop Engineering 会话路由
|
|
264
|
+
|
|
265
|
+
- 仅路由明确要求使用 Loop 的请求。\`走 loop\` 表示入队并立即执行一次;只有 \`只入队\` 或 \`只排队\` 才不立即执行。
|
|
266
|
+
- \`用 loop engineering\`、\`丢进 Ironman loop\`、\`loop Ironman\` 和 \`task-runner\` 也属于明确的 Loop 请求。
|
|
267
|
+
- 从当前工作区运行 \`node scripts/loops/openclaw-loop.mjs route --message "<完整用户消息>"\`,并保留来源元数据。
|
|
268
|
+
- 会话来源任务必须使用标准包装器。缺少来源元数据时必须失败关闭,不能退回手工入队或直接运行队列。
|
|
269
|
+
- 人工门禁或终态只有在通知命令成功并写入通知记录后才算已送达。
|
|
270
|
+
- 已由 Loop 管理的任务必须直接执行,不能再次路由。
|
|
271
|
+
- 状态查询只读。高风险外部动作、破坏性操作、生产变更、凭据操作和记忆迁移仍需单独确认。
|
|
272
|
+
- 队列:\`${queue}\`。
|
|
273
|
+
<!-- loop-engineering:openclaw:end -->\n`;
|
|
213
274
|
return `\n<!-- loop-engineering:openclaw:start -->
|
|
214
275
|
## Loop Engineering conversation routing
|
|
215
276
|
|
|
216
|
-
- Route only explicit
|
|
217
|
-
-
|
|
277
|
+
- Route only explicit Loop Engineering requests. For example, \`Use Loop Engineering to fix this issue\` and \`Run this through Loop Engineering\` enqueue the request and immediately execute one tick.
|
|
278
|
+
- \`Queue this only; do not run it yet\` suppresses immediate execution. \`Continue the current loop with this amendment: ...\` amends the active task instead of replacing it.
|
|
279
|
+
- Treat explicit references to \`Loop Engineering\`, \`the loop\`, \`task-runner\`, or a named loop queue as Loop requests.
|
|
218
280
|
- Run \`node scripts/loops/openclaw-loop.mjs route --message "<full user message>"\` from this workspace and preserve source metadata when available.
|
|
219
281
|
- For conversation-originated work, the standard wrapper is mandatory. Missing source metadata must fail closed; never fall back to manual enqueue or direct run-queue.
|
|
220
282
|
- A human-gated or terminal state is not delivered until its notification command succeeds and writes a notification record.
|
|
@@ -226,8 +288,9 @@ function instructionsBlock({ queue }) {
|
|
|
226
288
|
|
|
227
289
|
async function main() {
|
|
228
290
|
const args = parseArgs(process.argv.slice(2));
|
|
291
|
+
args.language = resolveLanguage(args.language);
|
|
229
292
|
if (args.help) {
|
|
230
|
-
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]');
|
|
293
|
+
console.log('Usage: loop-engineering-openclaw-install [--root workspace] [--queue agent-tasks] [--worker-agent agent-id] [--language auto|en|zh] [--openclaw-bin openclaw] [--systemctl-bin systemctl] [--confirm-install] [--force] [--json]');
|
|
231
294
|
return;
|
|
232
295
|
}
|
|
233
296
|
safeId(args.queue, 'queue');
|
|
@@ -252,11 +315,11 @@ async function main() {
|
|
|
252
315
|
};
|
|
253
316
|
const conflicts = [];
|
|
254
317
|
for (const [kind, file] of Object.entries(files)) if (!['instructions', 'workspaceHealth', 'manifest'].includes(kind) && await exists(file)) conflicts.push(path.relative(args.root, file));
|
|
255
|
-
const confirmationSummary = { targetPlatform: 'OpenClaw', platformCli: args.openclawBin, workspace: args.root, queue: args.queue, scheduler: `systemd user timer ${schedulerTimer}`, notificationTarget: 'source-bound at runtime (original OpenClaw conversation)', writesEnabled: args.confirmInstall };
|
|
256
|
-
const report = { version: 1, platform: 'openclaw', 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 }, confirmationSummary, files: Object.fromEntries(Object.entries(files).map(([key, file]) => [key, path.relative(args.root, file)])), conflicts };
|
|
257
|
-
report.next = args.confirmInstall ? 'Run loop-engineering-openclaw-doctor, then route a harmless smoke task.' : 'Review this plan, then rerun with --confirm-install.';
|
|
318
|
+
const confirmationSummary = { targetPlatform: 'OpenClaw', platformCli: args.openclawBin, workspace: args.root, queue: args.queue, scheduler: `systemd user timer ${schedulerTimer}`, notificationTarget: text(args.language, 'source-bound at runtime (original OpenClaw conversation)', '运行时绑定到原始 OpenClaw 会话'), writesEnabled: args.confirmInstall };
|
|
319
|
+
const report = { version: 1, platform: 'openclaw', language: args.language, 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 }, confirmationSummary, files: Object.fromEntries(Object.entries(files).map(([key, file]) => [key, path.relative(args.root, file)])), conflicts };
|
|
320
|
+
report.next = args.confirmInstall ? text(args.language, 'Run loop-engineering-openclaw-doctor, then route a harmless smoke task.', '运行 loop-engineering-openclaw-doctor,然后路由一个无害的冒烟任务。') : text(args.language, 'Review this plan, then rerun with --confirm-install.', '检查此计划,然后使用 --confirm-install 重新运行。');
|
|
258
321
|
if (conflicts.length && !args.force && args.confirmInstall) throw new Error(`Refusing to overwrite: ${conflicts.join(', ')}. Use --force after review.`);
|
|
259
|
-
if (!args.json) console.log(formatConfirmationSummary(confirmationSummary));
|
|
322
|
+
if (!args.json) console.log(formatConfirmationSummary(confirmationSummary, args.language));
|
|
260
323
|
if (args.confirmInstall) {
|
|
261
324
|
await mkdir(path.dirname(files.queueConfig), { recursive: true });
|
|
262
325
|
await mkdir(path.dirname(files.dispatcher), { recursive: true });
|
|
@@ -271,6 +334,7 @@ async function main() {
|
|
|
271
334
|
}
|
|
272
335
|
const queueContent = `${JSON.stringify({
|
|
273
336
|
queue: args.queue,
|
|
337
|
+
language: args.language,
|
|
274
338
|
description: `OpenClaw conversation queue dispatched to agent ${args.workerAgent}.`,
|
|
275
339
|
dispatcher: 'node scripts/loops/openclaw-loop-dispatch.mjs',
|
|
276
340
|
preflightConfig: 'configs/loops/workspace-health.json',
|
|
@@ -300,7 +364,7 @@ async function main() {
|
|
|
300
364
|
if (!instructions.includes('<!-- loop-engineering:openclaw:start -->')) await appendFile(files.instructions, managedInstructions);
|
|
301
365
|
await mkdir(path.dirname(files.manifest), { recursive: true });
|
|
302
366
|
await writeFile(files.manifest, `${JSON.stringify({
|
|
303
|
-
version:
|
|
367
|
+
version: 3, queue: args.queue, language: args.language, workerAgent: args.workerAgent, openclawBin: args.openclawBin, systemctlBin: args.systemctlBin, installedAt: new Date().toISOString(),
|
|
304
368
|
managedFiles: [
|
|
305
369
|
{ path: path.relative(args.root, files.queueConfig), sha256: sha256(queueContent) },
|
|
306
370
|
{ path: path.relative(args.root, files.dispatcher), sha256: sha256(dispatcherContent) },
|
|
@@ -315,7 +379,7 @@ async function main() {
|
|
|
315
379
|
retainedOnUninstall: [`runtime/loops/${args.queue}`]
|
|
316
380
|
}, null, 2)}\n`);
|
|
317
381
|
}
|
|
318
|
-
console.log(args.json ? JSON.stringify(report, null, 2) : `OpenClaw integration: ${report.status}\nconflicts: ${report.conflicts.join(', ') || 'none'}\nnext: ${report.next}`);
|
|
382
|
+
console.log(args.json ? JSON.stringify(report, null, 2) : text(args.language, `OpenClaw integration: ${report.status}\nconflicts: ${report.conflicts.join(', ') || 'none'}\nnext: ${report.next}`, `OpenClaw 集成:${report.status}\n冲突:${report.conflicts.join(', ') || '无'}\n下一步:${report.next}`));
|
|
319
383
|
}
|
|
320
384
|
|
|
321
385
|
main().catch((error) => { console.error(error instanceof Error ? error.message : String(error)); process.exitCode = 1; });
|