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.
Files changed (163) hide show
  1. package/.ultra-template/docs/research/README.md +11 -7
  2. package/.ultra-template/specs/architecture.md +36 -36
  3. package/.ultra-template/specs/discovery.md +46 -33
  4. package/.ultra-template/specs/product.md +38 -29
  5. package/.ultra-template/specs/research-distillate.md +48 -0
  6. package/.ultra-template/tasks/contexts/TEMPLATE.md +11 -28
  7. package/CHANGELOG.md +77 -1
  8. package/README.md +46 -24
  9. package/adapters/_shared/codex-assets.cjs +6 -2
  10. package/adapters/_shared/file-ops.cjs +6 -2
  11. package/adapters/_shared/handbook.cjs +2 -1
  12. package/adapters/claude.js +3 -2
  13. package/adapters/codex.js +1 -0
  14. package/adapters/kimi.js +3 -2
  15. package/adapters/opencode.js +3 -2
  16. package/agents/review-coordinator.md +9 -6
  17. package/commands/ultra-change.md +1 -1
  18. package/commands/ultra-plan.md +1 -1
  19. package/commands/ultra-research.md +1 -1
  20. package/commands/ultra-think.md +2 -2
  21. package/docs/AGENT-CONTEXT.md +43 -19
  22. package/docs/ARCHITECTURE.md +54 -13
  23. package/docs/PLAN.zh-CN.md +4 -2
  24. package/docs/ROADMAP.md +10 -4
  25. package/docs/RUNTIME-COMPAT-MATRIX.md +4 -4
  26. package/docs/STATE-DB-ACCESS-POLICY.md +33 -29
  27. package/docs/USER-HANDBOOK-CONTRACT.md +8 -2
  28. package/docs/WORKFLOW-LIFECYCLE.md +342 -0
  29. package/hooks/README.md +6 -1
  30. package/hooks/health_check.py +12 -3
  31. package/hooks/subagent_tracker.py +52 -77
  32. package/mcp-server/hook-event.cjs +34 -0
  33. package/mcp-server/lib/archive-journal.cjs +17 -5
  34. package/mcp-server/lib/baseline-workflow.cjs +158 -18
  35. package/mcp-server/lib/change-workflow.cjs +598 -85
  36. package/mcp-server/lib/context-spine.cjs +305 -25
  37. package/mcp-server/lib/decision-dialogue.cjs +713 -0
  38. package/mcp-server/lib/doctor.cjs +42 -3
  39. package/mcp-server/lib/init-project.cjs +210 -16
  40. package/mcp-server/lib/plan-store.cjs +4 -1
  41. package/mcp-server/lib/pricing.cjs +7 -10
  42. package/mcp-server/lib/project-breadcrumb.cjs +15 -0
  43. package/mcp-server/lib/projector.cjs +57 -3
  44. package/mcp-server/lib/spec-learning.cjs +79 -2
  45. package/mcp-server/lib/state-db.cjs +168 -8
  46. package/mcp-server/lib/state-ops.cjs +160 -9
  47. package/mcp-server/lib/workflow-state.cjs +2305 -0
  48. package/mcp-server/server.cjs +215 -15
  49. package/orchestrator/daemon.cjs +1 -2
  50. package/orchestrator/dispatch-rules.cjs +5 -31
  51. package/orchestrator/planner/plan-builder.cjs +21 -12
  52. package/package.json +13 -3
  53. package/skills/ultra-change/SKILL.md +94 -80
  54. package/skills/ultra-change/references/change-contract.md +36 -0
  55. package/skills/ultra-deliver/SKILL.md +76 -79
  56. package/skills/ultra-deliver/references/baseline-reconciliation.md +20 -0
  57. package/skills/ultra-dev/SKILL.md +62 -59
  58. package/skills/ultra-doctor/SKILL.md +61 -82
  59. package/skills/ultra-init/SKILL.md +101 -103
  60. package/skills/ultra-init/references/brownfield-adoption.md +2 -2
  61. package/skills/ultra-plan/SKILL.md +99 -85
  62. package/skills/ultra-plan/references/semantic-preflight.md +18 -0
  63. package/skills/ultra-research/SKILL.md +102 -60
  64. package/skills/ultra-research/references/00-problem-validation.md +36 -0
  65. package/skills/ultra-research/references/01-opportunity-discovery.md +33 -0
  66. package/skills/ultra-research/references/02-market-assessment.md +30 -0
  67. package/skills/ultra-research/references/03-competitive-landscape.md +31 -0
  68. package/skills/ultra-research/references/04-product-strategy.md +32 -0
  69. package/skills/ultra-research/references/05-assumptions-validation.md +31 -0
  70. package/skills/ultra-research/references/10-user-personas.md +29 -0
  71. package/skills/ultra-research/references/11-user-scenarios.md +28 -0
  72. package/skills/ultra-research/references/20-user-stories.md +30 -0
  73. package/skills/ultra-research/references/21-features-scope.md +29 -0
  74. package/skills/ultra-research/references/22-success-metrics.md +28 -0
  75. package/skills/ultra-research/references/30-architecture-context.md +30 -0
  76. package/skills/ultra-research/references/31-solution-strategy.md +28 -0
  77. package/skills/ultra-research/references/32-building-blocks.md +28 -0
  78. package/skills/ultra-research/references/40-deployment.md +29 -0
  79. package/skills/ultra-research/references/41-quality-risks.md +28 -0
  80. package/skills/ultra-research/references/99-synthesis.md +35 -0
  81. package/skills/ultra-review/SKILL.md +63 -73
  82. package/skills/ultra-review/references/review-modes.md +27 -0
  83. package/skills/ultra-review/references/unified-schema.md +25 -0
  84. package/skills/ultra-review/scripts/review_wait.py +40 -3
  85. package/skills/ultra-status/SKILL.md +41 -41
  86. package/skills/ultra-test/SKILL.md +54 -55
  87. package/skills/ultra-think/SKILL.md +26 -12
  88. package/skills/ultra-think/references/decision-dialogue.md +130 -0
  89. package/skills/ultra-verify/SKILL.md +1 -1
  90. package/skills/ultra-verify/references/{confidence-system.md → evidence-status.md} +3 -3
  91. package/spec/cli-protocol.md +17 -3
  92. package/spec/mcp-tools.yaml +683 -64
  93. package/spec/schemas/baseline-reconciliation.v1.schema.json +73 -0
  94. package/spec/schemas/delivery-report.v1.schema.json +111 -0
  95. package/spec/schemas/mcp-tools.schema.json +1 -1
  96. package/spec/schemas/state-db.sql +140 -1
  97. package/spec/schemas/tasks.v4.5.schema.json +8 -1
  98. package/spec/schemas/test-report.v1.schema.json +194 -0
  99. package/ultra-tools/cli.cjs +1 -1
  100. package/ultra-tools/commands/migrate.cjs +3 -3
  101. package/ultra-tools/commands/status.cjs +228 -12
  102. package/.ultra-template/delivery-report.json +0 -9
  103. package/.ultra-template/test-report.json +0 -8
  104. package/adapters/_shared/tests/file-ops.test.cjs +0 -54
  105. package/adapters/_shared/tests/frontmatter.test.cjs +0 -47
  106. package/adapters/_shared/tests/handbook.test.cjs +0 -101
  107. package/adapters/_shared/tests/path-rewrite.test.cjs +0 -60
  108. package/adapters/_shared/tests/provenance.test.cjs +0 -133
  109. package/adapters/_shared/tests/resolve-target.test.cjs +0 -75
  110. package/adapters/_shared/tests/runtime-assets.test.cjs +0 -149
  111. package/adapters/_shared/tests/settings-merge.test.cjs +0 -85
  112. package/adapters/_shared/tests/validate.test.cjs +0 -30
  113. package/adapters/tests/claude.test.cjs +0 -138
  114. package/adapters/tests/codex-hook.test.cjs +0 -142
  115. package/adapters/tests/codex.test.cjs +0 -430
  116. package/adapters/tests/kimi-hook.test.cjs +0 -149
  117. package/adapters/tests/kimi.test.cjs +0 -259
  118. package/adapters/tests/opencode.test.cjs +0 -357
  119. package/mcp-server/lib/archive-journal.test.cjs +0 -35
  120. package/mcp-server/lib/baseline-workflow.test.cjs +0 -477
  121. package/mcp-server/lib/change-workflow.test.cjs +0 -971
  122. package/mcp-server/lib/context-spine.test.cjs +0 -93
  123. package/mcp-server/lib/doctor.test.cjs +0 -148
  124. package/mcp-server/lib/plan-store.test.cjs +0 -132
  125. package/mcp-server/lib/prd-parser.test.cjs +0 -82
  126. package/mcp-server/lib/project-breadcrumb.test.cjs +0 -112
  127. package/mcp-server/lib/runtime-state.test.cjs +0 -92
  128. package/mcp-server/lib/spec-learning.test.cjs +0 -67
  129. package/mcp-server/lib/state-db.test.cjs +0 -314
  130. package/mcp-server/lib/task-expander.test.cjs +0 -169
  131. package/mcp-server/lib/topo.test.cjs +0 -158
  132. package/mcp-server/tests/circuit-breaker.test.cjs +0 -125
  133. package/mcp-server/tests/commit-hash-flow.test.cjs +0 -117
  134. package/mcp-server/tests/concurrency-update.test.cjs +0 -96
  135. package/mcp-server/tests/concurrency.test.cjs +0 -112
  136. package/mcp-server/tests/db-ops.test.cjs +0 -121
  137. package/mcp-server/tests/init-project.test.cjs +0 -393
  138. package/mcp-server/tests/migrate.test.cjs +0 -232
  139. package/mcp-server/tests/projector.test.cjs +0 -162
  140. package/mcp-server/tests/server-contract.test.cjs +0 -1467
  141. package/mcp-server/tests/staleness.test.cjs +0 -190
  142. package/mcp-server/tests/state-db.test.cjs +0 -106
  143. package/mcp-server/tests/state-ops.test.cjs +0 -449
  144. package/mcp-server/tests/tagged-tasks.test.cjs +0 -163
  145. package/mcp-server/tests/telemetry.test.cjs +0 -219
  146. package/orchestrator/planner/plan-builder.test.cjs +0 -165
  147. package/orchestrator/tests/auto-merge.test.cjs +0 -216
  148. package/orchestrator/tests/daemon.test.cjs +0 -311
  149. package/orchestrator/tests/dispatch-rules.test.cjs +0 -124
  150. package/orchestrator/tests/parallel-orchestrator.test.cjs +0 -233
  151. package/orchestrator/tests/phase-8b-gate.test.cjs +0 -183
  152. package/orchestrator/tests/recovery.test.cjs +0 -190
  153. package/orchestrator/tests/session-runner.test.cjs +0 -314
  154. package/orchestrator/tests/skill-miner.test.cjs +0 -147
  155. package/orchestrator/tests/worktree-manager.test.cjs +0 -148
  156. package/skills/ultra-research/references/architecture.md +0 -38
  157. package/skills/ultra-research/references/problem-product.md +0 -39
  158. package/skills/ultra-research/references/quality-delivery.md +0 -34
  159. package/skills/ultra-research/references/synthesis.md +0 -38
  160. package/skills/ultra-research/references/users-scope.md +0 -40
  161. package/ultra-tools/legacy-memory.test.cjs +0 -80
  162. package/ultra-tools/status-command.test.cjs +0 -181
  163. package/ultra-tools/system-command.test.cjs +0 -309
