@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.
- package/CHANGELOG.md +35 -0
- package/README.md +107 -7
- package/codex-plugin/plugins/dsh/.codex-plugin/plugin.json +1 -1
- package/codex-plugin/plugins/dsh/skills/dsh-collab/SKILL.md +50 -7
- package/codex-plugin/plugins/dsh/skills/dsh-collab/scripts/task.mjs +175 -15
- package/docs/CODEX_DSH_TASK_REPLY_PLAN.md +206 -0
- package/docs/DSH_TO_CODEX_INBOX.md +167 -0
- package/docs/DSH_TO_CODEX_MESSAGING_PLAN.md +140 -0
- package/docs/codex-desktop-attachment-evidence.md +116 -0
- package/harness/codex-associations.mjs +265 -0
- package/harness/codex-outbox.mjs +432 -0
- package/harness/dsh-bridge.mjs +686 -43
- package/harness/fs-lock.mjs +208 -0
- package/harness/session-target.mjs +187 -0
- package/harness/task-table.mjs +32 -0
- package/mcp/codex-desktop-probe.mjs +407 -0
- package/mcp/codex-inbox.mjs +271 -0
- package/mcp/dsh-mcp.mjs +160 -20
- package/package.json +3 -1
package/harness/dsh-bridge.mjs
CHANGED
|
@@ -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)
|
|
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
|
-
|
|
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
|
|
566
|
-
|
|
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
|
-
|
|
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
|
|
668
|
-
|
|
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
|
|
698
|
-
|
|
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
|
-
|
|
775
|
-
|
|
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', {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
958
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1040
|
-
|
|
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:
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
...(
|
|
1047
|
-
|
|
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
|
-
|
|
1125
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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') {
|