dsh-plugin-t-expert 0.3.88 → 0.3.89

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.
@@ -1,642 +0,0 @@
1
- /**
2
- * Event-driven shared task scheduler.
3
- *
4
- * Claude Code teammates keep polling the shared task list after a turn. DSH
5
- * continuable agents instead expose explicit idle/running edges, so this
6
- * scheduler closes the same loop without keeping a polling turn alive: every
7
- * idle edge and every task-graph mutation attempts one atomic claim and wakes
8
- * the selected durable member. A resident member that becomes idle while it
9
- * still owns an open attempt is parked: only an explicit captain reassignment
10
- * may rotate that capability. Automatic retry is reserved for cold recovery,
11
- * when this process has not observed the durable owner settle its open attempt.
12
- * @module t-team/scheduler
13
- */
14
- import { join } from 'node:path';
15
- import { deliverToMember } from "./members.js";
16
- import { acknowledgeMailbox, beginTaskAttempt, CAPTAIN_KEY, claimMailboxDelivery, findTeamByParticipant, invalidateTaskAttempt, readTeam, readUnreadMailbox, releaseMailboxDelivery, unsatisfiedDependencies, withTeamLock, writeTeam, } from "./state.js";
17
- import { TERMINAL_TASK_STATUSES } from "./types.js";
18
- /** Per-dependency output cap in the assignment prompt. */
19
- export const DEPENDENCY_OUTPUT_MAX_CHARS = 2_000;
20
- /** Combined dependency-output budget in the assignment prompt. */
21
- export const DEPENDENCY_OUTPUTS_TOTAL_MAX_CHARS = 12_000;
22
- function taskProfileSeedId(task) {
23
- const seed = task.profileSeedId?.trim();
24
- return seed === undefined || seed === '' ? undefined : seed;
25
- }
26
- function teamProfileProtocol(team) {
27
- return team.profile?.protocol;
28
- }
29
- /**
30
- * Recursively collect `status=completed` ancestors of `taskId` in topological
31
- * order (dependencies before dependents). Cycles stop that branch only.
32
- */
33
- export function collectCompletedDependencyOutputs(tasks, taskId, warn) {
34
- const byId = new Map(tasks.map(task => [task.id, task]));
35
- // 直接依赖 vs 传递性祖先:前者是这条任务的**直接输入**(给全文),后者只是背景
36
- // (给摘要)。不区分的话,一条深链会用祖先的全文把预算吃光,把真正的直接输入挤掉。
37
- const direct = new Set(byId.get(taskId)?.dependencies ?? []);
38
- const visiting = new Set();
39
- const visited = new Set();
40
- const ordered = [];
41
- const walk = (id) => {
42
- if (visiting.has(id)) {
43
- warn?.(`t-team: dependency cycle involving "${id}" while collecting outputs; stopping this branch`);
44
- return;
45
- }
46
- if (visited.has(id))
47
- return;
48
- visiting.add(id);
49
- const task = byId.get(id);
50
- if (task !== undefined) {
51
- for (const dependency of task.dependencies)
52
- walk(dependency);
53
- if (id !== taskId)
54
- ordered.push(task);
55
- }
56
- visiting.delete(id);
57
- visited.add(id);
58
- };
59
- walk(taskId);
60
- return ordered
61
- .filter(task => task.status === 'completed')
62
- .map((task) => {
63
- const profileSeedId = taskProfileSeedId(task);
64
- return {
65
- id: task.id,
66
- subject: task.subject,
67
- direct: direct.has(task.id),
68
- ...profileSeedId === undefined ? {} : { profileSeedId },
69
- ...task.output === undefined ? {} : { output: task.output },
70
- ...task.summary === undefined ? {} : { summary: task.summary },
71
- ...Array.isArray(task.tried) && task.tried.length > 0 ? { tried: task.tried } : {},
72
- };
73
- });
74
- }
75
- /**
76
- * 与本任务**并行在途**的其它任务(claimed / in_progress)。
77
- *
78
- * 为什么要给成员看见:两条并行任务即使写域(inScope)不重叠、闸门放行,也可能做出互相
79
- * 冲突的架构或风格决策 —— Cognition 记录过的经典形态是两个子代理并行做同一个游戏,一个
80
- * 画角色、一个画背景,风格完全对不上,因为彼此看不到对方的决策。事后由 integration 任务
81
- * 发现时返工成本已经产生。这里只用现成的任务表告诉成员「此刻还有谁在并行决定什么」,
82
- * 不新增任何状态,也不要求它们互相等待。
83
- */
84
- export function collectInFlightSiblings(tasks, taskId) {
85
- return tasks
86
- .filter(task => task.id !== taskId
87
- && (task.status === 'claimed' || task.status === 'in_progress'))
88
- .map(task => ({
89
- id: task.id,
90
- subject: task.subject,
91
- assignee: task.assignee ?? '',
92
- ...Array.isArray(task.inScope) && task.inScope.length > 0 ? { inScope: task.inScope } : {},
93
- }));
94
- }
95
- /**
96
- * Format completed-dependency outputs with per-item and total truncation.
97
- *
98
- * 交接契约(2026-09-20):**直接依赖给全文,传递性祖先优先给 `summary`**。
99
- * 过去一律传 `output` 全文再按字符数硬截断,于是长链上的祖先会把预算吃光,而下游真正
100
- * 需要的直接输入反倒被切掉;截断点还落在句子中间。有 `summary` 的祖先改传那一段压缩
101
- * 结论(它正是写给下游看的),信息密度高得多。
102
- *
103
- * `tried`(走过的死路)**无论直接与否都附上**:它的价值就是阻止下游重复同一条死路,
104
- * 而重复死路恰恰最常发生在隔了一层的任务之间。
105
- */
106
- export function formatDependencyOutputs(items) {
107
- if (items.length === 0)
108
- return '(none)';
109
- const formatted = items.map((item) => {
110
- const seed = item.profileSeedId === undefined ? '' : ` [${item.profileSeedId}]`;
111
- // 祖先有摘要就用摘要;直接依赖始终用全文(缺全文时退回摘要)。
112
- const preferSummary = item.direct !== true && typeof item.summary === 'string' && item.summary !== '';
113
- const raw = preferSummary
114
- ? item.summary
115
- : item.output === undefined || item.output === ''
116
- ? (item.summary ?? '(no output recorded)')
117
- : item.output;
118
- const truncated = raw.length > DEPENDENCY_OUTPUT_MAX_CHARS;
119
- const body = truncated ? `${raw.slice(0, DEPENDENCY_OUTPUT_MAX_CHARS)} [truncated]` : raw;
120
- const label = preferSummary ? ' (summary)' : '';
121
- const tried = Array.isArray(item.tried) && item.tried.length > 0
122
- ? `\n Already tried and rejected: ${item.tried.join('; ')}`
123
- : '';
124
- return `- ${item.id}${seed} ${item.subject}${label}:\n ${body}${tried}`;
125
- });
126
- let selected = formatted;
127
- while (selected.length > 1 && selected.join('\n').length > DEPENDENCY_OUTPUTS_TOTAL_MAX_CHARS) {
128
- selected = selected.slice(1);
129
- }
130
- const last = selected[0];
131
- if (selected.length === 1 && last !== undefined && last.length > DEPENDENCY_OUTPUTS_TOTAL_MAX_CHARS) {
132
- selected = [`${last.slice(0, DEPENDENCY_OUTPUTS_TOTAL_MAX_CHARS)} [truncated]`];
133
- }
134
- return selected.join('\n');
135
- }
136
- /**
137
- * 渲染「此刻并行在途的任务」。没有并行任务时返回空串(不占提示词预算)。
138
- * 措辞刻意约束成**只用于对齐决策**:不许等待、不许插手,否则会退化成互相阻塞。
139
- */
140
- export function formatInFlightSiblings(items) {
141
- if (items === undefined || items.length === 0)
142
- return '';
143
- const lines = items.map((item) => {
144
- const owner = item.assignee === '' ? 'unassigned' : item.assignee;
145
- const scope = item.inScope === undefined || item.inScope.length === 0
146
- ? ''
147
- : ` — in scope: ${item.inScope.join(', ')}`;
148
- return `- ${item.id} ${item.subject} (${owner})${scope}`;
149
- });
150
- return `
151
- Running in parallel right now (do NOT wait for these, do NOT work on them):
152
- ${lines.join('\n')}
153
- Keep your choices compatible with theirs where your work meets. If your task forces a decision that would conflict with one of them, say so in your report instead of silently diverging.
154
- `;
155
- }
156
- function stateRootOf(workspace, config) {
157
- return join(workspace, config.stateDir);
158
- }
159
- function teamLockKey(stateRoot, teamId) {
160
- return `team:${stateRoot}:${teamId}`;
161
- }
162
- function liveCaptain(ctx, captainSessionId, supplied) {
163
- if (supplied !== undefined && supplied.id === captainSessionId)
164
- return supplied;
165
- return ctx.agents.get(captainSessionId);
166
- }
167
- function liveMember(ctx, member) {
168
- return ctx.agents.get(member.id);
169
- }
170
- function isMemberAvailable(ctx, member) {
171
- const live = liveMember(ctx, member);
172
- return live === undefined || live.status === 'idle';
173
- }
174
- function ownedOpenTask(tasks, memberName) {
175
- return tasks.find(task => task.assignee === memberName
176
- && (task.status === 'claimed' || task.status === 'in_progress'));
177
- }
178
- /**
179
- * 剩余关键路径长度(upward rank):沿**尚未终结**的后继任务能延伸出的最长链长。
180
- *
181
- * 为什么需要它:就绪前沿常常同时有好几条任务,而过去是按**创建顺序**取第一条
182
- * (`ready.find`)—— 先挑走一条叶子任务会把真正卡住后续工作的那条往后推,直接拉长
183
- * makespan。rank 回答的正是「先做它能解开多少后续工作」,降序取就是经典 HEFT 的
184
- * upward-rank 优先。
185
- *
186
- * 只数**未终结**的后继:completed/failed/cancelled 不再产生剩余工作量,把它们算进去
187
- * 会让早已走完的分支虚高。无权重(每节点算 1):引擎没有工期估计,节点数是唯一可得
188
- * 且稳定的代价度量。环(只可能出现在状态损坏时)按 0 截断该分支,绝不无限递归。
189
- * @returns Map<taskId, rank>;叶子为 1,越大越关键。
190
- */
191
- export function computeRemainingRanks(tasks) {
192
- const byId = new Map(tasks.map(task => [task.id, task]));
193
- const successors = new Map(tasks.map(task => [task.id, []]));
194
- for (const task of tasks) {
195
- for (const dependency of task.dependencies ?? [])
196
- successors.get(dependency)?.push(task.id);
197
- }
198
- const ranks = new Map();
199
- const visiting = new Set();
200
- const rankOf = (id) => {
201
- const cached = ranks.get(id);
202
- if (cached !== undefined)
203
- return cached;
204
- if (visiting.has(id))
205
- return 0; // 状态损坏出现环:这一支就此停住,不参与比较
206
- visiting.add(id);
207
- let deepest = 0;
208
- for (const childId of successors.get(id) ?? []) {
209
- const child = byId.get(childId);
210
- if (child === undefined || TERMINAL_TASK_STATUSES.includes(child.status))
211
- continue;
212
- deepest = Math.max(deepest, rankOf(childId));
213
- }
214
- visiting.delete(id);
215
- const rank = deepest + 1;
216
- ranks.set(id, rank);
217
- return rank;
218
- };
219
- for (const task of tasks)
220
- rankOf(task.id);
221
- return ranks;
222
- }
223
- /**
224
- * 亲和度词元:ASCII 词(≥3 字符)+ CJK 二元组。
225
- *
226
- * 中文没有分词,二元组是稳定又便宜的近似(「数据库性能工程师」→ 数据/据库/库性/性能/…);
227
- * ASCII 那一半来自专家 slug(`engineering-frontend-developer` → engineering/frontend/
228
- * developer),是更精确的一半。两者都只用现成字段,不需要给 48 支小队补任何声明。
229
- */
230
- export function affinityTokens(text) {
231
- const tokens = new Set();
232
- const lower = String(text ?? '').toLowerCase();
233
- for (const word of lower.match(/[a-z][a-z0-9]{2,}/gu) ?? [])
234
- tokens.add(word);
235
- for (const run of lower.replace(/[^\u4e00-\u9fff]+/gu, ' ').split(' ')) {
236
- for (let index = 0; index + 2 <= run.length; index += 1)
237
- tokens.add(run.slice(index, index + 2));
238
- }
239
- return tokens;
240
- }
241
- /** 成员画像词元:中文名 + 专家 slug(`member.role` 存的就是 slug)。 */
242
- export function memberAffinityTokens(member) {
243
- return affinityTokens(`${member?.name ?? ''} ${member?.role ?? ''}`);
244
- }
245
- /** 任务文本与成员画像的重合词元数(0 = 看不出关系)。 */
246
- export function taskAffinity(task, memberTokens) {
247
- if (memberTokens.size === 0)
248
- return 0;
249
- const text = [task?.subject, task?.objective, task?.description]
250
- .filter(part => typeof part === 'string' && part !== '')
251
- .join(' ');
252
- let score = 0;
253
- for (const token of affinityTokens(text)) {
254
- if (memberTokens.has(token))
255
- score += 1;
256
- }
257
- return score;
258
- }
259
- /**
260
- * 给一个空闲成员挑下一条任务。
261
- *
262
- * 排序键(依次):
263
- * 1. **剩余关键路径降序** —— makespan 优先,永远不为了「派得更对口」牺牲关键路径;
264
- * 2. **成员亲和度降序** —— 只在同等关键度内起作用。小队成员是**异构领域专家**,
265
- * 而共享池过去完全不看这一点(`member.role` 全项目只用在成员自己的人格提示里),
266
- * 于是安全任务可能落到设计师头上。亲和度做**平级决胜**而不是硬过滤:既拿到专业
267
- * 匹配的收益,又不可能把任务饿死,也不可能比旧行为更差;
268
- * 3. **创建顺序** —— 前两项相同时保持稳定、可预测。
269
- *
270
- * 导出仅为让自检能直接钉住这三条排序键(与本文件 `assignmentPrompt` 等同样的做法):
271
- * 这是**行为契约**,回退成旧的 `ready.find` 不会让任何文本断言变红。
272
- *
273
- * @param tasks 团队全部任务(rank 需要全图,不只是就绪集)
274
- * @param member 当前空闲成员记录(要用到 `name` 与 `role`)
275
- */
276
- export function nextReadyTask(tasks, member) {
277
- const memberName = member.name;
278
- const ready = tasks.filter(task => task.status === 'pending'
279
- && task.reassigning !== true
280
- && unsatisfiedDependencies([...tasks], task.dependencies).length === 0);
281
- if (ready.length === 0)
282
- return undefined;
283
- const ranks = computeRemainingRanks(tasks);
284
- const memberTokens = memberAffinityTokens(member);
285
- const position = new Map(tasks.map((task, index) => [task.id, index]));
286
- // 亲和度**每条任务只算一次**:它要把任务文本分词,而比较器会被调用 O(n log n) 次 ——
287
- // 写在比较器里等于把同一段文本反复分词几百遍(本可忽略,但没有任何理由这么做)。
288
- const affinity = new Map();
289
- const affinityOf = (task) => {
290
- let score = affinity.get(task.id);
291
- if (score === undefined) {
292
- score = taskAffinity(task, memberTokens);
293
- affinity.set(task.id, score);
294
- }
295
- return score;
296
- };
297
- const best = (candidates) => candidates
298
- .slice()
299
- .sort((left, right) => (ranks.get(right.id) ?? 0) - (ranks.get(left.id) ?? 0)
300
- || affinityOf(right) - affinityOf(left)
301
- || (position.get(left.id) ?? 0) - (position.get(right.id) ?? 0))
302
- .at(0);
303
- const assigned = ready.filter(task => task.assignee === memberName);
304
- if (assigned.length > 0)
305
- return best(assigned);
306
- // 审计修复(#5 调度侧):共享池里的 review 任务不得分给被审任务的实现者本人
307
- // (自审)。显式 assignee 已在创建/reassign 两道闸门挡住,这里挡住「调度器自动
308
- // 把 review 派回实现者」这条剩余路径。
309
- const pool = ready.filter(task => {
310
- if (task.assignee !== undefined)
311
- return false;
312
- if ((task.kind ?? 'work') !== 'review')
313
- return true;
314
- const reviewed = tasks.find(candidate => candidate.id === task.reviewedTaskId);
315
- return reviewed?.assignee !== memberName;
316
- });
317
- return pool.length === 0 ? undefined : best(pool);
318
- }
319
- export function assignmentPrompt(ticket, stateDir, teamId) {
320
- const description = ticket.description === undefined ? '' : `\n\n${ticket.description}`;
321
- const seed = ticket.profileSeedId === undefined ? '' : ` [${ticket.profileSeedId}]`;
322
- const goal = ticket.teamDescription?.trim() || '(not provided)';
323
- const protocol = ticket.profileProtocol?.trim() || '(none)';
324
- const executionPrompt = ticket.executionPrompt?.trim();
325
- const kind = ticket.kind?.trim() || 'work';
326
- const contract = [
327
- `Kind: ${kind}${ticket.round === undefined ? '' : ` (round ${ticket.round})`}`,
328
- ticket.objective === undefined || ticket.objective === '' ? '' : `Objective: ${ticket.objective}`,
329
- ticket.inScope === undefined || ticket.inScope.length === 0 ? '' : `In scope: ${ticket.inScope.join(', ')}`,
330
- ticket.outOfScope === undefined || ticket.outOfScope.length === 0 ? '' : `Out of scope: ${ticket.outOfScope.join(', ')}`,
331
- ticket.acceptance === undefined || ticket.acceptance.length === 0 ? '' : `Acceptance: ${ticket.acceptance.join('; ')}`,
332
- ticket.verify === undefined || ticket.verify.length === 0 ? '' : `Verify: ${ticket.verify.join('; ')}`,
333
- ticket.reviewedTaskId === undefined ? '' : `Reviewed task: ${ticket.reviewedTaskId}`,
334
- ].filter((line) => line !== '').join('\n');
335
- const structuredCompletion = ['implementation', 'repair', 'verification', 'integration'].includes(kind)
336
- ? `
337
- Structured completion payload (keep these arrays in contract order):
338
- acceptanceResults: ${JSON.stringify((ticket.acceptance ?? []).map((criterion) => ({ criterion, status: 'passed', evidence: '<what proved it>' })))}
339
- commandsRun: ${JSON.stringify((ticket.verify ?? []).map((command) => ({ command, status: 'passed', exitCode: 0, evidence: '<observed result>' })))}
340
- ${kind === 'implementation' || kind === 'repair' ? 'changedPaths: list the actual workspace-relative POSIX paths you changed.\n' : ''}`
341
- : '';
342
- return `T Team automatic task assignment from the shared task list.
343
-
344
- You are executing as configured member "${ticket.memberName}".
345
- Do not start a teammate's assigned task.
346
-
347
- Team goal:
348
- ${goal}
349
-
350
- Profile protocol:
351
- ${protocol}
352
- ${executionPrompt === undefined || executionPrompt === '' ? '' : `
353
- Execution guidance:
354
- ${executionPrompt}
355
- `}
356
- Completed dependency results:
357
- ${formatDependencyOutputs(ticket.dependencyOutputs)}
358
- ${formatInFlightSiblings(ticket.inFlightSiblings)}
359
- Task: ${ticket.taskId}${seed} — ${ticket.subject}${description}
360
- ${contract === '' ? '' : `\nContract:\n${contract}\n`}
361
- ${structuredCompletion}
362
- Attempt: ${ticket.attempt}
363
- Attempt id: ${ticket.attemptId}
364
-
365
- Call t_team_claim_task for ${ticket.taskId}; it will return this same attempt_id. Include attempt_id=${ticket.attemptId} in every t_team_update_task call. If it is rejected as stale, stop work because the task was reassigned. claimed cannot jump to completed. Mark in_progress first, then completed or failed. Include attempt_id on every update, then become idle.
366
-
367
- Write the report exactly once. t_team_update_task IS your report: the captain and every downstream task read output/summary/tried from it. After that update succeeds:
368
- - send_message to the captain with a ONE-LINE notice ("${ticket.taskId} completed — result in the task output"). That line is what wakes the captain; it must not restate the report.
369
- - Keep your closing turn message to ONE line too. It is delivered to the captain in full, so a write-up there is a third copy of the same text.
370
- Everything the captain needs is already in the task record. A completion written out three times costs three times the context and says nothing new.
371
- When finishing: use status=completed only when the task's success criteria are satisfied; use status=failed when blocking findings or validation failures mean downstream work must not proceed; include a concise output in either case. Always also send summary (the one-paragraph conclusion downstream tasks need, plus any assumption you had to make) and, whenever you hit dead ends, tried (each failed approach with why it failed) — tasks that depend on you transitively receive the summary instead of your full output, and tried is what stops them from repeating your dead ends. Quality kinds must submit structured fields: review/requirements need verdict=pass to complete (needs_revision/reject must fail with findings); implementation/repair/verification/integration need acceptanceResults and commandsRun, while implementation/repair also need in-scope changedPaths. Use status values "passed" or "failed" inside those arrays. After the work and verification finish, call t_team_update_task immediately; do not wait for captain confirmation and do not continue exploring. Do not approve your own implementation. Mail is not a formal next review. Treat the dependency results above as source material. Do not ignore them. Work only this task and only its in-scope paths in this turn.
372
-
373
- State policy: ${stateDir}/${teamId}/ is read-only diagnostics; mutate team state only through t_team_* tools.`;
374
- }
375
- function fallbackMailboxPrompt(messages) {
376
- return [
377
- 'T Team delivered messages that were persisted while live delivery was unavailable:',
378
- ...messages.map(message => `\nFrom ${message.from}:\n${message.content}`),
379
- '\nHandle these messages in this turn. Task assignments still require t_team_claim_task and the current attempt_id.',
380
- ].join('\n');
381
- }
382
- /** Install one scheduler and its member activity observer. */
383
- export function installTeamScheduler(ctx, config) {
384
- const memberQueues = new Map();
385
- // An idle edge in this process proves that the resident member ended its
386
- // turn while the current attempt was still open. Remember that capability
387
- // even after Harness disposes the continuable AgentHandle: later status or
388
- // graph kicks must keep it parked. A cold process starts with an empty map,
389
- // so durable open attempts are still recovered after restart.
390
- const parkedAttempts = new Map();
391
- const memberQueueKey = (stateRoot, teamId, memberName) => (`${stateRoot}\u0000${teamId}\u0000${memberName}`);
392
- const serializeMember = async (key, operation) => {
393
- const previous = memberQueues.get(key) ?? Promise.resolve();
394
- let release;
395
- const gate = new Promise((resolve) => { release = resolve; });
396
- const tail = previous.then(() => gate);
397
- memberQueues.set(key, tail);
398
- await previous;
399
- try {
400
- return await operation();
401
- }
402
- finally {
403
- release();
404
- if (memberQueues.get(key) === tail)
405
- memberQueues.delete(key);
406
- }
407
- };
408
- const runtime = {
409
- async kickTeam(workspace, teamId, suppliedCaptain) {
410
- const stateRoot = stateRootOf(workspace, config);
411
- const team = await readTeam(stateRoot, teamId);
412
- if (team === undefined || team.halted === true || team.phase === 'staged')
413
- return;
414
- const captain = liveCaptain(ctx, team.captainSessionId, suppliedCaptain);
415
- if (captain === undefined)
416
- return;
417
- for (const member of team.members) {
418
- if (member.status === 'removed')
419
- continue;
420
- await runtime.kickMember(workspace, teamId, member.name, captain);
421
- }
422
- },
423
- async kickMember(workspace, teamId, memberName, suppliedCaptain) {
424
- const stateRoot = stateRootOf(workspace, config);
425
- const queueKey = memberQueueKey(stateRoot, teamId, memberName);
426
- await serializeMember(queueKey, async () => {
427
- let team = await readTeam(stateRoot, teamId);
428
- if (team === undefined || team.halted === true || team.phase === 'staged')
429
- return;
430
- const captain = liveCaptain(ctx, team.captainSessionId, suppliedCaptain);
431
- if (captain === undefined)
432
- return;
433
- let member = team.members.find(candidate => candidate.name === memberName && candidate.status !== 'removed');
434
- if (member === undefined || member.id === '' || !isMemberAvailable(ctx, member))
435
- return;
436
- // A mailbox-only fallback is real pending work. Deliver it before a
437
- // fresh task and acknowledge only after Harness accepts the follow-up.
438
- const unread = await readUnreadMailbox(stateRoot, team.id, member.name);
439
- if (unread.length > 0) {
440
- await withTeamLock(teamLockKey(stateRoot, team.id), () => (claimMailboxDelivery(stateRoot, team.id, member.name, unread.map(message => message.id))));
441
- const accepted = await deliverToMember(ctx, captain, member.id, fallbackMailboxPrompt(unread), new AbortController().signal);
442
- if (accepted) {
443
- await withTeamLock(teamLockKey(stateRoot, team.id), () => (acknowledgeMailbox(stateRoot, team.id, member.name, unread.map(message => message.id))));
444
- }
445
- else {
446
- await withTeamLock(teamLockKey(stateRoot, team.id), () => (releaseMailboxDelivery(stateRoot, team.id, member.name, unread.map(message => message.id))));
447
- }
448
- return;
449
- }
450
- const ticket = await withTeamLock(teamLockKey(stateRoot, team.id), async () => {
451
- const fresh = await readTeam(stateRoot, team.id);
452
- if (fresh === undefined || fresh.halted === true || fresh.phase === 'staged')
453
- return undefined;
454
- const currentMember = fresh.members.find(candidate => candidate.name === memberName && candidate.status !== 'removed');
455
- if (currentMember === undefined || currentMember.id === '' || !isMemberAvailable(ctx, currentMember))
456
- return undefined;
457
- const owned = ownedOpenTask(fresh.tasks, currentMember.name);
458
- // An idle edge observed by this scheduler parks the exact open
459
- // capability. Harness may dispose its AgentHandle after settlement,
460
- // so registry absence is not evidence that the owner was lost. Keep
461
- // the marker sticky across later kicks; only a durable attempt that
462
- // this process has not observed is eligible for one cold recovery.
463
- const parkedAttemptId = parkedAttempts.get(currentMember.id);
464
- const recoverOwned = owned !== undefined
465
- && (owned.attemptId === undefined || owned.attemptId !== parkedAttemptId);
466
- const task = recoverOwned ? owned : owned === undefined
467
- ? nextReadyTask(fresh.tasks, currentMember)
468
- : undefined;
469
- if (task === undefined) {
470
- if (currentMember.status !== 'idle') {
471
- currentMember.status = 'idle';
472
- await writeTeam(stateRoot, fresh);
473
- }
474
- return undefined;
475
- }
476
- const previousAssignee = task.assignee;
477
- const previousStatus = recoverOwned ? task.status : undefined;
478
- const previousAttempt = recoverOwned ? task.attempt : undefined;
479
- const previousAttemptId = recoverOwned ? task.attemptId : undefined;
480
- const attemptId = beginTaskAttempt(task, currentMember.name);
481
- // A recovered generation is parked before delivery. This makes each
482
- // (member, attempt) recovery idempotent even if every status poll
483
- // sees a disposed handle. Fresh pending work remains unparked so a
484
- // genuinely lost first delivery can be recovered once.
485
- if (recoverOwned)
486
- parkedAttempts.set(currentMember.id, attemptId);
487
- else
488
- parkedAttempts.delete(currentMember.id);
489
- currentMember.status = 'working';
490
- await writeTeam(stateRoot, fresh);
491
- const profileSeedId = taskProfileSeedId(task);
492
- const protocol = teamProfileProtocol(fresh);
493
- return {
494
- taskId: task.id,
495
- memberName: currentMember.name,
496
- memberId: currentMember.id,
497
- attempt: task.attempt ?? 1,
498
- attemptId,
499
- previousAssignee,
500
- recoveredOwned: recoverOwned,
501
- ...previousStatus === undefined ? {} : { previousStatus },
502
- ...previousAttempt === undefined ? {} : { previousAttempt },
503
- ...previousAttemptId === undefined ? {} : { previousAttemptId },
504
- subject: task.subject,
505
- description: task.description,
506
- teamDescription: fresh.description,
507
- ...protocol === undefined ? {} : { profileProtocol: protocol },
508
- ...profileSeedId === undefined ? {} : { profileSeedId },
509
- ...fresh.profile?.executionPrompt === undefined && config.executionPrompt === undefined
510
- ? {}
511
- : { executionPrompt: fresh.profile?.executionPrompt ?? config.executionPrompt },
512
- kind: task.kind ?? 'work',
513
- ...task.round === undefined ? {} : { round: task.round },
514
- ...task.objective === undefined ? {} : { objective: task.objective },
515
- ...task.inScope === undefined ? {} : { inScope: task.inScope },
516
- ...task.outOfScope === undefined ? {} : { outOfScope: task.outOfScope },
517
- ...task.acceptance === undefined ? {} : { acceptance: task.acceptance },
518
- ...task.verify === undefined ? {} : { verify: task.verify },
519
- ...task.reviewedTaskId === undefined ? {} : { reviewedTaskId: task.reviewedTaskId },
520
- dependencyOutputs: collectCompletedDependencyOutputs(fresh.tasks, task.id, (message) => ctx.logger.warn(message)),
521
- inFlightSiblings: collectInFlightSiblings(fresh.tasks, task.id),
522
- };
523
- });
524
- if (ticket === undefined)
525
- return;
526
- const accepted = await deliverToMember(ctx, captain, ticket.memberId, assignmentPrompt(ticket, config.stateDir, team.id), new AbortController().signal);
527
- if (accepted)
528
- return { dispatched: true };
529
- // Roll back only our exact failed dispatch. A concurrent captain
530
- // handoff has already changed the capability and wins.
531
- await withTeamLock(teamLockKey(stateRoot, team.id), async () => {
532
- const fresh = await readTeam(stateRoot, team.id);
533
- if (fresh === undefined)
534
- return;
535
- const task = fresh.tasks.find(candidate => candidate.id === ticket.taskId);
536
- if (task?.attemptId !== ticket.attemptId)
537
- return;
538
- if (ticket.recoveredOwned && ticket.previousStatus !== undefined && ticket.previousAttemptId !== undefined) {
539
- // Recovery delivery failed. Restore the durable generation instead
540
- // of returning it to pending, then keep it parked so later status
541
- // kicks cannot spend an unbounded sequence of fresh attempts.
542
- task.status = ticket.previousStatus;
543
- task.assignee = ticket.previousAssignee;
544
- task.attempt = ticket.previousAttempt;
545
- task.attemptId = ticket.previousAttemptId;
546
- parkedAttempts.set(ticket.memberId, ticket.previousAttemptId);
547
- }
548
- else {
549
- task.status = 'pending';
550
- task.assignee = ticket.previousAssignee;
551
- task.attemptId = undefined;
552
- parkedAttempts.delete(ticket.memberId);
553
- }
554
- task.handoffId = undefined;
555
- task.reassigning = false;
556
- task.updatedAt = Date.now();
557
- const currentMember = fresh.members.find(candidate => candidate.name === ticket.memberName);
558
- if (currentMember !== undefined && currentMember.status !== 'removed')
559
- currentMember.status = 'idle';
560
- await writeTeam(stateRoot, fresh);
561
- });
562
- // 审计修复(#8):把投递失败传回调用方(同步的 create/reassign 会把它带进工具
563
- // 结果),而不是只让任务静默回滚成 pending。后台 idle-edge 调用方忽略该返回值。
564
- return { dispatched: false, deliveryError: 'prompt delivery to member failed; see host logs' };
565
- });
566
- },
567
- };
568
- const syncMemberStatus = async (agent, status) => {
569
- const workspace = agent.session.header.cwd ?? process.cwd();
570
- const stateRoot = stateRootOf(workspace, config);
571
- const located = await findTeamByParticipant(stateRoot, agent.id);
572
- if (located === undefined) {
573
- parkedAttempts.delete(agent.id);
574
- return;
575
- }
576
- if (located.captainSessionId === agent.id) {
577
- // Captain takeover is scoped to the captain's current turn. Unlike a
578
- // durable member, the captain has no scheduler lane that can resume an
579
- // abandoned attempt later. Returning unfinished captain-owned work to
580
- // the shared pool on the idle edge prevents it from becoming a
581
- // permanently parked `claimed` task after the captain answers, is
582
- // interrupted, or the user switches conversations.
583
- if (status === 'running')
584
- return;
585
- let requeued = false;
586
- await withTeamLock(teamLockKey(stateRoot, located.id), async () => {
587
- const fresh = await readTeam(stateRoot, located.id);
588
- if (fresh === undefined || fresh.captainSessionId !== agent.id)
589
- return;
590
- for (const task of fresh.tasks) {
591
- if (task.assignee !== CAPTAIN_KEY
592
- || task.status === 'completed'
593
- || task.status === 'failed'
594
- || task.status === 'cancelled')
595
- continue;
596
- invalidateTaskAttempt(task);
597
- task.reassigning = false;
598
- requeued = true;
599
- }
600
- if (requeued)
601
- await writeTeam(stateRoot, fresh);
602
- });
603
- if (requeued)
604
- await runtime.kickTeam(workspace, located.id, agent);
605
- return;
606
- }
607
- const member = located.members.find(candidate => candidate.id === agent.id && candidate.status !== 'removed');
608
- if (member === undefined) {
609
- parkedAttempts.delete(agent.id);
610
- return;
611
- }
612
- await withTeamLock(teamLockKey(stateRoot, located.id), async () => {
613
- const fresh = await readTeam(stateRoot, located.id);
614
- const current = fresh?.members.find(candidate => candidate.id === agent.id && candidate.status !== 'removed');
615
- if (fresh === undefined || current === undefined)
616
- return;
617
- const next = status === 'running' ? 'working' : 'idle';
618
- if (next === 'idle') {
619
- const owned = ownedOpenTask(fresh.tasks, current.name);
620
- if (owned?.attemptId === undefined)
621
- parkedAttempts.delete(agent.id);
622
- else
623
- parkedAttempts.set(agent.id, owned.attemptId);
624
- }
625
- else {
626
- parkedAttempts.delete(agent.id);
627
- }
628
- if (current.status === next)
629
- return;
630
- current.status = next;
631
- await writeTeam(stateRoot, fresh);
632
- });
633
- if (status === 'idle')
634
- await runtime.kickMember(workspace, located.id, member.name);
635
- };
636
- ctx.on('agent/status', ({ agent, status }) => {
637
- void syncMemberStatus(agent, status).catch((error) => {
638
- ctx.logger.warn(`t-team: member status scheduling failed for ${agent.id}: ${String(error)}`);
639
- });
640
- });
641
- return runtime;
642
- }