@whaletalk/dsh-codex-collab 0.1.7 → 0.1.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/README.md +197 -26
  3. package/codex-plugin/plugins/dsh/.codex-plugin/plugin.json +1 -1
  4. package/codex-plugin/plugins/dsh/skills/dsh-collab/SKILL.md +102 -6
  5. package/codex-plugin/plugins/dsh/skills/dsh-collab/scripts/review.mjs +91 -6
  6. package/codex-plugin/plugins/dsh/skills/dsh-collab/scripts/task.mjs +571 -44
  7. package/docs/CODEX_DSH_APPROVAL_DELEGATION_PLAN.md +130 -0
  8. package/docs/CODEX_DSH_APPROVAL_M0_EVIDENCE.md +285 -0
  9. package/docs/CODEX_DSH_DELIVERY_CONTROL_PLAN.md +81 -0
  10. package/docs/CODEX_DSH_MODEL_ROUTING_PLAN.md +75 -0
  11. package/docs/CODEX_DSH_TASK_REPLY_PLAN.md +206 -0
  12. package/docs/DSH_APPROVAL_DELEGATION.md +142 -0
  13. package/docs/DSH_DELIVERY_M0_EVIDENCE.md +69 -0
  14. package/docs/DSH_MODEL_ROUTING_M0_EVIDENCE.md +166 -0
  15. package/docs/DSH_TO_CODEX_INBOX.md +167 -0
  16. package/docs/DSH_TO_CODEX_MESSAGING_PLAN.md +140 -0
  17. package/docs/codex-desktop-attachment-evidence.md +116 -0
  18. package/harness/audit-tail.mjs +32 -0
  19. package/harness/codex-approvals.mjs +1078 -0
  20. package/harness/codex-associations.mjs +297 -0
  21. package/harness/codex-outbox.mjs +432 -0
  22. package/harness/dsh-bridge.mjs +2429 -167
  23. package/harness/fs-lock.mjs +208 -0
  24. package/harness/model-route-host.mjs +530 -0
  25. package/harness/model-routing.mjs +500 -0
  26. package/harness/session-observer.mjs +217 -0
  27. package/harness/session-target.mjs +291 -2
  28. package/harness/task-table.mjs +34 -0
  29. package/mcp/codex-desktop-probe.mjs +407 -0
  30. package/mcp/codex-inbox.mjs +271 -0
  31. package/mcp/dsh-mcp.mjs +616 -24
  32. package/package.json +4 -2
@@ -19,7 +19,19 @@
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'
25
+ import {
26
+ ASSOC_REASONS,
27
+ isValidOrigin,
28
+ openAssociations,
29
+ } from './codex-associations.mjs'
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'
23
35
  import {
24
36
  REASONS,
25
37
  textOfBlocks,
@@ -34,7 +46,27 @@ import {
34
46
  looksLikeDisabledSearch,
35
47
  rankTitleCandidates,
36
48
  taskResponse,
49
+ createRequestTracker,
50
+ consumeSessionEvent,
51
+ consumeSnapshotFrame,
52
+ turnOutcome,
53
+ openTurnsOf,
54
+ currentOpenTurn,
55
+ turnHasEnded,
56
+ findRequestEvidence,
37
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'
38
70
 
39
71
  export const name = 'dsh-bridge'
40
72
 
@@ -55,6 +87,12 @@ export async function apply(ctx) {
55
87
  // 在 apply() 时捕获会永久拿到 undefined。每次使用时现取;缺失时返回稳定的
56
88
  // session-controller-unavailable,而不是让整个插件挂掉。
57
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())
58
96
 
59
97
  // 清理历史版本可能泄漏的路由条目(重启前的动态插件、旧版本次插件)
60
98
  try {
@@ -141,6 +179,70 @@ export async function apply(ctx) {
141
179
  return {}
142
180
  }
143
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
+
144
246
  // ---- model alias resolution ----
145
247
  const modelAliasCache = new Map()
146
248
  let knownModels = []
@@ -204,6 +306,581 @@ export async function apply(ctx) {
204
306
  const tasks = []
205
307
  const sockets = new Set()
206
308
 
309
+ /** 把持久记录投影成"跨回合可读"的形态。 */
310
+ const projectStoredTask = (record) => {
311
+ const liveTask = tasks.find((t) => t.taskId === record.taskId)
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
+ }
322
+ const base = {
323
+ taskId: record.taskId,
324
+ originThreadId: record.originThreadId,
325
+ kind: record.kind,
326
+ target: record.target,
327
+ createdAt: record.createdAt,
328
+ updatedAt: record.updatedAt,
329
+ live: liveTask !== undefined,
330
+ ...(record.requestId === undefined ? {} : { requestId: record.requestId }),
331
+ ...(record.dshTurn === undefined ? {} : { dshTurn: record.dshTurn }),
332
+ ...deliveryFields,
333
+ }
334
+ if (liveTask !== undefined) {
335
+ return {
336
+ ...base,
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 }),
343
+ ...(liveTask.result === undefined ? {} : { result: liveTask.result }),
344
+ ...(liveTask.error === undefined ? {} : { error: liveTask.error }),
345
+ ...(liveTask.delivered === undefined ? {} : { delivered: liveTask.delivered }),
346
+ }
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
+ }
372
+ if (terminal) {
373
+ return {
374
+ ...base,
375
+ status: record.status,
376
+ ...(record.result === undefined ? {} : { result: record.result }),
377
+ ...(record.error === undefined ? {} : { error: record.error }),
378
+ ...(record.delivered === undefined ? {} : { delivered: record.delivered }),
379
+ }
380
+ }
381
+ // 没有本实例运行上下文的非终态记录:**只能**标未知,绝不谎报"还在被本实例观察"。
382
+ return {
383
+ ...base,
384
+ status: 'unknown',
385
+ persistedStatus: record.status,
386
+ reason: 'execution-state-unconfirmed',
387
+ resend: false,
388
+ note: '这条任务在执行/交付未确认前就失去了运行时上下文(网关重载或新实例);本实例不观察它、不重发。原目标见 target。',
389
+ }
390
+ }
391
+
392
+ /** 同通道已有在途任务:明确拒绝,而不是把完成结果猜给某个 pending。 */
393
+ const rejectBusy = (fields) => {
394
+ const task = {
395
+ taskId: uid(), kind: fields.kind, instruction: fields.instruction,
396
+ cwd: fields.cwd, lane: fields.lane, bucketKey: fields.bucketKey,
397
+ target: fields.target, status: 'error', time: Date.now(), result: undefined,
398
+ error: fields.error, reason: fields.reason,
399
+ ...(fields.activeTaskId === undefined ? {} : { activeTaskId: fields.activeTaskId }),
400
+ }
401
+ rememberTask(tasks, task)
402
+ // 仍然登记来源(如果调用方给了):让调用方能按来源查到"这次被拒绝了"。
403
+ const bound = bindAssociation(task, fields.originThreadId)
404
+ if (bound.ok) persistTask(task)
405
+ pushTask(task.taskId, 'error', { error: task.error, reason: task.reason, cwd: fields.cwd, kind: fields.kind, ...(fields.activeTaskId === undefined ? {} : { activeTaskId: fields.activeTaskId }) })
406
+ return task
407
+ }
408
+
409
+ /**
410
+ * 把任务关联存储(R1):把「来源 Codex 对话 → taskId → 执行目标」持久化,供跨回合精确收取。
411
+ * 打不开就置 null——此时**来源关联模式一律拒绝**,绝不静默降级成"无关联"执行。
412
+ */
413
+ const associations = (() => {
414
+ try {
415
+ return openAssociations({
416
+ root: process.env.DSH_BRIDGE_ASSOC_DIR,
417
+ namespace: process.env.DSH_BRIDGE_ASSOC_NAMESPACE,
418
+ profile: process.env.DSH_PROFILE,
419
+ })
420
+ } catch (e) {
421
+ console.error('[dsh-bridge] association store unavailable:', errText(e).slice(0, 300))
422
+ return null
423
+ }
424
+ })()
425
+
426
+ /**
427
+ * 从请求体/查询串解析 originThreadId:
428
+ * - **字段未传(或 undefined)** → 旧的"无关联"模式,保持兼容;
429
+ * - **显式给了值** → 必须是合法非空字符串。空串 / null / 纯空白 / 非字符串一律拒绝,
430
+ * 绝不静默回落成"无关联"(那会让调用方以为绑定成功、实际产生未绑定任务)。
431
+ */
432
+ const originOf = (parsed) => {
433
+ if (parsed === null || typeof parsed !== 'object') return { ok: true, originThreadId: undefined }
434
+ if (!Object.prototype.hasOwnProperty.call(parsed, 'originThreadId')) return { ok: true, originThreadId: undefined }
435
+ const raw = parsed.originThreadId
436
+ if (raw === undefined) return { ok: true, originThreadId: undefined }
437
+ if (!isValidOrigin(raw)) return { ok: false }
438
+ return { ok: true, originThreadId: raw.trim() }
439
+ }
440
+
441
+ /** 能力上报:客户端在**派活前**只读核验本网关是否真的支持持久来源绑定(旧网关没有这一块)。 */
442
+ const capabilityReport = () => {
443
+ const originBinding = associations === null
444
+ ? { supported: false, version: 0, reason: 'association-store-unavailable' }
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
+ }
505
+
506
+ /**
507
+ * 执行前登记关联。失败即返回错误,调用方必须放弃启动 DSH 执行——
508
+ * 绝不出现"执行了却没绑定"。
509
+ */
510
+ const bindAssociation = (task, originThreadId) => {
511
+ if (originThreadId === undefined) return { ok: true }
512
+ if (associations === null) {
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' }
514
+ }
515
+ try {
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
+ })
527
+ task.originThreadId = originThreadId
528
+ return { ok: true }
529
+ } catch (e) {
530
+ return { ok: false, reason: e !== null && typeof e === 'object' && typeof e.reason === 'string' ? e.reason : 'association-bind-failed', message: errText(e) }
531
+ }
532
+ }
533
+
534
+ /** 把任务当前状态写回持久关联记录(终态/失败/取消/交付确认都调用)。 */
535
+ const persistTask = (task) => {
536
+ if (task === undefined || task === null || task.originThreadId === undefined || associations === null) return
537
+ try {
538
+ associations.update(task.taskId, {
539
+ status: task.status,
540
+ delivered: task.delivered,
541
+ // 回执绝不允许把父任务的旧 result/error 留在落盘记录里(显式 null = 清除)。
542
+ result: task.deliver === 'steer' ? null : task.result,
543
+ error: task.deliver === 'steer' ? null : task.error,
544
+ reason: task.reason,
545
+ stopReason: task.stopReason,
546
+ requestId: task.requestId,
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 }),
559
+ })
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
+ }
566
+ } catch (e) {
567
+ // 明确暴露,绝不静默:任务本身的结果不受影响,但调用方能在状态里看到关联写入失败。
568
+ task.associationError = errText(e)
569
+ console.error('[dsh-bridge] association update failed for', task.taskId, ':', errText(e).slice(0, 200))
570
+ }
571
+ }
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
+
207
884
  const broadcast = (msg) => {
208
885
  let line
209
886
  try { line = JSON.stringify(msg) + '\n' } catch (e) { return }
@@ -308,6 +985,34 @@ export async function apply(ctx) {
308
985
  '不要修改任何文件。没有新请求时不要主动输出。',
309
986
  ].join('')
310
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
+
311
1016
  const startChildWith = async (bucket, firstInstruction, brief) => {
312
1017
  const parent = await ensureOwner(bucket)
313
1018
  const provider = pickProvider()
@@ -319,70 +1024,106 @@ export async function apply(ctx) {
319
1024
  toolFilter: { deny: ['dsh_collab_send', 'dsh_collab_review'] },
320
1025
  }
321
1026
  if (bucket.model !== null) request.agentOptions = { model: bucket.model }
322
- const started = await subagents.startContinuable({
1027
+ const spec = {
323
1028
  provider,
324
1029
  label: bucket.lane === '@review' ? 'codex-collab-reviewer' : 'codex-collab-worker-' + bucket.lane,
325
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 } : {}),
326
1034
  signal: signalOf(),
327
- })
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(() => {})
328
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
329
1045
  console.log('[dsh-bridge] worker started for', bucket.key, ':', bucket.childId, 'model:', bucket.model)
330
1046
  return bucket.childId
331
1047
  }
