ultra-builder-pro-cli 0.14.0 → 0.16.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 +77 -1
- package/README.md +46 -24
- package/adapters/_shared/codex-assets.cjs +6 -2
- package/adapters/_shared/file-ops.cjs +6 -2
- package/adapters/_shared/handbook.cjs +2 -1
- package/adapters/claude.js +3 -2
- package/adapters/codex.js +1 -0
- package/adapters/kimi.js +3 -2
- package/adapters/opencode.js +3 -2
- package/agents/review-coordinator.md +9 -6
- package/commands/ultra-change.md +1 -1
- package/commands/ultra-plan.md +1 -1
- package/commands/ultra-research.md +1 -1
- package/commands/ultra-think.md +2 -2
- package/docs/AGENT-CONTEXT.md +43 -19
- package/docs/ARCHITECTURE.md +54 -13
- package/docs/PLAN.zh-CN.md +4 -2
- package/docs/ROADMAP.md +10 -4
- package/docs/RUNTIME-COMPAT-MATRIX.md +4 -4
- package/docs/STATE-DB-ACCESS-POLICY.md +33 -29
- package/docs/USER-HANDBOOK-CONTRACT.md +8 -2
- package/docs/WORKFLOW-LIFECYCLE.md +342 -0
- package/hooks/README.md +6 -1
- package/hooks/health_check.py +12 -3
- package/hooks/subagent_tracker.py +52 -77
- 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 +598 -85
- package/mcp-server/lib/context-spine.cjs +305 -25
- package/mcp-server/lib/decision-dialogue.cjs +713 -0
- package/mcp-server/lib/doctor.cjs +42 -3
- package/mcp-server/lib/init-project.cjs +210 -16
- package/mcp-server/lib/plan-store.cjs +4 -1
- package/mcp-server/lib/pricing.cjs +7 -10
- package/mcp-server/lib/project-breadcrumb.cjs +15 -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 +168 -8
- package/mcp-server/lib/state-ops.cjs +160 -9
- package/mcp-server/lib/workflow-state.cjs +2305 -0
- package/mcp-server/server.cjs +215 -15
- package/orchestrator/daemon.cjs +1 -2
- package/orchestrator/dispatch-rules.cjs +5 -31
- package/orchestrator/planner/plan-builder.cjs +21 -12
- package/package.json +13 -3
- package/skills/ultra-change/SKILL.md +94 -80
- package/skills/ultra-change/references/change-contract.md +36 -0
- package/skills/ultra-deliver/SKILL.md +76 -79
- package/skills/ultra-deliver/references/baseline-reconciliation.md +20 -0
- package/skills/ultra-dev/SKILL.md +62 -59
- package/skills/ultra-doctor/SKILL.md +61 -82
- package/skills/ultra-init/SKILL.md +101 -103
- package/skills/ultra-init/references/brownfield-adoption.md +2 -2
- package/skills/ultra-plan/SKILL.md +99 -85
- package/skills/ultra-plan/references/semantic-preflight.md +18 -0
- package/skills/ultra-research/SKILL.md +102 -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 +63 -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 -41
- package/skills/ultra-test/SKILL.md +54 -55
- package/skills/ultra-think/SKILL.md +26 -12
- package/skills/ultra-think/references/decision-dialogue.md +130 -0
- package/skills/ultra-verify/SKILL.md +1 -1
- package/skills/ultra-verify/references/{confidence-system.md → evidence-status.md} +3 -3
- package/spec/cli-protocol.md +17 -3
- package/spec/mcp-tools.yaml +683 -64
- 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 +140 -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 +228 -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 -149
- 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 -138
- package/adapters/tests/codex-hook.test.cjs +0 -142
- package/adapters/tests/codex.test.cjs +0 -430
- package/adapters/tests/kimi-hook.test.cjs +0 -149
- package/adapters/tests/kimi.test.cjs +0 -259
- package/adapters/tests/opencode.test.cjs +0 -357
- 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/plan-store.test.cjs +0 -132
- package/mcp-server/lib/prd-parser.test.cjs +0 -82
- package/mcp-server/lib/project-breadcrumb.test.cjs +0 -112
- 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 -169
- 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 -1467
- 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/plan-builder.test.cjs +0 -165
- 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,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
|
-
});
|
|
@@ -1,233 +0,0 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
// Phase 8B.2 — Parallel orchestrator: consumes execution-plan.json waves,
|
|
4
|
-
// spawns sessions via session-runner, respects parallel vs serial per wave,
|
|
5
|
-
// and emits wave/plan events.
|
|
6
|
-
//
|
|
7
|
-
// AC (PLAN §6 Phase 8B.2):
|
|
8
|
-
// - 10 independent-file tasks run in parallel
|
|
9
|
-
// - 2 tasks sharing a file serialize (plan-builder flips wave.parallel=false)
|
|
10
|
-
// - wave boundary strict: wave N+1 waits on wave N close
|
|
11
|
-
// - Phase 5.4 daemon tests stay green (no shared state)
|
|
12
|
-
|
|
13
|
-
const { test } = require('node:test');
|
|
14
|
-
const assert = require('node:assert/strict');
|
|
15
|
-
const fs = require('node:fs');
|
|
16
|
-
const os = require('node:os');
|
|
17
|
-
const path = require('node:path');
|
|
18
|
-
const { execFileSync } = require('node:child_process');
|
|
19
|
-
|
|
20
|
-
const { initStateDb, closeStateDb } = require('../../mcp-server/lib/state-db.cjs');
|
|
21
|
-
const ops = require('../../mcp-server/lib/state-ops.cjs');
|
|
22
|
-
const { buildPlan } = require('../planner/plan-builder.cjs');
|
|
23
|
-
const parallelOrch = require('../parallel-orchestrator.cjs');
|
|
24
|
-
|
|
25
|
-
// Test-Double rationale: we don't invoke real LLM runtimes; a short-lived
|
|
26
|
-
// Node subprocess is enough to exercise spawn → exit → session.close flow.
|
|
27
|
-
const NODE = process.execPath;
|
|
28
|
-
function exitOk(delayMs = 20) { return ['-e', `setTimeout(() => process.exit(0), ${delayMs})`]; }
|
|
29
|
-
function exitFail() { return ['-e', 'process.exit(1)']; }
|
|
30
|
-
|
|
31
|
-
function mkRepo() {
|
|
32
|
-
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ubp-parorch-'));
|
|
33
|
-
execFileSync('git', ['init', '-q'], { cwd: dir });
|
|
34
|
-
execFileSync('git', ['config', 'user.email', 'test@ubp.dev'], { cwd: dir });
|
|
35
|
-
execFileSync('git', ['config', 'user.name', 'ubp-test'], { cwd: dir });
|
|
36
|
-
fs.writeFileSync(path.join(dir, 'seed.md'), '# seed\n');
|
|
37
|
-
execFileSync('git', ['add', '-A'], { cwd: dir });
|
|
38
|
-
execFileSync('git', ['commit', '-q', '-m', 'seed'], { cwd: dir });
|
|
39
|
-
return dir;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
function mkDb(repoRoot) {
|
|
43
|
-
const dbPath = path.join(repoRoot, '.ultra', 'state.db');
|
|
44
|
-
fs.mkdirSync(path.dirname(dbPath), { recursive: true });
|
|
45
|
-
const { db } = initStateDb(dbPath);
|
|
46
|
-
return db;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
function seedTask(db, id, files_modified = [], deps = []) {
|
|
50
|
-
ops.createTask(db, {
|
|
51
|
-
id, title: `task ${id}`, type: 'feature', priority: 'P2',
|
|
52
|
-
complexity: 2, files_modified, deps,
|
|
53
|
-
});
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
function cleanup(repoRoot, db) {
|
|
57
|
-
try { closeStateDb(db); } catch (_) { /* best-effort */ }
|
|
58
|
-
try { fs.rmSync(repoRoot, { recursive: true, force: true }); } catch (_) { /* best-effort */ }
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
// ─── happy path ───────────────────────────────────────────────────────────
|
|
62
|
-
|
|
63
|
-
test('runPlan: 1 wave 1 task → task.status=completed + plan_completed event', async () => {
|
|
64
|
-
const repo = mkRepo();
|
|
65
|
-
const db = mkDb(repo);
|
|
66
|
-
try {
|
|
67
|
-
seedTask(db, 't1');
|
|
68
|
-
const plan = buildPlan([{ id: 't1', deps: [], complexity: 2 }]);
|
|
69
|
-
await parallelOrch.runPlan({
|
|
70
|
-
db, repoRoot: repo, plan,
|
|
71
|
-
runtimes: ['claude'],
|
|
72
|
-
command: NODE, commandArgs: exitOk(),
|
|
73
|
-
});
|
|
74
|
-
const t = ops.readTask(db, 't1');
|
|
75
|
-
assert.equal(t.status, 'completed');
|
|
76
|
-
const { events } = ops.subscribeEventsSince(db, 0);
|
|
77
|
-
const types = events.map((e) => e.type);
|
|
78
|
-
assert.ok(types.includes('wave_started'));
|
|
79
|
-
assert.ok(types.includes('wave_completed'));
|
|
80
|
-
assert.ok(types.includes('plan_completed'));
|
|
81
|
-
} finally { cleanup(repo, db); }
|
|
82
|
-
});
|
|
83
|
-
|
|
84
|
-
test('runPlan: 2-wave 6-task independent files → all parallel, all completed', async () => {
|
|
85
|
-
const repo = mkRepo();
|
|
86
|
-
const db = mkDb(repo);
|
|
87
|
-
try {
|
|
88
|
-
const tasks = [];
|
|
89
|
-
for (let i = 1; i <= 3; i++) {
|
|
90
|
-
seedTask(db, `a${i}`, [`dir/a${i}.js`]);
|
|
91
|
-
tasks.push({ id: `a${i}`, deps: [], complexity: 1, files_modified: [`dir/a${i}.js`] });
|
|
92
|
-
}
|
|
93
|
-
for (let i = 1; i <= 3; i++) {
|
|
94
|
-
seedTask(db, `b${i}`, [`dir/b${i}.js`], [`a${i}`]);
|
|
95
|
-
tasks.push({ id: `b${i}`, deps: [`a${i}`], complexity: 1, files_modified: [`dir/b${i}.js`] });
|
|
96
|
-
}
|
|
97
|
-
const plan = buildPlan(tasks);
|
|
98
|
-
assert.equal(plan.waves.length, 2, 'topo should yield 2 waves');
|
|
99
|
-
assert.equal(plan.waves[0].parallel, true);
|
|
100
|
-
assert.equal(plan.waves[1].parallel, true);
|
|
101
|
-
|
|
102
|
-
await parallelOrch.runPlan({
|
|
103
|
-
db, repoRoot: repo, plan,
|
|
104
|
-
runtimes: ['claude'],
|
|
105
|
-
command: NODE, commandArgs: exitOk(30),
|
|
106
|
-
});
|
|
107
|
-
for (const t of tasks) {
|
|
108
|
-
assert.equal(ops.readTask(db, t.id).status, 'completed', `${t.id} should complete`);
|
|
109
|
-
}
|
|
110
|
-
} finally { cleanup(repo, db); }
|
|
111
|
-
});
|
|
112
|
-
|
|
113
|
-
test('runPlan: 2 tasks sharing one file → plan-builder flips parallel=false → serial exec', async () => {
|
|
114
|
-
const repo = mkRepo();
|
|
115
|
-
const db = mkDb(repo);
|
|
116
|
-
try {
|
|
117
|
-
seedTask(db, 'c1', ['shared.js']);
|
|
118
|
-
seedTask(db, 'c2', ['shared.js']);
|
|
119
|
-
const plan = buildPlan([
|
|
120
|
-
{ id: 'c1', deps: [], complexity: 1, files_modified: ['shared.js'] },
|
|
121
|
-
{ id: 'c2', deps: [], complexity: 1, files_modified: ['shared.js'] },
|
|
122
|
-
]);
|
|
123
|
-
assert.equal(plan.waves[0].parallel, false, 'plan-builder should detect conflict');
|
|
124
|
-
|
|
125
|
-
await parallelOrch.runPlan({
|
|
126
|
-
db, repoRoot: repo, plan,
|
|
127
|
-
runtimes: ['claude'],
|
|
128
|
-
command: NODE, commandArgs: exitOk(30),
|
|
129
|
-
});
|
|
130
|
-
assert.equal(ops.readTask(db, 'c1').status, 'completed');
|
|
131
|
-
assert.equal(ops.readTask(db, 'c2').status, 'completed');
|
|
132
|
-
} finally { cleanup(repo, db); }
|
|
133
|
-
});
|
|
134
|
-
|
|
135
|
-
test('runPlan: wave boundary — wave 2 does not start until wave 1 closes', async () => {
|
|
136
|
-
const repo = mkRepo();
|
|
137
|
-
const db = mkDb(repo);
|
|
138
|
-
try {
|
|
139
|
-
seedTask(db, 'w1');
|
|
140
|
-
seedTask(db, 'w2', [], ['w1']);
|
|
141
|
-
const plan = buildPlan([
|
|
142
|
-
{ id: 'w1', deps: [] },
|
|
143
|
-
{ id: 'w2', deps: ['w1'] },
|
|
144
|
-
]);
|
|
145
|
-
assert.equal(plan.waves.length, 2);
|
|
146
|
-
|
|
147
|
-
await parallelOrch.runPlan({
|
|
148
|
-
db, repoRoot: repo, plan,
|
|
149
|
-
runtimes: ['claude'],
|
|
150
|
-
command: NODE, commandArgs: exitOk(40),
|
|
151
|
-
});
|
|
152
|
-
|
|
153
|
-
const { events } = ops.subscribeEventsSince(db, 0);
|
|
154
|
-
const spawns = events.filter((e) => e.type === 'session_spawned');
|
|
155
|
-
const closes = events.filter((e) => e.type === 'session_closed');
|
|
156
|
-
const w2SpawnIdx = events.findIndex((e) => e.type === 'session_spawned' && e.task_id === 'w2');
|
|
157
|
-
const w1CloseIdx = events.findIndex((e) => e.type === 'session_closed' && e.task_id === 'w1');
|
|
158
|
-
assert.ok(w1CloseIdx > -1 && w2SpawnIdx > -1, 'both events should exist');
|
|
159
|
-
assert.ok(w1CloseIdx < w2SpawnIdx, 'w1 must close before w2 spawns');
|
|
160
|
-
assert.equal(spawns.length, 2);
|
|
161
|
-
assert.equal(closes.length, 2);
|
|
162
|
-
} finally { cleanup(repo, db); }
|
|
163
|
-
});
|
|
164
|
-
|
|
165
|
-
// ─── failure path ─────────────────────────────────────────────────────────
|
|
166
|
-
|
|
167
|
-
test('runPlan: task exits non-zero → session crashed, task stays pending', async () => {
|
|
168
|
-
const repo = mkRepo();
|
|
169
|
-
const db = mkDb(repo);
|
|
170
|
-
try {
|
|
171
|
-
seedTask(db, 'fail1');
|
|
172
|
-
const plan = buildPlan([{ id: 'fail1', deps: [] }]);
|
|
173
|
-
await parallelOrch.runPlan({
|
|
174
|
-
db, repoRoot: repo, plan,
|
|
175
|
-
runtimes: ['claude'],
|
|
176
|
-
command: NODE, commandArgs: exitFail(),
|
|
177
|
-
});
|
|
178
|
-
const t = ops.readTask(db, 'fail1');
|
|
179
|
-
assert.equal(t.status, 'pending', 'failed task stays pending for recovery');
|
|
180
|
-
const { events } = ops.subscribeEventsSince(db, 0);
|
|
181
|
-
const crashed = events.find((e) => e.type === 'session_crashed');
|
|
182
|
-
assert.ok(crashed, 'session_crashed event expected');
|
|
183
|
-
} finally { cleanup(repo, db); }
|
|
184
|
-
});
|
|
185
|
-
|
|
186
|
-
test('runPlan: one task fails in wave, others still complete (no short-circuit)', async () => {
|
|
187
|
-
const repo = mkRepo();
|
|
188
|
-
const db = mkDb(repo);
|
|
189
|
-
try {
|
|
190
|
-
seedTask(db, 'ok1', ['f1.js']);
|
|
191
|
-
seedTask(db, 'bad1', ['f2.js']);
|
|
192
|
-
seedTask(db, 'ok2', ['f3.js']);
|
|
193
|
-
const plan = buildPlan([
|
|
194
|
-
{ id: 'ok1', deps: [], files_modified: ['f1.js'] },
|
|
195
|
-
{ id: 'bad1', deps: [], files_modified: ['f2.js'] },
|
|
196
|
-
{ id: 'ok2', deps: [], files_modified: ['f3.js'] },
|
|
197
|
-
]);
|
|
198
|
-
await parallelOrch.runPlan({
|
|
199
|
-
db, repoRoot: repo, plan,
|
|
200
|
-
runtimes: ['claude'],
|
|
201
|
-
command: NODE,
|
|
202
|
-
commandArgsFor: (task) => (task.id === 'bad1' ? exitFail() : exitOk(20)),
|
|
203
|
-
});
|
|
204
|
-
assert.equal(ops.readTask(db, 'ok1').status, 'completed');
|
|
205
|
-
assert.equal(ops.readTask(db, 'ok2').status, 'completed');
|
|
206
|
-
assert.equal(ops.readTask(db, 'bad1').status, 'pending');
|
|
207
|
-
} finally { cleanup(repo, db); }
|
|
208
|
-
});
|
|
209
|
-
|
|
210
|
-
test('runPlan: emits wave_started/wave_completed per wave + one plan_completed', async () => {
|
|
211
|
-
const repo = mkRepo();
|
|
212
|
-
const db = mkDb(repo);
|
|
213
|
-
try {
|
|
214
|
-
seedTask(db, 'e1');
|
|
215
|
-
seedTask(db, 'e2', [], ['e1']);
|
|
216
|
-
const plan = buildPlan([
|
|
217
|
-
{ id: 'e1', deps: [] },
|
|
218
|
-
{ id: 'e2', deps: ['e1'] },
|
|
219
|
-
]);
|
|
220
|
-
await parallelOrch.runPlan({
|
|
221
|
-
db, repoRoot: repo, plan,
|
|
222
|
-
runtimes: ['claude'],
|
|
223
|
-
command: NODE, commandArgs: exitOk(20),
|
|
224
|
-
});
|
|
225
|
-
const { events } = ops.subscribeEventsSince(db, 0);
|
|
226
|
-
const waveStarts = events.filter((e) => e.type === 'wave_started');
|
|
227
|
-
const waveCloses = events.filter((e) => e.type === 'wave_completed');
|
|
228
|
-
const planCloses = events.filter((e) => e.type === 'plan_completed');
|
|
229
|
-
assert.equal(waveStarts.length, 2);
|
|
230
|
-
assert.equal(waveCloses.length, 2);
|
|
231
|
-
assert.equal(planCloses.length, 1);
|
|
232
|
-
} finally { cleanup(repo, db); }
|
|
233
|
-
});
|