dsh-task-dispatch-table 0.0.0 → 0.1.1

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.
Files changed (48) hide show
  1. package/README.md +101 -31
  2. package/README.zh.md +119 -0
  3. package/assets/icon-scheduler.svg +1 -0
  4. package/cordis.patch.yml +9 -0
  5. package/dist/attachment-allowlist.d.ts +24 -0
  6. package/dist/attachment-allowlist.js +41 -0
  7. package/dist/calendar-plan.d.ts +55 -0
  8. package/dist/calendar-plan.js +104 -0
  9. package/dist/client.js +72655 -0
  10. package/dist/client.js.map +1 -0
  11. package/dist/config.d.ts +79 -0
  12. package/dist/config.js +80 -0
  13. package/dist/deps.d.ts +25 -0
  14. package/dist/deps.js +54 -0
  15. package/dist/dispatch.d.ts +133 -0
  16. package/dist/dispatch.js +509 -0
  17. package/dist/event-bus.d.ts +16 -0
  18. package/dist/event-bus.js +67 -0
  19. package/dist/event-catalog.d.ts +42 -0
  20. package/dist/event-catalog.js +60 -0
  21. package/dist/host.d.ts +299 -0
  22. package/dist/host.js +1 -0
  23. package/dist/index.d.ts +25 -0
  24. package/dist/index.js +0 -0
  25. package/dist/receipt.d.ts +38 -0
  26. package/dist/receipt.js +236 -0
  27. package/dist/reconcile.d.ts +105 -0
  28. package/dist/reconcile.js +641 -0
  29. package/dist/runtime-index.d.ts +102 -0
  30. package/dist/runtime-index.js +340 -0
  31. package/dist/schedule-next.d.ts +44 -0
  32. package/dist/schedule-next.js +216 -0
  33. package/dist/scheduler.d.ts +82 -0
  34. package/dist/scheduler.js +639 -0
  35. package/dist/store.d.ts +461 -0
  36. package/dist/store.js +845 -0
  37. package/dist/submit.d.ts +1 -0
  38. package/dist/submit.js +60 -0
  39. package/dist/task-assets.d.ts +124 -0
  40. package/dist/task-assets.js +380 -0
  41. package/dist/task-sort.d.ts +27 -0
  42. package/dist/task-sort.js +59 -0
  43. package/dist/tasks.d.ts +191 -0
  44. package/dist/tasks.js +527 -0
  45. package/locale/en.json +6 -0
  46. package/locale/zh.json +6 -0
  47. package/package.json +67 -6
  48. package/index.js +0 -7
