@whaletalk/dsh-codex-collab 0.1.7 → 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.
- package/CHANGELOG.md +44 -0
- package/README.md +197 -26
- package/codex-plugin/plugins/dsh/.codex-plugin/plugin.json +1 -1
- package/codex-plugin/plugins/dsh/skills/dsh-collab/SKILL.md +102 -6
- package/codex-plugin/plugins/dsh/skills/dsh-collab/scripts/review.mjs +91 -6
- package/codex-plugin/plugins/dsh/skills/dsh-collab/scripts/task.mjs +571 -44
- package/docs/CODEX_DSH_APPROVAL_DELEGATION_PLAN.md +130 -0
- package/docs/CODEX_DSH_APPROVAL_M0_EVIDENCE.md +285 -0
- package/docs/CODEX_DSH_DELIVERY_CONTROL_PLAN.md +81 -0
- package/docs/CODEX_DSH_MODEL_ROUTING_PLAN.md +75 -0
- package/docs/CODEX_DSH_TASK_REPLY_PLAN.md +206 -0
- package/docs/DSH_APPROVAL_DELEGATION.md +142 -0
- package/docs/DSH_DELIVERY_M0_EVIDENCE.md +69 -0
- package/docs/DSH_MODEL_ROUTING_M0_EVIDENCE.md +166 -0
- package/docs/DSH_TO_CODEX_INBOX.md +167 -0
- package/docs/DSH_TO_CODEX_MESSAGING_PLAN.md +140 -0
- package/docs/codex-desktop-attachment-evidence.md +116 -0
- package/harness/audit-tail.mjs +32 -0
- package/harness/codex-approvals.mjs +1078 -0
- package/harness/codex-associations.mjs +297 -0
- package/harness/codex-outbox.mjs +432 -0
- package/harness/dsh-bridge.mjs +2429 -167
- package/harness/fs-lock.mjs +208 -0
- package/harness/model-route-host.mjs +530 -0
- package/harness/model-routing.mjs +500 -0
- package/harness/session-observer.mjs +217 -0
- package/harness/session-target.mjs +291 -2
- package/harness/task-table.mjs +34 -0
- package/mcp/codex-desktop-probe.mjs +407 -0
- package/mcp/codex-inbox.mjs +271 -0
- package/mcp/dsh-mcp.mjs +616 -24
- package/package.json +4 -2
|
@@ -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
|
+
}
|
|
@@ -27,8 +27,45 @@ export const REASONS = {
|
|
|
27
27
|
BAD_REQUEST: 'session-bad-request',
|
|
28
28
|
GATEWAY_INTERNAL: 'session-gateway-internal',
|
|
29
29
|
DISPATCH_FAILED: 'session-dispatch-failed',
|
|
30
|
+
/** 带来源的会话任务不能精确关联到单个请求回合:在执行前拒绝 steer。 */
|
|
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',
|
|
59
|
+
/** 关联到了请求回合,但该回合以非 completed 结束(aborted/blocked/error/…)。 */
|
|
60
|
+
TURN_FAILED: 'session-turn-failed',
|
|
61
|
+
/** 没有任何证据能确认这个请求回合的结局(不猜、不重发)。 */
|
|
62
|
+
UNCONFIRMED: 'execution-state-unconfirmed',
|
|
30
63
|
}
|
|
31
64
|
|
|
65
|
+
/** `turn/end` 的 reason.kind 枚举(来自 driver/typert):**只有 completed 算成功**。 */
|
|
66
|
+
export const TURN_END_KINDS = ['completed', 'aborted', 'blocked', 'error', 'max-tokens', 'interrupted', 'forked']
|
|
67
|
+
export const SUCCESS_TURN_END = 'completed'
|
|
68
|
+
|
|
32
69
|
/** 候选列表回传给调用方时的上限,避免把整个会话库倒出去。 */
|
|
33
70
|
export const MAX_CANDIDATES = 10
|
|
34
71
|
|
|
@@ -91,6 +128,235 @@ export const pickSessionResult = (streamed, extracted) => {
|
|
|
91
128
|
return fromStream !== '' ? fromStream : '(无输出)'
|
|
92
129
|
}
|
|
93
130
|
|
|
131
|
+
/**
|
|
132
|
+
* 请求 → DSH turn 的关联器(纯函数群)。
|
|
133
|
+
*
|
|
134
|
+
* 依据(本机 controller 0.2.0-rc.2 的实测字段):
|
|
135
|
+
* - `prompt.requestId` 会被写进 `user/message` 的 `event.data.source.rpcId`——这是"这条用户消息属于
|
|
136
|
+
* 哪次请求"的**唯一可靠凭据**(我们用 taskId 当 requestId);
|
|
137
|
+
* - **`user/message.data.turn` 不存在**:turn 号只能靠按 seq 有序跟踪"已 turn/start 但还没 turn/end"
|
|
138
|
+
* 的回合得到,遇到 rpcId 匹配的 user/message 时把它归到**当时仍未闭合**的那个 turn;
|
|
139
|
+
* - `assistant/message.data = {turn, step, message:{content:[...]}}`:只收自己那个 turn 的助手文本;
|
|
140
|
+
* - `turn/end.data = {turn, reason:{kind,…}}`:这是本次请求结束的依据;**只有 completed 算成功**;
|
|
141
|
+
* - `api-session/status` 只表示 agent 当前是否 running(连续跑 queued turn 时可能没有新的 running=true),
|
|
142
|
+
* 因此它**绝不能**当作某个请求完成的证明。
|
|
143
|
+
*
|
|
144
|
+
* 同一套规则被 live follow 帧、follow 开场 snapshot.records、以及轮询读日志共用;`seq` 去重保证
|
|
145
|
+
* 同一条事件被多条路径投递时也只算一次、不串文本。
|
|
146
|
+
*/
|
|
147
|
+
export const createRequestTracker = (requestId, floorSeq) => ({
|
|
148
|
+
floorSeq,
|
|
149
|
+
requestId: String(requestId),
|
|
150
|
+
/** 已 turn/start 但未 turn/end 的 turn 号(有序)。 */
|
|
151
|
+
openTurns: [],
|
|
152
|
+
/** 匹配到本请求的 turn 号(由 rpcId 匹配时那一刻的未闭合 turn 决定)。 */
|
|
153
|
+
turn: undefined,
|
|
154
|
+
sawRequest: false,
|
|
155
|
+
text: '',
|
|
156
|
+
/**
|
|
157
|
+
* **只记录"真正产生了效果"的事件 seq**(建立 turn 关联 / 追加自己 turn 的文字 / 收尾 / 记录 turn/start)。
|
|
158
|
+
* 当时因为"还不知道自己那个 turn"而无法使用的事件**不**标记——后续从完整日志回填时它们必须能被重新消费
|
|
159
|
+
* (否则快照缺前半段就会永久丢掉答案)。
|
|
160
|
+
*/
|
|
161
|
+
usedSeqs: new Set(),
|
|
162
|
+
/**
|
|
163
|
+
* 绑定到自己的 turn **之前**看到的助手文本(seq 升序回填)。
|
|
164
|
+
* 不能丢(快照缺前半段时会永久丢答案),也不能在回读时按"到达顺序"追加(会倒序/重复)。
|
|
165
|
+
*/
|
|
166
|
+
deferred: [],
|
|
167
|
+
/** 本请求的 user/message 所在 seq(回填助手文本的下界;未见到时保持 undefined)。 */
|
|
168
|
+
requestSeq: undefined,
|
|
169
|
+
end: undefined,
|
|
170
|
+
lastSeq: 0,
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
const dataOf = (event) => (event.data !== undefined && event.data !== null && typeof event.data === 'object' ? event.data : {})
|
|
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
|
+
|
|
257
|
+
/**
|
|
258
|
+
* 消费一条会话事件。返回 `'settled'`(本请求的 turn 已结束)/ `'progress'` / `'ignored'`。
|
|
259
|
+
*/
|
|
260
|
+
export const consumeSessionEvent = (tracker, event) => {
|
|
261
|
+
if (tracker === undefined || tracker === null) return 'ignored'
|
|
262
|
+
if (Number.isInteger(tracker.floorSeq) && Number.isInteger(event?.seq) && event.seq <= tracker.floorSeq) return 'ignored'
|
|
263
|
+
if (event === null || typeof event !== 'object') return 'ignored'
|
|
264
|
+
const seq = typeof event.seq === 'number' ? event.seq : undefined
|
|
265
|
+
if (seq !== undefined) {
|
|
266
|
+
if (tracker.usedSeqs.has(seq)) return 'ignored' // 已经用过的效果不重复施加
|
|
267
|
+
tracker.lastSeq = Math.max(tracker.lastSeq, seq)
|
|
268
|
+
}
|
|
269
|
+
const markUsed = () => { if (seq !== undefined) tracker.usedSeqs.add(seq) }
|
|
270
|
+
const data = dataOf(event)
|
|
271
|
+
if (event.type === 'turn/start') {
|
|
272
|
+
const turn = typeof data.turn === 'number' ? data.turn : undefined
|
|
273
|
+
if (turn !== undefined && !tracker.openTurns.includes(turn)) tracker.openTurns.push(turn)
|
|
274
|
+
markUsed()
|
|
275
|
+
return 'ignored'
|
|
276
|
+
}
|
|
277
|
+
if (event.type === 'user/message') {
|
|
278
|
+
const source = data.source !== undefined && data.source !== null && typeof data.source === 'object' ? data.source : {}
|
|
279
|
+
if (typeof source.rpcId !== 'string' || source.rpcId !== tracker.requestId) return 'ignored'
|
|
280
|
+
tracker.sawRequest = true
|
|
281
|
+
if (seq !== undefined && !Number.isInteger(tracker.requestSeq)) tracker.requestSeq = seq // 本请求消息所在的 seq(回填下界)
|
|
282
|
+
if (tracker.turn === undefined) {
|
|
283
|
+
// user/message 没有 data.turn:归到此刻仍未闭合的那个 turn(有 data.turn 时以它为准)
|
|
284
|
+
const fallback = tracker.openTurns.length > 0 ? tracker.openTurns[tracker.openTurns.length - 1] : undefined
|
|
285
|
+
tracker.turn = typeof data.turn === 'number' ? data.turn : fallback
|
|
286
|
+
}
|
|
287
|
+
// 只有真的建立了 turn 关联才算"用过";没有未闭合 turn 时留待回填。
|
|
288
|
+
if (tracker.turn !== undefined) {
|
|
289
|
+
markUsed()
|
|
290
|
+
flushDeferred(tracker) // 按 seq 顺序补上"绑定前"的助手文本(且只补一次)
|
|
291
|
+
}
|
|
292
|
+
return 'progress'
|
|
293
|
+
}
|
|
294
|
+
if (event.type === 'assistant/message') {
|
|
295
|
+
const text = assistantTextOf(event)
|
|
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
|
+
}
|
|
311
|
+
tracker.text += text
|
|
312
|
+
markUsed()
|
|
313
|
+
return 'progress'
|
|
314
|
+
}
|
|
315
|
+
if (event.type === 'turn/end') {
|
|
316
|
+
const turn = typeof data.turn === 'number' ? data.turn : undefined
|
|
317
|
+
if (turn !== undefined) tracker.openTurns = tracker.openTurns.filter((t) => t !== turn)
|
|
318
|
+
if (tracker.turn === undefined || turn !== tracker.turn) {
|
|
319
|
+
// 不是我们的回合:**不标记**,等自己那个 turn 绑定后若还有对应结束仍要能收到。
|
|
320
|
+
return 'ignored'
|
|
321
|
+
}
|
|
322
|
+
const reason = data.reason !== undefined && data.reason !== null && typeof data.reason === 'object' ? data.reason : {}
|
|
323
|
+
const kind = typeof reason.kind === 'string' ? reason.kind : 'unknown'
|
|
324
|
+
tracker.end = { kind, reason, seq }
|
|
325
|
+
markUsed()
|
|
326
|
+
return 'settled'
|
|
327
|
+
}
|
|
328
|
+
return 'ignored'
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* follow 开场的 snapshot 帧:`{type:'snapshot', records:[{type:'event', event:{…}}], …}`。
|
|
333
|
+
* **不能把 snapshot 整个丢掉**——快任务可能在派发与订阅之间就已经跑完,答案只在这个快照里。
|
|
334
|
+
*/
|
|
335
|
+
export const consumeSnapshotFrame = (tracker, frame) => {
|
|
336
|
+
const records = frame !== null && frame !== undefined && Array.isArray(frame.records)
|
|
337
|
+
? frame.records
|
|
338
|
+
: (frame !== null && frame !== undefined && Array.isArray(frame.events) ? frame.events : [])
|
|
339
|
+
let last = 'ignored'
|
|
340
|
+
for (const record of records) {
|
|
341
|
+
if (record === null || typeof record !== 'object') continue
|
|
342
|
+
const event = record.type === 'event' && record.event !== undefined && record.event !== null
|
|
343
|
+
? record.event
|
|
344
|
+
: (typeof record.type === 'string' && record.seq !== undefined ? record : undefined)
|
|
345
|
+
if (event === undefined) continue
|
|
346
|
+
const outcome = consumeSessionEvent(tracker, event)
|
|
347
|
+
if (outcome === 'settled') return 'settled'
|
|
348
|
+
if (outcome === 'progress') last = 'progress'
|
|
349
|
+
}
|
|
350
|
+
return last
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/** 由 tracker 得出终态结论:只有 completed 算成功;其他 kind 明确失败;没有证据=未确认。 */
|
|
354
|
+
export const turnOutcome = (end) => {
|
|
355
|
+
if (end === undefined || end === null) return { ok: false, unconfirmed: true, kind: 'unconfirmed' }
|
|
356
|
+
if (end.kind === SUCCESS_TURN_END) return { ok: true, unconfirmed: false, kind: end.kind }
|
|
357
|
+
return { ok: false, unconfirmed: false, kind: end.kind }
|
|
358
|
+
}
|
|
359
|
+
|
|
94
360
|
/**
|
|
95
361
|
* 判定一次派发的目标。
|
|
96
362
|
*
|
|
@@ -130,8 +396,10 @@ export const classifyTarget = (input) => {
|
|
|
130
396
|
if (id === undefined && query === undefined) {
|
|
131
397
|
return { ok: false, reason: REASONS.INVALID_TARGET, message: 'session target needs sessionId or query' }
|
|
132
398
|
}
|
|
133
|
-
// session
|
|
134
|
-
|
|
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]]) {
|
|
135
403
|
if (nonEmpty(value)) {
|
|
136
404
|
return { ok: false, reason: REASONS.INVALID_TARGET, message: `${field} is not accepted with a session target` }
|
|
137
405
|
}
|
|
@@ -283,6 +551,8 @@ export const rankTitleCandidates = (query, summaries, limit = 5) => {
|
|
|
283
551
|
/** 任务在 HTTP 行上的回显:调用方一眼能看出这次是"新建"还是"接进哪个会话"。 */
|
|
284
552
|
export const taskResponse = (task) => {
|
|
285
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
|
|
286
556
|
const target = task.target !== undefined && task.target !== null
|
|
287
557
|
? task.target
|
|
288
558
|
// 兼容 0.1.1 之前落下的记录(如 review 任务):没有 target 字段时按 worker 回显。
|
|
@@ -299,5 +569,24 @@ export const taskResponse = (task) => {
|
|
|
299
569
|
if (task.target !== undefined && task.target !== null && task.target.kind === 'session' && task.delivered === true) {
|
|
300
570
|
out.delivered = true
|
|
301
571
|
}
|
|
572
|
+
// 来源关联(R1):回显发起这次任务的 Codex 对话 id,便于跨回合按 origin+taskId 精确收取。
|
|
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
|
+
}
|
|
302
591
|
return out
|
|
303
592
|
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* task-table.mjs —— 网关内存任务表的淘汰策略(纯函数,可单测)。
|
|
3
|
+
*
|
|
4
|
+
* 为什么单独抽出来:原来三个入口都无条件 `tasks.splice(0, tasks.length - 200)`,会把
|
|
5
|
+
* **仍在执行**的任务挤掉,导致完成回调/会话观察器 `find(taskId)` 找不到它(结果丢失)。
|
|
6
|
+
* 现在只淘汰**终态**任务,且永不淘汰刚创建的那一条。
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export const DEFAULT_TASK_LIMIT = 200
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* 把任务放进表里,并在超限时淘汰终态任务。
|
|
13
|
+
* @param {Array<object>} tasks 任务表(原地修改)
|
|
14
|
+
* @param {object} task 新任务
|
|
15
|
+
* @param {number} [limit]
|
|
16
|
+
* @returns {Array<object>} 同一个数组
|
|
17
|
+
*/
|
|
18
|
+
export const rememberTask = (tasks, task, limit = DEFAULT_TASK_LIMIT) => {
|
|
19
|
+
tasks.push(task)
|
|
20
|
+
const bound = typeof limit === 'number' && limit > 0 ? limit : DEFAULT_TASK_LIMIT
|
|
21
|
+
while (tasks.length > bound) {
|
|
22
|
+
// 淘汰**终态**(非 active)且不是刚加进来的那一条。
|
|
23
|
+
// active 含 accepted / running / needs-approval(等待审批的活任务不能被淘汰,
|
|
24
|
+
// 否则完成回调、会话观察器与审批 pending 都会失去归属)。
|
|
25
|
+
const index = tasks.findIndex((t) => t !== task && !isActiveTask(t))
|
|
26
|
+
if (index === -1) break // 全是活任务:宁可超过上限,也不丢执行中的任务
|
|
27
|
+
tasks.splice(index, 1)
|
|
28
|
+
}
|
|
29
|
+
return tasks
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** 是否处于"还在执行/等待中"的状态(完成回调与审批归属需要能找到它)。 */
|
|
33
|
+
export const isActiveTask = (task) => task !== null && typeof task === 'object'
|
|
34
|
+
&& (task.status === 'accepted' || task.status === 'running' || task.status === 'needs-approval' || task.awaitingOfficialEnd === true)
|