@sqdr2/dsh-fusion 0.0.0-stage → 0.3.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 (211) hide show
  1. package/INSTALL.md +89 -0
  2. package/INSTALL.zh.md +86 -0
  3. package/LICENSE +201 -0
  4. package/README.en.md +191 -0
  5. package/README.md +180 -2
  6. package/contracts/fusion.ts +279 -0
  7. package/contracts/storage.sql +86 -0
  8. package/cordis.patch.yml +3 -0
  9. package/docs/COMPATIBILITY.md +162 -0
  10. package/docs/EVIDENCE.md +141 -0
  11. package/dshx.yml +39 -0
  12. package/lib/client.js +1920 -0
  13. package/lib/client.js.map +1 -0
  14. package/lib/dsh-fusion.js +12661 -0
  15. package/lib/types/activity.d.ts +20 -0
  16. package/lib/types/approval/logical.d.ts +10 -0
  17. package/lib/types/benchmark/naive.d.ts +37 -0
  18. package/lib/types/benchmark/native-run.d.ts +68 -0
  19. package/lib/types/benchmark/output-limits.d.ts +19 -0
  20. package/lib/types/bilingual.d.ts +2 -0
  21. package/lib/types/client/ActivityRows.d.ts +8 -0
  22. package/lib/types/client/FusionCache.d.ts +4 -0
  23. package/lib/types/client/FusionHint.d.ts +9 -0
  24. package/lib/types/client/FusionHistory.d.ts +5 -0
  25. package/lib/types/client/FusionPanelIcon.d.ts +13 -0
  26. package/lib/types/client/FusionRecovery.d.ts +5 -0
  27. package/lib/types/client/FusionSettings.d.ts +9 -0
  28. package/lib/types/client/FusionStatus.d.ts +5 -0
  29. package/lib/types/client/FusionTool.d.ts +4 -0
  30. package/lib/types/client/activity-model.d.ts +14 -0
  31. package/lib/types/client/i18n.d.ts +15 -0
  32. package/lib/types/client/index.d.ts +22 -0
  33. package/lib/types/client/settings-events.d.ts +2 -0
  34. package/lib/types/client/status-polling.d.ts +9 -0
  35. package/lib/types/context/guard.d.ts +21 -0
  36. package/lib/types/context/handoff.d.ts +17 -0
  37. package/lib/types/contracts.d.ts +449 -0
  38. package/lib/types/digest.d.ts +5 -0
  39. package/lib/types/dsh-fusion.d.ts +13 -0
  40. package/lib/types/errors.d.ts +28 -0
  41. package/lib/types/evidence/gate.d.ts +23 -0
  42. package/lib/types/evidence/receipts.d.ts +65 -0
  43. package/lib/types/execution/write-lease.d.ts +42 -0
  44. package/lib/types/host/bindings.d.ts +30 -0
  45. package/lib/types/host/cache-defaults.d.ts +48 -0
  46. package/lib/types/host/cache-policy.d.ts +155 -0
  47. package/lib/types/host/change-evidence.d.ts +38 -0
  48. package/lib/types/host/compaction-routing.d.ts +15 -0
  49. package/lib/types/host/coordinator.d.ts +169 -0
  50. package/lib/types/host/evidence-page.d.ts +13 -0
  51. package/lib/types/host/history.d.ts +88 -0
  52. package/lib/types/host/http.d.ts +4 -0
  53. package/lib/types/host/model-output.d.ts +13 -0
  54. package/lib/types/host/native-activity.d.ts +26 -0
  55. package/lib/types/host/native-approvals.d.ts +29 -0
  56. package/lib/types/host/native-auxiliary.d.ts +17 -0
  57. package/lib/types/host/native-budget.d.ts +33 -0
  58. package/lib/types/host/native-checks.d.ts +104 -0
  59. package/lib/types/host/native-compaction.d.ts +8 -0
  60. package/lib/types/host/native-context.d.ts +36 -0
  61. package/lib/types/host/native-effects.d.ts +75 -0
  62. package/lib/types/host/native-evidence.d.ts +4 -0
  63. package/lib/types/host/native-exploration.d.ts +15 -0
  64. package/lib/types/host/native-keepalive.d.ts +82 -0
  65. package/lib/types/host/native-model-control.d.ts +99 -0
  66. package/lib/types/host/native-on-demand-context.d.ts +39 -0
  67. package/lib/types/host/native-request.d.ts +5 -0
  68. package/lib/types/host/native-review.d.ts +11 -0
  69. package/lib/types/host/native-role-sandbox.d.ts +49 -0
  70. package/lib/types/host/native-scopes.d.ts +49 -0
  71. package/lib/types/host/native-selection.d.ts +29 -0
  72. package/lib/types/host/native-task-context.d.ts +39 -0
  73. package/lib/types/host/native-usage.d.ts +46 -0
  74. package/lib/types/host/native-worker-brief.d.ts +74 -0
  75. package/lib/types/host/native-worker-notices.d.ts +15 -0
  76. package/lib/types/host/native-worker.d.ts +44 -0
  77. package/lib/types/host/native-workflow.d.ts +41 -0
  78. package/lib/types/host/route-transport.d.ts +22 -0
  79. package/lib/types/host/settings.d.ts +39 -0
  80. package/lib/types/host/shell.d.ts +11 -0
  81. package/lib/types/host/status.d.ts +7 -0
  82. package/lib/types/host/subagent-seam.d.ts +10 -0
  83. package/lib/types/host/tool-table.d.ts +35 -0
  84. package/lib/types/host/worker-terminal.d.ts +68 -0
  85. package/lib/types/host/workspace-guard.d.ts +13 -0
  86. package/lib/types/host/workspace.d.ts +52 -0
  87. package/lib/types/identity.d.ts +64 -0
  88. package/lib/types/index.d.ts +34 -0
  89. package/lib/types/message-source.d.ts +29 -0
  90. package/lib/types/profile/compactor.d.ts +5 -0
  91. package/lib/types/profile/keepalive.d.ts +3 -0
  92. package/lib/types/profile/resolve.d.ts +17 -0
  93. package/lib/types/prompts.d.ts +8 -0
  94. package/lib/types/recovery/reconcile.d.ts +28 -0
  95. package/lib/types/review/dispatch.d.ts +20 -0
  96. package/lib/types/review/protocol.d.ts +51 -0
  97. package/lib/types/review/ticket.d.ts +32 -0
  98. package/lib/types/status.d.ts +88 -0
  99. package/lib/types/store-migration.d.ts +29 -0
  100. package/lib/types/task/deletion.d.ts +64 -0
  101. package/lib/types/task/reducer.d.ts +6 -0
  102. package/lib/types/task/sqlite-store.d.ts +71 -0
  103. package/lib/types/task/state.d.ts +51 -0
  104. package/lib/types/task/status-index.d.ts +28 -0
  105. package/lib/types/task/store.d.ts +48 -0
  106. package/lib/types/usage/budget.d.ts +27 -0
  107. package/lib/types/usage/ledger.d.ts +66 -0
  108. package/lib/types/usage/normalize.d.ts +50 -0
  109. package/lib/types/usage/pricing.d.ts +18 -0
  110. package/package.json +176 -4
  111. package/profiles/fusion-auto.example.json +45 -0
  112. package/prompts/compact.md +7 -0
  113. package/prompts/lead.md +42 -0
  114. package/prompts/model-lead.md +13 -0
  115. package/prompts/model-worker.md +5 -0
  116. package/prompts/worker.md +21 -0
  117. package/src/activity.ts +23 -0
  118. package/src/approval/logical.ts +44 -0
  119. package/src/benchmark/naive.ts +216 -0
  120. package/src/benchmark/native-run.ts +215 -0
  121. package/src/benchmark/output-limits.ts +33 -0
  122. package/src/bilingual.ts +2 -0
  123. package/src/client/ActivityRows.tsx +59 -0
  124. package/src/client/FusionCache.tsx +130 -0
  125. package/src/client/FusionHint.tsx +39 -0
  126. package/src/client/FusionHistory.tsx +247 -0
  127. package/src/client/FusionPanelIcon.tsx +39 -0
  128. package/src/client/FusionRecovery.tsx +69 -0
  129. package/src/client/FusionSettings.tsx +205 -0
  130. package/src/client/FusionStatus.tsx +103 -0
  131. package/src/client/FusionTool.tsx +66 -0
  132. package/src/client/activity-model.ts +31 -0
  133. package/src/client/i18n.ts +30 -0
  134. package/src/client/index.ts +50 -0
  135. package/src/client/settings-events.ts +3 -0
  136. package/src/client/status-polling.ts +88 -0
  137. package/src/context/guard.ts +81 -0
  138. package/src/context/handoff.ts +28 -0
  139. package/src/contracts.ts +449 -0
  140. package/src/digest.ts +28 -0
  141. package/src/dsh-fusion.ts +321 -0
  142. package/src/errors.ts +34 -0
  143. package/src/evidence/gate.ts +290 -0
  144. package/src/evidence/receipts.ts +175 -0
  145. package/src/execution/write-lease.ts +187 -0
  146. package/src/host/bindings.ts +91 -0
  147. package/src/host/cache-defaults.ts +103 -0
  148. package/src/host/cache-policy.ts +168 -0
  149. package/src/host/change-evidence.ts +156 -0
  150. package/src/host/compaction-routing.ts +46 -0
  151. package/src/host/coordinator.ts +2160 -0
  152. package/src/host/evidence-page.ts +25 -0
  153. package/src/host/history.ts +114 -0
  154. package/src/host/http.ts +30 -0
  155. package/src/host/model-output.ts +22 -0
  156. package/src/host/native-activity.ts +199 -0
  157. package/src/host/native-approvals.ts +196 -0
  158. package/src/host/native-auxiliary.ts +19 -0
  159. package/src/host/native-budget.ts +112 -0
  160. package/src/host/native-checks.ts +474 -0
  161. package/src/host/native-compaction.ts +34 -0
  162. package/src/host/native-context.ts +204 -0
  163. package/src/host/native-effects.ts +260 -0
  164. package/src/host/native-evidence.ts +31 -0
  165. package/src/host/native-exploration.ts +114 -0
  166. package/src/host/native-keepalive.ts +292 -0
  167. package/src/host/native-model-control.ts +298 -0
  168. package/src/host/native-on-demand-context.ts +74 -0
  169. package/src/host/native-request.ts +12 -0
  170. package/src/host/native-review.ts +51 -0
  171. package/src/host/native-role-sandbox.ts +171 -0
  172. package/src/host/native-scopes.ts +203 -0
  173. package/src/host/native-selection.ts +110 -0
  174. package/src/host/native-task-context.ts +264 -0
  175. package/src/host/native-usage.ts +121 -0
  176. package/src/host/native-worker-brief.ts +122 -0
  177. package/src/host/native-worker-notices.ts +72 -0
  178. package/src/host/native-worker.ts +215 -0
  179. package/src/host/native-workflow.ts +103 -0
  180. package/src/host/route-transport.ts +54 -0
  181. package/src/host/settings.ts +93 -0
  182. package/src/host/shell.ts +13 -0
  183. package/src/host/status.ts +112 -0
  184. package/src/host/subagent-seam.ts +20 -0
  185. package/src/host/tool-table.ts +56 -0
  186. package/src/host/worker-terminal.ts +91 -0
  187. package/src/host/workspace-guard.ts +17 -0
  188. package/src/host/workspace.ts +184 -0
  189. package/src/identity.ts +76 -0
  190. package/src/index.ts +50 -0
  191. package/src/message-source.ts +31 -0
  192. package/src/profile/compactor.ts +26 -0
  193. package/src/profile/keepalive.ts +13 -0
  194. package/src/profile/resolve.ts +131 -0
  195. package/src/prompts.ts +32 -0
  196. package/src/recovery/reconcile.ts +38 -0
  197. package/src/review/dispatch.ts +48 -0
  198. package/src/review/protocol.ts +212 -0
  199. package/src/review/ticket.ts +166 -0
  200. package/src/status.ts +55 -0
  201. package/src/store-migration.ts +65 -0
  202. package/src/task/deletion.ts +105 -0
  203. package/src/task/reducer.ts +679 -0
  204. package/src/task/sqlite-store.ts +386 -0
  205. package/src/task/state.ts +72 -0
  206. package/src/task/status-index.ts +198 -0
  207. package/src/task/store.ts +287 -0
  208. package/src/usage/budget.ts +88 -0
  209. package/src/usage/ledger.ts +218 -0
  210. package/src/usage/normalize.ts +308 -0
  211. package/src/usage/pricing.ts +95 -0
