@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.
- package/INSTALL.md +89 -0
- package/INSTALL.zh.md +86 -0
- package/LICENSE +201 -0
- package/README.en.md +193 -0
- package/README.md +182 -2
- package/RELEASING.md +123 -0
- package/RELEASING.zh.md +115 -0
- package/contracts/fusion.ts +279 -0
- package/contracts/storage.sql +86 -0
- package/cordis.patch.yml +3 -0
- package/docs/COMPATIBILITY.md +162 -0
- package/docs/EVIDENCE.md +141 -0
- package/dshx.yml +39 -0
- package/lib/client.js +1920 -0
- package/lib/client.js.map +1 -0
- package/lib/dsh-fusion.js +12664 -0
- package/lib/types/activity.d.ts +20 -0
- package/lib/types/approval/logical.d.ts +10 -0
- package/lib/types/benchmark/naive.d.ts +37 -0
- package/lib/types/benchmark/native-run.d.ts +68 -0
- package/lib/types/benchmark/output-limits.d.ts +19 -0
- package/lib/types/bilingual.d.ts +2 -0
- package/lib/types/client/ActivityRows.d.ts +8 -0
- package/lib/types/client/FusionCache.d.ts +4 -0
- package/lib/types/client/FusionHint.d.ts +9 -0
- package/lib/types/client/FusionHistory.d.ts +5 -0
- package/lib/types/client/FusionPanelIcon.d.ts +13 -0
- package/lib/types/client/FusionRecovery.d.ts +5 -0
- package/lib/types/client/FusionSettings.d.ts +9 -0
- package/lib/types/client/FusionStatus.d.ts +5 -0
- package/lib/types/client/FusionTool.d.ts +4 -0
- package/lib/types/client/activity-model.d.ts +14 -0
- package/lib/types/client/i18n.d.ts +15 -0
- package/lib/types/client/index.d.ts +22 -0
- package/lib/types/client/settings-events.d.ts +2 -0
- package/lib/types/client/status-polling.d.ts +9 -0
- package/lib/types/context/guard.d.ts +21 -0
- package/lib/types/context/handoff.d.ts +17 -0
- package/lib/types/contracts.d.ts +449 -0
- package/lib/types/digest.d.ts +5 -0
- package/lib/types/dsh-fusion.d.ts +13 -0
- package/lib/types/errors.d.ts +28 -0
- package/lib/types/evidence/gate.d.ts +23 -0
- package/lib/types/evidence/receipts.d.ts +65 -0
- package/lib/types/execution/write-lease.d.ts +42 -0
- package/lib/types/host/bindings.d.ts +30 -0
- package/lib/types/host/cache-defaults.d.ts +48 -0
- package/lib/types/host/cache-policy.d.ts +155 -0
- package/lib/types/host/change-evidence.d.ts +38 -0
- package/lib/types/host/compaction-routing.d.ts +15 -0
- package/lib/types/host/coordinator.d.ts +169 -0
- package/lib/types/host/evidence-page.d.ts +13 -0
- package/lib/types/host/history.d.ts +88 -0
- package/lib/types/host/http.d.ts +4 -0
- package/lib/types/host/model-output.d.ts +13 -0
- package/lib/types/host/native-activity.d.ts +26 -0
- package/lib/types/host/native-approvals.d.ts +29 -0
- package/lib/types/host/native-auxiliary.d.ts +17 -0
- package/lib/types/host/native-budget.d.ts +33 -0
- package/lib/types/host/native-checks.d.ts +104 -0
- package/lib/types/host/native-compaction.d.ts +8 -0
- package/lib/types/host/native-context.d.ts +36 -0
- package/lib/types/host/native-effects.d.ts +75 -0
- package/lib/types/host/native-evidence.d.ts +4 -0
- package/lib/types/host/native-exploration.d.ts +15 -0
- package/lib/types/host/native-keepalive.d.ts +82 -0
- package/lib/types/host/native-model-control.d.ts +99 -0
- package/lib/types/host/native-on-demand-context.d.ts +39 -0
- package/lib/types/host/native-request.d.ts +5 -0
- package/lib/types/host/native-review.d.ts +11 -0
- package/lib/types/host/native-role-sandbox.d.ts +49 -0
- package/lib/types/host/native-scopes.d.ts +49 -0
- package/lib/types/host/native-selection.d.ts +29 -0
- package/lib/types/host/native-task-context.d.ts +39 -0
- package/lib/types/host/native-usage.d.ts +46 -0
- package/lib/types/host/native-worker-brief.d.ts +74 -0
- package/lib/types/host/native-worker-notices.d.ts +15 -0
- package/lib/types/host/native-worker.d.ts +44 -0
- package/lib/types/host/native-workflow.d.ts +41 -0
- package/lib/types/host/route-transport.d.ts +22 -0
- package/lib/types/host/settings.d.ts +39 -0
- package/lib/types/host/shell.d.ts +11 -0
- package/lib/types/host/status.d.ts +7 -0
- package/lib/types/host/subagent-seam.d.ts +10 -0
- package/lib/types/host/tool-table.d.ts +35 -0
- package/lib/types/host/worker-terminal.d.ts +68 -0
- package/lib/types/host/workspace-guard.d.ts +13 -0
- package/lib/types/host/workspace.d.ts +52 -0
- package/lib/types/identity.d.ts +64 -0
- package/lib/types/index.d.ts +34 -0
- package/lib/types/message-source.d.ts +29 -0
- package/lib/types/profile/compactor.d.ts +5 -0
- package/lib/types/profile/keepalive.d.ts +3 -0
- package/lib/types/profile/resolve.d.ts +17 -0
- package/lib/types/prompts.d.ts +8 -0
- package/lib/types/recovery/reconcile.d.ts +28 -0
- package/lib/types/review/dispatch.d.ts +20 -0
- package/lib/types/review/protocol.d.ts +51 -0
- package/lib/types/review/ticket.d.ts +32 -0
- package/lib/types/status.d.ts +88 -0
- package/lib/types/store-migration.d.ts +29 -0
- package/lib/types/task/deletion.d.ts +64 -0
- package/lib/types/task/reducer.d.ts +6 -0
- package/lib/types/task/sqlite-store.d.ts +71 -0
- package/lib/types/task/state.d.ts +51 -0
- package/lib/types/task/status-index.d.ts +28 -0
- package/lib/types/task/store.d.ts +48 -0
- package/lib/types/usage/budget.d.ts +27 -0
- package/lib/types/usage/ledger.d.ts +66 -0
- package/lib/types/usage/normalize.d.ts +50 -0
- package/lib/types/usage/pricing.d.ts +18 -0
- package/package.json +178 -4
- package/profiles/fusion-auto.example.json +45 -0
- package/prompts/compact.md +7 -0
- package/prompts/lead.md +42 -0
- package/prompts/model-lead.md +13 -0
- package/prompts/model-worker.md +5 -0
- package/prompts/worker.md +21 -0
- package/src/activity.ts +23 -0
- package/src/approval/logical.ts +44 -0
- package/src/benchmark/naive.ts +216 -0
- package/src/benchmark/native-run.ts +215 -0
- package/src/benchmark/output-limits.ts +33 -0
- package/src/bilingual.ts +2 -0
- package/src/client/ActivityRows.tsx +59 -0
- package/src/client/FusionCache.tsx +130 -0
- package/src/client/FusionHint.tsx +39 -0
- package/src/client/FusionHistory.tsx +247 -0
- package/src/client/FusionPanelIcon.tsx +39 -0
- package/src/client/FusionRecovery.tsx +69 -0
- package/src/client/FusionSettings.tsx +205 -0
- package/src/client/FusionStatus.tsx +103 -0
- package/src/client/FusionTool.tsx +66 -0
- package/src/client/activity-model.ts +31 -0
- package/src/client/i18n.ts +30 -0
- package/src/client/index.ts +50 -0
- package/src/client/settings-events.ts +3 -0
- package/src/client/status-polling.ts +88 -0
- package/src/context/guard.ts +81 -0
- package/src/context/handoff.ts +28 -0
- package/src/contracts.ts +449 -0
- package/src/digest.ts +28 -0
- package/src/dsh-fusion.ts +321 -0
- package/src/errors.ts +34 -0
- package/src/evidence/gate.ts +290 -0
- package/src/evidence/receipts.ts +175 -0
- package/src/execution/write-lease.ts +187 -0
- package/src/host/bindings.ts +91 -0
- package/src/host/cache-defaults.ts +103 -0
- package/src/host/cache-policy.ts +168 -0
- package/src/host/change-evidence.ts +156 -0
- package/src/host/compaction-routing.ts +46 -0
- package/src/host/coordinator.ts +2160 -0
- package/src/host/evidence-page.ts +25 -0
- package/src/host/history.ts +114 -0
- package/src/host/http.ts +30 -0
- package/src/host/model-output.ts +22 -0
- package/src/host/native-activity.ts +199 -0
- package/src/host/native-approvals.ts +196 -0
- package/src/host/native-auxiliary.ts +19 -0
- package/src/host/native-budget.ts +112 -0
- package/src/host/native-checks.ts +474 -0
- package/src/host/native-compaction.ts +34 -0
- package/src/host/native-context.ts +204 -0
- package/src/host/native-effects.ts +260 -0
- package/src/host/native-evidence.ts +31 -0
- package/src/host/native-exploration.ts +114 -0
- package/src/host/native-keepalive.ts +292 -0
- package/src/host/native-model-control.ts +298 -0
- package/src/host/native-on-demand-context.ts +74 -0
- package/src/host/native-request.ts +12 -0
- package/src/host/native-review.ts +51 -0
- package/src/host/native-role-sandbox.ts +171 -0
- package/src/host/native-scopes.ts +203 -0
- package/src/host/native-selection.ts +110 -0
- package/src/host/native-task-context.ts +264 -0
- package/src/host/native-usage.ts +121 -0
- package/src/host/native-worker-brief.ts +122 -0
- package/src/host/native-worker-notices.ts +72 -0
- package/src/host/native-worker.ts +215 -0
- package/src/host/native-workflow.ts +103 -0
- package/src/host/route-transport.ts +54 -0
- package/src/host/settings.ts +93 -0
- package/src/host/shell.ts +13 -0
- package/src/host/status.ts +112 -0
- package/src/host/subagent-seam.ts +20 -0
- package/src/host/tool-table.ts +56 -0
- package/src/host/worker-terminal.ts +91 -0
- package/src/host/workspace-guard.ts +17 -0
- package/src/host/workspace.ts +184 -0
- package/src/identity.ts +76 -0
- package/src/index.ts +50 -0
- package/src/message-source.ts +31 -0
- package/src/profile/compactor.ts +26 -0
- package/src/profile/keepalive.ts +13 -0
- package/src/profile/resolve.ts +131 -0
- package/src/prompts.ts +32 -0
- package/src/recovery/reconcile.ts +38 -0
- package/src/review/dispatch.ts +48 -0
- package/src/review/protocol.ts +212 -0
- package/src/review/ticket.ts +166 -0
- package/src/status.ts +55 -0
- package/src/store-migration.ts +65 -0
- package/src/task/deletion.ts +105 -0
- package/src/task/reducer.ts +679 -0
- package/src/task/sqlite-store.ts +386 -0
- package/src/task/state.ts +72 -0
- package/src/task/status-index.ts +198 -0
- package/src/task/store.ts +287 -0
- package/src/usage/budget.ts +88 -0
- package/src/usage/ledger.ts +218 -0
- package/src/usage/normalize.ts +308 -0
- 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
|
+
}
|