taskforce-loop-engineering 0.12.0 → 0.14.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.
Files changed (36) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/README.md +23 -0
  3. package/bin/loop-engineering.mjs +11 -0
  4. package/docs/adapter-sdk-terminal-contract.json +19 -0
  5. package/docs/production-operations.md +29 -0
  6. package/docs/production-trust-backlog.json +18 -0
  7. package/docs/production-trust-contract.md +53 -0
  8. package/docs/runtime-adapter-sdk.md +50 -0
  9. package/examples/adapter-sdk-demo.mjs +9 -0
  10. package/examples/code-worktree-queue.json +35 -0
  11. package/examples/queue-runner.json +37 -0
  12. package/examples/safe-canary.mjs +5 -0
  13. package/examples/workspace-health.json +35 -0
  14. package/lib/action-reservations.mjs +7 -0
  15. package/lib/core.mjs +66 -0
  16. package/lib/durable-journal.mjs +90 -0
  17. package/lib/execution-ledger.mjs +41 -0
  18. package/lib/operator-dashboard.mjs +18 -4
  19. package/lib/production-evidence.mjs +54 -0
  20. package/lib/runtime-adapter-sdk.mjs +115 -0
  21. package/lib/runtime-adapter-v1.mjs +36 -0
  22. package/lib/upgrade-planner.mjs +24 -0
  23. package/package.json +5 -1
  24. package/scripts/async-acceptance-refresh-self-test.mjs +46 -0
  25. package/scripts/durable-journal-self-test.mjs +24 -0
  26. package/scripts/execution-ledger-self-test.mjs +10 -0
  27. package/scripts/live-runtime-soak.mjs +97 -0
  28. package/scripts/operator-dashboard-self-test.mjs +10 -0
  29. package/scripts/production-acceptance.mjs +10 -0
  30. package/scripts/production-evidence-self-test.mjs +17 -0
  31. package/scripts/production-soak.mjs +43 -0
  32. package/scripts/runtime-adapter-conformance.mjs +21 -0
  33. package/scripts/runtime-adapter-contract-self-test.mjs +14 -0
  34. package/scripts/upgrade-planner-self-test.mjs +9 -0
  35. package/templates/github-production-trust.yml +20 -0
  36. package/templates/production-evidence.schema.json +20 -0
