dsh-bettertaskgraphs 0.0.0-stage → 0.2.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/LICENSE +674 -0
- package/README.md +55 -2
- package/client.js +539 -0
- package/client.source.mjs +322 -0
- package/cordis.patch.yml +5 -0
- package/core.mjs +432 -0
- package/host.mjs +472 -0
- package/index.mjs +70 -0
- package/journal.mjs +131 -0
- package/package.json +78 -4
- package/projection.mjs +205 -0
- package/remote.mjs +51 -0
- package/scripts/build-client.mjs +26 -0
- package/store.mjs +152 -0
- package/tools.mjs +94 -0
- package/view-model.mjs +157 -0
- package/work-policy.mjs +74 -0
package/host.mjs
ADDED
|
@@ -0,0 +1,472 @@
|
|
|
1
|
+
// SPDX-License-Identifier: GPL-3.0-only
|
|
2
|
+
import fs from 'node:fs'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
import { createHash, randomUUID } from 'node:crypto'
|
|
5
|
+
import { TransactionGraph, canonical, copy, digest, evidenceRefs, GraphError, view } from './core.mjs'
|
|
6
|
+
import { MemoryGraphStore } from './store.mjs'
|
|
7
|
+
import { HostJournal, claimDigest } from './journal.mjs'
|
|
8
|
+
import { KEY, INTENT, RECEIPT, RECOVERY } from './projection.mjs'
|
|
9
|
+
import { administrative, incompleteDisposition, ownedExecutionChain, unresolvedWork, workKey } from './work-policy.mjs'
|
|
10
|
+
|
|
11
|
+
const fail = (code, detail = code) => { throw new GraphError(code, detail) }
|
|
12
|
+
const under = (root, target) => { const relative = path.relative(root, target); return !path.isAbsolute(relative) && relative !== '..' && !relative.startsWith('..' + path.sep) }
|
|
13
|
+
export function realProjectPath(root, requested, { directory = true } = {}) {
|
|
14
|
+
if (typeof requested !== 'string' || !path.isAbsolute(requested)) fail('ABSOLUTE_PROJECT_PATH_REQUIRED')
|
|
15
|
+
const value = path.resolve(requested)
|
|
16
|
+
if (!under(root, value)) fail('PATH_OUTSIDE_OWN_PROJECT')
|
|
17
|
+
let cursor = root
|
|
18
|
+
for (const part of path.relative(root, value).split(path.sep).filter(Boolean)) {
|
|
19
|
+
cursor = path.join(cursor, part)
|
|
20
|
+
if (fs.lstatSync(cursor).isSymbolicLink()) fail('PROJECT_SYMLINK_REJECTED')
|
|
21
|
+
}
|
|
22
|
+
const info = fs.lstatSync(value)
|
|
23
|
+
if (fs.realpathSync(value) !== value || (directory ? !info.isDirectory() : !info.isFile())) fail('PROJECT_PATH_TYPE_OR_IDENTITY')
|
|
24
|
+
return value
|
|
25
|
+
}
|
|
26
|
+
export const sessionReference = (sessionId, seq) => `dsh-session:${encodeURIComponent(sessionId)}#event=${seq}`
|
|
27
|
+
// Official JSONL toHeaderLine uses delegationDepth ?? 0; a freshly created
|
|
28
|
+
// Session may omit this one optional field. Every other header field remains
|
|
29
|
+
// part of exact equality; this is not a general projection or subset compare.
|
|
30
|
+
export function sameStoredSessionHeader(a, b) {
|
|
31
|
+
if (!a || !b || typeof a !== 'object' || typeof b !== 'object' || Array.isArray(a) || Array.isArray(b)) return false
|
|
32
|
+
return canonical({ ...a, delegationDepth: a.delegationDepth ?? 0 }) === canonical({ ...b, delegationDepth: b.delegationDepth ?? 0 })
|
|
33
|
+
}
|
|
34
|
+
function referenceSeq(reference, sessionId) {
|
|
35
|
+
const prefix = `dsh-session:${encodeURIComponent(sessionId)}#event=`
|
|
36
|
+
if (!reference.startsWith(prefix) || !/^\d+$/.test(reference.slice(prefix.length))) fail('EVIDENCE_OTHER_SESSION_OR_FORMAT')
|
|
37
|
+
const value = Number(reference.slice(prefix.length))
|
|
38
|
+
if (!Number.isSafeInteger(value) || value < 0) fail('EVIDENCE_BAD_SEQ')
|
|
39
|
+
return value
|
|
40
|
+
}
|
|
41
|
+
export function artifactProof(projectRoot, ref, signal) {
|
|
42
|
+
if (!ref.sha256) fail('ARTIFACT_SHA_REQUIRED')
|
|
43
|
+
const requested = path.isAbsolute(ref.ref) ? ref.ref : path.resolve(projectRoot, ref.ref)
|
|
44
|
+
const file = realProjectPath(projectRoot, requested, { directory: false })
|
|
45
|
+
if (file.toLowerCase().endsWith('.gguf')) fail('MODEL_CONTENT_IS_NOT_TRANSACTION_ARTIFACT')
|
|
46
|
+
const fd = fs.openSync(file, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW)
|
|
47
|
+
try {
|
|
48
|
+
const before = fs.fstatSync(fd, { bigint: true })
|
|
49
|
+
if (!before.isFile() || before.size > 16n * 1024n * 1024n) fail('ARTIFACT_BYTE_LIMIT')
|
|
50
|
+
const hash = createHash('sha256'), buffer = Buffer.alloc(65536)
|
|
51
|
+
let total = 0n
|
|
52
|
+
while (true) {
|
|
53
|
+
signal?.throwIfAborted()
|
|
54
|
+
const bytes = fs.readSync(fd, buffer, 0, buffer.length, null)
|
|
55
|
+
if (!bytes) break
|
|
56
|
+
total += BigInt(bytes); if (total > before.size) fail('ARTIFACT_GREW_DURING_READ')
|
|
57
|
+
hash.update(buffer.subarray(0, bytes))
|
|
58
|
+
}
|
|
59
|
+
const after = fs.fstatSync(fd, { bigint: true }), outside = fs.lstatSync(file, { bigint: true })
|
|
60
|
+
const keys = ['dev', 'ino', 'size', 'mtimeNs', 'ctimeNs']
|
|
61
|
+
if (total !== before.size || keys.some(key => before[key] !== after[key] || before[key] !== outside[key])
|
|
62
|
+
|| fs.realpathSync(file) !== file || hash.digest('hex') !== ref.sha256) fail('ARTIFACT_CHANGED_OR_HASH_MISMATCH')
|
|
63
|
+
return { kind: 'artifact', refDigest: digest(ref), path: file, sha256: ref.sha256,
|
|
64
|
+
stat: Object.fromEntries(keys.map(key => [key, String(before[key])])) }
|
|
65
|
+
} finally { fs.closeSync(fd) }
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function resolveEvidence({ references, state, binding, activeProof, outcome, signal }) {
|
|
69
|
+
const refs = evidenceRefs(references), result = []
|
|
70
|
+
let successfulAfterBegin = 0
|
|
71
|
+
for (const ref of refs) {
|
|
72
|
+
if (ref.kind === 'artifact') { result.push(artifactProof(binding.projectRoot, ref, signal)); continue }
|
|
73
|
+
const seq = referenceSeq(ref.ref, binding.sessionId)
|
|
74
|
+
const proof = state.proofs.find(row => row.seq === seq)
|
|
75
|
+
if (seq < state.ownFrom || !proof || proof.rootResultSeq >= state.lastSeq + 1) fail('EVIDENCE_NOT_OWN_COMMITTED_TOOL_RESULT')
|
|
76
|
+
if (ref.sha256 && ref.sha256 !== proof.eventDigest) fail('EVIDENCE_EVENT_DIGEST_MISMATCH')
|
|
77
|
+
if (activeProof && (seq <= activeProof.beginResultSeq || proof.rootCallSeq <= activeProof.beginResultSeq || proof.rootResultSeq <= activeProof.beginResultSeq))
|
|
78
|
+
fail('ACTIVE_EVIDENCE_MUST_FOLLOW_BEGIN_COMMIT')
|
|
79
|
+
if (outcome === 'complete') {
|
|
80
|
+
if (!activeProof) fail('ACTIVE_BEGIN_PROOF_REQUIRED')
|
|
81
|
+
if (!proof.successful || proof.qualification !== 'SUCCESS' || proof.ownOutcome !== 'UNVERIFIED') fail('FAILED_UNKNOWN_OR_NOT_RUN_CANNOT_COMPLETE')
|
|
82
|
+
successfulAfterBegin++
|
|
83
|
+
}
|
|
84
|
+
result.push({ kind: ref.kind, refDigest: digest(ref), ...copy(proof) })
|
|
85
|
+
}
|
|
86
|
+
if (outcome === 'complete' && successfulAfterBegin < 1) fail('COMPLETE_REQUIRES_ACTUAL_SUCCESSFUL_TOOL_EVIDENCE')
|
|
87
|
+
return result
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export class DshTransactionHost {
|
|
91
|
+
constructor(ctx, { projectRoot, storageRelative = '.dsh/transaction-graphs-v4', enableSwitch = false,
|
|
92
|
+
appendPluginRecord, journalFactory = options => new HostJournal(options), owner = `owner_${randomUUID()}` }) {
|
|
93
|
+
if (typeof appendPluginRecord !== 'function') fail('SESSION_WRITER_REQUIRED')
|
|
94
|
+
if (projectRoot !== undefined && (typeof projectRoot !== 'string' || !path.isAbsolute(projectRoot)
|
|
95
|
+
|| fs.realpathSync(projectRoot) !== path.resolve(projectRoot) || !fs.lstatSync(projectRoot).isDirectory()))
|
|
96
|
+
fail('EXPLICIT_REAL_PROJECT_REQUIRED')
|
|
97
|
+
if (!ctx.sessionPersistence || typeof ctx.sessionPersistence.open !== 'function') fail('REAL_SESSION_PERSISTENCE_REQUIRED')
|
|
98
|
+
if (typeof storageRelative !== 'string' || path.isAbsolute(storageRelative) || !storageRelative || storageRelative.split(/[\\/]/).some(x => !x || x === '..' || x === '.')) fail('INVALID_STORAGE_RELATIVE_PATH')
|
|
99
|
+
if (typeof enableSwitch !== 'boolean') fail('INVALID_SWITCH_POLICY')
|
|
100
|
+
this.ctx = ctx; this.projectRoot = projectRoot === undefined ? undefined : path.resolve(projectRoot)
|
|
101
|
+
this.storageRelative = storageRelative; this.enableSwitch = enableSwitch
|
|
102
|
+
this.append = appendPluginRecord; this.journalFactory = journalFactory; this.owner = owner
|
|
103
|
+
this.journals = new Map(); this.serial = new WeakMap(); this.tasks = new Set(); this.definitions = new Map(); this.closed = false
|
|
104
|
+
this.recoveryVerifier = null
|
|
105
|
+
this.running = new WeakMap()
|
|
106
|
+
this.lifecycle = new AbortController()
|
|
107
|
+
}
|
|
108
|
+
projection(session) {
|
|
109
|
+
const state = this.ctx.sessionProjections.stateOf(session, KEY)
|
|
110
|
+
if (!state || state.failure) fail('TRANSACTION_PROJECTION_UNAVAILABLE', state?.failure ?? 'Required projection missing')
|
|
111
|
+
return state
|
|
112
|
+
}
|
|
113
|
+
binding(session) {
|
|
114
|
+
if (this.ctx.sessions.get(session.id) !== session || session.header.id !== session.id || !Number.isSafeInteger(session.header.createdAt)) fail('SESSION_NOT_EXACT_LIVE_OWNER')
|
|
115
|
+
const original = realProjectPath(this.projectRoot ?? session.header.cwd, session.header.cwd)
|
|
116
|
+
const projectRoot = this.projectRoot ?? original
|
|
117
|
+
realProjectPath(projectRoot, this.ctx.workingDirectory.get(session))
|
|
118
|
+
return { contract: 'DSH_TRANSACTION_GRAPH_HOST_V4', projectRoot, sessionId: session.id,
|
|
119
|
+
sessionCreatedAt: session.header.createdAt, originalCwd: original, enableSwitch: this.enableSwitch }
|
|
120
|
+
}
|
|
121
|
+
journal(session, { readOnly = false } = {}) {
|
|
122
|
+
const binding = this.binding(session), key = digest(binding)
|
|
123
|
+
const cached = this.journals.get(key)
|
|
124
|
+
const journal = cached ?? this.journalFactory({ projectRoot: binding.projectRoot,
|
|
125
|
+
directory: path.join(binding.projectRoot, this.storageRelative, key), binding, readOnly })
|
|
126
|
+
if (!cached && !readOnly) this.journals.set(key, journal)
|
|
127
|
+
const saved = journal.read(), projected = this.projection(session)
|
|
128
|
+
if (saved.revision === 0 && (projected.intents.length || projected.receipts.length)) fail('HOST_JOURNAL_MISSING_FOR_RECORDED_SESSION')
|
|
129
|
+
for (const row of saved.claims) {
|
|
130
|
+
const intent = projected.intents.find(item => item.data.operationId === row.operationId)
|
|
131
|
+
if (!intent || intent.intentSeq !== row.intentSeq || intent.rootResultSeq !== row.resultSeq
|
|
132
|
+
|| intent.data.claimDigest !== row.claimDigest || intent.data.candidateChecksum !== row.candidateChecksum
|
|
133
|
+
|| intent.data.expectedGraphRevision !== row.expectedGraphRevision || intent.data.operation !== row.operation
|
|
134
|
+
|| row.status === 'COMMITTED' && intent.status !== 'SUCCESS') fail('JOURNAL_NOT_ANCHORED_TO_ACTUAL_SESSION_INTENT')
|
|
135
|
+
const receipt = projected.receipts.find(item => item.operationId === row.operationId)
|
|
136
|
+
if (receipt && ['status', 'graphRevision', 'graphChecksum'].some(field => receipt[field] !== row[field])) fail('JOURNAL_SESSION_RECEIPT_CONFLICT')
|
|
137
|
+
}
|
|
138
|
+
const latestCommit = saved.claims.findLast(row => row.status === 'COMMITTED')
|
|
139
|
+
if ((saved.document !== null) !== Boolean(latestCommit) || latestCommit &&
|
|
140
|
+
(latestCommit.graphRevision !== saved.document.revision || latestCommit.graphChecksum !== saved.document.checksum
|
|
141
|
+
|| latestCommit.candidateChecksum !== saved.document.checksum)) fail('GRAPH_NOT_ANCHORED_TO_COMMITTED_CANDIDATE')
|
|
142
|
+
if (saved.activeProof) {
|
|
143
|
+
const begin = projected.intents.find(row => row.data.operationId === latestCommit?.operationId)
|
|
144
|
+
const proof = saved.activeProof
|
|
145
|
+
if (begin?.data.operation !== 'begin' || begin.rootResultSeq !== proof.beginResultSeq || begin.data.turn !== proof.beginTurn
|
|
146
|
+
|| begin.workStartedAtRoot !== proof.workStartedAtBegin || begin.workExecutedAtRoot !== proof.workExecutedAtBegin)
|
|
147
|
+
fail('ACTIVE_BEGIN_WORK_BASELINE_MISMATCH')
|
|
148
|
+
}
|
|
149
|
+
return { journal, binding, key, saved }
|
|
150
|
+
}
|
|
151
|
+
inProject(session) {
|
|
152
|
+
if (this.closed || !session) return false
|
|
153
|
+
try { this.binding(session); return true } catch { return false }
|
|
154
|
+
}
|
|
155
|
+
snapshot(session) {
|
|
156
|
+
const base = { sessionId: session.id, graph: null }
|
|
157
|
+
try {
|
|
158
|
+
if (this.closed) fail('HOST_DISPOSED')
|
|
159
|
+
const binding = this.binding(session), key = digest(binding), projected = this.projection(session)
|
|
160
|
+
const file = path.join(binding.projectRoot, this.storageRelative, key, 'transaction-host-journal.json')
|
|
161
|
+
if (!fs.existsSync(file)) {
|
|
162
|
+
if (projected.intents.length || projected.receipts.length) fail('HOST_JOURNAL_MISSING_FOR_RECORDED_SESSION')
|
|
163
|
+
return { ...base, status: 'empty' }
|
|
164
|
+
}
|
|
165
|
+
const { saved } = this.journal(session, { readOnly: true })
|
|
166
|
+
if (!saved.document) return { ...base, status: 'empty' }
|
|
167
|
+
const display = view(saved.document, this.owner), plan = saved.document.state.plan
|
|
168
|
+
const parents = new Map(plan.nodes.map(node => [node.id, node.parent]))
|
|
169
|
+
const blocked = new Set(display.pendingButBlocked.filter(node => node.unmetPrerequisites.length).map(node => node.id))
|
|
170
|
+
const pending = saved.pending ? { operation: saved.pending.operation,
|
|
171
|
+
qualification: projected.intents.find(row => row.data.operationId === saved.pending.operationId)?.status ?? 'UNKNOWN' } : null
|
|
172
|
+
const choices = rows => pending ? [] : rows.map(({ id, choice }) => ({ id, choice }))
|
|
173
|
+
return { ...base, status: 'ready', graph: { revision: display.revision, title: display.graph.title,
|
|
174
|
+
nodes: display.nodes.map(({ id, label, kind, status, disposition, branchState, selectedBranch }) => ({
|
|
175
|
+
id, label, kind, status, parent: parents.get(id), ...(disposition === undefined ? {} : { disposition }),
|
|
176
|
+
...(blocked.has(id) ? { blocked: true } : {}),
|
|
177
|
+
...(branchState ? { branchState } : {}), ...(selectedBranch ? { selectedBranch } : {}),
|
|
178
|
+
})),
|
|
179
|
+
edges: plan.edges.map(({ from, to, kind, outcome, case: selectedCase }) => ({ from, to, kind,
|
|
180
|
+
...(outcome === undefined ? {} : { outcome }), ...(selectedCase === undefined ? {} : { case: selectedCase }) })),
|
|
181
|
+
active: display.active ? { nodeId: display.active.nodeId, recoveryRequired: display.active.recoveryRequired } : null,
|
|
182
|
+
pending, legalNext: choices(display.legalNext), skipChoices: choices(display.skipChoices) } }
|
|
183
|
+
} catch { return { ...base, status: 'unavailable' } }
|
|
184
|
+
}
|
|
185
|
+
assertOwned(authority, exec) {
|
|
186
|
+
const agent = exec.agent
|
|
187
|
+
exec.signal?.throwIfAborted()
|
|
188
|
+
if (this.closed || !agent || agent !== authority.agent || agent.session !== authority.session || agent.status !== 'running'
|
|
189
|
+
|| this.ctx.agents.get(agent.id) !== agent || this.ctx.agents.currentInitiator() !== agent
|
|
190
|
+
|| this.ctx.sessions.get(agent.session.id) !== agent.session || digest(this.binding(agent.session)) !== authority.sessionScope)
|
|
191
|
+
fail('EXECUTION_OWNER_CHANGED')
|
|
192
|
+
const definition = this.definitions.get(exec.name)
|
|
193
|
+
if (!definition || this.ctx.tools.get(exec.name, agent) !== definition) fail('EXACT_REGISTERED_TRANSACTION_TOOL_REQUIRED')
|
|
194
|
+
if (!this.projection(agent.session).turn) fail('OPEN_MODEL_TURN_REQUIRED')
|
|
195
|
+
}
|
|
196
|
+
async authorize(exec) {
|
|
197
|
+
if (!exec.agent || this.ctx.agents.get(exec.agent.id) !== exec.agent || this.ctx.agents.currentInitiator() !== exec.agent) fail('EXACT_CALLING_AGENT_REQUIRED')
|
|
198
|
+
await this.ctx.workingDirectory.ensure(exec.agent, exec.signal)
|
|
199
|
+
const { binding, key } = this.journal(exec.agent.session)
|
|
200
|
+
const authority = { agent: exec.agent, session: exec.agent.session, agentId: exec.agent.id,
|
|
201
|
+
sessionScope: key, currentInitiatorVerified: true, binding }
|
|
202
|
+
this.assertOwned(authority, exec)
|
|
203
|
+
return authority
|
|
204
|
+
}
|
|
205
|
+
schedule(session) {
|
|
206
|
+
const previous = this.serial.get(session) ?? Promise.resolve()
|
|
207
|
+
const task = previous.catch(() => {}).then(() => this.drain(session))
|
|
208
|
+
this.serial.set(session, task)
|
|
209
|
+
this.track(task)
|
|
210
|
+
// Session publication contains listener exceptions; retain failure as pending
|
|
211
|
+
// rather than claiming a successful commit. A subsequent read retries safely.
|
|
212
|
+
task.catch(error => this.ctx.logger?.warn(`transaction graph pending reconciliation: ${error.message}`))
|
|
213
|
+
return task
|
|
214
|
+
}
|
|
215
|
+
track(task) {
|
|
216
|
+
this.tasks.add(task); task.then(() => this.tasks.delete(task), () => this.tasks.delete(task))
|
|
217
|
+
return task
|
|
218
|
+
}
|
|
219
|
+
finishChain(exec, state) {
|
|
220
|
+
const chain = ownedExecutionChain(exec, this.running.get(exec.agent) ?? [])
|
|
221
|
+
const root = [...this.running.get(exec.agent) ?? []].find(row => row.callId === exec.rootCallId && chain.ancestors.has(row.token))
|
|
222
|
+
if (!root || state.calls[root.callId]?.tool !== root.name) fail('OWNED_ROOT_DOES_NOT_MATCH_SESSION_CALL')
|
|
223
|
+
if (chain.others.length) fail('FINISH_OTHER_OWN_TOOLS_BEFORE_TRANSACTION_FINISH')
|
|
224
|
+
return chain
|
|
225
|
+
}
|
|
226
|
+
workState(session, saved, state = this.projection(session)) {
|
|
227
|
+
const agent = this.ctx.agents.get(session.id), rows = [...this.running.get(agent) ?? []]
|
|
228
|
+
const ignored = new Set(), callIds = new Set()
|
|
229
|
+
const protocol = rows.filter(item => administrative(item.name)
|
|
230
|
+
&& this.definitions.has(item.name) && this.ctx.tools.get(item.name, item.agent) === this.definitions.get(item.name))
|
|
231
|
+
// The one read/mutation waiting on this drain and its real ancestors are
|
|
232
|
+
// protocol transport. Never union two sibling chains into a fake quiescence.
|
|
233
|
+
const chains = protocol.map(row => ownedExecutionChain(row, rows))
|
|
234
|
+
const maximal = chains.filter(chain => protocol.every(row => chain.ancestors.has(row.token)))
|
|
235
|
+
if (maximal.length === 1) {
|
|
236
|
+
const chain = maximal[0]
|
|
237
|
+
for (const token of chain.ancestors) ignored.add(token)
|
|
238
|
+
for (const id of chain.callIds) callIds.add(id)
|
|
239
|
+
}
|
|
240
|
+
return { unresolved: unresolvedWork(state, saved.activeProof, callIds), running: rows.filter(row => !ignored.has(row.token)),
|
|
241
|
+
workStarted: state.workStarted, workExecuted: state.workExecuted }
|
|
242
|
+
}
|
|
243
|
+
async flush(session, proofs) {
|
|
244
|
+
const backend = this.ctx.sessionPersistence, identity = backend.identity
|
|
245
|
+
if (!Array.isArray(proofs) || !proofs.length) fail('EXACT_DURABILITY_EVENT_PROOFS_REQUIRED')
|
|
246
|
+
if (await this.ctx.sessions.flush(session) !== true) fail('DURABLE_SESSION_FLUSH_LISTENER_REQUIRED')
|
|
247
|
+
if (this.ctx.sessions.get(session.id) !== session) fail('SESSION_DISPOSED_DURING_FLUSH')
|
|
248
|
+
// A no-op flush listener must not stand in for the real storage writer.
|
|
249
|
+
const handle = await backend.open(session.id, 'read')
|
|
250
|
+
try {
|
|
251
|
+
if (handle.access !== 'read' || handle.id !== session.id || !sameStoredSessionHeader(handle.header, session.header)
|
|
252
|
+
|| handle.inheritedEventCount !== session.inheritedEventCount) fail('DURABLE_SESSION_HEADER_OR_SCOPE_MISMATCH')
|
|
253
|
+
for (const proof of proofs) {
|
|
254
|
+
const result = await handle.read(proof.seq, 1)
|
|
255
|
+
if (result.events.length !== 1 || result.events[0].seq !== proof.seq || digest(result.events[0]) !== proof.digest)
|
|
256
|
+
fail('DURABLE_SESSION_EVENT_PROOF_REQUIRED')
|
|
257
|
+
}
|
|
258
|
+
if (this.ctx.sessionPersistence.identity !== identity || this.ctx.sessions.get(session.id) !== session) fail('PERSISTENCE_OWNER_CHANGED_DURING_PROOF')
|
|
259
|
+
} finally { await handle.close() }
|
|
260
|
+
}
|
|
261
|
+
async drain(session) {
|
|
262
|
+
if (this.closed) return
|
|
263
|
+
const { journal, binding } = this.journal(session)
|
|
264
|
+
let saved = journal.read(), projected = this.projection(session)
|
|
265
|
+
if (saved.pending) {
|
|
266
|
+
const p = saved.pending, record = projected.intents.find(row => row.data.operationId === p.operationId)
|
|
267
|
+
if (!record || !['SUCCESS', 'FAILED'].includes(record.status)) return saved
|
|
268
|
+
if (record.data.claimDigest !== p.claimDigest || record.data.bindingDigest !== digest(binding)) fail('PENDING_SESSION_PROOF_CHANGED')
|
|
269
|
+
// Root result + all PTC ancestors must survive the session durability barrier
|
|
270
|
+
// before the separate atomic journal can commit a candidate.
|
|
271
|
+
await this.flush(session, [{ seq: record.intentSeq, digest: record.intentDigest },
|
|
272
|
+
{ seq: record.rootResultSeq, digest: record.rootResultDigest }])
|
|
273
|
+
if (this.closed) fail('HOST_DISPOSED_NO_COMMIT')
|
|
274
|
+
projected = this.projection(session)
|
|
275
|
+
const current = projected.intents.find(row => row.data.operationId === p.operationId)
|
|
276
|
+
if (canonical(current) !== canonical(record)) fail('PENDING_RESULT_CHANGED_DURING_FLUSH')
|
|
277
|
+
if (p.intentSeq === null) saved = journal.bindIntent(p.operationId, record.intentSeq)
|
|
278
|
+
let rejectReason = null
|
|
279
|
+
if (p.operation === 'finish' && record.status === 'SUCCESS') {
|
|
280
|
+
try {
|
|
281
|
+
const work = this.workState(session, saved, projected)
|
|
282
|
+
if (work.unresolved.length || work.running.length) fail('ACTIVE_OWNED_WORK_UNRESOLVED')
|
|
283
|
+
const again = resolveEvidence({ references: p.arguments.evidence ?? [], state: projected, binding,
|
|
284
|
+
activeProof: saved.activeProof, outcome: p.arguments.outcome })
|
|
285
|
+
if (canonical(again) !== canonical(p.resolvedEvidence)) fail('FINISH_EVIDENCE_CHANGED_BEFORE_COMMIT')
|
|
286
|
+
if (p.arguments.outcome === 'incomplete') incompleteDisposition({ requested: p.arguments.disposition,
|
|
287
|
+
state: projected, activeProof: saved.activeProof, evidence: again })
|
|
288
|
+
} catch (error) {
|
|
289
|
+
// Reject this candidate while preserving the real successful root
|
|
290
|
+
// result and ACTIVE transaction; this is not transport UNKNOWN.
|
|
291
|
+
rejectReason = `Candidate evidence failed revalidation: ${error.code ?? 'ARTIFACT_OR_TOOL_REFERENCE_ERROR'}`
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
if (p.operation === 'reconcile' && record.status === 'SUCCESS') {
|
|
295
|
+
const work = this.workState(session, saved, projected)
|
|
296
|
+
if (work.unresolved.length || work.running.length) rejectReason = 'New unresolved/running owned work appeared after operator cleanup approval; ACTIVE retained.'
|
|
297
|
+
}
|
|
298
|
+
saved = journal.resolve(current, { rejectReason })
|
|
299
|
+
}
|
|
300
|
+
for (const row of saved.claims) {
|
|
301
|
+
if (this.closed) fail('HOST_DISPOSED_NO_RECEIPT_APPEND')
|
|
302
|
+
if (projected.receipts.some(receipt => receipt.operationId === row.operationId)) continue
|
|
303
|
+
const seq = this.append(session, RECEIPT, { operationId: row.operationId, status: row.status,
|
|
304
|
+
graphRevision: row.graphRevision, graphChecksum: row.graphChecksum })
|
|
305
|
+
const receipt = this.projection(session).receipts.find(value => value.seq === seq)
|
|
306
|
+
if (!receipt) fail('RECEIPT_NOT_IN_ACTUAL_PROJECTION')
|
|
307
|
+
await this.flush(session, [{ seq, digest: receipt.eventDigest }])
|
|
308
|
+
projected = this.projection(session)
|
|
309
|
+
}
|
|
310
|
+
return saved
|
|
311
|
+
}
|
|
312
|
+
async read(authority, exec) {
|
|
313
|
+
let reconciliationError = null
|
|
314
|
+
try { await this.schedule(authority.session) } catch (error) { reconciliationError = error.code ?? 'JOURNAL_RECONCILIATION_FAILED' }
|
|
315
|
+
this.assertOwned(authority, exec)
|
|
316
|
+
const { journal } = this.journal(authority.session), saved = journal.read(), state = this.projection(authority.session)
|
|
317
|
+
const result = view(saved.document, this.owner)
|
|
318
|
+
const work = this.workState(authority.session, saved, state)
|
|
319
|
+
return { ...result, ...(saved.pending ? { legalNext: [], skipChoices: [] } : {}),
|
|
320
|
+
journalReconciliationError: reconciliationError,
|
|
321
|
+
lastToolClaim: saved.claims.length ? copy(saved.claims.at(-1)) : null,
|
|
322
|
+
unresolvedOwnedWork: work.unresolved, otherRunningWorkCount: work.running.length,
|
|
323
|
+
pendingIntent: saved.pending ? { operationId: saved.pending.operationId, operation: saved.pending.operation,
|
|
324
|
+
state: reconciliationError ? 'COMMIT_BLOCKED' : 'AWAITING_ACTUAL_ROOT_RESULT_OR_JOURNAL_COMMIT',
|
|
325
|
+
rootAndParentQualification: state.intents.find(row => row.data.operationId === saved.pending.operationId)?.status ?? 'UNKNOWN',
|
|
326
|
+
qualificationMeaning: 'Execution/typed-result ancestry qualification; original root tool/result is unchanged.',
|
|
327
|
+
reconciliationError,
|
|
328
|
+
instruction: 'Do not replay this call or begin dependent work. Reconcile the actual root tool result.' } : null,
|
|
329
|
+
recentToolEvidence: state.proofs.slice(-32).map(row => ({ ref: sessionReference(authority.session.id, row.seq),
|
|
330
|
+
kind: 'tool_result', sha256: row.eventDigest, tool: row.tool, successful: row.successful,
|
|
331
|
+
ownOutcome: row.ownOutcome, isError: row.isError, qualification: row.qualification,
|
|
332
|
+
ancestorOutcomes: row.ancestorOutcomes, afterActiveBegin: Boolean(saved.activeProof && row.seq > saved.activeProof.beginResultSeq) })),
|
|
333
|
+
qualification: 'Successful owned tool execution references only; semantic acceptance and GoalReview remain independent.' }
|
|
334
|
+
}
|
|
335
|
+
stageMutation(request) { return this.track(this.stage(request)) }
|
|
336
|
+
async stage({ operation, arguments: args, operationId, authority, execution: exec }) {
|
|
337
|
+
await this.schedule(authority.session)
|
|
338
|
+
this.assertOwned(authority, exec)
|
|
339
|
+
const { journal, binding } = this.journal(authority.session), saved = journal.read(), state = this.projection(authority.session)
|
|
340
|
+
if (saved.pending) fail('PENDING_INTENT_REQUIRES_RECONCILIATION')
|
|
341
|
+
if (operation === 'finish') {
|
|
342
|
+
const chain = this.finishChain(exec, state)
|
|
343
|
+
if (unresolvedWork(state, saved.activeProof, chain.callIds).length) fail('ACTIVE_OWNED_WORK_UNRESOLVED')
|
|
344
|
+
}
|
|
345
|
+
if (saved.document?.state.active?.owner && saved.document.state.active.owner !== this.owner) fail('ACTIVE_RESTART_REQUIRES_EXPLICIT_OPERATOR_RECONCILIATION')
|
|
346
|
+
const payloadHash = digest({ operation, arguments: args })
|
|
347
|
+
if (saved.claims.some(row => row.operationId === operationId)) fail('HOST_TOOL_CALL_REPLAY')
|
|
348
|
+
const resolvedEvidence = operation === 'finish' || operation === 'skip'
|
|
349
|
+
? resolveEvidence({ references: args.evidence ?? [], state, binding, activeProof: saved.activeProof,
|
|
350
|
+
outcome: operation === 'finish' ? args.outcome : 'incomplete', signal: exec.signal }) : []
|
|
351
|
+
if (operation === 'finish' && args.outcome === 'incomplete') incompleteDisposition({ requested: args.disposition,
|
|
352
|
+
state, activeProof: saved.activeProof, evidence: resolvedEvidence })
|
|
353
|
+
if (operation === 'finish' && args.outcome === 'complete' && args.disposition !== undefined) fail('COMPLETE_HAS_NO_INCOMPLETE_DISPOSITION')
|
|
354
|
+
const temporary = new MemoryGraphStore(saved.document), core = new TransactionGraph(temporary, {
|
|
355
|
+
enableSwitch: this.enableSwitch, id: prefix => prefix === 'owner' ? this.owner : `${prefix}_${randomUUID()}` })
|
|
356
|
+
if (!['create', 'begin', 'finish', 'skip'].includes(operation)) fail('UNKNOWN_TRANSACTION_OPERATION')
|
|
357
|
+
core[operation]({ ...copy(args), operationId })
|
|
358
|
+
const candidate = temporary.read(), claim = { operationId, payloadHash, operation, arguments: copy(args),
|
|
359
|
+
callId: exec.callId, rootCallId: exec.rootCallId ?? exec.callId, turn: state.turn, sessionId: binding.sessionId,
|
|
360
|
+
owner: this.owner, bindingDigest: digest(binding), expectedGraphRevision: saved.document?.revision ?? 0,
|
|
361
|
+
candidate, resolvedEvidence, clearedWork: [], intentSeq: null }
|
|
362
|
+
claim.claimDigest = claimDigest(claim)
|
|
363
|
+
this.assertOwned(authority, exec)
|
|
364
|
+
journal.reserve(claim)
|
|
365
|
+
const intentSeq = this.append(authority.session, INTENT, { version: 4, operationId, callId: claim.callId,
|
|
366
|
+
rootCallId: claim.rootCallId, turn: claim.turn, sessionId: claim.sessionId,
|
|
367
|
+
bindingDigest: claim.bindingDigest, claimDigest: claim.claimDigest, operation,
|
|
368
|
+
expectedGraphRevision: claim.expectedGraphRevision, candidateChecksum: candidate.checksum, clearedWork: [] })
|
|
369
|
+
const projectedIntent = this.projection(authority.session).intents.find(row => row.intentSeq === intentSeq)
|
|
370
|
+
if (!projectedIntent) fail('INTENT_NOT_IN_ACTUAL_PROJECTION')
|
|
371
|
+
await this.flush(authority.session, [{ seq: intentSeq, digest: projectedIntent.intentDigest }])
|
|
372
|
+
this.assertOwned(authority, exec)
|
|
373
|
+
journal.bindIntent(operationId, intentSeq)
|
|
374
|
+
this.assertOwned(authority, exec)
|
|
375
|
+
return { state: 'PENDING_HOST_COMMIT', operationId, intentSequence: intentSeq,
|
|
376
|
+
candidateGraphRevision: candidate.revision, commit: 'Await actual successful root tool result and all PTC parents, then read.' }
|
|
377
|
+
}
|
|
378
|
+
registerRecoveryVerifier({ verify, version }) {
|
|
379
|
+
if (this.recoveryVerifier || typeof verify !== 'function' || typeof version !== 'string' || !version) fail('EXPLICIT_SINGLE_TRUSTED_RECOVERY_VERIFIER_REQUIRED')
|
|
380
|
+
const row = { verify, hash: digest({ version, source: verify.toString() }) }
|
|
381
|
+
this.recoveryVerifier = row
|
|
382
|
+
return () => { if (this.recoveryVerifier === row) this.recoveryVerifier = null }
|
|
383
|
+
}
|
|
384
|
+
context(agent) {
|
|
385
|
+
if (!agent || this.ctx.agents.get(agent.id) !== agent) return ''
|
|
386
|
+
try {
|
|
387
|
+
const binding = this.binding(agent.session), key = digest(binding)
|
|
388
|
+
const file = path.join(binding.projectRoot, this.storageRelative, key, 'transaction-host-journal.json')
|
|
389
|
+
if (!this.journals.has(key) && !fs.existsSync(file)) {
|
|
390
|
+
if (this.projection(agent.session).intents.length) fail('HOST_JOURNAL_MISSING_FOR_RECORDED_SESSION')
|
|
391
|
+
return '<transaction_graph_state>{"graph":null,"instruction":"Use transaction_graph_read before creating a graph."}</transaction_graph_state>'
|
|
392
|
+
}
|
|
393
|
+
const saved = this.journal(agent.session).journal.read(), state = view(saved.document, this.owner)
|
|
394
|
+
const work = this.workState(agent.session, saved)
|
|
395
|
+
const value = { revision: state.revision, graph: state.graph ? { id: state.graph.id, title: state.graph.title.slice(0, 120) } : null,
|
|
396
|
+
active: state.active ? { nodeId: state.active.nodeId, recoveryRequired: state.active.recoveryRequired } : null,
|
|
397
|
+
pendingIntent: saved.pending ? { operationId: saved.pending.operationId, operation: saved.pending.operation } : null,
|
|
398
|
+
legalNextCount: saved.pending ? 0 : state.legalNext.length,
|
|
399
|
+
legalNext: saved.pending ? [] : state.legalNext.slice(0, 8).map(row => ({ choice: row.choice, id: row.id, label: row.label.slice(0, 80) })),
|
|
400
|
+
lastClaim: saved.claims.length ? { status: saved.claims.at(-1).status, graphRevision: saved.claims.at(-1).graphRevision } : null,
|
|
401
|
+
unresolvedOwnedWorkCount: work.unresolved.length, otherRunningWorkCount: work.running.length,
|
|
402
|
+
goalReached: false, instruction: 'Read full program choices/evidence before mutation. Pending/ACTIVE restart requires reconciliation; do not replay.' }
|
|
403
|
+
const result = JSON.stringify(value)
|
|
404
|
+
if (Buffer.byteLength(result) > 4096) fail('CONTEXT_PROGRAM_VIEW_LIMIT')
|
|
405
|
+
return `<transaction_graph_state>\n${result}\n</transaction_graph_state>`
|
|
406
|
+
} catch (error) { return `<transaction_graph_state>${JSON.stringify({ state: 'BLOCKED', reason: error.code ?? 'TRANSACTION_GRAPH_READ_FAILED', goalReached: false })}</transaction_graph_state>` }
|
|
407
|
+
}
|
|
408
|
+
// Trusted Host API only, deliberately absent from the model's five tools.
|
|
409
|
+
// It can abandon an interrupted attempt as INCOMPLETE, never certify success.
|
|
410
|
+
recoverInterrupted(agent, request) { return this.track(this.recover(agent, request)) }
|
|
411
|
+
async recover(agent, { expectedRevision, expectedAttemptId, note }) {
|
|
412
|
+
const verifier = this.recoveryVerifier
|
|
413
|
+
if (!verifier || this.closed || this.ctx.agents.get(agent.id) !== agent || this.ctx.sessions.get(agent.session.id) !== agent.session)
|
|
414
|
+
fail('TRUSTED_OPERATOR_RECOVERY_REQUIRED')
|
|
415
|
+
const session = agent.session
|
|
416
|
+
await this.schedule(session)
|
|
417
|
+
if (this.closed) fail('HOST_DISPOSED_NO_RECOVERY')
|
|
418
|
+
const { journal, binding } = this.journal(session), before = journal.read()
|
|
419
|
+
if (before.pending || !before.document?.state.active || before.document.revision !== expectedRevision
|
|
420
|
+
|| before.document.state.active.attemptId !== expectedAttemptId)
|
|
421
|
+
fail('RECOVERY_STATE_PENDING_STALE_OR_NOT_INTERRUPTED')
|
|
422
|
+
const initialWork = this.workState(session, before)
|
|
423
|
+
if (before.document.state.active.owner === this.owner && !initialWork.unresolved.length) fail('USE_NORMAL_INCOMPLETE_FOR_KNOWN_QUIESCENT_WORK')
|
|
424
|
+
if (typeof note !== 'string' || !note.trim() || note.length > 1200) fail('RECOVERY_NOTE_REQUIRED')
|
|
425
|
+
const accepted = await verifier.verify({ agent, session, binding: copy(binding), active: copy(before.document.state.active),
|
|
426
|
+
expectedRevision, outcome: 'incomplete', disposition: 'UNSATISFIED', unresolvedOwnedWork: copy(initialWork.unresolved),
|
|
427
|
+
runningWorkCount: initialWork.running.length, note, signal: this.lifecycle.signal })
|
|
428
|
+
if (accepted !== true || this.closed || this.recoveryVerifier !== verifier || this.ctx.agents.get(agent.id) !== agent
|
|
429
|
+
|| this.ctx.sessions.get(session.id) !== session || canonical(journal.read()) !== canonical(before)) fail('RECOVERY_APPROVAL_NOT_CURRENT')
|
|
430
|
+
const approved = this.workState(session, before), initialKeys = new Set(initialWork.unresolved.map(workKey))
|
|
431
|
+
if (approved.running.length || approved.unresolved.some(row => !initialKeys.has(workKey(row)))) fail('OPERATOR_CLEANUP_DID_NOT_QUIESCE_OR_NEW_WORK_APPEARED')
|
|
432
|
+
const clearedWork = approved.unresolved.map(({ state, ...raw }) => raw)
|
|
433
|
+
const operationId = `op_${digest({ binding, attempt: expectedAttemptId, nonce: randomUUID() })}`
|
|
434
|
+
const arguments_ = { expectedRevision, outcome: 'incomplete', disposition: 'UNSATISFIED', evidence: [], note }
|
|
435
|
+
const recoveryOwner = `owner_reconcile_${randomUUID()}`
|
|
436
|
+
const temporary = new MemoryGraphStore(before.document), core = new TransactionGraph(temporary, {
|
|
437
|
+
enableSwitch: this.enableSwitch, id: prefix => prefix === 'owner' ? recoveryOwner : `${prefix}_${randomUUID()}`, verifyRecovery: async () => true })
|
|
438
|
+
await core.reconcile({ ...arguments_, operationId })
|
|
439
|
+
if (this.closed || this.recoveryVerifier !== verifier || canonical(journal.read()) !== canonical(before)) fail('RECOVERY_APPROVAL_NOT_CURRENT')
|
|
440
|
+
const currentWork = this.workState(session, before)
|
|
441
|
+
if (canonical(currentWork.unresolved) !== canonical(approved.unresolved) || currentWork.running.length
|
|
442
|
+
|| currentWork.workStarted !== approved.workStarted || currentWork.workExecuted !== approved.workExecuted) fail('RECOVERY_WORK_CHANGED_AFTER_APPROVAL')
|
|
443
|
+
const callId = `operator_${randomUUID()}`, state = this.projection(session)
|
|
444
|
+
const claim = { operationId, payloadHash: digest({ operation: 'reconcile', arguments: arguments_ }), operation: 'reconcile',
|
|
445
|
+
arguments: arguments_, callId, rootCallId: callId, turn: state.turn, sessionId: binding.sessionId, owner: this.owner,
|
|
446
|
+
bindingDigest: digest(binding), expectedGraphRevision: expectedRevision, candidate: temporary.read(),
|
|
447
|
+
resolvedEvidence: [{ kind: 'operator_attestation', verifierHash: verifier.hash, expectedAttemptId, noteDigest: digest(note), clearedWork }],
|
|
448
|
+
clearedWork, intentSeq: null }
|
|
449
|
+
claim.claimDigest = claimDigest(claim)
|
|
450
|
+
journal.reserve(claim)
|
|
451
|
+
const intentSeq = this.append(session, RECOVERY, { version: 4, operationId, callId, rootCallId: callId,
|
|
452
|
+
turn: state.turn, sessionId: binding.sessionId, bindingDigest: claim.bindingDigest, claimDigest: claim.claimDigest,
|
|
453
|
+
operation: 'reconcile', expectedGraphRevision: claim.expectedGraphRevision, candidateChecksum: claim.candidate.checksum, clearedWork })
|
|
454
|
+
const projectedIntent = this.projection(session).intents.find(row => row.intentSeq === intentSeq)
|
|
455
|
+
if (!projectedIntent) fail('RECOVERY_NOT_IN_ACTUAL_PROJECTION')
|
|
456
|
+
await this.flush(session, [{ seq: intentSeq, digest: projectedIntent.intentDigest }])
|
|
457
|
+
if (this.closed) fail('HOST_DISPOSED_NO_RECOVERY_COMMIT')
|
|
458
|
+
journal.bindIntent(operationId, intentSeq)
|
|
459
|
+
await this.schedule(session)
|
|
460
|
+
const after = journal.read()
|
|
461
|
+
if (after.pending) fail('OPERATOR_RECOVERY_PENDING_NO_REPLAY')
|
|
462
|
+
if (after.claims.at(-1)?.operationId !== operationId || after.claims.at(-1).status !== 'COMMITTED') fail('OPERATOR_RECOVERY_NOT_COMMITTED_ACTIVE_RETAINED')
|
|
463
|
+
return view(after.document, this.owner)
|
|
464
|
+
}
|
|
465
|
+
async close() {
|
|
466
|
+
this.closed = true
|
|
467
|
+
this.lifecycle.abort(new Error('Transaction graph plugin disposed'))
|
|
468
|
+
// No ACTIVE transaction, pending intent, old lock or user process is cleared.
|
|
469
|
+
// Teardown never replays begin/finish or turns UNKNOWN into an abort/success.
|
|
470
|
+
await Promise.allSettled([...this.tasks])
|
|
471
|
+
}
|
|
472
|
+
}
|
package/index.mjs
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// SPDX-License-Identifier: GPL-3.0-only
|
|
2
|
+
import { z } from 'zod'
|
|
3
|
+
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
4
|
+
import { appendPluginRecord } from '@deepseek-ai/dsh-session'
|
|
5
|
+
import { Remote, RemoteError, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol'
|
|
6
|
+
import { createToolDefinitions } from './tools.mjs'
|
|
7
|
+
import { DshTransactionHost } from './host.mjs'
|
|
8
|
+
import { projection, KEY } from './projection.mjs'
|
|
9
|
+
import { createRemoteContribution } from './remote.mjs'
|
|
10
|
+
|
|
11
|
+
export const name = 'dsh-bettertaskgraphs'
|
|
12
|
+
let bindSessionResource
|
|
13
|
+
Remote({ resources: { session: ({ sessionId }) => sessionId } })(undefined, {
|
|
14
|
+
kind: 'method', name: 'status', addInitializer: callback => { bindSessionResource = callback },
|
|
15
|
+
})
|
|
16
|
+
const guidance = 'For work the user asks to track in a transaction graph, read the program-generated revision and choices before creating or resuming it. One leaf may be active. Begin must commit before work starts. Pending or UNKNOWN work blocks finish and dependent work. Never replay blindly. Complete requires a qualified successful tool result after begin. Incomplete requires known failure, confirmed non-execution, or an unsatisfied outcome. Wait for the enclosing tool call to finish and read the committed state before continuing. Containers aggregate. Transaction completion does not complete Goal.'
|
|
17
|
+
|
|
18
|
+
export default class TransactionGraphPlugin extends TypertRemoteService {
|
|
19
|
+
static inject = ['agents', 'sessions', 'sessionProjections', 'sessionPersistence', 'workingDirectory', 'tools', 'systemPrompt']
|
|
20
|
+
constructor(ctx, config = {}) {
|
|
21
|
+
super(ctx, 'transactionGraph', { namespace: 'transactionGraphView' })
|
|
22
|
+
bindSessionResource.call(this)
|
|
23
|
+
if (!config || typeof config !== 'object' || Array.isArray(config)
|
|
24
|
+
|| Object.keys(config).some(key => !['projectRoot', 'storageRelative', 'enableSwitch'].includes(key)))
|
|
25
|
+
throw new Error('Invalid transaction graph configuration')
|
|
26
|
+
this.host = new DshTransactionHost(ctx, { ...config, appendPluginRecord })
|
|
27
|
+
ctx.sessionProjections.register(projection)
|
|
28
|
+
for (const definition of createToolDefinitions({ defineTool, hostPort: this.host })) {
|
|
29
|
+
this.host.definitions.set(definition.name, definition)
|
|
30
|
+
ctx.tools.register(definition)
|
|
31
|
+
}
|
|
32
|
+
ctx.systemPrompt.section({ name: 'project:transaction-graph', order: 46,
|
|
33
|
+
text: ({ agent }) => this.host.inProject(agent?.session) ? guidance : '' })
|
|
34
|
+
ctx.systemPrompt.context({ name: 'project:transaction-graph-state', order: 46, required: true, interpolate: false,
|
|
35
|
+
text: ({ agent }) => this.host.inProject(agent?.session) ? this.host.context(agent) : '' })
|
|
36
|
+
ctx.inject(['typert', 'sessionController'], remoteCtx => {
|
|
37
|
+
const typert = remoteCtx.typert
|
|
38
|
+
if (typert.resources.get('session')?.field !== 'sessionId') throw new Error('Transaction graph requires the native Session RPC resource')
|
|
39
|
+
const contribution = createRemoteContribution(z)
|
|
40
|
+
remoteCtx.effect(() => typert.register({ package: contribution.package, face: 'host', schemas: [],
|
|
41
|
+
model: { services: [], events: [], objects: [] }, invocations: contribution.descriptors }))
|
|
42
|
+
})
|
|
43
|
+
ctx.on('tools/execute', async (exec, next) => {
|
|
44
|
+
if (!exec.agent) return next()
|
|
45
|
+
const running = this.host.running.get(exec.agent) ?? new Set()
|
|
46
|
+
running.add(exec); this.host.running.set(exec.agent, running)
|
|
47
|
+
try { return await next() } finally { running.delete(exec) }
|
|
48
|
+
})
|
|
49
|
+
ctx.on('session/event', (session, event) => {
|
|
50
|
+
if ((event.type === 'tool/result' || event.type === 'turn/end')
|
|
51
|
+
&& ctx.sessionProjections.stateOf(session, KEY)?.intents.length) {
|
|
52
|
+
// Appending during publication is forbidden by Session; defer the
|
|
53
|
+
// durability barrier and journal CAS until publication has unwound.
|
|
54
|
+
queueMicrotask(() => { if (!this.host.closed) this.host.schedule(session) })
|
|
55
|
+
}
|
|
56
|
+
})
|
|
57
|
+
ctx.effect(() => () => this.host.close())
|
|
58
|
+
}
|
|
59
|
+
async status({ sessionId }, signal) {
|
|
60
|
+
signal?.throwIfAborted()
|
|
61
|
+
const controller = this.ctx.get('sessionController')
|
|
62
|
+
if (!controller) throw new RemoteError('session/not-found', 'Session controller unavailable', { sessionId })
|
|
63
|
+
const result = await controller.resolveAgent(sessionId)
|
|
64
|
+
if (result.error) throw result.error
|
|
65
|
+
signal?.throwIfAborted()
|
|
66
|
+
return this.host.snapshot(result.agent.session)
|
|
67
|
+
}
|
|
68
|
+
registerRecoveryVerifier(adapter) { return this.host.registerRecoveryVerifier(adapter) }
|
|
69
|
+
recoverInterrupted(agent, request) { return this.host.recoverInterrupted(agent, request) }
|
|
70
|
+
}
|