@@ -1,183 +0,0 @@
1
- 'use strict';
2
-
3
- // Phase 8B gate — end-to-end integration verifying the three PLAN gate clauses:
4
- //
5
- // 1. "10-task PRD run → auto-completion rate ≥ 80%"
6
- // 2. "5-slice worktree stress → no .git/config.lock contention"
7
- // 3. "merge-back conflicts correctly identified"
8
- //
9
- // All use the Test-Double command (`node -e '...'`) so there's no LLM cost;
10
- // Phase 8A.1 LLM-backed parse_prd is out of scope here.
11
-
12
- const { test } = require('node:test');
13
- const assert = require('node:assert/strict');
14
- const fs = require('node:fs');
15
- const os = require('node:os');
16
- const path = require('node:path');
17
- const { execFileSync } = require('node:child_process');
18
-
19
- const { initStateDb, closeStateDb } = require('../../mcp-server/lib/state-db.cjs');
20
- const ops = require('../../mcp-server/lib/state-ops.cjs');
21
- const { buildPlan } = require('../planner/plan-builder.cjs');
22
- const parallelOrch = require('../parallel-orchestrator.cjs');
23
- const wtmgr = require('../worktree-manager.cjs');
24
-
25
- const NODE = process.execPath;
26
- function commitScript(filename, content) {
27
- // Create a file (with parent dirs) in the session's CWD and commit it —
28
- // simulates an agent's session work.
29
- return [
30
- '-e',
31
- `
32
- const fs = require('fs');
33
- const path = require('path');
34
- const { execFileSync } = require('child_process');
35
- const f = ${JSON.stringify(filename)};
36
- fs.mkdirSync(path.dirname(f) || '.', { recursive: true });
37
- fs.writeFileSync(f, ${JSON.stringify(content)});
38
- execFileSync('git', ['add', '-A']);
39
- execFileSync('git', ['-c', 'user.email=agent@ubp.dev', '-c', 'user.name=agent',
40
- 'commit', '-q', '-m', 'agent change ' + f]);
41
- process.exit(0);
42
- `,
43
- ];
44
- }
45
-
46
- function mkRepo() {
47
- const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ubp-8b-gate-'));
48
- execFileSync('git', ['init', '-q', '-b', 'main'], { cwd: dir });
49
- execFileSync('git', ['config', 'user.email', 'test@ubp.dev'], { cwd: dir });
50
- execFileSync('git', ['config', 'user.name', 'ubp-test'], { cwd: dir });
51
- fs.writeFileSync(path.join(dir, 'seed.md'), '# seed\n');
52
- fs.writeFileSync(path.join(dir, '.gitignore'), '.ultra/\n');
53
- execFileSync('git', ['add', '-A'], { cwd: dir });
54
- execFileSync('git', ['commit', '-q', '-m', 'seed'], { cwd: dir });
55
- return dir;
56
- }
57
-
58
- function mkDb(repoRoot) {
59
- const dbPath = path.join(repoRoot, '.ultra', 'state.db');
60
- fs.mkdirSync(path.dirname(dbPath), { recursive: true });
61
- const { db } = initStateDb(dbPath);
62
- return db;
63
- }
64
-
65
- function cleanup(repoRoot, db) {
66
- try { if (db) closeStateDb(db); } catch (_) { /* best-effort */ }
67
- try { fs.rmSync(repoRoot, { recursive: true, force: true }); } catch (_) { /* best-effort */ }
68
- }
69
-
70
- // ─── Gate clause 1 — 10-task end-to-end, auto-completion ≥ 80% ────────────
71
-
72
- test('gate 1: 10-task plan with independent files → completion ≥ 80%', async () => {
73
- const repo = mkRepo();
74
- const db = mkDb(repo);
75
- try {
76
- const tasks = [];
77
- for (let i = 1; i <= 10; i++) {
78
- const id = `T${String(i).padStart(2, '0')}`;
79
- const file = `feat/${id}.txt`;
80
- ops.createTask(db, {
81
- id, title: `feature ${i}`,
82
- type: 'feature', priority: 'P2',
83
- complexity: 2, files_modified: [file],
84
- });
85
- tasks.push({ id, deps: [], complexity: 2, files_modified: [file] });
86
- }
87
- const plan = buildPlan(tasks);
88
- // All files independent → single parallel wave.
89
- assert.equal(plan.waves.length, 1);
90
- assert.equal(plan.waves[0].parallel, true);
91
-
92
- const { results } = await parallelOrch.runPlan({
93
- db, repoRoot: repo, plan,
94
- runtimes: ['claude'],
95
- command: NODE,
96
- commandArgsFor: (task) => commitScript(task.files_modified[0], `${task.id}\n`),
97
- autoMerge: true,
98
- mergeBaseBranch: 'main',
99
- });
100
-
101
- const completed = results.filter((r) => r.status === 'completed').length;
102
- const completionRate = completed / tasks.length;
103
- assert.ok(completionRate >= 0.8, `completion rate ${completionRate} below 0.8`);
104
-
105
- // Independent files → all merges clean → main HEAD reflects each file.
106
- const merged = results.filter((r) => r.merge && r.merge.merged).length;
107
- assert.ok(merged >= 8, `expected ≥8 clean merges, got ${merged}`);
108
- for (let i = 1; i <= 10; i++) {
109
- const id = `T${String(i).padStart(2, '0')}`;
110
- const file = path.join(repo, 'feat', `${id}.txt`);
111
- if (fs.existsSync(file)) {
112
- // if merged, the file must be present on main
113
- const content = fs.readFileSync(file, 'utf8');
114
- assert.equal(content, `${id}\n`);
115
- }
116
- }
117
- } finally { cleanup(repo, db); }
118
- });
119
-
120
- // ─── Gate clause 2 — 5-slice stress, no .git/config.lock contention ───────
121
-
122
- test('gate 2: 5 rapid worktree allocations → no git lock errors, all tracked', () => {
123
- const repo = mkRepo();
124
- try {
125
- const sids = ['g2-1', 'g2-2', 'g2-3', 'g2-4', 'g2-5'];
126
- // Back-to-back — Node single-thread execFileSync serializes git calls.
127
- for (const sid of sids) wtmgr.allocate({ repoRoot: repo, sid });
128
- const active = wtmgr.listActive(repo);
129
- assert.equal(active.length, 5);
130
- const seenSids = new Set(active.map((a) => a.sid));
131
- for (const sid of sids) assert.ok(seenSids.has(sid), `missing slice ${sid}`);
132
- // Cleanup and re-check — releaseAll must fully drain.
133
- wtmgr.releaseAll(repo);
134
- assert.equal(wtmgr.listActive(repo).length, 0);
135
- } finally { cleanup(repo); }
136
- });
137
-
138
- // ─── Gate clause 3 — merge-back conflict identification ───────────────────
139
-
140
- test('gate 3: 2 parallel tasks on same file → 1 merged, 1 conflict event captured', async () => {
141
- const repo = mkRepo();
142
- const db = mkDb(repo);
143
- try {
144
- ops.createTask(db, {
145
- id: 'X1', title: 'conflict A', type: 'feature', priority: 'P2',
146
- files_modified: ['battle.txt'],
147
- });
148
- ops.createTask(db, {
149
- id: 'X2', title: 'conflict B', type: 'feature', priority: 'P2',
150
- files_modified: ['battle.txt'],
151
- });
152
- // Force concurrent execution so both worktrees fork from the SAME base
153
- // commit. (plan-builder normally serializes these, which would cause X2
154
- // to fork from X1's merged HEAD and fast-forward cleanly.)
155
- const manualPlan = {
156
- waves: [{
157
- id: 1, tasks: ['X1', 'X2'], parallel: true,
158
- reason: 'forced concurrent for conflict repro',
159
- }],
160
- ownership_forecast: { X1: ['battle.txt'], X2: ['battle.txt'] },
161
- conflict_surface: [{ files: ['battle.txt'], tasks: ['X1', 'X2'], recommend: 'sequentialize' }],
162
- estimated_cost_usd: 0,
163
- estimated_duration_min: 0,
164
- cycles: [],
165
- };
166
-
167
- await parallelOrch.runPlan({
168
- db, repoRoot: repo, plan: manualPlan,
169
- runtimes: ['claude'],
170
- command: NODE,
171
- commandArgsFor: (task) => commitScript('battle.txt', `${task.id}-version\n`),
172
- autoMerge: true,
173
- mergeBaseBranch: 'main',
174
- });
175
-
176
- const { events } = ops.subscribeEventsSince(db, 0);
177
- const merged = events.filter((e) => e.type === 'merged_back');
178
- const conflict = events.filter((e) => e.type === 'merge_conflict');
179
- assert.equal(merged.length, 1, 'exactly one task should merge');
180
- assert.equal(conflict.length, 1, 'exactly one task should conflict');
181
- assert.ok(conflict[0].payload.conflict_paths.includes('battle.txt'));
182
- } finally { cleanup(repo, db); }
183
- });
@@ -1,190 +0,0 @@
1
- 'use strict';
2
-
3
- // Phase 5.1 — recoverOnBoot semantics:
4
- // • running + fresh heartbeat → untouched
5
- // • running + lease/heartbeat expired → orphan (handled by 4.5 reaper)
6
- // • orphan + pid is dead → crashed + task_failure event
7
- // • orphan + pid still alive → stays orphan (might recover)
8
- //
9
- // "pid dead" is determined via process.kill(pid, 0) throwing ESRCH.
10
-
11
- const { test } = require('node:test');
12
- const assert = require('node:assert/strict');
13
- const fs = require('node:fs');
14
- const os = require('node:os');
15
- const path = require('node:path');
16
- const { execFileSync } = require('node:child_process');
17
-
18
- const { initStateDb, closeStateDb } = require('../../mcp-server/lib/state-db.cjs');
19
- const ops = require('../../mcp-server/lib/state-ops.cjs');
20
- const runner = require('../session-runner.cjs');
21
- const recovery = require('../recovery.cjs');
22
-
23
- const LONG_SLEEP_CMD = process.execPath;
24
- const LONG_SLEEP_ARGS = ['-e', 'setInterval(() => {}, 60000);'];
25
-
26
- function mkRepo() {
27
- const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ubp-recovery-repo-'));
28
- execFileSync('git', ['init', '-q'], { cwd: dir });
29
- execFileSync('git', ['config', 'user.email', 'test@ubp.dev'], { cwd: dir });
30
- execFileSync('git', ['config', 'user.name', 'ubp-test'], { cwd: dir });
31
- fs.writeFileSync(path.join(dir, 'README.md'), '# test\n');
32
- execFileSync('git', ['add', '-A'], { cwd: dir });
33
- execFileSync('git', ['commit', '-q', '-m', 'seed'], { cwd: dir });
34
- return dir;
35
- }
36
-
37
- function mkDb(repoRoot) {
38
- const dbPath = path.join(repoRoot, '.ultra', 'state.db');
39
- const { db } = initStateDb(dbPath);
40
- return { db, dbPath };
41
- }
42
-
43
- function seedTask(db, id) {
44
- ops.createTask(db, { id, title: 'recovery target', type: 'feature', priority: 'P1' });
45
- return id;
46
- }
47
-
48
- function cleanup(repoRoot, db) {
49
- try { closeStateDb(db); } catch (_) { /* ignore */ }
50
- try { fs.rmSync(repoRoot, { recursive: true, force: true }); } catch (_) { /* ignore */ }
51
- }
52
-
53
- // Create a session row bypassing real spawn — lets us control pid precisely.
54
- function insertRawSession(db, { sid, task_id, pid, status = 'orphan', worktree_path, artifact_dir }) {
55
- const row = ops.createSession(db, {
56
- sid, task_id, runtime: 'claude', pid,
57
- worktree_path: worktree_path || `/tmp/ubp-fake-wt/${sid}`,
58
- artifact_dir: artifact_dir || `/tmp/ubp-fake-art/${sid}`,
59
- });
60
- if (status !== 'running') ops.updateSession(db, sid, { status });
61
- return row;
62
- }
63
-
64
- test('recoverOnBoot: orphan with dead pid → crashed + task_failure event', () => {
65
- const repoRoot = mkRepo();
66
- const { db } = mkDb(repoRoot);
67
- try {
68
- seedTask(db, 'rec-1');
69
- // pid 0x7FFFFFFE is almost certainly not assigned; process.kill will ESRCH.
70
- insertRawSession(db, { sid: 'sess-dead01', task_id: 'rec-1', pid: 0x7FFFFFFE, status: 'orphan' });
71
-
72
- const { events: beforeEvents } = ops.subscribeEventsSince(db, { since_id: 0, limit: 500 });
73
- const result = recovery.recoverOnBoot(db);
74
-
75
- assert.equal(result.count, 1);
76
- assert.equal(result.recovered[0].sid, 'sess-dead01');
77
- assert.equal(result.recovered[0].task_id, 'rec-1');
78
- assert.equal(result.recovered[0].pid_alive, false);
79
-
80
- const row = ops.readSession(db, 'sess-dead01');
81
- assert.equal(row.status, 'crashed');
82
-
83
- const { events: afterEvents } = ops.subscribeEventsSince(db, { since_id: 0, limit: 500 });
84
- const newEvents = afterEvents.slice(beforeEvents.length);
85
- assert.ok(newEvents.some((e) => e.type === 'session_crashed' && e.session_id === 'sess-dead01'));
86
- assert.ok(newEvents.some((e) => e.type === 'task_failure' && e.task_id === 'rec-1'));
87
- } finally {
88
- cleanup(repoRoot, db);
89
- }
90
- });
91
-
92
- test('recoverOnBoot: orphan with alive pid → stays orphan', () => {
93
- const repoRoot = mkRepo();
94
- const { db } = mkDb(repoRoot);
95
- try {
96
- seedTask(db, 'rec-2');
97
- // Current process is definitely alive.
98
- insertRawSession(db, { sid: 'sess-alive01', task_id: 'rec-2', pid: process.pid, status: 'orphan' });
99
-
100
- const result = recovery.recoverOnBoot(db);
101
- assert.equal(result.count, 0);
102
- const row = ops.readSession(db, 'sess-alive01');
103
- assert.equal(row.status, 'orphan');
104
- } finally {
105
- cleanup(repoRoot, db);
106
- }
107
- });
108
-
109
- test('recoverOnBoot: running with fresh heartbeat → unchanged', () => {
110
- const repoRoot = mkRepo();
111
- const { db } = mkDb(repoRoot);
112
- try {
113
- seedTask(db, 'rec-3');
114
- // Running session that will NOT be reaped — fresh heartbeat + future lease.
115
- ops.createSession(db, {
116
- sid: 'sess-healthy',
117
- task_id: 'rec-3',
118
- runtime: 'claude',
119
- pid: process.pid,
120
- worktree_path: '/tmp/ubp-fake-wt/sess-healthy',
121
- artifact_dir: '/tmp/ubp-fake-art/sess-healthy',
122
- });
123
- ops.heartbeatSession(db, 'sess-healthy');
124
-
125
- recovery.recoverOnBoot(db);
126
- const row = ops.readSession(db, 'sess-healthy');
127
- assert.equal(row.status, 'running');
128
- } finally {
129
- cleanup(repoRoot, db);
130
- }
131
- });
132
-
133
- test('recoverOnBoot: first reaps stale running → orphan, then upgrades dead ones to crashed', () => {
134
- const repoRoot = mkRepo();
135
- const { db } = mkDb(repoRoot);
136
- try {
137
- seedTask(db, 'rec-4');
138
- // Running session with expired lease + dead pid.
139
- ops.createSession(db, {
140
- sid: 'sess-stale-dead',
141
- task_id: 'rec-4',
142
- runtime: 'claude',
143
- pid: 0x7FFFFFFE,
144
- worktree_path: '/tmp/ubp-fake-wt/sess-stale-dead',
145
- artifact_dir: '/tmp/ubp-fake-art/sess-stale-dead',
146
- });
147
- ops.updateSession(db, 'sess-stale-dead', {
148
- lease_expires_at: new Date(Date.now() - 60 * 1000).toISOString(),
149
- heartbeat_at: new Date(Date.now() - 60 * 1000).toISOString(),
150
- });
151
-
152
- const result = recovery.recoverOnBoot(db, { graceSeconds: 30 });
153
- // Should have reaped to orphan first, then crashed.
154
- assert.equal(result.count, 1);
155
- assert.equal(result.recovered[0].sid, 'sess-stale-dead');
156
- const row = ops.readSession(db, 'sess-stale-dead');
157
- assert.equal(row.status, 'crashed');
158
- } finally {
159
- cleanup(repoRoot, db);
160
- }
161
- });
162
-
163
- test('integration: kill -9 real child → recoverOnBoot marks crashed', async () => {
164
- const repoRoot = mkRepo();
165
- const { db } = mkDb(repoRoot);
166
- let handle;
167
- try {
168
- seedTask(db, 'rec-int');
169
- handle = runner.spawnSession({
170
- db, repoRoot,
171
- task_id: 'rec-int', runtime: 'claude',
172
- command: LONG_SLEEP_CMD, args: LONG_SLEEP_ARGS,
173
- });
174
- // Force lease/heartbeat into the past so reaper catches it.
175
- ops.updateSession(db, handle.sid, {
176
- lease_expires_at: new Date(Date.now() - 60 * 1000).toISOString(),
177
- heartbeat_at: new Date(Date.now() - 60 * 1000).toISOString(),
178
- });
179
- process.kill(handle.pid, 'SIGKILL');
180
- // Let the kernel reap the zombie.
181
- await new Promise((r) => setTimeout(r, 80));
182
-
183
- const result = recovery.recoverOnBoot(db, { graceSeconds: 30 });
184
- assert.ok(result.recovered.some((r) => r.sid === handle.sid));
185
- const row = ops.readSession(db, handle.sid);
186
- assert.equal(row.status, 'crashed');
187
- } finally {
188
- cleanup(repoRoot, db);
189
- }
190
- });
@@ -1,314 +0,0 @@
1
- 'use strict';
2
-
3
- const { test } = require('node:test');
4
- const assert = require('node:assert/strict');
5
- const fs = require('node:fs');
6
- const os = require('node:os');
7
- const path = require('node:path');
8
- const { execFileSync } = require('node:child_process');
9
-
10
- const { initStateDb, closeStateDb } = require('../../mcp-server/lib/state-db.cjs');
11
- const ops = require('../../mcp-server/lib/state-ops.cjs');
12
- const runner = require('../session-runner.cjs');
13
-
14
- // Short-lived child: keep the process alive until killed.
15
- const LONG_SLEEP_CMD = process.execPath;
16
- const LONG_SLEEP_ARGS = ['-e', 'setInterval(() => {}, 60000);'];
17
-
18
- function mkRepo() {
19
- const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ubp-runner-repo-'));
20
- execFileSync('git', ['init', '-q'], { cwd: dir });
21
- execFileSync('git', ['config', 'user.email', 'test@ubp.dev'], { cwd: dir });
22
- execFileSync('git', ['config', 'user.name', 'ubp-test'], { cwd: dir });
23
- fs.writeFileSync(path.join(dir, 'README.md'), '# test\n');
24
- execFileSync('git', ['add', '-A'], { cwd: dir });
25
- execFileSync('git', ['commit', '-q', '-m', 'seed'], { cwd: dir });
26
- return dir;
27
- }
28
-
29
- function mkDb(repoRoot) {
30
- const dbPath = path.join(repoRoot, '.ultra', 'state.db');
31
- const { db } = initStateDb(dbPath);
32
- return { db, dbPath };
33
- }
34
-
35
- function seedTask(db, id = 't-1') {
36
- ops.createTask(db, { id, title: 'runner target', type: 'feature', priority: 'P1' });
37
- return id;
38
- }
39
-
40
- function cleanup(repoRoot, db) {
41
- try { closeStateDb(db); } catch (_) { /* ignore */ }
42
- try { fs.rmSync(repoRoot, { recursive: true, force: true }); } catch (_) { /* ignore */ }
43
- }
44
-
45
- function isProcessAlive(pid) {
46
- try { process.kill(pid, 0); return true; }
47
- catch (_) { return false; }
48
- }
49
-
50
- test('spawnSession creates worktree, child process, and sessions row', () => {
51
- const repoRoot = mkRepo();
52
- const { db } = mkDb(repoRoot);
53
- let handle;
54
- try {
55
- seedTask(db, 'r-1');
56
- handle = runner.spawnSession({
57
- db, repoRoot,
58
- task_id: 'r-1', runtime: 'claude',
59
- command: LONG_SLEEP_CMD, args: LONG_SLEEP_ARGS,
60
- });
61
- assert.match(handle.sid, /^sess-/);
62
- assert.ok(fs.existsSync(handle.worktree_path));
63
- assert.ok(fs.existsSync(handle.artifact_dir));
64
- assert.ok(handle.pid);
65
- assert.ok(isProcessAlive(handle.pid));
66
-
67
- const row = ops.readSession(db, handle.sid);
68
- assert.equal(row.task_id, 'r-1');
69
- assert.equal(row.status, 'running');
70
- assert.equal(row.pid, handle.pid);
71
- } finally {
72
- if (handle && handle.pid) { try { process.kill(handle.pid, 'SIGKILL'); } catch (_) { /* ignore */ } }
73
- cleanup(repoRoot, db);
74
- }
75
- });
76
-
77
- test('closeSession kills child, marks completed, removes worktree', async () => {
78
- const repoRoot = mkRepo();
79
- const { db } = mkDb(repoRoot);
80
- let handle;
81
- try {
82
- seedTask(db, 'r-close');
83
- handle = runner.spawnSession({
84
- db, repoRoot,
85
- task_id: 'r-close', runtime: 'claude',
86
- command: LONG_SLEEP_CMD, args: LONG_SLEEP_ARGS,
87
- });
88
- const wt = handle.worktree_path;
89
- runner.closeSession(
90
- { db, repoRoot, sid: handle.sid },
91
- { status: 'completed', kill_signal: 'SIGKILL' },
92
- );
93
-
94
- const row = ops.readSession(db, handle.sid);
95
- assert.equal(row.status, 'completed');
96
- // give the kernel a tick to reap the SIGKILL
97
- await new Promise((r) => setTimeout(r, 50));
98
- assert.ok(!isProcessAlive(handle.pid), `pid ${handle.pid} should be dead`);
99
- assert.ok(!fs.existsSync(wt));
100
- } finally {
101
- cleanup(repoRoot, db);
102
- }
103
- });
104
-
105
- test('spawnSession refuses second session without takeover (ADMISSION_DENIED)', () => {
106
- const repoRoot = mkRepo();
107
- const { db } = mkDb(repoRoot);
108
- let first;
109
- try {
110
- seedTask(db, 'r-adm');
111
- first = runner.spawnSession({
112
- db, repoRoot,
113
- task_id: 'r-adm', runtime: 'claude',
114
- command: LONG_SLEEP_CMD, args: LONG_SLEEP_ARGS,
115
- });
116
- assert.throws(
117
- () => runner.spawnSession({
118
- db, repoRoot,
119
- task_id: 'r-adm', runtime: 'codex',
120
- command: LONG_SLEEP_CMD, args: LONG_SLEEP_ARGS,
121
- }),
122
- (err) => err instanceof runner.SessionRunnerError && err.code === 'ADMISSION_DENIED',
123
- );
124
- } finally {
125
- if (first && first.pid) { try { process.kill(first.pid, 'SIGKILL'); } catch (_) { /* ignore */ } }
126
- cleanup(repoRoot, db);
127
- }
128
- });
129
-
130
- test('takeover=true crashes old session and spawns new one', () => {
131
- const repoRoot = mkRepo();
132
- const { db } = mkDb(repoRoot);
133
- let first, second;
134
- try {
135
- seedTask(db, 'r-take');
136
- first = runner.spawnSession({
137
- db, repoRoot,
138
- task_id: 'r-take', runtime: 'claude',
139
- command: LONG_SLEEP_CMD, args: LONG_SLEEP_ARGS,
140
- });
141
- second = runner.spawnSession({
142
- db, repoRoot,
143
- task_id: 'r-take', runtime: 'codex',
144
- command: LONG_SLEEP_CMD, args: LONG_SLEEP_ARGS,
145
- takeover: true,
146
- });
147
- assert.notEqual(second.sid, first.sid);
148
-
149
- const firstRow = ops.readSession(db, first.sid);
150
- assert.equal(firstRow.status, 'crashed');
151
- const secondRow = ops.readSession(db, second.sid);
152
- assert.equal(secondRow.status, 'running');
153
- assert.equal(secondRow.runtime, 'codex');
154
- } finally {
155
- for (const h of [first, second]) {
156
- if (h && h.pid) { try { process.kill(h.pid, 'SIGKILL'); } catch (_) { /* ignore */ } }
157
- }
158
- cleanup(repoRoot, db);
159
- }
160
- });
161
-
162
- test('attachHeartbeat updates heartbeat_at while running', async () => {
163
- const repoRoot = mkRepo();
164
- const { db } = mkDb(repoRoot);
165
- let handle;
166
- try {
167
- seedTask(db, 'r-hb');
168
- handle = runner.spawnSession({
169
- db, repoRoot,
170
- task_id: 'r-hb', runtime: 'claude',
171
- command: LONG_SLEEP_CMD, args: LONG_SLEEP_ARGS,
172
- });
173
- const stop = runner.attachHeartbeat(db, handle.sid, { intervalMs: 20 });
174
- await new Promise((r) => setTimeout(r, 80));
175
- stop();
176
-
177
- const row = ops.readSession(db, handle.sid);
178
- assert.ok(row.heartbeat_at, 'heartbeat_at should be populated');
179
- // lease extended beyond initial default of started_at + 1800s? compare against start
180
- assert.ok(Date.parse(row.heartbeat_at) > 0);
181
- } finally {
182
- if (handle && handle.pid) { try { process.kill(handle.pid, 'SIGKILL'); } catch (_) { /* ignore */ } }
183
- cleanup(repoRoot, db);
184
- }
185
- });
186
-
187
- test('kill -9 child: reapOrphanSessions marks orphan after heartbeat grace expires', async () => {
188
- const repoRoot = mkRepo();
189
- const { db } = mkDb(repoRoot);
190
- let handle;
191
- try {
192
- seedTask(db, 'r-orphan');
193
- handle = runner.spawnSession({
194
- db, repoRoot,
195
- task_id: 'r-orphan', runtime: 'claude',
196
- command: LONG_SLEEP_CMD, args: LONG_SLEEP_ARGS,
197
- });
198
- // force lease + heartbeat into the past so reaper has something to grab
199
- ops.updateSession(db, handle.sid, {
200
- lease_expires_at: new Date(Date.now() - 60 * 1000).toISOString(),
201
- heartbeat_at: new Date(Date.now() - 60 * 1000).toISOString(),
202
- });
203
- process.kill(handle.pid, 'SIGKILL');
204
-
205
- const reaped = ops.reapOrphanSessions(db, { graceSeconds: 30 });
206
- assert.ok(reaped.reaped.includes(handle.sid));
207
- const row = ops.readSession(db, handle.sid);
208
- assert.equal(row.status, 'orphan');
209
- } finally {
210
- cleanup(repoRoot, db);
211
- }
212
- });
213
-
214
- test('concurrent spawns on same task: only one succeeds (admission serialization)', async () => {
215
- const repoRoot = mkRepo();
216
- const { db } = mkDb(repoRoot);
217
- let winner;
218
- try {
219
- seedTask(db, 'r-race');
220
-
221
- const attempts = [0, 1, 2].map((i) =>
222
- Promise.resolve().then(() => {
223
- try {
224
- return runner.spawnSession({
225
- db, repoRoot,
226
- task_id: 'r-race', runtime: ['claude', 'opencode', 'codex'][i],
227
- command: LONG_SLEEP_CMD, args: LONG_SLEEP_ARGS,
228
- });
229
- } catch (err) {
230
- return { error: err.code };
231
- }
232
- }),
233
- );
234
- const results = await Promise.all(attempts);
235
- const winners = results.filter((r) => r && r.sid);
236
- const denied = results.filter((r) => r && r.error === 'ADMISSION_DENIED');
237
- assert.equal(winners.length, 1, `expected 1 winner, got ${winners.length}`);
238
- assert.equal(denied.length, 2, `expected 2 denied, got ${denied.length}`);
239
- winner = winners[0];
240
- } finally {
241
- if (winner && winner.pid) { try { process.kill(winner.pid, 'SIGKILL'); } catch (_) { /* ignore */ } }
242
- cleanup(repoRoot, db);
243
- }
244
- });
245
-
246
- test('spawnSession refuses tripped task (CIRCUIT_TRIPPED, takeover cannot override)', () => {
247
- const repoRoot = mkRepo();
248
- const { db } = mkDb(repoRoot);
249
- try {
250
- seedTask(db, 'r-cb');
251
- for (let i = 0; i < 3; i += 1) ops.recordTaskFailure(db, 'r-cb', { reason: 'test' });
252
- assert.equal(ops.isCircuitTripped(db, 'r-cb'), true);
253
-
254
- const attempt = (takeover) => runner.spawnSession({
255
- db, repoRoot,
256
- task_id: 'r-cb', runtime: 'claude',
257
- command: LONG_SLEEP_CMD, args: LONG_SLEEP_ARGS,
258
- takeover,
259
- });
260
- assert.throws(() => attempt(false), (err) => err.code === 'CIRCUIT_TRIPPED');
261
- assert.throws(() => attempt(true), (err) => err.code === 'CIRCUIT_TRIPPED');
262
- } finally {
263
- cleanup(repoRoot, db);
264
- }
265
- });
266
-
267
- test('spawnSession works again after resetCircuitBreaker', () => {
268
- const repoRoot = mkRepo();
269
- const { db } = mkDb(repoRoot);
270
- let handle;
271
- try {
272
- seedTask(db, 'r-cb-reset');
273
- for (let i = 0; i < 3; i += 1) ops.recordTaskFailure(db, 'r-cb-reset', { reason: 'test' });
274
- ops.resetCircuitBreaker(db, 'r-cb-reset');
275
- handle = runner.spawnSession({
276
- db, repoRoot,
277
- task_id: 'r-cb-reset', runtime: 'claude',
278
- command: LONG_SLEEP_CMD, args: LONG_SLEEP_ARGS,
279
- });
280
- assert.ok(handle.sid);
281
- } finally {
282
- if (handle && handle.pid) { try { process.kill(handle.pid, 'SIGKILL'); } catch (_) { /* ignore */ } }
283
- cleanup(repoRoot, db);
284
- }
285
- });
286
-
287
- test('two sessions on different tasks get independent worktrees', () => {
288
- const repoRoot = mkRepo();
289
- const { db } = mkDb(repoRoot);
290
- let a, b;
291
- try {
292
- seedTask(db, 'r-multi-a');
293
- seedTask(db, 'r-multi-b');
294
- a = runner.spawnSession({
295
- db, repoRoot,
296
- task_id: 'r-multi-a', runtime: 'claude',
297
- command: LONG_SLEEP_CMD, args: LONG_SLEEP_ARGS,
298
- });
299
- b = runner.spawnSession({
300
- db, repoRoot,
301
- task_id: 'r-multi-b', runtime: 'codex',
302
- command: LONG_SLEEP_CMD, args: LONG_SLEEP_ARGS,
303
- });
304
- assert.notEqual(a.sid, b.sid);
305
- assert.notEqual(a.worktree_path, b.worktree_path);
306
- assert.notEqual(a.pid, b.pid);
307
- assert.ok(isProcessAlive(a.pid) && isProcessAlive(b.pid));
308
- } finally {
309
- for (const h of [a, b]) {
310
- if (h && h.pid) { try { process.kill(h.pid, 'SIGKILL'); } catch (_) { /* ignore */ } }
311
- }
312
- cleanup(repoRoot, db);
313
- }
314
- });