@@ -0,0 +1,41 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { mkdir, readFile, readdir, rename, rm, stat, writeFile } from 'node:fs/promises';
3
+ import path from 'node:path';
4
+
5
+ export const EXECUTION_LEDGER_VERSION = 2;
6
+ const KINDS = new Set(['llm', 'tool', 'effect']);
7
+ const TERMINAL = new Set(['succeeded', 'failed', 'cancelled']);
8
+ const SENSITIVE = /(^|_)(secret|token|password|credential|api[_-]?key|private[_-]?key)(_|$)/i;
9
+ const canonicalValue = (value) => Array.isArray(value) ? value.map(canonicalValue) : value && typeof value === 'object' ? Object.fromEntries(Object.keys(value).sort().map((key) => [key, canonicalValue(value[key])])) : value;
10
+ const hash = (value) => createHash('sha256').update(typeof value === 'string' ? value : JSON.stringify(canonicalValue(value))).digest('hex');
11
+ export const stepInputFingerprint = (input) => hash(input);
12
+ function text(value, name) { if (typeof value !== 'string' || !value.trim()) throw new Error(`${name} must be a non-empty string.`); return value; }
13
+ function safeId(value, name = 'stepId') { text(value, name); if (!/^[a-zA-Z0-9][a-zA-Z0-9._:-]{0,299}$/.test(value)) throw new Error(`${name} contains unsafe characters or is too long.`); return value; }
14
+ function redact(value, key = '') { if (SENSITIVE.test(key)) return '[REDACTED]'; if (Array.isArray(value)) return value.map((item) => redact(item)); if (value && typeof value === 'object') return Object.fromEntries(Object.keys(value).sort().map((name) => [name, redact(value[name], name)])); return value; }
15
+ function locations(root, stepId) { const directory = path.join(path.resolve(root), 'runtime', 'loops', 'execution-ledger'); const key = hash(safeId(stepId)); return { file: path.join(directory, 'steps', `${key}.json`), lock: path.join(directory, 'locks', key) }; }
16
+ async function atomicWrite(file, value) { await mkdir(path.dirname(file), { recursive: true }); const temporary = `${file}.${process.pid}.${randomUUID()}.tmp`; await writeFile(temporary, `${JSON.stringify(value, null, 2)}\n`, { flag: 'wx', mode: 0o600 }); await rename(temporary, file); }
17
+ async function mutex(lock) { await mkdir(path.dirname(lock), { recursive: true }); const deadline = Date.now() + 5000; while (true) { try { await mkdir(lock); return; } catch (error) { if (error.code !== 'EEXIST') throw error; const info = await stat(lock).catch(() => null); if (info && Date.now() - info.mtimeMs > 30_000) await rm(lock, { recursive: true, force: true }); else if (Date.now() >= deadline) throw new Error('Timed out acquiring execution ledger mutex.'); else await new Promise((resolve) => setTimeout(resolve, 10)); } } }
18
+ async function mutate(root, stepId, operation) { const loc = locations(root, stepId); await mkdir(path.dirname(loc.file), { recursive: true }); await mutex(loc.lock); try { const record = await readFile(loc.file, 'utf8').then(JSON.parse).catch((error) => error.code === 'ENOENT' ? null : Promise.reject(error)); const result = await operation(record); if (result.write) await atomicWrite(loc.file, result.record); return result.output ?? result.record; } finally { await rm(loc.lock, { recursive: true, force: true }); } }
19
+ function event(record, type, data = {}) { const entry = { sequence: record.events.length + 1, type, at: new Date().toISOString(), ...redact(data), previous: record.events.at(-1)?.checksum ?? null }; entry.checksum = hash(entry); return entry; }
20
+ function assertFence(record, token) { if (record.status !== 'running' || record.lease?.fencing_token !== Number(token)) throw new Error('Stale or invalid fencing token.'); }
21
+
22
+ export async function registerStep(root, input) {
23
+ const stepId = safeId(input.stepId); const kind = text(input.kind, 'kind'); if (!KINDS.has(kind)) throw new Error(`Unsupported step kind: ${kind}`); const fingerprint = stepInputFingerprint(input.input);
24
+ return mutate(root, stepId, async (record) => {
25
+ if (record) { if (record.kind !== kind || record.input_fingerprint !== fingerprint) throw new Error('Replay divergence: step identity is bound to a different kind or input fingerprint.'); return { write: false, output: { created: false, reusable: record.status === 'succeeded' && record.policy.reusable, record } }; }
26
+ const now = new Date().toISOString(); const created = { schema: 'loop.execution_step', version: 2, step_id: stepId, kind, status: 'pending', input_fingerprint: fingerprint, input: redact(input.input), lineage: { run_id: input.runId ?? null, task_id: input.taskId ?? null, parent_step_id: input.parentStepId ?? null, replaces_step_id: input.replacesStepId ?? null }, policy: { reusable: input.reusable !== false, effect_key: kind === 'effect' ? safeId(input.effectKey ?? stepId, 'effectKey') : null, authorization_scope: input.authorizationScope ?? null }, attempt: 0, fencing_counter: 0, lease: null, checkpoints: [], outcome: null, evidence: [], reconciliation: null, created_at: now, updated_at: now, events: [] }; created.events.push(event(created, 'registered', { input_fingerprint: fingerprint })); return { write: true, record: created, output: { created: true, reusable: false, record: created } };
27
+ });
28
+ }
29
+ export async function inspectStep(root, stepId) { const loc = locations(root, stepId); return readFile(loc.file, 'utf8').then(JSON.parse).catch((error) => error.code === 'ENOENT' ? null : Promise.reject(error)); }
30
+ export async function claimStep(root, input) {
31
+ const leaseMs = Number(input.leaseMs ?? 60_000); if (!Number.isInteger(leaseMs) || leaseMs <= 0) throw new Error('leaseMs must be a positive integer.');
32
+ return mutate(root, input.stepId, async (record) => { if (!record) throw new Error('Step must be registered before claim.'); if (record.status === 'succeeded') return { write: false, output: { claimed: false, reason: record.policy.reusable ? 'reusable_outcome' : 'terminal', record } }; if (TERMINAL.has(record.status)) return { write: false, output: { claimed: false, reason: 'terminal', record } }; if (record.status === 'unknown') return { write: false, output: { claimed: false, reason: 'reconcile_required', record } }; const nowMs = Date.now(); if (record.status === 'running' && Date.parse(record.lease.expires_at) > nowMs) return { write: false, output: { claimed: false, reason: 'lease_active', record } }; if (record.status === 'running') { const at = new Date().toISOString(); const next = { ...record, status: 'unknown', lease: null, reconciliation: { required: true, reason: 'lease_expired_after_claim', at }, updated_at: at }; next.events = [...record.events, event(record, 'outcome_unknown', { reason: 'lease_expired_after_claim' })]; return { write: true, record: next, output: { claimed: false, reason: 'reconcile_required', record: next } }; } const token = record.fencing_counter + 1; const at = new Date().toISOString(); const next = { ...record, status: 'running', attempt: record.attempt + 1, fencing_counter: token, lease: { owner: text(input.owner, 'owner'), fencing_token: token, claimed_at: at, expires_at: new Date(nowMs + leaseMs).toISOString() }, updated_at: at }; next.events = [...record.events, event(record, 'claimed', { attempt: next.attempt, fencing_token: token, owner: input.owner })]; return { write: true, record: next, output: { claimed: true, fencingToken: token, attempt: next.attempt, record: next } }; });
33
+ }
34
+ export async function checkpointStep(root, input) { return mutate(root, input.stepId, async (record) => { assertFence(record, input.fencingToken); const checkpoint = { sequence: record.checkpoints.length + 1, at: new Date().toISOString(), fingerprint: stepInputFingerprint(input.state), state: redact(input.state), evidence: redact(input.evidence ?? null) }; const next = { ...record, checkpoints: [...record.checkpoints, checkpoint], updated_at: checkpoint.at }; next.events = [...record.events, event(record, 'checkpointed', { sequence: checkpoint.sequence, fingerprint: checkpoint.fingerprint })]; return { write: true, record: next, output: checkpoint }; }); }
35
+ export async function completeStep(root, input) { return mutate(root, input.stepId, async (record) => { if (record?.status === 'succeeded') return { write: false, output: { completed: false, duplicate: true, record } }; assertFence(record, input.fencingToken); const at = new Date().toISOString(); const outcome = { status: 'succeeded', output_fingerprint: stepInputFingerprint(input.output), output: redact(input.output), at }; const next = { ...record, status: 'succeeded', lease: null, outcome, evidence: [...record.evidence, redact(input.evidence ?? {})], reconciliation: null, updated_at: at }; next.events = [...record.events, event(record, 'succeeded', { output_fingerprint: outcome.output_fingerprint })]; return { write: true, record: next, output: { completed: true, duplicate: false, record: next } }; }); }
36
+ export async function markStepUnknown(root, input) { return mutate(root, input.stepId, async (record) => { assertFence(record, input.fencingToken); const at = new Date().toISOString(); const next = { ...record, status: 'unknown', lease: null, reconciliation: { required: true, reason: input.reason ?? 'submit_outcome_unknown', at }, evidence: [...record.evidence, redact(input.evidence ?? {})], updated_at: at }; next.events = [...record.events, event(record, 'outcome_unknown', { reason: next.reconciliation.reason })]; return { write: true, record: next }; }); }
37
+ export async function reconcileStep(root, input) { return mutate(root, input.stepId, async (record) => { if (!record || record.status !== 'unknown') throw new Error('Only an unknown step can be reconciled.'); if (!['accepted', 'not_accepted'].includes(input.outcome)) throw new Error('Reconciliation outcome must be accepted or not_accepted.'); if (!input.evidence) throw new Error('Reconciliation requires evidence.'); const at = new Date().toISOString(); const evidence = redact(input.evidence); if (input.outcome === 'accepted') { const outcome = { status: 'succeeded', output_fingerprint: stepInputFingerprint(input.output), output: redact(input.output), reconciled: true, at }; const next = { ...record, status: 'succeeded', outcome, evidence: [...record.evidence, evidence], reconciliation: { required: false, outcome: 'accepted', at }, updated_at: at }; next.events = [...record.events, event(record, 'reconciled_accepted', { output_fingerprint: outcome.output_fingerprint })]; return { write: true, record: next }; } const next = { ...record, status: 'pending', lease: null, evidence: [...record.evidence, evidence], reconciliation: { required: false, outcome: 'not_accepted', at }, updated_at: at }; next.events = [...record.events, event(record, 'reconciled_not_accepted')]; return { write: true, record: next }; }); }
38
+ export async function replayDecision(root, input) { const record = await inspectStep(root, input.stepId); if (!record) return { action: 'register_and_execute', record: null }; if (record.kind !== input.kind || record.input_fingerprint !== stepInputFingerprint(input.input)) return { action: 'divergence', reason: 'input_fingerprint_mismatch', record }; if (record.status === 'succeeded' && record.policy.reusable) return { action: 'reuse', output: record.outcome.output, evidence: record.evidence, record }; if (record.status === 'unknown' || record.status === 'running') return { action: 'reconcile', record }; if (record.status === 'pending') return { action: 'execute', checkpoint: record.checkpoints.at(-1) ?? null, record }; return { action: 'terminal', record }; }
39
+ export async function listSteps(root) { const dir = path.join(path.resolve(root), 'runtime', 'loops', 'execution-ledger', 'steps'); const result = []; for (const name of (await readdir(dir).catch(() => [])).filter((name) => name.endsWith('.json')).sort()) result.push(JSON.parse(await readFile(path.join(dir, name), 'utf8'))); return result.sort((a, b) => a.step_id.localeCompare(b.step_id)); }
40
+ export async function migrateLegacyJournalStep(root, legacy) { return registerStep(root, { stepId: legacy.step_id ?? legacy.stepId ?? `legacy:${legacy.transactionId ?? hash(legacy).slice(0, 24)}`, kind: KINDS.has(legacy.kind) ? legacy.kind : 'tool', input: legacy.input ?? legacy.payload ?? legacy, taskId: legacy.task_id, runId: legacy.run_id, reusable: legacy.reusable !== false }); }
41
+ export async function migrateActionReservation(root, reservation) { const registered = await registerStep(root, { stepId: `effect:${reservation.idempotency_key}`, kind: 'effect', effectKey: reservation.idempotency_key, input: reservation.request, authorizationScope: reservation.authorization?.scope, reusable: true }); return { ...registered, legacy_state: reservation.state, migration: 'action_reservation_to_effect_protocol_v2' }; }
@@ -102,9 +102,23 @@ async function actions(root, warnings, nowMs) {
102
102
  return result.sort((a, b) => String(a.idempotency_key).localeCompare(String(b.idempotency_key)));
103
103
  }
