@whaletalk/dsh-codex-collab 0.1.0 → 0.1.2
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/README.md +34 -5
- package/codex-plugin/plugins/dsh/.codex-plugin/plugin.json +1 -1
- package/codex-plugin/plugins/dsh/skills/dsh-collab/SKILL.md +50 -21
- package/codex-plugin/plugins/dsh/skills/dsh-collab/scripts/task.mjs +68 -15
- package/harness/dsh-bridge.mjs +454 -46
- package/harness/session-target.mjs +208 -0
- package/mcp/dsh-mcp.mjs +47 -6
- package/package.json +1 -1
package/harness/dsh-bridge.mjs
CHANGED
|
@@ -2,18 +2,35 @@
|
|
|
2
2
|
* dsh-bridge: Codex ↔ DeepSeek Harness 双向协作网关(持久化宿主插件)。
|
|
3
3
|
*
|
|
4
4
|
* 提供:
|
|
5
|
-
* - POST /api/dsh-bridge/task
|
|
6
|
-
*
|
|
7
|
-
* -
|
|
8
|
-
* - POST /api/dsh-bridge/
|
|
9
|
-
* - GET /api/dsh-bridge/
|
|
10
|
-
* -
|
|
11
|
-
* -
|
|
5
|
+
* - POST /api/dsh-bridge/task 派活:target.kind=worker 新建子代理(现有行为),
|
|
6
|
+
* target.kind=session 接进已有会话
|
|
7
|
+
* - POST /api/dsh-bridge/sessions 只读"找到会话"(search / resolve),不创建任何东西
|
|
8
|
+
* - POST /api/dsh-bridge/review 双向评审(独立评审子代理,必须跑通构建/测试)
|
|
9
|
+
* - GET /api/dsh-bridge/tasks 任务列表
|
|
10
|
+
* - POST /api/dsh-bridge/cancel 取消任务(session 目标默认拒绝,需 force)
|
|
11
|
+
* - GET /api/dsh-bridge/status 任务状态
|
|
12
|
+
* - GET /api/dsh-bridge/debug 调试信息
|
|
13
|
+
* - WS /api/dsh-bridge/ws 实时推送(升级协议,供可选客户端使用)
|
|
14
|
+
*
|
|
15
|
+
* "找到原对话"与"接通原对话"是两件事:前者是 /sessions 只读查询,后者是 /task
|
|
16
|
+
* 带 session 目标投递。session 目标**绝不**静默降级为新建——任何失败都返回稳定
|
|
17
|
+
* reason 且不创建任何会话/工作区。
|
|
12
18
|
*
|
|
13
19
|
* 与动态插件版本的关键差异:宿主插件运行在完整 Node 环境,
|
|
14
20
|
* AbortSignal 可用,因此 followup 的 cold-resume 路径不再降级。
|
|
15
21
|
*/
|
|
16
22
|
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
23
|
+
import {
|
|
24
|
+
REASONS,
|
|
25
|
+
textOfBlocks,
|
|
26
|
+
assistantTextOf,
|
|
27
|
+
baselineOf,
|
|
28
|
+
extractNewAssistantText,
|
|
29
|
+
classifyTarget,
|
|
30
|
+
selectSession,
|
|
31
|
+
mapControllerError,
|
|
32
|
+
taskResponse,
|
|
33
|
+
} from './session-target.mjs'
|
|
17
34
|
|
|
18
35
|
export const name = 'dsh-bridge'
|
|
19
36
|
|
|
@@ -29,6 +46,9 @@ export async function apply(ctx) {
|
|
|
29
46
|
const sessionPersistence = ctx.get('sessionPersistence')
|
|
30
47
|
const workspaceRegistry = ctx.get('workspaceRegistry')
|
|
31
48
|
const llm = ctx.get('llm')
|
|
49
|
+
// 已有会话的读写入口(由 dsh-web-app bundle 提供,与 webServer 同源)。
|
|
50
|
+
// 不放进 inject:缺失时降级为稳定的 session-controller-unavailable,而不是整个插件挂掉。
|
|
51
|
+
const sessionController = ctx.get('sessionController')
|
|
32
52
|
|
|
33
53
|
// 清理历史版本可能泄漏的路由条目(重启前的动态插件、旧版本次插件)
|
|
34
54
|
try {
|
|
@@ -39,6 +59,7 @@ export async function apply(ctx) {
|
|
|
39
59
|
webServer.exact.delete('/api/dsh-bridge/review')
|
|
40
60
|
webServer.exact.delete('/api/dsh-bridge/tasks')
|
|
41
61
|
webServer.exact.delete('/api/dsh-bridge/cancel')
|
|
62
|
+
webServer.exact.delete('/api/dsh-bridge/sessions')
|
|
42
63
|
}
|
|
43
64
|
if (webServer.upgrades instanceof Map) webServer.upgrades.delete('/api/dsh-bridge/ws')
|
|
44
65
|
} catch (e) {
|
|
@@ -64,17 +85,6 @@ export async function apply(ctx) {
|
|
|
64
85
|
}
|
|
65
86
|
return s
|
|
66
87
|
}
|
|
67
|
-
const textOfBlocks = (blocks) => {
|
|
68
|
-
if (blocks === undefined || blocks === null) return ''
|
|
69
|
-
let out = ''
|
|
70
|
-
const list = Array.isArray(blocks) ? blocks : [blocks]
|
|
71
|
-
for (const b of list) {
|
|
72
|
-
if (b === undefined || b === null || typeof b !== 'object') continue
|
|
73
|
-
if (b.type === 'text' && typeof b.text === 'string') out += b.text
|
|
74
|
-
if (Array.isArray(b.content)) out += textOfBlocks(b.content)
|
|
75
|
-
}
|
|
76
|
-
return out
|
|
77
|
-
}
|
|
78
88
|
const qsOf = (req) => {
|
|
79
89
|
const u = req.url || ''
|
|
80
90
|
const qi = u.indexOf('?')
|
|
@@ -235,12 +245,18 @@ export async function apply(ctx) {
|
|
|
235
245
|
|
|
236
246
|
const createOwner = async (bucket) => {
|
|
237
247
|
const modelOptions = pickModelOptions()
|
|
248
|
+
let ownerPreset = 'code'
|
|
249
|
+
if (agentPresets !== undefined) {
|
|
250
|
+
const presets = await agentPresets.list()
|
|
251
|
+
const available = (id) => presets.some((p) => p.id === id && !p.broken)
|
|
252
|
+
if (!available('code') && available('standard')) ownerPreset = 'standard'
|
|
253
|
+
}
|
|
238
254
|
const handle = await agents.create({
|
|
239
255
|
sessionId: 'dsh-bridge-owner-' + uid(),
|
|
240
|
-
meta: { cwd: bucket.cwd, origin: 'subagent', agentPreset:
|
|
256
|
+
meta: { cwd: bucket.cwd, origin: 'subagent', agentPreset: ownerPreset },
|
|
241
257
|
agentOptions: modelOptions,
|
|
242
258
|
setup: async (agentCtx) => {
|
|
243
|
-
if (agentPresets !== undefined) await agentPresets.mount(agentCtx,
|
|
259
|
+
if (agentPresets !== undefined) await agentPresets.mount(agentCtx, ownerPreset)
|
|
244
260
|
},
|
|
245
261
|
})
|
|
246
262
|
bucket.handle = handle
|
|
@@ -365,10 +381,213 @@ export async function apply(ctx) {
|
|
|
365
381
|
return { fallback: false, output: '', stopReason: '', fallbackNote: '' }
|
|
366
382
|
}
|
|
367
383
|
|
|
368
|
-
|
|
384
|
+
// ---- 已有会话:解析 → 派发 → 观察 ----
|
|
385
|
+
const envMs = (name, fallback) => {
|
|
386
|
+
const v = Number(process.env[name])
|
|
387
|
+
return Number.isFinite(v) && v > 0 ? v : fallback
|
|
388
|
+
}
|
|
389
|
+
/** 派发后被接受、但迟迟没有真正开始跑(仍在排队)的容忍时间。 */
|
|
390
|
+
const SESSION_DISPATCH_TIMEOUT_MS = envMs('DSH_BRIDGE_SESSION_DISPATCH_TIMEOUT_MS', 120000)
|
|
391
|
+
/** 见过 running 之后,多久没有新助手文本算读完了。 */
|
|
392
|
+
const SESSION_QUIET_TIMEOUT_MS = envMs('DSH_BRIDGE_SESSION_QUIET_TIMEOUT_MS', 600000)
|
|
393
|
+
/** 单个会话任务的总预算上限。 */
|
|
394
|
+
const SESSION_TOTAL_TIMEOUT_MS = envMs('DSH_BRIDGE_SESSION_TOTAL_TIMEOUT_MS', 3600000)
|
|
395
|
+
// sessionTargets 以 sessionId 为键:同一会话同时只允许一个在途 bridge 任务。
|
|
396
|
+
// DSH 自己会排队,但我们无法可靠地把两次并发派的回报各自归属,所以宁可明确拒绝。
|
|
397
|
+
const sessionTargets = new Map()
|
|
398
|
+
|
|
399
|
+
const sessionSearch = async (query) => {
|
|
400
|
+
const value = await sessionController.search({ query }, signalOf())
|
|
401
|
+
const items = value !== undefined && value !== null && Array.isArray(value.items) ? value.items : []
|
|
402
|
+
return items.filter((it) => it !== null && typeof it === 'object' && typeof it.sessionId === 'string' && it.sessionId !== '')
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/** 只读解析:query → search 选一;sessionId → 直接用。任何不确定性都返回 reason。 */
|
|
406
|
+
const resolveSessionTarget = async (target) => {
|
|
407
|
+
if (sessionController === undefined) {
|
|
408
|
+
return { ok: false, reason: REASONS.CONTROLLER_UNAVAILABLE, message: 'sessionController service is not mounted in this profile', candidates: [] }
|
|
409
|
+
}
|
|
410
|
+
let hits = []
|
|
411
|
+
if (target.sessionId === undefined) {
|
|
412
|
+
try {
|
|
413
|
+
hits = await sessionSearch(target.query)
|
|
414
|
+
} catch (e) {
|
|
415
|
+
return { ok: false, reason: REASONS.GATEWAY_INTERNAL, message: errText(e), candidates: [] }
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
const picked = selectSession(hits, target)
|
|
419
|
+
if (!picked.ok) return picked
|
|
420
|
+
let result
|
|
421
|
+
try {
|
|
422
|
+
result = await sessionController.resolveAgent(picked.sessionId)
|
|
423
|
+
} catch (e) {
|
|
424
|
+
return { ok: false, reason: mapControllerError(e), message: errText(e), candidates: picked.candidates }
|
|
425
|
+
}
|
|
426
|
+
if (result !== undefined && result !== null && result.error !== undefined) {
|
|
427
|
+
return { ok: false, reason: mapControllerError(result.error), message: errText(result.error), candidates: picked.candidates }
|
|
428
|
+
}
|
|
429
|
+
return { ok: true, sessionId: picked.sessionId, snippet: picked.snippet, candidates: picked.candidates }
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
const sessionPendingTask = (sessionId) => tasks.find((t) => (t.status === 'accepted' || t.status === 'running') && t.target !== undefined && t.target.kind === 'session' && t.target.sessionId === sessionId)
|
|
433
|
+
|
|
434
|
+
const settleSessionTask = (record, status, fields) => {
|
|
435
|
+
if (record.settled) return
|
|
436
|
+
record.settled = true
|
|
437
|
+
if (record.abort !== undefined) { try { record.abort.abort() } catch (e) { /* ignore */ } }
|
|
438
|
+
sessionTargets.delete(record.sessionId)
|
|
439
|
+
const task = tasks.find((t) => t.taskId === record.taskId)
|
|
440
|
+
if (task === undefined) return
|
|
441
|
+
task.status = status
|
|
442
|
+
if (fields.result !== undefined) task.result = fields.result
|
|
443
|
+
if (fields.error !== undefined) task.error = fields.error
|
|
444
|
+
if (fields.reason !== undefined) task.reason = fields.reason
|
|
445
|
+
if (fields.stopReason !== undefined) task.stopReason = fields.stopReason
|
|
446
|
+
pushTask(task.taskId, status === 'done' ? 'result' : 'error', {
|
|
447
|
+
...(task.result === undefined ? {} : { text: task.result }),
|
|
448
|
+
...(task.error === undefined ? {} : { error: task.error }),
|
|
449
|
+
target: { kind: 'session', sessionId: record.sessionId },
|
|
450
|
+
...(task.reason === undefined ? {} : { reason: task.reason }),
|
|
451
|
+
})
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/** 收尾时以「持久日志 + 派发前基线」为准,避免只信流式片段。 */
|
|
455
|
+
const finishSessionTask = (record, stopReason) => {
|
|
456
|
+
if (record.settled) return
|
|
457
|
+
void (async () => {
|
|
458
|
+
let text = record.total
|
|
459
|
+
if (sessionQuery !== undefined) {
|
|
460
|
+
try {
|
|
461
|
+
const snap = await sessionQuery.readSession(record.sessionId)
|
|
462
|
+
const events = snap !== undefined && snap !== null && Array.isArray(snap.events) ? snap.events : []
|
|
463
|
+
const extracted = extractNewAssistantText(events, record.baseline)
|
|
464
|
+
record.baseline = extracted.baseline
|
|
465
|
+
if (extracted.text.trim() !== '') text = record.total + extracted.text
|
|
466
|
+
} catch (e) {
|
|
467
|
+
console.error('[dsh-bridge] session result read failed:', errText(e).slice(0, 200))
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
settleSessionTask(record, 'done', { result: text.trim() !== '' ? text.trim() : '(无输出)', stopReason })
|
|
471
|
+
})()
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
const watchSessionTask = (record) => {
|
|
475
|
+
// 主路径:follow 增量流(不激活 Agent,也不重读整份日志——目标会话可能有几十 MB)。
|
|
476
|
+
const controller = new AbortController()
|
|
477
|
+
record.abort = controller
|
|
478
|
+
record.watcher = 'follow'
|
|
479
|
+
void (async () => {
|
|
480
|
+
try {
|
|
481
|
+
const stream = sessionController.follow({ address: { kind: 'session', sessionId: record.sessionId } }, controller.signal)
|
|
482
|
+
for await (const frame of stream) {
|
|
483
|
+
if (record.settled) break
|
|
484
|
+
if (frame === null || typeof frame !== 'object') continue
|
|
485
|
+
if (frame.type !== 'event') continue // snapshot 是派发前的历史,不作为本轮输出
|
|
486
|
+
const text = assistantTextOf(frame.event)
|
|
487
|
+
if (text === '') continue
|
|
488
|
+
record.total += text
|
|
489
|
+
record.activeSince = Date.now()
|
|
490
|
+
const task = tasks.find((t) => t.taskId === record.taskId)
|
|
491
|
+
if (task !== undefined && task.status === 'accepted') task.status = 'running'
|
|
492
|
+
pushTask(record.taskId, 'progress', { text, target: { kind: 'session', sessionId: record.sessionId } })
|
|
493
|
+
}
|
|
494
|
+
} catch (e) {
|
|
495
|
+
if (record.settled) return
|
|
496
|
+
// 降级:交给 2.5s 轮询(与子代理路径同一套解析),并在任务上标出来以便观测。
|
|
497
|
+
record.watcher = 'poll'
|
|
498
|
+
console.error('[dsh-bridge] follow unavailable, falling back to polling:', errText(e).slice(0, 200))
|
|
499
|
+
}
|
|
500
|
+
})()
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
/** 派发一条指令进已有会话。任何失败都不创建会话,只落一个 error 任务。 */
|
|
504
|
+
const dispatchToSession = async (instruction, target, deliver, notes, incomingHistory) => {
|
|
505
|
+
const task = {
|
|
506
|
+
taskId: uid(), kind: 'task', instruction,
|
|
507
|
+
target: { kind: 'session', sessionId: target.sessionId, query: target.query, snippet: target.snippet },
|
|
508
|
+
deliver, notes, status: 'accepted', time: Date.now(), result: undefined, error: undefined,
|
|
509
|
+
cwd: undefined, lane: undefined, bucketKey: undefined,
|
|
510
|
+
}
|
|
511
|
+
tasks.push(task)
|
|
512
|
+
if (tasks.length > 200) tasks.splice(0, tasks.length - 200)
|
|
513
|
+
|
|
514
|
+
const resolved = await resolveSessionTarget(target)
|
|
515
|
+
if (!resolved.ok) {
|
|
516
|
+
task.status = 'error'
|
|
517
|
+
task.error = resolved.message
|
|
518
|
+
task.reason = resolved.reason
|
|
519
|
+
task.candidates = resolved.candidates
|
|
520
|
+
pushTask(task.taskId, 'error', { error: task.error, reason: task.reason })
|
|
521
|
+
return { task, failed: resolved }
|
|
522
|
+
}
|
|
523
|
+
task.target.sessionId = resolved.sessionId
|
|
524
|
+
if (resolved.snippet !== undefined) task.target.snippet = resolved.snippet
|
|
525
|
+
if (resolved.candidates.length > 0) task.candidates = resolved.candidates
|
|
526
|
+
|
|
527
|
+
const inFlight = sessionPendingTask(resolved.sessionId)
|
|
528
|
+
if (inFlight !== undefined && inFlight.taskId !== task.taskId) {
|
|
529
|
+
task.status = 'error'
|
|
530
|
+
task.error = 'another bridge task is already pending on this session'
|
|
531
|
+
task.reason = REASONS.BUSY
|
|
532
|
+
pushTask(task.taskId, 'error', { error: task.error, reason: task.reason, target: { kind: 'session', sessionId: resolved.sessionId } })
|
|
533
|
+
return { task, failed: { reason: REASONS.BUSY, message: task.error, candidates: [] } }
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
// 派发前取基线:结果只回报这一轮新产生的助手文本。
|
|
537
|
+
let baseline = { count: 0, textLen: 0 }
|
|
538
|
+
if (sessionQuery !== undefined) {
|
|
539
|
+
try {
|
|
540
|
+
const snap = await sessionQuery.readSession(resolved.sessionId)
|
|
541
|
+
const events = snap !== undefined && snap !== null && Array.isArray(snap.events) ? snap.events : []
|
|
542
|
+
baseline = baselineOf(events)
|
|
543
|
+
} catch (e) {
|
|
544
|
+
console.error('[dsh-bridge] session baseline read failed:', errText(e).slice(0, 200))
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
const record = {
|
|
548
|
+
sessionId: resolved.sessionId, taskId: task.taskId, deliver, baseline,
|
|
549
|
+
total: '', sawRunning: false, activeSince: Date.now(), startedAt: Date.now(),
|
|
550
|
+
settled: false, abort: undefined, watcher: 'pending',
|
|
551
|
+
}
|
|
552
|
+
sessionTargets.set(resolved.sessionId, record)
|
|
553
|
+
|
|
554
|
+
try {
|
|
555
|
+
await sessionController.prompt({
|
|
556
|
+
requestId: task.taskId,
|
|
557
|
+
sessionId: resolved.sessionId,
|
|
558
|
+
mode: deliver,
|
|
559
|
+
content: [{ type: 'text', text: instruction }],
|
|
560
|
+
}, signalOf())
|
|
561
|
+
} catch (e) {
|
|
562
|
+
const reason = mapControllerError(e)
|
|
563
|
+
sessionTargets.delete(resolved.sessionId)
|
|
564
|
+
record.settled = true
|
|
565
|
+
task.status = 'error'
|
|
566
|
+
task.error = errText(e)
|
|
567
|
+
task.reason = reason
|
|
568
|
+
pushTask(task.taskId, 'error', { error: task.error, reason: reason, target: { kind: 'session', sessionId: resolved.sessionId } })
|
|
569
|
+
return { task, failed: { reason, message: task.error, candidates: [] } }
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
watchSessionTask(record)
|
|
573
|
+
pushTask(task.taskId, 'task-accepted', { target: { kind: 'session', sessionId: resolved.sessionId }, deliver, cwd: resolved.snippet })
|
|
574
|
+
return { task, failed: null }
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
const enqueue = async (instruction, incomingHistory, target, modelAlias, kind, deliver, notes) => {
|
|
369
578
|
if (incomingHistory !== undefined) syncHistory(incomingHistory)
|
|
370
|
-
|
|
371
|
-
|
|
579
|
+
if (target !== undefined && target.kind === 'session') {
|
|
580
|
+
// session 目标:不注册工作区、不建 owner、不建子代理,失败也不降级为新建。
|
|
581
|
+
const dispatched = await dispatchToSession(
|
|
582
|
+
instruction, target,
|
|
583
|
+
deliver === undefined ? 'queue' : deliver,
|
|
584
|
+
Array.isArray(notes) ? notes : [],
|
|
585
|
+
incomingHistory,
|
|
586
|
+
)
|
|
587
|
+
return dispatched.task
|
|
588
|
+
}
|
|
589
|
+
const cwd = normalizeCwd(target === undefined ? undefined : target.cwd)
|
|
590
|
+
const lane = normalizeLane(target === undefined ? undefined : target.lane)
|
|
372
591
|
registerWorkspace(cwd)
|
|
373
592
|
const bucket = bucketOf(cwd, lane)
|
|
374
593
|
const requestedModel = await resolveModel(modelAlias)
|
|
@@ -379,7 +598,7 @@ export async function apply(ctx) {
|
|
|
379
598
|
bucket.lastSeen = 0
|
|
380
599
|
bucket.seenCount = 0
|
|
381
600
|
}
|
|
382
|
-
const task = { taskId: uid(), kind, instruction, cwd, lane, bucketKey: bucket.key, status: 'accepted', time: Date.now(), result: undefined, error: undefined }
|
|
601
|
+
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 }
|
|
383
602
|
tasks.push(task)
|
|
384
603
|
if (tasks.length > 200) tasks.splice(0, tasks.length - 200)
|
|
385
604
|
try {
|
|
@@ -409,7 +628,7 @@ export async function apply(ctx) {
|
|
|
409
628
|
const cwd = normalizeCwd(rawCwd)
|
|
410
629
|
const bucket = reviewerBucketOf(cwd)
|
|
411
630
|
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')
|
|
412
|
-
const task = { taskId: uid(), kind: 'review', instruction, cwd, lane: '@review', bucketKey: bucket.key, status: 'accepted', time: Date.now(), result: undefined, error: undefined }
|
|
631
|
+
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 }
|
|
413
632
|
tasks.push(task)
|
|
414
633
|
if (tasks.length > 200) tasks.splice(0, tasks.length - 200)
|
|
415
634
|
try {
|
|
@@ -434,13 +653,7 @@ export async function apply(ctx) {
|
|
|
434
653
|
return task
|
|
435
654
|
}
|
|
436
655
|
|
|
437
|
-
|
|
438
|
-
if (event === undefined || event === null || event.type !== 'assistant/message') return ''
|
|
439
|
-
const data = event.data
|
|
440
|
-
const msg = data && data.message !== undefined ? data.message : data
|
|
441
|
-
const content = msg && msg.content !== undefined ? msg.content : data && data.content
|
|
442
|
-
return textOfBlocks(content)
|
|
443
|
-
}
|
|
656
|
+
// assistantTextOf / textOfBlocks 由 session-target.mjs 提供(同一套语义,可单测)。
|
|
444
657
|
const pollOne = async (bucket) => {
|
|
445
658
|
if (bucket.childId === null || sessionQuery === undefined) return
|
|
446
659
|
const hasPending = tasks.some((t) => (t.status === 'accepted' || t.status === 'running') && t.bucketKey === bucket.key)
|
|
@@ -470,11 +683,67 @@ export async function apply(ctx) {
|
|
|
470
683
|
}
|
|
471
684
|
}
|
|
472
685
|
}
|
|
686
|
+
// 会话任务的兜底:超时判定 + follow 不可用时的轮询降级。
|
|
687
|
+
const sweepSessionTargets = async () => {
|
|
688
|
+
for (const record of Array.from(sessionTargets.values())) {
|
|
689
|
+
if (record.settled) continue
|
|
690
|
+
const task = tasks.find((t) => t.taskId === record.taskId)
|
|
691
|
+
if (task === undefined) { settleSessionTask(record, 'cancelled', {}); continue }
|
|
692
|
+
if (task.status === 'cancelled') { record.settled = true; if (record.abort !== undefined) { try { record.abort.abort() } catch (e) { /* ignore */ } } sessionTargets.delete(record.sessionId); continue }
|
|
693
|
+
const now = Date.now()
|
|
694
|
+
if (record.watcher === 'poll' && sessionQuery !== undefined) {
|
|
695
|
+
try {
|
|
696
|
+
const snap = await sessionQuery.readSession(record.sessionId)
|
|
697
|
+
const events = snap !== undefined && snap !== null && Array.isArray(snap.events) ? snap.events : []
|
|
698
|
+
const extracted = extractNewAssistantText(events, record.baseline)
|
|
699
|
+
record.baseline = extracted.baseline
|
|
700
|
+
if (extracted.text.trim() !== '') {
|
|
701
|
+
record.total += extracted.text
|
|
702
|
+
record.activeSince = now
|
|
703
|
+
if (task.status === 'accepted') task.status = 'running'
|
|
704
|
+
pushTask(record.taskId, 'progress', { text: extracted.text, target: { kind: 'session', sessionId: record.sessionId } })
|
|
705
|
+
}
|
|
706
|
+
} catch (e) { /* 读不到就下一轮再试 */ }
|
|
707
|
+
}
|
|
708
|
+
if (!record.sawRunning && now - record.startedAt > SESSION_DISPATCH_TIMEOUT_MS) {
|
|
709
|
+
settleSessionTask(record, 'error', { error: 'the session never started a turn after the prompt was accepted (it may still be queued)', reason: REASONS.NOT_ACCEPTED })
|
|
710
|
+
continue
|
|
711
|
+
}
|
|
712
|
+
if (record.sawRunning && record.activeSince > 0 && now - record.activeSince > SESSION_QUIET_TIMEOUT_MS) {
|
|
713
|
+
finishSessionTask(record, 'quiet-timeout')
|
|
714
|
+
continue
|
|
715
|
+
}
|
|
716
|
+
if (now - record.startedAt > SESSION_TOTAL_TIMEOUT_MS) {
|
|
717
|
+
settleSessionTask(record, 'error', { error: 'session task exceeded its total budget', reason: REASONS.TIMEOUT })
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
// 完成信号:会话从 running 回到 idle 即这一轮结束(follow 只负责流式进度)。
|
|
723
|
+
if (sessionController !== undefined) {
|
|
724
|
+
ctx.effect(() => {
|
|
725
|
+
const off = ctx.on('api-session/status', (sessionId, running) => {
|
|
726
|
+
const record = sessionTargets.get(sessionId)
|
|
727
|
+
if (record === undefined || record.settled) return
|
|
728
|
+
const task = tasks.find((t) => t.taskId === record.taskId)
|
|
729
|
+
if (running === true) {
|
|
730
|
+
record.sawRunning = true
|
|
731
|
+
record.activeSince = Date.now()
|
|
732
|
+
if (task !== undefined && task.status === 'accepted') task.status = 'running'
|
|
733
|
+
return
|
|
734
|
+
}
|
|
735
|
+
if (running === false && record.sawRunning) finishSessionTask(record, 'stop')
|
|
736
|
+
})
|
|
737
|
+
return () => { if (typeof off === 'function') off() }
|
|
738
|
+
})
|
|
739
|
+
}
|
|
740
|
+
|
|
473
741
|
if (timer !== undefined) {
|
|
474
742
|
ctx.effect(() => timer.interval(() => {
|
|
475
743
|
void (async () => {
|
|
476
744
|
for (const bucket of buckets.values()) await pollOne(bucket)
|
|
477
745
|
for (const bucket of reviewerBuckets.values()) await pollOne(bucket)
|
|
746
|
+
await sweepSessionTargets()
|
|
478
747
|
})()
|
|
479
748
|
}, 2500))
|
|
480
749
|
}
|
|
@@ -517,8 +786,83 @@ export async function apply(ctx) {
|
|
|
517
786
|
if (parsed === null) return
|
|
518
787
|
const instruction = parsed !== null && typeof parsed === 'object' && typeof parsed.instruction === 'string' ? parsed.instruction : ''
|
|
519
788
|
if (instruction.trim() === '') { sendJson(res, 400, { error: 'missing instruction' }); return }
|
|
520
|
-
const
|
|
521
|
-
sendJson(res,
|
|
789
|
+
const classified = classifyTarget(parsed)
|
|
790
|
+
if (!classified.ok) { sendJson(res, 400, { error: classified.message, reason: classified.reason }); return }
|
|
791
|
+
const task = await enqueue(instruction, parsed.history, classified.target, parsed.model, 'task', classified.deliver, classified.notes)
|
|
792
|
+
if (task.status === 'error') {
|
|
793
|
+
sendJson(res, 400, {
|
|
794
|
+
error: task.error,
|
|
795
|
+
reason: task.reason,
|
|
796
|
+
...(Array.isArray(task.candidates) && task.candidates.length > 0 ? { candidates: task.candidates } : {}),
|
|
797
|
+
})
|
|
798
|
+
return
|
|
799
|
+
}
|
|
800
|
+
sendJson(res, 202, {
|
|
801
|
+
...taskResponse(task),
|
|
802
|
+
model: classified.target.kind === 'worker' ? (parsed.model || null) : null,
|
|
803
|
+
})
|
|
804
|
+
},
|
|
805
|
+
}))
|
|
806
|
+
|
|
807
|
+
// 只读"找到会话":不创建、不投递、不激活 Agent(search/附件/历史页都不激活)。
|
|
808
|
+
ctx.effect(() => webServer.register({
|
|
809
|
+
kind: 'exact',
|
|
810
|
+
path: '/api/dsh-bridge/sessions',
|
|
811
|
+
handler: async (req, res) => {
|
|
812
|
+
if (!isLoopback(req)) { res.writeHead(403); res.end('forbidden'); return }
|
|
813
|
+
if (req.method !== 'POST' && req.method !== 'GET') { res.writeHead(405); res.end('POST or GET only'); return }
|
|
814
|
+
let parsed = {}
|
|
815
|
+
if (req.method === 'POST') {
|
|
816
|
+
const body = await readJsonBody(req, res)
|
|
817
|
+
if (body === null) return
|
|
818
|
+
parsed = body
|
|
819
|
+
} else {
|
|
820
|
+
parsed = qsOf(req)
|
|
821
|
+
}
|
|
822
|
+
if (sessionController === undefined) {
|
|
823
|
+
sendJson(res, 503, { error: 'sessionController service is not mounted in this profile', reason: REASONS.CONTROLLER_UNAVAILABLE })
|
|
824
|
+
return
|
|
825
|
+
}
|
|
826
|
+
const query = typeof parsed.query === 'string' && parsed.query.trim() !== '' ? parsed.query.trim() : undefined
|
|
827
|
+
const sessionId = typeof parsed.sessionId === 'string' && parsed.sessionId.trim() !== '' ? parsed.sessionId.trim() : undefined
|
|
828
|
+
if (query === undefined && sessionId === undefined) {
|
|
829
|
+
sendJson(res, 400, { error: 'query or sessionId required', reason: REASONS.INVALID_TARGET })
|
|
830
|
+
return
|
|
831
|
+
}
|
|
832
|
+
const limit = Math.min(Math.max(Number(parsed.limit) || 10, 1), 50)
|
|
833
|
+
try {
|
|
834
|
+
if (sessionId !== undefined) {
|
|
835
|
+
const resolved = await resolveSessionTarget({ kind: 'session', sessionId })
|
|
836
|
+
if (!resolved.ok) {
|
|
837
|
+
sendJson(res, 404, { error: resolved.message, reason: resolved.reason, candidates: resolved.candidates })
|
|
838
|
+
return
|
|
839
|
+
}
|
|
840
|
+
sendJson(res, 200, { items: [{ sessionId: resolved.sessionId, ...(resolved.snippet === undefined ? {} : { snippet: resolved.snippet }), agentAvailable: true }], hasMore: false })
|
|
841
|
+
return
|
|
842
|
+
}
|
|
843
|
+
const hits = await sessionSearch(query)
|
|
844
|
+
const items = hits.slice(0, limit).map((h) => ({
|
|
845
|
+
sessionId: h.sessionId,
|
|
846
|
+
snippet: typeof h.snippet === 'string' ? h.snippet.slice(0, 200) : '',
|
|
847
|
+
}))
|
|
848
|
+
if (parsed.inspect === true) {
|
|
849
|
+
// 只对前 5 条探能力:逐条 stat 会很贵,这里保持有界。
|
|
850
|
+
for (const item of items.slice(0, 5)) {
|
|
851
|
+
try {
|
|
852
|
+
const r = await sessionController.resolveAgent(item.sessionId)
|
|
853
|
+
const failed = r !== undefined && r !== null && r.error !== undefined
|
|
854
|
+
item.agentAvailable = !failed
|
|
855
|
+
if (failed) item.problem = mapControllerError(r.error)
|
|
856
|
+
} catch (e) {
|
|
857
|
+
item.agentAvailable = false
|
|
858
|
+
item.problem = mapControllerError(e)
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
sendJson(res, 200, { items, hasMore: hits.length > items.length, total: hits.length })
|
|
863
|
+
} catch (e) {
|
|
864
|
+
sendJson(res, 502, { error: errText(e), reason: mapControllerError(e) })
|
|
865
|
+
}
|
|
522
866
|
},
|
|
523
867
|
}))
|
|
524
868
|
|
|
@@ -540,7 +884,7 @@ export async function apply(ctx) {
|
|
|
540
884
|
path: '/api/dsh-bridge/tasks',
|
|
541
885
|
handler: async (req, res) => {
|
|
542
886
|
if (!isLoopback(req)) { res.writeHead(403); res.end('forbidden'); return }
|
|
543
|
-
sendJson(res, 200, { tasks: tasks.slice(-50).map((t) => ({ taskId: t.taskId, kind: t.kind, cwd: t.cwd, lane: t.lane, status: t.status, time: t.time, instruction: t.instruction.slice(0, 200), error: t.error, fallback: t.fallback })) })
|
|
887
|
+
sendJson(res, 200, { tasks: tasks.slice(-50).map((t) => ({ taskId: t.taskId, kind: t.kind, cwd: t.cwd, lane: t.lane, target: t.target, deliver: t.deliver, status: t.status, time: t.time, instruction: t.instruction.slice(0, 200), error: t.error, reason: t.reason, fallback: t.fallback })) })
|
|
544
888
|
},
|
|
545
889
|
}))
|
|
546
890
|
|
|
@@ -556,6 +900,34 @@ export async function apply(ctx) {
|
|
|
556
900
|
const task = tasks.find((t) => t.taskId === taskId)
|
|
557
901
|
if (task === undefined) { sendJson(res, 404, { error: 'unknown taskId' }); return }
|
|
558
902
|
if (task.status !== 'accepted' && task.status !== 'running') { sendJson(res, 200, { taskId, cancelled: false, status: task.status }); return }
|
|
903
|
+
|
|
904
|
+
// session 目标动的是用户自己的对话:取消会打断他正在跑的回合,因此必须显式 force。
|
|
905
|
+
if (task.target !== undefined && task.target.kind === 'session') {
|
|
906
|
+
if (parsed.force !== true) {
|
|
907
|
+
sendJson(res, 409, {
|
|
908
|
+
taskId,
|
|
909
|
+
cancelled: false,
|
|
910
|
+
reason: REASONS.CANCEL_REFUSED,
|
|
911
|
+
error: 'cancelling a session-target task interrupts the user\'s own turn; pass force: true to do it deliberately',
|
|
912
|
+
})
|
|
913
|
+
return
|
|
914
|
+
}
|
|
915
|
+
let cancelled = false
|
|
916
|
+
try {
|
|
917
|
+
await sessionController.cancel({ sessionId: task.target.sessionId }, signalOf())
|
|
918
|
+
cancelled = true
|
|
919
|
+
} catch (e) {
|
|
920
|
+
console.error('[dsh-bridge] session cancel failed:', errText(e).slice(0, 200))
|
|
921
|
+
}
|
|
922
|
+
task.status = 'cancelled'
|
|
923
|
+
task.cancelReason = 'cancelled by request (force)'
|
|
924
|
+
const record = sessionTargets.get(task.target.sessionId)
|
|
925
|
+
if (record !== undefined) settleSessionTask(record, 'cancelled', {})
|
|
926
|
+
pushTask(taskId, 'cancelled', { target: { kind: 'session', sessionId: task.target.sessionId } })
|
|
927
|
+
sendJson(res, 200, { taskId, cancelled: true, interrupted: cancelled, target: task.target })
|
|
928
|
+
return
|
|
929
|
+
}
|
|
930
|
+
|
|
559
931
|
task.status = 'cancelled'
|
|
560
932
|
task.cancelReason = 'cancelled by request'
|
|
561
933
|
let interrupted = false
|
|
@@ -583,11 +955,25 @@ export async function apply(ctx) {
|
|
|
583
955
|
if (taskId !== undefined && taskId !== '') {
|
|
584
956
|
const task = tasks.find((t) => t.taskId === taskId)
|
|
585
957
|
if (task === undefined) { sendJson(res, 404, { error: 'unknown taskId' }); return }
|
|
586
|
-
|
|
958
|
+
const record = task.target !== undefined && task.target.kind === 'session' ? sessionTargets.get(task.target.sessionId) : undefined
|
|
959
|
+
sendJson(res, 200, {
|
|
960
|
+
taskId: task.taskId, status: task.status, kind: task.kind, cwd: task.cwd, lane: task.lane,
|
|
961
|
+
target: task.target, deliver: task.deliver, instruction: task.instruction.slice(0, 300),
|
|
962
|
+
result: task.result, error: task.error, reason: task.reason, fallback: task.fallback,
|
|
963
|
+
...(Array.isArray(task.candidates) && task.candidates.length > 0 ? { candidates: task.candidates } : {}),
|
|
964
|
+
...(record === undefined ? {} : { watcher: record.watcher, sawRunning: record.sawRunning }),
|
|
965
|
+
})
|
|
587
966
|
return
|
|
588
967
|
}
|
|
589
968
|
const pending = tasks.filter((t) => t.status === 'accepted' || t.status === 'running').length
|
|
590
|
-
sendJson(res, 200, {
|
|
969
|
+
sendJson(res, 200, {
|
|
970
|
+
pending,
|
|
971
|
+
lanes: Array.from(buckets.keys()),
|
|
972
|
+
reviewers: Array.from(reviewerBuckets.keys()),
|
|
973
|
+
sessions: Array.from(sessionTargets.keys()),
|
|
974
|
+
sessionController: sessionController !== undefined,
|
|
975
|
+
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 })),
|
|
976
|
+
})
|
|
591
977
|
},
|
|
592
978
|
}))
|
|
593
979
|
|
|
@@ -596,7 +982,7 @@ export async function apply(ctx) {
|
|
|
596
982
|
path: '/api/dsh-bridge/debug',
|
|
597
983
|
handler: async (req, res) => {
|
|
598
984
|
if (!isLoopback(req)) { res.writeHead(403); res.end('forbidden'); return }
|
|
599
|
-
const out = { pending: 0, historyCount: history.length, presets: [], providers: [], buckets: [], reviewers: [], models: knownModels, modelProvider, modelListError, workspaces: [] }
|
|
985
|
+
const out = { pending: 0, historyCount: history.length, presets: [], providers: [], buckets: [], reviewers: [], sessionController: sessionController !== undefined, sessionTargets: [], models: knownModels, modelProvider, modelListError, workspaces: [] }
|
|
600
986
|
out.pending = tasks.filter((t) => t.status === 'accepted' || t.status === 'running').length
|
|
601
987
|
try { out.providers = subagents.list() } catch (e) { out.providers = ['list failed: ' + errText(e)] }
|
|
602
988
|
if (agentPresets !== undefined) {
|
|
@@ -614,6 +1000,9 @@ export async function apply(ctx) {
|
|
|
614
1000
|
for (const bucket of reviewerBuckets.values()) {
|
|
615
1001
|
out.reviewers.push({ cwd: bucket.cwd, childId: bucket.childId })
|
|
616
1002
|
}
|
|
1003
|
+
for (const record of sessionTargets.values()) {
|
|
1004
|
+
out.sessionTargets.push({ sessionId: record.sessionId, taskId: record.taskId, deliver: record.deliver, watcher: record.watcher, sawRunning: record.sawRunning })
|
|
1005
|
+
}
|
|
617
1006
|
sendJson(res, 200, out)
|
|
618
1007
|
},
|
|
619
1008
|
}))
|
|
@@ -640,7 +1029,12 @@ export async function apply(ctx) {
|
|
|
640
1029
|
if (msg.type === 'ping') {
|
|
641
1030
|
try { socket.write(JSON.stringify({ type: 'pong', time: Date.now() }) + '\n') } catch (e) {}
|
|
642
1031
|
} else if (msg.type === 'instruction' && typeof msg.text === 'string' && msg.text.trim() !== '') {
|
|
643
|
-
|
|
1032
|
+
const classified = classifyTarget(msg)
|
|
1033
|
+
if (!classified.ok) {
|
|
1034
|
+
try { socket.write(JSON.stringify({ type: 'error', reason: classified.reason, error: classified.message, time: Date.now() }) + '\n') } catch (e) { /* drop */ }
|
|
1035
|
+
} else {
|
|
1036
|
+
void enqueue(msg.text, msg.history, classified.target, msg.model, 'task', classified.deliver, classified.notes)
|
|
1037
|
+
}
|
|
644
1038
|
} else if (msg.type === 'review') {
|
|
645
1039
|
void submitReview(msg.cwd, msg.diff, msg.focus, msg.history)
|
|
646
1040
|
}
|
|
@@ -658,26 +1052,35 @@ export async function apply(ctx) {
|
|
|
658
1052
|
// ---- 原生工具(宿主全局工具层)----
|
|
659
1053
|
ctx.effect(() => ctx.tools.register(defineTool({
|
|
660
1054
|
name: 'dsh_collab_send',
|
|
661
|
-
description: '向 Codex
|
|
1055
|
+
description: '向 Codex 协作频道发送一条编码指令。默认新建编码子代理在指定工作目录执行;给出 sessionId 或 sessionQuery 时改为投递进那一条已有会话(会话自带的对话与上下文)。指令用中文。',
|
|
662
1056
|
parameters: {
|
|
663
1057
|
instruction: { type: 'string', required: true, description: '要交给编码子代理执行的指令。' },
|
|
664
|
-
cwd: { type: 'string', description: '工作目录(默认 ' + DEFAULT_WORKSPACE + ')
|
|
1058
|
+
cwd: { type: 'string', description: '工作目录(默认 ' + DEFAULT_WORKSPACE + ')。与 sessionId/sessionQuery 互斥。' },
|
|
665
1059
|
lane: { type: 'string', description: '并行通道名(默认 main,同目录不同 lane 并行)。' },
|
|
666
1060
|
model: { type: 'string', description: '模型别名: default | fast | pro。' },
|
|
1061
|
+
sessionId: { type: 'string', description: '已有会话 id:把指令投递进这条会话,而不是新建。' },
|
|
1062
|
+
sessionQuery: { type: 'string', description: '按标题/内容搜索已有会话(唯一命中才生效,多条会返回候选)。' },
|
|
1063
|
+
deliver: { type: 'string', description: "投递方式: queue(默认,排队) 或 steer(插入当前回合)。" },
|
|
667
1064
|
wait: { type: 'boolean', description: '为 true 时等待结果(最长 15 分钟)。' },
|
|
668
1065
|
},
|
|
669
1066
|
output: { schema: { type: 'json' }, render: (_args, value) => [{ type: 'text', text: JSON.stringify(value, null, 2) }] },
|
|
670
1067
|
async execute(args) {
|
|
671
|
-
const
|
|
1068
|
+
const classified = classifyTarget(args)
|
|
1069
|
+
if (!classified.ok) return { ok: false, reason: classified.reason, error: classified.message }
|
|
1070
|
+
const task = await enqueue(args.instruction, undefined, classified.target, args.model, 'task', classified.deliver, classified.notes)
|
|
1071
|
+
if (task.status === 'error') {
|
|
1072
|
+
return { ok: false, taskId: task.taskId, reason: task.reason, error: task.error, ...(task.candidates === undefined ? {} : { candidates: task.candidates }) }
|
|
1073
|
+
}
|
|
1074
|
+
const brief = { taskId: task.taskId, status: task.status, ...taskResponse(task) }
|
|
672
1075
|
if (args.wait !== true || timer === undefined) {
|
|
673
|
-
return
|
|
1076
|
+
return brief
|
|
674
1077
|
}
|
|
675
1078
|
const t0 = Date.now()
|
|
676
1079
|
while (task.status === 'accepted' || task.status === 'running') {
|
|
677
|
-
if (Date.now() - t0 > 900000) return {
|
|
1080
|
+
if (Date.now() - t0 > 900000) return { ...brief, status: task.status, result: task.result, note: 'wait 超时' }
|
|
678
1081
|
await timer.timeout(2500)
|
|
679
1082
|
}
|
|
680
|
-
return {
|
|
1083
|
+
return { ...brief, status: task.status, result: task.result, error: task.error, reason: task.reason }
|
|
681
1084
|
},
|
|
682
1085
|
})))
|
|
683
1086
|
|
|
@@ -714,7 +1117,12 @@ export async function apply(ctx) {
|
|
|
714
1117
|
for (const bucket of reviewerBuckets.values()) {
|
|
715
1118
|
if (bucket.handle !== null) { void bucket.handle.dispose() }
|
|
716
1119
|
}
|
|
1120
|
+
for (const record of sessionTargets.values()) {
|
|
1121
|
+
record.settled = true
|
|
1122
|
+
if (record.abort !== undefined) { try { record.abort.abort() } catch (e) { /* ignore */ } }
|
|
1123
|
+
}
|
|
1124
|
+
sessionTargets.clear()
|
|
717
1125
|
})
|
|
718
1126
|
|
|
719
|
-
console.log('[dsh-bridge] persistent gateway ready (host composition): task · review · tasks · cancel · status · debug · WS')
|
|
1127
|
+
console.log('[dsh-bridge] persistent gateway ready (host composition): task(new|session) · sessions · review · tasks · cancel · status · debug · WS | sessionController=' + (sessionController !== undefined ? 'yes' : 'no'))
|
|
720
1128
|
}
|