dsh-agent-board 1.8.2 → 1.8.3
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 +2 -2
- package/lib/dispatch.mjs +78 -7
- package/package.json +1 -1
- package/test/spawn-activation.test.mjs +186 -0
package/README.md
CHANGED
|
@@ -113,10 +113,10 @@ dsh plugin --profile web remove dsh-agent-board
|
|
|
113
113
|
|
|
114
114
|
## 功能总览
|
|
115
115
|
|
|
116
|
-
**核心能力一页速览**(`★` 为 v1.7.5–1.8.
|
|
116
|
+
**核心能力一页速览**(`★` 为 v1.7.5–1.8.3 新栈):
|
|
117
117
|
|
|
118
118
|
- **看板**:六列状态流、拖拽 / 多选批量 / 文本·优先级·标签筛选、归档留档、会话隔离
|
|
119
|
-
- **派发**:一次性 Worker / 独立 Verifier、依赖调度(DFS 环检测)、touches 文件级排他、可续跑 Worker、Team 托管、**spawn 落闸锁内重验**(spawnGateOk:claim 占位与落闸决策读同一把锁的同一份快照,terminate/dependsOn 后补 落在 spawn 在途窗口即弃权——竞态与后补依赖双双根治)、**高优干预分态**(intervene 无活跃 run 时区分「续跑窗口,干预随续跑注入」与「真无 run」,返回体带 state 字段)、**建卡派发预警**(将自动派发时 lintWarnings 瞬态提示「约 1s 内被领取,想占位请 draft:true
|
|
119
|
+
- **派发**:一次性 Worker / 独立 Verifier、依赖调度(DFS 环检测)、touches 文件级排他、可续跑 Worker、Team 托管、**spawn 落闸锁内重验**(spawnGateOk:claim 占位与落闸决策读同一把锁的同一份快照,terminate/dependsOn 后补 落在 spawn 在途窗口即弃权——竞态与后补依赖双双根治)、**高优干预分态**(intervene 无活跃 run 时区分「续跑窗口,干预随续跑注入」与「真无 run」,返回体带 state 字段)、**建卡派发预警**(将自动派发时 lintWarnings 瞬态提示「约 1s 内被领取,想占位请 draft:true」)、**宿主 spawn API 双兼容**(v0.2.1 宿主把 `subagents.start/startContinuable` 统一重构为 `startActivation(spec)`——能力探测自动选路:新契约按 `delivery` 语义分发(continuable=`parent` 结算留言 / 一次性=`caller` 只回 result),Activation 适配旧 run 形状下游零改动;老宿主逐字走旧双路径)+ **continuable 结算兜底臂**(`activation.result` 在子会话被宿主拆除时落定即接失败结算——rate-limit/模型崩溃的死子不再挂到硬超时)
|
|
120
120
|
- **验收治理** `★`:硬性验收脚本独立复跑、驳回全量带回重派、Verifier 自测指南(userTest)、风险队列「待你过目」、验收时序防护(Verifier 在场时主窗口抢批需显式 `force:true`;有活跃 run 的卡禁止归档;Verifier 迟到结论落 `lateVerdict` 留痕+直投告警)、**迟到 verdict 三保险**(①入参校验:`verdict` 非 approved/rejected 拒收并返回重调指引——漏带字段不再被当驳回落账;②通知分态:approved 迟到=「✅ 迟到复核·全过追认」notice 级,🚨「迟到驳回」只留给真 rejected;③指纹去重:同 taskId+by+verdict 只投一次,重复落账零重复通知)
|
|
121
121
|
- **度量** `★`:Token 消耗(总量 / 有效 / 缓存读,按模型·按任务·按日)+ 记分卡(模型表现 / 质量趋势 / 驳回聚类)
|
|
122
122
|
- **可运维性** `★`:运行时健康自检(派发冻结 / 结算停滞告警)、异常驱动审视(风险队列 / 质量异动告警 / 批次摘要)、起草 lint、数据安全(原子写盘 / 腐坏隔离 / 自动 salvage)、**僵尸 run 留档清扫**(host 重启后「落定卡上的 running 无 endedAt 留档」在派发周期自动关账 `stale-closed` + 清认领位,归档门禁不再被幻影 run 永久拦截)、**子会话生命周期收口**(一次性 run 落定/迟到 verdict 落账后主动 interrupt 子会话——「结算≠停止」空转根治;continuable 收紧为「续跑位真在本 child 手上」才豁免,残影 Worker 无处藏身)、**terminate 三保险**(no-rec 也按认领位盲杀补刀 + interrupt 失败 err 级可见化 + `hold:true` 原子「终止+转手动」)、**配置审计**(set-board-config 成功写入落板级 configLog {key,from,to,actor,at} 截尾 50 条;模型下拉两段式选中≠保存)
|
package/lib/dispatch.mjs
CHANGED
|
@@ -228,6 +228,35 @@ export function createDispatch(ctx, state, deps) {
|
|
|
228
228
|
} catch (_) {}
|
|
229
229
|
}
|
|
230
230
|
|
|
231
|
+
// ===== 新旧宿主 spawn 双兼容(DSH v0.2.1 宿主 API 重构实证 2026-10-24)=====
|
|
232
|
+
// v0.2.1 把 subagents.start(provider,req) 与 subagents.startContinuable(spec) 统一替换为
|
|
233
|
+
// subagents.startActivation(spec):
|
|
234
|
+
// spec = { provider, label, childId?, request: Omit<SubagentStartRequest,'label'|'signal'>, signal,
|
|
235
|
+
// delivery: 'parent'|'caller' } → 返回 SubagentActivation { childId, messageId?, result, dispose() }
|
|
236
|
+
// delivery 语义:'parent'=子会话结算时向父会话投递结果消息(continuable 的「完成留言」旧行为);
|
|
237
|
+
// 'caller'=只回 result promise、不惊动父会话(一次性 run 的旧行为)。
|
|
238
|
+
// 能力探测一次性缓存:同一 host 生命周期内契约不变;老宿主无 startActivation → 走旧路径。
|
|
239
|
+
var _hasActivationApi = null
|
|
240
|
+
function hasActivationApi(subagents) {
|
|
241
|
+
if (_hasActivationApi === null) _hasActivationApi = !!(subagents && typeof subagents.startActivation === 'function')
|
|
242
|
+
return _hasActivationApi
|
|
243
|
+
}
|
|
244
|
+
// Activation → 旧 SubagentRun 同形适配 {id,result,dispose}:一次性路径下游消费面(settleRun/
|
|
245
|
+
// armTimeouts/资源清理)零改动。
|
|
246
|
+
function activationAsRun(act) {
|
|
247
|
+
return { id: act.childId, result: act.result, dispose: function () { return act.dispose() } }
|
|
248
|
+
}
|
|
249
|
+
// 新契约 request 组装(Omit<label|signal>):只带 prompt/parent/agentOptions/cwd。
|
|
250
|
+
// persona/toolFilter 不在新契约面(v0.2.1 将其收进 agentPreset 体系——spawn-in-process 源码实证
|
|
251
|
+
// 「The selected preset owns persona and tool composition」),新路径不携带;
|
|
252
|
+
// 老路径仍挂在 req 上由旧 provider 消费,两条路径各自完整。
|
|
253
|
+
function activationRequestOf(req) {
|
|
254
|
+
var r = { prompt: req.prompt, parent: req.parent }
|
|
255
|
+
if (req.agentOptions) r.agentOptions = req.agentOptions
|
|
256
|
+
if (req.cwd) r.cwd = req.cwd
|
|
257
|
+
return r
|
|
258
|
+
}
|
|
259
|
+
|
|
231
260
|
async function spawnOneShot(sid, t, role) {
|
|
232
261
|
var subagents = ctx.subagents; if (!subagents) return null
|
|
233
262
|
// 兜底保留(真异常时有用):poolCycle 入口已有 root 存活早闸门,正常派发路径不会到达这里;
|
|
@@ -285,13 +314,27 @@ export function createDispatch(ctx, state, deps) {
|
|
|
285
314
|
var useContinuable = role === 'worker' && c.workerContinuable !== false
|
|
286
315
|
var run
|
|
287
316
|
var childId = ''
|
|
317
|
+
var actForSettle = null // 新契约 activation 留存(continuable 结算兜底臂用,见 rec 创建后)
|
|
288
318
|
try {
|
|
289
319
|
if (useContinuable) {
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
320
|
+
if (hasActivationApi(subagents)) {
|
|
321
|
+
// 新契约:continuable 走 startActivation delivery='parent'(结算留言父会话,旧 startContinuable 行为)
|
|
322
|
+
var act0 = await subagents.startActivation({ provider: providerName, label: req.label, request: activationRequestOf(req), signal: req.signal, delivery: 'parent' })
|
|
323
|
+
actForSettle = act0
|
|
324
|
+
childId = String(act0 && act0.childId || '')
|
|
325
|
+
if (!childId) throw new Error('startActivation 未返回 childId')
|
|
326
|
+
} else {
|
|
327
|
+
var cs = await subagents.startContinuable({ provider: providerName, label: req.label, request: req, signal: req.signal })
|
|
328
|
+
childId = String(cs && cs.childId || '')
|
|
329
|
+
if (!childId) throw new Error('startContinuable 未返回 childId')
|
|
330
|
+
}
|
|
293
331
|
} else {
|
|
294
|
-
|
|
332
|
+
if (hasActivationApi(subagents)) {
|
|
333
|
+
// 新契约:一次性走 startActivation delivery='caller'(只回 result,不惊动父会话),适配成旧 run 形状
|
|
334
|
+
run = activationAsRun(await subagents.startActivation({ provider: providerName, label: req.label, request: activationRequestOf(req), signal: req.signal, delivery: 'caller' }))
|
|
335
|
+
} else {
|
|
336
|
+
run = await subagents.start(providerName, req)
|
|
337
|
+
}
|
|
295
338
|
}
|
|
296
339
|
} catch (e) {
|
|
297
340
|
// 外挂参数回退(既有模型覆盖通道,task-muy3gm03 起并入 verifier 加餐 persona/toolFilter):
|
|
@@ -302,9 +345,18 @@ export function createDispatch(ctx, state, deps) {
|
|
|
302
345
|
delete req.agentOptions; delete req.persona; delete req.toolFilter
|
|
303
346
|
try {
|
|
304
347
|
if (useContinuable) {
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
348
|
+
if (hasActivationApi(subagents)) {
|
|
349
|
+
var act1 = await subagents.startActivation({ provider: providerName, label: req.label, request: activationRequestOf(req), signal: req.signal, delivery: 'parent' })
|
|
350
|
+
actForSettle = act1
|
|
351
|
+
childId = String(act1 && act1.childId || '')
|
|
352
|
+
if (!childId) throw new Error('startActivation 未返回 childId')
|
|
353
|
+
} else {
|
|
354
|
+
var cs2 = await subagents.startContinuable({ provider: providerName, label: req.label, request: req, signal: req.signal })
|
|
355
|
+
childId = String(cs2 && cs2.childId || '')
|
|
356
|
+
if (!childId) throw new Error('startContinuable 未返回 childId')
|
|
357
|
+
}
|
|
358
|
+
} else if (hasActivationApi(subagents)) {
|
|
359
|
+
run = activationAsRun(await subagents.startActivation({ provider: providerName, label: req.label, request: activationRequestOf(req), signal: req.signal, delivery: 'caller' }))
|
|
308
360
|
} else run = await subagents.start(providerName, req)
|
|
309
361
|
} catch (e2) { console.error('[task-board] spawn ' + role + ' failed:', String(e2)); return null }
|
|
310
362
|
} else { console.error('[task-board] spawn ' + role + ' failed:', String(e)); return null }
|
|
@@ -316,6 +368,25 @@ export function createDispatch(ctx, state, deps) {
|
|
|
316
368
|
var rec = useContinuable
|
|
317
369
|
? { id: childId, childId: childId, continuable: true, ran: false, run: null, role: role, taskId: t.id, startedAt: Date.now(), model: modelOverride, settled: false }
|
|
318
370
|
: { id: String(run.id), run: run, role: role, taskId: t.id, startedAt: Date.now(), model: modelOverride, settled: false }
|
|
371
|
+
// ===== 新宿主 continuable 结算兜底臂(v0.2.1 实证 2026-10-24:activation.result 只认拆除落定)=====
|
|
372
|
+
// 原形:rate-limit 子会话 turn/end error → 管理器拆除 activation → result 落定——
|
|
373
|
+
// 但 agent/status 事件面在 v0.2.1 的 Scoped 事件体系下对插件全局订阅不再可达(子会话死亡 8 分钟零结算实证),
|
|
374
|
+
// rec 挂 running 直到 120 分钟硬超时。activation.result 恰好在「子会话被拆除」这一刻落定
|
|
375
|
+
// (正常完成 parked-idle 不落定、不误伤续跑)——把它接上就是失败/拆除场景的零事件依赖结算通道。
|
|
376
|
+
// rec.settled 首胜旗与事件/超时/上报三通道共用:先到先结,后到被挡。
|
|
377
|
+
if (useContinuable && actForSettle && actForSettle.result && typeof actForSettle.result.then === 'function') {
|
|
378
|
+
actForSettle.result.then(function (res) {
|
|
379
|
+
if (rec.settled) return
|
|
380
|
+
rec.settled = true // 先立旗:与事件/超时臂同一纪律
|
|
381
|
+
var stopReason = (res && res.stopReason) || 'error'
|
|
382
|
+
var errText = '子会话被宿主拆除(stopReason: ' + stopReason + (res && res.diagnostic ? ',' + String(res.diagnostic).slice(0, 120) : '') + ')'
|
|
383
|
+
settleWorker(sid, rec, '', true, errText).catch(function (e) { console.error('[task-board] activation 拆除结算失败 (task ' + rec.taskId + '):', String(e)) })
|
|
384
|
+
}, function (err) {
|
|
385
|
+
if (rec.settled) return
|
|
386
|
+
rec.settled = true
|
|
387
|
+
settleWorker(sid, rec, '', true, '子会话 activation 异常:' + String(err && err.message || err).slice(0, 200)).catch(function (e) { console.error('[task-board] activation 异常结算失败 (task ' + rec.taskId + '):', String(e)) })
|
|
388
|
+
})
|
|
389
|
+
}
|
|
319
390
|
// ⚠️ spawn 返回 → 登记 rec 必须**零 await**(既有纪律):continuable 子会话的 running→idle 事件
|
|
320
391
|
// 随时可能到达,事件通道必须马上能在活跃表里看到它——否则 idle 被 rec.ran 守卫挡下,结算干等硬超时。
|
|
321
392
|
runsFor(sid)[t.id] = rec
|
package/package.json
CHANGED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
// ===== DSH v0.2.1 宿主 startActivation 双兼容路径专测(2026-10-24 宿主 API 重构实证)=====
|
|
2
|
+
// 背景:v0.2.1 宿主把 subagents.start(provider,req) 与 subagents.startContinuable(spec)
|
|
3
|
+
// 统一替换为 subagents.startActivation(spec{provider,label,request,signal,delivery})。
|
|
4
|
+
// 本文件钉死新路径的四个契约面:
|
|
5
|
+
// ① worker(continuable)→ delivery='parent' + request 剥 label/signal + childId 透出
|
|
6
|
+
// ② verifier(一次性)→ delivery='caller' + Activation 适配成旧 run 形状 {id,result,dispose}
|
|
7
|
+
// ③ 新路径不携带 persona/toolFilter(v0.2.1 已收进 agentPreset 体系)
|
|
8
|
+
// ④ agentOptions 传递 + UNKNOWN_MODEL 首次失败 → 剥覆盖重试(模型回退语义不回归)
|
|
9
|
+
// ⑤ 老宿主桩(无 startActivation)仍走 start/startContinuable(双路径判别直证)
|
|
10
|
+
import test from 'node:test'
|
|
11
|
+
import assert from 'node:assert/strict'
|
|
12
|
+
import { createDispatch } from '../lib/dispatch.mjs'
|
|
13
|
+
|
|
14
|
+
var SID = 'session-test-0000-0000-000000000000'
|
|
15
|
+
|
|
16
|
+
function mkTask(over) {
|
|
17
|
+
return Object.assign({ id: 't1', title: 'T', description: '', status: 'pending', priority: 'medium', tags: [], parentId: null, assignMode: 'auto', assignee: null, context: { instructions: '' }, acceptance: '', dependsOn: [], pipeline: 'full', claimedBy: null, claimedAt: null, createdAt: '2026-01-01T00:00:00Z', history: [], messages: [] }, over || {})
|
|
18
|
+
}
|
|
19
|
+
function mkBoard(tasks) { return { version: 11, ownerSession: 's1', boardMode: 'auto', tasks: tasks || [] } }
|
|
20
|
+
|
|
21
|
+
function mkDispatch(board, ctxOver) {
|
|
22
|
+
var dispatch = createDispatch(
|
|
23
|
+
Object.assign({ fs: {}, effect: function () {}, get: function () { return null } }, ctxOver || {}),
|
|
24
|
+
{ knownSessions: {}, dispatchedEver: {}, badModels: {}, teamModeCache: {}, activeRuns: {} },
|
|
25
|
+
{
|
|
26
|
+
rt: async () => board,
|
|
27
|
+
wt: async () => {},
|
|
28
|
+
mutateLocked: async (sid, fn) => fn(board),
|
|
29
|
+
kickCycle: () => {},
|
|
30
|
+
rootForSession: () => ({ id: SID }),
|
|
31
|
+
sessionCwd: () => '', withTimeout: (p) => p, runsFor: () => ({}), feedbackOn: () => true,
|
|
32
|
+
pushSysNote: () => {}, maybeNotify: () => {}, notifyTaskDone: () => {},
|
|
33
|
+
})
|
|
34
|
+
return dispatch
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// 新宿主桩:startActivation 记录 spec,返回可控 Activation
|
|
38
|
+
function mkActivationSubagents(calls, opts) {
|
|
39
|
+
opts = opts || {}
|
|
40
|
+
return {
|
|
41
|
+
list: function () { return ['p1'] },
|
|
42
|
+
getProvider: function () { return { inheritsParentContext: false } },
|
|
43
|
+
startActivation: async function (spec) {
|
|
44
|
+
calls.push(spec)
|
|
45
|
+
if (opts.failFirst) { opts.failFirst = false; throw new Error('UNKNOWN_MODEL') }
|
|
46
|
+
return {
|
|
47
|
+
childId: 'child-' + calls.length,
|
|
48
|
+
messageId: 'msg-' + calls.length,
|
|
49
|
+
result: new Promise(function () {}),
|
|
50
|
+
dispose: async function () { (opts.disposed = opts.disposed || []).push(spec.label) },
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
// 新宿主桩刻意**不带** start/startContinuable——若代码误走旧路径必然 TypeError 炸出
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
test('startActivation①:worker 走 delivery=parent + request 剥 label/signal + childId 透出', async () => {
|
|
58
|
+
var board = mkBoard([mkTask({ status: 'in-progress', claimedBy: 'spawn-pending' })]) // 占位态:worker 落闸(spawnGateOk)需要
|
|
59
|
+
var calls = []
|
|
60
|
+
var dispatch = mkDispatch(board, { subagents: mkActivationSubagents(calls) })
|
|
61
|
+
var t = board.tasks[0]
|
|
62
|
+
var sr = await dispatch.spawnOneShot(SID, t, 'worker')
|
|
63
|
+
assert.ok(sr, 'spawn 成功返回 rec')
|
|
64
|
+
assert.equal(calls.length, 1)
|
|
65
|
+
var spec = calls[0]
|
|
66
|
+
assert.equal(spec.delivery, 'parent', 'continuable 必须 parent 投递(结算留言旧行为)')
|
|
67
|
+
assert.equal(spec.label, 'worker:t1')
|
|
68
|
+
assert.equal(spec.provider, 'p1')
|
|
69
|
+
assert.ok(spec.request, 'request 存在')
|
|
70
|
+
assert.equal(spec.request.label, undefined, 'request 不携带 label(新契约 Omit)')
|
|
71
|
+
assert.equal(spec.request.signal, undefined, 'request 不携带 signal(新契约 Omit)')
|
|
72
|
+
assert.ok(Array.isArray(spec.request.prompt), 'prompt 透传')
|
|
73
|
+
assert.ok(spec.signal, 'signal 在 spec 顶层')
|
|
74
|
+
assert.equal(String(sr.id), 'child-1', 'rec.id = activation childId')
|
|
75
|
+
assert.equal(sr.continuable, true, 'worker 缺省 continuable')
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
test('startActivation②:verifier 走 delivery=caller + Activation 适配旧 run 形状 {id,result,dispose}', async () => {
|
|
79
|
+
var board = mkBoard([mkTask()])
|
|
80
|
+
var opts = {}
|
|
81
|
+
var calls = []
|
|
82
|
+
var subagents = mkActivationSubagents(calls, opts)
|
|
83
|
+
var dispatch = mkDispatch(board, { subagents: subagents })
|
|
84
|
+
var sr = await dispatch.spawnOneShot(SID, board.tasks[0], 'verifier')
|
|
85
|
+
assert.ok(sr, 'verifier spawn 成功')
|
|
86
|
+
assert.equal(calls.length, 1)
|
|
87
|
+
assert.equal(calls[0].delivery, 'caller', '一次性必须 caller 投递(不惊动父会话)')
|
|
88
|
+
var run = sr.run
|
|
89
|
+
assert.equal(String(run.id), 'child-1', '适配 run.id = activation childId')
|
|
90
|
+
assert.ok(run.result instanceof Promise, '适配 run.result 是 Promise')
|
|
91
|
+
assert.equal(typeof run.dispose, 'function', '适配 run.dispose 存在')
|
|
92
|
+
await run.dispose()
|
|
93
|
+
assert.deepEqual(opts.disposed, ['verifier:t1'], 'dispose 透传到 activation.dispose')
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
test('startActivation③:verifier 加餐 persona/toolFilter 不进新契约 request', async () => {
|
|
97
|
+
var board = mkBoard([mkTask()])
|
|
98
|
+
var calls = []
|
|
99
|
+
var dispatch = mkDispatch(board, { subagents: mkActivationSubagents(calls) })
|
|
100
|
+
await dispatch.spawnOneShot(SID, board.tasks[0], 'verifier')
|
|
101
|
+
var spec = calls[0]
|
|
102
|
+
assert.equal(spec.request.persona, undefined, 'persona 不在新契约面(v0.2.1 收进 agentPreset)')
|
|
103
|
+
assert.equal(spec.request.toolFilter, undefined, 'toolFilter 不在新契约面')
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
test('startActivation④:agentOptions 传递 + UNKNOWN_MODEL 首败 → 剥覆盖重试(模型回退不回归)', async () => {
|
|
107
|
+
var board = mkBoard([mkTask({ status: 'in-progress', claimedBy: 'spawn-pending' })])
|
|
108
|
+
board.workerModel = 'gsmoma/kimi/kimi-k3'
|
|
109
|
+
var opts = { failFirst: true }
|
|
110
|
+
var calls = []
|
|
111
|
+
var origErr = console.error; console.error = function () {} // 压掉回退日志
|
|
112
|
+
try {
|
|
113
|
+
var dispatch = mkDispatch(board, { subagents: mkActivationSubagents(calls, opts) })
|
|
114
|
+
var sr = await dispatch.spawnOneShot(SID, board.tasks[0], 'worker')
|
|
115
|
+
assert.ok(sr, '回退后 spawn 成功')
|
|
116
|
+
assert.equal(calls.length, 2, '首败后重试一次')
|
|
117
|
+
var first = calls[0].request.agentOptions
|
|
118
|
+
assert.deepEqual(first, { provider: 'gsmoma', model: 'kimi/kimi-k3' }, '复合 id 拆 provider/model 透传')
|
|
119
|
+
assert.equal(calls[1].request.agentOptions, undefined, '重试剥掉模型覆盖')
|
|
120
|
+
} finally { console.error = origErr }
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
test('startActivation⑤:老宿主桩(无 startActivation)仍走 start/startContinuable(双路径判别)', async () => {
|
|
124
|
+
var board = mkBoard([mkTask()])
|
|
125
|
+
var used = []
|
|
126
|
+
var oldSubagents = {
|
|
127
|
+
list: function () { return ['p1'] },
|
|
128
|
+
getProvider: function () { return { inheritsParentContext: false } },
|
|
129
|
+
start: async function () { used.push('start'); return { id: 'run-1', result: new Promise(function () {}), dispose: async function () {} } },
|
|
130
|
+
startContinuable: async function () { used.push('startContinuable'); return { childId: 'child-old', messageId: 'm1' } },
|
|
131
|
+
}
|
|
132
|
+
var dispatch = mkDispatch(board, { subagents: oldSubagents })
|
|
133
|
+
await dispatch.spawnOneShot(SID, board.tasks[0], 'worker')
|
|
134
|
+
await dispatch.spawnOneShot(SID, board.tasks[0], 'verifier')
|
|
135
|
+
assert.deepEqual(used, ['startContinuable', 'start'], '老宿主逐字走旧双路径')
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
test('startActivation⑤b:continuable 兜底臂——activation.result 拆除落定 → settleWorker 失败结算(rate-limit 原形)', async () => {
|
|
139
|
+
var board = mkBoard([mkTask({ status: 'in-progress', claimedBy: 'spawn-pending' })])
|
|
140
|
+
var settle = null
|
|
141
|
+
var actResult = new Promise(function (res) { settle = res })
|
|
142
|
+
var subagents = {
|
|
143
|
+
list: function () { return ['p1'] },
|
|
144
|
+
getProvider: function () { return { inheritsParentContext: false } },
|
|
145
|
+
startActivation: async function () {
|
|
146
|
+
return { childId: 'child-1', messageId: 'm1', result: actResult, dispose: async function () {} }
|
|
147
|
+
},
|
|
148
|
+
interrupt: function () {},
|
|
149
|
+
}
|
|
150
|
+
var dispatch = createDispatch(
|
|
151
|
+
{ fs: {}, effect: function () {}, get: function () { return null }, subagents: subagents },
|
|
152
|
+
{ knownSessions: {}, dispatchedEver: {}, badModels: {}, teamModeCache: {}, activeRuns: {} },
|
|
153
|
+
{
|
|
154
|
+
rt: async () => board,
|
|
155
|
+
wt: async () => {},
|
|
156
|
+
mutateLocked: async (sid, fn) => fn(board),
|
|
157
|
+
kickCycle: () => {},
|
|
158
|
+
rootForSession: () => ({ id: SID }),
|
|
159
|
+
sessionCwd: () => '', withTimeout: (p) => p, runsFor: () => ({}), feedbackOn: () => true,
|
|
160
|
+
pushSysNote: () => {}, maybeNotify: () => {}, notifyTaskDone: () => {},
|
|
161
|
+
})
|
|
162
|
+
var sr = await dispatch.spawnOneShot(SID, board.tasks[0], 'worker')
|
|
163
|
+
assert.ok(sr, 'spawn 成功')
|
|
164
|
+
// 模拟宿主拆除(rate-limit turn/end error):result 以 stopReason=error 落定
|
|
165
|
+
settle({ output: [], stopReason: 'error', diagnostic: '429 RATE_LIMIT' })
|
|
166
|
+
await new Promise(function (r) { setTimeout(r, 30) })
|
|
167
|
+
var t = board.tasks[0]
|
|
168
|
+
assert.ok((t.retryCount || 0) >= 1, '失败结算落 retryCount(settleWorker 失败臂实证)')
|
|
169
|
+
assert.ok(t.lastError && String(t.lastError).indexOf('拆除') >= 0, 'lastError 记拆除原因')
|
|
170
|
+
assert.equal(t.status, 'pending', '失败回收重新排队(retryCount<3 回 pending)')
|
|
171
|
+
// 幂等:同 rec 再落定不再二次结算
|
|
172
|
+
var rc = t.retryCount
|
|
173
|
+
settle({ output: [], stopReason: 'error' })
|
|
174
|
+
await new Promise(function (r) { setTimeout(r, 30) })
|
|
175
|
+
assert.equal(t.retryCount, rc, 'settled 首胜旗挡重复落定')
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
test('startActivation⑥:新契约路径在源码层接线(startActivation 调用点 + 双 delivery 常量)', async () => {
|
|
179
|
+
var { readFileSync } = await import('node:fs')
|
|
180
|
+
var src = readFileSync(new URL('../lib/dispatch.mjs', import.meta.url), 'utf8')
|
|
181
|
+
assert.match(src, /subagents\.startActivation\(\{ provider: providerName, label: req\.label, request: activationRequestOf\(req\), signal: req\.signal, delivery: 'parent' \}\)/)
|
|
182
|
+
assert.match(src, /delivery: 'caller' \}\)\)/)
|
|
183
|
+
assert.match(src, /function hasActivationApi\(subagents\)/)
|
|
184
|
+
assert.match(src, /function activationAsRun\(act\)/)
|
|
185
|
+
assert.match(src, /function activationRequestOf\(req\)/)
|
|
186
|
+
})
|