104
104
 
105
+ async function executionSteps(root, warnings, nowMs) {
106
+ const result = [];
107
+ for (const file of await files(path.join(root, 'runtime', 'loops', 'execution-ledger', 'steps'))) {
108
+ const item = await json(file, warnings, root); if (!item) continue;
109
+ result.push(clean({ step_id: item.step_id, kind: item.kind, state: normalizedState(item, null, nowMs), status: item.status, input_fingerprint: item.input_fingerprint, attempt: item.attempt, lineage: item.lineage, lease: item.lease, checkpoint_count: item.checkpoints?.length ?? 0, outcome: item.outcome, evidence: item.evidence, reconciliation: item.reconciliation, updated_at: item.updated_at }));
110
+ }
111
+ return result.sort((a, b) => a.step_id.localeCompare(b.step_id));
112
+ }
113
+
114
+ async function productionEvidence(root, warnings) {
115
+ const file = path.join(root, '.production-evidence', 'public-summary.json');
116
+ try { return clean(await json(file, warnings, root)); } catch { return null; }
117
+ }
118
+
105
119
  async function legacyQueues(root, warnings, nowMs) {
106
120
  const loops = path.join(root, 'runtime', 'loops');
107
- const excluded = new Set(['control-plane', 'action-reservations', 'projects']);
121
+ const excluded = new Set(['control-plane', 'action-reservations', 'execution-ledger', 'projects']);
108
122
  const queues = [];
109
123
  for (const name of (await dirs(loops)).filter((item) => !excluded.has(item))) {
110
124
  const base = path.join(loops, name); const counts = Object.fromEntries([...STATES].map((state) => [state, 0])); const tasks = [];
@@ -138,13 +152,13 @@ export async function buildOperatorProjection(root, options = {}) {
138
152
  const resolved = path.resolve(root); const now = options.now ? new Date(options.now) : new Date();
139
153
  if (Number.isNaN(now.getTime())) throw new Error('Invalid projection time.');
140
154
  const warnings = []; const before = await stat(path.join(resolved, 'runtime', 'loops')).catch(() => null);
141
- const [control, reservations, queueList, projectList] = await Promise.all([controlPlane(resolved, warnings, now.getTime()), actions(resolved, warnings, now.getTime()), legacyQueues(resolved, warnings, now.getTime()), projects(resolved, warnings)]);
142
- const newest = [control.updated_at, ...control.todos.map((item) => item.updated_at), ...reservations.map((item) => item.updated_at), ...queueList.flatMap((queue) => queue.tasks.map((item) => item.updated_at))].filter(Boolean).sort().at(-1) ?? null;
155
+ const [control, reservations, steps, queueList, projectList, trustEvidence] = await Promise.all([controlPlane(resolved, warnings, now.getTime()), actions(resolved, warnings, now.getTime()), executionSteps(resolved, warnings, now.getTime()), legacyQueues(resolved, warnings, now.getTime()), projects(resolved, warnings), productionEvidence(resolved, warnings)]);
156
+ const newest = [control.updated_at, ...control.todos.map((item) => item.updated_at), ...reservations.map((item) => item.updated_at), ...steps.map((item) => item.updated_at), ...queueList.flatMap((queue) => queue.tasks.map((item) => item.updated_at))].filter(Boolean).sort().at(-1) ?? null;
143
157
  const counts = Object.fromEntries([...STATES].map((state) => [state, 0]));
144
158
  for (const item of [...control.todos, ...queueList.flatMap((queue) => queue.tasks)]) counts[item.state] += 1;
145
159
  const after = await stat(path.join(resolved, 'runtime', 'loops')).catch(() => null);
146
160
  if (before && after && before.mtimeMs !== after.mtimeMs) warnings.push({ code: 'concurrent_update', artifact: 'runtime/loops', message: 'Artifacts changed while the projection was read; refresh recommended.' });
147
- return clean({ schema_version: DASHBOARD_SCHEMA_VERSION, generated_at: now.toISOString(), source: { root: resolved, read_only: true, newest_artifact_at: newest, freshness_seconds: newest ? Math.max(0, Math.floor((now.getTime() - Date.parse(newest)) / 1000)) : null }, health: { status: warnings.length ? 'degraded' : 'ok', warnings }, overview: { counts, queue_count: queueList.length, project_count: projectList.length, todo_count: control.todos.length, action_count: reservations.length }, projects: projectList, queues: queueList, todos: control.todos, agents: control.agents, handoffs: control.handoffs, gates: control.todos.filter((item) => ['parked', 'waiting_for_human', 'waiting_for_external_condition', 'timed_out_or_escalated'].includes(item.state)).map((item) => ({ todo_id: item.id, state: item.state, gate: item.gate, next_action: item.next_action })), actions: reservations, cost: { quotas: control.quotas, requested_total: control.todos.reduce((sum, item) => sum + Number(item.cost?.amount ?? 0), 0) } });
161
+ return clean({ schema_version: DASHBOARD_SCHEMA_VERSION, generated_at: now.toISOString(), source: { root: resolved, read_only: true, newest_artifact_at: newest, freshness_seconds: newest ? Math.max(0, Math.floor((now.getTime() - Date.parse(newest)) / 1000)) : null }, health: { status: warnings.length ? 'degraded' : 'ok', warnings }, overview: { counts, queue_count: queueList.length, project_count: projectList.length, todo_count: control.todos.length, action_count: reservations.length, step_count: steps.length, reconciliation_required_steps: steps.filter((item) => item.state === 'reconciliation_required').length, production_trust: trustEvidence?.passed ? 'passed' : trustEvidence ? 'failed' : 'not_generated' }, projects: projectList, queues: queueList, todos: control.todos, agents: control.agents, handoffs: control.handoffs, gates: control.todos.filter((item) => ['parked', 'waiting_for_human', 'waiting_for_external_condition', 'timed_out_or_escalated'].includes(item.state)).map((item) => ({ todo_id: item.id, state: item.state, gate: item.gate, next_action: item.next_action })), actions: reservations, execution_steps: steps, production_evidence: trustEvidence, cost: { quotas: control.quotas, requested_total: control.todos.reduce((sum, item) => sum + Number(item.cost?.amount ?? 0), 0) } });
148
162
  }
149
163
 
150
164
  export function filterProjection(projection, options = {}) {
@@ -0,0 +1,54 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
3
+ import path from 'node:path';
4
+
5
+ export const PRODUCTION_EVIDENCE_VERSION = 1;
6
+ const SECRET = /(^|_)(secret|token|password|credential|api[_-]?key|private[_-]?key)(_|$)/i;
7
+ const canonical = (value) => Array.isArray(value) ? value.map(canonical) : value && typeof value === 'object' ? Object.fromEntries(Object.keys(value).sort().map((key) => [key, canonical(value[key])])) : value;
8
+ const digest = (value) => createHash('sha256').update(JSON.stringify(canonical(value))).digest('hex');
9
+ export const redactEvidence = (value, key = '') => SECRET.test(key) ? '[REDACTED]' : Array.isArray(value) ? value.map((item) => redactEvidence(item)) : value && typeof value === 'object' ? Object.fromEntries(Object.entries(value).map(([name, item]) => [name, redactEvidence(item, name)])) : value;
10
+ export const evidenceDigest = (value) => digest(value);
11
+
12
+ export function evaluateEvidence(report, baseline = null) {
13
+ const thresholds = report.thresholds ?? {};
14
+ const metrics = report.metrics ?? {};
15
+ const checks = Object.entries(thresholds).map(([metric, rule]) => {
16
+ const actual = Number(metrics[metric]);
17
+ const passed = Number.isFinite(actual) && (rule.max === undefined || actual <= rule.max) && (rule.min === undefined || actual >= rule.min);
18
+ return { metric, actual, ...rule, passed, attribution: passed ? null : rule.attribution ?? 'unattributed_threshold_failure' };
19
+ });
20
+ const previous = baseline?.metrics ?? {};
21
+ const trend = Object.fromEntries(Object.keys(metrics).sort().filter((key) => Number.isFinite(Number(metrics[key])) && Number.isFinite(Number(previous[key]))).map((key) => [key, { baseline: Number(previous[key]), current: Number(metrics[key]), delta: Number(metrics[key]) - Number(previous[key]) }]));
22
+ return { checks, trend, passed: checks.every((item) => item.passed) && report.scenarios.every((item) => item.status === 'passed') && report.compatibility.every((item) => item.status === 'passed') };
23
+ }
24
+
25
+ export function sealEvidence(input) {
26
+ const report = redactEvidence({ ...input, schema: 'loop.production_trust_evidence', schema_version: PRODUCTION_EVIDENCE_VERSION });
27
+ const unsigned = { ...report, integrity: undefined };
28
+ delete unsigned.integrity;
29
+ return { ...report, integrity: { algorithm: 'sha256', digest: digest(unsigned) } };
30
+ }
31
+
32
+ export function verifyEvidence(report) {
33
+ if (report?.schema !== 'loop.production_trust_evidence' || report.schema_version !== PRODUCTION_EVIDENCE_VERSION) return { valid: false, reason: 'unsupported_schema' };
34
+ const unsigned = { ...report }; delete unsigned.integrity;
35
+ const actual = digest(unsigned); const expected = report.integrity?.digest;
36
+ return { valid: actual === expected, reason: actual === expected ? null : 'digest_mismatch', expected, actual };
37
+ }
38
+
39
+ export function publicEvidenceSummary(report) {
40
+ const verification = verifyEvidence(report);
41
+ return redactEvidence({ schema: 'loop.production_trust_public_summary', schema_version: 1, evidence_digest: report.integrity?.digest, generated_at: report.generated_at, passed: report.passed && verification.valid, fixtures: report.compatibility.map(({ runtime, contract_version, status }) => ({ runtime, contract_version, status })), metrics: report.metrics, thresholds: report.thresholds, failures: report.evaluation.checks.filter((item) => !item.passed).map(({ metric, attribution }) => ({ metric, attribution })), limitations: report.limitations });
42
+ }
43
+
44
+ export async function writeEvidenceBundle(directory, report) {
45
+ const sealed = sealEvidence(report); const summary = publicEvidenceSummary(sealed);
46
+ await mkdir(directory, { recursive: true });
47
+ for (const [name, value] of [['evidence.json', sealed], ['public-summary.json', summary]]) {
48
+ const file = path.join(directory, name); const temporary = `${file}.${process.pid}.tmp`;
49
+ await writeFile(temporary, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 }); await rename(temporary, file);
50
+ }
51
+ return { report: sealed, summary };
52
+ }
53
+
54
+ export async function readAndVerifyEvidence(file) { const report = JSON.parse(await readFile(file, 'utf8')); return { report, verification: verifyEvidence(report) }; }
@@ -0,0 +1,115 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+
3
+ export const RUNTIME_ADAPTER_CONTRACT = 'loop.runtime-adapter';
4
+ export const RUNTIME_ADAPTER_VERSION = '1.0.0';
5
+ export const RUNTIME_ADAPTER_MAJOR = 1;
6
+ export const RUNTIMES = Object.freeze(['openclaw', 'hermes', 'codex-cli', 'claude-code']);
7
+ export const EFFECT_STATES = Object.freeze(['prepared', 'authorized', 'submitted', 'settled', 'unknown', 'rejected']);
8
+
9
+ export class AdapterError extends Error {
10
+ constructor(code, message, { retryable = false, cause, details } = {}) {
11
+ super(message, { cause });
12
+ this.name = 'AdapterError'; this.code = code; this.retryable = retryable;
13
+ this.details = redact(details ?? null);
14
+ }
15
+ toJSON() { return { name: this.name, code: this.code, message: this.message, retryable: this.retryable, details: this.details }; }
16
+ }
17
+
18
+ export function redact(value) {
19
+ if (Array.isArray(value)) return value.map(redact);
20
+ if (!value || typeof value !== 'object') return value;
21
+ return Object.fromEntries(Object.entries(value).map(([key, item]) =>
22
+ /token|secret|password|credential|authorization|cookie|api[-_]?key/i.test(key)
23
+ ? [key, '[REDACTED]'] : [key, redact(item)]));
24
+ }
25
+
26
+ const required = (value, label) => {
27
+ if (typeof value !== 'string' || !value.trim()) throw new AdapterError('INVALID_INPUT', `${label} is required`);
28
+ return value.trim();
29
+ };
30
+ const stableId = (prefix, value) => `${prefix}_${createHash('sha256').update(JSON.stringify(value)).digest('hex').slice(0, 20)}`;
31
+
32
+ export function validateAdapter(adapter) {
33
+ if (adapter?.contract !== RUNTIME_ADAPTER_CONTRACT) throw new AdapterError('UNSUPPORTED_CONTRACT', 'unsupported adapter contract');
34
+ const major = Number.parseInt(String(adapter.version).split('.')[0], 10);
35
+ if (major !== RUNTIME_ADAPTER_MAJOR) throw new AdapterError('UNSUPPORTED_VERSION', `unsupported adapter version: ${adapter?.version}`);
36
+ if (!RUNTIMES.includes(adapter.runtime)) throw new AdapterError('UNSUPPORTED_RUNTIME', `unsupported runtime: ${adapter?.runtime}`);
37
+ for (const method of ['createSession', 'startRun', 'recordStep', 'prepareEffect', 'resolveGate', 'heartbeat', 'continueRun']) {
38
+ if (typeof adapter[method] !== 'function') throw new AdapterError('INVALID_ADAPTER', `adapter.${method} must be a function`);
39
+ }
40
+ return adapter;
41
+ }
42
+
43
+ export function defineRuntimeAdapter({ runtime, capabilities = [], transport }) {
44
+ if (!transport || typeof transport.invoke !== 'function') throw new AdapterError('INVALID_ADAPTER', 'transport.invoke must be a function');
45
+ const events = [];
46
+ const emit = (type, payload) => {
47
+ const event = redact({ contract: 'loop.telemetry', version: 1, type, runtime, at: new Date().toISOString(), ...payload });
48
+ events.push(event); transport.telemetry?.(event); return event;
49
+ };
50
+ const invoke = async (operation, payload) => {
51
+ try { return redact(await transport.invoke(operation, redact(payload))); }
52
+ catch (cause) {
53
+ if (cause instanceof AdapterError) throw cause;
54
+ throw new AdapterError('TRANSPORT_FAILURE', `${runtime} ${operation} failed`, { retryable: true, cause });
55
+ }
56
+ };
57
+ const adapter = {
58
+ contract: RUNTIME_ADAPTER_CONTRACT, version: RUNTIME_ADAPTER_VERSION, runtime,
59
+ capabilities: Object.freeze([...new Set(capabilities)].sort()),
60
+ telemetry: events,
61
+ async createSession(input = {}) {
62
+ const sessionId = input.sessionId ?? stableId('ses', { runtime, key: required(input.key, 'session key') });
63
+ const out = { sessionId, runtime, metadata: redact(input.metadata ?? {}) }; emit('session.created', out); return out;
64
+ },
65
+ async startRun(input = {}) {
66
+ const sessionId = required(input.sessionId, 'sessionId');
67
+ const runId = input.runId ?? stableId('run', { sessionId, requestId: input.requestId ?? randomUUID() });
68
+ const result = await invoke('run.start', { sessionId, runId, input: input.input ?? null });
69
+ const out = { sessionId, runId, status: result?.status ?? 'running' }; emit('run.started', out); return out;
70
+ },
71
+ async recordStep(input = {}) {
72
+ const runId = required(input.runId, 'runId'); const stepId = required(input.stepId, 'stepId');
73
+ const state = input.state ?? 'completed';
74
+ if (!['pending', 'running', 'completed', 'failed', 'waiting'].includes(state)) throw new AdapterError('INVALID_STEP_STATE', `invalid step state: ${state}`);
75
+ const evidence = redact(input.evidence ?? []); emit('step.recorded', { runId, stepId, state, evidence }); return { runId, stepId, state, evidence };
76
+ },
77
+ async prepareEffect(input = {}) {
78
+ const runId = required(input.runId, 'runId'); const effectId = required(input.effectId, 'effectId');
79
+ if (!input.idempotencyKey) throw new AdapterError('EFFECT_KEY_REQUIRED', 'effect idempotencyKey is required');
80
+ if (input.authorized !== true) throw new AdapterError('EFFECT_NOT_AUTHORIZED', 'effect is fail-closed until explicitly authorized');
81
+ const out = { runId, effectId, idempotencyKey: input.idempotencyKey, state: 'authorized', payload: redact(input.payload ?? null) };
82
+ emit('effect.authorized', out); return out;
83
+ },
84
+ async resolveGate(input = {}) {
85
+ const gateId = required(input.gateId, 'gateId');
86
+ if (!['approved', 'rejected'].includes(input.decision)) throw new AdapterError('INVALID_GATE_DECISION', 'gate decision must be approved or rejected');
87
+ const out = { gateId, decision: input.decision, responseRef: input.responseRef ?? null }; emit('gate.resolved', out); return out;
88
+ },
89
+ async heartbeat(input = {}) {
90
+ const runId = required(input.runId, 'runId'); const result = await invoke('run.heartbeat', { runId });
91
+ const out = { runId, alive: result?.alive !== false, continuationToken: result?.continuationToken ?? null };
92
+ emit('run.heartbeat', out); return out;
93
+ },
94
+ async continueRun(input = {}) {
95
+ const runId = required(input.runId, 'runId'); const continuationToken = required(input.continuationToken, 'continuationToken');
96
+ const result = await invoke('run.continue', { runId, continuationToken });
97
+ const out = { runId, status: result?.status ?? 'running' }; emit('run.continued', out); return out;
98
+ }
99
+ };
100
+ return validateAdapter(adapter);
101
+ }
102
+
103
+ export function createMemoryTransport() {
104
+ const calls = [];
105
+ return { calls, async invoke(operation, payload) {
106
+ calls.push({ operation, payload });
107
+ if (operation === 'run.heartbeat') return { alive: true, continuationToken: `continue_${payload.runId}` };
108
+ return { status: 'running' };
109
+ } };
110
+ }
111
+
112
+ export const createOpenClawAdapter = (transport) => defineRuntimeAdapter({ runtime: 'openclaw', capabilities: ['sessions', 'effects', 'human-gates', 'continuation'], transport });
113
+ export const createHermesAdapter = (transport) => defineRuntimeAdapter({ runtime: 'hermes', capabilities: ['sessions', 'effects', 'human-gates', 'continuation'], transport });
114
+ export const createCodexCliAdapter = (transport) => defineRuntimeAdapter({ runtime: 'codex-cli', capabilities: ['sessions', 'effects', 'human-gates', 'continuation'], transport });
115
+ export const createClaudeCodeAdapter = (transport) => defineRuntimeAdapter({ runtime: 'claude-code', capabilities: ['sessions', 'effects', 'human-gates', 'continuation'], transport });
@@ -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.14.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,9 @@
17
17
  "run-loop-cron.sh": "scripts/run-loop-cron.sh"
18
18
  },
19
19
  "scripts": {
20
+ "check:adapters": "node --check lib/runtime-adapter-sdk.mjs && node scripts/runtime-adapter-conformance.mjs",
21
+ "demo:adapter": "node examples/adapter-sdk-demo.mjs",
22
+ "check:production-trust": "node --check lib/runtime-adapter-v1.mjs && node --check lib/durable-journal.mjs && node --check lib/execution-ledger.mjs && node --check lib/production-evidence.mjs && node --check lib/upgrade-planner.mjs && node scripts/production-acceptance.mjs",
20
23
  "check:config-drift": "node --check scripts/config-drift-self-test.mjs && node scripts/config-drift-self-test.mjs",
21
24
  "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
25
  "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",
@@ -32,6 +35,7 @@
32
35
  "README.md",
33
36
  "bin/",
34
37
  "docs/",
38
+ "examples/",
35
39
  "lib/",
36
40
  "scripts/",
37
41
  "templates/",
@@ -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,10 @@
1
+ import assert from 'node:assert/strict';
2
+ import { mkdtemp } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import path from 'node:path';
3
+ import { checkpointStep, claimStep, completeStep, inspectStep, listSteps, markStepUnknown, migrateActionReservation, reconcileStep, registerStep, replayDecision, stepInputFingerprint } from '../lib/execution-ledger.mjs';
4
+ const root = await mkdtemp(path.join(tmpdir(), 'loop-ledger-')); const base = { stepId: 'run:1:llm:plan', kind: 'llm', input: { model: 'mock', prompt: 'plan', api_key: 'secret' }, runId: 'run:1', taskId: 'task:1' };
5
+ assert.equal((await registerStep(root, base)).created, true); assert.equal((await registerStep(root, base)).created, false); await assert.rejects(() => registerStep(root, { ...base, input: { prompt: 'changed' } }), /Replay divergence/); assert.equal((await inspectStep(root, base.stepId)).input.api_key, '[REDACTED]');
6
+ const claims = await Promise.all(Array.from({ length: 16 }, (_, i) => claimStep(root, { stepId: base.stepId, owner: `w${i}`, leaseMs: 1000 }))); assert.equal(claims.filter((item) => item.claimed).length, 1); const winner = claims.find((item) => item.claimed); await checkpointStep(root, { stepId: base.stepId, fencingToken: winner.fencingToken, state: { cursor: 7 } }); await assert.rejects(() => completeStep(root, { stepId: base.stepId, fencingToken: winner.fencingToken + 1, output: 'bad' }), /fencing token/); await completeStep(root, { stepId: base.stepId, fencingToken: winner.fencingToken, output: { answer: 42 }, evidence: { fixture: 'mock' } }); assert.equal((await replayDecision(root, base)).action, 'reuse');
7
+ await registerStep(root, { stepId: 'tool:crash-before', kind: 'tool', input: { command: 'mock' } }); await claimStep(root, { stepId: 'tool:crash-before', owner: 'dead', leaseMs: 1 }); await new Promise((resolve) => setTimeout(resolve, 5)); assert.equal((await claimStep(root, { stepId: 'tool:crash-before', owner: 'recover' })).reason, 'reconcile_required'); await reconcileStep(root, { stepId: 'tool:crash-before', outcome: 'not_accepted', evidence: { local: 'adapter_not_called' } }); assert.equal((await claimStep(root, { stepId: 'tool:crash-before', owner: 'recover' })).claimed, true);
8
+ await registerStep(root, { stepId: 'effect:charge:1', kind: 'effect', effectKey: 'charge:1', input: { cents: 4 }, authorizationScope: 'test' }); const effect = await claimStep(root, { stepId: 'effect:charge:1', owner: 'worker' }); await markStepUnknown(root, { stepId: 'effect:charge:1', fencingToken: effect.fencingToken, reason: 'accepted_before_local_settle' }); assert.equal((await replayDecision(root, { stepId: 'effect:charge:1', kind: 'effect', input: { cents: 4 } })).action, 'reconcile'); await reconcileStep(root, { stepId: 'effect:charge:1', outcome: 'accepted', output: { upstream_id: 'mock-1' }, evidence: { upstream_lookup: 'mock-1' } }); assert.equal((await claimStep(root, { stepId: 'effect:charge:1', owner: 'retry' })).reason, 'reusable_outcome');
9
+ const migrated = await migrateActionReservation(root, { idempotency_key: 'legacy:notify:1', state: 'reserved', request: { body: 'hello' }, authorization: { scope: 'chat' } }); assert.equal(migrated.record.policy.effect_key, 'legacy:notify:1'); assert.equal((await listSteps(root)).length, 4); assert.equal(stepInputFingerprint({ b: 2, a: 1 }), stepInputFingerprint({ a: 1, b: 2 }));
10
+ console.log(JSON.stringify({ status: 'ok', assertions: ['identity', 'divergence', 'redaction', 'claim', 'lease', 'fencing', 'checkpoint', 'reuse', 'reconciliation', 'effect migration'] }));
@@ -0,0 +1,97 @@
1
+ #!/usr/bin/env node
2
+ import { spawn } from 'node:child_process';
3
+ import { mkdir, rm, writeFile, rename } 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 statusOutput = `${output}.status.json`;
27
+ const writeStatus = async (state, extra = {}) => {
28
+ const temporary = `${statusOutput}.${process.pid}.tmp`;
29
+ await writeFile(temporary, `${JSON.stringify({ version: 1, state, runId, pid: process.pid, startedAt: report.startedAt, deadlineAt: report.deadlineAt, updatedAt: new Date().toISOString(), modelCallsAttempted: report.modelCallsAttempted, consecutiveRuntimeErrors: report.consecutiveRuntimeErrors, metrics: report.metrics, ...extra }, null, 2)}\n`);
30
+ await rename(temporary, statusOutput);
31
+ };
32
+ await writeStatus('starting');
33
+ const sanitize = (text) => String(text).replace(/[A-Za-z0-9_=-]{24,}/g, '[redacted]').slice(0, 240);
34
+ const record = (type, fields = {}) => report.events.push({ at: new Date().toISOString(), type, ...fields });
35
+ const invoke = (worker) => new Promise((resolve) => {
36
+ report.modelCallsAttempted++;
37
+ const session = `agent:${agent}:loop-production-soak-${runId}-${worker}`;
38
+ 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'] });
39
+ let stdout = ''; let stderr = ''; child.stdout.on('data', (c) => { stdout += c; }); child.stderr.on('data', (c) => { stderr += c; });
40
+ child.on('close', (code, signal) => resolve({ code: code ?? (signal ? 128 : 1), evidence: sanitize(stdout || stderr) }));
41
+ child.on('error', (error) => resolve({ code: 127, evidence: sanitize(error.message) }));
42
+ });
43
+ const probe = (command, args, runtime) => new Promise((resolve) => {
44
+ const child = spawn(command, args, { cwd: workDir, stdio: ['ignore', 'pipe', 'pipe'] }); let stdout = ''; let stderr = '';
45
+ child.stdout.on('data', (c) => { stdout += c; }); child.stderr.on('data', (c) => { stderr += c; });
46
+ 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); };
47
+ child.on('close', (code) => finish(code === 0, stdout || stderr)); child.on('error', (error) => finish(false, error.message));
48
+ });
49
+
50
+ const leases = { owner: null, until: 0, fence: 0, quotaUsed: 0, parked: false, effect: 'reserved' };
51
+ 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; };
52
+ const heartbeatChildren = new Map();
53
+ let interruptedSignal = null;
54
+ for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP']) process.on(signal, () => { interruptedSignal = signal; });
55
+ const startHeartbeat = (worker) => {
56
+ const source = `setInterval(()=>process.stdout.write('h\\n'),1000)`;
57
+ const child = spawn(process.execPath, ['-e', source], { cwd: workDir, stdio: ['ignore', 'pipe', 'pipe'] });
58
+ child.stdout.on('data', (chunk) => { report.metrics.heartbeats += String(chunk).split('\n').filter(Boolean).length; });
59
+ heartbeatChildren.set(worker, child); return child;
60
+ };
61
+
62
+ try {
63
+ if (!dryRun) {
64
+ if (!await probe(openclawBin, [...(openclawProfile ? ['--profile', openclawProfile] : []), 'agents', 'list', '--json'], 'openclaw')) report.consecutiveRuntimeErrors++;
65
+ if (hermesBin && !await probe(hermesBin, ['--version'], 'hermes')) report.consecutiveRuntimeErrors++;
66
+ if (report.consecutiveRuntimeErrors >= 2) report.stoppedByCircuitBreaker = true;
67
+ }
68
+ for (let worker = 1; !dryRun && !runtimeOnly && worker <= 3 && report.modelCallsAttempted < maxCalls; worker++) {
69
+ const result = await invoke(`w${worker}`); record('runtime_probe', { worker: `w${worker}`, ok: result.code === 0, evidence: result.evidence });
70
+ report.consecutiveRuntimeErrors = result.code === 0 ? 0 : report.consecutiveRuntimeErrors + 1;
71
+ if (report.consecutiveRuntimeErrors >= 2) { report.stoppedByCircuitBreaker = true; break; }
72
+ }
73
+ if (!report.stoppedByCircuitBreaker) {
74
+ for (const worker of ['w1', 'w2', 'w3']) startHeartbeat(worker);
75
+ const first = claim('w1', Date.now()); record('claim', { worker: 'w1', fence: first });
76
+ leases.effect = 'unknown'; record('unknown_outcome', { replaySuppressed: true }); leases.effect = 'not_accepted'; report.metrics.unknownReconciled++;
77
+ await new Promise((resolve) => setTimeout(resolve, Math.min(65_000, Math.max(5_000, durationMs / 4))));
78
+ const crashed = heartbeatChildren.get('w1'); crashed.kill('SIGTERM'); report.metrics.injectedCrashes++; record('dedicated_worker_crash', { worker: 'w1' });
79
+ 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 });
80
+ if (first === second) report.metrics.staleFencesAccepted++;
81
+ startHeartbeat('w1-restarted'); report.metrics.restarts++; record('dedicated_worker_restart', { worker: 'w1-restarted' });
82
+ leases.parked = true; record('parked_gate', { claimRejected: claim('w3', Date.now()) === null }); leases.parked = false;
83
+ await writeStatus('running');
84
+ while (Date.now() < deadline && !interruptedSignal) {
85
+ await new Promise((resolve) => setTimeout(resolve, Math.min(30_000, deadline - Date.now())));
86
+ await writeStatus(interruptedSignal ? 'stopping' : 'running');
87
+ }
88
+ }
89
+ } finally {
90
+ for (const child of heartbeatChildren.values()) if (!child.killed) child.kill('SIGTERM');
91
+ report.completedAt = new Date().toISOString(); report.durationMs = Date.parse(report.completedAt) - startedAt.getTime();
92
+ report.interruptedSignal = interruptedSignal;
93
+ 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;
94
+ const temporary = `${output}.${process.pid}.tmp`; await writeFile(temporary, `${JSON.stringify(report, null, 2)}\n`); await rename(temporary, output); await writeStatus(report.passed ? 'passed' : 'failed', { completedAt: report.completedAt, passed: report.passed, interruptedSignal }); await rm(workDir, { recursive: true, force: true });
95
+ }
96
+ console.log(JSON.stringify({ runId, passed: report.passed, output, durationMs: report.durationMs, modelCallsAttempted: report.modelCallsAttempted }, null, 2));
97
+ if (!report.passed) process.exitCode = 1;
@@ -3,6 +3,7 @@ import { mkdtemp, mkdir, readFile, stat, writeFile } from 'node:fs/promises';
3
3
  import { tmpdir } from 'node:os';
