feat(tasks): 适配 DSH 0.1.7 的 Agent Teams(读 projection + 写路由对接真实服务)

团队功能在 0.1.7 上整条失效:team-routes.ts 当初镜像的是 0.1.6 的 Remote 词汇
(remoteView / remoteCreateTask / remoteUpdateTask),而 0.1.7 把这三个方法删掉了,
读路径改到 Lead Session 的 `agentTeam` Session projection。真机 3080 复现:
teams.view 对带团队的会话返回 400 "membership.svc.remoteView is not a function",
于是 TeamBoard 从不渲染、节点富化与任务窗连带失效。

- 读:删掉 teams.view 与 5s 轮询,改读 SessionListState.projectionsBySession[root]
  .values.agentTeam(与 subagentCatalog 同源,推送式);新增 client/team-projection.ts
  做纯适配器,成员运行状态由 subagents.live 的 running 叠加、Lead 回退 session list
  (官方 README 的 "overlays activity from Session status")。projection 缺席 =
  这里没有团队,与旧的 {available:false}/{team:null} 合并成同一静默降级。
- 写:taskCreate/taskUpdate 改调 0.1.7 的 createTask/updateTask(返回已提交的 view,
  拒绝改为抛 TeamError),TEAM_TASK_STALE_REVISION → 409 team-conflict,其余 → 400
  team-error(wire 新增 team-conflict)。api.ts 的 writeTask 把失败信封还原成
  {ok:false,code,message} —— 原先窗口永远走不到该分支(readEnvelope 是抛)。
- 任务窗:外壳换成上一轮的 FloatingWindow(可拖拽/可拉伸/只在关闭或 Esc 时消失),
  动作行下沉到 shell 的 footer,长描述不再把动作推出视野。
- 真机抓到的两个单测抓不到的问题:
  1. 队列中任务的「完成」被宿主状态机拒绝(pending → claim → in_progress →
     complete → completed → reopen)→ 动作行跟随状态,pending 出「认领」;
  2. 认领后 Tag 变「阻塞」—— ready 的语义只是「pending 且无未完成 blocker」,
     对所有非 pending 任务都是 false → 抽出唯一规则 taskBlocked/taskStatusLabel/
     taskTone/taskDotState,四处展示点全部改走它。
- i18n:新增 teamTaskClaim / taskWindowCreate / taskWindowDetail /
  tasksStateProvisioning 词条 ×20 词典。

验证:typecheck / lint 通过;pnpm test 1276 passed / 9 skipped;
pnpm test:mount 7/7(真实 DSH 0.1.7-rc.2 挂载 + tab 全扫无崩溃)。
真机 3080(0.1.7-rc.1 + Agent Teams 实验层):看板从 projection 渲染、
队列任务「认领」→「完成」全链路写通(负责人 lead / 进行中 / 已完成)、
外部点击不关窗、Escape 关窗,截图见 docs/plans/2026-09-27-agent-teams-dsh-0.1.7-adaptation.md。

