@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,292 @@
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 { createUserMessage, isAgentLoopRequest } from '@deepseek-ai/dsh-llm'
5
+ import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
6
+ import type { Role, TaskId } from '../contracts.js'
7
+ import { digestOf } from '../digest.js'
8
+ import type { SqliteFusionStore } from '../task/sqlite-store.js'
9
+ import type { SessionBinding } from './bindings.js'
10
+ import type { CacheMode } from './cache-defaults.js'
11
+ import { cacheHit } from './cache-policy.js'
12
+ import { NativeAuxiliaryRequests } from './native-auxiliary.js'
13
+ import { nativeRequestAgent } from './native-request.js'
14
+ import { FUSION_SOURCE_KIND } from '../message-source.js'
15
+
16
+ /** R19 recovered schedule. */
17
+ export const KEEPALIVE_INTERVAL_MS = 285_000
18
+ /**
19
+ * The line appended after the copied prefix. maxTokens: 1 is not enough on its own: the ChatGPT Codex route
20
+ * rejects an output cap and pi-ai omits it, so a ping that said "continue" made the Lead think and call tools
21
+ * (live log: median 463 output tokens, all ending in tool calls). Measured 2026-10-01 on gpt-6-astra (Codex):
22
+ * "Reply OK" answered "OK" in 5 output tokens, 0 reasoning, no tool calls, 16/16 with tools still offered,
23
+ * full cache hit; "continue" called tools 8/8 and a bare "OK" read as approval and produced a plan.
24
+ * Tool choice and reasoning effort cannot change here: effort is part of OpenAI's cache key (effort "low"
25
+ * missed the cache entirely) and DSH requests carry no tool_choice.
26
+ */
27
+ export const KEEPALIVE_PROMPT = 'Reply OK'
28
+ export const KEEPALIVE_ATTEMPTS = 11
29
+ export interface KeepaliveClock {
30
+ now(): number
31
+ schedule(callback: () => void, delayMs: number): () => void
32
+ }
33
+ const systemClock: KeepaliveClock = {
34
+ now: () => performance.now(),
35
+ schedule: (callback, delay) => {
36
+ const timer = setTimeout(callback, delay)
37
+ timer.unref()
38
+ return () => clearTimeout(timer)
39
+ },
40
+ }
41
+ type Owner = { binding: SessionBinding; role: Role }
42
+ interface Callbacks {
43
+ owner(agent: Agent): Owner | undefined
44
+ allowed(agent: Agent, owner: Owner): boolean
45
+ failed(agent: Agent, reason: string): void
46
+ /** Per-model mode and interval (user setting, learned, defaults); absent: the pair profile and a fixed interval. */
47
+ policy?(owner: Owner, provider: string, model: string): { mode: CacheMode; intervalMs: number }
48
+ /** Evidence for learning and the settings page: a request after a wait, or a ping. */
49
+ observe?(owner: Owner, provider: string, model: string, sample: { gapSeconds: number; ping: boolean; hit: boolean; cacheRead: number; input: number; output?: number }): void
50
+ }
51
+ export interface KeepaliveRecord {
52
+ schemaVersion: 1
53
+ id: string
54
+ taskId: TaskId
55
+ sessionId: string
56
+ role: Role
57
+ profileDigest: string
58
+ inputDigest: string
59
+ createdAt: string
60
+ updatedAt: string
61
+ state: 'generating' | 'scheduled' | 'inflight' | 'stopped'
62
+ attempts: number
63
+ successes: number
64
+ stopReason?: string
65
+ }
66
+ interface Series {
67
+ agent: Agent
68
+ owner: Owner
69
+ input: GenerateOptions
70
+ started: number
71
+ controller: AbortController
72
+ record: KeepaliveRecord
73
+ revision: number
74
+ cancelTimer?: () => void
75
+ detachSourceAbort?: () => void
76
+ intervalMs: number
77
+ }
78
+
79
+ /** Retains only plugin-owned requests in memory; never replays timers on restart. */
80
+ /** Explicit per-role choice; unset is automatic for the Lead (armed only on observed cache reads), off for the Worker. */
81
+ export function keepalivePolicy(binding: SessionBinding, role: Role): boolean | 'auto' {
82
+ return binding.profile.cacheKeepalive?.[role] ?? (role === 'lead' ? 'auto' : false)
83
+ }
84
+
85
+ export class NativeCacheKeepalive {
86
+ readonly #series = new Map<string, Series>()
87
+ /** Start time of each agent's previous model request (real or ping): the wait before the next one. */
88
+ readonly #lastStart = new Map<string, number>()
89
+ readonly #pending = new Map<Promise<void>, { sessionId: string; taskId: TaskId }>()
90
+ readonly #dispose: (() => void)[] = []
91
+ #closed = false
92
+ constructor(readonly ctx: Context, readonly store: SqliteFusionStore,
93
+ readonly auxiliary: NativeAuxiliaryRequests, readonly callbacks: Callbacks,
94
+ readonly clock: KeepaliveClock = systemClock) {}
95
+
96
+ #active(row: Series): boolean {
97
+ if (this.#closed || this.#series.get(row.agent.id) !== row || row.controller.signal.aborted
98
+ || this.ctx.agents.get(row.agent.id) !== row.agent) return false
99
+ const current = this.callbacks.owner(row.agent)
100
+ return Boolean(current?.binding.selected && current.binding.taskId === row.owner.binding.taskId
101
+ && current.binding.profile.digest === row.owner.binding.profile.digest && current.role === row.owner.role
102
+ && this.#policy(current, row.input.provider, row.input.model).mode !== 'off' && this.callbacks.allowed(row.agent, current))
103
+ }
104
+
105
+ #policy(owner: Owner, provider: string, model: string): { mode: CacheMode; intervalMs: number } {
106
+ if (this.callbacks.policy) return this.callbacks.policy(owner, provider, model)
107
+ const legacy = keepalivePolicy(owner.binding, owner.role)
108
+ return { mode: legacy === true ? 'on' : legacy === false ? 'off' : 'auto', intervalMs: KEEPALIVE_INTERVAL_MS }
109
+ }
110
+
111
+ #invalidate(row: Series): void {
112
+ if (this.#series.get(row.agent.id) === row) this.#series.delete(row.agent.id)
113
+ row.cancelTimer?.(); row.cancelTimer = undefined
114
+ row.detachSourceAbort?.(); row.detachSourceAbort = undefined
115
+ row.controller.abort()
116
+ }
117
+
118
+ #persist(row: Series): boolean {
119
+ row.record.updatedAt = new Date().toISOString()
120
+ try {
121
+ row.revision = this.store.writeDocument(`keepalive:${row.record.taskId}:${row.record.id}`, row.revision, row.record)
122
+ return true
123
+ } catch (error) {
124
+ this.#invalidate(row)
125
+ this.callbacks.failed(row.agent, `Cache keepalive tracking failed: ${String(error)}`)
126
+ return false
127
+ }
128
+ }
129
+
130
+ #stop(row: Series, reason: string): void {
131
+ this.#invalidate(row)
132
+ if (row.record.state === 'stopped') return
133
+ row.record.state = 'stopped'; row.record.stopReason = reason
134
+ this.#persist(row)
135
+ }
136
+
137
+ stopSession(sessionId: string, reason: string): Promise<void> {
138
+ const row = this.#series.get(sessionId)
139
+ if (row) this.#stop(row, reason)
140
+ return Promise.all([...this.#pending].filter(([, item]) => item.sessionId === sessionId).map(([pending]) => pending)).then(() => undefined)
141
+ }
142
+
143
+ stopTask(taskId: TaskId, reason: string): Promise<void> {
144
+ for (const row of this.#series.values()) if (row.record.taskId === taskId) this.#stop(row, reason)
145
+ return Promise.all([...this.#pending].filter(([, item]) => item.taskId === taskId).map(([pending]) => pending)).then(() => undefined)
146
+ }
147
+
148
+ /** Called again after asynchronous context measurement, before spend admission. */
149
+ assertRequest(agent: Agent, request: GenerateOptions): void {
150
+ const extra = this.auxiliary.get(request)
151
+ if (!extra) return
152
+ const row = this.#series.get(agent.id)
153
+ if (!row || row.record.id !== extra.seriesId || row.record.attempts !== extra.iteration
154
+ || row.record.taskId !== extra.taskId || row.record.sessionId !== extra.sessionId
155
+ || row.record.profileDigest !== extra.profileDigest || row.record.role !== extra.role || !this.#active(row)) {
156
+ throw new Error('Cache keepalive generation is no longer active')
157
+ }
158
+ request.signal?.throwIfAborted()
159
+ }
160
+
161
+ #arm(row: Series, deadline: number): void {
162
+ if (!this.#active(row)) { this.#stop(row, 'inactive'); return }
163
+ row.record.state = 'scheduled'
164
+ if (!this.#persist(row)) return
165
+ row.cancelTimer = this.clock.schedule(() => {
166
+ row.cancelTimer = undefined
167
+ const pending = Promise.resolve().then(() => this.#ping(row)).finally(() => { this.#pending.delete(pending) })
168
+ this.#pending.set(pending, { sessionId: row.agent.id, taskId: row.record.taskId })
169
+ }, Math.max(0, deadline - this.clock.now()))
170
+ }
171
+
172
+ async #ping(row: Series): Promise<void> {
173
+ try {
174
+ if (!this.#active(row)) { this.#stop(row, 'inactive'); return }
175
+ const nextDeadline = this.clock.now() + row.intervalMs
176
+ row.record.attempts++; row.record.state = 'inflight'
177
+ if (!this.#persist(row)) return
178
+ const request: GenerateOptions = { ...structuredClone(row.input), maxTokens: 1, signal: row.controller.signal,
179
+ messages: [...structuredClone(row.input.messages), createUserMessage({ content: [{ type: 'text', text: KEEPALIVE_PROMPT }],
180
+ source: { kind: FUSION_SOURCE_KIND } })] }
181
+ this.auxiliary.tag(request, { purpose: 'cache-keepalive', taskId: row.record.taskId, sessionId: row.agent.id,
182
+ profileDigest: row.owner.binding.profile.digest, role: row.owner.role, seriesId: row.record.id, iteration: row.record.attempts })
183
+ let success = false
184
+ // Drain only: returned text, reasoning and tool calls never enter a Session
185
+ // or execute. Public llm/stream still applies context, spend and usage hooks.
186
+ for await (const chunk of this.ctx.llm.stream(request)) {
187
+ if (chunk.type === 'finish') success = ['stop', 'max-tokens', 'tool-calls'].includes(chunk.reason.kind)
188
+ }
189
+ if (success) row.record.successes++
190
+ if (row.controller.signal.aborted || this.#series.get(row.agent.id) !== row) { this.#persist(row); return }
191
+ if (!success) { this.#stop(row, 'request-failed'); return }
192
+ if (row.record.attempts >= KEEPALIVE_ATTEMPTS) { this.#stop(row, 'attempt-limit'); return }
193
+ this.#arm(row, nextDeadline)
194
+ } catch {
195
+ this.#stop(row, row.controller.signal.aborted ? 'cancelled' : 'request-failed')
196
+ }
197
+ }
198
+
199
+ install(): void {
200
+ const manager = this
201
+ // Evidence for the settings page and the interval rule: how long each agent waited and whether its
202
+ // prefix was still cached. Pings count too (they refresh the cache the next request relies on).
203
+ this.#dispose.push(this.ctx.on('llm/stream', async function* (request, next): AsyncIterable<StreamChunk> {
204
+ const agent = nativeRequestAgent(manager.ctx, request), owner = agent && manager.callbacks.owner(agent)
205
+ const extra = manager.auxiliary.get(request), ping = extra?.purpose === 'cache-keepalive'
206
+ if (!agent || !owner || !manager.callbacks.observe || (extra && !ping) || (!ping && (!isAgentLoopRequest(request) || request.purpose !== undefined))) {
207
+ yield* next(); return
208
+ }
209
+ const started = manager.clock.now(), previous = manager.#lastStart.get(agent.id)
210
+ manager.#lastStart.set(agent.id, started)
211
+ let usage: { inputTokens?: number; cacheReadTokens?: number; outputTokens?: number } | undefined
212
+ for await (const chunk of next()) {
213
+ if (chunk.type === 'usage') usage = chunk.usage
214
+ yield chunk
215
+ }
216
+ if (!usage || previous === undefined) return
217
+ const cacheRead = usage.cacheReadTokens ?? 0, input = usage.inputTokens ?? 0
218
+ try {
219
+ manager.callbacks.observe(owner, request.provider, request.model,
220
+ { gapSeconds: (started - previous) / 1000, ping, hit: cacheHit(cacheRead, input), cacheRead, input, output: usage.outputTokens ?? 0 })
221
+ } catch { /* evidence is advisory; never fail a model request over it */ }
222
+ }))
223
+ // Replacement invalidates the previous loop even if the next input is
224
+ // rejected locally. An in-flight ping drains before a new request enters.
225
+ this.#dispose.push(this.ctx.on('llm/stream', async function* (request, next) {
226
+ if (!manager.auxiliary.get(request) && (isAgentLoopRequest(request) || request.purpose === 'compaction')) {
227
+ const agent = nativeRequestAgent(manager.ctx, request)
228
+ if (agent) await manager.stopSession(agent.id, request.purpose === 'compaction' ? 'compaction' : 'new-generation')
229
+ }
230
+ yield* next()
231
+ }, { prepend: true }))
232
+ // This observer is inside admission. Only actual admitted AgentLoop input
233
+ // can establish a new prefix; unrelated one-shot calls cannot start timers.
234
+ this.#dispose.push(this.ctx.on('llm/stream', async function* (request, next): AsyncIterable<StreamChunk> {
235
+ const agent = nativeRequestAgent(manager.ctx, request), owner = agent && manager.callbacks.owner(agent)
236
+ // Explicit per-role choice wins. Unset means automatic for the Lead: it waits minutes for the Sidekick,
237
+ // and an expired prefix is resent uncached at the frontier price (round 4: 69% cache hits against 94%
238
+ // alone). Automatic arming requires this route to have reported cache reads, so a route without prompt
239
+ // caching never pays for pings.
240
+ const policy = owner ? manager.#policy(owner, request.provider, request.model) : undefined
241
+ if (!agent || !owner || !policy || policy.mode === 'off' || manager.auxiliary.get(request)
242
+ || !isAgentLoopRequest(request) || request.purpose !== undefined || manager.#closed) { yield* next(); return }
243
+ // Copy only public fields: no AgentLoop brand, source signal or mutable
244
+ // session history. Adapters may normalize their own copy downstream.
245
+ const input: GenerateOptions = structuredClone({ provider: request.provider, model: request.model,
246
+ messages: request.messages, tools: request.tools, system: request.system, temperature: request.temperature,
247
+ reasoningEffort: request.reasoningEffort, stop: request.stop, sessionId: request.sessionId })
248
+ const row: Series = { agent, owner, input, started: manager.clock.now(), controller: new AbortController(), revision: 0, intervalMs: policy.intervalMs,
249
+ record: { schemaVersion: 1, id: randomUUID(), taskId: owner.binding.taskId, sessionId: agent.id, role: owner.role,
250
+ profileDigest: owner.binding.profile.digest, inputDigest: digestOf(input), createdAt: new Date().toISOString(),
251
+ updatedAt: new Date().toISOString(), state: 'generating', attempts: 0, successes: 0 } }
252
+ manager.#series.set(agent.id, row)
253
+ if (request.signal) {
254
+ const abort = () => { void manager.stopSession(agent.id, 'native-cancel') }
255
+ request.signal.addEventListener('abort', abort, { once: true })
256
+ row.detachSourceAbort = () => request.signal!.removeEventListener('abort', abort)
257
+ }
258
+ if (!manager.#persist(row)) throw new Error('Cache keepalive request tracking failed')
259
+ let success = false, cached = policy.mode === 'on'
260
+ try {
261
+ for await (const chunk of next()) {
262
+ if (chunk.type === 'finish') success = ['stop', 'max-tokens', 'tool-calls'].includes(chunk.reason.kind)
263
+ if (chunk.type === 'usage' && (chunk.usage.cacheReadTokens ?? 0) > 0) cached = true
264
+ yield chunk
265
+ }
266
+ } finally {
267
+ if (success && cached && !request.signal?.aborted && manager.#series.get(agent.id) === row) manager.#arm(row, row.started + row.intervalMs)
268
+ else manager.#stop(row, cached ? 'generation-stopped' : 'no-cache-evidence')
269
+ }
270
+ }))
271
+ this.#dispose.push(this.ctx.on('session/event', (session, event) => {
272
+ if (event.type === 'compaction/start') void this.stopSession(session.id, 'compaction')
273
+ if (event.type !== 'turn/end') return
274
+ const row = this.#series.get(session.id)
275
+ if (row && (event.data.reason.kind !== 'completed' || row.owner.role === 'worker' || !this.#active(row))) {
276
+ void this.stopSession(session.id, 'agent-stopping')
277
+ }
278
+ }))
279
+ this.#dispose.push(this.ctx.on('agent/status', ({ status }) => {
280
+ if (status !== 'idle') return
281
+ for (const row of this.#series.values()) if (!this.#active(row)) void this.stopSession(row.agent.id, 'agent-idle')
282
+ }))
283
+ this.#dispose.push(this.ctx.on('agent/disposed', ({ agent }) => { this.#lastStart.delete(agent.id); void this.stopSession(agent.id, 'agent-disposed') }))
284
+ }
285
+
286
+ async close(): Promise<void> {
287
+ this.#closed = true
288
+ for (const row of this.#series.values()) this.#stop(row, 'runtime-closing')
289
+ await Promise.all(this.#pending.keys())
290
+ for (const dispose of this.#dispose.reverse()) dispose()
291
+ }
292
+ }
@@ -0,0 +1,298 @@
1
+ import { bi } from '../bilingual.js'
2
+ import { digestOf } from '../digest.js'
3
+ import { randomUUID } from 'node:crypto'
4
+ import type { Context } from '@deepseek-ai/cordis'
5
+ import type { Agent } from '@deepseek-ai/dsh-agent'
6
+ import { createUserMessage } from '@deepseek-ai/dsh-llm'
7
+ import type { LlmFailure, UserMessage } from '@deepseek-ai/dsh-llm'
8
+ import type { PhysicalRoute, Role } from '../contracts.js'
9
+ import type { SqliteFusionStore } from '../task/sqlite-store.js'
10
+ import { BindingRepository, type SessionBinding } from './bindings.js'
11
+ import { adaptiveWorkflow, enforcedWorkflow } from './native-workflow.js'
12
+ import { isShellTool } from './shell.js'
13
+ import { unsettledContinuationError } from './worker-terminal.js'
14
+ import { FUSION_SOURCE_KIND } from '../message-source.js'
15
+
16
+ const LEAD_RECOVERABLE = ['WORKFLOW_INCOMPLETE', 'NO_PROGRESS', 'UNKNOWN']
17
+ const LEAD_RECOVERIES = 2
18
+
19
+ export interface ModelControl {
20
+ schemaVersion: 1
21
+ sessionId: string
22
+ profileDigest: string
23
+ epoch: number
24
+ recoveryEpoch?: number
25
+ /** Lead-cleared recoverable Worker waits for the current task (bounded). */
26
+ leadRecoveries?: { taskId: string; count: number }
27
+ routes: Record<Role, PhysicalRoute>
28
+ waits: Partial<Record<Role, { taskId: string; code: string; at: string; retryNotBefore?: string; quotaResetAt: null }>>
29
+ operation?: { id: string; taskId: string; state: 'prepared' | 'dispatching' | 'delivered' | 'failed'; message?: UserMessage; error?: string }
30
+ schedule?: { id: string; taskId: string; epoch: number; dueAt: string; userSeq: number; state: 'scheduled' | 'cancelled' | 'fired' | 'failed'; reason?: string }
31
+ }
32
+ export interface ControlView extends ModelControl { revision: number }
33
+ export function readModelControl(store: SqliteFusionStore, binding: SessionBinding): ControlView | undefined {
34
+ if (binding.profile.interactionMode !== 'model-like') return undefined
35
+ const row = store.readDocument(`model-control:${binding.sessionId}`)
36
+ if (!row) return { schemaVersion: 1, revision: 0, sessionId: binding.sessionId, profileDigest: binding.profile.digest,
37
+ epoch: 0, routes: { lead: binding.profile.lead, worker: binding.profile.worker }, waits: {} }
38
+ const value = row.value as ModelControl
39
+ if (value.schemaVersion !== 1 || value.sessionId !== binding.sessionId || value.profileDigest !== binding.profile.digest
40
+ || !Number.isSafeInteger(value.epoch) || value.epoch < 0 || !value.routes?.lead || !value.routes.worker || !value.waits) {
41
+ throw new Error('Fusion model control requires reconciliation')
42
+ }
43
+ return { ...value, revision: row.revision }
44
+ }
45
+
46
+ interface Callbacks {
47
+ owner(agent: Agent): { binding: SessionBinding; role: Role } | undefined
48
+ resolve(sessionId: string): Promise<Agent>
49
+ pause(agent: Agent): Promise<void>
50
+ resume(agent: Agent): Promise<void>
51
+ settled(binding: SessionBinding): boolean
52
+ stopAuxiliary(binding: SessionBinding): void
53
+ }
54
+
55
+ /** Durable local controls. No provider polling, synthetic user identity, or silent routing fallback. */
56
+ export class NativeModelControl {
57
+ readonly #bindings: BindingRepository
58
+ readonly #locks = new Set<string>()
59
+ readonly #pending = new Set<Promise<void>>()
60
+ readonly #timers = new Map<string, ReturnType<typeof setTimeout>>()
61
+ readonly #dispose: (() => void)[] = []
62
+ #closed = false
63
+ constructor(readonly ctx: Context, readonly store: SqliteFusionStore, readonly callbacks: Callbacks) {
64
+ this.#bindings = new BindingRepository(store)
65
+ this.#dispose.push(ctx.on('agent/request-error', async (payload, next) => {
66
+ const owner = callbacks.owner(payload.agent)
67
+ if (owner?.binding.profile.interactionMode !== 'model-like') return next()
68
+ const quota = ['QUOTA', 'QUOTA_EXHAUSTED', 'INSUFFICIENT_QUOTA', 'USAGE_LIMIT_REACHED'].includes(payload.failure.code)
69
+ if (!quota) {
70
+ const action = await next()
71
+ if (!action && !payload.signal.aborted) this.failure(owner.binding, owner.role, payload.failure)
72
+ return action
73
+ }
74
+ this.failure(owner.binding, owner.role, payload.failure)
75
+ payload.agent.cancel({ kind: 'hook', reason: 'Fusion provider quota requires local continuation' }, { keepInbox: true })
76
+ return undefined
77
+ }, { prepend: true }))
78
+ this.#dispose.push(ctx.on('session/event', (session, event) => {
79
+ const user = event.type === 'user/message' ? !event.data.source || event.data.source.kind === 'user'
80
+ : event.type === 'agent/inbox/spliced' && event.data.inserted.some(message => !message.source || message.source.kind === 'user')
81
+ if (!user) return
82
+ const binding = this.#bindings.read(session.id)?.binding
83
+ if (!binding?.selected || binding.profile.interactionMode !== 'model-like') return
84
+ let view = readModelControl(store, binding)!
85
+ if (view.schedule?.state === 'scheduled' && event.seq > view.schedule.userSeq) this.cancel(binding, bi('新的用户消息已取消定时继续', 'A new user message cancelled the scheduled continuation'))
86
+ view = readModelControl(store, binding)!
87
+ if (adaptiveWorkflow(binding) && Object.values(view.waits).some(wait => wait?.taskId === binding.taskId
88
+ && ['NO_PROGRESS', 'WORKFLOW_INCOMPLETE'].includes(wait.code))) {
89
+ this.write({ ...view, recoveryEpoch: (view.recoveryEpoch ?? 0) + 1,
90
+ waits: Object.fromEntries(Object.entries(view.waits).filter(([, wait]) => wait?.taskId !== binding.taskId
91
+ || !['NO_PROGRESS', 'WORKFLOW_INCOMPLETE'].includes(wait.code))) })
92
+ }
93
+ }))
94
+ this.#dispose.push(ctx.on('tools/result', (exec, result) => {
95
+ const owner = exec.agent && callbacks.owner(exec.agent)
96
+ if (owner?.binding.profile.interactionMode !== 'model-like') return undefined
97
+ if (enforcedWorkflow(owner.binding)) return undefined
98
+ const id = `progress:${owner.binding.taskId}:${exec.agent!.id}`, row = store.readDocument(id)
99
+ const prior = row?.value as { fingerprint?: string; count?: number } | undefined
100
+ const shell = isShellTool(exec.name) && !result.isError ? result.value as { exitCode?: unknown; stdout?: unknown; stderr?: unknown } : undefined
101
+ const failedShell = typeof shell?.exitCode === 'number' && shell.exitCode !== 0
102
+ const fingerprint = result.isError || failedShell ? digestOf({ name: exec.name, args: exec.arguments,
103
+ outcome: failedShell ? { exitCode: shell!.exitCode, stdout: shell!.stdout, stderr: shell!.stderr } : result.content }) : undefined
104
+ const count = fingerprint && fingerprint === prior?.fingerprint ? (prior.count ?? 0) + 1 : fingerprint ? 1 : 0
105
+ store.writeDocument(id, row?.revision ?? 0, { fingerprint, count })
106
+ if (count >= 3) this.failure(owner.binding, owner.role, { code: 'NO_PROGRESS', message: 'Three unchanged failed tool attempts; a changed approach is required' })
107
+ return undefined
108
+ }))
109
+ for (const binding of this.#bindings.selected()) this.arm(binding)
110
+ }
111
+ reset(binding: SessionBinding) {
112
+ if (binding.profile.interactionMode !== 'model-like') return
113
+ const id = `model-control:${binding.sessionId}`, row = this.store.readDocument(id)
114
+ this.store.writeDocument(id, row?.revision ?? 0, { schemaVersion: 1, sessionId: binding.sessionId,
115
+ profileDigest: binding.profile.digest, epoch: 0, routes: { lead: binding.profile.lead, worker: binding.profile.worker }, waits: {} })
116
+ }
117
+ write(view: ControlView) {
118
+ const { revision, ...value } = view
119
+ return this.store.writeDocument(`model-control:${view.sessionId}`, revision, value)
120
+ }
121
+ route(binding: SessionBinding, role: Role): PhysicalRoute { return readModelControl(this.store, binding)?.routes[role] ?? binding.profile[role] }
122
+ waiting(binding: SessionBinding, role: Role) {
123
+ const wait = readModelControl(this.store, binding)?.waits[role]
124
+ return wait?.taskId === binding.taskId ? wait : undefined
125
+ }
126
+ /**
127
+ * Clear a role wait the Lead can resolve itself by sending a new instruction: a missing report or
128
+ * transition (WORKFLOW_INCOMPLETE), a repeated unchanged result (NO_PROGRESS) or an unclassified
129
+ * error (UNKNOWN). At most LEAD_RECOVERIES per task; quota and credential stops are never cleared here.
130
+ */
131
+ leadRecover(binding: SessionBinding, role: Role): boolean {
132
+ const view = readModelControl(this.store, binding)
133
+ const wait = view?.waits[role]
134
+ if (!view || !wait || wait.taskId !== binding.taskId || !LEAD_RECOVERABLE.includes(wait.code)) return false
135
+ const used = view.leadRecoveries?.taskId === binding.taskId ? view.leadRecoveries.count : 0
136
+ if (used >= LEAD_RECOVERIES) return false
137
+ const { [role]: _cleared, ...waits } = view.waits
138
+ this.write({ ...view, waits, recoveryEpoch: (view.recoveryEpoch ?? 0) + 1, leadRecoveries: { taskId: binding.taskId, count: used + 1 } })
139
+ return true
140
+ }
141
+ /** The role stalled again after the Lead already redirected it LEAD_RECOVERIES times this task. */
142
+ recoveriesExhausted(binding: SessionBinding, role: Role): boolean {
143
+ const view = readModelControl(this.store, binding), wait = view?.waits[role]
144
+ if (!view || !wait || wait.taskId !== binding.taskId || !LEAD_RECOVERABLE.includes(wait.code)) return false
145
+ return (view.leadRecoveries?.taskId === binding.taskId ? view.leadRecoveries.count : 0) >= LEAD_RECOVERIES
146
+ }
147
+ blocked(binding: SessionBinding, role: Role): string | undefined {
148
+ const view = readModelControl(this.store, binding)
149
+ if (view?.operation && ['prepared', 'dispatching'].includes(view.operation.state)) return 'Fusion continuation is pending local delivery confirmation'
150
+ return view?.waits[role]?.taskId === binding.taskId ? `${role} is paused (${view.waits[role]!.code}); use local Fusion controls to continue` : undefined
151
+ }
152
+ reconcileDelivery(agent: Agent, binding: SessionBinding, evidenceId: string) {
153
+ const view = readModelControl(this.store, binding), operation = view?.operation
154
+ if (!view || !operation || !['prepared', 'dispatching'].includes(operation.state)) return
155
+ const delivered = operation.message && agent.session.snapshotEvents().some(event => event.type === 'agent/inbox/spliced'
156
+ && event.data.inserted.some(message => message.id === operation.message!.id))
157
+ this.write({ ...view, operation: { ...operation, state: delivered ? 'delivered' : 'failed',
158
+ error: `Delivery inspected; evidence ${evidenceId}. No automatic replay.` } })
159
+ }
160
+ failure(binding: SessionBinding, role: Role, failure: LlmFailure) {
161
+ const view = readModelControl(this.store, binding)!
162
+ const ms = failure.providerRetryAfterMs
163
+ this.write({ ...view, waits: { ...view.waits, [role]: { taskId: binding.taskId, code: failure.code,
164
+ at: new Date().toISOString(), quotaResetAt: null,
165
+ ...(ms !== undefined && Number.isFinite(ms) && ms >= 0 && ms < 8.64e15 - Date.now()
166
+ ? { retryNotBefore: new Date(Date.now() + ms).toISOString() } : {}) } },
167
+ ...(view.schedule ? { schedule: { ...view.schedule, state: 'cancelled', reason: bi('模型仍不可用;不会重复自动请求', 'The model is still unavailable; no further automatic request') } } : {}) })
168
+ this.callbacks.stopAuxiliary(binding)
169
+ this.clearTimer(binding.sessionId)
170
+ }
171
+ private require(sessionId: string, taskId: string, revision: number) {
172
+ const saved = this.#bindings.read(sessionId)
173
+ if (!saved?.binding.selected || saved.binding.taskId !== taskId) throw new Error(bi('任务已变化,请刷新状态', 'The task changed; refresh the status'))
174
+ const view = readModelControl(this.store, saved.binding)
175
+ if (!view || view.revision !== revision) throw new Error(bi('恢复状态已变化,请刷新后重试', 'The recovery state changed; refresh and retry'))
176
+ if (this.store.load(saved.binding.taskId)?.control.mode === 'completed') throw new Error(bi('任务已经完成', 'The task is already finished'))
177
+ return { binding: saved.binding, view, bindingRevision: saved.revision }
178
+ }
179
+ private userSeq(agent: Agent) { return agent.session.snapshotEvents().findLast(event => event.type === 'user/message'
180
+ && (!event.data.source || event.data.source.kind === 'user') || event.type === 'agent/inbox/spliced'
181
+ && event.data.inserted.some(message => !message.source || message.source.kind === 'user'))?.seq ?? 0 }
182
+ cancel(binding: SessionBinding, reason = bi('已取消定时继续', 'Scheduled continuation cancelled')) {
183
+ const view = readModelControl(this.store, binding)
184
+ if (view?.schedule?.state === 'scheduled') this.write({ ...view, schedule: { ...view.schedule, state: 'cancelled', reason } })
185
+ this.clearTimer(binding.sessionId)
186
+ }
187
+ async schedule(sessionId: string, taskId: string, revision: number, dueAt: string) {
188
+ const time = Date.parse(dueAt)
189
+ if (!Number.isFinite(time) || time <= Date.now()) throw new Error(bi('请选择未来的继续时间', 'Choose a time in the future'))
190
+ const { binding } = this.require(sessionId, taskId, revision)
191
+ const agent = await this.callbacks.resolve(sessionId)
192
+ const { view } = this.require(sessionId, taskId, revision)
193
+ if (agent.status !== 'idle' || !this.callbacks.settled(binding)) throw new Error(bi('等待当前操作停止并核对结果后再设置定时继续', 'Wait for the current operation to stop and check it before scheduling'))
194
+ if (view.operation && ['prepared', 'dispatching'].includes(view.operation.state)) throw new Error(bi('请先检查上次继续操作的投递结果', 'Check the delivery of the last continuation first'))
195
+ if (!Object.values(view.waits).some(wait => wait?.taskId === taskId)) throw new Error(bi('只有等待模型恢复的任务可以定时继续', 'Only a task waiting for its model can be scheduled'))
196
+ this.write({ ...view, schedule: { id: randomUUID(), taskId, epoch: view.epoch, dueAt: new Date(time).toISOString(),
197
+ userSeq: this.userSeq(agent), state: 'scheduled' } })
198
+ this.arm(binding)
199
+ }
200
+ continue(sessionId: string, taskId: string, revision: number, change?: { role: Role; route: PhysicalRoute }, scheduledId?: string): Promise<void> {
201
+ if (this.#closed) return Promise.reject(new Error('Fusion runtime is closing'))
202
+ const pending = this.runContinue(sessionId, taskId, revision, change, scheduledId)
203
+ this.#pending.add(pending)
204
+ void pending.finally(() => this.#pending.delete(pending)).catch(() => undefined)
205
+ return pending
206
+ }
207
+ private async runContinue(sessionId: string, taskId: string, revision: number, change?: { role: Role; route: PhysicalRoute }, scheduledId?: string) {
208
+ if (this.#locks.has(sessionId)) throw new Error(bi('当前会话正在恢复,请稍候', 'This conversation is recovering; please wait'))
209
+ this.#locks.add(sessionId)
210
+ try {
211
+ const initial = this.require(sessionId, taskId, revision)
212
+ const agent = await this.callbacks.resolve(sessionId)
213
+ const userSeq = this.userSeq(agent)
214
+ let { view, binding } = this.require(sessionId, taskId, revision)
215
+ if (view.operation && ['prepared', 'dispatching'].includes(view.operation.state)) {
216
+ const messageId = view.operation.message?.id
217
+ const delivered = messageId && agent.session.snapshotEvents().some(event => event.type === 'agent/inbox/spliced'
218
+ && event.data.inserted.some(message => message.id === messageId))
219
+ if (delivered) { this.write({ ...view, operation: { ...view.operation, state: 'delivered' } }); return }
220
+ throw new Error(bi('上次继续操作的投递结果未知;请检查会话,不会自动重复发送', 'Delivery of the last continuation is unknown; check the conversation. Nothing is resent automatically'))
221
+ }
222
+ if (scheduledId && (view.schedule?.id !== scheduledId || view.schedule.state !== 'scheduled'
223
+ || view.schedule.epoch !== view.epoch || view.schedule.userSeq !== userSeq || agent.status !== 'idle')) {
224
+ this.cancel(binding, bi('任务状态已变化,定时继续已取消', 'The task changed; the scheduled continuation was cancelled')); return
225
+ }
226
+ // Native pause drains both agents and native effects before committing a route.
227
+ await this.callbacks.pause(agent)
228
+ const current = this.#bindings.read(sessionId)
229
+ if (current?.revision !== initial.bindingRevision || this.userSeq(agent) !== userSeq) throw new Error(bi('暂停期间任务或用户消息已变化,请刷新', 'The task or messages changed while paused; refresh'))
230
+ ;({ view, binding } = this.require(sessionId, taskId, revision))
231
+ if (!this.callbacks.settled(binding)) {
232
+ const text = unsettledContinuationError(Boolean(change))
233
+ throw new Error(bi(text.zh, text.en))
234
+ }
235
+ const operation = { id: randomUUID(), taskId, state: 'prepared' as const }
236
+ const message = createUserMessage({ content: [{ type: 'text', text: `Continue the existing Fusion task ${taskId} from its saved state. Read fusion_read_state if needed. Preserve accepted constraints and inspect uncertain effects before replay. Local continuation operation: ${operation.id}.` }],
237
+ source: { kind: FUSION_SOURCE_KIND, form: 'notice', summary: bi('用户通过 Fusion 控件继续当前任务', 'The user continued the task from the Fusion controls') } })
238
+ view = { ...view, epoch: view.epoch + (change ? 1 : 0), recoveryEpoch: (view.recoveryEpoch ?? 0) + 1,
239
+ routes: change ? { ...view.routes, [change.role]: change.route } : view.routes,
240
+ waits: change ? Object.fromEntries(Object.entries(view.waits).filter(([role]) => role !== change.role)) : {}, operation: { ...operation, message },
241
+ ...(view.schedule ? { schedule: { ...view.schedule, state: scheduledId ? 'fired' : 'cancelled' } } : {}) }
242
+ view.revision = this.write(view)
243
+ for (const id of this.store.listDocumentIds(`progress:${taskId}:`)) {
244
+ const row = this.store.readDocument(id)!
245
+ this.store.writeDocument(id, row.revision, { count: 0 })
246
+ }
247
+ for (const id of this.store.listDocumentIds(`workflow-progress:${taskId}:`)) {
248
+ const row = this.store.readDocument(id)!
249
+ this.store.writeDocument(id, row.revision, { schemaVersion: 1, milestone: '', recent: [] })
250
+ }
251
+ this.clearTimer(sessionId)
252
+ try {
253
+ await this.callbacks.resume(agent)
254
+ if (this.#closed || this.userSeq(agent) !== userSeq) throw new Error(bi('恢复期间出现新消息或插件已退出', 'A new message arrived or the plugin exited during recovery'))
255
+ view = { ...view, operation: { ...view.operation!, state: 'dispatching' } }
256
+ view.revision = this.write(view)
257
+ agent.send(message, 'next-turn', true)
258
+ const delivered = agent.session.snapshotEvents().some(event => event.type === 'agent/inbox/spliced'
259
+ && event.data.inserted.some(item => item.id === message.id))
260
+ if (!delivered) throw new Error(bi('继续消息未获得原生收件箱确认;不会自动重发', 'The native inbox did not confirm the continuation; it is not resent automatically'))
261
+ this.write({ ...view, operation: { ...view.operation!, state: 'delivered' } })
262
+ } catch (error) {
263
+ if (view.operation?.state === 'prepared') this.write({ ...view, operation: { ...view.operation, state: 'failed', error: String(error) } })
264
+ throw error
265
+ }
266
+ } finally { this.#locks.delete(sessionId) }
267
+ }
268
+ private clearTimer(sessionId: string) { clearTimeout(this.#timers.get(sessionId)); this.#timers.delete(sessionId) }
269
+ private arm(binding: SessionBinding) {
270
+ this.clearTimer(binding.sessionId)
271
+ if (this.#closed || binding.profile.interactionMode !== 'model-like') return
272
+ const view = readModelControl(this.store, binding)!, schedule = view.schedule
273
+ if (schedule?.state !== 'scheduled') return
274
+ const delay = Math.max(0, Date.parse(schedule.dueAt) - Date.now())
275
+ const timer = setTimeout(() => {
276
+ this.#timers.delete(binding.sessionId)
277
+ const current = this.#bindings.read(binding.sessionId)?.binding
278
+ if (!current?.selected || current.taskId !== schedule.taskId || this.store.load(current.taskId)?.control.mode === 'completed') {
279
+ if (current?.selected && current.profile.interactionMode === 'model-like') this.cancel(current, bi('任务已结束或切换', 'The task ended or changed'))
280
+ return
281
+ }
282
+ if (delay > 60_000) { this.arm(current); return }
283
+ const fresh = readModelControl(this.store, current)!
284
+ void this.continue(current.sessionId, current.taskId, fresh.revision, undefined, schedule.id).catch(error => {
285
+ const latest = readModelControl(this.store, current)!
286
+ if (latest.schedule?.id === schedule.id && latest.schedule.state === 'scheduled') this.write({ ...latest,
287
+ schedule: { ...latest.schedule, state: 'failed', reason: String(error) } })
288
+ })
289
+ }, Math.min(delay, 60_000))
290
+ timer.unref(); this.#timers.set(binding.sessionId, timer)
291
+ }
292
+ async close() {
293
+ this.#closed = true
294
+ for (const id of this.#timers.keys()) this.clearTimer(id)
295
+ this.#dispose.forEach(dispose => dispose())
296
+ await Promise.allSettled(this.#pending)
297
+ }
298
+ }