sintbot 1.0.15 → 1.3.0
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/CHANGELOG.en.json +118 -0
- package/CHANGELOG.json +118 -0
- package/README.md +20 -156
- package/README.zh-CN.md +20 -164
- package/THIRD-PARTY-NOTICES.md +394 -0
- package/bin/sintbot-v2.js +363 -0
- package/docs/AI-ASSISTED-INSTALL.md +19 -0
- package/docs/install.md +93 -0
- package/docs/zh-CN/AI-ASSISTED-INSTALL.md +19 -0
- package/docs/zh-CN/install.md +93 -0
- package/install.sh +84 -0
- package/package.json +74 -34
- package/src/agent/claudecode.js +149 -108
- package/src/agent/codexapp.js +458 -0
- package/src/agent/codexcode.js +210 -107
- package/src/agent/errtag.js +34 -0
- package/src/agent/privatecore.js +790 -0
- package/src/agent/privatecore.wasm +0 -0
- package/src/agent/runner.js +10 -8
- package/src/agent/winexec.js +35 -5
- package/src/c2install.js +117 -0
- package/src/config.js +2 -1
- package/src/core/c2cardeffectbridge.js +51 -0
- package/src/core/c2cardterminalpath.js +216 -0
- package/src/core/c2durableingress.js +148 -0
- package/src/core/c2durablejournal.js +1560 -0
- package/src/core/c2durableruntime.js +2361 -0
- package/src/core/c2eventidentity.js +90 -0
- package/src/core/c2globalfault.js +401 -0
- package/src/core/c2localeffectexecutor.js +193 -0
- package/src/core/c2payloadhandles.js +333 -0
- package/src/core/c2persistrelation.js +179 -0
- package/src/core/c2persiststate.js +342 -0
- package/src/core/c2platformrecoverysender.js +76 -0
- package/src/core/c2recoverygate.js +385 -0
- package/src/core/c2runnerinput.js +63 -0
- package/src/core/c2servicestartgate.js +103 -0
- package/src/core/c2sessionbridge.js +72 -0
- package/src/core/c2sourcequarantine.js +30 -0
- package/src/core/c2startuprecovery.js +105 -0
- package/src/core/c2successorbridge.js +151 -0
- package/src/core/c2terminaleffectbridge.js +154 -0
- package/src/core/c2terminalprune.js +108 -0
- package/src/core/c2turnprojection.js +177 -0
- package/src/core/enginecheckpoint.js +211 -0
- package/src/core/engineeffectcursor.js +394 -0
- package/src/core/enginestepcoordinator.js +183 -0
- package/src/core/store.js +52 -8
- package/src/core/v2engine.js +1469 -0
- package/src/platform/feishu.js +324 -67
- package/src/plugins.js +1 -9
- package/assets/design-styles/acid-pop.md +0 -155
- package/assets/design-styles/archive-index.md +0 -161
- package/assets/design-styles/art-deco.md +0 -164
- package/assets/design-styles/cinematic-noir.md +0 -178
- package/assets/design-styles/editorial.md +0 -121
- package/assets/design-styles/extra/README.md +0 -23
- package/assets/design-styles/extra/constructivism.md +0 -146
- package/assets/design-styles/extra/neo-brutalism.md +0 -175
- package/assets/design-styles/extra/new-wave.md +0 -166
- package/assets/design-styles/extra/nordic-material.md +0 -162
- package/assets/design-styles/extra/paris-couture.md +0 -162
- package/assets/design-styles/extra/tropical-modern.md +0 -168
- package/assets/design-styles/extra/ulm-functional.md +0 -182
- package/assets/design-styles/extra/wa-shikimen.md +0 -160
- package/assets/design-styles/extra/y2k-chrome.md +0 -172
- package/assets/design-styles/geometric-pop.md +0 -122
- package/assets/design-styles/hand-drawn.md +0 -117
- package/assets/design-styles/islamic-geometry.md +0 -155
- package/assets/design-styles/liquid-glass.md +0 -183
- package/assets/design-styles/neo-chinese.md +0 -136
- package/assets/design-styles/precision-mono.md +0 -156
- package/assets/design-styles/quiet-luxury.md +0 -163
- package/assets/design-styles/riso-print.md +0 -116
- package/assets/design-styles/soft-friendly.md +0 -174
- package/assets/design-styles/swiss-grid.md +0 -156
- package/assets/design-styles/tech-dark.md +0 -182
- package/assets/favicon.svg +0 -6
- package/assets/logo.svg +0 -24
- package/bin/sbot.js +0 -4711
- package/docs/slack-app-manifest.yaml +0 -104
- package/sbot.config.example.json +0 -235
- package/src/agent/askonce.js +0 -76
- package/src/agent/claudemodels.js +0 -60
- package/src/agent/claudestatus.js +0 -74
- package/src/agent/claudeusage.js +0 -200
- package/src/agent/codexstatus.js +0 -181
- package/src/agent/imagen.js +0 -458
- package/src/agent/quotaerr.js +0 -44
- package/src/agent/transienterr.js +0 -32
- package/src/cdp.js +0 -254
- package/src/clilang.js +0 -31
- package/src/core/cron.js +0 -182
- package/src/core/cronfire.js +0 -120
- package/src/core/cronrun.js +0 -116
- package/src/core/engine.js +0 -2762
- package/src/core/interfaces.js +0 -114
- package/src/core/issues.js +0 -185
- package/src/core/ledger.js +0 -30
- package/src/core/opsalert.js +0 -214
- package/src/core/pending.js +0 -123
- package/src/core/proxy.js +0 -424
- package/src/core/quotaalert.js +0 -79
- package/src/core/quotaerr.js +0 -55
- package/src/core/scheduler.js +0 -59
- package/src/design.js +0 -5161
- package/src/gitrepo.js +0 -150
- package/src/health.js +0 -353
- package/src/installdeps.js +0 -72
- package/src/leftover.js +0 -174
- package/src/platform/boardnotice.js +0 -77
- package/src/platform/boardops.js +0 -36
- package/src/platform/boardwatch.js +0 -447
- package/src/platform/cli.js +0 -45
- package/src/platform/slack.js +0 -1289
- package/src/platform/takeovercard.js +0 -37
- package/src/platform/wecom.js +0 -938
- package/src/platform/wecomcli.js +0 -128
- package/src/platform/wecomgate.js +0 -108
- package/src/platform/wecomname.js +0 -43
- package/src/platform/wecompoll.js +0 -241
- package/src/preview.js +0 -581
- package/src/projectmemory.js +0 -175
- package/src/service.js +0 -317
- package/src/ui/auth.js +0 -31
- package/src/ui/browser.html +0 -392
- package/src/ui/browserroutes.js +0 -234
- package/src/ui/console.html +0 -5494
- package/src/ui/dashboard.js +0 -168
- package/src/ui/feishulogin.js +0 -136
- package/src/ui/server.js +0 -2314
- package/src/ui/sessions.js +0 -78
- package/src/ui/takeover.js +0 -108
- package/src/updater.js +0 -243
- package/src/upgrade.js +0 -35
- package/src/usage.js +0 -144
|
@@ -0,0 +1,2361 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const { createHash } = require('node:crypto')
|
|
4
|
+
const { isDeepStrictEqual } = require('node:util')
|
|
5
|
+
const { C2DurableIngress } = require('./c2durableingress')
|
|
6
|
+
const { C2DurableJournal } = require('./c2durablejournal')
|
|
7
|
+
const { platformMessageEventId, callbackRegistrationId, callbackEventId } = require('./c2eventidentity')
|
|
8
|
+
const { C2PayloadHandles } = require('./c2payloadhandles')
|
|
9
|
+
const { EngineStepCoordinator } = require('./enginestepcoordinator')
|
|
10
|
+
const { engineTerminalStep } = require('../agent/privatecore')
|
|
11
|
+
const { recoverAndOpen } = require('./c2recoverygate')
|
|
12
|
+
const { pruneConfirmedTerminal } = require('./c2terminalprune')
|
|
13
|
+
const { prepareClosedSessionEvent } = require('./c2sessionbridge')
|
|
14
|
+
|
|
15
|
+
function positiveLimit(value, label) {
|
|
16
|
+
if (!Number.isSafeInteger(value) || value <= 0) throw new TypeError(`C2 ${label} required`)
|
|
17
|
+
return value
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const RECEIPT_ID = /^[0-9a-f]{32}$/
|
|
21
|
+
const GENERATION = /^[0-9a-f]{32}$/
|
|
22
|
+
const CARD_OPERATION_KINDS = new Set(['card.create', 'message.reply',
|
|
23
|
+
'message.create', 'cardElement.content', 'card.update', 'card.settings',
|
|
24
|
+
'message.delete'])
|
|
25
|
+
|
|
26
|
+
function cardOperationIdentity({ receiptId, turnToken, generation, operation }) {
|
|
27
|
+
if (!operation || typeof operation !== 'object' || Array.isArray(operation) ||
|
|
28
|
+
!CARD_OPERATION_KINDS.has(operation.kind) ||
|
|
29
|
+
typeof operation.operationId !== 'string' || !operation.operationId ||
|
|
30
|
+
Buffer.byteLength(operation.operationId) > 512 ||
|
|
31
|
+
typeof operation.invoke !== 'function' ||
|
|
32
|
+
(operation.kind !== 'card.create' &&
|
|
33
|
+
(typeof operation.cardId !== 'string' || !operation.cardId)) ||
|
|
34
|
+
(['message.reply', 'message.create', 'message.delete', 'card.create']
|
|
35
|
+
.includes(operation.kind) &&
|
|
36
|
+
(typeof operation.chatId !== 'string' || !operation.chatId)) ||
|
|
37
|
+
(operation.kind === 'message.reply' &&
|
|
38
|
+
(typeof operation.replyToMsgId !== 'string' || !operation.replyToMsgId)) ||
|
|
39
|
+
(operation.kind === 'message.delete' &&
|
|
40
|
+
(typeof operation.msgId !== 'string' || !operation.msgId))) {
|
|
41
|
+
throw new TypeError('C2 card operation identity invalid')
|
|
42
|
+
}
|
|
43
|
+
// The Feishu adapter alone supplies a one-call invoke. Hash the stable
|
|
44
|
+
// operation identity with this turn generation; no card/body/target enters
|
|
45
|
+
// the Rust event or durable journal.
|
|
46
|
+
const parts = [receiptId, turnToken, generation, operation.kind,
|
|
47
|
+
operation.operationId]
|
|
48
|
+
const hash = createHash('sha256').update('sbot-c2-card-operation-v1\0')
|
|
49
|
+
for (const part of parts) {
|
|
50
|
+
const bytes = Buffer.from(String(part), 'utf8')
|
|
51
|
+
hash.update(String(bytes.length)).update(':').update(bytes)
|
|
52
|
+
}
|
|
53
|
+
const operationId = hash.digest('hex')
|
|
54
|
+
return { operationId,
|
|
55
|
+
eventId: `sbot-c2-card-v1:${receiptId}:${turnToken}:${operationId}` }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function cardTargetDigest({ chatId, cardId, msgId }) {
|
|
59
|
+
if (![chatId, cardId, msgId].every(value =>
|
|
60
|
+
typeof value === 'string' && value.length > 0)) {
|
|
61
|
+
throw new TypeError('C2 card cleanup target invalid')
|
|
62
|
+
}
|
|
63
|
+
const hash = createHash('sha256').update('sbot-c2-card-target-v1\0')
|
|
64
|
+
for (const value of [chatId, cardId, msgId]) {
|
|
65
|
+
const bytes = Buffer.from(value, 'utf8')
|
|
66
|
+
hash.update(String(bytes.length)).update(':').update(bytes)
|
|
67
|
+
}
|
|
68
|
+
return hash.digest('hex')
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function queuedPayloads(snapshot, scope, handles) {
|
|
72
|
+
if (!(handles instanceof C2PayloadHandles) ||
|
|
73
|
+
handles.blocked || !Array.isArray(snapshot.state.lanes.queue?.[scope]?.queue)) {
|
|
74
|
+
throw new Error('C2 atomic release needs verified live queue handles')
|
|
75
|
+
}
|
|
76
|
+
const pending = new Map(snapshot.receipts.pending.map(item => [item.id, item]))
|
|
77
|
+
const queue = snapshot.state.lanes.queue[scope].queue
|
|
78
|
+
const accepted = []
|
|
79
|
+
const seen = new Set()
|
|
80
|
+
const add = id => { if (!seen.has(id)) { seen.add(id); accepted.push(id) } }
|
|
81
|
+
for (const item of queue) {
|
|
82
|
+
const id = item.id?.slice('receipt:job:'.length)
|
|
83
|
+
if (!RECEIPT_ID.test(id) || item.id !== `receipt:job:${id}`) {
|
|
84
|
+
throw new Error('C2 atomic release has unstable queue ID')
|
|
85
|
+
}
|
|
86
|
+
const receipt = pending.get(id)
|
|
87
|
+
const job = handles.requireQueued(id)
|
|
88
|
+
if (!receipt || receipt.chatId !== scope || job?.msg?._c2ReceiptId !== id ||
|
|
89
|
+
job.msg.chatId !== scope || (job.msg.receiptSourceId || job.msg.msgId) !== receipt.sourceId) {
|
|
90
|
+
throw new Error('C2 atomic release queue handle differs from durable receipt')
|
|
91
|
+
}
|
|
92
|
+
add(id)
|
|
93
|
+
if (item.batch) {
|
|
94
|
+
if (!Array.isArray(job.batch) || job.batch.length !== item.batch.length) {
|
|
95
|
+
throw new Error('C2 atomic release batch handle missing')
|
|
96
|
+
}
|
|
97
|
+
for (let index = 0; index < item.batch.length; index++) {
|
|
98
|
+
const memberId = item.batch[index].id?.slice('receipt:member:'.length)
|
|
99
|
+
const member = job.batch[index]
|
|
100
|
+
const memberReceipt = pending.get(memberId)
|
|
101
|
+
if (!RECEIPT_ID.test(memberId) ||
|
|
102
|
+
item.batch[index].id !== `receipt:member:${memberId}` ||
|
|
103
|
+
!memberReceipt || memberReceipt.chatId !== scope ||
|
|
104
|
+
member?._c2ReceiptId !== memberId || member.chatId !== scope ||
|
|
105
|
+
(member.receiptSourceId || member.msgId) !== memberReceipt.sourceId) {
|
|
106
|
+
throw new Error('C2 atomic release batch handle differs from durable receipt')
|
|
107
|
+
}
|
|
108
|
+
add(memberId)
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return { queue, accepted }
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function queueRunLen(queue) {
|
|
116
|
+
if (!queue.length) return 0
|
|
117
|
+
const { quiet, polled } = queue[0]
|
|
118
|
+
const end = queue.findIndex(item => item.quiet !== quiet || item.polled !== polled)
|
|
119
|
+
return end < 0 ? queue.length : end
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// The model receives top-level jobs, while a retried job may also contain
|
|
123
|
+
// member receipts. Durable terminal settlement must cover every member.
|
|
124
|
+
function queueCohortReceiptIds(items) {
|
|
125
|
+
const ids = []
|
|
126
|
+
for (const item of items) {
|
|
127
|
+
for (const handle of [item.id, ...(item.batch || []).map(member => member.id)]) {
|
|
128
|
+
const prefix = handle.startsWith('receipt:job:')
|
|
129
|
+
? 'receipt:job:' : 'receipt:member:'
|
|
130
|
+
const id = handle.slice(prefix.length)
|
|
131
|
+
if (!RECEIPT_ID.test(id) || handle !== `${prefix}${id}`) {
|
|
132
|
+
throw new Error('C2 queue cohort has unstable receipt handle')
|
|
133
|
+
}
|
|
134
|
+
if (!ids.includes(id)) ids.push(id)
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return ids
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Development-only v2 authority. The caller must hold the daemon's single
|
|
141
|
+
// writer lock, supply an explicitly opened/initialized journal, and own all
|
|
142
|
+
// process-local payloads. This façade never saves a body or reply context.
|
|
143
|
+
class C2DurableRuntime {
|
|
144
|
+
#journal
|
|
145
|
+
#coordinator
|
|
146
|
+
#ingress = null
|
|
147
|
+
#resolveTarget
|
|
148
|
+
#send
|
|
149
|
+
#now
|
|
150
|
+
#maxEventCount
|
|
151
|
+
#maxJournalBytes
|
|
152
|
+
#maxCompletionEvents
|
|
153
|
+
#maxCompletionBytes
|
|
154
|
+
#assertWriterLock
|
|
155
|
+
#singleNoticePerGroup
|
|
156
|
+
#opening = false
|
|
157
|
+
#closed = true
|
|
158
|
+
#effectAuthorityReady = false
|
|
159
|
+
#capacitySealed = false
|
|
160
|
+
#scheduledHandoffs = new Map()
|
|
161
|
+
// A dispatch receipt is only a live capability in this process. Reopen
|
|
162
|
+
// cannot reconstruct its body or prove which pending sibling was running.
|
|
163
|
+
#activeRuns = new Map()
|
|
164
|
+
#terminalAwaitingSettlement = new Map()
|
|
165
|
+
#externalEffectInFlight = false
|
|
166
|
+
#gate = {
|
|
167
|
+
close: () => { this.#closed = true },
|
|
168
|
+
open: () => { this.#closed = false },
|
|
169
|
+
isClosed: () => this.#closed,
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
constructor({ journal, resolveTarget, send, now = Date.now,
|
|
173
|
+
maxEventCount, maxJournalBytes, maxCompletionEvents, maxCompletionBytes,
|
|
174
|
+
assertWriterLock = null, singleNoticePerGroup = false } = {}) {
|
|
175
|
+
if (!(journal instanceof C2DurableJournal) || typeof journal.snapshot !== 'function' ||
|
|
176
|
+
typeof journal.appendTransition !== 'function' ||
|
|
177
|
+
typeof journal.byteLength !== 'number' || journal.byteLength <= 0 ||
|
|
178
|
+
typeof resolveTarget !== 'function' || typeof send !== 'function' ||
|
|
179
|
+
typeof now !== 'function' ||
|
|
180
|
+
(assertWriterLock !== null &&
|
|
181
|
+
(typeof assertWriterLock !== 'function' ||
|
|
182
|
+
assertWriterLock.constructor?.name === 'AsyncFunction')) ||
|
|
183
|
+
typeof singleNoticePerGroup !== 'boolean') {
|
|
184
|
+
throw new TypeError('C2 runtime needs an explicit journal and recovery sender')
|
|
185
|
+
}
|
|
186
|
+
this.#journal = journal
|
|
187
|
+
this.#coordinator = new EngineStepCoordinator({ journal })
|
|
188
|
+
this.#resolveTarget = resolveTarget
|
|
189
|
+
this.#send = send
|
|
190
|
+
this.#now = now
|
|
191
|
+
this.#assertWriterLock = assertWriterLock
|
|
192
|
+
this.#singleNoticePerGroup = singleNoticePerGroup
|
|
193
|
+
this.#maxEventCount = positiveLimit(maxEventCount, 'event limit')
|
|
194
|
+
this.#maxJournalBytes = positiveLimit(maxJournalBytes, 'journal byte limit')
|
|
195
|
+
this.#maxCompletionEvents = positiveLimit(maxCompletionEvents ?? maxEventCount,
|
|
196
|
+
'completion event reserve')
|
|
197
|
+
this.#maxCompletionBytes = positiveLimit(maxCompletionBytes ?? maxJournalBytes,
|
|
198
|
+
'completion byte reserve')
|
|
199
|
+
if (!Number.isSafeInteger(this.#maxEventCount + this.#maxCompletionEvents) ||
|
|
200
|
+
!Number.isSafeInteger(this.#maxJournalBytes + this.#maxCompletionBytes)) {
|
|
201
|
+
throw new RangeError('C2 authority limits exceed safe integer range')
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
get blocked() { return this.#closed || this.#opening || this.#externalEffectInFlight || !this.#ingress ||
|
|
206
|
+
this.#ingress.blocked || this.#journal.blocked }
|
|
207
|
+
|
|
208
|
+
hasRecoveryPolicy({ assertWriterLock, singleNoticePerGroup } = {}) {
|
|
209
|
+
return typeof assertWriterLock === 'function' &&
|
|
210
|
+
this.#assertWriterLock === assertWriterLock &&
|
|
211
|
+
this.#singleNoticePerGroup === singleNoticePerGroup
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// A scope-labelled gate must not be mistaken for a scoped runtime lease.
|
|
215
|
+
// The current callbacks and failure catches still close the whole runtime,
|
|
216
|
+
// while open() discards every group's live capabilities. Keep this contract
|
|
217
|
+
// visibly closed until all effect/callback paths can fence one group only.
|
|
218
|
+
scopedGate(chatId) {
|
|
219
|
+
if (typeof chatId !== 'string' || !/^[A-Za-z0-9_-]{1,128}$/.test(chatId) ||
|
|
220
|
+
['__proto__', 'constructor', 'prototype'].includes(chatId)) {
|
|
221
|
+
throw new TypeError('C2 scoped runtime chat ID invalid')
|
|
222
|
+
}
|
|
223
|
+
const unavailable = () => {
|
|
224
|
+
throw new Error('C2 scoped runtime close/open unavailable; global authority unchanged')
|
|
225
|
+
}
|
|
226
|
+
return Object.freeze({ scope: chatId, close: unavailable, open: unavailable,
|
|
227
|
+
isClosed: () => true })
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
closeScope(chatId) { this.scopedGate(chatId).close() }
|
|
231
|
+
|
|
232
|
+
async recoverScope(chatId) { this.scopedGate(chatId).open() }
|
|
233
|
+
|
|
234
|
+
#capacity() {
|
|
235
|
+
const snapshot = this.#journal.snapshot()
|
|
236
|
+
if (!Number.isSafeInteger(snapshot.eventCount) || snapshot.eventCount < 0 ||
|
|
237
|
+
!Number.isSafeInteger(this.#journal.byteLength) || this.#journal.byteLength <= 0) {
|
|
238
|
+
this.close()
|
|
239
|
+
throw new Error('C2 durable authority capacity unreadable')
|
|
240
|
+
}
|
|
241
|
+
if (snapshot.eventCount >= this.#maxEventCount ||
|
|
242
|
+
this.#journal.byteLength >= this.#maxJournalBytes) {
|
|
243
|
+
this.#sealForCapacity()
|
|
244
|
+
throw new Error('C2 durable authority capacity reached; ingress closed')
|
|
245
|
+
}
|
|
246
|
+
return snapshot
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
#completionCapacity() {
|
|
250
|
+
if (!this.#effectAuthorityReady || this.#opening || this.#externalEffectInFlight ||
|
|
251
|
+
this.#journal.blocked ||
|
|
252
|
+
(this.#closed && !this.#capacitySealed)) {
|
|
253
|
+
throw new Error('C2 durable completion authority closed')
|
|
254
|
+
}
|
|
255
|
+
const snapshot = this.#journal.snapshot()
|
|
256
|
+
if (snapshot.eventCount >= this.#maxEventCount + this.#maxCompletionEvents ||
|
|
257
|
+
this.#journal.byteLength >= this.#maxJournalBytes + this.#maxCompletionBytes) {
|
|
258
|
+
this.#sealForCapacity()
|
|
259
|
+
throw new Error('C2 durable completion reserve exhausted; manual recovery required')
|
|
260
|
+
}
|
|
261
|
+
return snapshot
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
async open() {
|
|
265
|
+
if (this.#externalEffectInFlight) throw new Error('C2 external effect still in flight')
|
|
266
|
+
if (this.#opening) throw new Error('C2 runtime startup already in progress')
|
|
267
|
+
if (!this.blocked) return this.snapshot()
|
|
268
|
+
this.#opening = true
|
|
269
|
+
this.#ingress = null
|
|
270
|
+
this.#effectAuthorityReady = false
|
|
271
|
+
this.#capacitySealed = false
|
|
272
|
+
this.#scheduledHandoffs.clear()
|
|
273
|
+
this.#activeRuns.clear()
|
|
274
|
+
this.#terminalAwaitingSettlement.clear()
|
|
275
|
+
this.#gate.close()
|
|
276
|
+
try {
|
|
277
|
+
// Capacity limits new ingress. Recovery must still settle already
|
|
278
|
+
// accepted receipts before deciding whether new work may enter.
|
|
279
|
+
await recoverAndOpen({ gate: this.#gate, journal: this.#journal,
|
|
280
|
+
coordinator: this.#coordinator, resolveTarget: this.#resolveTarget,
|
|
281
|
+
send: this.#send, now: this.#now,
|
|
282
|
+
assertWriterLock: this.#assertWriterLock,
|
|
283
|
+
singleNoticePerGroup: this.#singleNoticePerGroup })
|
|
284
|
+
this.#capacity()
|
|
285
|
+
this.#ingress = C2DurableIngress.resumeAfterVerifiedRecovery({
|
|
286
|
+
journal: this.#journal, coordinator: this.#coordinator, gate: this.#gate })
|
|
287
|
+
this.#effectAuthorityReady = true
|
|
288
|
+
return this.snapshot()
|
|
289
|
+
} catch (error) {
|
|
290
|
+
this.close()
|
|
291
|
+
throw error
|
|
292
|
+
} finally { this.#opening = false }
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
close() {
|
|
296
|
+
this.#gate.close()
|
|
297
|
+
this.#ingress = null
|
|
298
|
+
this.#effectAuthorityReady = false
|
|
299
|
+
this.#capacitySealed = false
|
|
300
|
+
this.#scheduledHandoffs.clear()
|
|
301
|
+
this.#activeRuns.clear()
|
|
302
|
+
this.#terminalAwaitingSettlement.clear()
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
#sealForCapacity() {
|
|
306
|
+
this.#gate.close()
|
|
307
|
+
this.#ingress = null
|
|
308
|
+
this.#capacitySealed = true
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
enqueueMessage(options) {
|
|
312
|
+
if (this.blocked) throw new Error('C2 durable runtime ingress closed')
|
|
313
|
+
this.#capacity()
|
|
314
|
+
const result = this.#ingress.enqueueMessage(options)
|
|
315
|
+
if (this.#journal.snapshot().eventCount >= this.#maxEventCount ||
|
|
316
|
+
this.#journal.byteLength >= this.#maxJournalBytes) this.#sealForCapacity()
|
|
317
|
+
return result
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// A visible busy receipt remains pending after its queue acknowledgement.
|
|
321
|
+
// The effect descriptor and target identity are durable before any send;
|
|
322
|
+
// only the same process may turn one attempted call into an ACK.
|
|
323
|
+
prepareQueueNotice({ receiptId, chatId, payloadHandles } = {}) {
|
|
324
|
+
const before = this.#completionCapacity()
|
|
325
|
+
if (!RECEIPT_ID.test(receiptId || '') ||
|
|
326
|
+
typeof chatId !== 'string' || !chatId ||
|
|
327
|
+
!(payloadHandles instanceof C2PayloadHandles) || payloadHandles.blocked) {
|
|
328
|
+
throw new TypeError('C2 queue notice identity invalid')
|
|
329
|
+
}
|
|
330
|
+
const receipt = before.receipts.pending.find(item => item.id === receiptId)
|
|
331
|
+
const queue = before.state.lanes.queue?.[chatId]?.queue
|
|
332
|
+
const index = queue?.findIndex(item => item.id === `receipt:job:${receiptId}`)
|
|
333
|
+
const witness = this.#activeRuns.get(chatId)
|
|
334
|
+
const item = index >= 0 ? queue[index] : null
|
|
335
|
+
const target = payloadHandles.requireQueued(receiptId)?.msg
|
|
336
|
+
const visibleQueue = queue?.filter(job => !job.quiet && !job.polled).length
|
|
337
|
+
const eventId = `sbot-c2-queue-notice-v1:${receiptId}`
|
|
338
|
+
if (!receipt || receipt.chatId !== chatId || receipt.source !== 'message' ||
|
|
339
|
+
receipt.quiet || !Array.isArray(queue) || index < 0 ||
|
|
340
|
+
item.quiet || item.polled || item.batch !== undefined ||
|
|
341
|
+
!Number.isSafeInteger(visibleQueue) || visibleQueue <= 0 ||
|
|
342
|
+
target?._c2ReceiptId !== receiptId || target.chatId !== chatId ||
|
|
343
|
+
(target.receiptSourceId || target.msgId) !== receipt.sourceId ||
|
|
344
|
+
target.replyCtx?.chatId !== chatId ||
|
|
345
|
+
target.replyCtx.messageId !== receipt.sourceId ||
|
|
346
|
+
!witness || before.state.lanes.turn?.[chatId]?.active !== witness.turnToken ||
|
|
347
|
+
before.state.lanes.handoff?.[chatId]?.phase !== 'running' ||
|
|
348
|
+
before.state.lanes.handoff[chatId].turnToken !== witness.turnToken ||
|
|
349
|
+
this.#journal.hasEvent(eventId) ||
|
|
350
|
+
!isDeepStrictEqual(this.#coordinator.snapshot(), before.state)) {
|
|
351
|
+
throw new Error('C2 queue notice lacks exact pending busy receipt')
|
|
352
|
+
}
|
|
353
|
+
try {
|
|
354
|
+
const prepared = this.#coordinator.prepare({ id: eventId, domain: 'queue',
|
|
355
|
+
scope: chatId, payload: { type: 'prepareQueueNotice', receiptId,
|
|
356
|
+
queueIndex: index, visibleQueue },
|
|
357
|
+
validate: candidate => {
|
|
358
|
+
const effect = candidate.effects?.[0]
|
|
359
|
+
const descriptor = candidate.effectDescriptors?.[0]
|
|
360
|
+
if (candidate.cursor !== before.cursor + 1 ||
|
|
361
|
+
candidate.state?.seq !== candidate.cursor ||
|
|
362
|
+
!isDeepStrictEqual(candidate.state.lanes, before.state.lanes) ||
|
|
363
|
+
candidate.effects?.length !== 1 || effect?.type !== 'queueNotice' ||
|
|
364
|
+
effect.receiptId !== receiptId || effect.visibleQueue !== visibleQueue ||
|
|
365
|
+
descriptor?.effectId !== effect.effectId ||
|
|
366
|
+
descriptor.domain !== 'queue' || descriptor.scope !== chatId ||
|
|
367
|
+
descriptor.type !== 'queueNotice' || descriptor.turnToken !== null ||
|
|
368
|
+
descriptor.receiptId !== receiptId || descriptor.targetId !== null ||
|
|
369
|
+
candidate.decision?.receiptId !== receiptId ||
|
|
370
|
+
candidate.decision.queueIndex !== index ||
|
|
371
|
+
candidate.decision.visibleQueue !== visibleQueue ||
|
|
372
|
+
candidate.decision.receiptSettlement !== false) {
|
|
373
|
+
throw new Error('C2 queue notice result invalid')
|
|
374
|
+
}
|
|
375
|
+
} })
|
|
376
|
+
const result = prepared.commit()
|
|
377
|
+
return { effectId: result.effects[0].effectId, receiptId, chatId,
|
|
378
|
+
queueIndex: index, visibleQueue }
|
|
379
|
+
} catch (error) { this.close(); throw error }
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
async executeQueueNotice({ effectId, receiptId, chatId, payloadHandles,
|
|
383
|
+
assertWriterLock, perform, confirm } = {}) {
|
|
384
|
+
const before = this.#completionCapacity()
|
|
385
|
+
const descriptor = this.#journal.effectDescriptor(effectId)
|
|
386
|
+
const receipt = before.receipts.pending.find(item => item.id === receiptId)
|
|
387
|
+
const queue = before.state.lanes.queue?.[chatId]?.queue
|
|
388
|
+
const target = payloadHandles instanceof C2PayloadHandles &&
|
|
389
|
+
!payloadHandles.blocked ? payloadHandles.requireQueued(receiptId)?.msg : null
|
|
390
|
+
if (!RECEIPT_ID.test(receiptId || '') || !receipt ||
|
|
391
|
+
receipt.chatId !== chatId || receipt.source !== 'message' || receipt.quiet ||
|
|
392
|
+
descriptor?.domain !== 'queue' || descriptor.scope !== chatId ||
|
|
393
|
+
descriptor.type !== 'queueNotice' || descriptor.receiptId !== receiptId ||
|
|
394
|
+
descriptor.turnToken !== null || descriptor.targetId !== null ||
|
|
395
|
+
this.#journal.effectStatus(effectId) !== 'pending' ||
|
|
396
|
+
!Array.isArray(queue) || !queue.some(item =>
|
|
397
|
+
item.id === `receipt:job:${receiptId}` && !item.quiet && !item.polled &&
|
|
398
|
+
item.batch === undefined) ||
|
|
399
|
+
target?._c2ReceiptId !== receiptId || target.chatId !== chatId ||
|
|
400
|
+
(target.receiptSourceId || target.msgId) !== receipt.sourceId ||
|
|
401
|
+
target.replyCtx?.chatId !== chatId ||
|
|
402
|
+
target.replyCtx.messageId !== receipt.sourceId ||
|
|
403
|
+
typeof assertWriterLock !== 'function' ||
|
|
404
|
+
assertWriterLock.constructor?.name === 'AsyncFunction' ||
|
|
405
|
+
typeof perform !== 'function' || typeof confirm !== 'function' ||
|
|
406
|
+
confirm.constructor?.name === 'AsyncFunction' ||
|
|
407
|
+
!isDeepStrictEqual(this.#coordinator.snapshot(), before.state) ||
|
|
408
|
+
assertWriterLock() !== true) {
|
|
409
|
+
throw new Error('C2 queue notice lacks exact live target and effect')
|
|
410
|
+
}
|
|
411
|
+
const attemptedAt = this.#now()
|
|
412
|
+
if (!Number.isSafeInteger(attemptedAt) || attemptedAt <= 0) {
|
|
413
|
+
throw new TypeError('C2 queue notice time invalid')
|
|
414
|
+
}
|
|
415
|
+
this.#externalEffectInFlight = true
|
|
416
|
+
try {
|
|
417
|
+
this.#journal.beginEffectAttempt({ effectId, attemptedAt })
|
|
418
|
+
let confirmed = false
|
|
419
|
+
let response
|
|
420
|
+
try {
|
|
421
|
+
response = await perform({ effectId, target: target.replyCtx })
|
|
422
|
+
confirmed = confirm(response) === true &&
|
|
423
|
+
this.#effectAuthorityReady && assertWriterLock() === true
|
|
424
|
+
} catch { /* The platform call may have succeeded. */ }
|
|
425
|
+
const now = this.#now()
|
|
426
|
+
const resolvedAt = Number.isSafeInteger(now) && now >= attemptedAt
|
|
427
|
+
? now : attemptedAt
|
|
428
|
+
this.#journal.recordEffectOutcome({ effectId,
|
|
429
|
+
status: confirmed ? 'acked' : 'unknown', resolvedAt })
|
|
430
|
+
if (!confirmed) throw new Error('C2 queue notice outcome unknown')
|
|
431
|
+
return { effectId, receiptId, status: 'acked', response }
|
|
432
|
+
} catch (error) { this.close(); throw error }
|
|
433
|
+
finally { this.#externalEffectInFlight = false }
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
#retainActiveRun(scope, result, receiptId, jobReceiptIds, batchReceiptIds) {
|
|
437
|
+
const turnToken = result.decision?.turnToken
|
|
438
|
+
const dispatch = result.effects?.find(item => item.type === 'dispatch')
|
|
439
|
+
const descriptor = dispatch && this.#journal.effectDescriptor(dispatch.effectId)
|
|
440
|
+
const durable = this.#journal.snapshot()
|
|
441
|
+
if (!RECEIPT_ID.test(receiptId) || !Array.isArray(jobReceiptIds) ||
|
|
442
|
+
!Array.isArray(batchReceiptIds) || !jobReceiptIds.includes(receiptId) ||
|
|
443
|
+
!batchReceiptIds.includes(receiptId) ||
|
|
444
|
+
!Number.isSafeInteger(turnToken) || turnToken <= 0 ||
|
|
445
|
+
descriptor?.type !== 'dispatch' || descriptor.domain !== 'atomicHandoff' ||
|
|
446
|
+
descriptor.scope !== scope || descriptor.turnToken !== turnToken ||
|
|
447
|
+
descriptor.receiptId !== receiptId ||
|
|
448
|
+
durable.state.lastEventId !== result.eventId ||
|
|
449
|
+
durable.state.lanes.turn?.[scope]?.active !== turnToken ||
|
|
450
|
+
durable.state.lanes.handoff?.[scope]?.phase !== 'running' ||
|
|
451
|
+
durable.state.lanes.handoff[scope].turnToken !== turnToken ||
|
|
452
|
+
!isDeepStrictEqual(durable.state.lanes.handoff[scope].acceptedReceiptIds,
|
|
453
|
+
batchReceiptIds.length > 1 ? batchReceiptIds : [])) {
|
|
454
|
+
throw new Error('C2 running receipt witness differs from durable dispatch')
|
|
455
|
+
}
|
|
456
|
+
this.#activeRuns.set(scope, { receiptId, turnToken, eventId: result.eventId,
|
|
457
|
+
dispatchEffectId: dispatch.effectId, jobReceiptIds: [...jobReceiptIds],
|
|
458
|
+
batchReceiptIds: [...batchReceiptIds] })
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// One accepted queue head becomes a running turn under a single fsynced
|
|
462
|
+
// cursor. Raw jobs remain in the process-local registry until the caller
|
|
463
|
+
// consumes the committed dispatch; nothing may run before this returns.
|
|
464
|
+
commitInitialDispatch({ receiptId, payloadHandles } = {}) {
|
|
465
|
+
this.#completionCapacity()
|
|
466
|
+
if (!RECEIPT_ID.test(receiptId)) throw new TypeError('C2 initial dispatch receipt invalid')
|
|
467
|
+
const before = this.#journal.snapshot()
|
|
468
|
+
const receipt = before.receipts.pending.find(item => item.id === receiptId)
|
|
469
|
+
if (!receipt) throw new Error('C2 initial dispatch lacks pending receipt')
|
|
470
|
+
const scope = receipt.chatId
|
|
471
|
+
if (this.#terminalAwaitingSettlement.has(scope)) {
|
|
472
|
+
throw new Error('C2 prior terminal receipt has not settled')
|
|
473
|
+
}
|
|
474
|
+
const { queue } = queuedPayloads(before, scope, payloadHandles)
|
|
475
|
+
if (!queue.length ||
|
|
476
|
+
before.state.lanes.handoff?.[scope]?.phase !== undefined &&
|
|
477
|
+
before.state.lanes.handoff[scope].phase !== 'idle' ||
|
|
478
|
+
before.state.lanes.turn?.[scope]?.active != null ||
|
|
479
|
+
!isDeepStrictEqual(this.#coordinator.snapshot(), before.state)) {
|
|
480
|
+
throw new Error('C2 initial dispatch differs from durable idle queue')
|
|
481
|
+
}
|
|
482
|
+
const runLen = queueRunLen(queue)
|
|
483
|
+
const taken = queue.slice(0, runLen)
|
|
484
|
+
const remaining = queue.slice(runLen)
|
|
485
|
+
const jobReceiptIds = taken.map(item => item.id.slice('receipt:job:'.length))
|
|
486
|
+
const batchReceiptIds = queueCohortReceiptIds(taken)
|
|
487
|
+
const expectedAnchor = [...taken].reverse().find(item => !item.synthetic)?.id ||
|
|
488
|
+
taken.at(-1)?.id
|
|
489
|
+
if (expectedAnchor !== `receipt:job:${receiptId}`) {
|
|
490
|
+
throw new Error('C2 initial dispatch anchor differs from queue decision')
|
|
491
|
+
}
|
|
492
|
+
const id = platformMessageEventId({ receiptId, domain: 'atomicHandoff',
|
|
493
|
+
kind: 'initialDispatchCommit', lookupReceipt: key =>
|
|
494
|
+
before.receipts.pending.find(item => item.id === key) || null })
|
|
495
|
+
if (this.#journal.hasEvent(id)) throw new Error('C2 initial dispatch already committed')
|
|
496
|
+
try {
|
|
497
|
+
const prepared = this.#coordinator.prepare({ id, domain: 'atomicHandoff', scope,
|
|
498
|
+
payload: { type: 'initialDispatchCommit' },
|
|
499
|
+
validate: candidate => {
|
|
500
|
+
const token = candidate.decision?.turnToken
|
|
501
|
+
const dispatch = candidate.decision?.queueDispatch
|
|
502
|
+
const effect = candidate.effects?.[0]
|
|
503
|
+
if (candidate.cursor !== before.cursor + 1 || candidate.state?.seq !== candidate.cursor ||
|
|
504
|
+
!Number.isSafeInteger(token) || token <= 0 ||
|
|
505
|
+
candidate.state.lanes.turn?.[scope]?.active !== token ||
|
|
506
|
+
candidate.state.lanes.handoff?.[scope]?.phase !== 'running' ||
|
|
507
|
+
candidate.state.lanes.handoff[scope].turnToken !== token ||
|
|
508
|
+
!isDeepStrictEqual(candidate.state.lanes.queue?.[scope]?.queue, remaining) ||
|
|
509
|
+
dispatch?.type !== 'dispatch' ||
|
|
510
|
+
!taken.some(item => item.id === dispatch.anchor) ||
|
|
511
|
+
!isDeepStrictEqual(dispatch.batch, taken.flatMap(item =>
|
|
512
|
+
item.batch?.map(member => member.id) || [item.id])) ||
|
|
513
|
+
!isDeepStrictEqual(candidate.decision.jobReceiptIds, jobReceiptIds) ||
|
|
514
|
+
!isDeepStrictEqual(candidate.decision.batchReceiptIds, batchReceiptIds) ||
|
|
515
|
+
!isDeepStrictEqual(candidate.state.lanes.handoff[scope].acceptedReceiptIds,
|
|
516
|
+
batchReceiptIds.length > 1 ? batchReceiptIds : []) ||
|
|
517
|
+
candidate.decision.queueRunLen !== queueRunLen(remaining) ||
|
|
518
|
+
candidate.decision.visibleQueue !== remaining.filter(item =>
|
|
519
|
+
!item.quiet && !item.polled).length ||
|
|
520
|
+
candidate.effects.length !== 1 || effect.type !== 'dispatch' ||
|
|
521
|
+
effect.origin !== 'queue' || effect.turnToken !== token ||
|
|
522
|
+
effect.effectId !== `sbot-c2-effect-v1:${Buffer.byteLength(id)}:${id}:0`) {
|
|
523
|
+
throw new Error('C2 initial dispatch result invalid')
|
|
524
|
+
}
|
|
525
|
+
} })
|
|
526
|
+
const result = prepared.commit()
|
|
527
|
+
this.#retainActiveRun(scope, result, receiptId, jobReceiptIds, batchReceiptIds)
|
|
528
|
+
if (this.#journal.snapshot().eventCount >= this.#maxEventCount ||
|
|
529
|
+
this.#journal.byteLength >= this.#maxJournalBytes) this.#sealForCapacity()
|
|
530
|
+
return { ...result, jobReceiptIds, batchReceiptIds }
|
|
531
|
+
} catch (error) { this.close(); throw error }
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
// The model process may only start after its dispatch effect has an fsynced
|
|
535
|
+
// attempt. A trusted runner hook confirms acceptance before any turn event
|
|
536
|
+
// reaches Engine. Losing either side leaves an ambiguous attempt closed on
|
|
537
|
+
// restart; no process relaunch is inferred from a pending receipt.
|
|
538
|
+
beginRunnerStart({ receiptId, chatId, turnToken, generation,
|
|
539
|
+
verifyGeneration } = {}) {
|
|
540
|
+
const before = this.#completionCapacity()
|
|
541
|
+
if (!RECEIPT_ID.test(receiptId) || typeof chatId !== 'string' || !chatId ||
|
|
542
|
+
!Number.isSafeInteger(turnToken) || turnToken <= 0 ||
|
|
543
|
+
!GENERATION.test(generation) || typeof verifyGeneration !== 'function' ||
|
|
544
|
+
verifyGeneration.constructor?.name === 'AsyncFunction') {
|
|
545
|
+
throw new TypeError('C2 runner start identity invalid')
|
|
546
|
+
}
|
|
547
|
+
const witness = this.#activeRuns.get(chatId)
|
|
548
|
+
const receipt = before.receipts.pending.find(item => item.id === receiptId)
|
|
549
|
+
const effectId = witness?.dispatchEffectId
|
|
550
|
+
const descriptor = effectId && this.#journal.effectDescriptor(effectId)
|
|
551
|
+
if (!receipt || receipt.chatId !== chatId || witness?.receiptId !== receiptId ||
|
|
552
|
+
witness?.turnToken !== turnToken ||
|
|
553
|
+
!this.#matchesRunningReceipts(before, witness, chatId, receiptId, turnToken) ||
|
|
554
|
+
descriptor?.domain !== 'atomicHandoff' || descriptor.type !== 'dispatch' ||
|
|
555
|
+
descriptor.scope !== chatId || descriptor.receiptId !== receiptId ||
|
|
556
|
+
descriptor.turnToken !== turnToken ||
|
|
557
|
+
this.#journal.effectStatus(effectId) !== 'pending' ||
|
|
558
|
+
before.state.lanes.turn?.[chatId]?.active !== turnToken ||
|
|
559
|
+
before.state.lanes.handoff?.[chatId]?.phase !== 'running' ||
|
|
560
|
+
before.state.lanes.handoff[chatId].turnToken !== turnToken ||
|
|
561
|
+
!isDeepStrictEqual(this.#coordinator.snapshot(), before.state) ||
|
|
562
|
+
verifyGeneration({ chatId, receiptId, turnToken, generation }) !== true) {
|
|
563
|
+
throw new Error('C2 runner start lacks exact live dispatch authority')
|
|
564
|
+
}
|
|
565
|
+
const attemptedAt = this.#now()
|
|
566
|
+
if (!Number.isSafeInteger(attemptedAt) || attemptedAt <= 0) {
|
|
567
|
+
throw new TypeError('C2 runner start time invalid')
|
|
568
|
+
}
|
|
569
|
+
const registrationId = callbackRegistrationId({ receiptId, generation,
|
|
570
|
+
domain: 'atomicHandoff', kind: 'runnerAccepted' })
|
|
571
|
+
try {
|
|
572
|
+
this.#journal.registerCallback({ id: registrationId, receiptId, generation,
|
|
573
|
+
domain: 'atomicHandoff', kind: 'runnerAccepted', ordinal: 0,
|
|
574
|
+
registeredAt: attemptedAt })
|
|
575
|
+
this.#journal.beginEffectAttempt({ effectId, attemptedAt })
|
|
576
|
+
} catch (error) { this.close(); throw error }
|
|
577
|
+
|
|
578
|
+
let phase = 'attempting'
|
|
579
|
+
const onSpawnFailed = (failure) => {
|
|
580
|
+
if (phase !== 'attempting' || failure?.backend !== 'codex-exec' ||
|
|
581
|
+
failure.code !== 'ENOENT' || failure.pid !== null ||
|
|
582
|
+
!isDeepStrictEqual(witness.jobReceiptIds, [receiptId]) ||
|
|
583
|
+
!isDeepStrictEqual(witness.batchReceiptIds, [receiptId]) ||
|
|
584
|
+
verifyGeneration({ chatId, receiptId, turnToken, generation }) !== true ||
|
|
585
|
+
this.#assertWriterLock?.() !== true) {
|
|
586
|
+
throw new Error('C2 runner not-started lacks exact single-receipt witness')
|
|
587
|
+
}
|
|
588
|
+
const current = this.#journal.snapshot()
|
|
589
|
+
if (current.state.lanes.turn?.[chatId]?.active !== turnToken ||
|
|
590
|
+
current.state.lanes.handoff?.[chatId]?.phase !== 'running' ||
|
|
591
|
+
this.#journal.effectStatus(effectId) !== 'attempting' ||
|
|
592
|
+
!isDeepStrictEqual(this.#coordinator.snapshot(), current.state)) {
|
|
593
|
+
throw new Error('C2 runner not-started authority changed')
|
|
594
|
+
}
|
|
595
|
+
try {
|
|
596
|
+
const now = this.#now()
|
|
597
|
+
const evidenceId = this.#journal.recordRunnerNotStarted({ receiptId, chatId,
|
|
598
|
+
turnToken, generation, dispatchEffectId: effectId,
|
|
599
|
+
resolvedAt: Number.isSafeInteger(now) && now >= attemptedAt ? now : attemptedAt })
|
|
600
|
+
phase = 'notStarted'
|
|
601
|
+
const eventId = `sbot-c2-runner-not-started-commit-v1:${receiptId}:${turnToken}:${generation}`
|
|
602
|
+
const result = this.#coordinator.transition({ id: eventId,
|
|
603
|
+
domain: 'runnerStartFailure', scope: chatId,
|
|
604
|
+
payload: { type: 'abortUnstartedDispatch', receiptId, turnToken,
|
|
605
|
+
runnerGeneration: generation, dispatchEffectId: effectId,
|
|
606
|
+
notStartedEvidenceId: evidenceId },
|
|
607
|
+
validate: candidate => {
|
|
608
|
+
if (candidate.effects.length ||
|
|
609
|
+
!isDeepStrictEqual(candidate.decision,
|
|
610
|
+
{ abandonedReceiptId: receiptId, requiresStartupRecovery: true,
|
|
611
|
+
scope: chatId, dispatchEffectId: effectId })) {
|
|
612
|
+
throw new Error('C2 runner not-started decision invalid')
|
|
613
|
+
}
|
|
614
|
+
} })
|
|
615
|
+
if (result.decision.requiresStartupRecovery !== true) {
|
|
616
|
+
throw new Error('C2 runner not-started recovery not required')
|
|
617
|
+
}
|
|
618
|
+
this.close()
|
|
619
|
+
return true
|
|
620
|
+
} catch (error) { this.close(); throw error }
|
|
621
|
+
}
|
|
622
|
+
const markUnknown = () => {
|
|
623
|
+
if (phase !== 'attempting') return false
|
|
624
|
+
phase = 'unknown'
|
|
625
|
+
try {
|
|
626
|
+
const now = this.#now()
|
|
627
|
+
this.#journal.recordEffectOutcome({ effectId, status: 'unknown',
|
|
628
|
+
resolvedAt: Number.isSafeInteger(now) && now >= attemptedAt ? now : attemptedAt })
|
|
629
|
+
} finally { this.close() }
|
|
630
|
+
return true
|
|
631
|
+
}
|
|
632
|
+
const onTurnAccepted = (accepted) => {
|
|
633
|
+
if (phase !== 'attempting') throw new Error('C2 runner start callback already resolved')
|
|
634
|
+
const registered = this.#journal.lookupRegistration(registrationId)
|
|
635
|
+
const current = this.#journal.snapshot()
|
|
636
|
+
const active = this.#activeRuns.get(chatId)
|
|
637
|
+
const validChannel = ['claude-code', 'codex-exec', 'codex-app-server']
|
|
638
|
+
.includes(accepted?.channel)
|
|
639
|
+
const validIdentity = typeof accepted?.sessionId === 'string' &&
|
|
640
|
+
accepted.sessionId.length > 0 &&
|
|
641
|
+
(accepted.channel !== 'codex-app-server' ||
|
|
642
|
+
typeof accepted.turnId === 'string' && accepted.turnId.length > 0)
|
|
643
|
+
if (!validChannel || !validIdentity || !this.#effectAuthorityReady ||
|
|
644
|
+
registered?.generation !== generation || registered?.receiptId !== receiptId ||
|
|
645
|
+
active?.receiptId !== receiptId || active.turnToken !== turnToken ||
|
|
646
|
+
current.state.lanes.turn?.[chatId]?.active !== turnToken ||
|
|
647
|
+
current.state.lanes.handoff?.[chatId]?.phase !== 'running' ||
|
|
648
|
+
current.state.lanes.handoff[chatId].turnToken !== turnToken ||
|
|
649
|
+
verifyGeneration({ chatId, receiptId, turnToken, generation }) !== true) {
|
|
650
|
+
markUnknown()
|
|
651
|
+
throw new Error('C2 runner acceptance differs from live dispatch')
|
|
652
|
+
}
|
|
653
|
+
const now = this.#now()
|
|
654
|
+
try {
|
|
655
|
+
this.#journal.recordEffectOutcome({ effectId, status: 'acked',
|
|
656
|
+
resolvedAt: Number.isSafeInteger(now) && now >= attemptedAt ? now : attemptedAt,
|
|
657
|
+
registrationId })
|
|
658
|
+
phase = 'acked'
|
|
659
|
+
return { effectId, status: 'acked' }
|
|
660
|
+
} catch (error) { this.close(); throw error }
|
|
661
|
+
}
|
|
662
|
+
return { effectId, onTurnAccepted, onSpawnFailed, markUnknown }
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
// A trusted runner wrapper calls this only after its process/turn close is
|
|
666
|
+
// observed. The synchronous host witness is not persisted verbatim: only
|
|
667
|
+
// exact receipt/generation/turn/accepted-dispatch identity is fsynced. A
|
|
668
|
+
// future global-fault transition may require this ID, but must separately
|
|
669
|
+
// reconcile every external effect before mutating active lanes.
|
|
670
|
+
confirmRunnerClosed({ receiptId, chatId, turnToken, generation,
|
|
671
|
+
verifyGeneration, assertRunnerClosed } = {}) {
|
|
672
|
+
const before = this.#completionCapacity()
|
|
673
|
+
if (!RECEIPT_ID.test(receiptId) || typeof chatId !== 'string' || !chatId ||
|
|
674
|
+
!Number.isSafeInteger(turnToken) || turnToken <= 0 ||
|
|
675
|
+
!GENERATION.test(generation) || typeof verifyGeneration !== 'function' ||
|
|
676
|
+
verifyGeneration.constructor?.name === 'AsyncFunction' ||
|
|
677
|
+
typeof assertRunnerClosed !== 'function' ||
|
|
678
|
+
assertRunnerClosed.constructor?.name === 'AsyncFunction') {
|
|
679
|
+
throw new TypeError('C2 runner close identity invalid')
|
|
680
|
+
}
|
|
681
|
+
const witness = this.#activeRuns.get(chatId)
|
|
682
|
+
const effectId = witness?.dispatchEffectId
|
|
683
|
+
const receipt = before.receipts.pending.find(item => item.id === receiptId)
|
|
684
|
+
const descriptor = effectId && this.#journal.effectDescriptor(effectId)
|
|
685
|
+
if (!receipt || receipt.chatId !== chatId || witness?.receiptId !== receiptId ||
|
|
686
|
+
witness?.turnToken !== turnToken ||
|
|
687
|
+
!this.#matchesRunningReceipts(before, witness, chatId, receiptId, turnToken) ||
|
|
688
|
+
descriptor?.domain !== 'atomicHandoff' || descriptor.type !== 'dispatch' ||
|
|
689
|
+
descriptor.scope !== chatId || descriptor.receiptId !== receiptId ||
|
|
690
|
+
descriptor.turnToken !== turnToken ||
|
|
691
|
+
this.#journal.effectStatus(effectId) !== 'acked' ||
|
|
692
|
+
before.state.lanes.turn?.[chatId]?.active !== turnToken ||
|
|
693
|
+
before.state.lanes.handoff?.[chatId]?.phase !== 'running' ||
|
|
694
|
+
before.state.lanes.handoff[chatId].turnToken !== turnToken ||
|
|
695
|
+
!isDeepStrictEqual(this.#coordinator.snapshot(), before.state) ||
|
|
696
|
+
verifyGeneration({ chatId, receiptId, turnToken, generation }) !== true ||
|
|
697
|
+
assertRunnerClosed({ chatId, receiptId, turnToken, generation }) !== true) {
|
|
698
|
+
throw new Error('C2 runner close lacks exact stopped runner authority')
|
|
699
|
+
}
|
|
700
|
+
try {
|
|
701
|
+
const closedAt = this.#now()
|
|
702
|
+
const evidenceId = this.#journal.recordRunnerClosed({ receiptId, chatId,
|
|
703
|
+
turnToken, generation, dispatchEffectId: effectId, closedAt })
|
|
704
|
+
if (this.#journal.byteLength >= this.#maxJournalBytes) this.#sealForCapacity()
|
|
705
|
+
return { evidenceId, receiptId, chatId, turnToken,
|
|
706
|
+
runnerGeneration: generation, runnerClosed: true }
|
|
707
|
+
} catch (error) { this.close(); throw error }
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
// One closed runner may write its process-local session ID only after a
|
|
711
|
+
// generation-bound reducer decision and fsynced effect attempts. Neither
|
|
712
|
+
// the session ID nor Store content enters the journal. A failed Store write
|
|
713
|
+
// or outcome ACK is ambiguous and seals this authority for reconciliation.
|
|
714
|
+
commitClosedRunnerSession({ receiptId, chatId, turnToken, generation,
|
|
715
|
+
sessionId = null, aborted, verifyGeneration, assertWriterLock, store,
|
|
716
|
+
usageDecision = null } = {}) {
|
|
717
|
+
const before = this.#completionCapacity()
|
|
718
|
+
if (!store || typeof store.setSessionAtomic !== 'function' ||
|
|
719
|
+
store.setSessionAtomic.constructor?.name === 'AsyncFunction' ||
|
|
720
|
+
typeof store.getSession !== 'function' ||
|
|
721
|
+
typeof assertWriterLock !== 'function' ||
|
|
722
|
+
assertWriterLock.constructor?.name === 'AsyncFunction' ||
|
|
723
|
+
(sessionId !== null && (typeof sessionId !== 'string' || !sessionId)) ||
|
|
724
|
+
(usageDecision !== null && (sessionId === null ||
|
|
725
|
+
typeof store.getSessionStats !== 'function' || !usageDecision ||
|
|
726
|
+
usageDecision.write !== true || !usageDecision.stats ||
|
|
727
|
+
typeof usageDecision.stats !== 'object' ||
|
|
728
|
+
usageDecision.stats.sessionId !== sessionId))) {
|
|
729
|
+
throw new TypeError('C2 closed session needs synchronous Store authority')
|
|
730
|
+
}
|
|
731
|
+
let locked = false
|
|
732
|
+
try { locked = assertWriterLock() === true } catch { /* lost lock is fatal */ }
|
|
733
|
+
if (!locked) {
|
|
734
|
+
this.close()
|
|
735
|
+
throw new Error('C2 closed session lost single-writer lock')
|
|
736
|
+
}
|
|
737
|
+
const witness = this.#activeRuns.get(chatId)
|
|
738
|
+
if (witness?.receiptId !== receiptId || witness.turnToken !== turnToken ||
|
|
739
|
+
!isDeepStrictEqual(this.#coordinator.snapshot(), before.state)) {
|
|
740
|
+
throw new Error('C2 closed session lacks live running dispatch')
|
|
741
|
+
}
|
|
742
|
+
const prepared = prepareClosedSessionEvent({ journal: this.#journal,
|
|
743
|
+
receiptId, chatId, turnToken, generation, sessionId, aborted,
|
|
744
|
+
verifyGeneration })
|
|
745
|
+
let result
|
|
746
|
+
let attemptedAt = null
|
|
747
|
+
const attempted = []
|
|
748
|
+
try {
|
|
749
|
+
result = this.#coordinator.prepare(prepared).commit()
|
|
750
|
+
if (assertWriterLock() !== true) {
|
|
751
|
+
throw new Error('C2 closed session lost single-writer lock')
|
|
752
|
+
}
|
|
753
|
+
if (result.effects.length) {
|
|
754
|
+
this.#externalEffectInFlight = true
|
|
755
|
+
attemptedAt = this.#now()
|
|
756
|
+
if (!Number.isSafeInteger(attemptedAt) || attemptedAt <= 0) {
|
|
757
|
+
throw new TypeError('C2 closed session attempt time invalid')
|
|
758
|
+
}
|
|
759
|
+
for (const effect of result.effects) {
|
|
760
|
+
this.#journal.beginEffectAttempt({ effectId: effect.effectId, attemptedAt })
|
|
761
|
+
attempted.push(effect.effectId)
|
|
762
|
+
}
|
|
763
|
+
if (assertWriterLock() !== true) {
|
|
764
|
+
throw new Error('C2 closed session lost single-writer lock')
|
|
765
|
+
}
|
|
766
|
+
const saved = store.setSessionAtomic(chatId, sessionId,
|
|
767
|
+
{ tainted: aborted === true,
|
|
768
|
+
...(usageDecision === null ? {} : { sessionStats: usageDecision.stats }) })
|
|
769
|
+
if (saved && typeof saved.then === 'function' ||
|
|
770
|
+
store.getSession(chatId) !== sessionId ||
|
|
771
|
+
(usageDecision !== null &&
|
|
772
|
+
!isDeepStrictEqual(store.getSessionStats?.(chatId), usageDecision.stats)) ||
|
|
773
|
+
assertWriterLock() !== true) {
|
|
774
|
+
throw new Error('C2 closed session Store write unconfirmed')
|
|
775
|
+
}
|
|
776
|
+
const resolvedAt = Math.max(attemptedAt, this.#now())
|
|
777
|
+
for (const effectId of attempted) {
|
|
778
|
+
this.#journal.recordEffectOutcome({ effectId, status: 'acked', resolvedAt })
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
if (this.#journal.snapshot().eventCount >= this.#maxEventCount ||
|
|
782
|
+
this.#journal.byteLength >= this.#maxJournalBytes) this.#sealForCapacity()
|
|
783
|
+
return { cursor: result.cursor, eventId: result.eventId,
|
|
784
|
+
effectIds: result.effects.map(effect => effect.effectId),
|
|
785
|
+
sessionWritten: sessionId !== null }
|
|
786
|
+
} catch (error) {
|
|
787
|
+
if (attemptedAt !== null) {
|
|
788
|
+
for (const effectId of attempted) {
|
|
789
|
+
if (this.#journal.effectStatus(effectId) !== 'attempting') continue
|
|
790
|
+
try {
|
|
791
|
+
this.#journal.recordEffectOutcome({ effectId, status: 'unknown',
|
|
792
|
+
resolvedAt: Math.max(attemptedAt, this.#now()) })
|
|
793
|
+
} catch { /* a failed journal write remains ambiguous on disk */ }
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
this.close()
|
|
797
|
+
throw error
|
|
798
|
+
} finally { this.#externalEffectInFlight = false }
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
registerReleaseRequestedCallback({ receiptId, generation, registeredAt } = {}) {
|
|
802
|
+
this.#completionCapacity()
|
|
803
|
+
const id = callbackRegistrationId({ receiptId, generation,
|
|
804
|
+
domain: 'atomicHandoff', kind: 'releaseRequestedCommit' })
|
|
805
|
+
const result = this.#journal.registerCallback({ id, receiptId, generation,
|
|
806
|
+
domain: 'atomicHandoff', kind: 'releaseRequestedCommit', ordinal: 0,
|
|
807
|
+
registeredAt })
|
|
808
|
+
if (this.#journal.byteLength >= this.#maxJournalBytes) this.#sealForCapacity()
|
|
809
|
+
return result
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
// The runner completion callback is registered before exposure. Its request
|
|
813
|
+
// invalidates the old turn and enters releasing in one durable transition.
|
|
814
|
+
commitReleaseRequested({ receiptId, generation, turnToken, payloadHandles } = {}) {
|
|
815
|
+
this.#completionCapacity()
|
|
816
|
+
if (!RECEIPT_ID.test(receiptId) || !GENERATION.test(generation) ||
|
|
817
|
+
!Number.isSafeInteger(turnToken) || turnToken <= 0) {
|
|
818
|
+
throw new TypeError('C2 release request identity invalid')
|
|
819
|
+
}
|
|
820
|
+
const before = this.#journal.snapshot()
|
|
821
|
+
const receipt = before.receipts.pending.find(item => item.id === receiptId)
|
|
822
|
+
if (!receipt) throw new Error('C2 release request lacks pending turn receipt')
|
|
823
|
+
const scope = receipt.chatId
|
|
824
|
+
const handoff = before.state.lanes.handoff?.[scope]
|
|
825
|
+
const turn = before.state.lanes.turn?.[scope]
|
|
826
|
+
const terminal = this.#terminalAwaitingSettlement.get(scope)
|
|
827
|
+
if (terminal && (terminal.receiptId !== receiptId ||
|
|
828
|
+
terminal.turnToken !== turnToken || !terminal.ready)) {
|
|
829
|
+
throw new Error('C2 terminal effect must finish before release request')
|
|
830
|
+
}
|
|
831
|
+
if (handoff?.phase !== 'running' || handoff.turnToken !== turnToken ||
|
|
832
|
+
turn?.active !== turnToken ||
|
|
833
|
+
!isDeepStrictEqual(this.#coordinator.snapshot(), before.state)) {
|
|
834
|
+
throw new Error('C2 release request is not durably running')
|
|
835
|
+
}
|
|
836
|
+
const { accepted } = queuedPayloads(before, scope, payloadHandles)
|
|
837
|
+
const id = callbackEventId({ receiptId, generation,
|
|
838
|
+
domain: 'atomicHandoff', kind: 'releaseRequestedCommit',
|
|
839
|
+
lookupReceipt: key => before.receipts.pending.find(item => item.id === key) || null,
|
|
840
|
+
lookupRegistration: key => this.#journal.lookupRegistration(key) })
|
|
841
|
+
if (this.#journal.hasEvent(id)) throw new Error('C2 release request already committed')
|
|
842
|
+
try {
|
|
843
|
+
const prepared = this.#coordinator.prepare({ id, domain: 'atomicHandoff', scope,
|
|
844
|
+
payload: { type: 'releaseRequestedCommit', turnToken,
|
|
845
|
+
acceptedReceiptIds: accepted },
|
|
846
|
+
validate: candidate => {
|
|
847
|
+
const effect = candidate.effects?.[0]
|
|
848
|
+
if (candidate.cursor !== before.cursor + 1 || candidate.state?.seq !== candidate.cursor ||
|
|
849
|
+
candidate.state.lanes.turn?.[scope]?.active !== null ||
|
|
850
|
+
candidate.state.lanes.handoff?.[scope]?.phase !== 'releasing' ||
|
|
851
|
+
candidate.state.lanes.handoff[scope].turnToken !== turnToken ||
|
|
852
|
+
!isDeepStrictEqual(candidate.state.lanes.handoff[scope].acceptedReceiptIds, accepted) ||
|
|
853
|
+
!isDeepStrictEqual(candidate.state.lanes.queue?.[scope]?.queue,
|
|
854
|
+
before.state.lanes.queue?.[scope]?.queue) ||
|
|
855
|
+
candidate.decision?.turnToken !== turnToken ||
|
|
856
|
+
!isDeepStrictEqual(candidate.decision?.acceptedReceiptIds, accepted) ||
|
|
857
|
+
candidate.effects.length !== 1 || effect.type !== 'attemptQueueRelease' ||
|
|
858
|
+
effect.origin !== 'handoff' ||
|
|
859
|
+
effect.effectId !== `sbot-c2-effect-v1:${Buffer.byteLength(id)}:${id}:0`) {
|
|
860
|
+
throw new Error('C2 release request result invalid')
|
|
861
|
+
}
|
|
862
|
+
} })
|
|
863
|
+
const result = prepared.commit()
|
|
864
|
+
this.#activeRuns.delete(scope)
|
|
865
|
+
if (this.#journal.snapshot().eventCount >= this.#maxEventCount ||
|
|
866
|
+
this.#journal.byteLength >= this.#maxJournalBytes) this.#sealForCapacity()
|
|
867
|
+
return result
|
|
868
|
+
} catch (error) { this.close(); throw error }
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
registerHandoffBeginCallback({ receiptId, generation, registeredAt } = {}) {
|
|
872
|
+
this.#completionCapacity()
|
|
873
|
+
const id = callbackRegistrationId({ receiptId, generation,
|
|
874
|
+
domain: 'atomicHandoff', kind: 'handoffBeginCommit' })
|
|
875
|
+
const result = this.#journal.registerCallback({ id, receiptId, generation,
|
|
876
|
+
domain: 'atomicHandoff', kind: 'handoffBeginCommit', ordinal: 0,
|
|
877
|
+
registeredAt })
|
|
878
|
+
if (this.#journal.byteLength >= this.#maxJournalBytes) this.#sealForCapacity()
|
|
879
|
+
return result
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
// A successor is only eligible in the same process that committed its
|
|
883
|
+
// queue release. The journal cannot recreate the removed jobs' raw bodies;
|
|
884
|
+
// a reopen has no scheduled capability and must interrupt instead.
|
|
885
|
+
commitHandoffBegin({ receiptId, generation, priorTurnToken, payloadHandles } = {}) {
|
|
886
|
+
this.#completionCapacity()
|
|
887
|
+
if (!RECEIPT_ID.test(receiptId) || !GENERATION.test(generation) ||
|
|
888
|
+
!Number.isSafeInteger(priorTurnToken) || priorTurnToken <= 0 ||
|
|
889
|
+
!(payloadHandles instanceof C2PayloadHandles) || payloadHandles.blocked ||
|
|
890
|
+
typeof payloadHandles.peekHandoff !== 'function') {
|
|
891
|
+
throw new TypeError('C2 successor dispatch identity or handles invalid')
|
|
892
|
+
}
|
|
893
|
+
const before = this.#journal.snapshot()
|
|
894
|
+
const receipt = before.receipts.pending.find(item => item.id === receiptId)
|
|
895
|
+
if (!receipt) throw new Error('C2 successor dispatch lacks pending receipt')
|
|
896
|
+
const scope = receipt.chatId
|
|
897
|
+
if (this.#terminalAwaitingSettlement.has(scope)) {
|
|
898
|
+
throw new Error('C2 prior terminal receipt has not settled')
|
|
899
|
+
}
|
|
900
|
+
const scheduled = this.#scheduledHandoffs.get(scope)
|
|
901
|
+
const handoff = before.state.lanes.handoff?.[scope]
|
|
902
|
+
if (!scheduled || scheduled.nextJobId !== receiptId ||
|
|
903
|
+
scheduled.cursor > before.cursor || !this.#journal.hasEvent(scheduled.eventId) ||
|
|
904
|
+
scheduled.priorTurnToken !== priorTurnToken ||
|
|
905
|
+
handoff?.phase !== 'handoff' || handoff.turnToken !== priorTurnToken ||
|
|
906
|
+
handoff.nextJobId !== receiptId || before.state.lanes.turn?.[scope]?.active !== null ||
|
|
907
|
+
this.#journal.effectStatus(scheduled.scheduleEffectId) !== 'acked' ||
|
|
908
|
+
!isDeepStrictEqual(this.#coordinator.snapshot(), before.state)) {
|
|
909
|
+
throw new Error('C2 successor dispatch lacks committed scheduled handoff')
|
|
910
|
+
}
|
|
911
|
+
payloadHandles.peekHandoff(receiptId)
|
|
912
|
+
const liveIds = new Set(payloadHandles.snapshot().receiptIds)
|
|
913
|
+
if (!scheduled.jobReceiptIds.length ||
|
|
914
|
+
!scheduled.jobReceiptIds.every(id => liveIds.has(id)) ||
|
|
915
|
+
!isDeepStrictEqual(handoff.acceptedReceiptIds, scheduled.batchReceiptIds)) {
|
|
916
|
+
throw new Error('C2 successor dispatch payload handles missing')
|
|
917
|
+
}
|
|
918
|
+
const id = callbackEventId({ receiptId, generation,
|
|
919
|
+
domain: 'atomicHandoff', kind: 'handoffBeginCommit',
|
|
920
|
+
lookupReceipt: key => before.receipts.pending.find(item => item.id === key) || null,
|
|
921
|
+
lookupRegistration: key => this.#journal.lookupRegistration(key) })
|
|
922
|
+
if (this.#journal.hasEvent(id)) throw new Error('C2 successor dispatch already committed')
|
|
923
|
+
try {
|
|
924
|
+
const prepared = this.#coordinator.prepare({ id, domain: 'atomicHandoff', scope,
|
|
925
|
+
payload: { type: 'handoffBeginCommit', priorTurnToken, nextJobId: receiptId,
|
|
926
|
+
batchReceiptIds: [...scheduled.batchReceiptIds] },
|
|
927
|
+
validate: candidate => {
|
|
928
|
+
const token = candidate.decision?.turnToken
|
|
929
|
+
const [clear, dispatch] = candidate.effects || []
|
|
930
|
+
if (candidate.cursor !== before.cursor + 1 || candidate.state?.seq !== candidate.cursor ||
|
|
931
|
+
!Number.isSafeInteger(token) || token <= priorTurnToken ||
|
|
932
|
+
candidate.decision.nextJobId !== receiptId ||
|
|
933
|
+
!isDeepStrictEqual(candidate.decision.batchReceiptIds,
|
|
934
|
+
scheduled.batchReceiptIds) ||
|
|
935
|
+
candidate.state.lanes.turn?.[scope]?.active !== token ||
|
|
936
|
+
candidate.state.lanes.handoff?.[scope]?.phase !== 'running' ||
|
|
937
|
+
candidate.state.lanes.handoff[scope].turnToken !== token ||
|
|
938
|
+
candidate.state.lanes.handoff[scope].nextJobId !== null ||
|
|
939
|
+
!isDeepStrictEqual(candidate.state.lanes.handoff[scope].acceptedReceiptIds,
|
|
940
|
+
scheduled.batchReceiptIds.length > 1 ? scheduled.batchReceiptIds : []) ||
|
|
941
|
+
!isDeepStrictEqual(candidate.state.lanes.queue?.[scope]?.queue,
|
|
942
|
+
before.state.lanes.queue?.[scope]?.queue) ||
|
|
943
|
+
candidate.effects.length !== 2 || clear.type !== 'clearHandoff' ||
|
|
944
|
+
clear.origin !== 'handoff' || dispatch.type !== 'dispatch' ||
|
|
945
|
+
dispatch.origin !== 'handoff' || dispatch.jobId !== receiptId ||
|
|
946
|
+
dispatch.turnToken !== token ||
|
|
947
|
+
[clear, dispatch].some((effect, index) =>
|
|
948
|
+
effect.effectId !== `sbot-c2-effect-v1:${Buffer.byteLength(id)}:${id}:${index}`)) {
|
|
949
|
+
throw new Error('C2 successor dispatch result invalid')
|
|
950
|
+
}
|
|
951
|
+
} })
|
|
952
|
+
const result = prepared.commit()
|
|
953
|
+
this.#retainActiveRun(scope, result, receiptId,
|
|
954
|
+
scheduled.jobReceiptIds, scheduled.batchReceiptIds)
|
|
955
|
+
this.#scheduledHandoffs.delete(scope)
|
|
956
|
+
if (this.#journal.snapshot().eventCount >= this.#maxEventCount ||
|
|
957
|
+
this.#journal.byteLength >= this.#maxJournalBytes) this.#sealForCapacity()
|
|
958
|
+
return { ...result, jobReceiptIds: [...scheduled.jobReceiptIds],
|
|
959
|
+
batchReceiptIds: [...scheduled.batchReceiptIds] }
|
|
960
|
+
} catch (error) { this.close(); throw error }
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
// Register the callback identity before exposing a runner-close or queue
|
|
964
|
+
// release callback. Registration alone does not advance reducer state.
|
|
965
|
+
registerQueueReleaseCallback({ receiptId, generation, registeredAt } = {}) {
|
|
966
|
+
this.#completionCapacity()
|
|
967
|
+
const id = callbackRegistrationId({ receiptId, generation,
|
|
968
|
+
domain: 'atomicHandoff', kind: 'queueReleaseCommit' })
|
|
969
|
+
const result = this.#journal.registerCallback({ id, receiptId, generation,
|
|
970
|
+
domain: 'atomicHandoff', kind: 'queueReleaseCommit', ordinal: 0,
|
|
971
|
+
registeredAt })
|
|
972
|
+
if (this.#journal.byteLength >= this.#maxJournalBytes) this.#sealForCapacity()
|
|
973
|
+
return result
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
// The handoff lane must already be durably in `releasing` for this token.
|
|
977
|
+
// This call commits one queue+handoff transition. It never dispatches or
|
|
978
|
+
// acknowledges the returned effects; the caller owns those later stages.
|
|
979
|
+
commitQueueRelease({ receiptId, generation, turnToken, paused, payloadHandles } = {}) {
|
|
980
|
+
this.#completionCapacity()
|
|
981
|
+
if (!RECEIPT_ID.test(receiptId) || !GENERATION.test(generation) ||
|
|
982
|
+
!Number.isSafeInteger(turnToken) || turnToken <= 0 || typeof paused !== 'boolean') {
|
|
983
|
+
throw new TypeError('C2 atomic release identity invalid')
|
|
984
|
+
}
|
|
985
|
+
const before = this.#journal.snapshot()
|
|
986
|
+
const receipt = before.receipts.pending.find(item => item.id === receiptId)
|
|
987
|
+
if (!receipt) throw new Error('C2 atomic release lacks pending turn receipt')
|
|
988
|
+
const scope = receipt.chatId
|
|
989
|
+
const handoff = before.state.lanes.handoff?.[scope]
|
|
990
|
+
const terminal = this.#terminalAwaitingSettlement.get(scope)
|
|
991
|
+
if (terminal && (terminal.receiptId !== receiptId ||
|
|
992
|
+
terminal.turnToken !== turnToken || !terminal.ready)) {
|
|
993
|
+
throw new Error('C2 terminal effect must finish before queue release')
|
|
994
|
+
}
|
|
995
|
+
if (!handoff || handoff.phase !== 'releasing' || handoff.turnToken !== turnToken ||
|
|
996
|
+
!isDeepStrictEqual(this.#coordinator.snapshot(), before.state)) {
|
|
997
|
+
throw new Error('C2 atomic release is not durably releasing')
|
|
998
|
+
}
|
|
999
|
+
const { queue, accepted } = queuedPayloads(before, scope, payloadHandles)
|
|
1000
|
+
if (!isDeepStrictEqual(handoff.acceptedReceiptIds, accepted)) {
|
|
1001
|
+
throw new Error('C2 atomic release accepted receipts differ from queue')
|
|
1002
|
+
}
|
|
1003
|
+
const releasedCount = paused ? queue.length : queueRunLen(queue)
|
|
1004
|
+
const remaining = queue.slice(releasedCount)
|
|
1005
|
+
const id = callbackEventId({ receiptId, generation,
|
|
1006
|
+
domain: 'atomicHandoff', kind: 'queueReleaseCommit',
|
|
1007
|
+
lookupReceipt: key => before.receipts.pending.find(item => item.id === key) || null,
|
|
1008
|
+
lookupRegistration: key => this.#journal.lookupRegistration(key) })
|
|
1009
|
+
if (this.#journal.hasEvent(id)) throw new Error('C2 atomic release already committed')
|
|
1010
|
+
try {
|
|
1011
|
+
const prepared = this.#coordinator.prepare({ id, domain: 'atomicHandoff', scope,
|
|
1012
|
+
payload: { type: 'queueReleaseCommit', turnToken, paused },
|
|
1013
|
+
validate: candidate => {
|
|
1014
|
+
if (candidate.cursor !== before.cursor + 1 ||
|
|
1015
|
+
candidate.state?.seq !== candidate.cursor ||
|
|
1016
|
+
!isDeepStrictEqual(candidate.state.lanes.queue?.[scope]?.queue, remaining) ||
|
|
1017
|
+
candidate.decision?.queueRunLen !== queueRunLen(remaining) ||
|
|
1018
|
+
!isDeepStrictEqual(candidate.decision?.jobReceiptIds,
|
|
1019
|
+
paused ? [] : queue.slice(0, releasedCount).map(item =>
|
|
1020
|
+
item.id.slice('receipt:job:'.length))) ||
|
|
1021
|
+
!isDeepStrictEqual(candidate.decision?.batchReceiptIds,
|
|
1022
|
+
paused ? [] : queueCohortReceiptIds(queue.slice(0, releasedCount))) ||
|
|
1023
|
+
candidate.decision?.visibleQueue !== remaining.filter(item =>
|
|
1024
|
+
!item.quiet && !item.polled).length ||
|
|
1025
|
+
!Array.isArray(candidate.effects) ||
|
|
1026
|
+
candidate.effects.some((effect, index) =>
|
|
1027
|
+
effect.effectId !== `sbot-c2-effect-v1:${Buffer.byteLength(id)}:${id}:${index}`)) {
|
|
1028
|
+
throw new Error('C2 atomic release result invalid')
|
|
1029
|
+
}
|
|
1030
|
+
const release = candidate.decision.queueRelease
|
|
1031
|
+
const next = candidate.decision.nextJobId
|
|
1032
|
+
if (paused) {
|
|
1033
|
+
if (next !== null || candidate.state.lanes.handoff?.[scope]?.phase !== 'idle' ||
|
|
1034
|
+
candidate.state.lanes.handoff[scope].nextJobId !== null ||
|
|
1035
|
+
candidate.effects.some(effect => effect.type !== 'dropQueued' || effect.origin !== 'queue') ||
|
|
1036
|
+
(queue.length > 0 && (candidate.effects.length !== 1 ||
|
|
1037
|
+
release?.type !== 'dropQueued' ||
|
|
1038
|
+
!isDeepStrictEqual(release.batch, queue.map(item => item.id)))) ||
|
|
1039
|
+
(queue.length === 0 && (release !== null || candidate.effects.length))) {
|
|
1040
|
+
throw new Error('C2 atomic paused release mismatch')
|
|
1041
|
+
}
|
|
1042
|
+
} else if (next !== null) {
|
|
1043
|
+
const anchor = release?.anchor
|
|
1044
|
+
if (release?.type !== 'dispatch' ||
|
|
1045
|
+
!queue.slice(0, releasedCount).some(item => item.id === anchor) ||
|
|
1046
|
+
next !== anchor.slice('receipt:job:'.length) ||
|
|
1047
|
+
candidate.state.lanes.handoff?.[scope]?.phase !== 'handoff' ||
|
|
1048
|
+
candidate.state.lanes.handoff[scope].nextJobId !== next ||
|
|
1049
|
+
!isDeepStrictEqual(candidate.state.lanes.handoff[scope].acceptedReceiptIds,
|
|
1050
|
+
candidate.decision.batchReceiptIds) ||
|
|
1051
|
+
!isDeepStrictEqual(candidate.effects.map(effect =>
|
|
1052
|
+
[effect.type, effect.origin, effect.jobId ?? null]),
|
|
1053
|
+
[['holdHandoff', 'handoff', null], ['scheduleDispatch', 'handoff', next]])) {
|
|
1054
|
+
throw new Error('C2 atomic next job mismatch')
|
|
1055
|
+
}
|
|
1056
|
+
} else if (release !== null || candidate.effects.length ||
|
|
1057
|
+
candidate.state.lanes.handoff?.[scope]?.phase !== 'idle') {
|
|
1058
|
+
throw new Error('C2 atomic empty release mismatch')
|
|
1059
|
+
}
|
|
1060
|
+
} })
|
|
1061
|
+
const result = prepared.commit()
|
|
1062
|
+
const terminal = this.#terminalAwaitingSettlement.get(scope)
|
|
1063
|
+
if (terminal?.receiptId === receiptId && terminal.turnToken === turnToken) {
|
|
1064
|
+
terminal.releaseCommitted = true
|
|
1065
|
+
}
|
|
1066
|
+
const nextJobId = result.decision.nextJobId
|
|
1067
|
+
if (nextJobId) {
|
|
1068
|
+
const effect = result.effects.find(item =>
|
|
1069
|
+
item.type === 'scheduleDispatch' && item.jobId === nextJobId)
|
|
1070
|
+
if (!effect) throw new Error('C2 atomic release schedule effect missing')
|
|
1071
|
+
this.#scheduledHandoffs.set(scope, { eventId: id, cursor: result.cursor,
|
|
1072
|
+
nextJobId, priorTurnToken: turnToken, scheduleEffectId: effect.effectId,
|
|
1073
|
+
jobReceiptIds: queue.slice(0, releasedCount).map(item =>
|
|
1074
|
+
item.id.slice('receipt:job:'.length)),
|
|
1075
|
+
batchReceiptIds: queueCohortReceiptIds(queue.slice(0, releasedCount)) })
|
|
1076
|
+
} else this.#scheduledHandoffs.delete(scope)
|
|
1077
|
+
if (this.#journal.snapshot().eventCount >= this.#maxEventCount ||
|
|
1078
|
+
this.#journal.byteLength >= this.#maxJournalBytes) this.#sealForCapacity()
|
|
1079
|
+
return { ...result, releasedJobReceiptIds: queue.slice(0, releasedCount).map(item =>
|
|
1080
|
+
item.id.slice('receipt:job:'.length)) }
|
|
1081
|
+
} catch (error) {
|
|
1082
|
+
this.close()
|
|
1083
|
+
throw error
|
|
1084
|
+
}
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
// Isolated v2 terminal boundary. The callback is registered before the
|
|
1088
|
+
// runner is exposed, and only this process can prove which pending receipt
|
|
1089
|
+
// was actually dispatched. No text or reply context enters the journal.
|
|
1090
|
+
registerTerminalCallback({ receiptId, generation, registeredAt } = {}) {
|
|
1091
|
+
this.#completionCapacity()
|
|
1092
|
+
const id = callbackRegistrationId({ receiptId, generation,
|
|
1093
|
+
domain: 'terminal', kind: 'finalizeEffectCommit' })
|
|
1094
|
+
const result = this.#journal.registerCallback({ id, receiptId, generation,
|
|
1095
|
+
domain: 'terminal', kind: 'finalizeEffectCommit', ordinal: 0, registeredAt })
|
|
1096
|
+
if (this.#journal.byteLength >= this.#maxJournalBytes) this.#sealForCapacity()
|
|
1097
|
+
return result
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
commitTerminalEffect({ receiptId, generation, turnToken, payload } = {}) {
|
|
1101
|
+
this.#completionCapacity()
|
|
1102
|
+
if (!RECEIPT_ID.test(receiptId) || !GENERATION.test(generation) ||
|
|
1103
|
+
!Number.isSafeInteger(turnToken) || turnToken <= 0 ||
|
|
1104
|
+
!payload || typeof payload !== 'object' || Array.isArray(payload) ||
|
|
1105
|
+
payload.type !== 'finalizeEffectCommit' || payload.receiptId !== receiptId ||
|
|
1106
|
+
payload.turnToken !== turnToken) {
|
|
1107
|
+
throw new TypeError('C2 terminal identity invalid')
|
|
1108
|
+
}
|
|
1109
|
+
const before = this.#journal.snapshot()
|
|
1110
|
+
const receipt = before.receipts.pending.find(item => item.id === receiptId)
|
|
1111
|
+
const scope = receipt?.chatId
|
|
1112
|
+
const witness = this.#activeRuns.get(scope)
|
|
1113
|
+
const batchReceiptIds = witness?.batchReceiptIds?.length > 1
|
|
1114
|
+
? witness.batchReceiptIds : null
|
|
1115
|
+
if (!receipt || payload.quiet !== receipt.quiet ||
|
|
1116
|
+
witness?.receiptId !== receiptId || witness.turnToken !== turnToken ||
|
|
1117
|
+
!isDeepStrictEqual(before.state.lanes.handoff?.[scope]?.acceptedReceiptIds,
|
|
1118
|
+
batchReceiptIds || []) ||
|
|
1119
|
+
(batchReceiptIds
|
|
1120
|
+
? !isDeepStrictEqual(payload.batchReceiptIds, batchReceiptIds) ||
|
|
1121
|
+
payload.replyTargetReceiptId !== receiptId ||
|
|
1122
|
+
batchReceiptIds.some(id =>
|
|
1123
|
+
before.receipts.pending.find(item => item.id === id)?.chatId !== scope)
|
|
1124
|
+
: payload.batchReceiptIds !== undefined ||
|
|
1125
|
+
payload.replyTargetReceiptId !== undefined) ||
|
|
1126
|
+
this.#journal.effectStatus(witness.dispatchEffectId) !== 'acked' ||
|
|
1127
|
+
before.state.lanes.turn?.[scope]?.active !== turnToken ||
|
|
1128
|
+
before.state.lanes.handoff?.[scope]?.phase !== 'running' ||
|
|
1129
|
+
before.state.lanes.handoff[scope].turnToken !== turnToken ||
|
|
1130
|
+
!isDeepStrictEqual(this.#coordinator.snapshot(), before.state)) {
|
|
1131
|
+
throw new Error('C2 terminal lacks exact running receipt authority')
|
|
1132
|
+
}
|
|
1133
|
+
const id = callbackEventId({ receiptId, generation,
|
|
1134
|
+
domain: 'terminal', kind: 'finalizeEffectCommit',
|
|
1135
|
+
lookupReceipt: key => before.receipts.pending.find(item => item.id === key) || null,
|
|
1136
|
+
lookupRegistration: key => this.#journal.lookupRegistration(key) })
|
|
1137
|
+
if (this.#journal.hasEvent(id)) throw new Error('C2 terminal already committed')
|
|
1138
|
+
try {
|
|
1139
|
+
const prepared = this.#coordinator.prepare({ id, domain: 'terminal', scope,
|
|
1140
|
+
payload, validate: candidate => {
|
|
1141
|
+
const effect = candidate.effects?.at(-1)
|
|
1142
|
+
const descriptor = candidate.effectDescriptors?.at(-1)
|
|
1143
|
+
const terminal = candidate.state?.lanes?.terminal?.[scope]
|
|
1144
|
+
const expectedType = receipt.quiet || terminal?.outcome?.silent
|
|
1145
|
+
? 'quietCompletion' : 'finalReply'
|
|
1146
|
+
if (candidate.cursor !== before.cursor + 1 ||
|
|
1147
|
+
candidate.state?.seq !== candidate.cursor ||
|
|
1148
|
+
terminal?.finalized !== true || terminal.pendingEffectIds?.at(-1) !== effect?.effectId ||
|
|
1149
|
+
!isDeepStrictEqual(terminal?.batchReceiptIds,
|
|
1150
|
+
batchReceiptIds || undefined) ||
|
|
1151
|
+
terminal?.replyTargetReceiptId !==
|
|
1152
|
+
(batchReceiptIds ? receiptId : undefined) ||
|
|
1153
|
+
!isDeepStrictEqual(candidate.decision?.batchReceiptIds,
|
|
1154
|
+
batchReceiptIds || undefined) ||
|
|
1155
|
+
candidate.decision?.replyTargetReceiptId !==
|
|
1156
|
+
(batchReceiptIds ? receiptId : undefined) ||
|
|
1157
|
+
!['quietCompletion', 'finalReply'].includes(effect?.type) ||
|
|
1158
|
+
effect.type !== expectedType || descriptor?.type !== effect.type ||
|
|
1159
|
+
descriptor.domain !== 'terminal' || descriptor.scope !== scope ||
|
|
1160
|
+
descriptor.turnToken !== turnToken || descriptor.receiptId !== receiptId ||
|
|
1161
|
+
descriptor.targetId !== null) {
|
|
1162
|
+
throw new Error('C2 terminal effect result invalid')
|
|
1163
|
+
}
|
|
1164
|
+
} })
|
|
1165
|
+
const result = prepared.commit()
|
|
1166
|
+
this.#terminalAwaitingSettlement.set(scope, { receiptId, turnToken,
|
|
1167
|
+
generation, effectId: result.effects.at(-1).effectId,
|
|
1168
|
+
batchReceiptIds: batchReceiptIds ? [...batchReceiptIds] : null,
|
|
1169
|
+
type: result.effects.at(-1).type, ready: false, releaseCommitted: false })
|
|
1170
|
+
if (this.#journal.snapshot().eventCount >= this.#maxEventCount ||
|
|
1171
|
+
this.#journal.byteLength >= this.#maxJournalBytes) this.#sealForCapacity()
|
|
1172
|
+
return { ...result, terminalEffectId: result.effects.at(-1).effectId }
|
|
1173
|
+
} catch (error) { this.close(); throw error }
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
// The local Store/verification projection is an external effect too: its
|
|
1177
|
+
// attempt is durable before the host writes, and only a fresh process-local
|
|
1178
|
+
// claim may turn the observed write into an ACK.
|
|
1179
|
+
performLocalTerminalEffect({ effect, receiptId, chatId, turnToken,
|
|
1180
|
+
generation, verifyGeneration, assertWriterLock, applyLocalEffect } = {}) {
|
|
1181
|
+
const before = this.#completionCapacity()
|
|
1182
|
+
const effectId = effect?.effectId
|
|
1183
|
+
const descriptor = this.#journal.effectDescriptor(effectId)
|
|
1184
|
+
const capability = this.#terminalAwaitingSettlement.get(chatId)
|
|
1185
|
+
const terminal = before.state.lanes.terminal?.[chatId]
|
|
1186
|
+
if (!effect || typeof effect !== 'object' || Array.isArray(effect) ||
|
|
1187
|
+
!['clearTainted', 'clearExhausted', 'verifyTurn'].includes(effect.type) ||
|
|
1188
|
+
!RECEIPT_ID.test(receiptId || '') || !GENERATION.test(generation || '') ||
|
|
1189
|
+
typeof chatId !== 'string' || !chatId ||
|
|
1190
|
+
!Number.isSafeInteger(turnToken) || turnToken <= 0 ||
|
|
1191
|
+
typeof verifyGeneration !== 'function' ||
|
|
1192
|
+
verifyGeneration.constructor?.name === 'AsyncFunction' ||
|
|
1193
|
+
typeof assertWriterLock !== 'function' ||
|
|
1194
|
+
assertWriterLock.constructor?.name === 'AsyncFunction' ||
|
|
1195
|
+
typeof applyLocalEffect !== 'function' ||
|
|
1196
|
+
typeof applyLocalEffect.claim !== 'function' ||
|
|
1197
|
+
applyLocalEffect.constructor?.name === 'AsyncFunction' ||
|
|
1198
|
+
assertWriterLock() !== true ||
|
|
1199
|
+
verifyGeneration({ chatId, receiptId, turnToken, generation }) !== true ||
|
|
1200
|
+
before.receipts.pending.find(item => item.id === receiptId)?.chatId !== chatId ||
|
|
1201
|
+
before.state.lanes.turn?.[chatId]?.active !== turnToken ||
|
|
1202
|
+
before.state.lanes.handoff?.[chatId]?.phase !== 'running' ||
|
|
1203
|
+
before.state.lanes.handoff[chatId].turnToken !== turnToken ||
|
|
1204
|
+
capability?.receiptId !== receiptId || capability.turnToken !== turnToken ||
|
|
1205
|
+
capability.generation !== generation || capability.ready ||
|
|
1206
|
+
terminal?.finalized !== true ||
|
|
1207
|
+
!terminal.pendingEffectIds?.includes(effectId) ||
|
|
1208
|
+
descriptor?.domain !== 'terminal' || descriptor.scope !== chatId ||
|
|
1209
|
+
descriptor.type !== effect.type || descriptor.receiptId !== receiptId ||
|
|
1210
|
+
descriptor.turnToken !== turnToken ||
|
|
1211
|
+
this.#journal.effectStatus(effectId) !== 'pending') {
|
|
1212
|
+
throw new Error('C2 local effect lacks exact live authority')
|
|
1213
|
+
}
|
|
1214
|
+
const attemptedAt = this.#now()
|
|
1215
|
+
if (!Number.isSafeInteger(attemptedAt) || attemptedAt <= 0) {
|
|
1216
|
+
throw new TypeError('C2 local effect time invalid')
|
|
1217
|
+
}
|
|
1218
|
+
this.#externalEffectInFlight = true
|
|
1219
|
+
try {
|
|
1220
|
+
this.#journal.beginEffectAttempt({ effectId, attemptedAt })
|
|
1221
|
+
let confirmed = false
|
|
1222
|
+
try {
|
|
1223
|
+
applyLocalEffect.claim(effectId)
|
|
1224
|
+
confirmed = applyLocalEffect(effect) === true &&
|
|
1225
|
+
assertWriterLock() === true &&
|
|
1226
|
+
verifyGeneration({ chatId, receiptId, turnToken, generation }) === true
|
|
1227
|
+
} catch { /* A write may have happened; its outcome is ambiguous. */ }
|
|
1228
|
+
const now = this.#now()
|
|
1229
|
+
const resolvedAt = Number.isSafeInteger(now) && now >= attemptedAt
|
|
1230
|
+
? now : attemptedAt
|
|
1231
|
+
this.#journal.recordEffectOutcome({ effectId,
|
|
1232
|
+
status: confirmed ? 'acked' : 'unknown', resolvedAt })
|
|
1233
|
+
if (!confirmed) {
|
|
1234
|
+
throw new Error('C2 local effect outcome unknown; manual recovery required')
|
|
1235
|
+
}
|
|
1236
|
+
return { effectId, status: 'acked' }
|
|
1237
|
+
} catch (error) { this.close(); throw error }
|
|
1238
|
+
finally { this.#externalEffectInFlight = false }
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1241
|
+
// Classify a closed runner that cannot be delivered. This is an internal
|
|
1242
|
+
// zero-effect transition: the receipt and running turn remain unresolved,
|
|
1243
|
+
// and this process closes ingress for explicit fault recovery. No error
|
|
1244
|
+
// text, session ID, or reply target is written into the journal.
|
|
1245
|
+
commitUnresolvedClassification({ receiptId, generation, turnToken, status,
|
|
1246
|
+
payload, verifyGeneration, assertWriterLock } = {}) {
|
|
1247
|
+
this.#completionCapacity()
|
|
1248
|
+
if (!RECEIPT_ID.test(receiptId || '') || !GENERATION.test(generation || '') ||
|
|
1249
|
+
!Number.isSafeInteger(turnToken) || turnToken <= 0 ||
|
|
1250
|
+
!['error', 'empty', 'stopped'].includes(status) ||
|
|
1251
|
+
!payload || typeof payload !== 'object' || Array.isArray(payload) ||
|
|
1252
|
+
payload.type !== 'finalizeEffectCommit' || payload.receiptId !== receiptId ||
|
|
1253
|
+
payload.turnToken !== turnToken ||
|
|
1254
|
+
typeof verifyGeneration !== 'function' ||
|
|
1255
|
+
verifyGeneration.constructor?.name === 'AsyncFunction' ||
|
|
1256
|
+
typeof assertWriterLock !== 'function' ||
|
|
1257
|
+
assertWriterLock.constructor?.name === 'AsyncFunction') {
|
|
1258
|
+
throw new TypeError('C2 unresolved classification needs closed runner identity')
|
|
1259
|
+
}
|
|
1260
|
+
try {
|
|
1261
|
+
const before = this.#journal.snapshot()
|
|
1262
|
+
const receipt = before.receipts.pending.find(item => item.id === receiptId)
|
|
1263
|
+
const scope = receipt?.chatId
|
|
1264
|
+
const witness = this.#activeRuns.get(scope)
|
|
1265
|
+
const closeEvidenceId = `sbot-c2-runner-closed-v1:${receiptId}:${generation}`
|
|
1266
|
+
const proof = this.#journal.lookupRunnerClosed(closeEvidenceId)
|
|
1267
|
+
const dispatch = proof && this.#journal.effectDescriptor(proof.dispatchEffectId)
|
|
1268
|
+
const registrationId = callbackRegistrationId({ receiptId, generation,
|
|
1269
|
+
domain: 'terminal', kind: 'finalizeEffectCommit' })
|
|
1270
|
+
const registration = this.#journal.lookupRegistration(registrationId)
|
|
1271
|
+
const cohortIds = witness?.batchReceiptIds || []
|
|
1272
|
+
const batch = cohortIds.length > 1
|
|
1273
|
+
if (this.blocked || assertWriterLock() !== true || !receipt ||
|
|
1274
|
+
payload.quiet !== receipt.quiet ||
|
|
1275
|
+
witness?.receiptId !== receiptId || witness.turnToken !== turnToken ||
|
|
1276
|
+
!witness.jobReceiptIds.includes(receiptId) ||
|
|
1277
|
+
!isDeepStrictEqual(witness.jobReceiptIds, cohortIds) ||
|
|
1278
|
+
!isDeepStrictEqual(before.state.lanes.handoff?.[scope]?.acceptedReceiptIds,
|
|
1279
|
+
batch ? cohortIds : []) ||
|
|
1280
|
+
(batch
|
|
1281
|
+
? !isDeepStrictEqual(payload.batchReceiptIds, cohortIds) ||
|
|
1282
|
+
payload.replyTargetReceiptId !== receiptId ||
|
|
1283
|
+
cohortIds.some(id =>
|
|
1284
|
+
before.receipts.pending.find(item => item.id === id)?.chatId !== scope)
|
|
1285
|
+
: payload.batchReceiptIds !== undefined ||
|
|
1286
|
+
payload.replyTargetReceiptId !== undefined) ||
|
|
1287
|
+
proof?.receiptId !== receiptId || proof.chatId !== scope ||
|
|
1288
|
+
proof.turnToken !== turnToken || proof.generation !== generation ||
|
|
1289
|
+
dispatch?.type !== 'dispatch' || dispatch.scope !== scope ||
|
|
1290
|
+
dispatch.receiptId !== receiptId || dispatch.turnToken !== turnToken ||
|
|
1291
|
+
this.#journal.effectStatus(proof.dispatchEffectId) !== 'acked' ||
|
|
1292
|
+
registration?.receiptId !== receiptId ||
|
|
1293
|
+
registration.generation !== generation ||
|
|
1294
|
+
registration.domain !== 'terminal' ||
|
|
1295
|
+
registration.kind !== 'finalizeEffectCommit' ||
|
|
1296
|
+
!this.#journal.hasEvent(`sbot-c2-session-close-v1:${receiptId}:${generation}`) ||
|
|
1297
|
+
before.state.lanes.session?.[scope]?.closeSeen !== true ||
|
|
1298
|
+
before.state.lanes.turn?.[scope]?.active !== turnToken ||
|
|
1299
|
+
before.state.lanes.handoff?.[scope]?.phase !== 'running' ||
|
|
1300
|
+
before.state.lanes.handoff[scope].turnToken !== turnToken ||
|
|
1301
|
+
!isDeepStrictEqual(this.#coordinator.snapshot(), before.state) ||
|
|
1302
|
+
[...before.effects].some(([effectId, effectStatus]) =>
|
|
1303
|
+
cohortIds.includes(this.#journal.effectDescriptor(effectId)?.receiptId) &&
|
|
1304
|
+
effectStatus !== 'acked') ||
|
|
1305
|
+
verifyGeneration({ chatId: scope, receiptId, turnToken,
|
|
1306
|
+
generation }) !== true) {
|
|
1307
|
+
throw new Error('C2 unresolved runner lacks settled durable proof')
|
|
1308
|
+
}
|
|
1309
|
+
const eventId = `sbot-c2-unresolved-v1:${receiptId}:${turnToken}:${generation}:${status}`
|
|
1310
|
+
if (this.#journal.hasEvent(eventId)) throw new Error('C2 unresolved already classified')
|
|
1311
|
+
const terminalPayload = { ...payload, type: 'finalize' }
|
|
1312
|
+
delete terminalPayload.receiptId
|
|
1313
|
+
delete terminalPayload.turnToken
|
|
1314
|
+
delete terminalPayload.quiet
|
|
1315
|
+
if (engineTerminalStep({ finalized: false, outcome: null },
|
|
1316
|
+
terminalPayload).state.outcome.status !== status) {
|
|
1317
|
+
throw new Error('C2 unresolved status differs from runner projection')
|
|
1318
|
+
}
|
|
1319
|
+
const prepared = this.#coordinator.prepare({ id: eventId,
|
|
1320
|
+
domain: 'terminal', scope,
|
|
1321
|
+
payload: { ...payload, type: 'classifyUnresolved',
|
|
1322
|
+
runnerGeneration: generation,
|
|
1323
|
+
runnerClosed: true, closeEvidenceId },
|
|
1324
|
+
validate: candidate => {
|
|
1325
|
+
if (candidate.eventId !== eventId ||
|
|
1326
|
+
candidate.cursor !== before.cursor + 1 ||
|
|
1327
|
+
candidate.decision?.outcome?.status !== status ||
|
|
1328
|
+
candidate.decision?.sealed !== true ||
|
|
1329
|
+
candidate.decision?.requiresFaultRecovery !== true ||
|
|
1330
|
+
!isDeepStrictEqual(candidate.decision?.batchReceiptIds,
|
|
1331
|
+
batch ? cohortIds : undefined) ||
|
|
1332
|
+
candidate.decision?.replyTargetReceiptId !==
|
|
1333
|
+
(batch ? receiptId : undefined) ||
|
|
1334
|
+
!Array.isArray(candidate.effects) || candidate.effects.length !== 0 ||
|
|
1335
|
+
!Array.isArray(candidate.effectDescriptors) ||
|
|
1336
|
+
candidate.effectDescriptors.length !== 0 ||
|
|
1337
|
+
!isDeepStrictEqual(candidate.state?.lanes, before.state.lanes)) {
|
|
1338
|
+
throw new Error('C2 unresolved classification changed running state')
|
|
1339
|
+
}
|
|
1340
|
+
} })
|
|
1341
|
+
if (assertWriterLock() !== true) {
|
|
1342
|
+
prepared.cancel()
|
|
1343
|
+
throw new Error('C2 unresolved lost single-writer lock before commit')
|
|
1344
|
+
}
|
|
1345
|
+
const result = prepared.commit()
|
|
1346
|
+
if (!this.#journal.hasEvent(eventId) ||
|
|
1347
|
+
!isDeepStrictEqual(this.#journal.snapshot().state, result.state)) {
|
|
1348
|
+
throw new Error('C2 unresolved durable classification unverified')
|
|
1349
|
+
}
|
|
1350
|
+
this.close()
|
|
1351
|
+
return Object.freeze({ eventId, cursor: result.cursor, status,
|
|
1352
|
+
ingressClosed: true, deliveryWithheld: true })
|
|
1353
|
+
} catch (error) { this.close(); throw error }
|
|
1354
|
+
}
|
|
1355
|
+
|
|
1356
|
+
// A recalled queued message has no outward result. The queue decision and
|
|
1357
|
+
// quiet receipt settlement share one journal record; only after fsync may
|
|
1358
|
+
// its process-local body be discarded. This deliberately supports one
|
|
1359
|
+
// ordinary queued job, not a coalesced batch or synthetic source.
|
|
1360
|
+
commitQueuedCancellation({ receiptId, chatId, control, payloadHandles,
|
|
1361
|
+
assertWriterLock } = {}) {
|
|
1362
|
+
this.#completionCapacity()
|
|
1363
|
+
if (!RECEIPT_ID.test(receiptId || '') || typeof chatId !== 'string' || !chatId ||
|
|
1364
|
+
!['recall', 'stop'].includes(control) ||
|
|
1365
|
+
!(payloadHandles instanceof C2PayloadHandles) ||
|
|
1366
|
+
typeof assertWriterLock !== 'function' ||
|
|
1367
|
+
assertWriterLock.constructor?.name === 'AsyncFunction') {
|
|
1368
|
+
throw new TypeError('C2 queued recall identity invalid')
|
|
1369
|
+
}
|
|
1370
|
+
try {
|
|
1371
|
+
const before = this.#journal.snapshot()
|
|
1372
|
+
const receipt = before.receipts.pending.find(item => item.id === receiptId)
|
|
1373
|
+
const queue = before.state.lanes.queue?.[chatId]?.queue
|
|
1374
|
+
const handle = payloadHandles.requireQueued(receiptId)
|
|
1375
|
+
const index = queue?.findIndex(item => item.id === `receipt:job:${receiptId}`)
|
|
1376
|
+
if (this.blocked || assertWriterLock() !== true ||
|
|
1377
|
+
receipt?.chatId !== chatId || receipt.source !== 'message' ||
|
|
1378
|
+
index < 0 || !Array.isArray(queue) || queue[index]?.batch ||
|
|
1379
|
+
handle?.msg?._c2ReceiptId !== receiptId ||
|
|
1380
|
+
handle.msg.msgId !== receipt.sourceId ||
|
|
1381
|
+
!isDeepStrictEqual(this.#coordinator.snapshot(), before.state) ||
|
|
1382
|
+
[...before.effects].some(([id, status]) =>
|
|
1383
|
+
this.#journal.effectDescriptor(id)?.receiptId === receiptId &&
|
|
1384
|
+
status !== 'acked') ||
|
|
1385
|
+
this.#journal.noticeStatus(receiptId) !== null) {
|
|
1386
|
+
throw new Error('C2 queued recall differs from durable queue')
|
|
1387
|
+
}
|
|
1388
|
+
const settledAt = this.#now()
|
|
1389
|
+
if (!Number.isSafeInteger(settledAt) || settledAt <= 0) {
|
|
1390
|
+
throw new TypeError('C2 queued recall time invalid')
|
|
1391
|
+
}
|
|
1392
|
+
const eventId = `sbot-c2-queue-drop-v1:${receiptId}:${control}`
|
|
1393
|
+
const receipts = {
|
|
1394
|
+
pending: before.receipts.pending.filter(item => item.id !== receiptId),
|
|
1395
|
+
completed: [...before.receipts.completed, { id: receiptId, settledAt }],
|
|
1396
|
+
}
|
|
1397
|
+
const expectedQueue = [...queue.slice(0, index), ...queue.slice(index + 1)]
|
|
1398
|
+
const prepared = this.#coordinator.prepare({ id: eventId, domain: 'queue',
|
|
1399
|
+
scope: chatId, payload: { type: 'dropQueuedReceipt', receiptId,
|
|
1400
|
+
control }, receipts,
|
|
1401
|
+
validate: candidate => {
|
|
1402
|
+
if (candidate.cursor !== before.cursor + 1 ||
|
|
1403
|
+
candidate.decision?.receiptId !== receiptId ||
|
|
1404
|
+
candidate.decision?.control !== control ||
|
|
1405
|
+
candidate.decision?.position !== 'job' ||
|
|
1406
|
+
candidate.decision?.quietSettlement !== true ||
|
|
1407
|
+
candidate.decision?.sealed !== false ||
|
|
1408
|
+
candidate.effects?.length !== 0 ||
|
|
1409
|
+
candidate.effectDescriptors?.length !== 0 ||
|
|
1410
|
+
!isDeepStrictEqual(candidate.state?.lanes?.queue?.[chatId]?.queue,
|
|
1411
|
+
expectedQueue)) {
|
|
1412
|
+
throw new Error('C2 queued recall decision invalid')
|
|
1413
|
+
}
|
|
1414
|
+
} })
|
|
1415
|
+
if (assertWriterLock() !== true) {
|
|
1416
|
+
prepared.cancel()
|
|
1417
|
+
throw new Error('C2 queued recall lost writer lock')
|
|
1418
|
+
}
|
|
1419
|
+
const result = prepared.commit()
|
|
1420
|
+
payloadHandles.dropDurablyCancelled(receiptId, eventId, control)
|
|
1421
|
+
if (this.#journal.snapshot().eventCount >= this.#maxEventCount ||
|
|
1422
|
+
this.#journal.byteLength >= this.#maxJournalBytes) this.#sealForCapacity()
|
|
1423
|
+
return { eventId, cursor: result.cursor, receiptId, status: 'cancelled',
|
|
1424
|
+
control }
|
|
1425
|
+
} catch (error) { this.close(); throw error }
|
|
1426
|
+
}
|
|
1427
|
+
|
|
1428
|
+
#cardEffects(before, receiptId, chatId, turnToken, cleanupEffectId = null) {
|
|
1429
|
+
const prior = []
|
|
1430
|
+
for (const [id, status] of before.effects) {
|
|
1431
|
+
const descriptor = this.#journal.effectDescriptor(id)
|
|
1432
|
+
if (descriptor?.receiptId !== receiptId) continue
|
|
1433
|
+
if (status !== 'acked' || descriptor.scope !== chatId ||
|
|
1434
|
+
descriptor.turnToken !== turnToken && descriptor.domain === 'outbound' ||
|
|
1435
|
+
['outbound', 'terminal'].includes(descriptor.domain) &&
|
|
1436
|
+
(descriptor.domain !== 'outbound' ||
|
|
1437
|
+
!CARD_OPERATION_KINDS.has(descriptor.type))) {
|
|
1438
|
+
throw new Error('C2 card cleanup has unsettled or foreign effect')
|
|
1439
|
+
}
|
|
1440
|
+
if (descriptor.domain === 'outbound') {
|
|
1441
|
+
if (id === cleanupEffectId && descriptor.type === 'message.delete') continue
|
|
1442
|
+
if (descriptor.type === 'message.delete') {
|
|
1443
|
+
throw new Error('C2 card cleanup has unexpected delete effect')
|
|
1444
|
+
}
|
|
1445
|
+
prior.push(id)
|
|
1446
|
+
}
|
|
1447
|
+
}
|
|
1448
|
+
return prior
|
|
1449
|
+
}
|
|
1450
|
+
|
|
1451
|
+
#cardCleanupIdentity({ receiptId, chatId, turnToken, generation, target }) {
|
|
1452
|
+
if (!RECEIPT_ID.test(receiptId || '') || !GENERATION.test(generation || '') ||
|
|
1453
|
+
!Number.isSafeInteger(turnToken) || turnToken <= 0 ||
|
|
1454
|
+
target?.chatId !== chatId) throw new TypeError('C2 card cleanup identity invalid')
|
|
1455
|
+
const cardTargetDigestValue = cardTargetDigest(target)
|
|
1456
|
+
const { operationId: deleteOperationId, eventId } = cardOperationIdentity({
|
|
1457
|
+
receiptId, turnToken, generation,
|
|
1458
|
+
operation: { kind: 'message.delete', operationId: `${receiptId}:message.delete`,
|
|
1459
|
+
chatId, cardId: target.cardId, msgId: target.msgId, invoke: () => {} } })
|
|
1460
|
+
const cleanupEffectId = `sbot-c2-effect-v1:${Buffer.byteLength(eventId)}:${eventId}:0`
|
|
1461
|
+
return { receiptId, turnToken, runnerGeneration: generation,
|
|
1462
|
+
cardTargetDigest: cardTargetDigestValue, deleteOperationId,
|
|
1463
|
+
cleanupEffectId }
|
|
1464
|
+
}
|
|
1465
|
+
|
|
1466
|
+
commitCardCleanupIntent({ receiptId, chatId, turnToken, generation, target,
|
|
1467
|
+
verifyGeneration, assertWriterLock } = {}) {
|
|
1468
|
+
const before = this.#completionCapacity()
|
|
1469
|
+
try {
|
|
1470
|
+
const identity = this.#cardCleanupIdentity({ receiptId, chatId,
|
|
1471
|
+
turnToken, generation, target })
|
|
1472
|
+
const witness = this.#activeRuns.get(chatId)
|
|
1473
|
+
const session = before.state.lanes.session?.[chatId]
|
|
1474
|
+
const prior = this.#cardEffects(before, receiptId, chatId, turnToken)
|
|
1475
|
+
const cardOperations = prior.length
|
|
1476
|
+
if (this.blocked || assertWriterLock?.() !== true ||
|
|
1477
|
+
verifyGeneration?.({ chatId, receiptId, turnToken, generation }) !== true ||
|
|
1478
|
+
before.receipts.pending.find(item => item.id === receiptId)?.chatId !== chatId ||
|
|
1479
|
+
witness?.receiptId !== receiptId || witness.turnToken !== turnToken ||
|
|
1480
|
+
!witness.jobReceiptIds.includes(receiptId) ||
|
|
1481
|
+
!isDeepStrictEqual(witness.jobReceiptIds, witness.batchReceiptIds) ||
|
|
1482
|
+
!isDeepStrictEqual(before.state.lanes.handoff?.[chatId]?.acceptedReceiptIds,
|
|
1483
|
+
witness.batchReceiptIds.length > 1 ? witness.batchReceiptIds : []) ||
|
|
1484
|
+
witness.batchReceiptIds.some(id =>
|
|
1485
|
+
before.receipts.pending.find(item => item.id === id)?.chatId !== chatId) ||
|
|
1486
|
+
before.state.lanes.turn?.[chatId]?.active !== turnToken ||
|
|
1487
|
+
before.state.lanes.handoff?.[chatId]?.phase !== 'running' ||
|
|
1488
|
+
session?.epoch !== turnToken || session.closeSeen !== true ||
|
|
1489
|
+
session.settled !== false ||
|
|
1490
|
+
!this.#journal.hasEvent(`sbot-c2-session-close-v1:${receiptId}:${generation}`) ||
|
|
1491
|
+
!this.#journal.lookupRunnerClosed(`sbot-c2-runner-closed-v1:${receiptId}:${generation}`) ||
|
|
1492
|
+
cardOperations <= 0 ||
|
|
1493
|
+
before.state.lanes.cardCleanup?.[chatId] !== undefined ||
|
|
1494
|
+
!isDeepStrictEqual(this.#coordinator.snapshot(), before.state)) {
|
|
1495
|
+
throw new Error('C2 card cleanup intent lacks closed turn and ACKed card')
|
|
1496
|
+
}
|
|
1497
|
+
const eventId = `sbot-c2-card-cleanup-intent-v1:${receiptId}:${turnToken}:${generation}:${identity.cardTargetDigest}:${identity.deleteOperationId}`
|
|
1498
|
+
const lane = { phase: 'intent', ...identity, cardOperations }
|
|
1499
|
+
const prepared = this.#coordinator.prepare({ id: eventId, domain: 'outbound',
|
|
1500
|
+
scope: chatId, payload: { type: 'cardCleanupIntent', receiptId, turnToken,
|
|
1501
|
+
runnerGeneration: generation, cardTargetDigest: identity.cardTargetDigest,
|
|
1502
|
+
deleteOperationId: identity.deleteOperationId, cardOperations },
|
|
1503
|
+
validate: candidate => {
|
|
1504
|
+
if (candidate.cursor !== before.cursor + 1 ||
|
|
1505
|
+
candidate.effects?.length !== 0 ||
|
|
1506
|
+
candidate.effectDescriptors?.length !== 0 ||
|
|
1507
|
+
!isDeepStrictEqual(candidate.state?.lanes?.cardCleanup?.[chatId], lane) ||
|
|
1508
|
+
candidate.decision?.cleanupEffectId !== identity.cleanupEffectId) {
|
|
1509
|
+
throw new Error('C2 card cleanup intent decision invalid')
|
|
1510
|
+
}
|
|
1511
|
+
} })
|
|
1512
|
+
if (assertWriterLock() !== true) {
|
|
1513
|
+
prepared.cancel(); throw new Error('C2 card cleanup intent lost writer lock')
|
|
1514
|
+
}
|
|
1515
|
+
prepared.commit()
|
|
1516
|
+
return { ...identity, cardOperations, targetDigest: identity.cardTargetDigest }
|
|
1517
|
+
} catch (error) { this.close(); throw error }
|
|
1518
|
+
}
|
|
1519
|
+
|
|
1520
|
+
commitCardCleanupConfirmed({ receiptId, chatId, turnToken, generation, target,
|
|
1521
|
+
proof, verifyGeneration, assertWriterLock } = {}) {
|
|
1522
|
+
const before = this.#completionCapacity()
|
|
1523
|
+
try {
|
|
1524
|
+
const identity = this.#cardCleanupIdentity({ receiptId, chatId,
|
|
1525
|
+
turnToken, generation, target })
|
|
1526
|
+
const lane = before.state.lanes.cardCleanup?.[chatId]
|
|
1527
|
+
const prior = this.#cardEffects(before, receiptId, chatId, turnToken,
|
|
1528
|
+
identity.cleanupEffectId)
|
|
1529
|
+
const descriptor = this.#journal.effectDescriptor(identity.cleanupEffectId)
|
|
1530
|
+
if (this.blocked || assertWriterLock?.() !== true ||
|
|
1531
|
+
verifyGeneration?.({ chatId, receiptId, turnToken, generation }) !== true ||
|
|
1532
|
+
!isDeepStrictEqual(proof, { ...identity,
|
|
1533
|
+
cardOperations: prior.length, targetDigest: identity.cardTargetDigest }) ||
|
|
1534
|
+
!isDeepStrictEqual(lane, { phase: 'intent', ...identity,
|
|
1535
|
+
cardOperations: prior.length }) ||
|
|
1536
|
+
descriptor?.domain !== 'outbound' || descriptor.type !== 'message.delete' ||
|
|
1537
|
+
descriptor.scope !== chatId || descriptor.receiptId !== receiptId ||
|
|
1538
|
+
descriptor.turnToken !== turnToken ||
|
|
1539
|
+
this.#journal.effectStatus(identity.cleanupEffectId) !== 'acked' ||
|
|
1540
|
+
!isDeepStrictEqual(this.#coordinator.snapshot(), before.state)) {
|
|
1541
|
+
throw new Error('C2 card cleanup delete ACK proof invalid')
|
|
1542
|
+
}
|
|
1543
|
+
const eventId = `sbot-c2-card-cleanup-confirm-v1:${receiptId}:${turnToken}:${generation}:${identity.cardTargetDigest}:${identity.deleteOperationId}`
|
|
1544
|
+
const payload = { type: 'cardCleanupConfirmed', receiptId, turnToken,
|
|
1545
|
+
runnerGeneration: generation, cardTargetDigest: identity.cardTargetDigest,
|
|
1546
|
+
deleteOperationId: identity.deleteOperationId,
|
|
1547
|
+
cardOperations: prior.length, cleanupEffectId: identity.cleanupEffectId,
|
|
1548
|
+
cleanupAcked: true }
|
|
1549
|
+
const prepared = this.#coordinator.prepare({ id: eventId, domain: 'outbound',
|
|
1550
|
+
scope: chatId, payload, validate: candidate => {
|
|
1551
|
+
if (candidate.cursor !== before.cursor + 1 ||
|
|
1552
|
+
candidate.effects?.length !== 0 ||
|
|
1553
|
+
candidate.effectDescriptors?.length !== 0 ||
|
|
1554
|
+
!isDeepStrictEqual(candidate.state?.lanes?.cardCleanup?.[chatId],
|
|
1555
|
+
{ ...lane, phase: 'acked' }) ||
|
|
1556
|
+
candidate.decision?.cleanupAcked !== true) {
|
|
1557
|
+
throw new Error('C2 card cleanup confirmation decision invalid')
|
|
1558
|
+
}
|
|
1559
|
+
} })
|
|
1560
|
+
if (assertWriterLock() !== true) {
|
|
1561
|
+
prepared.cancel(); throw new Error('C2 card cleanup confirmation lost writer lock')
|
|
1562
|
+
}
|
|
1563
|
+
prepared.commit()
|
|
1564
|
+
return { cardOperations: prior.length,
|
|
1565
|
+
cardTargetDigest: identity.cardTargetDigest,
|
|
1566
|
+
cleanupEffectId: identity.cleanupEffectId }
|
|
1567
|
+
} catch (error) { this.close(); throw error }
|
|
1568
|
+
}
|
|
1569
|
+
|
|
1570
|
+
#cancellationCardFields(before, { receiptId, chatId, turnToken, generation,
|
|
1571
|
+
cardCleanup }) {
|
|
1572
|
+
const lane = before.state.lanes.cardCleanup?.[chatId]
|
|
1573
|
+
if (cardCleanup === null || cardCleanup === undefined) {
|
|
1574
|
+
if (lane !== undefined ||
|
|
1575
|
+
this.#cardEffects(before, receiptId, chatId, turnToken).length !== 0) {
|
|
1576
|
+
throw new Error('C2 cancellation has visible card without cleanup proof')
|
|
1577
|
+
}
|
|
1578
|
+
return { cardOperations: 0 }
|
|
1579
|
+
}
|
|
1580
|
+
const { cardOperations, cardTargetDigest: digest, cleanupEffectId } = cardCleanup
|
|
1581
|
+
const descriptor = this.#journal.effectDescriptor(cleanupEffectId)
|
|
1582
|
+
const prior = this.#cardEffects(before, receiptId, chatId, turnToken,
|
|
1583
|
+
cleanupEffectId)
|
|
1584
|
+
if (!Number.isSafeInteger(cardOperations) || cardOperations <= 0 ||
|
|
1585
|
+
prior.length !== cardOperations ||
|
|
1586
|
+
!isDeepStrictEqual(lane, { phase: 'acked', receiptId, turnToken,
|
|
1587
|
+
runnerGeneration: generation, cardTargetDigest: digest,
|
|
1588
|
+
deleteOperationId: lane?.deleteOperationId,
|
|
1589
|
+
cleanupEffectId, cardOperations }) ||
|
|
1590
|
+
descriptor?.domain !== 'outbound' || descriptor.type !== 'message.delete' ||
|
|
1591
|
+
descriptor.scope !== chatId || descriptor.receiptId !== receiptId ||
|
|
1592
|
+
descriptor.turnToken !== turnToken ||
|
|
1593
|
+
this.#journal.effectStatus(cleanupEffectId) !== 'acked') {
|
|
1594
|
+
throw new Error('C2 cancellation card cleanup proof invalid')
|
|
1595
|
+
}
|
|
1596
|
+
return { cardOperations, cardTargetDigest: digest, cleanupEffectId }
|
|
1597
|
+
}
|
|
1598
|
+
|
|
1599
|
+
#matchesRunningReceipts(before, witness, chatId, receiptId, turnToken) {
|
|
1600
|
+
const handoff = before.state.lanes.handoff?.[chatId]
|
|
1601
|
+
const ids = witness?.batchReceiptIds
|
|
1602
|
+
return witness?.receiptId === receiptId && witness.turnToken === turnToken &&
|
|
1603
|
+
handoff?.phase === 'running' && handoff.turnToken === turnToken &&
|
|
1604
|
+
Array.isArray(ids) && ids.length >= 1 && ids.includes(receiptId) &&
|
|
1605
|
+
new Set(ids).size === ids.length &&
|
|
1606
|
+
ids.every(id => RECEIPT_ID.test(id) &&
|
|
1607
|
+
before.receipts.pending.some(item => item.id === id &&
|
|
1608
|
+
item.chatId === chatId && item.source === 'message')) &&
|
|
1609
|
+
isDeepStrictEqual(handoff.acceptedReceiptIds,
|
|
1610
|
+
ids.length > 1 ? ids : [])
|
|
1611
|
+
}
|
|
1612
|
+
|
|
1613
|
+
#runningControlReceipts(before, witness, chatId, receiptId, turnToken) {
|
|
1614
|
+
if (!this.#matchesRunningReceipts(before, witness, chatId, receiptId,
|
|
1615
|
+
turnToken)) {
|
|
1616
|
+
throw new Error('C2 running control lacks exact batch receipts')
|
|
1617
|
+
}
|
|
1618
|
+
const ids = witness.batchReceiptIds
|
|
1619
|
+
return { ids, batchFields: ids.length > 1 ? { batchReceiptIds: ids } : {} }
|
|
1620
|
+
}
|
|
1621
|
+
|
|
1622
|
+
// A running recall is allowed only after the exact child has stopped and
|
|
1623
|
+
// the dispatch/session closure are durable. Visible card effects require
|
|
1624
|
+
// the separately ACKed deletion proof before quiet settlement.
|
|
1625
|
+
commitRunningCancellation({ receiptId, chatId, turnToken, generation, control,
|
|
1626
|
+
cardCleanup = null, verifyGeneration, assertWriterLock } = {}) {
|
|
1627
|
+
this.#completionCapacity()
|
|
1628
|
+
if (!RECEIPT_ID.test(receiptId || '') || typeof chatId !== 'string' || !chatId ||
|
|
1629
|
+
!Number.isSafeInteger(turnToken) || turnToken <= 0 ||
|
|
1630
|
+
!['recall', 'stop'].includes(control) ||
|
|
1631
|
+
!GENERATION.test(generation || '') ||
|
|
1632
|
+
typeof verifyGeneration !== 'function' ||
|
|
1633
|
+
verifyGeneration.constructor?.name === 'AsyncFunction' ||
|
|
1634
|
+
typeof assertWriterLock !== 'function' ||
|
|
1635
|
+
assertWriterLock.constructor?.name === 'AsyncFunction') {
|
|
1636
|
+
throw new TypeError('C2 running recall identity invalid')
|
|
1637
|
+
}
|
|
1638
|
+
try {
|
|
1639
|
+
const before = this.#journal.snapshot()
|
|
1640
|
+
const receipt = before.receipts.pending.find(item => item.id === receiptId)
|
|
1641
|
+
const witness = this.#activeRuns.get(chatId)
|
|
1642
|
+
const { ids: runningIds, batchFields } = this.#runningControlReceipts(
|
|
1643
|
+
before, witness, chatId, receiptId, turnToken)
|
|
1644
|
+
const closeEvidenceId = `sbot-c2-runner-closed-v1:${receiptId}:${generation}`
|
|
1645
|
+
const proof = this.#journal.lookupRunnerClosed(closeEvidenceId)
|
|
1646
|
+
const dispatch = proof && this.#journal.effectDescriptor(proof.dispatchEffectId)
|
|
1647
|
+
const session = before.state.lanes.session?.[chatId]
|
|
1648
|
+
const cardFields = this.#cancellationCardFields(before, { receiptId,
|
|
1649
|
+
chatId, turnToken, generation, cardCleanup })
|
|
1650
|
+
if (this.blocked || assertWriterLock() !== true ||
|
|
1651
|
+
receipt?.source !== 'message' || receipt.chatId !== chatId ||
|
|
1652
|
+
witness?.receiptId !== receiptId || witness.turnToken !== turnToken ||
|
|
1653
|
+
proof?.receiptId !== receiptId || proof.chatId !== chatId ||
|
|
1654
|
+
proof.turnToken !== turnToken || proof.generation !== generation ||
|
|
1655
|
+
dispatch?.type !== 'dispatch' || dispatch.scope !== chatId ||
|
|
1656
|
+
dispatch.receiptId !== receiptId || dispatch.turnToken !== turnToken ||
|
|
1657
|
+
this.#journal.effectStatus(proof.dispatchEffectId) !== 'acked' ||
|
|
1658
|
+
!this.#journal.hasEvent(`sbot-c2-session-close-v1:${receiptId}:${generation}`) ||
|
|
1659
|
+
session?.epoch !== turnToken || session.closeSeen !== true ||
|
|
1660
|
+
session.settled !== false ||
|
|
1661
|
+
before.state.lanes.turn?.[chatId]?.active !== turnToken ||
|
|
1662
|
+
before.state.lanes.handoff?.[chatId]?.phase !== 'running' ||
|
|
1663
|
+
before.state.lanes.handoff[chatId].turnToken !== turnToken ||
|
|
1664
|
+
!isDeepStrictEqual(this.#coordinator.snapshot(), before.state) ||
|
|
1665
|
+
[...before.effects].some(([id, status]) => {
|
|
1666
|
+
const descriptor = this.#journal.effectDescriptor(id)
|
|
1667
|
+
return runningIds.includes(descriptor?.receiptId) &&
|
|
1668
|
+
(status !== 'acked' || descriptor.domain === 'terminal')
|
|
1669
|
+
}) ||
|
|
1670
|
+
verifyGeneration({ chatId, receiptId, turnToken, generation }) !== true) {
|
|
1671
|
+
throw new Error('C2 running recall lacks closed, unsettled turn proof')
|
|
1672
|
+
}
|
|
1673
|
+
const settledAt = this.#now()
|
|
1674
|
+
if (!Number.isSafeInteger(settledAt) || settledAt <= 0) {
|
|
1675
|
+
throw new TypeError('C2 running recall time invalid')
|
|
1676
|
+
}
|
|
1677
|
+
const eventId = `sbot-c2-cancel-v1:${receiptId}:${turnToken}:${generation}:${control}`
|
|
1678
|
+
const settled = new Set(runningIds)
|
|
1679
|
+
const receipts = {
|
|
1680
|
+
pending: before.receipts.pending.filter(item => !settled.has(item.id)),
|
|
1681
|
+
completed: [...before.receipts.completed,
|
|
1682
|
+
...runningIds.map(id => ({ id, settledAt }))],
|
|
1683
|
+
}
|
|
1684
|
+
const prepared = this.#coordinator.prepare({ id: eventId,
|
|
1685
|
+
domain: 'terminal', scope: chatId,
|
|
1686
|
+
payload: { type: 'cancelRunningTurn', receiptId, turnToken,
|
|
1687
|
+
runnerGeneration: generation, runnerClosed: true,
|
|
1688
|
+
closeEvidenceId, control, ...cardFields, ...batchFields }, receipts,
|
|
1689
|
+
validate: candidate => {
|
|
1690
|
+
if (candidate.cursor !== before.cursor + 1 ||
|
|
1691
|
+
candidate.decision?.receiptId !== receiptId ||
|
|
1692
|
+
!isDeepStrictEqual(candidate.decision?.batchReceiptIds,
|
|
1693
|
+
batchFields.batchReceiptIds) ||
|
|
1694
|
+
candidate.decision?.turnToken !== turnToken ||
|
|
1695
|
+
candidate.decision?.control !== control ||
|
|
1696
|
+
candidate.decision?.quietSettlement !== true ||
|
|
1697
|
+
candidate.decision?.cardCleanupRequired !== false ||
|
|
1698
|
+
candidate.decision?.sealed !== false ||
|
|
1699
|
+
candidate.effects?.length !== 0 ||
|
|
1700
|
+
candidate.effectDescriptors?.length !== 0 ||
|
|
1701
|
+
candidate.state?.lanes?.turn?.[chatId]?.active !== null ||
|
|
1702
|
+
candidate.state?.lanes?.handoff?.[chatId]?.phase !== 'idle' ||
|
|
1703
|
+
candidate.state?.lanes?.session?.[chatId]?.settled !== true) {
|
|
1704
|
+
throw new Error('C2 running recall decision invalid')
|
|
1705
|
+
}
|
|
1706
|
+
} })
|
|
1707
|
+
if (assertWriterLock() !== true) {
|
|
1708
|
+
prepared.cancel()
|
|
1709
|
+
throw new Error('C2 running recall lost writer lock')
|
|
1710
|
+
}
|
|
1711
|
+
const result = prepared.commit()
|
|
1712
|
+
this.#activeRuns.delete(chatId)
|
|
1713
|
+
if (this.#journal.snapshot().eventCount >= this.#maxEventCount ||
|
|
1714
|
+
this.#journal.byteLength >= this.#maxJournalBytes) this.#sealForCapacity()
|
|
1715
|
+
return { eventId, cursor: result.cursor, receiptId, status: 'cancelled',
|
|
1716
|
+
control, cancelledReceiptIds: runningIds }
|
|
1717
|
+
} catch (error) { this.close(); throw error }
|
|
1718
|
+
}
|
|
1719
|
+
|
|
1720
|
+
// A hard timer has no outward acknowledgement. Its exact child closure,
|
|
1721
|
+
// pending receipt, ACKed effects and quiet settlement form one transition.
|
|
1722
|
+
commitHardTimeoutRelease({ receiptId, chatId, turnToken, generation,
|
|
1723
|
+
timeoutProof, cardCleanup = null, verifyGeneration, assertWriterLock } = {}) {
|
|
1724
|
+
const before = this.#completionCapacity()
|
|
1725
|
+
if (!RECEIPT_ID.test(receiptId || '') || typeof chatId !== 'string' || !chatId ||
|
|
1726
|
+
!Number.isSafeInteger(turnToken) || turnToken <= 0 ||
|
|
1727
|
+
!GENERATION.test(generation || '') ||
|
|
1728
|
+
typeof verifyGeneration !== 'function' ||
|
|
1729
|
+
typeof assertWriterLock !== 'function' ||
|
|
1730
|
+
timeoutProof?.receiptId !== receiptId ||
|
|
1731
|
+
timeoutProof.turnToken !== turnToken ||
|
|
1732
|
+
timeoutProof.generation !== generation ||
|
|
1733
|
+
!Number.isSafeInteger(timeoutProof.deadlineAt) ||
|
|
1734
|
+
!Number.isSafeInteger(timeoutProof.elapsedAt) ||
|
|
1735
|
+
timeoutProof.deadlineAt <= 0 ||
|
|
1736
|
+
timeoutProof.elapsedAt < timeoutProof.deadlineAt ||
|
|
1737
|
+
this.#now() < timeoutProof.deadlineAt) {
|
|
1738
|
+
throw new TypeError('C2 hard timeout timer proof invalid')
|
|
1739
|
+
}
|
|
1740
|
+
try {
|
|
1741
|
+
const receipt = before.receipts.pending.find(item => item.id === receiptId)
|
|
1742
|
+
const witness = this.#activeRuns.get(chatId)
|
|
1743
|
+
const { ids: runningIds, batchFields } = this.#runningControlReceipts(
|
|
1744
|
+
before, witness, chatId, receiptId, turnToken)
|
|
1745
|
+
const closeEvidenceId = `sbot-c2-runner-closed-v1:${receiptId}:${generation}`
|
|
1746
|
+
const proof = this.#journal.lookupRunnerClosed(closeEvidenceId)
|
|
1747
|
+
const dispatch = proof && this.#journal.effectDescriptor(proof.dispatchEffectId)
|
|
1748
|
+
const session = before.state.lanes.session?.[chatId]
|
|
1749
|
+
const cardFields = this.#cancellationCardFields(before, { receiptId,
|
|
1750
|
+
chatId, turnToken, generation, cardCleanup })
|
|
1751
|
+
if (this.blocked || assertWriterLock() !== true ||
|
|
1752
|
+
receipt?.source !== 'message' || receipt.chatId !== chatId ||
|
|
1753
|
+
witness?.receiptId !== receiptId || witness.turnToken !== turnToken ||
|
|
1754
|
+
proof?.receiptId !== receiptId || proof.chatId !== chatId ||
|
|
1755
|
+
proof.turnToken !== turnToken || proof.generation !== generation ||
|
|
1756
|
+
dispatch?.type !== 'dispatch' || dispatch.scope !== chatId ||
|
|
1757
|
+
dispatch.receiptId !== receiptId || dispatch.turnToken !== turnToken ||
|
|
1758
|
+
this.#journal.effectStatus(proof.dispatchEffectId) !== 'acked' ||
|
|
1759
|
+
!this.#journal.hasEvent(`sbot-c2-session-close-v1:${receiptId}:${generation}`) ||
|
|
1760
|
+
session?.epoch !== turnToken || session.closeSeen !== true ||
|
|
1761
|
+
session.settled !== false ||
|
|
1762
|
+
before.state.lanes.turn?.[chatId]?.active !== turnToken ||
|
|
1763
|
+
before.state.lanes.handoff?.[chatId]?.phase !== 'running' ||
|
|
1764
|
+
before.state.lanes.handoff[chatId].turnToken !== turnToken ||
|
|
1765
|
+
before.state.lanes.stopIntent?.[chatId] !== undefined ||
|
|
1766
|
+
!isDeepStrictEqual(this.#coordinator.snapshot(), before.state) ||
|
|
1767
|
+
[...before.effects].some(([id, status]) => {
|
|
1768
|
+
const descriptor = this.#journal.effectDescriptor(id)
|
|
1769
|
+
return runningIds.includes(descriptor?.receiptId) &&
|
|
1770
|
+
(status !== 'acked' || descriptor.domain === 'terminal')
|
|
1771
|
+
}) ||
|
|
1772
|
+
verifyGeneration({ chatId, receiptId, turnToken, generation }) !== true) {
|
|
1773
|
+
throw new Error('C2 hard timeout lacks closed reconciled turn proof')
|
|
1774
|
+
}
|
|
1775
|
+
const settledAt = this.#now()
|
|
1776
|
+
if (!Number.isSafeInteger(settledAt) || settledAt <= 0) {
|
|
1777
|
+
throw new TypeError('C2 hard timeout time invalid')
|
|
1778
|
+
}
|
|
1779
|
+
const eventId = `sbot-c2-hard-timeout-release-v1:${receiptId}:${turnToken}:${generation}`
|
|
1780
|
+
const queue = before.state.lanes.queue?.[chatId]?.queue
|
|
1781
|
+
const settled = new Set(runningIds)
|
|
1782
|
+
const receipts = { pending: before.receipts.pending.filter(item => !settled.has(item.id)),
|
|
1783
|
+
completed: [...before.receipts.completed,
|
|
1784
|
+
...runningIds.map(id => ({ id, settledAt }))] }
|
|
1785
|
+
const prepared = this.#coordinator.prepare({ id: eventId,
|
|
1786
|
+
domain: 'terminal', scope: chatId,
|
|
1787
|
+
payload: { type: 'hardTimeoutReleaseCommit', receiptId, turnToken,
|
|
1788
|
+
runnerGeneration: generation, timeoutElapsed: true, runnerClosed: true,
|
|
1789
|
+
closeEvidenceId, externalEffectsReconciled: true, ...cardFields,
|
|
1790
|
+
...batchFields },
|
|
1791
|
+
receipts, validate: candidate => {
|
|
1792
|
+
if (candidate.cursor !== before.cursor + 1 ||
|
|
1793
|
+
candidate.decision?.receiptId !== receiptId ||
|
|
1794
|
+
!isDeepStrictEqual(candidate.decision?.batchReceiptIds,
|
|
1795
|
+
batchFields.batchReceiptIds) ||
|
|
1796
|
+
candidate.decision?.turnToken !== turnToken ||
|
|
1797
|
+
candidate.decision?.outcome?.status !== 'timeout' ||
|
|
1798
|
+
candidate.decision?.outcome?.silent !== true ||
|
|
1799
|
+
candidate.decision?.quietSettlement !== true ||
|
|
1800
|
+
candidate.decision?.cardOperations !== cardFields.cardOperations ||
|
|
1801
|
+
candidate.decision?.sealed !== false ||
|
|
1802
|
+
candidate.effects?.length !== 0 ||
|
|
1803
|
+
candidate.effectDescriptors?.length !== 0 ||
|
|
1804
|
+
candidate.state?.lanes?.turn?.[chatId]?.active !== null ||
|
|
1805
|
+
candidate.state?.lanes?.handoff?.[chatId]?.phase !== 'idle' ||
|
|
1806
|
+
candidate.state?.lanes?.session?.[chatId]?.settled !== true ||
|
|
1807
|
+
!isDeepStrictEqual(candidate.state?.lanes?.queue?.[chatId]?.queue, queue)) {
|
|
1808
|
+
throw new Error('C2 hard timeout release decision invalid')
|
|
1809
|
+
}
|
|
1810
|
+
} })
|
|
1811
|
+
if (assertWriterLock() !== true) {
|
|
1812
|
+
prepared.cancel(); throw new Error('C2 hard timeout lost writer lock')
|
|
1813
|
+
}
|
|
1814
|
+
const result = prepared.commit()
|
|
1815
|
+
this.#activeRuns.delete(chatId)
|
|
1816
|
+
if (this.#journal.snapshot().eventCount >= this.#maxEventCount ||
|
|
1817
|
+
this.#journal.byteLength >= this.#maxJournalBytes) this.#sealForCapacity()
|
|
1818
|
+
return { eventId, cursor: result.cursor, receiptId, status: 'timeout',
|
|
1819
|
+
settledReceiptIds: runningIds }
|
|
1820
|
+
} catch (error) { this.close(); throw error }
|
|
1821
|
+
}
|
|
1822
|
+
|
|
1823
|
+
// Persist the separate /stop command receipt and its binding to the live
|
|
1824
|
+
// turn before asking the child to stop. A crash after this point leaves
|
|
1825
|
+
// startup closed rather than losing the command or replaying the runner.
|
|
1826
|
+
commitStopIntent({ controlReceipt, runningReceiptId, chatId, turnToken,
|
|
1827
|
+
generation, cardTarget = null, assertWriterLock } = {}) {
|
|
1828
|
+
const before = this.#capacity()
|
|
1829
|
+
const controlReceiptId = controlReceipt?.id
|
|
1830
|
+
if (!RECEIPT_ID.test(controlReceiptId || '') ||
|
|
1831
|
+
!RECEIPT_ID.test(runningReceiptId || '') ||
|
|
1832
|
+
controlReceiptId === runningReceiptId ||
|
|
1833
|
+
typeof chatId !== 'string' || !chatId ||
|
|
1834
|
+
!Number.isSafeInteger(turnToken) || turnToken <= 0 ||
|
|
1835
|
+
!GENERATION.test(generation || '') ||
|
|
1836
|
+
typeof assertWriterLock !== 'function' ||
|
|
1837
|
+
assertWriterLock.constructor?.name === 'AsyncFunction') {
|
|
1838
|
+
throw new TypeError('C2 stop intent identity invalid')
|
|
1839
|
+
}
|
|
1840
|
+
try {
|
|
1841
|
+
const running = before.receipts.pending.find(item => item.id === runningReceiptId)
|
|
1842
|
+
const witness = this.#activeRuns.get(chatId)
|
|
1843
|
+
const { ids: runningIds, batchFields } = this.#runningControlReceipts(
|
|
1844
|
+
before, witness, chatId, runningReceiptId, turnToken)
|
|
1845
|
+
const registered = this.#journal.lookupRegistration(callbackRegistrationId({
|
|
1846
|
+
receiptId: runningReceiptId, generation,
|
|
1847
|
+
domain: 'atomicHandoff', kind: 'runnerAccepted' }))
|
|
1848
|
+
const cardOperations = this.#cardEffects(before, runningReceiptId,
|
|
1849
|
+
chatId, turnToken).length
|
|
1850
|
+
if (this.blocked || assertWriterLock() !== true ||
|
|
1851
|
+
before.receipts.pending.some(item => item.id === controlReceiptId) ||
|
|
1852
|
+
before.receipts.completed.some(item => item.id === controlReceiptId) ||
|
|
1853
|
+
controlReceipt.source !== 'message' || controlReceipt.chatId !== chatId ||
|
|
1854
|
+
!/^om_[A-Za-z0-9_-]{1,124}$/.test(controlReceipt.sourceId || '') ||
|
|
1855
|
+
controlReceipt.quiet !== false ||
|
|
1856
|
+
running?.chatId !== chatId || running.source !== 'message' ||
|
|
1857
|
+
witness?.receiptId !== runningReceiptId || witness.turnToken !== turnToken ||
|
|
1858
|
+
registered?.receiptId !== runningReceiptId ||
|
|
1859
|
+
registered.generation !== generation ||
|
|
1860
|
+
this.#journal.effectStatus(witness.dispatchEffectId) !== 'acked' ||
|
|
1861
|
+
before.state.lanes.turn?.[chatId]?.active !== turnToken ||
|
|
1862
|
+
before.state.lanes.handoff?.[chatId]?.phase !== 'running' ||
|
|
1863
|
+
before.state.lanes.stopIntent?.[chatId] !== undefined ||
|
|
1864
|
+
(cardOperations > 0 &&
|
|
1865
|
+
(cardTarget?.chatId !== chatId || !cardTarget.cardId || !cardTarget.msgId)) ||
|
|
1866
|
+
[...before.effects].some(([id, status]) => {
|
|
1867
|
+
const descriptor = this.#journal.effectDescriptor(id)
|
|
1868
|
+
return runningIds.includes(descriptor?.receiptId) &&
|
|
1869
|
+
(status !== 'acked' || descriptor.domain === 'terminal')
|
|
1870
|
+
}) ||
|
|
1871
|
+
!isDeepStrictEqual(this.#coordinator.snapshot(), before.state)) {
|
|
1872
|
+
throw new Error('C2 stop intent differs from running durable turn')
|
|
1873
|
+
}
|
|
1874
|
+
const eventId = `sbot-c2-stop-intent-v1:${controlReceiptId}:${runningReceiptId}:${turnToken}:${generation}`
|
|
1875
|
+
const receipts = { pending: [...before.receipts.pending, controlReceipt],
|
|
1876
|
+
completed: [...before.receipts.completed] }
|
|
1877
|
+
const prepared = this.#coordinator.prepare({ id: eventId, domain: 'control',
|
|
1878
|
+
scope: chatId, payload: { type: 'stopIntent', controlReceiptId,
|
|
1879
|
+
runningReceiptId, turnToken, runnerGeneration: generation,
|
|
1880
|
+
cardOperations, ...batchFields }, receipts,
|
|
1881
|
+
validate: candidate => {
|
|
1882
|
+
if (candidate.cursor !== before.cursor + 1 ||
|
|
1883
|
+
candidate.effects?.length !== 0 ||
|
|
1884
|
+
candidate.effectDescriptors?.length !== 0 ||
|
|
1885
|
+
candidate.decision?.controlReceiptId !== controlReceiptId ||
|
|
1886
|
+
candidate.decision?.runningReceiptId !== runningReceiptId ||
|
|
1887
|
+
!isDeepStrictEqual(candidate.decision?.batchReceiptIds,
|
|
1888
|
+
batchFields.batchReceiptIds) ||
|
|
1889
|
+
candidate.decision?.turnToken !== turnToken ||
|
|
1890
|
+
!isDeepStrictEqual(candidate.state?.lanes?.stopIntent?.[chatId],
|
|
1891
|
+
{ phase: 'intent', controlReceiptId, runningReceiptId,
|
|
1892
|
+
turnToken, runnerGeneration: generation, ...batchFields })) {
|
|
1893
|
+
throw new Error('C2 stop intent decision invalid')
|
|
1894
|
+
}
|
|
1895
|
+
} })
|
|
1896
|
+
if (assertWriterLock() !== true) {
|
|
1897
|
+
prepared.cancel()
|
|
1898
|
+
throw new Error('C2 stop intent lost writer lock')
|
|
1899
|
+
}
|
|
1900
|
+
const result = prepared.commit()
|
|
1901
|
+
return { eventId, cursor: result.cursor, controlReceiptId }
|
|
1902
|
+
} catch (error) { this.close(); throw error }
|
|
1903
|
+
}
|
|
1904
|
+
|
|
1905
|
+
commitStopAll({ controlReceiptId, runningReceiptId, chatId, turnToken,
|
|
1906
|
+
generation, queuedReceiptIds, payloadHandles, cardCleanup = null, verifyGeneration,
|
|
1907
|
+
assertWriterLock } = {}) {
|
|
1908
|
+
const before = this.#completionCapacity()
|
|
1909
|
+
if (!RECEIPT_ID.test(controlReceiptId || '') ||
|
|
1910
|
+
!RECEIPT_ID.test(runningReceiptId || '') ||
|
|
1911
|
+
controlReceiptId === runningReceiptId ||
|
|
1912
|
+
typeof chatId !== 'string' || !chatId ||
|
|
1913
|
+
!Number.isSafeInteger(turnToken) || turnToken <= 0 ||
|
|
1914
|
+
!GENERATION.test(generation || '') ||
|
|
1915
|
+
!Array.isArray(queuedReceiptIds) ||
|
|
1916
|
+
new Set(queuedReceiptIds).size !== queuedReceiptIds.length ||
|
|
1917
|
+
!(payloadHandles instanceof C2PayloadHandles) ||
|
|
1918
|
+
typeof verifyGeneration !== 'function' ||
|
|
1919
|
+
typeof assertWriterLock !== 'function') {
|
|
1920
|
+
throw new TypeError('C2 stop-all identity invalid')
|
|
1921
|
+
}
|
|
1922
|
+
try {
|
|
1923
|
+
const control = before.receipts.pending.find(item => item.id === controlReceiptId)
|
|
1924
|
+
const running = before.receipts.pending.find(item => item.id === runningReceiptId)
|
|
1925
|
+
const intent = before.state.lanes.stopIntent?.[chatId]
|
|
1926
|
+
const witness = this.#activeRuns.get(chatId)
|
|
1927
|
+
const { ids: runningIds, batchFields } = this.#runningControlReceipts(
|
|
1928
|
+
before, witness, chatId, runningReceiptId, turnToken)
|
|
1929
|
+
const closeEvidenceId = `sbot-c2-runner-closed-v1:${runningReceiptId}:${generation}`
|
|
1930
|
+
const proof = this.#journal.lookupRunnerClosed(closeEvidenceId)
|
|
1931
|
+
const dispatch = proof && this.#journal.effectDescriptor(proof.dispatchEffectId)
|
|
1932
|
+
const session = before.state.lanes.session?.[chatId]
|
|
1933
|
+
const cardFields = this.#cancellationCardFields(before, {
|
|
1934
|
+
receiptId: runningReceiptId, chatId, turnToken, generation, cardCleanup })
|
|
1935
|
+
const queued = queuedPayloads(before, chatId, payloadHandles)
|
|
1936
|
+
if (this.blocked || assertWriterLock() !== true ||
|
|
1937
|
+
control?.chatId !== chatId || control.source !== 'message' || control.quiet ||
|
|
1938
|
+
running?.chatId !== chatId || running.source !== 'message' ||
|
|
1939
|
+
!isDeepStrictEqual(intent, { phase: 'intent', controlReceiptId,
|
|
1940
|
+
runningReceiptId, turnToken, runnerGeneration: generation,
|
|
1941
|
+
...batchFields }) ||
|
|
1942
|
+
witness?.receiptId !== runningReceiptId || witness.turnToken !== turnToken ||
|
|
1943
|
+
proof?.receiptId !== runningReceiptId || proof.chatId !== chatId ||
|
|
1944
|
+
proof.turnToken !== turnToken || proof.generation !== generation ||
|
|
1945
|
+
dispatch?.type !== 'dispatch' || dispatch.scope !== chatId ||
|
|
1946
|
+
dispatch.receiptId !== runningReceiptId ||
|
|
1947
|
+
this.#journal.effectStatus(proof.dispatchEffectId) !== 'acked' ||
|
|
1948
|
+
!this.#journal.hasEvent(`sbot-c2-session-close-v1:${runningReceiptId}:${generation}`) ||
|
|
1949
|
+
session?.epoch !== turnToken || session.closeSeen !== true ||
|
|
1950
|
+
session.settled !== false ||
|
|
1951
|
+
before.state.lanes.turn?.[chatId]?.active !== turnToken ||
|
|
1952
|
+
before.state.lanes.handoff?.[chatId]?.phase !== 'running' ||
|
|
1953
|
+
!isDeepStrictEqual(queued.accepted, queuedReceiptIds) ||
|
|
1954
|
+
queued.queue.some(item => item.batch) ||
|
|
1955
|
+
[...before.effects].some(([id, status]) => {
|
|
1956
|
+
const descriptor = this.#journal.effectDescriptor(id)
|
|
1957
|
+
return [...runningIds, controlReceiptId, ...queuedReceiptIds]
|
|
1958
|
+
.includes(descriptor?.receiptId) &&
|
|
1959
|
+
(status !== 'acked' || descriptor.domain === 'terminal')
|
|
1960
|
+
}) ||
|
|
1961
|
+
!isDeepStrictEqual(this.#coordinator.snapshot(), before.state) ||
|
|
1962
|
+
verifyGeneration({ chatId, receiptId: runningReceiptId,
|
|
1963
|
+
turnToken, generation }) !== true) {
|
|
1964
|
+
throw new Error('C2 stop-all lacks exact closed turn and queue proof')
|
|
1965
|
+
}
|
|
1966
|
+
const settledAt = this.#now()
|
|
1967
|
+
if (!Number.isSafeInteger(settledAt) || settledAt <= 0) {
|
|
1968
|
+
throw new TypeError('C2 stop-all time invalid')
|
|
1969
|
+
}
|
|
1970
|
+
const all = new Set([...runningIds, ...queuedReceiptIds])
|
|
1971
|
+
const receipts = { pending: before.receipts.pending.filter(item => !all.has(item.id)),
|
|
1972
|
+
completed: [...before.receipts.completed,
|
|
1973
|
+
...[...all].map(id => ({ id, settledAt }))] }
|
|
1974
|
+
const controlTargetId = `receipt:control:${controlReceiptId}`
|
|
1975
|
+
const eventId = `sbot-c2-stop-all-v1:${controlReceiptId}:${runningReceiptId}:${turnToken}:${generation}`
|
|
1976
|
+
const effectId = `sbot-c2-effect-v1:${Buffer.byteLength(eventId)}:${eventId}:0`
|
|
1977
|
+
const prepared = this.#coordinator.prepare({ id: eventId, domain: 'control',
|
|
1978
|
+
scope: chatId, payload: { type: 'stopAllCommit', controlReceiptId,
|
|
1979
|
+
controlTargetId, runningReceiptId, turnToken, runnerGeneration: generation,
|
|
1980
|
+
runnerClosed: true, closeEvidenceId, queuedReceiptIds,
|
|
1981
|
+
...cardFields, ...batchFields }, receipts,
|
|
1982
|
+
validate: candidate => {
|
|
1983
|
+
if (candidate.cursor !== before.cursor + 1 ||
|
|
1984
|
+
candidate.decision?.controlReceiptId !== controlReceiptId ||
|
|
1985
|
+
candidate.decision?.runningReceiptId !== runningReceiptId ||
|
|
1986
|
+
!isDeepStrictEqual(candidate.decision?.batchReceiptIds,
|
|
1987
|
+
batchFields.batchReceiptIds) ||
|
|
1988
|
+
!isDeepStrictEqual(candidate.decision?.queuedReceiptIds, queuedReceiptIds) ||
|
|
1989
|
+
candidate.decision?.stopAckEffectId !== effectId ||
|
|
1990
|
+
candidate.decision?.controlTargetId !== controlTargetId ||
|
|
1991
|
+
candidate.decision?.quietSettlement !== true ||
|
|
1992
|
+
candidate.effects?.length !== 1 ||
|
|
1993
|
+
!isDeepStrictEqual(candidate.effects[0],
|
|
1994
|
+
{ type: 'stopAck', controlReceiptId, targetId: controlTargetId, effectId }) ||
|
|
1995
|
+
!isDeepStrictEqual(candidate.effectDescriptors?.[0],
|
|
1996
|
+
{ effectId, domain: 'control', scope: chatId, type: 'stopAck',
|
|
1997
|
+
turnToken: null, receiptId: controlReceiptId,
|
|
1998
|
+
targetId: controlTargetId }) ||
|
|
1999
|
+
candidate.state?.lanes?.stopIntent?.[chatId] !== undefined ||
|
|
2000
|
+
candidate.state?.lanes?.turn?.[chatId]?.active !== null ||
|
|
2001
|
+
candidate.state?.lanes?.handoff?.[chatId]?.phase !== 'idle' ||
|
|
2002
|
+
candidate.state?.lanes?.queue?.[chatId]?.queue?.length !== 0) {
|
|
2003
|
+
throw new Error('C2 stop-all decision invalid')
|
|
2004
|
+
}
|
|
2005
|
+
} })
|
|
2006
|
+
if (assertWriterLock() !== true) {
|
|
2007
|
+
prepared.cancel()
|
|
2008
|
+
throw new Error('C2 stop-all lost writer lock')
|
|
2009
|
+
}
|
|
2010
|
+
const result = prepared.commit()
|
|
2011
|
+
payloadHandles.dropDurablyStopped(queuedReceiptIds, eventId)
|
|
2012
|
+
this.#activeRuns.delete(chatId)
|
|
2013
|
+
return { eventId, cursor: result.cursor, effectId, controlReceiptId,
|
|
2014
|
+
cancelledReceiptIds: [...all] }
|
|
2015
|
+
} catch (error) { this.close(); throw error }
|
|
2016
|
+
}
|
|
2017
|
+
|
|
2018
|
+
// One stop confirmation uses its own accepted command receipt. The Feishu
|
|
2019
|
+
// call is allowed only after the stop-all transition and durable attempt.
|
|
2020
|
+
async executeStopAck({ effectId, controlReceiptId, chatId, target,
|
|
2021
|
+
perform, confirm, assertWriterLock } = {}) {
|
|
2022
|
+
const before = this.#completionCapacity()
|
|
2023
|
+
const receipt = before.receipts.pending.find(item => item.id === controlReceiptId)
|
|
2024
|
+
const descriptor = this.#journal.effectDescriptor(effectId)
|
|
2025
|
+
if (!RECEIPT_ID.test(controlReceiptId || '') ||
|
|
2026
|
+
typeof chatId !== 'string' || !chatId ||
|
|
2027
|
+
!target || typeof target !== 'object' || Array.isArray(target) ||
|
|
2028
|
+
target.receiptId !== controlReceiptId || target.chatId !== chatId ||
|
|
2029
|
+
target.sourceId !== receipt?.sourceId ||
|
|
2030
|
+
target.replyCtx?.chatId !== chatId ||
|
|
2031
|
+
target.replyCtx?.messageId !== receipt?.sourceId ||
|
|
2032
|
+
typeof perform !== 'function' || typeof confirm !== 'function' ||
|
|
2033
|
+
typeof assertWriterLock !== 'function' ||
|
|
2034
|
+
descriptor?.domain !== 'control' || descriptor.type !== 'stopAck' ||
|
|
2035
|
+
descriptor.scope !== chatId || descriptor.receiptId !== controlReceiptId ||
|
|
2036
|
+
descriptor.targetId !== `receipt:control:${controlReceiptId}` ||
|
|
2037
|
+
descriptor.turnToken !== null ||
|
|
2038
|
+
this.#journal.effectStatus(effectId) !== 'pending' ||
|
|
2039
|
+
before.state.lanes.stopIntent?.[chatId] !== undefined ||
|
|
2040
|
+
before.state.lanes.turn?.[chatId]?.active !== null ||
|
|
2041
|
+
before.state.lanes.handoff?.[chatId]?.phase !== 'idle' ||
|
|
2042
|
+
before.state.lanes.queue?.[chatId]?.queue?.length !== 0 ||
|
|
2043
|
+
!isDeepStrictEqual(this.#coordinator.snapshot(), before.state) ||
|
|
2044
|
+
assertWriterLock() !== true) {
|
|
2045
|
+
this.close()
|
|
2046
|
+
throw new Error('C2 stop acknowledgement lacks exact durable target and effect')
|
|
2047
|
+
}
|
|
2048
|
+
const attemptedAt = this.#now()
|
|
2049
|
+
if (!Number.isSafeInteger(attemptedAt) || attemptedAt <= 0) {
|
|
2050
|
+
this.close()
|
|
2051
|
+
throw new TypeError('C2 stop acknowledgement time invalid')
|
|
2052
|
+
}
|
|
2053
|
+
this.#externalEffectInFlight = true
|
|
2054
|
+
try {
|
|
2055
|
+
this.#journal.beginEffectAttempt({ effectId, attemptedAt })
|
|
2056
|
+
let confirmed = false
|
|
2057
|
+
let response
|
|
2058
|
+
try {
|
|
2059
|
+
response = await perform(target)
|
|
2060
|
+
confirmed = confirm(response) === true &&
|
|
2061
|
+
this.#effectAuthorityReady && assertWriterLock() === true
|
|
2062
|
+
} catch { /* The platform outcome may have succeeded. */ }
|
|
2063
|
+
const now = this.#now()
|
|
2064
|
+
const resolvedAt = Number.isSafeInteger(now) && now > 0
|
|
2065
|
+
? Math.max(attemptedAt, now) : attemptedAt
|
|
2066
|
+
if (!confirmed) {
|
|
2067
|
+
if (assertWriterLock() === true) {
|
|
2068
|
+
this.#journal.recordEffectOutcome({ effectId, status: 'unknown',
|
|
2069
|
+
resolvedAt })
|
|
2070
|
+
}
|
|
2071
|
+
this.close()
|
|
2072
|
+
throw new Error('C2 stop acknowledgement outcome unknown')
|
|
2073
|
+
}
|
|
2074
|
+
this.#journal.settleStopAck({ effectId, receiptId: controlReceiptId,
|
|
2075
|
+
chatId, settledAt: resolvedAt })
|
|
2076
|
+
if (this.#journal.byteLength >= this.#maxJournalBytes) this.#sealForCapacity()
|
|
2077
|
+
return { effectId, receiptId: controlReceiptId, status: 'acked', response }
|
|
2078
|
+
} catch (error) { this.close(); throw error }
|
|
2079
|
+
finally { this.#externalEffectInFlight = false }
|
|
2080
|
+
}
|
|
2081
|
+
|
|
2082
|
+
// Development-only terminal executor. The normal Engine path never calls
|
|
2083
|
+
// this yet. It executes one externally visible reply only after a durable
|
|
2084
|
+
// attempt; a lost ACK leaves the receipt and effect ambiguous on restart.
|
|
2085
|
+
async executeTerminalEffect({ effectId, receiptId, chatId, turnToken, generation,
|
|
2086
|
+
verifyGeneration, resolveTarget, perform, confirm } = {}) {
|
|
2087
|
+
const before = this.#completionCapacity()
|
|
2088
|
+
if (typeof effectId !== 'string' || !RECEIPT_ID.test(receiptId) ||
|
|
2089
|
+
typeof chatId !== 'string' || !chatId || !GENERATION.test(generation) ||
|
|
2090
|
+
!Number.isSafeInteger(turnToken) || turnToken <= 0 ||
|
|
2091
|
+
typeof verifyGeneration !== 'function' ||
|
|
2092
|
+
verifyGeneration.constructor?.name === 'AsyncFunction') {
|
|
2093
|
+
throw new TypeError('C2 terminal effect identity invalid')
|
|
2094
|
+
}
|
|
2095
|
+
const receipt = before.receipts.pending.find(item => item.id === receiptId)
|
|
2096
|
+
const descriptor = this.#journal.effectDescriptor(effectId)
|
|
2097
|
+
const witness = this.#activeRuns.get(chatId)
|
|
2098
|
+
const terminalCapability = this.#terminalAwaitingSettlement.get(chatId)
|
|
2099
|
+
const terminal = before.state.lanes.terminal?.[chatId]
|
|
2100
|
+
const runningReceiptIds = terminal?.batchReceiptIds || [receiptId]
|
|
2101
|
+
const expectedType = receipt?.quiet || terminal?.outcome?.silent
|
|
2102
|
+
? 'quietCompletion' : 'finalReply'
|
|
2103
|
+
const registration = this.#journal.lookupRegistration(callbackRegistrationId({ receiptId,
|
|
2104
|
+
generation, domain: 'terminal', kind: 'finalizeEffectCommit' }))
|
|
2105
|
+
if (!receipt || receipt.chatId !== chatId ||
|
|
2106
|
+
witness?.receiptId !== receiptId || witness.turnToken !== turnToken ||
|
|
2107
|
+
!isDeepStrictEqual(witness.batchReceiptIds, runningReceiptIds) ||
|
|
2108
|
+
terminal?.replyTargetReceiptId !==
|
|
2109
|
+
(runningReceiptIds.length > 1 ? receiptId : undefined) ||
|
|
2110
|
+
runningReceiptIds.some(id =>
|
|
2111
|
+
before.receipts.pending.find(item => item.id === id)?.chatId !== chatId) ||
|
|
2112
|
+
terminalCapability?.effectId !== effectId ||
|
|
2113
|
+
terminalCapability.receiptId !== receiptId ||
|
|
2114
|
+
terminalCapability.turnToken !== turnToken ||
|
|
2115
|
+
terminalCapability.generation !== generation || terminalCapability.ready ||
|
|
2116
|
+
before.state.lanes.turn?.[chatId]?.active !== turnToken ||
|
|
2117
|
+
before.state.lanes.handoff?.[chatId]?.phase !== 'running' ||
|
|
2118
|
+
before.state.lanes.handoff[chatId].turnToken !== turnToken ||
|
|
2119
|
+
terminal?.finalized !== true ||
|
|
2120
|
+
terminal.pendingEffectIds?.at(-1) !== effectId ||
|
|
2121
|
+
!terminal.pendingEffectIds.slice(0, -1).every(id =>
|
|
2122
|
+
this.#journal.effectStatus(id) === 'acked') ||
|
|
2123
|
+
descriptor?.domain !== 'terminal' || descriptor.scope !== chatId ||
|
|
2124
|
+
descriptor.type !== expectedType || descriptor.turnToken !== turnToken ||
|
|
2125
|
+
descriptor.receiptId !== receiptId || descriptor.targetId !== null ||
|
|
2126
|
+
this.#journal.effectStatus(effectId) !== 'pending' ||
|
|
2127
|
+
registration?.generation !== generation || registration.receiptId !== receiptId ||
|
|
2128
|
+
verifyGeneration({ chatId, receiptId, turnToken, generation }) !== true) {
|
|
2129
|
+
throw new Error('C2 terminal effect lacks exact live authority')
|
|
2130
|
+
}
|
|
2131
|
+
if (expectedType === 'quietCompletion') {
|
|
2132
|
+
if (resolveTarget !== undefined || perform !== undefined || confirm !== undefined) {
|
|
2133
|
+
throw new Error('C2 quiet completion cannot perform an external effect')
|
|
2134
|
+
}
|
|
2135
|
+
terminalCapability.ready = true
|
|
2136
|
+
return { effectId, status: 'ready-to-settle', external: false }
|
|
2137
|
+
}
|
|
2138
|
+
if (typeof resolveTarget !== 'function' ||
|
|
2139
|
+
resolveTarget.constructor?.name === 'AsyncFunction' ||
|
|
2140
|
+
typeof perform !== 'function' || typeof confirm !== 'function' ||
|
|
2141
|
+
confirm.constructor?.name === 'AsyncFunction') {
|
|
2142
|
+
throw new TypeError('C2 final reply needs one confirmed platform operation')
|
|
2143
|
+
}
|
|
2144
|
+
const target = resolveTarget({ id: receipt.id, chatId: receipt.chatId,
|
|
2145
|
+
source: receipt.source, sourceId: receipt.sourceId })
|
|
2146
|
+
if (!target || typeof target !== 'object' || Array.isArray(target) ||
|
|
2147
|
+
typeof target.then === 'function' || target.receiptId !== receiptId ||
|
|
2148
|
+
target.chatId !== chatId || target.sourceId !== receipt.sourceId ||
|
|
2149
|
+
target.replyCtx == null) {
|
|
2150
|
+
throw new Error('C2 final reply target differs from durable receipt')
|
|
2151
|
+
}
|
|
2152
|
+
const attemptedAt = this.#now()
|
|
2153
|
+
if (!Number.isSafeInteger(attemptedAt) || attemptedAt <= 0) {
|
|
2154
|
+
throw new TypeError('C2 terminal effect time invalid')
|
|
2155
|
+
}
|
|
2156
|
+
this.#externalEffectInFlight = true
|
|
2157
|
+
try {
|
|
2158
|
+
this.#journal.beginEffectAttempt({ effectId, attemptedAt })
|
|
2159
|
+
let confirmed = false
|
|
2160
|
+
try {
|
|
2161
|
+
const reply = await perform({ effectId, target })
|
|
2162
|
+
confirmed = confirm(reply) === true &&
|
|
2163
|
+
this.#effectAuthorityReady &&
|
|
2164
|
+
verifyGeneration({ chatId, receiptId, turnToken, generation }) === true
|
|
2165
|
+
} catch { /* External outcome is unknown after the durable attempt. */ }
|
|
2166
|
+
const now = this.#now()
|
|
2167
|
+
const resolvedAt = Number.isSafeInteger(now) && now > 0
|
|
2168
|
+
? Math.max(attemptedAt, now) : attemptedAt
|
|
2169
|
+
if (!confirmed) {
|
|
2170
|
+
this.#journal.recordEffectOutcome({ effectId, status: 'unknown', resolvedAt })
|
|
2171
|
+
this.close()
|
|
2172
|
+
throw new Error('C2 final reply outcome unknown; manual recovery required')
|
|
2173
|
+
}
|
|
2174
|
+
terminalCapability.ready = true
|
|
2175
|
+
return { effectId, status: 'confirmed-pending-release', external: true }
|
|
2176
|
+
} catch (error) {
|
|
2177
|
+
this.close()
|
|
2178
|
+
throw error
|
|
2179
|
+
} finally { this.#externalEffectInFlight = false }
|
|
2180
|
+
}
|
|
2181
|
+
|
|
2182
|
+
// The runner-close and queue-release callbacks still need the pending
|
|
2183
|
+
// receipt. Only after both durable transitions may reply ACK and receipt
|
|
2184
|
+
// completion share one record. Successor dispatch remains held until prune.
|
|
2185
|
+
settleTerminalEffect({ effectId, receiptId, chatId, turnToken, generation } = {}) {
|
|
2186
|
+
const before = this.#completionCapacity()
|
|
2187
|
+
if (typeof effectId !== 'string' || !RECEIPT_ID.test(receiptId) ||
|
|
2188
|
+
typeof chatId !== 'string' || !chatId || !GENERATION.test(generation) ||
|
|
2189
|
+
!Number.isSafeInteger(turnToken) || turnToken <= 0) {
|
|
2190
|
+
throw new TypeError('C2 terminal settlement identity invalid')
|
|
2191
|
+
}
|
|
2192
|
+
const capability = this.#terminalAwaitingSettlement.get(chatId)
|
|
2193
|
+
const handoff = before.state.lanes.handoff?.[chatId]
|
|
2194
|
+
const receipt = before.receipts.pending.find(item => item.id === receiptId)
|
|
2195
|
+
const batchReceiptIds = capability?.batchReceiptIds
|
|
2196
|
+
const session = before.state.lanes.session?.[chatId]
|
|
2197
|
+
if (!capability || capability.effectId !== effectId ||
|
|
2198
|
+
capability.receiptId !== receiptId || capability.turnToken !== turnToken ||
|
|
2199
|
+
capability.generation !== generation || !capability.ready ||
|
|
2200
|
+
!capability.releaseCommitted || !receipt || receipt.chatId !== chatId ||
|
|
2201
|
+
session?.epoch !== turnToken || session.closeSeen !== true ||
|
|
2202
|
+
session.settled !== false ||
|
|
2203
|
+
!this.#journal.hasEvent(`sbot-c2-session-close-v1:${receiptId}:${generation}`) ||
|
|
2204
|
+
(batchReceiptIds !== null &&
|
|
2205
|
+
(!isDeepStrictEqual(before.state.lanes.terminal?.[chatId]?.batchReceiptIds,
|
|
2206
|
+
batchReceiptIds) ||
|
|
2207
|
+
before.state.lanes.terminal[chatId].replyTargetReceiptId !== receiptId ||
|
|
2208
|
+
batchReceiptIds.some(id =>
|
|
2209
|
+
before.receipts.pending.find(item => item.id === id)?.chatId !== chatId))) ||
|
|
2210
|
+
before.state.lanes.turn?.[chatId]?.active !== null ||
|
|
2211
|
+
!['idle', 'handoff'].includes(handoff?.phase) ||
|
|
2212
|
+
handoff.turnToken !== turnToken ||
|
|
2213
|
+
!isDeepStrictEqual(this.#coordinator.snapshot(), before.state)) {
|
|
2214
|
+
throw new Error('C2 terminal settlement requires confirmed release')
|
|
2215
|
+
}
|
|
2216
|
+
const settledAt = this.#now()
|
|
2217
|
+
if (!Number.isSafeInteger(settledAt) || settledAt <= 0) {
|
|
2218
|
+
throw new TypeError('C2 terminal settlement time invalid')
|
|
2219
|
+
}
|
|
2220
|
+
try {
|
|
2221
|
+
const result = this.#journal.settleTerminalEffect({ effectId, receiptId,
|
|
2222
|
+
chatId, turnToken, status: 'acked', settledAt,
|
|
2223
|
+
...(batchReceiptIds ? { batchReceiptIds: [...batchReceiptIds] } : {}) })
|
|
2224
|
+
pruneConfirmedTerminal({ journal: this.#journal,
|
|
2225
|
+
coordinator: this.#coordinator, scope: chatId })
|
|
2226
|
+
this.#terminalAwaitingSettlement.delete(chatId)
|
|
2227
|
+
if (this.#journal.byteLength >= this.#maxJournalBytes) this.#sealForCapacity()
|
|
2228
|
+
return { effectId, status: result, receiptId, pruned: true }
|
|
2229
|
+
} catch (error) { this.close(); throw error }
|
|
2230
|
+
}
|
|
2231
|
+
|
|
2232
|
+
// Development-only boundary for one Feishu card SDK operation. The adapter
|
|
2233
|
+
// supplies a stable operationId and a single-use invoke. This path never
|
|
2234
|
+
// authorizes the final text reply or the normal Engine runner.
|
|
2235
|
+
async performOutboundCardOperation({ receiptId, chatId, turnToken, generation,
|
|
2236
|
+
operation, verifyGeneration, confirm } = {}) {
|
|
2237
|
+
const before = this.#completionCapacity()
|
|
2238
|
+
if (!RECEIPT_ID.test(receiptId) || typeof chatId !== 'string' || !chatId ||
|
|
2239
|
+
!GENERATION.test(generation) || !Number.isSafeInteger(turnToken) ||
|
|
2240
|
+
turnToken <= 0 || typeof verifyGeneration !== 'function' ||
|
|
2241
|
+
verifyGeneration.constructor?.name === 'AsyncFunction' ||
|
|
2242
|
+
typeof confirm !== 'function' || confirm.constructor?.name === 'AsyncFunction') {
|
|
2243
|
+
throw new TypeError('C2 outbound card identity invalid')
|
|
2244
|
+
}
|
|
2245
|
+
const { operationId, eventId } = cardOperationIdentity({ receiptId,
|
|
2246
|
+
turnToken, generation, operation })
|
|
2247
|
+
const receipt = before.receipts.pending.find(item => item.id === receiptId)
|
|
2248
|
+
const witness = this.#activeRuns.get(chatId)
|
|
2249
|
+
const cleanupLane = before.state.lanes.cardCleanup?.[chatId]
|
|
2250
|
+
if (!receipt || receipt.chatId !== chatId ||
|
|
2251
|
+
(before.state.lanes.stopIntent?.[chatId] &&
|
|
2252
|
+
!(operation.kind === 'message.delete' && cleanupLane?.phase === 'intent')) ||
|
|
2253
|
+
(cleanupLane &&
|
|
2254
|
+
!(operation.kind === 'message.delete' && cleanupLane.phase === 'intent' &&
|
|
2255
|
+
cleanupLane.receiptId === receiptId &&
|
|
2256
|
+
cleanupLane.turnToken === turnToken &&
|
|
2257
|
+
cleanupLane.deleteOperationId === operationId)) ||
|
|
2258
|
+
(operation.chatId !== undefined && operation.chatId !== chatId) ||
|
|
2259
|
+
witness?.receiptId !== receiptId || witness.turnToken !== turnToken ||
|
|
2260
|
+
!witness.jobReceiptIds.includes(receiptId) ||
|
|
2261
|
+
!isDeepStrictEqual(witness.jobReceiptIds, witness.batchReceiptIds) ||
|
|
2262
|
+
!isDeepStrictEqual(before.state.lanes.handoff?.[chatId]?.acceptedReceiptIds,
|
|
2263
|
+
witness.batchReceiptIds.length > 1 ? witness.batchReceiptIds : []) ||
|
|
2264
|
+
witness.batchReceiptIds.some(id =>
|
|
2265
|
+
before.receipts.pending.find(item => item.id === id)?.chatId !== chatId) ||
|
|
2266
|
+
this.#journal.effectStatus(witness.dispatchEffectId) !== 'acked' ||
|
|
2267
|
+
before.state.lanes.turn?.[chatId]?.active !== turnToken ||
|
|
2268
|
+
before.state.lanes.handoff?.[chatId]?.phase !== 'running' ||
|
|
2269
|
+
before.state.lanes.handoff[chatId].turnToken !== turnToken ||
|
|
2270
|
+
this.#terminalAwaitingSettlement.has(chatId) ||
|
|
2271
|
+
!isDeepStrictEqual(this.#coordinator.snapshot(), before.state) ||
|
|
2272
|
+
verifyGeneration({ chatId, receiptId, turnToken, generation }) !== true) {
|
|
2273
|
+
throw new Error('C2 outbound card lacks exact live running receipt authority')
|
|
2274
|
+
}
|
|
2275
|
+
if (this.#journal.hasEvent(eventId)) {
|
|
2276
|
+
throw new Error('C2 outbound card operation already committed')
|
|
2277
|
+
}
|
|
2278
|
+
const effectId = `sbot-c2-effect-v1:${Buffer.byteLength(eventId)}:${eventId}:0`
|
|
2279
|
+
try {
|
|
2280
|
+
const prepared = this.#coordinator.prepare({ id: eventId,
|
|
2281
|
+
domain: 'outbound', scope: chatId,
|
|
2282
|
+
payload: { type: 'prepareCardOperation', kind: operation.kind,
|
|
2283
|
+
receiptId, turnToken, operationId },
|
|
2284
|
+
validate: candidate => {
|
|
2285
|
+
const descriptor = candidate.effectDescriptors?.[0]
|
|
2286
|
+
const effect = candidate.effects?.[0]
|
|
2287
|
+
if (candidate.cursor !== before.cursor + 1 ||
|
|
2288
|
+
candidate.state?.seq !== candidate.cursor ||
|
|
2289
|
+
!isDeepStrictEqual(candidate.state.lanes, before.state.lanes) ||
|
|
2290
|
+
!isDeepStrictEqual(candidate.decision, {
|
|
2291
|
+
type: operation.kind, operationId, receiptId, turnToken }) ||
|
|
2292
|
+
candidate.effects.length !== 1 || effect?.type !== operation.kind ||
|
|
2293
|
+
effect.operationId !== operationId ||
|
|
2294
|
+
effect.turnToken !== turnToken || effect.effectId !== effectId ||
|
|
2295
|
+
!isDeepStrictEqual(descriptor, { effectId, domain: 'outbound',
|
|
2296
|
+
scope: chatId, type: operation.kind, turnToken, receiptId,
|
|
2297
|
+
targetId: null })) {
|
|
2298
|
+
throw new Error('C2 outbound card result invalid')
|
|
2299
|
+
}
|
|
2300
|
+
} })
|
|
2301
|
+
prepared.commit()
|
|
2302
|
+
} catch (error) { this.close(); throw error }
|
|
2303
|
+
|
|
2304
|
+
const attemptedAt = this.#now()
|
|
2305
|
+
if (!Number.isSafeInteger(attemptedAt) || attemptedAt <= 0) {
|
|
2306
|
+
this.close()
|
|
2307
|
+
throw new TypeError('C2 outbound card time invalid')
|
|
2308
|
+
}
|
|
2309
|
+
this.#externalEffectInFlight = true
|
|
2310
|
+
try {
|
|
2311
|
+
this.#journal.beginEffectAttempt({ effectId, attemptedAt })
|
|
2312
|
+
let response
|
|
2313
|
+
let confirmed = false
|
|
2314
|
+
try {
|
|
2315
|
+
response = await operation.invoke()
|
|
2316
|
+
confirmed = confirm(response, operation) === true &&
|
|
2317
|
+
this.#effectAuthorityReady &&
|
|
2318
|
+
verifyGeneration({ chatId, receiptId, turnToken, generation }) === true
|
|
2319
|
+
} catch { /* The SDK call may have succeeded; never retry this ID. */ }
|
|
2320
|
+
const now = this.#now()
|
|
2321
|
+
const resolvedAt = Number.isSafeInteger(now) && now > 0
|
|
2322
|
+
? Math.max(attemptedAt, now) : attemptedAt
|
|
2323
|
+
this.#journal.recordEffectOutcome({ effectId,
|
|
2324
|
+
status: confirmed ? 'acked' : 'unknown', resolvedAt })
|
|
2325
|
+
if (!confirmed) {
|
|
2326
|
+
this.close()
|
|
2327
|
+
throw new Error('C2 outbound card outcome unknown; manual recovery required')
|
|
2328
|
+
}
|
|
2329
|
+
if (this.#journal.byteLength >= this.#maxJournalBytes) this.#sealForCapacity()
|
|
2330
|
+
return response
|
|
2331
|
+
} catch (error) { this.close(); throw error }
|
|
2332
|
+
finally { this.#externalEffectInFlight = false }
|
|
2333
|
+
}
|
|
2334
|
+
|
|
2335
|
+
recordEffect(effectId, status) {
|
|
2336
|
+
if (!this.#effectAuthorityReady || this.#opening || this.#externalEffectInFlight ||
|
|
2337
|
+
this.#journal.blocked) {
|
|
2338
|
+
throw new Error('C2 durable runtime effect authority closed')
|
|
2339
|
+
}
|
|
2340
|
+
const descriptor = this.#journal.effectDescriptor(effectId)
|
|
2341
|
+
if (descriptor?.type === 'scheduleDispatch' &&
|
|
2342
|
+
this.#terminalAwaitingSettlement.has(descriptor.scope)) {
|
|
2343
|
+
throw new Error('C2 successor schedule awaits prior terminal settlement')
|
|
2344
|
+
}
|
|
2345
|
+
const result = this.#journal.recordEffect(effectId, status)
|
|
2346
|
+
if (this.#journal.byteLength >= this.#maxJournalBytes) this.#sealForCapacity()
|
|
2347
|
+
return result
|
|
2348
|
+
}
|
|
2349
|
+
|
|
2350
|
+
snapshot() { return this.#journal.snapshot() }
|
|
2351
|
+
|
|
2352
|
+
scopeState(domain, scope) {
|
|
2353
|
+
if (typeof domain !== 'string' || typeof scope !== 'string' || !domain || !scope) {
|
|
2354
|
+
throw new TypeError('C2 scope required')
|
|
2355
|
+
}
|
|
2356
|
+
const lane = this.#journal.snapshot().state.lanes[domain]?.[scope]
|
|
2357
|
+
return lane === undefined ? null : structuredClone(lane)
|
|
2358
|
+
}
|
|
2359
|
+
}
|
|
2360
|
+
|
|
2361
|
+
module.exports = { C2DurableRuntime }
|