Every zeroclaw mission minted a fresh team of claws. They are created
`lifecycle = 'permanent'` and nothing reaps them until the MISSION is deleted,
so the roster grew by a whole team per mission while each member worked exactly
once — "My Workforce" was a list of strangers, and upskilling had nothing
durable to act on.
A mission now hires the claw that already does the job, matched on
`agent_template_link (template_id, role_slot)`, minting only what is missing.
Oldest first, so reuse concentrates on the same few claws and their brains
actually accumulate rather than spreading thinly across a growing pool.
A claw on a RUNNING mission is not offered. Two missions driving the same
ZeroClaw agent and the same `.brain` at once is a data race with a model on the
other end of it, and minting a second claw is much cheaper than reasoning about
that.
A reused claw is NOT re-seeded from the template's brain_seed — that would
overwrite what it learned with its starting point, which is precisely the
accumulation this exists for.
Names are the role now (`planner`), not
`"{mission} · {purpose} · {template} · {slot}"`. That produced
"verify: a repo-less research mission keeps its output · mission · Rust SDLC ·
planner" — unreadable in the roster, the API and every log line at once. Which
mission a claw is on is context a caller can join to; it is not its name.
And the half that makes reuse safe rather than destructive: deleting a mission
now purges only claws no OTHER mission still employs. Without it, tidying up one
mission deletes staff another one holds — presenting as the roster quietly
shrinking rather than as an error. A test asserts the guard exists inside the
reaper AND runs before the purge, because a check after it is decoration.
Co-Authored-By: Claude Opus 5 <[email protected]>
164 lines
5.5 KiB
Rust
164 lines
5.5 KiB
Rust
//! Agent → team-template lineage. Slice 3.5d of the missions
|
|
//! consolidation. Populated by the team materialization path when a
|
|
//! new agent is created from a template role; read by:
|
|
//! - the `clawmates_skills` MCP server to resolve the caller's
|
|
//! template + role_slot so it can merge template default skills
|
|
//! with agent overrides
|
|
//! - level-up (Slice 8.5) to diff learned-vs-seeded knowledge and
|
|
//! route "promote to template" proposals
|
|
|
|
use serde::{Deserialize, Serialize};
|
|
use sqlx::PgPool;
|
|
use sqlx::Row;
|
|
use time::OffsetDateTime;
|
|
use uuid::Uuid;
|
|
|
|
use crate::DbError;
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct AgentTemplateLink {
|
|
pub agent_id: Uuid,
|
|
pub template_id: Uuid,
|
|
pub template_version: i32,
|
|
pub role_slot: String,
|
|
#[serde(with = "time::serde::rfc3339::option")]
|
|
pub seeded_at: Option<OffsetDateTime>,
|
|
#[serde(with = "time::serde::rfc3339")]
|
|
pub created_at: OffsetDateTime,
|
|
}
|
|
|
|
/// Idempotent link insert. If the agent already has a link (rare —
|
|
/// only via manual overrides) we UPDATE the version/role rather than
|
|
/// error, since the intended template may have shifted.
|
|
pub async fn upsert(
|
|
pool: &PgPool,
|
|
agent_id: Uuid,
|
|
template_id: Uuid,
|
|
template_version: i32,
|
|
role_slot: &str,
|
|
) -> Result<(), DbError> {
|
|
sqlx::query(
|
|
"INSERT INTO agent_template_link
|
|
(agent_id, template_id, template_version, role_slot)
|
|
VALUES ($1,$2,$3,$4)
|
|
ON CONFLICT (agent_id) DO UPDATE SET
|
|
template_id = EXCLUDED.template_id,
|
|
template_version = EXCLUDED.template_version,
|
|
role_slot = EXCLUDED.role_slot",
|
|
)
|
|
.bind(agent_id)
|
|
.bind(template_id)
|
|
.bind(template_version)
|
|
.bind(role_slot)
|
|
.execute(pool)
|
|
.await?;
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn mark_seeded(pool: &PgPool, agent_id: Uuid) -> Result<(), DbError> {
|
|
sqlx::query(
|
|
"UPDATE agent_template_link SET seeded_at = now()
|
|
WHERE agent_id = $1 AND seeded_at IS NULL",
|
|
)
|
|
.bind(agent_id)
|
|
.execute(pool)
|
|
.await?;
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn get(pool: &PgPool, agent_id: Uuid) -> Result<Option<AgentTemplateLink>, DbError> {
|
|
let row = sqlx::query(
|
|
"SELECT agent_id, template_id, template_version, role_slot,
|
|
seeded_at, created_at
|
|
FROM agent_template_link WHERE agent_id = $1",
|
|
)
|
|
.bind(agent_id)
|
|
.fetch_optional(pool)
|
|
.await?;
|
|
Ok(row.map(|r| AgentTemplateLink {
|
|
agent_id: r.get("agent_id"),
|
|
template_id: r.get("template_id"),
|
|
template_version: r.get("template_version"),
|
|
role_slot: r.get("role_slot"),
|
|
seeded_at: r.get("seeded_at"),
|
|
created_at: r.get("created_at"),
|
|
}))
|
|
}
|
|
|
|
/// All agents provisioned from a given template — used by level-up
|
|
/// when a template gains a new version and we want to prompt each
|
|
/// downstream agent for upgrade.
|
|
pub async fn agents_for_template(
|
|
pool: &PgPool,
|
|
template_id: Uuid,
|
|
) -> Result<Vec<AgentTemplateLink>, DbError> {
|
|
let rows = sqlx::query(
|
|
"SELECT agent_id, template_id, template_version, role_slot,
|
|
seeded_at, created_at
|
|
FROM agent_template_link WHERE template_id = $1
|
|
ORDER BY created_at",
|
|
)
|
|
.bind(template_id)
|
|
.fetch_all(pool)
|
|
.await?;
|
|
Ok(rows
|
|
.into_iter()
|
|
.map(|r| AgentTemplateLink {
|
|
agent_id: r.get("agent_id"),
|
|
template_id: r.get("template_id"),
|
|
template_version: r.get("template_version"),
|
|
role_slot: r.get("role_slot"),
|
|
seeded_at: r.get("seeded_at"),
|
|
created_at: r.get("created_at"),
|
|
})
|
|
.collect())
|
|
}
|
|
|
|
/// A claw already in this workspace that can take this template role again.
|
|
///
|
|
/// The workforce is meant to be KEPT: a mission that needs a `coder` should
|
|
/// hire the one that already exists rather than mint a sixth. Without this,
|
|
/// every zeroclaw mission added a whole team to the roster permanently — they
|
|
/// are minted `lifecycle = 'permanent'` and nothing reaps them until the
|
|
/// mission itself is deleted — while each member was used exactly once.
|
|
///
|
|
/// A claw currently on a RUNNING mission is not offered. Two missions driving
|
|
/// the same ZeroClaw agent and the same `.brain` at once is a data race with a
|
|
/// model on the other end of it; minting a second claw is much cheaper than
|
|
/// reasoning about that.
|
|
///
|
|
/// Oldest first, so reuse concentrates on the same few claws and their brains
|
|
/// actually accumulate, instead of spreading thinly across a growing pool.
|
|
pub async fn reusable_claw(
|
|
pool: &PgPool,
|
|
workspace_id: uuid::Uuid,
|
|
template_id: uuid::Uuid,
|
|
role_slot: &str,
|
|
) -> Result<Option<uuid::Uuid>, DbError> {
|
|
let row: Option<(uuid::Uuid,)> = sqlx::query_as(
|
|
"SELECT a.id
|
|
FROM agents a
|
|
JOIN agent_template_link l ON l.agent_id = a.id
|
|
WHERE a.workspace_id = $1
|
|
AND a.deleted_at IS NULL
|
|
AND l.template_id = $2
|
|
AND l.role_slot = $3
|
|
AND NOT EXISTS (
|
|
SELECT 1
|
|
FROM team_members tm
|
|
JOIN mission_teams mt ON mt.team_id = tm.team_id
|
|
JOIN missions m ON m.id = mt.mission_id
|
|
WHERE tm.claw_id = a.id
|
|
AND m.status = 'running'
|
|
)
|
|
ORDER BY a.created_at
|
|
LIMIT 1",
|
|
)
|
|
.bind(workspace_id)
|
|
.bind(template_id)
|
|
.bind(role_slot)
|
|
.fetch_optional(pool)
|
|
.await?;
|
|
Ok(row.map(|(id,)| id))
|
|
}
|