fix(missions): bind graph nodes to claws via attrs, not a dropped top-level key

`inject_node_agents` wrote the claw alias as a top-level `"agent"` key on
each graph node, but `cm_topology::Node` only deserializes `{id, role,
level, attrs}` — serde silently dropped it. `TurnRequest::agent` came back
`None` and every mission turn fell back to `ZEROCLAW_DEFAULT_AGENT`
(`scout`), running with scout's workspace and tools instead of the
mission's claws. The runtime trace confirms it: every turn logged
`"agent_alias":"scout"`.

That is why mission agents reported an "empty greenfield" workspace and
emitted artifacts inline instead of writing them: scout is jailed to
`/zeroclaw-data/.zeroclaw/agents/scout/workspace` and cannot see
`/mission/repo`. The per-mission provisioning and `workspace.path` pinning
shipped earlier were correct — they were just applied to agents that
nothing ever drove.

- bind into `node.attrs["agent"]` (top-level key kept for display/debug)
- extract the DB-free `apply_node_agents` and add a regression test that
  round-trips through the real `TopologyGraph` deserializer, which is the
  guard that was missing
- log loudly in `topology_exec::run_turn` when a node falls back to the
  default agent, instead of silently swapping in a different agent

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
Omar Sobh
2026-07-28 14:02:08 +02:00
co-authored by Claude Opus 5
parent 0a647c0bfa
commit 9bc5f6a142
2 changed files with 114 additions and 9 deletions
+13 -1
View File
@@ -354,7 +354,19 @@ impl TurnExecutor for ZeroClawDriveExecutor {
.map(str::trim)
.filter(|a| !a.is_empty())
.map(str::to_string)
.unwrap_or_else(|| self.alias_for(&req.role));
.unwrap_or_else(|| {
// Falling back here means the graph node was never bound to a
// claw, so the turn runs as the default agent with the DEFAULT
// agent's workspace and tools — not the mission's. That silently
// produced whole missions of unusable output, so say so loudly.
let fallback = self.alias_for(&req.role);
eprintln!(
"topology_exec: node={} role={} has no bound agent — falling back to `{fallback}` \
(its workspace/tools, NOT the mission's)",
req.node_id, req.role,
);
fallback
});
let prompt = Self::build_prompt(&req);
self.drive(&alias, &prompt).await
}