332
1048
 
1049
+ /** 子代理**失败**终态(官方 SubagentResult.stopReason):这些结束绝不算 done,更不允许 done+空输出。 */
1050
+ const SUBAGENT_FAILURE_STOP_REASONS = new Set(['error', 'aborted', 'refusal', 'max-tokens', 'interrupted', 'failed', 'cancelled'])
333
1051
  const onChildEnd = (bucket, info) => {
334
1052
  if (bucket.childId === null || info.id !== bucket.childId) return
335
1053
  const text = textOfBlocks(info.lastAssistantMessage)
336
- const pending = tasks.filter((t) => (t.status === 'accepted' || t.status === 'running') && t.bucketKey === bucket.key)
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)
337
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
+ }
338
1078
  if (task !== null) {
339
1079
  task.status = 'done'
1080
+ delete task.error
1081
+ delete task.reason
1082
+ delete task.partialResult
1083
+ delete task.resend
340
1084
  task.result = text.length > 0 ? text : '(无输出)'
341
- task.stopReason = info.stopReason
1085
+ task.stopReason = stopReason
342
1086
  remember('user', task.instruction)
343
1087
  remember('assistant', task.result)
344
- pushTask(task.taskId, 'result', { text: task.result, stopReason: info.stopReason, childId: bucket.childId, cwd: bucket.cwd, kind: task.kind })
1088
+ persistTask(task)
1089
+ pushTask(task.taskId, 'result', { text: task.result, stopReason, childId: bucket.childId, cwd: bucket.cwd, kind: task.kind })
345
1090
  } else {
346
- broadcast({ type: 'result', taskId: null, childId: bucket.childId, time: Date.now(), text, stopReason: info.stopReason, cwd: bucket.cwd, kind: bucket.lane === '@review' ? 'review' : 'task' })
1091
+ broadcast({ type: 'result', taskId: null, childId: bucket.childId, time: Date.now(), text, stopReason, cwd: bucket.cwd, kind: bucket.lane === '@review' ? 'review' : 'task' })
347
1092
  }
348
1093
  bucket.activeSince = 0
1094
+ bucket.observationAbort?.abort()
349
1095
  }
350
1096
 
351
- const deliver = async (bucket, instruction, brief, followupPrefix) => {
1097
+ const deliver = async (bucket, instruction, brief, followupPrefix, requestId) => {
352
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
353
1106
  if (bucket.childId === null) {
354
1107
  if (bucket.childPromise === null) {
355
1108
  bucket.childPromise = startChildWith(bucket, instruction, brief).catch((e) => { bucket.childPromise = null; throw e })
356
1109
  await bucket.childPromise
357
1110
  } else {
358
1111
  await bucket.childPromise
359
- await subagents.followup(parent, bucket.childId, [{ type: 'text', text: followupPrefix + instruction }], {
360
- source: { kind: 'coordinator', form: 'relay', senderSessionId: parent.id },
361
- signal: signalOf(),
362
- })
1112
+ await promptChild(bucket, parent, requestId, followupPrefix + instruction)
363
1113
  }
364
1114
  } else {
365
- try {
366
- await subagents.followup(parent, bucket.childId, [{ type: 'text', text: followupPrefix + instruction }], {
367
- source: { kind: 'coordinator', form: 'relay', senderSessionId: parent.id },
368
- signal: signalOf(),
369
- })
370
- } catch (followupError) {
371
- // 兜底:one-shot + 历史摘要(正常情况下宿主插件的真 AbortSignal 已打通 cold-resume)
372
- const digest = historyDigest()
373
- const text = (digest !== '' ? digest + '\n\n' : '') + brief + instruction
374
- const request = { prompt: [{ type: 'text', text }], parent, signal: signalOf() }
375
- if (bucket.model !== null) request.agentOptions = { model: bucket.model }
376
- const run = await subagents.start('spawn', Object.assign({ label: 'codex-collab-' + bucket.lane + '-' + uid() }, request))
377
- const result = await run.result
378
- const out = textOfBlocks(result.output)
379
- bucket.childId = null
380
- bucket.childPromise = null
381
- bucket.lastSeen = 0
382
- bucket.seenCount = 0
383
- void run.dispose()
384
- return { fallback: true, output: out.length > 0 ? out : '(无输出)', stopReason: result.stopReason, fallbackNote: 'one-shot (cold-resume unavailable: ' + errText(followupError).slice(0, 200) + ')' }
385
- }
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
+ })
386
1127
  }
387
1128
  return { fallback: false, output: '', stopReason: '', fallbackNote: '' }
388
1129
  }
