taskforce-loop-engineering 0.15.13 → 0.15.15
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 +14 -0
- package/bin/loop-engineering.mjs +15 -2
- package/docs/agent-team-backlog.json +1 -0
- package/docs/agent-team-terminal-contract.json +1 -0
- package/docs/human-gate-command.md +17 -0
- package/docs/multi-agent-control-plane.md +8 -0
- package/docs/quota-runtime-decision.md +9 -0
- package/lib/core.mjs +74 -10
- package/lib/human-gate-channel-adapter.mjs +37 -0
- package/lib/human-gate-command.mjs +161 -0
- package/lib/operator-dashboard.mjs +24 -3
- package/lib/quota-runtime-decision.mjs +62 -0
- package/lib/todo-control-plane.mjs +105 -6
- package/package.json +19 -6
- package/scripts/agent-team-control-plane-self-test.mjs +29 -0
- package/scripts/agent-team-final-judgement.mjs +27 -0
- package/scripts/distribution-skill-self-test.mjs +13 -3
- package/scripts/final-judgement-self-test.mjs +25 -0
- package/scripts/human-gate-command-self-test.mjs +52 -0
- package/scripts/human-gate-final-judgement.mjs +30 -0
- package/scripts/live-agent-team-conformance.mjs +61 -0
- package/scripts/openclaw-doctor.mjs +8 -0
- package/scripts/openclaw-install-self-test.mjs +4 -2
- package/scripts/openclaw-install.mjs +38 -3
- package/scripts/openclaw-smoke.mjs +4 -0
- package/scripts/operator-dashboard-self-test.mjs +2 -1
- package/scripts/project-gate-reconciliation-self-test.mjs +27 -12
- package/scripts/quota-runtime-decision-self-test.mjs +29 -0
- package/scripts/route-notify-self-test.mjs +4 -4
- package/scripts/todo-control-plane-self-test.mjs +4 -1
- package/skills/taskforce-loop-engineering/SKILL.md +8 -0
- package/skills/taskforce-loop-engineering/references/npm-package.md +1 -1
|
@@ -104,6 +104,8 @@ if (schedulerTick.code !== 0) throw new Error(`installed scheduler tick failed:
|
|
|
104
104
|
const schedulerState = JSON.parse(await readFile(path.join(root, 'runtime/loops/test-tasks/scheduler/state.json'), 'utf8'));
|
|
105
105
|
if (!schedulerState.generatedAt || !schedulerState.nextRunAt) throw new Error('installed scheduler tick did not persist its heartbeat and cadence');
|
|
106
106
|
const notifier = await readFile(path.join(root, 'scripts/loops/openclaw-loop-notify.mjs'), 'utf8');
|
|
107
|
+
const gateBridge = await readFile(path.join(root, 'scripts/loops/openclaw-loop-gate.mjs'), 'utf8');
|
|
108
|
+
if (!gateBridge.includes('feishu_signature_unverified') || !gateBridge.includes('ignored_untrusted_chat') || !gateBridge.includes('handleChannelGateEvent')) throw new Error('installed Human Gate bridge is incomplete');
|
|
107
109
|
if (!notifier.includes("'message', 'send'") || !notifier.includes('source.channel') || !notifier.includes('source.target')) throw new Error('channel-neutral notifier missing');
|
|
108
110
|
const delivery = await new Promise((resolve) => {
|
|
109
111
|
const child = spawn(process.execPath, [path.join(root, 'scripts/loops/openclaw-loop-notify.mjs'), 'async result'], {
|
|
@@ -129,7 +131,7 @@ const doctorResult = await new Promise((resolve) => {
|
|
|
129
131
|
});
|
|
130
132
|
if (doctorResult.code !== 0) throw new Error(`doctor failed: ${doctorResult.stderr}`);
|
|
131
133
|
const doctorReport = JSON.parse(doctorResult.stdout);
|
|
132
|
-
if (doctorReport.status !== 'ok' || doctorReport.externalWrite !== false || !doctorReport.checks.some((check) => check.id === 'notification_dry_run' && check.ok)) throw new Error('doctor did not complete
|
|
134
|
+
if (doctorReport.status !== 'ok' || doctorReport.externalWrite !== false || !doctorReport.checks.some((check) => check.id === 'notification_dry_run' && check.ok) || !doctorReport.checks.some((check) => check.id === 'human_gate_bridge_self_test' && check.ok)) throw new Error('doctor did not complete safe notification and Human Gate self-tests');
|
|
133
135
|
const smoke = new URL('./openclaw-smoke.mjs', import.meta.url).pathname;
|
|
134
136
|
const smokeSource = await readFile(smoke, 'utf8');
|
|
135
137
|
if (!smokeSource.includes('Do not change user or project files, configuration, credentials, or external state.')
|
|
@@ -151,7 +153,7 @@ const smokeReport = JSON.parse(smokeResult.stdout);
|
|
|
151
153
|
if (smokeReport.status !== 'ok' || smokeReport.externalWrite !== false || !smokeReport.steps.every((step) => step.ok)) throw new Error('end-to-end smoke did not pass safely');
|
|
152
154
|
try { await readFile(path.join(root, `configs/loops/queues/${smokeReport.smokeQueue}.json`)); throw new Error('smoke config was not cleaned'); } catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
153
155
|
try { await readFile(path.join(root, `runtime/loops/${smokeReport.smokeQueue}/state.json`)); throw new Error('smoke runtime was not cleaned'); } catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
154
|
-
for (const generated of ['scripts/loops/openclaw-loop-dispatch.mjs', 'scripts/loops/openclaw-loop.mjs', 'scripts/loops/openclaw-loop-notify.mjs']) {
|
|
156
|
+
for (const generated of ['scripts/loops/openclaw-loop-dispatch.mjs', 'scripts/loops/openclaw-loop.mjs', 'scripts/loops/openclaw-loop-notify.mjs', 'scripts/loops/openclaw-loop-gate.mjs']) {
|
|
155
157
|
const syntax = await run(['--help']);
|
|
156
158
|
if (syntax.code !== 0) throw new Error(`installer help failed while checking ${generated}`);
|
|
157
159
|
const check = await new Promise((resolve) => {
|
|
@@ -181,6 +181,7 @@ function run(args) {
|
|
|
181
181
|
child.on('close', (code, signal) => resolve(code ?? (signal ? 128 : 1)));
|
|
182
182
|
});
|
|
183
183
|
}
|
|
184
|
+
|
|
184
185
|
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
185
186
|
async function runWhenUnlocked(args, waitMs = 300000) {
|
|
186
187
|
const deadline = Date.now() + waitMs;
|
|
@@ -229,6 +230,31 @@ if (command === 'route') {
|
|
|
229
230
|
`;
|
|
230
231
|
}
|
|
231
232
|
|
|
233
|
+
function gateBridgeSource({ humanGateAdapter }) {
|
|
234
|
+
return `#!/usr/bin/env node
|
|
235
|
+
import { handleChannelGateEvent, normalizeFeishuGateEvent } from ${JSON.stringify(humanGateAdapter)};
|
|
236
|
+
if (process.argv.includes('--self-test')) {
|
|
237
|
+
let rejected = false;
|
|
238
|
+
try { normalizeFeishuGateEvent({ event: {} }); } catch (error) { rejected = error?.message === 'feishu_signature_unverified'; }
|
|
239
|
+
if (!rejected) throw new Error('feishu_signature_boundary_not_fail_closed');
|
|
240
|
+
const ignored = await handleChannelGateEvent(process.env.LOOP_WORKSPACE_ROOT || process.cwd(), { kind: 'ordinary_message', text: 'approve the first one' });
|
|
241
|
+
if (ignored.outcome !== 'ignored_untrusted_chat') throw new Error('ordinary_chat_not_fail_closed');
|
|
242
|
+
process.stdout.write(JSON.stringify({ status: 'ok', externalWrite: false, signatureBoundary: 'fail_closed', ordinaryChat: 'ignored' }) + '\\n');
|
|
243
|
+
process.exit(0);
|
|
244
|
+
}
|
|
245
|
+
const chunks = [];
|
|
246
|
+
for await (const chunk of process.stdin) chunks.push(chunk);
|
|
247
|
+
if (!chunks.length) throw new Error('gate_event_required_on_stdin');
|
|
248
|
+
const payload = JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
249
|
+
const channel = String(process.env.LOOP_GATE_CHANNEL || payload.channel || '').toLowerCase();
|
|
250
|
+
const event = channel === 'feishu'
|
|
251
|
+
? normalizeFeishuGateEvent(payload, { signatureVerified: process.env.LOOP_FEISHU_SIGNATURE_VERIFIED === '1' })
|
|
252
|
+
: payload;
|
|
253
|
+
const result = await handleChannelGateEvent(process.env.LOOP_WORKSPACE_ROOT || process.cwd(), event);
|
|
254
|
+
process.stdout.write(JSON.stringify(result) + '\\n');
|
|
255
|
+
`;
|
|
256
|
+
}
|
|
257
|
+
|
|
232
258
|
function schedulerServiceSource({ root, queue, language }) {
|
|
233
259
|
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`;
|
|
234
260
|
}
|
|
@@ -270,6 +296,8 @@ function instructionsBlock({ queue, language }) {
|
|
|
270
296
|
- 从当前工作区运行 \`node scripts/loops/openclaw-loop.mjs route --message "<完整用户消息>"\`,并保留来源元数据。
|
|
271
297
|
- 会话来源任务必须使用标准包装器。缺少来源元数据时必须失败关闭,不能退回手工入队或直接运行队列。
|
|
272
298
|
- 人工门禁或终态只有在通知命令成功并写入通知记录后才算已送达。
|
|
299
|
+
- Dashboard 与聊天审批必须共用 Gate Command 和同一份门禁状态;聊天接入使用 \`scripts/loops/openclaw-loop-gate.mjs\`。
|
|
300
|
+
- 飞书回调必须先由可信传输层验签,再设置 \`LOOP_FEISHU_SIGNATURE_VERIFIED=1\` 调用 Gate bridge;未验签失败关闭。普通聊天、引用、转发和截图不能审批。
|
|
273
301
|
- 已由 Loop 管理的任务必须直接执行,不能再次路由。
|
|
274
302
|
- 状态查询只读。高风险外部动作、破坏性操作、生产变更、凭据操作和记忆迁移仍需单独确认。
|
|
275
303
|
- 队列:\`${queue}\`。
|
|
@@ -283,6 +311,8 @@ function instructionsBlock({ queue, language }) {
|
|
|
283
311
|
- Run \`node scripts/loops/openclaw-loop.mjs route --message "<full user message>"\` from this workspace and preserve source metadata when available.
|
|
284
312
|
- 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.
|
|
285
313
|
- A human-gated or terminal state is not delivered until its notification command succeeds and writes a notification record.
|
|
314
|
+
- Dashboard and chat approvals must share the Gate Command core and one gate state; chat transports use \`scripts/loops/openclaw-loop-gate.mjs\`.
|
|
315
|
+
- A trusted transport must verify Feishu callbacks before setting \`LOOP_FEISHU_SIGNATURE_VERIFIED=1\`; unverified callbacks fail closed. Ordinary chat, quotes, forwards, and screenshots cannot approve.
|
|
286
316
|
- An already loop-managed task must be executed directly and never routed again.
|
|
287
317
|
- Status questions are read-only. High-risk external, destructive, production, credential, or memory migration actions remain separately gated.
|
|
288
318
|
- Queue: \`${queue}\`.
|
|
@@ -302,6 +332,7 @@ async function main() {
|
|
|
302
332
|
const worker = await resolveWorkerAgent(args);
|
|
303
333
|
args.workerAgent = worker.workerAgent;
|
|
304
334
|
args.loopBin = new URL('../bin/loop-engineering.mjs', import.meta.url).pathname;
|
|
335
|
+
args.humanGateAdapter = new URL('../lib/human-gate-channel-adapter.mjs', import.meta.url).href;
|
|
305
336
|
const systemdUserDir = path.join(process.env.XDG_CONFIG_HOME || path.join(process.env.HOME || '', '.config'), 'systemd', 'user');
|
|
306
337
|
const schedulerUnit = `openclaw-loop-${args.queue}-scheduler.service`;
|
|
307
338
|
const schedulerTimer = `openclaw-loop-${args.queue}-scheduler.timer`;
|
|
@@ -311,6 +342,7 @@ async function main() {
|
|
|
311
342
|
dispatcher: path.join(args.root, 'scripts', 'loops', 'openclaw-loop-dispatch.mjs'),
|
|
312
343
|
wrapper: path.join(args.root, 'scripts', 'loops', 'openclaw-loop.mjs'),
|
|
313
344
|
notifier: path.join(args.root, 'scripts', 'loops', 'openclaw-loop-notify.mjs'),
|
|
345
|
+
gateBridge: path.join(args.root, 'scripts', 'loops', 'openclaw-loop-gate.mjs'),
|
|
314
346
|
manifest: path.join(args.root, 'runtime', 'loop-engineering-openclaw-install.json'),
|
|
315
347
|
instructions: path.join(args.root, 'AGENTS.md'),
|
|
316
348
|
schedulerService: path.join(systemdUserDir, schedulerUnit),
|
|
@@ -319,7 +351,7 @@ async function main() {
|
|
|
319
351
|
const conflicts = [];
|
|
320
352
|
for (const [kind, file] of Object.entries(files)) if (!['instructions', 'workspaceHealth', 'manifest'].includes(kind) && await exists(file)) conflicts.push(path.relative(args.root, file));
|
|
321
353
|
const dashboardDescription = args.dashboardListen === 'tailscale' ? 'read-only Tailnet address on port 4174 coupled to openclaw-gateway.service' : 'read-only http://127.0.0.1:4174/ coupled to openclaw-gateway.service';
|
|
322
|
-
const confirmationSummary = { targetPlatform: 'OpenClaw', platformCli: args.openclawBin, workspace: args.root, queue: args.queue, scheduler: `systemd user timer ${schedulerTimer}`, dashboard: dashboardDescription, notificationTarget: text(args.language, 'source-bound at runtime (original OpenClaw conversation)', '运行时绑定到原始 OpenClaw 会话'), writesEnabled: args.confirmInstall };
|
|
354
|
+
const confirmationSummary = { targetPlatform: 'OpenClaw', platformCli: args.openclawBin, workspace: args.root, queue: args.queue, scheduler: `systemd user timer ${schedulerTimer}`, dashboard: dashboardDescription, humanGate: 'Dashboard + source-bound chat Gate Command bridge', notificationTarget: text(args.language, 'source-bound at runtime (original OpenClaw conversation)', '运行时绑定到原始 OpenClaw 会话'), writesEnabled: args.confirmInstall };
|
|
323
355
|
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 }, dashboardAutostart: { required: true, listen: args.dashboardListen, address: args.dashboardListen === 'localhost' ? 'http://127.0.0.1:4174/' : null, gateway: 'openclaw-gateway.service' }, confirmationSummary, files: Object.fromEntries(Object.entries(files).map(([key, file]) => [key, path.relative(args.root, file)])), conflicts };
|
|
324
356
|
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 重新运行。');
|
|
325
357
|
if (conflicts.length && !args.force && args.confirmInstall) throw new Error(`Refusing to overwrite: ${conflicts.join(', ')}. Use --force after review.`);
|
|
@@ -350,12 +382,14 @@ async function main() {
|
|
|
350
382
|
const dispatcherContent = dispatcherSource(args);
|
|
351
383
|
const wrapperContent = wrapperSource(args);
|
|
352
384
|
const notifierContent = notifierSource(args);
|
|
385
|
+
const gateBridgeContent = gateBridgeSource(args);
|
|
353
386
|
const schedulerServiceContent = schedulerServiceSource(args);
|
|
354
387
|
const schedulerTimerContent = schedulerTimerSource(args);
|
|
355
388
|
await writeFile(files.queueConfig, queueContent);
|
|
356
389
|
await writeFile(files.dispatcher, dispatcherContent);
|
|
357
390
|
await writeFile(files.wrapper, wrapperContent);
|
|
358
391
|
await writeFile(files.notifier, notifierContent);
|
|
392
|
+
await writeFile(files.gateBridge, gateBridgeContent);
|
|
359
393
|
await mkdir(systemdUserDir, { recursive: true });
|
|
360
394
|
await writeFile(files.schedulerService, schedulerServiceContent);
|
|
361
395
|
await writeFile(files.schedulerTimer, schedulerTimerContent);
|
|
@@ -371,12 +405,13 @@ async function main() {
|
|
|
371
405
|
if (!instructions.includes('<!-- loop-engineering:openclaw:start -->')) await appendFile(files.instructions, managedInstructions);
|
|
372
406
|
await mkdir(path.dirname(files.manifest), { recursive: true });
|
|
373
407
|
await writeFile(files.manifest, `${JSON.stringify({
|
|
374
|
-
version:
|
|
408
|
+
version: 4, queue: args.queue, language: args.language, workerAgent: args.workerAgent, openclawBin: args.openclawBin, systemctlBin: args.systemctlBin, installedAt: new Date().toISOString(),
|
|
375
409
|
managedFiles: [
|
|
376
410
|
{ path: path.relative(args.root, files.queueConfig), sha256: sha256(queueContent) },
|
|
377
411
|
{ path: path.relative(args.root, files.dispatcher), sha256: sha256(dispatcherContent) },
|
|
378
412
|
{ path: path.relative(args.root, files.wrapper), sha256: sha256(wrapperContent) },
|
|
379
|
-
{ path: path.relative(args.root, files.notifier), sha256: sha256(notifierContent) }
|
|
413
|
+
{ path: path.relative(args.root, files.notifier), sha256: sha256(notifierContent) },
|
|
414
|
+
{ path: path.relative(args.root, files.gateBridge), sha256: sha256(gateBridgeContent) }
|
|
380
415
|
],
|
|
381
416
|
managedUnits: [
|
|
382
417
|
{ path: files.schedulerService, unit: schedulerUnit, sha256: sha256(schedulerServiceContent) },
|
|
@@ -53,6 +53,10 @@ async function main() {
|
|
|
53
53
|
const doctor = await run(process.execPath, [doctorScript, '--root', args.root, '--queue', args.queue, '--worker-agent', args.workerAgent, '--openclaw-bin', args.openclawBin, '--json'], { cwd: args.root });
|
|
54
54
|
steps.push({ id: 'doctor', ok: doctor.code === 0 });
|
|
55
55
|
if (doctor.code !== 0) throw new Error(`doctor failed: ${doctor.stderr || doctor.stdout}`);
|
|
56
|
+
const doctorReport = JSON.parse(doctor.stdout);
|
|
57
|
+
const gateSelfTestOk = doctorReport.checks?.some((check) => check.id === 'human_gate_bridge_self_test' && check.ok);
|
|
58
|
+
steps.push({ id: 'human_gate_bridge_self_test', ok: gateSelfTestOk === true });
|
|
59
|
+
if (!gateSelfTestOk) throw new Error('doctor did not verify the installed Human Gate bridge');
|
|
56
60
|
const config = JSON.parse(await readFile(baseConfig, 'utf8'));
|
|
57
61
|
config.queue = smokeQueue;
|
|
58
62
|
config.description = 'Temporary read-only OpenClaw integration smoke queue.';
|
|
@@ -90,7 +90,8 @@ try {
|
|
|
90
90
|
assert.match(page, /No matching operational work/);
|
|
91
91
|
assert.match(page, /Unable to load workspace/);
|
|
92
92
|
assert.match(page, /Gates & reservations/);
|
|
93
|
-
assert.
|
|
93
|
+
assert.match(page, /approve/);
|
|
94
|
+
assert.doesNotMatch(page, /confirm-send|settle reservation/i);
|
|
94
95
|
const project = await fetch(`${base}/api/v1/projects/p3`).then((response) => response.json());
|
|
95
96
|
assert.equal(project.terminal_contract.milestone_rule, 'A milestone is not project completion.');
|
|
96
97
|
assert.doesNotMatch(page, /<img src=x/);
|
|
@@ -40,7 +40,7 @@ assert(authorizedContract.constraints.allowed_actions.includes('paid_provider_ac
|
|
|
40
40
|
assert(authorizedContract.constraints.blocked_actions.includes('credential_change_without_explicit_confirmation'));
|
|
41
41
|
await writeFile(projectFile, `${JSON.stringify(spec, null, 2)}\n`);
|
|
42
42
|
|
|
43
|
-
const b = await enqueueTask(root, { queue, title: 'OpenReel B-01', task: 'Project openreel requirement B-01', projectId: 'openreel', sourceChannel: 'test', sourceTarget: 'owner' });
|
|
43
|
+
const b = await enqueueTask(root, { queue, title: 'OpenReel B-01', task: 'Project openreel requirement B-01', projectId: 'openreel', sourceChannel: 'test', sourceTarget: 'owner', sourceAccount: 'main' });
|
|
44
44
|
const checkpointDir = path.join(taskRuntimeDirFor(root, queue, b.task.id), 'checkpoints');
|
|
45
45
|
await mkdir(checkpointDir, { recursive: true });
|
|
46
46
|
await writeFile(path.join(checkpointDir, 'cp1.json'), `${JSON.stringify({ version: 1, task_id: b.task.id, checkpoint_id: 'cp1', milestone_id: 'B-01', requirement_ids: ['B-01'], status: 'needs_human_input', blockers: ['Authorize B-01'] }, null, 2)}\n`);
|
|
@@ -132,7 +132,7 @@ await writeFile(projectFile, `${JSON.stringify({
|
|
|
132
132
|
|
|
133
133
|
// A ready milestone with project in progress and a deferred authorization is
|
|
134
134
|
// converted into a structured waiting gate, not left as prose on a done task.
|
|
135
|
-
const deferred = await enqueueTask(root, { queue, title: 'OpenReel deferred S-01', task: 'Project openreel S-01', projectId: 'openreel', sourceChannel: 'test', sourceTarget: 'owner' });
|
|
135
|
+
const deferred = await enqueueTask(root, { queue, title: 'OpenReel deferred S-01', task: 'Project openreel S-01', projectId: 'openreel', sourceChannel: 'test', sourceTarget: 'owner', sourceAccount: 'main' });
|
|
136
136
|
const deferredDir = path.join(taskRuntimeDirFor(root, queue, deferred.task.id), 'checkpoints');
|
|
137
137
|
await mkdir(deferredDir, { recursive: true });
|
|
138
138
|
await writeFile(path.join(deferredDir, 'cp-ready.json'), `${JSON.stringify({
|
|
@@ -164,7 +164,7 @@ await writeFile(authoritativeBacklog, `${JSON.stringify({
|
|
|
164
164
|
{ id: 'LOCAL-02', status: 'pending', dependsOn: ['LOCAL-01'] }
|
|
165
165
|
]
|
|
166
166
|
}, null, 2)}\n`);
|
|
167
|
-
const futureGateTask = await enqueueTask(root, { queue, title: 'OpenReel future production gate', task: 'Continue safe local backlog', projectId: 'openreel', sourceChannel: 'test', sourceTarget: 'owner' });
|
|
167
|
+
const futureGateTask = await enqueueTask(root, { queue, title: 'OpenReel future production gate', task: 'Continue safe local backlog', projectId: 'openreel', sourceChannel: 'test', sourceTarget: 'owner', sourceAccount: 'main' });
|
|
168
168
|
const futureGateDir = path.join(taskRuntimeDirFor(root, queue, futureGateTask.task.id), 'checkpoints');
|
|
169
169
|
await mkdir(futureGateDir, { recursive: true });
|
|
170
170
|
await writeFile(path.join(futureGateDir, 'cp1.json'), `${JSON.stringify({
|
|
@@ -180,7 +180,7 @@ assert.equal((await queueStatus(root, queue)).waiting, 0);
|
|
|
180
180
|
// deferred_gates must not materialize a gate without a concrete action and
|
|
181
181
|
// authority requirement.
|
|
182
182
|
await reconcileProjectGates(root, { queue });
|
|
183
|
-
const conditional = await enqueueTask(root, { queue, title: 'OpenReel conditional policy boundary', task: 'Continue safe OpenReel backlog', projectId: 'openreel', sourceChannel: 'test', sourceTarget: 'owner' });
|
|
183
|
+
const conditional = await enqueueTask(root, { queue, title: 'OpenReel conditional policy boundary', task: 'Continue safe OpenReel backlog', projectId: 'openreel', sourceChannel: 'test', sourceTarget: 'owner', sourceAccount: 'main' });
|
|
184
184
|
const conditionalDir = path.join(taskRuntimeDirFor(root, queue, conditional.task.id), 'checkpoints');
|
|
185
185
|
await mkdir(conditionalDir, { recursive: true });
|
|
186
186
|
await writeFile(path.join(conditionalDir, 'cp1.json'), `${JSON.stringify({
|
|
@@ -194,7 +194,7 @@ assert.equal(conditionalNotice.results.some((item) => item.taskId === conditiona
|
|
|
194
194
|
// A conditional formal blocker becomes current when the producer explicitly
|
|
195
195
|
// marks it needed now and materialize=true. It must stop once, rather than be
|
|
196
196
|
// filtered into a needs_revision/project_in_progress polling loop.
|
|
197
|
-
const conditionalNow = await enqueueTask(root, { queue, title: 'OpenReel conditional blocker now', task: 'Wait for a real external precondition', projectId: 'openreel', sourceChannel: 'test', sourceTarget: 'owner' });
|
|
197
|
+
const conditionalNow = await enqueueTask(root, { queue, title: 'OpenReel conditional blocker now', task: 'Wait for a real external precondition', projectId: 'openreel', sourceChannel: 'test', sourceTarget: 'owner', sourceAccount: 'main' });
|
|
198
198
|
const conditionalNowDir = path.join(taskRuntimeDirFor(root, queue, conditionalNow.task.id), 'checkpoints');
|
|
199
199
|
await mkdir(conditionalNowDir, { recursive: true });
|
|
200
200
|
await writeFile(path.join(conditionalNowDir, 'cp1.json'), `${JSON.stringify({
|
|
@@ -213,7 +213,7 @@ await reconcileProjectGates(root, { queue });
|
|
|
213
213
|
// Authorization already granted or already consumed is audit context, not a
|
|
214
214
|
// new human-input request. A future boundary is likewise dormant.
|
|
215
215
|
for (const authorizationState of ['authorized', 'consumed', 'future']) {
|
|
216
|
-
const stateTask = await enqueueTask(root, { queue, title: `OpenReel ${authorizationState} authority`, task: 'Continue within recorded authority', projectId: 'openreel', sourceChannel: 'test', sourceTarget: 'owner' });
|
|
216
|
+
const stateTask = await enqueueTask(root, { queue, title: `OpenReel ${authorizationState} authority`, task: 'Continue within recorded authority', projectId: 'openreel', sourceChannel: 'test', sourceTarget: 'owner', sourceAccount: 'main' });
|
|
217
217
|
const stateDir = path.join(taskRuntimeDirFor(root, queue, stateTask.task.id), 'checkpoints');
|
|
218
218
|
await mkdir(stateDir, { recursive: true });
|
|
219
219
|
await writeFile(path.join(stateDir, 'cp1.json'), `${JSON.stringify({
|
|
@@ -239,7 +239,7 @@ await writeFile(subprojectBacklog, `${JSON.stringify({
|
|
|
239
239
|
{ id: 'CDQI2-11', status: 'in_progress', dependsOn: ['CDQI2-10'] }
|
|
240
240
|
]
|
|
241
241
|
}, null, 2)}\n`);
|
|
242
|
-
const subprojectTask = await enqueueTask(root, { queue, title: 'OpenReel CDQI2 actionable backlog', task: 'Continue CDQI2-11', projectId: 'openreel', sourceChannel: 'test', sourceTarget: 'owner' });
|
|
242
|
+
const subprojectTask = await enqueueTask(root, { queue, title: 'OpenReel CDQI2 actionable backlog', task: 'Continue CDQI2-11', projectId: 'openreel', sourceChannel: 'test', sourceTarget: 'owner', sourceAccount: 'main' });
|
|
243
243
|
const subprojectDir = path.join(taskRuntimeDirFor(root, queue, subprojectTask.task.id), 'checkpoints');
|
|
244
244
|
await mkdir(subprojectDir, { recursive: true });
|
|
245
245
|
await writeFile(path.join(subprojectDir, 'cp1.json'), `${JSON.stringify({
|
|
@@ -254,7 +254,7 @@ assert.equal(subprojectNotice.results.some((item) => item.taskId === subprojectT
|
|
|
254
254
|
// A genuinely missing current authorization becomes a waiting gate once no
|
|
255
255
|
// safe project work remains.
|
|
256
256
|
await writeFile(authoritativeBacklog, `${JSON.stringify({ status: 'ongoing', items: [{ id: 'GLOBAL-01', status: 'accepted', dependsOn: [] }] }, null, 2)}\n`);
|
|
257
|
-
const missing = await enqueueTask(root, { queue, title: 'OpenReel missing current authority', task: 'Perform currently gated action', projectId: 'openreel', sourceChannel: 'test', sourceTarget: 'owner' });
|
|
257
|
+
const missing = await enqueueTask(root, { queue, title: 'OpenReel missing current authority', task: 'Perform currently gated action', projectId: 'openreel', sourceChannel: 'test', sourceTarget: 'owner', sourceAccount: 'main' });
|
|
258
258
|
const missingDir = path.join(taskRuntimeDirFor(root, queue, missing.task.id), 'checkpoints');
|
|
259
259
|
await mkdir(missingDir, { recursive: true });
|
|
260
260
|
await writeFile(path.join(missingDir, 'cp1.json'), `${JSON.stringify({
|
|
@@ -279,7 +279,7 @@ await writeFile(projectFile, `${JSON.stringify({
|
|
|
279
279
|
backupRestoreRollbackRehearsal: 'standing_authorization_openreel_2026-08-19'
|
|
280
280
|
}
|
|
281
281
|
}, null, 2)}\n`);
|
|
282
|
-
const coveredBlocker = await enqueueTask(root, { queue, title: 'OpenReel covered production blocker', task: 'Deploy accepted candidate', projectId: 'openreel', sourceChannel: 'test', sourceTarget: 'owner' });
|
|
282
|
+
const coveredBlocker = await enqueueTask(root, { queue, title: 'OpenReel covered production blocker', task: 'Deploy accepted candidate', projectId: 'openreel', sourceChannel: 'test', sourceTarget: 'owner', sourceAccount: 'main' });
|
|
283
283
|
const coveredContract = await writeTaskContract(root, queue, coveredBlocker.task);
|
|
284
284
|
assert.equal(coveredContract.contract.constraints.project_authorization.production_authorized, true);
|
|
285
285
|
const coveredDir = path.join(taskRuntimeDirFor(root, queue, coveredBlocker.task.id), 'checkpoints');
|
|
@@ -294,7 +294,7 @@ assert.equal(coveredNotice.results.some((item) => item.taskId === coveredBlocker
|
|
|
294
294
|
|
|
295
295
|
// Explicitly authorized blocker metadata is also non-materializable, while a
|
|
296
296
|
// genuinely missing publication permission remains a human gate.
|
|
297
|
-
const publication = await enqueueTask(root, { queue, title: 'OpenReel publication blocker', task: 'Publish candidate', projectId: 'openreel', sourceChannel: 'test', sourceTarget: 'owner' });
|
|
297
|
+
const publication = await enqueueTask(root, { queue, title: 'OpenReel publication blocker', task: 'Publish candidate', projectId: 'openreel', sourceChannel: 'test', sourceTarget: 'owner', sourceAccount: 'main' });
|
|
298
298
|
await writeTaskContract(root, queue, publication.task);
|
|
299
299
|
const publicationDir = path.join(taskRuntimeDirFor(root, queue, publication.task.id), 'checkpoints');
|
|
300
300
|
await mkdir(publicationDir, { recursive: true });
|
|
@@ -332,7 +332,7 @@ await rename(legacyInbox, `${legacyInbox}.moved`);
|
|
|
332
332
|
await reconcileProjectGates(root, { queue });
|
|
333
333
|
assert.equal((await queueStatus(root, queue)).done >= 1, true, 'accepted project task must close in done, not canceled');
|
|
334
334
|
assert.equal(JSON.parse(await readFile(legacyGateFile, 'utf8')).status, 'superseded');
|
|
335
|
-
const acceptedOptional = await enqueueTask(root, { queue, title: 'OpenReel optional operations transfer', task: 'Project openreel optional post-completion transfer', projectId: 'openreel', sourceChannel: 'test', sourceTarget: 'owner' });
|
|
335
|
+
const acceptedOptional = await enqueueTask(root, { queue, title: 'OpenReel optional operations transfer', task: 'Project openreel optional post-completion transfer', projectId: 'openreel', sourceChannel: 'test', sourceTarget: 'owner', sourceAccount: 'main' });
|
|
336
336
|
const acceptedOptionalDir = path.join(taskRuntimeDirFor(root, queue, acceptedOptional.task.id), 'checkpoints');
|
|
337
337
|
await mkdir(acceptedOptionalDir, { recursive: true });
|
|
338
338
|
await writeFile(path.join(acceptedOptionalDir, 'cp1.json'), `${JSON.stringify({
|
|
@@ -344,4 +344,19 @@ const acceptedNotice = await notifyHumanInputRequests(root, { queue, notifyComma
|
|
|
344
344
|
assert.equal(acceptedNotice.results.some((item) => item.taskId === acceptedOptional.task.id), false);
|
|
345
345
|
assert.equal((await queueStatus(root, queue)).waiting, 0);
|
|
346
346
|
|
|
347
|
-
|
|
347
|
+
// Missing source/actor/generation binding must fail closed: no waiting move,
|
|
348
|
+
// no gate command text, and no notification invocation.
|
|
349
|
+
const unbound = await enqueueTask(root, { queue, title: 'Unbound human request', task: 'Project openreel B-01', projectId: 'openreel', sourceChannel: 'test', sourceTarget: 'owner' });
|
|
350
|
+
const unboundDir = path.join(taskRuntimeDirFor(root, queue, unbound.task.id), 'checkpoints');
|
|
351
|
+
await mkdir(unboundDir, { recursive: true });
|
|
352
|
+
await writeFile(path.join(unboundDir, 'cp1.json'), `${JSON.stringify({
|
|
353
|
+
version: 1, task_id: unbound.task.id, checkpoint_id: 'cp1', milestone_id: 'B-01',
|
|
354
|
+
status: 'needs_human_input', blockers: [{ action: 'approve', required_authority: 'owner', authorization_state: 'missing', needed_when: 'now' }]
|
|
355
|
+
}, null, 2)}\n`);
|
|
356
|
+
const unboundNotice = await notifyHumanInputRequests(root, { queue, notifyCommand: '/bin/true' });
|
|
357
|
+
const unboundResult = unboundNotice.results.find((item) => item.taskId === unbound.task.id);
|
|
358
|
+
assert.equal(unboundResult.outcome, 'fail_closed');
|
|
359
|
+
assert.equal(unboundResult.message, undefined);
|
|
360
|
+
assert.equal((await queueStatus(root, queue)).waiting, 0);
|
|
361
|
+
|
|
362
|
+
console.log(JSON.stringify({ status: 'ok', assertions: ['standing project authorization reaches task contract', 'structured gate context', 'B-01 waiting to zero', 'project-isolated supersede', 'doctor strong validation', 'authoritative ledger drift', 'ready milestone deferred gate', 'future gate does not stop safe actionable backlog', 'conditional policy prose does not create a gate', 'authorized and consumed authority do not create gates', 'checkpoint-bound subproject backlog remains actionable', 'missing current authority creates a gate', 'standing-authorized production blocker does not create a gate', 'missing publication blocker creates a gate', 'accepted project optional deferred gate stays out of queue', 'unbound gate fails closed without actionable text'] }));
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { mkdtemp } from 'node:fs/promises';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { decideQuota, readQuotaLedger, recordVerifiedSliceSpend } from '../lib/quota-runtime-decision.mjs';
|
|
6
|
+
|
|
7
|
+
const budget = { tokens: 100, time_ms: 1_000, money_minor: 50, rounds: 3 };
|
|
8
|
+
const request = { tokens: 10, time_ms: 100, money_minor: 5, rounds: 1 };
|
|
9
|
+
assert.equal(decideQuota({ has_work: true, limits: budget, request }).decision, 'execute');
|
|
10
|
+
assert.equal(decideQuota({ has_work: false, limits: budget }).decision, 'silent');
|
|
11
|
+
assert.equal(decideQuota({ has_work: true, repairable_error: true, limits: budget }).decision, 'self-repair');
|
|
12
|
+
assert.equal(decideQuota({ has_work: true, external_condition_pending: true, limits: budget }).decision, 'wait');
|
|
13
|
+
assert.equal(decideQuota({ has_work: true, limits: budget, spend: { tokens: 95 }, request }).decision, 'wait');
|
|
14
|
+
assert.equal(decideQuota({ has_work: true, limits: budget, spend: { tokens: 95 }, request, can_wait_for_reset: false }).decision, 'ask');
|
|
15
|
+
|
|
16
|
+
const fallback = decideQuota({ has_work: true, limits: budget, request, lane_id: 'paid', lanes: [
|
|
17
|
+
{ id: 'paid', state: 'waiting_for_human' },
|
|
18
|
+
{ id: 'local', state: 'runnable', safe_fallback: true, audited: true }
|
|
19
|
+
] });
|
|
20
|
+
assert.equal(fallback.decision, 'execute'); assert.equal(fallback.lane_id, 'local'); assert.equal(fallback.reason, 'audited_safe_fallback');
|
|
21
|
+
assert.equal(decideQuota({ has_work: true, limits: budget, request, lane_id: 'paid', lanes: [{ id: 'paid', state: 'waiting_for_human' }, { id: 'unsafe', state: 'runnable', safe_fallback: true, audited: false }] }).decision, 'ask');
|
|
22
|
+
|
|
23
|
+
const root = await mkdtemp(path.join(tmpdir(), 'quota-runtime-'));
|
|
24
|
+
assert.equal((await recordVerifiedSliceSpend(root, { slice_id: 'idle', status: 'idle', verified: false, spend: request })).recorded, false);
|
|
25
|
+
assert.deepEqual((await readQuotaLedger(root)).spend, { tokens: 0, time_ms: 0, money_minor: 0, rounds: 0 });
|
|
26
|
+
assert.equal((await recordVerifiedSliceSpend(root, { slice_id: 's1', status: 'completed', verified: true, spend: request, evidence: 'test:pass' })).recorded, true);
|
|
27
|
+
assert.equal((await recordVerifiedSliceSpend(root, { slice_id: 's1', status: 'completed', verified: true, spend: request })).recorded, false);
|
|
28
|
+
assert.deepEqual((await readQuotaLedger(root)).spend, request);
|
|
29
|
+
console.log(JSON.stringify({ ok: true, assertions: ['five decisions', 'scheduler hint', 'four budget dimensions', 'verified-only spend', 'idle free', 'idempotent spend', 'audited fallback'] }));
|
|
@@ -423,8 +423,8 @@ await writeJson(path.join(root, 'configs', 'loops', 'queues', `${queue}.json`),
|
|
|
423
423
|
|
|
424
424
|
const gateDryRun = await notifyHumanInputRequests(root, { queue, dryRun: true });
|
|
425
425
|
assert.equal(gateDryRun.results[0].outcome, 'dry_run');
|
|
426
|
-
assert.
|
|
427
|
-
assert.
|
|
426
|
+
assert.equal(gateDryRun.results[0].preview, 'authoritative_gate_would_be_materialized');
|
|
427
|
+
assert.equal(gateDryRun.results[0].message, undefined, 'dry-run must not emit an actionable reply command before gate materialization');
|
|
428
428
|
const gateSent = await notifyHumanInputRequests(root, { queue, notifyCommand: '/bin/true' });
|
|
429
429
|
assert.equal(gateSent.sent, 1);
|
|
430
430
|
const gateId = gateSent.results[0].gateId;
|
|
@@ -544,7 +544,7 @@ await writeJson(path.join(regressionRoot, 'configs', 'loops', 'projects', 'demo.
|
|
|
544
544
|
for (const [id, enqueuedAt] of [['history-done', '2026-01-01T00:00:00Z'], ['latest-done', '2026-01-02T00:00:00Z']]) {
|
|
545
545
|
await writeJson(path.join(queueSubdirFor(regressionRoot, regressionQueue, 'done'), `${id}.json`), {
|
|
546
546
|
id, title: `demo ${id}`, body: 'demo R-1', projectId: 'demo', status: 'completed', enqueuedAt,
|
|
547
|
-
source: { channel: 'test', target: 'owner' }
|
|
547
|
+
source: { channel: 'test', target: 'owner', account: 'main' }
|
|
548
548
|
});
|
|
549
549
|
await writeJson(path.join(taskRuntimeDirFor(regressionRoot, regressionQueue, id), 'checkpoints', 'cp1.json'), {
|
|
550
550
|
version: 1, task_id: id, checkpoint_id: 'cp1', milestone_id: 'R-1', sequence: 1,
|
|
@@ -577,7 +577,7 @@ await writeJson(path.join(queueSubdirFor(regressionRoot, regressionQueue, 'faile
|
|
|
577
577
|
projectId: 'demo',
|
|
578
578
|
status: 'blocked',
|
|
579
579
|
enqueuedAt: '2026-01-03T00:00:00Z',
|
|
580
|
-
source: { channel: 'feishu', target: 'owner' }
|
|
580
|
+
source: { channel: 'feishu', target: 'owner', account: 'main' }
|
|
581
581
|
});
|
|
582
582
|
await writeJson(path.join(taskRuntimeDirFor(regressionRoot, regressionQueue, blockedProjectTask), 'checkpoints', 'cp2.json'), {
|
|
583
583
|
version: 1,
|
|
@@ -23,7 +23,10 @@ await assert.rejects(() => renewTodo(root, { todoId: 'race', agentId: loser ===
|
|
|
23
23
|
await todo('capability');
|
|
24
24
|
assert.equal((await claimTodo(root, { todoId: 'capability', agentId: 'observer' })).reason, 'capability_mismatch');
|
|
25
25
|
await todo('quota');
|
|
26
|
-
|
|
26
|
+
const quotaRejected = await claimTodo(root, { todoId: 'quota', agentId: 'poor' });
|
|
27
|
+
assert.equal(quotaRejected.reason, 'quota_exhausted');
|
|
28
|
+
assert.equal(quotaRejected.decision, 'wait');
|
|
29
|
+
assert.equal(quotaRejected.scheduler_hint.action, 'wait');
|
|
27
30
|
|
|
28
31
|
await todo('dependency');
|
|
29
32
|
await todo('dependent', { dependencies: ['dependency'], priority: 100 });
|
|
@@ -120,6 +120,14 @@ an LLM call. Preserve source metadata and pass `--source-target` in Hermes
|
|
|
120
120
|
`platform:chat_id[:thread_id]` format. The managed systemd scheduler owns wakeups
|
|
121
121
|
so durable queue continuity does not depend on resuming a Hermes Cron session.
|
|
122
122
|
|
|
123
|
+
## Cross-interface Human Gates
|
|
124
|
+
|
|
125
|
+
Dashboard and chat approvals use one Gate Command core and one authoritative gate artifact. Cards must show project/task, Gate ID, action, reason, impact, risk, cost/budget, evidence, Dashboard URL, expiry and generation; processed cards are refreshed disabled. Only card buttons or exact card-bound `/approve gate_<id>`, `/reject gate_<id>` and `/request_revision gate_<id> <reason>` replies may mutate a gate. Ordinary chat, quotes, forwards, screenshots and ordinal phrases fail closed as `ignored_untrusted_chat`; `/show_gate gate_<id>` is display-only.
|
|
126
|
+
|
|
127
|
+
The OpenClaw installer generates `scripts/loops/openclaw-loop-gate.mjs`. A trusted Feishu callback/plugin transport must verify the official signature or encrypted-event envelope, timestamp/nonce and secret before invoking it with `LOOP_GATE_CHANNEL=feishu` and `LOOP_FEISHU_SIGNATURE_VERIFIED=1`; missing verification fails with `feishu_signature_unverified`. The bridge itself performs no delivery. Other channel transports pass normalized, source-bound events. Dashboard and chat commands share actor/source binding, generation fencing, idempotency receipts, confirmation escalation and synchronized card state.
|
|
128
|
+
|
|
129
|
+
After installation, run `loop-engineering-openclaw-doctor` and `loop-engineering-openclaw-smoke`. Doctor syntax-checks and locally self-tests the Gate bridge; smoke remains disposable and uses dry-run notifications. Neither command sends an online test message.
|
|
130
|
+
|
|
123
131
|
## Conversation Contract
|
|
124
132
|
|
|
125
133
|
Interpret explicit loop language as follows:
|