@whaletalk/dsh-codex-collab 0.1.7 → 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,
@@ -34,6 +40,10 @@ import {
34
40
  looksLikeDisabledSearch,
35
41
  rankTitleCandidates,
36
42
  taskResponse,
43
+ createRequestTracker,
44
+ consumeSessionEvent,
45
+ consumeSnapshotFrame,
46
+ turnOutcome,
37
47
  } from './session-target.mjs'
38
48
 
39
49
  export const name = 'dsh-bridge'
@@ -204,6 +214,146 @@ export async function apply(ctx) {
204
214
  const tasks = []
205
215
  const sockets = new Set()
206
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
+
207
357
  const broadcast = (msg) => {
208
358
  let line
209
359
  try { line = JSON.stringify(msg) + '\n' } catch (e) { return }
@@ -341,6 +491,7 @@ export async function apply(ctx) {
341
491
  task.stopReason = info.stopReason
342
492
  remember('user', task.instruction)
343
493
  remember('assistant', task.result)
494
+ persistTask(task)
344
495
  pushTask(task.taskId, 'result', { text: task.result, stopReason: info.stopReason, childId: bucket.childId, cwd: bucket.cwd, kind: task.kind })
345
496
  } else {
346
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' })
@@ -529,6 +680,17 @@ export async function apply(ctx) {
529
680
  if (fields.reason !== undefined) task.reason = fields.reason
530
681
  if (fields.stopReason !== undefined) task.stopReason = fields.stopReason
531
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)
532
694
  pushTask(task.taskId, status === 'done' ? 'result' : 'error', {
533
695
  ...(task.result === undefined ? {} : { text: task.result }),
534
696
  ...(task.error === undefined ? {} : { error: task.error }),
@@ -537,10 +699,66 @@ export async function apply(ctx) {
537
699
  })
538
700
  }
539
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
+
540
748
  /** 收尾时以「持久日志 + 派发前基线」为准,避免只信流式片段。 */
541
749
  const finishSessionTask = (record, stopReason) => {
542
750
  if (record.settled) return
543
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
+ }
544
762
  let extractedText = ''
545
763
  if (sessionQuery !== undefined) {
546
764
  try {
@@ -571,7 +789,16 @@ export async function apply(ctx) {
571
789
  for await (const frame of stream) {
572
790
  if (record.settled) break
573
791
  if (frame === null || typeof frame !== 'object') continue
574
- 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 是派发前的历史,不作为本轮输出
575
802
  const text = assistantTextOf(frame.event)
576
803
  if (text === '') continue
577
804
  record.total += text
@@ -590,15 +817,26 @@ export async function apply(ctx) {
590
817
  }
591
818
 
592
819
  /** 派发一条指令进已有会话。任何失败都不创建会话,只落一个 error 任务。 */
593
- const dispatchToSession = async (instruction, target, deliver, notes, incomingHistory) => {
820
+ const dispatchToSession = async (instruction, target, deliver, notes, incomingHistory, originThreadId) => {
594
821
  const task = {
595
822
  taskId: uid(), kind: 'task', instruction,
596
823
  target: { kind: 'session', sessionId: target.sessionId, query: target.query, snippet: target.snippet },
597
824
  deliver, notes, status: 'accepted', time: Date.now(), result: undefined, error: undefined,
598
825
  cwd: undefined, lane: undefined, bucketKey: undefined,
599
826
  }
600
- tasks.push(task)
601
- 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
+ }
602
840
 
603
841
  const resolved = await resolveSessionTarget(target)
604
842
  if (!resolved.ok) {
@@ -623,6 +861,16 @@ export async function apply(ctx) {
623
861
  return { task, failed: { reason: REASONS.BUSY, message: task.error, candidates: [] } }
624
862
  }
625
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
+
626
874
  // 派发前取基线:结果只回报这一轮新产生的助手文本。
627
875
  let baseline = { count: 0, textLen: 0 }
628
876
  if (sessionQuery !== undefined) {
@@ -638,7 +886,10 @@ export async function apply(ctx) {
638
886
  sessionId: resolved.sessionId, taskId: task.taskId, deliver, baseline,
639
887
  total: '', sawRunning: false, activeSince: Date.now(), startedAt: Date.now(),
640
888
  settled: false, abort: undefined, watcher: 'pending',
641
- // prompt 被接受 = 消息已进会话队列。此后就不该再把它当"没发出去"(见 sweepSessionTargets)。
889
+ // 精确关联模式(带来源):靠 requestId→rpcId→turn 关联收结果,而不是派发前基线 + 全会话 idle。
890
+ precise: originThreadId !== undefined,
891
+ tracker: createRequestTracker(task.taskId),
892
+ reported: 0, // prompt 被接受 = 消息已进会话队列。此后就不该再把它当"没发出去"(见 sweepSessionTargets)。
642
893
  delivered: false, queuedNoted: false,
643
894
  }
644
895
  sessionTargets.set(resolved.sessionId, record)
@@ -678,7 +929,7 @@ export async function apply(ctx) {
678
929
  return { task, failed: null }
679
930
  }
680
931
 
681
- const enqueue = async (instruction, incomingHistory, target, modelAlias, kind, deliveryMode, notes) => {
932
+ const enqueue = async (instruction, incomingHistory, target, modelAlias, kind, deliveryMode, notes, originThreadId) => {
682
933
  if (incomingHistory !== undefined) syncHistory(incomingHistory)
683
934
  if (target !== undefined && target.kind === 'session') {
684
935
  // session 目标:不注册工作区、不建 owner、不建子代理,失败也不降级为新建。
@@ -687,6 +938,7 @@ export async function apply(ctx) {
687
938
  deliveryMode === undefined ? 'queue' : deliveryMode,
688
939
  Array.isArray(notes) ? notes : [],
689
940
  incomingHistory,
941
+ originThreadId,
690
942
  )
691
943
  return dispatched.task
692
944
  }
@@ -694,6 +946,19 @@ export async function apply(ctx) {
694
946
  const lane = normalizeLane(target === undefined ? undefined : target.lane)
695
947
  registerWorkspace(cwd)
696
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
+ }
697
962
  const requestedModel = await resolveModel(modelAlias)
698
963
  if (requestedModel !== null && requestedModel !== bucket.model) {
699
964
  bucket.model = requestedModel
@@ -703,8 +968,16 @@ export async function apply(ctx) {
703
968
  bucket.seenCount = 0
704
969
  }
705
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 }
706
- tasks.push(task)
707
- 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
+ }
708
981
  try {
709
982
  const delivered = await deliver(bucket, instruction, ROLE_BRIEF(cwd), '[Codex 指令] ')
710
983
  if (delivered.fallback) {
@@ -714,6 +987,7 @@ export async function apply(ctx) {
714
987
  task.fallback = delivered.fallbackNote
715
988
  remember('user', instruction)
716
989
  remember('assistant', task.result)
990
+ persistTask(task)
717
991
  pushTask(task.taskId, 'result', { text: task.result, stopReason: delivered.stopReason, childId: null, cwd, kind })
718
992
  } else {
719
993
  bucket.activeSince = Date.now()
@@ -722,19 +996,40 @@ export async function apply(ctx) {
722
996
  } catch (e) {
723
997
  task.status = 'error'
724
998
  task.error = errText(e)
999
+ persistTask(task)
725
1000
  pushTask(task.taskId, 'error', { error: task.error, cwd, kind })
726
1001
  }
727
1002
  return task
728
1003
  }
729
1004
 
730
- const submitReview = async (rawCwd, diff, focus, incomingHistory) => {
1005
+ const submitReview = async (rawCwd, diff, focus, incomingHistory, originThreadId) => {
731
1006
  if (incomingHistory !== undefined) syncHistory(incomingHistory)
732
1007
  const cwd = normalizeCwd(rawCwd)
733
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
+ }
734
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')
735
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 }
736
- tasks.push(task)
737
- 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
+ }
738
1033
  try {
739
1034
  const delivered = await deliver(bucket, instruction, REVIEWER_BRIEF(cwd), '[评审请求] ')
740
1035
  if (delivered.fallback) {
@@ -744,6 +1039,7 @@ export async function apply(ctx) {
744
1039
  task.fallback = delivered.fallbackNote
745
1040
  remember('user', '[评审请求] ' + (typeof focus === 'string' && focus !== '' ? focus : 'diff 审查'))
746
1041
  remember('assistant', task.result)
1042
+ persistTask(task)
747
1043
  pushTask(task.taskId, 'result', { text: task.result, stopReason: delivered.stopReason, childId: null, cwd, kind: 'review' })
748
1044
  } else {
749
1045
  bucket.activeSince = Date.now()
@@ -752,6 +1048,7 @@ export async function apply(ctx) {
752
1048
  } catch (e) {
753
1049
  task.status = 'error'
754
1050
  task.error = errText(e)
1051
+ persistTask(task)
755
1052
  pushTask(task.taskId, 'error', { error: task.error, cwd, kind: 'review' })
756
1053
  }
757
1054
  return task
@@ -782,6 +1079,7 @@ export async function apply(ctx) {
782
1079
  if (active !== undefined && total.length > 0) {
783
1080
  active.status = 'done'
784
1081
  active.result = total.slice(Math.max(0, total.length - 4000)).trim() || '(无输出)'
1082
+ persistTask(active)
785
1083
  pushTask(active.taskId, 'result', { text: active.result, stopReason: 'quiet-timeout', childId: bucket.childId, cwd: bucket.cwd, kind: bucket.lane === '@review' ? 'review' : 'task' })
786
1084
  bucket.activeSince = 0
787
1085
  }
@@ -795,6 +1093,41 @@ export async function apply(ctx) {
795
1093
  if (task === undefined) { settleSessionTask(record, 'cancelled', {}); continue }
796
1094
  if (task.status === 'cancelled') { record.settled = true; if (record.abort !== undefined) { try { record.abort.abort() } catch (e) { /* ignore */ } } sessionTargets.delete(record.sessionId); continue }
797
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
+ }
798
1131
  if (record.watcher === 'poll' && sessionQuery !== undefined) {
799
1132
  try {
800
1133
  const snap = await sessionQuery.readSession(record.sessionId)
@@ -917,6 +1250,12 @@ export async function apply(ctx) {
917
1250
  if (!dryRun && instruction.trim() === '') { sendJson(res, 400, { error: 'missing instruction' }); return }
918
1251
  const classified = classifyTarget(parsed)
919
1252
  if (!classified.ok) { sendJson(res, 400, { error: classified.message, reason: classified.reason }); return }
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
+ }
920
1259
  // dryRun:只解析目标、回显它,**不投递任何东西**。存在的意义是让"我这条查询会命中
921
1260
  // 哪条会话"能纯只读地问出来——真实事故里调用方就是因为没法先验证,才发了一条占位
922
1261
  // 消息去探测,结果污染了用户会话。
@@ -956,11 +1295,12 @@ export async function apply(ctx) {
956
1295
  })
957
1296
  return
958
1297
  }
959
- const task = await enqueue(instruction, parsed.history, classified.target, parsed.model, 'task', classified.deliver, classified.notes)
1298
+ const task = await enqueue(instruction, parsed.history, classified.target, parsed.model, 'task', classified.deliver, classified.notes, origin.originThreadId)
960
1299
  if (task.status === 'error') {
961
1300
  sendJson(res, 400, {
962
1301
  error: task.error,
963
1302
  reason: task.reason,
1303
+ ...(task.originThreadId === undefined ? {} : { originThreadId: task.originThreadId }),
964
1304
  ...(task.delivered === true ? { delivered: true } : {}),
965
1305
  ...(Array.isArray(task.candidates) && task.candidates.length > 0 ? { candidates: task.candidates } : {}),
966
1306
  })
@@ -1068,8 +1408,17 @@ export async function apply(ctx) {
1068
1408
  if (req.method !== 'POST') { res.writeHead(405); res.end('POST only'); return }
1069
1409
  const parsed = await readJsonBody(req, res)
1070
1410
  if (parsed === null) return
1071
- const task = await submitReview(parsed.cwd, parsed.diff, parsed.focus, parsed.history)
1072
- sendJson(res, 202, { taskId: task.taskId, status: task.status, cwd: task.cwd, kind: 'review' })
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 }) })
1073
1422
  },
1074
1423
  }))
1075
1424
 
@@ -1078,7 +1427,31 @@ export async function apply(ctx) {
1078
1427
  path: '/api/dsh-bridge/tasks',
1079
1428
  handler: async (req, res) => {
1080
1429
  if (!isLoopback(req)) { res.writeHead(403); res.end('forbidden'); return }
1081
- sendJson(res, 200, { tasks: tasks.slice(-50).map((t) => ({ taskId: t.taskId, kind: t.kind, cwd: t.cwd, lane: t.lane, target: t.target, deliver: t.deliver, status: t.status, time: t.time, instruction: t.instruction.slice(0, 200), error: t.error, reason: t.reason, fallback: t.fallback })) })
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() })
1082
1455
  },
1083
1456
  }))
1084
1457
 
@@ -1091,9 +1464,29 @@ export async function apply(ctx) {
1091
1464
  if (parsed === null) return
1092
1465
  const taskId = parsed && parsed.taskId
1093
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
+ }
1094
1472
  const task = tasks.find((t) => t.taskId === taskId)
1095
1473
  if (task === undefined) { sendJson(res, 404, { error: 'unknown taskId' }); return }
1096
- if (task.status !== 'accepted' && task.status !== 'running') { sendJson(res, 200, { taskId, cancelled: false, status: task.status }); return }
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 }
1097
1490
 
1098
1491
  // session 目标动的是用户自己的对话:取消会打断他正在跑的回合,因此必须显式 force。
1099
1492
  if (task.target !== undefined && task.target.kind === 'session') {
@@ -1119,8 +1512,9 @@ export async function apply(ctx) {
1119
1512
  task.cancelReason = 'cancelled by request (force)'
1120
1513
  const record = sessionTargets.get(task.target.sessionId)
1121
1514
  if (record !== undefined) settleSessionTask(record, 'cancelled', {})
1515
+ persistTask(task)
1122
1516
  pushTask(taskId, 'cancelled', { target: { kind: 'session', sessionId: task.target.sessionId } })
1123
- 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 }) })
1124
1518
  return
1125
1519
  }
1126
1520
 
@@ -1136,8 +1530,9 @@ export async function apply(ctx) {
1136
1530
  } catch (e) {
1137
1531
  console.error('[dsh-bridge] cancel interrupt failed:', errText(e).slice(0, 200))
1138
1532
  }
1533
+ persistTask(task)
1139
1534
  pushTask(taskId, 'cancelled', { cwd: task.cwd })
1140
- sendJson(res, 200, { taskId, cancelled: true, interrupted })
1535
+ sendJson(res, 200, { taskId, cancelled: true, interrupted, ...(task.originThreadId === undefined ? {} : { originThreadId: task.originThreadId }) })
1141
1536
  },
1142
1537
  }))
