ultra-builder-pro-cli 0.25.1 → 0.26.1

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 (243) hide show
  1. package/.ultra-template/changes/abandoned/.gitkeep +0 -0
  2. package/.ultra-template/contexts/TEMPLATE.md +94 -0
  3. package/.ultra-template/decisions/.gitkeep +1 -0
  4. package/.ultra-template/evidence/.gitkeep +1 -0
  5. package/.ultra-template/north-star.md +46 -0
  6. package/.ultra-template/research/.gitkeep +1 -0
  7. package/.ultra-template/specs/discovery.md +2 -2
  8. package/.ultra-template/tasks.json +3 -0
  9. package/.ultra-template/test-report.json +27 -0
  10. package/CHANGELOG.md +107 -1
  11. package/README.md +217 -579
  12. package/adapters/_shared/delegate-profiles/kimi-read-only.md +15 -0
  13. package/adapters/_shared/delegate-profiles/kimi-write.md +17 -0
  14. package/adapters/_shared/file-ops.cjs +76 -2
  15. package/adapters/_shared/host-profile.cjs +97 -0
  16. package/adapters/_shared/plugin-core.cjs +204 -0
  17. package/adapters/_shared/runtime-assets.cjs +31 -96
  18. package/adapters/claude.js +92 -291
  19. package/adapters/codex.js +309 -842
  20. package/adapters/grok.js +143 -1469
  21. package/adapters/kimi.js +124 -1053
  22. package/adapters/opencode.js +230 -742
  23. package/bin/delegate-worker.cjs +328 -0
  24. package/bin/delegate.cjs +463 -0
  25. package/bin/install.js +38 -11
  26. package/docs/ARCHITECTURE.md +125 -216
  27. package/docs/ARTIFACT-AUTHORITY.md +90 -268
  28. package/docs/DECISIONS.md +77 -87
  29. package/docs/PHILOSOPHY.md +208 -0
  30. package/docs/PLUGIN-ISOLATION-CONTRACT.md +65 -39
  31. package/docs/RUNTIME-COMPAT-MATRIX.md +140 -155
  32. package/docs/SKILL-AUTHORING.md +94 -73
  33. package/docs/WORKFLOW-LIFECYCLE.md +97 -189
  34. package/hooks/README.md +19 -37
  35. package/hooks/_common.py +113 -0
  36. package/hooks/adapters/codex.py +9 -16
  37. package/hooks/adapters/grok.py +5 -5
  38. package/hooks/adapters/kimi.py +8 -14
  39. package/hooks/block_dangerous_commands.py +201 -0
  40. package/hooks/compact_context.py +65 -0
  41. package/hooks/mid_workflow_recall.py +25 -0
  42. package/hooks/post_edit_guard.py +105 -0
  43. package/hooks/session_context.py +30 -0
  44. package/package.json +17 -52
  45. package/skills/ultra-change/SKILL.md +115 -50
  46. package/skills/ultra-change/references/change-contract.md +68 -3
  47. package/skills/ultra-delegate/SKILL.md +89 -0
  48. package/skills/ultra-delegate/references/delegation-contract.md +59 -0
  49. package/skills/ultra-delegate/scripts/delegate_wait.py +86 -0
  50. package/skills/ultra-deliver/SKILL.md +98 -42
  51. package/skills/ultra-deliver/references/baseline-reconciliation.md +39 -20
  52. package/skills/ultra-dev/SKILL.md +115 -62
  53. package/skills/ultra-dev/references/debugging.md +22 -0
  54. package/skills/ultra-domain-modeling/SKILL.md +79 -0
  55. package/skills/ultra-grilling/SKILL.md +118 -0
  56. package/skills/ultra-grilling/references/reframing.md +72 -0
  57. package/skills/ultra-init/SKILL.md +102 -37
  58. package/skills/ultra-init/references/brownfield-adoption.md +33 -83
  59. package/skills/ultra-init/scripts/init_project.cjs +84 -0
  60. package/skills/ultra-plan/SKILL.md +101 -68
  61. package/skills/ultra-research/SKILL.md +68 -48
  62. package/skills/ultra-research/references/00-problem-validation.md +5 -5
  63. package/skills/ultra-research/references/01-opportunity-discovery.md +3 -3
  64. package/skills/ultra-research/references/02-market-assessment.md +5 -4
  65. package/skills/ultra-research/references/03-competitive-landscape.md +3 -3
  66. package/skills/ultra-research/references/04-product-strategy.md +3 -3
  67. package/skills/ultra-research/references/05-assumptions-validation.md +5 -5
  68. package/skills/ultra-research/references/10-user-personas.md +3 -2
  69. package/skills/ultra-research/references/11-user-scenarios.md +3 -3
  70. package/skills/ultra-research/references/20-user-stories.md +4 -3
  71. package/skills/ultra-research/references/21-features-scope.md +3 -2
  72. package/skills/ultra-research/references/22-success-metrics.md +3 -3
  73. package/skills/ultra-research/references/30-architecture-context.md +3 -3
  74. package/skills/ultra-research/references/31-solution-strategy.md +3 -3
  75. package/skills/ultra-research/references/32-building-blocks.md +3 -3
  76. package/skills/ultra-research/references/40-deployment.md +3 -3
  77. package/skills/ultra-research/references/41-quality-risks.md +3 -3
  78. package/skills/ultra-research/references/99-synthesis.md +7 -7
  79. package/skills/ultra-review/SKILL.md +84 -32
  80. package/skills/ultra-review/references/code.md +17 -0
  81. package/skills/ultra-review/references/comments.md +12 -0
  82. package/skills/ultra-review/references/design.md +12 -0
  83. package/skills/ultra-review/references/errors.md +13 -0
  84. package/skills/ultra-review/references/spec.md +13 -0
  85. package/skills/ultra-review/references/tests.md +12 -0
  86. package/skills/ultra-review/references/worker-packet.md +40 -0
  87. package/skills/ultra-review/scripts/review_wait.py +26 -15
  88. package/skills/ultra-status/SKILL.md +61 -36
  89. package/skills/ultra-tdd/SKILL.md +81 -0
  90. package/skills/ultra-tdd/references/templates/README.md +20 -0
  91. package/skills/ultra-tdd/references/templates/feature-flag-default-audit.sh +79 -0
  92. package/skills/ultra-tdd/references/templates/persistence-real.ts +78 -0
  93. package/skills/ultra-tdd/references/templates/testcontainer-postgres.py +78 -0
  94. package/skills/ultra-tdd/references/templates/testcontainer-postgres.ts +73 -0
  95. package/skills/ultra-tdd/references/templates/vertical-slice.ts +66 -0
  96. package/skills/ultra-tdd/references/test-execution.md +18 -0
  97. package/skills/ultra-test/SKILL.md +75 -30
  98. package/skills/ultra-test/references/export-syntax.md +18 -0
  99. package/skills/ultra-test/scripts/worktree_digest.cjs +54 -0
  100. package/skills/ultra-think/SKILL.md +49 -27
  101. package/skills/ultra-think/references/autonomy-boundary.md +48 -0
  102. package/skills/ultra-think/references/interaction-boundary.md +18 -33
  103. package/.ultra-template/docs/research/README.md +0 -55
  104. package/.ultra-template/reports/templates/delivery-report.json +0 -17
  105. package/.ultra-template/reports/templates/test-report.json +0 -18
  106. package/.ultra-template/tasks/tasks.json +0 -15
  107. package/.ultra-template/templates/task-context.md +0 -19
  108. package/adapters/_shared/codex-assets.cjs +0 -725
  109. package/adapters/_shared/codex-ultra-tools-entry.cjs +0 -11
  110. package/adapters/_shared/interaction-contract.cjs +0 -129
  111. package/adapters/_shared/path-rewrite.cjs +0 -79
  112. package/adapters/_shared/runtime-native.cjs +0 -132
  113. package/adapters/_shared/settings-merge.cjs +0 -76
  114. package/agents/code-reviewer.md +0 -42
  115. package/agents/debugger.md +0 -40
  116. package/agents/review-code.md +0 -49
  117. package/agents/review-comments.md +0 -38
  118. package/agents/review-coordinator.md +0 -56
  119. package/agents/review-design.md +0 -40
  120. package/agents/review-errors.md +0 -43
  121. package/agents/review-spec.md +0 -39
  122. package/agents/review-tests.md +0 -40
  123. package/agents/tdd-runner.md +0 -38
  124. package/bin/orchestrator.js +0 -383
  125. package/commands/ultra-change.md +0 -10
  126. package/commands/ultra-deliver.md +0 -10
  127. package/commands/ultra-dev.md +0 -10
  128. package/commands/ultra-doctor.md +0 -10
  129. package/commands/ultra-init.md +0 -10
  130. package/commands/ultra-plan.md +0 -10
  131. package/commands/ultra-research.md +0 -10
  132. package/commands/ultra-review.md +0 -10
  133. package/commands/ultra-status.md +0 -10
  134. package/commands/ultra-test.md +0 -10
  135. package/commands/ultra-think.md +0 -10
  136. package/docs/AGENT-CONTEXT.md +0 -191
  137. package/docs/COMMIT-HASH-BACKFILL.md +0 -51
  138. package/docs/LEGACY-CLI-CROSSWALK.md +0 -175
  139. package/docs/ROADMAP.md +0 -101
  140. package/docs/STATE-DB-ACCESS-POLICY.md +0 -161
  141. package/hooks/active_task_context.py +0 -152
  142. package/hooks/context_envelope.py +0 -100
  143. package/hooks/health_check.py +0 -196
  144. package/hooks/pre_stop_check.py +0 -66
  145. package/hooks/runtime_paths.py +0 -396
  146. package/hooks/subagent_tracker.py +0 -88
  147. package/hooks/workflow_checkpoint.py +0 -76
  148. package/hooks/workflow_context.py +0 -48
  149. package/hooks/workflow_resume.py +0 -84
  150. package/mcp-server/hook-context.cjs +0 -38
  151. package/mcp-server/hook-event.cjs +0 -34
  152. package/mcp-server/lib/archive-journal.cjs +0 -1119
  153. package/mcp-server/lib/archive-mutation-worker.py +0 -365
  154. package/mcp-server/lib/artifact-registry.cjs +0 -1232
  155. package/mcp-server/lib/baseline-workflow.cjs +0 -1193
  156. package/mcp-server/lib/canonical-json.cjs +0 -19
  157. package/mcp-server/lib/change-authority.cjs +0 -26
  158. package/mcp-server/lib/change-packet.cjs +0 -790
  159. package/mcp-server/lib/change-workflow.cjs +0 -65
  160. package/mcp-server/lib/context-envelope.cjs +0 -581
  161. package/mcp-server/lib/context-paths.cjs +0 -142
  162. package/mcp-server/lib/decision-records.cjs +0 -423
  163. package/mcp-server/lib/delivery-transaction.cjs +0 -471
  164. package/mcp-server/lib/doctor-backup-worker.cjs +0 -328
  165. package/mcp-server/lib/doctor.cjs +0 -627
  166. package/mcp-server/lib/git-bootstrap.cjs +0 -651
  167. package/mcp-server/lib/init-project.cjs +0 -1021
  168. package/mcp-server/lib/kernel-change-workflow.cjs +0 -2029
  169. package/mcp-server/lib/legacy-change-workflow.cjs +0 -3032
  170. package/mcp-server/lib/managed-file-write.cjs +0 -172
  171. package/mcp-server/lib/plan-checkpoint.cjs +0 -177
  172. package/mcp-server/lib/plan-publication-worker.cjs +0 -830
  173. package/mcp-server/lib/plan-store.cjs +0 -794
  174. package/mcp-server/lib/prd-parser.cjs +0 -83
  175. package/mcp-server/lib/pricing.cjs +0 -44
  176. package/mcp-server/lib/project-context.cjs +0 -178
  177. package/mcp-server/lib/projector.cjs +0 -386
  178. package/mcp-server/lib/provider-refs.cjs +0 -42
  179. package/mcp-server/lib/runtime-paths.cjs +0 -1714
  180. package/mcp-server/lib/runtime-state.cjs +0 -222
  181. package/mcp-server/lib/safe-project-file.cjs +0 -385
  182. package/mcp-server/lib/stage-checkpoints.cjs +0 -463
  183. package/mcp-server/lib/state-authority.cjs +0 -95
  184. package/mcp-server/lib/state-db.cjs +0 -1580
  185. package/mcp-server/lib/state-ops.cjs +0 -1428
  186. package/mcp-server/lib/task-contract.cjs +0 -404
  187. package/mcp-server/lib/task-expander.cjs +0 -105
  188. package/mcp-server/lib/task-ledger.cjs +0 -1964
  189. package/mcp-server/lib/telemetry.cjs +0 -85
  190. package/mcp-server/lib/topo.cjs +0 -131
  191. package/mcp-server/lib/ultra-facade.cjs +0 -2684
  192. package/mcp-server/lib/worker-packet.cjs +0 -358
  193. package/mcp-server/server.cjs +0 -437
  194. package/orchestrator/auto-merge.cjs +0 -128
  195. package/orchestrator/daemon.cjs +0 -264
  196. package/orchestrator/dispatch-rules.cjs +0 -78
  197. package/orchestrator/parallel-orchestrator.cjs +0 -903
  198. package/orchestrator/planner/plan-builder.cjs +0 -125
  199. package/orchestrator/recovery.cjs +0 -188
  200. package/orchestrator/session-close-journal-worker.cjs +0 -5
  201. package/orchestrator/session-close-journal.cjs +0 -1014
  202. package/orchestrator/session-runner.cjs +0 -1153
  203. package/orchestrator/worktree-manager.cjs +0 -282
  204. package/settings.json +0 -7
  205. package/skills/cc-collab/SKILL.md +0 -42
  206. package/skills/code-review-expert/SKILL.md +0 -43
  207. package/skills/code-review-expert/references/correctness-reliability.md +0 -30
  208. package/skills/code-review-expert/references/design-boundaries.md +0 -26
  209. package/skills/code-review-expert/references/removal-plan.md +0 -24
  210. package/skills/codex-collab/SKILL.md +0 -49
  211. package/skills/codex-collab/evals/evals.json +0 -35
  212. package/skills/integration-rules/SKILL.md +0 -32
  213. package/skills/security-rules/SKILL.md +0 -38
  214. package/skills/testing-rules/SKILL.md +0 -32
  215. package/skills/ultra-doctor/SKILL.md +0 -41
  216. package/skills/ultra-plan/references/semantic-preflight.md +0 -20
  217. package/skills/ultra-verify/SKILL.md +0 -46
  218. package/skills/ultra-verify/evals/evals.json +0 -46
  219. package/skills/ultra-verify/references/cross-verify-modes.md +0 -28
  220. package/skills/ultra-verify/references/evidence-status.md +0 -21
  221. package/skills/ultra-verify/scripts/verify_wait.py +0 -94
  222. package/spec/cli-protocol.md +0 -102
  223. package/spec/mcp-tools.yaml +0 -312
  224. package/spec/schemas/baseline-reconciliation.v1.schema.json +0 -73
  225. package/spec/schemas/change-delta.v1.schema.json +0 -153
  226. package/spec/schemas/command-manifest.schema.json +0 -29
  227. package/spec/schemas/context-file.v4.5.schema.json +0 -54
  228. package/spec/schemas/delivery-report.v1.schema.json +0 -66
  229. package/spec/schemas/documentation-reconciliation.v1.schema.json +0 -106
  230. package/spec/schemas/mcp-tools.schema.json +0 -136
  231. package/spec/schemas/skill-manifest.schema.json +0 -24
  232. package/spec/schemas/state-db.sql +0 -641
  233. package/spec/schemas/task-ledger.v2.schema.json +0 -296
  234. package/spec/schemas/tasks.v4.5.schema.json +0 -96
  235. package/spec/schemas/test-report.v1.schema.json +0 -225
  236. package/ultra-tools/cli.cjs +0 -129
  237. package/ultra-tools/commands/db.cjs +0 -226
  238. package/ultra-tools/commands/legacy-memory.cjs +0 -159
  239. package/ultra-tools/commands/migrate.cjs +0 -1386
  240. package/ultra-tools/commands/session.cjs +0 -213
  241. package/ultra-tools/commands/status.cjs +0 -559
  242. package/ultra-tools/commands/system.cjs +0 -498
  243. package/ultra-tools/commands/task.cjs +0 -105