@@ -0,0 +1,509 @@
1
+ // 派发(决策 22 + 23 + 24):模型漏斗解析 + 部署默认 preset 解析 → assign-session 落库
2
+ // → ctx.agents.create 驱动模型(内部自建会话:sessions.prepare + enter + announce;setup 里
3
+ // mount preset(工具/提示词/skill 跟随系统)+ 注册本任务专属的回执工具(receipt.ts))
4
+ // → 工作区 attachSession 归组 → agent.send 拼装消息。
5
+ // 不要预建 ctx.sessions.create——会撞 store 的 'session "…" already exists'(真机教训 2026-09-23)。
6
+ import { randomUUID } from 'node:crypto';
7
+ import { readFile, stat } from 'node:fs/promises';
8
+ import { basename, resolve } from 'node:path';
9
+ import { receiptInstruction, registerReceiptTool } from './receipt.js';
10
+ /**
11
+ * 派发前置条件失败(决策 22 / 决策 23):scheduler 按具体 reason 收敛实例,而非笼统 dispatch-error。
12
+ * reason 取值:no-model-route / agent-create-failed / receipt-tool-unavailable / workspace-attach-failed。
13
+ */
14
+ export class DispatchPreconditionError extends Error {
15
+ reason;
16
+ constructor(reason, message) {
17
+ super(message);
18
+ this.reason = reason;
19
+ this.name = 'DispatchPreconditionError';
20
+ }
21
+ }
22
+ /**
23
+ * 工作区名 → 工作区实体(决策 22:target.workspace 语义是**工作区**,不是工作目录;
24
+ * 目录由实体 path 派生)。registry 无按 name 查询 API,遍历 list() 比对:
25
+ * title 精确匹配优先,id 兜底。匹配不到即抛错 ⇒ 任务判失败,绝不落到「未分组」或随便找个目录跑。
26
+ * (Loop A 落库前的前置检查用;Loop B 发动走 resolveWorkspaceByPath——快照里存的是 path。)
27
+ */
28
+ export function resolveWorkspace(ctx, name) {
29
+ const workspaces = ctx.workspaceRegistry.list();
30
+ const hit = workspaces.find(workspace => workspace.title === name)
31
+ ?? workspaces.find(workspace => workspace.id === name);
32
+ if (hit === undefined) {
33
+ throw new Error(`找不到工作区 "${name}"(任务必须挂在一个已注册工作区下;已注册: ${workspaces.map(workspace => workspace.title).join(', ') || '(无)'})`);
34
+ }
35
+ return hit;
36
+ }
37
+ /**
38
+ * 工作区 path → 工作区实体(决策 41:Loop B 发动只读快照的 workspacePath)。
39
+ * 快照里的 path 是落库时从实体原样取的,精确比对即可;找不到 = 工作区被删/重建,
40
+ * 行保留走重试判定(失败原因落事件)。
41
+ */
42
+ export function resolveWorkspaceByPath(ctx, path) {
43
+ const hit = ctx.workspaceRegistry.list().find(workspace => workspace.path === path);
44
+ if (hit === undefined) {
45
+ throw new Error(`找不到 path 为 "${path}" 的工作区(派发时存在、发动时消失;请在工作区注册表里恢复后等重试)`);
46
+ }
47
+ return hit;
48
+ }
49
+ /**
50
+ * provider 反查:在已注册 provider 里找第一个能发现该 model 的。
51
+ * 宿主明示模型目录是 advisory(未列出不等于不可用),故本函数只用于「补全」,
52
+ * 查不到时由调用方继续下漏,而不是把这一层判成非法。
53
+ */
54
+ async function lookupProvider(llm, model, logger, label) {
55
+ for (const provider of llm.listProviders()) {
56
+ try {
57
+ const models = await llm.listModels(provider.id);
58
+ if (models.some(item => item.id === model))
59
+ return provider.id;
60
+ }
61
+ catch (error) {
62
+ logger.warn(`${label} 反查 provider 时读取 ${provider.id} 模型目录失败: ${String(error)}`);
63
+ }
64
+ }
65
+ return undefined;
66
+ }
67
+ /** 解析漏斗的一层:成对直接用;只给 model 则反查 provider;只给 provider 或反查失败 → 跳过该层。 */
68
+ async function resolveLayer(ctx, logger, source, label, candidate) {
69
+ const provider = candidate.provider.trim();
70
+ const model = candidate.model.trim();
71
+ if (provider.length === 0 && model.length === 0)
72
+ return undefined;
73
+ if (model.length === 0) {
74
+ logger.warn(`${label} 配了 provider 但没有 model(须成对)→ 跳过该层`);
75
+ return undefined;
76
+ }
77
+ if (provider.length > 0)
78
+ return { provider, model, source };
79
+ const llm = ctx.get('llm');
80
+ if (llm === undefined) {
81
+ logger.warn(`${label} 只给了 model "${model}",但宿主未挂载 llm 服务、无法反查 provider → 跳过该层`);
82
+ return undefined;
83
+ }
84
+ const found = await lookupProvider(llm, model, logger, label);
85
+ if (found === undefined) {
86
+ logger.warn(`${label} 只给了 model "${model}",已注册 provider 中未反查到(模型目录为 advisory)→ 跳过该层`);
87
+ return undefined;
88
+ }
89
+ return { provider: found, model, source };
90
+ }
91
+ /**
92
+ * 模型解析漏斗(决策 22,决策 41 修订第①层来源):逐层下漏,四层全空返回 undefined
93
+ * (调用方判失败,走重试判定、行保留)。
94
+ *
95
+ * ① 派发快照的 provider/model 提示(= 落库时的任务 target,决策 41:Loop B 不读任务表)
96
+ * ② 插件配置 defaultProvider/defaultModel
97
+ * ③ 宿主默认 ctx.get('agentDefaultModel').currentSelection()(= 用户配的 / 上次用的模型)
98
+ * ④ llm 首个可用:listProviders() 首个能列出模型的 provider + 其首个模型
99
+ *
100
+ * ⚠️ 只在落库 / 发动时现算,**绝不回写**任务定义或配置:用户日后换模型要能自动跟上,
101
+ * 在配置期固化等于自己废掉兜底。
102
+ */
103
+ export async function resolveModelRoute(ctx, logger, providerHint, modelHint, config, hintLabel = '派发快照的 target') {
104
+ const fromTask = await resolveLayer(ctx, logger, 'task', hintLabel, {
105
+ provider: providerHint,
106
+ model: modelHint,
107
+ });
108
+ if (fromTask !== undefined)
109
+ return fromTask;
110
+ const fromConfig = await resolveLayer(ctx, logger, 'plugin-config', '插件配置 defaultProvider/defaultModel', {
111
+ provider: config.defaultProvider,
112
+ model: config.defaultModel,
113
+ });
114
+ if (fromConfig !== undefined)
115
+ return fromConfig;
116
+ const hostDefault = ctx.get('agentDefaultModel');
117
+ if (hostDefault !== undefined) {
118
+ try {
119
+ const route = hostDefault.currentSelection();
120
+ if (typeof route?.provider === 'string' && route.provider.length > 0
121
+ && typeof route.model === 'string' && route.model.length > 0) {
122
+ return { provider: route.provider, model: route.model, source: 'host-default' };
123
+ }
124
+ logger.warn('宿主 agentDefaultModel 未给出可用的 provider/model → 继续下漏');
125
+ }
126
+ catch (error) {
127
+ logger.warn(`读取宿主默认模型失败: ${String(error)}`);
128
+ }
129
+ }
130
+ const llm = ctx.get('llm');
131
+ if (llm !== undefined) {
132
+ for (const provider of llm.listProviders()) {
133
+ try {
134
+ const first = (await llm.listModels(provider.id))[0];
135
+ if (first !== undefined)
136
+ return { provider: provider.id, model: first.id, source: 'llm-first' };
137
+ }
138
+ catch (error) {
139
+ logger.warn(`llm 首个可用模型探测失败 ${provider.id}: ${String(error)}`);
140
+ }
141
+ }
142
+ }
143
+ logger.warn('模型漏斗四层全部落空(决策 22):请在 target 或插件配置里指定 provider+model');
144
+ return undefined;
145
+ }
146
+ /**
147
+ * 解析「与用户新建会话相同」的 agent 组装方式(决策 23):取**部署默认** preset
148
+ * (`resolve()` 省略 id = settings 的 selectionPolicy().defaultId,热读)。
149
+ *
150
+ * preset 决定 agent 的**工具、prompt sections、skill 目录**(含工作区 AGENTS.md 注入)。
151
+ * 不挂 preset 的 agent 落到「空的全局层」——真机实测只剩下根作用域的 MCP 工具,fs/bash 全无。
152
+ * 这里没有任何硬编码工具清单:部署往 preset 的 composition 里加什么,派发的会话就有什么。
153
+ *
154
+ * @returns 组装方式;presets 服务未挂载、或部署无可用 preset(rosterless)时返回 undefined
155
+ * ——那种部署下这些行住在宿主 composition 的全局层,不挂也看得见,故**跳过而非判失败**。
156
+ */
157
+ async function resolveAgentComposition(ctx, logger, instanceId) {
158
+ const presets = ctx.get('agentPresets');
159
+ if (presets === undefined) {
160
+ logger.warn(`实例 ${instanceId}: 宿主未挂载 agentPresets 服务,按 rosterless 处理(工具/提示词取全局层)`);
161
+ return undefined;
162
+ }
163
+ try {
164
+ const { id } = await presets.resolve();
165
+ return { presets, presetId: id };
166
+ }
167
+ catch (error) {
168
+ logger.warn(`实例 ${instanceId}: 解析部署默认 preset 失败,按 rosterless 处理: ${String(error)}`);
169
+ return undefined;
170
+ }
171
+ }
172
+ /**
173
+ * 插件→会话的用户消息(决策 19:追问层用,form=notice 走系统通知样式)。
174
+ * source.kind 用**生产者自有 kind**(0.1.7 v4 格式要求,`kind: 'plugin'` 已废弃被拒)。
175
+ */
176
+ export function userNotice(text, summary, files = []) {
177
+ return {
178
+ id: randomUUID(),
179
+ role: 'user',
180
+ // 文本块在前、文件块在后:官方按 content 顺序渲染(气泡正文 + 下方附件卡)。
181
+ content: [{ type: 'text', text }, ...files],
182
+ source: {
183
+ kind: 'task-dispatch-table',
184
+ form: 'notice',
185
+ summary,
186
+ },
187
+ };
188
+ }
189
+ /**
190
+ * 宿主 ctx 命名空间属性的安全读取:ctx 的属性是 getter,**对应模块未 inject 时读取直接 throw**
191
+ * (真机实证:`cannot get property "agentTeams" without inject` ⇒ 派发 launch-error)。
192
+ * 探测语义 = 「有就用、没有就降级」,所以这里把 throw 归一成 undefined,绝不让它炸掉派发。
193
+ */
194
+ function readCtxProp(ctx, key) {
195
+ try {
196
+ return ctx[key];
197
+ }
198
+ catch {
199
+ return undefined;
200
+ }
201
+ }
202
+ /**
203
+ * 上游依赖段(决策 43):把快照里冻结的 resolvedDeps 渲染给下游 agent——
204
+ * 产出路径按**上游**工作区绝对化(基准不是下游工作区);上游旧行无快照 ⇒ 基准未知,原样给相对路径。
205
+ */
206
+ function dependencyLines(snapshot) {
207
+ const deps = snapshot.resolvedDeps;
208
+ if (deps === undefined || deps.length === 0)
209
+ return [];
210
+ const lines = ['', '上游依赖(落库时已锁定,勿自行查找最新产出):'];
211
+ for (const dep of deps) {
212
+ const head = `- 任务 ${dep.task}(${dep.semantics === 'same_period' ? '同周期' : '最近成功'}):`
213
+ + `实例 ${dep.instanceId.slice(0, 8)} · 计划时刻 ${dep.scheduledAt}`;
214
+ if (dep.outputs.length === 0) {
215
+ lines.push(`${head} · 未声明产出`);
216
+ continue;
217
+ }
218
+ lines.push(`${head},产出:`);
219
+ for (const output of dep.outputs) {
220
+ lines.push(` - ${dep.workspacePath === null ? `${output}(基准工作区未知,相对路径)` : resolve(dep.workspacePath, output)}`);
221
+ }
222
+ }
223
+ return lines;
224
+ }
225
+ /**
226
+ * 权限约束指令(决策 50):宿主 0.2.0-rc.2 的 `AgentOptions` 只有 provider / model /
227
+ * reasoningEffort / maxTokens,**没有按任务下发权限的参数**(权限是宿主新建会话 UI 的会话级设置),
228
+ * 故所选档位以派发消息中的约束指令执行——这是我们能真执行的语义,不做系统级拦截的假承诺;
229
+ * 宿主一旦开放 per-task 权限参数,此处改为随派发下发。默认档不加任何指令。
230
+ */
231
+ function permissionInstruction(mode) {
232
+ if (mode === 'readOnly')
233
+ return '权限:本次仅可查看。只读工作区内容,禁止写入、修改或删除任何文件,禁止执行会产生副作用的命令。';
234
+ if (mode === 'workspace')
235
+ return `权限:仅可在目标工作区(${WORKSPACE_PLACEHOLDER})内修改文件,禁止改动工作区之外的任何内容。`;
236
+ if (mode === 'full')
237
+ return '权限:完全权限,按任务需要执行(工作目录仍为目标工作区)。';
238
+ return null;
239
+ }
240
+ /** 权限指令里的工作区占位符:拼装时替换成真实工作区绝对路径(文案不写死路径)。 */
241
+ const WORKSPACE_PLACEHOLDER = '{{workspace}}';
242
+ /**
243
+ * 派发消息拼装(决策 12 模板 + 决策 24 回执工具 + 决策 41 快照化 + 决策 43 依赖冻结段 +
244
+ * 决策 49 团队段 + **随附文件段(决策 54)** + **执行时间段(U4,2026-10-05)**):短指令 prompt +
245
+ * 手册路径 + 执行时间(原定 / 实际派发,给了 times 才注入)+ 上游依赖段 + **随附文件段** +
246
+ * 团队执行段(仅 agentTeam 且宿主具备时)+ 回执调用说明。
247
+ * prompt / manual / validStatuses / resolvedDeps / attachments 全部来自派发快照,与任务设置无关。
248
+ *
249
+ * 随附文件**两手都给**(2026-10-03 拍板):
250
+ * ① **文本路径**——把**绝对路径**逐条写清(「从哪一层开始」就是它),并显式声明「允许读取」,否则
251
+ * 会与下面的权限指令(「仅工作区」)打架——upload 型附件落在**任务目录**(工作区之外),
252
+ * 不开口子模型就等于看不见;
253
+ * ② **官方 file 内容块**——把文件注册进宿主附件库(`ctx.attachments`),模型侧由宿主换成一句
254
+ * 「只读副本路径」(`projectFilesToText`),**不额外吃 token**;人的那一面则由官方渲染成
255
+ * **附件卡**(图标 + 文件名 + 大小,可点开),且 fork 续聊带得走、当时那一份内容被钉住。
256
+ */
257
+ export function buildMessage(snapshot, workspacePath, logicalDate, teamMode = false, attachments = [], fileBlocks = [],
258
+ /** 执行时间两条(用户 2026-10-05 拍板,U4 收口):不给 ⇒ 不注入该段(旧调用 / 冒烟旧行为不变)。 */
259
+ times) {
260
+ // 回执说明**只放一处、且放最末**(用户 2026-09-30 拍板,推翻先前的"放最前 + 首尾双写"):
261
+ // 真机证据 —— 模型**跳过了第 1 段**的回执要求(回了句问候就收工),而插件那条**只含回执要求**的
262
+ // 追问它**立刻照做** ⇒ 越靠后、越"只讲这一件事"的段落遵守率越高;同一条指令出现两处,反而被当成
263
+ // 背景噪音(注释此前写着"只放最前",代码却首尾各放一次 ⇒ 一处口径、一处行为,本次一并纠正)。
264
+ // 任务提示词排在最前,靠**末段那句显式优先级裁决**压住「不要做其他事情」这类说法。
265
+ const lines = [
266
+ snapshot.prompt,
267
+ '',
268
+ `任务实例:${snapshot.title} · ${logicalDate}(目标工作区:${workspacePath})`,
269
+ ];
270
+ // 执行时间两条(用户 2026-10-05 拍板,U4 收口):模型对「现在几点」未必可靠(真机 once8 产出
271
+ // 文件日期差过一天)⇒ 把**原定时刻**与**实际派发时刻**原样下发(ISO 8601 / UTC,精确到秒),
272
+ // 模型要处理自己处理。只给**事实**:日期的业务语义(如「日报算调度那天还是自然天」)仍由任务
273
+ // 提示词自定,插件不越权(用户 2026-10-05:这个插件解决不了)。
274
+ if (times !== undefined) {
275
+ lines.push(`执行时间:原定 ${times.scheduledAt} · 实际派发 ${times.dispatchedAt}(ISO 8601,UTC;你的当前时间以实际派发为准)`);
276
+ }
277
+ if (snapshot.manual !== null && snapshot.manual.trim() !== '') {
278
+ lines.push(`任务手册:先读工作区内 ${snapshot.manual},再按手册执行。`);
279
+ }
280
+ lines.push(...dependencyLines(snapshot));
281
+ lines.push(...attachmentLines(attachments));
282
+ if (teamMode) {
283
+ // 决策 49 多 Agent 协作段:官方 experimental profile(tool-agent-team)已给根会话 agent
284
+ // 装好 spawn_teammate / send_message / list_agents / wait_agent / interrupt_agent /
285
+ // team_task_* 工具(lib/index.js:242-445 源码核实),这里只负责把「按团队方式执行」讲清楚;
286
+ // 工具名如实列出,不做任何模拟通道。
287
+ lines.push('执行方式:本次启用多 Agent 协作。你作为团队队长(lead),请按需把可并行的子工作拆给队友:'
288
+ + '用 spawn_teammate 创建命名队友(写清职责),用 team_task_create 在共享任务板上登记分工与依赖,'
289
+ + '用 send_message 向队友下发具体指引,并用 wait_agent 等待其完成;'
290
+ + '队友与你在同一工作区工作。所有工作收束后由你统一汇总,并按下方要求交回执行结果。');
291
+ }
292
+ const permissionLine = permissionInstruction(snapshot.permission ?? 'default');
293
+ if (permissionLine !== null) {
294
+ lines.push(permissionLine.replace(WORKSPACE_PLACEHOLDER, workspacePath));
295
+ // 权限口子:随附文件可能是「工作区之外」的只读输入 ⇒ 必须显式豁免,且只放开读、不放开写。
296
+ if (attachments.length > 0) {
297
+ lines.push('说明:上方「本次随附文件」列出的路径是本次派发随附的只读输入,允许读取;'
298
+ + '它们不受本条权限指令里「仅在目标工作区内」的限制(同样不得修改、删除)。');
299
+ }
300
+ }
301
+ lines.push(receiptInstruction(snapshot.validStatuses));
302
+ return userNotice(lines.join('\n'), `[TASK] ${snapshot.title} · ${logicalDate}`, fileBlocks);
303
+ }
304
+ /** 随附文件段(用户 2026-09-30:必须让模型明确知道文件在哪一层、在什么地方)。 */
305
+ function attachmentLines(list) {
306
+ if (list.length === 0)
307
+ return [];
308
+ const lines = ['', '本次随附文件(只读输入,请勿修改;以下均为可直接读取的绝对路径):'];
309
+ for (const item of list) {
310
+ const tag = item.kind === 'upload' ? '上传' : '工作区';
311
+ lines.push(item.path === null
312
+ ? ` - ${item.name}(${tag}):${item.ref}(基准工作区未知,为工作区相对路径)`
313
+ : ` - ${item.name}(${tag}):${item.path}`);
314
+ }
315
+ return lines;
316
+ }
317
+ /** 单个附件进宿主附件库的字节上限(防御:误挂大文件不至于把附件库撑爆;库**永不自动删除**)。 */
318
+ const ATTACHMENT_BLOCK_MAX_BYTES = 8 * 1024 * 1024;
319
+ /** 一次派发最多注册多少个附件块(同上,防御性上限)。 */
320
+ const ATTACHMENT_BLOCK_MAX_COUNT = 20;
321
+ /**
322
+ * 随附文件 → 官方 `file` 内容块(2026-10-03 拍板「附加文件走 A」)。
323
+ *
324
+ * 逐个把文件字节交给宿主附件服务 `ctx.attachments.saveFile`(内容寻址、不可变、
325
+ * **永不自动删除**),拿回持久引用后拼成 file 块随派发消息发出 ⇒ 官方界面渲染成
326
+ * **附件卡**(图标 + 文件名 + 大小,可点开)、fork 续聊带得走、当时那份内容被钉住。
327
+ *
328
+ * **降级不阻塞**:宿主没挂 `dsh-attachment-local`、文件不在盘上、是目录、超过上限、
329
+ * 或 saveFile 抛错 ⇒ 该附件**只留文本路径**(2026-10-03 前的行为),派发照常。
330
+ *
331
+ * ⚠️ 目录**不能**作为附件:官方附件 = 一段字节,`FileAttachmentRef` 里没有路径
332
+ * (`dsh-attachment` `lib/types/types.d.ts:34-41`)。
333
+ */
334
+ export async function attachmentFileBlocks(ctx, attachments, logger, instanceId) {
335
+ const store = readCtxProp(ctx, 'attachments');
336
+ if (store === undefined) {
337
+ logger.warn(`[dispatch] attachment-store-unavailable 实例 ${instanceId}:宿主未暴露 ctx.attachments(需 dsh-attachment-local),随附文件本次只给路径`);
338
+ return [];
339
+ }
340
+ const blocks = [];
341
+ for (const item of attachments.slice(0, ATTACHMENT_BLOCK_MAX_COUNT)) {
342
+ if (item.path === null)
343
+ continue;
344
+ try {
345
+ const info = await stat(item.path);
346
+ if (!info.isFile())
347
+ continue;
348
+ if (info.size > ATTACHMENT_BLOCK_MAX_BYTES) {
349
+ logger.warn(`[dispatch] attachment-too-large 实例 ${instanceId}:${item.name} 超过 ${ATTACHMENT_BLOCK_MAX_BYTES} 字节,本次只给路径`);
350
+ continue;
351
+ }
352
+ const data = await readFile(item.path);
353
+ const ref = await store.saveFile({ data, name: item.name.trim() === '' ? basename(item.path) : item.name });
354
+ blocks.push({ type: 'file', attachment: ref });
355
+ }
356
+ catch (error) {
357
+ logger.warn(`[dispatch] attachment-block-failed 实例 ${instanceId}:${item.name} 未注册进宿主附件库(${String(error)}),本次只给路径`);
358
+ }
359
+ }
360
+ return blocks;
361
+ }
362
+ /**
363
+ * 发动一个已落库实例(决策 41:Loop B 发动;决策 22 顺序 + 决策 23 preset 组装):
364
+ * 解析模型漏斗(第①层取快照提示)→ **解析部署默认 preset** → 落 session_id → agents.create
365
+ * (自建会话并 announce session/created,对账收到时实例必已带 session_id;preset 在 create 的
366
+ * setup 里 mount,工具/prompt sections/skill 由此挂上)→ **工作区 attachSession 归组**
367
+ * → 落 dispatch 事件(含本次实测 route、命中层级与 preset)→ send。
368
+ * 会话改名在 reconciler.onCreated 做——此处拿不到 Session 对象(handle 只有 agent)。
369
+ * @returns sessionId 与 agent handle——handle 供对账层超时追问(决策 19 第二层)。
370
+ * @throws DispatchPreconditionError 模型解析不出、会话建不起来(含 preset 挂载失败)、
371
+ * 或会话建好后无法归组工作区——调用方(Loop B)按重试判定收敛、**行保留**。
372
+ */
373
+ export async function dispatchTask(input) {
374
+ const { ctx, logger, store, instanceId, logicalDate, snapshot, workspace, config } = input;
375
+ const route = await resolveModelRoute(ctx, logger, snapshot.provider, snapshot.model, config);
376
+ if (route === undefined) {
377
+ // 前置条件不足:不建会话、不发动(先于 session_id 落库,实例行不留假 session)。
378
+ throw new DispatchPreconditionError('no-model-route', `实例 ${instanceId} 无法解析出 provider+model,不发动(决策 22 漏斗四层全空)`);
379
+ }
380
+ // 组装方式(决策 23):部署默认 preset——与用户在 UI 新建会话同一套。
381
+ const composition = await resolveAgentComposition(ctx, logger, instanceId);
382
+ const sessionId = randomUUID();
383
+ // 领取后先把会话身份落到实例行,再建 agent——session/created(factory announce)
384
+ // 到达时实例必已带 session_id,对账可立即转 running。
385
+ // ⚠️ dispatched_at 必须在此写入(原由 store.casClaim 负责,决策 31 懒建行后已不走 CAS):
386
+ // ① sweep 用它算「等 session/created」的派发宽限;② `latestReceipt` 用它做 afterIso,
387
+ // 防上一轮 attempt 的旧回执冒充本轮。缺失会退到 updated_at(最后一次状态变更,晚于回执)
388
+ // ⇒ 旧回执会被当成新回执。
389
+ // (2026-10-03:回执校验已不再用 mtime 比它判新鲜 —— 见 reconcile.ts checkReceipt。)
390
+ store.transition(instanceId, {
391
+ status: 'dispatched',
392
+ session_id: sessionId,
393
+ dispatched_at: new Date().toISOString(),
394
+ detail: 'assign-session',
395
+ });
396
+ // provider/model 必须成对显式传(决策 22):宿主缺省不填 {{model}},deployment persona
397
+ // 里的 {{model}} 取不到值会直接抛错、本轮秒结束。会话由本调用自建,禁止预建(见文件头)。
398
+ // setup(决策 23)是**唯一**能挂上模型可见层(工具 / prompt sections / skill)的时机:
399
+ // 在 mint agentCtx 之后、发布之前。抛错则工厂回滚作用域、会话与 agent 都不发布,故这里
400
+ // 撤回占位 session_id——实例行不留一个从未发布过的会话身份。
401
+ let handle;
402
+ try {
403
+ handle = await ctx.agents.create({
404
+ sessionId,
405
+ meta: {
406
+ cwd: workspace.path,
407
+ ...(composition === undefined ? {} : { agentPreset: composition.presetId }),
408
+ },
409
+ agentOptions: { provider: route.provider, model: route.model },
410
+ // setup 是发布前唯一能组装 agent 作用域的时机(决策 23 / 24):
411
+ // ① 挂 preset(工具 / prompt sections / skill 跟随系统);② 注册本实例专属的回执工具
412
+ // (per-agent,只对该会话可见;execute 在插件进程内写库,绕开 agent 沙箱——决策 24)。
413
+ setup: async (agentCtx) => {
414
+ if (composition !== undefined)
415
+ await composition.presets.mount(agentCtx, composition.presetId);
416
+ // 回执工具注册不上 ⇒ 这个会话没有任何回执通道,跑完必然白跑(决策 24):当作前置条件
417
+ // 失败直接抛,工厂会回滚作用域、不发布会话;Loop B 按 receipt-tool-unavailable 收敛。
418
+ if (!registerReceiptTool(agentCtx, {
419
+ store,
420
+ taskName: snapshot.title,
421
+ instanceId,
422
+ sessionId,
423
+ validStatuses: snapshot.validStatuses,
424
+ logger,
425
+ sessionProjections: ctx.sessionProjections,
426
+ })) {
427
+ throw new DispatchPreconditionError('receipt-tool-unavailable', `实例 ${instanceId} 的回执工具未注册成功(同名冲突或宿主未暴露 tools 服务),不发动`);
428
+ }
429
+ },
430
+ });
431
+ }
432
+ catch (error) {
433
+ store.transition(instanceId, { status: 'dispatched', session_id: null, detail: 'create-failed' });
434
+ // setup 里抛的前置条件失败(如 receipt-tool-unavailable)原样上抛,保住具体 reason。
435
+ if (error instanceof DispatchPreconditionError)
436
+ throw error;
437
+ throw new DispatchPreconditionError('agent-create-failed', `实例 ${instanceId} 会话 ${sessionId} 未能建立(preset=${composition?.presetId ?? '(rosterless)'}): ${String(error)}`);
438
+ }
439
+ // 归组(决策 22):只设 meta.cwd 不会进工作区 sessionIds,必须显式 attach;
440
+ // attach 内部要求 session header 的 cwd 归一后 === workspace.path,故 cwd 只能取自本实体。
441
+ try {
442
+ await workspace.attachSession(sessionId);
443
+ }
444
+ catch (error) {
445
+ void handle.dispose().catch(() => { });
446
+ throw new DispatchPreconditionError('workspace-attach-failed', `会话 ${sessionId} 已建立但无法归入工作区 "${workspace.title}"(${workspace.path}): ${String(error)}`);
447
+ }
448
+ // 多 Agent 协作探测(决策 49):快照开启且宿主暴露 ctx.agentTeams(experimental Agent Teams
449
+ // profile)才注入团队执行指令;profile 未启用 ⇒ 降级单 Agent + 告警留痕,不阻塞派发
450
+ // (与决策 48 goal-unavailable 同款语义;正常功能一律真实——绝不注入装不出来的假指令)。
451
+ // ⚠️ 宿主 ctx 的命名空间属性是 getter:**对应模块未 inject 时读取会直接 throw**(真机实证:
452
+ // `cannot get property "agentTeams" without inject` ⇒ 整次派发 launch-error),必须护栏读。
453
+ const teamAvailable = readCtxProp(ctx, 'agentTeams') !== undefined;
454
+ const teamMode = snapshot.agentTeam === true && teamAvailable;
455
+ if (snapshot.agentTeam === true && !teamAvailable) {
456
+ logger.warn(`[dispatch] agent-team-unavailable 实例 ${instanceId}:宿主未启用 Agent Teams(ctx.agentTeams 缺失),按单 Agent 执行`);
457
+ }
458
+ // route/source/preset 落事件:只记本次实测用了什么,不回写任务定义或配置。
459
+ store.appendEvent(instanceId, 'dispatch', {
460
+ sessionId,
461
+ workspacePath: workspace.path,
462
+ provider: route.provider,
463
+ model: route.model,
464
+ modelSource: route.source,
465
+ ...(composition === undefined ? {} : { agentPreset: composition.presetId }),
466
+ goal: snapshot.goal !== false,
467
+ agentTeam: snapshot.agentTeam === true,
468
+ permission: snapshot.permission ?? 'default',
469
+ teamMode,
470
+ });
471
+ // /goal 多轮续跑(决策 48,用户 2026-09-29「默认都是多轮会话」):目标开启时把任务目标交给
472
+ // dsh 内置 /goal(ctx.goals,@deepseek-ai/dsh-goal 0.2.0-rc.2:CreateGoalRequest { objective };
473
+ // goal continuation round 自动续跑、agent 标记 complete 收束,看板届时才结算)。宿主未暴露
474
+ // goals 服务或创建失败都不阻塞派发(本轮照常执行),只落警告留痕——行为与开关语义一致。
475
+ if (snapshot.goal !== false) {
476
+ const goals = readCtxProp(ctx, 'goals');
477
+ if (goals === undefined) {
478
+ logger.warn(`[dispatch] goal-unavailable 实例 ${instanceId}:宿主 ctx 未暴露 goals 服务,本轮按单轮执行`);
479
+ }
480
+ else {
481
+ try {
482
+ // ⚠️ goal 是**另一个指令入口**(宿主持久目标 + 自动续跑轮):真机"首轮只回一句问候、不交回执"
483
+ // 与它高度吻合(那一轮模型可能只看到 objective,而它原先**不含任何回执要求**)。补一句指针做
484
+ // 零风险对冲 —— 完整说明仍在派发消息最末段。
485
+ await goals.create(handle.agent, {
486
+ objective: `${snapshot.title}:${snapshot.prompt}\n\n`
487
+ + '【回执】本轮回复结束前必须按派发消息里的说明提交回执(不提交等于失败;没有产出也要交)。',
488
+ });
489
+ }
490
+ catch (error) {
491
+ logger.warn(`[dispatch] goal-create-failed 实例 ${instanceId}:${String(error)}`);
492
+ }
493
+ }
494
+ }
495
+ // 会话列表治理:规范名在 reconciler.onCreated 改(决策 42 格式),跑完归档在 succeeded 对账后。
496
+ // 随附文件 → 官方 file 块(2026-10-03 拍板「附加文件走 A」):注册不成功/宿主无附件服务
497
+ // 都不阻塞派发,退化成只给路径。放在 send 前——注册的是**当时那份字节**,会话里钉住版本。
498
+ const fileBlocks = input.attachments.length === 0
499
+ ? []
500
+ : await attachmentFileBlocks(ctx, input.attachments, logger, instanceId);
501
+ if (fileBlocks.length > 0) {
502
+ store.appendEvent(instanceId, 'dispatch', { attachmentBlocks: fileBlocks.length });
503
+ }
504
+ handle.agent.send(buildMessage(snapshot, workspace.path, logicalDate, teamMode, input.attachments, fileBlocks, {
505
+ scheduledAt: input.scheduledAt,
506
+ dispatchedAt: new Date().toISOString(),
507
+ }), 'next-turn', true);
508
+ return { sessionId, handle };
509
+ }
@@ -0,0 +1,16 @@
1
+ import type { PushEvent } from './event-catalog.js';
2
+ export interface EventBusOptions {
3
+ /** 合并窗口(毫秒):同 key 在该窗口内的重复事件合并成一条。默认 200ms。 */
4
+ windowMs?: number;
5
+ /** 最大等待(毫秒):兜底上界,窗口配置得比它还大时按它发。默认 1000ms。 */
6
+ maxWaitMs?: number;
7
+ }
8
+ export interface EventBus {
9
+ /** 变更点**唯一调用口**(不写库、不抛错)。 */
10
+ emit(event: PushEvent): void;
11
+ /** 每个订阅者(一条 SSE 连接)注册一次;返回退订函数。 */
12
+ subscribe(send: (event: PushEvent) => void): () => void;
13
+ /** 清掉待发缓冲 / 定时器 / 订阅集合(插件 dispose 用)。 */
14
+ dispose(): void;
15
+ }
16
+ export declare function createEventBus(opts?: EventBusOptions): EventBus;
@@ -0,0 +1,67 @@
1
+ const DEFAULT_WINDOW_MS = 200;
2
+ const DEFAULT_MAX_WAIT_MS = 1_000;
3
+ /** 合并 key = `type` + payload 里的身份(taskId / instanceId / id / ids),无身份则只按 type。 */
4
+ function keyOf(event) {
5
+ const p = event.payload;
6
+ if (p === undefined)
7
+ return event.type;
8
+ const id = p.taskId ?? p.instanceId ?? p.id;
9
+ if (typeof id === 'string' && id !== '')
10
+ return `${event.type}:${id}`;
11
+ const ids = p.ids;
12
+ if (Array.isArray(ids) && ids.length > 0)
13
+ return `${event.type}:${ids.join(',')}`;
14
+ return event.type;
15
+ }
16
+ export function createEventBus(opts = {}) {
17
+ const windowMs = opts.windowMs ?? DEFAULT_WINDOW_MS;
18
+ const maxWaitMs = opts.maxWaitMs ?? DEFAULT_MAX_WAIT_MS;
19
+ const subscribers = new Set();
20
+ /** 待发缓冲:key → 最新事件(同 key 只留最后一条)+ 首个到达时刻。 */
21
+ const pending = new Map();
22
+ let timer = null;
23
+ const flush = () => {
24
+ timer = null;
25
+ if (pending.size === 0)
26
+ return;
27
+ const batch = [...pending.values()].map(item => item.event);
28
+ pending.clear();
29
+ for (const send of subscribers) {
30
+ for (const event of batch) {
31
+ // 单个订阅者(连接)出错不连累其它订阅者,也不影响广播器。
32
+ try {
33
+ send(event);
34
+ }
35
+ catch { /* 忽略:连接已被对端关闭等 */ }
36
+ }
37
+ }
38
+ };
39
+ return {
40
+ emit(event) {
41
+ if (subscribers.size === 0)
42
+ return; // 无人听 ⇒ 不缓冲(也避免空转定时器)
43
+ const now = Date.now();
44
+ const key = keyOf(event);
45
+ const prev = pending.get(key);
46
+ const firstAt = prev === undefined ? now : prev.firstAt;
47
+ pending.set(key, { event, firstAt });
48
+ // ponytail: 合并按「首个事件起算的固定窗口」而非「尾随 debounce」——200ms 差异不可感,
49
+ // 且免掉逐事件重置计时器带来的「持续高频把别的 key 一起推迟」问题。窗口不合适改常量即可。
50
+ if (timer !== null)
51
+ return;
52
+ timer = setTimeout(flush, Math.min(windowMs, Math.max(0, maxWaitMs - (now - firstAt))));
53
+ },
54
+ subscribe(send) {
55
+ subscribers.add(send);
56
+ return () => { subscribers.delete(send); };
57
+ },
58
+ dispose() {
59
+ if (timer !== null) {
60
+ clearTimeout(timer);
61
+ timer = null;
62
+ }
63
+ pending.clear();
64
+ subscribers.clear();
65
+ },
66
+ };
67
+ }
@@ -0,0 +1,42 @@
1
+ /** 事件类型目录(前后端唯一真源;新增类型只在这里加)。 */
2
+ export declare const EventType: {
3
+ /** 任务定义增删改(含开关 / 整批替换 / 版本删除 / 附件文件变更;这些写路径都汇聚到 onDefinitionsChanged)。 */
4
+ readonly TASKS_CHANGED: "tasks.changed";
5
+ /** 实例进入 dispatched / running(自动调度或「立即执行」)。 */
6
+ readonly TASK_RUN_STARTED: "task.run.started";
7
+ /** 实例成功终态。 */
8
+ readonly TASK_RUN_SUCCEEDED: "task.run.succeeded";
9
+ /** 实例失败终态(含判死 / 租约回收 / 回执缺失收敛)。 */
10
+ readonly TASK_RUN_FAILED: "task.run.failed";
11
+ /** 跳过 / 错过刻度 / 过期(「未执行」的原因类)。 */
12
+ readonly TASK_RUN_SKIPPED: "task.run.skipped";
13
+ /** 其余实例行变化(重试退回 / unknown 复活 / 转 running / redispatch / 删行 / 阻塞原因变化)。 */
14
+ readonly TASK_RUN_CHANGED: "task.run.changed";
15
+ /** 插件配置变更。 */
16
+ readonly CONFIG_CHANGED: "config.changed";
17
+ /** 无参:强制前端重读一次当前值(后端升级 / 索引重建等)。 */
18
+ readonly FORCE_REFRESH: "force.refresh";
19
+ };
20
+ export type EventTypeValue = typeof EventType[keyof typeof EventType];
21
+ /** 「实例运行态」这一族事件(订阅方通常一并关心)。 */
22
+ export declare const RUN_EVENT_TYPES: readonly EventTypeValue[];
23
+ /**
24
+ * 连接心跳的事件类型(**不属于业务事件目录**,故意不在 `EventType` 里):
25
+ * 服务端每 `SSE_HEARTBEAT_MS` 发一条,客户端只用它判断「这条连接还是活的」——这是「`readyState` 是
26
+ * OPEN 但已经半死」唯一可观测的判据(反向代理静默丢流 / 无 FIN 的黑洞)。
27
+ * ⚠️ 必须是**真实 data 帧**:SSE 注释帧(`: ping`)浏览器直接吞掉,前端 `onmessage` 根本看不到。
28
+ * 前端 `byType` 里没有它的订阅者 ⇒ 收到后直接丢弃,不会当成业务事件(2026-10-06 审计)。
29
+ */
30
+ export declare const HEARTBEAT_TYPE = "sys.ping";
31
+ /** 与心跳相关的**非**业务类型集合(订阅方一律忽略;留一处便于将来扩充)。 */
32
+ export declare const NON_BUSINESS_TYPES: readonly string[];
33
+ /** 事件信封:type 固定;payload 任意(可为空)。 */
34
+ export interface PushEvent {
35
+ type: EventTypeValue;
36
+ payload?: Record<string, unknown>;
37
+ }
38
+ /**
39
+ * 实例状态 → 事件类型(`RuntimeIndex.markTerminal` / `markDispatched` 的映射)。
40
+ * 只按「类型」订阅的前端拿它区分成功 / 失败 / 跳过 / 其它;状态细节交给重读。
41
+ */
42
+ export declare function runEventTypeOf(status: string): EventTypeValue;