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,324 +0,0 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
// Integration tests for task-expander — real SQLite (Testcontainers-style
|
|
4
|
-
// in-process) so parent_id column + status transition + task_expanded event
|
|
5
|
-
// are exercised end-to-end. The only Test Double is the LLM client, which
|
|
6
|
-
// we inject with deterministic child fixtures.
|
|
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
|
-
|
|
14
|
-
const { initStateDb, closeStateDb } = require('./state-db.cjs');
|
|
15
|
-
const ops = require('./state-ops.cjs');
|
|
16
|
-
const changes = require('./change-workflow.cjs');
|
|
17
|
-
const { expandTask, TaskExpandError } = require('./task-expander.cjs');
|
|
18
|
-
|
|
19
|
-
function tmpDb() {
|
|
20
|
-
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ubp-expand-'));
|
|
21
|
-
const { db } = initStateDb(path.join(dir, 'state.db'));
|
|
22
|
-
db.prepare(
|
|
23
|
-
`INSERT INTO baselines
|
|
24
|
-
(id, project_name, mode, status, approved_by, approval_note, converged_at)
|
|
25
|
-
VALUES ('test-baseline', 'fixture', 'greenfield', 'ready', 'test', 'fixture', ?)`,
|
|
26
|
-
).run(new Date().toISOString());
|
|
27
|
-
return { dir, db };
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
function cleanup({ dir, db }) {
|
|
31
|
-
closeStateDb(db);
|
|
32
|
-
fs.rmSync(dir, { recursive: true, force: true });
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
function fakeLlm(childrenJson, { shouldFail = false, usage = {} } = {}) {
|
|
36
|
-
return {
|
|
37
|
-
provider: 'anthropic',
|
|
38
|
-
model: 'claude-sonnet-4-6',
|
|
39
|
-
async completeJson() {
|
|
40
|
-
if (shouldFail) throw new Error('upstream LLM 500');
|
|
41
|
-
return {
|
|
42
|
-
json: { children: childrenJson },
|
|
43
|
-
raw: JSON.stringify({ children: childrenJson }),
|
|
44
|
-
usage,
|
|
45
|
-
model: 'claude-sonnet-4-6',
|
|
46
|
-
provider: 'anthropic',
|
|
47
|
-
};
|
|
48
|
-
},
|
|
49
|
-
};
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
function seedParent(db, overrides = {}) {
|
|
53
|
-
return ops.createTask(db, {
|
|
54
|
-
id: 'parent-1',
|
|
55
|
-
title: 'Build authentication module',
|
|
56
|
-
type: 'feature',
|
|
57
|
-
priority: 'P1',
|
|
58
|
-
complexity: 9,
|
|
59
|
-
files_modified: ['src/auth/index.ts'],
|
|
60
|
-
...overrides,
|
|
61
|
-
});
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
const HAPPY_CHILDREN = [
|
|
65
|
-
{ id: 'child-1', title: 'Design auth schema', type: 'architecture', priority: 'P1', complexity: 4, deps: [], files_modified: ['src/auth/schema.ts'] },
|
|
66
|
-
{ id: 'child-2', title: 'Implement login endpoint', type: 'feature', priority: 'P1', complexity: 5, deps: ['child-1'], files_modified: ['src/auth/login.ts'] },
|
|
67
|
-
{ id: 'child-3', title: 'Add signup endpoint', type: 'feature', priority: 'P2', complexity: 3, deps: ['child-1'], files_modified: ['src/auth/signup.ts'] },
|
|
68
|
-
];
|
|
69
|
-
|
|
70
|
-
test('expandTask happy path: creates children, flips parent status, emits task_expanded', async () => {
|
|
71
|
-
const ctx = tmpDb();
|
|
72
|
-
try {
|
|
73
|
-
seedParent(ctx.db);
|
|
74
|
-
const result = await expandTask(ctx.db, {
|
|
75
|
-
id: 'parent-1',
|
|
76
|
-
strategy: 'llm',
|
|
77
|
-
llmClient: fakeLlm(HAPPY_CHILDREN),
|
|
78
|
-
});
|
|
79
|
-
assert.equal(result.parent_id, 'parent-1');
|
|
80
|
-
assert.equal(result.children.length, 3);
|
|
81
|
-
|
|
82
|
-
const parentAfter = ops.readTask(ctx.db, 'parent-1');
|
|
83
|
-
assert.equal(parentAfter.status, 'expanded');
|
|
84
|
-
|
|
85
|
-
for (const child of HAPPY_CHILDREN) {
|
|
86
|
-
const row = ops.readTask(ctx.db, child.id);
|
|
87
|
-
assert.ok(row, `child ${child.id} missing`);
|
|
88
|
-
assert.equal(row.parent_id, 'parent-1');
|
|
89
|
-
assert.equal(row.status, 'pending');
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
const events = ops.subscribeEventsSince(ctx.db, { since_id: 0, limit: 100 });
|
|
93
|
-
const expandedEvents = events.events.filter((e) => e.type === 'task_expanded');
|
|
94
|
-
assert.equal(expandedEvents.length, 1);
|
|
95
|
-
assert.equal(expandedEvents[0].task_id, 'parent-1');
|
|
96
|
-
const createdEvents = events.events.filter((e) => e.type === 'task_created');
|
|
97
|
-
assert.ok(createdEvents.length >= 4, 'parent + 3 children task_created events');
|
|
98
|
-
} finally {
|
|
99
|
-
cleanup(ctx);
|
|
100
|
-
}
|
|
101
|
-
});
|
|
102
|
-
|
|
103
|
-
test('expandTask: missing parent → TASK_NOT_FOUND', async () => {
|
|
104
|
-
const ctx = tmpDb();
|
|
105
|
-
try {
|
|
106
|
-
await assert.rejects(
|
|
107
|
-
expandTask(ctx.db, {
|
|
108
|
-
id: 'does-not-exist',
|
|
109
|
-
llmClient: fakeLlm(HAPPY_CHILDREN),
|
|
110
|
-
}),
|
|
111
|
-
(err) => err instanceof TaskExpandError && err.code === 'TASK_NOT_FOUND',
|
|
112
|
-
);
|
|
113
|
-
} finally {
|
|
114
|
-
cleanup(ctx);
|
|
115
|
-
}
|
|
116
|
-
});
|
|
117
|
-
|
|
118
|
-
test('expandTask: already-expanded parent → ALREADY_EXPANDED', async () => {
|
|
119
|
-
const ctx = tmpDb();
|
|
120
|
-
try {
|
|
121
|
-
seedParent(ctx.db);
|
|
122
|
-
// First expansion succeeds
|
|
123
|
-
await expandTask(ctx.db, {
|
|
124
|
-
id: 'parent-1',
|
|
125
|
-
llmClient: fakeLlm(HAPPY_CHILDREN),
|
|
126
|
-
});
|
|
127
|
-
// Second attempt must refuse
|
|
128
|
-
await assert.rejects(
|
|
129
|
-
expandTask(ctx.db, {
|
|
130
|
-
id: 'parent-1',
|
|
131
|
-
llmClient: fakeLlm(HAPPY_CHILDREN.map((c, i) => ({ ...c, id: `other-${i}` }))),
|
|
132
|
-
}),
|
|
133
|
-
(err) => err instanceof TaskExpandError && err.code === 'ALREADY_EXPANDED',
|
|
134
|
-
);
|
|
135
|
-
} finally {
|
|
136
|
-
cleanup(ctx);
|
|
137
|
-
}
|
|
138
|
-
});
|
|
139
|
-
|
|
140
|
-
test('expandTask: strategy="manual" → NOT_IMPLEMENTED', async () => {
|
|
141
|
-
const ctx = tmpDb();
|
|
142
|
-
try {
|
|
143
|
-
seedParent(ctx.db);
|
|
144
|
-
await assert.rejects(
|
|
145
|
-
expandTask(ctx.db, {
|
|
146
|
-
id: 'parent-1',
|
|
147
|
-
strategy: 'manual',
|
|
148
|
-
llmClient: fakeLlm(HAPPY_CHILDREN),
|
|
149
|
-
}),
|
|
150
|
-
(err) => err instanceof TaskExpandError && err.code === 'NOT_IMPLEMENTED',
|
|
151
|
-
);
|
|
152
|
-
} finally {
|
|
153
|
-
cleanup(ctx);
|
|
154
|
-
}
|
|
155
|
-
});
|
|
156
|
-
|
|
157
|
-
test('expandTask: missing llmClient → NO_LLM_CLIENT', async () => {
|
|
158
|
-
const ctx = tmpDb();
|
|
159
|
-
try {
|
|
160
|
-
seedParent(ctx.db);
|
|
161
|
-
await assert.rejects(
|
|
162
|
-
expandTask(ctx.db, { id: 'parent-1' }),
|
|
163
|
-
(err) => err instanceof TaskExpandError && err.code === 'NO_LLM_CLIENT',
|
|
164
|
-
);
|
|
165
|
-
} finally {
|
|
166
|
-
cleanup(ctx);
|
|
167
|
-
}
|
|
168
|
-
});
|
|
169
|
-
|
|
170
|
-
test('expandTask: LLM throws → LLM_CALL_FAILED', async () => {
|
|
171
|
-
const ctx = tmpDb();
|
|
172
|
-
try {
|
|
173
|
-
seedParent(ctx.db);
|
|
174
|
-
await assert.rejects(
|
|
175
|
-
expandTask(ctx.db, {
|
|
176
|
-
id: 'parent-1',
|
|
177
|
-
llmClient: fakeLlm([], { shouldFail: true }),
|
|
178
|
-
}),
|
|
179
|
-
(err) => err instanceof TaskExpandError && err.code === 'LLM_CALL_FAILED',
|
|
180
|
-
);
|
|
181
|
-
// parent status must be unchanged
|
|
182
|
-
assert.equal(ops.readTask(ctx.db, 'parent-1').status, 'pending');
|
|
183
|
-
} finally {
|
|
184
|
-
cleanup(ctx);
|
|
185
|
-
}
|
|
186
|
-
});
|
|
187
|
-
|
|
188
|
-
test('expandTask: invalid child shape → INVALID_OUTPUT, parent untouched', async () => {
|
|
189
|
-
const ctx = tmpDb();
|
|
190
|
-
try {
|
|
191
|
-
seedParent(ctx.db);
|
|
192
|
-
const bad = [{ id: 'child-1', title: 'x', type: 'feature', priority: 'P1' }]; // title too short
|
|
193
|
-
await assert.rejects(
|
|
194
|
-
expandTask(ctx.db, { id: 'parent-1', llmClient: fakeLlm(bad) }),
|
|
195
|
-
(err) => err instanceof TaskExpandError && err.code === 'INVALID_OUTPUT',
|
|
196
|
-
);
|
|
197
|
-
assert.equal(ops.readTask(ctx.db, 'parent-1').status, 'pending');
|
|
198
|
-
} finally {
|
|
199
|
-
cleanup(ctx);
|
|
200
|
-
}
|
|
201
|
-
});
|
|
202
|
-
|
|
203
|
-
test('expandTask: duplicate child id → INVALID_OUTPUT', async () => {
|
|
204
|
-
const ctx = tmpDb();
|
|
205
|
-
try {
|
|
206
|
-
seedParent(ctx.db);
|
|
207
|
-
const dup = [
|
|
208
|
-
{ id: 'same', title: 'First child', type: 'feature', priority: 'P2' },
|
|
209
|
-
{ id: 'same', title: 'Second child', type: 'feature', priority: 'P2' },
|
|
210
|
-
];
|
|
211
|
-
await assert.rejects(
|
|
212
|
-
expandTask(ctx.db, { id: 'parent-1', llmClient: fakeLlm(dup) }),
|
|
213
|
-
(err) => err instanceof TaskExpandError && err.code === 'INVALID_OUTPUT',
|
|
214
|
-
);
|
|
215
|
-
} finally {
|
|
216
|
-
cleanup(ctx);
|
|
217
|
-
}
|
|
218
|
-
});
|
|
219
|
-
|
|
220
|
-
test('expandTask: tx rollback — child id collides with existing task → parent status unchanged, no children inserted', async () => {
|
|
221
|
-
const ctx = tmpDb();
|
|
222
|
-
try {
|
|
223
|
-
seedParent(ctx.db);
|
|
224
|
-
// Pre-create a task with the same id one of the children will try
|
|
225
|
-
ops.createTask(ctx.db, {
|
|
226
|
-
id: 'child-1',
|
|
227
|
-
title: 'Pre-existing task',
|
|
228
|
-
type: 'bugfix',
|
|
229
|
-
priority: 'P3',
|
|
230
|
-
});
|
|
231
|
-
await assert.rejects(
|
|
232
|
-
expandTask(ctx.db, { id: 'parent-1', llmClient: fakeLlm(HAPPY_CHILDREN) }),
|
|
233
|
-
/* any error is acceptable — the point is no partial state */
|
|
234
|
-
);
|
|
235
|
-
assert.equal(ops.readTask(ctx.db, 'parent-1').status, 'pending');
|
|
236
|
-
// child-2 / child-3 must NOT have been inserted
|
|
237
|
-
assert.equal(ops.readTask(ctx.db, 'child-2'), null);
|
|
238
|
-
assert.equal(ops.readTask(ctx.db, 'child-3'), null);
|
|
239
|
-
} finally {
|
|
240
|
-
cleanup(ctx);
|
|
241
|
-
}
|
|
242
|
-
});
|
|
243
|
-
|
|
244
|
-
test('expandTask: children inherit parent tag', async () => {
|
|
245
|
-
const ctx = tmpDb();
|
|
246
|
-
try {
|
|
247
|
-
seedParent(ctx.db, { tag: 'feat-auth' });
|
|
248
|
-
await expandTask(ctx.db, {
|
|
249
|
-
id: 'parent-1',
|
|
250
|
-
llmClient: fakeLlm(HAPPY_CHILDREN),
|
|
251
|
-
});
|
|
252
|
-
for (const child of HAPPY_CHILDREN) {
|
|
253
|
-
const row = ops.readTask(ctx.db, child.id);
|
|
254
|
-
assert.equal(row.tag, 'feat-auth');
|
|
255
|
-
}
|
|
256
|
-
} finally {
|
|
257
|
-
cleanup(ctx);
|
|
258
|
-
}
|
|
259
|
-
});
|
|
260
|
-
|
|
261
|
-
test('expandTask blocks new children when baseline authority is missing, draft, or migrated', async () => {
|
|
262
|
-
for (const baseline of ['missing', 'draft', 'migrated']) {
|
|
263
|
-
const ctx = tmpDb();
|
|
264
|
-
try {
|
|
265
|
-
seedParent(ctx.db);
|
|
266
|
-
if (baseline === 'missing') ctx.db.prepare('DELETE FROM baselines').run();
|
|
267
|
-
else if (baseline === 'draft') {
|
|
268
|
-
ctx.db.prepare("UPDATE baselines SET status = 'draft' WHERE id = 'test-baseline'").run();
|
|
269
|
-
} else {
|
|
270
|
-
ctx.db.prepare(
|
|
271
|
-
"UPDATE baselines SET mode = 'migrated', status = 'adopting' WHERE id = 'test-baseline'",
|
|
272
|
-
).run();
|
|
273
|
-
}
|
|
274
|
-
await assert.rejects(
|
|
275
|
-
expandTask(ctx.db, {
|
|
276
|
-
id: 'parent-1', llmClient: fakeLlm(HAPPY_CHILDREN), rootDir: ctx.dir,
|
|
277
|
-
}),
|
|
278
|
-
(error) => error.code === 'BASELINE_NOT_READY',
|
|
279
|
-
);
|
|
280
|
-
assert.equal(ops.readTask(ctx.db, 'child-1'), null);
|
|
281
|
-
assert.equal(ops.readTask(ctx.db, 'parent-1').status, 'pending');
|
|
282
|
-
} finally { cleanup(ctx); }
|
|
283
|
-
}
|
|
284
|
-
});
|
|
285
|
-
|
|
286
|
-
test('expandTask preserves active ordinary and approved incident change ownership while adoption is incomplete', async () => {
|
|
287
|
-
for (const kind of ['quick', 'incident']) {
|
|
288
|
-
const ctx = tmpDb();
|
|
289
|
-
try {
|
|
290
|
-
if (kind === 'incident') {
|
|
291
|
-
ctx.db.prepare(
|
|
292
|
-
"UPDATE baselines SET mode = 'brownfield', status = 'adopting' WHERE id = 'test-baseline'",
|
|
293
|
-
).run();
|
|
294
|
-
}
|
|
295
|
-
const changeId = `expand-${kind}`;
|
|
296
|
-
changes.createChange(ctx.db, {
|
|
297
|
-
id: changeId,
|
|
298
|
-
title: kind === 'incident' ? 'Recover production expansion' : 'Continue planned expansion',
|
|
299
|
-
kind,
|
|
300
|
-
intent: 'Keep every expanded child inside the already-authorized continuous change.',
|
|
301
|
-
...(kind === 'incident' ? {
|
|
302
|
-
baseline_bypass: {
|
|
303
|
-
approved_by: 'incident-commander',
|
|
304
|
-
reason: 'Production recovery requires a bounded task expansion before adoption converges.',
|
|
305
|
-
},
|
|
306
|
-
} : {}),
|
|
307
|
-
}, { rootDir: ctx.dir });
|
|
308
|
-
seedParent(ctx.db, { change_id: changeId });
|
|
309
|
-
if (kind === 'quick') {
|
|
310
|
-
ctx.db.prepare(
|
|
311
|
-
"UPDATE baselines SET mode = 'brownfield', status = 'adopting' WHERE id = 'test-baseline'",
|
|
312
|
-
).run();
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
const result = await expandTask(ctx.db, {
|
|
316
|
-
id: 'parent-1', llmClient: fakeLlm(HAPPY_CHILDREN), rootDir: ctx.dir,
|
|
317
|
-
});
|
|
318
|
-
assert.equal(result.children.length, HAPPY_CHILDREN.length);
|
|
319
|
-
for (const child of HAPPY_CHILDREN) {
|
|
320
|
-
assert.equal(ops.readTask(ctx.db, child.id).change_id, changeId);
|
|
321
|
-
}
|
|
322
|
-
} finally { cleanup(ctx); }
|
|
323
|
-
}
|
|
324
|
-
});
|
|
@@ -1,158 +0,0 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
const test = require('node:test');
|
|
4
|
-
const assert = require('node:assert/strict');
|
|
5
|
-
|
|
6
|
-
const { computeWaves } = require('./topo.cjs');
|
|
7
|
-
|
|
8
|
-
function makeTask(id, deps = []) {
|
|
9
|
-
return { id, deps };
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
function idsSet(ids) {
|
|
13
|
-
return new Set(ids);
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
function asWaveSets(waves) {
|
|
17
|
-
return waves.map(idsSet);
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
test('topo: empty array → empty waves and cycles', () => {
|
|
21
|
-
const r = computeWaves([]);
|
|
22
|
-
assert.deepEqual(r.waves, []);
|
|
23
|
-
assert.deepEqual(r.cycles, []);
|
|
24
|
-
});
|
|
25
|
-
|
|
26
|
-
test('topo: single task with no deps → single wave', () => {
|
|
27
|
-
const r = computeWaves([makeTask('A')]);
|
|
28
|
-
assert.deepEqual(r.waves, [['A']]);
|
|
29
|
-
assert.deepEqual(r.cycles, []);
|
|
30
|
-
});
|
|
31
|
-
|
|
32
|
-
test('topo fixture 1: linear chain A, B(A), C(B) → three serial waves', () => {
|
|
33
|
-
const r = computeWaves([
|
|
34
|
-
makeTask('A'),
|
|
35
|
-
makeTask('B', ['A']),
|
|
36
|
-
makeTask('C', ['B']),
|
|
37
|
-
]);
|
|
38
|
-
assert.deepEqual(asWaveSets(r.waves), [idsSet(['A']), idsSet(['B']), idsSet(['C'])]);
|
|
39
|
-
assert.deepEqual(r.cycles, []);
|
|
40
|
-
});
|
|
41
|
-
|
|
42
|
-
test('topo fixture 2: fan-out A→{B,C} + independent D→E → 2 waves', () => {
|
|
43
|
-
const r = computeWaves([
|
|
44
|
-
makeTask('A'),
|
|
45
|
-
makeTask('B', ['A']),
|
|
46
|
-
makeTask('C', ['A']),
|
|
47
|
-
makeTask('D'),
|
|
48
|
-
makeTask('E', ['D']),
|
|
49
|
-
]);
|
|
50
|
-
assert.equal(r.waves.length, 2);
|
|
51
|
-
assert.deepEqual(asWaveSets(r.waves), [
|
|
52
|
-
idsSet(['A', 'D']),
|
|
53
|
-
idsSet(['B', 'C', 'E']),
|
|
54
|
-
]);
|
|
55
|
-
assert.deepEqual(r.cycles, []);
|
|
56
|
-
});
|
|
57
|
-
|
|
58
|
-
test('topo fixture 3: double diamond merges waves correctly', () => {
|
|
59
|
-
const r = computeWaves([
|
|
60
|
-
makeTask('A'),
|
|
61
|
-
makeTask('B', ['A']),
|
|
62
|
-
makeTask('C', ['A']),
|
|
63
|
-
makeTask('D', ['B', 'C']),
|
|
64
|
-
makeTask('E'),
|
|
65
|
-
makeTask('F', ['E']),
|
|
66
|
-
makeTask('G', ['E']),
|
|
67
|
-
makeTask('H', ['F', 'G']),
|
|
68
|
-
]);
|
|
69
|
-
assert.deepEqual(asWaveSets(r.waves), [
|
|
70
|
-
idsSet(['A', 'E']),
|
|
71
|
-
idsSet(['B', 'C', 'F', 'G']),
|
|
72
|
-
idsSet(['D', 'H']),
|
|
73
|
-
]);
|
|
74
|
-
assert.deepEqual(r.cycles, []);
|
|
75
|
-
});
|
|
76
|
-
|
|
77
|
-
test('topo fixture 4: 2-node cycle A↔B → cycle reported, no waves', () => {
|
|
78
|
-
const r = computeWaves([
|
|
79
|
-
makeTask('A', ['B']),
|
|
80
|
-
makeTask('B', ['A']),
|
|
81
|
-
]);
|
|
82
|
-
assert.deepEqual(r.waves, []);
|
|
83
|
-
assert.equal(r.cycles.length, 1);
|
|
84
|
-
assert.deepEqual(idsSet(r.cycles[0]), idsSet(['A', 'B']));
|
|
85
|
-
});
|
|
86
|
-
|
|
87
|
-
test('topo fixture 5: two independent cycles A↔B and C↔D → both reported', () => {
|
|
88
|
-
const r = computeWaves([
|
|
89
|
-
makeTask('A', ['B']),
|
|
90
|
-
makeTask('B', ['A']),
|
|
91
|
-
makeTask('C', ['D']),
|
|
92
|
-
makeTask('D', ['C']),
|
|
93
|
-
]);
|
|
94
|
-
assert.deepEqual(r.waves, []);
|
|
95
|
-
assert.equal(r.cycles.length, 2);
|
|
96
|
-
const cycleSets = r.cycles.map((c) => idsSet(c));
|
|
97
|
-
const expected = [idsSet(['A', 'B']), idsSet(['C', 'D'])];
|
|
98
|
-
for (const exp of expected) {
|
|
99
|
-
const found = cycleSets.some(
|
|
100
|
-
(s) => s.size === exp.size && [...s].every((x) => exp.has(x)),
|
|
101
|
-
);
|
|
102
|
-
assert.ok(found, `cycle ${[...exp].join(',')} not reported`);
|
|
103
|
-
}
|
|
104
|
-
});
|
|
105
|
-
|
|
106
|
-
test('topo: self-loop counts as cycle', () => {
|
|
107
|
-
const r = computeWaves([makeTask('A', ['A'])]);
|
|
108
|
-
assert.deepEqual(r.waves, []);
|
|
109
|
-
assert.equal(r.cycles.length, 1);
|
|
110
|
-
assert.deepEqual(r.cycles[0], ['A']);
|
|
111
|
-
});
|
|
112
|
-
|
|
113
|
-
test('topo: cycle plus acyclic tail — tail is waved, cycle isolated', () => {
|
|
114
|
-
const r = computeWaves([
|
|
115
|
-
makeTask('A', ['B']),
|
|
116
|
-
makeTask('B', ['A']),
|
|
117
|
-
makeTask('X'),
|
|
118
|
-
makeTask('Y', ['X']),
|
|
119
|
-
]);
|
|
120
|
-
assert.deepEqual(asWaveSets(r.waves), [idsSet(['X']), idsSet(['Y'])]);
|
|
121
|
-
assert.equal(r.cycles.length, 1);
|
|
122
|
-
assert.deepEqual(idsSet(r.cycles[0]), idsSet(['A', 'B']));
|
|
123
|
-
});
|
|
124
|
-
|
|
125
|
-
test('topo: external deps (ids not in task_ids) are treated as satisfied', () => {
|
|
126
|
-
const r = computeWaves([makeTask('A', ['X'])]);
|
|
127
|
-
assert.deepEqual(r.waves, [['A']]);
|
|
128
|
-
assert.deepEqual(r.cycles, []);
|
|
129
|
-
});
|
|
130
|
-
|
|
131
|
-
test('topo: input order does not change wave membership', () => {
|
|
132
|
-
const tasks = [
|
|
133
|
-
makeTask('A'),
|
|
134
|
-
makeTask('B', ['A']),
|
|
135
|
-
makeTask('C', ['A']),
|
|
136
|
-
makeTask('D', ['B', 'C']),
|
|
137
|
-
];
|
|
138
|
-
const r1 = computeWaves(tasks);
|
|
139
|
-
const r2 = computeWaves([...tasks].reverse());
|
|
140
|
-
assert.deepEqual(asWaveSets(r1.waves), asWaveSets(r2.waves));
|
|
141
|
-
assert.deepEqual(r1.cycles, r2.cycles);
|
|
142
|
-
});
|
|
143
|
-
|
|
144
|
-
test('topo: rejects non-array input', () => {
|
|
145
|
-
assert.throws(() => computeWaves(null), TypeError);
|
|
146
|
-
assert.throws(() => computeWaves({}), TypeError);
|
|
147
|
-
});
|
|
148
|
-
|
|
149
|
-
test('topo: 3-node cycle A→B→C→A surfaced intact', () => {
|
|
150
|
-
const r = computeWaves([
|
|
151
|
-
makeTask('A', ['C']),
|
|
152
|
-
makeTask('B', ['A']),
|
|
153
|
-
makeTask('C', ['B']),
|
|
154
|
-
]);
|
|
155
|
-
assert.deepEqual(r.waves, []);
|
|
156
|
-
assert.equal(r.cycles.length, 1);
|
|
157
|
-
assert.deepEqual(idsSet(r.cycles[0]), idsSet(['A', 'B', 'C']));
|
|
158
|
-
});
|
|
@@ -1,125 +0,0 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
// Phase 5.2 — Circuit breaker:
|
|
4
|
-
// per-task consecutive failures ≥3 → trip; tripped tasks refused spawn.
|
|
5
|
-
|
|
6
|
-
const test = require('node:test');
|
|
7
|
-
const assert = require('node:assert/strict');
|
|
8
|
-
const fs = require('node:fs');
|
|
9
|
-
const os = require('node:os');
|
|
10
|
-
const path = require('node:path');
|
|
11
|
-
|
|
12
|
-
const { initStateDb, closeStateDb } = require('../lib/state-db.cjs');
|
|
13
|
-
const ops = require('../lib/state-ops.cjs');
|
|
14
|
-
|
|
15
|
-
function freshDb() {
|
|
16
|
-
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ubp-cb-'));
|
|
17
|
-
const file = path.join(dir, 'state.db');
|
|
18
|
-
const init = initStateDb(file);
|
|
19
|
-
return { dir, db: init.db };
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
function teardown(dir, db) {
|
|
23
|
-
try { closeStateDb(db); } catch (_) { /* ignore */ }
|
|
24
|
-
try { fs.rmSync(dir, { recursive: true, force: true }); } catch (_) { /* ignore */ }
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
function seedTask(db, id) {
|
|
28
|
-
ops.createTask(db, { id, title: 'cb target', type: 'feature', priority: 'P1' });
|
|
29
|
-
return id;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
test('isCircuitTripped: unrecorded task → false', () => {
|
|
33
|
-
const { dir, db } = freshDb();
|
|
34
|
-
try {
|
|
35
|
-
seedTask(db, 'cb-1');
|
|
36
|
-
assert.equal(ops.isCircuitTripped(db, 'cb-1'), false);
|
|
37
|
-
} finally { teardown(dir, db); }
|
|
38
|
-
});
|
|
39
|
-
|
|
40
|
-
test('recordTaskFailure: 3 consecutive → trips + task_circuit_broken event', () => {
|
|
41
|
-
const { dir, db } = freshDb();
|
|
42
|
-
try {
|
|
43
|
-
seedTask(db, 'cb-2');
|
|
44
|
-
ops.recordTaskFailure(db, 'cb-2', { reason: 'test1' });
|
|
45
|
-
assert.equal(ops.isCircuitTripped(db, 'cb-2'), false);
|
|
46
|
-
ops.recordTaskFailure(db, 'cb-2', { reason: 'test2' });
|
|
47
|
-
assert.equal(ops.isCircuitTripped(db, 'cb-2'), false);
|
|
48
|
-
ops.recordTaskFailure(db, 'cb-2', { reason: 'test3' });
|
|
49
|
-
assert.equal(ops.isCircuitTripped(db, 'cb-2'), true);
|
|
50
|
-
|
|
51
|
-
const { events } = ops.subscribeEventsSince(db, { since_id: 0, limit: 500 });
|
|
52
|
-
const brokenEvents = events.filter((e) => e.type === 'task_circuit_broken' && e.task_id === 'cb-2');
|
|
53
|
-
assert.equal(brokenEvents.length, 1, 'circuit broken event should fire exactly once');
|
|
54
|
-
const failures = events.filter((e) => e.type === 'task_failure' && e.task_id === 'cb-2');
|
|
55
|
-
assert.equal(failures.length, 3);
|
|
56
|
-
} finally { teardown(dir, db); }
|
|
57
|
-
});
|
|
58
|
-
|
|
59
|
-
test('recordTaskFailure: 4th + 5th failure does not re-emit task_circuit_broken', () => {
|
|
60
|
-
const { dir, db } = freshDb();
|
|
61
|
-
try {
|
|
62
|
-
seedTask(db, 'cb-3');
|
|
63
|
-
for (let i = 0; i < 5; i += 1) {
|
|
64
|
-
ops.recordTaskFailure(db, 'cb-3', { reason: `test${i}` });
|
|
65
|
-
}
|
|
66
|
-
assert.equal(ops.isCircuitTripped(db, 'cb-3'), true);
|
|
67
|
-
const { events } = ops.subscribeEventsSince(db, { since_id: 0, limit: 500 });
|
|
68
|
-
const brokenEvents = events.filter((e) => e.type === 'task_circuit_broken' && e.task_id === 'cb-3');
|
|
69
|
-
assert.equal(brokenEvents.length, 1, 'should only emit on the trip transition');
|
|
70
|
-
} finally { teardown(dir, db); }
|
|
71
|
-
});
|
|
72
|
-
|
|
73
|
-
test('resetCircuitBreaker: clears count and tripped_at', () => {
|
|
74
|
-
const { dir, db } = freshDb();
|
|
75
|
-
try {
|
|
76
|
-
seedTask(db, 'cb-4');
|
|
77
|
-
ops.recordTaskFailure(db, 'cb-4', { reason: 'x' });
|
|
78
|
-
ops.recordTaskFailure(db, 'cb-4', { reason: 'x' });
|
|
79
|
-
ops.recordTaskFailure(db, 'cb-4', { reason: 'x' });
|
|
80
|
-
assert.equal(ops.isCircuitTripped(db, 'cb-4'), true);
|
|
81
|
-
|
|
82
|
-
ops.resetCircuitBreaker(db, 'cb-4');
|
|
83
|
-
assert.equal(ops.isCircuitTripped(db, 'cb-4'), false);
|
|
84
|
-
|
|
85
|
-
// Next failure starts count at 1, not accumulating pre-reset.
|
|
86
|
-
ops.recordTaskFailure(db, 'cb-4', { reason: 'x' });
|
|
87
|
-
assert.equal(ops.isCircuitTripped(db, 'cb-4'), false);
|
|
88
|
-
ops.recordTaskFailure(db, 'cb-4', { reason: 'x' });
|
|
89
|
-
assert.equal(ops.isCircuitTripped(db, 'cb-4'), false);
|
|
90
|
-
} finally { teardown(dir, db); }
|
|
91
|
-
});
|
|
92
|
-
|
|
93
|
-
test('admissionCheck: tripped task → can_spawn=false, recommended=blocked_by_breaker', () => {
|
|
94
|
-
const { dir, db } = freshDb();
|
|
95
|
-
try {
|
|
96
|
-
seedTask(db, 'cb-5');
|
|
97
|
-
for (let i = 0; i < 3; i += 1) ops.recordTaskFailure(db, 'cb-5', { reason: 'x' });
|
|
98
|
-
|
|
99
|
-
const verdict = ops.admissionCheck(db, 'cb-5');
|
|
100
|
-
assert.equal(verdict.can_spawn, false);
|
|
101
|
-
assert.equal(verdict.recommended_action, 'blocked_by_breaker');
|
|
102
|
-
} finally { teardown(dir, db); }
|
|
103
|
-
});
|
|
104
|
-
|
|
105
|
-
test('admissionCheck: reset after trip → can_spawn=true', () => {
|
|
106
|
-
const { dir, db } = freshDb();
|
|
107
|
-
try {
|
|
108
|
-
seedTask(db, 'cb-6');
|
|
109
|
-
for (let i = 0; i < 3; i += 1) ops.recordTaskFailure(db, 'cb-6', { reason: 'x' });
|
|
110
|
-
ops.resetCircuitBreaker(db, 'cb-6');
|
|
111
|
-
const verdict = ops.admissionCheck(db, 'cb-6');
|
|
112
|
-
assert.equal(verdict.can_spawn, true);
|
|
113
|
-
} finally { teardown(dir, db); }
|
|
114
|
-
});
|
|
115
|
-
|
|
116
|
-
test('custom threshold: fail_threshold=2 trips on 2nd failure', () => {
|
|
117
|
-
const { dir, db } = freshDb();
|
|
118
|
-
try {
|
|
119
|
-
seedTask(db, 'cb-7');
|
|
120
|
-
ops.recordTaskFailure(db, 'cb-7', { reason: 'x', fail_threshold: 2 });
|
|
121
|
-
assert.equal(ops.isCircuitTripped(db, 'cb-7'), false);
|
|
122
|
-
ops.recordTaskFailure(db, 'cb-7', { reason: 'x', fail_threshold: 2 });
|
|
123
|
-
assert.equal(ops.isCircuitTripped(db, 'cb-7'), true);
|
|
124
|
-
} finally { teardown(dir, db); }
|
|
125
|
-
});
|