@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,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
|
+
}
|