@whaletalk/dsh-codex-collab 0.1.9 → 0.1.10

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