1143
1538
 
@@ -1147,20 +1542,112 @@ export async function apply(ctx) {
1147
1542
  handler: async (req, res) => {
1148
1543
  if (!isLoopback(req)) { res.writeHead(403); res.end('forbidden'); return }
1149
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
+ }
1150
1550
  const taskId = qs.taskId
1151
1551
  if (taskId !== undefined && taskId !== '') {
1152
1552
  const task = tasks.find((t) => t.taskId === taskId)
1153
- if (task === undefined) { sendJson(res, 404, { error: 'unknown taskId' }); return }
1154
- const record = task.target !== undefined && task.target.kind === 'session' ? sessionTargets.get(task.target.sessionId) : undefined
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'
1155
1609
  sendJson(res, 200, {
1156
- taskId: task.taskId, status: task.status, kind: task.kind, cwd: task.cwd, lane: task.lane,
1157
- target: task.target, deliver: task.deliver, instruction: task.instruction.slice(0, 300),
1158
- result: task.result, error: task.error, reason: task.reason, fallback: task.fallback,
1159
- // delivered=true 表示 prompt 已被会话接受(进了队列)——即便后面报错也不要重发。
1160
- ...(task.delivered === undefined ? {} : { delivered: task.delivered }),
1161
- ...(task.matchedBy === undefined ? {} : { matchedBy: task.matchedBy }),
1162
- ...(Array.isArray(task.candidates) && task.candidates.length > 0 ? { candidates: task.candidates } : {}),
1163
- ...(record === undefined ? {} : { watcher: record.watcher, sawRunning: record.sawRunning }),
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) })),
1164
1651
  })
