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