@@ -396,11 +1137,34 @@ export async function apply(ctx) {
396
1137
  const SESSION_DISPATCH_TIMEOUT_MS = envMs('DSH_BRIDGE_SESSION_DISPATCH_TIMEOUT_MS', 120000)
397
1138
  /** 见过 running 之后,多久没有新助手文本算读完了。 */
398
1139
  const SESSION_QUIET_TIMEOUT_MS = envMs('DSH_BRIDGE_SESSION_QUIET_TIMEOUT_MS', 600000)
399
- /** 单个会话任务的总预算上限。 */
1140
+ /** 单个会话任务的总预算上限。**未委托**任务按它收口;有认证活跃委托时以 grant 绝对有效期为界。 */
400
1141
  const SESSION_TOTAL_TIMEOUT_MS = envMs('DSH_BRIDGE_SESSION_TOTAL_TIMEOUT_MS', 3600000)
401
- // sessionTargets 以 sessionId 为键:同一会话同时只允许一个在途 bridge 任务。
402
- // DSH 自己会排队,但我们无法可靠地把两次并发派的回报各自归属,所以宁可明确拒绝。
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。
403
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
404
1168
 
405
1169
  const sessionSearch = async (query) => {
406
1170
  const controller = sessionControllerNow()
@@ -514,21 +1278,91 @@ export async function apply(ctx) {
514
1278
  return { ok: true, sessionId: picked.sessionId, snippet: picked.snippet, candidates: picked.candidates, matchedBy }
515
1279
  }
516
1280
 
517
- const sessionPendingTask = (sessionId) => tasks.find((t) => (t.status === 'accepted' || t.status === 'running') && t.target !== undefined && t.target.kind === 'session' && t.target.sessionId === sessionId)
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
+ }
518
1329
 
519
1330
  const settleSessionTask = (record, status, fields) => {
520
1331
  if (record.settled) return
521
1332
  record.settled = true
522
1333
  if (record.abort !== undefined) { try { record.abort.abort() } catch (e) { /* ignore */ } }
523
- sessionTargets.delete(record.sessionId)
1334
+ sessionTargets.delete(record.taskId)
524
1335
  const task = tasks.find((t) => t.taskId === record.taskId)
525
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
+ }
526
1348
  task.status = status
527
1349
  if (fields.result !== undefined) task.result = fields.result
528
1350
  if (fields.error !== undefined) task.error = fields.error
529
1351
  if (fields.reason !== undefined) task.reason = fields.reason
530
1352
  if (fields.stopReason !== undefined) task.stopReason = fields.stopReason
531
1353
  if (fields.delivered !== undefined) task.delivered = fields.delivered
1354
+ if (fields.partialResult !== undefined) task.partialResult = fields.partialResult
1355
+ if (fields.turnEndKind !== undefined) task.turnEndKind = fields.turnEndKind
1356
+ if (fields.note !== undefined) task.note = fields.note
1357
+ if (fields.resend !== undefined) task.resend = fields.resend
1358
+ if (fields.routeStopReason !== undefined) task.routeStopReason = fields.routeStopReason
1359
+ // 精确关联模式:回显这次请求的 requestId 与匹配到的 DSH turn(便于跨回合核对归属)。
1360
+ if (record.precise === true) {
1361
+ task.requestId = record.taskId
1362
+ task.dshTurn = record.tracker === undefined ? undefined : record.tracker.turn
1363
+ }
1364
+ // 终态(done/error/cancelled/timeout/unknown)一律写回持久关联记录。
1365
+ persistTask(task)
532
1366
  pushTask(task.taskId, status === 'done' ? 'result' : 'error', {
533
1367
  ...(task.result === undefined ? {} : { text: task.result }),
534
1368
  ...(task.error === undefined ? {} : { error: task.error }),
@@ -537,15 +1371,112 @@ export async function apply(ctx) {
537
1371
  })
538
1372
  }
539
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
+
1410
+ /**
1411
+ * 把"读日志"当成一次**观察机会**(不是完成证明):把日志事件喂给关联器。
1412
+ * 与 live follow 共用同一套规则,靠 seq 去重。
1413
+ */
1414
+ const observeSessionLog = async (record) => {
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
1420
+ }
1421
+
1422
+ /** 精确模式下的进度推送:只推自己那个 turn 的**新增**文本。 */
1423
+ const pushPreciseProgress = (record) => {
1424
+ const text = record.tracker === undefined ? '' : record.tracker.text
1425
+ if (text.length <= record.reported) return
1426
+ const delta = text.slice(record.reported)
1427
+ record.reported = text.length
1428
+ record.activeSince = Date.now()
1429
+ const task = tasks.find((t) => t.taskId === record.taskId)
1430
+ if (task !== undefined && task.status === 'accepted') task.status = 'running'
1431
+ pushTask(record.taskId, 'progress', { text: delta, target: { kind: 'session', sessionId: record.sessionId } })
1432
+ }
1433
+
1434
+ /**
1435
+ * 依据关联器收尾:只有该请求自己的 turn 以 `completed` 结束才算成功;
1436
+ * 其他 kind(aborted/blocked/error/max-tokens/interrupted/forked/unknown)都是**明确的终态失败**。
1437
+ */
1438
+ const settleFromTracker = (record) => {
1439
+ const outcome = turnOutcome(record.tracker.end)
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
1443
+ if (outcome.ok) {
1444
+ settleSessionTask(record, 'done', { result: text === '' ? '(无输出)' : text, stopReason: 'request-turn-completed' })
1445
+ return
1446
+ }
1447
+ // 路由已给出本任务终态(耗尽/禁 fallback)→ 用稳定 reason 收口,并保留 routeStopReason;
1448
+ // 其余非 completed 结束仍按 TURN_FAILED 如实失败。
1449
+ settleSessionTask(record, 'error', {
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 }),
1455
+ turnEndKind: outcome.kind,
1456
+ ...(text === '' ? {} : { partialResult: text }),
1457
+ })
1458
+ }
1459
+
540
1460
  /** 收尾时以「持久日志 + 派发前基线」为准,避免只信流式片段。 */
541
1461
  const finishSessionTask = (record, stopReason) => {
542
1462
  if (record.settled) return
543
1463
  void (async () => {
1464
+ // 精确关联模式(带来源):running→idle 只是"可以读一次日志"的机会,不是完成证明。
1465
+ // 只有读到自己请求那个 turn 的 turn/end 才收尾;否则保持未确认,绝不拿旧回合或不相关的
1466
+ // 全会话 idleness 判成 done。
1467
+ if (record.precise === true) {
1468
+ await observeSessionLog(record)
1469
+ if (record.settled) return
1470
+ if (record.tracker.end !== undefined) { settleFromTracker(record); return }
1471
+ record.lastObservation = { at: Date.now(), stopReason, sawRequest: record.tracker.sawRequest, turn: record.tracker.turn }
1472
+ return
1473
+ }
544
1474
  let extractedText = ''
545
1475
  if (sessionQuery !== undefined) {
546
1476
  try {
547
- const snap = await sessionQuery.readSession(record.sessionId)
1477
+ const snap = await observations.capture(record.sessionId)
548
1478
  const events = snap !== undefined && snap !== null && Array.isArray(snap.events) ? snap.events : []
1479
+ observeApprovalEvents(record.sessionId, events)
549
1480
  const extracted = extractNewAssistantText(events, record.baseline)
550
1481
  record.baseline = extracted.baseline
551
1482
  extractedText = extracted.text
@@ -559,46 +1490,239 @@ export async function apply(ctx) {
559
1490
  }
560
1491
 
561
1492
  const watchSessionTask = (record) => {
562
- // 主路径:follow 增量流(不激活 Agent,也不重读整份日志——目标会话可能有几十 MB)。
563
- const controller = new AbortController()
564
- record.abort = controller
1493
+ record.abort ??= new AbortController()
565
1494
  record.watcher = 'follow'
566
- void (async () => {
567
- try {
568
- const controllerRef = sessionControllerNow()
569
- if (controllerRef === undefined) throw new Error('sessionController is not mounted')
570
- const stream = controllerRef.follow({ address: { kind: 'session', sessionId: record.sessionId } }, controller.signal)
571
- for await (const frame of stream) {
572
- if (record.settled) break
573
- if (frame === null || typeof frame !== 'object') continue
574
- if (frame.type !== 'event') continue // snapshot 是派发前的历史,不作为本轮输出
575
- const text = assistantTextOf(frame.event)
576
- if (text === '') continue
577
- record.total += text
578
- record.activeSince = Date.now()
579
- const task = tasks.find((t) => t.taskId === record.taskId)
580
- if (task !== undefined && task.status === 'accepted') task.status = 'running'
581
- pushTask(record.taskId, 'progress', { text, target: { kind: 'session', sessionId: record.sessionId } })
582
- }
583
- } catch (e) {
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) => {
584
1500
  if (record.settled) return
585
- // 降级:交给 2.5s 轮询(与子代理路径同一套解析),并在任务上标出来以便观测。
586
- record.watcher = 'poll'
587
- console.error('[dsh-bridge] follow unavailable, falling back to polling:', errText(e).slice(0, 200))
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 })
1509
+ }
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 } }) }
1515
+ }
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
588
1619
  }
589
- })()
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
+ }
590
1687
  }
591
1688
 
592
1689
  /** 派发一条指令进已有会话。任何失败都不创建会话,只落一个 error 任务。 */
593
- const dispatchToSession = async (instruction, target, deliver, notes, incomingHistory) => {
1690
+ const dispatchToSession = async (instruction, target, deliver, notes, incomingHistory, originThreadId, delegateOptions, steering, routePolicy) => {
594
1691
  const task = {
595
1692
  taskId: uid(), kind: 'task', instruction,
596
1693
  target: { kind: 'session', sessionId: target.sessionId, query: target.query, snippet: target.snippet },
597
1694
  deliver, notes, status: 'accepted', time: Date.now(), result: undefined, error: undefined,
598
1695
  cwd: undefined, lane: undefined, bucketKey: undefined,
599
1696
  }
600
- tasks.push(task)
601
- if (tasks.length > 200) tasks.splice(0, tasks.length - 200)
1697
+ rememberTask(tasks, task)
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) => {
1708
+ const bound = bindAssociation(task, originThreadId)
1709
+ task.status = 'error'
1710
+ task.error = message
1711
+ task.reason = reason
1712
+ if (bound.ok) persistTask(task)
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
+ }
1725
+ }
602
1726
 
603
1727
  const resolved = await resolveSessionTarget(target)
604
1728
  if (!resolved.ok) {
@@ -614,39 +1738,201 @@ export async function apply(ctx) {
614
1738
  if (resolved.candidates.length > 0) task.candidates = resolved.candidates
615
1739
  if (resolved.matchedBy !== undefined) task.matchedBy = resolved.matchedBy
616
1740
 
617
- const inFlight = sessionPendingTask(resolved.sessionId)
618
- if (inFlight !== undefined && inFlight.taskId !== task.taskId) {
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
+ }
1847
+ }
1848
+
1849
+ // 来源关联必须在**实际投递之前**落盘:绑定失败就不投递(宁可失败,也不出现"执行了却没绑定")。
1850
+ const bound = bindAssociation(task, originThreadId)
1851
+ if (!bound.ok) {
619
1852
  task.status = 'error'
620
- task.error = 'another bridge task is already pending on this session'
621
- task.reason = REASONS.BUSY
1853
+ task.error = bound.message
1854
+ task.reason = bound.reason
622
1855
  pushTask(task.taskId, 'error', { error: task.error, reason: task.reason, target: { kind: 'session', sessionId: resolved.sessionId } })
623
- return { task, failed: { reason: REASONS.BUSY, message: task.error, candidates: [] } }
1856
+ return { task, failed: { reason: bound.reason, message: bound.message, candidates: [] } }
624
1857
  }
1858
+ // 模型路由选择:**绑定之后**才持久化(bind 时已带 route/routePolicy;这里只刷新非秘密回显字段,
1859
+ // 不再出现 "association update failed: task has no association record" 这种真实日志错误)。
1860
+ if (routePolicy !== undefined && mode !== 'steer') persistTask(task)
625
1861
 
626
- // 派发前取基线:结果只回报这一轮新产生的助手文本。
627
- let baseline = { count: 0, textLen: 0 }
628
- if (sessionQuery !== undefined) {
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
+ }
629
1883
  try {
630
- const snap = await sessionQuery.readSession(resolved.sessionId)
631
- const events = snap !== undefined && snap !== null && Array.isArray(snap.events) ? snap.events : []
632
- baseline = baselineOf(events)
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
633
1901
  } catch (e) {
634
- console.error('[dsh-bridge] session baseline read failed:', errText(e).slice(0, 200))
1902
+ return failDelegate('grant-issue-failed', 'delegated dispatch refused: could not issue the task grant (' + errText(e) + ') — nothing was sent')
635
1903
  }
636
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)
637
1913
  const record = {
638
- sessionId: resolved.sessionId, taskId: task.taskId, deliver, baseline,
639
- total: '', sawRunning: false, activeSince: Date.now(), startedAt: Date.now(),
640
- settled: false, abort: undefined, watcher: 'pending',
641
- // prompt 被接受 = 消息已进会话队列。此后就不该再把它当"没发出去"(见 sweepSessionTargets)。
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,
1917
+ // 精确关联模式(带来源):靠 requestId→rpcId→turn 关联收结果,而不是派发前基线 + 全会话 idle。
1918
+ precise: originThreadId !== undefined,
1919
+ tracker: createRequestTracker(task.taskId, cut?.cursor),
1920
+ reported: 0, // prompt 被接受 = 消息已进会话队列。此后就不该再把它当"没发出去"(见 sweepSessionTargets)。
642
1921
  delivered: false, queuedNoted: false,
1922
+ // 投递控制:回执记录与父任务同 turn,绝不参与审批归属、也绝不等父任务的最终结果。
1923
+ receipt: mode === 'steer',
1924
+ parentTaskId,
1925
+ // 服务端捕获的目标父回合(B):观察时以它为准,expectedTurn 只是可选校验。
1926
+ parentTurn: task.parentTurn,
643
1927
  }
