@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.
- package/CHANGELOG.md +35 -0
- package/README.md +96 -5
- package/codex-plugin/plugins/dsh/.codex-plugin/plugin.json +1 -1
- package/codex-plugin/plugins/dsh/skills/dsh-collab/SKILL.md +41 -5
- package/codex-plugin/plugins/dsh/skills/dsh-collab/scripts/task.mjs +134 -11
- 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 +538 -35
- package/harness/fs-lock.mjs +208 -0
- package/harness/session-target.mjs +134 -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 +147 -17
- 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,
|
|
@@ -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
|
-
|
|
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
|
|
601
|
-
|
|
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
|
-
//
|
|
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
|
|
707
|
-
|
|
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
|
|
737
|
-
|
|
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
|
|
1072
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1154
|
-
|
|
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:
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
...(
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
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
|
-
|
|
1241
|
-
|
|
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
|
-
|
|
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
|
|
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') {
|