@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.
- package/CHANGELOG.md +9 -0
- package/README.md +105 -25
- package/codex-plugin/plugins/dsh/.codex-plugin/plugin.json +1 -1
- package/codex-plugin/plugins/dsh/skills/dsh-collab/SKILL.md +63 -3
- package/codex-plugin/plugins/dsh/skills/dsh-collab/scripts/review.mjs +91 -6
- package/codex-plugin/plugins/dsh/skills/dsh-collab/scripts/task.mjs +443 -39
- 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/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/harness/audit-tail.mjs +32 -0
- package/harness/codex-approvals.mjs +1078 -0
- package/harness/codex-associations.mjs +39 -7
- package/harness/dsh-bridge.mjs +1950 -191
- 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 +161 -6
- package/harness/task-table.mjs +6 -4
- package/mcp/dsh-mcp.mjs +474 -12
- package/package.json +2 -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
|
+
}
|
|
@@ -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)
|
|
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
|
-
|
|
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
|
}
|
package/harness/task-table.mjs
CHANGED
|
@@ -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
|
-
//
|
|
23
|
-
|
|
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)
|