644
- sessionTargets.set(resolved.sessionId, record)
1928
+ record.tracker.openTurns = openTurnsOf(cut?.evidence || cut?.events || [])
1929
+ sessionTargets.set(task.taskId, record)
1930
+ if (mode !== 'steer') watchSessionTask(record)
645
1931
 
646
1932
  try {
647
1933
  const controllerRef = sessionControllerNow()
648
1934
  if (controllerRef === undefined) {
649
- sessionTargets.delete(resolved.sessionId)
1935
+ sessionTargets.delete(task.taskId)
650
1936
  record.settled = true
651
1937
  task.status = 'error'
652
1938
  task.error = 'sessionController service is not mounted in this profile'
@@ -657,12 +1943,14 @@ export async function apply(ctx) {
657
1943
  await controllerRef.prompt({
658
1944
  requestId: task.taskId,
659
1945
  sessionId: resolved.sessionId,
660
- mode: deliver,
1946
+ mode,
661
1947
  content: [{ type: 'text', text: instruction }],
662
- }, signalOf())
1948
+ }, taskAbort.signal)
1949
+ // 模型路由耗尽时 abort 的是**这个**任务的作用域 signal:观察循环据此收口(不误伤别的任务)。
1950
+ record.abort = taskAbort
663
1951
  } catch (e) {
664
1952
  const reason = mapControllerError(e)
665
- sessionTargets.delete(resolved.sessionId)
1953
+ sessionTargets.delete(task.taskId)
666
1954
  record.settled = true
667
1955
  task.status = 'error'
668
1956
  task.error = errText(e)
@@ -671,14 +1959,22 @@ export async function apply(ctx) {
671
1959
  return { task, failed: { reason, message: task.error, candidates: [] } }
672
1960
  }
673
1961
 
674
- watchSessionTask(record)
675
1962
  record.delivered = true
676
1963
  task.delivered = true
677
- pushTask(task.taskId, 'task-accepted', { target: { kind: 'session', sessionId: resolved.sessionId }, deliver, cwd: resolved.snippet, delivered: true })
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 })
678
1974
  return { task, failed: null }
679
1975
  }
680
1976
 
