taskforce-loop-engineering 0.7.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.
@@ -0,0 +1,98 @@
1
+ #!/usr/bin/env node
2
+ import { access, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
3
+ import { spawn } from 'node:child_process';
4
+ import path from 'node:path';
5
+
6
+ function parseArgs(argv) {
7
+ const out = { root: process.cwd(), queue: 'agent-tasks', workerAgent: 'main', openclawBin: 'openclaw', loopBin: 'loop-engineering', json: false, keepArtifacts: false };
8
+ for (let i = 0; i < argv.length; i++) {
9
+ const arg = argv[i];
10
+ if (arg === '--root') out.root = path.resolve(argv[++i]);
11
+ else if (arg === '--queue') out.queue = argv[++i];
12
+ else if (arg === '--worker-agent') out.workerAgent = argv[++i];
13
+ else if (arg === '--openclaw-bin') out.openclawBin = argv[++i];
14
+ else if (arg === '--loop-bin') out.loopBin = argv[++i];
15
+ else if (arg === '--keep-artifacts') out.keepArtifacts = true;
16
+ else if (arg === '--json') out.json = true;
17
+ else if (arg === '--help' || arg === '-h') out.help = true;
18
+ else throw new Error(`Unknown argument: ${arg}`);
19
+ }
20
+ return out;
21
+ }
22
+
23
+ function run(command, args, options = {}) {
24
+ return new Promise((resolve) => {
25
+ const child = spawn(command, args, { cwd: options.cwd, env: options.env || process.env, stdio: ['ignore', 'pipe', 'pipe'] });
26
+ let stdout = ''; let stderr = '';
27
+ child.stdout.on('data', (chunk) => { stdout += chunk; });
28
+ child.stderr.on('data', (chunk) => { stderr += chunk; });
29
+ child.on('error', (error) => resolve({ code: 127, stdout, stderr: `${stderr}${error.message}` }));
30
+ child.on('close', (code, signal) => resolve({ code: code ?? (signal ? 128 : 1), stdout, stderr }));
31
+ });
32
+ }
33
+
34
+ async function exists(file) { try { await access(file); return true; } catch { return false; } }
35
+ function safeId(value) { if (!/^[a-zA-Z0-9._-]+$/.test(value)) throw new Error('queue contains unsupported characters.'); return value; }
36
+
37
+ async function main() {
38
+ const args = parseArgs(process.argv.slice(2));
39
+ if (args.help) {
40
+ console.log('Usage: loop-engineering-openclaw-smoke [--root workspace] [--queue agent-tasks] [--worker-agent main] [--openclaw-bin openclaw] [--loop-bin loop-engineering] [--keep-artifacts] [--json]');
41
+ return;
42
+ }
43
+ safeId(args.queue);
44
+ const runToken = `${Date.now()}-${process.pid}`;
45
+ const smokeQueue = `smoke-${args.queue}-${runToken}`;
46
+ const baseConfig = path.join(args.root, `configs/loops/queues/${args.queue}.json`);
47
+ const smokeConfig = path.join(args.root, `configs/loops/queues/${smokeQueue}.json`);
48
+ const smokeRuntime = path.join(args.root, 'runtime', 'loops', smokeQueue);
49
+ const doctorScript = new URL('./openclaw-doctor.mjs', import.meta.url).pathname;
50
+ const steps = [];
51
+ let taskId = null;
52
+ try {
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
+ steps.push({ id: 'doctor', ok: doctor.code === 0 });
55
+ if (doctor.code !== 0) throw new Error(`doctor failed: ${doctor.stderr || doctor.stdout}`);
56
+ const config = JSON.parse(await readFile(baseConfig, 'utf8'));
57
+ config.queue = smokeQueue;
58
+ config.description = 'Temporary read-only OpenClaw integration smoke queue.';
59
+ config.retry = { ...(config.retry || {}), maxAttempts: 1 };
60
+ await mkdir(path.dirname(smokeConfig), { recursive: true });
61
+ await writeFile(smokeConfig, `${JSON.stringify(config, null, 2)}\n`);
62
+ const message = '走 loop:Perform a read-only integration smoke. Do not change files or external state. Report SMOKE_OK with verification evidence.';
63
+ const route = await run(args.loopBin, ['route-message', '--root', args.root, '--queue', smokeQueue, '--message', message, '--route', '--confirm-execute', '--source-channel', 'feishu', '--source-target', 'user:loop-smoke-dry-run', '--source-account', 'doctor', '--source-message-id', `smoke-${runToken}`, '--source-reply-to', `smoke-${runToken}`, '--json'], { cwd: args.root });
64
+ steps.push({ id: 'route', ok: route.code === 0 });
65
+ if (route.code !== 0) throw new Error(`route failed: ${route.stderr || route.stdout}`);
66
+ const routed = JSON.parse(route.stdout);
67
+ taskId = routed.task?.id || null;
68
+ const execute = await run(args.loopBin, ['run-queue', '--root', args.root, '--config', path.relative(args.root, smokeConfig), '--json'], { cwd: args.root });
69
+ steps.push({ id: 'worker_execution', ok: execute.code === 0 });
70
+ if (execute.code !== 0) throw new Error(`worker execution failed: ${execute.stderr || execute.stdout}`);
71
+ const taskDir = taskId ? path.join(smokeRuntime, 'tasks', taskId) : '';
72
+ for (const artifact of ['task_contract.json', 'dev_plan.json', 'acceptance_plan.json', 'final_judgement.json']) {
73
+ const ok = Boolean(taskDir) && await exists(path.join(taskDir, artifact));
74
+ steps.push({ id: `artifact:${artifact}`, ok });
75
+ if (!ok) throw new Error(`missing smoke artifact: ${artifact}`);
76
+ }
77
+ const notifyEnv = { ...process.env, LOOP_NOTIFICATION_DRY_RUN: '1' };
78
+ const notifyCommand = 'node scripts/loops/openclaw-loop-notify.mjs';
79
+ const humanNotify = await run(args.loopBin, ['queue-human-input-notify', '--root', args.root, '--queue', smokeQueue, '--notify-command', notifyCommand, '--json'], { cwd: args.root, env: notifyEnv });
80
+ steps.push({ id: 'human_gate_scan', ok: humanNotify.code === 0 });
81
+ const terminalNotify = await run(args.loopBin, ['queue-terminal-notify', '--root', args.root, '--queue', smokeQueue, '--notify-command', notifyCommand, '--json'], { cwd: args.root, env: notifyEnv });
82
+ const terminal = terminalNotify.code === 0 ? JSON.parse(terminalNotify.stdout) : null;
83
+ const terminalOk = terminalNotify.code === 0 && terminal?.sent === 1;
84
+ steps.push({ id: 'terminal_dry_run_return', ok: terminalOk });
85
+ if (!terminalOk) throw new Error(`terminal dry-run failed: ${terminalNotify.stderr || terminalNotify.stdout}`);
86
+ const report = { version: 1, status: 'ok', readOnlyTask: true, externalWrite: false, queue: args.queue, smokeQueue, workerAgent: args.workerAgent, taskId, steps, keptArtifacts: args.keepArtifacts };
87
+ console.log(args.json ? JSON.stringify(report, null, 2) : `OpenClaw Loop smoke: ok\ntask: ${taskId}\nexternal write: no\nartifacts: ${args.keepArtifacts ? smokeRuntime : 'cleaned'}`);
88
+ } finally {
89
+ if (!args.keepArtifacts) {
90
+ if (smokeQueue.startsWith(`smoke-${args.queue}-`)) {
91
+ await rm(smokeConfig, { force: true });
92
+ await rm(smokeRuntime, { recursive: true, force: true });
93
+ }
94
+ }
95
+ }
96
+ }
97
+
98
+ main().catch((error) => { console.error(error instanceof Error ? error.message : String(error)); process.exitCode = 1; });
@@ -0,0 +1,297 @@
1
+ #!/usr/bin/env node
2
+ import assert from 'node:assert/strict';
3
+ import { mkdtemp, readdir, rm } from 'node:fs/promises';
4
+ import { tmpdir } from 'node:os';
5
+ import path from 'node:path';
6
+ import {
7
+ classifyLoopMessage,
8
+ goalLoopTransition,
9
+ goalStrategyFingerprint,
10
+ normalizeGoalDecision,
11
+ notifyHumanInputRequests,
12
+ notifyTerminalTasks,
13
+ queueSubdirFor,
14
+ readJson,
15
+ routeLoopMessage,
16
+ resolveHumanInput,
17
+ runQueueOnce,
18
+ runQueueDrain,
19
+ taskRuntimeDirFor,
20
+ writeJson
21
+ } from '../lib/core.mjs';
22
+
23
+ const root = await mkdtemp(path.join(tmpdir(), 'loop-route-notify-'));
24
+ const queue = 'route-smoke';
25
+
26
+ assert.equal(normalizeGoalDecision({ verdict: 'revise' }).decision, 'change_strategy');
27
+ assert.deepEqual(goalLoopTransition({ decision: 'change_strategy' }, { round: 1, maxRounds: 3 }), {
28
+ status: 'replan_pending',
29
+ continue: true,
30
+ terminal: false
31
+ });
32
+ assert.equal(goalLoopTransition({ decision: 'change_strategy' }, { round: 3, maxRounds: 3 }).status, 'exploration_exhausted');
33
+ assert.equal(goalLoopTransition({ decision: 'human_input' }, { round: 1, maxRounds: 3 }).status, 'waiting_for_human');
34
+ assert.equal(goalStrategyFingerprint('Try A!'), goalStrategyFingerprint(' try a '));
35
+
36
+ assert.deepEqual(classifyLoopMessage('查一下 loop engineering 的情况'), {
37
+ intent: 'status',
38
+ risk: 'model_assessed',
39
+ enqueue: false,
40
+ readOnly: true
41
+ });
42
+ assert.deepEqual(classifyLoopMessage('用 loop engineering 绕过某个检查'), {
43
+ intent: 'execute',
44
+ risk: 'model_assessed',
45
+ enqueue: true,
46
+ readOnly: false
47
+ });
48
+
49
+ const routed = await routeLoopMessage(root, {
50
+ route: true,
51
+ confirmExecute: true,
52
+ queue,
53
+ message: '走 loop 检查 adb 设备',
54
+ sourceChannel: 'feishu',
55
+ sourceTarget: 'user-1',
56
+ sourceAccount: 'main',
57
+ sourceMessageId: 'message-1'
58
+ });
59
+ assert.equal(routed.action, 'enqueued');
60
+ assert.equal(routed.task.riskAssessment, 'model_assessed');
61
+ assert.equal(routed.task.source.target, 'user-1');
62
+
63
+ const run = await runQueueOnce(root, {
64
+ queue,
65
+ dispatcher: '/bin/true',
66
+ progressNotifyCommand: '/bin/true',
67
+ timeoutMs: 10_000,
68
+ leaseMs: 20_000,
69
+ staleActiveMs: 60_000
70
+ });
71
+ assert.equal(run.processed, true);
72
+ assert.ok(run.progressNotifications.filter((item) => item.outcome === 'sent').length >= 5);
73
+ const progressLedger = path.join(taskRuntimeDirFor(root, queue, routed.task.id), 'progress_notifications');
74
+ assert.ok((await readdir(progressLedger)).length >= 5);
75
+ const contract = await readJson(path.join(taskRuntimeDirFor(root, queue, routed.task.id), 'task_contract.json'));
76
+ assert.equal(contract.risk_level, 'model_assessed');
77
+ assert.equal(contract.requires_human_gate, false);
78
+
79
+ for (const suffix of ['second', 'third']) {
80
+ await routeLoopMessage(root, {
81
+ route: true,
82
+ confirmExecute: true,
83
+ queue,
84
+ message: `走 loop ${suffix}`
85
+ });
86
+ }
87
+ const drained = await runQueueDrain(root, {
88
+ queue,
89
+ dispatcher: '/bin/true',
90
+ timeoutMs: 10_000,
91
+ leaseMs: 20_000,
92
+ staleActiveMs: 60_000,
93
+ maxTasks: 10
94
+ });
95
+ assert.equal(drained.processed, 2);
96
+ assert.equal(drained.remaining, 0);
97
+ assert.equal(drained.stopReason, 'empty');
98
+
99
+ const handoffQueue = 'handoff-smoke';
100
+ await routeLoopMessage(root, {
101
+ route: true,
102
+ confirmExecute: true,
103
+ queue: handoffQueue,
104
+ message: '走 loop first handoff task'
105
+ });
106
+ let handoffEnqueue = null;
107
+ const handedOff = await runQueueDrain(root, {
108
+ queue: handoffQueue,
109
+ dispatcher: '/bin/sleep 0.1',
110
+ timeoutMs: 10_000,
111
+ leaseMs: 20_000,
112
+ staleActiveMs: 60_000,
113
+ maxTasks: 10,
114
+ onProgress: (event) => {
115
+ if (event.status !== 'activated' || handoffEnqueue) return;
116
+ handoffEnqueue = routeLoopMessage(root, {
117
+ route: true,
118
+ confirmExecute: true,
119
+ queue: handoffQueue,
120
+ message: '走 loop task enqueued while first is active'
121
+ });
122
+ }
123
+ });
124
+ await handoffEnqueue;
125
+ assert.equal(handedOff.processed, 2);
126
+ assert.equal(handedOff.remaining, 0);
127
+ assert.equal(handedOff.stopReason, 'empty');
128
+
129
+ const supersedeQueue = 'supersede-smoke';
130
+ const original = await routeLoopMessage(root, {
131
+ route: true,
132
+ confirmExecute: true,
133
+ supersedeActive: true,
134
+ queue: supersedeQueue,
135
+ message: '走 loop original task'
136
+ });
137
+ let activated;
138
+ const activeReady = new Promise((resolve) => { activated = resolve; });
139
+ const originalRunPromise = runQueueOnce(root, {
140
+ queue: supersedeQueue,
141
+ dispatcher: '/bin/sleep 5',
142
+ timeoutMs: 10_000,
143
+ leaseMs: 20_000,
144
+ staleActiveMs: 60_000,
145
+ onProgress: (event) => {
146
+ if (event.phase === 'dispatch' && event.status === 'running') activated();
147
+ }
148
+ });
149
+ await activeReady;
150
+ const replacement = await routeLoopMessage(root, {
151
+ route: true,
152
+ confirmExecute: true,
153
+ supersedeActive: true,
154
+ queue: supersedeQueue,
155
+ message: '走 loop corrected replacement task'
156
+ });
157
+ assert.equal(replacement.action, 'supersede_requested');
158
+ assert.equal(replacement.supersededTaskId, original.task.id);
159
+ assert.equal(replacement.task.supersedesTaskId, original.task.id);
160
+ const originalRun = await originalRunPromise;
161
+ assert.equal(originalRun.status, 'superseded');
162
+ assert.equal(originalRun.run.dispatch.canceled, true);
163
+ assert.equal(originalRun.run.finalJudgement.outcome, 'superseded');
164
+ assert.match(originalRun.taskPath, /canceled/);
165
+ const replacementRun = await runQueueOnce(root, {
166
+ queue: supersedeQueue,
167
+ dispatcher: '/bin/true',
168
+ timeoutMs: 10_000,
169
+ leaseMs: 20_000,
170
+ staleActiveMs: 60_000
171
+ });
172
+ assert.equal(replacementRun.processed, true);
173
+ assert.equal(replacementRun.run.taskId, replacement.task.id);
174
+
175
+ const amendmentQueue = 'amendment-smoke';
176
+ const amendmentOriginal = await routeLoopMessage(root, {
177
+ route: true,
178
+ confirmExecute: true,
179
+ supersedeActive: true,
180
+ queue: amendmentQueue,
181
+ message: '走 loop original task that will receive a supplement',
182
+ sourceChannel: 'feishu',
183
+ sourceTarget: 'user-1'
184
+ });
185
+ let amendmentActivated;
186
+ const amendmentReady = new Promise((resolve) => { amendmentActivated = resolve; });
187
+ let checkpointWrite = null;
188
+ const amendmentRunPromise = runQueueOnce(root, {
189
+ queue: amendmentQueue,
190
+ dispatcher: '/bin/sleep 0.2',
191
+ timeoutMs: 10_000,
192
+ leaseMs: 20_000,
193
+ staleActiveMs: 60_000,
194
+ progressNotifyCommand: '/bin/true',
195
+ progressHeartbeatMs: 25,
196
+ checkpointPollMs: 10,
197
+ onProgress: (event) => {
198
+ if (event.phase === 'dispatch' && event.status === 'running') {
199
+ amendmentActivated();
200
+ checkpointWrite = writeJson(path.join(taskRuntimeDirFor(root, amendmentQueue, amendmentOriginal.task.id), 'checkpoints', 'cp-live.json'), {
201
+ version: 1,
202
+ task_id: amendmentOriginal.task.id,
203
+ checkpoint_id: 'cp-live',
204
+ status: 'ready_for_acceptance',
205
+ summary: 'Live checkpoint visible to the source conversation.',
206
+ files_changed: [],
207
+ verification: [{ command: '/bin/true', outcome: 'passed' }],
208
+ blockers: [],
209
+ risks: [],
210
+ next_action: 'acceptance_review'
211
+ });
212
+ }
213
+ }
214
+ });
215
+ await amendmentReady;
216
+ const amendment = await routeLoopMessage(root, {
217
+ route: true,
218
+ confirmExecute: true,
219
+ amendActive: true,
220
+ queue: amendmentQueue,
221
+ message: '继续当前 loop,补充要求:验收必须覆盖新的边界条件',
222
+ sourceChannel: 'feishu',
223
+ sourceTarget: 'user-1',
224
+ sourceMessageId: 'amendment-1'
225
+ });
226
+ assert.equal(amendment.action, 'active_task_amended');
227
+ assert.equal(amendment.taskId, amendmentOriginal.task.id);
228
+ assert.equal(amendment.amendment.sequence, 1);
229
+ assert.equal(amendment.updatedPlans.length, 3);
230
+ assert.match(amendment.amendmentFile, /amendments\/0001\.json$/);
231
+ const amendedContract = await readJson(path.join(taskRuntimeDirFor(root, amendmentQueue, amendmentOriginal.task.id), 'task_contract.json'));
232
+ const amendedAcceptance = await readJson(path.join(taskRuntimeDirFor(root, amendmentQueue, amendmentOriginal.task.id), 'acceptance_plan.json'));
233
+ const amendedDev = await readJson(path.join(taskRuntimeDirFor(root, amendmentQueue, amendmentOriginal.task.id), 'dev_plan.json'));
234
+ assert.equal(amendedContract.amendment_version, 1);
235
+ assert.match(amendedContract.supplemental_requirements[0], /新的边界条件/);
236
+ assert.match(amendedAcceptance.supplemental_checks[0], /新的边界条件/);
237
+ assert.match(amendedDev.supplemental_instructions[0], /新的边界条件/);
238
+ const amendmentRun = await amendmentRunPromise;
239
+ await checkpointWrite;
240
+ assert.notEqual(amendmentRun.status, 'superseded');
241
+ assert.equal(amendmentRun.run.dispatch.canceled, false);
242
+ assert.ok(amendmentRun.progress.some((event) => event.status === 'heartbeat'));
243
+ assert.ok(amendmentRun.progress.some((event) => event.status === 'checkpoint_update'));
244
+ assert.ok(amendmentRun.progressNotifications.filter((item) => item.outcome === 'sent').length >= 7);
245
+ await assert.rejects(
246
+ routeLoopMessage(root, {
247
+ route: true,
248
+ confirmExecute: true,
249
+ amendActive: true,
250
+ queue: amendmentQueue,
251
+ message: '继续当前 loop,补充要求:任务结束后不应再接受补充'
252
+ }),
253
+ /No active loop task exists to amend/
254
+ );
255
+
256
+ const terminalFile = path.join(root, run.taskPath);
257
+ const terminalTask = await readJson(terminalFile);
258
+ terminalTask.status = 'needs_human_input';
259
+ const checkpointFile = path.join(taskRuntimeDirFor(root, queue, routed.task.id), 'checkpoints', 'cp-human.json');
260
+ await writeJson(checkpointFile, {
261
+ version: 1,
262
+ task_id: routed.task.id,
263
+ checkpoint_id: 'cp-human',
264
+ status: 'needs_human_input',
265
+ summary: 'Waiting for a one-time code.',
266
+ blockers: [{ human_action_required: 'Provide the SMS code.' }],
267
+ verification: [],
268
+ risks: [],
269
+ next_action: 'wait_for_sms_code'
270
+ });
271
+ const failedFile = path.join(queueSubdirFor(root, queue, 'failed'), path.basename(terminalFile));
272
+ await writeJson(failedFile, terminalTask);
273
+ if (failedFile !== terminalFile) await rm(terminalFile, { force: true });
274
+
275
+ const gateDryRun = await notifyHumanInputRequests(root, { queue, dryRun: true });
276
+ assert.equal(gateDryRun.results[0].outcome, 'dry_run');
277
+ assert.match(gateDryRun.results[0].message, /Provide the SMS code/);
278
+ const gateSent = await notifyHumanInputRequests(root, { queue, notifyCommand: '/bin/true' });
279
+ assert.equal(gateSent.sent, 1);
280
+ const gateId = gateSent.results[0].gateId;
281
+ const resolved = await resolveHumanInput(root, { queue, gateId, input: '123456', sourceMessageId: 'reply-1' });
282
+ assert.equal(resolved.outcome, 'resolved_and_requeued');
283
+ const requeuedTask = await readJson(path.join(queueSubdirFor(root, queue, 'inbox'), path.basename(failedFile)));
284
+ assert.equal(requeuedTask.humanInput.response, '123456');
285
+ assert.match(requeuedTask.body, /Human input for gate/);
286
+ await writeJson(failedFile, { ...requeuedTask, status: 'needs_human_input' });
287
+ await rm(path.join(queueSubdirFor(root, queue, 'inbox'), path.basename(failedFile)), { force: true });
288
+
289
+ const dryRun = await notifyTerminalTasks(root, { queue, dryRun: true });
290
+ assert.equal(dryRun.results[0].outcome, 'dry_run');
291
+ const sent = await notifyTerminalTasks(root, { queue, notifyCommand: '/bin/true' });
292
+ assert.equal(sent.sent, 1);
293
+ const repeated = await notifyTerminalTasks(root, { queue, notifyCommand: '/bin/true' });
294
+ assert.equal(repeated.results[0].outcome, 'already_notified');
295
+
296
+ console.log('route/notify self-test passed');
297
+ await import('./config-drift-self-test.mjs');
@@ -0,0 +1,84 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ WORKDIR="${LOOP_WORKDIR:-$(pwd)}"
5
+ CONFIG="${1:-}"
6
+ LOG_DIR="${LOOP_LOG_DIR:-$WORKDIR/logs}"
7
+ SEND_CMD="${LOOP_ALERT_COMMAND:-}"
8
+
9
+ if [[ -z "$CONFIG" ]]; then
10
+ echo "Usage: run-loop-cron.sh <loop-config.json>" >&2
11
+ exit 2
12
+ fi
13
+
14
+ mkdir -p "$LOG_DIR"
15
+ cd "$WORKDIR"
16
+
17
+ loop_id="$(node -e '
18
+ const fs = require("fs");
19
+ const config = process.argv[1];
20
+ try {
21
+ const spec = JSON.parse(fs.readFileSync(config, "utf8"));
22
+ if (!/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(spec.id || "")) process.exit(2);
23
+ process.stdout.write(spec.id);
24
+ } catch {
25
+ process.exit(2);
26
+ }
27
+ ' "$CONFIG")"
28
+
29
+ stamp="$(date -u '+%Y-%m-%dT%H:%M:%SZ')"
30
+ log_file="$LOG_DIR/loop-cron-${loop_id}.log"
31
+ tmp_out="$(mktemp)"
32
+ exit_code=0
33
+
34
+ if loop-engineering run --config "$CONFIG" --root "$WORKDIR" > "$tmp_out" 2>&1; then
35
+ exit_code=0
36
+ else
37
+ exit_code=$?
38
+ fi
39
+
40
+ {
41
+ printf '[%s] %s exit=%s\n' "$stamp" "$loop_id" "$exit_code"
42
+ sed -n '1,80p' "$tmp_out"
43
+ printf '\n'
44
+ } >> "$log_file"
45
+
46
+ if [[ "$exit_code" == "0" ]]; then
47
+ rm -f "$tmp_out"
48
+ exit 0
49
+ fi
50
+
51
+ summary="$(node -e '
52
+ const fs = require("fs");
53
+ const path = require("path");
54
+ const loopId = process.argv[1];
55
+ const dir = path.join("runtime", "loops", loopId, "runs");
56
+ let latest = null;
57
+ try {
58
+ const files = fs.readdirSync(dir).filter((f) => f.endsWith(".json")).sort();
59
+ if (files.length > 0) {
60
+ const file = path.join(dir, files[files.length - 1]);
61
+ const run = JSON.parse(fs.readFileSync(file, "utf8"));
62
+ const failed = Array.isArray(run.checks)
63
+ ? run.checks.filter((check) => !check.ok).map((check) => check.id).join(", ")
64
+ : "";
65
+ latest = [
66
+ `outcome=${run.outcome || "unknown"}`,
67
+ `reason=${run.breaker?.reason || run.runtimeError || "runner failed"}`,
68
+ failed ? `failed_checks=${failed}` : null,
69
+ `run=${run.runPath || file}`
70
+ ].filter(Boolean).join("; ");
71
+ }
72
+ } catch {}
73
+ process.stdout.write(latest || "runner failed before writing a run artifact");
74
+ ' "$loop_id")"
75
+
76
+ message="Loop escalation ($stamp): $loop_id exit=$exit_code. $summary"
77
+ if [[ -n "$SEND_CMD" ]]; then
78
+ "$SEND_CMD" "$message" || true
79
+ else
80
+ printf '%s\n' "$message" >&2
81
+ fi
82
+
83
+ rm -f "$tmp_out"
84
+ exit "$exit_code"