sintbot 1.4.8 → 1.4.10

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.
@@ -1204,16 +1204,56 @@ class C2DurableRuntime {
1204
1204
  // Isolated v2 terminal boundary. The callback is registered before the
1205
1205
  // runner is exposed, and only this process can prove which pending receipt
1206
1206
  // was actually dispatched. No text or reply context enters the journal.
1207
- registerTerminalCallback({ receiptId, generation, registeredAt } = {}) {
1207
+ registerTerminalCallback({ receiptId, generation, registeredAt, reuse = false } = {}) {
1208
1208
  this.#completionCapacity()
1209
1209
  const id = callbackRegistrationId({ receiptId, generation,
1210
1210
  domain: 'terminal', kind: 'finalizeEffectCommit' })
1211
+ if (reuse) {
1212
+ const prior = this.#journal.lookupRegistration(id)
1213
+ if (prior) {
1214
+ if (prior.receiptId !== receiptId || prior.generation !== generation ||
1215
+ prior.domain !== 'terminal' || prior.kind !== 'finalizeEffectCommit' ||
1216
+ prior.ordinal !== 0) throw new Error('C2 terminal registration identity differs')
1217
+ return prior
1218
+ }
1219
+ }
1211
1220
  const result = this.#journal.registerCallback({ id, receiptId, generation,
1212
1221
  domain: 'terminal', kind: 'finalizeEffectCommit', ordinal: 0, registeredAt })
1213
1222
  if (this.#journal.byteLength >= this.#maxJournalBytes) this.#sealForCapacity()
1214
1223
  return result
1215
1224
  }
1216
1225
 
1226
+ assertClosedFailureReady({ receiptId, chatId, turnToken, generation,
1227
+ verifyGeneration, assertWriterLock } = {}) {
1228
+ const before = this.#completionCapacity()
1229
+ const witness = this.#activeRuns.get(chatId)
1230
+ const proof = this.#journal.lookupRunnerClosed(
1231
+ `sbot-c2-runner-closed-v1:${receiptId}:${generation}`)
1232
+ const receipt = before.receipts.pending.find(item => item.id === receiptId)
1233
+ const dispatch = proof && this.#journal.effectDescriptor(proof.dispatchEffectId)
1234
+ if (this.blocked || assertWriterLock?.() !== true ||
1235
+ verifyGeneration?.({ receiptId, chatId, turnToken, generation }) !== true ||
1236
+ receipt?.chatId !== chatId || witness?.receiptId !== receiptId ||
1237
+ witness.turnToken !== turnToken || proof?.receiptId !== receiptId ||
1238
+ proof.chatId !== chatId || proof.turnToken !== turnToken ||
1239
+ proof.generation !== generation || dispatch?.type !== 'dispatch' ||
1240
+ dispatch.scope !== chatId || dispatch.receiptId !== receiptId ||
1241
+ dispatch.turnToken !== turnToken ||
1242
+ this.#journal.effectStatus(proof.dispatchEffectId) !== 'acked' ||
1243
+ !this.#journal.hasEvent(`sbot-c2-session-close-v1:${receiptId}:${generation}`) ||
1244
+ before.state.lanes.session?.[chatId]?.closeSeen !== true ||
1245
+ before.state.lanes.turn?.[chatId]?.active !== turnToken ||
1246
+ before.state.lanes.handoff?.[chatId]?.phase !== 'running' ||
1247
+ before.state.lanes.steer?.[chatId]?.entries?.some(item => item.status !== 'landed') ||
1248
+ [...before.effects].some(([id, status]) =>
1249
+ this.#journal.effectDescriptor(id)?.scope === chatId && status !== 'acked') ||
1250
+ !isDeepStrictEqual(this.#coordinator.snapshot(), before.state)) {
1251
+ this.close()
1252
+ throw new Error('C2 failure terminal lacks settled durable proof')
1253
+ }
1254
+ return true
1255
+ }
1256
+
1217
1257
  commitTerminalEffect({ receiptId, generation, turnToken, payload } = {}) {
1218
1258
  this.#completionCapacity()
1219
1259
  if (!RECEIPT_ID.test(receiptId) || !GENERATION.test(generation) ||
@@ -2200,7 +2240,7 @@ class C2DurableRuntime {
2200
2240
  // this yet. It executes one externally visible reply only after a durable
2201
2241
  // attempt; a lost ACK leaves the receipt and effect ambiguous on restart.
2202
2242
  async executeTerminalEffect({ effectId, receiptId, chatId, turnToken, generation,
2203
- verifyGeneration, resolveTarget, perform, confirm } = {}) {
2243
+ verifyGeneration, resolveTarget, perform, confirm, assertWriterLock = () => true } = {}) {
2204
2244
  const before = this.#completionCapacity()
2205
2245
  if (typeof effectId !== 'string' || !RECEIPT_ID.test(receiptId) ||
2206
2246
  typeof chatId !== 'string' || !chatId || !GENERATION.test(generation) ||
@@ -2242,7 +2282,8 @@ class C2DurableRuntime {
2242
2282
  descriptor.receiptId !== receiptId || descriptor.targetId !== null ||
2243
2283
  this.#journal.effectStatus(effectId) !== 'pending' ||
2244
2284
  registration?.generation !== generation || registration.receiptId !== receiptId ||
2245
- verifyGeneration({ chatId, receiptId, turnToken, generation }) !== true) {
2285
+ verifyGeneration({ chatId, receiptId, turnToken, generation }) !== true ||
2286
+ assertWriterLock() !== true) {
2246
2287
  throw new Error('C2 terminal effect lacks exact live authority')
2247
2288
  }
2248
2289
  if (expectedType === 'quietCompletion') {
@@ -2278,13 +2319,16 @@ class C2DurableRuntime {
2278
2319
  const reply = await perform({ effectId, target })
2279
2320
  confirmed = confirm(reply) === true &&
2280
2321
  this.#effectAuthorityReady &&
2322
+ assertWriterLock() === true &&
2281
2323
  verifyGeneration({ chatId, receiptId, turnToken, generation }) === true
2282
2324
  } catch { /* External outcome is unknown after the durable attempt. */ }
2283
2325
  const now = this.#now()
2284
2326
  const resolvedAt = Number.isSafeInteger(now) && now > 0
2285
2327
  ? Math.max(attemptedAt, now) : attemptedAt
2286
2328
  if (!confirmed) {
2287
- this.#journal.recordEffectOutcome({ effectId, status: 'unknown', resolvedAt })
2329
+ if (assertWriterLock() === true) {
2330
+ this.#journal.recordEffectOutcome({ effectId, status: 'unknown', resolvedAt })
2331
+ }
2288
2332
  this.close()
2289
2333
  throw new Error('C2 final reply outcome unknown; manual recovery required')
2290
2334
  }
@@ -147,12 +147,11 @@ function createC2LocalEffectExecutor({ journal, store, resolveTurn,
147
147
  const chatId = descriptor.scope
148
148
  if (effect.type === 'verifyTurn' && effect.success === false) {
149
149
  const outcome = journal.snapshot().state.lanes.terminal?.[chatId]?.outcome
150
- // A known quota failure says nothing about credential validity. The
150
+ // A failed task alone says nothing about credential validity. The
151
151
  // binary's failed verification intent is acknowledged without writing
152
152
  // a false success or altering the previous credential-health record.
153
- if (outcome?.status !== 'error' || outcome.quotaError !== true ||
154
- outcome.sideEffects !== false) {
155
- throw new Error('C2 local effect payload differs: failed verification lacks a supported quota outcome')
153
+ if (outcome?.status !== 'error') {
154
+ throw new Error('C2 local effect payload differs: failed verification lacks a core error outcome')
156
155
  }
157
156
  return true
158
157
  }
@@ -6,6 +6,10 @@
6
6
  // receive only the v2 decision and stable effect/receipt identities.
7
7
  const { C2DurableRuntime } = require('./c2durableruntime')
8
8
  const { C2PayloadHandles } = require('./c2payloadhandles')
9
+ const { engineTerminalStep } = require('../agent/privatecore')
10
+
11
+ const FAILURE_NOTICE = '本次任务执行失败。若已执行过工具,请先核对实际结果,再决定是否重试。'
12
+ const INTERRUPTED_NOTICE = '本次任务执行中断,未收到最终结果。已执行的操作可能已经生效,请核对后再决定是否重试。'
9
13
 
10
14
  const RECEIPT_ID = /^[0-9a-f]{32}$/
11
15
  const GENERATION = /^[0-9a-f]{32}$/
@@ -23,6 +27,15 @@ function terminalReplyText(payload) {
23
27
  typeof payload.answerText !== 'string') {
24
28
  throw new TypeError('C2 terminal reply text fields invalid')
25
29
  }
30
+ // The core classifies the result; the host supplies a fixed display string.
31
+ // Neither a provider error nor unfinished assistant text is a final answer.
32
+ if (payload.source === 'runner-exit' || payload.source === 'result' && payload.isError === true) {
33
+ const event = { ...payload, type: 'finalize' }
34
+ delete event.receiptId; delete event.turnToken; delete event.quiet
35
+ if (engineTerminalStep({ finalized: false, outcome: null }, event)
36
+ .state.outcome.status === 'error') return payload.source === 'runner-exit'
37
+ ? INTERRUPTED_NOTICE : FAILURE_NOTICE
38
+ }
26
39
  return (payload.answerText.trim() ? payload.answerText : payload.bufferText) ||
27
40
  payload.resultText
28
41
  }
@@ -106,7 +119,7 @@ function prepareTerminalEffectBridge({ runtime, payloadHandles, receiptId, chatI
106
119
  }
107
120
  }
108
121
  const effect = { effectId: final.effectId, receiptId, chatId,
109
- turnToken, generation, verifyGeneration }
122
+ turnToken, generation, verifyGeneration, assertWriterLock }
110
123
  if (final.type === 'quietCompletion') {
111
124
  if (replyText !== undefined || resolveTarget !== undefined ||
112
125
  sendReply !== undefined || confirmReply !== undefined) {
@@ -8,11 +8,11 @@ const NO_REPLY = 'NO_REPLY'
8
8
  const { isDeepStrictEqual } = require('node:util')
9
9
 
10
10
  function createC2TurnProjection({ receiptId, turnToken, quiet, resumed = false,
11
- freshRetried = false, batchReceiptIds } = {}) {
11
+ freshRetried = false, batchReceiptIds, requireResult = false } = {}) {
12
12
  if (!RECEIPT_ID.test(receiptId || '') ||
13
13
  !Number.isSafeInteger(turnToken) || turnToken <= 0 ||
14
14
  typeof quiet !== 'boolean' || typeof resumed !== 'boolean' ||
15
- typeof freshRetried !== 'boolean') {
15
+ typeof freshRetried !== 'boolean' || typeof requireResult !== 'boolean') {
16
16
  throw new TypeError('C2 projection needs a typed running receipt')
17
17
  }
18
18
  if (batchReceiptIds !== undefined &&
@@ -137,10 +137,11 @@ function createC2TurnProjection({ receiptId, turnToken, quiet, resumed = false,
137
137
  throw new TypeError('C2 projection current resumed session requires abort')
138
138
  }
139
139
  completed = true
140
+ const runnerExit = requireResult && !observedResult && !runnerResult.aborted
140
141
  const resultText = observedResult?.text || ''
141
142
  const payload = Object.freeze({
142
143
  type: 'finalizeEffectCommit', receiptId, turnToken, quiet,
143
- source: observedResult ? 'result' : 'close',
144
+ source: observedResult ? 'result' : runnerExit ? 'runner-exit' : 'close',
144
145
  aborted: runnerResult.aborted,
145
146
  isError: observedResult?.isError || false,
146
147
  resumed, sessionMissing: observedResult?.sessionMissing || false,
@@ -156,10 +157,12 @@ function createC2TurnProjection({ receiptId, turnToken, quiet, resumed = false,
156
157
  const display = Object.freeze({
157
158
  resultText, bufferText, answerText, fullText,
158
159
  finalAnswer: (answerText.trim() ? answerText : bufferText) || resultText,
159
- silent: quiet || resultText.trim() === NO_REPLY ||
160
- (!resultText.trim() && bufferText.trim() === NO_REPLY),
160
+ silent: quiet || !runnerExit && (resultText.trim() === NO_REPLY ||
161
+ (!resultText.trim() && bufferText.trim() === NO_REPLY)),
161
162
  assistantEvents, tools,
162
- sessionId: runnerResult.sessionId || sessionId,
163
+ // A crashed process may never have flushed its new session. Retain the
164
+ // previously stored session; never publish an unproven new session ID.
165
+ sessionId: runnerExit ? null : runnerResult.sessionId || sessionId,
163
166
  contextTokens,
164
167
  usage: result?.usage || null,
165
168
  usageCumulative: result?.usageCumulative === true,
@@ -39,6 +39,31 @@ function humanDuration(ms) {
39
39
  if (minutes < 60) return `${minutes} 分 ${seconds % 60} 秒`
40
40
  return `${Math.floor(minutes / 60)} 小时 ${minutes % 60} 分`
41
41
  }
42
+ // The final card is an accounting line: keep seconds for short turns and
43
+ // switch to minutes/hours before a long turn becomes an unreadable number.
44
+ function cardDuration(ms) {
45
+ const seconds = Math.max(0, Math.round(ms / 1000))
46
+ if (seconds < 60) return `${seconds} 秒`
47
+ const minutes = Math.floor(seconds / 60)
48
+ if (minutes < 60) return seconds % 60 ? `${minutes} 分 ${seconds % 60} 秒` : `${minutes} 分钟`
49
+ const hours = Math.floor(minutes / 60)
50
+ return minutes % 60 ? `${hours} 小时 ${minutes % 60} 分` : `${hours} 小时`
51
+ }
52
+ function cardModelInfo(group, provider) {
53
+ if (!provider) return { model: '', effort: '' }
54
+ const model = group?.model || provider.model ||
55
+ (provider.runner === 'codex' ? '' : provider.env?.ANTHROPIC_MODEL) || ''
56
+ const effort = provider.runner === 'codex'
57
+ ? provider.reasoningEffort : provider.env?.CLAUDE_CODE_EFFORT_LEVEL
58
+ const clean = value => String(value || '').replace(/\s+/g, ' ').trim()
59
+ return { model: clean(model), effort: clean(effort) }
60
+ }
61
+ function cardModelDetail(info, result) {
62
+ // A rejected configured effort may have been retried at the model default.
63
+ const fellBack = result?.effortFellBack || result?.result?.effortFellBack
64
+ return [info?.model, fellBack ? '' : info?.effort]
65
+ .filter(Boolean).join(' ')
66
+ }
42
67
  // Group texts name the project by its folder only, never by a machine path.
43
68
  function projectName(group) {
44
69
  return String(group?.workdir || '').split(/[\\/]/).filter(Boolean).pop() || '(未设)'
@@ -104,9 +129,11 @@ const T = {
104
129
  skipPending: '正在跳过当前任务,这条消息没有处理,请稍后重发。',
105
130
  quotaFailure: 'Codex 订阅额度已用完,本次任务未完成。请恢复额度后重新发送;本次请求不会自动重试。',
106
131
  cardFooter: ({ ms, tokens }) => {
107
- const parts = [`耗时 ${Math.max(0, Math.round(ms / 1000))} 秒`]
132
+ const parts = [`耗时 ${cardDuration(ms)}`]
108
133
  const tokenLabel = n => n >= 1000 ? (n / 1000).toFixed(1) + 'k' : String(n)
109
- if (tokens) parts.push(`tokens ↑${tokenLabel((tokens.in || 0) + (tokens.cr || 0) + (tokens.cw || 0))} ↓${tokenLabel(tokens.out || 0)}`)
134
+ if (tokens && ((tokens.in || 0) + (tokens.cr || 0) + (tokens.cw || 0) + (tokens.out || 0)) > 0) {
135
+ parts.push(`tokens ↑${tokenLabel((tokens.in || 0) + (tokens.cr || 0) + (tokens.cw || 0))} ↓${tokenLabel(tokens.out || 0)}`)
136
+ }
110
137
  return parts.join(' · ')
111
138
  },
112
139
  }
@@ -136,6 +163,21 @@ function supportedV2QuotaFailure(payload, runnerResult) {
136
163
  outcome.sideEffects === false
137
164
  }
138
165
 
166
+ // Admit only an observed, closed result on this release's no-retry route.
167
+ // Error classification remains in the core; close/session/effect authority is
168
+ // checked by the terminal path before any delivery can be attempted.
169
+ function supportedV2ClosedFailure(payload, runnerResult) {
170
+ if (!payload || !['result', 'runner-exit'].includes(payload.source) || payload.aborted ||
171
+ payload.sessionMissing || payload.transientNetwork || payload.hasQuota ||
172
+ payload.freshRetried || payload.resumedSessionCurrent ||
173
+ runnerResult?.aborted !== false ||
174
+ (payload.source === 'runner-exit' && runnerResult.result != null)) return false
175
+ const terminal = { ...payload, type: 'finalize' }
176
+ delete terminal.receiptId; delete terminal.turnToken; delete terminal.quiet
177
+ return engineTerminalStep({ finalized: false, outcome: null }, terminal)
178
+ .state.outcome.status === 'error'
179
+ }
180
+
139
181
  function ordinaryV2Cohort(snapshot, jobs, binding, chatId, receiptId) {
140
182
  if (!Array.isArray(jobs) || !jobs.length ||
141
183
  jobs.some(job => !job || job.msg?.batch || job.msg?.synthetic ||
@@ -781,7 +823,9 @@ async _runDurableV2Steer(rt, receiptId) {
781
823
  invoke: () => control.steerDurable({ msgId: job.msg.msgId, text }) })
782
824
  entry.accepted = accepted
783
825
  if (accepted) {
784
- if (entry.landed) authority.commitSteerObservation({ ...identity, type: 'landed' })
826
+ if (entry.landed) {
827
+ authority.commitSteerObservation({ ...identity, type: 'landed' })
828
+ }
785
829
  return { receiptId, steered: true, accepted: true, landed: entry.landed, staged: true }
786
830
  }
787
831
  if (entry.landed) throw new Error('C2 rejected steer also landed; outcome inconsistent')
@@ -803,9 +847,20 @@ async _runDurableV2Steer(rt, receiptId) {
803
847
  return { receiptId, queued: true, staged: true, accepted: false }
804
848
  })()
805
849
  rt.v2QueueNoticePromise = work
806
- try { return await work }
850
+ let outcome
851
+ try { outcome = await work }
807
852
  catch (error) { authority.close(); rt.v2Controller?.abort(); this.durableV2SealCapacity?.(error); throw error }
808
853
  finally { if (rt.v2QueueNoticePromise === work) rt.v2QueueNoticePromise = null }
854
+ try {
855
+ if (outcome.accepted && entry.landed) {
856
+ await rt.v2SteerLandedChain
857
+ await rt.v2MoveCard?.(entry)
858
+ }
859
+ return outcome
860
+ } catch (error) {
861
+ authority.close(); rt.v2Controller?.abort(); this.durableV2SealCapacity?.(error)
862
+ throw error
863
+ }
809
864
  }
810
865
 
811
866
  async runDurableV2Stop(msg, platform) {
@@ -1203,7 +1258,8 @@ async _drainDurableV2NormalChain(initial) {
1203
1258
  const outcome = engineTerminalStep({ finalized: false, outcome: null },
1204
1259
  terminal).state.outcome
1205
1260
  const quotaFailure = supportedV2QuotaFailure(payload, rt.v2RunnerResult)
1206
- if (['error', 'empty', 'stopped'].includes(outcome.status) && !quotaFailure) {
1261
+ const closedFailure = supportedV2ClosedFailure(payload, rt.v2RunnerResult)
1262
+ if (['error', 'empty', 'stopped'].includes(outcome.status) && !quotaFailure && !closedFailure) {
1207
1263
  const classified = this.classifyDurableV2UnresolvedTurn({ ...identity,
1208
1264
  payload })
1209
1265
  const error = new Error('C2 v2 ordinary result requires fault recovery')
@@ -1211,10 +1267,10 @@ async _drainDurableV2NormalChain(initial) {
1211
1267
  error.classificationEventId = classified.eventId
1212
1268
  throw error
1213
1269
  }
1214
- if (payload.source !== 'result' || payload.sessionMissing ||
1270
+ if ((payload.source !== 'result' && !closedFailure) || payload.sessionMissing ||
1215
1271
  (payload.hasQuota && !quotaFailure) || payload.transientNetwork || payload.freshRetried ||
1216
1272
  payload.resumedSessionCurrent ||
1217
- (!['ok', 'no-reply'].includes(outcome.status) && !quotaFailure)) {
1273
+ (!['ok', 'no-reply'].includes(outcome.status) && !quotaFailure && !closedFailure)) {
1218
1274
  authority.close()
1219
1275
  const error = new Error('C2 v2 ordinary result needs a retry or notice decision')
1220
1276
  error.code = 'C2_V2_UNRESOLVED_TURN'
@@ -1232,7 +1288,7 @@ async _drainDurableV2NormalChain(initial) {
1232
1288
  for (const settledReceiptId of completed.settledReceiptIds ||
1233
1289
  [identity.receiptId]) {
1234
1290
  settled.push({ receiptId: settledReceiptId,
1235
- status: quotaFailure ? 'error' : silent ? 'silent' : 'answered',
1291
+ status: quotaFailure || closedFailure ? 'error' : silent ? 'silent' : 'answered',
1236
1292
  terminalEffectId: completed.terminalEffectId })
1237
1293
  }
1238
1294
  if (authority.blocked) {
@@ -1436,7 +1492,12 @@ async startDurableV2Runner({ chatId, receiptId, turnToken, effectId } = {}) {
1436
1492
  rt.v2Doing = []
1437
1493
  rt.v2AutoMilestones = []
1438
1494
  rt.v2CardQuotaFailure = false
1495
+ rt.v2CardModelInfo = null
1439
1496
  rt.v2CardBinding = null
1497
+ rt.v2CardAnchor = null
1498
+ rt.v2CardJoinedNames = []
1499
+ rt.v2LastCardText = ''
1500
+ rt.v2MoveCard = null
1440
1501
  rt.v2StreamRequested = false
1441
1502
  rt.v2StreamQuiescing = false
1442
1503
  rt.v2StreamChain = Promise.resolve()
@@ -1463,7 +1524,7 @@ async startDurableV2Runner({ chatId, receiptId, turnToken, effectId } = {}) {
1463
1524
  throw new Error('C2 v2 runner effect changed before start')
1464
1525
  }
1465
1526
  const projection = createC2TurnProjection({ receiptId, turnToken,
1466
- quiet: receipt.quiet,
1527
+ quiet: receipt.quiet, requireResult: true,
1467
1528
  resumed: !!this.store.getSession(chatId), freshRetried: false,
1468
1529
  ...(cohort.receiptIds.length > 1
1469
1530
  ? { batchReceiptIds: cohort.receiptIds } : {}) })
@@ -1472,6 +1533,7 @@ async startDurableV2Runner({ chatId, receiptId, turnToken, effectId } = {}) {
1472
1533
  if (!provider || (provider.baseUrl && !provider.apiKey)) {
1473
1534
  throw new Error('C2 v2 runner provider unavailable')
1474
1535
  }
1536
+ rt.v2CardModelInfo = cardModelInfo(job.group, provider)
1475
1537
  rt.v2SteerControl = null
1476
1538
  rt.v2SteerEntries = new Map()
1477
1539
  rt.v2SteerClosing = false
@@ -1516,6 +1578,7 @@ async startDurableV2Runner({ chatId, receiptId, turnToken, effectId } = {}) {
1516
1578
  if (rt.v2CardBinding?.opened) await rt.v2CardBinding.path.flushForTerminal()
1517
1579
  if (rt.v2RunnerGeneration !== generation) return
1518
1580
  authority.commitSteerObservation({ ...steerIdentity, receiptId: entry.identity.receiptId, type: 'landed' })
1581
+ await rt.v2MoveCard?.(entry)
1519
1582
  })
1520
1583
  rt.v2SteerLandedChain = observation
1521
1584
  observation.catch(() => { authority.close(); controller?.abort() })
@@ -1531,6 +1594,8 @@ async startDurableV2Runner({ chatId, receiptId, turnToken, effectId } = {}) {
1531
1594
  rt.v2RunnerNotStarted = false
1532
1595
  let streamChain = Promise.resolve()
1533
1596
  let streamFailure = null
1597
+ const joinedLine = () => rt.v2CardJoinedNames.length
1598
+ ? `\n\n↪ 已加入 ${rt.v2CardJoinedNames.join('、')} 的补充` : ''
1534
1599
  // Card progress (same mechanism and wording as the legacy engine): deterministic
1535
1600
  // milestones and "recently doing" from tool calls, an early "received" card
1536
1601
  // and a pulse line. All of it is process-local card text; the card itself is
@@ -1547,6 +1612,7 @@ async startDurableV2Runner({ chatId, receiptId, turnToken, effectId } = {}) {
1547
1612
  rt.v2TerminalStarted || controller.signal.aborted || rt.v2CancelControl ||
1548
1613
  rt.v2StreamQuiescing || !!authority.scopeState('stopIntent', chatId)
1549
1614
  const enqueueCardText = text => {
1615
+ rt.v2LastCardText = text
1550
1616
  rt.v2StreamRequested = true
1551
1617
  streamChain = streamChain.then(async () => {
1552
1618
  if (rt.v2QueueNoticePromise) await rt.v2QueueNoticePromise
@@ -1558,7 +1624,8 @@ async startDurableV2Runner({ chatId, receiptId, turnToken, effectId } = {}) {
1558
1624
  if (!rt.v2CardBinding) {
1559
1625
  const path = prepareCardTerminalPath({ runtime: authority,
1560
1626
  payloadHandles: this.durableV2Payloads, platform: job.platform,
1561
- replyCtx: job.msg.replyCtx, receiptId, chatId, turnToken,
1627
+ replyCtx: job.msg.replyCtx, cardAnchor: rt.v2CardAnchor,
1628
+ receiptId, chatId, turnToken,
1562
1629
  generation, verifyGeneration,
1563
1630
  applyLocalEffect: this.durableV2ApplyLocalEffect,
1564
1631
  assertWriterLock: this.durableV2AssertWriterLock,
@@ -1573,7 +1640,7 @@ async startDurableV2Runner({ chatId, receiptId, turnToken, effectId } = {}) {
1573
1640
  rt.v2CardBinding = { path, receiptId, turnToken,
1574
1641
  generation, opened }
1575
1642
  }
1576
- if (rt.v2CardBinding.opened) rt.v2CardBinding.path.update(text)
1643
+ if (rt.v2CardBinding.opened) rt.v2CardBinding.path.update(text + joinedLine())
1577
1644
  })
1578
1645
  rt.v2StreamChain = streamChain
1579
1646
  streamChain.catch(error => {
@@ -1581,6 +1648,33 @@ async startDurableV2Runner({ chatId, receiptId, turnToken, effectId } = {}) {
1581
1648
  try { controller.abort() } catch {}
1582
1649
  })
1583
1650
  }
1651
+ rt.v2MoveCard = entry => {
1652
+ if (entry.movePromise) return entry.movePromise
1653
+ const move = streamChain.then(async () => {
1654
+ if (progressClosed() || job.platform.features?.cardPinned !== true) return false
1655
+ const anchor = { receiptId: entry.identity.receiptId,
1656
+ replyCtx: entry.job.msg.replyCtx }
1657
+ const who = entry.job.msg.senderName || entry.job.msg.userId || 'user'
1658
+ if (!rt.v2CardJoinedNames.includes(who)) rt.v2CardJoinedNames.push(who)
1659
+ if (!rt.v2CardBinding) {
1660
+ rt.v2CardAnchor = anchor
1661
+ return true
1662
+ }
1663
+ if (!rt.v2CardBinding.opened) return false
1664
+ rt.v2CardBinding.path.update((rt.v2LastCardText || progress.T.ackWorking) + joinedLine())
1665
+ const moved = await rt.v2CardBinding.path.relocate(anchor)
1666
+ if (moved) rt.v2CardAnchor = anchor
1667
+ return moved
1668
+ })
1669
+ streamChain = move
1670
+ rt.v2StreamChain = move
1671
+ entry.movePromise = move
1672
+ move.catch(error => {
1673
+ streamFailure = error
1674
+ try { controller.abort() } catch {}
1675
+ })
1676
+ return move
1677
+ }
1584
1678
  const enqueueFrame = event => {
1585
1679
  if (event.type !== 'assistant') return
1586
1680
  // /status and the pulse read these; a late event from an older
@@ -1650,10 +1744,8 @@ async startDurableV2Runner({ chatId, receiptId, turnToken, effectId } = {}) {
1650
1744
  const sofar = buffer.trim()
1651
1745
  const body = autoBlock() + (sofar && !NO_REPLY.startsWith(sofar)
1652
1746
  ? progress.renderMilestones(buffer) + '\n\n' : '')
1653
- try {
1654
- binding.path.update(body + progress.T.pulse(
1655
- progress.humanDur(Date.now() - pulseFrom), rt.v2Doing, stale))
1656
- } catch { /* a closed card never affects the turn */ }
1747
+ enqueueCardText(body + progress.T.pulse(
1748
+ progress.humanDur(Date.now() - pulseFrom), rt.v2Doing, stale))
1657
1749
  }, PULSE_MS)
1658
1750
  if (pulseTimer.unref) pulseTimer.unref()
1659
1751
  }
@@ -1872,6 +1964,7 @@ async completeDurableV2CardTurn({ chatId, receiptId, turnToken, effectId,
1872
1964
  const projected = rt?.v2TerminalProjection
1873
1965
  const resultText = projected?.payload?.resultText
1874
1966
  const quotaFailure = supportedV2QuotaFailure(payload, rt?.v2RunnerResult)
1967
+ const closedFailure = supportedV2ClosedFailure(payload, rt?.v2RunnerResult)
1875
1968
  if (!/^[0-9a-f]{32}$/.test(receiptId || '') ||
1876
1969
  !Number.isSafeInteger(turnToken) || turnToken <= 0 ||
1877
1970
  typeof effectId !== 'string' || !effectId ||
@@ -1903,10 +1996,9 @@ async completeDurableV2CardTurn({ chatId, receiptId, turnToken, effectId,
1903
1996
  (!isDeepStrictEqual(payload.batchReceiptIds, cohort.receiptIds) ||
1904
1997
  payload.replyTargetReceiptId !== receiptId)) ||
1905
1998
  !isDeepStrictEqual(payload, projected.payload) ||
1906
- // Only successful answers and binary-classified, side-effect-free
1907
- // quota failures use this no-retry terminal bridge. Unknown failures
1908
- // and lost-session outcomes still need a separate recovery decision.
1909
- payload.source !== 'result' || payload.aborted || (payload.isError && !quotaFailure) ||
1999
+ // A deterministic failure is delivered once, after the same durable
2000
+ // close checks as success. Retry and unknown outcomes stay sealed.
2001
+ (payload.source !== 'result' && !closedFailure) || payload.aborted || (payload.isError && !quotaFailure && !closedFailure) ||
1910
2002
  payload.sessionMissing || (payload.hasQuota && !quotaFailure) || payload.transientNetwork ||
1911
2003
  payload.freshRetried || payload.resumedSessionCurrent ||
1912
2004
  (replyText !== undefined && replyText !== terminalReplyText(projected.payload)) ||
@@ -1920,6 +2012,9 @@ async completeDurableV2CardTurn({ chatId, receiptId, turnToken, effectId,
1920
2012
  value.generation === generation && rt.v2RunnerGeneration === generation
1921
2013
  try {
1922
2014
  const preopened = rt.v2CardBinding
2015
+ if (closedFailure) authority.assertClosedFailureReady({ receiptId, chatId,
2016
+ turnToken, generation, verifyGeneration,
2017
+ assertWriterLock: this.durableV2AssertWriterLock })
1923
2018
  if (preopened && (preopened.receiptId !== receiptId ||
1924
2019
  preopened.turnToken !== turnToken ||
1925
2020
  preopened.generation !== generation)) {
@@ -1927,7 +2022,8 @@ async completeDurableV2CardTurn({ chatId, receiptId, turnToken, effectId,
1927
2022
  }
1928
2023
  const path = preopened?.path || prepareCardTerminalPath({ runtime: authority,
1929
2024
  payloadHandles: handles, platform: job.platform,
1930
- replyCtx: job.msg.replyCtx, receiptId, chatId, turnToken, generation,
2025
+ replyCtx: job.msg.replyCtx, cardAnchor: rt.v2CardAnchor, preferPlain: closedFailure,
2026
+ receiptId, chatId, turnToken, generation,
1931
2027
  verifyGeneration, applyLocalEffect: this.durableV2ApplyLocalEffect,
1932
2028
  assertWriterLock: this.durableV2AssertWriterLock,
1933
2029
  redactText: text => redact(quotaFailure
@@ -1942,6 +2038,8 @@ async completeDurableV2CardTurn({ chatId, receiptId, turnToken, effectId,
1942
2038
  for (const text of updates) path.update(text)
1943
2039
  const footer = T.cardFooter({ ms: Math.max(0, Date.now() - rt.v2StartedAt),
1944
2040
  tokens: rt.v2UsageDecision?.tokens || null })
2041
+ const footerDetail = job.platform.features?.cardQuota === true
2042
+ ? cardModelDetail(rt.v2CardModelInfo, rt.v2RunnerResult) : ''
1945
2043
  // Milestones from tool calls first, then the model's own `>> ` lines;
1946
2044
  // the card shows them in its "process" fold (display only).
1947
2045
  rt.v2CardQuotaFailure = !!quotaFailure
@@ -1950,7 +2048,8 @@ async completeDurableV2CardTurn({ chatId, receiptId, turnToken, effectId,
1950
2048
  ...progress.milestonesOf(payload.bufferText)]
1951
2049
  .filter((m, i, a) => a.indexOf(m) === i).map(redactMilestone)
1952
2050
  .filter(m => typeof m === 'string' && m.trim())
1953
- const completed = await path.complete({ payload, replyText, paused, footer, milestones })
2051
+ const completed = await path.complete({ payload, replyText, paused,
2052
+ footer, footerDetail, milestones })
1954
2053
  const after = authority.snapshot()
1955
2054
  const queue = after.state?.lanes?.queue?.[chatId]?.queue
1956
2055
  let successor = null
@@ -2064,7 +2163,7 @@ classifyDurableV2UnresolvedTurn({ chatId, receiptId, turnToken, effectId,
2064
2163
  }
2065
2164
  rt.v2TerminalStarted = true
2066
2165
  authority.registerTerminalCallback({ receiptId, generation,
2067
- registeredAt: Date.now() })
2166
+ registeredAt: Date.now(), reuse: true })
2068
2167
  return authority.commitUnresolvedClassification({ receiptId, generation,
2069
2168
  turnToken, status, payload,
2070
2169
  verifyGeneration: value => value.chatId === chatId &&
@@ -605,7 +605,7 @@ function createFeishuPlatform({ appId, appSecret, botOpenId = null, onlyWhenMent
605
605
  }
606
606
 
607
607
  return {
608
- features: { cardQuota: true, noticeCard: true }, // 飞书支持通知卡;企微/Slack 退回文本
608
+ features: { cardQuota: true, noticeCard: true, cardPinned: true }, // 回复卡钉在触发消息下;补话落地后可搬到新消息下
609
609
  async start(onMessage, hooks = {}) {
610
610
  if (!botOpenId && !onlyWhenMentioned) log('warning: feishu.botOpenId not set — cannot tell @bot from @others; all group messages will be handled')
611
611
  if (hooks.onFatal !== undefined && typeof hooks.onFatal !== 'function') {