@sqdr2/dsh-fusion 0.0.0-stage → 0.3.2

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 (213) 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 +193 -0
  5. package/README.md +182 -2
  6. package/RELEASING.md +123 -0
  7. package/RELEASING.zh.md +115 -0
  8. package/contracts/fusion.ts +279 -0
  9. package/contracts/storage.sql +86 -0
  10. package/cordis.patch.yml +3 -0
  11. package/docs/COMPATIBILITY.md +162 -0
  12. package/docs/EVIDENCE.md +141 -0
  13. package/dshx.yml +39 -0
  14. package/lib/client.js +1920 -0
  15. package/lib/client.js.map +1 -0
  16. package/lib/dsh-fusion.js +12664 -0
  17. package/lib/types/activity.d.ts +20 -0
  18. package/lib/types/approval/logical.d.ts +10 -0
  19. package/lib/types/benchmark/naive.d.ts +37 -0
  20. package/lib/types/benchmark/native-run.d.ts +68 -0
  21. package/lib/types/benchmark/output-limits.d.ts +19 -0
  22. package/lib/types/bilingual.d.ts +2 -0
  23. package/lib/types/client/ActivityRows.d.ts +8 -0
  24. package/lib/types/client/FusionCache.d.ts +4 -0
  25. package/lib/types/client/FusionHint.d.ts +9 -0
  26. package/lib/types/client/FusionHistory.d.ts +5 -0
  27. package/lib/types/client/FusionPanelIcon.d.ts +13 -0
  28. package/lib/types/client/FusionRecovery.d.ts +5 -0
  29. package/lib/types/client/FusionSettings.d.ts +9 -0
  30. package/lib/types/client/FusionStatus.d.ts +5 -0
  31. package/lib/types/client/FusionTool.d.ts +4 -0
  32. package/lib/types/client/activity-model.d.ts +14 -0
  33. package/lib/types/client/i18n.d.ts +15 -0
  34. package/lib/types/client/index.d.ts +22 -0
  35. package/lib/types/client/settings-events.d.ts +2 -0
  36. package/lib/types/client/status-polling.d.ts +9 -0
  37. package/lib/types/context/guard.d.ts +21 -0
  38. package/lib/types/context/handoff.d.ts +17 -0
  39. package/lib/types/contracts.d.ts +449 -0
  40. package/lib/types/digest.d.ts +5 -0
  41. package/lib/types/dsh-fusion.d.ts +13 -0
  42. package/lib/types/errors.d.ts +28 -0
  43. package/lib/types/evidence/gate.d.ts +23 -0
  44. package/lib/types/evidence/receipts.d.ts +65 -0
  45. package/lib/types/execution/write-lease.d.ts +42 -0
  46. package/lib/types/host/bindings.d.ts +30 -0
  47. package/lib/types/host/cache-defaults.d.ts +48 -0
  48. package/lib/types/host/cache-policy.d.ts +155 -0
  49. package/lib/types/host/change-evidence.d.ts +38 -0
  50. package/lib/types/host/compaction-routing.d.ts +15 -0
  51. package/lib/types/host/coordinator.d.ts +169 -0
  52. package/lib/types/host/evidence-page.d.ts +13 -0
  53. package/lib/types/host/history.d.ts +88 -0
  54. package/lib/types/host/http.d.ts +4 -0
  55. package/lib/types/host/model-output.d.ts +13 -0
  56. package/lib/types/host/native-activity.d.ts +26 -0
  57. package/lib/types/host/native-approvals.d.ts +29 -0
  58. package/lib/types/host/native-auxiliary.d.ts +17 -0
  59. package/lib/types/host/native-budget.d.ts +33 -0
  60. package/lib/types/host/native-checks.d.ts +104 -0
  61. package/lib/types/host/native-compaction.d.ts +8 -0
  62. package/lib/types/host/native-context.d.ts +36 -0
  63. package/lib/types/host/native-effects.d.ts +75 -0
  64. package/lib/types/host/native-evidence.d.ts +4 -0
  65. package/lib/types/host/native-exploration.d.ts +15 -0
  66. package/lib/types/host/native-keepalive.d.ts +82 -0
  67. package/lib/types/host/native-model-control.d.ts +99 -0
  68. package/lib/types/host/native-on-demand-context.d.ts +39 -0
  69. package/lib/types/host/native-request.d.ts +5 -0
  70. package/lib/types/host/native-review.d.ts +11 -0
  71. package/lib/types/host/native-role-sandbox.d.ts +49 -0
  72. package/lib/types/host/native-scopes.d.ts +49 -0
  73. package/lib/types/host/native-selection.d.ts +29 -0
  74. package/lib/types/host/native-task-context.d.ts +39 -0
  75. package/lib/types/host/native-usage.d.ts +46 -0
  76. package/lib/types/host/native-worker-brief.d.ts +74 -0
  77. package/lib/types/host/native-worker-notices.d.ts +15 -0
  78. package/lib/types/host/native-worker.d.ts +44 -0
  79. package/lib/types/host/native-workflow.d.ts +41 -0
  80. package/lib/types/host/route-transport.d.ts +22 -0
  81. package/lib/types/host/settings.d.ts +39 -0
  82. package/lib/types/host/shell.d.ts +11 -0
  83. package/lib/types/host/status.d.ts +7 -0
  84. package/lib/types/host/subagent-seam.d.ts +10 -0
  85. package/lib/types/host/tool-table.d.ts +35 -0
  86. package/lib/types/host/worker-terminal.d.ts +68 -0
  87. package/lib/types/host/workspace-guard.d.ts +13 -0
  88. package/lib/types/host/workspace.d.ts +52 -0
  89. package/lib/types/identity.d.ts +64 -0
  90. package/lib/types/index.d.ts +34 -0
  91. package/lib/types/message-source.d.ts +29 -0
  92. package/lib/types/profile/compactor.d.ts +5 -0
  93. package/lib/types/profile/keepalive.d.ts +3 -0
  94. package/lib/types/profile/resolve.d.ts +17 -0
  95. package/lib/types/prompts.d.ts +8 -0
  96. package/lib/types/recovery/reconcile.d.ts +28 -0
  97. package/lib/types/review/dispatch.d.ts +20 -0
  98. package/lib/types/review/protocol.d.ts +51 -0
  99. package/lib/types/review/ticket.d.ts +32 -0
  100. package/lib/types/status.d.ts +88 -0
  101. package/lib/types/store-migration.d.ts +29 -0
  102. package/lib/types/task/deletion.d.ts +64 -0
  103. package/lib/types/task/reducer.d.ts +6 -0
  104. package/lib/types/task/sqlite-store.d.ts +71 -0
  105. package/lib/types/task/state.d.ts +51 -0
  106. package/lib/types/task/status-index.d.ts +28 -0
  107. package/lib/types/task/store.d.ts +48 -0
  108. package/lib/types/usage/budget.d.ts +27 -0
  109. package/lib/types/usage/ledger.d.ts +66 -0
  110. package/lib/types/usage/normalize.d.ts +50 -0
  111. package/lib/types/usage/pricing.d.ts +18 -0
  112. package/package.json +178 -4
  113. package/profiles/fusion-auto.example.json +45 -0
  114. package/prompts/compact.md +7 -0
  115. package/prompts/lead.md +42 -0
  116. package/prompts/model-lead.md +13 -0
  117. package/prompts/model-worker.md +5 -0
  118. package/prompts/worker.md +21 -0
  119. package/src/activity.ts +23 -0
  120. package/src/approval/logical.ts +44 -0
  121. package/src/benchmark/naive.ts +216 -0
  122. package/src/benchmark/native-run.ts +215 -0
  123. package/src/benchmark/output-limits.ts +33 -0
  124. package/src/bilingual.ts +2 -0
  125. package/src/client/ActivityRows.tsx +59 -0
  126. package/src/client/FusionCache.tsx +130 -0
  127. package/src/client/FusionHint.tsx +39 -0
  128. package/src/client/FusionHistory.tsx +247 -0
  129. package/src/client/FusionPanelIcon.tsx +39 -0
  130. package/src/client/FusionRecovery.tsx +69 -0
  131. package/src/client/FusionSettings.tsx +205 -0
  132. package/src/client/FusionStatus.tsx +103 -0
  133. package/src/client/FusionTool.tsx +66 -0
  134. package/src/client/activity-model.ts +31 -0
  135. package/src/client/i18n.ts +30 -0
  136. package/src/client/index.ts +50 -0
  137. package/src/client/settings-events.ts +3 -0
  138. package/src/client/status-polling.ts +88 -0
  139. package/src/context/guard.ts +81 -0
  140. package/src/context/handoff.ts +28 -0
  141. package/src/contracts.ts +449 -0
  142. package/src/digest.ts +28 -0
  143. package/src/dsh-fusion.ts +321 -0
  144. package/src/errors.ts +34 -0
  145. package/src/evidence/gate.ts +290 -0
  146. package/src/evidence/receipts.ts +175 -0
  147. package/src/execution/write-lease.ts +187 -0
  148. package/src/host/bindings.ts +91 -0
  149. package/src/host/cache-defaults.ts +103 -0
  150. package/src/host/cache-policy.ts +168 -0
  151. package/src/host/change-evidence.ts +156 -0
  152. package/src/host/compaction-routing.ts +46 -0
  153. package/src/host/coordinator.ts +2160 -0
  154. package/src/host/evidence-page.ts +25 -0
  155. package/src/host/history.ts +114 -0
  156. package/src/host/http.ts +30 -0
  157. package/src/host/model-output.ts +22 -0
  158. package/src/host/native-activity.ts +199 -0
  159. package/src/host/native-approvals.ts +196 -0
  160. package/src/host/native-auxiliary.ts +19 -0
  161. package/src/host/native-budget.ts +112 -0
  162. package/src/host/native-checks.ts +474 -0
  163. package/src/host/native-compaction.ts +34 -0
  164. package/src/host/native-context.ts +204 -0
  165. package/src/host/native-effects.ts +260 -0
  166. package/src/host/native-evidence.ts +31 -0
  167. package/src/host/native-exploration.ts +114 -0
  168. package/src/host/native-keepalive.ts +292 -0
  169. package/src/host/native-model-control.ts +298 -0
  170. package/src/host/native-on-demand-context.ts +74 -0
  171. package/src/host/native-request.ts +12 -0
  172. package/src/host/native-review.ts +51 -0
  173. package/src/host/native-role-sandbox.ts +171 -0
  174. package/src/host/native-scopes.ts +203 -0
  175. package/src/host/native-selection.ts +110 -0
  176. package/src/host/native-task-context.ts +264 -0
  177. package/src/host/native-usage.ts +121 -0
  178. package/src/host/native-worker-brief.ts +122 -0
  179. package/src/host/native-worker-notices.ts +72 -0
  180. package/src/host/native-worker.ts +215 -0
  181. package/src/host/native-workflow.ts +103 -0
  182. package/src/host/route-transport.ts +54 -0
  183. package/src/host/settings.ts +93 -0
  184. package/src/host/shell.ts +13 -0
  185. package/src/host/status.ts +112 -0
  186. package/src/host/subagent-seam.ts +20 -0
  187. package/src/host/tool-table.ts +56 -0
  188. package/src/host/worker-terminal.ts +91 -0
  189. package/src/host/workspace-guard.ts +17 -0
  190. package/src/host/workspace.ts +184 -0
  191. package/src/identity.ts +76 -0
  192. package/src/index.ts +50 -0
  193. package/src/message-source.ts +31 -0
  194. package/src/profile/compactor.ts +26 -0
  195. package/src/profile/keepalive.ts +13 -0
  196. package/src/profile/resolve.ts +131 -0
  197. package/src/prompts.ts +32 -0
  198. package/src/recovery/reconcile.ts +38 -0
  199. package/src/review/dispatch.ts +48 -0
  200. package/src/review/protocol.ts +212 -0
  201. package/src/review/ticket.ts +166 -0
  202. package/src/status.ts +55 -0
  203. package/src/store-migration.ts +65 -0
  204. package/src/task/deletion.ts +105 -0
  205. package/src/task/reducer.ts +679 -0
  206. package/src/task/sqlite-store.ts +386 -0
  207. package/src/task/state.ts +72 -0
  208. package/src/task/status-index.ts +198 -0
  209. package/src/task/store.ts +287 -0
  210. package/src/usage/budget.ts +88 -0
  211. package/src/usage/ledger.ts +218 -0
  212. package/src/usage/normalize.ts +308 -0
  213. package/src/usage/pricing.ts +95 -0
