@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
package/harness/dsh-bridge.mjs
CHANGED
|
@@ -19,6 +19,8 @@
|
|
|
19
19
|
* 与动态插件版本的关键差异:宿主插件运行在完整 Node 环境,
|
|
20
20
|
* AbortSignal 可用,因此 followup 的 cold-resume 路径不再降级。
|
|
21
21
|
*/
|
|
22
|
+
import fs from 'node:fs'
|
|
23
|
+
import path from 'node:path'
|
|
22
24
|
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
23
25
|
import {
|
|
24
26
|
ASSOC_REASONS,
|
|
@@ -26,6 +28,10 @@ import {
|
|
|
26
28
|
openAssociations,
|
|
27
29
|
} from './codex-associations.mjs'
|
|
28
30
|
import { rememberTask } from './task-table.mjs'
|
|
31
|
+
import { createAuditTailReader } from './audit-tail.mjs'
|
|
32
|
+
import { createSessionObservationHub } from './session-observer.mjs'
|
|
33
|
+
import { createModelRouting } from './model-route-host.mjs'
|
|
34
|
+
import { DEFAULT_ROUTE_LIMITS } from './model-routing.mjs'
|
|
29
35
|
import {
|
|
30
36
|
REASONS,
|
|
31
37
|
textOfBlocks,
|
|
@@ -44,7 +50,23 @@ import {
|
|
|
44
50
|
consumeSessionEvent,
|
|
45
51
|
consumeSnapshotFrame,
|
|
46
52
|
turnOutcome,
|
|
53
|
+
openTurnsOf,
|
|
54
|
+
currentOpenTurn,
|
|
55
|
+
turnHasEnded,
|
|
56
|
+
findRequestEvidence,
|
|
47
57
|
} from './session-target.mjs'
|
|
58
|
+
import {
|
|
59
|
+
APPROVAL_REASONS,
|
|
60
|
+
APPROVAL_STATES,
|
|
61
|
+
DECISIONS,
|
|
62
|
+
MAX_AUTHORIZED_TTL_MS,
|
|
63
|
+
controlCredDigest,
|
|
64
|
+
keyFingerprint8,
|
|
65
|
+
openApprovalStore,
|
|
66
|
+
readControlKey,
|
|
67
|
+
verifyControlHeader,
|
|
68
|
+
controlKeyPath,
|
|
69
|
+
} from './codex-approvals.mjs'
|
|
48
70
|
|
|
49
71
|
export const name = 'dsh-bridge'
|
|
50
72
|
|
|
@@ -65,6 +87,12 @@ export async function apply(ctx) {
|
|
|
65
87
|
// 在 apply() 时捕获会永久拿到 undefined。每次使用时现取;缺失时返回稳定的
|
|
66
88
|
// session-controller-unavailable,而不是让整个插件挂掉。
|
|
67
89
|
const sessionControllerNow = () => ctx.get('sessionController')
|
|
90
|
+
const bridgeVersion = (() => { try { return JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version } catch { return 'unknown' } })()
|
|
91
|
+
const observations = createSessionObservationHub({
|
|
92
|
+
ctx, query: () => sessionQuery, controller: sessionControllerNow,
|
|
93
|
+
liveSession: (id) => ctx.get('sessions')?.get?.(id) || agents?.get?.(id)?.session,
|
|
94
|
+
})
|
|
95
|
+
ctx.effect(() => () => observations.close())
|
|
68
96
|
|
|
69
97
|
// 清理历史版本可能泄漏的路由条目(重启前的动态插件、旧版本次插件)
|
|
70
98
|
try {
|
|
@@ -151,6 +179,70 @@ export async function apply(ctx) {
|
|
|
151
179
|
return {}
|
|
152
180
|
}
|
|
153
181
|
|
|
182
|
+
// ---- 动态模型路由(M2)----
|
|
183
|
+
// 目录/选择/分类逻辑在 model-routing.mjs,宿主 hook 与归属在 model-route-host.mjs;
|
|
184
|
+
// 这里只负责:策略解析、派活前登记、审计落盘、只读目录路由与能力声明。
|
|
185
|
+
const routeAuditPath = () => (associations === null || typeof associations.dir !== 'string' ? null : associations.dir + '/model-routing.jsonl')
|
|
186
|
+
const routeAuditLines = []
|
|
187
|
+
const appendRouteAudit = (entry) => {
|
|
188
|
+
routeAuditLines.push(entry)
|
|
189
|
+
if (routeAuditLines.length > 400) routeAuditLines.shift()
|
|
190
|
+
const file = routeAuditPath()
|
|
191
|
+
if (file === null) return
|
|
192
|
+
try {
|
|
193
|
+
// 审计目录是 assoc 根下的 namespace 子目录:首次写入前必须建好,否则 appendFileSync 会 ENOENT。
|
|
194
|
+
fs.mkdirSync(path.dirname(file), { recursive: true })
|
|
195
|
+
fs.appendFileSync(file, JSON.stringify(entry) + '\n', 'utf8')
|
|
196
|
+
} catch (e) { /* 审计失败绝不影响执行 */ }
|
|
197
|
+
}
|
|
198
|
+
const routeAuditReader = createAuditTailReader({ file: routeAuditPath, fallback: () => routeAuditLines })
|
|
199
|
+
const readRouteAudit = (taskId, limit) => routeAuditReader.read(taskId, limit)
|
|
200
|
+
const modelRouting = createModelRouting({
|
|
201
|
+
ctx,
|
|
202
|
+
llm,
|
|
203
|
+
agents: () => (typeof agents !== 'undefined' ? agents : undefined),
|
|
204
|
+
// 官方选择意图(`model/selection`)用真实会话日志判定:同值的人工重选也必须被尊重
|
|
205
|
+
readSessionEvents: (sessionId) => readSessionEvents(sessionId),
|
|
206
|
+
// 默认选择只读快照(绝不用它写任何配置)
|
|
207
|
+
selectionOf: () => {
|
|
208
|
+
const base = pickModelOptions()
|
|
209
|
+
return typeof base.provider === 'string' && typeof base.model === 'string' ? { provider: base.provider, model: base.model } : null
|
|
210
|
+
},
|
|
211
|
+
baseSelection: (() => { const base = pickModelOptions(); return typeof base.provider === 'string' && typeof base.model === 'string' ? { provider: base.provider, model: base.model } : null })(),
|
|
212
|
+
audit: appendRouteAudit,
|
|
213
|
+
limits: DEFAULT_ROUTE_LIMITS,
|
|
214
|
+
})
|
|
215
|
+
const uninstallModelRouting = modelRouting.install()
|
|
216
|
+
// 插件卸载 → hook 与绑定全部失效(在途异步返回不得复活策略)
|
|
217
|
+
ctx.effect(() => () => { try { uninstallModelRouting() } catch (e) { /* ignore */ } })
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* 把调用方的模型输入解析成任务级策略(第四次交付契约):
|
|
221
|
+
* - 缺省/`auto` → auto(默认);`fast`/`pro` → 兼容偏好;`default` → inherit;
|
|
222
|
+
* - 给了精确 model(非别名)→ exact(provider 可选,缺省时同名歧义会被拒绝并给候选);
|
|
223
|
+
* - `modelFallback` 显式 false 即关闭;精确指定默认关闭(调用方显式 true 才允许降级)。
|
|
224
|
+
*/
|
|
225
|
+
const parseRoutePolicy = (parsed) => {
|
|
226
|
+
const rawModel = typeof parsed.model === 'string' && parsed.model.trim() !== '' ? parsed.model.trim() : (typeof parsed.modelAlias === 'string' ? parsed.modelAlias.trim() : '')
|
|
227
|
+
const provider = typeof parsed.provider === 'string' && parsed.provider.trim() !== '' ? parsed.provider.trim() : undefined
|
|
228
|
+
const explicitFallback = parsed.modelFallback === undefined ? undefined : parsed.modelFallback === true
|
|
229
|
+
let mode = 'auto'
|
|
230
|
+
let model
|
|
231
|
+
if (rawModel === 'fast' || rawModel === 'pro') mode = rawModel
|
|
232
|
+
else if (rawModel === 'default' || rawModel === '') mode = rawModel === 'default' ? 'inherit' : 'auto'
|
|
233
|
+
else if (rawModel === 'auto') mode = 'auto'
|
|
234
|
+
else { mode = 'exact'; model = rawModel }
|
|
235
|
+
if (mode === 'inherit') return { mode, provider: undefined, model: undefined, allowFallback: explicitFallback === true, taskKind: parsed.taskKind }
|
|
236
|
+
return {
|
|
237
|
+
mode,
|
|
238
|
+
provider: mode === 'exact' ? provider : undefined,
|
|
239
|
+
model: mode === 'exact' ? model : undefined,
|
|
240
|
+
// auto/fast/pro 默认允许切换;精确指定默认固定,除非调用方显式开启
|
|
241
|
+
allowFallback: explicitFallback === undefined ? (mode !== 'exact') : explicitFallback,
|
|
242
|
+
taskKind: typeof parsed.taskKind === 'string' ? parsed.taskKind : 'code',
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
154
246
|
// ---- model alias resolution ----
|
|
155
247
|
const modelAliasCache = new Map()
|
|
156
248
|
let knownModels = []
|
|
@@ -218,6 +310,15 @@ export async function apply(ctx) {
|
|
|
218
310
|
const projectStoredTask = (record) => {
|
|
219
311
|
const liveTask = tasks.find((t) => t.taskId === record.taskId)
|
|
220
312
|
const terminal = record.status === 'done' || record.status === 'error' || record.status === 'cancelled'
|
|
313
|
+
// 投递控制(第四轮 A):父关系/投递模式/回执证据是**不可变事实**,所有投影都必须带上。
|
|
314
|
+
const deliveryFields = {
|
|
315
|
+
...(record.deliver === undefined ? {} : { deliver: record.deliver }),
|
|
316
|
+
...(record.parentTaskId === undefined ? {} : { parentTaskId: record.parentTaskId }),
|
|
317
|
+
...(record.receiptState === undefined ? {} : { receiptState: record.receiptState }),
|
|
318
|
+
...(record.observedTurn === undefined ? {} : { observedTurn: record.observedTurn }),
|
|
319
|
+
...(record.resultScope === undefined ? {} : { resultScope: record.resultScope }),
|
|
320
|
+
...(record.requestSeq === undefined ? {} : { requestSeq: record.requestSeq }),
|
|
321
|
+
}
|
|
221
322
|
const base = {
|
|
222
323
|
taskId: record.taskId,
|
|
223
324
|
originThreadId: record.originThreadId,
|
|
@@ -228,16 +329,46 @@ export async function apply(ctx) {
|
|
|
228
329
|
live: liveTask !== undefined,
|
|
229
330
|
...(record.requestId === undefined ? {} : { requestId: record.requestId }),
|
|
230
331
|
...(record.dshTurn === undefined ? {} : { dshTurn: record.dshTurn }),
|
|
332
|
+
...deliveryFields,
|
|
231
333
|
}
|
|
232
334
|
if (liveTask !== undefined) {
|
|
233
335
|
return {
|
|
234
336
|
...base,
|
|
235
337
|
status: liveTask.status,
|
|
338
|
+
deliver: liveTask.deliver,
|
|
339
|
+
...(liveTask.receiptState === undefined ? {} : { receiptState: liveTask.receiptState }),
|
|
340
|
+
...(liveTask.observedTurn === undefined ? {} : { observedTurn: liveTask.observedTurn }),
|
|
341
|
+
...(liveTask.parentTaskId === undefined ? {} : { parentTaskId: liveTask.parentTaskId }),
|
|
342
|
+
...(liveTask.resultScope === undefined ? {} : { resultScope: liveTask.resultScope }),
|
|
236
343
|
...(liveTask.result === undefined ? {} : { result: liveTask.result }),
|
|
237
344
|
...(liveTask.error === undefined ? {} : { error: liveTask.error }),
|
|
238
345
|
...(liveTask.delivered === undefined ? {} : { delivered: liveTask.delivered }),
|
|
239
346
|
}
|
|
240
347
|
}
|
|
348
|
+
// 回执的"自身已被观察到"是**独立于父任务执行**的持久事实:重启后不能降级成 unknown。
|
|
349
|
+
if (record.deliver === 'steer' && record.receiptState === 'observed') {
|
|
350
|
+
return {
|
|
351
|
+
...base,
|
|
352
|
+
status: 'receipt-observed',
|
|
353
|
+
receiptState: 'observed',
|
|
354
|
+
resultScope: record.resultScope === undefined ? 'parent-task' : record.resultScope,
|
|
355
|
+
...(record.observedTurn === undefined ? {} : { observedTurn: record.observedTurn }),
|
|
356
|
+
...(record.delivered === undefined ? {} : { delivered: record.delivered }),
|
|
357
|
+
note: '补充回执已被官方回合接收并观察到(自身证据);父任务的执行结果请查父 task(本回执不承载父结果)。',
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
if (record.deliver === 'steer' && record.receiptState !== undefined && record.receiptState !== 'observed') {
|
|
361
|
+
// 回执已接收但未观察到/已偏离:同样是持久事实,不重发。
|
|
362
|
+
return {
|
|
363
|
+
...base,
|
|
364
|
+
status: record.status === 'receipt-deviated' ? 'receipt-deviated' : (record.status === 'receipt-unconfirmed' ? 'receipt-unconfirmed' : 'receipt-pending'),
|
|
365
|
+
receiptState: record.receiptState,
|
|
366
|
+
resultScope: record.resultScope === undefined ? 'parent-task' : record.resultScope,
|
|
367
|
+
...(record.observedTurn === undefined ? {} : { observedTurn: record.observedTurn }),
|
|
368
|
+
resend: false,
|
|
369
|
+
note: '回执已提交但未(或未按目标回合)观察到自身 rpcId 落进父回合;**不要重发**,父任务结果请查父 task。',
|
|
370
|
+
}
|
|
371
|
+
}
|
|
241
372
|
if (terminal) {
|
|
242
373
|
return {
|
|
243
374
|
...base,
|
|
@@ -308,11 +439,69 @@ export async function apply(ctx) {
|
|
|
308
439
|
}
|
|
309
440
|
|
|
310
441
|
/** 能力上报:客户端在**派活前**只读核验本网关是否真的支持持久来源绑定(旧网关没有这一块)。 */
|
|
311
|
-
const capabilityReport = () =>
|
|
312
|
-
originBinding
|
|
442
|
+
const capabilityReport = () => {
|
|
443
|
+
const originBinding = associations === null
|
|
313
444
|
? { supported: false, version: 0, reason: 'association-store-unavailable' }
|
|
314
|
-
: { supported: true, version: 1, persistence: 'append-only-jsonl', namespace: associations.namespace }
|
|
315
|
-
|
|
445
|
+
: { supported: true, version: 1, persistence: 'append-only-jsonl', namespace: associations.namespace }
|
|
446
|
+
const key = readControlKey()
|
|
447
|
+
return {
|
|
448
|
+
originBinding,
|
|
449
|
+
approvals: approvalStore === null
|
|
450
|
+
? { supported: false, enabled: false, reason: 'approval-store-unavailable', canWakeIdle: false }
|
|
451
|
+
: {
|
|
452
|
+
// supported=官方接缝可用;enabled=key 在位(缺 key 整条能力默认关闭)
|
|
453
|
+
supported: true,
|
|
454
|
+
enabled: key !== null,
|
|
455
|
+
...(key === null ? {} : { keyFp: keyFingerprint8(key) }),
|
|
456
|
+
requiresExplicitEnable: true, // 委托必须按任务显式开启,提示/文本不构成授权
|
|
457
|
+
requiresKeyHeader: 'X-Dsh-Control', // 私有 raw key(服务端只存域分离单向摘要)
|
|
458
|
+
activeOnly: true, // 只在 Codex 活跃循环内生效
|
|
459
|
+
workerSupported: false, // worker child 身份未证明 → 委托派活会被拒绝
|
|
460
|
+
ptcSupported: false, // PTC callId/root 未证明 → 嵌套调用直接 next()
|
|
461
|
+
sessionTargetSupported: true, // 已证明:req.agent.session.id 严格绑定
|
|
462
|
+
canWakeIdle: false,
|
|
463
|
+
},
|
|
464
|
+
// 动态模型路由(M2):真实 scope/能力声明;新客户端用新参数前必须只读预检这一段。
|
|
465
|
+
modelRouting: {
|
|
466
|
+
supported: true,
|
|
467
|
+
version: 1,
|
|
468
|
+
defaultMode: 'auto',
|
|
469
|
+
modes: ['auto', 'fast', 'pro', 'inherit', 'exact'],
|
|
470
|
+
providerModelPairIdentity: true,
|
|
471
|
+
catalogDiscovery: true,
|
|
472
|
+
metadataHasNoQualityOrPriceScore: true,
|
|
473
|
+
firstRequestBinding: 'agent/inbox/claimed',
|
|
474
|
+
runtimeSwitch: {
|
|
475
|
+
supported: true,
|
|
476
|
+
sameTurnStep: true,
|
|
477
|
+
via: 'agent/request+agent/request-error',
|
|
478
|
+
boundedCandidates: DEFAULT_ROUTE_LIMITS.maxCandidates,
|
|
479
|
+
},
|
|
480
|
+
workerFirstRequest: 'spec.childId+live-registry-equality',
|
|
481
|
+
// 诚实:worker 首请求绑定依赖 provider 真正接受 spec.childId 且 claim 报出同一 agent id;
|
|
482
|
+
// 只有在一次真实 claim 命中后才有证据,因此默认声明**未证明**。
|
|
483
|
+
workerFirstRequestProven: false,
|
|
484
|
+
// worker 后续任务沿用**同一个 durable child**(warm 复用 / cold 官方恢复),入口是官方
|
|
485
|
+
// `subagents.prompt({mode:'continuable',delivery:'queue',requestId})`;不存在 `subagents.followup`。
|
|
486
|
+
workerContinuation: 'subagents.prompt(continuable,queue)',
|
|
487
|
+
workerContinuationProven: false,
|
|
488
|
+
maxCandidates: DEFAULT_ROUTE_LIMITS.maxCandidates,
|
|
489
|
+
limits: { ...DEFAULT_ROUTE_LIMITS },
|
|
490
|
+
structuredCodesOnly: true,
|
|
491
|
+
note: '只枚举已注册配置;目录可见不等于凭据/额度/远端健康已验证,也没有质量/价格/速度评分。',
|
|
492
|
+
},
|
|
493
|
+
// 投递控制(第四轮):queue 走官方原生 FIFO(一次 claim 一条 next-turn);steer 是父任务的补充回执。
|
|
494
|
+
// 新字段(deliver=steer / parentTaskId / expectedTurn)遇到没有这段能力的旧网关 → 客户端零派活拒绝。
|
|
495
|
+
delivery: {
|
|
496
|
+
supported: true,
|
|
497
|
+
defaultMode: 'queue',
|
|
498
|
+
modes: ['queue', 'steer'],
|
|
499
|
+
queue: { supported: true, multiTaskPerSession: true, nativeFifo: true, oneTurnPerClaim: true, independentRequestId: true },
|
|
500
|
+
steer: { supported: true, requiresParentTaskId: true, requiresOrigin: true, requiresControlAuth: true, sameTurnAsParent: true, resultScope: 'parent-task', boundedReceiptMs: RECEIPT_OBSERVE_TIMEOUT_MS },
|
|
501
|
+
maxConcurrentPerSession: null, // 不人为限制;由官方队列顺序决定
|
|
502
|
+
},
|
|
503
|
+
}
|
|
504
|
+
}
|
|
316
505
|
|
|
317
506
|
/**
|
|
318
507
|
* 执行前登记关联。失败即返回错误,调用方必须放弃启动 DSH 执行——
|
|
@@ -324,7 +513,17 @@ export async function apply(ctx) {
|
|
|
324
513
|
return { ok: false, reason: 'association-store-unavailable', message: 'the task association store could not be opened in this gateway; refusing to dispatch an origin-bound task' }
|
|
325
514
|
}
|
|
326
515
|
try {
|
|
327
|
-
associations.bind({
|
|
516
|
+
associations.bind({
|
|
517
|
+
originThreadId, taskId: task.taskId, kind: task.kind, target: task.target,
|
|
518
|
+
// 不可变父关系 / 投递模式:**执行前**就落盘(第四轮 A)
|
|
519
|
+
deliver: task.deliver,
|
|
520
|
+
parentTaskId: task.parentTaskId,
|
|
521
|
+
resultScope: task.resultScope,
|
|
522
|
+
requestSeq: task.requestSeq,
|
|
523
|
+
receiptState: task.receiptState,
|
|
524
|
+
route: task.route,
|
|
525
|
+
routePolicy: task.routePolicy,
|
|
526
|
+
})
|
|
328
527
|
task.originThreadId = originThreadId
|
|
329
528
|
return { ok: true }
|
|
330
529
|
} catch (e) {
|
|
@@ -339,14 +538,31 @@ export async function apply(ctx) {
|
|
|
339
538
|
associations.update(task.taskId, {
|
|
340
539
|
status: task.status,
|
|
341
540
|
delivered: task.delivered,
|
|
342
|
-
result
|
|
343
|
-
|
|
541
|
+
// 回执绝不允许把父任务的旧 result/error 留在落盘记录里(显式 null = 清除)。
|
|
542
|
+
result: task.deliver === 'steer' ? null : task.result,
|
|
543
|
+
error: task.deliver === 'steer' ? null : task.error,
|
|
344
544
|
reason: task.reason,
|
|
345
545
|
stopReason: task.stopReason,
|
|
346
546
|
requestId: task.requestId,
|
|
347
547
|
dshTurn: task.dshTurn,
|
|
548
|
+
// 投递控制(第四轮 A):父关系/投递模式/回执证据必须随每次落盘一起写,否则重启后全丢。
|
|
549
|
+
deliver: task.deliver,
|
|
550
|
+
parentTaskId: task.parentTaskId,
|
|
551
|
+
receiptState: task.receiptState,
|
|
552
|
+
observedTurn: task.observedTurn,
|
|
553
|
+
resultScope: task.resultScope,
|
|
554
|
+
requestSeq: task.requestSeq,
|
|
555
|
+
// 模型路由(第一版验收修正):选择与策略随任务落盘,**重启后同 origin/task 仍可取回**
|
|
556
|
+
route: task.route,
|
|
557
|
+
routePolicy: task.routePolicy,
|
|
558
|
+
...(task.routeStopReason === undefined ? {} : { routeStopReason: task.routeStopReason }),
|
|
348
559
|
})
|
|
349
560
|
delete task.associationError
|
|
561
|
+
// 模型路由收口(M2):终态**一次**收口(清绑定、落审计);在途异步返回不得复活策略。
|
|
562
|
+
if ((task.status === 'done' || task.status === 'error' || task.status === 'cancelled') && task.routeFinished !== true) {
|
|
563
|
+
task.routeFinished = true
|
|
564
|
+
try { modelRouting.finishRoute({ taskId: task.taskId, status: task.status, reason: task.reason }) } catch (e) { /* ignore */ }
|
|
565
|
+
}
|
|
350
566
|
} catch (e) {
|
|
351
567
|
// 明确暴露,绝不静默:任务本身的结果不受影响,但调用方能在状态里看到关联写入失败。
|
|
352
568
|
task.associationError = errText(e)
|
|
@@ -354,6 +570,317 @@ export async function apply(ctx) {
|
|
|
354
570
|
}
|
|
355
571
|
}
|
|
356
572
|
|
|
573
|
+
// ================================================================ 审批委托(M1/M2,独立验收修正版)
|
|
574
|
+
|
|
575
|
+
/** 审批存储:打开失败即能力关闭(capabilities.approvals.enabled=false,审批路由全 401)。 */
|
|
576
|
+
const approvalStore = (() => {
|
|
577
|
+
try {
|
|
578
|
+
return openApprovalStore({ root: process.env.DSH_APPROVAL_DIR, lockTimeoutMs: 10_000 })
|
|
579
|
+
} catch (e) {
|
|
580
|
+
console.error('[dsh-bridge] approval store unavailable:', errText(e).slice(0, 200))
|
|
581
|
+
return null
|
|
582
|
+
}
|
|
583
|
+
})()
|
|
584
|
+
|
|
585
|
+
/**
|
|
586
|
+
* 每次请求重读 key(支持轮换)。返回的是**域分离单向摘要**(`digest:…`),
|
|
587
|
+
* 只用于 grant 绑定与审计;它**不能**用于认证(修正 C)。
|
|
588
|
+
*/
|
|
589
|
+
const approvalCredDigest = () => {
|
|
590
|
+
const key = readControlKey()
|
|
591
|
+
return key === null ? null : controlCredDigest(key)
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
/** 审批类路由鉴权:`X-Dsh-Control` = 私有 raw key,与服务端读到的 key 等值比较。失败零副作用、不回显头。 */
|
|
595
|
+
const approvalAuth = (req) => {
|
|
596
|
+
if (approvalStore === null) return { ok: false, reason: 'approval-store-unavailable' }
|
|
597
|
+
const key = readControlKey()
|
|
598
|
+
if (key === null) return { ok: false, reason: APPROVAL_REASONS.NO_KEY }
|
|
599
|
+
const header = req.headers !== undefined && req.headers !== null ? req.headers['x-dsh-control'] : undefined
|
|
600
|
+
if (!verifyControlHeader(header, key)) return { ok: false, reason: APPROVAL_REASONS.BAD_HEADER }
|
|
601
|
+
return { ok: true, credDigest: controlCredDigest(key) }
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
/**
|
|
605
|
+
* **原生入口**(宿主进程内)的鉴权(第二次验收修正 1):没有 HTTP 头,但绝不能因此变成"无鉴权"。
|
|
606
|
+
* 判据与 HTTP 完全一致——只读**本机已有**的可信控制 key(不接收、不回显、不新增任何凭据/token,
|
|
607
|
+
* 也绝不让模型提供 raw key):缺 key → no-key;key 存在但格式非法 → bad-header;两者都**零派活**。
|
|
608
|
+
*/
|
|
609
|
+
const nativeControlAuth = () => {
|
|
610
|
+
if (approvalStore === null) return { ok: false, reason: 'approval-store-unavailable' }
|
|
611
|
+
const key = readControlKey()
|
|
612
|
+
if (key === null) {
|
|
613
|
+
// 区分"根本没有 key 文件"(能力关闭)与"文件在但格式非法"(坏 key),两者都零派活。
|
|
614
|
+
let raw = null
|
|
615
|
+
try {
|
|
616
|
+
const file = controlKeyPath()
|
|
617
|
+
if (typeof file === 'string' && file !== '') raw = fs.readFileSync(file, 'utf8')
|
|
618
|
+
} catch (e) { raw = null }
|
|
619
|
+
if (raw !== null && typeof raw === 'string' && raw.trim() !== '') return { ok: false, reason: APPROVAL_REASONS.BAD_HEADER }
|
|
620
|
+
return { ok: false, reason: APPROVAL_REASONS.NO_KEY }
|
|
621
|
+
}
|
|
622
|
+
// 复用与 HTTP 相同的格式/等值校验(内部调用把 key 同时当"服务端 key"和"来头值")。
|
|
623
|
+
if (!verifyControlHeader(key, key)) return { ok: false, reason: APPROVAL_REASONS.BAD_HEADER }
|
|
624
|
+
return { ok: true, credDigest: controlCredDigest(key) }
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
/** PTC 嵌套子调用判别器(dsh-tools:1298 `<parentCallId>:ptc:<n>`):未证明 → 不接手。 */
|
|
628
|
+
const isPtcNestedCall = (callId) => typeof callId === 'string' && /:ptc:\d+$/.test(callId)
|
|
629
|
+
|
|
630
|
+
/** 读该会话的日志事件(失败/不可用 → null)。 */
|
|
631
|
+
const readSessionEvents = (sessionId) => observations.evidence(sessionId)
|
|
632
|
+
|
|
633
|
+
/**
|
|
634
|
+
* turn/step 证明(修正 A/D):必须来自日志里**同一个 callId 的 `tool/call`**,且 turn 与 step **都是整数**。
|
|
635
|
+
* 找不到、或只有 turn 没有 step → null(调用方 next())。**未知 turn/step 绝不能作为可批准默认值。**
|
|
636
|
+
*/
|
|
637
|
+
const resolveTurnStepFrom = (events, callId) => {
|
|
638
|
+
if (!Array.isArray(events)) return null
|
|
639
|
+
for (let i = events.length - 1; i >= 0; i--) {
|
|
640
|
+
const event = events[i]
|
|
641
|
+
if (event === null || typeof event !== 'object') continue
|
|
642
|
+
if (event.type !== 'tool/call') continue
|
|
643
|
+
const data = event.data !== null && typeof event.data === 'object' ? event.data : {}
|
|
644
|
+
if (data.callId !== callId) continue
|
|
645
|
+
if (!Number.isInteger(data.turn) || !Number.isInteger(data.step)) return null
|
|
646
|
+
return { turn: data.turn, step: data.step, turnSource: 'log-tool-call', stepSource: 'log' }
|
|
647
|
+
}
|
|
648
|
+
return null
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
/** 该 session+task+origin 上「未撤销、未过绝对 TTL、凭据摘要匹配」的 grant(租约由等待/决定路径校验)。 */
|
|
652
|
+
const grantForTask = ({ sessionId, taskId, originThreadId, credDigest }) => {
|
|
653
|
+
if (approvalStore === null || credDigest === null) return null
|
|
654
|
+
const now = Date.now()
|
|
655
|
+
for (const g of approvalStore.listGrants({ sessionId, taskId, originThreadId, activeOnly: true })) {
|
|
656
|
+
if (now > g.expiresAt) continue
|
|
657
|
+
const check = approvalStore.validateGrant({ grantId: g.grantId, taskId, originThreadId, sessionId, credDigest }, { ignoreLease: true })
|
|
658
|
+
if (check.ok === true) return check.grant
|
|
659
|
+
}
|
|
660
|
+
return null
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
/**
|
|
664
|
+
* **认证活跃委托窗口**(第二轮修正 blocker 3):只有"仍有效(未撤销/未过绝对 TTL)、凭据摘要匹配、
|
|
665
|
+
* 且严格属于该 task/origin/session"的 grant 才能延长观察窗口——这就是"认证活跃"。
|
|
666
|
+
* 失联(本机 key 没了)→ approvalCredDigest() 为 null → 退回旧预算;撤销/到期 → 同样退回。
|
|
667
|
+
* 注意:不看 lease(慢模型在第一个 ask 之前不该被初始租约卡死),只看 grant 的绝对有效期。
|
|
668
|
+
*/
|
|
669
|
+
const activeDelegatedGrantFor = (task) => {
|
|
670
|
+
if (approvalStore === null || task === undefined || task === null) return null
|
|
671
|
+
if (task.target === undefined || task.target.kind !== 'session') return null
|
|
672
|
+
if (typeof task.originThreadId !== 'string' || task.originThreadId === '') return null
|
|
673
|
+
const credDigest = approvalCredDigest()
|
|
674
|
+
if (credDigest === null) return null
|
|
675
|
+
const grant = task.grantId !== undefined
|
|
676
|
+
? approvalStore.validateGrant({ grantId: task.grantId, taskId: task.taskId, originThreadId: task.originThreadId, sessionId: task.target.sessionId, credDigest }, { ignoreLease: true })
|
|
677
|
+
: { ok: false }
|
|
678
|
+
const resolved = grant.ok === true
|
|
679
|
+
? grant.grant
|
|
680
|
+
: grantForTask({ sessionId: task.target.sessionId, taskId: task.taskId, originThreadId: task.originThreadId, credDigest })
|
|
681
|
+
if (resolved === undefined || resolved === null || resolved === false) return null
|
|
682
|
+
if (Date.now() > resolved.expiresAt) return null
|
|
683
|
+
return { grantId: resolved.grantId, until: resolved.expiresAt }
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
/** 观察预算:无委托=旧语义;有认证活跃委托=以 grant 绝对到期为界(另加宽限与硬上限,仍是有界的)。 */
|
|
687
|
+
const observationBudgetMs = (record, delegated) => {
|
|
688
|
+
if (delegated === null) return SESSION_TOTAL_TIMEOUT_MS
|
|
689
|
+
const untilGrant = Math.max(0, delegated.until - record.startedAt) + DELEGATED_OBSERVATION_GRACE_MS
|
|
690
|
+
return Math.min(Math.max(SESSION_TOTAL_TIMEOUT_MS, untilGrant), MAX_DELEGATED_OBSERVATION_MS)
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
/**
|
|
694
|
+
* 该任务当前的 grant 视图(**非秘密**:id/状态/期限),用于 status 响应与客户端自适应续租。
|
|
695
|
+
* 只回属于本任务/来源/会话的 grant;raw key 与摘要绝不出现。
|
|
696
|
+
*/
|
|
697
|
+
const delegatedGrantView = (task) => {
|
|
698
|
+
if (approvalStore === null || task === undefined || task === null) return null
|
|
699
|
+
if (typeof task.grantId !== 'string' || task.grantId === '') return null
|
|
700
|
+
const grant = approvalStore.getGrant(task.grantId)
|
|
701
|
+
if (grant === undefined || grant === null) return null
|
|
702
|
+
return { grantId: grant.grantId, status: grant.status, expiresAt: grant.expiresAt, leaseUntil: grant.leaseUntil }
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
/** 给**该 grant 的 task** 标 needs-approval(修正 D:不再按 session 找任意活任务)。 */
|
|
706
|
+
const markNeedsApproval = (task, pending) => {
|
|
707
|
+
if (task === undefined || task === null) return undefined
|
|
708
|
+
task.status = 'needs-approval'
|
|
709
|
+
const keys = Array.isArray(task.approvalKeys) ? task.approvalKeys.slice() : []
|
|
710
|
+
if (!keys.includes(pending.approvalKey)) keys.push(pending.approvalKey)
|
|
711
|
+
task.approvalKeys = keys
|
|
712
|
+
task.approvalPendingCount = approvalStore.liveCountForTask(task.taskId)
|
|
713
|
+
pushTask(task.taskId, 'approval', {
|
|
714
|
+
approvalKey: pending.approvalKey, toolName: pending.toolName, taskId: task.taskId,
|
|
715
|
+
target: task.target, approvalState: APPROVAL_STATES.PENDING,
|
|
716
|
+
})
|
|
717
|
+
return task
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
/** 决定返回后:**还有别的未决就保持 needs-approval**(修正 E),最后一条解决才回 running。 */
|
|
721
|
+
const restoreAfterDecision = (task) => {
|
|
722
|
+
if (task === undefined || task === null || approvalStore === null) return
|
|
723
|
+
if (task.status !== 'needs-approval') return
|
|
724
|
+
const live = approvalStore.liveCountForTask(task.taskId)
|
|
725
|
+
task.approvalPendingCount = live
|
|
726
|
+
if (live > 0) return
|
|
727
|
+
task.status = 'running'
|
|
728
|
+
pushTask(task.taskId, 'approval-done', { taskId: task.taskId, approvalPendingCount: 0 })
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
/** 官方词汇映射:只有官方 decided 的 allowed-once 才算批准。 */
|
|
732
|
+
const outcomeOfPending = (pending) => {
|
|
733
|
+
switch (pending.state) {
|
|
734
|
+
case APPROVAL_STATES.CONFIRMED: return 'allowed-once'
|
|
735
|
+
case APPROVAL_STATES.CANCELLED: return 'cancelled'
|
|
736
|
+
case APPROVAL_STATES.REJECTED: return 'rejected'
|
|
737
|
+
case APPROVAL_STATES.SUBMITTED: return pending.outcome // 已提交但官方 decided 尚未观测到
|
|
738
|
+
case APPROVAL_STATES.DECISION_UNKNOWN: return undefined
|
|
739
|
+
default: return undefined // expired/context-changed/superseded/ambiguous → 交人工
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
/**
|
|
744
|
+
* 等待决定(修正 E):按**有效租约/绝对 TTL 与 req.signal** 保持(不再有 25s 硬上限;
|
|
745
|
+
* 25s 只是"单次 HTTP/MCP 调用"的有界等待,不能套在真实 SDK callback 上)。
|
|
746
|
+
* 认证续租/续期会移动 lease/expiry;撤销/过期/上下文变化/信号中止立刻收口。
|
|
747
|
+
* **退出前一定关闭可答状态**,交还 next() 之后迟到的批准不可能再被接受。
|
|
748
|
+
*/
|
|
749
|
+
const waitForDecision = async (pending, req) => {
|
|
750
|
+
let lastIdentityCheck = 0
|
|
751
|
+
for (;;) {
|
|
752
|
+
const current = approvalStore.getPending(pending.approvalKey)
|
|
753
|
+
if (current === undefined) return undefined
|
|
754
|
+
if (current.state !== APPROVAL_STATES.PENDING) return outcomeOfPending(current)
|
|
755
|
+
const signal = req.signal
|
|
756
|
+
if (signal !== undefined && signal !== null && signal.aborted) {
|
|
757
|
+
approvalStore.closePending(pending.approvalKey, APPROVAL_STATES.CANCELLED, 'request signal aborted')
|
|
758
|
+
return 'cancelled'
|
|
759
|
+
}
|
|
760
|
+
const check = approvalStore.validateGrant({
|
|
761
|
+
grantId: pending.grantId, taskId: pending.taskId, originThreadId: pending.originThreadId,
|
|
762
|
+
sessionId: pending.sessionId, credDigest: approvalCredDigest(),
|
|
763
|
+
})
|
|
764
|
+
if (check.ok !== true) {
|
|
765
|
+
approvalStore.closePending(pending.approvalKey, APPROVAL_STATES.EXPIRED, 'grant no longer valid: ' + check.reason)
|
|
766
|
+
return undefined
|
|
767
|
+
}
|
|
768
|
+
// 完整执行身份(第二轮):coarse snapshotId + **日志里当前 turn/step**,任一变化 → 收口交还人工。
|
|
769
|
+
// 日志重读按 ~1s 节流(等待本身要保持轻量)。
|
|
770
|
+
if (Date.now() - lastIdentityCheck >= 1000) {
|
|
771
|
+
lastIdentityCheck = Date.now()
|
|
772
|
+
const live = approvalStore.getSnapshot(pending.sessionId, pending.callId)
|
|
773
|
+
if (live === undefined || live.snapshotId !== pending.snapshotId) {
|
|
774
|
+
approvalStore.closePending(pending.approvalKey, APPROVAL_STATES.CONTEXT_CHANGED, 'live execution snapshot changed while pending')
|
|
775
|
+
return undefined
|
|
776
|
+
}
|
|
777
|
+
const events = await readSessionEvents(pending.sessionId)
|
|
778
|
+
const currentSnapshot = approvalStore.getSnapshot(pending.sessionId, pending.callId)
|
|
779
|
+
const currentTurnStep = events === null ? null : resolveTurnStepFrom(events, pending.callId)
|
|
780
|
+
if (currentTurnStep === null) {
|
|
781
|
+
approvalStore.closePending(pending.approvalKey, APPROVAL_STATES.CONTEXT_CHANGED, 'current turn/step can no longer be proven for this call')
|
|
782
|
+
return undefined
|
|
783
|
+
}
|
|
784
|
+
if (currentSnapshot === undefined || approvalStore.executionIdentity(currentSnapshot, currentTurnStep) !== pending.executionId) {
|
|
785
|
+
approvalStore.closePending(pending.approvalKey, APPROVAL_STATES.CONTEXT_CHANGED, 'execution identity changed while pending (tool/root/parent/step/args)')
|
|
786
|
+
return undefined
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
await new Promise((resolve) => setTimeout(resolve, 50))
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
/**
|
|
794
|
+
* 正式 answerer:严格谓词不成立一律返回 undefined → 调用方立刻 `next()`(不吞人工/其它 answerer)。
|
|
795
|
+
* 内部异常同样走 next()——绝不 throw:官方 decide 会把 throw 归一成 unavailable,那会跳过人工链。
|
|
796
|
+
*/
|
|
797
|
+
const handleApprovalRequest = async (req) => {
|
|
798
|
+
if (approvalStore === null) return undefined
|
|
799
|
+
if (req === null || typeof req !== 'object') return undefined
|
|
800
|
+
if (typeof req.callId !== 'string' || req.callId === '') return undefined // 无 callId → next()(ACP 同款)
|
|
801
|
+
const session = req.agent !== undefined && req.agent !== null ? req.agent.session : undefined
|
|
802
|
+
const sessionId = session !== undefined && session !== null && typeof session.id === 'string' ? session.id : undefined
|
|
803
|
+
if (sessionId === undefined) return undefined
|
|
804
|
+
|
|
805
|
+
// 投递控制 D1:把日志喂给**该会话所有队列执行记录**(幂等,按 seq 去重),
|
|
806
|
+
// 然后由"当前开放回合"选出**唯一**执行 owner——steer 回执永不参与审批归属。
|
|
807
|
+
const events = await readSessionEvents(sessionId)
|
|
808
|
+
if (events === null) return undefined
|
|
809
|
+
const candidatesForTurn = []
|
|
810
|
+
for (const r of queueOwnerRecords(sessionId)) {
|
|
811
|
+
const ownerTask = taskById(r.taskId)
|
|
812
|
+
if (ownerTask === undefined || ownerTask.target === undefined || ownerTask.target.kind !== 'session') continue
|
|
813
|
+
if (typeof ownerTask.originThreadId !== 'string' || ownerTask.originThreadId === '') continue
|
|
814
|
+
if (r.tracker === undefined) continue
|
|
815
|
+
for (const event of events) consumeSessionEvent(r.tracker, event)
|
|
816
|
+
if (r.tracker.sawRequest === true && Number.isInteger(r.tracker.turn)) candidatesForTurn.push({ record: r, task: ownerTask })
|
|
817
|
+
}
|
|
818
|
+
// 同一 callId 的真实 turn/step 由日志证明;本轮 owner 必须**恰好一条**(歧义 → 交人工,绝不任选一条活 task)
|
|
819
|
+
const turnStep = resolveTurnStepFrom(events, req.callId)
|
|
820
|
+
if (turnStep === null) return undefined
|
|
821
|
+
// 首次验收修正 E:owner 必须是**当前真实开放**的那个 queue 任务回合。
|
|
822
|
+
// 只比对历史 tool/call 的 turn 不够——已结束的 A( turn1 ) 会靠旧授权接管 B( turn2 ) 的 ask。
|
|
823
|
+
const openTurn = currentOpenTurn(events)
|
|
824
|
+
if (openTurn === null || openTurn !== turnStep.turn) return undefined
|
|
825
|
+
const owners = candidatesForTurn.filter((c) => c.record.tracker.turn === turnStep.turn && c.record.tracker.end === undefined && c.record.settled !== true)
|
|
826
|
+
if (owners.length !== 1) return undefined
|
|
827
|
+
const record = owners[0].record
|
|
828
|
+
const task = owners[0].task
|
|
829
|
+
// 自己的回合必须仍然开放(tracker.end 未置位且日志里该 turn 没有 turn/end)
|
|
830
|
+
if (turnHasEnded(events, openTurn) === true) return undefined
|
|
831
|
+
const ownTurn = turnStep.turn
|
|
832
|
+
|
|
833
|
+
const credDigest = approvalCredDigest()
|
|
834
|
+
if (credDigest === null) return undefined
|
|
835
|
+
const grant = grantForTask({ sessionId, taskId: task.taskId, originThreadId: task.originThreadId, credDigest })
|
|
836
|
+
if (grant === null) return undefined // 未委托该 task → next()
|
|
837
|
+
|
|
838
|
+
const snapshot = approvalStore.getSnapshot(sessionId, req.callId)
|
|
839
|
+
if (snapshot === undefined) return undefined // 无真实参数快照 → next()
|
|
840
|
+
if (snapshot.name !== req.toolName) return undefined
|
|
841
|
+
if (snapshot.identityComplete !== true) return undefined
|
|
842
|
+
if (isPtcNestedCall(req.callId)) return undefined // PTC 嵌套(未证明)→ next()
|
|
843
|
+
if (typeof snapshot.rootCallId === 'string' && snapshot.rootCallId !== req.callId) return undefined // 父子混淆 → next()
|
|
844
|
+
if (snapshot.parentKey !== null && snapshot.parentKey !== undefined) return undefined // 存在父执行(嵌套未证明)→ next()
|
|
845
|
+
// 登记官方 asked 身份(不依赖 sweep 先跑过)
|
|
846
|
+
observeApprovalEvents(sessionId, events)
|
|
847
|
+
if (approvalStore.annotateSnapshot(sessionId, req.callId, turnStep) === null) return undefined
|
|
848
|
+
const fresh = approvalStore.getSnapshot(sessionId, req.callId)
|
|
849
|
+
|
|
850
|
+
// 官方身份(UUID)必须**唯一认领**:歧义/未观测 → 交人工,绝不猜、绝不按 callId 合并(修正 B)
|
|
851
|
+
const candidates = approvalStore.unclaimedAskedFor(sessionId, req.callId, req.toolName)
|
|
852
|
+
if (candidates.length !== 1) return undefined
|
|
853
|
+
|
|
854
|
+
const pending = approvalStore.openPending({
|
|
855
|
+
execution: {
|
|
856
|
+
snapshotId: fresh.snapshotId, sessionId, callId: req.callId, toolName: req.toolName,
|
|
857
|
+
rootCallId: fresh.rootCallId, parentKey: fresh.parentKey, cwd: fresh.cwd, cwdSource: fresh.cwdSource,
|
|
858
|
+
args: fresh.args,
|
|
859
|
+
turn: fresh.turn, step: fresh.step, turnSource: fresh.turnSource, stepSource: fresh.stepSource,
|
|
860
|
+
},
|
|
861
|
+
grant,
|
|
862
|
+
reason: req.reason,
|
|
863
|
+
displayReason: req.displayReason,
|
|
864
|
+
askSeq: approvalStore.askCountFor(sessionId, req.callId) + 1,
|
|
865
|
+
})
|
|
866
|
+
// 身份不完整(未知 cwd/父身份等)→ 明确拒绝接手(绝不假装完整);关键:**不认领官方身份**
|
|
867
|
+
if (pending === null) return undefined
|
|
868
|
+
const claimed = approvalStore.claimOfficialAsk(pending.approvalKey, candidates[0].officialId)
|
|
869
|
+
if (claimed.ok !== true) {
|
|
870
|
+
approvalStore.closePending(pending.approvalKey, APPROVAL_STATES.AMBIGUOUS, 'official request could not be uniquely claimed')
|
|
871
|
+
return undefined
|
|
872
|
+
}
|
|
873
|
+
const marked = markNeedsApproval(task, pending)
|
|
874
|
+
const outcome = await waitForDecision(pending, req)
|
|
875
|
+
if (outcome === undefined) {
|
|
876
|
+
// 交还人工前先关闭可答状态(迟到批准不可能被接受)
|
|
877
|
+
approvalStore.closePending(pending.approvalKey, APPROVAL_STATES.SUPERSEDED, 'handed back to the human chain')
|
|
878
|
+
return undefined
|
|
879
|
+
}
|
|
880
|
+
restoreAfterDecision(marked)
|
|
881
|
+
return outcome
|
|
882
|
+
}
|
|
883
|
+
|
|
357
884
|
const broadcast = (msg) => {
|
|
358
885
|
let line
|
|
359
886
|
try { line = JSON.stringify(msg) + '\n' } catch (e) { return }
|
|
@@ -458,6 +985,34 @@ export async function apply(ctx) {
|
|
|
458
985
|
'不要修改任何文件。没有新请求时不要主动输出。',
|
|
459
986
|
].join('')
|
|
460
987
|
|
|
988
|
+
/** 官方 continuable 语义:父/子 **session id** 精确、queue 投递、本次 taskId 作为 requestId(首请求归属靠它)。
|
|
989
|
+
* `subagents.prompt` 内部用 `agents.get(parentSessionId)` 找 **live parent Agent** —— 父这一侧是 Agent id。 */
|
|
990
|
+
const parentSessionIdOf = (agent) => (agent !== null && agent !== undefined && typeof agent.id === 'string' && agent.id !== '' ? agent.id : undefined)
|
|
991
|
+
|
|
992
|
+
/**
|
|
993
|
+
* 继续同一个 durable child(**warm**:live 复用 / **cold**:已自然释放后官方恢复)。
|
|
994
|
+
* 官方 `subagents.prompt({requestId,parentSessionId,childSessionId,mode:'continuable',delivery:'queue',content},signal)`
|
|
995
|
+
* 是唯一正确入口(本机 44 个官方包里**没有** `subagents.followup`);失败时如实报错,绝不 one-shot 另建 child 冒充继续。
|
|
996
|
+
*/
|
|
997
|
+
const promptChild = async (bucket, parent, requestId, text) => {
|
|
998
|
+
if (subagents === undefined || subagents === null || typeof subagents.prompt !== 'function') {
|
|
999
|
+
throw Object.assign(new Error('this host does not expose subagents.prompt (continuable)'), { code: 'WORKER_CONTINUE_UNAVAILABLE' })
|
|
1000
|
+
}
|
|
1001
|
+
const parentSessionId = parentSessionIdOf(parent)
|
|
1002
|
+
const childSessionId = typeof bucket.childSessionId === 'string' && bucket.childSessionId !== '' ? bucket.childSessionId : bucket.childId
|
|
1003
|
+
if (typeof parentSessionId !== 'string' || typeof childSessionId !== 'string' || childSessionId === '') {
|
|
1004
|
+
throw Object.assign(new Error('continuable prompt needs exact parentSessionId and childSessionId'), { code: 'WORKER_CONTINUE_INVALID' })
|
|
1005
|
+
}
|
|
1006
|
+
return subagents.prompt({
|
|
1007
|
+
requestId,
|
|
1008
|
+
parentSessionId,
|
|
1009
|
+
childSessionId,
|
|
1010
|
+
mode: 'continuable',
|
|
1011
|
+
delivery: 'queue',
|
|
1012
|
+
content: [{ type: 'text', text }],
|
|
1013
|
+
}, signalOf())
|
|
1014
|
+
}
|
|
1015
|
+
|
|
461
1016
|
const startChildWith = async (bucket, firstInstruction, brief) => {
|
|
462
1017
|
const parent = await ensureOwner(bucket)
|
|
463
1018
|
const provider = pickProvider()
|
|
@@ -469,71 +1024,106 @@ export async function apply(ctx) {
|
|
|
469
1024
|
toolFilter: { deny: ['dsh_collab_send', 'dsh_collab_review'] },
|
|
470
1025
|
}
|
|
471
1026
|
if (bucket.model !== null) request.agentOptions = { model: bucket.model }
|
|
472
|
-
const
|
|
1027
|
+
const spec = {
|
|
473
1028
|
provider,
|
|
474
1029
|
label: bucket.lane === '@review' ? 'codex-collab-reviewer' : 'codex-collab-worker-' + bucket.lane,
|
|
475
1030
|
request,
|
|
1031
|
+
// 预分配 childId:官方要求放在 **startContinuable 顶层 spec.childId**(不是 request.childId);
|
|
1032
|
+
// 有了它,worker 首个 `kind:user`(无 rpcId)的 claim 才能按精确 id 认领路由。
|
|
1033
|
+
...(typeof bucket.expectedChildId === 'string' && bucket.expectedChildId !== '' ? { childId: bucket.expectedChildId } : {}),
|
|
476
1034
|
signal: signalOf(),
|
|
477
|
-
}
|
|
1035
|
+
}
|
|
1036
|
+
// 0.2.1 uses activation delivery; 0.2.0 exposes the original continuable entry.
|
|
1037
|
+
// Caller delivery suppresses an unsolicited parent-model turn; lifecycle end remains authoritative.
|
|
1038
|
+
const started = typeof subagents.startActivation === 'function'
|
|
1039
|
+
? await subagents.startActivation({ ...spec, delivery: 'caller' })
|
|
1040
|
+
: await subagents.startContinuable(spec)
|
|
1041
|
+
if (started.result && typeof started.result.catch === 'function') void started.result.catch(() => {})
|
|
478
1042
|
bucket.childId = started.childId
|
|
1043
|
+
// durable child 的 **session id** 在 warm/cold 下都稳定:冷恢复后仍是同一个 id(官方参考脚本实测)
|
|
1044
|
+
bucket.childSessionId = typeof started.sessionId === 'string' && started.sessionId !== '' ? started.sessionId : started.childId
|
|
479
1045
|
console.log('[dsh-bridge] worker started for', bucket.key, ':', bucket.childId, 'model:', bucket.model)
|
|
480
1046
|
return bucket.childId
|
|
481
1047
|
}
|
|
482
1048
|
|
|
1049
|
+
/** 子代理**失败**终态(官方 SubagentResult.stopReason):这些结束绝不算 done,更不允许 done+空输出。 */
|
|
1050
|
+
const SUBAGENT_FAILURE_STOP_REASONS = new Set(['error', 'aborted', 'refusal', 'max-tokens', 'interrupted', 'failed', 'cancelled'])
|
|
483
1051
|
const onChildEnd = (bucket, info) => {
|
|
484
1052
|
if (bucket.childId === null || info.id !== bucket.childId) return
|
|
485
1053
|
const text = textOfBlocks(info.lastAssistantMessage)
|
|
486
|
-
const
|
|
1054
|
+
const stopReason = info.stopReason
|
|
1055
|
+
const failed = SUBAGENT_FAILURE_STOP_REASONS.has(String(stopReason === undefined || stopReason === null ? '' : stopReason).toLowerCase())
|
|
1056
|
+
const pending = tasks.filter((t) => (t.status === 'accepted' || t.status === 'running' || (t.status === 'unknown' && t.taskId === bucket.executingTaskId)) && t.bucketKey === bucket.key)
|
|
487
1057
|
const task = pending.length > 0 ? pending[0] : null
|
|
1058
|
+
if (task !== null) delete task.awaitingOfficialEnd
|
|
1059
|
+
bucket.executingTaskId = null
|
|
1060
|
+
bucket.observationAbort?.abort()
|
|
1061
|
+
if (failed) {
|
|
1062
|
+
// 真实 SDK 终止原因优先:end 只是"子代理结束了",不等于成功。只按 stopReason 判失败,**不**凭文本空。
|
|
1063
|
+
if (task !== null) {
|
|
1064
|
+
const routeStop = task.routeStopReason
|
|
1065
|
+
task.status = 'error'
|
|
1066
|
+
task.error = 'subagent turn ended as "' + String(stopReason) + '"' + (routeStop === undefined ? '' : ' (routeStopReason=' + routeStop + ')') + (text.length > 0 ? '; partial output not treated as success' : '')
|
|
1067
|
+
task.reason = routeStop === undefined ? REASONS.SUBAGENT_TURN_FAILED : REASONS.MODEL_ROUTE_EXHAUSTED
|
|
1068
|
+
task.stopReason = String(stopReason)
|
|
1069
|
+
if (text.length > 0) task.partialResult = text
|
|
1070
|
+
persistTask(task)
|
|
1071
|
+
pushTask(task.taskId, 'error', { error: task.error, reason: task.reason, stopReason: task.stopReason, childId: bucket.childId, cwd: bucket.cwd, kind: task.kind })
|
|
1072
|
+
} else {
|
|
1073
|
+
broadcast({ type: 'error', taskId: null, childId: bucket.childId, time: Date.now(), error: 'subagent turn ended as "' + String(stopReason) + '"', stopReason: String(stopReason), cwd: bucket.cwd, kind: bucket.lane === '@review' ? 'review' : 'task' })
|
|
1074
|
+
}
|
|
1075
|
+
bucket.activeSince = 0
|
|
1076
|
+
return
|
|
1077
|
+
}
|
|
488
1078
|
if (task !== null) {
|
|
489
1079
|
task.status = 'done'
|
|
1080
|
+
delete task.error
|
|
1081
|
+
delete task.reason
|
|
1082
|
+
delete task.partialResult
|
|
1083
|
+
delete task.resend
|
|
490
1084
|
task.result = text.length > 0 ? text : '(无输出)'
|
|
491
|
-
task.stopReason =
|
|
1085
|
+
task.stopReason = stopReason
|
|
492
1086
|
remember('user', task.instruction)
|
|
493
1087
|
remember('assistant', task.result)
|
|
494
1088
|
persistTask(task)
|
|
495
|
-
pushTask(task.taskId, 'result', { text: task.result, stopReason
|
|
1089
|
+
pushTask(task.taskId, 'result', { text: task.result, stopReason, childId: bucket.childId, cwd: bucket.cwd, kind: task.kind })
|
|
496
1090
|
} else {
|
|
497
|
-
broadcast({ type: 'result', taskId: null, childId: bucket.childId, time: Date.now(), text, stopReason
|
|
1091
|
+
broadcast({ type: 'result', taskId: null, childId: bucket.childId, time: Date.now(), text, stopReason, cwd: bucket.cwd, kind: bucket.lane === '@review' ? 'review' : 'task' })
|
|
498
1092
|
}
|
|
499
1093
|
bucket.activeSince = 0
|
|
1094
|
+
bucket.observationAbort?.abort()
|
|
500
1095
|
}
|
|
501
1096
|
|
|
502
|
-
const deliver = async (bucket, instruction, brief, followupPrefix) => {
|
|
1097
|
+
const deliver = async (bucket, instruction, brief, followupPrefix, requestId) => {
|
|
503
1098
|
const parent = await ensureOwner(bucket)
|
|
1099
|
+
bucket.progressTail = ''
|
|
1100
|
+
if (bucket.childId !== null) {
|
|
1101
|
+
const cut = await observations.refresh(bucket.childId, { fresh: true })
|
|
1102
|
+
if (!cut) throw new Error('worker continuation refused: a fresh history boundary could not be observed; nothing was sent')
|
|
1103
|
+
bucket.observedSeq = cut.cursor
|
|
1104
|
+
} else bucket.observedSeq = -1
|
|
1105
|
+
bucket.executingTaskId = requestId
|
|
504
1106
|
if (bucket.childId === null) {
|
|
505
1107
|
if (bucket.childPromise === null) {
|
|
506
1108
|
bucket.childPromise = startChildWith(bucket, instruction, brief).catch((e) => { bucket.childPromise = null; throw e })
|
|
507
1109
|
await bucket.childPromise
|
|
508
1110
|
} else {
|
|
509
1111
|
await bucket.childPromise
|
|
510
|
-
await
|
|
511
|
-
source: { kind: 'coordinator', form: 'relay', senderSessionId: parent.id },
|
|
512
|
-
signal: signalOf(),
|
|
513
|
-
})
|
|
1112
|
+
await promptChild(bucket, parent, requestId, followupPrefix + instruction)
|
|
514
1113
|
}
|
|
515
1114
|
} else {
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
const result = await run.result
|
|
529
|
-
const out = textOfBlocks(result.output)
|
|
530
|
-
bucket.childId = null
|
|
531
|
-
bucket.childPromise = null
|
|
532
|
-
bucket.lastSeen = 0
|
|
533
|
-
bucket.seenCount = 0
|
|
534
|
-
void run.dispose()
|
|
535
|
-
return { fallback: true, output: out.length > 0 ? out : '(无输出)', stopReason: result.stopReason, fallbackNote: 'one-shot (cold-resume unavailable: ' + errText(followupError).slice(0, 200) + ')' }
|
|
536
|
-
}
|
|
1115
|
+
// warm/cold 继续:同 durable child,官方 `subagents.prompt`(warm 直接复用 live,cold 由官方自然恢复)。
|
|
1116
|
+
// **绝不**在这里 one-shot 新建 child(已接受/未确认都不能重做,不清 childId、不丢 nonce 历史)。
|
|
1117
|
+
await promptChild(bucket, parent, requestId, followupPrefix + instruction)
|
|
1118
|
+
}
|
|
1119
|
+
if (tasks.some(t => (t.status === 'accepted' || t.status === 'running') && t.bucketKey === bucket.key)) {
|
|
1120
|
+
bucket.observationAbort?.abort()
|
|
1121
|
+
bucket.observationAbort = new AbortController()
|
|
1122
|
+
observations.subscribe(bucket.childId, {
|
|
1123
|
+
afterSeq: bucket.observedSeq ?? -1, signal: bucket.observationAbort.signal,
|
|
1124
|
+
address: { kind: 'subagent', parentSessionId: parentSessionIdOf(parent), childSessionId: bucket.childSessionId || bucket.childId, mode: 'continuable' },
|
|
1125
|
+
onEvent: event => observeWorkerEvent(bucket, event),
|
|
1126
|
+
})
|
|
537
1127
|
}
|
|
538
1128
|
return { fallback: false, output: '', stopReason: '', fallbackNote: '' }
|
|
539
1129
|
}
|
|
@@ -547,11 +1137,34 @@ export async function apply(ctx) {
|
|
|
547
1137
|
const SESSION_DISPATCH_TIMEOUT_MS = envMs('DSH_BRIDGE_SESSION_DISPATCH_TIMEOUT_MS', 120000)
|
|
548
1138
|
/** 见过 running 之后,多久没有新助手文本算读完了。 */
|
|
549
1139
|
const SESSION_QUIET_TIMEOUT_MS = envMs('DSH_BRIDGE_SESSION_QUIET_TIMEOUT_MS', 600000)
|
|
550
|
-
/**
|
|
1140
|
+
/** 单个会话任务的总预算上限。**未委托**任务按它收口;有认证活跃委托时以 grant 绝对有效期为界。 */
|
|
551
1141
|
const SESSION_TOTAL_TIMEOUT_MS = envMs('DSH_BRIDGE_SESSION_TOTAL_TIMEOUT_MS', 3600000)
|
|
552
|
-
|
|
553
|
-
|
|
1142
|
+
/** 委托任务的观察宽限(grant 到期后仍允许收尾/交接的余量)。 */
|
|
1143
|
+
const DELEGATED_OBSERVATION_GRACE_MS = envMs('DSH_BRIDGE_DELEGATED_GRACE_MS', 300000)
|
|
1144
|
+
/** 委托观察的硬上限(不超过"授权窗口上限 + 宽限",保证仍然有界)。 */
|
|
1145
|
+
const MAX_DELEGATED_OBSERVATION_MS = MAX_AUTHORIZED_TTL_MS + DELEGATED_OBSERVATION_GRACE_MS
|
|
1146
|
+
/** steer 回执的观察上限:只等"自己的 rpcId 落进父回合",单次有界(≤30s),绝不等父任务结果。 */
|
|
1147
|
+
const RECEIPT_OBSERVE_TIMEOUT_MS = envMs('DSH_BRIDGE_RECEIPT_OBSERVE_TIMEOUT_MS', 30000)
|
|
1148
|
+
/** 单次等待之外,回执在**总观察寿命**内继续求证(长工具结束后同父回合的迟到 rpc 仍能被证明)。 */
|
|
1149
|
+
const RECEIPT_TOTAL_OBSERVE_MS = envMs('DSH_BRIDGE_RECEIPT_TOTAL_OBSERVE_MS', 15 * 60 * 1000)
|
|
1150
|
+
/** 回持续证轮询间隔。 */
|
|
1151
|
+
const RECEIPT_POLL_MS = Math.max(50, envMs('DSH_BRIDGE_RECEIPT_POLL_MS', 1000))
|
|
1152
|
+
// 观察记录**按 taskId 保存**(投递控制 D1):同一会话可以同时有多个来源明确的在途任务
|
|
1153
|
+
// (queue 排进官方 FIFO;steer 回执与父任务同 turn),每条记录只按**自己**的 rpcId→turn/end 结算,
|
|
1154
|
+
// 绝不因为全会话 running/idle 事件就把别的任务标 running/done。
|
|
554
1155
|
const sessionTargets = new Map()
|
|
1156
|
+
const taskById = (taskId) => tasks.find((t) => t.taskId === taskId)
|
|
1157
|
+
const recordForTask = (taskId) => sessionTargets.get(taskId)
|
|
1158
|
+
/** 该会话所有未收口的观察记录(顺序 = 入队顺序)。 */
|
|
1159
|
+
const sessionRecords = (sessionId) => Array.from(sessionTargets.values()).filter((r) => r.sessionId === sessionId && r.settled !== true)
|
|
1160
|
+
/** 该会话里**可作为审批 owner** 的队列执行记录(steer 回执永不参与)。 */
|
|
1161
|
+
const queueOwnerRecords = (sessionId) => sessionRecords(sessionId).filter((r) => r.deliver !== 'steer' && r.precise === true)
|
|
1162
|
+
/**
|
|
1163
|
+
* 该记录是否**已真正开始执行**(第四轮 D1):只有"自己的 rpcId 已被自己的回合认领、且还没收到自己的 turn/end"。
|
|
1164
|
+
* 排队中的任务自己的 rpcId 还没出现在日志里 → 保持 accepted/queued,**不因为会话 running 就标 running**。
|
|
1165
|
+
*/
|
|
1166
|
+
const isExecutingRecord = (record) => record !== undefined && record !== null && record.tracker !== undefined
|
|
1167
|
+
&& record.tracker.sawRequest === true && record.tracker.end === undefined
|
|
555
1168
|
|
|
556
1169
|
const sessionSearch = async (query) => {
|
|
557
1170
|
const controller = sessionControllerNow()
|
|
@@ -665,15 +1278,73 @@ export async function apply(ctx) {
|
|
|
665
1278
|
return { ok: true, sessionId: picked.sessionId, snippet: picked.snippet, candidates: picked.candidates, matchedBy }
|
|
666
1279
|
}
|
|
667
1280
|
|
|
668
|
-
|
|
1281
|
+
/**
|
|
1282
|
+
* **非终态状态集合**(第二轮修正 blocker 2):`needs-approval` 也是"在途"——它表示任务还挂在
|
|
1283
|
+
* 同一个会话的同一个 R1 request/turn 上,只差一次审批。漏掉它会让第二个任务被接受并覆盖
|
|
1284
|
+
* sessionTargets 原 record(root 复现:busySecondDispatchStatus=202)。
|
|
1285
|
+
*/
|
|
1286
|
+
const NON_TERMINAL_STATUSES = new Set(['accepted', 'queued', 'running', 'needs-approval'])
|
|
1287
|
+
const isNonTerminal = (task) => task !== undefined && task !== null && (NON_TERMINAL_STATUSES.has(task.status) || task.awaitingOfficialEnd === true)
|
|
1288
|
+
|
|
1289
|
+
/** 同会话已有在途任务(含等待审批)→ 新派活必须被拒(零 prompt),保住原 record/观察/结果。 */
|
|
1290
|
+
const sessionPendingTask = (sessionId) => tasks.find((t) => isNonTerminal(t) && t.target !== undefined && t.target.kind === 'session' && t.target.sessionId === sessionId)
|
|
1291
|
+
|
|
1292
|
+
/**
|
|
1293
|
+
* 观测官方 `approval/asked|decided`(修正 F):
|
|
1294
|
+
* - asked → 建立 officialId ↔ (session, toolName, callId) 台账,供 answerer **唯一认领**(不再"只记最后一个");
|
|
1295
|
+
* - decided → 按**具体 officialId** 收敛,且状态由官方 outcome 决定
|
|
1296
|
+
* (allowed-once→confirmed;cancelled/rejected 绝不算批准;未知→decision-unknown)。
|
|
1297
|
+
* 只做观测与状态推进,**永不写会话日志**(asked/decided 只有官方服务能写),
|
|
1298
|
+
* 也不在 handler 里等自己的 decided(官方是 handler 返回后才 append)。
|
|
1299
|
+
*/
|
|
1300
|
+
const observeApprovalEvents = (sessionId, events) => {
|
|
1301
|
+
if (approvalStore === null || !Array.isArray(events)) return
|
|
1302
|
+
for (const ev of events) {
|
|
1303
|
+
if (ev === null || typeof ev !== 'object') continue
|
|
1304
|
+
const data = ev.data !== undefined && ev.data !== null && typeof ev.data === 'object' ? ev.data : {}
|
|
1305
|
+
if (ev.type === 'approval/asked' && typeof data.id === 'string') {
|
|
1306
|
+
approvalStore.observeOfficialAsked({
|
|
1307
|
+
officialId: data.id,
|
|
1308
|
+
sessionId,
|
|
1309
|
+
toolName: typeof data.toolName === 'string' ? data.toolName : '',
|
|
1310
|
+
callId: typeof data.callId === 'string' ? data.callId : undefined,
|
|
1311
|
+
})
|
|
1312
|
+
continue
|
|
1313
|
+
}
|
|
1314
|
+
if (ev.type !== 'approval/decided' || typeof data.id !== 'string') continue
|
|
1315
|
+
const settled = approvalStore.settleFromOfficial(data.id, typeof data.outcome === 'string' ? data.outcome : 'unknown')
|
|
1316
|
+
if (settled.ok !== true) continue
|
|
1317
|
+
const pending = approvalStore.listPending({}).find((p) => p.officialId === data.id)
|
|
1318
|
+
const ownerTask = pending === undefined ? undefined : tasks.find((t) => t.taskId === pending.taskId)
|
|
1319
|
+
if (ownerTask !== undefined) {
|
|
1320
|
+
pushTask(ownerTask.taskId, 'approval-official', {
|
|
1321
|
+
officialId: data.id,
|
|
1322
|
+
outcome: typeof data.outcome === 'string' ? data.outcome : undefined,
|
|
1323
|
+
state: settled.state,
|
|
1324
|
+
target: { kind: 'session', sessionId },
|
|
1325
|
+
})
|
|
1326
|
+
}
|
|
1327
|
+
}
|
|
1328
|
+
}
|
|
669
1329
|
|
|
670
1330
|
const settleSessionTask = (record, status, fields) => {
|
|
671
1331
|
if (record.settled) return
|
|
672
1332
|
record.settled = true
|
|
673
1333
|
if (record.abort !== undefined) { try { record.abort.abort() } catch (e) { /* ignore */ } }
|
|
674
|
-
sessionTargets.delete(record.
|
|
1334
|
+
sessionTargets.delete(record.taskId)
|
|
675
1335
|
const task = tasks.find((t) => t.taskId === record.taskId)
|
|
676
1336
|
if (task === undefined) return
|
|
1337
|
+
// 任务收口只处置**自己这条 task** 的审批挂起项(第四轮 D1:绝不按 session 清掉别的任务/回执的 pending): // - 还在 pending → superseded(交给正式人工/下游链);
|
|
1338
|
+
// - 已提交但没等到官方 approval/decided 确认 → decision-unknown(不重发放行)。
|
|
1339
|
+
if (approvalStore !== null) {
|
|
1340
|
+
for (const p of approvalStore.listPending({ sessionId: record.sessionId, taskId: record.taskId })) {
|
|
1341
|
+
if (p.state === APPROVAL_STATES.PENDING) {
|
|
1342
|
+
approvalStore.closePending(p.approvalKey, APPROVAL_STATES.SUPERSEDED, 'task settled while this approval was still pending')
|
|
1343
|
+
} else if (p.state === APPROVAL_STATES.SUBMITTED) {
|
|
1344
|
+
approvalStore.markDecisionUnknown(p.approvalKey, 'task settled before an official approval/decided confirmation was observed')
|
|
1345
|
+
}
|
|
1346
|
+
}
|
|
1347
|
+
}
|
|
677
1348
|
task.status = status
|
|
678
1349
|
if (fields.result !== undefined) task.result = fields.result
|
|
679
1350
|
if (fields.error !== undefined) task.error = fields.error
|
|
@@ -684,6 +1355,7 @@ export async function apply(ctx) {
|
|
|
684
1355
|
if (fields.turnEndKind !== undefined) task.turnEndKind = fields.turnEndKind
|
|
685
1356
|
if (fields.note !== undefined) task.note = fields.note
|
|
686
1357
|
if (fields.resend !== undefined) task.resend = fields.resend
|
|
1358
|
+
if (fields.routeStopReason !== undefined) task.routeStopReason = fields.routeStopReason
|
|
687
1359
|
// 精确关联模式:回显这次请求的 requestId 与匹配到的 DSH turn(便于跨回合核对归属)。
|
|
688
1360
|
if (record.precise === true) {
|
|
689
1361
|
task.requestId = record.taskId
|
|
@@ -699,19 +1371,52 @@ export async function apply(ctx) {
|
|
|
699
1371
|
})
|
|
700
1372
|
}
|
|
701
1373
|
|
|
1374
|
+
/**
|
|
1375
|
+
* 路由已按"本任务"给出终态(候选耗尽 / 禁 fallback 终止):这是**可信的本任务终止事实**。
|
|
1376
|
+
* 优先让原生 `turn/end` 证据收口(follow 是实时的);有界兜底再用该事实收口,避免 HTTP 悬挂 accepted。
|
|
1377
|
+
* 只处置**本任务**(不 cancel 用户 Agent、不清别人的 inbox、不碰其他 queue、不重派)。
|
|
1378
|
+
*/
|
|
1379
|
+
const scheduleRouteFinalize = (task, reason) => {
|
|
1380
|
+
if (task === undefined || task === null || task.routeFinalizeScheduled === true) return
|
|
1381
|
+
task.routeFinalizeScheduled = true
|
|
1382
|
+
const terminalReason = task.routeStopReason === undefined ? reason : task.routeStopReason
|
|
1383
|
+
const settleByFact = () => {
|
|
1384
|
+
if (task.status === 'done' || task.status === 'error' || task.status === 'cancelled') return
|
|
1385
|
+
const record = sessionTargets.get(task.taskId)
|
|
1386
|
+
if (record === undefined || record.settled === true) return // worker 路径由 subagent/end 收口
|
|
1387
|
+
settleSessionTask(record, 'error', {
|
|
1388
|
+
error: 'model routing stopped this task before a successful turn (routeStopReason=' + String(terminalReason) + ')',
|
|
1389
|
+
reason: REASONS.MODEL_ROUTE_EXHAUSTED,
|
|
1390
|
+
routeStopReason: terminalReason,
|
|
1391
|
+
note: 'route-terminal-fact',
|
|
1392
|
+
})
|
|
1393
|
+
}
|
|
1394
|
+
// 1) 先给原生证据一次机会:读一次会话日志;能拿到本请求的 turn/end 就按原生原因收口(更准确)。
|
|
1395
|
+
void (async () => {
|
|
1396
|
+
const record = sessionTargets.get(task.taskId)
|
|
1397
|
+
if (record !== undefined && record.settled !== true && record.precise === true) {
|
|
1398
|
+
await observeSessionLog(record)
|
|
1399
|
+
if (record.settled !== true && record.tracker !== undefined && record.tracker.end !== undefined) { settleFromTracker(record); return }
|
|
1400
|
+
}
|
|
1401
|
+
// 2) 原生证据还没到达(follow/poll 有延迟)→ 用**可信的本任务终止事实**有界收口,绝不悬挂 accepted。
|
|
1402
|
+
const waitMs = 250
|
|
1403
|
+
const go = () => { try { setTimeout(settleByFact, waitMs) } catch (e) { settleByFact() } }
|
|
1404
|
+
if (timer !== undefined && timer !== null && typeof timer.timeout === 'function') {
|
|
1405
|
+
try { void Promise.resolve(timer.timeout(waitMs)).then(() => { if (task.status === 'accepted' || task.status === 'running') settleByFact() }, settleByFact) } catch (e) { go() }
|
|
1406
|
+
} else go()
|
|
1407
|
+
})()
|
|
1408
|
+
}
|
|
1409
|
+
|
|
702
1410
|
/**
|
|
703
1411
|
* 把"读日志"当成一次**观察机会**(不是完成证明):把日志事件喂给关联器。
|
|
704
1412
|
* 与 live follow 共用同一套规则,靠 seq 去重。
|
|
705
1413
|
*/
|
|
706
1414
|
const observeSessionLog = async (record) => {
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
} catch (e) {
|
|
713
|
-
console.error('[dsh-bridge] session observation failed:', errText(e).slice(0, 200))
|
|
714
|
-
}
|
|
1415
|
+
const snap = await observations.since(record.sessionId, record.observedSeq ?? -1)
|
|
1416
|
+
if (!snap || record.settled) return
|
|
1417
|
+
observeApprovalEvents(record.sessionId, snap.events)
|
|
1418
|
+
for (const event of snap.events) consumeSessionEvent(record.tracker, event)
|
|
1419
|
+
record.observedSeq = snap.cursor
|
|
715
1420
|
}
|
|
716
1421
|
|
|
717
1422
|
/** 精确模式下的进度推送:只推自己那个 turn 的**新增**文本。 */
|
|
@@ -733,13 +1438,20 @@ export async function apply(ctx) {
|
|
|
733
1438
|
const settleFromTracker = (record) => {
|
|
734
1439
|
const outcome = turnOutcome(record.tracker.end)
|
|
735
1440
|
const text = record.tracker.text.trim()
|
|
1441
|
+
const task = tasks.find((t) => t.taskId === record.taskId)
|
|
1442
|
+
const routeStop = task === undefined ? undefined : task.routeStopReason
|
|
736
1443
|
if (outcome.ok) {
|
|
737
1444
|
settleSessionTask(record, 'done', { result: text === '' ? '(无输出)' : text, stopReason: 'request-turn-completed' })
|
|
738
1445
|
return
|
|
739
1446
|
}
|
|
1447
|
+
// 路由已给出本任务终态(耗尽/禁 fallback)→ 用稳定 reason 收口,并保留 routeStopReason;
|
|
1448
|
+
// 其余非 completed 结束仍按 TURN_FAILED 如实失败。
|
|
740
1449
|
settleSessionTask(record, 'error', {
|
|
741
|
-
error:
|
|
742
|
-
|
|
1450
|
+
error: routeStop === undefined
|
|
1451
|
+
? 'the DSH turn for this request ended as "' + outcome.kind + '" (only "completed" is success)'
|
|
1452
|
+
: 'model routing stopped this task (routeStopReason=' + routeStop + '); the DSH turn ended as "' + outcome.kind + '"',
|
|
1453
|
+
reason: routeStop === undefined ? REASONS.TURN_FAILED : REASONS.MODEL_ROUTE_EXHAUSTED,
|
|
1454
|
+
...(routeStop === undefined ? {} : { routeStopReason: routeStop }),
|
|
743
1455
|
turnEndKind: outcome.kind,
|
|
744
1456
|
...(text === '' ? {} : { partialResult: text }),
|
|
745
1457
|
})
|
|
@@ -762,8 +1474,9 @@ export async function apply(ctx) {
|
|
|
762
1474
|
let extractedText = ''
|
|
763
1475
|
if (sessionQuery !== undefined) {
|
|
764
1476
|
try {
|
|
765
|
-
const snap = await
|
|
1477
|
+
const snap = await observations.capture(record.sessionId)
|
|
766
1478
|
const events = snap !== undefined && snap !== null && Array.isArray(snap.events) ? snap.events : []
|
|
1479
|
+
observeApprovalEvents(record.sessionId, events)
|
|
767
1480
|
const extracted = extractNewAssistantText(events, record.baseline)
|
|
768
1481
|
record.baseline = extracted.baseline
|
|
769
1482
|
extractedText = extracted.text
|
|
@@ -777,47 +1490,204 @@ export async function apply(ctx) {
|
|
|
777
1490
|
}
|
|
778
1491
|
|
|
779
1492
|
const watchSessionTask = (record) => {
|
|
780
|
-
|
|
781
|
-
const controller = new AbortController()
|
|
782
|
-
record.abort = controller
|
|
1493
|
+
record.abort ??= new AbortController()
|
|
783
1494
|
record.watcher = 'follow'
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
if (outcome === 'settled') { settleFromTracker(record); break }
|
|
799
|
-
continue
|
|
1495
|
+
observations.subscribe(record.sessionId, {
|
|
1496
|
+
afterSeq: record.observedSeq ?? -1,
|
|
1497
|
+
signal: record.abort.signal,
|
|
1498
|
+
onUncertain: () => { if (!record.settled) record.watcher = 'poll' },
|
|
1499
|
+
onEvent: (event) => {
|
|
1500
|
+
if (record.settled) return
|
|
1501
|
+
record.observedSeq = event.seq
|
|
1502
|
+
observeApprovalEvents(record.sessionId, [event])
|
|
1503
|
+
if (record.precise) {
|
|
1504
|
+
const outcome = consumeSessionEvent(record.tracker, event)
|
|
1505
|
+
const task = taskById(record.taskId)
|
|
1506
|
+
if (task && (task.status === 'accepted' || task.status === 'queued') && isExecutingRecord(record)) {
|
|
1507
|
+
task.status = 'running'
|
|
1508
|
+
pushTask(record.taskId, 'running', { target: { kind: 'session', sessionId: record.sessionId }, deliver: record.deliver })
|
|
800
1509
|
}
|
|
801
|
-
if (
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
record.activeSince = Date.now()
|
|
806
|
-
const task = tasks.find((t) => t.taskId === record.taskId)
|
|
807
|
-
if (task !== undefined && task.status === 'accepted') task.status = 'running'
|
|
808
|
-
pushTask(record.taskId, 'progress', { text, target: { kind: 'session', sessionId: record.sessionId } })
|
|
1510
|
+
if (outcome === 'progress') pushPreciseProgress(record)
|
|
1511
|
+
if (outcome === 'settled') settleFromTracker(record)
|
|
1512
|
+
} else {
|
|
1513
|
+
const text = assistantTextOf(event)
|
|
1514
|
+
if (text) { record.total += text; record.activeSince = Date.now(); pushTask(record.taskId, 'progress', { text, target: { kind: 'session', sessionId: record.sessionId } }) }
|
|
809
1515
|
}
|
|
810
|
-
}
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
1516
|
+
},
|
|
1517
|
+
})
|
|
1518
|
+
}
|
|
1519
|
+
|
|
1520
|
+
/**
|
|
1521
|
+
* 回执的**自身输入证据**(第四轮 C):只用"自己的 rpcId 的 user/message 落在哪个回合"这一条证据。
|
|
1522
|
+
* 绝不复用通用 tracker(那会累计父任务的助手文本、被普通 sweep 当成执行收口)。
|
|
1523
|
+
* 返回 `{state, evidence}`:state ∈ observed | deviated | pending | unconfirmed。
|
|
1524
|
+
*/
|
|
1525
|
+
const receiptEvidenceOf = async (record, task) => {
|
|
1526
|
+
const events = await readSessionEvents(record.sessionId)
|
|
1527
|
+
// 读取失败(瞬时故障/会话不可读):**结论未知**——绝不据此写任何状态(第二次验收修正 2)。
|
|
1528
|
+
if (events === null) return { state: 'read-error', evidence: null, events: null }
|
|
1529
|
+
if (record.settled === true) return { state: 'settled', evidence: null, events }
|
|
1530
|
+
record.sawSuccessfulRead = true // 只有真的读到过日志,才允许下"未确认"的结论
|
|
1531
|
+
const evidence = findRequestEvidence(events, task.taskId)
|
|
1532
|
+
const targetTurn = Number.isInteger(record.parentTurn) ? record.parentTurn : (Number.isInteger(task.parentTurn) ? task.parentTurn : undefined)
|
|
1533
|
+
if (evidence === null || evidence.turn === null) {
|
|
1534
|
+
// 我方 rpcId 还没出现:父回合已结束 → 再也等不到(明确未确认);父回合仍开放 → 继续等(长工具后的迟到 rpc 仍可被后续查询证明)。
|
|
1535
|
+
if (targetTurn !== undefined && turnHasEnded(events, targetTurn)) return { state: 'unconfirmed', evidence: null, events }
|
|
1536
|
+
return { state: 'pending', evidence: null, events }
|
|
1537
|
+
}
|
|
1538
|
+
if (targetTurn === undefined || evidence.turn === targetTurn) return { state: 'observed', evidence, events }
|
|
1539
|
+
// 官方把这条补充落进了**另一个回合**(异步 admission / wakingAfterAbort 改道):如实报告偏离,绝不假成功。
|
|
1540
|
+
return { state: 'deviated', evidence, events }
|
|
1541
|
+
}
|
|
1542
|
+
|
|
1543
|
+
/** 回执观察的**唯一**终态收口(第二次验收修正 2/3):幂等 + 单调 + 卸载门闩。 */
|
|
1544
|
+
const settleReceiptRecord = (record) => {
|
|
1545
|
+
if (record.settled === true && typeof record.receiptWake !== 'function') return
|
|
1546
|
+
record.settled = true
|
|
1547
|
+
// 释放自己的等待定时器:让循环立刻醒来退出,而不是继续睡到下一个 tick("及时释放")。
|
|
1548
|
+
if (typeof record.receiptWake === 'function') {
|
|
1549
|
+
const wake = record.receiptWake
|
|
1550
|
+
record.receiptWake = undefined
|
|
1551
|
+
try { wake() } catch (e) { /* ignore */ }
|
|
1552
|
+
}
|
|
1553
|
+
}
|
|
1554
|
+
|
|
1555
|
+
/** 可被 settle 立刻唤醒的等待(卸载后不再空转)。 */
|
|
1556
|
+
const sleepWithWake = (record, ms) => new Promise((resolve) => {
|
|
1557
|
+
const timer = setTimeout(() => { record.receiptWake = undefined; resolve() }, ms)
|
|
1558
|
+
record.receiptWake = () => { clearTimeout(timer); resolve() }
|
|
1559
|
+
})
|
|
1560
|
+
|
|
1561
|
+
/**
|
|
1562
|
+
* 幂等终态收口:首循环 / 后台续证 / 显式查询续证**共用这一条路径**。
|
|
1563
|
+
* · `record.settled`(卸载/已收口)→ 不读不写;
|
|
1564
|
+
* · 已证终态 `observed`/`deviated` **单调不可回退**(临时读失败/陈旧读取绝不能改回 pending/unconfirmed);
|
|
1565
|
+
* · `unconfirmed` 只可能由"成功读取证明父回合已结束"或"观察寿命到期"产生,且可被之后的成功读取升级回 observed;
|
|
1566
|
+
* · pending 不重复写。
|
|
1567
|
+
*/
|
|
1568
|
+
const finalizeReceipt = (record, task, outcome, options = {}) => {
|
|
1569
|
+
if (record === undefined || record.settled === true) return false
|
|
1570
|
+
const current = task.receiptState
|
|
1571
|
+
if (current === 'observed' || current === 'deviated') return false // 已证事实绝不回退
|
|
1572
|
+
if (current === 'unconfirmed' && outcome.state !== 'observed') return false // 只允许"恢复读取后升级"
|
|
1573
|
+
if (outcome.state === 'pending' && current === 'pending') return false
|
|
1574
|
+
applyReceiptEvidence(record, task, outcome)
|
|
1575
|
+
if (outcome.state !== 'pending' && options.keepForQuery !== true) settleReceiptRecord(record)
|
|
1576
|
+
return true
|
|
1577
|
+
}
|
|
1578
|
+
/** 把回执证据写进任务与落盘记录(父结果永不残留)。 */
|
|
1579
|
+
const applyReceiptEvidence = (record, task, outcome) => {
|
|
1580
|
+
const push = {
|
|
1581
|
+
...(outcome.note === undefined ? {} : { note: outcome.note }),
|
|
1582
|
+
...(outcome.text === undefined ? {} : { text: outcome.text }),
|
|
1583
|
+
}
|
|
1584
|
+
task.resultScope = 'parent-task'
|
|
1585
|
+
task.result = undefined // 回执不承载父结果;配合 persistTask 的显式清除
|
|
1586
|
+
task.resend = false
|
|
1587
|
+
if (outcome.observedTurn !== undefined) task.observedTurn = outcome.observedTurn
|
|
1588
|
+
if (outcome.requestSeq !== undefined) task.requestSeq = outcome.requestSeq
|
|
1589
|
+
task.receiptState = outcome.state
|
|
1590
|
+
task.status = outcome.status
|
|
1591
|
+
if (outcome.reason !== undefined) task.reason = outcome.reason
|
|
1592
|
+
persistTask(task)
|
|
1593
|
+
pushTask(task.taskId, outcome.status, {
|
|
1594
|
+
parentTaskId: record.parentTaskId, requestId: task.taskId, resultScope: 'parent-task',
|
|
1595
|
+
...(task.observedTurn === undefined ? {} : { observedTurn: task.observedTurn }),
|
|
1596
|
+
...(Number.isInteger(record.parentTurn) ? { parentTurn: record.parentTurn } : {}),
|
|
1597
|
+
...push,
|
|
1598
|
+
target: { kind: 'session', sessionId: record.sessionId },
|
|
1599
|
+
})
|
|
1600
|
+
}
|
|
1601
|
+
|
|
1602
|
+
/**
|
|
1603
|
+
* steer 回执观察(第四轮 D1 + 首次验收修正 B/C):
|
|
1604
|
+
* 1. 单次有界等待(默认 ≤30s,`RECEIPT_OBSERVE_TIMEOUT_MS`)→ 观察到就 observed;
|
|
1605
|
+
* 2. 界内没等到**不是**永久未确认:转 `receipt-pending` 并在**总观察寿命**(`RECEIPT_TOTAL_OBSERVE_MS`)内后台继续求证,
|
|
1606
|
+
* 同时每次显式查询都会再核一次(长工具结束后同父回合的迟到 rpc 仍能被后续查询证明);
|
|
1607
|
+
* 3. 只认"自己的 rpcId 落在服务端捕获的父回合":落到别的回合 → `receipt-deviated`(真实 observedTurn),
|
|
1608
|
+
* 父回合已结束而自己没落进去 → `receipt-unconfirmed`;两者都 `resend:false`。
|
|
1609
|
+
* 绝不累计父任务助手文本、绝不等父任务结果、绝不参与审批归属。
|
|
1610
|
+
*/
|
|
1611
|
+
const observeSteerReceipt = async (record, task) => {
|
|
1612
|
+
const deadline = Date.now() + RECEIPT_OBSERVE_TIMEOUT_MS
|
|
1613
|
+
/** 单次检查 → 收口;返回 true 表示"已有结论(或已卸载)"。瞬时读失败**不写任何状态**。 */
|
|
1614
|
+
const step = (check) => {
|
|
1615
|
+
if (check.state === 'read-error' || check.state === 'settled') return record.settled === true
|
|
1616
|
+
if (check.state === 'observed') {
|
|
1617
|
+
finalizeReceipt(record, task, { state: 'observed', status: 'receipt-observed', observedTurn: check.evidence.turn, requestSeq: check.evidence.seq, note: 'receipt-observed' })
|
|
1618
|
+
return true
|
|
815
1619
|
}
|
|
816
|
-
|
|
1620
|
+
if (check.state === 'deviated') {
|
|
1621
|
+
finalizeReceipt(record, task, { state: 'deviated', status: 'receipt-deviated', reason: REASONS.RECEIPT_TURN_DEVIATED, observedTurn: check.evidence.turn, requestSeq: check.evidence.seq, note: 'RECEIPT-TURN-DEVIATED' })
|
|
1622
|
+
return true
|
|
1623
|
+
}
|
|
1624
|
+
if (check.state === 'unconfirmed') {
|
|
1625
|
+
finalizeReceipt(record, task, { state: 'unconfirmed', status: 'receipt-unconfirmed', reason: REASONS.RECEIPT_UNCONFIRMED, note: 'RECEIPT-UNCONFIRMED' })
|
|
1626
|
+
return true
|
|
1627
|
+
}
|
|
1628
|
+
return false
|
|
1629
|
+
}
|
|
1630
|
+
for (;;) {
|
|
1631
|
+
if (record.settled === true) return
|
|
1632
|
+
if (step(await receiptEvidenceOf(record, task)) === true) return
|
|
1633
|
+
if (record.settled === true) return // await 之后:卸载门闩(绝不写盘)
|
|
1634
|
+
if (Date.now() >= deadline) break
|
|
1635
|
+
await sleepWithWake(record, 250)
|
|
1636
|
+
}
|
|
1637
|
+
if (record.settled === true) return
|
|
1638
|
+
// 界内未观察到:转 pending(**不是**终态),后台在总观察寿命内继续求证。
|
|
1639
|
+
finalizeReceipt(record, task, {
|
|
1640
|
+
state: 'pending', status: 'receipt-pending', note: 'RECEIPT-PENDING',
|
|
1641
|
+
text: '官方已接收这条补充;有界等待窗口内还没观察到自己的 rpcId 落进父回合。**不要重发**:本回执仍在观察寿命内继续求证,也可随时显式查询(父任务结果请查父 task)。',
|
|
1642
|
+
})
|
|
1643
|
+
if (record.settled === true) return
|
|
1644
|
+
const totalDeadline = Date.now() + RECEIPT_TOTAL_OBSERVE_MS
|
|
1645
|
+
record.watcher = 'receipt'
|
|
1646
|
+
for (;;) {
|
|
1647
|
+
await sleepWithWake(record, RECEIPT_POLL_MS)
|
|
1648
|
+
if (record.settled === true) return // 卸载/已收口 → 零读取、零写盘
|
|
1649
|
+
if (step(await receiptEvidenceOf(record, task)) === true) return
|
|
1650
|
+
if (record.settled === true) return
|
|
1651
|
+
if (Date.now() >= totalDeadline) {
|
|
1652
|
+
// 观察寿命到期:**只有"成功读取已证明父回合结束"**才允许 unconfirmed(那条路径在上面的 step 里就已收口)。
|
|
1653
|
+
// 到期而证据不足时**绝不臆造未确认**(第二次验收修正 2):保留 pending 记录,等后续显式查询继续求证。
|
|
1654
|
+
record.watcher = record.sawSuccessfulRead === true ? 'receipt-expired' : 'receipt-expired-unread'
|
|
1655
|
+
pushTask(task.taskId, 'note', {
|
|
1656
|
+
parentTaskId: record.parentTaskId, requestId: task.taskId, resultScope: 'parent-task',
|
|
1657
|
+
note: 'RECEIPT-UNRESOLVED',
|
|
1658
|
+
text: '官方已接收这条补充;到观察寿命结束仍未取得"自身 rpcId 落进父回合"或"父回合已结束"的证据——**不下结论、不重发**;读取恢复后显式查询即可继续求证。',
|
|
1659
|
+
target: { kind: 'session', sessionId: record.sessionId },
|
|
1660
|
+
})
|
|
1661
|
+
return
|
|
1662
|
+
}
|
|
1663
|
+
}
|
|
1664
|
+
}
|
|
1665
|
+
|
|
1666
|
+
/**
|
|
1667
|
+
* 显式查询回执时的**同步续证**(第四轮 C):状态查询本身就是一次官方证据机会,
|
|
1668
|
+
* 因此"长工具结束后同父回合的迟到 rpc"不会因为单次等待窗口过去而永久丢确认。
|
|
1669
|
+
* 走**同一条**幂等收口:已证 observed/deviated 不回退;临时读失败什么都不写(第二次验收修正 2)。
|
|
1670
|
+
*/
|
|
1671
|
+
const refreshReceiptNow = async (record, task) => {
|
|
1672
|
+
if (record.settled === true) return
|
|
1673
|
+
if (task.receiptState !== 'pending' && task.receiptState !== 'accepted' && task.receiptState !== 'unconfirmed') return
|
|
1674
|
+
const check = await receiptEvidenceOf(record, task)
|
|
1675
|
+
if (record.settled === true) return
|
|
1676
|
+
if (check.state === 'observed') {
|
|
1677
|
+
finalizeReceipt(record, task, { state: 'observed', status: 'receipt-observed', observedTurn: check.evidence.turn, requestSeq: check.evidence.seq, note: 'receipt-observed' })
|
|
1678
|
+
return
|
|
1679
|
+
}
|
|
1680
|
+
if (check.state === 'deviated') {
|
|
1681
|
+
finalizeReceipt(record, task, { state: 'deviated', status: 'receipt-deviated', reason: REASONS.RECEIPT_TURN_DEVIATED, observedTurn: check.evidence.turn, requestSeq: check.evidence.seq, note: 'RECEIPT-TURN-DEVIATED' })
|
|
1682
|
+
return
|
|
1683
|
+
}
|
|
1684
|
+
if (check.state === 'unconfirmed') {
|
|
1685
|
+
finalizeReceipt(record, task, { state: 'unconfirmed', status: 'receipt-unconfirmed', reason: REASONS.RECEIPT_UNCONFIRMED, note: 'RECEIPT-UNCONFIRMED' }, { keepForQuery: true })
|
|
1686
|
+
}
|
|
817
1687
|
}
|
|
818
1688
|
|
|
819
1689
|
/** 派发一条指令进已有会话。任何失败都不创建会话,只落一个 error 任务。 */
|
|
820
|
-
const dispatchToSession = async (instruction, target, deliver, notes, incomingHistory, originThreadId) => {
|
|
1690
|
+
const dispatchToSession = async (instruction, target, deliver, notes, incomingHistory, originThreadId, delegateOptions, steering, routePolicy) => {
|
|
821
1691
|
const task = {
|
|
822
1692
|
taskId: uid(), kind: 'task', instruction,
|
|
823
1693
|
target: { kind: 'session', sessionId: target.sessionId, query: target.query, snippet: target.snippet },
|
|
@@ -825,17 +1695,33 @@ export async function apply(ctx) {
|
|
|
825
1695
|
cwd: undefined, lane: undefined, bucketKey: undefined,
|
|
826
1696
|
}
|
|
827
1697
|
rememberTask(tasks, task)
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
1698
|
+
// 任务作用域的 abort(模型路由预算耗尽时**只终止本任务**;不碰用户回合、不碰别的任务)
|
|
1699
|
+
const taskAbort = new AbortController()
|
|
1700
|
+
|
|
1701
|
+
// ---- 投递控制(第四轮):queue = 排进官方 FIFO 的**独立**任务;steer = 明确父任务的补充回执 ----
|
|
1702
|
+
const mode = deliver === 'steer' ? 'steer' : 'queue'
|
|
1703
|
+
const parentTaskId = steering !== undefined && steering !== null && typeof steering.parentTaskId === 'string' && steering.parentTaskId !== ''
|
|
1704
|
+
? steering.parentTaskId
|
|
1705
|
+
: undefined
|
|
1706
|
+
const expectedTurn = steering !== undefined && steering !== null && Number.isInteger(steering.expectedTurn) ? steering.expectedTurn : undefined
|
|
1707
|
+
const refuseDispatch = (reason, message) => {
|
|
832
1708
|
const bound = bindAssociation(task, originThreadId)
|
|
833
1709
|
task.status = 'error'
|
|
834
|
-
task.error =
|
|
835
|
-
task.reason =
|
|
1710
|
+
task.error = message
|
|
1711
|
+
task.reason = reason
|
|
836
1712
|
if (bound.ok) persistTask(task)
|
|
837
|
-
pushTask(task.taskId, 'error', { error: task.error, reason: task.reason })
|
|
838
|
-
return { task, failed: { reason
|
|
1713
|
+
pushTask(task.taskId, 'error', { error: task.error, reason: task.reason, ...(originThreadId === undefined ? {} : { originThreadId }) })
|
|
1714
|
+
return { task, failed: { reason, message, candidates: [] } }
|
|
1715
|
+
}
|
|
1716
|
+
if (mode === 'steer') {
|
|
1717
|
+
// 无来源 steer 会改动"可能是别人的"在途任务 → 零投递
|
|
1718
|
+
if (originThreadId === undefined) {
|
|
1719
|
+
return refuseDispatch(REASONS.ORIGIN_REQUIRED_FOR_STEER, 'unbound steer is refused: it would change a task that may belong to another source. Pass originThreadId and parentTaskId.')
|
|
1720
|
+
}
|
|
1721
|
+
// 显式父任务是硬要求(绝不按"最近/最老任务"推断)
|
|
1722
|
+
if (parentTaskId === undefined) {
|
|
1723
|
+
return refuseDispatch(REASONS.PARENT_REQUIRED, 'steer requires an explicit parentTaskId — the parent is never inferred from "most recent"/"oldest" task')
|
|
1724
|
+
}
|
|
839
1725
|
}
|
|
840
1726
|
|
|
841
1727
|
const resolved = await resolveSessionTarget(target)
|
|
@@ -852,13 +1738,112 @@ export async function apply(ctx) {
|
|
|
852
1738
|
if (resolved.candidates.length > 0) task.candidates = resolved.candidates
|
|
853
1739
|
if (resolved.matchedBy !== undefined) task.matchedBy = resolved.matchedBy
|
|
854
1740
|
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
1741
|
+
// ---- 忙时不等于错误(第四轮 D1):
|
|
1742
|
+
// * 来源绑定的 queue:**允许**在同一会话已有在途任务时接收(排进官方 FIFO,各自独立 requestId/回合/结果);
|
|
1743
|
+
// * steer:校验父任务(同一来源/会话/profile + 真正在执行 + 回合可证)后作为回执接收;
|
|
1744
|
+
// * 旧无来源(不可精确关联)路径:保持原忙锁;且与新的来源绑定任务**不得混用**(结果归属会被污染)。
|
|
1745
|
+
const activeRecords = sessionRecords(resolved.sessionId)
|
|
1746
|
+
const legacyActive = activeRecords.some((r) => r.precise !== true)
|
|
1747
|
+
if (originThreadId === undefined) {
|
|
1748
|
+
const inFlight = sessionPendingTask(resolved.sessionId)
|
|
1749
|
+
if (inFlight !== undefined && inFlight.taskId !== task.taskId) {
|
|
1750
|
+
return refuseDispatch(REASONS.BUSY, 'another bridge task is already pending on this session')
|
|
1751
|
+
}
|
|
1752
|
+
} else if (legacyActive) {
|
|
1753
|
+
return refuseDispatch(REASONS.LEGACY_MIX_UNSAFE, 'this session already has an unbound (non-precise) bridge task; a source-bound task cannot be attributed safely alongside it — wait for it to finish or use another session')
|
|
1754
|
+
}
|
|
1755
|
+
if (mode === 'steer') {
|
|
1756
|
+
const parent = taskById(parentTaskId)
|
|
1757
|
+
if (parent === undefined || parent.target === undefined || parent.target.kind !== 'session') {
|
|
1758
|
+
return refuseDispatch(REASONS.PARENT_INVALID, 'unknown parent task ' + parentTaskId)
|
|
1759
|
+
}
|
|
1760
|
+
if (parent.target.sessionId !== resolved.sessionId) {
|
|
1761
|
+
return refuseDispatch(REASONS.PARENT_INVALID, 'parent task ' + parentTaskId + ' belongs to another session')
|
|
1762
|
+
}
|
|
1763
|
+
if (parent.originThreadId !== originThreadId) {
|
|
1764
|
+
return refuseDispatch(REASONS.PARENT_INVALID, 'parent task ' + parentTaskId + ' belongs to another source conversation')
|
|
1765
|
+
}
|
|
1766
|
+
if (parent.deliver === 'steer') {
|
|
1767
|
+
return refuseDispatch(REASONS.PARENT_INVALID, 'a steer receipt cannot itself be a parent')
|
|
1768
|
+
}
|
|
1769
|
+
if (parent.status === 'done' || parent.status === 'error' || parent.status === 'cancelled' || parent.status === 'unknown') {
|
|
1770
|
+
return refuseDispatch(REASONS.PARENT_TERMINAL, 'parent task ' + parentTaskId + ' is already terminal (' + parent.status + '); nothing left to steer — queue a new task instead')
|
|
1771
|
+
}
|
|
1772
|
+
if (parent.status !== 'running' && parent.status !== 'needs-approval') {
|
|
1773
|
+
return refuseDispatch(REASONS.PARENT_QUEUED, 'parent task ' + parentTaskId + ' is ' + parent.status + ' (not executing yet); steering a queued parent is meaningless — wait until it runs')
|
|
1774
|
+
}
|
|
1775
|
+
// 首次验收修正 B:拒绝只看**缓存** parent.status/tracker——官方日志可能早已"父回合 end + 新回合 start"。
|
|
1776
|
+
// 因此这里 fresh 读一次官方日志,用"父回合是否仍是当前开放回合"这一硬证据做预检。
|
|
1777
|
+
const parentRecord = recordForTask(parent.taskId)
|
|
1778
|
+
const parentTurn = parentRecord !== undefined && parentRecord.tracker !== undefined && Number.isInteger(parentRecord.tracker.turn) ? parentRecord.tracker.turn : undefined
|
|
1779
|
+
if (parentTurn === undefined) {
|
|
1780
|
+
return refuseDispatch(REASONS.PARENT_INVALID, 'parent task ' + parentTaskId + ' has no provably open turn (no rpcId/turn evidence)')
|
|
1781
|
+
}
|
|
1782
|
+
if (parentRecord.tracker.end !== undefined) {
|
|
1783
|
+
return refuseDispatch(REASONS.PARENT_TERMINAL, 'parent task ' + parentTaskId + ' has already ended its turn (tracker收口证据); nothing left to steer')
|
|
1784
|
+
}
|
|
1785
|
+
const parentEvents = await readSessionEvents(resolved.sessionId)
|
|
1786
|
+
if (parentEvents !== null) {
|
|
1787
|
+
if (turnHasEnded(parentEvents, parentTurn)) {
|
|
1788
|
+
return refuseDispatch(REASONS.PARENT_TURN_CLOSED, 'parent task ' + parentTaskId + ' 的回合 ' + parentTurn + ' 在官方日志里已经 turn/end;插入没有意义(执行前拒绝、零 prompt)')
|
|
1789
|
+
}
|
|
1790
|
+
const openTurn = currentOpenTurn(parentEvents)
|
|
1791
|
+
if (openTurn !== null && openTurn !== parentTurn) {
|
|
1792
|
+
return refuseDispatch(REASONS.PARENT_TURN_CLOSED, 'parent task ' + parentTaskId + ' 的回合 ' + parentTurn + ' 已不是当前开放回合(官方日志当前开放 turn=' + openTurn + ');执行前拒绝、零 prompt')
|
|
1793
|
+
}
|
|
1794
|
+
}
|
|
1795
|
+
if (expectedTurn !== undefined && expectedTurn !== parentTurn) {
|
|
1796
|
+
return refuseDispatch(REASONS.PARENT_TURN_MISMATCH, 'expectedTurn=' + expectedTurn + ' does not match the parent task\'s current turn ' + parentTurn)
|
|
1797
|
+
}
|
|
1798
|
+
task.parentTaskId = parentTaskId
|
|
1799
|
+
task.requestedMode = 'steer'
|
|
1800
|
+
task.resultScope = 'parent-task'
|
|
1801
|
+
task.receiptState = 'accepted'
|
|
1802
|
+
task.observedTurn = undefined
|
|
1803
|
+
task.expectedTurn = expectedTurn
|
|
1804
|
+
// 服务端**自己捕获**的目标父回合:expectedTurn 可有可无都不会丢目标(B)。
|
|
1805
|
+
task.parentTurn = parentTurn
|
|
1806
|
+
} else {
|
|
1807
|
+
task.requestedMode = 'queue'
|
|
1808
|
+
// 队列语义:同一会话已有在途任务时,新任务先落 'queued'(还没轮到它),自己的 rpcId 出现后才变 running。
|
|
1809
|
+
if (activeRecords.some((r) => isExecutingRecord(r))) task.status = 'queued'
|
|
1810
|
+
}
|
|
1811
|
+
|
|
1812
|
+
// 模型路由(M2):**派活前**登记任务策略(steer 回执沿父,不另建策略)。
|
|
1813
|
+
if (routePolicy !== undefined && mode !== 'steer') {
|
|
1814
|
+
task.routePolicy = routePolicy
|
|
1815
|
+
const plan = await modelRouting.planRoute({
|
|
1816
|
+
taskId: task.taskId,
|
|
1817
|
+
policy: routePolicy,
|
|
1818
|
+
kind: task.kind,
|
|
1819
|
+
execution: 'session',
|
|
1820
|
+
originThreadId,
|
|
1821
|
+
sessionId: resolved.sessionId,
|
|
1822
|
+
// 预算耗尽/托管终态:**不 abort 自己的观察者**(那会丢掉原生 turn/end 证据 → HTTP 悬挂 accepted)。
|
|
1823
|
+
// 记录本任务的稳定终止事实,由观察者按原生 turn/end 收口;有界兜底再用该事实收口。
|
|
1824
|
+
onTerminal: (info) => {
|
|
1825
|
+
const reason = info !== null && typeof info === 'object' && typeof info.reason === 'string' ? info.reason : 'terminal'
|
|
1826
|
+
task.routeStopReason = reason
|
|
1827
|
+
if (info !== null && typeof info === 'object' && info.exhausted === true) scheduleRouteFinalize(task, reason)
|
|
1828
|
+
},
|
|
1829
|
+
onExhausted: (reason) => {
|
|
1830
|
+
task.routeStopReason = reason
|
|
1831
|
+
scheduleRouteFinalize(task, reason)
|
|
1832
|
+
},
|
|
1833
|
+
})
|
|
1834
|
+
const chosen = plan.selection !== null && plan.selection.ok === true ? { provider: plan.selection.pair.provider, model: plan.selection.pair.model } : null
|
|
1835
|
+
const reason = plan.selection === null ? 'inherit' : plan.selection.reason
|
|
1836
|
+
task.route = {
|
|
1837
|
+
mode: routePolicy.mode,
|
|
1838
|
+
allowFallback: routePolicy.allowFallback,
|
|
1839
|
+
chosen,
|
|
1840
|
+
reason,
|
|
1841
|
+
...(plan.selection !== null && plan.selection.ok !== true && Array.isArray(plan.selection.candidates) ? { candidates: plan.selection.candidates.slice(0, 5) } : {}),
|
|
1842
|
+
}
|
|
1843
|
+
// 精确指定但目录里没有 / 同名跨 provider 歧义 → **派活前拒绝**(绝不静默跑另一个模型)
|
|
1844
|
+
if (routePolicy.mode === 'exact' && chosen === null) {
|
|
1845
|
+
return refuseDispatch(REASONS.MODEL_ROUTE_INVALID, 'exact model request cannot be satisfied from the registered catalog: ' + reason + (task.route.candidates === undefined ? '' : ' (candidates: ' + task.route.candidates.map((c) => c.provider + '/' + c.model).join(', ') + ')'))
|
|
1846
|
+
}
|
|
862
1847
|
}
|
|
863
1848
|
|
|
864
1849
|
// 来源关联必须在**实际投递之前**落盘:绑定失败就不投递(宁可失败,也不出现"执行了却没绑定")。
|
|
@@ -870,34 +1855,84 @@ export async function apply(ctx) {
|
|
|
870
1855
|
pushTask(task.taskId, 'error', { error: task.error, reason: task.reason, target: { kind: 'session', sessionId: resolved.sessionId } })
|
|
871
1856
|
return { task, failed: { reason: bound.reason, message: bound.message, candidates: [] } }
|
|
872
1857
|
}
|
|
873
|
-
|
|
874
|
-
//
|
|
875
|
-
|
|
876
|
-
|
|
1858
|
+
// 模型路由选择:**绑定之后**才持久化(bind 时已带 route/routePolicy;这里只刷新非秘密回显字段,
|
|
1859
|
+
// 不再出现 "association update failed: task has no association record" 这种真实日志错误)。
|
|
1860
|
+
if (routePolicy !== undefined && mode !== 'steer') persistTask(task)
|
|
1861
|
+
|
|
1862
|
+
// 审批委托时序(方案 §4.1 / 验收要求):**grant 与 origin 绑定必须在发出 prompt/运行工具之前完成**,
|
|
1863
|
+
// 失败则零派活(这里直接返回 error,不进入 prompt/queue)。
|
|
1864
|
+
if (delegateOptions !== undefined && delegateOptions !== null) {
|
|
1865
|
+
const failDelegate = (reason, message) => {
|
|
1866
|
+
task.status = 'error'
|
|
1867
|
+
task.reason = reason
|
|
1868
|
+
task.error = message
|
|
1869
|
+
persistTask(task)
|
|
1870
|
+
pushTask(task.taskId, 'error', { error: task.error, reason: task.reason, target: { kind: 'session', sessionId: resolved.sessionId } })
|
|
1871
|
+
return { task, failed: { reason, message, candidates: [] } }
|
|
1872
|
+
}
|
|
1873
|
+
if (approvalStore === null) {
|
|
1874
|
+
return failDelegate('approval-store-unavailable', 'delegated dispatch refused: the approval store could not be opened (nothing was sent)')
|
|
1875
|
+
}
|
|
1876
|
+
const credDigest = approvalCredDigest()
|
|
1877
|
+
if (credDigest === null) {
|
|
1878
|
+
return failDelegate(APPROVAL_REASONS.NO_KEY, 'delegated dispatch refused: no valid control key on this gateway — initialize it offline first (nothing was sent)')
|
|
1879
|
+
}
|
|
1880
|
+
if (task.originThreadId === undefined) {
|
|
1881
|
+
return failDelegate(APPROVAL_REASONS.ORIGIN_REQUIRED, 'delegated dispatch refused: the grant must bind task + origin before dispatch (nothing was sent)')
|
|
1882
|
+
}
|
|
877
1883
|
try {
|
|
878
|
-
const
|
|
879
|
-
|
|
880
|
-
|
|
1884
|
+
const grant = approvalStore.issueGrant({
|
|
1885
|
+
originThreadId: task.originThreadId,
|
|
1886
|
+
taskId: task.taskId,
|
|
1887
|
+
scope: { kind: 'session', sessionId: resolved.sessionId },
|
|
1888
|
+
ttlMs: typeof delegateOptions.ttlSec === 'number' ? delegateOptions.ttlSec * 1000 : undefined,
|
|
1889
|
+
leaseMs: typeof delegateOptions.leaseSec === 'number' ? delegateOptions.leaseSec * 1000 : undefined,
|
|
1890
|
+
credDigest,
|
|
1891
|
+
authorization: delegateOptions.authorization,
|
|
1892
|
+
})
|
|
1893
|
+
approvalStore.audit({
|
|
1894
|
+
type: 'grant', phase: 'issued', grantId: grant.grantId, taskId: task.taskId,
|
|
1895
|
+
originThreadId: task.originThreadId, sessionId: resolved.sessionId,
|
|
1896
|
+
authorization: grant.authorization, how: 'delegate-dispatch',
|
|
1897
|
+
})
|
|
1898
|
+
task.grantId = grant.grantId
|
|
1899
|
+
task.grantExpiresAt = grant.expiresAt
|
|
1900
|
+
task.grantLeaseUntil = grant.leaseUntil
|
|
881
1901
|
} catch (e) {
|
|
882
|
-
|
|
1902
|
+
return failDelegate('grant-issue-failed', 'delegated dispatch refused: could not issue the task grant (' + errText(e) + ') — nothing was sent')
|
|
883
1903
|
}
|
|
884
1904
|
}
|
|
1905
|
+
|
|
1906
|
+
// Exact RPC/turn tracking needs only a sequence floor and open-turn metadata.
|
|
1907
|
+
// Legacy callers still need their old text baseline, but borrow rather than clone it.
|
|
1908
|
+
let baseline = { count: 0, textLen: 0 }
|
|
1909
|
+
const cut = originThreadId !== undefined
|
|
1910
|
+
? await observations.refresh(resolved.sessionId, { fresh: true })
|
|
1911
|
+
: await observations.capture(resolved.sessionId)
|
|
1912
|
+
if (originThreadId === undefined && cut) baseline = baselineOf(cut.events)
|
|
885
1913
|
const record = {
|
|
886
|
-
sessionId: resolved.sessionId, taskId: task.taskId, deliver, baseline,
|
|
887
|
-
|
|
888
|
-
|
|
1914
|
+
sessionId: resolved.sessionId, taskId: task.taskId, deliver: mode, baseline, total: '', sawRunning: false, activeSince: Date.now(), startedAt: Date.now(),
|
|
1915
|
+
settled: false, abort: taskAbort, watcher: 'pending',
|
|
1916
|
+
observedSeq: cut?.cursor ?? -1,
|
|
889
1917
|
// 精确关联模式(带来源):靠 requestId→rpcId→turn 关联收结果,而不是派发前基线 + 全会话 idle。
|
|
890
1918
|
precise: originThreadId !== undefined,
|
|
891
|
-
tracker: createRequestTracker(task.taskId),
|
|
1919
|
+
tracker: createRequestTracker(task.taskId, cut?.cursor),
|
|
892
1920
|
reported: 0, // prompt 被接受 = 消息已进会话队列。此后就不该再把它当"没发出去"(见 sweepSessionTargets)。
|
|
893
1921
|
delivered: false, queuedNoted: false,
|
|
1922
|
+
// 投递控制:回执记录与父任务同 turn,绝不参与审批归属、也绝不等父任务的最终结果。
|
|
1923
|
+
receipt: mode === 'steer',
|
|
1924
|
+
parentTaskId,
|
|
1925
|
+
// 服务端捕获的目标父回合(B):观察时以它为准,expectedTurn 只是可选校验。
|
|
1926
|
+
parentTurn: task.parentTurn,
|
|
894
1927
|
}
|
|
895
|
-
|
|
1928
|
+
record.tracker.openTurns = openTurnsOf(cut?.evidence || cut?.events || [])
|
|
1929
|
+
sessionTargets.set(task.taskId, record)
|
|
1930
|
+
if (mode !== 'steer') watchSessionTask(record)
|
|
896
1931
|
|
|
897
1932
|
try {
|
|
898
1933
|
const controllerRef = sessionControllerNow()
|
|
899
1934
|
if (controllerRef === undefined) {
|
|
900
|
-
sessionTargets.delete(
|
|
1935
|
+
sessionTargets.delete(task.taskId)
|
|
901
1936
|
record.settled = true
|
|
902
1937
|
task.status = 'error'
|
|
903
1938
|
task.error = 'sessionController service is not mounted in this profile'
|
|
@@ -908,12 +1943,14 @@ export async function apply(ctx) {
|
|
|
908
1943
|
await controllerRef.prompt({
|
|
909
1944
|
requestId: task.taskId,
|
|
910
1945
|
sessionId: resolved.sessionId,
|
|
911
|
-
mode
|
|
1946
|
+
mode,
|
|
912
1947
|
content: [{ type: 'text', text: instruction }],
|
|
913
|
-
},
|
|
1948
|
+
}, taskAbort.signal)
|
|
1949
|
+
// 模型路由耗尽时 abort 的是**这个**任务的作用域 signal:观察循环据此收口(不误伤别的任务)。
|
|
1950
|
+
record.abort = taskAbort
|
|
914
1951
|
} catch (e) {
|
|
915
1952
|
const reason = mapControllerError(e)
|
|
916
|
-
sessionTargets.delete(
|
|
1953
|
+
sessionTargets.delete(task.taskId)
|
|
917
1954
|
record.settled = true
|
|
918
1955
|
task.status = 'error'
|
|
919
1956
|
task.error = errText(e)
|
|
@@ -922,14 +1959,22 @@ export async function apply(ctx) {
|
|
|
922
1959
|
return { task, failed: { reason, message: task.error, candidates: [] } }
|
|
923
1960
|
}
|
|
924
1961
|
|
|
925
|
-
watchSessionTask(record)
|
|
926
1962
|
record.delivered = true
|
|
927
1963
|
task.delivered = true
|
|
928
|
-
|
|
1964
|
+
if (mode === 'steer') {
|
|
1965
|
+
// 回执:只观察"自己的 rpcId 落进父回合"(有界 ≤30s),不等父任务结果(父任务唯一拥有最终结果)。
|
|
1966
|
+
void observeSteerReceipt(record, task)
|
|
1967
|
+
pushTask(task.taskId, 'receipt-accepted', {
|
|
1968
|
+
parentTaskId, requestId: task.taskId, deliver: 'steer', resultScope: 'parent-task',
|
|
1969
|
+
target: { kind: 'session', sessionId: resolved.sessionId },
|
|
1970
|
+
})
|
|
1971
|
+
return { task, failed: null }
|
|
1972
|
+
}
|
|
1973
|
+
pushTask(task.taskId, 'task-accepted', { target: { kind: 'session', sessionId: resolved.sessionId }, deliver: mode, cwd: resolved.snippet, delivered: true })
|
|
929
1974
|
return { task, failed: null }
|
|
930
1975
|
}
|
|
931
1976
|
|
|
932
|
-
const enqueue = async (instruction, incomingHistory, target, modelAlias, kind, deliveryMode, notes, originThreadId) => {
|
|
1977
|
+
const enqueue = async (instruction, incomingHistory, target, modelAlias, kind, deliveryMode, notes, originThreadId, delegateOptions, steering, routePolicy) => {
|
|
933
1978
|
if (incomingHistory !== undefined) syncHistory(incomingHistory)
|
|
934
1979
|
if (target !== undefined && target.kind === 'session') {
|
|
935
1980
|
// session 目标:不注册工作区、不建 owner、不建子代理,失败也不降级为新建。
|
|
@@ -939,6 +1984,9 @@ export async function apply(ctx) {
|
|
|
939
1984
|
Array.isArray(notes) ? notes : [],
|
|
940
1985
|
incomingHistory,
|
|
941
1986
|
originThreadId,
|
|
1987
|
+
delegateOptions,
|
|
1988
|
+
steering,
|
|
1989
|
+
routePolicy,
|
|
942
1990
|
)
|
|
943
1991
|
return dispatched.task
|
|
944
1992
|
}
|
|
@@ -948,7 +1996,7 @@ export async function apply(ctx) {
|
|
|
948
1996
|
const bucket = bucketOf(cwd, lane)
|
|
949
1997
|
// 同一通道(cwd+lane)只允许一个在途任务:完成事件只能定位到"这个桶里最老的 pending",
|
|
950
1998
|
// 两个并发任务会让结果归属变成猜测。这里明确拒绝(上一任务结束后再派下一条;不同 lane 不受影响)。
|
|
951
|
-
const activeInBucket = tasks.find((t) => (t.status === 'accepted' || t.status === 'running') && t.bucketKey === bucket.key)
|
|
1999
|
+
const activeInBucket = tasks.find((t) => (t.status === 'accepted' || t.status === 'running' || (t.status === 'unknown' && t.taskId === bucket.executingTaskId)) && t.bucketKey === bucket.key)
|
|
952
2000
|
if (activeInBucket !== undefined) {
|
|
953
2001
|
return rejectBusy({
|
|
954
2002
|
kind, instruction, cwd, lane, bucketKey: bucket.key,
|
|
@@ -978,17 +2026,51 @@ export async function apply(ctx) {
|
|
|
978
2026
|
pushTask(task.taskId, 'error', { error: task.error, reason: task.reason, cwd, kind })
|
|
979
2027
|
return task
|
|
980
2028
|
}
|
|
2029
|
+
// 模型路由(M2):worker 也要在**首次请求之前**有归属——预先分配 childId 并登记计划,
|
|
2030
|
+
// 由 agent/inbox/claimed 用真实 agent/owner 核对后绑定(provider 不支持预分配时不会误绑)。
|
|
2031
|
+
if (routePolicy !== undefined) {
|
|
2032
|
+
// worker 的 owner 必须在**登记路由之前**确定:claim 时用官方 isOwnedBy 核验,
|
|
2033
|
+
// 拿不到可信 owner 就不登记(fail-closed,绝不按 childId 撞一个就算接管)。
|
|
2034
|
+
const routeOwner = await ensureOwner(bucket)
|
|
2035
|
+
const reuseChildId = bucket.childId !== null && bucket.childId !== undefined ? bucket.childId : null
|
|
2036
|
+
const expectedChildId = reuseChildId !== null ? reuseChildId : 'dsh-bridge-child-' + uid()
|
|
2037
|
+
const plan = await modelRouting.planRoute({
|
|
2038
|
+
taskId: task.taskId, policy: routePolicy, kind, execution: 'worker', originThreadId, childId: expectedChildId,
|
|
2039
|
+
expectedOwner: routeOwner !== undefined && routeOwner !== null && typeof routeOwner.id === 'string' ? routeOwner.id : undefined,
|
|
2040
|
+
onTerminal: (info) => { task.routeStopReason = info !== null && typeof info === 'object' && typeof info.reason === 'string' ? info.reason : 'terminal' },
|
|
2041
|
+
onExhausted: (reason) => { task.routeStopReason = reason },
|
|
2042
|
+
})
|
|
2043
|
+
bucket.expectedChildId = expectedChildId
|
|
2044
|
+
const chosen = plan.selection !== null && plan.selection.ok === true ? { provider: plan.selection.pair.provider, model: plan.selection.pair.model } : null
|
|
2045
|
+
const reason = plan.selection === null ? 'inherit' : plan.selection.reason
|
|
2046
|
+
task.routePolicy = routePolicy
|
|
2047
|
+
task.route = {
|
|
2048
|
+
mode: routePolicy.mode,
|
|
2049
|
+
allowFallback: routePolicy.allowFallback,
|
|
2050
|
+
chosen,
|
|
2051
|
+
reason,
|
|
2052
|
+
expectedChildId,
|
|
2053
|
+
...(plan.selection !== null && plan.selection.ok !== true && Array.isArray(plan.selection.candidates) ? { candidates: plan.selection.candidates.slice(0, 5) } : {}),
|
|
2054
|
+
}
|
|
2055
|
+
// 精确指定但目录里没有 / 同名跨 provider 歧义 → **派活前拒绝**(与 session 目标同一契约)
|
|
2056
|
+
if (routePolicy.mode === 'exact' && chosen === null) {
|
|
2057
|
+
task.status = 'error'
|
|
2058
|
+
task.error = 'exact model request cannot be satisfied from the registered catalog: ' + reason
|
|
2059
|
+
task.reason = REASONS.MODEL_ROUTE_INVALID
|
|
2060
|
+
task.candidates = task.route.candidates === undefined ? [] : task.route.candidates
|
|
2061
|
+
pushTask(task.taskId, 'error', { error: task.error, reason: task.reason, cwd, kind })
|
|
2062
|
+
return task
|
|
2063
|
+
}
|
|
2064
|
+
}
|
|
981
2065
|
try {
|
|
982
|
-
const delivered = await deliver(bucket, instruction, ROLE_BRIEF(cwd), '[Codex 指令] ')
|
|
2066
|
+
const delivered = await deliver(bucket, instruction, ROLE_BRIEF(cwd), '[Codex 指令] ', task.taskId)
|
|
983
2067
|
if (delivered.fallback) {
|
|
984
|
-
|
|
985
|
-
task.
|
|
986
|
-
task.
|
|
987
|
-
task.
|
|
988
|
-
remember('user', instruction)
|
|
989
|
-
remember('assistant', task.result)
|
|
2068
|
+
// 兜底路径**不再冒充成功**:worker 继续失败必须如实 error(不重派、不 one-shot 重做)。
|
|
2069
|
+
task.status = 'error'
|
|
2070
|
+
task.error = 'worker continuation degraded: ' + delivered.fallbackNote
|
|
2071
|
+
task.reason = 'worker-continuation-degraded'
|
|
990
2072
|
persistTask(task)
|
|
991
|
-
pushTask(task.taskId, '
|
|
2073
|
+
pushTask(task.taskId, 'error', { error: task.error, reason: task.reason, cwd, kind })
|
|
992
2074
|
} else {
|
|
993
2075
|
bucket.activeSince = Date.now()
|
|
994
2076
|
pushTask(task.taskId, 'task-accepted', { childId: bucket.childId, cwd, kind })
|
|
@@ -1002,12 +2084,12 @@ export async function apply(ctx) {
|
|
|
1002
2084
|
return task
|
|
1003
2085
|
}
|
|
1004
2086
|
|
|
1005
|
-
const submitReview = async (rawCwd, diff, focus, incomingHistory, originThreadId) => {
|
|
2087
|
+
const submitReview = async (rawCwd, diff, focus, incomingHistory, originThreadId, routePolicy) => {
|
|
1006
2088
|
if (incomingHistory !== undefined) syncHistory(incomingHistory)
|
|
1007
2089
|
const cwd = normalizeCwd(rawCwd)
|
|
1008
2090
|
const bucket = reviewerBucketOf(cwd)
|
|
1009
2091
|
// 评审桶同理:同一目录只允许一个在途评审,否则完成结果无法归属到具体 taskId。
|
|
1010
|
-
const activeReview = tasks.find((t) => (t.status === 'accepted' || t.status === 'running') && t.bucketKey === bucket.key)
|
|
2092
|
+
const activeReview = tasks.find((t) => (t.status === 'accepted' || t.status === 'running' || (t.status === 'unknown' && t.taskId === bucket.executingTaskId)) && t.bucketKey === bucket.key)
|
|
1011
2093
|
if (activeReview !== undefined) {
|
|
1012
2094
|
return rejectBusy({
|
|
1013
2095
|
kind: 'review', instruction: '(review rejected: channel busy)', cwd, lane: '@review', bucketKey: bucket.key,
|
|
@@ -1021,6 +2103,36 @@ export async function apply(ctx) {
|
|
|
1021
2103
|
const instruction = ['【变更 diff】\n' + (typeof diff === 'string' && diff.trim() !== '' ? diff.slice(0, 60000) : '(未提供 diff)'), '【审查重点】\n' + (typeof focus === 'string' && focus.trim() !== '' ? focus.slice(0, 4000) : '(未指定,请按一般标准审查)')].join('\n\n')
|
|
1022
2104
|
const task = { taskId: uid(), kind: 'review', instruction, cwd, lane: '@review', bucketKey: bucket.key, target: { kind: 'worker', cwd, lane: '@review' }, status: 'accepted', time: Date.now(), result: undefined, error: undefined }
|
|
1023
2105
|
rememberTask(tasks, task)
|
|
2106
|
+
// 模型路由(评审入口):与编码走**同一任务级机制**——业务 kind='review'、执行目标 worker;
|
|
2107
|
+
// 默认 auto 优先兼容 DeepSeek、支持显式 pair/default/禁 fallback、有界同 turn/step 恢复与独立审计。
|
|
2108
|
+
if (routePolicy !== undefined) {
|
|
2109
|
+
task.routePolicy = routePolicy
|
|
2110
|
+
const routeOwner = await ensureOwner(bucket)
|
|
2111
|
+
const reuseChildId = bucket.childId !== null && bucket.childId !== undefined ? bucket.childId : null
|
|
2112
|
+
const expectedChildId = reuseChildId !== null ? reuseChildId : 'dsh-bridge-child-' + uid()
|
|
2113
|
+
const plan = await modelRouting.planRoute({
|
|
2114
|
+
taskId: task.taskId, policy: { ...routePolicy, taskKind: 'review' }, kind: 'review', execution: 'worker',
|
|
2115
|
+
originThreadId, childId: expectedChildId,
|
|
2116
|
+
expectedOwner: routeOwner !== undefined && routeOwner !== null && typeof routeOwner.id === 'string' ? routeOwner.id : undefined,
|
|
2117
|
+
onExhausted: (reason) => { task.routeStopReason = reason },
|
|
2118
|
+
})
|
|
2119
|
+
bucket.expectedChildId = expectedChildId
|
|
2120
|
+
const chosen = plan.selection !== null && plan.selection.ok === true ? { provider: plan.selection.pair.provider, model: plan.selection.pair.model } : null
|
|
2121
|
+
const routeReason = plan.selection === null ? 'inherit' : plan.selection.reason
|
|
2122
|
+
task.route = {
|
|
2123
|
+
mode: routePolicy.mode, allowFallback: routePolicy.allowFallback, chosen, reason: routeReason,
|
|
2124
|
+
...(plan.selection !== null && plan.selection.ok !== true && Array.isArray(plan.selection.candidates) ? { candidates: plan.selection.candidates.slice(0, 5) } : {}),
|
|
2125
|
+
}
|
|
2126
|
+
// 精确指定但目录里没有 / 同名跨 provider 歧义 → 派活前拒绝(评审也不跑"另一个模型")
|
|
2127
|
+
if (routePolicy.mode === 'exact' && chosen === null) {
|
|
2128
|
+
task.status = 'error'
|
|
2129
|
+
task.error = 'exact model request cannot be satisfied from the registered catalog: ' + routeReason
|
|
2130
|
+
task.reason = REASONS.MODEL_ROUTE_INVALID
|
|
2131
|
+
task.candidates = task.route.candidates === undefined ? [] : task.route.candidates
|
|
2132
|
+
pushTask(task.taskId, 'error', { error: task.error, reason: task.reason, cwd, kind: 'review' })
|
|
2133
|
+
return task
|
|
2134
|
+
}
|
|
2135
|
+
}
|
|
1024
2136
|
// 评审同样产生 taskId,因此同样支持来源关联;绑定失败不派活。
|
|
1025
2137
|
const bound = bindAssociation(task, originThreadId)
|
|
1026
2138
|
if (!bound.ok) {
|
|
@@ -1031,7 +2143,7 @@ export async function apply(ctx) {
|
|
|
1031
2143
|
return task
|
|
1032
2144
|
}
|
|
1033
2145
|
try {
|
|
1034
|
-
const delivered = await deliver(bucket, instruction, REVIEWER_BRIEF(cwd), '[评审请求] ')
|
|
2146
|
+
const delivered = await deliver(bucket, instruction, REVIEWER_BRIEF(cwd), '[评审请求] ', task.taskId)
|
|
1035
2147
|
if (delivered.fallback) {
|
|
1036
2148
|
task.status = 'done'
|
|
1037
2149
|
task.result = delivered.output
|
|
@@ -1055,32 +2167,39 @@ export async function apply(ctx) {
|
|
|
1055
2167
|
}
|
|
1056
2168
|
|
|
1057
2169
|
// assistantTextOf / textOfBlocks 由 session-target.mjs 提供(同一套语义,可单测)。
|
|
2170
|
+
const observeWorkerEvent = (bucket, event) => {
|
|
2171
|
+
if (event.seq <= (bucket.observedSeq ?? -1)) return
|
|
2172
|
+
bucket.observedSeq = event.seq
|
|
2173
|
+
const active = tasks.find(t => (t.status === 'accepted' || t.status === 'running') && t.bucketKey === bucket.key)
|
|
2174
|
+
if (!active) return
|
|
2175
|
+
bucket.activeSince = Date.now()
|
|
2176
|
+
const text = assistantTextOf(event)
|
|
2177
|
+
if (!text) return
|
|
2178
|
+
bucket.progressTail = ((bucket.progressTail || '') + text + '\n').slice(-4000)
|
|
2179
|
+
active.status = 'running'
|
|
2180
|
+
pushTask(active.taskId, 'progress', { text, cwd: bucket.cwd, kind: bucket.lane === '@review' ? 'review' : 'task' })
|
|
2181
|
+
}
|
|
1058
2182
|
const pollOne = async (bucket) => {
|
|
1059
2183
|
if (bucket.childId === null || sessionQuery === undefined) return
|
|
1060
2184
|
const hasPending = tasks.some((t) => (t.status === 'accepted' || t.status === 'running') && t.bucketKey === bucket.key)
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
const
|
|
1067
|
-
bucket.lastSeen = total.length
|
|
1068
|
-
const newEvents = events.length > bucket.seenCount
|
|
1069
|
-
bucket.seenCount = events.length
|
|
1070
|
-
if (!hasPending) return
|
|
1071
|
-
if (newEvents) bucket.activeSince = Date.now()
|
|
1072
|
-
if (fresh.trim() !== '') {
|
|
1073
|
-
const active = tasks.find((t) => (t.status === 'accepted' || t.status === 'running') && t.bucketKey === bucket.key)
|
|
1074
|
-
if (active !== undefined) active.status = 'running'
|
|
1075
|
-
pushTask(active !== undefined ? active.taskId : bucket.childId, 'progress', { text: fresh.trim(), cwd: bucket.cwd, kind: bucket.lane === '@review' ? 'review' : 'task' })
|
|
1076
|
-
}
|
|
2185
|
+
if (!hasPending) { bucket.observationAbort?.abort(); return }
|
|
2186
|
+
const snap = await observations.since(bucket.childId, bucket.observedSeq ?? -1)
|
|
2187
|
+
if (!snap) return
|
|
2188
|
+
for (const event of snap.events) observeWorkerEvent(bucket, event)
|
|
2189
|
+
bucket.observedSeq = Math.max(bucket.observedSeq ?? -1, snap.cursor)
|
|
2190
|
+
const total = bucket.progressTail || ''
|
|
1077
2191
|
if (bucket.activeSince > 0 && Date.now() - bucket.activeSince > 600000) {
|
|
1078
2192
|
const active = tasks.find((t) => (t.status === 'accepted' || t.status === 'running') && t.bucketKey === bucket.key)
|
|
1079
2193
|
if (active !== undefined && total.length > 0) {
|
|
1080
|
-
active.status = '
|
|
1081
|
-
active.
|
|
2194
|
+
active.status = 'unknown'
|
|
2195
|
+
active.awaitingOfficialEnd = true
|
|
2196
|
+
active.partialResult = total.trim()
|
|
2197
|
+
active.reason = REASONS.UNCONFIRMED
|
|
2198
|
+
active.resend = false
|
|
2199
|
+
active.error = 'worker stopped producing progress without an official completion; do not resend'
|
|
1082
2200
|
persistTask(active)
|
|
1083
|
-
pushTask(active.taskId, '
|
|
2201
|
+
pushTask(active.taskId, 'unknown', { error: active.error, reason: active.reason, partialResult: active.partialResult, resend: false, stopReason: 'quiet-timeout', childId: bucket.childId, cwd: bucket.cwd, kind: bucket.lane === '@review' ? 'review' : 'task' })
|
|
2202
|
+
bucket.observationAbort?.abort()
|
|
1084
2203
|
bucket.activeSince = 0
|
|
1085
2204
|
}
|
|
1086
2205
|
}
|
|
@@ -1091,12 +2210,21 @@ export async function apply(ctx) {
|
|
|
1091
2210
|
if (record.settled) continue
|
|
1092
2211
|
const task = tasks.find((t) => t.taskId === record.taskId)
|
|
1093
2212
|
if (task === undefined) { settleSessionTask(record, 'cancelled', {}); continue }
|
|
1094
|
-
if (task.status === 'cancelled') { record.settled = true; if (record.abort !== undefined) { try { record.abort.abort() } catch (e) { /* ignore */ } } sessionTargets.delete(record.
|
|
2213
|
+
if (task.status === 'cancelled') { record.settled = true; if (record.abort !== undefined) { try { record.abort.abort() } catch (e) { /* ignore */ } } sessionTargets.delete(record.taskId); continue }
|
|
1095
2214
|
const now = Date.now()
|
|
2215
|
+
// 回执(第四轮首次验收修正 C):**绝不**进入通用执行收口。
|
|
2216
|
+
// 它的生命周期只由 observeSteerReceipt/refreshReceiptNow 用"自身 rpcId 落进哪个回合"驱动,
|
|
2217
|
+
// 因此这里既不喂通用 tracker(会累计父任务助手文本),也不允许 settleFromTracker 抢结算/落盘父结果。
|
|
2218
|
+
if (record.receipt === true) continue
|
|
1096
2219
|
// 精确关联模式:轮询/读日志只是观察机会,收尾只能由"自己那个 turn 的 turn/end"触发。
|
|
1097
2220
|
if (record.precise === true) {
|
|
1098
|
-
await observeSessionLog(record)
|
|
2221
|
+
if (!observations.healthy(record.sessionId)) await observeSessionLog(record)
|
|
1099
2222
|
if (record.settled) continue
|
|
2223
|
+
// 按"自己的证据"推进 accepted/queued → running(全会话 running 事件只是机会,不是判定)。
|
|
2224
|
+
if (record.receipt !== true && (task.status === 'accepted' || task.status === 'queued') && isExecutingRecord(record)) {
|
|
2225
|
+
task.status = 'running'
|
|
2226
|
+
pushTask(task.taskId, 'running', { target: { kind: 'session', sessionId: record.sessionId }, deliver: record.deliver })
|
|
2227
|
+
}
|
|
1100
2228
|
if (record.tracker.end !== undefined) { settleFromTracker(record); continue }
|
|
1101
2229
|
if (record.watcher !== 'follow') pushPreciseProgress(record)
|
|
1102
2230
|
if (!record.sawRunning && now - record.startedAt > SESSION_DISPATCH_TIMEOUT_MS && record.delivered === true && record.queuedNoted !== true) {
|
|
@@ -1112,9 +2240,26 @@ export async function apply(ctx) {
|
|
|
1112
2240
|
settleSessionTask(record, 'error', { error: 'the session never accepted the prompt', reason: REASONS.NOT_ACCEPTED, delivered: false })
|
|
1113
2241
|
continue
|
|
1114
2242
|
}
|
|
2243
|
+
// 等待审批期间:**不按 quiet/派发超时判完成或失败**(有效租约必须被尊重);
|
|
2244
|
+
// 观察预算 = 有效认证委托的 grant 有效期(blocker 3),未委托/失联/撤销/到期才回到旧预算。
|
|
2245
|
+
const delegated = activeDelegatedGrantFor(task)
|
|
2246
|
+
const budgetMs = observationBudgetMs(record, delegated)
|
|
2247
|
+
if (task.status === 'needs-approval') {
|
|
2248
|
+
if (now - record.startedAt > budgetMs) {
|
|
2249
|
+
settleSessionTask(record, 'unknown', {
|
|
2250
|
+
error: '等待审批超过了有效观察窗口(委托 grant 已到期/撤销/失联,或未委托时超过总预算);执行状态未确认,不要重发(审批链已交还人工)',
|
|
2251
|
+
reason: REASONS.UNCONFIRMED,
|
|
2252
|
+
resend: false,
|
|
2253
|
+
note: delegated === null ? 'approval wait exceeded SESSION_TOTAL_TIMEOUT_MS' : 'delegated grant window closed (' + delegated.grantId + ')',
|
|
2254
|
+
...(record.delivered === true ? { delivered: true } : {}),
|
|
2255
|
+
})
|
|
2256
|
+
}
|
|
2257
|
+
continue
|
|
2258
|
+
}
|
|
1115
2259
|
// quiet / 总预算到期但**没有**自己 turn 的结束证据:只能报未确认(resend:false),绝不猜 done。
|
|
1116
|
-
|
|
1117
|
-
const
|
|
2260
|
+
// 有认证活跃委托时不做 quiet 判定(慢模型长时间无输出是正常的,grant 有效期才是界)。
|
|
2261
|
+
const quietExpired = delegated === null && record.sawRunning && record.activeSince > 0 && now - record.activeSince > SESSION_QUIET_TIMEOUT_MS
|
|
2262
|
+
const budgetExpired = now - record.startedAt > budgetMs
|
|
1118
2263
|
if (quietExpired || budgetExpired) {
|
|
1119
2264
|
settleSessionTask(record, 'unknown', {
|
|
1120
2265
|
error: record.delivered === true
|
|
@@ -1130,8 +2275,9 @@ export async function apply(ctx) {
|
|
|
1130
2275
|
}
|
|
1131
2276
|
if (record.watcher === 'poll' && sessionQuery !== undefined) {
|
|
1132
2277
|
try {
|
|
1133
|
-
const snap = await
|
|
2278
|
+
const snap = await observations.capture(record.sessionId)
|
|
1134
2279
|
const events = snap !== undefined && snap !== null && Array.isArray(snap.events) ? snap.events : []
|
|
2280
|
+
observeApprovalEvents(record.sessionId, events)
|
|
1135
2281
|
const extracted = extractNewAssistantText(events, record.baseline)
|
|
1136
2282
|
record.baseline = extracted.baseline
|
|
1137
2283
|
if (extracted.text.trim() !== '') {
|
|
@@ -1142,6 +2288,21 @@ export async function apply(ctx) {
|
|
|
1142
2288
|
}
|
|
1143
2289
|
} catch (e) { /* 读不到就下一轮再试 */ }
|
|
1144
2290
|
}
|
|
2291
|
+
// 等待审批期间不走 legacy 的 quiet/total/派发超时;观察预算同上(有效认证委托按 grant 有效期)。
|
|
2292
|
+
const delegated = activeDelegatedGrantFor(task)
|
|
2293
|
+
const budgetMs = observationBudgetMs(record, delegated)
|
|
2294
|
+
if (task.status === 'needs-approval') {
|
|
2295
|
+
if (now - record.startedAt > budgetMs) {
|
|
2296
|
+
settleSessionTask(record, 'unknown', {
|
|
2297
|
+
error: '等待审批超过了有效观察窗口(委托 grant 已到期/撤销/失联,或未委托时超过总预算);执行状态未确认,不要重发(审批链已交还人工)',
|
|
2298
|
+
reason: REASONS.UNCONFIRMED,
|
|
2299
|
+
resend: false,
|
|
2300
|
+
note: delegated === null ? 'approval wait exceeded SESSION_TOTAL_TIMEOUT_MS' : 'delegated grant window closed (' + delegated.grantId + ')',
|
|
2301
|
+
...(record.delivered === true ? { delivered: true } : {}),
|
|
2302
|
+
})
|
|
2303
|
+
}
|
|
2304
|
+
continue
|
|
2305
|
+
}
|
|
1145
2306
|
// 派发超时只在"prompt 没被接受"时才算失败。
|
|
1146
2307
|
// queue 模式下 prompt 被接受就意味着消息已进队列,会在会话空闲时跑;此时报 error
|
|
1147
2308
|
// 是误报——实测会话正跑一个 38 分钟的回合,排队等待远超 120s 完全正常,而误报会
|
|
@@ -1163,11 +2324,12 @@ export async function apply(ctx) {
|
|
|
1163
2324
|
continue
|
|
1164
2325
|
}
|
|
1165
2326
|
}
|
|
1166
|
-
|
|
2327
|
+
// 有认证活跃委托时不按 quiet 判完成(慢模型长时间无输出正常;grant 有效期才是界)。
|
|
2328
|
+
if (delegated === null && record.sawRunning && record.activeSince > 0 && now - record.activeSince > SESSION_QUIET_TIMEOUT_MS) {
|
|
1167
2329
|
finishSessionTask(record, 'quiet-timeout')
|
|
1168
2330
|
continue
|
|
1169
2331
|
}
|
|
1170
|
-
if (now - record.startedAt >
|
|
2332
|
+
if (now - record.startedAt > budgetMs) {
|
|
1171
2333
|
settleSessionTask(record, 'error', {
|
|
1172
2334
|
error: record.delivered === true
|
|
1173
2335
|
? 'prompt 已投递进该会话队列,但预算内它始终没有开始这一轮(会话可能一直在忙,或未被激活);不要重发,可用 status 继续观察'
|
|
@@ -1184,28 +2346,111 @@ export async function apply(ctx) {
|
|
|
1184
2346
|
{
|
|
1185
2347
|
ctx.effect(() => {
|
|
1186
2348
|
const off = ctx.on('api-session/status', (sessionId, running) => {
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
const
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
if (
|
|
1194
|
-
|
|
2349
|
+
// 全会话 running/idle 只是"再观察一次"的机会(第四轮 D1):绝不据此把同会话的其它任务标 running/done。
|
|
2350
|
+
// 每个任务仍必须靠**自己**的 rpcId→turn/end 结算(settleFromTracker / finishSessionTask)。
|
|
2351
|
+
for (const record of sessionRecords(sessionId)) {
|
|
2352
|
+
const task = taskById(record.taskId)
|
|
2353
|
+
if (task === undefined) continue
|
|
2354
|
+
// 回执:共享 running/idle 只当"再看一眼自身证据"的机会,绝不进通用执行收口(C)。
|
|
2355
|
+
if (record.receipt === true) {
|
|
2356
|
+
if (running === true) record.sawRunning = true
|
|
2357
|
+
void refreshReceiptNow(record, task)
|
|
2358
|
+
continue
|
|
2359
|
+
}
|
|
2360
|
+
if (running === true) {
|
|
2361
|
+
record.sawRunning = true
|
|
2362
|
+
record.activeSince = Date.now()
|
|
2363
|
+
// 先读一次日志把 rpcId 喂给关联器,再按"自己的证据"决定是否 running(否则会误标排队中的任务)。
|
|
2364
|
+
void (async () => {
|
|
2365
|
+
await observeSessionLog(record)
|
|
2366
|
+
if (record.settled) return
|
|
2367
|
+
if ((task.status === 'accepted' || task.status === 'queued') && isExecutingRecord(record)) {
|
|
2368
|
+
task.status = 'running'
|
|
2369
|
+
pushTask(task.taskId, 'running', { target: { kind: 'session', sessionId: record.sessionId }, deliver: record.deliver })
|
|
2370
|
+
}
|
|
2371
|
+
})()
|
|
2372
|
+
continue
|
|
2373
|
+
}
|
|
2374
|
+
if (running === false && record.sawRunning) finishSessionTask(record, 'stop')
|
|
1195
2375
|
}
|
|
1196
|
-
if (running === false && record.sawRunning) finishSessionTask(record, 'stop')
|
|
1197
2376
|
})
|
|
1198
2377
|
return () => { if (typeof off === 'function') off() }
|
|
1199
2378
|
})
|
|
1200
2379
|
}
|
|
1201
2380
|
|
|
2381
|
+
// ---- 正式审批接线(M0 证据:cordis:317-325 组合语义 / user-approval:176 waterfall /
|
|
2382
|
+
// acp:1116 无 callId 即 next / ui-approval:284 只在自己会话内接管)
|
|
2383
|
+
// 1) tools/pre-execute 快照:真实参数的唯一来源(客户端不得代传);只记录,原链透传。
|
|
2384
|
+
ctx.effect(() => {
|
|
2385
|
+
const off = ctx.on('tools/pre-execute', (exec, next) => {
|
|
2386
|
+
try {
|
|
2387
|
+
const session = exec !== null && typeof exec === 'object' && exec.agent !== undefined && exec.agent !== null ? exec.agent.session : undefined
|
|
2388
|
+
const sessionId = session !== undefined && session !== null && typeof session.id === 'string' ? session.id : undefined
|
|
2389
|
+
if (approvalStore !== null && typeof exec.callId === 'string' && exec.callId !== '' && sessionId !== undefined) {
|
|
2390
|
+
// cwd:真实来源是 `exec.agent.session.header.cwd`(dsh-tools:1423 同一取值路径;dsh-session:1044 保证
|
|
2391
|
+
// 为合法绝对路径)。exec 上**没有** cwd;exec.cwd 仅作镜像兜底,两者都没有 → 身份不完整(unsupported)。
|
|
2392
|
+
let cwd
|
|
2393
|
+
let cwdSource
|
|
2394
|
+
const headerCwd = session.header !== undefined && session.header !== null ? session.header.cwd : undefined
|
|
2395
|
+
if (typeof headerCwd === 'string' && headerCwd !== '') { cwd = headerCwd; cwdSource = 'agent.session.header.cwd' }
|
|
2396
|
+
else if (typeof exec.cwd === 'string' && exec.cwd !== '') { cwd = exec.cwd; cwdSource = 'exec.cwd' }
|
|
2397
|
+
// parent:官方真实字段(嵌套/PTC 派发时是父执行的 token;顶层通常没有)
|
|
2398
|
+
approvalStore.snapshotCall({
|
|
2399
|
+
sessionId, callId: exec.callId, rootCallId: exec.rootCallId, parent: exec.parent,
|
|
2400
|
+
name: exec.name, args: exec.arguments, cwd, cwdSource,
|
|
2401
|
+
})
|
|
2402
|
+
}
|
|
2403
|
+
} catch (e) {
|
|
2404
|
+
// 快照失败绝不影响工具执行链
|
|
2405
|
+
console.error('[dsh-bridge] approval snapshot failed:', errText(e).slice(0, 200))
|
|
2406
|
+
}
|
|
2407
|
+
return next()
|
|
2408
|
+
})
|
|
2409
|
+
return () => { if (typeof off === 'function') off() }
|
|
2410
|
+
})
|
|
2411
|
+
|
|
2412
|
+
// 2) approval/request answerer:prepend 抢先注册;严格谓词不成立 → 立即 next();
|
|
2413
|
+
// 内部异常 → next()(绝不 throw:官方 decide 把 throw 归一成 unavailable,会跳过人工链)。
|
|
2414
|
+
ctx.effect(() => {
|
|
2415
|
+
const off = ctx.on('approval/request', (req, next) => {
|
|
2416
|
+
try {
|
|
2417
|
+
return handleApprovalRequest(req).then(
|
|
2418
|
+
(outcome) => (typeof outcome === 'string' && outcome !== '' ? outcome : next()),
|
|
2419
|
+
(e) => {
|
|
2420
|
+
console.error('[dsh-bridge] approval answerer error → next():', errText(e).slice(0, 200))
|
|
2421
|
+
return next()
|
|
2422
|
+
},
|
|
2423
|
+
)
|
|
2424
|
+
} catch (e) {
|
|
2425
|
+
console.error('[dsh-bridge] approval answerer error → next():', errText(e).slice(0, 200))
|
|
2426
|
+
return next()
|
|
2427
|
+
}
|
|
2428
|
+
}, { prepend: true })
|
|
2429
|
+
return () => {
|
|
2430
|
+
// 卸载:把在途挂起项安全收口(绝不留下"可答但已无回调"的假状态)
|
|
2431
|
+
try {
|
|
2432
|
+
if (approvalStore !== null) approvalStore.closeLiveForTask(undefined, APPROVAL_STATES.SUPERSEDED, 'bridge plugin unloaded')
|
|
2433
|
+
} catch (e) { /* ignore */ }
|
|
2434
|
+
// 卸载后**零读取、零写盘**(第二次验收修正 3):把所有观察记录标 settled 并立刻唤醒它们的等待,
|
|
2435
|
+
// 这样首循环、后台续证、显式查询续证都立即停手,也不再空转定时器。
|
|
2436
|
+
for (const record of Array.from(sessionTargets.values())) {
|
|
2437
|
+
try { settleReceiptRecord(record) } catch (e) { /* ignore */ }
|
|
2438
|
+
}
|
|
2439
|
+
if (typeof off === 'function') off()
|
|
2440
|
+
}
|
|
2441
|
+
})
|
|
2442
|
+
|
|
2443
|
+
let sweeping = false
|
|
1202
2444
|
if (timer !== undefined) {
|
|
1203
2445
|
ctx.effect(() => timer.interval(() => {
|
|
2446
|
+
if (sweeping) return
|
|
2447
|
+
sweeping = true
|
|
1204
2448
|
void (async () => {
|
|
1205
2449
|
for (const bucket of buckets.values()) await pollOne(bucket)
|
|
1206
2450
|
for (const bucket of reviewerBuckets.values()) await pollOne(bucket)
|
|
1207
2451
|
await sweepSessionTargets()
|
|
1208
|
-
|
|
2452
|
+
observations.prune()
|
|
2453
|
+
})().catch((e) => console.error('[dsh-bridge] observation failed:', errText(e).slice(0, 200))).finally(() => { sweeping = false })
|
|
1209
2454
|
}, 2500))
|
|
1210
2455
|
}
|
|
1211
2456
|
|
|
@@ -1256,6 +2501,55 @@ export async function apply(ctx) {
|
|
|
1256
2501
|
sendJson(res, 400, { error: 'originThreadId must be a non-empty string (<= 200 chars, no control characters)', reason: ASSOC_REASONS.INVALID_ORIGIN })
|
|
1257
2502
|
return
|
|
1258
2503
|
}
|
|
2504
|
+
// 审批委托(M1/M2):`delegate:true` 必须由控制凭据鉴权(loopback/origin/task 都只是定位,
|
|
2505
|
+
// 不自证授权);worker 目标(child 身份未证明)、缺 key、缺来源一律在**发出 prompt 之前**拒绝
|
|
2506
|
+
// ——零执行,绝不出现"派活后才发现没接审批"。
|
|
2507
|
+
let delegateOptions
|
|
2508
|
+
if (parsed !== null && typeof parsed === 'object' && parsed.delegate === true) {
|
|
2509
|
+
const auth = approvalAuth(req)
|
|
2510
|
+
if (auth.ok !== true) {
|
|
2511
|
+
sendJson(res, 401, { error: 'delegated dispatch requires a valid control credential (initialize the control key first)', reason: auth.reason })
|
|
2512
|
+
return
|
|
2513
|
+
}
|
|
2514
|
+
if (origin.originThreadId === undefined) {
|
|
2515
|
+
sendJson(res, 400, { error: 'delegate requires originThreadId: the grant binds task + origin before anything is dispatched', reason: APPROVAL_REASONS.ORIGIN_REQUIRED })
|
|
2516
|
+
return
|
|
2517
|
+
}
|
|
2518
|
+
if (classified.target.kind !== 'session') {
|
|
2519
|
+
sendJson(res, 400, {
|
|
2520
|
+
error: 'approval delegation does not support worker targets (the executing child identity is unproven); dispatch without delegate to keep the legacy worker path',
|
|
2521
|
+
reason: APPROVAL_REASONS.UNSUPPORTED_TARGET,
|
|
2522
|
+
})
|
|
2523
|
+
return
|
|
2524
|
+
}
|
|
2525
|
+
delegateOptions = {
|
|
2526
|
+
ttlSec: typeof parsed.delegateTtlSec === 'number' && parsed.delegateTtlSec > 0 ? Math.min(parsed.delegateTtlSec, 3600) : undefined,
|
|
2527
|
+
leaseSec: typeof parsed.delegateLeaseSec === 'number' && parsed.delegateLeaseSec > 0 ? Math.min(parsed.delegateLeaseSec, 600) : undefined,
|
|
2528
|
+
// 授权说明/工作范围:由**可信发起端**在这次已认证的派活里给出,随 grant 固定记录并回给审批模型。
|
|
2529
|
+
authorization: typeof parsed.authorization === 'string' && parsed.authorization.trim() !== '' ? parsed.authorization.trim().slice(0, 500) : undefined,
|
|
2530
|
+
credDigest: auth.credDigest,
|
|
2531
|
+
}
|
|
2532
|
+
}
|
|
2533
|
+
|
|
2534
|
+
// ---- 投递控制(第四轮):steer 是"改动既有任务"的动作 → 必须有**认证发起端** + 显式父任务。
|
|
2535
|
+
// 队列(默认)对来源绑定任务不要求凭据(旧非委托 queue 行为不变);新字段遇旧网关由客户端预检拒绝。
|
|
2536
|
+
let steering
|
|
2537
|
+
if (classified.deliver === 'steer' || (parsed !== null && typeof parsed === 'object' && (parsed.parentTaskId !== undefined || parsed.expectedTurn !== undefined))) {
|
|
2538
|
+
const auth = approvalAuth(req)
|
|
2539
|
+
if (auth.ok !== true) {
|
|
2540
|
+
sendJson(res, 401, { error: 'steer modifies an existing task and requires a valid control credential on the requesting client', reason: auth.reason })
|
|
2541
|
+
return
|
|
2542
|
+
}
|
|
2543
|
+
const parentTaskId = parsed !== null && typeof parsed === 'object' && typeof parsed.parentTaskId === 'string' && parsed.parentTaskId.trim() !== '' ? parsed.parentTaskId.trim() : undefined
|
|
2544
|
+
if (classified.deliver !== 'steer' && parentTaskId !== undefined) {
|
|
2545
|
+
sendJson(res, 400, { error: 'parentTaskId is only valid with deliver="steer"', reason: REASONS.PARENT_REQUIRED })
|
|
2546
|
+
return
|
|
2547
|
+
}
|
|
2548
|
+
steering = {
|
|
2549
|
+
parentTaskId,
|
|
2550
|
+
expectedTurn: parsed !== null && typeof parsed === 'object' && Number.isInteger(parsed.expectedTurn) ? parsed.expectedTurn : undefined,
|
|
2551
|
+
}
|
|
2552
|
+
}
|
|
1259
2553
|
// dryRun:只解析目标、回显它,**不投递任何东西**。存在的意义是让"我这条查询会命中
|
|
1260
2554
|
// 哪条会话"能纯只读地问出来——真实事故里调用方就是因为没法先验证,才发了一条占位
|
|
1261
2555
|
// 消息去探测,结果污染了用户会话。
|
|
@@ -1295,24 +2589,49 @@ export async function apply(ctx) {
|
|
|
1295
2589
|
})
|
|
1296
2590
|
return
|
|
1297
2591
|
}
|
|
1298
|
-
const
|
|
2592
|
+
const routePolicy = parseRoutePolicy(parsed)
|
|
2593
|
+
const task = await enqueue(instruction, parsed.history, classified.target, parsed.model, 'task', classified.deliver, classified.notes, origin.originThreadId, delegateOptions, steering, routePolicy)
|
|
1299
2594
|
if (task.status === 'error') {
|
|
1300
2595
|
sendJson(res, 400, {
|
|
1301
2596
|
error: task.error,
|
|
1302
2597
|
reason: task.reason,
|
|
1303
2598
|
...(task.originThreadId === undefined ? {} : { originThreadId: task.originThreadId }),
|
|
1304
2599
|
...(task.delivered === true ? { delivered: true } : {}),
|
|
2600
|
+
...(task.route === undefined ? {} : { route: task.route }),
|
|
1305
2601
|
...(Array.isArray(task.candidates) && task.candidates.length > 0 ? { candidates: task.candidates } : {}),
|
|
1306
2602
|
})
|
|
1307
2603
|
return
|
|
1308
2604
|
}
|
|
1309
2605
|
sendJson(res, 202, {
|
|
1310
2606
|
...taskResponse(task),
|
|
2607
|
+
...(task.route === undefined ? {} : { route: task.route }),
|
|
1311
2608
|
model: classified.target.kind === 'worker' ? (parsed.model || null) : null,
|
|
1312
2609
|
})
|
|
1313
2610
|
},
|
|
1314
2611
|
}))
|
|
1315
2612
|
|
|
2613
|
+
// 只读模型目录(M2):`GET /api/dsh-bridge/models` —— 不激活任何会话、不发起模型请求、不显示凭据/endpoint。
|
|
2614
|
+
ctx.effect(() => webServer.register({
|
|
2615
|
+
kind: 'exact',
|
|
2616
|
+
path: '/api/dsh-bridge/models',
|
|
2617
|
+
handler: async (req, res) => {
|
|
2618
|
+
if (!isLoopback(req)) { res.writeHead(403); res.end('forbidden'); return }
|
|
2619
|
+
if (req.method !== 'GET' && req.method !== 'POST') { res.writeHead(405); res.end('GET or POST only'); return }
|
|
2620
|
+
try {
|
|
2621
|
+
const query = req.method === 'POST' ? ((await readJsonBody(req, res)) || {}) : qsOf(req)
|
|
2622
|
+
const view = await modelRouting.modelsView({ force: query.force === true || query.force === 'true' })
|
|
2623
|
+
sendJson(res, 200, {
|
|
2624
|
+
...view,
|
|
2625
|
+
status: modelRouting.status().counters,
|
|
2626
|
+
limits: modelRouting.limits,
|
|
2627
|
+
routing: capabilityReport().modelRouting,
|
|
2628
|
+
})
|
|
2629
|
+
} catch (e) {
|
|
2630
|
+
sendJson(res, 502, { ok: false, error: 'model catalog query failed', reason: 'model-catalog-failed', detail: errText(e).slice(0, 200) })
|
|
2631
|
+
}
|
|
2632
|
+
},
|
|
2633
|
+
}))
|
|
2634
|
+
|
|
1316
2635
|
// 只读"找到会话":不创建、不投递、不激活 Agent(search/附件/历史页都不激活)。
|
|
1317
2636
|
ctx.effect(() => webServer.register({
|
|
1318
2637
|
kind: 'exact',
|
|
@@ -1413,12 +2732,21 @@ export async function apply(ctx) {
|
|
|
1413
2732
|
sendJson(res, 400, { error: 'originThreadId must be a non-empty string (<= 200 chars, no control characters)', reason: ASSOC_REASONS.INVALID_ORIGIN })
|
|
1414
2733
|
return
|
|
1415
2734
|
}
|
|
1416
|
-
const task = await submitReview(parsed.cwd, parsed.diff, parsed.focus, parsed.history, origin.originThreadId)
|
|
2735
|
+
const task = await submitReview(parsed.cwd, parsed.diff, parsed.focus, parsed.history, origin.originThreadId, parseRoutePolicy(parsed))
|
|
1417
2736
|
if (task.status === 'error') {
|
|
1418
|
-
sendJson(res, 400, {
|
|
2737
|
+
sendJson(res, 400, {
|
|
2738
|
+
error: task.error, reason: task.reason,
|
|
2739
|
+
...(task.originThreadId === undefined ? {} : { originThreadId: task.originThreadId }),
|
|
2740
|
+
...(task.route === undefined ? {} : { route: task.route }),
|
|
2741
|
+
...(Array.isArray(task.candidates) && task.candidates.length > 0 ? { candidates: task.candidates } : {}),
|
|
2742
|
+
})
|
|
1419
2743
|
return
|
|
1420
2744
|
}
|
|
1421
|
-
sendJson(res, 202, {
|
|
2745
|
+
sendJson(res, 202, {
|
|
2746
|
+
taskId: task.taskId, status: task.status, cwd: task.cwd, kind: 'review',
|
|
2747
|
+
...(task.originThreadId === undefined ? {} : { originThreadId: task.originThreadId }),
|
|
2748
|
+
...(task.route === undefined ? {} : { route: task.route }),
|
|
2749
|
+
})
|
|
1422
2750
|
},
|
|
1423
2751
|
}))
|
|
1424
2752
|
|
|
@@ -1486,10 +2814,36 @@ export async function apply(ctx) {
|
|
|
1486
2814
|
return
|
|
1487
2815
|
}
|
|
1488
2816
|
}
|
|
2817
|
+
// 投递控制(第四轮 D1)取消安全:
|
|
2818
|
+
// * steer 回执:取消回执 ≠ 取消父任务,且回执本身没有可撤回的官方入口 → 明确拒绝;
|
|
2819
|
+
// * queued 项:无法证明"逐项撤回"(官方只有 session 级 clear/cancel)→ 明确拒绝,绝不 cancel 整个会话去清队列。
|
|
2820
|
+
if (task.deliver === 'steer' || task.status === 'queued') {
|
|
2821
|
+
sendJson(res, 409, {
|
|
2822
|
+
taskId,
|
|
2823
|
+
cancelled: false,
|
|
2824
|
+
reason: 'cancel-unsupported-for-queued',
|
|
2825
|
+
error: task.deliver === 'steer'
|
|
2826
|
+
? 'cancelling a steer receipt is not supported: it is not the parent task, and the official API has no per-message retraction. Cancel the parent (if it is yours) or wait for it to finish.'
|
|
2827
|
+
: 'cancelling a queued task is not supported: the official API only offers session-level cancel, which would also kill other tasks/queued items. Wait for it to run, or cancel the running task explicitly when it is the only one.',
|
|
2828
|
+
})
|
|
2829
|
+
return
|
|
2830
|
+
}
|
|
1489
2831
|
if (task.status !== 'accepted' && task.status !== 'running') { sendJson(res, 200, { taskId, cancelled: false, status: task.status, ...(task.originThreadId === undefined ? {} : { originThreadId: task.originThreadId }) }); return }
|
|
1490
2832
|
|
|
1491
2833
|
// session 目标动的是用户自己的对话:取消会打断他正在跑的回合,因此必须显式 force。
|
|
1492
2834
|
if (task.target !== undefined && task.target.kind === 'session') {
|
|
2835
|
+
// 多任务安全(第四轮 D1):session cancel 会清掉整队列——只有"本会话没有别的在途任务"时才允许。
|
|
2836
|
+
const others = sessionRecords(task.target.sessionId).filter((r) => r.taskId !== task.taskId)
|
|
2837
|
+
if (others.length > 0) {
|
|
2838
|
+
sendJson(res, 409, {
|
|
2839
|
+
taskId,
|
|
2840
|
+
cancelled: false,
|
|
2841
|
+
reason: 'cancel-unsafe-with-other-tasks',
|
|
2842
|
+
error: 'this session has ' + others.length + ' other in-flight/queued bridge task(s) (' + others.map((r) => r.taskId).join(', ') + '); a session-level cancel would kill them too, so cancelling this task is refused. Wait for them to finish or settle them first.',
|
|
2843
|
+
otherTaskIds: others.map((r) => r.taskId),
|
|
2844
|
+
})
|
|
2845
|
+
return
|
|
2846
|
+
}
|
|
1493
2847
|
if (parsed.force !== true) {
|
|
1494
2848
|
sendJson(res, 409, {
|
|
1495
2849
|
taskId,
|
|
@@ -1510,7 +2864,7 @@ export async function apply(ctx) {
|
|
|
1510
2864
|
}
|
|
1511
2865
|
task.status = 'cancelled'
|
|
1512
2866
|
task.cancelReason = 'cancelled by request (force)'
|
|
1513
|
-
const record =
|
|
2867
|
+
const record = recordForTask(taskId)
|
|
1514
2868
|
if (record !== undefined) settleSessionTask(record, 'cancelled', {})
|
|
1515
2869
|
persistTask(task)
|
|
1516
2870
|
pushTask(taskId, 'cancelled', { target: { kind: 'session', sessionId: task.target.sessionId } })
|
|
@@ -1536,6 +2890,306 @@ export async function apply(ctx) {
|
|
|
1536
2890
|
},
|
|
1537
2891
|
}))
|
|
1538
2892
|
|
|
2893
|
+
// ================================================================ 审批 HTTP(全部鉴权:私有 raw key)
|
|
2894
|
+
// 边界:origin/task 只负责定位且**必须显式给出**(修正 D:空过滤不再顺手续所有任务租约、
|
|
2895
|
+
// 不跨来源泄露);真正的授权校验在 store.decide 里按 pending/grant 硬核对;
|
|
2896
|
+
// 响应从不回显收到的头、raw key 或任何授权摘要;决定请求**不接受**任何参数副本。
|
|
2897
|
+
const approvalCtx = (src, required = {}) => {
|
|
2898
|
+
const origin = originOf(src)
|
|
2899
|
+
if (!origin.ok || origin.originThreadId === undefined) {
|
|
2900
|
+
return { ok: false, status: 400, reason: ASSOC_REASONS.INVALID_ORIGIN, error: 'originThreadId is required (a non-empty string) — it locates the task; it never authorizes by itself' }
|
|
2901
|
+
}
|
|
2902
|
+
const taskId = typeof src.taskId === 'string' ? src.taskId.trim() : ''
|
|
2903
|
+
if (taskId === '') {
|
|
2904
|
+
return { ok: false, status: 400, reason: APPROVAL_REASONS.TASK_REQUIRED, error: 'taskId is required together with originThreadId' }
|
|
2905
|
+
}
|
|
2906
|
+
const out = { ok: true, originThreadId: origin.originThreadId, taskId }
|
|
2907
|
+
for (const key of ['grantId', 'approvalKey', 'sessionId']) {
|
|
2908
|
+
if (required[key] === true) {
|
|
2909
|
+
const value = typeof src[key] === 'string' ? src[key].trim() : ''
|
|
2910
|
+
if (value === '') return { ok: false, status: 400, reason: 'bad-input', error: key + ' is required' }
|
|
2911
|
+
out[key] = value
|
|
2912
|
+
} else if (typeof src[key] === 'string' && src[key].trim() !== '') {
|
|
2913
|
+
out[key] = src[key].trim()
|
|
2914
|
+
}
|
|
2915
|
+
}
|
|
2916
|
+
return out
|
|
2917
|
+
}
|
|
2918
|
+
|
|
2919
|
+
const ensureGrantView = (grant, credDigest) => (grant !== undefined && grant !== null && grant.credDigest === credDigest)
|
|
2920
|
+
|
|
2921
|
+
ctx.effect(() => webServer.register({
|
|
2922
|
+
kind: 'prefix',
|
|
2923
|
+
path: '/api/dsh-bridge/approvals',
|
|
2924
|
+
handler: async (req, res) => {
|
|
2925
|
+
if (!isLoopback(req)) { res.writeHead(403); res.end('forbidden'); return }
|
|
2926
|
+
const auth = approvalAuth(req)
|
|
2927
|
+
if (auth.ok !== true) {
|
|
2928
|
+
sendJson(res, 401, { ok: false, error: 'a valid control credential is required for approval operations', reason: auth.reason })
|
|
2929
|
+
return
|
|
2930
|
+
}
|
|
2931
|
+
if (approvalStore === null) { sendJson(res, 503, { ok: false, error: 'approval store unavailable' }); return }
|
|
2932
|
+
if (approvalStore.grantLogHealthy() !== true) {
|
|
2933
|
+
// 权限记录受损 → 安全失败(不基于受损记录做任何判断)
|
|
2934
|
+
sendJson(res, 503, { ok: false, error: 'authorization records are damaged; refusing to act until repaired', reason: APPROVAL_REASONS.GRANT_LOG_CORRUPT })
|
|
2935
|
+
return
|
|
2936
|
+
}
|
|
2937
|
+
const credDigest = auth.credDigest
|
|
2938
|
+
const url = new URL(req.url, 'http://127.0.0.1')
|
|
2939
|
+
const sub = url.pathname.slice('/api/dsh-bridge/approvals'.length)
|
|
2940
|
+
const now = Date.now()
|
|
2941
|
+
|
|
2942
|
+
// ---- 清单(必须显式 origin+task;续租/续期只作用于本次查询显式命中的 grant)
|
|
2943
|
+
if (sub === '' || sub === '/') {
|
|
2944
|
+
if (req.method !== 'GET') { sendJson(res, 405, { ok: false, error: 'GET required' }); return }
|
|
2945
|
+
const qs = qsOf(req)
|
|
2946
|
+
const scope = approvalCtx(qs)
|
|
2947
|
+
if (scope.ok !== true) { sendJson(res, scope.status, { ok: false, error: scope.error, reason: scope.reason }); return }
|
|
2948
|
+
const extendSec = Number(qs.extendSec)
|
|
2949
|
+
const renew = qs.renew === '1' || qs.renew === 'true'
|
|
2950
|
+
const grantFilter = { originThreadId: scope.originThreadId, taskId: scope.taskId }
|
|
2951
|
+
if (scope.grantId !== undefined) grantFilter.grantId = scope.grantId
|
|
2952
|
+
if (scope.sessionId !== undefined) grantFilter.sessionId = scope.sessionId
|
|
2953
|
+
const scoped = approvalStore.listGrants(grantFilter).filter((g) => ensureGrantView(g, credDigest))
|
|
2954
|
+
const renewed = []
|
|
2955
|
+
const extended = []
|
|
2956
|
+
for (const g of scoped) {
|
|
2957
|
+
if (g.status !== 'active' || now > g.expiresAt) continue
|
|
2958
|
+
const r = approvalStore.renewLease({ grantId: g.grantId, taskId: g.taskId, originThreadId: g.originThreadId, sessionId: g.scope.sessionId, credDigest, now })
|
|
2959
|
+
if (r.ok === true) renewed.push(g.grantId)
|
|
2960
|
+
if (renew) {
|
|
2961
|
+
const e = approvalStore.renewGrant({ grantId: g.grantId, taskId: g.taskId, originThreadId: g.originThreadId, sessionId: g.scope.sessionId, credDigest, extendSec: Number.isFinite(extendSec) && extendSec > 0 ? extendSec : undefined, now })
|
|
2962
|
+
if (e.ok === true) extended.push({ grantId: g.grantId, expiresAt: e.grant.expiresAt, leaseUntil: e.grant.leaseUntil, cap: e.cap })
|
|
2963
|
+
}
|
|
2964
|
+
}
|
|
2965
|
+
const pendings = approvalStore.listPending({
|
|
2966
|
+
originThreadId: scope.originThreadId, taskId: scope.taskId,
|
|
2967
|
+
...(scope.grantId === undefined ? {} : { grantId: scope.grantId }),
|
|
2968
|
+
...(scope.sessionId === undefined ? {} : { sessionId: scope.sessionId }),
|
|
2969
|
+
}).filter((p) => ensureGrantView(approvalStore.getGrant(p.grantId), credDigest))
|
|
2970
|
+
sendJson(res, 200, {
|
|
2971
|
+
ok: true,
|
|
2972
|
+
now,
|
|
2973
|
+
renewHint: 'authenticated list/detail/decide calls renew the lease; pass renew=1 (or the /renew route) to extend the absolute grant TTL inside the authorized window',
|
|
2974
|
+
renewedGrants: renewed,
|
|
2975
|
+
extendedGrants: extended,
|
|
2976
|
+
grants: scoped.map((g) => ({
|
|
2977
|
+
grantId: g.grantId, version: g.version, status: g.status, taskId: g.taskId,
|
|
2978
|
+
originThreadId: g.originThreadId, sessionId: g.scope.sessionId,
|
|
2979
|
+
authorization: g.authorization,
|
|
2980
|
+
issuedAt: g.issuedAt, expiresAt: g.expiresAt, leaseUntil: g.leaseUntil,
|
|
2981
|
+
...(g.revokedAt === undefined ? {} : { revokedAt: g.revokedAt, revokeReason: g.revokeReason }),
|
|
2982
|
+
})),
|
|
2983
|
+
approvals: pendings.map((p) => approvalStore.pendingView(p)),
|
|
2984
|
+
unanswerable: approvalStore.unansweredFromAudit()
|
|
2985
|
+
.filter((u) => u.originThreadId === undefined || (u.originThreadId === scope.originThreadId && (u.taskId === undefined || u.taskId === scope.taskId))),
|
|
2986
|
+
})
|
|
2987
|
+
return
|
|
2988
|
+
}
|
|
2989
|
+
|
|
2990
|
+
// ---- 为**已存在且已绑定来源**的任务显式启用委托(不再允许给 unbound 任务随意声明 origin)
|
|
2991
|
+
if (sub === '/enable') {
|
|
2992
|
+
if (req.method !== 'POST') { sendJson(res, 405, { ok: false, error: 'POST required' }); return }
|
|
2993
|
+
const body = await readJsonBody(req, res)
|
|
2994
|
+
if (body === null) return
|
|
2995
|
+
const scope = approvalCtx(body, { sessionId: true })
|
|
2996
|
+
if (scope.ok !== true) { sendJson(res, scope.status, { ok: false, error: scope.error, reason: scope.reason }); return }
|
|
2997
|
+
const task = tasks.find((t) => t.taskId === scope.taskId)
|
|
2998
|
+
if (task === undefined) {
|
|
2999
|
+
sendJson(res, 404, { ok: false, error: 'unknown task in this gateway (enable cannot invent a binding)', reason: 'task-not-found' })
|
|
3000
|
+
return
|
|
3001
|
+
}
|
|
3002
|
+
if (task.target === undefined || task.target.kind !== 'session' || task.target.sessionId !== scope.sessionId) {
|
|
3003
|
+
sendJson(res, 400, { ok: false, error: 'the grant scope must equal the task session target', reason: APPROVAL_REASONS.GRANT_SCOPE_MISMATCH })
|
|
3004
|
+
return
|
|
3005
|
+
}
|
|
3006
|
+
if (task.originThreadId === undefined) {
|
|
3007
|
+
sendJson(res, 400, { ok: false, error: 'this task has no source binding; delegation requires a task that was dispatched with originThreadId', reason: APPROVAL_REASONS.ORIGIN_REQUIRED })
|
|
3008
|
+
return
|
|
3009
|
+
}
|
|
3010
|
+
if (task.originThreadId !== scope.originThreadId) {
|
|
3011
|
+
sendJson(res, 403, { ok: false, error: 'task is bound to another source conversation', reason: APPROVAL_REASONS.ORIGIN_MISMATCH })
|
|
3012
|
+
return
|
|
3013
|
+
}
|
|
3014
|
+
const existing = approvalStore.listGrants({ originThreadId: scope.originThreadId, taskId: scope.taskId, sessionId: scope.sessionId, activeOnly: true })
|
|
3015
|
+
.find((g) => ensureGrantView(g, credDigest) && g.expiresAt > now)
|
|
3016
|
+
if (existing !== undefined) {
|
|
3017
|
+
sendJson(res, 200, { ok: true, reused: true, grantId: existing.grantId, version: existing.version, expiresAt: existing.expiresAt, leaseUntil: existing.leaseUntil, taskId: scope.taskId, originThreadId: scope.originThreadId, sessionId: scope.sessionId, authorization: existing.authorization })
|
|
3018
|
+
return
|
|
3019
|
+
}
|
|
3020
|
+
try {
|
|
3021
|
+
const grant = approvalStore.issueGrant({
|
|
3022
|
+
originThreadId: scope.originThreadId,
|
|
3023
|
+
taskId: scope.taskId,
|
|
3024
|
+
scope: { kind: 'session', sessionId: scope.sessionId },
|
|
3025
|
+
ttlMs: typeof body.ttlSec === 'number' && body.ttlSec > 0 ? Math.min(body.ttlSec, 3600) * 1000 : undefined,
|
|
3026
|
+
leaseMs: typeof body.leaseSec === 'number' && body.leaseSec > 0 ? Math.min(body.leaseSec, 600) * 1000 : undefined,
|
|
3027
|
+
credDigest,
|
|
3028
|
+
authorization: typeof body.authorization === 'string' ? body.authorization : undefined,
|
|
3029
|
+
})
|
|
3030
|
+
approvalStore.audit({ type: 'grant', phase: 'issued', grantId: grant.grantId, taskId: scope.taskId, originThreadId: scope.originThreadId, sessionId: scope.sessionId, authorization: grant.authorization, how: 'enable-route' })
|
|
3031
|
+
sendJson(res, 200, { ok: true, grantId: grant.grantId, version: grant.version, expiresAt: grant.expiresAt, leaseUntil: grant.leaseUntil, taskId: scope.taskId, originThreadId: scope.originThreadId, sessionId: scope.sessionId, authorization: grant.authorization })
|
|
3032
|
+
} catch (e) {
|
|
3033
|
+
sendJson(res, 500, { ok: false, error: errText(e), reason: 'grant-issue-failed' })
|
|
3034
|
+
}
|
|
3035
|
+
return
|
|
3036
|
+
}
|
|
3037
|
+
|
|
3038
|
+
// ---- 撤销(立刻失效,含在途 pending 收口)
|
|
3039
|
+
const revokeMatch = /^\/grants\/([A-Za-z0-9-]+)\/revoke$/.exec(sub)
|
|
3040
|
+
if (revokeMatch !== null) {
|
|
3041
|
+
if (req.method !== 'POST') { sendJson(res, 405, { ok: false, error: 'POST required' }); return }
|
|
3042
|
+
const body = await readJsonBody(req, res)
|
|
3043
|
+
if (body === null) return
|
|
3044
|
+
const scope = approvalCtx(body, { grantId: true })
|
|
3045
|
+
if (scope.ok !== true) { sendJson(res, scope.status, { ok: false, error: scope.error, reason: scope.reason }); return }
|
|
3046
|
+
const grantId = revokeMatch[1]
|
|
3047
|
+
if (grantId !== scope.grantId) { sendJson(res, 400, { ok: false, error: 'grantId must match the route', reason: APPROVAL_REASONS.GRANT_SCOPE_MISMATCH }); return }
|
|
3048
|
+
const grant = approvalStore.getGrant(grantId)
|
|
3049
|
+
if (grant === undefined || !ensureGrantView(grant, credDigest)) {
|
|
3050
|
+
sendJson(res, 404, { ok: false, error: 'grant not found for this credential', reason: APPROVAL_REASONS.GRANT_MISSING })
|
|
3051
|
+
return
|
|
3052
|
+
}
|
|
3053
|
+
if (grant.taskId !== scope.taskId || grant.originThreadId !== scope.originThreadId) {
|
|
3054
|
+
sendJson(res, 403, { ok: false, error: 'grant belongs to another task/source', reason: APPROVAL_REASONS.GRANT_SCOPE_MISMATCH })
|
|
3055
|
+
return
|
|
3056
|
+
}
|
|
3057
|
+
const result = approvalStore.revokeGrant(grantId, typeof body.reason === 'string' ? body.reason : 'revoked via route')
|
|
3058
|
+
approvalStore.audit({ type: 'grant', phase: 'revoked', grantId, taskId: grant.taskId, originThreadId: grant.originThreadId, reason: typeof body.reason === 'string' ? body.reason.slice(0, 200) : 'revoked via route' })
|
|
3059
|
+
sendJson(res, 200, { ok: result.ok === true, grantId, status: 'revoked', ...(result.reason === undefined ? {} : { reason: result.reason }) })
|
|
3060
|
+
return
|
|
3061
|
+
}
|
|
3062
|
+
|
|
3063
|
+
// ---- 认证续期(在原授权窗口内显式延长绝对 TTL;不靠后台无限续租)
|
|
3064
|
+
const renewMatch = /^\/grants\/([A-Za-z0-9-]+)\/renew$/.exec(sub)
|
|
3065
|
+
if (renewMatch !== null) {
|
|
3066
|
+
if (req.method !== 'POST') { sendJson(res, 405, { ok: false, error: 'POST required' }); return }
|
|
3067
|
+
const body = await readJsonBody(req, res)
|
|
3068
|
+
if (body === null) return
|
|
3069
|
+
const scope = approvalCtx(body, { grantId: true })
|
|
3070
|
+
if (scope.ok !== true) { sendJson(res, scope.status, { ok: false, error: scope.error, reason: scope.reason }); return }
|
|
3071
|
+
const grantId = renewMatch[1]
|
|
3072
|
+
if (grantId !== scope.grantId) { sendJson(res, 400, { ok: false, error: 'grantId must match the route', reason: APPROVAL_REASONS.GRANT_SCOPE_MISMATCH }); return }
|
|
3073
|
+
const grant = approvalStore.getGrant(grantId)
|
|
3074
|
+
if (grant === undefined || !ensureGrantView(grant, credDigest)) {
|
|
3075
|
+
sendJson(res, 404, { ok: false, error: 'grant not found for this credential', reason: APPROVAL_REASONS.GRANT_MISSING })
|
|
3076
|
+
return
|
|
3077
|
+
}
|
|
3078
|
+
const result = approvalStore.renewGrant({
|
|
3079
|
+
grantId, taskId: scope.taskId, originThreadId: scope.originThreadId, sessionId: grant.scope.sessionId, credDigest,
|
|
3080
|
+
extendSec: typeof body.extendSec === 'number' && body.extendSec > 0 ? Math.min(body.extendSec, 3600) : undefined,
|
|
3081
|
+
now,
|
|
3082
|
+
})
|
|
3083
|
+
if (result.ok !== true) { sendJson(res, 409, { ok: false, reason: result.reason }); return }
|
|
3084
|
+
approvalStore.audit({ type: 'grant', phase: 'renewed', grantId, taskId: grant.taskId, originThreadId: grant.originThreadId, expiresAt: result.grant.expiresAt })
|
|
3085
|
+
sendJson(res, 200, { ok: true, grantId, expiresAt: result.grant.expiresAt, leaseUntil: result.grant.leaseUntil, cap: result.cap })
|
|
3086
|
+
return
|
|
3087
|
+
}
|
|
3088
|
+
|
|
3089
|
+
// ---- 决定(严格入参;身份/版本/指纹/上下文由 store.decide 硬核对)
|
|
3090
|
+
const decideMatch = /^\/([A-Za-z0-9-]+)\/decide$/.exec(sub)
|
|
3091
|
+
if (decideMatch !== null) {
|
|
3092
|
+
if (req.method !== 'POST') { sendJson(res, 405, { ok: false, error: 'POST required' }); return }
|
|
3093
|
+
const body = await readJsonBody(req, res)
|
|
3094
|
+
if (body === null) return
|
|
3095
|
+
const allowedKeys = new Set(['originThreadId', 'taskId', 'grantId', 'grantVersion', 'fingerprint', 'decision', 'idempotencyKey'])
|
|
3096
|
+
const extra = Object.keys(body).filter((k) => !allowedKeys.has(k))
|
|
3097
|
+
if (extra.length > 0) {
|
|
3098
|
+
// 参数副本/歧义入参一律拒绝(绝不"默默接受后按原命令批准")
|
|
3099
|
+
sendJson(res, 400, { ok: false, error: 'unexpected fields are not accepted on a decision: ' + extra.join(','), reason: APPROVAL_REASONS.EXTRA_FIELDS })
|
|
3100
|
+
return
|
|
3101
|
+
}
|
|
3102
|
+
const scope = approvalCtx(body, { grantId: true })
|
|
3103
|
+
if (scope.ok !== true) { sendJson(res, scope.status, { ok: false, error: scope.error, reason: scope.reason }); return }
|
|
3104
|
+
const approvalKey = decideMatch[1]
|
|
3105
|
+
const pending = approvalStore.getPending(approvalKey)
|
|
3106
|
+
if (pending === undefined) {
|
|
3107
|
+
sendJson(res, 404, { ok: false, error: 'no live approval with that key (a restart loses live callbacks; wait for a fresh request)', reason: APPROVAL_REASONS.PENDING_MISSING })
|
|
3108
|
+
return
|
|
3109
|
+
}
|
|
3110
|
+
if (pending.grantId !== scope.grantId || pending.taskId !== scope.taskId || pending.originThreadId !== scope.originThreadId) {
|
|
3111
|
+
sendJson(res, 403, { ok: false, error: 'this approval belongs to another task/source/grant', reason: APPROVAL_REASONS.GRANT_SCOPE_MISMATCH })
|
|
3112
|
+
return
|
|
3113
|
+
}
|
|
3114
|
+
if (body.decision !== DECISIONS.APPROVE_ONCE && body.decision !== DECISIONS.REJECT) {
|
|
3115
|
+
sendJson(res, 400, { ok: false, error: 'decision must be approve-once or reject', reason: 'unknown-decision' })
|
|
3116
|
+
return
|
|
3117
|
+
}
|
|
3118
|
+
if (typeof body.fingerprint !== 'string' || body.fingerprint === '') {
|
|
3119
|
+
sendJson(res, 400, { ok: false, error: 'fingerprint is required (from GET /approvals/<key>)', reason: APPROVAL_REASONS.FINGERPRINT_MISMATCH })
|
|
3120
|
+
return
|
|
3121
|
+
}
|
|
3122
|
+
// 认证 + 匹配该委托 → 续租(这是"活跃 RPC 有界续租"的唯一途径)
|
|
3123
|
+
approvalStore.renewLease({ grantId: pending.grantId, taskId: pending.taskId, originThreadId: pending.originThreadId, sessionId: pending.sessionId, credDigest, now })
|
|
3124
|
+
// 当前 turn/step 必须由**日志实时**给出:只比 coarse snapshotId 会漏掉 step-only 变化(root 复现)。
|
|
3125
|
+
// 日志不可读/无 tool/call → 身份不可证明 → 明确拒绝(绝不当作可放行)。
|
|
3126
|
+
const decideEvents = await readSessionEvents(pending.sessionId)
|
|
3127
|
+
const live = approvalStore.getSnapshot(pending.sessionId, pending.callId)
|
|
3128
|
+
const currentTurnStep = decideEvents === null ? null : resolveTurnStepFrom(decideEvents, pending.callId)
|
|
3129
|
+
if (currentTurnStep === null) {
|
|
3130
|
+
sendJson(res, 409, {
|
|
3131
|
+
ok: false,
|
|
3132
|
+
reason: APPROVAL_REASONS.IDENTITY_UNKNOWN,
|
|
3133
|
+
error: 'the current execution identity cannot be proven right now (session log unreadable or no tool/call for this callId); refusing the decision instead of guessing',
|
|
3134
|
+
})
|
|
3135
|
+
return
|
|
3136
|
+
}
|
|
3137
|
+
const result = approvalStore.decide({
|
|
3138
|
+
approvalKey,
|
|
3139
|
+
fingerprint: body.fingerprint,
|
|
3140
|
+
decision: body.decision,
|
|
3141
|
+
idempotencyKey: typeof body.idempotencyKey === 'string' && body.idempotencyKey !== '' ? body.idempotencyKey.slice(0, 120) : undefined,
|
|
3142
|
+
credDigest,
|
|
3143
|
+
originThreadId: scope.originThreadId,
|
|
3144
|
+
taskId: scope.taskId,
|
|
3145
|
+
grantVersion: typeof body.grantVersion === 'number' ? body.grantVersion : undefined,
|
|
3146
|
+
currentSnapshotId: live === undefined ? undefined : live.snapshotId,
|
|
3147
|
+
currentTurnStep,
|
|
3148
|
+
now,
|
|
3149
|
+
})
|
|
3150
|
+
if (result.ok !== true) {
|
|
3151
|
+
sendJson(res, 409, { ok: false, reason: result.reason, ...(result.state === undefined ? {} : { state: result.state }) })
|
|
3152
|
+
return
|
|
3153
|
+
}
|
|
3154
|
+
sendJson(res, 200, {
|
|
3155
|
+
ok: true,
|
|
3156
|
+
phase: 'submitted',
|
|
3157
|
+
state: result.state,
|
|
3158
|
+
outcome: result.outcome,
|
|
3159
|
+
approvalKey,
|
|
3160
|
+
...(result.idempotent === true ? { idempotent: true } : {}),
|
|
3161
|
+
confirmationHint: body.decision === DECISIONS.REJECT
|
|
3162
|
+
? '提交 ≠ 终态:reject 的官方终态是 rejected(approval/decided, outcome=rejected);轮询该审批看到 state=rejected 即已收敛,不要去等只对批准有意义的 confirmed'
|
|
3163
|
+
: 'submitted ≠ confirmed: re-query this approval; state=confirmed means the official approval/decided was observed with allowed-once, decision-unknown means confirmation was lost (do not re-send)',
|
|
3164
|
+
})
|
|
3165
|
+
return
|
|
3166
|
+
}
|
|
3167
|
+
|
|
3168
|
+
// ---- 详情(鉴权 + origin/task 硬核对 + 续租)
|
|
3169
|
+
const detailMatch = /^\/([A-Za-z0-9-]+)$/.exec(sub)
|
|
3170
|
+
if (detailMatch !== null) {
|
|
3171
|
+
if (req.method !== 'GET') { sendJson(res, 405, { ok: false, error: 'GET required' }); return }
|
|
3172
|
+
const scope = approvalCtx(qsOf(req))
|
|
3173
|
+
if (scope.ok !== true) { sendJson(res, scope.status, { ok: false, error: scope.error, reason: scope.reason }); return }
|
|
3174
|
+
const pending = approvalStore.getPending(detailMatch[1])
|
|
3175
|
+
if (pending === undefined) {
|
|
3176
|
+
sendJson(res, 404, { ok: false, error: 'no live approval with that key', reason: APPROVAL_REASONS.PENDING_MISSING })
|
|
3177
|
+
return
|
|
3178
|
+
}
|
|
3179
|
+
const g = approvalStore.getGrant(pending.grantId)
|
|
3180
|
+
if (!ensureGrantView(g, credDigest) || pending.taskId !== scope.taskId || pending.originThreadId !== scope.originThreadId) {
|
|
3181
|
+
sendJson(res, 403, { ok: false, error: 'this approval is not visible for this credential/task/source', reason: APPROVAL_REASONS.GRANT_SCOPE_MISMATCH })
|
|
3182
|
+
return
|
|
3183
|
+
}
|
|
3184
|
+
approvalStore.renewLease({ grantId: g.grantId, taskId: g.taskId, originThreadId: g.originThreadId, sessionId: g.scope.sessionId, credDigest, now })
|
|
3185
|
+
sendJson(res, 200, { ok: true, approval: approvalStore.pendingView(pending) })
|
|
3186
|
+
return
|
|
3187
|
+
}
|
|
3188
|
+
|
|
3189
|
+
sendJson(res, 404, { ok: false, error: 'unknown approval route' })
|
|
3190
|
+
},
|
|
3191
|
+
}))
|
|
3192
|
+
|
|
1539
3193
|
ctx.effect(() => webServer.register({
|
|
1540
3194
|
kind: 'exact',
|
|
1541
3195
|
path: '/api/dsh-bridge/status',
|
|
@@ -1567,13 +3221,30 @@ export async function apply(ctx) {
|
|
|
1567
3221
|
return
|
|
1568
3222
|
}
|
|
1569
3223
|
}
|
|
1570
|
-
const record =
|
|
3224
|
+
const record = recordForTask(task.taskId)
|
|
3225
|
+
// 回执:**每次显式查询都是一次官方证据机会**(第四轮首次验收修正 C)——长工具结束后
|
|
3226
|
+
// 同一父回合里的迟到 rpc 不会因为"单次等待窗口已过"而永久停留在未确认。
|
|
3227
|
+
if (record !== undefined && record.receipt === true) await refreshReceiptNow(record, task) // 委托等待循环需要**当前**的 grant 定位/租约(第三轮修正 A):每次 status 都从存储刷新,
|
|
3228
|
+
// 这样客户端能按真实 lease 剩余时间自适应续租(固定 10s 会放过 1–5s 的短租约)。
|
|
3229
|
+
const grantView = delegatedGrantView(task)
|
|
1571
3230
|
sendJson(res, 200, {
|
|
1572
3231
|
taskId: task.taskId, status: task.status, kind: task.kind, cwd: task.cwd, lane: task.lane,
|
|
1573
3232
|
target: task.target, deliver: task.deliver, instruction: task.instruction.slice(0, 300),
|
|
1574
3233
|
result: task.result, error: task.error, reason: task.reason, fallback: task.fallback,
|
|
1575
3234
|
// delivered=true 表示 prompt 已被会话接受(进了队列)——即便后面报错也不要重发。
|
|
1576
3235
|
...(task.delivered === undefined ? {} : { delivered: task.delivered }),
|
|
3236
|
+
...(grantView === null ? {} : { grantId: grantView.grantId, grantStatus: grantView.status, grantExpiresAt: grantView.expiresAt, grantLeaseUntil: grantView.leaseUntil }),
|
|
3237
|
+
// 投递控制(第四轮):排队任务与补充回执的真实状态(重启后也能查)
|
|
3238
|
+
...(task.requestedMode === undefined && task.deliver === undefined ? {} : { deliver: task.requestedMode !== undefined ? task.requestedMode : task.deliver }),
|
|
3239
|
+
...(task.parentTaskId === undefined ? {} : { parentTaskId: task.parentTaskId }),
|
|
3240
|
+
...(task.receiptState === undefined ? {} : { receiptState: task.receiptState }),
|
|
3241
|
+
...(Number.isInteger(task.observedTurn) ? { observedTurn: task.observedTurn } : {}),
|
|
3242
|
+
...(task.resultScope === undefined ? {} : { resultScope: task.resultScope }),
|
|
3243
|
+
// 动态模型路由(M2):真实选择 + 切换次数/原因 + **跨重启可查**的审计尾部(只有稳定 code,无正文)
|
|
3244
|
+
...(task.route === undefined ? {} : { route: task.route }),
|
|
3245
|
+
...(task.routeStopReason === undefined ? {} : { routeStopReason: task.routeStopReason }),
|
|
3246
|
+
routeAudit: readRouteAudit(task.taskId, 20),
|
|
3247
|
+
routeAuditWindow: { maxBytes: 1024 * 1024, truncated: routeAuditReader.status().truncated },
|
|
1577
3248
|
...(task.originThreadId === undefined ? {} : { originThreadId: task.originThreadId }),
|
|
1578
3249
|
...(task.requestId === undefined ? {} : { requestId: task.requestId }),
|
|
1579
3250
|
...(task.dshTurn === undefined ? {} : { dshTurn: task.dshTurn }),
|
|
@@ -1584,6 +3255,10 @@ export async function apply(ctx) {
|
|
|
1584
3255
|
...(task.associationError === undefined ? {} : { associationError: task.associationError }),
|
|
1585
3256
|
...(task.matchedBy === undefined ? {} : { matchedBy: task.matchedBy }),
|
|
1586
3257
|
...(Array.isArray(task.candidates) && task.candidates.length > 0 ? { candidates: task.candidates } : {}),
|
|
3258
|
+
// 审批等待态:非终态;只回非敏感能力/计数(approvalKey 等正文必须走鉴权审批接口取)。
|
|
3259
|
+
...(task.status === 'needs-approval' || Array.isArray(task.approvalKeys)
|
|
3260
|
+
? { needsApproval: task.status === 'needs-approval', approvalPendingCount: typeof task.approvalPendingCount === 'number' ? task.approvalPendingCount : 0, approvalsPath: '/api/dsh-bridge/approvals' }
|
|
3261
|
+
: {}),
|
|
1587
3262
|
...(record === undefined ? {} : { watcher: record.watcher, sawRunning: record.sawRunning }),
|
|
1588
3263
|
})
|
|
1589
3264
|
return
|
|
@@ -1606,23 +3281,52 @@ export async function apply(ctx) {
|
|
|
1606
3281
|
}
|
|
1607
3282
|
const record = stored.record
|
|
1608
3283
|
const terminal = record.status === 'done' || record.status === 'error' || record.status === 'cancelled'
|
|
3284
|
+
// 投递控制(第四轮首次验收修正 A):回执的"自身已被观察到"是**独立于父任务执行**的持久事实,
|
|
3285
|
+
// 重启后不能降级成 unknown(列表视图与单任务查询必须是同一套投影)。
|
|
3286
|
+
const receiptView = record.deliver === 'steer'
|
|
3287
|
+
? {
|
|
3288
|
+
deliver: 'steer',
|
|
3289
|
+
resultScope: record.resultScope === undefined ? 'parent-task' : record.resultScope,
|
|
3290
|
+
...(record.parentTaskId === undefined ? {} : { parentTaskId: record.parentTaskId }),
|
|
3291
|
+
...(record.receiptState === undefined ? {} : { receiptState: record.receiptState }),
|
|
3292
|
+
...(record.observedTurn === undefined ? {} : { observedTurn: record.observedTurn }),
|
|
3293
|
+
...(record.requestSeq === undefined ? {} : { requestSeq: record.requestSeq }),
|
|
3294
|
+
}
|
|
3295
|
+
: {}
|
|
3296
|
+
const receiptStatus = record.deliver !== 'steer' ? undefined
|
|
3297
|
+
: (record.receiptState === 'observed' ? 'receipt-observed'
|
|
3298
|
+
: (record.receiptState === 'deviated' ? 'receipt-deviated'
|
|
3299
|
+
: (record.receiptState === 'unconfirmed' ? 'receipt-unconfirmed' : 'receipt-pending')))
|
|
1609
3300
|
sendJson(res, 200, {
|
|
1610
3301
|
taskId: record.taskId,
|
|
1611
3302
|
originThreadId: record.originThreadId,
|
|
1612
3303
|
kind: record.kind,
|
|
1613
3304
|
target: record.target,
|
|
1614
3305
|
...(record.profile === undefined ? {} : { profile: record.profile }),
|
|
3306
|
+
...receiptView,
|
|
3307
|
+
// 模型路由(第一版验收修正):执行状态可以是 unknown(不谎报),但**审计事实必须仍可按 origin/task 取回**。
|
|
3308
|
+
...(record.route === undefined || record.route === null ? {} : { route: record.route }),
|
|
3309
|
+
...(record.routePolicy === undefined || record.routePolicy === null ? {} : { routePolicy: record.routePolicy }),
|
|
3310
|
+
...(record.routeStopReason === undefined ? {} : { routeStopReason: record.routeStopReason }),
|
|
3311
|
+
routeAudit: readRouteAudit(record.taskId, 20),
|
|
3312
|
+
routeAuditWindow: { maxBytes: 1024 * 1024, truncated: routeAuditReader.status().truncated },
|
|
1615
3313
|
// 非终态记录:**不谎报已完成**,也不重新观察(未实现),明确标未知并禁止重发。
|
|
1616
|
-
|
|
3314
|
+
// 回执例外:自身已观察到的回执**不**降级(父任务执行状态仍可 unknown)。
|
|
3315
|
+
status: receiptStatus !== undefined ? receiptStatus : (terminal ? record.status : 'unknown'),
|
|
1617
3316
|
persistedStatus: record.status,
|
|
1618
3317
|
recovered: true,
|
|
1619
|
-
...(
|
|
1620
|
-
? {
|
|
1621
|
-
: {
|
|
1622
|
-
reason: 'execution-state-unconfirmed',
|
|
1623
|
-
note: '这条任务在执行/交付未确认前就失去了运行时上下文(网关重载或新实例);不重发、不重新观察。原目标见 target。',
|
|
3318
|
+
...(receiptStatus !== undefined
|
|
3319
|
+
? {
|
|
1624
3320
|
resend: false,
|
|
1625
|
-
|
|
3321
|
+
note: '补充回执的自身证据已持久化(收据/回合/来源是两维事实);父任务的执行结果请查父 task。',
|
|
3322
|
+
}
|
|
3323
|
+
: (terminal
|
|
3324
|
+
? {}
|
|
3325
|
+
: {
|
|
3326
|
+
reason: 'execution-state-unconfirmed',
|
|
3327
|
+
note: '这条任务在执行/交付未确认前就失去了运行时上下文(网关重载或新实例);不重发、不重新观察。原目标见 target。',
|
|
3328
|
+
resend: false,
|
|
3329
|
+
})),
|
|
1626
3330
|
...(record.delivered === undefined ? {} : { delivered: record.delivered }),
|
|
1627
3331
|
...(record.requestId === undefined ? {} : { requestId: record.requestId }),
|
|
1628
3332
|
...(record.dshTurn === undefined ? {} : { dshTurn: record.dshTurn }),
|
|
@@ -1651,12 +3355,12 @@ export async function apply(ctx) {
|
|
|
1651
3355
|
})
|
|
1652
3356
|
return
|
|
1653
3357
|
}
|
|
1654
|
-
const pending = tasks.filter((t) => t
|
|
3358
|
+
const pending = tasks.filter((t) => isNonTerminal(t)).length
|
|
1655
3359
|
sendJson(res, 200, {
|
|
1656
3360
|
pending,
|
|
1657
3361
|
lanes: Array.from(buckets.keys()),
|
|
1658
3362
|
reviewers: Array.from(reviewerBuckets.keys()),
|
|
1659
|
-
sessions: Array.from(sessionTargets.
|
|
3363
|
+
sessions: Array.from(new Set(Array.from(sessionTargets.values()).map((r) => r.sessionId))),
|
|
1660
3364
|
sessionController: sessionControllerNow() !== undefined,
|
|
1661
3365
|
capabilities: capabilityReport(),
|
|
1662
3366
|
tasks: tasks.slice(-20).map((t) => ({ taskId: t.taskId, kind: t.kind, status: t.status, cwd: t.cwd, lane: t.lane, target: t.target, deliver: t.deliver })),
|
|
@@ -1678,8 +3382,11 @@ export async function apply(ctx) {
|
|
|
1678
3382
|
services: Object.fromEntries(['sessionController', 'connection', 'sessionQuery', 'sessionPersistence', 'workspaceRegistry', 'agents', 'subagents', 'agentPresets', 'timer', 'llm'].map((n) => [n, probe(n)])),
|
|
1679
3383
|
sessionTargets: [], models: knownModels, modelProvider, modelListError, workspaces: [],
|
|
1680
3384
|
capabilities: capabilityReport(),
|
|
3385
|
+
observation: observations.status(),
|
|
3386
|
+
bridgeVersion,
|
|
3387
|
+
routeAuditTail: routeAuditReader.status(),
|
|
1681
3388
|
}
|
|
1682
|
-
out.pending = tasks.filter((t) => t
|
|
3389
|
+
out.pending = tasks.filter((t) => isNonTerminal(t)).length
|
|
1683
3390
|
try { out.providers = subagents.list() } catch (e) { out.providers = ['list failed: ' + errText(e)] }
|
|
1684
3391
|
if (agentPresets !== undefined) {
|
|
1685
3392
|
try {
|
|
@@ -1767,7 +3474,9 @@ export async function apply(ctx) {
|
|
|
1767
3474
|
model: { type: 'string', description: '模型别名: default | fast | pro。' },
|
|
1768
3475
|
sessionId: { type: 'string', description: '已有会话 id:把指令投递进这条会话,而不是新建。' },
|
|
1769
3476
|
sessionQuery: { type: 'string', description: '按标题/内容搜索已有会话(唯一命中才生效,多条或落空会返回候选)。' },
|
|
1770
|
-
deliver: { type: 'string', description: "投递方式: queue(
|
|
3477
|
+
deliver: { type: 'string', description: "投递方式: queue(默认,排队,忙时也可靠接收) 或 steer(作为**补充回执**插进父任务当前回合)。" },
|
|
3478
|
+
parentTaskId: { type: 'string', description: 'steer 必填: 被补充的父任务 id(dsh_collab_send 返回)。父必须同 origin/同会话且**正在执行**(官方日志证据);绝不按"最近/最老任务"推断。' },
|
|
3479
|
+
expectedTurn: { type: 'number', description: 'steer 可选: 期望父任务当前 turn;与实际不符则零投递拒绝。' },
|
|
1771
3480
|
dryRun: { type: 'boolean', description: '为 true 时只解析目标并回显(含 matchedBy 与候选),不投递任何消息——验证命中用它。' },
|
|
1772
3481
|
originThreadId: { type: 'string', description: '发起本任务的 Codex 对话 id(来源关联)。必须是显式传入的非空字符串——不要用常驻进程环境或"最近对话"推断。用于之后按 origin+taskId 精确收取结果。' },
|
|
1773
3482
|
wait: { type: 'boolean', description: '为 true 时等待结果(最长 15 分钟)。' },
|
|
@@ -1798,7 +3507,34 @@ export async function apply(ctx) {
|
|
|
1798
3507
|
const lane = normalizeLane(classified.target.lane)
|
|
1799
3508
|
return { ok: true, dryRun: true, resolved: true, target: { kind: 'worker', cwd, lane }, note: '只解析目标,未派活' }
|
|
1800
3509
|
}
|
|
1801
|
-
|
|
3510
|
+
// 投递控制(第四轮首次验收修正 F):原生入口同样要能新建 steer——把 steering 传给同一条派发路径
|
|
3511
|
+
// (因此**天然复用**同一套父校验与内部控制凭据语义:这个入口在宿主进程内,不经过 HTTP,
|
|
3512
|
+
// 但它绝不放宽校验——缺父/父已结束/错来源一律零投递拒绝)。
|
|
3513
|
+
const isSteer = classified.deliver === 'steer' || typeof args.parentTaskId === 'string'
|
|
3514
|
+
if (isSteer && args.deliver !== 'steer') return { ok: false, reason: REASONS.PARENT_REQUIRED, error: 'parentTaskId 只在 deliver="steer" 时有效' }
|
|
3515
|
+
if (isSteer && (typeof args.parentTaskId !== 'string' || args.parentTaskId.trim() === '')) {
|
|
3516
|
+
return { ok: false, reason: REASONS.PARENT_REQUIRED, error: 'steer 必须显式给 parentTaskId(绝不按"最近/最老任务"推断)' }
|
|
3517
|
+
}
|
|
3518
|
+
// 第二次验收修正 1:原生入口的 steer **同样 fail-closed**——本机没有可信控制 key(或缺/坏)时零派活。
|
|
3519
|
+
// 普通非委托 queue 不增加凭据前提(与 HTTP 契约一致)。
|
|
3520
|
+
if (isSteer) {
|
|
3521
|
+
const auth = nativeControlAuth()
|
|
3522
|
+
if (auth.ok !== true) {
|
|
3523
|
+
return {
|
|
3524
|
+
ok: false,
|
|
3525
|
+
reason: auth.reason,
|
|
3526
|
+
error: auth.reason === APPROVAL_REASONS.NO_KEY
|
|
3527
|
+
? 'steer 改动既有任务:原生入口需要本机可信控制 key(先运行 --approval-key-init)。缺 key 零派活——本工具**不接受**调用方传入的 raw key。'
|
|
3528
|
+
: 'steer 的控制 key 校验失败(格式非法):零派活。',
|
|
3529
|
+
resend: false,
|
|
3530
|
+
}
|
|
3531
|
+
}
|
|
3532
|
+
}
|
|
3533
|
+
const steering = isSteer
|
|
3534
|
+
? { parentTaskId: args.parentTaskId.trim(), expectedTurn: Number.isInteger(args.expectedTurn) ? args.expectedTurn : undefined }
|
|
3535
|
+
: undefined
|
|
3536
|
+
const routePolicy = parseRoutePolicy(args)
|
|
3537
|
+
const task = await enqueue(args.instruction, undefined, classified.target, args.model, 'task', classified.deliver, classified.notes, origin.originThreadId, undefined, steering, routePolicy)
|
|
1802
3538
|
if (task.status === 'error') {
|
|
1803
3539
|
return { ok: false, taskId: task.taskId, reason: task.reason, error: task.error, ...(task.delivered === true ? { delivered: true } : {}), ...(task.candidates === undefined ? {} : { candidates: task.candidates }) }
|
|
1804
3540
|
}
|
|
@@ -1806,8 +3542,29 @@ export async function apply(ctx) {
|
|
|
1806
3542
|
if (args.wait !== true || timer === undefined) {
|
|
1807
3543
|
return brief
|
|
1808
3544
|
}
|
|
3545
|
+
// 回执(F/C):只等**回执自身**的终态(observed/deviated/unconfirmed/pending),绝不等父任务结果。
|
|
3546
|
+
if (isSteer) {
|
|
3547
|
+
const t0 = Date.now()
|
|
3548
|
+
while (task.receiptState === 'accepted' || task.receiptState === 'pending') {
|
|
3549
|
+
if (Date.now() - t0 > 30000) break
|
|
3550
|
+
await timer.timeout(500)
|
|
3551
|
+
}
|
|
3552
|
+
return {
|
|
3553
|
+
...brief,
|
|
3554
|
+
status: task.status,
|
|
3555
|
+
receiptState: task.receiptState,
|
|
3556
|
+
resultScope: 'parent-task',
|
|
3557
|
+
...(task.observedTurn === undefined ? {} : { observedTurn: task.observedTurn }),
|
|
3558
|
+
resend: false,
|
|
3559
|
+
phase: task.receiptState === 'observed' ? 'receipt-observed'
|
|
3560
|
+
: (task.receiptState === 'deviated' ? 'receipt-deviated'
|
|
3561
|
+
: (task.receiptState === 'unconfirmed' ? 'receipt-unconfirmed' : 'receipt-pending')),
|
|
3562
|
+
hint: '这是补充回执(resultScope=parent-task),不代表父任务结果;父任务结果请查父 taskId 的状态;未观察到时不要重发。',
|
|
3563
|
+
}
|
|
3564
|
+
}
|
|
3565
|
+
// 队列语义(F):queued 也是"在途",等待时要一直等到真正的终态(或超时)。
|
|
1809
3566
|
const t0 = Date.now()
|
|
1810
|
-
while (task.status === 'accepted' || task.status === 'running') {
|
|
3567
|
+
while (task.status === 'accepted' || task.status === 'queued' || task.status === 'running') {
|
|
1811
3568
|
if (Date.now() - t0 > 900000) return { ...brief, status: task.status, result: task.result, note: 'wait 超时' }
|
|
1812
3569
|
await timer.timeout(2500)
|
|
1813
3570
|
}
|
|
@@ -1846,9 +3603,11 @@ export async function apply(ctx) {
|
|
|
1846
3603
|
for (const s of sockets) { try { s.destroy() } catch (e) {} }
|
|
1847
3604
|
sockets.clear()
|
|
1848
3605
|
for (const bucket of buckets.values()) {
|
|
3606
|
+
bucket.observationAbort?.abort()
|
|
1849
3607
|
if (bucket.handle !== null) { void bucket.handle.dispose() }
|
|
1850
3608
|
}
|
|
1851
3609
|
for (const bucket of reviewerBuckets.values()) {
|
|
3610
|
+
bucket.observationAbort?.abort()
|
|
1852
3611
|
if (bucket.handle !== null) { void bucket.handle.dispose() }
|
|
1853
3612
|
}
|
|
1854
3613
|
for (const record of sessionTargets.values()) {
|