@@ -1,1428 +0,0 @@
1
- 'use strict';
2
-
3
- // State-operations layer over .ultra/.runtime/state.db.
4
- //
5
- // Every mutation in the Ultra Builder Pro runtime goes through this module.
6
- // Callers (MCP server tool handlers, the migration CLI, the orchestrator)
7
- // must NOT reach into the better-sqlite3 connection directly — the helpers
8
- // here apply the BEGIN IMMEDIATE / retry / status-machine guards specified
9
- // in PLAN §6 Phase 2.3 + docs/STATE-DB-ACCESS-POLICY.md.
10
-
11
- const crypto = require('node:crypto');
12
- const fs = require('node:fs');
13
- const path = require('node:path');
14
- const { spawnSync } = require('node:child_process');
15
-
16
- const canonical = require('./canonical-json.cjs');
17
- const { openStateDb } = require('./state-db.cjs');
18
- const {
19
- TASK_CONTRACT_DEFINE_FIELDS,
20
- TASK_CONTRACT_PATCH_FIELDS,
21
- } = require('./task-contract.cjs');
22
- const { isSupportedRuntime } = require('../../adapters/_shared/runtime-assets.cjs');
23
- const closeJournal = require('../../orchestrator/session-close-journal.cjs');
24
-
25
- const STATUS_TRANSITIONS = Object.freeze({
26
- pending: new Set(['in_progress', 'blocked', 'expanded']),
27
- in_progress: new Set(['completed', 'blocked', 'pending']),
28
- blocked: new Set(['pending', 'in_progress']),
29
- expanded: new Set(['completed']),
30
- completed: new Set(['in_progress']),
31
- });
32
-
33
- const TASK_FIELDS = Object.freeze([
34
- 'id', 'title', 'type', 'priority', 'complexity', 'estimated_days', 'status',
35
- 'deps', 'files_modified', 'session_id', 'stale',
36
- 'tag', 'trace_to', 'outcome', 'slice_kind', 'public_seam', 'verification_command',
37
- 'acceptance_json', 'context_refs_json', 'docs_impact_json', 'ownership_json',
38
- 'context_file', 'completion_commit', 'parent_id',
39
- 'change_id',
40
- 'created_at', 'updated_at',
41
- ]);
42
-
43
- const PATCHABLE_FIELDS = Object.freeze([
44
- ...TASK_CONTRACT_PATCH_FIELDS,
45
- 'session_id', 'stale', 'tag',
46
- 'context_file', 'completion_commit',
47
- 'change_id',
48
- ]);
49
-
50
- const STALE_RECONCILIATION_FIELDS = Object.freeze([
51
- 'deps', 'files_modified', 'trace_to', 'outcome', 'slice_kind', 'public_seam',
52
- 'verification_command', 'acceptance', 'context_refs', 'docs_impact', 'ownership',
53
- ]);
54
-
55
- const TASK_CONTRACT_PATCH_FIELD_SET = new Set(TASK_CONTRACT_PATCH_FIELDS);
56
-
57
- const SESSION_PATCHABLE = Object.freeze([
58
- 'pid', 'status', 'lease_expires_at', 'heartbeat_at', 'worktree_path', 'artifact_dir',
59
- ]);
60
- const SESSION_STATUSES = new Set(['running', 'completed', 'crashed', 'orphan']);
61
- const SESSION_TERMINAL_STATUSES = new Set(['completed', 'crashed']);
62
- const SESSION_STATUS_TRANSITIONS = Object.freeze({
63
- running: new Set(['completed', 'crashed', 'orphan']),
64
- orphan: new Set(['completed', 'crashed']),
65
- completed: new Set(),
66
- crashed: new Set(),
67
- });
68
-
69
- class StateOpsError extends Error {
70
- constructor(code, message, { retriable = false, details } = {}) {
71
- super(message);
72
- this.code = code;
73
- this.retriable = retriable;
74
- this.details = details;
75
- }
76
- }
77
-
78
- function nowIso() {
79
- return new Date().toISOString();
80
- }
81
-
82
- function sleep(ms) {
83
- Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
84
- }
85
-
86
- function isBusyError(err) {
87
- const msg = String(err && (err.code || err.message) || '');
88
- return msg.includes('SQLITE_BUSY') || msg.includes('database is locked');
89
- }
90
-
91
- // Decorrelated jitter backoff (Marc Brooker, AWS Architecture Blog).
92
- // Avoids thundering-herd retries when many writers hit a contended writer
93
- // lock at the same moment — each worker picks a distinct delay window.
94
- function withRetry(fn, { attempts = 10, baseMs = 50, capMs = 2000 } = {}) {
95
- let last;
96
- let delay = baseMs;
97
- for (let i = 0; i < attempts; i++) {
98
- try {
99
- return fn();
100
- } catch (err) {
101
- if (!isBusyError(err)) throw err;
102
- last = err;
103
- if (i === attempts - 1) break;
104
- const jitterUpper = Math.min(capMs, delay * 3);
105
- delay = baseMs + Math.floor(Math.random() * jitterUpper);
106
- sleep(delay);
107
- }
108
- }
109
- throw new StateOpsError('STATE_DB_LOCKED', 'database is locked after retries', {
110
- retriable: true,
111
- details: last && last.message,
112
- });
113
- }
114
-
115
- function tx(db, fn) {
116
- return withRetry(() => db.transaction(fn).immediate());
117
- }
118
-
119
- // ─── tasks ───────────────────────────────────────────────────────────────
120
-
121
- function rowToTask(row) {
122
- if (!row) return null;
123
- const out = { ...row };
124
- // Pre-13 databases may retain this retired Claude-specific column. It is
125
- // intentionally ignored instead of leaking host/model assumptions into the
126
- // current task contract or projections.
127
- delete out.complexity_hint;
128
- for (const k of ['deps', 'files_modified']) {
129
- if (typeof out[k] === 'string') {
130
- try { out[k] = JSON.parse(out[k]); } catch { out[k] = null; }
131
- }
132
- }
133
- const jsonFields = {
134
- acceptance_json: ['acceptance', []],
135
- context_refs_json: ['context_refs', []],
136
- docs_impact_json: ['docs_impact', { status: 'unknown', files: [], rationale: null }],
137
- ownership_json: ['ownership', {}],
138
- };
139
- for (const [column, [field, fallback]] of Object.entries(jsonFields)) {
140
- try { out[field] = typeof out[column] === 'string' ? JSON.parse(out[column]) : fallback; }
141
- catch { out[field] = fallback; }
142
- delete out[column];
143
- }
144
- if (out.stale !== undefined && out.stale !== null) {
145
- if (out.stale === 0 || out.stale === 1) out.stale = Boolean(out.stale);
146
- }
147
- return out;
148
- }
149
-
150
- function normalizeObjectArray(value, field) {
151
- if (!Array.isArray(value) || value.some((item) => !item || typeof item !== 'object' || Array.isArray(item))) {
152
- throw new StateOpsError('VALIDATION_ERROR', `${field} must be an array of objects`);
153
- }
154
- return JSON.parse(JSON.stringify(value));
155
- }
156
-
157
- function normalizeAcceptance(value) {
158
- const items = normalizeObjectArray(value, 'acceptance');
159
- const ids = new Set();
160
- return items.map((item, index) => {
161
- const id = String(item.id || '').trim();
162
- const criterion = String(item.criterion || '').trim();
163
- const verification = String(item.verification || '').trim();
164
- if (!id || !criterion || !verification || ids.has(id)) {
165
- throw new StateOpsError(
166
- 'VALIDATION_ERROR', `acceptance[${index}] requires a unique id, criterion, and verification`,
167
- );
168
- }
169
- ids.add(id);
170
- return { id, criterion, verification };
171
- });
172
- }
173
-
174
- function normalizeContextRefs(value) {
175
- return normalizeObjectArray(value, 'context_refs').map((item, index) => {
176
- const ref = String(item.ref || '').trim();
177
- const reason = String(item.reason || '').trim();
178
- if (!ref || !reason) {
179
- throw new StateOpsError('VALIDATION_ERROR', `context_refs[${index}] requires ref and reason`);
180
- }
181
- const kind = String(item.kind || 'source').trim();
182
- if (!['spec', 'source', 'test', 'docs', 'external'].includes(kind)) {
183
- throw new StateOpsError('VALIDATION_ERROR', `context_refs[${index}].kind is unsupported`);
184
- }
185
- const expectedDigest = item.expected_digest ?? item.digest ?? null;
186
- if (expectedDigest !== null && !/^[0-9a-f]{64}$/.test(String(expectedDigest))) {
187
- throw new StateOpsError(
188
- 'VALIDATION_ERROR', `context_refs[${index}].expected_digest must be sha256`,
189
- );
190
- }
191
- const freshnessPolicy = String(
192
- item.freshness_policy || (expectedDigest ? 'digest' : 'existence'),
193
- ).trim();
194
- if (!['digest', 'existence', 'advisory'].includes(freshnessPolicy)) {
195
- throw new StateOpsError(
196
- 'VALIDATION_ERROR',
197
- `context_refs[${index}].freshness_policy must be digest, existence, or advisory`,
198
- );
199
- }
200
- if (item.required !== undefined && typeof item.required !== 'boolean') {
201
- throw new StateOpsError(
202
- 'VALIDATION_ERROR',
203
- `context_refs[${index}].required must be a boolean`,
204
- );
205
- }
206
- const normalized = {
207
- ref,
208
- reason,
209
- kind,
210
- required: item.required === undefined ? true : item.required,
211
- freshness_policy: freshnessPolicy,
212
- };
213
- if (expectedDigest !== null) normalized.expected_digest = String(expectedDigest);
214
- if (item.anchor !== undefined) {
215
- const anchor = String(item.anchor).trim();
216
- if (!anchor) throw new StateOpsError('VALIDATION_ERROR', `context_refs[${index}].anchor cannot be empty`);
217
- normalized.anchor = anchor;
218
- }
219
- if (item.scope !== undefined) {
220
- const scope = String(item.scope).trim();
221
- if (!scope) throw new StateOpsError('VALIDATION_ERROR', `context_refs[${index}].scope cannot be empty`);
222
- normalized.scope = scope;
223
- }
224
- return normalized;
225
- });
226
- }
227
-
228
- function normalizeDocsImpact(value) {
229
- if (!value || typeof value !== 'object' || Array.isArray(value)) {
230
- throw new StateOpsError('VALIDATION_ERROR', 'docs_impact must be an object');
231
- }
232
- const status = String(value.status || 'unknown').trim();
233
- if (!['unknown', 'required', 'none'].includes(status)) {
234
- throw new StateOpsError('VALIDATION_ERROR', 'docs_impact.status must be unknown, required, or none');
235
- }
236
- const files = value.files === undefined ? [] : value.files;
237
- if (!Array.isArray(files) || files.some((item) => typeof item !== 'string' || !item.trim())) {
238
- throw new StateOpsError('VALIDATION_ERROR', 'docs_impact.files must be an array of paths');
239
- }
240
- return {
241
- status, files: files.map((item) => item.trim()),
242
- rationale: value.rationale == null ? null : String(value.rationale).trim(),
243
- };
244
- }
245
-
246
- function normalizeOwnership(value) {
247
- if (!value || typeof value !== 'object' || Array.isArray(value)) {
248
- throw new StateOpsError('VALIDATION_ERROR', 'ownership must be an object');
249
- }
250
- const reviewers = value.reviewers === undefined ? [] : value.reviewers;
251
- if (!Array.isArray(reviewers) || reviewers.some((item) => typeof item !== 'string' || !item.trim())) {
252
- throw new StateOpsError('VALIDATION_ERROR', 'ownership.reviewers must be an array of names');
253
- }
254
- return {
255
- owner: value.owner == null ? null : String(value.owner).trim(),
256
- reviewers: reviewers.map((item) => item.trim()),
257
- };
258
- }
259
-
260
- function taskContractBlockers(task) {
261
- const blockers = [];
262
- if (!String(task?.outcome || '').trim()) blockers.push('TASK_OUTCOME_MISSING');
263
- if (!String(task?.slice_kind || '').trim()) {
264
- blockers.push('TASK_SLICE_KIND_MISSING');
265
- }
266
- if (!String(task?.public_seam || '').trim()) blockers.push('TASK_PUBLIC_SEAM_MISSING');
267
- if (!String(task?.verification_command || '').trim()) blockers.push('TASK_VERIFICATION_COMMAND_MISSING');
268
- if (!Array.isArray(task?.acceptance) || task.acceptance.length === 0) blockers.push('TASK_ACCEPTANCE_MISSING');
269
- if (!Array.isArray(task?.context_refs) || task.context_refs.length === 0) blockers.push('TASK_CONTEXT_REFS_MISSING');
270
- if (!String(task?.trace_to || '').trim()) blockers.push('TASK_TRACEABILITY_MISSING');
271
- if (!task?.docs_impact || task.docs_impact.status === 'unknown') blockers.push('TASK_DOCS_IMPACT_UNRESOLVED');
272
- else if (task.docs_impact.status === 'required' && task.docs_impact.files.length === 0) {
273
- blockers.push('TASK_DOCS_FILES_MISSING');
274
- } else if (!String(task.docs_impact.rationale || '').trim()) blockers.push('TASK_DOCS_RATIONALE_MISSING');
275
- if (!String(task?.ownership?.owner || '').trim()) blockers.push('TASK_OWNER_MISSING');
276
- return blockers;
277
- }
278
-
279
- function assertTaskExecutionContract(task) {
280
- const blockers = taskContractBlockers(task);
281
- if (blockers.length > 0) {
282
- throw new StateOpsError(
283
- 'TASK_EXECUTION_CONTRACT_INCOMPLETE',
284
- `task ${task?.id || '(unknown)'} execution contract is incomplete`,
285
- { details: { blockers } },
286
- );
287
- }
288
- return task;
289
- }
290
-
291
- function currentChangeAuthorityDigest(db, changeId) {
292
- if (!changeId) return null;
293
- const row = db.prepare(
294
- `SELECT id, kind, intent, docs_impact_json, provider_refs_json, contract_json,
295
- classification_json, research_disposition_json
296
- FROM changes WHERE id = ?`,
297
- ).get(changeId);
298
- if (!row) return null;
299
- const parse = (value) => {
300
- try { return JSON.parse(value || '{}'); } catch { return {}; }
301
- };
302
- const payload = {
303
- id: row.id,
304
- kind: row.kind,
305
- intent: row.intent,
306
- docs_impact: parse(row.docs_impact_json),
307
- provider_refs: parse(row.provider_refs_json),
308
- contract: parse(row.contract_json),
309
- classification: parse(row.classification_json),
310
- research_disposition: parse(row.research_disposition_json),
311
- };
312
- return crypto.createHash('sha256').update(JSON.stringify(payload)).digest('hex');
313
- }
314
-
315
- function readTask(db, id) {
316
- const row = db.prepare('SELECT * FROM tasks WHERE id = ?').get(id);
317
- return rowToTask(row);
318
- }
319
-
320
- // Single static SQL with named parameters and NULL-pass-through filters.
321
- // Every value flows through @bindings; the string is a frozen literal so no
322
- // concatenation / interpolation hooks can flag injection risk.
323
- const LIST_TASKS_SQL = "SELECT * FROM tasks WHERE (@status IS NULL OR status = @status) AND (@tag IS NULL OR tag = @tag) AND (@change_id IS NULL OR change_id = @change_id) AND (@since IS NULL OR updated_at >= @since) ORDER BY created_at ASC LIMIT IIF(@maxn IS NULL, -1, @maxn)";
324
-
325
- function listTasks(db, filter = {}) {
326
- const status = filter.status && filter.status !== 'any' ? filter.status : null;
327
- return db.prepare(LIST_TASKS_SQL).all({
328
- status,
329
- tag: filter.tag || null,
330
- change_id: filter.change_id || null,
331
- since: filter.since || null,
332
- maxn: filter.limit || null,
333
- }).map(rowToTask);
334
- }
335
-
336
- function assertChangeAcceptsTasks(db, changeId) {
337
- if (changeId === null || changeId === undefined) return null;
338
- const change = db.prepare('SELECT id, status FROM changes WHERE id = ?').get(changeId);
339
- if (!change) throw new StateOpsError('CHANGE_NOT_FOUND', `change ${changeId} does not exist`);
340
- if (!['active', 'blocked'].includes(change.status)) {
341
- throw new StateOpsError('CHANGE_NOT_MUTABLE', `change ${changeId} is ${change.status}`);
342
- }
343
- return change;
344
- }
345
-
346
- function resolveTaskCreationChangeId(db, input = {}) {
347
- const explicitlyAssigned = input.change_id !== undefined;
348
- const requestedChangeId = explicitlyAssigned ? input.change_id : null;
349
- if (!input.parent_id) {
350
- assertChangeAcceptsTasks(db, requestedChangeId);
351
- return requestedChangeId;
352
- }
353
- const parent = readTask(db, input.parent_id);
354
- if (!parent) {
355
- throw new StateOpsError('TASK_NOT_FOUND', `parent task ${input.parent_id} does not exist`);
356
- }
357
- if (explicitlyAssigned && requestedChangeId !== parent.change_id) {
358
- throw new StateOpsError(
359
- 'TASK_CHANGE_OWNERSHIP_MISMATCH',
360
- `child task change ${requestedChangeId || '(none)'} does not match parent ${parent.id} change ${parent.change_id || '(none)'}`,
361
- );
362
- }
363
- assertChangeAcceptsTasks(db, parent.change_id);
364
- return parent.change_id;
365
- }
366
-
367
- function assertTaskChangeAssignment(db, task, changeId) {
368
- if (task.parent_id) {
369
- const parent = readTask(db, task.parent_id);
370
- if (!parent) throw new StateOpsError('TASK_NOT_FOUND', `parent task ${task.parent_id} does not exist`);
371
- if (changeId !== parent.change_id) {
372
- throw new StateOpsError(
373
- 'TASK_CHANGE_OWNERSHIP_MISMATCH',
374
- `task ${task.id} must retain parent ${parent.id} change ${parent.change_id || '(none)'}`,
375
- );
376
- }
377
- }
378
- const mismatchedChild = db.prepare(
379
- 'SELECT id, change_id FROM tasks WHERE parent_id = ? AND change_id IS NOT ? LIMIT 1',
380
- ).get(task.id, changeId);
381
- if (mismatchedChild) {
382
- throw new StateOpsError(
383
- 'TASK_CHANGE_OWNERSHIP_MISMATCH',
384
- `task ${task.id} change must continue to match child ${mismatchedChild.id}`,
385
- );
386
- }
387
- assertChangeAcceptsTasks(db, changeId);
388
- }
389
-
390
- function createTask(db, input) {
391
- if (!input || !input.id || !input.title || !input.type || !input.priority) {
392
- throw new StateOpsError('VALIDATION_ERROR', 'id, title, type, priority required');
393
- }
394
- const ts = nowIso();
395
- // Phase 7.2 — auto-derive tag from _cwd git branch when caller omits tag.
396
- const derivedTag = (input.tag === undefined || input.tag === null) && input._cwd
397
- ? deriveBranchTag(input._cwd)
398
- : null;
399
- const row = {
400
- id: input.id,
401
- title: input.title,
402
- type: input.type,
403
- priority: input.priority,
404
- complexity: input.complexity ?? null,
405
- estimated_days: input.estimated_days ?? null,
406
- status: 'pending',
407
- deps: input.deps ? JSON.stringify(input.deps) : null,
408
- files_modified: input.files_modified ? JSON.stringify(input.files_modified) : null,
409
- session_id: input.session_id ?? null,
410
- stale: 0,
411
- tag: input.tag ?? derivedTag,
412
- trace_to: input.trace_to ?? null,
413
- outcome: input.outcome ?? null,
414
- slice_kind: input.slice_kind ?? null,
415
- public_seam: input.public_seam ?? null,
416
- verification_command: input.verification_command ?? null,
417
- acceptance_json: JSON.stringify(input.acceptance === undefined ? [] : normalizeAcceptance(input.acceptance)),
418
- context_refs_json: JSON.stringify(input.context_refs === undefined ? [] : normalizeContextRefs(input.context_refs)),
419
- docs_impact_json: JSON.stringify(input.docs_impact === undefined
420
- ? { status: 'unknown', files: [], rationale: null }
421
- : normalizeDocsImpact(input.docs_impact)),
422
- ownership_json: JSON.stringify(input.ownership === undefined ? {} : normalizeOwnership(input.ownership)),
423
- context_file: input.context_file ?? null,
424
- completion_commit: null,
425
- parent_id: input.parent_id ?? null,
426
- change_id: input.change_id ?? null,
427
- created_at: ts,
428
- updated_at: ts,
429
- };
430
- return tx(db, () => {
431
- row.change_id = resolveTaskCreationChangeId(db, input);
432
- try {
433
- db.prepare(
434
- `INSERT INTO tasks (${TASK_FIELDS.join(', ')})
435
- VALUES (${TASK_FIELDS.map(() => '?').join(', ')})`,
436
- ).run(...TASK_FIELDS.map((f) => row[f]));
437
- } catch (err) {
438
- if (String(err.message).includes('UNIQUE')) {
439
- throw new StateOpsError('DUPLICATE_TASK_ID', `task id ${input.id} already exists`);
440
- }
441
- throw err;
442
- }
443
- appendEventInTx(db, {
444
- type: 'task_created',
445
- task_id: row.id,
446
- change_id: row.change_id,
447
- payload: { priority: row.priority, type: row.type },
448
- });
449
- return readTask(db, row.id);
450
- });
451
- }
452
-
453
- function patchTask(db, id, patch = {}) {
454
- return tx(db, () => {
455
- const current = readTask(db, id);
456
- if (!current) throw new StateOpsError('TASK_NOT_FOUND', `no task ${id}`);
457
- // A task owned by a change remains writable only while that change is an
458
- // active execution authority. Ownership is durable once assigned: it may
459
- // not be detached or moved to another change by a generic task patch.
460
- assertChangeAcceptsTasks(db, current.change_id);
461
- const clearingStale = current.stale === true && patch.stale === false;
462
- if (clearingStale) {
463
- const missing = STALE_RECONCILIATION_FIELDS.filter(
464
- (field) => !Object.prototype.hasOwnProperty.call(patch, field),
465
- );
466
- if (missing.length > 0) {
467
- throw new StateOpsError(
468
- 'TASK_STALE_RECONCILIATION_REQUIRED',
469
- `task ${id} can clear stale only while rebinding its complete execution contract`,
470
- { details: { missing_fields: missing } },
471
- );
472
- }
473
- }
474
- const sets = [];
475
- const params = [];
476
- let nextStatus = null;
477
- for (const key of Object.keys(patch)) {
478
- if (key === 'status') {
479
- nextStatus = patch[key];
480
- continue;
481
- }
482
- if (!PATCHABLE_FIELDS.includes(key)) {
483
- throw new StateOpsError('VALIDATION_ERROR', `field ${key} is not patchable`);
484
- }
485
- let value = patch[key];
486
- if (key === 'change_id') {
487
- if (current.change_id !== null && value !== current.change_id) {
488
- throw new StateOpsError(
489
- 'TASK_CHANGE_OWNERSHIP_MISMATCH',
490
- `task ${current.id} already belongs to change ${current.change_id}`,
491
- );
492
- }
493
- assertTaskChangeAssignment(db, current, value);
494
- }
495
- if (key === 'deps' || key === 'files_modified') {
496
- if (value !== null && !Array.isArray(value)) {
497
- throw new StateOpsError('VALIDATION_ERROR', `${key} must be an array`);
498
- }
499
- value = value === null ? null : JSON.stringify(value);
500
- }
501
- if (key === 'acceptance') {
502
- sets.push('acceptance_json = ?');
503
- params.push(JSON.stringify(normalizeAcceptance(value)));
504
- continue;
505
- }
506
- if (key === 'context_refs') {
507
- sets.push('context_refs_json = ?');
508
- params.push(JSON.stringify(normalizeContextRefs(value)));
509
- continue;
510
- }
511
- if (key === 'docs_impact') {
512
- sets.push('docs_impact_json = ?');
513
- params.push(JSON.stringify(normalizeDocsImpact(value)));
514
- continue;
515
- }
516
- if (key === 'ownership') {
517
- sets.push('ownership_json = ?');
518
- params.push(JSON.stringify(normalizeOwnership(value)));
519
- continue;
520
- }
521
- if (key === 'stale') value = value ? 1 : 0;
522
- sets.push(`${key} = ?`);
523
- params.push(value);
524
- }
525
- if (nextStatus !== null) {
526
- const allowed = STATUS_TRANSITIONS[current.status] || new Set();
527
- const recoveringUncommittedCompletion = current.status === 'completed'
528
- && nextStatus === 'blocked'
529
- && !current.completion_commit
530
- && Boolean(current.session_id);
531
- if (!allowed.has(nextStatus) && nextStatus !== current.status
532
- && !recoveringUncommittedCompletion) {
533
- throw new StateOpsError(
534
- 'ILLEGAL_STATUS_TRANSITION',
535
- `cannot transition task ${id} from ${current.status} to ${nextStatus}`,
536
- );
537
- }
538
- if (current.status === 'completed' && nextStatus === 'in_progress') {
539
- if (!Object.prototype.hasOwnProperty.call(patch, 'completion_commit')) {
540
- sets.push('completion_commit = ?');
541
- params.push(null);
542
- }
543
- if (!Object.prototype.hasOwnProperty.call(patch, 'session_id')) {
544
- sets.push('session_id = ?');
545
- params.push(null);
546
- }
547
- }
548
- sets.push('status = ?');
549
- params.push(nextStatus);
550
- }
551
- if (sets.length === 0) return current;
552
- sets.push('updated_at = ?');
553
- params.push(nowIso());
554
- params.push(id);
555
- db.prepare(`UPDATE tasks SET ${sets.join(', ')} WHERE id = ?`).run(...params);
556
- const updated = readTask(db, id);
557
- if (nextStatus && nextStatus !== current.status) {
558
- appendEventInTx(db, {
559
- type: current.status === 'completed' && nextStatus === 'in_progress'
560
- ? 'task_reopened'
561
- : statusEventType(nextStatus),
562
- task_id: id,
563
- change_id: patch.change_id !== undefined ? patch.change_id : current.change_id,
564
- payload: {
565
- from: current.status,
566
- to: nextStatus,
567
- ...(current.status === 'completed' && nextStatus === 'in_progress'
568
- ? { previous_completion_commit: current.completion_commit }
569
- : {}),
570
- },
571
- });
572
- }
573
- const contractFields = Object.keys(patch).filter(
574
- (field) => TASK_CONTRACT_PATCH_FIELD_SET.has(field),
575
- );
576
- if (contractFields.length > 0) {
577
- appendEventInTx(db, {
578
- type: clearingStale ? 'task_contract_reconciled' : 'task_contract_updated',
579
- task_id: id,
580
- change_id: updated.change_id,
581
- payload: {
582
- fields: contractFields.sort(),
583
- change_authority_digest: currentChangeAuthorityDigest(db, updated.change_id),
584
- },
585
- });
586
- }
587
- return updated;
588
- });
589
- }
590
-
591
- function statusEventType(status) {
592
- switch (status) {
593
- case 'in_progress': return 'task_started';
594
- case 'completed': return 'task_completed';
595
- case 'blocked': return 'task_blocked';
596
- case 'expanded': return 'task_expanded';
597
- case 'pending': return 'task_stale_marked';
598
- default: return 'task_started';
599
- }
600
- }
601
-
602
- function updateTaskStatus(db, id, nextStatus) {
603
- return patchTask(db, id, { status: nextStatus });
604
- }
605
-
606
- function deleteTask(db, id, { force = false, rootDir = null } = {}) {
607
- return tx(db, () => {
608
- const task = readTask(db, id);
609
- if (!task) throw new StateOpsError('TASK_NOT_FOUND', `no task ${id}`);
610
- const lifecycleEvents = db.prepare(
611
- 'SELECT type FROM events WHERE task_id = ? ORDER BY id',
612
- ).all(id).map((row) => row.type).filter((type) => (
613
- [
614
- 'task_started',
615
- 'task_completed',
616
- 'task_blocked',
617
- 'task_reopened',
618
- 'task_stale_marked',
619
- 'task_expanded',
620
- ].includes(type)
621
- || type.startsWith('session_')
622
- || type.startsWith('worker_packet_')
623
- || type.startsWith('task_failure')
624
- || type.startsWith('task_circuit')
625
- || type.includes('checkpoint')
626
- || type.startsWith('workflow_')
627
- ));
628
- const references = [
629
- ['sessions', db.prepare(
630
- 'SELECT COUNT(*) AS count FROM sessions WHERE task_id = ?',
631
- ).get(id).count],
632
- ['children', db.prepare(
633
- 'SELECT COUNT(*) AS count FROM tasks WHERE parent_id = ?',
634
- ).get(id).count],
635
- ['dependencies', db.prepare(
636
- `SELECT COUNT(*) AS count FROM tasks
637
- WHERE deps IS NOT NULL AND json_valid(deps)
638
- AND EXISTS (SELECT 1 FROM json_each(tasks.deps) WHERE value = ?)`,
639
- ).get(id).count],
640
- ['worker_packets', db.prepare(
641
- `SELECT COUNT(*) AS count FROM worker_packets
642
- WHERE scope_type = 'task' AND scope_id = ?`,
643
- ).get(id).count],
644
- ['artifacts', db.prepare(
645
- `SELECT COUNT(*) AS count FROM artifacts
646
- WHERE task_id = ? OR (owner_type = 'task' AND owner_id = ?)`,
647
- ).get(id, id).count],
648
- ['artifact_edges', db.prepare(
649
- `SELECT COUNT(*) AS count FROM artifact_edges
650
- WHERE (source_type = 'task' AND source_id = ?)
651
- OR (target_type = 'task' AND target_id = ?)`,
652
- ).get(id, id).count],
653
- ['stage_checkpoints', db.prepare(
654
- `SELECT COUNT(*) AS count FROM stage_checkpoints
655
- WHERE scope_type = 'task' AND scope_id = ?`,
656
- ).get(id).count],
657
- ['context_envelopes', db.prepare(
658
- `SELECT COUNT(*) AS count FROM context_envelopes
659
- WHERE scope_type = 'task' AND scope_id = ?`,
660
- ).get(id).count],
661
- ['workflow_runs', db.prepare(
662
- 'SELECT COUNT(*) AS count FROM workflow_runs WHERE task_id = ?',
663
- ).get(id).count],
664
- ['context_snapshots', db.prepare(
665
- 'SELECT COUNT(*) AS count FROM context_snapshots WHERE task_id = ?',
666
- ).get(id).count],
667
- ['incidents', db.prepare(
668
- 'SELECT COUNT(*) AS count FROM incidents WHERE task_id = ?',
669
- ).get(id).count],
670
- ['spec_learning_candidates', db.prepare(
671
- 'SELECT COUNT(*) AS count FROM spec_learning_candidates WHERE task_id = ?',
672
- ).get(id).count],
673
- ['trace_links', db.prepare(
674
- 'SELECT COUNT(*) AS count FROM trace_links WHERE task_id = ?',
675
- ).get(id).count],
676
- ['circuit_breaker', db.prepare(
677
- 'SELECT COUNT(*) AS count FROM circuit_breaker WHERE task_id = ?',
678
- ).get(id).count],
679
- ['session_close_journal', rootDir && closeJournal.findForTask(rootDir, id) ? 1 : 0],
680
- ].filter(([, count]) => count > 0)
681
- .map(([kind, count]) => ({ kind, count }));
682
- if (references.length > 0) {
683
- throw new StateOpsError(
684
- 'TASK_DELETE_REFERENCED',
685
- `task ${id} cannot be deleted because durable authority still references it`,
686
- { details: { task_id: id, references } },
687
- );
688
- }
689
- if (!force && (
690
- task.status !== 'pending' || task.session_id || task.completion_commit
691
- || lifecycleEvents.length > 0
692
- )) {
693
- throw new StateOpsError(
694
- 'TASK_DELETE_NOT_DRAFT',
695
- `task ${id} is not an unowned pending draft`,
696
- {
697
- details: {
698
- task_id: id,
699
- status: task.status,
700
- session_id: task.session_id,
701
- completion_commit: task.completion_commit,
702
- lifecycle_events: [...new Set(lifecycleEvents)],
703
- },
704
- },
705
- );
706
- }
707
- db.prepare('DELETE FROM tasks WHERE id = ?').run(id);
708
- const contract = Object.fromEntries(
709
- TASK_CONTRACT_DEFINE_FIELDS
710
- .filter((field) => task[field] !== undefined && task[field] !== null)
711
- .map((field) => [field, task[field]]),
712
- );
713
- const tombstone = appendEventInTx(db, {
714
- type: 'task_deleted',
715
- task_id: id,
716
- change_id: task.change_id,
717
- payload: {
718
- id: task.id,
719
- title: task.title,
720
- type: task.type,
721
- priority: task.priority,
722
- reason: 'task_contract_remove',
723
- contract_digest: canonical.digest(contract),
724
- },
725
- });
726
- return { ok: true, tombstone };
727
- });
728
- }
729
-
730
- // ─── events ──────────────────────────────────────────────────────────────
731
-
732
- const EVENT_FIELDS = new Set([
733
- 'type', 'task_id', 'change_id', 'session_id', 'runtime', 'payload',
734
- ]);
735
-
736
- function eventText(value, field, { nullable = false } = {}) {
737
- if (value === null && nullable) return null;
738
- if (typeof value !== 'string' || value.trim() === '') {
739
- throw new StateOpsError(
740
- 'VALIDATION_ERROR',
741
- `${field} must be a non-empty string${nullable ? ' or null' : ''}`,
742
- );
743
- }
744
- return value.trim();
745
- }
746
-
747
- function assertJsonValue(value, field, seen = new Set()) {
748
- if (value === null || typeof value === 'string' || typeof value === 'boolean') return;
749
- if (typeof value === 'number') {
750
- if (Number.isFinite(value)) return;
751
- throw new StateOpsError('VALIDATION_ERROR', `${field} must contain valid JSON values`);
752
- }
753
- if (typeof value !== 'object') {
754
- throw new StateOpsError('VALIDATION_ERROR', `${field} must contain valid JSON values`);
755
- }
756
- if (seen.has(value)) {
757
- throw new StateOpsError('VALIDATION_ERROR', `${field} must not contain circular references`);
758
- }
759
- if (!Array.isArray(value)) {
760
- const prototype = Object.getPrototypeOf(value);
761
- if (prototype !== Object.prototype && prototype !== null) {
762
- throw new StateOpsError('VALIDATION_ERROR', `${field} must contain only JSON objects`);
763
- }
764
- }
765
- seen.add(value);
766
- if (Array.isArray(value)) {
767
- value.forEach((item, index) => assertJsonValue(item, `${field}[${index}]`, seen));
768
- } else {
769
- Object.entries(value).forEach(([key, item]) => (
770
- assertJsonValue(item, `${field}.${key}`, seen)
771
- ));
772
- }
773
- seen.delete(value);
774
- }
775
-
776
- function appendEventInTx(db, event) {
777
- if (!event || typeof event !== 'object' || Array.isArray(event)) {
778
- throw new StateOpsError('VALIDATION_ERROR', 'event must be an object');
779
- }
780
- const unknown = Object.keys(event).filter((field) => !EVENT_FIELDS.has(field));
781
- if (unknown.length > 0) {
782
- throw new StateOpsError('VALIDATION_ERROR', `event.${unknown[0]} is not allowed`);
783
- }
784
- const type = eventText(event.type, 'event.type');
785
- const taskId = event.task_id === undefined
786
- ? null : eventText(event.task_id, 'event.task_id', { nullable: true });
787
- const changeId = event.change_id === undefined
788
- ? null : eventText(event.change_id, 'event.change_id', { nullable: true });
789
- const sessionId = event.session_id === undefined
790
- ? null : eventText(event.session_id, 'event.session_id', { nullable: true });
791
- const runtime = event.runtime === undefined
792
- ? null : eventText(event.runtime, 'event.runtime', { nullable: true });
793
- if (runtime !== null && !isSupportedRuntime(runtime)) {
794
- throw new StateOpsError('VALIDATION_ERROR', `unsupported runtime: ${runtime}`);
795
- }
796
- let payloadJson = null;
797
- if (event.payload !== undefined) {
798
- if (!event.payload || typeof event.payload !== 'object' || Array.isArray(event.payload)) {
799
- throw new StateOpsError('VALIDATION_ERROR', 'event.payload must be a JSON object');
800
- }
801
- assertJsonValue(event.payload, 'event.payload');
802
- payloadJson = JSON.stringify(event.payload);
803
- }
804
- const result = db.prepare(
805
- `INSERT INTO events (type, task_id, change_id, session_id, runtime, payload_json)
806
- VALUES (?, ?, ?, ?, ?, ?)`,
807
- ).run(
808
- type,
809
- taskId,
810
- changeId,
811
- sessionId,
812
- runtime,
813
- payloadJson,
814
- );
815
- const row = db.prepare('SELECT id, ts FROM events WHERE id = ?').get(result.lastInsertRowid);
816
- return { event_id: Number(row.id), ts: row.ts };
817
- }
818
-
819
- function appendEvent(db, event) {
820
- return tx(db, () => appendEventInTx(db, event));
821
- }
822
-
823
- // Single static SQL using json_each for the optional types IN-list and
824
- // NULL-pass-through for task_id/session_id. Frozen literal — no concat, no interpolation.
825
- const SUBSCRIBE_EVENTS_SQL = "SELECT id, ts, type, task_id, change_id, session_id, runtime, payload_json FROM events WHERE id > @since_id AND (@types_json IS NULL OR EXISTS (SELECT 1 FROM json_each(@types_json) WHERE value = events.type)) AND (@task_id IS NULL OR task_id = @task_id) AND (@session_id IS NULL OR session_id = @session_id) ORDER BY id ASC LIMIT @maxn";
826
-
827
- function subscribeEventsSince(db, { since_id = 0, types, task_id, session_id, limit = 100 } = {}) {
828
- const events = db.prepare(SUBSCRIBE_EVENTS_SQL).all({
829
- since_id,
830
- types_json: types && types.length > 0 ? JSON.stringify(types) : null,
831
- task_id: task_id || null,
832
- session_id: session_id || null,
833
- maxn: Math.min(Math.max(limit, 1), 500),
834
- });
835
-
836
- for (const e of events) {
837
- if (typeof e.payload_json === 'string') {
838
- try { e.payload = JSON.parse(e.payload_json); } catch { e.payload = null; }
839
- }
840
- delete e.payload_json;
841
- e.id = Number(e.id);
842
- }
843
- const next = events.length > 0 ? events[events.length - 1].id : since_id;
844
- return { events, next_since_id: next };
845
- }
846
-
847
- // ─── sessions ────────────────────────────────────────────────────────────
848
-
849
- function createSession(db, { sid, task_id, runtime, pid = null, worktree_path, artifact_dir, lease_seconds = 1800 }) {
850
- if (!sid || !task_id || !runtime || !worktree_path || !artifact_dir) {
851
- throw new StateOpsError('VALIDATION_ERROR', 'sid, task_id, runtime, worktree_path, artifact_dir required');
852
- }
853
- if (!isSupportedRuntime(runtime)) {
854
- throw new StateOpsError('VALIDATION_ERROR', `unsupported runtime: ${runtime}`);
855
- }
856
- const lease = new Date(Date.now() + lease_seconds * 1000).toISOString();
857
- return tx(db, () => {
858
- if (!readTask(db, task_id)) {
859
- throw new StateOpsError('TASK_NOT_FOUND', `task ${task_id} does not exist`);
860
- }
861
- try {
862
- db.prepare(
863
- `INSERT INTO sessions (sid, task_id, runtime, pid, worktree_path, artifact_dir, status, lease_expires_at)
864
- VALUES (?, ?, ?, ?, ?, ?, 'running', ?)`,
865
- ).run(sid, task_id, runtime, pid, worktree_path, artifact_dir, lease);
866
- } catch (error) {
867
- if (String(error?.code || '').startsWith('SQLITE_CONSTRAINT')) {
868
- const conflict = db.prepare(
869
- "SELECT sid, status FROM sessions WHERE task_id = ? AND status = 'running' LIMIT 1",
870
- ).get(task_id);
871
- if (conflict) {
872
- throw new StateOpsError(
873
- 'ADMISSION_DENIED',
874
- `active session exists for task ${task_id} (${conflict.sid})`,
875
- {
876
- details: {
877
- task_id,
878
- conflict_sid: conflict.sid,
879
- conflict_status: conflict.status,
880
- },
881
- },
882
- );
883
- }
884
- }
885
- throw error;
886
- }
887
- appendEventInTx(db, {
888
- type: 'session_spawned',
889
- task_id,
890
- session_id: sid,
891
- runtime,
892
- payload: { worktree_path, artifact_dir },
893
- });
894
- return db.prepare('SELECT * FROM sessions WHERE sid = ?').get(sid);
895
- });
896
- }
897
-
898
- function updateSession(db, sid, patch = {}) {
899
- return tx(db, () => {
900
- const cur = db.prepare('SELECT * FROM sessions WHERE sid = ?').get(sid);
901
- if (!cur) throw new StateOpsError('SESSION_NOT_FOUND', `no session ${sid}`);
902
- const requestedStatus = patch.status;
903
- if (requestedStatus !== undefined && !SESSION_STATUSES.has(requestedStatus)) {
904
- throw new StateOpsError(
905
- 'VALIDATION_ERROR',
906
- `unsupported session status: ${String(requestedStatus)}`,
907
- );
908
- }
909
- if (requestedStatus !== undefined && requestedStatus !== cur.status) {
910
- if (SESSION_TERMINAL_STATUSES.has(cur.status)) {
911
- throw new StateOpsError(
912
- 'SESSION_TERMINAL_CONFLICT',
913
- `session ${sid} is already ${cur.status} and cannot transition to ${requestedStatus}`,
914
- {
915
- details: {
916
- sid,
917
- current_status: cur.status,
918
- requested_status: requestedStatus,
919
- },
920
- },
921
- );
922
- }
923
- if (!SESSION_STATUS_TRANSITIONS[cur.status]?.has(requestedStatus)) {
924
- throw new StateOpsError(
925
- 'SESSION_STATUS_TRANSITION_INVALID',
926
- `session ${sid} cannot transition from ${cur.status} to ${requestedStatus}`,
927
- {
928
- details: {
929
- sid,
930
- current_status: cur.status,
931
- requested_status: requestedStatus,
932
- },
933
- },
934
- );
935
- }
936
- }
937
- const normalizedPatch = (
938
- SESSION_TERMINAL_STATUSES.has(requestedStatus)
939
- )
940
- ? { ...patch, pid: null }
941
- : patch;
942
- const sets = [];
943
- const params = [];
944
- for (const key of Object.keys(normalizedPatch)) {
945
- if (!SESSION_PATCHABLE.includes(key)) {
946
- throw new StateOpsError('VALIDATION_ERROR', `session field ${key} is not patchable`);
947
- }
948
- sets.push(`${key} = ?`);
949
- params.push(normalizedPatch[key]);
950
- }
951
- if (sets.length === 0) return cur;
952
- params.push(sid, cur.status);
953
- const result = db.prepare(
954
- `UPDATE sessions SET ${sets.join(', ')} WHERE sid = ? AND status = ?`,
955
- ).run(...params);
956
- if (result.changes !== 1) {
957
- const latest = db.prepare('SELECT status FROM sessions WHERE sid = ?').get(sid);
958
- if (latest && SESSION_TERMINAL_STATUSES.has(latest.status)
959
- && requestedStatus !== undefined && latest.status !== requestedStatus) {
960
- throw new StateOpsError(
961
- 'SESSION_TERMINAL_CONFLICT',
962
- `session ${sid} is already ${latest.status} and cannot transition to ${requestedStatus}`,
963
- {
964
- details: {
965
- sid,
966
- current_status: latest.status,
967
- requested_status: requestedStatus,
968
- },
969
- },
970
- );
971
- }
972
- throw new StateOpsError(
973
- 'SESSION_STATUS_CONFLICT',
974
- `session ${sid} changed while applying an update`,
975
- {
976
- retriable: true,
977
- details: {
978
- sid,
979
- expected_status: cur.status,
980
- actual_status: latest?.status || null,
981
- },
982
- },
983
- );
984
- }
985
- if (requestedStatus && requestedStatus !== cur.status) {
986
- const eventType = requestedStatus === 'completed' ? 'session_closed'
987
- : requestedStatus === 'crashed' ? 'session_crashed'
988
- : requestedStatus === 'orphan' ? 'session_orphaned'
989
- : 'session_closed';
990
- appendEventInTx(db, {
991
- type: eventType,
992
- task_id: cur.task_id,
993
- session_id: sid,
994
- runtime: cur.runtime,
995
- payload: {
996
- from: cur.status,
997
- to: requestedStatus,
998
- worker_pid: cur.pid,
999
- },
1000
- });
1001
- }
1002
- return db.prepare('SELECT * FROM sessions WHERE sid = ?').get(sid);
1003
- });
1004
- }
1005
-
1006
- // Frozen SELECT — every literal value flows through @bindings so the SQL
1007
- // string contains no inline single-quoted constants (which trip the hook's
1008
- // SQL-injection scanner when mixed inside a double-quoted host string).
1009
- const LIST_ACTIVE_SESSIONS_SQL = "SELECT * FROM sessions WHERE status = @status AND (@task_id IS NULL OR task_id = @task_id) ORDER BY started_at ASC";
1010
- const LIST_STALE_TASKS_SQL = "SELECT t.* FROM tasks t JOIN sessions s ON s.task_id = t.id WHERE s.status = @status AND s.heartbeat_at < @cutoff";
1011
-
1012
- // ─── tagged task lists (Phase 7.2) ───────────────────────────────────────
1013
- //
1014
- // Tag is a plain TEXT column on tasks (Phase 2). Phase 7.2 adds
1015
- // • deriveBranchTag(cwd) — git HEAD → sanitized tag
1016
- // • switchTaskTag(db, id, new_tag) — change tag + emit event
1017
- // and taps createTask() above to auto-derive when caller passes `_cwd`.
1018
-
1019
- function deriveBranchTag(cwd) {
1020
- if (!cwd) return null;
1021
- // spawnSync is non-throwing — we branch on status rather than catch,
1022
- // because "not a git repo" and "detached HEAD" are legitimate states,
1023
- // not errors to suppress.
1024
- if (!fs.existsSync(path.join(cwd, '.git'))) {
1025
- const probe = spawnSync('git', ['rev-parse', '--git-dir'], { cwd, stdio: 'pipe' });
1026
- if (probe.status !== 0) return null;
1027
- }
1028
- const result = spawnSync('git', ['symbolic-ref', '--short', '-q', 'HEAD'], {
1029
- cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'],
1030
- });
1031
- if (result.status !== 0) return null; // detached HEAD or similar
1032
- const branch = (result.stdout || '').trim();
1033
- if (!branch) return null;
1034
- return sanitizeTag(branch);
1035
- }
1036
-
1037
- function sanitizeTag(raw) {
1038
- // Convert slashes to hyphens; strip @ and any non [\w.-] bytes.
1039
- return raw
1040
- .replace(/\//g, '-')
1041
- .replace(/@/g, '.')
1042
- .replace(/[^\w.-]/g, '')
1043
- .slice(0, 100);
1044
- }
1045
-
1046
- function switchTaskTag(db, task_id, new_tag) {
1047
- if (!task_id) throw new StateOpsError('VALIDATION_ERROR', 'task_id required');
1048
- return tx(db, () => {
1049
- const task = readTask(db, task_id);
1050
- if (!task) throw new StateOpsError('TASK_NOT_FOUND', `task ${task_id} not found`);
1051
- const prev = task.tag;
1052
- db.prepare('UPDATE tasks SET tag = ?, updated_at = ? WHERE id = ?').run(new_tag, nowIso(), task_id);
1053
- appendEventInTx(db, {
1054
- type: 'task_tag_changed',
1055
- task_id,
1056
- payload: { from: prev, to: new_tag },
1057
- });
1058
- return { task_id, tag: new_tag, from: prev };
1059
- });
1060
- }
1061
-
1062
- function listActiveSessions(db, { task_id } = {}) {
1063
- return db.prepare(LIST_ACTIVE_SESSIONS_SQL).all({
1064
- status: 'running',
1065
- task_id: task_id || null,
1066
- });
1067
- }
1068
-
1069
- function listStaleTasks(db, graceSeconds = 300) {
1070
- const cutoff = new Date(Date.now() - graceSeconds * 1000).toISOString();
1071
- return db.prepare(LIST_STALE_TASKS_SQL).all({
1072
- status: 'running',
1073
- cutoff,
1074
- }).map(rowToTask);
1075
- }
1076
-
1077
- function readSession(db, sid) {
1078
- if (!sid) return null;
1079
- const row = db.prepare('SELECT * FROM sessions WHERE sid = ?').get(sid);
1080
- return row || null;
1081
- }
1082
-
1083
- function heartbeatSession(db, sid, { lease_seconds = 1800 } = {}) {
1084
- const cur = readSession(db, sid);
1085
- if (!cur) throw new StateOpsError('SESSION_NOT_FOUND', `no session ${sid}`);
1086
- const now = Date.now();
1087
- const oldExpiry = cur.lease_expires_at ? Date.parse(cur.lease_expires_at) : null;
1088
- if (oldExpiry !== null && oldExpiry < now) {
1089
- throw new StateOpsError('LEASE_EXPIRED', `lease for ${sid} already expired`);
1090
- }
1091
- const nextHeartbeat = new Date(now).toISOString();
1092
- const nextExpiry = new Date(now + lease_seconds * 1000).toISOString();
1093
- updateSession(db, sid, {
1094
- heartbeat_at: nextHeartbeat,
1095
- lease_expires_at: nextExpiry,
1096
- });
1097
- return { ok: true, lease_expires_at: nextExpiry };
1098
- }
1099
-
1100
- function admissionCheck(db, task_id, { freshnessSeconds = 120 } = {}) {
1101
- if (!readTask(db, task_id)) {
1102
- throw new StateOpsError('TASK_NOT_FOUND', `task ${task_id} does not exist`);
1103
- }
1104
- if (isCircuitTripped(db, task_id)) {
1105
- return { can_spawn: false, recommended_action: 'blocked_by_breaker' };
1106
- }
1107
- const active = listActiveSessions(db, { task_id });
1108
- if (active.length === 0) {
1109
- return { can_spawn: true, recommended_action: 'spawn' };
1110
- }
1111
- const conflict = active[0];
1112
- const now = Date.now();
1113
- const heartbeatAge = conflict.heartbeat_at ? now - Date.parse(conflict.heartbeat_at) : null;
1114
- const leaseExpired = conflict.lease_expires_at && Date.parse(conflict.lease_expires_at) < now;
1115
- // Fresh heartbeat — default abandon (D33 conservative default)
1116
- let recommended_action = 'abandon';
1117
- if (leaseExpired || (heartbeatAge !== null && heartbeatAge > freshnessSeconds * 1000)) {
1118
- recommended_action = 'takeover';
1119
- }
1120
- return {
1121
- can_spawn: false,
1122
- conflict: {
1123
- sid: conflict.sid,
1124
- status: conflict.status,
1125
- heartbeat_age_ms: heartbeatAge !== null ? Math.max(0, heartbeatAge) : 0,
1126
- },
1127
- recommended_action,
1128
- };
1129
- }
1130
-
1131
- function reapOrphanSessions(db, {
1132
- graceSeconds = 300,
1133
- exclude_session_ids = [],
1134
- } = {}) {
1135
- const cutoff = new Date(Date.now() - graceSeconds * 1000).toISOString();
1136
- const excluded = new Set(exclude_session_ids);
1137
- const candidates = db.prepare(
1138
- "SELECT * FROM sessions WHERE status = 'running' AND (lease_expires_at < ? OR heartbeat_at < ?)",
1139
- ).all(cutoff, cutoff).filter((session) => !excluded.has(session.sid));
1140
- const reaped = [];
1141
- for (const s of candidates) {
1142
- updateSession(db, s.sid, { status: 'orphan' });
1143
- reaped.push(s.sid);
1144
- }
1145
- return { reaped, count: reaped.length };
1146
- }
1147
-
1148
- // ─── circuit breaker (Phase 5.2) ─────────────────────────────────────────
1149
- //
1150
- // Per-task failure accumulator. Each `recordTaskFailure` upserts the row and
1151
- // emits `task_failure`; when the count crosses `fail_threshold` for the first
1152
- // time, `tripped_at` is stamped and a single `task_circuit_broken` event
1153
- // fires. Admission control refuses new spawns while tripped — Phase 8B will
1154
- // add automatic reset strategies; for now `resetCircuitBreaker` is manual or
1155
- // called on task completion.
1156
-
1157
- const DEFAULT_FAIL_THRESHOLD = 3;
1158
-
1159
- function readCircuitBreakerRow(db, task_id) {
1160
- return db.prepare('SELECT * FROM circuit_breaker WHERE task_id = ?').get(task_id) || null;
1161
- }
1162
-
1163
- function recordTaskFailureInTx(db, task_id, {
1164
- reason = 'unknown',
1165
- session_id = null,
1166
- fail_threshold = DEFAULT_FAIL_THRESHOLD,
1167
- } = {}) {
1168
- if (!task_id) throw new StateOpsError('VALIDATION_ERROR', 'task_id required');
1169
- const now = nowIso();
1170
- const existing = readCircuitBreakerRow(db, task_id);
1171
- const wasTripped = !!(existing && existing.tripped_at);
1172
- const newCount = (existing ? existing.failure_count : 0) + 1;
1173
- const crossesThreshold = !wasTripped && newCount >= fail_threshold;
1174
- const trippedAt = wasTripped
1175
- ? existing.tripped_at
1176
- : (crossesThreshold ? now : null);
1177
-
1178
- if (existing) {
1179
- db.prepare(
1180
- 'UPDATE circuit_breaker SET failure_count = ?, tripped_at = ?, last_failure_at = ?, last_failure_reason = ? WHERE task_id = ?',
1181
- ).run(newCount, trippedAt, now, reason, task_id);
1182
- } else {
1183
- db.prepare(
1184
- 'INSERT INTO circuit_breaker (task_id, failure_count, tripped_at, last_failure_at, last_failure_reason) VALUES (?, ?, ?, ?, ?)',
1185
- ).run(task_id, newCount, trippedAt, now, reason);
1186
- }
1187
-
1188
- appendEventInTx(db, {
1189
- type: 'task_failure',
1190
- task_id,
1191
- session_id,
1192
- payload: { reason, failure_count: newCount },
1193
- });
1194
-
1195
- if (crossesThreshold) {
1196
- appendEventInTx(db, {
1197
- type: 'task_circuit_broken',
1198
- task_id,
1199
- session_id,
1200
- payload: { failure_count: newCount, threshold: fail_threshold },
1201
- });
1202
- }
1203
-
1204
- return { failure_count: newCount, tripped: crossesThreshold || wasTripped };
1205
- }
1206
-
1207
- function recordTaskFailure(db, task_id, options = {}) {
1208
- return tx(db, () => recordTaskFailureInTx(db, task_id, options));
1209
- }
1210
-
1211
- function crashOrphanSession(db, sid, {
1212
- reason = 'session_crashed_on_boot',
1213
- fail_threshold = DEFAULT_FAIL_THRESHOLD,
1214
- } = {}) {
1215
- if (!sid) throw new StateOpsError('VALIDATION_ERROR', 'sid required');
1216
- return tx(db, () => {
1217
- const session = db.prepare('SELECT * FROM sessions WHERE sid = ?').get(sid);
1218
- if (!session) {
1219
- throw new StateOpsError('SESSION_NOT_FOUND', `no session ${sid}`);
1220
- }
1221
- if (session.status !== 'orphan') {
1222
- return {
1223
- changed: false,
1224
- status: session.status,
1225
- failure_recorded: false,
1226
- };
1227
- }
1228
- const result = db.prepare(
1229
- "UPDATE sessions SET status = 'crashed', pid = NULL WHERE sid = ? AND status = 'orphan'",
1230
- ).run(sid);
1231
- if (result.changes !== 1) {
1232
- return {
1233
- changed: false,
1234
- status: db.prepare('SELECT status FROM sessions WHERE sid = ?').get(sid)?.status || null,
1235
- failure_recorded: false,
1236
- };
1237
- }
1238
- appendEventInTx(db, {
1239
- type: 'session_crashed',
1240
- task_id: session.task_id,
1241
- session_id: sid,
1242
- runtime: session.runtime,
1243
- payload: {
1244
- from: 'orphan',
1245
- to: 'crashed',
1246
- worker_pid: session.pid,
1247
- },
1248
- });
1249
- const failure = recordTaskFailureInTx(db, session.task_id, {
1250
- reason,
1251
- session_id: sid,
1252
- fail_threshold,
1253
- });
1254
- return {
1255
- changed: true,
1256
- status: 'crashed',
1257
- failure_recorded: true,
1258
- failure,
1259
- };
1260
- });
1261
- }
1262
-
1263
- function resetCircuitBreaker(db, task_id) {
1264
- if (!task_id) throw new StateOpsError('VALIDATION_ERROR', 'task_id required');
1265
- return tx(db, () => {
1266
- const existing = readCircuitBreakerRow(db, task_id);
1267
- if (!existing) return { reset: false };
1268
- db.prepare(
1269
- 'UPDATE circuit_breaker SET failure_count = 0, tripped_at = NULL, last_failure_reason = NULL WHERE task_id = ?',
1270
- ).run(task_id);
1271
- appendEventInTx(db, {
1272
- type: 'task_circuit_reset',
1273
- task_id,
1274
- payload: {
1275
- prior_count: existing.failure_count,
1276
- was_tripped: !!existing.tripped_at,
1277
- },
1278
- });
1279
- return { reset: true, prior_count: existing.failure_count };
1280
- });
1281
- }
1282
-
1283
- function isCircuitTripped(db, task_id) {
1284
- const row = db.prepare('SELECT tripped_at FROM circuit_breaker WHERE task_id = ?').get(task_id);
1285
- return !!(row && row.tripped_at);
1286
- }
1287
-
1288
- // ─── staleness (Phase 5.3) ───────────────────────────────────────────────
1289
- //
1290
- // When a spec section changes, every pending task whose trace_to points at
1291
- // that section needs to be flagged stale so the next scheduler skips it
1292
- // until the context is refreshed. Only pending tasks are touched —
1293
- // in-progress/blocked/completed tasks are the running agent's concern.
1294
-
1295
- // Frozen SELECT: variadic section list via json_each to avoid dynamic SQL.
1296
- const LIST_PENDING_BY_SECTIONS_SQL = "SELECT id, trace_to, stale FROM tasks WHERE status = 'pending' AND trace_to IS NOT NULL AND EXISTS (SELECT 1 FROM json_each(@sections_json) WHERE value = tasks.trace_to)";
1297
-
1298
- function markTasksStaleBySpecSections(db, sections) {
1299
- if (!Array.isArray(sections) || sections.length === 0) {
1300
- return { marked_count: 0, marked_ids: [] };
1301
- }
1302
- return tx(db, () => {
1303
- const candidates = db.prepare(LIST_PENDING_BY_SECTIONS_SQL).all({
1304
- sections_json: JSON.stringify(sections),
1305
- });
1306
- const toMark = candidates.filter((r) => !r.stale);
1307
- if (toMark.length === 0) return { marked_count: 0, marked_ids: [] };
1308
- const ts = nowIso();
1309
- const update = db.prepare('UPDATE tasks SET stale = 1, updated_at = ? WHERE id = ?');
1310
- for (const row of toMark) {
1311
- update.run(ts, row.id);
1312
- appendEventInTx(db, {
1313
- type: 'task_stale_marked',
1314
- task_id: row.id,
1315
- payload: { sections, trace_to: row.trace_to },
1316
- });
1317
- }
1318
- return { marked_count: toMark.length, marked_ids: toMark.map((r) => r.id) };
1319
- });
1320
- }
1321
-
1322
- // ─── telemetry aggregation (Phase 6.2) ───────────────────────────────────
1323
- //
1324
- // Reads are LEFT-JOINed against `sessions` so CLI telemetry (session_id=null)
1325
- // shows up under a synthetic "unknown" runtime bucket. Writes go through
1326
- // mcp-server/lib/telemetry.cjs — don't insert telemetry rows directly.
1327
-
1328
- const AGGREGATE_BY_RUNTIME_SQL = "SELECT COALESCE(s.runtime, 'unknown') AS runtime, COUNT(*) AS calls, COALESCE(SUM(t.tokens_input), 0) AS tokens_in, COALESCE(SUM(t.tokens_output), 0) AS tokens_out, COALESCE(SUM(t.cost_usd), 0.0) AS cost_usd, SUM(CASE WHEN t.event_type = 'token_usage' AND (COALESCE(t.tokens_input, 0) > 0 OR COALESCE(t.tokens_output, 0) > 0) AND t.cost_usd IS NULL THEN 1 ELSE 0 END) AS unpriced_usage_events, SUM(CASE WHEN t.event_type = 'token_usage' AND (COALESCE(t.tokens_input, 0) > 0 OR COALESCE(t.tokens_output, 0) > 0) AND t.cost_usd IS NOT NULL THEN 1 ELSE 0 END) AS priced_usage_events FROM telemetry t LEFT JOIN sessions s ON t.session_id = s.sid WHERE (@since IS NULL OR t.ts >= @since) GROUP BY COALESCE(s.runtime, 'unknown') ORDER BY cost_usd DESC";
1329
-
1330
- const AGGREGATE_BY_TASK_SQL = "SELECT s.task_id AS task_id, COUNT(*) AS calls, COALESCE(SUM(t.tokens_input), 0) AS tokens_in, COALESCE(SUM(t.tokens_output), 0) AS tokens_out, COALESCE(SUM(t.cost_usd), 0.0) AS cost_usd, SUM(CASE WHEN t.event_type = 'token_usage' AND (COALESCE(t.tokens_input, 0) > 0 OR COALESCE(t.tokens_output, 0) > 0) AND t.cost_usd IS NULL THEN 1 ELSE 0 END) AS unpriced_usage_events, SUM(CASE WHEN t.event_type = 'token_usage' AND (COALESCE(t.tokens_input, 0) > 0 OR COALESCE(t.tokens_output, 0) > 0) AND t.cost_usd IS NOT NULL THEN 1 ELSE 0 END) AS priced_usage_events FROM telemetry t JOIN sessions s ON t.session_id = s.sid WHERE s.task_id IS NOT NULL AND (@since IS NULL OR t.ts >= @since) GROUP BY s.task_id ORDER BY cost_usd DESC LIMIT @maxn";
1331
-
1332
- const AGGREGATE_BY_SESSION_SQL = "SELECT @sid AS session_id, COUNT(*) AS tool_calls, COALESCE(SUM(tokens_input), 0) AS tokens_in, COALESCE(SUM(tokens_output), 0) AS tokens_out, COALESCE(SUM(cost_usd), 0.0) AS cost_usd, SUM(CASE WHEN event_type = 'token_usage' AND (COALESCE(tokens_input, 0) > 0 OR COALESCE(tokens_output, 0) > 0) AND cost_usd IS NULL THEN 1 ELSE 0 END) AS unpriced_usage_events, SUM(CASE WHEN event_type = 'token_usage' AND (COALESCE(tokens_input, 0) > 0 OR COALESCE(tokens_output, 0) > 0) AND cost_usd IS NOT NULL THEN 1 ELSE 0 END) AS priced_usage_events FROM telemetry WHERE session_id = @sid";
1333
-
1334
- function aggregateTelemetryByRuntime(db, { since = null } = {}) {
1335
- return db.prepare(AGGREGATE_BY_RUNTIME_SQL).all({ since });
1336
- }
1337
-
1338
- function aggregateTelemetryByTask(db, { since = null, limit = 10 } = {}) {
1339
- return db.prepare(AGGREGATE_BY_TASK_SQL).all({
1340
- since,
1341
- maxn: Math.min(Math.max(limit, 1), 500),
1342
- });
1343
- }
1344
-
1345
- function aggregateTelemetryBySession(db, sid) {
1346
- if (!sid) throw new StateOpsError('VALIDATION_ERROR', 'sid required');
1347
- return db.prepare(AGGREGATE_BY_SESSION_SQL).get({ sid });
1348
- }
1349
-
1350
- const LIST_SPEC_CHANGED_SQL = "SELECT id, payload_json FROM events WHERE id > @since_id AND type = 'spec_changed' ORDER BY id ASC LIMIT @maxn";
1351
-
1352
- function consumeSpecChangedEvents(db, { since_id = 0, limit = 100 } = {}) {
1353
- const rows = db.prepare(LIST_SPEC_CHANGED_SQL).all({
1354
- since_id,
1355
- maxn: Math.min(Math.max(limit, 1), 500),
1356
- });
1357
- if (rows.length === 0) return { processed: 0, next_since_id: since_id, marked_ids: [] };
1358
-
1359
- const allMarked = [];
1360
- let lastId = since_id;
1361
- for (const r of rows) {
1362
- let sections = null;
1363
- if (typeof r.payload_json === 'string') {
1364
- try {
1365
- const payload = JSON.parse(r.payload_json);
1366
- sections = Array.isArray(payload && payload.sections) ? payload.sections : null;
1367
- } catch { sections = null; }
1368
- }
1369
- if (sections && sections.length > 0) {
1370
- const out = markTasksStaleBySpecSections(db, sections);
1371
- for (const id of out.marked_ids) allMarked.push(id);
1372
- }
1373
- lastId = Number(r.id);
1374
- }
1375
- return { processed: rows.length, next_since_id: lastId, marked_ids: allMarked };
1376
- }
1377
-
1378
- // ─── exports ─────────────────────────────────────────────────────────────
1379
-
1380
- module.exports = {
1381
- StateOpsError,
1382
- STATUS_TRANSITIONS,
1383
- PATCHABLE_FIELDS,
1384
- taskContractBlockers,
1385
- assertTaskExecutionContract,
1386
- SESSION_PATCHABLE,
1387
- openStateDb,
1388
- tx,
1389
- withRetry,
1390
- // tasks
1391
- readTask,
1392
- listTasks,
1393
- resolveTaskCreationChangeId,
1394
- createTask,
1395
- patchTask,
1396
- updateTaskStatus,
1397
- deleteTask,
1398
- // events
1399
- appendEvent,
1400
- appendEventInTx,
1401
- subscribeEventsSince,
1402
- // sessions
1403
- createSession,
1404
- updateSession,
1405
- listActiveSessions,
1406
- listStaleTasks,
1407
- readSession,
1408
- heartbeatSession,
1409
- admissionCheck,
1410
- reapOrphanSessions,
1411
- crashOrphanSession,
1412
- // circuit breaker
1413
- recordTaskFailure,
1414
- resetCircuitBreaker,
1415
- isCircuitTripped,
1416
- DEFAULT_FAIL_THRESHOLD,
1417
- // staleness
1418
- markTasksStaleBySpecSections,
1419
- consumeSpecChangedEvents,
1420
- // telemetry aggregation
1421
- aggregateTelemetryByRuntime,
1422
- aggregateTelemetryByTask,
1423
- aggregateTelemetryBySession,
1424
- // tagged task lists
1425
- deriveBranchTag,
1426
- sanitizeTag,
1427
- switchTaskTag,
1428
- };