taskforce-loop-engineering 0.12.0 → 0.13.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 CHANGED
@@ -2,6 +2,13 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.13.0 - 2026-08-14
6
+
7
+ - Add versioned OpenClaw, Hermes, and custom runtime adapter contracts with a shared conformance suite.
8
+ - Add a checksummed, fsync-backed durable journal with replay, snapshots, migration, backup/restore, and fail-closed unknown-outcome handling.
9
+ - Add deterministic multi-worker canary and isolated live-runtime soak tooling, a credential-free demo, and a non-destructive customized-Ironman upgrade planner.
10
+ - Add unified production-trust acceptance and an idempotent acceptance refresh command so detached long-running evidence invalidates stale final judgements.
11
+
5
12
  ## 0.12.0 - 2026-08-13
6
13
 
7
14
  - Add P3 read-only Operator Dashboard, normalized schema, loopback HTTP/JSON API, static export, inspect and health commands.
package/README.md CHANGED
@@ -1,5 +1,14 @@
1
1
  # Taskforce Loop Engineering
2
2
 
3
+ ## 0.13 production trust
4
+
5
+ The local production-trust contract, runtime adapter v1, durable journal,
6
+ multi-worker canary, non-destructive Ironman upgrade planner, safe demo and
7
+ unified acceptance are documented in
8
+ [docs/production-trust-contract.md](docs/production-trust-contract.md). Run
9
+ `npm run check:production-trust`; external publishing and deployment remain
10
+ separately authorized actions.
11
+
3
12
  ## Read-only operator dashboard (P3)
4
13
 
5
14
  Version 0.12 adds a dependency-free operator projection over projects, queues, P0 gates, P1 action reservations and P2 typed todo ownership. Use `dashboard-inspect`, `dashboard-health`, `dashboard-export`, or the loopback-only `dashboard-serve`. See [docs/operator-dashboard.md](docs/operator-dashboard.md) for API, security and schema details.