681
- const enqueue = async (instruction, incomingHistory, target, modelAlias, kind, deliveryMode, notes) => {
1977
+ const enqueue = async (instruction, incomingHistory, target, modelAlias, kind, deliveryMode, notes, originThreadId, delegateOptions, steering, routePolicy) => {
682
1978
  if (incomingHistory !== undefined) syncHistory(incomingHistory)
683
1979
  if (target !== undefined && target.kind === 'session') {
684
1980
  // session 目标:不注册工作区、不建 owner、不建子代理,失败也不降级为新建。
@@ -687,6 +1983,10 @@ export async function apply(ctx) {
687
1983
  deliveryMode === undefined ? 'queue' : deliveryMode,
688
1984
  Array.isArray(notes) ? notes : [],
689
1985
  incomingHistory,
1986
+ originThreadId,
1987
+ delegateOptions,
1988
+ steering,
1989
+ routePolicy,
690
1990
  )
691
1991
  return dispatched.task
692
1992
  }
@@ -694,6 +1994,19 @@ export async function apply(ctx) {
694
1994
  const lane = normalizeLane(target === undefined ? undefined : target.lane)
695
1995
  registerWorkspace(cwd)
696
1996
  const bucket = bucketOf(cwd, lane)
1997
+ // 同一通道(cwd+lane)只允许一个在途任务:完成事件只能定位到"这个桶里最老的 pending",
1998
+ // 两个并发任务会让结果归属变成猜测。这里明确拒绝(上一任务结束后再派下一条;不同 lane 不受影响)。
1999
+ const activeInBucket = tasks.find((t) => (t.status === 'accepted' || t.status === 'running' || (t.status === 'unknown' && t.taskId === bucket.executingTaskId)) && t.bucketKey === bucket.key)
2000
+ if (activeInBucket !== undefined) {
2001
+ return rejectBusy({
2002
+ kind, instruction, cwd, lane, bucketKey: bucket.key,
2003
+ target: { kind: 'worker', cwd, lane },
2004
+ originThreadId,
2005
+ activeTaskId: activeInBucket.taskId,
2006
+ reason: 'worker-bucket-busy',
2007
+ error: 'another task (' + activeInBucket.taskId + ') is still active on this channel; refusing to start a second one — its completion could not be attributed to a specific task. Wait for it to finish (or use another --lane).',
2008
+ })
2009
+ }
697
2010
  const requestedModel = await resolveModel(modelAlias)
698
2011
  if (requestedModel !== null && requestedModel !== bucket.model) {
699
2012
  bucket.model = requestedModel
@@ -703,18 +2016,61 @@ export async function apply(ctx) {
703
2016
  bucket.seenCount = 0
704
2017
  }
705
2018
  const task = { taskId: uid(), kind, instruction, cwd, lane, bucketKey: bucket.key, target: { kind: 'worker', cwd, lane }, status: 'accepted', time: Date.now(), result: undefined, error: undefined }
706
- tasks.push(task)
707
- if (tasks.length > 200) tasks.splice(0, tasks.length - 200)
2019
+ rememberTask(tasks, task)
2020
+ // 来源关联必须在**实际派活之前**落盘:绑定失败就不派活。
2021
+ const bound = bindAssociation(task, originThreadId)
2022
+ if (!bound.ok) {
2023
+ task.status = 'error'
2024
+ task.error = bound.message
2025
+ task.reason = bound.reason
2026
+ pushTask(task.taskId, 'error', { error: task.error, reason: task.reason, cwd, kind })
2027
+ return task
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
+ }
708
2065
  try {
709
- const delivered = await deliver(bucket, instruction, ROLE_BRIEF(cwd), '[Codex 指令] ')
2066
+ const delivered = await deliver(bucket, instruction, ROLE_BRIEF(cwd), '[Codex 指令] ', task.taskId)
710
2067
  if (delivered.fallback) {
711
- task.status = 'done'
712
- task.result = delivered.output
713
- task.stopReason = delivered.stopReason
714
- task.fallback = delivered.fallbackNote
715
- remember('user', instruction)
716
- remember('assistant', task.result)
717
- pushTask(task.taskId, 'result', { text: task.result, stopReason: delivered.stopReason, childId: null, cwd, kind })
2068
+ // 兜底路径**不再冒充成功**:worker 继续失败必须如实 error(不重派、不 one-shot 重做)。
2069
+ task.status = 'error'
2070
+ task.error = 'worker continuation degraded: ' + delivered.fallbackNote
2071
+ task.reason = 'worker-continuation-degraded'
2072
+ persistTask(task)
2073
+ pushTask(task.taskId, 'error', { error: task.error, reason: task.reason, cwd, kind })
718
2074
  } else {
719
2075
  bucket.activeSince = Date.now()
720
2076
  pushTask(task.taskId, 'task-accepted', { childId: bucket.childId, cwd, kind })
@@ -722,21 +2078,72 @@ export async function apply(ctx) {
722
2078
  } catch (e) {
723
2079
  task.status = 'error'
724
2080
  task.error = errText(e)
2081
+ persistTask(task)
725
2082
  pushTask(task.taskId, 'error', { error: task.error, cwd, kind })
726
2083
  }
727
2084
  return task
728
2085
  }
729
2086
 
730
- const submitReview = async (rawCwd, diff, focus, incomingHistory) => {
2087
+ const submitReview = async (rawCwd, diff, focus, incomingHistory, originThreadId, routePolicy) => {
731
2088
  if (incomingHistory !== undefined) syncHistory(incomingHistory)
732
2089
  const cwd = normalizeCwd(rawCwd)
733
2090
  const bucket = reviewerBucketOf(cwd)
2091
+ // 评审桶同理:同一目录只允许一个在途评审,否则完成结果无法归属到具体 taskId。
2092
+ const activeReview = tasks.find((t) => (t.status === 'accepted' || t.status === 'running' || (t.status === 'unknown' && t.taskId === bucket.executingTaskId)) && t.bucketKey === bucket.key)
2093
+ if (activeReview !== undefined) {
2094
+ return rejectBusy({
2095
+ kind: 'review', instruction: '(review rejected: channel busy)', cwd, lane: '@review', bucketKey: bucket.key,
2096
+ target: { kind: 'worker', cwd, lane: '@review' },
2097
+ originThreadId,
2098
+ activeTaskId: activeReview.taskId,
2099
+ reason: 'reviewer-bucket-busy',
2100
+ error: 'another review (' + activeReview.taskId + ') is still active for this directory; refusing to start a second one — its completion could not be attributed to a specific task. Wait for it to finish.',
2101
+ })
2102
+ }
734
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')
735
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 }
736
- tasks.push(task)
737
- if (tasks.length > 200) tasks.splice(0, tasks.length - 200)
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
+ }
2136
+ // 评审同样产生 taskId,因此同样支持来源关联;绑定失败不派活。
2137
+ const bound = bindAssociation(task, originThreadId)
2138
+ if (!bound.ok) {
2139
+ task.status = 'error'
2140
+ task.error = bound.message
2141
+ task.reason = bound.reason
2142
+ pushTask(task.taskId, 'error', { error: task.error, reason: task.reason, cwd, kind: 'review' })
2143
+ return task
2144
+ }
738
2145
  try {
739
- const delivered = await deliver(bucket, instruction, REVIEWER_BRIEF(cwd), '[评审请求] ')
2146
+ const delivered = await deliver(bucket, instruction, REVIEWER_BRIEF(cwd), '[评审请求] ', task.taskId)
740
2147
  if (delivered.fallback) {
741
2148
  task.status = 'done'
742
2149
  task.result = delivered.output
@@ -744,6 +2151,7 @@ export async function apply(ctx) {
744
2151
  task.fallback = delivered.fallbackNote
745
2152
  remember('user', '[评审请求] ' + (typeof focus === 'string' && focus !== '' ? focus : 'diff 审查'))
746
2153
  remember('assistant', task.result)
2154
+ persistTask(task)
747
2155
  pushTask(task.taskId, 'result', { text: task.result, stopReason: delivered.stopReason, childId: null, cwd, kind: 'review' })
748
2156
  } else {
749
2157
  bucket.activeSince = Date.now()
@@ -752,37 +2160,46 @@ export async function apply(ctx) {
752
2160
  } catch (e) {
753
2161
  task.status = 'error'
754
2162
  task.error = errText(e)
2163
+ persistTask(task)
755
2164
  pushTask(task.taskId, 'error', { error: task.error, cwd, kind: 'review' })
756
2165
  }
757
2166
  return task
758
2167
  }
759
2168
 
760
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
+ }
761
2182
  const pollOne = async (bucket) => {
762
2183
  if (bucket.childId === null || sessionQuery === undefined) return
763
2184
  const hasPending = tasks.some((t) => (t.status === 'accepted' || t.status === 'running') && t.bucketKey === bucket.key)
764
- let snap
765
- try { snap = await sessionQuery.readSession(bucket.childId) } catch (e) { return }
766
- const events = snap && Array.isArray(snap.events) ? snap.events : []
767
- let total = ''
768
- for (const e of events) total += assistantTextOf(e) + '\n'
769
- const fresh = total.slice(bucket.lastSeen)
770
- bucket.lastSeen = total.length
771
- const newEvents = events.length > bucket.seenCount
772
- bucket.seenCount = events.length
773
- if (!hasPending) return
774
- if (newEvents) bucket.activeSince = Date.now()
775
- if (fresh.trim() !== '') {
776
- const active = tasks.find((t) => (t.status === 'accepted' || t.status === 'running') && t.bucketKey === bucket.key)
777
- if (active !== undefined) active.status = 'running'
778
- pushTask(active !== undefined ? active.taskId : bucket.childId, 'progress', { text: fresh.trim(), cwd: bucket.cwd, kind: bucket.lane === '@review' ? 'review' : 'task' })
779
- }
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 || ''
780
2191
  if (bucket.activeSince > 0 && Date.now() - bucket.activeSince > 600000) {
781
2192
  const active = tasks.find((t) => (t.status === 'accepted' || t.status === 'running') && t.bucketKey === bucket.key)
782
2193
  if (active !== undefined && total.length > 0) {
783
- active.status = 'done'
784
- active.result = total.slice(Math.max(0, total.length - 4000)).trim() || '(无输出)'
785
- pushTask(active.taskId, 'result', { text: active.result, stopReason: 'quiet-timeout', childId: bucket.childId, cwd: bucket.cwd, kind: bucket.lane === '@review' ? 'review' : 'task' })
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'
2200
+ persistTask(active)
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()
786
2203
  bucket.activeSince = 0
787
2204
  }
788
2205
  }
@@ -793,12 +2210,74 @@ export async function apply(ctx) {
793
2210
  if (record.settled) continue
794
2211
  const task = tasks.find((t) => t.taskId === record.taskId)
795
2212
  if (task === undefined) { settleSessionTask(record, 'cancelled', {}); continue }
796
- if (task.status === 'cancelled') { record.settled = true; if (record.abort !== undefined) { try { record.abort.abort() } catch (e) { /* ignore */ } } sessionTargets.delete(record.sessionId); continue }
2213
+ if (task.status === 'cancelled') { record.settled = true; if (record.abort !== undefined) { try { record.abort.abort() } catch (e) { /* ignore */ } } sessionTargets.delete(record.taskId); continue }
797
2214
  const now = Date.now()
2215
+ // 回执(第四轮首次验收修正 C):**绝不**进入通用执行收口。
2216
+ // 它的生命周期只由 observeSteerReceipt/refreshReceiptNow 用"自身 rpcId 落进哪个回合"驱动,
2217
+ // 因此这里既不喂通用 tracker(会累计父任务助手文本),也不允许 settleFromTracker 抢结算/落盘父结果。
2218
+ if (record.receipt === true) continue
2219
+ // 精确关联模式:轮询/读日志只是观察机会,收尾只能由"自己那个 turn 的 turn/end"触发。
2220
+ if (record.precise === true) {
2221
+ if (!observations.healthy(record.sessionId)) await observeSessionLog(record)
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
+ }
2228
+ if (record.tracker.end !== undefined) { settleFromTracker(record); continue }
2229
+ if (record.watcher !== 'follow') pushPreciseProgress(record)
2230
+ if (!record.sawRunning && now - record.startedAt > SESSION_DISPATCH_TIMEOUT_MS && record.delivered === true && record.queuedNoted !== true) {
2231
+ record.queuedNoted = true
2232
+ pushTask(record.taskId, 'note', {
2233
+ note: 'QUEUED',
2234
+ text: 'prompt 已被接受并进入该会话队列,等它空闲时会跑;不要重发',
2235
+ target: { kind: 'session', sessionId: record.sessionId },
2236
+ delivered: true,
2237
+ })
2238
+ }
2239
+ if (!record.sawRunning && now - record.startedAt > SESSION_DISPATCH_TIMEOUT_MS && record.delivered !== true) {
2240
+ settleSessionTask(record, 'error', { error: 'the session never accepted the prompt', reason: REASONS.NOT_ACCEPTED, delivered: false })
2241
+ continue
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
+ }
2259
+ // quiet / 总预算到期但**没有**自己 turn 的结束证据:只能报未确认(resend:false),绝不猜 done。
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
2263
+ if (quietExpired || budgetExpired) {
2264
+ settleSessionTask(record, 'unknown', {
2265
+ error: record.delivered === true
2266
+ ? 'prompt 已投递进该会话队列,但没有观察到本请求自己那个 turn 的结束(可能仍在排队/仍在跑/已被别的回合掩盖);执行状态未确认,不要重发'
2267
+ : 'the session never accepted the prompt within the budget',
2268
+ reason: REASONS.UNCONFIRMED,
2269
+ resend: false,
2270
+ note: 'execution state unconfirmed: no turn/end matching this request\'s requestId was observed',
2271
+ ...(record.delivered === true ? { delivered: true } : {}),
2272
+ })
2273
+ }
2274
+ continue
2275
+ }
798
2276
  if (record.watcher === 'poll' && sessionQuery !== undefined) {
799
2277
  try {
800
- const snap = await sessionQuery.readSession(record.sessionId)
2278
+ const snap = await observations.capture(record.sessionId)
801
2279
  const events = snap !== undefined && snap !== null && Array.isArray(snap.events) ? snap.events : []
2280
+ observeApprovalEvents(record.sessionId, events)
802
2281
  const extracted = extractNewAssistantText(events, record.baseline)
803
2282
  record.baseline = extracted.baseline
804
2283
  if (extracted.text.trim() !== '') {
@@ -809,6 +2288,21 @@ export async function apply(ctx) {
809
2288
  }
810
2289
  } catch (e) { /* 读不到就下一轮再试 */ }
811
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
+ }
812
2306
  // 派发超时只在"prompt 没被接受"时才算失败。
813
2307
  // queue 模式下 prompt 被接受就意味着消息已进队列,会在会话空闲时跑;此时报 error
814
2308
  // 是误报——实测会话正跑一个 38 分钟的回合,排队等待远超 120s 完全正常,而误报会
@@ -830,11 +2324,12 @@ export async function apply(ctx) {
830
2324
  continue
831
2325
  }
832
2326
  }
833
- if (record.sawRunning && record.activeSince > 0 && now - record.activeSince > SESSION_QUIET_TIMEOUT_MS) {
2327
+ // 有认证活跃委托时不按 quiet 判完成(慢模型长时间无输出正常;grant 有效期才是界)。
2328
+ if (delegated === null && record.sawRunning && record.activeSince > 0 && now - record.activeSince > SESSION_QUIET_TIMEOUT_MS) {
834
2329
  finishSessionTask(record, 'quiet-timeout')
835
2330
  continue
836
2331
  }
837
- if (now - record.startedAt > SESSION_TOTAL_TIMEOUT_MS) {
2332
+ if (now - record.startedAt > budgetMs) {
838
2333
  settleSessionTask(record, 'error', {
839
2334
  error: record.delivered === true
840
2335
  ? 'prompt 已投递进该会话队列,但预算内它始终没有开始这一轮(会话可能一直在忙,或未被激活);不要重发,可用 status 继续观察'
@@ -851,28 +2346,111 @@ export async function apply(ctx) {
851
2346
  {
852
2347
  ctx.effect(() => {
853
2348
  const off = ctx.on('api-session/status', (sessionId, running) => {
854
- const record = sessionTargets.get(sessionId)
855
- if (record === undefined || record.settled) return
856
- const task = tasks.find((t) => t.taskId === record.taskId)
857
- if (running === true) {
858
- record.sawRunning = true
859
- record.activeSince = Date.now()
860
- if (task !== undefined && task.status === 'accepted') task.status = 'running'
861
- return
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')
862
2375
  }
863
- if (running === false && record.sawRunning) finishSessionTask(record, 'stop')
864
2376
  })
865
2377
  return () => { if (typeof off === 'function') off() }
866
2378
  })
867
2379
  }
868
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
869
2444
  if (timer !== undefined) {
870
2445
  ctx.effect(() => timer.interval(() => {
2446
+ if (sweeping) return
2447
+ sweeping = true
871
2448
  void (async () => {
872
2449
  for (const bucket of buckets.values()) await pollOne(bucket)
873
2450
  for (const bucket of reviewerBuckets.values()) await pollOne(bucket)
874
2451
  await sweepSessionTargets()
875
- })()
2452
+ observations.prune()
2453
+ })().catch((e) => console.error('[dsh-bridge] observation failed:', errText(e).slice(0, 200))).finally(() => { sweeping = false })
876
2454
  }, 2500))
877
2455
  }
878
2456
 
@@ -917,6 +2495,61 @@ export async function apply(ctx) {
917
2495
  if (!dryRun && instruction.trim() === '') { sendJson(res, 400, { error: 'missing instruction' }); return }
918
2496
  const classified = classifyTarget(parsed)
919
2497
  if (!classified.ok) { sendJson(res, 400, { error: classified.message, reason: classified.reason }); return }
2498
+ // 来源关联(R1):非法值在**外部执行前**拒绝;dryRun 不写任何关联。
2499
+ const origin = originOf(parsed)
2500
+ if (!origin.ok) {
2501
+ sendJson(res, 400, { error: 'originThreadId must be a non-empty string (<= 200 chars, no control characters)', reason: ASSOC_REASONS.INVALID_ORIGIN })
2502
+ return
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
+ }
920
2553
  // dryRun:只解析目标、回显它,**不投递任何东西**。存在的意义是让"我这条查询会命中
921
2554
  // 哪条会话"能纯只读地问出来——真实事故里调用方就是因为没法先验证,才发了一条占位
922
2555
  // 消息去探测,结果污染了用户会话。
@@ -956,23 +2589,49 @@ export async function apply(ctx) {
956
2589
  })
957
2590
  return
958
2591
  }
959
- const task = await enqueue(instruction, parsed.history, classified.target, parsed.model, 'task', classified.deliver, classified.notes)
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)
960
2594
  if (task.status === 'error') {
961
2595
  sendJson(res, 400, {
962
2596
  error: task.error,
963
2597
  reason: task.reason,
2598
+ ...(task.originThreadId === undefined ? {} : { originThreadId: task.originThreadId }),
964
2599
  ...(task.delivered === true ? { delivered: true } : {}),
2600
+ ...(task.route === undefined ? {} : { route: task.route }),
965
2601
  ...(Array.isArray(task.candidates) && task.candidates.length > 0 ? { candidates: task.candidates } : {}),
966
2602
  })
967
2603
  return
968
2604
  }
969
2605
  sendJson(res, 202, {
970
2606
  ...taskResponse(task),
2607
+ ...(task.route === undefined ? {} : { route: task.route }),
971
2608
  model: classified.target.kind === 'worker' ? (parsed.model || null) : null,
972
2609
  })
973
2610
  },
974
2611
  }))
975
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
+
976
2635
  // 只读"找到会话":不创建、不投递、不激活 Agent(search/附件/历史页都不激活)。
977
2636
  ctx.effect(() => webServer.register({
978
2637
  kind: 'exact',
@@ -1068,8 +2727,26 @@ export async function apply(ctx) {
1068
2727
  if (req.method !== 'POST') { res.writeHead(405); res.end('POST only'); return }
1069
2728
  const parsed = await readJsonBody(req, res)
1070
2729
  if (parsed === null) return
1071
- const task = await submitReview(parsed.cwd, parsed.diff, parsed.focus, parsed.history)
1072
- sendJson(res, 202, { taskId: task.taskId, status: task.status, cwd: task.cwd, kind: 'review' })
2730
+ const origin = originOf(parsed)
2731
+ if (!origin.ok) {
2732
+ sendJson(res, 400, { error: 'originThreadId must be a non-empty string (<= 200 chars, no control characters)', reason: ASSOC_REASONS.INVALID_ORIGIN })
2733
+ return
2734
+ }
2735
+ const task = await submitReview(parsed.cwd, parsed.diff, parsed.focus, parsed.history, origin.originThreadId, parseRoutePolicy(parsed))
2736
+ if (task.status === 'error') {
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
+ })
2743
+ return
2744
+ }
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
+ })
1073
2750
  },
1074
2751
  }))
1075
2752
 
@@ -1078,7 +2755,31 @@ export async function apply(ctx) {
1078
2755
  path: '/api/dsh-bridge/tasks',
1079
2756
  handler: async (req, res) => {
1080
2757
  if (!isLoopback(req)) { res.writeHead(403); res.end('forbidden'); return }
1081
- sendJson(res, 200, { tasks: tasks.slice(-50).map((t) => ({ taskId: t.taskId, kind: t.kind, cwd: t.cwd, lane: t.lane, target: t.target, deliver: t.deliver, status: t.status, time: t.time, instruction: t.instruction.slice(0, 200), error: t.error, reason: t.reason, fallback: t.fallback })) })
2758
+ const qs = qsOf(req)
2759
+ const origin = originOf(qs)
2760
+ if (!origin.ok) {
2761
+ sendJson(res, 400, { error: 'originThreadId must be a non-empty string (<= 200 chars, no control characters)', reason: ASSOC_REASONS.INVALID_ORIGIN })
2762
+ return
2763
+ }
2764
+ const projection = (t) => ({ taskId: t.taskId, kind: t.kind, cwd: t.cwd, lane: t.lane, target: t.target, deliver: t.deliver, status: t.status, time: t.time, instruction: t.instruction.slice(0, 200), error: t.error, reason: t.reason, fallback: t.fallback, ...(t.originThreadId === undefined ? {} : { originThreadId: t.originThreadId }) })
2765
+ // 带 originThreadId:跨回合按来源找任务(含只存在于持久记录里的历史任务)。
2766
+ if (origin.originThreadId !== undefined) {
2767
+ const live = tasks.filter((t) => t.originThreadId === origin.originThreadId)
2768
+ const liveIds = new Set(live.map((t) => t.taskId))
2769
+ const listed = associations === null ? { ok: false, reason: 'association-store-unavailable' } : associations.byOrigin(origin.originThreadId)
2770
+ if (listed.ok !== true) {
2771
+ sendJson(res, 503, { error: 'task association store is unavailable in this gateway', reason: listed.reason })
2772
+ return
2773
+ }
2774
+ const byId = new Map(listed.tasks.map((r) => [r.taskId, r]))
2775
+ const merged = [
2776
+ ...live.map((t) => ({ ...projectStoredTask(byId.get(t.taskId) === undefined ? { taskId: t.taskId, originThreadId: t.originThreadId, kind: t.kind, target: t.target, status: t.status, createdAt: t.time, updatedAt: t.time } : byId.get(t.taskId)), instruction: t.instruction.slice(0, 200), live: true })),
2777
+ ...listed.tasks.filter((r) => !liveIds.has(r.taskId)).map((r) => projectStoredTask(r)),
2778
+ ]
2779
+ sendJson(res, 200, { originThreadId: origin.originThreadId, count: merged.length, tasks: merged, recovered: listed.tasks.some((r) => !liveIds.has(r.taskId)), live: live.length, capabilities: capabilityReport() })
2780
+ return
2781
+ }
2782
+ sendJson(res, 200, { tasks: tasks.slice(-50).map(projection), capabilities: capabilityReport() })
1082
2783
  },
1083
2784
  }))
