@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.
Files changed (32) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/README.md +197 -26
  3. package/codex-plugin/plugins/dsh/.codex-plugin/plugin.json +1 -1
  4. package/codex-plugin/plugins/dsh/skills/dsh-collab/SKILL.md +102 -6
  5. package/codex-plugin/plugins/dsh/skills/dsh-collab/scripts/review.mjs +91 -6
  6. package/codex-plugin/plugins/dsh/skills/dsh-collab/scripts/task.mjs +571 -44
  7. package/docs/CODEX_DSH_APPROVAL_DELEGATION_PLAN.md +130 -0
  8. package/docs/CODEX_DSH_APPROVAL_M0_EVIDENCE.md +285 -0
  9. package/docs/CODEX_DSH_DELIVERY_CONTROL_PLAN.md +81 -0
  10. package/docs/CODEX_DSH_MODEL_ROUTING_PLAN.md +75 -0
  11. package/docs/CODEX_DSH_TASK_REPLY_PLAN.md +206 -0
  12. package/docs/DSH_APPROVAL_DELEGATION.md +142 -0
  13. package/docs/DSH_DELIVERY_M0_EVIDENCE.md +69 -0
  14. package/docs/DSH_MODEL_ROUTING_M0_EVIDENCE.md +166 -0
  15. package/docs/DSH_TO_CODEX_INBOX.md +167 -0
  16. package/docs/DSH_TO_CODEX_MESSAGING_PLAN.md +140 -0
  17. package/docs/codex-desktop-attachment-evidence.md +116 -0
  18. package/harness/audit-tail.mjs +32 -0
  19. package/harness/codex-approvals.mjs +1078 -0
  20. package/harness/codex-associations.mjs +297 -0
  21. package/harness/codex-outbox.mjs +432 -0
  22. package/harness/dsh-bridge.mjs +2429 -167
  23. package/harness/fs-lock.mjs +208 -0
  24. package/harness/model-route-host.mjs +530 -0
  25. package/harness/model-routing.mjs +500 -0
  26. package/harness/session-observer.mjs +217 -0
  27. package/harness/session-target.mjs +291 -2
  28. package/harness/task-table.mjs +34 -0
  29. package/mcp/codex-desktop-probe.mjs +407 -0
  30. package/mcp/codex-inbox.mjs +271 -0
  31. package/mcp/dsh-mcp.mjs +616 -24
  32. package/package.json +4 -2