4
4
  import path from 'node:path';
5
5
  import { buildOperatorProjection, createDashboardServer, dashboardHealth, exportDashboard, filterProjection } from '../lib/operator-dashboard.mjs';
6
+ import { claimStep, markStepUnknown, registerStep } from '../lib/execution-ledger.mjs';
6
7
 
7
8
  const root = await mkdtemp(path.join(tmpdir(), 'loop-dashboard-'));
8
9
  const loops = path.join(root, 'runtime', 'loops');
@@ -11,6 +12,7 @@ await mkdir(path.join(loops, 'action-reservations'), { recursive: true });
11
12
  await mkdir(path.join(loops, 'legacy', 'waiting'), { recursive: true });
12
13
  await mkdir(path.join(loops, 'legacy', 'active'), { recursive: true });
13
14
  await mkdir(path.join(loops, 'projects', 'p3', 'intake'), { recursive: true });
15
+ await mkdir(path.join(root, '.production-evidence'), { recursive: true });
14
16
  const now = '2026-08-13T16:00:00.000Z';
15
17
  const control = {
16
18
  version: 2, updated_at: now, quotas: { credits: 9 }, agents: { a: { id: 'a', capabilities: ['code'], authority_grants: ['local'], provider_token: 'never-show' } },
@@ -25,6 +27,10 @@ await writeFile(path.join(loops, 'action-reservations', 'x.json'), JSON.stringif
25
27
  await writeFile(path.join(loops, 'legacy', 'waiting', 'vps.json'), JSON.stringify({ id: 'vps', title: 'VPS down', parked: { kind: 'external_condition', next_check_at: '2026-08-13T17:00:00.000Z' }, provider: 'hidden' }));
26
28
  await writeFile(path.join(loops, 'legacy', 'active', 'bad.json'), '{broken');
27
29
  await writeFile(path.join(loops, 'projects', 'p3', 'intake', 'latest.json'), JSON.stringify({ version: 1, goal: 'Operator dashboard', token: 'hidden' }));
30
+ await writeFile(path.join(root, '.production-evidence', 'public-summary.json'), JSON.stringify({ schema: 'loop.production_trust_public_summary', schema_version: 1, passed: true, evidence_digest: 'fixture', metrics: { duplicate_settled_effects: 0 } }));
31
+ await registerStep(root, { stepId: 'run:1:effect:send', kind: 'effect', effectKey: 'send:1', input: { api_key: 'hidden', body: 'hello' } });
32
+ const ledgerClaim = await claimStep(root, { stepId: 'run:1:effect:send', owner: 'a' });
33
+ await markStepUnknown(root, { stepId: 'run:1:effect:send', fencingToken: ledgerClaim.fencingToken, reason: 'accepted_before_local_settle' });
28
34
 
29
35
  const before = await stat(path.join(loops, 'control-plane', 'state.json'));
30
36
  const first = await buildOperatorProjection(root, { now });
@@ -35,6 +41,10 @@ assert.equal(before.mtimeMs, after.mtimeMs, 'projection does not mutate source s
35
41
  assert.equal(first.todos.find((item) => item.id === 'human').state, 'waiting_for_human');
36
42
  assert.equal(first.todos.find((item) => item.id === 'leased').state, 'reconciliation_required');
37
43
  assert.equal(first.actions[0].state, 'reconciliation_required');
44
+ assert.equal(first.execution_steps[0].state, 'reconciliation_required');
45
+ assert.equal(first.overview.reconciliation_required_steps, 1);
46
+ assert.equal(first.overview.production_trust, 'passed');
47
+ assert.equal(first.production_evidence.metrics.duplicate_settled_effects, 0);
38
48
  assert.equal(first.queues[0].tasks[0].state, 'waiting_for_external_condition');
39
49
  assert.equal(first.agents[0].provider_token, '[REDACTED]');
40
50
  assert.equal(first.todos[0].gate.secret_input, '[REDACTED]');
@@ -0,0 +1,10 @@
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/execution-ledger-self-test.mjs']],
5
+ ['node', ['scripts/production-evidence-self-test.mjs']],
6
+ ['node', ['scripts/upgrade-planner-self-test.mjs']], ['node', ['scripts/production-soak.mjs']],
7
+ ['node', ['scripts/async-acceptance-refresh-self-test.mjs']], ['node', ['examples/safe-canary.mjs']]
8
+ ];
9
+ 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}`))); });
10
+ console.log('production trust acceptance passed');