1165
1652
  return
1166
1653
  }
@@ -1171,6 +1658,7 @@ export async function apply(ctx) {
1171
1658
  reviewers: Array.from(reviewerBuckets.keys()),
1172
1659
  sessions: Array.from(sessionTargets.keys()),
1173
1660
  sessionController: sessionControllerNow() !== undefined,
1661
+ capabilities: capabilityReport(),
1174
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 })),
1175
1663
  })
1176
1664
  },
@@ -1189,6 +1677,7 @@ export async function apply(ctx) {
1189
1677
  sessionController: probe('sessionController'),
1190
1678
  services: Object.fromEntries(['sessionController', 'connection', 'sessionQuery', 'sessionPersistence', 'workspaceRegistry', 'agents', 'subagents', 'agentPresets', 'timer', 'llm'].map((n) => [n, probe(n)])),
1191
1679
  sessionTargets: [], models: knownModels, modelProvider, modelListError, workspaces: [],
1680
+ capabilities: capabilityReport(),
1192
1681
  }
1193
1682
  out.pending = tasks.filter((t) => t.status === 'accepted' || t.status === 'running').length
1194
1683
  try { out.providers = subagents.list() } catch (e) { out.providers = ['list failed: ' + errText(e)] }
@@ -1237,13 +1726,21 @@ export async function apply(ctx) {
1237
1726
  try { socket.write(JSON.stringify({ type: 'pong', time: Date.now() }) + '\n') } catch (e) {}
1238
1727
  } else if (msg.type === 'instruction' && typeof msg.text === 'string' && msg.text.trim() !== '') {
1239
1728
  const classified = classifyTarget(msg)
1240
- if (!classified.ok) {
1241
- try { socket.write(JSON.stringify({ type: 'error', reason: classified.reason, error: classified.message, time: Date.now() }) + '\n') } catch (e) { /* drop */ }
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 */ }
1242
1734
  } else {
1243
- 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)
1244
1736
  }