1084
2785
 
@@ -1091,12 +2792,58 @@ export async function apply(ctx) {
1091
2792
  if (parsed === null) return
1092
2793
  const taskId = parsed && parsed.taskId
1093
2794
  if (typeof taskId !== 'string' || taskId === '') { sendJson(res, 400, { error: 'missing taskId' }); return }
2795
+ const origin = originOf(parsed)
2796
+ if (!origin.ok) {
2797
+ sendJson(res, 400, { error: 'originThreadId must be a non-empty string (<= 200 chars, no control characters)', reason: ASSOC_REASONS.INVALID_ORIGIN })
2798
+ return
2799
+ }
1094
2800
  const task = tasks.find((t) => t.taskId === taskId)
1095
2801
  if (task === undefined) { sendJson(res, 404, { error: 'unknown taskId' }); return }
1096
- if (task.status !== 'accepted' && task.status !== 'running') { sendJson(res, 200, { taskId, cancelled: false, status: task.status }); return }
2802
+ // 已关联的任务:缺来源/错来源都拒绝(同一个入口规则与 /status 一致)。
2803
+ if (task.originThreadId !== undefined) {
2804
+ if (origin.originThreadId === undefined) {
2805
+ sendJson(res, 404, {
2806
+ error: 'task ' + taskId + ' is bound to a source conversation; pass originThreadId to cancel it',
2807
+ reason: ASSOC_REASONS.MISSING_ORIGIN,
2808
+ hint: 'pass originThreadId (the Codex conversation that dispatched this task)',
2809
+ })
2810
+ return
2811
+ }
2812
+ if (task.originThreadId !== origin.originThreadId) {
2813
+ sendJson(res, 404, { error: 'task ' + taskId + ' is bound to another origin', reason: ASSOC_REASONS.ORIGIN_MISMATCH })
2814
+ return
2815
+ }
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
+ }
2831
+ if (task.status !== 'accepted' && task.status !== 'running') { sendJson(res, 200, { taskId, cancelled: false, status: task.status, ...(task.originThreadId === undefined ? {} : { originThreadId: task.originThreadId }) }); return }
1097
2832
 
1098
2833
  // session 目标动的是用户自己的对话:取消会打断他正在跑的回合,因此必须显式 force。
1099
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
+ }
1100
2847
  if (parsed.force !== true) {
1101
2848
  sendJson(res, 409, {
1102
2849
  taskId,
@@ -1117,10 +2864,11 @@ export async function apply(ctx) {
1117
2864
  }
1118
2865
  task.status = 'cancelled'
1119
2866
  task.cancelReason = 'cancelled by request (force)'
1120
- const record = sessionTargets.get(task.target.sessionId)
2867
+ const record = recordForTask(taskId)
1121
2868
  if (record !== undefined) settleSessionTask(record, 'cancelled', {})
2869
+ persistTask(task)
1122
2870
  pushTask(taskId, 'cancelled', { target: { kind: 'session', sessionId: task.target.sessionId } })
1123
- sendJson(res, 200, { taskId, cancelled: true, interrupted: cancelled, target: task.target })
2871
+ sendJson(res, 200, { taskId, cancelled: true, interrupted: cancelled, target: task.target, ...(task.originThreadId === undefined ? {} : { originThreadId: task.originThreadId }) })
1124
2872
  return
1125
2873
  }
1126
2874
 
@@ -1136,8 +2884,309 @@ export async function apply(ctx) {
1136
2884
  } catch (e) {
1137
2885
  console.error('[dsh-bridge] cancel interrupt failed:', errText(e).slice(0, 200))
1138
2886
  }
2887
+ persistTask(task)
1139
2888
  pushTask(taskId, 'cancelled', { cwd: task.cwd })
1140
- sendJson(res, 200, { taskId, cancelled: true, interrupted })
2889
+ sendJson(res, 200, { taskId, cancelled: true, interrupted, ...(task.originThreadId === undefined ? {} : { originThreadId: task.originThreadId }) })
2890
+ },
2891
+ }))
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' })
1141
3190
  },