@@ -0,0 +1,204 @@
1
+ import { randomUUID } from 'node:crypto'
2
+ import type { Context } from '@deepseek-ai/cordis'
3
+ import type { Agent } from '@deepseek-ai/dsh-agent'
4
+ import { toolPairingBalancedBefore } from '@deepseek-ai/dsh-compaction'
5
+ import { createUserMessage, isAgentLoopRequest, LlmError } from '@deepseek-ai/dsh-llm'
6
+ import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
7
+ import type {} from '@deepseek-ai/dsh-token-meter'
8
+ import type {} from '@deepseek-ai/dsh-agent-preset-registry'
9
+ import type { PhysicalRoute, Role, TokenMeasurement } from '../contracts.js'
10
+ import { inputBudget, safetyTokens } from '../context/guard.js'
11
+ import { digestOf } from '../digest.js'
12
+ import { FUSION_CONTEXT_BUDGET } from '../errors.js'
13
+ import type { SqliteFusionStore } from '../task/sqlite-store.js'
14
+ import type { SessionBinding } from './bindings.js'
15
+ import { FUSION_SOURCE_KIND, isFusionSourceKind } from '../message-source.js'
16
+ import { nativeRequestAgent } from './native-request.js'
17
+ import { FUSION_TASK_CONTEXT, type NativeTaskContext } from './native-task-context.js'
18
+ import type { NativeAuxiliaryRequests } from './native-auxiliary.js'
19
+ import { routeNativeCompaction } from './native-compaction.js'
20
+
21
+ type Owner = { binding: SessionBinding; role: Role }
22
+ interface ContextCheck {
23
+ route: PhysicalRoute
24
+ measurement: TokenMeasurement
25
+ budget: number
26
+ purpose: GenerateOptions['purpose'] | 'conversation' | 'cache-keepalive'
27
+ logRevision: number
28
+ nativeBaseline: string
29
+ minimumRetainedInputTokens: number
30
+ }
31
+ interface RejectedRequest extends ContextCheck { owner: Owner; recordId: string }
32
+
33
+ /** Optional summary routing, then final admission and the Host's single compaction owner. */
34
+ export class NativeContextGuard {
35
+ readonly #rejected = new WeakMap<Agent, RejectedRequest>()
36
+ constructor(readonly ctx: Context, readonly store: SqliteFusionStore,
37
+ readonly owner: (agent: Agent) => Owner | undefined,
38
+ readonly blocked: (owner: Owner, reason: string) => void,
39
+ readonly taskContext?: NativeTaskContext,
40
+ readonly auxiliary?: NativeAuxiliaryRequests,
41
+ readonly route?: (binding: SessionBinding, role: Role) => PhysicalRoute) {}
42
+
43
+ async measure(agent: Agent, owner: Owner, request: GenerateOptions): Promise<ContextCheck> {
44
+ const meter = this.ctx.get('tokenMeter')
45
+ if (!meter) throw new Error('Fusion requires the native tokenMeter service')
46
+ const separateCompactor = request.purpose === 'compaction' && owner.binding.profile.compactor
47
+ const route = separateCompactor ? separateCompactor.route : owner.binding.profile[owner.role]
48
+ const policy = owner.binding.profile.context[owner.role]
49
+ if (request.provider !== route.provider || request.model !== route.model) {
50
+ throw new Error('Fusion request must use its frozen role or compactor model')
51
+ }
52
+ const logged = isAgentLoopRequest(request) ? agent.session.requestContext() : undefined
53
+ const capacity = logged?.provider === request.provider && logged.model === request.model
54
+ ? logged.contextWindow : (await this.ctx.llm.resolveModelInfo(request.provider, request.model, request.signal)).context?.contextWindow
55
+ if (!Number.isSafeInteger(capacity) || Number(capacity) <= 0) throw new Error('The configured model does not disclose a usable context window')
56
+ const contextWindow = Number(capacity), output = request.maxTokens
57
+ if (!Number.isSafeInteger(output) || Number(output) <= 0) throw new Error('Fusion requires an explicit output token reservation')
58
+ const { messages, tools, signal: _signal, sessionId: _sessionId, purpose: _purpose, system, ...config } = request
59
+ const replay = meter.measure(agent.session, { config, ...(tools?.length ? { tools } : {}) })
60
+ // The actual one-shot envelope can include a summary directive or tools not
61
+ // on the current surface. Charge it too. JSON tool schemas use the public
62
+ // message estimator with extra framing, rather than a private meter import.
63
+ const text = (value: string) => meter.estimateMessage(createUserMessage({ content: [{ type: 'text', text: value }],
64
+ source: { kind: FUSION_SOURCE_KIND } }))
65
+ const fixed = (system ? text(system) : 0) + (tools?.length ? text(JSON.stringify(tools)) : 0)
66
+ const actual = messages.reduce((total, message) => total + meter.estimateMessage(message.id ? message : createUserMessage({ content: message.content, source: { kind: FUSION_SOURCE_KIND } })), 0) + fixed
67
+ const pinned = messages.flatMap(message => isFusionSourceKind(message.source?.kind)
68
+ && message.source.form === 'snapshot' ? message.source.sections.filter(section => section.name === FUSION_TASK_CONTEXT) : []).at(-1)
69
+ // A summary cannot shrink the required current task snapshot, system prompt
70
+ // or tool schemas. Reject an impossible restore before charging a summary.
71
+ const minimumRetainedInputTokens = fixed + (pinned ? text(pinned.text) : 0)
72
+ // Replay includes cached history once, including any native usage anchor.
73
+ // New surface deltas remain heuristic even when that anchor is measured.
74
+ const measurement: TokenMeasurement = {
75
+ // A separately routed summary reads only its selected region. The role's
76
+ // full-history usage anchor cannot price this different model's request.
77
+ inputTokens: this.auxiliary?.get(request) || separateCompactor ? actual : Math.max(replay.totalTokens, actual), reservedOutputTokens: Number(output), contextWindow,
78
+ safetyTokens: safetyTokens(contextWindow, policy, 'heuristic'), quality: 'heuristic',
79
+ requestDigest: digestOf({ ...config, messages, tools, system, purpose: request.purpose }),
80
+ }
81
+ // A summary repairs the role's soft target and must be allowed to read a
82
+ // larger prefix; it is still subject to the physical window and spend cap.
83
+ const effectivePolicy = request.purpose === 'compaction' ? { ...policy, targetInputTokens: contextWindow } : policy
84
+ return { route, measurement, budget: inputBudget(effectivePolicy, contextWindow, Number(output), 'heuristic'),
85
+ purpose: this.auxiliary?.get(request)?.purpose ?? request.purpose ?? 'conversation', logRevision: replay.logRevision,
86
+ nativeBaseline: replay.baseline.kind, minimumRetainedInputTokens }
87
+ }
88
+
89
+ install(admit: (agent: Agent, owner: Owner, request: GenerateOptions) => void,
90
+ ready: (agent: Agent, request: GenerateOptions) => void): () => void {
91
+ const guard = this
92
+ const disposeStream = this.ctx.on('llm/stream', async function* (request, next): AsyncIterable<StreamChunk> {
93
+ const agent = nativeRequestAgent(guard.ctx, request), owner = agent && guard.owner(agent)
94
+ if (!agent || !owner) { yield* next(); return }
95
+ const auxiliary = guard.auxiliary?.get(request)
96
+ ready(agent, request)
97
+ if (owner.binding.profile.interactionMode === 'model-like') {
98
+ // The native compactor and actual physical adapter own window/defaults.
99
+ // Keep request/effect admission, without inventing a second output cap.
100
+ routeNativeCompaction(request, owner.binding.profile, guard.route?.(owner.binding, owner.role))
101
+ admit(agent, owner, request)
102
+ yield* next()
103
+ return
104
+ }
105
+ let check: ContextCheck
106
+ try {
107
+ routeNativeCompaction(request, owner.binding.profile)
108
+ check = await guard.measure(agent, owner, request)
109
+ }
110
+ catch (error) {
111
+ const reason = `Fusion 上下文检查失败:${error instanceof Error ? error.message : String(error)}`
112
+ if (!auxiliary && request.purpose !== 'session-title') guard.blocked(owner, reason)
113
+ yield { type: 'finish', reason: { kind: 'error', failure: new LlmError(reason, 'FUSION_CONTEXT_UNAVAILABLE').failure } }
114
+ return
115
+ }
116
+ request.signal?.throwIfAborted()
117
+ const accepted = check.measurement.inputTokens <= check.budget
118
+ const recordId = `context-request:${owner.binding.taskId}:${randomUUID()}`
119
+ guard.store.writeDocument(recordId, 0, { schemaVersion: 1, sessionId: agent.id, role: owner.role,
120
+ taskId: owner.binding.taskId, checkedAt: new Date().toISOString(), admitted: accepted, ...check })
121
+ if (!accepted) {
122
+ const reason = `Fusion 上下文 ${check.measurement.inputTokens} 超过本轮预算 ${check.budget}(估算)`
123
+ if (isAgentLoopRequest(request) && request.purpose === undefined) guard.#rejected.set(agent, { owner, recordId, ...check })
124
+ else if (!auxiliary && request.purpose !== 'session-title') guard.blocked(owner, reason)
125
+ yield { type: 'finish', reason: { kind: 'error', failure: new LlmError(reason, FUSION_CONTEXT_BUDGET).failure } }
126
+ return
127
+ }
128
+ if (isAgentLoopRequest(request) && request.purpose === undefined) guard.#rejected.delete(agent)
129
+ // Run spend reservation only after context admission; rejected local
130
+ // attempts are neither upstream calls nor consumed call allowances.
131
+ ready(agent, request)
132
+ admit(agent, owner, request)
133
+ yield* next()
134
+ }, { prepend: true })
135
+ const disposeRecovery = this.ctx.on('agent/request-error', async ({ agent, turn, step, failure, signal }, next) => {
136
+ const rejected = this.#rejected.get(agent)
137
+ if (failure.code !== FUSION_CONTEXT_BUDGET || !rejected) return next()
138
+ this.#rejected.delete(agent)
139
+ const { owner } = rejected
140
+ const key = `context-recovery:${owner.binding.taskId}:${agent.id}:${turn}:${step}`
141
+ const saved = this.store.readDocument(key)
142
+ const prior = saved?.value as { attempts: number; priorInput: number } | undefined
143
+ const attempts = prior?.attempts ?? 0
144
+ const stop = (reason: string) => {
145
+ const current = this.store.readDocument(key)
146
+ const used = (current?.value as { attempts?: number } | undefined)?.attempts ?? attempts
147
+ this.store.writeDocument(key, current?.revision ?? 0,
148
+ { ...(current?.value as Record<string, unknown> | undefined), schemaVersion: 1, attempts: used,
149
+ priorInput: rejected.measurement.inputTokens, state: 'blocked', reason, request: rejected.recordId })
150
+ this.blocked(owner, reason)
151
+ return undefined
152
+ }
153
+ if (signal.aborted) return stop('上下文压缩已中断;Fusion 已暂停并保留原始记录')
154
+ if (rejected.minimumRetainedInputTokens > rejected.budget) return stop('Fusion 必须保留的任务内容已超过本轮上下文预算;请缩短最新交接或调整模型配置,原始记录已保留')
155
+ if (attempts >= 2 || prior && rejected.measurement.inputTokens >= prior.priorInput) return stop('上下文压缩没有继续缩小;Fusion 已暂停,保留原始记录')
156
+ // Web presets deliberately isolate this service. The public preset
157
+ // addressor selects this Agent's existing owner without mounting another.
158
+ const compaction = this.ctx.get('agentPresets')?.serviceFor(agent, 'compaction')
159
+ ?? agent.ctx.get('compaction') ?? this.ctx.get('compaction')
160
+ const meter = this.ctx.get('tokenMeter')
161
+ if (!compaction || !meter) return stop('DSH 未提供上下文压缩服务;Fusion 已暂停')
162
+ const measurement = meter.measure(agent.session)
163
+ const nodes = measurement.nodes
164
+ const first = nodes.findIndex(node => agent.session.eventAt(node.seq)?.type !== 'system/message')
165
+ // Keep the newest balanced unit and a bounded recent tail. Selection is
166
+ // by surface position, never numeric seq order after a replacement.
167
+ const policy = owner.binding.profile.context[owner.role]
168
+ const keepTokens = Math.max(0, Math.floor(rejected.budget * policy.compactToFraction)
169
+ - Math.max(0, measurement.totalTokens - measurement.surfaceTokens) - policy.reserveOutputTokens)
170
+ let keep = nodes.length - 1
171
+ while (keep > first && !toolPairingBalancedBefore(agent.session, nodes[keep]!.seq)) keep--
172
+ let retained = nodes.slice(Math.max(0, keep)).reduce((sum, node) => sum + node.tokens, 0)
173
+ for (let index = keep - 1; index > first; index--) {
174
+ retained += nodes[index]!.tokens
175
+ if (retained > keepTokens) break
176
+ if (toolPairingBalancedBefore(agent.session, nodes[index]!.seq)) keep = index
177
+ }
178
+ if (first < 0 || keep <= first) return stop('当前输入没有可安全压缩的历史;请缩短输入或调整 Fusion 模型配置')
179
+ this.store.writeDocument(key, saved?.revision ?? 0, { schemaVersion: 1, attempts: attempts + 1,
180
+ priorInput: rejected.measurement.inputTokens, state: 'started', request: rejected.recordId })
181
+ try {
182
+ const checkpoint = this.taskContext?.prepareCompaction(agent, owner.binding, owner.role,
183
+ nodes.slice(first, keep).map(node => node.seq))
184
+ const result = await compaction.compactRegion(nodes[first]!.seq, nodes[keep - 1]!.seq, agent, signal)
185
+ signal.throwIfAborted()
186
+ if (checkpoint) this.taskContext!.restoreCompaction(agent, owner.binding, owner.role, checkpoint, result.compactionId)
187
+ const after = meter.measure(agent.session).totalTokens
188
+ this.store.writeDocument(key, this.store.readDocument(key)!.revision, { schemaVersion: 1, attempts: attempts + 1,
189
+ priorInput: rejected.measurement.inputTokens, state: 'compacted', compactionId: result.compactionId,
190
+ beforeTokens: rejected.measurement.inputTokens, beforeNativeTokens: measurement.totalTokens,
191
+ afterTokens: after, request: rejected.recordId, ...(checkpoint ? { checkpoint } : {}) })
192
+ // A provider usage anchor can be below the actual one-shot envelope.
193
+ // Compare against the admitted measurement, then check the complete
194
+ // frozen retry again. A still-rejected nonshrinking retry stops above.
195
+ if (after >= rejected.measurement.inputTokens) return stop('DSH 压缩后上下文没有缩小;Fusion 已暂停')
196
+ return { kind: 'retry' }
197
+ } catch (error) {
198
+ if (signal.aborted) return stop('上下文压缩已中断;Fusion 已暂停并保留原始记录')
199
+ return stop(`DSH 上下文压缩失败;Fusion 已暂停:${error instanceof Error ? error.message : String(error)}`)
200
+ }
201
+ }, { prepend: true })
202
+ return () => { disposeRecovery(); disposeStream() }
203
+ }
204
+ }
@@ -0,0 +1,260 @@
1
+ import { randomUUID } from 'node:crypto'
2
+ import type { Context } from '@deepseek-ai/cordis'
3
+ import type { Agent } from '@deepseek-ai/dsh-agent'
4
+ import type { ToolExecution } from '@deepseek-ai/dsh-tools'
5
+ import { JobId } from '@deepseek-ai/dsh-jobs'
6
+ import type { JobView } from '@deepseek-ai/dsh-jobs'
7
+ import type { ArtifactRef, Role, TaskId } from '../contracts.js'
8
+ import type { SqliteFusionStore } from '../task/sqlite-store.js'
9
+ import type { SessionBinding } from './bindings.js'
10
+ import { isShellTool } from './shell.js'
11
+
12
+ interface EffectRecord {
13
+ schemaVersion: 1
14
+ taskId: TaskId
15
+ agentId: string
16
+ role: Role
17
+ callId: string
18
+ rootCallId: string
19
+ toolName: string
20
+ processId: number
21
+ startedAt: string
22
+ state: 'dispatch-started' | 'returned' | 'outcome-unknown' | 'inspected'
23
+ arguments: ArtifactRef
24
+ endedAt?: string
25
+ nativeIsError?: boolean
26
+ inspectionEvidence?: string
27
+ quiescenceBasis?: 'explicit-user-inspection'
28
+ nativeJob?: { id: string; startedAt: number; deadlineAt: number | null; status: JobView['status']; detail?: string }
29
+ }
30
+
31
+ /** Public native dispatch journal; it never wraps a provider or invents a PID. */
32
+ export class NativeEffects {
33
+ readonly #live = new Map<symbol, TaskId>()
34
+ readonly #waiters = new Map<symbol, Agent>()
35
+ readonly #jobs = new Map<string, { id: string; row: EffectRecord; agent: Agent; timer: ReturnType<typeof setTimeout> | undefined }>()
36
+ readonly #registry: Context['jobs'] | undefined
37
+ constructor(readonly ctx: Context, readonly store: SqliteFusionStore, readonly callbacks: {
38
+ owner(agent: Agent): { binding: SessionBinding; role: Role } | undefined
39
+ track(exec: Readonly<ToolExecution>): boolean
40
+ failed(agent: Agent, error: unknown): void
41
+ backgroundMaxMs?(exec: Readonly<ToolExecution>, binding: SessionBinding): number | null
42
+ }) { this.#registry = ctx.get('jobs') }
43
+
44
+ install(): () => void {
45
+ const offJobs = this.#registry?.events.subscribe({ owners: 'all' }, event => {
46
+ if (event.type !== 'settled') return
47
+ const snapshot = event.job
48
+ const live = this.#jobs.get(snapshot.id)
49
+ if (live && snapshot.owner === live.agent.id) this.#settled(live, snapshot)
50
+ })
51
+ const offTools = this.ctx.on('tools/execute', async (exec, next) => {
52
+ const owner = exec.agent && this.callbacks.owner(exec.agent)
53
+ if (owner && exec.name === 'job_output') {
54
+ this.#waiters.set(exec.token, exec.agent!)
55
+ try { return await next() } finally { this.#waiters.delete(exec.token) }
56
+ }
57
+ if (!owner || !this.callbacks.track(exec)) return next()
58
+ const taskId = owner.binding.taskId, id = `native-effect:${taskId}:${randomUUID()}`
59
+ let row: EffectRecord
60
+ try {
61
+ row = { schemaVersion: 1, taskId, agentId: exec.agent!.id, role: owner.role,
62
+ callId: exec.callId, rootCallId: exec.rootCallId, toolName: exec.name, processId: process.pid,
63
+ startedAt: new Date().toISOString(), state: 'dispatch-started',
64
+ arguments: this.store.putArtifact(taskId, Buffer.from(JSON.stringify({ tool: exec.name, arguments: exec.arguments })), 'application/vnd.dsh-fusion.native-effect+json') }
65
+ this.store.writeDocument(id, 0, row)
66
+ } catch (error) { this.callbacks.failed(exec.agent!, error); throw error }
67
+ this.#live.set(exec.token, taskId)
68
+ try {
69
+ const result = await next()
70
+ const value = !result.isError && result.value
71
+ // A shell call keeps a live native job when it asked for background OR its foreground run was
72
+ // promoted (rc.2 returns kind:'promoted' + jobId on a foreground timeout, even with
73
+ // run_in_background=false). Either way the job must own the write lease until it settles.
74
+ const resultJob = (!result.isError ? value : null) as { kind?: unknown; jobId?: unknown } | null
75
+ const jobKind = resultJob?.kind
76
+ const keepsLiveJob = isShellTool(exec.name) && !result.isError
77
+ && ((exec.arguments as { run_in_background?: unknown }).run_in_background === true
78
+ || jobKind === 'background' || jobKind === 'promoted')
79
+ if (keepsLiveJob) {
80
+ // kind=promoted/background is a live job claim: verify the identity, never record it as
81
+ // returned — a missing jobId would release the write lease while the command still runs.
82
+ if ((jobKind !== 'background' && jobKind !== 'promoted') || typeof resultJob?.jobId !== 'string') {
83
+ throw new Error(`Native ${exec.name} returned a live job without an authoritative jobId`)
84
+ }
85
+ const snapshot = this.#registry!.get(JobId(resultJob.jobId), exec.agent?.id)
86
+ if (snapshot.kind !== exec.name || snapshot.owner !== exec.agent!.id) throw new Error('Native job ownership mismatch')
87
+ const maximum = this.callbacks.backgroundMaxMs ? this.callbacks.backgroundMaxMs(exec, owner.binding) : 60_000
88
+ const requested = (exec.arguments as { timeoutMs?: unknown }).timeoutMs
89
+ const timeout = typeof requested === 'number' && Number.isFinite(requested) && requested > 0
90
+ ? Math.min(requested, maximum ?? Infinity) : maximum
91
+ if (timeout !== null && (!Number.isFinite(timeout) || timeout <= 0)) throw new Error('Background command deadline is unavailable')
92
+ row = { ...row, nativeJob: { id: snapshot.id, startedAt: snapshot.startedAt,
93
+ deadlineAt: timeout === null ? null : Date.now() + Math.ceil(timeout), status: snapshot.status } }
94
+ const timer = timeout === null ? undefined : setTimeout(() => {
95
+ try {
96
+ this.#assertJob(row, this.#registry!.get(snapshot.id, exec.agent?.id))
97
+ this.#registry!.kill(snapshot.id, exec.agent?.id, 'Fusion command deadline elapsed')
98
+ } catch (error) {
99
+ const live = this.#jobs.get(snapshot.id)
100
+ if (live) this.#uncertain(live, error)
101
+ else this.callbacks.failed(exec.agent!, error)
102
+ }
103
+ }, timeout)
104
+ timer?.unref()
105
+ const live = { id, row, agent: exec.agent!, timer }
106
+ this.#jobs.set(snapshot.id, live)
107
+ this.store.writeDocument(id, 1, row)
108
+ if (snapshot.status !== 'running' && snapshot.status !== 'stopping') this.#settled(live, snapshot)
109
+ return result
110
+ }
111
+ // This means the native tool promise returned, not that its mutation
112
+ // was correct, or that a post-crash provider range was inspected.
113
+ this.store.writeDocument(id, 1, { ...row, state: 'returned', endedAt: new Date().toISOString(), nativeIsError: Boolean(result.isError) })
114
+ return result
115
+ } catch (error) {
116
+ // A start may have succeeded before durable job identity could be saved.
117
+ // Cancel only this exact native job, never replay the command.
118
+ if (row.nativeJob && this.#jobs.has(row.nativeJob.id)) {
119
+ try { this.#registry!.kill(JobId(row.nativeJob.id), exec.agent?.id, 'Fusion job tracking failed') }
120
+ catch (stopError) { this.callbacks.failed(exec.agent!, stopError) }
121
+ }
122
+ try {
123
+ this.store.writeDocument(id, this.store.readDocument(id)!.revision,
124
+ { ...row, state: 'outcome-unknown', endedAt: new Date().toISOString() })
125
+ } catch (recordingError) { this.callbacks.failed(exec.agent!, recordingError) }
126
+ this.callbacks.failed(exec.agent!, error)
127
+ throw error
128
+ } finally { this.#live.delete(exec.token) }
129
+ }, { prepend: true })
130
+ return () => {
131
+ offTools(); offJobs?.()
132
+ for (const live of this.#jobs.values()) clearTimeout(live.timer)
133
+ }
134
+ }
135
+
136
+ #settled(live: { id: string; row: EffectRecord; agent: Agent; timer: ReturnType<typeof setTimeout> | undefined }, snapshot: JobView): void {
137
+ try {
138
+ this.#assertJob(live.row, snapshot)
139
+ if (snapshot.status === 'running' || snapshot.status === 'stopping') return
140
+ const row = this.store.readDocument(live.id)!
141
+ this.store.writeDocument(live.id, row.revision, { ...live.row,
142
+ nativeJob: { ...live.row.nativeJob!, status: snapshot.status, ...(snapshot.detail ? { detail: snapshot.detail } : {}) },
143
+ state: snapshot.status === 'failed' ? 'outcome-unknown' : 'returned', endedAt: new Date().toISOString() })
144
+ // Failed registry records can result from a throwing producer cancel;
145
+ // unlike completed/killed bash, that does not prove process quiescence.
146
+ if (snapshot.status === 'failed') this.callbacks.failed(live.agent, new Error('Native job failed without proven process quiescence'))
147
+ } catch (error) { this.callbacks.failed(live.agent, error) }
148
+ finally { clearTimeout(live.timer); this.#jobs.delete(snapshot.id) }
149
+ }
150
+
151
+ #assertJob(row: EffectRecord, snapshot: JobView): void {
152
+ if (!row.nativeJob || row.processId !== process.pid || snapshot.id !== row.nativeJob.id
153
+ || snapshot.kind !== row.toolName || snapshot.owner !== row.agentId || snapshot.startedAt !== row.nativeJob.startedAt) {
154
+ throw new Error('Native job identity changed; inspect rather than adopting or replaying it')
155
+ }
156
+ }
157
+
158
+ #uncertain(live: { id: string; row: EffectRecord; agent: Agent; timer: ReturnType<typeof setTimeout> | undefined }, error: unknown): void {
159
+ clearTimeout(live.timer)
160
+ this.#jobs.delete(live.row.nativeJob!.id)
161
+ try {
162
+ const row = this.store.readDocument(live.id)!
163
+ this.store.writeDocument(live.id, row.revision, { ...live.row, state: 'outcome-unknown' })
164
+ } catch (recordingError) { this.callbacks.failed(live.agent, recordingError) }
165
+ this.callbacks.failed(live.agent, error)
166
+ }
167
+
168
+ backgroundProblem(exec: Readonly<ToolExecution>): string | undefined {
169
+ if (!this.#registry || !exec.agent || !this.ctx.tools.get('job_output', exec.agent?.id) || !this.ctx.tools.get('job_kill', exec.agent?.id)) {
170
+ return 'Background commands require the native jobs runtime and visible job_output/job_kill tools'
171
+ }
172
+ return undefined
173
+ }
174
+
175
+ jobProblem(exec: Readonly<ToolExecution>, taskId: TaskId): string | undefined {
176
+ const id = (exec.arguments as { job_id?: unknown }).job_id
177
+ if (typeof id !== 'string' || !exec.agent || !this.#registry) return 'Native job identity is required'
178
+ const record = this.store.listDocumentIds(`native-effect:${taskId}:`).map(key => this.store.readDocument(key)!.value as EffectRecord)
179
+ .find(row => row.nativeJob?.id === id && row.agentId === exec.agent!.id && row.taskId === taskId)
180
+ if (!record) return 'Only native jobs recorded for this Agent and current Fusion task may be accessed'
181
+ try { this.#assertJob(record, this.#registry.get(JobId(id), exec.agent?.id)) }
182
+ catch (error) { return String(error) }
183
+ return undefined
184
+ }
185
+
186
+ waitingForJob(agent: Agent): boolean { return [...this.#waiters.values()].includes(agent) }
187
+
188
+ /**
189
+ * Whether a tool call this process is running belongs to this task, or a
190
+ * native job it started there is still unsettled. This is the in-process half
191
+ * of the deletion guard: a durable row alone cannot tell a live call from the
192
+ * residue of an interrupted one, because both look like `dispatch-started`.
193
+ */
194
+ live(taskId: TaskId): boolean {
195
+ return [...this.#live.values()].includes(taskId)
196
+ || [...this.#jobs.values()].some(job => job.row.taskId === taskId)
197
+ }
198
+
199
+ onlyBackgroundPending(taskId: TaskId): boolean {
200
+ const pending = this.pending(taskId)
201
+ return pending.length > 0 && pending.every(({ id, record }) => record.state === 'dispatch-started'
202
+ && record.nativeJob && this.#jobs.get(record.nativeJob.id)?.id === id)
203
+ }
204
+
205
+ async stopJobs(taskId: TaskId): Promise<void> {
206
+ for (const live of [...this.#jobs.values()].filter(job => job.row.taskId === taskId)) {
207
+ try {
208
+ const id = JobId(live.row.nativeJob!.id)
209
+ this.#assertJob(live.row, this.#registry!.get(id, live.agent.id))
210
+ this.#registry!.kill(id, live.agent.id, 'Fusion task is stopping')
211
+ const done = await this.#registry!.wait(id, 10_000, live.agent.id)
212
+ if (done.status === 'running' || done.status === 'stopping' || done.status === 'failed') {
213
+ throw new Error('Native job did not prove process quiescence; keep the writer and inspect')
214
+ }
215
+ if (this.#jobs.has(id)) this.#settled(live, done)
216
+ } catch (error) { this.#uncertain(live, error); throw error }
217
+ }
218
+ }
219
+
220
+ pending(taskId: TaskId): readonly { id: string; record: EffectRecord }[] {
221
+ return this.store.listDocumentIds(`native-effect:${taskId}:`).flatMap(id => {
222
+ const record = this.store.readDocument(id)!.value as EffectRecord
223
+ if (record.schemaVersion !== 1 || record.taskId !== taskId || !record.agentId || !record.toolName
224
+ || !Number.isSafeInteger(record.processId) || record.processId <= 0
225
+ || !['dispatch-started', 'returned', 'outcome-unknown', 'inspected'].includes(record.state)) {
226
+ throw new Error('Native effect journal requires migration or inspection')
227
+ }
228
+ if (record.nativeJob && (typeof record.nativeJob.id !== 'string'
229
+ || !Number.isSafeInteger(record.nativeJob.startedAt) || record.nativeJob.deadlineAt !== null && !Number.isSafeInteger(record.nativeJob.deadlineAt))) {
230
+ throw new Error('Native job journal requires migration or inspection')
231
+ }
232
+ return record.state === 'dispatch-started' || record.state === 'outcome-unknown' ? [{ id, record }] : []
233
+ })
234
+ }
235
+
236
+ assertInspection(taskId: TaskId, effectsStopped: boolean): void {
237
+ const pending = this.pending(taskId)
238
+ if (!pending.length) return
239
+ if ([...this.#live.values()].includes(taskId) || [...this.#jobs.values()].some(job => job.row.taskId === taskId)) {
240
+ throw new Error('Native tool execution is still live; stop and settle it before recovery')
241
+ }
242
+ if (!effectsStopped) {
243
+ throw new Error(`有 ${pending.length} 项原生操作在中断时未收尾。Host 退出不代表命令及子进程已停止。请先检查并停止这些操作,再使用 /fusion recover --effects-stopped <检查记录>。`)
244
+ }
245
+ for (const { record } of pending) {
246
+ if (record.processId === process.pid) continue
247
+ try { process.kill(record.processId, 0); throw new Error('The recorded effect owner is still live; settle it before recovery') }
248
+ catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ESRCH') throw error }
249
+ }
250
+ }
251
+
252
+ inspected(taskId: TaskId, evidence: string, effectsStopped: boolean): void {
253
+ this.assertInspection(taskId, effectsStopped)
254
+ if (!evidence) throw new Error('Effect inspection evidence is required')
255
+ for (const { id, record } of this.pending(taskId)) {
256
+ this.store.writeDocument(id, this.store.readDocument(id)!.revision, { ...record,
257
+ state: 'inspected', inspectionEvidence: evidence, quiescenceBasis: 'explicit-user-inspection' })
258
+ }
259
+ }
260
+ }
@@ -0,0 +1,31 @@
1
+ import type { ArtifactRef, TaskId, ToolEvidence } from '../contracts.js'
2
+ import { digestOf } from '../digest.js'
3
+ import type { PlannedReceipt } from '../evidence/receipts.js'
4
+ import type { SqliteFusionStore } from '../task/sqlite-store.js'
5
+ import type { FrozenCheck } from './native-checks.js'
6
+
7
+ /** Coverage uses logical receipt IDs; ordinary evidence uses content digests. */
8
+ export function readNativeEvidence(store: SqliteFusionStore, taskId: TaskId, id: string): Uint8Array {
9
+ if (!id.startsWith('receipt:')) return store.readArtifact(taskId, id)
10
+ const match = /^receipt:(fusion-check-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/.exec(id)
11
+ if (!match) throw new Error('Invalid native check receipt id')
12
+ // Never search another task's documents, even when the caller knows its ID.
13
+ const document = store.readDocument(`check-invocation:${taskId}:${match[1]}`)
14
+ const record = document?.value as { check?: FrozenCheck; receipt?: PlannedReceipt; evidence?: ToolEvidence } | undefined
15
+ const { check, receipt, evidence } = record ?? {}
16
+ if (!check?.plan || !check.definition || !receipt?.evidence || !evidence
17
+ || receipt.id !== id || check.plan.taskId !== taskId || evidence.taskId !== taskId
18
+ || evidence.nativeToolCallId !== match[1] || evidence.operationId !== match[1]
19
+ || receipt.planDigest !== digestOf(check.plan) || digestOf(receipt.evidence) !== digestOf(evidence)
20
+ || evidence.argvDigest !== check.plan.argvDigest || evidence.cwdDigest !== check.plan.cwdDigest) {
21
+ throw new Error('Native check receipt unavailable or invalid for this task')
22
+ }
23
+ const refs: ArtifactRef[] = [evidence.stdout, evidence.stderr]
24
+ if (refs.some(ref => !ref || ref.ownerTaskId !== taskId || String(ref.id) !== ref.digest
25
+ || !Number.isSafeInteger(ref.bytes) || ref.bytes < 0)) throw new Error('Invalid check output ownership or reference')
26
+ const contents = store.readArtifacts(taskId, refs.map(ref => ref.id))
27
+ if (contents.some((bytes, index) => bytes.byteLength !== refs[index]!.bytes)) throw new Error('Invalid check output length')
28
+ // Reading a failed or old check is allowed. Only the existing review gate can
29
+ // decide whether it proves acceptance of the current candidate.
30
+ return Buffer.from(JSON.stringify({ kind: 'check-receipt', check, receipt }))
31
+ }
@@ -0,0 +1,114 @@
1
+ import { lstatSync, readFileSync } from 'node:fs'
2
+ import type { ExplorationReport, ExplorationSource, WorkOrder } from '../contracts.js'
3
+ import { sha256Hex } from '../digest.js'
4
+ import type { SqliteFusionStore } from '../task/sqlite-store.js'
5
+ import { changedPaths, workspacePath } from './workspace.js'
6
+ import type { WorkspaceSnapshot } from './workspace.js'
7
+
8
+ interface PreparedSource { source: ExplorationSource; text: string; digest: string }
9
+
10
+ function formatChanged(paths: readonly string[]): string {
11
+ const shown = paths.slice(0, 12)
12
+ if (!shown.length) return '(snapshot id changed with no listed path delta)'
13
+ return `${shown.join(', ')}${paths.length > shown.length ? ` (+${paths.length - shown.length} more)` : ''}`
14
+ }
15
+
16
+ /** Read cited ranges against the frozen baseline. Nothing is stored until every range is valid. */
17
+ function prepareSources(baseline: WorkspaceSnapshot, sources: readonly ExplorationSource[]): PreparedSource[] {
18
+ if (sources.length > 12) throw new Error('A completed exploration requires 1–12 source ranges')
19
+ let total = 0
20
+ return sources.map(source => {
21
+ const path = workspacePath(baseline.root, source.path)
22
+ const entry = baseline.entries.find(item => item.path === source.path && item.kind === 'file')
23
+ if (!entry) throw new Error('Exploration sources must be files in the frozen workspace manifest')
24
+ if (!Number.isSafeInteger(source.startLine) || !Number.isSafeInteger(source.endLine)
25
+ || source.startLine < 1 || source.endLine < source.startLine || source.endLine - source.startLine >= 80) {
26
+ throw new Error('Exploration source ranges require 1–80 lines each')
27
+ }
28
+ let stat
29
+ try { stat = lstatSync(path) }
30
+ catch (error) {
31
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') throw new Error('Exploration source is missing')
32
+ throw error
33
+ }
34
+ // A FIFO or socket is not source text. Reject it before readFileSync, which would block on a pipe.
35
+ if (!stat.isFile()) throw new Error(`Unsupported source file type: ${source.path}`)
36
+ const bytes = readFileSync(path)
37
+ if (sha256Hex(bytes) !== entry.digest) throw new Error('Exploration source changed during capture')
38
+ const content = new TextDecoder('utf-8', { fatal: true }).decode(bytes)
39
+ if (content.includes('\0')) throw new Error('Exploration sources must be text files')
40
+ const lines = content.split('\n')
41
+ if (source.endLine > lines.length) throw new Error('Exploration source range exceeds the file')
42
+ const text = lines.slice(source.startLine - 1, source.endLine).join('\n')
43
+ total += Buffer.byteLength(text, 'utf8')
44
+ if (total > 24_000) throw new Error('Exploration excerpts exceed 24000 bytes; select narrower ranges')
45
+ return { source, text, digest: entry.digest }
46
+ })
47
+ }
48
+
49
+ function storeSources(store: SqliteFusionStore, taskId: WorkOrder['taskId'], prepared: readonly PreparedSource[]): ExplorationReport['sources'] {
50
+ return prepared.map(item => ({ ...item.source, fileDigest: item.digest,
51
+ excerpt: store.putArtifact(taskId, Buffer.from(item.text), 'text/plain') }))
52
+ }
53
+
54
+ /**
55
+ * Source-backed handoff. Completed reports are checked against the files they cite,
56
+ * not against every other file in a non-git root. blocked and needs-decision reports
57
+ * are always recorded, with drift or a failed whole-tree snapshot named, and are not acceptance.
58
+ *
59
+ * `current` is omitted when snapshotting the tree threw. The failure is stored on a
60
+ * non-completed report. A completed report ignores that unrelated failure and still
61
+ * stands or falls on the cited files alone.
62
+ */
63
+ export function captureExploration(store: SqliteFusionStore, order: WorkOrder, baseline: WorkspaceSnapshot, current: WorkspaceSnapshot | undefined,
64
+ submitted: Pick<ExplorationReport, 'summary' | 'status' | 'unresolved'>, sources: readonly ExplorationSource[], snapshotFailure?: string): ExplorationReport {
65
+ if (order.mode !== 'explore' || baseline.id !== order.baseSnapshot || (current !== undefined && baseline.root !== current.root)) {
66
+ throw new Error('Exploration capture requires the frozen explore baseline')
67
+ }
68
+ if (current === undefined && !snapshotFailure) throw new Error('Exploration capture requires a current snapshot or a recorded snapshot failure')
69
+ const drifted = current !== undefined && baseline.id !== current.id
70
+ const changed = drifted && current ? changedPaths(baseline, current) : []
71
+ const driftDetail = drifted && current
72
+ ? `Exploration workspace changed since frozen baseline ${baseline.id}; current snapshot ${current.id}; changed paths: ${formatChanged(changed)}. This is not acceptance. A completed report can cite only files that still match the baseline; blocked or needs-decision records this drift.`
73
+ : undefined
74
+ const snapshotDetail = current === undefined
75
+ ? `Workspace snapshot failed: ${snapshotFailure}. Frozen baseline ${baseline.id}. This is not acceptance. The report is recorded so the blocker is not dropped; a completed report can cite only files that still match that baseline.`
76
+ : undefined
77
+ if (submitted.status !== 'completed') {
78
+ const problems = [driftDetail, snapshotDetail].filter((item): item is string => item !== undefined)
79
+ let prepared: PreparedSource[] = []
80
+ if (sources.length) {
81
+ try { prepared = prepareSources(baseline, sources) }
82
+ catch (error) { problems.push(error instanceof Error ? error.message : String(error)) }
83
+ }
84
+ return { schemaVersion: 1, workOrderId: order.id, revision: order.revision, summary: submitted.summary,
85
+ status: submitted.status, unresolved: [...submitted.unresolved, ...problems], snapshot: baseline.id,
86
+ sources: storeSources(store, order.taskId, prepared) }
87
+ }
88
+ if (!sources.length || sources.length > 12) throw new Error('A completed exploration requires 1–12 source ranges')
89
+ if (drifted && current) {
90
+ const cited = new Set(sources.map(source => source.path))
91
+ const citedChanged = changed.filter(path => cited.has(path))
92
+ if (citedChanged.length) {
93
+ throw new Error(`Cited exploration evidence changed: ${citedChanged.join(', ')}. Frozen baseline ${baseline.id}; current snapshot ${current.id}. Submit status "blocked" or "needs-decision" to record this drift; a completed report cannot cite files that no longer match the frozen baseline.`)
94
+ }
95
+ }
96
+ const prepared = prepareSources(baseline, sources)
97
+ return { schemaVersion: 1, workOrderId: order.id, revision: order.revision, ...submitted,
98
+ snapshot: baseline.id, sources: storeSources(store, order.taskId, prepared) }
99
+ }
100
+
101
+ /** Path of the first cited file whose bytes no longer match the captured digest, if any. */
102
+ export function citedEvidenceChanged(root: string, baseline: WorkspaceSnapshot, report: Pick<ExplorationReport, 'sources'>): string | undefined {
103
+ for (const source of report.sources) {
104
+ const entry = baseline.entries.find(item => item.path === source.path && item.kind === 'file')
105
+ if (!entry || entry.digest !== source.fileDigest) return source.path
106
+ try {
107
+ const path = workspacePath(root, source.path)
108
+ if (!lstatSync(path).isFile()) return source.path
109
+ const bytes = readFileSync(path)
110
+ if (sha256Hex(bytes) !== source.fileDigest) return source.path
111
+ } catch { return source.path }
112
+ }
113
+ return undefined
114
+ }