@whaletalk/dsh-codex-collab 0.1.9 → 0.1.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.
@@ -0,0 +1,217 @@
1
+ /** Shared, bounded observation of session history. Never retain full message history. */
2
+ import { assistantTextOf } from './session-target.mjs'
3
+ const compact = (event) => {
4
+ const d = event.data || {}
5
+ const fields = {
6
+ 'turn/start': ['turn'], 'turn/end': ['turn', 'reason'],
7
+ 'tool/call': ['callId', 'turn', 'step'],
8
+ 'approval/asked': ['id', 'toolName', 'callId'],
9
+ 'approval/decided': ['id', 'outcome'],
10
+ 'user/message': ['turn', 'source'], 'model/selection': [],
11
+ }[event.type]
12
+ if (!fields) return null
13
+ const data = {}
14
+ for (const key of fields) {
15
+ if (d[key] === undefined) continue
16
+ data[key] = key === 'source' ? { rpcId: d.source?.rpcId }
17
+ : key === 'reason' ? { kind: d.reason?.kind } : d[key]
18
+ }
19
+ return { seq: event.seq, type: event.type, data }
20
+ }
21
+ const replayEvent = (event) => {
22
+ if (event.type !== 'assistant/message') return compact(event) || { seq: event.seq, type: event.type, data: {} }
23
+ const text = assistantTextOf(event)
24
+ return { seq: event.seq, type: event.type, data: { turn: event.data?.turn, step: event.data?.step, message: { content: [{ type: 'text', text }] } } }
25
+ }
26
+ const weight = (event) => JSON.stringify(event).length * 2 + 128
27
+
28
+ export const createSessionObservationHub = (options = {}) => {
29
+ const entries = new Map()
30
+ const counters = { reads: 0, observations: 0, events: 0, gaps: 0, failures: 0 }
31
+ const maxEvidence = options.maxEvidence ?? 32768
32
+ const now = options.now || Date.now
33
+ let closed = false
34
+ const maxTailBytes = options.maxTailBytes ?? 4 * 1024 * 1024
35
+ const retain = (e, event) => {
36
+ if (!e.listeners.size) return
37
+ const value = replayEvent(event), bytes = weight(value)
38
+ if (bytes > maxTailBytes) { e.tail = []; e.tailBytes = 0; return }
39
+ e.tail.push(value); e.tailBytes = (e.tailBytes || 0) + bytes
40
+ while (e.tail.length > 256 || e.tailBytes > maxTailBytes) e.tailBytes -= weight(e.tail.shift())
41
+ }
42
+ const query = () => typeof options.query === 'function' ? options.query() : options.query
43
+ const live = (id) => { try { return options.liveSession?.(id) } catch { return null } }
44
+ const entry = (id) => {
45
+ if (!entries.has(id) && entries.size >= 32) {
46
+ const idle = [...entries.values()].filter(e => !e.listeners.size && !e.inflight).sort((a,b) => a.touched - b.touched)[0]
47
+ if (idle) { idle.stream?.abort(); entries.delete(idle.id) }
48
+ }
49
+ let e = entries.get(id)
50
+ if (!e) { e = { id, cursor: -1, ready: false, evidence: [], overflow: false, tail: [], listeners: new Set(), buffered: [], reading: false, requested: 0, completed: 0, inflight: null, stream: null, connected: false, touched: now(), object: null }; if (!closed) entries.set(id, e) }
51
+ e.touched = now()
52
+ return e
53
+ }
54
+ const headMatches = (e) => {
55
+ const s = live(e.id)
56
+ return e.ready && s && s === e.object?.deref() && Number.isInteger(s.seq) && s.seq - 1 === e.cursor
57
+ }
58
+ const uncertain = (e) => { e.connected = false; for (const sub of e.listeners) sub.onUncertain?.() }
59
+ const deliver = (e, event) => {
60
+ for (const sub of [...e.listeners]) {
61
+ if (sub.signal?.aborted || event.seq <= sub.afterSeq) continue
62
+ sub.afterSeq = event.seq
63
+ sub.onEvent?.(event)
64
+ }
65
+ }
66
+ const accept = (e, event) => {
67
+ if (closed || !event || !Number.isInteger(event.seq)) return
68
+ if (e.reading || !e.ready) {
69
+ const value = replayEvent(event), bytes = weight(value)
70
+ if (e.buffered.length >= 1024 || (e.bufferBytes || 0) + bytes > maxTailBytes) {
71
+ e.buffered = []; e.bufferBytes = 0; e.bufferOverflow = true; uncertain(e); return
72
+ }
73
+ if (!e.bufferOverflow) { e.buffered.push(value); e.bufferBytes = (e.bufferBytes || 0) + bytes }
74
+ return
75
+ }
76
+ if (event.seq <= e.cursor) return
77
+ if (event.seq !== e.cursor + 1) { counters.gaps++; e.ready = false; uncertain(e); accept(e, event); return }
78
+ e.cursor = event.seq
79
+ counters.events++
80
+ const small = compact(event)
81
+ if (small) { e.evidence.push(small); if (e.evidence.length > maxEvidence) { e.evidence.shift(); e.overflow = true } }
82
+ retain(e, event)
83
+ deliver(e, event)
84
+ }
85
+ const off = options.ctx?.on?.('session/event', (session, event) => {
86
+ const id = session?.id ?? session?.header?.id
87
+ const e = entries.get(id)
88
+ if (!e || closed) return
89
+ if (e.object?.deref() && e.object.deref() !== session) { e.ready = false; e.cursor = -1; uncertain(e) }
90
+ e.object = new WeakRef(session)
91
+ accept(e, event)
92
+ }, { global: true })
93
+
94
+ const read = async (e) => {
95
+ const q = query()
96
+ if (!q) return null
97
+ let snapshot
98
+ try {
99
+ e.reading = true
100
+ const object = live(e.id)
101
+ if (typeof q.observeSession === 'function') { counters.observations++; snapshot = await q.observeSession(e.id, { projectionMode: 'none' }) }
102
+ else { counters.reads++; snapshot = await q.readSession(e.id) }
103
+ if (closed || !snapshot || !Array.isArray(snapshot.events)) return null
104
+ const events = snapshot.events
105
+ const cursor = Number.isInteger(snapshot.cursor) ? snapshot.cursor : (events.length ? events[events.length - 1].seq : -1)
106
+ if (!Number.isInteger(cursor)) return null
107
+ if (object && live(e.id) !== object) { uncertain(e); return null }
108
+ if (cursor < e.cursor) { uncertain(e); return null }
109
+ const previous = e.cursor
110
+ e.evidence = []; e.overflow = false
111
+ for (const event of events) {
112
+ const small = compact(event)
113
+ if (small) { e.evidence.push(small); if (e.evidence.length > maxEvidence) { e.evidence.shift(); e.overflow = true } }
114
+ }
115
+ e.cursor = cursor; e.ready = true; e.object = object ? new WeakRef(object) : e.object
116
+ // A cold observation is borrowed, immutable SDK history. Release its cache lease immediately.
117
+ e.tail = []; e.tailBytes = 0
118
+ for (let i = Math.max(0, events.length - 256); i < events.length; i++) retain(e, events[i])
119
+ for (const event of events) if (event.seq > previous) deliver(e, event)
120
+ return { events, cursor }
121
+ } catch { counters.failures++; e.failures = (e.failures || 0) + 1; e.retryAt = now() + Math.min(30000, 1000 * 2 ** Math.min(e.failures, 5)); uncertain(e); return null }
122
+ finally {
123
+ try { snapshot?.[Symbol.dispose]?.() } catch { /* release must not hide failure */ }
124
+ e.reading = false
125
+ const buffered = e.buffered; e.buffered = []; e.bufferBytes = 0
126
+ if (e.bufferOverflow) { e.bufferOverflow = false; e.ready = false; uncertain(e) }
127
+ buffered.sort((a,b) => a.seq - b.seq)
128
+ for (const event of buffered) accept(e, event)
129
+ }
130
+ }
131
+ const refreshEntry = async (e, fresh, force = false) => {
132
+ if (closed) return null
133
+ if (!fresh && !force && e.retryAt > now()) return null
134
+ if (!force && headMatches(e)) return { cursor: e.cursor, evidence: e.overflow ? null : e.evidence }
135
+ if (!force && !fresh && e.ready && e.connected) return { cursor: e.cursor, evidence: e.overflow ? null : e.evidence }
136
+ const ticket = ++e.requested
137
+ if (!e.inflight) {
138
+ e.inflight = (async () => {
139
+ let result = null
140
+ while (!closed && e.completed < e.requested) {
141
+ const through = e.requested
142
+ result = await read(e)
143
+ e.completed = through
144
+ if (!result) { e.ready = false; break }
145
+ e.failures = 0; e.retryAt = 0
146
+ }
147
+ return result
148
+ })().finally(() => { e.inflight = null })
149
+ }
150
+ const result = await e.inflight
151
+ // A query requested after an older read began needs a cut begun after its request.
152
+ if (!closed && result && e.completed < ticket) return refreshEntry(e, true)
153
+ return closed || !result || !e.ready ? null : { ...result, evidence: e.overflow ? null : e.evidence }
154
+ }
155
+ const startStream = (e) => {
156
+ if (e.stream || closed || headMatches(e)) return
157
+ const controller = new AbortController()
158
+ e.stream = controller
159
+ void (async () => {
160
+ try {
161
+ const service = typeof options.controller === 'function' ? options.controller() : options.controller
162
+ if (!service?.follow) return
163
+ for await (const frame of service.follow({ address: e.address || { kind: 'session', sessionId: e.id } }, controller.signal)) {
164
+ if (closed || controller.signal.aborted) break
165
+ if (frame.type === 'snapshot') {
166
+ // The follow tail has no resume cursor. A missing business interval is repaired by query.
167
+ const records = frame.records || []
168
+ for (const record of records) accept(e, record?.type === 'event' ? record.event : record)
169
+ if (!e.ready || (Number.isInteger(frame.cursor) && frame.cursor !== e.cursor)) await refreshEntry(e, true)
170
+ e.connected = e.ready
171
+ } else if (frame.type === 'event') { accept(e, frame.event); if (!e.ready) await refreshEntry(e, true) }
172
+ }
173
+ } catch { counters.failures++ }
174
+ finally { if (e.stream === controller) { e.stream = null; uncertain(e) } }
175
+ })()
176
+ }
177
+ const hub = {
178
+ async refresh(id, { fresh = false } = {}) { return refreshEntry(entry(id), fresh) },
179
+ async evidence(id, { fresh = true } = {}) { const e = entry(id); return await refreshEntry(e, fresh) && !e.overflow ? e.evidence : null },
180
+ async latestSelectionSeq(id) { const events = await hub.evidence(id); if (!events) return { ok: false }; return { ok: true, seq: events.findLast(e => e.type === 'model/selection')?.seq ?? null } },
181
+ async capture(id) { return readThrough(entry(id)) },
182
+ async since(id, afterSeq, { fresh = false } = {}) {
183
+ const e = entry(id)
184
+ const result = await refreshEntry(e, fresh)
185
+ if (!result) return null
186
+ if (e.listeners.size) startStream(e)
187
+ if (afterSeq >= e.cursor) return { events: [], cursor: e.cursor }
188
+ if (result.events) return { events: result.events.filter(x => x.seq > afterSeq), cursor: result.cursor }
189
+ if (e.tail.length && e.tail[0].seq <= afterSeq + 1) return { events: e.tail.filter(x => x.seq > afterSeq), cursor: e.cursor }
190
+ const snap = await readThrough(e)
191
+ return snap && { events: snap.events.filter(x => x.seq > afterSeq), cursor: snap.cursor }
192
+ },
193
+ subscribe(id, { afterSeq = -1, onEvent, onUncertain, signal, address } = {}) {
194
+ const e = entry(id), sub = { afterSeq, onEvent, onUncertain, signal }
195
+ if (closed || signal?.aborted) return () => {}
196
+ if (address) {
197
+ const normalized = address.kind === 'subagent'
198
+ ? { kind: 'subagent', parentSessionId: address.parentSessionId, childSessionId: address.childSessionId, mode: address.mode }
199
+ : { kind: 'session', sessionId: address.sessionId }
200
+ if (e.address && JSON.stringify(e.address) !== JSON.stringify(normalized)) { onUncertain?.(); return () => {} }
201
+ e.address = normalized
202
+ }
203
+ e.listeners.add(sub)
204
+ const release = () => { e.listeners.delete(sub); signal?.removeEventListener('abort', release); if (!e.listeners.size) { e.stream?.abort(); e.tail = []; e.tailBytes = 0 } }
205
+ signal?.addEventListener('abort', release, { once: true })
206
+ void hub.since(id, afterSeq).then(snap => { if (!snap || closed || !e.listeners.has(sub)) return; for (const event of snap.events) { if (event.seq > sub.afterSeq) { sub.afterSeq = event.seq; sub.onEvent?.(event) } }; startStream(e) })
207
+ return release
208
+ },
209
+ healthy(id) { const e = entries.get(id); return !!e && (headMatches(e) || (e.ready && e.connected)) },
210
+ prune() { for (const [id,e] of entries) if (!e.listeners.size && !e.inflight && now() - e.touched > 30000) { e.stream?.abort(); entries.delete(id) } },
211
+ status() { return { ...counters, sessions: entries.size, tailBytes: [...entries.values()].reduce((n,e) => n + (e.tailBytes || 0), 0), bufferedBytes: [...entries.values()].reduce((n,e) => n + (e.bufferBytes || 0), 0) } },
212
+ close() { closed = true; if (typeof off === 'function') off(); for (const e of entries.values()) { e.stream?.abort(); e.listeners.clear(); e.evidence = []; e.tail = []; e.buffered = [] }; entries.clear() },
213
+ }
214
+ // Force a full borrowed cut for legacy result extraction, still serialized with all other reads.
215
+ const readThrough = (e) => refreshEntry(e, true, true)
216
+ return hub
217
+ }
@@ -29,6 +29,33 @@ export const REASONS = {
29
29
  DISPATCH_FAILED: 'session-dispatch-failed',
30
30
  /** 带来源的会话任务不能精确关联到单个请求回合:在执行前拒绝 steer。 */
31
31
  ORIGIN_STEER_UNSUPPORTED: 'origin-steer-unsupported',
32
+ // ---- 投递控制(queue / steer)的稳定失败码 ----
33
+ /** steer 必须显式给出父任务(不允许按"最近/最老任务"推断)。 */
34
+ PARENT_REQUIRED: 'steer-parent-required',
35
+ /** 给的父任务不存在、不属于同一来源/会话/档案,或不是真实在执行的队列任务。 */
36
+ PARENT_INVALID: 'steer-parent-invalid',
37
+ /** 父任务已终态(不可能再插入)。 */
38
+ PARENT_TERMINAL: 'steer-parent-terminal',
39
+ /** 父任务还在排队(还没真正开始执行)——插入没有意义,执行前拒绝。 */
40
+ PARENT_QUEUED: 'steer-parent-queued',
41
+ /** 给了 expectedTurn 但与父任务当前真实 turn 不一致。 */
42
+ PARENT_TURN_MISMATCH: 'steer-turn-mismatch',
43
+ /** 无来源的 steer 会改动别人的在途任务:零投递拒绝。 */
44
+ ORIGIN_REQUIRED_FOR_STEER: 'steer-origin-required',
45
+ /** 旧无来源(不可精确关联)任务与新的来源绑定任务混在同一会话:拒绝这种不安全组合。 */
46
+ LEGACY_MIX_UNSAFE: 'legacy-mix-unsafe',
47
+ /** 回执已接收但没能观察到自己的 rpcId 落进父回合(不重发、不猜)。 */
48
+ RECEIPT_UNCONFIRMED: 'steer-receipt-unconfirmed',
49
+ /** 回执落进了**另一个**回合(官方异步 admission / wakingAfterAbort 改道):如实报告偏离,绝不假成功。 */
50
+ RECEIPT_TURN_DEVIATED: 'steer-turn-deviated',
51
+ /** steer 预检时父任务的回合已经结束(官方日志证据):执行前拒绝。 */
52
+ PARENT_TURN_CLOSED: 'steer-parent-turn-closed',
53
+ /** 模型路由:精确指定的 pair/model 不在已配置目录内,或同名跨 provider 歧义 → 派活前拒绝(不静默选错)。 */
54
+ MODEL_ROUTE_INVALID: 'model-route-invalid',
55
+ /** 模型路由已按"本任务"终态收口(候选耗尽/禁 fallback 终止等):稳定 reason,客户端不重发、不冒充成功。 */
56
+ MODEL_ROUTE_EXHAUSTED: 'model-route-exhausted',
57
+ /** 子代理回合以失败 stopReason 结束(error/aborted/refusal/max-tokens…):绝不 done。 */
58
+ SUBAGENT_TURN_FAILED: 'subagent-turn-failed',
32
59
  /** 关联到了请求回合,但该回合以非 completed 结束(aborted/blocked/error/…)。 */
33
60
  TURN_FAILED: 'session-turn-failed',
34
61
  /** 没有任何证据能确认这个请求回合的结局(不猜、不重发)。 */
@@ -117,7 +144,8 @@ export const pickSessionResult = (streamed, extracted) => {
117
144
  * 同一套规则被 live follow 帧、follow 开场 snapshot.records、以及轮询读日志共用;`seq` 去重保证
118
145
  * 同一条事件被多条路径投递时也只算一次、不串文本。
119
146
  */
120
- export const createRequestTracker = (requestId) => ({
147
+ export const createRequestTracker = (requestId, floorSeq) => ({
148
+ floorSeq,
121
149
  requestId: String(requestId),
122
150
  /** 已 turn/start 但未 turn/end 的 turn 号(有序)。 */
123
151
  openTurns: [],
@@ -131,17 +159,107 @@ export const createRequestTracker = (requestId) => ({
131
159
  * (否则快照缺前半段就会永久丢掉答案)。
132
160
  */
133
161
  usedSeqs: new Set(),
162
+ /**
163
+ * 绑定到自己的 turn **之前**看到的助手文本(seq 升序回填)。
164
+ * 不能丢(快照缺前半段时会永久丢答案),也不能在回读时按"到达顺序"追加(会倒序/重复)。
165
+ */
166
+ deferred: [],
167
+ /** 本请求的 user/message 所在 seq(回填助手文本的下界;未见到时保持 undefined)。 */
168
+ requestSeq: undefined,
134
169
  end: undefined,
135
170
  lastSeq: 0,
136
171
  })
137
172
 
138
173
  const dataOf = (event) => (event.data !== undefined && event.data !== null && typeof event.data === 'object' ? event.data : {})
139
174
 
175
+ /**
176
+ * **官方日志的 fresh 证据**(第四轮 B/C/E):只读事件序列,不看任何缓存状态。
177
+ * - `openTurns(events)`:按出现顺序列出仍未结束的 turn(turn/start 入、对应 turn/end 出)。
178
+ * - `currentOpenTurn(events)`:最后一个仍未结束的 turn(= 真实"当前开放回合"),没有则 null。
179
+ * - `turnHasEnded(events, turn)`:该 turn 是否已经收到 turn/end。
180
+ * - `findRequestEvidence(events, requestId)`:该 rpcId 的 user/message 落在哪个 turn(含 seq)。
181
+ */
182
+ export const openTurnsOf = (events) => {
183
+ const open = []
184
+ if (!Array.isArray(events)) return open
185
+ for (const event of events) {
186
+ if (event === null || typeof event !== 'object') continue
187
+ const data = dataOf(event)
188
+ const turn = typeof data.turn === 'number' ? data.turn : undefined
189
+ if (event.type === 'turn/start' && turn !== undefined) { if (!open.includes(turn)) open.push(turn); continue }
190
+ if (event.type === 'turn/end' && turn !== undefined) {
191
+ const index = open.indexOf(turn)
192
+ if (index >= 0) open.splice(index, 1)
193
+ }
194
+ }
195
+ return open
196
+ }
197
+
198
+ export const currentOpenTurn = (events) => {
199
+ const open = openTurnsOf(events)
200
+ return open.length === 0 ? null : open[open.length - 1]
201
+ }
202
+
203
+ export const turnHasEnded = (events, turn) => {
204
+ if (!Array.isArray(events) || !Number.isInteger(turn)) return false
205
+ for (const event of events) {
206
+ if (event === null || typeof event !== 'object') continue
207
+ const data = dataOf(event)
208
+ if (event.type === 'turn/end' && data.turn === turn) return true
209
+ }
210
+ return false
211
+ }
212
+
213
+ /** 该 rpcId 的 user/message 在日志里的位置:`{seq, turn}`(turn = 它所在的那个开放回合;不明则 null)。 */
214
+ export const findRequestEvidence = (events, requestId) => {
215
+ if (!Array.isArray(events) || typeof requestId !== 'string' || requestId === '') return null
216
+ const open = []
217
+ for (const event of events) {
218
+ if (event === null || typeof event !== 'object') continue
219
+ const data = dataOf(event)
220
+ const turn = typeof data.turn === 'number' ? data.turn : undefined
221
+ if (event.type === 'turn/start' && turn !== undefined) { if (!open.includes(turn)) open.push(turn); continue }
222
+ if (event.type === 'turn/end' && turn !== undefined) {
223
+ const index = open.indexOf(turn)
224
+ if (index >= 0) open.splice(index, 1)
225
+ continue
226
+ }
227
+ if (event.type !== 'user/message') continue
228
+ const source = data.source !== undefined && data.source !== null && typeof data.source === 'object' ? data.source : {}
229
+ if (source.rpcId !== requestId) continue
230
+ const enclosing = typeof data.turn === 'number' ? data.turn : (open.length > 0 ? open[open.length - 1] : null)
231
+ return { seq: typeof event.seq === 'number' ? event.seq : undefined, turn: enclosing }
232
+ }
233
+ return null
234
+ }
235
+
236
+ /**
237
+ * 一次性把"绑定前"的助手文本按 seq 升序补进 `text`(第四轮修正 E/D):
238
+ * - **批内/跨回放去重**:已经用过的 seq 绝不重复追加(同一缺前快照喂两遍不能变成 OWNOWN);
239
+ * - **按 turn 过滤**:明确属于别的 turn 的文本丢弃(不能把另一个回合的 FOREIGN 记到自己头上);
240
+ * - **只在"自己的 user/message 之后"**(requestSeq 下界),否则同 turn 里更早请求的文本会张冠李戴。
241
+ * 不能用"把所有未知 turn 事件一律提前标记 used"来简化——那会破坏缺前半段快照的合法回填。
242
+ */
243
+ const flushDeferred = (tracker) => {
244
+ if (!Array.isArray(tracker.deferred) || tracker.deferred.length === 0) return
245
+ const pending = tracker.deferred.slice().sort((a, b) => a.seq - b.seq)
246
+ tracker.deferred = []
247
+ const requestSeq = Number.isInteger(tracker.requestSeq) ? tracker.requestSeq : -1
248
+ for (const item of pending) {
249
+ if (tracker.usedSeqs.has(item.seq)) continue // 批内/跨回放去重
250
+ tracker.usedSeqs.add(item.seq)
251
+ if (item.turn !== undefined && item.turn !== tracker.turn) continue // 明确属于别的回合 → 丢弃
252
+ if (item.seq < requestSeq) continue // 请求之前的文本不属于本请求
253
+ tracker.text += item.text
254
+ }
255
+ }
256
+
140
257
  /**
141
258
  * 消费一条会话事件。返回 `'settled'`(本请求的 turn 已结束)/ `'progress'` / `'ignored'`。
142
259
  */
143
260
  export const consumeSessionEvent = (tracker, event) => {
144
261
  if (tracker === undefined || tracker === null) return 'ignored'
262
+ if (Number.isInteger(tracker.floorSeq) && Number.isInteger(event?.seq) && event.seq <= tracker.floorSeq) return 'ignored'
145
263
  if (event === null || typeof event !== 'object') return 'ignored'
146
264
  const seq = typeof event.seq === 'number' ? event.seq : undefined
147
265
  if (seq !== undefined) {
@@ -160,20 +278,36 @@ export const consumeSessionEvent = (tracker, event) => {
160
278
  const source = data.source !== undefined && data.source !== null && typeof data.source === 'object' ? data.source : {}
161
279
  if (typeof source.rpcId !== 'string' || source.rpcId !== tracker.requestId) return 'ignored'
162
280
  tracker.sawRequest = true
281
+ if (seq !== undefined && !Number.isInteger(tracker.requestSeq)) tracker.requestSeq = seq // 本请求消息所在的 seq(回填下界)
163
282
  if (tracker.turn === undefined) {
164
283
  // user/message 没有 data.turn:归到此刻仍未闭合的那个 turn(有 data.turn 时以它为准)
165
284
  const fallback = tracker.openTurns.length > 0 ? tracker.openTurns[tracker.openTurns.length - 1] : undefined
166
285
  tracker.turn = typeof data.turn === 'number' ? data.turn : fallback
167
286
  }
168
287
  // 只有真的建立了 turn 关联才算"用过";没有未闭合 turn 时留待回填。
169
- if (tracker.turn !== undefined) markUsed()
288
+ if (tracker.turn !== undefined) {
289
+ markUsed()
290
+ flushDeferred(tracker) // 按 seq 顺序补上"绑定前"的助手文本(且只补一次)
291
+ }
170
292
  return 'progress'
171
293
  }
172
294
  if (event.type === 'assistant/message') {
173
- if (tracker.turn === undefined) return 'ignored' // 还不知道自己的 turn:留待回填
174
- if (typeof data.turn === 'number' && data.turn !== tracker.turn) return 'ignored'
175
295
  const text = assistantTextOf(event)
176
296
  if (text === '') return 'ignored'
297
+ if (tracker.turn === undefined) {
298
+ // 还不知道自己的 turn:**排队等待按 seq 顺序回填**(不能直接丢,也不能在回读时才追加——
299
+ // 那会把更早的文本接到更晚的文本后面,root 复现过 AFTER → AFTER|BEFORE 的倒序)。
300
+ // 同时记下事件自带的 turn(若官方给了),回填时据此丢弃别的回合的文本;同一 seq 只入队一次
301
+ // (同一"缺前快照"喂两遍不得变成 OWNOWN)。
302
+ if (seq !== undefined && !tracker.deferred.some((item) => item.seq === seq)) {
303
+ tracker.deferred.push({ seq, turn: typeof data.turn === 'number' ? data.turn : undefined, text })
304
+ }
305
+ return 'ignored'
306
+ }
307
+ if (typeof data.turn === 'number' && data.turn !== tracker.turn) {
308
+ markUsed() // 明确不是我们的 turn:用掉,避免反复评估
309
+ return 'ignored'
310
+ }
177
311
  tracker.text += text
178
312
  markUsed()
179
313
  return 'progress'
@@ -262,8 +396,10 @@ export const classifyTarget = (input) => {
262
396
  if (id === undefined && query === undefined) {
263
397
  return { ok: false, reason: REASONS.INVALID_TARGET, message: 'session target needs sessionId or query' }
264
398
  }
265
- // session 语义自带工作目录与模型:混用会让人以为还能控制它们。
266
- for (const [field, value] of [['cwd', raw.cwd], ['lane', raw.lane], ['model', raw.model]]) {
399
+ // session 语义自带工作目录:混用会让人以为还能控制它。
400
+ // **模型例外**(第五轮模型路由契约):session 目标**接受** model/provider/modelFallback——扁平写法
401
+ // (sessionId+model)与 target.kind='session'+model 必须语义一致,两者都由任务级路由策略处理。
402
+ for (const [field, value] of [['cwd', raw.cwd], ['lane', raw.lane]]) {
267
403
  if (nonEmpty(value)) {
268
404
  return { ok: false, reason: REASONS.INVALID_TARGET, message: `${field} is not accepted with a session target` }
269
405
  }
@@ -415,6 +551,8 @@ export const rankTitleCandidates = (query, summaries, limit = 5) => {
415
551
  /** 任务在 HTTP 行上的回显:调用方一眼能看出这次是"新建"还是"接进哪个会话"。 */
416
552
  export const taskResponse = (task) => {
417
553
  const out = { taskId: task.taskId, status: task.status, kind: task.kind }
554
+ // 动态模型路由(M2):回显实际选择/模式/是否允许降级(非秘密;不含任何凭据)。
555
+ if (task.route !== undefined && task.route !== null && typeof task.route === 'object') out.route = task.route
418
556
  const target = task.target !== undefined && task.target !== null
419
557
  ? task.target
420
558
  // 兼容 0.1.1 之前落下的记录(如 review 任务):没有 target 字段时按 worker 回显。
@@ -433,5 +571,22 @@ export const taskResponse = (task) => {
433
571
  }
434
572
  // 来源关联(R1):回显发起这次任务的 Codex 对话 id,便于跨回合按 origin+taskId 精确收取。
435
573
  if (typeof task.originThreadId === 'string' && task.originThreadId !== '') out.originThreadId = task.originThreadId
574
+ // 委托审批的**非秘密**定位/期限(第三轮修正):客户端等待循环必须能凭它做认证续租,
575
+ // 否则慢模型超过初始租约就会把首个 ask 交回人工。raw key/摘要一律不出现在这里。
576
+ if (typeof task.grantId === 'string' && task.grantId !== '') {
577
+ out.grantId = task.grantId
578
+ if (Number.isFinite(task.grantExpiresAt)) out.grantExpiresAt = task.grantExpiresAt
579
+ if (Number.isFinite(task.grantLeaseUntil)) out.grantLeaseUntil = task.grantLeaseUntil
580
+ }
581
+ // 投递控制(第四轮):回显投递模式与父/回执关系(非秘密),让调用方能区分"排队任务"与"补充回执"。
582
+ if (task.target !== undefined && task.target !== null && task.target.kind === 'session') {
583
+ const mode = task.requestedMode !== undefined ? task.requestedMode : task.deliver
584
+ if (mode !== undefined) out.deliver = mode
585
+ if (typeof task.parentTaskId === 'string' && task.parentTaskId !== '') out.parentTaskId = task.parentTaskId
586
+ if (typeof task.receiptState === 'string' && task.receiptState !== '') out.receiptState = task.receiptState
587
+ if (Number.isInteger(task.observedTurn)) out.observedTurn = task.observedTurn
588
+ if (Number.isInteger(task.expectedTurn)) out.expectedTurn = task.expectedTurn
589
+ if (typeof task.resultScope === 'string' && task.resultScope !== '') out.resultScope = task.resultScope
590
+ }
436
591
  return out
437
592
  }
@@ -19,14 +19,16 @@ export const rememberTask = (tasks, task, limit = DEFAULT_TASK_LIMIT) => {
19
19
  tasks.push(task)
20
20
  const bound = typeof limit === 'number' && limit > 0 ? limit : DEFAULT_TASK_LIMIT
21
21
  while (tasks.length > bound) {
22
- // 只淘汰终态(非 accepted/running)且不是刚加进来的那一条。
23
- const index = tasks.findIndex((t) => t !== task && t.status !== 'accepted' && t.status !== 'running')
22
+ // 淘汰**终态**(非 active)且不是刚加进来的那一条。
23
+ // active 含 accepted / running / needs-approval(等待审批的活任务不能被淘汰,
24
+ // 否则完成回调、会话观察器与审批 pending 都会失去归属)。
25
+ const index = tasks.findIndex((t) => t !== task && !isActiveTask(t))
24
26
  if (index === -1) break // 全是活任务:宁可超过上限,也不丢执行中的任务
25
27
  tasks.splice(index, 1)
26
28
  }
27
29
  return tasks
28
30
  }
29
31
 
30
- /** 是否处于"还在执行"的状态(完成回调需要能找到它)。 */
32
+ /** 是否处于"还在执行/等待中"的状态(完成回调与审批归属需要能找到它)。 */
31
33
  export const isActiveTask = (task) => task !== null && typeof task === 'object'
32
- && (task.status === 'accepted' || task.status === 'running')
34
+ && (task.status === 'accepted' || task.status === 'running' || task.status === 'needs-approval' || task.awaitingOfficialEnd === true)