From 6b7564606daba445013de40deca97bcdf635d96f Mon Sep 17 00:00:00 2001 From: Shaojin Wen Date: Sat, 10 Oct 2026 04:46:57 +0000 Subject: [PATCH] feat(managed-agent): H4e-a team record contract (#13811) * feat(managed-agent): H4e-a team record contract The record-contract half of slice H4e of #12827, tracked by #13745. H4e is split like H4d: the team domains land as records first, and the runtime follows as H4e-b, together with the close cascade under teams (#13745 E3) and the rest of what the H4a map gives H4e. Detach (#13745 E2) stays the separate Detach follow-up of the H4a map. The team domains are the durable form of the Legacy team model, not a parallel one. All four stay disabled for submission, and the Java store validates them ahead of any writer. - managed-team_state v1 (chain teamId): the roster and the lifecycle. Members are append-only, join one per revision and only while the team is active; membershipRevision is one more than the member count; the lifecycle runs active -> closing -> deleted one step at a time, with a purely logical run that is admitted until the team is deleted. - managed-team_task v1 (chain taskId): one board item with the Legacy fields and a team-unique number. in_progress needs an owner, deleted is terminal, and dependencies are stored once, as blockedBy, and only grow; whether one still blocks is read from the blocker's status. - managed-team_message v1 (chain messageId): one mailbox message to one recipient, shaped like the H4d-a outbox entry, with plain text or one of the six Legacy mailbox types, each with a fixed direction between the leader and a member. - managed-team_plan v1 (chain requestId): the approval state of one plan request, keyed by the D6 action that asks the leader; it waits, then settles with a decision or is cancelled when withdrawn or superseded. - Commit-time rules in the TS authority and the Java store, also checked when the authority rebuilds a log: the team is led by this Session; a member joins as a live child Session run of this Session that no other team lists; a task, message or plan request opens only in an active team, so a closing team drains; task numbers are unique per team, owners are the leader or members, and new dependencies stay in the team and close no cycle; messages travel between the leader and members and target the lead Session or the Session the member's run attached; plan requests come from members that require plan mode. Content references join the resource closure on both sides. - Shared fixtures managed-team-record/1 (125 cases, 57 successor pairs) replay in both languages; no team record projects a task. No migration and no public contract change. Design: docs/design/2026-10-10-managed-agent-teams.md (en + zh-CN); the H4, H4a and H4d-a designs point to it, and the H4 design's detach-owner question now points to the Detach follow-up instead of H4c. * fix(managed-agent): check a team message's parties on every revision Address the review on #13811. The authority and the store checked that a team message travels between the leader and members of its team only when the message opened, and the cross-record rules run before the chain rules. A successor that readdressed an existing message to a name outside the roster therefore reached the recipient lookup with no member: the TypeScript authority threw a TypeError and the Java store a NullPointerException (a 500) instead of the record conflict. Check the parties on every revision in both languages. The roster only grows and from/to are fixed, so a lawful successor always passes, and a readdressed one is now refused as the conflict it is, before anything looks its recipient up. Authority and store witnesses change an existing message's recipient to a stranger and assert the conflict, the rule's message and an unchanged committed revision. --- .../design/2026-10-04-managed-child-agents.md | 5 +- .../2026-10-04-managed-child-agents.zh-CN.md | 2 +- .../2026-10-06-managed-child-agent-runtime.md | 4 +- ...10-06-managed-child-agent-runtime.zh-CN.md | 4 +- .../2026-10-09-managed-session-messages.md | 2 +- ...26-10-09-managed-session-messages.zh-CN.md | 2 +- docs/design/2026-10-10-managed-agent-teams.md | 190 + .../2026-10-10-managed-agent-teams.zh-CN.md | 190 + ...aged-extension-projection-v1.fixtures.json | 6 +- .../managed-team-record-v1.fixtures.json | 3100 +++++++++++++++++ .../managed-extension-projection.ts | 50 + .../managed-session-authority.team.test.ts | 950 +++++ .../managed-session-authority.ts | 163 + .../managed-team-record.test.ts | 209 ++ .../managed-runtime/managed-team-record.ts | 738 ++++ .../store/ManagedExtensionProjection.java | 22 +- .../store/ManagedExtensionRecordStore.java | 186 + .../store/ManagedTeamRecords.java | 487 +++ .../ManagedTeamRecordContractTest.java | 160 + .../managedagent/ManagedTeamStoreTest.java | 455 +++ 20 files changed, 6915 insertions(+), 10 deletions(-) create mode 100644 docs/design/2026-10-10-managed-agent-teams.md create mode 100644 docs/design/2026-10-10-managed-agent-teams.zh-CN.md create mode 100644 packages/core/src/managed-runtime/contracts/managed-team-record-v1.fixtures.json create mode 100644 packages/core/src/managed-runtime/managed-session-authority.team.test.ts create mode 100644 packages/core/src/managed-runtime/managed-team-record.test.ts create mode 100644 packages/core/src/managed-runtime/managed-team-record.ts create mode 100644 packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/store/ManagedTeamRecords.java create mode 100644 packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/ManagedTeamRecordContractTest.java create mode 100644 packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/ManagedTeamStoreTest.java diff --git a/docs/design/2026-10-04-managed-child-agents.md b/docs/design/2026-10-04-managed-child-agents.md index f3ce05f791..a7c9bcb749 100644 --- a/docs/design/2026-10-04-managed-child-agents.md +++ b/docs/design/2026-10-04-managed-child-agents.md @@ -336,4 +336,7 @@ the `workflow` task kind; team domains and mailbox; cross-workspace children. task view may be required before H4c enables cancel at scale. 4. **Detached-child ownership.** What durable owner a detached child takes (a tenant-level retention policy, or the root Session authority) is left - to H4c; the close path only enforces that one exists. + to the Detach follow-up of the [H4a map](2026-10-06-managed-child-agent-runtime.md), + since H4c was re-scoped (see Scope settled above) and the + [H4e-a design](2026-10-10-managed-agent-teams.md) keeps detach out of H4e; + the close path only enforces that one exists. diff --git a/docs/design/2026-10-04-managed-child-agents.zh-CN.md b/docs/design/2026-10-04-managed-child-agents.zh-CN.md index 0743def06f..de2d27c227 100644 --- a/docs/design/2026-10-04-managed-child-agents.zh-CN.md +++ b/docs/design/2026-10-04-managed-child-agents.zh-CN.md @@ -102,4 +102,4 @@ Legacy 子 Agent 是进程内的:Agent 工具、默认后台的顶层子 Agent 1. **前台结果的边界。** 前台 child 的结果在父 Session 的工具结果中返回;该结果是否也必须按 O 系列切片规则转存为 Artifact、还是可以经工具结果存储内联传递,由 H4b 决定。 2. **消费事务的分组。** `consumed` 修订是在 `turn.settled` 事务中提交还是在之后的事务中提交,由 H4b 固定。 3. **重建成本。** H0c 未决问题 1(无界链回放)同样适用于有大量接受修订的长运行 child;在 H4c 大规模开放取消之前,可能需要带检查点的任务视图。 -4. **detach 后 child 的 owner。** 被 detach 的 child 换成什么持久 owner(租户级保留策略,或 root Session authority)留给 H4c;关闭路径只强制要求存在这样一个 owner。 +4. **detach 后 child 的 owner。** 被 detach 的 child 换成什么持久 owner(租户级保留策略,或 root Session authority)留给 [H4a 地图](2026-10-06-managed-child-agent-runtime.zh-CN.md)中的 Detach 后续工作,因为 H4c 已重新划定范围(见上文“范围已裁定”),而 [H4e-a 设计](2026-10-10-managed-agent-teams.zh-CN.md)把 detach 留在 H4e 之外;关闭路径只强制要求存在这样一个 owner。 diff --git a/docs/design/2026-10-06-managed-child-agent-runtime.md b/docs/design/2026-10-06-managed-child-agent-runtime.md index 265bc52c38..d071515bcc 100644 --- a/docs/design/2026-10-06-managed-child-agent-runtime.md +++ b/docs/design/2026-10-06-managed-child-agent-runtime.md @@ -25,7 +25,7 @@ H4 is delivered in six slices, as H0 was delivered as H0a/H0b/H0c. This change i | H4b — child Session runtime | `startChildRun` / `queryChildRun` / `continueChildRun` / `commitChildResult` / `acceptChildResult` / `cancelChildRun` / `closeChildScope`; Java lineage storage and idempotent child creation; the first cross-Session dispatcher (the child result relay); the foreground and background completion paths; close cascade; quotas; enablement with the server-first deployment order. | The reference design §13 H4 gate for child agents and the §14.7 items, on real stacks. | | H4c — workflow kind | The `workflow` body kind (registered disabled) and the child launch budget, per the [H4c design](2026-10-09-managed-workflow-child-kind.md); the Workflow tool's managed admission moves to the workflow runtime slice. | A workflow child rides the same lifecycle; its definition pin names the workflow revision. | | H4d — peer messages | The `session_message` body and durable `send_message` routing without a team (the background `task_id` path of automation §5.1; named peers are deferred, per H4d-a decision 2). The record contract and the `continueChildRun` rules land first, disabled, per the [H4d-a design](2026-10-09-managed-session-messages.md); the runtime follows as H4d-b. | Durable delivery, per-recipient accepted and consumed, across restart and reconnect. | -| H4e — teams | The `team_state` / `team_task` / `team_message` / `team_plan` bodies, the seven team tools, `resolveTeamPlan`, `requestMemberShutdown`, and legacy team import. | Automation §8 A06 team matrix. | +| H4e — teams | The `team_state` / `team_task` / `team_message` / `team_plan` bodies, the seven team tools, `resolveTeamPlan`, `requestMemberShutdown`, and legacy team import. The record contract lands first, disabled, per the [H4e-a design](2026-10-10-managed-agent-teams.md); the runtime follows as H4e-b. | Automation §8 A06 team matrix. | | H4f — public task cancel (shared with H5) | The `cancelSessionTask` / `cancelWebShellTask` routes with durable command persistence, per H3's follow-up table and #12847 A6/A7 and B12. | Contract items A6/A7 settled; a cancel of an H4 child acts on its record. | Nothing in H4a widens what any profile may run: `child_run` and `child_acceptance` stay disabled for submission, exactly as `monitor_run` did between H0b and H3. @@ -166,6 +166,6 @@ Schema version 1. The chain is keyed by `childRunId` — one acceptance chain pe | H4b | The six child operations; Java lineage storage (V48+), the internal idempotent creation route and the result relay; the completion paths; close cascade; quotas; enablement with the deployment order; `drain` phase wiring for close. Also owes the delivery-direction decision surfaced by real-stack review: today a `child_run` delivery may advance to `consumed` with no acceptance record, and may move to `unknown`/`rejected` after an acceptance exists — the two are independent facts by decision 4, so a run marked `rejected` while its acceptance says `accepted` is a contradiction. H4b either gates such back-off on the acceptance record (a reverse check) or declares the acceptance record authoritative in its design. | | H4c | Delivered by the [H4c design](2026-10-09-managed-workflow-child-kind.md): the `workflow` body kind (disabled) and the child launch budget. Workflow-tool admission moved to the workflow runtime slice. | | H4d | Record contract delivered by the [H4d-a design](2026-10-09-managed-session-messages.md): the `session_message` body and the continuation rules, both disabled. The runtime (managed `send_message`, the message relay, revive) is H4d-b. | -| H4e | The `team_state` / `team_task` / `team_message` / `team_plan` bodies and runtime; the seven team tools; plan resolution; member shutdown; legacy team import. | +| H4e | Record contract delivered by the [H4e-a design](2026-10-10-managed-agent-teams.md): the four team bodies and their lead-Session rules, all disabled. The runtime (the seven team tools, plan resolution, member shutdown, the mailbox relay, legacy team import) is H4e-b. | | H4f | Public task cancel routes with durable command persistence, settling #12847 A6/A7 and B12, shared with H5. | | Detach | Migrating an explicitly detached child to an independent durable owner, across parent close. | diff --git a/docs/design/2026-10-06-managed-child-agent-runtime.zh-CN.md b/docs/design/2026-10-06-managed-child-agent-runtime.zh-CN.md index 05aa575996..890e742d6e 100644 --- a/docs/design/2026-10-06-managed-child-agent-runtime.zh-CN.md +++ b/docs/design/2026-10-06-managed-child-agent-runtime.zh-CN.md @@ -25,7 +25,7 @@ H4 分六个切片交付,如同 H0 分为 H0a/H0b/H0c。本次变更是第一个 | H4b — child Session 运行时 | `startChildRun` / `queryChildRun` / `continueChildRun` / `commitChildResult` / `acceptChildResult` / `cancelChildRun` / `closeChildScope`;Java 血缘存储与幂等 child 创建;首个跨 Session dispatcher(child 结果 relay);前台与后台完成路径;关闭级联;配额;按 server-first 部署顺序启用。 | 参考设计 §13 的 child agent 门槛与 §14.7 各项,在真实栈上验证。 | | H4c — workflow kind | `workflow` 记录体 kind(已登记、禁用)与 child launch 预算,见 [H4c 设计](2026-10-09-managed-workflow-child-kind.zh-CN.md);Workflow 工具的 Managed 准入移到 workflow 运行时切片。 | workflow child 复用同一生命周期;其 definition pin 指向 workflow 修订。 | | H4d — peer 消息 | `session_message` 记录体与无团队的持久 `send_message` 路由(自动化 §5.1 的 background `task_id` 路径;命名 peer 按 H4d-a 决策 2 延后)。记录契约与 `continueChildRun` 规则按 [H4d-a 设计](2026-10-09-managed-session-messages.zh-CN.md) 先行落地且保持 disabled;运行时随后作为 H4d-b 交付。 | 持久送达;每个收件方各有 accepted 与 consumed;跨重启与重连可恢复。 | -| H4e — 团队 | `team_state` / `team_task` / `team_message` / `team_plan` 记录体、七个团队工具、`resolveTeamPlan`、`requestMemberShutdown` 与旧团队导入。 | 自动化 §8 的 A06 团队矩阵。 | +| H4e — 团队 | `team_state` / `team_task` / `team_message` / `team_plan` 记录体、七个团队工具、`resolveTeamPlan`、`requestMemberShutdown` 与旧团队导入。记录契约先落地并保持禁用,见 [H4e-a 设计](2026-10-10-managed-agent-teams.zh-CN.md);运行时随后作为 H4e-b。 | 自动化 §8 的 A06 团队矩阵。 | | H4f — 公开任务取消(与 H5 共享) | `cancelSessionTask` / `cancelWebShellTask` 路由,带持久命令存储,见 H3 的 follow-up 表与 #12847 A6/A7、B12。 | 契约项 A6/A7 落定;对 H4 child 的 cancel 作用于其记录。 | H4a 不扩大任何 profile 可运行的范围:`child_run` 与 `child_acceptance` 保持禁用提交,如同 `monitor_run` 在 H0b 与 H3 之间的状态。 @@ -166,6 +166,6 @@ Schema version 1。链以 `childRunId` 键控——每个 child run 一条 accep | H4b | 六个 child 操作;Java 血缘存储(V48+)、内部幂等创建路由与结果 relay;完成路径;关闭级联;配额;按部署顺序启用;关闭时的 `drain` phase 接线。另欠真栈评审暴露的交付方向决策:当前 `child_run` 的 delivery 可在没有 acceptance 记录时推进到 `consumed`,也可在 acceptance 已存在后改到 `unknown`/`rejected`——按决策 4 两者是独立事实,因此 run 标 `rejected` 而 acceptance 写着 `accepted` 是矛盾态。H4b 要么把这类反向迁移门到 acceptance 记录(反向检查),要么在其设计中声明 acceptance 记录为准。 | | H4c | 由 [H4c 设计](2026-10-09-managed-workflow-child-kind.zh-CN.md)交付:`workflow` 记录体 kind(禁用)与 child launch 预算。Workflow 工具准入已移到 workflow 运行时切片。 | | H4d | 记录契约已由 [H4d-a 设计](2026-10-09-managed-session-messages.zh-CN.md) 交付:`session_message` 记录体与续跑规则,二者均为 disabled。运行时(managed `send_message`、消息 relay、复活)属于 H4d-b。 | -| H4e | `team_state` / `team_task` / `team_message` / `team_plan` 记录体与运行时;七个团队工具;plan resolution;成员关停;旧团队导入。 | +| H4e | 记录契约已由 [H4e-a 设计](2026-10-10-managed-agent-teams.zh-CN.md)交付:四个团队记录体及其 lead Session 规则,均禁用。运行时(七个团队工具、plan resolution、成员关停、mailbox 中继、旧团队导入)属于 H4e-b。 | | H4f | 公开任务取消路由,带持久命令存储,落定 #12847 A6/A7 与 B12,与 H5 共享。 | | Detach | 把显式 detach 的 child 迁移到独立 durable owner,跨越父关闭。 | diff --git a/docs/design/2026-10-09-managed-session-messages.md b/docs/design/2026-10-09-managed-session-messages.md index 37dac74e11..411d0ce9d3 100644 --- a/docs/design/2026-10-09-managed-session-messages.md +++ b/docs/design/2026-10-09-managed-session-messages.md @@ -158,4 +158,4 @@ No key changes. Decision 9's rules are commit-time checks in the authority and t | Slice | Scope | | ----- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | H4d-b | The managed `send_message` tool (routing: a running child gets a message, a completed child gets a continuation, an ended child gets a named refusal); the Java message relay (claim, target resolution, inbound receipt with its input and wake, sender advances, crash recovery); consumption; revive; enablement. Also owed there: H4b's relay settles a child from its latest API Turn and then closes it, which would strand a message queued behind that Turn, so its completion criterion must account for undelivered and unconsumed messages; the Hosted recovery domain allowlist must admit `session_message`; a continuation runs the launch admission; and `send_message` bounds a message by the smaller of its two carriers (the 64 KiB content bound and the 32 KiB launch envelope of a continuation), so whether the child has completed never changes the answer. | -| H4e | Teams and the mailbox ([#13745](https://github.com/QwenLM/qwen-code/issues/13745)). | +| H4e | Teams and the mailbox ([#13745](https://github.com/QwenLM/qwen-code/issues/13745)); the record contract is the [H4e-a design](2026-10-10-managed-agent-teams.md). | diff --git a/docs/design/2026-10-09-managed-session-messages.zh-CN.md b/docs/design/2026-10-09-managed-session-messages.zh-CN.md index a745d2f1d3..2e46c4d8d3 100644 --- a/docs/design/2026-10-09-managed-session-messages.zh-CN.md +++ b/docs/design/2026-10-09-managed-session-messages.zh-CN.md @@ -158,4 +158,4 @@ Schema version 1。链以 `messageId` 为键。所有键都必填;可空的键 | 切片 | 范围 | | ----- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | H4d-b | managed `send_message` 工具(路由:运行中的 child 收到消息,已完成的 child 得到续跑,已结束的 child 得到具名拒绝);Java 消息 relay(认领、目标解析、带 input 与 wake 的 inbound 回执、推进发送方、崩溃恢复);消费;复活;启用。同样归它:H4b 的 relay 依据 child 最新的 API Turn 结算并随后关闭它,会让排在该 Turn 之后的消息搁浅,所以它的完成判据必须考虑未投递与未消费的消息;Hosted 恢复的 domain 白名单要放行 `session_message`;续跑要经过 launch 准入;`send_message` 按两条承载路径中较小的上限约束消息(64 KiB 内容上限与续跑 32 KiB 的 launch 信封),所以 child 是否已完成永远不会改变答复。 | -| H4e | 团队与 mailbox([#13745](https://github.com/QwenLM/qwen-code/issues/13745))。 | +| H4e | 团队与 mailbox([#13745](https://github.com/QwenLM/qwen-code/issues/13745));记录契约见 [H4e-a 设计](2026-10-10-managed-agent-teams.zh-CN.md)。 | diff --git a/docs/design/2026-10-10-managed-agent-teams.md b/docs/design/2026-10-10-managed-agent-teams.md new file mode 100644 index 0000000000..10d894a858 --- /dev/null +++ b/docs/design/2026-10-10-managed-agent-teams.md @@ -0,0 +1,190 @@ +# Managed agent teams: the record contract (H4e-a) + +[English](2026-10-10-managed-agent-teams.md) | [简体中文](2026-10-10-managed-agent-teams.zh-CN.md) + +Status: implemented in this change. Landed: the `managed-team_state`, `managed-team_task`, `managed-team_message` and `managed-team_plan` record bodies, registered and validated in TypeScript and Java, with the commit-time rules that bind every team record to its lead Session. None of the four domains is enabled for submission. Still design: the runtime that creates teams, spawns members, relays the mailbox, resolves plans and shuts members down (H4e-b, Follow-up work). This is the record-contract half of slice **H4e** of [#12827](https://github.com/QwenLM/qwen-code/issues/12827), stage H of the Managed Agent proposal [#12380](https://github.com/QwenLM/qwen-code/issues/12380), tracked by [#13745](https://github.com/QwenLM/qwen-code/issues/13745). It follows H4a ([record contract](2026-10-06-managed-child-agent-runtime.md), #13505), H4b ([child Session runtime](2026-10-07-managed-child-session-runtime.md), #13550), H4c ([workflow kind](2026-10-09-managed-workflow-child-kind.md), #13754) and H4d-a ([Session messages](2026-10-09-managed-session-messages.md), #13786). Below, "the automation design" is section 5.1 of the [automation, Channels and child delivery design](https://github.com/doudouOUC/code_agent/blob/689121646cc25ca08a34508a5f5555ae15308833/qwen-code/feature/managed-agents/managed-agent-automation.md) at the commit that #12827 pins. + +## Problem and scope + +The child agents H4b runs answer only their parent. A team is the construct that lets several of them work together: a lead Session, named members, a shared task board, a mailbox between them, and plan approval for members that must plan before they act. On the Managed path none of that exists. The four team domains are registered names with no body, so nothing about a team can be committed, rebuilt or checked. + +Issue #13745 asked for three items: E1 (the team record domains), E2 (detach to an independent durable owner) and E3 (the close cascade under teams). Its triage left three questions for a human: which durable owner a detached child takes, where the teammate routing that H4b left to H4e goes, and whether the team domains project the Legacy team model or stand beside it. This design settles the scope as follows: + +- **H4e is split like H4d.** This change lands E1 as the record half: every team fact the runtime will need is a committed record with commit-time rules in both languages. The runtime half is H4e-b. It also takes what the H4a delivery map assigns to H4e beyond the records: the seven team tools, `resolveTeamPlan`, `requestMemberShutdown`, legacy team import, the mailbox relay that H4d-a moved here, and the managed Agent tool's `name` parameter, which H4b decision 11 left for teammate routing. E3, the close cascade under teams, needs the runtime too, so it is H4e-b's. +- **E2 (detach) is not H4e.** The H4a delivery map gives detach its own follow-up row ("Detach"), and the H4b and H4c designs repeat it. The question of which durable owner a detached child takes moves with it. The [H4 design](2026-10-04-managed-child-agents.md) assigned that question to H4c, and now points to the Detach follow-up. +- **The team domains are the durable form of the Legacy team model, not a parallel one.** The automation design requires the migration to reuse the rules of `agents/team/TeamManager.ts`, its tasks, mailbox and identity. The H4a map also gives H4e the import of legacy teams. So the records keep the Legacy vocabulary: sanitized team and member names, the reserved `leader`, the board statuses, the mailbox message types and plan approval. Where the Legacy model rewrites two files to keep one fact, a record keeps the fact once (decision 5). + +This follows the H4a → H4b, H4d-a → H4d-b and H6a → H6b cadence: the record contract lands first, validated ahead of any writer, and enablement ships with the producers. + +## Current state + +The facts below are from `main` at `9ec44d45c4`. + +- **Domains.** `team_state`, `team_task`, `team_message` and `team_plan` are in the closed v1 domain index (`MANAGED_SESSION_DOMAINS`) and in Java's `ManagedExtensionRecords.DOMAINS`. They have no body in either language, no store rule, no task kind and no enablement. `team_plan` is also a value of `MANAGED_SESSION_ACTION_SOURCES`, so a D6 action can already name a team plan as its source. +- **Legacy model.** `packages/core/src/agents/team/` keeps a team in files under `~/.qwen/teams/{team}` and `~/.qwen/tasks/{team}`. `TeamFile` holds the lead and an append-only member list of at most `MAX_TEAMMATES` (10) members. A member is removed only when its own spawn rolls back. Names are sanitized to `[a-z0-9-]`, and `leader` names the lead. A `SwarmTask` has a positive integer id, `subject`, `description`, `activeForm`, `owner`, status `pending | in_progress | completed`, both dependency directions (`blocks` and `blockedBy`) and `metadata` of at most 32 KiB. `task_update` deletes a task with `status: 'deleted'`, merges metadata and only adds edges. It mirrors each edge into the other task, removes a completed or deleted task from its dependents' `blockedBy`, and gives an `in_progress` task without an owner its caller as owner. The mailbox carries `shutdown_request`, `shutdown_approved`, `shutdown_rejected`, `plan_approval_request`, `plan_approval_response` and `task_assignment`. Plain text goes through each agent's in-memory queue. All of it lives in the lead's process and its local files, and the Managed path has none of it. +- **Seven team tools.** `team_create`, `team_delete`, `task_create`, `task_update`, `task_list`, `team_plan_approval` and `request_shutdown` act on that model. + +## Decisions + +1. **All four domains live in the lead Session's journal.** A team is a persistent domain resource under the lead Session's authority (the automation design, §5.1). Every team record is committed by the lead Session's single writer and names its `teamId`. The `team_state` record names the lead as `leadSessionId`, and the authority requires that to be its own Session. A member's own Session holds no team record. A member acts through an identity-checked command to the lead, and a message reaches it as an input in its own journal (open question 1). One journal is enough here, unlike H4d's two, because the lead authorizes every team fact. +2. **Names keep the Legacy form.** A team name and a member name are the output of Legacy `sanitizeName`: lowercase letters and digits in dash-separated runs, at most 64 characters. A member is never named `leader`. Where a record names a participant (a task owner, a message's sender or recipient), `leader` is the lead and any other name is a member of the team. +3. **`team_state` is the roster and the lifecycle.** Members are append-only, and only while the team is `active`. Each revision appends at most one, so each membership fact is committed on its own. `membershipRevision` counts membership facts: it opens at 1 with no members and is always one more than the member count. Each member names its child run in the lead's journal (`childRunId`) and whether it must plan before it acts. The lifecycle follows the automation design: `active → closing → deleted`, one step at a time. The run block is a purely logical lifecycle, `admitted` while the team lives and `cancelled` once it is deleted, which freezes the record. +4. **A member is a live child Session run of the lead, in exactly one team.** When a member joins, its `childRunId` must name a child Session run (`child_agent` or `workflow`) of the lead's journal that has not ended. No other team of the lead may list that run. A member keeps its entry after its run ends, as a Legacy member keeps its file entry, so the roster is the team's history. Its run's state is the member's liveness. +5. **`team_task` keeps one fact once.** A task's identity is its `taskId`, which is unique in the journal. Its `number` is the board's `#N`, unique within the team and never reused. Its subject, description, active form, metadata, owner and status follow `task_update`. `in_progress` needs an owner, and `deleted` is terminal: the run moves from `admitted` to `cancelled` and the task freezes. Dependencies are stored in one direction only, as `blockedBy`, and only grow. A Legacy edge always reaches the dependent's `blockedBy`, so `blocks` is the reverse index, not a second copy. Whether a dependency still blocks is read from the blocker's status: a completed or deleted blocker no longer blocks. So a write commits one record, where Legacy rewrites both tasks of an edge and every dependent of a completed task. A new edge must name a task of the same team and must not close a cycle. The revision of the task record serves as its `expectedTaskRevision`. A task assignment is a `team_message` of kind `task_assignment` (decision 6). The task carries no delivery line, because one task is assigned again and again and a delivery line ends. +6. **`team_message` is the mailbox.** Each record is one message to one recipient, so each recipient has its own delivery, acceptance and consumption. A broadcast is one record per recipient, and a broadcast that partly fails never resends to a member that already accepted. The record follows the H4d-a outbox entry: settled from its first revision, naming its sending call, with a `session` delivery line (`planned → accepting → accepted → consumed`, plus `cancelled`, `unknown` and `rejected`), a target Session fixed once at handover, and the target's `inputId` exactly once accepted. The content is at most 64 KiB and bound to its digest. `kind` is plain `message` or one of the six Legacy mailbox types, each with a fixed direction: + + | Kind | From | To | + | ----------------------------------------------------------------- | -------- | ----------- | + | `message` | anyone | anyone else | + | `task_assignment` | anyone | a member | + | `plan_approval_request`, `shutdown_approved`, `shutdown_rejected` | a member | `leader` | + | `plan_approval_response`, `shutdown_request` | `leader` | a member | + + A message to `leader` targets the lead Session. A message to a member targets the Session its child run attached, so a message to a member whose run has not attached yet stays `planned`. + +7. **`team_plan` is the approval state, not its transport.** A plan request is keyed by `requestId`, the id of the D6 action that asks the leader to decide (source `team_plan`). It names the member, the member's `planRevision` and the plan content. Its run opens `waiting` for the decision and ends `settled` with `decision: approved | rejected` and optional feedback. It ends `cancelled` with no decision when the plan is withdrawn or superseded: a revised plan opens a new request with a later revision, so a decision never applies to a plan it did not see. The request and the decision travel as `plan_approval_request` and `plan_approval_response` messages, and the decision itself is resolved through the D6 action (`resolveAction`) before H4e-b commits it here. A plan request can come only from a member that requires plan mode. +8. **Commit-time rules check what the lead's journal holds.** Both the TypeScript authority and the Java store check: + + | Rule | Domain | + | --------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------- | + | The team is led by this Session | `team_state` | + | A joining member names a child Session run of this Session that has not ended and that no other team lists | `team_state` | + | A record opens only in a team of this Session that is `active` | `team_task`, `team_message`, `team_plan` | + | A task number is unique in its team | `team_task` | + | A task owner, whenever it changes, is `leader` or a member of the team | `team_task` | + | A new dependency names a task of the same team and closes no cycle | `team_task` | + | A message travels between `leader` and members of its team, and targets the lead Session or the Session its member's run attached | `team_message` | + | A plan request comes from a member of its team that requires plan mode | `team_plan` | + + A record of a team that is closing or deleted can still take its later revisions: a message in flight is still delivered, a pending plan request is still withdrawn, and a task can still be updated. Only new records are refused, so a team in `closing` admits no new work while it drains. + +9. **The four domains stay disabled and project no task.** None is in `MANAGED_SESSION_ENABLED_DOMAINS`, and a commit is refused with "registered but not enabled" before anything publishes. The Java store validates all four ahead of any writer, in the server-first order of H1 through H4d-a. None is on the list of domains that Java's lifecycle gate admits for a Session under a close or delete claim, and H4e-b decides what a closing lead may still commit (open question 3). A team task is a board item, not a runtime task, so all four register a null task kind and their Java rows carry null task columns. + +## Records + +All four are schema version 1. All keys are required, and a nullable key holds `null`. Every run block is purely logical: no definition pin, effect, dispatch, Channel delivery, execution or Runtime binding. Only `team_message` names an `executionCallId` and carries a delivery. + +### `managed-team_state` + +Chain identity: `teamId`. + +| Key | Rule | +| -------------------- | ---------------------------------------------------------------------------------------------------------------------------- | +| `teamId` | id: the team, minted by the lead | +| `name` | team name (decision 2) | +| `leadSessionId` | id: the lead Session | +| `lifecycle` | `active`, `closing` or `deleted` | +| `membershipRevision` | integer, exactly one more than the number of members | +| `members` | at most 10 entries of `{ name, childRunId, planModeRequired }`; names and child runs are unique; no member is named `leader` | +| `run` | `admitted` while `active` or `closing`, `cancelled` once `deleted` | + +- **Opening.** `active`, no members, run `admitted`. +- **Successors.** `teamId`, `name` and `leadSessionId` never change. The lifecycle stays or takes one step. The members of a revision are the previous members plus at most one more, and only an `active` team that stays `active` gains one. A deleted team is frozen. + +### `managed-team_task` + +Chain identity: `taskId`. + +| Key | Rule | +| ---------------- | ------------------------------------------------------------------ | +| `teamId` | id: the team | +| `taskId` | id: the task, unique in the journal | +| `number` | integer from 1: the board's `#N` | +| `subject` | bounded text | +| `descriptionRef` | durable ref, at most 65536 bytes | +| `activeForm` | null or bounded text | +| `metadataRef` | null or durable ref, at most 32768 bytes (the Legacy metadata cap) | +| `owner` | null or a participant name; required while `in_progress` | +| `status` | `pending`, `in_progress`, `completed` or `deleted` | +| `blockedBy` | at most 64 distinct task ids, never this task's own | +| `run` | `admitted` unless `deleted`, `cancelled` once `deleted` | + +- **Opening.** Any status but `deleted`, so an import can open a completed task. +- **Successors.** `teamId`, `taskId` and `number` never change. `blockedBy` keeps every earlier entry in order and may append more. Every other field may change until the task is deleted, which freezes it. + +### `managed-team_message` + +Chain identity: `messageId`. + +| Key | Rule | +| ----------------- | ---------------------------------------------------------------------------------------------------------------- | +| `teamId` | id: the team | +| `messageId` | id: the message to one recipient, minted by the sender | +| `kind` | `message` or a Legacy mailbox type, with the direction decision 6 fixes | +| `from`, `to` | participant names, never equal | +| `contentRef` | durable ref, at most 65536 bytes | +| `contentDigest` | digest: must equal `contentRef.digest` | +| `targetSessionId` | null or id: set once, required once the delivery is past `planned` except at `cancelled` | +| `inputId` | null or id: the input carrying the message in the target; set once, present exactly at `accepted` and `consumed` | +| `run` | settled, naming its sending call in `executionCallId`, with a `session` delivery | + +- **Opening.** Delivery `planned`. +- **Successors.** Every key but `targetSessionId`, `inputId` and the run is fixed. The target and the input are set once. The delivery takes one shared step at a time. + +### `managed-team_plan` + +Chain identity: `requestId`. + +| Key | Rule | +| -------------- | --------------------------------------------------------------------- | +| `teamId` | id: the team | +| `requestId` | id: the D6 action that asks the leader | +| `member` | member name: the member whose plan it is | +| `planRevision` | integer from 1 | +| `planRef` | durable ref, at most 65536 bytes | +| `decision` | null, `approved` or `rejected`; set exactly when the run is `settled` | +| `feedbackRef` | null or durable ref of at most 65536 bytes; only with a decision | +| `run` | `waiting`, then `settled` or `cancelled` | + +- **Opening.** Run `waiting`, no decision and no feedback. +- **Successors.** `teamId`, `requestId`, `member`, `planRevision` and `planRef` never change. The run ends once, which fixes the decision and the feedback. + +## Non-goals + +- **The runtime (H4e-b):** the seven team tools on the managed path, the managed Agent tool's `name` and teammate routing, the mailbox relay and the member-side delivery, plan resolution through D6 actions, member shutdown, the close cascade over team membership, legacy team import, and enablement. +- **Detach** (the Detach follow-up of the H4a map) and **cross-workspace teams**. +- **Any public contract change.** The OpenAPI contract, routes and Flyway migrations stay as they are. A team resource on the public surface belongs with #13785. + +## Files affected + +- `packages/core/src/managed-runtime/managed-team-record.ts` (new): the four bodies, their start and successor rules. +- `packages/core/src/managed-runtime/contracts/managed-team-record-v1.fixtures.json` (new): the shared cases. `managed-extension-projection-v1.fixtures.json` gains the four domains with a null task kind. +- `packages/core/src/managed-runtime/managed-extension-projection.ts`: the body registrations. +- `packages/core/src/managed-runtime/managed-session-authority.ts`: resource closure and the rules of decision 8. +- `packages/sdk-java/managed-agent-server`: + - `ManagedTeamRecords` (new): the validator. + - `ManagedExtensionProjection`: the body registrations. + - `ManagedExtensionRecordStore`: closure and the rules of decision 8. +- Tests in both languages: the TypeScript fixture replay and `managed-session-authority.team.test.ts`, then `ManagedTeamRecordContractTest` and `ManagedTeamStoreTest`. +- This design in both languages, plus pointers from the H4 and H4a designs. + +## Validation + +- **Fixture parity.** The shared cases and successors replay in both languages, and every invalid case names the clause both validators must report. +- **Authority.** The suite lifts the domain gate to plant records, except where it tests the gate itself: + - The real gate refuses each of the four domains, and nothing publishes. + - A team opens, gains members one at a time, closes and is deleted. Its tasks, messages and plan requests chain to their ends, and a reopened log rebuilds every chain. + - Every rule of decision 8 refuses its violation with the named message. +- **Java store.** The same chains commit over H2 in MySQL mode, the same rules refuse with `managed_session_extension_record_rejected`, and a team row projects no task. +- **Mutation checks.** Each new guard is disabled in turn, and its witness goes red in its own language. + +## Acceptance criteria + +- TypeScript and Java accept and refuse identical team records and successors from the shared fixtures, and every existing contract corpus replays unchanged. +- The four domains stay off the enabled list and are refused before anything publishes. +- In both languages, every rule of decision 8 refuses its violation, and a lawful team, task, message and plan request commit and rebuild. +- No public API or migration changes, and every existing H1–H4d-a suite stays green. + +## Open questions + +1. **The member-side receipt.** A message to a member becomes an input in the member's journal, and that journal needs its own record to recognize a redelivery. The H4d-a `session_message` receipt fits a lead that is also the member's parent, but a member Session has no team record. H4e-b decides whether to reuse that receipt or add one. +2. **The input of a message to the leader.** A message to `leader` is accepted in the lead's own journal, so its input could commit with the `accepted` revision, as H4b's `sent` acceptance does. This contract does not yet bind them. H4e-b adds the binding when its relay uses it. +3. **What a closing lead commits.** The close cascade under teams (issue E3) cancels member runs through the child-run funnel. Whether team records must also commit while the lead Session is closing, which the lifecycle gate would then have to admit, is H4e-b's call. +4. **A member whose run is continued.** A continuation (H4d-a) is a new child run, but a roster entry names one run forever. H4e-b decides whether a continued member re-joins under a new entry or the roster follows the continuation. + +## Follow-up work + +| Slice | Scope | +| ------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| H4e-b | The seven team tools on the managed path; the managed Agent tool's `name` with teammate routing (H4b decision 11); the mailbox relay and member-side delivery (open questions 1 and 2); plan resolution through D6 actions; `requestMemberShutdown`; the close cascade over team membership (#13745 E3, open question 3); legacy team import; enablement after a physical acceptance pass, as for H3 (#13532). | +| Detach | Migrating an explicitly detached child to an independent durable owner across parent close (#13745 E2), with the owner question the [H4 design](2026-10-04-managed-child-agents.md) leaves open. | diff --git a/docs/design/2026-10-10-managed-agent-teams.zh-CN.md b/docs/design/2026-10-10-managed-agent-teams.zh-CN.md new file mode 100644 index 0000000000..9235afafec --- /dev/null +++ b/docs/design/2026-10-10-managed-agent-teams.zh-CN.md @@ -0,0 +1,190 @@ +# Managed agent 团队:记录契约(H4e-a) + +[English](2026-10-10-managed-agent-teams.md) | [简体中文](2026-10-10-managed-agent-teams.zh-CN.md) + +状态:已在本变更中实现。已落地:`managed-team_state`、`managed-team_task`、`managed-team_message` 与 `managed-team_plan` 四个记录体,在 TypeScript 与 Java 中均已登记并校验,并带有把每条团队记录绑定到其 lead Session 的提交时规则。四个 domain 均不开放提交。仍为设计:创建团队、拉起成员、中继 mailbox、裁决计划与关停成员的运行时(H4e-b,见"后续工作")。这是 [#12827](https://github.com/QwenLM/qwen-code/issues/12827) 的 **H4e** 切片中记录契约的那一半,即 Managed Agent 提案 [#12380](https://github.com/QwenLM/qwen-code/issues/12380) 的 H 阶段,由 [#13745](https://github.com/QwenLM/qwen-code/issues/13745) 跟踪。它承接 H4a([记录契约](2026-10-06-managed-child-agent-runtime.zh-CN.md),#13505)、H4b([child Session 运行时](2026-10-07-managed-child-session-runtime.zh-CN.md),#13550)、H4c([workflow kind](2026-10-09-managed-workflow-child-kind.zh-CN.md),#13754)与 H4d-a([Session 消息](2026-10-09-managed-session-messages.zh-CN.md),#13786)。下文"自动化设计"指 #12827 所固定提交上的 [automation、Channels 与 child 投递设计](https://github.com/doudouOUC/code_agent/blob/689121646cc25ca08a34508a5f5555ae15308833/qwen-code/feature/managed-agents/managed-agent-automation.md) 第 5.1 节。 + +## 问题与范围 + +H4b 运行的 child agent 只对其父负责。团队是让多个 child 协同工作的构造:一个 lead Session、具名成员、共享任务板、成员之间的 mailbox,以及对必须先出计划再行动的成员的计划审批。Managed 路径上这些都不存在。四个团队 domain 只是没有记录体的已登记名字,因此团队的任何事实都无法提交、重建或检查。 + +Issue #13745 提出了三项:E1(团队记录 domain)、E2(detach 到独立的持久所有者)与 E3(团队下的关闭级联)。其分诊留下了三个需要人来定的问题:已 detach 的 child 采用哪个持久所有者;H4b 留给 H4e 的 teammate 路由放在哪里;团队 domain 是 Legacy 团队模型的投影,还是与之并列的另一套。本设计把范围定为: + +- **H4e 像 H4d 一样一分为二。** 本变更把 E1 作为记录那一半落地:运行时将需要的每个团队事实都是已提交的记录,并在两种语言中有提交时规则。运行时那一半是 H4e-b。H4a 交付地图在记录之外分给 H4e 的内容也归它:七个团队工具、`resolveTeamPlan`、`requestMemberShutdown`、旧团队导入、H4d-a 移到这里的 mailbox 中继,以及 H4b 决策 11 留给 teammate 路由的 managed Agent 工具 `name` 参数。E3(团队下的关闭级联)同样需要运行时,因此归 H4e-b。 +- **E2(detach)不属于 H4e。** H4a 交付地图给 detach 单列了一行后续工作("Detach"),H4b 与 H4c 的设计都沿用了这一划分。已 detach 的 child 采用哪个持久所有者的问题随之移走。[H4 设计](2026-10-04-managed-child-agents.zh-CN.md)曾把这个问题交给 H4c,现已改为指向 Detach 后续工作。 +- **团队 domain 是 Legacy 团队模型的持久形态,而不是并列的另一套。** 自动化设计要求迁移复用 `agents/team/TeamManager.ts` 及其任务、mailbox 与身份的规则。H4a 地图也把旧团队导入交给了 H4e。因此记录沿用 Legacy 的词汇:经过清洗的团队名与成员名、保留名 `leader`、任务板状态、mailbox 消息类型与计划审批。凡是 Legacy 模型为维持一个事实而改写两个文件的地方,记录只保存一次该事实(决策 5)。 + +这沿用 H4a → H4b、H4d-a → H4d-b 与 H6a → H6b 的节奏:记录契约先落地,在任何写入方之前完成校验,启用随生产者一起交付。 + +## 现状 + +以下事实取自 `main` 的 `9ec44d45c4`。 + +- **Domain。** `team_state`、`team_task`、`team_message` 与 `team_plan` 在封闭的 v1 domain 索引(`MANAGED_SESSION_DOMAINS`)与 Java 的 `ManagedExtensionRecords.DOMAINS` 中。它们在两种语言中都没有记录体、没有 store 规则、没有 task kind,也没有启用。`team_plan` 同时是 `MANAGED_SESSION_ACTION_SOURCES` 的一个取值,因此 D6 action 已经可以把团队计划作为来源。 +- **Legacy 模型。** `packages/core/src/agents/team/` 把团队保存在 `~/.qwen/teams/{team}` 与 `~/.qwen/tasks/{team}` 下的文件中。`TeamFile` 保存 lead 和一个只追加的成员列表,最多 `MAX_TEAMMATES`(10)个成员。成员只有在自身拉起回滚时才会被移除。名字被清洗为 `[a-z0-9-]`,`leader` 指 lead。`SwarmTask` 有正整数 id、`subject`、`description`、`activeForm`、`owner`、状态 `pending | in_progress | completed`、两个方向的依赖(`blocks` 与 `blockedBy`),以及最多 32 KiB 的 `metadata`。`task_update` 以 `status: 'deleted'` 删除任务,合并 metadata,只增加边。它把每条边镜像到另一个任务中,把已完成或已删除的任务从其依赖方的 `blockedBy` 中移除,并在 `in_progress` 任务没有 owner 时把调用方设为 owner。mailbox 承载 `shutdown_request`、`shutdown_approved`、`shutdown_rejected`、`plan_approval_request`、`plan_approval_response` 与 `task_assignment`。纯文本走各 agent 的内存队列。这一切都只存在于 lead 的进程及其本地文件中,Managed 路径上一样也没有。 +- **七个团队工具。** `team_create`、`team_delete`、`task_create`、`task_update`、`task_list`、`team_plan_approval` 与 `request_shutdown` 作用于这一模型。 + +## 决策 + +1. **四个 domain 都位于 lead Session 的 journal 中。** 团队是 lead Session 权威下的持久 domain 资源(自动化设计 §5.1)。每条团队记录都由 lead Session 的单一写入方提交,并写明其 `teamId`。`team_state` 记录以 `leadSessionId` 写明 lead,authority 要求它就是自己的 Session。成员自己的 Session 不保存任何团队记录。成员通过身份受检的命令作用于 lead,消息则作为成员自己 journal 中的 input 到达(开放问题 1)。与 H4d 的两个 journal 不同,这里一个 journal 就够了,因为每个团队事实都由 lead 授权。 +2. **名字沿用 Legacy 形式。** 团队名与成员名是 Legacy `sanitizeName` 的输出:由短横线分隔的小写字母与数字段组成,最多 64 个字符。成员不会被命名为 `leader`。凡记录中指名参与者(任务 owner、消息的发送方或接收方)之处,`leader` 指 lead,其他名字指团队的某个成员。 +3. **`team_state` 是名册与生命周期。** 成员只追加,且只能在团队为 `active` 时追加。每个修订最多追加一个,因此每个成员关系事实都单独提交。`membershipRevision` 计数成员关系事实:它在没有成员时从 1 开始,始终比成员数多 1。每个成员写明其在 lead journal 中的 child run(`childRunId`),以及它是否必须先出计划再行动。生命周期沿用自动化设计:`active → closing → deleted`,每次一步。run 块是纯逻辑的生命周期:团队存续期间为 `admitted`,删除后为 `cancelled`,记录随之冻结。 +4. **成员是 lead 的一个存活的 child Session run,且只属于一个团队。** 成员加入时,其 `childRunId` 必须指向 lead journal 中一个尚未结束的 child Session run(`child_agent` 或 `workflow`)。lead 的其他团队都不得列出这个 run。成员的 run 结束后,其条目仍然保留,就像 Legacy 成员在文件中保留其条目一样,因此名册就是团队的历史。成员是否存活由其 run 的状态决定。 +5. **`team_task` 把一个事实只保存一次。** 任务的身份是 `taskId`,在 journal 中唯一。`number` 是任务板上的 `#N`,在团队内唯一且永不复用。subject、description、active form、metadata、owner 与 status 沿用 `task_update`。`in_progress` 需要 owner;`deleted` 是终态:run 从 `admitted` 变为 `cancelled`,任务随之冻结。依赖只按一个方向保存为 `blockedBy`,且只增不减。Legacy 的每条边总会落到依赖方的 `blockedBy` 中,因此 `blocks` 是反向索引,而不是第二份副本。依赖是否仍在阻塞,由阻塞方的状态读出:已完成或已删除的阻塞方不再阻塞。于是一次写入只提交一条记录,而 Legacy 需要改写一条边两端的任务,以及已完成任务的每个依赖方。新边必须指向同一团队的任务,且不得形成环。任务记录的修订号即其 `expectedTaskRevision`。任务分配是一条 kind 为 `task_assignment` 的 `team_message`(决策 6)。任务本身不带投递线,因为一个任务会被反复分配,而投递线会走到终点。 +6. **`team_message` 就是 mailbox。** 每条记录是发给一个接收方的一条消息,因此每个接收方都有自己的投递、接受与消费。广播是每个接收方一条记录,部分失败的广播绝不会重发给已经接受的成员。记录沿用 H4d-a 的 outbox 条目:从第一个修订起就是 settled,写明其发送调用,带一条 `session` 投递线(`planned → accepting → accepted → consumed`,外加 `cancelled`、`unknown` 与 `rejected`),目标 Session 在交接时固定一次,且恰在 accepted 之后写明目标的 `inputId`。内容最多 64 KiB,并与其摘要绑定。`kind` 是普通的 `message`,或六种 Legacy mailbox 类型之一,每种都有固定的方向: + + | Kind | 发送方 | 接收方 | + | ----------------------------------------------------------------- | -------- | ------------ | + | `message` | 任何一方 | 其他任何一方 | + | `task_assignment` | 任何一方 | 某个成员 | + | `plan_approval_request`、`shutdown_approved`、`shutdown_rejected` | 某个成员 | `leader` | + | `plan_approval_response`、`shutdown_request` | `leader` | 某个成员 | + + 发给 `leader` 的消息以 lead Session 为目标。发给成员的消息以该成员 child run 所 attach 的 Session 为目标,因此发给一个其 run 尚未 attach 的成员的消息停留在 `planned`。 + +7. **`team_plan` 是审批状态,不是其传输。** 计划请求以 `requestId` 为键,即请 leader 做决定的那个 D6 action 的 id(来源为 `team_plan`)。它写明成员、成员的 `planRevision` 与计划内容。其 run 以 `waiting` 开启,等待决定,并以 `settled` 结束,带 `decision: approved | rejected` 与可选的反馈。计划被撤回或被取代时,它以 `cancelled` 结束且没有决定:修改后的计划以更新的修订开启新请求,因此一个决定绝不会作用于它没有看过的计划。请求与决定分别以 `plan_approval_request` 与 `plan_approval_response` 消息传递,决定本身先经 D6 action(`resolveAction`)裁决,再由 H4e-b 提交到这里。计划请求只能来自要求 plan mode 的成员。 +8. **提交时规则检查 lead journal 所持有的内容。** TypeScript authority 与 Java store 都检查: + + | 规则 | Domain | + | ----------------------------------------------------------------------------------------------- | ---------------------------------------- | + | 团队由本 Session 领导 | `team_state` | + | 加入的成员指向本 Session 一个尚未结束、且没有其他团队列出的 child Session run | `team_state` | + | 记录只能在本 Session 一个 `active` 的团队中开启 | `team_task`、`team_message`、`team_plan` | + | 任务编号在其团队内唯一 | `team_task` | + | 任务 owner 每次变化时,都是 `leader` 或团队成员 | `team_task` | + | 新依赖指向同一团队的任务,且不形成环 | `team_task` | + | 消息在 `leader` 与本团队成员之间传递,并以 lead Session 或该成员 run 所 attach 的 Session 为目标 | `team_message` | + | 计划请求来自本团队中要求 plan mode 的成员 | `team_plan` | + + 处于 closing 或已删除团队中的记录仍然可以提交其后续修订:在途消息仍会送达,待决的计划请求仍可撤回,任务仍可更新。只有新记录会被拒绝,因此处于 `closing` 的团队在收尾期间不再接收新工作。 + +9. **四个 domain 保持禁用,且不投影 task。** 它们都不在 `MANAGED_SESSION_ENABLED_DOMAINS` 中,提交会在任何内容发布之前以"registered but not enabled"被拒绝。Java store 先于任何写入方校验全部四个 domain,遵循 H1 至 H4d-a 的 server 先行顺序。它们都不在 Java 生命周期门禁为处于 close 或 delete 认领下的 Session 所放行的 domain 列表中,closing 中的 lead 还能提交什么由 H4e-b 决定(开放问题 3)。团队任务是任务板条目,不是运行时 task,因此四者都登记为空 task kind,其 Java 行的 task 列为空。 + +## 记录 + +四者都是 schema version 1。所有键都必填,可空的键写 `null`。每个 run 块都是纯逻辑的:没有定义 pin、effect、dispatch、Channel 投递、执行或 Runtime 绑定。只有 `team_message` 写明 `executionCallId` 并带有投递。 + +### `managed-team_state` + +链身份:`teamId`。 + +| 键 | 规则 | +| -------------------- | ------------------------------------------------------------------------------------------------------ | +| `teamId` | id:团队,由 lead 生成 | +| `name` | 团队名(决策 2) | +| `leadSessionId` | id:lead Session | +| `lifecycle` | `active`、`closing` 或 `deleted` | +| `membershipRevision` | 整数,恰好比成员数多 1 | +| `members` | 最多 10 个 `{ name, childRunId, planModeRequired }` 条目;名字与 child run 各不相同;没有成员叫 `leader` | +| `run` | `active` 或 `closing` 时为 `admitted`,`deleted` 后为 `cancelled` | + +- **开启。** `active`,没有成员,run 为 `admitted`。 +- **后继。** `teamId`、`name` 与 `leadSessionId` 永不改变。生命周期保持不变或前进一步。一个修订的成员是上一修订的成员再加至多一个,且只有保持 `active` 的 `active` 团队才能新增成员。已删除的团队被冻结。 + +### `managed-team_task` + +链身份:`taskId`。 + +| 键 | 规则 | +| ---------------- | -------------------------------------------------------- | +| `teamId` | id:团队 | +| `taskId` | id:任务,在 journal 中唯一 | +| `number` | 从 1 开始的整数:任务板上的 `#N` | +| `subject` | 有界文本 | +| `descriptionRef` | 持久引用,最多 65536 字节 | +| `activeForm` | null 或有界文本 | +| `metadataRef` | null 或持久引用,最多 32768 字节(Legacy 的 metadata 上限) | +| `owner` | null 或参与者名;`in_progress` 期间必填 | +| `status` | `pending`、`in_progress`、`completed` 或 `deleted` | +| `blockedBy` | 最多 64 个互不相同的任务 id,绝不包含本任务自己 | +| `run` | 未删除时为 `admitted`,`deleted` 后为 `cancelled` | + +- **开启。** 除 `deleted` 外的任何状态,因此导入时可以直接开启一个已完成的任务。 +- **后继。** `teamId`、`taskId` 与 `number` 永不改变。`blockedBy` 按顺序保留之前的每一项,并可追加更多。其他字段在任务被删除之前都可以改变,删除后冻结。 + +### `managed-team_message` + +链身份:`messageId`。 + +| 键 | 规则 | +| ----------------- | ----------------------------------------------------------------------------------- | +| `teamId` | id:团队 | +| `messageId` | id:发给一个接收方的消息,由发送方生成 | +| `kind` | `message` 或一种 Legacy mailbox 类型,方向由决策 6 固定 | +| `from`、`to` | 参与者名,二者不相同 | +| `contentRef` | 持久引用,最多 65536 字节 | +| `contentDigest` | 摘要:必须等于 `contentRef.digest` | +| `targetSessionId` | null 或 id:只设一次;投递越过 `planned` 后必填,`cancelled` 除外 | +| `inputId` | null 或 id:在目标中承载该消息的 input;只设一次,恰在 `accepted` 与 `consumed` 时存在 | +| `run` | settled,在 `executionCallId` 中写明其发送调用,带 `session` 投递 | + +- **开启。** 投递为 `planned`。 +- **后继。** 除 `targetSessionId`、`inputId` 与 run 外的每个键都固定。目标与 input 只设一次。投递每次按共享状态线前进一步。 + +### `managed-team_plan` + +链身份:`requestId`。 + +| 键 | 规则 | +| -------------- | ----------------------------------------------------------- | +| `teamId` | id:团队 | +| `requestId` | id:请 leader 做决定的那个 D6 action | +| `member` | 成员名:计划所属的成员 | +| `planRevision` | 从 1 开始的整数 | +| `planRef` | 持久引用,最多 65536 字节 | +| `decision` | null、`approved` 或 `rejected`;恰在 run 为 `settled` 时设置 | +| `feedbackRef` | null 或最多 65536 字节的持久引用;只能与决定一起出现 | +| `run` | `waiting`,随后为 `settled` 或 `cancelled` | + +- **开启。** run 为 `waiting`,没有决定,也没有反馈。 +- **后继。** `teamId`、`requestId`、`member`、`planRevision` 与 `planRef` 永不改变。run 只结束一次,这同时固定了决定与反馈。 + +## 非目标 + +- **运行时(H4e-b):** managed 路径上的七个团队工具、managed Agent 工具的 `name` 与 teammate 路由、mailbox 中继与成员一侧的投递、经由 D6 action 的计划裁决、成员关停、团队成员关系上的关闭级联、旧团队导入,以及启用。 +- **Detach**(H4a 地图中的 Detach 后续工作)与**跨工作区团队**。 +- **任何公共契约变更。** OpenAPI 契约、路由与 Flyway 迁移保持不变。公共接口上的团队资源属于 #13785。 + +## 受影响的文件 + +- `packages/core/src/managed-runtime/managed-team-record.ts`(新增):四个记录体及其开启与后继规则。 +- `packages/core/src/managed-runtime/contracts/managed-team-record-v1.fixtures.json`(新增):共享用例。`managed-extension-projection-v1.fixtures.json` 新增四个 domain,task kind 为空。 +- `packages/core/src/managed-runtime/managed-extension-projection.ts`:记录体登记。 +- `packages/core/src/managed-runtime/managed-session-authority.ts`:资源闭包与决策 8 的规则。 +- `packages/sdk-java/managed-agent-server`: + - `ManagedTeamRecords`(新增):校验器。 + - `ManagedExtensionProjection`:记录体登记。 + - `ManagedExtensionRecordStore`:资源闭包与决策 8 的规则。 +- 两种语言的测试:TypeScript 的 fixture 回放与 `managed-session-authority.team.test.ts`,以及 `ManagedTeamRecordContractTest` 与 `ManagedTeamStoreTest`。 +- 本设计的两种语言版本,以及 H4 与 H4a 设计中指向它的链接。 + +## 验证 + +- **Fixture 一致性。** 共享用例与后继对在两种语言中回放,每个无效用例都写明两个校验器必须报告的子句。 +- **Authority。** 测试套件放开 domain 门禁来写入记录,测试门禁本身的用例除外: + - 真实的门禁拒绝四个 domain 中的每一个,且不发布任何内容。 + - 团队开启、逐个加入成员、进入 closing 并被删除。其任务、消息与计划请求的链各自走到终点,重新打开的日志会重建每条链。 + - 决策 8 的每条规则都以其指名的消息拒绝相应的违规。 +- **Java store。** 同样的链在 MySQL 模式的 H2 上提交,同样的规则以 `managed_session_extension_record_rejected` 拒绝,团队行不投影 task。 +- **变异检查。** 依次禁用每个新增的守卫,其见证测试在各自的语言中变红。 + +## 验收标准 + +- TypeScript 与 Java 对共享 fixture 中的团队记录与后继对给出完全相同的接受与拒绝,现有的每个契约语料都原样回放通过。 +- 四个 domain 不进入启用列表,并在任何内容发布之前被拒绝。 +- 在两种语言中,决策 8 的每条规则都拒绝相应的违规,合法的团队、任务、消息与计划请求都能提交并重建。 +- 不改动公共 API 与迁移,现有的 H1–H4d-a 测试套件全部保持通过。 + +## 开放问题 + +1. **成员一侧的回执。** 发给成员的消息会成为成员 journal 中的一个 input,该 journal 需要自己的记录来识别重投。H4d-a 的 `session_message` 回执适用于同时是成员之父的 lead,但成员 Session 中没有团队记录。是复用该回执还是新增一种,由 H4e-b 决定。 +2. **发给 leader 的消息的 input。** 发给 `leader` 的消息在 lead 自己的 journal 中被接受,因此其 input 可以随 `accepted` 修订一起提交,就像 H4b 的 `sent` acceptance 那样。本契约尚未绑定二者。H4e-b 的 relay 用到时再加上这一绑定。 +3. **closing 中的 lead 提交什么。** 团队下的关闭级联(issue E3)经由 child-run 漏斗取消成员的 run。lead Session 处于 closing 时团队记录是否也必须提交(那样生命周期门禁就得放行它们),由 H4e-b 决定。 +4. **run 被续跑的成员。** 续跑(H4d-a)是一个新的 child run,而名册条目永远只指向一个 run。被续跑的成员是以新条目重新加入,还是让名册跟随续跑,由 H4e-b 决定。 + +## 后续工作 + +| 切片 | 范围 | +| ------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| H4e-b | managed 路径上的七个团队工具;managed Agent 工具的 `name` 与 teammate 路由(H4b 决策 11);mailbox 中继与成员一侧的投递(开放问题 1 与 2);经由 D6 action 的计划裁决;`requestMemberShutdown`;团队成员关系上的关闭级联(#13745 E3,开放问题 3);旧团队导入;经过与 H3 相同的实机验收(#13532)后启用。 | +| Detach | 把显式 detach 的 child 跨父关闭迁移到独立的持久所有者(#13745 E2),以及 [H4 设计](2026-10-04-managed-child-agents.zh-CN.md) 留下的所有者问题。 | diff --git a/packages/core/src/managed-runtime/contracts/managed-extension-projection-v1.fixtures.json b/packages/core/src/managed-runtime/contracts/managed-extension-projection-v1.fixtures.json index 07779c0e82..71098c3227 100644 --- a/packages/core/src/managed-runtime/contracts/managed-extension-projection-v1.fixtures.json +++ b/packages/core/src/managed-runtime/contracts/managed-extension-projection-v1.fixtures.json @@ -18,7 +18,11 @@ "additionalRecordBodies": { "schedule": null, "automation_run": "automation_run", - "session_message": null + "session_message": null, + "team_state": null, + "team_task": null, + "team_message": null, + "team_plan": null }, "taskStates": [ "pending", diff --git a/packages/core/src/managed-runtime/contracts/managed-team-record-v1.fixtures.json b/packages/core/src/managed-runtime/contracts/managed-team-record-v1.fixtures.json new file mode 100644 index 0000000000..bf67a83abf --- /dev/null +++ b/packages/core/src/managed-runtime/contracts/managed-team-record-v1.fixtures.json @@ -0,0 +1,3100 @@ +{ + "contract": "managed-team-record/1", + "domains": { + "team_state": { + "taskKind": null + }, + "team_task": { + "taskKind": null + }, + "team_message": { + "taskKind": null + }, + "team_plan": { + "taskKind": null + } + }, + "leader": "leader", + "limits": { + "maxMembers": 10, + "maxNameLength": 64, + "maxBlockers": 64, + "maxContentBytes": 65536, + "maxMetadataBytes": 32768 + }, + "messageKinds": [ + "message", + "task_assignment", + "plan_approval_request", + "shutdown_approved", + "shutdown_rejected", + "plan_approval_response", + "shutdown_request" + ], + "keys": { + "team_state": [ + "leadSessionId", + "lifecycle", + "members", + "membershipRevision", + "name", + "run", + "teamId" + ], + "team_task": [ + "activeForm", + "blockedBy", + "descriptionRef", + "metadataRef", + "number", + "owner", + "run", + "status", + "subject", + "taskId", + "teamId" + ], + "team_message": [ + "contentDigest", + "contentRef", + "from", + "inputId", + "kind", + "messageId", + "run", + "targetSessionId", + "teamId", + "to" + ], + "team_plan": [ + "decision", + "feedbackRef", + "member", + "planRef", + "planRevision", + "requestId", + "run", + "teamId" + ] + }, + "fixedKeys": { + "team_state": ["leadSessionId", "name", "teamId"], + "team_task": ["number", "taskId", "teamId"], + "team_message": [ + "contentDigest", + "contentRef", + "from", + "kind", + "messageId", + "teamId", + "to" + ], + "team_plan": ["member", "planRef", "planRevision", "requestId", "teamId"] + }, + "templates": { + "team_state": { + "teamId": "team-1", + "name": "review-team", + "leadSessionId": "session-lead", + "lifecycle": "active", + "membershipRevision": 1, + "members": [], + "run": { + "state": "admitted", + "reason": null, + "definition": null, + "executionCallId": null, + "effectId": null, + "dispatchId": null, + "deliveryId": null, + "execution": null, + "runtime": null, + "delivery": null + } + }, + "team_state_staffed": { + "teamId": "team-1", + "name": "review-team", + "leadSessionId": "session-lead", + "lifecycle": "active", + "membershipRevision": 3, + "members": [ + { + "name": "alice", + "childRunId": "run-1", + "planModeRequired": false + }, + { + "name": "bob", + "childRunId": "run-2", + "planModeRequired": true + } + ], + "run": { + "state": "admitted", + "reason": null, + "definition": null, + "executionCallId": null, + "effectId": null, + "dispatchId": null, + "deliveryId": null, + "execution": null, + "runtime": null, + "delivery": null + } + }, + "team_task": { + "teamId": "team-1", + "taskId": "task-1", + "number": 1, + "subject": "Audit the diff", + "descriptionRef": { + "resourceId": "description-1", + "kind": "managed-team-content", + "schemaVersion": 1, + "byteLength": 42, + "digest": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + }, + "activeForm": null, + "metadataRef": null, + "owner": null, + "status": "pending", + "blockedBy": [], + "run": { + "state": "admitted", + "reason": null, + "definition": null, + "executionCallId": null, + "effectId": null, + "dispatchId": null, + "deliveryId": null, + "execution": null, + "runtime": null, + "delivery": null + } + }, + "team_message": { + "teamId": "team-1", + "messageId": "message-1", + "kind": "message", + "from": "leader", + "to": "alice", + "contentRef": { + "resourceId": "message-1", + "kind": "managed-team-content", + "schemaVersion": 1, + "byteLength": 20, + "digest": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" + }, + "contentDigest": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "targetSessionId": null, + "inputId": null, + "run": { + "state": "settled", + "reason": null, + "definition": null, + "executionCallId": "call-send-1", + "effectId": null, + "dispatchId": null, + "deliveryId": null, + "execution": null, + "runtime": null, + "delivery": { + "target": "session", + "state": "planned" + } + } + }, + "team_plan": { + "teamId": "team-1", + "requestId": "request-1", + "member": "bob", + "planRevision": 1, + "planRef": { + "resourceId": "plan-1", + "kind": "managed-team-content", + "schemaVersion": 1, + "byteLength": 300, + "digest": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "decision": null, + "feedbackRef": null, + "run": { + "state": "waiting", + "reason": null, + "definition": null, + "executionCallId": null, + "effectId": null, + "dispatchId": null, + "deliveryId": null, + "execution": null, + "runtime": null, + "delivery": null + } + } + }, + "cases": [ + { + "id": "state-start", + "domain": "team_state", + "template": "team_state", + "patch": {}, + "valid": true, + "start": true + }, + { + "id": "state-staffed", + "domain": "team_state", + "template": "team_state_staffed", + "patch": {}, + "valid": true, + "start": false + }, + { + "id": "state-closing", + "domain": "team_state", + "template": "team_state_staffed", + "patch": { + "lifecycle": "closing" + }, + "valid": true, + "start": false + }, + { + "id": "state-deleted", + "domain": "team_state", + "template": "team_state_staffed", + "patch": { + "lifecycle": "deleted", + "run": { + "state": "cancelled" + } + }, + "valid": true, + "start": false + }, + { + "id": "state-ten-members", + "domain": "team_state", + "template": "team_state", + "patch": { + "members": [ + { + "name": "member-0", + "childRunId": "run-0", + "planModeRequired": false + }, + { + "name": "member-1", + "childRunId": "run-1", + "planModeRequired": false + }, + { + "name": "member-2", + "childRunId": "run-2", + "planModeRequired": false + }, + { + "name": "member-3", + "childRunId": "run-3", + "planModeRequired": false + }, + { + "name": "member-4", + "childRunId": "run-4", + "planModeRequired": false + }, + { + "name": "member-5", + "childRunId": "run-5", + "planModeRequired": false + }, + { + "name": "member-6", + "childRunId": "run-6", + "planModeRequired": false + }, + { + "name": "member-7", + "childRunId": "run-7", + "planModeRequired": false + }, + { + "name": "member-8", + "childRunId": "run-8", + "planModeRequired": false + }, + { + "name": "member-9", + "childRunId": "run-9", + "planModeRequired": false + } + ], + "membershipRevision": 11 + }, + "valid": true, + "start": false + }, + { + "id": "state-closing-empty", + "domain": "team_state", + "template": "team_state", + "patch": { + "lifecycle": "closing" + }, + "valid": true, + "start": false + }, + { + "id": "state-extra-key", + "domain": "team_state", + "template": "team_state", + "patch": { + "extra": "x" + }, + "valid": false, + "start": false, + "error": "exactly" + }, + { + "id": "state-members-null", + "domain": "team_state", + "template": "team_state", + "patch": { + "members": null + }, + "valid": false, + "start": false, + "error": "Team members must be an array" + }, + { + "id": "state-member-extra-key", + "domain": "team_state", + "template": "team_state", + "patch": { + "members": [ + { + "name": "alice", + "childRunId": "run-1", + "planModeRequired": false, + "color": "#FF6B6B" + } + ], + "membershipRevision": 2 + }, + "valid": false, + "start": false, + "error": "exactly" + }, + { + "id": "state-lifecycle-unknown", + "domain": "team_state", + "template": "team_state", + "patch": { + "lifecycle": "archived" + }, + "valid": false, + "start": false, + "error": "Team lifecycle must be active, closing or deleted" + }, + { + "id": "state-run-running", + "domain": "team_state", + "template": "team_state", + "patch": { + "run": { + "state": "running" + } + }, + "valid": false, + "start": false, + "error": "Team run must be admitted or cancelled" + }, + { + "id": "state-run-with-call", + "domain": "team_state", + "template": "team_state", + "patch": { + "run": { + "executionCallId": "call-1" + } + }, + "valid": false, + "start": false, + "error": "Team run must be purely logical" + }, + { + "id": "state-run-with-delivery", + "domain": "team_state", + "template": "team_state", + "patch": { + "run": { + "delivery": { + "target": "session", + "state": "planned" + } + } + }, + "valid": false, + "start": false, + "error": "Team run must be purely logical" + }, + { + "id": "state-deleted-admitted", + "domain": "team_state", + "template": "team_state", + "patch": { + "lifecycle": "deleted" + }, + "valid": false, + "start": false, + "error": "Team run must be cancelled exactly once the team is deleted" + }, + { + "id": "state-active-cancelled", + "domain": "team_state", + "template": "team_state", + "patch": { + "run": { + "state": "cancelled" + } + }, + "valid": false, + "start": false, + "error": "Team run must be cancelled exactly once the team is deleted" + }, + { + "id": "state-members-not-array", + "domain": "team_state", + "template": "team_state", + "patch": { + "members": "alice" + }, + "valid": false, + "start": false, + "error": "Team members must be an array" + }, + { + "id": "state-members-over-cap", + "domain": "team_state", + "template": "team_state", + "patch": { + "members": [ + { + "name": "member-0", + "childRunId": "run-0", + "planModeRequired": false + }, + { + "name": "member-1", + "childRunId": "run-1", + "planModeRequired": false + }, + { + "name": "member-2", + "childRunId": "run-2", + "planModeRequired": false + }, + { + "name": "member-3", + "childRunId": "run-3", + "planModeRequired": false + }, + { + "name": "member-4", + "childRunId": "run-4", + "planModeRequired": false + }, + { + "name": "member-5", + "childRunId": "run-5", + "planModeRequired": false + }, + { + "name": "member-6", + "childRunId": "run-6", + "planModeRequired": false + }, + { + "name": "member-7", + "childRunId": "run-7", + "planModeRequired": false + }, + { + "name": "member-8", + "childRunId": "run-8", + "planModeRequired": false + }, + { + "name": "member-9", + "childRunId": "run-9", + "planModeRequired": false + }, + { + "name": "member-10", + "childRunId": "run-10", + "planModeRequired": false + } + ], + "membershipRevision": 12 + }, + "valid": false, + "start": false, + "error": "Team members exceed 10 entries" + }, + { + "id": "state-member-leader", + "domain": "team_state", + "template": "team_state", + "patch": { + "members": [ + { + "name": "leader", + "childRunId": "run-1", + "planModeRequired": false + } + ], + "membershipRevision": 2 + }, + "valid": false, + "start": false, + "error": "Team member cannot be named leader" + }, + { + "id": "state-member-uppercase", + "domain": "team_state", + "template": "team_state", + "patch": { + "members": [ + { + "name": "Alice", + "childRunId": "run-1", + "planModeRequired": false + } + ], + "membershipRevision": 2 + }, + "valid": false, + "start": false, + "error": "Team member name must be dash-separated lowercase letters and digits" + }, + { + "id": "state-member-trailing-dash", + "domain": "team_state", + "template": "team_state", + "patch": { + "members": [ + { + "name": "alice-", + "childRunId": "run-1", + "planModeRequired": false + } + ], + "membershipRevision": 2 + }, + "valid": false, + "start": false, + "error": "Team member name must be dash-separated lowercase letters and digits" + }, + { + "id": "state-member-name-too-long", + "domain": "team_state", + "template": "team_state", + "patch": { + "members": [ + { + "name": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "childRunId": "run-1", + "planModeRequired": false + } + ], + "membershipRevision": 2 + }, + "valid": false, + "start": false, + "error": "Team member name must be dash-separated lowercase letters and digits" + }, + { + "id": "state-member-name-not-text", + "domain": "team_state", + "template": "team_state", + "patch": { + "members": [ + { + "name": 7, + "childRunId": "run-1", + "planModeRequired": false + } + ], + "membershipRevision": 2 + }, + "valid": false, + "start": false, + "error": "Team member name must be dash-separated lowercase letters and digits" + }, + { + "id": "state-member-name-at-limit", + "domain": "team_state", + "template": "team_state", + "patch": { + "members": [ + { + "name": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "childRunId": "run-1", + "planModeRequired": false + } + ], + "membershipRevision": 2 + }, + "valid": true, + "start": false + }, + { + "id": "state-member-duplicate-name", + "domain": "team_state", + "template": "team_state", + "patch": { + "members": [ + { + "name": "alice", + "childRunId": "run-1", + "planModeRequired": false + }, + { + "name": "alice", + "childRunId": "run-2", + "planModeRequired": false + } + ], + "membershipRevision": 3 + }, + "valid": false, + "start": false, + "error": "Team members must have distinct names and child runs" + }, + { + "id": "state-member-duplicate-run", + "domain": "team_state", + "template": "team_state", + "patch": { + "members": [ + { + "name": "alice", + "childRunId": "run-1", + "planModeRequired": false + }, + { + "name": "bob", + "childRunId": "run-1", + "planModeRequired": false + } + ], + "membershipRevision": 3 + }, + "valid": false, + "start": false, + "error": "Team members must have distinct names and child runs" + }, + { + "id": "state-member-empty-run", + "domain": "team_state", + "template": "team_state", + "patch": { + "members": [ + { + "name": "alice", + "childRunId": "", + "planModeRequired": false + } + ], + "membershipRevision": 2 + }, + "valid": false, + "start": false, + "error": "members.childRunId must be a non-empty string" + }, + { + "id": "state-member-plan-not-boolean", + "domain": "team_state", + "template": "team_state", + "patch": { + "members": [ + { + "name": "alice", + "childRunId": "run-1", + "planModeRequired": "yes" + } + ], + "membershipRevision": 2 + }, + "valid": false, + "start": false, + "error": "members.planModeRequired must be boolean" + }, + { + "id": "state-revision-behind", + "domain": "team_state", + "template": "team_state", + "patch": { + "members": [ + { + "name": "alice", + "childRunId": "run-1", + "planModeRequired": false + } + ] + }, + "valid": false, + "start": false, + "error": "Team membershipRevision must be one more than its member count" + }, + { + "id": "state-revision-ahead", + "domain": "team_state", + "template": "team_state", + "patch": { + "membershipRevision": 2 + }, + "valid": false, + "start": false, + "error": "Team membershipRevision must be one more than its member count" + }, + { + "id": "state-revision-zero", + "domain": "team_state", + "template": "team_state", + "patch": { + "membershipRevision": 0 + }, + "valid": false, + "start": false, + "error": "membershipRevision must be an integer from 1" + }, + { + "id": "state-revision-fraction", + "domain": "team_state", + "template": "team_state", + "patch": { + "membershipRevision": 1.5 + }, + "valid": false, + "start": false, + "error": "membershipRevision must be an integer from 1" + }, + { + "id": "state-name-spaced", + "domain": "team_state", + "template": "team_state", + "patch": { + "name": "review team" + }, + "valid": false, + "start": false, + "error": "Team name must be dash-separated lowercase letters and digits" + }, + { + "id": "state-name-leading-dash", + "domain": "team_state", + "template": "team_state", + "patch": { + "name": "-review" + }, + "valid": false, + "start": false, + "error": "Team name must be dash-separated lowercase letters and digits" + }, + { + "id": "state-name-double-dash", + "domain": "team_state", + "template": "team_state", + "patch": { + "name": "review--team" + }, + "valid": false, + "start": false, + "error": "Team name must be dash-separated lowercase letters and digits" + }, + { + "id": "state-lead-empty", + "domain": "team_state", + "template": "team_state", + "patch": { + "leadSessionId": "" + }, + "valid": false, + "start": false, + "error": "leadSessionId must be a non-empty string" + }, + { + "id": "state-team-id-control", + "domain": "team_state", + "template": "team_state", + "patch": { + "teamId": "team\u00071" + }, + "valid": false, + "start": false, + "error": "teamId must not contain control characters" + }, + { + "id": "task-start", + "domain": "team_task", + "template": "team_task", + "patch": {}, + "valid": true, + "start": true + }, + { + "id": "task-in-progress", + "domain": "team_task", + "template": "team_task", + "patch": { + "status": "in_progress", + "owner": "alice" + }, + "valid": true, + "start": true + }, + { + "id": "task-completed", + "domain": "team_task", + "template": "team_task", + "patch": { + "status": "completed", + "owner": "alice" + }, + "valid": true, + "start": true + }, + { + "id": "task-completed-unowned", + "domain": "team_task", + "template": "team_task", + "patch": { + "status": "completed" + }, + "valid": true, + "start": true + }, + { + "id": "task-owned-by-leader", + "domain": "team_task", + "template": "team_task", + "patch": { + "owner": "leader" + }, + "valid": true, + "start": true + }, + { + "id": "task-blocked", + "domain": "team_task", + "template": "team_task", + "patch": { + "blockedBy": ["task-0", "task-2"] + }, + "valid": true, + "start": true + }, + { + "id": "task-detailed", + "domain": "team_task", + "template": "team_task", + "patch": { + "activeForm": "Auditing the diff", + "metadataRef": { + "resourceId": "metadata-1", + "kind": "managed-team-content", + "schemaVersion": 1, + "byteLength": 32768, + "digest": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" + } + }, + "valid": true, + "start": true + }, + { + "id": "task-description-at-limit", + "domain": "team_task", + "template": "team_task", + "patch": { + "descriptionRef": { + "byteLength": 65536 + } + }, + "valid": true, + "start": true + }, + { + "id": "task-deleted", + "domain": "team_task", + "template": "team_task", + "patch": { + "status": "deleted", + "run": { + "state": "cancelled" + } + }, + "valid": true, + "start": false + }, + { + "id": "task-extra-key", + "domain": "team_task", + "template": "team_task", + "patch": { + "extra": "x" + }, + "valid": false, + "start": false, + "error": "exactly" + }, + { + "id": "task-status-unknown", + "domain": "team_task", + "template": "team_task", + "patch": { + "status": "blocked" + }, + "valid": false, + "start": false, + "error": "Team task status must be pending, in_progress, completed or deleted" + }, + { + "id": "task-in-progress-unowned", + "domain": "team_task", + "template": "team_task", + "patch": { + "status": "in_progress" + }, + "valid": false, + "start": false, + "error": "Team task in progress must have an owner" + }, + { + "id": "task-deleted-admitted", + "domain": "team_task", + "template": "team_task", + "patch": { + "status": "deleted" + }, + "valid": false, + "start": false, + "error": "Team task run must be cancelled exactly once the task is deleted" + }, + { + "id": "task-pending-cancelled", + "domain": "team_task", + "template": "team_task", + "patch": { + "run": { + "state": "cancelled" + } + }, + "valid": false, + "start": false, + "error": "Team task run must be cancelled exactly once the task is deleted" + }, + { + "id": "task-run-with-dispatch", + "domain": "team_task", + "template": "team_task", + "patch": { + "run": { + "dispatchId": "dispatch-1" + } + }, + "valid": false, + "start": false, + "error": "Team task run must be purely logical" + }, + { + "id": "task-run-running", + "domain": "team_task", + "template": "team_task", + "patch": { + "run": { + "state": "running" + } + }, + "valid": false, + "start": false, + "error": "Team task run must be admitted or cancelled" + }, + { + "id": "task-number-zero", + "domain": "team_task", + "template": "team_task", + "patch": { + "number": 0 + }, + "valid": false, + "start": false, + "error": "number must be an integer from 1" + }, + { + "id": "task-number-fraction", + "domain": "team_task", + "template": "team_task", + "patch": { + "number": 1.5 + }, + "valid": false, + "start": false, + "error": "number must be an integer from 1" + }, + { + "id": "task-number-text", + "domain": "team_task", + "template": "team_task", + "patch": { + "number": "1" + }, + "valid": false, + "start": false, + "error": "number must be an integer from 1" + }, + { + "id": "task-subject-empty", + "domain": "team_task", + "template": "team_task", + "patch": { + "subject": "" + }, + "valid": false, + "start": false, + "error": "subject must be a non-empty string" + }, + { + "id": "task-subject-control", + "domain": "team_task", + "template": "team_task", + "patch": { + "subject": "Audit\u0007" + }, + "valid": false, + "start": false, + "error": "subject must not contain control characters" + }, + { + "id": "task-subject-too-long", + "domain": "team_task", + "template": "team_task", + "patch": { + "subject": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + }, + "valid": false, + "start": false, + "error": "subject exceeds 4096 UTF-8 bytes" + }, + { + "id": "task-active-form-empty", + "domain": "team_task", + "template": "team_task", + "patch": { + "activeForm": "" + }, + "valid": false, + "start": false, + "error": "activeForm must be a non-empty string" + }, + { + "id": "task-description-too-large", + "domain": "team_task", + "template": "team_task", + "patch": { + "descriptionRef": { + "byteLength": 65537 + } + }, + "valid": false, + "start": false, + "error": "descriptionRef exceeds 65536 bytes" + }, + { + "id": "task-description-bad-digest", + "domain": "team_task", + "template": "team_task", + "patch": { + "descriptionRef": { + "digest": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + } + }, + "valid": false, + "start": false, + "error": "descriptionRef.digest must be a lowercase SHA-256 hex digest" + }, + { + "id": "task-metadata-too-large", + "domain": "team_task", + "template": "team_task", + "patch": { + "metadataRef": { + "resourceId": "metadata-1", + "kind": "managed-team-content", + "schemaVersion": 1, + "byteLength": 32769, + "digest": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" + } + }, + "valid": false, + "start": false, + "error": "metadataRef exceeds 32768 bytes" + }, + { + "id": "task-owner-uppercase", + "domain": "team_task", + "template": "team_task", + "patch": { + "owner": "Alice" + }, + "valid": false, + "start": false, + "error": "Team task owner must be dash-separated lowercase letters and digits" + }, + { + "id": "task-blocked-by-self", + "domain": "team_task", + "template": "team_task", + "patch": { + "blockedBy": ["task-1"] + }, + "valid": false, + "start": false, + "error": "Team task blockedBy must name distinct other tasks" + }, + { + "id": "task-blocked-by-twice", + "domain": "team_task", + "template": "team_task", + "patch": { + "blockedBy": ["task-0", "task-0"] + }, + "valid": false, + "start": false, + "error": "Team task blockedBy must name distinct other tasks" + }, + { + "id": "task-blocked-by-not-array", + "domain": "team_task", + "template": "team_task", + "patch": { + "blockedBy": "task-0" + }, + "valid": false, + "start": false, + "error": "Team task blockedBy must be an array" + }, + { + "id": "task-blocked-by-empty-id", + "domain": "team_task", + "template": "team_task", + "patch": { + "blockedBy": [""] + }, + "valid": false, + "start": false, + "error": "blockedBy must be a non-empty string" + }, + { + "id": "task-blocked-by-over-cap", + "domain": "team_task", + "template": "team_task", + "patch": { + "blockedBy": [ + "task-100", + "task-101", + "task-102", + "task-103", + "task-104", + "task-105", + "task-106", + "task-107", + "task-108", + "task-109", + "task-110", + "task-111", + "task-112", + "task-113", + "task-114", + "task-115", + "task-116", + "task-117", + "task-118", + "task-119", + "task-120", + "task-121", + "task-122", + "task-123", + "task-124", + "task-125", + "task-126", + "task-127", + "task-128", + "task-129", + "task-130", + "task-131", + "task-132", + "task-133", + "task-134", + "task-135", + "task-136", + "task-137", + "task-138", + "task-139", + "task-140", + "task-141", + "task-142", + "task-143", + "task-144", + "task-145", + "task-146", + "task-147", + "task-148", + "task-149", + "task-150", + "task-151", + "task-152", + "task-153", + "task-154", + "task-155", + "task-156", + "task-157", + "task-158", + "task-159", + "task-160", + "task-161", + "task-162", + "task-163", + "task-164" + ] + }, + "valid": false, + "start": false, + "error": "Team task blockedBy exceeds 64 entries" + }, + { + "id": "task-blocked-by-at-cap", + "domain": "team_task", + "template": "team_task", + "patch": { + "blockedBy": [ + "task-100", + "task-101", + "task-102", + "task-103", + "task-104", + "task-105", + "task-106", + "task-107", + "task-108", + "task-109", + "task-110", + "task-111", + "task-112", + "task-113", + "task-114", + "task-115", + "task-116", + "task-117", + "task-118", + "task-119", + "task-120", + "task-121", + "task-122", + "task-123", + "task-124", + "task-125", + "task-126", + "task-127", + "task-128", + "task-129", + "task-130", + "task-131", + "task-132", + "task-133", + "task-134", + "task-135", + "task-136", + "task-137", + "task-138", + "task-139", + "task-140", + "task-141", + "task-142", + "task-143", + "task-144", + "task-145", + "task-146", + "task-147", + "task-148", + "task-149", + "task-150", + "task-151", + "task-152", + "task-153", + "task-154", + "task-155", + "task-156", + "task-157", + "task-158", + "task-159", + "task-160", + "task-161", + "task-162", + "task-163" + ] + }, + "valid": true, + "start": true + }, + { + "id": "message-start", + "domain": "team_message", + "template": "team_message", + "patch": {}, + "valid": true, + "start": true + }, + { + "id": "message-member-to-member", + "domain": "team_message", + "template": "team_message", + "patch": { + "from": "alice", + "to": "bob" + }, + "valid": true, + "start": true + }, + { + "id": "message-to-leader", + "domain": "team_message", + "template": "team_message", + "patch": { + "from": "alice", + "to": "leader" + }, + "valid": true, + "start": true + }, + { + "id": "message-task-assignment", + "domain": "team_message", + "template": "team_message", + "patch": { + "kind": "task_assignment", + "from": "alice", + "to": "bob" + }, + "valid": true, + "start": true + }, + { + "id": "message-task-assignment-from-leader", + "domain": "team_message", + "template": "team_message", + "patch": { + "kind": "task_assignment" + }, + "valid": true, + "start": true + }, + { + "id": "message-plan-request", + "domain": "team_message", + "template": "team_message", + "patch": { + "kind": "plan_approval_request", + "from": "bob", + "to": "leader" + }, + "valid": true, + "start": true + }, + { + "id": "message-plan-response", + "domain": "team_message", + "template": "team_message", + "patch": { + "kind": "plan_approval_response", + "to": "bob" + }, + "valid": true, + "start": true + }, + { + "id": "message-shutdown-request", + "domain": "team_message", + "template": "team_message", + "patch": { + "kind": "shutdown_request" + }, + "valid": true, + "start": true + }, + { + "id": "message-shutdown-approved", + "domain": "team_message", + "template": "team_message", + "patch": { + "kind": "shutdown_approved", + "from": "alice", + "to": "leader" + }, + "valid": true, + "start": true + }, + { + "id": "message-shutdown-rejected", + "domain": "team_message", + "template": "team_message", + "patch": { + "kind": "shutdown_rejected", + "from": "alice", + "to": "leader" + }, + "valid": true, + "start": true + }, + { + "id": "message-claimed", + "domain": "team_message", + "template": "team_message", + "patch": { + "run": { + "delivery": { + "target": "session", + "state": "accepting" + } + }, + "targetSessionId": "session-alice" + }, + "valid": true, + "start": false + }, + { + "id": "message-accepted", + "domain": "team_message", + "template": "team_message", + "patch": { + "run": { + "delivery": { + "target": "session", + "state": "accepted" + } + }, + "targetSessionId": "session-alice", + "inputId": "message-1:input" + }, + "valid": true, + "start": false + }, + { + "id": "message-consumed", + "domain": "team_message", + "template": "team_message", + "patch": { + "run": { + "delivery": { + "target": "session", + "state": "consumed" + } + }, + "targetSessionId": "session-alice", + "inputId": "message-1:input" + }, + "valid": true, + "start": false + }, + { + "id": "message-cancelled-unresolved", + "domain": "team_message", + "template": "team_message", + "patch": { + "run": { + "delivery": { + "target": "session", + "state": "cancelled" + } + } + }, + "valid": true, + "start": false + }, + { + "id": "message-unknown", + "domain": "team_message", + "template": "team_message", + "patch": { + "run": { + "delivery": { + "target": "session", + "state": "unknown" + } + }, + "targetSessionId": "session-alice" + }, + "valid": true, + "start": false + }, + { + "id": "message-extra-key", + "domain": "team_message", + "template": "team_message", + "patch": { + "extra": "x" + }, + "valid": false, + "start": false, + "error": "exactly" + }, + { + "id": "message-kind-unknown", + "domain": "team_message", + "template": "team_message", + "patch": { + "kind": "broadcast" + }, + "valid": false, + "start": false, + "error": "Team message kind must be one of" + }, + { + "id": "message-self", + "domain": "team_message", + "template": "team_message", + "patch": { + "from": "alice" + }, + "valid": false, + "start": false, + "error": "Team message cannot address its own sender" + }, + { + "id": "message-from-uppercase", + "domain": "team_message", + "template": "team_message", + "patch": { + "from": "Leader" + }, + "valid": false, + "start": false, + "error": "Team message from must be dash-separated lowercase letters and digits" + }, + { + "id": "message-to-empty", + "domain": "team_message", + "template": "team_message", + "patch": { + "to": "" + }, + "valid": false, + "start": false, + "error": "Team message to must be dash-separated lowercase letters and digits" + }, + { + "id": "message-plan-request-from-leader", + "domain": "team_message", + "template": "team_message", + "patch": { + "kind": "plan_approval_request" + }, + "valid": false, + "start": false, + "error": "Team message of kind plan_approval_request must go from a member to the leader" + }, + { + "id": "message-plan-request-to-member", + "domain": "team_message", + "template": "team_message", + "patch": { + "kind": "plan_approval_request", + "from": "bob", + "to": "alice" + }, + "valid": false, + "start": false, + "error": "Team message of kind plan_approval_request must go from a member to the leader" + }, + { + "id": "message-shutdown-approved-to-member", + "domain": "team_message", + "template": "team_message", + "patch": { + "kind": "shutdown_approved", + "from": "alice", + "to": "bob" + }, + "valid": false, + "start": false, + "error": "Team message of kind shutdown_approved must go from a member to the leader" + }, + { + "id": "message-shutdown-rejected-from-leader", + "domain": "team_message", + "template": "team_message", + "patch": { + "kind": "shutdown_rejected" + }, + "valid": false, + "start": false, + "error": "Team message of kind shutdown_rejected must go from a member to the leader" + }, + { + "id": "message-shutdown-request-from-member", + "domain": "team_message", + "template": "team_message", + "patch": { + "kind": "shutdown_request", + "from": "bob" + }, + "valid": false, + "start": false, + "error": "Team message of kind shutdown_request must go from the leader to a member" + }, + { + "id": "message-plan-response-to-leader", + "domain": "team_message", + "template": "team_message", + "patch": { + "kind": "plan_approval_response", + "from": "bob", + "to": "leader" + }, + "valid": false, + "start": false, + "error": "Team message of kind plan_approval_response must go from the leader to a member" + }, + { + "id": "message-task-assignment-to-leader", + "domain": "team_message", + "template": "team_message", + "patch": { + "kind": "task_assignment", + "from": "alice", + "to": "leader" + }, + "valid": false, + "start": false, + "error": "Team message of kind task_assignment must go to a member" + }, + { + "id": "message-run-admitted", + "domain": "team_message", + "template": "team_message", + "patch": { + "run": { + "state": "admitted" + } + }, + "valid": false, + "start": false, + "error": "Team message run must be settled" + }, + { + "id": "message-run-with-execution", + "domain": "team_message", + "template": "team_message", + "patch": { + "run": { + "execution": "settled" + } + }, + "valid": false, + "start": false, + "error": "Team message run must be purely logical" + }, + { + "id": "message-run-with-effect", + "domain": "team_message", + "template": "team_message", + "patch": { + "run": { + "executionCallId": null, + "effectId": "effect-1" + } + }, + "valid": false, + "start": false, + "error": "Team message run must be purely logical" + }, + { + "id": "message-without-call", + "domain": "team_message", + "template": "team_message", + "patch": { + "run": { + "executionCallId": null + } + }, + "valid": false, + "start": false, + "error": "Team message run must name its sending call" + }, + { + "id": "message-without-delivery", + "domain": "team_message", + "template": "team_message", + "patch": { + "run": { + "delivery": null + } + }, + "valid": false, + "start": false, + "error": "Team message delivery must be a session delivery" + }, + { + "id": "message-digest-mismatch", + "domain": "team_message", + "template": "team_message", + "patch": { + "contentDigest": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + }, + "valid": false, + "start": false, + "error": "Team message contentDigest must name the content's digest" + }, + { + "id": "message-content-too-large", + "domain": "team_message", + "template": "team_message", + "patch": { + "contentRef": { + "byteLength": 65537 + } + }, + "valid": false, + "start": false, + "error": "contentRef exceeds 65536 bytes" + }, + { + "id": "message-claimed-without-target", + "domain": "team_message", + "template": "team_message", + "patch": { + "run": { + "delivery": { + "target": "session", + "state": "accepting" + } + } + }, + "valid": false, + "start": false, + "error": "Team message must fix its target once its delivery is claimed" + }, + { + "id": "message-accepted-without-input", + "domain": "team_message", + "template": "team_message", + "patch": { + "run": { + "delivery": { + "target": "session", + "state": "accepted" + } + }, + "targetSessionId": "session-alice" + }, + "valid": false, + "start": false, + "error": "Team message names its target's input exactly once accepted" + }, + { + "id": "message-planned-with-input", + "domain": "team_message", + "template": "team_message", + "patch": { + "inputId": "message-1:input" + }, + "valid": false, + "start": false, + "error": "Team message names its target's input exactly once accepted" + }, + { + "id": "message-target-empty", + "domain": "team_message", + "template": "team_message", + "patch": { + "targetSessionId": "" + }, + "valid": false, + "start": false, + "error": "targetSessionId must be a non-empty string" + }, + { + "id": "plan-start", + "domain": "team_plan", + "template": "team_plan", + "patch": {}, + "valid": true, + "start": true + }, + { + "id": "plan-approved", + "domain": "team_plan", + "template": "team_plan", + "patch": { + "decision": "approved", + "run": { + "state": "settled" + } + }, + "valid": true, + "start": false + }, + { + "id": "plan-rejected-with-feedback", + "domain": "team_plan", + "template": "team_plan", + "patch": { + "decision": "rejected", + "run": { + "state": "settled" + }, + "feedbackRef": { + "resourceId": "feedback-1", + "kind": "managed-team-content", + "schemaVersion": 1, + "byteLength": 80, + "digest": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" + } + }, + "valid": true, + "start": false + }, + { + "id": "plan-withdrawn", + "domain": "team_plan", + "template": "team_plan", + "patch": { + "run": { + "state": "cancelled" + } + }, + "valid": true, + "start": false + }, + { + "id": "plan-extra-key", + "domain": "team_plan", + "template": "team_plan", + "patch": { + "extra": "x" + }, + "valid": false, + "start": false, + "error": "exactly" + }, + { + "id": "plan-member-leader", + "domain": "team_plan", + "template": "team_plan", + "patch": { + "member": "leader" + }, + "valid": false, + "start": false, + "error": "Team plan member cannot be the leader" + }, + { + "id": "plan-member-uppercase", + "domain": "team_plan", + "template": "team_plan", + "patch": { + "member": "Bob" + }, + "valid": false, + "start": false, + "error": "Team plan member must be dash-separated lowercase letters and digits" + }, + { + "id": "plan-run-admitted", + "domain": "team_plan", + "template": "team_plan", + "patch": { + "run": { + "state": "admitted" + } + }, + "valid": false, + "start": false, + "error": "Team plan run must be waiting or settled or cancelled" + }, + { + "id": "plan-run-with-delivery", + "domain": "team_plan", + "template": "team_plan", + "patch": { + "run": { + "delivery": { + "target": "session", + "state": "planned" + } + } + }, + "valid": false, + "start": false, + "error": "Team plan run must be purely logical" + }, + { + "id": "plan-decision-unknown", + "domain": "team_plan", + "template": "team_plan", + "patch": { + "decision": "deferred", + "run": { + "state": "settled" + } + }, + "valid": false, + "start": false, + "error": "Team plan decision must be approved or rejected" + }, + { + "id": "plan-waiting-decided", + "domain": "team_plan", + "template": "team_plan", + "patch": { + "decision": "approved" + }, + "valid": false, + "start": false, + "error": "Team plan has a decision exactly once its run settled" + }, + { + "id": "plan-settled-undecided", + "domain": "team_plan", + "template": "team_plan", + "patch": { + "run": { + "state": "settled" + } + }, + "valid": false, + "start": false, + "error": "Team plan has a decision exactly once its run settled" + }, + { + "id": "plan-cancelled-decided", + "domain": "team_plan", + "template": "team_plan", + "patch": { + "decision": "rejected", + "run": { + "state": "cancelled" + } + }, + "valid": false, + "start": false, + "error": "Team plan has a decision exactly once its run settled" + }, + { + "id": "plan-feedback-without-decision", + "domain": "team_plan", + "template": "team_plan", + "patch": { + "feedbackRef": { + "resourceId": "feedback-1", + "kind": "managed-team-content", + "schemaVersion": 1, + "byteLength": 80, + "digest": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" + } + }, + "valid": false, + "start": false, + "error": "Team plan feedback comes only with a decision" + }, + { + "id": "plan-revision-zero", + "domain": "team_plan", + "template": "team_plan", + "patch": { + "planRevision": 0 + }, + "valid": false, + "start": false, + "error": "planRevision must be an integer from 1" + }, + { + "id": "plan-too-large", + "domain": "team_plan", + "template": "team_plan", + "patch": { + "planRef": { + "byteLength": 65537 + } + }, + "valid": false, + "start": false, + "error": "planRef exceeds 65536 bytes" + }, + { + "id": "plan-feedback-too-large", + "domain": "team_plan", + "template": "team_plan", + "patch": { + "decision": "approved", + "run": { + "state": "settled" + }, + "feedbackRef": { + "resourceId": "feedback-1", + "kind": "managed-team-content", + "schemaVersion": 1, + "byteLength": 65537, + "digest": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" + } + }, + "valid": false, + "start": false, + "error": "feedbackRef exceeds 65536 bytes" + }, + { + "id": "plan-request-empty", + "domain": "team_plan", + "template": "team_plan", + "patch": { + "requestId": "" + }, + "valid": false, + "start": false, + "error": "requestId must be a non-empty string" + } + ], + "successors": [ + { + "id": "state-join-one", + "domain": "team_state", + "template": "team_state", + "before": {}, + "after": { + "members": [ + { + "name": "alice", + "childRunId": "run-1", + "planModeRequired": false + } + ], + "membershipRevision": 2 + }, + "valid": true + }, + { + "id": "state-join-second", + "domain": "team_state", + "template": "team_state", + "before": { + "members": [ + { + "name": "alice", + "childRunId": "run-1", + "planModeRequired": false + } + ], + "membershipRevision": 2 + }, + "after": { + "members": [ + { + "name": "alice", + "childRunId": "run-1", + "planModeRequired": false + }, + { + "name": "bob", + "childRunId": "run-2", + "planModeRequired": true + } + ], + "membershipRevision": 3 + }, + "valid": true + }, + { + "id": "state-join-two-at-once", + "domain": "team_state", + "template": "team_state", + "before": {}, + "after": { + "members": [ + { + "name": "alice", + "childRunId": "run-1", + "planModeRequired": false + }, + { + "name": "bob", + "childRunId": "run-2", + "planModeRequired": true + } + ], + "membershipRevision": 3 + }, + "valid": false + }, + { + "id": "state-join-while-closing", + "domain": "team_state", + "template": "team_state", + "before": { + "lifecycle": "closing" + }, + "after": { + "members": [ + { + "name": "alice", + "childRunId": "run-1", + "planModeRequired": false + } + ], + "membershipRevision": 2, + "lifecycle": "closing" + }, + "valid": false + }, + { + "id": "state-join-and-close", + "domain": "team_state", + "template": "team_state", + "before": {}, + "after": { + "members": [ + { + "name": "alice", + "childRunId": "run-1", + "planModeRequired": false + } + ], + "membershipRevision": 2, + "lifecycle": "closing" + }, + "valid": false + }, + { + "id": "state-close", + "domain": "team_state", + "template": "team_state", + "before": { + "members": [ + { + "name": "alice", + "childRunId": "run-1", + "planModeRequired": false + } + ], + "membershipRevision": 2 + }, + "after": { + "members": [ + { + "name": "alice", + "childRunId": "run-1", + "planModeRequired": false + } + ], + "membershipRevision": 2, + "lifecycle": "closing" + }, + "valid": true + }, + { + "id": "state-delete", + "domain": "team_state", + "template": "team_state", + "before": { + "members": [ + { + "name": "alice", + "childRunId": "run-1", + "planModeRequired": false + } + ], + "membershipRevision": 2, + "lifecycle": "closing" + }, + "after": { + "members": [ + { + "name": "alice", + "childRunId": "run-1", + "planModeRequired": false + } + ], + "membershipRevision": 2, + "lifecycle": "deleted", + "run": { + "state": "cancelled" + } + }, + "valid": true + }, + { + "id": "state-delete-while-active", + "domain": "team_state", + "template": "team_state", + "before": { + "members": [ + { + "name": "alice", + "childRunId": "run-1", + "planModeRequired": false + } + ], + "membershipRevision": 2 + }, + "after": { + "members": [ + { + "name": "alice", + "childRunId": "run-1", + "planModeRequired": false + } + ], + "membershipRevision": 2, + "lifecycle": "deleted", + "run": { + "state": "cancelled" + } + }, + "valid": false + }, + { + "id": "state-reopen", + "domain": "team_state", + "template": "team_state", + "before": { + "lifecycle": "closing" + }, + "after": {}, + "valid": false + }, + { + "id": "state-stay-active", + "domain": "team_state", + "template": "team_state", + "before": { + "members": [ + { + "name": "alice", + "childRunId": "run-1", + "planModeRequired": false + } + ], + "membershipRevision": 2 + }, + "after": { + "members": [ + { + "name": "alice", + "childRunId": "run-1", + "planModeRequired": false + } + ], + "membershipRevision": 2 + }, + "valid": true + }, + { + "id": "state-deleted-restated", + "domain": "team_state", + "template": "team_state", + "before": { + "lifecycle": "deleted", + "run": { + "state": "cancelled" + } + }, + "after": { + "lifecycle": "deleted", + "run": { + "state": "cancelled" + } + }, + "valid": true + }, + { + "id": "state-rename", + "domain": "team_state", + "template": "team_state", + "before": {}, + "after": { + "name": "other-team" + }, + "valid": false + }, + { + "id": "state-change-lead", + "domain": "team_state", + "template": "team_state", + "before": {}, + "after": { + "leadSessionId": "session-other" + }, + "valid": false + }, + { + "id": "state-reorder-members", + "domain": "team_state", + "template": "team_state", + "before": { + "members": [ + { + "name": "alice", + "childRunId": "run-1", + "planModeRequired": false + }, + { + "name": "bob", + "childRunId": "run-2", + "planModeRequired": true + } + ], + "membershipRevision": 3 + }, + "after": { + "members": [ + { + "name": "bob", + "childRunId": "run-2", + "planModeRequired": true + }, + { + "name": "alice", + "childRunId": "run-1", + "planModeRequired": false + } + ], + "membershipRevision": 3 + }, + "valid": false + }, + { + "id": "state-change-member", + "domain": "team_state", + "template": "team_state", + "before": { + "members": [ + { + "name": "alice", + "childRunId": "run-1", + "planModeRequired": false + }, + { + "name": "bob", + "childRunId": "run-2", + "planModeRequired": true + } + ], + "membershipRevision": 3 + }, + "after": { + "members": [ + { + "name": "alice", + "childRunId": "run-1", + "planModeRequired": false + }, + { + "name": "bob", + "childRunId": "run-2", + "planModeRequired": false + } + ], + "membershipRevision": 3 + }, + "valid": false + }, + { + "id": "state-replace-member", + "domain": "team_state", + "template": "team_state", + "before": { + "members": [ + { + "name": "alice", + "childRunId": "run-1", + "planModeRequired": false + }, + { + "name": "bob", + "childRunId": "run-2", + "planModeRequired": true + } + ], + "membershipRevision": 3 + }, + "after": { + "members": [ + { + "name": "alice", + "childRunId": "run-1", + "planModeRequired": false + }, + { + "name": "carol", + "childRunId": "run-3", + "planModeRequired": false + } + ], + "membershipRevision": 3 + }, + "valid": false + }, + { + "id": "state-drop-member", + "domain": "team_state", + "template": "team_state", + "before": { + "members": [ + { + "name": "alice", + "childRunId": "run-1", + "planModeRequired": false + }, + { + "name": "bob", + "childRunId": "run-2", + "planModeRequired": true + } + ], + "membershipRevision": 3 + }, + "after": { + "members": [ + { + "name": "alice", + "childRunId": "run-1", + "planModeRequired": false + } + ], + "membershipRevision": 2 + }, + "valid": false + }, + { + "id": "task-claim", + "domain": "team_task", + "template": "team_task", + "before": {}, + "after": { + "status": "in_progress", + "owner": "alice" + }, + "valid": true + }, + { + "id": "task-complete", + "domain": "team_task", + "template": "team_task", + "before": { + "status": "in_progress", + "owner": "alice" + }, + "after": { + "status": "completed", + "owner": "alice" + }, + "valid": true + }, + { + "id": "task-reopen", + "domain": "team_task", + "template": "team_task", + "before": { + "status": "completed", + "owner": "alice" + }, + "after": { + "status": "pending", + "owner": "alice" + }, + "valid": true + }, + { + "id": "task-reassign", + "domain": "team_task", + "template": "team_task", + "before": { + "status": "in_progress", + "owner": "alice" + }, + "after": { + "status": "in_progress", + "owner": "bob" + }, + "valid": true + }, + { + "id": "task-edit", + "domain": "team_task", + "template": "team_task", + "before": {}, + "after": { + "subject": "Audit the whole diff", + "activeForm": "Auditing", + "descriptionRef": { + "resourceId": "description-2", + "kind": "managed-team-content", + "schemaVersion": 1, + "byteLength": 50, + "digest": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + } + }, + "valid": true + }, + { + "id": "task-add-blocker", + "domain": "team_task", + "template": "team_task", + "before": {}, + "after": { + "blockedBy": ["task-0"] + }, + "valid": true + }, + { + "id": "task-append-blocker", + "domain": "team_task", + "template": "team_task", + "before": { + "blockedBy": ["task-0"] + }, + "after": { + "blockedBy": ["task-0", "task-2"] + }, + "valid": true + }, + { + "id": "task-drop-blocker", + "domain": "team_task", + "template": "team_task", + "before": { + "blockedBy": ["task-0"] + }, + "after": { + "blockedBy": [] + }, + "valid": false + }, + { + "id": "task-reorder-blockers", + "domain": "team_task", + "template": "team_task", + "before": { + "blockedBy": ["task-0", "task-2"] + }, + "after": { + "blockedBy": ["task-2", "task-0"] + }, + "valid": false + }, + { + "id": "task-renumber", + "domain": "team_task", + "template": "team_task", + "before": {}, + "after": { + "number": 2 + }, + "valid": false + }, + { + "id": "task-move-team", + "domain": "team_task", + "template": "team_task", + "before": {}, + "after": { + "teamId": "team-2" + }, + "valid": false + }, + { + "id": "task-delete", + "domain": "team_task", + "template": "team_task", + "before": {}, + "after": { + "status": "deleted", + "run": { + "state": "cancelled" + } + }, + "valid": true + }, + { + "id": "task-delete-claimed", + "domain": "team_task", + "template": "team_task", + "before": { + "status": "in_progress", + "owner": "alice" + }, + "after": { + "status": "deleted", + "owner": "alice", + "run": { + "state": "cancelled" + } + }, + "valid": true + }, + { + "id": "task-deleted-restated", + "domain": "team_task", + "template": "team_task", + "before": { + "status": "deleted", + "run": { + "state": "cancelled" + } + }, + "after": { + "status": "deleted", + "run": { + "state": "cancelled" + } + }, + "valid": true + }, + { + "id": "task-deleted-edited", + "domain": "team_task", + "template": "team_task", + "before": { + "status": "deleted", + "run": { + "state": "cancelled" + } + }, + "after": { + "status": "deleted", + "run": { + "state": "cancelled" + }, + "subject": "Something else" + }, + "valid": false + }, + { + "id": "task-undelete", + "domain": "team_task", + "template": "team_task", + "before": { + "status": "deleted", + "run": { + "state": "cancelled" + } + }, + "after": {}, + "valid": false + }, + { + "id": "message-claim", + "domain": "team_message", + "template": "team_message", + "before": {}, + "after": { + "run": { + "delivery": { + "target": "session", + "state": "accepting" + } + }, + "targetSessionId": "session-alice" + }, + "valid": true + }, + { + "id": "message-accept", + "domain": "team_message", + "template": "team_message", + "before": { + "run": { + "delivery": { + "target": "session", + "state": "accepting" + } + }, + "targetSessionId": "session-alice" + }, + "after": { + "run": { + "delivery": { + "target": "session", + "state": "accepted" + } + }, + "targetSessionId": "session-alice", + "inputId": "message-1:input" + }, + "valid": true + }, + { + "id": "message-consume", + "domain": "team_message", + "template": "team_message", + "before": { + "run": { + "delivery": { + "target": "session", + "state": "accepted" + } + }, + "targetSessionId": "session-alice", + "inputId": "message-1:input" + }, + "after": { + "run": { + "delivery": { + "target": "session", + "state": "consumed" + } + }, + "targetSessionId": "session-alice", + "inputId": "message-1:input" + }, + "valid": true + }, + { + "id": "message-cancel", + "domain": "team_message", + "template": "team_message", + "before": {}, + "after": { + "run": { + "delivery": { + "target": "session", + "state": "cancelled" + } + } + }, + "valid": true + }, + { + "id": "message-unknown-accepted", + "domain": "team_message", + "template": "team_message", + "before": { + "run": { + "delivery": { + "target": "session", + "state": "unknown" + } + }, + "targetSessionId": "session-alice" + }, + "after": { + "run": { + "delivery": { + "target": "session", + "state": "accepted" + } + }, + "targetSessionId": "session-alice", + "inputId": "message-1:input" + }, + "valid": true + }, + { + "id": "message-skip-to-accepted", + "domain": "team_message", + "template": "team_message", + "before": {}, + "after": { + "run": { + "delivery": { + "target": "session", + "state": "accepted" + } + }, + "targetSessionId": "session-alice", + "inputId": "message-1:input" + }, + "valid": false + }, + { + "id": "message-retarget", + "domain": "team_message", + "template": "team_message", + "before": { + "run": { + "delivery": { + "target": "session", + "state": "accepting" + } + }, + "targetSessionId": "session-alice" + }, + "after": { + "run": { + "delivery": { + "target": "session", + "state": "accepting" + } + }, + "targetSessionId": "session-bob" + }, + "valid": false + }, + { + "id": "message-reinput", + "domain": "team_message", + "template": "team_message", + "before": { + "run": { + "delivery": { + "target": "session", + "state": "accepted" + } + }, + "targetSessionId": "session-alice", + "inputId": "message-1:input" + }, + "after": { + "run": { + "delivery": { + "target": "session", + "state": "consumed" + } + }, + "targetSessionId": "session-alice", + "inputId": "message-1:other" + }, + "valid": false + }, + { + "id": "message-change-content", + "domain": "team_message", + "template": "team_message", + "before": {}, + "after": { + "contentRef": { + "resourceId": "message-2", + "kind": "managed-team-content", + "schemaVersion": 1, + "byteLength": 20, + "digest": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" + } + }, + "valid": false + }, + { + "id": "message-change-kind", + "domain": "team_message", + "template": "team_message", + "before": {}, + "after": { + "kind": "task_assignment" + }, + "valid": false + }, + { + "id": "message-change-recipient", + "domain": "team_message", + "template": "team_message", + "before": {}, + "after": { + "to": "bob" + }, + "valid": false + }, + { + "id": "message-change-team", + "domain": "team_message", + "template": "team_message", + "before": {}, + "after": { + "teamId": "team-2" + }, + "valid": false + }, + { + "id": "message-change-call", + "domain": "team_message", + "template": "team_message", + "before": {}, + "after": { + "run": { + "executionCallId": "call-send-2" + } + }, + "valid": false + }, + { + "id": "plan-approve", + "domain": "team_plan", + "template": "team_plan", + "before": {}, + "after": { + "decision": "approved", + "run": { + "state": "settled" + } + }, + "valid": true + }, + { + "id": "plan-reject-with-feedback", + "domain": "team_plan", + "template": "team_plan", + "before": {}, + "after": { + "decision": "rejected", + "run": { + "state": "settled" + }, + "feedbackRef": { + "resourceId": "feedback-1", + "kind": "managed-team-content", + "schemaVersion": 1, + "byteLength": 80, + "digest": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" + } + }, + "valid": true + }, + { + "id": "plan-withdraw", + "domain": "team_plan", + "template": "team_plan", + "before": {}, + "after": { + "run": { + "state": "cancelled" + } + }, + "valid": true + }, + { + "id": "plan-decided-restated", + "domain": "team_plan", + "template": "team_plan", + "before": { + "decision": "approved", + "run": { + "state": "settled" + } + }, + "after": { + "decision": "approved", + "run": { + "state": "settled" + } + }, + "valid": true + }, + { + "id": "plan-change-decision", + "domain": "team_plan", + "template": "team_plan", + "before": { + "decision": "approved", + "run": { + "state": "settled" + } + }, + "after": { + "decision": "rejected", + "run": { + "state": "settled" + } + }, + "valid": false + }, + { + "id": "plan-add-feedback-later", + "domain": "team_plan", + "template": "team_plan", + "before": { + "decision": "approved", + "run": { + "state": "settled" + } + }, + "after": { + "decision": "approved", + "run": { + "state": "settled" + }, + "feedbackRef": { + "resourceId": "feedback-1", + "kind": "managed-team-content", + "schemaVersion": 1, + "byteLength": 80, + "digest": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" + } + }, + "valid": false + }, + { + "id": "plan-decide-withdrawn", + "domain": "team_plan", + "template": "team_plan", + "before": { + "run": { + "state": "cancelled" + } + }, + "after": { + "decision": "approved", + "run": { + "state": "settled" + } + }, + "valid": false + }, + { + "id": "plan-reopen", + "domain": "team_plan", + "template": "team_plan", + "before": { + "decision": "approved", + "run": { + "state": "settled" + } + }, + "after": {}, + "valid": false + }, + { + "id": "plan-change-revision", + "domain": "team_plan", + "template": "team_plan", + "before": {}, + "after": { + "planRevision": 2 + }, + "valid": false + }, + { + "id": "plan-change-plan", + "domain": "team_plan", + "template": "team_plan", + "before": {}, + "after": { + "planRef": { + "resourceId": "plan-2", + "kind": "managed-team-content", + "schemaVersion": 1, + "byteLength": 300, + "digest": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + } + }, + "valid": false + }, + { + "id": "plan-change-member", + "domain": "team_plan", + "template": "team_plan", + "before": {}, + "after": { + "member": "alice" + }, + "valid": false + } + ] +} diff --git a/packages/core/src/managed-runtime/managed-extension-projection.ts b/packages/core/src/managed-runtime/managed-extension-projection.ts index 065713c27e..e24f45cbb1 100644 --- a/packages/core/src/managed-runtime/managed-extension-projection.ts +++ b/packages/core/src/managed-runtime/managed-extension-projection.ts @@ -67,6 +67,20 @@ import { isSessionMessageSuccessor, parseSessionMessage, } from './managed-session-message-record.js'; +import { + isTeamMessageStart, + isTeamMessageSuccessor, + isTeamPlanStart, + isTeamPlanSuccessor, + isTeamStateStart, + isTeamStateSuccessor, + isTeamTaskStart, + isTeamTaskSuccessor, + parseTeamMessage, + parseTeamPlan, + parseTeamState, + parseTeamTask, +} from './managed-team-record.js'; // H0c of #12827: how the Session authority keys, chains and projects the // Stage H records of managed-extension-record/1. The shared fixtures in @@ -265,6 +279,42 @@ export const MANAGED_EXTENSION_RECORD_BODIES: Readonly< isStart: isSessionMessageStart, isSuccessor: isSessionMessageSuccessor, }), + team_state: Object.freeze({ + taskKindOf: () => null, + parse: (value: unknown) => { + const record = parseTeamState(value); + return { record, recordId: record.teamId, run: record.run }; + }, + isStart: isTeamStateStart, + isSuccessor: isTeamStateSuccessor, + }), + team_task: Object.freeze({ + taskKindOf: () => null, + parse: (value: unknown) => { + const record = parseTeamTask(value); + return { record, recordId: record.taskId, run: record.run }; + }, + isStart: isTeamTaskStart, + isSuccessor: isTeamTaskSuccessor, + }), + team_message: Object.freeze({ + taskKindOf: () => null, + parse: (value: unknown) => { + const record = parseTeamMessage(value); + return { record, recordId: record.messageId, run: record.run }; + }, + isStart: isTeamMessageStart, + isSuccessor: isTeamMessageSuccessor, + }), + team_plan: Object.freeze({ + taskKindOf: () => null, + parse: (value: unknown) => { + const record = parseTeamPlan(value); + return { record, recordId: record.requestId, run: record.run }; + }, + isStart: isTeamPlanStart, + isSuccessor: isTeamPlanSuccessor, + }), }); // An envelope domain's commits predates any body a later slice could diff --git a/packages/core/src/managed-runtime/managed-session-authority.team.test.ts b/packages/core/src/managed-runtime/managed-session-authority.team.test.ts new file mode 100644 index 0000000000..ba23712f16 --- /dev/null +++ b/packages/core/src/managed-runtime/managed-session-authority.team.test.ts @@ -0,0 +1,950 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as fs from 'node:fs/promises'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { SessionWriterLease } from '../services/session-writer-lease.js'; +import { + LocalManagedSessionAuthority, + ManagedSessionConflictError, +} from './managed-session-authority.js'; +import { + childAttachBody, + childDispatchBody, + childLaunchBody, + childSettleCompletedBody, +} from './managed-child-operations.js'; +import { type ChildAgentRun } from './managed-child-run-record.js'; +import { LocalManagedSessionResourceStore } from './managed-session-resources.js'; +import { + type ManagedSessionDomain, + type ManagedSessionDurableRef, +} from './managed-session-records.js'; +import { type TeamMember } from './managed-team-record.js'; + +// H4e-a: the four team domains are registered and checked but not enabled +// for submission. The flags below lift the domain gate for them (and the +// shell kind gate for one planting), so the suite runs the commit and +// rebuild paths ahead of enablement. +const enablement = vi.hoisted(() => ({ teams: true, shellKind: false })); + +vi.mock('./managed-session-records.js', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + assertManagedSessionDomainEnabled: (domain: string) => { + if (!(domain.startsWith('team_') && enablement.teams)) { + actual.assertManagedSessionDomainEnabled( + domain as Parameters< + typeof actual.assertManagedSessionDomainEnabled + >[0], + ); + } + }, + assertManagedSessionChildRunKindEnabled: (kind: string) => { + if (!(kind === 'shell' && enablement.shellKind)) { + actual.assertManagedSessionChildRunKindEnabled(kind); + } + }, + }; +}); + +const temporaryDirectories = new Set(); + +afterEach(async () => { + enablement.teams = true; + enablement.shellKind = false; + for (const directory of temporaryDirectories) { + await fs.rm(directory, { recursive: true, force: true }); + } + temporaryDirectories.clear(); +}); + +const sessionId = '550e8400-e29b-41d4-a716-446655440000'; +const sessionKey = { + tenantId: 'tenant-1', + workspaceId: 'workspace-1', + sessionId, +}; +const TEAM_DOMAINS = [ + 'team_state', + 'team_task', + 'team_message', + 'team_plan', +] as const; + +interface Harness { + readonly runtimeBaseDir: string; + readonly transcriptPath: string; + readonly store: LocalManagedSessionResourceStore; + commands: number; +} + +async function createHarness(): Promise { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'qwen-managed-agent-')); + temporaryDirectories.add(root); + const runtimeBaseDir = path.join(root, 'runtime'); + const transcriptPath = path.join(root, 'chats', `${sessionId}.jsonl`); + await fs.mkdir(runtimeBaseDir, { recursive: true }); + await fs.mkdir(path.dirname(transcriptPath), { recursive: true }); + return { + runtimeBaseDir, + transcriptPath, + store: LocalManagedSessionResourceStore.create({ + runtimeBaseDir, + sessionKey, + }), + commands: 0, + }; +} + +async function withAuthority( + harness: Harness, + run: (authority: LocalManagedSessionAuthority) => Promise, + options: { create?: boolean } = {}, +): Promise { + const lease = await SessionWriterLease.acquire({ + runtimeBaseDir: harness.runtimeBaseDir, + sessionId, + transcriptPath: harness.transcriptPath, + }); + try { + const create = + options.create === false + ? undefined + : { + definitionRef: await harness.store.publish( + 'managed-definition', + Buffer.from('{}', 'utf8'), + ), + rootSnapshotRef: await harness.store.publish( + 'managed-root', + Buffer.from('{}', 'utf8'), + ), + createdBy: 'daemon', + }; + const authority = await LocalManagedSessionAuthority.open({ + lease, + sessionKey, + cwd: '/workspace', + version: 'test', + resources: harness.store, + now: () => 1_000, + ...(create === undefined ? {} : { create }), + }); + return await run(authority); + } finally { + await lease.release().catch(() => undefined); + } +} + +const TRUSTED = { class: 'trusted_entry' } as const; + +interface Refs { + readonly input: ManagedSessionDurableRef; + readonly result: ManagedSessionDurableRef; + readonly receipt: ManagedSessionDurableRef; + readonly content: ManagedSessionDurableRef; +} + +async function publishRefs(harness: Harness): Promise { + return { + input: await harness.store.publish( + 'managed-input', + Buffer.from('{"prompt":"review the diff"}', 'utf8'), + ), + result: await harness.store.publish( + 'managed-child-result', + Buffer.from('{"summary":"clean"}', 'utf8'), + ), + receipt: await harness.store.publish( + 'managed-runtime-receipt', + Buffer.from('{"outcome":"settled"}', 'utf8'), + ), + content: await harness.store.publish( + 'managed-team-content', + Buffer.from('check the tests too', 'utf8'), + ), + }; +} + +/** A child run's revisions, from launch to its settled result. */ +function childLife( + refs: Refs, + childRunId: string, + childSessionId = `session-${childRunId}`, +): ChildAgentRun[] { + const launched = childLaunchBody({ + childRunId, + ownerScopeId: 'scope-main', + rootSessionId: sessionId, + completion: 'sent', + inputRef: refs.input, + workingDirectory: '.', + executionCallId: `call-${childRunId}`, + definition: { + definitionId: 'agent-def-1', + definitionRevision: 1, + definitionDigest: 'f'.repeat(64), + }, + }); + const dispatched = childDispatchBody(launched, { + dispatchId: `dispatch-${childRunId}`, + runtime: { runtimeBindingId: 'binding-1', generation: '1' }, + }); + const attached = childAttachBody(dispatched, { childSessionId }); + const settled = childSettleCompletedBody(attached, { + resultRef: refs.result, + terminalReceiptRef: refs.receipt, + }); + return [launched, dispatched, attached, settled]; +} + +function commit( + harness: Harness, + authority: LocalManagedSessionAuthority, + domain: ManagedSessionDomain, + record: unknown, +) { + harness.commands += 1; + return authority.commitExtensionRecord( + { + operation: 'commitTeamRecord', + commandId: `command-${harness.commands}`, + sessionKey, + contentDigest: 'd'.repeat(64), + }, + { domain, record }, + TRUSTED, + ); +} + +/** Commits the first `count` revisions of a child run. */ +async function commitChild( + harness: Harness, + authority: LocalManagedSessionAuthority, + life: readonly ChildAgentRun[], + count: number, +): Promise { + for (const record of life.slice(0, count)) { + await commit(harness, authority, 'child_run', record); + } +} + +function logical(state: string) { + return { + state, + reason: null, + definition: null, + executionCallId: null, + effectId: null, + dispatchId: null, + deliveryId: null, + execution: null, + runtime: null, + delivery: null, + }; +} + +const ALICE: TeamMember = { + name: 'alice', + childRunId: 'run-1', + planModeRequired: false, +}; +const BOB: TeamMember = { + name: 'bob', + childRunId: 'run-2', + planModeRequired: true, +}; + +function team( + members: readonly TeamMember[], + lifecycle = 'active', + overrides: Record = {}, +) { + return { + teamId: 'team-1', + name: 'review-team', + leadSessionId: sessionId, + lifecycle, + membershipRevision: members.length + 1, + members, + run: logical(lifecycle === 'deleted' ? 'cancelled' : 'admitted'), + ...overrides, + }; +} + +function task(refs: Refs, overrides: Record = {}) { + const status = (overrides['status'] as string | undefined) ?? 'pending'; + return { + teamId: 'team-1', + taskId: 'task-1', + number: 1, + subject: 'Audit the diff', + descriptionRef: refs.content, + activeForm: null, + metadataRef: null, + owner: null, + status, + blockedBy: [], + run: logical(status === 'deleted' ? 'cancelled' : 'admitted'), + ...overrides, + }; +} + +function message( + refs: Refs, + state = 'planned', + overrides: Record = {}, +) { + const accepted = state === 'accepted' || state === 'consumed'; + return { + teamId: 'team-1', + messageId: 'message-1', + kind: 'message', + from: 'leader', + to: 'alice', + contentRef: refs.content, + contentDigest: refs.content.digest, + targetSessionId: state === 'planned' ? null : 'session-run-1', + inputId: accepted ? 'message-1:input' : null, + run: { + ...logical('settled'), + executionCallId: 'call-send-1', + delivery: { target: 'session', state }, + }, + ...overrides, + }; +} + +function plan(refs: Refs, overrides: Record = {}) { + return { + teamId: 'team-1', + requestId: 'request-1', + member: 'bob', + planRevision: 1, + planRef: refs.content, + decision: null, + feedbackRef: null, + run: logical('waiting'), + ...overrides, + }; +} + +/** A team with alice and bob, both attached and running. */ +async function staffed( + harness: Harness, + authority: LocalManagedSessionAuthority, + refs: Refs, +): Promise { + await commitChild(harness, authority, childLife(refs, 'run-1'), 3); + await commitChild(harness, authority, childLife(refs, 'run-2'), 3); + await commit(harness, authority, 'team_state', team([])); + await commit(harness, authority, 'team_state', team([ALICE])); + await commit(harness, authority, 'team_state', team([ALICE, BOB])); +} + +async function publishedBodies( + harness: Harness, + domain: string, +): Promise { + try { + return ( + await fs.readdir( + path.join( + harness.runtimeBaseDir, + 'resources', + sessionId, + `managed-${domain}`, + ), + ) + ).length; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return 0; + throw error; + } +} + +describe('managed session authority team records (H4e)', () => { + it('refuses every team domain while it is disabled, publishing nothing', async () => { + enablement.teams = false; + const harness = await createHarness(); + const refs = await publishRefs(harness); + const records = { + team_state: team([]), + team_task: task(refs), + team_message: message(refs), + team_plan: plan(refs), + }; + await withAuthority(harness, async (authority) => { + for (const domain of TEAM_DOMAINS) { + await expect( + commit(harness, authority, domain, records[domain]), + ).rejects.toThrow( + `domain ${domain} is registered but not enabled for submission.`, + ); + expect(await publishedBodies(harness, domain)).toBe(0); + } + }); + }); + + it('chains a team, its board, its mailbox and a plan, and rebuilds them', async () => { + const harness = await createHarness(); + const refs = await publishRefs(harness); + const done = task(refs, { status: 'completed', owner: 'leader' }); + const claimed = task(refs, { + taskId: 'task-2', + number: 2, + blockedBy: ['task-1'], + owner: 'alice', + status: 'in_progress', + }); + const reported = message(refs, 'accepting', { + messageId: 'message-2', + from: 'alice', + to: 'leader', + targetSessionId: sessionId, + }); + const approved = plan(refs, { + decision: 'approved', + run: logical('settled'), + }); + await withAuthority(harness, async (authority) => { + await staffed(harness, authority, refs); + await commit(harness, authority, 'team_task', task(refs)); + await commit(harness, authority, 'team_task', { + ...claimed, + owner: null, + status: 'pending', + }); + await commit(harness, authority, 'team_task', claimed); + await commit(harness, authority, 'team_task', done); + for (const state of ['planned', 'accepting', 'accepted', 'consumed']) { + await commit(harness, authority, 'team_message', message(refs, state)); + } + await commit(harness, authority, 'team_message', { + ...reported, + targetSessionId: null, + run: { + ...reported.run, + delivery: { target: 'session', state: 'planned' }, + }, + }); + await commit(harness, authority, 'team_message', reported); + await commit(harness, authority, 'team_plan', plan(refs)); + const decided = await commit(harness, authority, 'team_plan', approved); + expect(decided).toMatchObject({ + domain: 'team_plan', + recordId: 'request-1', + taskId: null, + revision: 2, + }); + await commit( + harness, + authority, + 'team_state', + team([ALICE, BOB], 'closing'), + ); + await commit( + harness, + authority, + 'team_state', + team([ALICE, BOB], 'deleted'), + ); + }); + await withAuthority( + harness, + async (authority) => { + const expected: Array<[ManagedSessionDomain, string, number, unknown]> = + [ + ['team_state', 'team-1', 5, team([ALICE, BOB], 'deleted')], + ['team_task', 'task-1', 2, done], + ['team_task', 'task-2', 2, claimed], + ['team_message', 'message-1', 4, message(refs, 'consumed')], + ['team_message', 'message-2', 2, reported], + ['team_plan', 'request-1', 2, approved], + ]; + for (const [domain, recordId, revision, record] of expected) { + expect(authority.extensionRecord(domain, recordId)).toMatchObject({ + revision, + record, + task: null, + }); + } + }, + { create: false }, + ); + }); + + it('refuses a team that another Session leads', async () => { + const harness = await createHarness(); + await withAuthority(harness, async (authority) => { + await expect( + commit( + harness, + authority, + 'team_state', + team([], 'active', { leadSessionId: 'session-other' }), + ), + ).rejects.toThrow('Team must be led by this Session.'); + expect(await publishedBodies(harness, 'team_state')).toBe(0); + }); + }); + + it('admits a member only as a live child Session run of this Session', async () => { + const harness = await createHarness(); + const refs = await publishRefs(harness); + const refusal = + 'Team member must join as a child Session run of this Session that has not ended.'; + await withAuthority(harness, async (authority) => { + await commit(harness, authority, 'team_state', team([])); + await expect( + commit(harness, authority, 'team_state', team([ALICE])), + ).rejects.toThrow(refusal); + enablement.shellKind = true; + await commit(harness, authority, 'child_run', { + kind: 'shell', + shellId: 'shell-1', + ownerScopeId: 'scope-main', + commandRef: refs.input, + startReceiptRef: null, + outputRef: null, + stopReason: null, + stopRequested: false, + exitCode: null, + exitSignal: null, + run: { + ...logical('admitted'), + executionCallId: 'call-shell-1', + execution: 'intent', + }, + }); + await expect( + commit( + harness, + authority, + 'team_state', + team([{ ...ALICE, childRunId: 'shell-1' }]), + ), + ).rejects.toThrow(refusal); + await commitChild(harness, authority, childLife(refs, 'run-1'), 4); + await expect( + commit(harness, authority, 'team_state', team([ALICE])), + ).rejects.toThrow(refusal); + // A run that only launched is live: it may join before it attaches. + await commitChild(harness, authority, childLife(refs, 'run-2'), 1); + await commit( + harness, + authority, + 'team_state', + team([{ ...BOB, name: 'alice' }]), + ); + }); + }); + + it('keeps a member that already joined after its run ends', async () => { + const harness = await createHarness(); + const refs = await publishRefs(harness); + await withAuthority(harness, async (authority) => { + await staffed(harness, authority, refs); + await commit( + harness, + authority, + 'child_run', + childLife(refs, 'run-1')[3], + ); + await commit( + harness, + authority, + 'team_state', + team([ALICE, BOB], 'closing'), + ); + }); + }); + + it('keeps each child run in one team, even after that team is deleted', async () => { + const harness = await createHarness(); + const refs = await publishRefs(harness); + const other = (members: readonly TeamMember[], lifecycle = 'active') => + team(members, lifecycle, { teamId: 'team-2', name: 'other-team' }); + await withAuthority(harness, async (authority) => { + await staffed(harness, authority, refs); + await commit(harness, authority, 'team_state', other([])); + await expect( + commit(harness, authority, 'team_state', other([BOB])), + ).rejects.toThrow( + "Team member's child run must belong to no other team.", + ); + await commit( + harness, + authority, + 'team_state', + team([ALICE, BOB], 'closing'), + ); + await commit( + harness, + authority, + 'team_state', + team([ALICE, BOB], 'deleted'), + ); + await expect( + commit( + harness, + authority, + 'team_state', + other([{ ...BOB, name: 'robert' }]), + ), + ).rejects.toThrow( + "Team member's child run must belong to no other team.", + ); + }); + }); + + it('opens team records only in an active team, and lets open ones drain', async () => { + const harness = await createHarness(); + const refs = await publishRefs(harness); + const refusal = 'Team record must open in an active team of this Session.'; + await withAuthority(harness, async (authority) => { + for (const [domain, record] of [ + ['team_task', task(refs)], + ['team_message', message(refs)], + ['team_plan', plan(refs)], + ] as const) { + await expect( + commit(harness, authority, domain, record), + ).rejects.toThrow(refusal); + } + await staffed(harness, authority, refs); + await commit(harness, authority, 'team_message', message(refs)); + await commit(harness, authority, 'team_task', task(refs)); + await commit(harness, authority, 'team_plan', plan(refs)); + await commit( + harness, + authority, + 'team_state', + team([ALICE, BOB], 'closing'), + ); + for (const [domain, record] of [ + ['team_task', task(refs, { taskId: 'task-2', number: 2 })], + ['team_message', message(refs, 'planned', { messageId: 'message-2' })], + ['team_plan', plan(refs, { requestId: 'request-2' })], + ] as const) { + await expect( + commit(harness, authority, domain, record), + ).rejects.toThrow(refusal); + } + await commit( + harness, + authority, + 'team_message', + message(refs, 'accepting'), + ); + await commit( + harness, + authority, + 'team_task', + task(refs, { status: 'deleted' }), + ); + await commit( + harness, + authority, + 'team_plan', + plan(refs, { run: logical('cancelled') }), + ); + }); + }); + + it('keeps task numbers unique within a team', async () => { + const harness = await createHarness(); + const refs = await publishRefs(harness); + await withAuthority(harness, async (authority) => { + await staffed(harness, authority, refs); + await commit( + harness, + authority, + 'team_state', + team([], 'active', { + teamId: 'team-2', + name: 'other-team', + }), + ); + await commit(harness, authority, 'team_task', task(refs)); + await commit( + harness, + authority, + 'team_task', + task(refs, { taskId: 'task-1', status: 'deleted' }), + ); + // A deleted task keeps its number. + await expect( + commit( + harness, + authority, + 'team_task', + task(refs, { taskId: 'task-2' }), + ), + ).rejects.toThrow('Team task number must be unique in its team.'); + await commit( + harness, + authority, + 'team_task', + task(refs, { taskId: 'task-2', teamId: 'team-2' }), + ); + }); + }); + + it('gives a task only to the leader or a member of its team', async () => { + const harness = await createHarness(); + const refs = await publishRefs(harness); + const refusal = + 'Team task owner must be the leader or a member of its team.'; + await withAuthority(harness, async (authority) => { + await staffed(harness, authority, refs); + await expect( + commit(harness, authority, 'team_task', task(refs, { owner: 'carol' })), + ).rejects.toThrow(refusal); + await commit( + harness, + authority, + 'team_task', + task(refs, { owner: 'leader' }), + ); + await commit( + harness, + authority, + 'team_task', + task(refs, { owner: 'bob' }), + ); + await expect( + commit(harness, authority, 'team_task', task(refs, { owner: 'carol' })), + ).rejects.toThrow(refusal); + await commit( + harness, + authority, + 'team_task', + task(refs, { owner: null }), + ); + }); + }); + + it('refuses a dependency outside the team or one that closes a cycle', async () => { + const harness = await createHarness(); + const refs = await publishRefs(harness); + const at = (taskId: string, number: number, blockedBy: string[] = []) => + task(refs, { taskId, number, blockedBy }); + await withAuthority(harness, async (authority) => { + await staffed(harness, authority, refs); + await commit( + harness, + authority, + 'team_state', + team([], 'active', { + teamId: 'team-2', + name: 'other-team', + }), + ); + await commit( + harness, + authority, + 'team_task', + task(refs, { taskId: 'elsewhere', teamId: 'team-2' }), + ); + await expect( + commit(harness, authority, 'team_task', at('task-1', 1, ['missing'])), + ).rejects.toThrow('Team task must be blocked only by tasks of its team.'); + await expect( + commit(harness, authority, 'team_task', at('task-1', 1, ['elsewhere'])), + ).rejects.toThrow('Team task must be blocked only by tasks of its team.'); + await commit(harness, authority, 'team_task', at('task-1', 1)); + await commit( + harness, + authority, + 'team_task', + at('task-2', 2, ['task-1']), + ); + await commit( + harness, + authority, + 'team_task', + at('task-3', 3, ['task-2']), + ); + await expect( + commit(harness, authority, 'team_task', at('task-1', 1, ['task-3'])), + ).rejects.toThrow('Team task dependencies must not form a cycle.'); + await expect( + commit(harness, authority, 'team_task', at('task-1', 1, ['task-2'])), + ).rejects.toThrow('Team task dependencies must not form a cycle.'); + // A second path to the same blocker is no cycle. + await commit( + harness, + authority, + 'team_task', + at('task-3', 3, ['task-2', 'task-1']), + ); + }); + }); + + it("routes a message only within the team and to its recipient's Session", async () => { + const harness = await createHarness(); + const refs = await publishRefs(harness); + const DAVE: TeamMember = { + name: 'dave', + childRunId: 'run-3', + planModeRequired: false, + }; + const routing = + 'Team message must travel between the leader and members of its team.'; + const targeting = 'Team message must target the Session of its recipient.'; + await withAuthority(harness, async (authority) => { + await staffed(harness, authority, refs); + await commitChild(harness, authority, childLife(refs, 'run-3'), 1); + await commit(harness, authority, 'team_state', team([ALICE, BOB, DAVE])); + await expect( + commit( + harness, + authority, + 'team_message', + message(refs, 'planned', { + from: 'carol', + }), + ), + ).rejects.toThrow(routing); + await expect( + commit( + harness, + authority, + 'team_message', + message(refs, 'planned', { + to: 'carol', + }), + ), + ).rejects.toThrow(routing); + await commit(harness, authority, 'team_message', message(refs)); + // A successor that readdresses the message to a stranger is refused + // as the conflict it is, before anything looks its recipient up. + const readdressed = commit( + harness, + authority, + 'team_message', + message(refs, 'accepting', { to: 'carol' }), + ); + await expect(readdressed).rejects.toBeInstanceOf( + ManagedSessionConflictError, + ); + await expect(readdressed).rejects.toThrow(routing); + expect( + authority.extensionRecord('team_message', 'message-1'), + ).toMatchObject({ revision: 1, record: message(refs) }); + await expect( + commit( + harness, + authority, + 'team_message', + message(refs, 'accepting', { + targetSessionId: 'session-run-2', + }), + ), + ).rejects.toThrow(targeting); + await commit( + harness, + authority, + 'team_message', + message(refs, 'accepting'), + ); + // dave's run has not attached, so a message to him stays planned. + const toDave = (state: string, targetSessionId: string | null) => + message(refs, state, { + messageId: 'message-2', + from: 'alice', + to: 'dave', + targetSessionId, + }); + await commit(harness, authority, 'team_message', toDave('planned', null)); + await expect( + commit( + harness, + authority, + 'team_message', + toDave('accepting', 'session-run-3'), + ), + ).rejects.toThrow(targeting); + const toLeader = (targetSessionId: string | null, state = 'accepting') => + message(refs, state, { + messageId: 'message-3', + from: 'bob', + to: 'leader', + targetSessionId, + }); + await commit( + harness, + authority, + 'team_message', + toLeader(null, 'planned'), + ); + await expect( + commit(harness, authority, 'team_message', toLeader('session-run-2')), + ).rejects.toThrow(targeting); + await commit(harness, authority, 'team_message', toLeader(sessionId)); + }); + }); + + it('takes a plan request only from a member that must plan', async () => { + const harness = await createHarness(); + const refs = await publishRefs(harness); + const refusal = + 'Team plan must come from a member of its team that requires plan mode.'; + await withAuthority(harness, async (authority) => { + await staffed(harness, authority, refs); + await expect( + commit( + harness, + authority, + 'team_plan', + plan(refs, { member: 'alice' }), + ), + ).rejects.toThrow(refusal); + await expect( + commit( + harness, + authority, + 'team_plan', + plan(refs, { member: 'carol' }), + ), + ).rejects.toThrow(refusal); + await commit(harness, authority, 'team_plan', plan(refs)); + }); + }); + + it('refuses a team record whose content this Session does not hold', async () => { + const harness = await createHarness(); + const refs = await publishRefs(harness); + const missing = { ...refs.content, resourceId: 'never-published' }; + await withAuthority(harness, async (authority) => { + await staffed(harness, authority, refs); + for (const [domain, record] of [ + ['team_task', task(refs, { descriptionRef: missing })], + ['team_task', task(refs, { metadataRef: missing })], + ['team_message', message(refs, 'planned', { contentRef: missing })], + ['team_plan', plan(refs, { planRef: missing })], + [ + 'team_plan', + plan(refs, { + decision: 'rejected', + feedbackRef: missing, + run: logical('settled'), + }), + ], + ] as const) { + await expect( + commit(harness, authority, domain, record), + ).rejects.toThrow('resource never-published is not present'); + } + }); + }); +}); diff --git a/packages/core/src/managed-runtime/managed-session-authority.ts b/packages/core/src/managed-runtime/managed-session-authority.ts index 84e228c8b9..7c7bd4b7a9 100644 --- a/packages/core/src/managed-runtime/managed-session-authority.ts +++ b/packages/core/src/managed-runtime/managed-session-authority.ts @@ -98,6 +98,13 @@ import { parseSessionMessage, type SessionMessage, } from './managed-session-message-record.js'; +import { + MANAGED_TEAM_LEADER, + parseTeamMessage, + parseTeamPlan, + parseTeamState, + parseTeamTask, +} from './managed-team-record.js'; import { ManagedSessionCommitRejectedError, managedSessionActivationStateFrom, @@ -2348,6 +2355,154 @@ export class LocalManagedSessionAuthority { } } } + if (domain === 'team_state') { + // H4e: a team lives in its lead's journal, and each member joins as a + // live child Session run of the lead that no other team lists. + const team = parseTeamState(parsed.record); + if (team.leadSessionId !== this.sessionKey.sessionId) { + reject('Team must be led by this Session.'); + } + const before = + previous === undefined ? [] : parseTeamState(previous.record).members; + for (const member of team.members) { + if (before.some((each) => each.childRunId === member.childRunId)) { + continue; + } + const named = this.extensionRecord('child_run', member.childRunId); + const child = + named === undefined ? undefined : parseChildRun(named.record); + if ( + child === undefined || + !isChildSessionRun(child) || + isTerminalRunState(child.run.state) + ) { + reject( + 'Team member must join as a child Session run of this Session that has not ended.', + ); + } + if ( + this.extensionRecordsInDomain('team_state').some( + (other) => + other.recordId !== team.teamId && + parseTeamState(other.record).members.some( + (each) => each.childRunId === member.childRunId, + ), + ) + ) { + reject("Team member's child run must belong to no other team."); + } + } + } + if ( + domain === 'team_task' || + domain === 'team_message' || + domain === 'team_plan' + ) { + // H4e: every team record belongs to a team of this journal, and only + // an active team takes new ones, so a closing team drains. + const { teamId } = parsed.record as { teamId: string }; + const named = this.extensionRecord('team_state', teamId); + const team = + named === undefined ? undefined : parseTeamState(named.record); + if ( + team === undefined || + (previous === undefined && team.lifecycle !== 'active') + ) { + reject('Team record must open in an active team of this Session.'); + } + const takesPart = (name: string) => + name === MANAGED_TEAM_LEADER || + team.members.some((member) => member.name === name); + if (domain === 'team_task') { + const task = parseTeamTask(parsed.record); + const earlier = + previous === undefined ? undefined : parseTeamTask(previous.record); + if ( + earlier === undefined && + this.extensionRecordsInDomain('team_task').some((other) => { + const each = parseTeamTask(other.record); + return each.teamId === teamId && each.number === task.number; + }) + ) { + reject('Team task number must be unique in its team.'); + } + if ( + task.owner !== null && + task.owner !== earlier?.owner && + !takesPart(task.owner) + ) { + reject('Team task owner must be the leader or a member of its team.'); + } + const added = task.blockedBy.filter( + (blocker) => !earlier?.blockedBy.includes(blocker), + ); + for (const blocker of added) { + const other = this.extensionRecord('team_task', blocker); + if ( + other === undefined || + parseTeamTask(other.record).teamId !== teamId + ) { + reject('Team task must be blocked only by tasks of its team.'); + } + } + // Only the new edges can close a cycle: one does exactly when this + // task is reachable from a new blocker. + const pending = [...added]; + const seen = new Set(); + while (pending.length > 0) { + const next = pending.pop()!; + if (next === task.taskId) { + reject('Team task dependencies must not form a cycle.'); + } + if (seen.has(next)) continue; + seen.add(next); + pending.push( + ...parseTeamTask(this.extensionRecord('team_task', next)!.record) + .blockedBy, + ); + } + } + if (domain === 'team_message') { + const message = parseTeamMessage(parsed.record); + // Every revision, not only the opening: the roster only grows, so a + // lawful successor always passes, while one that readdresses the + // message is refused before its recipient is looked up below. + if (!takesPart(message.from) || !takesPart(message.to)) { + reject( + 'Team message must travel between the leader and members of its team.', + ); + } + if (message.targetSessionId !== null) { + // The leader is this Session; a member is the Session its run + // attached, so a message to one not attached yet stays planned. + let target: string | null = this.sessionKey.sessionId; + if (message.to !== MANAGED_TEAM_LEADER) { + const member = team.members.find( + (each) => each.name === message.to, + )!; + const child = parseChildRun( + this.extensionRecord('child_run', member.childRunId)!.record, + ); + target = isChildSessionRun(child) ? child.childSessionId : null; + } + if (message.targetSessionId !== target) { + reject('Team message must target the Session of its recipient.'); + } + } + } + if (domain === 'team_plan' && previous === undefined) { + const plan = parseTeamPlan(parsed.record); + if ( + !team.members.some( + (member) => member.name === plan.member && member.planModeRequired, + ) + ) { + reject( + 'Team plan must come from a member of its team that requires plan mode.', + ); + } + } + } if (previous === undefined) { if (!body.isStart(parsed.record)) { reject( @@ -2588,6 +2743,14 @@ export class LocalManagedSessionAuthority { refs = [acceptance.contentRef, acceptance.terminalReceiptRef]; } else if (domain === 'session_message') { refs = [parseSessionMessage(record).contentRef]; + } else if (domain === 'team_task') { + const task = parseTeamTask(record); + refs = [task.descriptionRef, task.metadataRef]; + } else if (domain === 'team_message') { + refs = [parseTeamMessage(record).contentRef]; + } else if (domain === 'team_plan') { + const plan = parseTeamPlan(record); + refs = [plan.planRef, plan.feedbackRef]; } else if (domain === 'schedule') { refs = [parseScheduleRecord(record).promptRef]; } else if (domain === 'monitor_run') { diff --git a/packages/core/src/managed-runtime/managed-team-record.test.ts b/packages/core/src/managed-runtime/managed-team-record.test.ts new file mode 100644 index 0000000000..76e30883f0 --- /dev/null +++ b/packages/core/src/managed-runtime/managed-team-record.test.ts @@ -0,0 +1,209 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { readFileSync } from 'node:fs'; +import { describe, expect, it } from 'vitest'; +import { MANAGED_EXTENSION_RECORD_BODIES } from './managed-extension-projection.js'; +import { + MANAGED_TEAM_LEADER, + MANAGED_TEAM_LIMITS, +} from './managed-team-record.js'; +import { + MANAGED_SESSION_ENABLED_DOMAINS, + assertManagedSessionDomainEnabled, +} from './managed-session-records.js'; + +type TeamDomain = 'team_state' | 'team_task' | 'team_message' | 'team_plan'; + +interface Fixture { + id: string; + domain: TeamDomain; + template: string; + patch: Record; + valid: boolean; + start: boolean; + /** The clause substring both validators must report on an invalid case. */ + error?: string; +} +const fixtures = JSON.parse( + readFileSync( + new URL( + './contracts/managed-team-record-v1.fixtures.json', + import.meta.url, + ), + 'utf8', + ), +) as { + contract: string; + domains: Record; + leader: string; + limits: Record; + keys: Record; + fixedKeys: Record; + templates: Record>; + cases: Fixture[]; + successors: Array< + Omit & { + before: Record; + after: Record; + } + >; +}; + +const RECORD_ID: Record = { + team_state: 'teamId', + team_task: 'taskId', + team_message: 'messageId', + team_plan: 'requestId', +}; + +function merge( + base: Record, + patch: Record, +): Record { + const value = structuredClone(base ?? {}); + for (const [key, replacement] of Object.entries(patch)) { + value[key] = + replacement !== null && + typeof replacement === 'object' && + !Array.isArray(replacement) + ? merge( + value[key] as Record, + replacement as Record, + ) + : replacement; + } + return value; +} + +/** Resolves a fixture's template, or fails loudly: a silent miss degenerates. */ +function templateOf(fixture: { id: string; template: string }) { + const template = fixtures.templates[fixture.template]; + if (template === undefined) { + throw new Error(`fixture ${fixture.id} names an unknown template`); + } + return template; +} + +function frozenDeep(value: unknown): boolean { + if (typeof value !== 'object' || value === null) return true; + return Object.isFrozen(value) && Object.values(value).every(frozenDeep); +} + +describe('managed-team-record/1 shared contract', () => { + it('projects no task and stays disabled for submission', () => { + expect(fixtures.contract).toBe('managed-team-record/1'); + expect(fixtures.leader).toBe(MANAGED_TEAM_LEADER); + expect(fixtures.limits).toEqual({ ...MANAGED_TEAM_LIMITS }); + for (const domain of Object.keys(fixtures.domains) as TeamDomain[]) { + const body = MANAGED_EXTENSION_RECORD_BODIES[domain]!; + for (const template of Object.keys(fixtures.templates).filter((name) => + name.startsWith(domain), + )) { + expect( + body.taskKindOf(body.parse(fixtures.templates[template]).record), + ).toBeNull(); + } + expect(MANAGED_SESSION_ENABLED_DOMAINS).not.toContain(domain); + expect(() => assertManagedSessionDomainEnabled(domain)).toThrow( + `domain ${domain} is registered but not enabled for submission.`, + ); + } + }); + + it('pins the closed keys and the fixed keys', () => { + expect(fixtures.keys).toEqual({ + team_state: [ + 'leadSessionId', + 'lifecycle', + 'members', + 'membershipRevision', + 'name', + 'run', + 'teamId', + ], + team_task: [ + 'activeForm', + 'blockedBy', + 'descriptionRef', + 'metadataRef', + 'number', + 'owner', + 'run', + 'status', + 'subject', + 'taskId', + 'teamId', + ], + team_message: [ + 'contentDigest', + 'contentRef', + 'from', + 'inputId', + 'kind', + 'messageId', + 'run', + 'targetSessionId', + 'teamId', + 'to', + ], + team_plan: [ + 'decision', + 'feedbackRef', + 'member', + 'planRef', + 'planRevision', + 'requestId', + 'run', + 'teamId', + ], + }); + expect(fixtures.fixedKeys).toEqual({ + team_state: ['leadSessionId', 'name', 'teamId'], + team_task: ['number', 'taskId', 'teamId'], + team_message: [ + 'contentDigest', + 'contentRef', + 'from', + 'kind', + 'messageId', + 'teamId', + 'to', + ], + team_plan: ['member', 'planRef', 'planRevision', 'requestId', 'teamId'], + }); + for (const [name, template] of Object.entries(fixtures.templates)) { + const domain = (Object.keys(fixtures.keys) as TeamDomain[]).find((each) => + name.startsWith(each), + )!; + expect(Object.keys(template).sort()).toEqual([...fixtures.keys[domain]]); + } + }); + + it.each(fixtures.cases)('$id', (fixture) => { + const body = MANAGED_EXTENSION_RECORD_BODIES[fixture.domain]!; + const record = merge(templateOf(fixture), fixture.patch); + if (fixture.valid) { + const parsed = body.parse(record); + expect(parsed.record).toEqual(record); + expect(parsed.recordId).toBe(record[RECORD_ID[fixture.domain]]); + expect(frozenDeep(parsed.record)).toBe(true); + } else { + expect(() => body.parse(record)).toThrow(fixture.error as string); + } + expect(body.isStart(record)).toBe(fixture.start); + }); + + it.each(fixtures.successors)('$id', (fixture) => { + const template = templateOf(fixture); + expect( + MANAGED_EXTENSION_RECORD_BODIES[fixture.domain]!.isSuccessor( + merge(template, fixture.before), + merge(template, fixture.after), + ), + ).toBe(fixture.valid); + }); +}); diff --git a/packages/core/src/managed-runtime/managed-team-record.ts b/packages/core/src/managed-runtime/managed-team-record.ts new file mode 100644 index 0000000000..ca4c976244 --- /dev/null +++ b/packages/core/src/managed-runtime/managed-team-record.ts @@ -0,0 +1,738 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + isExtensionRunSuccessor, + parseExtensionRun, + type ExtensionRun, +} from './managed-extension-record.js'; +import { + assertManagedSessionDigest, + assertManagedSessionDurableRef, + assertManagedSessionStableId, + boundedString, + MANAGED_SESSION_LIMITS, + ManagedSessionRecordError, + type ManagedSessionDurableRef, + type ManagedSessionJsonValue, +} from './managed-session-records.js'; + +// The four team record bodies, schema version 1 (H4e of #12827): a team's +// roster and lifecycle (`managed-team_state`), its board +// (`managed-team_task`), its mailbox (`managed-team_message`) and its plan +// approvals (`managed-team_plan`), all held by the lead Session. They keep +// the Legacy team vocabulary of packages/core/src/agents/team. The shared +// fixtures in contracts/managed-team-record-v1.fixtures.json pin them, and +// ManagedTeamRecords in packages/sdk-java/managed-agent-server replays the +// same cases. See docs/design/2026-10-10-managed-agent-teams.md. + +export const MANAGED_TEAM_LIMITS = Object.freeze({ + /** Legacy `MAX_TEAMMATES`; the lead is not a member. */ + maxMembers: 10, + maxNameLength: 64, + maxBlockers: 64, + /** Bytes of a description, a message, a plan or its feedback. */ + maxContentBytes: 64 * 1024, + /** Legacy `MAX_METADATA_BYTES`. */ + maxMetadataBytes: 32 * 1024, +} as const); + +/** The name `leader` stands for the lead wherever a record names a participant. */ +export const MANAGED_TEAM_LEADER = 'leader'; + +export type TeamLifecycle = 'active' | 'closing' | 'deleted'; +export type TeamTaskStatus = + | 'pending' + | 'in_progress' + | 'completed' + | 'deleted'; +export type TeamMessageKind = + | 'message' + | 'task_assignment' + | 'plan_approval_request' + | 'plan_approval_response' + | 'shutdown_request' + | 'shutdown_approved' + | 'shutdown_rejected'; +export type TeamPlanDecision = 'approved' | 'rejected'; + +export interface TeamMember { + readonly name: string; + /** The member's child Session run in the lead's journal. */ + readonly childRunId: string; + readonly planModeRequired: boolean; +} + +/** The body of a `managed-team_state` schema version 1 record. */ +export interface TeamState { + readonly teamId: string; + readonly name: string; + readonly leadSessionId: string; + readonly lifecycle: TeamLifecycle; + /** One more than the member count: each membership fact is one step. */ + readonly membershipRevision: number; + readonly members: readonly TeamMember[]; + readonly run: ExtensionRun; +} + +/** The body of a `managed-team_task` schema version 1 record. */ +export interface TeamTask { + readonly teamId: string; + readonly taskId: string; + /** The board's `#N`, unique in the team. */ + readonly number: number; + readonly subject: string; + readonly descriptionRef: ManagedSessionDurableRef; + readonly activeForm: string | null; + readonly metadataRef: ManagedSessionDurableRef | null; + readonly owner: string | null; + readonly status: TeamTaskStatus; + /** The one stored direction of each dependency; `blocks` is its reverse. */ + readonly blockedBy: readonly string[]; + readonly run: ExtensionRun; +} + +/** The body of a `managed-team_message` schema version 1 record. */ +export interface TeamMessage { + readonly teamId: string; + readonly messageId: string; + readonly kind: TeamMessageKind; + readonly from: string; + readonly to: string; + readonly contentRef: ManagedSessionDurableRef; + readonly contentDigest: string; + /** Fixed at handover. */ + readonly targetSessionId: string | null; + /** The input that carries the message in the target Session. */ + readonly inputId: string | null; + readonly run: ExtensionRun; +} + +/** The body of a `managed-team_plan` schema version 1 record. */ +export interface TeamPlan { + readonly teamId: string; + /** The D6 action that asks the leader to decide. */ + readonly requestId: string; + readonly member: string; + readonly planRevision: number; + readonly planRef: ManagedSessionDurableRef; + readonly decision: TeamPlanDecision | null; + readonly feedbackRef: ManagedSessionDurableRef | null; + readonly run: ExtensionRun; +} + +const STATE_KEYS = [ + 'lifecycle', + 'leadSessionId', + 'members', + 'membershipRevision', + 'name', + 'run', + 'teamId', +] as const; +const MEMBER_KEYS = ['childRunId', 'name', 'planModeRequired'] as const; +const TASK_KEYS = [ + 'activeForm', + 'blockedBy', + 'descriptionRef', + 'metadataRef', + 'number', + 'owner', + 'run', + 'status', + 'subject', + 'taskId', + 'teamId', +] as const; +const MESSAGE_KEYS = [ + 'contentDigest', + 'contentRef', + 'from', + 'inputId', + 'kind', + 'messageId', + 'run', + 'targetSessionId', + 'teamId', + 'to', +] as const; +const PLAN_KEYS = [ + 'decision', + 'feedbackRef', + 'member', + 'planRef', + 'planRevision', + 'requestId', + 'run', + 'teamId', +] as const; +const STATE_FIXED_KEYS = ['leadSessionId', 'name', 'teamId'] as const; +const TASK_FIXED_KEYS = ['number', 'taskId', 'teamId'] as const; +/** Every message key but the run is fixed, except the two set once. */ +const MESSAGE_FIXED_KEYS = [ + 'contentDigest', + 'contentRef', + 'from', + 'kind', + 'messageId', + 'teamId', + 'to', +] as const; +const PLAN_FIXED_KEYS = [ + 'member', + 'planRef', + 'planRevision', + 'requestId', + 'teamId', +] as const; + +const LIFECYCLES: readonly TeamLifecycle[] = ['active', 'closing', 'deleted']; +const TASK_STATUSES: readonly TeamTaskStatus[] = [ + 'pending', + 'in_progress', + 'completed', + 'deleted', +]; +/** Who may send each kind: a member to the leader, or the reverse. */ +const TO_LEADER: readonly TeamMessageKind[] = [ + 'plan_approval_request', + 'shutdown_approved', + 'shutdown_rejected', +]; +const FROM_LEADER: readonly TeamMessageKind[] = [ + 'plan_approval_response', + 'shutdown_request', +]; +const MESSAGE_KINDS: readonly TeamMessageKind[] = [ + 'message', + 'task_assignment', + ...TO_LEADER, + ...FROM_LEADER, +]; +/** Legacy `sanitizeName` output: dash-separated lowercase runs. */ +const NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; +/** The largest integer both validators read exactly. */ +const MAX_COUNT = 9_007_199_254_740_990; + +function fail(message: string): never { + throw new ManagedSessionRecordError(message); +} + +function closed( + label: string, + value: unknown, + keys: readonly Key[], +): Record { + if ( + typeof value !== 'object' || + value === null || + (Object.getPrototypeOf(value) !== Object.prototype && + Object.getPrototypeOf(value) !== null) || + Object.keys(value).length !== keys.length || + Object.keys(value).some((key) => !keys.includes(key as Key)) + ) + fail(`${label} must have exactly the keys ${keys.join(', ')}.`); + return { ...value } as Record; +} + +function oneOf( + value: ManagedSessionJsonValue, + allowed: readonly T[], + message: string, +): T { + if (typeof value !== 'string' || !allowed.includes(value as T)) fail(message); + return value as T; +} + +function count( + value: ManagedSessionJsonValue, + label: string, + min: number, + max = MAX_COUNT, +): number { + if ( + typeof value !== 'number' || + !Number.isSafeInteger(value) || + value < min || + value > max + ) { + fail(`${label} must be an integer from ${min} to ${max}.`); + } + return value; +} + +function bool(value: ManagedSessionJsonValue, label: string): boolean { + if (typeof value !== 'boolean') fail(`${label} must be boolean.`); + return value; +} + +function name(value: ManagedSessionJsonValue, label: string): string { + if ( + typeof value !== 'string' || + value.length > MANAGED_TEAM_LIMITS.maxNameLength || + !NAME.test(value) + ) { + fail( + `${label} must be dash-separated lowercase letters and digits, at most ${MANAGED_TEAM_LIMITS.maxNameLength} characters.`, + ); + } + return value; +} + +function ref( + value: ManagedSessionJsonValue, + label: string, + maxBytes: number, +): ManagedSessionDurableRef { + const parsed = Object.freeze(assertManagedSessionDurableRef(value, label)); + if (parsed.byteLength > maxBytes) fail(`${label} exceeds ${maxBytes} bytes.`); + return parsed; +} + +function nullable( + value: ManagedSessionJsonValue, + parse: (value: ManagedSessionJsonValue) => T, +): T | null { + return value === null ? null : parse(value); +} + +/** + * A purely logical lifecycle in one of `states`: nothing runs for a team + * record, so it pins no definition and names no physical identity. + */ +function logicalRun( + value: ManagedSessionJsonValue, + label: string, + states: readonly string[], +): ExtensionRun { + const run = parseExtensionRun(value); + if ( + run.definition !== null || + run.executionCallId !== null || + run.effectId !== null || + run.dispatchId !== null || + run.deliveryId !== null || + run.execution !== null || + run.runtime !== null || + run.delivery !== null + ) { + fail(`${label} run must be purely logical.`); + } + if (!states.includes(run.state)) { + fail(`${label} run must be ${states.join(' or ')}.`); + } + return run; +} + +function accepts(check: () => boolean): boolean { + try { + return check(); + } catch (error) { + if (error instanceof ManagedSessionRecordError) return false; + throw error; + } +} + +function same(left: unknown, right: unknown): boolean { + return JSON.stringify(left) === JSON.stringify(right); +} + +function fixed(before: T, after: T, keys: ReadonlyArray): boolean { + return keys.every((key) => same(before[key], after[key])); +} + +function prefix(before: readonly unknown[], after: readonly unknown[]) { + return before.every((each, index) => same(each, after[index])); +} + +function parseMember(value: ManagedSessionJsonValue): TeamMember { + const member = closed('Team member', value, MEMBER_KEYS); + const memberName = name(member.name, 'Team member name'); + if (memberName === MANAGED_TEAM_LEADER) { + fail('Team member cannot be named leader.'); + } + return Object.freeze({ + name: memberName, + childRunId: assertManagedSessionStableId( + member.childRunId, + 'members.childRunId', + ), + planModeRequired: bool(member.planModeRequired, 'members.planModeRequired'), + }); +} + +/** + * Parses the body of a `managed-team_state` schema version 1 record: the + * append-only roster and the lifecycle. The run is `admitted` while the + * team lives and `cancelled` once it is deleted. + */ +export function parseTeamState(value: unknown): TeamState { + const body = closed('Team state', value, STATE_KEYS); + const lifecycle = oneOf( + body.lifecycle, + LIFECYCLES, + 'Team lifecycle must be active, closing or deleted.', + ); + const run = logicalRun(body.run, 'Team', ['admitted', 'cancelled']); + if ((lifecycle === 'deleted') !== (run.state === 'cancelled')) { + fail('Team run must be cancelled exactly once the team is deleted.'); + } + if (!Array.isArray(body.members)) fail('Team members must be an array.'); + if (body.members.length > MANAGED_TEAM_LIMITS.maxMembers) { + fail(`Team members exceed ${MANAGED_TEAM_LIMITS.maxMembers} entries.`); + } + const members = Object.freeze(body.members.map(parseMember)); + if ( + new Set(members.map((member) => member.name)).size !== members.length || + new Set(members.map((member) => member.childRunId)).size !== members.length + ) { + fail('Team members must have distinct names and child runs.'); + } + const membershipRevision = count( + body.membershipRevision, + 'membershipRevision', + 1, + ); + if (membershipRevision !== members.length + 1) { + fail('Team membershipRevision must be one more than its member count.'); + } + return Object.freeze({ + teamId: assertManagedSessionStableId(body.teamId, 'teamId'), + name: name(body.name, 'Team name'), + leadSessionId: assertManagedSessionStableId( + body.leadSessionId, + 'leadSessionId', + ), + lifecycle, + membershipRevision, + members, + run, + }); +} + +/** Whether `value` may open a team: active, with no members yet. */ +export function isTeamStateStart(value: unknown): boolean { + return accepts(() => { + const team = parseTeamState(value); + return team.lifecycle === 'active' && team.members.length === 0; + }); +} + +/** + * Whether `next` may follow `previous`: the team's identity and lead never + * change, the lifecycle stays or takes one step, and an active team that + * stays active may gain one member, appended. A deleted team is frozen. + */ +export function isTeamStateSuccessor( + previous: unknown, + next: unknown, +): boolean { + return accepts(() => { + const before = parseTeamState(previous); + const after = parseTeamState(next); + if ( + !fixed(before, after, STATE_FIXED_KEYS) || + !isExtensionRunSuccessor(before.run, after.run) + ) { + return false; + } + const step = LIFECYCLES.indexOf(after.lifecycle); + const from = LIFECYCLES.indexOf(before.lifecycle); + if (step !== from && step !== from + 1) return false; + if (!prefix(before.members, after.members)) return false; + const joined = after.members.length - before.members.length; + return ( + joined === 0 || + (joined === 1 && + before.lifecycle === 'active' && + after.lifecycle === 'active') + ); + }); +} + +/** + * Parses the body of a `managed-team_task` schema version 1 record: one + * board item of the Legacy task model. Its run is `admitted` until the + * task is deleted and `cancelled` after. + */ +export function parseTeamTask(value: unknown): TeamTask { + const body = closed('Team task', value, TASK_KEYS); + const status = oneOf( + body.status, + TASK_STATUSES, + 'Team task status must be pending, in_progress, completed or deleted.', + ); + const run = logicalRun(body.run, 'Team task', ['admitted', 'cancelled']); + if ((status === 'deleted') !== (run.state === 'cancelled')) { + fail('Team task run must be cancelled exactly once the task is deleted.'); + } + const taskId = assertManagedSessionStableId(body.taskId, 'taskId'); + const owner = nullable(body.owner, (each) => name(each, 'Team task owner')); + if (status === 'in_progress' && owner === null) { + fail('Team task in progress must have an owner.'); + } + if (!Array.isArray(body.blockedBy)) { + fail('Team task blockedBy must be an array.'); + } + if (body.blockedBy.length > MANAGED_TEAM_LIMITS.maxBlockers) { + fail( + `Team task blockedBy exceeds ${MANAGED_TEAM_LIMITS.maxBlockers} entries.`, + ); + } + const blockedBy = Object.freeze( + body.blockedBy.map((each) => + assertManagedSessionStableId(each, 'blockedBy'), + ), + ); + if ( + new Set(blockedBy).size !== blockedBy.length || + blockedBy.includes(taskId) + ) { + fail('Team task blockedBy must name distinct other tasks.'); + } + return Object.freeze({ + teamId: assertManagedSessionStableId(body.teamId, 'teamId'), + taskId, + number: count(body.number, 'number', 1), + subject: boundedString( + body.subject, + 'subject', + MANAGED_SESSION_LIMITS.maxTextBytes, + ), + descriptionRef: ref( + body.descriptionRef, + 'descriptionRef', + MANAGED_TEAM_LIMITS.maxContentBytes, + ), + activeForm: nullable(body.activeForm, (each) => + boundedString(each, 'activeForm', MANAGED_SESSION_LIMITS.maxTextBytes), + ), + metadataRef: nullable(body.metadataRef, (each) => + ref(each, 'metadataRef', MANAGED_TEAM_LIMITS.maxMetadataBytes), + ), + owner, + status, + blockedBy, + run, + }); +} + +/** Whether `value` may open a task: any status but deleted. */ +export function isTeamTaskStart(value: unknown): boolean { + return accepts(() => parseTeamTask(value).status !== 'deleted'); +} + +/** + * Whether `next` may follow `previous`: the task's identity and number + * never change, its dependencies only grow, and every other field moves + * freely until the task is deleted, which freezes it. + */ +export function isTeamTaskSuccessor(previous: unknown, next: unknown): boolean { + return accepts(() => { + const before = parseTeamTask(previous); + const after = parseTeamTask(next); + if ( + !fixed(before, after, TASK_FIXED_KEYS) || + !isExtensionRunSuccessor(before.run, after.run) || + !prefix(before.blockedBy, after.blockedBy) + ) { + return false; + } + return before.status !== 'deleted' || same(before, after); + }); +} + +/** + * Parses the body of a `managed-team_message` schema version 1 record: one + * mailbox message to one recipient, held as the lead's outbox entry. As + * with a Session message, the send is an act completed by the commit, so + * the run is settled from its first revision and only its session delivery + * moves. + */ +export function parseTeamMessage(value: unknown): TeamMessage { + const body = closed('Team message', value, MESSAGE_KEYS); + const kind = oneOf( + body.kind, + MESSAGE_KINDS, + `Team message kind must be one of ${MESSAGE_KINDS.join(', ')}.`, + ); + const from = name(body.from, 'Team message from'); + const to = name(body.to, 'Team message to'); + if (from === to) fail('Team message cannot address its own sender.'); + if ( + TO_LEADER.includes(kind) && + (from === MANAGED_TEAM_LEADER || to !== MANAGED_TEAM_LEADER) + ) { + fail(`Team message of kind ${kind} must go from a member to the leader.`); + } + if ( + FROM_LEADER.includes(kind) && + (from !== MANAGED_TEAM_LEADER || to === MANAGED_TEAM_LEADER) + ) { + fail(`Team message of kind ${kind} must go from the leader to a member.`); + } + if (kind === 'task_assignment' && to === MANAGED_TEAM_LEADER) { + fail('Team message of kind task_assignment must go to a member.'); + } + const run = parseExtensionRun(body.run); + if ( + run.definition !== null || + run.effectId !== null || + run.dispatchId !== null || + run.deliveryId !== null || + run.execution !== null || + run.runtime !== null + ) { + fail('Team message run must be purely logical.'); + } + if (run.state !== 'settled') fail('Team message run must be settled.'); + if (run.executionCallId === null) { + fail('Team message run must name its sending call.'); + } + const delivery = run.delivery; + // The run carries no deliveryId, so the delivery is a session one. + if (delivery === null) { + fail('Team message delivery must be a session delivery.'); + } + const contentRef = ref( + body.contentRef, + 'contentRef', + MANAGED_TEAM_LIMITS.maxContentBytes, + ); + const contentDigest = assertManagedSessionDigest( + body.contentDigest, + 'contentDigest', + ); + if (contentDigest !== contentRef.digest) { + fail("Team message contentDigest must name the content's digest."); + } + const targetSessionId = nullable(body.targetSessionId, (each) => + assertManagedSessionStableId(each, 'targetSessionId'), + ); + const inputId = nullable(body.inputId, (each) => + assertManagedSessionStableId(each, 'inputId'), + ); + if ( + targetSessionId === null && + delivery.state !== 'planned' && + delivery.state !== 'cancelled' + ) { + fail('Team message must fix its target once its delivery is claimed.'); + } + if ( + (inputId !== null) !== + (delivery.state === 'accepted' || delivery.state === 'consumed') + ) { + fail("Team message names its target's input exactly once accepted."); + } + return Object.freeze({ + teamId: assertManagedSessionStableId(body.teamId, 'teamId'), + messageId: assertManagedSessionStableId(body.messageId, 'messageId'), + kind, + from, + to, + contentRef, + contentDigest, + targetSessionId, + inputId, + run, + }); +} + +/** Whether `value` may open a message: its delivery is still planned. */ +export function isTeamMessageStart(value: unknown): boolean { + return accepts( + () => parseTeamMessage(value).run.delivery?.state === 'planned', + ); +} + +/** + * Whether `next` may follow `previous`: the message, its parties and its + * content never change, the target and the input are set once, and the + * delivery takes one shared step at a time. + */ +export function isTeamMessageSuccessor( + previous: unknown, + next: unknown, +): boolean { + return accepts(() => { + const before = parseTeamMessage(previous); + const after = parseTeamMessage(next); + return ( + fixed(before, after, MESSAGE_FIXED_KEYS) && + (before.targetSessionId === null || + before.targetSessionId === after.targetSessionId) && + (before.inputId === null || before.inputId === after.inputId) && + isExtensionRunSuccessor(before.run, after.run) + ); + }); +} + +/** + * Parses the body of a `managed-team_plan` schema version 1 record: one + * plan a member submitted for the leader's approval. Its run waits for the + * decision, settles with it, or is cancelled when the plan is withdrawn or + * superseded. + */ +export function parseTeamPlan(value: unknown): TeamPlan { + const body = closed('Team plan', value, PLAN_KEYS); + const run = logicalRun(body.run, 'Team plan', [ + 'waiting', + 'settled', + 'cancelled', + ]); + const member = name(body.member, 'Team plan member'); + if (member === MANAGED_TEAM_LEADER) { + fail('Team plan member cannot be the leader.'); + } + const decision = nullable(body.decision, (each) => + oneOf( + each, + ['approved', 'rejected'], + 'Team plan decision must be approved or rejected.', + ), + ); + if ((decision !== null) !== (run.state === 'settled')) { + fail('Team plan has a decision exactly once its run settled.'); + } + const feedbackRef = nullable(body.feedbackRef, (each) => + ref(each, 'feedbackRef', MANAGED_TEAM_LIMITS.maxContentBytes), + ); + if (feedbackRef !== null && decision === null) { + fail('Team plan feedback comes only with a decision.'); + } + return Object.freeze({ + teamId: assertManagedSessionStableId(body.teamId, 'teamId'), + requestId: assertManagedSessionStableId(body.requestId, 'requestId'), + member, + planRevision: count(body.planRevision, 'planRevision', 1), + planRef: ref(body.planRef, 'planRef', MANAGED_TEAM_LIMITS.maxContentBytes), + decision, + feedbackRef, + run, + }); +} + +/** Whether `value` may open a plan request: it waits for its decision. */ +export function isTeamPlanStart(value: unknown): boolean { + return accepts(() => parseTeamPlan(value).run.state === 'waiting'); +} + +/** + * Whether `next` may follow `previous`: the request, its member and its + * plan never change, and the run ends once, which fixes the decision. + */ +export function isTeamPlanSuccessor(previous: unknown, next: unknown): boolean { + return accepts(() => { + const before = parseTeamPlan(previous); + const after = parseTeamPlan(next); + return ( + fixed(before, after, PLAN_FIXED_KEYS) && + isExtensionRunSuccessor(before.run, after.run) && + (before.run.state === 'waiting' || same(before, after)) + ); + }); +} diff --git a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/store/ManagedExtensionProjection.java b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/store/ManagedExtensionProjection.java index 12022f4663..34a2168ce3 100644 --- a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/store/ManagedExtensionProjection.java +++ b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/store/ManagedExtensionProjection.java @@ -84,7 +84,27 @@ public final class ManagedExtensionProjection { ManagedSessionMessageRecords::requireMessage, body -> body.get("messageId").textValue(), ManagedSessionMessageRecords::isMessageStart, - ManagedSessionMessageRecords::isMessageSuccessor))); + ManagedSessionMessageRecords::isMessageSuccessor)), + entry("team_state", new Body(record -> null, + ManagedTeamRecords::requireState, + body -> body.get("teamId").textValue(), + ManagedTeamRecords::isStateStart, + ManagedTeamRecords::isStateSuccessor)), + entry("team_task", new Body(record -> null, + ManagedTeamRecords::requireTask, + body -> body.get("taskId").textValue(), + ManagedTeamRecords::isTaskStart, + ManagedTeamRecords::isTaskSuccessor)), + entry("team_message", new Body(record -> null, + ManagedTeamRecords::requireMessage, + body -> body.get("messageId").textValue(), + ManagedTeamRecords::isMessageStart, + ManagedTeamRecords::isMessageSuccessor)), + entry("team_plan", new Body(record -> null, + ManagedTeamRecords::requirePlan, + body -> body.get("requestId").textValue(), + ManagedTeamRecords::isPlanStart, + ManagedTeamRecords::isPlanSuccessor))); public static final List TASK_STATES = List.of("pending", "running", "waiting", "completed", "failed", "cancelled", "degraded", "recovery_blocked"); diff --git a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/store/ManagedExtensionRecordStore.java b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/store/ManagedExtensionRecordStore.java index b3a035f5a9..beb1234f2d 100644 --- a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/store/ManagedExtensionRecordStore.java +++ b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/store/ManagedExtensionRecordStore.java @@ -18,7 +18,10 @@ import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.sql.ResultSet; import java.sql.SQLException; +import java.util.ArrayDeque; import java.util.ArrayList; +import java.util.Deque; +import java.util.HashSet; import java.util.HexFormat; import java.util.Iterator; import java.util.List; @@ -710,6 +713,15 @@ public class ManagedExtensionRecordStore { JsonNode ref = record.get("contentRef"); requireReference(resources.apply(ref.get("resourceId").textValue()), ref); } + if (List.of("team_task", "team_message", "team_plan").contains(domain)) { + for (String field : List.of("descriptionRef", "metadataRef", "contentRef", + "planRef", "feedbackRef")) { + JsonNode ref = record.get(field); + if (ref != null && !ref.isNull()) { + requireReference(resources.apply(ref.get("resourceId").textValue()), ref); + } + } + } if (domain.equals("schedule")) { JsonNode ref = record.get("promptRef"); requireReference(resources.apply(ref.get("resourceId").textValue()), ref); @@ -882,6 +894,12 @@ public class ManagedExtensionRecordStore { requireSessionMessage(tenantId, sessionId, scopeKey, record, previous == null, resources); } + if (domain.startsWith("team_")) { + requireTeamRecord(sessionId, scopeKey, domain, record, + previous == null ? null + : readBody(resources.apply(previous.resourceId())), + resources); + } if (previous == null) { if (domain.equals("child_run")) { requireContinuation(sessionId, scopeKey, record, resources); @@ -1324,6 +1342,174 @@ public class ManagedExtensionRecordStore { } } + /** + * H4e: a team lives in its lead's journal. Each member joins as a live + * child Session run of the lead that no other team lists; every other + * team record opens only in an active team of this Session, so a + * closing team drains, and its parties, owner, dependencies and plan + * member come from that team. The authority checks the same rules. + */ + private void requireTeamRecord(String sessionId, String scopeKey, + String domain, JsonNode record, JsonNode previous, + Function resources) { + if (domain.equals("team_state")) { + require(sessionId.equals(record.get("leadSessionId").textValue()), + "Team must be led by this Session."); + Set joined = new HashSet<>(); + if (previous != null) { + previous.get("members").forEach(member -> joined.add( + member.get("childRunId").textValue())); + } + String teamId = record.get("teamId").textValue(); + for (JsonNode member : record.get("members")) { + String run = member.get("childRunId").textValue(); + if (joined.contains(run)) { + continue; + } + JsonNode child = recordBody(scopeKey, sessionId, "child_run", + run, resources); + require(child != null + && ManagedExtensionRecords.isChildSessionRun(child) + && !ManagedExtensionRecords.isTerminalRunState( + child.at("/run/state").textValue()), + "Team member must join as a child Session run of this" + + " Session that has not ended."); + // Joins are rare, so the teams are read rather than indexed. + for (JsonNode other : domainBodies(scopeKey, "team_state", + resources)) { + require(other.get("teamId").textValue().equals(teamId) + || memberWith(other, "childRunId", run) == null, + "Team member's child run must belong to no other" + + " team."); + } + } + return; + } + String teamId = record.get("teamId").textValue(); + JsonNode team = recordBody(scopeKey, sessionId, "team_state", teamId, + resources); + require(team != null && (previous != null + || "active".equals(team.get("lifecycle").textValue())), + "Team record must open in an active team of this Session."); + if (domain.equals("team_task")) { + String taskId = record.get("taskId").textValue(); + if (previous == null) { + // Read only when a task opens; its number never changes. + for (JsonNode other : domainBodies(scopeKey, "team_task", + resources)) { + require(!teamId.equals(other.get("teamId").textValue()) + || other.get("number").longValue() + != record.get("number").longValue(), + "Team task number must be unique in its team."); + } + } + JsonNode owner = record.get("owner"); + require(owner.isNull() || (previous != null + && ManagedExtensionRecords.same(owner, previous.get("owner"))) + || takesPart(team, owner.textValue()), + "Team task owner must be the leader or a member of its" + + " team."); + Set earlier = new HashSet<>(); + if (previous != null) { + previous.get("blockedBy").forEach(each -> earlier.add( + each.textValue())); + } + Deque pending = new ArrayDeque<>(); + for (JsonNode each : record.get("blockedBy")) { + String blocker = each.textValue(); + if (earlier.contains(blocker)) { + continue; + } + JsonNode other = recordBody(scopeKey, sessionId, "team_task", + blocker, resources); + require(other != null + && teamId.equals(other.get("teamId").textValue()), + "Team task must be blocked only by tasks of its team."); + pending.push(blocker); + } + // Only the new edges can close a cycle: one does exactly when + // this task is reachable from a new blocker. + Set seen = new HashSet<>(); + while (!pending.isEmpty()) { + String next = pending.pop(); + require(!next.equals(taskId), + "Team task dependencies must not form a cycle."); + if (seen.add(next)) { + recordBody(scopeKey, sessionId, "team_task", next, + resources).get("blockedBy").forEach(each -> + pending.push(each.textValue())); + } + } + } + if (domain.equals("team_message")) { + String to = record.get("to").textValue(); + // Every revision, not only the opening: the roster only grows, + // so a lawful successor always passes, while one that + // readdresses the message is refused before its recipient is + // looked up below. + require(takesPart(team, record.get("from").textValue()) + && takesPart(team, to), + "Team message must travel between the leader and members" + + " of its team."); + JsonNode target = record.get("targetSessionId"); + if (!target.isNull()) { + // The leader is this Session; a member is the Session its + // run attached, so a message to one not attached yet stays + // planned. + String expected = sessionId; + if (!ManagedTeamRecords.LEADER.equals(to)) { + JsonNode child = recordBody(scopeKey, sessionId, + "child_run", memberWith(team, "name", to) + .get("childRunId").textValue(), + resources); + expected = ManagedExtensionRecords.isChildSessionRun(child) + && !child.get("childSessionId").isNull() + ? child.get("childSessionId").textValue() + : null; + } + require(target.textValue().equals(expected), + "Team message must target the Session of its" + + " recipient."); + } + } + if (domain.equals("team_plan") && previous == null) { + JsonNode member = memberWith(team, "name", + record.get("member").textValue()); + require(member != null + && member.get("planModeRequired").booleanValue(), + "Team plan must come from a member of its team that" + + " requires plan mode."); + } + } + + private static boolean takesPart(JsonNode team, String name) { + return ManagedTeamRecords.LEADER.equals(name) + || memberWith(team, "name", name) != null; + } + + private static JsonNode memberWith(JsonNode team, String key, + String value) { + for (JsonNode member : team.get("members")) { + if (value.equals(member.get(key).textValue())) { + return member; + } + } + return null; + } + + /** The latest committed body of every record of one domain of this + * Session. */ + private List domainBodies(String scopeKey, String domain, + Function resources) { + return jdbc.query("SELECT record_resource_id FROM" + + " qwen_managed_session_extension_record WHERE" + + " session_scope_key = ? AND domain = ?", + (result, row) -> result.getString("record_resource_id"), + scopeKey, domain) + .stream().map(resource -> readBody(resources.apply(resource))) + .toList(); + } + /** The latest committed body of one record of this Session, or null. */ private JsonNode recordBody(String scopeKey, String sessionId, String domain, String recordId, diff --git a/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/store/ManagedTeamRecords.java b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/store/ManagedTeamRecords.java new file mode 100644 index 0000000000..dafb173f31 --- /dev/null +++ b/packages/sdk-java/managed-agent-server/src/main/java/com/alibaba/qwen/code/managedagent/store/ManagedTeamRecords.java @@ -0,0 +1,487 @@ +package com.alibaba.qwen.code.managedagent.store; + +import com.alibaba.qwen.code.managedagent.store.ManagedExtensionRecords.InvalidRecordException; +import com.fasterxml.jackson.databind.JsonNode; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.function.BooleanSupplier; +import java.util.regex.Pattern; + +/** + * The four team record bodies (H4e of #12827): a team's roster and + * lifecycle ({@code managed-team_state}), its board + * ({@code managed-team_task}), its mailbox ({@code managed-team_message}) + * and its plan approvals ({@code managed-team_plan}), all held by the lead + * Session. The shared fixtures in packages/core pin the validators, and + * managed-team-record.ts there replays the same cases. The domains stay + * disabled for submission until the slice that ships their producers. + */ +public final class ManagedTeamRecords { + /** Legacy MAX_TEAMMATES; the lead is not a member. */ + public static final int MAX_MEMBERS = 10; + public static final int MAX_NAME_LENGTH = 64; + public static final int MAX_BLOCKERS = 64; + /** Bytes of a description, a message, a plan or its feedback. */ + public static final long MAX_CONTENT_BYTES = 64 * 1024; + /** Legacy MAX_METADATA_BYTES. */ + public static final long MAX_METADATA_BYTES = 32 * 1024; + /** The name that stands for the lead wherever a record names a + * participant. */ + public static final String LEADER = "leader"; + + private static final Set STATE_KEYS = Set.of("lifecycle", + "leadSessionId", "members", "membershipRevision", "name", "run", + "teamId"); + private static final Set MEMBER_KEYS = Set.of("childRunId", + "name", "planModeRequired"); + private static final Set TASK_KEYS = Set.of("activeForm", + "blockedBy", "descriptionRef", "metadataRef", "number", "owner", + "run", "status", "subject", "taskId", "teamId"); + private static final Set MESSAGE_KEYS = Set.of("contentDigest", + "contentRef", "from", "inputId", "kind", "messageId", "run", + "targetSessionId", "teamId", "to"); + private static final Set PLAN_KEYS = Set.of("decision", + "feedbackRef", "member", "planRef", "planRevision", "requestId", + "run", "teamId"); + private static final List STATE_FIXED = List.of("leadSessionId", + "name", "teamId"); + private static final List TASK_FIXED = List.of("number", + "taskId", "teamId"); + /** Every message key but the run is fixed, except the two set once. */ + private static final List MESSAGE_FIXED = List.of( + "contentDigest", "contentRef", "from", "kind", "messageId", + "teamId", "to"); + private static final List PLAN_FIXED = List.of("member", + "planRef", "planRevision", "requestId", "teamId"); + private static final List LIFECYCLES = List.of("active", + "closing", "deleted"); + private static final List TASK_STATUSES = List.of("pending", + "in_progress", "completed", "deleted"); + /** Who may send each kind: a member to the leader, or the reverse. */ + private static final List TO_LEADER = List.of( + "plan_approval_request", "shutdown_approved", + "shutdown_rejected"); + private static final List FROM_LEADER = List.of( + "plan_approval_response", "shutdown_request"); + private static final List MESSAGE_KINDS = List.of("message", + "task_assignment", "plan_approval_request", "shutdown_approved", + "shutdown_rejected", "plan_approval_response", "shutdown_request"); + private static final List RUN_IDENTITIES = List.of("definition", + "executionCallId", "effectId", "dispatchId", "deliveryId", + "execution", "runtime", "delivery"); + /** Legacy sanitizeName output: dash-separated lowercase runs. */ + private static final Pattern NAME = Pattern.compile( + "[a-z0-9]+(?:-[a-z0-9]+)*"); + /** The largest integer both validators read exactly. */ + private static final long MAX_COUNT = 9_007_199_254_740_990L; + + private ManagedTeamRecords() { + } + + /** + * Checks the body of a managed-team_state record: the append-only + * roster and the lifecycle. The run is admitted while the team lives + * and cancelled once it is deleted. + */ + public static void requireState(JsonNode team) { + ManagedExtensionRecords.closed(team, STATE_KEYS, "Team state"); + String lifecycle = oneOf(team.get("lifecycle"), LIFECYCLES, + "Team lifecycle must be active, closing or deleted"); + String state = logicalRun(team.get("run"), "Team", + List.of("admitted", "cancelled")); + require("deleted".equals(lifecycle) == "cancelled".equals(state), + "Team run must be cancelled exactly once the team is deleted"); + JsonNode members = team.get("members"); + require(members.isArray(), "Team members must be an array"); + require(members.size() <= MAX_MEMBERS, + "Team members exceed " + MAX_MEMBERS + " entries"); + Set names = new HashSet<>(); + Set runs = new HashSet<>(); + for (JsonNode member : members) { + ManagedExtensionRecords.closed(member, MEMBER_KEYS, + "Team member"); + String memberName = name(member.get("name"), "Team member name"); + require(!LEADER.equals(memberName), + "Team member cannot be named leader"); + String run = ManagedExtensionRecords.id(member.get("childRunId"), + "members.childRunId"); + require(member.get("planModeRequired").isBoolean(), + "members.planModeRequired must be boolean"); + names.add(memberName); + runs.add(run); + } + require(names.size() == members.size() + && runs.size() == members.size(), + "Team members must have distinct names and child runs"); + long revision = ManagedExtensionRecords.count( + team.get("membershipRevision"), 1, MAX_COUNT, + "membershipRevision"); + require(revision == members.size() + 1L, + "Team membershipRevision must be one more than its member" + + " count"); + ManagedExtensionRecords.id(team.get("teamId"), "teamId"); + name(team.get("name"), "Team name"); + ManagedExtensionRecords.id(team.get("leadSessionId"), + "leadSessionId"); + } + + /** Whether {@code team} may open a team: active, with no members yet. */ + public static boolean isStateStart(JsonNode team) { + return accepts(() -> { + requireState(team); + return "active".equals(team.get("lifecycle").textValue()) + && team.get("members").isEmpty(); + }); + } + + /** + * Whether {@code next} may follow {@code previous}: the team's identity + * and lead never change, the lifecycle stays or takes one step, and an + * active team that stays active may gain one member, appended. A + * deleted team is frozen. + */ + public static boolean isStateSuccessor(JsonNode previous, JsonNode next) { + return accepts(() -> { + requireState(previous); + requireState(next); + if (!fixed(previous, next, STATE_FIXED) + || !ManagedExtensionRecords.isRunSuccessor( + previous.get("run"), next.get("run"))) { + return false; + } + String before = previous.get("lifecycle").textValue(); + String after = next.get("lifecycle").textValue(); + int from = LIFECYCLES.indexOf(before); + int step = LIFECYCLES.indexOf(after); + if (step != from && step != from + 1) { + return false; + } + if (!prefix(previous.get("members"), next.get("members"))) { + return false; + } + int joined = next.get("members").size() + - previous.get("members").size(); + return joined == 0 || (joined == 1 && "active".equals(before) + && "active".equals(after)); + }); + } + + /** + * Checks the body of a managed-team_task record: one board item of the + * Legacy task model. Its run is admitted until the task is deleted and + * cancelled after. + */ + public static void requireTask(JsonNode task) { + ManagedExtensionRecords.closed(task, TASK_KEYS, "Team task"); + String status = oneOf(task.get("status"), TASK_STATUSES, + "Team task status must be pending, in_progress, completed or" + + " deleted"); + String state = logicalRun(task.get("run"), "Team task", + List.of("admitted", "cancelled")); + require("deleted".equals(status) == "cancelled".equals(state), + "Team task run must be cancelled exactly once the task is" + + " deleted"); + String taskId = ManagedExtensionRecords.id(task.get("taskId"), + "taskId"); + JsonNode owner = task.get("owner"); + if (!owner.isNull()) { + name(owner, "Team task owner"); + } + require(!"in_progress".equals(status) || !owner.isNull(), + "Team task in progress must have an owner"); + JsonNode blockedBy = task.get("blockedBy"); + require(blockedBy.isArray(), "Team task blockedBy must be an array"); + require(blockedBy.size() <= MAX_BLOCKERS, + "Team task blockedBy exceeds " + MAX_BLOCKERS + " entries"); + Set blockers = new HashSet<>(); + for (JsonNode blocker : blockedBy) { + blockers.add(ManagedExtensionRecords.id(blocker, "blockedBy")); + } + require(blockers.size() == blockedBy.size() + && !blockers.contains(taskId), + "Team task blockedBy must name distinct other tasks"); + ManagedExtensionRecords.id(task.get("teamId"), "teamId"); + ManagedExtensionRecords.count(task.get("number"), 1, MAX_COUNT, + "number"); + ManagedExtensionRecords.boundedText(task.get("subject"), "subject"); + ref(task.get("descriptionRef"), "descriptionRef", MAX_CONTENT_BYTES); + if (!task.get("activeForm").isNull()) { + ManagedExtensionRecords.boundedText(task.get("activeForm"), + "activeForm"); + } + if (!task.get("metadataRef").isNull()) { + ref(task.get("metadataRef"), "metadataRef", MAX_METADATA_BYTES); + } + } + + /** Whether {@code task} may open a task: any status but deleted. */ + public static boolean isTaskStart(JsonNode task) { + return accepts(() -> { + requireTask(task); + return !"deleted".equals(task.get("status").textValue()); + }); + } + + /** + * Whether {@code next} may follow {@code previous}: the task's identity + * and number never change, its dependencies only grow, and every other + * field moves freely until the task is deleted, which freezes it. + */ + public static boolean isTaskSuccessor(JsonNode previous, JsonNode next) { + return accepts(() -> { + requireTask(previous); + requireTask(next); + if (!fixed(previous, next, TASK_FIXED) + || !ManagedExtensionRecords.isRunSuccessor( + previous.get("run"), next.get("run")) + || !prefix(previous.get("blockedBy"), + next.get("blockedBy"))) { + return false; + } + return !"deleted".equals(previous.get("status").textValue()) + || ManagedExtensionRecords.same(previous, next); + }); + } + + /** + * Checks the body of a managed-team_message record: one mailbox + * message to one recipient, held as the lead's outbox entry. As with a + * Session message, the send is an act completed by the commit, so the + * run is settled from its first revision and only its session delivery + * moves. + */ + public static void requireMessage(JsonNode message) { + ManagedExtensionRecords.closed(message, MESSAGE_KEYS, + "Team message"); + String kind = oneOf(message.get("kind"), MESSAGE_KINDS, + "Team message kind must be one of " + + String.join(", ", MESSAGE_KINDS)); + String from = name(message.get("from"), "Team message from"); + String to = name(message.get("to"), "Team message to"); + require(!from.equals(to), "Team message cannot address its own sender"); + require(!TO_LEADER.contains(kind) + || (!LEADER.equals(from) && LEADER.equals(to)), + "Team message of kind " + kind + + " must go from a member to the leader"); + require(!FROM_LEADER.contains(kind) + || (LEADER.equals(from) && !LEADER.equals(to)), + "Team message of kind " + kind + + " must go from the leader to a member"); + require(!"task_assignment".equals(kind) || !LEADER.equals(to), + "Team message of kind task_assignment must go to a member"); + JsonNode run = message.get("run"); + ManagedExtensionRecords.requireRun(run); + require(run.get("definition").isNull() + && run.get("effectId").isNull() + && run.get("dispatchId").isNull() + && run.get("deliveryId").isNull() + && run.get("execution").isNull() + && run.get("runtime").isNull(), + "Team message run must be purely logical"); + require("settled".equals(run.get("state").textValue()), + "Team message run must be settled"); + require(!run.get("executionCallId").isNull(), + "Team message run must name its sending call"); + JsonNode delivery = run.get("delivery"); + // The run carries no deliveryId, so the delivery is a session one. + require(!delivery.isNull(), + "Team message delivery must be a session delivery"); + String state = delivery.get("state").textValue(); + JsonNode contentRef = message.get("contentRef"); + ref(contentRef, "contentRef", MAX_CONTENT_BYTES); + ManagedExtensionRecords.digest(message.get("contentDigest"), + "contentDigest"); + require(message.get("contentDigest").textValue().equals( + contentRef.get("digest").textValue()), + "Team message contentDigest must name the content's digest"); + JsonNode target = message.get("targetSessionId"); + if (!target.isNull()) { + ManagedExtensionRecords.id(target, "targetSessionId"); + } + JsonNode input = message.get("inputId"); + if (!input.isNull()) { + ManagedExtensionRecords.id(input, "inputId"); + } + require(!target.isNull() || "planned".equals(state) + || "cancelled".equals(state), + "Team message must fix its target once its delivery is" + + " claimed"); + require(!input.isNull() == ("accepted".equals(state) + || "consumed".equals(state)), + "Team message names its target's input exactly once" + + " accepted"); + ManagedExtensionRecords.id(message.get("teamId"), "teamId"); + ManagedExtensionRecords.id(message.get("messageId"), "messageId"); + } + + /** Whether {@code message} may open a message: its delivery is still + * planned. */ + public static boolean isMessageStart(JsonNode message) { + return accepts(() -> { + requireMessage(message); + return "planned".equals(message.at("/run/delivery/state") + .textValue()); + }); + } + + /** + * Whether {@code next} may follow {@code previous}: the message, its + * parties and its content never change, the target and the input are + * set once, and the delivery takes one shared step at a time. + */ + public static boolean isMessageSuccessor(JsonNode previous, + JsonNode next) { + return accepts(() -> { + requireMessage(previous); + requireMessage(next); + if (!fixed(previous, next, MESSAGE_FIXED)) { + return false; + } + for (String key : List.of("targetSessionId", "inputId")) { + if (!previous.get(key).isNull() + && !ManagedExtensionRecords.same(previous.get(key), + next.get(key))) { + return false; + } + } + return ManagedExtensionRecords.isRunSuccessor(previous.get("run"), + next.get("run")); + }); + } + + /** + * Checks the body of a managed-team_plan record: one plan a member + * submitted for the leader's approval. Its run waits for the decision, + * settles with it, or is cancelled when the plan is withdrawn or + * superseded. + */ + public static void requirePlan(JsonNode plan) { + ManagedExtensionRecords.closed(plan, PLAN_KEYS, "Team plan"); + String state = logicalRun(plan.get("run"), "Team plan", + List.of("waiting", "settled", "cancelled")); + String member = name(plan.get("member"), "Team plan member"); + require(!LEADER.equals(member), + "Team plan member cannot be the leader"); + JsonNode decision = plan.get("decision"); + if (!decision.isNull()) { + oneOf(decision, List.of("approved", "rejected"), + "Team plan decision must be approved or rejected"); + } + require(!decision.isNull() == "settled".equals(state), + "Team plan has a decision exactly once its run settled"); + JsonNode feedback = plan.get("feedbackRef"); + if (!feedback.isNull()) { + ref(feedback, "feedbackRef", MAX_CONTENT_BYTES); + } + require(feedback.isNull() || !decision.isNull(), + "Team plan feedback comes only with a decision"); + ManagedExtensionRecords.id(plan.get("teamId"), "teamId"); + ManagedExtensionRecords.id(plan.get("requestId"), "requestId"); + ManagedExtensionRecords.count(plan.get("planRevision"), 1, MAX_COUNT, + "planRevision"); + ref(plan.get("planRef"), "planRef", MAX_CONTENT_BYTES); + } + + /** Whether {@code plan} may open a plan request: it waits for its + * decision. */ + public static boolean isPlanStart(JsonNode plan) { + return accepts(() -> { + requirePlan(plan); + return "waiting".equals(plan.at("/run/state").textValue()); + }); + } + + /** + * Whether {@code next} may follow {@code previous}: the request, its + * member and its plan never change, and the run ends once, which fixes + * the decision. + */ + public static boolean isPlanSuccessor(JsonNode previous, JsonNode next) { + return accepts(() -> { + requirePlan(previous); + requirePlan(next); + return fixed(previous, next, PLAN_FIXED) + && ManagedExtensionRecords.isRunSuccessor( + previous.get("run"), next.get("run")) + && ("waiting".equals(previous.at("/run/state").textValue()) + || ManagedExtensionRecords.same(previous, next)); + }); + } + + /** + * A purely logical lifecycle in one of {@code states}: nothing runs for + * a team record, so it pins no definition and names no physical + * identity. + */ + private static String logicalRun(JsonNode run, String label, + List states) { + ManagedExtensionRecords.requireRun(run); + for (String key : RUN_IDENTITIES) { + require(run.get(key).isNull(), label + " run must be purely logical"); + } + String state = run.get("state").textValue(); + require(states.contains(state), + label + " run must be " + String.join(" or ", states)); + return state; + } + + private static String name(JsonNode node, String label) { + require(node.isTextual() && node.textValue().length() <= MAX_NAME_LENGTH + && NAME.matcher(node.textValue()).matches(), + label + " must be dash-separated lowercase letters and digits," + + " at most " + MAX_NAME_LENGTH + " characters"); + return node.textValue(); + } + + private static void ref(JsonNode node, String label, long maxBytes) { + ManagedExtensionRecords.durableRef(node, label); + require(node.get("byteLength").longValue() <= maxBytes, + label + " exceeds " + maxBytes + " bytes"); + } + + private static String oneOf(JsonNode node, List allowed, + String message) { + require(node.isTextual() && allowed.contains(node.textValue()), + message); + return node.textValue(); + } + + private static boolean fixed(JsonNode previous, JsonNode next, + List keys) { + for (String key : keys) { + if (!ManagedExtensionRecords.same(previous.get(key), + next.get(key))) { + return false; + } + } + return true; + } + + private static boolean prefix(JsonNode before, JsonNode after) { + if (after.size() < before.size()) { + return false; + } + for (int index = 0; index < before.size(); index++) { + if (!ManagedExtensionRecords.same(before.get(index), + after.get(index))) { + return false; + } + } + return true; + } + + private static boolean accepts(BooleanSupplier check) { + try { + return check.getAsBoolean(); + } catch (InvalidRecordException exception) { + return false; + } + } + + private static void require(boolean condition, String message) { + if (!condition) { + throw new InvalidRecordException(message + "."); + } + } +} diff --git a/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/ManagedTeamRecordContractTest.java b/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/ManagedTeamRecordContractTest.java new file mode 100644 index 0000000000..36aeef483a --- /dev/null +++ b/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/ManagedTeamRecordContractTest.java @@ -0,0 +1,160 @@ +package com.alibaba.qwen.code.managedagent; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.alibaba.qwen.code.managedagent.store.ManagedExtensionProjection; +import com.alibaba.qwen.code.managedagent.store.ManagedExtensionRecords.InvalidRecordException; +import com.alibaba.qwen.code.managedagent.store.ManagedTeamRecords; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import org.junit.jupiter.api.Test; + +/** + * Replays the language-neutral managed-team-record/1 fixtures (H4e of + * #12827) that the TypeScript module replays too. + */ +class ManagedTeamRecordContractTest { + private static final ObjectMapper JSON = new ObjectMapper(); + private static final Map RECORD_ID = Map.of( + "team_state", "teamId", "team_task", "taskId", + "team_message", "messageId", "team_plan", "requestId"); + + @Test + void pinsTheContractTheKeysAndTheLimits() throws IOException { + JsonNode fixtures = fixtures(); + assertEquals("managed-team-record/1", + fixtures.required("contract").textValue()); + assertEquals(ManagedTeamRecords.LEADER, + fixtures.required("leader").textValue()); + JsonNode limits = fixtures.required("limits"); + assertEquals(ManagedTeamRecords.MAX_MEMBERS, + limits.required("maxMembers").intValue()); + assertEquals(ManagedTeamRecords.MAX_NAME_LENGTH, + limits.required("maxNameLength").intValue()); + assertEquals(ManagedTeamRecords.MAX_BLOCKERS, + limits.required("maxBlockers").intValue()); + assertEquals(ManagedTeamRecords.MAX_CONTENT_BYTES, + limits.required("maxContentBytes").longValue()); + assertEquals(ManagedTeamRecords.MAX_METADATA_BYTES, + limits.required("maxMetadataBytes").longValue()); + assertEquals(List.of("leadSessionId", "lifecycle", "members", + "membershipRevision", "name", "run", "teamId"), + jsonList(fixtures.at("/keys/team_state"))); + assertEquals(List.of("activeForm", "blockedBy", "descriptionRef", + "metadataRef", "number", "owner", "run", "status", "subject", + "taskId", "teamId"), jsonList(fixtures.at("/keys/team_task"))); + assertEquals(List.of("contentDigest", "contentRef", "from", "inputId", + "kind", "messageId", "run", "targetSessionId", "teamId", "to"), + jsonList(fixtures.at("/keys/team_message"))); + assertEquals(List.of("decision", "feedbackRef", "member", "planRef", + "planRevision", "requestId", "run", "teamId"), + jsonList(fixtures.at("/keys/team_plan"))); + assertEquals(List.of("leadSessionId", "name", "teamId"), + jsonList(fixtures.at("/fixedKeys/team_state"))); + assertEquals(List.of("number", "taskId", "teamId"), + jsonList(fixtures.at("/fixedKeys/team_task"))); + assertEquals(List.of("contentDigest", "contentRef", "from", "kind", + "messageId", "teamId", "to"), + jsonList(fixtures.at("/fixedKeys/team_message"))); + assertEquals(List.of("member", "planRef", "planRevision", "requestId", + "teamId"), jsonList(fixtures.at("/fixedKeys/team_plan"))); + fixtures.required("domains").fieldNames().forEachRemaining(domain -> { + assertNotNull(ManagedExtensionProjection.RECORD_BODIES.get(domain), + domain); + assertTrue(fixtures.at("/domains/" + domain + "/taskKind") + .isNull(), domain); + }); + } + + @Test + void validatesTheSharedRecordsAndStarts() throws IOException { + JsonNode fixtures = fixtures(); + for (JsonNode fixture : fixtures.get("cases")) { + String id = fixture.get("id").textValue(); + String domain = fixture.get("domain").textValue(); + var body = ManagedExtensionProjection.RECORD_BODIES.get(domain); + JsonNode record = merge(template(fixtures, fixture), + fixture.get("patch")); + if (fixture.get("valid").booleanValue()) { + body.require().accept(record); + assertNull(body.taskKindOf().apply(record), id); + assertEquals(record.get(RECORD_ID.get(domain)).textValue(), + body.recordId().apply(record), id); + } else { + InvalidRecordException refused = assertThrows( + InvalidRecordException.class, + () -> body.require().accept(record), id); + // Every invalid case names the clause that must refuse it, + // so a masked guard can never slip a fixture green. + assertTrue(refused.getMessage() + .contains(fixture.get("error").textValue()), + id + ": " + refused.getMessage()); + } + assertEquals(fixture.get("start").booleanValue(), + body.isStart().test(record), id); + } + } + + @Test + void validatesTheSharedSuccessors() throws IOException { + JsonNode fixtures = fixtures(); + for (JsonNode fixture : fixtures.get("successors")) { + JsonNode base = template(fixtures, fixture); + assertEquals(fixture.get("valid").booleanValue(), + ManagedExtensionProjection.RECORD_BODIES + .get(fixture.get("domain").textValue()) + .isSuccessor().test( + merge(base, fixture.get("before")), + merge(base, fixture.get("after"))), + fixture.get("id").textValue()); + } + } + + private static JsonNode template(JsonNode fixtures, JsonNode fixture) { + return Objects.requireNonNull(fixtures.get("templates") + .get(fixture.get("template").textValue()), + () -> fixture.get("id").textValue() + + " names an unknown template"); + } + + private static List jsonList(JsonNode node) { + List values = new ArrayList<>(); + node.forEach(each -> values.add(each.textValue())); + return values; + } + + static JsonNode fixtures() throws IOException { + Path directory = Path.of("").toAbsolutePath(); + while (directory != null) { + Path path = directory.resolve( + "packages/core/src/managed-runtime/contracts/managed-team-record-v1.fixtures.json"); + if (Files.exists(path)) { + return JSON.readTree(Files.readString(path)); + } + directory = directory.getParent(); + } + throw new IOException("Team contract fixtures not found"); + } + + static JsonNode merge(JsonNode base, JsonNode patch) { + ObjectNode value = base == null || !base.isObject() + ? JSON.createObjectNode() : base.deepCopy(); + patch.fields().forEachRemaining(entry -> value.set(entry.getKey(), + entry.getValue().isObject() + ? merge(value.get(entry.getKey()), entry.getValue()) + : entry.getValue().deepCopy())); + return value; + } +} diff --git a/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/ManagedTeamStoreTest.java b/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/ManagedTeamStoreTest.java new file mode 100644 index 0000000000..cfbfd0faf9 --- /dev/null +++ b/packages/sdk-java/managed-agent-server/src/test/java/com/alibaba/qwen/code/managedagent/ManagedTeamStoreTest.java @@ -0,0 +1,455 @@ +package com.alibaba.qwen.code.managedagent; + +import static com.alibaba.qwen.code.managedagent.ManagedExtensionRecordStoreTest.childAgent; +import static com.alibaba.qwen.code.managedagent.ManagedExtensionRecordStoreTest.childRun; +import static com.alibaba.qwen.code.managedagent.ManagedExtensionRecordStoreTest.commitDomain; +import static com.alibaba.qwen.code.managedagent.ManagedExtensionRecordStoreTest.hookRef; +import static com.alibaba.qwen.code.managedagent.ManagedExtensionRecordStoreTest.hookResource; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.alibaba.qwen.code.managedagent.api.ApiException; +import com.alibaba.qwen.code.managedagent.store.ManagedExtensionRecordStore; +import com.alibaba.qwen.code.managedagent.store.ManagedSessionStore; +import com.alibaba.qwen.code.managedagent.store.ManagedSessionStoreModels; +import com.alibaba.qwen.code.managedagent.store.ManagedSessionStoreModels.CommitResource; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.JsonNodeFactory; +import com.fasterxml.jackson.databind.node.ObjectNode; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.UUID; +import org.assertj.core.api.ThrowableAssert.ThrowingCallable; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.HttpStatus; +import org.springframework.jdbc.core.JdbcTemplate; + +/** + * The store-side rules of H4e-a (docs/design/2026-10-10-managed-agent-teams.md): + * every team record binds to its lead Session's journal, a member joins as + * a live child Session run in one team, new team records open only in an + * active team, and a task's owner and dependencies, a message's parties and + * target and a plan's member come from that team. The authority suite + * managed-session-authority.team.test.ts pins the same rules. + */ +@SpringBootTest(properties = { + "spring.datasource.url=jdbc:h2:mem:managed-extension-records;" + + "MODE=MySQL;DB_CLOSE_DELAY=-1;DATABASE_TO_LOWER=TRUE", + "spring.datasource.driver-class-name=org.h2.Driver", + "spring.datasource.username=sa", + "spring.datasource.password=", + "qwen.managed-agent.harness.enabled=false" +}) +class ManagedTeamStoreTest { + private static final String TENANT = "tenant-extension"; + private static final String WORKSPACE = "workspace-extension"; + private static final String OPEN = + "Team record must open in an active team of this Session"; + private static final String ROUTE = "Team message must travel between the" + + " leader and members of its team"; + private static final String TARGET = + "Team message must target the Session of its recipient"; + private static final String JOIN = "Team member must join as a child" + + " Session run of this Session that has not ended"; + private static final JsonNode TEMPLATES = templates(); + + @Autowired + private ManagedSessionStore sessionStore; + + @Autowired + private ManagedExtensionRecordStore records; + + @Autowired + private JdbcTemplate jdbc; + + private final CommitResource input = hookResource("child-input", + "managed-input", "{\"prompt\":\"review\"}".getBytes( + StandardCharsets.UTF_8)); + private final CommitResource result = hookResource("child-result", + "managed-child-result", "{\"summary\":\"clean\"}".getBytes( + StandardCharsets.UTF_8)); + private final CommitResource receipt = hookResource("child-receipt", + "managed-runtime-receipt", "{}".getBytes(StandardCharsets.UTF_8)); + private final CommitResource content = hookResource("team-content", + "managed-team-content", "check the tests too".getBytes( + StandardCharsets.UTF_8)); + private int commands; + + @Test + void chainsATeamItsBoardItsMailboxAndAPlan() { + String lead = UUID.randomUUID().toString(); + ExtensionRecordJournal journal = staffed(lead); + commit(journal, "team_task", task("task-1", 1)); + ObjectNode claimed = task("task-2", 2, "task-1"); + commit(journal, "team_task", claimed); + claimed.put("owner", "alice"); + claimed.put("status", "in_progress"); + commit(journal, "team_task", claimed); + for (String state : List.of("planned", "accepting", "accepted", + "consumed")) { + commit(journal, "team_message", message("message-1", "leader", + "alice", state, "session-run-1")); + } + commit(journal, "team_message", message("message-2", "alice", + "leader", "planned", null)); + commit(journal, "team_message", message("message-2", "alice", + "leader", "accepting", lead)); + commit(journal, "team_plan", plan("bob")); + ObjectNode approved = plan("bob"); + approved.put("decision", "approved"); + approved.withObject("/run").put("state", "settled"); + commit(journal, "team_plan", approved); + commit(journal, "team_state", team(lead, "closing", ALICE, BOB)); + commit(journal, "team_state", team(lead, "deleted", ALICE, BOB)); + assertThat(records.listRecords(TENANT, lead, "team_state")) + .singleElement().satisfies(team -> assertThat( + team.get("lifecycle").textValue()) + .isEqualTo("deleted")); + assertThat(records.listRecords(TENANT, lead, "team_task")) + .hasSize(2); + assertThat(records.listRecords(TENANT, lead, "team_message")) + .extracting(each -> each.at("/run/delivery/state") + .textValue()) + .containsExactlyInAnyOrder("consumed", "accepting"); + assertThat(records.listRecords(TENANT, lead, "team_plan")) + .singleElement().satisfies(each -> assertThat( + each.get("decision").textValue()) + .isEqualTo("approved")); + // No team record is a task: the two members' child runs alone show. + assertThat(records.listTasks(TENANT, lead, null, null, 10).tasks()) + .hasSize(2); + } + + @Test + void bindsATeamToItsLeadAndItsMembersToLiveChildRuns() { + String lead = UUID.randomUUID().toString(); + ExtensionRecordJournal journal = journal(lead); + assertRefused("a team another Session leads", lead, + "Team must be led by this Session", + () -> commit(journal, "team_state", + team("session-other", "active"))); + commit(journal, "team_state", team(lead, "active")); + assertRefused("a member with no child run", lead, JOIN, + () -> commit(journal, "team_state", + team(lead, "active", ALICE))); + commitDomain(journal, "shell-1", "child_run", + childRun("admitted", "intent", null, input), List.of(input)); + assertRefused("a member that is a background Shell", lead, JOIN, + () -> commit(journal, "team_state", team(lead, "active", + member("alice", "shell-x", false)))); + child(journal, lead, "run-1", 4); + assertRefused("a member whose run ended", lead, JOIN, + () -> commit(journal, "team_state", + team(lead, "active", ALICE))); + // A run that only launched is live: it may join before it attaches. + child(journal, lead, "run-2", 1); + commit(journal, "team_state", team(lead, "active", BOB)); + ObjectNode other = team(lead, "active"); + other.put("teamId", "team-2"); + other.put("name", "other-team"); + commit(journal, "team_state", other); + ObjectNode poached = team(lead, "active", member("robert", "run-2", + true)); + poached.put("teamId", "team-2"); + poached.put("name", "other-team"); + assertRefused("a member another team lists", lead, + "Team member's child run must belong to no other team", + () -> commit(journal, "team_state", poached)); + } + + @Test + void opensTeamRecordsOnlyInAnActiveTeamAndLetsOpenOnesDrain() { + String lead = UUID.randomUUID().toString(); + ExtensionRecordJournal journal = journal(lead); + assertRefused("a task in a missing team", lead, OPEN, + () -> commit(journal, "team_task", task("task-1", 1))); + ExtensionRecordJournal staffed = staffed(lead, journal); + commit(staffed, "team_message", message("message-1", "leader", + "alice", "planned", null)); + commit(staffed, "team_plan", plan("bob")); + commit(staffed, "team_state", team(lead, "closing", ALICE, BOB)); + assertRefused("a task in a closing team", lead, OPEN, + () -> commit(staffed, "team_task", task("task-1", 1))); + assertRefused("a message in a closing team", lead, OPEN, + () -> commit(staffed, "team_message", message("message-2", + "leader", "alice", "planned", null))); + ObjectNode later = plan("bob"); + later.put("requestId", "request-2"); + assertRefused("a plan request in a closing team", lead, OPEN, + () -> commit(staffed, "team_plan", later)); + commit(staffed, "team_message", message("message-1", "leader", + "alice", "accepting", "session-run-1")); + ObjectNode withdrawn = plan("bob"); + withdrawn.withObject("/run").put("state", "cancelled"); + commit(staffed, "team_plan", withdrawn); + } + + @Test + void keepsTheBoardInsideItsTeam() { + String lead = UUID.randomUUID().toString(); + ExtensionRecordJournal journal = staffed(lead); + ObjectNode other = team(lead, "active"); + other.put("teamId", "team-2"); + other.put("name", "other-team"); + commit(journal, "team_state", other); + ObjectNode elsewhere = task("elsewhere", 1); + elsewhere.put("teamId", "team-2"); + commit(journal, "team_task", elsewhere); + commit(journal, "team_task", task("task-1", 1)); + assertRefused("a second task #1", lead, + "Team task number must be unique in its team", + () -> commit(journal, "team_task", task("task-9", 1))); + ObjectNode stranger = task("task-1", 1); + stranger.put("owner", "carol"); + assertRefused("a task given to a stranger", lead, + "Team task owner must be the leader or a member of its team", + () -> commit(journal, "team_task", stranger)); + ObjectNode led = task("task-1", 1); + led.put("owner", "leader"); + commit(journal, "team_task", led); + assertRefused("a blocker outside the team", lead, + "Team task must be blocked only by tasks of its team", + () -> commit(journal, "team_task", task("task-2", 2, + "elsewhere"))); + assertRefused("a missing blocker", lead, + "Team task must be blocked only by tasks of its team", + () -> commit(journal, "team_task", task("task-2", 2, + "missing"))); + commit(journal, "team_task", task("task-2", 2, "task-1")); + commit(journal, "team_task", task("task-3", 3, "task-2")); + ObjectNode cycle = task("task-1", 1, "task-3"); + cycle.put("owner", "leader"); + assertRefused("a dependency that closes a cycle", lead, + "Team task dependencies must not form a cycle", + () -> commit(journal, "team_task", cycle)); + // A second path to the same blocker is no cycle. + commit(journal, "team_task", task("task-3", 3, "task-2", "task-1")); + } + + @Test + void routesMessagesAndPlansWithinTheTeam() { + String lead = UUID.randomUUID().toString(); + ExtensionRecordJournal journal = staffed(lead); + child(journal, lead, "run-3", 1); + commit(journal, "team_state", team(lead, "active", ALICE, BOB, + member("dave", "run-3", false))); + assertRefused("a message from a stranger", lead, ROUTE, + () -> commit(journal, "team_message", message("message-1", + "carol", "alice", "planned", null))); + assertRefused("a message to a stranger", lead, ROUTE, + () -> commit(journal, "team_message", message("message-1", + "leader", "carol", "planned", null))); + commit(journal, "team_message", message("message-1", "leader", + "alice", "planned", null)); + // A successor that readdresses the message to a stranger is refused + // as the conflict it is, before anything looks its recipient up. + assertRefused("a successor readdressed to a stranger", lead, ROUTE, + () -> commit(journal, "team_message", message("message-1", + "leader", "carol", "accepting", "session-run-1"))); + assertRefused("a message to another member's Session", lead, TARGET, + () -> commit(journal, "team_message", message("message-1", + "leader", "alice", "accepting", "session-run-2"))); + commit(journal, "team_message", message("message-2", "alice", "dave", + "planned", null)); + assertRefused("a claim before the member attached", lead, TARGET, + () -> commit(journal, "team_message", message("message-2", + "alice", "dave", "accepting", "session-run-3"))); + commit(journal, "team_message", message("message-3", "bob", + "leader", "planned", null)); + assertRefused("a message to the leader in another Session", lead, + TARGET, () -> commit(journal, "team_message", + message("message-3", "bob", "leader", "accepting", + "session-run-2"))); + commit(journal, "team_message", message("message-3", "bob", "leader", + "accepting", lead)); + String planning = "Team plan must come from a member of its team" + + " that requires plan mode"; + assertRefused("a plan from a member that need not plan", lead, + planning, () -> commit(journal, "team_plan", plan("alice"))); + assertRefused("a plan from a stranger", lead, planning, + () -> commit(journal, "team_plan", plan("carol"))); + commit(journal, "team_plan", plan("bob")); + } + + @Test + void refusesTeamContentTheSessionDoesNotHold() { + String lead = UUID.randomUUID().toString(); + ExtensionRecordJournal journal = staffed(lead); + ObjectNode unheld = task("task-1", 1); + unheld.withObject("/descriptionRef").put("resourceId", + "never-committed"); + assertRefused("a task citing a description the Session does not" + + " hold", lead, ManagedSessionStoreModels.ERROR_RESOURCE_MISSING, + null, () -> journal.commit(journal.requestDomain("task-1", + "team_task", unheld, List.of(), 1_000))); + } + + private static final ObjectNode ALICE = member("alice", "run-1", false); + private static final ObjectNode BOB = member("bob", "run-2", true); + + private static ObjectNode member(String name, String run, + boolean planModeRequired) { + return JsonNodeFactory.instance.objectNode().put("name", name) + .put("childRunId", run) + .put("planModeRequired", planModeRequired); + } + + private static ObjectNode team(String lead, String lifecycle, + ObjectNode... members) { + ObjectNode body = TEMPLATES.get("team_state").deepCopy(); + body.put("leadSessionId", lead); + body.put("lifecycle", lifecycle); + ArrayNode roster = body.putArray("members"); + for (ObjectNode member : members) { + roster.add(member.deepCopy()); + } + body.put("membershipRevision", members.length + 1); + body.withObject("/run").put("state", "deleted".equals(lifecycle) + ? "cancelled" : "admitted"); + return body; + } + + private ObjectNode task(String taskId, int number, String... blockedBy) { + ObjectNode body = TEMPLATES.get("team_task").deepCopy(); + body.put("taskId", taskId); + body.put("number", number); + body.set("descriptionRef", hookRef(content)); + ArrayNode blockers = body.putArray("blockedBy"); + for (String blocker : blockedBy) { + blockers.add(blocker); + } + return body; + } + + private ObjectNode message(String messageId, String from, String to, + String state, String target) { + ObjectNode body = TEMPLATES.get("team_message").deepCopy(); + body.put("messageId", messageId); + body.put("from", from); + body.put("to", to); + body.set("contentRef", hookRef(content)); + body.put("contentDigest", content.digest()); + if ("planned".equals(state)) { + body.putNull("targetSessionId"); + } else { + body.put("targetSessionId", target); + } + if ("accepted".equals(state) || "consumed".equals(state)) { + body.put("inputId", messageId + ":input"); + } + body.withObject("/run/delivery").put("state", state); + return body; + } + + private ObjectNode plan(String member) { + ObjectNode body = TEMPLATES.get("team_plan").deepCopy(); + body.put("member", member); + body.set("planRef", hookRef(content)); + return body; + } + + /** Commits the first {@code count} revisions of child run {@code run}, + * which attaches to {@code session-}. */ + private void child(ExtensionRecordJournal journal, String lead, + String run, int count) { + List steps = List.of( + new String[] {"admitted", "intent", null}, + new String[] {"running", "dispatch_started", "binding-1"}, + new String[] {"running", "running_attached", "binding-1"}, + new String[] {"settled", "settled", "binding-1"}); + for (int index = 0; index < count; index++) { + String[] step = steps.get(index); + ObjectNode body = childAgent(lead, "sent", step[0], step[1], + step[2], input); + body.put("childRunId", run); + body.withObject("/run").put("executionCallId", "call-" + run); + if (index > 0) { + body.withObject("/run").put("dispatchId", "dispatch-" + run); + } + if (index > 1) { + body.put("childSessionId", "session-" + run); + } + List closure = List.of(input); + if (index == 3) { + body.put("stopReason", "completed"); + body.set("resultRef", hookRef(result)); + body.set("terminalReceiptRef", hookRef(receipt)); + closure = List.of(result, receipt); + } + commitDomain(journal, run + ":" + (index + 1), "child_run", body, + closure); + } + } + + /** A team with alice (run-1) and bob (run-2), both attached. */ + private ExtensionRecordJournal staffed(String lead) { + return staffed(lead, journal(lead)); + } + + private ExtensionRecordJournal staffed(String lead, + ExtensionRecordJournal journal) { + child(journal, lead, "run-1", 3); + child(journal, lead, "run-2", 3); + commit(journal, "team_state", team(lead, "active")); + commit(journal, "team_state", team(lead, "active", ALICE)); + commit(journal, "team_state", team(lead, "active", ALICE, BOB)); + return journal; + } + + private void commit(ExtensionRecordJournal journal, String domain, + JsonNode body) { + commands++; + commitDomain(journal, "team-command-" + commands, domain, body, + "team_state".equals(domain) ? List.of() : List.of(content)); + } + + private ExtensionRecordJournal journal(String sessionId) { + return new ExtensionRecordJournal(sessionStore, TENANT, WORKSPACE, + sessionId).open(); + } + + /** A refused commit is a 409 naming its rule, and leaves no revision + * behind. */ + private void assertRefused(String label, String sessionId, + String message, ThrowingCallable commit) { + assertRefused(label, sessionId, + ManagedExtensionRecordStore.ERROR_REJECTED, message, commit); + } + + private void assertRefused(String label, String sessionId, String code, + String message, ThrowingCallable commit) { + long revisions = revisions(sessionId); + assertThatThrownBy(commit).as(label) + .isInstanceOfSatisfying(ApiException.class, error -> { + assertThat(error.getCode()).as(label).isEqualTo(code); + if (message != null) { + assertThat(error.getStatus()).as(label) + .isEqualTo(HttpStatus.CONFLICT); + assertThat(error.getMessage()).as(label) + .contains(message); + } + }); + assertThat(revisions(sessionId)).as(label).isEqualTo(revisions); + } + + private long revisions(String sessionId) { + Long total = jdbc.queryForObject("SELECT COALESCE(SUM(revision), 0)" + + " FROM qwen_managed_session_extension_record" + + " WHERE tenant_id = ? AND session_id = ?", + Long.class, TENANT, sessionId); + return total == null ? 0 : total; + } + + private static JsonNode templates() { + try { + return ManagedTeamRecordContractTest.fixtures().get("templates"); + } catch (IOException error) { + throw new UncheckedIOException(error); + } + } +}