@whaletalk/dsh-codex-collab 0.1.6 → 0.1.9

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.
@@ -20,6 +20,12 @@
20
20
  * AbortSignal 可用,因此 followup 的 cold-resume 路径不再降级。
21
21
  */
22
22
  import { defineTool } from '@deepseek-ai/dsh-tools'
23
+ import {
24
+ ASSOC_REASONS,
25
+ isValidOrigin,
26
+ openAssociations,
27
+ } from './codex-associations.mjs'
28
+ import { rememberTask } from './task-table.mjs'
23
29
  import {
24
30
  REASONS,
25
31
  textOfBlocks,
@@ -32,7 +38,12 @@ import {
32
38
  mapControllerError,
33
39
  sessionTitleOf,
34
40
  looksLikeDisabledSearch,
41
+ rankTitleCandidates,
35
42
  taskResponse,
43
+ createRequestTracker,
44
+ consumeSessionEvent,
45
+ consumeSnapshotFrame,
46
+ turnOutcome,
36
47
  } from './session-target.mjs'
37
48
 
38
49
  export const name = 'dsh-bridge'
@@ -203,6 +214,146 @@ export async function apply(ctx) {
203
214
  const tasks = []
204
215
  const sockets = new Set()
205
216
 
217
+ /** 把持久记录投影成"跨回合可读"的形态。 */
218
+ const projectStoredTask = (record) => {
219
+ const liveTask = tasks.find((t) => t.taskId === record.taskId)
220
+ const terminal = record.status === 'done' || record.status === 'error' || record.status === 'cancelled'
221
+ const base = {
222
+ taskId: record.taskId,
223
+ originThreadId: record.originThreadId,
224
+ kind: record.kind,
225
+ target: record.target,
226
+ createdAt: record.createdAt,
227
+ updatedAt: record.updatedAt,
228
+ live: liveTask !== undefined,
229
+ ...(record.requestId === undefined ? {} : { requestId: record.requestId }),
230
+ ...(record.dshTurn === undefined ? {} : { dshTurn: record.dshTurn }),
231
+ }
232
+ if (liveTask !== undefined) {
233
+ return {
234
+ ...base,
235
+ status: liveTask.status,
236
+ ...(liveTask.result === undefined ? {} : { result: liveTask.result }),
237
+ ...(liveTask.error === undefined ? {} : { error: liveTask.error }),
238
+ ...(liveTask.delivered === undefined ? {} : { delivered: liveTask.delivered }),
239
+ }
240
+ }
241
+ if (terminal) {
242
+ return {
243
+ ...base,
244
+ status: record.status,
245
+ ...(record.result === undefined ? {} : { result: record.result }),
246
+ ...(record.error === undefined ? {} : { error: record.error }),
247
+ ...(record.delivered === undefined ? {} : { delivered: record.delivered }),
248
+ }
249
+ }
250
+ // 没有本实例运行上下文的非终态记录:**只能**标未知,绝不谎报"还在被本实例观察"。
251
+ return {
252
+ ...base,
253
+ status: 'unknown',
254
+ persistedStatus: record.status,
255
+ reason: 'execution-state-unconfirmed',
256
+ resend: false,
257
+ note: '这条任务在执行/交付未确认前就失去了运行时上下文(网关重载或新实例);本实例不观察它、不重发。原目标见 target。',
258
+ }
259
+ }
260
+
261
+ /** 同通道已有在途任务:明确拒绝,而不是把完成结果猜给某个 pending。 */
262
+ const rejectBusy = (fields) => {
263
+ const task = {
264
+ taskId: uid(), kind: fields.kind, instruction: fields.instruction,
265
+ cwd: fields.cwd, lane: fields.lane, bucketKey: fields.bucketKey,
266
+ target: fields.target, status: 'error', time: Date.now(), result: undefined,
267
+ error: fields.error, reason: fields.reason,
268
+ ...(fields.activeTaskId === undefined ? {} : { activeTaskId: fields.activeTaskId }),
269
+ }
270
+ rememberTask(tasks, task)
271
+ // 仍然登记来源(如果调用方给了):让调用方能按来源查到"这次被拒绝了"。
272
+ const bound = bindAssociation(task, fields.originThreadId)
273
+ if (bound.ok) persistTask(task)
274
+ pushTask(task.taskId, 'error', { error: task.error, reason: task.reason, cwd: fields.cwd, kind: fields.kind, ...(fields.activeTaskId === undefined ? {} : { activeTaskId: fields.activeTaskId }) })
275
+ return task
276
+ }
277
+
278
+ /**
279
+ * 把任务关联存储(R1):把「来源 Codex 对话 → taskId → 执行目标」持久化,供跨回合精确收取。
280
+ * 打不开就置 null——此时**来源关联模式一律拒绝**,绝不静默降级成"无关联"执行。
281
+ */
282
+ const associations = (() => {
283
+ try {
284
+ return openAssociations({
285
+ root: process.env.DSH_BRIDGE_ASSOC_DIR,
286
+ namespace: process.env.DSH_BRIDGE_ASSOC_NAMESPACE,
287
+ profile: process.env.DSH_PROFILE,
288
+ })
289
+ } catch (e) {
290
+ console.error('[dsh-bridge] association store unavailable:', errText(e).slice(0, 300))
291
+ return null
292
+ }
293
+ })()
294
+
295
+ /**
296
+ * 从请求体/查询串解析 originThreadId:
297
+ * - **字段未传(或 undefined)** → 旧的"无关联"模式,保持兼容;
298
+ * - **显式给了值** → 必须是合法非空字符串。空串 / null / 纯空白 / 非字符串一律拒绝,
299
+ * 绝不静默回落成"无关联"(那会让调用方以为绑定成功、实际产生未绑定任务)。
300
+ */
301
+ const originOf = (parsed) => {
302
+ if (parsed === null || typeof parsed !== 'object') return { ok: true, originThreadId: undefined }
303
+ if (!Object.prototype.hasOwnProperty.call(parsed, 'originThreadId')) return { ok: true, originThreadId: undefined }
304
+ const raw = parsed.originThreadId
305
+ if (raw === undefined) return { ok: true, originThreadId: undefined }
306
+ if (!isValidOrigin(raw)) return { ok: false }
307
+ return { ok: true, originThreadId: raw.trim() }
308
+ }
309
+
310
+ /** 能力上报:客户端在**派活前**只读核验本网关是否真的支持持久来源绑定(旧网关没有这一块)。 */
311
+ const capabilityReport = () => ({
312
+ originBinding: associations === null
313
+ ? { supported: false, version: 0, reason: 'association-store-unavailable' }
314
+ : { supported: true, version: 1, persistence: 'append-only-jsonl', namespace: associations.namespace },
315
+ })
316
+
317
+ /**
318
+ * 执行前登记关联。失败即返回错误,调用方必须放弃启动 DSH 执行——
319
+ * 绝不出现"执行了却没绑定"。
320
+ */
321
+ const bindAssociation = (task, originThreadId) => {
322
+ if (originThreadId === undefined) return { ok: true }
323
+ if (associations === null) {
324
+ 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
+ }
326
+ try {
327
+ associations.bind({ originThreadId, taskId: task.taskId, kind: task.kind, target: task.target })
328
+ task.originThreadId = originThreadId
329
+ return { ok: true }
330
+ } catch (e) {
331
+ return { ok: false, reason: e !== null && typeof e === 'object' && typeof e.reason === 'string' ? e.reason : 'association-bind-failed', message: errText(e) }
332
+ }
333
+ }
334
+
335
+ /** 把任务当前状态写回持久关联记录(终态/失败/取消/交付确认都调用)。 */
336
+ const persistTask = (task) => {
337
+ if (task === undefined || task === null || task.originThreadId === undefined || associations === null) return
338
+ try {
339
+ associations.update(task.taskId, {
340
+ status: task.status,
341
+ delivered: task.delivered,
342
+ result: task.result,
343
+ error: task.error,
344
+ reason: task.reason,
345
+ stopReason: task.stopReason,
346
+ requestId: task.requestId,
347
+ dshTurn: task.dshTurn,
348
+ })
349
+ delete task.associationError
350
+ } catch (e) {
351
+ // 明确暴露,绝不静默:任务本身的结果不受影响,但调用方能在状态里看到关联写入失败。
352
+ task.associationError = errText(e)
353
+ console.error('[dsh-bridge] association update failed for', task.taskId, ':', errText(e).slice(0, 200))
354
+ }
355
+ }
356
+
206
357
  const broadcast = (msg) => {
207
358
  let line
208
359
  try { line = JSON.stringify(msg) + '\n' } catch (e) { return }
@@ -340,6 +491,7 @@ export async function apply(ctx) {
340
491
  task.stopReason = info.stopReason
341
492
  remember('user', task.instruction)
342
493
  remember('assistant', task.result)
494
+ persistTask(task)
343
495
  pushTask(task.taskId, 'result', { text: task.result, stopReason: info.stopReason, childId: bucket.childId, cwd: bucket.cwd, kind: task.kind })
344
496
  } else {
345
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' })
@@ -449,6 +601,33 @@ export async function apply(ctx) {
449
601
  }
450
602
  }
451
603
 
604
+ /**
605
+ * 查询没命中时给的"近似候选":读 list(不激活 Agent、不开索引),按 bigram 相似度
606
+ * 与最近更新排序。存在的意义是**不给"探测"留动机**——真实事故里调用方搜不到标题后
607
+ * 发了一条占位消息去试探,那条消息落进用户会话并跑成回合。有了候选,只读就能改词重查。
608
+ */
609
+ const sessionNearMisses = async (query, limit = 5) => {
610
+ const controller = sessionControllerNow()
611
+ if (controller === undefined) return []
612
+ try {
613
+ const value = await controller.list({}, signalOf())
614
+ const items = value !== undefined && value !== null && Array.isArray(value.items) ? value.items : []
615
+ const summaries = []
616
+ for (const summary of items) {
617
+ if (summary === null || typeof summary !== 'object' || typeof summary.sessionId !== 'string') continue
618
+ summaries.push({
619
+ sessionId: summary.sessionId,
620
+ title: sessionTitleOf(summary),
621
+ updatedAt: typeof summary.updatedAt === 'number' ? summary.updatedAt : 0,
622
+ })
623
+ }
624
+ return rankTitleCandidates(query, summaries, limit)
625
+ } catch (e) {
626
+ console.error('[dsh-bridge] near-miss lookup failed:', errText(e).slice(0, 200))
627
+ return []
628
+ }
629
+ }
630
+
452
631
  /** 只读解析:query → search 选一;sessionId → 直接用。任何不确定性都返回 reason。 */
453
632
  const resolveSessionTarget = async (target) => {
454
633
  const controller = sessionControllerNow()
@@ -467,7 +646,13 @@ export async function apply(ctx) {
467
646
  }
468
647
  }
469
648
  const picked = selectSession(hits, target)
470
- if (!picked.ok) return picked
649
+ if (!picked.ok) {
650
+ // 查询落空不是"死路":附上近似候选,调用方只读就能改词重查(别再发消息探测)。
651
+ if (picked.reason === REASONS.QUERY_EMPTY) {
652
+ return { ...picked, candidates: await sessionNearMisses(target.query) }
653
+ }
654
+ return picked
655
+ }
471
656
  let result
472
657
  try {
473
658
  result = await controller.resolveAgent(picked.sessionId)
@@ -494,6 +679,18 @@ export async function apply(ctx) {
494
679
  if (fields.error !== undefined) task.error = fields.error
495
680
  if (fields.reason !== undefined) task.reason = fields.reason
496
681
  if (fields.stopReason !== undefined) task.stopReason = fields.stopReason
682
+ if (fields.delivered !== undefined) task.delivered = fields.delivered
683
+ if (fields.partialResult !== undefined) task.partialResult = fields.partialResult
684
+ if (fields.turnEndKind !== undefined) task.turnEndKind = fields.turnEndKind
685
+ if (fields.note !== undefined) task.note = fields.note
686
+ if (fields.resend !== undefined) task.resend = fields.resend
687
+ // 精确关联模式:回显这次请求的 requestId 与匹配到的 DSH turn(便于跨回合核对归属)。
688
+ if (record.precise === true) {
689
+ task.requestId = record.taskId
690
+ task.dshTurn = record.tracker === undefined ? undefined : record.tracker.turn
691
+ }
692
+ // 终态(done/error/cancelled/timeout/unknown)一律写回持久关联记录。
693
+ persistTask(task)
497
694
  pushTask(task.taskId, status === 'done' ? 'result' : 'error', {
498
695
  ...(task.result === undefined ? {} : { text: task.result }),
499
696
  ...(task.error === undefined ? {} : { error: task.error }),
@@ -502,10 +699,66 @@ export async function apply(ctx) {
502
699
  })
503
700
  }
504
701
 
702
+ /**
703
+ * 把"读日志"当成一次**观察机会**(不是完成证明):把日志事件喂给关联器。
704
+ * 与 live follow 共用同一套规则,靠 seq 去重。
705
+ */
706
+ 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
+ }
715
+ }
716
+
717
+ /** 精确模式下的进度推送:只推自己那个 turn 的**新增**文本。 */
718
+ const pushPreciseProgress = (record) => {
719
+ const text = record.tracker === undefined ? '' : record.tracker.text
720
+ if (text.length <= record.reported) return
721
+ const delta = text.slice(record.reported)
722
+ record.reported = text.length
723
+ record.activeSince = Date.now()
724
+ const task = tasks.find((t) => t.taskId === record.taskId)
725
+ if (task !== undefined && task.status === 'accepted') task.status = 'running'
726
+ pushTask(record.taskId, 'progress', { text: delta, target: { kind: 'session', sessionId: record.sessionId } })
727
+ }
728
+
729
+ /**
730
+ * 依据关联器收尾:只有该请求自己的 turn 以 `completed` 结束才算成功;
731
+ * 其他 kind(aborted/blocked/error/max-tokens/interrupted/forked/unknown)都是**明确的终态失败**。
732
+ */
733
+ const settleFromTracker = (record) => {
734
+ const outcome = turnOutcome(record.tracker.end)
735
+ const text = record.tracker.text.trim()
736
+ if (outcome.ok) {
737
+ settleSessionTask(record, 'done', { result: text === '' ? '(无输出)' : text, stopReason: 'request-turn-completed' })
738
+ return
739
+ }
740
+ settleSessionTask(record, 'error', {
741
+ error: 'the DSH turn for this request ended as "' + outcome.kind + '" (only "completed" is success)',
742
+ reason: REASONS.TURN_FAILED,
743
+ turnEndKind: outcome.kind,
744
+ ...(text === '' ? {} : { partialResult: text }),
745
+ })
746
+ }
747
+
505
748
  /** 收尾时以「持久日志 + 派发前基线」为准,避免只信流式片段。 */
506
749
  const finishSessionTask = (record, stopReason) => {
507
750
  if (record.settled) return
508
751
  void (async () => {
752
+ // 精确关联模式(带来源):running→idle 只是"可以读一次日志"的机会,不是完成证明。
753
+ // 只有读到自己请求那个 turn 的 turn/end 才收尾;否则保持未确认,绝不拿旧回合或不相关的
754
+ // 全会话 idleness 判成 done。
755
+ if (record.precise === true) {
756
+ await observeSessionLog(record)
757
+ if (record.settled) return
758
+ if (record.tracker.end !== undefined) { settleFromTracker(record); return }
759
+ record.lastObservation = { at: Date.now(), stopReason, sawRequest: record.tracker.sawRequest, turn: record.tracker.turn }
760
+ return
761
+ }
509
762
  let extractedText = ''
510
763
  if (sessionQuery !== undefined) {
511
764
  try {
@@ -536,7 +789,16 @@ export async function apply(ctx) {
536
789
  for await (const frame of stream) {
537
790
  if (record.settled) break
538
791
  if (frame === null || typeof frame !== 'object') continue
539
- if (frame.type !== 'event') continue // snapshot 是派发前的历史,不作为本轮输出
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
800
+ }
801
+ if (frame.type !== 'event') continue // 旧路径:snapshot 是派发前的历史,不作为本轮输出
540
802
  const text = assistantTextOf(frame.event)
541
803
  if (text === '') continue
542
804
  record.total += text
@@ -555,15 +817,26 @@ export async function apply(ctx) {
555
817
  }
556
818
 
557
819
  /** 派发一条指令进已有会话。任何失败都不创建会话,只落一个 error 任务。 */
558
- const dispatchToSession = async (instruction, target, deliver, notes, incomingHistory) => {
820
+ const dispatchToSession = async (instruction, target, deliver, notes, incomingHistory, originThreadId) => {
559
821
  const task = {
560
822
  taskId: uid(), kind: 'task', instruction,
561
823
  target: { kind: 'session', sessionId: target.sessionId, query: target.query, snippet: target.snippet },
562
824
  deliver, notes, status: 'accepted', time: Date.now(), result: undefined, error: undefined,
563
825
  cwd: undefined, lane: undefined, bucketKey: undefined,
564
826
  }
565
- tasks.push(task)
566
- if (tasks.length > 200) tasks.splice(0, tasks.length - 200)
827
+ rememberTask(tasks, task)
828
+
829
+ // 带来源的"精确关联模式"要求一个请求对应一个可判定的 turn;steer 会把整批 next-step 合并进
830
+ // 同一个 turn(多个 request 共用),无法为本次请求单独保证结果归属。执行前明确拒绝(零 prompt)。
831
+ if (originThreadId !== undefined && deliver === 'steer') {
832
+ const bound = bindAssociation(task, originThreadId)
833
+ 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
836
+ 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: [] } }
839
+ }
567
840
 
568
841
  const resolved = await resolveSessionTarget(target)
569
842
  if (!resolved.ok) {
@@ -588,6 +861,16 @@ export async function apply(ctx) {
588
861
  return { task, failed: { reason: REASONS.BUSY, message: task.error, candidates: [] } }
589
862
  }
590
863
 
864
+ // 来源关联必须在**实际投递之前**落盘:绑定失败就不投递(宁可失败,也不出现"执行了却没绑定")。
865
+ const bound = bindAssociation(task, originThreadId)
866
+ if (!bound.ok) {
867
+ task.status = 'error'
868
+ task.error = bound.message
869
+ task.reason = bound.reason
870
+ pushTask(task.taskId, 'error', { error: task.error, reason: task.reason, target: { kind: 'session', sessionId: resolved.sessionId } })
871
+ return { task, failed: { reason: bound.reason, message: bound.message, candidates: [] } }
872
+ }
873
+
591
874
  // 派发前取基线:结果只回报这一轮新产生的助手文本。
592
875
  let baseline = { count: 0, textLen: 0 }
593
876
  if (sessionQuery !== undefined) {
@@ -603,6 +886,11 @@ export async function apply(ctx) {
603
886
  sessionId: resolved.sessionId, taskId: task.taskId, deliver, baseline,
604
887
  total: '', sawRunning: false, activeSince: Date.now(), startedAt: Date.now(),
605
888
  settled: false, abort: undefined, watcher: 'pending',
889
+ // 精确关联模式(带来源):靠 requestId→rpcId→turn 关联收结果,而不是派发前基线 + 全会话 idle。
890
+ precise: originThreadId !== undefined,
891
+ tracker: createRequestTracker(task.taskId),
892
+ reported: 0, // prompt 被接受 = 消息已进会话队列。此后就不该再把它当"没发出去"(见 sweepSessionTargets)。
893
+ delivered: false, queuedNoted: false,
606
894
  }
607
895
  sessionTargets.set(resolved.sessionId, record)
608
896
 
@@ -635,11 +923,13 @@ export async function apply(ctx) {
635
923
  }
636
924
 
637
925
  watchSessionTask(record)
638
- pushTask(task.taskId, 'task-accepted', { target: { kind: 'session', sessionId: resolved.sessionId }, deliver, cwd: resolved.snippet })
926
+ record.delivered = true
927
+ task.delivered = true
928
+ pushTask(task.taskId, 'task-accepted', { target: { kind: 'session', sessionId: resolved.sessionId }, deliver, cwd: resolved.snippet, delivered: true })
639
929
  return { task, failed: null }
640
930
  }
641
931
 
642
- const enqueue = async (instruction, incomingHistory, target, modelAlias, kind, deliveryMode, notes) => {
932
+ const enqueue = async (instruction, incomingHistory, target, modelAlias, kind, deliveryMode, notes, originThreadId) => {
643
933
  if (incomingHistory !== undefined) syncHistory(incomingHistory)
644
934
  if (target !== undefined && target.kind === 'session') {
645
935
  // session 目标:不注册工作区、不建 owner、不建子代理,失败也不降级为新建。
@@ -648,6 +938,7 @@ export async function apply(ctx) {
648
938
  deliveryMode === undefined ? 'queue' : deliveryMode,
649
939
  Array.isArray(notes) ? notes : [],
650
940
  incomingHistory,
941
+ originThreadId,
651
942
  )
652
943
  return dispatched.task
653
944
  }
@@ -655,6 +946,19 @@ export async function apply(ctx) {
655
946
  const lane = normalizeLane(target === undefined ? undefined : target.lane)
656
947
  registerWorkspace(cwd)
657
948
  const bucket = bucketOf(cwd, lane)
949
+ // 同一通道(cwd+lane)只允许一个在途任务:完成事件只能定位到"这个桶里最老的 pending",
950
+ // 两个并发任务会让结果归属变成猜测。这里明确拒绝(上一任务结束后再派下一条;不同 lane 不受影响)。
951
+ const activeInBucket = tasks.find((t) => (t.status === 'accepted' || t.status === 'running') && t.bucketKey === bucket.key)
952
+ if (activeInBucket !== undefined) {
953
+ return rejectBusy({
954
+ kind, instruction, cwd, lane, bucketKey: bucket.key,
955
+ target: { kind: 'worker', cwd, lane },
956
+ originThreadId,
957
+ activeTaskId: activeInBucket.taskId,
958
+ reason: 'worker-bucket-busy',
959
+ error: 'another task (' + activeInBucket.taskId + ') is still active on this channel; refusing to start a second one — its completion could not be attributed to a specific task. Wait for it to finish (or use another --lane).',
960
+ })
961
+ }
658
962
  const requestedModel = await resolveModel(modelAlias)
659
963
  if (requestedModel !== null && requestedModel !== bucket.model) {
660
964
  bucket.model = requestedModel
@@ -664,8 +968,16 @@ export async function apply(ctx) {
664
968
  bucket.seenCount = 0
665
969
  }
666
970
  const task = { taskId: uid(), kind, instruction, cwd, lane, bucketKey: bucket.key, target: { kind: 'worker', cwd, lane }, status: 'accepted', time: Date.now(), result: undefined, error: undefined }
667
- tasks.push(task)
668
- if (tasks.length > 200) tasks.splice(0, tasks.length - 200)
971
+ rememberTask(tasks, task)
972
+ // 来源关联必须在**实际派活之前**落盘:绑定失败就不派活。
973
+ const bound = bindAssociation(task, originThreadId)
974
+ if (!bound.ok) {
975
+ task.status = 'error'
976
+ task.error = bound.message
977
+ task.reason = bound.reason
978
+ pushTask(task.taskId, 'error', { error: task.error, reason: task.reason, cwd, kind })
979
+ return task
980
+ }
669
981
  try {
670
982
  const delivered = await deliver(bucket, instruction, ROLE_BRIEF(cwd), '[Codex 指令] ')
671
983
  if (delivered.fallback) {
@@ -675,6 +987,7 @@ export async function apply(ctx) {
675
987
  task.fallback = delivered.fallbackNote
676
988
  remember('user', instruction)
677
989
  remember('assistant', task.result)
990
+ persistTask(task)
678
991
  pushTask(task.taskId, 'result', { text: task.result, stopReason: delivered.stopReason, childId: null, cwd, kind })
679
992
  } else {
680
993
  bucket.activeSince = Date.now()
@@ -683,19 +996,40 @@ export async function apply(ctx) {
683
996
  } catch (e) {
684
997
  task.status = 'error'
685
998
  task.error = errText(e)
999
+ persistTask(task)
686
1000
  pushTask(task.taskId, 'error', { error: task.error, cwd, kind })
687
1001
  }
688
1002
  return task
689
1003
  }
690
1004
 
691
- const submitReview = async (rawCwd, diff, focus, incomingHistory) => {
1005
+ const submitReview = async (rawCwd, diff, focus, incomingHistory, originThreadId) => {
692
1006
  if (incomingHistory !== undefined) syncHistory(incomingHistory)
693
1007
  const cwd = normalizeCwd(rawCwd)
694
1008
  const bucket = reviewerBucketOf(cwd)
1009
+ // 评审桶同理:同一目录只允许一个在途评审,否则完成结果无法归属到具体 taskId。
1010
+ const activeReview = tasks.find((t) => (t.status === 'accepted' || t.status === 'running') && t.bucketKey === bucket.key)
1011
+ if (activeReview !== undefined) {
1012
+ return rejectBusy({
1013
+ kind: 'review', instruction: '(review rejected: channel busy)', cwd, lane: '@review', bucketKey: bucket.key,
1014
+ target: { kind: 'worker', cwd, lane: '@review' },
1015
+ originThreadId,
1016
+ activeTaskId: activeReview.taskId,
1017
+ reason: 'reviewer-bucket-busy',
1018
+ error: 'another review (' + activeReview.taskId + ') is still active for this directory; refusing to start a second one — its completion could not be attributed to a specific task. Wait for it to finish.',
1019
+ })
1020
+ }
695
1021
  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')
696
1022
  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 }
697
- tasks.push(task)
698
- if (tasks.length > 200) tasks.splice(0, tasks.length - 200)
1023
+ rememberTask(tasks, task)
1024
+ // 评审同样产生 taskId,因此同样支持来源关联;绑定失败不派活。
1025
+ const bound = bindAssociation(task, originThreadId)
1026
+ if (!bound.ok) {
1027
+ task.status = 'error'
1028
+ task.error = bound.message
1029
+ task.reason = bound.reason
1030
+ pushTask(task.taskId, 'error', { error: task.error, reason: task.reason, cwd, kind: 'review' })
1031
+ return task
1032
+ }
699
1033
  try {
700
1034
  const delivered = await deliver(bucket, instruction, REVIEWER_BRIEF(cwd), '[评审请求] ')
701
1035
  if (delivered.fallback) {
@@ -705,6 +1039,7 @@ export async function apply(ctx) {
705
1039
  task.fallback = delivered.fallbackNote
706
1040
  remember('user', '[评审请求] ' + (typeof focus === 'string' && focus !== '' ? focus : 'diff 审查'))
707
1041
  remember('assistant', task.result)
1042
+ persistTask(task)
708
1043
  pushTask(task.taskId, 'result', { text: task.result, stopReason: delivered.stopReason, childId: null, cwd, kind: 'review' })
709
1044
  } else {
710
1045
  bucket.activeSince = Date.now()
@@ -713,6 +1048,7 @@ export async function apply(ctx) {
713
1048
  } catch (e) {
714
1049
  task.status = 'error'
715
1050
  task.error = errText(e)
1051
+ persistTask(task)
716
1052
  pushTask(task.taskId, 'error', { error: task.error, cwd, kind: 'review' })
717
1053
  }
718
1054
  return task
@@ -743,6 +1079,7 @@ export async function apply(ctx) {
743
1079
  if (active !== undefined && total.length > 0) {
744
1080
  active.status = 'done'
745
1081
  active.result = total.slice(Math.max(0, total.length - 4000)).trim() || '(无输出)'
1082
+ persistTask(active)
746
1083
  pushTask(active.taskId, 'result', { text: active.result, stopReason: 'quiet-timeout', childId: bucket.childId, cwd: bucket.cwd, kind: bucket.lane === '@review' ? 'review' : 'task' })
747
1084
  bucket.activeSince = 0
748
1085
  }
@@ -756,6 +1093,41 @@ export async function apply(ctx) {
756
1093
  if (task === undefined) { settleSessionTask(record, 'cancelled', {}); continue }
757
1094
  if (task.status === 'cancelled') { record.settled = true; if (record.abort !== undefined) { try { record.abort.abort() } catch (e) { /* ignore */ } } sessionTargets.delete(record.sessionId); continue }
758
1095
  const now = Date.now()
1096
+ // 精确关联模式:轮询/读日志只是观察机会,收尾只能由"自己那个 turn 的 turn/end"触发。
1097
+ if (record.precise === true) {
1098
+ await observeSessionLog(record)
1099
+ if (record.settled) continue
1100
+ if (record.tracker.end !== undefined) { settleFromTracker(record); continue }
1101
+ if (record.watcher !== 'follow') pushPreciseProgress(record)
1102
+ if (!record.sawRunning && now - record.startedAt > SESSION_DISPATCH_TIMEOUT_MS && record.delivered === true && record.queuedNoted !== true) {
1103
+ record.queuedNoted = true
1104
+ pushTask(record.taskId, 'note', {
1105
+ note: 'QUEUED',
1106
+ text: 'prompt 已被接受并进入该会话队列,等它空闲时会跑;不要重发',
1107
+ target: { kind: 'session', sessionId: record.sessionId },
1108
+ delivered: true,
1109
+ })
1110
+ }
1111
+ if (!record.sawRunning && now - record.startedAt > SESSION_DISPATCH_TIMEOUT_MS && record.delivered !== true) {
1112
+ settleSessionTask(record, 'error', { error: 'the session never accepted the prompt', reason: REASONS.NOT_ACCEPTED, delivered: false })
1113
+ continue
1114
+ }
1115
+ // 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
1118
+ if (quietExpired || budgetExpired) {
1119
+ settleSessionTask(record, 'unknown', {
1120
+ error: record.delivered === true
1121
+ ? 'prompt 已投递进该会话队列,但没有观察到本请求自己那个 turn 的结束(可能仍在排队/仍在跑/已被别的回合掩盖);执行状态未确认,不要重发'
1122
+ : 'the session never accepted the prompt within the budget',
1123
+ reason: REASONS.UNCONFIRMED,
1124
+ resend: false,
1125
+ note: 'execution state unconfirmed: no turn/end matching this request\'s requestId was observed',
1126
+ ...(record.delivered === true ? { delivered: true } : {}),
1127
+ })
1128
+ }
1129
+ continue
1130
+ }
759
1131
  if (record.watcher === 'poll' && sessionQuery !== undefined) {
760
1132
  try {
761
1133
  const snap = await sessionQuery.readSession(record.sessionId)
@@ -770,16 +1142,39 @@ export async function apply(ctx) {
770
1142
  }
771
1143
  } catch (e) { /* 读不到就下一轮再试 */ }
772
1144
  }
1145
+ // 派发超时只在"prompt 没被接受"时才算失败。
1146
+ // queue 模式下 prompt 被接受就意味着消息已进队列,会在会话空闲时跑;此时报 error
1147
+ // 是误报——实测会话正跑一个 38 分钟的回合,排队等待远超 120s 完全正常,而误报会
1148
+ // 直接诱导调用方重发(真实事故:同一条汇报被投了两次)。所以这里只记一条 note,
1149
+ // 继续等,由下面的总预算兜底。
773
1150
  if (!record.sawRunning && now - record.startedAt > SESSION_DISPATCH_TIMEOUT_MS) {
774
- settleSessionTask(record, 'error', { error: 'the session never started a turn after the prompt was accepted (it may still be queued)', reason: REASONS.NOT_ACCEPTED })
775
- continue
1151
+ if (record.delivered === true) {
1152
+ if (record.queuedNoted !== true) {
1153
+ record.queuedNoted = true
1154
+ pushTask(record.taskId, 'note', {
1155
+ note: 'QUEUED',
1156
+ text: 'prompt 已被接受并进入该会话队列,等它空闲时会跑;不要重发',
1157
+ target: { kind: 'session', sessionId: record.sessionId },
1158
+ delivered: true,
1159
+ })
1160
+ }
1161
+ } else {
1162
+ settleSessionTask(record, 'error', { error: 'the session never accepted the prompt', reason: REASONS.NOT_ACCEPTED, delivered: false })
1163
+ continue
1164
+ }
776
1165
  }
777
1166
  if (record.sawRunning && record.activeSince > 0 && now - record.activeSince > SESSION_QUIET_TIMEOUT_MS) {
778
1167
  finishSessionTask(record, 'quiet-timeout')
779
1168
  continue
780
1169
  }
781
1170
  if (now - record.startedAt > SESSION_TOTAL_TIMEOUT_MS) {
782
- settleSessionTask(record, 'error', { error: 'session task exceeded its total budget', reason: REASONS.TIMEOUT })
1171
+ settleSessionTask(record, 'error', {
1172
+ error: record.delivered === true
1173
+ ? 'prompt 已投递进该会话队列,但预算内它始终没有开始这一轮(会话可能一直在忙,或未被激活);不要重发,可用 status 继续观察'
1174
+ : 'session task exceeded its total budget before the prompt was accepted',
1175
+ reason: REASONS.TIMEOUT,
1176
+ delivered: record.delivered === true,
1177
+ })
783
1178
  }
784
1179
  }
785
1180
  }
@@ -851,14 +1246,62 @@ export async function apply(ctx) {
851
1246
  const parsed = await readJsonBody(req, res)
852
1247
  if (parsed === null) return
853
1248
  const instruction = parsed !== null && typeof parsed === 'object' && typeof parsed.instruction === 'string' ? parsed.instruction : ''
854
- if (instruction.trim() === '') { sendJson(res, 400, { error: 'missing instruction' }); return }
1249
+ const dryRun = parsed !== null && typeof parsed === 'object' && parsed.dryRun === true
1250
+ if (!dryRun && instruction.trim() === '') { sendJson(res, 400, { error: 'missing instruction' }); return }
855
1251
  const classified = classifyTarget(parsed)
856
1252
  if (!classified.ok) { sendJson(res, 400, { error: classified.message, reason: classified.reason }); return }
857
- const task = await enqueue(instruction, parsed.history, classified.target, parsed.model, 'task', classified.deliver, classified.notes)
1253
+ // 来源关联(R1):非法值在**外部执行前**拒绝;dryRun 不写任何关联。
1254
+ const origin = originOf(parsed)
1255
+ if (!origin.ok) {
1256
+ sendJson(res, 400, { error: 'originThreadId must be a non-empty string (<= 200 chars, no control characters)', reason: ASSOC_REASONS.INVALID_ORIGIN })
1257
+ return
1258
+ }
1259
+ // dryRun:只解析目标、回显它,**不投递任何东西**。存在的意义是让"我这条查询会命中
1260
+ // 哪条会话"能纯只读地问出来——真实事故里调用方就是因为没法先验证,才发了一条占位
1261
+ // 消息去探测,结果污染了用户会话。
1262
+ if (dryRun) {
1263
+ if (classified.target.kind === 'session') {
1264
+ const resolved = await resolveSessionTarget(classified.target)
1265
+ if (!resolved.ok) {
1266
+ sendJson(res, 200, {
1267
+ dryRun: true,
1268
+ resolved: false,
1269
+ reason: resolved.reason,
1270
+ error: resolved.message,
1271
+ ...(Array.isArray(resolved.candidates) && resolved.candidates.length > 0 ? { candidates: resolved.candidates } : {}),
1272
+ })
1273
+ return
1274
+ }
1275
+ sendJson(res, 200, {
1276
+ dryRun: true,
1277
+ resolved: true,
1278
+ wouldDispatch: true,
1279
+ target: { kind: 'session', sessionId: resolved.sessionId, deliver: classified.deliver },
1280
+ ...(resolved.snippet === undefined ? {} : { snippet: resolved.snippet }),
1281
+ ...(resolved.matchedBy === undefined ? {} : { matchedBy: resolved.matchedBy }),
1282
+ ...(resolved.candidates.length > 0 ? { candidates: resolved.candidates } : {}),
1283
+ note: '只解析目标,未投递任何消息',
1284
+ })
1285
+ return
1286
+ }
1287
+ const cwd = normalizeCwd(classified.target.cwd)
1288
+ const lane = normalizeLane(classified.target.lane)
1289
+ sendJson(res, 200, {
1290
+ dryRun: true,
1291
+ resolved: true,
1292
+ wouldDispatch: true,
1293
+ target: { kind: 'worker', cwd, lane },
1294
+ note: '只解析目标,未派活',
1295
+ })
1296
+ return
1297
+ }
1298
+ const task = await enqueue(instruction, parsed.history, classified.target, parsed.model, 'task', classified.deliver, classified.notes, origin.originThreadId)
858
1299
  if (task.status === 'error') {
859
1300
  sendJson(res, 400, {
860
1301
  error: task.error,
861
1302
  reason: task.reason,
1303
+ ...(task.originThreadId === undefined ? {} : { originThreadId: task.originThreadId }),
1304
+ ...(task.delivered === true ? { delivered: true } : {}),
862
1305
  ...(Array.isArray(task.candidates) && task.candidates.length > 0 ? { candidates: task.candidates } : {}),
863
1306
  })
864
1307
  return
@@ -939,7 +1382,18 @@ export async function apply(ctx) {
939
1382
  }
940
1383
  }
941
1384
  }
942
- sendJson(res, 200, { items, hasMore: hits.length > items.length, total: hits.length, matchedBy: found.matchedBy })
1385
+ // 没命中不是死路:给近似候选(同一批只读数据,按相似度 + 最近更新排序),
1386
+ // 让调用方改词重查——而不是发一条消息去"探测命中"。
1387
+ const candidates = hits.length === 0 ? await sessionNearMisses(query, limit) : []
1388
+ sendJson(res, 200, {
1389
+ items,
1390
+ hasMore: hits.length > items.length,
1391
+ total: hits.length,
1392
+ matchedBy: found.matchedBy,
1393
+ ...(candidates.length > 0
1394
+ ? { candidates, reason: REASONS.QUERY_EMPTY, note: '标题/目录里都没有这个关键词;candidates 是按相似度与最近更新排的候选,改词重查或直接用 sessionId' }
1395
+ : {}),
1396
+ })
943
1397
  } catch (e) {
944
1398
  sendJson(res, 502, { error: errText(e), reason: mapControllerError(e) })
945
1399
  }
@@ -954,8 +1408,17 @@ export async function apply(ctx) {
954
1408
  if (req.method !== 'POST') { res.writeHead(405); res.end('POST only'); return }
955
1409
  const parsed = await readJsonBody(req, res)
956
1410
  if (parsed === null) return
957
- const task = await submitReview(parsed.cwd, parsed.diff, parsed.focus, parsed.history)
958
- sendJson(res, 202, { taskId: task.taskId, status: task.status, cwd: task.cwd, kind: 'review' })
1411
+ const origin = originOf(parsed)
1412
+ if (!origin.ok) {
1413
+ sendJson(res, 400, { error: 'originThreadId must be a non-empty string (<= 200 chars, no control characters)', reason: ASSOC_REASONS.INVALID_ORIGIN })
1414
+ return
1415
+ }
1416
+ const task = await submitReview(parsed.cwd, parsed.diff, parsed.focus, parsed.history, origin.originThreadId)
1417
+ if (task.status === 'error') {
1418
+ sendJson(res, 400, { error: task.error, reason: task.reason, ...(task.originThreadId === undefined ? {} : { originThreadId: task.originThreadId }) })
1419
+ return
1420
+ }
1421
+ sendJson(res, 202, { taskId: task.taskId, status: task.status, cwd: task.cwd, kind: 'review', ...(task.originThreadId === undefined ? {} : { originThreadId: task.originThreadId }) })
959
1422
  },
960
1423
  }))
961
1424
 
@@ -964,7 +1427,31 @@ export async function apply(ctx) {
964
1427
  path: '/api/dsh-bridge/tasks',
965
1428
  handler: async (req, res) => {
966
1429
  if (!isLoopback(req)) { res.writeHead(403); res.end('forbidden'); return }
967
- sendJson(res, 200, { tasks: tasks.slice(-50).map((t) => ({ taskId: t.taskId, kind: t.kind, cwd: t.cwd, lane: t.lane, target: t.target, deliver: t.deliver, status: t.status, time: t.time, instruction: t.instruction.slice(0, 200), error: t.error, reason: t.reason, fallback: t.fallback })) })
1430
+ const qs = qsOf(req)
1431
+ const origin = originOf(qs)
1432
+ if (!origin.ok) {
1433
+ sendJson(res, 400, { error: 'originThreadId must be a non-empty string (<= 200 chars, no control characters)', reason: ASSOC_REASONS.INVALID_ORIGIN })
1434
+ return
1435
+ }
1436
+ const projection = (t) => ({ taskId: t.taskId, kind: t.kind, cwd: t.cwd, lane: t.lane, target: t.target, deliver: t.deliver, status: t.status, time: t.time, instruction: t.instruction.slice(0, 200), error: t.error, reason: t.reason, fallback: t.fallback, ...(t.originThreadId === undefined ? {} : { originThreadId: t.originThreadId }) })
1437
+ // 带 originThreadId:跨回合按来源找任务(含只存在于持久记录里的历史任务)。
1438
+ if (origin.originThreadId !== undefined) {
1439
+ const live = tasks.filter((t) => t.originThreadId === origin.originThreadId)
1440
+ const liveIds = new Set(live.map((t) => t.taskId))
1441
+ const listed = associations === null ? { ok: false, reason: 'association-store-unavailable' } : associations.byOrigin(origin.originThreadId)
1442
+ if (listed.ok !== true) {
1443
+ sendJson(res, 503, { error: 'task association store is unavailable in this gateway', reason: listed.reason })
1444
+ return
1445
+ }
1446
+ const byId = new Map(listed.tasks.map((r) => [r.taskId, r]))
1447
+ const merged = [
1448
+ ...live.map((t) => ({ ...projectStoredTask(byId.get(t.taskId) === undefined ? { taskId: t.taskId, originThreadId: t.originThreadId, kind: t.kind, target: t.target, status: t.status, createdAt: t.time, updatedAt: t.time } : byId.get(t.taskId)), instruction: t.instruction.slice(0, 200), live: true })),
1449
+ ...listed.tasks.filter((r) => !liveIds.has(r.taskId)).map((r) => projectStoredTask(r)),
1450
+ ]
1451
+ sendJson(res, 200, { originThreadId: origin.originThreadId, count: merged.length, tasks: merged, recovered: listed.tasks.some((r) => !liveIds.has(r.taskId)), live: live.length, capabilities: capabilityReport() })
1452
+ return
1453
+ }
1454
+ sendJson(res, 200, { tasks: tasks.slice(-50).map(projection), capabilities: capabilityReport() })
968
1455
  },
969
1456
  }))
970
1457
 
@@ -977,9 +1464,29 @@ export async function apply(ctx) {
977
1464
  if (parsed === null) return
978
1465
  const taskId = parsed && parsed.taskId
979
1466
  if (typeof taskId !== 'string' || taskId === '') { sendJson(res, 400, { error: 'missing taskId' }); return }
1467
+ const origin = originOf(parsed)
1468
+ if (!origin.ok) {
1469
+ sendJson(res, 400, { error: 'originThreadId must be a non-empty string (<= 200 chars, no control characters)', reason: ASSOC_REASONS.INVALID_ORIGIN })
1470
+ return
1471
+ }
980
1472
  const task = tasks.find((t) => t.taskId === taskId)
981
1473
  if (task === undefined) { sendJson(res, 404, { error: 'unknown taskId' }); return }
982
- if (task.status !== 'accepted' && task.status !== 'running') { sendJson(res, 200, { taskId, cancelled: false, status: task.status }); return }
1474
+ // 已关联的任务:缺来源/错来源都拒绝(同一个入口规则与 /status 一致)。
1475
+ if (task.originThreadId !== undefined) {
1476
+ if (origin.originThreadId === undefined) {
1477
+ sendJson(res, 404, {
1478
+ error: 'task ' + taskId + ' is bound to a source conversation; pass originThreadId to cancel it',
1479
+ reason: ASSOC_REASONS.MISSING_ORIGIN,
1480
+ hint: 'pass originThreadId (the Codex conversation that dispatched this task)',
1481
+ })
1482
+ return
1483
+ }
1484
+ if (task.originThreadId !== origin.originThreadId) {
1485
+ sendJson(res, 404, { error: 'task ' + taskId + ' is bound to another origin', reason: ASSOC_REASONS.ORIGIN_MISMATCH })
1486
+ return
1487
+ }
1488
+ }
1489
+ if (task.status !== 'accepted' && task.status !== 'running') { sendJson(res, 200, { taskId, cancelled: false, status: task.status, ...(task.originThreadId === undefined ? {} : { originThreadId: task.originThreadId }) }); return }
983
1490
 
984
1491
  // session 目标动的是用户自己的对话:取消会打断他正在跑的回合,因此必须显式 force。
985
1492
  if (task.target !== undefined && task.target.kind === 'session') {
@@ -1005,8 +1512,9 @@ export async function apply(ctx) {
1005
1512
  task.cancelReason = 'cancelled by request (force)'
1006
1513
  const record = sessionTargets.get(task.target.sessionId)
1007
1514
  if (record !== undefined) settleSessionTask(record, 'cancelled', {})
1515
+ persistTask(task)
1008
1516
  pushTask(taskId, 'cancelled', { target: { kind: 'session', sessionId: task.target.sessionId } })
1009
- sendJson(res, 200, { taskId, cancelled: true, interrupted: cancelled, target: task.target })
1517
+ sendJson(res, 200, { taskId, cancelled: true, interrupted: cancelled, target: task.target, ...(task.originThreadId === undefined ? {} : { originThreadId: task.originThreadId }) })
1010
1518
  return
1011
1519
  }
1012
1520
 
@@ -1022,8 +1530,9 @@ export async function apply(ctx) {
1022
1530
  } catch (e) {
1023
1531
  console.error('[dsh-bridge] cancel interrupt failed:', errText(e).slice(0, 200))
1024
1532
  }
1533
+ persistTask(task)
1025
1534
  pushTask(taskId, 'cancelled', { cwd: task.cwd })
1026
- sendJson(res, 200, { taskId, cancelled: true, interrupted })
1535
+ sendJson(res, 200, { taskId, cancelled: true, interrupted, ...(task.originThreadId === undefined ? {} : { originThreadId: task.originThreadId }) })
1027
1536
  },
1028
1537
  }))
1029
1538
 
@@ -1033,18 +1542,112 @@ export async function apply(ctx) {
1033
1542
  handler: async (req, res) => {
1034
1543
  if (!isLoopback(req)) { res.writeHead(403); res.end('forbidden'); return }
1035
1544
  const qs = qsOf(req)
1545
+ const origin = originOf(qs)
1546
+ if (!origin.ok) {
1547
+ sendJson(res, 400, { error: 'originThreadId must be a non-empty string (<= 200 chars, no control characters)', reason: ASSOC_REASONS.INVALID_ORIGIN })
1548
+ return
1549
+ }
1036
1550
  const taskId = qs.taskId
1037
1551
  if (taskId !== undefined && taskId !== '') {
1038
1552
  const task = tasks.find((t) => t.taskId === taskId)
1039
- if (task === undefined) { sendJson(res, 404, { error: 'unknown taskId' }); return }
1040
- const record = task.target !== undefined && task.target.kind === 'session' ? sessionTargets.get(task.target.sessionId) : undefined
1553
+ if (task !== undefined) {
1554
+ // 已关联的任务:缺来源 → origin-required;错来源 → origin-mismatch。两条都**不返回结果**。
1555
+ // 只有旧的"未关联"任务才继续支持只按 taskId 查询(兼容)。
1556
+ if (task.originThreadId !== undefined) {
1557
+ if (origin.originThreadId === undefined) {
1558
+ sendJson(res, 404, {
1559
+ error: 'task ' + taskId + ' is bound to a source conversation; pass originThreadId to read it',
1560
+ reason: ASSOC_REASONS.MISSING_ORIGIN,
1561
+ hint: 'pass originThreadId (the Codex conversation that dispatched this task)',
1562
+ })
1563
+ return
1564
+ }
1565
+ if (task.originThreadId !== origin.originThreadId) {
1566
+ sendJson(res, 404, { error: 'task ' + taskId + ' is bound to another origin', reason: ASSOC_REASONS.ORIGIN_MISMATCH })
1567
+ return
1568
+ }
1569
+ }
1570
+ const record = task.target !== undefined && task.target.kind === 'session' ? sessionTargets.get(task.target.sessionId) : undefined
1571
+ sendJson(res, 200, {
1572
+ taskId: task.taskId, status: task.status, kind: task.kind, cwd: task.cwd, lane: task.lane,
1573
+ target: task.target, deliver: task.deliver, instruction: task.instruction.slice(0, 300),
1574
+ result: task.result, error: task.error, reason: task.reason, fallback: task.fallback,
1575
+ // delivered=true 表示 prompt 已被会话接受(进了队列)——即便后面报错也不要重发。
1576
+ ...(task.delivered === undefined ? {} : { delivered: task.delivered }),
1577
+ ...(task.originThreadId === undefined ? {} : { originThreadId: task.originThreadId }),
1578
+ ...(task.requestId === undefined ? {} : { requestId: task.requestId }),
1579
+ ...(task.dshTurn === undefined ? {} : { dshTurn: task.dshTurn }),
1580
+ ...(task.turnEndKind === undefined ? {} : { turnEndKind: task.turnEndKind }),
1581
+ ...(task.partialResult === undefined ? {} : { partialResult: task.partialResult }),
1582
+ ...(task.resend === undefined ? {} : { resend: task.resend }),
1583
+ ...(task.note === undefined ? {} : { note: task.note }),
1584
+ ...(task.associationError === undefined ? {} : { associationError: task.associationError }),
1585
+ ...(task.matchedBy === undefined ? {} : { matchedBy: task.matchedBy }),
1586
+ ...(Array.isArray(task.candidates) && task.candidates.length > 0 ? { candidates: task.candidates } : {}),
1587
+ ...(record === undefined ? {} : { watcher: record.watcher, sawRunning: record.sawRunning }),
1588
+ })
1589
+ return
1590
+ }
1591
+
1592
+ // 内存里没有(网关重载/新实例):从持久关联记录恢复。
1593
+ const stored = associations === null
1594
+ ? { ok: false, reason: 'association-store-unavailable' }
1595
+ : associations.fetch({ originThreadId: origin.originThreadId, taskId })
1596
+ if (stored.ok !== true) {
1597
+ const hint = stored.reason === ASSOC_REASONS.MISSING_ORIGIN
1598
+ ? 'pass originThreadId to recover a persisted task result'
1599
+ : (stored.reason === ASSOC_REASONS.ORIGIN_MISMATCH ? 'this task is bound to another origin' : undefined)
1600
+ sendJson(res, 404, {
1601
+ error: stored.reason === ASSOC_REASONS.UNKNOWN_TASK ? 'unknown taskId' : 'cannot recover task ' + taskId,
1602
+ reason: stored.reason,
1603
+ ...(hint === undefined ? {} : { hint }),
1604
+ })
1605
+ return
1606
+ }
1607
+ const record = stored.record
1608
+ const terminal = record.status === 'done' || record.status === 'error' || record.status === 'cancelled'
1041
1609
  sendJson(res, 200, {
1042
- taskId: task.taskId, status: task.status, kind: task.kind, cwd: task.cwd, lane: task.lane,
1043
- target: task.target, deliver: task.deliver, instruction: task.instruction.slice(0, 300),
1044
- result: task.result, error: task.error, reason: task.reason, fallback: task.fallback,
1045
- ...(task.matchedBy === undefined ? {} : { matchedBy: task.matchedBy }),
1046
- ...(Array.isArray(task.candidates) && task.candidates.length > 0 ? { candidates: task.candidates } : {}),
1047
- ...(record === undefined ? {} : { watcher: record.watcher, sawRunning: record.sawRunning }),
1610
+ taskId: record.taskId,
1611
+ originThreadId: record.originThreadId,
1612
+ kind: record.kind,
1613
+ target: record.target,
1614
+ ...(record.profile === undefined ? {} : { profile: record.profile }),
1615
+ // 非终态记录:**不谎报已完成**,也不重新观察(未实现),明确标未知并禁止重发。
1616
+ status: terminal ? record.status : 'unknown',
1617
+ persistedStatus: record.status,
1618
+ recovered: true,
1619
+ ...(terminal
1620
+ ? {}
1621
+ : {
1622
+ reason: 'execution-state-unconfirmed',
1623
+ note: '这条任务在执行/交付未确认前就失去了运行时上下文(网关重载或新实例);不重发、不重新观察。原目标见 target。',
1624
+ resend: false,
1625
+ }),
1626
+ ...(record.delivered === undefined ? {} : { delivered: record.delivered }),
1627
+ ...(record.requestId === undefined ? {} : { requestId: record.requestId }),
1628
+ ...(record.dshTurn === undefined ? {} : { dshTurn: record.dshTurn }),
1629
+ ...(record.result === undefined ? {} : { result: record.result }),
1630
+ ...(record.error === undefined ? {} : { error: record.error }),
1631
+ ...(record.stopReason === undefined ? {} : { stopReason: record.stopReason }),
1632
+ createdAt: record.createdAt,
1633
+ updatedAt: record.updatedAt,
1634
+ })
1635
+ return
1636
+ }
1637
+
1638
+ // 没有 taskId:只给来源时列出该来源登记过的任务,用来找回丢失的 taskId。
1639
+ if (origin.originThreadId !== undefined) {
1640
+ const listed = associations === null ? { ok: false, reason: 'association-store-unavailable' } : associations.byOrigin(origin.originThreadId)
1641
+ if (listed.ok !== true) {
1642
+ sendJson(res, 503, { error: 'task association store is unavailable in this gateway', reason: listed.reason })
1643
+ return
1644
+ }
1645
+ const liveIds = new Set(tasks.filter((t) => t.originThreadId === origin.originThreadId).map((t) => t.taskId))
1646
+ sendJson(res, 200, {
1647
+ originThreadId: origin.originThreadId,
1648
+ count: listed.tasks.length,
1649
+ // 与精确查询同一套语义:本实例没有运行上下文的非终态记录只能标 unknown(绝不谎报还在观察)。
1650
+ tasks: listed.tasks.map((r) => ({ ...projectStoredTask(r), live: liveIds.has(r.taskId) })),
1048
1651
  })
1049
1652
  return
1050
1653
  }
@@ -1055,6 +1658,7 @@ export async function apply(ctx) {
1055
1658
  reviewers: Array.from(reviewerBuckets.keys()),
1056
1659
  sessions: Array.from(sessionTargets.keys()),
1057
1660
  sessionController: sessionControllerNow() !== undefined,
1661
+ capabilities: capabilityReport(),
1058
1662
  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 })),
1059
1663
  })
1060
1664
  },
@@ -1073,6 +1677,7 @@ export async function apply(ctx) {
1073
1677
  sessionController: probe('sessionController'),
1074
1678
  services: Object.fromEntries(['sessionController', 'connection', 'sessionQuery', 'sessionPersistence', 'workspaceRegistry', 'agents', 'subagents', 'agentPresets', 'timer', 'llm'].map((n) => [n, probe(n)])),
1075
1679
  sessionTargets: [], models: knownModels, modelProvider, modelListError, workspaces: [],
1680
+ capabilities: capabilityReport(),
1076
1681
  }
1077
1682
  out.pending = tasks.filter((t) => t.status === 'accepted' || t.status === 'running').length
1078
1683
  try { out.providers = subagents.list() } catch (e) { out.providers = ['list failed: ' + errText(e)] }
@@ -1121,13 +1726,21 @@ export async function apply(ctx) {
1121
1726
  try { socket.write(JSON.stringify({ type: 'pong', time: Date.now() }) + '\n') } catch (e) {}
1122
1727
  } else if (msg.type === 'instruction' && typeof msg.text === 'string' && msg.text.trim() !== '') {
1123
1728
  const classified = classifyTarget(msg)
1124
- if (!classified.ok) {
1125
- try { socket.write(JSON.stringify({ type: 'error', reason: classified.reason, error: classified.message, time: Date.now() }) + '\n') } catch (e) { /* drop */ }
1729
+ const origin = originOf(msg)
1730
+ if (!classified.ok || !origin.ok) {
1731
+ const reason = classified.ok ? ASSOC_REASONS.INVALID_ORIGIN : classified.reason
1732
+ const error = classified.ok ? 'originThreadId must be a non-empty string (<= 200 chars, no control characters)' : classified.message
1733
+ try { socket.write(JSON.stringify({ type: 'error', reason, error, time: Date.now() }) + '\n') } catch (e) { /* drop */ }
1126
1734
  } else {
1127
- void enqueue(msg.text, msg.history, classified.target, msg.model, 'task', classified.deliver, classified.notes)
1735
+ void enqueue(msg.text, msg.history, classified.target, msg.model, 'task', classified.deliver, classified.notes, origin.originThreadId)
1128
1736
  }
1129
1737
  } else if (msg.type === 'review') {
1130
- void submitReview(msg.cwd, msg.diff, msg.focus, msg.history)
1738
+ const origin = originOf(msg)
1739
+ if (!origin.ok) {
1740
+ try { socket.write(JSON.stringify({ type: 'error', reason: ASSOC_REASONS.INVALID_ORIGIN, error: 'originThreadId must be a non-empty string (<= 200 chars, no control characters)', time: Date.now() }) + '\n') } catch (e) { /* drop */ }
1741
+ } else {
1742
+ void submitReview(msg.cwd, msg.diff, msg.focus, msg.history, origin.originThreadId)
1743
+ }
1131
1744
  }
1132
1745
  }
1133
1746
  }
@@ -1143,24 +1756,51 @@ export async function apply(ctx) {
1143
1756
  // ---- 原生工具(宿主全局工具层)----
1144
1757
  ctx.effect(() => ctx.tools.register(defineTool({
1145
1758
  name: 'dsh_collab_send',
1146
- description: '向 Codex 协作频道发送一条编码指令。默认新建编码子代理在指定工作目录执行;给出 sessionId 或 sessionQuery 时改为投递进那一条已有会话(会话自带的对话与上下文)。指令用中文。',
1759
+ description: '向 Codex 协作频道发送一条编码指令。默认新建编码子代理在指定工作目录执行;给出 sessionId 或 sessionQuery 时改为投递进那一条已有会话(会话自带的对话与上下文)。指令用中文。'
1760
+ + '【硬规则】验证"这条查询会命中哪条会话"必须用 dryRun=true(只解析、不投递);**绝不投递任何消息去试探命中**——那会作为一条真实用户消息落进对方会话并跑成回合,污染对话。'
1761
+ + '【硬规则】返回里 delivered=true 表示消息已被那条会话接受(进了它的队列),即便随后观察到超时或错误也**不要重发**,用 wait=false 记下 taskId 再查状态。'
1762
+ + 'sessionQuery 落空时会返回 candidates(按标题相似度 + 最近更新排序),换词重查或改用 sessionId。',
1147
1763
  parameters: {
1148
1764
  instruction: { type: 'string', required: true, description: '要交给编码子代理执行的指令。' },
1149
1765
  cwd: { type: 'string', description: '工作目录(默认 ' + DEFAULT_WORKSPACE + ')。与 sessionId/sessionQuery 互斥。' },
1150
1766
  lane: { type: 'string', description: '并行通道名(默认 main,同目录不同 lane 并行)。' },
1151
1767
  model: { type: 'string', description: '模型别名: default | fast | pro。' },
1152
1768
  sessionId: { type: 'string', description: '已有会话 id:把指令投递进这条会话,而不是新建。' },
1153
- sessionQuery: { type: 'string', description: '按标题/内容搜索已有会话(唯一命中才生效,多条会返回候选)。' },
1769
+ sessionQuery: { type: 'string', description: '按标题/内容搜索已有会话(唯一命中才生效,多条或落空会返回候选)。' },
1154
1770
  deliver: { type: 'string', description: "投递方式: queue(默认,排队) 或 steer(插入当前回合)。" },
1771
+ dryRun: { type: 'boolean', description: '为 true 时只解析目标并回显(含 matchedBy 与候选),不投递任何消息——验证命中用它。' },
1772
+ originThreadId: { type: 'string', description: '发起本任务的 Codex 对话 id(来源关联)。必须是显式传入的非空字符串——不要用常驻进程环境或"最近对话"推断。用于之后按 origin+taskId 精确收取结果。' },
1155
1773
  wait: { type: 'boolean', description: '为 true 时等待结果(最长 15 分钟)。' },
1156
1774
  },
1157
1775
  output: { schema: { type: 'json' }, render: (_args, value) => [{ type: 'text', text: JSON.stringify(value, null, 2) }] },
1158
1776
  async execute(args) {
1159
1777
  const classified = classifyTarget(args)
1160
1778
  if (!classified.ok) return { ok: false, reason: classified.reason, error: classified.message }
1161
- const task = await enqueue(args.instruction, undefined, classified.target, args.model, 'task', classified.deliver, classified.notes)
1779
+ const origin = originOf(args)
1780
+ if (!origin.ok) return { ok: false, reason: ASSOC_REASONS.INVALID_ORIGIN, error: 'originThreadId must be a non-empty string (<= 200 chars, no control characters)' }
1781
+ // dryRun:只解析目标、回显,不投递。给调用方一条"先验证命中"的正路,免得它发消息试探。
1782
+ if (args.dryRun === true) {
1783
+ if (classified.target.kind === 'session') {
1784
+ const resolved = await resolveSessionTarget(classified.target)
1785
+ if (!resolved.ok) {
1786
+ return { ok: false, dryRun: true, resolved: false, reason: resolved.reason, error: resolved.message, ...(resolved.candidates.length > 0 ? { candidates: resolved.candidates } : {}) }
1787
+ }
1788
+ return {
1789
+ ok: true, dryRun: true, resolved: true,
1790
+ target: { kind: 'session', sessionId: resolved.sessionId, deliver: classified.deliver },
1791
+ ...(resolved.snippet === undefined ? {} : { snippet: resolved.snippet }),
1792
+ ...(resolved.matchedBy === undefined ? {} : { matchedBy: resolved.matchedBy }),
1793
+ ...(resolved.candidates.length > 0 ? { candidates: resolved.candidates } : {}),
1794
+ note: '只解析目标,未投递任何消息',
1795
+ }
1796
+ }
1797
+ const cwd = normalizeCwd(classified.target.cwd)
1798
+ const lane = normalizeLane(classified.target.lane)
1799
+ return { ok: true, dryRun: true, resolved: true, target: { kind: 'worker', cwd, lane }, note: '只解析目标,未派活' }
1800
+ }
1801
+ const task = await enqueue(args.instruction, undefined, classified.target, args.model, 'task', classified.deliver, classified.notes, origin.originThreadId)
1162
1802
  if (task.status === 'error') {
1163
- return { ok: false, taskId: task.taskId, reason: task.reason, error: task.error, ...(task.candidates === undefined ? {} : { candidates: task.candidates }) }
1803
+ return { ok: false, taskId: task.taskId, reason: task.reason, error: task.error, ...(task.delivered === true ? { delivered: true } : {}), ...(task.candidates === undefined ? {} : { candidates: task.candidates }) }
1164
1804
  }
1165
1805
  const brief = { taskId: task.taskId, status: task.status, ...taskResponse(task) }
1166
1806
  if (args.wait !== true || timer === undefined) {
@@ -1171,7 +1811,7 @@ export async function apply(ctx) {
1171
1811
  if (Date.now() - t0 > 900000) return { ...brief, status: task.status, result: task.result, note: 'wait 超时' }
1172
1812
  await timer.timeout(2500)
1173
1813
  }
1174
- return { ...brief, status: task.status, result: task.result, error: task.error, reason: task.reason }
1814
+ return { ...brief, status: task.status, result: task.result, error: task.error, reason: task.reason, ...(task.delivered === true ? { delivered: true, resend: false } : {}) }
1175
1815
  },
1176
1816
  })))
1177
1817
 
@@ -1182,13 +1822,16 @@ export async function apply(ctx) {
1182
1822
  cwd: { type: 'string', required: true, description: '评审目标所在工作目录。' },
1183
1823
  diff: { type: 'string', description: '变更 diff 文本(git diff 输出)。' },
1184
1824
  focus: { type: 'string', description: '审查重点。' },
1825
+ originThreadId: { type: 'string', description: '发起本次评审的 Codex 对话 id(来源关联)。必须显式传入;评审同样产生 taskId,因此同样支持按 origin+taskId 收取。' },
1185
1826
  wait: { type: 'boolean', description: '为 true 时等待评审结果(最长 15 分钟)。' },
1186
1827
  },
1187
1828
  output: { schema: { type: 'json' }, render: (_args, value) => [{ type: 'text', text: JSON.stringify(value, null, 2) }] },
1188
1829
  async execute(args) {
1189
- const task = await submitReview(args.cwd, args.diff, args.focus, undefined)
1830
+ const origin = originOf(args)
1831
+ if (!origin.ok) return { ok: false, reason: ASSOC_REASONS.INVALID_ORIGIN, error: 'originThreadId must be a non-empty string (<= 200 chars, no control characters)' }
1832
+ const task = await submitReview(args.cwd, args.diff, args.focus, undefined, origin.originThreadId)
1190
1833
  if (args.wait !== true || timer === undefined) {
1191
- return { taskId: task.taskId, status: task.status, cwd: task.cwd }
1834
+ return { taskId: task.taskId, status: task.status, cwd: task.cwd, ...(task.originThreadId === undefined ? {} : { originThreadId: task.originThreadId }) }
1192
1835
  }
1193
1836
  const t0 = Date.now()
1194
1837
  while (task.status === 'accepted' || task.status === 'running') {