@ludi-uni/ludi-agent-kit 0.1.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/AGENTS.md +55 -0
- package/LICENSE +21 -0
- package/README.md +107 -0
- package/adapters/codex/README.md +24 -0
- package/adapters/codex/skill-metadata/visual-verification/agents/openai.yaml +7 -0
- package/adapters/pi/README.md +88 -0
- package/adapters/pi/browser/agent-browser.mjs +193 -0
- package/adapters/pi/lib/invoke.mjs +55 -0
- package/adapters/pi/lib/list-models.mjs +29 -0
- package/adapters/pi/lib/settings-proposal.mjs +34 -0
- package/adapters/pi/lib/subagent.mjs +175 -0
- package/adapters/pi/loop-guard/index.js +51 -0
- package/adapters/pi/maintenance-policy.json +36 -0
- package/adapters/pi/mcp.template.json +4 -0
- package/adapters/pi/model-catalog.json +97 -0
- package/adapters/pi/models.json +13 -0
- package/adapters/pi/models.local.example.json +14 -0
- package/adapters/pi/orchestrator-ext/command.mjs +14 -0
- package/adapters/pi/orchestrator-ext/index.js +150 -0
- package/adapters/pi/settings.template.json +7 -0
- package/adapters/pi/shell-gate/index.js +70 -0
- package/adapters/pi/sync-pi.ps1 +137 -0
- package/agents/README.md +26 -0
- package/agents/browser.md +64 -0
- package/agents/coder.md +31 -0
- package/agents/orchestrator.md +37 -0
- package/agents/reviewer.md +32 -0
- package/agents/scout.md +35 -0
- package/agents/tester.md +28 -0
- package/agents/visual.md +28 -0
- package/context-pack/SPEC.md +101 -0
- package/context-pack/context-pack.schema.json +79 -0
- package/context-pack/examples/example-fix.md +44 -0
- package/docs/architecture.md +55 -0
- package/docs/migration-from-codex-setting.md +44 -0
- package/docs/model-maintenance.md +401 -0
- package/docs/orchestrator.md +155 -0
- package/docs/phase2-report.md +39 -0
- package/docs/roadmap.md +27 -0
- package/docs/third-party.md +15 -0
- package/lib/agents.mjs +79 -0
- package/lib/context-pack.mjs +215 -0
- package/lib/job.mjs +312 -0
- package/lib/language-policy.mjs +27 -0
- package/lib/maintenance-exec.mjs +377 -0
- package/lib/maintenance-runner.mjs +266 -0
- package/lib/maintenance.mjs +422 -0
- package/lib/normalize.mjs +101 -0
- package/lib/observe/differ.mjs +185 -0
- package/lib/observe/observation.mjs +147 -0
- package/lib/observe/observers.mjs +134 -0
- package/lib/observe/sources.mjs +154 -0
- package/lib/orchestrator/activity.mjs +249 -0
- package/lib/orchestrator/api.mjs +151 -0
- package/lib/orchestrator/contract.mjs +68 -0
- package/lib/orchestrator/escalation.mjs +84 -0
- package/lib/orchestrator/evaluator.mjs +92 -0
- package/lib/orchestrator/failures.mjs +88 -0
- package/lib/orchestrator/health.mjs +53 -0
- package/lib/orchestrator/orchestrator.mjs +483 -0
- package/lib/orchestrator/permissions.mjs +64 -0
- package/lib/orchestrator/planner.mjs +194 -0
- package/lib/orchestrator/policy.mjs +134 -0
- package/lib/orchestrator/router.mjs +45 -0
- package/lib/orchestrator/runner.mjs +278 -0
- package/lib/orchestrator/shell-policy.mjs +52 -0
- package/lib/orchestrator/store.mjs +581 -0
- package/lib/orchestrator/task-store.mjs +79 -0
- package/lib/orchestrator/turn-budget.mjs +63 -0
- package/lib/orchestrator/worktree.mjs +72 -0
- package/lib/pipeline.mjs +279 -0
- package/lib/registry.mjs +63 -0
- package/lib/resolve.mjs +35 -0
- package/lib/routing.mjs +137 -0
- package/lib/telemetry.mjs +222 -0
- package/mcp/README.md +11 -0
- package/mcp/servers.json +13 -0
- package/orchestration/decision-policy.json +66 -0
- package/package.json +56 -0
- package/routing/README.md +24 -0
- package/routing/routing.json +81 -0
- package/routing/routing.schema.json +66 -0
- package/rules/README.md +10 -0
- package/rules/common.md +52 -0
- package/rules/loop-prevention.md +15 -0
- package/rules/repo-local.md +6 -0
- package/scripts/check-environment.ps1 +22 -0
- package/scripts/context-pack.mjs +17 -0
- package/scripts/e2e-investigate-repro.mjs +66 -0
- package/scripts/model-maintenance-job.mjs +59 -0
- package/scripts/observe-models.mjs +97 -0
- package/scripts/orchestrate.mjs +137 -0
- package/scripts/reevaluate-models.mjs +95 -0
- package/scripts/report-model-maintenance.mjs +70 -0
- package/scripts/resolve-capabilities.mjs +39 -0
- package/scripts/run-pipeline.mjs +56 -0
- package/scripts/sync-agents-md.ps1 +10 -0
- package/scripts/validate.mjs +71 -0
- package/skills/README.md +14 -0
- package/skills/pi-workflow/SKILL.md +26 -0
- package/skills/pi-workflow/references/code-investigation-and-fix.md +16 -0
- package/skills/pi-workflow/references/research.md +14 -0
- package/skills/pi-workflow/references/review.md +11 -0
- package/skills/pi-workflow/references/visual-work.md +14 -0
- package/skills/project-management/SKILL.md +106 -0
- package/skills/project-management/references/operations.md +52 -0
- package/skills/visual-verification/SKILL.md +88 -0
- package/skills/visual-verification/scripts/analyze-speech.ps1 +346 -0
- package/skills/visual-verification/scripts/backends/whisperx_backend.py +234 -0
- package/skills/visual-verification/scripts/common.ps1 +387 -0
- package/skills/visual-verification/scripts/contact-sheet.ps1 +121 -0
- package/skills/visual-verification/scripts/desktop-discover.ps1 +45 -0
- package/skills/visual-verification/scripts/desktop-inspect.ps1 +67 -0
- package/skills/visual-verification/scripts/desktop-record.ps1 +97 -0
- package/skills/visual-verification/scripts/desktop-screenshot.ps1 +65 -0
- package/skills/visual-verification/scripts/evaluate-sync.ps1 +249 -0
- package/skills/visual-verification/scripts/extract-frames.ps1 +79 -0
- package/skills/visual-verification/scripts/inspect-media.ps1 +138 -0
- package/skills/visual-verification/scripts/record-av.ps1 +102 -0
- package/skills/visual-verification/scripts/record.ps1 +72 -0
- package/skills/visual-verification/scripts/screenshot.ps1 +44 -0
- package/skills/visual-verification/scripts/waveform.ps1 +450 -0
- package/skills/visual-verification/scripts/winapp-common.ps1 +465 -0
- package/tests/activity.test.mjs +252 -0
- package/tests/attempt-budget.test.mjs +102 -0
- package/tests/browser.test.mjs +121 -0
- package/tests/context-pack.test.mjs +98 -0
- package/tests/dirty-gate.test.mjs +211 -0
- package/tests/e2e-browser.mjs +66 -0
- package/tests/e2e-real-orchestrator-resume.mjs +101 -0
- package/tests/e2e-real-orchestrator.mjs +41 -0
- package/tests/e2e-real-pi.mjs +27 -0
- package/tests/e2e-real-tool-orchestrator.mjs +66 -0
- package/tests/fixtures/browser-page/index.html +20 -0
- package/tests/fixtures/maintenance/availability.txt +5 -0
- package/tests/fixtures/maintenance/catalog.json +74 -0
- package/tests/fixtures/maintenance/events.json +13 -0
- package/tests/fixtures/math-repo/README.md +3 -0
- package/tests/fixtures/math-repo/package.json +7 -0
- package/tests/fixtures/math-repo/src/math.js +11 -0
- package/tests/fixtures/math-repo/test/math.test.js +7 -0
- package/tests/fixtures/observe/announcements.json +8 -0
- package/tests/fixtures/orch-concurrent-child.mjs +44 -0
- package/tests/fixtures/orch-persist-child.mjs +61 -0
- package/tests/job.test.mjs +230 -0
- package/tests/kit.test.mjs +79 -0
- package/tests/language-policy.test.mjs +93 -0
- package/tests/loop-guard.test.mjs +60 -0
- package/tests/maintenance-exec.test.mjs +218 -0
- package/tests/maintenance-runner.test.mjs +222 -0
- package/tests/maintenance.test.mjs +195 -0
- package/tests/observe.test.mjs +283 -0
- package/tests/observer-registry.test.mjs +157 -0
- package/tests/orchestrator-cleanup.test.mjs +358 -0
- package/tests/orchestrator-command.test.mjs +14 -0
- package/tests/orchestrator-persist.test.mjs +375 -0
- package/tests/orchestrator-tools.test.mjs +215 -0
- package/tests/orchestrator.test.mjs +396 -0
- package/tests/package.test.mjs +37 -0
- package/tests/pipeline.test.mjs +239 -0
- package/tests/planner-classification.test.mjs +81 -0
- package/tests/planner-split.test.mjs +67 -0
- package/tests/qoder-observer.test.mjs +266 -0
- package/tests/reassign-progression.test.mjs +104 -0
- package/tests/retry-escalation.test.mjs +120 -0
- package/tests/routing.test.mjs +110 -0
- package/tests/sqlite-concurrency.test.mjs +178 -0
- package/tests/task-global-e2e.test.mjs +63 -0
- package/tests/task-global-failed.test.mjs +134 -0
- package/tests/telemetry.test.mjs +173 -0
- package/tests/test-sync-pi.ps1 +56 -0
- package/tests/turn-budget.test.mjs +106 -0
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
// SQLite concurrency and exception-safe run termination: busy_timeout, two writers on
|
|
2
|
+
// one state.db, injected exceptions, and no `running` zombie in list / latest-run.
|
|
3
|
+
import test from 'node:test';
|
|
4
|
+
import assert from 'node:assert/strict';
|
|
5
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
6
|
+
import { mkdtempSync } from 'node:fs';
|
|
7
|
+
import { tmpdir } from 'node:os';
|
|
8
|
+
import { join, resolve, dirname } from 'node:path';
|
|
9
|
+
import { fileURLToPath } from 'node:url';
|
|
10
|
+
import { loadRouting } from '../lib/routing.mjs';
|
|
11
|
+
import { loadAgents } from '../lib/agents.mjs';
|
|
12
|
+
import { DEFAULT_POLICY } from '../lib/orchestrator/policy.mjs';
|
|
13
|
+
import { openStore, BUSY_TIMEOUT_MS } from '../lib/orchestrator/store.mjs';
|
|
14
|
+
import { orchestrate } from '../lib/orchestrator/orchestrator.mjs';
|
|
15
|
+
|
|
16
|
+
const kit = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
17
|
+
const routing = loadRouting(join(kit, 'routing/routing.json'));
|
|
18
|
+
const { agents } = loadAgents(join(kit, 'agents'), routing);
|
|
19
|
+
const REG = { version: 1, backends: {
|
|
20
|
+
local: { provider: 'pl', model: 'ml', thinking: 'off' },
|
|
21
|
+
cheap: { provider: 'pc', model: 'mc', thinking: 'low' },
|
|
22
|
+
sol: { provider: 'ps', model: 'ms', thinking: 'high' },
|
|
23
|
+
codex: { provider: 'px', model: 'mx', thinking: 'high' },
|
|
24
|
+
} };
|
|
25
|
+
const spec = (id, agent, dependencies = []) => ({ id, title: `task ${id}`, goal: `goal ${id}`, agent, dependencies, acceptance: ['done'] });
|
|
26
|
+
const completed = task => ({ ok: true, structured: true, result: { status: 'completed', summary: `did ${task.id}`, artifacts: [], verification: [], acceptance: [{ id: 'A1', met: true, evidence: 'observed' }], remainingIssues: [], decisions: [], newTasks: [] } });
|
|
27
|
+
const dbPath = () => join(mkdtempSync(join(tmpdir(), 'ludi-sql-')), 'state.db');
|
|
28
|
+
const child = join(kit, 'tests/fixtures/orch-concurrent-child.mjs');
|
|
29
|
+
const cli = join(kit, 'scripts/orchestrate.mjs');
|
|
30
|
+
|
|
31
|
+
const spawnChild = args => new Promise(res => {
|
|
32
|
+
const p = spawn(process.execPath, [child, ...args], { encoding: 'utf8' });
|
|
33
|
+
let out = '', err = '';
|
|
34
|
+
p.stdout.on('data', d => { out += d; });
|
|
35
|
+
p.stderr.on('data', d => { err += d; });
|
|
36
|
+
p.on('close', code => res({ code, out, err, json: (() => { try { return JSON.parse(out.trim().split(/\r?\n/).at(-1)); } catch { return null; } })() }));
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test('busy_timeout is set to a bounded value and WAL is kept', () => {
|
|
40
|
+
const session = openStore(dbPath());
|
|
41
|
+
const p = session.pragmas();
|
|
42
|
+
assert.equal(p.journalMode, 'wal');
|
|
43
|
+
assert.equal(p.busyTimeoutMs, BUSY_TIMEOUT_MS);
|
|
44
|
+
assert.ok(BUSY_TIMEOUT_MS >= 1000 && BUSY_TIMEOUT_MS <= 30000, 'bounded');
|
|
45
|
+
session.close();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('concurrent writer: another process holding the write lock makes us wait (busy_timeout), not throw', async () => {
|
|
49
|
+
const path = dbPath();
|
|
50
|
+
const session = openStore(path);
|
|
51
|
+
const holdMs = 600;
|
|
52
|
+
// Separate process: BEGIN IMMEDIATE, hold for holdMs, COMMIT.
|
|
53
|
+
const holder = spawn(process.execPath, ['--input-type=module', '-e', `
|
|
54
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
55
|
+
const db = new DatabaseSync(${JSON.stringify(path)});
|
|
56
|
+
db.exec('BEGIN IMMEDIATE');
|
|
57
|
+
db.prepare("INSERT INTO runs (id, request, status, created_at, updated_at, policy_snapshot, counters, scope_key) VALUES ('hold','h','completed','2000','2000','{}','{}','k')").run();
|
|
58
|
+
console.log('locked');
|
|
59
|
+
await new Promise(r => setTimeout(r, ${holdMs}));
|
|
60
|
+
db.exec('COMMIT'); db.close();
|
|
61
|
+
`], { encoding: 'utf8' });
|
|
62
|
+
await new Promise(res => holder.stdout.on('data', d => { if (String(d).includes('locked')) res(); }));
|
|
63
|
+
const started = Date.now();
|
|
64
|
+
const runId = session.createRun({ request: 'waiter', policy: DEFAULT_POLICY }); // would throw SQLITE_BUSY without busy_timeout
|
|
65
|
+
const waited = Date.now() - started;
|
|
66
|
+
assert.ok(waited >= 200, `expected to wait for the lock, waited ${waited}ms`);
|
|
67
|
+
assert.ok(waited < BUSY_TIMEOUT_MS, `waited ${waited}ms`);
|
|
68
|
+
assert.equal(session.getRun(runId).status, 'running');
|
|
69
|
+
await new Promise(res => holder.on('close', res));
|
|
70
|
+
assert.equal(session.listRuns().length, 2);
|
|
71
|
+
session.close();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test('without busy_timeout the same overlap throws SQLITE_BUSY (documents why the pragma matters)', async () => {
|
|
75
|
+
const path = dbPath();
|
|
76
|
+
openStore(path).close();
|
|
77
|
+
const { DatabaseSync } = await import('node:sqlite');
|
|
78
|
+
const raw = new DatabaseSync(path); // default busy_timeout = 0
|
|
79
|
+
const holder = spawn(process.execPath, ['--input-type=module', '-e', `
|
|
80
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
81
|
+
const db = new DatabaseSync(${JSON.stringify(path)});
|
|
82
|
+
db.exec('BEGIN IMMEDIATE'); console.log('locked');
|
|
83
|
+
await new Promise(r => setTimeout(r, 400)); db.exec('COMMIT'); db.close();
|
|
84
|
+
`], { encoding: 'utf8' });
|
|
85
|
+
await new Promise(res => holder.stdout.on('data', d => { if (String(d).includes('locked')) res(); }));
|
|
86
|
+
assert.throws(() => raw.exec('BEGIN IMMEDIATE'), /SQLITE_BUSY|database is locked/i);
|
|
87
|
+
raw.close();
|
|
88
|
+
await new Promise(res => holder.on('close', res));
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test('concurrent E2E: two orchestrate processes on one state.db both terminate, no running zombie', async () => {
|
|
92
|
+
const path = dbPath();
|
|
93
|
+
openStore(path).close(); // create schema once so both children start on an existing file
|
|
94
|
+
const [x, y] = await Promise.all([spawnChild([path, 'alpha', '4', '40']), spawnChild([path, 'beta', '4', '40'])]);
|
|
95
|
+
assert.equal(x.code, 0, x.err + x.out);
|
|
96
|
+
assert.equal(y.code, 0, y.err + y.out);
|
|
97
|
+
assert.equal(x.json.runStatus, 'completed');
|
|
98
|
+
assert.equal(y.json.runStatus, 'completed');
|
|
99
|
+
assert.notEqual(x.json.runId, y.json.runId);
|
|
100
|
+
const session = openStore(path);
|
|
101
|
+
const runs = session.listRuns();
|
|
102
|
+
assert.equal(runs.length, 2);
|
|
103
|
+
assert.deepEqual(runs.map(r => r.status), ['completed', 'completed']);
|
|
104
|
+
assert.ok(runs.every(r => r.completed === 4 && r.total === 4));
|
|
105
|
+
assert.equal(session.listRuns({ status: 'running' }).length, 0, 'no zombie');
|
|
106
|
+
for (const r of runs) assert.ok(session.loadTasks(r.id).every(t => t.status === 'completed'));
|
|
107
|
+
session.close();
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test('concurrent E2E with an injected exception: the failing run ends `failed`, the other completes, latest-run is never a zombie', async () => {
|
|
111
|
+
const path = dbPath();
|
|
112
|
+
openStore(path).close();
|
|
113
|
+
const [x, y] = await Promise.all([spawnChild([path, 'alpha', '3', '40', 't2']), spawnChild([path, 'beta', '3', '40'])]);
|
|
114
|
+
assert.equal(x.code, 3, x.err + x.out);
|
|
115
|
+
assert.match(x.json.error, /injected failure at t2/);
|
|
116
|
+
assert.equal(x.json.persisted, true, 'run state was persisted on error');
|
|
117
|
+
assert.equal(y.code, 0, y.err + y.out);
|
|
118
|
+
const session = openStore(path);
|
|
119
|
+
const failed = session.getRun(x.json.runId);
|
|
120
|
+
assert.equal(failed.status, 'failed');
|
|
121
|
+
assert.ok(failed.counters.unresolved.some(u => /run aborted/.test(u)));
|
|
122
|
+
assert.ok(session.loadTrace(failed.id).some(e => e.type === 'error' && /injected/.test(e.message)));
|
|
123
|
+
assert.ok(session.loadTasks(failed.id).every(t => t.status !== 'running'));
|
|
124
|
+
assert.equal(session.getRun(y.json.runId).status, 'completed');
|
|
125
|
+
assert.equal(session.listRuns({ status: 'running' }).length, 0);
|
|
126
|
+
// list / latest-run: the top row is a terminal state, and --list from the CLI agrees.
|
|
127
|
+
const list = spawnSync(process.execPath, [cli, '--list', '--store', path], { encoding: 'utf8' });
|
|
128
|
+
assert.equal(list.status, 0, list.stderr);
|
|
129
|
+
assert.doesNotMatch(list.stdout, /\srunning\s/);
|
|
130
|
+
session.close();
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test('in-process injected store exception mid-round: in-flight task and run are terminated, updated_at moves, transaction is released', async () => {
|
|
134
|
+
const path = dbPath();
|
|
135
|
+
const real = openStore(path);
|
|
136
|
+
let armed = false;
|
|
137
|
+
// Wrap the session: appendTrace throws once on the first `result` event, i.e. while
|
|
138
|
+
// the task is still `running` in the store.
|
|
139
|
+
const session = { ...real, appendTrace(runId, e) { if (e.type === 'result' && !armed) { armed = true; throw new Error('disk full (injected)'); } return real.appendTrace(runId, e); } };
|
|
140
|
+
let caught = null;
|
|
141
|
+
try {
|
|
142
|
+
await orchestrate({ request: 'boom', plan: [spec('a', 'scout'), spec('b', 'coder', ['a'])], agents, routing, registry: REG, policy: DEFAULT_POLICY, session, runner: { async run(t) { return completed(t); } } });
|
|
143
|
+
} catch (e) { caught = e; }
|
|
144
|
+
assert.ok(caught, 'orchestrate rethrows');
|
|
145
|
+
assert.match(caught.message, /disk full/);
|
|
146
|
+
assert.equal(caught.persisted, true);
|
|
147
|
+
const run = real.getRun(caught.runId);
|
|
148
|
+
assert.equal(run.status, 'failed');
|
|
149
|
+
assert.ok(run.updatedAt >= run.createdAt);
|
|
150
|
+
const tasks = real.loadTasks(run.id);
|
|
151
|
+
assert.equal(tasks.find(t => t.id === 'a').status, 'failed');
|
|
152
|
+
assert.match(tasks.find(t => t.id === 'a').blockedReason, /run aborted by error/);
|
|
153
|
+
assert.equal(tasks.find(t => t.id === 'b').status, 'pending');
|
|
154
|
+
assert.ok(real.loadTrace(run.id).some(e => e.type === 'error'));
|
|
155
|
+
// The connection is usable (no dangling BEGIN): a fresh run on the same session completes.
|
|
156
|
+
const ok = await orchestrate({ request: 'after', plan: [spec('a', 'scout')], agents, routing, registry: REG, policy: DEFAULT_POLICY, session: real, runner: { async run(t) { return completed(t); } } });
|
|
157
|
+
assert.equal(ok.runStatus, 'completed');
|
|
158
|
+
assert.equal(real.listRuns({ status: 'running' }).length, 0);
|
|
159
|
+
assert.equal(real.listRuns()[0].id, ok.runId, 'latest run is the completed one');
|
|
160
|
+
real.close();
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test('store unwritable during termination: the error names the persistence limitation instead of hiding it', async () => {
|
|
164
|
+
const real = openStore(dbPath());
|
|
165
|
+
let phase = 0;
|
|
166
|
+
const session = { ...real,
|
|
167
|
+
appendTrace(runId, e) { if (e.type === 'result') { phase = 1; throw new Error('primary failure'); } if (phase === 1) throw new Error('store closed'); return real.appendTrace(runId, e); },
|
|
168
|
+
updateRun(id, patch) { if (phase === 1) throw new Error('store closed'); return real.updateRun(id, patch); },
|
|
169
|
+
saveTask(runId, t) { if (phase === 1) throw new Error('store closed'); return real.saveTask(runId, t); },
|
|
170
|
+
};
|
|
171
|
+
let caught = null;
|
|
172
|
+
try { await orchestrate({ request: 'boom', plan: [spec('a', 'scout')], agents, routing, registry: REG, policy: DEFAULT_POLICY, session, runner: { async run(t) { return completed(t); } } }); }
|
|
173
|
+
catch (e) { caught = e; }
|
|
174
|
+
assert.match(caught.message, /primary failure/);
|
|
175
|
+
assert.match(caught.message, /run state could not be persisted: store closed/);
|
|
176
|
+
assert.equal(caught.persisted, false);
|
|
177
|
+
real.close();
|
|
178
|
+
});
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { resolve, dirname, join } from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { loadRouting } from '../lib/routing.mjs';
|
|
6
|
+
import { loadAgents } from '../lib/agents.mjs';
|
|
7
|
+
import { DEFAULT_POLICY, mergePolicy } from '../lib/orchestrator/policy.mjs';
|
|
8
|
+
import { createAgentRunner } from '../lib/orchestrator/runner.mjs';
|
|
9
|
+
import { orchestrate, formatReport } from '../lib/orchestrator/orchestrator.mjs';
|
|
10
|
+
|
|
11
|
+
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
12
|
+
const routing = structuredClone(loadRouting(join(root, 'routing/routing.json')));
|
|
13
|
+
routing.capabilities['cheap-code'].fallback = ['local'];
|
|
14
|
+
routing.capabilities['strong-code'].fallback = ['qoder', 'codex', 'local'];
|
|
15
|
+
const { agents } = loadAgents(join(root, 'agents'), routing);
|
|
16
|
+
const registry = { version: 1, backends: {
|
|
17
|
+
qoder: { provider: 'qoder', model: 'Qwen3.8-Flash' },
|
|
18
|
+
local: { provider: 'freetoken', model: 'FreeToken' },
|
|
19
|
+
devin: { provider: 'devin', model: 'SWE-2' },
|
|
20
|
+
codex: { provider: 'codex', model: 'quota-0' },
|
|
21
|
+
} };
|
|
22
|
+
const policy = mergePolicy(DEFAULT_POLICY, { limits: { max_retries: 3, model_attempts_per_task: 2, max_total_attempts_per_task: 4 } });
|
|
23
|
+
const task = { id: 't1', title: 'fixture', goal: 'complete fixture', agent: 'scout', dependencies: [], acceptance: ['done'] };
|
|
24
|
+
|
|
25
|
+
test('Codex quota 0 E2E: cheap protocol failures remain excluded on strong-code; three invocations and pipeline continues', async () => {
|
|
26
|
+
const calls = [];
|
|
27
|
+
const health = { skip: c => c.provider === 'codex' ? 'quota-0' : null, report() {} };
|
|
28
|
+
const runner = createAgentRunner({ agents, routing, registry, policy, health,
|
|
29
|
+
invoke: async () => { throw new Error('unexpected oneshot'); },
|
|
30
|
+
runSubagent: async c => {
|
|
31
|
+
calls.push(c.modelId);
|
|
32
|
+
if (c.provider === 'qoder') return { ok: true, text: 'not structured', child: { toolCalls: 2, turns: 3 } };
|
|
33
|
+
if (c.provider === 'freetoken') return { ok: false, error: 'turn limit 12', failureClass: 'NO_PROGRESS_TIMEOUT',
|
|
34
|
+
telemetry: { text: '', toolCalls: 0, turns: 12 }, child: { toolCalls: 0, turns: 12, stopReason: 'no-progress-turn-limit' } };
|
|
35
|
+
if (c.provider === 'devin') return { ok: true, text: '```json\n{"status":"completed","summary":"done","acceptance":[{"id":"A1","met":true,"evidence":"verified"}]}\n```', child: { toolCalls: 1, turns: 2 } };
|
|
36
|
+
throw new Error(`unexpected invocation: ${c.modelId}`);
|
|
37
|
+
},
|
|
38
|
+
});
|
|
39
|
+
const result = await orchestrate({ request: 'fixture', plan: [task], agents, routing, registry, policy, runner, health });
|
|
40
|
+
const t = result.tasks[0];
|
|
41
|
+
assert.equal(result.status, 'completed');
|
|
42
|
+
assert.equal(t.status, 'completed');
|
|
43
|
+
assert.equal(t.totalModelAttempts, 3, JSON.stringify({ calls, trace: result.trace.filter(e => e.type === 'result' || e.type === 'retry' || e.type === 'fallback'), task: t }, null, 2));
|
|
44
|
+
assert.deepEqual(calls.map(id => id.split('/')[0]), ['qoder', 'freetoken', 'devin']);
|
|
45
|
+
assert.ok(t.taskGlobalFailedModels.some(id => id.startsWith('qoder/')));
|
|
46
|
+
assert.ok(t.taskGlobalFailedModels.some(id => id.startsWith('freetoken/')));
|
|
47
|
+
assert.deepEqual(t.capabilityLocalTriedModels, []);
|
|
48
|
+
const strong = result.trace.find(e => e.type === 'result' && e.capability === 'strong-code');
|
|
49
|
+
assert.equal(strong.counters.invocationsStarted, 1);
|
|
50
|
+
assert.equal(strong.counters.candidatesSkipped, 2);
|
|
51
|
+
assert.ok(strong.steps.some(s => s.reason === 'task-global-failed' && s.modelId.startsWith('qoder/')));
|
|
52
|
+
assert.ok(strong.steps.some(s => s.reason === 'task-global-failed' && s.modelId.startsWith('freetoken/')));
|
|
53
|
+
assert.ok(strong.steps.some(s => s.reason === 'quota-0' && s.modelId.startsWith('codex/')) === false, 'health candidate after success is not reached');
|
|
54
|
+
const failedChild = result.trace.find(e => e.type === 'child' && e.modelId.startsWith('freetoken/'));
|
|
55
|
+
assert.equal(failedChild.capability, 'cheap-code');
|
|
56
|
+
assert.equal(failedChild.provider, 'freetoken');
|
|
57
|
+
assert.equal(failedChild.toolCalls, 0);
|
|
58
|
+
assert.equal(failedChild.turns, 12);
|
|
59
|
+
assert.equal(failedChild.failureClass, 'NO_PROGRESS_TIMEOUT');
|
|
60
|
+
assert.equal(typeof failedChild.durationMs, 'number');
|
|
61
|
+
assert.match(formatReport(result), /devin\/SWE-2 on strong-code → completed/);
|
|
62
|
+
assert.match(formatReport(result), /task-global-failed skip/);
|
|
63
|
+
});
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
// Two-layer tried history: a model that protocol-fails is skipped for the WHOLE
|
|
2
|
+
// task across capabilities, while the capability-local candidate list re-resolves.
|
|
3
|
+
// Regression for run-muf8ula6-8509b1 (cheap-code Qwen re-invoked on strong-code).
|
|
4
|
+
import test from 'node:test';
|
|
5
|
+
import assert from 'node:assert/strict';
|
|
6
|
+
import { resolve, dirname, join } from 'node:path';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
|
+
import { loadRouting } from '../lib/routing.mjs';
|
|
9
|
+
import { loadAgents } from '../lib/agents.mjs';
|
|
10
|
+
import { DEFAULT_POLICY, mergePolicy } from '../lib/orchestrator/policy.mjs';
|
|
11
|
+
import { createAgentRunner } from '../lib/orchestrator/runner.mjs';
|
|
12
|
+
import { orchestrate } from '../lib/orchestrator/orchestrator.mjs';
|
|
13
|
+
import { shouldMarkTaskGlobalFailure } from '../lib/orchestrator/failures.mjs';
|
|
14
|
+
import { withEscalation } from '../lib/pipeline.mjs';
|
|
15
|
+
import { createScriptedInvoker } from '../adapters/pi/lib/invoke.mjs';
|
|
16
|
+
|
|
17
|
+
const kit = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
18
|
+
const routing = loadRouting(join(kit, 'routing/routing.json'));
|
|
19
|
+
const { agents } = loadAgents(join(kit, 'agents'), routing);
|
|
20
|
+
// cheap-code: pc/mc -> pl/ml -> ps/ms ; strong-code: devin(pd/md) -> pc/mc -> ps/ms -> px/mx -> pl/ml
|
|
21
|
+
const REG = { version: 1, backends: {
|
|
22
|
+
local: { provider: 'pl', model: 'ml', thinking: 'off' },
|
|
23
|
+
cheap: { provider: 'pc', model: 'mc', thinking: 'low' },
|
|
24
|
+
sol: { provider: 'ps', model: 'ms', thinking: 'high' },
|
|
25
|
+
codex: { provider: 'px', model: 'mx', thinking: 'high' },
|
|
26
|
+
devin: { provider: 'pd', model: 'md', thinking: 'high' },
|
|
27
|
+
} };
|
|
28
|
+
const policyWith = over => mergePolicy(DEFAULT_POLICY, over ?? {});
|
|
29
|
+
const jsonReply = obj => `notes\n\n\`\`\`json\n${JSON.stringify(obj)}\n\`\`\``;
|
|
30
|
+
const okReply = jsonReply({ status: 'completed', summary: 'done', acceptance: [{ id: 'A1', met: true, evidence: 'e' }] });
|
|
31
|
+
const spec = (id, agent, dependencies = [], extra = {}) => ({ id, title: `task ${id}`, goal: `goal ${id}`, agent, dependencies, acceptance: ['done'], ...extra });
|
|
32
|
+
const healthSkip = (skipIds) => ({ skip: c => (skipIds.includes(c.modelId) ? 'usage_exhausted' : null), report() {} });
|
|
33
|
+
|
|
34
|
+
async function runScenario(script, { policy: over = {}, health = null } = {}) {
|
|
35
|
+
const policy = policyWith(over);
|
|
36
|
+
const calls = [];
|
|
37
|
+
const invoke = createScriptedInvoker(script, calls);
|
|
38
|
+
const runner = createAgentRunner({ invoke, agents, routing, registry: REG, policy, health });
|
|
39
|
+
const r = await orchestrate({ request: 'r', plan: [spec('a', 'scout')], agents, routing, registry: REG, policy, runner });
|
|
40
|
+
return { r, calls };
|
|
41
|
+
}
|
|
42
|
+
const count = (calls, prefix) => calls.filter(c => c.modelId.startsWith(prefix)).length;
|
|
43
|
+
|
|
44
|
+
// A: cheap-code Qwen malformed -> added to taskGlobalFailedModels.
|
|
45
|
+
test('A: protocol failure records the model in taskGlobalFailedModels', async () => {
|
|
46
|
+
const { r } = await runScenario({ '*': req => req.modelId.startsWith('pc/') ? 'Done' : okReply });
|
|
47
|
+
const t = r.tasks[0];
|
|
48
|
+
// pc/mc failed malformed on attempt 1; task should record it globally.
|
|
49
|
+
assert.ok((t.taskGlobalFailedModels ?? []).includes('pc/mc:low'), `got ${JSON.stringify(t.taskGlobalFailedModels)}`);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
// B: strong-code escalation -> Qwen present in candidate list but skipped task-global.
|
|
53
|
+
test('B: after escalation a task-global-failed model is skipped, not re-invoked', async () => {
|
|
54
|
+
// pc/mc (cheap primary) malformed; on strong-code pc/mc is also a candidate.
|
|
55
|
+
// devin (pd/md) must run first; pc/mc must NOT be re-invoked.
|
|
56
|
+
const { r, calls } = await runScenario({ '*': req => req.modelId.startsWith('pd/') ? okReply : 'Done' },
|
|
57
|
+
{ policy: { limits: { max_retries: 4, model_attempts_per_task: 4, max_total_attempts_per_task: 8 } } });
|
|
58
|
+
assert.equal(r.status, 'completed');
|
|
59
|
+
assert.equal(count(calls, 'pc/'), 1, `pc/mc invoked ${count(calls,'pc/')}x — should be exactly once (cheap only)`);
|
|
60
|
+
assert.ok(count(calls, 'pd/') >= 1, 'devin invoked on strong-code');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
// C: no-progress turn-limit TIMEOUT on FreeToken -> not re-invoked on strong-code.
|
|
64
|
+
test('C: no-progress turn-limit marks task-global; not re-invoked after escalation', async () => {
|
|
65
|
+
assert.equal(shouldMarkTaskGlobalFailure('TIMEOUT', { toolCalls: 0 }), false, 'missing progress evidence must not be guessed');
|
|
66
|
+
assert.equal(shouldMarkTaskGlobalFailure('TIMEOUT', { toolCalls: 0, turnLimit: true, structuredProgress: false, hasFinalOutput: false }), true);
|
|
67
|
+
const calls = [];
|
|
68
|
+
const policy = policyWith({ limits: { max_retries: 4, model_attempts_per_task: 4, max_total_attempts_per_task: 8 } });
|
|
69
|
+
const runner = createAgentRunner({ invoke: async () => { throw new Error('oneshot used'); }, runSubagent: async req => {
|
|
70
|
+
calls.push({ modelId: req.modelId });
|
|
71
|
+
return req.modelId.startsWith('pd/') ? { ok: true, text: okReply, child: { toolCalls: 1, turns: 1 } } :
|
|
72
|
+
{ ok: false, error: 'turn limit 12', failureClass: 'NO_PROGRESS_TIMEOUT', telemetry: { text: '', toolCalls: 0, turns: 12 }, child: { toolCalls: 0, turns: 12, stopReason: 'no-progress-turn-limit' } };
|
|
73
|
+
}, agents, routing, registry: REG, policy });
|
|
74
|
+
const r = await orchestrate({ request: 'r', plan: [spec('a', 'scout')], agents, routing, registry: REG, policy, runner });
|
|
75
|
+
// pl/ml (freetoken-analog) failed once on cheap; must not re-run on strong.
|
|
76
|
+
assert.ok(count(calls, 'pl/') <= 1, `pl/ml invoked ${count(calls,'pl/')}x`);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
// D: transient tool failure does NOT mark task-global; same model may retry.
|
|
80
|
+
test('D: transient tool failure stays eligible after a capability change', async () => {
|
|
81
|
+
assert.equal(shouldMarkTaskGlobalFailure('TOOL_FAILURE'), false);
|
|
82
|
+
assert.equal(shouldMarkTaskGlobalFailure('TEST_FAILURE'), false);
|
|
83
|
+
const called = [];
|
|
84
|
+
const localRouting = structuredClone(routing);
|
|
85
|
+
localRouting.capabilities['strong-code'].fallback = ['cheap'];
|
|
86
|
+
const result = await withEscalation({ routing: localRouting, registry: REG, capability: 'strong-code', agent: 'scout', pack: {}, trace: [], maxAttempts: 2,
|
|
87
|
+
excludeModels: [], taskGlobalFailedModels: [], fn: async c => {
|
|
88
|
+
called.push(c.modelId);
|
|
89
|
+
return { ok: c.modelId.startsWith('pc/'), reason: 'recoverable tool failure on cheap-code must not exclude this model' };
|
|
90
|
+
},
|
|
91
|
+
});
|
|
92
|
+
assert.equal(result.ok, true);
|
|
93
|
+
assert.deepEqual(called.map(id => id.split('/')[0]), ['pd', 'pc']);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
// E: task-global skip consumes zero attempt budget.
|
|
97
|
+
test('E: task-global-failed skip does not consume invocationsStarted', async () => {
|
|
98
|
+
const { r, calls } = await runScenario({ '*': req => req.modelId.startsWith('pd/') ? okReply : 'Done' },
|
|
99
|
+
{ policy: { limits: { max_retries: 4, model_attempts_per_task: 4, max_total_attempts_per_task: 8 } } });
|
|
100
|
+
assert.equal(r.status, 'completed');
|
|
101
|
+
// pc/mc invoked once on cheap; on strong-code it is skipped (not invoked again).
|
|
102
|
+
assert.equal(count(calls, 'pc/'), 1);
|
|
103
|
+
// total real invocations = cheap pc/mc + pl/ml + ps/ms + devin (bounded)
|
|
104
|
+
assert.ok(calls.length <= 5, `invocations ${calls.length}`);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
// F: Qwen + FreeToken failed on cheap -> strong-code Devin is invoked first.
|
|
108
|
+
test('F: cheap failures -> strong-code Devin is the first invoked candidate', async () => {
|
|
109
|
+
const { r, calls } = await runScenario({ '*': req => req.modelId.startsWith('pd/') ? okReply : 'Done' },
|
|
110
|
+
{ policy: { limits: { max_retries: 4, model_attempts_per_task: 4, max_total_attempts_per_task: 8 } } });
|
|
111
|
+
assert.equal(r.status, 'completed');
|
|
112
|
+
const strongIdx = calls.findIndex(c => c.modelId.startsWith('pd/'));
|
|
113
|
+
assert.ok(strongIdx >= 0, 'devin invoked');
|
|
114
|
+
// After escalation, devin should be invoked before any repeated cheap model.
|
|
115
|
+
const afterEscalation = calls.slice(strongIdx);
|
|
116
|
+
assert.ok(afterEscalation[0].modelId.startsWith('pd/'), 'devin first on strong-code');
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
// G: protocol failure still leaves modelId in the audit/child record.
|
|
120
|
+
test('G: protocol failure keeps modelId in the result/child audit', async () => {
|
|
121
|
+
const { r } = await runScenario({ '*': req => req.modelId.startsWith('pc/') ? 'Done' : okReply });
|
|
122
|
+
const resultEvent = r.trace.find(e => e.type === 'result');
|
|
123
|
+
// invokedModels records which models were actually called this attempt.
|
|
124
|
+
assert.ok((resultEvent.invokedModels ?? []).includes('pc/mc:low'));
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
// H: autoDecisions/report show the strong-code progression (capability+model+failure).
|
|
128
|
+
test('H: auto decision reports capability + modelId + failureClass', async () => {
|
|
129
|
+
const { r } = await runScenario({ '*': req => req.modelId.startsWith('pd/') ? okReply : 'Done' },
|
|
130
|
+
{ policy: { limits: { max_retries: 4, model_attempts_per_task: 4, max_total_attempts_per_task: 8 } } });
|
|
131
|
+
const decisions = r.autoDecisions.map(d => d.choice).join(' | ');
|
|
132
|
+
assert.match(decisions, /on cheap-code|escalate to strong-code/, decisions);
|
|
133
|
+
assert.match(decisions, /MALFORMED_RESULT|TIMEOUT|→/, decisions);
|
|
134
|
+
});
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
// Phase 6: shadow mode, telemetry aggregation, calibration warnings, counterfactuals,
|
|
2
|
+
// policy calibration proposal, retention. Cases A–L. Temp dirs only.
|
|
3
|
+
import test from 'node:test';
|
|
4
|
+
import assert from 'node:assert/strict';
|
|
5
|
+
import { mkdtempSync, rmSync, writeFileSync, readFileSync, existsSync } from 'node:fs';
|
|
6
|
+
import { tmpdir } from 'node:os';
|
|
7
|
+
import { resolve, dirname, join } from 'node:path';
|
|
8
|
+
import { fileURLToPath } from 'node:url';
|
|
9
|
+
import {
|
|
10
|
+
emptyTelemetry, recordRun, persistRun, loadTelemetry, calibrationWarnings,
|
|
11
|
+
counterfactuals, calibrationProposal, compactTelemetry, telemetryPaths,
|
|
12
|
+
} from '../lib/telemetry.mjs';
|
|
13
|
+
import { runMaintenanceJob } from '../lib/job.mjs';
|
|
14
|
+
import { DEFAULT_POLICY } from '../lib/maintenance-exec.mjs';
|
|
15
|
+
import { loadRouting } from '../lib/routing.mjs';
|
|
16
|
+
import { loadAgents } from '../lib/agents.mjs';
|
|
17
|
+
import { mergeRegistries } from '../lib/registry.mjs';
|
|
18
|
+
|
|
19
|
+
const kit = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
20
|
+
const routing = loadRouting(join(kit, 'routing/routing.json'));
|
|
21
|
+
const { agents } = loadAgents(join(kit, 'agents'), routing);
|
|
22
|
+
const tmp = () => mkdtempSync(join(tmpdir(), 'tel-'));
|
|
23
|
+
|
|
24
|
+
const CAT = { version: 1, models: [
|
|
25
|
+
{ provider: 'cloudp', model: 'cheap-ok', status: 'active', cost: { usdPerMInput: 0.2, usdPerMOutput: 0.8 }, contextK: 256, toolUse: 'good', location: 'cloud', scores: { coding: 68, reasoning: 62, speed: 75 } },
|
|
26
|
+
{ provider: 'cloudp', model: 'vis-ok', status: 'active', cost: { usdPerMInput: 2, usdPerMOutput: 8 }, contextK: 400, vision: true, toolUse: 'good', location: 'cloud', scores: { coding: 80, reasoning: 82, speed: 55 } },
|
|
27
|
+
] };
|
|
28
|
+
const REG = mergeRegistries(null, { version: 1, backends: { cheap: { provider: 'cloudp', model: 'cheap-ok' }, astra: { provider: 'cloudp', model: 'vis-ok' } } });
|
|
29
|
+
const base = dir => ({ outDir: dir, adapterDir: '.', kit: '.', catalog: CAT, routing, registry: REG, agents, policy: DEFAULT_POLICY });
|
|
30
|
+
const manualObs = (dir, observations) => { const p = join(dir, 'o.json'); writeFileSync(p, JSON.stringify({ version: 1, observations })); return p; };
|
|
31
|
+
const OBS = (p, m, changes, over = {}) => ({ provider: p, model: m, observedAt: '2026-03-05T00:00:00Z', source: { type: 'manual', trust: 'manual_verified' }, changes, confidence: 0.9, ...over });
|
|
32
|
+
|
|
33
|
+
// --- A: shadow mode -> no external notification ------------------------------
|
|
34
|
+
test('A: shadow mode suppresses the external notify-command', async () => {
|
|
35
|
+
const dir = tmp();
|
|
36
|
+
const input = manualObs(dir, [OBS('newp', 'Nova-1', { status: 'active' })]);
|
|
37
|
+
let cmdRan = false;
|
|
38
|
+
const run = await runMaintenanceJob({ ...base(dir), source: 'manual', input, shadow: true, notifyCommand: `node -e "require('fs').writeFileSync('${join(dir, 'cmd-ran').replace(/\\/g, '\\\\')}','1')"` });
|
|
39
|
+
assert.equal(run.shadow, true);
|
|
40
|
+
assert.equal(run.notification.sent, true); // decision still made + file sink
|
|
41
|
+
assert.ok(run.notification.shadowSuppressed);
|
|
42
|
+
assert.equal(existsSync(join(dir, 'cmd-ran')), false);
|
|
43
|
+
assert.ok(existsSync(join(dir, 'model-maintenance.notification.json'))); // file sink still wrote
|
|
44
|
+
rmSync(dir, { recursive: true, force: true });
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('A2: --shadow --shadow-notify DOES run the command', async () => {
|
|
48
|
+
const dir = tmp();
|
|
49
|
+
const input = manualObs(dir, [OBS('newp', 'Nova-1', { status: 'active' })]);
|
|
50
|
+
const marker = join(dir, 'cmd-ran').replace(/\\/g, '\\\\');
|
|
51
|
+
const run = await runMaintenanceJob({ ...base(dir), source: 'manual', input, shadow: true, shadowNotify: true, notifyCommand: `node -e "require('fs').writeFileSync('${marker}','1')"` });
|
|
52
|
+
assert.equal(existsSync(join(dir, 'cmd-ran')), true);
|
|
53
|
+
rmSync(dir, { recursive: true, force: true });
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
// --- B: telemetry aggregation -------------------------------------------------
|
|
57
|
+
test('B: telemetry aggregates runs, costs, severities, decisions', async () => {
|
|
58
|
+
const dir = tmp();
|
|
59
|
+
const input = manualObs(dir, [OBS('newp', 'Nova-1', { status: 'active' })]);
|
|
60
|
+
await runMaintenanceJob({ ...base(dir), source: 'manual', input });
|
|
61
|
+
await runMaintenanceJob(base(dir)); // second run -> deduped notification
|
|
62
|
+
const t = loadTelemetry(dir);
|
|
63
|
+
assert.equal(t.runs, 2);
|
|
64
|
+
assert.equal(t.meaningfulRuns >= 1, true);
|
|
65
|
+
assert.ok(t.notifications >= 1);
|
|
66
|
+
assert.ok(t.dedupedNotifications >= 1);
|
|
67
|
+
assert.ok(t.cost.totalUsd > 0);
|
|
68
|
+
assert.ok(t.decisions.length >= 1);
|
|
69
|
+
assert.ok(t.decisions[0].model);
|
|
70
|
+
rmSync(dir, { recursive: true, force: true });
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
// --- C: mostly quiet -> no noise warning --------------------------------------
|
|
74
|
+
test('C: quiet-dominated history produces no too-noisy warning', () => {
|
|
75
|
+
const t = emptyTelemetry();
|
|
76
|
+
for (let i = 0; i < 20; i++) recordRun(t, { runId: `r${i}`, quiet: true, completedAt: '2026-03-01' });
|
|
77
|
+
recordRun(t, { runId: 'm1', quiet: false, completedAt: '2026-03-02', notification: { severity: 'info', sent: true } });
|
|
78
|
+
const w = calibrationWarnings(t, DEFAULT_POLICY);
|
|
79
|
+
assert.equal(w.filter(x => x.kind === 'too-noisy').length, 0);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
// --- D: notification-heavy -> noise warning ------------------------------------
|
|
83
|
+
test('D: notification-per-meaningful > 0.8 -> too-noisy warning', () => {
|
|
84
|
+
const t = emptyTelemetry();
|
|
85
|
+
for (let i = 0; i < 5; i++) recordRun(t, { runId: `m${i}`, quiet: false, completedAt: '2026-03-01', notification: { severity: 'info', sent: true } });
|
|
86
|
+
const w = calibrationWarnings(t, DEFAULT_POLICY);
|
|
87
|
+
assert.ok(w.some(x => x.kind === 'too-noisy'));
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
// --- E: premium escalation heavy -> warning ------------------------------------
|
|
91
|
+
test('E: frequent reconfigure escalations -> too-many-premium-escalations', () => {
|
|
92
|
+
const t = emptyTelemetry();
|
|
93
|
+
for (let i = 0; i < 6; i++) recordRun(t, { runId: `r${i}`, quiet: false, completedAt: '2026-03-01', escalation: { escalationReason: 'structural', targetTier: 'reconfigure' } });
|
|
94
|
+
const w = calibrationWarnings(t, DEFAULT_POLICY);
|
|
95
|
+
assert.ok(w.some(x => x.kind === 'too-many-premium-escalations'));
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
// --- F: fallback heavy -> warning ----------------------------------------------
|
|
99
|
+
test('F: frequent fallbacks -> too-many-fallbacks warning', () => {
|
|
100
|
+
const t = emptyTelemetry();
|
|
101
|
+
for (let i = 0; i < 6; i++) recordRun(t, { runId: `r${i}`, quiet: false, completedAt: '2026-03-01', tiers: [{ role: 'monitor', fallbackOccurred: true, selected: { model: 'p/m', location: 'cloud', quality: 70, effectiveCostUsd: 0.001 }, requiredQuality: 40, candidates: [] }] });
|
|
102
|
+
const w = calibrationWarnings(t, DEFAULT_POLICY);
|
|
103
|
+
assert.ok(w.some(x => x.kind === 'too-many-fallbacks'));
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
// --- G: small quality margins -> warning ---------------------------------------
|
|
107
|
+
test('G: chronically small quality margins -> quality-margin-too-small', () => {
|
|
108
|
+
const t = emptyTelemetry();
|
|
109
|
+
for (let i = 0; i < 6; i++) recordRun(t, { runId: `r${i}`, quiet: false, completedAt: '2026-03-01', tiers: [{ role: 'evaluate', selected: { model: 'p/m', location: 'cloud', quality: 66, effectiveCostUsd: 0.001 }, requiredQuality: 65, candidates: [] }] });
|
|
110
|
+
const w = calibrationWarnings(t, DEFAULT_POLICY);
|
|
111
|
+
assert.ok(w.some(x => x.kind === 'quality-margin-too-small'));
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
// --- H: below minimum data -> insufficient-observation-data --------------------
|
|
115
|
+
test('H: too few runs -> calibration proposal withheld', () => {
|
|
116
|
+
const t = emptyTelemetry();
|
|
117
|
+
for (let i = 0; i < 3; i++) recordRun(t, { runId: `r${i}`, quiet: true, completedAt: '2026-03-01' });
|
|
118
|
+
const p = calibrationProposal(t, DEFAULT_POLICY);
|
|
119
|
+
assert.equal(p.status, 'insufficient-observation-data');
|
|
120
|
+
assert.equal(p.proposals.length, 0);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
// --- I: enough data -> calibration proposals ------------------------------------
|
|
124
|
+
test('I: sufficient history -> policy calibration proposal emitted', () => {
|
|
125
|
+
const t = emptyTelemetry();
|
|
126
|
+
for (let i = 0; i < 25; i++) recordRun(t, { runId: `r${i}`, quiet: i < 20, completedAt: '2026-03-01', notification: i < 20 ? null : { severity: 'info', sent: true } });
|
|
127
|
+
const p = calibrationProposal(t, DEFAULT_POLICY);
|
|
128
|
+
assert.equal(p.status, 'ok');
|
|
129
|
+
assert.ok(p.proposals.length >= 1);
|
|
130
|
+
assert.ok(p.proposals.every(x => 'currentValue' in x && 'proposedValue' in x && 'evidence' in x && 'expectedEffect' in x && 'confidence' in x));
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
// --- J: counterfactual comparison ----------------------------------------------
|
|
134
|
+
test('J: counterfactuals compare selected vs alternatives on cost/quality', () => {
|
|
135
|
+
const t = emptyTelemetry();
|
|
136
|
+
recordRun(t, { runId: 'r1', quiet: false, completedAt: '2026-03-01', tiers: [{ role: 'monitor', selected: { model: 'p/mid', location: 'cloud', quality: 70, effectiveCostUsd: 0.002 }, requiredQuality: 40, ordered: [{ model: 'p/mid' }, { model: 'p/cheap', effectiveCostUsd: 0.001, quality: 65 }, { model: 'p/pro', effectiveCostUsd: 0.01, quality: 90 }], candidates: [] }] });
|
|
137
|
+
const cf = counterfactuals(t);
|
|
138
|
+
assert.equal(cf.length, 1);
|
|
139
|
+
const alts = cf[0].counterfactual;
|
|
140
|
+
assert.equal(alts.find(a => a.model === 'p/cheap').verdict, 'cheaper-lower-quality');
|
|
141
|
+
assert.equal(alts.find(a => a.model === 'p/pro').verdict, 'better-more-expensive');
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
// --- K: retention compaction preserves totals -----------------------------------
|
|
145
|
+
test('K: compaction folds old runs but keeps summary + recent data', async () => {
|
|
146
|
+
const dir = tmp();
|
|
147
|
+
const { runs } = telemetryPaths(dir);
|
|
148
|
+
const old = { runId: 'old', quiet: true, completedAt: new Date(Date.now() - 40 * 86400e3).toISOString() };
|
|
149
|
+
const recent = { runId: 'new', quiet: true, completedAt: new Date().toISOString() };
|
|
150
|
+
writeFileSync(runs, JSON.stringify(old) + '\n' + JSON.stringify(recent) + '\n');
|
|
151
|
+
const r = compactTelemetry(dir, { days: 30 });
|
|
152
|
+
assert.equal(r.compacted, 1);
|
|
153
|
+
const lines = readFileSync(runs, 'utf8').split(/\r?\n/).filter(Boolean);
|
|
154
|
+
assert.ok(lines.some(l => l.includes('_compactedSummary')));
|
|
155
|
+
assert.ok(lines.some(l => l.includes('"new"')));
|
|
156
|
+
assert.ok(!lines.some(l => l.includes('"old"') && !l.includes('_compactedSummary')));
|
|
157
|
+
rmSync(dir, { recursive: true, force: true });
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
// --- L: calibration proposal does not touch real policy -------------------------
|
|
161
|
+
test('L: writing a calibration proposal leaves the real policy file unchanged', async () => {
|
|
162
|
+
const dir = tmp();
|
|
163
|
+
const policyPath = join(dir, 'maintenance-policy.json');
|
|
164
|
+
writeFileSync(policyPath, JSON.stringify({ version: 1, requiredQuality: { evaluate: 65 } }));
|
|
165
|
+
const before = readFileSync(policyPath, 'utf8');
|
|
166
|
+
const t = emptyTelemetry();
|
|
167
|
+
for (let i = 0; i < 25; i++) recordRun(t, { runId: `r${i}`, quiet: i < 20, completedAt: '2026-03-01', notification: i < 20 ? null : { severity: 'info', sent: true } });
|
|
168
|
+
const p = calibrationProposal(t, DEFAULT_POLICY);
|
|
169
|
+
writeFileSync(join(dir, 'maintenance-policy.calibration.proposal.json'), JSON.stringify(p));
|
|
170
|
+
assert.equal(readFileSync(policyPath, 'utf8'), before);
|
|
171
|
+
assert.equal(p.status, 'ok');
|
|
172
|
+
rmSync(dir, { recursive: true, force: true });
|
|
173
|
+
});
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
#requires -Version 7.0
|
|
2
|
+
# Exercises adapters/pi/sync-pi.ps1 against an isolated temporary agent dir.
|
|
3
|
+
# Dry-run must write nothing to the target; -Apply must create only the planned entries and never touch settings/auth.
|
|
4
|
+
Set-StrictMode -Version Latest
|
|
5
|
+
$ErrorActionPreference = 'Stop'
|
|
6
|
+
$kit = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..'))
|
|
7
|
+
$root = Join-Path ([IO.Path]::GetTempPath()) ('ludi-kit-sync-' + [guid]::NewGuid().ToString('N').Substring(0, 8))
|
|
8
|
+
$agent = Join-Path $root 'agent'
|
|
9
|
+
New-Item -ItemType Directory -Path $agent | Out-Null
|
|
10
|
+
$settings = '{"defaultProvider":"fixture","defaultModel":"fixture-model","defaultTools":["read"]}'
|
|
11
|
+
[IO.File]::WriteAllText((Join-Path $agent 'settings.json'), $settings)
|
|
12
|
+
[IO.File]::WriteAllText((Join-Path $agent 'auth.json'), '{"fixture":"not-a-real-secret"}')
|
|
13
|
+
[IO.File]::WriteAllText((Join-Path $agent 'AGENTS.md'), '# user instructions')
|
|
14
|
+
$sync = Join-Path $kit 'adapters/pi/sync-pi.ps1'
|
|
15
|
+
|
|
16
|
+
# 1. dry-run: nothing changes in target
|
|
17
|
+
$before = Get-ChildItem $agent -Recurse -Force | Select-Object -ExpandProperty FullName | Sort-Object
|
|
18
|
+
& pwsh -NoProfile -File $sync -AgentDir $agent | Out-Null
|
|
19
|
+
if ($LASTEXITCODE -ne 0) { throw 'dry-run failed' }
|
|
20
|
+
$after = Get-ChildItem $agent -Recurse -Force | Select-Object -ExpandProperty FullName | Sort-Object
|
|
21
|
+
if (($before -join '|') -cne ($after -join '|')) { throw 'dry-run modified the agent directory' }
|
|
22
|
+
$plan = Get-Content (Join-Path $kit 'adapters/pi/out/plan.json') -Raw | ConvertFrom-Json
|
|
23
|
+
if ($plan.mode -ne 'dry-run') { throw 'plan mode should be dry-run' }
|
|
24
|
+
if (-not ($plan.plan | Where-Object { $_.entry -eq 'AGENTS.md' -and $_.state -eq 'conflict-existing' })) { throw 'existing AGENTS.md should be reported as conflict' }
|
|
25
|
+
if (-not ($plan.plan | Where-Object { $_.entry -eq 'skills/visual-verification' -and $_.state -eq 'create' })) { throw 'skill link should be planned' }
|
|
26
|
+
if (-not (Test-Path (Join-Path $kit 'adapters/pi/out/AGENTS.md'))) { throw 'generated AGENTS.md missing' }
|
|
27
|
+
|
|
28
|
+
# 2. apply without -BackupConflicts must refuse and change nothing
|
|
29
|
+
$failed = $false
|
|
30
|
+
try { & pwsh -NoProfile -File $sync -AgentDir $agent -Apply 2>$null | Out-Null; if ($LASTEXITCODE -ne 0) { $failed = $true } } catch { $failed = $true }
|
|
31
|
+
if (-not $failed) { throw 'apply with conflicts should fail' }
|
|
32
|
+
if ((Get-Content (Join-Path $agent 'AGENTS.md') -Raw) -cne '# user instructions') { throw 'conflicting AGENTS.md was modified' }
|
|
33
|
+
|
|
34
|
+
# 3. apply with -BackupConflicts: junctions + AGENTS, backup retained, settings/auth untouched
|
|
35
|
+
& pwsh -NoProfile -File $sync -AgentDir $agent -Apply -BackupConflicts | Out-Null
|
|
36
|
+
if ($LASTEXITCODE -ne 0) { throw 'apply failed' }
|
|
37
|
+
foreach ($rel in 'skills/visual-verification', 'skills/project-management', 'skills/pi-workflow', 'agents/ludi-agent-kit', 'extensions/ludi-agent-kit', 'extensions/ludi-orchestrator') {
|
|
38
|
+
$item = Get-Item -LiteralPath (Join-Path $agent $rel) -Force
|
|
39
|
+
if ($item.LinkType -ne 'Junction') { throw "$rel is not a Junction" }
|
|
40
|
+
}
|
|
41
|
+
if ((Get-Content (Join-Path $agent 'AGENTS.md') -Raw) -notmatch '^<!-- Generated by ludi-agent-kit') { throw 'AGENTS.md not generated' }
|
|
42
|
+
$backups = @(Get-ChildItem (Join-Path $agent 'ludi-agent-kit') -Directory -Filter 'backup-*')
|
|
43
|
+
if ($backups.Count -ne 1 -or -not (Test-Path (Join-Path $backups[0].FullName 'AGENTS.md'))) { throw 'AGENTS.md backup missing' }
|
|
44
|
+
if ([IO.File]::ReadAllText((Join-Path $agent 'settings.json')) -cne $settings) { throw 'settings.json modified' }
|
|
45
|
+
if ([IO.File]::ReadAllText((Join-Path $agent 'auth.json')) -cne '{"fixture":"not-a-real-secret"}') { throw 'auth.json modified' }
|
|
46
|
+
|
|
47
|
+
# 4. repeat apply is a no-op
|
|
48
|
+
& pwsh -NoProfile -File $sync -AgentDir $agent -Apply | Out-Null
|
|
49
|
+
if ($LASTEXITCODE -ne 0) { throw 'repeat apply failed' }
|
|
50
|
+
$plan = Get-Content (Join-Path $kit 'adapters/pi/out/plan.json') -Raw | ConvertFrom-Json
|
|
51
|
+
if ($plan.plan | Where-Object { $_.state -ne 'ok' }) { throw 'repeat apply should report all ok' }
|
|
52
|
+
|
|
53
|
+
# cleanup: remove junctions (links only) then fixture
|
|
54
|
+
foreach ($j in Get-ChildItem $agent -Recurse -Force -Attributes ReparsePoint) { $j.Delete() }
|
|
55
|
+
Remove-Item $root -Recurse -Force
|
|
56
|
+
Write-Output 'PASS: sync-pi dry-run writes nothing; apply is bounded, backed up and idempotent; settings/auth untouched'
|