@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,375 @@
|
|
|
1
|
+
// Persistent orchestration: resume, decisions, memory, backend health, crash recovery, idempotency.
|
|
2
|
+
import test from 'node:test';
|
|
3
|
+
import assert from 'node:assert/strict';
|
|
4
|
+
import { spawnSync } from 'node:child_process';
|
|
5
|
+
import { mkdtempSync } from 'node:fs';
|
|
6
|
+
import { tmpdir } from 'node:os';
|
|
7
|
+
import { join, resolve, dirname } from 'node:path';
|
|
8
|
+
import { fileURLToPath } from 'node:url';
|
|
9
|
+
import { loadRouting } from '../lib/routing.mjs';
|
|
10
|
+
import { loadAgents } from '../lib/agents.mjs';
|
|
11
|
+
import { withEscalation } from '../lib/pipeline.mjs';
|
|
12
|
+
import { DEFAULT_POLICY, mergePolicy } from '../lib/orchestrator/policy.mjs';
|
|
13
|
+
import { openStore } from '../lib/orchestrator/store.mjs';
|
|
14
|
+
import { newTask } from '../lib/orchestrator/task-store.mjs';
|
|
15
|
+
import { classifyBackendFailure, createHealthMonitor } from '../lib/orchestrator/health.mjs';
|
|
16
|
+
import { evaluateDecision } from '../lib/orchestrator/escalation.mjs';
|
|
17
|
+
import { orchestrate } from '../lib/orchestrator/orchestrator.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 REG = { version: 1, backends: {
|
|
23
|
+
local: { provider: 'pl', model: 'ml', thinking: 'off' },
|
|
24
|
+
cheap: { provider: 'pc', model: 'mc', thinking: 'low' },
|
|
25
|
+
sol: { provider: 'ps', model: 'ms', thinking: 'high' },
|
|
26
|
+
astra: { provider: 'pa', model: 'ma', thinking: 'medium', vision: true },
|
|
27
|
+
codex: { provider: 'px', model: 'mx', thinking: 'high' },
|
|
28
|
+
} };
|
|
29
|
+
const policyWith = over => mergePolicy(DEFAULT_POLICY, over ?? {});
|
|
30
|
+
const spec = (id, agent, dependencies = []) => ({ id, title: `task ${id}`, goal: `goal ${id}`, agent, dependencies, acceptance: ['done'] });
|
|
31
|
+
const completed = (task, extra = {}) => ({ ok: true, structured: true, modelId: 'fake', result: {
|
|
32
|
+
status: 'completed', summary: `did ${task.id}`, artifacts: [], verification: ['checked'],
|
|
33
|
+
acceptance: [{ id: 'A1', met: true, evidence: 'observed' }], remainingIssues: [], decisions: [], newTasks: [], ...extra } });
|
|
34
|
+
const blocked = decisions => ({ ok: true, structured: true, result: { status: 'blocked', summary: 'need a choice', artifacts: [], verification: [], acceptance: [], remainingIssues: [], decisions, newTasks: [] } });
|
|
35
|
+
const dbPath = () => join(mkdtempSync(join(tmpdir(), 'ludi-orch-')), 'state.db');
|
|
36
|
+
|
|
37
|
+
test('persistent run creation stores the run, tasks and trace', async () => {
|
|
38
|
+
const session = openStore(dbPath());
|
|
39
|
+
const r = await orchestrate({
|
|
40
|
+
request: 'Fix the failing average() test', plan: [spec('a', 'scout'), spec('b', 'coder', ['a'])],
|
|
41
|
+
agents, routing, registry: REG, policy: DEFAULT_POLICY, session,
|
|
42
|
+
runner: { async run(t) { return completed(t); } },
|
|
43
|
+
});
|
|
44
|
+
assert.equal(r.runStatus, 'completed');
|
|
45
|
+
assert.equal(r.tasks.length, 2);
|
|
46
|
+
assert.equal(session.getRun(r.runId).status, 'completed');
|
|
47
|
+
assert.equal(session.loadTasks(r.runId).length, 2);
|
|
48
|
+
const types = session.loadTrace(r.runId).map(e => e.type);
|
|
49
|
+
for (const kind of ['plan', 'routing', 'round', 'result']) assert.ok(types.includes(kind), kind);
|
|
50
|
+
session.close();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('round limit survives restart and a completed task is not rerun', async () => {
|
|
54
|
+
const path = dbPath();
|
|
55
|
+
const session = openStore(path);
|
|
56
|
+
const calls = [];
|
|
57
|
+
const first = await orchestrate({
|
|
58
|
+
request: 'r', plan: [spec('a', 'scout'), spec('b', 'coder', ['a'])],
|
|
59
|
+
agents, routing, registry: REG, policy: policyWith({ limits: { max_rounds: 1 } }), session,
|
|
60
|
+
runner: { async run(t) { calls.push(t.id); return completed(t); } },
|
|
61
|
+
});
|
|
62
|
+
assert.deepEqual(calls, ['a']);
|
|
63
|
+
assert.equal(first.tasks.find(t => t.id === 'a').status, 'completed');
|
|
64
|
+
session.close();
|
|
65
|
+
const again = openStore(path);
|
|
66
|
+
const calls2 = [];
|
|
67
|
+
const second = await orchestrate({
|
|
68
|
+
request: 'ignored', resumeRunId: first.runId, agents, routing, registry: REG, policy: policyWith({ limits: { max_rounds: 100 } }), session: again,
|
|
69
|
+
runner: { async run(t) { calls2.push(t.id); return completed(t); } },
|
|
70
|
+
});
|
|
71
|
+
assert.deepEqual(calls2, []);
|
|
72
|
+
assert.equal(second.rounds, 1);
|
|
73
|
+
assert.equal(second.tasks.find(t => t.id === 'a').attempts, 1);
|
|
74
|
+
again.close();
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test('waiting_for_user, answer, resume, and duplicate answer are idempotent', async () => {
|
|
78
|
+
const path = dbPath();
|
|
79
|
+
const session = openStore(path);
|
|
80
|
+
const ask = { key: 'lib', question: 'Library A or B?', options: [{ id: 'a', summary: 'A', risk: 'medium', cost: 'medium' }, { id: 'b', summary: 'B', risk: 'medium', cost: 'medium' }] };
|
|
81
|
+
const first = await orchestrate({
|
|
82
|
+
request: 'choose', plan: [spec('s', 'scout'), spec('a', 'coder', ['s']), spec('b', 'reviewer', ['a'])],
|
|
83
|
+
agents, routing, registry: REG, policy: DEFAULT_POLICY, session,
|
|
84
|
+
runner: { async run(t) { return t.id === 'a' && !(t.decisions ?? []).length ? blocked([ask]) : completed(t); } },
|
|
85
|
+
});
|
|
86
|
+
assert.equal(first.runStatus, 'waiting_for_user');
|
|
87
|
+
assert.equal(first.tasks.find(t => t.id === 's').status, 'completed');
|
|
88
|
+
assert.equal(first.tasks.find(t => t.id === 'a').status, 'waiting_for_user');
|
|
89
|
+
assert.equal(first.tasks.find(t => t.id === 'b').status, 'blocked');
|
|
90
|
+
const decisionId = first.escalations[0].id;
|
|
91
|
+
assert.equal(session.listDecisions(first.runId, 'pending').length, 1);
|
|
92
|
+
session.close();
|
|
93
|
+
|
|
94
|
+
const mid = openStore(path);
|
|
95
|
+
const once = mid.answerDecision({ runId: first.runId, decisionId, answer: 'a' });
|
|
96
|
+
const twice = mid.answerDecision({ runId: first.runId, decisionId, answer: 'a' });
|
|
97
|
+
const other = mid.answerDecision({ runId: first.runId, decisionId, answer: 'b' });
|
|
98
|
+
assert.equal(once.idempotent, false);
|
|
99
|
+
assert.equal(twice.idempotent, true);
|
|
100
|
+
assert.equal(twice.same, true);
|
|
101
|
+
assert.equal(other.same, false);
|
|
102
|
+
assert.equal(mid.getDecision(decisionId).status, 'answered');
|
|
103
|
+
assert.equal(mid.loadTasks(first.runId).find(t => t.id === 'a').decisions.length, 1);
|
|
104
|
+
const memory = mid.listMemory();
|
|
105
|
+
assert.equal(memory.length, 1);
|
|
106
|
+
assert.equal(memory[0].scope, 'repository');
|
|
107
|
+
assert.equal(memory[0].decision.optionId, 'a');
|
|
108
|
+
mid.close();
|
|
109
|
+
|
|
110
|
+
const next = openStore(path);
|
|
111
|
+
const calls = [];
|
|
112
|
+
const resumed = await orchestrate({
|
|
113
|
+
request: '', resumeRunId: first.runId, agents, routing, registry: REG, policy: DEFAULT_POLICY, session: next,
|
|
114
|
+
runner: { async run(t) { calls.push(t.id); return completed(t); } },
|
|
115
|
+
});
|
|
116
|
+
assert.equal(resumed.status, 'completed');
|
|
117
|
+
assert.deepEqual(calls, ['a', 'b']);
|
|
118
|
+
const callsAgain = [];
|
|
119
|
+
const again = await orchestrate({
|
|
120
|
+
request: '', resumeRunId: first.runId, answers: [{ decisionId, answer: 'a' }], agents, routing, registry: REG, policy: DEFAULT_POLICY, session: next,
|
|
121
|
+
runner: { async run(t) { callsAgain.push(t.id); return completed(t); } },
|
|
122
|
+
});
|
|
123
|
+
assert.equal(again.status, 'completed');
|
|
124
|
+
assert.deepEqual(callsAgain, []);
|
|
125
|
+
next.close();
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test('natural-language option answers are remembered; ambiguous answers remain pending', async () => {
|
|
129
|
+
const session = openStore(dbPath());
|
|
130
|
+
try {
|
|
131
|
+
const ask = { key: 'approval', question: 'Proceed or abort?', flags: ['destructive_action'], options: [{ id: 'proceed', summary: 'Proceed' }, { id: 'abort', summary: 'Abort' }] };
|
|
132
|
+
const first = await orchestrate({ request: 'r', plan: [spec('a', 'scout')], agents, routing, registry: REG, policy: DEFAULT_POLICY, session,
|
|
133
|
+
runner: { async run() { return blocked([ask]); } } });
|
|
134
|
+
const decisionId = first.escalations[0].id;
|
|
135
|
+
assert.throws(() => session.answerDecision({ runId: first.runId, decisionId, answer: 'maybe later' }), /select an option id/);
|
|
136
|
+
assert.equal(session.getDecision(decisionId).status, 'pending');
|
|
137
|
+
const answered = session.answerDecision({ runId: first.runId, decisionId, answer: 'yes, proceed with care' });
|
|
138
|
+
assert.equal(answered.optionId, 'proceed');
|
|
139
|
+
assert.equal(session.lookupMemory({ key: 'approval', scopeKey: 'default' })[0].decision.optionId, 'proceed');
|
|
140
|
+
} finally { session.close(); }
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test('decision memory is reused across runs; run-local decisions and hard gates win', async () => {
|
|
144
|
+
const path = dbPath();
|
|
145
|
+
const session = openStore(path);
|
|
146
|
+
const ask = { key: 'approach', question: 'Approach?', options: [{ id: 'a', summary: 'A', risk: 'medium', cost: 'medium' }, { id: 'b', summary: 'B', risk: 'medium', cost: 'medium' }] };
|
|
147
|
+
const first = await orchestrate({
|
|
148
|
+
request: 'one', plan: [spec('a', 'coder')], agents, routing, registry: REG, policy: DEFAULT_POLICY, session,
|
|
149
|
+
runner: { async run(t) { return t.decisions?.length ? completed(t) : blocked([ask]); } },
|
|
150
|
+
});
|
|
151
|
+
session.answerDecision({ runId: first.runId, decisionId: first.escalations[0].id, answer: 'b' });
|
|
152
|
+
await orchestrate({ request: '', resumeRunId: first.runId, agents, routing, registry: REG, policy: DEFAULT_POLICY, session, runner: { async run(t) { return completed(t); } } });
|
|
153
|
+
const calls = [];
|
|
154
|
+
const second = await orchestrate({
|
|
155
|
+
request: 'two', plan: [spec('a', 'coder')], agents, routing, registry: REG, policy: DEFAULT_POLICY, session,
|
|
156
|
+
runner: { async run(t) { calls.push((t.decisions ?? []).map(d => d.choice)); return t.decisions?.length ? completed(t) : blocked([ask]); } },
|
|
157
|
+
});
|
|
158
|
+
assert.equal(second.status, 'completed');
|
|
159
|
+
assert.equal(second.escalations.length, 0);
|
|
160
|
+
assert.ok(second.autoDecisions.some(d => d.step === 'memory' && d.choice.startsWith('b')));
|
|
161
|
+
assert.ok(second.trace.some(e => e.type === 'memory-lookup'));
|
|
162
|
+
assert.equal(calls.at(-1)[0].startsWith('b'), true);
|
|
163
|
+
|
|
164
|
+
const reversible = { key: 'tmp', question: 'Which name?', options: [{ id: 'x', summary: 'X', reversible: true }, { id: 'y', summary: 'Y', reversible: true }] };
|
|
165
|
+
await orchestrate({
|
|
166
|
+
request: 'auto', plan: [spec('a', 'coder')], agents, routing, registry: REG, policy: DEFAULT_POLICY, session,
|
|
167
|
+
runner: { async run(t) { return t.decisions?.length ? completed(t) : blocked([reversible]); } },
|
|
168
|
+
});
|
|
169
|
+
assert.equal(session.listMemory().filter(m => m.key === 'tmp').length, 0);
|
|
170
|
+
|
|
171
|
+
const local = evaluateDecision(ask, { policy: DEFAULT_POLICY, decisionLog: [{ key: 'approach', action: 'decide', optionId: 'a', step: 'policy' }], memory: [{ key: 'approach', scope: 'global', decision: { optionId: 'b' } }] });
|
|
172
|
+
assert.deepEqual([local.step, local.optionId], ['context', 'a']);
|
|
173
|
+
session.saveMemory({ scope: 'global', key: 'remove', decision: { optionId: 'yes' }, rationale: 'prior run' });
|
|
174
|
+
const hard = evaluateDecision({ key: 'remove', flags: ['destructive_action'], question: 'Remove the old records?', options: [{ id: 'yes' }, { id: 'no' }] }, { policy: DEFAULT_POLICY, memory: session.lookupMemory({ key: 'remove', scopeKey: 'default' }) });
|
|
175
|
+
assert.equal(hard.step, 'hard-gate');
|
|
176
|
+
const gated = await orchestrate({
|
|
177
|
+
request: 'danger', plan: [spec('a', 'coder')], agents, routing, registry: REG, policy: DEFAULT_POLICY, session,
|
|
178
|
+
runner: { async run() { return blocked([{ key: 'remove', flags: ['destructive_action'], question: 'Remove the old records?', options: [{ id: 'yes', summary: 'delete' }, { id: 'no', summary: 'keep' }] }]); } },
|
|
179
|
+
});
|
|
180
|
+
assert.equal(gated.runStatus, 'waiting_for_user');
|
|
181
|
+
session.close();
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test('backend usage exhaustion is skipped until its TTL, including the next run', async () => {
|
|
185
|
+
const session = openStore(dbPath());
|
|
186
|
+
const now = { at: '2026-09-23T00:00:00.000Z' };
|
|
187
|
+
const health = createHealthMonitor({ session, policy: DEFAULT_POLICY, now: () => now.at });
|
|
188
|
+
health.bindRun('run-a');
|
|
189
|
+
const calls = [];
|
|
190
|
+
const fn = async c => { calls.push(c.modelId); return c.provider === 'pc' ? { ok: false, reason: 'Codex error: The usage limit has been reached' } : { ok: true, reason: 'ok' }; };
|
|
191
|
+
const first = await withEscalation({ routing, registry: REG, capability: 'cheap-code', agent: 'scout', pack: {}, maxAttempts: 3, trace: [], skip: health.skip, onFailure: health.report, fn });
|
|
192
|
+
assert.equal(first.ok, true);
|
|
193
|
+
assert.deepEqual(calls, ['pc/mc:low', 'pl/ml:off']);
|
|
194
|
+
calls.length = 0;
|
|
195
|
+
health.bindRun('run-b');
|
|
196
|
+
await withEscalation({ routing, registry: REG, capability: 'cheap-code', agent: 'scout', pack: {}, maxAttempts: 3, trace: [], skip: health.skip, onFailure: health.report, fn: async c => { calls.push(c.modelId); return { ok: true, reason: 'ok' }; } });
|
|
197
|
+
assert.deepEqual(calls, ['pl/ml:off']);
|
|
198
|
+
now.at = '2026-09-23T07:00:00.000Z';
|
|
199
|
+
calls.length = 0;
|
|
200
|
+
await withEscalation({ routing, registry: REG, capability: 'cheap-code', agent: 'scout', pack: {}, maxAttempts: 1, trace: [], skip: health.skip, onFailure: health.report, fn: async c => { calls.push(c.modelId); return { ok: true, reason: 'ok' }; } });
|
|
201
|
+
assert.deepEqual(calls, ['pc/mc:low']);
|
|
202
|
+
assert.equal(classifyBackendFailure('usage limit has been reached'), 'usage_exhausted');
|
|
203
|
+
assert.equal(classifyBackendFailure('plain bug'), null);
|
|
204
|
+
session.close();
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
test('rate limit is scoped to the run that observed it', async () => {
|
|
208
|
+
const session = openStore(dbPath());
|
|
209
|
+
const health = createHealthMonitor({ session, policy: DEFAULT_POLICY, now: () => '2026-09-23T00:00:00.000Z' });
|
|
210
|
+
health.bindRun('run-a');
|
|
211
|
+
health.report({ provider: 'ps', model: 'ms' }, 'rate limit exceeded');
|
|
212
|
+
assert.match(health.skip({ provider: 'ps', model: 'ms' }), /rate_limited/);
|
|
213
|
+
health.bindRun('run-b');
|
|
214
|
+
assert.equal(health.skip({ provider: 'ps', model: 'ms' }), null);
|
|
215
|
+
session.close();
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test('stale running task is recovered to pending and keeps its attempt count', async () => {
|
|
219
|
+
const path = dbPath();
|
|
220
|
+
const session = openStore(path);
|
|
221
|
+
const runId = session.createRun({ request: 'r', policy: DEFAULT_POLICY });
|
|
222
|
+
const store = session.openTaskStore(runId);
|
|
223
|
+
store.add(newTask({ id: 't1', title: 't', goal: 'g', capability: 'cheap-code', assignedAgent: 'scout', acceptance: ['done'] }));
|
|
224
|
+
store.update('t1', { status: 'running', attempts: 1 });
|
|
225
|
+
session.close();
|
|
226
|
+
const next = openStore(path);
|
|
227
|
+
assert.equal(next.loadTasks(runId)[0].status, 'running');
|
|
228
|
+
assert.deepEqual(next.recoverStale(runId), ['t1']);
|
|
229
|
+
const task = next.loadTasks(runId)[0];
|
|
230
|
+
assert.equal(task.status, 'pending');
|
|
231
|
+
assert.equal(task.attempts, 1);
|
|
232
|
+
next.close();
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
test('retry count, task limit and trace survive a restart', async () => {
|
|
236
|
+
const path = dbPath();
|
|
237
|
+
const session = openStore(path);
|
|
238
|
+
const exhausted = await orchestrate({
|
|
239
|
+
request: 'r', plan: [spec('a', 'coder')], agents, routing, registry: REG, policy: policyWith({ limits: { max_retries: 1 } }), session,
|
|
240
|
+
runner: { async run() { return { ok: false, error: 'nope' }; } },
|
|
241
|
+
});
|
|
242
|
+
assert.equal(exhausted.tasks[0].status, 'failed');
|
|
243
|
+
assert.equal(exhausted.tasks[0].attempts, 2);
|
|
244
|
+
const traceBefore = session.loadTrace(exhausted.runId).length;
|
|
245
|
+
session.close();
|
|
246
|
+
const reopened = openStore(path);
|
|
247
|
+
const calls = [];
|
|
248
|
+
const after = await orchestrate({
|
|
249
|
+
request: '', resumeRunId: exhausted.runId, agents, routing, registry: REG, policy: DEFAULT_POLICY, session: reopened,
|
|
250
|
+
runner: { async run(t) { calls.push(t.id); return completed(t); } },
|
|
251
|
+
});
|
|
252
|
+
assert.deepEqual(calls, []);
|
|
253
|
+
assert.equal(after.tasks[0].attempts, 2);
|
|
254
|
+
assert.ok(reopened.loadTrace(exhausted.runId).length > traceBefore);
|
|
255
|
+
assert.ok(reopened.loadTrace(exhausted.runId).some(e => e.type === 'resume'));
|
|
256
|
+
|
|
257
|
+
const limited = await orchestrate({
|
|
258
|
+
request: 'cap', plan: [spec('a', 'scout')], agents, routing, registry: REG, policy: policyWith({ limits: { max_tasks: 1 } }), session: reopened,
|
|
259
|
+
runner: { async run(t) { return completed(t, { newTasks: [{ title: 'more', goal: 'g', agent: 'scout', acceptance: ['x'] }] }); } },
|
|
260
|
+
});
|
|
261
|
+
assert.ok(limited.limitsHit.includes('max_tasks'));
|
|
262
|
+
assert.equal(limited.tasks.length, 1);
|
|
263
|
+
reopened.close();
|
|
264
|
+
const third = openStore(path);
|
|
265
|
+
const extra = [];
|
|
266
|
+
const resumed = await orchestrate({
|
|
267
|
+
request: '', resumeRunId: limited.runId, agents, routing, registry: REG, policy: policyWith({ limits: { max_tasks: 50 } }), session: third,
|
|
268
|
+
runner: { async run(t) { extra.push(t.id); return completed(t, { newTasks: [{ title: 'more', goal: 'g', agent: 'scout', acceptance: ['x'] }] }); } },
|
|
269
|
+
});
|
|
270
|
+
assert.deepEqual(extra, []);
|
|
271
|
+
assert.equal(resumed.tasks.length, 1);
|
|
272
|
+
assert.ok(resumed.limitsHit.includes('max_tasks'));
|
|
273
|
+
third.close();
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
test('multiple runs stay independent', async () => {
|
|
277
|
+
const session = openStore(dbPath());
|
|
278
|
+
const runner = { async run(t) { return completed(t); } };
|
|
279
|
+
const a = await orchestrate({ request: 'alpha request', plan: [spec('a', 'scout')], agents, routing, registry: REG, policy: DEFAULT_POLICY, session, runner });
|
|
280
|
+
const b = await orchestrate({ request: 'beta request', plan: [spec('a', 'reviewer')], agents, routing, registry: REG, policy: DEFAULT_POLICY, session, runner });
|
|
281
|
+
const rows = session.listRuns();
|
|
282
|
+
assert.equal(rows.length, 2);
|
|
283
|
+
assert.ok(rows.every(r => r.status === 'completed' && r.completed === 1 && r.total === 1 && r.pendingDecisions === 0));
|
|
284
|
+
assert.notEqual(a.runId, b.runId);
|
|
285
|
+
const waiting = session.listRuns({ status: 'waiting_for_user' });
|
|
286
|
+
assert.equal(waiting.length, 0);
|
|
287
|
+
session.close();
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
test('a second session cannot resume a live run or reset its running task', async () => {
|
|
291
|
+
const path = dbPath();
|
|
292
|
+
const firstStore = openStore(path);
|
|
293
|
+
const secondStore = openStore(path);
|
|
294
|
+
let enter, finish;
|
|
295
|
+
const entered = new Promise(resolve => { enter = resolve; });
|
|
296
|
+
const hold = new Promise(resolve => { finish = resolve; });
|
|
297
|
+
const running = orchestrate({ request: 'r', plan: [spec('a', 'scout')], agents, routing, registry: REG, policy: DEFAULT_POLICY, session: firstStore,
|
|
298
|
+
runner: { async run(t) { enter(); await hold; return completed(t); } } });
|
|
299
|
+
try {
|
|
300
|
+
await entered;
|
|
301
|
+
const id = secondStore.listRuns()[0].id;
|
|
302
|
+
const before = secondStore.loadTasks(id)[0];
|
|
303
|
+
assert.equal(before.status, 'running');
|
|
304
|
+
await assert.rejects(orchestrate({ request: '', resumeRunId: id, agents, routing, registry: REG, policy: DEFAULT_POLICY, session: secondStore,
|
|
305
|
+
runner: { async run() { throw new Error('duplicate execution'); } } }), /already active/);
|
|
306
|
+
assert.equal(secondStore.loadTasks(id)[0].status, 'running');
|
|
307
|
+
assert.equal(secondStore.loadTasks(id)[0].attempts, before.attempts);
|
|
308
|
+
assert.equal(secondStore.loadTrace(id).some(e => e.type === 'resume'), false);
|
|
309
|
+
} finally {
|
|
310
|
+
finish();
|
|
311
|
+
await running;
|
|
312
|
+
secondStore.close();
|
|
313
|
+
firstStore.close();
|
|
314
|
+
}
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
test('experiment dependency survives an unrelated user escalation', async () => {
|
|
318
|
+
const session = openStore(dbPath());
|
|
319
|
+
const choose = { key: 'choice', question: 'Which approach?', options: [{ id: 'a', summary: 'A', risk: 'medium' }, { id: 'b', summary: 'B', risk: 'medium' }], experiment: { estimatedHours: 1 } };
|
|
320
|
+
const approval = { key: 'approval', question: 'Allow publication?', flags: ['external_publish'], options: [{ id: 'yes', summary: 'Yes' }, { id: 'no', summary: 'No' }] };
|
|
321
|
+
let calls = 0;
|
|
322
|
+
try {
|
|
323
|
+
const first = await orchestrate({ request: 'r', plan: [spec('a', 'scout')], agents, routing, registry: REG, policy: DEFAULT_POLICY, session,
|
|
324
|
+
runner: { async run(t) { if (t.kind === 'experiment') return completed(t, { acceptance: [{ id: 'A1', met: true, evidence: 'observed' }, { id: 'A2', met: true, evidence: 'observed' }] }); calls++; return calls === 1 ? blocked([choose, approval]) : completed(t); } } });
|
|
325
|
+
const experiment = first.tasks.find(t => t.kind === 'experiment');
|
|
326
|
+
assert.ok(experiment);
|
|
327
|
+
assert.equal(first.runStatus, 'waiting_for_user');
|
|
328
|
+
assert.ok(session.loadTasks(first.runId).find(t => t.id === 'a').dependencies.includes(experiment.id));
|
|
329
|
+
session.answerDecision({ runId: first.runId, decisionId: first.escalations[0].id, answer: 'yes' });
|
|
330
|
+
const resumed = await orchestrate({ request: '', resumeRunId: first.runId, agents, routing, registry: REG, policy: DEFAULT_POLICY, session,
|
|
331
|
+
runner: { async run(t) { calls++; return completed(t); } } });
|
|
332
|
+
assert.equal(resumed.tasks.find(t => t.id === 'a').status, 'completed');
|
|
333
|
+
assert.ok(resumed.tasks.find(t => t.id === 'a').dependencies.includes(experiment.id));
|
|
334
|
+
} finally { session.close(); }
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
test('process restart: list, answer, resume, and crash recovery', () => {
|
|
338
|
+
const path = dbPath();
|
|
339
|
+
const child = join(kit, 'tests/fixtures/orch-persist-child.mjs');
|
|
340
|
+
const cli = join(kit, 'scripts/orchestrate.mjs');
|
|
341
|
+
const start = spawnSync(process.execPath, [child, 'start', path], { encoding: 'utf8' });
|
|
342
|
+
assert.equal(start.status, 0, start.stderr);
|
|
343
|
+
const info = JSON.parse(start.stdout);
|
|
344
|
+
assert.equal(info.runStatus, 'waiting_for_user');
|
|
345
|
+
assert.equal(info.tasks.find(t => t.id === 't1').status, 'completed');
|
|
346
|
+
const list = spawnSync(process.execPath, [cli, '--list', '--store', path], { encoding: 'utf8' });
|
|
347
|
+
assert.equal(list.status, 0, list.stderr);
|
|
348
|
+
assert.match(list.stdout, new RegExp(info.runId));
|
|
349
|
+
assert.match(list.stdout, /waiting_for_user/);
|
|
350
|
+
const decisions = spawnSync(process.execPath, [cli, '--decisions', '--resume', info.runId, '--store', path], { encoding: 'utf8' });
|
|
351
|
+
assert.match(decisions.stdout, new RegExp(info.decisionId));
|
|
352
|
+
const resume = spawnSync(process.execPath, [child, 'resume', path, info.runId, info.decisionId], { encoding: 'utf8' });
|
|
353
|
+
assert.equal(resume.status, 0, resume.stderr + resume.stdout);
|
|
354
|
+
const out = JSON.parse(resume.stdout);
|
|
355
|
+
assert.equal(out.status, 'completed');
|
|
356
|
+
assert.deepEqual(out.calls.map(c => c.id), ['t2', 't3']);
|
|
357
|
+
|
|
358
|
+
const crashDb = dbPath();
|
|
359
|
+
const marker = join(dirname(crashDb), 'marker.json');
|
|
360
|
+
const crash = spawnSync(process.execPath, [child, 'crash', crashDb, marker], { encoding: 'utf8' });
|
|
361
|
+
assert.equal(crash.status, 99, crash.stderr);
|
|
362
|
+
const session = openStore(crashDb);
|
|
363
|
+
const runs = session.listRuns({ status: 'running' });
|
|
364
|
+
assert.equal(runs.length, 1);
|
|
365
|
+
const tasks = session.loadTasks(runs[0].id);
|
|
366
|
+
assert.equal(tasks[0].status, 'running');
|
|
367
|
+
assert.equal(tasks[0].attempts, 1);
|
|
368
|
+
session.close();
|
|
369
|
+
const recovered = spawnSync(process.execPath, [child, 'resume', crashDb, runs[0].id, '-'], { encoding: 'utf8' });
|
|
370
|
+
assert.equal(recovered.status, 0, recovered.stderr + recovered.stdout);
|
|
371
|
+
const done = JSON.parse(recovered.stdout);
|
|
372
|
+
assert.equal(done.status, 'completed');
|
|
373
|
+
assert.equal(done.tasks[0].attempts, 2);
|
|
374
|
+
assert.deepEqual(done.calls.map(c => c.id), ['t1']);
|
|
375
|
+
});
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
// Tool-capable execution: mode selection, permissions, git baseline, shell gate, failure class, child trace.
|
|
2
|
+
import test from 'node:test';
|
|
3
|
+
import assert from 'node:assert/strict';
|
|
4
|
+
import { EventEmitter } from 'node:events';
|
|
5
|
+
import { mkdtempSync, writeFileSync } from 'node:fs';
|
|
6
|
+
import { spawnSync } from 'node:child_process';
|
|
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, parseFrontmatter } from '../lib/agents.mjs';
|
|
12
|
+
import { DEFAULT_POLICY, mergePolicy } from '../lib/orchestrator/policy.mjs';
|
|
13
|
+
import { accessOf, piToolsForAccess, resolveExecutionMode, declaredMode } from '../lib/orchestrator/permissions.mjs';
|
|
14
|
+
import { decideShell } from '../lib/orchestrator/shell-policy.mjs';
|
|
15
|
+
import { captureWorktree, diffWorktree } from '../lib/orchestrator/worktree.mjs';
|
|
16
|
+
import { classifyRun } from '../lib/orchestrator/failures.mjs';
|
|
17
|
+
import { parseStructuredResult, createAgentRunner, guardWorkspace } from '../lib/orchestrator/runner.mjs';
|
|
18
|
+
import { evaluateResult } from '../lib/orchestrator/evaluator.mjs';
|
|
19
|
+
import { buildTaskContract } from '../lib/orchestrator/contract.mjs';
|
|
20
|
+
import { orchestrate, dryRun } from '../lib/orchestrator/orchestrator.mjs';
|
|
21
|
+
import { openStore } from '../lib/orchestrator/store.mjs';
|
|
22
|
+
import { inspectPiEvents, runPiSubagent } from '../adapters/pi/lib/subagent.mjs';
|
|
23
|
+
import { createScriptedInvoker } from '../adapters/pi/lib/invoke.mjs';
|
|
24
|
+
|
|
25
|
+
const kit = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
26
|
+
const routing = loadRouting(join(kit, 'routing/routing.json'));
|
|
27
|
+
const { agents } = loadAgents(join(kit, 'agents'), routing);
|
|
28
|
+
const REG = { version: 1, backends: {
|
|
29
|
+
local: { provider: 'pl', model: 'ml', thinking: 'off' },
|
|
30
|
+
cheap: { provider: 'pc', model: 'mc', thinking: 'low' },
|
|
31
|
+
sol: { provider: 'ps', model: 'ms', thinking: 'high' },
|
|
32
|
+
astra: { provider: 'pa', model: 'ma', thinking: 'medium', vision: true },
|
|
33
|
+
codex: { provider: 'px', model: 'mx', thinking: 'high' },
|
|
34
|
+
} };
|
|
35
|
+
const json = obj => `note\n\n\`\`\`json\n${JSON.stringify(obj)}\n\`\`\``;
|
|
36
|
+
const done = { status: 'completed', summary: 'checked files', acceptance: [{ id: 'A1', met: true, evidence: 'read src' }] };
|
|
37
|
+
const spec = (id, agent, dependencies = [], extra = {}) => ({ id, title: `task ${id}`, goal: `goal ${id}`, agent, dependencies, acceptance: ['done'], ...extra });
|
|
38
|
+
|
|
39
|
+
test('tool capability is parsed separately from the pi tool-name list', () => {
|
|
40
|
+
const scout = agents.find(a => a.meta.name === 'scout');
|
|
41
|
+
const coder = agents.find(a => a.meta.name === 'coder');
|
|
42
|
+
assert.equal(scout.meta.execution, 'subagent');
|
|
43
|
+
assert.deepEqual(accessOf(scout), { filesystem: 'read', shell: 'limited', git: 'read', network: false });
|
|
44
|
+
assert.deepEqual(piToolsForAccess(accessOf(coder)), ['read', 'grep', 'find', 'ls', 'edit', 'write', 'ludi_exec']);
|
|
45
|
+
assert.ok(!piToolsForAccess(accessOf(scout)).includes('edit'));
|
|
46
|
+
assert.ok(!piToolsForAccess(accessOf(coder)).includes('powershell'));
|
|
47
|
+
const parsed = parseFrontmatter('---\nname: x\ndescription: d\ncapability: cheap-code\nexecution:\n preferred_mode: oneshot\naccess:\n filesystem: read-write\n shell: false\n git: none\n network: false\n---\nbody');
|
|
48
|
+
assert.equal(parsed.meta.execution.preferred_mode, 'oneshot');
|
|
49
|
+
assert.equal(parsed.meta.access.filesystem, 'read-write');
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('runner selects subagent, falls back to oneshot, and keeps pipeline for --apply', async () => {
|
|
53
|
+
const coder = agents.find(a => a.meta.name === 'coder');
|
|
54
|
+
assert.equal(declaredMode(coder, {}), 'subagent');
|
|
55
|
+
assert.equal(resolveExecutionMode(coder, {}, { hasSubagent: false }), 'oneshot');
|
|
56
|
+
assert.equal(resolveExecutionMode(coder, {}, { hasSubagent: true }), 'subagent');
|
|
57
|
+
assert.equal(resolveExecutionMode(coder, {}, { hasSubagent: true, pipelineAgents: ['coder'] }), 'pipeline');
|
|
58
|
+
let sub = 0;
|
|
59
|
+
const subRunner = createAgentRunner({
|
|
60
|
+
invoke: async () => { throw new Error('oneshot used'); },
|
|
61
|
+
runSubagent: async () => { sub++; return { ok: true, text: json(done), child: { childSessionId: 'c1', toolCalls: 1, turns: 1 } }; },
|
|
62
|
+
agents, routing, registry: REG,
|
|
63
|
+
});
|
|
64
|
+
const subResult = await subRunner.run({ id: 'a', title: 't', goal: 'g', capability: 'cheap-code', assignedAgent: 'scout', acceptance: ['done'], dependencies: [], outputs: [], attemptsLog: [] }, { dependencyResults: [] });
|
|
65
|
+
assert.equal(subResult.executor, 'subagent');
|
|
66
|
+
assert.equal(sub, 1);
|
|
67
|
+
const one = createAgentRunner({ invoke: createScriptedInvoker({ '*': json(done) }), agents, routing, registry: REG });
|
|
68
|
+
const oneResult = await one.run({ id: 'a', title: 't', goal: 'g', capability: 'cheap-code', assignedAgent: 'scout', acceptance: ['done'], dependencies: [], outputs: [] }, { dependencyResults: [] });
|
|
69
|
+
assert.equal(oneResult.executor, 'oneshot');
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test('structured result accepts needs_decision and tolerates malformed text', () => {
|
|
73
|
+
const blocked = parseStructuredResult(json({ status: 'needs_decision', summary: 'which file', decisions: [{ question: 'which?' }] }));
|
|
74
|
+
assert.equal(blocked.result.status, 'blocked');
|
|
75
|
+
assert.equal(blocked.structured, true);
|
|
76
|
+
const bad = parseStructuredResult('実装しました');
|
|
77
|
+
assert.equal(bad.structured, false);
|
|
78
|
+
assert.equal(bad.failureClass, 'MALFORMED_RESULT');
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test('git baseline ignores existing dirty files and reports new agent changes', () => {
|
|
82
|
+
const root = mkdtempSync(join(tmpdir(), 'ludi-wt-'));
|
|
83
|
+
assert.equal(spawnSync('git', ['init'], { cwd: root, encoding: 'utf8' }).status, 0, 'git init');
|
|
84
|
+
writeFileSync(join(root, 'keep.txt'), 'user\n');
|
|
85
|
+
spawnSync('git', ['add', 'keep.txt'], { cwd: root });
|
|
86
|
+
spawnSync('git', ['-c', 'user.email=test@example.com', '-c', 'user.name=test', 'commit', '-m', 'base'], { cwd: root });
|
|
87
|
+
writeFileSync(join(root, 'keep.txt'), 'user edited\n');
|
|
88
|
+
const before = captureWorktree(root);
|
|
89
|
+
assert.equal(before.source, 'git');
|
|
90
|
+
writeFileSync(join(root, 'agent.txt'), 'new\n');
|
|
91
|
+
const delta = diffWorktree(before, captureWorktree(root));
|
|
92
|
+
assert.deepEqual(delta.map(d => d.path), ['agent.txt']);
|
|
93
|
+
assert.equal(delta[0].kind, 'untracked');
|
|
94
|
+
const outside = guardWorkspace({ path: join(root, '..', 'elsewhere') }, root);
|
|
95
|
+
assert.match(outside, /outside/);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test('safe shell is allowed and dangerous shell becomes a user decision', () => {
|
|
99
|
+
assert.equal(decideShell('npm test', { shell: 'limited' }).allow, true);
|
|
100
|
+
assert.equal(decideShell('git diff -- src', { shell: 'limited' }).allow, true);
|
|
101
|
+
assert.equal(decideShell('git push origin main', { shell: 'true' }).allow, false);
|
|
102
|
+
assert.equal(decideShell('npm publish', { shell: 'true' }).allow, false);
|
|
103
|
+
assert.equal(decideShell('rm -rf /', { shell: 'true' }).allow, false);
|
|
104
|
+
const ev = evaluateResult({ id: 'a', kind: 'implement', acceptance: ['done'] }, {
|
|
105
|
+
ok: true, structured: true,
|
|
106
|
+
result: { status: 'completed', summary: 'pushed', acceptance: [{ id: 'A1', met: true, evidence: 'ran' }], commandsRun: ['git push'], remainingIssues: [], newTasks: [] },
|
|
107
|
+
});
|
|
108
|
+
assert.equal(ev.verdict, 'blocked');
|
|
109
|
+
assert.ok(ev.decisions[0].flags.includes('external_publish'));
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test('subagent decisions stay with the orchestrator and are not a direct user question', async () => {
|
|
113
|
+
const ask = { key: 'name', question: 'Which module name?', options: [{ id: 'a', summary: 'A', reversible: true }, { id: 'b', summary: 'B', reversible: true }] };
|
|
114
|
+
const calls = [];
|
|
115
|
+
const runner = createAgentRunner({
|
|
116
|
+
invoke: async () => { throw new Error('no'); },
|
|
117
|
+
runSubagent: async req => { calls.push(req.prompt); return calls.length === 1 ? { ok: true, text: json({ status: 'blocked', summary: 'need a name', decisions: [ask] }), child: { childSessionId: 'c-ask' } } : { ok: true, text: json(done), child: { childSessionId: 'c-go' } }; },
|
|
118
|
+
agents, routing, registry: REG,
|
|
119
|
+
});
|
|
120
|
+
const result = await orchestrate({ request: 'r', plan: [spec('a', 'coder')], agents, routing, registry: REG, policy: DEFAULT_POLICY, runner });
|
|
121
|
+
assert.equal(result.status, 'completed');
|
|
122
|
+
assert.deepEqual(result.escalations, []);
|
|
123
|
+
assert.match(calls[1], /DECISIONS_ALREADY_MADE/);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test('backend limit reassigns and the next attempt sees the previous failure', async () => {
|
|
127
|
+
const seen = [];
|
|
128
|
+
const runner = createAgentRunner({
|
|
129
|
+
maxModelAttempts: 1,
|
|
130
|
+
invoke: async () => { throw new Error('no'); },
|
|
131
|
+
runSubagent: async req => {
|
|
132
|
+
seen.push({ capability: req.capability, prompt: req.prompt });
|
|
133
|
+
if (seen.length === 1) return { ok: false, error: 'usage limit has been reached', failureClass: 'BACKEND_LIMIT', child: { childSessionId: 'c-limit' } };
|
|
134
|
+
return { ok: true, text: json(done), child: { childSessionId: 'c2' } };
|
|
135
|
+
},
|
|
136
|
+
agents, routing, registry: REG,
|
|
137
|
+
});
|
|
138
|
+
const result = await orchestrate({
|
|
139
|
+
request: 'r', plan: [spec('a', 'scout')], agents, routing, registry: REG, policy: DEFAULT_POLICY, runner,
|
|
140
|
+
});
|
|
141
|
+
assert.deepEqual(seen.map(s => s.capability), ['cheap-code', 'strong-code']);
|
|
142
|
+
assert.match(seen[1].prompt, /BACKEND_LIMIT/);
|
|
143
|
+
assert.equal(classifyRun({ error: 'usage limit has been reached' }), 'BACKEND_LIMIT');
|
|
144
|
+
assert.equal(result.status, 'completed');
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test('test failure retries the same capability', async () => {
|
|
148
|
+
const caps = [];
|
|
149
|
+
const runner = {
|
|
150
|
+
async run(task) {
|
|
151
|
+
caps.push(task.capability);
|
|
152
|
+
if (caps.length === 1) return { ok: true, structured: true, failureClass: 'TEST_FAILURE', result: { status: 'failed', summary: 'tests failed', acceptance: [], remainingIssues: [], decisions: [], newTasks: [], commandsRun: [], verification: [{ command: 'npm test', result: 'fail' }] } };
|
|
153
|
+
return { ok: true, structured: true, result: { status: 'completed', summary: 'tests passed', acceptance: [{ id: 'A1', met: true, evidence: 'npm test' }], remainingIssues: [], decisions: [], newTasks: [], commandsRun: ['npm test'], verification: [{ command: 'npm test', result: 'pass' }] } };
|
|
154
|
+
},
|
|
155
|
+
};
|
|
156
|
+
const result = await orchestrate({ request: 'r', plan: [spec('a', 'tester')], agents, routing, registry: REG, policy: DEFAULT_POLICY, runner });
|
|
157
|
+
assert.deepEqual(caps, ['cheap-code', 'cheap-code']);
|
|
158
|
+
assert.equal(result.status, 'completed');
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test('child session is stored on the trace and a completed task is not run again', async () => {
|
|
162
|
+
const path = join(mkdtempSync(join(tmpdir(), 'ludi-child-')), 'state.db');
|
|
163
|
+
const session = openStore(path);
|
|
164
|
+
let calls = 0;
|
|
165
|
+
const runner = createAgentRunner({
|
|
166
|
+
invoke: async () => { throw new Error('no'); },
|
|
167
|
+
runSubagent: async () => { calls++; return { ok: true, text: json(done), child: { childSessionId: 'child-abc', toolCalls: 3, turns: 2 } }; },
|
|
168
|
+
agents, routing, registry: REG,
|
|
169
|
+
});
|
|
170
|
+
const first = await orchestrate({ request: 'look', plan: [spec('a', 'scout')], agents, routing, registry: REG, policy: DEFAULT_POLICY, runner, session });
|
|
171
|
+
assert.equal(calls, 1);
|
|
172
|
+
assert.ok(session.loadTrace(first.runId).some(e => e.type === 'child' && e.childSessionId === 'child-abc'));
|
|
173
|
+
session.close();
|
|
174
|
+
const again = openStore(path);
|
|
175
|
+
const second = await orchestrate({ request: '', resumeRunId: first.runId, agents, routing, registry: REG, policy: DEFAULT_POLICY, runner, session: again });
|
|
176
|
+
assert.equal(calls, 1);
|
|
177
|
+
assert.equal(second.status, 'completed');
|
|
178
|
+
again.close();
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test('timeout and tool-call limits stop the child without treating it as a backend failure', async () => {
|
|
182
|
+
const hanging = () => {
|
|
183
|
+
const proc = new EventEmitter();
|
|
184
|
+
proc.stdout = new EventEmitter();
|
|
185
|
+
proc.stderr = new EventEmitter();
|
|
186
|
+
proc.stdout.setEncoding = () => {};
|
|
187
|
+
proc.stderr.setEncoding = () => {};
|
|
188
|
+
proc.kill = () => {};
|
|
189
|
+
return proc;
|
|
190
|
+
};
|
|
191
|
+
const timed = await runPiSubagent({ modelId: 'pc/mc:low', prompt: 'x', systemPrompt: 's', toolNames: ['read'], limits: { max_runtime_ms: 20 } }, { piEntry: 'pi-entry', spawnImpl: hanging, timeoutMs: 30 });
|
|
192
|
+
assert.equal(timed.failureClass, 'TIMEOUT');
|
|
193
|
+
const events = [{ type: 'message_end', message: { role: 'assistant', content: [{ type: 'toolCall', name: 'read', arguments: { path: 'a' } }, { type: 'text', text: 'seen' }] } }];
|
|
194
|
+
assert.equal(inspectPiEvents(events).toolCalls, 1);
|
|
195
|
+
assert.equal(inspectPiEvents(events).text, 'seen');
|
|
196
|
+
assert.match(inspectPiEvents([{ type: 'message_end', message: { role: 'assistant', content: [], errorMessage: 'usage limit has been reached', stopReason: 'error' } }]).error, /usage limit/);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
test('contract names the workspace and forbids publish', () => {
|
|
200
|
+
const text = buildTaskContract({ id: 'a', title: 'Fix', goal: 'fix it', acceptance: ['tests pass'], attemptsLog: [{ attempt: 1, failureClass: 'TEST_FAILURE', reasons: ['red'], filesChanged: ['src/a.js'], verification: [] }] }, { workspace: { path: 'D:/repo', repository: 'D:/repo' }, access: accessOf(agents.find(a => a.meta.name === 'coder')) });
|
|
201
|
+
assert.match(text, /WORKSPACE/);
|
|
202
|
+
assert.match(text, /FORBIDDEN_ACTIONS/);
|
|
203
|
+
assert.match(text, /git push/);
|
|
204
|
+
assert.match(text, /PREVIOUS_ATTEMPTS/);
|
|
205
|
+
assert.match(text, /filesystem: read-write/);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
test('dry-run describes the subagent and does not launch one', async () => {
|
|
209
|
+
let launched = 0;
|
|
210
|
+
const dry = await dryRun('Fix the failing average() test', { agents, routing, registry: REG, policy: DEFAULT_POLICY, cwd: kit, runSubagent: () => { launched++; } });
|
|
211
|
+
assert.equal(launched, 0);
|
|
212
|
+
assert.equal(dry.tasks.find(t => t.assignedAgent === 'coder').executionMode, 'subagent');
|
|
213
|
+
assert.ok(dry.tasks.find(t => t.assignedAgent === 'coder').tools.includes('ludi_exec'));
|
|
214
|
+
assert.equal(dry.tasks.find(t => t.assignedAgent === 'tester').workspace.path, kit);
|
|
215
|
+
});
|