@@ -0,0 +1,530 @@
1
+ /**
2
+ * 动态模型路由的**宿主接线**(M2,第二轮验收修正版)。
3
+ *
4
+ * 第二轮修正要点(逐条对应 Root 第二轮反例):
5
+ * 1. `agent/request-error` 必须 **prepend**,且托管任务**拥有恢复/终止权**:耗尽抛结构化错误终止、
6
+ * 其它终态直接 `return undefined` **不调 next**(`llm-retry` 的 always 在**外层**,append/交给 next 会被吞并重试)。
7
+ * 2. 生命周期用 **closed + generation 门闩**:每个 await 之后、写盘/计数/返回配置之前复核;卸载即释放等待。
8
+ * 3. 意图跨**连续托管任务**保留真实基线(不把我们的覆盖当成新的用户基线);人工 `model/selection`
9
+ * 事件(同值也算明确重选)优先于字符串比较;恢复是**完整合法配置**(清掉我们带上的 effort/maxTokens)。
10
+ * 4. worker 必须核对 **expectedOwner + exact live 对象 + isOwnedBy**;拿不到 owner 就 fail-closed 不绑定。
11
+ */
12
+ import { createModelCatalog, selectRoute, createRouteSession, classifyFailure, boundedDelay, DEFAULT_ROUTE_LIMITS } from './model-routing.mjs'
13
+
14
+ export const AUDIT_KINDS = ['plan', 'first-request', 'attempt', 'switch', 'stop', 'finish', 'restore', 'unbound', 'respect-human-selection']
15
+
16
+ const sanitizePolicy = (raw) => {
17
+ const policy = raw !== null && typeof raw === 'object' ? raw : {}
18
+ const mode = typeof policy.mode === 'string' && ['auto', 'fast', 'pro', 'inherit', 'exact'].includes(policy.mode) ? policy.mode : 'auto'
19
+ return {
20
+ mode,
21
+ provider: typeof policy.provider === 'string' && policy.provider !== '' ? policy.provider : undefined,
22
+ model: typeof policy.model === 'string' && policy.model !== '' ? policy.model : undefined,
23
+ allowFallback: policy.allowFallback === undefined ? (mode === 'auto' || mode === 'fast' || mode === 'pro') : policy.allowFallback === true,
24
+ taskKind: typeof policy.taskKind === 'string' ? policy.taskKind : 'code',
25
+ needs: policy.needs !== null && typeof policy.needs === 'object' ? policy.needs : {},
26
+ }
27
+ }
28
+
29
+ const sessionIdOf = (agent) => {
30
+ if (agent === null || agent === undefined) return undefined
31
+ if (typeof agent.sessionId === 'string') return agent.sessionId
32
+ const session = agent.session
33
+ if (session !== null && session !== undefined && typeof session.id === 'string') return session.id
34
+ if (session !== null && session !== undefined && session.header !== undefined && session.header !== null && typeof session.header.id === 'string') return session.header.id
35
+ return undefined
36
+ }
37
+
38
+ /** 完整配置快照(含"没有 effort/maxTokens"这一事实——恢复时据此清除我们带上的残留)。 */
39
+ const cfgOf = (config) => ({
40
+ provider: config.provider,
41
+ model: config.model,
42
+ ...(config.reasoningEffort === undefined ? {} : { reasoningEffort: config.reasoningEffort }),
43
+ ...(config.maxTokens === undefined ? {} : { maxTokens: config.maxTokens }),
44
+ })
45
+
46
+ export const createModelRouting = (options = {}) => {
47
+ const ctx = options.ctx
48
+ const llm = options.llm
49
+ const now = typeof options.now === 'function' ? options.now : () => Date.now()
50
+ const agentsOf = typeof options.agents === 'function' ? options.agents : () => undefined
51
+ const auditSink = typeof options.audit === 'function' ? options.audit : null
52
+ const readSessionEvents = typeof options.readSessionEvents === 'function' ? options.readSessionEvents : null
53
+ const limits = { ...DEFAULT_ROUTE_LIMITS, ...(options.limits || {}) }
54
+ const maxRetryAfterMs = Number.isFinite(options.maxRetryAfterMs) ? options.maxRetryAfterMs : 30_000
55
+
56
+ const catalog = createModelCatalog({ llm, now })
57
+ const cooldowns = new Map()
58
+ const plans = new Map()
59
+ const routes = new Map()
60
+ const routeKeyOfAgent = new Map()
61
+ /** agentId → { baseline:完整cfg, override:{provider,model}, taskId, recordedSeq }(跨连续托管任务保留) */
62
+ const intents = new Map()
63
+ const audit = []
64
+ const counters = { plans: 0, firstRequests: 0, attempts: 0, switches: 0, stops: 0, restores: 0, unboundRequests: 0, rejectedBindings: 0, retryAfterWaits: 0, exhaustedAborts: 0, unloadedDrops: 0, humanSelections: 0 }
65
+ /** 生命周期门闩:uninstall 后任何异步返回都不得改配置/写盘/计数。 */
66
+ let closed = false
67
+ let generation = 0
68
+ const isLive = () => closed !== true
69
+
70
+ const emit = (record) => {
71
+ if (!isLive()) { counters.unloadedDrops += 1; return null }
72
+ const entry = { at: now(), ...record }
73
+ audit.push(entry)
74
+ if (audit.length > 800) audit.shift()
75
+ if (auditSink !== null) { try { const r = auditSink(entry); if (r !== undefined && typeof r.then === 'function') r.catch(() => {}) } catch (e) { /* 审计失败绝不影响执行 */ } }
76
+ return entry
77
+ }
78
+
79
+ const liveAgentOf = (agentId) => {
80
+ const live = agentsOf()
81
+ if (live === undefined || live === null || typeof live.get !== 'function') return undefined
82
+ try { return live.get(agentId) } catch (e) { return undefined }
83
+ }
84
+ const isOwnedBy = (childId, owner) => {
85
+ const live = agentsOf()
86
+ if (live === undefined || live === null || typeof live.isOwnedBy !== 'function') return null // 无法证明 → null(fail-closed)
87
+ try { return live.isOwnedBy(childId, owner) === true } catch (e) { return null }
88
+ }
89
+
90
+ /** 官方选择意图:会话里最后一条 `model/selection` 的 seq(同值也算"人工明确重选")。 */
91
+ const latestSelectionSeq = async (agent) => {
92
+ if (readSessionEvents === null) return null
93
+ const sessionId = sessionIdOf(agent)
94
+ if (typeof sessionId !== 'string' || sessionId === '') return null
95
+ let events = null
96
+ try { events = await readSessionEvents(sessionId) } catch (e) { return undefined }
97
+ if (!Array.isArray(events)) return undefined
98
+ let seq = null
99
+ for (const e of events) {
100
+ if (e === null || typeof e !== 'object') continue
101
+ if (e.type !== 'model/selection') continue
102
+ if (typeof e.seq === 'number') seq = seq === null ? e.seq : Math.max(seq, e.seq)
103
+ }
104
+ return seq
105
+ }
106
+
107
+ const planRoute = async ({ taskId, policy, kind, execution, originThreadId, sessionId, childId, expectedOwner, onExhausted, onTerminal }) => {
108
+ const normalized = sanitizePolicy(policy)
109
+ const gen = generation
110
+ // **执行目标与业务 kind 解耦**:业务 kind 只是 task/review,用哪个通道执行由 target/预分配 childId 决定。
111
+ const execTarget = execution !== undefined && (execution === 'session' || execution === 'worker')
112
+ ? execution
113
+ : (typeof childId === 'string' && childId !== '' ? 'worker' : 'session')
114
+ let selection = null
115
+ if (normalized.mode !== 'inherit') {
116
+ selection = await selectRoute({
117
+ catalog,
118
+ request: { mode: normalized.mode, provider: normalized.provider, model: normalized.model, taskKind: normalized.taskKind, needs: normalized.needs, allowFallback: normalized.allowFallback },
119
+ resolveInfo: catalog.resolveInfo,
120
+ })
121
+ } else if (normalized.allowFallback === true) {
122
+ selection = await selectRoute({ catalog, request: { mode: 'inherit', allowFallback: true, taskKind: normalized.taskKind, needs: normalized.needs }, resolveInfo: catalog.resolveInfo })
123
+ }
124
+ // 门闩:目录/能力 await 之后若已卸载或换代 → **不写盘、不计数、不登记**
125
+ if (!isLive() || gen !== generation) { counters.unloadedDrops += 1; return { cancelled: true, taskId } }
126
+ const plan = { taskId, kind, execution: execTarget, originThreadId, sessionId, childId, expectedOwner, policy: normalized, selection, createdAt: now(), bound: false, onExhausted, onTerminal }
127
+ plans.set(taskId, plan)
128
+ counters.plans += 1
129
+ emit({
130
+ kind: 'plan', taskId, originThreadId, sessionId, taskKind: normalized.taskKind, businessKind: kind, execution: execTarget,
131
+ mode: normalized.mode, allowFallback: normalized.allowFallback,
132
+ chosen: selection !== null && selection.ok === true ? { provider: selection.pair.provider, model: selection.pair.model } : null,
133
+ reason: selection === null ? 'inherit' : selection.reason,
134
+ ...(execTarget === 'worker' ? { childId, expectedOwner } : {}),
135
+ ...(selection !== null && selection.ok !== true ? { rejected: selection.rejected === undefined ? undefined : selection.rejected.slice(0, 5) } : {}),
136
+ })
137
+ return plan
138
+ }
139
+
140
+ const routesForAgent = (agentId) => {
141
+ const set = routeKeyOfAgent.get(agentId)
142
+ return set === undefined ? [] : Array.from(set).map((key) => routes.get(key)).filter((r) => r !== undefined)
143
+ }
144
+
145
+ const retireBinding = (binding, reason) => {
146
+ if (binding === undefined || binding === null || binding.active !== true) return
147
+ binding.active = false
148
+ if (typeof binding.wake === 'function') { try { binding.wake() } catch (e) { /* ignore */ } }
149
+ const key = binding.agentId + '\u0000' + binding.taskId
150
+ routes.delete(key)
151
+ const set = routeKeyOfAgent.get(binding.agentId)
152
+ if (set !== undefined) { set.delete(key); if (set.size === 0) routeKeyOfAgent.delete(binding.agentId) }
153
+ emit({ kind: 'finish', taskId: binding.taskId, status: 'retired', reason })
154
+ }
155
+
156
+ /** 绑定查找:exact live 对象 + **本任务那一个 turn**;turn 迁移即失活。 */
157
+ const bindingFor = (agent, turn, step) => {
158
+ if (agent === null || agent === undefined || typeof agent.id !== 'string') return null
159
+ const candidates = routesForAgent(agent.id).filter((r) => r.active === true)
160
+ if (candidates.length === 0) return null
161
+ const live = liveAgentOf(agent.id)
162
+ for (const binding of candidates) {
163
+ if (binding.agent !== agent) continue
164
+ if (live !== undefined && live !== agent) continue
165
+ if (Number.isInteger(binding.boundTurn) && Number.isInteger(turn) && binding.boundTurn !== turn) {
166
+ retireBinding(binding, 'turn-moved-on')
167
+ continue
168
+ }
169
+ return binding
170
+ }
171
+ return null
172
+ }
173
+
174
+ /** 未绑定请求:官方选择事件优先,其次逐 agent 真实基线;恢复是**完整合法配置**。 */
175
+ const unboundDecision = async (agent, base) => {
176
+ counters.unboundRequests += 1
177
+ if (agent === null || agent === undefined || typeof agent.id !== 'string') return null
178
+ const intent = intents.get(agent.id)
179
+ if (intent === undefined) return null
180
+ const lastSelection = await latestSelectionSeq(agent)
181
+ if (!isLive()) return null
182
+ if (lastSelection === undefined) return null // No fresh evidence: do not restore over an unseen human choice.
183
+ if (lastSelection !== null && (intent.recordedSeq === null || intent.recordedSeq === undefined || lastSelection > intent.recordedSeq)) {
184
+ counters.humanSelections += 1
185
+ intents.delete(agent.id)
186
+ emit({ kind: 'respect-human-selection', taskId: intent.taskId, agentId: agent.id, selectionSeq: lastSelection, recordedSeq: intent.recordedSeq === undefined ? null : intent.recordedSeq })
187
+ return null
188
+ }
189
+ const proposalsOurOverride = base !== null && base !== undefined && base.provider === intent.override.provider && base.model === intent.override.model
190
+ if (!proposalsOurOverride) { intents.delete(agent.id); return null }
191
+ const restored = { ...base, provider: intent.baseline.provider, model: intent.baseline.model }
192
+ if (intent.baseline.reasoningEffort === undefined) delete restored.reasoningEffort
193
+ else restored.reasoningEffort = intent.baseline.reasoningEffort
194
+ if (intent.baseline.maxTokens === undefined) delete restored.maxTokens
195
+ else restored.maxTokens = intent.baseline.maxTokens
196
+ counters.restores += 1
197
+ emit({ kind: 'restore', taskId: intent.taskId, agentId: agent.id, from: { provider: intent.override.provider, model: intent.override.model }, to: { provider: intent.baseline.provider, model: intent.baseline.model }, reason: 'restore-per-agent-baseline' })
198
+ intents.delete(agent.id)
199
+ return restored
200
+ }
201
+
202
+ /** 按目标能力处理 effort/maxTokens(只调这两个字段,不删图/不截历史/不重放工具)。 */
203
+ const compatibleConfig = async (base, pair) => {
204
+ const next = { ...base, provider: pair.provider, model: pair.model }
205
+ const info = await catalog.resolveInfo(pair.provider, pair.model)
206
+ const cap = info.ok === true ? info.capability : null
207
+ const efforts = cap !== null && Array.isArray(cap.efforts) ? cap.efforts : null
208
+ if (base.reasoningEffort !== undefined) {
209
+ if (efforts !== null) {
210
+ if (!efforts.includes(base.reasoningEffort)) {
211
+ if (typeof cap.defaultEffort === 'string' && efforts.includes(cap.defaultEffort)) next.reasoningEffort = cap.defaultEffort
212
+ else delete next.reasoningEffort
213
+ }
214
+ } else delete next.reasoningEffort
215
+ }
216
+ if (cap !== null && Number.isFinite(cap.defaultMaxTokens)) {
217
+ if (!Number.isFinite(next.maxTokens) || next.maxTokens > cap.defaultMaxTokens) next.maxTokens = cap.defaultMaxTokens
218
+ }
219
+ return next
220
+ }
221
+
222
+ /** 记录/延续该 agent 的**真实基线**(跨连续托管任务保留;绝不把我们的覆盖当基线)。 */
223
+ const rememberIntent = (agentId, base, override, taskId, recordedSeq) => {
224
+ const prev = intents.get(agentId)
225
+ const proposalsOurPreviousOverride = prev !== undefined && prev.override !== undefined && base.provider === prev.override.provider && base.model === prev.override.model
226
+ const baseline = proposalsOurPreviousOverride ? prev.baseline : cfgOf(base)
227
+ const carrySeq = proposalsOurPreviousOverride && prev.recordedSeq !== undefined ? prev.recordedSeq : recordedSeq
228
+ intents.set(agentId, { baseline, override: { provider: override.provider, model: override.model }, taskId, recordedSeq: carrySeq })
229
+ return baseline
230
+ }
231
+
232
+ const onRequest = async (payload, next) => {
233
+ const base = await next()
234
+ if (!isLive()) return base // 卸载后绝不改请求
235
+ const agent = payload !== null && typeof payload === 'object' ? payload.agent : undefined
236
+ const turn = payload !== null && typeof payload === 'object' && Number.isInteger(payload.turn) ? payload.turn : undefined
237
+ const step = payload !== null && typeof payload === 'object' && Number.isInteger(payload.step) ? payload.step : undefined
238
+ const binding = bindingFor(agent, turn, step)
239
+ if (binding === null) {
240
+ const restored = await unboundDecision(agent, base)
241
+ if (!isLive() || restored === null) return base // await 后门闩
242
+ return restored
243
+ }
244
+ counters.attempts += 1
245
+ const recordedSeq = await latestSelectionSeq(agent)
246
+ if (!isLive() || binding.active !== true) return base // await 后门闩(选择/能力查询在途卸载)
247
+ const summary = binding.routeSession === null ? null : binding.routeSession.summary()
248
+ const target = binding.pendingTarget !== undefined && binding.pendingTarget !== null ? binding.pendingTarget : (summary === null ? null : summary.current)
249
+ if (target === null || target === undefined || typeof target.provider !== 'string') return base
250
+ const nextConfig = await compatibleConfig(base, target)
251
+ // 能力解析 await 之后:卸载/失活 → 绝不改配置、绝不写审计(Root 第二轮反例 2/3e)
252
+ if (!isLive() || binding.active !== true) return base
253
+ if (binding.routeSession !== null) {
254
+ binding.routeSession.recordAttempt({ provider: nextConfig.provider, model: nextConfig.model }, { turn, step })
255
+ if (binding.pendingTarget !== undefined) binding.pendingTarget = undefined
256
+ }
257
+ rememberIntent(agent.id, base, nextConfig, binding.taskId, recordedSeq)
258
+ if (binding.firstRequestDone !== true) {
259
+ binding.firstRequestDone = true
260
+ counters.firstRequests += 1
261
+ emit({ kind: 'first-request', taskId: binding.taskId, turn, step, provider: nextConfig.provider, model: nextConfig.model, reason: binding.planReason })
262
+ } else {
263
+ emit({ kind: 'attempt', taskId: binding.taskId, turn, step, provider: nextConfig.provider, model: nextConfig.model })
264
+ }
265
+ return nextConfig
266
+ }
267
+
268
+ const onRequestError = async (payload, next) => {
269
+ const agent = payload !== null && typeof payload === 'object' ? payload.agent : undefined
270
+ const turn = payload !== null && typeof payload === 'object' ? payload.turn : undefined
271
+ const step = payload !== null && typeof payload === 'object' ? payload.step : undefined
272
+ const binding = bindingFor(agent, turn, step)
273
+ if (binding === null) return next() // 未托管:交还下游
274
+ if (!isLive() || binding.active !== true) return next()
275
+ const failure = payload !== null && typeof payload === 'object' ? payload.failure : undefined
276
+ const classified = classifyFailure(failure)
277
+ emit({
278
+ kind: 'failure', taskId: binding.taskId, turn, step,
279
+ provider: payload !== null && typeof payload.provider === 'string' ? payload.provider : undefined,
280
+ code: classified.code, action: classified.action, reason: classified.reason,
281
+ })
282
+ const aborted = payload !== null && payload.signal !== undefined && payload.signal !== null && payload.signal.aborted === true
283
+ /** 托管任务**拥有终态权**:耗尽抛出结构化终止;其它终态 return undefined(都不调 next,always 在外层会吞掉 next 后继续)。 */
284
+ const ownedTerminal = (reason, code, exhausted) => {
285
+ counters.stops += 1
286
+ emit({ kind: 'stop', taskId: binding.taskId, reason, ...(code === undefined ? {} : { code }), exhausted })
287
+ // 终态事实通知宿主(**只记录/收口本任务**,不 abort 观察者、不 cancel Agent、不碰其他 queue)
288
+ if (typeof binding.onTerminal === 'function') {
289
+ try { binding.onTerminal({ reason, code, exhausted: exhausted === true }) } catch (e) { /* ignore */ }
290
+ }
291
+ if (exhausted) {
292
+ counters.exhaustedAborts += 1
293
+ if (typeof binding.onExhausted === 'function') { try { binding.onExhausted(reason) } catch (e) { /* ignore */ } }
294
+ const stopError = new Error('model routing budget exhausted: ' + reason)
295
+ stopError.code = 'MODEL_ROUTE_EXHAUSTED'
296
+ stopError.reason = reason
297
+ throw stopError
298
+ }
299
+ return undefined
300
+ }
301
+ if (binding.policy.allowFallback !== true) return ownedTerminal('fallback-disabled', classified.code, false)
302
+ if (binding.routeSession === null) return ownedTerminal('no-route-pool', classified.code, false)
303
+ const decision = binding.routeSession.onFailure(failure, { aborted })
304
+ if (decision.action !== 'retry') return ownedTerminal(decision.reason, decision.code, decision.exhausted === true)
305
+ if (decision.switched === true) {
306
+ counters.switches += 1
307
+ const summary2 = binding.routeSession.summary()
308
+ const lastSwitch = summary2.switchLog[summary2.switchLog.length - 1]
309
+ emit({ kind: 'switch', taskId: binding.taskId, turn, step, from: lastSwitch === undefined ? null : lastSwitch.from, to: lastSwitch === undefined ? null : lastSwitch.to, code: classified.code, reason: decision.reason, switchCount: summary2.switches })
310
+ }
311
+ if (Number.isFinite(decision.retryAfterMs) && decision.retryAfterMs > 0) {
312
+ counters.retryAfterWaits += 1
313
+ const waitMs = Math.min(decision.retryAfterMs, maxRetryAfterMs)
314
+ emit({ kind: 'attempt', taskId: binding.taskId, note: 'retry-after-wait', waitMs })
315
+ await boundedDelay(waitMs, { isCancelled: () => binding.active !== true || !isLive() })
316
+ if (!isLive() || binding.active !== true) return undefined // 卸载/失活 → 不再重试
317
+ }
318
+ binding.pendingTarget = { provider: decision.pair.provider, model: decision.pair.model }
319
+ return { kind: 'retry' }
320
+ }
321
+
322
+ /**
323
+ * 首请求归属的**统一口径**(execution 决定通道;rpcId 只是关联标识,**不是授权**):
324
+ * - exact live registry 对象(ID 不能代替对象身份);
325
+ * - worker 执行目标:**精确 childId** + 可信 expectedOwner(当前 live 对象)+ 官方 isOwnedBy + parent lineage;
326
+ * - session 执行目标:claimed session 必须等于登记 session;
327
+ * - 任何一项失败 → 拒绝且**不消耗 plan**(真正的预期 child 之后仍可认领)。
328
+ */
329
+ const verifyClaim = (plan, agent, live) => {
330
+ const workerTarget = plan.execution === 'worker' || (typeof plan.childId === 'string' && plan.childId !== '')
331
+ const registry = agentsOf()
332
+ const registryHasGet = registry !== undefined && registry !== null && typeof registry.get === 'function'
333
+ if (registryHasGet) {
334
+ if (live === undefined || live === null) return { ok: false, reason: 'registry-missing' }
335
+ if (live !== agent) return { ok: false, reason: 'registry-epoch-mismatch' }
336
+ }
337
+ if (workerTarget) {
338
+ if (typeof plan.childId !== 'string' || plan.childId === '') return { ok: false, reason: 'worker-child-unknown' }
339
+ // 错 child(另一 parent 的 child / 同 parent 的另一 child)一律拒绝:rpcId 相同也不行
340
+ if (plan.childId !== agent.id) return { ok: false, reason: 'worker-child-mismatch', claimedChildId: agent.id, expectedChildId: plan.childId }
341
+ if (typeof plan.expectedOwner !== 'string' || plan.expectedOwner === '') return { ok: false, reason: 'worker-owner-unknown' }
342
+ const ownerAgent = liveAgentOf(plan.expectedOwner)
343
+ if (registryHasGet && (ownerAgent === undefined || ownerAgent === null)) return { ok: false, reason: 'worker-owner-not-live', expectedOwner: plan.expectedOwner }
344
+ const owned = isOwnedBy(agent.id, ownerAgent)
345
+ if (owned !== true) return { ok: false, reason: owned === false ? 'worker-owner-mismatch' : 'worker-owner-unverifiable', expectedOwner: plan.expectedOwner }
346
+ const header = agent !== null && agent !== undefined && agent.session !== null && agent.session !== undefined ? agent.session.header : undefined
347
+ const parentSession = header !== null && header !== undefined ? header.parentSession : undefined
348
+ if (parentSession !== undefined && parentSession !== null && parentSession !== plan.expectedOwner) {
349
+ return { ok: false, reason: 'worker-lineage-mismatch', parentSession, expectedOwner: plan.expectedOwner }
350
+ }
351
+ return { ok: true }
352
+ }
353
+ if (plan.sessionId !== undefined && plan.sessionId !== null) {
354
+ const claimedSession = sessionIdOf(agent)
355
+ const liveSession = sessionIdOf(live)
356
+ if (claimedSession !== undefined && claimedSession !== plan.sessionId) return { ok: false, reason: 'session-mismatch', claimedSession, expectedSession: plan.sessionId }
357
+ if (claimedSession === undefined && liveSession !== undefined && liveSession !== plan.sessionId) return { ok: false, reason: 'live-session-mismatch', claimedSession: liveSession, expectedSession: plan.sessionId }
358
+ }
359
+ return { ok: true }
360
+ }
361
+
362
+ const onClaimed = (payload) => {
363
+ if (!isLive()) return
364
+ const message = payload !== null && typeof payload === 'object' ? payload.message : undefined
365
+ const source = message !== null && message !== undefined && typeof message.source === 'object' && message.source !== null ? message.source : {}
366
+ const agent = payload !== null && typeof payload === 'object' ? payload.agent : undefined
367
+ const turn = payload !== null && typeof payload === 'object' && Number.isInteger(payload.turn) ? payload.turn : undefined
368
+ const rpcId = typeof source.rpcId === 'string' ? source.rpcId : undefined
369
+ if (agent === undefined || typeof agent.id !== 'string') return
370
+ const live = liveAgentOf(agent.id)
371
+ let plan
372
+ if (rpcId !== undefined) {
373
+ plan = plans.get(rpcId)
374
+ if (plan === undefined || plan.bound === true) return
375
+ } else {
376
+ // 无 rpcId 的 worker 首输入:只按**精确 childId** 唯一匹配预登记 plan
377
+ const matches = Array.from(plans.values()).filter((p) => p.bound !== true && typeof p.childId === 'string' && p.childId !== '' && p.childId === agent.id)
378
+ if (matches.length !== 1) { if (matches.length > 1) counters.rejectedBindings += 1; return }
379
+ plan = matches[0]
380
+ }
381
+ const verdict = verifyClaim(plan, agent, live)
382
+ if (verdict.ok !== true) {
383
+ counters.rejectedBindings += 1
384
+ const { ok, reason, ...details } = verdict
385
+ emit({ kind: 'unbound', taskId: plan.taskId, reason, ...details })
386
+ return false // 不消耗 plan:真正的预期 child 之后仍能认领
387
+ }
388
+ const routeSession = plan.selection !== null && plan.selection.ok === true
389
+ ? createRouteSession({
390
+ initial: { provider: plan.selection.pair.provider, model: plan.selection.pair.model },
391
+ alternatives: (plan.selection.alternatives || []).map((a) => ({ provider: a.provider, model: a.model })),
392
+ cooldowns, now, limits,
393
+ })
394
+ : (plan.policy.allowFallback === true && plan.selection !== null && Array.isArray(plan.selection.alternatives)
395
+ ? createRouteSession({ initial: null, alternatives: plan.selection.alternatives.map((a) => ({ provider: a.provider, model: a.model })), cooldowns, now, limits })
396
+ : null)
397
+ const binding = {
398
+ taskId: plan.taskId, plan, policy: plan.policy, agent, agentId: agent.id,
399
+ routeSession, pendingTarget: undefined, boundTurn: turn, firstRequestDone: false, active: true,
400
+ planReason: plan.selection === null ? 'inherit' : (plan.selection.ok === true ? plan.selection.reason : 'selection-failed:' + plan.selection.reason),
401
+ onExhausted: plan.onExhausted,
402
+ onTerminal: plan.onTerminal,
403
+ createdAt: now(),
404
+ }
405
+ const key = agent.id + '\u0000' + plan.taskId
406
+ routes.set(key, binding)
407
+ const set = routeKeyOfAgent.get(agent.id)
408
+ if (set === undefined) routeKeyOfAgent.set(agent.id, new Set([key]))
409
+ else set.add(key)
410
+ plan.bound = true
411
+ emit({
412
+ kind: 'first-request-bound', taskId: plan.taskId, agentId: agent.id, turn,
413
+ businessKind: plan.kind, execution: plan.execution,
414
+ ...(rpcId === undefined ? { via: 'worker-childId+owner', expectedOwner: plan.expectedOwner } : { via: 'agent/inbox/claimed', rpcId }),
415
+ })
416
+ return binding
417
+ }
418
+
419
+ const finishRoute = ({ taskId, status, reason }) => {
420
+ const plan = plans.get(taskId)
421
+ const affected = Array.from(routes.values()).filter((r) => r.taskId === taskId)
422
+ const summary = affected.length > 0 && affected[0].routeSession !== null ? affected[0].routeSession.summary() : null
423
+ for (const binding of affected) retireBinding(binding, 'task-finished:' + String(status))
424
+ if (plan !== undefined) plans.delete(taskId)
425
+ emit({
426
+ kind: 'finish', taskId, status,
427
+ ...(reason === undefined ? {} : { reason }),
428
+ switches: summary === null ? 0 : summary.switches,
429
+ attempts: summary === null ? 0 : summary.attempts,
430
+ ...(summary === null ? {} : { current: summary.current, lastFailure: summary.lastFailure === null ? null : { code: summary.lastFailure.code, action: summary.lastFailure.action } }),
431
+ })
432
+ return summary
433
+ }
434
+
435
+ const modelsView = async ({ force = false } = {}) => {
436
+ const snap = await catalog.snapshot({ force })
437
+ const at = now()
438
+ const cooldownView = Array.from(cooldowns.entries()).filter(([, until]) => until > at).map(([key, until]) => {
439
+ const [provider, model] = key.split('\u0000')
440
+ return { provider, model, until, remainingMs: until - at }
441
+ })
442
+ return {
443
+ ok: true,
444
+ fetchedAt: snap.at,
445
+ servedFromCache: snap.servedFromCache === true,
446
+ unavailable: snap.unavailable === true,
447
+ pairs: (snap.pairs || []).map((p) => ({ provider: p.provider, model: p.model, name: p.name })),
448
+ failures: snap.failures || [],
449
+ cooldowns: cooldownView,
450
+ note: '目录只说明"已注册且已列出";这不等于凭据/额度/远端健康已验证,也没有质量/价格/评分。',
451
+ }
452
+ }
453
+
454
+ const status = () => ({
455
+ plans: counters.plans,
456
+ boundRoutes: routes.size,
457
+ counters: { ...counters },
458
+ limits: { ...limits },
459
+ live: isLive(),
460
+ generation,
461
+ auditTail: audit.slice(-10),
462
+ })
463
+
464
+ /** 官方会话事件:turn/end 即时失活;model/selection 记录为"人工明确选择"。 */
465
+ const installSessionListeners = () => {
466
+ const offs = []
467
+ const handler = (payload) => {
468
+ try {
469
+ const agent = payload !== null && typeof payload === 'object' ? (payload.agent !== undefined ? payload.agent : payload) : undefined
470
+ const event = payload !== null && typeof payload === 'object' && payload.event !== undefined ? payload.event : payload
471
+ if (event === null || event === undefined || typeof event.type !== 'string') return
472
+ if (event.type === 'model/selection' && agent !== undefined && typeof agent.id === 'string') {
473
+ const intent = intents.get(agent.id)
474
+ const seq = event.seq !== undefined && Number.isInteger(event.seq) ? event.seq : Number.MAX_SAFE_INTEGER
475
+ if (intent !== undefined) { counters.humanSelections += 1; intents.set(agent.id, { ...intent, recordedSeq: seq, humanSelected: true }) }
476
+ return
477
+ }
478
+ if (event.type !== 'turn/end') return
479
+ const turn = event.data !== undefined && event.data !== null && Number.isInteger(event.data.turn) ? event.data.turn : undefined
480
+ if (agent === undefined || typeof agent.id !== 'string') return
481
+ for (const binding of routesForAgent(agent.id)) {
482
+ if (turn === undefined || binding.boundTurn === turn) retireBinding(binding, 'official-turn-end')
483
+ }
484
+ } catch (e) { /* 失活失败不影响执行 */ }
485
+ }
486
+ for (const name of ['session/event', 'session/events']) {
487
+ try {
488
+ const off = ctx.on(name, handler)
489
+ if (typeof off === 'function') offs.push(off)
490
+ } catch (e) { /* 事件名不存在 → 忽略 */ }
491
+ }
492
+ return () => { for (const off of offs) { try { off() } catch (e) { /* ignore */ } } }
493
+ }
494
+
495
+ const install = () => {
496
+ if (ctx === undefined || typeof ctx.on !== 'function') return () => {}
497
+ closed = false
498
+ generation += 1
499
+ const offs = []
500
+ offs.push(ctx.on('agent/inbox/claimed', (payload) => { try { onClaimed(payload) } catch (e) { /* 归属失败绝不影响执行 */ } }))
501
+ offs.push(ctx.on('agent/request', (payload, next) => onRequest(payload, next), { prepend: true }))
502
+ // **prepend** 是必须的:llm-retry 的 always 在外层,append 会被 settleDownstream 吞掉后继续重试。
503
+ offs.push(ctx.on('agent/request-error', (payload, next) => onRequestError(payload, next), { prepend: true }))
504
+ const offSession = installSessionListeners()
505
+ let offAdapters = null
506
+ try { offAdapters = ctx.on('llm/adapters-updated', () => { catalog.invalidate() }) } catch (e) { offAdapters = null }
507
+ return () => {
508
+ // 卸载:先关门(此后所有异步返回都不得改配置/写盘/计数),再卸监听、失活绑定、释放等待
509
+ closed = true
510
+ generation += 1
511
+ for (const off of offs) { try { if (typeof off === 'function') off() } catch (e) { /* ignore */ } }
512
+ try { offSession() } catch (e) { /* ignore */ }
513
+ if (typeof offAdapters === 'function') { try { offAdapters() } catch (e) { /* ignore */ } }
514
+ for (const binding of Array.from(routes.values())) { binding.active = false; if (typeof binding.wake === 'function') { try { binding.wake() } catch (e) { /* ignore */ } } }
515
+ routes.clear()
516
+ routeKeyOfAgent.clear()
517
+ plans.clear()
518
+ intents.clear()
519
+ emit({ kind: 'finish', taskId: null, status: 'unloaded' })
520
+ }
521
+ }
522
+
523
+ return {
524
+ planRoute, finishRoute, modelsView, status, install, onClaimed, onRequest, onRequestError,
525
+ catalog, cooldowns, limits,
526
+ isBound: (taskId) => Array.from(routes.values()).some((r) => r.taskId === taskId),
527
+ auditView: (taskId) => audit.filter((e) => taskId === undefined || e.taskId === taskId),
528
+ intentFor: (agentId) => intents.get(agentId),
529
+ }
530
+ }