From 1fe82099724dd4b8df640cf5a2970848df752978 Mon Sep 17 00:00:00 2001 From: sodawlh <3240101939@zju.edu.cn> Date: Tue, 11 Aug 2026 15:00:23 +0800 Subject: [PATCH 1/3] feat(swarm): add list_checkpoints tool and checkpoint name discovery --- openjiuwen/agent_teams/agent/team_agent.py | 60 ++++++++++-- openjiuwen/agent_teams/i18n.py | 11 +++ openjiuwen/agent_teams/runtime/metadata.py | 31 ++++-- .../agent_teams/spawn/inprocess_spawn.py | 2 +- openjiuwen/agent_teams/tools/locales/cn.py | 3 + .../tools/locales/descs/cn/checkpoint.md | 4 + .../locales/descs/cn/list_checkpoints.md | 14 +++ .../tools/locales/descs/cn/spawn_teammate.md | 1 + .../tools/locales/descs/en/checkpoint.md | 4 + .../locales/descs/en/list_checkpoints.md | 14 +++ .../tools/locales/descs/en/spawn_teammate.md | 1 + openjiuwen/agent_teams/tools/locales/en.py | 3 + openjiuwen/agent_teams/tools/team.py | 49 ++++++++-- openjiuwen/agent_teams/tools/tool_factory.py | 2 + openjiuwen/agent_teams/tools/tool_member.py | 94 ++++++++++++++++++- .../agent_teams/tools/tool_permissions.py | 1 + 16 files changed, 265 insertions(+), 29 deletions(-) create mode 100644 openjiuwen/agent_teams/tools/locales/descs/cn/list_checkpoints.md create mode 100644 openjiuwen/agent_teams/tools/locales/descs/en/list_checkpoints.md diff --git a/openjiuwen/agent_teams/agent/team_agent.py b/openjiuwen/agent_teams/agent/team_agent.py index 211441670..8b62b6514 100644 --- a/openjiuwen/agent_teams/agent/team_agent.py +++ b/openjiuwen/agent_teams/agent/team_agent.py @@ -78,7 +78,7 @@ def __init__(self, card): super().__init__(card) self._configurator = AgentConfigurator(card) self._state = TeamAgentState() - self._named_checkpoints: dict[str, int] = {} # name → message_count + self._named_checkpoints: dict[str, dict] = {} # name → {count, description, created_by} self._spawn_manager = SpawnManager( state=self._state, @@ -570,6 +570,7 @@ def _setup_infra(self, spec: TeamAgentSpec, ctx: TeamRuntimeContext) -> None: team_backend = self._configurator.team_backend if team_backend is not None: team_backend.set_store_checkpoint_fn(self.set_checkpoint) + team_backend.set_checkpoint_list_fn(lambda: self._named_checkpoints) def _setup_agent( self, @@ -1124,7 +1125,8 @@ async def _on_teammate_created(self, teammate_id: str): isinstance(fork_value, str) and fork_value not in ("true", "false") ) - ckpt_idx = self._named_checkpoints.get(fork_value) if is_named else None + ckpt_record = self._named_checkpoints.get(fork_value) if is_named else None + ckpt_idx = ckpt_record["count"] if ckpt_record else None if compact: if not is_named: @@ -1155,6 +1157,7 @@ async def _on_teammate_created(self, teammate_id: str): "member=%s; falling back to full context", fork_value, teammate_id, ) + await self._notify_fork_name_not_found(teammate_id, fork_value) elif is_named: fork_ctx = ForkContext.from_agent( native, checkpoint=ckpt_idx, @@ -1215,19 +1218,58 @@ def _resolve_fork_native(self, source_name: str | None): ) return None + async def _notify_fork_name_not_found(self, member: str, fork_name: str) -> None: + """Surface a wrong fork checkpoint name to the leader. + + The spawn still proceeds with a full-context fallback, but the + leader is told which name was requested and which names actually + exist, so a naming mismatch is no longer silent. + """ + from openjiuwen.agent_teams.i18n import t + + available = ", ".join(sorted(self._named_checkpoints)) or "(无)" + try: + await self.message_manager.send_message( + content=t( + "checkpoint.fork_not_found", + fork=fork_name, + member=member, + available=available, + ), + to_member_name=self._member_name(), + ) + except Exception as exc: # noqa: BLE001 - best-effort, never block the spawn + team_logger.warning( + "[fork] failed to notify leader about missing checkpoint '%s': %s", + fork_name, exc, + ) + def share_checkpoints_with(self, other: "TeamAgent") -> None: """Share the leader's checkpoint namespace with another agent.""" other.set_checkpoints_from(self._named_checkpoints) - def set_checkpoint(self, name: str, count: int) -> None: + def set_checkpoint( + self, + name: str, + count: int, + *, + description: str = "", + created_by: str | None = None, + ) -> None: """Store a named checkpoint. - The leader also mirrors the full mapping into the session's - per-team namespace so it survives process restart. Persistence is - deferred to the run cycle's ``post_run`` (no explicit flush), - matching allocator / lifecycle / pending_resume semantics. + Each checkpoint records ``{count, description, created_by}`` so the + leader can later list names together with their purpose and creator. + The leader also mirrors the full mapping into the session's per-team + namespace so it survives process restart. Persistence is deferred to + the run cycle's ``post_run`` (no explicit flush), matching allocator + / lifecycle / pending_resume semantics. """ - self._named_checkpoints[name] = count + self._named_checkpoints[name] = { + "count": count, + "description": description or "", + "created_by": created_by or "", + } if self.role == TeamRole.LEADER: self._merge_checkpoints_into_session() @@ -1252,7 +1294,7 @@ def _merge_checkpoints_into_session(self) -> None: exc, ) - def set_checkpoints_from(self, source: dict[str, int]) -> None: + def set_checkpoints_from(self, source: dict[str, dict]) -> None: """Replace this agent's checkpoint namespace with *source*.""" self._named_checkpoints = source diff --git a/openjiuwen/agent_teams/i18n.py b/openjiuwen/agent_teams/i18n.py index d5e6ed3c6..43b2d15aa 100644 --- a/openjiuwen/agent_teams/i18n.py +++ b/openjiuwen/agent_teams/i18n.py @@ -51,6 +51,11 @@ "人类成员 {member_name} 仍持有 {count} 个活跃任务 [{task_ids}],不允许非强制关闭。" "请先通过 send_message 与成员协商是否同意强制关闭并取消任务。" ), + # agent/fork.py — fork name mismatch surfaced to the leader + "checkpoint.fork_not_found": ( + "[fork 警告] checkpoint '{fork}' 不存在,已回退为全量继承(成员 {member})。" + "可用 checkpoint:{available}。请用 list_checkpoints 核对名字后再 fork。" + ), # reliability/ — anomaly remediation messages "reliability.steer_self_correct": ( "⚙️[可靠性] 检测到 {kind}:{summary}。请停止重复无效操作,改换策略或换用其他工具。" @@ -304,6 +309,12 @@ "and cannot be shut down without force. " "Use send_message to coordinate with the member on whether to force-shutdown and cancel the tasks." ), + # agent/fork.py — fork name mismatch surfaced to the leader + "checkpoint.fork_not_found": ( + "[fork warning] checkpoint '{fork}' not found; fell back to full-context " + "inheritance (member {member}). Available checkpoints: {available}. " + "Use list_checkpoints to verify names before forking." + ), # reliability/ — anomaly remediation messages "reliability.steer_self_correct": ( "[reliability] Detected {kind}: {summary}. Stop repeating the ineffective action; " diff --git a/openjiuwen/agent_teams/runtime/metadata.py b/openjiuwen/agent_teams/runtime/metadata.py index 364f61044..3170554e5 100644 --- a/openjiuwen/agent_teams/runtime/metadata.py +++ b/openjiuwen/agent_teams/runtime/metadata.py @@ -126,11 +126,13 @@ def clear_pending_resume(session, team_name: str) -> bool: return True -def read_team_checkpoints(session, team_name: str) -> dict[str, int] | None: +def read_team_checkpoints(session, team_name: str) -> dict[str, dict] | None: """Return the persisted named checkpoints for a team, or ``None`` when absent. - Defensively keeps only entries whose value is an ``int``: a stale blob - with a non-int message count must not break cold recovery. + Each checkpoint is a record ``{"count": int, "description": str, + "created_by": str}``. Legacy blobs that still hold a bare ``int`` count + are coerced to a record with empty description / creator so cold + recovery never breaks on an old format. """ bucket = read_team_namespace(session, team_name) if bucket is None: @@ -138,13 +140,22 @@ def read_team_checkpoints(session, team_name: str) -> dict[str, int] | None: raw = bucket.get(TEAM_CHECKPOINTS_KEY) if not isinstance(raw, dict): return None - return { - k: v for k, v in raw.items() - if isinstance(k, str) and isinstance(v, int) - } - - -def merge_team_checkpoints(session, team_name: str, mapping: dict[str, int]) -> None: + result: dict[str, dict] = {} + for name, value in raw.items(): + if not isinstance(name, str): + continue + if isinstance(value, int): + result[name] = {"count": value, "description": "", "created_by": ""} + elif isinstance(value, dict) and isinstance(value.get("count"), int): + result[name] = { + "count": value["count"], + "description": str(value.get("description") or ""), + "created_by": str(value.get("created_by") or ""), + } + return result + + +def merge_team_checkpoints(session, team_name: str, mapping: dict[str, dict]) -> None: """Replace the team bucket's named-checkpoint mapping (whole overwrite).""" merge_team_namespace(session, team_name, {TEAM_CHECKPOINTS_KEY: dict(mapping)}) diff --git a/openjiuwen/agent_teams/spawn/inprocess_spawn.py b/openjiuwen/agent_teams/spawn/inprocess_spawn.py index ac0002916..f3b9b99a0 100644 --- a/openjiuwen/agent_teams/spawn/inprocess_spawn.py +++ b/openjiuwen/agent_teams/spawn/inprocess_spawn.py @@ -75,7 +75,7 @@ async def inprocess_spawn( team_agent.share_checkpoints_with(teammate) if teammate.team_backend is not None: teammate.team_backend.set_store_checkpoint_fn( - lambda name, count: team_agent.set_checkpoint(name, count) + team_agent.set_checkpoint ) # Fork context injection: seed the teammate's context engine with the diff --git a/openjiuwen/agent_teams/tools/locales/cn.py b/openjiuwen/agent_teams/tools/locales/cn.py index 1ac0c61d4..2dbf33381 100644 --- a/openjiuwen/agent_teams/tools/locales/cn.py +++ b/openjiuwen/agent_teams/tools/locales/cn.py @@ -34,6 +34,9 @@ # checkpoint._desc lives in descs/cn/checkpoint.md "checkpoint.name": "快照名(语义化 slug,如 code-ready)。后续 fork 通过此名引用", "checkpoint.description": "可选描述,说明为何在此打快照", + "checkpoint.notify_leader": ( + "[checkpoint] 成员 {member} 在消息 {count} 处创建快照 '{name}'{description}" + ), # ===== clean_team ========================================================== # clean_team._desc lives in descs/cn/clean_team.md # ===== spawn_teammate ====================================================== diff --git a/openjiuwen/agent_teams/tools/locales/descs/cn/checkpoint.md b/openjiuwen/agent_teams/tools/locales/descs/cn/checkpoint.md index b873332ad..613b9b31e 100644 --- a/openjiuwen/agent_teams/tools/locales/descs/cn/checkpoint.md +++ b/openjiuwen/agent_teams/tools/locales/descs/cn/checkpoint.md @@ -34,3 +34,7 @@ spawn_teammate(name="dev-2", fork="code-ready", fork_source="understander", ...) ``` **快照存的是调用时刻此成员的 `len(messages)`**。之后上下文继续增长不会影响已存快照的语义——fork 从该位置截取,后续消息不在继承范围内。 + +## 告知 Leader + +打完快照后,**必须用 `send_message` 把确切快照名报给 leader**,方便 leader 据此 fork。运行时也会自动把快照名通知 leader;`send_message` 用于补充 leader 理解快照所需的上下文。leader 随时可用 `list_checkpoints` 查看权威清单——不要指望 leader 猜你起的名字。 diff --git a/openjiuwen/agent_teams/tools/locales/descs/cn/list_checkpoints.md b/openjiuwen/agent_teams/tools/locales/descs/cn/list_checkpoints.md new file mode 100644 index 000000000..35aff4612 --- /dev/null +++ b/openjiuwen/agent_teams/tools/locales/descs/cn/list_checkpoints.md @@ -0,0 +1,14 @@ +列出当前所有可供 fork 继承的命名 checkpoint,包含每个快照的名字、消息数、创建者与描述。 + +| 字段 | 含义 | +|---|---| +| **name** | 传给 `spawn_teammate(fork="")` 的**确切名字** | +| **message_count** | 打快照时的上下文长度 | +| **created_by** | 创建该快照的成员 | +| **description** | 打快照时记录的可选说明 | + +## 何时调用 + +**fork 之前必须调用**——不能凭猜测填 checkpoint 名字。成员创建快照时名字是任意的(见 `checkpoint` 工具),权威清单只在这里。填了不存在的名字,fork 会静默回退为全量继承,你将得不到任何继承的理解。 + +把返回的**确切名字**用于 `spawn_teammate(fork="", fork_source="", ...)`。 diff --git a/openjiuwen/agent_teams/tools/locales/descs/cn/spawn_teammate.md b/openjiuwen/agent_teams/tools/locales/descs/cn/spawn_teammate.md index 275edf8e2..61ec29b7b 100644 --- a/openjiuwen/agent_teams/tools/locales/descs/cn/spawn_teammate.md +++ b/openjiuwen/agent_teams/tools/locales/descs/cn/spawn_teammate.md @@ -82,4 +82,5 @@ - 继承的消息为源成员的完整对话历史(包含文件读取结果、搜索输出、分析结论) - fork 前先让源成员调 `checkpoint(name="xxx")` 打快照,再用 `fork="xxx"` 指定 +- **fork 前先调 `list_checkpoints` 拿到确切名字**——不要猜 checkpoint 名。填了不存在的名字会静默回退为全量继承 - `fork=true` 继承调用时刻的全部上下文,包含后续调度噪音时建议改用 checkpoint 模式 diff --git a/openjiuwen/agent_teams/tools/locales/descs/en/checkpoint.md b/openjiuwen/agent_teams/tools/locales/descs/en/checkpoint.md index 2dc24e6eb..b824bec26 100644 --- a/openjiuwen/agent_teams/tools/locales/descs/en/checkpoint.md +++ b/openjiuwen/agent_teams/tools/locales/descs/en/checkpoint.md @@ -34,3 +34,7 @@ spawn_teammate(name="dev-2", fork="code-ready", fork_source="understander", ...) ``` **The checkpoint stores `len(messages)` at call time.** Context growth after the call does not affect the snapshot's semantics — fork captures from that position; messages that arrive later are not inherited. + +## Notify the Leader + +After saving a checkpoint, **report the exact name to the leader via `send_message`** so the leader can fork from it. The runtime also auto-notifies the leader of the name; use `send_message` to add context the leader needs to understand the snapshot. The leader can call `list_checkpoints` at any time to see the authoritative list — never expect the leader to guess the name you chose. diff --git a/openjiuwen/agent_teams/tools/locales/descs/en/list_checkpoints.md b/openjiuwen/agent_teams/tools/locales/descs/en/list_checkpoints.md new file mode 100644 index 000000000..46981b4ff --- /dev/null +++ b/openjiuwen/agent_teams/tools/locales/descs/en/list_checkpoints.md @@ -0,0 +1,14 @@ +List all named checkpoints currently available for fork inheritance, with each snapshot's name, message count, creator, and description. + +| Field | Meaning | +|---|---| +| **name** | The exact string to pass to `spawn_teammate(fork="")` | +| **message_count** | Context length at snapshot time | +| **created_by** | The member who created the snapshot | +| **description** | Optional note recorded at snapshot time | + +## When to Call + +**Call before forking** — you must not guess a checkpoint name. Members create snapshots with arbitrary names (see the `checkpoint` tool), so the authoritative list lives here. Forking with a name that does not exist silently falls back to a full-context inheritance and you get no inherited understanding. + +Use the returned **exact name** in `spawn_teammate(fork="", fork_source="", ...)`. diff --git a/openjiuwen/agent_teams/tools/locales/descs/en/spawn_teammate.md b/openjiuwen/agent_teams/tools/locales/descs/en/spawn_teammate.md index 7088cf010..009efa779 100644 --- a/openjiuwen/agent_teams/tools/locales/descs/en/spawn_teammate.md +++ b/openjiuwen/agent_teams/tools/locales/descs/en/spawn_teammate.md @@ -87,4 +87,5 @@ Other combinations rely on built-in context compaction. - Inherited messages are the source member's full conversation history (file reads, search outputs, analysis conclusions) - Have the source member call `checkpoint(name="xxx")` before forking, then use `fork="xxx"` +- **Before forking, call `list_checkpoints` to get the exact names** — never guess a checkpoint name. Forking with a name that does not exist silently falls back to a full-context inheritance - `fork=true` inherits the full context at call time; prefer checkpoint mode when later scheduling noise would otherwise be included diff --git a/openjiuwen/agent_teams/tools/locales/en.py b/openjiuwen/agent_teams/tools/locales/en.py index 3f8f2ef8f..906c4e49e 100644 --- a/openjiuwen/agent_teams/tools/locales/en.py +++ b/openjiuwen/agent_teams/tools/locales/en.py @@ -43,6 +43,9 @@ # checkpoint._desc lives in descs/en/checkpoint.md "checkpoint.name": "Semantic snapshot name (e.g. code-ready). Used by later fork calls to reference this snapshot", "checkpoint.description": "Optional description of why this checkpoint was taken", + "checkpoint.notify_leader": ( + "[checkpoint] '{name}' created by {member} at message {count}{description}" + ), # ===== clean_team ========================================================== # clean_team._desc lives in descs/en/clean_team.md # ===== spawn_teammate ====================================================== diff --git a/openjiuwen/agent_teams/tools/team.py b/openjiuwen/agent_teams/tools/team.py index 1e96a32fe..df39e03fd 100644 --- a/openjiuwen/agent_teams/tools/team.py +++ b/openjiuwen/agent_teams/tools/team.py @@ -295,9 +295,10 @@ def __init__( # Fork / checkpoint support self._pending_forks: dict[str, dict] = {} # member_name → {fork, since, source} - self._checkpoints: dict[str, int] = {} # name → message_count + self._checkpoints: dict[str, dict] = {} # name → {count, description, created_by} self._snapshot_length: Callable[[], int] | None = None - self._store_checkpoint_fn: Callable[[str, int], None] | None = None + self._store_checkpoint_fn: Callable[..., None] | None = None + self._checkpoint_list_fn: Callable[[], dict] | None = None team_logger.info(f"AgentTeam manager initialized for {team_name}, member={member_name}") @@ -320,9 +321,17 @@ def set_snapshot_length(self, fn) -> None: self._snapshot_length = fn def set_store_checkpoint_fn(self, fn) -> None: - """Register the callback for persisting a named checkpoint.""" + """Register the callback for persisting a named checkpoint. + + ``fn(name, count, *, description, created_by)`` — the record fields + are passed through so the authoritative namespace can store them. + """ self._store_checkpoint_fn = fn + def set_checkpoint_list_fn(self, fn) -> None: + """Register the callback returning the authoritative checkpoint mapping.""" + self._checkpoint_list_fn = fn + def mark_fork_on_spawn( self, member: str, @@ -368,20 +377,44 @@ def snapshot_context_length(self) -> int: ) return 0 - def store_checkpoint(self, name: str, count: int) -> None: + def store_checkpoint( + self, + name: str, + count: int, + *, + description: str = "", + created_by: str | None = None, + ) -> None: team_logger.debug( "[fork] store_checkpoint: member=%s name=%s count=%d " "has_store_fn=%s", self.member_name, name, count, self._store_checkpoint_fn is not None, ) + created_by = created_by or self.member_name + record = { + "count": count, + "description": description or "", + "created_by": created_by, + } if self._store_checkpoint_fn is not None: - self._store_checkpoint_fn(name, count) + self._store_checkpoint_fn(name, count, description=description, created_by=created_by) else: - self._checkpoints[name] = count + self._checkpoints[name] = record + + def get_checkpoints(self) -> dict[str, dict]: + return {name: dict(record) for name, record in self._checkpoints.items()} - def get_checkpoints(self) -> dict[str, int]: - return dict(self._checkpoints) + def list_checkpoints(self) -> dict[str, dict]: + """Return the authoritative checkpoint mapping for this team. + + Prefers the callback-wired namespace (the leader's in-memory dict, + which in-process members write into); falls back to the local dict + when no callback is wired. + """ + if self._checkpoint_list_fn is not None: + return {name: dict(record) for name, record in self._checkpoint_list_fn().items()} + return self.get_checkpoints() # ------------------------------------------------------------------ diff --git a/openjiuwen/agent_teams/tools/tool_factory.py b/openjiuwen/agent_teams/tools/tool_factory.py index 986bfc75f..94977b053 100644 --- a/openjiuwen/agent_teams/tools/tool_factory.py +++ b/openjiuwen/agent_teams/tools/tool_factory.py @@ -17,6 +17,7 @@ ApprovePlanTool, ApproveToolCallTool, CheckpointTool, + ListCheckpointsTool, ShutdownMemberTool, SpawnBridgeAgentTool, SpawnExternalCliTool, @@ -172,6 +173,7 @@ def create_team_tools( # Member management — one tool per role_type (flat schema, no role branching) "spawn_teammate": SpawnTeammateTool(agent_team, t, model_config_allocator=model_config_allocator), "checkpoint": CheckpointTool(agent_team, t), + "list_checkpoints": ListCheckpointsTool(agent_team, t), "spawn_human_agent": SpawnHumanAgentTool(agent_team, t), "spawn_bridge_agent": SpawnBridgeAgentTool(agent_team, t), "spawn_external_cli": SpawnExternalCliTool(agent_team, t), diff --git a/openjiuwen/agent_teams/tools/tool_member.py b/openjiuwen/agent_teams/tools/tool_member.py index 0c05bdaa6..693beb0e5 100644 --- a/openjiuwen/agent_teams/tools/tool_member.py +++ b/openjiuwen/agent_teams/tools/tool_member.py @@ -10,6 +10,7 @@ from openjiuwen.agent_teams.tools.team import TeamBackend from openjiuwen.agent_teams.tools.tool_base import TeamTool from openjiuwen.agent_teams.tools.tool_permissions import _MEMBER_NAME_PATTERN +from openjiuwen.core.common.logging import team_logger from openjiuwen.core.foundation.tool.base import ToolCard from openjiuwen.harness.tools.base_tool import ToolOutput @@ -40,6 +41,7 @@ def __init__(self, team: TeamBackend, t: Translator, tool_name: str): ) ) self.team = team + self.t = t @staticmethod def _validate_member_name(member_name: str | None) -> str | None: @@ -228,12 +230,54 @@ def __init__(self, team: TeamBackend, t: Translator): async def invoke(self, inputs: dict[str, Any], **kwargs) -> ToolOutput: name = inputs["name"] count = self.team.snapshot_context_length() - self.team.store_checkpoint(name, count) + description = inputs.get("description") or "" + self.team.store_checkpoint( + name, + count, + description=description, + created_by=self.team.member_name, + ) + await self._notify_leader_created(name, count, description) return ToolOutput( success=True, data={"name": name, "message_count": count}, ) + async def _notify_leader_created( + self, + name: str, + count: int, + description: str, + ) -> None: + """Tell the leader a checkpoint was created by this member. + + This is the fixed channel through which a checkpoint's exact name + reaches the leader, so the leader never has to guess it when forking. + """ + if self.team.is_leader: + return + try: + leader = await self.team.resolve_leader_member_name() + if not leader or leader == self.team.member_name: + return + await self.team.message_manager.send_message( + content=self.t( + "checkpoint", "notify_leader", + name=name, + member=self.team.member_name, + count=str(count), + description=( + f" ({description})" if description else "" + ), + ), + to_member_name=leader, + ) + except Exception as exc: # noqa: BLE001 - best-effort, never break the tool call + team_logger.warning( + "[checkpoint] failed to notify leader of '%s': %s", + name, exc, + ) + def map_result(self, output: ToolOutput) -> str: if not output.success: return output.error or "Failed to save checkpoint" @@ -241,6 +285,54 @@ def map_result(self, output: ToolOutput) -> str: return f"Checkpoint '{d['name']}' saved at message {d['message_count']}" +class ListCheckpointsTool(TeamTool): + """List all named checkpoints available for fork inheritance.""" + + def __init__(self, team: TeamBackend, t: Translator): + super().__init__( + ToolCard( + id="team.list_checkpoints", + name="list_checkpoints", + description=t("list_checkpoints"), + ) + ) + self.team = team + self.card.input_params = {"type": "object", "properties": {}, "required": []} + + async def invoke(self, inputs: dict[str, Any], **kwargs) -> ToolOutput: + checkpoints = self.team.list_checkpoints() + items = [ + { + "name": name, + "message_count": record.get("count"), + "description": record.get("description", ""), + "created_by": record.get("created_by", ""), + } + for name, record in sorted(checkpoints.items()) + ] + return ToolOutput( + success=True, + data={"checkpoints": items, "count": len(items)}, + ) + + def map_result(self, output: ToolOutput) -> str: + if not output.success: + return output.error or "Failed to list checkpoints" + checkpoints = output.data["checkpoints"] + if not checkpoints: + return "No checkpoints" + lines = [] + for item in checkpoints: + line = ( + f"name={item['name']} message_count={item['message_count']} " + f"created_by={item['created_by']}" + ) + if item.get("description"): + line += f' description="{item["description"]}"' + lines.append(line) + return "\n".join(lines) + + class SpawnHumanAgentTool(_SpawnToolBase): """Spawn a human member driven by the real user (``role_type='human_agent'``). diff --git a/openjiuwen/agent_teams/tools/tool_permissions.py b/openjiuwen/agent_teams/tools/tool_permissions.py index d509a6111..7fd2d06d5 100644 --- a/openjiuwen/agent_teams/tools/tool_permissions.py +++ b/openjiuwen/agent_teams/tools/tool_permissions.py @@ -19,6 +19,7 @@ "shutdown_member", # Shutdown a team member "approve_plan", # Approve or reject a member's plan "approve_tool", # Approve or reject a teammate tool call + "list_checkpoints", # List named checkpoints available for fork inheritance "create_task", # Create tasks (batch / with deps) "update_task", # Update task content / cancel tasks "swarmflow", # Run a swarmflow orchestration script (gated by enable_swarmflow) From 9ba7d154c0cef77f9d0274ff8014c2af7d9bb6d7 Mon Sep 17 00:00:00 2001 From: sodawlh <3240101939@zju.edu.cn> Date: Tue, 11 Aug 2026 15:00:24 +0800 Subject: [PATCH 2/3] test(swarm): add list_checkpoints and leader-notify tests --- .../agent_teams/runtime/test_metadata.py | 44 +++++-- tests/unit_tests/agent_teams/test_fork.py | 121 ++++++++++++++++-- .../agent_teams/test_runner_team_runtime.py | 26 +++- .../unit_tests/agent_teams/test_team_tools.py | 75 +++++++++++ 4 files changed, 239 insertions(+), 27 deletions(-) diff --git a/tests/unit_tests/agent_teams/runtime/test_metadata.py b/tests/unit_tests/agent_teams/runtime/test_metadata.py index cd89df1ce..dd0bb418c 100644 --- a/tests/unit_tests/agent_teams/runtime/test_metadata.py +++ b/tests/unit_tests/agent_teams/runtime/test_metadata.py @@ -163,17 +163,23 @@ def test_read_pending_resume_ignores_non_dict_payload(): def test_merge_team_checkpoints_creates_bucket_key(): session = _StubSession() write_team_namespace(session, "alpha", {"spec": {"team_name": "alpha"}}) - merge_team_checkpoints(session, "alpha", {"code-ready": 5}) + merge_team_checkpoints( + session, + "alpha", + {"code-ready": {"count": 5, "description": "base done", "created_by": "dev-1"}}, + ) bucket = read_team_namespace(session, "alpha") - assert bucket["checkpoints"] == {"code-ready": 5} + assert bucket["checkpoints"] == { + "code-ready": {"count": 5, "description": "base done", "created_by": "dev-1"} + } assert bucket["spec"] == {"team_name": "alpha"} def test_merge_team_checkpoints_replaces_whole_mapping(): session = _StubSession() - merge_team_checkpoints(session, "alpha", {"a": 1}) - merge_team_checkpoints(session, "alpha", {"b": 2}) - assert read_team_checkpoints(session, "alpha") == {"b": 2} + merge_team_checkpoints(session, "alpha", {"a": {"count": 1}}) + merge_team_checkpoints(session, "alpha", {"b": {"count": 2}}) + assert read_team_checkpoints(session, "alpha") == {"b": {"count": 2, "description": "", "created_by": ""}} def test_read_team_checkpoints_returns_none_when_absent(): @@ -183,7 +189,29 @@ def test_read_team_checkpoints_returns_none_when_absent(): assert read_team_checkpoints(session, "alpha") is None -def test_read_team_checkpoints_filters_non_int_values(): +def test_read_team_checkpoints_coerces_legacy_int_counts(): + """A legacy ``name -> int`` blob is coerced to a full record.""" session = _StubSession() - write_team_namespace(session, "alpha", {TEAM_CHECKPOINTS_KEY: {"ok": 3, "bad": "x", 1: 2}}) - assert read_team_checkpoints(session, "alpha") == {"ok": 3} + write_team_namespace(session, "alpha", {TEAM_CHECKPOINTS_KEY: {"code-ready": 5}}) + assert read_team_checkpoints(session, "alpha") == { + "code-ready": {"count": 5, "description": "", "created_by": ""} + } + + +def test_read_team_checkpoints_filters_invalid_entries(): + session = _StubSession() + write_team_namespace( + session, + "alpha", + { + TEAM_CHECKPOINTS_KEY: { + "ok": {"count": 3, "description": "d", "created_by": "dev-1"}, + "bad-str": "x", + "bad-count": {"count": "not-int"}, + 1: {"count": 2}, + } + }, + ) + assert read_team_checkpoints(session, "alpha") == { + "ok": {"count": 3, "description": "d", "created_by": "dev-1"} + } diff --git a/tests/unit_tests/agent_teams/test_fork.py b/tests/unit_tests/agent_teams/test_fork.py index be5958e23..4fbdb45d1 100644 --- a/tests/unit_tests/agent_teams/test_fork.py +++ b/tests/unit_tests/agent_teams/test_fork.py @@ -196,7 +196,47 @@ async def test_invoke_saves_checkpoint(self, agent_team): assert result.success is True assert result.data["name"] == "code-ready" assert result.data["message_count"] == 15 - assert agent_team.get_checkpoints()["code-ready"] == 15 + record = agent_team.get_checkpoints()["code-ready"] + assert record["count"] == 15 + assert record["description"] == "" + assert record["created_by"] == "leader-1" + + @pytest.mark.asyncio + @pytest.mark.level1 + async def test_invoke_notifies_leader_when_member(self, db, messager): + t = make_translator("cn") + member = TeamBackend( + team_name="test-team", + member_name="counter-1", + is_leader=False, + db=db, + messager=messager, + ) + member._snapshot_length = lambda: 7 + member.message_manager = AsyncMock() + member.resolve_leader_member_name = AsyncMock(return_value="team-leader") + tool = CheckpointTool(member, t) + + result = await tool.invoke({"name": "count-1", "description": "报数1完成"}) + + assert result.success is True + member.message_manager.send_message.assert_awaited_once() + args = member.message_manager.send_message.await_args + assert args.kwargs["to_member_name"] == "team-leader" + assert "count-1" in args.kwargs["content"] + assert "counter-1" in args.kwargs["content"] + + @pytest.mark.asyncio + @pytest.mark.level1 + async def test_invoke_does_not_notify_leader_when_leader(self, agent_team): + t = make_translator("cn") + agent_team._snapshot_length = lambda: 15 + agent_team.message_manager = AsyncMock() + tool = CheckpointTool(agent_team, t) + + await tool.invoke({"name": "code-ready"}) + + agent_team.message_manager.send_message.assert_not_awaited() @pytest.mark.level0 def test_map_result_success(self, agent_team): @@ -251,15 +291,33 @@ def test_snapshot_context_length_no_callback(self, agent_team): @pytest.mark.level0 def test_store_checkpoint_with_callback(self, agent_team): stored = {} - agent_team._store_checkpoint_fn = lambda n, c: stored.update({n: c}) - agent_team.store_checkpoint("ck", 7) - assert stored["ck"] == 7 + agent_team._store_checkpoint_fn = ( + lambda n, c, description="", created_by=None: stored.update({n: (c, description, created_by)}) + ) + agent_team.store_checkpoint("ck", 7, description="desc", created_by="dev-1") + assert stored["ck"] == (7, "desc", "dev-1") @pytest.mark.level0 def test_store_checkpoint_fallback(self, agent_team): agent_team._store_checkpoint_fn = None agent_team.store_checkpoint("fb", 3) - assert agent_team.get_checkpoints()["fb"] == 3 + record = agent_team.get_checkpoints()["fb"] + assert record["count"] == 3 + assert record["description"] == "" + assert record["created_by"] == "leader-1" + + @pytest.mark.level0 + def test_list_checkpoints_prefers_wired_namespace(self, agent_team): + agent_team.set_checkpoint_list_fn(lambda: {"a": {"count": 1, "description": "d", "created_by": "dev-1"}}) + checkpoints = agent_team.list_checkpoints() + assert checkpoints["a"]["count"] == 1 + assert checkpoints["a"]["description"] == "d" + + @pytest.mark.level1 + def test_list_checkpoints_falls_back_to_local(self, agent_team): + agent_team._checkpoint_list_fn = None + agent_team._checkpoints = {"b": {"count": 2, "description": "", "created_by": ""}} + assert agent_team.list_checkpoints()["b"]["count"] == 2 # ── SpawnTeammateTool fork params ────────────────────────────────────────── @@ -472,6 +530,7 @@ def _make_agent( agent._configurator.team_backend.consume_fork_on_spawn = MagicMock( return_value=fork_info ) + agent._configurator.message_manager = AsyncMock() agent._named_checkpoints = dict(checkpoints or {}) agent._spawn_manager = MagicMock() agent._spawn_manager.spawned_handles = {} @@ -525,7 +584,7 @@ async def test_live_fork_boolean_true_injects_full_context(self): async def test_named_fork_truncates_to_checkpoint(self): agent = self._make_agent( {"fork": "code-ready", "since": None, "source": None, "compact": False}, - checkpoints={"code-ready": 2}, + checkpoints={"code-ready": {"count": 2}}, ) fork_from = await self._run(agent) assert isinstance(fork_from, ForkContext) @@ -548,7 +607,7 @@ async def test_named_fork_missing_falls_back_to_full(self): async def test_named_fork_with_compact_sets_split(self): agent = self._make_agent( {"fork": "code-ready", "since": None, "source": None, "compact": True}, - checkpoints={"code-ready": 2}, + checkpoints={"code-ready": {"count": 2}}, ) fork_from = await self._run(agent) assert isinstance(fork_from, ForkContext) @@ -602,6 +661,32 @@ async def test_source_equal_leader_uses_own_native(self): assert isinstance(fork_from, ForkContext) assert len(fork_from.messages) == self._MESSAGE_COUNT + @pytest.mark.asyncio + @pytest.mark.level1 + async def test_missing_checkpoint_name_notifies_leader_with_available(self): + """A wrong fork name is surfaced to the leader, not silently ignored.""" + agent = self._make_agent( + {"fork": "no-such", "since": None, "source": None, "compact": False}, + checkpoints={"code-ready": {"count": 2}}, + ) + fork_from = await self._run(agent) + assert isinstance(fork_from, ForkContext) + assert len(fork_from.messages) == self._MESSAGE_COUNT # full-context fallback + agent._configurator.message_manager.send_message.assert_awaited_once() + content = agent._configurator.message_manager.send_message.await_args.kwargs["content"] + assert "no-such" in content + assert "code-ready" in content + + @pytest.mark.asyncio + @pytest.mark.level1 + async def test_resolved_checkpoint_name_does_not_notify(self): + agent = self._make_agent( + {"fork": "code-ready", "since": None, "source": None, "compact": False}, + checkpoints={"code-ready": {"count": 2}}, + ) + await self._run(agent) + agent._configurator.message_manager.send_message.assert_not_awaited() + @pytest.mark.asyncio @pytest.mark.level1 async def test_fork_capture_failure_degrades_to_no_inheritance(self, monkeypatch): @@ -613,7 +698,7 @@ async def test_fork_capture_failure_degrades_to_no_inheritance(self, monkeypatch """ agent = self._make_agent( {"fork": "code-ready", "since": None, "source": None, "compact": False}, - checkpoints={"code-ready": 2}, + checkpoints={"code-ready": {"count": 2}}, ) def _boom(*args, **kwargs): @@ -880,11 +965,17 @@ def test_leader_set_checkpoint_mirrors_full_mapping_into_session(self): session = _StubSession() agent = self._make_agent(role=TeamRole.LEADER, team_session=session) - agent.set_checkpoint("code-ready", 5) + agent.set_checkpoint("code-ready", 5, description="base done", created_by="dev-1") agent.set_checkpoint("refactor-done", 12) - assert agent._named_checkpoints == {"code-ready": 5, "refactor-done": 12} - assert self._bucket_checkpoints(session) == {"code-ready": 5, "refactor-done": 12} + assert agent._named_checkpoints == { + "code-ready": {"count": 5, "description": "base done", "created_by": "dev-1"}, + "refactor-done": {"count": 12, "description": "", "created_by": ""}, + } + assert self._bucket_checkpoints(session) == { + "code-ready": {"count": 5, "description": "base done", "created_by": "dev-1"}, + "refactor-done": {"count": 12, "description": "", "created_by": ""}, + } @pytest.mark.level1 def test_leader_set_checkpoint_unbound_session_degrades_gracefully(self): @@ -892,7 +983,9 @@ def test_leader_set_checkpoint_unbound_session_degrades_gracefully(self): agent = self._make_agent(role=TeamRole.LEADER, team_session=None) agent.set_checkpoint("code-ready", 5) - assert agent._named_checkpoints == {"code-ready": 5} + assert agent._named_checkpoints == { + "code-ready": {"count": 5, "description": "", "created_by": ""} + } @pytest.mark.level1 def test_teammate_set_checkpoint_does_not_touch_session(self): @@ -903,5 +996,7 @@ def test_teammate_set_checkpoint_does_not_touch_session(self): agent.set_checkpoint("code-ready", 5) - assert agent._named_checkpoints == {"code-ready": 5} + assert agent._named_checkpoints == { + "code-ready": {"count": 5, "description": "", "created_by": ""} + } assert TEAMS_KEY not in session.state diff --git a/tests/unit_tests/agent_teams/test_runner_team_runtime.py b/tests/unit_tests/agent_teams/test_runner_team_runtime.py index deba15f67..aa5adebfe 100644 --- a/tests/unit_tests/agent_teams/test_runner_team_runtime.py +++ b/tests/unit_tests/agent_teams/test_runner_team_runtime.py @@ -1114,11 +1114,21 @@ def test_team_agent_recover_from_session_restores_checkpoints(): }, }, ) - merge_team_checkpoints(session, team_name, {"code-ready": 5, "refactor-done": 12}) + merge_team_checkpoints( + session, + team_name, + { + "code-ready": {"count": 5, "description": "base done", "created_by": "dev-1"}, + "refactor-done": {"count": 12}, + }, + ) agent = TeamAgent.recover_from_session(session, team_name) - assert agent._named_checkpoints == {"code-ready": 5, "refactor-done": 12} + assert agent._named_checkpoints == { + "code-ready": {"count": 5, "description": "base done", "created_by": "dev-1"}, + "refactor-done": {"count": 12, "description": "", "created_by": ""}, + } @pytest.mark.asyncio @@ -1157,14 +1167,16 @@ async def test_checkpoints_survive_reload(isolated_checkpointer): }, }, ) - merge_team_checkpoints(session, team_name, {"code-ready": 5}) + merge_team_checkpoints(session, team_name, {"code-ready": {"count": 5}}) await session.flush_checkpoint() restored = create_agent_team_session(session_id=session_id, team_id=team_name) await restored.pre_run() agent = TeamAgent.recover_from_session(restored, team_name) - assert agent._named_checkpoints == {"code-ready": 5} + assert agent._named_checkpoints == { + "code-ready": {"count": 5, "description": "", "created_by": ""} + } await isolated_checkpointer.release(session_id) @@ -1190,11 +1202,13 @@ def test_persist_leader_config_preserves_checkpoints(): recovery = RecoveryManager(configurator=configurator, spawn_manager=MagicMock()) - merge_team_checkpoints(session, team_name, {"code-ready": 5}) + merge_team_checkpoints(session, team_name, {"code-ready": {"count": 5}}) recovery.persist_leader_config(session) bucket = read_team_namespace(session, team_name) - assert bucket["checkpoints"] == {"code-ready": 5} + assert bucket["checkpoints"] == { + "code-ready": {"count": 5, "description": "", "created_by": ""} + } assert bucket["spec"] == {"team_name": team_name} diff --git a/tests/unit_tests/agent_teams/test_team_tools.py b/tests/unit_tests/agent_teams/test_team_tools.py index a2770c644..cddf31afb 100644 --- a/tests/unit_tests/agent_teams/test_team_tools.py +++ b/tests/unit_tests/agent_teams/test_team_tools.py @@ -26,6 +26,7 @@ TeamDatabase, ) from openjiuwen.agent_teams.tools.locales import Translator, make_translator +from openjiuwen.agent_teams.tools.tool_member import ListCheckpointsTool from openjiuwen.agent_teams.schema.team import ExternalCliAgentSpec, TeamRole from openjiuwen.agent_teams.tools.team import TeamBackend from openjiuwen.agent_teams.tools.team_tools import ( @@ -706,6 +707,80 @@ async def test_invoke_projects_roster_columns_only(self, agent_team, t, sample_a assert set(member) == {"member_name", "display_name", "status"} +# ========== Checkpoint listing ========== + + +class TestListCheckpointsTool: + """Test ListCheckpointsTool""" + + @pytest.mark.level0 + def test_initialization(self, agent_team, t): + """Test tool initialization""" + tool = ListCheckpointsTool(agent_team, t) + assert tool.card.name == "list_checkpoints" + assert tool.card.id == "team.list_checkpoints" + assert tool.team == agent_team + + @pytest.mark.asyncio + @pytest.mark.level0 + async def test_invoke_empty(self, agent_team, t): + """Test invoking list checkpoints when empty""" + tool = ListCheckpointsTool(agent_team, t) + result = await tool.invoke({}) + + assert result.success is True + assert result.data["count"] == 0 + assert result.data["checkpoints"] == [] + + @pytest.mark.asyncio + @pytest.mark.level0 + async def test_invoke_with_checkpoints(self, agent_team, t): + """Records surface name / message_count / description / created_by.""" + agent_team.set_checkpoint_list_fn( + lambda: { + "code-ready": {"count": 5, "description": "base done", "created_by": "dev-1"}, + "refactor-done": {"count": 12, "description": "", "created_by": "dev-2"}, + } + ) + tool = ListCheckpointsTool(agent_team, t) + result = await tool.invoke({}) + + assert result.success is True + assert result.data["count"] == 2 + items = result.data["checkpoints"] + by_name = {item["name"]: item for item in items} + assert by_name["code-ready"] == { + "name": "code-ready", + "message_count": 5, + "description": "base done", + "created_by": "dev-1", + } + assert by_name["refactor-done"]["message_count"] == 12 + + @pytest.mark.level1 + def test_map_result_renders_rows(self, agent_team, t): + tool = ListCheckpointsTool(agent_team, t) + out = ToolOutput( + success=True, + data={ + "checkpoints": [ + {"name": "code-ready", "message_count": 5, "description": "base done", "created_by": "dev-1"}, + {"name": "refactor-done", "message_count": 12, "description": "", "created_by": "dev-2"}, + ], + "count": 2, + }, + ) + text = tool.map_result(out) + assert "code-ready" in text and "message_count=5" in text and "base done" in text + assert "refactor-done" in text and "message_count=12" in text + + @pytest.mark.level1 + def test_map_result_empty(self, agent_team, t): + tool = ListCheckpointsTool(agent_team, t) + text = tool.map_result(ToolOutput(success=True, data={"checkpoints": [], "count": 0})) + assert text == "No checkpoints" + + # ========== Task Management Tools (V2) ========== From e2197f2094a943ad773f0aed6957e6aadb1c5d53 Mon Sep 17 00:00:00 2001 From: sodawlh <3240101939@zju.edu.cn> Date: Tue, 11 Aug 2026 15:00:24 +0800 Subject: [PATCH 3/3] docs(swarm): sync S_04/S_12 checkpoints record format --- .../docs/specs/S_04_session-and-recovery.md | 12 ++++++------ .../docs/specs/S_12_schema-data-models.md | 4 ++-- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/openjiuwen/agent_teams/docs/specs/S_04_session-and-recovery.md b/openjiuwen/agent_teams/docs/specs/S_04_session-and-recovery.md index 9722973a5..a8d3b5368 100644 --- a/openjiuwen/agent_teams/docs/specs/S_04_session-and-recovery.md +++ b/openjiuwen/agent_teams/docs/specs/S_04_session-and-recovery.md @@ -23,7 +23,7 @@ present it writes `pending_create`. `TeamAgent._mark_team_built` and |---|---| | 类型 | spec | | 关联模块 | `openjiuwen/agent_teams/agent/session_manager.py`、`openjiuwen/agent_teams/agent/recovery_manager.py`、`openjiuwen/agent_teams/runtime/metadata.py`、`openjiuwen/agent_teams/context.py` | -| 最近一次修订日期 | 2026-05-12 | +| 最近一次修订日期 | 2026-08-11 | | 关联 feature | `F_01_coordination-protocol-cleanup.md`、`F_05_lifecycle-finalize-relocation.md` | ## 范围 / 边界 @@ -125,7 +125,7 @@ state["teams"][team_name] = { "model_allocator_state": Optional[dict], "lifecycle": Optional["running" | "paused"], "db_state": Optional["pending_create" | "created" | "cleaned"], - "checkpoints": Optional[dict[str, int]], # {name: message_count} 命名 fork 快照 + "checkpoints": Optional[dict[str, dict]], # {name: {count, description, created_by}} 命名 fork 快照 } ``` @@ -315,11 +315,11 @@ def read_teams_bucket(session) -> dict[str, dict[str, Any]]: ... def read_team_namespace(session, team_name: str) -> dict[str, Any] | None: ... def read_team_names_in_session(session) -> list[str]: ... def read_team_db_state(session, team_name: str) -> str | None: ... -def read_team_checkpoints(session, team_name: str) -> dict[str, int] | None: ... +def read_team_checkpoints(session, team_name: str) -> dict[str, dict] | None: ... def write_team_namespace(session, team_name: str, payload: dict[str, Any]) -> None: ... def merge_team_namespace(session, team_name: str, partial: dict[str, Any]) -> None: ... def merge_team_db_state(session, team_name: str, state: str) -> None: ... -def merge_team_checkpoints(session, team_name: str, mapping: dict[str, int]) -> None: ... +def merge_team_checkpoints(session, team_name: str, mapping: dict[str, dict]) -> None: ... def remove_team_namespace(session, team_name: str) -> bool: ... ``` @@ -438,7 +438,7 @@ session.state │ ├── model_allocator_state Optional[dict] ← persist_allocator_state │ ├── lifecycle Optional["running" | "paused"] │ │ ← coordination 写入 - │ └── checkpoints Optional[dict[str, int]] ← 命名 fork 快照 + │ └── checkpoints Optional[dict[str, dict]] ← 命名 fork 快照({count, description, created_by}) └── └── ... ``` @@ -452,7 +452,7 @@ session.state | `model_allocator_state`(初始)| 同上 | 同上 | | `model_allocator_state`(增量)| `recovery_manager.persist_allocator_state` → `merge_team_namespace` | round 中模型分配变更后 | | `lifecycle` | `coordination/kernel._persist_team_lifecycle` → `merge_team_namespace` | pause / resume 切换时 | -| `checkpoints` | `TeamAgent._merge_checkpoints_into_session` → `merge_team_namespace` | `checkpoint()` 工具调用时(leader 镜像) | +| `checkpoints` | `TeamAgent._merge_checkpoints_into_session` → `merge_team_namespace` | `checkpoint()` 工具调用时(leader 镜像);记录含 `{count, description, created_by}` | 新增字段时,按"是否在 bind 时一次性给定"决定走 `write_team_namespace`(覆盖) 还是 `merge_team_namespace`(增量)。**新增字段名进 namespace 之前**,先在 diff --git a/openjiuwen/agent_teams/docs/specs/S_12_schema-data-models.md b/openjiuwen/agent_teams/docs/specs/S_12_schema-data-models.md index d1e95f26d..94334ddd3 100644 --- a/openjiuwen/agent_teams/docs/specs/S_12_schema-data-models.md +++ b/openjiuwen/agent_teams/docs/specs/S_12_schema-data-models.md @@ -14,7 +14,7 @@ does not. |---|---| | 类型 | spec | | 关联模块 | `openjiuwen/agent_teams/schema/blueprint.py`、`openjiuwen/agent_teams/schema/deep_agent_spec.py`、`openjiuwen/agent_teams/schema/team.py`、`openjiuwen/agent_teams/schema/events.py`、`openjiuwen/agent_teams/schema/status.py`、`openjiuwen/agent_teams/schema/stream.py`、`openjiuwen/agent_teams/schema/task.py` | -| 最近一次修订日期 | 2026-07-28 | +| 最近一次修订日期 | 2026-08-11 | | 关联 feature | `F_05_lifecycle-finalize-relocation.md`(`MemberStatus.STOPPED` 新增)、`F_24_agent-time-awareness.md`(`TaskSummary.updated_at` 新增)、`F_38_team-teammate-worktree-isolation-agenttool.md`(`TeamRuntimeContext.worktree_path`)、`F_59_condition-named-task-state-machine-with-verify-gate.md`(条件命名 `TaskStatus` 状态机 + verify 闸)、`F_62_scheduled-dispatch-runtime-and-review-voting.md`(票表 + 轮数列 + `TASK_REVIEW_VOTE` + dispatch 能力上限)、`F_63_scheduler-message-templating-and-delivery-render.md`(消息表 `meta` 投递载荷列)、`F_65_runtime-idle-clock-stall-nudge.md`(`TeamAgentState.idle_since` 运行时 idle 时钟 + 两个停滞阈值 spec 字段)、`F_69_cwd-workspace-project-root-separation.md`(`DeepAgentSpec.cwd` / `project_root` 与 workspace 分离)。其余条目见 `docs/features/` | ## 范围 / 边界 @@ -824,7 +824,7 @@ state["teams"][team_name] = { "context": ..., # TeamRuntimeContext.model_dump() "model_allocator_state": ... # allocator 的 round-robin 游标 / 已分配 model_id 等 "lifecycle": ..., # TeamLifecycle 字符串 - "checkpoints": ..., # {name: message_count} 命名 fork 快照 + "checkpoints": ..., # {name: {count, description, created_by}} 命名 fork 快照 } ```