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
+38 -19
View File
@@ -12,8 +12,6 @@ import type {
SidebarCreateTeamTaskRequest,
SidebarChildLiveView,
SidebarSessionEvent,
SidebarTeamMemberView,
SidebarTeamTaskMutationResult,
SidebarTeamTaskView,
SidebarUpdateTeamTaskRequest,
} from '../context-types.ts'
@@ -109,19 +107,24 @@ export type SubagentLiveResult = { live: Record<string, SidebarChildLiveView> }
/** The `workflows.list` response: the tree's folded workflow runs. */
export type WorkflowsListResult = { runs: WorkflowRunView[] }
/** The `teams.view` response (structural degradation contract). */
export type TeamsViewResult =
| { available: false }
| { available: true; team: { members: SidebarTeamMemberView[]; tasks: SidebarTeamTaskView[] } | null }
/** The `teams.taskCreate` request payload (minus the rootSessionId). */
export type TeamsTaskCreateRequest = SidebarCreateTeamTaskRequest
/** The `teams.taskUpdate` request payload (minus the rootSessionId). */
export type TeamsTaskUpdateRequest = SidebarUpdateTeamTaskRequest
/** The task-mutation result union (CAS conflict stays distinct). */
export type TeamsTaskMutationResult = SidebarTeamTaskMutationResult
/**
* One team-task write as CALLERS consume it. The board's READ path has no
* route at all (it rides the Lead Session's `agentTeam` projection, see
* team-projection.ts), and a rejected write is a RESULT rather than an
* exception: the route answers 409 `team-conflict` for a stale revision and
* 400 `team-error` otherwise, {@link writeTask} turns that failed envelope
* back into `{ok:false, code, message}`, and the task window routes on the
* code (a stale revision deserves its own wording, not "operation failed").
*/
export type TeamsTaskMutationResult =
| { ok: true; value: SidebarTeamTaskView }
| { ok: false; code?: string; message: string }
/**
* Parse one `/sidebar` JSON response envelope into its value. A non-ok
@@ -157,6 +160,29 @@ async function call<T>(method: string, payload: Record<string, unknown>, signal?
return readEnvelope<T>(response)
}
/**
* One team-task write with the rejection turned back into a RESULT. Every
* other `/sidebar/api` caller lets a failed envelope throw (that is what
* {@link readEnvelope} is for); here the route's error CODE is data the task
* window needs — `team-conflict` means "someone changed this task, refresh",
* which is a different sentence from every other failure.
* @param method - `teams.taskCreate` or `teams.taskUpdate`.
* @param payload - the request plus its `rootSessionId`.
*/
async function writeTask(
method: string,
payload: Record<string, unknown>,
): Promise<TeamsTaskMutationResult> {
try {
return { ok: true, value: await call<SidebarTeamTaskView>(method, payload) }
} catch (error) {
if (error instanceof SidebarApiError) {
return { ok: false, code: error.code, message: error.message }
}
throw error
}
}
/**
* Upload one file to the sidebar's raw upload route: the File goes straight
* into the POST body (no JSON/base64 re-encoding — the host streams it into
@@ -348,19 +374,12 @@ export const api = {
*/
workflowsList: (rootSessionId: string, signal?: AbortSignal) =>
call<WorkflowsListResult>('workflows.list', { rootSessionId }, signal),
/**
* The team led by the tree's root agent (experimental Agent Teams layer):
* `{available:false}` hides the whole block; `{team:null}` means the root
* leads no team. Poll-driven (the official panel is manual-refresh too).
*/
teamsView: (rootSessionId: string, signal?: AbortSignal) =>
call<TeamsViewResult>('teams.view', { rootSessionId }, signal),
/** Create one shared task on the root-led team. */
teamsTaskCreate: (rootSessionId: string, req: TeamsTaskCreateRequest) =>
call<TeamsTaskMutationResult>('teams.taskCreate', { rootSessionId, ...req }),
/** CAS-mutate one shared task; conflicts ride the returned union. */
writeTask('teams.taskCreate', { rootSessionId, ...req }),
/** CAS-mutate one shared task; a stale revision yields `team-conflict`. */
teamsTaskUpdate: (rootSessionId: string, req: TeamsTaskUpdateRequest) =>
call<TeamsTaskMutationResult>('teams.taskUpdate', { rootSessionId, ...req }),
writeTask('teams.taskUpdate', { rootSessionId, ...req }),
/** Create a Side Chat thread: a child session seeded with the parent's
* full log up to now. Empty question = immediate create (Codex-style):
* the thread opens empty, the first prompt carries the boundary. */