1245
1737
  } else if (msg.type === 'review') {
1246
- 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
+ }
1247
1744
  }
1248
1745
  }
1249
1746
  }
@@ -1272,12 +1769,15 @@ export async function apply(ctx) {
1272
1769
  sessionQuery: { type: 'string', description: '按标题/内容搜索已有会话(唯一命中才生效,多条或落空会返回候选)。' },
1273
1770
  deliver: { type: 'string', description: "投递方式: queue(默认,排队) 或 steer(插入当前回合)。" },
1274
1771
  dryRun: { type: 'boolean', description: '为 true 时只解析目标并回显(含 matchedBy 与候选),不投递任何消息——验证命中用它。' },
1772
+ originThreadId: { type: 'string', description: '发起本任务的 Codex 对话 id(来源关联)。必须是显式传入的非空字符串——不要用常驻进程环境或"最近对话"推断。用于之后按 origin+taskId 精确收取结果。' },
1275
1773
  wait: { type: 'boolean', description: '为 true 时等待结果(最长 15 分钟)。' },
1276
1774
  },
1277
1775
  output: { schema: { type: 'json' }, render: (_args, value) => [{ type: 'text', text: JSON.stringify(value, null, 2) }] },
1278
1776
  async execute(args) {
1279
1777
  const classified = classifyTarget(args)
1280
1778
  if (!classified.ok) return { ok: false, reason: classified.reason, error: classified.message }
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)' }
1281
1781
  // dryRun:只解析目标、回显,不投递。给调用方一条"先验证命中"的正路,免得它发消息试探。
