taskforce-loop-engineering 0.10.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 +24 -0
- package/MIGRATING.md +47 -2
- package/README.md +70 -0
- package/bin/loop-engineering.mjs +192 -0
- package/docs/architecture.md +444 -0
- package/docs/multi-agent-control-plane.md +31 -0
- package/docs/operator-dashboard.md +27 -0
- package/docs/production-operations.md +29 -0
- package/docs/production-trust-backlog.json +13 -0
- package/docs/production-trust-contract.md +54 -0
- package/docs/release-0.12-acceptance.md +35 -0
- package/lib/action-reservations.mjs +196 -0
- package/lib/core.mjs +219 -2
- package/lib/durable-journal.mjs +90 -0
- package/lib/operator-dashboard.mjs +198 -0
- package/lib/runtime-adapter-v1.mjs +36 -0
- package/lib/todo-control-plane.mjs +287 -0
- package/lib/upgrade-planner.mjs +24 -0
- package/package.json +4 -2
- package/scripts/action-reservation-self-test.mjs +65 -0
- package/scripts/async-acceptance-refresh-self-test.mjs +46 -0
- package/scripts/durable-journal-self-test.mjs +24 -0
- package/scripts/human-gate-lifecycle-v2-self-test.mjs +81 -0
- package/scripts/live-runtime-soak.mjs +86 -0
- package/scripts/operator-dashboard-self-test.mjs +74 -0
- package/scripts/production-acceptance.mjs +8 -0
- package/scripts/production-soak.mjs +19 -0
- package/scripts/route-notify-self-test.mjs +1 -0
- package/scripts/runtime-adapter-contract-self-test.mjs +14 -0
- package/scripts/todo-control-plane-self-test.mjs +74 -0
- package/scripts/upgrade-planner-self-test.mjs +9 -0
- package/templates/operator-projection.schema.json +1 -0
- package/templates/todo.schema.json +28 -0
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { mkdtemp } from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import {
|
|
6
|
+
actionAdapters,
|
|
7
|
+
claimAction,
|
|
8
|
+
inspectAction,
|
|
9
|
+
markActionUnknown,
|
|
10
|
+
migrateLegacyActionArtifact,
|
|
11
|
+
reconcileAction,
|
|
12
|
+
releaseAction,
|
|
13
|
+
reserveAction,
|
|
14
|
+
settleAction
|
|
15
|
+
} from '../lib/action-reservations.mjs';
|
|
16
|
+
|
|
17
|
+
const root = await mkdtemp(path.join(tmpdir(), 'loop-action-reservation-'));
|
|
18
|
+
const base = { idempotencyKey: 'paid:task-1:step-1', kind: 'paid_api', authorizationScope: 'approval:task-1:provider-call', request: { model: 'mock', promptHash: 'abc', cents: 4 } };
|
|
19
|
+
|
|
20
|
+
const reserved = await reserveAction(root, base);
|
|
21
|
+
assert.equal(reserved.created, true);
|
|
22
|
+
assert.equal((await reserveAction(root, base)).duplicate, true);
|
|
23
|
+
await assert.rejects(() => reserveAction(root, { ...base, request: { ...base.request, cents: 5 } }), /different request/);
|
|
24
|
+
|
|
25
|
+
// Concurrent workers get one atomic lease and one fencing token.
|
|
26
|
+
const claims = await Promise.all(Array.from({ length: 12 }, (_, i) => claimAction(root, { idempotencyKey: base.idempotencyKey, owner: `worker-${i}`, leaseMs: 1000 })));
|
|
27
|
+
assert.equal(claims.filter((item) => item.claimed).length, 1);
|
|
28
|
+
const winner = claims.find((item) => item.claimed);
|
|
29
|
+
await assert.rejects(() => settleAction(root, { idempotencyKey: base.idempotencyKey, fencingToken: winner.fencingToken + 1 }), /fencing token/);
|
|
30
|
+
assert.equal((await settleAction(root, { idempotencyKey: base.idempotencyKey, fencingToken: winner.fencingToken, evidence: { upstreamId: 'mock-1' } })).settled, true);
|
|
31
|
+
assert.equal((await settleAction(root, { idempotencyKey: base.idempotencyKey, fencingToken: winner.fencingToken })).duplicate, true);
|
|
32
|
+
assert.equal((await inspectAction(root, base.idempotencyKey)).authorization.state, 'consumed');
|
|
33
|
+
await assert.rejects(() => releaseAction(root, { idempotencyKey: base.idempotencyKey, reason: 'late release' }), /cannot be released/);
|
|
34
|
+
|
|
35
|
+
// Crash before send: an expired claim becomes unknown, never blindly claimable.
|
|
36
|
+
const beforeSend = { ...base, idempotencyKey: 'paid:crash-before-send' };
|
|
37
|
+
await reserveAction(root, beforeSend);
|
|
38
|
+
await claimAction(root, { idempotencyKey: beforeSend.idempotencyKey, owner: 'crashed', leaseMs: 1 });
|
|
39
|
+
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
40
|
+
assert.equal((await claimAction(root, { idempotencyKey: beforeSend.idempotencyKey, owner: 'recovery', leaseMs: 10 })).reason, 'reconcile_required');
|
|
41
|
+
await reconcileAction(root, { idempotencyKey: beforeSend.idempotencyKey, outcome: 'not_accepted', evidence: { local: 'adapter_not_invoked' } });
|
|
42
|
+
assert.equal((await claimAction(root, { idempotencyKey: beforeSend.idempotencyKey, owner: 'recovery', leaseMs: 100 })).claimed, true);
|
|
43
|
+
|
|
44
|
+
// Crash after upstream acceptance: reconciliation settles without another send/charge.
|
|
45
|
+
const afterAcceptance = { ...base, idempotencyKey: 'paid:crash-after-acceptance' };
|
|
46
|
+
await reserveAction(root, afterAcceptance);
|
|
47
|
+
const afterClaim = await claimAction(root, { idempotencyKey: afterAcceptance.idempotencyKey, owner: 'worker', leaseMs: 100 });
|
|
48
|
+
await markActionUnknown(root, { idempotencyKey: afterAcceptance.idempotencyKey, fencingToken: afterClaim.fencingToken, reason: 'accepted_before_local_commit' });
|
|
49
|
+
await reconcileAction(root, { idempotencyKey: afterAcceptance.idempotencyKey, outcome: 'accepted', evidence: { upstreamId: 'mock-accepted' } });
|
|
50
|
+
assert.equal((await claimAction(root, { idempotencyKey: afterAcceptance.idempotencyKey, owner: 'retry' })).reason, 'settled');
|
|
51
|
+
|
|
52
|
+
// Notification adapter suppresses duplicates, and an unused reservation can release authorization.
|
|
53
|
+
await actionAdapters.notification.reserve(root, { idempotencyKey: 'notify:task-1:terminal', authorizationScope: 'task-1:source-chat', request: { target: 'mock-chat', digest: 'done' } });
|
|
54
|
+
const notifyDuplicate = await actionAdapters.notification.reserve(root, { idempotencyKey: 'notify:task-1:terminal', authorizationScope: 'task-1:source-chat', request: { target: 'mock-chat', digest: 'done' } });
|
|
55
|
+
assert.equal(notifyDuplicate.duplicate, true);
|
|
56
|
+
const releasable = { ...base, idempotencyKey: 'deploy:cancelled', kind: 'deployment', authorizationScope: 'approval:deploy-staging' };
|
|
57
|
+
await reserveAction(root, releasable);
|
|
58
|
+
assert.equal((await releaseAction(root, { idempotencyKey: releasable.idempotencyKey, reason: 'operator_cancelled', evidence: { ticket: 'mock' } })).record.authorization.state, 'released');
|
|
59
|
+
|
|
60
|
+
// Legacy artifacts are imported without changing their logical identity.
|
|
61
|
+
const migrated = await migrateLegacyActionArtifact(root, { idempotency_key: 'legacy:notification:1', kind: 'notification', authorization_scope: 'legacy:chat', request: { digest: 'old' } });
|
|
62
|
+
assert.equal(migrated.created, true);
|
|
63
|
+
assert.equal((await migrateLegacyActionArtifact(root, { idempotency_key: 'legacy:notification:1', kind: 'notification', authorization_scope: 'legacy:chat', request: { digest: 'old' } })).duplicate, true);
|
|
64
|
+
|
|
65
|
+
console.log(JSON.stringify({ status: 'ok', assertions: 'reservation, fingerprint, concurrency, fencing, crash recovery, reconciliation, authorization, release, adapters, migration' }));
|
|
@@ -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,81 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { mkdtemp, rm } from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import {
|
|
6
|
+
ensureQueueDirs,
|
|
7
|
+
parkQueueTask,
|
|
8
|
+
queueStatus,
|
|
9
|
+
queueSubdirFor,
|
|
10
|
+
readJson,
|
|
11
|
+
resumeParkedTask,
|
|
12
|
+
tickParkedTasks,
|
|
13
|
+
writeJson
|
|
14
|
+
} from '../lib/core.mjs';
|
|
15
|
+
|
|
16
|
+
const root = await mkdtemp(path.join(tmpdir(), 'loop-human-gate-v2-'));
|
|
17
|
+
const queue = 'vps-fixture';
|
|
18
|
+
const taskId = 'vps-down-ssh-banner-timeout';
|
|
19
|
+
const taskFile = path.join(queueSubdirFor(root, queue, 'inbox'), `${taskId}.json`);
|
|
20
|
+
|
|
21
|
+
try {
|
|
22
|
+
await ensureQueueDirs(root, queue);
|
|
23
|
+
await writeJson(taskFile, { version: 1, id: taskId, title: 'Recover provider VPS', status: 'queued' });
|
|
24
|
+
const parked = await parkQueueTask(root, {
|
|
25
|
+
queue,
|
|
26
|
+
taskId,
|
|
27
|
+
kind: 'external_condition',
|
|
28
|
+
reason: 'VPS is down; SSH banner timed out.',
|
|
29
|
+
now: '2026-08-13T00:00:00.000Z',
|
|
30
|
+
executionKey: 'provider-call-1',
|
|
31
|
+
authorization: { state: 'unconsumed', scope: 'provider_call' },
|
|
32
|
+
policy: { timeoutMs: 2_000, reminderIntervalMs: 1_000, escalationIntervalMs: 2_000, maxReminders: 1 }
|
|
33
|
+
});
|
|
34
|
+
assert.equal(parked.outcome, 'parked');
|
|
35
|
+
assert.equal(parked.task.parked.authorization.state, 'unconsumed');
|
|
36
|
+
assert.equal(parked.task.parked.execution_boundary.action_executed, false);
|
|
37
|
+
|
|
38
|
+
const status = await queueStatus(root, queue);
|
|
39
|
+
assert.equal(status.waitingStates.timed_out_or_escalated, 1);
|
|
40
|
+
assert.equal(status.waitingTasks[0].waitKind, 'external_condition');
|
|
41
|
+
assert.equal(status.waitingTasks[0].authorizationState, 'unconsumed');
|
|
42
|
+
|
|
43
|
+
const notifyCommand = 'node -e "process.exit(0)"';
|
|
44
|
+
const reminder = await tickParkedTasks(root, { queue, now: '2026-08-13T00:00:01.000Z', notifyCommand });
|
|
45
|
+
assert.equal(reminder.results[0].type, 'reminder');
|
|
46
|
+
const duplicateTick = await tickParkedTasks(root, { queue, now: '2026-08-13T00:00:01.000Z', notifyCommand });
|
|
47
|
+
assert.equal(duplicateTick.results[0].outcome, 'throttled');
|
|
48
|
+
const escalation = await tickParkedTasks(root, { queue, now: '2026-08-13T00:00:03.000Z', notifyCommand });
|
|
49
|
+
assert.equal(escalation.results[0].type, 'escalation');
|
|
50
|
+
|
|
51
|
+
await assert.rejects(
|
|
52
|
+
resumeParkedTask(root, { queue, taskId, recoverySignal: 'ssh banner verified' }),
|
|
53
|
+
/--verified/
|
|
54
|
+
);
|
|
55
|
+
const resumed = await resumeParkedTask(root, {
|
|
56
|
+
queue,
|
|
57
|
+
taskId,
|
|
58
|
+
verified: true,
|
|
59
|
+
recoverySignal: 'probe=vps-1;ssh_banner=verified',
|
|
60
|
+
now: '2026-08-13T00:00:04.000Z'
|
|
61
|
+
});
|
|
62
|
+
assert.equal(resumed.outcome, 'verified_and_requeued');
|
|
63
|
+
assert.equal(resumed.task.parked.state, 'runnable');
|
|
64
|
+
assert.equal(resumed.task.parked.authorization.state, 'unconsumed');
|
|
65
|
+
assert.equal(resumed.task.parked.execution_boundary.action_executed, false);
|
|
66
|
+
assert.ok(resumed.signalSha256);
|
|
67
|
+
|
|
68
|
+
const afterRestart = await resumeParkedTask(root, {
|
|
69
|
+
queue,
|
|
70
|
+
taskId,
|
|
71
|
+
verified: true,
|
|
72
|
+
recoverySignal: 'probe=vps-1;ssh_banner=verified'
|
|
73
|
+
});
|
|
74
|
+
assert.equal(afterRestart.outcome, 'already_resumed');
|
|
75
|
+
const durable = await readJson(path.join(queueSubdirFor(root, queue, 'inbox'), `${taskId}.json`));
|
|
76
|
+
assert.equal(durable.parked.execution_boundary.key, 'provider-call-1');
|
|
77
|
+
assert.equal(durable.parked.authorization.state, 'unconsumed');
|
|
78
|
+
console.log('human-gate-lifecycle-v2 self-test: ok');
|
|
79
|
+
} finally {
|
|
80
|
+
await rm(root, { recursive: true, force: true });
|
|
81
|
+
}
|
|
@@ -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,74 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { mkdtemp, mkdir, readFile, stat, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { buildOperatorProjection, createDashboardServer, dashboardHealth, exportDashboard, filterProjection } from '../lib/operator-dashboard.mjs';
|
|
6
|
+
|
|
7
|
+
const root = await mkdtemp(path.join(tmpdir(), 'loop-dashboard-'));
|
|
8
|
+
const loops = path.join(root, 'runtime', 'loops');
|
|
9
|
+
await mkdir(path.join(loops, 'control-plane'), { recursive: true });
|
|
10
|
+
await mkdir(path.join(loops, 'action-reservations'), { recursive: true });
|
|
11
|
+
await mkdir(path.join(loops, 'legacy', 'waiting'), { recursive: true });
|
|
12
|
+
await mkdir(path.join(loops, 'legacy', 'active'), { recursive: true });
|
|
13
|
+
await mkdir(path.join(loops, 'projects', 'p3', 'intake'), { recursive: true });
|
|
14
|
+
const now = '2026-08-13T16:00:00.000Z';
|
|
15
|
+
const control = {
|
|
16
|
+
version: 2, updated_at: now, quotas: { credits: 9 }, agents: { a: { id: 'a', capabilities: ['code'], authority_grants: ['local'], provider_token: 'never-show' } },
|
|
17
|
+
handoffs: { h: { id: 'h', todo_id: 'leased', from_agent_id: 'a', to_agent_id: 'b', state: 'pending', created_at: now } },
|
|
18
|
+
todos: {
|
|
19
|
+
human: { version: 2, id: 'human', title: '<img src=x onerror=alert(1)>', state: 'runnable', priority: 2, risk: 'medium', authority_class: 'local', required_capabilities: [], acceptance_contract: { checks: ['ok'] }, evidence_requirements: ['test'], cost_envelope: { quota: 'credits', amount: 2 }, parked: { state: 'waiting_for_human', gate_id: 'g1', secret_input: 'hide' }, blocked_reasons: [], claim: null, lineage: { root_todo_id: 'human' }, evidence: [], idempotency_keys: [], created_at: now, updated_at: now },
|
|
20
|
+
leased: { version: 2, id: 'leased', title: 'Lease expired', state: 'claimed', priority: 1, authority_class: 'local', required_capabilities: [], acceptance_contract: {}, evidence_requirements: ['lease'], cost_envelope: { quota: 'credits', amount: 3 }, claim: { owner: 'a', fencing_token: 7, claimed_at: '2026-08-13T15:00:00.000Z', lease_expires_at: '2026-08-13T15:01:00.000Z' }, blocked_reasons: [], lineage: {}, evidence: [], idempotency_keys: ['paid:x'], created_at: now, updated_at: now }
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
await writeFile(path.join(loops, 'control-plane', 'state.json'), JSON.stringify(control));
|
|
24
|
+
await writeFile(path.join(loops, 'action-reservations', 'x.json'), JSON.stringify({ version: 1, idempotency_key: 'paid:x', kind: 'paid_api', state: 'unknown', request: { api_key: 'hide', prompt: '<script>x</script>' }, request_fingerprint: 'abc', authorization: { scope: 'paid', credential: 'hide' }, claim: null, reconciliation: { required: true, reason: 'stale_lease' }, created_at: now, updated_at: now }));
|
|
25
|
+
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
|
+
await writeFile(path.join(loops, 'legacy', 'active', 'bad.json'), '{broken');
|
|
27
|
+
await writeFile(path.join(loops, 'projects', 'p3', 'intake', 'latest.json'), JSON.stringify({ version: 1, goal: 'Operator dashboard', token: 'hidden' }));
|
|
28
|
+
|
|
29
|
+
const before = await stat(path.join(loops, 'control-plane', 'state.json'));
|
|
30
|
+
const first = await buildOperatorProjection(root, { now });
|
|
31
|
+
const second = await buildOperatorProjection(root, { now });
|
|
32
|
+
const after = await stat(path.join(loops, 'control-plane', 'state.json'));
|
|
33
|
+
assert.deepEqual(first, second, 'fixed-time projections are deterministic');
|
|
34
|
+
assert.equal(before.mtimeMs, after.mtimeMs, 'projection does not mutate source state');
|
|
35
|
+
assert.equal(first.todos.find((item) => item.id === 'human').state, 'waiting_for_human');
|
|
36
|
+
assert.equal(first.todos.find((item) => item.id === 'leased').state, 'reconciliation_required');
|
|
37
|
+
assert.equal(first.actions[0].state, 'reconciliation_required');
|
|
38
|
+
assert.equal(first.queues[0].tasks[0].state, 'waiting_for_external_condition');
|
|
39
|
+
assert.equal(first.agents[0].provider_token, '[REDACTED]');
|
|
40
|
+
assert.equal(first.todos[0].gate.secret_input, '[REDACTED]');
|
|
41
|
+
assert.equal(JSON.stringify(first).includes('never-show'), false);
|
|
42
|
+
assert.equal(JSON.stringify(first).includes('hidden'), false);
|
|
43
|
+
assert.equal(first.health.status, 'degraded');
|
|
44
|
+
assert.equal(filterProjection(first, { state: 'waiting_for_human', query: 'img' }).todos.length, 1);
|
|
45
|
+
assert.equal(dashboardHealth(first, { maxAgeSeconds: 1 }).stale, false);
|
|
46
|
+
|
|
47
|
+
const output = path.join(root, 'export');
|
|
48
|
+
await exportDashboard(root, output, { now });
|
|
49
|
+
assert.match(await readFile(path.join(output, 'index.html'), 'utf8'), /projection\.json/);
|
|
50
|
+
assert.equal(JSON.parse(await readFile(path.join(output, 'projection.json'), 'utf8')).schema_version, '1.0.0');
|
|
51
|
+
await assert.rejects(() => createDashboardServer(root, { host: '0.0.0.0' }), /requires --allow-non-loopback/);
|
|
52
|
+
|
|
53
|
+
const server = await createDashboardServer(root, { host: '127.0.0.1', port: 0 });
|
|
54
|
+
const address = server.address(); const base = `http://127.0.0.1:${address.port}`;
|
|
55
|
+
try {
|
|
56
|
+
const overview = await fetch(`${base}/api/v1/overview?state=waiting_for_human`).then((response) => response.json());
|
|
57
|
+
assert.equal(overview.todos.length, 1);
|
|
58
|
+
const detail = await fetch(`${base}/api/v1/todos/human`).then((response) => response.json());
|
|
59
|
+
assert.match(detail.title, /onerror/);
|
|
60
|
+
const page = await fetch(base).then((response) => response.text());
|
|
61
|
+
assert.doesNotMatch(page, /<img src=x/);
|
|
62
|
+
assert.equal((await fetch(`${base}/api/v1/todos/%2e%2e%2fsecret`)).status, 400);
|
|
63
|
+
assert.equal((await fetch(`${base}/api/v1/unknown`)).status, 404);
|
|
64
|
+
assert.equal((await fetch(`${base}/api/v1/overview`, { method: 'POST' })).status, 405);
|
|
65
|
+
} finally { await new Promise((resolve) => server.close(resolve)); }
|
|
66
|
+
|
|
67
|
+
// Large queue stays dependency-light and completes within a generous local budget.
|
|
68
|
+
await mkdir(path.join(loops, 'large', 'inbox'), { recursive: true });
|
|
69
|
+
await Promise.all(Array.from({ length: 500 }, (_, i) => writeFile(path.join(loops, 'large', 'inbox', `${i}.json`), JSON.stringify({ id: `bulk-${i}`, title: `Task ${i}`, state: 'runnable' }))));
|
|
70
|
+
const started = performance.now(); const large = await buildOperatorProjection(root, { now });
|
|
71
|
+
assert.equal(large.queues.find((queue) => queue.id === 'large').tasks.length, 500);
|
|
72
|
+
assert.ok(performance.now() - started < 5000, '500 task projection should finish under 5 seconds');
|
|
73
|
+
|
|
74
|
+
console.log(JSON.stringify({ status: 'ok', assertions: 'empty-compatible, legacy, malformed, deterministic, read-only, P0 gates, P1 reconciliation, P2 lease/handoff, redaction, XSS, traversal, bind safety, export, restart-safe server, large queue performance' }));
|
|
@@ -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;
|
|
@@ -57,6 +57,7 @@ assert.deepEqual(classifyLoopMessage('用 loop engineering 把现有的 growth o
|
|
|
57
57
|
assert.equal(classifyLoopMessage('Use Loop Engineering to fix this issue.').intent, 'execute');
|
|
58
58
|
assert.equal(classifyLoopMessage('Run this through Loop Engineering.').intent, 'execute');
|
|
59
59
|
assert.equal(classifyLoopMessage('Continue the current loop with this amendment: add English examples.').intent, 'execute');
|
|
60
|
+
assert.equal(classifyLoopMessage('我们继续开发我们的loop engineering').intent, 'execute');
|
|
60
61
|
|
|
61
62
|
const routed = await routeLoopMessage(root, {
|
|
62
63
|
route: true,
|
|
@@ -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,74 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { mkdtemp, readFile } from 'node:fs/promises';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { spawnSync } from 'node:child_process';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
import { claimAction, markActionUnknown, reserveAction } from '../lib/action-reservations.mjs';
|
|
8
|
+
import { claimTodo, createTodo, decideHandoff, handoffTodo, inspectTodo, recoverTodos, registerAgent, releaseTodo, renewTodo } from '../lib/todo-control-plane.mjs';
|
|
9
|
+
|
|
10
|
+
const root = await mkdtemp(path.join(tmpdir(), 'loop-p2-'));
|
|
11
|
+
const agent = (id, capabilities = ['code'], authority_grants = ['local'], quota = 10) => registerAgent(root, { id, capabilities, authority_grants, quota_grants: { default: quota } });
|
|
12
|
+
const todo = (id, extra = {}) => createTodo(root, { id, title: `Todo ${id}`, required_capabilities: ['code'], authority_class: 'local', acceptance_contract: { checks: ['test'] }, evidence_requirements: ['test-output'], cost: 1, ...extra });
|
|
13
|
+
|
|
14
|
+
await Promise.all([agent('alpha'), agent('beta'), agent('observer', []), agent('poor', ['code'], ['local'], 0)]);
|
|
15
|
+
await todo('race');
|
|
16
|
+
const race = await Promise.all([claimTodo(root, { todoId: 'race', agentId: 'alpha', leaseMs: 1000 }), claimTodo(root, { todoId: 'race', agentId: 'beta', leaseMs: 1000 })]);
|
|
17
|
+
assert.equal(race.filter((item) => item.claimed).length, 1, 'exactly one agent wins an atomic claim');
|
|
18
|
+
const winner = race.find((item) => item.claimed);
|
|
19
|
+
const loser = race.find((item) => !item.claimed);
|
|
20
|
+
assert.match(loser.reason, /state:claimed/);
|
|
21
|
+
await assert.rejects(() => renewTodo(root, { todoId: 'race', agentId: loser === race[0] ? 'alpha' : 'beta', fencingToken: winner.fencing_token }), /Stale or invalid/);
|
|
22
|
+
|
|
23
|
+
await todo('capability');
|
|
24
|
+
assert.equal((await claimTodo(root, { todoId: 'capability', agentId: 'observer' })).reason, 'capability_mismatch');
|
|
25
|
+
await todo('quota');
|
|
26
|
+
assert.equal((await claimTodo(root, { todoId: 'quota', agentId: 'poor' })).reason, 'quota_exhausted');
|
|
27
|
+
|
|
28
|
+
await todo('dependency');
|
|
29
|
+
await todo('dependent', { dependencies: ['dependency'], priority: 100 });
|
|
30
|
+
const blocked = await claimTodo(root, { todoId: 'dependent', agentId: 'alpha' });
|
|
31
|
+
assert.match(blocked.reason, /dependencies/);
|
|
32
|
+
const dependencyClaim = await claimTodo(root, { todoId: 'dependency', agentId: 'alpha' });
|
|
33
|
+
await releaseTodo(root, { todoId: 'dependency', agentId: 'alpha', fencingToken: dependencyClaim.fencing_token, completed: true, evidence: 'passed' });
|
|
34
|
+
assert.equal((await claimTodo(root, { todoId: 'dependent', agentId: 'alpha' })).claimed, true, 'completion unlocks dependencies');
|
|
35
|
+
|
|
36
|
+
await todo('stale');
|
|
37
|
+
const stale = await claimTodo(root, { todoId: 'stale', agentId: 'alpha', leaseMs: 1 });
|
|
38
|
+
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
39
|
+
await assert.rejects(() => renewTodo(root, { todoId: 'stale', agentId: 'alpha', fencingToken: stale.fencing_token }), /expired/);
|
|
40
|
+
assert.equal((await recoverTodos(root)).results.find((item) => item.todo_id === 'stale').outcome, 'runnable');
|
|
41
|
+
const reclaimed = await claimTodo(root, { todoId: 'stale', agentId: 'beta' });
|
|
42
|
+
assert.ok(reclaimed.fencing_token > stale.fencing_token, 'recovered work gets a higher fencing token');
|
|
43
|
+
|
|
44
|
+
await todo('handoff');
|
|
45
|
+
const owned = await claimTodo(root, { todoId: 'handoff', agentId: 'alpha' });
|
|
46
|
+
const packet = await handoffTodo(root, { todoId: 'handoff', agentId: 'alpha', targetAgentId: 'beta', fencingToken: owned.fencing_token });
|
|
47
|
+
assert.deepEqual(packet.idempotency_keys, []);
|
|
48
|
+
const cli = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../bin/loop-engineering.mjs');
|
|
49
|
+
const restarted = spawnSync(process.execPath, [cli, 'todo-inspect', '--todo-id', 'handoff', '--root', root], { encoding: 'utf8' });
|
|
50
|
+
assert.equal(restarted.status, 0);
|
|
51
|
+
assert.equal(JSON.parse(restarted.stdout).state, 'handoff_pending', 'handoff survives a fresh CLI process');
|
|
52
|
+
const accepted = await decideHandoff(root, { handoffId: packet.id, agentId: 'beta', accept: true });
|
|
53
|
+
assert.equal(accepted.todo.claim.owner, 'beta');
|
|
54
|
+
assert.ok(accepted.todo.claim.fencing_token > owned.fencing_token);
|
|
55
|
+
await todo('handoff-reject');
|
|
56
|
+
const rejectOwned = await claimTodo(root, { todoId: 'handoff-reject', agentId: 'alpha' });
|
|
57
|
+
const rejectPacket = await handoffTodo(root, { todoId: 'handoff-reject', agentId: 'alpha', targetAgentId: 'beta', fencingToken: rejectOwned.fencing_token });
|
|
58
|
+
const rejected = await decideHandoff(root, { handoffId: rejectPacket.id, agentId: 'beta', accept: false, reason: 'busy' });
|
|
59
|
+
assert.equal(rejected.todo.claim.owner, 'alpha');
|
|
60
|
+
|
|
61
|
+
await todo('parked', { parked: { state: 'waiting_for_human' } });
|
|
62
|
+
assert.equal((await claimTodo(root, { todoId: 'parked', agentId: 'alpha' })).reason, 'parked_human_gate');
|
|
63
|
+
|
|
64
|
+
await reserveAction(root, { idempotencyKey: 'side-effect', kind: 'external_message', authorizationScope: 'todo:external', request: { body: 'once' } });
|
|
65
|
+
const action = await claimAction(root, { idempotencyKey: 'side-effect', owner: 'alpha', leaseMs: 1000 });
|
|
66
|
+
await markActionUnknown(root, { idempotencyKey: 'side-effect', fencingToken: action.fencingToken, reason: 'worker_crash' });
|
|
67
|
+
await todo('unknown-action', { idempotency_keys: ['side-effect'], authorization: { scope: 'todo:external', grant: 'preserved' } });
|
|
68
|
+
assert.match((await claimTodo(root, { todoId: 'unknown-action', agentId: 'beta' })).reason, /action_reconciliation/);
|
|
69
|
+
assert.equal((await inspectTodo(root, 'unknown-action')).authorization.grant, 'preserved');
|
|
70
|
+
|
|
71
|
+
const audit = await readFile(path.join(root, 'runtime', 'loops', 'control-plane', 'audit.jsonl'), 'utf8');
|
|
72
|
+
assert.match(audit, /todo_claimed/);
|
|
73
|
+
assert.match(audit, /handoff_accepted/);
|
|
74
|
+
console.log(JSON.stringify({ ok: true, root, assertions: ['atomic claim', 'capability', 'dependency', 'quota', 'lease fencing', 'orphan recovery', 'handoff accept/reject', 'parked gate', 'unknown action reconciliation', 'audit'] }));
|
|
@@ -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 }); }
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"$schema":"https://json-schema.org/draft/2020-12/schema","$id":"https://loop-engineering.local/schema/operator-projection-v1.json","title":"Loop Engineering read-only operator projection v1","type":"object","required":["schema_version","generated_at","source","health","overview","projects","queues","todos","agents","handoffs","gates","actions","cost"],"properties":{"schema_version":{"const":"1.0.0"},"generated_at":{"type":"string","format":"date-time"},"source":{"type":"object"},"health":{"type":"object"},"overview":{"type":"object"},"projects":{"type":"array"},"queues":{"type":"array"},"todos":{"type":"array"},"agents":{"type":"array"},"handoffs":{"type":"array"},"gates":{"type":"array"},"actions":{"type":"array"},"cost":{"type":"object"}},"additionalProperties":false}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://loop-engineering.local/schema/todo-v2.json",
|
|
4
|
+
"title": "Loop Engineering typed todo v2",
|
|
5
|
+
"type": "object",
|
|
6
|
+
"required": ["id", "title", "priority", "risk", "authority_class", "required_capabilities", "acceptance_contract", "evidence_requirements", "cost_envelope", "state"],
|
|
7
|
+
"properties": {
|
|
8
|
+
"id": { "type": "string", "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._:-]{0,199}$" },
|
|
9
|
+
"title": { "type": "string", "minLength": 1 },
|
|
10
|
+
"project_id": { "type": ["string", "null"] },
|
|
11
|
+
"dependencies": { "type": "array", "uniqueItems": true, "items": { "type": "string" } },
|
|
12
|
+
"priority": { "type": "number" },
|
|
13
|
+
"risk": { "enum": ["low", "medium", "high", "critical"] },
|
|
14
|
+
"authority_class": { "type": "string", "minLength": 1 },
|
|
15
|
+
"required_capabilities": { "type": "array", "uniqueItems": true, "items": { "type": "string" } },
|
|
16
|
+
"acceptance_contract": { "type": "object" },
|
|
17
|
+
"evidence_requirements": { "type": "array", "minItems": 1, "uniqueItems": true, "items": { "type": "string" } },
|
|
18
|
+
"cost_envelope": { "type": "object", "required": ["quota", "amount"], "properties": { "quota": { "type": "string" }, "amount": { "type": "number", "minimum": 0 } } },
|
|
19
|
+
"state": { "enum": ["runnable", "blocked", "claimed", "handoff_pending", "completed"] },
|
|
20
|
+
"authorization": {},
|
|
21
|
+
"idempotency_keys": { "type": "array", "uniqueItems": true, "items": { "type": "string" } },
|
|
22
|
+
"lineage": { "type": "object" },
|
|
23
|
+
"context": { "type": "object" },
|
|
24
|
+
"evidence": { "type": "array" },
|
|
25
|
+
"claim": { "type": ["object", "null"] }
|
|
26
|
+
},
|
|
27
|
+
"additionalProperties": true
|
|
28
|
+
}
|