Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 51 additions & 9 deletions openjiuwen/agent_teams/agent/team_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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()

Expand All @@ -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

Expand Down
12 changes: 6 additions & 6 deletions openjiuwen/agent_teams/docs/specs/S_04_session-and-recovery.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |

## 范围 / 边界
Expand Down Expand Up @@ -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 快照
}
```

Expand Down Expand Up @@ -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: ...
```

Expand Down Expand Up @@ -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})
└── <team_name_B>
└── ...
```
Expand All @@ -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 之前**,先在
Expand Down
4 changes: 2 additions & 2 deletions openjiuwen/agent_teams/docs/specs/S_12_schema-data-models.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/` |

## 范围 / 边界
Expand Down Expand Up @@ -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 快照
}
```

Expand Down
11 changes: 11 additions & 0 deletions openjiuwen/agent_teams/i18n.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}。请停止重复无效操作,改换策略或换用其他工具。"
Expand Down Expand Up @@ -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; "
Expand Down
31 changes: 21 additions & 10 deletions openjiuwen/agent_teams/runtime/metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -126,25 +126,36 @@ 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:
return 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)})

Expand Down
2 changes: 1 addition & 1 deletion openjiuwen/agent_teams/spawn/inprocess_spawn.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions openjiuwen/agent_teams/tools/locales/cn.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 ======================================================
Expand Down
4 changes: 4 additions & 0 deletions openjiuwen/agent_teams/tools/locales/descs/cn/checkpoint.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 猜你起的名字。
14 changes: 14 additions & 0 deletions openjiuwen/agent_teams/tools/locales/descs/cn/list_checkpoints.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
列出当前所有可供 fork 继承的命名 checkpoint,包含每个快照的名字、消息数、创建者与描述。

| 字段 | 含义 |
|---|---|
| **name** | 传给 `spawn_teammate(fork="<name>")` 的**确切名字** |
| **message_count** | 打快照时的上下文长度 |
| **created_by** | 创建该快照的成员 |
| **description** | 打快照时记录的可选说明 |

## 何时调用

**fork 之前必须调用**——不能凭猜测填 checkpoint 名字。成员创建快照时名字是任意的(见 `checkpoint` 工具),权威清单只在这里。填了不存在的名字,fork 会静默回退为全量继承,你将得不到任何继承的理解。

把返回的**确切名字**用于 `spawn_teammate(fork="<name>", fork_source="<created_by>", ...)`。
Original file line number Diff line number Diff line change
Expand Up @@ -82,4 +82,5 @@

- 继承的消息为源成员的完整对话历史(包含文件读取结果、搜索输出、分析结论)
- fork 前先让源成员调 `checkpoint(name="xxx")` 打快照,再用 `fork="xxx"` 指定
- **fork 前先调 `list_checkpoints` 拿到确切名字**——不要猜 checkpoint 名。填了不存在的名字会静默回退为全量继承
- `fork=true` 继承调用时刻的全部上下文,包含后续调度噪音时建议改用 checkpoint 模式
4 changes: 4 additions & 0 deletions openjiuwen/agent_teams/tools/locales/descs/en/checkpoint.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
14 changes: 14 additions & 0 deletions openjiuwen/agent_teams/tools/locales/descs/en/list_checkpoints.md
Original file line number Diff line number Diff line change
@@ -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="<name>")` |
| **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="<name>", fork_source="<created_by>", ...)`.
Original file line number Diff line number Diff line change
Expand Up @@ -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
3 changes: 3 additions & 0 deletions openjiuwen/agent_teams/tools/locales/en.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 ======================================================
Expand Down
Loading