(cherry picked from commit dbc524e0b350c5d3564843f4776cd15d797506d8)
This commit is contained in:
Menghuan1918
2026-09-27 22:19:05 +08:00
parent 421fd3fde7
commit d8bc6566df
40 changed files with 1352 additions and 576 deletions
+59 -13
View File
@@ -48,19 +48,58 @@ export function stateLabel(state: TasksNodeState): string {
return t(key)
}
/**
* The two facts every task display rule reads. Deliberately NARROWER than
* either carrier (`TasksNodeTask` on the graph, `SidebarTeamTaskView` in the
* board and the task window) so one rule serves all of them — the window sees
* a `deleted` status the graph never draws, and only the shared rule needs to
* know that a deleted task is not "blocked".
*/
export interface TaskStatusFacts {
status: 'pending' | 'in_progress' | 'completed' | 'deleted'
ready: boolean
}
/** The task status label key. */
export function taskStatusKey(status: TasksNodeTask['status']): CopyKey {
export function taskStatusKey(status: TaskStatusFacts['status']): CopyKey {
switch (status) {
case 'pending': return 'teamTaskPending'
case 'in_progress': return 'teamTaskInProgress'
case 'completed': return 'teamTaskCompleted'
case 'deleted': return 'teamTaskDeleted'
}
}
/**
* Whether a shared task is HELD UP by its blockers.
*
* The service's `ready` flag means exactly one thing: "a pending task whose
* every blocker completed, so it can be CLAIMED". It is therefore false for
* every task that is not queued — which makes `!ready` the wrong test for
* "blocked": a claimed task (in_progress) and a finished one both report
* `ready: false`, and reading the flag alone labelled a task that is actively
* being worked on as 阻塞 (reproduced on the real board: claiming a task made
* its Tag flip from 待办 to 阻塞).
*/
export function taskBlocked(task: TaskStatusFacts): boolean {
return task.status === 'pending' && !task.ready
}
/** The status word of a task row: its own status, or 阻塞 when held up. */
export function taskStatusLabel(task: TaskStatusFacts): string {
return t(taskBlocked(task) ? 'teamTaskBlocked' : taskStatusKey(task.status))
}
/** The tone of a task's status Tag (or dot) — the same three-way rule. */
export function taskTone(task: TaskStatusFacts): 'success' | 'info' | 'warning' {
if (task.status === 'completed') return 'success'
return taskBlocked(task) ? 'warning' : 'info'
}
/** The host StateDot semantic of a task row/line. */
export function taskDotState(task: TasksNodeTask): StateDotState {
export function taskDotState(task: TaskStatusFacts): StateDotState {
if (task.status === 'completed') return 'done'
return task.ready ? 'ongoing' : 'warning'
return taskBlocked(task) ? 'warning' : 'ongoing'
}
/** First `limit` characters with an ellipsis when truncated. */
@@ -108,17 +147,17 @@ export function modeLabel(mode: TasksAgentNode['mode']): string | undefined {
/**
* The mono meta line of an agent card — at most two short tokens:
* - the topology root: 主代理 · 状态
* - a teammate with a known model: 模型 · 状态
* - any other agent: 模式 · 状态
* - any other agent: 模式 · 状态 (a teammate without a catalog mode falls back
* to its team role, the one durable identity the roster still carries — the
* `agentTeam` projection has no model field to print, see team-projection.ts)
*/
export function agentMeta(node: TasksAgentNode): string {
const state = stateLabel(node.state)
if (node.parentId === undefined) return `${t('subagentMainAgent')} · ${state}`
if (node.team?.role === 'teammate' && node.team.model !== undefined && node.team.model !== '') {
return `${node.team.model} · ${state}`
}
const mode = modeLabel(node.mode)
return mode === undefined ? state : `${mode} · ${state}`
if (mode !== undefined) return `${mode} · ${state}`
const fallback = node.team === undefined ? undefined : teamRoleLabel(node.team)
return fallback === undefined ? state : `${fallback} · ${state}`
}
/**
@@ -133,10 +172,17 @@ export function agentMeta(node: TasksAgentNode): string {
export function agentIdentity(node: TasksAgentNode): string {
// The root's badge already reads 主代理: an identity line would repeat it.
if (node.parentId === undefined) return ''
if (node.team?.role === 'teammate' && node.team.model !== undefined && node.team.model !== '') {
return node.team.model
}
return modeLabel(node.mode) ?? ''
const mode = modeLabel(node.mode)
if (mode !== undefined) return mode
return node.team === undefined ? '' : teamRoleLabel(node.team) ?? ''
}
/** The roster row's own identity word (the fallback for a modelless teammate). */
function teamRoleLabel(team: NonNullable<TasksAgentNode['team']>): string | undefined {
if (team.role === 'lead') return undefined
if (team.phase === 'provisioning') return t('tasksStateProvisioning')
if (team.phase === 'failed') return t('tasksStateError')
return t('tasksKindTeammate')
}
/** The mono meta line of a workflow run card: status · member tally. */