1142
3191
  }))
1143
3192
 
@@ -1147,30 +3196,173 @@ export async function apply(ctx) {
1147
3196
  handler: async (req, res) => {
1148
3197
  if (!isLoopback(req)) { res.writeHead(403); res.end('forbidden'); return }
1149
3198
  const qs = qsOf(req)
3199
+ const origin = originOf(qs)
3200
+ if (!origin.ok) {
3201
+ sendJson(res, 400, { error: 'originThreadId must be a non-empty string (<= 200 chars, no control characters)', reason: ASSOC_REASONS.INVALID_ORIGIN })
3202
+ return
3203
+ }
1150
3204
  const taskId = qs.taskId
1151
3205
  if (taskId !== undefined && taskId !== '') {
1152
3206
  const task = tasks.find((t) => t.taskId === taskId)
1153
- if (task === undefined) { sendJson(res, 404, { error: 'unknown taskId' }); return }
1154
- const record = task.target !== undefined && task.target.kind === 'session' ? sessionTargets.get(task.target.sessionId) : undefined
3207
+ if (task !== undefined) {
3208
+ // 已关联的任务:缺来源 → origin-required;错来源 → origin-mismatch。两条都**不返回结果**。
3209
+ // 只有旧的"未关联"任务才继续支持只按 taskId 查询(兼容)。
3210
+ if (task.originThreadId !== undefined) {
3211
+ if (origin.originThreadId === undefined) {
3212
+ sendJson(res, 404, {
3213
+ error: 'task ' + taskId + ' is bound to a source conversation; pass originThreadId to read it',
3214
+ reason: ASSOC_REASONS.MISSING_ORIGIN,
3215
+ hint: 'pass originThreadId (the Codex conversation that dispatched this task)',
3216
+ })
3217
+ return
3218
+ }
3219
+ if (task.originThreadId !== origin.originThreadId) {
3220
+ sendJson(res, 404, { error: 'task ' + taskId + ' is bound to another origin', reason: ASSOC_REASONS.ORIGIN_MISMATCH })
3221
+ return
3222
+ }
3223
+ }
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)
3230
+ sendJson(res, 200, {
3231
+ taskId: task.taskId, status: task.status, kind: task.kind, cwd: task.cwd, lane: task.lane,
3232
+ target: task.target, deliver: task.deliver, instruction: task.instruction.slice(0, 300),
3233
+ result: task.result, error: task.error, reason: task.reason, fallback: task.fallback,
3234
+ // delivered=true 表示 prompt 已被会话接受(进了队列)——即便后面报错也不要重发。
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 },
3248
+ ...(task.originThreadId === undefined ? {} : { originThreadId: task.originThreadId }),
3249
+ ...(task.requestId === undefined ? {} : { requestId: task.requestId }),
3250
+ ...(task.dshTurn === undefined ? {} : { dshTurn: task.dshTurn }),
3251
+ ...(task.turnEndKind === undefined ? {} : { turnEndKind: task.turnEndKind }),
3252
+ ...(task.partialResult === undefined ? {} : { partialResult: task.partialResult }),
3253
+ ...(task.resend === undefined ? {} : { resend: task.resend }),
3254
+ ...(task.note === undefined ? {} : { note: task.note }),
3255
+ ...(task.associationError === undefined ? {} : { associationError: task.associationError }),
3256
+ ...(task.matchedBy === undefined ? {} : { matchedBy: task.matchedBy }),
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
+ : {}),
3262
+ ...(record === undefined ? {} : { watcher: record.watcher, sawRunning: record.sawRunning }),
3263
+ })
3264
+ return
3265
+ }
3266
+
3267
+ // 内存里没有(网关重载/新实例):从持久关联记录恢复。
3268
+ const stored = associations === null
3269
+ ? { ok: false, reason: 'association-store-unavailable' }
3270
+ : associations.fetch({ originThreadId: origin.originThreadId, taskId })
3271
+ if (stored.ok !== true) {
3272
+ const hint = stored.reason === ASSOC_REASONS.MISSING_ORIGIN
3273
+ ? 'pass originThreadId to recover a persisted task result'
3274
+ : (stored.reason === ASSOC_REASONS.ORIGIN_MISMATCH ? 'this task is bound to another origin' : undefined)
3275
+ sendJson(res, 404, {
3276
+ error: stored.reason === ASSOC_REASONS.UNKNOWN_TASK ? 'unknown taskId' : 'cannot recover task ' + taskId,
3277
+ reason: stored.reason,
3278
+ ...(hint === undefined ? {} : { hint }),
3279
+ })
3280
+ return
3281
+ }
3282
+ const record = stored.record
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')))
1155
3300
  sendJson(res, 200, {
1156
- taskId: task.taskId, status: task.status, kind: task.kind, cwd: task.cwd, lane: task.lane,
1157
- target: task.target, deliver: task.deliver, instruction: task.instruction.slice(0, 300),
1158
- result: task.result, error: task.error, reason: task.reason, fallback: task.fallback,
1159
- // delivered=true 表示 prompt 已被会话接受(进了队列)——即便后面报错也不要重发。
1160
- ...(task.delivered === undefined ? {} : { delivered: task.delivered }),
1161
- ...(task.matchedBy === undefined ? {} : { matchedBy: task.matchedBy }),
1162
- ...(Array.isArray(task.candidates) && task.candidates.length > 0 ? { candidates: task.candidates } : {}),
1163
- ...(record === undefined ? {} : { watcher: record.watcher, sawRunning: record.sawRunning }),
3301
+ taskId: record.taskId,
3302
+ originThreadId: record.originThreadId,
3303
+ kind: record.kind,
3304
+ target: record.target,
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 },
3313
+ // 非终态记录:**不谎报已完成**,也不重新观察(未实现),明确标未知并禁止重发。
3314
+ // 回执例外:自身已观察到的回执**不**降级(父任务执行状态仍可 unknown)。
3315
+ status: receiptStatus !== undefined ? receiptStatus : (terminal ? record.status : 'unknown'),
3316
+ persistedStatus: record.status,
3317
+ recovered: true,
3318
+ ...(receiptStatus !== undefined
3319
+ ? {
3320
+ resend: false,
3321
+ note: '补充回执的自身证据已持久化(收据/回合/来源是两维事实);父任务的执行结果请查父 task。',
3322
+ }
3323
+ : (terminal
3324
+ ? {}
3325
+ : {
3326
+ reason: 'execution-state-unconfirmed',
3327
+ note: '这条任务在执行/交付未确认前就失去了运行时上下文(网关重载或新实例);不重发、不重新观察。原目标见 target。',
3328
+ resend: false,
3329
+ })),
3330
+ ...(record.delivered === undefined ? {} : { delivered: record.delivered }),
3331
+ ...(record.requestId === undefined ? {} : { requestId: record.requestId }),
3332
+ ...(record.dshTurn === undefined ? {} : { dshTurn: record.dshTurn }),
3333
+ ...(record.result === undefined ? {} : { result: record.result }),
3334
+ ...(record.error === undefined ? {} : { error: record.error }),
3335
+ ...(record.stopReason === undefined ? {} : { stopReason: record.stopReason }),
3336
+ createdAt: record.createdAt,
3337
+ updatedAt: record.updatedAt,
3338
+ })
3339
+ return
3340
+ }
3341
+
3342
+ // 没有 taskId:只给来源时列出该来源登记过的任务,用来找回丢失的 taskId。
3343
+ if (origin.originThreadId !== undefined) {
3344
+ const listed = associations === null ? { ok: false, reason: 'association-store-unavailable' } : associations.byOrigin(origin.originThreadId)
3345
+ if (listed.ok !== true) {
3346
+ sendJson(res, 503, { error: 'task association store is unavailable in this gateway', reason: listed.reason })
3347
+ return
3348
+ }
3349
+ const liveIds = new Set(tasks.filter((t) => t.originThreadId === origin.originThreadId).map((t) => t.taskId))
3350
+ sendJson(res, 200, {
3351
+ originThreadId: origin.originThreadId,
3352
+ count: listed.tasks.length,
3353
+ // 与精确查询同一套语义:本实例没有运行上下文的非终态记录只能标 unknown(绝不谎报还在观察)。
3354
+ tasks: listed.tasks.map((r) => ({ ...projectStoredTask(r), live: liveIds.has(r.taskId) })),
1164
3355
  })
1165
3356
  return
1166
3357
  }
