Files
apes-sidebar/src/client/team-projection.ts
T
Menghuan1918 d8bc6566df 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)
2026-09-27 22:19:05 +08:00

123 lines
5.6 KiB
TypeScript

/**
* The plugin's view of DSH 0.1.7's `agentTeam` Session projection — the Lead
* Session's team board, read straight off the client snapshot.
*
* 0.1.6 exposed the board through the experimental service's Remote vocabulary
* (`agentTeams.remoteView`, reached over the wire) and the page had to poll it
* every 5s. 0.1.7 deleted that trio and publishes the same state as a Session
* projection instead: `SessionListState.projectionsBySession[leadId].values
* .agentTeam`, loaded once per connection like every other projection. This
* module is therefore a pure adapter — no request, no poller, no observer —
* exactly like ./subagent-catalog.ts for the catalog.
*
* Two facts the durable projection does not carry are DERIVED, not invented:
*
* - TURN ACTIVITY. A projection row is `{ id, name, role, phase, error? }`
* where `phase` is the durable lifecycle (`provisioning | active | failed`,
* the Lead row always `active`). Whether a member is running right now comes
* from the session's own status — upstream's own web UI "reads the shared
* Session projections and overlays activity from Session status" — and this
* plugin already has that overlay: `subagents.live` reports EVERY tree child
* with an explicit `running` flag (see ./subagent-catalog.ts's `childLive`),
* with the session list's own coarse `running` as the fallback for the Lead.
* - `diagnostics`, the 0.1.6 view's array of provisioning notes, collapses to
* the projection's single durable `error` string.
*
* What is genuinely gone is the member's `model` (and `description` /
* `provider` / `context`): the wire view never carried them, so the node
* detail no longer has a model row to show. See the design note
* docs/plans/2026-09-27-agent-teams-dsh-0.1.7-adaptation.md.
*/
import type {
SidebarChildLiveView,
SidebarSessionList,
SidebarSessionSummary,
SidebarTeamMemberProjection,
SidebarTeamProjection,
} from '../context-types.ts'
/**
* One roster row as this page consumes it: the projection's durable half plus
* the derived runtime status. A THIRD type next to the host service's
* `SidebarTeamMemberView` on purpose — that one is the 0.1.6 `listMembers`
* shape (status/description/provider/model) this plugin no longer reads, and
* conflating the two is exactly how the field drift that broke the board went
* unnoticed.
*/
export interface TeamMemberRow {
/** The member's session id (the teammate's child session under the lead). */
id: string
name: string
role: 'lead' | 'teammate'
/** Durable lifecycle, as the projection publishes it. */
phase: SidebarTeamMemberProjection['phase']
/** Derived display status: durable phase wins, else the live activity flag. */
status: 'running' | 'idle' | 'provisioning' | 'failed'
/** The durable provisioning error as a one-item list (see the header). */
diagnostics: string[]
}
/**
* The team board led by `rootId`, or undefined when that session leads none.
*
* ABSENT MEANS NO TEAM, and there are three ways to get there: the deployment
* never mounted the experimental layer (no projection key at all), the root is
* a teammate rather than the Lead, or the session simply has no team. All
* three collapse into "the page draws no Teams block" — the same silent
* degradation the 0.1.6 route expressed as `{available:false}` (layer absent)
* and `{team:null}` (root leads none), with one lookup instead of two states
* the page had to distinguish and never did.
*
* @param projections - `SessionListState.projectionsBySession`.
* @param rootId - the tree's root session (the only session that can be Lead).
*/
export function teamProjectionOf(
projections: SidebarSessionList['projectionsBySession'],
rootId: string | undefined,
): SidebarTeamProjection | undefined {
if (rootId === undefined) return undefined
return projections?.[rootId]?.values.agentTeam
}
/**
* Overlay runtime activity onto the durable roster.
*
* Status precedence is deliberately narrow: the durable phase wins for the two
* states it owns (`failed` never becomes "running" because a retry is in
* flight, `provisioning` is not yet a turn), and only an `active` member asks
* the live channel whether it is running right now. A member the channel does
* not mention yet (the first poll of a freshly rooted tree) reads as `idle`,
* which is the safe direction: it never claims work that is not happening.
*
* @param team - the Lead's projection ({@link teamProjectionOf}).
* @param live - the `subagents.live` map (`session id → live view`).
* @param byId - the session list by id (the Lead's own running flag lives here).
*/
export function teamMembersOf(
team: SidebarTeamProjection | undefined,
live: Readonly<Record<string, SidebarChildLiveView | undefined>>,
byId: Readonly<Record<string, SidebarSessionSummary | undefined>>,
): TeamMemberRow[] {
if (team === undefined) return []
return team.members.map(member => ({
id: member.id,
name: member.name,
role: member.role,
phase: member.phase,
status: memberStatus(member, live, byId),
diagnostics: member.error === undefined ? [] : [member.error],
}))
}
/** One member's runtime status (see {@link teamMembersOf} for the rule). */
function memberStatus(
member: SidebarTeamMemberProjection,
live: Readonly<Record<string, SidebarChildLiveView | undefined>>,
byId: Readonly<Record<string, SidebarSessionSummary | undefined>>,
): TeamMemberRow['status'] {
if (member.phase === 'failed') return 'failed'
if (member.phase === 'provisioning') return 'provisioning'
const running = live[member.id]?.running ?? byId[member.id]?.running
return running === true ? 'running' : 'idle'
}