dsh-bettertaskgraphs 0.0.0-stage → 0.2.3

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/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
+ }