1167
- const pending = tasks.filter((t) => t.status === 'accepted' || t.status === 'running').length
3358
+ const pending = tasks.filter((t) => isNonTerminal(t)).length
1168
3359
  sendJson(res, 200, {
1169
3360
  pending,
1170
3361
  lanes: Array.from(buckets.keys()),
1171
3362
  reviewers: Array.from(reviewerBuckets.keys()),
1172
- sessions: Array.from(sessionTargets.keys()),
3363
+ sessions: Array.from(new Set(Array.from(sessionTargets.values()).map((r) => r.sessionId))),
1173
3364
  sessionController: sessionControllerNow() !== undefined,
3365
+ capabilities: capabilityReport(),
1174
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 })),
1175
3367
  })
1176
3368
  },
@@ -1189,8 +3381,12 @@ export async function apply(ctx) {
1189
3381
  sessionController: probe('sessionController'),
1190
3382
  services: Object.fromEntries(['sessionController', 'connection', 'sessionQuery', 'sessionPersistence', 'workspaceRegistry', 'agents', 'subagents', 'agentPresets', 'timer', 'llm'].map((n) => [n, probe(n)])),
1191
3383
  sessionTargets: [], models: knownModels, modelProvider, modelListError, workspaces: [],
3384
+ capabilities: capabilityReport(),
3385
+ observation: observations.status(),
3386
+ bridgeVersion,
3387
+ routeAuditTail: routeAuditReader.status(),
1192
3388
  }
1193
- out.pending = tasks.filter((t) => t.status === 'accepted' || t.status === 'running').length
3389
+ out.pending = tasks.filter((t) => isNonTerminal(t)).length
1194
3390
  try { out.providers = subagents.list() } catch (e) { out.providers = ['list failed: ' + errText(e)] }
1195
3391
  if (agentPresets !== undefined) {
1196
3392
  try {
@@ -1237,13 +3433,21 @@ export async function apply(ctx) {
1237
3433
  try { socket.write(JSON.stringify({ type: 'pong', time: Date.now() }) + '\n') } catch (e) {}
1238
3434
  } else if (msg.type === 'instruction' && typeof msg.text === 'string' && msg.text.trim() !== '') {
1239
3435
  const classified = classifyTarget(msg)
1240
- if (!classified.ok) {
1241
- try { socket.write(JSON.stringify({ type: 'error', reason: classified.reason, error: classified.message, time: Date.now() }) + '\n') } catch (e) { /* drop */ }
3436
+ const origin = originOf(msg)
3437
+ if (!classified.ok || !origin.ok) {
3438
+ const reason = classified.ok ? ASSOC_REASONS.INVALID_ORIGIN : classified.reason
3439
+ const error = classified.ok ? 'originThreadId must be a non-empty string (<= 200 chars, no control characters)' : classified.message
3440
+ try { socket.write(JSON.stringify({ type: 'error', reason, error, time: Date.now() }) + '\n') } catch (e) { /* drop */ }
1242
3441
  } else {
1243
- void enqueue(msg.text, msg.history, classified.target, msg.model, 'task', classified.deliver, classified.notes)
3442
+ void enqueue(msg.text, msg.history, classified.target, msg.model, 'task', classified.deliver, classified.notes, origin.originThreadId)
1244
3443
  }
1245
3444
  } else if (msg.type === 'review') {
1246
- void submitReview(msg.cwd, msg.diff, msg.focus, msg.history)
3445
+ const origin = originOf(msg)
3446
+ if (!origin.ok) {
3447
+ try { socket.write(JSON.stringify({ type: 'error', reason: ASSOC_REASONS.INVALID_ORIGIN, error: 'originThreadId must be a non-empty string (<= 200 chars, no control characters)', time: Date.now() }) + '\n') } catch (e) { /* drop */ }
3448
+ } else {
3449
+ void submitReview(msg.cwd, msg.diff, msg.focus, msg.history, origin.originThreadId)
3450
+ }
1247
3451
  }
1248
3452
  }
1249
3453
  }
@@ -1270,14 +3474,19 @@ export async function apply(ctx) {
1270
3474
  model: { type: 'string', description: '模型别名: default | fast | pro。' },
1271
3475
  sessionId: { type: 'string', description: '已有会话 id:把指令投递进这条会话,而不是新建。' },
1272
3476
  sessionQuery: { type: 'string', description: '按标题/内容搜索已有会话(唯一命中才生效,多条或落空会返回候选)。' },
1273
- deliver: { type: 'string', description: "投递方式: queue(默认,排队) 或 steer(插入当前回合)。" },
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;与实际不符则零投递拒绝。' },
1274
3480
  dryRun: { type: 'boolean', description: '为 true 时只解析目标并回显(含 matchedBy 与候选),不投递任何消息——验证命中用它。' },
3481
+ originThreadId: { type: 'string', description: '发起本任务的 Codex 对话 id(来源关联)。必须是显式传入的非空字符串——不要用常驻进程环境或"最近对话"推断。用于之后按 origin+taskId 精确收取结果。' },
1275
3482
  wait: { type: 'boolean', description: '为 true 时等待结果(最长 15 分钟)。' },
1276
3483
  },
1277
3484
  output: { schema: { type: 'json' }, render: (_args, value) => [{ type: 'text', text: JSON.stringify(value, null, 2) }] },
1278
3485
  async execute(args) {
1279
3486
  const classified = classifyTarget(args)
1280
3487
  if (!classified.ok) return { ok: false, reason: classified.reason, error: classified.message }
3488
+ const origin = originOf(args)
3489
+ if (!origin.ok) return { ok: false, reason: ASSOC_REASONS.INVALID_ORIGIN, error: 'originThreadId must be a non-empty string (<= 200 chars, no control characters)' }
1281
3490
  // dryRun:只解析目标、回显,不投递。给调用方一条"先验证命中"的正路,免得它发消息试探。
1282
3491
  if (args.dryRun === true) {
1283
3492
  if (classified.target.kind === 'session') {
@@ -1298,7 +3507,34 @@ export async function apply(ctx) {
1298
3507
  const lane = normalizeLane(classified.target.lane)
1299
3508
  return { ok: true, dryRun: true, resolved: true, target: { kind: 'worker', cwd, lane }, note: '只解析目标,未派活' }
1300
3509
  }
1301
- const task = await enqueue(args.instruction, undefined, classified.target, args.model, 'task', classified.deliver, classified.notes)
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)
1302
3538
  if (task.status === 'error') {
1303
3539
  return { ok: false, taskId: task.taskId, reason: task.reason, error: task.error, ...(task.delivered === true ? { delivered: true } : {}), ...(task.candidates === undefined ? {} : { candidates: task.candidates }) }
1304
3540
  }
@@ -1306,8 +3542,29 @@ export async function apply(ctx) {
1306
3542
  if (args.wait !== true || timer === undefined) {
1307
3543
  return brief
1308
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 也是"在途",等待时要一直等到真正的终态(或超时)。
1309
3566
  const t0 = Date.now()
1310
- while (task.status === 'accepted' || task.status === 'running') {
3567
+ while (task.status === 'accepted' || task.status === 'queued' || task.status === 'running') {
1311
3568
  if (Date.now() - t0 > 900000) return { ...brief, status: task.status, result: task.result, note: 'wait 超时' }
1312
3569
  await timer.timeout(2500)
1313
3570
  }
@@ -1322,13 +3579,16 @@ export async function apply(ctx) {
1322
3579
  cwd: { type: 'string', required: true, description: '评审目标所在工作目录。' },
1323
3580
  diff: { type: 'string', description: '变更 diff 文本(git diff 输出)。' },
1324
3581
  focus: { type: 'string', description: '审查重点。' },
3582
+ originThreadId: { type: 'string', description: '发起本次评审的 Codex 对话 id(来源关联)。必须显式传入;评审同样产生 taskId,因此同样支持按 origin+taskId 收取。' },
1325
3583
  wait: { type: 'boolean', description: '为 true 时等待评审结果(最长 15 分钟)。' },
1326
3584
  },
1327
3585
  output: { schema: { type: 'json' }, render: (_args, value) => [{ type: 'text', text: JSON.stringify(value, null, 2) }] },
1328
3586
  async execute(args) {
1329
- const task = await submitReview(args.cwd, args.diff, args.focus, undefined)
3587
+ const origin = originOf(args)
3588
+ if (!origin.ok) return { ok: false, reason: ASSOC_REASONS.INVALID_ORIGIN, error: 'originThreadId must be a non-empty string (<= 200 chars, no control characters)' }
3589
+ const task = await submitReview(args.cwd, args.diff, args.focus, undefined, origin.originThreadId)
1330
3590
  if (args.wait !== true || timer === undefined) {
1331
- return { taskId: task.taskId, status: task.status, cwd: task.cwd }
3591
+ return { taskId: task.taskId, status: task.status, cwd: task.cwd, ...(task.originThreadId === undefined ? {} : { originThreadId: task.originThreadId }) }
1332
3592
  }
1333
3593
  const t0 = Date.now()
1334
3594
  while (task.status === 'accepted' || task.status === 'running') {
@@ -1343,9 +3603,11 @@ export async function apply(ctx) {
1343
3603
  for (const s of sockets) { try { s.destroy() } catch (e) {} }
1344
3604
  sockets.clear()
1345
3605
  for (const bucket of buckets.values()) {
3606
+ bucket.observationAbort?.abort()
1346
3607
  if (bucket.handle !== null) { void bucket.handle.dispose() }
1347
3608
  }
1348
3609
  for (const bucket of reviewerBuckets.values()) {
3610
+ bucket.observationAbort?.abort()
1349
3611
  if (bucket.handle !== null) { void bucket.handle.dispose() }
1350
3612
  }
1351
3613
  for (const record of sessionTargets.values()) {