@@ -0,0 +1,679 @@
1
+ import {
2
+ CONTROL_BLOCKED,
3
+ EVENT_CONFLICT,
4
+ FusionError,
5
+ REVIEW_RESULT_CONFLICT,
6
+ REVIEW_TICKET_REQUIRED,
7
+ REVISION_STALE,
8
+ WORK_ORDER_CONFLICT,
9
+ } from '../errors.js'
10
+ import type {
11
+ ControlState,
12
+ FusionEvent,
13
+ LogicalApproval,
14
+ Phase,
15
+ ReviewGate,
16
+ ReviewResultV2,
17
+ WorkerReport,
18
+ } from '../contracts.js'
19
+ import { TERMINAL_PHASES } from '../contracts.js'
20
+ import { digestOf } from '../digest.js'
21
+ import { assertCurrentSubject, reportSubject, reviewReadinessReceipt, sameSubject } from '../review/ticket.js'
22
+ import type { TaskState } from './state.js'
23
+
24
+ const WAITING: ReadonlySet<Phase> = new Set([
25
+ 'WAITING_APPROVAL',
26
+ 'WAITING_USER',
27
+ 'WAITING_BUDGET',
28
+ 'RECOVERING',
29
+ 'PAUSED',
30
+ 'STOPPING',
31
+ 'NEEDS_DECISION',
32
+ ])
33
+
34
+ function initialControl(): ControlState {
35
+ return {
36
+ mode: 'running',
37
+ pendingApprovalIds: [],
38
+ budgetBlocked: false,
39
+ recovering: false,
40
+ outcomeUnknown: false,
41
+ }
42
+ }
43
+
44
+ function waiting(state: TaskState, phase: Phase): TaskState {
45
+ return { ...state, resumePhase: WAITING.has(state.phase) ? state.resumePhase : state.phase, phase }
46
+ }
47
+
48
+ function resume(state: TaskState, fallback: Phase): TaskState {
49
+ return { ...state, phase: state.resumePhase ?? fallback, resumePhase: undefined }
50
+ }
51
+
52
+ function patchControl(state: TaskState, patch: Partial<ControlState>): ControlState {
53
+ return { ...state.control, ...patch }
54
+ }
55
+
56
+ /** Releasing one gate never releases another; late review results remain actionable. */
57
+ function projectReleasedControl(state: TaskState): TaskState {
58
+ const control = state.control
59
+ if (control.mode !== 'running') return state
60
+ if (control.pendingApprovalIds.length) return { ...state, phase: 'WAITING_APPROVAL' }
61
+ if (control.outcomeUnknown || state.outcomeUnknown) return { ...state, phase: 'NEEDS_DECISION' }
62
+ if (control.recovering) return { ...state, phase: 'RECOVERING' }
63
+ if (control.budgetBlocked) return { ...state, phase: 'WAITING_BUDGET' }
64
+ if (state.reviewResult) {
65
+ const decision = state.reviewResult.decision
66
+ return {
67
+ ...state, resumePhase: undefined,
68
+ phase: decision === 'rework' ? 'REWORK'
69
+ : decision === 'accept' && state.reviewResult.protocolValid ? 'REVIEWING' : 'NEEDS_DECISION',
70
+ }
71
+ }
72
+ return resume(state, state.intent === 'DIRECT' ? 'DIRECT' : 'PLANNING')
73
+ }
74
+
75
+ function requireReconciliation(state: TaskState, evidenceRef: string): void {
76
+ if (!evidenceRef.trim() || writerBusy(state)) {
77
+ throw new FusionError(CONTROL_BLOCKED, 'reconciliation requires evidence and a quiescent writer')
78
+ }
79
+ }
80
+
81
+ function applyPayload(state: TaskState | undefined, event: FusionEvent): TaskState {
82
+ switch (event.type) {
83
+ case 'task/created':
84
+ if (state) throw new FusionError(EVENT_CONFLICT, 'task already exists')
85
+ if (event.seq !== 1) throw new FusionError(EVENT_CONFLICT, 'task/created must be seq 1')
86
+ return {
87
+ schemaVersion: 1,
88
+ taskId: event.taskId,
89
+ revision: event.revision,
90
+ seq: event.seq,
91
+ phase: 'READY',
92
+ parent: event.payload.parent,
93
+ selection: event.payload.selection,
94
+ verification: 'unverified',
95
+ pendingApprovalIds: [],
96
+ pendingApprovals: {},
97
+ control: initialControl(),
98
+ outcomeUnknown: false,
99
+ reviewGeneration: 0,
100
+ ignoredReviewResults: [],
101
+ staleValidations: [],
102
+ appliedEventIds: [event.id],
103
+ }
104
+ case 'requirements/revised':
105
+ return resetReviewForNewSubject({
106
+ ...need(state, event),
107
+ revision: event.revision,
108
+ phase: state!.phase === 'READY' ? 'PLANNING' : state!.phase,
109
+ })
110
+ case 'profile/frozen':
111
+ return applyProfileFrozen(state, event)
112
+ case 'intent/chosen':
113
+ return {
114
+ ...need(state, event),
115
+ intent: event.payload.intent,
116
+ phase: event.payload.intent === 'DIRECT' ? 'DIRECT' : 'PLANNING',
117
+ }
118
+ case 'work-order/prepared':
119
+ return applyWorkOrderPrepared(state, event)
120
+ case 'work-order/acceptance-amended': {
121
+ // Only the Lead amends its own frozen checks. Earlier reports were
122
+ // verified against the old plans, so no review may carry over.
123
+ const next = need(state, event), order = next.currentWorkOrder
124
+ if (!order || order.mode === 'explore' || order.id !== event.payload.workOrderId || next.lease) {
125
+ throw new FusionError(WORK_ORDER_CONFLICT, 'Acceptance amendment requires the current quiescent implementation work order')
126
+ }
127
+ return resetReviewForNewSubject({ ...next, currentWorkOrder: { ...order, acceptance: event.payload.acceptance } })
128
+ }
129
+ case 'work-order/scope-expanded': {
130
+ // Only the Lead widens its own frozen scope, and only by adding paths.
131
+ const next = need(state, event), order = next.currentWorkOrder
132
+ if (!order || order.mode === 'explore' || order.mode === 'text' || order.id !== event.payload.workOrderId || next.lease
133
+ || order.allowedPaths.some(path => !event.payload.allowedPaths.includes(path))) {
134
+ throw new FusionError(WORK_ORDER_CONFLICT, 'Scope expansion requires the current quiescent implementation work order and keeps every allowed path')
135
+ }
136
+ return resetReviewForNewSubject({ ...next, currentWorkOrder: { ...order, allowedPaths: event.payload.allowedPaths } })
137
+ }
138
+ case 'child/accepted':
139
+ return {
140
+ ...need(state, event),
141
+ exploration: state?.currentWorkOrder?.mode === 'explore' ? undefined : state?.exploration,
142
+ acceptedChild: event.payload.child,
143
+ acceptedMessageId: event.payload.messageId,
144
+ phase: 'WORKER_RUNNING',
145
+ }
146
+ case 'child/claimed':
147
+ return { ...need(state, event), phase: 'WORKER_RUNNING' }
148
+ case 'exploration/recorded': {
149
+ const next = need(state, event), report = event.payload.report, order = next.currentWorkOrder
150
+ assertControlAllowsExecution(next)
151
+ if (order?.mode !== 'explore' || order.id !== report.workOrderId || order.revision !== report.revision
152
+ || next.revision !== report.revision || order.baseSnapshot !== report.snapshot || next.lease) {
153
+ throw new FusionError(EVENT_CONFLICT, 'Exploration requires the current read-only work order and unchanged snapshot')
154
+ }
155
+ return { ...next, exploration: report, phase: 'PLANNING', lastSnapshot: report.snapshot }
156
+ }
157
+ case 'report/submitted':
158
+ return applySubmittedReport(state, event)
159
+ case 'report/validated':
160
+ return applyValidatedReport(state, event)
161
+ case 'review/requested':
162
+ return applyReviewRequested(state, event)
163
+ case 'review/completed':
164
+ return applyReviewCompleted(state, event)
165
+ case 'lease/acquired':
166
+ return { ...need(state, event), lease: event.payload.lease }
167
+ case 'lease/released':
168
+ return { ...need(state, event), lease: undefined }
169
+ case 'approval/pending':
170
+ return applyApprovalPending(state, event)
171
+ case 'approval/answered':
172
+ return applyApprovalAnswered(state, event)
173
+ case 'budget/blocked':
174
+ return {
175
+ ...waiting(need(state, event), 'WAITING_BUDGET'),
176
+ control: patchControl(need(state, event), { budgetBlocked: true }),
177
+ }
178
+ case 'budget/unblocked': {
179
+ const next = need(state, event)
180
+ if (!event.payload.authorizationId.trim()) throw new FusionError(CONTROL_BLOCKED, 'budget authorization is required')
181
+ return projectReleasedControl({ ...next, control: patchControl(next, { budgetBlocked: false }) })
182
+ }
183
+ case 'checkpoint/committed':
184
+ return { ...need(state, event), lastSnapshot: event.payload.checkpoint.snapshot }
185
+ case 'recovery/needed':
186
+ return {
187
+ ...waiting(need(state, event), 'RECOVERING'),
188
+ control: patchControl(need(state, event), { recovering: true }),
189
+ }
190
+ case 'recovery/reconciled': {
191
+ const next = need(state, event)
192
+ requireReconciliation(next, event.payload.evidenceRef)
193
+ return projectReleasedControl({ ...next, lastSnapshot: event.payload.snapshot, control: patchControl(next, { recovering: false }) })
194
+ }
195
+ case 'effect/outcome-unknown':
196
+ return {
197
+ ...waiting(need(state, event), 'NEEDS_DECISION'),
198
+ outcomeUnknown: true,
199
+ control: patchControl(need(state, event), { outcomeUnknown: true }),
200
+ }
201
+ case 'effects/reconciled': {
202
+ const next = need(state, event)
203
+ requireReconciliation(next, event.payload.evidenceRef)
204
+ if (event.payload.quiescent !== true) throw new FusionError(CONTROL_BLOCKED, 'effects are not quiescent')
205
+ return projectReleasedControl({
206
+ ...next, outcomeUnknown: false, lastSnapshot: event.payload.snapshot,
207
+ control: patchControl(next, { outcomeUnknown: false }),
208
+ })
209
+ }
210
+ case 'task/stop-requested':
211
+ return {
212
+ ...waiting(need(state, event), 'STOPPING'),
213
+ control: patchControl(need(state, event), { mode: 'stop-requested' }),
214
+ }
215
+ case 'task/paused':
216
+ return {
217
+ ...waiting(need(state, event), 'PAUSED'),
218
+ control: patchControl(need(state, event), { mode: 'paused' }),
219
+ }
220
+ case 'task/resumed': {
221
+ const next = need(state, event)
222
+ if (next.control.mode !== 'paused' || writerBusy(next)) {
223
+ throw new FusionError(CONTROL_BLOCKED, 'only a quiescent paused task can resume')
224
+ }
225
+ return projectReleasedControl({ ...next, control: patchControl(next, { mode: 'running' }) })
226
+ }
227
+ case 'task/completed':
228
+ return complete(need(state, event), event)
229
+ case 'task/cancelled':
230
+ return {
231
+ ...need(state, event),
232
+ phase: 'CANCELLED',
233
+ control: patchControl(need(state, event), { mode: 'cancelled' }),
234
+ }
235
+ default:
236
+ return assertNever(event)
237
+ }
238
+ }
239
+
240
+ function need(state: TaskState | undefined, event: FusionEvent): TaskState {
241
+ if (!state) throw new FusionError(EVENT_CONFLICT, `${event.type} requires an existing task`)
242
+ if (state.taskId !== event.taskId) throw new FusionError(EVENT_CONFLICT, 'taskId mismatch')
243
+ if (event.seq !== state.seq + 1) throw new FusionError(EVENT_CONFLICT, `expected seq ${state.seq + 1}, got ${event.seq}`)
244
+ if (event.revision < 1) throw new FusionError(EVENT_CONFLICT, 'revision must be >= 1')
245
+ if (TERMINAL_PHASES.has(state.phase) && event.type !== 'task/cancelled') {
246
+ throw new FusionError(EVENT_CONFLICT, `terminal phase ${state.phase} rejects ${event.type}`)
247
+ }
248
+ return { ...state, seq: event.seq, revision: Math.max(state.revision, event.revision), appliedEventIds: [...state.appliedEventIds, event.id] }
249
+ }
250
+
251
+ function rejectStaleReport(state: TaskState | undefined, reportRevision: number): void {
252
+ if (state && reportRevision < state.revision) {
253
+ throw new FusionError(REVISION_STALE, `report revision ${reportRevision} cannot advance task revision ${state.revision}`)
254
+ }
255
+ }
256
+
257
+ function reportDigest(report: WorkerReport) {
258
+ return digestOf(report)
259
+ }
260
+
261
+ function blockingGate(gate: ReviewGate | undefined): boolean {
262
+ return gate !== undefined && gate.terminalStatus !== 'accepting'
263
+ }
264
+
265
+ function resetReviewForNewSubject(state: TaskState): TaskState {
266
+ return {
267
+ ...state,
268
+ candidateReport: undefined,
269
+ validatedReport: undefined,
270
+ validatedReceipt: undefined,
271
+ activeReviewTicket: undefined,
272
+ reviewResult: undefined,
273
+ reviewResultDigest: undefined,
274
+ acceptedReview: undefined,
275
+ lastReport: undefined,
276
+ reviewGate: undefined,
277
+ reviewGeneration: state.reviewGeneration + 1,
278
+ }
279
+ }
280
+
281
+ function applyProfileFrozen(
282
+ state: TaskState | undefined,
283
+ event: Extract<FusionEvent, { type: 'profile/frozen' }>,
284
+ ): TaskState {
285
+ const next = need(state, event)
286
+ const updated: TaskState = {
287
+ ...next,
288
+ profileDigest: event.payload.digest,
289
+ phase: next.phase === 'READY' ? 'PLANNING' : next.phase,
290
+ }
291
+ if (next.candidateReport || next.validatedReport || next.activeReviewTicket || next.acceptedReview) {
292
+ return resetReviewForNewSubject(updated)
293
+ }
294
+ return updated
295
+ }
296
+
297
+ function applyWorkOrderPrepared(
298
+ state: TaskState | undefined,
299
+ event: Extract<FusionEvent, { type: 'work-order/prepared' }>,
300
+ ): TaskState {
301
+ const next = need(state, event)
302
+ const incoming = event.payload.order
303
+ const current = next.currentWorkOrder
304
+ if (current && current.taskId === incoming.taskId && current.id === incoming.id && current.revision === incoming.revision) {
305
+ if (digestOf(current) !== digestOf(incoming)) {
306
+ throw new FusionError(WORK_ORDER_CONFLICT, 'same work-order identity cannot change its frozen digest')
307
+ }
308
+ return { ...next, currentWorkOrder: incoming, reservedChild: event.payload.reservedChild, phase: 'PLANNING' }
309
+ }
310
+ const reset = current || next.candidateReport || next.validatedReport || next.activeReviewTicket || next.acceptedReview
311
+ ? resetReviewForNewSubject(next)
312
+ : next
313
+ return {
314
+ ...reset,
315
+ currentWorkOrder: incoming,
316
+ reservedChild: event.payload.reservedChild,
317
+ phase: 'PLANNING',
318
+ }
319
+ }
320
+
321
+ function applySubmittedReport(
322
+ state: TaskState | undefined,
323
+ event: Extract<FusionEvent, { type: 'report/submitted' }>,
324
+ ): TaskState {
325
+ rejectStaleReport(state, event.payload.report.revision)
326
+ const next = need(state, event)
327
+ const incoming = event.payload.report
328
+ if (next.currentWorkOrder?.mode === 'explore') throw new FusionError(EVENT_CONFLICT, 'Exploration cannot submit an implementation report')
329
+ if (next.candidateReport && reportDigest(next.candidateReport) === reportDigest(incoming)) {
330
+ return { ...next, candidateReport: incoming, lastReport: next.validatedReport ?? incoming }
331
+ }
332
+ const reset = resetReviewForNewSubject(next)
333
+ return {
334
+ ...reset,
335
+ candidateReport: incoming,
336
+ lastReport: incoming,
337
+ phase: next.phase === 'WORKER_RUNNING' || next.phase === 'REVIEWING' || next.phase === 'REWORK' ? 'WORKER_RUNNING' : next.phase,
338
+ }
339
+ }
340
+
341
+ function applyValidatedReport(
342
+ state: TaskState | undefined,
343
+ event: Extract<FusionEvent, { type: 'report/validated' }>,
344
+ ): TaskState {
345
+ rejectStaleReport(state, event.payload.report.revision)
346
+ const next = need(state, event)
347
+ const incoming = event.payload.report
348
+ const digest = reportDigest(incoming)
349
+ const subject = reportSubject(next.taskId, incoming)
350
+ const receipt = reviewReadinessReceipt(subject)
351
+ const sameValidated = next.validatedReport !== undefined && reportDigest(next.validatedReport) === digest
352
+ const sameCandidate = next.candidateReport !== undefined && reportDigest(next.candidateReport) === digest
353
+ const sameWork = next.currentWorkOrder
354
+ && incoming.workOrderId === next.currentWorkOrder.id
355
+ && incoming.revision === next.currentWorkOrder.revision
356
+
357
+ if (!sameCandidate || !sameWork) {
358
+ return {
359
+ ...next,
360
+ staleValidations: [...next.staleValidations, { reportDigest: digest, reason: 'stale-validation' }],
361
+ }
362
+ }
363
+ if (sameValidated && blockingGate(next.reviewGate)) {
364
+ return { ...next, lastReport: incoming, validatedReport: incoming, validatedReceipt: receipt }
365
+ }
366
+ return {
367
+ ...next,
368
+ lastReport: incoming,
369
+ validatedReport: incoming,
370
+ validatedReceipt: receipt,
371
+ phase: businessPhaseForReview(next),
372
+ }
373
+ }
374
+
375
+ function businessPhaseForReview(state: TaskState): Phase {
376
+ if (state.control.mode !== 'running') return state.phase
377
+ if (state.control.pendingApprovalIds.length) return 'WAITING_APPROVAL'
378
+ if (state.control.budgetBlocked) return 'WAITING_BUDGET'
379
+ if (state.control.recovering) return 'RECOVERING'
380
+ if (state.control.outcomeUnknown) return 'NEEDS_DECISION'
381
+ return 'REVIEWING'
382
+ }
383
+
384
+ function applyReviewRequested(
385
+ state: TaskState | undefined,
386
+ event: Extract<FusionEvent, { type: 'review/requested' }>,
387
+ ): TaskState {
388
+ const next = need(state, event)
389
+ assertControlAllowsExecution(next)
390
+ const receipt = next.validatedReceipt
391
+ if (!receipt || !next.validatedReport) {
392
+ throw new FusionError(EVENT_CONFLICT, 'review/requested requires a validated report')
393
+ }
394
+ const ticket = event.payload.ticket
395
+ const actualSnapshot = next.lastSnapshot ?? ticket.subject.snapshot
396
+ assertCurrentSubject(next, ticket, actualSnapshot)
397
+ if (!sameSubject(ticket.subject, receipt.subject)) {
398
+ throw new FusionError(EVENT_CONFLICT, 'review ticket subject does not match the validated report')
399
+ }
400
+ if (ticket.validationDigest !== digestOf(receipt)) {
401
+ throw new FusionError(EVENT_CONFLICT, 'review ticket validation digest does not match readiness receipt')
402
+ }
403
+ if (ticket.generation !== next.reviewGeneration + 1 && next.activeReviewTicket && digestOf(next.activeReviewTicket) === digestOf(ticket)) {
404
+ return next
405
+ }
406
+ if (ticket.generation !== next.reviewGeneration + 1) {
407
+ throw new FusionError(EVENT_CONFLICT, `review ticket generation ${ticket.generation} is not the next generation ${next.reviewGeneration + 1}`)
408
+ }
409
+ return {
410
+ ...next,
411
+ activeReviewTicket: ticket,
412
+ reviewGeneration: ticket.generation,
413
+ reviewResult: undefined,
414
+ reviewResultDigest: undefined,
415
+ acceptedReview: undefined,
416
+ reviewGate: undefined,
417
+ phase: 'REVIEWING',
418
+ }
419
+ }
420
+
421
+ function applyReviewCompleted(
422
+ state: TaskState | undefined,
423
+ event: Extract<FusionEvent, { type: 'review/completed' }>,
424
+ ): TaskState {
425
+ const next = need(state, event)
426
+ const binding = event.payload.binding
427
+ if (!binding) {
428
+ return applyLegacyReviewDecision(next, event)
429
+ }
430
+ return applyBoundReviewResult(next, binding, event)
431
+ }
432
+
433
+ function applyLegacyReviewDecision(
434
+ state: TaskState,
435
+ event: Extract<FusionEvent, { type: 'review/completed' }>,
436
+ ): TaskState {
437
+ const decision = event.payload.decision
438
+ if (decision === 'accept') {
439
+ return {
440
+ ...state,
441
+ reviewGate: state.lastReport
442
+ ? {
443
+ taskId: state.taskId,
444
+ workOrderId: state.currentWorkOrder?.id ?? state.lastReport.workOrderId,
445
+ revision: state.lastReport.revision,
446
+ reportDigest: reportDigest(state.lastReport),
447
+ snapshot: state.lastReport.snapshot,
448
+ reviewAttemptId: event.id,
449
+ decision,
450
+ terminalStatus: 'legacy-unbound',
451
+ }
452
+ : state.reviewGate,
453
+ }
454
+ }
455
+ if (!state.lastReport) {
456
+ throw new FusionError(EVENT_CONFLICT, 'review/completed requires a validated report')
457
+ }
458
+ const gate: ReviewGate = {
459
+ taskId: state.taskId,
460
+ workOrderId: state.currentWorkOrder?.id ?? state.lastReport.workOrderId,
461
+ revision: state.lastReport.revision,
462
+ reportDigest: reportDigest(state.lastReport),
463
+ snapshot: state.lastReport.snapshot,
464
+ reviewAttemptId: event.id,
465
+ decision,
466
+ terminalStatus: decision === 'rework'
467
+ ? 'rework'
468
+ : decision === 'REVIEW_INCOMPLETE'
469
+ ? 'incomplete'
470
+ : 'needs-decision',
471
+ }
472
+ if (decision === 'rework') return { ...state, reviewGate: gate, phase: 'REWORK' }
473
+ return { ...state, reviewGate: gate, phase: 'NEEDS_DECISION' }
474
+ }
475
+
476
+ function applyBoundReviewResult(state: TaskState, result: ReviewResultV2, event: Extract<FusionEvent, { type: 'review/completed' }>): TaskState {
477
+ const ticket = state.activeReviewTicket
478
+ if (
479
+ !ticket
480
+ || ticket.id !== result.ticketId
481
+ || ticket.requestId !== result.requestId
482
+ || ticket.generation !== result.generation
483
+ || !sameSubject(ticket.subject, result.subject)
484
+ ) {
485
+ return { ...state, ignoredReviewResults: [...state.ignoredReviewResults, result] }
486
+ }
487
+ const resultDigest = digestOf(result)
488
+ if (state.reviewResultDigest) {
489
+ if (state.reviewResultDigest !== resultDigest) {
490
+ throw new FusionError(REVIEW_RESULT_CONFLICT, 'conflicting review result for the same ticket')
491
+ }
492
+ return state
493
+ }
494
+ const accepts = result.protocolValid && result.decision === 'accept'
495
+ const gate: ReviewGate = {
496
+ taskId: state.taskId,
497
+ workOrderId: ticket.subject.workOrderId,
498
+ revision: ticket.subject.revision,
499
+ reportDigest: ticket.subject.reportDigest,
500
+ snapshot: ticket.subject.snapshot,
501
+ reviewAttemptId: event.id,
502
+ decision: result.decision,
503
+ terminalStatus: accepts
504
+ ? 'accepting'
505
+ : result.decision === 'rework'
506
+ ? 'rework'
507
+ : result.decision === 'REVIEW_INCOMPLETE'
508
+ ? 'incomplete'
509
+ : 'needs-decision',
510
+ }
511
+ const recorded: TaskState = {
512
+ ...state,
513
+ reviewResult: result,
514
+ reviewResultDigest: resultDigest,
515
+ acceptedReview: accepts ? { ticket, resultDigest } : undefined,
516
+ reviewGate: gate,
517
+ }
518
+ if (state.control.mode !== 'running' || state.control.pendingApprovalIds.length
519
+ || state.control.budgetBlocked || state.control.recovering || state.control.outcomeUnknown) {
520
+ return recorded
521
+ }
522
+ if (result.decision === 'rework') return { ...recorded, phase: 'REWORK' }
523
+ if (result.decision === 'needs-decision' || result.decision === 'REVIEW_INCOMPLETE') {
524
+ return { ...recorded, phase: 'NEEDS_DECISION' }
525
+ }
526
+ return { ...recorded, phase: 'REVIEWING' }
527
+ }
528
+
529
+ function applyApprovalPending(
530
+ state: TaskState | undefined,
531
+ event: Extract<FusionEvent, { type: 'approval/pending' }>,
532
+ ): TaskState {
533
+ const next = need(state, event)
534
+ const approval = event.payload.approval
535
+ const pendingApprovalIds = [...new Set([...next.pendingApprovalIds, approval.id])]
536
+ return {
537
+ ...waiting(next, 'WAITING_APPROVAL'),
538
+ pendingApprovalIds,
539
+ pendingApprovals: { ...next.pendingApprovals, [approval.id]: approval },
540
+ control: patchControl(next, { pendingApprovalIds }),
541
+ }
542
+ }
543
+
544
+ function applyApprovalAnswered(
545
+ state: TaskState | undefined,
546
+ event: Extract<FusionEvent, { type: 'approval/answered' }>,
547
+ ): TaskState {
548
+ const next = need(state, event)
549
+ const existing = next.pendingApprovals[event.payload.approvalId]
550
+ if (!existing || !next.pendingApprovalIds.includes(event.payload.approvalId)) {
551
+ throw new FusionError(EVENT_CONFLICT, `approval ${event.payload.approvalId} is not pending`)
552
+ }
553
+ const pendingApprovalIds = next.pendingApprovalIds.filter(id => id !== event.payload.approvalId)
554
+ const pendingApprovals = { ...next.pendingApprovals }
555
+ delete pendingApprovals[event.payload.approvalId]
556
+ const control = patchControl(next, { pendingApprovalIds })
557
+ if (event.payload.state !== 'approved') {
558
+ return {
559
+ ...next,
560
+ pendingApprovalIds,
561
+ pendingApprovals,
562
+ control: { ...control, mode: 'paused' },
563
+ phase: pendingApprovalIds.length ? 'WAITING_APPROVAL' : 'NEEDS_DECISION',
564
+ }
565
+ }
566
+ return projectReleasedControl({ ...next, pendingApprovalIds, pendingApprovals, control })
567
+ }
568
+
569
+ function currentSnapshot(state: TaskState) {
570
+ return state.lastSnapshot ?? state.validatedReport?.snapshot ?? state.candidateReport?.snapshot ?? state.lastReport?.snapshot
571
+ }
572
+
573
+ function writerBusy(state: TaskState): boolean {
574
+ return Boolean(state.lease && state.lease.activeOperationIds.length > 0)
575
+ }
576
+
577
+ function assertControlAllowsExecution(state: TaskState): void {
578
+ if (state.control.mode !== 'running') {
579
+ throw new FusionError(CONTROL_BLOCKED, `control mode ${state.control.mode} blocks execution`)
580
+ }
581
+ if (state.control.pendingApprovalIds.length) {
582
+ throw new FusionError(CONTROL_BLOCKED, 'pending human approval blocks execution')
583
+ }
584
+ if (state.control.outcomeUnknown || state.outcomeUnknown) {
585
+ throw new FusionError(EVENT_CONFLICT, 'cannot complete while an effect is OUTCOME_UNKNOWN')
586
+ }
587
+ if (state.control.budgetBlocked || state.control.recovering) {
588
+ throw new FusionError(CONTROL_BLOCKED, 'budget or recovery gate is still set')
589
+ }
590
+ if (writerBusy(state)) {
591
+ throw new FusionError(CONTROL_BLOCKED, 'an active write lease blocks execution')
592
+ }
593
+ }
594
+
595
+ function assertAcceptingReview(state: TaskState, event: Extract<FusionEvent, { type: 'task/completed' }>): void {
596
+ if (state.intent === 'DIRECT') return
597
+ if (event.payload.verification !== 'verified') {
598
+ throw new FusionError(EVENT_CONFLICT, 'delegated downgrade must not use ordinary verified completion')
599
+ }
600
+ const accepted = state.acceptedReview
601
+ const ticket = state.activeReviewTicket
602
+ const validated = state.validatedReport
603
+ const candidate = state.candidateReport
604
+ const receipt = state.validatedReceipt
605
+ if (!accepted || !ticket || !validated || !candidate || !receipt) {
606
+ throw new FusionError(REVIEW_TICKET_REQUIRED, 'delegated task cannot complete before a bound accepting review')
607
+ }
608
+ const actualSnapshot = state.lastSnapshot ?? accepted.ticket.subject.snapshot
609
+ assertCurrentSubject(state, accepted.ticket, actualSnapshot)
610
+ if (digestOf(accepted.ticket) !== digestOf(ticket) || accepted.ticket.generation !== state.reviewGeneration) {
611
+ throw new FusionError(EVENT_CONFLICT, 'accepting review is stale for the current generation')
612
+ }
613
+ if (!sameSubject(accepted.ticket.subject, reportSubject(state.taskId, candidate))) {
614
+ throw new FusionError(EVENT_CONFLICT, 'accepting review does not match the current candidate')
615
+ }
616
+ if (!sameSubject(accepted.ticket.subject, reportSubject(state.taskId, validated))) {
617
+ throw new FusionError(EVENT_CONFLICT, 'accepting review does not match the current validated report')
618
+ }
619
+ if (accepted.ticket.validationDigest !== digestOf(receipt)) {
620
+ throw new FusionError(EVENT_CONFLICT, 'accepting review validation receipt changed')
621
+ }
622
+ if (!state.reviewResult || digestOf(state.reviewResult) !== accepted.resultDigest) {
623
+ throw new FusionError(EVENT_CONFLICT, 'accepting review result changed')
624
+ }
625
+ if (!state.reviewResult.protocolValid || state.reviewResult.decision !== 'accept') {
626
+ throw new FusionError(EVENT_CONFLICT, 'review result is not an accepting decision')
627
+ }
628
+ const snapshot = currentSnapshot(state)
629
+ if (!snapshot || accepted.ticket.subject.snapshot !== snapshot) {
630
+ throw new FusionError(EVENT_CONFLICT, 'accepting review snapshot is not the current workspace snapshot')
631
+ }
632
+ if (event.payload.snapshot !== snapshot) {
633
+ throw new FusionError(EVENT_CONFLICT, 'completion snapshot is not the current workspace snapshot')
634
+ }
635
+ }
636
+
637
+ function complete(state: TaskState, event: Extract<FusionEvent, { type: 'task/completed' }>): TaskState {
638
+ if (event.revision < state.revision) {
639
+ throw new FusionError(REVISION_STALE, 'old revision cannot complete a newer task')
640
+ }
641
+ assertControlAllowsExecution(state)
642
+ if (state.outcomeUnknown || state.control.outcomeUnknown) {
643
+ throw new FusionError(EVENT_CONFLICT, 'cannot complete while an effect is OUTCOME_UNKNOWN')
644
+ }
645
+ if (state.phase !== 'REVIEWING' && state.phase !== 'DIRECT') {
646
+ throw new FusionError(EVENT_CONFLICT, `cannot complete from ${state.phase}`)
647
+ }
648
+ if (event.payload.verification === 'verified') {
649
+ assertAcceptingReview(state, event)
650
+ } else if (state.intent !== 'DIRECT') {
651
+ throw new FusionError(EVENT_CONFLICT, 'delegated downgrade must use an explicit fallback event, not ordinary completion')
652
+ }
653
+ return {
654
+ ...state,
655
+ phase: 'COMPLETED',
656
+ verification: event.payload.verification,
657
+ lastSnapshot: event.payload.snapshot,
658
+ control: patchControl(state, { mode: 'completed' }),
659
+ }
660
+ }
661
+
662
+ function assertNever(event: never): never {
663
+ throw new FusionError(EVENT_CONFLICT, `unknown event ${(event as FusionEvent).type}`)
664
+ }
665
+
666
+ export function reduce(state: TaskState | undefined, event: FusionEvent): TaskState {
667
+ if (event.schemaVersion !== 1) throw new FusionError(EVENT_CONFLICT, `unsupported schemaVersion ${event.schemaVersion}`)
668
+ if (state?.appliedEventIds.includes(event.id)) return state
669
+ return applyPayload(state, event)
670
+ }
671
+
672
+ export function reduceAll(events: readonly FusionEvent[]): TaskState {
673
+ let state: TaskState | undefined
674
+ for (const event of events) state = reduce(state, event)
675
+ if (!state) throw new FusionError(EVENT_CONFLICT, 'no events')
676
+ return state
677
+ }
678
+
679
+ export { assertControlAllowsExecution }