1282
1782
  if (args.dryRun === true) {
1283
1783
  if (classified.target.kind === 'session') {
@@ -1298,7 +1798,7 @@ export async function apply(ctx) {
1298
1798
  const lane = normalizeLane(classified.target.lane)
1299
1799
  return { ok: true, dryRun: true, resolved: true, target: { kind: 'worker', cwd, lane }, note: '只解析目标,未派活' }
1300
1800
  }
1301
- const task = await enqueue(args.instruction, undefined, classified.target, args.model, 'task', classified.deliver, classified.notes)
1801
+ const task = await enqueue(args.instruction, undefined, classified.target, args.model, 'task', classified.deliver, classified.notes, origin.originThreadId)
1302
1802
  if (task.status === 'error') {
1303
1803
  return { ok: false, taskId: task.taskId, reason: task.reason, error: task.error, ...(task.delivered === true ? { delivered: true } : {}), ...(task.candidates === undefined ? {} : { candidates: task.candidates }) }
1304
1804
  }
@@ -1322,13 +1822,16 @@ export async function apply(ctx) {
1322
1822
  cwd: { type: 'string', required: true, description: '评审目标所在工作目录。' },
1323
1823
  diff: { type: 'string', description: '变更 diff 文本(git diff 输出)。' },
1324
1824
  focus: { type: 'string', description: '审查重点。' },
1825
+ originThreadId: { type: 'string', description: '发起本次评审的 Codex 对话 id(来源关联)。必须显式传入;评审同样产生 taskId,因此同样支持按 origin+taskId 收取。' },
1325
1826
  wait: { type: 'boolean', description: '为 true 时等待评审结果(最长 15 分钟)。' },
1326
1827
  },
1327
1828
  output: { schema: { type: 'json' }, render: (_args, value) => [{ type: 'text', text: JSON.stringify(value, null, 2) }] },
1328
1829
  async execute(args) {
1329
- 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)
1330
1833
  if (args.wait !== true || timer === undefined) {
1331
- 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 }) }
1332
1835
  }
1333
1836
  const t0 = Date.now()
1334
1837
  while (task.status === 'accepted' || task.status === 'running') {