ultra-builder-pro-cli 0.13.0 → 0.15.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/.ultra-template/docs/research/README.md +11 -7
- package/.ultra-template/specs/architecture.md +36 -36
- package/.ultra-template/specs/discovery.md +46 -33
- package/.ultra-template/specs/product.md +38 -29
- package/.ultra-template/specs/research-distillate.md +48 -0
- package/.ultra-template/tasks/contexts/TEMPLATE.md +11 -28
- package/CHANGELOG.md +86 -1
- package/README.md +40 -26
- package/adapters/_shared/codex-assets.cjs +29 -165
- package/adapters/_shared/file-ops.cjs +6 -2
- package/adapters/_shared/runtime-assets.cjs +1 -0
- package/adapters/claude.js +3 -2
- package/adapters/codex.js +1 -0
- package/adapters/kimi.js +3 -2
- package/adapters/opencode.js +33 -122
- package/agents/review-coordinator.md +9 -6
- package/docs/AGENT-CONTEXT.md +21 -11
- package/docs/ARCHITECTURE.md +41 -13
- package/docs/PLAN.zh-CN.md +3 -2
- package/docs/ROADMAP.md +9 -5
- package/docs/RUNTIME-COMPAT-MATRIX.md +6 -6
- package/docs/STATE-DB-ACCESS-POLICY.md +27 -25
- package/docs/WORKFLOW-LIFECYCLE.md +266 -0
- package/hooks/README.md +6 -1
- package/hooks/active_task_context.py +22 -50
- package/hooks/context_spine.py +61 -332
- package/hooks/health_check.py +29 -18
- package/hooks/pre_stop_check.py +28 -33
- package/hooks/subagent_tracker.py +54 -85
- package/hooks/workflow_checkpoint.py +39 -47
- package/hooks/workflow_context.py +4 -9
- package/hooks/workflow_resume.py +48 -128
- package/mcp-server/breadcrumb.cjs +22 -0
- package/mcp-server/hook-event.cjs +34 -0
- package/mcp-server/lib/archive-journal.cjs +17 -5
- package/mcp-server/lib/baseline-workflow.cjs +158 -18
- package/mcp-server/lib/change-workflow.cjs +550 -85
- package/mcp-server/lib/context-spine.cjs +242 -24
- package/mcp-server/lib/doctor.cjs +33 -3
- package/mcp-server/lib/init-project.cjs +210 -16
- package/mcp-server/lib/plan-store.cjs +4 -1
- package/mcp-server/lib/prd-parser.cjs +14 -41
- package/mcp-server/lib/pricing.cjs +7 -10
- package/mcp-server/lib/project-breadcrumb.cjs +145 -0
- package/mcp-server/lib/projector.cjs +57 -3
- package/mcp-server/lib/spec-learning.cjs +79 -2
- package/mcp-server/lib/state-db.cjs +145 -8
- package/mcp-server/lib/state-ops.cjs +160 -9
- package/mcp-server/lib/task-expander.cjs +9 -38
- package/mcp-server/lib/workflow-state.cjs +2292 -0
- package/mcp-server/server.cjs +182 -50
- package/orchestrator/daemon.cjs +1 -2
- package/orchestrator/dispatch-rules.cjs +5 -31
- package/orchestrator/planner/plan-builder.cjs +21 -12
- package/package.json +14 -6
- package/skills/cc-collab/SKILL.md +2 -2
- package/skills/codex-collab/SKILL.md +2 -3
- package/skills/ultra-change/SKILL.md +53 -68
- package/skills/ultra-change/references/change-contract.md +36 -0
- package/skills/ultra-deliver/SKILL.md +69 -79
- package/skills/ultra-deliver/references/baseline-reconciliation.md +20 -0
- package/skills/ultra-dev/SKILL.md +54 -59
- package/skills/ultra-doctor/SKILL.md +40 -71
- package/skills/ultra-init/SKILL.md +96 -103
- package/skills/ultra-init/references/brownfield-adoption.md +2 -2
- package/skills/ultra-plan/SKILL.md +79 -75
- package/skills/ultra-plan/references/semantic-preflight.md +18 -0
- package/skills/ultra-research/SKILL.md +87 -60
- package/skills/ultra-research/references/00-problem-validation.md +36 -0
- package/skills/ultra-research/references/01-opportunity-discovery.md +33 -0
- package/skills/ultra-research/references/02-market-assessment.md +30 -0
- package/skills/ultra-research/references/03-competitive-landscape.md +31 -0
- package/skills/ultra-research/references/04-product-strategy.md +32 -0
- package/skills/ultra-research/references/05-assumptions-validation.md +31 -0
- package/skills/ultra-research/references/10-user-personas.md +29 -0
- package/skills/ultra-research/references/11-user-scenarios.md +28 -0
- package/skills/ultra-research/references/20-user-stories.md +30 -0
- package/skills/ultra-research/references/21-features-scope.md +29 -0
- package/skills/ultra-research/references/22-success-metrics.md +28 -0
- package/skills/ultra-research/references/30-architecture-context.md +30 -0
- package/skills/ultra-research/references/31-solution-strategy.md +28 -0
- package/skills/ultra-research/references/32-building-blocks.md +28 -0
- package/skills/ultra-research/references/40-deployment.md +29 -0
- package/skills/ultra-research/references/41-quality-risks.md +28 -0
- package/skills/ultra-research/references/99-synthesis.md +35 -0
- package/skills/ultra-review/SKILL.md +57 -73
- package/skills/ultra-review/references/review-modes.md +27 -0
- package/skills/ultra-review/references/unified-schema.md +25 -0
- package/skills/ultra-review/scripts/review_wait.py +40 -3
- package/skills/ultra-status/SKILL.md +41 -45
- package/skills/ultra-test/SKILL.md +48 -55
- package/skills/ultra-verify/SKILL.md +22 -31
- package/skills/ultra-verify/references/{confidence-system.md → evidence-status.md} +3 -3
- package/skills/ultra-verify/scripts/verify_wait.py +54 -54
- package/spec/cli-protocol.md +7 -2
- package/spec/mcp-tools.yaml +422 -73
- package/spec/schemas/baseline-reconciliation.v1.schema.json +73 -0
- package/spec/schemas/delivery-report.v1.schema.json +111 -0
- package/spec/schemas/mcp-tools.schema.json +1 -1
- package/spec/schemas/state-db.sql +82 -1
- package/spec/schemas/tasks.v4.5.schema.json +8 -1
- package/spec/schemas/test-report.v1.schema.json +194 -0
- package/ultra-tools/cli.cjs +1 -1
- package/ultra-tools/commands/migrate.cjs +3 -3
- package/ultra-tools/commands/status.cjs +213 -12
- package/.ultra-template/delivery-report.json +0 -9
- package/.ultra-template/test-report.json +0 -8
- package/adapters/_shared/tests/file-ops.test.cjs +0 -54
- package/adapters/_shared/tests/frontmatter.test.cjs +0 -47
- package/adapters/_shared/tests/handbook.test.cjs +0 -101
- package/adapters/_shared/tests/path-rewrite.test.cjs +0 -60
- package/adapters/_shared/tests/provenance.test.cjs +0 -133
- package/adapters/_shared/tests/resolve-target.test.cjs +0 -75
- package/adapters/_shared/tests/runtime-assets.test.cjs +0 -131
- package/adapters/_shared/tests/settings-merge.test.cjs +0 -85
- package/adapters/_shared/tests/validate.test.cjs +0 -30
- package/adapters/tests/claude.test.cjs +0 -137
- package/adapters/tests/codex-hook.test.cjs +0 -115
- package/adapters/tests/codex.test.cjs +0 -423
- package/adapters/tests/kimi-hook.test.cjs +0 -122
- package/adapters/tests/kimi.test.cjs +0 -259
- package/adapters/tests/opencode.test.cjs +0 -371
- package/mcp-server/lib/archive-journal.test.cjs +0 -35
- package/mcp-server/lib/baseline-workflow.test.cjs +0 -477
- package/mcp-server/lib/change-workflow.test.cjs +0 -971
- package/mcp-server/lib/context-spine.test.cjs +0 -93
- package/mcp-server/lib/doctor.test.cjs +0 -148
- package/mcp-server/lib/llm-client.cjs +0 -157
- package/mcp-server/lib/llm-client.test.cjs +0 -156
- package/mcp-server/lib/plan-store.test.cjs +0 -132
- package/mcp-server/lib/prd-parser.test.cjs +0 -171
- package/mcp-server/lib/runtime-state.test.cjs +0 -92
- package/mcp-server/lib/spec-learning.test.cjs +0 -67
- package/mcp-server/lib/state-db.test.cjs +0 -314
- package/mcp-server/lib/task-expander.test.cjs +0 -324
- package/mcp-server/lib/topo.test.cjs +0 -158
- package/mcp-server/tests/circuit-breaker.test.cjs +0 -125
- package/mcp-server/tests/commit-hash-flow.test.cjs +0 -117
- package/mcp-server/tests/concurrency-update.test.cjs +0 -96
- package/mcp-server/tests/concurrency.test.cjs +0 -112
- package/mcp-server/tests/db-ops.test.cjs +0 -121
- package/mcp-server/tests/init-project.test.cjs +0 -393
- package/mcp-server/tests/migrate.test.cjs +0 -232
- package/mcp-server/tests/projector.test.cjs +0 -162
- package/mcp-server/tests/server-contract.test.cjs +0 -1471
- package/mcp-server/tests/staleness.test.cjs +0 -190
- package/mcp-server/tests/state-db.test.cjs +0 -106
- package/mcp-server/tests/state-ops.test.cjs +0 -449
- package/mcp-server/tests/tagged-tasks.test.cjs +0 -163
- package/mcp-server/tests/telemetry.test.cjs +0 -219
- package/orchestrator/planner/expand-prompt.cjs +0 -62
- package/orchestrator/planner/expand-prompt.test.cjs +0 -52
- package/orchestrator/planner/plan-builder.test.cjs +0 -165
- package/orchestrator/planner/prd-prompt.cjs +0 -44
- package/orchestrator/planner/prd-prompt.test.cjs +0 -30
- package/orchestrator/tests/auto-merge.test.cjs +0 -216
- package/orchestrator/tests/daemon.test.cjs +0 -311
- package/orchestrator/tests/dispatch-rules.test.cjs +0 -124
- package/orchestrator/tests/parallel-orchestrator.test.cjs +0 -233
- package/orchestrator/tests/phase-8b-gate.test.cjs +0 -183
- package/orchestrator/tests/recovery.test.cjs +0 -190
- package/orchestrator/tests/session-runner.test.cjs +0 -314
- package/orchestrator/tests/skill-miner.test.cjs +0 -147
- package/orchestrator/tests/worktree-manager.test.cjs +0 -148
- package/skills/ultra-research/references/architecture.md +0 -38
- package/skills/ultra-research/references/problem-product.md +0 -39
- package/skills/ultra-research/references/quality-delivery.md +0 -34
- package/skills/ultra-research/references/synthesis.md +0 -38
- package/skills/ultra-research/references/users-scope.md +0 -40
- package/ultra-tools/legacy-memory.test.cjs +0 -80
- package/ultra-tools/status-command.test.cjs +0 -181
- package/ultra-tools/system-command.test.cjs +0 -309
|
@@ -1,216 +0,0 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
// Phase 8B.4 — Auto-merge back.
|
|
4
|
-
//
|
|
5
|
-
// autoMerge({ repoRoot, worktreePath, baseBranch, sid, task_id, db }):
|
|
6
|
-
// • no changes (session_sha === base_sha) → { merged:false, reason:'no_changes' }
|
|
7
|
-
// • clean merge → { merged:true } + merged_back event
|
|
8
|
-
// • conflict → { merged:false, reason:'conflict', conflict_paths[] }
|
|
9
|
-
// + merge_conflict event + merge --abort
|
|
10
|
-
// closeSession({ autoMerge:true }) integration:
|
|
11
|
-
// • autoMerge=true + conflict → worktree kept on disk (for human to resolve)
|
|
12
|
-
// • autoMerge=false (default) → behavior unchanged (Phase 4.5 / 5 / 7 tests)
|
|
13
|
-
|
|
14
|
-
const { test } = require('node:test');
|
|
15
|
-
const assert = require('node:assert/strict');
|
|
16
|
-
const fs = require('node:fs');
|
|
17
|
-
const os = require('node:os');
|
|
18
|
-
const path = require('node:path');
|
|
19
|
-
const { execFileSync } = require('node:child_process');
|
|
20
|
-
|
|
21
|
-
const { initStateDb, closeStateDb } = require('../../mcp-server/lib/state-db.cjs');
|
|
22
|
-
const ops = require('../../mcp-server/lib/state-ops.cjs');
|
|
23
|
-
const wtmgr = require('../worktree-manager.cjs');
|
|
24
|
-
const autoMerge = require('../auto-merge.cjs');
|
|
25
|
-
const runner = require('../session-runner.cjs');
|
|
26
|
-
|
|
27
|
-
function mkRepo() {
|
|
28
|
-
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ubp-automerge-'));
|
|
29
|
-
execFileSync('git', ['init', '-q', '-b', 'main'], { cwd: dir });
|
|
30
|
-
execFileSync('git', ['config', 'user.email', 'test@ubp.dev'], { cwd: dir });
|
|
31
|
-
execFileSync('git', ['config', 'user.name', 'ubp-test'], { cwd: dir });
|
|
32
|
-
fs.writeFileSync(path.join(dir, 'seed.md'), '# seed\n');
|
|
33
|
-
// The DB and worktree dirs live under .ultra/ — keep them out of git so
|
|
34
|
-
// `git status` in conflict tests isn't polluted by untracked test artifacts.
|
|
35
|
-
fs.writeFileSync(path.join(dir, '.gitignore'), '.ultra/\n');
|
|
36
|
-
execFileSync('git', ['add', '-A'], { cwd: dir });
|
|
37
|
-
execFileSync('git', ['commit', '-q', '-m', 'seed'], { cwd: dir });
|
|
38
|
-
return dir;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
function mkDb(repoRoot) {
|
|
42
|
-
const dbPath = path.join(repoRoot, '.ultra', 'state.db');
|
|
43
|
-
fs.mkdirSync(path.dirname(dbPath), { recursive: true });
|
|
44
|
-
const { db } = initStateDb(dbPath);
|
|
45
|
-
return db;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
function commitInWorktree(wtPath, filename, content, msg = 'session change') {
|
|
49
|
-
fs.writeFileSync(path.join(wtPath, filename), content);
|
|
50
|
-
execFileSync('git', ['add', '-A'], { cwd: wtPath });
|
|
51
|
-
execFileSync('git', ['-c', 'user.email=s@ubp.dev', '-c', 'user.name=s-ubp',
|
|
52
|
-
'commit', '-q', '-m', msg], { cwd: wtPath });
|
|
53
|
-
return execFileSync('git', ['rev-parse', 'HEAD'], { cwd: wtPath, stdio: ['ignore', 'pipe', 'pipe'] })
|
|
54
|
-
.toString().trim();
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
function cleanup(repoRoot, db) {
|
|
58
|
-
try { if (db) closeStateDb(db); } catch (_) { /* best-effort */ }
|
|
59
|
-
try { fs.rmSync(repoRoot, { recursive: true, force: true }); } catch (_) { /* best-effort */ }
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
// ─── autoMerge pure function ──────────────────────────────────────────────
|
|
63
|
-
|
|
64
|
-
test('autoMerge: no session commits → merged:false reason:no_changes', () => {
|
|
65
|
-
const repo = mkRepo();
|
|
66
|
-
try {
|
|
67
|
-
const { worktree_path } = wtmgr.allocate({ repoRoot: repo, sid: 's-noop' });
|
|
68
|
-
const r = autoMerge.autoMerge({
|
|
69
|
-
repoRoot: repo, worktreePath: worktree_path,
|
|
70
|
-
baseBranch: 'main', sid: 's-noop',
|
|
71
|
-
});
|
|
72
|
-
assert.equal(r.merged, false);
|
|
73
|
-
assert.equal(r.reason, 'no_changes');
|
|
74
|
-
} finally { cleanup(repo); }
|
|
75
|
-
});
|
|
76
|
-
|
|
77
|
-
test('autoMerge: 3 slices with independent files → all merged back to main', () => {
|
|
78
|
-
const repo = mkRepo();
|
|
79
|
-
const db = mkDb(repo);
|
|
80
|
-
try {
|
|
81
|
-
for (const sid of ['s1', 's2', 's3']) {
|
|
82
|
-
ops.createTask(db, { id: sid, title: sid, type: 'feature', priority: 'P2' });
|
|
83
|
-
}
|
|
84
|
-
const shas = {};
|
|
85
|
-
for (const [sid, file] of [['s1', 'a.txt'], ['s2', 'b.txt'], ['s3', 'c.txt']]) {
|
|
86
|
-
const { worktree_path } = wtmgr.allocate({ repoRoot: repo, sid });
|
|
87
|
-
shas[sid] = commitInWorktree(worktree_path, file, `${file}\n`);
|
|
88
|
-
const r = autoMerge.autoMerge({
|
|
89
|
-
repoRoot: repo, worktreePath: worktree_path,
|
|
90
|
-
baseBranch: 'main', sid, task_id: sid, db,
|
|
91
|
-
});
|
|
92
|
-
assert.equal(r.merged, true, `${sid} should merge`);
|
|
93
|
-
assert.equal(r.session_sha, shas[sid]);
|
|
94
|
-
}
|
|
95
|
-
assert.ok(fs.existsSync(path.join(repo, 'a.txt')));
|
|
96
|
-
assert.ok(fs.existsSync(path.join(repo, 'b.txt')));
|
|
97
|
-
assert.ok(fs.existsSync(path.join(repo, 'c.txt')));
|
|
98
|
-
const { events } = ops.subscribeEventsSince(db, 0);
|
|
99
|
-
const merged = events.filter((e) => e.type === 'merged_back');
|
|
100
|
-
assert.equal(merged.length, 3);
|
|
101
|
-
} finally { cleanup(repo, db); }
|
|
102
|
-
});
|
|
103
|
-
|
|
104
|
-
test('autoMerge: 2 slices on same file → first merges, second conflict + event', () => {
|
|
105
|
-
const repo = mkRepo();
|
|
106
|
-
const db = mkDb(repo);
|
|
107
|
-
try {
|
|
108
|
-
ops.createTask(db, { id: 'cf1', title: 'cf1', type: 'feature', priority: 'P2' });
|
|
109
|
-
ops.createTask(db, { id: 'cf2', title: 'cf2', type: 'feature', priority: 'P2' });
|
|
110
|
-
const { worktree_path: w1 } = wtmgr.allocate({ repoRoot: repo, sid: 'cf1' });
|
|
111
|
-
const { worktree_path: w2 } = wtmgr.allocate({ repoRoot: repo, sid: 'cf2' });
|
|
112
|
-
commitInWorktree(w1, 'conflict.txt', 'version-A\n');
|
|
113
|
-
commitInWorktree(w2, 'conflict.txt', 'version-B\n');
|
|
114
|
-
|
|
115
|
-
const r1 = autoMerge.autoMerge({
|
|
116
|
-
repoRoot: repo, worktreePath: w1, baseBranch: 'main', sid: 'cf1', task_id: 'cf1', db,
|
|
117
|
-
});
|
|
118
|
-
assert.equal(r1.merged, true);
|
|
119
|
-
const r2 = autoMerge.autoMerge({
|
|
120
|
-
repoRoot: repo, worktreePath: w2, baseBranch: 'main', sid: 'cf2', task_id: 'cf2', db,
|
|
121
|
-
});
|
|
122
|
-
assert.equal(r2.merged, false);
|
|
123
|
-
assert.equal(r2.reason, 'conflict');
|
|
124
|
-
assert.ok(Array.isArray(r2.conflict_paths));
|
|
125
|
-
assert.ok(r2.conflict_paths.includes('conflict.txt'));
|
|
126
|
-
|
|
127
|
-
// main HEAD rolled back to r1's merge commit, not mid-conflict
|
|
128
|
-
const status = execFileSync('git', ['status', '--porcelain'], {
|
|
129
|
-
cwd: repo, stdio: ['ignore', 'pipe', 'pipe'],
|
|
130
|
-
}).toString();
|
|
131
|
-
assert.equal(status.trim(), '', 'working tree must be clean after abort');
|
|
132
|
-
|
|
133
|
-
const { events } = ops.subscribeEventsSince(db, 0);
|
|
134
|
-
const conflict = events.find((e) => e.type === 'merge_conflict');
|
|
135
|
-
assert.ok(conflict, 'merge_conflict event expected');
|
|
136
|
-
assert.deepEqual(conflict.payload.conflict_paths, ['conflict.txt']);
|
|
137
|
-
} finally { cleanup(repo, db); }
|
|
138
|
-
});
|
|
139
|
-
|
|
140
|
-
// ─── closeSession integration ─────────────────────────────────────────────
|
|
141
|
-
|
|
142
|
-
test('closeSession autoMerge=true + clean merge → worktree removed', () => {
|
|
143
|
-
const repo = mkRepo();
|
|
144
|
-
const db = mkDb(repo);
|
|
145
|
-
try {
|
|
146
|
-
ops.createTask(db, { id: 'cs-clean', title: 'x', type: 'feature', priority: 'P2' });
|
|
147
|
-
const handle = runner.spawnSession({
|
|
148
|
-
db, repoRoot: repo,
|
|
149
|
-
task_id: 'cs-clean', runtime: 'claude',
|
|
150
|
-
command: process.execPath, args: ['-e', 'process.exit(0)'],
|
|
151
|
-
});
|
|
152
|
-
// Simulate agent commit inside worktree
|
|
153
|
-
commitInWorktree(handle.worktree_path, 'new.txt', 'hi\n');
|
|
154
|
-
// Wait for child to exit so closeSession can kill cleanly (it already did)
|
|
155
|
-
if (handle.process) { try { handle.process.kill('SIGTERM'); } catch (_) { /* noop */ } }
|
|
156
|
-
|
|
157
|
-
const result = runner.closeSession(
|
|
158
|
-
{ db, repoRoot: repo, sid: handle.sid },
|
|
159
|
-
{ autoMerge: true, mergeBaseBranch: 'main' },
|
|
160
|
-
);
|
|
161
|
-
assert.equal(result.merge && result.merge.merged, true);
|
|
162
|
-
assert.equal(fs.existsSync(handle.worktree_path), false, 'clean merge → worktree removed');
|
|
163
|
-
} finally { cleanup(repo, db); }
|
|
164
|
-
});
|
|
165
|
-
|
|
166
|
-
test('closeSession autoMerge=true + conflict → worktree kept', () => {
|
|
167
|
-
const repo = mkRepo();
|
|
168
|
-
const db = mkDb(repo);
|
|
169
|
-
try {
|
|
170
|
-
ops.createTask(db, { id: 'cs-cflict', title: 'x', type: 'feature', priority: 'P2' });
|
|
171
|
-
|
|
172
|
-
// Spawn FIRST so the worktree forks at seed commit (before main diverges).
|
|
173
|
-
const handle = runner.spawnSession({
|
|
174
|
-
db, repoRoot: repo,
|
|
175
|
-
task_id: 'cs-cflict', runtime: 'claude',
|
|
176
|
-
command: process.execPath, args: ['-e', 'process.exit(0)'],
|
|
177
|
-
});
|
|
178
|
-
// Advance main with a conflicting version of shared.txt.
|
|
179
|
-
fs.writeFileSync(path.join(repo, 'shared.txt'), 'main-version\n');
|
|
180
|
-
execFileSync('git', ['add', '-A'], { cwd: repo });
|
|
181
|
-
execFileSync('git', ['commit', '-q', '-m', 'main change'], { cwd: repo });
|
|
182
|
-
// Session worktree forks off seed and writes its own version of the same file.
|
|
183
|
-
commitInWorktree(handle.worktree_path, 'shared.txt', 'session-version\n');
|
|
184
|
-
|
|
185
|
-
const result = runner.closeSession(
|
|
186
|
-
{ db, repoRoot: repo, sid: handle.sid },
|
|
187
|
-
{ autoMerge: true, mergeBaseBranch: 'main' },
|
|
188
|
-
);
|
|
189
|
-
assert.equal(result.merge && result.merge.merged, false);
|
|
190
|
-
assert.equal(result.merge.reason, 'conflict');
|
|
191
|
-
assert.ok(fs.existsSync(handle.worktree_path), 'conflict → worktree preserved for resolution');
|
|
192
|
-
} finally { cleanup(repo, db); }
|
|
193
|
-
});
|
|
194
|
-
|
|
195
|
-
test('closeSession autoMerge=false (default) → no merge, legacy behavior', () => {
|
|
196
|
-
const repo = mkRepo();
|
|
197
|
-
const db = mkDb(repo);
|
|
198
|
-
try {
|
|
199
|
-
ops.createTask(db, { id: 'cs-off', title: 'x', type: 'feature', priority: 'P2' });
|
|
200
|
-
const handle = runner.spawnSession({
|
|
201
|
-
db, repoRoot: repo,
|
|
202
|
-
task_id: 'cs-off', runtime: 'claude',
|
|
203
|
-
command: process.execPath, args: ['-e', 'process.exit(0)'],
|
|
204
|
-
});
|
|
205
|
-
commitInWorktree(handle.worktree_path, 'n.txt', 'x\n');
|
|
206
|
-
|
|
207
|
-
const result = runner.closeSession(
|
|
208
|
-
{ db, repoRoot: repo, sid: handle.sid },
|
|
209
|
-
{}, // autoMerge not set
|
|
210
|
-
);
|
|
211
|
-
assert.equal(result.merge, undefined, 'no merge when opt-in disabled');
|
|
212
|
-
assert.equal(fs.existsSync(handle.worktree_path), false, 'worktree removed normally');
|
|
213
|
-
// main must still be at seed (no merge happened)
|
|
214
|
-
assert.equal(fs.existsSync(path.join(repo, 'n.txt')), false);
|
|
215
|
-
} finally { cleanup(repo, db); }
|
|
216
|
-
});
|
|
@@ -1,311 +0,0 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
// Phase 5.4 — Orchestrator daemon:
|
|
4
|
-
// • routeTask: pure function — complexity_hint + availableRuntimes → runtime
|
|
5
|
-
// • runDaemon: poll loop that spawns pending tasks, respects admission +
|
|
6
|
-
// circuit breaker, and shuts down gracefully via stop().
|
|
7
|
-
|
|
8
|
-
const { test } = require('node:test');
|
|
9
|
-
const assert = require('node:assert/strict');
|
|
10
|
-
const fs = require('node:fs');
|
|
11
|
-
const os = require('node:os');
|
|
12
|
-
const path = require('node:path');
|
|
13
|
-
const { execFileSync } = require('node:child_process');
|
|
14
|
-
|
|
15
|
-
const { initStateDb, closeStateDb } = require('../../mcp-server/lib/state-db.cjs');
|
|
16
|
-
const ops = require('../../mcp-server/lib/state-ops.cjs');
|
|
17
|
-
const daemon = require('../daemon.cjs');
|
|
18
|
-
const RETIRED_RUNTIME = ['gem', 'ini'].join('');
|
|
19
|
-
|
|
20
|
-
const LONG_SLEEP_CMD = process.execPath;
|
|
21
|
-
const LONG_SLEEP_ARGS = ['-e', 'setInterval(() => {}, 60000);'];
|
|
22
|
-
|
|
23
|
-
function mkRepo() {
|
|
24
|
-
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ubp-daemon-repo-'));
|
|
25
|
-
execFileSync('git', ['init', '-q'], { cwd: dir });
|
|
26
|
-
execFileSync('git', ['config', 'user.email', 'test@ubp.dev'], { cwd: dir });
|
|
27
|
-
execFileSync('git', ['config', 'user.name', 'ubp-test'], { cwd: dir });
|
|
28
|
-
fs.writeFileSync(path.join(dir, 'README.md'), '# test\n');
|
|
29
|
-
execFileSync('git', ['add', '-A'], { cwd: dir });
|
|
30
|
-
execFileSync('git', ['commit', '-q', '-m', 'seed'], { cwd: dir });
|
|
31
|
-
return dir;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
function mkDb(repoRoot) {
|
|
35
|
-
const { db } = initStateDb(path.join(repoRoot, '.ultra', 'state.db'));
|
|
36
|
-
return db;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
function cleanup(repoRoot, db, handle) {
|
|
40
|
-
if (handle) {
|
|
41
|
-
try { handle.stop(); } catch (_) { /* ignore */ }
|
|
42
|
-
}
|
|
43
|
-
// Kill any stray children spawned by the daemon.
|
|
44
|
-
try {
|
|
45
|
-
const rows = db.prepare("SELECT pid FROM sessions WHERE pid IS NOT NULL AND status = 'running'").all();
|
|
46
|
-
for (const r of rows) {
|
|
47
|
-
try { process.kill(r.pid, 'SIGKILL'); } catch (_) { /* ignore */ }
|
|
48
|
-
}
|
|
49
|
-
} catch (_) { /* ignore */ }
|
|
50
|
-
try { closeStateDb(db); } catch (_) { /* ignore */ }
|
|
51
|
-
try { fs.rmSync(repoRoot, { recursive: true, force: true }); } catch (_) { /* ignore */ }
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
// ─── routeTask ────────────────────────────────────────────────────────────
|
|
55
|
-
|
|
56
|
-
test('routeTask: haiku hint prefers cheap runtimes', () => {
|
|
57
|
-
const runtime = daemon.routeTask(
|
|
58
|
-
{ id: 't', complexity_hint: 'haiku' },
|
|
59
|
-
['claude', 'opencode', 'codex'],
|
|
60
|
-
);
|
|
61
|
-
assert.equal(runtime, 'opencode');
|
|
62
|
-
});
|
|
63
|
-
|
|
64
|
-
test('routeTask: opus hint prefers strong runtimes', () => {
|
|
65
|
-
const runtime = daemon.routeTask(
|
|
66
|
-
{ id: 't', complexity_hint: 'opus' },
|
|
67
|
-
['claude', 'opencode', 'codex'],
|
|
68
|
-
);
|
|
69
|
-
assert.ok(['claude', 'codex'].includes(runtime), `got ${runtime}`);
|
|
70
|
-
});
|
|
71
|
-
|
|
72
|
-
test('routeTask: sonnet hint middle-ground', () => {
|
|
73
|
-
const runtime = daemon.routeTask(
|
|
74
|
-
{ id: 't', complexity_hint: 'sonnet' },
|
|
75
|
-
['claude', 'opencode', 'codex'],
|
|
76
|
-
);
|
|
77
|
-
assert.ok(['claude', 'codex', 'opencode'].includes(runtime));
|
|
78
|
-
});
|
|
79
|
-
|
|
80
|
-
test('routeTask: respects availability constraint', () => {
|
|
81
|
-
const runtime = daemon.routeTask(
|
|
82
|
-
{ id: 't', complexity_hint: 'opus' },
|
|
83
|
-
['opencode'],
|
|
84
|
-
);
|
|
85
|
-
// Prefers claude/codex but those aren't available → fall back to any.
|
|
86
|
-
assert.equal(runtime, 'opencode');
|
|
87
|
-
});
|
|
88
|
-
|
|
89
|
-
test('routeTask: no hint skips unsupported runtimes', () => {
|
|
90
|
-
const runtime = daemon.routeTask({ id: 't' }, [RETIRED_RUNTIME, 'claude']);
|
|
91
|
-
assert.equal(runtime, 'claude');
|
|
92
|
-
});
|
|
93
|
-
|
|
94
|
-
test('routeTask: no runtimes available → null', () => {
|
|
95
|
-
assert.equal(daemon.routeTask({ id: 't' }, []), null);
|
|
96
|
-
});
|
|
97
|
-
|
|
98
|
-
// ─── runDaemon ────────────────────────────────────────────────────────────
|
|
99
|
-
|
|
100
|
-
test('runDaemon spawns pending task within pollMs window', async () => {
|
|
101
|
-
const repoRoot = mkRepo();
|
|
102
|
-
const db = mkDb(repoRoot);
|
|
103
|
-
let handle;
|
|
104
|
-
try {
|
|
105
|
-
ops.createTask(db, { id: 'd-1', title: 'pending target', type: 'feature', priority: 'P1' });
|
|
106
|
-
handle = daemon.runDaemon({
|
|
107
|
-
db, repoRoot,
|
|
108
|
-
runtimes: ['claude'],
|
|
109
|
-
pollMs: 50,
|
|
110
|
-
command: LONG_SLEEP_CMD,
|
|
111
|
-
commandArgs: LONG_SLEEP_ARGS,
|
|
112
|
-
});
|
|
113
|
-
|
|
114
|
-
// Wait up to 500ms for the daemon to pick up the task.
|
|
115
|
-
const start = Date.now();
|
|
116
|
-
let spawned = null;
|
|
117
|
-
while (Date.now() - start < 500) {
|
|
118
|
-
const sessions = db.prepare("SELECT * FROM sessions WHERE task_id = 'd-1'").all();
|
|
119
|
-
if (sessions.length > 0) { spawned = sessions[0]; break; }
|
|
120
|
-
await new Promise((r) => setTimeout(r, 25));
|
|
121
|
-
}
|
|
122
|
-
assert.ok(spawned, 'daemon should have spawned a session within 500ms');
|
|
123
|
-
assert.equal(spawned.status, 'running');
|
|
124
|
-
assert.equal(spawned.runtime, 'claude');
|
|
125
|
-
} finally {
|
|
126
|
-
cleanup(repoRoot, db, handle);
|
|
127
|
-
}
|
|
128
|
-
});
|
|
129
|
-
|
|
130
|
-
test('runDaemon skips tripped tasks', async () => {
|
|
131
|
-
const repoRoot = mkRepo();
|
|
132
|
-
const db = mkDb(repoRoot);
|
|
133
|
-
let handle;
|
|
134
|
-
try {
|
|
135
|
-
ops.createTask(db, { id: 'd-trip', title: 'tripped', type: 'feature', priority: 'P1' });
|
|
136
|
-
for (let i = 0; i < 3; i += 1) ops.recordTaskFailure(db, 'd-trip', { reason: 'x' });
|
|
137
|
-
assert.equal(ops.isCircuitTripped(db, 'd-trip'), true);
|
|
138
|
-
|
|
139
|
-
handle = daemon.runDaemon({
|
|
140
|
-
db, repoRoot,
|
|
141
|
-
runtimes: ['claude'],
|
|
142
|
-
pollMs: 50,
|
|
143
|
-
command: LONG_SLEEP_CMD,
|
|
144
|
-
commandArgs: LONG_SLEEP_ARGS,
|
|
145
|
-
});
|
|
146
|
-
await new Promise((r) => setTimeout(r, 300));
|
|
147
|
-
const sessions = db.prepare("SELECT * FROM sessions WHERE task_id = 'd-trip'").all();
|
|
148
|
-
assert.equal(sessions.length, 0, 'tripped task must not be spawned');
|
|
149
|
-
} finally {
|
|
150
|
-
cleanup(repoRoot, db, handle);
|
|
151
|
-
}
|
|
152
|
-
});
|
|
153
|
-
|
|
154
|
-
test('runDaemon does not double-spawn same task', async () => {
|
|
155
|
-
const repoRoot = mkRepo();
|
|
156
|
-
const db = mkDb(repoRoot);
|
|
157
|
-
let handle;
|
|
158
|
-
try {
|
|
159
|
-
ops.createTask(db, { id: 'd-once', title: 'single', type: 'feature', priority: 'P1' });
|
|
160
|
-
handle = daemon.runDaemon({
|
|
161
|
-
db, repoRoot,
|
|
162
|
-
runtimes: ['claude'],
|
|
163
|
-
pollMs: 30,
|
|
164
|
-
command: LONG_SLEEP_CMD,
|
|
165
|
-
commandArgs: LONG_SLEEP_ARGS,
|
|
166
|
-
});
|
|
167
|
-
await new Promise((r) => setTimeout(r, 400));
|
|
168
|
-
const sessions = db.prepare("SELECT * FROM sessions WHERE task_id = 'd-once'").all();
|
|
169
|
-
assert.equal(sessions.length, 1, 'daemon should not double-spawn');
|
|
170
|
-
} finally {
|
|
171
|
-
cleanup(repoRoot, db, handle);
|
|
172
|
-
}
|
|
173
|
-
});
|
|
174
|
-
|
|
175
|
-
test('runDaemon.stop() halts polling; existing children stay alive', async () => {
|
|
176
|
-
const repoRoot = mkRepo();
|
|
177
|
-
const db = mkDb(repoRoot);
|
|
178
|
-
let handle;
|
|
179
|
-
try {
|
|
180
|
-
ops.createTask(db, { id: 'd-stop', title: 'stop test', type: 'feature', priority: 'P1' });
|
|
181
|
-
handle = daemon.runDaemon({
|
|
182
|
-
db, repoRoot,
|
|
183
|
-
runtimes: ['claude'],
|
|
184
|
-
pollMs: 30,
|
|
185
|
-
command: LONG_SLEEP_CMD,
|
|
186
|
-
commandArgs: LONG_SLEEP_ARGS,
|
|
187
|
-
});
|
|
188
|
-
// Wait for spawn.
|
|
189
|
-
await new Promise((r) => setTimeout(r, 200));
|
|
190
|
-
const before = db.prepare("SELECT pid FROM sessions WHERE task_id = 'd-stop'").all();
|
|
191
|
-
assert.equal(before.length, 1);
|
|
192
|
-
const childPid = before[0].pid;
|
|
193
|
-
|
|
194
|
-
handle.stop();
|
|
195
|
-
// After stop: child still alive, no new spawn for a fresh task.
|
|
196
|
-
ops.createTask(db, { id: 'd-after-stop', title: 'after stop', type: 'feature', priority: 'P1' });
|
|
197
|
-
await new Promise((r) => setTimeout(r, 200));
|
|
198
|
-
const afterStop = db.prepare("SELECT * FROM sessions WHERE task_id = 'd-after-stop'").all();
|
|
199
|
-
assert.equal(afterStop.length, 0, 'no new spawns after stop');
|
|
200
|
-
|
|
201
|
-
try { process.kill(childPid, 0); } catch (err) {
|
|
202
|
-
assert.fail(`child ${childPid} should still be alive after daemon stop: ${err.message}`);
|
|
203
|
-
}
|
|
204
|
-
} finally {
|
|
205
|
-
cleanup(repoRoot, db, handle);
|
|
206
|
-
}
|
|
207
|
-
});
|
|
208
|
-
|
|
209
|
-
test('runDaemon branchScoped=true only spawns tasks matching cwd branch tag', async () => {
|
|
210
|
-
const repoRoot = mkRepo();
|
|
211
|
-
const db = mkDb(repoRoot);
|
|
212
|
-
let handle;
|
|
213
|
-
try {
|
|
214
|
-
// Normalize to a known branch name so the test doesn't depend on
|
|
215
|
-
// the dev machine's git init.defaultBranch config.
|
|
216
|
-
execFileSync('git', ['checkout', '-q', '-B', 'main'], { cwd: repoRoot });
|
|
217
|
-
ops.createTask(db, { id: 'd-branch-match', title: 'main task', type: 'feature', priority: 'P1', tag: 'main' });
|
|
218
|
-
ops.createTask(db, { id: 'd-branch-other', title: 'other task', type: 'feature', priority: 'P1', tag: 'feat-other' });
|
|
219
|
-
handle = daemon.runDaemon({
|
|
220
|
-
db, repoRoot,
|
|
221
|
-
runtimes: ['claude'],
|
|
222
|
-
pollMs: 50,
|
|
223
|
-
command: LONG_SLEEP_CMD,
|
|
224
|
-
commandArgs: LONG_SLEEP_ARGS,
|
|
225
|
-
branchScoped: true,
|
|
226
|
-
});
|
|
227
|
-
await new Promise((r) => setTimeout(r, 300));
|
|
228
|
-
const match = db.prepare("SELECT * FROM sessions WHERE task_id = 'd-branch-match'").all();
|
|
229
|
-
const other = db.prepare("SELECT * FROM sessions WHERE task_id = 'd-branch-other'").all();
|
|
230
|
-
assert.equal(match.length, 1, 'current-branch task must spawn');
|
|
231
|
-
assert.equal(other.length, 0, 'other-branch task must stay pending');
|
|
232
|
-
} finally {
|
|
233
|
-
cleanup(repoRoot, db, handle);
|
|
234
|
-
}
|
|
235
|
-
});
|
|
236
|
-
|
|
237
|
-
test('runDaemon applies complexity_hint route', async () => {
|
|
238
|
-
const repoRoot = mkRepo();
|
|
239
|
-
const db = mkDb(repoRoot);
|
|
240
|
-
let handle;
|
|
241
|
-
try {
|
|
242
|
-
ops.createTask(db, {
|
|
243
|
-
id: 'd-opus', title: 'opus task', type: 'architecture', priority: 'P0',
|
|
244
|
-
complexity_hint: 'opus',
|
|
245
|
-
});
|
|
246
|
-
handle = daemon.runDaemon({
|
|
247
|
-
db, repoRoot,
|
|
248
|
-
runtimes: ['claude', 'opencode'],
|
|
249
|
-
pollMs: 50,
|
|
250
|
-
command: LONG_SLEEP_CMD,
|
|
251
|
-
commandArgs: LONG_SLEEP_ARGS,
|
|
252
|
-
});
|
|
253
|
-
await new Promise((r) => setTimeout(r, 300));
|
|
254
|
-
const sessions = db.prepare("SELECT * FROM sessions WHERE task_id = 'd-opus'").all();
|
|
255
|
-
assert.equal(sessions.length, 1);
|
|
256
|
-
// opus should prefer claude over opencode.
|
|
257
|
-
assert.equal(sessions[0].runtime, 'claude');
|
|
258
|
-
} finally {
|
|
259
|
-
cleanup(repoRoot, db, handle);
|
|
260
|
-
}
|
|
261
|
-
});
|
|
262
|
-
|
|
263
|
-
test('maintainState consumes spec changes with a durable cursor', () => {
|
|
264
|
-
const repoRoot = mkRepo();
|
|
265
|
-
const db = mkDb(repoRoot);
|
|
266
|
-
try {
|
|
267
|
-
ops.createTask(db, {
|
|
268
|
-
id: 'maintain-stale', title: 'stale target', type: 'feature', priority: 'P1',
|
|
269
|
-
trace_to: '.ultra/specs/product.md#daily-change',
|
|
270
|
-
});
|
|
271
|
-
ops.appendEvent(db, {
|
|
272
|
-
type: 'spec_changed', payload: { sections: ['.ultra/specs/product.md#daily-change'] },
|
|
273
|
-
});
|
|
274
|
-
const first = daemon.maintainState({ db, repoRoot });
|
|
275
|
-
assert.equal(first.staleness.processed, 1);
|
|
276
|
-
assert.equal(ops.readTask(db, 'maintain-stale').stale, true);
|
|
277
|
-
const second = daemon.maintainState({ db, repoRoot });
|
|
278
|
-
assert.equal(second.staleness.processed, 0);
|
|
279
|
-
} finally {
|
|
280
|
-
cleanup(repoRoot, db);
|
|
281
|
-
}
|
|
282
|
-
});
|
|
283
|
-
|
|
284
|
-
test('runDaemon performs crash recovery before dispatch polling', async () => {
|
|
285
|
-
const repoRoot = mkRepo();
|
|
286
|
-
const db = mkDb(repoRoot);
|
|
287
|
-
let handle;
|
|
288
|
-
try {
|
|
289
|
-
ops.createTask(db, { id: 'recover-boot', title: 'recover boot', type: 'bugfix', priority: 'P0' });
|
|
290
|
-
ops.updateTaskStatus(db, 'recover-boot', 'in_progress');
|
|
291
|
-
ops.createSession(db, {
|
|
292
|
-
sid: 'dead-session', task_id: 'recover-boot', runtime: 'claude', pid: 999999,
|
|
293
|
-
worktree_path: '/tmp/dead-worktree', artifact_dir: '/tmp/dead-artifact', lease_seconds: 1,
|
|
294
|
-
});
|
|
295
|
-
db.prepare(
|
|
296
|
-
"UPDATE sessions SET heartbeat_at = '2000-01-01T00:00:00.000Z', lease_expires_at = '2000-01-01T00:00:00.000Z' WHERE sid = 'dead-session'",
|
|
297
|
-
).run();
|
|
298
|
-
|
|
299
|
-
handle = daemon.runDaemon({
|
|
300
|
-
db, repoRoot, runtimes: ['claude'], pollMs: 50,
|
|
301
|
-
command: LONG_SLEEP_CMD, commandArgs: LONG_SLEEP_ARGS,
|
|
302
|
-
});
|
|
303
|
-
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
304
|
-
const session = ops.readSession(db, 'dead-session');
|
|
305
|
-
assert.equal(session.status, 'crashed');
|
|
306
|
-
const failure = db.prepare("SELECT * FROM circuit_breaker WHERE task_id = 'recover-boot'").get();
|
|
307
|
-
assert.equal(failure.failure_count, 1);
|
|
308
|
-
} finally {
|
|
309
|
-
cleanup(repoRoot, db, handle);
|
|
310
|
-
}
|
|
311
|
-
});
|
|
@@ -1,124 +0,0 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
// Phase 8B.1 — Dispatch rules declarative table.
|
|
4
|
-
//
|
|
5
|
-
// evaluate(ctx, rules) → { rule_id, action, runtime }.
|
|
6
|
-
// DEFAULT_RULES must reproduce Phase 5.4 routeTask behavior (ROUTE_PREFERENCES
|
|
7
|
-
// by complexity_hint, fall back to first available) so daemon.cjs keeps green.
|
|
8
|
-
// New dimensions: breaker_state / deps_ready / wave conflict / custom rules.
|
|
9
|
-
|
|
10
|
-
const { test } = require('node:test');
|
|
11
|
-
const assert = require('node:assert/strict');
|
|
12
|
-
|
|
13
|
-
const { evaluate, DEFAULT_RULES, ROUTE_PREFERENCES } = require('../dispatch-rules.cjs');
|
|
14
|
-
const RETIRED_RUNTIME = ['gem', 'ini'].join('');
|
|
15
|
-
|
|
16
|
-
function baseCtx(overrides = {}) {
|
|
17
|
-
return {
|
|
18
|
-
task: { id: 't1' },
|
|
19
|
-
deps_ready: true,
|
|
20
|
-
available_runtimes: ['claude', 'opencode', 'codex'],
|
|
21
|
-
breaker_state: 'ok',
|
|
22
|
-
wave: null,
|
|
23
|
-
...overrides,
|
|
24
|
-
};
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
// ─── spawn_agent path (preference routing, 6 cases mirror routeTask) ──────
|
|
28
|
-
|
|
29
|
-
test('evaluate: opus hint + all available → claude (preference head)', () => {
|
|
30
|
-
const d = evaluate(baseCtx({ task: { id: 't', complexity_hint: 'opus' } }));
|
|
31
|
-
assert.equal(d.action, 'spawn_agent');
|
|
32
|
-
assert.equal(d.runtime, 'claude');
|
|
33
|
-
});
|
|
34
|
-
|
|
35
|
-
test('evaluate: opus hint + only codex available → codex', () => {
|
|
36
|
-
const d = evaluate(baseCtx({
|
|
37
|
-
task: { id: 't', complexity_hint: 'opus' },
|
|
38
|
-
available_runtimes: ['codex', 'opencode'],
|
|
39
|
-
}));
|
|
40
|
-
assert.equal(d.runtime, 'codex');
|
|
41
|
-
});
|
|
42
|
-
|
|
43
|
-
test('evaluate: opus hint + preference exhausted → first available fallback', () => {
|
|
44
|
-
const d = evaluate(baseCtx({
|
|
45
|
-
task: { id: 't', complexity_hint: 'opus' },
|
|
46
|
-
available_runtimes: ['opencode'],
|
|
47
|
-
}));
|
|
48
|
-
assert.equal(d.action, 'spawn_agent');
|
|
49
|
-
assert.equal(d.runtime, 'opencode');
|
|
50
|
-
});
|
|
51
|
-
|
|
52
|
-
test('evaluate: haiku hint + all available → opencode (cheapest preference)', () => {
|
|
53
|
-
const d = evaluate(baseCtx({ task: { id: 't', complexity_hint: 'haiku' } }));
|
|
54
|
-
assert.equal(d.runtime, 'opencode');
|
|
55
|
-
});
|
|
56
|
-
|
|
57
|
-
test('evaluate: sonnet hint + all available → claude', () => {
|
|
58
|
-
const d = evaluate(baseCtx({ task: { id: 't', complexity_hint: 'sonnet' } }));
|
|
59
|
-
assert.equal(d.runtime, 'claude');
|
|
60
|
-
});
|
|
61
|
-
|
|
62
|
-
test('evaluate: retired runtimes are discarded before fallback routing', () => {
|
|
63
|
-
const d = evaluate(baseCtx({
|
|
64
|
-
task: { id: 't' },
|
|
65
|
-
available_runtimes: [RETIRED_RUNTIME, 'claude'],
|
|
66
|
-
}));
|
|
67
|
-
assert.equal(d.action, 'spawn_agent');
|
|
68
|
-
assert.equal(d.runtime, 'claude');
|
|
69
|
-
});
|
|
70
|
-
|
|
71
|
-
// ─── block / defer paths (new in 8B.1) ────────────────────────────────────
|
|
72
|
-
|
|
73
|
-
test('evaluate: empty runtimes → block', () => {
|
|
74
|
-
const d = evaluate(baseCtx({ available_runtimes: [] }));
|
|
75
|
-
assert.equal(d.action, 'block');
|
|
76
|
-
assert.equal(d.runtime, null);
|
|
77
|
-
});
|
|
78
|
-
|
|
79
|
-
test('evaluate: breaker tripped → block (wins over spawn rules)', () => {
|
|
80
|
-
const d = evaluate(baseCtx({ breaker_state: 'tripped' }));
|
|
81
|
-
assert.equal(d.action, 'block');
|
|
82
|
-
assert.equal(d.rule_id, 'breaker-blocked');
|
|
83
|
-
});
|
|
84
|
-
|
|
85
|
-
test('evaluate: deps_ready=false → defer', () => {
|
|
86
|
-
const d = evaluate(baseCtx({ deps_ready: false }));
|
|
87
|
-
assert.equal(d.action, 'defer');
|
|
88
|
-
assert.equal(d.rule_id, 'deps-not-ready');
|
|
89
|
-
});
|
|
90
|
-
|
|
91
|
-
test('evaluate: serial wave already has running task → defer', () => {
|
|
92
|
-
const d = evaluate(baseCtx({
|
|
93
|
-
wave: { parallel: false, running_count: 1 },
|
|
94
|
-
}));
|
|
95
|
-
assert.equal(d.action, 'defer');
|
|
96
|
-
assert.equal(d.rule_id, 'wave-conflict');
|
|
97
|
-
});
|
|
98
|
-
|
|
99
|
-
// ─── composability: custom rules override defaults ────────────────────────
|
|
100
|
-
|
|
101
|
-
test('evaluate: custom rules cannot select an unsupported runtime', () => {
|
|
102
|
-
const customRules = [
|
|
103
|
-
...DEFAULT_RULES,
|
|
104
|
-
{
|
|
105
|
-
id: 'force-retired-runtime',
|
|
106
|
-
priority: 999,
|
|
107
|
-
when: () => true,
|
|
108
|
-
action: 'spawn_agent',
|
|
109
|
-
resolve: () => RETIRED_RUNTIME,
|
|
110
|
-
},
|
|
111
|
-
];
|
|
112
|
-
const d = evaluate(baseCtx({ task: { id: 't', complexity_hint: 'opus' } }), customRules);
|
|
113
|
-
assert.equal(d.action, 'block');
|
|
114
|
-
assert.equal(d.runtime, null);
|
|
115
|
-
assert.equal(d.rule_id, 'force-retired-runtime');
|
|
116
|
-
});
|
|
117
|
-
|
|
118
|
-
// ─── ROUTE_PREFERENCES shape guard (regression: don't drift from Phase 5.4) ─
|
|
119
|
-
|
|
120
|
-
test('DEFAULT_RULES preserves ROUTE_PREFERENCES shape', () => {
|
|
121
|
-
assert.deepEqual(Object.keys(ROUTE_PREFERENCES).sort(), ['haiku', 'opus', 'sonnet']);
|
|
122
|
-
assert.equal(ROUTE_PREFERENCES.opus[0], 'claude');
|
|
123
|
-
assert.equal(ROUTE_PREFERENCES.haiku[0], 'opencode');
|
|
124
|
-
});
|