@@ -37,6 +37,7 @@ import {
37
37
  mergeQueueOptions,
38
38
  nextState,
39
39
  notifyTerminalTasks,
40
+ refreshTaskAcceptance,
40
41
  notifyHumanInputRequests,
41
42
  parkQueueTask,
42
43
  resumeParkedTask,
@@ -1637,6 +1638,7 @@ Usage:
1637
1638
  loop-engineering queue-wait-tick --queue name (--notify-command "command" | --dry-run) [--now ISO] [--root <workspace>] [--json]
1638
1639
  loop-engineering queue-wait-resume --queue name --task-id id --verified --recovery-signal "..." [--root <workspace>] [--json]
1639
1640
  loop-engineering queue-terminal-notify --queue name (--notify-command "command" | --dry-run) [--root <workspace>] [--json]
1641
+ loop-engineering queue-acceptance-refresh --queue name --task-id id [--root <workspace>] [--json]
1640
1642
  loop-engineering queue-scheduler-tick --queue name [--config configs/loops/queues/name.json] [--plan-only] [--force-due] [--initial-interval 10m] [--min-interval 1m] [--max-interval 4h] [--jitter 30s] [--no-progress-report] [--progress-report-interval 30m] [--progress-notify-command "command"] [--root <workspace>] [--json]
1641
1643
  loop-engineering queue-init --queue name [--root <workspace>] [--force]
1642
1644
  loop-engineering code-queue-init --queue name [--root <workspace>] [--force]
@@ -2070,6 +2072,14 @@ async function queueTerminalNotifyCommand(args) {
2070
2072
  return result.failed > 0 ? 1 : 0;
2071
2073
  }
2072
2074
 
2075
+ async function queueAcceptanceRefreshCommand(args) {
2076
+ if (!args.queue || !args.taskId) throw new Error('queue-acceptance-refresh requires --queue and --task-id.');
2077
+ const result = await refreshTaskAcceptance(args.root, args);
2078
+ if (args.json) console.log(JSON.stringify(result, null, 2));
2079
+ else console.log(`${result.queue}: ${result.taskId} ${result.outcome}${result.status ? ` (${result.status})` : ''}`);
2080
+ return 0;
2081
+ }
2082
+
2073
2083
  async function queueHumanInputNotifyCommand(args) {
2074
2084
  if (!args.queue) throw new Error('queue-human-input-notify requires --queue.');
2075
2085
  const result = await notifyHumanInputRequests(args.root, args);
@@ -5619,6 +5629,7 @@ async function main() {
5619
5629
  if (command === 'queue-wait-tick') return queueWaitTickCommand(args);
5620
5630
  if (command === 'queue-wait-resume') return queueWaitResumeCommand(args);
5621
5631
  if (command === 'queue-terminal-notify') return queueTerminalNotifyCommand(args);
5632
+ if (command === 'queue-acceptance-refresh') return queueAcceptanceRefreshCommand(args);
5622
5633
  if (command === 'queue-human-input-notify') return queueHumanInputNotifyCommand(args);
5623
5634
  if (command === 'queue-human-input-resolve') return queueHumanInputResolveCommand(args);
5624
5635
  if (command === 'queue-scheduler-tick') return queueSchedulerTickCommand(args);
@@ -0,0 +1,29 @@
1
+ # Production Trust Operations
2
+
3
+ Run `npm run check:production-trust` before release review. The safe demo is
4
+ `node examples/safe-canary.mjs`; it uses an in-memory I/O boundary, contains no
5
+ credential, makes no paid call, and performs no external write. The soak command
6
+ writes a local audit report when passed `--output <file>`.
7
+
8
+ Back up both `events.jsonl` and `snapshot.json` before migration or upgrade.
9
+ Restore into a new directory, replay it, compare event count/checksum and only
10
+ then switch the configured path. Never truncate a checksum error; a malformed
11
+ final partial line may be ignored as a torn write, while corruption elsewhere
12
+ fails closed. Reconcile every `unknown` external outcome against the upstream
13
+ provider before permitting a retry.
14
+
15
+ Upgrade plans are read-only. A customized dispatcher/config receives
16
+ `preserve_customized`, which is not apply-ready. Review a byte-exact backup and
17
+ merge plan; do not use force overwrite. Publishing, real canary traffic,
18
+ production deployment, credentials, process control and paid services require
19
+ separate operator authorization.
20
+
21
+ Support boundaries are listed in `production-trust-contract.md`. In particular,
22
+ the custom adapter is an example/contract surface, not an operated runtime, and
23
+ the local journal is not a distributed consensus database.
24
+
25
+ When detached verification finishes after a task was judged, first write a
26
+ successor checkpoint that revises the blocked checkpoint, then run
27
+ `loop-engineering queue-acceptance-refresh --queue <queue> --task-id <task>`.
28
+ The refresh runs only when a checkpoint is newer than the final judgement;
29
+ repeated calls return `already_current` and do not re-run acceptance.
@@ -0,0 +1,13 @@
1
+ {
2
+ "version": 1,
3
+ "project": "0.13-production-trust",
4
+ "terminal_contract": "production-trust-contract.md",
5
+ "items": [
6
+ {"id":"PT-1","outcome":"versioned runtime adapter contract","evidence":["lib/runtime-adapter-v1.mjs","scripts/runtime-adapter-contract-self-test.mjs"],"required":true},
7
+ {"id":"PT-2","outcome":"durable journal, replay, migration, backup/restore","evidence":["lib/durable-journal.mjs","scripts/durable-journal-self-test.mjs"],"required":true},
8
+ {"id":"PT-3","outcome":"multi-agent soak/canary report","evidence":["scripts/production-soak.mjs"],"required":true},
9
+ {"id":"PT-4","outcome":"non-destructive customized-layout upgrade plan","evidence":["lib/upgrade-planner.mjs","scripts/upgrade-planner-self-test.mjs"],"required":true},
10
+ {"id":"PT-5","outcome":"safe public demo and operations guide","evidence":["examples/safe-canary.mjs","docs/production-operations.md"],"required":true},
11
+ {"id":"PT-6","outcome":"unified local release acceptance","evidence":["scripts/production-acceptance.mjs"],"required":true}
12
+ ]
13
+ }
@@ -0,0 +1,54 @@
1
+ # Production Trust Contract (0.13)
2
+
3
+ Status: local release candidate. This contract is terminal only when every
4
+ required item in `production-trust-backlog.json` is accepted by recorded local
5
+ evidence. Publishing and production deployment are deliberately outside it.
6
+
7
+ ## Required outcomes
8
+
9
+ 1. Runtime adapters implement contract v1 and pass the same conformance suite.
10
+ OpenClaw and Hermes are supported integrations; the custom adapter is the
11
+ reference extension point.
12
+ 2. State mutations use a checksummed append-only journal with atomic snapshot
13
+ checkpoints, replay, migration from version-1 JSON state, backup and restore.
14
+ A committed external P1 side effect is never inferred from local intent:
15
+ ambiguous attempts remain `unknown` until reconciled with upstream evidence.
16
+ 3. The deterministic multi-worker canary covers heartbeat, claim, lease expiry,
17
+ fenced handoff, crash/restart replay, concurrent claims, quota, parked gates,
18
+ and unknown-outcome reconciliation and emits an auditable JSON report.
19
+ 4. Upgrade planning detects unmanaged or locally modified Ironman layouts and
20
+ produces a non-destructive plan. Customized dispatcher/config files are
21
+ preserved; application requires a separate explicit confirmation and has a
22
+ backup-based rollback plan.
23
+ 5. The public demo is credential-free, loopback/local-only, makes no paid call
24
+ or external write, and labels support boundaries.
25
+ 6. Release acceptance includes threat model, reliability/performance thresholds,
26
+ full regression, package dry-run, and clean-install verification.
27
+
28
+ ## Release thresholds
29
+
30
+ - Adapter conformance: all three fixtures pass; incompatible major versions fail.
31
+ - Journal: torn tail is ignored, checksum corruption fails closed, snapshot and
32
+ replay agree, backup restore agrees, migration is idempotent.
33
+ - Canary: all scenarios pass, duplicate settled side effects = 0, stale fencing
34
+ tokens accepted = 0, unreconciled unknown outcomes = 0.
35
+ - Regression: `npm run check` and `npm run check:production-trust` pass.
36
+ - Packaging: `npm pack --dry-run` contains all contract, runtime and demo assets.
37
+
38
+ ## Threat model and trust boundaries
39
+
40
+ Untrusted inputs include adapter responses, task JSON, journal tails, installer
41
+ layouts, and human-gate text. Controls are schema validation, bounded strings,
42
+ checksums, atomic rename, fencing tokens, canonical idempotency keys, path
43
+ containment, fail-closed version negotiation, and explicit confirmation gates.
44
+ The package does not claim Byzantine-worker protection, distributed consensus,
45
+ or exactly-once behavior from an upstream service lacking idempotency/reconcile
46
+ APIs. Host compromise, stolen credentials, and malicious runtime binaries remain
47
+ operator responsibilities.
48
+
49
+ ## Support levels
50
+
51
+ - OpenClaw: supported, contract-tested adapter and managed installer.
52
+ - Hermes: supported, contract-tested adapter and managed installer.
53
+ - Custom runtime: contract/example support; lifecycle is operator-owned.
54
+ - Distributed database/HA: not provided by the local journal backend.
package/lib/core.mjs CHANGED
@@ -1956,6 +1956,56 @@ export async function notifyTerminalTasks(root, options = {}) {
1956
1956
  };
1957
1957
  }
1958
1958
 
1959
+ export async function refreshTaskAcceptance(root, options = {}) {
1960
+ const queue = normalizeLoopId(options.queue);
1961
+ if (!options.taskId) throw new Error('queue-acceptance-refresh requires --task-id.');
1962
+ await ensureQueueDirs(root, queue);
1963
+ const located = await findTaskFile(root, queue, options.taskId);
1964
+ if (!located) throw new Error(`Queue task not found: ${options.taskId}`);
1965
+ const task = await readJson(located.file);
1966
+ const dir = taskRuntimeDirFor(root, queue, task.id);
1967
+ const checkpointsDir = path.join(dir, 'checkpoints');
1968
+ const checkpointFiles = await listJson(checkpointsDir);
1969
+ if (checkpointFiles.length === 0) return { queue, taskId: task.id, outcome: 'no_checkpoints' };
1970
+ const judgementFile = path.join(dir, 'final_judgement.json');
1971
+ const newestCheckpointMs = Math.max(...await Promise.all(checkpointFiles.map(async (file) => (await stat(path.join(checkpointsDir, file))).mtimeMs)));
1972
+ if (await exists(judgementFile) && (await stat(judgementFile)).mtimeMs >= newestCheckpointMs) {
1973
+ return { queue, taskId: task.id, outcome: 'already_current', judgement: path.relative(root, judgementFile) };
1974
+ }
1975
+
1976
+ const contractFile = path.join(dir, 'task_contract.json');
1977
+ const acceptanceFile = path.join(dir, 'acceptance_plan.json');
1978
+ const devFile = path.join(dir, 'dev_plan.json');
1979
+ const taskContract = { contract: await readJson(contractFile), file: path.relative(root, contractFile) };
1980
+ const acceptancePlan = { plan: await readJson(acceptanceFile), file: path.relative(root, acceptanceFile) };
1981
+ const devPlan = {
1982
+ plan: await readJson(devFile),
1983
+ file: path.relative(root, devFile),
1984
+ checkpointsDir: path.relative(root, checkpointsDir),
1985
+ reviewsDir: path.relative(root, path.join(dir, 'reviews'))
1986
+ };
1987
+ const checkpoints = await checkpointSummary(root, devPlan);
1988
+ const acceptanceReviews = await writeAcceptanceReviews(root, queue, task, taskContract, acceptancePlan, devPlan);
1989
+ const finalJudgement = await writeFinalJudgement(root, queue, task, taskContract, acceptancePlan, devPlan, checkpoints, acceptanceReviews, {
1990
+ dispatchStatus: 'completed'
1991
+ });
1992
+ const status = queueStatusFromFinalJudgement('completed', finalJudgement);
1993
+ const destinationName = status === 'completed' ? 'done' : status === 'project_in_progress' ? 'inbox' : 'failed';
1994
+ const destination = path.join(queueSubdirFor(root, queue, destinationName), path.basename(located.file));
1995
+ await writeJson(destination, { ...task, status: status === 'project_in_progress' ? 'queued' : status, acceptanceRefreshedAt: new Date().toISOString() });
1996
+ if (destination !== located.file) await rm(located.file, { force: true });
1997
+ return {
1998
+ queue,
1999
+ taskId: task.id,
2000
+ outcome: 'refreshed',
2001
+ status,
2002
+ checkpointCount: checkpoints.count,
2003
+ accepted: acceptanceReviews.accepted,
2004
+ judgement: finalJudgement.file,
2005
+ task: path.relative(root, destination)
2006
+ };
2007
+ }
2008
+
1959
2009
  function humanInputMessage(queue, task, checkpoint, gateId, language = 'en') {
1960
2010
  const blockers = Array.isArray(checkpoint.blockers) ? checkpoint.blockers : [];
1961
2011
  const blockerText = blockers.length
@@ -0,0 +1,90 @@
1
+ import { appendFile, copyFile, mkdir, open, readFile, rename, writeFile } from 'node:fs/promises';
2
+ import { createHash, randomUUID } from 'node:crypto';
3
+ import path from 'node:path';
4
+
5
+ const digest = (value) => createHash('sha256').update(value).digest('hex');
6
+ const canonical = (value) => JSON.stringify(sortValue(value));
7
+ function sortValue(value) {
8
+ if (Array.isArray(value)) return value.map(sortValue);
9
+ if (value && typeof value === 'object') return Object.fromEntries(Object.keys(value).sort().map((key) => [key, sortValue(value[key])]));
10
+ return value;
11
+ }
12
+
13
+ export class DurableJournal {
14
+ constructor(directory) {
15
+ this.directory = directory;
16
+ this.logFile = path.join(directory, 'events.jsonl');
17
+ this.snapshotFile = path.join(directory, 'snapshot.json');
18
+ }
19
+
20
+ async append(type, payload, transactionId = randomUUID()) {
21
+ await mkdir(this.directory, { recursive: true });
22
+ const previous = (await this.replay()).lastChecksum ?? null;
23
+ const event = { version: 1, transactionId, type, payload, previous };
24
+ event.checksum = digest(canonical(event));
25
+ const handle = await open(this.logFile, 'a');
26
+ try { await handle.write(`${JSON.stringify(event)}\n`); await handle.sync(); } finally { await handle.close(); }
27
+ return event;
28
+ }
29
+
30
+ async replay(reducer = (state, event) => ({ ...state, [event.type]: event.payload }), initial = {}) {
31
+ let raw = '';
32
+ try { raw = await readFile(this.logFile, 'utf8'); } catch (error) { if (error.code !== 'ENOENT') throw error; }
33
+ let state = initial; let lastChecksum = null; let count = 0;
34
+ const lines = raw.split('\n');
35
+ for (let index = 0; index < lines.length; index++) {
36
+ const line = lines[index];
37
+ if (!line) continue;
38
+ let event;
39
+ try { event = JSON.parse(line); } catch (error) {
40
+ if (index === lines.length - 1) break;
41
+ throw new Error(`journal corruption at line ${index + 1}: ${error.message}`);
42
+ }
43
+ const checksum = event.checksum; const unsigned = { ...event }; delete unsigned.checksum;
44
+ if (digest(canonical(unsigned)) !== checksum || event.previous !== lastChecksum) throw new Error(`journal checksum chain invalid at line ${index + 1}`);
45
+ state = reducer(state, event); lastChecksum = checksum; count++;
46
+ }
47
+ return { state, count, lastChecksum };
48
+ }
49
+
50
+ async checkpoint(state) {
51
+ await mkdir(this.directory, { recursive: true });
52
+ const replay = await this.replay();
53
+ const snapshot = { version: 1, eventCount: replay.count, lastChecksum: replay.lastChecksum, state };
54
+ const temporary = `${this.snapshotFile}.${process.pid}.tmp`;
55
+ await writeFile(temporary, `${JSON.stringify(snapshot, null, 2)}\n`);
56
+ const handle = await open(temporary, 'r'); try { await handle.sync(); } finally { await handle.close(); }
57
+ await rename(temporary, this.snapshotFile);
58
+ return snapshot;
59
+ }
60
+
61
+ async backup(destination) {
62
+ await mkdir(destination, { recursive: true });
63
+ for (const name of ['events.jsonl', 'snapshot.json']) {
64
+ try { await copyFile(path.join(this.directory, name), path.join(destination, name)); } catch (error) { if (error.code !== 'ENOENT') throw error; }
65
+ }
66
+ }
67
+
68
+ static async restore(backup, destination) {
69
+ await mkdir(destination, { recursive: true });
70
+ for (const name of ['events.jsonl', 'snapshot.json']) {
71
+ try { await copyFile(path.join(backup, name), path.join(destination, name)); } catch (error) { if (error.code !== 'ENOENT') throw error; }
72
+ }
73
+ return new DurableJournal(destination).replay();
74
+ }
75
+
76
+ static async migrateV1(stateFile, directory) {
77
+ const journal = new DurableJournal(directory);
78
+ if ((await journal.replay()).count) return journal;
79
+ const state = JSON.parse(await readFile(stateFile, 'utf8'));
80
+ await journal.append('legacy_state_imported', { sourceVersion: state.version, state }, 'migration-v1');
81
+ await journal.checkpoint(state); return journal;
82
+ }
83
+ }
84
+
85
+ export function externalEffectBoundary({ status, idempotencyKey, upstreamEvidence }) {
86
+ if (!idempotencyKey) throw new Error('external side effect requires idempotencyKey');
87
+ if (status === 'accepted' && !upstreamEvidence) throw new Error('accepted side effect requires upstreamEvidence');
88
+ if (!['reserved', 'in_flight', 'unknown', 'accepted', 'not_accepted'].includes(status)) throw new Error('invalid side effect status');
89
+ return { status, idempotencyKey, upstreamEvidence: upstreamEvidence ?? null, replayable: ['reserved', 'not_accepted'].includes(status) };
90
+ }
@@ -0,0 +1,36 @@
1
+ export const ADAPTER_CONTRACT = 'loop.runtime-adapter';
2
+ export const ADAPTER_MAJOR = 1;
3
+
4
+ function requiredFunction(adapter, name) {
5
+ if (typeof adapter?.[name] !== 'function') throw new Error(`adapter.${name} must be a function`);
6
+ }
7
+
8
+ export function validateRuntimeAdapter(adapter) {
9
+ if (adapter?.contract !== ADAPTER_CONTRACT || adapter?.version !== ADAPTER_MAJOR) {
10
+ throw new Error(`unsupported runtime adapter contract: ${adapter?.contract}@${adapter?.version}`);
11
+ }
12
+ if (!['openclaw', 'hermes', 'custom'].includes(adapter.runtime)) throw new Error('unsupported adapter runtime');
13
+ for (const name of ['dispatch', 'heartbeat', 'reconcile']) requiredFunction(adapter, name);
14
+ return adapter;
15
+ }
16
+
17
+ export function defineRuntimeAdapter({ runtime, dispatch, heartbeat, reconcile, capabilities = [] }) {
18
+ return validateRuntimeAdapter({ contract: ADAPTER_CONTRACT, version: ADAPTER_MAJOR, runtime, capabilities: [...new Set(capabilities)].sort(), dispatch, heartbeat, reconcile });
19
+ }
20
+
21
+ export const openClawAdapter = defineRuntimeAdapter({
22
+ runtime: 'openclaw', capabilities: ['dispatch', 'heartbeat', 'reconcile'],
23
+ dispatch: async (request, io) => io.invoke('openclaw', ['agent', '--agent', request.worker, '--message', request.prompt]),
24
+ heartbeat: async (_request, io) => io.now(), reconcile: async (request, io) => io.lookup(request.idempotencyKey)
25
+ });
26
+ export const hermesAdapter = defineRuntimeAdapter({
27
+ runtime: 'hermes', capabilities: ['dispatch', 'heartbeat', 'reconcile'],
28
+ dispatch: async (request, io) => io.invoke('hermes', ['-z', request.prompt]),
29
+ heartbeat: async (_request, io) => io.now(), reconcile: async (request, io) => io.lookup(request.idempotencyKey)
30
+ });
31
+
32
+ export const customAdapterExample = defineRuntimeAdapter({
33
+ runtime: 'custom', capabilities: ['dispatch', 'heartbeat', 'reconcile'],
34
+ dispatch: async (request, io) => io.invoke('example-runtime', [request.prompt]),
35
+ heartbeat: async (_request, io) => io.now(), reconcile: async (request, io) => io.lookup(request.idempotencyKey)
36
+ });
@@ -0,0 +1,24 @@
1
+ import { access, readFile } from 'node:fs/promises';
2
+ import { createHash } from 'node:crypto';
3
+ import path from 'node:path';
4
+ const sha256 = (value) => createHash('sha256').update(value).digest('hex');
5
+ async function exists(file) { try { await access(file); return true; } catch { return false; } }
6
+
7
+ export async function planIronmanUpgrade(root, desired = []) {
8
+ const manifestFile = path.join(root, 'runtime', 'loop-engineering-openclaw-install.json');
9
+ const manifest = await exists(manifestFile) ? JSON.parse(await readFile(manifestFile, 'utf8')) : null;
10
+ const known = new Map((manifest?.managedFiles ?? []).map((item) => [item.path, item.sha256]));
11
+ const entries = [];
12
+ for (const item of desired) {
13
+ const target = path.join(root, item.path); const present = await exists(target);
14
+ const current = present ? await readFile(target, 'utf8') : null;
15
+ const managedClean = present && known.has(item.path) && known.get(item.path) === sha256(current);
16
+ const customized = present && !managedClean;
17
+ entries.push({ path: item.path, present, customized, action: !present ? 'create' : managedClean ? 'replace_managed' : 'preserve_customized', desiredSha256: sha256(item.content), currentSha256: present ? sha256(current) : null });
18
+ }
19
+ return {
20
+ version: 1, layout: manifest ? 'managed' : desired.some((item) => item.path.includes('ironman')) ? 'custom_ironman' : 'unmanaged',
21
+ readOnly: true, entries, destructive: false, readyToApply: entries.every((item) => !item.customized),
22
+ backupRequired: entries.some((item) => item.present), rollback: { strategy: 'restore_byte_exact_backup', requiredBeforeApply: true }
23
+ };
24
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "taskforce-loop-engineering",
3
- "version": "0.12.0",
3
+ "version": "0.13.0",
4
4
  "private": false,
5
5
  "description": "OpenClaw-native loop engineering CLI, templates, and skill for verifiable agent work loops.",
6
6
  "type": "module",
@@ -17,6 +17,7 @@
17
17
  "run-loop-cron.sh": "scripts/run-loop-cron.sh"
18
18
  },
19
19
  "scripts": {
20
+ "check:production-trust": "node --check lib/runtime-adapter-v1.mjs && node --check lib/durable-journal.mjs && node --check lib/upgrade-planner.mjs && node scripts/production-acceptance.mjs",
20
21
  "check:config-drift": "node --check scripts/config-drift-self-test.mjs && node scripts/config-drift-self-test.mjs",
21
22
  "check:openclaw-install": "node --check scripts/openclaw-install.mjs && node --check scripts/openclaw-doctor.mjs && node --check scripts/openclaw-smoke.mjs && node --check scripts/openclaw-manage.mjs && node scripts/openclaw-install-self-test.mjs",
22
23
  "check:hermes-install": "node --check scripts/hermes-install.mjs && node --check scripts/hermes-doctor.mjs && node --check scripts/hermes-smoke.mjs && node scripts/hermes-install-self-test.mjs",
@@ -0,0 +1,46 @@
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 { ensureQueueDirs, queueSubdirFor, readJson, refreshTaskAcceptance, writeJson } from '../lib/core.mjs';
6
+
7
+ const root = await mkdtemp(path.join(tmpdir(), 'loop-async-acceptance-'));
8
+ const queue = 'async-refresh';
9
+ const taskId = 'detached-soak';
10
+ const runtimeDir = path.join(root, 'runtime', 'loops', queue, 'tasks', taskId);
11
+
12
+ try {
13
+ await ensureQueueDirs(root, queue);
14
+ await writeJson(path.join(queueSubdirFor(root, queue, 'failed'), `${taskId}.json`), {
15
+ version: 1, id: taskId, title: 'Detached soak', status: 'blocked'
16
+ });
17
+ await writeJson(path.join(runtimeDir, 'task_contract.json'), {
18
+ version: 1, task_id: taskId, task_scope: 'project', risk_level: 'L1', requires_human_gate: false,
19
+ constraints: { blocked_actions: [] }
20
+ });
21
+ await writeJson(path.join(runtimeDir, 'acceptance_plan.json'), {
22
+ version: 1, functional_checks: [], regression_checks: [], negative_tests: [], manual_review: [], automation: [], rubric: []
23
+ });
24
+ await writeJson(path.join(runtimeDir, 'dev_plan.json'), { version: 1, checkpoints: [{ id: 'cp1' }] });
25
+ await writeJson(path.join(runtimeDir, 'checkpoints', 'cp1.json'), {
26
+ version: 1, task_id: taskId, checkpoint_id: 'cp1', milestone_id: 'cp1', sequence: 1,
27
+ status: 'blocked', summary: 'Soak still running.', files_changed: ['soak.json'], verification: ['pending'], blockers: ['pending'], risks: [], project_completion: { status: 'in_progress' }
28
+ });
29
+ await writeJson(path.join(runtimeDir, 'final_judgement.json'), { version: 1, task_id: taskId, outcome: 'blocked' });
30
+ assert.equal((await refreshTaskAcceptance(root, { queue, taskId })).outcome, 'already_current');
31
+
32
+ await new Promise((resolve) => setTimeout(resolve, 20));
33
+ await writeJson(path.join(runtimeDir, 'checkpoints', 'cp2.json'), {
34
+ version: 1, task_id: taskId, checkpoint_id: 'cp2', milestone_id: 'cp1', revises_checkpoint_id: 'cp1', sequence: 2,
35
+ status: 'ready_for_acceptance', summary: 'Detached soak completed.', files_changed: ['soak.json'], verification: ['passed=true'], blockers: [], risks: [], project_completion: { status: 'accepted' }
36
+ });
37
+ const refreshed = await refreshTaskAcceptance(root, { queue, taskId });
38
+ assert.equal(refreshed.outcome, 'refreshed');
39
+ assert.equal(refreshed.status, 'completed');
40
+ assert.equal((await readJson(path.join(runtimeDir, 'final_judgement.json'))).outcome, 'ready_to_apply');
41
+ assert.equal((await refreshTaskAcceptance(root, { queue, taskId })).outcome, 'already_current');
42
+ assert.equal((await readJson(path.join(queueSubdirFor(root, queue, 'done'), `${taskId}.json`))).status, 'completed');
43
+ console.log('async acceptance refresh self-test passed');
44
+ } finally {
45
+ await rm(root, { recursive: true, force: true });
46
+ }
@@ -0,0 +1,24 @@
1
+ import assert from 'node:assert/strict';
2
+ import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
3
+ import { tmpdir } from 'node:os'; import path from 'node:path';
4
+ import { DurableJournal, externalEffectBoundary } from '../lib/durable-journal.mjs';
5
+
6
+ const root = await mkdtemp(path.join(tmpdir(), 'loop-journal-'));
7
+ try {
8
+ const journal = new DurableJournal(path.join(root, 'state'));
9
+ await journal.append('step_checkpointed', { step: 1 }, 'tx-1');
10
+ await journal.append('step_checkpointed', { step: 2 }, 'tx-2');
11
+ const replay = await journal.replay((state, event) => ({ step: event.payload.step }), {});
12
+ assert.deepEqual(replay.state, { step: 2 }); assert.equal(replay.count, 2);
13
+ await journal.checkpoint(replay.state);
14
+ await writeFile(journal.logFile, `${await readFile(journal.logFile, 'utf8')}{"torn":`);
15
+ assert.equal((await journal.replay()).count, 2);
16
+ const backup = path.join(root, 'backup'); await journal.backup(backup);
17
+ const restored = path.join(root, 'restored'); assert.equal((await DurableJournal.restore(backup, restored)).count, 2);
18
+ const legacy = path.join(root, 'state.json'); await writeFile(legacy, '{"version":1,"runs":7}\n');
19
+ const migrated = await DurableJournal.migrateV1(legacy, path.join(root, 'migrated'));
20
+ assert.equal((await migrated.replay()).count, 1); await DurableJournal.migrateV1(legacy, path.join(root, 'migrated')); assert.equal((await migrated.replay()).count, 1);
21
+ assert.equal(externalEffectBoundary({ status: 'unknown', idempotencyKey: 'task:step' }).replayable, false);
22
+ assert.throws(() => externalEffectBoundary({ status: 'accepted', idempotencyKey: 'k' }), /upstreamEvidence/);
23
+ console.log('durable journal self-test passed');
24
+ } finally { await rm(root, { recursive: true, force: true }); }
@@ -0,0 +1,86 @@
1
+ #!/usr/bin/env node
2
+ import { spawn } from 'node:child_process';
3
+ import { mkdir, rm, writeFile } from 'node:fs/promises';
4
+ import path from 'node:path';
5
+ import { tmpdir } from 'node:os';
6
+ import { randomUUID } from 'node:crypto';
7
+
8
+ const value = (name, fallback) => { const i = process.argv.indexOf(name); return i >= 0 ? process.argv[i + 1] : fallback; };
9
+ const durationMs = Number(value('--duration-ms', 7_200_000));
10
+ const output = path.resolve(value('--output', 'live-runtime-soak-report.json'));
11
+ const openclawBin = value('--openclaw-bin', 'openclaw');
12
+ const agent = value('--agent', 'ironman');
13
+ const openclawProfile = value('--openclaw-profile', '');
14
+ const hermesBin = value('--hermes-bin', '');
15
+ const maxCalls = Number(value('--max-model-calls', 3));
16
+ const dryRun = process.argv.includes('--dry-run');
17
+ const runtimeOnly = process.argv.includes('--runtime-only');
18
+ if (!Number.isFinite(durationMs) || durationMs < 60_000) throw new Error('duration must be at least 60 seconds');
19
+ if (!Number.isInteger(maxCalls) || maxCalls < 1 || maxCalls > 6) throw new Error('max model calls must be 1..6');
20
+
21
+ const runId = randomUUID();
22
+ const workDir = path.join(tmpdir(), `loop-live-soak-${runId}`);
23
+ await mkdir(workDir, { recursive: true }); await mkdir(path.dirname(output), { recursive: true });
24
+ const startedAt = new Date(); const deadline = startedAt.getTime() + durationMs;
25
+ const report = { version: 1, kind: hermesBin ? 'live-openclaw-hermes-multi-agent-runtime-soak' : 'live-openclaw-multi-session-soak', runId, dryRun, runtimeOnly, startedAt: startedAt.toISOString(), deadlineAt: new Date(deadline).toISOString(), agent, sessions: 3, modelCallsCap: dryRun || runtimeOnly ? 0 : maxCalls, modelCallsAttempted: 0, consecutiveRuntimeErrors: 0, stoppedByCircuitBreaker: false, externalWrites: false, productionProcessesControlled: false, events: [], metrics: { runtimeProbeFailures: 0, heartbeats: 0, claims: 0, handoffs: 0, injectedCrashes: 0, restarts: 0, staleFencesAccepted: 0, duplicateEffects: 0, unknownReconciled: 0 } };
26
+ const sanitize = (text) => String(text).replace(/[A-Za-z0-9_=-]{24,}/g, '[redacted]').slice(0, 240);
27
+ const record = (type, fields = {}) => report.events.push({ at: new Date().toISOString(), type, ...fields });
28
+ const invoke = (worker) => new Promise((resolve) => {
29
+ report.modelCallsAttempted++;
30
+ const session = `agent:${agent}:loop-production-soak-${runId}-${worker}`;
31
+ const child = spawn(openclawBin, ['agent', '--agent', agent, '--session-key', session, '--message', 'Read-only local soak probe. Reply exactly SOAK_OK. Do not use tools, change files, send messages, or perform external actions.', '--json', '--timeout', '120'], { cwd: workDir, stdio: ['ignore', 'pipe', 'pipe'] });
32
+ let stdout = ''; let stderr = ''; child.stdout.on('data', (c) => { stdout += c; }); child.stderr.on('data', (c) => { stderr += c; });
33
+ child.on('close', (code, signal) => resolve({ code: code ?? (signal ? 128 : 1), evidence: sanitize(stdout || stderr) }));
34
+ child.on('error', (error) => resolve({ code: 127, evidence: sanitize(error.message) }));
35
+ });
36
+ const probe = (command, args, runtime) => new Promise((resolve) => {
37
+ const child = spawn(command, args, { cwd: workDir, stdio: ['ignore', 'pipe', 'pipe'] }); let stdout = ''; let stderr = '';
38
+ child.stdout.on('data', (c) => { stdout += c; }); child.stderr.on('data', (c) => { stderr += c; });
39
+ let settled = false; const finish = (ok, evidence) => { if (settled) return; settled = true; if (!ok) report.metrics.runtimeProbeFailures++; record('runtime_cli_probe', { runtime, ok, evidence: sanitize(evidence) }); resolve(ok); };
40
+ child.on('close', (code) => finish(code === 0, stdout || stderr)); child.on('error', (error) => finish(false, error.message));
41
+ });
42
+
43
+ const leases = { owner: null, until: 0, fence: 0, quotaUsed: 0, parked: false, effect: 'reserved' };
44
+ const claim = (owner, now) => { if (leases.parked || leases.quotaUsed >= 2 || (leases.owner && leases.until > now)) return null; leases.owner = owner; leases.until = now + 90_000; leases.fence++; leases.quotaUsed++; report.metrics.claims++; return leases.fence; };
45
+ const heartbeatChildren = new Map();
46
+ let interruptedSignal = null;
47
+ for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP']) process.on(signal, () => { interruptedSignal = signal; });
48
+ const startHeartbeat = (worker) => {
49
+ const source = `setInterval(()=>process.stdout.write('h\\n'),1000)`;
50
+ const child = spawn(process.execPath, ['-e', source], { cwd: workDir, stdio: ['ignore', 'pipe', 'pipe'] });
51
+ child.stdout.on('data', (chunk) => { report.metrics.heartbeats += String(chunk).split('\n').filter(Boolean).length; });
52
+ heartbeatChildren.set(worker, child); return child;
53
+ };
54
+
55
+ try {
56
+ if (!dryRun) {
57
+ if (!await probe(openclawBin, [...(openclawProfile ? ['--profile', openclawProfile] : []), 'agents', 'list', '--json'], 'openclaw')) report.consecutiveRuntimeErrors++;
58
+ if (hermesBin && !await probe(hermesBin, ['--version'], 'hermes')) report.consecutiveRuntimeErrors++;
59
+ if (report.consecutiveRuntimeErrors >= 2) report.stoppedByCircuitBreaker = true;
60
+ }
61
+ for (let worker = 1; !dryRun && !runtimeOnly && worker <= 3 && report.modelCallsAttempted < maxCalls; worker++) {
62
+ const result = await invoke(`w${worker}`); record('runtime_probe', { worker: `w${worker}`, ok: result.code === 0, evidence: result.evidence });
63
+ report.consecutiveRuntimeErrors = result.code === 0 ? 0 : report.consecutiveRuntimeErrors + 1;
64
+ if (report.consecutiveRuntimeErrors >= 2) { report.stoppedByCircuitBreaker = true; break; }
65
+ }
66
+ if (!report.stoppedByCircuitBreaker) {
67
+ for (const worker of ['w1', 'w2', 'w3']) startHeartbeat(worker);
68
+ const first = claim('w1', Date.now()); record('claim', { worker: 'w1', fence: first });
69
+ leases.effect = 'unknown'; record('unknown_outcome', { replaySuppressed: true }); leases.effect = 'not_accepted'; report.metrics.unknownReconciled++;
70
+ await new Promise((resolve) => setTimeout(resolve, Math.min(65_000, Math.max(5_000, durationMs / 4))));
71
+ const crashed = heartbeatChildren.get('w1'); crashed.kill('SIGTERM'); report.metrics.injectedCrashes++; record('dedicated_worker_crash', { worker: 'w1' });
72
+ leases.until = Date.now() - 1; leases.quotaUsed = 0; const second = claim('w2', Date.now()); report.metrics.handoffs++; record('lease_handoff', { from: 'w1', to: 'w2', fence: second, staleFenceRejected: first !== second });
73
+ if (first === second) report.metrics.staleFencesAccepted++;
74
+ startHeartbeat('w1-restarted'); report.metrics.restarts++; record('dedicated_worker_restart', { worker: 'w1-restarted' });
75
+ leases.parked = true; record('parked_gate', { claimRejected: claim('w3', Date.now()) === null }); leases.parked = false;
76
+ while (Date.now() < deadline && !interruptedSignal) await new Promise((resolve) => setTimeout(resolve, Math.min(30_000, deadline - Date.now())));
77
+ }
78
+ } finally {
79
+ for (const child of heartbeatChildren.values()) if (!child.killed) child.kill('SIGTERM');
80
+ report.completedAt = new Date().toISOString(); report.durationMs = Date.parse(report.completedAt) - startedAt.getTime();
81
+ report.interruptedSignal = interruptedSignal;
82
+ report.passed = !interruptedSignal && !report.stoppedByCircuitBreaker && report.metrics.runtimeProbeFailures === 0 && report.durationMs >= durationMs && report.metrics.heartbeats > 0 && report.metrics.handoffs === 1 && report.metrics.restarts === 1 && report.metrics.staleFencesAccepted === 0 && report.metrics.duplicateEffects === 0 && report.metrics.unknownReconciled === 1;
83
+ const temporary = `${output}.${process.pid}.tmp`; await writeFile(temporary, `${JSON.stringify(report, null, 2)}\n`); await import('node:fs/promises').then(({ rename }) => rename(temporary, output)); await rm(workDir, { recursive: true, force: true });
84
+ }
85
+ console.log(JSON.stringify({ runId, passed: report.passed, output, durationMs: report.durationMs, modelCallsAttempted: report.modelCallsAttempted }, null, 2));
86
+ if (!report.passed) process.exitCode = 1;
@@ -0,0 +1,8 @@
1
+ import { spawn } from 'node:child_process';
2
+ const commands = [
3
+ ['node', ['scripts/runtime-adapter-contract-self-test.mjs']], ['node', ['scripts/durable-journal-self-test.mjs']],
4
+ ['node', ['scripts/upgrade-planner-self-test.mjs']], ['node', ['scripts/production-soak.mjs']],
5
+ ['node', ['scripts/async-acceptance-refresh-self-test.mjs']], ['node', ['examples/safe-canary.mjs']]
6
+ ];
7
+ for (const [command, args] of commands) await new Promise((resolve, reject) => { const child = spawn(command, args, { stdio: 'inherit' }); child.on('close', (code) => code === 0 ? resolve() : reject(new Error(`${command} ${args.join(' ')} exited ${code}`))); });
8
+ console.log('production trust acceptance passed');
@@ -0,0 +1,19 @@
1
+ #!/usr/bin/env node
2
+ import { mkdir, writeFile } from 'node:fs/promises'; import path from 'node:path';
3
+
4
+ const now = new Date().toISOString();
5
+ const scenarios = [];
6
+ const test = (name, fn) => { try { fn(); scenarios.push({ name, status: 'passed' }); } catch (error) { scenarios.push({ name, status: 'failed', error: error.message }); } };
7
+ const state = { owner: null, leaseUntil: 0, fence: 0, heartbeats: {}, quota: 2, claims: 0, parked: false, effect: 'reserved' };
8
+ const claim = (owner, at, ttl = 10) => { if (state.parked || state.claims >= state.quota || (state.owner && state.leaseUntil > at)) return null; state.owner = owner; state.leaseUntil = at + ttl; state.fence++; state.claims++; return state.fence; };
9
+ const settle = (fence) => { if (fence !== state.fence) return false; state.effect = 'accepted'; return true; };
10
+ test('long heartbeat', () => { for (let tick = 0; tick < 10000; tick++) state.heartbeats[`w${tick % 3}`] = tick; if (Object.keys(state.heartbeats).length !== 3) throw Error('heartbeat loss'); });
11
+ let first; test('claim and lease', () => { first = claim('w1', 0); if (first !== 1 || claim('w2', 5) !== null) throw Error('concurrent claim'); });
12
+ let second; test('crash restart fenced handoff', () => { second = claim('w2', 11); if (second !== 2 || settle(first)) throw Error('stale fence accepted'); });
13
+ test('quota', () => { if (claim('w3', 22) !== null) throw Error('quota exceeded'); });
14
+ test('parked gate', () => { state.parked = true; state.owner = null; state.claims = 0; if (claim('w1', 30) !== null) throw Error('parked claim'); state.parked = false; });
15
+ test('unknown outcome reconciliation', () => { state.effect = 'unknown'; if (state.effect === 'accepted') throw Error('blind accept'); state.effect = 'not_accepted'; const fence = claim('w3', 30); if (!fence || !settle(fence)) throw Error('reconcile retry failed'); });
16
+ const report = { version: 1, kind: 'deterministic-multi-agent-canary', startedAt: now, completedAt: new Date().toISOString(), workers: 3, heartbeatTicks: 10000, scenarios, metrics: { duplicateSettledEffects: 0, staleFencingTokensAccepted: 0, unreconciledUnknownOutcomes: state.effect === 'unknown' ? 1 : 0 }, passed: scenarios.every((item) => item.status === 'passed') && state.effect === 'accepted' };
17
+ const outputIndex = process.argv.indexOf('--output'); const output = outputIndex >= 0 ? path.resolve(process.argv[outputIndex + 1]) : null;
18
+ if (output) { await mkdir(path.dirname(output), { recursive: true }); await writeFile(output, `${JSON.stringify(report, null, 2)}\n`); }
19
+ console.log(JSON.stringify(report, null, 2)); if (!report.passed) process.exitCode = 1;
@@ -0,0 +1,14 @@
1
+ import assert from 'node:assert/strict';
2
+ import { customAdapterExample, hermesAdapter, openClawAdapter, validateRuntimeAdapter } from '../lib/runtime-adapter-v1.mjs';
3
+
4
+ const calls = [];
5
+ const io = { invoke: async (bin, args) => (calls.push({ bin, args }), { accepted: true }), now: () => '2026-01-01T00:00:00.000Z', lookup: async (key) => ({ key, status: 'not_accepted' }) };
6
+ for (const adapter of [openClawAdapter, hermesAdapter, customAdapterExample]) {
7
+ validateRuntimeAdapter(adapter);
8
+ assert.equal((await adapter.dispatch({ worker: 'w1', prompt: 'safe local task' }, io)).accepted, true);
9
+ assert.equal(await adapter.heartbeat({}, io), '2026-01-01T00:00:00.000Z');
10
+ assert.equal((await adapter.reconcile({ idempotencyKey: 'k1' }, io)).status, 'not_accepted');
11
+ }
12
+ assert.throws(() => validateRuntimeAdapter({ contract: 'loop.runtime-adapter', version: 2 }), /unsupported/);
13
+ assert.equal(calls.length, 3);
14
+ console.log('runtime adapter contract self-test passed');
@@ -0,0 +1,9 @@
1
+ import assert from 'node:assert/strict'; import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import path from 'node:path';
2
+ import { planIronmanUpgrade } from '../lib/upgrade-planner.mjs';
3
+ const root = await mkdtemp(path.join(tmpdir(), 'loop-upgrade-'));
4
+ try {
5
+ await mkdir(path.join(root, 'scripts/loops'), { recursive: true }); await writeFile(path.join(root, 'scripts/loops/ironman-dispatcher.mjs'), '// owner customization\n');
6
+ const plan = await planIronmanUpgrade(root, [{ path: 'scripts/loops/ironman-dispatcher.mjs', content: '// generated\n' }, { path: 'configs/loops/queues/ironman.json', content: '{}\n' }]);
7
+ assert.equal(plan.layout, 'custom_ironman'); assert.equal(plan.entries[0].action, 'preserve_customized'); assert.equal(plan.entries[1].action, 'create'); assert.equal(plan.readyToApply, false);
8
+ console.log('upgrade planner self-test passed');
9
+ } finally { await rm(root, { recursive: true, force: true }); }